Traductions pour la plateforme Java

Métaprogrammation avec les fermetures, ExpandoMetaClass, et les catégories

Article d'origine: 

Ajoutez des methodes où et quand vous le voulez

Résumé: 

Entrez dans le monde de la métaprogrammation façon Groovy. La possibilité d'ajouter de nouvelles méthodes aux classes dynamiquement à l'exécution - même aux classes Java™, et même aux classes Java final - est incroyablement puissante. Que ce soit pour du code de production, des tests unitaires, ou quoi que ce soit dans l'intervalle, les capacités de la métaprogrammation Groovy devraient piquer la curiosité de n'importe quel développeur Java, même le plus blasé.

Depuis des années, on entend dire que Groovy est un langage de programmation dynamique pour la JVM. Mais qu'est-ce-que ça signifie réellement ? Dans ce volet de Groovy par la pratique, vous en apprendrez davantage sur la capacité qu'a Groovy d'ajouter des méthodes aux classes dynamiquement à l'exécution. Cette souplesse va bien au delà de ce que le langage Java standard peut proposer. A travers une série d'exemples de code (tous disponibles en téléchargement), vous verrez que la métaprogrammation est des aspects les plus pratiques et puissants de Groovy.

Modéliser ce qui nous entoure

Notre travail de programmeurs est de modéliser le monde réel dans nos logiciels. Quand le monde réel est assez gentil pour exposer un domaine simple - les animaux qui ont des écailles (ndT: scaly) ou des plumes (ndT: feathery) pondent des oeufs, et les animaux à pelage (ndT: furry) donnent naissance à des petits complètement formés - il est facile d'abstraire des comportements dans le logiciel, comme dans le Listing 1:

Listing 1. Modéliser des animaux avec Groovy

class ScalyOrFeatheryAnimal{
  ScalyOrFeatheryAnimal layEgg(){
    return new ScalyOrFeatheryAnimal()
  }
}

class FurryAnimal{
  FurryAnimal giveBirth(){
    return new FurryAnimal()
  }
}

Malheureusement, le monde réel abonde en exceptions et en cas aux limites - les ornithorynques (ndT: platypuses) ont un pelage et pondent des oeufs. C'est presque comme si nos abstractions si soigneusement conçues étaient la cible privilégiée de toute une équipe de contradicteurs patentés.

Si le langage que vous utilisez pour modéliser le domaine est trop rigide pour s'accomoder des exceptions qui arriveront inévitablement, vous pourriez bien finir par ressembler à un fonctionnaire têtu empêtré dans une bureaucratie mesquine - "Je suis désolé madame Ornithorynque, mais il faudra accoucher d'un petit complètement formé si vous voulez bénécier de notre système."

A l'opposé, un langage dynamique comme Groovy vous donne la flexibilité nécessaire pour permettre à votre logiciel de modéliser la réalite avec plus de précision, plutôt que de vouloir présomptueusement (et futilement) que ce soit la réalité qui fasse des concessions. Si la class Platypus a besoin d'une méthode layEgg(), Groovy le permet, comme le montre le Listing 2:

Listing 2. Ajouter une méthode layEgg() dynamiquement

Platypus.metaClass.layEgg = {->
  return new FurryAnimal()
}

def baby = new Platypus().layEgg()

Si tous ces discours sur les animaux à pelage et les oeufs semblent frivoles, parlons donc de la rigidité d'une des classes les plus couramment utilisées de Java: String.

Les méthodes ajoutées par Groovy à java.lang.String

Un des aspects sympathiques du travail avec Groovy est qu'il ajoute beaucoup de méthodes à java.lang.String. Des méthodes comme padRight() et reverse() effectuent de simples transformations de String, comme le montre le Listing 3. (Pour un lien vers la liste de toutes les méthodes ajoutées par le GDK à String, voir les Ressources. Comme la page d'accueil du GDK le dit avec enthousiasme, "Ce document décrit les méthodes ajoutées au JDK pour le rendre plus fun (ndT: groovy en V.O.)").

Listing 3. Méthodes ajoutées à String par Groovy

println "Introduction".padRight(15, ".")
println "Introduction".reverse()

//sortie
Introduction...
noitcudortnI

Mais les ajouts à String ne se limitent pas à ces petites gâteries. Si la String est en fait une URL bien formée, en une seule ligne vous pouvez la transformer en une java.net.URL et obtenir le résultat d'une requête HTTP GET, comme dans le Listing 4:

Listing 4. Effectuer une requête HTTP GET

println "http://thirstyhead.com".toURL().text

//sortie
<html>
  <head>
    <title>ThirstyHead: Training done right.</title>
<!-- snip -->

Pour donner un autre exemple, exécuter une commande shell locale est tout aussi simple qu'effectuer un appel réseau distant. Habituellement, je taperais ifconfig en0 en ligne de commande pour vérifier les paramètres TCP/IP de ma carte réseau. (Si vous utilisez Windows® et non Mac OS X ou Linux®, essayez ipconfig). Avec Groovy, je peux faire la même chose par programmation, comme vous le voyez dans le Listing 5:

Listing 5. Exécuter une commande shell avec Groovy

println "ifconfig en0".execute().text

//sortie
en0: flags=8863<UP,BROADCAST,SMART,RUNNING,SIMPLEX,MULTICAST> mtu 1500
	ether 00:17:f2:cb:bc:6b
	media: autoselect status: inactive
  //snip

Je ne veux pas dire que Groovy apporte un plus parce que vous ne pouvez pas faire la même chose avec Java. Vous le pouvez. Le plus vient du fait que ces méthodes ont apparemment été ajoutées à la classe String - ce qui n'est pas une mince affaire, vu qu'elle est final. (Nous en dirons plus sur ce sujet dans un instant). Le Listing 6 montre l'équivalent Java de String.execute().text:

Listing 6. Exécuter une commande shell avec Java

Process p = new ProcessBuilder("ifconfig", "en0").start();
BufferedReader br = new BufferedReader(new InputStreamReader(p.getInputStream()));
String line = br.readLine();
while(line != null){
  System.out.println(line);
  line = br.readLine();
}

On a un peu l'impression d'être renvoyé d'un guichet de l'administration des impôts (ndT: Department of Motor Vehicles en V.O., mais en France on préfère taper sur les impôts !)  à un autre, n'est-ce-pas ? "Je suis désolé monsieur, mais pour obtenir la String que vous avez demandé, vous devez d'abord faire la queue là-bas pour avoir un BufferedReader."

Evidemment, vous pouvez créer des méthodes et des classes utilitaires pour faire abstraction de toute cette laideur, mais toutes les solutions du type com.mycompany.StringUtil du monde ne sont qu'un pâle substitut à l'ajout direct d'une méthode là où l'on en a besoin: la classe String. (Cela rappelle Platypus.layEgg() !)

Alors, comment Groovy fait-il exactement pour attacher de nouvelles méthodes à des classes qui ne peuvent être ni étendues ni modifiées directement ? Pour le comprendre, il faut connaitre la notion de fermeture et la classe ExpandoMetaClass.

Les fermetures et ExpandoMetaClass

Groovy possède une fonctionnalité à l'air inoffensif mais en réalité extrêmement puissante - les fermetures - sans laquelle l'Ornithorynque ne serait jamais en mesure de pondre un oeuf. Pour faire court, une fermeture est un morceau de code nommé. C'est une méthode n'appartenant à aucune classe. Le Listing 7 montre une fermeture simple:

Listing 7. Une fermeture simple

def shout = {src->
  return src.toUpperCase()
}

println shout("Hello World")

//sortie
HELLO WORLD

Avoir des méthodes autonomes est assez sympathique, mais pas autant, loin s'en faut, que la possibilité d'attacher ces méthodes à des classes existantes. Examinez le code du Listing 8 qui, au lieu de créer une méthode qui prend une String en paramètre, ajoute la méthode directement à la classe String:

Listing 8. Ajout de la méthode shout à la classe String

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

println "Hello MetaProgramming".shout()

//sortie
HELLO METAPROGRAMMING

 

La fermeture sans argument shout() est ajoutée à l'ExpandoMetaClass (EMC) de String. Toute classe - qu'elle soit Java ou Groovy - est enveloppée dans une EMC qui intercepte les appels de ses méthodes. Cela veut dire que même si String est final, on peut ajouter des méthodes à son EMC. Pour un observateur non averti, le résultat est que String semble posséder une méthode shout().

Comme ce type de relation n'existe pas dans le langage Java, Groovy a introduit un nouveau concept: les délégués. Le delegate est la classe que l'EMC enveloppe.

Sachant que les appels de méthodes atteignent l'EMC d'abord, et le delegate ensuite, on peut faire toutes sortes de choses intéressantes. Par exemple, notez de quelle manière on redéfinit la méthode toUpperCase() de String dans le Listing 9:

Listing 9. Redéfinition de la méthode toUpperCase()

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

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

println "Hello MetaProgramming".shout()

//sortie
hello metaprogramming

Encore une fois, tout çà peut sembler futile (et même dangereux !). Dans le monde réel, peu de gens ont besoin de changer le comportement de la méthode toUpperCase(), mais pouvez-vous imaginer quelle bénédiction cela se révèle être pour écrire les tests unitaires de votre code ?

La métaprogrammation offre un moyen simple et facile de rendre déterministe un comportement potentiellement aléatoire. Pour l'illustrer, le Listing 10 redéfinit la méthode statique random() de la classe Math:

Listing 10. Redéfinition de la méthode Math.random()

println "Before metaprogramming"
3.times{
  println Math.random()
}

Math.metaClass.static.random = {->
  return 0.5
}

println "After metaprogramming"
3.times{
  println Math.random()
}

//sortie
Before metaprogramming
0.3452
0.9412
0.2932
After metaprogramming
0.5
0.5
0.5

Maintenant imaginez que vous essayez de tester unitairement une classe qui réalise des appels SOAP coûteux. Nul besoin de créer une interface et d'écrire un bouchon qui l'implémente - vous pouvez redéfinir cette méthode pour qu'elle retourne une simple réponse simulée. (Vous verrez des exemples d'utilisation de Groovy pour les tests unitaires et la création de simulacres dans la prochaine section).

La métaprogrammation de Groovy est un phénomène qui se passe à l'exécution - il dure aussi longtemps que le programme s'exécute. Mais comment faire si vous voulez que votre métaprogrammation aie une durée de vie plus limitée (ce qui est tout spécialement important lors de l'écriture de tests unitaires) ? Dans la prochaine section, vous apprendrez comment définir une portée pour la magie de la métaprogrammation.

Définir la portée de la métaprogrammation

Le Listing 11 place le code dont je me suis servi en exemple dans un GroovyTestCase , pour me permettre de tester un peu plus rigoureusement. (Voir l'article "Practically Groovy: Unit test your Java code faster with Groovy" pour plus de détails sur l'utilisation de GroovyTestCase).

Listing 11. Exploration de la métaprogrammation avec un test unitaire

class MetaTest extends GroovyTestCase{

  void testExpandoMetaClass(){
    String message = "Hello"
    shouldFail(groovy.lang.MissingMethodException){
      message.shout()
    }

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

    assertEquals "HELLO", message.shout()

    String.metaClass = null
    shouldFail{
      message.shout()
    }
  }
}

Tapez groovy MetaTest en ligne de commande pour exécuter ce test.

Notez que vous pouvez annuler la métaprogrammation en positionnant tout simplement String.metaClass à null.

Mais comment faire si vous ne souhaitez pas que la méthode shout() apparaisse pour toutes les instances de Strings ? Eh bien, vous pouvez simplement faire modifier l'EMC d'une instance particulière de la classe, comme le montre le Listing 12:

Listing 12. Métaprogrammation d'une seule instance

void testInstance(){
  String message = "Hola"
  message.metaClass.shout = {->
    delegate.toUpperCase()
  }

  assertEquals "HOLA", message.shout()
  shouldFail{
    "Adios".shout()
  }
}

Si vous voulez ajouter ou redéfinir plusieurs méthodes à la fois, le Listing 13 montre comment définir d'un seul coup plusieurs méthodes:

Listing 13. Métaprogrammation de plusieurs méthodes à la fois

void testFile(){
  File f = new File("nonexistent.file")
  f.metaClass{
    exists{-> true}
    getAbsolutePath{-> "/opt/some/dir/${delegate.name}"}
    isFile{-> true}
    getText{-> "This is the text of my file."}
  }

  assertTrue f.exists()
  assertTrue f.isFile()
  assertEquals "/opt/some/dir/nonexistent.file", f.absolutePath
  assertTrue f.text.startsWith("This is")
}

Remarquez que je ne me soucie plus de savoir si le fichier existe réellement dans le système. Je peux le passer aux autres classes dans le test unitaire, et il se comportera comme un vrai fichier. Une fois que la variable f n'est plus définie à la fin du test, le comportement personnalisé disparait également.

Même si ExpandoMetaClass est indéniablement puissante, Groovy propose une seconde approche de la métaprogrammation avec ses capacités propres: les catégories.

Les catégories et le bloc use

Le meilleur moyen d'expliquer ce qu'est une catégorie est d'en voir une en action. Le Listing 14 montre l'utilisation d'une catégorie pour ajouter une méthode shout() à String:

Listing 14. Utilisation d'une catégorie pour la métaprogrammation

class MetaTest extends GroovyTestCase{
  void testCategory(){
    String message = "Hello"
    use(StringHelper){
      assertEquals "HELLO", message.shout()
      assertEquals "GOODBYE", "goodbye".shout()
    }

    shouldFail{
      message.shout()
      "foo".shout()
    }
  }
}

class StringHelper{
  static String shout(String self){
    return self.toUpperCase()
  }
}

Si vous avez déjà développé en Objective-C, cette technique doit vous paraitre familière. La catégorie StringHelper est une classe normale - elle n'étend aucune classe parent spécifique et n'implémente pas spécialement d'interface. Pour ajouter de nouvelles méthodes à un type T, il faut juste définir des méthodes statiques qui prennent le type T comme premier paramètre. Puisque shout() est une méthode statique qui prend une String comme premier paramètre, toutes les Strings faisant partie d'un bloc use reçoivent une méthode shout().

Alors, quand faut-il préférer une catégorie à une EMC ? Comme vous pouvez le voir, définir une catégorie permet d'ajouter des méthodes à certaines instances - uniquement les instances qui se trouvent à l'intérieur du bloc use.

Alors qu'une EMC vous permet de définir de nouveaux comportements à la volée, une catégorie vous permet d'isoler le comportement dans un fichier classe séparé. Cela implique que vous pouvez l'utiliser dans des circonstances bien différentes: test unitaires, code de production, et ainsi de suite. Le travail supplémentaire requis pour définir des classes distinctes est contrebalancé en termes de réutilisabilité.

Le Listing 15 montre l'utilisation de StringHelper et d'une autre catégorie FileHelper dans le même bloc use:

Listing 15. Utilisation de plusieurs catégories dans un bloc use

class MetaTest extends GroovyTestCase{
  void testFileWithCategory(){
    File f = new File("iDoNotExist.txt")
    use(FileHelper, StringHelper){
      assertTrue f.exists()
      assertTrue f.isFile()
      assertEquals "/opt/some/dir/iDoNotExist.txt", f.absolutePath
      assertTrue f.text.startsWith("This is")

      assertTrue f.text.shout().startsWith("THIS IS")
    }

    assertFalse f.exists()
    shouldFail(java.io.FileNotFoundException){
      f.text
    }
  }
}


class StringHelper{
  static String shout(String self){
    return self.toUpperCase()
  }
}


class FileHelper{
 static boolean exists(File f){
   return true
 }

 static String getAbsolutePath(File f){
   return "/opt/some/dir/${f.name}"
 }

 static boolean isFile(File f){
   return true
 }

 static String getText(File f){
   return "This is the text of my file."
 }
}

Cependant l'aspect le plus intéressant des catégories est leur implémentation. Les EMCs requièrent l'utilisation de fermetures, ce qui a pour conséquence que vous ne pouvez les implémenter qu'en Groovy. Comme les catégories ne sont rien d'autre que des classes avec des méthodes statiques, elles peuvent être définies en Java. En réalité vous pouvez réutiliser des classes Java existantes - qui n'ont jamais été conçues expressément pour la métaprogrammation - avec Groovy.

Le Listing 16 donne un exemple d'utilisation du package Jakarta Commons Lang (voir les Ressources) pour la métaprogrammation. Par coïncidence, toutes les méthodes qui se trouvent dans org.apache.commons.lang.StringUtils suivent le modèle défini pour les catégories - des méthodes statiques qui prennent une String en premier paramètre. Cela veut dire que l'on peut utiliser telle quelle la classe StringUtils comme une catégorie.

Listing 16. Utilisation d'une classe Java pour la métaprogrammation

import org.apache.commons.lang.StringUtils

class CommonsTest extends GroovyTestCase{
  void testStringUtils(){
    def word = "Introduction"

    word.metaClass.whisper = {->
      delegate.toLowerCase()
    }

    use(StringUtils, StringHelper){
      //from org.apache.commons.lang.StringUtils
      assertEquals "Intro...", word.abbreviate(8)

      //from the StringHelper Category
      assertEquals "INTRODUCTION", word.shout()

      //from the word.metaClass
      assertEquals "introduction", word.whisper()
    }
  }
}

class StringHelper{
  static String shout(String self){
    return self.toUpperCase()
  }
}

Tapez groovy -cp /jars/commons-lang-2.4.jar:. CommonsTest.groovy pour exécuter le test. (Bien sûr, vous devez modifier le chemin pour refléter l'endroit où se trouve le JAR dans votre système).

Métaprogrammation et REST

Juste afin de m'assurer que je ne vous laisse pas avec l'impression erronnée que la métaprogrammation est utile seulement pour les tests unitaires, voici un dernier exemple. Rappelez-vous l'exemple du service web de type REST fourni par Yahoo! afin d'avoir les conditions météorologiques actuelles, que nous avons utilisé dans le volet "Générer, analyser et ingurgiter du XML". Combinez les compétences concernant XmlSlurper que vous avez acquises dans cet article avec les compétences en métaprogrammation acquises venant de celui-ci, et vous pouvez consulter la météo pour n'importe quel code ZIP en 10 lignes de code, comme dans le Listing 17:

Listing 17. Ajout d'une méthode weather

String.metaClass.weather={->
  if(!delegate.isInteger()){
    return "The weather() method only works with zip codes like '90201'"
  }
  def addr = "http://weather.yahooapis.com/forecastrss?p=${delegate}"
  def rss = new XmlSlurper().parse(addr)
  def results = rss.channel.item.title
  results << "\n" + rss.channel.item.condition.@text
  results << "\nTemp: " + rss.channel.item.condition.@temp
}

println "80020".weather()


//sortie
Conditions for Broomfield, CO at 1:57 pm MDT
Mostly Cloudy
Temp: 72

Comme vous le voyez, la métaprogrammation apporte une souplesse extrême. Vous pouvez utiliser une ou plusieurs des techniques mises en valeurs dans l'article pour ajouter aisément des méthodes à une, plusieurs ou toutes les classes que vous voulez.

Conclusion

Il est tout simplement irréaliste d'attendre de la réalité qu'elle se conforme aux limitations arbitraires de votre langage. La modélisation du monde réel dans un logiciel nécessite un outil assez souple pour gérer tous les cas limite. Heureusement, avec les fermetures de Groovy, les ExpandoMetaClasses, et les catégories, vous disposez d'un ensemble d'outils bien affûté pour ajouter des comportements où vous en avez besoin, quand vous en avez besoin.

La prochaine fois, je m'arrêterai sur la puissance de Groovy pour les tests unitaires. Il y a de réels bénéfices à écrire vos tests en Groovy, que ce soit un GroovyTestCase ou un test JUnit 4.x avec des annotations. Vous verrez également GMock à l'oeuvre, un framework de simulacres écrit en Groovy. En attendant, j'espère que vous trouverez beaucoup d'utilisations pratiques de Groovy.