Что решает стирание типов

Шаблоны дают типобезопасность и нулевые накладные расходы — но дублируют код для каждого типа и не могут храниться в гетерогенных контейнерах.

Виртуальный вызов даёт полиморфизм времени выполнения — но требует наследования от базового класса, которого у вас может не быть.

Стирание типов даёт оба: хранить объекты любого типа за одним интерфейсом, не требуя от хранимых типов ни от чего наследоваться.

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: утиная типизация времени компиляции без накладных расходов на стирание типов

Что дальше