Héritage et relations est-un
Apprenez l'héritage public, le chaînage de constructeurs, le problème du découpage, l'héritage multiple et le problème du diamant — et quand l'héritage est approprié.
L'héritage modélise le « est-un »
L'héritage public modélise une relation « est-un » : un Dog est un Animal, un SavingsAccount est un BankAccount. Ceci est régi par le principe de substitution de Liskov (LSP) : partout où une classe de base est attendue, une classe dérivée doit fonctionner correctement sans surprises.
L'héritage est l'une des fonctionnalités les plus puissantes — et les plus mal utilisées — de la POO. Toute relation n'est pas un « est-un ». Un Stack n'est pas un Vector même s'il en utilise un en interne. Utiliser l'héritage à mauvais escient là où la composition est appropriée mène à un code fragile et difficile à maintenir.
Héritage basique et chaînage de constructeurs
Une classe dérivée hérite de tous les membres de la classe de base. Le constructeur de la classe de base s'exécute en premier, puis le constructeur de la classe dérivée. La destruction se fait dans l'ordre inverse.
#include <iostream>
#include <string>
class Animal {
std::string name_;
int age_;
public:
Animal(const std::string& name, int age)
: name_{name}, age_{age}
{
std::cout << "Animal constructed: " << name_ << '\n';
}
virtual ~Animal() {
std::cout << "Animal destroyed: " << name_ << '\n';
}
const std::string& name() const { return name_; }
int age() const { return age_; }
virtual void speak() const {
std::cout << name_ << " makes a sound\n";
}
};
class Dog : public Animal { // public inheritance = is-a
std::string breed_;
public:
Dog(const std::string& name, int age, const std::string& breed)
: Animal{name, age} // base class constructor MUST be called first
, breed_{breed}
{
std::cout << "Dog constructed: " << name() << '\n';
}
~Dog() override {
std::cout << "Dog destroyed: " << name() << '\n';
}
void speak() const override {
std::cout << name() << " barks! (" << breed_ << ")\n";
}
};
int main() {
Dog rex{"Rex", 5, "German Shepherd"};
rex.speak(); // Rex barks! (German Shepherd)
// Destruction order: Dog destroyed, then Animal destroyed
return 0;
}Le problème du découpage
Le découpage d'objet se produit quand un objet dérivé est assigné ou passé par valeur à une variable de classe de base. La partie dérivée est « découpée » — seule la portion de base reste. C'est un bogue subtil et courant.
#include <iostream>
#include <string>
#include <vector>
#include <memory>
class Shape {
public:
virtual ~Shape() = default;
virtual std::string type() const { return "Shape"; }
};
class Circle : public Shape {
double radius_;
public:
explicit Circle(double r) : radius_{r} {}
std::string type() const override { return "Circle"; }
};
// BUG: passing by value slices the object!
void print_type_bad(Shape s) {
std::cout << s.type() << '\n'; // always prints "Shape"!
}
// CORRECT: pass by reference to preserve polymorphism
void print_type_good(const Shape& s) {
std::cout << s.type() << '\n'; // prints actual type
}
int main() {
Circle c{5.0};
print_type_bad(c); // "Shape" — SLICED!
print_type_good(c); // "Circle" — correct
// Slicing also happens with containers of values:
// std::vector<Shape> shapes; // BAD: slicing on insert
// Use pointers instead:
std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Circle>(3.0));
std::cout << shapes[0]->type() << '\n'; // "Circle"
return 0;
}Héritage multiple et le problème du diamant
Le C++ permet à une classe d'hériter de plusieurs bases. Le problème du diamant se produit quand deux bases partagent un ancêtre commun, causant la classe dérivée à contenir deux copies des données de l'ancêtre. L'héritage virtuel résout ce problème.
#include <iostream>
// Diamond problem:
// Device
// / \
// Printer Scanner
// \ /
// AllInOne
class Device {
protected:
int id_;
public:
Device(int id) : id_{id} {
std::cout << "Device(" << id_ << ")\n";
}
virtual ~Device() = default;
};
// Virtual inheritance: only ONE copy of Device in AllInOne
class Printer : virtual public Device {
public:
Printer(int id) : Device{id} {
std::cout << "Printer\n";
}
void print_doc() { std::cout << "Printing on device " << id_ << '\n'; }
};
class Scanner : virtual public Device {
public:
Scanner(int id) : Device{id} {
std::cout << "Scanner\n";
}
void scan_doc() { std::cout << "Scanning on device " << id_ << '\n'; }
};
class AllInOne : public Printer, public Scanner {
public:
// With virtual inheritance, the most-derived class
// must initialize the virtual base directly
AllInOne(int id) : Device{id}, Printer{id}, Scanner{id} {
std::cout << "AllInOne\n";
}
};
int main() {
AllInOne aio{42};
aio.print_doc(); // Printing on device 42
aio.scan_doc(); // Scanning on device 42
// Only ONE Device sub-object exists — no ambiguity
return 0;
}Types de retour covariants et accès protégé
Les types de retour covariants permettent à une classe dérivée de surcharger une fonction virtuelle et de retourner un type pointeur ou référence plus spécifique (dérivé). Par exemple, si Base::clone() retourne Base*, Derived::clone() peut retourner Derived*.
Le spécificateur d'accès protected se situe entre public et private : les membres protégés sont accessibles à la classe elle-même et à ses classes dérivées, mais pas au code extérieur. Utilisez protected pour les membres dont les classes dérivées ont légitimement besoin — mais soyez prudent, car cela crée un couplage entre la base et la dérivée.
Une erreur courante est d'utiliser l'héritage uniquement pour réutiliser du code. Si un Stack hérite de std::vector, les utilisateurs peuvent appeler push_back, insert et d'autres opérations de vector qui violent l'abstraction de pile. L'héritage devrait modéliser une vraie relation est-un satisfaisant le principe de substitution de Liskov. Pour la réutilisation de code, préférez la composition (un Stack qui *contient* un vector).
Utilisez toujours l'héritage public — l'héritage private/protected est rarement nécessaire et déroutant. Déclarez toujours les destructeurs de classe de base comme virtual. Passez les objets polymorphiques par référence ou pointeur, jamais par valeur (pour éviter le découpage). Préférez la composition à l'héritage sauf si la relation est vraiment un « est-un ». Évitez les hiérarchies d'héritage profondes (2-3 niveaux est généralement la limite pratique).
- L'héritage public modélise le est-un — la classe dérivée doit satisfaire le principe de substitution de Liskov
- Les constructeurs de base s'exécutent en premier, les destructeurs en dernier (ordre inverse de la construction)
- Le découpage d'objet se produit quand un objet dérivé est passé par valeur à une base — utilisez toujours des références ou pointeurs
- Le problème du diamant est résolu avec l'héritage
virtual, mais préférez la composition à la place - L'héritage est pour la substituabilité, pas la réutilisation de code — utilisez la composition pour cette dernière
Quiz — Testez vos connaissances
(15 XP)1. Qu'est-ce que le découpage d'objet ?
2. Comment l'héritage virtuel résout-il le problème du diamant ?
3. Pourquoi ne devriez-vous PAS hériter de `std::vector` pour créer une classe `Stack` ?