Aller au contenu

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.

polymorphism.cpp
#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.

virtual_destructor.cpp
#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.

override_final.cpp
#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.

nvi_pattern.cpp
#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(base_ptr) 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.

Piège

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.

Points clés
  • 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 override lors 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 ?