Type erasure
Type erasure keeps the interface of a concept and hides the concrete type behind it. std::function is the standard example: a vector of lambdas, function pointers and class objects with operator() all have the type std::function<int(int)>.
The pimpl idiom of lesson 124 hides the data of one class; type erasure hides the type of many classes behind one interface. This lesson uses the standard tools and then writes the mechanism by hand, which is the same three lines of a virtual call.
The standard library uses the same triple in std::function, std::any and the deleter of std::shared_ptr. Recognising the base class, the model and the pointer makes the cost of those types predictable and the choice between them easier.
The copy of a type-erased wrapper is what separates the designs: std::function copies its target, std::any copies its value, and a hand-written wrapper copies only when its model implements the copy.
std::function
A std::function<R(Args)> holds any callable with that signature: a lambda, a function pointer, a struct with operator(). The wrapper stores the callable, possibly behind an allocation, and the call is one virtual dispatch.
The program puts a function pointer and a lambda in one vector and calls both. The total is 25: twice five plus five and ten.
The requirement is that the callable is copyable, which is why the move-only task of lesson 112 could not go into one. The target type is gone from the variable, so a function does not know which callable it holds.
#include <functional>
#include <iostream>
#include <vector>
int twice(int value)
{
return value * 2;
}
int main()
{
std::vector<std::function<int(int)>> functions;
functions.emplace_back(twice);
functions.emplace_back([](int value) { return value + 10; });
int total = 0;
for (const auto& function : functions) {
total += function(5);
}
std::cout << total << '\n';
}
g++ -std=c++23 -Wall -Wextra function.cpp -o function_erase
./function_erase
25
The wrapper by hand
The mechanism behind std::function is three pieces: a base class with the virtual function, a model that stores one concrete callable and implements the function, and a pointer to the concept. The template constructor creates the model of whatever it is given.
The program builds one wrapper around a square lambda and one around an increment lambda and calls both: 16 and 5. The two variables have the same type, and neither knows the type of the lambda inside.
Writing it once is the fastest way to understand the cost: one allocation per construction, one virtual call per use, and a copy operation that the model has to implement if the wrapper is copyable. The standard one adds a small-buffer optimisation for callables that fit.
#include <iostream>
#include <memory>
#include <utility>
class callable
{
public:
template <typename F>
explicit callable(F function)
: impl_{std::make_unique<model<F>>(std::move(function))} {}
int operator()(int value) const
{
return impl_->call(value);
}
private:
struct base
{
virtual ~base() = default;
virtual int call(int value) const = 0;
};
template <typename F>
struct model : base
{
explicit model(F function) : function_(std::move(function)) {}
int call(int value) const override
{
return function_(value);
}
F function_;
};
std::unique_ptr<base> impl_;
};
int main()
{
callable square{[](int value) { return value * value; }};
callable increment{[](int value) { return value + 1; }};
std::cout << square(4) << ' ' << increment(4) << '\n';
}
g++ -std=c++23 -Wall -Wextra hand.cpp -o hand
./hand
16 5
std::any
std::any is type erasure without a concept: it holds a value of any copyable type and remembers what it holds. any_cast<T> gives the value back when T is the stored type and throws std::bad_any_cast when it is not.
The program stores an int, reads it, stores a string, reads it, and then asks for an int again. The print is 42, text and the name of the exception.
The type is checked at run time, so an any is the tool for a property bag or a message payload whose type is decided by the data. It is not a replacement for a variant, which knows the set of types at compile time and is checked by the compiler.
#include <any>
#include <iostream>
#include <string>
int main()
{
std::any value = 42;
std::cout << std::any_cast<int>(value) << ' ';
value = std::string{"text"};
std::cout << std::any_cast<std::string>(value) << ' ';
try {
std::cout << std::any_cast<int>(value);
} catch (const std::bad_any_cast&) {
std::cout << "bad_any_cast";
}
std::cout << '\n';
}
g++ -std=c++23 -Wall -Wextra any.cpp -o any_demo
./any_demo
42 text bad_any_cast
The empty function
A default-constructed std::function holds nothing, and calling it throws std::bad_function_call. The wrapper has no target to dispatch to, so the library reports it at run time instead of doing something undefined.
The program builds an empty function and calls it inside a try block. The message is printed by the catch, and the exception is the one the standard defines for this case.
The fix is to check the function before the call, which is what the bool conversion is for, or to initialise it with a default callable. An empty std::function is a valid state and a common one in a callback that was never set.
#include <functional>
#include <iostream>
int main()
{
std::function<int(int)> function;
try {
std::cout << function(1) << '\n';
} catch (const std::bad_function_call&) {
std::cout << "empty function\n";
}
}
g++ -std=c++23 -Wall -Wextra empty.cpp -o empty_function
./empty_function
empty function
Exercises
- Add a third callable to the vector that captures a value and check that all three are called.
- Add a copy constructor to the hand-written wrapper and check that a copied wrapper still calls the same lambda.
-
Store a std::vector
in a std::any and read it back with any_cast.
Questions
A new set of questions in a new order every time. Choose one answer for each, then check.
-
What does type erasure give a program?
Answer
One type that holds many concrete types behind a common interface
The concrete type is hidden behind a virtual base or a stored value, and the caller sees one type.
-
What must a callable be to go into a
std::function?Answer
Copyable and callable with the function's signature
The wrapper may copy its target, and the signature is the type of the call.
-
What does
std::any_cast<int>do when the any holds a string?Answer
It throws std::bad_any_cast
The type is checked at run time, and a mismatch is an exception.
-
What does the std::function program print?
Answer
25
The pointer returns 10 and the lambda returns 15, and the loop adds both.
-
What does the hand-written wrapper print?
Answer
16 5
The square of 4 is 16 and the increment of 4 is 5.
-
What does the std::any program print?
Answer
42 text bad_any_cast
The two casts succeed and the third, on the string, throws and prints the name.
-
What does this program print?
#include <functional> #include <iostream> int main() { std::function<int(int)> function; try { std::cout << function(1); } catch (const std::bad_function_call&) { std::cout << "empty function"; } }Answer
empty function
The call throws std::bad_function_call, which the catch turns into the message.
-
What is the cost of type erasure?
Answer
An indirection, often a virtual call, and possibly an allocation
The wrapper stores the target somewhere and reaches it through a pointer or a virtual function.
-
How is
std::anydifferent fromstd::variant?Answer
any holds a type decided at run time, variant a set of types fixed at compile time
A variant knows its alternatives and can be visited; an any carries the type in the value.
-
How do you test whether a
std::functionholds a callable?Answer
With its conversion to bool
The bool conversion is true when a target is stored, and a false one is the state that would throw.
The code on this page was built with g++ 15.2.0 and run on Alpine Linux 3.24.3 (x86_64) in a Docker container.
Article author: Arthur Isaev