Вступление
C++ 17 привнес в язык достаточно много нововведений, в том числе шаблон std::variant (хоть в Boost он есть уже довольно давно). Фактически, последним вышедшим и полноценно реализованным стандартом C++ на тот момент, как я начал изучать данный язык, являлся как раз C++17, поэтому нововведениям данного стандарта в свое время я уделил наибольшее внимание.
В какой-то момент мне стало интересно, как именно устроен std::variant, в связи с чем я немного погуглил про его принципиальное устройство и, вооружившись variadic templates, сел писать свою реализацию. Данный шаблон устроен достаточно интересно, поэтому людям, вообще не знакомым с его устройством, данная статья будет полезна. Если данную статью прочитают более опытные разработчики, я буду рад их комментариям по поводу моей реализации.
Упомяну несколько моментов перед началом статьи:
реализована возможность вызвать произвольный функтор для хранимого в std::variant объекта
можно передавать объекты заданных типов
естественно, хранимые объекты правильно удаляются
с использованием C++ 20 код можно сделать красивее, но в данной статье будет использоваться именно 17 стандарт
перечисленный далее функционал НЕ реализован: множественный вызов различных std::variant объектов (потихоньку делаю, но это достаточно непросто. Если не забью и доделаю, то будет небольшое продолжение статьи); emplace создание; весь прочий вспомогательный функционал
автор ни в коем случае не претендует на оптимальность реализации. Статья рассчитана на тех, кто не знаком/плохо знаком с variadic templates и тем, как его вообще использовать, а также плохо знаком с std::variant
предполагается, что читатель хотя бы поверхностно знаком с метапрограммированием
Принципиальное устройство
Перед тем, как приводить какой-то код хочется упомянуть, что используется в основе вариантного типа. Как известно (а может быть, кому то и неизвестно), размер std::variant зависит от максимального размера среди переданных типов. Уже из этого свойства можно предположить, что внутри используется буфер, размер которого равен размеру наибольшего из типов.
До C++ 11 реализовать вариантный тип было достаточно нетривиальной задачей (например, один из возможных костылей - задать шаблон с большим количеством параметров типов с присвоенным значением по умолчанию всем, кроме первого), однако, с появлением variadic templates сложность написания данного кода сильно уменьшилась.
Каким образом хранить информацию о том, какой именно тип данных хранится в экземпляре вариантного типа? Для этой задачи можно использовать индекс, сопоставляя его с порядком переданных в шаблон типов.
Заодно, в дальнейшем с помощью индекса можно будет достаточно легко вызывать специализацию функтора под нужный тип, используя массив указателей.
Осталось только написать код, а также решить некоторые нюансы реализации
Начнем писать код
Для начала предлагаю реализовать вспомогательные функции – нам нужно уметь находить максимальный размер среди переданных типов, а также уметь ассоциировать тип с некоторым индексом. Делать это нужно в compile-time, особенно в случае функции нахождения максимального размера
template <std::uint32_t CurMax, class T, class...Types>
constexpr std::uint32_t FindMaxSize() {
constexpr std::uint32_t sZT = sizeof(T);
if constexpr (sZT > CurMax) {
if constexpr (sizeof...(Types) != 0) {
return FindMaxSize<sZT, Types...>();
} else {
return sZT;
}
} else {
if constexpr (sizeof...(Types) != 0) {
return FindMaxSize<CurMax, Types...>();
} else {
return CurMax;
}
}
}
Разберем данную фукнцию. Она рекурсивно проходит по переданным типам, находя наибольший размер. При этом, в функции используются constexpr вычисления, за счет чего мы можем использовать результат выполнения данной функции для определения размера std::array. Шаблон принимает в себя максимальный размер типа с предыдущей итерации (руками явно передаем 0 при вызове данной функции), а также конструкция вида class T, class… Types позволит нам отщипывать по одному типу в каждой следующей итерации, а также гарантирует, что был передан хотя бы один тип (class… Types может принимать и 0 типов). На основании размера текущего рассматриваемого типа мы решаем, какой размер передать дальше – максимальный размер с предыдущей итерации или же текущий размер. Условие остановки рекурсии – размер Types… равен 0. Результат выполнения – максимальный размер.
Теперь напишем функцию определения индекса конкретного типа:
template <std::int16_t curN, class T, class Val, class... Vals>
constexpr std::int16_t FindNumber() {
if constexpr (std::is_same_v<std::decay_t<T>, std::decay_t<Val>>) {
return curN;
} else if constexpr (sizeof...(Vals) != 0) {
return FindNumber<curN + 1, T, Vals...>();
} else {
return -1;
}
}
Принципиальное устройство идентичное. Класс T – это тип, индекс которого мы определяем, конструкция class Val class… Vals позволит нам итерировать. Однако, в данном коде есть один нюанс, который необходимо рассмотреть. А именно, вызов std::decay_t. Если данная функция в том или ином виде будет вызываться в шаблонном коде (а она будет в нем вызываться), то, например, Val может иметь тип Foo, а T – const Foo. В этом случае, std::is_same_v без использования std::decay_t выдаст false, так как у типов отличаются квалификаторы. Вряд ли такое поведение будет являться ожидаемым. Поэтому, квалификаторы при сравнении типов лучше отбросить. И действительно – std::variant позволяет использовать типы с отличными от заданных квалификаторами, так что данное поведение является более менее корректным.
Благодаря данным функциям, мы можем написать следующий код:
template <class... PTypes>
class MyVariant {
private:
std::int16_t curT;
std::array<char, FindMaxSize<0, PTypes...>()> data;
public:
MyVariant() : curT(-1) {}
template <class T> MyVariant(T &&val) {
constexpr auto nextN = FindNumber<0, T, PTypes...>();
static_assert(nextN != -1, "Uknown type passed");
using valueT = std::decay_t<T>;
new (this->data.data()) valueT(std::forward<T>(val));
this->curT = nextN;
}
};
Также договоримся, что раз уж индексация типов начинается с 0, то значение -1 будет обозначать отсутствие типа. В данном случае, дефолтный конструктор указывает, что никакой объект не хранится.
Что делает конструктор копирования\перемещения:
находим индекс типа
если тип не найден, то уведомляем об этом с помощью static_assert
если все хорошо, то создаем данный тип, используя буфер. Особенности создания: использование std::forward(val) для прямой передачи. Таким образом, мы реализуем и конструктор копирования, и конструктор перемещения; память выделяется в буфере за счет вызова new(указатель на буфер) ТипДанных(аргументы); на всякий случай отбрасываем квалификаторы при создании
присваивание индекса происходит именно после успешного создания объекта.
Хочу обратить ваше внимание на последнем пункте. Дело в том, что пользовательский тип довольно легко и непринужденно может выбрасывать исключения. А так как индекс заодно в дальнейшем будет использоваться и для правильной очистки хранимого объекта, то обратный порядок может привести к попытке очистить не сконструированный объект. Что из этого выйдет – неизвестно.
И вот мы плавно подошли к одному из самых важных моментов – очистка памяти. Утечки памяти – это нехорошо, поэтому мы должны быть уверены в правильном удалении.
Определим вспомогательную шаблонную функцию:
template <class T> void Clearer(void *data) {
T *casted = reinterpret_cast<T *>(data);
casted->~T();
}
Она максимально простая: принимается void* data и тип T, который фактически является настоящим типом объекта. Зачем это нужно? Подобным маневром мы потом сможем определить специализации данного шаблона и хранить их в одном массиве, просто вызывая нужную фукнцию очистки по индексу, хранимому внутри нашего вариантного типа. Есть один момент, на который мы обязаны обратить внимание – данная функция должна уметь вызывать деструктор объекта. Впрочем, данное ограничение не является чем-то необычным, поэтому проблемой это не является.
С использованием данной вспомогательной функции мы можем определить следующий публичный метод нашего класса MyVariant:
bool TryClear() {
bool cleared = false;
if (this->curT != -1) {
static constexpr std::array<void (*)(void *), sizeof...(PTypes)> cont{
&Clearer<PTypes>...};
cont[this->curT](this->data.data());
this->curT = -1;
cleared = true;
}
return cleared;
}
Модификатор static здесь по идее можно убрать, т.к. constexpr неявно статический, но пусть будет. Здесь с помощью выражения свертки создается массив из указателей на специализации определенной раньше вспомогательной функции. Также, хочу обратить внимание, что деструктор, по-хорошему, не должен выбрасывать исключения. Если деструктор вашего класса способен выбрасывать исключения, то механику его правильного удаления в данной ситуации вам в любом случае придется продумать отдельно. Вызов нужной функции очищения происходит за счет вызова нужно указателя на фукнцию по текущему индексу типа.
Заодно, готов и деструктор:
~MyVariant() { this->TryClear(); }
А также оператор копирования/перемещения:
template <class T>
MyVariant &operator=(T &&val) {
constexpr auto nextN = FindNumber<0, T, PTypes...>();
static_assert(nextN != -1, "Uknown type passed");
this->TryClear();
using valueT = std::decay_t<T>;
new (this->data.data()) valueT(std::forward<T>(val));
this->curT = nextN;
return *this;
}
Один момент, на который стоит обратить внимание - если конструктор пользовательского типа выкинет исключение, то мы получим следующую ситуацию: старые данные уже потеряны, а новые не созданы. В некоторых случаях подобное поведение может быть нежелательно.
Итак, у нас уже все готово, кроме одного момента – непосредственно вызов функции. На самом деле, общий принцип уже придуман – подобное мы реализовали в вспомогательном шаблоне Clearer(), только в данном случае мы дополнительно будем принимать тип предиката и флаг, отвечающий за необходимость перемещения объекта
template <class Predicate, class T, bool needToMove>
void CallP(Predicate pr, void *data) {
T *casted = reinterpret_cast<T *>(data);
if constexpr (needToMove) {
pr(std::move(*casted));
} else {
pr(*casted);
}
}
Эта функция похожа на Clearer, но за одним исключением - я принимаю needToMove. Почему я передаю это в виде bool, а не извлекаю информацию из типа? Я решил спроектировать подобным образом, так как при вызове Visit пользователь будет передавать именно вариантный тип, а не лежащее внутри значение (что очевидно). В связи с этим проще извлечь информацию про lvalue/rvalue непосредственно из вариантного типа, а при передаче хранимого внутри значения просто указать, что мы используем.
Чтобы воспользоваться данным шаблоном, определим следующую структуру:
struct SimpleVisiterInternal {
template <class Visitor, class... VariantTypes>
static bool TryVisit(Visitor &&visitor, MyVariant<VariantTypes...> &variant) {
bool result = false;
if (variant.curT != -1) {
result = true;
constexpr auto TypesCount = sizeof...(VariantTypes);
constexpr std::array<void (*)(Visitor, void *), TypesCount> cont{
&CallP<Visitor, VariantTypes, false>...};
cont[variant.curT](std::forward<Visitor>(visitor),
(void *)(variant.data.data()));
}
return result;
}
template <class Visitor, class... VariantTypes>
static bool TryVisit(Visitor &&visitor,
MyVariant<VariantTypes...> &&variant) {
bool result = false;
if (variant.curT != -1) {
constexpr auto TypesCount = sizeof...(VariantTypes);
result = true;
constexpr std::array<void (*)(Visitor, void *), TypesCount> cont{
&CallP<Visitor, VariantTypes, true>...};
cont[variant.curT](std::forward<Visitor>(visitor),
(void *)(variant.data.data()));
}
return result;
}
};
Данная структура для произвольного Visitorа определяет массив из указателей на специализацию шаблона CallP. Также, здесь мы различаем, lvalue или rvalue является объект нашего MyVariant и задаем нужный флаг при вызове. И да, данная структура должна быть friend для MyVariant, чтобы уметь работать с его внутренним представлением.
Теперь обернем реализацию в функцию, чтобы было удобнее пользоваться
template <class Visitor, class Variant>
bool TryVisit(Visitor &&visitor, Variant &&variant) {
return SimpleVisiterInternal::TryVisit(std::forward<Visitor>(visitor),
std::forward<Variant>(variant));
}
По сути, все готово, шаблоном нашего вариантного типа можно пользоваться
А где TryVisit со множественным вызовом?
Пока пишется. Общий принцип все тот же - надо сделать массив. В данном случае я считаю, что удобнее всего будет сделать одномерный массив, который за счет извлечения количества возможных хранимых типов для каждого из MyVariant будет корректно выбирать нужный указатель. Трудность в том, чтобы этот массив сгенерировать, т.к. приходится оперировать произвольным количеством MyVariant, внутри каждого из которых может быть произвольное количество возможных типов. И все эти размеры разные. Сподвижки и идеи вроде есть, но ввиду других дел реализация может затянуться на неопределенный срок.
Немного о том, почему TryVisit - функция, а не метод
std::visit является именно функцией, а не методом. Более того, вызвать функтор с помощью метода вообще нельзя. Почему так? Лично для себя я выделил 2 причины:
std::visit умеет работать с произвольным количеством переданных вариантных типов. Данная возможность достаточно спорная, так как в процессе генерируется очень большое количество специализаций функций N1 * N2 * ... и так по количеству вариантный типов, но она присутствует, поэтому лучше использовать функцию
распознать, где нужна move семантика будет достаточно непросто, т.к. напрямую извлечь информацию о том, является ли std::variant lvalue или rvalue мы не можем (deducing this пока не завезли). Вынесение функционала в функцию дает гораздо более простое решение данной проблемы
Заключение
Данный код писался сугубо из интереса. Я постарался все учесть, а также протестировал код для различных типов, но гарантировать отсутствие каких-то ошибок все же не могу.
Это моя первая статья на habr, поэтому надеюсь, что она была кому-нибудь полезна.
Код можно глянуть по этой ссылке - https://github.com/KirillVelichk0/MyVariant
Поправки
alignas(PTypes...) char data[FindMaxSize<0, PTypes...>()];
Лучше для буфера использовать данную конструкцию вместо std::array, чтобы учитывать выравнивание. FindMaxSize можно заменить на std::max(sizeof...(PTypes)), но в статье уже используется функция, поэтому оставлю так (спасибо, Kelbon)
Комментарии (18)
segment
02.04.2024 12:51+1Можете показать реальное применение этому? Не наброс, для меня остается загадкой любовь к метапрограммированию, я помню как-то закапывался в boost/std и реализовывал свои контейнеры, оно конечно увлекает, но по итогу на простом Си (в embedded) оно реализовалось в более поддерживаемом варианте. Как всё это отлаживать, тестировать и документировать?
KirillVelichk0 Автор
02.04.2024 12:51Секунду. Комментарий поправил
Kelbon
02.04.2024 12:51vtable это не хеш таблица... Не более чем массив функций. У варианта другие преимущества
KirillVelichk0 Автор
02.04.2024 12:51Действительно, был не прав, по факту везде vtables используется. Но, возможно я ошибаюсь, мы в любом случае не можем гарантировать константный O(1), вызов vtalbes может занять дополнительные расходы?
KirillVelichk0 Автор
02.04.2024 12:51Так, ладно, в баню производительность. std::variant будет очевидно шустрее виртуальных методов, но я боюсь наврать лишний раз.
Ну, вообще, так как полиморфизм внешний, можно навесить достаточно большое количество дополнительных методов работы с хранимыми данными. Для виртуальных методов мы так не можем. Если использовать указатели на функции и просто передавать в них void*, то проблема будет примерно та же (надеюсь, я вас правильно понял)
Ritan
02.04.2024 12:51Вместо std::arrray<char, ...> лучше std::aligned_storage_t использовать с максимальным для всех типов align
Kelbon
02.04.2024 12:51это устаревшее и нет, не лучше( про алигмент написал выше)
Ritan
02.04.2024 12:51Любопытно, упустил, что aligned_storage объявили устаревшим.
Аргументация там имхо хромает: aligned_storage требует использования reinterpret_cast, поэтому используйте alignas(align) std::byte[size], который требует того же самого.
Опять объявляют что-то устаревшим так как не смогли что-то сделать не ломая ABI, поэтому решили сломать API
Kelbon
02.04.2024 12:51ну aligned storage объективно бесполезен, зачем тогда его оставлять?
Ritan
02.04.2024 12:51Он более ясно выражает цель - получить какой-то объём хранилища с указанным алайментом. И мне не важно, что под капотом там alignas(align) char data[size], alignas(align) std::byte data[size] или вообще struct { char pad[pad_size], char data[size] }.
Так ведь можно и std::array задепрекейтить, тоже можно(почти) заменить на сишные массивы
voldemar_d
02.04.2024 12:51Читал, что есть библиотеки, которые работают с контейнерами, имеющими явные методы begin и end - они могут работать с std::array, а с сишными массивами не могут.
Ritan
02.04.2024 12:51+1Это вина библиотек - использовать рекомендуют свободные функции std::begin(c), std::end(c), а не методы
Kelbon
02.04.2024 12:51байты с align более чем ясно дают понять что происходит, по итогу получается, что aligned_storage только добавлял мороки и вероятность ошибиться лишний раз
array действительно хотелось бы задепрекейтить, точнее улучшить встроенный массив, добавив ему копирование и пару методов (begin/end/data), но к сожалению это легаси и этого никто делать не собирается
Ritan
02.04.2024 12:51улучшить встроенный массив, добавив ему копирование и пару методов (begin/end/data),
Это сломает совместимость с большой кучей сишного кода, так что не нужно
Kelbon
тип индекса можно выбирать в зависимости от числа типов. И байты нужно поместить по правильному алигменту, alignas(alignof(T)) char data[std::max({sizeof(Types)...})];
KirillVelichk0 Автор
спасибо, что-то не подумал об этом. Сейчас поправлю