# 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érateurSignificationExemple
&x”adresse de x”&value
*ptr”valeur à l’adresse de ptr”*ptr = 100;
ptr->maccès membre via pointeurptr->name()

nullptr Un pointeur qui ne pointe vers rien doit valoir nullptr. Toujours vérifier avant de déréférencer.

int* ptr {nullptr};
if (ptr != nullptr) {
    *ptr = 42;        // sûr
}
// *ptr = 42;         // CRASH si ptr == nullptr !

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 value
int* ptr {&value};
std::cout << value;   // 100 - modifié à travers la référence

Pointeurs vs références (tableau de décision)

CaractéristiqueRéférence (T&)Pointeur (T*)
Peut être nul ?NonOui (nullptr)
Peut être réassigné ?NonOui
Syntaxe d’accèsDirect : refDéréférencement : *ptr, ptr->
Quand l’utiliserCible toujours valideCible 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 tas
std::cout << *ptr;         // 42
delete ptr;                // libère la mémoire
ptr = nullptr;             // bonne pratique

Trois pièges classiques :

  1. Fuite mémoire : oublier delete (la mémoire n’est jamais libérée).
  2. Pointeur pendant : utiliser ptr après delete ptr;.
  3. 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 tas
std::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.

RAII s’applique partout

{
    std::vector<int> v;
    std::lock_guard<std::mutex> lock(mtx);
    auto ptr = std::make_unique<Character>("Aria", 80, 100);
}

Dans ce bloc :

  • v libère sa mémoire automatiquement
  • lock déverrouille le mutex automatiquement
  • 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 :

UCLASS()
class MYGAME_API AMyCharacter : public APawn
{
    GENERATED_BODY()
 
public:
    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    float Health;
 
    UFUNCTION(BlueprintCallable)
    void TakeDamage(float Amount);
};

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 :

  1. Sérialiser des objets (sauvegardes, niveaux, réseau)
  2. Exposer des propriétés à l’éditeur (les champs visibles dans le panneau Details)
  3. Faire le pont avec Blueprint, un langage visuel
  4. 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éfixeSignificationExemple
AActeur dans le mondeAActor, AMyCharacter
UObjet géré par le GCUObject, UStaticMeshComponent
FStruct simple, sans GCFVector, FString
IInterfaceIInteractable
EEnumEInputType
TTemplateTArray, TMap, TSharedPtr

UE n’utilise pas la library standard C++ (STL, Standard Template Library)

UE a ses propres équivalents :

STLUE
std::vector<T>TArray<T>
std::map<K,V>TMap<K,V>
std::stringFString
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éation
AMyActor* 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 :

  1. 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
  1. Pour tout le reste (structs F, containers T, smart pointers T)
  • RAII s’applique normalement, comme en C++ pur
  1. 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
  1. 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 :

  1. Préférer les valeurs.
  2. Sinon, les références.
  3. Sinon, les pointeurs intelligents (unique_ptr, shared_ptr).
  4. 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.

std::vector<std::unique_ptr<Animal>> zoo;
zoo.push_back(std::make_unique<Lion>());
zoo.push_back(std::make_unique<Penguin>());
 
for (const auto& animal : zoo) {
    animal->speak();  // dispatch virtuel
}

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 null
public:
    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

// Header
class 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.

GLuint texture;
glGenTextures(1, &texture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, w, h, 0, GL_RGBA,
             GL_UNSIGNED_BYTE, pixel_data);

Tableau comparatif des cinq langages

AspectC++RustJavaC#Python
Pointeurs brutsOui (T*)Oui (*const T, en unsafe)NonOptionnel (en unsafe)Non
Références syntaxiquesT&&T, &mut TTout est référenceTout est référence (sauf struct)Tout est référence
Propriété expliciteunique_ptr<T>Système d’ownership intégréGCGCGC + comptage
Partageshared_ptr<T>Rc<T>, Arc<T>ImpliciteImpliciteImplicite
Référence faibleweak_ptr<T>Weak<T>WeakReference<T>WeakReference<T>weakref.ref
VérificationRuntime + disciplineCompile-timeRuntimeRuntimeRuntime
Risque de fuiteÉlevé sans RAIITrès faibleCycles seulement (rare)Cycles seulement (rare)Cycles (gérés par GC cyclique)

Comparaison détaillée par langage

Rust : la propriété au compile-time

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).

// Polymorphisme : équivalent de vector<unique_ptr<Animal>>
let zoo: Vec<Box<dyn Animal>> = vec![
    Box::new(Lion::new()),
    Box::new(Penguin::new()),
];
 
// Observateur non-propriétaire : référence empruntée avec lifetime
struct Camera<'a> {
    target: Option<&'a Target>,
}
 
// Référence arrière : Weak pour casser les cycles
use std::rc::{Rc, Weak};
use std::cell::RefCell;
 
struct Node {
    children: RefCell<Vec<Rc<Node>>>,
    parent: RefCell<Weak<Node>>,
}

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 possible
List<Animal> zoo = new ArrayList<>();
zoo.add(new Lion());
zoo.add(new Penguin());
 
// Observateur non-propriétaire : juste une référence
class 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ée
import 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 Java
List<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 path
unsafe void ProcessPixels(byte* data, int length) {
    for (int i = 0; i < length; i++) data[i] = 255;
}
 
// WeakReference pour observation longue durée
WeakReference<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 typing
zoo = [Lion(), Penguin()]
for animal in zoo:
    animal.speak()
 
# Observateur non-propriétaire : assignation simple
class Camera:
    def __init__(self):
        self.target = None  # référence ou None
 
# Référence faible pour éviter les cycles
import weakref
 
class 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 :

  1. C++ expose tous les outils et laisse le développeur choisir. Puissance maximale, responsabilité maximale.
  2. Rust prend les meilleures pratiques du C++ moderne et les impose au compile-time. Garanties fortes, courbe d’apprentissage exigeante.
  3. C# propose un modèle managé par défaut avec des trappes d’évacuation pour la performance.
  4. Java pousse l’abstraction plus loin : pas de pointeurs, modèle uniforme par référence pour les objets.
  5. 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 :

IntentionOutil C++
Je possède cet objet, seul propriétaireunique_ptr<T>
Je partage la propriété entre plusieurs entitésshared_ptr<T>
Je casse un cycle de propriété partagéeweak_ptr<T>
L’objet existe toujours et ne change jamais de cibleT& (référence)
L’objet peut être null ou changer de cibleT* (pointeur brut)
Interop avec API C ou code bas niveauT* (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.

Bases de l'héritage

Classes de base et classes dérivées

class Box {
public:
    Box() = default;
    Box(double length, double width, double height)
        : m_length{length}, m_width{width}, m_height{height} {}
 
    double volume() const { return m_length * m_width * m_height; }
 
    double getLength() const { return m_length; }
    double getWidth()  const { return m_width; }
    double getHeight() const { return m_height; }
 
    virtual ~Box() = default;
 
protected:
    double m_length {1.0};
    double m_width  {1.0};
    double m_height {1.0};
};
 
class Carton : public Box {
public:
    explicit Carton(double l, double w, double h, std::string_view material = "Cardboard")
        : Box{l, w, h}, m_material{material} {}
 
    std::string material() const { return m_material; }
 
private:
    std::string m_material;
};

1. La relation EST-UN

class Carton : public Box {

Cette ligne signifie : **un **Carton** EST-UN ****Box**. C’est la relation d’héritage public.

Concrètement, ça veut dire :

  • Un Carton possède toutes les données et méthodes de Box
  • Partout où on attend un Box, on peut passer un Carton
  • L’inverse n’est pas vrai : un Box n’est pas forcément un Carton
Carton c{30, 20, 15, "Cardboard"};
Box& b = c;              // OK, un Carton EST-UN Box
double v = b.volume();   // OK, méthode héritée
 
Box base{10, 10, 10};
Carton& cr = base;       // ERREUR, un Box n'est pas forcément un Carton

Test mental pour valider l’héritage

Avant d’utiliser l’héritage, demande-toi : est-ce que cette phrase a du sens ?

  • “Un Carton est un Box” : oui, l’héritage est justifié
  • “Un Moteur est une Voiture” : non, c’est une relation A-UN (composition), pas EST-UN

L’héritage est souvent abusé. Quand tu hésites entre héritage et composition, choisis la composition par défaut.


2. Le mot-clé protected

protected:
    double m_length {1.0};
    double m_width  {1.0};
    double m_height {1.0};

protected est un troisième niveau de visibilité, entre public et private :

Mot-cléAccessible depuisAccessible depuis les classes fillesAccessible de l’extérieur
publicpartoutouioui
protectedla classe et les classes fillesouinon
privatela classe seulementnonnon

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.

Ici, on aurait pu écrire :

private:
    double m_length {1.0};
    double m_width  {1.0};
    double m_height {1.0};

Et Carton aurait quand même pu faire getLength() pour lire la valeur. C’est généralement préférable.


3. Le mot-clé virtual et le destructeur virtuel

virtual ~Box() = default;

Le mot-clé virtual active le polymorphisme dynamique : la résolution de la méthode appelée se fait à l’exécution, en fonction du type réel de l’objet.

Pourquoi un destructeur virtuel ?

Considère ce code :

Box* b = new Carton{30, 20, 15};
delete b;  // que se passe-t-il ?

Si le destructeur de Box n’est pas virtual :

  • Seul ~Box() est appelé
  • ~Carton() n’est jamais appelé
  • La string m_material n’est pas détruite proprement
  • Comportement indéfini, fuite mémoire potentielle

Si le destructeur est virtual :

  • ~Carton() est appelé d’abord (détruit m_material)
  • Puis ~Box() automatiquement
  • Tout est détruit correctement

La règle

Si une classe est destinée à être héritée et qu’on peut la manipuler via un pointeur de la classe de base, son destructeur doit être **virtual**.

Le coût : une indirection à l’exécution (table virtuelle). Acceptable dans 99% des cas.

= default indique simplement au compilateur de générer le destructeur par défaut, mais virtuel.


4. La liste d’initialisation et le chaînage du constructeur de base

explicit Carton(double l, double w, double h, std::string_view material = "Cardboard")
    : Box{l, w, h}, m_material{material} {}

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 :

  1. Le compilateur appelle le constructeur de Box avec l, w, h
  2. Puis m_material est initialisé avec material
  3. 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éfaut Box(), 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)

5. Le mot-clé explicit

explicit Carton(double l, double w, double h, std::string_view material = "Cardboard")

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 compilation
process(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).


6. Le mot-clé const sur les méthodes

double volume() const { return m_length * m_width * m_height; }
double getLength() const { return m_length; }

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

ConceptSignificationQuand l’utiliser
class B : public AB EST-UN AQuand la phrase “B est un A” est vraie
protectedaccessible aux classes fillesAvec parcimonie, préférer private + accesseurs
virtualdispatch dynamiqueSur les méthodes redéfinies dans les filles
virtual ~Class()destructeur virtuelDès qu’on hérite et qu’on peut manipuler par pointeur de base
explicitempêche conversion impliciteSur tous les constructeurs convertibles, par défaut
: Base{...}, m_x{...}liste d’initialisationToujours pour initialiser membres et base
const (méthode)promesse de non-modificationSur toute méthode qui ne modifie pas l’état
= defaultgénération par compilateurPour 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

ConceptSyntaxeIdée clé
Adresse&xAdresse mémoire de x
PointeurT* ptr = &x;Stocke une adresse
Déréférencement*ptr, ptr->mAccès à la valeur / au membre via pointeur
RéférenceT& ref = x;Alias permanent, jamais nul
nullptrT* ptr = nullptr;Pointeur qui ne pointe vers rien
new / deleteT* 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éritageclass D : public B { ... };”D est un B”
Chaînage de constructeurD(...) : B{...}, m_x{...} {}Initialise la base avant les membres
virtualvirtual void f();Fonction redéfinissable, résolue à l’exécution
overridevoid f() override;Indique et vérifie la redéfinition
Destructeur virtuelvirtual ~Base() = default;Obligatoire dans toute classe de base

Erreurs courantes

#ErreurConséquenceCorrection
1Déréférencer un nullptrCrashVérifier if (p != nullptr) avant *p
2Oublier delete après newFuite mémoirePréférer std::make_unique<T>()
3Utiliser un pointeur après deleteComportement indéfiniMettre ptr = nullptr; après delete
4Confondre . et ->Erreur de compilationobj.m pour un objet, ptr->m pour un pointeur
5Oublier virtual sur le destructeur de la baseDestructeur de la dérivée non appelévirtual ~Base() = default;
6Oublier override sur la redéfinitionBug silencieux si la signature diffèreToujours écrire override
7Ne pas chaîner le constructeur de baseMembres de base non initialisés correctementListe d’init : Derived(...) : Base{...}
8Object slicing (passage par valeur d’une dérivée)Perte de la partie dérivéePasser 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 public
class B : protected A { /* ... */ };   // heritage protege
class 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 Apublic Aprotected Aprivate A
publicreste publicdevient protecteddevient private
protectedreste protectedreste protecteddevient private
privateinaccessibleinaccessibleinaccessible

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.

void process(Box& b);
 
Carton c{30, 20, 15, "Cardboard"};
process(c);   // ERREUR : conversion inaccessible

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 :

// Approche heritage prive : Carton EST-implemente-avec un Box
class Carton : private Box {
public:
    using Box::volume;   // expose explicitement certaines methodes
};
 
// Approche composition : Carton A-UN Box (preferable)
class Carton {
public:
    double volume() const { return m_box.volume(); }
private:
    Box m_box;
};

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é :

  1. **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).
  2. 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.
  3. 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

LangageModèle
Java, C#Un seul type d’héritage, implicitement public
RustPas d’héritage du tout, uniquement composition et traits
PythonPas 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

TypePhrase mentaleFréquenceRecommandation
public“B EST-UN A”omniprésentutilisation normale
protected“B est-un A pour ses filles uniquement”quasi inexistantéviter
private“B est implémenté avec A”rarepré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

  1. É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.
  2. Réécrire un héritage privé en utilisant une composition équivalente, et comparer la lisibilité du code.
  3. 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;
};

Puis dans main :

std::unique_ptr<Shape> s = std::make_unique<Square>(3.0);
s->describe();
std::cout << "area = " << s->area() << "\n";

Vérifiez :

  1. Sans virtual sur describe(), qu’est-ce qui s’affiche ?
  2. Sans override sur Square::describe(), le code compile-t-il toujours ?
  3. Si vous remplacez unique_ptr<Shape> par Shape* s = new Square(3.0); et oubliez delete, que se passe-t-il en termes de mémoire ?

Solution shape.h

#ifndef SHAPE_SHAPE_H
#define SHAPE_SHAPE_H
 
 
class Shape {
public:
    Shape() = default;
    virtual double area() const = 0;
    virtual void describe() const;
    virtual ~Shape() = default;
};
 
 
#endif //SHAPE_SHAPE_H
 

shape.cpp

#include <iostream>
#include <ostream>
#include "shape.h"
 
double Shape::area() const
{
    return 0;
}
 
void Shape::describe() const
{
    std::cout << "I am a Shape" << std::endl;
}
 

square.cpp

 
#include "square.h"
 
#include <iostream>
#include <ostream>
 
Square::Square(double side)
{
    m_side = side;
}
 
double Square::area() const
{
    return m_side * m_side;
}
 
void Square::describe() const
{
    std::cout << "I am a square of size " << m_side << std::endl;
}
 

Square.h

#ifndef SHAPE_SQUARE_H
#define SHAPE_SQUARE_H
 
#include "shape.h"
 
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;
};
 
#endif //SHAPE_SQUARE_H

main.cpp

#include "shape.h"
#include  "square.h"
 
int main()
{
    Square square(5);
    square.describe();
    return 0;
}
 

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 code de référence

#include <iostream>
#include <string>
#include <vector>
#include <memory>
#include <numbers>
 
class Shape {
public:
    virtual double area() const = 0;
    virtual void describe() const = 0;
    virtual ~Shape() = default;
};
 
class Square : public Shape {
public:
    explicit Square(double side) : m_side{side} {}
 
    double area() const override { return m_side * m_side; }
 
    void describe() const override {
        std::cout << "Square de cote " << m_side
                  << ", aire = " << area() << '\\n';
    }
 
private:
    double m_side;
};
 
class Circle : public Shape {
public:
    explicit Circle(double radius) : m_radius{radius} {}
 
    double area() const override {
        return std::numbers::pi * m_radius * m_radius;
    }
 
    void describe() const override {
        std::cout << "Cercle de rayon " << m_radius
                  << ", aire = " << area() << '\\n';
    }
 
private:
    double m_radius;
};
 
int 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();
    }
}

1. La fonction virtuelle pure : = 0

virtual double area() const = 0;

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 :

  1. La méthode n’a pas d’implémentation dans cette classe
  2. 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.

Comparaison avec d’autres langages :

// C++
virtual double area() const = 0;
 
// Java
abstract double area();
 
// C#
public abstract double Area();
 
// Python (avec ABC)
@abstractmethod
def area(self): pass
 
// Rust (dans un trait)
fn area(&self) -> f64;

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 abstraite
auto s = std::make_unique<Shape>();         // ERREUR : Shape est abstraite
 
Shape* p = new Square{3.0};                 // OK : pointeur vers Shape
Shape& r = *p;                              // OK : reference vers Shape
std::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.


3. Le mot-clé override

double area() const override { return m_side * m_side; }

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() :

  1. Le compilateur ne sait pas quelle version appeler à la compilation
  2. À l’exécution, le programme regarde le type réel de l’objet pointé
  3. 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 = 9
Cercle de rayon 2, aire = 12.5664

6. Comparaison : virtuelle pure vs implémentation par défaut

On aurait pu écrire Shape autrement :

class Shape {
public:
    virtual double area() const { return 0.0; }   // implementation par defaut
    virtual void describe() const {
        std::cout << "Shape generique\\n";
    }
    virtual ~Shape() = default;
};

Avec = 0 (virtuelle pure)

  • Shape est abstraite, ne peut pas être instanciée
  • Les filles doivent redéfinir area() et describe()
  • Le compilateur garantit qu’on n’oubliera pas
  • 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

SyntaxeNomEffet
virtual f();virtuelle simplepeut être redéfinie, a une implémentation
virtual f() = 0;virtuelle puredoit être redéfinie, rend la classe abstraite
f() override;redéfinition explicitesécurité de compilation
f() final;méthode finaleempêche toute redéfinition ultérieure
class C finalclasse finaleempêche toute classe fille
virtual ~C() = default;destructeur virtuelindispensable pour héritage polymorphique

Exercices suggérés

  1. Ajouter une classe Triangle à la hiérarchie qui prend la base et la hauteur.
  2. Ajouter une méthode virtuelle pure perimeter() à Shape et l’implémenter dans toutes les filles.
  3. Essayer de retirer override et introduire une faute de frappe (area() const devient aera() const). Observer le message du compilateur.
  4. 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.
  5. 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 Baseclass AMyActor : public AActor
virtual void f() overridevirtual void BeginPlay() override
virtual ~Base() = defaultGéré par le moteur via UObject et le GC
std::unique_ptr<T>TObjectPtr<T>, UPROPERTY()
new / deleteNewObject<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)

  • Beginning C++23, Chapitre 12 (sections héritage public, virtual, override)

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 :

  1. Un paramètre de fonction qui doit lire une grosse std::string sans la copier.
  2. Un membre d’une classe Player qui peut avoir ou ne pas avoir d’arme équipée.
  3. Une fonction qui crée et retourne un nouvel Enemy alloué dynamiquement.
  4. 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 :

  • Données protégées : m_brand (string), m_speed (double, km/h).
  • Constructeur prenant marque et vitesse.
  • Méthode virtual void describe() const qui affiche "Vehicle: <brand>, <speed> km/h".
  • Destructeur virtuel.

Puis deux classes dérivées :

  • Car : public Vehicle qui ajoute m_numDoors (int) et redéfinit describe() pour afficher en plus le nombre de portes.
  • Bike : public Vehicle qui ajoute m_hasGears (bool) et redéfinit describe() pour afficher si le vélo a des vitesses.

Dans main, créez :

std::vector<std::unique_ptr<Vehicle>> garage;
garage.push_back(std::make_unique<Car>("Toyota", 180.0, 4));
garage.push_back(std::make_unique<Bike>("Specialized", 35.0, true));
garage.push_back(std::make_unique<Car>("Ferrari", 320.0, 2));
 
for (const auto& v : garage) {
    v->describe();   // appel polymorphique
}

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.