Type Erasure in C++: How std::function and std::any Work, and Building Your Own

Key takeaways

Type erasure hides concrete types behind a stable interface: how std::function and std::any use it, how to implement it by hand, practical use cases, and how it compares to templates and inheritance.

What is Type Erasure?

Type Erasure hides type information and handles various types through a unified interface. This design pattern enables polymorphism without requiring inheritance. std::any and std::function are prominent examples.

#include <any>
#include <iostream>
using namespace std;
int main() {
    any a = 10;
    cout << any_cast<int>(a) << endl;  // 10
    
    a = 3.14;
    cout << any_cast<double>(a) << endl;  // 3.14
    
    a = string("Hello");
    cout << any_cast<string>(a) << endl;  // Hello
}

Why is it needed?

  • No inheritance required: Implements polymorphism without modifying existing types.
  • Flexibility: Useful when types are unknown at compile time.
  • Decoupling: Removes dependencies between types.
  • Reusability: Provides consistent interface for diverse types.

The underlying idea is that the concrete type is known in exactly one place: a template constructor. At that moment the wrapper instantiates a small piece of code that knows how to perform each supported operation on that type (call it, copy it, destroy it, draw it), stores a pointer to that code next to the object, and then forgets the type. Every later operation goes through the stored pointer. Virtual functions do the same thing, but they force the object’s own class to carry the vtable, which means it must inherit from your interface. Type erasure moves the vtable into a wrapper you own, so int, a lambda, or a class from a third-party library can all satisfy the interface without being modified.

The other difference is value semantics. A classic inheritance hierarchy pushes you toward std::vector<std::unique_ptr<Shape>>, with explicit ownership and pointer syntax everywhere. A type-erased Drawable can be stored in a plain std::vector<Drawable>, copied and assigned like an int. Sean Parent’s talk “Inheritance Is the Base Class of Evil” popularized this style, and it is the design behind std::function, std::any, and many library interfaces.

The sketch below is conceptual (the Drawable internals are shown later in the article); what matters is that Circle does not name Drawable anywhere.

// ❌ Virtual functions: requires inheritance
class Shape {
public:
    virtual void draw() = 0;
};
class Circle : public Shape {  // Requires inheritance
    void draw() override {}
};
// ✅ Type Erasure: no inheritance required
class Drawable {
    // Internal implementation...
};
class Circle {  // No inheritance needed!
    void draw() {}
};
Drawable d = Circle();  // OK
d.draw();

Type Erasure Structure

graph TD
    A[Drawable External Interface] --> B[DrawableConcept Abstract Interface]
    B --> C[DrawableModel&lt;Circle&gt;]
    B --> D[DrawableModel&lt;Rectangle&gt;]
    C --> E[Circle Object]
    D --> F[Rectangle Object]
    
    style A fill:#90EE90
    style B fill:#FFB6C1
    style C fill:#87CEEB
    style D fill:#87CEEB

Type Erasure vs Virtual Functions

FeatureVirtual FunctionsType Erasure
Inheritance✅ Required❌ Not required
Modify existing type✅ Required❌ Not required
Flexibility❌ Limited✅ High
Implementation Complexity✅ Low❌ High
Performance✅ Fast❌ Slightly slower
Type Information✅ Maintained❌ Erased
// Virtual functions: requires inheritance
class Shape {
public:
    virtual void draw() = 0;
};
class Circle : public Shape {
    void draw() override {
        std::cout << "Circle\n";
    }
};
// Type Erasure: no inheritance required
class Circle {  // Does not inherit Shape
public:
    void draw() const {
        std::cout << "Circle\n";
    }
};
Drawable d = Circle();  // OK

std::function: Function Type Erasure

What is std::function?

std::function is type-erased function wrapper that stores any callable object (function pointer, lambda, functor, etc.) with matching signature.

#include <functional>
#include <iostream>
void freeFunc() { std::cout << "Free function\n"; }
struct Functor {
    void operator()() const { std::cout << "Functor\n"; }
};
int main() {
    std::function<void()> f;
    f = freeFunc;
    f();  // Free function
    f = Functor{};
    f();  // Functor
    f = []() { std::cout << "Lambda\n"; };
    f();  // Lambda
    return 0;
}

Practical Example: Callback System

#include <functional>
#include <vector>
#include <iostream>
class EventManager {
    std::vector<std::function<void(int)>> listeners;
public:
    void subscribe(std::function<void(int)> callback) {
        listeners.push_back(std::move(callback));
    }
    void emit(int value) {
        for (auto& listener : listeners) {
            listener(value);
        }
    }
};
int main() {
    EventManager em;
    em.subscribe([](int x) { std::cout << "Listener 1: " << x << "\n"; });
    em.subscribe([](int x) { std::cout << "Listener 2: " << x * 2 << "\n"; });
    em.emit(10);  // Both listeners called
    return 0;
}

Output:

Listener 1: 10
Listener 2: 20

std::function Characteristics

  • Flexible: Stores any callable with matching signature.
  • Cost: Possible heap allocation, indirect call overhead.
  • Copyable: Can copy std::function, but captured objects are copied too (expensive for large captures).

“Matching signature” is looser than it sounds: std::function<void(int)> accepts any callable that can be invoked with an int and whose result can be converted to (or discarded for) the return type, so a lambda taking long or returning bool is accepted. The allocation is not guaranteed either. All three major standard libraries store small callables, typically a function pointer or a lambda capturing a pointer or two, inside the std::function object itself (the small buffer optimization) and heap-allocate only larger ones. The buffer size is an implementation detail, around 16 bytes in libstdc++ and larger in libc++ and MSVC’s library, so the same code can allocate on one platform and not on another.

Calling an empty std::function throws std::bad_function_call, which is a runtime error rather than a compile error; check if (f) before calling when the callback is optional. The call itself cannot be inlined through the erased pointer, which is the real cost in tight loops, as the performance section below shows.

std::any: Type Erasure for Arbitrary Types

What is std::any?

std::any stores any type object, retrievable with any_cast. Type information checked at runtime.

#include <any>
#include <iostream>
#include <string>
int main() {
    std::any a = 10;
    std::cout << std::any_cast<int>(a) << "\n";  // 10
    a = 3.14;
    std::cout << std::any_cast<double>(a) << "\n";  // 3.14
    a = std::string("Hello");
    std::cout << std::any_cast<std::string>(a) << "\n";  // Hello
    return 0;
}

Practical Example: Type-Safe Any Container

#include <any>
#include <map>
#include <string>
#include <iostream>
class Config {
    std::map<std::string, std::any> data;
public:
    template <typename T>
    void set(const std::string& key, T value) {
        data[key] = std::move(value);
    }
    template <typename T>
    T get(const std::string& key) const {
        return std::any_cast<T>(data.at(key));
    }
};
int main() {
    Config cfg;
    cfg.set("port", 8080);
    cfg.set("host", std::string("localhost"));
    cfg.set("timeout", 30.0);
    std::cout << "Port: " << cfg.get<int>("port") << "\n";
    std::cout << "Host: " << cfg.get<std::string>("host") << "\n";
    std::cout << "Timeout: " << cfg.get<double>("timeout") << "\n";
    return 0;
}

std::any Characteristics

  • Flexibility: Stores arbitrary types.
  • Runtime check: any_cast throws std::bad_any_cast if type mismatches.
  • Cost: Heap allocation for large types, type info check overhead.

std::any_cast checks for an exact type match; it performs no conversions. That is the source of the most common std::any bug: cfg.set("port", 8080) stores an int, and cfg.get<long>("port") throws bad_any_cast even though the value obviously fits. The same happens with string literals: std::any a = "localhost"; stores a const char*, not a std::string, so any_cast<std::string> fails. The Config example avoids this by wrapping the host in std::string(...) explicitly, which is the kind of discipline std::any requires everywhere.

Implementations may store small, nothrow-movable types inline, but the standard only encourages it; anything larger goes to the heap. The contained type must be copy-constructible, because copying the std::any copies the value, so move-only types such as std::unique_ptr cannot be stored at all. In most codebases a std::map<std::string, std::any> is a sign that the set of possible types is actually known, and a std::variant<int, double, std::string> would give compile-time checking and no allocation.

Manual Type Erasure Implementation

Basic Structure

Manual type erasure uses Concept (abstract interface) and Model (concrete implementation) pattern.

#include <memory>
#include <iostream>
class Drawable {
    struct Concept {
        virtual ~Concept() = default;
        virtual void draw() const = 0;
    };
    template <typename T>
    struct Model : Concept {
        T object;
        Model(T obj) : object(std::move(obj)) {}
        void draw() const override {
            object.draw();
        }
    };
    std::unique_ptr<Concept> pImpl;
public:
    template <typename T>
    Drawable(T obj) : pImpl(std::make_unique<Model<T>>(std::move(obj))) {}
    void draw() const {
        pImpl->draw();
    }
};
// No inheritance needed!
struct Circle {
    void draw() const { std::cout << "Circle\n"; }
};
struct Rectangle {
    void draw() const { std::cout << "Rectangle\n"; }
};
int main() {
    Drawable d1 = Circle{};
    Drawable d2 = Rectangle{};
    d1.draw();  // Circle
    d2.draw();  // Rectangle
    return 0;
}

Structure explanation:

  • Concept: Abstract interface defining draw() virtual function.
  • Model<T>: Template class inheriting Concept, holding concrete type T object.
  • Drawable: External interface holding unique_ptr<Concept>, hiding concrete type.

The inheritance has not disappeared; it has moved inside. Model<T> derives from Concept, so the call path is one virtual call from Drawable::draw to Model<Circle>::draw, which then calls Circle::draw directly (and usually inlines it). The requirement on T is purely structural: it must have a draw() const member. If it does not, the error appears when Model<T>::draw is instantiated, deep inside the wrapper, which is why production versions often add a C++20 constraint on the constructor (template <typename T> requires requires(const T& t) { t.draw(); }) so the error points at the call site instead.

This minimal version is move-only, because std::unique_ptr is. It also has an empty state after being moved from, and draw() on a moved-from Drawable dereferences a null pointer. The copyable version below guards against that.

Detailed Implementation: Copyable Type Erasure

#include <memory>
#include <iostream>
class Drawable {
    struct Concept {
        virtual ~Concept() = default;
        virtual void draw() const = 0;
        virtual std::unique_ptr<Concept> clone() const = 0;
    };
    template <typename T>
    struct Model : Concept {
        T object;
        Model(T obj) : object(std::move(obj)) {}
        void draw() const override {
            object.draw();
        }
        std::unique_ptr<Concept> clone() const override {
            return std::make_unique<Model<T>>(object);
        }
    };
    std::unique_ptr<Concept> pImpl;
public:
    template <typename T>
    Drawable(T obj) : pImpl(std::make_unique<Model<T>>(std::move(obj))) {}
    // Copy constructor
    Drawable(const Drawable& other)
        : pImpl(other.pImpl ? other.pImpl->clone() : nullptr) {}
    // Copy assignment
    Drawable& operator=(const Drawable& other) {
        if (this != &other) {
            pImpl = other.pImpl ? other.pImpl->clone() : nullptr;
        }
        return *this;
    }
    // Move constructor/assignment = default
    Drawable(Drawable&&) = default;
    Drawable& operator=(Drawable&&) = default;
    void draw() const {
        if (pImpl) pImpl->draw();
    }
};

Key: clone() virtual function enables copying Drawable itself. Each Model<T> implements clone() returning newly allocated Model<T> copy.

Why a virtual clone()? The copy constructor of Drawable no longer knows T, so it cannot write new Model<T>(...) itself. Only Model<T> still knows the type, so it is asked to copy itself. This is the “virtual constructor” idiom, and it is exactly what std::function does internally. One consequence: every T you store must now be copyable, even if you never copy the Drawable, because Model<T>::clone is instantiated as part of the vtable.

A pitfall appears when people “optimize” the constructor into a forwarding reference, template <typename T> Drawable(T&& obj). For a non-const Drawable lvalue, that template is a better match than the copy constructor taking const Drawable&, so copying a Drawable silently wraps it in a Model<Drawable&>-style nested wrapper, or fails to compile in confusing ways. The fix is to constrain the template so it does not accept Drawable itself (for example requires (!std::is_same_v<std::remove_cvref_t<T>, Drawable>)). The by-value constructor used here does not have this problem, at the cost of one extra move.


Practical Use Cases

Callback System

#include <functional>
#include <vector>
class EventSystem {
    std::vector<std::function<void(int)>> callbacks;
public:
    void subscribe(std::function<void(int)> cb) {
        callbacks.push_back(std::move(cb));
    }
    void trigger(int value) {
        for (auto& cb : callbacks) {
            cb(value);
        }
    }
};

Type-Safe Any

#include <any>
#include <map>
#include <string>
class PropertyBag {
    std::map<std::string, std::any> props;
public:
    template <typename T>
    void set(const std::string& key, T value) {
        props[key] = std::move(value);
    }
    template <typename T>
    T get(const std::string& key) const {
        return std::any_cast<T>(props.at(key));
    }
};

Plugin System

class Plugin {
    struct Concept {
        virtual ~Concept() = default;
        virtual void execute() = 0;
    };
    template <typename T>
    struct Model : Concept {
        T plugin;
        Model(T p) : plugin(std::move(p)) {}
        void execute() override {
            plugin.execute();
        }
    };
    std::unique_ptr<Concept> pImpl;
public:
    template <typename T>
    Plugin(T p) : pImpl(std::make_unique<Model<T>>(std::move(p))) {}
    void execute() {
        pImpl->execute();
    }
};
// Plugin implementations (no inheritance needed)
struct LogPlugin {
    void execute() { std::cout << "Logging...\n"; }
};
struct CachePlugin {
    void execute() { std::cout << "Caching...\n"; }
};
int main() {
    std::vector<Plugin> plugins;
    plugins.emplace_back(LogPlugin{});
    plugins.emplace_back(CachePlugin{});
    for (auto& p : plugins) {
        p.execute();
    }
    return 0;
}

”Plugin” here means “independently written component”, not a dynamically loaded library. Type erasure works at compile time: Plugin(T p) must see the definition of T. For plugins loaded from .so or .dll files at runtime, you still need a stable, usually C-compatible interface or an abstract base class exported from the host, because templates cannot be instantiated across a binary boundary that did not exist when the host was compiled.

Common Issues

Issue 1: any_cast Failure

#include <any>
#include <iostream>
int main() {
    std::any a = 10;
    try {
        double d = std::any_cast<double>(a);  // ❌ Type mismatch
    } catch (const std::bad_any_cast& e) {
        std::cout << "Error: " << e.what() << "\n";
    }
    // ✅ Correct type
    int i = std::any_cast<int>(a);
    std::cout << i << "\n";  // 10
    return 0;
}

Solution: Pass a pointer to the any (std::any_cast<int>(&a)); that overload returns nullptr on a mismatch instead of throwing. Alternatively compare a.type() == typeid(int) first.

if (auto* ptr = std::any_cast<int>(&a)) {
    std::cout << *ptr << "\n";
} else {
    std::cout << "Not an int\n";
}

Issue 2: std::function Copy Cost

// ❌ Bad: copying large captures
std::vector<int> largeData(1000000);
std::function<void()> f = [largeData]() {  // Copies largeData
    // Use largeData...
};
std::function<void()> f2 = f;  // Copies largeData again!

Solution: Use std::move or capture by reference.

// ✅ Move
std::function<void()> f = [data = std::move(largeData)]() {
    // Use data...
};
// ✅ Reference (caution: lifetime management needed)
std::function<void()> f = [&largeData]() {
    // Use largeData...
};

Be precise about what each fix buys. Moving into the capture avoids the first copy, but the std::function still owns a full vector, so f2 = f still copies a million integers. If the function object itself is copied around (stored in several containers, passed by value), capture a std::shared_ptr<const std::vector<int>> instead; copies then cost one reference-count increment. Capturing by reference is the cheapest and the most dangerous: callbacks usually outlive the scope that registered them, and a lambda holding a reference to a destroyed local is a dangling reference that often “works” in tests and crashes later.

Issue 3: Type Info Loss

std::any a = std::string("Hello");
// ❌ Can't directly call string methods
// a.size();  // Error
// ✅ Must cast first
std::string& s = std::any_cast<std::string&>(a);
std::cout << s.size() << "\n";  // 5

Issue 4: Performance Overhead

Type erasure involves heap allocation and indirect calls. For hot paths, consider templates directly.

// ❌ Slow: type erasure overhead
void processSlow(std::function<int(int)> f) {
    for (int i = 0; i < 1000000; ++i) {
        f(i);  // Indirect call
    }
}
// ✅ Fast: template inline
template <typename F>
void processFast(F f) {
    for (int i = 0; i < 1000000; ++i) {
        f(i);  // Inlined
    }
}

The indirect call itself is only a few nanoseconds; the larger loss is what it prevents. When processFast is instantiated with a lambda, the compiler sees the lambda body and can inline it, hoist invariants out of the loop and vectorize. Through std::function, it sees an opaque call on every iteration and must assume the callee can touch any memory. In a loop doing real work per element the difference is negligible; in a loop whose body is one addition it can be an order of magnitude. The usual compromise is to keep std::function at API boundaries, where callbacks are stored, and to use template parameters for algorithms that call the function in a hot loop. C++26 adds std::function_ref, a non-owning, non-allocating reference to a callable, aimed at exactly the “pass a callback to a function that calls it right away” case.

When I first replaced a virtual hierarchy with std::function callbacks, the surprise in profiles was not the call overhead but allocations: each registration of a lambda that captured a few strings exceeded the small buffer and allocated. Capturing a pointer to a context object instead of the strings themselves brought it back under the buffer size.

Comparison Table

Type Erasure vs Other Polymorphism Techniques

FeatureVirtual FunctionsTemplatesType Erasure
Compile-time polymorphism❌✅❌
Runtime polymorphism✅❌✅
Inheritance required✅❌❌
Type info maintained✅✅❌
PerformanceMediumFastMedium
Binary sizeSmallLarge (template bloat)Medium
FlexibilityLowHighHigh

std::any vs std::variant vs std::optional

Featurestd::anystd::variantstd::optional
Stored typesArbitraryFixed setSingle type
Type checkRuntimeCompile-timeN/A
PerformanceSlowFastFast
SafetyRuntime exceptionCompile-timeCompile-time
Use caseUnknown typesKnown alternativesMay/may not exist

Two cells in the first table deserve nuance. “Performance: Medium” for type erasure and virtual functions is the same mechanism, one indirect call, and the real difference is where the object lives: a type-erased wrapper usually allocates each object separately, while a hierarchy may or may not. And “Type info maintained: No” does not mean the type is unrecoverable; std::any::type() and std::function::target_type() return a std::type_info, but using them to branch on types is a sign that std::variant or a real interface would have been the better design.

Practical Patterns

Pattern 1: Message Queue

#include <functional>
#include <queue>
#include <iostream>
class MessageQueue {
    std::queue<std::function<void()>> messages;
public:
    template <typename F>
    void post(F&& f) {
        messages.emplace(std::forward<F>(f));
    }
    void process() {
        while (!messages.empty()) {
            auto msg = std::move(messages.front());
            messages.pop();
            msg();
        }
    }
};
int main() {
    MessageQueue mq;
    mq.post([]() { std::cout << "Message 1\n"; });
    mq.post([]() { std::cout << "Message 2\n"; });
    mq.process();
    return 0;
}

Pattern 2: Command Pattern

#include <memory>
#include <vector>
#include <iostream>
class Command {
    struct Concept {
        virtual ~Concept() = default;
        virtual void execute() = 0;
    };
    template <typename T>
    struct Model : Concept {
        T cmd;
        Model(T c) : cmd(std::move(c)) {}
        void execute() override {
            cmd.execute();
        }
    };
    std::unique_ptr<Concept> pImpl;
public:
    template <typename T>
    Command(T cmd) : pImpl(std::make_unique<Model<T>>(std::move(cmd))) {}
    void execute() {
        pImpl->execute();
    }
};
struct PrintCommand {
    std::string msg;
    void execute() { std::cout << msg << "\n"; }
};
struct IncrementCommand {
    int& counter;
    void execute() { ++counter; }
};
int main() {
    int count = 0;
    std::vector<Command> commands;
    commands.emplace_back(PrintCommand{"Hello"});
    commands.emplace_back(IncrementCommand{count});
    commands.emplace_back(PrintCommand{"World"});
    for (auto& cmd : commands) {
        cmd.execute();
    }
    std::cout << "Counter: " << count << "\n";  // 1
    return 0;
}

Pattern 3: Function Wrapper (std::function Alternative)

#include <memory>
#include <iostream>
template <typename Signature>
class Function;
template <typename R, typename... Args>
class Function<R(Args...)> {
    struct Concept {
        virtual ~Concept() = default;
        virtual R invoke(Args... args) = 0;
    };
    template <typename F>
    struct Model : Concept {
        F func;
        Model(F f) : func(std::move(f)) {}
        R invoke(Args... args) override {
            return func(std::forward<Args>(args)...);
        }
    };
    std::unique_ptr<Concept> pImpl;
public:
    template <typename F>
    Function(F f) : pImpl(std::make_unique<Model<F>>(std::move(f))) {}
    R operator()(Args... args) {
        return pImpl->invoke(std::forward<Args>(args)...);
    }
};
int main() {
    Function<int(int, int)> add = [](int a, int b) { return a + b; };
    std::cout << add(3, 4) << "\n";  // 7
    Function<void()> greet = []() { std::cout << "Hello!\n"; };
    greet();  // Hello!
    return 0;
}

This hand-written Function shows how little is essential: a partial specialization on the signature R(Args...), a Concept whose virtual invoke has that signature, and a Model<F> per callable type. What it leaves out is what makes std::function larger: copying (a clone() as in Drawable), the small buffer so small lambdas do not allocate, the empty state and bad_function_call, and target() for recovering the stored type. It is a useful starting point when you need a variant the standard does not offer, for example a move-only callback before C++23’s std::move_only_function was available.

Pattern 4: Iterator Type Erasure

#include <memory>
#include <vector>
#include <list>
#include <iostream>
template <typename T>
class AnyIterator {
    struct Concept {
        virtual ~Concept() = default;
        virtual T& deref() = 0;
        virtual void next() = 0;
        virtual bool equal(const Concept& other) const = 0;
        virtual std::unique_ptr<Concept> clone() const = 0;
    };
    template <typename Iter>
    struct Model : Concept {
        Iter iter;
        Model(Iter it) : iter(it) {}
        T& deref() override { return *iter; }
        void next() override { ++iter; }
        bool equal(const Concept& other) const override {
            auto* p = dynamic_cast<const Model<Iter>*>(&other);
            return p && iter == p->iter;
        }
        std::unique_ptr<Concept> clone() const override {
            return std::make_unique<Model<Iter>>(iter);
        }
    };
    std::unique_ptr<Concept> pImpl;
public:
    template <typename Iter>
    AnyIterator(Iter it) : pImpl(std::make_unique<Model<Iter>>(it)) {}
    AnyIterator(const AnyIterator& other)
        : pImpl(other.pImpl ? other.pImpl->clone() : nullptr) {}
    AnyIterator& operator=(const AnyIterator& other) {
        if (this != &other) {
            pImpl = other.pImpl ? other.pImpl->clone() : nullptr;
        }
        return *this;
    }
    AnyIterator(AnyIterator&&) = default;
    AnyIterator& operator=(AnyIterator&&) = default;
    T& operator*() { return pImpl->deref(); }
    AnyIterator& operator++() { pImpl->next(); return *this; }
    bool operator==(const AnyIterator& other) const {
        return pImpl->equal(*other.pImpl);
    }
    bool operator!=(const AnyIterator& other) const {
        return !(*this == other);
    }
};
int main() {
    std::vector<int> vec = {1, 2, 3};
    std::list<int> lst = {4, 5, 6};
    AnyIterator<int> it1 = vec.begin();
    AnyIterator<int> it2 = lst.begin();
    std::cout << *it1 << "\n";  // 1
    std::cout << *it2 << "\n";  // 4
    return 0;
}

Iterator erasure is where the costs of the technique become visible. Every ++ and * is a virtual call, copying an iterator allocates, and equality needs dynamic_cast (and therefore RTTI) to check that both sides wrap the same iterator type. That is acceptable for an API that hands out a few iterators, such as a plugin exposing its items, and far too slow as a general replacement for templates in algorithms. Ranges libraries that offer an any_view make the same trade-off and document it prominently.

Advanced Patterns

Pattern 5: Task Queue with Type Erasure

#include <functional>
#include <queue>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <iostream>
class TaskQueue {
    std::queue<std::function<void()>> tasks;
    std::mutex mtx;
    std::condition_variable cv;
    bool stop = false;
public:
    template <typename F>
    void enqueue(F&& f) {
        {
            std::lock_guard<std::mutex> lock(mtx);
            tasks.emplace(std::forward<F>(f));
        }
        cv.notify_one();
    }
    void worker() {
        while (true) {
            std::function<void()> task;
            {
                std::unique_lock<std::mutex> lock(mtx);
                cv.wait(lock, [this]() { return stop || !tasks.empty(); });
                if (stop && tasks.empty()) return;
                task = std::move(tasks.front());
                tasks.pop();
            }
            task();
        }
    }
    void shutdown() {
        {
            std::lock_guard<std::mutex> lock(mtx);
            stop = true;
        }
        cv.notify_all();
    }
};
int main() {
    TaskQueue tq;
    std::thread worker([&]() { tq.worker(); });
    tq.enqueue([]() { std::cout << "Task 1\n"; });
    tq.enqueue([]() { std::cout << "Task 2\n"; });
    std::this_thread::sleep_for(std::chrono::milliseconds(100));
    tq.shutdown();
    worker.join();
    return 0;
}

The queue stores std::function<void()>, so any lambda, bound member function or functor can be queued, and the worker never needs to know what it is running. Note the order in worker(): the task is moved out and the lock released before task() runs; calling it while holding the mutex would serialize enqueuing behind task execution. The sleep_for in main (which needs <chrono>) is only there to let the demo tasks run; the shutdown logic drains remaining tasks because the worker exits only when stop is set and the queue is empty. Tasks that need to return a value usually wrap a std::packaged_task, which is move-only; since std::function requires copyable targets, thread pools that do this store std::move_only_function (C++23) or a shared pointer to the task.

Pattern 6: Polymorphic Value Type

#include <memory>
#include <iostream>
template <typename T>
class PolyValue {
    struct Concept {
        virtual ~Concept() = default;
        virtual T get() const = 0;
        virtual std::unique_ptr<Concept> clone() const = 0;
    };
    template <typename U>
    struct Model : Concept {
        U value;
        Model(U v) : value(std::move(v)) {}
        T get() const override { return value; }
        std::unique_ptr<Concept> clone() const override {
            return std::make_unique<Model<U>>(value);
        }
    };
    std::unique_ptr<Concept> pImpl;
public:
    template <typename U>
    PolyValue(U value) : pImpl(std::make_unique<Model<U>>(std::move(value))) {}
    PolyValue(const PolyValue& other)
        : pImpl(other.pImpl ? other.pImpl->clone() : nullptr) {}
    PolyValue& operator=(const PolyValue& other) {
        if (this != &other) {
            pImpl = other.pImpl ? other.pImpl->clone() : nullptr;
        }
        return *this;
    }
    PolyValue(PolyValue&&) = default;
    PolyValue& operator=(PolyValue&&) = default;
    T get() const { return pImpl->get(); }
};
int main() {
    PolyValue<int> v1 = 10;
    PolyValue<int> v2 = 3.14;  // double → int
    std::cout << v1.get() << "\n";  // 10
    std::cout << v2.get() << "\n";  // 3
    return 0;
}

Best Practices

When to Use Type Erasure

  • Plugin systems: Load arbitrary types at runtime.
  • Callback systems: Store various callable types.
  • Generic containers: Store heterogeneous types.
  • API boundaries: Hide implementation details.

When NOT to Use

  • Performance-critical paths: Template inlining is faster.
  • Simple inheritance suffices: Don’t over-engineer.
  • Type safety needed: std::variant is safer than std::any.

The decision usually comes down to who owns the set of types. If your code defines every alternative and you want the compiler to force handling of each, use std::variant and std::visit. If the types come from users of your library and share one behavior, use type erasure. If a small class hierarchy already exists and objects have identity rather than value semantics, plain virtual functions are simpler to write, debug, and read than a hand-rolled wrapper.

std::function Performance Tips

  • Avoid frequent copies: Use std::move or references.
  • Small captures: Keep captures small so the implementation can store them inline (small buffer optimization) instead of heap-allocating.
  • Consider alternatives: For hot paths, use templates or function pointers.

std::any Safety Tips

  • Type check before cast: Use pointer version of any_cast.
  • Document expected types: Comment which types are stored.
  • Consider std::variant: If types are known, std::variant is safer.

Production Examples

Example 1: HTTP Handler Registry

#include <functional>
#include <map>
#include <string>
#include <iostream>
class HttpServer {
    using Handler = std::function<void(const std::string&)>;
    std::map<std::string, Handler> routes;
public:
    void route(const std::string& path, Handler handler) {
        routes[path] = std::move(handler);
    }
    void handleRequest(const std::string& path, const std::string& body) {
        if (auto it = routes.find(path); it != routes.end()) {
            it->second(body);
        } else {
            std::cout << "404 Not Found\n";
        }
    }
};
int main() {
    HttpServer server;
    server.route("/hello", [](const std::string& body) {
        std::cout << "Hello: " << body << "\n";
    });
    server.route("/echo", [](const std::string& body) {
        std::cout << "Echo: " << body << "\n";
    });
    server.handleRequest("/hello", "World");  // Hello: World
    server.handleRequest("/echo", "Test");    // Echo: Test
    server.handleRequest("/unknown", "");     // 404 Not Found
    return 0;
}

Example 2: Validation System

#include <functional>
#include <vector>
#include <string>
#include <iostream>
class Validator {
    std::vector<std::function<bool(const std::string&)>> rules;
public:
    void addRule(std::function<bool(const std::string&)> rule) {
        rules.push_back(std::move(rule));
    }
    bool validate(const std::string& input) const {
        for (const auto& rule : rules) {
            if (!rule(input)) return false;
        }
        return true;
    }
};
int main() {
    Validator v;
    v.addRule([](const std::string& s) { return s.size() >= 8; });
    v.addRule([](const std::string& s) { return s.find_first_of("0123456789") != std::string::npos; });
    std::cout << std::boolalpha;
    std::cout << v.validate("short") << "\n";      // false
    std::cout << v.validate("longtext") << "\n";   // false
    std::cout << v.validate("longtext123") << "\n"; // true
    return 0;
}

Example 3: Logger with Type-Erased Sinks

#include <memory>
#include <vector>
#include <iostream>
#include <fstream>
class Logger {
    struct Sink {
        virtual ~Sink() = default;
        virtual void write(const std::string& msg) = 0;
    };
    template <typename T>
    struct SinkModel : Sink {
        T sink;
        SinkModel(T s) : sink(std::move(s)) {}
        void write(const std::string& msg) override {
            sink.write(msg);
        }
    };
    std::vector<std::unique_ptr<Sink>> sinks;
public:
    template <typename T>
    void addSink(T sink) {
        sinks.push_back(std::make_unique<SinkModel<T>>(std::move(sink)));
    }
    void log(const std::string& msg) {
        for (auto& sink : sinks) {
            sink->write(msg);
        }
    }
};
struct ConsoleSink {
    void write(const std::string& msg) {
        std::cout << "[Console] " << msg << "\n";
    }
};
struct FileSink {
    std::ofstream file;
    FileSink(const std::string& path) : file(path, std::ios::app) {}
    void write(const std::string& msg) {
        file << "[File] " << msg << "\n";
    }
};
int main() {
    Logger logger;
    logger.addSink(ConsoleSink{});
    logger.addSink(FileSink{"log.txt"});
    logger.log("Application started");
    logger.log("Processing data");
    return 0;
}

The logger shows a variant of the pattern without a separate wrapper class: the Concept/Model pair lives inside Logger, and addSink is the template “entry point” where the concrete type is last known. FileSink works only because it is movable: std::ofstream is move-only, and addSink(T sink) moves it into the model. Had the logger been copyable with a clone(), FileSink would have been rejected at compile time, which is a real design question (should two copies of a logger share a file handle?) that type erasure forces you to answer explicitly.

Frequently Asked Questions (FAQ)

Q. When to use type erasure in production?

A. When you need runtime polymorphism without inheritance constraints. Examples: plugin systems loading arbitrary types, callback registries storing various callables, generic message queues, API boundaries hiding implementation details. Refer to Practical Use Cases and Production Examples sections.

Q. Type erasure vs virtual functions?

A. Virtual functions require common base class and modifying existing types. Type erasure wraps arbitrary types sharing behavior without inheritance. Choose type erasure when you can’t modify existing types or need more flexibility; choose virtual functions when inheritance hierarchy is natural and performance is critical.

Q. std::any vs std::variant?

A. std::any for truly arbitrary types (runtime checks, slower), std::variant for closed set of known types (compile-time checks, faster/safer). Choose std::any when you cannot enumerate all possible types at compile time; choose std::variant when you know all possible types and want compile-time safety.

Q. How to reduce std::function overhead?

A. Use std::move to avoid copying large captures, keep captures small to leverage small functor optimization (SFO), or use templates directly for hot paths where inlining matters. For performance-critical code, consider function pointers or template parameters instead of std::function. Type erasure provides flexible polymorphism without inheritance, but involves performance tradeoffs. Use for plugin systems, callbacks, and API boundaries where flexibility outweighs overhead.

Q. Why won’t std::function accept my lambda that captures a std::unique_ptr?

A. std::function requires its target to be copyable, because copying a std::function copies the stored callable, and a lambda that owns a unique_ptr is move-only. In C++23 use std::move_only_function, which drops the copy requirement. Before C++23, hold the resource in a shared_ptr or write a small move-only type-erased wrapper using the concept/model pattern from this article, just without a clone().