Aller au contenu principal

Types paramétrés

Les types paramétrés (parameterized types, ou généricité) sont un mécanisme de programmation générique introduit dans la version 5.0 du langage Java.

Ce mécanisme permet de définir une classe / interface ou une méthode sans préciser le type de variables, de paramètres ou de la valeur de retour. La spécification des types manquants est faite au moment de l'utilisation (pour une classe à l'instanciation ou à la déclaration d'une variable de cette classe, pour une méthode à son appel).

L'exemple standard de la programmation générique se trouve dans l'utilisation et la réalisation des classes conteneurs de données.

Les conteneurs de données​

Définition : une classe "conteneur" encapsule une collection d'objets dans une structure de données particulière (liste, ensemble, pile, arbre...). Elle est chargée du stockage, de la gestion (ajouter, enlever...), de l'accès à un objet et du parcours des objets de la collection (notion d'itérateur).

Un conteneur ne déclenche pas le comportement des objets de la collection (à part l'égalité et la comparaison pour le tri). Pourtant, dans un langage typé, la réalisation de ces conteneurs entraîne un problème de cohérence du type des éléments gérés par un conteneur.

Cohérence de type​

En Java, les classes "conteneurs" les plus communes sont fournies :

  • par les tableaux ;
  • par des classes collection de l'API standard, dans le paquetage java.util (voir les interfaces Collection et Iterator).

Pour les tableaux​

Exemple d'une erreur de cohérence de type avec un tableau :

public class CoherenceTableau {
static void parcours(Porte[] portes) {
for (int i = 0; i < portes.length; i++) {
portes[i].ouvrir();
}
}

public static void main(String[] args) {
Porte[] mesPortes = new Porte[3];

mesPortes[0] = new PorteCoulissante(20);
mesPortes[1] = new PorteCharniere();
mesPortes[2] = "Coucou";

parcours(mesPortes);
}
}

/*
% javac CoherenceTableau.java
CoherenceTableau.java:13: error: incompatible types: String cannot be converted to Porte
*/

La déclaration d'un tableau spécifie le type des objets acceptés par la collection. La cohérence de type est vérifiée à la compilation lors de l'ajout d'un objet.

Pour les classes "collection"​

Avant Java 5, dans la réalisation de ces classes, le type des éléments est déclaré du type Object puisque c'est la racine de la hiérarchie de classes en Java. Les méthodes d'ajout et d'accès utilisent aussi le type Object. La cohérence de type est vérifiée à l'exécution lors de l'accès à un élément de la collection. Il est nécessaire d'inclure un code de vérification de la conversion de type (opérateur instanceof ou capture de l'exception java.lang.ClassCastException).

Paramétrer les types​

Le mécanisme des types paramétrés peut s'utiliser sur une interface, une classe, une méthode ou un constructeur. L'objectif est de détecter l'incohérence de type dans un conteneur à la compilation plutôt qu'à l'exécution. Pour cela, il est possible de définir le type des éléments à l'utilisation du conteneur.

ClasseParametree<Porte, PassagerStandard> var;

var = new ClasseParametree<Porte, PassagerStandard>();
var.methodeParametree(new DentBleu());

Dans la définition d'une classe paramétrée, il est nécessaire de déclarer des "paramètres formels" pour la spécification du type. Par convention, le nom du paramètre formel est une seule lettre en majuscule.

class ClasseParametree<E, T> {
private E uneInstance;
public T nomMethode(E par1, String par2) {
T t = null;
// ...
return t;
}
public <U> void methodeParametree(U u) {
// ...
}
}

Chaque paramètre effectif précisé à l'utilisation va remplacer, dans l'ordre, chaque paramètre formel de la définition.

Cohérence de type pour la version avec type paramétré de la classe java.util.Vector :

Vector<Porte> portes = new Vector<Porte>(5);

portes.add(new PorteCoulissante(999));
portes.add(new PorteCharniere());
portes.add("Coucou"); // erreur à la compilation

Vector<PorteCoulissante> coulissantes = new Vector<PorteCoulissante>(3);

coulissantes.add(new PorteCoulissante(666));
coulissantes.add(new PorteCharniere()); // erreur à la compilation

Depuis Java 7, l'opérateur diamant permet d'omettre les paramètres effectifs à l'instanciation lorsqu'ils peuvent être déduits : new Vector<>(5). Par ailleurs, Vector (synchronisée) est aujourd'hui généralement remplacée par ArrayList.

L'accès aux éléments se fait sans conversion de type. Le langage Java a introduit une boucle simplifiée (for-each) pour le parcours d'un tableau ou de tout objet réalisant l'interface java.lang.Iterable, dont les collections de java.util.

void boucleSimplifiee(Vector<Porte> portes) {
for (Porte p : portes)
p.ouvrir();
// équivalent à
for (Iterator<Porte> it = portes.iterator(); it.hasNext();) {
Porte p = it.next();
p.ouvrir();
}
}

Remarque : pour des raisons de compatibilité, il n'est pas obligatoire de préciser le type paramétré à la déclaration ou à l'instanciation. On parle alors de type brut (raw type) : les éléments sont manipulés sous le type Object et le compilateur désactive les vérifications liées à la généricité (avertissement unchecked). Un type brut n'est donc pas strictement équivalent à Vector<Object>.

Vector desObjets = new Vector(5);
// proche de (mais non équivalent à)
Vector<Object> desObjets2 = new Vector<Object>(5);

Compilation d'une classe paramétrée​

En Java, tous les paramètres effectifs d'une classe paramétrée partagent le même code. Il y a toujours un seul fichier compilé (.class) par classe. Le mécanisme n'implique pas une recompilation du code pour chaque utilisation d'une classe paramétrée.

L'information sur un type paramétré n'est présente que pour la compilation de l'utilisation de la classe paramétrée. Dans le fichier compilé, le paramètre formel est remplacé par sa borne (le type Object en l'absence de borne) et le compilateur insère les conversions nécessaires ; il y a effacement de type (type erasure).

Cette compilation unique aboutit à des contraintes sur l'utilisation des types paramétrés :

  • un paramètre effectif est forcément un type référence : Vector<int> est interdit, il faut utiliser la classe enveloppe Vector<Integer> (avec conversion automatique, ou autoboxing) ;
  • les instructions impliquant la classe ne sont pas autorisées sur un paramètre formel : new T(), T.methodeDeClasse(), new T[initTaille], x instanceof T.

Envoi de messages sur un paramètre formel​

Le compilateur vérifie la validité d'un envoi de message par rapport au type de la variable. Il est possible de donner une contrainte (borne) sur le paramètre formel pour s'assurer d'un intervalle de types, par exemple tous les sous-types de l'interface Porte.

public <U extends Porte> void parametreUneMethode(U u) {
u.estFerme();
}

Le compilateur vérifie cette contrainte sur le paramètre effectif. Il est possible de combiner ces contraintes avec &, par exemple : <T extends Porte & Comparable<T>> (seule la première borne peut être une classe, les suivantes sont des interfaces).

Relation de type / sous-type entre paramètres effectifs​

Comment considérer les trois conteneurs Vector<Porte>, Vector<PorteCoulissante> et Vector<PorteCharniere> par rapport à la relation de type / sous-type entre PorteCharniere, PorteCoulissante et Porte ?

Regardons le cas des tableaux : il est possible de passer le paramètre PorteCoulissante[] à la méthode parcours(Porte[] portes).

static void parcours(Porte[] portes) {
for (Porte p : portes) {
if (p != null)
p.ouvrir();
}
}
// ...
PorteCoulissante[] coulissantes = new PorteCoulissante[3];

coulissantes[0] = new PorteCoulissante(20);
coulissantes[1] = new PorteCoulissante(10);
coulissantes[2] = new PorteCoulissante(11);

parcours(coulissantes);

Quand la relation de type / sous-type est transmise aux conteneurs, nous avons un mécanisme de covariance. C'est le cas pour les tableaux : PorteCoulissante[] est sous-type de Porte[]. Cela permet d'appliquer la méthode parcours() à tous les tableaux correspondant à un sous-type de Porte.

Que se passe-t-il si le tableau référencé par la variable de type Porte[] est modifié ?

PorteCoulissante[] coulissantes = new PorteCoulissante[3];

Porte[] portes = coulissantes;
portes[0] = new PorteCoulissante(66);
portes[1] = new PorteCharniere(); // ArrayStoreException à l'exécution

coulissantes[1].coulisser();

Le conteneur typé par PorteCoulissante contiendrait une instance de PorteCharniere. Pour les tableaux, cette incohérence est détectée non pas à la compilation mais à l'exécution : la vérification est faite au moment de l'affectation avec la levée de l'exception java.lang.ArrayStoreException.

La variance des types paramétrés​

Par défaut, la relation de type / sous-type du paramètre effectif n'est pas transmise à la classe paramétrée. C'est l'invariance.

Mais, pour permettre la factorisation de code, le langage Java permet de modifier la variance des classes paramétrées, à l'aide du joker ?, sous certaines contraintes qui vont assurer la cohérence du conteneur.

Invariance​

Dans notre exemple, Vector<PorteCharniere> et Vector<PorteCoulissante> ne sont pas sous-types de Vector<Porte>.

static void parcours(Vector<Porte> portes) {
for (Porte p : portes)
if (p != null)
p.ouvrir();
}
// ...
parcours(new Vector<Porte>());
parcours(new Vector<PorteCoulissante>()); // erreur à la compilation
parcours(new Vector<PorteCharniere>()); // erreur à la compilation

Covariance​

Le type de la classe paramétrée varie de la même manière que le type du paramètre effectif. La covariance est indiquée par le mot-clé extends. Pour accéder aux éléments du conteneur, c'est la borne supérieure qui est fixée (les éléments sont récupérés à travers ce type) : <D extends B> pour un paramètre formel D sous-type de B, ou <? extends B> pour un type quelconque sous-type de B. Avec la covariance, le code de la méthode parcours() est valide pour des instances de Vector<PorteCharniere> et Vector<PorteCoulissante>.

static void parcours(Vector<? extends Porte> portes) {
for (Porte p : portes)
if (p != null)
p.ouvrir();
}

static <T extends Porte> void autreEcriture(Vector<T> portes) {
for (T p : portes)
if (p != null)
p.ouvrir();
}

// ...

parcours(new Vector<Porte>());
parcours(new Vector<PorteCoulissante>());
autreEcriture(new Vector<PorteCharniere>());

Pour éviter une incohérence des éléments du conteneur (le problème rencontré avec les tableaux), la covariance ne permet pas d'ajouter des éléments au conteneur passé en paramètre (hormis null). Dans le code de la méthode parcours(Vector<? extends Porte> portes), l'instruction portes.add(new PorteCharniere()); provoque une erreur de compilation.

Contravariance​

Pour factoriser le code qui ajoute un élément à un conteneur, il faut utiliser la contravariance. La contravariance est indiquée par le mot-clé super. Pour modifier le conteneur, il faut fixer la borne inférieure du type accepté : <? super C> désigne tout type dont C est un sous-type (C compris).

Par exemple, pour ajouter des instances de PorteCoulissante, il faut que le type paramétré soit au moins PorteCoulissante.

static void ajouter(Vector<? super PorteCoulissante> portes) {
portes.add(new PorteCoulissante(999));
portes.add(new PorteCoulissante(666));
portes.add(new PorteCharniere()); // erreur à la compilation
}

ajouter(new Vector<Porte>());
ajouter(new Vector<PorteCoulissante>());
ajouter(new Vector<PorteCharniere>()); // erreur à la compilation

Avec la contravariance, les éléments du conteneur passé en paramètre ne peuvent être lus que sous le type Object : Porte p = portes.get(0); provoque une erreur à la compilation. Ces deux règles sont souvent résumées par le principe PECS (Producer Extends, Consumer Super).

Bivariance​

La bivariance est indiquée par le caractère joker seul <?>. Dans ce cas, tous les types sont acceptés. Il n'est pas possible d'ajouter des éléments au conteneur (hormis null) et les éléments sont récupérés sous le type Object.

void bivariant(Vector<?> v) {
v.add(new PorteCoulissante(999)); // erreur à la compilation
v.add("coucou"); // erreur à la compilation
Porte p1 = v.get(0); // erreur à la compilation

Porte p2 = (Porte) v.get(0);
Object o = v.get(0);
}