Aller au contenu

ELCa11 - BE #2 — La classe Damier : programmation orientée objet en C++

Cours associé : Cours 2 — Programmation orientée objet (voir le polycopié, section Cours 2).


Objectifs

  • Mettre en œuvre une classe en C++ : déclaration, constructeur, méthodes, encapsulation.
  • Organiser le code en deux fichiers : .h (déclaration) et .cpp (implémentation).
  • Comprendre les conséquences de la gestion manuelle de la mémoire dans une classe : destructeur, constructeur de recopie, opérateur d’affectation.
  • Appréhender la règle des trois (Rule of Three).

Prérequis

  • BE #1, en particulier la séquence 5 (pointeurs et allocation dynamique).
  • Notions de POO en Python : classe, instance, méthode, attribut, héritage.

Principe

Le BE comporte deux parties :

  1. Partie 1 — Damier statique : premier contact avec la syntaxe C++ des classes, sur un tableau 2D de taille fixe.
  2. Partie 2 — Damier dynamique : on supprime la contrainte de taille fixe en allouant le tableau dynamiquement, ce qui pose de nouvelles questions de gestion mémoire.

Python → C++ : carte de correspondance

Quelques équivalences pour s’orienter quand on vient de Python :

Concept Python C++
Définir une classe class Foo: class Foo { … }; (point-virgule final !)
Constructeur def __init__(self, …): Foo(…) { … }
Référence à l’objet courant self (1ᵉʳ argument obligatoire) this (pointeur, souvent implicite)
Affecter un attribut self.x = 5 this->x = 5; ou simplement x = 5;
Visibilité (privé) convention _attr ou __attr mot-clé private:
Visibilité (public) par défaut mot-clé public:
Destructeur rare (GC automatique) ~Foo() { … } (manuel)
Copier un objet b = a (les deux désignent le même objet) Foo b = a; (nouvel objet, ctor de recopie)

Partie 1 — Le Damier statique

Notions à maîtriser

  • Déclaration d’une classe avec class, public:, private: ;
  • séparation .h (déclaration) / .cpp (implémentation) ;
  • header guards (#ifndef / #define / #endif) ;
  • constructeur avec valeur par défaut ;
  • définition d’une méthode hors classe : ClassName::methode() ;
  • tableau 2D statique en C++ : int T[L][C] ;
  • méthode const (qui ne modifie pas l’objet) ;
  • sortie d’erreur sur std::cerr.

Contexte

On souhaite représenter une grille de mesures d’entiers de dimensions fixes (4 lignes, 5 colonnes), comme par exemple les relevés de 20 capteurs disposés en grille. La classe DamierStat encapsule le tableau et fournit les méthodes pour l’initialiser, modifier une case et l’afficher.

Travail à réaliser

Créez un nouveau projet Qt Creator nommé Damier. Le projet contiendra trois fichiers :

  • damierstat.h — déclaration de la classe ;
  • damierstat.cpp — implémentation des méthodes ;
  • main.cpp — programme de test.

Implémentez la classe DamierStat avec les méthodes suivantes :

class DamierStat {
public:
    DamierStat(int val = 0);              // constructeur (init toutes les cases à val)
    void Init(int val = 0);               // (ré)initialise toutes les cases à val
    void Set(int x, int y, int val);      // modifie la case (x, y)
    int  Get(int x, int y) const;         // lit la case (x, y)
    void Print() const;                   // affiche la grille
private:
    int T[4][5];                          // tableau 4×5 d'entiers
};

Précisions

  • Set(x, y, val) : si (x, y) est hors damier, afficher un message d’erreur sur std::cerr et ne pas modifier la grille.
  • Get(x, y) : si (x, y) est hors damier, afficher un message d’erreur sur std::cerr et retourner 0 par convention. (Cette convention est discutable — un 0 légitime est indistinguable d’une erreur. On traitera ce problème proprement au BE #4, avec les exceptions.)
  • Print() : utiliser <iomanip> pour aligner les colonnes (par exemple std::setw(4) pour chaque case, vu en BE #1 séq. 2).

Aparté — std::cerr : c’est le flux de sortie d’erreur standard, jumeau de std::cout. Par convention, std::cout reçoit les messages “normaux” et std::cerr les messages d’erreur. Sur la console, les deux apparaissent ensemble, mais on peut les rediriger séparément (utile en pratique : ./prog > resultat.txt 2> erreurs.txt envoie les deux flux dans des fichiers distincts).

Programme de test (main.cpp)

Dans main(), écrire les tests suivants pour valider votre classe :

  1. Créer un objet DamierStat sans argument ; vérifier que la grille s’affiche bien remplie de 0.
  2. Créer un objet DamierStat avec valeur initiale 7 ; modifier la case (2, 4) à -2 ; afficher la grille.
  3. Tenter une modification hors damier (par exemple (10, 0)) ; vérifier qu’un message d’erreur s’affiche sur la console et que la grille n’est pas modifiée.
  4. Lire une case avec Get() et afficher la valeur lue.
  5. Créer un troisième damier par allocation dynamique (avec new), l’utiliser, puis le libérer avec delete avant la fin du programme. Tout objet, qu’il gère ou non de la mémoire dynamique, peut être créé soit en automatique (sur la pile), soit en dynamique (sur le tas).

Points à noter

  • En Python, self.x est obligatoire dans une méthode. En C++, this->x est possible mais souvent omis : tant qu’il n’y a pas d’ambiguïté (ex. un paramètre int x masquerait l’attribut x), on écrit simplement x.
  • Le constructeur DamierStat(int val = 0) permet deux usages sans surcharger : DamierStat D1; (val par défaut = 0) et DamierStat D2(7); (val explicite).
  • void Print() const : le const après la signature indique que la méthode ne modifie pas l’objet. À mettre sur toute méthode “lecture seule”. C’est une garantie utile et un bon style.
  • Le ; après }; à la fin de la déclaration de classe est obligatoire (oubli classique).
  • L’accès D3->Print() (avec une flèche) s’utilise via un pointeur ; D2.Print() (avec un point) s’utilise sur une instance directe. En Python, on n’a que la notation . car tout est implicitement référence.

Questions à se poser

  • Pourquoi met-on les attributs en private: plutôt qu’en public: ?
  • Que se passe-t-il si l’on oublie le delete D3; à la fin du programme ?
  • À quoi servent les header guards (#ifndef / #define / #endif) dans damierstat.h ? Que se passerait-il sans eux si le header est inclus deux fois ?
  • L’attribut T est int T[4][5], donc un tableau stocké à l’intérieur de l’objet. Combien d’octets occupe un objet DamierStat en mémoire (à peu près) ?

Partie 2 — Le Damier dynamique

Notions à maîtriser

  • Constructeur paramétré ;
  • destructeur ;
  • allocation et libération d’un tableau 2D dynamique (int**) ;
  • copie superficielle vs profonde ;
  • constructeur de recopie ;
  • surcharge de l’opérateur d’affectation = ;
  • la règle des trois (Rule of Three) ;
  • méthodes privées pour factoriser le code.

Contexte

La classe DamierStat a une limitation gênante : ses dimensions sont gravées en dur (4 × 5). Pour un usage réel, on veut paramétrer ces dimensions à la construction.

On va donc créer une nouvelle classe DamierDyn, équivalente fonctionnellement à DamierStat, mais dont le tableau est alloué dynamiquement selon des dimensions reçues en argument du constructeur.

Cette modification, en apparence anodine, ouvre la boîte de Pandore de la gestion mémoire.


Étape 1 — Constructeur paramétré et destructeur

L’attribut T n’est plus un int[4][5] mais un int** : un pointeur sur un tableau de pointeurs sur lignes.

Implémentez la classe DamierDyn avec, pour commencer, ces méthodes :

class DamierDyn {
public:
    DamierDyn(int l, int c, int val = 0);    // alloue et initialise à val
    ~DamierDyn();                            // libère la mémoire

    void Init(int val = 0);
    void Set(int x, int y, int val);
    int  Get(int x, int y) const;
    void Print() const;

private:
    int   L;       // nombre de lignes
    int   C;       // nombre de colonnes
    int** T;       // pointeur sur tableau de pointeurs
};

L’allocation 2D se fait en deux étapes (revoir au besoin BE #1 séquence 5) :

T = new int*[L];                    // tableau de L pointeurs
for (int i = 0; i < L; ++i) {
    T[i] = new int[C];              // chaque ligne : tableau de C entiers
}

La libération se fait dans l’ordre inverse :

for (int i = 0; i < L; ++i) {
    delete[] T[i];                  // libère chaque ligne
}
delete[] T;                         // libère le tableau de pointeurs
T = nullptr;

Quand est appelé le destructeur ? Automatiquement, à la fin de la durée de vie de l’objet : sortie de portée pour un objet automatique (DamierDyn D(3, 5);), ou delete pour un objet alloué dynamiquement (delete pD;). En Python, c’est le ramasse-miettes qui s’en occupe à un moment indéterminé ; en C++, c’est déterministe.

Aparté — la liste d’initialisation

Avant le { du corps du constructeur, on peut écrire : suivi d’une liste d’initialisation qui fixe la valeur initiale de chaque attribut :

DamierDyn::DamierDyn(int l, int c, int val) : L(0), C(0), T(nullptr) {
    Alloc(l, c);
    Init(val);
}

Ici, L(0), C(0), T(nullptr) initialise L = 0, C = 0, T = nullptr avant l’exécution du corps { ... }.

Quelle différence avec une affectation dans le corps ?

DamierDyn::DamierDyn(int l, int c, int val) {
    L = 0;           // ces affectations se font sur des attributs
    C = 0;           // déjà construits avec leur valeur par défaut.
    T = nullptr;     // → deux opérations : construction + affectation.
    Alloc(l, c);
    Init(val);
}

Pour les types primitifs (int, pointeurs…), la différence est marginale. Mais la liste d’initialisation devient obligatoire dans trois cas :

  1. Attribut const : un const ne peut pas être affecté après sa construction, il faut le construire à sa valeur définitive d’emblée.
  2. Attribut référence (T&) : une référence doit être liée à sa construction et ne peut pas être réaffectée.
  3. Appel au constructeur de la classe parente (héritage) : on y reviendra au BE #4.

C’est aussi la pratique recommandée en C++ moderne, même quand elle n’est pas strictement obligatoire : plus court, plus efficace, plus lisible.


Étape 2 — Le bug de la copie superficielle

Avec la classe ci-dessus (sans constructeur de recopie ni operator=), que se passe-t-il dans le code suivant ?

DamierDyn D1(3, 5, 7);
DamierDyn D2(D1);          // copie : D2 reçoit une copie de D1

Sans constructeur de recopie défini, le compilateur en génère un par défaut, qui copie les attributs un par un. Pour L et C (entiers), pas de souci. Pour T (int**)… il copie le pointeur, pas ce qu’il pointe :

Schéma de la copie superficielle

Conséquences catastrophiques :

  1. Modifier D2 modifie aussi D1 (et vice-versa) : les deux objets partagent la même mémoire.
  2. À la destruction, les deux destructeurs essaient de libérer le même tableau → double free → plantage assuré.

En Python, ce problème n’existe pas, car b = a ne fait pas de copie : les deux noms pointent vers le même objet, et le ramasse-miettes coordonne tout. En C++, le compilateur ne sait pas comment copier proprement notre classe : il faut le lui dire.

La solution : écrire nous-mêmes un constructeur de recopie qui fait une copie profonde.


Étape 3 — Constructeur de recopie

Ajoutez à la classe :

DamierDyn(const DamierDyn& D);     // constructeur de recopie

Ce constructeur doit :

  1. allouer un nouveau tableau 2D aux mêmes dimensions que D ;
  2. y recopier le contenu des cases de D.

Ainsi, DamierDyn D2(D1); crée bien deux objets indépendants, qui possèdent chacun leur propre mémoire.

Vérifiez ce comportement : modifiez une case de D2 et vérifiez que D1 est inchangé.


Étape 4 — Opérateur d’affectation =

Le code suivant pose le même problème que DamierDyn D2(D1); :

DamierDyn D1(3, 5);
DamierDyn D2(7, 2);
D2 = D1;                  // sans operator= défini : copie superficielle → catastrophe

Sans surcharge de operator=, le compilateur génère une affectation par défaut superficielle, avec les mêmes conséquences que précédemment (double free, état partagé).

Ajoutez à la classe :

DamierDyn& operator= (const DamierDyn& D);

Cette méthode doit :

  1. Vérifier l’auto-affectation (D2 = D2;) avec if (this != &D) — sans cela, on libérerait avant de copier.
  2. Libérer la mémoire actuellement détenue par *this.
  3. Réallouer aux dimensions de D.
  4. Recopier le contenu de D.
  5. Retourner *this par référence (permet l’enchaînement a = b = c;).

La règle des trois (Rule of Three)

En C++, dès qu’une classe gère elle-même de la mémoire dynamique (ou toute autre ressource : fichier ouvert, connexion réseau…), vous devez écrire les trois ensemble :

  1. le destructeur : ~Foo()
  2. le constructeur de recopie : Foo(const Foo&)
  3. l’opérateur d’affectation : Foo& operator=(const Foo&)

Si vous n’en écrivez qu’un, votre code compilera mais plantera dans certains scénarios. Cette règle est universellement connue et appliquée par les développeurs C++.


Étape 5 — Méthode ReDim()

Ajoutez une méthode publique permettant de redimensionner le damier :

void ReDim(int newL, int newC, int val = 0);

Elle doit :

  1. libérer la mémoire actuelle ;
  2. réallouer aux nouvelles dimensions ;
  3. initialiser toutes les cases à val.

Remarque : cette implémentation ne préserve pas le contenu existant, même pour les cases qui se trouveraient encore dans les nouvelles dimensions. C’est un choix de simplicité ; on pourrait imaginer une variante qui copie les cases communes, mais on n’ira pas jusque-là dans ce TD.

Suggestion : factorisation

Vous avez probablement remarqué que plusieurs méthodes manipulent l’allocation et la libération du tableau : le constructeur, le destructeur, le constructeur de recopie, operator=, ReDim. Pour éviter de répéter le même code, il est conseillé d’introduire deux méthodes privées :

private:
    void Alloc(int l, int c);    // alloue T pour des dimensions (l, c)
    void Free();                 // libère T et met le pointeur à nullptr

Ce n’est pas obligatoire, mais c’est une bonne pratique d’ingénierie : Don’t Repeat Yourself.

Programme de test

Dans main(), écrire les tests suivants pour valider votre classe :

  1. Créer un DamierDyn aux dimensions (4, 5) avec valeur initiale par défaut ; l’afficher.
  2. Créer un DamierDyn aux dimensions (3, 7) avec valeur initiale 5 ; modifier quelques cases avec Set() ; afficher.
  3. Tester le constructeur de recopie : créer un nouvel objet par recopie d’un précédent ; modifier une case de la copie et vérifier que l’original est inchangé.
  4. Tester operator= : créer un objet aux dimensions différentes ; lui affecter un autre objet via = ; vérifier qu’il est correctement redimensionné et indépendant.
  5. Tester l’auto-affectation : D = D; ne doit pas planter.
  6. Tester ReDim() : redimensionner un damier ; vérifier les nouvelles dimensions et que toutes les cases sont à la nouvelle valeur initiale.
  7. Créer un dernier objet par allocation dynamique (new/delete).

Points à noter

  • L’attribut T doit toujours pointer sur quelque chose de défini : soit alloué dynamiquement, soit nullptr. Un pointeur non initialisé est une bombe à retardement.
  • const DamierDyn& D (référence constante) en paramètre du constructeur de recopie et de operator= : pas de copie de l’argument, mais lecture seule garantie.
  • Le retour DamierDyn& (par référence) de operator= permet l’enchaînement a = b = c;. C’est l’idiome standard.
  • En Python, D2 = D1 ne crée pas de nouvel objet : les deux noms désignent le même. En C++, l’objet D2 existe déjà ; D2 = D1 modifie son contenu via operator=.

Questions à se poser

  • Pourquoi le test if (this != &D) dans operator= ? Que se passerait-il sans cette protection si l’utilisateur écrit D = D; ?
  • Quel est l’ordre d’appel des méthodes lors de DamierDyn D3 = D1; ? Constructeur de recopie ou operator= ?
  • Si vous écrivez le constructeur de recopie mais oubliez operator=, dans quel cas votre programme plantera-t-il ?
  • Combien d’octets sont alloués sur le tas pour un DamierDyn(100, 100) ? (À comparer avec un DamierStat.)

Partie 3 — Composition : la classe Jeu

Notions à maîtriser

  • Composition forte : un attribut est un objet d’une autre classe, par valeur ;
  • propagation automatique des constructeur, destructeur, constructeur de recopie et opérateur d’affectation vers les attributs-objets ;
  • conséquence : une classe qui ne fait que composer d’autres classes (sans pointeur nu) n’a pas besoin de Rule of Three.

Contexte

On veut représenter une partie de jeu jouée sur un damier, par un joueur nommé, avec un score qui évolue au fil des coups. On crée une classe Jeu qui regroupe :

  • un damier (le terrain) ;
  • le nom du joueur ;
  • le score courant ;
  • le nombre de coups joués.

Travail à réaliser

Implémentez la classe Jeu avec les méthodes suivantes :

#include <string>
#include "damierdyn.h"

class Jeu {
public:
    Jeu(const std::string& nomJoueur, int largeurDamier, int hauteurDamier);

    void jouer(int x, int y, int val);   // pose `val` en (x, y), MAJ score et nbCoups
    void afficher() const;                // affiche joueur, score, nbCoups, damier

    int  getScore()   const;
    int  getNbCoups() const;
    const std::string& getJoueur() const;

private:
    std::string nom;        // composition forte (par valeur)
    DamierDyn   terrain;    // composition forte (par valeur)
    int         score;
    int         nbCoups;
};

Précisions

  • jouer(x, y, val) : modifie la case (x, y) du terrain (via Set), ajoute val au score, incrémente nbCoups.
  • afficher() : affiche sur std::cout quelque chose comme :

    --- Jeu d'Alice ---
    Score : 25     Coups joués : 3
    [grille du damier]
    
  • Pas de constructeur par défaut : on impose un nom de joueur et des dimensions.

  • Le constructeur doit utiliser la liste d’initialisation pour construire terrain (qui n’a pas de constructeur sans argument). Voir l’aparté de la Partie 2.

Programme de test

Dans main(), vérifier ces comportements :

  1. Créer Jeu j1("Alice", 4, 5);. L’afficher : score 0, coups 0, grille remplie de 0.
  2. Jouer plusieurs coups (j1.jouer(0, 0, 5);, j1.jouer(2, 3, 10); etc.). Vérifier que score et nbCoups évoluent comme prévu.
  3. Tester la copie sans avoir écrit de constructeur de recopie :

    Jeu j2 = j1;            // copie
    j2.jouer(1, 1, 99);     // modifie j2
    j1.afficher();          // j1 INCHANGÉ !
    j2.afficher();
    

    Le constructeur de recopie généré par défaut délègue la copie à chaque attribut : nom est copié (\C{std::string} sait se copier), terrain est copié (via le constructeur de recopie de DamierDyn que vous avez écrit en Partie 2 !), score et nbCoups sont des int (copiés trivialement). 4. Tester l’affectation : Jeu j3("Bob", 2, 2); j3 = j1;. Même observation. 5. Tester la destruction : à la sortie de main(), tous les objets sont détruits. Vérifier (avec un débogueur ou des messages dans les destructeurs) que le destructeur de DamierDyn est bien appelé une fois pour chaque Jeu.

Points à noter

  • Jeu n’a pas besoin de Rule of Three : pas de pointeur nu géré directement par Jeu, donc les comportements générés par défaut conviennent. C’est l’argument décisif pour préférer composer des classes (qui se gèrent elles-mêmes) plutôt qu’allouer manuellement des pointeurs.
  • La composition forte lie la durée de vie des membres à celle du contenant : terrain est construit avec le Jeu, détruit avec lui.
  • Si on remplaçait DamierDyn terrain; par DamierDyn* terrain; (pointeur), il faudrait new/delete dans Jeu et écrire la Rule of Three pour Jeu (puisque Jeu posséderait alors directement de la mémoire dynamique). Plus complexe, plus risqué.
  • Anticipation BE #4 : composition (« a-un ») et héritage (« est-un ») sont deux manières différentes de combiner des classes. On verra l’héritage au BE #4.

Questions à se poser

  • Que se passe-t-il si on essaie d’écrire Jeu j; (sans argument) ? Pourquoi le compilateur refuse-t-il ?
  • Pourquoi getJoueur() retourne-t-il const std::string& plutôt que std::string ?
  • Si on remplaçait DamierDyn terrain; par DamierDyn* terrain;, quelles méthodes faudrait-il ajouter à Jeu ?
  • Le destructeur de Jeu n’est pas explicité. Que fait-il quand même ?

Bonus — historique des coups

Ajouter à Jeu un attribut std::vector<Coup> (où Coup est une petite structure ou classe interne contenant x, y, val) et une méthode historique() qui affiche les coups joués depuis le début, dans l’ordre.

struct Coup { int x, y, val; };

class Jeu {
    // ...
private:
    std::vector<Coup> coups;
};

Implémenter jouer(x, y, val) pour aussi push_back un Coup dans coups, et historique() pour les afficher tous.


Aller plus loin — vers la surcharge d’opérateurs (BE #3)

Votre classe DamierDyn permet déjà de créer, modifier, copier, redimensionner un damier. La prochaine étape est de la rendre plus expressive en ajoutant des opérateurs : D1 + D2 pour additionner deux damiers, D1 += 3 pour ajouter une valeur partout, std::cout << D1 pour l’afficher. Au passage, on découvrira la surcharge d’opérateurs et les classes templates (pour généraliser au-delà des int).