Runtime Polymorphism
Virtual dispatch, std::variant+std::visit, type erasure, and std::function compared — heap cost, extensibility, performance, and when to reach for each.
Runtime Polymorphismsince C++98Runtime polymorphism is the selection of behavior at runtime based on the actual type of an object, allowing a caller that holds only an abstract reference to invoke type-specific logic without compile-time knowledge of the concrete type.
Overview
C++ offers four distinct mechanisms for runtime polymorphism. The choice hinges on two orthogonal questions: is the set of concrete types open (extensible, unknown at compile time) or closed (fully enumerated in one translation unit), and do you need value semantics or is pointer/reference ownership acceptable?
| Mechanism | Type set | Value semantics | Heap allocation |
|---|---|---|---|
| Virtual dispatch | Open | No | Always |
std::variant + std::visit (C++17) | Closed | Yes | No |
| Type erasure | Open | Yes (with SBO) | Usually |
std::function (C++11) | Open | Yes | Sometimes |
Virtual dispatch is the lingua franca; the others exist precisely because it forces heap allocation and pointer semantics. std::variant (C++17) is the go-to when the type set is fixed at compile time. Type erasure gives open extensibility with value semantics at the cost of implementation complexity. std::function (C++11) is for single-operation polymorphism: callbacks and event handlers.
Virtual Dispatch
Every polymorphic object carries a hidden vptr — one pointer per instance pointing to the class's vtable, a static array of function pointers. A virtual call dereferences the vptr, loads the function pointer at a fixed offset, then calls through it: two pointer hops. Under cache pressure this costs roughly 3–5 ns per call versus ~1 ns for a direct call.
struct Shape {
virtual double area() const = 0;
virtual void draw() const = 0;
virtual ~Shape() = default; // required — deleting via base pointer is UB without this
};
struct Circle final : Shape { // C++11: final enables devirtualization
double r;
explicit Circle(double r) : r{r} {}
double area() const override { return 3.14159265 * r * r; }
void draw() const override { /* ... */ }
};
struct Rect final : Shape {
double w, h;
double area() const override { return w * h; }
void draw() const override { /* ... */ }
};
// C++11: unique_ptr; C++14: make_unique
std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Circle>(5.0));
shapes.push_back(std::make_unique<Rect>(3.0, 4.0));
for (const auto& s : shapes) s->draw(); // virtual dispatchMarking concrete types final (C++11) allows the compiler to devirtualize calls when the static type is visible — turning a virtual call into a direct call or inlining it entirely. When iterating through vector<unique_ptr<Shape>> the static type is unknown and devirtualization cannot occur.
Virtual dispatch is correct when external code must extend the hierarchy, when objects have non-trivial lifetimes that naturally map to heap allocation, or when ABI stability requires a stable vtable layout.
Devirtualization — When the Compiler Eliminates Virtual Overhead
The virtual dispatch overhead is not fixed. Compilers eliminate it entirely under several provable conditions, turning the indirect call into a direct call or inlining the body completely.
Known dynamic type. When the compiler can prove that the static and dynamic types are the same — a stack-allocated concrete object, or a value that was never cast to a base pointer — the call is resolved at compile time regardless of the virtual keyword:
Circle c{5.0};
c.area(); // devirtualized: compiler calls Circle::area directly, may inlinefinal class or method (C++11). The final specifier is the most reliable portable trigger. When derived is final, any Derived* parameter can only point to a Derived object; there are no subclasses to consider:
struct Derived final : Base {
void f() const override {}
};
void call(Derived const& d) {
d.f(); // devirtualized: MSVC, GCC, Clang all inline to a no-op `ret`
}
// Without `final`, the same function emits:
// mov rax, QWORD PTR [rcx] ; load vptr
// rex_jmp QWORD PTR [rax] ; indirect jump through vtableIndividual methods can also be marked final without sealing the whole class, giving finer-grained optimization hints.
Internal linkage (anonymous namespace). A class defined inside an anonymous namespace cannot be named — and therefore cannot be subclassed — outside the current translation unit. GCC leverages this to devirtualize calls even without final:
namespace {
class LocalImpl final : public Base { /* ... */ };
}
// All calls on LocalImpl within this TU are devirtualizableLink-time optimization (LTO). With -flto (GCC/Clang) or /GL /LTCG (MSVC), the linker performs whole-program analysis and can devirtualize calls that cross translation unit boundaries — eliminating virtual overhead on hot paths that are invisible at single-TU compile time.
Profile-guided optimization (PGO). PGO lets the compiler speculatively devirtualize: it instruments a training run, identifies which concrete type a virtual call resolves to most often (e.g. 98% Circle), and emits a type guard followed by a direct call for the common case, falling back to the vtable for the rest. This is especially effective in plugin systems where final cannot be used.
The key takeaway from Arthur O'Dwyer's analysis (2021) is that final on the class or method is the only portable, compiler-independent trigger. Dataflow-based devirtualization (inferring the dynamic type from control flow) is supported by GCC and Clang but fails on many non-trivial patterns and should not be relied upon.
std::variant + std::visit (C++17)
std::variant<Ts...> is a discriminated union: stack-allocated, sizeof(variant) equals the largest member plus a discriminant. std::visit generates a jump table at compile time — typically faster than virtual dispatch — and the compiler enforces exhaustive handling.
The overloaded-lambda idiom is more idiomatic than if constexpr chains:
// C++17: aggregate base with variadic using-declaration
template<class... Ts> struct overload : Ts... { using Ts::operator()...; };
template<class... Ts> overload(Ts...) -> overload<Ts...>; // deduction guide, C++17
// C++20+: CTAD for aggregates makes the deduction guide unnecessary
struct Circle { double r; };
struct Rect { double w, h; };
struct Triangle { double base, height; };
using Shape = std::variant<Circle, Rect, Triangle>; // C++17
double area(const Shape& s) {
return std::visit(overload{
[](const Circle& c) { return 3.14159265 * c.r * c.r; },
[](const Rect& r) { return r.w * r.h; },
[](const Triangle& t) { return 0.5 * t.base * t.height; },
}, s); // compile error if any alternative is unhandled
}
std::vector<Shape> shapes{Circle{5}, Rect{3, 4}, Triangle{6, 4}};
for (const auto& s : shapes)
std::cout << area(s) << '\n';For non-visitor access, std::get_if<T>(&v) (C++17) returns T* or nullptr without throwing, making it safe for ad-hoc type inspection.
The hard constraint is coupling: adding a new type to the variant requires updating every visitor site. In a codebase with dozens of visitors this becomes a maintenance burden. Keep the variant narrow — ideally used in one subsystem.
std::variant Closed-Hierarchy Patterns
std::monostate — the "empty" alternative
std::variant requires its first alternative to be default-constructible unless you provide another. When all your types have non-trivial constructors, std::monostate (C++17) serves as an explicit empty/uninitialised sentinel:
// C++17: monostate makes the variant default-constructible
using Token = std::variant<std::monostate, IntLiteral, FloatLiteral, Identifier>;
Token tok; // holds monostate — "not yet parsed"
if (std::holds_alternative<std::monostate>(tok)) { /* unset */ }
// In a visitor, monostate is just another arm:
std::visit(overload{
[](std::monostate) { throw std::logic_error("unset token"); },
[](IntLiteral l) { /* ... */ },
[](FloatLiteral f) { /* ... */ },
[](Identifier id) { /* ... */ },
}, tok);if constexpr dispatch inside a visitor
When the per-type logic differs only slightly, a generic lambda with if constexpr avoids writing separate overloads:
// C++17: generic lambda + if constexpr inside std::visit
std::visit([](const auto& node) {
using T = std::decay_t<decltype(node)>;
if constexpr (std::is_same_v<T, IntLiteral>) {
emit_int(node.value);
} else if constexpr (std::is_same_v<T, FloatLiteral>) {
emit_float(node.value);
} else {
emit_identifier(node.name);
}
}, tok);The overload helper (shown above) is usually cleaner; prefer if constexpr only when the alternatives share significant boilerplate that would be duplicated across overloads.
Recursive variants via std::unique_ptr
std::variant cannot directly hold itself — the size would be infinite. The standard workaround is to break the cycle with heap indirection. std::unique_ptr is the minimal tool; wrap it in a value-semantic box<T> (propagates const, deep-copies) for cleaner interfaces:
// C++17: break recursive cycle with unique_ptr
struct LiteralExpr { int value; };
struct AddExpr;
struct MulExpr;
using Expr = std::variant<
LiteralExpr,
std::unique_ptr<AddExpr>,
std::unique_ptr<MulExpr>
>;
struct AddExpr { Expr lhs, rhs; };
struct MulExpr { Expr lhs, rhs; };
int evaluate(const Expr& e) {
return std::visit(overload{
[](const LiteralExpr& l) { return l.value; },
[](const std::unique_ptr<AddExpr>& a) { return evaluate(a->lhs) + evaluate(a->rhs); },
[](const std::unique_ptr<MulExpr>& m) { return evaluate(m->lhs) * evaluate(m->rhs); },
}, e);
}std::unique_ptr breaks copyability. For a fully value-semantic recursive variant wrap the pointer in a deep-copying box<T> that propagates const access (foonathan 2022). C++26's std::indirect<T> standardises exactly this pattern.
Performance Deep-Dive
Virtual dispatch costs fall into three distinct regimes:
| Scenario | Typical cost | Cause |
|---|---|---|
| Virtual call, vtable cache-hot | 1–3 ns | Two pointer hops; branch predictor has seen this call target |
| Virtual call, vtable cache-cold | 5–50+ ns | Instruction-cache miss + pipeline stall on mispredicted indirect branch |
| Virtual call after devirtualization | ~0 ns | Compiler inlined; no indirect call at all |
std::variant + std::visit (cache-warm) | 1–3 ns | Jump table; data lives contiguously in one vector<Variant> |
std::function call (small lambda, SBO) | 2–5 ns | One indirect call; no heap round-trip if callable fits in SBO buffer |
std::function construction, large capture | 10–50 ns | Heap allocation for captures that exceed the ~16–32 byte SBO buffer |
Key findings from benchmarks (johnnysswlab.com 2021, cppstories.com 2020, Mateusz Pusz CppCon 2018):
- For a short, fast function on a cache-warm vtable, virtual dispatch is ~18% slower than a direct call. For a long, compute-heavy function the overhead disappears below 1%.
- Memory layout dominates. A
vector<unique_ptr<Base>>with randomly scattered heap objects can be 7.5x slower than a sorted or contiguous layout — because each element access is a cache miss, not because of the virtual call itself. - Type-sorted containers recover most performance. Keeping objects sorted by concrete type lets the branch predictor learn the pattern; unsorted mixed-type iteration can be 2.5x slower than sorted for short virtual functions.
std::variantwins on contiguous data. Storingvector<Variant>keeps all objects in one block; cppstories benchmarks show ~10% throughput improvement overvector<unique_ptr<Base>>for a 72-byte particle type. The benefit is the data layout, not the dispatch mechanism itself.std::visitvs virtual: no silver bullet. For object types of similar size, benchmarks show near-parity when both are cache-warm.std::varianthas a memory overhead (all elements are the size of the largest alternative); a poorly-sized variant wastes cache lines and negates the layout benefit.
Practical rule of thumb: before replacing virtual dispatch with std::variant for performance, measure. The difference is usually dominated by allocator behaviour and memory layout, not by the dispatch mechanism itself. Prefer std::variant when the closed-set guarantee and value semantics are architecturally correct; treat performance as a secondary benefit that requires profiling to confirm.
Type Erasure
Type erasure (the pattern, not the term) gives you open extensibility and value semantics simultaneously. It is what std::any (C++17) and std::function (C++11) use internally. The canonical form embeds a private Concept/Model pair:
class AnyShape {
struct Concept {
virtual double area() const = 0;
virtual void draw() const = 0;
virtual std::unique_ptr<Concept> clone() const = 0;
virtual ~Concept() = default;
};
template<typename T>
struct Model final : Concept {
T obj;
explicit Model(T v) : obj{std::move(v)} {}
double area() const override { return obj.area(); }
void draw() const override { obj.draw(); }
std::unique_ptr<Concept> clone() const override {
return std::make_unique<Model>(*this);
}
};
std::unique_ptr<Concept> impl_; // C++11
public:
template<typename T>
AnyShape(T v) : impl_{std::make_unique<Model<std::decay_t<T>>>(std::move(v))} {}
AnyShape(const AnyShape& o) : impl_{o.impl_->clone()} {}
AnyShape& operator=(AnyShape o) noexcept { // C++11: noexcept
std::swap(impl_, o.impl_); return *this;
}
AnyShape(AnyShape&&) noexcept = default; // C++11
double area() const { return impl_->area(); }
void draw() const { impl_->draw(); }
};
// No base class, no modification of existing types:
struct Pentagon {
double side;
double area() const { return 1.72047 * side * side; }
void draw() const { /* ... */ }
};
AnyShape s = Pentagon{4.0};
s.draw(); // dispatches to Pentagon::drawPentagon has no knowledge of AnyShape. This is external polymorphism — the polymorphic interface is defined outside the types it wraps, making it non-intrusive and ideal for wrapping third-party types.
Small Buffer Optimization (SBO): The unique_ptr<Concept> implementation heap-allocates on every construction. Production-quality type erasure replaces it with an aligned stack buffer (typically 16–32 bytes) and falls back to heap only for larger types. This is how std::function avoids allocation for small lambdas. Implementing SBO correctly requires placement new, manual alignment bookkeeping, and move/copy dispatch — it is non-trivial. Libraries like dyno or folly::Poly provide polished SBO-enabled type erasure.
std::function and C++23 Successors
For single-operation polymorphism std::function (C++11) is the minimal tool:
// C++11
using DrawCmd = std::function<void(int x, int y)>;
std::vector<DrawCmd> queue;
queue.emplace_back([](int x, int y) { draw_circle(x, y, 10); });
queue.emplace_back([offset = 5](int x, int y) { draw_rect(x + offset, y, 20, 20); });
for (auto& cmd : queue) cmd(0, 0);std::function uses internal type erasure with SBO (size is implementation-defined, typically 16–32 bytes). Callables that exceed the buffer trigger a heap allocation on every copy-construction. It also requires the stored callable to be copyable, which excludes lambdas capturing unique_ptr or other move-only types.
C++23 addresses both problems:
// C++23: accepts move-only callables, no copy overhead
std::move_only_function<void(int, int)> cmd =
[p = std::make_unique<Brush>(Color::Red)](int x, int y) {
p->stroke(x, y);
};
// C++26: non-owning, zero allocation, for function parameters
void apply(std::function_ref<int(int)> f, std::span<int> data) { // C++26 / C++20
for (auto& x : data) x = f(x);
}Prefer std::move_only_function (C++23) over std::function for new code unless copyability is specifically required. Use std::function_ref (C++26) for parameters where the callee does not store the callable past the call — it is zero-cost and expresses the lifetime contract precisely.
Best Practices
Prefer std::variant when the type set is fixed and small. Compiler-enforced exhaustiveness, zero heap allocation, and measurable throughput gains justify the closed-set constraint in hot paths.
Use virtual dispatch when external extensibility is the requirement. Plugin architectures, framework hooks, and APIs that accept arbitrary user-defined types all justify pointer indirection.
Apply type erasure at stable API boundaries. When shipping a library that must accept arbitrary user types without coupling to a base class, type erasure keeps the interface clean. Hide the Concept/Model internals behind an opaque value type.
Mark leaf classes final (C++11). Even within a virtual hierarchy, final concrete types allow the compiler to devirtualize calls made through concrete-type variables or references — a free optimization.
Reach for std::move_only_function (C++23) over std::function by default. It handles move-only captures, avoids copy machinery, and is strictly more general.
Common Pitfalls
Object slicing. Assigning a derived object to a base-class value silently discards the derived members:
Circle c{5.0};
Shape s = c; // strips Circle to Shape (compile error if Shape is abstract, silent data loss otherwise)
void process(Shape s); // BAD: slices any derived type passed by value
void process(const Shape& s); // OK: reference preserves dynamic typeAlways accept polymorphic types by pointer or reference, or use type erasure for genuine value semantics.
Missing virtual destructor. Deleting a derived object through a base pointer is undefined behaviour if the base destructor is non-virtual:
Shape* s = new Circle{5.0};
delete s; // UB if Shape::~Shape is not virtual — Circle's destructor never calledEvery polymorphic base class must declare virtual ~Base() = default.
Virtual calls in constructors and destructors. During Base construction, the vtable is Base's — derived overrides are not yet registered. The call resolves to Base::f() regardless of the dynamic type. This is defined behaviour but almost always incorrect.
Variant narrowing on std::get. std::get<T>(v) throws std::bad_variant_access if v holds a different alternative. Use std::get_if<T>(&v) (C++17) when the type is uncertain, or use std::visit to handle all alternatives.
std::function silent heap allocation. Lambdas capturing by value with non-trivial state (a string, a vector, multiple members) easily exceed the SBO buffer and allocate on every construction. Profile before assuming std::function is free.
std::variant Multi-Variant Visitation and Closed-Set Design
When the full set of types is known at compile time, std::variant is architecturally superior to virtual dispatch. The compiler enforces exhaustive handling, no heap allocation is required, and the data for a collection of variants lives contiguously in memory. The closed-set constraint is a feature, not a limitation — it makes illegal states unrepresentable.
std::visit accepts multiple variants simultaneously, generating an N-dimensional dispatch table:
// C++17: two-variant visitation — all pairs handled, compiler-enforced
using Scalar = std::variant<int, double, std::string>;
std::string add_to_string(const Scalar& lhs, const Scalar& rhs) {
return std::visit(overload{
[](int l, int r) { return std::to_string(l + r); },
[](double l, double r) { return std::to_string(l + r); },
[](const auto& l, const auto& r) {
// catch-all for mixed-type pairs
return std::string{"<mixed>"};
},
}, lhs, rhs);
}The catch-all generic lambda arm handles every combination not explicitly listed — without it, the code would fail to compile if any combination were unhandled. For N alternatives across K variants, std::visit generates an N^K jump table at compile time; keep variant cardinality modest to avoid code-size bloat.
A related pattern uses std::visit to implement the double-dispatch problem that classic OOP solves with the Visitor pattern, but without requiring base-class modifications:
// C++17: double dispatch via two-argument std::visit
using Node = std::variant<Literal, BinOp, UnaryOp>;
struct Evaluator {
int operator()(const Literal& l, const Literal& r) const { return l.val + r.val; }
int operator()(const BinOp& l, const Literal& r) const { return eval(l) + r.val; }
// ... other combinations
};
// Single std::visit call handles all (N * M) type-pair combinationsPrefer std::variant over a virtual Visitor base class when adding new operations is frequent but adding new types is rare — the classic asymmetry discussed in Philip Wadler's "Expression Problem".
std::any and Custom SBO Type Erasure
std::any (C++17) is type erasure without a concept — it stores any copy-constructible value and retrieves it via std::any_cast, but forgets all other operations. It is appropriate for heterogeneous containers where the consumer knows the concrete type (e.g., a configuration map), but it is the wrong tool when the consumer needs to invoke behaviour on the stored object without knowing its type.
// C++17: std::any — type-safe void*
#include <any>
std::any cfg = std::string{"debug"};
cfg = 42; // rebind to int
cfg = std::vector<int>{1, 2, 3}; // rebind again
// Safe retrieval
if (auto* p = std::any_cast<int>(&cfg)) {
std::cout << *p << '\n'; // prints 42
}
// Throwing retrieval
try {
auto s = std::any_cast<std::string>(cfg); // throws bad_any_cast
} catch (const std::bad_any_cast& e) { /* ... */ }std::any uses SBO internally (buffer size is implementation-defined, typically 16 bytes on libc++ and libstdc++). Types that fit are stored in-place with no heap allocation; larger types are heap-allocated. The discriminator is a type-erased pointer to a "manage" function that handles copy, move, and destroy.
For behaviour-bearing type erasure with SBO, the canonical approach replaces std::unique_ptr<Concept> with an aligned storage buffer:
// C++17: manual SBO type erasure — concept operations stored in-place when small
template<typename Concept, std::size_t BufferSize = 32>
class SBOHolder {
alignas(std::max_align_t) std::byte buf_[BufferSize];
bool heap_ = false;
Concept* concept_ptr() {
return heap_
? *reinterpret_cast<Concept**>(buf_) // pointer stored in buffer
: reinterpret_cast<Concept*>(buf_); // object stored in buffer
}
public:
template<typename T>
explicit SBOHolder(T val) {
if constexpr (sizeof(T) <= BufferSize && alignof(T) <= alignof(std::max_align_t)) {
new (buf_) ConcreteModel<T>(std::move(val));
heap_ = false;
} else {
auto* p = new ConcreteModel<T>(std::move(val));
std::memcpy(buf_, &p, sizeof(p));
heap_ = true;
}
}
// move, copy, destructor elided for brevity
};Production implementations handle the five special member functions carefully and add a noexcept move path for the in-buffer case. Libraries such as dyno (Louis Dionne), folly::Poly, and proxy (Microsoft, C++26-targeted) provide polished SBO type erasure with minimal boilerplate.
Performance: Virtual Dispatch vs std::visit vs std::function
The overhead of each polymorphism mechanism is dominated by memory layout and branch-predictor state, not the dispatch instruction itself. Isolating the mechanisms in a microbenchmark produces misleading numbers; production workloads behave differently.
| Mechanism | Dispatch cost (cache-warm) | Allocation on construction | Compiler can inline? |
|---|---|---|---|
Virtual call, final class | ~1 ns (devirtualized: 0) | One heap alloc (object) | Yes, if final |
| Virtual call, general | 2–5 ns | One heap alloc (object) | No |
std::variant + std::visit | 1–3 ns | None (stack) | Yes (jump table) |
std::function, small lambda | 2–5 ns | None (SBO) | No |
std::function, large capture | 2–5 ns + 10–50 ns | Heap on construction | No |
std::move_only_function (C++23) | same as std::function | same policy | No |
Vtable indirection is two pointer hops. The first dereferences the object's hidden vptr to reach the vtable; the second loads the function pointer at a fixed slot offset. Modern CPUs predict direct branches accurately; indirect branches (through a register) are harder. Monomorphic call sites — where the same concrete type is seen every time — are predicted correctly after a few iterations. Polymorphic call sites — alternating between two or more concrete types — can stall the front-end by 10–30 cycles per misprediction.
std::visit generates a jump table, which is also an indirect branch, but the table is typically denser and more predictable because std::variant encodes the active type as a small integer discriminant. For sorted or type-grouped variant collections the predictor converges quickly.
std::function pays a fixed indirect-call cost regardless of closure size, but construction cost varies sharply: a lambda that captures a std::string or a std::vector will exceed the SBO threshold and allocate. In tight loops, prefer storing pre-constructed std::function objects rather than constructing them per-iteration.
Devirtualization eliminates all dispatch cost. When the compiler proves the dynamic type — via final, local construction, or LTO — it replaces the indirect call with a direct call and may inline the body. This is the single biggest lever for virtual-dispatch performance and requires no algorithmic change.
Polymorphism in Value Semantics
Classic virtual dispatch forces pointer or reference semantics; the object must outlive any reference to it, and ownership must be managed explicitly. Value semantics — objects that copy and move like int — are more composable: they fit into standard containers naturally, support structured bindings, and eliminate entire categories of lifetime bugs.
std::unique_ptr ownership and transfer
std::unique_ptr<Base> (C++11) is the standard single-ownership handle for polymorphic objects. It moves freely and deletes correctly through a virtual destructor:
// C++14: make_unique for exception-safe construction
auto make_shape(std::string_view kind) -> std::unique_ptr<Shape> {
if (kind == "circle") return std::make_unique<Circle>(5.0);
if (kind == "rect") return std::make_unique<Rect>(3.0, 4.0);
throw std::invalid_argument{"unknown shape"};
}
// Transfer of ownership is explicit and zero-cost
std::unique_ptr<Shape> s = make_shape("circle");
std::vector<std::unique_ptr<Shape>> collection;
collection.push_back(std::move(s)); // s is null after moveThe Clone Pattern for Copyable Polymorphic Hierarchies
std::unique_ptr<Base> is move-only. To build a copyable polymorphic value type — a class that copies its internal hierarchy deeply — add a virtual clone() method to the base:
// C++11: clone pattern for deep-copyable polymorphic hierarchies
struct Shape {
virtual std::unique_ptr<Shape> clone() const = 0;
virtual double area() const = 0;
virtual ~Shape() = default;
};
struct Circle final : Shape {
double r;
explicit Circle(double r) : r{r} {}
std::unique_ptr<Shape> clone() const override {
return std::make_unique<Circle>(*this); // copy constructor
}
double area() const override { return 3.14159265 * r * r; }
};
// Wrapper that gives full value semantics to the hierarchy
class AnyShapeValue {
std::unique_ptr<Shape> impl_;
public:
template<typename S>
AnyShapeValue(S s) : impl_{std::make_unique<S>(std::move(s))} {}
AnyShapeValue(const AnyShapeValue& o) : impl_{o.impl_->clone()} {}
AnyShapeValue(AnyShapeValue&&) noexcept = default;
AnyShapeValue& operator=(AnyShapeValue o) noexcept {
std::swap(impl_, o.impl_); return *this;
}
double area() const { return impl_->area(); }
};
// Now AnyShapeValue is copyable and assignable — stores on the heap,
// but the caller sees pure value semantics
std::vector<AnyShapeValue> shapes;
shapes.push_back(Circle{5.0});
auto copy = shapes; // deep-copies every element via clone()Value-Based Polymorphism Libraries
For production code, third-party libraries automate the boilerplate above. dyno (Louis Dionne, proposed for standardisation) separates the concept definition from the storage policy; folly::Poly follows the same design inside Meta's codebase. proxy (Microsoft, targeting C++26) uses a macro-free interface-map approach and is available on Compiler Explorer today:
// C++20: Microsoft's `proxy` library — zero-boilerplate type erasure
// https://github.com/microsoft/proxy
#include <proxy.h>
PRO_DEF_MEM_DISPATCH(MemArea, area);
PRO_DEF_MEM_DISPATCH(MemDraw, draw);
struct ShapeFacade : pro::facade_builder
::add_convention<MemArea, double() const>
::add_convention<MemDraw, void()>
::build {};
pro::proxy<ShapeFacade> s = pro::make_proxy<ShapeFacade>(Circle{5.0});
std::cout << s->area() << '\n'; // dispatches to Circle::areaproxy stores the vtable as a per-proxy static constant rather than per-object, reducing instance size. It supports SBO, const-correctness, and noexcept specification on individual operations — qualities that manual type erasure must implement from scratch.
See Also
- Type Erasure — deep dive into the Concept/Model pattern with SBO
- CRTP — static (compile-time) polymorphism via the Curiously Recurring Template Pattern
- Policy-Based Design — compile-time strategy injection via template parameters
std::variant— discriminated union, C++17std::function— polymorphic callable wrapper, C++11