Nous concluons ce chapitre avec l’une des fonctionnalités les plus utiles, et parfois les plus déroutantes, du langage Java : les génériques. En fait, nous les avons utilisés abondamment dans les deux derniers chapitres — le type entre les <>. Pourquoi avons-nous besoin de génériques ? Imaginez si nous ne spécifions pas le type de nos listes et espérons simplement que l’appelant n’y mette pas quelque chose que nous n’attendons pas. Voici ce qui se passe :
static void afficherNoms(List liste) {
for (int i = 0; i < liste.size(); i++) {
String nom = (String) liste.get(i); // ClassCastException
System.out.println(nom);
}
}
public static void main(String[] args) {
List noms = new ArrayList();
noms.add(new StringBuilder("Tanguy"));
afficherNoms(noms);
}
Ce code lance une ClassCastException. La ligne 22 ajoute un StringBuilder à la liste. C’est légal car une liste non générique peut contenir n’importe quoi. Cependant, la ligne 16 est écrite pour s’attendre à une classe spécifique. Elle effectue un cast vers String, reflétant cette hypothèse. Comme l’hypothèse est incorrecte, le code lance une ClassCastException indiquant que java.lang.StringBuilder ne peut pas être converti en java.lang.String.
Les génériques résolvent ce problème en vous permettant d’écrire et d’utiliser des types paramétrés. Puisque nous spécifions que nous voulons un ArrayList de String, le compilateur a suffisamment d’informations pour éviter ce problème dès le départ.
List<String> noms = new ArrayList<String>();
noms.add(new StringBuilder("Tanguy")); // NE COMPILE PAS
Obtenir une erreur de compilation est une bonne chose. Vous saurez immédiatement que quelque chose ne va pas plutôt que d’espérer le découvrir plus tard.
Création de Classes Génériques
Vous pouvez introduire des génériques dans vos propres classes. La syntaxe pour introduire un générique consiste à déclarer un paramètre de type formel entre crochets angulaires. Par exemple, la classe suivante nommée Caisse a une variable de type générique déclarée après le nom de la classe :
public class Caisse<T> {
private T contenu;
public T regarderDansCaisse() {
return contenu;
}
public void emballerCaisse(T contenu) {
this.contenu = contenu;
}
}
Le type générique T est disponible partout dans la classe Caisse. Lorsque vous instanciez la classe, vous indiquez au compilateur ce que T devrait être pour cette instance particulière.
Conventions de Nommage pour les Génériques
Un paramètre de type peut être nommé comme vous le souhaitez. La convention est d’utiliser des lettres majuscules simples pour qu’il soit évident qu’elles ne sont pas de vrais noms de classe. Voici les lettres couramment utilisées :
- E pour un élément
- K pour une clé de map
- V pour une valeur de map
- N pour un nombre
- T pour un type de données générique
- S, U, V, et ainsi de suite pour plusieurs types génériques
Par exemple, supposons qu’une classe Elephant existe, et que nous déplaçons notre éléphant vers un nouvel enclos plus grand dans notre zoo. (Le zoo de San Diego a fait cela en 2009. C’était intéressant de voir la grande caisse métallique.)
Elephant elephant = new Elephant();
Caisse<Elephant> caisseElephant = new Caisse<>();
caisseElephant.emballerCaisse(elephant);
Elephant nouveauDomicile = caisseElephant.regarderDansCaisse();
Pour être honnête, nous n’avons pas emballé la caisse autant que l’éléphant y est entré. Cependant, vous pouvez voir que la classe Caisse est capable de gérer un Elephant sans rien savoir à son sujet.
Cela ne semble probablement pas particulièrement impressionnant. Nous aurions pu simplement taper Elephant au lieu de T lors du codage de Caisse. Et si nous voulions créer une Caisse pour un autre animal?
Caisse<Zebre> caisseZebre = new Caisse<>();
Maintenant, nous n’aurions pas pu simplement coder en dur Elephant dans la classe Caisse puisqu’un Zebre n’est pas un Elephant. Cependant, nous aurions pu créer une superclasse ou une interface Animal et l’utiliser dans Caisse.
Les classes génériques deviennent utiles lorsque les classes utilisées comme paramètre de type n’ont absolument rien à voir les unes avec les autres. Par exemple, nous devons expédier notre robot de 120 livres dans une autre ville :
Robot robotJules = new Robot();
Caisse<Robot> caisseRobot = new Caisse<>();
caisseRobot.emballerCaisse(robotJules);
// expédition à Marseille
Robot aDestination = caisseRobot.regarderDansCaisse();
Maintenant ça devient intéressant. La classe Caisse fonctionne avec n’importe quel type de classe. Avant les génériques, nous aurions dû coder Caisse pour utiliser la classe Object pour sa variable d’instance, ce qui aurait mis le fardeau sur l’appelant pour effectuer un cast de l’objet qu’il reçoit lors du vidage de la caisse.
En plus du fait que Caisse n’a pas besoin de connaître les objets qui y entrent, ces objets n’ont pas besoin de connaître Caisse. Nous n’exigeons pas que les objets implémentent une interface nommée “Caissable” ou quelque chose de similaire. Une classe peut être mise dans la Caisse sans aucun changement.
Ne vous inquiétez pas si vous ne pensez pas à une utilisation pour les classes génériques dans votre propre code. À moins que vous n’écriviez une bibliothèque à réutiliser par d’autres, les génériques apparaissent rarement dans les définitions de classe que vous écrivez. Vous les avez déjà vus fréquemment dans le code que vous appelez, comme les interfaces fonctionnelles et les collections.
Les classes génériques ne sont pas limitées à avoir un seul paramètre de type. Cette classe montre deux paramètres génériques :
public class CaisseLimiteTaille<T, U> {
private T contenu;
private U limiteTaille;
public CaisseLimiteTaille(T contenu, U limiteTaille) {
this.contenu = contenu;
this.limiteTaille = limiteTaille;
}
}
T représente le type que nous mettons dans la caisse. U représente l’unité que nous utilisons pour mesurer la taille maximale de la caisse. Pour utiliser cette classe générique, nous pouvons écrire ce qui suit :
Elephant elephant = new Elephant();
Integer nbKilos = 15_000;
CaisseLimiteTaille<Elephant, Integer> c1 = new CaisseLimiteTaille<>(elephant, nbKilos);
Ici, nous spécifions que le type est Elephant, et l’unité est Integer. Nous incluons également un rappel que les littéraux numériques peuvent contenir des soulignements.
Comprendre l’Effacement de Type
Spécifier un type générique permet au compilateur d’appliquer une utilisation correcte du type générique. Par exemple, spécifier le type générique de Caisse comme Robot revient à remplacer le T dans la classe Caisse par Robot. Cependant, cela ne se produit qu’au moment de la compilation.
En coulisses, le compilateur remplace toutes les références à T dans Caisse par Object. En d’autres termes, après la compilation du code, vos génériques ne sont que des types Object. La classe Caisse ressemble à ceci au moment de l’exécution :
public class Caisse {
private Object contenu;
public Object regarderDansCaisse() {
return contenu;
}
public void emballerCaisse(Object contenu) {
this.contenu = contenu;
}
}
Cela signifie qu’il n’y a qu’un seul fichier de classe. Il n’y a pas de copies différentes pour différents types paramétrés. (Certains autres langages fonctionnent de cette façon.) Ce processus de suppression de la syntaxe des génériques de votre code est appelé effacement de type. L’effacement de type permet à votre code d’être compatible avec les anciennes versions de Java qui ne contiennent pas de génériques.
Le compilateur ajoute les casts pertinents pour que votre code fonctionne avec ce type de classe effacée. Par exemple, vous tapez ce qui suit :
Robot r = caisse.regarderDansCaisse();
Le compilateur le transforme en ce qui suit :
Robot r = (Robot) caisse.regarderDansCaisse();
Dans les sections suivantes, nous examinons les implications des génériques pour les déclarations de méthodes.
Surcharger une Méthode Générique
Seule une de ces deux méthodes est autorisée dans une classe car l’effacement de type réduira les deux ensembles d’arguments à (List input) :
public class AnimalLongueQueue {
protected void macher(List<Object> input) {}
protected void macher(List<Double> input) {} // NE COMPILE PAS
}
Pour la même raison, vous ne pouvez pas non plus surcharger une méthode générique d’une classe parente.
public class AnimalLongueQueue {
protected void macher(List<Object> input) {}
}
public class Fourmilier extends AnimalLongueQueue {
protected void macher(List<Double> input) {} // NE COMPILE PAS
}
Ces deux exemples ne compilent pas en raison de l’effacement de type. Dans la forme compilée, le type générique est supprimé, et cela apparaît comme une méthode surchargée invalide. Maintenant, examinons une sous-classe :
public class Fourmilier extends AnimalLongueQueue {
protected void macher(List<Object> input) {}
protected void macher(ArrayList<Double> input) {}
}
La première méthode macher() compile car elle utilise le même type générique dans la méthode surchargée que celle définie dans la classe parente. La seconde méthode macher() compile également. Cependant, c’est une méthode surchargée car l’un des arguments de méthode est une List et l’autre est un ArrayList. Lorsque vous travaillez avec des méthodes génériques, il est important de considérer le type sous-jacent.
Retourner des Types Génériques
Lorsque vous travaillez avec des méthodes surchargées qui retournent des génériques, les valeurs de retour doivent être covariantes. En termes de génériques, cela signifie que le type de retour de la classe ou de l’interface déclaré dans la méthode de surcharge doit être un sous-type de la classe définie dans la classe parente. Le type de paramètre générique doit correspondre exactement au type de son parent.
Étant donné la déclaration suivante pour la classe Mammifere, laquelle des deux sous-classes, Singe et Chevre, compile ?
public class Mammifere {
public List<CharSequence> jouer() { ... }
public CharSequence dormir() { ... }
}
public class Singe extends Mammifere {
public ArrayList<CharSequence> jouer() { ... }
}
public class Chevre extends Mammifere {
public List<String> jouer() { ... } // NE COMPILE PAS
public String dormir() { ... }
}
La classe Singe compile car ArrayList est un sous-type de List. La méthode jouer() dans la classe Chevre ne compile pas, cependant. Pour que les types de retour soient covariants, le paramètre de type générique doit correspondre. Même si String est un sous-type de CharSequence, il ne correspond pas exactement au type générique défini dans la classe Mammifere. Par conséquent, c’est considéré comme une surcharge invalide.
Notez que la méthode dormir() dans la classe Chevre compile puisque String est un sous-type de CharSequence. Cet exemple montre que la covariance s’applique au type de retour, tout comme au type de paramètre générique.
Pour ce chapitre, il pourrait être utile d’appliquer l’effacement de type aux questions impliquant des génériques pour s’assurer qu’elles compilent correctement. Une fois que vous avez déterminé quelles méthodes sont surchargées et lesquelles sont en train d’être surchargées, revenez en arrière, en vous assurant que les types génériques correspondent pour les méthodes surchargées. Et rappelez-vous, les méthodes génériques ne peuvent pas être surchargées en changeant uniquement le type de paramètre générique.
Implémentation d’Interfaces Génériques
Tout comme une classe, une interface peut déclarer un paramètre de type formel. Par exemple, l’interface Expediable suivante utilise un type générique comme argument de sa méthode expedier() :
public interface Expediable<T> {
void expedier(T t);
}
Il existe trois façons dont une classe peut aborder l’implémentation de cette interface. La première consiste à spécifier le type générique dans la classe. La classe concrète suivante indique qu’elle ne traite que des robots. Cela lui permet de déclarer la méthode expedier() avec un paramètre Robot :
class CaisseRobotExpediable implements Expediable<Robot> {
public void expedier(Robot t) { }
}
La deuxième façon est de créer une classe générique. La classe concrète suivante permet à l’appelant de spécifier le type du générique :
class CaisseAbstraiteExpediable<U> implements Expediable<U> {
public void expedier(U t) { }
}
Dans cet exemple, le paramètre de type aurait pu être nommé n’importe quoi, y compris T. Nous avons utilisé U dans l’exemple pour éviter toute confusion sur ce à quoi T fait référence.
La dernière façon est de ne pas utiliser de génériques du tout. C’est l’ancienne façon d’écrire du code. Cela génère un avertissement du compilateur concernant Expediable étant un type brut, mais cela compile. Ici, la méthode expedier() a un paramètre Object puisque le type générique n’est pas défini :
class CaisseExpediable implements Expediable {
public void expedier(Object t) { }
}
Ce que Vous Ne Pouvez Pas Faire avec les Types Génériques
Il y a certaines limitations sur ce que vous pouvez faire avec un type générique. La plupart des limitations sont dues à l’effacement de type. Oracle fait référence aux types dont l’information est entièrement disponible au moment de l’exécution comme réifiables. Les types réifiables peuvent faire tout ce que Java permet. Les types non réifiables ont certaines limitations.
Voici les choses que vous ne pouvez pas faire avec les génériques (et par “ne pouvez pas”, nous voulons dire sans recourir à des contorsions comme passer un objet de classe) :
- Appeler un constructeur : Écrire new T() n’est pas autorisé car au moment de l’exécution, ce serait new Object().
- Créer un tableau de ce type générique : Celui-ci est le plus ennuyeux, mais cela a du sens car vous créeriez un tableau de valeurs Object.
- Appeler instanceof : Cela n’est pas autorisé car au moment de l’exécution List<Integer> et List<String> apparaissent identiques à Java, grâce à l’effacement de type.
- Utiliser un type primitif comme type de paramètre générique : Ce n’est pas un gros problème car vous pouvez utiliser la classe wrapper à la place. Si vous voulez un type de int, utilisez simplement Integer.
- Créer une variable statique comme paramètre de type générique : Cela n’est pas autorisé car le type est lié à l’instance de la classe.
Écrire des Méthodes Génériques
Jusqu’à présent, vous avez vu des paramètres de type formels déclarés au niveau de la classe ou de l’interface. Il est également possible de les déclarer au niveau de la méthode. C’est souvent utile pour les méthodes statiques puisqu’elles ne font pas partie d’une instance qui peut déclarer le type. Cependant, c’est également autorisé sur les méthodes non statiques.
Dans cet exemple, les deux méthodes utilisent un paramètre générique :
public class Manipulateur {
public static <T> void preparer(T t) {
System.out.println("Préparation de " + t);
}
public static <T> Caisse<T> expedier(T t) {
System.out.println("Expédition de " + t);
return new Caisse<T>();
}
}
Le paramètre de méthode est le type générique T. Avant le type de retour, nous déclarons le paramètre de type formel de <T>. Dans la méthode expedier(), nous montrons comment vous pouvez utiliser le paramètre générique dans le type de retour, Caisse<T>, pour la méthode.
À moins qu’une méthode n’obtienne le paramètre de type générique formel de la classe/interface, il est spécifié immédiatement avant le type de retour de la méthode. Cela peut conduire à un code d’apparence intéressante !
public class Plus {
public static <T> void couler(T t) { }
public static <T> T identite(T t) { return t; }
public static T pasbon(T t) { return t; } // NE COMPILE PAS
}
La ligne 3 montre le type de paramètre formel immédiatement avant le type de retour void. La ligne 4 montre le type de retour étant le type de paramètre formel. Cela a l’air bizarre, mais c’est correct. La ligne 5 omet le type de paramètre formel et ne compile donc pas.
Syntaxe Optionnelle pour Appeler une Méthode Générique
Vous pouvez appeler une méthode générique normalement, et le compilateur essaiera de comprendre laquelle vous voulez. Alternativement, vous pouvez spécifier le type explicitement pour rendre évident quel est le type.
Box.<String>expedier("colis");
Box.<String[]>expedier(args);
C’est à vous de décider si cela rend les choses plus claires. Vous devriez au moins être conscient que cette syntaxe existe.
Lorsque vous avez une méthode qui déclare un type de paramètre générique, il est indépendant des génériques de la classe. Jetez un coup d’œil à cette classe qui déclare un générique T aux deux niveaux :
public class CaisseTrompeuse<T> {
public <T> T trompeuse(T t) {
return t;
}
}
Voyez si vous pouvez déterminer le type de T aux lignes 1 et 2 lorsque nous appelons le code comme suit :
public static String nomCaisse() {
CaisseTrompeuse<Robot> caisse = new CaisseTrompeuse<>();
return caisse.trompeuse("bot");
}
Clairement, “T est pour trompeur.” Voyons ce qui se passe. À la ligne 1, T est Robot car c’est ce qui est référencé lors de la construction d’une Caisse. À la ligne 2, T est String car c’est ce qui est passé à la méthode. Lorsque vous voyez du code comme celui-ci, prenez une profonde inspiration et notez ce qui se passe pour ne pas vous confondre.
Création d’un Enregistrement Générique
Les génériques peuvent également être utilisés avec les enregistrements. Cet enregistrement prend un seul paramètre de type générique :
public record EnregistrementCaisse<T>(T contenu) {
@Override
public T contenu() {
if (contenu == null)
throw new IllegalStateException("contenu manquant");
return contenu;
}
}
Cela fonctionne de la même manière que les classes. Vous pouvez créer un enregistrement du robot !
Robot robot = new Robot();
EnregistrementCaisse<Robot> enregistrement = new EnregistrementCaisse<>(robot);
C’est pratique. Maintenant nous avons un enregistrement immuable et générique !
Borner les Types Génériques
À ce stade, vous avez peut-être remarqué que les génériques ne semblent pas particulièrement utiles puisqu’ils sont traités comme des Objects et, par conséquent, n’ont pas beaucoup de méthodes disponibles. Les jokers bornés résolvent cela en restreignant les types qui peuvent être utilisés dans un joker. Un type de paramètre borné est un type générique qui spécifie une borne pour le générique. Un type de joker générique est un type générique inconnu représenté par un point d’interrogation (?).
Type de borne | Syntaxe | Exemple |
---|---|---|
Joker non borné | ? | List<?> a = new ArrayList<String>(); |
Joker avec borne supérieure | ? extends type | List<? extends Exception> a = new ArrayList<RuntimeException>(); |
Joker avec borne inférieure | ? super type | List<? super Exception> a = new ArrayList<Object>(); |
Créer des Jokers Non Bornés
Un joker non borné représente n’importe quel type de données. Vous utilisez ? lorsque vous voulez spécifier que n’importe quel type vous convient. Supposons que nous voulions écrire une méthode qui parcourt une liste de n’importe quel type.
public static void afficherListe(List<Object> liste) {
for (Object x: liste)
System.out.println(x);
}
public static void main(String[] args) {
List<String> motsClefs = new ArrayList<>();
motsClefs.add("java");
afficherListe(motsClefs); // NE COMPILE PAS
}
Attendez. Qu’est-ce qui ne va pas ? Un String est une sous-classe d’un Object. C’est vrai. Cependant, List<String> ne peut pas être assigné à List<Object>. Nous savons, cela ne semble pas logique. Java essaie de nous protéger de nous-mêmes avec celui-ci. Imaginez si nous pouvions écrire du code comme ceci :
List<Integer> nombres = new ArrayList<>();
nombres.add(Integer.valueOf(42));
List<Object> objets = nombres; // NE COMPILE PAS
objets.add("quarante deux");
System.out.println(nombres.get(1));
À la ligne 4, le compilateur nous promet que seuls des objets Integer apparaîtront dans nombres. Si la ligne 6 compilait, la ligne 7 briserait cette promesse en y mettant un String puisque nombres et objets sont des références au même objet. C’est une bonne chose que le compilateur empêche cela.
En revenant à l’affichage d’une liste, nous ne pouvons pas assigner une List<String> à une List<Object>. C’est bien ; nous n’avons pas besoin d’une List<Object>. Ce dont nous avons vraiment besoin est une List de “n’importe quoi”. C’est ce qu’est List<?>. Le code suivant fait ce que nous attendons :
public static void afficherListe(List<?> liste) {
for (Object x: liste)
System.out.println(x);
}
public static void main(String[] args) {
List<String> motsClefs = new ArrayList<>();
motsClefs.add("java");
afficherListe(motsClefs);
}
La méthode afficherListe() prend n’importe quel type de liste comme paramètre. La variable motsClefs est de type List<String>. Nous avons une correspondance ! List<String> est une liste de n’importe quoi. “N’importe quoi” se trouve être un String ici.
Enfin, examinons l’impact de var. Pensez-vous que ces deux déclarations sont équivalentes ?
List<?> x1 = new ArrayList<>();
var x2 = new ArrayList<>();
Elles ne le sont pas. Il y a deux différences clés. Premièrement, x1 est de type List, tandis que x2 est de type ArrayList. De plus, nous ne pouvons assigner x2 qu’à une List<Object>. Ces deux variables ont une chose en commun. Les deux retournent le type Object lors de l’appel de la méthode get().
Créer des Jokers avec Borne Supérieure
Essayons d’écrire une méthode qui additionne le total d’une liste de nombres. Nous avons établi qu’un type générique ne peut pas simplement utiliser une sous-classe.
ArrayList<Number> liste = new ArrayList<Integer>(); // NE COMPILE PAS
Au lieu de cela, nous devons utiliser un joker :
List<? extends Number> liste = new ArrayList<Integer>();
Le joker avec borne supérieure indique que n’importe quelle classe qui extends Number ou Number lui-même peut être utilisé comme type de paramètre formel :
public static long total(List<? extends Number> liste) {
long compte = 0;
for (Number nombre: liste)
compte += nombre.longValue();
return compte;
}
Rappelez-vous comment nous avons dit que l’effacement de type fait que Java considère qu’un type générique est un Object ? Cela se produit toujours ici. Java convertit le code précédent en quelque chose d’équivalent à ce qui suit :
public static long total(List liste) {
long compte = 0;
for (Object obj: liste) {
Number nombre = (Number) obj;
compte += nombre.longValue();
}
return compte;
}
Quelque chose d’intéressant se produit lorsque nous travaillons avec des bornes supérieures ou des jokers non bornés. La liste devient logiquement immuable et ne peut donc pas être modifiée. Techniquement, vous pouvez supprimer des éléments de la liste, mais ce n’est pas vraiment important pour notre discussion.
static class Moineau extends Oiseau { }
static class Oiseau { }
public static void main(String[] args) {
List<? extends Oiseau> oiseaux = new ArrayList<Oiseau>();
oiseaux.add(new Moineau()); // NE COMPILE PAS
oiseaux.add(new Oiseau()); // NE COMPILE PAS
}
Le problème vient du fait que Java ne sait pas quel type est réellement List<? extends Oiseau>. Ce pourrait être List<Oiseau> ou List<Moineau> ou un autre type générique qui n’a même pas encore été écrit. La ligne 7 ne compile pas parce que nous ne pouvons pas ajouter un Moineau à List<? extends Oiseau>, et la ligne 8 ne compile pas parce que nous ne pouvons pas ajouter un Oiseau à List<Moineau>. Du point de vue de Java, les deux scénarios sont également possibles, donc aucun n’est autorisé.
Maintenant, essayons un exemple avec une interface. Nous avons une interface et deux classes qui l’implémentent.
interface Volant { void voler(); }
class Deltaplane implements Volant { public void voler() {} }
class Oie implements Volant { public void voler() {} }
Nous avons aussi deux méthodes qui l’utilisent. L’une liste simplement l’interface, et l’autre utilise une borne supérieure.
private void nimporteQuelVolant(List<Volant> volant) {}
private void groupeDeVolants(List<? extends Volant> volant) {}
Notez que nous avons utilisé le mot-clé extends plutôt que implements. Les bornes supérieures sont comme les classes anonymes en ce sens qu’elles utilisent extends indépendamment du fait que nous travaillons avec une classe ou une interface.
Vous avez déjà appris qu’une variable de type List<Volant> peut être passée à l’une ou l’autre méthode. Une variable de type List<Oie> ne peut être passée qu’à celle avec la borne supérieure. Cela montre un avantage des génériques. Les volants aléatoires ne volent pas ensemble. Nous voulons que notre méthode groupeDeVolants() ne soit appelée qu’avec le même type. Les oies volent ensemble mais ne volent pas avec des deltaplanes.
Créer des Jokers avec Borne Inférieure
Essayons d’écrire une méthode qui ajoute une chaîne “coin” à deux listes :
List<String> chaines = new ArrayList<String>();
chaines.add("gazouiller");
List<Object> objets = new ArrayList<Object>(chaines);
ajouterSon(chaines);
ajouterSon(objets);
Le problème est que nous voulons passer une List<String> et une List<Object> à la même méthode. Tout d’abord, assurons-nous de comprendre pourquoi les trois premiers exemples dans le tableau suivant ne résolvent pas ce problème.
static void ajouterSon(______ liste) {liste.add(“coin”);} | La méthode compile | Peut passer une List<String> | Peut passer une List<Object> |
---|---|---|---|
List<?> | Non (les génériques non bornés sont immuables) | Oui | Oui |
List<? extends Object> | Non (les génériques à borne supérieure sont immuables) | Oui | Oui |
List<Object> | Oui | Non (avec les génériques, doit passer une correspondance exacte) | Oui |
List<? super String> | Oui | Oui | Oui |
Pour résoudre ce problème, nous devons utiliser une borne inférieure.
public static void ajouterSon(List<? super String> liste) {
liste.add("coin");
}
Avec une borne inférieure, nous disons à Java que la liste sera une liste d’objets String ou une liste de certains objets qui sont une superclasse de String. Dans tous les cas, il est sûr d’ajouter un String à cette liste.
Tout comme les classes génériques, vous n’utiliserez probablement pas cela dans votre code à moins que vous n’écriviez du code pour que d’autres réutilisent. Même alors, ce serait rare. Mais c’est un concept important à comprendre, alors c’est le moment de l’apprendre !
Comprendre les Supertypes Génériques
Lorsque vous avez des sous-classes et des superclasses, les bornes inférieures peuvent devenir délicates.
List<? super IOException> exceptions = new ArrayList<Exception>();
exceptions.add(new Exception()); // NE COMPILE PAS
exceptions.add(new IOException());
exceptions.add(new FileNotFoundException());
La première ligne référence une List qui pourrait être List<IOException> ou List<Exception> ou List<Object>. La deuxième ligne ne compile pas car nous pourrions avoir une List<IOException> et un objet Exception n’y rentrerait pas.
La troisième ligne est correcte. IOException peut être ajouté à n’importe lequel de ces types. La quatrième ligne est également correcte. FileNotFoundException peut aussi être ajouté à n’importe lequel de ces trois types. C’est délicat parce que FileNotFoundException est une sous-classe de IOException, et le mot-clé dit super. Java dit : “Eh bien, FileNotFoundException est aussi une IOException, donc tout va bien.”
Mettre tout ensemble
À ce stade, vous connaissez tout ce que vous devez savoir sur les génériques. Il est possible de rassembler ces concepts pour écrire du code vraiment déroutant. Cette section va être difficile à lire. Elle contient les questions les plus difficiles que vous pourriez rencontrer sur les génériques. Ne paniquez pas. Prenez votre temps et relisez le code quelques fois. Vous y arriverez.
Combiner les Déclarations Génériques
Essayons un exemple. D’abord, nous déclarons trois classes que l’exemple utilisera :
class A {}
class B extends A {}
class C extends B {}
Prêt ? Pouvez-vous déterminer pourquoi ces déclarations compilent ou ne compilent pas ? Essayez aussi de comprendre ce qu’elles font.
List<?> liste1 = new ArrayList<A>();
List<? extends A> liste2 = new ArrayList<A>();
List<? super A> liste3 = new ArrayList<A>();
La première ligne crée un ArrayList qui peut contenir des instances de la classe A. Elle est stockée dans une variable avec un joker non borné. N’importe quel type générique peut être référencé à partir d’un joker non borné, ce qui rend cela correct.
La deuxième ligne essaie de stocker une liste dans une déclaration de variable avec un joker à borne supérieure. C’est correct. Vous pouvez avoir ArrayList<A>, ArrayList<B>, ou ArrayList<C> stockés dans cette référence. La troisième ligne est également correcte. Cette fois, vous avez un joker à borne inférieure. Le type le plus bas que vous pouvez référencer est A. Puisque c’est ce que vous avez, cela compile.
Avez-vous réussi? Essayons-en quelques autres.
List<? extends B> liste4 = new ArrayList<A>(); // NE COMPILE PAS
List<? super B> liste5 = new ArrayList<A>();
List<?> liste6 = new ArrayList<? extends A>(); // NE COMPILE PAS
La quatrième ligne a un joker à borne supérieure qui permet de référencer ArrayList<B> ou ArrayList<C>. Puisque vous avez ArrayList<A> qui essaie d’être référencé, le code ne compile pas. La cinquième ligne a un joker à borne inférieure, qui permet une référence à ArrayList<A>, ArrayList<B>, ou ArrayList<Object>.
Enfin, la sixième ligne permet une référence à n’importe quel type générique puisqu’il s’agit d’un joker non borné. Le problème est que vous devez savoir quel sera ce type lors de l’instanciation de l’ArrayList. Ce ne serait pas utile de toute façon, car vous ne pouvez pas ajouter d’éléments à cet ArrayList.
Passer des Arguments Génériques
Passons maintenant aux méthodes. Même question : essayez de comprendre pourquoi elles ne compilent pas ou ce qu’elles font. Nous présenterons les méthodes une par une car il y a plus à réfléchir.
<T> T premier(List<? extends T> liste) {
return liste.get(0);
}
La première méthode, premier(), est une utilisation parfaitement normale des génériques. Elle utilise un paramètre de type spécifique à la méthode, T. Elle prend un paramètre de List<T>, ou une sous-classe de T, et renvoie un seul objet de ce type T. Par exemple, vous pourriez l’appeler avec un paramètre List<String> et avoir un String retourné. Ou vous pourriez l’appeler avec un paramètre List<Number> et avoir un Number retourné. Ou — eh bien, vous avez compris l’idée.
Compte tenu de cela, vous devriez être capable de voir ce qui ne va pas avec celle-ci :
<T> <? extends T> second(List<? extends T> liste) { // NE COMPILE PAS
return liste.get(0);
}
La méthode suivante, second(), ne compile pas car le type de retour n’est pas réellement un type. Vous écrivez la méthode. Vous savez quel type elle est censée retourner. Vous n’avez pas le droit de spécifier cela comme un joker.
Maintenant, soyez prudent — celle-ci est particulièrement délicate :
<B extends A> B troisieme(List<B> liste) {
return new B(); // NE COMPILE PAS
}
Cette méthode, troisieme(), ne compile pas. <B extends A> indique que vous voulez utiliser B comme paramètre de type juste pour cette méthode et qu’il doit étendre la classe A. Par coïncidence, B est aussi le nom d’une classe. Eh bien, ce n’est pas une coïncidence. C’est un piège diabolique. Dans la portée de la méthode, B peut représenter la classe A, B ou C, car toutes étendent la classe A. Puisque B ne fait plus référence à la classe B dans la méthode, vous ne pouvez pas l’instancier.
Après cela, il serait agréable d’avoir quelque chose de simple.
void quatrieme(List<? super B> liste) {}
Nous obtenons enfin une méthode, quatrieme(), qui est une utilisation normale des génériques. Vous pouvez passer le type List<B>, List<A>, ou List<Object>.
Enfin, pouvez-vous comprendre pourquoi cet exemple ne compile pas ?
<X> void cinquieme(List<X super B> liste) { // NE COMPILE PAS
}
Cette dernière méthode, cinquieme(), ne compile pas car elle essaie de mélanger un paramètre de type spécifique à la méthode avec un joker. Un joker doit avoir un ? dedans.
Ouf. Vous avez réussi à traverser les génériques. C’est le sujet le plus difficile de ce chapitre (et c’est pourquoi nous l’avons traité en dernier !). N’oubliez pas qu’il est normal de devoir revoir ce matériel plusieurs fois pour bien le comprendre.