C5 — Mise en pratiqueDans le dialogue d’impression, choisissez « Enregistrer au format PDF » comme destination.
Retour

Licence 2 · Programmation orientée objet

Cours 5Mise en pratique

Faire vivre plusieurs objets ensemble avec les collections de la bibliothèque standard, puis conduire un projet de bout en bout.

2 chapitres · 8 h de travail estimé

  1. 1. Bibliothèques et collections5 h
  2. 2. Projet3 h

Chapitre 1 · 5 h

Bibliothèques et collections

Chaînes de caractères, tableaux d'objets, List, ArrayList et Map, parcours et tri d'objets, généricité en première approche, entrées/sorties fichier.

Neuf chapitres ont appris à écrire une classe. Un programme réel en fait vivre des milliers d'instances ensemble : une bibliothèque contient des livres, un lecteur détient des emprunts, un catalogue associe un ISBN à un ouvrage.

Le tableau d'Algorithmique 1 ne suffit plus. Sa taille est fixée à la création, il n'offre aucune opération — ni insertion, ni suppression, ni recherche — et il ne sait rien associer. Ce chapitre donne les outils de la bibliothèque standard qui remplacent tout cela, et l'on retrouvera derrière chacun une structure d'Algorithmique 2.

Les chaînes, et un piège de complexité

String est la classe la plus employée de Java, et elle a une propriété qui explique tout son comportement : elle est immuable. Une chaîne ne se modifie jamais ; toute opération en fabrique une nouvelle.

String s = "bonjour";s.toUpperCase();          /* ne change PAS s */s = s.toUpperCase();      /* il faut réaffecter */

L'immuabilité a des vertus — une chaîne peut être partagée sans risque, mise en cache, employée comme clé — et un coût, qui se paie dans une boucle :

String resultat = "";for (String mot : mots) {    resultat = resultat + mot;      /* QUADRATIQUE */}

Chaque concaténation recopie tout ce qui précède dans une nouvelle chaîne. Sur nn mots, on recopie 1+2++n1 + 2 + \dots + n caractères : c'est le O(n2)O(n^2) du chapitre 4 d'Algorithmique 2. La solution est StringBuilder, qui accumule dans un tampon extensible et ne construit la chaîne qu'à la fin — le tableau dynamique du chapitre 8 du cours de programmation, exactement.

Rappel du chapitre 6, qui coûte cher chaque année : on compare deux chaînes avec equals, jamais avec ==.

Les trois familles

La bibliothèque standard s'organise autour de trois interfaces, et le choix entre elles est une décision de modélisation.

InterfaceContratDoublonsOrdre
Listséquence indexéeouicelui d'insertion
Setensemblenonselon l'implémentation
Mapassociation clé → valeurclés uniquesselon l'implémentation

Le point de méthode le plus important du chapitre tient en une ligne :

List<Livre> catalogue = new ArrayList<>();

On déclare l'interface, on instancie l'implémentation. Le reste du programme ne dépend alors que du contrat, et remplacer ArrayList par LinkedList ne touche qu'une ligne. C'est le polymorphisme du chapitre 6 appliqué à la conception, et c'est la pratique standard.

Derrière chaque nom, une structure connue

C'est ici que le cours d'Algorithmique 2 se rembourse.

ArrayList est un tableau dynamique : accès indexé en O(1)O(1), insertion en fin amortie en O(1)O(1) — par doublement de capacité, comme au chapitre 8 du cours de programmation —, mais insertion ou suppression au milieu en O(n)O(n), à cause du décalage.

LinkedList est une liste doublement chaînée : insertion et suppression en O(1)O(1) si l'on tient déjà la position, accès indexé en O(n)O(n).

En pratique, ArrayList est le choix par défaut, et l'argument est celui du chapitre 5 d'Algorithmique 2 : ses éléments sont contigus, donc le cache travaille pour elle, alors que les cellules d'une LinkedList sont dispersées. Sur les tailles courantes, ArrayList gagne même là où la complexité annonce le contraire.

HashMap repose sur une table de hachage : get et put en O(1)O(1) en moyenne, à condition que les clés respectent le contrat equals/hashCode du chapitre 6. Une clé dont le hashCode est mal écrit rend l'objet introuvable — sans aucune erreur.

TreeMap repose sur un arbre binaire de recherche équilibré, celui du chapitre 8 d'Algorithmique 2 : opérations en O(logn)O(\log n), mais les clés sont triées, ce qu'une HashMap n'offre pas.

Même partage du côté des ensembles : HashSet est rapide et désordonné, TreeSet est trié.

Généricité

List<Livre> se lit « liste de Livre », et les chevrons ne sont pas décoratifs.

List<Livre> catalogue = new ArrayList<>();catalogue.add(new Livre("Dune"));catalogue.add("une chaîne");        /* REFUSÉ à la compilation */Livre premier = catalogue.get(0);   /* pas de transtypage nécessaire */

Deux bénéfices, et ils sont du même ordre que ceux du typage en général. Le compilateur refuse ce qui n'a pas le bon type, donc l'erreur est détectée à l'écriture et non trois semaines plus tard. Et la lecture ne demande aucun transtypage, donc aucune ClassCastException possible — le chapitre 6 rappelait qu'un transtypage descendant n'est qu'une promesse.

Une limite à connaître : la généricité de Java est réalisée par effacement de type. À l'exécution, une List<Livre> est une List ordinaire — l'information de type n'existe qu'à la compilation. D'où quelques interdits déroutants, comme l'impossibilité de créer un tableau de type générique.

Parcourir et trier

for (Livre l : catalogue) { ... }               /* la forme à employer */

La boucle « pour chaque » fonctionne sur tout ce qui est parcourable, sans indice à gérer donc sans débordement possible. On ne revient à l'indice que si l'on en a réellement besoin.

Pour trier des objets, il faut dire ce que « plus petit » signifie, et Java offre deux voies qui expriment deux choses différentes.

Comparable définit l'ordre naturel de la classe, celui qui va de soi. On implémente l'interface et sa méthode compareTo, qui rend un négatif, zéro ou un positif :

public class Livre implements Comparable<Livre> {    @Override    public int compareTo(Livre autre) {        return this.titre.compareTo(autre.titre);    }}Collections.sort(catalogue);

Comparator définit un ordre parmi d'autres, fourni de l'extérieur : trier par auteur aujourd'hui, par date demain. On le passe à sort, et l'on peut en avoir autant qu'on veut.

Le critère est simple : Comparable pour l'ordre évident et unique, Comparator pour tous les autres. Et une règle de cohérence, souvent violée : l'ordre naturel devrait être compatible avec equals — deux objets égaux devraient se comparer à zéro, faute de quoi les collections triées se comportent bizarrement.

Fichiers

Les entrées/sorties suivent le même principe qu'au chapitre 9 du cours de programmation, avec les exceptions du chapitre 7 en plus.

try (BufferedReader r = Files.newBufferedReader(Path.of("livres.txt"))) {    String ligne;    while ((ligne = r.readLine()) != null) {        String[] champs = ligne.split(";");        catalogue.add(new Livre(champs[0], champs[1]));    }} catch (IOException e) {    System.err.println("lecture impossible : " + e.getMessage());}

Trois choses à noter. Le try avec ressources ferme le fichier automatiquement, y compris en cas d'exception. La condition teste le retour de la lecturereadLine rend null en fin de fichier — et non un hypothétique « suis-je à la fin ? » : c'est exactement le piège de feof du cours de programmation. Et IOException est contrôlée, donc le compilateur exige qu'on la traite : c'est le cas d'école de l'échec prévisible et extérieur.

Quiz · 1 question

Pourquoi écrit-on List<Livre> catalogue = new ArrayList<>() plutôt que ArrayList<Livre> catalogue = new ArrayList<>() ?

  • Par pure convention : les deux écritures sont strictement équivalenteséquivalentes
  • Pour que le reste du programme ne dépende que du CONTRAT et non de l'implémentation : remplacer ArrayList par LinkedList ne touche alors qu'une seule ligne. C'est le polymorphisme appliqué à la conceptiondépendre du contrat
  • Parce que ArrayList ne peut pas être déclarée à gauche d'une affectationcontrainte de syntaxe

Réponse : Déclarer le type le plus général possible est la pratique standard, et c'est une application directe du chapitre 6 : une variable déclarée List n'autorise que les méthodes du contrat List, donc aucun code appelant ne peut dépendre d'une particularité d'ArrayList. Le jour où l'on mesure que les insertions en tête dominent, on remplace new ArrayList par new LinkedList sur UNE ligne, et rien d'autre ne bouge. L'inverse — déclarer ArrayList partout — laisse le code appelant utiliser ensureCapacity ou trimToSize, et le remplacement devient une refonte. Le même principe vaut pour les paramètres de méthode : accepter une List plutôt qu'une ArrayList élargit ce qu'on peut vous passer sans rien vous coûter.

Quiz · 1 question

Une boucle construit une chaîne par resultat = resultat + mot sur 10 000 mots, et le programme rame. Pourquoi ?

  • Parce que la concaténation est une opération réseau bufferiséeopération réseau
  • Parce que String est IMMUABLE : chaque concaténation recopie tout ce qui précède dans une nouvelle chaîne, soit 1 + 2 + … + n caractères recopiés — un O(n²). Il faut un StringBuilderimmuabilité et recopie
  • Parce que le ramasse-miettes ne peut pas récupérer les chaînes intermédiairesramasse-miettes

Réponse : Une String ne se modifie jamais : toute opération en fabrique une nouvelle. Concaténer, c'est donc allouer une chaîne de taille croissante et y recopier l'intégralité de ce qui précède, à chaque tour. La somme des recopies vaut 1 + 2 + … + n, soit n(n+1)/2 caractères : c'est le O(n²) du chapitre 4 d'Algorithmique 2, et sur 10 000 mots la différence avec la version linéaire est de plusieurs ordres de grandeur. StringBuilder accumule dans un tampon extensible — le tableau dynamique du cours de programmation, avec son doublement de capacité et son coût amorti — et ne construit la chaîne finale qu'une fois. Le ramasse-miettes récupère bien les intermédiaires, mais il doit d'abord les allouer, ce qui est justement le coût. Note : le compilateur Java optimise une concaténation ISOLÉE en StringBuilder, jamais une concaténation en boucle.

À vous

L'exercice mesure ce que le chapitre affirme, plutôt que de le répéter.

D'abord la concaténation : la version naïve et celle à tampon, avec le nombre de caractères recopiés dans chacune. Le rapport se voit dès quelques centaines de mots, et la courbe est sans appel.

Ensuite les trois familles sur le même jeu de données : la même bibliothèque rangée en List, en Set et en Map, et ce que chacune répond aux mêmes questions — combien d'éléments après insertion de doublons, l'ordre est-il conservé, combien de comparaisons pour retrouver un ISBN.

Puis le tri : ordre naturel par titre, puis deux comparateurs — par auteur, par date décroissante — sur la même liste.

Enfin le piège du chapitre 6 qui se paie ici : une clé dont le hashCode est incohérent, et un livre rangé dans une Map qui devient introuvable.

Exercice de code

Mesurez le coût de la concaténation, opposez List, Set et Map, triez de trois façons, puis cassez une HashMap.

Point de départ

// ── 1. Concaténation : ce que l'immuabilité coûte ─────────────────────────
function concatenationNaive(mots) {
  let resultat = "", recopies = 0;
  for (const mot of mots) {
    recopies += resultat.length;      // tout ce qui précède est recopié
    resultat = resultat + mot;
  }
  return { longueur: resultat.length, recopies };
}

function avecTampon(mots) {
  const tampon = [];                  // le StringBuilder : on accumule
  let recopies = 0;
  for (const mot of mots) tampon.push(mot);
  const resultat = tampon.join("");   // une seule construction, à la fin
  recopies += resultat.length;
  return { longueur: resultat.length, recopies };
}

// ── 2. Les trois familles sur le même jeu ─────────────────────────────────
class Livre {
  constructor(isbn, titre, auteur, annee) {
    this.isbn = isbn; this.titre = titre; this.auteur = auteur; this.annee = annee;
  }
  equals(a) { return a instanceof Livre && a.isbn === this.isbn; }
  hashCode() { return this.isbn; }
  toString() { return this.titre + " (" + this.auteur + ", " + this.annee + ")"; }
}

const CATALOGUE = [
  new Livre(3, "Dune", "Herbert", 1965),
  new Livre(1, "Ubik", "Dick", 1969),
  new Livre(2, "Solaris", "Lem", 1961),
  new Livre(3, "Dune", "Herbert", 1965),      // doublon d'ISBN
];

// ── 3. Tri ────────────────────────────────────────────────────────────────
// Ordre NATUREL : celui qui va de soi pour la classe.
function compareTo(a, b) { return a.titre.localeCompare(b.titre); }
// Ordres PARMI D'AUTRES, fournis de l'extérieur.
const PAR_AUTEUR = (a, b) => a.auteur.localeCompare(b.auteur);
const PAR_ANNEE_DESC = (a, b) => 0;          // ← à écrire

// ── 4. Une Map dont la clé ment ───────────────────────────────────────────
function creerMap(hachage) {
  const seaux = new Map();
  return {
    put(cle, valeur) {
      const h = hachage(cle);
      if (!seaux.has(h)) seaux.set(h, []);
      seaux.get(h).push({ cle, valeur });
    },
    get(cle) {
      let comparaisons = 0;
      const seau = seaux.get(hachage(cle)) ?? [];
      for (const e of seau) { comparaisons++; if (e.cle.equals(cle)) return { valeur: e.valeur, comparaisons }; }
      return { valeur: undefined, comparaisons };
    },
  };
}

// ── À VOUS ────────────────────────────────────────────────────────────────
// 1. Comparez les recopies des deux concaténations sur 100 puis 1000 mots.
// 2. Rangez CATALOGUE en liste, en ensemble (par ISBN) et en map ISBN → livre.
//    Combien d'éléments dans chacun, et pourquoi ?
// 3. Écrivez PAR_ANNEE_DESC, et triez selon les trois ordres.
// 4. Construisez une map avec un hachage INCOHÉRENT et cherchez un livre.

const mots = Array.from({ length: 200 }, (_, i) => "mot" + i);
console.log("   naïve  :", JSON.stringify(concatenationNaive(mots)));
console.log("   tampon :", JSON.stringify(avecTampon(mots)));

Solution

function concatenationNaive(mots) {
  let resultat = "", recopies = 0;
  for (const mot of mots) { recopies += resultat.length; resultat = resultat + mot; }
  return { longueur: resultat.length, recopies };
}
function avecTampon(mots) {
  const tampon = [];
  for (const mot of mots) tampon.push(mot);
  const resultat = tampon.join("");
  return { longueur: resultat.length, recopies: resultat.length };
}

console.log("— 1. ce que l'immuabilité coûte —");
for (const n of [100, 400, 1600]) {
  const mots = Array.from({ length: n }, (_, i) => "mot" + i);
  const a = concatenationNaive(mots), b = avecTampon(mots);
  console.log("   " + String(n).padStart(4) + " mots : naïve " +
    String(a.recopies).padStart(8) + " caractères recopiés | tampon " +
    String(b.recopies).padStart(6) + "   rapport " + Math.round(a.recopies / b.recopies));
}
console.log("   Le rapport CROÎT avec n : c'est un O(n²) contre un O(n). Le");
console.log("   compilateur Java optimise une concaténation isolée, jamais une");
console.log("   concaténation en boucle.");

class Livre {
  constructor(isbn, titre, auteur, annee) {
    this.isbn = isbn; this.titre = titre; this.auteur = auteur; this.annee = annee;
  }
  equals(a) { return a instanceof Livre && a.isbn === this.isbn; }
  hashCode() { return this.isbn; }
  toString() { return this.titre + " (" + this.auteur + ", " + this.annee + ")"; }
}

const CATALOGUE = [
  new Livre(3, "Dune", "Herbert", 1965),
  new Livre(1, "Ubik", "Dick", 1969),
  new Livre(2, "Solaris", "Lem", 1961),
  new Livre(3, "Dune", "Herbert", 1965),
];

console.log("");
console.log("— 2. les trois familles, mêmes données —");
const liste = [...CATALOGUE];
const ensemble = [];
for (const l of CATALOGUE) if (!ensemble.some((x) => x.equals(l))) ensemble.push(l);
const map = new Map(CATALOGUE.map((l) => [l.isbn, l]));
console.log("   List : " + liste.length + " éléments — doublons conservés, ordre d'insertion");
console.log("   Set  : " + ensemble.length + " éléments — le doublon d'ISBN a été rejeté par equals");
console.log("   Map  : " + map.size + " entrées   — clé unique, la seconde écrase la première");
console.log("   Choisir entre les trois est une décision de MODÉLISATION : accepte-t-on");
console.log("   deux exemplaires du même ISBN ?");

console.log("");
console.log("— 3. tri —");
const compareTo = (a, b) => a.titre.localeCompare(b.titre);
const PAR_AUTEUR = (a, b) => a.auteur.localeCompare(b.auteur);
// Décroissant : on inverse simplement les opérandes.
const PAR_ANNEE_DESC = (a, b) => b.annee - a.annee;

for (const [nom, ordre] of [["naturel (titre)", compareTo], ["par auteur", PAR_AUTEUR],
                            ["par année décroissante", PAR_ANNEE_DESC]]) {
  const tri = [...ensemble].sort(ordre);
  console.log("   " + nom.padEnd(24) + tri.map((l) => l.titre).join(", "));
}
console.log("   Un seul ordre naturel (Comparable), autant de comparateurs qu'on veut.");

console.log("");
console.log("— 4. la clé qui ment —");
function creerMap(hachage) {
  const seaux = new Map();
  return {
    put(cle, valeur) {
      const h = hachage(cle);
      if (!seaux.has(h)) seaux.set(h, []);
      seaux.get(h).push({ cle, valeur });
    },
    get(cle) {
      let comparaisons = 0;
      const seau = seaux.get(hachage(cle)) ?? [];
      for (const e of seau) {
        comparaisons++;
        if (e.cle.equals(cle)) return { valeur: e.valeur, comparaisons };
      }
      return { valeur: undefined, comparaisons };
    },
  };
}

const dune = new Livre(3, "Dune", "Herbert", 1965);
const memeLivre = new Livre(3, "Dune", "Herbert", 1965);   // égal, autre objet

const bonne = creerMap((l) => l.hashCode());
for (const l of ensemble) bonne.put(l, "rayon A");
const r1 = bonne.get(memeLivre);
console.log("   contrat respecté : trouvé = " + (r1.valeur !== undefined) +
            " en " + r1.comparaisons + " comparaison(s)");

let compteur = 0;
const cassee = creerMap(() => compteur++);   // hachage incohérent
for (const l of ensemble) cassee.put(l, "rayon A");
const r2 = cassee.get(memeLivre);
console.log("   contrat violé    : trouvé = " + (r2.valeur !== undefined) +
            " en " + r2.comparaisons + " comparaison(s)");
console.log("   Le livre EST dans la map. La recherche va simplement dans le mauvais");
console.log("   seau, et rend undefined sans lever la moindre erreur. C'est le");
console.log("   contrat equals/hashCode du chapitre 6 qui se paie ici.");

En travaux pratiques

Travaux pratiques 9 · 3 h

Choisir une collection, et le prouver

Mesurer les collections plutôt que les choisir par habitude, et retrouver dans une bibliothèque toute faite les structures écrites à la main en Algorithmique 2.

Avant de commencer

  • Le TP 6 : equals et hashCode
  • De quoi chronométrer

Énoncé

  1. MesurerComparez ArrayList et LinkedList sur : ajout en fin, ajout en tête, accès par indice, parcours complet. Cent mille éléments, quatre mesures chacune.
  2. La rechercheCherchez cent mille fois un élément dans une List, puis dans un HashSet, puis dans un TreeSet. Comparez et reliez chaque résultat à une structure du cours d'Algorithmique 2.
  3. Le piège du hachageUtilisez comme clé de HashMap un objet dont vous modifiez un champ APRÈS insertion. Essayez ensuite de le retrouver. Indice : La clé a changé de seau sans que la table le sache.
  4. Ordonné ou pasInsérez les mêmes éléments dans HashSet, LinkedHashSet et TreeSet, et affichez les trois. Expliquez les trois ordres obtenus.
  5. Comparable et ComparatorRendez Document triable par année, puis triez la même liste par titre sans toucher à la classe. Dites quand chacune des deux voies s'impose.
  6. La modification pendant le parcoursSupprimez un élément d'une liste pendant que vous la parcourez avec une boucle pour-chaque. Lisez l'exception, puis corrigez de deux façons.
  7. Les fluxRéécrivez avec des flux : filtrer les documents empruntables, les grouper par genre, compter par genre. Comparez à la version en boucles.
  8. Au fil rougeRemplacez le tableau de Mediatheque par la collection que vos mesures désignent, et justifiez le choix en trois lignes dans un commentaire.

C'est réussi quand

  • Vos mesures contredisent au moins une idée reçue sur LinkedList
  • Vous perdez une clé dans une HashMap, et vous savez expliquer où elle est
  • Vous triez la même liste de deux façons sans modifier Document
  • Votre choix final de collection est justifié par un chiffre, pas par une habitude

Correction

Les mesures
100 000 éléments      ArrayList   LinkedList
ajout en fin           0,003 s     0,004 s
ajout en tête          1,8 s       0,004 s     ← le seul avantage
get(i) aléatoire       0,001 s     12,4 s      ← catastrophique
parcours complet       0,0004 s    0,003 s     ← ×8, même complexité

LinkedList ne gagne que sur l'insertion en tête, et perd partout ailleurs — y compris sur le parcours, à complexité pourtant identique, pour la raison mesurée au TP 5 d'Algorithmique 2 : les maillons sont dispersés, chaque accès coûte un défaut de cache. En pratique, ArrayList est le bon choix par défaut, et LinkedList quasiment jamais.

La recherche, et les structures reconnues
100 000 recherches    temps      structure sous-jacente
List.contains()       9,8 s      parcours linéaire, O(n)
HashSet.contains()    0,004 s    table de hachage, O(1) en moyenne
TreeSet.contains()    0,021 s    arbre ROUGE-NOIR équilibré, O(log n)

TreeSet donne EN PLUS l'ordre trié, et des requêtes par intervalle

Ce sont les structures d'Algorithmique 2, déjà écrites et éprouvées. TreeSet est l'arbre de recherche du TP 8 — mais AUTO-ÉQUILIBRÉ, donc sans le cas dégénéré que vous aviez provoqué sur entrée triée. On paie ce log n par rapport au hachage, et l'on obtient l'ordre : c'est le compromis à connaître pour choisir.

La clé perdue
Document d = new Document("Dune", 1965);
Map<Document, Integer> stock = new HashMap<>();
stock.put(d, 3);

d.setAnnee(2021);              /* la clé est MUTÉE après insertion */

stock.get(d)          →  null      /* introuvable par elle-même ! */
stock.containsKey(d)  →  false
stock.size()          →  1         /* elle est pourtant bien là */

L'entrée a été rangée dans le seau du hachage d'origine ; la recherche va maintenant chercher dans le seau du nouveau hachage. L'objet est présent et inaccessible — et il le restera. D'où la règle : une clé de table de hachage doit être IMMUABLE. C'est pourquoi String, Integer et les enregistrements font de si bonnes clés.

Les trois ordres
insertion : Dune, 1984, Ubik, Solaris

HashSet       → [1984, Solaris, Dune, Ubik]  ordre des SEAUX, imprévisible
LinkedHashSet → [Dune, 1984, Ubik, Solaris]  ordre d'INSERTION
TreeSet       → [1984, Dune, Solaris, Ubik]  ordre NATUREL (trié)

L'ordre d'un HashSet n'est pas aléatoire : il est déterministe et dépend des hachages, donc imprévisible pour le lecteur et susceptible de changer d'une version de Java à l'autre. Écrire du code qui suppose un ordre de HashSet est un bogue qui passe tous les tests jusqu'au jour où il ne les passe plus.

Comparable et Comparator
/* Comparable : UN ordre naturel, dans la classe */
class Document implements Comparable<Document> {
  @Override public int compareTo(Document a) {
      return Integer.compare(this.annee, a.annee);
  }
}
Collections.sort(liste);

/* Comparator : autant d'ordres qu'on veut, HORS de la classe */
liste.sort(Comparator.comparing(Document::getTitre));
liste.sort(Comparator.comparing(Document::getAnnee)
                   .thenComparing(Document::getTitre)
                   .reversed());

Comparable pour l'ordre unique et évident — l'ordre d'un entier, d'une date ; Comparator dès qu'il y a plusieurs critères, ou que la classe ne vous appartient pas. Un point de cohérence souvent oublié : compareTo doit renvoyer 0 exactement quand equals renvoie vrai, faute de quoi TreeSet et HashSet ne s'accordent pas sur ce qu'est un doublon.

Modifier pendant le parcours
for (Document d : liste)
  if (!d.estDisponible()) liste.remove(d);   /* ConcurrentModification */

/* correction 1 : l'itérateur explicite */
Iterator<Document> it = liste.iterator();
while (it.hasNext()) if (!it.next().estDisponible()) it.remove();

/* correction 2 : plus lisible */
liste.removeIf(d -> !d.estDisponible());

L'exception n'est pas un caprice : elle protège d'un parcours devenu incohérent, où des éléments seraient sautés silencieusement. Elle est détectée au mieux, pas garantie — d'où l'importance de ne jamais s'en remettre à elle et d'utiliser removeIf, qui exprime l'intention en une ligne.

Ce que la suite en fait

Le chapitre 10 est le projet, et il ne présente plus aucune notion : il demande de choisir. Quel découpage en classes, quelles relations, quelle collection pour chaque multiplicité du diagramme — et c'est là que le tableau de ce chapitre devient un outil de décision plutôt qu'une liste à connaître.

À retenir

Flashcards · 5 cartes

Pourquoi String est-elle immuable, et quel piège en découle ?
Une chaîne ne se modifie jamais : toute opération en fabrique une nouvelle — d'où la nécessité de RÉAFFECTER (s = s.toUpperCase()). Vertus : partage sans risque, mise en cache, emploi comme clé. Piège : concaténer en boucle recopie tout ce qui précède à chaque tour, soit 1 + 2 + … + n caractères, un O(n²). Remède : StringBuilder, qui accumule dans un tampon extensible. Et l'on compare toujours avec equals, jamais avec ==.
Quelles sont les trois familles de collections, et que garantit chacune ?
LIST : séquence indexée, doublons autorisés, ordre d'insertion conservé. SET : ensemble, PAS de doublons, ordre selon l'implémentation. MAP : association clé → valeur, clés uniques. Le choix entre elles est une décision de MODÉLISATION — un Set interdit les doublons, une Map impose une clé unique — et pas seulement une question de performance.
Que se cache-t-il derrière ArrayList, LinkedList, HashMap et TreeMap ?
ARRAYLIST : un tableau dynamique — accès O(1), ajout en fin amorti O(1) par doublement, insertion au milieu O(n). LINKEDLIST : une liste doublement chaînée — insertion O(1) si l'on tient la position, accès indexé O(n). HASHMAP : une table de hachage, O(1) en moyenne, à condition que les clés respectent le contrat equals/hashCode. TREEMAP : un arbre binaire de recherche équilibré, O(log n) mais clés TRIÉES. En pratique ArrayList est le choix par défaut : ses éléments sont contigus, donc le cache travaille pour elle.
Pourquoi déclarer List et instancier ArrayList ?
Pour que le reste du programme ne dépende que du CONTRAT : une variable déclarée List n'autorise que les méthodes de List, donc aucun appelant ne peut s'appuyer sur une particularité d'ArrayList. Remplacer l'implémentation ne touche alors qu'UNE ligne. C'est le polymorphisme appliqué à la conception, et cela vaut aussi pour les paramètres de méthode : accepter une List plutôt qu'une ArrayList élargit ce qu'on peut vous passer sans rien coûter.
Comparable ou Comparator : quel critère, et quelle règle de cohérence ?
COMPARABLE définit l'ordre NATUREL de la classe, celui qui va de soi : on implémente compareTo, et il n'y en a qu'un. COMPARATOR définit un ordre PARMI D'AUTRES, fourni de l'extérieur — par auteur aujourd'hui, par date demain — et l'on peut en cumuler. Règle de cohérence souvent violée : l'ordre naturel devrait être compatible avec equals, deux objets égaux se comparant à zéro, faute de quoi les collections triées se comportent bizarrement.

Chapitre 2 · 3 h

Projet

Analyser un énoncé, en tirer un diagramme de classes, implémenter par incréments, tester, et restituer.

L'énoncé tient en trois lignes :

Réaliser une application de gestion de bibliothèque. Les adhérents empruntent des ouvrages pour une durée limitée. Un ouvrage peut exister en plusieurs exemplaires. Les retards donnent lieu à une pénalité.

Le réflexe est d'ouvrir l'éditeur et d'écrire public class Main. C'est la façon la plus sûre de produire, trois semaines plus tard, une classe de huit cents lignes que personne ne peut modifier.

Ce dernier chapitre n'introduit aucune notion. Il dit ce qu'on fait avant d'écrire la première ligne, et dans quel ordre — c'est le seul moment du semestre où l'on conçoit au lieu d'appliquer.

Analyser l'énoncé

La méthode est vieille, un peu naïve, et elle fonctionne : souligner les noms, entourer les verbes.

Les noms communs sont des candidats-classes : adhérent, ouvrage, exemplaire, emprunt, pénalité, bibliothèque. Les verbes sont des candidats-méthodes : emprunter, rendre, calculer une pénalité, rechercher.

Vient ensuite le filtrage, et c'est là que le travail commence — un nom n'est pas toujours une classe.

« Durée » n'est pas une classe : c'est un attribut d'Emprunt, ou une constante de la bibliothèque. Un nom qui n'a ni comportement ni identité propre est une donnée.

« Pénalité » est douteux. Si c'est un simple montant, c'est une valeur calculée. Si elle a une date, un statut payé ou non, un historique — alors elle a une identité, et c'est une classe. L'énoncé ne tranche pas : c'est une question à poser, et savoir qu'il faut la poser vaut mieux que deviner.

« Ouvrage » et « exemplaire » sont deux classes distinctes, et c'est la vraie difficulté de cet énoncé. « Un ouvrage peut exister en plusieurs exemplaires » dit exactement cela : Dune est un ouvrage — un titre, un auteur, un ISBN — dont la bibliothèque possède trois exemplaires physiques, chacun avec sa cote et son état. On n'emprunte pas un ouvrage, on emprunte un exemplaire.

Les fusionner est l'erreur numéro un sur cet énoncé, et elle se paie immédiatement : on ne sait plus dire combien d'exemplaires sont disponibles.

Le filtre final est celui du chapitre 1 : chaque classe retenue doit se décrire en une phrase sans « et ». Si l'on n'y arrive pas, il y en a deux.

Concevoir le diagramme

On dessine ensuite, avec la notation du chapitre 8 — et l'on vise cinq à huit classes. Au-delà sur un projet de L2, c'est généralement qu'on a modélisé des détails.

Adherent  1 ───── 0..*  Emprunt  0..*  ─────  1  Exemplaire  *  ─────  1  Ouvrage

Trois décisions se lisent dans cette seule ligne, et chacune était une question ouverte.

Emprunt est devenu une classe à part entière, et non une simple association. La raison est qu'il porte des données propres — date de début, date de retour prévue, date de retour effective — et un comportement : calculer son retard. Une association qui porte des attributs est une classe.

La multiplicité 0..* du côté Emprunt dit qu'un adhérent peut n'avoir aucun emprunt en cours, et plusieurs à la fois. Un 0..3 dirait « trois au maximum », ce qui est une règle métier qu'il faut alors faire respecter quelque part.

Et Exemplaire → Ouvrage en * ── 1 traduit exactement la phrase de l'énoncé.

Une fois le diagramme posé, on le vérifie en y faisant marcher un scénario : « Ana emprunte Dune, le rend avec cinq jours de retard, paie sa pénalité ». On suit le chemin sur le dessin. Si une information manque — comment retrouver un exemplaire libre de cet ouvrage ? — le diagramme est incomplet, et il vaut mieux s'en apercevoir maintenant.

Implémenter par incréments

C'est ici que la plupart des projets de L2 se perdent, et l'erreur est toujours la même : écrire toutes les classes, puis tous les attributs, puis toutes les méthodes, et essayer de faire tourner l'ensemble la veille du rendu. Rien ne compile jamais avant la fin, et le premier test a lieu quand il est trop tard.

La méthode qui marche est la tranche verticale : choisir un scénario complet, le faire fonctionner de bout en bout, puis passer au suivant.

Incrément 1   créer un ouvrage, un exemplaire, les afficherIncrément 2   un adhérent emprunte un exemplaire disponibleIncrément 3   le refus si l'exemplaire est déjà emprunté   (exceptions)Incrément 4   le retour, et le calcul du retardIncrément 5   la pénalité, et l'historiqueIncrément 6   la recherche par titre et par auteur          (collections)

Trois bénéfices, et le troisième est le plus important.

Le programme compile et s'exécute en permanence. Une régression se détecte le jour même, pas trois semaines plus tard.

On a toujours quelque chose à montrer. Un projet à moitié fait mais qui tourne vaut mieux qu'un projet complet qui ne compile pas — c'est vrai pour la note comme pour la suite.

La conception se corrige en marchant. L'incrément 3 révélera peut-être qu'il faut une classe Reservation à laquelle on n'avait pas pensé. La découvrir en écrivant coûte une heure ; la découvrir à la fin coûte une refonte.

Tester

Un jeu de tests, même minimal, n'est pas un supplément : c'est ce qui permet de modifier sans casser.

Quatre choses méritent d'être testées dans un programme objet, et elles ne sont pas les mêmes qu'en programmation impérative.

Les invariants du chapitre 3 : un constructeur refuse-t-il bien un ISBN vide, un solde négatif, une date de retour antérieure à la date d'emprunt ?

Les cas limites, comme au chapitre 10 du cours de programmation : emprunter le dernier exemplaire disponible, rendre un jour pile après l'échéance, une liste vide.

Le comportement polymorphe : une méthode redéfinie fait-elle bien ce qu'elle doit dans chaque sous-classe ? C'est le seul endroit où le chapitre 6 peut se vérifier mécaniquement.

Les exceptions : la bonne exception est-elle levée dans le bon cas ? Un test qui vérifie qu'un emprunt impossible échoue vaut autant qu'un test de succès.

Un dernier bénéfice, moins évident : écrire un test, c'est être le premier client de sa propre API. Si le test est pénible à écrire — s'il faut créer sept objets pour en tester un — la conception est trop couplée. Le test révèle le défaut avant l'utilisateur.

Restituer

Le rendu compte, et il tient en peu de chose.

Un fichier de description — ce que fait le programme, comment le compiler et le lancer, ce qui est fait et ce qui ne l'est pas. La dernière partie est celle qu'on omet, et c'est la plus appréciée : un projet honnête sur ses limites est jugé plus favorablement qu'un projet qui les cache.

Le diagramme de classes, tel qu'il est à la fin — pas celui du début. S'ils diffèrent, c'est normal, et l'écart vaut d'être expliqué en deux phrases : c'est la preuve qu'on a conçu en marchant.

Un jeu de données de démonstration, pour que le correcteur n'ait pas à en inventer.

Les cinq pièges classiques

Ils reviennent chaque année, et chacun contredit un chapitre précis.

La classe divine : un Main de six cents lignes qui fait tout, entouré de classes anémiques réduites à des getters. C'est le chapitre 1 et le chapitre 3 ignorés — et la marque distinctive est que les autres classes n'ont aucune méthode intéressante.

L'encapsulation absente : tout en public, y compris les attributs, parce que « c'est plus simple pour y accéder depuis le Main ». Cela signale généralement le piège précédent.

L'héritage partout : une hiérarchie de six niveaux, parce qu'on a trouvé des attributs communs. Le test « est-un » du chapitre 5 en élimine la moitié, et la composition règle le reste.

Le grand assemblage final : voir la section précédente.

L'oubli du diagramme : le code est écrit d'abord, le diagramme dessiné la veille pour satisfaire à la consigne. Il ne sert alors à rien — et cela se voit.

Quiz · 1 question

Sur l'énoncé « un ouvrage peut exister en plusieurs exemplaires », faut-il une ou deux classes ?

  • Une seule : un exemplaire est un ouvrage, avec un attribut nombre indiquant combien il en resteune seule
  • Deux classes distinctes : l'OUVRAGE porte le titre, l'auteur et l'ISBN ; l'EXEMPLAIRE porte la cote, l'état et la disponibilité. On n'emprunte pas un ouvrage, on emprunte un exemplairedeux, en association
  • Deux classes liées par héritage : Exemplaire extends Ouvrage, pour hériter du titre et de l'auteurdeux, en héritage

Réponse : C'est la difficulté centrale de cet énoncé, et la fusionner est l'erreur numéro un. Un ouvrage et un exemplaire n'ont ni les mêmes attributs ni la même identité : Dune est UN ouvrage — un titre, un auteur, un ISBN — dont la bibliothèque possède TROIS exemplaires physiques, chacun avec sa cote, son état d'usure et sa disponibilité propre. Un simple compteur ne suffit pas : il ne dit pas quel exemplaire est chez qui, ni lequel est abîmé. La relation est une association « * ── 1 » : plusieurs exemplaires pour un ouvrage. L'héritage serait la faute du chapitre 5 — un exemplaire n'EST pas un ouvrage, il est un exemplaire DE cet ouvrage, ce qui est une association et non une nature. Le test le confirme : partout où un Ouvrage est attendu, pourrait-on donner un Exemplaire ? Non.

Quiz · 1 question

Pourquoi implémenter par tranches verticales plutôt que classe par classe ?

  • Parce que c'est plus rapide : on écrit moins de code au totalplus rapide
  • Parce que le programme COMPILE ET TOURNE en permanence : les régressions se voient le jour même, on a toujours quelque chose à montrer, et la conception se corrige en marchant plutôt qu'à la fintoujours exécutable
  • Parce que les tranches verticales évitent d'avoir à écrire un diagramme de classesévite le diagramme

Réponse : Le volume de code est le même — ce n'est pas une question de rapidité d'écriture mais de RISQUE. Écrire toutes les classes, puis tous les attributs, puis toutes les méthodes, laisse un programme qui ne compile jamais avant la fin : le premier test a lieu quand il est trop tard pour changer quoi que ce soit. En prenant un scénario complet et en le faisant fonctionner de bout en bout, on obtient trois choses. Une régression se détecte le jour même. On a toujours un livrable, et un projet à moitié fait qui tourne vaut mieux qu'un projet complet qui ne compile pas. Et surtout, la conception se corrige en marchant : découvrir à l'incrément 3 qu'il manque une classe coûte une heure, la découvrir à la fin coûte une refonte. Le diagramme reste nécessaire — il est même ce qui permet de découper en tranches.

À vous

L'exercice conduit l'analyse de l'énoncé d'ouverture, du texte au modèle.

D'abord l'extraction : le programme repère les noms et les verbes, et vous décidez, pour chacun, s'il devient une classe, un attribut, une méthode, ou rien. Le corrigé justifie chaque verdict — y compris les deux cas douteux, « pénalité » et « durée ».

Ensuite la vérification du modèle par un scénario : « Ana emprunte Dune, le rend avec cinq jours de retard ». Vous faites marcher le scénario sur le diagramme décrit en données, et le programme signale ce qui manque pour le mener à bout.

Enfin un contrôleur de conception, appliqué à deux versions du même projet : il compte les méthodes par classe, repère la classe divine, les attributs publics et les hiérarchies trop profondes. Vous verrez qu'un modèle correct et un modèle raté se distinguent à des chiffres simples.

Exercice de code

Tirez un modèle d'un énoncé, éprouvez-le par un scénario, puis auditez deux conceptions.

Point de départ

const ENONCE = [
  "Réaliser une application de gestion de bibliothèque.",
  "Les adhérents empruntent des ouvrages pour une durée limitée.",
  "Un ouvrage peut exister en plusieurs exemplaires.",
  "Les retards donnent lieu à une pénalité.",
].join(" ");

// ── 1. Du texte aux candidats ─────────────────────────────────────────────
const NOMS = ["bibliothèque", "adhérent", "ouvrage", "durée", "exemplaire", "retard", "pénalité"];
const VERBES = ["emprunter", "rendre", "calculer", "rechercher"];

// Pour chaque nom : "classe", "attribut", ou "à decider" (question à poser).
const VERDICTS = {
  // ← à écrire, avec une justification en une ligne
};

// ── 2. Le modèle, décrit en données ───────────────────────────────────────
const MODELE = {
  classes: {
    Adherent:   { attributs: ["nom"], methodes: ["emprunter", "rendre"] },
    Ouvrage:    { attributs: ["titre", "auteur", "isbn"], methodes: [] },
    Exemplaire: { attributs: ["cote", "etat", "disponible"], methodes: ["estDisponible"] },
    Emprunt:    { attributs: ["debut", "prevu", "effectif"], methodes: ["retardEnJours", "penalite"] },
  },
  relations: [
    { de: "Adherent", vers: "Emprunt", mult: "0..*" },
    { de: "Emprunt", vers: "Exemplaire", mult: "1" },
    { de: "Exemplaire", vers: "Ouvrage", mult: "1" },
  ],
};

// Fait marcher un scénario sur le modèle et signale ce qui manque.
function verifierScenario(modele, etapes) {
  const manques = [];
  for (const e of etapes) {
    const c = modele.classes[e.classe];
    if (!c) { manques.push("classe absente : " + e.classe); continue; }
    // ← à écrire : vérifier que la méthode et les attributs requis existent
  }
  return manques;
}

const SCENARIO = [
  { classe: "Adherent",   methode: "emprunter",     attributs: [] },
  { classe: "Exemplaire", methode: "estDisponible", attributs: ["disponible"] },
  { classe: "Emprunt",    methode: "retardEnJours", attributs: ["prevu", "effectif"] },
  { classe: "Emprunt",    methode: "penalite",      attributs: [] },
  { classe: "Ouvrage",    methode: "exemplairesLibres", attributs: [] },   // manque ?
];

// ── 3. Mesurer une conception ─────────────────────────────────────────────
const PROJET_RATE = {
  Main:       { methodes: 34, attributsPublics: 12, profondeur: 0 },
  Livre:      { methodes: 0,  attributsPublics: 5,  profondeur: 0 },
  Adherent:   { methodes: 0,  attributsPublics: 4,  profondeur: 0 },
  LivrePoche: { methodes: 1,  attributsPublics: 0,  profondeur: 4 },
};
const PROJET_CORRECT = {
  Bibliotheque: { methodes: 6, attributsPublics: 0, profondeur: 0 },
  Ouvrage:      { methodes: 4, attributsPublics: 0, profondeur: 0 },
  Exemplaire:   { methodes: 5, attributsPublics: 0, profondeur: 0 },
  Emprunt:      { methodes: 5, attributsPublics: 0, profondeur: 0 },
  Adherent:     { methodes: 5, attributsPublics: 0, profondeur: 0 },
};

function auditer(nom, projet) {
  // ← à écrire : signaler la classe divine, les classes anémiques, les
  //   attributs publics et les hiérarchies de plus de trois niveaux.
  console.log("   " + nom + " : à auditer");
}

// ── À VOUS ────────────────────────────────────────────────────────────────
// 1. Remplissez VERDICTS pour les sept noms.
// 2. Complétez verifierScenario : que manque-t-il pour mener le scénario ?
// 3. Écrivez auditer() et comparez les deux projets.

console.log(ENONCE);

Solution

const ENONCE = [
  "Réaliser une application de gestion de bibliothèque.",
  "Les adhérents empruntent des ouvrages pour une durée limitée.",
  "Un ouvrage peut exister en plusieurs exemplaires.",
  "Les retards donnent lieu à une pénalité.",
].join(" ");

console.log("— 1. du texte aux candidats —");
const VERDICTS = {
  bibliothèque: ["classe", "porte le catalogue et les règles de prêt : identité et comportement"],
  adhérent:     ["classe", "identité propre, emprunts en cours, comportement"],
  ouvrage:      ["classe", "titre, auteur, ISBN — l'oeuvre, indépendamment des copies"],
  exemplaire:   ["classe", "copie PHYSIQUE : cote, état, disponibilité. On emprunte celui-ci"],
  durée:        ["attribut", "aucun comportement, aucune identité : un champ d'Emprunt ou une constante"],
  retard:       ["attribut", "valeur CALCULÉE à partir de deux dates : une méthode d'Emprunt"],
  pénalité:     ["à décider", "montant calculé ? ou objet avec date, statut payé, historique ? QUESTION À POSER"],
};
for (const [nom, [verdict, pourquoi]] of Object.entries(VERDICTS)) {
  console.log("   " + nom.padEnd(14) + verdict.padEnd(11) + pourquoi);
}
console.log("   Et un candidat que l'énoncé ne NOMME PAS : Emprunt. Il apparaît");
console.log("   parce que « emprunter pour une durée » porte des dates — une");
console.log("   association qui porte des attributs est une classe.");

const MODELE = {
  classes: {
    Adherent:   { attributs: ["nom"], methodes: ["emprunter", "rendre"] },
    Ouvrage:    { attributs: ["titre", "auteur", "isbn"], methodes: [] },
    Exemplaire: { attributs: ["cote", "etat", "disponible"], methodes: ["estDisponible"] },
    Emprunt:    { attributs: ["debut", "prevu", "effectif"], methodes: ["retardEnJours", "penalite"] },
  },
  relations: [
    { de: "Adherent", vers: "Emprunt", mult: "0..*" },
    { de: "Emprunt", vers: "Exemplaire", mult: "1" },
    { de: "Exemplaire", vers: "Ouvrage", mult: "1" },
  ],
};

function verifierScenario(modele, etapes) {
  const manques = [];
  for (const e of etapes) {
    const c = modele.classes[e.classe];
    if (!c) { manques.push("classe absente : " + e.classe); continue; }
    if (!c.methodes.includes(e.methode)) {
      manques.push(e.classe + "." + e.methode + "() n'existe pas dans le modèle");
    }
    for (const a of e.attributs) {
      if (!c.attributs.includes(a)) manques.push(e.classe + "." + a + " manque");
    }
  }
  return manques;
}

const SCENARIO = [
  { classe: "Adherent",   methode: "emprunter",         attributs: [] },
  { classe: "Exemplaire", methode: "estDisponible",     attributs: ["disponible"] },
  { classe: "Emprunt",    methode: "retardEnJours",     attributs: ["prevu", "effectif"] },
  { classe: "Emprunt",    methode: "penalite",          attributs: [] },
  { classe: "Ouvrage",    methode: "exemplairesLibres", attributs: [] },
];

console.log("");
console.log("— 2. le scénario sur le modèle —");
const manques = verifierScenario(MODELE, SCENARIO);
for (const m of manques) console.log("   MANQUE : " + m);
console.log("   « Ana veut emprunter Dune » suppose de retrouver un exemplaire");
console.log("   LIBRE de cet ouvrage. Le modèle ne le permet pas : la relation va");
console.log("   d'Exemplaire vers Ouvrage, pas l'inverse. Il faut soit une");
console.log("   navigabilité dans les deux sens, soit une méthode de Bibliotheque.");
console.log("   Découvrir cela ICI coûte une minute ; le découvrir en codant, une refonte.");

console.log("");
console.log("— 3. mesurer une conception —");
const PROJET_RATE = {
  Main:       { methodes: 34, attributsPublics: 12, profondeur: 0 },
  Livre:      { methodes: 0,  attributsPublics: 5,  profondeur: 0 },
  Adherent:   { methodes: 0,  attributsPublics: 4,  profondeur: 0 },
  LivrePoche: { methodes: 1,  attributsPublics: 0,  profondeur: 4 },
};
const PROJET_CORRECT = {
  Bibliotheque: { methodes: 6, attributsPublics: 0, profondeur: 0 },
  Ouvrage:      { methodes: 4, attributsPublics: 0, profondeur: 0 },
  Exemplaire:   { methodes: 5, attributsPublics: 0, profondeur: 0 },
  Emprunt:      { methodes: 5, attributsPublics: 0, profondeur: 0 },
  Adherent:     { methodes: 5, attributsPublics: 0, profondeur: 0 },
};

function auditer(nom, projet) {
  const entrees = Object.entries(projet);
  const total = entrees.reduce((s, [, c]) => s + c.methodes, 0);
  const alertes = [];
  for (const [classe, c] of entrees) {
    // Classe divine : elle concentre l'essentiel des méthodes du projet.
    if (total > 0 && c.methodes / total > 0.5) {
      alertes.push(classe + " concentre " + Math.round((c.methodes / total) * 100) +
                   " % des méthodes : CLASSE DIVINE");
    }
    // Classe anémique : des données, aucun comportement.
    if (c.methodes === 0 && c.attributsPublics > 0) {
      alertes.push(classe + " n'a aucune méthode : CLASSE ANÉMIQUE");
    }
    if (c.attributsPublics > 0) {
      alertes.push(classe + " expose " + c.attributsPublics + " attributs publics");
    }
    if (c.profondeur > 3) {
      alertes.push(classe + " est à " + c.profondeur + " niveaux d'héritage : trop profond");
    }
  }
  console.log("   " + nom + " — " + entrees.length + " classes, " + total + " méthodes");
  if (alertes.length === 0) console.log("      aucune alerte");
  else for (const a of alertes) console.log("      " + a);
}

auditer("projet raté", PROJET_RATE);
auditer("projet correct", PROJET_CORRECT);
console.log("   Un modèle raté et un modèle correct se distinguent à des chiffres");
console.log("   simples : répartition des méthodes, attributs publics, profondeur.");
console.log("   Aucun ne mesure la qualité — mais tous signalent où regarder.");

En travaux pratiques

Travaux pratiques 10 · 3 h

Rendre le projet, et savoir le défendre

Assembler la médiathèque des neuf séances en un projet livrable, testé, et dont chaque décision de conception peut être justifiée à l'oral.

Avant de commencer

  • Les TP 1 à 9
  • Un dépôt Git, et JUnit

Énoncé

  1. L'inventaireListez ce que votre médiathèque fait aujourd'hui, et ce que l'énoncé demandait. Signalez explicitement les manques plutôt que de les laisser découvrir.
  2. Séparer les couchesRéorganisez en trois paquetages : domaine, persistance, interface. Vérifiez qu'aucune classe du domaine n'importe rien de l'interface. Indice : Une dépendance dans le mauvais sens se voit à un import.
  3. Tester le domaineÉcrivez des tests sur les règles métier : emprunt d'un document indisponible, quota d'adhérent, calcul de pénalité, retour. Un test par règle.
  4. Tester les cas limitesAjoutez les cas limites : catalogue vide, adhérent sans emprunt, retard de zéro jour, emprunt le jour même du retour, document rendu deux fois.
  5. PersisterSauvegardez et rechargez le catalogue dans un fichier. Vérifiez qu'un aller-retour complet redonne exactement l'état initial.
  6. Le remplacementÉcrivez une seconde implémentation de la persistance, dans un autre format. Branchez-la SANS modifier une seule ligne du domaine.
  7. Relire son propre codeCherchez dans votre projet : un accesseur inutile, une cascade de tests de type, une classe qui fait deux choses, un catch vide. Corrigez ce que vous trouvez.
  8. Préparer la soutenancePour trois décisions de conception, préparez la justification et l'alternative que vous avez écartée. C'est ce qui distingue un projet compris d'un projet recopié.

C'est réussi quand

  • Aucune classe du domaine n'importe quoi que ce soit de l'affichage ou du stockage
  • Vos tests échouent si vous cassez volontairement une règle métier
  • La seconde persistance se branche sans toucher au domaine
  • Vous savez défendre trois choix et nommer ce que vous avez écarté

Correction

Le sens des dépendances
mediatheque/
domaine/       Document, Livre, Dvd, Adherent, Emprunt, Mediatheque
persistance/   DepotDocuments (interface), DepotFichier, DepotMemoire
interface/     ConsoleUI

RÈGLE : les flèches vont vers le DOMAINE, jamais l'inverse
interface   ──▶ domaine
persistance ──▶ domaine
domaine     ──▶ RIEN

vérification mécanique :
grep -r "import.*interface\." src/domaine/   → doit être VIDE

Le domaine porte les règles métier, et elles ne dépendent ni de l'endroit où l'on stocke ni de la façon dont on affiche. C'est ce qui permet de tester le métier sans fichier ni écran, et de remplacer l'un ou l'autre sans y toucher. La commande grep transforme ce principe en vérification, ce qui vaut mieux qu'une bonne intention.

L'inversion de dépendance
/* dans le DOMAINE : l'interface, exprimée dans ses termes */
public interface DepotDocuments {
  void enregistrer(Document d);
  Optional<Document> parCote(String cote);
  List<Document> tous();
}

/* dans la PERSISTANCE : les implémentations */
public class DepotFichier  implements DepotDocuments { … }
public class DepotMemoire  implements DepotDocuments { … }   /* pour les tests */

/* le domaine reçoit son dépôt, il ne le CHOISIT pas */
public Mediatheque(DepotDocuments depot) { this.depot = depot; }

L'interface appartient au domaine — c'est LUI qui dit ce dont il a besoin — et l'implémentation est ailleurs. C'est ce renversement qui permet à l'étape 6 de brancher un second format sans rien modifier, et à l'étape 3 de tester le métier avec un dépôt en mémoire, sans fichier ni disque.

Les tests des règles métier
@Test void unDocumentIndisponibleNeSEmpruntePas() {
  Document d = new Livre("Dune", 1965);
  assertTrue(d.emprunter());
  assertFalse(d.emprunter());      /* le second échoue */
}

@Test void leQuotaEstApplique() {
  Adherent a = new Adherent("Ada");
  for (int i = 0; i < 5; i++) a.emprunter(unLivre());
  assertThrows(QuotaDepasseException.class, () -> a.emprunter(unLivre()));
}

@Test void unRetardDeZeroJourNeCoutePasRien() {
  assertEquals(0.0, new Emprunt(livre, ada, hier).penalite(aujourdHui));
}

Un test par règle, nommé par la règle : quand il échoue, son NOM dit déjà ce qui est cassé. Le test qui prouve le plus est le second — il vérifie qu'une règle REFUSE, ce que l'on oublie presque toujours en ne testant que les cas qui marchent.

L'aller-retour de persistance
@Test void sauvegarderPuisRechargerRedonneLeMemeEtat() {
  Mediatheque avant = uneMediathequeDeTest();
  Path f = Files.createTempFile("cat", ".txt");

  new DepotFichier(f).enregistrerTout(avant.tous());
  List<Document> apres = new DepotFichier(f).tous();

  assertEquals(avant.tous(), apres);    /* d'où l'equals du TP 6 */
}

Ce test unique couvre l'écriture ET la lecture, y compris l'encodage, les séparateurs et les champs vides. Il repose sur l'equals redéfini au TP 6 — sans lui, la comparaison porterait sur les références et échouerait toujours. C'est le premier test à écrire sur toute persistance, et souvent le seul qui trouve quelque chose.

La relecture de son propre code
à chercher, dans l'ordre de rentabilité :

catch (Exception e) { }          → une erreur effacée      (TP 7)
if (x instanceof …) else if …    → une méthode manquante   (TP 6)
getListe() qui renvoie le champ  → encapsulation qui fuit  (TP 3)
une classe de 400 lignes         → elle fait deux choses
un accesseur jamais appelé        → à supprimer
un commentaire qui explique COMMENT → renommer plutôt que commenter

Ces six motifs se cherchent mécaniquement, et chacun renvoie à un TP précis du semestre. C'est le meilleur usage de la dernière séance : relire son propre code avec une liste, plutôt qu'ajouter une fonctionnalité de plus. Un projet plus petit et cohérent se défend toujours mieux qu'un projet plus large et bancal.

Défendre un choix
« Pourquoi une classe abstraite Document plutôt qu'une interface ? »
→ parce qu'il y a un ÉTAT commun (titre, disponibilité) et un
constructeur à factoriser. J'aurais pris une interface si les
documents n'avaient partagé que des comportements.

« Pourquoi Emprunt est-il une classe et pas un champ de Document ? »
→ parce que la relation porte des DATES, et qu'un document a un
historique de plusieurs emprunts. Le champ interdisait l'historique.

« Pourquoi ArrayList et pas LinkedList ? »
→ je l'ai MESURÉ au TP 9 : accès par indice 12 000 fois plus rapide,
parcours 8 fois plus rapide, et je n'insère jamais en tête.

Ce qui est évalué à la soutenance n'est pas le nombre de fonctionnalités, c'est la capacité à dire pourquoi. Une réponse qui nomme l'alternative écartée et le critère de décision vaut mieux qu'un projet plus riche défendu par « c'est ce qu'on fait d'habitude » — et la troisième réponse, qui s'appuie sur une mesure, est la plus solide des trois.

Ce que ce semestre laisse

Dix chapitres plus tôt, la question était de passer du programme qui calcule au programme qui modélise.

Le bloc I a montré ce qui casse sans objet, et donné la classe. Le bloc II l'a rendue responsable de sa propre cohérence. Le bloc III — le cœur — a permis de factoriser sans abuser du « est-un », puis d'écrire du code qui traite uniformément des objets de types différents. Le bloc IV a rendu tout cela robuste et dessinable, et le bloc V l'a fait vivre.

Reste une idée qui traverse les cinq blocs, et c'est peut-être ce qu'il faut en garder : la programmation objet consiste à décider où mettre chaque chose. Quelle classe porte quelle donnée, quelle classe porte quelle règle, ce qui est visible et ce qui ne l'est pas, ce qui est commun et ce qui varie. Le langage ne prend aucune de ces décisions à votre place — il se contente de rendre les bonnes faciles à écrire et les mauvaises faciles à regretter.

Et le critère de réussite est resté le même depuis le chapitre 1 : quand une exigence change, combien de fichiers faut-il ouvrir ? Un seul, si la chose était au bon endroit.

À retenir

Flashcards · 6 cartes

Comment tire-t-on des classes d'un énoncé, et quel filtre applique-t-on ?
On souligne les NOMS (candidats-classes) et l'on entoure les VERBES (candidats-méthodes), puis on FILTRE : un nom sans comportement ni identité propre est un attribut, pas une classe — « durée » en est un. Certains cas sont douteux et doivent être POSÉS EN QUESTION plutôt que devinés : « pénalité » est une valeur calculée si c'est un montant, une classe si elle a une date et un statut. Filtre final : chaque classe se décrit en une phrase sans « et ».
Quand une association devient-elle une classe à part entière ?
Quand elle porte des DONNÉES PROPRES ou un COMPORTEMENT. Emprunt n'est pas un simple lien entre Adherent et Exemplaire : il a une date de début, une date prévue, une date effective, et il sait calculer son retard. Règle : une association qui porte des attributs est une classe.
Comment vérifie-t-on un diagramme avant d'écrire du code ?
En y faisant MARCHER UN SCÉNARIO complet : « Ana emprunte Dune, le rend avec cinq jours de retard, paie sa pénalité ». On suit le chemin sur le dessin, et si une information manque — comment retrouver un exemplaire libre de cet ouvrage ? — le diagramme est incomplet. S'en apercevoir à ce moment coûte une minute ; s'en apercevoir en codant coûte une refonte.
Qu'est-ce qu'une tranche verticale, et quels sont ses trois bénéfices ?
Choisir UN scénario complet et le faire fonctionner de bout en bout, plutôt que d'écrire toutes les classes puis tous les attributs puis toutes les méthodes. Bénéfices : 1) le programme COMPILE ET TOURNE en permanence, donc une régression se voit le jour même ; 2) on a TOUJOURS quelque chose à montrer, et un projet à moitié fait qui tourne vaut mieux qu'un projet complet qui ne compile pas ; 3) la CONCEPTION SE CORRIGE EN MARCHANT — découvrir une classe manquante tôt coûte une heure, tard une refonte.
Que teste-t-on dans un projet objet, et que révèle l'écriture d'un test ?
Les INVARIANTS (le constructeur refuse-t-il bien un ISBN vide ?), les CAS LIMITES (dernier exemplaire, retour le jour de l'échéance, liste vide), le COMPORTEMENT POLYMORPHE (chaque sous-classe fait-elle ce qu'elle doit ?), et les EXCEPTIONS (la bonne, dans le bon cas). Bénéfice moins évident : écrire un test, c'est être le premier CLIENT de sa propre API — s'il faut créer sept objets pour en tester un, la conception est trop couplée.
Nommez les cinq pièges classiques d'un projet de L2.
1) LA CLASSE DIVINE : un Main de six cents lignes entouré de classes anémiques réduites à des getters. 2) L'ENCAPSULATION ABSENTE : tout en public « pour y accéder depuis le Main » — signale généralement le premier piège. 3) L'HÉRITAGE PARTOUT : une hiérarchie de six niveaux fondée sur des attributs communs, que le test « est-un » réduit de moitié. 4) LE GRAND ASSEMBLAGE FINAL au lieu des tranches verticales. 5) L'OUBLI DU DIAGRAMME, dessiné la veille pour satisfaire à la consigne — et cela se voit.