C++: пишем свою std::function

В этой статье я хочу погрузиться (и погрузить читателя) в дебри C++ на примере написания собственной std::function. Погружаться мы будем плавно и постепенно, наращивая сложность по мере продвижения. Я постараюсь объяснять все максимально просто и доходчиво, чтобы порог вхождения в статью был низким, а количество людей комфортно усвоивших материал - высоким.
Почему std::function? Потому что реализацияstd::function, внезапно, затрагивает большое количество продвинутых техник и нюансов языка, и все они достаточно любопытны для пытливого плюсовика.
Недавно на Хабре вышла статья @dalerank C++101 с огромным списком устоявшихся C++/-идиом, используемых в реальной боевой разработке. Я считаю, что она прекрасно дополняет эту статью, поскольку мы по ходу развития повествования увидим реальное внедрение многих из этих идиом - концентрированно, в одном конкретном, достаточно компактном классе. А при желании глубже ознакомиться с тем или иным паттерном, вы сможете перейти к статье Сергея за дополнительным ознакомлением с конкретной техникой.
Наивная реализация
Что такое std::function по своей сути? Это обертка над любым объектом, который можно вызвать, будь то лямбда, функтор или указатель на функцию. Ключевое слово - “любым”. Обертка должна абстрагировать нас от конкретного типа объекта. Тут нам приходит на помощь первый лайфхак из мира C++:
В C++ конкретный тип всегда можно спрятать за шаблоном. Например, мы могли бы обернуть наш объект вот так:
template<typename F>
class Function
{
public:
Function(F f)
: m_f(std::move(f))
{}
private:
F m_f;
};
Так можно обернуть вообще все что угодно. Другой вопрос - как потом пользоваться такой оберткой. Как только у нас возникает необходимость вызвать наш объект, мы понимаем, что нам не хватает выразительных средств для описания нужного нам метода:
??? operator()(???... args) {
return m_f(std::forward<???>(args)...);
}
И угадайте что? - мы можем добавить еще больше шаблонных аргументов, чтобы добиться задуманного:
template<typename F, typename Ret, typename ...Args>
class Function
{
public:
Function(F f)
: m_f(std::move(f))
{}
Ret operator()(Args... args) {
return m_f(std::forward<Args>(args)...);
}
private:
F m_f;
};
И вот уже в самом начале статьи мы имеем худо-бедно работающее решение. Да, этот код действительно работает:
auto discriminant = [](float a, float b, float c) {
return b * b - 4 * a * c;
};
Function<decltype(discriminant), float, float, float, float> f = discriminant;
auto res = f(1, 2, 3);
Но как видите, у такой реализации есть фатальные проблемы с описанием шаблонных аргументов - нам приходится расчехлять decltype и выражать тип первого аргумента через сам аргумент, что во-первых, громоздко, во-вторых, не всегда возможно.
А теперь вспомните, как удобно тип для такого же объекта описывается у std::function:
Function<float(float, float, float)> f;
Бросаются в глаза два отличия:
Тип оборачиваемого объекта - тот самый первый аргумент - вообще не используется
В описании аргументов каким-то образом появились магические скобочки и дали возможность описывать аргументы естественным для функции “сигнатурным” способом
Нам бы хотелось добиться того же.
Type erasure
Будем избавляться от typename F в шапке класса. В целом, от typename F можно не избавляться полностью, а попробовать переместить его в более укромное место. Хороший кандидат на убежище для typename F - это конструктор, потому что вызов такого конструктора позволит вывести F автоматически, что нам и нужно:
template<typename Ret, typename ...Args>
class Function
{
public:
template <typename F>
Function(F&& f)
: m_f(f)
{}
...
private:
??? m_f;
};
Как видим, заметание шаблонного аргумента под конструктор сужает его “область действия” - теперь он, внезапно, виден только в конструкторе, а за его рамками класс не знает о существовании F. Как теперь объявлять m_f, решительно не понятно. Отобрав у него шаблон, мы отобрали у него возможность сообщить компилятору о своем типе на этапе компиляции.
Тип m_f все еще должен быть абстрактным, обобщенным - ведь в этом и вся суть оборачивания объекта. Но с шаблонами мы зашли в тупик - нам нужен какой-то иной механизм. И в C++ он есть:
Если статический полиморфизм - то бишь шаблоны - уже не справляется, есть большая вероятность, что вам нужен динамический полиморфизм - то бишь полиморфный базовый класс с виртуальными функциями
Как нам переориентироваться с шаблонного типа на динамический рантайм-тип? С шаблонами было просто - компилятор верил программисту на слово, что с типом F позволено делать все, что код с ним делает. И если на стадии компиляции код с конкретным подставленным типом успешно компилировался, значит все хорошо.
С динамическими типами у нас нет такой роскоши, поэтому придется описать руками, что умеет тип. В нашем случае практически все, что требуется от объекта - это возможность его вызвать через какой-нибудь call, поэтому я вижу базовый класс примерно так:
struct FuncInterface
{
virtual Ret call(Args... args) const = 0;
virtual ~FuncInterface() {}
};
Прозорливый читатель может спросить - чем являются Ret и Args? Я планирую сделать FuncInterface внутренним классом нашего Function, поэтому цельная картина будет выглядеть так:
template <typename Ret, typename... Args>
class Function
{
...
private:
struct FuncInterface
{
virtual Ret call(Args... args) const = 0;
virtual ~FuncInterface() {}
};
...
std::unique_ptr<FuncInterface> m_f;
};
То есть Ret и Args остаются шаблонными аргументами. И заметьте, что мы теперь смогли разрешить тип m_f - это полиморфный рантайм-класс на куче. Вот такой вот симбиоз шаблонов и виртуальности. Но это только начало!
Следите за руками: базовый класс FuncInterface у нас есть. А кто от него будет наследоваться? И как создать инстанс такого объекта? Давайте начнем на ощупь, с конструктора:
template <typename Ret, typename... Args>
class Function
{
public:
template <typename F>
Function(F&& f) {
m_f = std::make_unique<FuncImpl<F>>(std::forward<F>(f));
}
...
};
Видите FuncImpl<F>? Очевидно, эта штука должна быть наследником FuncInterface, чтобы все пошло по нашей задумке. А еще это должен быть шаблонный класс, чтобы знать об F. Вот такой вот симбиоз шаблонов и виртуальности. Уфф.
Пробуем описать задуманное:
template <typename Ret, typename... Args>
class Function
{
public:
template <typename F>
Function(F&& f) {
m_f = std::make_unique<FuncImpl<F>>(std::forward<F>(f));
}
private:
struct FuncInterface
{
virtual Ret call(Args... args) const = 0;
virtual ~FuncInterface() {}
};
template <typename F>
struct FuncImpl final : public FuncInterface
{
F m_f;
FuncImpl(F f)
: m_f(std::move(f))
{}
Ret call(Args... args) const override {
return m_f(std::forward<Args>(args)...);
}
};
std::unique_ptr<FuncInterface> m_f;
};
Теперь, чтобы сделать вызов функтора, нам всего лишь нужно вызвать метод call у внутренней обертки:
template <typename Ret, typename... Args>
class Function
{
public:
...
Ret operator()(Args... args) {
return m_f->call(std::forward<Args>(args)...);
}
...
};
И это работает. Только что мы реализовали, идиому под названием Type Erasue, которая практически всегда используется в каноничных реализациях std::function и std::any, т.е. там, где конкретный тип надо стереть и обезличить.
Давайте еще раз рассмотрим получившийся код, чтобы осознать что в нем происходит, поскольку он совершенно нетривиальный с точки зрения типового C++ и может причинить головной дискомфорт впервые его увидевшему.
Основная идея заключается в том, что в погоне за желанием спрятать тип F мы сначала убрали его в конструктор, а потом передали эстафету дальше - во внутренний класс FuncImpl<F>, где он и осел. Внутри этого класса мы по-прежнему можем оперировать F как шаблоном. Но чтобы он не отсвечивал наружу, мы наследовали FuncImpl<F> от FuncInterface и на этом стыке обеспечили хитрую конвертацию из шаблонности в виртуальность. Виртуальностью мы светим наружу и притворяемся, будто шаблона и вовсе нет, хотя на самом деле он просто хитро спрятан. Вот такой вот симбиоз шаблонов и виртуальности.
Самое забавное в этой идиоме, что фактически весь написанный код - это просто boilerplate по борьбе с сутью языка C++, с его типизацией. К тому же, этот подход имеет существенные накладные расходы - мы знаем, что за виртуальность приходится платить косвенностью вызовов и наличием виртуальной таблицы. Что хуже, у нас теперь присутствует аллокация на куче. И все исключительно ради того, чтобы побороть типизацию языка. То есть это тот самый момент, когда ты платишь за то, чем не планировал пользоваться - тебя просто заставили.
Но у нас впереди еще долгий путь, и мы попытаемся нивелировать эти проблемы. А пока что я позволю себе парочку маленьких педантских поправок в коде.
Первое: шаблонный тип, переданный в конструктор, по-хорошему нужно очистить от всякой шелухи. Это могут быть ссылки, cv-qualifiers, косвенность - все, что угодно. Поэтому хорошим тоном является очистка типа через std::decay:
template <typename F>
Function(F&& f) {
using Fn = std::decay_t<F>;
m_f = std::make_unique<FuncImpl<Fn>>(std::forward<F>(f));
}
Второе: сразу подстелим соломку под желаемое const-correctness поведение. Мы хотим, чтобы наши Function::operator() и FuncImpl::call() были константными всегда, даже если у объекта внутри меняется состояние при его вызове. Почему? Потому что мутабельность внутреннего объекта - не забота Function. Проще и чище сделать Function константным всегда. Так поступают в частности и в std::function.
Помимо этого конструктор для FuncImpl стоит усовершенствовать и превратить в forwarding конструктор, чтобы он принимал в себя объект без лишних копирований или перемещений. Реализуем оба желания в коде разом:
template <typename F>
struct FuncImpl final : public FuncInterface
{
mutable F m_f;
template<typename U>
FuncImpl(U&& f)
: m_f(std::forward<U>(f))
{}
...
};
Для воплощения задуманного к m_f мы добавили mutable, а конструктор сделали шаблонным.
Третье: хотелось бы вернуться к проблеме сигнатурной записи. Пока у нас все еще существует отличие с std::function:
Function<float, float, float, float> f = discriminant;
...
std::function<float(float, float, float)> f = discriminant;
У этой штуки есть решение, и я не могу предложить ничего путного, кроме как запомнить его:
template<typename>
class Function;
template<typename Ret, typename ...Args>
class Function<Ret(Args...)>
{
...
}
“Что происходит?” - была у меня первая мысль, когда я это увидел. А происходит следующее. Сначала мы объявляем пустой общий случай для Function<>. Им никто и никогда не будет пользоваться. class Function<Ret(Args...)> же - это частичная специализация шаблона под function type. В языке предусмотрительно заложена возможность записать тип как Ret(Args...). Это любопытная запись, поскольку по-факту - это один аргумент, но составной и содержит в себе Ret и Args. Это будет единственная специализация для Function и теперь мы вынуждены пользоваться только ей:
Function<float(float, float, float)> f = discriminant;
А нам только это и было нужно.
In-place storage
Нередко std::function используется там, где в качестве параметров функции нужно передать произвольную логику. Во времена старинного C++ для такого могли использовать обычный указатель на функцию - каноничный коллбэк. Но в современном мире в качестве такого параметра мы зачастую хотим передавать какую-нибудь лямбду с захватом переменных, с контекстом. И практически никакой альтернативы кроме std::function у нас не остается.
А теперь представьте, что такая функция с std::function в качестве параметра становится частью горячего кода, а значит, вызывается огромное количество раз. Здесь мы неизбежно сталкиваемся с основной проблемой, которую нам подарил Type Erasure - аллокации на куче. Это всегда дорого, а под нагрузкой - это очень дорого.
Дорогими могут быть не только аллокации, но и, внезапно, деаллокации памяти.
Стандартный std::function решил эту проблему самой важной оптимизацией:
Small Object Optimization. Она же известна под аббревиатурами SOO, SSO, SBO. Я же впредь буду называть ее In-place storage, поскольку мне так больше нравится. И мы будем внедрять ее в наш
Function.Суть оптимизации красива: если объект, за который мы ответственны, достаточно мал, мы можем не выделять для него место на куче, а размещать его прямо в своем теле. Главное для этого зарезервировать некоторое место, и размер этого места определяется исходя из того, чего вам больше жалко - лишней памяти или количества аллокаций.
Без этой оптимизации наш Function сейчас имеет размер sizeof std::unique_ptr<>, то есть 8 байт. Обычно объект расширяют до 16 или 24 байт - кому сколько не жалко. Мы будем определяться с этим размером по ходу повествования.
Первое, что мешает реализовать данную оптимизацию - это std::unique_ptr<>, чей интерфейс сильно ограничен в возможностях создания и уничтожения объекта, поэтому мы сдеградируем и перейдем на обычный сырой указатель.
// было
std::unique_ptr<FuncInterface> m_f;
// стало
FuncInterface* m_f;
Теперь введем константы, описывающие характеристики нашего in-place:
static constexpr size_t INPLACE_SIZE = 8;
static constexpr size_t INPLACE_ALIGNMENT = alignof(std::max_align_t);
Как видите, пока что я решил применять in-place только для объектов до 8 байт. И это мало. Прям мало. Но скоро мы увидим, что такие стрессовые условия обернутся нам на пользу. Выравнивание же мы постарались сделать максимально демократичным, чтобы в наше хранилище могло уместиться почти что угодно.
Теперь у нас все готово, чтобы устроить биполярку нашему указателю:
union {
FuncInterface* m_heap;
alignas(INPLACE_ALIGNMENT) std::byte m_inplace[INPLACE_SIZE];
};
bool m_isInplace = false;
Старый дедовский union лучше новых двух - типовое решение для SSO-оптимизации. Заметим, что std::variantm_inplace - это просто байтовый массив, выровненный по INPLACE_ALIGNMENT. Он готов разместить в себе любой объект, соответствующий критериям. Без смс и аллокаций. Незамысловатые критерии есть смысл зафиксировать в отдельной constexpr-функции:
template<typename F>
static constexpr bool DoesFitInplace() {
return sizeof(F) <= INPLACE_SIZE && alignof(F) <= INPLACE_ALIGNMENT;
}
Этот метод и будет определять судьбу объекта еще на стадии создания Function: если тип объекта позволяет ему встроиться, он станет жить в m_inplace, иначе будет честно аллоцирован в m_heap. И код Function будет учитывать эту дуальность везде:
template<typename Ret, typename ...Args>
class Function<Ret(Args...)>
{
public:
template <typename F>
Function(F&& f) {
using Fn = std::decay_t<F>;
using WrapperT = FuncImpl<Fn>;
if constexpr (DoesFitInplace<WrapperT>()) {
new (m_inplace) WrapperT(std::forward<F>(f));
m_isInplace = true;
} else {
m_heap = new WrapperT(std::forward<F>(f));
m_isInplace = false;
}
}
~Function() {
FuncInterface* ptr = getPtr_();
if (m_isInplace) {
ptr->~FuncInterface();
} else {
delete ptr;
}
}
Ret operator()(Args... args) const {
return getPtr_()->call(std::forward<Args>(args)...);
}
private:
struct FuncInterface
{
virtual Ret call(Args... args) const = 0;
virtual ~FuncInterface() {}
};
template <typename F>
struct FuncImpl final : public FuncInterface
{
F m_f;
FuncImpl(F f)
: m_f(std::move(f))
{}
Ret call(Args... args) const override {
return m_f(std::forward<Args>(args)...);
}
};
static constexpr size_t INPLACE_SIZE = 8;
static constexpr size_t INPLACE_ALIGNMENT = alignof(std::max_align_t);
template<typename F>
static constexpr bool DoesFitInplace() {
return sizeof(F) <= INPLACE_SIZE && alignof(F) <= INPLACE_ALIGNMENT;
}
const FuncInterface* getPtr_() const {
if (m_isInplace) {
return std::launder(reinterpret_cast<const FuncInterface*>(m_inplace));
} else {
return m_heap;
}
}
FuncInterface* getPtr_() {
return const_cast<FuncInterface*>(
std::as_const(*this).getPtr_()
);
}
union {
FuncInterface* m_heap;
alignas(INPLACE_ALIGNMENT) std::byte m_inplace[INPLACE_SIZE];
};
bool m_isInplace = false;
};
Как видите, нам даже удалось сокрыть дуализм heap/in-place за методом _getPtr(). Явное обращение к m_isInplace осталось только в конструкторе и деструкторе.
Получившиеся конструктор, деструктор и
_getPtr()- это отличные живые примеры, на которых можно подтянуть уже вполне себе advanced нюансы языка, такие как:
Чем отличается expression new от operator new
Что такое placement new, какой у него синтаксис, и выделяет ли он память
В каких случаях и зачем нам может понадобиться вызывать деструктор вручную
Для чего нужен
std::launder, и зачем он нужен в нашем конкретном случаеВ скором времени я опубликую обширную статью про время жизни объектов в C++, которая покрывает все эти вопросы.
Inplace problem
После внедрения in-place storage в Function, я захотел вкусить плоды проделанной работы и увидеть своими глазами, как мелкие функторы будут размещаться в буфере вместо выделения на куче. Я вставил незамысловатый std::cout в конструктор:
template<typename F>
Function(F f)
{
...
if constexpr (DoesFitInplace<WrapperT>()) {
std::cout << "inplace!\n";
...
} else {
...
}
}
и запустил тесты Function на множестве разных объектов. Знаете, что я получил? Ни-че-го. Ноль восторженных записей “inplace!” в консоли. Это было странно, ведь условная лямбда
auto lambda = []() { std::cout << "YEAH"; };
занимает минимум места - моих, пусть и скромных, 8 байт на in-place должно было хватить.
Тогда я стал выводить размеры исходного объекта и хранимой обертки над ним:
template<typename F>
Function(F f)
{
using Fn = std::decay_t<F>;
using WrapperT = FuncImpl<Fn>;
std::cout << "callable size: " << sizeof(Fn) << "\n"
<< " wrapper size: " << sizeof(WrapperT) << "\n";
if constexpr (DoesFitInplace<WrapperT>()) {
std::cout << "inplace!\n";
...
} else {
...
}
}
Что я увидел:
callable size: 1
wrapper size: 16
callable size: 8
wrapper size: 16
callable size: 64
wrapper size: 72
callable size: 48
wrapper size: 56
callable size: 4
wrapper size: 16
callable size: 16
wrapper size: 24
FFFUUU!!1
Моя лямбда могла занимать хоть 1 байт, и все равно не влезала в буфер, потому что размер обертки всегда был минимум 16 байт!
В чем проблема, я понял сразу: FuncImpl<Fn> тащит с собой 8-байтовый указатель на виртуальную таблицу, который весь, целиком, один способен занять весь наш буфер. Memory layout у такой обертки будет примерно такой:
00 xxxxxxxx vtable
08 x....... lambda
Крестики - занятые байты, точки - padding для выравнивания в 8 байт.
Потрачено. Пока просто затаим злобу, увеличим размер in-place storage до 16 байт
static constexpr size_t INPLACE_SIZE = 16;
и пойдем дальше. Но предварительно проверим работоспособность:
callable size: 1
wrapper size: 16
inplace!
callable size: 8
wrapper size: 16
inplace!
callable size: 64
wrapper size: 72
callable size: 48
wrapper size: 56
callable size: 4
wrapper size: 16
inplace!
callable size: 16
wrapper size: 24
Работает.
Copy/move
Добавим в Function поддержку copy/move семантики.
Сначала подготовим почву. Во-первых,
Конструктор перемещения должен быть
noexcept- это база. Тот жеstd::vectorне всегда включит move-семантику, если его элементы могут кинуть исключение при перемещении
И в случае с размещением на куче все будет хорошо - мы просто перекинем пару указателей. Но если наш объект лежит в in-place storage, в процессе перемещения мы будем вызывать конструктор перемещения хранимого объекта, и теоретически он может кинуть исключение. Самым беспроблемным будет удостовериться, что помещаемые в in-place storage объекты умеют перемещаться без исключений:
template <typename F>
static constexpr bool DoesFitInplace() {
return sizeof(F) <= INPLACE_SIZE
&& alignof(F) <= INPLACE_ALIGNMENT
&& std::is_nothrow_move_constructible_v<F>;
}
Во-вторых, у класса намечается добавление новых конструкторов, а наш текущий, пока что единственный конструктор
template <typename F> Function(F&& f);
реализован так, что способен съесть все, что ему предложат и оставить другие конструкторы не у дел. Область его действия нужно как-то сузить. Будем исходить из того, что конструкторы копирования и перемещения работают только с типом Function<...>. Значит хитрым SFINAE можно изолировать наш всеядный конструктор от этого типа и позволить принимать в себя все остальное:
template <typename F, typename = std::enable_if_t<!std::is_same_v<std::decay_t<F>, Function>>>
Function(F&& f) {
Приступаем к реализации конструкторов и операторов присваивания.
Обычно для реализации exception-safe copy/move семантики удобно использовать идиому copy-and-swap. Но она выглядит элегантно и работает эффективно только если у класса легко реализуем
std::swap.
При наличии in-place storage мы отсекаем себя от такого варианта - когда вы представите, как будете переносить данные из in-place storage на кучу и наоборот, вам просто перехочется :)
Поэтому будем действовать по-другому: если аккуратно выполнять шаги по копированию или перемещению в правильном порядке, можно получить strong exception safety и без идиомы copy-and-swap. Наша цель - написать деструктор, конструкторы копирования и перемещения и операторы копирующего и перемещающего присваивания. Они практически всегда однообразно реализовываются на базе функций-кирпичиков: destroy_, copyFrom_, moveFrom_:
public:
~Function() noexcept {
destroy_();
}
Function(const Function& other) {
copyFrom_(other);
}
Function(Function&& other) noexcept {
moveFrom_(std::move(other));
}
Function& operator=(const Function& other) {
if (this == &other)
return *this;
Function tmp(other);
destroy_();
moveFrom_(std::move(tmp));
return *this;
}
Function& operator=(Function &&other) noexcept {
if (this == &other)
return *this;
destroy_();
moveFrom_(std::move(other));
return *this;
}
Оператор копирующего присваивания добился strong exception safety за счет локальной копии tmp. Если на стадии копирования в tmp произойдет исключение, оригинальный объект останется в нетронутом состоянии. Оператор перемещающего присваивания у нас выбросить исключение не может, т.к. мы позаботились об этом заранее.
Теперь функции-кирпичики. Все они будут обладать дуальностью куча-стек. Вот только, приступив к реализации, мы понимаем, что нам чего-то не хватает:
private:
void destroy_() noexcept {
FuncInterface* ptr = getPtr_();
if (m_isInplace) {
ptr->~FuncInterface();
} else {
if (ptr) {
delete ptr;
}
}
}
void copyFrom_(const Function& other) {
m_isInplace = other.m_isInplace;
if (m_isInplace) {
new (m_inplace) ???(other.???)
} else {
m_heap = new ???(other.???);
}
}
void moveFrom_(Function&& other) noexcept {
m_isInplace = other.m_isInplace;
if (m_isInplace) {
new (m_inplace) ???(std::move(other.???));
} else {
m_heap = other.m_heap;
other.m_heap = nullptr;
}
}
Нам не хватает знаний о исходном типе F, который у нас украли уже давно. Чудом этой участи избежал destroy_. Единственный способ обойти проблему - прописать в публичном интерфейсе все недостающие действия, чтобы мы могли реализовать их там, где нам доступен тип F - в теле класса FuncImpl.
Где у нас были ??? в коде, там и не хватает функции в FuncInterface. Всего таких места три:
Копирование объекта в in-place storage
Копирование объекта на кучу
Перемещение объекта в in-place-storage
Внимание, сейчас произойдет семантический сдвиг: если операторы перемещающего и копирующего присваивания совершали действие “мы заберем к себе чужое”, то наши нынешние функции изменят направление - они будут “отдавать свое в чужое”. Так происходит от того, что именно объект-источник с дуальностью куча-стек должен управиться с тем, как и откуда отдать свои данные, а объект-приемник лишь предоставляет место, куда надо положить данные.
Дополненный FuncInterface:
struct FuncInterface
{
virtual Ret call(Args... args) const = 0;
virtual FuncInterface* copyToHeap() const = 0;
virtual void copyToPlace(void* place) const = 0;
virtual void moveToPlace(void* place) = 0;
virtual ~FuncInterface() {}
};
Реализация этих методов в FuncImpl тривиальна:
template <typename F>
struct FuncImpl final : public FuncInterface
{
mutable F m_f;
template <typename U>
FuncImpl(U&& f)
: m_f(std::forward<U>(f))
{}
Ret call(Args... args) const override {
return m_f(std::forward<Args>(args)...);
}
FuncInterface* copyToHeap() const override {
return new FuncImpl(m_f);
}
void copyToPlace(void* place) const override {
new (place) FuncImpl(m_f);
}
void moveToPlace(void* place) override {
new (place) FuncImpl(std::move(m_f));
}
};
И теперь мы можем вернуться к “кирпичикам” и дописать copyFrom_ и moveFrom_
void copyFrom_(const Function& other) {
const FuncInterface* srcPtr = other.getPtr_();
m_isInplace = other.m_isInplace;
if (m_isInplace) {
srcPtr->copyToPlace(m_inplace);
} else {
m_heap = srcPtr->copyToHeap();
}
}
void moveFrom_(Function&& other) noexcept {
FuncInterface* srcPtr = other.getPtr_();
m_isInplace = other.m_isInplace;
if (m_isInplace) {
srcPtr->moveToPlace(m_inplace);
} else {
m_heap = other.m_heap;
other.m_heap = nullptr;
}
}
Готово - copy/move семантика поддерживается.
Проблемы vtable
Давайте вернемся к проблеме виртуальных таблиц. Напомню, что в каждом объекте структуры struct FuncImpl<F> : FuncInterface первые 8 байт занимаются виртуальной таблицей. Это лишние 8 байт, которые мертвым грузом лежат в in-place storage, и при этом даже не относятся к объекту, который мы храним - ведь это виртуальная таблица обертки. В итоге мы теряем существенное количество случаев, когда in-place оптимизация могла бы случиться, но vtable занял место.
Ничего с этим по понятным причинам мы сделать не можем - здесь сам C++ диктует нам правила игры. Все, что нам остается: СЛОМ ПАРАДИГМЫ.
Если вам не подходит стандартный механизм наследования и виртуальных таблиц в C++, вы всегда можете написать свой механизм. Даже в Си зачастую пишут свои реализации, чтобы сымитировать возможности C++.
Преимущество собственного механизма заключается в возможности управлять тем, где и как размещается виртуальная таблица; как она структурирована; какой у нее жизненный цикл и правила
Уфф. Насколько это звучит гибко, настолько же это обещает быть сложным. Но мы уже влезли по колено - что нам терять? К тому же это будет бесценный опыт. А так как я знаю конец статьи, скажу, что с этого решения мы поимеем большие бонусы помимо преследуемой в данный момент цели сэкономить на месте в in-place буфере.
В основе своей виртуальная таблица - очень простой концепт. Это объект с указателями на функции, никакой магии. И описать саму таблицу будет совсем не сложно. Мы просто берем наш FuncInterface:
struct FuncInterface
{
virtual Ret call(Args... args) const = 0;
virtual FuncInterface* copyToHeap() const = 0;
virtual void copyToPlace(void* place) const = 0;
virtual void moveToPlace(void* place) = 0;
virtual ~FuncInterface() {}
};
и переписываем каждую виртуальную функцию так, чтобы она стала классическим сишным указателем на функцию:
struct VTable
{
Ret (*call)(void*, Args... args);
void* (*copyToHeap)(const void*);
void (*copyToPlace)(const void*, void* place);
void (*moveToPlace)(void*, void* place);
void (*destroy)(void*, bool isInplace);
};
Заметьте, что теперь у нас не будет this, поэтому указатель на объект указывается явно первым параметром, а его тип мы спрятали за void*. Еще вместо деструктора мы ввели destroy и планируем поместить в него всю логику, которая раньше была в Function::destroy_() - мне показалось это уместным.
Нам удалось заменить FuncInterface на VTable - теперь настала пора для замены FuncImpl<F> чем-то новым. Наследования у нас теперь не будет. Как тогда быть? Как реализовать конкретные специализации функций и как заполнить ими виртуальную таблицу? А как потом составить и отдать нужную таблицу конкретному Function<>?Давайте рассуждать поступательно:
Нам все еще нужен
typename F- без этого типа type erasure развалитсяФункции под конкретный
Fмогут быть просто статическими функциями, которые лежат где-то - даже не так важно, гдеПод каждый
Fдолжна иметься виртуальная таблица, поля которой указывают на функции из предыдущего пунктаВ конструкторе
Function<F>(F&& f)таблица под конкретныйFдолжна быть найдена и сохранена классом для использования
Хорошо - если под конкретный F должны существовать статические функции и своя виртуальная таблица, имеет смысл положить их всех в тело шаблонной структуры:
template <typename F>
struct VTableFor
{
static Ret call(void* obj, Args... args) {
F& callable = *std::launder(static_cast<F*>(obj));
return callable(std::forward<Args>(args)...);
}
static void* copyToHeap(const void* obj) {
const F& callable = *std::launder(static_cast<const F*>(obj));
return new F(callable);
}
static void copyToPlace(const void* obj, void* place) {
const F& callable = *std::launder(static_cast<const F*>(obj));
new (place) F(callable);
}
static void moveToPlace(void* obj, void* place) {
F& callable = *std::launder(static_cast<F*>(obj));
new (place) F(std::move(callable));
}
static void destroy(void* obj, bool isInplace) {
F* callable = std::launder(static_cast<F*>(obj));
if (isInplace) {
callable->~F();
} else {
if (callable) {
delete callable;
}
}
}
static const VTable vtable;
};
Таблица сделана константной намеренно - теперь у нас нет выбора, кроме как где-то прописать ее корректную инициализацию указателями на статические функции:
template <typename Ret, typename... Args>
template <typename F>
/*static*/ const typename Function<Ret(Args...)>::VTable
Function<Ret(Args...)>::VTableFor<F>::vtable = {
&VTableFor<F>::call,
&VTableFor<F>::copyToHeap,
&VTableFor<F>::copyToPlace,
&VTableFor<F>::moveToPlace,
&VTableFor<F>::destroy
};
Запись выглядит жутко, но на деле вся сложность заключается в том, чтобы пробиться к идентификаторам VTable и vtable, помещенными глубоко за шаблонными дебрями.
Теперь то, ради чего это все затевалось: класс Function будет иметь собственную виртуальную таблицу, лежащую вне in-place storage, а хранимый объект будет единолично занимать все место на куче и/или стеке:
union {
void* m_heap;
alignas(INPLACE_ALIGNMENT) std::byte m_inplace[INPLACE_SIZE];
};
const VTable* m_vtable = nullptr;
bool m_isInplace = false;
То есть да - мы не сделали какой-то особой магии, мы не сэкономили 8 байт, они просто переместились в другое место. Но это было стратегически выгодное перемещение, поскольку in-place storage оптимизация теперь может вобрать в себя куда больше объектов при том же размере буфера.
У нас остался завершающий штрих по сетапу виртуальной таблицы в момент создания Function:
template <typename F, typename = std::enable_if_t<!std::is_same_v<std::decay_t<F>, Function>>>
Function(F&& f) {
using Fn = std::decay_t<F>;
if constexpr (DoesFitInplace<Fn>()) {
new (m_inplace) Fn(std::forward<F>(f));
m_isInplace = true;
} else {
m_heap = new Fn(std::forward<F>(f));
m_isInplace = false;
}
m_vtable = &VTableFor<Fn>::vtable;
}
И вот теперь все места, где раньше мы брали полиморфную объект-обертку, просто переписываем на использование vtable, практически не изменяя структуру кода:
Ret operator()(Args... args) const {
return m_vtable->call(getPtr_(), std::forward<Args>(args)...);
}
void destroy_() noexcept {
m_vtable->destroy(getPtr_(), m_isInplace);
}
void copyFrom_(const Function& other) {
const void* srcPtr = other.getPtr_();
m_isInplace = other.m_isInplace;
if (m_isInplace) {
other.m_vtable->copyToPlace(srcPtr, m_inplace);
} else {
m_heap = other.m_vtable->copyToHeap(srcPtr);
}
m_vtable = other.m_vtable;
}
void moveFrom_(Function&& other) noexcept {
void* srcPtr = other.getPtr_();
m_isInplace = other.m_isInplace;
if (m_isInplace) {
other.m_vtable->moveToPlace(srcPtr, m_inplace);
} else {
m_heap = other.m_heap;
other.m_heap = nullptr;
}
m_vtable = other.m_vtable;
}
Заметим лишь, что в copyFrom_ и moveFrom_ нам в том числе теперь нужно следить за перезаписью m_vtable, чтобы она не осталась от старого объекта.
Холодное/горячее
Какие действия чаще всего выполняют с std::function/Function? Конечно же, вызывают ее как обычную функцию. Это естественным образом доминирующее использование для такого рода классов - они буквально созданы, чтобы их вызывали.
Что происходит с CPU-кешем каждый раз, когда в очередной раз вызывается Function? Давайте проследим: operator() вызывает m_vtable->call(). Но чтобы добраться до call(), в кеш должно попасть содержимое виртуальной таблицы, целиком:
struct VTable
{
Ret (*call)(void*, Args... args);
void* (*copyToHeap)(const void*);
void (*copyToPlace)(const void*, void* place);
void (*moveToPlace)(void*, void* place);
void (*destroy)(void*, bool isInplace);
};
Все пять 8-байтовых указателей идут в кеш, каждый раз, когда вы пытаетесь вызвать функтор Function. Это 40 байт кеша на один Function. При этом все четыре оставшихся указателя с огромной вероятностью не будут использованы вовсе - операции копирования, перемещения и уничтожения крайне редки в сравнением с операцией call.
На hot-path оптимизация использования кеша выдвигается на одну из ведущих ролей. Любые лишние данные, попадающие в кеш занимают место, которое могли занять другие, более актуальные данные из памяти.
Если у вас есть часто используемые данные и редко используемые данные, их можно немного разнести, чтобы использование горячих данных не тащило за собой в кеш холодные.
Решение конкретно нашей проблемы поразительно простое - мы можем разбить виртуальную таблицу на две: горячую и холодную:
struct HotVTable
{
Ret (*call)(void*, Args... args);
};
struct ColdVTable
{
void* (*copyToHeap)(const void*);
void (*copyToPlace)(const void*, void* place);
void (*moveToPlace)(void*, void* place);
void (*destroy)(void*, bool isInplace);
};
Теперь в классе Function будет две таблицы вместо одной:
-const VTable* m_vtable = nullptr;
+const HotVTable* m_hotVtable = nullptr;
+const ColdVTable* m_coldVtable = nullptr;
Оператор вызова идет по “выделенному” горячему каналу:
Ret operator()(Args... args) const {
return m_hotVtable->call(getPtr_(), std::forward<Args>(args)...);
}
В кеш приходит только один указатель. А служебные функции лежат в холодной таблице до востребования.
Но можно еще эффективнее. Посмотрите эти два места в коде:
const HotVTable* m_hotVtable = nullptr;
...
struct HotVTable
{
Ret (*call)(void*, Args... args);
};
Это натурально цепочка “указатель за указателем”. И так как указатель в таблице всего один, эта косвенность лишняя, и ее можно упразднить, убрав понятие HotVTable вовсе и оставив голый указатель:
Ret(*m_call)(void*, Args... args) = nullptr;
const ColdVTable* m_coldVtable = nullptr;
Изменяем Function::operator():
Ret operator()(Args... args) const {
return m_call(getPtr_(), std::forward<Args>(args)...);
}
Крайне приятные оптимизации. А самое крутое, что они стали возможны только благодаря отказу от стандартных виртуальных таблиц C++.
FunctionBuffer
После всех метаморфоз и преобразований данные класса Function выглядят так:
union {
void* m_heap;
alignas(INPLACE_ALIGNMENT) std::byte m_inplace[INPLACE_SIZE];
};
Ret(*m_call)(void*, Args... args) = nullptr;
const ColdVTable* m_coldVtable = nullptr;
bool m_isInplace = false;
Посмотрите на bool m_isInPlace - он стоит у меня костью в горле, и на это есть две причины.
Во-первых, это поле занимает не один байт, как это должно было быть (в идеале он должен занимать один бит, но увы - мы живем в мире, где это не так), а целых восемь байт из-за особенностей memory layout нашего класса Function<F>:
00 xxxxxxxx union
08 xxxxxxxx union
16 xxxxxxxx m_call
24 xxxxxxxx m_coldVtable
32 x....... m_isInPlace
Как видите m_isInPlace - это одинокий bool среди стройного ряда 8-битных указателей, и увы - он не может адекватно притиснуться в нашу память, не съев дополнительные 7 байт padding’а.
Когда INPLACE_SIZE был 8 байт, класс имел размер 24 байта. Сейчас, когда я увеличил INPLACE_SIZE до 16 байт, класс разросся до 40 байт.
Размер кеш-линии - 64 байта на современных архитектурах. Если сомневаетесь, у C++ есть константы std::hardware_destructive_interference_size и std::hardware_constructive_interference_size, которые вернут точные цифры для вашей платформы.
Используйте
std::hardware_destructive_interference_size, если вам нужен размер, на минимального расстоянии которого нужно разнести два объекта, чтобы предотвратить false sharing. Используйтеstd::hardware_constructive_interference_size, если вам нужен размер, в рамках которого должны находиться объекты, чтобы получить true sharing. Обычно обе константы равны друг другу.
Ни один из наших размеров - ни 24, ни 40 - не ложится хорошо в кеш-линию. Если мы могли бы убрать m_isInPlace, мы бы обрели размер в 32 байта - ровно 2 объекта на 64-битную кеш-линию. Это тот идеал, к которому наш класс может стремиться.
Во-вторых, я догадываюсь, что чисто теоретически флаг m_isInPlace можно уметь вычислять на стадии компиляции всегда. Мы даже делаем это в одном конкретном месте уже сейчас:
template <typename F>
Function(F&& f) {
using Fn = std::decay_t<F>;
using WrapperT = FuncImpl<Fn>;
if constexpr (DoesFitInplace<WrapperT>()) {
new (m_inplace) WrapperT(std::forward<F>(f));
m_isInplace = true;
} else {
m_heap = new WrapperT(std::forward<F>(f));
m_isInplace = false;
}
}
Видите? - if constexpr (DoesFitInplace<WrapperT>()). Я убежден, что этот подход можно применить на все остальные случаи, где сейчас у нас в рантайме проверяется флаг m_isInPlace. А это означает, что устранив этот флаг, мы не только станем эффективно ложиться в память, но еще сэкономим на вычислениях: уйдут ветвления, что должно очень понравиться нашему CPU.
if constexpr- для CPU уже не ветвление! Код будет сгенерирован только для одной из веток исполнения.
Как нам провернуть задуманное? Посмотрим на нынешний вид виртуальной таблицы:
struct ColdVTable
{
void* (*copyToHeap)(const void*);
void (*copyToPlace)(const void*, void* place);
void (*moveToPlace)(void*, void* place);
void (*destroy)(void*, bool isInplace);
};
Первое, что видно - интерфейс четко разграничивает дуальность стек/куча и таким образом снимает с себя ответственность за проверку и управление этим аспектом. Все ложится на вызывающий код:
void copyFrom_(const Function& other) {
const void* srcPtr = other.getPtr_();
m_isInplace = other.m_isInplace;
if (m_isInplace) {
other.m_vtable->copyToPlace(srcPtr, m_inplace);
} else {
m_heap = other.m_vtable->copyToHeap(srcPtr);
}
m_vtable = other.m_vtable;
}
Особняком стоит функция destroy, которая намекает нам, как могло бы выглядеть решение:
template <typename F>
struct VTableFor
{
...
static void destroy(void* obj, bool isInplace) {
F* callable = std::launder(static_cast<F*>(obj));
if (isInplace) {
callable->~F();
} else {
if (callable) {
delete callable;
}
}
}
...
}
Но опять же мы передаем здесь флаг isInplace в рантайме, так что это лишь намек на решение, но не само решение.
Теперь заметим, что в качестве объекта, над которым виртуальная таблица совершает манипуляции, мы используем void*, и это - не что иное, как сам наш оригинальный callable, поэтому в виртуальной таблице мы просто кастимся к нему и используем его по назначению:
static Ret call(void* obj, Args... args) {
F& callable = *std::launder(static_cast<F*>(obj));
return callable(std::forward<Args>(args)...);
}
Происхождение указателя void* нам на этом моменте неизвестно - он может указывать как на кучу, так и на inplace storage внутри нашего Function. Это и есть основная проблема.
Решить ее можно, немного сдвинув точку обзора - что если виртуальная таблица будет работать не с void*, а с тем самым union, который лежит в Function? Тогда мы бы владели всей необходимой нам информацией, посудите сами:
Виртуальная таблица имеет доступ и к куче, и к inplace storage одновременно
Через
if constexpr (DoesFitInplace<F>())таблица всегда знает, в какое из этих двух мест надо лезть за объектом, поскольку это по существуconstexpr-информация и зависит от характеристикF: его размера и выравниванияВ итоге таблица сама сможет совершить все операции без явного рантайм-флага
m_isInPlace
И что приятно - теперь, когда виртуальная таблица написана полностью нами, мы можем делать с ней действительно все что угодно, в том числе провернуть предложенный хак. При подходе со стандартным наследованием и нативными плюсовыми vtable, мы ничего такого сделать не можем: виртуальная таблица живет внутри объекта, объект уже создан. И казалось бы - все тоже самое - мы можем сделать if constexpr (DoesFitInplace<F>()) и вычислить, где нас создали, но на руках у нас есть только this, с которым мы не можем сделать ничего фривольного.
Воплощаем в жизнь задуманное. Сначала дадим имя нашему union, чтобы на него можно было ссылаться:
union FunctionBuffer
{
void* heap;
alignas(INPLACE_ALIGNMENT) std::byte inplace[INPLACE_SIZE];
};
Сразу реализуем удобный способ взять верный указатель, если мы знаем тип F: напишем вспомогательный шаблонный метод getPtr():
union FunctionBuffer
{
void* heap;
alignas(INPLACE_ALIGNMENT) std::byte inplace[INPLACE_SIZE];
template <typename F>
F* getPtr() const {
if constexpr (DoesFitInplace<F>()) {
return std::launder(
reinterpret_cast<F*>(const_cast<std::byte*>(inplace))
);
} else {
return static_cast<F*>(heap);
}
}
template <typename F>
F* getPtr() {
return const_cast<F*>(
std::as_const(*this).getPtr<F>()
);
}
};
Виртуальная таблица начинает смотреть на FunctionBuffer и приобретает весьма приятный интерфейс:
struct ColdVTable
{
void (*copyTo)(const FunctionBuffer& from, FunctionBuffer& to);
void (*moveTo)(FunctionBuffer& from, FunctionBuffer& to);
void (*destroy)(FunctionBuffer&);
};
Внешний класс Function теперь имеет следующие поля:
FunctionBuffer m_buffer;
Ret(*m_call)(const FunctionBuffer&, Args... args) = nullptr;
const ColdVTable* m_coldVtable = nullptr;
Во-первых заметьте, как m_call под шумок стал работать с FunctionBuffer. Во-вторых посмотрите: мы больше не храним булеву, и теперь мы изящно легли в 32 байта:
00 xxxxxxxx union
08 xxxxxxxx union
16 xxxxxxxx m_call
24 xxxxxxxx m_coldVtable
Теперь я крайне доволен разметкой памяти для Function.
Осталось всего ничего - переписать реализацию виртуальной таблицы. На самом деле это совсем нетрудно:
template <typename F>
struct ColdVTableFor
{
static void copyTo(const FunctionBuffer& from, FunctionBuffer& to) {
const F& srcCallable = *from.getPtr<F>();
if constexpr (DoesFitInplace<F>()) {
new (to.inplace) F(srcCallable);
} else {
to.heap = new F(srcCallable);
}
}
static void moveTo(FunctionBuffer& from, FunctionBuffer& to) {
const F& srcCallable = *from.getPtr<F>();
if constexpr (DoesFitInplace<F>()) {
new (to.inplace) F(std::move(srcCallable));
} else {
to.heap = from.heap;
from.heap = nullptr;
}
}
static void destroy(FunctionBuffer& obj) {
const F* callable = obj.getPtr<F>();
if constexpr (DoesFitInplace<F>()) {
callable->~F();
} else {
if (callable) {
delete callable;
}
}
}
static const ColdVTable vtable;
};
А наши приватные destroy_, copyFrom_, moveFrom_ стали фактическими односрочниками:
void destroy_() noexcept {
m_coldVtable->destroy(m_buffer);
}
void copyFrom_(const Function& other) {
other.m_coldVtable->copyTo(other.m_buffer, m_buffer);
m_call = other.m_call;
m_coldVtable = other.m_coldVtable;
}
void moveFrom_(Function&& other) noexcept {
other.m_coldVtable->moveTo(other.m_buffer, m_buffer);
m_call = other.m_call;
m_coldVtable = other.m_coldVtable;
}
Все правки получились очень простыми и органичными и как-будто сами собой напрашивались.
Итог
Мы плавно пришли к итоговой реализации класса Function. Я не думаю, что хочу оптимизировать класс дальше, поскольку на его текущей стадии мне он кажется вполне убедительным.
Несмотря на достаточно длинную статью и большое количество объяснений код оказался вполне компактным:
template <typename>
class Function;
template <typename Ret, typename... Args>
class Function<Ret(Args...)>
{
public:
template <typename F, typename = std::enable_if_t<!std::is_same_v<std::decay_t<F>, Function>>>
Function(F&& f) {
using Fn = std::decay_t<F>;
if constexpr (DoesFitInplace<Fn>()) {
new (m_buffer.inplace) Fn(std::forward<F>(f));
} else {
m_buffer.heap = new Fn(std::forward<F>(f));
}
m_call = &HotVTableFor<Fn>::call;
m_coldVtable = &ColdVTableFor<Fn>::vtable;
}
~Function() noexcept {
destroy_();
}
Function(const Function& other) {
copyFrom_(other);
}
Function(Function&& other) noexcept {
moveFrom_(std::move(other));
}
Function& operator=(const Function& other) {
if (this == &other)
return *this;
Function tmp(other);
destroy_();
moveFrom_(std::move(tmp));
return *this;
}
Function& operator=(Function &&other) noexcept {
if (this == &other)
return *this;
destroy_();
moveFrom_(std::move(other));
return *this;
}
Ret operator()(Args... args) const {
return m_call(m_buffer, std::forward<Args>(args)...);
}
private:
static constexpr size_t INPLACE_SIZE = 16;
static constexpr size_t INPLACE_ALIGNMENT = alignof(std::max_align_t);
template <typename F>
static constexpr bool DoesFitInplace() {
return sizeof(F) <= INPLACE_SIZE
&& alignof(F) <= INPLACE_ALIGNMENT
&& std::is_nothrow_move_constructible_v<F>;
}
union FunctionBuffer
{
void* heap;
alignas(INPLACE_ALIGNMENT) std::byte inplace[INPLACE_SIZE];
template <typename F>
F* getPtr() const {
if constexpr (DoesFitInplace<F>()) {
return std::launder(
reinterpret_cast<F*>(const_cast<std::byte*>(inplace))
);
} else {
return static_cast<F*>(heap);
}
}
template <typename F>
F* getPtr() {
return const_cast<F*>(
std::as_const(*this).getPtr<F>()
);
}
};
struct ColdVTable
{
void (*copyTo)(const FunctionBuffer& from, FunctionBuffer& to);
void (*moveTo)(FunctionBuffer& from, FunctionBuffer& to);
void (*destroy)(FunctionBuffer&);
};
template <typename F>
struct HotVTableFor
{
static Ret call(const FunctionBuffer& obj, Args... args) {
F& callable = *obj.getPtr<F>();
return callable(std::forward<Args>(args)...);
}
};
template <typename F>
struct ColdVTableFor
{
static void copyTo(const FunctionBuffer& from, FunctionBuffer& to) {
const F& srcCallable = *from.getPtr<F>();
if constexpr (DoesFitInplace<F>()) {
new (to.inplace) F(srcCallable);
} else {
to.heap = new F(srcCallable);
}
}
static void moveTo(FunctionBuffer& from, FunctionBuffer& to) {
const F& srcCallable = *from.getPtr<F>();
if constexpr (DoesFitInplace<F>()) {
new (to.inplace) F(std::move(srcCallable));
} else {
to.heap = from.heap;
from.heap = nullptr;
}
}
static void destroy(FunctionBuffer& obj) {
const F* callable = obj.getPtr<F>();
if constexpr (DoesFitInplace<F>()) {
callable->~F();
} else {
if (callable) {
delete callable;
}
}
}
static const ColdVTable vtable;
};
void destroy_() noexcept {
m_coldVtable->destroy(m_buffer);
}
void copyFrom_(const Function& other) {
other.m_coldVtable->copyTo(other.m_buffer, m_buffer);
m_call = other.m_call;
m_coldVtable = other.m_coldVtable;
}
void moveFrom_(Function&& other) noexcept {
other.m_coldVtable->moveTo(other.m_buffer, m_buffer);
m_call = other.m_call;
m_coldVtable = other.m_coldVtable;
}
FunctionBuffer m_buffer;
Ret(*m_call)(const FunctionBuffer&, Args... args) = nullptr;
const ColdVTable* m_coldVtable = nullptr;
};
template <typename Ret, typename... Args>
template <typename F>
/*static*/ const typename Function<Ret(Args...)>::ColdVTable
Function<Ret(Args...)>::ColdVTableFor<F>::vtable = {
&ColdVTableFor<F>::copyTo,
&ColdVTableFor<F>::moveTo,
&ColdVTableFor<F>::destroy
};
Щутка
namespace std {
template<typename Signature>
using funktion = ::Function<Signature>;
} // namespace std
Теперь мы привели наш класс к форме, представленной в КДПВ: пользуйтесь std::funktion на здоровье.
Спойлер
На самом деле класть в namespace std ничего нельзя - формально вы получаете UB.
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.