I. Introduction▲
Depuis plusieurs semaines voire plusieurs mois, ma mission actuelle m'amène à enrichir les tests unitaires de l'application JEE sur laquelle je travaille, et ce pour diverses raisons que je n'étalerai pas ici. Bien évidemment, je n'ai pas toujours la chance de travailler sur du code neuf ; je dois donc faire avec l'existant, sous toutes ses formes, qu'elles soient bonnes ou mauvaises.
Dès lors, je me suis aperçu que je croisais quasi quotidiennement un antipattern répondant au doux nom de « Telescoping Constructor » . À travers cet article, je me propose donc de vous l'expliquer par l'exemple puis de dresser les différentes alternatives pour y remédier. Enfin, et comme il est toujours bon d'aller un peu plus loin que l'objectif initial, je prendrai le temps de vous présenter les Fluent Interfaces qui peuvent être abordées à partir de notre problématique initiale.
Comme tous les articles de mon blog, les exemples sont disponibles sur GitHub . Enfin, ces derniers sont écrits en Java, mais peuvent s'appliquer à n'importe quel langage orienté objet.
II. L'antipattern « Telescoping Constructor »▲
II-A. De la nécessité de construire…▲
Un langage orienté objet fait la part belle aux objets, comme son nom l'indique (si si, je vous assure !). Prenons par exemple la classe suivante :
2.
3.
4.
5.
6.
7.
8.
9.
10.
public
class
Person {
private
final
int
id;
private
boolean
married;
private
int
age;
private
String firstname;
private
String lastname;
}
J'ai modélisé très simplement une personne via un identifiant technique, ses nom et prénom, son âge et par un booléen indiquant si elle est mariée ou non. Le mot clé final indique que l'identifiant technique est obligatoire ; je suis obligé de le fournir à l'instanciation de cette classe.
C'est une représentation volontairement simpliste, mais pas forcément incongrue selon le contexte. Disons que je n'ai besoin que de cela pour l'instant. La création des objets se fera alors via un constructeur de ce type :
2.
3.
4.
5.
6.
7.
public
Person
(
boolean
married, int
age, int
id, String firstname, String lastname) {
this
.married =
married;
this
.age =
age;
this
.id =
id;
this
.firstname =
firstname;
this
.lastname =
lastname;
}
Les EDIEnvironnements de Développement Intégrés sont d'ailleurs largement utilisés pour générer de tels constructeurs. À plusieurs endroits dans mon code, je verrai donc dorénavant ce type d'appels :
final
Person pierre =
new
Person
(
false
, 15
, 1
, "Pierre"
, "Duchêne"
);
Ici, mon appel reste relativement lisible, car je n'ai que cinq paramètres ; il m'est donc facile de savoir à quoi ils correspondent. Dans une application JEE , ce type de code est plutôt rare, car la construction des entités se fait généralement dans la couche d'accès aux données. C'est par exemple l' ORM qui s'en charge. En revanche, il n'est pas rare d'en trouver dans les tests unitaires, où l'on a besoin de vérifier le comportement de notre application selon les données contenues par l'instance.
II-B. … aux constructions incertaines▲
Maintenant, imaginons que l'application adresse de nouveaux besoins et qu'il faille ajouter de nouveaux champs à notre classe :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
public
class
Person {
private
final
int
id;
private
boolean
married;
private
boolean
single;
private
boolean
widowed;
private
int
age;
private
int
children;
private
int
femaleChildren;
private
int
grandchildren;
private
int
maleChildren;
private
String firstname;
private
String lastname;
private
String nickname;
}
En conséquence, j'ajoute un constructeur pour pouvoir instancier ces nouveaux champs :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
public
Person
(
boolean
married, boolean
single, boolean
widowed, int
age, int
children, int
femaleChildren, int
grandchildren, int
id, int
maleChildren, String firstname, String lastname, String nickname) {
this
.married =
married;
this
.single =
single;
this
.widowed =
widowed;
this
.age =
age;
this
.children =
children;
this
.femaleChildren =
femaleChildren;
this
.grandchildren =
grandchildren;
this
.id =
id;
this
.maleChildren =
maleChildren;
this
.firstname =
firstname;
this
.lastname =
lastname;
this
.nickname =
nickname;
}
Et là, mes appels aux constructeurs commencent à devenir difficiles à maintenir :
final
Person harry =
new
Person
(
true
, false
, false
, 46
, 2
, 1
, 1
, 2
, 1
, "Harry"
, "Potter"
, "HP"
);
Comme vu précédemment, ce type de code se retrouve très souvent dans les tests unitaires et possède quatre inconvénients majeurs, en termes de :
- lisibilité : il est impossible de savoir ce que signifie la valeur d'un paramètre ; dans l'exemple ci-dessus, on confond et l'on inverse facilement le nombre d'enfants et de petits-enfants ;
- robustesse : si je change l'ordre des paramètres d'un constructeur, j'affecte tous les appels à ce dernier ;
-
usage :
- il est difficile de se rappeler de l'ordre des paramètres du constructeur,
- il est difficile de savoir quel constructeur utiliser selon les variations souhaitées.
Cette situation correspond à l'antipattern « Telescoping Constructor ». Voyons maintenant quelles sont les alternatives pour y remédier.
Dans l'exemple ci-dessus, j'ai volontairement simplifié les choses. Dans la vie réelle, j'ai croisé des constructeurs avec pas loin du triple de paramètres, au bas mot…
III. Quelles alternatives ?▲
III-A. Java Beans▲
La première alternative qui s'offre à nous consiste à réduire le nombre de paramètres passés au constructeur. On ne fournit donc que les valeurs obligatoires lors de l'instanciation et l'on fait ensuite usage des accesseurs (ou setters ) pour affecter les autres valeurs, d'où le lien avec la notion de Java Bean . Le constructeur s'en trouve simplifié :
2.
3.
public
Person
(
final
int
id) {
this
.id =
id;
}
Et l'instanciation d'une personne prend cette forme :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
final
Person paul =
new
Person
(
3
);
paul.setAge
(
23
);
paul.setChildren
(
0
);
paul.setFemaleChildren
(
0
);
paul.setFirstname
(
"Paul"
);
paul.setGrandchildren
(
0
);
paul.setLastname
(
"Nickel"
);
paul.setMaleChildren
(
0
);
paul.setMarried
(
true
);
paul.setNickname
(
"Paulo"
);
paul.setSingle
(
false
);
paul.setWidowed
(
false
);
Nous sommes loin d'être au top, mais nous avons déjà amélioré notre code en termes de :
- robustesse : je réduis les paramètres passés au constructeur à ceux qui sont obligatoires, donc je réduis les possibilités de changement d'ordre et donc les impacts sur les instanciations ;
- lisibilité : je suis plus à même de savoir à quoi correspond chaque valeur ; la sémantique est portée par le nom de l'accesseur, si tant est que le membre est correctement nommé ;
-
usage :
- je n'ai plus à me rappeler de l'ordre de passage des paramètres ; je fais comme bon me semble,
- je n'ai plus qu'un seul constructeur à utiliser.
Néanmoins, cette technique est inefficace si le nombre de membres obligatoires est important. On retombe alors sur les inconvénients vus avec les constructions traditionnelles. De plus, nous avons introduit des faiblesses en termes de :
- lisibilité : je me retrouve avec une construction étalée sur une quinzaine de lignes là où une seule suffisait auparavant ;
- robustesse : cette technique n'est pas thread-safe ; une fois l'instance créée (ligne 1), plusieurs threads peuvent y accéder si je n'y prête pas attention.
À la vue de tous ces éléments, cette technique n'est pas adaptée même si elle apporte quelques pistes intéressantes. Tournons-nous donc maintenant vers nos amies les fabriques.
III-B. Fabriques▲
La seconde alternative qui s'offre à nous pour améliorer la création de nos objets est une variante du patron de conception dit de la fabrique. Ce dernier fait partie d'un ensemble plus large proposé par le « gang des quatre » ( GOF dans la suite de cet article) dans l'ouvrage référence « Design Patterns: elements of reusable object-oriented software » , en 1994. Initialement, ce patron est dédié aux hiérarchies de classes et permet de créer des objets dont le type est dérivé d'une interface ou d'une classe abstraite, en fonction d'un certain nombre de paramètres. Il facilite la mise en œuvre du polymorphisme.
Mais au fil du temps, ce patron de conception créationnel a été détourné pour encapsuler la création d'objets. Le constructeur multiparamétré reste présent, mais sa complexité est maintenant encapsulée dans une méthode statique que l'on nomme fabrique :
2.
3.
public
static
Person createDefaultPerson
(
) {
return
new
Person
(
true
, false
, false
, 46
, 2
, 1
, 1
, 2
, 1
, "Jack"
, "Bauer"
, "JB"
);
}
Si l'on souhaite forcer l'usage de la (les) fabrique(s), le constructeur doit être défini comme privé. Ceci fait, mon appel devient simpliste :
final
Person jack =
Person.createDefaultPerson
(
);
À ce stade, j'ai obtenu quelques gains en termes de :
- lisibilité : en temps normal, les constructeurs portent le nom de leur classe. On ne peut donc pas leur faire porter de sémantique comme c'est le cas avec les fabriques (exemple: createDefaultPerson() ) ;
- usage : je n'ai plus à me rappeler des paramètres à passer ; ces derniers sont cachés au sein de la fabrique ;
- robustesse : ce type d'instanciation est thread-safe puisque faisant usage des constructeurs traditionnels.
Mais je vous arrête de suite, cette solution est loin d'être idéale. En effet, on ne passe plus les paramètres au constructeur et pour chaque variation, il faut donc créer une nouvelle fabrique. Voyez ci-après un petit aperçu de ce que cela pourrait donner :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
public
static
Person createDefaultPerson
(
) {
return
new
Person
(
true
, false
, false
, 46
, 2
, 1
, 1
, 2
, 1
, "Jack"
, "Bauer"
, "JB"
);
}
public
static
Person createDefaultSinglePerson
(
) {
return
new
Person
(
false
, true
, false
, 46
, 2
, 1
, 1
, 2
, 1
, "Jack"
, "Bauer"
, "JB"
);
}
public
static
Person createDefaultWindowedPerson
(
) {
return
new
Person
(
false
, true
, true
, 46
, 2
, 1
, 1
, 2
, 1
, "Jack"
, "Bauer"
, "JB"
);
}
Et dites-vous bien que le nombre de combinaisons possibles augmente avec la quantité de paramètres ! Finalement, cette solution comporte pas mal d'inconvénients :
- lisibilité : si je n'ai plus à me soucier de l'ordre et du sens des paramètres, je n'ai fait que reléguer ces informations au sein des fabriques. De plus, lorsque ces dernières deviennent trop nombreuses, la lecture des variations n'est plus évidente ;
- robustesse : si un possible changement d'ordre des paramètres de mon constructeur n'impacte plus mon code appelant, il touche tout de même à l'ensemble de mes fabriques. J'ai donc déshabillé Paul pour habiller Pierre ;
- usage : pour chaque variation, je dois créer une fabrique. À terme, je ne saurai donc plus laquelle utiliser et risquerai de dupliquer une existante.
Clairement, cette solution n'est pas la bonne. Tournons-nous donc maintenant vers un autre patron de conception et je dirais même plus, de construction.
III-C. Builder pattern▲
III-C-1. Principe▲
Pour aborder cette nouvelle alternative, je vais utiliser une analogie. Imaginons que vous entrez dans une pizzeria. Vous commandez la pizza du jour et quelques minutes plus tard, elle est sur votre table. Cependant, vous n'avez aucune idée du processus de construction et surtout, vous ne pouvez pas influer sur son contenu. Cette pizza du jour représente les fabriques vues précédemment. Ce que nous souhaitons, c'est pouvoir choisir les spécificités de notre pizza à partir d'un catalogue tout en s'appuyant sur une base commune. Cette idée, c'est le builder pattern .
Un builder (ou bâtisseur) est une classe qui :
- Contient une variable d'instance pour chaque paramètre de la classe à construire ;
- Initialise ces variables avec une valeur par défaut ;
- Propose une méthode statique permettant d'initier la construction ;
- Propose des méthodes chaînables permettant de surcharger chacune des variables ;
- Propose une méthode de construction qui instancie un nouvel objet à partir des variables d'instances et peut se charger d'un certain nombre de vérifications.
Un exemple célèbre est celui du StringBuilder qui permet de construire des chaînes de caractères ( StringBuffer étant sa version synchronisée).
Ce patron de conception est particulièrement adapté lorsqu'il existe un besoin de variation important sur la construction d'un objet. C'est très souvent le cas lorsque l'on construit des objets dans des tests unitaires ou avec des objets forts variants tel un DOMDocument Object Model. S'il fallait donner une règle quant à son utilisation, je suivrai celle énoncée par Joshua Bloch dans « Effective Java, 2de edition »:
« Le builder pattern est un choix avisé lors de la conception de classes dont les constructeurs ou fabriques statiques possèdent plus d'une poignée de paramètres. »
À noter qu'il diffère du patron de conception proposé par le GOF , ce dernier contenant notamment une classe directrice, rendant le tout un peu trop verbeux pour ce besoin précis.
III-C-2. Application au cas d'exemple▲
Appliquons maintenant ces différents points à l'exemple que nous poursuivons depuis le début de cet article. Tout d'abord, nous créons une variable d'instance avec une valeur par défaut pour chaque paramètre de l'objet à construire :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
public
static
class
PersonBuilder {
private
boolean
married =
false
;
private
boolean
single =
true
;
private
boolean
widowed =
false
;
private
int
age =
18
;
private
int
children =
0
;
private
int
femaleChildren =
0
;
private
int
grandchildren =
0
;
private
int
id =
1
;
private
int
maleChildren =
0
;
private
String firstname =
"Henry"
;
private
String lastname =
"Golant"
;
private
String nickname =
"Riri"
;
}
Puis, nous proposons une méthode statique qui permet d'initialiser la construction. Celle-ci retourne l'instance en cours de construction via l'usage du constructeur privé :
2.
3.
4.
5.
6.
7.
8.
9.
public
static
class
PersonBuilder {
private
PersonBuilder
(
) {
}
public
static
PersonBuilder createDefaultPerson
(
) {
return
new
PersonBuilder
(
);
}
}
Ceci fait, nous définissons les méthodes qui vont permettre de surcharger les différentes variables d'instances. Ci-après celles dédiées aux noms et prénoms :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
public
static
class
PersonBuilder {
public
PersonBuilder withFirstname
(
final
String firstname) {
this
.firstname =
firstname;
return
this
;
}
public
PersonBuilder withLastname
(
final
String lastname) {
this
.lastname =
lastname;
return
this
;
}
}
Notez que pour être chaînables, ces méthodes retournent l'instance en cours de construction. Enfin, nous définissons la méthode qui finalise la construction de l'objet, opère quelques vérifications et retourne l'instance créée à partir des variables du bâtisseur :
2.
3.
4.
5.
6.
7.
public
Person build
(
) {
if
(
married &&
single) {
throw
new
IllegalArgumentException
(
"You can't be married and single at the same time!"
);
}
return
new
Person
(
married, single, widowed, age, children, femaleChildren, grandchildren, id, maleChildren, firstname, lastname, nickname);
}
Ceci fait, je peux maintenant faire usage de mon bâtisseur pour obtenir une personne par défaut :
final
Person henry =
PersonBuilder.createDefaultPerson
(
).build
(
);
Je peux également surcharger les noms et prénoms par l'usage des méthodes chaînables :
final
Person joan =
PersonBuilder.createDefaultPerson
(
).withFirstname
(
"Joan"
).withLastname
(
"Baez"
).build
(
);
Enfin, mes constructions sont dorénavant vérifiées lors de la création de l'instance. Admettons que je cherche à créer une personne célibataire et mariée :
final
Person sandy =
PersonBuilder.createDefaultPerson
(
).withMarriedStatus
(
true
).build
(
);
Lors de l'exécution, une erreur sera levée :
2.
3.
Exception in
thread "main"
java.lang.IllegalArgumentException: You can't be married and single at the same time!
at fr.mistertie.exemples.fctfi.model.Person$PersonBuilder.build(Person.java:94)
at fr.mistertie.exemples.fctfi.service.PersonService.main(PersonService.java:42)
Ainsi, l'usage du builder pattern me permet d'obtenir un certain nombre d'avantages en termes de :
-
lisibilité :
- Les caractéristiques de l'objet créé sont bien plus faciles à déterminer puisque la sémantique est portée par les méthodes chaînées ; plus de noms de fabriques interminables ou de constructeurs aux multiples paramètres,
- La construction reste sommaire de par l'usage des méthodes chaînées ;
-
usage :
- Je dispose de bien plus de flexibilité pour introduire mes variations ; je redéfinis seulement ce qui m'intéresse via l'usage des méthodes chaînées,
- Je peux dorénavant introduire des vérifications dès l'instanciation de mon objet et m'assurer ainsi de sa validité,
- Je n'ai plus à me rappeler de l'ordre des paramètres du constructeur ; je fais ce que bon me semble ;
-
robustesse :
- Contrairement à la technique dite du Java Bean , l'usage du builder pattern est thread-safe puisque l'instanciation se fait au travers du constructeur traditionnel lors de l'appel final. De plus, ce dernier se base sur les variables d'instance du bâtisseur,
- Si je change l'ordre des paramètres, je n'impacte aucune de mes constructions.
L'inconvénient principal de ce pattern est sa verbosité ; il oblige à écrire un bâtisseur pour chaque classe dont on souhaite en faire profiter.
IV. Optimisations du Builder Pattern▲
IV-A. Gestion de l'héritage▲
L'exemple que nous poursuivons depuis le début de cet article ne fait pas usage de l'héritage. Or, ce cas d'utilisation courant induit quelques modifications sur notre bâtisseur. Découvrons-les en simplifiant un instant notre exemple. Soit la classe suivante :
2.
3.
4.
5.
public
class
Parent {
private
int
fieldA;
private
int
fieldB;
}
Le bâtisseur associé doit maintenant vous sembler familier :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
public
static
class
ParentBuilder {
private
int
fieldA =
0
;
private
int
fieldB =
0
;
private
ParentBuilder
(
) {
}
public
static
ParentBuilder createDefaultParent
(
) {
return
new
ParentBuilder
(
);
}
public
Parent build
(
) {
return
new
Parent
(
fieldA, fieldB);
}
public
ParentBuilder withFieldA
(
final
int
fieldA) {
this
.fieldA =
fieldA;
return
this
;
}
public
ParentBuilder withFieldB
(
final
int
fieldB) {
this
.fieldB =
fieldB;
return
this
;
}
protected
int
getFieldA
(
) {
return
fieldA;
}
protected
int
getFieldB
(
) {
return
fieldB;
}
}
Notez cependant deux subtilités :
- le constructeur par défaut du bâtisseur a la visibilité protected ;
- les variables d'instances du bâtisseur ont des accesseurs avec la visibilité protected.
Nous verrons par la suite l'origine de ces dernières. Mais pour l'instant, nous pouvons déjà faire un usage simple de ce bâtisseur, comme vu dans le chapitre précédent :
2.
final
Parent parent =
ParentBuilder.createDefaultParent
(
).build
(
);
final
Parent parent =
ParentBuilder.createDefaultParent
(
).withFieldA
(
2
).withFieldB
(
14
).build
(
);
Imaginons maintenant que cette classe est étendue par une autre :
2.
3.
4.
5.
6.
7.
8.
9.
public
class
Child extends
Parent {
private
int
fieldC;
public
Child
(
final
int
fieldA, final
int
fieldB, final
int
fieldC) {
super
(
fieldA, fieldB);
this
.fieldC =
fieldC;
}
}
Outre un champ supplémentaire, la classe fille possède un constructeur faisant appel à celui de la classe mère. Voyons maintenant à quoi ressemble le bâtisseur d'une telle classe.
Ce dernier possède deux variables d'instance ; l'une, attendue, concerne le membre fieldC de la classe fille. L'autre, est une instance du bâtisseur du parent, avec sa valeur par défaut.
2.
3.
4.
5.
public
static
class
ChildBuilder {
private
ParentBuilder parentBuilder =
ParentBuilder.createDefaultParent
(
);
private
int
fieldC =
0
;
}
De manière similaire, on trouve deux méthodes de surcharge ; l'une pour le champ de la classe fille et l'autre pour le bâtisseur parent.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
public
static
class
ChildBuilder {
public
ChildBuilder withParentBuilder
(
final
ParentBuilder parentBuilder) {
this
.parentBuilder =
parentBuilder;
return
this
;
}
public
ChildBuilder withFieldC
(
final
int
fieldC) {
this
.fieldC =
fieldC;
return
this
;
}
}
La méthode initialisant la construction ne change pas dans sa forme. Mais c'est elle qui nécessite que le constructeur sans arguments de la classe mère ait la visibilité protected. Enfin, la méthode de finalisation fait usage des variables d'instances des deux bâtisseurs. D'où la présence des accesseurs dans la classe mère.
2.
3.
4.
5.
6.
7.
8.
9.
10.
public
static
class
ChildBuilder {
public
static
ChildBuilder createDefaultChild
(
) {
return
new
ChildBuilder
(
);
}
public
Child build
(
) {
return
new
Child
(
parentBuilder.getFieldA
(
), parentBuilder.getFieldB
(
), fieldC);
}
}
Avec une telle gestion des bâtisseurs, je peux maintenant mettre en œuvre plusieurs scénarios :
- créer un enfant par défaut ;
- créer un enfant en surchargeant uniquement le champ fieldC ;
- créer un enfant en surchargeant les champs parents.
2.
3.
final
Child child =
ChildBuilder.createDefaultChild
(
).build
(
);
final
Child child =
ChildBuilder.createDefaultChild
(
).withFieldC
(
12
).build
(
);
final
Child child =
createDefaultChild
(
).withParentBuilder
(
createDefaultParent
(
).withFieldA
(
14
)).withFieldC
(
12
).build
(
);
Les sorties consoles nous montrent bien que le résultat est celui attendu :
2.
3.
Child
(
super
=
Parent
(
fieldA
=
0
, fieldB
=
0
), fieldC
=
0
)
Child
(
super
=
Parent
(
fieldA
=
0
, fieldB
=
0
), fieldC
=
12
)
Child
(
super
=
Parent
(
fieldA
=
14
, fieldB
=
0
), fieldC
=
12
)
IV-B. Partage des bâtisseurs▲
Si vous utilisez couramment les bâtisseurs, vous mourez probablement d'envie de me signaler qu'à l'usage, ce pattern peut amener à dupliquer du code. Reprenons notre exemple à base de personnes et imaginons que, dans un test unitaire, nous souhaitions en créer deux quasiment identiques :
2.
final
Person paulSpencer =
PersonBuilder.createDefaultPerson
(
).withAge
(
18
).withFirstname
(
"Paul"
).withLastname
(
"Spencer"
).build
(
);
final
Person paulJones =
PersonBuilder.createDefaultPerson
(
).withAge
(
18
).withFirstname
(
"Paul"
).withLastname
(
"Jones"
).build
(
);
L'exemple est ici volontairement simpliste, mais prend tout son sens lorsque le nombre d'appels de méthodes chaînées communes augmente. On pourrait être tenté de faire les choses comme ceci :
2.
3.
final
PersonBuilder paulBuilder =
PersonBuilder.createDefaultPerson
(
).withAge
(
18
).withFirstname
(
"Paul"
);
final
Person paulSpencerJr =
paulBuilder.withLastname
(
"Spencer"
).build
(
);
final
Person paulJonesJr =
paulBuilder.withLastname
(
"Jones"
).build
(
);
Cela fonctionne dans ce cas, mais uniquement, car la méthode chaînée utilisée (i.e. withLastName() ) est la même ! Si je surcharge d'autres valeurs, le résultat sera plus incertain :
2.
3.
final
PersonBuilder maryBuilder =
PersonBuilder.createDefaultPerson
(
).withAge
(
18
).withFirstname
(
"Mary"
);
final
Person marySpencer =
maryBuilder.withChildren
(
2
).withFemaleChildren
(
2
).build
(
);
final
Person maryJones =
maryBuilder.withLastname
(
"Jones"
).build
(
);
Dans le code ci-dessus, le deuxième objet ne s'attend pas à obtenir une instance avec deux filles, mais seulement un objet de 18 ans s'appelant Mary Jones :
2.
Person [id
=
1
, married
=
false, single
=
true, widowed
=
false, age
=
18
, children
=
2
, femaleChildren
=
2
, grandchildren
=
0
, maleChildren
=
0
, firstname
=
Mary, lastname
=
Golant, nickname
=
Riri]
Person [id
=
1
, married
=
false, single
=
true, widowed
=
false, age
=
18
, children
=
2
, femaleChildren
=
2
, grandchildren
=
0
, maleChildren
=
0
, firstname
=
Mary, lastname
=
Jones, nickname
=
Riri]
Pour parer à ce type de mésaventure, il faut optimiser son bâtisseur. Nous ajoutons un nouveau constructeur qui prend un bâtisseur en entrée et en copie les données.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
public
static
PersonBuilder createFrom
(
final
PersonBuilder prototype) {
final
PersonBuilder builder =
new
PersonBuilder
(
);
builder.married =
prototype.married;
builder.single =
prototype.single;
builder.widowed =
prototype.widowed;
builder.age =
prototype.age;
builder.children =
prototype.children;
builder.femaleChildren =
prototype.femaleChildren;
builder.grandchildren =
prototype.grandchildren;
builder.id =
prototype.id;
builder.maleChildren =
prototype.maleChildren;
builder.firstname =
prototype.firstname;
builder.lastname =
prototype.lastname;
builder.nickname =
prototype.nickname;
return
builder;
}
On peut ainsi facilement partager certaines étapes de nos constructions :
2.
3.
final
PersonBuilder shareableMaryBuilder =
PersonBuilder.createDefaultPerson
(
).withAge
(
18
).withFirstname
(
"Mary"
);
final
Person marySpencerJr =
PersonBuilder.createFrom
(
shareableMaryBuilder).withChildren
(
2
).withFemaleChildren
(
2
).build
(
);
final
Person maryJonesJr =
PersonBuilder.createFrom
(
shareableMaryBuilder).withLastname
(
"Jones"
).build
(
);
Le résultat est bien celui attendu :
2.
Person [id
=
1
, married
=
false, single
=
true, widowed
=
false, age
=
18
, children
=
2
, femaleChildren
=
2
, grandchildren
=
0
, maleChildren
=
0
, firstname
=
Mary, lastname
=
Golant, nickname
=
Riri]
Person [id
=
1
, married
=
false, single
=
true, widowed
=
false, age
=
18
, children
=
0
, femaleChildren
=
0
, grandchildren
=
0
, maleChildren
=
0
, firstname
=
Mary, lastname
=
Jones, nickname
=
Riri]
Notez qu'à la place du constructeur par copie, j'aurais pu utiliser un clonage classique. Cette technique à l'avantage de déléguer l'écriture du code de la copie à Java.
IV-C. Réduction de la verbosité▲
Nous l'avons vu précédemment, le principal inconvénient du Builder Pattern est sa verbosité. Pour réduire cette dernière, nous allons voir ensemble ce qu'une bibliothèquecomme lombok peut faire pour nous.
Lombok est une bibliothèque qui, à partir d'un ensemble d'annotations, se propose d'écrire tout le code rébarbatif à notre place. Plus précisément, elle intervient durant la phase de compilation pour modifier le code source et y ajouter certaines méthodes.
Si vous désirez en savoir plus ou n'êtes pas familier de cette bibliothèque, je vous conseille ce tutoriel, par Thierry Leriche-Dessirier ainsi que ce post qui explique son fonctionnement.
Lombok ne propose pas d'annotation par défaut pour mettre en œuvre le Builder Pattern . En revanche, c'est le cas de la bibliothèque d'extensions lombok-pg . Voyons ensemble comment faire. Tout d'abord, notre classe s'en trouve simplifiée :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
@Builder
(
prefix =
"with"
)
public
class
ThinPerson {
private
final
int
id;
private
boolean
married;
private
boolean
single;
private
boolean
widowed;
private
int
age;
private
int
children;
private
int
femaleChildren;
private
int
grandchildren;
private
int
maleChildren;
private
String firstname;
private
String lastname;
private
String nickname;
}
Nous avons posé l'annotation @Builder sur notre classe. C'est cette dernière qui va nous permettre de générer le bâtisseur associé. Grâce à l'attribut prefix , nous avons précisé que les méthodes de surcharge devront commencer par with . Si vous regardez dans la vue Outline de votre EDI , vous pouvez observer que Lombok a généré pas mal de choses. Nous y reviendrons par la suite.
Voyons maintenant l'usage que nous pouvons faire de ce bâtisseur. Essayons de créer une personne par défaut :
final
ThinPerson henry =
ThinPerson.thinPerson
(
).build
(
);
Nous faisons usage de la méthode statique thinPerson() qui initialise la construction. Elle équivaut à la méthode createDefaultPerson() vue précédemment. Nous faisons également usage de la méthode build() qui finalise la construction. Néanmoins, ce code ne compile pas (« The method build() is undefined for the type ThinPerson.IdDef » ), car le bâtisseur de lombok oblige à (re)définir tous les champs finaux, ce qui est le cas de notre champ « id ». Il nous faut donc mettre à jour notre code :
final
ThinPerson henry =
ThinPerson.thinPerson
(
).withId
(
12
).build
(
);
La surcharge des autres champs n'a alors rien de bien compliqué :
final
ThinPerson joan =
ThinPerson.thinPerson
(
).withId
(
12
).withFirstname
(
"Joan"
).withLastname
(
"Baez"
).build
(
);
Au premier abord, le bâtisseur proposé par Lombok semble donc faire le travail demandé. Mais à y regarder de plus près, les inconvénients sont nombreux en termes de :
- lisibilité : il est impossible de renommer la méthode qui initialise la construction. Or, le nom de classe qui est utilisé est loin d'être le plus explicite ;
- usage : il est impossible de créer une instance sans redéfinir les champs finaux ;
-
robustesse :
- il est impossible d'introduire une quelconque validation dans la méthode de finalisation,
- il n'est pas possible de partager les bâtisseurs, car d'une part, ces derniers sont privés et d'autre part, ils font usage de la première méthode vue ensemble (ex. : les méthodes de surcharge retournent l'instance en cours de création),
- il n'y a pas d'intelligence dans les valeurs par défaut des variables d'instance. En effet, lombok ne se base que sur le type de la variable pour en déduire la valeur associée. Un entier sera donc initialisé à 0, un objet à null , etc. Ce qui n'a pas forcément de sens selon le contexte.
Vous l'aurez compris, si cette solution réduit la verbosité de votre code, elle est loin d'être optimale et doit être réservée à des cas d'utilisation très simples où les contraintes ne vous impacteront pas. Dans notre cas, je considère qu’elle n'est pas adaptée et reste donc sur les bâtisseurs obtenus précédemment.
Une alternative plus souple consiste à faire usage du générateur de code de son EDI préféré.
V. Aller plus loin ; les fluent interfaces▲
Maintenant que nous sommes arrivés à une solution satisfaisante, essayons d'y ajouter la touche finale. En l'occurrence, il s'agit d'y rajouter une pincée de lisibilité via les fluent interfaces (ou interfaces fluides). Ces dernières sont des façades sémantiques qui peuvent par exemple, venir se greffer au-dessus d'un code existant pour :
- réduire sa verbosité ;
- améliorer sa lisibilité ;
- exprimer son contenu.
Contrairement aux idées reçues, les fluent interfaces ne correspondent pas simplement au chaînage des méthodes. Elles introduisent un lien sémantique entre les objets afin d'en exprimer le contenu et surtout la relation. Elles sont largement utilisées pour créer des DSL (Domain Specific Languages ). L'idée est qu'un code en faisant usage doit pouvoir être lu comme une phrase en anglais.
Reprenons l'exemple suivi tout au long de cet article. Disons que nous souhaitons proposer une API pour créer des couples de personnes. Nous pourrions utiliser notre bâtisseur pour ce faire :
2.
final
Person barackObama =
PersonBuilder.createDefaultPerson
(
).withFirstname
(
"Barack"
).withLastname
(
"Obama"
).withMarriedStatus
(
true
).withSingleStatus
(
false
).build
(
);
final
Person michelleObama =
PersonBuilder.createDefaultPerson
(
).withFirstname
(
"Michelle"
).withLastname
(
"Obama"
).withMarriedStatus
(
true
).withSingleStatus
(
false
).build
(
);
L'usage correspond à ce que nous avons vu auparavant, mais s'avère relativement verbeux, peu lisible et n'exprime pas le fait que les deux personnes sont mariées. Nous allons donc créer une façade sémantique pour cacher cette complexité et exprimer la relation entre les objets créés :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
public
class
PersonFluentInterface {
private
final
Person fiance;
private
PersonFluentInterface
(
final
Person fiance) {
this
.fiance =
fiance;
}
public
static
PersonFluentInterface aGuyNamed
(
final
String firstname, final
String lastname) {
return
new
PersonFluentInterface
(
PersonBuilder.createDefaultPerson
(
).withFirstname
(
firstname).withLastname
(
lastname).build
(
));
}
public
static
Person aGirlNamed
(
final
String firstname) {
return
PersonBuilder.createDefaultPerson
(
).withFirstname
(
firstname).build
(
);
}
public
Set<
Person>
isMarriedTo
(
final
Person fiancee) {
this
.fiance.setMarried
(
true
);
this
.fiance.setSingle
(
false
);
fiancee.setMarried
(
true
);
fiancee.setSingle
(
false
);
fiancee.setLastname
(
this
.fiance.getLastname
(
));
final
Set<
Person>
couple =
new
HashSet<>(
2
);
couple.add
(
fiancee);
couple.add
(
this
.fiance);
return
couple;
}
}
On remarque quelques similitudes avec le bâtisseur, mais ne nous y trompons pas, les contraintes ne sont pas les mêmes. À vrai dire, il y en a même moins ; j'ai par exemple décidé d'offrir une méthode statique pour démarrer mon chaînage et deux méthodes finales. L'une me permet d'obtenir une fille et l'autre de marier le garçon du chaînage avec une autre personne ; le couple obtenu est ainsi retourné. Par l'usage des imports statiques, je peux ensuite obtenir l'usage suivant :
final
Set<
Person>
theObamas =
aGuyNamed
(
"Barack"
, "Obama"
).isMarriedTo
(
aGirlNamed
(
"Michelle"
));
Ici, le contenu de ma ligne est rempli de sémantique ; j'exprime qu'un garçon nommé Barack Obama est marié avec une fille appelée Michelle, peu importe les mécanismes sous-jacents. J'exprime cette relation et réduis la verbosité de mon bâtisseur ; c'est idéal pour que l'utilisateur de mon API sache rapidement s'en servir.
À travers ce court exemple volontairement simpliste, on découvre l'intérêt des fluent interfaces . Elles ont vocation à améliorer la lisibilité et à faciliter l'apprentissage des API . Elles doivent être utilisées avec parcimonie et être réservées à cet usage. Enfin, sachez qu'il n'est pas rare de prendre du temps pour construire une bonne interface fluent ; plusieurs itérations peuvent être nécessaires.
À titre d'exemples, voyez les interfaces proposées par la bibliothèque FEST ( Java ) :
2.
3.
4.
File xFile =
writeFile
(
"xFile"
, "The Truth Is Out There"
);
assertThat
(
xFile).exists
(
).isFile
(
).isRelative
(
);
assertThat
(
xFile).canRead
(
).canWrite
(
);
assertThat
(
contentOf
(
xFile)).startsWith
(
"The Truth"
).contains
(
"Is Out"
).endsWith
(
"There"
);
Ainsi que par l'outil Gatling ( Scala ) :
2.
3.
4.
5.
.exec
(
http
(
"request_1"
)
.get
(
"/"
)
.headers
(
headers_1)
.check
(
status.is
(
302
)
)
VI. Conclusion▲
Dans ce billet, je vous ai présenté l'antipattern « Telescoping Constructor » et les alternatives disponibles pour y remédier, listant les avantages et inconvénients de chacune. La solution du bâtisseur semble être la plus appropriée dans bien des usages et je vous la recommande vivement, particulièrement dans l'écriture de vos tests unitaires. C'est d'autant plus vrai que l'arrivée de Java 8 et des lambdas devrait permettre de faire encore mieux (voir ce lien ).
Enfin, je vous ai donné un aperçu des Fluent Interfaces et vous invite donc à creuser ce sujet pour en connaître davantage (ce dernier fait parfois polémique, cf. « Les fluent interfaces sont le diable » ).
VII. Remerciements▲
Je remercie les membres de la communauté developpez.com pour leur relecture attentive et leurs conseils avisés qui ont permis d'enrichir l'article initial. Je remercie particulièrement Laurent.B , Mickael Baron , Thierry Leriche-Dessirier et Nemek . Je remercie également Phanloga pour sa relecture orthographique.