Что решает стирание типов
Шаблоны дают типобезопасность и нулевые накладные расходы — но дублируют код для каждого типа и не могут храниться в гетерогенных контейнерах.
Виртуальный вызов даёт полиморфизм времени выполнения — но требует наследования от базового класса, которого у вас может не быть.
Стирание типов даёт оба: хранить объекты любого типа за одним интерфейсом, не требуя от хранимых типов ни от чего наследоваться.
std::function, std::any и std::shared_ptr<void> — всё это стирание типов.
Внутренности std::function
std::function<void(int)> хранит любой callable, принимающий int и возвращающий
void — лямбду, указатель на функцию, функтор, результат std::bind:
1std::function<void(int)> f;
2
3f = [](int x) { printf("%d\n", x); }; // лямбда
4f = &freeFunction; // указатель на функцию
5f = Functor{42}; // функтор с состоянием
6f = std::bind(&MyClass::method, obj, std::placeholders::_1);
Внутри std::function использует стирание типов:
1// Упрощённая реализация std::function
2template <typename Signature>
3class function;
4
5template <typename R, typename... Args>
6class function<R(Args...)> {
7 // Малый буфер — избегает кучи для маленьких callable
8 alignas(void*) char storage_[3 * sizeof(void*)];
9 bool heapAllocated_ = false;
10
11 // Стёртая vtable
12 struct VTable {
13 R (*invoke)(void* obj, Args&&... args);
14 void (*destroy)(void* obj);
15 void (*copy)(void* dst, const void* src);
16 };
17 const VTable* vtable_ = nullptr;
18
19public:
20 template <typename F>
21 function(F&& f) {
22 using Decay = std::decay_t<F>;
23 // Генерируем vtable для этого конкретного типа F
24 static const VTable vt = {
25 // invoke: приводим storage обратно к F, вызываем
26 [](void* obj, Args&&... args) -> R {
27 return (*static_cast<Decay*>(obj))(std::forward<Args>(args)...);
28 },
29 // destroy
30 [](void* obj) { static_cast<Decay*>(obj)->~Decay(); },
31 // copy
32 [](void* dst, const void* src) {
33 new (dst) Decay(*static_cast<const Decay*>(src));
34 }
35 };
36 vtable_ = &vt;
37
38 if constexpr (sizeof(Decay) <= sizeof(storage_) && alignof(Decay) <= alignof(void*)) {
39 new (storage_) Decay(std::forward<F>(f)); // оптимизация малого буфера
40 } else {
41 // выделение в куче для больших callable
42 }
43 }
44
45 R operator()(Args&&... args) {
46 return vtable_->invoke(storage_, std::forward<Args>(args)...);
47 }
48};
Ключевое понимание: лямбды vtable захватывают тип F при создании. После этого весь
доступ идёт через void* — тип стёрт, но правильные операции по-прежнему вызываются
через vtable.
Это паттерн, лежащий в основе всего стирания типов в C++.
Оптимизация малого буфера
Реализации std::function обычно имеют небольшой встроенный буфер (24–32 байта)
для избежания выделения в куче для маленьких callable (лямбды без состояния, указатели
на функции, малые функторы). Большая лямбда, захватывающая много переменных, выходит в кучу.
1// Без выделения в куче — помещается в малый буфер
2auto f1 = std::function<void()>{[] { return 42; }};
3
4// Выделение в куче — захватывает 5 int
5int a, b, c, d, e;
6auto f2 = std::function<void()>{[a, b, c, d, e] { return a + b + c + d + e; }};
Для встраиваемых систем или реального времени выделение в куче в callback проблематично. Самодельный вариант ниже управляет этим.
Самодельное стирание типов с фиксированным хранилищем
Типобезопасный callable с фиксированным встроенным буфером — без кучи, безопасен для ISR:
1template <typename Signature, size_t StorageSize = 3 * sizeof(void*)>
2class FixedFunction;
3
4template <typename R, typename... Args, size_t StorageSize>
5class FixedFunction<R(Args...), StorageSize> {
6 using InvokeFn = R(*)(void*, Args&&...);
7 using DestroyFn = void(*)(void*);
8
9 alignas(std::max_align_t) char storage_[StorageSize];
10 InvokeFn invoke_ = nullptr;
11 DestroyFn destroy_ = nullptr;
12
13public:
14 FixedFunction() = default;
15
16 template <typename F>
17 FixedFunction(F&& f) {
18 using Decay = std::decay_t<F>;
19 static_assert(sizeof(Decay) <= StorageSize,
20 "Callable слишком велик для фиксированного буфера — увеличьте StorageSize");
21 static_assert(std::is_trivially_destructible_v<Decay> ||
22 std::is_nothrow_destructible_v<Decay>);
23
24 new (storage_) Decay(std::forward<F>(f));
25
26 invoke_ = [](void* s, Args&&... a) -> R {
27 return (*static_cast<Decay*>(s))(std::forward<Args>(a)...);
28 };
29 destroy_ = [](void* s) { static_cast<Decay*>(s)->~Decay(); };
30 }
31
32 ~FixedFunction() { if (destroy_) destroy_(storage_); }
33
34 // Некопируемый (потребовал бы vtable копирования)
35 FixedFunction(const FixedFunction&) = delete;
36 FixedFunction& operator=(const FixedFunction&) = delete;
37
38 FixedFunction(FixedFunction&&) = default;
39
40 R operator()(Args&&... args) {
41 return invoke_(storage_, std::forward<Args>(args)...);
42 }
43
44 explicit operator bool() const { return invoke_ != nullptr; }
45};
46
47// Использование — без кучи, без исключений, проверка размера при компиляции
48FixedFunction<void(float)> handler;
49handler = [](float v) { display.update(v); };
50handler(23.5f);
Если попытаться сохранить слишком большую лямбду:
1// Ошибка компиляции — 8 float = 32 байта, не помещается в буфер по умолчанию 24 байта
2float a[8];
3FixedFunction<void()> f = [a]() { /* использовать a */ };
4// ошибка: Callable слишком велик для фиксированного буфера
При необходимости увеличьте StorageSize в месте использования.
std::any — стирание типов для хранения, не вызова
std::any (C++17) хранит значение любого копируемого типа. В отличие от std::function,
он не специфичен для callable — стирает любой тип.
1#include <any>
2
3std::any value;
4value = 42; // int
5value = 3.14f; // float
6value = std::string("hello");
7
8// Извлечение — бросает std::bad_any_cast при неправильном типе
9int i = std::any_cast<int>(value);
10std::string* s = std::any_cast<std::string>(&value); // возвращает nullptr при неправильном типе
Используйте std::any, когда нужен гетерогенный контейнер и типы не известны при
компиляции — конфигурации плагинов, сериализованные настройки:
1std::map<std::string, std::any> config;
2config["baud"] = 115200;
3config["device"] = std::string("/dev/ttyS0");
4config["timeout"] = 5.0f;
5
6int baud = std::any_cast<int>(config["baud"]);
Стоимость: std::any выделяет большие значения в куче (с оптимизацией малого буфера
для маленьких тривиальных типов). Непригоден для встраиваемых систем без кучи.
Утиное стирание типов с концептами (C++20)
Концепты C++20 позволяют определить, какие операции тип должен поддерживать, без обязательного наследования:
1template <typename T>
2concept Sensor = requires(T s) {
3 { s.read() } -> std::convertible_to<float>;
4 { s.isReady() } -> std::convertible_to<bool>;
5};
6
7// Работает с любым типом, удовлетворяющим Sensor — без наследования
8template <Sensor S>
9class Pipeline {
10 S& sensor_;
11public:
12 explicit Pipeline(S& s) : sensor_(s) {}
13 float measure() {
14 if (!sensor_.isReady()) return NAN;
15 return sensor_.read();
16 }
17};
18
19struct AnalogSensor {
20 float read() { return adcToVoltage(HAL_ADC_GetValue(hadc)); }
21 bool isReady() { return true; }
22};
23
24AnalogSensor s;
25Pipeline p(s); // CTAD — выводит Pipeline<AnalogSensor>
Никакого базового класса ISensor. Никакой vtable. Концепт — контракт времени компиляции.
Выбор правильного механизма
| Механизм | Полиморфизм в рантайме | Куча | Нужно наследование | Применение |
|---|---|---|---|---|
std::function |
Да | Иногда | Нет | Callback-и, обработчики событий |
FixedFunction |
Да | Нет | Нет | Встраиваемые callback-и, безопасные для ISR |
std::any |
Да (через cast) | Иногда | Нет | Карты конфигов, значения плагинов |
| Виртуальное наследование | Да | На усмотрение пользователя | Да | Классическая иерархия ООП |
| CRTP | Нет | Нет | Да (CRTP-база) | Нулевая стоимость, время компиляции |
| Концепты (C++20) | Нет | Нет | Нет | Ограниченные шаблоны |
Итоги
- Стирание типов: хранит любой тип за одним интерфейсом без обязательного наследования
std::functionиспользует внутреннюю vtable + оптимизацию малого буфера — может выделять в куче- Самодельный
FixedFunction: фиксированный встроенный буфер, проверка размера при компиляции, без кучи std::any: стирает любой копируемый тип, не только callable- Концепты C++20: утиная типизация времени компиляции без накладных расходов на стирание типов
Что дальше
- CRTP — статический полиморфизм и примеси — альтернатива с нулевой стоимостью для полиморфизма времени компиляции
- Паттерн «стратегия» — стратегии времени выполнения через стирание типов или шаблоны
- Паттерн «наблюдатель» — списки наблюдателей фиксированного размера с использованием стирания типов