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

  1. Add a third callable to the vector that captures a value and check that all three are called.
  2. Add a copy constructor to the hand-written wrapper and check that a copied wrapper still calls the same lambda.
  3. 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.

  1. 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.

  2. 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.

  3. 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.

  4. What does the std::function program print?

    Answer

    25

    The pointer returns 10 and the lambda returns 15, and the loop adds both.

  5. What does the hand-written wrapper print?

    Answer

    16 5

    The square of 4 is 16 and the increment of 4 is 5.

  6. 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.

  7. 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.

  8. 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.

  9. How is std::any different from std::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.

  10. How do you test whether a std::function holds 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