Banque de cours - Cours 11 - Pointeurs, référence et héritage
37 min de lecture
# Cours 11 - Pointeurs, référence et héritage
Où est Charlie le Bug ? (15 minutes)
class Character {public: Character(const std::string& name, int health, int maxHealth) { m_name = name; m_health = health; m_maxHealth = maxHealth; } std::string getName() { return m_name; } void heal(int amount) { m_health += amount; } static int getCount() { return s_count; }private: std::string m_name; int m_health; int m_maxHealth; static int s_count;};int main() { Character hero {"Aria", 80, 100}; hero.heal(50); std::cout << hero.m_health;}
Solution
class Character {public: Character(const std::string& name, int health, int maxHealth) { m_name = name; // Bug 1: pas de liste d'initialisation m_health = health; // (init par défaut puis assignatioo -> coûteux m_maxHealth = maxHealth; } std::string getName() { return m_name; } void heal(int amount) { m_health += amount; // Bug 3: aucune borne supérieure - m_health peut dépasser m_maxHealth } // (l'encapsulation existe pour valider, comme clamp() dans ColorLab) static int getCount() { return s_count; }private: std::string m_name; int m_health; int m_maxHealth; static int s_count; // Bug 4: déclaré mais jamais défini hors de la classe → erreur de linker};int main() { Character hero {"Aria", 80, 100}; hero.heal(50); std::cout << hero.m_health; // Bug 5: accès direct à un membre private - erreur de compilation}
Pointeurs, références et mémoire
Pile (Stack) vs Tas (Heap)
┌──────────────────────┐│ PILE (STACK) │ Rapide, automatique, taille limitée│ Variables locales │ Détruites à la fin de la portée│ Paramètres │├──────────────────────┤│ (libre) │├──────────────────────┤│ TAS (HEAP) │ Plus lent, manuel, grande taille│ Allocation dynamique│ Vous devez libérer (ou utiliser un smart pointer)│ new / delete │└──────────────────────┘
Idée clé : Tout ce que vous avez écrit jusqu’ici vit sur la pile. Le tas n’est utile que pour des objets dont la durée de vie dépasse la portée locale, ou dont la taille n’est connue qu’à l’exécution.
Qu'est-ce qu'un pointeur ?
Un pointeur est une variable qui stocke une adresse mémoire.
int value {42};int* ptr {&value}; // ptr stocke L'ADRESSE de value std::cout << std::format("value = {}", value) << std::endl; // 42 std::cout << &value << std::endl; // ex: 0x7fff... std::cout << ptr << std::endl; // même adresse std::cout << std::format("*ptr = {}", *ptr) << std::endl; // 42 (déréférencement)
Trois opérateurs à retenir :
Opérateur
Signification
Exemple
&x
”adresse de x”
&value
*ptr
”valeur à l’adresse de ptr”
*ptr = 100;
ptr->m
accès membre via pointeur
ptr->name()
nullptr
Un pointeur qui ne pointe vers rien doit valoir nullptr. Toujours vérifier avant de déréférencer.
Les références : un alias
Une référence est un autre nom pour une variable existante. Elle ne peut être ni nulle, ni réassignée.
int value {42};int& ref {value}; // ref EST valueint* ptr {&value};std::cout << value; // 100 - modifié à travers la référence
Pointeurs vs références (tableau de décision)
Caractéristique
Référence (T&)
Pointeur (T*)
Peut être nul ?
Non
Oui (nullptr)
Peut être réassigné ?
Non
Oui
Syntaxe d’accès
Direct : ref
Déréférencement : *ptr, ptr->
Quand l’utiliser
Cible toujours valide
Cible nullable ou changeante
Règle pratique : préférez la référence quand vous le pouvez ; utilisez le pointeur quand vous avez besoin d’optionnel ou de réassignation.
new / delete : le tas brut
int* ptr = new int {42}; // alloue sur le tasstd::cout << *ptr; // 42delete ptr; // libère la mémoireptr = nullptr; // bonne pratique
Trois pièges classiques :
Fuite mémoire : oublier delete (la mémoire n’est jamais libérée).
Pointeur pendant : utiliser ptr après delete ptr;.
Double **delete** : comportement indéfini.
C’est précisément pour éviter ces pièges que le C++ moderne recommande les smart pointers.
RAII et std::unique_ptr (l'essentiel)
RAII (Resource Acquisition Is Initialization) : un objet acquiert une ressource à sa construction et la libère automatiquement à sa destruction. std::vector fait déjà ça pour vous.
#include <memory>auto ptr = std::make_unique<int>(42); // alloue sur le tasstd::cout << *ptr << std::endl; // 42// pas de delete : ptr libère la mémoire en sortant de la portée
Règles minimales à retenir cette semaine :
std::make_unique<T>(args...) remplace new T(args...).
unique_ptr ne se copie pas (un seul propriétaire). Pour le déplacer : std::move(ptr).
std::shared_ptr existe pour la propriété partagée, mais on l’utilise rarement par défaut. Mention rapide seulement.
Pont vers Unreal (semaines 12-14) : Unreal fournit son propre système de gestion mémoire (UObject géré par le garbage collector, TObjectPtr<T>, UPROPERTY). Vous n’utiliserez quasiment jamais new/delete ni std::unique_ptr dans du code Unreal. Mais comprendre le principe RAII est essentiel pour lire le code Unreal.
RAII signifie Resource Acquisition Is Initialization. Le nom est un peu trompeur. Ce qu’il faut retenir, c’est l’idée centrale :
Un objet acquiert ses ressources dans son constructeur, et les libère dans son destructeur. La durée de vie de l’objet pilote la durée de vie de la ressource.
L’exemple classique : un fichier
void traiterFichier() { std::ifstream f("data.txt"); // constructeur : ouvre le fichier // ... lecture, traitement ...} // destructeur de f appelé automatiquement : ferme le fichier
Tu n’as jamais besoin d’écrire f.close(). Quand f sort de sa portée (la fin du bloc { }), son destructeur s’exécute et ferme le fichier. Cela arrive même si une exception est levée au milieu de la fonction, grâce au mécanisme de stack unwinding.
ptr détruit le Character et libère la mémoire automatiquement
Tout cela en ordre inverse de construction. Aucun delete, aucun unlock(), aucun free().
Pourquoi c’est important
En C, tu dois libérer manuellement chaque ressource sur chaque chemin de sortie de ta fonction. Si une fonction a 5 points de sortie possibles, tu dois te rappeler de tout libérer 5 fois. Une erreur, et tu as une fuite mémoire ou un fichier qui reste ouvert.
En C++ avec RAII, le compilateur insère les destructeurs pour toi. Le code devient exception-safe gratuitement.
La règle mentale
RAII transforme la gestion des ressources en problème de durée de vie, pas en problème de gestes à se rappeler.
Partie 2 : Le C++ d’Unreal Engine
Quand tu ouvres un projet UE5, tu vois ce genre de code :
Tout ça (UCLASS, UPROPERTY, UFUNCTION, GENERATED_BODY) ce ne sont pas des mots-clés C++. Ce sont des macros, parsées avant la compilation par un outil appelé Unreal Header Tool (UHT), qui génère du code supplémentaire dans des fichiers .generated.h.
Pourquoi Unreal fait ça
Le C++ standard n’a pas de système de réflexion (la capacité d’inspecter une classe à l’exécution). Or un moteur de jeu a besoin de :
Sérialiser des objets (sauvegardes, niveaux, réseau)
Exposer des propriétés à l’éditeur (les champs visibles dans le panneau Details)
Faire le pont avec Blueprint, un langage visuel
Gérer la mémoire automatiquement avec un garbage collector
Epic Games a donc construit un système de réflexion par-dessus C++ avec des macros.
Les conventions de nommage UE
Préfixe
Signification
Exemple
A
Acteur dans le monde
AActor, AMyCharacter
U
Objet géré par le GC
UObject, UStaticMeshComponent
F
Struct simple, sans GC
FVector, FString
I
Interface
IInteractable
E
Enum
EInputType
T
Template
TArray, TMap, TSharedPtr
UE n’utilise pas la library standard C++ (STL, Standard Template Library)
UE a ses propres équivalents :
STL
UE
std::vector<T>
TArray<T>
std::map<K,V>
TMap<K,V>
std::string
FString
std::shared_ptr<T>
TSharedPtr<T>
std::unique_ptr<T>
TUniquePtr<T>
Les raisons sont historiques (UE existe depuis avant C++11), liées à la portabilité console, et à l’intégration avec le système de réflexion et de GC.
Partie 3 : Le pont entre RAII et Unreal
C’est ici que ça devient intéressant. **UE casse partiellement RAII pour les ****UObject**.
Les UObject ne suivent pas RAII
Un UObject, et donc tout ce qui en hérite (AActor, UActorComponent, etc.), n’est pas géré par durée de vie automatique. Il est géré par un garbage collector intégré au moteur.
Tu ne fais jamais new AMyActor ni delete Actor. Tu fais :
// CréationAMyActor* Actor = GetWorld()->SpawnActor<AMyActor>(...);// Destruction (marquage pour le GC)Actor->Destroy();
Pour qu’une référence à un UObject soit “vue” par le GC et empêche sa destruction prématurée, elle doit être déclarée avec UPROPERTY() :
UPROPERTY()AEnemy* TargetEnemy; // le GC sait que cette référence existe
Sans UPROPERTY(), ton pointeur peut devenir pendant à tout moment, parce que le GC ne sait pas qu’il existe.
Tout le reste suit RAII normalement
Pour les types non-UObject, c’est-à-dire les structs F, les containers T, et les smart pointers T, RAII fonctionne exactement comme en C++ standard :
void AMyActor::DoSomething() { TArray<int32> Numbers; // RAII normal FScopeLock Lock(&CriticalSection); // RAII normal TUniquePtr<FMyData> Data = MakeUnique<FMyData>(); // RAII normal} // tout libéré automatiquement
FScopeLock est l’équivalent UE de std::lock_guard. TUniquePtr est l’équivalent de std::unique_ptr. Les noms changent, le comportement RAII est identique.
Partie 4 : Les règles à retenir
Quand tu passes du C++ pur à Unreal Engine :
Pour les **UObject** et descendants (Actor, Component, etc.)
Oublie RAII, le GC s’en occupe
UPROPERTY() pour les références
SpawnActor pour créer
Destroy() pour détruire
Pour tout le reste (structs F, containers T, smart pointers T)
RAII s’applique normalement, comme en C++ pur
Les macros UE ne sont pas magiques
Elles génèrent du code dans Intermediate/Build/.../*.generated.h
Tu peux l’inspecter si tu es curieux
La STL est bannie en pratique
Apprends les équivalents UE
TArray au lieu de std::vector, etc.
Métaphore pour conclure
Le C++ pur, c’est ta cuisine personnelle. Tu gères tout : achat, stockage, nettoyage, rangement. RAII te fournit des outils qui font une partie du travail automatiquement (le destructeur range les choses quand tu finis).
Unreal Engine, c’est une cuisine de restaurant industriel. Il y a un système qui gère le stock, le nettoyage, la rotation des produits, mais tu dois suivre ses règles : étiquetage (UPROPERTY), zones réservées (UCLASS), procédures de commande (SpawnActor).
Les deux sont du C++. Mais avec des contrats de durée de vie différents. La compétence à développer, c’est de savoir dans quelle cuisine tu te trouves à chaque instant.
Pour aller plus loin
Lire les fichiers .generated.h d’un projet UE pour démystifier les macros
Comparer std::vector et TArray côte à côte sur les mêmes opérations
Tester un cas où une référence sans UPROPERTY() devient pendante après un GC
En C++ moderne (C++20 et au-delà), explorer std::span, std::expected, et les concepts, qui n’existent pas encore dans la version C++ d’UE
Pointeurs vers objets en C++ moderne : cas d’usage et comparaison avec Rust, Java, C# et Python
En C++ moderne (depuis C++11), la règle générale est la suivante :
Préférer les valeurs.
Sinon, les références.
Sinon, les pointeurs intelligents (unique_ptr, shared_ptr).
Les pointeurs bruts (T*) en dernier recours, pour des raisons précises.
Cette hiérarchie peut sembler restrictive, mais les pointeurs vers objets restent indispensables dans plusieurs situations. Ce document détaille ces situations, puis compare l’approche du C++ avec celle d’autres langages que vous croiserez en production.
Les six cas d’usage légitimes en C++ moderne
1. Polymorphisme runtime avec conteneurs hétérogènes
Un std::vector<Animal> ne fonctionne pas si Animal est une classe de base : on perd la partie dérivée par “slicing”. Il faut donc stocker des pointeurs.
C’est probablement le cas le plus fréquent en code de jeu et d’outillage.
2. Observateurs non-propriétaires
Un T* brut signifie en C++ moderne : “je référence cet objet mais je ne gère pas sa durée de vie”. Une référence ne peut pas être nulle ni rebindable, donc dès qu’un observateur peut pointer vers A, puis B, puis rien, le pointeur brut est l’outil correct.
class Camera { Target* current_target_ = nullptr; // peut changer ou être nullpublic: void set_target(Target* t) { current_target_ = t; }};
Certaines bases de code utilisent gsl::not_null<T*> ou un observer_ptr<T> maison pour expliciter l’intention.
3. Paramètres optionnels d’objet
void draw(const Mesh& mesh, const Material* override = nullptr);// override = nullptr signifie "utilise le matériau par défaut"
std::optional<T&> n’existe pas dans la bibliothèque standard, donc le pointeur brut reste le moyen propre d’exprimer “passe-en un si tu veux”.
4. Références arrière dans arbres et graphes
Un noeud enfant ne possède pas son parent. Un unique_ptr causerait une double propriété, un shared_ptr créerait un cycle.
class Node { std::vector<std::unique_ptr<Node>> children_; Node* parent_ = nullptr; // observation, pas propriété};
5. Idiome Pimpl
// Headerclass Widget { class Impl; std::unique_ptr<Impl> pImpl_;};
Cela cache l’implémentation aux utilisateurs du header et accélère significativement la compilation. Pointeur propriétaire, mais toujours un cas de pointeur vers objet.
6. Interopérabilité avec API C
OpenGL, Vulkan, handles système, librairies anciennes : tout passe par des pointeurs bruts à la frontière.
Rust prend les concepts du C++ moderne et les rend obligatoires via le compilateur. L’analogue de unique_ptr est Box<T>, celui de shared_ptr est Rc<T> (mono-thread) ou Arc<T> (multi-thread).
Le compilateur garantit qu’aucun pointeur pendant ne peut exister. Le coût pédagogique : la courbe d’apprentissage du borrow checker. Pour un étudiant qui maîtrise les pointeurs C++, Rust paraît surtout strict, pas étrange.
Java : tout est référence implicite
En Java, toute variable d’objet est déjà une référence gérée par le ramasse-miettes (garbage collector). Les six cas d’usage du C++ disparaissent ou se transforment.
// Polymorphisme : trivial, pas de slicing possibleList<Animal> zoo = new ArrayList<>();zoo.add(new Lion());zoo.add(new Penguin());// Observateur non-propriétaire : juste une référenceclass Camera { private Target currentTarget; // null autorisé, GC gère la durée de vie}// Référence faible explicite pour caches et observateurs longue duréeimport java.lang.ref.WeakReference;WeakReference<Cache> ref = new WeakReference<>(cache);
Java n’a pas de pointeurs bruts. Le concept de “non-propriétaire” n’existe pas explicitement car le GC gère tout. Le revers : moins de contrôle sur la durée de vie précise et le placement mémoire.
C# : Java avec des échappatoires
C# ressemble à Java mais offre plus de flexibilité : struct (sémantique de valeur), mots-clés ref et out, et même des pointeurs bruts dans des blocs unsafe pour l’interop ou la performance.
// Polymorphisme : comme en JavaList<Animal> zoo = new List<Animal> { new Lion(), new Penguin() };// Référence par paramètre (équivalent partiel de T& en C++)void Modify(ref Vector3 v) { v.X += 1; }// Pointeurs bruts pour interop native ou hot pathunsafe void ProcessPixels(byte* data, int length) { for (int i = 0; i < length; i++) data[i] = 255;}// WeakReference pour observation longue duréeWeakReference<Parent> parentRef = new WeakReference<Parent>(parent);
C# est probablement le plus proche du C++ en termes d’options, tout en gardant un GC par défaut.
Python : tout est référence, GC en arrière-plan
En Python, chaque variable est une référence vers un objet dans le tas. Aucune notion syntaxique de pointeur. Le compteur de références plus un GC cyclique gèrent la mémoire.
# Polymorphisme : naturel, duck typingzoo = [Lion(), Penguin()]for animal in zoo: animal.speak()# Observateur non-propriétaire : assignation simpleclass Camera: def __init__(self): self.target = None # référence ou None# Référence faible pour éviter les cyclesimport weakrefclass Node: def __init__(self, parent=None): self.children = [] self._parent_ref = weakref.ref(parent) if parent else None @property def parent(self): return self._parent_ref() if self._parent_ref else None
Python privilégie la simplicité totale. Le coût : performance moindre et moins de contrôle sur la libération mémoire.
Synthèse pédagogique
La progression conceptuelle entre les langages est claire :
C++ expose tous les outils et laisse le développeur choisir. Puissance maximale, responsabilité maximale.
Rust prend les meilleures pratiques du C++ moderne et les impose au compile-time. Garanties fortes, courbe d’apprentissage exigeante.
C# propose un modèle managé par défaut avec des trappes d’évacuation pour la performance.
Java pousse l’abstraction plus loin : pas de pointeurs, modèle uniforme par référence pour les objets.
Python abstrait au maximum : tout est objet, tout est référence, le GC gère.
Pour bien apprendre les pointeurs en C++, retenir cette grille de décision :
Intention
Outil C++
Je possède cet objet, seul propriétaire
unique_ptr<T>
Je partage la propriété entre plusieurs entités
shared_ptr<T>
Je casse un cycle de propriété partagée
weak_ptr<T>
L’objet existe toujours et ne change jamais de cible
T& (référence)
L’objet peut être null ou changer de cible
T* (pointeur brut)
Interop avec API C ou code bas niveau
T* (pointeur brut)
Une fois cette grille intériorisée, la peur traditionnelle des pointeurs disparaît : un pointeur brut signifie simplement “non-propriétaire”, et la propriété est rendue explicite via les pointeurs intelligents. C’est exactement ce que Rust impose au compilateur, ce que Java et C# délèguent au GC, et ce que Python cache complètement.
Pour aller plus loin
C++ Core Guidelines, sections F (functions) et R (resource management).
“Effective Modern C++” de Scott Meyers, items 18 à 22.
“The Rust Book”, chapitres 4 et 15 (ownership et smart pointers).
Documentation java.lang.ref pour les références faibles côté JVM.
PEP 442 pour la finalisation des objets en Python.
protected est un troisième niveau de visibilité, entre public et private :
Mot-clé
Accessible depuis
Accessible depuis les classes filles
Accessible de l’extérieur
public
partout
oui
oui
protected
la classe et les classes filles
oui
non
private
la classe seulement
non
non
Dans notre exemple, m_length, m_width et m_height sont protected. Ça signifie que :
Carton peut accéder directement à m_length (par exemple dans une méthode)
Le code utilisateur ne peut pas faire monCarton.m_length (compilateur refuse)
Faut-il utiliser protected ?
C’est un choix de design discuté. Beaucoup de spécialistes (Stroustrup, Sutter) recommandent de garder les membres private même en présence d’héritage, et d’exposer des accesseurs protected si nécessaire. Pourquoi ? Parce que protected casse l’encapsulation : toutes les classes filles dépendent de la représentation interne de la classe parente.
La partie après les : est la liste d’initialisation des membres. C’est ici qu’on initialise les membres de la classe et qu’on appelle le constructeur de la classe de base.
Pourquoi c’est important
Quand un objet Carton est construit, il faut d’abord construire la “partie Box” de l’objet, puis la “partie Carton”. L’ordre est :
Le compilateur appelle le constructeur de Box avec l, w, h
Puis m_material est initialisé avec material
Puis le corps {} est exécuté (vide ici)
Si tu n’appelles pas explicitement Box{l, w, h}, le compilateur appelle le constructeur par défautBox(), ce qui te donnerait des valeurs 1.0 partout, pas ce que tu veux.
Schéma mental
Carton{30, 20, 15, "Cardboard"} | +--> Box{30, 20, 15} (construit la partie Box) | m_length = 30 | m_width = 20 | m_height = 15 | +--> m_material = "Cardboard" (initialise le membre propre à Carton)
explicit empêche le compilateur d’utiliser ce constructeur pour des conversions implicites.
Sans explicit : le piège
Imagine un constructeur à un seul argument :
class Carton {public: Carton(double size) : Box{size, size, size}, m_material{"Cardboard"} {}};void process(Carton c) { /* ... */ }process(42.0); // OK sans explicit : 42.0 est converti implicitement en Carton
Cette conversion implicite est rarement souhaitable et source de bugs. Avec explicit :
class Carton {public: explicit Carton(double size) : Box{size, size, size} {}};process(42.0); // ERREUR de compilationprocess(Carton{42.0}); // OK, conversion explicite demandée
La règle
Marquer **explicit** tous les constructeurs qui pourraient être utilisés comme conversion, c’est-à-dire ceux avec un seul argument ou ceux dont tous les arguments sauf un ont des valeurs par défaut.
Dans notre cas, le constructeur de Carton a 4 paramètres dont 1 avec valeur par défaut. Il pourrait théoriquement être appelé avec 3 arguments seulement. Le explicit empêche que le compilateur l’utilise pour des conversions surprenantes.
C’est un bon réflexe à développer : par défaut, tous les constructeurs sont *explicit*, sauf si tu veux explicitement permettre une conversion implicite (rare).
Le const après les parenthèses signifie que la méthode ne modifie pas l’état de l’objet. Le compilateur garantit que *this est traité comme un const Box.
Conséquences :
La méthode peut être appelée sur un const Box
À l’intérieur, toute tentative de modifier un membre déclenche une erreur de compilation
C’est un contrat avec l’appelant : “je promets de ne rien changer”
C’est ce qu’on appelle la const-correctness. Toute méthode qui ne modifie pas logiquement l’objet doit être marquée const.
7. Le mot-clé default
Box() = default;virtual ~Box() = default;
= default demande au compilateur de générer la version par défaut de cette fonction spéciale (constructeur, destructeur, etc.).
Pourquoi explicitement le demander ? Parce que dès que tu déclares un constructeur (comme Box(double, double, double)), le compilateur ne génère plus automatiquement le constructeur par défaut. Si tu veux quand même qu’on puisse écrire Box b;, tu dois le redemander explicitement avec = default.
C’est plus expressif et plus efficace que d’écrire un corps vide {}.
Récapitulatif des concepts
Concept
Signification
Quand l’utiliser
class B : public A
B EST-UN A
Quand la phrase “B est un A” est vraie
protected
accessible aux classes filles
Avec parcimonie, préférer private + accesseurs
virtual
dispatch dynamique
Sur les méthodes redéfinies dans les filles
virtual ~Class()
destructeur virtuel
Dès qu’on hérite et qu’on peut manipuler par pointeur de base
explicit
empêche conversion implicite
Sur tous les constructeurs convertibles, par défaut
: Base{...}, m_x{...}
liste d’initialisation
Toujours pour initialiser membres et base
const (méthode)
promesse de non-modification
Sur toute méthode qui ne modifie pas l’état
= default
génération par compilateur
Pour réactiver les fonctions spéciales
Pour aller plus loin
Étudier override et final (C++11) qui complètent virtual
Comparer héritage public, protégé, et privé (les deux derniers sont rares)
Explorer la composition comme alternative à l’héritage
Voir le polymorphisme statique avec les templates et les concepts (C++20)
Récapitulatif
Concept
Syntaxe
Idée clé
Adresse
&x
Adresse mémoire de x
Pointeur
T* ptr = &x;
Stocke une adresse
Déréférencement
*ptr, ptr->m
Accès à la valeur / au membre via pointeur
Référence
T& ref = x;
Alias permanent, jamais nul
nullptr
T* ptr = nullptr;
Pointeur qui ne pointe vers rien
new / delete
T* p = new T{}; delete p;
Allocation manuelle (à éviter)
std::unique_ptr<T>
auto p = std::make_unique<T>(args);
Propriétaire unique avec libération auto
Héritage
class D : public B { ... };
”D est un B”
Chaînage de constructeur
D(...) : B{...}, m_x{...} {}
Initialise la base avant les membres
virtual
virtual void f();
Fonction redéfinissable, résolue à l’exécution
override
void f() override;
Indique et vérifie la redéfinition
Destructeur virtuel
virtual ~Base() = default;
Obligatoire dans toute classe de base
Erreurs courantes
#
Erreur
Conséquence
Correction
1
Déréférencer un nullptr
Crash
Vérifier if (p != nullptr) avant *p
2
Oublier delete après new
Fuite mémoire
Préférer std::make_unique<T>()
3
Utiliser un pointeur après delete
Comportement indéfini
Mettre ptr = nullptr; après delete
4
Confondre . et ->
Erreur de compilation
obj.m pour un objet, ptr->m pour un pointeur
5
Oublier virtual sur le destructeur de la base
Destructeur de la dérivée non appelé
virtual ~Base() = default;
6
Oublier override sur la redéfinition
Bug silencieux si la signature diffère
Toujours écrire override
7
Ne pas chaîner le constructeur de base
Membres de base non initialisés correctement
Liste d’init : Derived(...) : Base{...}
8
Object slicing (passage par valeur d’une dérivée)
Perte de la partie dérivée
Passer par référence ou pointeur
Les trois types d’héritage en C++ : public, protected, private
En C++, le mot-clé qui précède la classe parente détermine la visibilité des membres hérités. Ce document explique les trois variantes et leurs usages.
Les trois syntaxes
class B : public A { /* ... */ }; // heritage publicclass B : protected A { /* ... */ }; // heritage protegeclass B : private A { /* ... */ }; // heritage prive
Le mot-clé entre : et le nom de la classe parente détermine comment les membres de A sont visibles depuis l’extérieur de B. Il ne change rien à ce que B peut voir en interne : B voit toujours les public et protected de A, jamais les private.
Ce qui change, c’est le niveau de visibilité maximal des membres hérités vu depuis l’extérieur.
Tableau de transformation des visibilités
Pour chaque type d’héritage, voici comment les membres de A apparaissent dans B :
Membre dans A
public A
protected A
private A
public
reste public
devient protected
devient private
protected
reste protected
reste protected
devient private
private
inaccessible
inaccessible
inaccessible
Règle mentale
Le mot-clé d’héritage est un plafond de visibilité. Aucun membre ne peut être plus visible que ce plafond une fois hérité.
Plafond public : aucune restriction supplémentaire
Plafond protected : tout devient au mieux protected
Plafond private : tout devient au mieux private
Héritage public : la relation EST-UN
class Carton : public Box { };
C’est la relation classique d’héritage. **Un **Carton** EST-UN ****Box**. On peut substituer un Carton partout où un Box est attendu (principe de substitution de Liskov).
void process(Box& b);Carton c{30, 20, 15, "Cardboard"};process(c); // OK : Carton EST-UN Box, le code exterieur le sait
C’est de loin le type d’héritage le plus utilisé en pratique. Quand on parle d’héritage sans préciser, on parle généralement d’héritage public.
Héritage protégé : “implémenté en termes de, partageable avec les filles”
class Carton : protected Box { };
Carton utilise Box pour son implémentation interne. L’extérieur ne sait pas qu’il y a un Box dedans, mais les classes qui hériteront de Carton le sauront.
void process(Box& b);Carton c{30, 20, 15, "Cardboard"};process(c); // ERREUR : la conversion Carton -> Box est inaccessible
Cas d’usage extrêmement rare. En pratique, on ne croise quasiment jamais ce type d’héritage dans du code de production.
Héritage privé : “implémenté en termes de”
class Carton : private Box { };
Carton utilise Box pour son implémentation interne, et personne d’autre ne le sait, pas même les classes filles. C’est presque équivalent à une composition (un Box comme membre privé), avec quelques différences techniques mineures.
Pourquoi presque toujours public
L’héritage non-public exprime une relation “est implémenté en termes de”, qui correspond en réalité à une relation de composition. Et la composition est presque toujours préférable :
La composition est plus claire, plus flexible (on peut changer le membre interne sans toucher à l’interface publique), et plus facile à raisonner. La règle de Sutter et Alexandrescu
Dans C++ Coding Standards, Herb Sutter et Andrei Alexandrescu énoncent :
Préférer la composition à l’héritage. Quand on hérite, hériter publiquement.
C’est un bon principe à graver dans la tête de tes étudiants.
Quand l’héritage non-public a-t-il un sens ?
Les cas légitimes sont rares et relèvent du C++ avancé :
**Accès aux membres ****protected** : quand on a besoin d’accéder à des membres protected de la classe parente, ce que la composition ne permet pas (la composition ne donne accès qu’aux membres public).
Redéfinition de méthodes virtuelles : quand on veut redéfinir une méthode virtuelle de la classe parente sans exposer la relation d’héritage à l’extérieur.
Empty Base Optimization (EBO) : un membre composé d’une classe vide occupe quand même 1 octet en mémoire, alors qu’une base vide peut occuper 0 octet. Optimisation utilisée dans certaines bibliothèques.
Ces cas sont rares. Pour un cours d’introduction à la POO, on peut sans problème dire : héritage = **public**, point.
Comparaison avec d’autres langages
Langage
Modèle
Java, C#
Un seul type d’héritage, implicitement public
Rust
Pas d’héritage du tout, uniquement composition et traits
Python
Pas de distinction syntaxique, convention de noms (_membre)
C++
Trois types d’héritage explicites
C++ est presque le seul langage à offrir cette flexibilité, et c’est largement considéré comme un excès historique du langage. Les langages plus récents ont préféré simplifier ou éliminer cette dimension.
Synthèse pour le cours
Type
Phrase mentale
Fréquence
Recommandation
public
“B EST-UN A”
omniprésent
utilisation normale
protected
“B est-un A pour ses filles uniquement”
quasi inexistant
éviter
private
“B est implémenté avec A”
rare
préférer la composition
Message à retenir
Dans 99% des cas réels, on utilise l’héritage public. Les étudiants doivent savoir que protected et private existent comme types d’héritage, comprendre le tableau de transformation, mais aussi savoir que ce sont des cas particuliers à investiguer si jamais ils en croisent dans du code existant.
Exercices suggérés
Écrire trois petites classes filles d’une même classe A avec les trois types d’héritage, et tester en ligne sur godbolt.org quels appels passent ou échouent depuis l’extérieur.
Réécrire un héritage privé en utilisant une composition équivalente, et comparer la lisibilité du code.
Explorer la directive using qui permet de réintroduire un membre spécifique dans la visibilité publique :
class Carton : private Box {public: using Box::volume; // volume redevient public dans Carton};
Pour aller plus loin
Lire le chapitre sur l’héritage dans Effective C++ de Scott Meyers (item sur composition vs héritage privé)
Comparer avec le système de traits de Rust, qui résout les mêmes problèmes différemment
Étudier le pattern PIMPL (Pointer to Implementation), qui utilise la composition pour cacher l’implémentation
Exercice et pont vers Unreal
Mini-exercice (à faire en classe)
Créez une petite hiérarchie :
class Shape { // cette classe devient abstraite à cause de la fonction virtuelle pure area()public: virtual double area() const = 0; // ou retourner 0.0 si on évite les virtuelles pures virtual void describe() const; // affiche "Shape" virtual ~Shape() = default;};class Square : public Shape {public: explicit Square(double side); double area() const override; // side * side void describe() const override; // affiche "Square de côté X"private: double m_side;};
Fonctions virtuelles pures et classes abstraites en C++
Ce document explique les concepts de fonction virtuelle pure, de classe abstraite, et les mots-clés override et final, à travers une hiérarchie Shape / Square / Circle.
Le = 0 à la fin n’est pas une affectation. C’est une syntaxe spéciale qui déclare la méthode comme virtuelle pure (pure virtual function).
Ça signifie deux choses :
La méthode n’a pas d’implémentation dans cette classe
Toute classe fille doit la redéfinir
Pourquoi cette syntaxe bizarre
Historiquement, Bjarne Stroustrup voulait éviter d’ajouter un nouveau mot-clé comme pure ou abstract. Il a réutilisé une syntaxe libre. Ce n’est pas élégant, mais c’est le standard.
Tous ces langages expriment la même idée : cette méthode existe dans le contrat, mais doit être implémentée ailleurs.
2. La classe abstraite
Une classe qui contient au moins une fonction virtuelle pure devient automatiquement abstraite. On ne peut pas l’instancier directement.
Shape s; // ERREUR : Shape est abstraiteauto s = std::make_unique<Shape>(); // ERREUR : Shape est abstraiteShape* p = new Square{3.0}; // OK : pointeur vers ShapeShape& r = *p; // OK : reference vers Shapestd::unique_ptr<Shape> u = std::make_unique<Square>(3.0); // OK
C’est exactement ce qu’on veut pour Shape : ça n’a aucun sens d’instancier une “forme abstraite”. On veut toujours une forme concrète : Square, Circle, etc.
Une classe abstraite peut quand même
Avoir des données membres
Avoir des méthodes non virtuelles (utilitaires)
Avoir des méthodes virtuelles non pures (avec implémentation par défaut)
Avoir un constructeur (appelé par les filles via la liste d’initialisation)
Avoir un destructeur (souvent virtuel)
Ce qu’elle ne peut pas faire, c’est exister seule comme objet en mémoire.
override indique au compilateur : “je redéfinis une méthode virtuelle de la classe parente”. C’est une sécurité, pas une obligation.
Pourquoi c’est utile
Sans override, ce code compile silencieusement mais ne fait pas ce que tu crois :
class Shape {public: virtual double area() const = 0;};class Square : public Shape {public: double area() { return m_side * m_side; } // BUG : pas de const};
Le area() de Square n’est pas une redéfinition de celui de Shape (signatures différentes : un est const, l’autre non). C’est une nouvelle méthode qui cache l’autre. Square hérite donc de la version pure de Shape et reste abstraite. Le compilateur ne dit rien, et tu te retrouves avec une erreur incompréhensible plus tard.
Avec override :
double area() override { return m_side * m_side; }// ^^^^^^^^// ERREUR : aucune methode virtuelle correspondante dans la classe parente
Le compilateur t’arrête immédiatement.
Règle pratique
Toujours mettre **override** quand tu redéfinis une méthode virtuelle. C’est gratuit en performance, c’est une garantie de correction.
4. Le mot-clé final
final est l’inverse de override : il interdit toute redéfinition future.
Sur une méthode
class Square : public Shape {public: double area() const override final { return m_side * m_side; }};class FancySquare : public Square {public: double area() const override; // ERREUR : Square::area est final};
Sur une classe entière
class Square final : public Shape { // ...};class FancySquare : public Square { // ERREUR : Square est final // ...};
Quand l’utiliser
final est utile quand :
Tu veux empêcher l’extension d’une classe pour des raisons de design
Tu veux permettre au compilateur d’optimiser les appels (la méthode étant finale, le compilateur peut éviter le dispatch dynamique)
À utiliser avec parcimonie. La plupart du temps, on laisse la porte ouverte à l’extension.
5. Le polymorphisme dynamique en action
L’intérêt de tout ce mécanisme est dans la boucle du main :
std::vector<std::unique_ptr<Shape>> shapes;shapes.push_back(std::make_unique<Square>(3.0));shapes.push_back(std::make_unique<Circle>(2.0));for (const auto& s : shapes) { s->describe();}
Le vector contient des pointeurs vers Shape, mais à l’exécution, chaque pointeur pointe vers un type concret différent. L’appel s->describe() :
Le compilateur ne sait pas quelle version appeler à la compilation
À l’exécution, le programme regarde le type réel de l’objet pointé
Il appelle la bonne version : Square::describe ou Circle::describe
C’est le dispatch dynamique, rendu possible par virtual. Sortie attendue :
Square de cote 3, aire = 9Cercle de rayon 2, aire = 12.5664
6. Comparaison : virtuelle pure vs implémentation par défaut
Sémantique honnête : il n’y a pas de réponse par défaut sensée
Avec implémentation par défaut
Shape peut être instanciée
Les filles peuvent oublier de redéfinir, et hériteront du comportement par défaut
Plus permissif, mais source de bugs silencieux
Sémantique discutable : retourner 0.0 pour une forme inconnue est un mensonge
Quand choisir l’un ou l’autre
Virtuelle pure quand il n’existe pas de comportement par défaut sensé. C’est le cas typique des classes de base abstraites qui définissent une interface.
Virtuelle simple quand un comportement par défaut a du sens, et que les filles peuvent éventuellement le surcharger. Exemple : une méthode log() qui par défaut écrit sur std::cout, mais qu’une fille peut rediriger ailleurs.
Dans le doute pour une interface, préférer la virtuelle pure.
7. Le destructeur virtuel (rappel)
virtual ~Shape() = default;
Toute classe destinée à servir de base polymorphique doit avoir un destructeur virtuel. Sans ça :
std::unique_ptr<Shape> s = std::make_unique<Square>(3.0);// quand s est detruit, sans destructeur virtuel, seul ~Shape est appele// ~Square ne s'execute jamais : fuite potentielle, comportement indefini
Avec un destructeur virtuel, la chaîne complète de destruction se déroule correctement.
Récapitulatif des syntaxes
Syntaxe
Nom
Effet
virtual f();
virtuelle simple
peut être redéfinie, a une implémentation
virtual f() = 0;
virtuelle pure
doit être redéfinie, rend la classe abstraite
f() override;
redéfinition explicite
sécurité de compilation
f() final;
méthode finale
empêche toute redéfinition ultérieure
class C final
classe finale
empêche toute classe fille
virtual ~C() = default;
destructeur virtuel
indispensable pour héritage polymorphique
Exercices suggérés
Ajouter une classe Triangle à la hiérarchie qui prend la base et la hauteur.
Ajouter une méthode virtuelle pure perimeter() à Shape et l’implémenter dans toutes les filles.
Essayer de retirer override et introduire une faute de frappe (area() const devient aera() const). Observer le message du compilateur.
Créer une classe ColoredShape qui hérite de Shape mais ajoute un membre m_color sans implémenter area(). Vérifier que ColoredShape reste abstraite.
Marquer Square comme final et essayer d’en hériter. Lire le message d’erreur.
Pour aller plus loin
Étudier les interfaces pures : classes ne contenant que des virtuelles pures, équivalentes aux interfaces de Java
Comparer avec les concepts de C++20, qui offrent du polymorphisme statique
Explorer le pattern Strategy qui utilise abondamment l’héritage avec virtuelles pures
Regarder comment Unreal Engine utilise virtual et = 0 dans ses interfaces (préfixe I)
Pont vers Unreal (semaines 12-14)
Tout ce qu'on vient de voir, vous le retrouverez dans Unreal sous une forme légèrement différente :
C++ standard (cette semaine)
Équivalent Unreal
class Derived : public Base
class AMyActor : public AActor
virtual void f() override
virtual void BeginPlay() override
virtual ~Base() = default
Géré par le moteur via UObject et le GC
std::unique_ptr<T>
TObjectPtr<T>, UPROPERTY()
new / delete
NewObject<T>(), SpawnActor<T>()
Pointeur brut T*
Pointeur brut, mais marqué UPROPERTY pour le GC
Travail personnel
Lecture
Beginning C++23, Chapitre 10 (sections pointeurs et références uniquement)
Défi 1 - Pointeur ou référence ?
Pour chacun des cas suivants, indiquez si vous utiliseriez une référence, un pointeur brut, ou un **std::unique_ptr**, et pourquoi :
Un paramètre de fonction qui doit lire une grosse std::string sans la copier.
Un membre d’une classe Player qui peut avoir ou ne pas avoir d’arme équipée.
Une fonction qui crée et retourne un nouvel Enemy alloué dynamiquement.
Un paramètre de fonction qui doit modifier un int appartenant à l’appelant.
Défi 2 - Mini hiérarchie Vehicle
Implémentez une classe de base Vehicle avec :
Défi 3 (bonus) - Détecter le bug du destructeur
Reprenez la classe Vehicle du défi 2, mais retirez le mot-clé virtual du destructeur de Vehicle. Ajoutez un message dans les destructeurs de Vehicle, Car, et Bike (par exemple std::cout << "~Car\\n";).
Exécutez le programme et observez la sortie quand le vector se vide en fin de portée. Quels destructeurs sont appelés ? Lesquels manquent ? Pourquoi ?
Remettez ensuite virtual et observez la différence.