https://d226lax1qjow5r.cloudfront.net/blog/blogposts/builder-pattern-with-inheritance-in-java/builder-pattern.png

Modèle de construction avec héritage en Java

Publié le August 3, 2022

Temps de lecture : 7 minutes

Cet article a été mis à jour en avril 2025

Introduction

L'instanciation d'un objet se fait généralement par l'intermédiaire d'un constructeur, qui transmet directement les paramètres requis. Par souci de concision, nous pouvons regrouper les paramètres connexes dans une classe et construire une instance de cette classe séparément, en la passant à l'objet principal en tant que paramètre.

Cela présente l'avantage de réduire le nombre de paramètres nécessaires à la construction d'un objet en la décomposant en plusieurs étapes. Cependant, l'utilisation d'un constructeur présente plusieurs inconvénients. Tout d'abord, l'ordre dans lequel les paramètres sont passés est important, ce qui peut être décourageant lorsqu'il y a beaucoup de paramètres, et encore plus source d'erreurs lorsque ces paramètres sont du même type (par exemple, String).

Il peut également y avoir des paramètres optionnels. Bien que nous puissions contourner ce problème en ayant plusieurs constructeurs avec des signatures différentes, cela ajoute une grande quantité d'informations et une charge cognitive pour l'utilisateur et le développeur. Cela devient également infaisable lorsque plusieurs paramètres optionnels sont du même type.

Le modèle Builder est un modèle de conception bien connu dans les langages orientés objet pour contrôler la construction d'objets. Comme beaucoup (peut-être la plupart) des modèles de conception, il existe pour combler une lacune dans le langage. Dans les langages avec des paramètres nommés (comme Kotlin, Scala, Python, C#, Ruby et bien d'autres), le besoin de constructeurs est réduit dans de nombreux cas.

L'utilisation du modèle du constructeur est une recommandation dans Effective Java, mais pour les langages avec des arguments nommés, sa valeur devient discutable (voir, par exemple, cet article qui explore son utilité dans Kotlin). cet article qui explore son utilité en Kotlin).

La discussion sur la mesure dans laquelle les paramètres optionnels rendent obsolète le modèle du constructeur dépasse le cadre de cet article. L'objectif est plutôt d'expliquer la logique de ce qui semble être une application compliquée du modèle de construction dans l'implémentation de l'API Messages récemment publiée dans le SDK Java de Vonage. Messages API récemment publiée dans le SDK Java de Vonage.

MessageRequest Classes

Pour modéliser les différents types de messages qui peuvent être envoyés par l'intermédiaire de l'API Messages APIune approche orientée objet est utilisée, où une classe est créée pour chaque combinaison valide de type de message et de service. Il existe une hiérarchie d'héritage à trois niveaux. Prenons l'exemple suivant :

  • MessageRequest

    • MmsRequest

      • MmsVcardRequest

MessageRequest et MmsRequest sont des classes abstraites, et MmsVcardRequest est la classe qui représente la combinaison de l'envoi d'une vCard par MMS.

La classe de base MessageRequest prend comme arguments dans son constructeur le canal et le type de message, qui sont définis par les sous-classes. Elle prend également en paramètre un Builder en tant que paramètre, où sont définis les principaux détails du message. Certains paramètres sont facultatifs, comme clientRefet tous les messages ont un expéditeur et un destinataire, qui sont donc déclarés dans la classe de base MessageRequest.

Toutefois, cette hiérarchie d'héritage est transparente pour l'utilisateur lorsqu'il construit un fichier MmsVcardRequestqui ressemble à ceci :

MmsVcardRequest message = MmsVcardRequest.builder()
        .from("447900090000").to("447900090001")
        .url("https://www.example.com/contact.vcf")
        .clientRef("vCard-msg-#1")
        .build();

Des exemples complets sont disponibles dans le échantillons de code.

Cela semble quelque peu élégant du point de vue de l'utilisateur, étant donné l'absence de paramètres de méthode/constructeur nommés et facultatifs en Java. Cependant, en coulisses, rendre le code ci-dessus possible avec une hiérarchie d'héritage nécessite une conception qui n'est pas immédiatement évidente ou intuitive du point de vue du développeur du SDK.

Les complications sont liées à l'imbrication des classes de constructeurs associées à chaque sous-classe de MessageRequest. Prenons l'exemple de MmsRequest à titre d'exemple. Il s'agit toujours d'un exemple abstrait, puisque nous ne faisons que définir la classe Channel.

Mais qu'en est-il de cette intimidante Builder intimidante ? La suite de cet article tentera de vous aider à y voir plus clair :

protected abstract static class Builder<M extends MmsRequest, B extends Builder<? extends M, ? extends B>> extends MessageRequest.Builder<M, B>

Le<M> Paramètres

La classe de base Builder dans MessageRequest prend comme paramètres le type de MessageRequest à construire, et le type de Builder.

La première (M) est facile à expliquer : public abstract M build() en MessageRequest.Builder est ce que l'utilisateur appelle une fois qu'il a fini de définir les paramètres, en leur renvoyant la sous-classe concrète appropriée. MessageRequest appropriée.

Bien sûr, cela pourrait être omis puisque l'héritage Java prend en charge les types de retour covariants. En d'autres termes, nous pourrions omettre M si nous le souhaitions et obtenir le même résultat du point de vue de l'utilisateur. Dans ce cas, l'expression MessageRequest.Builder se présente alors comme suit :

public abstract static class Builder<B extends Builder<? extends B>> {
    protected String from, to, clientRef;

    public B from(String from) {
        this.from = from;
        return (B) this;
    }

    public B to(String to) {
        this.to = to;
        return (B) this;
    }

    public B clientRef(String clientRef) {
        this.clientRef = clientRef;
        return (B) this;
    }

    public abstract MessageRequest build();
}

**

Nous devons alors nous rappeler de surcharger cette fonction dans les sous-classes, idéalement même dans les classes abstraites comme MmsRequest.Builderde la manière suivante :

protected abstract static class Builder<B extends Builder<? extends B>> extends MessageRequest.Builder<B> {
    String url;

    protected B url(String url) {
        this.url = url;
        return (B) this;
    }

    @Override
    public abstract MmsRequest build();
}

La classe concrète MmsVcardRequest.Builder serait identique puisque c'est là que nous déclarons le type :

public static final class Builder extends MmsRequest.Builder<Builder> {
    Builder() {}

    public Builder url(String url) {
        return super.url(url);
    }

    @Override
    public MmsVcardRequest build() {
        return new MmsVcardRequest(this);
    }
}

Pourquoi donc ajouter ce paramètre apparemment redondant si nous pouvons utiliser des types de retour covariants ? C'est simplement pour s'assurer que nous n'oublions pas de surcharger le type de retour.

En générant le type de retour, le compilateur s'assure que la méthode build() a la bonne signature. En l'absence de cela, la version suivante de MmsVcardRequest.Builder serait également valide, mais inexacte :

public static final class Builder extends MmsRequest.Builder<Builder> {
    Builder() {}

    public Builder url(String url) {
        return super.url(url);
    }

    @Override
    public MmsRequest build() {
        return new MmsVcardRequest(this);
    }
}

Puisque nous nous sommes souvenus de surcharger le type de retour de MmsRequest.Builder#build()le compilateur détectera ce qui suit build() signature :

@Override
public MessageRequest build() {
    return new MmsVcardRequest(this);
}

avec un message d'erreur :

'build()' dans 'com.vonage.client.messages.mms.MmsVcardRequest.Builder' se heurte à 'build()' dans 'com.vonage.client.messages.mms.MmsRequest.Builder' ; tentative d'utilisation d'un type de retour incompatible.

C'est uniquement parce que nous n'avons pas oublié de remplacer manuellement la signature de la méthode dans le fichier MmsRequest.Builder. Si nous ne l'avions pas fait, il n'y aurait pas eu d'erreur. En paramétrant le type de retour, nous sommes obligés de déclarer le bon type, et nous n'avons pas besoin de surcharger la méthode build() dans les sous-classes de MessageRequest - le compilateur s'en charge pour nous.

Le<B> Paramètres

Revenons à ce dernier paramètre de Builder - B. Si vous êtes familier avec le modèle du constructeur, vous savez que chaque appel de méthode sur le constructeur renvoie le constructeur lui-même, de sorte que vous pouvez enchaîner couramment les appels de méthode pour définir facilement des paramètres.

Cela fonctionne bien lorsqu'il n'y a pas d'héritage, mais nous voulons que l'utilisateur puisse définir des paramètres dans n'importe quel ordre - après tout, n'est-ce pas l'une des principales raisons d'utiliser le modèle du constructeur ? Nous devons donc nous assurer que la classe de constructeur la plus spécifique est renvoyée, quelles que soient les méthodes appelées en premier.

Sinon, nous perdons la possibilité d'enchaîner les appels de méthode et nous devrions recourir à la distribution de la valeur de retour à chaque fois - ce qui ruine la fluidité que nous essayons d'obtenir en utilisant un constructeur.

Pour que cela soit plus clair, considérons le cas où nous renvoyons naïvement le constructeur actuel :

public abstract static class Builder<M extends MessageRequest> {
    protected String from, to clientRef;

    public Builder<M> from(String from) {
        this.from = from;
        return this;
    }

    public Builder<M> to(String to) {
        this.to = to;
        return this;
    }

    public Builder<M> clientRef(String clientRef) {
        this.clientRef = clientRef;
        return this;
    }

    public abstract M build();
}

Cela se compile bien et est utilisable. Ensuite, le constructeur de MmsRequestdevient alors le suivant :

protected abstract static class Builder<M extends MmsRequest> extends MessageRequest.Builder<M> {
    String url;

    protected Builder<M> url(String url) {
        this.url = url;
        return this;
    }
}

Enfin, la sous-classe concrète (si l'on s'en tient à l'exemple de MmsVcardRequest ) devient la suivante :

public static final class Builder extends MmsRequest.Builder<MmsVcardRequest> {
    Builder() {}

    /**
    * (REQUIRED)
    * Sets the URL of the vCard attachment. Supports only <code>.vcf</code> file extension.
    *
    * @param url The URL as a string.
    * @return This builder.
    */
    public Builder url(String url) {
        return (Builder) super.url(url);
    }

    @Override
    public MmsVcardRequest build() {
        return new MmsVcardRequest(this);
    }
}

Remarquez que nous avons dû convertir le type de retour de super.url(url) vers ce constructeur, puisque le type de retour de la super-méthode est com.vonage.messages.mms.MmsRequest.Builderet non com.vonage.messages.mms.MmsVcardRequest.Builder.

Notez que nous n'avons remplacé cette méthode que pour lui ajouter des Javadocs, et non pour modifier sa fonctionnalité. Mais cela met parfaitement en évidence le problème que le type Builder paramétré tente de résoudre. Pour illustrer notre propos, essayons d'utiliser ce constructeur :

MmsVcardRequest message = MmsVcardRequest.builder()
    .from("447900090000").to("447900090001")
    .url("https://www.example.com/path/to/contact.vcf")
    .build();

Le compilateur donne une erreur : Cannot resolve method 'url' in 'Builder'. En revanche, le code suivant fonctionne :

MmsVcardRequest message = MmsVcardRequest.builder()
    .url("https://www.example.com/path/to/contact.vcf")
    .from("447900090000").to("447900090001")
    .build();

Qu'est-ce que cela donne ? Il s'agit exactement des mêmes informations, mais les méthodes sont appelées dans un ordre différent. L'intérêt d'un constructeur n'est-il pas de permettre une certaine flexibilité dans l'ordre dans lequel les méthodes sont appelées ? Pour une bonne expérience utilisateur, il est nécessaire d'Account pour ces situations.

L'utilisateur ne devrait pas avoir à se préoccuper de savoir quelles classes contribuent à quelles propriétés - il s'agit là de détails internes de mise en œuvre. Une solution verbeuse consiste à surcharger chaque méthode de la sous-classe concrète de MessageRequest.Builder. Par exemple, dans MmsVcardRequestnous aurions :

public static final class Builder extends MmsRequest.Builder<MmsVcardRequest> {
    Builder() {}

    public Builder from(String from) {
        return (Builder) super.from(from);
    }

    public Builder to(String to) {
        return (Builder) super.to(to);
    }

    public Builder clientRef(String clientRef) {
        return (Builder) super.clientRef(clientRef);
    }

    public Builder url(String url) {
        return (Builder) super.url(url);
    }

    @Override
    public MmsVcardRequest build() {
        return new MmsVcardRequest(this);
    }
}

Mais cela va à l'encontre de l'objectif de l'héritage, puisque nous répétons des informations ! C'est pourquoi nous paramétrons le type du constructeur. Le compilateur s'assure que le sous-type le plus concret est toujours renvoyé.

Cependant, comme nos classes de constructeurs peuvent être étendues, nous devons également l'encoder dans les déclarations de paramètres, d'où l'utilisation de bound B extends Builder<? extends M, ? extends B> par opposition à B extends Builder<M, B>.

Malheureusement, nous devons toujours convertir le type de retour du constructeur en B chaque fois que nous appelons return thismais d'après ce que je sais, il s'agit d'une limitation du compilateur. Heureusement, le casting n'est nécessaire que dans les classes abstraites de constructeurs, et non dans les types concrets.

Conclusion

J'espère que cet article vous a appris un modèle quelque peu utile (bien qu'apparemment alambiqué) pour l'utilisation du modèle Builder lorsqu'il y a des classes abstraites et de l'héritage impliqués. Peut-être qu'un jour, de tels schémas deviendront obsolètes lorsque le langage offrira de meilleures façons d'instancier les objets. En attendant, nous avons au moins les génériques pour nous aider, aussi intimidants qu'ils puissent être à travailler parfois !

La participation de la communauté est toujours la bienvenue. N'hésitez pas à nous rejoindre sur le Communauté Vonage Slack ou envoyez-nous un message sur sur Twitter. Si vous avez des suggestions d'amélioration, des améliorations ou si vous repérez un bogue, n'hésitez pas à soulever un problème sur GitHub.


Partager:

https://a.storyblok.com/f/270183/400x400/46a3751f47/sina-madani.png
Sina MadaniVonage Ancien membre de l'équipe

Sina est développeur Java chez Vonage. Il est issu d'une formation universitaire et est généralement curieux de tout ce qui touche aux voitures, aux ordinateurs, à la programmation, à la technologie et à la nature humaine. Pendant son temps libre, on peut le trouver en train de marcher ou de jouer à des jeux vidéo compétitifs.