J2SE 1.5 Tiger


précédentsommairesuivant

Les Generics

Après plus de cinq ans d'attente depuis la première proposition, les generics vont finalement être intégrés au langage Java, dans le J2SE 1.5. Le chemin pour arriver à l'adoption de cette technologie pour le moins controversée mais sans doute essentielle fut long et douloureux.

Le manque des types génériques dans le langage Java est sujet à polémique quasiment depuis la sortie de ce langage de programmation.
En effet, cette fonctionnalité est une des extensions du langage les plus demandées par la communauté des développeurs Java (JDC).
Elle arrive en seconde position pour les extensions du langage et en septième dans le bug parade.
Ainsi nous comprenons mieux l'importance que prend cet ajout vis-à-vis du langage.

Le point de départ fut la conférence donnée par Gilad Bracha, théologiste chez Sun et aujourd'hui le leader technique de la JSR 014, « Add Generic Types to the Java Programming Language ».

L'implémentation retenue pour les generics en Java est basée sur celle de GJ (Generics Java), une extension du langage Java, développée par Philip Wadler, Martin Odersky, Gilad Bracha, et Dave Stoutamire.

Nous allons voir dans ce chapitre ce que sont les generics et ce qu'ils permettent.

Le principe des generics

Pour les personnes familières avec les templates en C++, les generics prennent tout leurs sens, bien que des différences fondamentales existent.

Par type générique, on entend aussi polymorphisme paramétrique de type.

De manière concise, on peut dire que les types génériques permettent de développer un comportement unique pour des types polymorphes.

En d'autre terme, les generics permettent de s'abstraire du typage des objets lors de la conception et donc de définir des comportements communs quel que soit le type des objets manipulés.

Le plus simple pour comprendre les generics est d'analyser un exemple type :

 
Sélectionnez

List integerList = new ArrayList();
integerList.add(new Integer(1));
Integer i = (Integer)integerList.get(0);

Dans cet exemple, nous remarquons que nous sommes obligé de transtyper explicitement l'objet que l'on récupère de la collection, car la seule chose dont nous somme sûrs, c'est que l'itérateur retourne un Object.

Ainsi, rien ne nous empêche d'insérer un objet de type String dans la liste :

 
Sélectionnez

integerList.add(“exemple”); // (1)

Une telle construction, bien que fausse, ne déclenchera aucune erreur lors de la compilation.
C'est seulement à l'utilisation que le programme lèvera une exception de type java.lang.ClassCastException.

Avec Tiger, il est possible, via une syntaxe particulière, de spécifier explicitement et avant construction, quel sera le type des objets contenus dans la collection :

 
Sélectionnez

List integerList<Integer> = new ArrayList<Integer>();
integerList.add(new Integer(1));
Integer i = integerList.get(0);

La syntaxe <Integer> spécifie que le type des objets utilisés avec cette collection est Integer.

Ainsi, l'exemple précédent (1) fera échouer la compilation car les generics introduisent une vérification de type statique, c'est-à-dire durant la compilation.

En fait, dans Tiger, les collections sont désormais implémentées en utilisant les types génériques.

Quels sont les avantages des generics? C'est ce que nous allons voir dans le paragraphe suivant.

Pourquoi utiliser les generics ?

Le premier avantage des generics réside dans la suppression du contrôle de type à l'exécution.
En effet, étant donné que les éventuelles erreurs de transtypage sont levées à la compilation, il n'est plus nécessaire de contrôler le type à l'exécution.
Ainsi, le code gagne en sécurité et le temps de maintenance éventuel en est très fortement réduit.

D'autre part, nul code supplémentaire à écrire (comme pour les templates C++) pour introduire les generics. La manière de développer ne change donc pas, si ce n'est des différences mineures dans la syntaxe (que nous verrons dans le paragraphe suivant).

Un avantage visible directement est une meilleure lisibilité et une plus grande robustesse du code.

Un autre aspect moins frappant mais tout aussi important, est la possibilité qu'offrent les types génériques de factoriser des comportements.
Les développeurs passent moins de temps à refaire sans cesse la même chose pour des types différents qu'ils doivent manipuler et peuvent ainsi se concentrer sur la logique fonctionnelle de leurs classes.
Le code en est d'autant plus concis et moins éparpillé et gagne ainsi en cohérence, en taille et en qualité.

La syntaxe des generics en Java

Nous allons fixer dans ce chapitre un certain nombre d'appellations et de notations.

Comme nous l'avons déjà vu dans le premier exemple, les premiers symboles nouveaux avec les types génériques sont les chevrons < et >.
Ils permettent de définir les paramètres de types formels qui peuvent être utilisés dans toutes les déclarations génériques, et en particulier en lieu et place des types ordinaires (bien qu'il y ait d'importantes restrictions).
A l'invocation de l'objet, toutes les occurrences du paramètre de type formel sont remplacées par le type défini.

Ainsi, l'interface List est définie de la sorte :

 
Sélectionnez

public interface List<T>
{
  void add(T t);
  Iterator<T> iterator();
}

T est ici le paramètre de type formel de l'interface List.

J'insiste bien sur le fait que T est un type, et pas une valeur. Nous verrons pourquoi dans le chapitre suivant.

D'autre part, une convention de nommage semble être acceptée par la communauté pour la notation des paramètres de type formel.
Elle recommande l'utilisation d'un caractère unique en majuscule comme E (Element) ou T (Type).

L'autre symbole important pour les generics est le point d'interrogation (?), que l'on appelle aussi wildcard ou inconnue.

Ainsi nous aurions pu écrire :

 
Sélectionnez

List unKnownList<?> = new ArrayList<String>();

Il permet de définir la variance d'un type générique.
Il permet de spécifier que n'importe quel type peut convenir comme élément de la liste.

Nous reviendrons plus loin sur les différentes possibilités qu'offre le wildcard.

Les generics ne sont pas des templates !

Nous l'avions déjà évoqué dans l'introduction, les generics ont des différences majeures avec les templates tels que nous les connaissons en C++ par exemple.

Nous avons vu que les occurrences d'un paramètre de type formel étaient remplacées à l'invocation.
A la lecture de ces mots, il est très facile d'extrapoler le code précédent d'une List qui aurait pour type String par exemple :

 
Sélectionnez

public interface StringList
{
  void add(String t);
  Iterator<String> iterator();
}

Ce mécanisme ne doit pas aller sans rappeler aux développeurs C++ les templates.

Et bien cette extrapolation est fausse, et c'est en cela que les templates et les generics sont différent. En effet, il n'y a pas de multiples copies du code, en fonction du type, ni en mémoire, ni dans les sources, ni dans le bytecode généré.

Un generics est compilé une fois pour toute en une unique classe comme une classe ou une interface classique et l'instance de cette classe est partagée entre toutes les invocations :

 
Sélectionnez

List<String> l1 = new ArrayList<String>();
List<Integer> l2 = new ArrayList<Integer>();
boolean jvmIdentity = (l1.getClass() == l2.getClass());

Ainsi, la valeur booléenne jvmIdentity vaut true.

Les paramètres de type formel sont tout simplement remplacés à l'invocation comme les valeurs formelles le sont dans des classes ordinaires, avec la valeur courante du type donnée dans la construction du code (resp. la valeur donnée).

Les generics et l'héritage

L'héritage avec les generics est une des choses les plus ardues à comprendre, car elle va à contresens des réflexions a priori.

En effet, si une classe C' hérite d'une classe C, et G est un generics de paramètre T, alors il est faux de dire que G<C'> hérite de G<C>. Pourtant cela semblerait être logique.

Voyons cela avec un exemple.

Supposons que nous disposions de la liste des habitants d'une ville et de la liste des clients d'un magasin de cette ville. Nous supposons aussi que la classe Customer hérite de la classe Resident.

 
Sélectionnez

List<Customer> customers = new ArrayList<Customer>;

Si nous supposons que tous les clients habitent la ville, on peut penser initialiser la liste des habitants comme ceci :

 
Sélectionnez

List<Resident> residents = customers;

Or cette initialisation est fausse; une liste de Resident n'est pas une liste de Customer. Nous allons voir pourquoi cette initialisation pose problème.

Supposons que l'initialisation de la liste des habitants se soit poursuivie comme ceci, sans l'erreur précédente :

 
Sélectionnez

residents.add(new Resident(…));

Si par la suite nous tentons de récupérer le premier client de la liste des clients, nous faisons naturellement :

 
Sélectionnez

Customer c = customers.get(0);

Or dans cette instruction nous tentons d'assigner à une instance de Customer une instance de Resident, puisque nous avons ajouté un habitant à la liste des habitants et que nous avons créé un alias entre les deux listes.

Cela ne poserait pas de problème si la liste des habitants était immuable. Or ce n'est pas le cas. En réalité, ce qui est passé à la liste des habitants est une copie de la liste des résidents. Sinon, le service commercial du magasin pourrait ajouter à sa liste de clients des personnes qui ne sont pas clientes.

C'est pour cela que le compilateur de J2SE 1.5 lève une erreur de compilation pour la seconde instruction.
Ceci est pour le moins restrictif, et pour palier à cela, Tiger comporte un mécanisme permettant de passer outre : il s'agit de l'utilisation du wildcard.

La variance dans les generics

La variance désigne les limites de portée d'un type générique.

Elle peut se définir comme les limites de la portée d'un type classique par les mots clé extends, super et implements et s'utilise de la même manière.
Pour combiner deux limites sur un même type, il faut utiliser le caractère &.

La construction respecte la règle suivante :

 
Sélectionnez

TypeVariable keyword Bound1 & Bound2 & ... & Boundn

Voici un exemple de limite complexe :

 
Sélectionnez

final class Foo<A extends Comparable<A> & Cloneable<A>, 
                   B extends Comparable<B> & Cloneable<B>>
  implements Comparable<Foo<A,B>>, 
             Cloneable<Foo<A,B>>
{
  ...
}

Le wildcard dans les generics

Supposons que la mairie de la ville souhaite envoyer un courrier à tous ses habitants qui sont clients du magasin. Le service informatique de la mairie a une méthode permettant d'envoyer un courrier aux habitants de la ville.

Nous aurions une méthode qui ressemble à celle-ci :

 
Sélectionnez

void mailResidents(List<Resident> residents)
{
  for(Resident r : residents)
  {
    sendMail(r);
  }
}

Nous tenterions de l'utiliser comme ceci:

 
Sélectionnez

List<Customer> customers = ...
...
mailResidents(customers);

Or nous avons vu dans le chapitre précédent qu'il est impossible d'assigner à une liste d'habitants une liste de clients.

Quelle est alors l'alternative ? Il est tout à fait normal de vouloir (et pouvoir ...) faire ce type d'opération. Pour cela nous avons le wildcard (?).

Nous pouvons en effet modifier la méthode mailResidents() afin qu'elle accepte n'importe quel type d'individu :

 
Sélectionnez

void mailResidents(List<?> persons)
{
  for(Person p : persons)
  {
    sendMail(p);
  }
}

Si nous souhaitons que le système se limite à tout type d'individu, pour peu que ceux-ci soient des habitants de la ville, nous pouvons écrire :

 
Sélectionnez

void mailResidents(List<? extends Resident> residents)
{
  for(Resident r : residents)
  {
    sendMail(r);
  }
}

Ainsi, comme Customer hérite de Resident, l'utilisation de la méthode devient valide dans notre exemple précédent. Dans ce cas, la classe Resident est dite la classe « upper bound » de la classe Customer.

Cette construction à pourtant une limitation. Il est en effet impossible de modifier la liste dans le corps de la méthode, car comme nous ne connaissons pas le type effectif (si ce n'est que c'est un type inconnu dont le supertype est Resident). Toute tentative d'ajout d'élément nous confronterait à un problème de compatibilité de type entre le type effectif et le type de l'objet à ajouter.

Tiger encore une fois propose une possibilité pour outrepasser ceci. Il s'agit des méthodes génériques.

Les méthodes génériques

Les méthodes génériques sont des méthodes qui sont paramétrées par des types génériques. Nous allons voir comment celles-ci se mettent en œuvre.

Supposons que le service du recensement fournisse à la mairie une liste de personnes nouvellement installées dans la commune. La mairie souhaite alors mettre à jour sa liste d'habitants. La classe NewComer hérite de la classe Resident.

La mairie dispose pour cela de la méthode suivante :

 
Sélectionnez

static void addResidents(List<? extends Resident> newComers,
                         List<Resident> residents)
{
  for(Resident n: newComers)
  {
    residents.add(n); // erreur de compilation
  }
}

Nous avons bien pris soin de spécifier que les éléments de la liste des résidents sont de tous types héritant de Resident. Cependant comme nous venons de le dire, il est impossible de modifier la liste passée en paramètre. La ligne 6 provoque donc une erreur de compilation.

Les méthodes génériques arrivent ici à point nommé en déduisant le type à assigner :

 
Sélectionnez

static <T extends Resident> void addResidents(List<T> newComers,
                         List<Resident> residents)
{
  for(T n: newComers)
  {
    residents.add(n); // pas d'erreur ici.
  }
}

Mêler code classique et code générique

Jusqu'ici nous avons toujours remplacé du code classique par du code générique. Or, dans une application existante, il ne sera sans doute pas possible de tout refaire en utilisant les generics.

On peut isoler deux cas principaux :

  • Nous utilisons du code générique au sein d'un code ordinaire.
  • Nous ajoutons du code existant ordinaire dans du code générique.

Dans le premier cas, le compilateur va lever des avertissements sur les blocs de code qui pourraient poser problème, comme par exemple des collections classiques qui utilisent des collections génériques.

Dans le second cas, le compilateur va convertir le code générique en code ordinaire. Ce processus est appelé « erasure and translation ». Il supprime toutes les informations génériques et transtype les objets dans un type compatible (voire Object si aucun type n'est compatible).


précédentsommairesuivant

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

Ce document est issu de http://www.developpez.com et reste la propriété exclusive de son auteur [Lionel Roux]. La copie, modification et/ou distribution, de tout ou partie de celui-ci, par quelque moyen que ce soit, est soumise à l'obtention préalable de l'autorisation de l'auteur.
Tout manquement à cette licence donnerait systématiquement lieu à des poursuites, en accord avec la loi sur l'économie numérique (LEN).