samedi 22 février 2014

Modèle robuste: immutabilité et Value Objects

Dans l'article JavaBean, la justification du mauvais orienté objet, nous avons vu comment utiliser les constructeurs et les setters pour garantir que les valeurs passées aux objets étaient correctes, en respectant le principe essentiel que l’objet était le seul responsable de la qualité de ses données.

Cette responsabilité peut être partagée par les paramètres eux-mêmes et c’est ce que nous allons voir dans un instant.

A toute épreuve?


Mais revenons un instant sur la dernière version de l’objet Rectangle de l’article précédent. Elle est parfaitement robuste et ne permet pas la création d’un objet incohérent.

Vraiment ?

Oui, vraiment, dans des conditions normales d’utilisation.

Là... vous le voyez, le piège? Des conditions normales d’utilisation, c’est-à-dire lorsque le développeur n’utilise que l’interface que l’objet lui offre.

S'il utilise la réflexion pour accéder directement aux attributs de l’objet, rien ne peut malheureusement garantir sa cohérence, comme le montre le test (TestNg) suivant :
@Test
public void testCanCreateIncorrectRectangleByReflection(){
     Rectangle r = new Rectangle(2.0,1.0);
     try {
          Field dimension1 = Rectangle.class.getDeclaredField("dimension1");
          Field dimension2 = Rectangle.class.getDeclaredField("dimension2");
          dimension1.setAccessible(true);
          dimension2.setAccessible(true);
          dimension1.set(r, -6.0);
          dimension2.set(r, 0.0);
     } catch (SecurityException e) {
          fail("Security manager...");
     } catch (NoSuchFieldException e) {
          fail("Etonnant...");
     } catch (IllegalArgumentException e) {
          fail("Etonnant...");
     } catch (IllegalAccessException e) {
          fail("Etonnant...");
     }

     assertEquals(r.getLongueur(),0.0);
     assertEquals(r.getLargeur(),-6.0);
     assertEquals(Math.abs(r.getSurface()),0.0);
     assertEquals(r.getPerimetre(),-12.0);
}
Ces quelques lignes permettent d’avoir un rectangle avec une dimension nulle et une autre négative, et des propriétés incohérentes.

Il n’y a malheureusement rien à faire pour contrer cela. Cependant, la réflexion reste lourde à utiliser (raison pour laquelle je suis généralement contre le développement de méthodes utilitaires qui faciliteraient la réflexion). Ceux qui l’utilisent le font, on peut le supposer, en connaissance de cause et assument les risques que cela entraîne.

Immutabilité


Poursuivons notre étude des modèles robustes et examinons une notion abordée succinctement dans l’article précédent : l’immutabilité. (A noter qu’en français, immutabilité n’existe pas et que c’est immuabilité qu’il faut utiliser. Néanmoins, je continuerai d’utiliser immutabilité, proche du mot anglais d’origine « immutability ».)

L’immutabilité signifie que, une fois l’objet créé, ses propriétés ne peuvent plus être modifiées.

Ceci pourrait soulever débat : sont-ce les attributs ou les propriétés qui ne peuvent plus être modifiés. Dans l’article précédent, j’ai fait une distinction entre les attributs (champs définis dans la classe) et les propriétés (valeurs exposées par la classe, de manière standard via des setters et des getters). Un objet peut utiliser certains attributs, mais exposer de propriétés différentes.

Dès lors, l’immutabilité touche-t-elle les attributs ou les propriétés ? Si les attributs sont immutables, quel effet pourrait-on attendre de la modification des propriétés ? Aucun. Par contre, les propriétés pourraient être immutables, mais les attributs seraient modifiables par l’objet lui-même, étant donné qu’il est le seul responsable de ses attributs.

Pour couper court à la discussion, je considérerai qu’attributs et propriétés sont immutables.

L’immutabilité peut aussi toucher à certaines propriétés et non à toutes. Dans ce cas, l’objet lui-même n’est bien sûr pas immutable.

C’est l’analyse du domaine de l’application qui déterminera ce qui est immutable ou non.

Pour ce qui suit, je vais utiliser une nouvelle classe : Book. Il s’agit d’une classe qui définit un livre (normalement, il n’était pas nécessaire de passer par Google Traduction pour le comprendre) lequel a les propriétés suivantes :
  • un titre
  • un auteur
  • un numéro ISBN
L'ISBN est un numéro de codification unique, par édition. Il doit respecter certaines règles pour être valide.

Pour ce qui suit, supposons que les critères de validité des propriétés soient les suivants :
  • Le numéro ISBN doit toujours être défini et doit être valide. Une fois défini, il n’y a pas moyen de le modifier.
  • Le titre doit toujours être défini par une chaîne non vide. Une fois défini, il ne peut être modifié.
  • L’auteur est facultatif. Il peut être modifié. S’il est défini, il doit être une chaîne non vide.
Notons également que le numéro ISBN constitue une clé métier.
Pour créer cette classe, je vais de plus employer deux librairies utilitaires:
La classe Book répondant aux conditions ci-dessus est la suivante :
public class Book {
     private String isbn;
     private String title;
     private String author;

     public Book(String isbn, String title){
          this(isbn,title,null);
     }

     public Book(String isbn, String title, String author){
          ISBNValidator validator = ISBNValidator.getInstance(true);
          if(! validator.isValid(isbn)){
               throw new IllegalArgumentException("Isbn incorrect");
          }
          if(StringUtils.isBlank(title)){
               throw new IllegalArgumentException("Le titre ne peut être vide");
          }
          this.isbn = validator.validate(isbn);
          this.title = title.trim();
          this.author = StringUtils.trimToNull(author);
     }

     public void setAuthor(String author) {
          this.author = StringUtils.trimToNull(author);
     }

     public String getIsbn() {
          return isbn;
     }

     public String getTitle() {
          return title;
     }

     public String getAuthor() {
          return author;
     }

     @Override
     public int hashCode() {
          return isbn.hashCode();
     }

     @Override
     public boolean equals(Object obj) {
          if (this == obj)
               return true;
          if (obj == null)
               return false;
          if (!(obj instanceof Book))
               return false;
          Book other = (Book) obj;
          return isbn.equals(other.isbn);
     }
}
Quelques remarques :

  • L’IsbnValidator valide les ISBN-13 et ISBN-10, la valeur null étant non valide. Il va aussi les convertir (enlever les ‘-‘) et transformer les ISBN-10 en ISBN-13. Le résultat est une String de 13 chiffres.
  • Le getIsbn renvoie la String non formatée. On pourrait aussi la formater d’une manière standard (xxx-x-xxxx-xxxx-x).
  • Un "trim" est appliqué au titre et à l’auteur afin de ne pas garder des espaces inutiles à l'avant et à l'arrière.
  • Si l’auteur est vide ("      ", par exemple), la valeur de l’attribut sera null.
  • Il y a deux constructeurs, un avec un auteur, l’autre sans. A noter que l’auteur peut être null dans le premier.
  • La méthode equals porte sur le numéro ISBN  uniquement puisqu’il correspond à une clé métier (trop souvent, je vois des equals sur l’ensemble des attributs).
  • Il n'y a qu'un seul setter, sur l'auteur, lequel valide le paramètre. C'est la seule propriété mutable.
  • Si vous demandez à Eclipse de générer le code du equals/hashcode à partir de l’attribut isbn, vous obtiendrez le code suivant :
    @Override
    public int hashCode() {
         final int prime = 31;
         int result = 1;
         result = prime * result + ((isbn == null) ? 0 : isbn.hashCode());
         return result;
    }
    
    @Override
    public boolean equals(Object obj) {
         if (this == obj)
              return true;
         if (obj == null)
              return false;
         if (!(obj instanceof Book))
              return false;
         Book other = (Book) obj;
         if (isbn == null) {
              if (other.isbn != null)
                   return false;
         } else if (!isbn.equals(other.isbn))
              return false;
         return true;
    }
    
    Néanmoins, notre modèle garantissant que l'attribut isbn n’est jamais null, certains contrôles sont superflux.
Notre classe Book est robuste. Elle ne peut contenir aucune valeur incorrecte. Les propriétés non mutables ne peuvent être modifiées. C’est parfait… enfin presque.

Value Object


C’est la question piège : "si vous deviez avoir un attribut qui est un numéro de compte/un isbn/un numéro de sécurité sociale… quel type utiliseriez-vous ?". Piège, car trop souvent la réponse va être "une String" (même si "un long" aurait été pire). Une String, c’est facile d’emploi et ça peut contenir n’importe quoi… vraiment n’importe quoi.

C’est le cas de notre isbn qui est une String. Nous sommes obligés de le valider lorsqu’on le passe en paramètre du constructeur. Tant qu’on reste dans le contexte de Book, ce n’est pas vraiment un problème. Mais si isbn devient un paramètre de méthode, il faudra peut-être le valider à chaque utilisation.

L’erreur est plus fondamentale : un des atouts de Java est d’avoir un typage fort. Lorsque j’utilise un Integer, je suis sûr que c’est un nombre entier, pas un décimal, pas une chaîne de caractères. Par contre, une String pour un numéro ISBN, c’est particulièrement faible, car cette String peut contenir n'importe quoi avant d'être validée.

La solution, ce sont les Value Objects.

Littéralement, ce sont des objets qui contiennent une valeur (sans entrer dans les détails, cela ne signifie pas nécessairement qu’ils n’ont qu’un seul attribut ou une seule propriété).

Integer est un exemple de Value Object, un objet qui contient un entier comme valeur. String aussi (objet qui contient une chaîne de caractères, quoi qu’elle représente).

Le numéro ISBN pourrait être avantageusement remplacé par un Value Object.

Avant de montrer le code, voyons quelques propriétés des Value Objects :
  • La valeur contenue dans le Value Object est toujours correcte (cohérence de l’objet). Essayez new Integer("toto"), vous m’en direz des nouvelles.
  • Un Value Object est généralement immutable. String, Integer, BigDecimal sont tous immutables (c’est même une erreur commune de croire le contraire).
  • On pourrait s’attendre à ce que deux Value Objects contenant la même valeur soient en fait la même référence. C’est rarement le cas, car c’est très difficile à implémenter (sauf si le nombre de valeurs différentes est petit).
Tenant compte de ces remarques, voici une classe pour les Value Objects ISBN :
public class Isbn {
     private String isbn;

     public Isbn(String isbn){
          if(!validate(isbn)){
               throw new IllegalArgumentException("Isbn incorrect");
          }
          this.isbn = ISBNValidator.getInstance(true).validate(isbn);
     }

     public Isbn(Isbn isbn){
          this.isbn = isbn.isbn;
     }

     public static boolean validate(String isbn){
          return ISBNValidator.getInstance(true).isValid(isbn);
     }

     public String getIsbn() {
          return isbn;
     }

     @Override
     public int hashCode() {
          return isbn.hashCode();
     }

     @Override
     public boolean equals(Object obj) {
          if (this == obj)
               return true;
          if (obj == null)
               return false;
          if (!(obj instanceof Isbn))
               return false;
          Isbn other = (Isbn) obj;

          return isbn.equals(other.isbn);
     }
}
Quelques points notables :
  • L’objet reste construit à partir d’une String. Mais cette String n’est utilisée qu’une seule fois, comme paramètre du construteur. Elle peut être une valeur entrée par l’utilisateur. Néanmoins, c’est le seul endroit où une String représentera un numéro ISBN. Une fois l’objet Isbn construit, plus aucune méthode une String pour un ISBN. Ceci dit, au sein de l'objet Isbn, le numéro est encodé dans une String.
  • Le deuxième constructeur n’est pas absolument nécessaire. C’est un constructeur de copie, parfois utile.
  • La méthode validate, statique, permet de valider la chaîne de caractère supposée contenir un numéro ISBN. Elle évite de devoir faire un try-catch autour de la construction du Value Object. Au lieu de :
    try {
         isbn = new Isbn(une string);
    } catch(IllegalArgumentException e) {
         //Gérer le fait que la String n'est pas valide
    }
    
    on écrira :
    if(Isbn.validate(une string)){
         isbn = new Isbn(une string);
    } else {
         //Gérer le fait que la String n'est pas valide
    }
    
    Bien sûr, la validation sera faite deux fois, mais l’écriture est plus propre.
  • Pour la méthode equals notez que l’attribut isbn n’est jamais null.
  • Une partie de la logique que l’on trouvait dans Book est maintenant dans Isbn.
Le code de Book devient :
public class Book {
     private Isbn isbn;
     private String title;
     private String author;

     public Book(Isbn isbn, String title){
          this(isbn,title,null);
     }

     public Book(Isbn isbn, String title, String author){
          if(isbn==null){
               throw new IllegalArgumentException("Isbn doit être fourni");
          }
          if(StringUtils.isBlank(title)){
               throw new IllegalArgumentException("Le titre ne peut être vide");
          }
          this.isbn = isbn;
          this.title = title.trim();
          this.author = StringUtils.trimToNull(author);
     }

     public void setAuthor(String author) {
          this.author = StringUtils.trimToNull(author);
     }

     public Isbn getIsbn() {
          return isbn;
     }

     public String getTitle() {
          return title;
     }

     public String getAuthor() {
          return author;
     }

     @Override
     public int hashCode() {
          return isbn.hashCode();
     }

     @Override
     public boolean equals(Object obj) {
          if (this == obj)
               return true;
          if (obj == null)
               return false;
          if (!(obj instanceof Book))
               return false;
          Book other = (Book) obj;
          return isbn.equals(other.isbn);
     }
}
Le résultat n’est pas beaucoup plus court qu’auparavant, mais à présent l’objet Isbn s’occupe de sa propre cohérence.

Malheureusement, nous devons toujours vérifier que la référence passée en paramètre pour isbn n’est pas nulle.

Jusqu’où aller ?


Ne serait-il pas intéressant d’utiliser un Value Object pour le titre, ce qui garantirait qu’il y a un contenu ? Dans le premier exemple de JavaBean, la justification du mauvais orienté objet, dans les personnes à charge, ne serait-il pas intéressant d’avoir un StrictlyPositiveInteger qui encapsulerait un entier obligatoirement strictement positif ?

Il n’y a pas de réponse catégorique à ces questions. Le point important à considérer est la réutilisation du Value Object : si la classe est peu utilisée hors d’un contexte spécifique (c'est-à-dire, un autre objet), un Value Object a peu d’intérêt.

Malgré tout, on pourrait être tenté d’utiliser des Value Object partout, mais cela demande du travail supplémentaire. D’autant plus que, comme nous le verrons une prochaine fois, il faudra mettre la main à la pâte pour faire fonctionner ces objets avec Hibernate.

Aucun commentaire:

Enregistrer un commentaire