Версия первая
Код для данного подраздела можно найти здесь.
Прежде чем мы перейдём к коду, давайте зададим несколько условий для версии 1.0:
-
Наш вариант должен поддерживать 2 типа: int и std::string, т.е. один «простой», и один «сложный». Такого варианта достаточно для рассмотрения типичных узких мест.
-
Основным типом должен быть union, т.е. никакого оборачивания в другие классы.
-
Он должен уметь сообщать о своём содержимом и возвращать его в типобезопасной манере.
-
Он должен удовлетворять следующему тестовому коду:
int main()
{
Variant var;
std::cout << "Var type: " << var.type() << "\n";
var = Variant{55};
std::cout << "Var type: " << var.type() << ", value=" << var.integral() << "\n";
var = Variant{"hey"};
std::cout << "Var type: " << var.type() << ", value=" << var.string() << "\n";
}
Вышеперечисленные базовые требования довольно просты и очевидны, кроме одного: основным типом должен выступать union. Учитывая то, что нам где-то нужно хранить дискриминатор, это может показаться нетривиальной задачей. Но нам на помощь приходит стандарт, который имеет одно исключение, благодаря которому обращение к неактивному члену объединения всё-таки возможно (class.union/p2): если в объединении содержится несколько структур, удовлетворяющим требованию стандартного размещения (англ. standard-layout) и имеющим общую начальную последовательность объектов, то в этом случае разрешается обращаться к объекту любой из этих структур, при условии, что обращение происходит только к общей части. Пример таких структур:
struct A
{
int a;
};
struct B
{
int a;
float b;
};
struct C
{
int a;
float b;
char c;
};
Здесь у A, B и C общим является первый член типа int, а также у B и C дополнительно общим является второй член типа float. Соответственно, если объекты этих типов будут содержаться в объединении, то можно будет обращаться к их общим частям, при условии, что активный объект содержит эти части.
Теперь давайте разберёмся, как мы можем это использовать. Для начала введём тип, который мы будем использовать в качестве дискриминатора:
enum Type{None, Integral, String};
А теперь воспользуемся вышеозначенным пунктом стандарта и сделаем структуру, которая будет содержать дискриминатор первым членом, а данные вторым. Также мы должны убедиться, что наша структура удовлетворяет ограничениям, наложенными стандартом — мы ведь не хотим писать код, содержащий НП? В результате у нас может получиться подобный шаблон структуры:
template<typename T>
struct Member
{
Type type;
T value;
static_assert(std::is_scalar_v<T> || std::is_standard_layout_v<T>);
};
И мы будем использовать этот шаблон для создания каждого члена результирующего объединения. Теперь мы можем объявить наш Variant следующим образом:
union Variant
{
public:
enum Type{None, Integral, String};
public:
Variant();
explicit Variant(int integral);
explicit Variant(const std::string& string);
Variant(const Variant& rhs);
Variant& operator=(const Variant& rhs);
~Variant();
Type type() const;
int integral() const;
const std::string& string() const;
private:
template<typename T>
struct Member
{
Type type;
T value;
static_assert(std::is_scalar_v<T> || std::is_standard_layout_v<T>,
"T should be of scalar or standard-layout type");
};
private:
Member<int> m_Integral;
Member<std::string> m_String;
};
Учтите, что std::string может не подходить критерию is_standard_layout, и, соответственно, версия представленная в этом разделе может собираться не везде. К примеру, она собирается в Release-версии MSVC 2022, но не собирается в Debug, т.к. внутреннее «убранство» std::string, очевидно, отличается в зависимости от флагов компилятора.
Как вы можете видеть, у нас есть два члена m_Integral и m_String, которые имеют общее начало, поэтому мы легко можем реализовать функцию type():
Type type() const
{
return m_Integral.type;
}
Базовые конструкторы тоже довольно просты (приведу только один для примера):
Variant(const std::string& string):
m_String{String, string}
{
}
Интересными остаются только три специальных функции: конструктор копирования, оператор присваивания и деструктор. Начнём с конструктора копирования:
Variant(const Variant& rhs)
{
if(rhs.type() == String)
new (&m_String) Member{rhs.m_String};
else
new (&m_Integral) Member{rhs.m_Integral};
}
Как мы говорили в предыдущем разделе, нельзя просто взять и присвоить новое значение члену объединения, если у того есть нетривиальный конструктор. Для этих целей мы должны использовать размещающий (англ. placement) new. С другой стороны, имея простой int, мы могли бы альтернативную ветку сделать такой: m_Integral = rhs.m_Integral;, но я так делать не стал, потому что предпочитаю единообразие.
Теперь реализуем деструктор:
Variant::~Variant()
{
if(type() == String)
m_String.value.~basic_string();
}
Т.к. для int деструктор вызывать не нужно, то мы проверяем только на наличие строки в нашем варианте, и если она там есть, то мы явно вызываем для неё деструктор - никто другой за нас этого не сделает!
По идее, я должен был написать m_String.value.~string(), и это должно было бы работать, но не работает. Не буду утверждать наверняка, но, с моей точки зрения, это баг в компиляторах.
Совместив конструктор копирования и деструктор, напишем оператор присваивания:
Variant& operator=(const Variant& rhs)
{
if(type() == String)
m_String.value.~basic_string();
if(rhs.type() == String)
new (&m_String) Member{rhs.m_String};
else
new (&m_Integral) Member{rhs.m_Integral};
return *this;
}
Думаю, что пояснения здесь излишни.
И, собственно, всё — первая версия Variant готова и проходит необходимые тесты, но мы её немного дополним, т.к. я сознательно использовал некоторые устаревшие конструкции.
В версии 1.1 мы используем std::construct_at и std::destroy_at, следуя современным веяниям по избавлению кода от наличия явных new, delete и вызовов деструкторов. Также задействуем механизм требований (англ. requirements) из C++20, который гораздо лучше подходит к нашей задаче, чем одинокий static_assert. Вот как изменится наш вспомогательный шаблон:
template<typename T>
requires std::is_scalar_v<T> || std::is_standard_layout_v<T>
struct Member
{
Type type;
T value;
};
А оператор присваивания станет таким:
Variant& operator=(const Variant& rhs)
{
if(type() == String)
destroy_at(&m_String);
if(rhs.type() == String)
construct_at(&m_String, rhs.m_String);
else
construct_at(&m_Integral, rhs.m_Integral);
return *this;
}
Остальные члены не привожу, т.к. они будут обновлены аналогично.
На этом первая версия полностью завершена, но пару слов добавить всё-таки нужно. Я привёл эту версию только в качестве интересного варианта; я не считаю, что эксплуатация разрешения на обращение к общему члену — хорошая идея. Данная реализация это «просто потому, что могу», я не вижу никакого практического смысла в использование union в качестве основного/верхнего типа. Поэтому предлагаю рассмотреть более рациональную реализацию.
Версия вторая
Код для данного подраздела можно найти здесь.
Для разработки второй версии мы воспользуемся требованиями к первой, но исключим из них требование №2, которое вынуждало нас использовать union в качестве основного класса.
Посмотреть требования:
-
Наш вариант должен поддерживать 2 типа: int и std::string, т.е. один «простой», и один «сложный». Такого варианта достаточно для рассмотрения типичных узких мест.
-
Основным типом должен быть union, т.е. никакого оборачивания в другие классы.
-
Он должен уметь сообщать о своём содержимом и возвращать его в типобезопасной манере.
-
Он должен удовлетворять следующему тестовому коду:
int main()
{
Variant var;
std::cout << "Var type: " << var.type() << "\n";
var = Variant{55};
std::cout << "Var type: " << var.type() << ", value=" << var.integral() << "\n";
var = Variant{"hey"};
std::cout << "Var type: " << var.type() << ", value=" << var.string() << "\n";
}
В данном варианте мы используем обычную структуру, которая будет содержать объединение; интерфейс будет выглядеть так:
struct Variant
{
public:
enum Type{None, Integral, String};
public:
Variant() = default;
Variant(int integral);
Variant(const std::string& string);
Variant(const Variant& rhs);
Variant& operator=(const Variant& rhs);
~Variant();
Type type() const;
int integral() const;
const std::string& string() const;
private:
union Container
{
Container();
Container(int val);
Container(const std::string& val);
~Container();
int integral;
std::string string;
};
private:
void _copy(const Variant& rhs);
private:
Type m_Type = None;
Container m_Container;
};
Публичная часть интерфейса практически ничем не отличается от первого варианта, кроме того, что в качестве основного класса мы используем struct вместо union, а также наличием конструктора по умолчанию. Интересное начинается в закрытой, приватной части. Наша структура содержит два члена:
Type m_Type = None;
Container m_Container;
Здесь никаких откровений, мы храним тип и данные (объект объединения); но вот само объединение выглядит не совсем обычно. Ведь мы привыкли видеть объединения, которые не содержат функций-членов, т.е. какие-то такие:
union Container
{
int integral;
std::string string;
};
Но в силу того, что наш Container содержит объект с нетривиальным конструктором, мы обязаны объявить все эти функции-члены, иначе мы бы не смогли использовать объект Container в нашей структуре. Реализация этих функций тривиальна, поэтому загромождать текст статьи ими не буду (всё есть в хранилище). Мы же перейдём к функциям-членам структуры Variant. Конструкторы снова очевидные (привожу один):
Variant(int integral):
m_Type{Integral},
m_Container{integral}
{
}
Основные сложности, как и в прошлый раз, содержатся в деструкторе, конструкторе копирования и операторе присваивания. Но решение этих трудностей мало чем отличается от того, что мы уже делали. Вот как выглядит деструктор:
~Variant()
{
if(type() == String)
std::destroy_at(&m_Container.string);
}
А вот как выглядит вспомогательная функция-член _copy:
void _copy(const Variant& rhs)
{
if(rhs.type() == String)
std::construct_at(&m_Container.string, rhs.m_Container.string);
else if(rhs.type() == Integral)
std::construct_at(&m_Container.integral, rhs.m_Container.integral);
m_Type = rhs.type();
}
Наконец, объединив наработки деструктора и функции копирования, получим оставшиеся члены:
Variant(const Variant& rhs)
{
_copy(rhs);
}
Variant& operator=(const Variant& rhs)
{
if(type() == String)
std::destroy_at(&m_Container.string);
_copy(rhs);
return *this;
}
Вот и весь вариант второй версии; но представленная версия не является эталонной в своём классе, а значит у нас есть, что дорабатывать.
В версии 2.1 мы откажемся от использования именованного объединения, заменив его безымянным (англ. anonymous). Это позволит нам избавиться не только от явного члена типа Container, но и от реализации функций-членов для нашего объединения: если нет явного объекта, то и потребностей в функциях нет — всё решается с помощью особой «магии» безымянного объединения. А «магия» заключается в том, что безымянное объединение превращает нашу структуру в некое подобие объединения, т.е. все члены безымянного union становятся вариативными членами охватывающей структуры Variant. Таким образом, получается, что ограничения, наложенные на объединение наличием нетривиального объекта типа std::string, никуда не делись, но в силу того, что мы не создаём его объектов, у нас нет нужды потакать этим ограничениям — всё будет решаться в рамках вышестоящей структуры.
Публичный интерфейс нашего класса не изменится, а вот закрытая часть несколько преобразится:
struct Variant
{
//...
private:
union
{
int m_Integral;
std::string m_String;
};
private:
void _copy(const Variant& rhs);
private:
Type m_Type;
};
Как легко заметить, наш код стал значительно компактнее, теперь union выглядит привычным образом, а явный член остаётся только один — дискриминатор. Реализация обновится соответствующим образом (отовсюду пропадёт Container), приведу лишь один пример:
void _copy(const Variant& rhs)
{
if(rhs.type() == String)
std::construct_at(&m_String, rhs.m_String);
else if(rhs.type() == Integral)
std::construct_at(&m_Integral, rhs.m_Integral);
m_Type = rhs.type();
}
Вот теперь мы имеем эталонную реализацию класса Variant. Конечно, под «эталонной» я имею в виду концепцию реализации, а не непосредственно код, код в этой статье исключительно академической направленности и не имеет многих атрибутов, присущих коду «реальному». Именно такого вида вы можете найти массу реализаций в «дикой природе», одной из которых является реализация класса QVariant фреймворка Qt (они не использовали безымянное объединение, но и «сложных» типов не хранят). И хотя мы достигли «эталона», данная версия является слишком ограниченной: почему у нас явно заданы типы, которые мы можем хранить в нашем варианте? Определённо, нам есть куда стремиться, а значит мы двигаемся дальше.
Версия четвёртая
Код для данного подраздела можно найти здесь.
Требования для четвёртой версии будут выглядеть следующим образом:
-
Наш вариант должен поддерживать бесчисленное количество любых типов, которые задаются на этапе создания.
-
Размер его объекта не должен зависеть от количества типов, с которым он создан.
-
Он должен уметь возвращать своё содержимое в типобезопасной манере.
-
Он должен удовлетворять следующему тестовому коду:
using Variant_t = Variant<int, std::string, float, double, char>;
int main()
{
Variant_t var{55};
std::cout << "Var value=" << var.get<int>() << "\n";
var = "hey"s;
std::cout << "Var value=" << var.get<std::string>() << "\n";
var = 42.;
std::cout << "Var value=" << var.get<double>() << "\n";
var = {};
std::vector<Variant_t> variants = {{11}, {'C'}, {"string me"s}};
for(const auto& var : variants )
{
var.match(overloaded {
[](auto arg) { cout << "generic: " << arg << '\n';},
[](int arg) { cout << "int: " << arg << '\n'; },
[](char arg) { cout << "char: " << arg << '\n'; },
[](const std::string& arg) { cout << "string: " << arg << '\n'; }
});
}
}
Этот вариант уже куда интереснее, чем всё, что было раньше: мы можем хранить любые типы, в любых количествах. Т.е. это уже полноценная версия варианта. Осталось разобраться, как нам его реализовать.
Если тип overloaded вызвал вопросы, то можете почитать про эту технику в данном разделе. Это просто удобный и де-факто стандартный способ работы с содержимым вариантов. Если вы посмотрели код в хранилище, то могли заметить там инструкцию выведения типа (англ. deduction guide) для overloaded. Так вот, она там не нужна, потому что в проекте стоит требование C++20, но я оставил её, посчитав, что так может быть нагляднее.
Итак, имея требование иметь возможность создания объекта нашего класса с бесчисленным количеством типов, у нас нет иного выбора, как обратиться к шаблонам с переменным количеством параметров (англ. variadic templates). Так как даже интерфейс класса будет выглядеть громоздко и непонятно, я буду представлять его постепенно, а не как в предыдущих разделах. Начнём с абсолютного минимума:
template<typename... Ts>
class Variant
{
//...
private:
int m_TypeIndex = -1;
// ???
};
У нас имеется шаблон класса с переменным количество параметров, а также селектор m_TypeIndex, хранящий порядковый номер типа, объект которого храниться в нашем варианте (мы уже не можем использовать перечисление, пусть и с обезличенными именами — их число переменно и неизвестно). Если мы создадим пустой объект, то в нём ничего не будет храниться, а значит и селектор будет указывать в никуда (-1). И это всё, что мы можем решить с наскока, потому что это просто и очевидно. А вот дальше начинается интересное: как нам хранить объект неизвестного типа в классе? Что скрывается за вопросительными знаками?
Т.к. статья у нас посвящена объединению, давайте попробуем его применить и здесь. Нам нужно как-то сделать так, чтобы из Variant<int, std::string, float, double, char> получилось что-то такое:
union
{
int a;
std::string b;
float c;
double d;
char e;
};
Можем ли мы как-то получить желаемое в C++? Нет. Мы не можем просто написать union {Ts...}, а потом как-то работать с этими типами. Такого язык не позволяет. Правда, он предлагает решать подобные задачи через наследование и рекурсию, да вот тут другая загвоздка: union не может участвовать в наследовании (class.union.general/p4), а значит никак мы его использовать не сможем. Вот так вот, union не годится даже для того, чтобы сделать нормальную реализацию варианта!
Так как нашей задачей является сохранение любого типа в некий заранее известный тип, само собой напрашивается использование техники сокрытия типа (англ. type erasure)!
Эту технику я описывать не стану, потому что разбирал её подробно в статье «Сокрытие типа». Дальнейший текст подразумевает знакомство читателя с ней.
Начнём мы нашу реализацию с помощью «дубового» варианта сокрытия, который, насколько мне известно, является наиболее популярным: хранить все данные прямо в объекте. Т.к. union у нас отпал, мы пойдём на уровень ещё ниже, и станем использовать массив объектов byte, который будет выравнен согласно требованию максимального выравнивания среди типов варианта, а его размер будет достаточным, для хранения максимально большого объекта. В результате мы получим вот такую основу:
template<typename... Ts>
class Variant
{
//...
private:
int m_TypeIndex = -1;
alignas(Ts...) std::byte m_Storage[std::max({sizeof(Ts)...})];
};
Член m_Storage может хранить всё, что угодно, удовлетворяющее ограничениям выравнивания и размера.
Осталась у нас ещё одна небольшая проблема, которую нужно решить, прежде чем приступать к написанию полного интерфейса и реализации нашего варианта. Очевидно, что у нас будут функции, которые помещают и извлекают значение из объекта, а также функции интересующиеся содержимым. Т.к. у нас используется неопределённые наборы шаблонных параметров, мы должны как-то ограничить наши функции, чтобы они предупреждали пользователя, если он попытается запросить тип, с которым наш объект работать не может. Для этого мы напишем вспомогательную функцию времени компиляции, которая будет возвращать индекс типа в пачке типов, или -1, если такого типа там нет:
template<typename T, typename... Ts>
consteval int getTypeIndex()
{
std::array answers{std::is_same_v<std::decay_t<T>, std::decay_t<Ts>>...};
auto answer = std::find(answers.begin(), answers.end(), true);
return answer != answers.end() ? std::distance(answers.begin(), answer) : -1;
}
Тут всё довольно просто: с помощью std::decay_t избавимся от всех потенциальных модификаторов типа, и сравним T с каждым из пачки Ts. Теперь воспользуемся требованиями для упрощения кода и наконец представим полный интерфейс:
template<typename... Ts>
class Variant
{
public:
Variant() = default;
template<typename T> requires (getTypeIndex<T, Ts...>() != -1)
Variant(T&& value);
Variant(const Variant& rhs);
Variant& operator=(const Variant& rhs);
template<typename T> requires (getTypeIndex<T, Ts...>() != -1)
const T& get() const;
template<size_t Index> requires (Index < sizeof...(Ts))
const auto& get() const;
template<typename T> requires (getTypeIndex<T, Ts...>() != -1)
bool is() const;
template<typename F>
auto match(F&& function) const;
~Variant();
private:
void _copy(const Variant& rhs);
void _destroy();
private:
int m_TypeIndex = -1;
alignas(Ts...) std::byte m_Storage[std::max({sizeof(Ts)...})];
};
Реализацию начнём по порядку; конструирование объекта чем-то похоже на то, как мы делали в предыдущих версиях:
Variant(T&& value):
m_TypeIndex{getTypeIndex<T, Ts...>()}
{
std::construct_at(reinterpret_cast<T*>(m_Storage), std::forward<T>(value));
}
Низкоуровневый подход к хранению требует низкоуровневых средств, таких как reinterpret_cast, но в целом код довольно простой: конструируем переданный нам объект в хранилище, а также сохраняем индекс типа.
Следом идёт реализация копирования, а значит нужно реализовать приватные функции _copy() и _destroy(). Но тут уже не так просто, как мы это делали ранее: для реализации этих вспомогательных функций-членов, нам понадобятся ещё вспомогательные шаблоны функций. Вот как будет выглядеть такой шаблон copy():
template<unsigned currentIndex, typename T, typename... Tail>
void copy(unsigned sourceTypeIndex, const void* source, void* destination)
{
if(sourceTypeIndex == currentIndex)
std::construct_at(static_cast<T*>(destination), *static_cast<const T*>(source));
else if constexpr(sizeof...(Tail) > 0)
copy<currentIndex + 1, Tail...>(sourceTypeIndex, source, destination);
}
Это типичный рекурсивный шаблон, который идёт через всю пачку типов, пока не найдёт нужный индекс, а затем конструирует через копирование нужный объект по адресу destination. delete() будет выглядеть похожим образом:
template<unsigned currentIndex, typename T, typename... Tail>
void destroy(unsigned typeIndex, void* storage)
{
if(typeIndex == currentIndex)
std::destroy_at(static_cast<T*>(storage));
else if constexpr(sizeof...(Tail) > 0)
destroy<currentIndex + 1, Tail...>(typeIndex, storage);
}
Очевидной проблемой данного подхода является то, что каждая операция копирования и удаления пропорциональна количеству типов, с которым вариант создан, т.е. имеем линейную сложность, O(n). Но с тем видом хранения, что мы используем, боюсь, у нас нет другого выбора. Теперь реализуем наши приватные члены:
void _copy(const Variant& rhs)
{
if(rhs.m_TypeIndex != -1)
copy<0, Ts...>(rhs.m_TypeIndex, rhs.m_Storage, m_Storage);
m_TypeIndex = rhs.m_TypeIndex;
}
void _destroy()
{
if(m_TypeIndex != -1)
destroy<0, Ts...>(m_TypeIndex, m_Storage);
}
Как можно было догадаться, они просто делегируют всю работу функциям, которые мы реализовали ранее. Совсем как публичный интерфейс их использующий:
Variant(const Variant& rhs)
{
_copy(rhs);
}
Variant<Ts...>& operator=(const Variant& rhs)
{
_destroy();
_copy(rhs);
return *this;
}
~Variant()
{
_destroy();
}
Хотя по списку у нас дальше идёт функция get(), сначала мы рассмотрим функцию is(), которая отвечает на вопрос: «Содержится ли сейчас в объекте значение заданного типа?». И вот её реализация:
template<typename T>
bool is() const
{
return getTypeIndex<T, Ts...>() == m_TypeIndex;
}
Теперь можно приступать и к get(), которых у нас две: первая возвращает значение по заданному типу (предполагается, что одинаковых типов быть не может, хотя этой проверки в моей версии и нет), а вторая по индексу. Реализация первой использует is(), чтобы удостовериться в том, что наш объект действительно содержит запрашиваемый тип, а потом использует данные содержащиеся в хранилище, предварительно преобразовав их к нужному типу:
template<typename T>
const T& get() const
{
if(is<T>())
return *(reinterpret_cast<const T*>(m_Storage));
throw std::logic_error("Wrong type!");
}
Вторая версия использует стандартные средства для нахождения типа по индексу, а потом делегирует работу версии первой:
template <size_t Index>
const auto& get() const
{
using Type_t = std::tuple_element_t<Index, std::tuple<Ts...>>;
return get<Type_t>();
}
Если бы я реализовывал вариант для реального кода, а не академического интереса ради, я скорее всего не стал бы использовать std::tuple и смежные возможности, потому что это очень тяжёлый тип для компилятора. Эти вспомогательные функции несложно реализовать самостоятельно, но раздувать код статьи ещё больше у меня нет никакого желания.
Осталась у нас последняя функция — match(). Она принимает объект (функтор), который может быть вызван, как функция с одним аргументом любого, из содержащихся в объекте варианта, типа. Для реализации этой функции-члена мы сначала реализуем вспомогательный шаблон с тем же именем:
template<unsigned currentIndex, typename T,
typename... Tail, typename Variant, typename F>
auto match(unsigned typeIndex, const Variant& variant, F&& function)
{
if(typeIndex == currentIndex)
return std::invoke(function, variant.template get<currentIndex>());
if constexpr(sizeof...(Tail) > 0)
return match<currentIndex + 1, Tail...>(typeIndex, variant,
std::forward<F>(function));
}
Это рекурсивный шаблон функции, который принимает индекс искомого типа, объект Variant, где искомый объект должен содержаться, а также функтор, который мы хотим вызвать для этого объекта. Как и в случае с copy()/delete(), мы рекурсивно движемся по пачке типов, а когда находим нужный — используем std::invoke для вызова переданного функтора.
Если кто-то не понимает, что это за конструкция: variant.template get<currentIndex>(), то кратко поясню: т.к. variant у нас зависит от шаблона, то для правильного разбора выражения после . (точки) компилятору требуется указание, что дальше идёт вызов функции, а не операторы сравнения (эта тема хорошо разобрана в книге C++ Templates: The Complete Guide, в главе 13).
Теперь возьмём этот шаблон и реализуем нашу простую и чистенькую функцию-член:
template<typename F>
auto match(F&& function) const
{
if(m_TypeIndex == -1)
return;
return match<0, Ts...>(m_TypeIndex, *this, std::forward<F>(function));
}
За счёт того, что мы спрятали весь «обвес» в другую функцию, вызов нашего интерфейса остаётся простым для пользователя. Косвенные вызовы это вообще краеугольный камень всего, что связано с шаблонами. Здесь это встречается очень часто. Правда, кто-то предпочитает использовать лямбды, реализуя всё в «одной» функции. Я же предпочитаю, когда в одной функции кода как можно меньше.
На этом реализация четвёртой версии завершается — мы реализовали весь шаблон класса. Но покончив с этим кодом, с четвёртой версией мы ещё не прощаемся.
Стандартный вариант
Те, кто знаком с std::variant, не могли не заметить разницу в интерфейсе моей и стандартной версий. И я не говорю о выборе имен функций, а о том, что мой интерфейс полностью состоит из функций-членов, тогда как стандартный состоит преимущественно из т.н. «свободных» функций (англ. free functions). Почему стандартная версия реализует интерфейс через внешние, по отношению к классу, сущности? Ответ тут довольно простой, но и несколько неожиданный. Помните конструкцию variant.template get<currentIndex>() из кода выше? Так вот, в комитете решили избавить пользователей от нужды писать такие конструкции, ведь имея внешний get, код выглядел бы так: get<currentIndex>(variant). Вот и вся причина разницы интерфейсов!
И хотя я понимаю, почему они выбрали такой вариант, я не понимаю, зачем вообще нужно было выбирать — реализуй оба и дай пользователям использовать то, что они посчитают нужным. Неудивительно, что я предпочитаю мой вариант стандартному. Тем не менее я реализовал стандартный подход, и, если интересно, вы можете найти код здесь. Подробно пояснять его не буду по двум причинам: а) изменения там минимальны, б) статья уже получается неприлично большой.
Элегантный вариант
Код для данного подраздела можно найти здесь.
В статье по сокрытию типа, упоминаемой ранее, рассматривался вариант реализации, который я назвал «элегантным». Предлагаю использовать его и для реализации четвертой версии варианта.
Для начала создадим базовый класс, который определит необходимый интерфейс и не будет шаблоном:
class StorageBase
{
public:
virtual std::unique_ptr<StorageBase> clone() const = 0;
virtual void* data() = 0;
virtual std::type_index typeIndex() = 0;
virtual ~StorageBase() = default;
};
Непосредственно данные хранимого объекта будем получать через data(), а тип будет «зашифрован» в числовом значении, возвращаемым typeIndex(). Реализовать этот интерфейс не составит никакого труда:
template<typename T>
class StorageImpl final: public StorageBase
{
public:
explicit StorageImpl(const T& data):
m_Data{data}
{
}
std::unique_ptr<StorageBase> clone() const override
{
return std::make_unique<StorageImpl<T>>(m_Data);
}
void* data() override
{
return &m_Data;
}
std::type_index typeIndex() override
{
return typeid(T);
}
private:
T m_Data;
};
Думаю, что пояснения тут излишни — в коде не используется никаких сложных конструкций. Теперь, вооружившись интерфейсом класса-хранилища, мы готовы заменить громоздкий массив byte на элегантный указатель:
template<typename... Ts>
class Variant
{
//...
private:
int m_TypeIndex = -1;
std::unique_ptr<StorageBase> m_Storage;
};
Т.е. мы храним указатель на объект конкретного, нешаблонного типа, перекладывая проблему хранения неизвестного типа на объект в куче, куда наш m_Storage будет указывать. В качестве бонуса, использование std::unique_ptr позволяет нам не переопределять деструктор, а значит и никаких функций delete() больше не нужно.
Таким будет полный интерфейс:
template<typename... Ts>
class Variant
{
public:
Variant() = default;
template<typename T> requires (getTypeIndex<T, Ts...>() != -1)
Variant(T&& value);
Variant(const Variant& rhs);
Variant& operator=(const Variant& rhs);
template<typename T> requires (getTypeIndex<T, Ts...>() != -1)
const T& get() const;
template<size_t Index> requires (Index < sizeof...(Ts))
const auto& get() const;
template<typename T> requires (getTypeIndex<T, Ts...>() != -1)
bool is() const;
template<typename F>
auto match(F&& function) const;
private:
void _copy(const Variant& rhs);
private:
int m_TypeIndex = -1;
std::unique_ptr<StorageBase> m_Storage;
};
Немного отличаться у нас будет конструирование:
Variant(T&& value):
m_TypeIndex{getTypeIndex<T, Ts...>()},
m_Storage{std::make_unique<StorageImpl<T>>(value)}
{
}
Получаем индекс T, а затем создаём объект StorageImpl<T>, запоминающий T, и прячем его за интерфейсом StorageBase. Также изменится функция _copy():
_copy(const Variant& rhs)
{
if(rhs.m_TypeIndex != -1)
m_Storage = rhs.m_Storage->clone();
else
m_Storage.reset();
m_TypeIndex = rhs.m_TypeIndex;
}
Как вы можете видеть, никаких больше вспомогательных функций (если, конечно, не считать таковой StorageBase::clone()). А вот применение функции _copy() не изменится, поэтому не буду приводить код её использования. Ещё незначительным изменениям подвергнется функция is():
template<typename T>
bool is() const
{
return m_Storage->typeIndex() == typeid(T);
}
Т.е. снова задействовали тот факт, что тип у нас сохранён в недрах, и нам не нужно его каждый раз искать. Помимо простоты реализации, крайне субъективного фактора, объективным преимуществом этой версии является то, что, за исключением match(), все операции над нашим вариантом не зависят от размера пачки Ts, а значит обладают постоянной сложностью, O(1).
Недостатки тоже должны быть очевидны: виртуальные функции, выделение памяти, данные хранятся отдельно от объекта хранения и т.п. Всё как всегда, идеальных вариантов не существует — везде свои плюсы и минусы. Но иметь различные методы в своём арсенале всегда полезно, поэтому, если вдруг вам понадобится собственная реализация, выбирайте с умом, в зависимости от тех потребностей, что вызвали необходимость в оной.