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) :
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 :
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é
La conversion n’est plus explicite
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 :
Cette covariance autorise une forme de polymorphisme sur les paramètres de type tableau :
Elle permet également la redéfinition d’une méthode en spécialisant son type de retour :
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 :
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 :
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.
Un type générique pourrait déclarer plusieurs paramètres de type :
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é :
Le paramètre de type est dit récursif quand il est déclaré d’une manière récursive :
Dans l’exemple suivant, le paramètre de type est récursif et borné avec plusieurs types :
Les méthodes et constructeurs génériques peuvent également avoir un paramètre de type borné :
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 :
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 :
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>.
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 :
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 :
A noter que nous pouvons définir une version équivalente avec l’utilisation d’un paramètre de type borné :
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 :
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
Une méthode générique (statique ou non)
Un constructeur
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>
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
Ou d’une méthode générique
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
Invoquer une méthode générique comme étant non générique
Simplifier l’appel au constructeur générique en remplaçant l’argument de type par l’opérateur Diamond <>
Références
https://docs.oracle.com/javase/tutorial/java/generics/index.html
https://docs.oracle.com/javase/tutorial/extra/generics/intro.html