web-dev-qa-db-fra.com

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

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é.

33
Boyan Kushlev

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.

28
candied_orange

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.
57
Erik Eidt

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.

21
Deacon

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.

6
JimmyJames

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.

3
yitzih

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
Ed Marty

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.

2
Shubham Bondarde

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"))));
2
Paŭlo Ebermann

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.