Пишем свой вариантный тип

d4180edf09c0d944bafcc98787a8ccbc

Вступление

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

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

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

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

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

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

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

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

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

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

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

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

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

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

template 
constexpr std::int16_t FindNumber() {

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

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

template  
class MyVariant {
private:
  std::int16_t curT;
  std::array()> data;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Заключение

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

© Habrahabr.ru