Polymorphisme en C++ : fonctions virtuelles et vtable
Apprenez le polymorphisme en C++ avec les fonctions virtuelles, la vtable et la liaison dynamique à travers des exemples.
Le polymorphisme permet à des objets partageant la même interface (type de base) d’adopter des comportements différents à l’exécution. En C++, le polymorphisme dynamique est réalisé grâce aux fonctions virtuelles, et le compilateur implémente ce mécanisme à l’aide d’une vtable (table virtuelle) et d’un vptr (pointeur virtuel) présent dans chaque objet. Dans cet article, nous verrons le fonctionnement des fonctions virtuelles, la structure vtable/vptr, les implications sur les performances et les meilleures pratiques, à travers des exemples concrets.
1) Qu’est-ce que le polymorphisme dynamique ?
Le polymorphisme dynamique signifie que la fonction appelée est déterminée à l’exécution. Cela permet d’appeler le comportement d’une classe dérivée à travers un pointeur ou une référence de la classe de base.
#include <iostream>
#include <memory>
using namespace std;
struct Book {
virtual ~Book() = default; // nécessaire pour la base polymorphe
virtual void PrintInfo() const { // fonction virtuelle
cout << "[Book] Informations générales" << endl;
}
};
struct EBook : Book {
void PrintInfo() const override {
cout << "[EBook] Édition numérique" << endl;
}
};
struct PrintedBook : Book {
void PrintInfo() const override {
cout << "[PrintedBook] Édition papier" << endl;
}
};
int main() {
unique_ptr<Book> a = make_unique<EBook>();
unique_ptr<Book> b = make_unique<PrintedBook>();
a->PrintInfo(); // [EBook] ...
b->PrintInfo(); // [PrintedBook] ...
}
Bien que a et b soient du type de base (Book),
les appels sont résolus à l’exécution selon le type réel (EBook/PrintedBook).
2) Comment fonctionnent vtable et vptr ?
Lorsqu’une classe contient au moins une fonction virtual,
le compilateur génère une vtable pour cette classe.
Chaque objet contient alors un champ caché vptr qui pointe vers la vtable correspondante.
- vtable : table contenant les adresses des fonctions virtuelles de la classe.
- vptr : champ caché dans l’objet qui stocke l’adresse de la vtable.
Lors d’un appel : objet.vptr → vtable → adresse réelle de la fonction.
Book (vptr) ──► vtable(Book) : &Book::PrintInfo
EBook (vptr) ─► vtable(EBook) : &EBook::PrintInfo
Printed (vptr)► vtable(PrintedBook) : &PrintedBook::PrintInfo
Cette redirection indirecte ajoute un léger coût d’appel, généralement négligeable.
3) override, final et destructeur virtuel
Le mot-clé override vérifie à la compilation qu’une fonction virtuelle de la base
est bien redéfinie.
final empêche toute redéfinition ultérieure.
Dans une classe de base polymorphe, un destructeur virtuel est essentiel :
sans lui, la suppression via un pointeur de base entraîne un comportement indéfini.
struct Book {
virtual ~Book() = default;
virtual double Price() const { return 0.0; }
};
struct EBook final : Book {
double Price() const override { return 99.0; }
};
// struct SpecialEBook : EBook {}; // ERREUR : EBook est final
4) Différence avec le polymorphisme statique (surcharge/templates)
Les surcharges (même nom, signature différente) et les templates sont résolus à la compilation, sans utiliser de vtable. Le polymorphisme dynamique, lui, est résolu à l’exécution via l’appel virtuel.
- Si le comportement doit être déterminé à l’exécution → utilisez virtual.
- Si la stratégie peut être résolue à la compilation → préférez template/CRTP ou surcharge.
5) Conception d’une interface polymorphe (exemple : Book)
Factorisez le comportement commun dans la classe de base et redéfinissez-le dans les classes dérivées. Exemple de calcul de prix pour les livres numériques et imprimés.
#include <iostream>
#include <memory>
#include <string>
using namespace std;
class Book {
protected:
string title, author;
public:
Book(string t, string a) : title(t), author(a) {}
virtual ~Book() = default;
virtual double FinalPrice() const = 0; // pure virtual : contrat abstrait
virtual void PrintInfo() const {
cout << title << " - " << author
<< " | " << FinalPrice() << " €" << endl;
}
};
class EBook : public Book {
double base, vat;
public:
EBook(string t, string a, double b, double v)
: Book(t,a), base(b), vat(v) {}
double FinalPrice() const override {
return base*(1.0+vat);
}
};
class PrintedBook : public Book {
double base, ship; int pages; bool hard;
public:
PrintedBook(string t, string a, double b, double s, int p, bool h)
: Book(t,a), base(b), ship(s), pages(p), hard(h) {}
double FinalPrice() const override {
double cost = base + ship + 0.04*pages;
if (hard) cost *= 1.1;
return cost;
}
};
int main() {
unique_ptr<Book> b1 = make_unique<EBook>("Modern C++", "A. Dev", 100, 0.10);
unique_ptr<Book> b2 = make_unique<PrintedBook>("Clean Code", "R. Martin", 120, 20, 464, true);
b1->PrintInfo(); // comportement EBook
b2->PrintInfo(); // comportement PrintedBook
}
6) Object Slicing et utilisation de références/pointeurs
Copier un objet dérivé par valeur dans un type de base « tranche » la partie spécifique du dérivé. Pour le polymorphisme, utilisez toujours une référence ou un pointeur de base.
PrintedBook pb("X","Y",100,20,200,false);
Book base = pb; // SLICING : seule la partie Book est copiée
base.PrintInfo(); // comportement de Book (partie dérivée perdue)
7) dynamic_cast et sécurité des types
Utilisez dynamic_cast pour des conversions sûres d’un type de base vers un type dérivé (RTTI nécessaire).
En cas d’échec, retourne nullptr (pour les pointeurs).
void PrintIfPrinted(const Book* b) {
if (auto pb = dynamic_cast<const PrintedBook*>(b)) {
cout << "Prix imprimé : " << pb->FinalPrice() << endl;
} else {
cout << "Pas un PrintedBook." << endl;
}
}
8) Remarques sur les performances
- Chaque appel virtuel est un appel indirect via la vtable (risque de mauvaise prédiction de branche).
- Le inline est moins probable pour une fonction virtuelle (résolue à l’exécution).
- En cas de boucles intensives avec beaucoup d’appels virtuels : envisagez le pattern Strategy + template (CRTP) ou des objets fonctionnels pour une liaison statique.
9) Classe/méthode final, erreurs override et bonnes pratiques
- Ajoutez toujours un destructeur virtuel dans les classes de base.
- Utilisez override pour toutes les fonctions redéfinies afin de détecter les erreurs de signature.
- Utilisez final pour interdire toute redéfinition ultérieure.
- Gérez les hiérarchies polymorphes avec le tas + smart pointers (
unique_ptr,shared_ptr). - N’ajoutez pas de virtual inutilement si le polymorphisme n’est pas requis.
10) Visualisation du fonctionnement de la vtable (étape par étape)
- La classe contient au moins une fonction virtuelle → le compilateur crée une vtable.
- Lors de la création d’un objet, le vptr pointe vers la vtable de sa classe.
- Lors d’un appel, le compilateur obtient l’adresse via vptr → vtable au lieu d’un lien statique.
- Une redéfinition (override) remplace l’entrée correspondante dans la vtable de la classe dérivée.
11) Erreurs fréquentes
- Oublier le destructeur virtuel → comportement indéfini lors de la suppression via un pointeur de base.
- Copier par valeur et provoquer un object slicing.
- Omettre
override→ une erreur de signature crée silencieusement une nouvelle fonction. - Ajouter des virtuals inutiles → complexité et coût accrus pour rien.
12) Exemple complet : collection polymorphe
#include <iostream>
#include <vector>
#include <memory>
using namespace std;
class Book {
public:
virtual ~Book() = default;
virtual double FinalPrice() const = 0;
virtual void Print() const = 0;
};
class EBook : public Book {
double base, vat;
public:
EBook(double b, double v) : base(b), vat(v) {}
double FinalPrice() const override { return base*(1+vat); }
void Print() const override {
cout << "[EBook] " << FinalPrice() << " €" << endl;
}
};
class PrintedBook : public Book {
double base, ship; int pages;
public:
PrintedBook(double b, double s, int p) : base(b), ship(s), pages(p) {}
double FinalPrice() const override { return base + ship + 0.05*pages; }
void Print() const override {
cout << "[Printed] " << FinalPrice() << " €" << endl;
}
};
int main() {
vector<unique_ptr<Book>> items;
items.push_back(make_unique<EBook>(100, 0.1));
items.push_back(make_unique<PrintedBook>(120, 20, 400));
for (const auto& it : items) it->Print(); // polymorphisme dynamique
}
13) TL;DR
- Le polymorphisme dynamique en C++ repose sur les fonctions virtuelles.
- Le compilateur les implémente avec une vtable/vptr.
- Un destructeur virtuel est indispensable pour les bases polymorphes.
- override garantit la bonne redéfinition ; final empêche de futures modifications.
- Utilisez références/pointeurs pour éviter le slicing.
- Le surcoût est généralement faible ; pour les boucles critiques, envisagez CRTP ou des surcharges.
- Tous les exemples fonctionnent sous Visual Studio 2022 et GCC 11+.