Вступление
В этой статье я хочу погрузиться (и погрузить читателя) в дебри C++ на примере написания собственной std::function. Погружаться мы будем плавно и постепенно, наращивая сложность по мере продвижения. Я постараюсь объяснять все максимально просто и доходчиво, чтобы порог вхождения в статью был низким, а количество людей комфортно усвоивших материал - высоким.
Почему std::function? Потому что реализацияstd::function, внезапно, затрагивает большое количество продвинутых техник и нюансов языка, и все они достаточно любопытны для пытливого плюсовика.
Недавно на Хабре вышла статья @dalerank C++101 с огромным списком устоявшихся C++/-идиом, используемых в реальной боевой разработке. Я считаю, что она прекрасно дополняет эту статью, поскольку мы по ходу развития повествования увидим реальное внедрение многих из этих идиом - концентрированно, в одном конкретном, достаточно компактном классе. А при желании глубже ознакомиться с тем или иным паттерном, вы сможете перейти к статье Сергея за дополнительным ознакомлением с конкретной техникой.
Наивная реализация
Что такое std::function по своей сути? Это обертка над любым объектом, который можно вызвать, будь то лямбда, функтор или указатель на функцию. Ключевое слово - “любым”. Обертка должна абстрагировать нас от конкретного типа объекта. Тут нам приходит на помощь первый лайфхак из мира C++:
В C++ конкретный тип всегда можно спрятать за шаблоном. Например, мы могли бы обернуть наш объект вот так:
template<typename F> class Function { public: Function(F f) : m_f(std::move(f)) {} private: F m_f; };
Так можно обернуть вообще все что угодно. Другой вопрос - как потом пользоваться такой оберткой. Как только у нас возникает необходимость вызвать наш объект, мы понимаем, что нам не хватает выразительных средств для описания нужного нам метода:
??? operator()(???... args) { return m_f(std::forward<???>(args)...); }
И угадайте что? - мы можем добавить еще больше шаблонных аргументов, чтобы добиться задуманного:
template<typename F, typename Ret, typename ...Args> class Function { public: Function(F f) : m_f(std::move(f)) {} Ret operator()(Args... args) { return m_f(std::forward<Args>(args)...); } private: F m_f; };
И вот уже в самом начале статьи мы имеем худо-бедно работающее решение. Да, этот код действительно работает:
auto discriminant = [](float a, float b, float c) { return b * b - 4 * a * c; }; Function<decltype(discriminant), float, float, float, float> f = discriminant; auto res = f(1, 2, 3);
Но как видите, у такой реализации есть фатальные проблемы с описанием шаблонных аргументов - нам приходится расчехлять decltype и выражать тип первого аргумента через сам аргумент, что во-первых, громоздко, во-вторых, не всегда возможно.
А теперь вспомните, как удобно тип для такого же объекта описывается у std::function:
Function<float(float, float, float)> f;
Бросаются в глаза два отличия:
Тип оборачиваемого объекта - тот самый первый аргумент - вообще не используется
В описании аргументов каким-то образом появились магические скобочки и дали возможность описывать аргументы естественным для функции “сигнатурным” способом
Нам бы хотелось добиться того же.
Type erasure
Будем избавляться от typename F в шапке класса. В целом, от typename F можно не избавляться полностью, а попробовать переместить его в более укромное место. Хороший кандидат на убежище для typename F - это конструктор, потому что вызов такого конструктора позволит вывести F автоматически, что нам и нужно:
template<typename Ret, typename ...Args> class Function { public: template <typename F> Function(F&& f) : m_f(f) {} ... private: ??? m_f; };
Как видим, заметание шаблонного аргумента под конструктор сужает его “область действия” - теперь он, внезапно, виден только в конструкторе, а за его рамками класс не знает о существовании F. Как теперь объявлять m_f, решительно не понятно. Отобрав у него шаблон, мы отобрали у него возможность сообщить компилятору о своем типе на этапе компиляции.
Тип m_f все еще должен быть абстрактным, обобщенным - ведь в этом и вся суть оборачивания объекта. Но с шаблонами мы зашли в тупик - нам нужен какой-то иной механизм. И в C++ он есть:
Если статический полиморфизм - то бишь шаблоны - уже не справляется, есть большая вероятность, что вам нужен динамический полиморфизм - то бишь полиморфный базовый класс с виртуальными функциями
Как нам переориентироваться с шаблонного типа на динамический рантайм-тип? С шаблонами было просто - компилятор верил программисту на слово, что с типом F позволено делать все, что код с ним делает. И если на стадии компиляции код с конкретным подставленным типом успешно компилировался, значит все хорошо.
С динамическими типами у нас нет такой роскоши, поэтому придется описать руками, что умеет тип. В нашем случае практически все, что требуется от объекта - это возможность его вызвать через какой-нибудь call, поэтому я вижу базовый класс примерно так:
struct FuncInterface { virtual Ret call(Args... args) const = 0; virtual ~FuncInterface() {} };
Прозорливый читатель может спросить - чем являются Ret и Args? Я планирую сделать FuncInterface внутренним классом нашего Function, поэтому цельная картина будет выглядеть так:
template <typename Ret, typename... Args> class Function { ... private: struct FuncInterface { virtual Ret call(Args... args) const = 0; virtual ~FuncInterface() {} }; ... std::unique_ptr<FuncInterface> m_f; };
То есть Ret и Args остаются шаблонными аргументами. И заметьте, что мы теперь смогли разрешить тип m_f - это полиморфный рантайм-класс на куче. Вот такой вот симбиоз шаблонов и виртуальности. Но это только начало!
Следите за руками: базовый класс FuncInterface у нас есть. А кто от него будет наследоваться? И как создать инстанс такого объекта? Давайте начнем на ощупь, с конструктора:
template <typename Ret, typename... Args> class Function { public: template <typename F> Function(F&& f) { m_f = std::make_unique<FuncImpl<F>>(std::forward<F>(f)); } ... };
Видите FuncImpl<F>? Очевидно, эта штука должна быть наследником FuncInterface, чтобы все пошло по нашей задумке. А еще это должен быть шаблонный класс, чтобы знать об F. Вот такой вот симбиоз шаблонов и виртуальности. Уфф.
Пробуем описать задуманное:
template <typename Ret, typename... Args> class Function { public: template <typename F> Function(F&& f) { m_f = std::make_unique<FuncImpl<F>>(std::forward<F>(f)); } private: struct FuncInterface { virtual Ret call(Args... args) const = 0; virtual ~FuncInterface() {} }; template <typename F> struct FuncImpl final : public FuncInterface { F m_f; FuncImpl(F f) : m_f(std::move(f)) {} Ret call(Args... args) const override { return m_f(std::forward<Args>(args)...); } }; std::unique_ptr<FuncInterface> m_f; };
Теперь, чтобы сделать вызов функтора, нам всего лишь нужно вызвать метод call у внутренней обертки:
template <typename Ret, typename... Args> class Function { public: ... Ret operator()(Args... args) { return m_f->call(std::forward<Args>(args)...); } ... };
И это работает. Только что мы реализовали, идиому под названием Type Erasue, которая практически всегда используется в каноничных реализациях std::function и std::any, т.е. там, где конкретный тип надо стереть и обезличить.
Давайте еще раз рассмотрим получившийся код, чтобы осознать что в нем происходит, поскольку он совершенно нетривиальный с точки зрения типового C++ и может причинить головной дискомфорт впервые его увидевшему.
Основная идея заключается в том, что в погоне за желанием спрятать тип F мы сначала убрали его в конструктор, а потом передали эстафету дальше - во внутренний класс FuncImpl<F>, где он и осел. Внутри этого класса мы по-прежнему можем оперировать F как шаблоном. Но чтобы он не отсвечивал наружу, мы наследовали FuncImpl<F> от FuncInterface и на этом стыке обеспечили хитрую конвертацию из шаблонности в виртуальность. Виртуальностью мы светим наружу и притворяемся, будто шаблона и вовсе нет, хотя на самом деле он просто хитро спрятан. Вот такой вот симбиоз шаблонов и виртуальности.
Самое забавное в этой идиоме, что фактически весь написанный код - это просто boilerplate по борьбе с сутью языка C++, с его типизацией. К тому же, этот подход имеет существенные накладные расходы - мы знаем, что за виртуальность приходится платить косвенностью вызовов и наличием виртуальной таблицы. Что хуже, у нас теперь присутствует аллокация на куче. И все исключительно ради того, чтобы побороть типизацию языка. То есть это тот самый момент, когда ты платишь за то, чем не планировал пользоваться - тебя просто заставили.
Но у нас впереди еще долгий путь, и мы попытаемся нивелировать эти проблемы. А пока что я позволю себе парочку маленьких педантских поправок в коде.
Первое: шаблонный тип, переданный в конструктор, по-хорошему нужно очистить от всякой шелухи. Это могут быть ссылки, cv-qualifiers, косвенность - все, что угодно. Поэтому хорошим тоном является очистка типа через std::decay:
template <typename F> Function(F&& f) { using Fn = std::decay_t<F>; m_f = std::make_unique<FuncImpl<Fn>>(std::forward<F>(f)); }
Второе: сразу подстелим соломку под желаемое const-correctness поведение. Мы хотим, чтобы наши Function::operator() и FuncImpl::call() были константными всегда, даже если у объекта внутри меняется состояние при его вызове. Почему? Потому что мутабельность внутреннего объекта - не забота Function. Проще и чище сделать Function константным всегда. Так поступают в частности и в std::function.
Помимо этого конструктор для FuncImpl стоит усовершенствовать и превратить в forwarding конструктор, чтобы он принимал в себя объект без лишних копирований или перемещений. Реализуем оба желания в коде разом:
template <typename F> struct FuncImpl final : public FuncInterface { mutable F m_f; template<typename U> FuncImpl(U&& f) : m_f(std::forward<U>(f)) {} ... };
Для воплощения задуманного к m_f мы добавили mutable, а конструктор сделали шаблонным.
Третье: хотелось бы вернуться к проблеме сигнатурной записи. Пока у нас все еще существует отличие с std::function:
Function<float, float, float, float> f = discriminant; ... std::function<float(float, float, float)> f = discriminant;
У этой штуки есть решение, и я не могу предложить ничего путного, кроме как запомнить его:
template<typename> class Function; template<typename Ret, typename ...Args> class Function<Ret(Args...)> { ... }
“Что происходит?” - была у меня первая мысль, когда я это увидел. А происходит следующее. Сначала мы объявляем пустой общий случай для Function<>. Им никто и никогда не будет пользоваться. class Function<Ret(Args...)> же - это частичная специализация шаблона под function type. В языке предусмотрительно заложена возможность записать тип как Ret(Args...). Это любопытная запись, поскольку по-факту - это один аргумент, но составной и содержит в себе Ret и Args. Это будет единственная специализация для Function и теперь мы вынуждены пользоваться только ей:
Function<float(float, float, float)> f = discriminant;
А нам только это и было нужно.
In-place storage
Нередко std::function используется там, где в качестве параметров функции нужно передать произвольную логику. Во времена старинного C++ для такого могли использовать обычный указатель на функцию - каноничный коллбэк. Но в современном мире в качестве такого параметра мы зачастую хотим передавать какую-нибудь лямбду с захватом переменных, с контекстом. И практически никакой альтернативы кроме std::function у нас не остается.
А теперь представьте, что такая функция с std::function в качестве параметра становится частью горячего кода, а значит, вызывается огромное количество раз. Здесь мы неизбежно сталкиваемся с основной проблемой, которую нам подарил Type Erasure - аллокации на куче. Это всегда дорого, а под нагрузкой - это очень дорого.
Дорогими могут быть не только аллокации, но и, внезапно, деаллокации памяти.
Стандартный std::function решил эту проблему самой важной оптимизацией:
Small Object Optimization. Она же известна под аббревиатурами SOO, SSO, SBO. Я же впредь буду называть ее In-place storage, поскольку мне так больше нравится. И мы будем внедрять ее в наш
Function.Суть оптимизации красива: если объект, за который мы ответственны, достаточно мал, мы можем не выделять для него место на куче, а размещать его прямо в своем теле. Главное для этого зарезервировать некоторое место, и размер этого места определяется исходя из того, чего вам больше жалко - лишней памяти или количества аллокаций.
Без этой оптимизации наш Function сейчас имеет размер sizeof std::unique_ptr<>, то есть 8 байт. Обычно объект расширяют до 16 или 24 байт - кому сколько не жалко. Мы будем определяться с этим размером по ходу повествования.
Первое, что мешает реализовать данную оптимизацию - это std::unique_ptr<>, чей интерфейс сильно ограничен в возможностях создания и уничтожения объекта, поэтому мы сдеградируем и перейдем на обычный сырой указатель.
// было std::unique_ptr<FuncInterface> m_f; // стало FuncInterface* m_f;
Теперь введем константы, описывающие характеристики нашего in-place:
static constexpr size_t INPLACE_SIZE = 8; static constexpr size_t INPLACE_ALIGNMENT = alignof(std::max_align_t);
Как видите, пока что я решил применять in-place только для объектов до 8 байт. И это мало. Прям мало. Но скоро мы увидим, что такие стрессовые условия обернутся нам на пользу. Выравнивание же мы постарались сделать максимально демократичным, чтобы в наше хранилище могло уместиться почти что угодно.
Теперь у нас все готово, чтобы устроить биполярку нашему указателю:
union { FuncInterface* m_heap; alignas(INPLACE_ALIGNMENT) std::byte m_inplace[INPLACE_SIZE]; }; bool m_isInplace = false;
Старый дедовский union лучше новых двух - типовое решение для SSO-оптимизации. Заметим, что std::variantm_inplace - это просто байтовый массив, выровненный по INPLACE_ALIGNMENT. Он готов разместить в себе любой объект, соответствующий критериям. Без смс и аллокаций. Незамысловатые критерии есть смысл зафиксировать в отдельной constexpr-функции:
template<typename F> static constexpr bool DoesFitInplace() { return sizeof(F) <= INPLACE_SIZE && alignof(F) <= INPLACE_ALIGNMENT; }
Этот метод и будет определять судьбу объекта еще на стадии создания Function: если тип объекта позволяет ему встроиться, он станет жить в m_inplace, иначе будет честно аллоцирован в m_heap. И код Function будет учитывать эту дуальность везде:
template<typename Ret, typename ...Args> class Function<Ret(Args...)> { public: template <typename F> Function(F&& f) { using Fn = std::decay_t<F>; using WrapperT = FuncImpl<Fn>; if constexpr (DoesFitInplace<WrapperT>()) { new (m_inplace) WrapperT(std::forward<F>(f)); m_isInplace = true; } else { m_heap = new WrapperT(std::forward<F>(f)); m_isInplace = false; } } ~Function() { FuncInterface* ptr = getPtr_(); if (m_isInplace) { ptr->~FuncInterface(); } else { delete ptr; } } Ret operator()(Args... args) const { return getPtr_()->call(std::forward<Args>(args)...); } private: struct FuncInterface { virtual Ret call(Args... args) const = 0; virtual ~FuncInterface() {} }; template <typename F> struct FuncImpl final : public FuncInterface { F m_f; FuncImpl(F f) : m_f(std::move(f)) {} Ret call(Args... args) const override { return m_f(std::forward<Args>(args)...); } }; static constexpr size_t INPLACE_SIZE = 8; static constexpr size_t INPLACE_ALIGNMENT = alignof(std::max_align_t); template<typename F> static constexpr bool DoesFitInplace() { return sizeof(F) <= INPLACE_SIZE && alignof(F) <= INPLACE_ALIGNMENT; } const FuncInterface* getPtr_() const { if (m_isInplace) { return std::launder(reinterpret_cast<const FuncInterface*>(m_inplace)); } else { return m_heap; } } FuncInterface* getPtr_() { return const_cast<FuncInterface*>( std::as_const(*this).getPtr_() ); } union { FuncInterface* m_heap; alignas(INPLACE_ALIGNMENT) std::byte m_inplace[INPLACE_SIZE]; }; bool m_isInplace = false; };
Как видите, нам даже удалось сокрыть дуализм heap/in-place за методом _getPtr(). Явное обращение к m_isInplace осталось только в конструкторе и деструкторе.
Получившиеся конструктор, деструктор и
_getPtr()- это отличные живые примеры, на которых можно подтянуть уже вполне себе advanced нюансы языка, такие как:
Чем отличается expression new от operator new
Что такое placement new, какой у него синтаксис, и выделяет ли он память
В каких случаях и зачем нам может понадобиться вызывать деструктор вручную
Для чего нужен
std::launder, и зачем он нужен в нашем конкретном случаеЕсли вы плаваете в этих темах, я отправляю вас за ответами в мою статью про время жизни объектов в C++. Главное, вернитесь оттуда живыми ;) В главе 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.
Crewstaking
Отличная статья! Спасибо за проделанную работу.