Java 5.0 et les types paramétrés

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.

- Titre : Java 5.0 et les types paramétrés

- Auteur : Romain Guy

- Parution : 22 juin 2006

- Mise à jour : 8 novembre 2019

- Licence : 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.

Sommaire

- 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 bêta 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'un 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 bogue 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ées 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és 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. À 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ésente 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 subi 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()

// C++
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 simples 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 œil neuf.

Romain Guy