Объединение

В какой-то мере продолжая тему начатую в предыдущей статье, в настоящей хотелось бы поднять тему ещё одного довольно низкоуровневого примитива, доставшегося C++ по наследству, — объединения. union является одним из самых простых классов в C++, но в то же время его использование сопряжено со множество ошибок и мифов, которые закрепились в умах людей за годы его использования (часто неправильного).

Кроме того, помимо своей сложности, объединение часто всплывает в темах по правилу строгого соответствия (ПСС, англ. strict aliasing rule), а мы как раз эту тему и рассматривали в прошлый раз.

Что вы, мистер union?

Прежде чем перейти непосредственно к языковому C++-примитиву, предлагаю рассмотреть, что же эта сущность из себя представляет концептуально. В информатике есть понятие алгебраических типов данных, с двумя яркими представителями: тип-сумма и тип-произведение.

Аналогия с тумбочкой и сейфом

Тип-сумму можно представить, как некий объект (к примеру, сейф), в котором есть только один слот и в который можно поместить один и только один объект в единицу времени. Т.е. если у нас есть, к примеру, клавиатура, мышка и телефон, то мы можем поместить что-то одно в сейф (пусть это будет мышь), но если нам нужно поместить что-то иное, то сначала нужно вынуть мышь и только потом помещать другой объект. Сейф всегда обладает нужным размером, который является достаточным для содержания самого большого из возможных объектов, но и только. Зачем нам нужен сейф, который может хранить мешок картошки, когда мы ничего больше клавиатуры туда класть не собираемся? Так и тип-сумма должен обладать возможностью хранить любой объект из списка возможных, но ему нет нужды быть ни капли больше. Это его основное свойство.

Тип-произведение, напротив, может хранить все вещи из списка одновременно, и его мы можем представить, как обычную тумбочку с выдвижными ящиками. Где каждый ящик имеет определённый размер и может хранить конкретный объект, но только его! Т.е. если у нас средний ящик представлен для хранения клавиатуры, а вы попытаетесь втиснуть туда телефон, то у вас ничего не выйдет.

На секунду отвлечёмся от аналогий и вернёмся в мир языков программирования, где вышеозначенное утверждение может оказаться неверным. В «законопослушном» ЯП вам действительно не удастся поместить «телефон» в «ящик» для «клавиатуры», а вот в таком языке как C++ вы вполне можете это осуществить. Но язык не гарантирует, что применив силу, и всё-таки затолкнув объект туда, куда его заталкивание не предполагается, вы не сломаете, как сам объект, так и целевое хранилище. Всё как и с реальными объектами.

Основным достоинством тумбочки перед сейфом является то, что в неё мы можем спрятать все необходимые объекты, чтобы они нам не мешали. Но есть и недостаток: размер. Тумбочка, как и сейф, всегда одного размера, но если сейф пропорционален размеру наибольшего объекта, то тумбочка — размеру всех объектов. Кроме того, если у нас нет ни одного объекта, или же только один, то место будет занято, как под сейф, так и под тумбочку, но сейф при этом гораздо компактнее. Разумеется, сейф и тумбочка друг друга не взаимозаменяют; они созданы для использования в разных ситуациях, у которых разные изначальные условия. Также нужно понимать, что сейф можно поместить в одно из отделений тумбочки, как и тумбочку можно поместить в сейф, и вложенность тут не ограничена (исключая физические ограничения, естественно)!

Понимаю, что представленная выше натурная аналогия не всем могла прийтись по душе, поэтому вернёмся к информатике. Самым простым примером из информатики является обычная таблица: строка таблицы представляет собой ничто иное, как тип-произведение (тумбочка), а ячейка строки — тип-сумму (сейф). Если же обратиться непосредственно к языку C++, то в самом примитивном варианте реализацией типа-произведения является C-подобная struct, а типа-суммы — union:

Структура и объединение

А вот как мы могли бы описать тип таблицы в языке C++:

union Cell
{
    int integer;
    double floatingPoint;
    char character;
};

struct Record
{
    Cell first;
    Cell second;
    Cell third;
};

struct Table
{
    Record first;
    Record second;
};

Здесь у нас есть всё: тип-сумма (Cell), типы-произведения (Record и Table), а также взаимодействие между ними (агрегация).

Выше я не зря упомянул именно C-подобную struct, т.к. C++-подобная structclass) выходят далеко за рамки простых алгебраических типов, а вот union находится на другой стороне спектра: он вообще не является достойным представителем концепции типа-суммы. И это всё хорошо видно в коде C++-приложений: struct используется повсеместно, а вот union такой популярностью похвастаться не может. Конечно, я покривлю душой, если не напомню, что популярность struct обеспечена скорее ООП, чем достойной реализацией концепции типа-произведения, но это не вся история. В конце концов, хотя struct и является крайне примитивной реализацией математической концепции, она всё-таки является полноценной и самодостаточной. std::tuple, на мой взгляд, лучше отражает эту концепцию, но он является лишь обобщением того, что можно было делать и до его появления, используя «голый» struct (достаточно посмотреть на язык C).

C union дела обстоят иначе. Это явно не достойная реализация типа-суммы в языке C++, его даже не получится использовать в основе std::variant. Но мы к этому ещё вернёмся, а пока закончим с лирическим вступлением и перейдём непосредственно к «мясу».

Объединение в языке C

Хотя эта статья и по C++, настоящий раздел будет посвящён union в языке C. Потому что без понимания истоков невозможно понять почему union является тем, чем является, а также почему случается путаница с тем, как его можно использовать, а как нельзя.

Итак, union изначально задумывался, как аналог вариативных (variant) типов существовавших в языках того времени. Т.е. причиной появления этого типа данных на свет являлась реализация алгебраического типа-суммы. Нужно понимать, что язык C разрабатывался тогда, когда не было принято в каждое приложение засовывать браузер, поэтому старались экономить на всём, чём можно. И одним из ключевых моментов была потребляемая память, так что основополагающие принципы union, можно сказать, написали сами себя:

  1. Все члены union должны располагаться по одному и тому же адресу.

  2. Размер объекта union должен быть достаточен для размещения любого его члена (но не больше необходимого).

  3. Выравнивание объекта union должно быть достаточным для размещения любого его члена.

Ключевым тут является пункт 2, а два других это скорее следствие и детали реализации. Но, как вы можете видеть, в этом списке нет ни слова о том, как мы должны понимать, что же хранится в нашем объекте. Давайте возьмём такой простой код:

union Cell
{
    int integer;
    double floatingPoint;
    char character;
};

void print(Cell cell)
{
    /*
    Что нам тут писать?
    */
}

Простейшая задача: распечатать содержимое объекта, но мы даже этого сделать не можем — мы не имеем понятия, что там хранится. Поэтому использовать union изначально предполагалось совместно с другим объектом, который бы хранил знание о содержимом своего «партнёра» — union был задуман калекой! Учитывая декларируемые цели типа (аналог вариативных типов того времени), для меня совершенно не ясно, почему был выбран именно такой подход. Да, «покалеченный» объект union занимает меньше места в памяти, но его целевое использование невозможно; с таким же успехом можно было ничего не создавать, это бы вообще места в памяти не занимало!

Таким образом, чтобы начать использовать наш Cell, нужно добавить дискриминатор (тэг), т.е. можно сделать как-то так:

enum CellTag
{
    eUnknown,
    eInteger,
    eFloat,
    eChar
};
//...
Cell cell;
cell.character = 'N';
CellTag tag = eChar;
//...
print(cell, tag);

Где print станет выглядеть следующим образом:

void print(Cell cell, CellTag tag)
{
    switch(tag)
    {
    case eInteger:
        printf("%d\n", cell.integer);
        break;
    case eFloat:
        printf("%f\n", cell.floatingPoint);
        break;
    case eChar:
        printf("%c\n", cell.character);
        break;
    default:
        printf("Ooops?!\n");
    }
}

Этот код имеет следующие проблемы:

  1. Отсутствует связность. Т.е. мы не можем никак связать tag с cell. Мы условились, что они связаны, но это никак не закреплено.

  2. Отсутствует согласованность. Т.е. мы легко можем сделать так:

    Cell cell;
    cell.character = 'N';
    CellTag tag = eFloat;
    print(cell, tag);

    И мы не получим никакого предупреждения, ничего.

Первую проблему мы можем решить довольно малой кровью, для этого всего лишь нужно ввести охватывающую сущность:

struct Cell
{
    CellTag tag;
    union
    {
        int integer;
        double floatingPoint;
        char character;
    } value;
};

Теперь у нас содержимое объединения связано с дискриминатором, поэтому они больше не смогут «потеряться». В таком виде мы получаем минимальное подобие вариативных типов из ЯП 60-х годов! Самостоятельный union даже им не является. Но с этим кодом есть ещё одна проблема: структуры очень трепетно относятся к расположение элементов, из-за чего их размер может меняться в зависимости от того, в каком порядке они расположены (я писал об этом ранее). Поэтому автору подобной структуры нужно учитывать это обстоятельство. Но если бы это было реализовано в самом языке, то всё бы решалось автоматически и было бы скрыто от человека, источника ошибок. Да и с размером самого дискриминатора прогрессивный компилятор мог бы что-то сделать, в отличии от массового программиста.

Итак, мы решили первую проблему, но что делать со второй? Вторую силами одних типов данных не решишь, тут уже нужно подключать инкапсуляцию и «методы», но я этим заниматься не буду, потому что это выходит за рамки статьи. Таким образом, получается, что даже доработанная нами версия union является весьма посредственной реализацией типа-суммы и явно уступает даже тому, чьим аналогом, по словам Кернигана/Ричи, union является — variant record из Pascal.

Разложение

Поговорив о том, как union задумывался, давайте перейдём к тому, как он на самом деле используется. Я не берусь утверждать наверняка, т.к. не обладаю необходимой статистикой, но исходя из своего опыта и того, что я видел в сети, использование union в качестве основы для типа-суммы составляет ничтожную часть от методов его применения.

Хотя union и задумывался, как тип данных, на деле получилось, что используется он, как метод разложения полных объектов на составные части. Давайте рассмотрим банальный пример:

union Color
{
    uint64_t rgba;
    struct
    {
        uint8_t a;
        uint8_t b;
        uint8_t g;
        uint8_t r;
    };
};

Мы использовали объединение, которое состоит из двух объектов: 64-битного объекта rgba и 64-битного объекта безымянной структуры, которая разбита на 4 8-битных члена.

Безымянность тут не является ключевым моментом, это просто удобно. Зачем нам промежуточный член при обращении, если можно обойтись без него?

А вот как мы можем наш новый тип использовать:

Color color;
color.rgba = 0xFFEEDDCC;
color.a = 0x11;
printf("R: %x, B: %x, RGBA: %x\n", color.r, color.b, color.rgba);

Можно ещё такой пример привести, переведя amd64 регистры на рельсы объединения:

union Register
{
    uint64_t rax;
    uint32_t eax;
    struct
    {
        uint8_t al;
        uint8_t ax;
    };
};

Здесь у нас 3 члена, каждый из которых в 2 раза меньше предыдущего и использует младшую часть предшественника — всё как у реального регистра.

Но разложение объекта на составные части — не единственный вариант использования union, можно ещё устроить «игры с типами»:

union Real
{
    float floatingPoint;
    uint32_t hexRepresentation;
    struct
    {
        uint32_t fraction: 23;
        uint32_t exponent: 8;
        uint32_t sign: 1;
    };
};

В этом коде мы имеем основной член union floatingPoint и два «рабочих» члена:

  • hexRepresentation используется для того, чтобы посмотреть, как выглядит число с плавающей точкой в шестнадцатеричной системе.

  • Безымянная структура служит для того, чтобы мы могли изучить отдельные части нашего числа, и, возможно, модифицировать его через них.

Здесь мы используем и разложение, и другую интерпретацию (англ. reinterpretation) типа вместе. Вот, как мы могли бы использовать наше объединение:

Real data; // №1
data.floatingPoint = -1.2;// №2
data.sign = 0; // №3
printf("float: %f, hex: %x, fraction: %x, exponent: %x, sign: %x\n",
    data.floatingPoint, data.hexRepresentation, data.fraction, data.exponent, data.sign);

Довольно удобно, не правда ли? Вот и разработчики на C так думают и повсеместно используют эту технику.

Но почему такой подход работает? Давайте разбираться, но сначала немного подтянем терминологию (для удобства, это не терминология стандарта C!). Т.к. в объединении существуют несколько членов, они делятся на 2 группы: активные и неактивные. Причём в группе активных может быть только один член, а в другой — сколько угодно. Активным членом объединения является тот член, в который последним была произведена запись, а неактивными являются все остальные. Т.е. если мы возьмём код выше, то после выполнения №1 все члены неактивны, после №2 появляется активный член floatingPoint, а после №3sign.

С самого начала в языке C чтение активного члена объединения было однозначно определено: то, что записано, то и будет прочитано, но чтение неактивного члена зависело от реализации. Тем не менее, благодаря удобству этого метода, все основные реализации должны были (потому что в коде UNIX был такой код) поступать предсказуемо: читать неактивный член объединения можно и результат будет ожидаемый. Справедливости ради, учитывая то, насколько тонкой надстройкой union является над обычный массивом char, реализовать его иначе было бы достаточно сложно. Однако, спецификация есть спецификация, а значит она должна была тоже подтянуться и вот, что мы имеем сейчас (6.5.2.3/p3): читать неактивный член объединения можно, при этом та часть записанного (см. запись в floatingPoint), что читается, будет интерпретирована в качестве типа читателя (см. вывод неактивных членов Real), но при этом остаётся возможность попадания в ловушку (англ. trap representation). Если же производится попытка чтения того, что не было записано (к примеру, записали только fraction, а читаем весь floatingPoint), то результат чтения неспецифицирован, но он не должен приводить к срабатыванию ловушки.

В языке C понятие содержимого объекта, которое может спровоцировать срабатывание ловушки, описано в 6.2.6.1/p5: это некий битовый паттерн конкретного типа, чтение которого (через что-либо отличное от char) приводит к неопределённому поведению (НП, англ. undefined behavior). Мне сложно оценить, насколько актуальным это понятие является сейчас, т.к. сам я с этим не сталкивался, но если верить документу, то не очень.

Для тех людей, кто читал мою предыдущую статью (или кто просто знает о правиле строгого соответствия (ПСС, англ. strict aliasing rule)) вышеописанное может выглядеть несколько странно, ведь мы знаем, что читать один объект посредством другого нельзя! Зато это отличный пример низкоуровневого примитива, который наглядно показывает, что нельзя писать «язык C/C++».

Объединение в языке C++

Итак, мы рассмотрели очень интересный метод разложения объекта посредством union и убедились, что это довольно удобно. Но мы не рассмотрели минусы такого подхода, которые, несомненно, присутствуют.

Самым очевидным минусом является полное отсутствие типобезопасности (англ. type safety): что-то куда-то пишем, потом что-то читаем и чего-то получаем. И это хорошо, когда у нас весь код помещается в экран и элементарен, а что будет, когда всё это размазано по разным файлам и тысячам строк? Никто нам не поможет, кроме внимательности и тестов. А как показывает практика, что внимательность, что тесты всегда в дефиците.

Другой минус заключается в том, что весь код, приведённый в предыдущем подразделе, абсолютно не переносим. Давайте возьмём структуру цвета и посмотрим, что мы можем о ней сказать:

union Color
{
    uint64_t rgba;
    struct
    {
        uint8_t a;
        uint8_t b;
        uint8_t g;
        uint8_t r;
    };
};

Мы использовали максимально переносимые типы с явным указанием их битового размера, но сделали ряд допущений:

  • В архитектуре, где будет работать наш код, запись многобайтовых типов происходит от младшего байта к старшему (англ. little-endian).

  • Компилятор не добавит никакого «мусора» (англ. padding) между членами нашей безымянной структуры.

Нарушение любого из этих допущений ведёт к тому, что приложение, использующее нашу структуру, становится некорректным. Мы использовали язык программирования высокого уровня для написания непереносимого кода, полностью заложившись на конкретную архитектуру, а это уже прерогатива низкоуровневых ассемблеров, а не ЯВУ. И ладно бы это использовалось редко и с осторожностью — нет, как я уже говорил, это удобно и C-программисты совершенно разной квалификации используют этот метод без особых раздумий.

Мы очень часто пишем программы, делая определённые допущения: API системы, наличие определённых примитивов (процессы, потоки, окна), изолированность среды и т.д. и т.п. Но нарушение большинства из этих допущений приведёт к тому, что программу мы либо не соберём вообще, либо она просто-напросто не запустится. Использование union для разбора или преобразования типов может скрывать ошибку очень долго и обнаружить её может быть не так легко. Подобный код порождает самые опасные ошибки: неявные и труднообнаруживаемые.

Понимая всю опасность этого метода, C++ его явно запрещает (class.union.general/p2): из объекта union можно читать только активный объект, чтение неактивного члена приводит к НП (и это уже терминология стандарта). И вот эта разница породила очень много некорректного кода на C++. Я никогда не работал над C-проектом, но такой код видел не раз, потому что люди думают, что так делать можно. И некоторые компиляторы им в этом потворствуют (gcc, к примеру), что позволяет дальше разносить скверну: «Этот код у меня работает!». Запомните, что в C++ единственно возможный вариант использование union, это использование его в качестве типа-суммы и ничего больше. Тот факт, что написание некорректного кода сходит вам с рук не делает его корректным.

Долгое время это изменение было единственным [1], что отличало C-union от C++-union, и это продолжалось до выхода стандарта C++11, когда с объединений решили снять ограничения, которых там изначально не должно было быть. В C++ union всегда был «младшим» братом class/struct — они все коллективно называются классами (англ. classes). Но из-за нижестоящей роли в иерархии union имел ряд ограничений, которые мы подробно разбирать не станем, т.к. в этом нет ничего интересного. Остановимся на основном ограничении, которое, на мой взгляд, являлось наиболее несправедливым.

До 11 года C++ запрещал хранить в union объекты типов, у которых был нетривиальный конструктор по умолчанию, или деструктор (или ещё пара других членов). Наиболее любопытные могут поискать определение нетривиальности в соответствующем стандарте, но, в сущности, это означало наличие пользовательского конструктора/деструктора и т.д. При этом union всегда являлся классом, а значит сам мог иметь и деструкторы, и конструкторы, и всё другое прочее. Поэтому мне непонятно, чем были вызваны вышеозначенные ограничения. Видимо, всё дело в ограниченности времени: нужно было заняться объединениями, но людям было не до них.

В C++11 ситуация изменилась, и теперь в union можно хранить типы с нетривиальными специальными функциями-членами, но наличие тако��о типа в объединение удаляет (= delete) соответствующий член самого объединения. Т.е. имея вот такое просто объединение:

union SickNTired
{
    int integer;
    std::string text;
};

Мы не сможем использовать объекты типа SickNTired ни в C++03, ни в C++11, но причины будут разными. И в C++11 у нас есть метод «починки» нашего типа, а в C++03 — нет. Чтобы вернуть наш union к жизни достаточно явно объявить необходимые члены, которые были удалены:

union SickNTired
{
    SickNTired()
    {}
    ~SickNTired()
    {}
    int integer = 0;
    std::string text;
};

Теперь мы можем создавать объекты SickNTired и оперировать ими! Или нет. Всё, что делает код выше — позволяет создать объект SickNTired и больше ничего. Для копирования и остального нужны соответствующие члены, которые сейчас так же являются удалёнными. Более того, как вы думаете назначать члены этого объединения? Попробуйте подумать, прежде чем читать дальше.

Наивным подходом мог бы быть такой:

SickNTired sick;
sick.integer = 2;
sick.text = "DISASTER!";
std::cout << sick.text;

Какие у нас проблемы с этим кодом? Проблема первая: sick.text = "DISASTER!". Эта строчка является синтаксическим сахаром для такого выражения: sick.text.operator=(std::string{"DISASTER!"}). Т.е. мы вызываем operator= для объекта sick.text типа std::string. И всё было бы ничего, но кто создал наш объект sick.text? Никто его не создавал! Как я уже говорил, union является классом, но с кучей своих особенностей. Одной из таких особенностей является то, что члены объединения являются не просто членами (англ. members), а вариативными членами (англ. variant members).

Стандарт C++ проводит чёткую границу между двумя типами членов классов и явно указывает (class.base.init/p9.2), что неявной инициализации вариативных членов не происходит. Т.е. если вы хотите инициализировать вариативный член, вы должны это делать явно, а в конструкторе SickNTired этого не происходит для объекта text, но происходит для объекта integer, т.к. для него явно задано значение по умолчанию. Разумеется, только один вариативный член класса может быть инициализирован, в противном случае получите ошибку компиляции, но это должно быть интуитивно понятно. Поэтому, чтобы наш пример работал, нам нужно явно создавать и инициализировать объект sick.text, и мы, конечно же, разберёмся, как это делать правильно и надёжно, но пока давайте схитрим и состряпаем такое «решение»:

union SickNTired
{
    SickNTired()
    {}
    ~SickNTired()
    {}
    int integer;
    std::string text{"Some text"};
};
//...
SickNTired sick;
sick.text = "ME LEAKING!";
std::cout << sick.text;

Такой пример соберётся и будет работать; у нас больше нет НП, потому что operator= вызывается для ранее созданного (в конструкторе SickNTired) объекта text. Но в этом коде всё равно присутствует проблема: он «протекает». Как вы можете знать, правила деструкторов обычно симметричны правилам конструкторов, а это значит, что деструктор класса не вызывает деструкторы вариативных членов (class.dtor/p13). Таким образом, хотя у нас и есть деструктор ~SickNTired(), ранее созданный объект sick.text никогда не будет уничтожен.

Вот такие интересные изменения у нас для объединений в C++, относительно C: часть возможностей убрали, а оставшиеся несколько адаптировали к более высокоуровневому языку. Правда, по умолчанию всё равно мало что работает и нужно многое дорабатывать, чтобы его использовать. Поэтому предлагаю вооружиться напильником и наконец сделать нормально работающий в C++ тип-сумму, который мы назовём Вариантом (англ. variant).

Варианты вариантов

В данном разделе мы рассмотрим, как починить представленный ранее SickNTired, только на примере другого класса — Variant. Но на этом не ограничимся, а рассмотрим несколько версий, начиная с простейшей и заканчивая наиболее сложной, приближенной к стандартной версией.

Версия первая

Код для данного подраздела можно найти здесь.

Прежде чем мы перейдём к коду, давайте зададим несколько условий для версии 1.0:

  1. Наш вариант должен поддерживать 2 типа: int и std::string, т.е. один «простой», и один «сложный». Такого варианта достаточно для рассмотрения типичных узких мест.

  2. Основным типом должен быть union, т.е. никакого оборачивания в другие классы.

  3. Он должен уметь сообщать о своём содержимом и возвращать его в типобезопасной манере.

  4. Он должен удовлетворять следующему тестовому коду:

    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 в качестве основного класса.

Посмотреть требования:
  1. Наш вариант должен поддерживать 2 типа: int и std::string, т.е. один «простой», и один «сложный». Такого варианта достаточно для рассмотрения типичных узких мест.

  2. Основным типом должен быть union, т.е. никакого оборачивания в другие классы.

  3. Он должен уметь сообщать о своём содержимом и возвращать его в типобезопасной манере.

  4. Он должен удовлетворять следующему тестовому коду:

    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 (они не использовали безымянное объединение, но и «сложных» типов не хранят). И хотя мы достигли «эталона», данная версия является слишком ограниченной: почему у нас явно заданы типы, которые мы можем хранить в нашем варианте? Определённо, нам есть куда стремиться, а значит мы двигаемся дальше.

Версия третья

Код для данного подраздела можно найти здесь.

Требования для третьей версии будут выглядеть следующим образом:

  1. Наш вариант должен поддерживать 2 любых типа, которые задаются на этапе создания.

  2. Он должен уметь возвращать своё содержимое в типобезопасной манере.

  3. Он должен удовлетворять следующему тестовому коду:

    int main()
    {
        Variant<int, std::string> var{55};
        std::cout << "Var value=" << var.value<int>() << "\n";
        var = "hey"s;
        std::cout << "Var value=" << var.value<std::string>() << "\n";
        var = 42;
        std::cout << "Var value=" << var.value<int>() << "\n";
        var = {};;
    }

Пункт 2 получился менее требовательным, чем был ранее — мы не требуем возврата типа того, что мы храним. Сделано это потому, что простого метода преобразования неизвестного типа во что-то удобоваримое в C++ нет, а сложные решения (скорее, «костыли») для нашей задачи избыточны. В целом же требования явно намекают, что третья версия будет ничем иным, как шаблоном. Вот, как будет выглядеть интерфейс шаблонного Variant:

template<typename FirstT, typename SecondT>
class Variant
{
public:
    Variant();
    Variant(const FirstT& value);
    Variant(const SecondT& value);
    Variant(const Variant& rhs);
    Variant& operator=(const Variant& rhs);
    ~Variant();
    template<typename Type>
    const Type& value() const;
private:
    union
    {
        FirstT m_First;
        SecondT m_Second;
    };
    enum Type{None, First, Second};
private:
    void _copy(const Variant& rhs);
    void _destroy();
private:
    Type m_Type;
};

Легко заметить, что отличия от ранее рассмотренной версии минимальны: пропала функция type(), функции возврата данных коллапсировали в единый шаблон функции value(), да имена дискриминаторов стали обезличенным, отражая шаблонность нашего решения. Также, в связи с тем, что у нас теперь оба члена могут требовать явного вызова деструктора, была добавлена вспомогательная функция _destroy(). Вот как будут выглядеть копирование и разрушение:

void _copy(const Variant& rhs)
{
    if(rhs.m_Type == First)
        std::construct_at(&m_First, rhs.m_First);
    else if(rhs.m_Type == Second)
        std::construct_at(&m_Second, rhs.m_Second);
    m_Type = rhs.m_Type;
}

void _destroy()
{
    if(m_Type == First)
        std::destroy_at(&m_First);
    else if(m_Type == Second)
        std::destroy_at(&m_Second);
}

Абсолютно ничего примечательного. Осталось только рассмотреть функцию value(), т.к. в ней есть хоть что-то новое:

template <typename FirstT, typename SecondT>
template <typename Type>
const Type& value() const
{
    if constexpr(std::is_same_v<Type, FirstT>)
    {
        if(m_Type == First)
            return m_First;
    }
    else if constexpr(std::is_same_v<Type, SecondT>)
    {
        if(m_Type == Second)
            return m_Second;
    }
    assert(false);
    throw std::logic_error{"oops?!"};
}

Очевидно, что никакого сопоставления между загадочным членом перечисления First и реально хранимым типом объекта не существует, поэтому приходится сравнивать и соответствие запрашиваемого типа одному из возможных, и его реальное там наличие. И это всё, что можно выжать из третьей версии; так что переходим к версии четвёртой.

Версия четвёртая

Код для данного подраздела можно найти здесь.

Требования для четвёртой версии будут выглядеть следующим образом:

  1. Наш вариант должен поддерживать бесчисленное количество любых типов, которые задаются на этапе создания.

  2. Размер его объекта не должен зависеть от количества типов, с которым он создан.

  3. Он должен уметь возвращать своё содержимое в типобезопасной манере.

  4. Он должен удовлетворять следующему тестовому коду:

    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).

Недостатки тоже должны быть очевидны: виртуальные функции, выделение памяти, данные хранятся отдельно от объекта хранения и т.п. Всё как всегда, идеальных вариантов не существует — везде свои плюсы и минусы. Но иметь различные методы в своём арсенале всегда полезно, поэтому, если вдруг вам понадобится собственная реализация, выбирайте с умом, в зависимости от тех потребностей, что вызвали необходимость в оной.

Заключение

Мы начали статью с рассмотрения union с разных сторон, а закончили кодом, где этой сущности нет и в помине. Как-то так я и вижу участие этого «класса» в C++-коде: да, он был в истории, и кода старого тоже много, но если сейчас вам нужно использовать тип-сумму, его там быть не должно. По умолчанию вы должны обращаться к std::variant, потому что это последние достижение C++ в мире вариантов. Лично я бы хотел другого, не библиотечного, но типа в самом языке, с возможностью использовать его в switch и прочих языковых конструкциях. Но имеем то, что имеем, а это уже больше, чем было. Может, мы даже когда-нибудь сопоставление по образцу увидим, но пока будем обходиться тем, что есть.

Так что, у union вообще не осталось применений? Остались, но только в глубине библиотек — там, где не ступает нога неофита. К примеру, union может применяться в реализации std::optional, для оптимизации малых строк (англ. small string optimization) в std::string и т.п.


Обновление от 11.03.2024:

В статье содержится неверное утверждение, что union нельзя использовать при создании варианта. Как оказалось, это не так: можно использовать рекурсивную композицию, что позволяет воспользоваться услугами union при реализации. Более того, все основные библиотечные реализации std::variant именно так и поступают. Почему? Потому что в реализации с массивом необходимо использовать reinterpret_cast, который запрещено использовать в constexpr-функциях, а std::variant, в свою очередь, является constexpr-классом! 

Пример реализации можно посмотреть у Microsoft. Я его детально не разбирал, но выглядит отвратительно. Тем не менее требования есть требования. Детально вариант с union я когда-нибудь разберу подробно и обновлю статью.


[1] Ещё в C++-union можно было добавить конструктор, деструктор и другие функции-члены.