Перейти к содержанию

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

Категория: паттерн поведения.

Проблема

Есть стабильная иерархия классов (набор видов фигур, узлов AST, элементов документа), но со временем нужно добавлять всё новые операции над этой иерархией (посчитать площадь, отрисовать, экспортировать, провалидировать). Если добавлять каждую операцию как метод прямо в базовый класс и его наследников, то: - классы иерархии разрастаются операциями, которые концептуально им не принадлежат (фигура ничего не должна "знать" про экспорт в конкретный формат); - при каждой новой операции приходится трогать и перекомпилировать все классы иерархии, даже если сама иерархия не менялась.

Решение

  1. Для иерархии вводится метод Accept(visitor), который есть у каждого класса и просто передаёт this в подходящий метод посетителя (visitor.Visit(this)).
  2. Определяется интерфейс посетителя с отдельным методом Visit(...) на каждый конкретный тип из иерархии.
  3. Каждая новая операция оформляется отдельным классом, реализующим интерфейс посетителя - без изменения классов самой иерархии.
  4. Благодаря Accept вызывается перегрузка Visit, соответствующая реальному типу объекта (двойная диспетчеризация), поэтому не требуется приведение типов (as/is) внутри операций.

Структура

  • Element - интерфейс элементов иерархии с методом Accept(IVisitor visitor).
  • ConcreteElementA/B - конкретные элементы, каждый передаёт себя в Visitor.Visit(this).
  • Visitor - интерфейс с перегруженным методом Visit на каждый конкретный элемент.
  • ConcreteVisitor - конкретная операция над иерархией.

Двойная диспетчеризация

Обычный вызов метода в C# выбирает перегрузку по статическому (объявленному) типу параметра. Прямой вызов visitor.Visit(shape), где shape объявлен как IShape, выбрал бы единственную подходящую по статическому типу перегрузку. Приём Accept решает эту проблему: внутри Circle.Accept компилятор уже точно знает, что this имеет тип Circle (а не IShape), поэтому вызов visitor.Visit(this) разрешается в перегрузку именно для Circle. Такой двухшаговый вызов (AcceptVisit) и называют двойной диспетчеризацией.

Когда применять

  • Иерархия классов стабильна (новые виды элементов добавляются редко), а операции над ней добавляются часто.
  • Нужно выполнять разные, не связанные друг с другом операции над одной и той же структурой объектов, не засоряя сами классы этой логикой.
  • Нужно избавиться от цепочек if (x is TypeA) ... else if (x is TypeB) ... при обработке разных типов из иерархии.

Плюсы

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

Минусы

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

Пример в .NET Framework / BCL

  • ExpressionVisitor в System.Linq.Expressions - классический учебный пример прямо из BCL: позволяет обходить и трансформировать деревья выражений LINQ, добавляя новые операции (например, перевод в SQL) без изменения классов Expression.
  • Разбор синтаксических деревьев в Roslyn (SyntaxVisitor, CSharpSyntaxVisitor) - применяется для анализа и рефакторинга кода C#.

Пример реализации на C#

Visitor.cs
using System;
using System.Collections.Generic;

namespace DesignPatterns.Behavioral.Visitor
{
    // Иерархия классов фигур - стабильная, новые виды фигур добавляются редко,
    // зато операции над ними (площадь, отрисовка, экспорт) добавляются часто.
    public interface IShape
    {
        // Каждая фигура умеет "принять" посетителя и передать себя в нужный метод
        void Accept(IShapeVisitor visitor);
    }

    public sealed class Circle : IShape
    {
        public double Radius { get; }
        public Circle(double radius) => Radius = radius;

        public void Accept(IShapeVisitor visitor) => visitor.Visit(this);
    }

    public sealed class Rectangle : IShape
    {
        public double Width { get; }
        public double Height { get; }

        public Rectangle(double width, double height)
        {
            Width = width;
            Height = height;
        }

        public void Accept(IShapeVisitor visitor) => visitor.Visit(this);
    }

    // Интерфейс посетителя содержит по методу на каждый конкретный тип фигуры -
    // это позволяет обойтись без приведения типов (as/is) внутри операций.
    public interface IShapeVisitor
    {
        void Visit(Circle circle);
        void Visit(Rectangle rectangle);
    }

    // Конкретный посетитель добавляет новую операцию - подсчёт площади -
    // не изменяя ни один из классов фигур.
    public sealed class AreaCalculator : IShapeVisitor
    {
        public double TotalArea { get; private set; }

        public void Visit(Circle circle) => TotalArea += Math.PI * circle.Radius * circle.Radius;

        public void Visit(Rectangle rectangle) => TotalArea += rectangle.Width * rectangle.Height;
    }

    // Ещё одна операция - экспорт в текстовое описание, снова без изменения классов фигур
    public sealed class ShapeDescriptionExporter : IShapeVisitor
    {
        private readonly List<string> _lines = new();

        public void Visit(Circle circle) => _lines.Add($"Круг радиусом {circle.Radius}");

        public void Visit(Rectangle rectangle) => _lines.Add($"Прямоугольник {rectangle.Width}x{rectangle.Height}");

        public string Export() => string.Join(Environment.NewLine, _lines);
    }

    public static class Demo
    {
        public static void Run()
        {
            var shapes = new List<IShape>
            {
                new Circle(radius: 3),
                new Rectangle(width: 4, height: 5),
            };

            var areaCalculator = new AreaCalculator();
            var exporter = new ShapeDescriptionExporter();

            foreach (var shape in shapes)
            {
                shape.Accept(areaCalculator);
                shape.Accept(exporter);
            }

            Console.WriteLine($"Суммарная площадь: {areaCalculator.TotalArea:F2}");
            Console.WriteLine(exporter.Export());
        }
    }
}

Открыть Visitor.cs отдельно Скачать Visitor.cs