logo le blog invivoo blanc

Maîtrisez les génériques en Java

5 janvier 2020 | Java | 0 comments

Le concept des génériques, introduit dans Java depuis la version 5, est venu enrichir l’aspect polymorphe du langage tout en renforçant le typage statique. Avec les génériques, le langage s’est doté d’un nouveau mécanisme pour coder. Le support des génériques simplifie l’implémentation des algorithmes et des structures de données abstraits et impacte particulièrement les collections. Avec Java 8 et ses nouvelles fonctionnalités génériques, bien maîtriser ce concept s’avère incontournable pour renforcer la connaissance du langage et son utilisation.

Dans cet article nous revenons sur cet aspect du langage dans l’objectif de souligner quelques éléments essentiels à son assimilation.

Pourquoi les génériques ?

Java est un langage fortement typé. Le compilateur intervient lors de la conversion des types et vérifie la pertinence de l’opération. Au delà des primitifs, lors de la conversion entre les types de référence, si les types sont incompatibles, une conversion explicite à l’aide de l’opérateur de conversion (Cast), est requise. Cette conversion n’est pas garantie, elle pourrait lever une exception à l’exécution. Prenons l’exemple suivant (pour des raisons de simplicité, nous utilisons les primitifs et leurs Wrappers d’une manière interchangeable) :

List list = new ArrayList();
list.add(1);
Integer integer = (Integer) list.get(0);

La liste, pouvant contenir tout type d’objet, le compilateur ne peut pas vérifier la compatibilité du type. Seul le programmeur détient l’information du type (Integer). Si la liste se trouve contenir un autre type incompatible avec celui demandé, Double par exemple, une exception sera levée à l’exécution :

List list = new ArrayList();
list.add(1d);
Integer integer = (Integer) list.get(0); // compile mais ClassCastException

Il est vrai que nous pouvons nous résoudre à implémenter plusieurs listes, une par type spécifique (ListInteger, ListDouble,..) mais cela va à l’encontre même des bonnes pratiques de factorisation.

A noter également que c’est toujours mieux de détecter une erreur à la compilation que plus loin à l’exécution. Dans ce sens, le recours aux génériques répond à cette situation. L’ajout de l’information du type renforce le contrôle du type et permet au compilateur de vérifier et assurer la compatibilité des types :

  • L’ajout d’un type incompatible est rejeté
List<Integer> list = new ArrayList<>();
	list.add(1d); // ne compile pas (cannot be applied)

La conversion n’est plus explicite

List<Integer> list = new ArrayList<>();
	list.add(1);
	Integer integer = list.get(0); // Plus besoin du cast

D’autre part, dans Java les tableaux sont dits covariants, pour un type B sous type d’un type A, le tableau B[] est un sous type du tableau A[]. Comme montré dans l’exemple suivant, affecter un tableau d’Integer à un tableau d’Object est tout à fait légal :

Integer[] integers = {1};
Object[] objects = integers;

Cette covariance autorise une forme de polymorphisme sur les paramètres de type tableau :

static void uneMethode(Object[] objects){..}
uneMethode(new Double[]{1d,2d});

Elle permet également la redéfinition d’une méthode en spécialisant son type de retour :

Object[] uneMethode(){..}

@Override
Integer[] uneMethode(){..}

Toutefois, cet aspect covariant des tableaux pourrait induire une erreur à l’exécution. Comme dans l’exemple qui suit, l’ajout d’un type incompatible se traduit par une erreur ArrayStoreException à l’exécution :

Integer[] integers = {1};
Object[] objects = integers;
objects[0] = "A"; // ArrayStoreException

La conception des génériques prend en compte ce genre de scénario. En mettant à disposition un nouveau système de gestion de la variance, le compilateur peut contrôler les éléments à ajouter dans une liste. A titre d’exemple la liste suivante de String ne peut pas être affectée à une liste d’Object :

List<String> listString = new ArrayList<>();
List<Object> listObject = listString; // Illegal

Dans la suite, nous revenons sur les différentes définitions des génériques et leur paramétrage

Définitions génériques

Toute définition peut être générique à l’exception des Enums, des exceptions, des classes anonymes

Type générique

Un type (classe ou interface) est générique s’il déclare un paramètre de type, nommé également variable de type.

GenericType<T> : type générique
T : paramètre de type ou variable de type

Un type générique pourrait déclarer plusieurs paramètres de type :

class GenericClass<T1,T2,...,Tn> {}
interface GenericInterface<T1,T2,...,Tn> {}

Méthodes et constructeurs génériques

De la même façon que les classes et les interfaces, les méthodes (statiques ou non) et les constructeurs peuvent être aussi déclarés génériques.

Un constructeur ou une méthode générique peuvent être dans une classe générique ou non. S’ils sont définis dans une classe générique, ils peuvent être paramétrés par un ou plusieurs paramètres supplémentaires.

Paramètre de type

Par convention, les paramètres du type sont déclarés en un seul caractère en majuscule. Cela permet de les distinguer des autres types. La convention de nommage suivante est largement adoptée :

  • T, S, U, V,… – Type
  • E – Element
  • K – Key
  • V – Value
  • N – Number

La portée du paramètre de type définit le bloc ou il pourrait être utilisé. On distingue trois catégories :

  • Toute la classe ou l’interface s’il s’agit d’un paramètre de type d’un type générique
  •  La méthode, s’il s’agit d’un paramètre de type d’une méthode générique (statique ou non)
               
  • Le constructeur s’il s’agit d’un paramètre de type d’un constructeur générique

Afin d’éviter toute confusion lié au nom il est recommandé de différencier les noms des paramètres de type d’une méthode ou d’un constructeur des noms des paramètres de type du type dans lequel ils sont déclarés.

Le paramètre de type peut être borné par un ou plusieurs types. Comme Java ne permet pas l’héritage multiple des classes, le paramètre de type peut être borné par plusieurs types dont un seul peut être une classe. Si une classe figure dans la liste des bornes, elle doit être déclarée en premier.

En effet, le paramètre de type sera remplacé par l’argument du type, à défaut il sera remplacé par sa première borne et en l’absence de la borne, il sera remplacé par le type Object.

L’exemple suivant présente un paramètre de type borné :

public class GenericClass<S extends Serializable> {..}
S : paramètre de type borné.

Le paramètre de type est dit récursif quand il est déclaré d’une manière récursive :

public class GenericClass<T extends Comparable<T>> {..}

Dans l’exemple suivant, le paramètre de type est récursif et borné avec plusieurs types :

public class GenericClass<T extends Serializable & Comparable<? super T>> {..}

Les méthodes et constructeurs génériques peuvent également avoir un paramètre de type borné :

static <N extends Number> void process(N number) {..}
static <T extends Object & Comparable<? super T>> T min(Collection<? extends T> collection) {..}
public <E extends Number> GenericClass(Collection<E> collection) {..}

Borner le paramètre de type nous permet d’appeler les méthodes définies dans le contexte de la borne, comme dans l’exemple suivant :

static <T extends Comparable<T>> Optional<T> min(List<T> list) {
   return list.stream()
           .reduce(BinaryOperator.minBy(Comparator.naturalOrder()));
}
Optional<Integer> min = min(Arrays.asList(3,5,2));

Type paramétré

Un type générique définit un ensemble de types paramétrés. On parle d’invocation de type générique dans le sens où lors de l’utilisation du type générique le paramètre du type se trouve valorisé par un argument du type. Pour chaque valorisation du paramètre de type on obtient un type paramétré. Le type paramétré est une version de type générique pour l’argument du type fixé. Toutefois, à cause de l’effacement du type, à l’exécution il existe un seul type nommé type brute (Rawtype).

L’argument de type peut être tout type non primitif ou même un autre paramètre de type ou un Wildcard.

Type paramétré par un type

Un type générique peut être paramétré par un type concret (String, Double, Object, ..) ou un autre type paramétré. On peut parler ainsi d’un type paramétré par un type.

Exemples :

List<String>, List<Double>, List<List<String>>, List<List<? extends Number>> : sont des types paramétrés
Double, String, List<String>, List<? extends Number> : sont les arguments de type respectifs

A noter que les types paramétrés par un type sont invariants. A titre d’exemple, List<String> n’est pas un sous type de List<Object>.

static void maMethode(List<Object> objects){..}
List<String> strings = new ArrayList<>();
maMethode(strings); // Illegal

Type paramétré par un Wildcard

Pour gagner en flexibilité, nous pouvons utiliser les Wildcards pour exprimer la covariance, la contravariance et la bi-variance. Paramétrer le type par un Wildcard permet de représenter toute une famille de type

Wildcard non borné

Un type paramétré par un Wildcard non borné représente toute version du type générique. Dans l’exemple suivant la liste paramétrée par un Wildcard permet d’accepter toute sorte de liste :

static void maMethode(List<?> objects){..}
List<String> strings = new ArrayList<>();
maMethode(strings);
List<Integer> integers = new ArrayList<>();
maMethode(integers);

Ainsi nous pouvons considérer que cette version List<?> du type générique List<E> exprime la bi-variance.

Wildcard borné

Afin d’ajouter une restriction sur les arguments de type. Nous pouvons appliquer au Wildcard une borne supérieure ou inférieure. Si la borne supérieure exprime la covariance, celle inférieure exprime la contravariance.

Wildcard borné par sa borne supérieure

La méthode suivante accepte en entrée toute liste de Number ou une liste d’un sous type de Number. Ce cas exprime la version covariante du type générique List<E>.

A titre d’exemple, dans le code qui suit, nous pouvons passer en paramètre une liste d’Integer, de Double ou toute autre liste d’un sous type de Number :

static OptionalDouble max(List<? extends Number> list) {
return list.stream()
.mapToDouble(Number::doubleValue)
.reduce(Double::max);
}
OptionalDouble max = max(Arrays.asList(1, 2, 3, 5));

A noter que nous pouvons définir une version équivalente avec l’utilisation d’un paramètre de type borné :

static <E extends Number> OptionalDouble max(List<E> list) {..}
Wildcard borné par sa borne inférieure

La méthode suivante accepte en entrée toute liste d’Integer ou d’un type supérieur d’Integer. Ce cas exprime la version contravariante du type générique List<E>.

Dans l’exemple qui suit, nous pouvons passer en paramètre une liste d’Integer ou de Number voir même d’Object :

static void fillWith2Power(List<? super Integer> list) {
   IntStream.iterate(1, x -> x * 2)
           .limit(10)
           .forEach(list::add);
}
List<Number> dest = new ArrayList<>();
fillWith2Power(dest);
dest.forEach(System.out::println);

Type paramétré par un paramètre de type

Le paramètre de type d’un type (classe ou interface), d’une méthode ou d’un constructeur génériques peut être utilisé pour paramétrer un autre type générique dans la portée correspondante. A titre d’exemple, le type générique List<E> est paramétré par le paramètre de type E dans les cas suivants :

  • Une classe générique
public class GenericClass<E> {
void inspect(List<E> list){..}
}
  • Une méthode générique (statique ou non)
static <E extends Number> OptionalDouble max(List<E> list) {..}
  • Un constructeur
public <E extends Number> GenericClass(List<E> list) {..}

A remarquer que le paramètre de type peut être borné uniquement par sa borne supérieure.

Effacement du type

Pour des raisons de compatibilité avec le code existant, les génériques se limitent à la compilation. A l’exécution aucune information de type relative au paramétrage des génériques n’existe. On parle donc d’effacement du type. Pour un type générique donné, tous les types paramétrés partagent le même type à l’exécution. Ce dernier est dit type brute (Rawtype) et correspond au type générique sans l’argument du type.

Exemple : List est le type brute de List<E>

List<Integer> x = new ArrayList<Integer>();
List<Double> y = new ArrayList<Double>();
assert x.getClass() == y.getClass(); // true

Compte tenu de l’effacement du type, les cas suivants ne sont pas possibles :

  • Déclarer un paramètre de type statique
  • Instancier un paramètre de type new T
  • Définir un type d’exception générique
  • Surcharger une méthode avec une méthode qui induit la même signature après l’effacement du type

Comme les types brutes ne servent qu’à maintenir la rétrocompatibilité, il est recommandé de ne pas les utiliser.

Inférence du type

Lors de l’invocation d’un type, d’une méthode ou d’un constructeur générique, le paramètre de type prend la valeur de l’argument du type. Exemples :

  • Instantiation d’un constructeur générique
ArrayList<String> strings = new ArrayList<String>();
  • Ou d’une méthode générique
List<String> src = ..
List<String> dest= new ArrayList<>()
Util.<String>copy(src,dest);

Le compilateur peut se baser sur le contexte pour déduire les arguments de types. On parle donc d’inférence de type. Si plusieurs types candidats sont possibles, le compilateur utilise le type le plus spécifique qui satisfait les contraintes de type. Dans le cas échéant, il utilise le type Object comme argument de type.

A noter que ce mécanisme est également utilisé lors de l’invocation d’une expression Lambda.

L’inférence des arguments de type permet de :

  • S’affranchir de typer les paramètres pour une expression Lambda simplifiant ainsi l’écriture de l’expression
IntBinaryOperator add = (x, y) -> x + y;
  • Invoquer une méthode générique comme étant non générique
Util.copy(src,dest);
  • Simplifier l’appel au constructeur générique en remplaçant l’argument de type par l’opérateur Diamond <>
List<Integer> integerList = new ArrayList<>();
List<Double> doubleList = new ArrayList<>();

Références

https://docs.oracle.com/javase/tutorial/java/generics/index.html

https://docs.oracle.com/javase/tutorial/extra/generics/intro.html