Cours BUT Info

Ch. 1 — Collections et Stream

Le chapitre d'ouverture de R3.02 : le Collections Framework du JDK (Set, Queue, List, Map), l'égalité d'objets avec equals et hashCode, les itérateurs, les fonctions lambda et les Stream.

R3.02 Développement efficace commence par un complément de Java : avant de construire piles, files, listes et tables de hachage à la main, il faut connaître les conteneurs déjà fournis par le JDK — et savoir choisir parmi eux. C'est l'objet de ce chapitre 1, Collection et Stream.

Le plan de la ressource compte cinq séances (un cours de 1 h, un TD d'1 h 30, un TP de 2 h) :

  1. Complément de Java — Collection et Stream : ce chapitre ;
  2. Pile et File — le chapitre 2, mis en œuvre dans les TD 2 et TP 2 ;
  3. Liste ;
  4. Table de hachage ;
  5. Arbres.

L'évaluation de la ressource est un DS noté.

1. Types abstraits et structures de données

Les programmes manipulent des données, et la façon de les organiser décide de tout : comment y accéder, les mettre à jour, les parcourir. Deux notions à distinguer :

  • un type abstrait est la spécification des opérations qu'on peut effectuer sur les données — par exemple « une pile : empiler, dépiler, tester si elle est vide » ;
  • une structure de données est une mise en œuvre concrète de ce type abstrait — par exemple « une pile implémentée avec un tableau ».

Vous en connaissez déjà beaucoup : tableaux, listes, piles, files, dictionnaires. Le bon choix dépend des besoins de l'application (quelles opérations sont fréquentes ? y a-t-il des doublons ? faut-il trier ?) : il est indispensable de comprendre les spécificités des structures du langage utilisé pour choisir la plus adaptée — et donc d'abord de savoir lesquelles existent.

2. Le Collections Framework

En Java, le Collections Framework propose des interfaces qui spécifient les opérations possibles, et des classes prêtes à l'emploi qui les implémentent. Le parti pris du cours :

En général, il vaut mieux savoir choisir parmi les structures de données existantes que de les implémenter soi-même.

Le Collections Framework de java.util : les interfaces (pointillés) et une classe concrète pour chacune. Tout en haut, Iterable — c'est elle qui offre le parcours avec for (E e : c). Map n'est pas une Collection : c'est un conteneur de couples clé → valeur.

Quatre interfaces principales, à connaître par cœur :

InterfaceContratDétail
Set<E>ensembledoublons non autorisés, pas d'ordre, pas d'indice
List<E>listecollection ordonnée, doublons autorisés, accès par indice
Queue<E>fileéléments ordonnés, en général FIFO (First In, First Out)
Map<K,V>dictionnairecouples clé/valeur, les clés ne peuvent pas être dupliquées

Le réflexe de déclaration

On déclare avec l'interface, on instancie avec la classe : Set<Etudiant> promo = new HashSet<>();. Si on change d'implémentation (par exemple TreeSet pour trier), seule la ligne d'instanciation bouge.

3. L'interface Collection

L'interface Collection<E> unifie les méthodes principales « de stockage d'éléments de type E », avec entre autres : boolean add(E e), boolean contains(Object o), boolean remove(Object o) et int size().

Une question cruciale se cache dans contains : sur quoi se base la comparaison ? Le cours montre le test exact effectué :

Le test effectué par contains
if (o == null) {
    return e == null;
} else {
    return o.equals(e);
}

C'est equals qui décide des correspondances — d'où tout ce qui suit.

4. Égalité d'objets : equals et hashCode

Deux objets distincts aux mêmes valeurs doivent-ils être considérés égaux ? Oui, si l'on redéfinit equals.

  • == compare l'égalité des références ;
  • equals() compare l'égalité tout court ;
  • mais par défaut, equals() se base sur les références pour les types non natifs de Java : il faut donc la redéfinir, sinon deux instances aux mêmes propriétés sont considérées comme différentes.

Règle du cours : dès qu'on utilise une Collection<E> où E n'est pas un type natif, il faut redéfinir equals — et donc hashCode.

Du côté du hachage : en Java chaque objet porte une valeur de hachage (hashCode()), qui par défaut référence l'emplacement mémoire. Le contrat à respecter :

Si a.equals(b) alors on doit avoir a.hashCode() == b.hashCode().

Il est donc important de redéfinir conjointement les deux méthodes.

equals dit si deux objets ont la même valeur ; hashCode doit rester d'accord, sinon les collections (Set, Map) cherchent au mauvais endroit. C'est exactement le bug du TP 1 : le doublon est accepté.

La conséquence pratique

Un HashSet (ou une HashMap) utilise le hashCode pour trouver l'emplacement de l'objet, puis equals pour comparer. Si l'une des deux méthodes manque, add, remove et contains rendent des résultats aberrants : le TP 1 le montre en direct, avec un add qui accepte un doublon.

5. Parcourir une collection : Iterable et Iterator

Collection<E> étend Iterable<E>, qui contient une seule méthode : Iterator<E> iterator(). Conséquence : tout objet Iterable fournit un itérateur pour parcourir ses éléments, et le foreach l'utilise implicitement.

Un itérateur est un curseur sur les éléments de la collection :

  • boolean hasNext() : vrai s'il reste des éléments ;
  • E next() : renvoie l'élément suivant et décale le curseur d'un cran vers la droite.

À l'état initial, l'itérateur est positionné avant le premier élément : un premier appel à next() est nécessaire pour atteindre le premier objet.

L'itérateur est un curseur posé entre deux éléments : next() le fait avancer et renvoie l'élément franchi. Impossible de revenir en arrière ni d'accéder directement au troisième — il faut appeler next() trois fois.

Exemple du cours avec un Set<Etudiant> :

Iterator.java
Iterator<Etudiant> it = promo.iterator();
while (it.hasNext()) {
    System.out.println(it.next());
}

la version foreach, strictement équivalente :

Foreach.java
for (Etudiant etu : promo) {
    System.out.println(etu);
}

On ne modifie pas une collection en la parcourant

Un itérateur se base sur l'état de la collection au moment de sa création. Ajouter ou supprimer un élément pendant le parcours est interdit — c'est aussi vrai avec un foreach, qui utilise implicitement un itérateur.

6. L'interface Set

Set<E> étend Collection<E> et correspond au type abstrait ensemble au sens mathématique :

  • pas de doublon (la méthode add peut échouer — elle renvoie alors false) ;
  • pas d'ordre, donc pas d'accès par indice.

Comme il n'y a pas d'indice, impossible de parcourir un Set avec une boucle get(i) comme pour une liste : il faut un itérateur ou un foreach (ou une version déléguée comme toString(), forEach() ou un Stream — qui restent des parcours).

Deux implémentations principales :

HashSetTreeSet
Ordreaucuntrié selon les données (il faut des éléments Comparable)
Insertion / recherchetrès efficacesmoins efficaces
Usage typiquevérifier une appartenancegarder un ensemble trié

Dans un TreeSet, c'est compareTo qui fait foi — on y reviendra au cours 5, sur les arbres.

7. L'interface Map

Map<K,V> correspond au type abstrait dictionnaire (ou table de correspondance) : une collection de couples (clé, valeur).

  • les clés sont uniques (pas de doublon), de type K ;
  • les valeurs de type V ne sont pas nécessairement uniques ;
  • une clé permet de retrouver rapidement la valeur associée.

Exemples du cours : annuaire, dictionnaire, gestion d'articles.

Les méthodes principales :

MéthodeRôle
boolean isEmpty()vrai si la table est vide
int size()nombre de clés
V get(Object key)valeur associée à la clé, null si elle est absente
boolean containsKey(Object key)vrai si la clé est dans la table
boolean containsValue(Object value)vrai si la valeur y est
V put(K key, V value)ajoute le couple ; renvoie null si la clé était absente, l'ancienne valeur sinon
V putIfAbsent(K key, V value)ajoute seulement si la clé est absente
V remove(Object key)retire le couple si la clé est présente

Parcours : Map n'étend pas Collection et n'est pas itérable (pas de foreach direct sur une Map). Trois vues permettent de la parcourir :

  • Set<K> keySet() — les clés ;
  • Collection<V> values() — les valeurs ;
  • Set<Map.Entry<K,V>> entrySet() — les couples, via getKey() et getValue().

Exemple du cours — un dictionnaire de moyennes :

ParcoursMap.java
Map<Etudiant, Double> resultats = new HashMap<>();

for (Etudiant etu : resultats.keySet()) {                          // les clés
    System.out.println(etu);
}
for (Double note : resultats.values()) {                           // les valeurs
    System.out.println(note);
}
for (Map.Entry<Etudiant, Double> couple : resultats.entrySet()) {  // les couples
    System.out.println(couple.getKey() + " " + couple.getValue());
}

Deux implémentations principales, mêmes rôles que pour Set :

HashMapTreeMap
Ordreaucuntrié selon les clés (donc Comparable)
Insertion / recherchetrès efficacesmoins efficaces

8. Collection et Collections — le piège des noms

  • Collection (sans « s ») est l'interface ;
  • Collections (avec « s ») est une classe boîte à outils de méthodes static — ne pas les confondre.

Quelques outils très utiles :

MéthodeRôle
max(Collection) / min(Collection)plus grand / plus petit élément
shuffle(List)mélange une liste
sort(List)trie selon l'ordre naturel (Comparable)
sort(List, Comparator)trie selon un comparateur
Collections.sort(promo, (e1, e2) -> e1.getName().compareTo(e2.getName()));

9. Fonctions lambda

Deux définitions avant de lire la ligne ci-dessus :

  • une interface fonctionnelle est une interface qui ne contient qu'une seule méthode abstraite — Comparator en est une ;
  • une fonction lambda est une implémentation concise de ce type d'interface.

Le compilateur infère le type : il en déduit les paramètres, le type de retour et les exceptions. Syntaxe : des paramètres, l'opérateur ->, et le corps de la fonction :

(paramètres) -> instruction;
(paramètres) -> { bloc d'instructions; }

Règles d'écriture : parenthèses vides s'il n'y a pas de paramètre, facultatives pour un paramètre unique au type inféré ; accolades facultatives si le corps tient en une instruction.

L'exemple du cours, pour trier une liste d'étudiants par nom — d'abord avec une classe externe :

Collections.sort(promo, new CompEtudiant());

puis sans classe, avec une lambda : plus courte, et la façon de comparer est explicite.

Collections.sort(promo, (e1, e2) -> e1.getName().compareTo(e2.getName()));

10. L'interface Stream

L'API Stream (« flux ») permet la programmation fonctionnelle : un Stream représente une séquence et permet d'effectuer des opérations dessus sans structure de boucle.

Créer un Stream :

Stream<String> deuxMots = Stream.of("Hello", "World");
IntStream de0a99 = IntStream.range(0, 100);
LongStream quiContinue = LongStream.iterate(1, n -> n + 1);
Stream<Etudiant> flux = promo.stream();   // depuis une Collection ou un tableau

Presque gratuit

Créer un Stream depuis un intervalle ou une lambda est presque instantané et ne prend quasiment pas de mémoire : les éléments sont produits à la demande.

Les opérations du cours, avec leur interface fonctionnelle :

OpérationRôle
forEach(Consumer)parcourt le flux (une action par élément, retour void)
filter(Predicate)garde les éléments qui satisfont la condition (retour boolean)
map(Function)transforme chaque élément
sorted() / sorted(Comparator)trie (ordre naturel ou comparateur)
max, minréduction, avec un comparateur
sum, average…calculs directs sur un flux numérique
collect(Collectors.toList() / toSet() / groupingBy(...))construit une Collection ou une Map
Stream.java
promo.stream().forEach(etu -> System.out.println(etu));
promo.stream().filter(etu -> etu.getName().equals("Dupont"));
promo.stream().map(etu -> etu.getName().toUpperCase());
promo.stream().sorted((e1, e2) -> e1.getName().compareTo(e2.getName()));
promo.stream().max((e1, e2) -> e1.getDate().compareTo(e2.getDate()));
IntStream.of(1, 5, 6, 7).sum();

List<Etudiant> liste = promo.stream().collect(Collectors.toList());
Set<Etudiant> ensemble = promo.stream().collect(Collectors.toSet());
Map<LocalDate, List<Etudiant>> anniversaires =
        promo.stream().collect(Collectors.groupingBy(etu -> etu.getDate()));

Un Stream est à usage unique

Chaque exemple ci-dessus repart de promo.stream() : un Stream se parcourt une seule fois. Les opérations intermédiaires (filter, map, sorted) renvoient un nouveau Stream sans calculer, et c'est l'opération terminale (collect, forEach, sum…) qui déclenche tout.

Un pipeline Stream : la source à gauche, une suite d'opérations intermédiaires (paresseuses), et une opération terminale à droite qui déclenche tout le calcul. Chaque étape est une méthode appelée sur le résultat de la précédente.

11. Synthèse : quelle structure choisir ?

Les trois questions du cours :

  • ai-je des doublons ?
  • dois-je privilégier le parcours ou l'insertion / suppression ?
  • peut-on ordonner mes données ? doivent-elles l'être ?

Et la réponse en tableau :

BesoinStructure
pas de doublonsSet — HashSet, TreeSet si tri
accès par indice, ordre d'insertionList — ArrayList, LinkedList
premier arrivé, premier servi (FIFO)Queue — ArrayDeque, PriorityQueue
associer une clé à une valeurMap — HashMap, TreeMap si tri des clés
des éléments triés en permanenceTreeSet / TreeMap

La suite

Ce chapitre est le socle : le chapitre 2 l'applique aux piles et files, le TD 1 manie Set et Map sur des exercices guidés, et le TP 1 met tout en œuvre sur de vrais objets (des iris) et un texte complet (Notre-Dame de Paris).


cours1-collections.pdf

R3.02 · Cours 1 — Complément de Java : Collection et Stream — P. Everaere, F. Guyomarch · 47 diapositives

Consultation seule — téléchargement désactivé

Chargement du document…

Dernière mise à jour le

Sur cette page