Посетитель (Visitor)

Суть паттерна

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

Проблема

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

Ваша задача — сделать экспорт этого графа в XML. Дело было бы плёвым, если бы вы могли редактировать классы узлов. Достаточно было бы добавить метод экспорта в каждый тип узла, а затем, перебирая узлы графа, вызывать этот метод для каждого узла. Благодаря полиморфизму, решение получилось бы изящным, так как вам не пришлось бы привязываться к конкретным классам узлов.

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

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

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

Решение

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

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

class ExportVisitor implements Visitor is
    method doForCity(City c) { ... }
    method doForIndustry(Industry f) { ... }
    method doForSightSeeing(SightSeeing ss) { ... }
    // ...

Здесь возникает вопрос: как подавать узлы в объект-посетитель? Так как все методы имеют отличающуюся сигнатуру, использовать полиморфизм при переборе узлов не получится. Придётся проверять тип узлов для того, чтобы выбрать соответствующий метод посетителя.

foreach (Node node in graph)
    if (node instanceof City)
        exportVisitor.doForCity((City) node)
    if (node instanceof Industry)
        exportVisitor.doForIndustry((Industry) node)
    // ...

Тут не поможет даже механизм перегрузки методов (доступный в Java и C#). Если назвать все методы одинаково, то неопределённость реального типа узла всё равно не даст вызвать правильный метод. Механизм перегрузки всё время будет вызывать метод посетителя, соответствующий типу Node, а не реального класса поданного узла.

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

// Client code
foreach (Node node in graph)
    node.accept(exportVisitor)

// City
class City is
    method accept(Visitor v) is
        v.doForCity(this)
    // ...

// Industry
class Industry is
    method accept(Visitor v) is
        v.doForIndustry(this)
    // ...

Как видите, изменить классы узлов всё-таки придётся. Но это простое изменение позволит применять к объектам узлов и другие поведения, ведь классы узлов будут привязаны не к конкретному классу посетителей, а к их общему интерфейсу. Поэтому если придётся добавить в программу новое поведение, вы создадите новый класс посетителей и будете передавать его в методы узлов.

Аналогия из жизни

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

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

  • Придя в банк, он предлагает страховку от грабежа.

  • Придя на фабрику, он предлагает страховку предприятия от пожара и наводнения.

Структура

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

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

  3. Элемент описывает метод принятия посетителя. Этот метод должен иметь единственный параметр, объявленный с типом интерфейса посетителя.

  4. Конкретные элементы реализуют методы принятия посетителя. Цель этого метода — вызвать тот метод посещения, который соответствует типу этого элемента. Так посетитель узнает, с каким именно элементом он работает.

  5. Клиентом зачастую выступает коллекция или сложный составной объект, например, дерево Компоновщика. Зачастую клиент не привязан к конкретным классам элементов, работая с ними через общий интерфейс элементов.

Применимость

Когда вам нужно выполнить какую-то операцию над всеми элементами сложной структуры объектов, например, деревом.

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

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

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

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

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

Шаги реализации

  1. Создайте интерфейс посетителя и объявите в нём методы «посещения» для каждого класса элемента, который существует в программе.

  2. Опишите интерфейс элементов. Если вы работаете с уже существующими классами, то объявите абстрактный метод принятия посетителей в базовом классе иерархии элементов.

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

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

  5. Для каждого нового поведения создайте конкретный класс посетителя. Приспособьте это поведение для работы со всеми типами элементов, реализовав все методы интерфейса посетителей.

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

  6. Клиент будет создавать объекты посетителей, а затем передавать их элементам, используя метод принятия.

Преимущества и недостатки

  • Упрощает добавление операций, работающих со сложными структурами объектов.

  • Объединяет родственные операции в одном классе.

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

  • Паттерн не оправдан, если иерархия элементов часто меняется.

  • Может привести к нарушению инкапсуляции элементов.

Пример

# include <iostream>
# include <memory>
# include <vector>

using namespace std;

class Circle;
class Rectangle;

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

        virtual void visit(Circle& ref) = 0;
        virtual void visit(Rectangle& ref) = 0;
};

class Shape
{
public:
        virtual ~Shape() = default;
        
        virtual void accept(shared_ptr<Visitor> visitor) = 0;
};

class Circle : public Shape
{
public:
        void accept(shared_ptr<Visitor> visitor) override { visitor->visit(*this); }
};

class Rectangle : public Shape
{
public:
        void accept(shared_ptr<Visitor> visitor)  override { visitor->visit(*this); }
};

class ConVisitor : public Visitor
{
public:
        void visit(Circle& ref) override { cout << "Circle;" << endl; }
        void visit(Rectangle& ref) override { cout << "Rectangle;" << endl; }
};

class Figure : public Shape
{
        using Shapes = vector<shared_ptr<Shape>>;

private:
        Shapes shapes;

public:
        Figure(initializer_list<shared_ptr<Shape>> list)
        {
                for (auto&& elem : list)
                        shapes.emplace_back(elem);
        }

        void accept(shared_ptr<Visitor> visitor)  override
        {
                for (auto& elem : shapes)
                        elem->accept(visitor);
        }
};

int main()
{
        shared_ptr<Shape> figure = make_shared<Figure>(
                        initializer_list<shared_ptr<Shape>>(
                                        { make_shared<Circle>(), make_shared<Rectangle>(), make_shared<Circle>() }
                        )
        );
        
        shared_ptr<Visitor> visitor = make_shared<ConVisitor>();
        
        figure->accept(visitor);
}

Пример. Приведение типа между базовыми классами

# include <iostream>
# include <vector>
# include <memory>

using namespace std;

class AbstractVisitor
{
public:
        virtual ~AbstractVisitor() = default;
};

template <typename T>
class Visitor
{
public:
        virtual ~Visitor() = default;

        virtual void visit(const T&) const = 0;
};

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

        virtual void accept(const AbstractVisitor&) const = 0;
};

class Circle : public Shape
{
private:
        double radius;

public:
        Circle(double radius) : radius(radius) {}

        void accept(const AbstractVisitor& v) const override
        {
                auto cv = dynamic_cast<const Visitor<Circle>*>(&v);

                if (cv)
                {
                        cv->visit(*this);
                }
        }
};

class Square : public Shape
{
private:
        double side;

public:
        Square(double side) : side(side) {}

        void accept(const AbstractVisitor& v) const override
        {
                auto cv = dynamic_cast<const Visitor<Square>*>(&v);

                if (cv)
                {
                    cv->visit(*this);
                }
        }
};

class DrawCircle : public Visitor<Circle>
{
        void visit(const Circle& circle) const override
        {
                cout << "Circle" << endl;
        }
};

class DrawSquare : public Visitor<Square>
{
        void visit(const Square& circle) const override
        {
                cout << "Square" << endl;
        }  
};

class Figure : public Shape
{
        using Shapes = vector<shared_ptr<Shape>>;

private:
         Shapes shapes;

public:
        Figure(initializer_list<shared_ptr<Shape>> list)
        {
                for (auto&& elem : list)
                        shapes.emplace_back(elem);
        }

        void accept(const AbstractVisitor& visitor) const override
        {
                for (auto& elem : shapes)
                        elem->accept(visitor);
        }
};

class Draw : public AbstractVisitor, public DrawCircle, public DrawSquare {};

int main()
{
        shared_ptr<Shape> figure = make_shared<Figure>(
                initializer_list<shared_ptr<Shape>>({ make_shared<Circle>(1), make_shared<Square>(2) })
        );

        figure->accept(Draw{});
}

Пример с использованием шаблона variant ("безопасный" union)

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

using namespace std;

class Circle {};
class Square {};

using Shape = std::variant<Circle, Square>;

class Formation
{
public:
        static vector<Shape> initialization(initializer_list<Shape> list)
        {
                vector<Shape> vec;

                for (auto&& elem : list)
                        vec.emplace_back(elem);

                return vec;
        }
};

class Draw
{
public:
        void operator ()(const Circle&) const { cout << "Circle" << endl; }
        void operator ()(const Square&) const { cout << "Square" << endl; }
};

int main()
{
        using Shapes = vector<Shape>;

        Shapes fiqure = Formation::initialization({ Circle{}, Square{} });

        for (const auto& elem : fiqure)
                std::visit(Draw{}, elem);
}

Last updated