Module 1 - cours du 25 août

Concepts de base et allocation dynamique

Leçons Moodle « Concepts de base » et « Allocation dynamique » | Horstmann, Big C++: Late Objects, chapitres sur les classes et les pointeurs | Mis en pratique au TP1 (classes de pièces de robot, pointeurs bruts)

Classe et objetEncapsulationConstructeur et destructeurListe d'initialisationPile et tasnew / deleteTableaux dynamiques

Fil conducteur de tout le site : l'atelier de robotique des TP (batterie, moteurs, capteurs, pinces, robot). Chaque notion est illustrée avec ces classes, puis exercée avec des questions du type de celles des intras passés.

1. Une classe décrit, un objet existe

Une classe est un plan : elle décrit les attributs (les données) et les méthodes (les fonctions membres) de ses objets. Un objet (ou instance) est une variable construite d'après ce plan. Deux moteurs construits séparément ont chacun leur nom, leur masse et leur charge : modifier l'un ne modifie pas l'autre.

Vocabulaire
  • Interface d'une classe : l'ensemble de ses membres publics (ce que le reste du programme peut utiliser).
  • Implémentation : le code des méthodes et les attributs privés (le « comment »).
  • Encapsulation : l'état d'un objet n'est manipulable que par son interface. Les attributs sont donc privés et modifiés uniquement par des méthodes, ce qui empêche n'importe qui de mettre l'objet dans un état incohérent (une charge de moteur à 250 %, par exemple).

Par convention à Polytechnique (guide de codage) : attributs privés avec un suffixe _, noms de classes en CamelCase, méthodes en camelCase, accesseurs getX() et mutateurs setX(). La déclaration va dans un fichier d'en-tête .h, la définition des méthodes dans le .cpp correspondant, dans le même ordre que dans le .h (exigence des TP).

Lecture du code
  • Moteur::getNom : l'opérateur de portée :: dit « la méthode getNom de la classe Moteur ». Sans lui, on définirait une fonction libre du même nom.
  • #pragma once évite d'inclure deux fois le même en-tête (équivalent des gardes #ifndef MOTEUR_H).
  • Dans le .h on écrit std::string (jamais using namespace std; dans un en-tête : il s'imposerait à tous les fichiers qui l'incluent).

2. Constructeurs et destructeur

Le constructeur porte le nom de la classe, n'a pas de type de retour et s'exécute automatiquement à la création de l'objet. Le destructeur (~Moteur()) s'exécute automatiquement à sa destruction : il sert à libérer ce que l'objet possède (mémoire dynamique, fichier ouvert).

Type de constructeurSignatureQuand il est appelé
Par défautMoteur();Moteur m;, Moteur t[10];, new Moteur[5], vector<Moteur> v(3). Fourni automatiquement par le compilateur seulement si la classe ne déclare aucun constructeur.
Par paramètresMoteur(const string& nom, double m, double p);Moteur m("roue", 2, 40);, new Moteur("roue", 2, 40). Des valeurs par défaut (double p = 0) permettent à un même constructeur de servir de constructeur par défaut.
Par copieMoteur(const Moteur& autre);Moteur m2 = m1;, Moteur m2(m1);, passage par valeur, retour par valeur. Fourni automatiquement (copie membre à membre). Détails au module 4.

La liste d'initialisation

Entre la signature du constructeur et son corps, après :, on initialise les attributs. C'est la forme attendue dans les TP (pénalité de style sinon) et la seule possible pour les attributs constants, les références et les attributs objets sans constructeur par défaut.

Avec la liste d'initialisation

Chaque attribut est construit une seule fois, directement avec la bonne valeur.

Avec des affectations dans le corps

Les attributs sont d'abord construits par défaut, puis affectés. Plus lent, et impossible pour const, les références ou une classe de base.

Piège classique

Les attributs sont initialisés dans l'ordre de leur déclaration dans la classe, pas dans l'ordre de la liste. Écrire la liste dans un autre ordre déclenche l'avertissement -Wreorder (donc une pénalité au TP) et peut donner un attribut initialisé à partir d'un autre pas encore construit. Règle simple : même ordre dans la liste que dans le .h.

Quand le destructeur est-il appelé ?

  1. À la sortie de la portée (accolade fermante, fin de fonction) pour une variable locale, dans l'ordre inverse de construction.
  2. Au delete pour un objet dynamique.
  3. À la destruction d'un objet qui le contient (attribut, élément d'un vecteur).
  4. À la fin d'une expression pour un objet temporaire (par exemple f(Moteur("x", 1, 1))).

Pas à pas : construction et destruction d'objets automatiques

Suivez l'exécution ligne par ligne. Observez l'ordre des destructions à la fermeture de chaque bloc.

3. Accesseurs, mutateurs et méthodes

Comme les attributs sont privés, on fournit des accesseurs (getMasseKg(), en lecture seule, donc const, voir module 2) et des mutateurs (setCharge()) qui valident la nouvelle valeur. Un mutateur peut retourner bool pour signaler un refus (convention des TP : setCharge retourne false pour une charge hors de [0, 1], sans modifier l'objet).

Une méthode a accès à tous les membres de l'objet sur lequel elle est appelée, y compris les privés, et aux membres privés des autres objets de la même classe (la visibilité est par classe, pas par objet). C'est ce qui permettra d'écrire autre.nom_ dans un constructeur de copie.

4. Pile et tas : allocation automatique ou dynamique

Allocation automatique (pile)Allocation dynamique (tas)
SyntaxeMoteur m("roue", 2, 40);Moteur* p = new Moteur("roue", 2, 40);
Taille décidéeà la compilationà l'exécution (new int[n] avec n lu au clavier)
Durée de viejusqu'à la fin du bloc : destruction automatiquejusqu'au delete explicite ; sinon l'objet survit à la fonction (ou fuit)
Accès aux membresm.afficher(cout)p->afficher(cout), équivalent à (*p).afficher(cout)
Libérationautomatiquedelete p; pour un objet, delete[] t; pour un tableau
Pointeurs : rappel

Un pointeur contient une adresse. Moteur* p déclare un pointeur vers un Moteur ; &m donne l'adresse de m ; *p est l'objet pointé ; p->x équivaut à (*p).x. Un pointeur qui ne pointe rien doit valoir nullptr (jamais 0 ni NULL en C++ moderne). Déréférencer un pointeur nul ou pendant est un comportement indéfini (plantage typique).

Pas à pas : new, delete et fuite de mémoire

Le pointeur vit sur la pile, l'objet pointé sur le tas. Observez ce qui arrive à l'objet « bras ».

Objets et tableaux dynamiques

Question d'examen classique (intra H16)

Avec const int INTMAX = 10;, déclarer : (i) un tableau sur la pile de INTMAX objets AA : AA tableau1[INTMAX]; ; (ii) un tableau sur le tas de INTMAX objets AA : AA* tableau2 = new AA[INTMAX]; ; (iii) un tableau sur le tas de INTMAX pointeurs vers des AA alloués sur le tas : AA** tableau3 = new AA*[INTMAX]; puis tableau3[i] = new AA; pour chaque case.

Agrandir un tableau dynamique

Un tableau alloué avec new int[taille] a une taille fixe. Pour l'agrandir, on alloue un nouveau tableau plus grand, on copie les éléments, on libère l'ancien et on fait pointer la variable vers le nouveau. C'est exactement ce que std::vector fait pour vous (module 2).

Insertion à la position pos en conservant l'ordre : décaler les éléments de la fin vers pos (boucle décroissante, tab[i] = tab[i-1]), puis écrire tab[pos] = val. Suppression : décaler vers la gauche (boucle croissante, tab[i] = tab[i+1]). Il faut bien sûr qu'il reste de la capacité pour insérer.

Devinez l'affichage : objets automatiques et dynamiques mélangés

Explication

a est construit, puis l'objet dynamique (A2), puis b dans le bloc intérieur, détruit dès l'accolade fermante (~A3). delete p détruit A2 à ce moment précis. c est construit après. À la fin de main, les variables locales encore vivantes sont détruites dans l'ordre inverse de construction : c (A4) puis a (A1).

5. Erreurs de mémoire à reconnaître

ErreurExempleConséquence
Fuite de mémoirep = new Moteur(...) deux fois sans delete entre les deux ; oublier delete avant la fin d'une fonctionmémoire jamais rendue ; le destructeur n'est jamais appelé
Pointeur pendantutiliser p après delete p ; retourner l'adresse d'une variable localecomportement indéfini (valeurs aléatoires, plantage)
Double libérationdelete p; delete p; ; deux objets qui possèdent le même pointeur (copie superficielle, module 4)plantage
Mauvais deletedelete t sur un tableau alloué avec new[] ; delete sur une adresse qui ne vient pas de new (variable locale)comportement indéfini
Déréférencement nulMoteur* p = nullptr; p->afficher(cout);plantage immédiat
Règle d'or du cours

Qui fait new fait delete : chaque objet dynamique a un seul propriétaire responsable de le libérer, exactement une fois. Dans le TP1, le robot possède sa batterie et son châssis (il les libère dans son destructeur) mais seulement utilise les moteurs, capteurs et pinces (c'est l'appelant qui les libère). Les pointeurs intelligents (module 3) automatisent cette règle.

6. Organisation des fichiers et compilation

7. À retenir

Quiz rapide - Module 1

Une seule bonne réponse par question. Le meilleur score est mémorisé dans ce navigateur.

Qu'est-ce que l'interface d'une classe ?

  • L'ensemble de ses attributs
  • L'ensemble de ses membres publics
  • Le contenu de son fichier .cpp
  • Ses constructeurs seulement
L'interface est ce que le reste du programme peut utiliser : tous les membres publics (méthodes, et attributs publics s'il y en a). L'implémentation (attributs privés, corps des méthodes) peut changer sans toucher l'interface.

Une classe déclare un seul constructeur Capteur(const string& nom, double masse);. Laquelle de ces lignes ne compile pas ?

  • Capteur c("temp", 0.5);
  • Capteur* p = new Capteur("temp", 0.5);
  • Capteur tableau[3];
  • Capteur c = Capteur("temp", 0.5);
Dès qu'une classe déclare un constructeur, le compilateur ne fournit plus de constructeur par défaut. Un tableau d'objets exige un constructeur par défaut pour chaque élément : erreur de compilation.

Dans quel ordre les attributs sont-ils initialisés par la liste d'initialisation ?

  • Dans l'ordre de leur déclaration dans la classe
  • Dans l'ordre où ils apparaissent dans la liste
  • Dans l'ordre alphabétique
  • Dans l'ordre inverse de la liste
Toujours l'ordre de déclaration dans la classe. Si la liste est dans un autre ordre, clang émet -Wreorder ; la règle des TP est d'écrire la liste dans le même ordre que les attributs.

Quand le destructeur d'une variable locale est-il appelé ?

  • Jamais, sauf si on appelle delete
  • Seulement à la fin du programme
  • Quand on appelle explicitement ~Classe()
  • Automatiquement à la sortie de son bloc
Une variable automatique est détruite à l'accolade fermante du bloc où elle est déclarée, dans l'ordre inverse de la construction. On n'appelle jamais un destructeur à la main.

Que fait delete p; quand p pointe un objet alloué avec new ?

  • Il met p à nullptr
  • Il libère la mémoire sans appeler le destructeur
  • Il appelle le destructeur puis libère la mémoire ; p garde l'ancienne adresse
  • Il détruit aussi la variable p
Après delete p, le pointeur contient toujours l'adresse de la mémoire libérée (pointeur pendant). D'où la bonne pratique p = nullptr; juste après.

Quelle différence entre delete et delete[] ?

  • Aucune, ce sont des synonymes
  • delete[] libère un tableau alloué avec new[] et appelle le destructeur de chaque élément
  • delete[] libère plusieurs pointeurs à la fois
  • delete est réservé aux types fondamentaux
Un bloc alloué avec new T[n] doit être libéré avec delete[], qui détruit les n objets. Utiliser delete sur un tableau est un comportement indéfini.

Quelle déclaration réserve, sur le tas, un tableau de 10 pointeurs vers des objets AA ?

  • AA** t = new AA*[10];
  • AA* t = new AA[10];
  • AA t[10];
  • AA* t[10] = new AA;
new AA*[10] alloue 10 pointeurs (non initialisés) ; le résultat est un pointeur vers un pointeur, AA**. Chaque case devra ensuite recevoir son propre new AA.

Quel est l'affichage de ce programme ?

  • abc~c~b~a
  • ab~bc~a~c
  • ab~bc~c~a
  • abc~a~b~c
b est détruit dès la fin de son bloc. À la fin de main, c (construit en dernier) est détruit avant a.

Pourquoi garder les attributs privés ?

  • Pour économiser de la mémoire
  • Pour que l'état de l'objet ne soit modifiable que par ses méthodes, qui peuvent valider les valeurs
  • Parce que le compilateur l'exige
  • Pour accélérer l'exécution
C'est le principe d'encapsulation : l'implémentation est cachée, l'objet reste cohérent (par exemple setCharge refuse une charge hors de [0, 1]).

Quelle affirmation sur la pile et le tas est vraie ?

  • Les objets du tas sont détruits automatiquement à la fin de la fonction
  • La taille d'un tableau sur la pile peut être décidée à l'exécution
  • Un objet sur la pile doit être libéré avec delete
  • Un objet sur le tas survit à la fonction qui l'a créé tant qu'on ne fait pas delete
C'est tout l'intérêt (et le danger) de l'allocation dynamique : la durée de vie est contrôlée par le programmeur, pas par les blocs.

Dans une méthode de la classe Moteur, l'expression autre.nom_ (où autre est un autre Moteur) :

  • Compile : la visibilité privée est par classe, pas par objet
  • Ne compile pas : nom_ est privé
  • Compile seulement si autre est passé par valeur
  • Compile seulement dans le constructeur
Une méthode de Moteur peut accéder aux membres privés de n'importe quel Moteur. C'est indispensable pour le constructeur de copie et les opérateurs.

Ce code contient une erreur de mémoire. Laquelle ?

  • Double libération
  • Pointeur pendant
  • Fuite de mémoire : l'objet « p1 » n'est jamais libéré
  • Aucune, le code est correct
Le deuxième new écrase la seule adresse de « p1 ». Plus personne ne peut la libérer : fuite.

Que vaut un pointeur juste après int* p; dans une fonction ?

  • nullptr
  • Une valeur indéterminée (garbage)
  • 0
  • L'adresse de la première variable locale
Une variable locale non initialisée contient n'importe quoi. Toujours initialiser : int* p = nullptr;.

Combien de fois le destructeur de Moteur est-il appelé par delete[] parc; si parc = new Moteur[4] ?

  • 0 fois
  • 1 fois
  • Cela dépend du compilateur
  • 4 fois
delete[] détruit chaque élément du tableau (4 appels à ~Moteur) avant de libérer le bloc.

Où doit-on définir le corps de double Moteur::getMasseKg() const dans un TP ?

  • Dans Moteur.cpp, à la même position que sa déclaration dans Moteur.h
  • Dans main.cpp
  • Obligatoirement dans Moteur.h
  • N'importe où, l'ordre n'a pas d'importance pour la note
Règle des TP : méthodes non génériques définies dans le .cpp, dans le même ordre que dans le .h (pénalité de style sinon). Les classes et fonctions génériques restent dans le .h.

Exercices à développement (à faire sur papier ou dans un éditeur)

Résolvez avant de déplier l'indice ou la solution. Les énoncés marqués « Intra » viennent des séances de révision d'intras passés (A2018, H2019).

Classe complèteTP1

Écrire la classe Capteur

Un capteur a un nom, une masse en kg (positive ou nulle), une puissance en W et un état actif ou inactif (inactif à la construction). Il consomme sa puissance seulement lorsqu'il est actif. Écrivez la déclaration (Capteur.h) et la définition (Capteur.cpp) avec : un constructeur par paramètres (liste d'initialisation), les accesseurs getNom, getMasseKg, estActif, getConsommationW, le mutateur setActif(bool) et afficher(std::ostream&).

Indice

Quatre attributs : std::string nom_; double masseKg_; double puissanceW_; bool actif_;. La consommation est une expression conditionnelle : actif_ ? puissanceW_ : 0.0. Tous les accesseurs sont const.

Solution

Points à vérifier : liste d'initialisation dans l'ordre des attributs ; const sur tout ce qui ne modifie pas l'objet ; définitions dans le même ordre que les déclarations ; std:: dans l'en-tête.

IntraAffichage

Constructeurs, destructeurs et portées

Donnez l'affichage exact de ce programme.

Indice

Dans f(), l'objet dynamique « d » n'est jamais libéré : son destructeur ne s'affiche jamais (fuite, deux fois).

Solution
  1. a construit (+a). Appel de f : +f, +d, « | », puis fin de f : seule locale est détruite (-f). « d » fuit.
  2. +p puis -p immédiatement au delete.
  3. Bloc : +b, f() de nouveau (+f +d | -f), accolade fermante : -b.
  4. « fin », puis fin de main : -a. Les deux objets « d » ne sont jamais détruits.
IntraTableaux dynamiques

Insérer dans un tableau dynamique

Écrivez la fonction void inserer(int* tab, int taille, int val, int pos) qui insère val à l'indice pos d'un tableau contenant taille éléments valides, en décalant les éléments suivants pour conserver l'ordre. On suppose que la capacité allouée est suffisante (au moins taille + 1). Écrivez ensuite supprimer(int* tab, int taille, int pos).

Indice

Pour insérer, décalez de la fin vers pos (boucle décroissante) sinon vous écrasez des valeurs. Pour supprimer, décalez de pos vers la fin (boucle croissante).

Solution

Avec un std::vector<int>&, l'insertion devient tab.push_back(tab.back()); puis la même boucle de décalage à partir de tab.size() - 1 ; la suppression se termine par tab.pop_back();. Ou simplement tab.insert(tab.begin() + pos, val) et tab.erase(tab.begin() + pos) (module 2).

Intra H15Mémoire

Compter les blocs mémoire

En considérant que la classe Poney possède tous les constructeurs nécessaires, combien de blocs mémoire contenant un objet Poney sont alloués par ce programme ?

Indice

Une affectation (= entre deux objets existants) ne crée pas d'objet ; elle copie dans un bloc déjà alloué. Un objet temporaire Poney() occupe un bloc le temps de l'expression.

Solution

11 blocs.

  • poney1 : 1 ; groupePoney[6] : 6 ; le temporaire Poney() affecté à la case 1 : 1 ; poney2 : 1 ; poney3 : 1 (le temporaire est élidé directement dans poney3) ; new Poney() : 1. Total 11.
  • Les affectations groupePoney[0] = poney1, [2] = poney2, [3] = poney3, [4] = *poney4 copient le contenu dans des blocs existants : aucun nouveau bloc.
  • Remarque : le bloc de poney4 n'est jamais libéré (fuite).
DébogageMémoire

Trouver les erreurs de mémoire

Ce code compile. Identifiez chaque erreur de gestion de mémoire et corrigez-la.

Solution
  1. creerMoteur retourne l'adresse d'une variable locale, détruite à la sortie de la fonction : pointeur pendant. Correction : return new Moteur("temp", 1, 10); (et l'appelant fait delete), ou retourner l'objet par valeur.
  2. new Moteur[3] exige un constructeur par défaut (ici il n'existe pas : erreur de compilation en réalité) et doit être libéré avec delete[] t, pas delete t.
  3. b et c pointent le même objet : delete b; delete c; est une double libération. Un seul delete, puis les deux pointeurs à nullptr.
  4. a->afficher(cout) utilise un pointeur pendant.
ConceptionConstructeurs

Constructeurs et valeurs par défaut

La classe Batterie a un nom, une masse, une tension (V), une capacité totale (Wh), une énergie restante (Wh, égale à la capacité au départ) et un courant maximal (A). Écrivez un constructeur par paramètres qui permette aussi de construire une batterie sans argument (nom vide, toutes les valeurs à 0). Puis écrivez getPuissanceMaxW() et getTempsRestant(double puissanceW) : 0 si la puissance est négative ou dépasse le maximum, l'infini (INFINITY de <cmath>) si elle est nulle, sinon énergie restante / puissance.

Solution

Grâce aux valeurs par défaut, Batterie b; et Batterie t[3]; compilent. energieWh_ est initialisé à partir du paramètre capaciteWh, pas de l'attribut capaciteWh_ (qui, lui, est bien déjà construit puisqu'il est déclaré avant, mais la règle d'ordre rend la version par paramètre plus sûre).