Пишем свой вариантный тип
Вступление
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
constexpr std::uint32_t FindMaxSize() {
constexpr std::uint32_t sZT = sizeof(T);
if constexpr (sZT > CurMax) {
if constexpr (sizeof...(Types) != 0) {
return FindMaxSize();
} else {
return sZT;
}
} else {
if constexpr (sizeof...(Types) != 0) {
return FindMaxSize();
} else {
return CurMax;
}
}
}
Разберем данную фукнцию. Она рекурсивно проходит по переданным типам, находя наибольший размер. При этом, в функции используются constexpr вычисления, за счет чего мы можем использовать результат выполнения данной функции для определения размера std: array. Шаблон принимает в себя максимальный размер типа с предыдущей итерации (руками явно передаем 0 при вызове данной функции), а также конструкция вида class T, class… Types позволит нам отщипывать по одному типу в каждой следующей итерации, а также гарантирует, что был передан хотя бы один тип (class… Types может принимать и 0 типов). На основании размера текущего рассматриваемого типа мы решаем, какой размер передать дальше — максимальный размер с предыдущей итерации или же текущий размер. Условие остановки рекурсии — размер Types… равен 0. Результат выполнения — максимальный размер.
Теперь напишем функцию определения индекса конкретного типа:
template
constexpr std::int16_t FindNumber() {
if constexpr (std::is_same_v, std::decay_t>) {
return curN;
} else if constexpr (sizeof...(Vals) != 0) {
return FindNumber();
} 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 MyVariant {
private:
std::int16_t curT;
std::array()> data;
public:
MyVariant() : curT(-1) {}
template MyVariant(T &&val) {
constexpr auto nextN = FindNumber<0, T, PTypes...>();
static_assert(nextN != -1, "Uknown type passed");
using valueT = std::decay_t;
new (this->data.data()) valueT(std::forward(val));
this->curT = nextN;
}
};
Также договоримся, что раз уж индексация типов начинается с 0, то значение -1 будет обозначать отсутствие типа. В данном случае, дефолтный конструктор указывает, что никакой объект не хранится.
Что делает конструктор копирования\перемещения:
находим индекс типа
если тип не найден, то уведомляем об этом с помощью static_assert
если все хорошо, то создаем данный тип, используя буфер. Особенности создания: использование std: forward (val) для прямой передачи. Таким образом, мы реализуем и конструктор копирования, и конструктор перемещения; память выделяется в буфере за счет вызова new (указатель на буфер) ТипДанных (аргументы); на всякий случай отбрасываем квалификаторы при создании
присваивание индекса происходит именно после успешного создания объекта.
Хочу обратить ваше внимание на последнем пункте. Дело в том, что пользовательский тип довольно легко и непринужденно может выбрасывать исключения. А так как индекс заодно в дальнейшем будет использоваться и для правильной очистки хранимого объекта, то обратный порядок может привести к попытке очистить не сконструированный объект. Что из этого выйдет — неизвестно.
И вот мы плавно подошли к одному из самых важных моментов — очистка памяти. Утечки памяти — это нехорошо, поэтому мы должны быть уверены в правильном удалении.
Определим вспомогательную шаблонную функцию:
template void Clearer(void *data) {
T *casted = reinterpret_cast(data);
casted->~T();
}
Она максимально простая: принимается void* data и тип T, который фактически является настоящим типом объекта. Зачем это нужно? Подобным маневром мы потом сможем определить специализации данного шаблона и хранить их в одном массиве, просто вызывая нужную фукнцию очистки по индексу, хранимому внутри нашего вариантного типа. Есть один момент, на который мы обязаны обратить внимание — данная функция должна уметь вызывать деструктор объекта. Впрочем, данное ограничение не является чем-то необычным, поэтому проблемой это не является.
С использованием данной вспомогательной функции мы можем определить следующий публичный метод нашего класса MyVariant:
bool TryClear() {
bool cleared = false;
if (this->curT != -1) {
static constexpr std::array cont{
&Clearer...};
cont[this->curT](this->data.data());
this->curT = -1;
cleared = true;
}
return cleared;
}
Модификатор static здесь по идее можно убрать, т.к. constexpr неявно статический, но пусть будет. Здесь с помощью выражения свертки создается массив из указателей на специализации определенной раньше вспомогательной функции. Также, хочу обратить внимание, что деструктор, по-хорошему, не должен выбрасывать исключения. Если деструктор вашего класса способен выбрасывать исключения, то механику его правильного удаления в данной ситуации вам в любом случае придется продумать отдельно. Вызов нужной функции очищения происходит за счет вызова нужно указателя на фукнцию по текущему индексу типа.
Заодно, готов и деструктор:
~MyVariant() { this->TryClear(); }
А также оператор копирования/перемещения:
template
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;
new (this->data.data()) valueT(std::forward(val));
this->curT = nextN;
return *this;
}
Один момент, на который стоит обратить внимание — если конструктор пользовательского типа выкинет исключение, то мы получим следующую ситуацию: старые данные уже потеряны, а новые не созданы. В некоторых случаях подобное поведение может быть нежелательно.
Итак, у нас уже все готово, кроме одного момента — непосредственно вызов функции. На самом деле, общий принцип уже придуман — подобное мы реализовали в вспомогательном шаблоне Clearer (), только в данном случае мы дополнительно будем принимать тип предиката и флаг, отвечающий за необходимость перемещения объекта
template
void CallP(Predicate pr, void *data) {
T *casted = reinterpret_cast(data);
if constexpr (needToMove) {
pr(std::move(*casted));
} else {
pr(*casted);
}
}
Эта функция похожа на Clearer, но за одним исключением — я принимаю needToMove. Почему я передаю это в виде bool, а не извлекаю информацию из типа? Я решил спроектировать подобным образом, так как при вызове Visit пользователь будет передавать именно вариантный тип, а не лежащее внутри значение (что очевидно). В связи с этим проще извлечь информацию про lvalue/rvalue непосредственно из вариантного типа, а при передаче хранимого внутри значения просто указать, что мы используем.
Чтобы воспользоваться данным шаблоном, определим следующую структуру:
struct SimpleVisiterInternal {
template
static bool TryVisit(Visitor &&visitor, MyVariant &variant) {
bool result = false;
if (variant.curT != -1) {
result = true;
constexpr auto TypesCount = sizeof...(VariantTypes);
constexpr std::array cont{
&CallP...};
cont[variant.curT](std::forward(visitor),
(void *)(variant.data.data()));
}
return result;
}
template
static bool TryVisit(Visitor &&visitor,
MyVariant &&variant) {
bool result = false;
if (variant.curT != -1) {
constexpr auto TypesCount = sizeof...(VariantTypes);
result = true;
constexpr std::array cont{
&CallP...};
cont[variant.curT](std::forward(visitor),
(void *)(variant.data.data()));
}
return result;
}
};
Данная структура для произвольного Visitorа определяет массив из указателей на специализацию шаблона CallP. Также, здесь мы различаем, lvalue или rvalue является объект нашего MyVariant и задаем нужный флаг при вызове. И да, данная структура должна быть friend для MyVariant, чтобы уметь работать с его внутренним представлением.
Теперь обернем реализацию в функцию, чтобы было удобнее пользоваться
template
bool TryVisit(Visitor &&visitor, Variant &&variant) {
return SimpleVisiterInternal::TryVisit(std::forward(visitor),
std::forward(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