Aller au contenu

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() et List<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'API Map et les Comparator

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
  1. <> (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.

static <T extends Comparable<T>> T max(List<T> liste) {
    T best = liste.get(0);
    for (T x : liste) {
        if (x.compareTo(best) > 0) best = x;
    }
    return best;
}

max(List.of(3, 9, 4));                    // 9
max(List.of("poire", "abricot", "zeste")); // "zeste"
fn max<T: Ord + Copy>(liste: &[T]) -> T {
    let mut best = liste[0];
    for &x in liste {
        if x > best { best = x; }
    }
    best
}

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)) ou Class<T>.
  • List<int> : utilise List<Integer> (voir le boxing juste après).
  • instanceof List<String> : teste instanceof List<?>, puis vérifie les éléments si besoin.
  • new T[] : utilise une List<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 :

Map<String, Integer> stock = new HashMap<>();
int n = stock.get("poires");  // NullPointerException !
// Cannot invoke "java.lang.Integer.intValue()" because the return value
// of "java.util.Map.get(Object)" is null
int ok = stock.getOrDefault("poires", 0);  // 0

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 :

List<Integer> l = new ArrayList<>(List.of(10, 20, 30));
l.remove(1);                    // [10, 30]
l.remove(Integer.valueOf(10));  // [30]

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 :

  • HashMap et HashSet n'ont aucun ordre garanti. Un dict Python garde l'ordre d'insertion : son vrai équivalent est LinkedHashMap.
  • HashMap / HashSet s'appuient sur equals et hashCode des clés (voir Classes et objets) ; les records les fournissent. TreeMap / TreeSet exigent des clés comparables.
  • LinkedList existe mais est presque toujours plus lente qu'ArrayList ou ArrayDeque. 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

List<String> noms = new ArrayList<>();   // oui
ArrayList<String> noms2 = new ArrayList<>(); // évite

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.of refusent les null (NullPointerException) et Map.of refuse les clés en double (IllegalArgumentException: duplicate key: a).
  • List.copyOf fait une vraie copie ; Collections.unmodifiableList n'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}
  1. Si la clé est absente, met 1 ; sinon, combine l'ancienne valeur et 1 avec Integer::sum.
let mut compte = BTreeMap::new();
for mot in "le chat et le chien et le rat".split(' ') {
    *compte.entry(mot).or_insert(0) += 1;
}
from collections import Counter
compte = Counter("le chat et le chien et le rat".split())
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).)

nombres.removeIf(n -> n % 2 == 0);          // [1, 3, 5]

for (Iterator<Integer> it = nombres.iterator(); it.hasNext(); ) {
    if (it.next() % 2 == 0) it.remove();    // [1, 3, 5]
}

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 de List<int>, pas de instanceof List<String>. Les déclarations, elles, gardent leurs types (et Spring s'en sert).
  • <T extends X> est l'équivalent des trait bounds ; ? extends pour lire, ? super pour écrire.
  • Les wrappers (Integer...) peuvent être null : un unboxing de null lève une NPE. Compare-les avec equals, jamais ==.
  • Déclare avec l'interface (List, Map), instancie avec la classe (ArrayList, HashMap).
  • HashMap n'a pas d'ordre ; LinkedHashMap garde l'insertion (comme dict), TreeMap trie (comme BTreeMap).
  • List.of / Map.of / copyOf sont immuables : l'erreur arrive à l'exécution (UnsupportedOperationException).
  • Pour supprimer en parcourant : removeIf ou Iterator.remove, sinon ConcurrentModificationException.
  • merge, computeIfAbsent, getOrDefault et Comparator.comparing(...).thenComparing(...) couvrent 90 % des besoins.