Module 1 · Les fondations

Les éléments de C++

Ce module ne t'apprend pas le C++. Il t'apprend à lire les quinze lignes qui vont revenir, presque à l'identique, dans chaque structure de données du reste de la session : une classe, un pointeur, un new, un destructeur. Quatre choses, et elles se répètent jusqu'au dernier module.

💡 Le concept

Le cours s'écrit en C++, et le cours lui-même traite le langage en rappel : il le range dans ses ressources permanentes, pas dans son semainier. Ce module fait pareil. Il ne cherche pas la couverture du langage — l'héritage, les exceptions et la moitié de la bibliothèque standard n'y sont pas —, il cherche la couverture de ce que tu vas lire.

D'où un test simple, appliqué à chaque section : est-ce que cette notion apparaît dans le code d'une liste chaînée, d'une pile ou d'un arbre ? Si oui, elle est ici. Sinon, elle n'y est pas, même quand elle est intéressante.

Un rappel, pas un cours de langage

Commençons par la question que tout le monde se pose en ouvrant le premier travail pratique : pourquoi le C++, alors qu'on sait déjà programmer dans un langage plus confortable ? La réponse n'est pas historique, et elle n'est pas non plus une question de vitesse. Elle tient au fait que ce cours-ci ne porte pas sur des programmes, mais sur des structures — et qu'une structure de données est faite de mémoire et de liens entre morceaux de mémoire.

🧭 L'automatique et la manuelle

Python et Java sont des voitures à transmission automatique : la boîte choisit le rapport toute seule, et tu n'as jamais à y penser. C++ est une manuelle. C'est plus exigeant, et c'est justement pour ça qu'on apprend la mécanique dessus — tu vois le moment où l'embrayage part.

Transposé : dans un langage à ramasse-miettes, la question « quand cet objet est-il détruit ? » n'a pas de réponse que tu contrôles. En C++, elle en a une, elle est écrite dans ton code, et c'est exactement la question qu'un arbre ou une liste chaînée pose à chaque insertion.

Trois raisons concrètes, et les trois se retrouvent dans les modules qui suivent. La première est le contrôle explicite de la mémoire : c'est toi qui décides quand un nœud naît et quand il meurt, donc tu peux le lire dans le code au lieu de le deviner. La deuxième est la généricité : une seule implémentation d'arbre sert pour des entiers, des chaînes ou des dates, et on verra comment. La troisième est la bibliothèque standard, la STL, qui fournit déjà toutes les structures du cours — non pas pour t'éviter de les écrire, mais pour te donner un modèle d'interface : quand tu implémenteras une pile, tu la nommeras comme la STL nomme la sienne.

Un mot sur la façon de lire ce qui suit. Chaque bloc de code de ce module est précédé d'une phrase qui dit pourquoi il est là et ce qu'il faut y regarder. Ce n'est pas une politesse : quinze lignes de C++ qu'on lit sans savoir ce qu'on cherche ne laissent rien. Lis la phrase, puis le code, puis les commentaires — et si tu n'as le temps que d'une des trois choses, garde la phrase.

Deux mémoires, deux durées de vie

Voici le problème qui rend tout le reste nécessaire. Tu écris une fonction qui doit créer un nœud et le raccrocher à une liste. La fonction se termine ; la liste, elle, doit continuer d'exister. Or une variable déclarée dans une fonction disparaît quand la fonction se termine — c'est la règle, et elle est absolue.

Il faut donc un endroit où poser des objets qui ne suivent pas le sort de la fonction qui les a créés. C'est pour ça que la mémoire d'un programme est coupée en deux régions aux règles opposées : la pile et le tas.

🧭 Le bureau et l'entrepôt

La pile, c'est ton bureau : petit, à portée de main, et débarrassé à la fin de chaque journée. Tout ce que tu y poses est temporaire, et tu n'as rien à ranger toi-même — quelqu'un passe et vide le bureau, systématiquement.

Le tas, c'est l'entrepôt : vaste, un peu plus lent d'accès, et surtout personne n'y passe le soir. Ce que tu y déposes y reste jusqu'à ce que tu ailles le reprendre. Un objet oublié dans l'entrepôt occupe de la place pour rien, et il l'occupera jusqu'à la fin du programme.

La pileLe tas
Ce qu'on y met les variables locales d'une fonction, ses paramètres ce qu'on alloue avec new
Durée de vie libérée automatiquement à la fin de la fonction vit jusqu'au delete, et pas une seconde de moins
Taille limitée, quelques mégaoctets vaste — la mémoire de la machine
L'accident typique débordement de pile, par récursion sans fin fuite mémoire, par delete oublié

Le point qui compte vraiment n'est dans aucune de ces deux colonnes : c'est ce qui se passe entre elles. Quand tu écris Noeud* n = new Noeud(5); dans une fonction, deux choses distinctes sont créées, dans deux régions différentes. Le nœud est dans le tas. La variable n, qui n'est qu'une adresse, est sur la pile. À la fin de la fonction, la pile est vidée : n disparaît. Le nœud, lui, ne bouge pas.

Tout dépend alors d'une seule chose : est-ce que quelqu'un d'autre connaît encore l'adresse de ce nœud ? Si tu l'as raccroché à une liste avant de sortir, oui — et c'est exactement ce qu'on veut. Sinon, le nœud existe toujours et plus personne ne peut l'atteindre : il est perdu. La figure ci-dessous met les trois moments côte à côte.

La même valeur, avant et après la fin de la fonction
1 · la fonction s'exécute
pile
localecompteur = 5
localen = 0x1A0
tas
alloué par newNoeud(7) à 0x1A0
2 · la fonction se termine, le nœud a été raccroché
pile
— vidée —
tas
adresse détenue par la listeNoeud(7) à 0x1A0
3 · la fonction se termine, le nœud n'a été raccroché à rien
pile
— vidée —
tas
plus aucune adresse n'y mèneNoeud(7) à 0x1A0
Le nœud occupe la même case du tas dans les trois moments : il ne bouge jamais, et la fin de la fonction ne le touche pas. Ce qui change, c'est la pile — elle est vidée — et donc l'existence d'une adresse qui mène encore à lui. Au troisième moment, le nœud est intact et inatteignable : c'est ça, une fuite mémoire.
⚠️ Piège fréquent

Croire qu'une fuite mémoire est un plantage. Ce n'est pas le cas, et c'est ce qui la rend coûteuse : un programme qui fuit fonctionne parfaitement. Il donne les bons résultats, il ne signale rien, et il grossit. Sur un travail pratique qui tourne trois secondes, tu ne verras jamais la différence ; sur un serveur qui tourne trois semaines, il finit par saturer la machine.

Le réflexe de diagnostic ne peut donc pas être « est-ce que ça marche ? », puisque ça marche. Il tient en une question à se poser sur chaque new : quelle ligne de mon code exécutera le delete correspondant, et est-ce que je peux la montrer du doigt ? Si tu ne peux pas la montrer, elle n'existe pas.

🧠 Quiz éclair

Cette fonction est-elle correcte ? Que reste-t-il en mémoire après son retour ?

int sommeDesDeux(int a, int b) {
  int* resultat = new int(a + b);
  return *resultat;
}

Elle rend la bonne valeur, et elle fuit. *resultat lit l'entier dans le tas et en renvoie une copie, donc l'appelant reçoit exactement ce qu'il attend. Puis la fonction se termine : la variable resultat, qui vivait sur la pile, disparaît — et l'entier alloué dans le tas reste là, sans plus aucune adresse pour le désigner.

La vraie leçon est ailleurs : cette fonction n'avait aucune raison d'allouer. Un int local aurait fait le travail. On n'emploie le tas que lorsqu'un objet doit survivre à la fonction qui le crée — un nœud, jamais un total.

Ce que chaque symbole te dit

Quatre écritures reviennent partout dès qu'il y a un pointeur, et elles se ressemblent assez pour qu'on les confonde en lisant vite. Voici ce que chacune dit, en une ligne — c'est un rappel, pas une découverte : on va vite pour arriver à ce qui suit, qui l'est moins.

L'écritureCe qu'elle ditCe qu'elle vaut
&x « l'adresse de x » un pointeur vers x
*p « la case que p désigne » la valeur elle-même — on peut la lire et l'écrire
p->membre « le membre de l'objet que p désigne » strictement (*p).membre, en plus lisible
nullptr « ce pointeur ne désigne rien » une valeur, pas une absence de valeur

La quatrième ligne mérite qu'on s'y arrête, parce qu'elle n'est pas une précaution d'écriture : elle est la raison pour laquelle les structures de données emploient des pointeurs plutôt que des références. nullptr est une valeur qu'un pointeur peut légitimement porter, et dans une liste chaînée elle signifie quelque chose — le dernier nœud pointe vers nullptr, et c'est ainsi qu'on sait qu'on est arrivé au bout. Un arbre dont le fils gauche vaut nullptr n'a pas de fils gauche. Le « rien » est une information du modèle, pas une erreur.

La référence, elle, ne peut pas porter cette information. Une référence — notée T& — est un autre nom pour une variable qui existe déjà ; elle est attachée à sa cible au moment de sa création et ne peut plus en changer, ni valoir « rien ». C'est une limitation, et c'est aussi ce qui la rend sûre. D'où la règle de lecture :

  • Une référence quand la chose existe forcément et qu'on ne changera pas de cible : le paramètre d'une fonction, la valeur qu'on veut modifier sur place.
  • Un pointeur quand « rien » est une réponse possible ou quand la cible change : les liens d'une liste, les fils d'un arbre, le sommet d'une pile.

Reste la forme que tu vas croiser le plus souvent de tout le cours, et qui n'est expliquée nulle part parce qu'elle a l'air anodine : const T& en paramètre. Le bloc ci-dessous met les trois façons de recevoir un objet l'une sous l'autre — regarde uniquement ce que chaque signature promet, le corps des fonctions n'a aucun intérêt ici.

trois_facons_de_recevoir.cpp
// 1. PAR VALEUR — la fonction reçoit une copie complète.
//    Sûr, et coûteux : copier un objet qui contient mille éléments
//    en recopie mille. Modifier `element` ne change rien chez l'appelant.
void ajouter(Element element);

// 2. PAR RÉFÉRENCE — aucun objet copié, la fonction travaille sur
//    l'original. C'est ce qu'on veut pour MODIFIER.
void remplir(Element& element);

// 3. PAR RÉFÉRENCE CONSTANTE — aucun objet copié, et interdiction
//    d'y toucher. Le compilateur refuse toute modification.
void afficher(const Element& element);

La troisième forme est le défaut de tout le cours : elle a le prix de la deuxième et la sûreté de la première.

Le raisonnement tient en deux temps. Passer par valeur copie, donc coûte le prix d'une copie ; passer par référence ne copie rien, donc coûte le prix d'une adresse, quelle que soit la taille de l'objet. Mais la référence nue autorise la fonction à modifier ton objet, ce qui n'est pas ce que tu veux quand tu voulais seulement le lire. Le const ferme cette porte : la fonction voit l'original, et le compilateur refuse qu'elle y touche.

🔗 Pont — un choix de langage qui se mesure

Ce n'est pas une préférence de style, et tu as déjà l'outil pour le dire précisément. Copier un objet qui contient n éléments demande n recopies : c'est du Θ(n). Passer son adresse en demande une seule, quelle que soit la valeur de n : c'est du Θ(1). Écrire Element element au lieu de const Element& element dans l'en-tête d'une méthode appelée en boucle transforme donc, à elle seule, un parcours linéaire en parcours quadratique.

C'est le vocabulaire de L'analyse asymptotique appliqué à un détail de signature — et c'est la première fois du cours, mais pas la dernière, où l'ordre de croissance se décide sur un caractère.

🧠 Quiz éclair

Après ces quatre lignes, que valent a et b ?

int a = 10;
int b = 20;
int* p = &a;
int& r = b;

p = &b;      // (1)
r = a;       // (2)

a vaut 10, b vaut 10. Les deux lignes se ressemblent et ne font pas du tout la même chose.

La ligne (1) réaffecte le pointeur : p cesse de désigner a et désigne maintenant b. Aucune valeur n'a changé, seul le pointeur a bougé.

La ligne (2) ne réaffecte rien du tout, parce qu'une référence ne peut pas être réaffectée. r est un autre nom pour b ; r = a se lit donc b = a, et écrit 10 dans b. Là est toute la différence : sur un pointeur, l'affectation déplace le pointeur ; sur une référence, elle écrit dans la cible.

Allouer et libérer : le contrat

Le tas ne se remplit pas tout seul : on y prend de la place avec new, et on la rend avec delete. Ces deux mots forment un contrat, et le contrat n'a qu'une clause : à chaque new correspond exactement un delete. Ni zéro — c'est une fuite —, ni deux — c'est un plantage.

Le bloc suivant est le vocabulaire complet de l'allocation dans ce cours : il n'y a rien de plus à connaître, et les six lignes se répondent deux à deux.

allocation.cpp
// Un objet seul, avec l'argument de son constructeur.
Noeud* n = new Noeud(5);
delete n;                    // rend la place, appelle le destructeur de Noeud

// Un TABLEAU de n objets. Les crochets sont dans les deux écritures,
// et ils doivent l'être : c'est ce qui distingue les deux cas.
int* donnees = new int[100];
delete[] donnees;

// Après un delete, le pointeur contient encore l'ancienne adresse —
// une adresse qui ne t'appartient plus. On le neutralise.
n = nullptr;

Trois gestes seulement : allouer, libérer avec la forme qui correspond, neutraliser le pointeur devenu invalide.

La paire new[] / delete[] mérite une explication, parce que la règle est toujours donnée comme un interdit — « ne mets pas delete sur un tableau » — sans jamais dire ce qui se passerait. Voici ce qui se passe. Quand tu alloues un tableau, le programme retient quelque part combien d'éléments il contient ; c'est delete[] qui va lire ce nombre, pour appeler le destructeur de chacun des éléments avant de rendre la place. delete tout seul ne lit pas ce nombre : il traite l'adresse comme celle d'un objet unique et n'appelle qu'un destructeur. Les autres ne sont jamais détruits, et la place n'est pas rendue correctement.

Les trois accidents de l'allocation ont chacun un nom, et tu les rencontreras sous ces noms-là.

  • La fuite — un new sans delete. Le programme marche et grossit. C'est le piège de la section précédente.
  • La double libération — deux delete sur la même adresse. Le premier rend la place, le second rend une place qui ne t'appartient plus. Plantage, immédiat ou différé selon la chance qu'on a.
  • Le pointeur ballant — l'emploi d'un pointeur après son delete. L'adresse est encore là, la place a été reprise par autre chose, et tu lis ou écris dans les affaires du voisin. C'est le plus difficile à diagnostiquer des trois, parce que le symptôme apparaît loin de la cause.

Reste la question pratique : écrit-on le delete ? La réponse gouverne toute la suite du cours, et elle est étonnamment simple. Une structure de données qui alloue de la mémoire est responsable de la rendre ; l'endroit prévu pour ça s'appelle le destructeur, et c'est le sujet de deux sections plus bas. Retiens la forme de la réponse : le new est dans une méthode d'insertion, le delete correspondant est dans le destructeur de la même classe. Les deux ne se lisent jamais côte à côte, et c'est normal.

⚠️ Piège fréquent

Le delete à qui il manque des crochets. Écrire delete tableau; là où il fallait delete[] tableau; ne produit aucune erreur de compilation, aucun message, et le programme continue. C'est la définition même du comportement indéfini : le langage n'exige rien du tout de ce qui suit.

Le réflexe de diagnostic se prend à l'écriture, pas à la relecture : chaque fois que tu tapes des crochets dans un new, tape-les tout de suite dans le delete. Et à la relecture, la question est mécanique — y a-t-il autant de paires de crochets du côté des delete que du côté des new ?

Lire une classe, ligne par ligne

Toutes les structures de données du cours sont des classes, et elles ont toutes la même allure. Cette section décortique la plus simple d'entre elles — un nœud de liste — en s'arrêtant sur les trois endroits où l'on décroche généralement.

Voici la classe entière. Ne cherche pas à la comprendre d'un coup : les commentaires numérotent les trois endroits qu'on va reprendre juste après, et c'est là-dessus que porte toute la section.

noeud.h
class Noeud {
private:                              // caché de l'extérieur — le défaut d'une class
    int    valeur;
    Noeud* suivant;

public:                               // visible de l'extérieur
    // (1) Le constructeur, et sa LISTE D'INITIALISATION après les deux-points.
    Noeud(int v) : valeur(v), suivant(nullptr) { }

    // (2) Le `const` de FIN promet que la méthode ne modifie pas l'objet.
    int    getValeur()  const { return valeur; }
    Noeud* getSuivant() const { return suivant; }

    void setSuivant(Noeud* n) { suivant = n; }
};

Un nœud ne fait rien : il porte une valeur et un lien. Toute la logique vit dans la liste qui le contient.

(1) La liste d'initialisation. C'est la partie entre les deux-points et l'accolade ouvrante, et c'est de loin ce qui déroute le plus, parce que valeur(v) a exactement l'allure d'un appel de fonction. Ce n'en est pas un. Cette ligne se lit : « l'attribut valeur est construit à partir de v, l'attribut suivant est construit à partir de nullptr » — et tout cela se produit avant que la première ligne du corps ne s'exécute.

Pourquoi ne pas simplement écrire valeur = v; dans le corps ? Pour trois raisons, dans l'ordre où elles deviennent contraignantes. La première est une question de coût : les attributs sont construits de toute façon, avant le corps. Affecter dans le corps revient donc à les construire une première fois avec une valeur par défaut, puis à écraser ce travail — deux gestes au lieu d'un. Sur un int, personne ne le remarque ; sur un attribut qui est lui-même un objet lourd, c'est une construction complète jetée.

La deuxième raison est une obligation : un attribut déclaré const, ou un attribut qui est une référence, ne peut recevoir sa valeur qu'au moment de sa construction. Il n'y a alors aucune alternative — sans liste d'initialisation, le code ne compile pas. La troisième est de l'ordre de la lecture : la liste d'initialisation énumère, en un seul endroit et dans l'ordre, tous les attributs de la classe. Quand tu ouvres une structure inconnue, c'est la ligne qui te dit le plus vite de quoi elle est faite.

(2) Le const de fin. Il ne porte pas sur ce que la méthode renvoie — il porte sur l'objet lui-même, et se lit : « appeler cette méthode ne modifie pas l'objet ». Le compilateur vérifie cette promesse et refuse toute affectation à un attribut dans le corps d'une telle méthode.

Ce n'est pas une décoration, et la conséquence te tombera dessus tôt ou tard : souviens-toi que tout le cours passe ses objets en const T&. Sur une référence constante, seules les méthodes marquées const peuvent être appelées — les autres sont refusées, puisque rien ne garantit qu'elles ne modifieront pas l'objet. Oublier le const sur un accesseur produit donc une erreur de compilation qui apparaît ailleurs, dans la fonction qui essaie de s'en servir, et dont le message ne parle pas de l'accesseur. La règle pratique : toute méthode qui ne fait que lire reçoit un const, tout de suite.

(3) Le mot struct. Il n'est pas dans ce bloc, mais tu le verras dès la première liste chaînée : struct Noeud { … };. En C++, struct et class sont le même mécanisme, à une seule différence près — ce qui n'est pas explicitement rangé est public dans une struct, et private dans une class. La convention du cours en découle : struct pour un simple porteur de données comme un nœud, class pour tout ce qui a un comportement à protéger.

✍️ Exercice de lecture

Ces deux constructeurs semblent équivalents. L'un des deux ne compile pas. Lequel, et pourquoi ?

class Capteur {
private:
    const int  identifiant;    // ne changera jamais après la construction
    int        mesure;
public:
    // Version A
    Capteur(int id) : identifiant(id), mesure(0) { }

    // Version B
    Capteur(int id) {
        identifiant = id;
        mesure = 0;
    }
};
Voir le corrigé

La version B ne compile pas, et l'attribut fautif est identifiant. Il est déclaré const : sa valeur se fixe au moment de sa construction et ne peut plus jamais changer ensuite. Or le corps du constructeur s'exécute après la construction des attributs. La ligne identifiant = id; est donc une modification d'une valeur constante — le compilateur la refuse.

mesure, lui, ne pose aucun problème dans les deux versions : il n'est pas constant, l'affecter dans le corps est simplement un peu moins efficace que de l'initialiser. C'est ce qui rend l'erreur difficile à voir — la version B a l'air correcte, et elle l'est à moitié.

La leçon à emporter : la liste d'initialisation n'est pas un raffinement de style. Dès qu'un attribut est const ou qu'il est une référence, elle est le seul chemin.

La règle des trois

On arrive à la section la plus importante du module, et à la seule dont l'oubli produit un programme qui plante sans qu'on ait écrit une ligne fautive. Le décor : une classe qui alloue de la mémoire dans son constructeur — c'est-à-dire toutes les structures de données du cours.

Le symptôme est connu. Une classe alloue un tableau, le libère dans son destructeur, et quelqu'un écrit Boite b = a;. Comme rien n'a été prévu pour la copie, C++ recopie les attributs un à un : b.donnees reçoit la même adresse que a.donnees. Un seul tableau, deux propriétaires, deux destructeurs — et delete[] appelé deux fois sur la même place. On appelle ça une copie superficielle : elle copie le pointeur, pas ce qu'il désigne.

Ce qu'il faut à la place s'appelle une copie profonde : allouer un second tableau et y recopier les valeurs, pour que les deux objets soient réellement indépendants. La figure ci-dessous met les deux régimes l'un à côté de l'autre ; l'adresse portée par chaque objet est ce qu'il faut lire.

La même affectation, selon qu'on a écrit un constructeur de copie ou non
copie superficielle
objet adonnees = 0x1A0
objet bdonnees = 0x1A0
dans le tas0x1A0 : [10, 20, 30]
copie profonde
objet adonnees = 0x1A0
objet bdonnees = 0x2C4
dans le tas0x1A0 : [10, 20, 30]
dans le tas0x2C4 : [10, 20, 30]
Sous le régime superficiel, les objets a et b portent la même adresse et le tas ne contient qu'un tableau : le destructeur de chacun le libérera, donc il sera libéré deux fois. Sous le régime profond, les deux adresses diffèrent et le tas contient deux tableaux de contenu identique : chaque destructeur libère le sien.

D'où la règle des trois : si ta classe gère une ressource, tu dois écrire trois méthodes, et les trois ensemble. Le raisonnement qui l'impose est plus utile que la liste : le compilateur fournit une version par défaut de chacune des trois, et ces versions par défaut font toutes la même chose — une copie superficielle. Si tu n'en écris aucune, tu obtiens le régime de gauche sur la figure. Si tu n'en écris qu'une, tu obtiens un mélange des deux, ce qui est pire, parce que le programme fonctionne jusqu'au jour où quelqu'un copie ton objet.

Le bloc ci-dessous est la version complète et correcte. Il est plus long que les autres, et il n'a pas à être mémorisé ligne par ligne : ce qu'il faut y voir, c'est que les trois méthodes racontent la même histoire dans trois situations différentes — je meurs, je nais d'un autre, je remplace mon contenu par celui d'un autre.

boite.h — la règle des trois, au complet
class Boite {
private:
    int* donnees;
    int  taille;

public:
    Boite(int n) : taille(n) { donnees = new int[n]; }

    // 1. LE DESTRUCTEUR — appelé automatiquement à la mort de l'objet.
    ~Boite() { delete[] donnees; }

    // 2. LE CONSTRUCTEUR DE COPIE — appelé par `Boite b = a;`.
    //    On alloue NOTRE tableau, puis on recopie les valeurs.
    Boite(const Boite& autre) : taille(autre.taille) {
        donnees = new int[taille];
        for (int i = 0; i < taille; i++) donnees[i] = autre.donnees[i];
    }

    // 3. L'OPÉRATEUR D'AFFECTATION — appelé par `b = a;` sur un objet
    //    qui existe DÉJÀ. D'où deux gestes de plus que ci-dessus.
    Boite& operator=(const Boite& autre) {
        if (this != &autre) {          // (a) on ne s'écrase pas soi-même
            delete[] donnees;           // (b) on rend l'ancien tableau
            taille = autre.taille;
            donnees = new int[taille];
            for (int i = 0; i < taille; i++) donnees[i] = autre.donnees[i];
        }
        return *this;                   // (c) pour permettre a = b = c
    }
};

Le constructeur de copie fabrique un objet neuf ; l'opérateur d'affectation remplace le contenu d'un objet qui existait déjà. C'est cette différence qui explique les trois lignes marquées (a), (b) et (c).

Ces trois marques valent une explication, parce qu'elles reviennent à l'identique dans chaque operator= que tu écriras.

(a) this est un pointeur vers l'objet courant, disponible dans toute méthode : c'est la façon dont un objet se désigne lui-même. La condition compare donc l'adresse de l'objet à celle de la source, et protège contre l'auto-affectation, c'est-à-dire a = a;. Le cas paraît absurde écrit comme ça ; il ne l'est plus du tout sous la forme liste[i] = liste[j];, où i et j peuvent parfaitement être égaux. Sans cette ligne, la ligne suivante libérerait le tableau qu'on s'apprête à recopier.

(b) est le geste qui distingue l'affectation de la copie : l'objet possédait déjà un tableau, et si on n'en fait rien avant d'en allouer un autre, on vient d'écrire une fuite. Un operator= commence toujours par se débarrasser de ce qu'il détenait.

(c) return *this; renvoie l'objet lui-même — pas son adresse, d'où l'étoile. C'est ce qui permet d'écrire a = b = c; : l'affectation de droite produit une valeur, que celle de gauche peut à son tour utiliser. Sans ce retour, le chaînage ne compile pas.

🧠 Quiz éclair

Cette classe n'a qu'un destructeur. Que se passe-t-il exactement à l'appel de traiter(sac) ?

class Sac {
    int* donnees;
public:
    Sac() { donnees = new int[100]; }
    ~Sac() { delete[] donnees; }
};

void traiter(Sac s) { /* ... */ }   // reçu PAR VALEUR

Sac sac;
traiter(sac);                        // et après cet appel ?

Le tableau de sac est libéré, alors que sac existe toujours. Le paramètre est reçu par valeur, donc le compilateur fabrique une copie — avec son constructeur de copie par défaut, c'est-à-dire une copie superficielle : s.donnees reçoit l'adresse de sac.donnees. À la fin de traiter, la copie meurt et son destructeur exécute delete[] donnees sur le tableau de sac.

À partir de là, sac est un objet valide dont le pointeur est ballant : toute lecture donne n'importe quoi, et son propre destructeur, à la fin du programme, libérera une seconde fois la même place.

Deux corrections, et les deux sont bonnes à connaître. La vraie : écrire les trois méthodes de la règle des trois. L'immédiate : recevoir le paramètre en const Sac&, ce qui ne copie rien du tout — et c'est pourquoi tout le cours passe ses objets ainsi.

Le moule, et le type qu'il exige

Tu viens d'écrire une pile d'entiers. On te demande maintenant une pile de chaînes. Tu copies le fichier, tu remplaces int par string partout, et tu te retrouves avec deux fichiers dont la logique est identique au mot près. Puis on te demande une pile de dates.

Cette duplication est absurde : la structure d'une pile ne dépend pas de ce qu'elle contient. Empiler, dépiler, regarder le sommet : les trois gestes sont les mêmes pour des entiers et pour des dates. Le seul mot qui change est le nom du type. Les templates existent pour écrire ce mot une fois.

🧭 L'emporte-pièce

Un template est un emporte-pièce à biscuits. Le moule définit la forme ; la pâte, elle, peut être au chocolat, à la vanille ou aux amandes. Tu ne fabriques pas trois moules, tu en fabriques un et tu changes de pâte.

Et surtout — c'est là que l'analogie devient exacte — : un moule n'est pas un biscuit. Tu ne peux pas manger le moule. De la même façon, Pile tout seul n'est pas une classe : c'est la recette pour en fabriquer une.

Voici le moule. Ce qu'il faut y regarder est la première ligne, et le fait que T se comporte ensuite exactement comme un nom de type ordinaire.

pile.h
template<typename T>              // « T sera un type, décidé plus tard »
class Pile {
private:
    T*  donnees;                   // T s'emploie partout où irait un type
    int taille;
    int capacite;

public:
    Pile(int cap = 100) : taille(0), capacite(cap) {
        donnees = new T[cap];
    }
    ~Pile() { delete[] donnees; }

    void empiler(const T& valeur) { donnees[taille++] = valeur; }
    T    depiler()                { return donnees[--taille]; }
    bool estVide() const          { return taille == 0; }
};

Une seule écriture. T n'est pas un type : c'est un emplacement réservé pour un type.

Un détail de lecture avant d'aller plus loin, parce qu'il déroute au premier fichier qu'on ouvre : template<class T> et template<typename T> veulent dire exactement la même chose. Les deux mots sont interchangeables à cet endroit — pour des raisons purement historiques —, et tu croiseras les deux écritures dans le même cours. En particulier, T ne devient pas obligatoirement une classe parce qu'on a écrit class : Pile<int> reste parfaitement légal.

Ce qui se passe ensuite est le point que personne n'explique, et c'est pourtant lui qui rend le reste compréhensible. Quand tu écris Pile<int> nombres;, le compilateur ne se contente pas de « retenir » que T vaut int : il fabrique, à cet instant, une classe complète et neuve où chaque T a été remplacé par int. Cette fabrication porte un nom, l'instanciation. Si tu écris ensuite Pile<string> mots;, il en fabrique une deuxième, indépendante de la première.

Le même moule, et les deux classes que le compilateur en tire
ce que tu écris — une fois
le mouletemplate<typename T> class Pile { T* donnees; }
ce que le compilateur fabrique — à chaque emploi
Pile<int>
classe complèteclass Pile_int { int* donnees; }
Pile<string>
classe complèteclass Pile_string { string* donnees; }
Le moule et les classes produites portent le même corps : seul le mot occupant la place de T a changé. Les deux classes fabriquées sont des classes ordinaires et indépendantes — les noms Pile_int et Pile_string sont donnés ici pour la lecture, le compilateur les nomme autrement.

De ce mécanisme découle une conséquence pratique qui déroute tout le monde une fois, et une seule : le corps des méthodes d'une classe template doit être visible dans le fichier d'en-tête. La raison est directe. Le compilateur ne fabrique la classe qu'au moment où il rencontre Pile<int>, c'est-à-dire dans ton fichier, pas dans celui de la pile. Pour la fabriquer, il lui faut le code complet sous les yeux à cet instant. S'il ne dispose que des signatures, il n'a rien à instancier.

⚠️ Piège fréquent

Le template dont l'implémentation est partie dans un .cpp. L'habitude est bonne pour une classe ordinaire, et fausse pour un template. Le symptôme est reconnaissable et il déroute : le fichier compile sans une erreur, et c'est l'édition des liens qui échoue, avec un message qui parle d'un symbole introuvable — le nom de ta méthode, décoré de caractères illisibles.

Le réflexe de diagnostic : devant une erreur d'édition de liens qui nomme une méthode dont tu vois parfaitement le code, la première question est « est-ce une classe template ? ». Si oui, le corps doit remonter dans le .h.

✍️ Exercice de lecture

Voici les deux méthodes centrales de la pile de cette section, recopiées telles quelles. Elles compilent sans le moindre avertissement, et chacune peut détruire le programme. Sur quelle utilisation, et pourquoi le compilateur ne dit-il rien ?

void empiler(const T& valeur) { donnees[taille++] = valeur; }
T    depiler()                { return donnees[--taille]; }
Voir le corrigé

Empiler sur une pile pleine. Quand taille a rejoint capacite, il n'y a plus de case libre — mais donnees[taille] écrit quand même, juste après la fin du tableau, dans une place qui appartient à autre chose. Rien ne plante forcément tout de suite : c'est la variable du voisin qui change de valeur, et le symptôme apparaîtra ailleurs.

Dépiler sur une pile vide. taille vaut 0, donc --taille le met à −1 et on lit donnees[-1], c'est-à-dire avant le début du tableau. Et le dégât ne s'arrête pas là : la pile reste à une taille de −1, donc le prochain empiler écrira lui aussi hors des bornes.

Pourquoi le compilateur se tait : les crochets sur un pointeur ne vérifient rien, jamais. Ils calculent une adresse et y vont. C'est exactement le contrat de v[i] face à v.at(i) — la rapidité contre la vérification —, sauf qu'ici personne n'a écrit la version qui vérifie.

Le remède tient en une ligne par méthode : refuser d'empiler quand taille == capacite, refuser de dépiler quand estVide(). Retiens le raisonnement plus que le correctif : toute méthode qui déplace un indice doit dire ce qu'elle fait aux deux extrémités. C'est la faute la plus fréquente du module suivant.

Reste la moitié dont on parle rarement. Le template promet de fonctionner pour « n'importe quel type », mais ce n'est pas tout à fait vrai : il fonctionne pour n'importe quel type qui sait faire ce que le code lui demande. Une pile ne demande presque rien. Un arbre binaire de recherche, lui, doit décider si une valeur va à gauche ou à droite — il exécute donc if (valeur < noeud->valeur), et cela n'a de sens que si T sait se comparer.

C'est à quoi sert la surcharge d'opérateurs : donner un sens à <, == ou [] pour tes propres types. Le bloc ci-dessous ne montre qu'une chose : la forme que prend cette définition, et le fait qu'elle est une méthode ordinaire au nom inhabituel.

date.h
class Date {
private:
    int jour, mois, annee;

public:
    Date(int j, int m, int a) : jour(j), mois(m), annee(a) { }

    // « Est-ce que je viens avant `autre` ? » — ordre lexicographique :
    // l'année tranche ; à année égale, le mois ; à mois égal, le jour.
    bool operator<(const Date& autre) const {
        if (annee != autre.annee) return annee < autre.annee;
        if (mois  != autre.mois)  return mois  < autre.mois;
        return jour < autre.jour;
    }
};

// À partir de là, `d1 < d2` a un sens — et un arbre de Date compile.

Trois opérateurs suffisent pour tout le cours : < pour les structures ordonnées, == pour les recherches, [] pour l'accès indexé.

📖 La formule

Le conteneur qui possède sa mémoire

template<typename T>             // 1. générique : un moule, tous les types
class Conteneur {
private:
    T*  donnees;                  // 2. ce qu'on POSSÈDE, et qu'il faudra rendre
    int taille;

public:
    Conteneur(int n)              // 3. liste d'initialisation, PUIS allocation
        : taille(n) { donnees = new T[n]; }

    ~Conteneur() { delete[] donnees; }   // 4. le delete vit ICI, toujours

    void ajouter(const T& v);            // 5. on reçoit en const T&
    int  getTaille() const;              // 6. qui ne modifie pas est const
};

Le squelette de toutes les structures du cours : liste, pile, file, arbre, monceau. On la reconnaît à la paire new dans le constructeur / delete dans le destructeur — et dès qu'on la voit, on sait qu'il faut chercher les trois méthodes de la règle des trois.

L'itérateur, et l'interface uniforme

Dernier outil du langage, et il répond à une question qui va se poser à chaque structure : comment parcourir ? Un tableau se parcourt par indice. Une liste chaînée n'a pas d'indice : on suit des pointeurs. Un arbre se parcourt en descendant. Trois structures, trois manières — et donc trois façons d'écrire la même boucle, ce qui interdit d'écrire une fonction qui marcherait sur les trois.

L'itérateur est la réponse : un objet qui désigne une position dans un conteneur, et qui sait avancer. Peu importe ce qu'il y a derrière — indice, pointeur, descente d'arbre —, il expose toujours les deux mêmes gestes.

🧭 Le doigt qui suit la ligne

C'est ton doigt posé sur une liste de courses. *it veut dire « regarde ce que je désigne » ; ++it veut dire « avance d'un cran ». Tu n'as pas besoin de savoir si la liste est écrite sur une feuille, sur un rouleau ou sur des fiches attachées : tu lis, tu avances.

Le bloc ci-dessous montre les trois écritures d'un même parcours, de la plus explicite à la plus courte. Elles font exactement la même chose ; ce qu'il faut retenir, c'est la première, parce que c'est celle dont les deux autres sont des abréviations.

parcours.cpp
vector<int> v = {10, 20, 30, 40};

// 1. La forme complète : on part du début, on s'arrête à la fin.
for (vector<int>::iterator it = v.begin(); it != v.end(); ++it)
    cout << *it;

// 2. `auto` demande au compilateur de deviner le type. Même boucle.
for (auto it = v.begin(); it != v.end(); ++it)
    cout << *it;

// 3. La boucle « pour chaque » : le compilateur écrit la 2 pour toi.
//    `const int&` plutôt que `int` — on ne copie pas pour lire.
for (const int& x : v)
    cout << x;

La condition d'arrêt est it != v.end(), jamais it < v.end() : l'itérateur d'une liste chaînée ne sait pas se comparer par ordre, seulement par égalité.

Un mot sur end(), parce que c'est la source d'erreur numéro un de la STL. v.end() ne désigne pas le dernier élément : il désigne la position juste après le dernier. C'est une borne, pas un élément — une sentinelle. Le dessin mental correct est celui d'une règle graduée : les éléments sont entre les graduations, et end() est la dernière graduation, celle qui ferme. Elle sert à savoir qu'on est arrivé, et à rien d'autre.

Deux conséquences immédiates. *v.end() est un comportement indéfini : il n'y a rien à cet endroit. Et pour obtenir le dernier élément, il existe une méthode faite pour ça, v.back() — c'est elle qu'on emploie, pas une arithmétique sur end().

Un dernier couple à connaître, parce qu'il tombe régulièrement à l'examen : pour lire l'élément d'indice i d'un vector, deux écritures existent. v[i] ne vérifie rien — si i est hors des bornes, tu lis la mémoire du voisin, sans message. v.at(i) vérifie et lance une exception si l'indice est invalide. La seconde est plus sûre, la première est plus rapide ; le cours emploie les deux, et ce qu'on te demande, c'est de savoir laquelle protège.

⚠️ Piège fréquent

Modifier un conteneur pendant qu'on le parcourt. Ajouter un élément à un vector peut le forcer à déménager dans une zone plus grande ; tous les itérateurs qu'on tenait désignent alors l'ancienne zone, qui n'existe plus. On dit qu'ils sont invalidés. Le parcours continue pourtant, et lit n'importe quoi.

Le réflexe de diagnostic : devant une boucle qui produit des valeurs aberrantes — ou qui plante une fois sur cinq —, cherche d'abord si son corps ajoute ou retire quelque chose au conteneur qu'elle parcourt. Si oui, la faute est là, et le remède est de collecter d'abord, de modifier ensuite.

🧠 Quiz éclair

Cette boucle veut afficher le dernier élément. Que se passe-t-il réellement ?

vector<int> v = {10, 20, 30};

for (auto it = v.begin(); it != v.end(); ++it)
    cout << *it;

cout << *v.end();     // affiche 30 ?

Non : *v.end() ne désigne aucun élément du vecteur. La boucle, elle, est parfaitement correcte et affiche bien 10, 20, 30 — elle s'arrête avant end(), ce qui est exactement le rôle de la sentinelle.

La dernière ligne, en revanche, déréférence une position qui est en dehors du contenu. C'est un comportement indéfini : elle peut afficher 30 par coïncidence — la mémoire voisine n'a pas encore été réutilisée —, afficher n'importe quel nombre, ou faire planter le programme. Le pire des trois est le premier : un code faux qui donne la bonne réponse pendant les essais.

L'écriture correcte est cout << v.back();.

Le TAD : le quoi, et le comment

Ce module s'achève sur une idée qui n'est pas du C++ du tout, et qui gouverne pourtant tout le reste de la session. Elle tient en une distinction : ce qu'une structure permet de faire n'est pas la même chose que la façon dont elle est faite.

Quand tu demandes une pile, ce qui t'intéresse est : puis-je empiler, dépiler, regarder le sommet, savoir si elle est vide ? La réponse à ces quatre questions ne change pas selon qu'elle est construite avec un tableau ou avec une liste chaînée. Cette description — les opérations, et ce qu'elles promettent, sans un mot sur leur réalisation — s'appelle un type abstrait de données, ou TAD.

🧭 L'interrupteur

Tu pousses, la lumière s'allume. C'est l'interface, et elle tient en une phrase. Ce qu'il y a derrière — un contact mécanique, un relais, un transistor — varie d'un bâtiment à l'autre et ne te concerne pas. Tant que l'interface est respectée, tu peux remplacer le mécanisme sans rien changer à la façon dont on s'en sert.

L'intérêt n'est pas philosophique : il est très concret. Si deux implémentations respectent le même TAD, on peut les échanger sans toucher une ligne du code qui s'en sert. Ce qui change alors n'est pas ce que le programme fait, c'est ce qu'il coûte — et c'est précisément l'objet du cours. Le tableau ci-dessous est la carte de la session entière : à gauche les TAD, à droite les implémentations qu'on va étudier une par une.

Le TADSes opérationsSes implémentations possibles
Pile empiler, depiler, sommet, estVide tableau dynamique, liste chaînée
File enfiler, defiler, tete, estVide tableau circulaire, liste chaînée
File avec priorité inserer, extraireMax, estVide liste triée, liste non triée, monceau binaire
Ensemble inserer, supprimer, contient arbre de recherche, arbre AVL, table de dispersion
Dictionnaire inserer(cle, valeur), obtenir(cle), supprimer(cle) arbre de recherche, arbre AVL, table de dispersion

La question « quelle est la différence entre un TAD et son implémentation ? » est de celles qui tombent à l'examen, et une bonne réponse tient en trois temps qu'il vaut mieux avoir en tête que d'improviser. Un : énoncer la distinction — le TAD décrit les opérations et leur contrat, l'implémentation décrit la structure interne qui les réalise. Deux : donner un exemple concret avec deux implémentations du même TAD — une pile faite avec un tableau, une pile faite avec une liste chaînée. Trois : dire ce que le choix change, et c'est ce troisième temps qui rapporte les points — il ne change pas l'interface, il change les complexités et l'occupation mémoire.

C'est aussi l'annonce du module suivant. On vient de faire le tour des outils ; Les structures linéaires prend chacun des TAD de ce tableau et l'implémente pour de bon, en mesurant ce que chaque choix coûte.

🎓 À l'examen

Cette matière est celle de la semaine 2 et elle est évaluée à l'intra — mais rarement pour elle-même. Le C++ arrive dans les questions par-dessous : on te donne le code d'une liste chaînée à compléter, et ce sont les réflexes de ce module qui décident si tu écris un destructeur correct.

Sur la feuille manuscrite que l'examen autorise, ce que ce module mérite tient en peu de lignes : les trois méthodes de la règle des trois, la paire new[]/delete[], et le tableau des TAD ci-dessus. Le reste se comprend une fois et ne se recopie pas.

« Qu'est-ce qu'un type abstrait de données, et en quoi diffère-t-il de son implémentation ? »

Le TAD est la spécification : la liste des opérations disponibles et ce qu'elles garantissent, sans rien dire de la façon dont elles sont réalisées. L'implémentation est la structure interne qui les réalise. Une pile est un TAD ; « une pile faite avec un tableau dynamique » est une implémentation.

Le troisième temps est celui qui rapporte : changer d'implémentation ne change rien au code qui utilise la structure, mais change ses complexités et sa consommation mémoire. C'est pour cette raison que le cours étudie plusieurs implémentations du même TAD au lieu d'en imposer une.

« Cette classe alloue de la mémoire dans son constructeur. Quelles méthodes devez-vous définir, et pourquoi ? »

Les trois de la règle des trois : le destructeur, le constructeur de copie et l'opérateur d'affectation. Le pourquoi est la moitié attendue de la réponse : si on n'en écrit aucune, le compilateur en fournit des versions par défaut qui font une copie superficielle — deux objets porteraient la même adresse, donc la mémoire serait libérée deux fois. Et si on n'en écrit qu'une, le défaut subsiste par les deux autres.

« Quelle est la différence entre v[i] et v.at(i) ? »

at(i) vérifie que l'indice est dans les bornes et lance une exception sinon ; v[i] ne vérifie rien et lit la mémoire telle quelle si l'indice est invalide. Les deux sont en temps constant : la vérification ne change pas l'ordre de croissance, seulement la constante.

À retenir

La mémoire d'un programme a deux régions aux règles opposées : la pile, vidée automatiquement à la fin de chaque fonction, et le tas, où rien ne disparaît sans un delete. Une structure de données vit dans le tas, et c'est ce qui rend nécessaire tout le reste : un pointeur pour la désigner, un destructeur pour la rendre, et la règle des trois pour que la copier ne casse rien. Le geste qui résume le module tient en une question à poser devant chaque new : quelle ligne exécutera le delete ?

Trois formes te suivront jusqu'au dernier module : const T& pour recevoir un objet sans le copier, template<typename T> pour écrire une structure une seule fois, et la paire new dans le constructeur / delete dans le destructeur. Et une distinction gouverne tout : le TAD dit ce qu'on peut faire, l'implémentation dit ce que ça coûte.

Et ailleurs : ce module ne s'évalue pas pour lui-même, il s'encaisse. Chaque structure de la session sera présentée par du code qui emploie exactement ces éléments-là, et l'essentiel du temps qu'on gagne à les avoir compris se gagne en lecture. Au-delà du cours, la distinction TAD / implémentation est la même idée que celle d'une interface en Java ou d'un contrat de module : décrire ce qu'on promet séparément de la façon dont on le tient.

🗂️ L'aide-mémoire
Ce que delete fait de moins que delete[]
il n'appelle qu'un destructeur au lieu de n : il ne lit pas le nombre d'éléments. new T[n] exige delete[]
Les trois de la règle des trois
destructeur, constructeur de copie, operator=. On les écrit ensemble ou pas du tout
Ce que le compilateur fournit si tu n'en écris aucune
les trois, en version copie superficielle — l'adresse est copiée, pas le contenu
Les deux lignes propres à operator=, absentes du constructeur de copie
if (this != &autre) contre l'auto-affectation, et le delete[] de l'ancien contenu avant d'allouer le nouveau
Ce que promet le const écrit APRÈS la parenthèse
que la méthode ne modifie pas l'objet. Sans lui, elle est inappelable sur une const T&
Ce qu'on écrit pour recevoir un objet qu'on veut seulement lire
const T& : aucune copie — Θ(1) au lieu de Θ(n)
Où vit le corps d'une méthode de classe template
dans le .h. Ailleurs, l'erreur tombe à l'édition des liens, pas à la compilation
Ce que v.end() désigne
la position juste après le dernier élément — une borne, jamais un élément. Le dernier s'obtient par v.back()
v.at(i) contre v[i]
at vérifie les bornes et lance ; [] ne vérifie rien. Même complexité, pas la même sûreté
L'opérateur qu'un arbre de recherche exige de T
operator< — sans lui, l'arbre ne sait pas de quel côté descendre et ne compile pas
Ce qui sépare struct de class
la visibilité par défaut, et rien d'autre : public pour struct, private pour class