Perché è necessaria una classe Builder quando si implementa un modello Builder?


31

Ho visto molte implementazioni del modello Builder (principalmente in Java). Tutti hanno una classe di entità (diciamo una Personclasse) e una classe builder PersonBuilder. Il builder "impila" una varietà di campi e restituisce a new Personcon gli argomenti passati. Perché abbiamo esplicitamente bisogno di una classe builder, invece di mettere tutti i metodi builder nella Personclasse stessa?

Per esempio:

class Person {

  private String name;
  private Integer age;

  public Person() {
  }

  Person withName(String name) {
    this.name = name;
    return this;
  }

  Person withAge(int age) {
    this.age = age;
    return this;
  }
}

Posso semplicemente dire Person john = new Person().withName("John");

Perché la necessità di una PersonBuilderlezione?

L'unico vantaggio che vedo è che possiamo dichiarare i Personcampi come final, garantendo così l'immutabilità.


4
Nota: il nome normale per questo modello è chainable setters: D
Mooing Duck il

4
Principio della sola responsabilità. Se metti la logica nella classe Person, allora ha più di un lavoro da fare
reggaeguitar

1
Il tuo vantaggio può essere ottenuto in entrambi i modi: posso avere withNameuna copia della Persona con solo il campo del nome modificato. In altre parole, Person john = new Person().withName("John");potrebbe funzionare anche se Personè immutabile (e questo è un modello comune nella programmazione funzionale).
Brian McCutchon,

9
@MooingDuck: un altro termine comune per indicare un'interfaccia fluida .
Mac,

2
@Mac Penso che l'interfaccia fluida sia un po 'più generale - eviti di avere voidmetodi. Quindi, ad esempio se Personha un metodo che stampa il suo nome, puoi comunque concatenarlo con un'interfaccia fluidaperson.setName("Alice").sayName().setName("Bob").sayName() . A proposito, annoto quelli in JavaDoc con esattamente il tuo suggerimento @return Fluent interface: è generico e abbastanza chiaro quando si applica a qualsiasi metodo che fa return thisalla fine della sua esecuzione ed è abbastanza chiaro. Quindi, un Builder farà anche un'interfaccia fluida.
VLAZ,

Risposte:


27

È così che puoi essere immutabile E simulare parametri nominati allo stesso tempo.

Person p = personBuilder
    .name("Arthur Dent")
    .age(42)
    .build()
;

Ciò mantiene i tuoi guanti lontani dalla persona finché non viene impostato il suo stato e, una volta impostato, non ti permetterà di cambiarlo, eppure ogni campo è chiaramente etichettato. Non puoi farlo con una sola classe in Java.

Sembra che tu stia parlando di Josh Blochs Builder Pattern . Questo non deve essere confuso con il modello Gang of Four Builder . Queste sono bestie diverse. Entrambi risolvono i problemi di costruzione, ma in modi abbastanza diversi.

Ovviamente puoi costruire il tuo oggetto senza usare un'altra classe. Ma allora devi scegliere. Si perde la capacità di simulare parametri denominati in linguaggi che non li hanno (come Java) o si perde la capacità di rimanere immutabili per tutta la durata degli oggetti.

Esempio immutabile, non ha nomi per i parametri

Person p = new Person("Arthur Dent", 42);

Qui stai costruendo tutto con un unico semplice costruttore. Ciò ti consentirà di rimanere immutabile ma perderai la simulazione dei parametri nominati. È difficile da leggere con molti parametri. Ai computer non importa ma è difficile per gli umani.

Esempio di parametro denominato simulato con setter tradizionali. Non immutabile.

Person p = new Person();
p.name("Arthur Dent");
p.age(42);

Qui stai costruendo tutto con setter e simuli parametri nominati ma non sei più immutabile. Ogni utilizzo di un setter cambia lo stato dell'oggetto.

Quindi, ciò che ottieni aggiungendo la classe è che puoi fare entrambe le cose.

La convalida può essere eseguita nel build()caso in cui un errore di runtime per un campo di età mancante sia sufficiente per te. Puoi aggiornarlo e applicarloage() viene chiamato con un errore del compilatore. Solo non con il modello del costruttore Josh Bloch.

Per questo è necessario un linguaggio specifico del dominio interno (iDSL).

Ciò consente di richiedere che chiamino age()e name()prima di chiamare build(). Ma non puoi farlo solo tornandothis ogni volta. Ogni cosa che ritorna restituisce una cosa diversa che ti costringe a chiamare la cosa successiva.

L'utilizzo potrebbe essere simile al seguente:

Person p = personBuilder
    .name("Arthur Dent")
    .age(42)
    .build()
;

Ma questo:

Person p = personBuilder
    .age(42)
    .build()
;

causa un errore del compilatore perché age()è valido solo per il tipo restituito daname() .

Questi iDSL sono estremamente potenti ( ad esempio JOOQ o Java8 Streams ) e sono molto belli da usare, specialmente se si utilizza un IDE con completamento del codice, ma sono un bel po 'di lavoro da configurare. Consiglierei di salvarli per cose che avranno un bel po 'di codice sorgente scritto contro di loro.


3
E poi qualcuno chiede perché si deve fornire il nome prima che l'età, e l'unica risposta è "perché esplosione combinatoria". Sono abbastanza sicuro che uno può evitarlo alla fonte se si hanno modelli propri come C ++, o si continua a usare un tipo diverso per tutto.
Deduplicatore,

1
Un'astrazione che perde richiede la conoscenza di una complessità sottostante per essere in grado di sapere come usarlo. Questo è quello che vedo qui. Ogni classe che non sa come costruirsi è necessariamente accoppiata al Costruttore che ha ulteriori dipendenze (tale è lo scopo e la natura del concetto di Costruttore). Una volta (non al campo di banda) una semplice necessità di creare un'istanza di un oggetto ha richiesto 3 mesi per annullare proprio un tale ammasso di cluster. Non ti prendo in giro.
radarbob,

Uno dei vantaggi delle classi che non conoscono le loro regole di costruzione è che quando queste regole cambiano non se ne curano. Posso solo aggiungere un AnonymousPersonBuilderche ha il proprio set di regole.
candied_orange,

La progettazione di classi / sistemi raramente mostra un'applicazione profonda di "massimizzare la coesione e minimizzare l'accoppiamento". Legioni di design e runtime sono estensibili e i difetti risolvibili solo se le massime OO e le linee guida sono applicate con la sensazione di essere frattali o "sono le tartarughe, fino in fondo".
radarbob,

1
@radarbob La classe che si sta costruendo sa ancora come costruirsi. Il suo costruttore è responsabile di garantire l'integrità dell'oggetto. La classe builder è semplicemente un aiuto per il costruttore, perché il costruttore prende spesso un gran numero di parametri, il che non costituisce un'API molto bella.
ReinstateMonicaSackTheStaff il

57

Perché usare / fornire una classe builder:

  • Per creare oggetti immutabili, il vantaggio che hai già identificato. Utile se la costruzione richiede più passaggi. FWIW, l'immutabilità dovrebbe essere vista come uno strumento significativo nella nostra ricerca per scrivere programmi mantenibili e senza errori.
  • Se la rappresentazione runtime dell'oggetto finale (possibilmente immutabile) è ottimizzata per la lettura e / o l'utilizzo dello spazio, ma non per l'aggiornamento. String e StringBuilder sono buoni esempi qui. La concatenazione ripetuta di stringhe non è molto efficiente, quindi StringBuilder utilizza una rappresentazione interna diversa che è utile per l'aggiunta, ma non altrettanto valida per l'utilizzo dello spazio e non altrettanto valida per la lettura e l'utilizzo come la normale classe String.
  • Separare chiaramente gli oggetti costruiti dagli oggetti in costruzione. Questo approccio richiede una chiara transizione dalla sotto costruzione alla costruzione. Per il consumatore, non c'è modo di confondere un oggetto in costruzione con un oggetto costruito: il sistema di tipi lo imporrà. Ciò significa che a volte possiamo usare questo approccio per "cadere nella fossa del successo", per così dire, e, quando facciamo astrazioni per altri (o noi stessi) da usare (come un'API o un livello), questo può essere un ottimo cosa.

4
Per quanto riguarda l'ultimo punto elenco. Quindi l'idea è che a PersonBuildernon abbia getter e che l'unico modo per ispezionare i valori correnti sia chiamare .Build()per restituire a Person. In questo modo .Build può validare un oggetto correttamente costruito, giusto? Vale a dire questo meccanismo inteso a prevenire l'uso di un oggetto "in costruzione"?
AaronLS

@AaronLS, sì, certamente; una complessa convalida può essere messa in Buildun'operazione per prevenire oggetti danneggiati e / o poco costruiti.
Erik Eidt,

2
Potresti anche validare il tuo oggetto all'interno del suo costruttore, la preferenza può essere personale o dipende dalla complessità. Qualunque sia la scelta, il punto principale è che una volta che hai il tuo oggetto immutabile, sai che è correttamente costruito e non ha valore inaspettato. Un oggetto immutabile con uno stato errato non dovrebbe accadere.
Walfrat,

2
@AaronLS un builder può avere getter; non è questo il punto. Non è necessario, ma se un builder ha getter, devi essere consapevole che la chiamata getAgeal builder potrebbe restituire nullquando questa proprietà non è stata ancora specificata. Al contrario, la Personclasse può far rispettare l'invariante che l'età non può mai essere null, il che è impossibile quando il costruttore e l'oggetto reale sono mescolati, come new Person() .withName("John")nell'esempio del PO , che crea felicemente un Personsenza età. Questo vale anche per le classi mutabili, poiché i setter possono imporre invarianti, ma non possono imporre valori iniziali.
Holger,

1
@Holger i tuoi punti sono veri, ma supportano principalmente l'argomento secondo cui un Builder dovrebbe evitare i vincitori.
user949300,

21

Una ragione sarebbe quella di garantire che tutti i dati trasferiti seguano le regole aziendali.

Il tuo esempio non ne tiene conto, ma diciamo che qualcuno ha passato una stringa vuota o una stringa composta da caratteri speciali. Si vorrebbe fare una sorta di logica basata sull'assicurarsi che il loro nome sia effettivamente un nome valido (che in realtà è un compito molto difficile).

Potresti metterlo tutto nella tua classe Person, specialmente se la logica è molto piccola (ad esempio, assicurandoti che un'età non sia negativa) ma man mano che la logica cresce ha senso separarla.


7
L'esempio nella domanda fornisce una semplice violazione delle regole: non esiste un'età indicatajohn
Caleth,

6
+1 Ed è molto difficile fare regole per due campi contemporaneamente senza la classe builder (i campi X + Y non possono essere maggiori di 10).
Reginald Blue,

7
@ReginaldBlue è ancora più difficile se sono legati da "x + y is even". La nostra condizione iniziale con (0,0) è OK, anche lo stato (1,1) è OK, ma non esiste una sequenza di aggiornamenti a singola variabile che mantenga lo stato valido e ci porti da (0,0) a (1 , 1).
Michael Anderson,

Credo che questo sia il più grande vantaggio. Tutti gli altri sono simpatici.
Giacomo Alzetta,

5

Un angolo leggermente diverso rispetto a quello che vedo nelle altre risposte.

L' withFooapproccio qui è problematico perché si comportano come setter ma sono definiti in modo tale da far sembrare che la classe supporti l'immutabilità. Nelle classi Java, se un metodo modifica una proprietà, è consuetudine avviare il metodo con 'set'. Non l'ho mai amato come standard, ma se fai qualcos'altro sorprenderà le persone e non va bene. C'è un altro modo in cui puoi supportare l'immutabilità con l'API di base che hai qui. Per esempio:

class Person {
  private final String name;
  private final Integer age;

  private Person(String name, String age) {
    this.name = name;
    this.age = age;
  }  

  public Person() {
    this.name = null;
    this.age = null;
  }

  Person withName(String name) {
    return new Person(name, this.age);
  }

  Person withAge(int age) {
    return new Person(this.name, age);
  }
}

Non fornisce molto in termini di prevenzione di oggetti parzialmente costruiti in modo improprio, ma impedisce modifiche a oggetti esistenti. Probabilmente è sciocco per questo tipo di cose (e così è il JB Builder). Sì, creerai più oggetti ma non è così costoso come potresti pensare.

Vedrai principalmente questo tipo di approccio utilizzato con strutture di dati simultanee come CopyOnWriteArrayList . E questo suggerisce perché l'immutabilità è importante. Se vuoi rendere sicuro il tuo codice thread, l'immutabilità dovrebbe quasi sempre essere considerata. In Java, ogni thread è autorizzato a mantenere una cache locale di stato variabile. Affinché un thread possa vedere le modifiche apportate in altri thread, è necessario utilizzare un blocco sincronizzato o un'altra funzione di concorrenza. Ognuno di questi aggiungerà un certo sovraccarico al codice. Ma se le tue variabili sono definitive, non c'è niente da fare. Il valore sarà sempre quello a cui è stato inizializzato e quindi tutti i thread vedono la stessa cosa, non importa quale.


2

Un altro motivo che non è già stato esplicitamente menzionato qui, è che il build()metodo può verificare che tutti i campi siano "campi contenenti valori validi (impostati direttamente o derivati ​​da altri valori di altri campi), che è probabilmente la modalità di errore più probabile che altrimenti si verificherebbe.

Un altro vantaggio è che il tuo Personoggetto finirebbe per avere una vita più semplice e un semplice insieme di invarianti. Sai che una volta che hai un Person p, hai un p.namee un valido p.age. Nessuno dei tuoi metodi deve essere progettato per gestire situazioni come "bene cosa succede se l'età è impostata ma non il nome, e cosa succede se il nome è impostato ma non l'età?" Ciò riduce la complessità generale della classe.


"[...] può verificare che tutti i campi siano impostati [...]" Il punto del modello Builder è che non devi impostare tutti i campi da solo e puoi ricadere su valori predefiniti sensibili. Se devi comunque impostare tutti i campi, forse non dovresti usare quel modello!
Vincent Savard,

@VincentSavard In effetti, ma a mio avviso, è l'uso più comune. Per convalidare che siano impostati alcuni set di valori "core" e fare un trattamento elaborato attorno ad alcuni facoltativi (impostazione dei valori predefiniti, derivazione del loro valore da altri valori, ecc.)
Alexander - Ripristina Monica il

Invece di dire "verifica che tutti i campi siano impostati", lo cambierei in "verifica che tutti i campi contengano valori validi [a volte dipendenti dai valori di altri campi]" (vale a dire che un campo può consentire solo determinati valori a seconda del valore di un altro campo). Questo potrebbe essere fatto in ogni setter, ma è più facile per qualcuno impostare l'oggetto senza che vengano generate eccezioni fino a quando l'oggetto non viene completato e pronto per essere costruito.
yitzih,

Il costruttore della classe in costruzione è in definitiva responsabile della validità dei suoi argomenti. La convalida nel builder è nella migliore delle ipotesi ridondante e potrebbe facilmente non essere sincronizzata con i requisiti del costruttore.
ReinstateMonicaSackTheStaff il

@TKK In effetti. Ma convalidano cose diverse. In molti dei casi in cui ho usato Builder, il compito del builder era costruire gli input che alla fine venivano dati al costruttore. Ad esempio, il builder è configurato con a URI, a File, o a FileInputStreame usa tutto ciò che è stato fornito per ottenere un FileInputStreamche alla fine va nella chiamata del costruttore come arg
Alexander - Reinstate Monica

2

Un builder può anche essere definito per restituire un'interfaccia o una classe astratta. È possibile utilizzare il builder per definire l'oggetto e il builder può determinare quale sottoclasse concreta restituire in base a quali proprietà sono impostate o su cosa sono impostate, ad esempio.


2

Il modello builder viene utilizzato per creare / creare l'oggetto passo dopo passo impostando le proprietà e quando tutti i campi richiesti sono impostati, restituisce l'oggetto finale usando il metodo build. L'oggetto appena creato è immutabile. Il punto principale da notare qui è che l'oggetto viene restituito solo quando viene invocato il metodo di compilazione finale. Ciò assicura che tutte le proprietà siano impostate sull'oggetto e quindi l'oggetto non si trovi in ​​uno stato incoerente quando viene restituito dalla classe builder.

Se non utilizziamo la classe builder e inseriamo direttamente tutti i metodi della classe builder nella classe Person stessa, allora dobbiamo prima creare l'oggetto e quindi invocare i metodi setter sull'oggetto creato che porteranno allo stato incoerente dell'oggetto tra la creazione di oggetto e impostazione delle proprietà.

Quindi, usando la classe builder (cioè qualche entità esterna diversa dalla classe Person stessa) assicuriamo che l'oggetto non sarà mai nello stato incoerente.


@DawidO hai capito bene. L'enfasi principale che sto cercando di mettere qui è sullo stato incoerente. E questo è uno dei principali vantaggi dell'utilizzo del modello Builder.
Shubham Bondarde,

2

Riutilizzare l'oggetto builder

Come altri hanno già detto, l'immutabilità e la verifica della logica aziendale di tutti i campi per convalidare l'oggetto sono le ragioni principali di un oggetto generatore separato.

Tuttavia, la riutilizzabilità è un altro vantaggio. Se voglio creare un'istanza di molti oggetti molto simili, posso apportare piccole modifiche all'oggetto builder e continuare a creare un'istanza. Non è necessario ricreare l'oggetto del builder. Questo riutilizzo consente al builder di fungere da modello per la creazione di molti oggetti immutabili. È un piccolo vantaggio, ma potrebbe essere utile.


1

In realtà, puoi avere i metodi di costruzione sulla tua classe stessa e avere ancora l'immutabilità. Significa solo che i metodi del builder restituiranno nuovi oggetti, invece di modificare quello esistente.

Funziona solo se esiste un modo per ottenere un oggetto iniziale (valido / utile) (ad es. Da un costruttore che imposta tutti i campi richiesti o un metodo di fabbrica che imposta i valori predefiniti) e che i metodi aggiuntivi del generatore restituiscono quindi gli oggetti modificati su quello esistente. Quei metodi di costruzione devono assicurarsi che non si ottengano oggetti non validi / incoerenti sulla strada.

Ovviamente, questo significa che avrai molti nuovi oggetti e non dovresti farlo se i tuoi oggetti sono costosi da creare.

L'ho usato nel codice di prova per creare abbinatori Hamcrest per uno dei miei oggetti business. Non ricordo il codice esatto, ma sembrava qualcosa del genere (semplificato):

public class CustomerMatcher extends TypeSafeMatcher<Customer> {
    private final Matcher<? super String> nameMatcher;
    private final Matcher<? super LocalDate> birthdayMatcher;

    @Override
    protected boolean matchesSafely(Customer c) {
        return nameMatcher.matches(c.getName()) &&
               birthdayMatcher.matches(c.getBirthday());
    }

    private CustomerMatcher(Matcher<? super String> nameMatcher,
                            Matcher<? super LocalDate> birthdayMatcher) {
        this.nameMatcher = nameMatcher;
        this.birthdayMatcher = birthdayMatcher;
    }

    // builder methods from here on

    public static CustomerMatcher isCustomer() {
        // I could return a static instance here instead
        return new CustomerMatcher(Matchers.anything(), Matchers.anything());
    }

    public CustomerMatcher withBirthday(Matcher<? super LocalDate> birthdayMatcher) {
        return new CustomerMatcher(this.nameMatcher, birthdayMatcher);
    }

    public CustomerMatcher withName(Matcher<? super String> nameMatcher) {
        return new CustomerMatcher(nameMatcher, this.birthdayMatcher);
    }
}

Lo userei così in questo modo nei miei test unitari (con opportune importazioni statiche):

assertThat(result, is(customer().withName(startsWith("Paŭlo"))));

1
Questo è vero - e penso che sia un punto molto importante, ma non risolve l'ultimo problema - Non ti dà la possibilità di convalidare che l'oggetto è in uno stato coerente quando è costruito. Una volta costruito, avrai bisogno di alcuni trucchi come chiamare un validatore prima di uno qualsiasi dei tuoi metodi aziendali per assicurarti che il chiamante abbia impostato tutti i metodi (che sarebbe una pratica terribile). Penso che sia utile ragionare su questo genere di cose per capire perché utilizziamo il modello Builder nel modo in cui lo facciamo.
Bill K,

@BillK true - un oggetto con essenzialmente setter immutabili (che restituiscono un nuovo oggetto ogni volta) significa che ogni oggetto restituito dovrebbe essere valido. Se stai restituendo oggetti non validi (ad es. Persona con namema no age), il concetto non funziona. Bene, potresti effettivamente restituire qualcosa del genere PartiallyBuiltPersonmentre non è valido ma sembra un trucco per mascherare il Builder.
VLAZ,

@BillK questo è ciò che intendevo con "se c'è un modo per ottenere un oggetto iniziale (valido / utile)" - Presumo che sia l'oggetto iniziale che l'oggetto restituito da ciascuna funzione del builder siano validi / coerenti. Il tuo compito come creatore di tale classe è quello di fornire solo metodi di costruzione che apportano tali cambiamenti coerenti.
Paŭlo Ebermann,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.