Fonctions virtuelles, vtables et polymorphisme
Plongez dans le polymorphisme d'exécution : comment le dispatch virtuel fonctionne en coulisses, quand utiliser override et final, les classes abstraites et le RTTI.
Polymorphisme d'exécution
Le polymorphisme signifie « plusieurs formes » — la même interface se comporte différemment selon le type réel de l'objet. En C++, le polymorphisme d'exécution est réalisé via les fonctions virtuelles. Quand vous appelez une fonction virtuelle via un pointeur ou une référence de base, l'appel est dispatché vers l'implémentation de la classe dérivée réelle à l'exécution.
C'est le mécanisme qui rend la POO puissante : vous pouvez écrire du code qui opère sur une interface de classe de base, et il fonctionne automatiquement correctement avec n'importe quelle classe dérivée — même celles écrites après votre code.
Comment fonctionnent les vtables
Quand une classe a des fonctions virtuelles, le compilateur crée une vtable (table de fonctions virtuelles) — un tableau de pointeurs de fonctions. Chaque objet de cette classe contient un vptr caché (pointeur vers la vtable). Quand une fonction virtuelle est appelée, le programme suit le vptr vers la vtable, recherche le pointeur de fonction et appelle via celui-ci. Cette indirection est la raison pour laquelle les appels virtuels ont un léger surcoût par rapport aux appels directs.
#include <iostream>
#include <memory>
#include <vector>
class Shape {
public:
virtual ~Shape() = default;
// Pure virtual — no default implementation
virtual double area() const = 0;
// Virtual with default implementation
virtual std::string name() const { return "Shape"; }
};
class Circle : public Shape {
double radius_;
public:
explicit Circle(double r) : radius_{r} {}
double area() const override {
return 3.14159265 * radius_ * radius_;
}
std::string name() const override { return "Circle"; }
};
class Rectangle : public Shape {
double w_, h_;
public:
Rectangle(double w, double h) : w_{w}, h_{h} {}
double area() const override { return w_ * h_; }
std::string name() const override { return "Rectangle"; }
};
// Works with ANY Shape — past, present, or future
void print_info(const Shape& s) {
std::cout << s.name() << ": area = " << s.area() << '\n';
}
int main() {
Circle c{5.0};
Rectangle r{3.0, 4.0};
print_info(c); // Circle: area = 78.5398
print_info(r); // Rectangle: area = 12
// Polymorphic container
std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Circle>(2.0));
shapes.push_back(std::make_unique<Rectangle>(6.0, 3.0));
for (const auto& s : shapes) {
print_info(*s); // virtual dispatch at runtime
}
return 0;
}Les destructeurs virtuels sont obligatoires
Si une classe a une quelconque fonction virtuelle, son destructeur doit être virtuel. Sans cela, supprimer un objet dérivé via un pointeur de base cause un comportement indéfini — le destructeur de la dérivée ne s'exécute jamais, causant des fuites de ressources.
#include <iostream>
#include <memory>
class Base {
public:
// Without virtual destructor: UNDEFINED BEHAVIOR on delete
// virtual ~Base() = default; // ALWAYS do this!
~Base() { std::cout << "~Base\n"; }
};
class Derived : public Base {
int* data_;
public:
Derived() : data_{new int[1000]} {
std::cout << "Derived: allocated\n";
}
~Derived() {
delete[] data_;
std::cout << "~Derived: freed\n";
}
};
int main() {
Base* ptr = new Derived{};
delete ptr; // Only ~Base runs! ~Derived is NOT called!
// Result: memory leak (data_ never freed)
// Fix: make ~Base() virtual
return 0;
}Les mots-clés override et final
Le mot-clé override indique au compilateur que vous avez l'intention de surcharger une fonction virtuelle de la classe de base. Si la signature ne correspond pas (une faute de frappe courante), le compilateur la détecte. Le mot-clé final empêche toute surcharge supplémentaire d'une fonction ou empêche l'héritage d'une classe.
#include <iostream>
class Base {
public:
virtual ~Base() = default;
virtual void process(int x) const { std::cout << "Base: " << x << '\n'; }
virtual void execute() { std::cout << "Base::execute\n"; }
};
class Middle : public Base {
public:
// override: compiler checks signature matches Base::process
void process(int x) const override {
std::cout << "Middle: " << x * 2 << '\n';
}
// final: no further derived class can override execute()
void execute() final { std::cout << "Middle::execute\n"; }
};
class Bottom : public Middle {
public:
void process(int x) const override {
std::cout << "Bottom: " << x * 3 << '\n';
}
// ERROR if uncommented: execute() is final in Middle
// void execute() override { }
};
// A final class cannot be inherited from at all
class Leaf final : public Base {
public:
void process(int x) const override {
std::cout << "Leaf: " << x << '\n';
}
};
// ERROR if uncommented: cannot inherit from final class
// class Attempt : public Leaf {};
int main() {
Bottom b;
Base& ref = b;
ref.process(10); // Bottom: 30 (virtual dispatch)
ref.execute(); // Middle::execute (final version)
return 0;
}Classes abstraites et patron NVI
Une classe avec au moins une fonction virtuelle pure (= 0) est abstraite — elle ne peut pas être instanciée. Le patron Non-Virtual Interface (NVI) fournit un point d'entrée public non virtuel qui appelle une fonction virtuelle privée, permettant à la classe de base de contrôler les pré/post conditions.
#include <iostream>
#include <string>
// NVI Pattern: public non-virtual calls private virtual
class Validator {
public:
virtual ~Validator() = default;
// Public non-virtual interface — controls the workflow
bool validate(const std::string& input) const {
if (input.empty()) {
std::cout << "[Validator] Input is empty\n";
return false;
}
// Delegate to derived class implementation
bool result = do_validate(input);
std::cout << "[Validator] " << (result ? "PASS" : "FAIL") << '\n';
return result;
}
private:
// Pure virtual — derived classes MUST implement
virtual bool do_validate(const std::string& input) const = 0;
};
class EmailValidator : public Validator {
bool do_validate(const std::string& input) const override {
return input.find('@') != std::string::npos
&& input.find('.') != std::string::npos;
}
};
class LengthValidator : public Validator {
std::size_t min_len_;
bool do_validate(const std::string& input) const override {
return input.length() >= min_len_;
}
public:
explicit LengthValidator(std::size_t min) : min_len_{min} {}
};
int main() {
EmailValidator email_v;
LengthValidator len_v{5};
email_v.validate("user@example.com"); // PASS
email_v.validate("invalid"); // FAIL
len_v.validate("hi"); // FAIL
len_v.validate("hello!"); // PASS
return 0;
}RTTI et dynamic_cast
Le RTTI (Run-Time Type Information) vous permet d'interroger le type réel d'un objet polymorphique à l'exécution. dynamic_cast effectue un cast sûr d'un pointeur de base vers un pointeur dérivé, retournant nullptr si le cast échoue. Pour les références, un dynamic_cast échoué lance std::bad_cast.
typeid(obj) retourne une référence std::type_info qui peut être comparée pour identifier le type à l'exécution.
Utilisez le RTTI avec parcimonie — un usage fréquent de dynamic_cast signale souvent un problème de conception. Préférez les fonctions virtuelles pour le comportement spécifique au type.
N'appelez jamais de fonctions virtuelles depuis un constructeur ou un destructeur. Pendant la construction, le type dynamique de l'objet est la classe en cours de construction, pas la classe dérivée finale. Un appel virtuel dans Base::Base() appellera Base::method(), pas Derived::method() — même si l'objet créé est un Derived. C'est une source bien connue de bogues qui peuvent être extrêmement difficiles à diagnostiquer.
- Les fonctions virtuelles permettent le polymorphisme d'exécution via les vtables et vptrs
- Toute classe avec des fonctions virtuelles doit avoir un destructeur
virtual - Utilisez toujours
overridelors d'une surcharge — cela détecte les incompatibilités de signature à la compilation - Les fonctions virtuelles pures (
= 0) rendent une classe abstraite et forcent les classes dérivées à les implémenter - Le patron NVI permet aux classes de base d'appliquer des pré/post conditions autour des points de personnalisation
- Évitez d'appeler des fonctions virtuelles dans les constructeurs/destructeurs — la classe dérivée n'est pas encore (ou n'est plus) entièrement construite
Quiz — Testez vos connaissances
(20 XP)1. Que se passe-t-il si une classe de base polymorphique n'a PAS de destructeur virtuel ?
2. Que fait le mot-clé `override` ?
3. Pourquoi devriez-vous éviter d'appeler des fonctions virtuelles depuis un constructeur ?