J'ai vu de nombreuses implémentations du modèle Builder (principalement en Java). Tous ont une classe d'entité (disons une classe Person
) et une classe constructeur PersonBuilder
. Le générateur "empile" une variété de champs et renvoie un new Person
Avec les arguments passés. Pourquoi avons-nous explicitement besoin d'une classe de générateur, au lieu de placer toutes les méthodes de générateur dans la classe Person
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 la nécessité d'une classe PersonBuilder
?
Le seul avantage que je vois, c'est que nous pouvons déclarer les champs Person
comme final
, assurant ainsi l'immuabilité.
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 Gang of Four Builder Pattern . 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. Non 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 la build()
si une erreur d'exécution pour un champ d'âge manquant vous suffit. Vous pouvez le mettre à niveau et appliquer que 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 renvoyant this
à chaque 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 du compilateur 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 l'achèvement du code, mais ils sont un peu de travail à mettre en place. Je recommande de les enregistrer pour des choses qui auront un peu de code source écrit contre eux.
Pourquoi utiliser/fournir une classe de générateur:
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.
Un angle un peu différent à ce sujet de ce que je vois dans d'autres réponses.
L'approche withFoo
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é comme standard mais si vous faites autre chose, cela va - surprise personnes 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 c'est pas aussi cher que vous pourriez le penser.
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.
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.
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.
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, retournez l'objet final en utilisant 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 é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 constructeur (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.
En fait, vous pouvez avoir les méthodes de construction sur votre classe elle-même, et vous avez 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 sur le chemin.
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.
J'ai utilisé cela dans le code de test pour créer Hamcrest matchers 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"))));
Une autre raison qui n'a pas déjà été explicitement mentionnée ici, est que la méthode build()
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), qui est probablement le mode de défaillance le plus probable qui se produirait autrement.
Un autre avantage est que votre objet Person
finirait par avoir une durée de vie plus simple et un simple ensemble d'invariants. Vous savez qu'une fois que vous avez un Person p
, tu as un p.name
et un 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.