Type Erasure in C++
One Box, Any Type: Understanding Type Erasure in C++
Or: How std::function stores a lambda, a function pointer, and a functor in the same box
C++ rewards precise thinking about types. Most of the time, that precision is a feature. But occasionally the type system and the design pull in opposite directions, and the compiler rejects code that is, by every conceptual measure, reasonable.
Consider a transaction monitor for a SystemC simulation. Every time a TLM transaction arrives, four separate objects need to respond: a Logger records it, a LatencyTracker measures it, a ProtocolChecker validates it, a CoverageCollector bins it. They share no base class, and they shouldn't need one. What they have in common is purely behavioral: they all respond to a transaction.
So the obvious thing gets written:
std::vector<TransactionHandler> handlers;
handlers.push_back(Logger{});
handlers.push_back(LatencyTracker{});
handlers.push_back(ProtocolChecker{});
for (auto& h : handlers)
h.on_transaction(txn);
The compiler disagrees. Logger, LatencyTracker, and ProtocolChecker are different types. A std::vector holds one type. They cannot be mixed, regardless of how similar their behavior looks from the outside.
This is the moment type erasure becomes necessary.
What Is Type Erasure?
Type erasure is a technique that lets you store objects of different concrete types behind a single uniform interface, without those types sharing a common base class, and without the caller knowing (or caring) what the real type is.
The "erasure" isn't deletion. It's concealment. The concrete type still exists, fully formed, somewhere in memory. But to the outside world, it has been replaced by a uniform wrapper that only exposes the behaviors you care about.
In one sentence: type erasure trades compile-time type knowledge for runtime flexibility, without exposing the mechanism to the caller.
Why Not Just Templates or Inheritance?
C++ already has two tools for writing code that works across types, and it's worth being precise about why neither one closes this particular gap.
Templates are the first instinct, and they're excellent right up to the point where you need to store the results together. The catch is that every instantiation is a distinct type. A function template happily calls on_transaction on a Logger or a LatencyTracker, but the moment you want to keep a collection of them, there is no type to name:
template<typename T>
void call(T& h, const Txn& t) { h.on_transaction(t); } // fine, one at a time
std::vector<???> handlers; // there is no single type to put here
Templates solve the single-call problem. They do nothing for the heterogeneous-container problem, which is the one we actually have.
Inheritance solves the container problem, and it's the natural next reach: give everything a common base with a virtual on_transaction, then store unique_ptr<HandlerBase>. This works, and sometimes it's the right answer. But it is invasive. Every handler must derive from your base, which means you have to own every handler type or wrap the ones you don't. A logger from a third-party library doesn't inherit from your base. A lambda can't inherit from anything at all. You end up modifying, or wrapping, types just to make them storable.
Type erasure takes what's good from each and pays neither cost. It gives you the uniform storage of inheritance without demanding that the stored types share a base, and it does that by generating the wrapper for you rather than making you write it into every class. The stored types are never touched.
Three Mechanisms, One Idea
That framing helps because type erasure isn't a single trick; it's a family, and you've likely used all three members. The C way is the void* plus a function pointer, as in qsort: the element type is erased to void* and the caller supplies a comparator that knows the real type. It works and it's fast, but nothing is type-safe. The object-oriented way is virtual dispatch behind an abstract base, which is exactly the plugin example later in this post: safe and clear, but it requires inheritance. The modern C++ way combines them: a template stamps out a small wrapper per concrete type, and that wrapper implements a virtual interface. You get the safety of virtual dispatch without forcing inheritance on the stored types, because the compiler writes the bridging class for you. That third mechanism is what std::function and std::any use, and it's the one we build next.
Building It Yourself: The Erasure Engine
Three Small Pieces
Type erasure always has the same three parts: an abstract interface that names the behavior, a template wrapper that captures one concrete type and implements that interface, and an owning class that hides the pointer. Here is the whole engine, taken from the runnable thread-pool example:
// 1. The erased interface: all the outside world sees
struct CallableBase {
virtual void call() = 0;
virtual ~CallableBase() = default;
};
// 2. The template wrapper: one instantiation per concrete T
template<typename T>
struct CallableModel : CallableBase {
T callable_;
explicit CallableModel(T t) : callable_(std::move(t)) {}
void call() override { callable_(); } // delegate to real T
};
// 3. The owning wrapper: the public API, move-only
class TypeErasedTask {
CallableBase* ptr_ = nullptr;
public:
// the type-erasure point: T is captured, then forgotten
template<typename T>
explicit TypeErasedTask(T c) : ptr_(new CallableModel<T>(std::move(c))) {}
void operator()() { if (ptr_) ptr_->call(); }
~TypeErasedTask() { delete ptr_; }
// move ctor/assign via std::exchange; copying is deleted
};
↗ Full, runnable version (with move semantics, futures, and tests): type-erasure/thread-pool/main.cpp
Three pieces, and the middle one does the real work. When you construct a TypeErasedTask from a lambda, the compiler deduces T, stamps out a CallableModel<T> whose call() invokes that specific lambda, and stores a CallableBase* to it. From that point on, nothing outside knows T. A lambda, a function pointer, and a stateful functor all become interchangeable TypeErasedTask objects that sit happily in one container.
Real implementations go one step further. Heap-allocating every callable is wasteful when the callable is tiny, so std::function keeps a small buffer inside itself and constructs the model directly in that buffer when the callable is small enough to fit. This is the Small Buffer Optimization. A captureless lambda or a bare function pointer fits and costs no allocation at all; a lambda capturing a large struct overflows the buffer and falls back to the heap. Our TypeErasedTask always heap-allocates, which keeps it easy to read; the standard library trades that simplicity for speed on the small-callable path.
Type Erasure You've Already Used
Once the three-piece pattern is in your head, you start seeing it all over the standard library. Four cases worth recognising:
std::function<R(Args...)>
The one everyone knows. It stores any callable matching a signature, using exactly the engine we just built (interface, template model, owning wrapper) with an SBO buffer added. The signature R(Args...) is the behavioural concept: the only thing the wrapper needs to know about the callable.
std::function<void(int)> f = [](int x){ log(x); }; // lambda (erased)
f = &handle_int; // function pointer (erased)
std::any
Type erasure with no behavioural concept at all. It stores any copy-constructible value and hands it back only if you name the right type. This is the same vehicle the property-map example uses to hold mixed-type values behind one interface.
std::any a = 42;
a = std::string("hi");
int x = std::any_cast<int>(a); // throws if the stored type isn't int
std::variant<Ts...>
The closed cousin. Where std::function and std::any accept any type at all, variant fixes the set of possible types at compile time. That constraint buys a lot: no heap, no vtable, a tag instead of dynamic dispatch, and std::visit for type-safe handling. When the type set is known up front, reach for this before reaching for erasure.
std::variant<int, float, std::string> v = 42;
std::visit([](auto& x){ use(x); }, v);
std::shared_ptr's deleter
The one that surprises people. The deleter you pass to a shared_ptr can be any callable, yet it does not appear in the shared_ptr's type. Both pointers below have distinct deleters and identical types, because the deleter is type-erased into the control block. That is exactly why copying a shared_ptr works without the copier ever knowing how the object gets destroyed.
std::shared_ptr<FILE> fp(fopen("x", "r"), fclose);
std::shared_ptr<Widget> wp(new Widget, [](Widget* w){ delete w; });
// different deleters, same type: the deleter is erased into the control block
Four facilities, one idea, expressed four different ways: erase any callable, erase any value, erase a fixed set, erase just the cleanup. The next three sections build things in the same spirit, and each one compiles and runs.
Use Case: A Thread Pool That Accepts Anything
A thread pool needs a queue of work, but "work" comes in every shape: a lambda capturing local state, a bare function, a stateful functor. They share no type. This is the heterogeneous-container problem from the opening, and the engine we just built solves it directly. The pool stores a queue of TypeErasedTask, and the concrete type of each task is gone the moment it is enqueued.
class ThreadPool {
public:
// enqueue(callable): the type-erasure point.
// F is deduced here, wrapped into a CallableModel<F>, then forgotten.
template<typename F>
void enqueue(F&& task) {
{
std::unique_lock<std::mutex> lock(mtx_);
queue_.push(TypeErasedTask(std::forward<F>(task)));
}
cv_.notify_one();
}
private:
void worker_loop() {
while (true) {
TypeErasedTask task;
{
std::unique_lock<std::mutex> lock(mtx_);
cv_.wait(lock, [this]{ return stop_ || !queue_.empty(); });
if (stop_ && queue_.empty()) return;
task = std::move(queue_.front());
queue_.pop();
}
task(); // concrete type was erased; we just call it
}
}
// queue_, mtx_, cv_, workers_, stop_ ...
};
↗ Full, runnable version (with std::future results, exception propagation, and a throughput test):
type-erasure/thread-pool/main.cpp
Two boundaries, and everything interesting happens at them. At enqueue, the compiler deduces F and stamps out a CallableModel<F>; after that line the queue holds only TypeErasedTask, and F is invisible to the rest of the class. In worker_loop, a thread pops a task and calls task() with no idea what it is running. A lambda, a function pointer, and a functor go in as different types and come out as identical queue entries.
Worth knowing: the version in the repo returns results too. enqueue_with_result wraps the callable in a lambda that fulfils a std::promise, hands you back a std::future, and forwards exceptions across the thread boundary, all while the pool still sees nothing but TypeErasedTask. For a hot path, note that TypeErasedTask heap-allocates per enqueue; a larger SBO buffer or C++23's std::move_only_function reduces that cost.
Use Case: A Heterogeneous Property Map
Configuration is the classic place where types refuse to line up. A timeout is an int, a name is a std::string, a flag is a bool, and next week someone adds a double. You want one key-value store that holds all of them, keyed by string, retrieved type-safely. No single value type covers that, so the value type is erased. The vehicle is std::any, and the whole store is a thin typed skin over it.
class PropertyMap {
std::unordered_map<std::string, std::any> props_;
public:
// set<T>: type T is erased into std::any here.
template<typename T>
void set(const std::string& key, T value) {
props_[key] = std::any(std::move(value));
}
// get<T>: recovers T. Throws std::bad_any_cast if T is wrong.
template<typename T>
T get(const std::string& key) const {
return std::any_cast<T>(props_.at(key));
}
// try_get<T>: no throw. Empty optional on missing key or wrong type.
template<typename T>
std::optional<T> try_get(const std::string& key) const {
auto it = props_.find(key);
if (it == props_.end()) return std::nullopt;
if (auto* p = std::any_cast<T>(&it->second)) return *p;
return std::nullopt;
}
};
↗ Full, runnable version (with get_or, a schema-checked variant, a change-notifying map, and a config demo):
type-erasure/property-map/main.cpp
The map's member type is unordered_map<string, any>. No int, no string, no bool appears anywhere in the class definition, yet all three can be stored. The type lives only at the two boundaries: it goes in at set<T> and must be named again at get<T>.
PropertyMap cfg;
cfg.set("timeout_ns", 100);
cfg.set("name", std::string("initiator"));
cfg.set("enable_trace", true);
int t = cfg.get<int>("timeout_ns"); // 100
auto n = cfg.try_get<std::string>("name"); // optional, has value
auto bad = cfg.try_get<double>("timeout_ns"); // nullopt: stored as int
This is the trade-off std::any makes plain. It accepts anything, which is exactly why it can promise nothing about what a key holds; the type check moves from compile time to the any_cast at retrieval. That is fine for genuinely open-ended data like configuration, and wrong for anything performance-sensitive or where the value set is fixed. When the keys and their types are known in advance, a struct or a std::variant is safer and faster. The repo shows a middle path too: a schema-checked map that records each key's expected type up front and rejects a wrong-typed set at the call site, moving the error back toward compile-time discipline without giving up the string-keyed flexibility.
Use Case: A Plugin System
A host program that loads plugins faces the strongest version of the problem: the plugin types don't just differ, they don't even exist when the host is compiled. In a real deployment they arrive from shared libraries opened at runtime with dlopen or LoadLibrary. The host cannot name a type it has never seen, so it names a behavior instead. That behavior is an abstract interface, and this is the oldest, plainest form of type erasure: the interface is the concept, and every concrete plugin inherits it.
// The erased concept: the only thing the host ever calls
class IPlugin {
public:
virtual ~IPlugin() = default;
virtual std::string name() const = 0;
virtual bool process(Message& msg) = 0; // false halts the pipeline
};
class PluginRegistry {
std::unordered_map<std::string, std::unique_ptr<IPlugin>> plugins_;
std::vector<std::string> order_;
public:
// the type-erasure point: any concrete plugin enters as IPlugin
void register_plugin(std::unique_ptr<IPlugin> plugin) {
auto key = plugin->name();
order_.push_back(key);
plugins_[key] = std::move(plugin);
}
// run a message through every plugin, in registration order
bool process(Message& msg) const {
for (auto& key : order_)
if (!plugins_.at(key)->process(msg)) return false;
return true;
}
};
↗ Full, runnable version (four plugins, a validation pipeline, capability filtering, and lifecycle): type-erasure/plugin_system/main.cpp
Notice what's missing compared to the thread pool: there is no CallableModel<T>. The template wrapper's job, giving each concrete type a uniform virtual interface, is done here by ordinary inheritance, because each plugin already is an IPlugin. A LoggerPlugin, a ValidatorPlugin, and a ProfilerPlugin share nothing but the base class, and the registry holds all of them as unique_ptr<IPlugin>. When process walks the pipeline, each call dispatches through the vtable to the right implementation, and the registry never learns which one.
Because the concrete type still exists behind the pointer, you can deliberately recover it when you must. Looking a plugin up by name gives back an IPlugin*; a dynamic_cast to the concrete type succeeds only if that's really what's there, letting the host reach a plugin-specific method the interface doesn't expose.
IPlugin* p = registry.find("Profiler");
if (auto* prof = dynamic_cast<ProfilerPlugin*>(p))
use(prof->records()); // a method that isn't on IPlugin
Treat this as the exception, not the pattern. If a design reaches for dynamic_cast often, the type wasn't really meant to be erased, and a std::variant over the known plugin types would model it more honestly. Used sparingly, though, it's the safety valve that makes an erased interface livable: uniform by default, specific when you genuinely need it.
Gotchas: Where Type Erasure Hurts
It allocates by default
Unless a small-buffer optimization kicks in, wrapping a callable or value means a heap allocation, and the fully permissive tools allocate the most. std::any in particular is the heaviest of the standard facilities because it stores anything and copies on every copy. In a tight loop or a hot container this is measurable, so profile before assuming it's free, and prefer std::variant when the type set is fixed.
It is not zero-cost at the call site
A template call can be inlined; an erased call cannot. Every invocation goes through a vtable or a stored function pointer, which is a lookup and an indirect branch. For something called millions of times per second, that overhead adds up, and it's the main reason to keep erasure at storage boundaries rather than on your hottest inner calls. One related trap in the inheritance flavor: if your erased base forgets virtual ~Base() = default;, deleting through the base pointer is undefined behavior. Always give a type-erased base a virtual destructor.
It usually requires copyability
std::function requires the wrapped callable to be copy-constructible, so a lambda that captures a unique_ptr won't fit. C++23 adds std::move_only_function for exactly this case, and a hand-rolled move-only wrapper (like the TypeErasedTask above) works too.
auto p = std::make_unique<Resource>();
// std::function<void()> f = [p = std::move(p)]{ p->use(); }; // ERROR: not copyable
std::move_only_function<void()> f = [p = std::move(p)]{ p->use(); }; // OK (C++23)
The type is gone, and getting it back is a smell
Once erased, the concrete type can only be recovered through std::any_cast, a type tag, or dynamic_cast. Doing that occasionally is fine, as the plugin lookup showed. Doing it often is a signal that the type wasn't really meant to be erased, and that a std::variant over a known set would model the design more honestly.
When to Use Type Erasure vs Templates
| Situation | Prefer templates | Prefer type erasure |
|---|---|---|
| Types known at compile time | ✓ | |
| Zero-overhead is critical | ✓ | |
| Called in a hot loop (>1M/s) | ✓ | |
| Single call site, no container | ✓ | |
| Storing mixed types in one container | ✓ | |
| Types not known until runtime | ✓ | |
| Template parameters bleeding into headers | ✓ | |
| Plugin or runtime-loaded code | ✓ | |
| Callback stored beyond the call site | ✓ |
The rule of thumb that captures the whole table: use templates at the point of use, and type erasure at storage boundaries. A closed set of known types is the one case where neither column wins outright, and std::variant is usually the better answer than either.
The Bigger Picture
Type erasure sits at the intersection of two ideas that usually pull in opposite directions: the zero-cost abstraction of templates, and the runtime polymorphism of virtual dispatch. Neither alone solves the heterogeneous-storage problem. Erasure combines them. The concrete type gets a full template instantiation inside the model, with all the inlining and static dispatch that implies, while the outside world sees one stable, uniform interface.
So the compiler never actually forgets the type. You ask it to hide the type, and it does so deliberately, generating exactly the dispatch machinery the stored type needs, at the exact point you handed that type over, in the one translation unit where the type was visible. That is not forgetting. It is encapsulation at its most precise.
Have a type-erasure pattern you've used in the wild that isn't here? Drop it in the comments.
Tags: C++, Templates, Type System, std::function, SystemC, Design Patterns

Comments
Post a Comment