Polymorphism, Type Erasure, and std::function
Polymorphism means that the same operation or interface can be applied to values of different types, with behavior appropriate to each type. It needs a common interface, multiple type-specific implementations, and a selection mechanism.
A useful C++ classification is:
- Static polymorphism: selection happens during compilation.
- Runtime polymorphism: selection uses runtime state.
Static polymorphism can generate type-specific adapters that are later stored and dispatched through a runtime interface.

Here, virtual dispatch is classified operationally as type erasure: callers use a base interface that hides the concrete dynamic type, while the compiler’s vtable is the function table that preserves type-specific operations.
- Static polymorphism
- Runtime polymorphism
- Static implementations feeding runtime polymorphism
std::functionas a hybrid case study- Two ways to isolate type-specific logic from a core library
- Construction is the concrete-type dependency boundary
Static polymorphism
Static polymorphism selects an implementation before execution, so runtime dispatch is unnecessary.
Function overloading
void process(Image&);
void process(Command&);
process(image); // Selects process(Image&).
process(command); // Selects process(Command&).
The compiler resolves the call using static argument types, conversions, constraints, and overload-ranking rules.
Templates and specialization
template <typename T>
void process(T& value)
{
value.execute();
}
The compiler instantiates distinct typed functions such as process<Image>
and process<Command>. The concrete type remains known in each instantiation.
Templates generate typed code; they do not by themselves erase a type.
Parameterization can also produce different layouts, such as Buffer<64> and
Buffer<128>, without creating a runtime-polymorphic interface.
Runtime polymorphism
Runtime polymorphism applies when the implementation cannot be fixed solely from the call site’s static types.
Closed tagged dispatch without type erasure
using Message = std::variant<Image, Command>;
std::visit(
[](auto& value) {
value.execute();
},
message);
The active alternative is selected at runtime, but Image and Command
remain visible in std::variant<Image, Command>. Under the conventional
definition, their types have not been erased. Type erasure is therefore not
required for every form of runtime polymorphism.
Type erasure
Type erasure converts a concrete typed object into a uniform runtime representation that no longer exposes the concrete type to its consumer. It usually preserves selected behavior through function pointers or an operation table.
Virtual interfaces
A virtual interface hides the concrete dynamic type behind a base-class interface. Concrete implementations participate in the hierarchy and override the same virtual operation:
class Operation
{
public:
virtual ~Operation() = default;
virtual void execute() = 0;
};
class ImageOperation final : public Operation
{
public:
void execute() override
{
processImage();
}
};
class CommandOperation final : public Operation
{
public:
void execute() override
{
processCommand();
}
};
void run(Operation& operation)
{
operation.execute();
}
The caller of run() knows only Operation. At runtime, the object’s vptr
selects the appropriate entry in a compiler-generated vtable. Operationally,
that vtable is a function table whose compatible slots point to type-specific
implementations. The compiler and language object model generate and manage
the table, object adjustment, and indirect call.
Function tables
The same erased runtime interface can be represented explicitly as object storage plus function-table slots with uniform signatures:
struct ErasedOperation
{
void* object;
void (*execute)(void*);
};
template <typename T>
ErasedOperation erase(T& object)
{
return {
&object,
[](void* pointer) {
static_cast<T*>(pointer)->execute();
}};
}
void run(const ErasedOperation& operation)
{
operation.execute(operation.object);
}
erase<T>() runs while T is known to the compiler. It generates a
type-specific adapter that performs the correct cast and call, but every
generated adapter has the same erased signature, void(void*). Consequently,
the execute slot can store the adapter for any supported concrete type.
Concrete type T
-> compile-time template instantiation
-> type-specific adapter with the signature void(void*)
-> function-table slot stores that adapter
-> runtime dispatch invokes the stored function pointer
Two moments must be distinguished:
- Binding or registration: generate or select the adapter and store its function pointer.
- Dispatch: invoke the already-stored function pointer at runtime.
Runtime invocation does not perform overload resolution among candidates. Construction has already selected the adapter. Type erasure is therefore one important runtime-polymorphism technique, but not a synonym for all polymorphism.
Static implementations feeding runtime polymorphism
Static polymorphism can generate a family of type-specific implementations that later become dispatch candidates for a type-erased runtime interface. Here, “candidates” means functions available for binding into an erased slot. Construction or registration selects one candidate and stores its address; runtime invocation follows that address without performing overload resolution.
Virtual interfaces
With a virtual interface, the user writes a derived class and supplies each override explicitly. The implementations can be completely different:
class ImageOperation final : public Operation
{
public:
void execute() override
{
processImageWithGpu();
}
};
class CommandOperation final : public Operation
{
public:
void execute() override
{
validateCommand();
sendCommand();
}
};
The compiler generates the class-specific vtables and any required dispatch thunks. Each object points to the table for its dynamic type, and a virtual call selects the corresponding function-table entry at runtime. The common virtual signature makes dispatch possible, while the user remains free to write unrelated implementation bodies for different derived classes.
Explicit function tables
Without templates, users can populate an erased function-table slot with separately written functions:
void executeImage(void* object);
void executeCommand(void* object);
ErasedOperation image{&image_object, &executeImage};
ErasedOperation command{&command_object, &executeCommand};
The functions have the same erased signature, but their names and implementations can be completely independent. Runtime dispatch invokes whichever pointer was stored during binding.
Static polymorphism can remove this repetitive adapter work:
template <typename T>
void executeAdapter(void* object)
{
static_cast<T*>(object)->execute();
}
Template instantiation generates functions such as:
executeAdapter<ImageOperation>(void*);
executeAdapter<CommandOperation>(void*);
They have the same erased signature and can therefore occupy the same function-table slot. This is the bridge between the two stages:
Compile time:
instantiate type-specific functions with a uniform erased signature
Binding time:
store one generated function pointer in an erased operation table
Runtime:
dispatch through the stored function pointer
The ordinary template form imposes one structural implementation pattern on
all instantiations: cast the erased object to T and perform the same generic
operation. This is less freely customized than independently written virtual
overrides or hand-written erased functions.
That restriction is not absolute. Function overloads, template
specializations, policy types, concepts, and if constexpr can provide
type-specific behavior when required. Increasing customization, however, also
reduces the simplicity gained from one uniform adapter template.
std::function is the representative hybrid. Its templated constructor
generates callable-specific invocation and lifetime adapters during
compilation. The std::function object then erases the callable type, stores
the selected adapters, and dispatches through them at runtime.
std::function as a hybrid case study
std::function combines static polymorphism, type erasure, and runtime
polymorphism.
First template level: public signature
The outer specialization fixes the stable callable interface:
std::function<int(std::string)>
It is one specialization of std::function<R(Args...)> and fixes
operator()’s signature.
Second template level: concrete callable
The instantiated class has a templated constructor accepting many callable types:
std::function<int(int)> function;
function = [](int value) { return value + 1; };
function = &freeFunction;
function = CallableObject{};
For each concrete callable type, the constructor can instantiate an adapter:
template <typename Callable>
int invoke(void* storage, int argument)
{
return std::invoke(
*static_cast<Callable*>(storage),
argument);
}
Implementations also need type-specific destruction, copy, move, and storage operations. The standard does not mandate an operation-table implementation, but it is a useful conceptual model.
Compile time:
instantiate the constructor for Callable
-> generate or select Callable-specific adapters
Construction:
store the Callable and its corresponding adapters
Runtime:
call std::function::operator()
-> dispatch through the stored invocation adapter
It is more precise to say that templates generate adapters than runtime “candidates.” Construction has already selected one adapter; runtime invocation simply follows it.
Thus std::function combines:
- Static polymorphism to generate type-specific adapters.
- Type erasure to remove the callable type from the public value.
- Runtime polymorphism to dispatch through the stored adapter.
Two ways to isolate type-specific logic from a core library
Type-specific behavior can be kept outside a core library without requiring one particular polymorphism technique. Two common designs perform the isolation at different stages: compile-time customization through public templates and runtime registration through a type-erased interface.
Both designs pursue the same architectural result:
The core library operates through a stable, type-independent boundary and does not need to be rebuilt for every application data type.
Compile-time isolation through templates
A library can expose a templated header API and let client code supply the concrete type during compilation:
template <typename T>
void store(const T& value)
{
ByteBuffer bytes = Serializer<T>::serialize(value);
writeBuffer(bytes.data(), bytes.size());
}
When the client calls:
archive.store<CustomerRecord>(record);
the client translation unit instantiates store<CustomerRecord>() and
Serializer<CustomerRecord>. The compiled core library implements only the
type-independent byte operation:
Client compilation:
CustomerRecord
-> instantiate Serializer<CustomerRecord>
-> serialize to bytes
Core-library boundary:
writeBuffer(data, size)
-> store bytes without knowing CustomerRecord
The concrete type remains visible throughout the typed client operation. Consequently, this approach is static polymorphism or compile-time customization, not C++ type erasure. Serialization removes the typed object from the later storage boundary, but the template API itself does not erase its C++ type.
The type-specific template definition may live in a public SDK header while its instantiation is emitted into the client executable or client shared library. Merely parsing the template while compiling the core does not instantiate it for a concrete data type.
Runtime isolation through type support
A core library can instead define a type-erased runtime interface:
class SerializationSupport
{
public:
virtual ~SerializationSupport() = default;
virtual bool serialize(
const void* object,
SerializedPayload& payload) const = 0;
virtual bool deserialize(
const SerializedPayload& payload,
void* object) const = 0;
};
A client or integration layer provides a concrete implementation and registers it during runtime setup:
registry.registerType(
"example.CustomerRecord",
std::make_shared<CustomerRecordSerializationSupport>());
The core retains the registered interface and invokes it later through virtual dispatch:
Runtime registration:
CustomerRecordSerializationSupport
-> register under a type identity
-> bind to a generic archive
Per operation:
generic core
-> SerializationSupport::serialize(void*, payload)
-> virtual dispatch
-> CustomerRecord-specific implementation
Here the core cannot see the concrete C++ object type behind void*. The
virtual object preserves the operations needed to manipulate that hidden type.
This is runtime polymorphism implemented with type erasure.
The implementation must already exist before it can be registered. Runtime binding does not mean runtime code generation: the type-support class and its serialization logic may have been handwritten, generated from a schema, or built as an adapter over generated callbacks.
Same isolation goal, different binding stage
| Aspect | Compile-time template customization | Runtime type support |
|---|---|---|
| Normal call direction | Client calls core | Core calls registered implementation |
| Binding time | Client compilation | Runtime registration |
| Client supplies type knowledge | During client compilation | During runtime registration |
| External implementation mandatory | Depends on whether the template provides a default | Yes |
| Can bypass abstraction | Usually yes, by calling the raw core API | No within the registered interface |
| Core-facing boundary | Serialized bytes | Opaque object and virtual interface |
| Selection mechanism | Template instantiation and direct calls | Virtual or function-table dispatch |
| Type erasure | Not inherently | Yes |
| Inversion of control | Not inherently | Fundamental |
| Core knows concrete data types | No | No |
| Core rebuilt for new types | No | No |
| Type-specific implementation supplied with the client deployment | Yes | Yes |
In both designs, the client-side program or deployment must contain the executable type-specific implementation. This does not mean that application developers must author it themselves: it may come from a library template, generated code, a separately compiled support library, or a plugin.
Construction is the concrete-type dependency boundary
Runtime polymorphism hides a concrete type at the dispatch site, but a concrete instance must first be created somewhere. That construction site is where the type erasure begins:
construction site
-> knows ConcreteOperation and its construction requirements
-> creates the concrete object
-> converts it to Operation*, an erased wrapper, or a function table
-> type-independent code performs runtime dispatch
Code that directly constructs a derived object must see its declaration and usually its complete definition. Its object file can also acquire ordinary symbol dependencies on the concrete constructor, destructor, vtable, and any public construction dependencies:
#include "image_operation.hpp"
std::unique_ptr<Operation> createOperation()
{
return std::make_unique<ImageOperation>();
}
The consumer of the resulting Operation* does not need to know
ImageOperation, but the translation unit containing createOperation() does.
If this construction code is placed in a normally linked provider library, the
host can remain source-isolated from the concrete type while still recording a
normal binary dependency on that provider:
host
-> DT_NEEDED: libimage_operation.so
-> concrete type, constructor, vtable, and private dependencies
The concrete type’s source dependencies remain owned by the provider; they do
not all become headers of every library that links to it. The provider and its
own DT_NEEDED closure do, however, remain part of the host’s normal startup
dependency graph.
Moving construction behind dlopen()
A plugin boundary can remove the concrete provider from the host’s ordinary
link step and DT_NEEDED graph. The host compiles only against a stable
interface and registry contract, then loads providers selected at runtime:
host build
-> knows Operation and Registry
-> no reference to ImageOperation
-> no DT_NEEDED entry for libimage_operation.so
runtime
-> dlopen("libimage_operation.so")
-> provider registers an ImageOperation factory
-> registry creates Operation objects
-> host dispatches through Operation
The provider owns the concrete construction code:
class ImageOperation final : public Operation
{
public:
void execute() override;
};
extern "C" bool register_plugin(Registry* registry)
{
registry->add(
"image",
[]() -> std::unique_ptr<Operation> {
return std::make_unique<ImageOperation>();
});
return true;
}
The host resolves and invokes only the stable registration entry point:
using RegisterPlugin = bool (*)(Registry*);
using LibraryHandle = std::unique_ptr<void, int (*)(void*)>;
LibraryHandle library{
dlopen(path, RTLD_NOW | RTLD_LOCAL),
&dlclose};
if (!library) {
throw PluginError(dlerror());
}
auto register_plugin = reinterpret_cast<RegisterPlugin>(
dlsym(library.get(), "register_plugin"));
if (register_plugin == nullptr || !register_plugin(®istry)) {
throw PluginError("plugin registration failed");
}
The host must retain library for as long as the registry can call a factory
or any object created by that factory remains alive. Unloading it earlier
invalidates the provider’s functions, vtables, and runtime metadata.
A provider can also register through a shared-library constructor or static
registration object that runs during dlopen(). That makes registration
automatic, but it introduces initialization-order, error-reporting, symbol
visibility, and unloading-lifetime concerns. An explicit versioned
register_plugin entry point is usually easier to validate and control.
dlopen() therefore changes when the dependency becomes mandatory:
| Boundary | Concrete provider dependency |
|---|---|
| Direct construction in the host | Compile and link time |
| Normally linked provider factory | Link time and process startup |
dlopen() plus runtime registration |
Only when the host chooses to load and use the provider |
The provider has not disappeared: its binary and its own dependencies must
still be present when the feature is loaded. What disappears is the host’s
compile-time knowledge of the concrete type, its direct link dependency on the
provider, and its requirement to load that provider before main().