Génériques et collections¶
Les génériques Java ressemblent aux génériques Rust sur le papier, mais fonctionnent très différemment à l'exécution. Et les collections sont la bibliothèque standard que tu utiliseras dans chaque ligne de code Spring.
Dans ce chapitre
- Les génériques
<T>, les bornes (extends) et les wildcards (?) - L'effacement de type : pourquoi
new T()etList<int>n'existent pas - Le boxing (
int↔Integer) et ses pièges List,Set,Map,Deque: les interfaces, leurs implémentations et leurs équivalents Rust / Python- Les collections immuables, la
ConcurrentModificationException, l'APIMapet lesComparator
Les génériques¶
Classes et méthodes génériques¶
class Boite<T> {
private final T valeur;
Boite(T valeur) { this.valeur = valeur; }
T get() { return valeur; }
}
// méthode générique : le <T> se déclare avant le type de retour
static <T> T premier(List<T> liste) {
return liste.get(0);
}
Boite<String> b = new Boite<>("salut"); // (1)!
String s = b.get(); // pas de cast
String p = premier(List.of("a", "b")); // T inféré : String
<>(le diamond) : le compilateur déduit<String>depuis la déclaration à gauche.
Les bornes : l'équivalent des trait bounds¶
Pour appeler une méthode sur T, il faut dire au compilateur ce que T sait faire. C'est extends, même quand la borne est une interface.
Plusieurs bornes se combinent avec & : <T extends Number & Comparable<T>>.
L'effacement de type (type erasure)¶
C'est la grosse différence avec Rust et C++. Le compilateur Java vérifie les types génériques, puis les efface : dans le bytecode, List<String> et List<Integer> deviennent la même classe List, qui manipule des Object. Le compilateur insère les casts aux bons endroits.
List<String> a = new ArrayList<>();
List<Integer> c = new ArrayList<>();
System.out.println(a.getClass() == c.getClass()); // affiche : true
| Langage | Mécanisme | Conséquence |
|---|---|---|
| Rust | Monomorphisation | Une copie du code par type, zéro coût, binaire plus gros |
| C++ | Templates | Idem, instanciés à la compilation |
| Java | Effacement | Un seul code pour tous les types, T inconnu à l'exécution |
Conséquences directes (ce code ne compile pas, les erreurs sont réelles) :
static <T> T creer() {
return new T(); // error: unexpected type (required: class, found: type parameter T)
}
List<int> l; // error: unexpected type (required: reference, found: int)
Object o = new ArrayList<String>();
if (o instanceof List<String> ls) {} // error: Object cannot be safely cast to List<String>
static <T> T[] tab() { return new T[10]; } // error: generic array creation
void f(List<String> l) {}
void f(List<Integer> l) {} // error: name clash: ... have the same erasure
Les contournements :
new T(): passe une fabrique,Supplier<T>(creer(ArrayList::new)) ouClass<T>.List<int>: utiliseList<Integer>(voir le boxing juste après).instanceof List<String>: testeinstanceof List<?>, puis vérifie les éléments si besoin.new T[]: utilise uneList<T>.
Sous le capot : pourquoi l'effacement ?
Les génériques sont arrivés en Java 5 (2004). Effacer les types permettait au nouveau code de tourner avec les anciennes bibliothèques sans recompiler. Le prix : T n'existe plus à l'exécution.
Nuance importante pour Spring : seules les instances perdent leur type. Les déclarations (type d'un champ, signature d'une méthode, classe parente) gardent leurs génériques dans le .class, et la réflexion peut les lire. Avec class TodoRepo extends Repo<String>, TodoRepo.class.getGenericSuperclass() renvoie bien Repo<java.lang.String>. C'est comme ça que Spring sait qu'il doit injecter un Repository<Todo> et pas un Repository<User>.
Le boxing : int contre Integer¶
Les génériques ne prennent que des objets. Chaque primitif a donc une classe « enveloppe » (wrapper) : int → Integer, long → Long, double → Double, boolean → Boolean, char → Character. Le compilateur convertit automatiquement dans les deux sens (autoboxing / unboxing).
List<Integer> nombres = new ArrayList<>();
nombres.add(42); // boxing : Integer.valueOf(42)
int n = nombres.get(0); // unboxing : .intValue()
Piège : un null déboxé lève une NullPointerException
Un Integer peut valoir null, un int non. L'unboxing appelle .intValue() sur null :
Et comme un Integer est un objet, == compare des références : le piège du cache des Integer (de -128 à 127) est détaillé dans Mémoire et références. Compare toujours les wrappers avec equals.
Piège : remove(int) contre remove(Object)
Sur une List<Integer>, remove(1) retire l'élément à l'index 1, pas la valeur 1 :
Coût : chaque Integer est un objet sur le tas (environ 16 octets, plus la référence de 4 à 8 octets) au lieu de 4 octets contigus. Une List<Integer> d'un million d'éléments est donc bien plus lourde et moins cache-friendly qu'un int[] ou qu'un Vec<i32>. Pour du calcul numérique massif, utilise des tableaux de primitifs ou IntStream (voir Lambdas et streams).
Les wildcards ? en bref¶
Une List<Integer> n'est pas une List<Number>, même si Integer hérite de Number (sinon on pourrait y ajouter un Double). Le compilateur refuse : incompatible types: List<Integer> cannot be converted to List<Number>. Les wildcards assouplissent ça :
// ? extends : je LIS des Number (producteur)
static double somme(List<? extends Number> nombres) {
double total = 0;
for (Number n : nombres) total += n.doubleValue();
return total;
}
somme(List.of(1, 2, 3)); // 6.0
somme(List.of(1.5, 2.5)); // 4.0
// ? super : j'ÉCRIS des Integer (consommateur)
static void remplir(List<? super Integer> cible) {
for (int i = 1; i <= 3; i++) cible.add(i);
}
remplir(new ArrayList<Number>()); // ok
remplir(new ArrayList<Object>()); // ok
Moyen mnémotechnique : PECS, Producer Extends, Consumer Super. Tu en croiseras surtout dans les signatures de la bibliothèque standard ; dans ton code, c'est rare.
Les collections¶
Interfaces et implémentations¶
Java sépare le contrat (l'interface) de la structure de données (la classe) :
flowchart TD
Collection --> List
Collection --> Set
Collection --> Deque
List --> ArrayList
Set --> HashSet
Set --> TreeSet
Deque --> ArrayDeque
Map --> HashMap
Map --> LinkedHashMap
Map --> TreeMap
| Interface | Implémentation | Rust | Python |
|---|---|---|---|
List |
ArrayList |
Vec<T> |
list |
Set |
HashSet |
HashSet<T> |
set |
Set |
TreeSet (trié) |
BTreeSet<T> |
— |
Map |
HashMap |
HashMap<K, V> |
dict (sans l'ordre) |
Map |
LinkedHashMap |
crate indexmap |
dict |
Map |
TreeMap (trié) |
BTreeMap<K, V> |
— |
Deque |
ArrayDeque |
VecDeque<T> |
collections.deque |
Quelques remarques :
HashMapetHashSetn'ont aucun ordre garanti. UndictPython garde l'ordre d'insertion : son vrai équivalent estLinkedHashMap.HashMap/HashSets'appuient surequalsethashCodedes clés (voir Classes et objets) ; les records les fournissent.TreeMap/TreeSetexigent des clés comparables.LinkedListexiste mais est presque toujours plus lente qu'ArrayListouArrayDeque. Oublie-la.
Set<String> tags = new HashSet<>(List.of("java", "rust", "python", "c++"));
System.out.println(tags); // affiche : [rust, python, c++, java] (ordre quelconque)
System.out.println(new TreeSet<>(tags)); // affiche : [c++, java, python, rust]
System.out.println(tags.add("java")); // affiche : false (déjà présent)
Map<String, Integer> ages = new HashMap<>();
ages.put("Ada", 36);
ages.get("Ada"); // 36
ages.get("Bob"); // null (pas d'exception, pas d'Option)
Deque<Integer> pile = new ArrayDeque<>();
pile.push(1); pile.push(2); pile.push(3);
pile.pop(); // 3 : pile LIFO
Deque<Integer> file = new ArrayDeque<>();
file.offer(1); file.offer(2); file.offer(3);
file.poll(); // 1 : file FIFO
Déclarer avec l'interface¶
Le reste du code ne dépend que du contrat List, et tu peux changer d'implémentation sur une seule ligne. Même logique qu'en Rust quand une fonction prend &[T] plutôt que &Vec<T>. Pour une variable locale, var noms = new ArrayList<String>(); est acceptable.
Piège : var + diamond
var l = new ArrayList<>(); donne une ArrayList<Object> : l.add("a"); l.add(42); compile. Avec var, écris le type entre les chevrons.
Les collections immuables¶
List<String> fixe = List.of("a", "b");
fixe.add("c"); // UnsupportedOperationException à l'exécution !
Map<String, Integer> m = Map.of("un", 1, "deux", 2);
List<String> copie = List.copyOf(autreListe); // copie immuable
List.of,Set.of,Map.ofrefusent lesnull(NullPointerException) etMap.ofrefuse les clés en double (IllegalArgumentException: duplicate key: a).List.copyOffait une vraie copie ;Collections.unmodifiableListn'est qu'une vue (voir Mémoire et références).
Différence avec Rust
En Rust, l'immutabilité est vérifiée à la compilation. En Java, elle est vérifiée à l'exécution : l'interface List a une méthode add même quand la liste est immuable, et l'appel lève une exception.
Parcourir et modifier¶
for (String nom : noms) { ... } // for-each (Iterable)
for (int i = 0; i < noms.size(); i++) { ... } // par index
noms.forEach(nom -> System.out.println(nom)); // lambda
nom -> ... est une lambda, l'équivalent de |nom| ... en Rust, détaillée dans Lambdas et streams.
Modifier une collection pendant qu'on la parcourt en for-each est interdit :
List<String> noms = new ArrayList<>(List.of("Ada", "Bob", "Carl", "Dan"));
for (String n : noms) {
if (n.startsWith("A")) noms.remove(n); // ConcurrentModificationException
}
Rust refuserait ce code à la compilation (emprunt mutable pendant un emprunt partagé). Java le détecte à l'exécution, et seulement « au mieux » : retirer l'avant-dernier élément ne lève pas d'exception, la boucle s'arrête juste un tour trop tôt. Les bonnes façons :
noms.removeIf(n -> n.startsWith("A")); // la plus simple
Iterator<String> it = noms.iterator(); // ou l'itérateur explicite
while (it.hasNext()) {
if (it.next().startsWith("A")) it.remove();
}
Le nom est trompeur
ConcurrentModificationException n'a rien à voir avec les threads : un seul thread suffit.
L'API Map¶
Les méthodes à connaître, avec le classique compteur de mots :
Map<String, Integer> compte = new TreeMap<>();
for (String mot : "le chat et le chien et le rat".split(" ")) {
compte.merge(mot, 1, Integer::sum); // (1)!
}
System.out.println(compte);
// affiche : {chat=1, chien=1, et=2, le=3, rat=1}
- Si la clé est absente, met
1; sinon, combine l'ancienne valeur et1avecInteger::sum.
compte.getOrDefault("loup", 0); // 0, comme dict.get(k, 0)
compte.putIfAbsent("loup", 5); // n'écrase pas une valeur existante
// regrouper : ≈ entry(k).or_insert_with(Vec::new) / defaultdict(list)
Map<Character, List<String>> parInitiale = new TreeMap<>();
for (String mot : List.of("chat", "chien", "rat", "renard")) {
parInitiale.computeIfAbsent(mot.charAt(0), k -> new ArrayList<>()).add(mot);
}
// {c=[chat, chien], r=[rat, renard]}
for (Map.Entry<String, Integer> e : compte.entrySet()) { // ≈ .items()
System.out.println(e.getKey() + " -> " + e.getValue());
}
compte.forEach((k, v) -> System.out.println(k + "=" + v));
Trier avec Comparator¶
Un type qui implémente Comparable a un ordre « naturel » (String, Integer, les dates...). Pour tout le reste, on construit un Comparator :
record Personne(String nom, int age) {}
gens.sort(Comparator.comparing(Personne::age).thenComparing(Personne::nom));
// [Personne[nom=Bob, age=20], Personne[nom=Ada, age=36],
// Personne[nom=Grace, age=36], Personne[nom=Linus, age=56]]
gens.sort(Comparator.comparingInt(Personne::age).reversed()
.thenComparing(Personne::nom));
// Linus 56, Ada 36, Grace 36, Bob 20
C'est l'équivalent de sort_by_key / cmp().then_with() en Rust, ou de sorted(key=lambda p: (p.age, p.nom)) en Python. Le tri de List.sort est stable.
Piège : lambda + reversed()
Comparator.comparing(p -> p.age()).reversed() ne compile pas : cannot find symbol: method age(), location: variable p of type Object. Avec le .reversed() chaîné, le compilateur ne peut plus inférer le type de p. Utilise une référence de méthode (Personne::age) ou type le paramètre : (Personne p) -> p.age().
Les collections séquencées (Java 21)¶
Depuis Java 21, List, Deque, LinkedHashSet, TreeSet, LinkedHashMap et TreeMap ont un ordre officiel et partagent des méthodes communes (interfaces SequencedCollection et SequencedMap) :
List<Integer> nombres = new ArrayList<>(List.of(1, 2, 3, 4));
nombres.getFirst(); // 1 (avant : nombres.get(0))
nombres.getLast(); // 4 (avant : nombres.get(nombres.size() - 1))
nombres.reversed(); // [4, 3, 2, 1] : une vue, pas une copie
nombres.addFirst(0);
nombres.removeLast(); // nombres = [0, 1, 2, 3]
LinkedHashMap<String, Integer> m = new LinkedHashMap<>();
m.put("a", 1); m.put("b", 2);
m.firstEntry(); // a=1
Sur une collection vide, getFirst() lève NoSuchElementException. HashSet et HashMap n'ont pas d'ordre, donc pas ces méthodes.
Exercices¶
1. Écris une méthode générique inverser qui transforme une Map<K, V> en Map<V, List<K>> (les clés regroupées par valeur), en gardant l'ordre d'insertion.
Solution
static <K, V> Map<V, List<K>> inverser(Map<K, V> source) {
Map<V, List<K>> resultat = new LinkedHashMap<>();
for (Map.Entry<K, V> e : source.entrySet()) {
resultat.computeIfAbsent(e.getValue(), v -> new ArrayList<>())
.add(e.getKey());
}
return resultat;
}
Map<String, String> langages = new LinkedHashMap<>();
langages.put("Rust", "compilé");
langages.put("Python", "interprété");
langages.put("C++", "compilé");
langages.put("Ruby", "interprété");
System.out.println(inverser(langages));
// affiche : {compilé=[Rust, C++], interprété=[Python, Ruby]}
2. Compte les mots de "le chat et le chien et le rat et la souris" et affiche les 3 plus fréquents (à égalité, par ordre alphabétique). Astuce : un petit record Compte(String mot, int n).
Solution
record Compte(String mot, int n) {}
Map<String, Integer> freq = new HashMap<>();
for (String mot : "le chat et le chien et le rat et la souris".split(" ")) {
freq.merge(mot, 1, Integer::sum);
}
List<Compte> comptes = new ArrayList<>();
for (var e : freq.entrySet()) {
comptes.add(new Compte(e.getKey(), e.getValue()));
}
comptes.sort(Comparator.comparingInt(Compte::n).reversed()
.thenComparing(Compte::mot));
System.out.println(comptes.subList(0, 3));
// affiche : [Compte[mot=et, n=3], Compte[mot=le, n=3], Compte[mot=chat, n=1]]
3. Ce code doit retirer les nombres pairs. Pourquoi plante-t-il ? Corrige-le de deux façons.
List<Integer> nombres = new ArrayList<>(List.of(1, 2, 3, 4, 5, 6));
for (Integer n : nombres) {
if (n % 2 == 0) nombres.remove(n);
}
Solution
On modifie la liste pendant un for-each : l'itérateur le détecte au tour suivant et lève ConcurrentModificationException. (Ici n est un Integer, donc c'est bien remove(Object) qui est appelé, pas remove(int index).)
4. Sans l'exécuter, que va afficher ce code ?
List<Integer> l = new ArrayList<>(List.of(1, 2, 3));
l.remove(1);
Integer x = 1000, y = 1000;
System.out.println(l + " " + (x == y) + " " + x.equals(y));
Solution
[1, 3] false true : remove(1) retire l'index 1 (la valeur 2), et 1000 est hors du cache des Integer, donc x et y sont deux objets distincts pour ==.
À retenir¶
- Les génériques sont effacés à l'exécution : pas de
new T(), pas deList<int>, pas deinstanceof List<String>. Les déclarations, elles, gardent leurs types (et Spring s'en sert). <T extends X>est l'équivalent des trait bounds ;? extendspour lire,? superpour écrire.- Les wrappers (
Integer...) peuvent êtrenull: un unboxing denulllève une NPE. Compare-les avecequals, jamais==. - Déclare avec l'interface (
List,Map), instancie avec la classe (ArrayList,HashMap). HashMapn'a pas d'ordre ;LinkedHashMapgarde l'insertion (commedict),TreeMaptrie (commeBTreeMap).List.of/Map.of/copyOfsont immuables : l'erreur arrive à l'exécution (UnsupportedOperationException).- Pour supprimer en parcourant :
removeIfouIterator.remove, sinonConcurrentModificationException. merge,computeIfAbsent,getOrDefaultetComparator.comparing(...).thenComparing(...)couvrent 90 % des besoins.