Lumière sur les génériques – 1

En ces périodes de restrictions de prestations sociales, utiliser les génériques est une recommandation classique. Ne reculant devant rien pour satisfaire à l’effort national de réduction des déficits, je me suis lancé dans cette mise en lumière, en me disant qu’il en sortirait bien quelques chose.

Les génériques, c’est un peu comme le vélo, ça a tendance à marcher tout seul. Et si ce n’est pas le cas, tout le monde sait qu’il est inutile de chercher à comprendre, et qu’il sera beaucoup plus efficace de bombarder son code de casts aléatoirement distribués jusqu’à ce que ça marche à nouveau.

Et puis, à un moment, malgré ce remède pourtant préconisé par le ministère de la Santé, ça déraille vraiment, et là on se retrouve les mains dans le cambouis, en se demandant ce qui a bien pu se passer.

Les génériques sont passés dans les mœurs depuis Java 5. L’impact a été énorme, puisque des centaines d’API ont été adaptées lors de leur introduction. Dans la plupart des cas, leur utilisation est naturelle, ce qui les rend très agréables à utiliser. Mais leur spécification recèle des mystères, et parfois des erreurs de compilation cryptiques apparaissent.

C’est à un petit voyage sous ces arcanes que je te convie, cher et précieux lecteur. Il ne nous mènera pas beaucoup plus loin que l’incomparable JLS, mais peut-être y découvriras-tu des paysages nouveaux ?

Introduction

On parle de l’introduction des génériques en Java quasiment depuis que ce langage existe. La JSR qui couvre cette partie de la spécification porte le n°14, et est datée de 2001. La sortie de Java 4 en 2002 ne verra pas son introduction, ce n’est qu’en 2004 lors de la sortie de Java 5 que les génériques seront disponibles.

L’idée originelle était d’introduire les templates du C++ en Java. S’en sont suivis des débats homériques sur l’intérêt et la faisabilité d’un tel objectif. Finalement, les génériques ne sont pas des templates, pour de nombreuses raisons. Java n’est pas le C++, cela a même fait partie des prérequis du langage.

Au début était la déclaration

Qu’est-ce qu’une variable générique ? La définition est simple : il s’agit d’une variable dont le type n’est pas connu.

On utilise traditionnellement, T pour dénoter ce type. S’il s’agit d’un élément dans une collection, on le note E, s’il s’agit d’une clé on le note K (le lecteur angliciste distingué, dont je ne doute pas que tu fais partie, précieux lecteur, aura reconnu la première lettre de key) et enfin, s’il s’agit d’une valeur, on le note V. Comme quoi les informaticiens sont des gens d’une grande imagination !

On peut borner (traduction personnelle de bound) le type générique d’une variable. Lors de sa déclaration, on peut écrire qu’il étend une classe, ou des interfaces. Un type générique ne peut pas étendre plus d’une classe, qui doit être déclarée en premier dans la liste, et peut étendre autant d’interfaces que l’on souhaite. On utilise pour cela le mot-clé extends, qui n’a pas du tout la même sémantique que le même mot-clé utilisé pour déclarer qu’une classe en étend une autre.

public class ClasseGenerique<T extends Number & Serializable & Comparable> {

	// contenu de la classe générique
}

Le type générique T que nous déclarons ici est donc une extension de la classe Number, et doit implémenter les interfaces Comparable et Serializable. L’exemple choisi pourrait être meilleur, vu que Number implémente déjà Serializable, et que Comparable est une interface générique dont il faudrait préciser le type, mais passons là-dessus.

Les classes ou interfaces étendues peuvent elles-mêmes être génériques. Je vais détailler ce qu’est le fameux mécanisme du type erasure dans quelques lignes, mais disons tout de suite qu’une fois ce type erasure appliqué, on ne peut pas avoir de doublon dans la liste des interfaces. Concrètement, la déclaration suivante est une erreur de compilation, quand bien même Float étend Number, et donc pourrait se résumer au fait que T doit étendre Comparable<Number>.

// ERREUR DE COMPILATION !!
public class ClasseGenerique<T extends Comparable<Number> & Comparable<Float>> {
}

Un type générique ne peut pas étendre, directement ou indirectement, la classe Throwable. La conséquence est qu’une exception ne peut pas être générique, et qu’une clause catch ne peut jamais contenir de type générique.
Un type générique peut être déclaré en quatre endroits :

  • sur la déclaration d’une classe ou d’une interface ;
  • sur la déclaration d’une méthode ou d’un constructeur.

On ne peut pas déclarer de type générique sur un champ, statique ou non.

On peut déclarer plusieurs types génériques, séparés par des virgules. Un type générique déclaré sur une classe peut être utilisé dans tous les membres non statiques de cette classe. Un membre statique (champ ou méthode), ou un bloc statique ne peut pas utiliser de type générique déclaré sur une classe. La déclaration sur une classe ou une interface est la plus simple, et la plus souvent rencontrée. Prenons l’exemple de l’interface Comparable, du JDK.

public interface Comparable<T> {

	public int compareTo(T t) ;
}

La déclaration sur une méthode ou un constructeur suit une syntaxe différente, et se rencontre moins souvent. Elle permet de déclarer des méthodes (ou constructeurs) génériques dans des classes qui ne le sont pas. Le JDK nous offre un exemple : la méthode asList() de  la classe Arrays.

public class Arrays { // pas de type générique déclaré sur cette classe

	public static <T> List asList(T... a)  {
		return new ArrayList(a) ;
	}
}

Le type générique est déclaré avant le nom de la méthode (qui peut être statique ou non), et utilisé normalement dans la liste de ses paramètres, son type de retour et son corps. La déclaration d’un type générique sur un constructeur est identique.

On peut utiliser un type générique dans la clause throws d’une méthode. En revanche, la clause catch ne peut pas prendre de type générique en paramètre. Nous verrons dans la suite que le mécanisme de type erasure impose quelques contraintes sur l’utilisation de types génériques déclarés sur des méthodes.

Le type erasure

Ce mécanisme, d’effacement de type, est ce qui fait la principale différence entre les génériques de Java et les templates du C++. En fait, le type générique n’est pas écrit dans le bytecode, il en est effacé. Prenons l’exemple de la classe Holder<T>.

public class Holder<T> {

	private T t ;

	public Holder(T t) {
		this.t = t ;
	}

	public T getValue() {
		return this.t ;
	}
}

Examinons le bytecode généré pour la déclaration du champ t.

// access flags 0x2
// signature TT;
// declaration: T
private Ljava/lang/Object; t

Damned ! Les génériques ne seraient-ils qu’une vaste escroquerie, une complexité inutile ajoutée au langage, et glissée sous le tapis par le compilateur ? C’est ce que l’on pourrait penser si l’on ne regardait pas ce qui se passe au niveau des appels de cette classe. Écrivons quelques lignes qui utilisent notre classe Holder.

Holder<String> h = new Holder<String>("Bonjour") ;
String s = h.getValue() ;

Et examinons le bytecode qui a été généré pour l’appel à la méthode getValue().

ALOAD 1
INVOKEVIRTUAL org/paumard/Holder.getValue()Ljava/lang/Object;
CHECKCAST java/lang/String
ASTORE 2

On se rend compte que l’appel générique a tout simplement été traduit par un appel à la méthode non générique, et converti dans le bon type lors de l’appel.

Le problème est à peine plus complexe si le type générique est borné. Que se passe-t-il si notre classe Holder était définie sur le type générique T extends Comparable ? Simplement, plutôt que de compiler une classe Holder construite sur Object (comme dans notre exemple), le compilateur construit une classe Holder sur Comparable. L’appel à getValue() que nous avons écrit ne change pas : la conversion se fera toujours vers la classe String (dans notre exemple).

Le bornage peut déclarer plusieurs types : éventuellement une unique classe et une liste d’interfaces. Les choses sont ici un peu plus subtiles, car la classe compilée est construite sur le premier type déclaré dans la liste. Le choix de ce premier type, quand il est possible, a donc de l’importance. Intuitivement, on peut estimer que de mettre en tête le type le plus restrictif (s’il existe) est une bonne idée. Mais on peut également examiner la façon dont la classe est écrite. Reprenons l’exemple de notre classe Holder, en la modifiant un peu.

public class Holder<T extends Comparable & Serializable> {

	// début de la classe inchangé

	public Serializable getValue() {
		return this.t ;
	}
}

Voici le code compilé de la méthode getValue().

ALOAD 0
GETFIELD org/paumard/Holder.t : Ljava/lang/Comparable;
CHECKCAST java/io/Serializable
ARETURN

On se rend compte qu’il comporte une conversion systématique, dans la mesure où le champ t de cette classe est de type Comparable. Si l’on déclare Serializable en tête dans les bornes, alors le champ t prend ce type, et la conversion disparaît de la méthode getValue().

Conséquences du type erasure

Le type erasure a d’emblée quatre conséquences :

  • la comparaison des classes génériques mène à des paradoxes ;
  • on ne peut pas instancier directement une variable à partir d’un type générique ;
  • les tableaux de types génériques sont interdits ;
  • il est illégal de créer des méthodes prenant des paramètres génériques, qui pourraient entrer en collision avec des méthodes existantes.

Le premier point est une conséquence directe et évidente. Étant donné que l’on n’a qu’une unique classe de laquelle on a retiré toute référence au type générique, la méthode getClass() retourne toujours la même classe quelle que soit l’instance d’une classe générique que l’on regarde.

Holder<String> h1 = new Holder<String>("Bonjour !") ;
Holder<Float> h2 = new Holder<Float>(3.14) ;

boolean b = h1.getClass() == h2.getClass() ; // ce booléen vaut true

On retrouve ce paradoxe sur l’utilisation de instanceof. Même si l’on peut utiliser cet opérateur avec des types génériques, ceux-ci ne sont pas pris en compte.

Le deuxième point signifie que l’on ne peut pas écrire T t = new T() ou encore T t = T.class.newInstance(). Le pattern préconisé pour instancier une variable générique à partir de son type est le suivant.

public static <T> T newInstance(Class<T> clazz) {

	// ajouter la gestion des exceptions
	return clazz.newInstance() ;
}

Ce pattern utilise la classe de la variable générique. Notons que la classe Class est elle-même une classe générique.

Le troisième point est un peu plus délicat, et repose sur le fonctionnement des tableaux en Java. Prenons un exemple. Le code suivant compile sans problème.

String [] tabString = new String [10] ;
Object [] tabObject = tabString ;
tabObject[0] = new Object() ; // ArrayStoreException

En revanche, dans la mesure où un tableau Java vérifie le type de ses éléments, une ArrayStoreException sera jetée à l’exécution de la ligne 3.

Suivons cette philosophie, et écrivons le même cas d’utilisation, cette fois avec des variables génériques.

// la ligne suivante ne compile pas !!
Holder<String> [] tabString = new Holder<String> [10] ;
Object [] tabObject = tabString ;
tabObject[0] = new Holder<Integer>(1) ;

Pour avoir le même comportement, il serait de bon goût que la ligne 4 jette aussi une ArrayStoreException. Seulement, du fait du type erasure il n’existe qu’une unique classe Holder, que les types Hodler<String> et Holder<Integer> partagent. À l’exécution, la machine Java n’a donc pas l’information dont elle aurait besoin pour pouvoir générer cette exception.

Plutôt que de laisser cette possibilité d’écrire des bugs, la décision logique d’interdire les tableaux de génériques a été prise. Le code de la ligne 1 est donc illégal.

En revanche, il est légal d’écrire le code suivant.

Holder<String> [] tabString = new Holder [10] ; // warning

Le compilateur génère alors un warning, nous indiquant que la validité de la conversion de Holder en Holder<String> ne peut pas être vérifiée à la compilation.

Le quatrième point est assez simple à comprendre. Le type erasure change la signature d’une méthode une fois celle-ci compilée. Si la méthode compilée a une signature qui est déjà présente dans la classe, alors elle devient illégale. L’exemple classique est le suivant.

public class Holder<T> {

	// méthode en collision avec equals(Object)
	// erreur de compilation !!
	public boolean equals(T t) {
		// corps de la méthode
	}
}

Il existe une autre configuration de collisions de méthodes génériques rendant illégales certaines opérations, mais nous avons besoin d’une notion supplémentaire pour la décrire.

Méthodes pont

Les méthodes pont sont une introduction de Java 5, utilisées dans les génériques, mais pas uniquement. Par exemple, l’interface et la classe suivantes ne compilaient pas en Java 4, car le type de retour de l’implémentation devait être identique à celui de l’interface. En Java 5 cette contrainte est partiellement levée : le type de retour d’une implémentation peut être une extension de type de retour déclaré dans l’interface. Si du point de vue sémantique cela semble raisonnable, techniquement il a fallu introduire cette notion de méthode pont pour l’implémenter.

// une interface
public interface NumberHolder {

	public Number getValue() ;
}

// une classe qui l'implémente
public class IntegerHolderImpl implements NumberHolder {

	// compile à partir de Java 5
	public Integer getValue() {
		// corps de la méthode
		return null ;
	}
}

Le bytecode généré pour cette classe comporte une méthode supplémentaire, cette fameuse méthode pont.

// access flags 0x1
  public getValue()Ljava/lang/Integer;
    ACONST_NULL
    ARETURN

// access flags 0x1041
  public volatile bridge getValue()Ljava/lang/Number;
    ALOAD 0
    INVOKEVIRTUAL org/paumard/IntegerHolderImpl.getValue()Ljava/lang/Integer;
    ARETURN

Il n’aura pas échappé au (précieux !) lecteur perspicace, que ce qui nous est interdit, à nous autres pauvres mineurs de fond du développement informatique, la machine Java se l’octroie royalement : deux méthodes dans une même classe, possédant même signature, avec des types de retour différents.

Ce hack caractérisé est également utilisé pour lever un problème posé par le type erasure, que nous allons pouvoir poser à présent. Prenons l’exemple des deux classes suivantes. La première est un holder générique classique, non immutable.

public class Holder<T> {

	private T t ;

	public Holder(T t) {
		this.t = t ;
	}

	public void setValue(T t) {
		this.t = t ;
	}

	public T getValue() {
		return t;
	}
}

La seconde est une extension de ce holder, spécialement ajustée pour poser deux problèmes.

public class StringHolder extends Holder<String> {

	public StringHolder(String s) {
		super(s) ;
	}

	public void setValue(String s) {
		super.setValue(s) ;
	}

	public String getValue() {
		return super.getValue() ;
	}
}

Examinons le cas du getter. Une fois le type erasure glorieusement appliqué, on se retrouve avec deux méthodes getValue() : celle de la superclasse retourne Object, et celle de la classe IntegerHolder retourne Integer. La question de la mêthode getValue() est donc exactement la même que celle de l’implémentation avec changement du type de retour.

La solution utilisée par le compilateur est la même : on observe la création d’une méthode pont analogue à celle de l’exemple précédent, dans la classe StringHolder.

Le cas du setter est différent. Une fois le bulldozer du type erasure passé, on a cette fois deux méthodes : setValue(Object) dans la classe Holder<String> et setValue(String) dans la classe StringHolder. Cette deuxième méthode ne surcharge pas la première, car sa signature est différente. Le problème est que l’on s’attend à ce que le polymorphisme soit appliqué sur ces deux méthodes, puisque setValue(T) est censée prendre un type String en paramètre.

// access flags 0x1041
public volatile bridge setValue(Ljava/lang/Object;)V
    ALOAD 0
    ALOAD 1
    CHECKCAST java/lang/String
    INVOKEVIRTUAL org/paumard/StringHolder.setValue(Ljava/lang/String;)V
    RETURN
    MAXSTACK = 2
    MAXLOCALS = 2

Cette fois, une méthode pont est ajoutée pour rétablir le polymorphisme. Cette méthode prend un Object en paramètre, et devient de ce fait une surcharge de la méthode correspondante dans la classe Holder<String>.

Conclusion provisoire

Cette article se continuera par la présentation de la notion d’héritage entre types génériques, et capture conversion. Merci de ta fidélité, précieux lecteur !

Références

La JLS, chapitre 4 : http://java.sun.com/docs/books/jls/third_edition/html/typesValues.html

La JSR 14 : http://www.jcp.org/aboutJava/communityprocess/review/jsr014/

Le tutorial sur le site de Sun, très léger : http://download.oracle.com/javase/tutorial/java/generics/index.html

Le chapitre 12 de l’excellent Core Java, de Cay Horstmann & Gary Cornell : http://www.horstmann.com/corejava.html