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 interfacesCollectionetIterator).
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 enveloppeVector<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);
}