Aller au contenu

Motifs de métaprogrammation par templates

Maîtrisez les motifs de templates avancés : le CRTP pour le polymorphisme statique, la conception par politiques pour une composition flexible, l'effacement de type pour la généricité à l'exécution, et des repères pour choisir entre templates et dispatch virtuel.

Au-delà des templates de base

Les templates permettent des motifs de conception sans équivalent dans d'autres langages. Ces motifs exploitent la nature « compilation » des templates pour obtenir du polymorphisme statique (sans le surcoût des fonctions virtuelles), des comportements composables (les politiques) et un effacement de type sûr (masquer les types concrets derrière une interface uniforme).

Ce ne sont pas des motifs pour débutants — ce sont des outils destinés aux auteurs de bibliothèques et aux concepteurs de frameworks. Les comprendre est indispensable pour lire le code interne de la bibliothèque standard, de Boost et des frameworks C++ modernes.

CRTP : Curiously Recurring Template Pattern

Avec le CRTP, une classe dérive d'un template instancié avec elle-même comme argument : class Derived : public Base. La classe de base peut appeler des méthodes de la classe dérivée à la compilation — sans le surcoût du dispatch virtuel. On l'utilise pour injecter des comportements, implémenter du polymorphisme statique et créer des fonctionnalités de type mixin.

crtp.cpp
#include <iostream>

// CRTP base: provides a counter for each derived class
template <typename Derived>
class Counter {
    static inline int count_ = 0;

protected:
    Counter()  { ++count_; }
    ~Counter() { --count_; }
    Counter(const Counter&) { ++count_; }

public:
    static int alive() { return count_; }
};

// Each derived class gets its OWN counter
class Dog : public Counter<Dog> {
public:
    std::string name;
    Dog(std::string n) : name(std::move(n)) {}
};

class Cat : public Counter<Cat> {
public:
    std::string name;
    Cat(std::string n) : name(std::move(n)) {}
};

// CRTP for static polymorphism: no virtual dispatch
template <typename Derived>
class Shape {
public:
    double area() const {
        // Call derived class method — resolved at compile time
        return static_cast<const Derived*>(this)->area_impl();
    }
};

class Circle : public Shape<Circle> {
    double radius_;
public:
    Circle(double r) : radius_(r) {}
    double area_impl() const { return 3.14159265 * radius_ * radius_; }
};

class Square : public Shape<Square> {
    double side_;
public:
    Square(double s) : side_(s) {}
    double area_impl() const { return side_ * side_; }
};

int main() {
    Dog d1("Rex"), d2("Buddy");
    Cat c1("Whiskers");
    std::cout << "Dogs alive: " << Dog::alive() << '\n'; // 2
    std::cout << "Cats alive: " << Cat::alive() << '\n'; // 1

    Circle circle(5.0);
    Square square(4.0);
    std::cout << "Circle area: " << circle.area() << '\n'; // 78.54
    std::cout << "Square area: " << square.area() << '\n'; // 16.0
}

Conception par politiques

La conception par politiques (policy-based design) décompose une classe en composants de comportement interchangeables (les politiques), passés comme paramètres de template. Chaque politique implémente un aspect du comportement. Les utilisateurs composent exactement la combinaison dont ils ont besoin. Cette approche a été popularisée par le livre d'Andrei Alexandrescu « Modern C++ Design ».

policy_design.cpp
#include <iostream>
#include <mutex>
#include <stdexcept>

// Threading policies
struct SingleThreaded {
    struct Lock { Lock(SingleThreaded&) {} }; // no-op
};

struct MultiThreaded {
    std::mutex mtx;
    struct Lock {
        std::lock_guard<std::mutex> guard;
        Lock(MultiThreaded& host) : guard(host.mtx) {}
    };
};

// Bounds checking policies
struct NoBoundsCheck {
    static void check(int /*index*/, int /*size*/) {} // no-op
};

struct StrictBoundsCheck {
    static void check(int index, int size) {
        if (index < 0 || index >= size)
            throw std::out_of_range("Index out of bounds");
    }
};

// Policy-based container: users choose their combination
template <typename T,
         typename ThreadingPolicy = SingleThreaded,
         typename BoundsPolicy = NoBoundsCheck>
class SmartArray : private ThreadingPolicy {
    T* data_;
    int size_;

public:
    SmartArray(int size) : data_(new T[size]{}), size_(size) {}
    ~SmartArray() { delete[] data_; }

    T& operator[](int index) {
        typename ThreadingPolicy::Lock lock(*this);
        BoundsPolicy::check(index, size_);
        return data_[index];
    }

    int size() const { return size_; }
};

int main() {
    // Fast, no overhead: no locking, no bounds checks
    SmartArray<int> fast(10);
    fast[0] = 42;

    // Safe: bounds-checked, single-threaded
    SmartArray<int, SingleThreaded, StrictBoundsCheck> safe(10);
    safe[0] = 42;
    try {
        safe[99] = 0; // throws!
    } catch (const std::out_of_range& e) {
        std::cout << e.what() << '\n';
    }

    // Thread-safe with bounds checking
    SmartArray<int, MultiThreaded, StrictBoundsCheck> robust(10);
    robust[5] = 100;
}

Effacement de type

L'effacement de type (type erasure) masque le type concret derrière une interface uniforme, en combinant la flexibilité des templates et le polymorphisme à l'exécution des fonctions virtuelles. L'exemple classique est std::function, qui peut contenir n'importe quel appelable. Le motif repose sur trois composants : un concept (interface abstraite), un modèle (enveloppe template qui implémente l'interface pour n'importe quel type) et une classe poignée externe (outer handle).

type_erasure.cpp
#include <iostream>
#include <memory>
#include <string>

// Type-erased "Printable" — can hold any type with a print() method
class Printable {
    // Concept: abstract interface
    struct Concept {
        virtual ~Concept() = default;
        virtual void print(std::ostream& os) const = 0;
        virtual std::unique_ptr<Concept> clone() const = 0;
    };

    // Model: wraps any T that supports operator<<
    template <typename T>
    struct Model : Concept {
        T value;
        Model(T v) : value(std::move(v)) {}
        void print(std::ostream& os) const override { os << value; }
        std::unique_ptr<Concept> clone() const override {
            return std::make_unique<Model>(value);
        }
    };

    std::unique_ptr<Concept> ptr_;

public:
    // Constructor: accepts any printable type
    template <typename T>
    Printable(T value) : ptr_(std::make_unique<Model<T>>(std::move(value))) {}

    // Copy support
    Printable(const Printable& other) : ptr_(other.ptr_->clone()) {}
    Printable& operator=(const Printable& other) {
        ptr_ = other.ptr_->clone();
        return *this;
    }
    Printable(Printable&&) = default;
    Printable& operator=(Printable&&) = default;

    friend std::ostream& operator<<(std::ostream& os, const Printable& p) {
        p.ptr_->print(os);
        return os;
    }
};

int main() {
    // Heterogeneous collection — no common base class needed!
    std::vector<Printable> items;
    items.emplace_back(42);
    items.emplace_back(3.14);
    items.emplace_back(std::string("hello"));
    items.emplace_back('X');

    for (const auto& item : items) {
        std::cout << item << '\n';
    }
    // Output: 42, 3.14, hello, X
}

Expression templates (aperçu)

Les expression templates sont une technique dans laquelle les expressions arithmétiques sur des objets construisent un arbre d'opérations à la compilation au lieu de calculer des résultats intermédiaires. Le résultat final est calculé en une seule passe, ce qui élimine les objets temporaires.

Par exemple, avec une classe Vector naïve, a + b + c crée deux vecteurs temporaires. Avec les expression templates, a + b + c construit un objet d'expression léger Add, Vector> qui calcule les éléments à la demande — aucun temporaire n'est nécessaire.

Cette technique est utilisée par des bibliothèques d'algèbre linéaire hautes performances comme Eigen et Blaze. L'arbre d'expression est évalué paresseusement, et le compilateur peut souvent vectoriser la boucle unique qui en résulte.

Les expression templates sont complexes à implémenter correctement et concernent surtout les auteurs de bibliothèques. Si vous avez besoin de calcul matriciel ou vectoriel, utilisez une bibliothèque comme Eigen plutôt que d'implémenter vous-même des expression templates.

Bonne pratique

Pour choisir entre les templates (polymorphisme statique) et les fonctions virtuelles (polymorphisme dynamique) :

Utilisez les templates lorsque :
- L'ensemble des types est connu à la compilation
- Vous avez besoin d'abstractions sans surcoût (pas de vtable, pas d'indirection)
- Vous voulez que le compilateur puisse inliner et optimiser au-delà des frontières de types
- Vous devez travailler avec une sémantique de valeur (pas d'allocation sur le tas)
- La performance est critique (boucles serrées, code numérique)

Utilisez les fonctions virtuelles lorsque :
- L'ensemble des types est ouvert et déterminé à l'exécution (plugins, saisie utilisateur)
- Vous avez besoin de conteneurs hétérogènes (par ex. vector>)
- La taille du binaire compte plus que la vitesse (les templates génèrent davantage de code)
- Vous voulez une ABI stable entre bibliothèques partagées
- Vous devez ajouter de nouveaux types sans recompiler le code existant

Utilisez l'effacement de type lorsque :
- Vous voulez la simplicité d'interface du dispatch virtuel sans obliger les utilisateurs à hériter de votre classe de base
- Vous avez besoin d'une sémantique de valeur avec du polymorphisme à l'exécution (std::function, std::any)

Piège

La métaprogrammation par templates est puissante, mais elle a de vrais coûts :

1. Explosion des temps de compilation — des instanciations de templates profondément imbriquées peuvent rendre les temps de compilation inacceptables. La TMP récursive est particulièrement coûteuse — préférez les expressions de repli et if constexpr.
2. Messages d'erreur — sans concepts, les erreurs de templates produisent des pages de texte incompréhensible. Contraignez toujours vos templates.
3. Débogage — parcourir pas à pas du code truffé de templates dans un débogueur est pénible. Les enveloppes à effacement de type ajoutent une indirection qui masque le type réel.
4. Lisibilité — le CRTP, la conception par politiques et les expression templates sont difficiles à comprendre pour les développeurs débutants. Documentez le nom du motif et son intention.
5. Sur-ingénierie — tous les problèmes n'appellent pas une solution à base de templates. Une simple fonction virtuelle, voire une instruction switch, peut être plus claire et suffisante.

Points clés
  • Le CRTP permet le polymorphisme statique ainsi que des compteurs/mixins par classe, en dérivant de Base
  • La conception par politiques décompose le comportement en paramètres de template — les utilisateurs composent exactement la combinaison dont ils ont besoin
  • L'effacement de type combine la flexibilité des templates et le polymorphisme à l'exécution (par ex. std::function, std::any)
  • Les expression templates éliminent les temporaires dans les opérations enchaînées — utilisées par Eigen, Blaze et d'autres bibliothèques similaires
  • Choisissez les templates pour des types connus et un surcoût nul ; le dispatch virtuel pour des ensembles de types ouverts et de la flexibilité à l'exécution
  • Évitez la sur-ingénierie — utilisez le mécanisme le plus simple qui répond à vos besoins

Quiz — Testez vos connaissances

(20 XP)

1. Avec le CRTP, qu'accomplit `class Dog : public Counter<Dog>` ?

2. Quel problème l'effacement de type résout-il ?

3. Quand devriez-vous préférer les fonctions virtuelles aux templates ?