The Jerusalem PostNova site restricted to memorial events, not celebrations, KKL-JNF says ahead of third anniversaryPunchUS establishes office of religious affairsBollywood HungamaBigg Boss 20: Rhiti Tiwari evicted in surprise mid-week exit after captaincy task? Here’s what we know!Daily MaverickWhen VW sneezes, Nelson Mandela Bay catches a coldBBC BusinessTravelodge failed sex assault victim 'at every stage'الشرقاكتشاف آلية تمهد لتطوير علاج يعتمد على الفيروسات لمكافحة البكتيرياObservador DesportoModelo de IA supera os melhores de jogo de estratégiaDeadlineBAFTA Makes Plans For ‘I Swear’s John Davidson To Attend Scotland Awards After N-Word ScandalLa PresseSénat | Richard Martel n’a pas choisi d’affiliation, mais dit conserver ses valeursAntara NewsNew FM Arrmanatha Nasir vows to continue Prabowo's foreign policyynetבעלי הבית היקר ביותר באוסטרליה: בן של שורד אושוויץ ואשתו היו בטיסת האימהRMF24Kolejny kraj wejdzie do strefy euro? 80 proc. obywateli jest za
The Daily Newsstand · Free, Always
Thursday, October 1, 2026

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

Translate

В этой статье я хочу погрузиться (и погрузить читателя) в дебри 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++, которая покрывает все эти вопросы.

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.

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

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.