C/C++ Arena

Step 6 of 7

Type erasure

std::function<int(int)> can hold a plain function, a lambda with captures, or any object with a matching operator(). None of them inherit from anything, yet one type holds them all, and you can copy it like a value. That technique is type erasure: the wrapper forgets ("erases") the concrete type, but still knows how to call, copy and destroy what's inside.

Classic polymorphism asks every type to inherit from a base class. That's intrusive: you can't make int or a third-party class inherit from your Shape, and a std::vector<std::unique_ptr<Shape>> gives you pointers and heap allocations instead of values. Type erasure keeps the inheritance inside the wrapper:

#include <iostream>
#include <memory>
#include <vector>

class Shape {
public:
    template <typename T>
    Shape(T value) : self_(std::make_unique<Model<T>>(std::move(value))) {}
    Shape(const Shape& other) : self_(other.self_->clone()) {}
    Shape& operator=(const Shape& other) {
        self_ = other.self_->clone();
        return *this;
    }
    Shape(Shape&&) noexcept = default;
    Shape& operator=(Shape&&) noexcept = default;

    double area() const { return self_->area(); }

private:
    struct Concept {                                   // the interface, hidden inside
        virtual ~Concept() = default;
        virtual double area() const = 0;
        virtual std::unique_ptr<Concept> clone() const = 0;
    };
    template <typename T>
    struct Model : Concept {                           // one per concrete type, generated
        T value;
        explicit Model(T v) : value(std::move(v)) {}
        double area() const override { return value.area(); }
        std::unique_ptr<Concept> clone() const override { return std::make_unique<Model>(*this); }
    };
    std::unique_ptr<Concept> self_;
};

struct Circle { double r; double area() const { return 3.14159 * r * r; } };   // no base class
struct Square { double s; double area() const { return s * s; } };

int main() {
    std::vector<Shape> shapes{Circle{1}, Square{2}, Square{3}};   // values, not pointers
    std::vector<Shape> copy = shapes;                              // deep copies
    double total = 0;
    for (const Shape& s : copy) total += s.area();
    std::cout << total << "\n";
}
16.1416

How it works

std::function, std::any and std::shared_ptr's deleter all work this way. Libraries add a "small buffer optimization" to store small objects inside the wrapper without a heap allocation, but the idea is the same.

Your turn: write Task, a type-erased wrapper for anything with int run() const. It needs the templated constructor, copying (with clone) and int run() const. The tests keep tasks of unrelated types in one std::vector<Task> and check that copies still work after the originals are gone.

Previous: RAII wrappers for C libraries Next: Challenge: a table-driven state machine