Les annotations de Java 5

Romain Guy

La dernière tendance à la mode en programmation consiste à utiliser des métadonnées. Vous pouvez d'ores et déjà les utiliser avec C# et la prochaine version 2.4 de Python nous les promet. La dernière version de Java, le J2SE 5 SDK 1.5, propose également les métadonnées, appelées annotations.

- Titre : Les annotations de Java 5

- Auteur : Romain Guy

- Parution : 12 juin 2006

- Licence : Ces textes sont disponibles sous licence Creative Commons Attribution-ShareAlike. La copie, modification et/ou distribution par quelque moyen que ce soit est soumise à l'obtention préalable de l'autorisation de l'auteur.

Sommaire

- Introduction

- Les annotations standard et personnalisées

- Exploiter vos annotations

Introduction

La définition la plus simple des métadonnées, et donc des annotations, pourrait se formuler de la sorte : il s'agit de données servant à décrire d'autres données. Malgré leur apparition tardive au sein du langage, ces métadonnées ont en réalité toujours fait partie intégrante de la plateforme Java. Le dernier exemple est celui d'XDoclet, largement employé pour la génération de code. Un autre outil, bien connu de tous les développeurs Java, sert aussi à manipuler des métadonnées, javadoc. Habituellement employé pour la rédaction et la génération de documentation, processus qui s'apparente déjà aux métadonnées, cet outil dispose d'une balise particulière répondant directement à notre définition. Cette dernière, intitulée @deprecated, indique qu'une méthode est obsolète. En l'utilisant, vous pourrez rencontrer des avertissements lors de la compilation. Les annotations sont issues de la volonté de standardiser et d'épurer ce système. Nous avons en effet ici un mélange peu élégant entre les commentaires et le code lui-même. Dans l'idéal, @deprecated devrait apparaître directement dans le source et non dans les commentaires. La solution proposée par Sun est, comme nous allons le voir, très flexible et adaptée à tous les usages.

L'utilisation de la métadonnée @deprecated de javadoc présente l'un des deux aspects les plus importants des annotations, à savoir la possibilité d'effectuer des vérifications à la compilation et celle d'analyser le code. Le premier aspect peut par exemple servir à vérifier qu'une méthode marquée obsolète n’est pas invoquée dans d'autres parties du code. L'aspect analyse de code est principalement utilisé pour la génération de code en fonction des métadonnées. Nous pouvons par exemple imaginer une annotation indiquant qu'un paramètre d'une méthode ne doit pas être de valeur null. Des outils de gestion des annotations pourront ensuite générer un code source contenant des lignes de code supplémentaires garantissant l'assertion. Une des grandes forces des annotations réside dans la possibilité de les appliquer à toutes les déclarations de votre code : classes, interfaces, méthodes, membres, constructeurs… Il suffit pour cela de faire précéder la déclaration de l'annotation.

Dans Java 5 un vocabulaire particulier est utilisé pour désigner les métadonnées. Les types d'annotations désignent l'équivalent des classes tandis que les annotations sont les instances des types d'annotations. Il existe en outre trois catégories de types d'annotations. La première concerne les marqueurs, ou markers, qui sont des métadonnées identifiées par un simple nom, comme @Deprecated ou @Override. Un type d'annotation commence toujours par une majuscule et une annotation est créée en précédant le nom du type par @. La deuxième catégorie comprend les annotations paramétrées. Celles-ci disposent donc d'une donnée supplémentaire que le programmeur peut choisir. Si nous disposions d'une annotation servant à définir le niveau de sécurité d'une méthode nous pourrions par exemple écrire @SecurityLevel("maximum"). Enfin, la dernière catégorie concerne les annotations multiparamétrées, ou annotations complètes. Nous pouvons dans ce cas fournir plusieurs paramètres en prenant garde de les nommer. Nous pourrions par exemple écrire @NotNull(argument="file", message="Le fichier ne peut être null.") afin d'afficher le message spécifié si l'argument « file » de la méthode prend la valeur null à l'exécution. Lorsque vous fournissez des paramètres à une annotation, vous pouvez utiliser des tableaux de tailles fixes à l'aide des accolades. Voici l'exemple d'une annotation Todo très simple associant plusieurs tâches à l'élément sur lequel elle s'applique :

exemple @ToDo

@Todo({ "Vérifier le type de retour.", "Optimiser la boucle."})
public String compute() { ... }

Cet exemple fait appel à une annotation personnalisée, créée de toutes pièces. Avant de nous intéresser à leur réalisation, examinons celles proposées par le SDK.

Les annotations standard et personnalisées

Java 5 propose deux annotations standard. La première, @Deprecated, est semblable à celle proposée par javadoc. Il s'agit d'un marqueur que vous pouvez appliquer sur une classe, une interface, un membre ou une méthode pour le rendre obsolète comme dans l'exemple du listing 1.

listing 1

@Deprecated
public class OldBehavior { }
// ...
OldBehavior ob = new OldBehavior();

En compilant un tel source, vous obtiendrez un message peu précis du compilateur indiquant qu'une API obsolète est utilisée. Pour obtenir plus de détails, ajoutez l'option -Xlint:deprecation sur la ligne de commande d'invocation de javac. La seconde annotation standard est également un marqueur et permet d'éviter de rencontrer des bogues sournois. En ajoutant @Override devant une déclaration de méthode vous indiquez que celle-ci est censée surcharger une méthode parente. Si aucune superméthode correspondante ne peut être trouvée, une erreur de compilation est lancée. Ceci peut être très utile pour démasquer des bogues comme celui du listing 2. Aviez-vous remarqué le caractère manquant ?

listing 2

@Override
public String toSting() {
return "foobar";
}

Bien que très utiles ces deux annotations restent très limitées et n'offrent que peu de nouvelles perspectives par rapport à ce que nous pouvions faire jusqu'alors avec javadoc ou XDoclet. La véritable nouveauté réside dans la possibilité de créer ses propres métadonnées. Concrètement, une annotation se rédige comme une interface normale, à savoir en créant un fichier .java portant le même nom qu'elle.

listing 3

package com.loginmag.tiger;
public @interface Debug { }
// ...
import com.loginmag.tiger.*;
@Debug
public void hiddenMethod() { ... }

Le listing 3 présente un exemple de type d'annotation Debug, appartenant au paquet com.loginmag.tiger, et son utilisation. La déclaration d'une métadonnée est régie par l'utilisation du caractère @ devant le mot-clé interface. En conservant notre travail tel quel, nous ne disposons que d'un marqueur. La déclaration d'annotations paramétrées ou multiparamétrées implique l'ajout de membres, comme dans le listing 4.

listing 4

public @interface SecurityLevel {
String value();
}
// ...
@SecurityLevel("maximum")
public void startNuclearReactor() { ... }

En déclarant un membre, suivi de parenthèses, nous ajoutons un paramètre nommé à l'annotation. Quand ce dernier est unique et intitulé value, comme ici, nous pouvons l'omettre lors de l'utilisation de l'annotation. Pour accepter des tableaux comme paramètres, à l'instar de notre exemple Todo précédent, il nous suffit de déclarer le membre comme un tableau en écrivant String[] value. Les annotations multiparamétrées peuvent également bénéficier de paramètres optionnels, c'est-à-dire disposant d'une valeur par défaut, ainsi qu'en atteste le listing 5.

listing 5

public @interface Log {
public enum Level { MESSAGE, WARNING, ERROR };
String message();
String level() default Level.WARNING;
}

Si vous souhaitez attribuer une valeur par défaut à un tableau, sachez que vous ne devez ni ne pouvez utiliser la syntaxe Java classique new Type[] { donnée } mais celle des annotations qui ne nécessite que les accolades. Nous écririons par exemple String[] messages() default { "Debug message" }.

Outre les deux annotations standard évoquées précédemment, Java 5 propose quatre annotations particulières destinées aux types d'annotations. Elles appartiennent au paquet java.lang.annotation et vous permettent de modifier le comportement de vos annotations personnalisées. La première d'entre elles s'appelle @Target et définit les éléments sur lesquels peut s'appliquer votre type d'annotation. Vous devez pour cela fournir un tableau de valeurs puisées dans l'énumération ElementType. Ainsi pour définir une annotation applicable uniquement sur les déclarations de méthodes et les constructeurs vous devrez écrire @Target({ ElementType.METHOD, ElementType.CONSTRUCTOR }). Les cibles disponibles sont décrites dans la documentation d'ElementType. Toute tentative d'utilisation de votre annotation ne respectant pas les cibles se soldera par une erreur de compilation. La deuxième annotation, @Retention, concerne la rétention des informations relatives aux annotations. Vous pouvez choisir un comportement parmi ces trois : l'annotation est conservée dans le bytecode et durant l'exécution (RetentionPolicy.RUNTIME), l'annotation est conservée dans le bytecode (RetentionPolicy.CLASS) ou l'annotation n'est conservée que dans le code source (RetentionPolicy.SOURCE). Ces options influencent directement sur les outils de manipulation des métadonnées. Ainsi @Retention(RetentionPolicy.COMPILE) ne vous permettra pas d'utiliser java.lang.reflect pour trouver l'annotation. La troisième annotation est @Documented. En l'utilisant, vous indiquez à javadoc que vous souhaitez voir apparaître l'annotation dans la documentation générée. En effet, javadoc n'indique pas par défaut quelles annotations sont employées dans votre code source. Sachez également que l'utilisation de @Documented impose @Retention(RetentionPolicy.RUNTIME). Enfin, la dernière annotation, @Inherited, permet d'appliquer automatiquement votre type d'annotation aux descendants de la classe sur laquelle elle s'applique. Par exemple, si votre classe Vehicule dispose de l'annotation @Todo, elle-même @Inherited, alors votre classe Voiture héritant de Véhicule héritera de @Todo.

Exploiter vos annotations

Une annotation personnalisée, aussi bien pensée soit-elle, ne vous sera d'aucune utilité si vous ne créez pas les outils capables de l'interpréter. Nous pourrions faire appel au procédé de réflexion, à la manipulation de bytecode ou encore à un générateur de parser comme JavaCC mais aucune de ces solutions ne serait véritablement satisfaisante. Les auteurs du SDK 1.5 ont heureusement pensé à tout en ajoutant apt, pour annotation processing tool, un dérivé un peu particulier de javac. En pratique, apt vous permet de compiler vos programmes Java tout en interprétant vos différentes annotations à l'aide de processeurs spécialisés. Ces processeurs sont créés par des fabriques, que apt reconnaît au démarrage, et peuvent générer de nouveaux fichiers source. Lorsqu'un processeur génère un nouveau fichier, apt recommence son travail avec celui-ci jusqu'à ce qu'aucun nouveau fichier n'apparaisse. Nous allons explorer apt à travers l'exemple de l'annotation @Todo, décrite dans le listing 6.

listing 6

@Retention(RetentionPolicy.SOURCE)
public @interface Todo {
String[] value() default { "Give this @Todo a value." };

Pour ce faire, nous allons devoir utiliser quatre sous-paquets de com.sun.mirror : apt, qui contient les interfaces avec l'outil du même nom, declaration, qui contient les classes décrivant des déclarations d'entités dans le code source, type, qui modélise ces entités, et util qui propose quelques outils. Nous allons commencer par créer une fabrique de type AnnotationProcessorFactory qui contient trois opérations, une pour créer le processeur, et deux pour définir les types d'annotations supportées ainsi que les options attendues sur la ligne de commande. Le code de la fabrique est très simple et se trouve dans le fichier TodoAnnotationProcessorFactory.java dans le zip accompagnant cet article (cf. lien en fin d'article). Nous devons ensuite implanter l'interface AnnotationProcessor pour le processeur lui-même. La méthode process() est appelée pour l'interprétation des annotations déclarées comme compréhensibles par le processeur.

listing 7

public void process() {
try {
Filer f = this.env.getFiler();
PrintWriter out = f.createTextFile(Filer.Location.SOURCE_TREE, "", new File("todo"), null);
for (TypeDeclaration typeDecl : env.getSpecifiedTypeDeclarations())
typeDecl.accept(DeclarationVisitors.getDeclarationScanner(new TodoVisitor(out), DeclarationVisitors.NO_OP));
out.close();
} catch (Exception e) { }
}

Le listing 7 présente notre code qui ouvre un fichier texte appelé « todo » dans l'arbre des sources et qui le passe en paramètre à des visiteurs. Notez que nous utilisons une méthode du contexte du processeur pour créer le fichier, ce qui permet à apt de savoir qu'un document a été généré. Les visiteurs sont des classes qui permettent de parcourir les déclarations sur lesquelles elles sont appliquées. Dans notre méthode process() nous appliquons notre visiteur TodoVisitor sur chaque type. Le visiteur est lui-même encapsulé dans un autre visiteur fourni par la classe d'outils DeclarationVisitors. La méthode getDeclarationScanner() nous fournit un visiteur capable de voir les déclarations contenues dans une déclaration, comme les méthodes dans une classe. Le premier paramètre définit le visiteur à appeler avant de visiter les déclarations internes tandis que le second définit le visiteur appelé après visite. Puisque nous n'avons besoin que d'un seul visiteur, nous utilisons le NO_OP fourni en standard, qui ne fait rien. Nous pouvons également invoquer getSourceOrderDeclarationScanner() qui effectue le même travail mais en essayant de suivre l'ordre du code source au plus près. Notre TodoVisitor ne surcharge que la seule méthode visitMethodDeclaration() bien que de nombreuses autres soient disponibles. Chacune de ces méthodes agit comme un gestionnaire d'événements lorsqu'une déclaration est rencontrée.

listing 8

public void visitMethodDeclaration(MethodDeclaration d) {
Todo n = (Todo) d.getAnnotation(Todo.class);
out.printf("TODO in %s() at line %d:", d.getSimpleName(), d.getPosition().line());
out.println();
for (String value: n.value())
out.println("  " + value);
out.println();
}

Le listing 8 présente l'implantation de notre méthode qui écrit dans le fichier « todo » toutes les valeurs associées à l'annotation @Todo de la méthode. Nous pouvons maintenant procéder à la compilation du processeur de la classe d'exemple, présentée dans le listing 9 :

listing 9

public class Computer {
@Todo({"Check annotations.",
"Change the return value."
})
public String compute() {
return "result";
}

@Todo({"Return the value of compute()."
})
public String toString() {
return super.toString();
}
}

$ javac -classpath $JAVA_HOME/lib/tools.jar:. TodoAnnotationProcessorFactory.java
$ apt -factory TodoAnnotationProcessorFactory Computer.java

Après exécution de la deuxième commande, vous obtiendrez les fichiers Computer.class et todo, ce dernier contenant la liste des tâches déclarées dans le code source. Cet exemple n'a fait qu'effleurer les nombreuses fonctionnalités offertes par l'outil apt et son API Mirror, aussi pourrez-vous consulter leur documentation pour aller plus loin.

Téléchargez les sources des exemples

Romain Guy