Vai al contenuto

Pattern di metaprogrammazione con i template

Padroneggia i pattern template avanzati: CRTP per il polimorfismo statico, design basato su policy per una composizione flessibile, type erasure per la genericità a runtime e criteri per scegliere tra template e dispatch virtuale.

Oltre i template di base

I template rendono possibili pattern di progettazione che non hanno equivalenti in altri linguaggi. Questi pattern sfruttano la natura compile-time dei template per ottenere polimorfismo statico (nessun costo per le funzioni virtuali), comportamenti componibili (le policy) e cancellazione dei tipi type-safe (nascondere i tipi concreti dietro un'interfaccia uniforme).

Non sono pattern da principianti: sono strumenti per chi scrive librerie e progetta framework. Capirli è essenziale per leggere le viscere della libreria standard, Boost e i framework C++ moderni.

CRTP: Curiously Recurring Template Pattern

Nel CRTP una classe deriva da un template istanziato con se stessa come argomento: class Derived : public Base. La classe base può chiamare i metodi della classe derivata a tempo di compilazione, senza il costo del dispatch virtuale. Si usa per iniettare comportamenti, realizzare il polimorfismo statico e creare funzionalità 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
}

Design basato su policy

Il design basato su policy scompone una classe in componenti di comportamento intercambiabili (le policy) passati come parametri template. Ogni policy implementa un aspetto del comportamento. Chi usa la classe compone esattamente la combinazione che gli serve. È stato reso popolare da "Modern C++ Design" di Andrei Alexandrescu.

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;
}

Type erasure

La type erasure nasconde il tipo concreto dietro un'interfaccia uniforme, unendo la flessibilità dei template al polimorfismo a runtime delle funzioni virtuali. L'esempio classico è std::function, che può contenere qualsiasi callable. Il pattern usa tre componenti: un concept (l'interfaccia astratta), un model (il wrapper template che implementa l'interfaccia per qualsiasi tipo) e una classe handle esterna.

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 template (panoramica)

Gli expression template sono una tecnica in cui le espressioni aritmetiche tra oggetti costruiscono un albero di operazioni a tempo di compilazione invece di calcolare i risultati intermedi. Il risultato finale viene calcolato in un'unica passata, eliminando gli oggetti temporanei.

Per esempio, con una classe Vector ingenua, a + b + c crea due vettori temporanei. Con gli expression template, a + b + c costruisce un leggero oggetto espressione Add, Vector> che calcola gli elementi su richiesta — nessun temporaneo.

Questa tecnica è usata dalle librerie di algebra lineare ad alte prestazioni come Eigen e Blaze. L'albero dell'espressione viene valutato in modo lazy e il compilatore riesce spesso a vettorizzare il ciclo a passata singola che ne risulta.

Gli expression template sono complessi da implementare correttamente e riguardano soprattutto chi scrive librerie. Se ti serve matematica vettoriale o matriciale, usa una libreria come Eigen invece di implementarli da zero.

Buona pratica

Quando devi scegliere tra template (polimorfismo statico) e funzioni virtuali (polimorfismo dinamico):

Usa i template quando:
- L'insieme dei tipi è noto a tempo di compilazione
- Ti servono astrazioni a costo zero (nessuna vtable, nessuna indirezione)
- Vuoi che il compilatore faccia inlining e ottimizzi attraverso i confini dei tipi
- Devi lavorare con la semantica di valore (nessuna allocazione sull'heap)
- Le prestazioni sono critiche (cicli stretti, codice numerico)

Usa le funzioni virtuali quando:
- L'insieme dei tipi è aperto e viene determinato a runtime (plugin, input dell'utente)
- Ti servono contenitori eterogenei (per esempio vector>)
- La dimensione del binario conta più della velocità (i template generano più codice)
- Vuoi un ABI stabile attraverso i confini delle librerie condivise
- Devi aggiungere nuovi tipi senza ricompilare il codice esistente

Usa la type erasure quando:
- Vuoi la semplicità d'interfaccia del dispatch virtuale ma non vuoi costringere gli utenti a ereditare dalla tua classe base
- Ti serve la semantica di valore insieme al polimorfismo a runtime (std::function, std::any)

Insidia

La metaprogrammazione con i template è potente, ma ha costi concreti:

1. Esplosione dei tempi di compilazione — istanziazioni template profondamente annidate possono rendere i tempi di compilazione inaccettabili. La TMP ricorsiva è particolarmente dannosa — preferisci le espressioni fold e if constexpr.
2. Messaggi di errore — senza i concept, gli errori sui template producono pagine di testo incomprensibile. Vincola sempre i tuoi template.
3. Debug — passo dopo passo in un debugger, il codice denso di template è una sofferenza. I wrapper con type erasure aggiungono un'indirezione che nasconde il tipo reale.
4. Leggibilità — CRTP, design basato su policy ed expression template sono difficili da capire per gli sviluppatori meno esperti. Documenta il nome del pattern e il suo scopo.
5. Sovraingegnerizzazione — non tutti i problemi richiedono una soluzione basata sui template. Una semplice funzione virtuale o persino uno switch possono essere più chiari e più che sufficienti.

Concetti chiave
  • Il CRTP abilita il polimorfismo statico e contatori o mixin per singola classe, derivando da Base
  • Il design basato su policy scompone il comportamento in parametri template — chi usa la classe compone esattamente la combinazione che gli serve
  • La type erasure unisce la flessibilità dei template al polimorfismo a runtime (per esempio std::function, std::any)
  • Gli expression template eliminano i temporanei nelle operazioni concatenate — sono usati da Eigen, Blaze e librerie simili
  • Scegli i template per tipi noti in anticipo e costo zero; il dispatch virtuale per insiemi di tipi aperti e flessibilità a runtime
  • Evita la sovraingegnerizzazione — usa il meccanismo più semplice che soddisfa i tuoi requisiti

Quiz — Metti alla prova le tue conoscenze

(20 XP)

1. Nel CRTP, cosa ottiene `class Dog : public Counter<Dog>`?

2. Quale problema risolve la type erasure?

3. Quando è preferibile usare le funzioni virtuali invece dei template?