Traductions pour la plateforme Java

L'annotation @Delegate

Article d'origine: 

Exploration des limites du duck typing dans un langage à typage statique

Résumé: 

Scott Davis continue sa présentation de la métaprogrammation Groovy avec une étude approfondie de l'annotation @Delegate, qui gomme les distinctions entre type de données et comportement d'une part, et entre typage statique et dynamique d'autre part.

Dans les articles précédents de Groovy par la pratique, vous avez vu que des caractéristiques de Groovy telles que les fermetures et la métaprogrammation apportent du dynamisme au développement Java™. Cet article va encore plus loin. Vous verrez que l'annotation @Delegate est une variante de l'utilisation du délégué par ExpandoMetaClass. Vous verrez également (une fois de plus) pourquoi les possibilités dynamiques de Groovy en font un langage idéal pour les tests unitaires.

Dans l'article "Métaprogrammation avec les fermetures, ExpandoMetaClass, et les catégories", nous avons introduit la notion de délégué. Par l'ajout d'une méthode shout() à l'ExpandoMetaClass de java.lang.String, nous avons utilisé le délégué pour définir la relation entre les deux classes, comme le montre le Listing 1:

Listing 1. Utilisation d'un délégué pour accéder à String.toUpperCase()

String.metaClass.shout = {->
  return delegate.toUpperCase()
}

println "Hello MetaProgramming".shout()

//sortie
HELLO METAPROGRAMMING

On ne peut pas utiliser this.toUpperCase(), parce que ExpandoMetaClass n'a pas de méthode toUpperCase(). De même, on ne peut pas utiliser super.toUpperCase(), parce que ExpandoMetaClass n'étend pas String. (En fait, elle ne pourrait pas étendre String, puisque String est une classe finale). Le langage Java n'a tout simplement pas le vocabulaire nécessaire pour exprimer la relation symbiotique entre les deux classes. C'est pourquoi Groovy a introduit le concept de délégué.

L'annotation @Delegate a été ajoutée au langage dans Groovy 1.6. (Voir les Ressources pour voir la liste des nouvelles annotations ajoutées dans Groovy 1.6). Cette annotation permet d'ajouter un ou plusieurs délégués à n'importe quelle classe, pas seulement ExpandoMetaClass.

Pour apprécier pleinement la puissance de l'annotation @Delegate, considérons une difficulté très courante en programmation Java: la création d'une nouvelle classe basée sur une classe finale.

Le modèle de conception Composite et les classes finales

Supposons que vous vouliez créer une classe AllCapsString qui se comporte comme java.lang.String, excepté que - comme son nom l'indique - la valeur retournée est toujours en majuscules. String est une classe finale - l'équivalent Java d'une impasse évolutionnaire. Le Listing 2 prouve que vous ne pouvez pas étendre String directement:

Listing 2. Impossibilité d'étendre une classe finale

class AllCapsString extends String{
}

$ groovyc AllCapsString.groovy

org.codehaus.groovy.control.MultipleCompilationErrorsException: 
startup failed, AllCapsString.groovy: 1: You are not allowed to 
overwrite the final class 'java.lang.String'.
 @ line 1, column 1.
   class AllCapsString extends String{
   ^

1 error

Ca n'a pas marché, si bien que l'idée suivante est d'utiliser le modèle de conception Composite, comme le montre le Listing 3 (pour plus de détails sur le modèle de conception Composite, voir les Ressources):

Listing 3. Utilisation de la composition pour créer un nouveau type de classe String

class AllCapsString{
  final String body
  
  AllCapsString(String body){
    this.body = body.toUpperCase()
  }
  
  String toString(){
    body
  }
    
  //Maintenant il faut implémenter les 72 méthodes de String
  char charAt(int index){
    return body.charAt(index)
  }
  
  //une de faite, plus que 71...
}

Ainsi, la classe AllCapsString a une String, mais elle ne se comportera pas comme une String à moins que vous ne lui rajoutiez les 72 méthodes de String. Pour voir les méthodes qu'il faut ajouter, vous pouvez vous référer à la Javadoc de String, ou exécuter le code du Listing 4:

Listing 4. Affichage des méthodes de la classe String

String.class.methods.eachWithIndex{method, i-> 
  println "${i} ${method}"
}

//output
0 public boolean java.lang.String.contentEquals(java.lang.CharSequence)
1 public boolean java.lang.String.contentEquals(java.lang.StringBuffer)
2 public boolean java.lang.String.contains(java.lang.CharSequence)
...

L'ajout manuel de 72 méthodes à AllCapsString n'est pas une utilisation judicieuse de votre précieux temps de développeur. C'est là que l'annotation @Delegate se révèle pratique.

Comprendre @Delegate

@Delegate est une annotation évaluée à la compilation qui demande au compilateur de pousser toutes les méthodes du délégué, ainsi que les interfaces qu'il implémente, vers la classe externe.

Avant d'ajouter l'annotation @Delegate à body, compilez AllCapsString et utilisez javap pour vérifier que la plupart des méthodes de String sont manquantes, comme le montre le Listing 5:

Listing 5. AllCapsString avant l'utilisation de @Delegate

$ groovyc AllCapsString.groovy 
$ javap AllCapsString
Compiled from "AllCapsString.groovy"
public class AllCapsString extends java.lang.Object 
       implements groovy.lang.GroovyObject{
    public AllCapsString(java.lang.String);
    public java.lang.String toString();
    public final java.lang.String getBody();
    //snip...

Maintenant, ajoutez l'annotation @Delegate à body comme dans le Listing 6. Repétez les commandes groovyc et javap, et voilà (ndT: en français dans le texte), AllCapsString possède les mêmes méthodes et implémente les mêmes interfaces que java.lang.String.

Listing 6. Utilisation de l'annotation @Delegate pour pousser toutes les méthodes de String vers la classe englobante

class AllCapsString{
  @Delegate final String body

  AllCapsString(String body){
    this.body = body.toUpperCase()
  }
  
  String toString(){
    body
  } 
}

$ groovyc AllCapsString.groovy 
$ javap AllCapsString
Compiled from "AllCapsString.groovy"
public class AllCapsString extends java.lang.Object 
       implements java.lang.CharSequence, java.lang.Comparable,
       java.io.Serializable,groovy.lang.GroovyObject{
       
    //NOTE: méthodes de AllCapsString:   
    public AllCapsString(java.lang.String);
    public java.lang.String toString();
    public final java.lang.String getBody();    
    
    //NOTE: méthodes de java.lang.String:
    public boolean contains(java.lang.CharSequence);
    public int compareTo(java.lang.Object);
    public java.lang.String toUpperCase();
    //snip...

Remarquez, cependant, qu'il est toujours possible d'appeler getBody() et ainsi de passer outre les méthodes poussées vers la classe englobante AllCapsString. En ajoutant private à la déclaration du champ - @Delegate final private String body - on peut empêcher le couple getter / setter usuel d'apparaitre. Ceci achève la transformation: AllCapsString fournit intégralement le comportement d'une String, vous permettant de redéfinir les méthodes natives de String comme vous le souhaitez.

Les limites du duck typing dans un langage statique

Bien que AllCapsString se comporte désormais exactement comme une String, ce n'est toujours pas vraiment une String. Dans le code Java, vous ne pouvez pas utiliser AllCapsString à la place de String, parce que ça n'est pas vraiment un canard, ça cancane seulement. (Les langages dynamiques utilisent le duck typing, le langage Java utilise le typage statique. Voir les Ressources pour en savoir plus sur la différence entre les deux). Autrement dit, parce que AllCapsString n'étend pas réellement String (et n'implémente pas non plus l'interface Stringable, qui n'existe pas), vous ne pouvez pas l'utiliser de manière interchangeable avec String dans votre code Java. Le Listing 7 montre qu'un cast de AllCapsString en String échoue en Java:

Listing 7. Le typage statique de Java empêche AllCapsString d'être utilisé en lieu et place de String

public class JavaExample{
  public static void main(String[] args){
    String s = new AllCapsString("Hello");
  }
}

$ javac JavaExample.java 
JavaExample.java:5: incompatible types
found   : AllCapsString
required: java.lang.String
    String s = new AllCapsString("Hello");
               ^
1 error

Comme on le voit, tout en ne corrompant pas vraiment la signification du mot-clé final de Java en vous permettant d'étendre une classe pour laquelle le développeur original avait expressément interdit l'extension, @Delegate vous donne autant de puissance que possible sans franchir la limite.

Et rappelez vous qu'une classe peut avoir plus d'un délégué. Supposez que vous vouliez créer une classe RemoteFile qui aie les caractéristiques à la fois de java.io.File et de java.net.URL. Le langage Java ne permet pas l'héritage multiple, mais vous pouvez vous en approcher terriblement avec une paire de @Delegates, comme le montre le Listing 8. La classe RemoteFile n'est ni un File ni une URL, mais elle a les comportements des deux.

Listing 8. Plusieurs @Delegates, offrant le comportement de l'héritage multiple

class RemoteFile{
  @Delegate File file
  @Delegate URL url
}

Si @Delegate peut uniquement changer le comportement de votre classe - pas le type - cela veut-il dire qu'elle est inutile aux développeurs Java ? Loin de là. Même les langages typés statiquement comme Java fournissent une forme limitée de duck typing appelée polymorphisme.

Cancaner comme un canard polymorphique

Le polymorphisme - un mot dérivé du Grec signifiant "plusieurs formes" - signifie que si des classes partagent explicitement le même comportement en implémentant la même interface, elle peuvent être utilisées de manière interchangeable. Dans d'autres termes, si vous définissez une variable de type Duck (en supposant que Duck (ndT: canard) est une interface qui expose les méthodes quack() (ndT: cancaner) et waddle() (ndT: se dandiner)), vous pouvez lui affecter un Mallard() (ndT: col-vert), un GreenWingedTeal() (ndT: sarcelle de Caroline), ou (mon préféré) un PekingWithHoisinSauce() (ndT: canard laqué à la Pékinoise).

L'annotation @Delegate fournit un support au polymorphisme en promouvant non seulement les méthodes de la classe déléguée vers la classe externe, mais aussi les interfaces du délégué. Cela a pour conséquence que si la classe déléguée implémente une interface, vous pouvez en créer un substitut.

@Delegate et l'interface List

Supposons que l'on veuille créer une nouvelle classe nommée FixedList. Elle doit se comporter comme une java.util.ArrayList avec une distinction importante: on doit pouvoir définir une limite supérieure au nombre d'éléments que l'on peut y ajouter. Cela vous permet de créer une variable sportsCar qui peut embarquer deux passagers mais pas plus, une variable restaurantTable qui peut accueillir 4 convives au maximum, etc.

La classe ArrayList implémente l'interface List. Cela vous donne plusieurs possibilités. Vous pourriez également implémenter l'interface List dans la classe FixedList, mais alors il faudrait effectuer le travail fastidieux de fournir une implémentation pour chacune des méthodes de List. Puisque ArrayList n'est pas une classe finale, une autre possibilité est d'étendre simplement ArrayList dans FixedList. C'est une solution parfaitement valide, mais dans le cas (hypothétique) où ArrayList serait finale, l'annotation @Delegate fournirait une troisième option: en faisant de ArrayList un délégué de FixedList, vous pourriez obtenir tous les comportements ArrayList de  tout en implémentant automatiquement l'interface List.

Pour commencer, créez la classe FixedList avec un délégué ArrayList, comme dans le Listing 9. Effectuez notre combinaison préférée groovyc / javap pour vérifier que FixedList offre non seulement les mêmes méthodes que ArrayList, mais qu'elle implémente aussi les mêmes interfaces.

Listing 9. Première étape de la création de la classe FixedList

class FixedList{
  @Delegate private List list = new ArrayList()
  final int sizeLimit
  
  /**
    * NOTE: This constructor limits the max size of the list,
    *  not just the initial capacity like an ArrayList.
    */
  FixedList(int sizeLimit){
    this.sizeLimit = sizeLimit
  }
}  

$ groovyc FixedList.groovy
$ javap FixedList
Compiled from "FixedList.groovy"
public class FixedList extends java.lang.Object 
             implements java.util.List,java.lang.Iterable,
             java.util.Collection,groovy.lang.GroovyObject{
    public FixedList(int);
    public java.lang.Object[] toArray(java.lang.Object[]);
    //snip..

Vous n'avez encore rien fait pour contraindre la taille de FixedList, mais c'est un bon début. Comment pourriez-vous vous assurer que la taille d'une FixedList n'est pas, eh bien, fixée à cet instant ? Vous pourriez écrire un bout de code jetable, mais si FixedList est destinée à passer en production, vous seriez mieux inspiré d'écrire quelques cas de test.

Tester @Delegate avec GroovyTestCase

Pour commencer à tester @Delegate, écrivez un test unitaire qui prouve que l'on peut ajouter plus d'éléments à une FixedList que ce qui est permis (ndT: vous connaissez les Trois Stooges ?). Le Listing 10 montre ce test:

Listing 10. Ecriture d'un test qui échoue tout d'abord

class FixedListTest extends GroovyTestCase{
  
  void testAdd(){
    List threeStooges = new FixedList(3)
    threeStooges.add("Moe")    
    threeStooges.add("Larry")
    threeStooges.add("Curly")
    threeStooges.add("Shemp")
    assertEquals threeStooges.sizeLimit, threeStooges.size()
  }
}

$ groovy FixedListTest.groovy 

There was 1 failure:
1) testAdd(FixedListTest)junit.framework.AssertionFailedError: 
   expected:<3> but was:<4>

Il semble que vous devriez redéfinir la méthode add() dans FixedList, comme dans le Listing 11. Une nouvelle exécution du test échoue encore, mais cette fois à cause de l'exception qui est lancée.

Listing 11. Rédéfinition de la méthode add() de ArrayList

class FixedList{
  @Delegate private List list = new ArrayList()
  final int sizeLimit
  
  //snip...
  
  boolean add(Object element){
    if(list.size() < sizeLimit){
      return list.add(element)
    }else{
      throw new UnsupportedOperationException("Error adding ${element}:" +
                 " the size of this FixedList is limited to ${sizeLimit}.")
    }
  }
}

$ groovy FixedListTest.groovy 

There was 1 error:
1) testAdd(FixedListTest)java.lang.UnsupportedOperationException: 
   Error adding Shemp: the size of this FixedList is limited to 3.

Grâce à la méthode shouldFail de GroovyTestCase, qui se révèle bien pratique, vous pouvez récupérer l'exception comme dans l'exemple du Listing 12, et fêter votre premier test couronné de succès:

Listing 12. La méthode shouldFail gère l'exception attendue

class FixedListTest extends GroovyTestCase{
  void testAdd(){
    List threeStooges = new FixedList(3)
    threeStooges.add("Moe")    
    threeStooges.add("Larry")
    threeStooges.add("Curly")
    assertEquals threeStooges.sizeLimit, threeStooges.size()
    shouldFail(java.lang.UnsupportedOperationException){
      threeStooges.add("Shemp")      
    }
  }
}  

Test de la surcharge d'opérateur

Dans l'article "Smooth Operators" (ndT: non traduit), vous avez vu que Groovy supporte la surcharge d'opérateur. Avec les Lists, on peut aussi bien utiliser << pour ajouter des éléments que la traditionnelle méthode add(). Ecrivez un petit test unitaire comme celui du Listing 13 pour vérifier que l'utilisation de << ne casse pas par hasard le fonctionnement de FixedList:

Listing 13. Test de la surcharge d'opérateur

class FixedListTest extends GroovyTestCase{
 
  void testOperatorOverloading(){
    List oneList = new FixedList(1)
    oneList << "one"
    shouldFail(java.lang.UnsupportedOperationException){
      oneList << "two"
    }    
  }
}

Voir le test passer devrait vous apporter une certaine sérénité.

Vous pouvez aussi tester les cas pathologiques. Le Listing 14, par exemple, teste ce qui se passe si vous créez une FixedList avec un nombre d'éléments négatif:

Listing 14. Test des cas aux limites

class FixedListTest extends GroovyTestCase{
  void testNegativeSize(){
    List badList = new FixedList(-1)
    shouldFail(java.lang.UnsupportedOperationException){
      badList << "will this work?"
    }    
  }
}

Test de l'insertion d'un élément en milieu de liste

Maintenant que vous êtes convaincu que la méthode add() redéfinie fonctionne, l'étape suivante est d'implémenter la surcharge de la méthode add() qui prend un index et un élément, comme le montre le Listing 15:

Listing 15. Ajout d'éléments avec index

class FixedList{
  @Delegate private List list = new ArrayList()
  final int sizeLimit
   
  void add(int index, Object element){
    list.add(index, element)
    trimToSize()
  }
  
  private void trimToSize(){
    if(list.size() > sizeLimit){
      (sizeLimit..<list.size()).each{
        list.pop()
      }
    }
  }
}

Notez que vous pouvez (et devriez) vous en remettre aux fonctionnalités natives du délégué à chaque fois que c'est possible - après tout, c'est bien pour cette raison que vous avez choisi d'en faire un délégué. Dans notre cas, nous laissons ArrayList s'occuper de l'ajout, et nous élaguons les éléments en surnombre par rapport à la taille de FixedList. (Le fait de déclencher ou non une UnsupportedOperationException dans notre méthode add() comme dans la méthode sans paramètre est un choix de conception qui vous appartient).

La méthode trimToSize() contient un peu de sucre syntaxique de Groovy digne que l'on s'y intéresse. Tout d'abord, la méthode pop() est un ajout par métaprogrammation de Groovy à toutes les Lists. Il supprime le dernier élément de la List, en mode LIFO (dernier entré, premier servi).

De plus, on peut remarquer l'utilisation d'un range Groovy dans la boucle each. Pour mieux comprendre ce comportement, il peut être utile de remplacer les variables par des vrais nombres. Supposons que la FixedList a pour sizeLimit 3, et qu'après l'ajout de nouveaux éléments elle atteigne une size() de 5. L'expression serait donc évaluée comme (3..5).each{}. Mais comme les Lists utilisent 0 comme indice de départ, aucun élément de la liste n'a 5 pour indice. En spécifiant (3..<5).each{}, on exclut le dernier nombre de l'intervalle.

Ecrivez quelques tests, à l'exemple du Listing 16, pour vérifier que la nouvelle méthode add() surchargée se comporte comme prévu:

Listing 16. Tester l'ajout d'éléments au milieu de FixedList

class FixedListTest extends GroovyTestCase{
  void testAddWithIndex(){
    List threeStooges = new FixedList(3)
    threeStooges.add("Moe")    
    threeStooges.add("Larry")
    threeStooges.add("Curly")
    threeStooges.add(2,"Shemp")
    assertEquals 3, threeStooges.size()
    assertFalse threeStooges.contains("Curly")
  }

  void testAddWithIndexOnALessThanFullList(){
    List threeStooges = new FixedList(3)
    threeStooges.add("Curly")
    assertEquals 1, threeStooges.size()

    threeStooges.add(0, "Larry")
    assertEquals 2, threeStooges.size()
    assertEquals "Larry", threeStooges[0]

    threeStooges.add(0, "Moe")
    assertEquals 3, threeStooges.size()
    assertEquals "Moe", threeStooges[0]
    assertEquals "Larry", threeStooges[1]
    assertEquals "Curly", threeStooges[2]
  }
}

Avez-vous remarqué que vous avez écrit plus de code de test que de code de production ? C'est bien ! Je dis souvent que le code de test doit peser au moins le double du code de production.

Implémenter addAll()

Pour terminer l'écriture de la classe FixedList, redéfinissez les méthodes addAll() de ArrayList, comme le montre le Listing 17:

Listing 17. Implémenter les méthodes addAll()

class FixedList{
  @Delegate private List list = new ArrayList()
  final int sizeLimit
  
  boolean addAll(Collection collection){
    def returnValue = list.addAll(collection)
    trimToSize()
    return returnValue
  }

  boolean addAll(int index, Collection collection){
    def returnValue = list.addAll(index, collection)
    trimToSize()
    return returnValue
  }
}

Maintenant écrivez les tests unitaires correspondants,  comme dans le Listing 18:

Listing 18. Tester les méthodes addAll()

class FixedListTest extends GroovyTestCase{
  void testAddAll(){
    def quartet = ["John", "Paul", "George", "Ringo"]
    def trio = new FixedList(3)
    trio.addAll(quartet)
    assertEquals 3, trio.size()
    assertFalse trio.contains("Ringo")
  }

  void testAddAllWithIndex(){
    def quartet = new FixedList(4)
    quartet << "John"
    quartet << "Ringo"
    quartet.addAll(1, ["Paul", "George"])
    assertEquals "John", quartet[0]
    assertEquals "Paul", quartet[1]
    assertEquals "George", quartet[2]
    assertEquals "Ringo", quartet[3]
  }
}

Et voilà le résultat. Grâce à la puissance de l'annotation @Delegate, vous avez été en mesure de créer une classe FixedList avec à peu près 50 lignes de code. Grâce à la puissance de GroovyTestCase, vous l'avez testé, ce qui vous permettra de mettre ce code en production avec l'assurance qu'il se comporte comme prévu. Le Listing 19 montre la totalité de la classe FixedList:

Listing 19. La classe FixedList complète

class FixedList{
  @Delegate private List list = new ArrayList()
  final int sizeLimit
  
  /**
    * NOTE: This constructor limits the max size of the list,
    *  not just the initial capacity like an ArrayList.
    */
  FixedList(int sizeLimit){
    this.sizeLimit = sizeLimit
  }
  
  boolean add(Object element){
    if(list.size() < sizeLimit){
      return list.add(element)
    }else{
      throw new UnsupportedOperationException("Error adding ${element}:" + 
        " the size of this FixedList is limited to ${sizeLimit}.")
    }
  }
  
  void add(int index, Object element){
    list.add(index, element)
    trimToSize()
  }
  
  private void trimToSize(){
    if(list.size() > sizeLimit){
      (sizeLimit..<list.size()).each{
        list.pop()
      }
    }
  }

  boolean addAll(Collection collection){
    def returnValue = list.addAll(collection)
    trimToSize()
    return returnValue
  }
  
  boolean addAll(int index, Collection collection){
    def returnValue = list.addAll(index, collection)
    trimToSize()
    return returnValue
  }
  
  String toString(){
    return "FixedList size: ${sizeLimit}\n" + "${list}"
  }
}

Conclusion

En se concentrant sur la capacité d'ajouter de nouveaux comportements aux classes au lieu de changer leur type, la métaprogrammation Groovy ouvre sur tout un ensemble de possibilités dynamiques sans violer les règles du système de typage statique de Java. Avec ExpandoMetaClass (avec laquelle vous pouvez ajouter arbitrairement de nouvelles méthodes à des classes existantes en les enveloppant) et @Delegate (avec laquelle vous pouvez exposer les fonctionnalités des classes composites internes dans la classe d'enveloppe externe), Groovy permet à la JVM de se dandiner et de cancaner comme jamais.

Dans le prochain article, je parlerai d'une technologie ancienne qui bénéficie d'un second souffle grâce à la syntaxe flexible de Groovy: Swing. Oui, la complexité de Swing s'évanouit complètement avec le SwingBuilder de Groovy. Il rend le développement d'interfaces graphiques - oserai-je le dire ? - amusant et facile. En attendant, j'espère que vous trouverez beaucoup d'utilisations pratiques de Groovy.