Java 5.0 et les types paramétrés
Date de publication : 22/06/2006
Par
Romain Guy
Les développeurs attendent impatiemment la version 5.0 de Java qui apporte
de nombreuses nouveautés au langage.
L'une d'entre elles, les types paramétrés ou generics en anglais,
changera considérablement la manière d'écrire les programmes Java.
Introduction
Problèmes de succession
Le Java, le C++ et le Python
Introduction
 |
Note de la rédaction : Ce tutoriel avait été publié à l'origine dans le défunt magazine Login avant la sortie de la version 5.0 de Java.
|
Sun Microsystems a mis à notre disposition la version beta du J2SE 5.0 depuis quelques mois.
Non finalisé, ce kit de développement nous permet de nous familiariser avec les
ajouts apportés à Java.
Aussi intéressants que complexes, les types paramétrés demandent un certain travail
avant d'en comprendre tous les rouages.
Bien que leur but et leur syntaxe les rapprochent des templates C++ ces nouveaux
venus présentent d'importantes différences qui pourront gêner certains d'entre vous.
Les types paramétrés servent donc à faire abstraction des types de données.
Les collections d'objets sont les structures de données les plus adaptées à leur utilisation
et à leur compréhension. Jusqu'à présent la création d'une liste de taille indéfinie
de nombres se faisait de cette manière :
List l = new ArrayList();
l.add(new Double(462.0d));
Double n = (Double) l.get(0); |
Cet exemple met en évidence plusieurs défauts de cette approche.
Tout d'abord le programmeur doit impérativement placer dans sa liste des instances
d'une classe dérivant de Object, lui interdisant d'utiliser directement les types primitifs.
Cette contrainte peut être contournée à l'aide des classes d'enveloppe telles que Boolean,
Integer ou Double. Pour la même raison, le retrait d'un objet dans une collection ne
permet d'obtenir qu'une type Object, imposant au développeur d'effectuer une
conversion explicite comme nous le faisons ici. En mettant de côté l'aspect purement
pratique, car la rédaction du transtypage tend à alourdir le code source et à en
rendre la lecture fastidieuse, un dernier problème paraît évident. Nous supposons
en effet que le programmeur sait exactement ce que contient la collection. Dans
la plupart des situations il n'y aura aucune conséquence grave. Malheureusement
puisque nous pouvons insérer ce que nous voulons dans la liste avons-nous une
garantie quant à son contenu ? Les erreurs de conversion seront mises en évidence
à l'exécution du programme, retardant d'autant la découverte d'un bug potentiel.
Il est certes possible de protéger son code par l'entremise du mot-clé instanceof
mais au prix d'une plus grande complexité.
L'idée principale des types paramétrés a pour rôle d'adresser ces problèmes en
fournissant non seulement une syntaxe plus claire et plus simple mais en
garantissant également le type de données utilisé. Ainsi notre petit exemple
pourra s'écrire de cette manière :
List<Double> l = new ArrayList<Double>();
l.add(new Double(462.0d));
Double d = l.get(0); |
Avec ce nouveau code nous éliminons la conversion explicite.
En outre toute tentative d'insertion d'un objet n'étant pas de typeDouble se
soldera par un échec à la compilation. Nous améliorons ainsi la lisibilité du
source tout en protégeant un peu plus le programme à l'exécution.
Vous constaterez enfin que la syntaxe est exactement la même qu'en C++.
La réalisation de classes ou d'interfaces paramétrées utilise une syntaxe
similaire ainsi qu'en témoigne le listing 1.
| listing1 | public class Printer<T>
{
public void print(T t)
{
System.out.println(t);
}
} |
Le ou les types paramétrés
définis entre < et > dans le nom de la classe peuvent être utilisé dans
le corps comme s'il s'agissait d'un type de donnée existant.
Une instance Printer<String> permettra donc d'invoquer la méthode print()
avec un paramètre de type String. La définition de plus types paramétrés
est possible en les séparant par des virgules comme dans Map<K, V>.
Contrairement à d'autres langages, Java ne produit pas différentes copies du
code en fonction de l'utilisation faite de ces types paramétrées dans le code.
Les classes paramétrées ne sont donc en rien distinguables des autres sur
le disque dur ou en mémoire.
Problèmes de succession
Bien que très simple d'accès de prime abord,
les types paramétrés cachent un grand nombre de subtilités.
Le plus gros problème que vous rencontrerez concerne la notion d'héritage
des types paramétrés. Prenons l'exemple d'une liste de Double et essayons
de l'utiliser comme une liste d'Object. A première vue cette opération ne
pose aucun problème puisqu'un Double n'est autre qu'un Object.
Voici concrètement ce que nous rédigerions :
List<Double> nombres = new ArrayList<Double>();
List<Object> objets = nombres;
objets.add(new Object());
Double d = nombres.get(0); |
L'assignement de nombres à objects semble parfaitement légal mais conduit à
une situation potentiellement dangereuse car autorisant la violation de la
contrainte d'intégrité introduite par l'utilisation des types paramétrés.
En définissant une liste d'Object à partir de notre collection de Double
nous permettons au programmeur d'ajouter un simple Object dans la liste,
qui ne contiendra donc plus uniquement des Double. Pour éviter ce problème
le compilateur refusera purement et simplement de traiter la deuxième ligne
de code. La raison de ce refus s'explique par le fait que List<Double> et
List<Object> n'ont absolument aucun lien d'héritage car ils correspondent
tous les deux à une même classe, la classe List. Rappelez-vous qu'une seule
version de la classe se trouve compilée ou chargée en mémoire.
Les types paramétrés semblent donc nous limiter à un seul et unique
type de données.
Imaginons le cas d'une application de dessin possédant une fonction d'affichage
acceptant une List<Shape> et nous souhaitons pouvoir fournir indifféremment
des List<Rectangle2D> ou List<Ellipse2D>.
| listing 2 | public static void draw(List<Shape> pipeline)
{
for (Shape s: pipeline)
System.out.println("Dessin : " + s);
}
public static void main(String[] args)
{
List<Rectangle2D> formes = new ArrayList<Rectangle2D>();
formes.add(new Rectangle2D.Double());
draw(formes);
} |
Le listing 2, qui utilise la nouvelle boucle d'énumération de Java 5.0, présente
une tentative de résolution de ce problème avec nos connaissances actuelles.
Comme dans le cas précédent le compilateur refuse de faire son travail en
précisant que le type List<Rectangle2D> ne correspond pas à List<Shape>.
Une fois de plus notre bon sens est mis à l'épreuve puisqu'il nous semble évident
qu'une liste de rectangles soit également une liste de formes géométriques.
Pour remédier à ce problème nous pouvons utiliser les jokers notés
avec un point d'interrogation.
En remplaçant List<Shape> par List<?> nous résolvons le problème de
compilation pour en rencontrer un nouveau. Nous apprenons alors que nous ne
pouvons récupérer un objet de type Shape à partir de la collection,
nous obligeant ainsi à réécrire la boucle d'itération avec un type Object.
Si vous essayez en outre d'ajouter un élément à la liste vous serez surpris de
constater que vous ne pourrez pas compiler votre code source.
L'utilisation du joker signifie que nous utilisons un type totalement inconnu
ce qui entraîne deux conséquences importantes.
Premièrement nous ne pouvons utiliser que les méthodes appartenant à la classe
Object puisqu'il s'agit des seules dont la machine virtuelle sera certaine de
disposer à l'exécution. Une List<?> peut en effet aussi bien correspondre
à une liste de formes qu'à une liste de marchandises.
Ensuite, puisque le type paramétré est inconnu il sera impossible pour le
compilateur de trouver des méthodes dont les paramètres sont de type inconnu.
| listing 3 | public static void draw(List<? extends Shape> pipeline)
{
for (Shape s: pipeline)
System.out.println("Dessin : " + s);
} |
Une solution à ce problème existe cependant. Nous pouvons tout simplement
restreindre les jokers à une hiérarchie de classes, c'est-à-dire utiliser
des types paramétrés contraints. Dans notre exemple de liste de formes
géométriques nous pourrons écrire un programme valide en nous inspirant
du listing 3. La notation <? extends Shape> signifie que nous acceptons
tous les types dérivés de Shape. Cette nouvelle forme permet également
de résoudre le problème d'affectation d'une liste de Double à une liste
d'Object rencontré précédemment :
List<Double> nombres = new ArrayList<Double>();
List<? extends Object> objs = nombres; |
Notez cependant que la présence du joker interdit toujours l'invocation de
méthodes utilisant le type paramétré dans leur liste d'arguments. Nous ne
pouvons donc pas compiler objs.add(new Object()) ni
pipeline.add(new Rectangle2D.Double()).
Il existe une seconde sorte de contrainte, indiquée par le mot-clé super.
La contrainte imposée par extends sert à définir la classe mère des types
acceptés tandis que celle imposée par super désigne l'héritage le plus profond
autorisé. Considérons une classe chargée de déterminer la meilleure
succession d'éléments dans une liste en fonction d'un comparateur.
Dans le cas d'une liste de villes nous pourrions par exemple rechercher le
trajet le plus court en distance ou le plus court en temps.
| listing 4 | public class Chemin<E>
{
public Chemin(Comparateur<E> c) ...
public List<E> trouverPlusCourtChemin() ...
} |
Le listing 4 présent une solution. En créant un Chemin<Ville>, nous
obligeons l'utilisateur de la classe à utiliser un Comparateur<Ville> ce qui
introduit une grande limitation puisqu'il sera impossible d'utiliser un
Comparateur<Object>, ou tout autre comparateur relatif à une super
classe de Ville. En réécrivant le constructeur de la manière suivante, nous
rendons l'utilisation du comparateur plus souple :
public Chemin(Comparateur<? super Ville> c) |
Il est intéressant que seuls les jokers non contraints permettent la création de
tableaux paramétrés. Il est par exemple impossible de déclarer
un tableau de 42 listes de Double :
List<Double>[] tableau = new ArrayList<Double>[42]; |
Cette limitation sert une fois de plus à protéger le code lors de l'exécution
car il serait sinon possible de manipuler le tableau comme un tableau d'Object
et d'en changer le contenu sans se soucier des types paramétrés :
Object[] tab = (Object[]) o;
o[0] = new ArrayList<String>(); |
Cet exemple contribuerait à insérer une liste de chaînes de caractères dans un
tableau supposé ne contenir que des listes de Double.
Voilà pourquoi seuls les jokers sont autorisés :
List<?>[] tableau = new ArrayList<?>[42]; |
Vous pouvez utiliser les types paramétrés pour caractériser une classe mais
également pour définir une méthode.
L'exemple le plus probant d'une telle utilisation concerne le cas d'une
méthode de copie d'un tableau dans une liste ainsi que le montre le listing 5.
| listing 5 | public static void copy(Object[] objs, List<?> l)
{
for (Object o: objs)
l.add(o);
} |
Nous avons ici utilisé le joker pour permettre, par exemple, de copier le
tableau dans une List<String> aussi bien que dans une List<Double>.
Ces quelques lignes ne peuvent malheureusement pas fonctionner car,
comme nous l'avons vu, les jokers interdisent l'exécution de méthodes dont les
arguments sont paramétrés. La solution consiste simplement à paramétrer la
méthode elle-même ainsi que le démontre le listing 6.
| listing 6 | public static <T> void copy(T[] objs, List<T> l)
{
for (T t: objs)
l.add(t);
} |
En fonction des paramètres fournis à la méthode, le compilateur déterminera
le type de T. Ce dernier sera choisi pour correspondre au type le moins
spécifique. Par exemple l'invocation de notre méthode copy() avec un tableau
de String et une collection d'Object fera de T un type Object. Si nous
choisissons une collection de String, le type T sera simplement String.
Le Java, le C++ et le Python
Les types paramétrés de Java ont récemment subit une vague de critiques
vindicatives de la part de développeurs maîtrisant les langages C++ ou Python.
Dans ces derniers, l'équivalent des types paramétrés repose sur un typage tardif
ou latent typing. Autrement dit le type des paramètres n'est vérifié que
le plus tard possible. Nous pouvons le vérifier très simplement à l'aide des
exemples proposés par le listing 7.
| listing 7 | # Python
def expression(personne):
personne.parler()
template <class T>
bool Comparator<T>::lowerThan(T lvalue, T rvalue)
{
return lvalue < rvalue;
} |
Dans le premier cas, en Python, nous estimons que le type de l'objet passé en
paramètre définit la méthode parler(). Si celle-ci manque à l'exécution, une
erreur surviendra.
Le second extrait, en C++, permet de comparer deux objets. Nous nous attendons
à ce que leur type propose une surcharge de operator <. Voilà pourquoi on
parle de typage tardif : rien dans la déclaration des types des paramètres ne
permet d'affirmer que les objets que l'on recevra seront conformes à notre
contrat. Il est donc très facile de prendre ces deux exemples en défaut car le
typage tardif permet de s'affranchir de toutes les contraintes sur le type de
l'objet. Nous pouvons ainsi réaliser des fonctions ou méthodes très génériques.
Java suit une approche plus restrictive mais plus sûre. L'utilisation des
types paramétrés de base de Java résout ces problèmes en interdisant
l'utilisation de méthodes n'appartenant pas à la classe de base. Il est donc
nécessaire d'utiliser les types paramétrés contraints pour garantir que le
type utilisé offre bien les services attendus.
En C++, la possibilité d'invoquer n'importe quelle méthode sur un template
offre une souplesse sans égale mais apporte de nombreux dangers. Nous avons
observé le plus évident, à savoir l'utilisation d'un type incorrect, qui sera
heureusement détecté à la compilation. Dans ce cas, la méthode devra être
particulièrement bien documentée pour expliquer clairement et précisément
quelles sont les méthodes attendues du type. Nous avons donc une contrainte
découplée puisque définie par contrat et par le type au lieu du seul type.
Un autre problème grave tient de la sémantique. Quand bien même nous disposerions
de deux types proposant les méthodes tirer(), serait-il judicieux de pouvoir les
considérer sémantiquement équivalentes ? Il est en effet évident que pour une
personne relisant le code source corde.tirer() n'aura pas la même signification
que pistolet.tirer(). Les types paramétrés proposés par Java interdisent une
telle confusion sémantique.
Il convient tout de même de limiter l'impact de cette nouveauté sur la manière
de développer. Bien que très pratiques pour rendre le code plus lisible et plus
concis, notamment en éliminant les conversions explicites, il n'existe que très
peu de situations rendant les types paramétrés indispensables. De nombreux
problèmes peuvent être résolus à l'aide de simple interfaces. Ce changement
dans le langage doit donc, au même titre que la nouvelle boucle for ou que les
imports statiques, être vu comme une commodité et non comme un bouleversement.
Le plus difficile pour les programmeurs expérimentés est d'oublier ce qu'ils savent
au sujet des templates C++ et d'aborder les types paramétrés avec un oeil neuf.
Romain Guy
 
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.
|