Introduction
En programmation orientée objet, la création d’objets est parfois laborieuse: elle peut être le résultat de la prise en compte de nombreux paramètres, de configurations contextuelles et d’un processus de construction parfois tordu.
Une première étape consiste à internaliser la création de l’objet : plutôt que de laisser le code client se dépatouiller avec la construction, on place toute la logique afférente au sein d’une méthode généralement (bien) nommée constructeur.
Lorsque la logique de création devient trop complexe, ou hautement paramétrable, la réduire à un appel de méthode ne suffit plus. On peut alors utiliser le design pattern builder: la création d’un objet est à nouveau externalisée, en la confiant à un objet spécialisé dans la gestion des étapes de la construction. Le builder expose un ensemble de méthodes permettant à l’utilisateur de décrire progressivement la construction.
Par exemple, considérons la création d’un objet représentant une requête SQL.
public class QueryBuilder {
public QueryBuilder columns(String[] columnNames) { ... }
public QueryBuilder all() { ... }
public QueryBuilder from(String table) { ... }
public QueryBuilder where(Condition predicate) { ... }
public Query build() { ... }
}
La plupart des méthodes modifient l’état interne du builder, afin de raffiner la description de l’objet à créer. Ces méthodes retournent le builder lui-même, ce qui permet d’enchaîner les appels. L’utilisation du builder se conclut par l’appel à build, qui aboutit à la création de l’objet désiré.
Query q = new QueryBuilder()
.columns("first_name", "last_name")
.from("people")
.build();
Pour l’utilisateur, la décomposition de la création en une séquence d’étapes facilite sa compréhension. Il est également plus facile de manipuler programmatiquement le builder, par exemple par des appels conditionnels à ses méthodes.
Ce design pattern est très présent en Java, principalement parce que ce langage ne supporte pas les paramètres nommés ni optionnels.
Dans de nombreux cas, cependant, une question demeure: comment s’assurer que le builder est correctement utilisé? Autrement dit, comment éviter que l’état du builder soit invalide au moment où la méthode build est invoquée? Dans notre exemple:
- On souhaite proscrire l’utilisation conjointe des méthodes
columnsetall(cette dernière permettant la sélection de toutes les colonnes, elle est contradictoire avec la sélection d’un sous-ensemble des colonnes existantes); - Le choix d’une table sur laquelle appliquer la sélection est obligatoire;
- La specification d’une clause
whereest optionnelle.
En général, la vérification des propriétés est réalisée lorsque la méthode build est invoquée: une exception est levée si l’état du builder n’est pas approprié. Ça permet d’éviter la construction d’objets invalides, mais cette vérification à l’exécution oblige à une gestion des problèmes qui aurait pu être évitée.
C’est ici que le type-safe builder fait son entrée.
Type-safe builder
L’idée de cette variante du design pattern builder est simple: s’arranger pour que l’état du builder soit assuré par le compilateur, et soit donc garanti à la compilation plutôt qu’à l’exécution.
Pour que le compilateur soit responsable de cette tâche, on représente explicitement l’état du builder par un ensemble d’interfaces. Ces interfaces, si elles définissent des méthodes, représentent avant tout des types. À chaque type, on associe une étape dans le processus de création de l’objet. À chaque étape, ses méthodes permettent de transiter vers une étape suivante, et ainsi de suite jusqu’à parvenir à une étape mature, qui permet la création effective de l’objet désiré.
Il s’agit donc d’une combinaison des design patterns builder et state.
Créons les interfaces correspondant aux différentes étapes de création d’un objet Query:
interface SelectStep {
FromStep column(String[] columnNames);
FromStep all();
}
interface FromStep {
WhereStep from(String table);
}
interface WhereStep extends BuildStep {
BuildStep where(Condition predicate);
}
interface BuildStep {
Query build();
}
Cette structuration des étapes de construction permet de représenter les contraintes souhaitées:
Le choix entre un ensemble de colonnes et l’entièreté des colonnes est forcé en déclarant les méthodes columns et all au sein de la même interface. Ces méthodes mènent à la même étape suivante, et il ne sera plus possible de revenir sur la sélection.
La spécification de la table de référence est rendue obligatoire en faisant en sorte qu’on ne peut créer un BuildStep qu’en passant par un FromStep.
La spécification d’une clause where est rendue optionnelle en faisant de WhereStep un BuildStep: à cette étape, il est possible de préciser la clause ou de créer la requête.
Heureusement, toutes ces interfaces peuvent être implémentées par notre classe QueryBuilder. L’utilisateur n’exploitera plus directement ce type, mais se fiera toujours aux types définis par les interfaces. Nous forçons l’utilisateur à commencer sa spécification d’une requête par une méthode statique qui retourne le type correspondant à la première étape, à savoir SelectStep.
public class QueryBuilder
implements SelectStep
implements FromStep
implements WhereStep
implements BuildStep {
protected QueryBuilder() { }
public static SelectStep init() {
return new QueryBuilder();
}
public FromStep columns(String[] columnNames) { ... }
public FromStep all() { ... }
public WhereStep from(String table) { ... }
public BuildStep where(Condition predicate) { ... }
public Query build() { ... }
}
L’utilisateur n’a pas à se soucier des interfaces que nous avons introduites. Son code restera pratiquement inchangé, mais, à présent, il a la garantie lors de la compilation que celui-ci mènera à la construction d’une requête valide:
Query q = QueryBuilder.init()
.columns("first_name", "last_name")
.from("people")
.build();
Limitations
L’approche proposée ne vient pas sans quelques limitations.
Premièrement, le code du builder s’est complexifié. Principalement par l’ajout des interfaces définissant explicitement les étapes de constructions ainsi que leurs relations. Il s’agit donc essentiellement d’une complexité intrinsèque et non accidentelle. Par ailleurs, cette complexité n’est pas visible de l’utilisateur, qui ne voit que la guidance améliorée et la fiabilisation de la construction. Il n’en reste pas moins qu’à grande échelle, ces interfaces augmentent le coût de maintenance.
Pour préserver la simplicité du côté de l’utilisateur, celui-ci doit faire une croix sur le stockage des étapes intermédiaires dans des variables temporaires, dont le type correspond aux interfaces qu’on souhaite garder discrètes. Cela est moins vrai avec la possibilité d’utiliser var pour déclarer une variable en Java 10 ou supérieur.
Notre nouveau builder impose un ordre dans la description des requêtes qui n’existait pas au préalable. Selon les cas, cet ordre imposé peut s’avérer bienvenu, sans intérêt ou contraignant.
Enfin, représenter une machine à état par une hierarchie d’interface n’est pas la panacée. Dans des cas plus complexes, il faudra la remplacer par un ensemble de classes ou carrément choisir d’adopter un DSL.
Pour la description de requêtes, vous avez peut-être déjà entendu parlé d’un DSL appelé SQL… La boucle est bouclée.