vendredi 14 février 2014

JavaBean: la justification du mauvais orienté objet

Il y a quelques semaines, je découvrais le code suivant (à noter que les extraits de code présentés ici ne sont pas les codes réels, cependant les principes restent les mêmes) :
public class Situation {
     private int enfantsACharge;
     private int autresACharge;
     private boolean personneACharge;

     public int getEnfantsACharge() {
          return enfantsACharge;
     }
     public void setEnfantsACharge(int enfantsACharge) {
          this.enfantsACharge = enfantsACharge;
     }
     public int getAutresACharge() {
          return autresACharge;
     }
     public void setAutresACharge(int autresACharge) {
          this.autresACharge = autresACharge;
     }
     public boolean isPersonneACharge() {
          return personneACharge;
     }
     public void setPersonneACharge(boolean personneACharge) {
          this.personneACharge = personneACharge;
     }
}

Cette classe décrit la situation fiscale d’une personne, à savoir le nombre d’enfants à charge, le nombre d’autres personnes à charge et si elle a ou non des personnes à charge. Rien ne vous choque ?

Non? Continuons…

Un peu plus loin, un service :
public class SituationService {
     public Situation createSituation(int enfantsACharge, int autresACharge){
          Situation situation = new Situation();
          situation.setEnfantsACharge(enfantsACharge);
          situation.setAutresACharge(autresACharge);
          situation.setPersonneACharge(enfantsACharge !=0 || autresACharge != 0);
          return situation;
     }
}
Toujours rien ne vous choque ?

Si vous répondez une nouvelle fois non, c’est que les JavaBeans ont eu raison de vous et que le domaine anémique (Anemic Domain Model) est votre quotidien. Vous ne faites donc plus de l’orienté objet, mais un ersatz procédural dans lequel les objets sont, soit des conteneurs de données, soit des conteneurs de méthodes. Bref, de la mauvaise programmation orientée objet.

Voici une version plus correcte et plus orientée objet de la situation fiscale :
public class Situation {
     private int enfantsACharge;
     private int autresACharge;

     public void setEnfantsACharge(int enfantsACharge) {
          if(enfantsACharge < 0){
               throw new IllegalArgumentException("Le nombre d'enfants à charge doit être positif");
          }
          this.enfantsACharge = enfantsACharge;
     }
     public int getEnfantsACharge() {
          return enfantsACharge;
     }
     public void setAutresACharge(int autresACharge) {
          if(autresACharge < 0){
               throw new IllegalArgumentException("Le nombre d'autres personnes à charge doit être positif");
          }
          this.autresACharge = autresACharge;
     }
     public int getAutresACharge() {
          return autresACharge;
     }
     public boolean isPersonnesACharge(){
          return enfantsACharge > 0 && autresACharge > 0;
     }
}
Quant à la méthode createSituation de SituationService, elle disparaît du service (lequel a peut-être d'autres raisons, légitimes, d'exister).

Cette version apporte quelques améliorations :

  • Il est impossible de passer un nombre négatif pour les enfants ou les autres à charge. Les setters vérifient la qualité des paramètres et ne les acceptent que s’ils sont corrects.
  • Le statut "personnesACharge" est déterminé par l’objet lui-même. Avant, sa valeur dépendait d’un calcul extérieur et rien ne garantissait qu’il soit calculé correctement. Accessoirement, l’attribut "personnesACharge" a disparu. Il reste juste un accesseur (qui suit la standard JavaBean, hé oui…).
Au final, ce modèle est plus robuste que le premier. Il n’y a plus moyen de créer un objet Situation qui n’aurait aucune cohérence (3 enfants à charge, mais personnesACharge false). Et qu’on ne vienne pas me dire que le service permettait d'en faire autant, puisque l’objet pouvait être créé à l'extérieur du service.

La première fois que j’ai présenté cette approche à un collègue, il s’est écrié : « si tu fais ça, tu vas casser le contrat JavaBean ».

A cela, je réponds deux choses :
  1. "oui, et alors ?" parce que les JavaBeans ne sont pas une religion (encore moins un contrat) et que les frameworks qui prétendent en avoir besoin (Hibernate, Spring) mentent un peu (je verrai ça une autre fois).
  2. "de toute façon, la classe Situation du départ n’était pas non plus un JavaBean" : elle n’implémente pas Serializable (exercice: comptez le nombre de vos entités Hibernate/Jpa, soit disant des JavaBeans, qui implémentent Serializable).

Modèle robuste

La propriété la plus importante de l’orienté objet, c’est l’encapsulation : l’objet est le seul responsable de la qualité de ses attributs et décide seul des informations et des opérations qu’il veut exposer vers l’extérieur.

Pour continuer ma démonstration, je vais utiliser une classe plus simple, mais mal écrite : un rectangle.
public class Rectangle {
     private double longueur;
     private double largeur;

     public double getLongueur() {
          return longueur;
     }
     public void setLongueur(double longueur) {
          this.longueur = longueur;
     }
     public double getLargeur() {
          return largeur;
     }
     public void setLargeur(double largeur) {
          this.largeur = largeur;
     }
}
Soyons sérieux ! Ce n’est pas de l’orienté objet. Pourtant, nombreux sont ceux qui s'imaginent respecter l'encapsulation parce que les attributs sont private...

Récemment, un développeur me disait, un peu dépité, "je ne vois pas pourquoi on continue de mettre les attributs en privé si de toute façon les setters et les getters permettent de les modifier directement". Il avait raison !

Dans le cas présent, un objet Rectangle n’a aucun contrôle sur la qualité des propriétés qui lui sont injectées.
Rectangle r = new Rectangle() ;
r.setLongueur(-3.0) ; //Non, mais allô quoi...
C’est absurde. Sans pousser trop l’analyse du domaine, il est clair qu’un rectangle ne peut avoir une longueur ou une largeur négative, ni même égale à 0.

Là où je suis le plus étonné, c'est que de nombreux développeurs pensent que la spécification JavaBean leur impose d’avoir des setters ridicules.

Première étape

Nous commencerons donc par consolider notre modèle du domaine en réécrivant les setters de manière intelligente :
public class Rectangle {
       private double longueur;
       private double largeur;
       public double getLongueur() {
             return longueur;
       }
       public void setLongueur(double longueur) {
             if(longueur <= 0){
                    throw new IllegalArgumentException("La longueur doit être strictement positive");
             }
             this.longueur = longueur;
       }
       public double getLargeur() {
             return largeur;
       }
       public void setLargeur(double largeur) {
             if(largeur <= 0){
                    throw new IllegalArgumentException("La largeur doit être strictement positive");
             }
             this.largeur = largeur;
       }
}
Impossible à présent d'injecter des valeurs incorrectes dans notre objet, les setters y veillent.

Le choix de l'exception est logique. C'est une RuntimeException pour laquelle on ne fait normalement aucun try-catch. Si cette erreur arrive, c'est que le développeur n'a pas été vigilant: avant d'utiliser les paramètres, il devait les valider. Lorsqu'elle arrive, il n'y a rien qu'on puisse faire. Il fallait agir avant. De la même manière qu'on ne fait pas de try-catch pour des NullPointerException, mais qu'on vérifie les références suspectes en les comparant à null.

Cette classe ne permet pas d'injecter dans ses instances des valeurs incorrectes.

En fait, ce n'est pas tout à fait exact puisqu'une classe qui hériterait de Rectangle pourrait surcharger les setters et les réécrire sans la validation. La réplique à ce risque consiste à mettre les setters en final.

En dehors de ce risque, peut-on considérer qu'une instance de Rectangle sera désormais correcte?

Deuxième étape

La réponse à la question précédente est hélas! non, comme le démontre le code suivant:
Rectangle r = new Rectangle();
r.setLongueur(3.0);
System.out.println(r.getLargeur());
Réponse... 0 ! Soit une largeur inacceptable. L'objet n'est pas cohérent.

Moralité, si certaines propriétés doivent obligatoirement être remplies, la création de l'objet doit être atomique.

Le moyen le plus simple pour obtenir cette atomicité est de passer par un constructeur.
public class Rectangle {
       private double longueur;
       private double largeur;
       
       public Rectangle(double longueur, double largeur){
             if(longueur <= 0){
                    throw new IllegalArgumentException("La longueur doit être strictement positive");
             }
             if(largeur <= 0){
                    throw new IllegalArgumentException("La largeur doit être strictement positive");
             }
             this.largeur = largeur;
             this.longueur = longueur;
       }
       public double getLongueur() {
             return longueur;
       }
       public double getLargeur() {
             return largeur;
       }
       public double getSurface(){
             return longueur * largeur;
       }
}
Là, plus moyen de se tromper. Lorsqu'il est créé, un rectangle a une longueur et une largeur, toutes deux strictement positives.

J'ai aussi introduit un autre aspect: l'objet est immutable. Une fois le rectangle créé avec une longueur et une largeur données, aucune de ses deux dimensions ne peut être modifiées. C'est le domaine de l'application qui déterminera si l'immutabilité est une propriété de l'objet ou non. Il est également possible que seules certaines propriétés soient immutables et pas les autres. D'une manière générale, je trouve que les développeurs ne font pas assez attention à l'immutabilité et que leur modèle gagnerait en qualité si c'était le cas.

Si le Rectangle devait avec une longueur mutable, il suffirait d'y ajouter une méthode setLongueur, qui vérifie la qualité du paramètre et qui permet de modifier l'attribut longueur précédemment fixé dans le constructeur.

Parfait? Presque, mais on peut mieux faire...

Troisième étape

Vous allez dire que je chicane (et c'est certainement vrai), mais rien ne m'empêche de construire un rectangle où la longueur est plus courte que la largeur.

Allez, un petit dernier:
public class Rectangle {
       private double dimension1;
       private double dimension2;
       
       public Rectangle(double dimension1, double dimension2){
             if(dimension1 <= 0 || dimension2 <= 0){
                    throw new IllegalArgumentException("Les dimensions doivent être strictement positives");
             }
             this.dimension2 = dimension1;
             this.dimension1 = dimension2;
       }
       public double getLongeur() {
             return dimension1>dimension2?dimension1:dimension2;
       }
       public double getLargeur() {
             return dimension1>dimension2?dimension2:dimension1;
       }
       public double getSurface(){
             return dimension1 * dimension2;
       }
}
Maintenant, la longueur est plus grande que la largeur. Ce qui est intéressant avec cette manière de procéder, c'est qu'elle montre bien le principe d'encapsulation. L'objet expose certaines propriétés (avec des getters, puisque c'est le standard JavaBean): sa longueur, sa largeur, sa surface et je pourrais y ajouter son périmètre. Mais ces propriétés ne correspondent en fait à aucun attribut de l'objet, lequel fait ce qu'il veut tant qu'il présente une interface cohérente.

Il y a moyen d'améliorer cette classe: on peut lui ajouter un equals (et ceux qui croient qu'il suffit de vérifier l'égalité des propriétés se planteront), un hashcode, un toString...

Un détail peut éventuellement déranger (en particulier si comme moi vous avez fait du C++ où c'est considéré comme une erreur): le constructeur lance une exception. Ce n'est pas un problème en Java.

Factory et builder

Il n'y a pas besoin d'améliorer la robustesse de ce Rectangle. La construction à l'aide des constructeurs est suffisante. Mais il existe deux autres patterns de construction qui permettent éventuellement de renforcer le modèle: la factory et le builder.

Une factory est utile pour créer une instance d'une des nombreuses implémentations d'une classe, que l'implémentation est choisie en fonction des paramètres de création et que l'on souhaite masquer l'implémentation réellement utilisé. Un builder est quant à lui intéressant lorsqu'on est amené à créer de nombreux constructeurs pour un objet, parce qu'il y a diverses combinaisons de propriétés possibles, ou que plusieurs paramètres peuvent être ignorés ou nulls. Le builder permet aussi d'avoir une interface "fluent" pour la création d'un objet.

Attention cependant qu'aucun des deux patterns ne doit prendre la place de la solidité du modèle. Ce n'est pas parce qu'une factory garantit la création atomique d'un objet cohérent qu'il doit être possible de créer un objet incohérent en se passant de la factory (c'est le problème posé par le service au début). Le modèle doit être solide par lui même.

Ni l'un ni l'autre n'a beaucoup de sens ici. Je pourrais éventuellement créer un version euclidienne du rectangle et une version... non-euclidienne, mais je me vais me contenter de pousser le raisonnement de la factory dans un design que je trouve finalement assez élégant.

Comme je veux masquer l'implémentation, je réduis le rectangle à une interface qui expose ses propriétés via de getters.
public interface Rectangle {
       double getLongeur();
       double getLargeur();
       double getSurface();
       double getPerimetre();
}
Et je laisse la factory créer l'implémentation à l'aide d'une inner classe anonyme...
public class RectangleFactory {
       public static Rectangle create(final double dimension1,final double dimension2){
             if(dimension1 <= 0 || dimension2 <= 0){
                    throw new IllegalArgumentException("Les dimensions doivent être strictement positives");
             }

             Rectangle r = new Rectangle(){
                    private double longueur = dimension1>dimension2?dimension1:dimension2;
                    private double largeur = dimension1>dimension2?dimension2:dimension1;
                    
                    public double getLongeur() {
                           return longueur;
                    }

                    public double getLargeur() {
                           return largeur;
                    }

                    public double getSurface() {
                           return longueur*largeur;
                    }

                    public double getPerimetre() {
                           return 2*(longueur+largeur);
                    }
                    
             };
             
             return r;
       }
}
Encore une fois, le rectangle obtenu est robuste: longueur et largeur strictement positives, longueur plus grande que la largeur.

Tiré par les cheveux? Certes, mais la Factory n'aurait pas eu d'intérêt si j'avais pu créer le rectangle par d'autres moyens. En en faisant une inner class, c'est presque totalement impossible (rien n'empêche en fait un développeur de créer une implémentation non robuste de mon interface...).

Ce qu'il faut en retenir

La spécification JavaBean a été écrite pour faciliter l'utilisation de langages script (comme dans les jsp). Si "r" représente une instance de Rectangle, alors en langage scripté "r.longueur" sortira sa longueur. Grâce à la spécification, le langage scripté sait qu'il doit trouver l'information, non pas dans un attribut longueur (qui n'existe peut-être pas), mais grâce à une méthode getLongueur qui renvoie la valeur d'une propriété, laquelle peut venir directement d'un attribut ou être calculée.

Rien dans la spécification JavaBean n'oblige :
  • à avoir systématiquement un setter et un getter pour chaque attribut
  • que les setters ou les getters correspondent à des attributs réels
  • que les setters ou les getters soient écrits de la manière la plus basique (stupide?) possible
  • qu'il n'y ait aucun constructeur avec paramètres (par contre il en faut obligatoirement un sans paramètre, ce qui n'est pas le cas ici)
  • à n'avoir aucune autre méthode que les setters et les getters (si, si... j'ai déjà entendu ça comme justification de l'absence d'une méthode equals)
Dans de prochains articles, je vous montrerai d'autres moyens de construire un modèle robuste, mais aussi qu'un framework comme Hibernate, pour lequel il est "bien connu" que les entités doivent être des JavaBeans, n'en a pas du tout besoin et fonctionne très bien avec un modèle robuste (et quelques points d'attention).

Les JavaBeans ne sont pas le mal absolu et ils ont leur utilité. Mais comme pour toute chose en programmation, il est important de comprendre ce que l'on fait et pourquoi.

L'automatisme dans la création d'une classe qui consiste à écrire les attributs en private et demander à Eclipse de générer automatiquement les getters et setters est une absurdité.

On peut rarement transiger avec la qualité du modèle.

Aucun commentaire:

Enregistrer un commentaire