Вход на сайт

Просмотр новости

Найдите то, что Вас интересует

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

Дата публикации: 01-10-2026 07:47:25

В этой статье я хочу погрузиться (и погрузить читателя) в дебри C++ на примере написания собственной std::function.Почему std::function? Потому что реализация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 лучше новых двух std::variant - типовое решение для SSO-оптимизации. Заметим, что m_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++. Главное, вернитесь оттуда живыми ;) В главе Provides Storage вы сможете увидеть схожий c нашим getPtr_() пример с std::launder.

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.

Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку

Схожие новости

#Наименование новостиТональностьИнформативностьДата публикации
1Терпение. Или как один зависший кадр заставил меня построить ещё полсистемы;‑)010.4123-09-2026
2range-for перестал ронять программу на временном объекте, зато теперь дольше держит мьютекс010.3530-09-2026
3Rust на вырост. Почему мы изучаем язык, который не входит в наш основной стек-17.2410-09-2026
4C++29 — начало. Встреча ISO C++ в Брно07.9917-08-2026
5[Перевод] Rewrite It in Rust: когда переписывание действительно оправдано07.9919-09-2026
6Кросс-компиляция под андроид и не только010.0723-09-2026
7«Лучшие» практики Rust, которые вас подведут. Часть 207.9817-09-2026
8Чудеса nightly, часть 1: на чём тайком держится stable Rust08.5529-09-2026
9Продвинутый статический анализ в TypeScript-проектах: выходим за рамки tsc и ESLint014.7501-10-2026

Классификация: Мнения. Схожих патентов: 0. Схожих новостей: 9. Тональность: 0. Информативность: 7.06. Источник: habr.com.