Understanding Type Erasure in C++

Type erasure is an advanced C++ design pattern that enables libraries to provide uniform, non-templated dynamic interfaces while internally supporting heterogeneous types without requiring an intrusive inheritance hierarchy. This article examines what type erasure is, breaks down the classic Concept-Model idiom used to implement it, illustrates the pattern with a practical code example, and discusses how standard library types like std::function and std::any leverage this technique to provide flexible, value-based dynamic polymorphism.

What Is Type Erasure?

Type erasure allows a programmer to manipulate objects of different types through a single, concrete, non-templated class interface. In traditional C++, runtime polymorphism requires types to inherit from a common base class using virtual functions (intrusive polymorphism). Conversely, compile-time polymorphism using templates requires interfaces to expose generic parameters, which can lead to header bloat, longer compilation times, and difficulty storing disparate objects in homogeneous containers (like std::vector).

Type erasure bridges this gap. It captures compile-time type information, binds dynamic behavior internally via templates and function pointers (or virtual tables), and then conceals—or "erases"—the original type behind an ordinary, non-templated value wrapper.

The Concept-Model Idiom

The most common technique for implementing type erasure in modern C++ is the Concept-Model pattern, popularized by Sean Parent. This approach structures the implementation into three distinct layers:

  1. The Public Wrapper Class: A regular, non-templated class with value semantics (it can be copied, moved, and passed by value). This is the only type users interact with.
  2. The Abstract Base Class (The Concept): A private, pure virtual interface defined inside the wrapper. It declares virtual functions for every operation the wrapper needs to expose.
  3. The Templated Derived Class (The Model): A private, templated derived class that implements the Concept interface. It stores the concrete underlying object and forwards calls from the Concept interface to that underlying object.

Implementation Example

Below is a complete implementation of a non-intrusive Drawable wrapper. It can hold any type that provides a draw() member function, without requiring those types to inherit from a shared base class:

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

class Drawable {
public:
    // Templated constructor accepts any type matching the requirements
    template <typename T>
    Drawable(T value) 
        : pimpl_(std::make_unique<Model<T>>(std::move(value))) {}

    // Copy constructor (deep copy via clone)
    Drawable(const Drawable& other) 
        : pimpl_(other.pimpl_ ? other.pimpl_->clone() : nullptr) {}

    Drawable& operator=(const Drawable& other) {
        Drawable temp(other);
        pimpl_ = std::move(temp.pimpl_);
        return *this;
    }

    Drawable(Drawable&&) noexcept = default;
    Drawable& operator=(Drawable&&) noexcept = default;

    // Non-templated public interface
    void draw() const {
        if (pimpl_) {
            pimpl_->draw();
        }
    }

private:
    // 1. Concept: Internal abstract base
    struct Concept {
        virtual ~Concept() = default;
        virtual void draw() const = 0;
        virtual std::unique_ptr<Concept> clone() const = 0;
    };

    // 2. Model: Templated implementation
    template <typename T>
    struct Model final : Concept {
        T data_;

        explicit Model(T value) : data_(std::move(value)) {}

        void draw() const override {
            data_.draw();
        }

        std::unique_ptr<Concept> clone() const override {
            return std::make_unique<Model<T>>(data_);
        }
    };

    // 3. Storage
    std::unique_ptr<Concept> pimpl_;
};

Usage Without Inheritance

Because the polymorphism is non-intrusive, unrelated classes can be treated identically:

struct Circle {
    void draw() const { std::cout << "Drawing Circle\n"; }
};

struct Square {
    void draw() const { std::cout << "Drawing Square\n"; }
};

int main() {
    std::vector<Drawable> shapes;
    shapes.push_back(Circle{});
    shapes.push_back(Square{});

    for (const auto& shape : shapes) {
        shape.draw();
    }
}

Benefits of Type Erasure

  • Non-Intrusive Design: External classes, third-party libraries, and primitive types do not need to be refactored or modified to derive from a custom base class.
  • Value Semantics: Unlike standard inheritance, which requires holding objects by raw pointer (Base*) or smart pointer (std::unique_ptr<Base>), type-erased wrappers behave like standard values with proper copy and move semantics.
  • ABI and Compilation Isolation: Templated code is kept entirely within the implementation details. Public interfaces remain non-templated, reducing compilation times and preventing implementation leakage across library boundaries.
  • Heterogeneous Collections: Objects of different types can be placed into standard containers like std::vector<Drawable> without manual memory management.

Real-World Applications in the Standard Library

Type erasure is widely used across standard C++ facilities:

  • std::function<R(Args...)>: Erases the type of any callable object (lambdas, function pointers, member function pointers, or custom functors) that matches the specified signature.
  • std::any: Stores any copy-constructible type and erases the static type entirely until explicit runtime queries (std::any_cast) are made.
  • std::move_only_function (C++23): Similar to std::function, but designed for move-only callables without requiring dynamic copy support.

By isolating templates to an internal instantiation step and presenting a stable, concrete type to the public interface, type erasure gives C++ developers the flexibility of dynamic polymorphism combined with the safety and performance of value-oriented programming.