Вступление

C++ 17 привнес в язык достаточно много нововведений, в том числе шаблон std::variant (хоть в Boost он есть уже довольно давно). Фактически, последним вышедшим и полноценно реализованным стандартом C++ на тот момент, как я начал изучать данный язык, являлся как раз C++17, поэтому нововведениям данного стандарта в свое время я уделил наибольшее внимание.
В какой-то момент мне стало интересно, как именно устроен std::variant, в связи с чем я немного погуглил про его принципиальное устройство и, вооружившись variadic templates, сел писать свою реализацию. Данный шаблон устроен достаточно интересно, поэтому людям, вообще не знакомым с его устройством, данная статья будет полезна. Если данную статью прочитают более опытные разработчики, я буду рад их комментариям по поводу моей реализации.
Упомяну несколько моментов перед началом статьи:

  • реализована возможность вызвать произвольный функтор для хранимого в std::variant объекта

  • можно передавать объекты заданных типов

  • естественно, хранимые объекты правильно удаляются

  • с использованием C++ 20 код можно сделать красивее, но в данной статье будет использоваться именно 17 стандарт

  • перечисленный далее функционал НЕ реализован: множественный вызов различных std::variant объектов (потихоньку делаю, но это достаточно непросто. Если не забью и доделаю, то будет небольшое продолжение статьи); emplace создание; весь прочий вспомогательный функционал

  • автор ни в коем случае не претендует на оптимальность реализации. Статья рассчитана на тех, кто не знаком/плохо знаком с variadic templates и тем, как его вообще использовать, а также плохо знаком с std::variant

  • предполагается, что читатель хотя бы поверхностно знаком с метапрограммированием

Принципиальное устройство

Перед тем, как приводить какой-то код хочется упомянуть, что используется в основе вариантного типа. Как известно (а может быть, кому то и неизвестно), размер std::variant зависит от максимального размера среди переданных типов. Уже из этого свойства можно предположить, что внутри используется буфер, размер которого равен размеру наибольшего из типов.
До C++ 11 реализовать вариантный тип было достаточно нетривиальной задачей (например, один из возможных костылей - задать шаблон с большим количеством параметров типов с присвоенным значением по умолчанию всем, кроме первого), однако, с появлением variadic templates сложность написания данного кода сильно уменьшилась.
Каким образом хранить информацию о том, какой именно тип данных хранится в экземпляре вариантного типа? Для этой задачи можно использовать индекс, сопоставляя его с порядком переданных в шаблон типов.
Заодно, в дальнейшем с помощью индекса можно будет достаточно легко вызывать специализацию функтора под нужный тип, используя массив указателей.
Осталось только написать код, а также решить некоторые нюансы реализации

Начнем писать код

Для начала предлагаю реализовать вспомогательные функции – нам нужно уметь находить максимальный размер среди переданных типов, а также уметь ассоциировать тип с некоторым индексом. Делать это нужно в compile-time, особенно в случае функции нахождения максимального размера

template <std::uint32_t CurMax, class T, class...Types>
constexpr std::uint32_t FindMaxSize() {
  constexpr std::uint32_t sZT = sizeof(T);
  if constexpr (sZT > CurMax) {
    if constexpr (sizeof...(Types) != 0) {
      return FindMaxSize<sZT, Types...>();
    } else {
      return sZT;
    }
  } else {
    if constexpr (sizeof...(Types) != 0) {
      return FindMaxSize<CurMax, Types...>();
    } else {
      return CurMax;
    }
  }
}

Разберем данную фукнцию. Она рекурсивно проходит по переданным типам, находя наибольший размер. При этом, в функции используются constexpr вычисления, за счет чего мы можем использовать результат выполнения данной функции для определения размера std::array. Шаблон принимает в себя максимальный размер типа с предыдущей итерации (руками явно передаем 0 при вызове данной функции), а также конструкция вида class T, class… Types позволит нам отщипывать по одному типу в каждой следующей итерации, а также гарантирует, что был передан хотя бы один тип (class… Types может принимать и 0 типов). На основании размера текущего рассматриваемого типа мы решаем, какой размер передать дальше – максимальный размер с предыдущей итерации или же текущий размер. Условие остановки рекурсии – размер Types… равен 0. Результат выполнения – максимальный размер.
Теперь напишем функцию определения индекса конкретного типа:

template <std::int16_t curN, class T, class Val, class... Vals>
constexpr std::int16_t FindNumber() {

  if constexpr (std::is_same_v<std::decay_t<T>, std::decay_t<Val>>) {
    return curN;
  } else if constexpr (sizeof...(Vals) != 0) {
    return FindNumber<curN + 1, T, Vals...>();
  } else {
    return -1;
  }
}

Принципиальное устройство идентичное. Класс T – это тип, индекс которого мы определяем, конструкция class Val class… Vals позволит нам итерировать. Однако, в данном коде есть один нюанс, который необходимо рассмотреть. А именно, вызов std::decay_t. Если данная функция в том или ином виде будет вызываться в шаблонном коде (а она будет в нем вызываться), то, например, Val может иметь тип Foo, а T – const Foo. В этом случае, std::is_same_v без использования std::decay_t выдаст false, так как у типов отличаются квалификаторы. Вряд ли такое поведение будет являться ожидаемым. Поэтому, квалификаторы при сравнении типов лучше отбросить. И действительно – std::variant позволяет использовать типы с отличными от заданных квалификаторами, так что данное поведение является более менее корректным.
Благодаря данным функциям, мы можем написать следующий код:

template <class... PTypes> 
class MyVariant {
private:
  std::int16_t curT;
  std::array<char, FindMaxSize<0, PTypes...>()> data;

public:
  MyVariant() : curT(-1) {}
  template <class T> MyVariant(T &&val) {
    constexpr auto nextN = FindNumber<0, T, PTypes...>();
    static_assert(nextN != -1, "Uknown type passed");
    using valueT = std::decay_t<T>;
    new (this->data.data()) valueT(std::forward<T>(val));
	this->curT = nextN;
  }
};

Также договоримся, что раз уж индексация типов начинается с 0, то значение -1 будет обозначать отсутствие типа. В данном случае, дефолтный конструктор указывает, что никакой объект не хранится.
Что делает конструктор копирования\перемещения:

  • находим индекс типа

  • если тип не найден, то уведомляем об этом с помощью static_assert

  • если все хорошо, то создаем данный тип, используя буфер. Особенности создания: использование std::forward(val) для прямой передачи. Таким образом, мы реализуем и конструктор копирования, и конструктор перемещения; память выделяется в буфере за счет вызова new(указатель на буфер) ТипДанных(аргументы); на всякий случай отбрасываем квалификаторы при создании

  • присваивание индекса происходит именно после успешного создания объекта.

Хочу обратить ваше внимание на последнем пункте. Дело в том, что пользовательский тип довольно легко и непринужденно может выбрасывать исключения. А так как индекс заодно в дальнейшем будет использоваться и для правильной очистки хранимого объекта, то обратный порядок может привести к попытке очистить не сконструированный объект. Что из этого выйдет – неизвестно.

И вот мы плавно подошли к одному из самых важных моментов – очистка памяти. Утечки памяти – это нехорошо, поэтому мы должны быть уверены в правильном удалении.
Определим вспомогательную шаблонную функцию:

template <class T> void Clearer(void *data) {
  T *casted = reinterpret_cast<T *>(data);
  casted->~T(); 
}

Она максимально простая: принимается void* data и тип T, который фактически является настоящим типом объекта. Зачем это нужно? Подобным маневром мы потом сможем определить специализации данного шаблона и хранить их в одном массиве, просто вызывая нужную фукнцию очистки по индексу, хранимому внутри нашего вариантного типа. Есть один момент, на который мы обязаны обратить внимание – данная функция должна уметь вызывать деструктор объекта. Впрочем, данное ограничение не является чем-то необычным, поэтому проблемой это не является.
С использованием данной вспомогательной функции мы можем определить следующий публичный метод нашего класса MyVariant:

bool TryClear() {
    bool cleared = false;
    if (this->curT != -1) {
      static constexpr std::array<void (*)(void *), sizeof...(PTypes)> cont{
          &Clearer<PTypes>...};
      cont[this->curT](this->data.data());
      this->curT = -1;
      cleared = true;
    }
    return cleared;
  }

Модификатор static здесь по идее можно убрать, т.к. constexpr неявно статический, но пусть будет. Здесь с помощью выражения свертки создается массив из указателей на специализации определенной раньше вспомогательной функции. Также, хочу обратить внимание, что деструктор, по-хорошему, не должен выбрасывать исключения. Если деструктор вашего класса способен выбрасывать исключения, то механику его правильного удаления в данной ситуации вам в любом случае придется продумать отдельно. Вызов нужной функции очищения происходит за счет вызова нужно указателя на фукнцию по текущему индексу типа.
Заодно, готов и деструктор:

~MyVariant() { this->TryClear(); }

А также оператор копирования/перемещения:

template <class T> 
MyVariant &operator=(T &&val) {
    constexpr auto nextN = FindNumber<0, T, PTypes...>();
    static_assert(nextN != -1, "Uknown type passed");
    this->TryClear();
    using valueT = std::decay_t<T>;
    new (this->data.data()) valueT(std::forward<T>(val));
    this->curT = nextN;
    return *this;
  }

Один момент, на который стоит обратить внимание - если конструктор пользовательского типа выкинет исключение, то мы получим следующую ситуацию: старые данные уже потеряны, а новые не созданы. В некоторых случаях подобное поведение может быть нежелательно.
Итак, у нас уже все готово, кроме одного момента – непосредственно вызов функции. На самом деле, общий принцип уже придуман – подобное мы реализовали в вспомогательном шаблоне Clearer(), только в данном случае мы дополнительно будем принимать тип предиката и флаг, отвечающий за необходимость перемещения объекта

template <class Predicate, class T, bool needToMove>
void CallP(Predicate pr, void *data) {
  T *casted = reinterpret_cast<T *>(data);
  if constexpr (needToMove) {
    pr(std::move(*casted));
  } else {
    pr(*casted);
  }
}

Эта функция похожа на Clearer, но за одним исключением - я принимаю needToMove. Почему я передаю это в виде bool, а не извлекаю информацию из типа? Я решил спроектировать подобным образом, так как при вызове Visit пользователь будет передавать именно вариантный тип, а не лежащее внутри значение (что очевидно). В связи с этим проще извлечь информацию про lvalue/rvalue непосредственно из вариантного типа, а при передаче хранимого внутри значения просто указать, что мы используем.
Чтобы воспользоваться данным шаблоном, определим следующую структуру:

struct SimpleVisiterInternal {
  template <class Visitor, class... VariantTypes>
  static bool TryVisit(Visitor &&visitor, MyVariant<VariantTypes...> &variant) {
    bool result = false;
    if (variant.curT != -1) {
      result = true;
      constexpr auto TypesCount = sizeof...(VariantTypes);
      constexpr std::array<void (*)(Visitor, void *), TypesCount> cont{
          &CallP<Visitor, VariantTypes, false>...};
      cont[variant.curT](std::forward<Visitor>(visitor),
                         (void *)(variant.data.data()));
    }
    return result;
  }
  template <class Visitor, class... VariantTypes>
  static bool TryVisit(Visitor &&visitor,
                       MyVariant<VariantTypes...> &&variant) {
    bool result = false;
    if (variant.curT != -1) {
      constexpr auto TypesCount = sizeof...(VariantTypes);
      result = true;
      constexpr std::array<void (*)(Visitor, void *), TypesCount> cont{
          &CallP<Visitor, VariantTypes, true>...};
      cont[variant.curT](std::forward<Visitor>(visitor),
                         (void *)(variant.data.data()));
    }
    return result;
  }
};

Данная структура для произвольного Visitorа определяет массив из указателей на специализацию шаблона CallP. Также, здесь мы различаем, lvalue или rvalue является объект нашего MyVariant и задаем нужный флаг при вызове. И да, данная структура должна быть friend для MyVariant, чтобы уметь работать с его внутренним представлением.
Теперь обернем реализацию в функцию, чтобы было удобнее пользоваться

template <class Visitor, class Variant>
bool TryVisit(Visitor &&visitor, Variant &&variant) {
  return SimpleVisiterInternal::TryVisit(std::forward<Visitor>(visitor),
                                         std::forward<Variant>(variant));
}

По сути, все готово, шаблоном нашего вариантного типа можно пользоваться

А где TryVisit со множественным вызовом?

Пока пишется. Общий принцип все тот же - надо сделать массив. В данном случае я считаю, что удобнее всего будет сделать одномерный массив, который за счет извлечения количества возможных хранимых типов для каждого из MyVariant будет корректно выбирать нужный указатель. Трудность в том, чтобы этот массив сгенерировать, т.к. приходится оперировать произвольным количеством MyVariant, внутри каждого из которых может быть произвольное количество возможных типов. И все эти размеры разные. Сподвижки и идеи вроде есть, но ввиду других дел реализация может затянуться на неопределенный срок.

Немного о том, почему TryVisit - функция, а не метод

std::visit является именно функцией, а не методом. Более того, вызвать функтор с помощью метода вообще нельзя. Почему так? Лично для себя я выделил 2 причины:

  • std::visit умеет работать с произвольным количеством переданных вариантных типов. Данная возможность достаточно спорная, так как в процессе генерируется очень большое количество специализаций функций N1 * N2 * ... и так по количеству вариантный типов, но она присутствует, поэтому лучше использовать функцию

  • распознать, где нужна move семантика будет достаточно непросто, т.к. напрямую извлечь информацию о том, является ли std::variant lvalue или rvalue мы не можем (deducing this пока не завезли). Вынесение функционала в функцию дает гораздо более простое решение данной проблемы

Заключение

Данный код писался сугубо из интереса. Я постарался все учесть, а также протестировал код для различных типов, но гарантировать отсутствие каких-то ошибок все же не могу.
Это моя первая статья на habr, поэтому надеюсь, что она была кому-нибудь полезна.
Код можно глянуть по этой ссылке - https://github.com/KirillVelichk0/MyVariant

Поправки

alignas(PTypes...) char data[FindMaxSize<0, PTypes...>()];

Лучше для буфера использовать данную конструкцию вместо std::array, чтобы учитывать выравнивание. FindMaxSize можно заменить на std::max(sizeof...(PTypes)), но в статье уже используется функция, поэтому оставлю так (спасибо, Kelbon)

Комментарии (18)


  1. Kelbon
    02.04.2024 12:51

    std::int16_t curT;
    std::array<char, FindMaxSize<0, PTypes...>()> data;

    тип индекса можно выбирать в зависимости от числа типов. И байты нужно поместить по правильному алигменту, alignas(alignof(T)) char data[std::max({sizeof(Types)...})];


    1. KirillVelichk0 Автор
      02.04.2024 12:51

      спасибо, что-то не подумал об этом. Сейчас поправлю


  1. segment
    02.04.2024 12:51
    +1

    Можете показать реальное применение этому? Не наброс, для меня остается загадкой любовь к метапрограммированию, я помню как-то закапывался в boost/std и реализовывал свои контейнеры, оно конечно увлекает, но по итогу на простом Си (в embedded) оно реализовалось в более поддерживаемом варианте. Как всё это отлаживать, тестировать и документировать?


    1. KirillVelichk0 Автор
      02.04.2024 12:51

      Секунду. Комментарий поправил


      1. Kelbon
        02.04.2024 12:51

        vtable это не хеш таблица... Не более чем массив функций. У варианта другие преимущества


        1. KirillVelichk0 Автор
          02.04.2024 12:51

          Действительно, был не прав, по факту везде vtables используется. Но, возможно я ошибаюсь, мы в любом случае не можем гарантировать константный O(1), вызов vtalbes может занять дополнительные расходы?


    1. KirillVelichk0 Автор
      02.04.2024 12:51

      Так, ладно, в баню производительность. std::variant будет очевидно шустрее виртуальных методов, но я боюсь наврать лишний раз.
      Ну, вообще, так как полиморфизм внешний, можно навесить достаточно большое количество дополнительных методов работы с хранимыми данными. Для виртуальных методов мы так не можем. Если использовать указатели на функции и просто передавать в них void*, то проблема будет примерно та же (надеюсь, я вас правильно понял)


  1. Ritan
    02.04.2024 12:51

    Вместо std::arrray<char, ...> лучше std::aligned_storage_t использовать с максимальным для всех типов align


    1. Kelbon
      02.04.2024 12:51

      это устаревшее и нет, не лучше( про алигмент написал выше)


      1. Ritan
        02.04.2024 12:51

        Любопытно, упустил, что aligned_storage объявили устаревшим.

        Аргументация там имхо хромает: aligned_storage требует использования reinterpret_cast, поэтому используйте alignas(align) std::byte[size], который требует того же самого.

        Опять объявляют что-то устаревшим так как не смогли что-то сделать не ломая ABI, поэтому решили сломать API


        1. Kelbon
          02.04.2024 12:51

          ну aligned storage объективно бесполезен, зачем тогда его оставлять?


          1. Ritan
            02.04.2024 12:51

            Он более ясно выражает цель - получить какой-то объём хранилища с указанным алайментом. И мне не важно, что под капотом там alignas(align) char data[size], alignas(align) std::byte data[size] или вообще struct { char pad[pad_size], char data[size] }.

            Так ведь можно и std::array задепрекейтить, тоже можно(почти) заменить на сишные массивы


            1. voldemar_d
              02.04.2024 12:51

              Читал, что есть библиотеки, которые работают с контейнерами, имеющими явные методы begin и end - они могут работать с std::array, а с сишными массивами не могут.


              1. Ritan
                02.04.2024 12:51
                +1

                Это вина библиотек - использовать рекомендуют свободные функции std::begin(c), std::end(c), а не методы


                1. voldemar_d
                  02.04.2024 12:51

                  Это понятно, что это их вина, но тем не менее.


            1. Kelbon
              02.04.2024 12:51

              байты с align более чем ясно дают понять что происходит, по итогу получается, что aligned_storage только добавлял мороки и вероятность ошибиться лишний раз

              array действительно хотелось бы задепрекейтить, точнее улучшить встроенный массив, добавив ему копирование и пару методов (begin/end/data), но к сожалению это легаси и этого никто делать не собирается


              1. Ritan
                02.04.2024 12:51

                улучшить встроенный массив, добавив ему копирование и пару методов (begin/end/data),

                Это сломает совместимость с большой кучей сишного кода, так что не нужно


  1. AndreyVelichko
    02.04.2024 12:51
    +1

    Маладэс