std::visit — это круто, и вот почему

Одной из очень немногих особенностей стандартной библиотеки C++, которая мне безоговорочно нравится, является расширение std::visit. Он уменьшает объем шаблонного кода, убивает нескольких зайцев одним выстрелом и при этом не жертвует производительностью. Итак, давайте посмотрим, как его использовать.

Шаблон посетителя

Шаблон «Посетитель» является более сложным шаблоном проектирования ООП, но как только вы его поймете, он станет отличным инструментом для разрушения зависимостей и удаления бестолковых условных операторов с помощью RTTI.

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

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

Если вы хотите узнать, как внедрить Visitor с нуля, не ищите ничего, кроме превосходного веб-сайта refactoring.guru.

Без наследства

В отличие от стандартной объектно-ориентированной реализации шаблона Посетитель, std::visit не требует использования наследования. Из-за того, что в основе лежит использование std::variant и магии темной стандартной библиотеки, std::visit не использует двойную диспетчеризацию, и поэтому вам не нужен какой-либо метод accept в ваших структурах данных:

#include <iostream>
#include <variant>
#include <vector>

struct NodeA {};
struct NodeB {};
struct NodeC {};

using Nodes = std::variant<NodeA, NodeB, NodeC>;

struct Visitor {
    void operator() (const NodeA&) { std::cout << "NodeA" << std::endl; }
    void operator() (const NodeB&) { std::cout << "NodeB" << std::endl; }
    void operator() (const NodeC&) { std::cout << "NodeC" << std::endl; }
};

int main() {
    std::vector<Nodes> nodes = {
        NodeA{},
        NodeB{},
        NodeA{},
        NodeC{}
    };

    for (auto&& node : nodes) std::visit(Visitor{}, node);

    // Prints:
    // NodeA
    // NodeB
    // NodeA
    // NodeC
}

Как видите, все, что вам нужно, — это вызываемая структура с перегрузками для требуемых типов. Обратите внимание, что вам нужно перегрузить все типы, принадлежащие Nodes, иначе вы получите ошибку компилятора (как при сопоставлении с образцом в других языках).

Сама ошибка представляет собой классическую std чепуху, поэтому обратите внимание на это сообщение:

no type named 'type' in 'std::invoke_result<Visitor, TypeThatIsMissing&>'

С наследством

Однако std::visit позволяет использовать наследование. Это еще более темная магия, потому что вы можете, но не обязаны добавлять перегрузки только для некоторых унаследованных типов. Алгоритм должен иметь возможность сопоставлять тип по крайней мере в одной перегрузке, и базовый класс также применяется:

#include <iostream>
#include <variant>
#include <vector>

struct NodeBase {};
struct NodeA : public NodeBase{};
struct NodeB : public NodeBase{};

using Nodes = std::variant<NodeA, NodeB>;

struct Visitor {
    void operator() (const NodeA&) { std::cout << "NodeA" << std::endl; }
    void operator() (const NodeBase&) { std::cout << "NodeBase" << std::endl; }
};

int main()
{
    std::vector<Nodes> nodes = {
        NodeA{},
        NodeB{},
    };

    for (auto&& node : nodes) std::visit(Visitor{}, node);

    // Prints:
    // NodeA
    // NodeBase
}

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

Быстрее, чем отправка вручную

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

Я настоятельно рекомендую посмотреть доклад Клауса Инглебергера о реализации паттерна Visitor, где он также показывает время сравнения на GCC и Clang (с отметкой времени в части со сравнением производительности).

Два года назад я выполнил свои собственные тесты именно для этого случая на MSVC, и, хотя отладочные сборки были мучительно медленными, оптимизированные сборки показали явное преимущество std::visit над другими методами. (Я написал об этом статью, но она на чешском языке).

Функциональное программирование

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

#include <iostream>
#include <variant>
#include <vector>

struct NodeA {};
struct NodeB {};

using Nodes = std::variant<NodeA, NodeB>;

template<class... Ts>
struct overloaded : Ts... { using Ts::operator()...; };

// Some compilers might require this explicit deduction guide
template<class... Ts>
overloaded(Ts...) -> overloaded<Ts...>;

int main()
{
    std::vector<Nodes> nodes = {
        NodeA{},
        NodeB{},
    };

    for (auto&& node : nodes) {
        std::visit(overloaded{
            [] (const NodeA&) { std::cout << "NodeA" << std::endl; },
            [] (const NodeB&) { std::cout << "NodeB" << std::endl; }
        }, node);
    }

    // Prints:
    // NodeA
    // NodeB
}

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

Недостатки

Как отметил Клаус в своем выступлении, каждый подход имеет свои преимущества и недостатки. Поскольку этот подход основан на std::variant, вы должны помнить о его недостатках:

  1. Типы должны быть полностью определены, прежде чем их можно будет использовать в варианте. Это связано с тем, что необходимо знать размер их хранилища.
  2. Использование типов с совершенно разными размерами означает, что ваш вариант по своей сути будет тратить много места.
  3. Добавление нового типа может означать обновление всех существующих посетителей (но не в том случае, если вы используете наследование) и обновление варианта (чего вам не нужно делать при классическом объектно-ориентированном подходе).

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

Краткое содержание

Как я уже сказал в начале, мне очень нравится std::visit. Реализация шаблона «Посетитель» требует некоторого привыкания, и с помощью этой функции вы значительно сократите количество шаблонов, улучшите читаемость кода и не пожертвуете производительностью.

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