Pourquoi avons-nous besoin d'une classe Builder lors de l'implémentation d'un modèle Builder?


31

J'ai vu de nombreuses implémentations du modèle Builder (principalement en Java). Tous ont une classe d'entité (disons une Personclasse) et une classe de constructeur PersonBuilder. Le générateur "empile" une variété de champs et renvoie un new Personavec les arguments passés. Pourquoi avons-nous explicitement besoin d'une classe de générateur, au lieu de mettre toutes les méthodes de générateur dans la Personclasse elle-même?

Par exemple:

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;
  }
}

Je peux simplement dire Person john = new Person().withName("John");

Pourquoi le besoin d'une PersonBuilderclasse?

Le seul avantage que je vois, c'est que nous pouvons déclarer les Personchamps comme final, assurant ainsi l'immuabilité.


4
Remarque: Le nom normal de ce modèle est chainable setters: D
Mooing Duck

4
Principe de responsabilité unique. Si vous mettez la logique dans la classe Person, elle a plus d'un travail à faire
reggaeguitar

1
Votre avantage peut être obtenu dans les deux cas: je peux avoir withNamerenvoyé une copie de la personne avec seulement le champ de nom changé. En d'autres termes, cela Person john = new Person().withName("John");pourrait fonctionner même s'il Personest immuable (et c'est un modèle courant en programmation fonctionnelle).
Brian McCutchon

9
@MooingDuck: un autre terme courant car il s'agit d'une interface fluide .
Mac

2
@Mac Je pense que l'interface fluide est un peu plus générale - vous évitez d'avoir des voidméthodes. Ainsi, par exemple, si Persona une méthode qui imprime leur nom, vous pouvez toujours la chaîner avec une interface Fluent person.setName("Alice").sayName().setName("Bob").sayName(). Soit dit en passant, j'annote ceux de JavaDoc avec exactement votre suggestion @return Fluent interface- c'est générique et assez clair quand il s'applique à n'importe quelle méthode qui le fait return thisà la fin de son exécution et c'est assez clair. Ainsi, un constructeur fera également une interface fluide.
VLAZ

Réponses:


27

C'est pour que vous puissiez être immuable ET simuler des paramètres nommés en même temps.

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

Cela garde vos mitaines hors de la personne jusqu'à ce que son état soit défini et, une fois défini, ne vous permettra pas de le changer, mais chaque champ est clairement étiqueté. Vous ne pouvez pas faire cela avec une seule classe en Java.

Il semble que vous parliez de Josh Blochs Builder Pattern . Cela ne doit pas être confondu avec le modèle Gang of Four Builder . Ce sont des bêtes différentes. Ils résolvent tous deux des problèmes de construction, mais de manière assez différente.

Bien sûr, vous pouvez construire votre objet sans utiliser une autre classe. Mais alors vous devez choisir. Vous perdez soit la possibilité de simuler des paramètres nommés dans des langages qui n'en ont pas (comme Java), soit vous perdez la possibilité de rester immuable pendant toute la durée de vie des objets.

Exemple immuable, n'a pas de nom pour les paramètres

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

Ici, vous construisez tout avec un seul constructeur simple. Cela vous permettra de rester immuable mais vous perdrez la simulation des paramètres nommés. Cela devient difficile à lire avec de nombreux paramètres. Les ordinateurs s'en fichent mais c'est dur pour les humains.

Exemple de paramètre nommé simulé avec des setters traditionnels. Pas immuable.

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

Ici, vous construisez tout avec des setters et simulez des paramètres nommés mais vous n'êtes plus immuable. Chaque utilisation d'un setter change l'état de l'objet.

Donc, ce que vous obtenez en ajoutant la classe, c'est que vous pouvez faire les deux.

La validation peut être effectuée dans le build()cas où une erreur d'exécution pour un champ d'âge manquant vous suffit. Vous pouvez mettre à niveau et appliquer ce qui age()est appelé avec une erreur de compilation. Mais pas avec le modèle de générateur Josh Bloch.

Pour cela, vous avez besoin d'un langage spécifique au domaine interne (iDSL).

Cela vous permet d'exiger qu'ils appellent age()et name()avant d'appeler build(). Mais vous ne pouvez pas le faire simplement en revenant à thischaque fois. Chaque chose qui retourne renvoie une chose différente qui vous oblige à appeler la chose suivante.

L'utilisation pourrait ressembler à ceci:

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

Mais ça:

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

provoque une erreur de compilation car age()n'est valide que pour appeler le type retourné par name().

Ces iDSL sont extrêmement puissants ( JOOQ ou Java8 Streams par exemple) et sont très agréables à utiliser, surtout si vous utilisez un IDE avec complétion de code, mais ils sont un peu de travail à configurer. Je recommanderais de les enregistrer pour des choses qui auront un peu de code source écrit contre eux.


3
Et puis quelqu'un demande pourquoi vous devez fournir le nom avant l'âge, et la seule réponse est "parce que l'explosion combinatoire". Je suis presque sûr que l'on peut éviter cela dans la source si l'on a des modèles appropriés comme C ++, ou si l'on revient en utilisant un type différent pour tout.
Déduplicateur

1
Une abstraction qui fuit nécessite la connaissance d'une complexité sous-jacente pour savoir comment l'utiliser. Voilà ce que je vois ici. Toute classe ne sachant pas se construire est nécessairement couplée au Builder qui a d'autres dépendances (tel est le but et la nature du concept Builder). Une fois (pas au camp de bande) un simple besoin d'instancier un objet a nécessité 3 mois pour défaire un tel frack cluster. Je ne plaisante pas.
radarbob

Un des avantages des classes qui ne connaissent aucune de leurs règles de construction est que lorsque ces règles changent, elles s'en moquent. Je peux simplement ajouter un AnonymousPersonBuilderqui a son propre ensemble de règles.
candied_orange

La conception de classe / système montre rarement une application profonde de «maximiser la cohésion et minimiser le couplage». Les légions de conception et d'exécution sont extensibles et les défauts ne peuvent être corrigés que si les maximes et les directives OO sont appliquées avec le sentiment d'être fractales, ou "ce sont des tortues, tout le long".
radarbob

1
@radarbob La classe en cours de construction sait toujours se construire. Son constructeur est chargé d'assurer l'intégrité de l'objet. La classe builder est simplement une aide pour le constructeur, car le constructeur prend souvent un grand nombre de paramètres, ce qui ne fait pas une très belle API.
ReinstateMonicaSackTheStaff

57

Pourquoi utiliser / fournir une classe de générateur:

  • Faire des objets immuables - l'avantage que vous avez déjà identifié. Utile si la construction prend plusieurs étapes. FWIW, l'immuabilité devrait être considérée comme un outil important dans notre quête pour écrire des programmes maintenables et sans bogues.
  • Si la représentation d'exécution de l'objet final (éventuellement immuable) est optimisée pour la lecture et / ou l'utilisation de l'espace, mais pas pour la mise à jour. String et StringBuilder sont de bons exemples ici. La concaténation répétée de chaînes n'est pas très efficace, donc le StringBuilder utilise une représentation interne différente qui est bonne pour l'ajout - mais pas aussi bonne sur l'utilisation de l'espace, et pas aussi bonne pour la lecture et l'utilisation que la classe String régulière.
  • Séparer clairement les objets construits des objets en construction. Cette approche nécessite une transition claire de la sous-construction à la construction. Pour le consommateur, il n'y a aucun moyen de confondre un objet en construction avec un objet construit: le système de types le fera respecter. Cela signifie que parfois nous pouvons utiliser cette approche pour "tomber dans le gouffre du succès", pour ainsi dire, et, lors de l'abstraction pour les autres (ou nous-mêmes) à utiliser (comme une API ou une couche), cela peut être un très bon chose.

4
Concernant le dernier point. Donc l'idée étant que a PersonBuildern'a pas de getters, et la seule façon d'inspecter les valeurs actuelles est d'appeler .Build()pour retourner a Person. De cette façon .Build peut valider un objet correctement construit, n'est-ce pas? C'est-à-dire le mécanisme destiné à empêcher l'utilisation d'un objet "en construction"?
AaronLS

@AaronLS, oui, certainement; une validation complexe peut être mise en Buildopération pour éviter les objets défectueux et / ou sous-construits.
Erik Eidt du

2
Vous pouvez également valider votre objet au sein de son constructeur, la préférence peut être personnelle ou dépendre de la complexité. Quel que soit le choix, le point principal est qu'une fois que vous avez votre objet immuable, vous savez qu'il est correctement construit et n'a aucune valeur inattendue. Un objet immuable avec un état incorrect ne devrait pas se produire.
Walfrat

2
@AaronLS un constructeur peut avoir des getters; ce n'est pas le propos. Cela n'est pas nécessaire, mais si un générateur a des getters, vous devez être conscient que l'appel getAgeau générateur peut revenir nulllorsque cette propriété n'a pas encore été spécifiée. En revanche, la Personclasse peut imposer l'invariant que l'âge ne peut jamais être null, ce qui est impossible lorsque le constructeur et l'objet réel sont mélangés, comme avec l' new Person() .withName("John")exemple de l'OP , qui crée heureusement un Personsans âge. Cela s'applique même aux classes mutables, car les setters peuvent appliquer des invariants, mais ne peuvent pas appliquer les valeurs initiales.
Holger

1
@Holger vos points sont vrais, mais soutiennent principalement l'argument selon lequel un constructeur devrait éviter les getters.
user949300

21

L'une des raisons serait de garantir que toutes les données transmises suivent les règles métier.

Votre exemple ne prend pas cela en considération, mais disons que quelqu'un a passé une chaîne vide ou une chaîne composée de caractères spéciaux. Vous voudriez faire une sorte de logique basée sur la vérification que leur nom est en fait un nom valide (ce qui est en fait une tâche très difficile).

Vous pouvez mettre tout cela dans votre classe Person, surtout si la logique est très petite (par exemple, en vous assurant simplement qu'un âge n'est pas négatif) mais que la logique grandit, il est logique de le séparer.


7
L'exemple de la question fournit une violation simple des règles: il n'y a pas d'âge pourjohn
Caleth

6
+1 Et il est très difficile de faire des règles pour deux champs simultanément sans la classe de générateur (les champs X + Y ne peuvent pas être supérieurs à 10).
Reginald Blue

7
@ReginaldBlue c'est encore plus difficile s'ils sont liés par "x + y est pair". Notre condition initiale avec (0,0) est OK, l'état (1,1) est OK aussi, mais il n'y a pas de séquence de mises à jour de variables uniques qui maintiennent l'état valide et nous amènent de (0,0) à (1) ,1).
Michael Anderson

Je pense que c'est le plus grand avantage. Tous les autres sont gentils.
Giacomo Alzetta

5

Un angle un peu différent à ce sujet de ce que je vois dans d'autres réponses.

L' withFooapproche ici est problématique car ils se comportent comme des setters mais sont définis d'une manière qui donne l'impression que la classe prend en charge l'immuabilité. Dans les classes Java, si une méthode modifie une propriété, il est habituel de démarrer la méthode avec 'set'. Je ne l'ai jamais aimé en tant que norme, mais si vous faites autre chose, cela va surprendre les gens et ce n'est pas bon. Il existe une autre façon de prendre en charge l'immuabilité avec l'API de base que vous avez ici. Par exemple:

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);
  }
}

Il n'offre pas beaucoup de moyens pour empêcher les objets partiellement construits de manière incorrecte, mais empêche les modifications des objets existants. C'est probablement idiot pour ce genre de chose (tout comme le JB Builder). Oui, vous créerez plus d'objets, mais ce n'est pas aussi cher que vous le pensez.

Vous verrez principalement ce type d'approche utilisée avec des structures de données simultanées telles que CopyOnWriteArrayList . Et cela indique pourquoi l'immuabilité est importante. Si vous souhaitez rendre votre code threadsafe, l'immuabilité doit presque toujours être considérée. En Java, chaque thread est autorisé à conserver un cache local à état variable. Pour qu'un thread puisse voir les modifications apportées dans d'autres threads, un bloc synchronisé ou une autre fonctionnalité de concurrence doit être utilisé. N'importe lequel de ces éléments ajoutera une surcharge au code. Mais si vos variables sont finales, il n'y a rien à faire. La valeur sera toujours celle à laquelle elle a été initialisée et par conséquent, tous les threads voient la même chose, quoi qu'il arrive.


2

Une autre raison qui n'a pas été explicitement mentionnée ici, est que la build()méthode peut vérifier que tous les champs sont des champs contenant des valeurs valides (soit définies directement, soit dérivées d'autres valeurs d'autres champs), ce qui est probablement le mode d'échec le plus probable. cela se produirait autrement.

Un autre avantage est que votre Personobjet finirait par avoir une durée de vie plus simple et un simple ensemble d'invariants. Vous savez qu'une fois que vous en avez un Person p, vous avez un p.nameet un valide p.age. Aucune de vos méthodes ne doit être conçue pour gérer des situations telles que "et si l'âge est défini mais pas le nom, ou si le nom est défini mais pas l'âge?" Cela réduit la complexité globale de la classe.


"[...] peut vérifier que tous les champs sont définis [...]" Le but du modèle Builder est que vous n'avez pas à définir tous les champs vous-même et que vous pouvez revenir à des valeurs par défaut raisonnables. Si vous devez quand même définir tous les champs, vous ne devriez peut-être pas utiliser ce modèle!
Vincent Savard

@VincentSavard En effet, mais à mon avis, c'est l'utilisation la plus courante. Pour valider que certains ensembles de valeurs «de base» sont définis et effectuer un traitement de fantaisie autour de certaines valeurs facultatives (définition des valeurs par défaut, dérivation de leur valeur à partir d'autres valeurs, etc.).
Alexander - Rétablir Monica

Au lieu de dire «vérifier que tous les champs sont définis», je changerais cela en «vérifiant que tous les champs contiennent des valeurs valides [parfois dépendant des valeurs des autres champs]» (c'est-à-dire qu'un champ ne peut autoriser certaines valeurs qu'en fonction de la valeur d'un autre domaine). Cela pourrait être fait dans chaque setter, mais il est plus facile pour quelqu'un de configurer l'objet sans avoir d'exceptions levées jusqu'à ce que l'objet soit terminé et prêt à être construit.
yitzih

Le constructeur de la classe en cours de construction est ultimement responsable de la validité de ses arguments. La validation dans le générateur est au mieux redondante et pourrait facilement se désynchroniser avec les exigences du constructeur.
ReinstateMonicaSackTheStaff

@TKK Effectivement. Mais ils valident différentes choses. Dans de nombreux cas dans lesquels j'ai utilisé Builder, le travail du constructeur consistait à construire les entrées qui finissent par être données au constructeur. Par exemple, le constructeur est configuré avec a URI, a Fileou a FileInputStream, et utilise tout ce qui a été fourni pour obtenir un FileInputStreamqui va finalement dans l'appel du constructeur en tant qu'arg.
Alexander - Reinstate Monica

2

Un générateur peut également être défini pour renvoyer une interface ou une classe abstraite. Vous pouvez utiliser le générateur pour définir l'objet et le générateur peut déterminer la sous-classe concrète à renvoyer en fonction des propriétés définies ou de leur définition, par exemple.


2

Le modèle de générateur est utilisé pour construire / créer l'objet étape par étape en définissant les propriétés et lorsque tous les champs obligatoires sont définis, puis renvoyez l'objet final à l'aide de la méthode de construction. L'objet nouvellement créé est immuable. Le point principal à noter ici est que l'objet n'est retourné que lorsque la méthode de construction finale est invoquée. Cela garantit que toutes les propriétés sont définies sur l'objet et que l'objet n'est donc pas dans un état incohérent lorsqu'il est renvoyé par la classe de générateur.

Si nous n'utilisons pas la classe de générateur et que nous plaçons directement toutes les méthodes de classe de générateur dans la classe Personne elle-même, nous devons d'abord créer l'objet, puis invoquer les méthodes de définition sur l'objet créé, ce qui conduira à l'état incohérent de l'objet entre la création de l'objet et la définition des propriétés.

Ainsi, en utilisant la classe builder (c'est-à-dire une entité externe autre que la classe Person elle-même), nous nous assurons que l'objet ne sera jamais dans l'état incohérent.


@DawidO vous avez compris mon point. Le principal accent que j'essaie de mettre ici est sur l'état incohérent. Et c'est l'un des principaux avantages de l'utilisation du modèle Builder.
Shubham Bondarde

2

Réutiliser l'objet générateur

Comme d'autres l'ont mentionné, l'immuabilité et la vérification de la logique métier de tous les champs pour valider l'objet sont les principales raisons d'un objet constructeur distinct.

Cependant, la réutilisation est un autre avantage. Si je veux instancier de nombreux objets très similaires, je peux apporter de petites modifications à l'objet générateur et continuer à instancier. Pas besoin de recréer l'objet générateur. Cette réutilisation permet au générateur de servir de modèle pour créer de nombreux objets immuables. C'est un petit avantage, mais il pourrait être utile.


1

En fait, vous pouvez avoir les méthodes de génération sur votre classe elle-même et avoir toujours l'immuabilité. Cela signifie simplement que les méthodes du générateur renverront de nouveaux objets, au lieu de modifier ceux existants.

Cela ne fonctionne que s'il existe un moyen d'obtenir un objet initial (valide / utile) (par exemple à partir d'un constructeur qui définit tous les champs requis, ou une méthode d'usine qui définit les valeurs par défaut), et les méthodes de générateur supplémentaires retournent ensuite les objets modifiés en fonction sur l'existant. Ces méthodes de génération doivent s'assurer que vous n'obtenez pas d'objets invalides / incohérents en cours de route.

Bien sûr, cela signifie que vous aurez beaucoup de nouveaux objets, et vous ne devriez pas le faire si vos objets sont coûteux à créer.

Je l'ai utilisé dans le code de test pour créer des comparateurs Hamcrest pour l'un de mes objets métier. Je ne me souviens pas du code exact, mais il ressemblait à quelque chose comme ça (simplifié):

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);
    }
}

Je l'utiliserais alors comme ceci dans mes tests unitaires (avec des importations statiques appropriées):

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

1
C'est vrai - et je pense que c'est un point très important, mais cela ne résout pas le dernier problème - Cela ne vous donne pas la possibilité de valider que l'objet est dans un état cohérent lors de sa construction. Une fois construit, vous aurez besoin d'une ruse comme appeler un validateur avant l'une de vos méthodes commerciales pour vous assurer que l'appelant a défini toutes les méthodes (ce qui serait une terrible pratique). Je pense qu'il est bon de raisonner à travers ce genre de chose pour voir pourquoi nous utilisons le modèle Builder comme nous le faisons.
Bill K

@BillK true - un objet avec essentiellement des setters immuables (qui renvoient un nouvel objet à chaque fois) signifie que chaque objet retourné doit être valide. Si vous retournez des objets invalides (par exemple, personne avec namemais non age), le concept ne fonctionne pas. Eh bien, vous pourriez en fait retourner quelque chose comme PartiallyBuiltPersons'il n'était pas valide, mais cela semble être un hack pour masquer le générateur.
VLAZ

@BillK c'est ce que je voulais dire par "s'il existe un moyen d'obtenir un objet initial (valide / utile)" - je suppose que l'objet initial et l'objet renvoyé par chaque fonction de générateur sont valides / cohérents. Votre tâche en tant que créateur d'une telle classe est de fournir uniquement des méthodes de générateur qui effectuent ces modifications cohérentes.
Paŭlo Ebermann
En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.