Динамический полиморфизм с использованием std::variant и std::visit

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Привет, хабровчане. В рамках курса "C++ Developer. Professional" подготовили для вас перевод материала.

Также приглашаем на открытый вебинар по теме
«Области видимости и невидимости». Участники вместе с экспертом на полуторачасовом занятии реализуют класс общего назначения и попробуют запустить несколько unit-тестов с использованием googletest.


Динамический полиморфизм (или полиморфизм времени выполнения) обычно связан с v-таблицами и виртуальными функциями. Однако в этой статье я покажу вам современную технику C++, которая использует std::variant и std::visit. Этот метод C++17 может предложить вам не только лучшую производительность и семантику значений, но и интересные паттерны проектирования.

Последнее обновление: 2 ноября 2020 г. (передача аргументов, бенчмарк времени сборки, исправления).

Виртуальные функции

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

Вы объявляете виртуальную функцию в базовом классе, а затем переопределяете ее в производных классах. Когда вы вызываете такую ​​функцию для ссылки или указателя на базовый класс, компилятор вызовет правильную перегрузку. В большинстве случаев компиляторы реализуют эту технику с помощью виртуальных таблиц (v-таблиц). Каждый класс, имеющий виртуальный метод, содержит дополнительную таблицу, указывающую на адреса функций-членов. Перед каждым вызовом виртуального метода компилятор должен просмотреть v-таблицу и разрешить адрес производной функции.

Канонический пример:

class Base {
public:
    virtual ~Base() = default;

    virtual void PrintName() const { 
        std::cout << "calling Bases!\n"
    }
};

class Derived : public Base {
public:
    void PrintName() const override { 
        std::cout << "calling Derived!\n"
    }
};

class ExtraDerived : public Base {
public:
    void PrintName() const override { 
        std::cout << "calling ExtraDerived!\n"
    }
};

std::unique_ptr<Base> pObject = std::make_unique<Derived>();
pObject->PrintName();

В чем преимущества этой техники? Назовем несколько:

  • Синтаксис встроен в язык, поэтому это очень естественный и удобный способ написания кода.

  • Если вы хотите добавить новый тип, вы просто пишете новый класс, не нужно менять базовый класс.

  • Объектно-ориентированность — позволяет создавать глубокие иерархии.

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

  • Передавать параметры в функции очень просто.

Хочу обратить ваше внимание на «расширяемость». Например, благодаря этому функционалу вы можете реализовать систему плагинов. Вы раскрываете интерфейс через некоторый базовый класс, но вы не знаете окончательное количество плагинов. Они даже могут загружаться динамически. Виртуальная диспетчеризация является важной частью этой системы.

А в чем недостатки?

  • Виртуальный метод должен быть разрешен до вызова, поэтому возникают дополнительные накладные расходы на производительность (компиляторы изо всех сил стараются максимально девиртуализировать вызовы, но в большинстве случаев это невозможно).

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

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

Однако в C++17 (и немного раньше, благодаря библиотекам boost) у нас также есть другой способ реализовать динамический полиморфизм! Давайте посмотрим на него.

Динамический полиморфизм с помощью std::variant и std::visit

С std::variant, доступным с C++17, вы теперь можете использовать безопасные объединения типов и хранить множество разных типов в одном объекте. Вместо указателя на базовый класс хранить все «производные» классы может std::variant.

Давайте перепишем наш первый пример с базовым классом Base, используя эту новую технику: 

Во-первых, классы:

class Derived {
public:
    void PrintName() const { 
        std::cout << "calling Derived!\n"
    }
};

class ExtraDerived {
public:
    void PrintName() const { 
        std::cout << "calling ExtraDerived!\n"
    }
};

Как видите, базового класса больше нет! Теперь у нас может быть куча несвязанных типов.

А теперь основная часть:

std::variant<Derived, ExtraDerived> var;

var определяет объект, который может быть Derived или ExtraDerived. По умолчанию он инициализируется значением по умолчанию первой альтернативы. Вы можете узнать больше о variant в моей отдельной большой статье: Все, что вам нужно знать о std::variant из C ++ 17.

Вызов функций

Как нам вызвать PrintName() в зависимости от типа, который в данный момент активен внутри var?

Нам нужны две вещи: вызываемый объект и std::visit.

struct CallPrintName {
    void operator()(const Derived& d) { d.PrintName(); }    
    void operator()(const ExtraDerived& ed) { ed.PrintName(); }    
};

std::visit(CallPrintName{}, var);

В приведенном выше примере я создал структуру, которая реализует две перегрузки для оператора вызова. Затем std::visit принимает вариантный объект и вызывает правильную перегрузку.

Если наши вариантные подтипы имеют общий интерфейс, мы также можем выразить посетителя (visitor) с помощью обобщенной лямбды:

auto caller = [](const auto& obj) { obj.PrintName(); }
std::visit(caller, var);

Передача аргументов

Наши функции «печати» не принимают никаких аргументов… но что, если они вам понадобятся?

С обычными функциями это не сложно, просто напишите:

void PrintName(std::string_view intro) const { 
    std::cout << intro << " calling Derived!\n;
}

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

Один из способов разрешить это неудобство — создать дополнительные переменные-члены для хранения параметров и вручную передать их в операторы вызова.

struct CallPrintName {
    void operator()(const Derived& d) { d.PrintName(intro); }    
    void operator()(const ExtraDerived& ed) { ed.PrintName(intro); } 

    std::string_view intro;
};

std::visit(CallPrintName{"intro text"}, var);

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

auto caller = [&intro](const auto& obj) { obj.PrintName(intro); }
std::visit(caller, var);

Давайте теперь рассмотрим плюсы и минусы такого подхода. Видите ли вы отличия от виртуальной диспетчеризации?

Преимущества полиморфизма с std::variant 

  • Семантика значений, отсутствие динамической аллокации

  • Легко добавить новый «метод», вам нужно реализовать новую вызываемую структуру. Нет необходимости изменять реализацию классов

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

  • Утиная типизация (Duck typing): хотя виртуальные функции должны иметь одинаковые сигнатуры, это не тот случай, когда вы вызываете функции из посетителя (visitor). У них может быть разное количество аргументов, типов возвращаемых значений и т. д. Это дает нам дополнительную гибкость.

Недостатки полиморфизма с std::variant полиморфизма

  • Вам необходимо знать все типы заранее, во время компиляции. Это исключает возможность создания таких конструкций, как система плагинов. Также сложно добавлять новые типы, так как это подразумевает изменение типа variant и всех посетителей.

  • Это может привести к избыточному потреблению памяти, так как std::variant имеет размер равный максимальному размеру поддерживаемых типов. Итак, если один тип занимает 10 байт, а другой — 100 байт, тогда каждый вариант будет составлять не менее 100 байт. Таким образом, вы потенциально потеряете 90 байт.

  • Утиная типизация: это как преимущество, так и недостаток, в зависимости от правил, необходимых для обеспечения соблюдения функций и типов.

  • Для каждой операции требуется писать отдельного посетителя. Иногда их организация может быть проблемой.

  • Передача параметров не так проста, как с обычными функциями, поскольку std::visit не имеет для этого интерфейса.

Пример

Ранее я показал вам базовый искусственный пример, но давайте попробуем что-нибудь более полезное и реалистичное.

Представьте себе набор классов, которые представляют метку (Label) в пользовательском интерфейсе. У нас может быть SimpleLabel с каким-нибудь текстом, затем DateLabel, который умеет красиво отображать значение даты, а затем IconLabel, который отображает значок рядом с текстом.

Для каждой метки нам нужен метод, который сгенерирует HTML-синтаксис, чтобы ее можно было отобразить позже:

class ILabel {
public:
    virtual ~ILabel() = default;

    [[nodiscard]] virtual std::string BuildHTML() const = 0;
};

class SimpleLabel : public ILabel {
public:
    SimpleLabel(std::string str) : _str(std::move(str)) { }

    [[nodiscard]] std::string BuildHTML() const override {
        return "<p>" + _str + "</p>";
    }

private:
    std::string _str;    
};

class DateLabel : public ILabel {
public:
    DateLabel(std::string dateStr) : _str(std::move(dateStr)) { }

    [[nodiscard]] std::string BuildHTML() const override {
        return "<p class=\"date\">Date: " + _str + "</p>";
    }

private:
    std::string _str;    
};

class IconLabel : public ILabel {
public:
    IconLabel(std::string str, std::string iconSrc) : 
         _str(std::move(str)), _iconSrc(std::move(iconSrc)) { }

    [[nodiscard]] std::string BuildHTML() const override {
        return "<p><img src=\"" + _iconSrc + "\"/>" + _str + "</p>";
    }

private:
    std::string _str;    
    std::string _iconSrc;
};

В приведенном выше примере показан интерфейс ILabel, а затем несколько производных классов, которые реализуют функцию-член BuildHTML.

А здесь мы видим вариант использования, где у нас есть вектор с указателями на ILabel и мы вызываем виртуальную функцию для генерации окончательного HTML-вывода:

std::vector<std::unique_ptr<ILabel>> vecLabels;
vecLabels.emplace_back(std::make_unique<SimpleLabel>("Hello World"));
vecLabels.emplace_back(std::make_unique<DateLabel>("10th August 2020"));
vecLabels.emplace_back(std::make_unique<IconLabel>("Error", "error.png"));

std::string finalHTML;
for (auto &label : vecLabels)
    finalHTML += label->BuildHTML() + '\n';

std::cout << finalHTML;

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

<p>Hello World</p>
<p class="date">Date: 10th August 2020</p>
<p><img src="error.png"/>Error</p>

А вот вариант с std::variant:

struct VSimpleLabel {
    std::string _str;    
};

struct VDateLabel {
    std::string _str;    
};

struct VIconLabel {
    std::string _str;    
    std::string _iconSrc;
};

struct HTMLLabelBuilder {
    [[nodiscard]] std::string operator()(const VSimpleLabel& label) {
        return "<p>" + label._str + "</p>";
    }
    [[nodiscard]] std::string operator()(const VDateLabel& label) {
        return "<p class=\"date\">Date: " + label._str + "</p>";
    }
    [[nodiscard]] std::string operator()(const VIconLabel& label) {
        return "<p><img src=\"" + label._iconSrc + "\"/>" + label._str + "</p>";
    }
};

В предыдущем фрагменте кода я упростил интерфейс для классов Label. Теперь они хранят только данные, а HTML-операции перемещены в HTMLLabelBuilder.

И вариант использования:

using LabelVariant = std::variant<VSimpleLabel, VDateLabel, VIconLabel>;
std::vector<LabelVariant> vecLabels;
vecLabels.emplace_back(VSimpleLabel { "Hello World"});
vecLabels.emplace_back(VDateLabel { "10th August 2020"});
vecLabels.emplace_back(VIconLabel { "Error", "error.png"});

std::string finalHTML;
for (auto &label : vecLabels)
    finalHTML += std::visit(HTMLLabelBuilder{}, label) + '\n';

std::cout << finalHTML;

Пример доступен на сайте Coliru.

Альтернативы

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

struct VSimpleLabel {
    [[nodiscard]] std::string BuildHTML() const {
        return "<p class=\"date\">Date: " + _str + "</p>";
    }

    std::string _str;    
};

struct VDateLabel {
    [[nodiscard]] std::string BuildHTML() const {
        return "<p class=\"date\">Date: " + _str + "</p>";
    }

    std::string _str;    
};

struct VIconLabel {
    [[nodiscard]] std::string BuildHTML() const {
        return "<p><img src=\"" + _iconSrc + "\"/>" + _str + "</p>";
    }

    std::string _str;    
    std::string _iconSrc;
};

auto callBuildHTML = [](auto& label) { return label.BuildHTML(); };
for (auto &label : vecLabels)
    finalHTML += std::visit(callBuildHTML, label) + '\n'

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

Добавление концептов (Concepts) в обобщенные лямбды

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

(Спасибо Mariusz J за эту идею)

template <typename T>
concept ILabel = requires(const T v)
{
    {v.buildHtml()} -> std::convertible_to<std::string>;
};

Этому концепту удовлетворяют все типы, у которых есть константная функция-член buildHtml(), которая возвращает типы, конвертируемые в std::string.

Теперь мы можем использовать его для принудительного применения обобщенной лямбды (благодаря краткому синтаксису constrained auto):

auto callBuildHTML = [](ILabel auto& label) -> std::string { return label.buildHtml(); };
for (auto &label : vecLabels)
    finalHTML += std::visit(callBuildHTML, label) + '\n';

Смотрите пример на @Wandbox.

Дополнительные примеры

У меня также есть еще одна статья, где я экспериментировал со своим старым проектом и заменил кучу производных классов на подход std::variant.

Взгляните при случае:

Замена unique_ptr на std::variant из C++17 - эксперимент на практике

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

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

Является ли std::visit быстрее, чем виртуальная диспетчеризация?

Давайте выясним.

Когда я создал простой бенчмарк для моего примера с ILabel, я не заметил никакой разницы.

Вы можете посмотреть бенчмарк здесь @QuickBench.

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

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

using ABC = std::variant<AParticle, BParticle, CParticle>;
std::vector<ABC> particles(PARTICLE_COUNT);

for (std::size_t i = 0; auto& p : particles) {
  switch (i%3) {
    case 0: p = AParticle(); break;
    case 1: p = BParticle(); break;
    case 2: p = CParticle(); break;
  }
  ++i;
}

auto CallGenerate = [](auto& p) { p.generate(); };
for (auto _ : state) {
  for (auto& p : particles)
    std::visit(CallGenerate, p);
}

Класс Particle (и его версии AParticle, BParticle и т. д.) использует 72 байта данных, и у них есть метод Generate(), который является «виртуальным».

И на этот раз я получил 10% за улучшение для версии с std::visit!

Так почему код может быть быстрее? Я думаю, здесь может быть несколько причин:

  • Версия с variant не использует динамическое распределение памяти, поэтому все частицы находятся в одном блоке памяти. ЦП могут использовать это для повышения производительности.

  • В зависимости от количества типов может случиться так, что среда выполнения, если она используется для проверки текущего активного типа в варианте, намного быстрее и более предсказуема для компилятора, чем поиск указателя в v-таблице.

Вот еще один бенчмакр, который показывает, что версия с variant на 20% медленнее, чем вектор с всего одним типом: td::vector particles(PARTICLE_COUNT);. Смотрите в QuickBench

Другие результаты производительности

Мой тест был относительно простым и не мог безапелляционно утверждать, что std::visit всегда быстрее. Но для лучшего понимания вы можете взглянуть на эту отличную презентацию от Матеуша Пуша, который реализовал целую машину состояний TCPIP и добился гораздо большей производительности с помощью std::visit. Время выполнения также было более стабильным и предсказуемым, чем виртуальные вызовы.

CppCon 2018: Матеуш Пуш «Эффективная замена динамического полиморфизма на std::variant»

Раздувание кода и время сборки

Есть также опасения по поводу раздувания кода, которое может возникнуть из-за std::visit. Поскольку эта функция является чистой библиотечной реализацией без дополнительной поддержки со стороны языка, мы можем смело ожидать, что она добавит дополнительные байты в наш исполняемый файл.

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

  • Явление вариантов V2 – Майкл Парк

  • Раздувание кода из-за std::variant? Похоже это вина std::visit fault : r/cpp

  • Раздувание кода из-за std::variant? Похоже это вина std::visit fault (Part 2) : r/cpp

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

Производительность сборки при использовании std::visit и std::variant

Я показал вам некоторые цифры с производительностью во время выполнения, но у нас также есть инструмент, который позволяет нам проверить скорость компиляции этих двух подходов.

Ищите их здесь @BuildBench

И результаты: GCC 10.1, C++17, O2:

Так что это почти то же самое! Что касается предварительно обработанных строк, оно еще меньше для вариантной версии — 39k против 44k. Что касается ассемблера, то это 2790 LOC для вариантной версии и 1945 LOC для виртуальной.

Извините за небольшое отступление от темы.

Я приготовил для вас небольшой бонус, если вам интересен современный C++, приглашаю вас сюда.

Резюме

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

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

Я также получил отличное резюме от людей, которые использовали такой код в продакшене. Вот один замечательный комментарий от Бориса Дж. (Смотрите его профиль на Github):

Некоторое время назад я использовал std::variant/std::visit для реализации обработки различных типов команд во встроенной системе. В вариантах хорошо то, что полиморфизм работает без косвенного обращения - вам не нужен указатель или ссылка, как в случае с виртуальными функциями. Это помогает в тех случаях, когда объект необходимо создать в функции, а затем вернуть из нее. Я часто пишу код вообще не использующий кучу/динамическую память, поэтому я не могу просто создать объект динамически внутри функции, а затем передать право владения вверх. С variant я могу просто вернуть его по значению (при условии, что оно достаточно мало) без потери полиморфизма.

С другой стороны, используя их в качестве техники ветвления на основе типов, я обнаружил, что когда вы перемещаете их (в общем смысле, я не имею в виду move семантику), и вам нужно обрабатывать их на разных этапах, вы в конечном итоге пишете новый тип посетителя каждый раз, когда вам нужно что-то сделать с вариантом. Что еще хуже, иногда способ обработки разных типов в варианте отличается лишь незначительно. В результате вы получаете несколько посетителей, некоторые из которых являются промежуточными и неестественными, каждый из которых имеет несколько отдельных функций-членов для каждого типа варианта. В конце концов, у вы снова скатываетесь в старый добрый callback hell. Конечно, вы можете использовать конструкцию перегрузки лямбды, но это не сильно меняет дело.

А как насчет вас:

  • Использовали ли вы std::variant и std::visit?

  • Вы использовали их в продакшене или просто в своем небольшом проекте?

Поделитесь своим опытом в комментариях ниже.


Узнать подробнее о курсе "C++ Developer. Professional".

Смотреть открытый вебинар по теме «Области видимости и невидимости».

Источник: https://habr.com/ru/company/otus/blog/546158/


Интересные статьи

Интересные статьи

В 4 части (вы же прочли первую, вторую и третью, да?) мы возвращаемся к нашей цели – создание фильтра для лица в стиле Snapchat, используя то, что мы уже узнали об отслеж...
В этой статье рассматривается создание достаточного простого автотеста. Статья будет полезна начинающим автоматизаторам. Материал изложен максимально доступно, однако, будет значител...
Очереди сообщений используются для выполнения: отложенных операций, взаимодействия сервисов между собой, «batch processing» и т.д. Для организации подобных очередей существуют специализирован...
Как-то у нас исторически сложилось, что Менеджеры сидят в Битрикс КП, а Разработчики в Jira. Менеджеры привыкли ставить и решать задачи через КП, Разработчики — через Джиру.
Сегодня мы публикуем вторую часть перевода материала о расширении синтаксиса JavaScript с использованием Babel. → Головокружительная первая часть