Perché abbiamo bisogno di una classe Builder quando implementiamo un pattern Builder?

28

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

Ad 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é è necessaria una classe PersonBuilder ?

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

    
posta Boyan Kushlev 22.10.2018 - 16:40
fonte

9 risposte

26

È così che puoi essere immutable AND simula i parametri denominati allo stesso tempo.

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

Ciò mantiene i tuoi mitts fuori dalla persona finché non viene impostato lo stato e, una volta impostato, non ti consente di cambiarlo, ma 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 Gang di Four Builder Pattern . 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 poi devi scegliere. Si perde la capacità di simulare i parametri con nome in lingue 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 singolo semplice costruttore. Ciò ti consentirà di rimanere immutabile, ma perderai la simulazione dei parametri con nome. Questo diventa difficile da leggere con molti parametri. I computer non si preoccupano ma è difficile per gli umani.

Esempio di parametro con nome 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 con nome ma non sei più immutabile. Ogni utilizzo di un setter cambia stato dell'oggetto.

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

Validazione può essere eseguita in build() se un errore di runtime per un campo di età mancante è sufficiente per te. Puoi aggiornarlo e imporre che age() venga chiamato con un errore del compilatore. Solo non con il modello del costruttore Josh Bloch.

Per questo è necessario un Lingua specifica di dominio interna (iDSL).

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

L'uso potrebbe essere simile a questo:

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 chiamare il tipo restituito da name() .

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

    
risposta data 22.10.2018 - 20:48
fonte
57

Perché usare / fornire una classe di 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 nel nostro tentativo di scrivere programmi manutenibili e privi di bug.
  • 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, pertanto StringBuilder utilizza una rappresentazione interna diversa che è utile per l'aggiunta, ma non valida per l'utilizzo dello spazio e non valida per la lettura e l'utilizzo come normale classe String.
  • Per separare chiaramente oggetti costruiti da oggetti in costruzione. Questo approccio richiede una chiara transizione dalla sottostruttura alla costruzione. Per il consumatore, non c'è modo di confondere un oggetto sotto-costruzione con un oggetto costruito: il sistema tipo lo farà rispettare. Ciò significa che a volte possiamo usare questo approccio per "cadere nella fossa del successo", per così dire, e, quando facciamo l'astrazione per gli altri (o per noi stessi) da usare (come un'API o un livello), questo può essere un ottimo cosa.
risposta data 22.10.2018 - 19:57
fonte
19

Un motivo potrebbe essere quello di garantire che tutti i dati passati seguano le regole aziendali.

Il tuo esempio non tiene conto di ciò, ma diciamo che qualcuno ha passato una stringa vuota o una stringa composta da caratteri speciali. Vorresti fare una sorta di logica basata sul fare in modo 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 (per esempio, assicurandoti solo che un'età sia non negativa) ma man mano che la logica cresce, ha senso separarla.

    
risposta data 22.10.2018 - 16:47
fonte
5

Un angolo leggermente diverso da quello che vedo in altre risposte.

L'approccio withFoo qui è problematico perché si comportano come setter ma sono definiti in modo 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 andrà a sorpresa persone e non va bene. C'è un altro modo in cui puoi supportare l'immutabilità con l'API di base che hai qui. Ad 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 le modifiche a qualsiasi oggetto esistente. Probabilmente è sciocco per questo genere di cose (e così è il JB Builder). Sì, creerai più oggetti ma questo è non così costoso come potresti pensare.

Vedrai principalmente questo tipo di approccio utilizzato con strutture di dati concorrenti come CopyOnWriteArrayList . E questo suggerisce perché l'immutabilità sia importante. Se vuoi rendere il tuo codice sicuro, l'immutabilità dovrebbe quasi sempre essere presa in considerazione. 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 funzionalità di concorrenza. Ognuno di questi aggiungerà un 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 indipendentemente da cosa.

    
risposta data 22.10.2018 - 22:29
fonte
2

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

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

    
risposta data 22.10.2018 - 20:05
fonte
2

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

    
risposta data 23.10.2018 - 08:20
fonte
2

Il pattern Builder viene utilizzato per creare / creare l'oggetto passo dopo passo impostando le proprietà e quando tutti i campi richiesti sono impostati, quindi restituire l'oggetto finale utilizzando il metodo build. L'oggetto appena creato è immutabile. Il punto principale da notare qui è che l'oggetto viene restituito solo quando viene richiamato il metodo di build finale. Ciò assicura che tutte le proprietà siano impostate sull'oggetto e quindi l'oggetto non sia in stato incoerente quando viene restituito dalla classe builder.

Se non usiamo la classe builder e mettiamo direttamente tutti i metodi della classe builder alla classe Person, allora dobbiamo prima creare l'oggetto e poi invocare i metodi setter sull'oggetto creato che porteranno allo stato incoerente del oggetto tra la creazione dell'oggetto e l'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.

    
risposta data 23.10.2018 - 13:15
fonte
2

Riutilizza oggetto builder

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

Tuttavia, la riutilizzabilità è un altro vantaggio. Se voglio istanziare molti oggetti che sono molto simili, posso apportare piccole modifiche all'oggetto costruttore e continuare l'istanziazione. Non c'è bisogno di ricreare l'oggetto del costruttore. Questo riutilizzo consente al builder di agire come un modello per la creazione di molti oggetti immutabili. È un piccolo vantaggio, ma potrebbe essere utile.

    
risposta data 22.10.2018 - 22:10
fonte
1

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

Funziona solo se c'è un modo per ottenere un oggetto iniziale (valido / utile) (ad es. da un costruttore che imposta tutti i campi richiesti, o un metodo factory che imposta i valori predefiniti), e restituisce i metodi aggiuntivi del builder oggetti modificati in base a quello esistente. Questi metodi di build devono essere sicuri che non vengano trovati oggetti non validi / incoerenti lungo il percorso.

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 test per creare Matcher di Hamcrest per uno dei miei oggetti di business. Non ricordo il codice esatto, ma sembrava qualcosa di simile (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 in questo modo nei miei test di unità (con opportune importazioni statiche):

assertThat(result, is(customer().withName(startsWith("Paŭlo"))));
    
risposta data 23.10.2018 - 23:14
fonte

Leggi altre domande sui tag