Bien comprendre les "generics" de Java

Depuis sa version 1.5, le JDK met à disposition le concept de "generics" ou "types paramétrés". Leur but est de permettre l'ajout d'informations de typage sur des objets pour définir leur domaine d'utilisation.

Un exemple rapide pour comprendre leur usage :

List myIntList = new LinkedList(); 
myIntList.add(new Integer(0)); 
Integer x = (Integer) myIntList.iterator().next(); 

Peut être ré-écrit de manière plus propre :

List<Integer> myIntList = new LinkedList<Integer>(); 
myIntList.add(new Integer(0));
Integer x = myIntList.iterator().next(); 

L'ajout du type paramétré <Integer> permet d'indiquer au compilateur (et accessoirement au développeur!) que notre liste n'acceptera que des Integer. Cependant, ceci n'est qu'une infime partie des possibilités offertes par les type paramétrés, bien plus vastes, puissantes et subtiles...

Wildcards

L'utilisation de la notation que nous venons de voir est très restrictive, comme le montre l'exemple suivant :

public static void main(String[] args) {
 List<String> ls = new ArrayList<String>();
 testMethod(ls);
}

private static void testMethod(List<Object> params) {
 for (Object o : params) {
  System.out.println(o);
 }
}

Ce code ne compilera pas car nous avons clairement décrit que la méthode testMethod n'accepte qu'une liste d'Object. Et ce n'est pas parce que String est un sous-type de Object que List<String> est un sous-type de List<Object>!! Pour résoudre ce problème, il faut utiliser les wildcards ("jokers"). Notre code deviendra donc :

public static void main(String[] args) {
 List<String> ls = new ArrayList<String>();
 testMethod(ls);
}

private static void testMethod(List<?> params) {
 for (Object o : params) {
  System.out.println(o);
 }
}

Mais avec cette notation, nous avons modifié le contrat initialement élaboré par notre méthode. En effet, maintenant, elle est susceptible de recevoir n'importe quel type d'objet ce qui pourrait compromettre son comportement. Un moyen plus subtile d'arriver à notre fin est d'utiliser les types paramétrés contraints ("bounded wildcards") :

public static void main(String[] args) {
 List<String> ls = new ArrayList<String>();
 testMethod(ls);
}

private static void testMethod(List<? extends MySuperClass> params) {
 for (Object o : params) {
  System.out.println(o);
 }
}

Ainsi nous pouvons restreindre l'utilisation de notre méthode aux seuls objets héritant de MySuperClass. Mais cette syntaxe a aussi ses inconvénients : puisque nous déclarons notre méthode comme utilisant "une liste d'objets de type inconnus héritant de MySuperClass", cette dernière ne sera accessible qu'en lecture seule. En effet, le code suivant créera une erreur de compilation :

private static void testMethod(List<? extends MySuperClass> params) {
 params.add(new MySuperClass());
}

Méthodes génériques

Pour aller plus loin avec les types paramétrés, il est possible de mette en place des méthodes génériques, qui pourront être utilisées avec tout type d'objets (très pratiques dans le cas de la réutilisation de code existant) :

private static <T,E> List<E> testMethod(List<T> params) {
 List<E> result = new ArrayList<E>();
 for(T var : params) {
  result.add(transformTtoE(var));
 }
 return result;
}

Cette méthode utilise deux types génériques T et E (définis arbitrairement). Elle retourne une liste du second et prend en entrée une liste du premier. Ainsi, elle pourra très bien être utilisée comme ceci :

List<String> params = new ArrayList<String>();
List<MyBean> list = testMethod(params);

On voit donc l'ampleur des possibilités qu'offre cette extension du JDK. Comme d'habitude, si vous voulez approfondir le sujet, consultez les liens indiqués dans les sources, mon but ici était de mettre en avant par une rapide présentation un autre aspect trop peu utilisé de Java.

Edit (25/01/2013) : Voici une petite méthode pour récupérer le className d'un type générique (via stackoverflow) :

import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;

abstract class MyClass {
  public MyClass() {        
    Type genericSuperclass = this.getClass().getGenericSuperclass();
    if (genericSuperclass instanceof ParameterizedType) {
      ParameterizedType pt = (ParameterizedType) genericSuperclass;
      Type type = pt.getActualTypeArguments()[0];
      System.out.println(type); // prints class java.lang.String for FooClass
    }
  }
}

public class FooClass extends MyClass { 
  public FooClass() {
    super();
  }
  public static void main(String[] args) {
    new FooClass();
  }
}

Les pour et contre

Comme pour toute chose, en lisant quelques documents sur le Net, on se rend vite compte que certaines personnes ont un avis négatif sur ces generics. Il est néanmoins intéressant d'étudier leur point de vue. J'ai tiré cet exemple d'une discussion initiée par un certain Eric Armstrong (source 2). Certes elle commence à dater mais les arguments utilisés n'ont pas vieillis.

Les principaux arguments utilisés par Eric Armstrong sont d'une part la lourdeur de cette syntaxe ainsi que la perte de lisibilité qu'elle entraine et d'autre part le travail supplémentaire qu'elle implique, je cite :

I just called generics "syntactic sugar". In this case, the sugar makes the code more palatable to the compiler, not the coder. You have to pour it on, but you don't get to enjoy it.

Pour résumer, il explique que les generics sont faits pour faciliter l'exécution du code par le compileur et non son utilisation par le développeur. Or, pour avoir plusieurs fois manipulé du code "ancien", non typé, il est vite agaçant d'avoir à caster des objets à tour de bras ou perdre du temps à lire l'implémentation d'une méthode pour savoir comment elle manipule ses paramètres à cause d'une signature trop peu explicite.

Eric Armstrong continue en comparant le syntaxe de Ruby à celle de Java, beaucoup plus permissive légère, mais qui à mon sens comporte aussi plus de risques : l'utilisation des generics pour du "typage fort" des objets permet de mieux cerner leur utilisation et leur évolution, sans que ce soit le compileur qui exécute des actions "magiques". Un intervenant de la discussion le démontre très bien en citant un des dangers déjà présent dans Java, l'autoboxing :

For example, I particularly dislike the automatic type boxing feature:

Integer i = 12; or
int i = new Integer(12);

Yes, it's shorter . More "elegant" than Integer.valueOf(12) according to Erics formula, but it's also wrong and confusing to newcomers. 12 is a primitive type and Integer is an object, they are not equal. This can cause some subtle errors that are hard to notice. For example every time you have an overloaded method that accepts a primitive type and an object, such as in ArrayList:
remove(Object o);
remove(int index);

I had an Integer object "i" and was calling list.remove(i), expecting to remove the object at a specific index (thanks to autoboxing). Of course, the actual method being called was remove(Object) and nothing was being removed, because my Integer object wasn't in the list.

Vous pouvez vous faire votre propre avis, mais il est certain que l'utilisation des generics apporte de réels avantages quant à la maintenance et l'évolution d'une application...

Sources


Fichier(s) joint(s) :

4 commentaires:

Capibara a dit…

Salut,

Il y a une erreur dans ton texte dans ta première partie (avant les wildcard), tu écris String alors que c'est en fait d'Integer que tu parles.

Sinon, c'est toujours très intéressant.

Paul-Emmanuel Faidherbe a dit…

Merci! La correction est faite.

Anonyme a dit…

Le dernier exemple n'est pas bon.
La méthode remove(Object) teste l'égalité via la méthode equals(), et du coup deux objets Integer différents ayant la même valeur seront considérés comme égaux.

Paul-Emmanuel Faidherbe a dit…

Exact. Le problème cité par Eric Armstrong n'est pas pertinent. Un simple code de test :

public static void main(String[] args) {
List liste = new ArrayList();
liste.add(new Integer(0));
liste.add(new Integer(1));
liste.add(new Integer(3));
for(int i : liste){
System.out.println(i);
}
System.out.println("#");
liste.remove(new Integer(3));
for(int i : liste){
System.out.println(i);
}
System.out.println("#");
liste.remove(0);
for(int i : liste){
System.out.println(i);
}
}

Sortie en console :

0
1
3
#
0
1
#
1

Cela confirme bien l'utilisation de la méthode equals() et que le comportement est correct.
Merci d'avoir remarqué cela!