Как стать автором
Обновить
VK
Технологии, которые объединяют

Фабрика для std::variant: как объединить compile-time и run-time, чтобы получить выигрыш от обоих

Уровень сложностиСредний
Время на прочтение18 мин
Количество просмотров4.6K


Привет, Хабр! Меня зовут Михаил Полукаров, я занимаюсь разработкой Desktop-версии пользовательского приложения для совместной работы сотрудников с любого устройства VK Teams

Если вы тоже работали с большими проектами, где активно применяются объектно-ориентированные паттерны проектирования, то наверняка знаете о быстро нарастающей лавине кода. Такой код сложно писать, изучать и тем более поддерживать. Сегодня я расскажу, как удалось избежать таких сложностей. Поделюсь, как можно использовать современный С++, чтобы совместить compile-time и run-time исполнение, не потеряв при этом в производительности и выразительности кода.

С какой проблемой столкнулись мы


В приложении VK Teams есть видеозвонки. Пользователи могут звонить 1:1, проводить большие конференции или вебинары. Перед нами встала задача реализации производительного отображения видео участников. Все осложнялось тем, что помимо собственно непростого рендеринга было необходимо задействовать средства аппаратного ускорения графики. Для этого существует множество библиотек (OpenGL, DirectX, macOS Metal Render и многие другие), которые устроены совершенно по разному и имеют неоднородную поддержку на различных операционных системах. Таким образом, одним из первых вариантов был код, который я не могу здесь привести в силу его размеров (он занял бы всю статью). Вместо кода я приведу несколько уменьшенную и упрощенную диаграмму этих классов:



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

Я задумался над тем, а что именно мы хотим получить в итоге? Каким бы мы хотели увидеть идеальный вариант? Стало очевидно, что в конечном итоге мы хотим просто нарисовать какой-то элемент (или объект) — практически настолько же просто как выполнить f(x)



На этой картинке, то как можно себе это представить: есть разные способы отрисовки (функции), есть элементы (объекты) — и посередине некая «черная магия», которая позволяет выполнять отрисовку отдельного элемента определенным способом.

Используя эту картинку в голове как отправную точку, мы в итоге пришли вот к такой диаграмме классов:



Ниже я расскажу какая именно «магия» современного С++ заставляет все это работать и какие еще плюсы от такого решения можно получить, на более простом примере.

Исходный пример и современные подходы к реализации


В последнее время часто можно встретить реализации полиморфного поведения на C++ без использования виртуальных функций. Такой подход называют статическим полиморфизмом. Он обладает своими достоинствами и недостатками. Наиболее часто приводимый пример такой реализации использует std::variant, паттерны проектирования «стратегия» и «посетитель»

Однако в реальности наиболее часто встречающийся паттерн в коде — фабричный метод. Именно этому паттерну и сочетанию его с остальными в парадигме современного C++ я и хотел бы уделить внимание.

Сперва нам необходима система близких по назначению типов, то есть паттерн-стратегия. Давайте возьмем в качестве примера систему классов двухмерных фигур и (как во многих примерах) напишем несложную ее реализацию:

struct Point
{
    int x = 0, y = 0;

    Point() = default;
    Point(int ax, int ay) 
        : x(ax), y(ay) 
    {}
    int area() const { return 0; }
    void draw(std::ostream& out) const {
         out << "point: [" << x << ',' << y << ']' << std::endl;
    }
};

struct Rect
{
    int x = 0, y = 0, w = 0, h = 0;

    Rect() = default;
    Rect(int ax, int ay, int aw, int ah)
         : x(ax), y(ay), w(aw), h(ah)
    {}
    int area() const { return w * h; }
    void draw(std::ostream& out) const {
        out << "rect: [" << x << ',' << y << " (" << w << 'x' << h << ")]" << std::endl;
    }
};

struct Line
{
     int x1 = 0, x2 = 0, y1 = 0, y2 = 0;
     Line() = default;
     Line(int ax1, int ay1, int ax2, int ay2)
          : x1(ax1), x2(ax2), y1(ay1), y2(ay2)
     {}
     int area() const { return 0; }
     void draw(std::ostream& out) const {
          out << "line: [(" << x1 << ',' << y1 << "),(" << x2 << ',' << y2 << ")]" << std::endl;
     }
};


int main()
{
    using Shape = std::variant<Point, Rect, Line>;

    std::vector<Shape> shapes;
    shapes.emplace_back(Point{ 10, 10 });
    shapes.emplace_back(Rect{ -5, 5, 10, 10 });
    shapes.emplace_back(Line{ 1, 1, 4, 5 });
    for (const auto& shape : shapes)
         std::visit(shape, [](const auto& s) { s.draw(std::cout); });
}

Если скомпилировать этот код и запустить, то мы увидим вполне ожидаемый вывод:


point: [10,10]
rect: [-5,5 (10x10)]
line: [(1,1),(4,5)]

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

Что, если мы решили использовать такой подход в продакшене? Достаточно ли нам того, что уже написано? В большинстве случаев нет, недостаточно. В реальных задачах надо уметь создавать различные объекты во время исполнения в зависимости от условий: нажатия элементов интерфейса пользователя, чтение данных из сети / файла / базы данных и т. д. Возникает следующий вопрос: а как мы можем абстрагировать, отделить способ создания от самого объекта?

Фабричный метод


Тут нам на помощь и должен прийти паттерн «фабричный метод». Все известные мне источники приводят примеры, использующие виртуальные функции, но мы их принципиально не используем. Так мы подобрались к самому интересному: нам нужна фабрика для std::variant.
На первый взгляд, может показаться, что нет ничего проще:

enum ShapeType
{
    ST_Point,
    ST_Rect,
    ST_Line
};


Shape createShape(ShapeType type)
{
    switch(type)
    {
    case ST_Point:
        return Point{};
    case ST_Rect:
        return Rect{};
    case ST_Line:
        return Line{};
    // ...
    }
    // ???
}

Но тут сразу всплывают несколько проблем. Что, если для указанного значения из перечисления нет типа? Что, если мы хотим создавать объекты, используя не только конструктор по умолчанию, но и другие? Каждое изменение состава типов — изменение Enum + добавление Case в Switch — выглядит не очень масштабируемо.

Попробуем разобраться и ответить на эти и некоторые другие вопросы.

Если подходящий тип отсутствует, мы можем использовать std::variant::monostate или std::optional. Каким именно путем пойти — дело вкуса. Для дальнейших примеров я выбрал std::optional. Тогда нам необходимо изменить псевдоним типа Shape:

using Shape = std::optional< std::variant<Point, Rect, Line> >;

Выглядит уже жутковато. Имеет смысл вместо псевдонима типа использовать паттерн «фасад» и обернуть определение типа и операции с ним в отдельный класс:

class Shape
{
public:
    using Data = std::variant<Point, Rect, Line>;

    Shape() = default;

    template<class T>
    explicit Shape(T shape) : d(std::move(shape)) {}

    void draw(std::ostream& out) const
    {
        if (!d) {
            std::cerr << "error: shape was not set to an instance of an object" << std::endl;
            return;
        }
        std::visit([&out](const auto& s) { s.draw(out); }, *d);
    }

    int area() const
    {
        if (!d)
            return 0;
        return std::visit([](const auto& s) { return s.area(); }, *d);
    }

    explicit operator bool() const { return !!d; }

private:
    std::optional<Data> d;
};


int main()
{
     std::vector<Shape> shapes;
     shapes.emplace_back(Point{ 10, 10 });
     shapes.emplace_back(Rect{ -5, 5, 10, 10 });
     shapes.emplace_back(Line{ 1, 1, 4, 5 });
     shapes.emplace_back(Shape{});
     for (const auto& shape : shapes)
     {
          if (shape)
              shape.draw(std::cout);
          else
              std::cerr << "unable to draw shape" << std::endl;
     }
}

Теперь наш объект Shape обрел Value-semantic и не только не потерял предыдущего поведения, но и стал Nullable-типом. Однако мы все еще не можем порождать Shape без передачи конкретного типа. 

Мост между мирами Compile-time и Run-time


Попробуем написать обобщенную функцию создания, которая хотя бы имеет нужный нам интерфейс:

template<class _Variant, class... _Args>
std::variant<_Types...> createVariant(int i, _Args&&... args)
{
    return std::variant<_Types...>(std::in_place_index<i>(), std::forward<_Args>(args)...);
}

Такой интерфейс нас устраивает, но этот код не скомпилируется. Основная сложность заключается в том, что алгоритмы, совместимые с std::variant, полагаются на Compile-time-вычисления, а переменные, используемые для создания, располагаются в Run-time. Получается, задача в том, что необходимо каким-то образом построить «мостик» между Compile-time и Run-time.

Мы можем использовать Boost, но это кажется излишним решением, если от нее нам необходимо только создание std::variant, поскольку Boost — довольно большая и сложная библиотека.

Попробуем реализовать создание, используя только STL и сам язык C++. Фактически нам необходимо найти по Run-time-индексу Compile-time-индекс и подставить его в std::in_place_index:

template <class _Variant, std::size_t I = 0>
_Variant createVariant(std::size_t index) 
{
    if constexpr(I >= std::variant_size_v<_Variant>)
        throw std::runtime_error{"index " + std::to_string(I + index) + " out of bounds"};
    else
        return index == 0
            ? _Variant{std::in_place_index<I>}
            : createVariant<_Variant, I + 1>(index - 1);
}

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

Разберемся, что здесь происходит. Первая же проверка — мы не вышли за границы количества зарегистрированных в std::variant типов. Эта проверка помечена Constexpr, поскольку должна быть проведена в Compile-time. В ветке Else происходит что-то непонятное: если переданный индекс равен нулю, то мы передаем I в std::in_place_index как нешаблонный параметр — в противном случае рекурсия? Да, именно так. Этот рекурсивный вызов позволяет нам искать по переданному индексу подходящий Compile-time-индекс. Давайте посмотрим на пример:

using Shapes = std::variant<Point, Rect, Line>;
auto result = createVariant<Shapes>(2);

И проследим на нем, как происходит рекурсия и во что разворачиваются шаблоны:

createVariant<Shapes, I=0>(2):
index = 2; index -1, I + 1 →
createVariant<Shapes, I=1>(1):
index = 1; → index – 1, I + 1 →
createVariant<Shapes, I=2>(0):
index = 0 →
return Shapes{std::in_place_index<2>};

Теперь становится более понятным, как работает эта магия: Compile-time-индекс возрастает, в то время как Run-time-индекс убывает. Когда Run-time-индекс станет равным нулю, это будет означать, что мы находимся в нужной инстанциации функции с нужным Compile-time-аргументом, который мы теперь можем подставить в std::in_place_index. Очевидно, что нам необходимо как-то ограничить рекурсию — именно для этого нам и нужна первая проверка.

Внимательный читатель задастся вопросом: а можем ли мы избежать рекурсии? Да, можем, однако это будет не бесплатно: расплатой будет Code Bloat и увеличение размера исполняемого файла. Реализация нерекурсивного поиска необходимой инстанциации заключается в создании Compile-time-таблицы указателей на функцию создания для всех типов, заданных для конкретного std::variant.

Это можно провернуть, используя трюки с делегирующими конструкторами, std::make_index_sequence и fold expressions из C++17:

template<class _Variant>
struct variant_creator // аналог std::array, но заполняется автоматически
{
    // число всех типов в std::variant<...>
    static constexpr size_t N = std::variant_size_v<_Variant>;
    
    // указатель на функцию создания конкретного типа
    typedef _Variant(*creator)(); 
    
     // конструктор по умолчанию: вызывает делегирующий конструктор 
     // с index_sequence<I...> чтобы использовать I... в fold expression
    variant_creator()
        : variant_creator(std::make_index_sequence<N>{})
    {}

    _Variant operator()(size_t i) const 
    {
        if (i >= N)
            throw std::runtime_error("invalid index");
        return arr[i]();
    }
private:
    template<size_t... I>
    variant_creator(std::index_sequence<I...>)
        : arr{ &create<I>... } // заполняем массив указателями на функцию, используя fold expression
    {}

    template<size_t I>
    static _Variant create() { return _Variant{std::in_place_index<I>}; }
    
private:
    creator lookup[N] = { nullptr }; // собственно массив указателей на функции
};

template <class _Variant, std::size_t I = 0>
_Variant createVariant(std::size_t index) 
{
    static variant_creator<_Variant> creator;
    return creator(index);
}

Отлично! Теперь мы можем создавать любой std::variant. Теперь попробуем адаптировать это решение под наши нужды, не теряя общности:

template<class... _Types>
struct VariantFactory
{
    static constexpr size_t kTypeCount = sizeof...(_Types);
    using VariantType = std::variant<_Types...>;
    using ResultType = std::optional<VariantType>;

    bool emplace(ResultType& result, int type) const
    {
        return emplaceVariant(result, type);
    }

protected:
    template <std::size_t I = 0>
    static bool emplaceVariant(ResultType& result, std::size_t index)
    {
        if constexpr (I >= kTypeCount)
        {
            return false;
        }
        else
        {
            if (index == 0)
            {
                result.emplace(std::in_place_index<I>);
                return true;
            }
            return emplaceVariant<I + 1>(result, index - 1);
        }
    }
};

Мы обернули наше решение в шаблонный класс VariantFactory. Поскольку нам необходим std::optional, то результат передается в качестве возвращаемого аргумента по ссылке. В случае неудачи при создании мы возвращаем False, чтобы вызывающий код смог обработать проблему.

Давайте теперь интегрируем эту фабрику с остальным кодом:

#include "VariantFactory.h"

class Shape
{
public:
    using ShapeFactory = VariantFactory<Point, Rect, Line>;

    enum ShapeType
    {
        ST_Invalid = -1,
        ST_Point,
        ST_Rect,
        ST_Line
    };

    Shape(int key = ST_Invalid)
    {
        if (key != ST_Invalid && !ShapeFactory{}.emplace(d, key))
            std::cerr << "error: unable to create shape for key " << key << std::endl;
    }

    void draw(std::ostream& out) const
    {
        if (!d) {
            std::cerr << "error: shape was not set to an instance of an object" << std::endl;
            return;
        }
        std::visit([&out](const auto& s) { s.draw(out); }, *d);
    }

    int area() const
    {
        if (!d)
            return 0;
        return std::visit([](const auto& s) { return s.area(); }, *d);
    }

    explicit operator bool() const { return !!d; }

private:
    ShapeFactory::ResultType d;
};


int main()
{
    std::vector<Shape> shapes;
    shapes.emplace_back(ST_Point);
    shapes.emplace_back(ST_Rect);
    shapes.emplace_back(ST_Line);
    shapes.emplace_back(Shape{});
    for (const auto& shape : shapes)
    {
        if (shape)
            shape.draw(std::cout);
        else
            std::cerr << "unable to draw shape" << std::endl;
    }
}

Обратим теперь внимание еще на одну деталь: наша реализация VariantFactory использует только конструктор по умолчанию. Но что, если типы, содержащиеся в std::variant, не поддерживают конструктор по умолчанию? Что делать, если эти типы не поддерживают копирование? Как мы можем предоставить механизм создания, используя переменное число произвольных аргументов (variadic template argument pack)?

Обобщай и расширяй


Расширим наше решение и на эти случаи: попробуем для начала разобраться со случаем запрета копирования. Текущая реализация это уже учитывает благодаря возвращаемому аргументу. Однако использование разного числа аргументов произвольных типов в такой реализации отсутствует. На первый взгляд кажется, что добавить это нет проблем:

template <std::size_t I = 0, class... _Args>
static constexpr bool emplaceVariant(ResultType& result, std::size_t index, _Args&&... args)
{
    if constexpr (I >= kTypeCount)
    {
        return false;
    }
    else
    {
        if (index == 0)
        {
             result.emplace(std::in_place_index<I>, std::forward<_Args>(args)...);
             return true;
        }
        return emplaceVariant<I + 1>(result, index - 1, std::forward<_Args>(args)...);
    }
}

Но, увы, такой код компилироваться не будет. Более того, компилятор выдаст не особо читаемую ошибку: «Не может вызвать конструктор с такими аргументами». Если присмотреться к этой ошибке, то мы увидим, что по каким-то причинам код скомпилировался так, что пытается вызывать конструктор одного типа с аргументами от другого! Получилось так из-за особенностей инстанцирования шаблонов C++. В сгенерированном коде будут сразу все варианты конструкторов всех типов. Следовательно, необходимо как-то различать, можем ли мы сконструировать тип из переданных _Args… или нет. И как ни странно, такой механизм нам предоставляет STL из коробки — std::is_constructible<_Args...>. Все, что нам нужно, — просто правильно его использовать:

template<size_t I>
using AlternativeType = std::variant_alternative_t<I, VariantType>;

template <std::size_t I = 0, class... _Args>
static constexpr bool emplaceVariant(ResultType& result, std::size_t index, _Args&&... args)
{
    if constexpr (I >= kTypeCount)
    {
        return false;
    }
    else
    {
        if (index == 0)
        {
            if constexpr (std::is_constructible<AlternativeType<I>, _Args...>{})
            {
                result.emplace(std::in_place_index<I>, std::forward<_Args>(args)...);
                return true;
            }
            else
            {
                 return false;
            }
        }
        return emplaceVariant<I + 1>(result, index - 1, std::forward<_Args>(args)...);
    }
}

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

#include "VariantFactory.h"

class Shape
{
public:
    using ShapeFactory = VariantFactory<Point, Rect, Line>;

    enum ShapeType
    {
        ST_Invalid = -1,
        ST_Point,
        ST_Rect,
        ST_Line
    };

    explicit Shape(int key = ST_Invalid)
        : Shape(key)
    {
    }

    template<class... _Args>
    Shape(int key, _Args&&... args)
    {
        if (key != ST_Invalid && !ShapeFactory{}.emplace(d, key, std::forward<_Args_>(args)...))
            std::cerr << "error: unable to create shape for key " << key << std::endl;
    }

    void draw(std::ostream& out) const
    {
        if (!d) {
            std::cerr << "error: shape was not set to an instance of an object" << std::endl;
            return;
        }
        std::visit([&out](const auto& s) { s.draw(out); }, *d);
    }

    int area() const
    {
         if (!d)
             return 0;
         return std::visit([](const auto& s) { return s.area(); }, *d);
    }

    explicit operator bool() const { return !!d; }

private:
    ShapeFactory::ResultType d;
};


int main()
{
    std::vector<Shape> shapes;
    shapes.emplace_back(ST_Point, 10, 10);
    shapes.emplace_back(ST_Rect, -5, -5, 10, 10);
    shapes.emplace_back(ST_Line, 1, 1, 4, 5);
    shapes.emplace_back(Shape{});
    for (const auto& shape : shapes)
    {
        if (shape)
            shape.draw(std::cout);
        else
            std::cerr << "unable to draw shape" << std::endl;
    }
    // Output:
    // point: [10,10]
    // rect: [-5,5 (10x10)]
    // line: [(1,1),(4,5)]
    // unable to draw shape
}

Замечательно — теперь код компилируется и работает как задумывалось!

Здесь вроде бы можно было поставить точку, но мы пойдем еще дальше. Можно заметить, что нам необходимо следить за тем, чтобы порядок, в котором объявлены типы в нашей фабрике, в точности совпадал с порядком, в котором объявлены типы в Enum ShapeType. В нашем игрушечном примере сделать это не составляет труда. В реальности же код пишут и исправляют множество людей, и уследить за этим будет практически невозможно. Хорошей идеей будет придумать механизм, благодаря которому поведение фабрики не будет зависеть от порядка объявления типов.

Чтобы решить проблему, нам нужна некая таблица поиска, которая преобразует переданный ключ в нужный индекс внутри std::variant:

template<class... _Types>
struct VariantFactory
{
    using VariantType = std::variant<_Types...>;
    using ResultType = std::optional<VariantType>;

    static constexpr size_t kTypeCount = sizeof...(_Types);
    static constexpr size_t kLookup[kTypeCount] = { ??? };

    template<class... _Args>
    bool emplace(ResultType& result, int type, _Args&&... args) const
    {
        auto first = std::begin(kLookup);
        auto last = std::end(kLookup);
        auto it = std::find(first, last, type);
        if (it == last)
            return false;
        return emplaceVariant(result, std::distance(first, it), std::forward<_Args>(args)...);
    }

    // as before
    // ...
};

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

struct Point
{
    static constexpr size_t kElementType = Shape::ST_Point;
    // as before
    // ...
};

struct Rect
{
    static constexpr size_t kElementType = Shape::ST_Rect;
    // as before
    // ...
};

struct Line
{
    static constexpr size_t kElementType = Shape::ST_Line;
    // as before
    // ...
};

Это позволит нам сгенерировать таблицу подстановки в виде массива kLookup автоматически:

template<class... _Types>
struct VariantFactory
{
   static constexpr size_t kTypeCount = sizeof...(_Types);
   static constexpr size_t kLookup[kTypeCount] = { _Types::kElementType... };
// as before ...
};

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

template<size_t _ElemId>
struct ShapeAdapter
{
    static constexpr kElementType = _ElemId;
};

struct Point : public ShapeAdapter<Shape::ST_Point>
{
    // as before...
};

Если мы попробуем использовать VariantFactory более широко, то столкнемся с другой сложностью — требованием, чтобы каждый тип имел объявление kElementType, а нам хотелось бы иметь наиболее общее решение. Чтобы обойти это ограничение, можно прибегнуть к достаточно простому решению — наследовать от VariantFactory<_Types…> более конкретные классы-фабрики и предоставить механизм передачи таблицы подстановки в функцию создания. Например, так:

template<class... _Types>
struct VariantFactory
{
    using VariantType = std::variant<_Types...>;
    using ResultType = std::optional<VariantType>;
    static constexpr size_t kTypeCount = sizeof...(_Types);

protected:
    template<class... _Args>
    bool emplace(ResultType& result, int type, _Args&&... args) const
    {
        return createVariant(result, type, std::forward<_Args>(args)...);
    }

    bool emplace(ResultType& result, int type, int (&lookup)[kTypeCount], _Args&&... args) const
    {
        auto first = std::begin(lookup);
        auto last = std::end(lookup);
        auto it = std::find(first, last, type);
        if (it == last)
            return false;
        return emplaceVariant(result, std::distance(first, it), std::forward<_Args>(args)...);
    }

private:
    template <std::size_t I = 0, class... _Args>
    static bool emplaceVariant(ResultType& result, std::size_t index, _Args&&... args)
    {
        // as before...
    }
};

Теперь мы можем объявить реализацию для нашей конкретной фабрики следующим образом:

template<class... _Types>
struct ShapeFactory : public VariantFactory<_Types...>
{
    static constexpr size_t kLookup[] = { _Types::kElementType... };

    bool createShape(ResultType& result, int type)
    {
        return this->emplace(result, type, kLookup);
    }
};

Это неплохое решение, достаточно надежное, но требует знаний шаблонов и метапрограммирования для реализации наследования от VariantFactory. Можем ли мы пойти еще дальше? Ответ — да!

Уникальные идентификаторы типа как ключи в фабрике


Следующий наш шаг будет заключаться в том, чтобы вовсе отказаться от каких-либо перечислений и ручной нумерации через Enum. Идея заключается в том, чтобы придумать механизм создания уникальных идентификаторов для типа в Compile-time. В каком-то смысле этот механизм напоминает RTTI (Run-Time Type Information), назовем его CTTI (Compile-Time Type Information). Сразу оговорюсь, что методика создания таких идентификаторов — обширная тема, достойная отдельной статьи. На просторах интернета можно найти десятки различных реализаций под самые разнообразные требования. Тут каждый найдет решение на свой вкус и потребности. Поэтому в качестве примера я решил использовать один из достаточно распространенных трюков — использование адресов статических переменных:

template<typename T>
struct type { static constexpr char _dummy_ = 0; };

template<typename T>
inline constexpr const void* kUniqueId = &type<T>::_dummy_;

template<typename T>
static size_t unique_id() { return reinterpret_cast<size_t>(kUniqueId<T>); }

int main()
{
    std::cout << "kUniqueId<int>=" << (void*)kUniqueId<int> << std::endl;
    std::cout << "kUniqueId<double>=" << (void*)kUniqueId<double> << std::endl;
    std::cout << "kUniqueId<std::string>=" << (void*)kUniqueId<std::string> << std::endl;

    std::cout << "unique_id<int>()=" << (void*)kUniqueId<int> << std::endl;
    std::cout << "unique_id<double>()=" << (void*)kUniqueId<double> << std::endl;
    std::cout << "unique_id<std::string>()=" << (void*)kUniqueId<std::string> << std::endl;

    // Possible output:
    // kUniqueId<int>=0x4019d8
    // kUniqueId<double>=0x4019d7
    // kUniqueId<std::string>=0x4019d6
    // unique_id<int>()=0x4019d8
    // unique_id<double>()=0x4019d7
    // unique_id<std::string>()=0x4019d6
}

Здесь стоит обратить особое внимание на то, что в результате инстанцирования kUniqueId мы получаем Const Void* указатели и не преобразуем их в какой-то целочисленный тип, хотя кажется, что это ничему не противоречит. Дело в том, что такое преобразование требует, чтобы переменная была в Run-time-контексте. Другими словами, если попытаться использовать такое приведение, то код не скомпилируется, если убрать constexpr, то лишимся Compile-time. Поэтому для Run-time-контекста мы используем обычную функцию unique_id(), возвращающую результат все того же kUniqueId.

Есть у такого подхода и недостатки. К основным относятся:

  • адрес статической переменной и сами идентификаторы типа будут разными от компиляции к компиляции;
  • возможны проблемы при переходе границ динамической библиотеки. Компилятор может посчитать такую переменную неиспользуемой и выкинуть ее при компиляции.

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

Используя все эти знания, мы теперь можем написать финальную, наиболее общую реализацию VariantFactory:

#include "unique_id.h"

template<class... _Types>
struct VariantFactory
{
    static constexpr size_t kTypeCount = sizeof...(_Types);
    static constexpr const void* kLookup[] = { kUniqueId<_Types>... };

    using VariantType = std::variant<_Types...>;
    using ResultType = std::optional<VariantType>;

    template<size_t I>
    using AlternativeType = std::variant_alternative_t<I, VariantType>;

    template<class... _Args>
    bool operator()(ResultType& result, size_t type, _Args&&... args) const
    {
        for (size_t i = 0; i < kTypeCount; ++i)
            if (reinterpret_cast<size_t>(kLookup[i]) == type)
                return emplaceVariant(result, I, std::forward<_Args>(args)...);
        return false;
    }

protected:
    template <std::size_t I = 0, class... _Args>
    static constexpr bool emplaceVariant(ResultType& result, std::size_t index, _Args&&... args)
    {
        if constexpr (I >= kTypeCount)
        {
            return false;
        }
        else
        {
            if (index == 0)
            {
                if constexpr (std::is_constructible<AlternativeType<I>, _Args...>{})
                {
                    result.emplace(std::in_place_index<I>, std::forward<_Args>(args)...);
                    return true;
                }
                else
                {
                    return false;
                }
            }
            return emplaceVariant<I + 1>(result, index - 1, std::forward<_Args>(args)...);
        }
    }
};

Такая реализация достаточно компактная, работает для любых типов, не зависит от порядка следования типов в объявлении std::variant, не требует объявлений специфических констант, не требует наследования.

Производительность


Теперь стоит сказать несколько слов о производительности. Несмотря на расхожее мнение, что преимущество std::variant заключается в отсутствии виртуальных вызовов при вызове методов вложенных типов, это не совсем так. Дело в том, что современные компиляторы выполняют тяжелую работу по оптимизации виртуальных вызовов: здесь и девиртуализация, и спекулятивное исполнение, и много других сложных приемов. В итоге, если замерить производительность std::variant<Point, Line, Rect> и подхода с использованием классического динамического полиморфизма, то разницы не будет. Однако у std::variant есть другое важное преимущество — время создания. 

Дело в том, что динамический полиморфизм заставляет нас использовать указатели, что, в свою очередь, диктует использование динамической памяти. Очевидно, мы вряд ли будем использовать Raw Pointers ввиду безопасности и стабильности — скорее всего, мы используем std::unique_ptr. Все становится еще хуже, если мы захотим использовать std::shared_ptr, поскольку теперь будет выделяться еще и контрольный блок. Более того, обычно такие полиморфные объекты мы не используем по одному, а создаем сразу множество и храним в каком-то контейнере. 

Именно здесь и кроется суть: выделение участка динамической памяти — дорогостоящая операция. Поэтому std::variant и выигрывает, значительно уменьшая число динамических аллокаций памяти.



Небольшой бенчмарк наглядно показывает эту разницу.

Заключение


В процессе разработки всегда встречаются сложности, связанные не только с производительностью, но и с поддерживаемостью, удобством и дальнейшим масштабированием решения в целом. Удачное решение таких задач — зачастую компромисс, включающий в себя использование необычных конструкций и трюков наравне с очевидными и известными паттернами. Основной целью этой статьи было показать пути решения таких задач с использованием возможности современного C++. Какое именно решение выбрать, всегда остается за вами, но если мне удалось хотя бы немного приоткрыть тайну черной магии шаблонов C++ и их использования, значит, цель статьи достигнута.

Узнать больше о приложении для команд VK Teams можно на нашем сайте.

Источники мудрости


  1. https://github.com/serge-sans-paille/frozen
  2. https://github.com/Manu343726/ctti
  3. https://mikejsavage.co.uk/cpp-tricks-type-id/
  4. https://codereview.stackexchange.com/questions/209950/unique-Compile-time-constexpr-type-id-without-rtti
  5. https://chromium.googlesource.com/chromium/src/base/+/4443f986771406c6973d1903687762a5088f7077/type_id.h
  6. https://dev.to/heliobatimarqui/Compile-time-type-id-with-templates-c-55c4
Теги:
Хабы:
Всего голосов 16: ↑16 и ↓0+24
Комментарии8

Публикации

Информация

Сайт
team.vk.company
Дата регистрации
Дата основания
Численность
свыше 10 000 человек
Местоположение
Россия