Banque de cours - Cours 9 - Classes & Programmation orientée objet
21 min de lecture
# Cours 9 - Classes & Programmation orientée objet
Où est Charlie le Bug ? (15 minutes)
#include <iostream>using namespace std;int main() { int scores[5] = {90, 85, 78, 92, 88}; for (int i = 0; i <= 5; i++) { cout << scores[i] << endl; } vector<int> nums; cout << nums[0]; string name = "Charlie"; char first = name[100]; cout << first; vector<int> data {1, 2, 3}; for (int i = 0; i < data.size(); i++) { cout << data[i] << endl; if (data[i] == 1) data.erase(data.begin() + i); } return 0;}
Solution
#includd <iostream>using namespace std;int main() { int scores[5] = {90, 85, 78, 92, 88}; for (int i = 0; i <= 5; i++) { // Bug 1: off-by-one! i <= 5 goes to index 5 (out of bounds) cout << scores[i] << endl; } vector<int> nums; //cout << nums[0]; // Bug 2: accessing empty vector - undefined behavior! string name = "Charlie"; char first = name[100]; // Bug 3: out of bounds - undefined behavior! cout << first; // Bug 4: Impression d'une valeur UB ! vector<int> data {1, 2, 3}; for (int i = 0; i < data.size(); i++) { cout << data[i] << endl; if (data[i] == 1) data.erase(data.begin() + i); // Bug 5: modifying container while iterating - skips elements } return 0;}
struct vs class
struct Point { double x; // public by default in struct double y;};class Point { double x; // private by default in class double y;public: // ... public interface};
Règle générale : Utiliser struct pour les simples conteneurs de données, class pour les types avec comportement.
Fondamentaux des classes
Classes et Objets en C++
En programmation orientée objet, les classes et les objets sont des concepts fondamentaux qui permettent de représenter des entités et des concepts du monde réel.
Une classe est un modèle (ou gabarit) servant à créer des objets ayant des propriétés et des comportements similaires. On peut dire qu’une classe est le plan de construction d’un objet.
Un objet est une instance d’une classe. Par exemple, le type animal Chien est une classe, tandis qu’un chien particulier nommé Tommy est un objet de la classe Chien.
Les Classes en C++
Une classe est un type de données défini par l'utilisateur, qui regroupe ses propres membres de données (data members) et fonctions membres (member functions), accessibles en créant une instance de cette classe. Une classe C++ est comme le plan de construction d'un objet.
Créer une classe : Une classe doit être définie avant d’être utilisée. En C++, on la définit avec le mot-clé class.
#include <iostream>using namespace std;// Définition de la classeclass GfG{ public: // Membre de données int val; // Fonction membre void show() { cout << "Valeur : " << val << endl; }};int main(){ // Les objets seront déclarés ici GfG myGfG; myGfG.val = 2; myGfG.show(); return 0;}
Dans l’exemple ci-dessus, la classe GfG est créée avec un membre de données val et une fonction membre show(). La fonction membre est ici définie à l’intérieur de la classe, mais elle peut aussi être simplement déclarée dans la classe puis définie à l’extérieur à l’aide de l’opérateur de résolution de portée::.
Ce qui précède s’appelle une définition de classe ou gabarit de classe.
Les Objets en C++
Quand une classe est définie, seules les spécifications (attributs et comportements) de l'objet sont établies. Aucune mémoire n'est allouée à la définition de la classe. Pour utiliser les données et accéder aux fonctions définies dans la classe, il faut créer des objets.
Les objets sont les entités réelles créées en tant qu’instances d’une classe. On peut créer autant d’objets d’une classe que l’on souhaite.
Créer un objet : Une fois la classe définie, on peut créer un objet de la même façon qu’on déclare une variable d’un type intégré :
NomClasse nomObjet;
Accès aux membres :
À l’intérieur de la classe, on accède aux membres directement par leur nom.
À l’extérieur, on utilise l’opérateur point (.) avec l’objet de la classe.
Les membres de données s’accèdent via objet.membre et les fonctions via objet.membre().
Classe Locale
Les classes sont généralement déclarées dans la portée globale et sont accessibles à toutes les fonctions ou autres classes une fois définies. Cependant, C++ permet aussi de définir une classe à l'intérieur d'une fonction. On parle alors de classe locale, qui n'est accessible qu'au sein de cette fonction.
Classe Imbriquée (Nested Class)
Une classe imbriquée est une classe définie à l'intérieur d'une autre classe (la classe englobante). En tant que membre de la classe englobante, elle dispose des mêmes droits d'accès que n'importe quel autre membre. Les membres de la classe englobante n'ont pas d'accès spécial aux membres de la classe imbriquée ; les règles d'accès standard s'appliquent.
#include <iostream>using namespace std;class Outer{ public: // Classe imbriquée à l'intérieur de Outer class Inner { public: void display() { cout << "Ceci est la classe Inner" << endl; } }; void show() { cout << "Ceci est la classe Outer" << endl; }};int main(){ // Création d'un objet de la classe Outer Outer outerObj; outerObj.show(); // Création d'un objet de la classe Inner via Outer Outer::Inner innerObj; innerObj.display(); return 0;}
Résultat :
Ceci est la classe OuterCeci est la classe Inner
Classe Enum (Enum Class)
Les classes enum en C++ offrent une façon plus sûre et plus organisée d'utiliser les énumérations. Elles permettent de regrouper des constantes liées tout en évitant les conflits de noms et en garantissant une meilleure sécurité de type.
Classe vs Objet
Le tableau suivant résume les principales différences entre classes et objets en C++ :
Classe
Objet
Un plan ou gabarit pour créer des objets.
Une instance d’une classe avec des valeurs réelles.
Aucune mémoire n’est allouée tant qu’un objet n’est pas créé.
La mémoire est allouée lors de la création d’un objet.
Entité conceptuelle décrivant structure et comportement.
Entité réelle créée à partir de la classe.
Définit les propriétés et fonctions communes à tous les objets du type.
Stocke des données spécifiques et les manipule via les fonctions de la classe.
Représente un concept ou un type général.
Représente une instance spécifique de la classe.
Définir une classe avec un constructeur
// Defining a class constructor#include <iostream>#include <format>class Box{public: // Constructor Box(double length, double width, double height) { std::cout << std::format("Box constructor called.") << std::endl; m_length = length; m_width = width; m_height = height; } // Function to calculate the volume of a box double volume() { return m_length * m_width * m_height; }private: double m_length {1.0}; double m_width {1.0}; double m_height {1.0};};int main(){ Box firstBox {80.0, 50.0, 40.0}; // Create a box double firstBoxVolume {firstBox.volume()}; // Calculate the box volume std::cout << std::format("Volume of Box object is {}", firstBoxVolume) << std::endl; // Box secondBox; // ERROR: no default constructor!}
Que se passe-t-il s'il n'y a pas de constructeur défini dans une classe C++ ?
Si aucun constructeur n'est défini, le compilateur en génère un automagiquement : le constructeur par défaut implicite.
class Chat {public: std::string nom; int age; float poids;};// Fonctionne : le compilateur a genere un constructeur par defautChat c; // Les membres sont initialises par defaut
C’est ici que se cache le piege. Le comportement depend de l’endroit ou l’objet est cree et du type du membre.
Type de membre
Stack (Chat c;)
Heap (new Chat)
Possède son propre constructeur
int, float, etc.
Valeur indéterminée
Valeur indéterminée
Non
std::string, std::vector, etc.
Initialise correctement
Initialise correctement
Oui
Classe ou struct imbriquée
Depend de cette classe
Depend de cette classe
Variable
Chat c;std::cout << c.age; // Comportement indéfini : valeur garbagestd::cout << c.nom; // Chaine vide : std::string a son propre constructeur
Le compilateur ne génère pas de constructeur par défaut si vous avez défini n’importe quel autre constructeur.
class Chien {public: // Un constructeur custom est defini : plus de constructeur par defaut Chien(std::string nom, int age) { ... }};Chien d; // Erreur de compilation : pas de constructeur par defautChien d("Rex", 3); // Correct
Vous pouvez le restaurer explicitement avec = default :
class Chien {public: Chien() = default; // Restaure le constructeur implicite Chien(std::string nom, int age) { ... }};
Ou l’interdire explicitement avec = delete :
class Singleton {public: Singleton() = delete; // Aucune construction par defaut autorisee};
Si votre classe gère une ressource (pointeur brut, descripteur de fichier…), le constructeur généré automagiquement ne sera pas suffisant. C’est là que s’applique la Règle de Trois / Cinq : destructeur, constructeur de copie, opérateur d’affectation par copie, et leurs variantes de déplacement (move) introduites en C++11. Ce sujet mérite son propre cours.
Le compilateur vous offre un constructeur gratuitement, mais les membres de types primitifs (int, float, etc.) contiennent des valeurs indéterminées.
☝🏼
Bref: Initialisez toujours vos membres explicitement.
Spécificateurs d'accès
class Character{public: // Anyone can access std::string getName() const { return m_name; } int getHealth() const { return m_health; } void takeDamage(int amount) { m_health -= amount; }protected: // Only this class and derived classes int m_level {1};private: // Only this class std::string m_name; int m_health {100};};
Listes d'initialisation (à privilégier !)
class Box{public: // Better: use initialization list Box(double length, double width, double height) : m_length{length}, m_width{width}, m_height{height} { // Constructor body (can be empty!) // m_length = length; // m_width = width; // m_height = height; }private: double m_length; double m_width; double m_height;};
Ce que tu vois s’appelle un member initializer list (ou simplement initializer list) en anglais.
En C++, les membres d’une classe sont construits avant que le corps du constructeur ne s’execute. Si tu fais ca :
Box(double length, double width, double height) { m_length = length; // Ce n'est PAS une initialisation, c'est une affectation}
m_length, m_width, m_height sont d’abord construits par défaut (valeur garbage pour un double)
Puis ecrases par l’affectation dans le corps
C’est un travail en double. Avec l’initializer list :
Les membres sont directement construits avec la bonne valeur, en une seule étape.
NomDuConstructeur(params) : membre1{val1}, membre2{val2}, membre3{val3}{ // corps optionnel}
Le : introduit la liste, et les membres sont séparés par des virgules.
class Moteur {public: const int cylindres; // const : ne peut etre affecté, seulement initialisé int& code_moteur; // reference : doit etre liée a la construction Turbo turbo; // classe sans constructeur par defaut Moteur(int cyl, int& code, Turbo t) : cylindres{cyl}, code_moteur{code}, turbo{t} // obligatoire ici {}};
Un membre const ou une référence ne peut pas être affecté après construction. L’initializer list est le seul endroit ou tu peux les initialiser.
L’ordre dans lequel les membres sont initialises est l’ordre de declaration dans la classe, pas l’ordre dans la liste. C’est un piège classique :
class Probleme { int a; int b;public: Probleme(int val) : b{val}, a{b} // a est initialisé AVANT b malgré l'ordre écrit {} // a reçoit une valeur garbage};
Bonne pratique : écrire la liste dans le même ordre que les déclarations de membres.
Préférer systématiquement l’initializer list au corps du constructeur. C’est plus efficace, c’est parfois obligatoire, et cela reflète mieux ce qui se passe réellement à l’exécution.
Fonctions membres const
class Circle{public: Circle(double radius) : m_radius{radius} {} double area() const // const = this function doesn't modify the object { return std::numbers::pi * m_radius * m_radius; } void setRadius(double r) // NOT const - it modifies the object { m_radius = r; }private: double m_radius;};
Règle : Marquez toute fonction membre qui ne modifie pas l’objet comme const.
Valeurs par défaut dans les constructeurs C++
Quand tu écris :
Le destructeur en C++ (Destructor)
Le ~ désigne le destructeur d'une classe. C'est une méthode spéciale appelée automagiquement quand un objet est détruit. C'est le pendant exact du constructeur : le constructeur est appelé à la naissance de l'objet, le destructeur à sa mort.
Enemy() { ++s_count; } // appele quand l'objet est cree~Enemy() { --s_count; } // appele quand l'objet est detruit
Quand est-ce qu'un objet est détruit
void maFonction() { Enemy e1; // constructeur appelé : s_count = 1 Enemy e2; // constructeur appelé : s_count = 2} // e1 et e2 sortent du scope, les deux destructeurs sont appelés : s_count = 0
Les objets sur le stack sont détruits automatiquement quand ils sortent de leur scope. Pour les objets sur le heap, le destructeur est appelé au moment du delete.
Enemy* e = new Enemy(); // constructeur : s_count = 1delete e; // destructeur : s_count = 0
Sa signature
Le destructeur a toujours la meme forme : le ~ suivi du nom de la classe, sans paramètres et sans type de retour. Il ne peut y en avoir qu'un seul par classe.
~Enemy() { /* code de nettoyage */ }
Quand doit-on en écrire un explicitement
Le compilateur génère un destructeur vide par défaut, ce qui est suffisant dans la majorité des cas. Tu dois en écrire un quand ta classe gère une ressource manuellement : un pointeur brut alloué avec new, un fichier ouvert, une connexion réseau. C'est la troisième pièce de la Règle de Trois mentionnée dans le section sur les constructeurs implicites.
Stack (pile) vs Heap (tas)
Le stack est une zone mémoire gérée automatiquement par le programme. Quand tu déclares une variable locale dans une fonction, elle va sur le stack. Quand la fonction se termine et que la variable sort de son scope, la memoire est libérée automatiquement. C'est rapide, c'est propre, mais la taille est limitée et la durée de vie est liée au scope.
void maFonction() { Enemy e1; // sur le stack, géré automatiquement} // e1 est détruit ici, destructeur appelé automatiquement
Le heap est une zone mémoire que tu gères toi-même. Tu alloues avec new, et tu dois libérer avec delete. La durée de vie n’est pas liée au scope, ce qui est utile quand tu veux qu’un objet survive a la fonction qui l’a créé. Mais si tu oublies le delete, la mémoire reste occupée jusqu’a la fin du programme : c’est une fuite memoire (memory leak).
void maFonction() { Enemy* e = new Enemy(); // sur le heap, tu gères la mémoire delete e; // destructeur appelé, mémoire libérée}
En C++ moderne, on évite le plus possible les new et delete manuels. On utilise des smart pointers comme std::unique_ptr ou std::shared_ptr qui gèrent le heap automatiquement pour toi, avec la garantie de ne pas oublier le delete. Mais ça, c’est un sujet à part entière.
Encapsulation & conception
L'encapsulation est le principe qui consiste a regrouper les données et les fonctions qui les manipulent dans une même unité (la classe), tout en contrôlant ce qui est accessible de l'extérieur.
En pratique, ca veut dire que les membres d’une classe sont divisés en deux categories : ce qui est privé (les donnÉes internes) et ce qui est public (l’interface que tu exposes au reste du programme).
// Pas de classe, tout est accessible directementdouble sante = 100.0;sante = -9999.0; // Rien ne t'en empeche, meme si c'est absurde
class Personnage {public: void setSante(double valeur) { if (valeur < 0.0) valeur = 0.0; // on valide avant d'accepter if (valeur > 100.0) valeur = 100.0; m_sante = valeur; } double getSante() const { return m_sante; }private: double m_sante{100.0}; // inaccessible directement de l'exterieur};Personnage p;p.m_sante = -9999.0; // erreur de compilation : membre privép.setSante(-9999.0); // accepte, mais corrige a 0.0 automatiquement
Les trois niveaux d’accès
class MaClasse {public: // accessible par tout le mondeprotected: // accessible par la classe elle-meme et ses classes derivees (heritage)private: // accessible uniquement par la classe elle-meme};
La validation des données. Comme dans l’exemple ci-dessus, tu peux imposer des règles metier directement dans le setter. Une santé négative n’a pas de sens, le setter le garantit.
La liberté de modifier l’implementation. Si tu changes la façon dont m_sante est stockée en interne (par exemple tu passes d’un double a un int), le reste du programme n’a pas besoin de changer tant que l’interface publique reste la meme.
La lisibilité et la maintenance. En lisant une classe bien encapsulée, tu sais immédiatement ce qu’elle expose et ce qu’elle cache. Tu n’as pas besoin de comprendre ses entrailles pour l’utiliser.
Tu as probablement remarqué le préfixe m_ sur les membres prives. C’est une convention très répandue en C++ pour distinguer visuellement les membres de la classe des variables locales. Ce n’est pas une obligation du langage, mais c’est une bonne habitude à prendre.
Membres static
class Enemy{public: Enemy() { ++s_count; } ~Enemy() { --s_count; } static int getCount() { return s_count; } // No object needed to callprivate: static int s_count; // Shared by ALL Enemy objects};int Enemy::s_count {0}; // Must be defined outside the class// Usage:Enemy e1, e2, e3;std::cout << std::format("Total enemies: {}", Enemy::getCount()) << std::endl; // 3
Lab :Concevoir une classe Vector2D
Le fichier Vector2D.h (La déclaration de la classe et ses membres)
Le fichier main.cpp (l’utilisation de la class Vector2D)
#include <iostream>#include <format>#include "Vector2D.h"int main(){ Vector2D a {3.0, 4.0}; Vector2D b {1.0, 2.0}; std::cout << std::format("Magnitude of vector a is : {}", a.magnitude()) << std::endl; Vector2D c = a + b; std::cout << std::format("Vector c is [{},{}] of magnitude {:.2f}", c.getX(), c.getY(), c.magnitude()) << std::endl; return 0;}
Travail personnel
Lecture
Lire Beginning C++23 Chapitres 8-9
Lire Tour of C++ Chapitres 4-5
Défi 1 - Surcharge d'opérateurs
Ajoutez deux opérateurs à la classe Color :
**operator==** - Compare deux couleurs par leurs composantes R, G, B (le nom n’est pas pris en compte).
**operator+** - Mélange deux couleurs en faisant la moyenne de leurs composantes et en combinant leurs noms avec " + ".
// Déclarations à ajouter dans la section public de Color (colors.h) :bool operator==(const Color& other) const;Color operator+(const Color& other) const;
Testez dans main.cpp :
Color a {"Rouge", 255, 0, 0};Color b {"Bleu", 0, 0, 255};if (a == b) { std::cout << "Identiques !\\n"; }else { std::cout << "Différentes !\\n"; }Color somme = a + b;somme.display(); // "Rouge + Bleu" R:127 G:0 B:127
Défi 2 - Setters avec validation
Ajoutez des setters qui démontrent la valeur de l'encapsulation :
// Déclarations à ajouter dans la section public de Color (colors.h) :void setR(int value);void setG(int value);void setB(int value);
Chaque setter doit clamper la valeur dans [0, 255] avant de l’assigner - exactement comme le constructeur. C’est l’avantage de l’encapsulation : on ne peut jamais mettre une valeur invalide !
Testez dans main.cpp :
Color c {"Test", 100, 100, 100};c.setR(999); // clampé à 255c.setG(-50); // clampé à 0c.display(); // R:255 G:0 B:100
Défi 3 - Couleur inversée (avancé)
Ajoutez une méthode qui retourne la couleur complémentaire (l'inverse) :
// Déclaration à ajouter dans la section public de Color (colors.h) :Color invert() const;
La couleur inversée se calcule comme : (255 - r, 255 - g, 255 - b). Le nom de la couleur inversée sera "Inv:" + m_name.
Testez en affichant des paires de couleurs avec leur inverse :
Color tomate {"Tomato", 255, 99, 71};Color inv = tomate.invert();std::cout << "--- Original et inverse ---\\n";tomate.display(); // R:255 G:99 B:71inv.display(); // R:0 G:156 B:184
Solutions des défis
⚠️ Essayez d’abord par vous-même avant de regarder !
Solution - Défi 1
Déclarations dans **colors.h** (section **public** de **Color**) :
// Compare deux couleurs par composantes RGB (le nom est ignoré).// other est BY CONST REFERENCE - on ne modifie pas l'autre couleur.// const - ne modifie pas l'objet courant.bool operator==(const Color& other) const;// Mélange deux couleurs (moyenne des composantes, concaténation des noms).// other est BY CONST REFERENCE.// const - ne modifie pas l'objet courant.// Retourne un nouveau Color BY VALUE.Color operator+(const Color& other) const;
Définitions dans **colors.cpp** :
// ------------------------------------------------------------// operator== - compare les composantes RGB uniquement// Le nom est intentionnellement ignoré : deux couleurs avec// des noms différents mais les mêmes composantes sont "égales".// ------------------------------------------------------------bool Color::operator==(const Color& other) const { return m_r == other.m_r && m_g == other.m_g && m_b == other.m_b;}// ------------------------------------------------------------// operator+ - mélange deux couleurs par moyenne// Retourne un nouveau Color avec le nom combiné// ------------------------------------------------------------Color Color::operator+(const Color& other) const { return Color{m_name + " + " + other.m_name, (m_r + other.m_r) / 2, (m_g + other.m_g) / 2, (m_b + other.m_b) / 2};}
Utilisation dans **main.cpp** :
Color a {"Rouge", 255, 0, 0};Color b {"Bleu", 0, 0, 255};Color c {"Rouge2", 255, 0, 0};std::cout << "--- Test operator== ---\\n";if (a == b) { std::cout << "a et b sont identiques\\n"; }else { std::cout << "a et b sont différentes\\n"; } // Différentesif (a == c) { std::cout << "a et c sont identiques\\n"; } // Identiques (mêmes RGB)else { std::cout << "a et c sont différentes\\n"; }std::cout << "\\n--- Test operator+ ---\\n";Color somme = a + b;somme.display(); // "Rouge + Bleu" R:127 G:0 B:127
Solution - Défi 2
Déclarations dans **colors.h** (section **public** de **Color**) :
// Modifie la composante rouge avec clamping automatique.// value est BY VALUE - petit int, copie triviale.void setR(int value);// Modifie la composante verte avec clamping automatique.void setG(int value);// Modifie la composante bleue avec clamping automatique.void setB(int value);
Définitions dans **colors.cpp** :
// ------------------------------------------------------------// Setters - chaque setter utilise clamp() pour garantir [0, 255]// C'est l'avantage de l'encapsulation : la validation est centralisée// ------------------------------------------------------------void Color::setR(int value) { m_r = clamp(value); }void Color::setG(int value) { m_g = clamp(value); }void Color::setB(int value) { m_b = clamp(value); }
Utilisation dans **main.cpp** :
std::cout << "--- Test setters avec clamping ---\\n";Color c {"Test", 100, 100, 100};c.display(); // R:100 G:100 B:100c.setR(999); // clampé à 255c.setG(-50); // clampé à 0c.setB(128);c.display(); // R:255 G:0 B:128std::cout << "Sans encapsulation, on aurait pu écrire c.m_r = -500 !\\n";std::cout << "Avec les setters, les valeurs sont toujours valides.\\n";
Solution - Défi 3
Déclaration dans **colors.h** (section **public** de **Color**) :
// Retourne la couleur complémentaire (255 - chaque composante).// const - ne modifie pas l'objet courant.// Retourne un nouveau Color BY VALUE.Color invert() const;
Définition dans **colors.cpp** :
// ------------------------------------------------------------// invert - retourne la couleur complémentaire// Calcul : (255 - r, 255 - g, 255 - b)// Le nom est préfixé par "Inv:" pour identifier l'inverse// ------------------------------------------------------------Color Color::invert() const { return Color{"Inv:" + m_name, 255 - m_r, 255 - m_g, 255 - m_b};}
Point clé : La méthode invert() est const - elle ne modifie pas l’objet courant, elle retourne un nouvel objet. C’est un patron de conception courant : les opérations qui transforment un objet retournent une copie modifiée plutôt que de modifier l’original.