C++ Module 23 – Design, idioms and maintainable C++
Type erasure in C++: how std::function works
Build a type-erased C++ wrapper with the concept and model pattern, see how std::function and std::any hold any type, and measure a small buffer.
What you will learn
- Explain the concept and model pattern behind type erasure
- Build a type-erased wrapper with value semantics for unrelated types
- Add a small-buffer optimisation and count the allocations it saves
- Compare the small buffers of std::function and std::any on two standard libraries
- Identify the failures that a type-erased wrapper hides, such as dangling references
Before you start
What you’ll learn · Before you start
What you will learn
- Explain the concept and model pattern behind type erasure
- Build a type-erased wrapper with value semantics for unrelated types
- Add a small-buffer optimisation and count the allocations it saves
- Compare the small buffers of std::function and std::any on two standard libraries
- Identify the failures that a type-erased wrapper hides, such as dangling references
Before you start
On this page
A drawing program must keep a Circle, a Label and a Grid from three different libraries in one std::vector,
and the three classes share no base class. type erasure: Hiding the type of a stored object behind one ordinary wrapper type: the wrapper's constructor template makes a model of an internal interface for each type it is given, and every call goes through that interface, as in std::function and std::any. makes that possible: one
ordinary, non-template type holds values of many different types, as long as each of them supports the operations
the wrapper needs. std::function<int(int)> is the best-known example: the same std::function can hold a function
pointer, a lambda or a function object, and std::any can hold a value of almost any copyable type.
Inside, the wrapper keeps a pointer to an internal interface, called the concept, and its constructor template creates an implementation of that interface, called a model, for each type it is given. A call goes through the interface to the model, and the model calls the stored object. Copying the wrapper copies the object inside, so it behaves like a value. The price is an indirect call on every use and, for larger objects, a heap allocation; every run below was made with Apple clang 21 and GCC 16.
Why not a base class, a template or a variant?
C++ already has three ways to treat different types alike, and each one leaves a gap. A base class with
virtual function: A member function declared virtual in a base class. A derived class may override it with a function of the same name, parameters and const, and a call through a reference or a pointer to the base then runs the version of the object's own class. More in “Virtual functions, override and virtual destructors” needs every type to derive from it, which a type from another
library cannot do. A function template accepts any type with the right member functions, but each type makes a
different instantiation: The compiler writing a real function or class from a template for particular template arguments, such as larger<int> from larger, the first time a use needs it. Each instantiation is compiled as a function or class of its own. More in “C++ function templates and argument deduction”, so one vector cannot hold them all. A std::variant holds
one of a list of types fixed in advance. Type erasure takes the open set of the base class, the “any type with the
right functions” of the template, and the value semantics of the variant. The template can treat the types alike but
never store them together, so the comparison that matters is between the other three:
| Criterion | Base class | Variant | Type erasure |
|---|---|---|---|
| Each type must | Derive from the base | Be in the list | Have the functions |
| Stored as | Pointers to objects | Values | Values |
| New types later | Yes | Only by editing the list | Yes |
| A call goes through | A virtual call | std::visit | A virtual call inside |
The C++ Core Guidelines describe the same idea as offering a generic interface on the outside while dispatching dynamically inside (rule T.5), and they also warn against overusing it (rule T.49): every call pays an indirection, so prefer a template where the type is known while compiling.
Checkpoint
Check: Base class, variant or type erasure
A concept and a model carry every call
Drawable holds any object that has a draw(std::ostream&) const member function. Its constructor template is the
only place that knows the stored type: it wraps the object in a Model<T>, which implements the Concept interface,
and from then on the type is erased:
The path of one call through a type-erased wrapper
Text description of the diagram
The diagram is one column of five boxes joined by arrows, from the caller down to the stored object.
- The caller writes d.draw(out) and sees only the type Drawable. An arrow labelled "calls draw()" leads down.
- Drawable holds one pointer, self_, to a Concept. An arrow labelled "self_->draw(out)" leads down.
- Concept is an internal interface with the virtual functions draw() and clone(). An arrow labelled "virtual call to the model of the stored type" leads down.
- Model<Label> was generated by Drawable's constructor template; it holds a copy of the Label, and its draw() calls label.draw(). An arrow labelled "plain call" leads down.
- Label::draw() is an ordinary member function, and Label has no base class.
C header · drawable.h
// drawable.h: a type-erased Drawable. It holds a copy of any object that has a draw(std::ostream&) const member
// function, and gives it value semantics: copying a Drawable copies the object inside.
#pragma once
#include <concepts>
#include <memory>
#include <ostream>
#include <utility>
template <class T>
concept CanDraw = requires(const T& shape, std::ostream& out) { shape.draw(out); };
class Drawable {
public:
template <CanDraw T>
requires(!std::same_as<T, Drawable>)
Drawable(T shape) : self_(std::make_unique<Model<T>>(std::move(shape))) {}
Drawable(const Drawable& other) : self_(other.self_->clone()) {}
Drawable& operator=(const Drawable& other) {
self_ = other.self_->clone(); // copy first: if that throws, *this is unchanged
return *this;
}
Drawable(Drawable&&) noexcept = default;
Drawable& operator=(Drawable&&) noexcept = default;
void draw(std::ostream& out) const { self_->draw(out); }
private:
struct Concept { // the concept: what every stored type must offer
virtual ~Concept() = default;
virtual void draw(std::ostream& out) const = 0;
virtual std::unique_ptr<Concept> clone() const = 0;
};
template <class T>
struct Model final : Concept { // a model: how one type T offers it
explicit Model(T s) : shape(std::move(s)) {}
void draw(std::ostream& out) const override { shape.draw(out); }
std::unique_ptr<Concept> clone() const override { return std::make_unique<Model>(*this); }
T shape;
};
std::unique_ptr<Concept> self_;
}; C++ · shapes.cpp
// shapes.cpp: three unrelated types in one vector, with no common base class, and copies that stay independent.
#include "drawable.h"
#include <iostream>
#include <string>
#include <vector>
struct Circle {
double radius;
void draw(std::ostream& out) const { out << "circle, radius " << radius; }
};
struct Label {
std::string text;
void draw(std::ostream& out) const { out << "label \"" << text << '"'; }
};
class Grid { // could come from another library: it knows nothing of Drawable
public:
Grid(int rows, int columns) : rows_(rows), columns_(columns) {}
void draw(std::ostream& out) const { out << "grid, " << rows_ << " x " << columns_ << " cells"; }
private:
int rows_;
int columns_;
};
int main() {
std::vector<Drawable> scene{Circle{2.5}, Label{"Exit"}, Grid{3, 4}};
for (const Drawable& d : scene) {
d.draw(std::cout);
std::cout << '\n';
}
Label sign{"Platform 3"};
Drawable a = sign; // a holds its own copy of sign
Drawable b = a; // b holds a copy of a's Label
sign.text = "Platform 4";
a = Circle{1.0}; // a now holds a Circle; b keeps the Label
std::cout << "a: ";
a.draw(std::cout);
std::cout << "\nb: ";
b.draw(std::cout);
std::cout << "\nsizeof(Drawable) = " << sizeof(Drawable) << " bytes\n";
} from session import run
# Three unrelated types behind one Drawable, with both compilers.
run("""
clang++ -std=c++23 -Wall -Wextra shapes.cpp -o shapes && ./shapes
g++-16 -std=c++23 -Wall -Wextra shapes.cpp -o shapes-gcc && ./shapes-gcc
""") Output
$ clang++ -std=c++23 -Wall -Wextra shapes.cpp -o shapes && ./shapes circle, radius 2.5 label "Exit" grid, 3 x 4 cells a: circle, radius 1 b: label "Platform 3" sizeof(Drawable) = 8 bytes $ g++-16 -std=c++23 -Wall -Wextra shapes.cpp -o shapes-gcc && ./shapes-gcc circle, radius 2.5 label "Exit" grid, 3 x 4 cells a: circle, radius 1 b: label "Platform 3" sizeof(Drawable) = 8 bytes
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 shapes_session.py
Circle, Label and Grid share no base class and know nothing about Drawable, yet one vector holds all three.
The copy constructor calls clone(), which copies the stored object. Step through the last lines of shapes.cpp and
watch which model each line changes:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- An ordinary Label. Label sign{"Platform 3"} is a plain struct with a draw() function. It knows nothing about Drawable and derives from nothing.
- Drawable a = sign;. The constructor template, the one place that knows T is Label, copies sign into a new Model<Label> on the heap and points a's self_ at it. From here on, a sees only the Concept interface.
- Drawable b = a;. The copy constructor calls self_->clone(). The virtual call reaches Model<Label>::clone, which copies the whole model, so b owns a second Label of its own.
- sign changes. sign.text = "Platform 4" changes sign alone. Both models hold their own copies, which still say "Platform 3".
- a = Circle{1.0};. A Drawable is built from the Circle and moved into a. a's old Model<Label> is destroyed, and its self_ now points to a Model<Circle>. b and its Label are untouched.
- Two independent values. a.draw prints "circle, radius 1" and b.draw prints the label "Platform 3". Each Drawable is 8 bytes on both compilers, one pointer, whatever it holds.
b kept the Label “Platform 3” after sign changed and after a was given a Circle. A Drawable is 8 bytes on both
compilers, one pointer, whatever object it holds. The CanDraw constraint rejects a type without draw() at the line
that stores it, instead of deep inside Model.
Key idea
Only the constructor template knows the stored type: it builds a model of the interface for that type, every call goes through the interface, and copying the wrapper clones the model.
Checkpoint
Check: The concept and model pattern
std::function and std::any follow the same plan
std::function works like Drawable: its constructor template builds a model for the callable’s type, its call
operator forwards through the interface, and copying it copies the callable. The standard wrappers add one
refinement that matters for speed. A small-buffer optimisation: Keeping a small stored object inside the wrapper's own storage instead of on the heap, so that holding it needs no allocation. The standard recommends it for std::function and std::any; each library chooses the size of the buffer. stores
small objects inside the wrapper itself, so that holding them needs no heap allocation. The standard recommends it
for std::function and std::any and leaves the size of the buffer to each library; for std::any it allows the
buffer only for types whose move constructor cannot throw. The newer callable wrappers differ in what they accept.
std::move_only_function (C++23) is moved, never copied, so it also holds callables that cannot be copied.
std::copyable_function (C++26) is the copyable counterpart of std::move_only_function, and std::function_ref
(C++26) refers to a callable instead of owning it. Walk through the editions:
- C++11
std::function, and the deleter of std::shared_ptr
std::function<R(Args...)> holds a copy of any copyable callable with that signature. A std::shared_ptr hides its deleter the same way: the deleter is not part of the shared_ptr's type.
- C++17
std::any
std::any holds a copy of a value of almost any copyable type. Its small buffer may hold only types whose move constructor cannot throw.
- C++23
std::move_only_function moves only
Like std::function, it holds a callable behind one type, and it also accepts callables that cannot be copied (paper P0288R9).
- C++26
std::copyable_function and std::function_ref new wrappers
std::copyable_function is the copyable counterpart of std::move_only_function (paper P2548R6). std::function_ref refers to a callable instead of owning it.
The timeline has 4 events, from C++11 to C++26.
Checkpoint
Check: The standard type-erased wrappers
Practise: many types behind one wrapper
Ten questions on these three ideas, asked one at a time. The idea your answers show is weakest comes first, a right answer brings a harder question, and the reason behind each choice appears as soon as you pick it.
Practice that adapts to you
Practise this lesson
10 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
A small buffer inside the wrapper, measured on both libraries Pro: opens with a Pro, Premium or Ultimate pass
Most stored objects are a few bytes, so a heap allocation for each of them costs more than the object itself. This part builds a 32-byte buffer into Drawable, counts the allocations it saves, and measures the buffers that std::function and std::any already have.
In this part:
- Storing small objects inside the wrapper, and the three conditions
- 1,000 copies of six shapes, counted
- What std::function and std::any keep inside, measured
1 diagram · 5 runnable examples (C header, C++, Python) · about 7 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Hidden references and empty wrappers, reproduced, then an exercise Pro: opens with a Pro, Premium or Ultimate pass
A type-erased wrapper hides what it holds, including references that outlive what they refer to. This part reproduces that failure under AddressSanitizer, shows what the standard wrappers do when they are empty or asked for the wrong type, compares the standard wrappers, and ends with an exercise.
- A std::function that keeps a dangling reference, caught by AddressSanitizer
- Empty wrappers and wrong casts: what each one throws
- Choosing a standard wrapper, with what each library has
- Your turn: AnyPrintable with a 32-byte buffer
1 diagram · 4 runnable examples (C header (any_printable.h), C header (count_new.h), C++, Python) · about 5 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Interview questions
One warm-up, answered in full, then three design questions:
-
Fresher to mid · Warm-up
What is type erasure in C++? Name two standard library types that use it.
Type erasure lets one ordinary, non-template type hold values of many different types that support the same operations, without those types sharing a base class. The wrapper keeps a pointer to an internal abstract class, the concept, and its constructor template creates an implementation of it, a model, for each type it is given; calls go through the concept to the model, which calls the stored object. std::function is the classic example: std::function<int(int)> can hold a function pointer, a lambda or a function object. std::any holds a copy of a value of almost any copyable type, and the deleter of a std::shared_ptr is erased too, since it is not part of the shared_ptr's type. The price is an indirect call on each use and, for objects too big for an internal buffer, a heap allocation.
-
Mid to senior
A thread pool stores queued jobs as std::function<void()>. A new job type captures a std::unique_ptr, and the code stops compiling. Explain why, and choose a type for the queue that works with Apple clang's libc++ and with GCC's libstdc++.
-
Senior to staff
A profile of an event loop shows millions of heap allocations a second inside std::function. How do you tell which callables allocate, and what are your options for removing those allocations?
-
Senior to staff
Design a Shape value type that can hold any shape class from several libraries, copies like an int and can be stored in a std::vector. Walk through its interface, its copy and move operations, and how you would keep copies cheap.
Key takeaways
- A base class needs every type to derive from it, a template cannot store different types together and a variant lists its types in advance; type erasure keeps an open set of types, stored as values.
- Type erasure gives one ordinary type value semantics over many unrelated types: a concept interface, a model template per stored type, and a constructor template that connects them.
std::function,std::anyand the deleter ofstd::shared_ptrare type-erased;std::variantis not, because it names every type it can hold.- A small buffer keeps small objects inside the wrapper without an allocation; each library sizes its own, and
std::anyuses it only for types whose move cannot throw. - Each call goes through an indirection and each stored object may need a heap allocation, so prefer a template where the type is known while compiling.
Terms in this lesson
- small-buffer optimisation
- Keeping a small stored object inside the wrapper's own storage instead of on the heap, so that holding it needs no allocation. The standard recommends it for std::function and std::any; each library chooses the size of the buffer.
- type erasure
- Hiding the type of a stored object behind one ordinary wrapper type: the wrapper's constructor template makes a model of an internal interface for each type it is given, and every call goes through that interface, as in std::function and std::any.
References
- Working Draft, Programming Languages: C++ (N4950): [any.class], [func.wrap.func], [func.wrap.func.con], [func.wrap.move] (ISO/IEC JTC1/SC22/WG21)
- C++ Core Guidelines: T.5, combine generic and OO techniques to amplify their strengths, not their costs (Standard C++ Foundation)
- C++ Core Guidelines: T.49, where possible, avoid type-erasure (Standard C++ Foundation)
- P0288R9: move_only_function (ISO/IEC JTC1/SC22/WG21)
- P2548R6: copyable_function (ISO/IEC JTC1/SC22/WG21)
- std::function (cppreference.com)
- std::any (cppreference.com)
- C++26 (cppreference.com)
- AddressSanitizer (LLVM Project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress