Некоторые мысли о паттерне Visitor

в 15:05, , рубрики: .net, C#, visitor pattern, Проектирование и рефакторинг

В последнее время очень мне часто приходится использовать всем известный паттерн «Visitor» (он же Посетитель, далее — визитор). Раньше же я им пренебрегал, считал костылём, лишним усложнением кода. В данной статье я поделюсь своими мыслями о том, что в этом паттерне, на мой взгляд, хорошо, что плохо, какие задачи он помогает решить и как упростить его использование. Код будет на C#. Если интересно – прошу под кат.

Некоторые мысли о паттерне Visitor - 1

Что это такое ?

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

Некоторые мысли о паттерне Visitor - 2

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

Потом нам понадобилось выводить описание каждой фигуры в консоль. А затем рисовать фигуры. Добавляя соответствующие методы, мы раздуваем нашу библиотеку, попутно жёстко нарушая SRP и OCP.

Что же делать? Конечно, в отдельных библиотеках создать классы, решающие нужные нам задачи. Как они узнают, какую конкретно фигуру им передали? Приведение типов!

public void Draw(IFigure figure)
        {
            if (figure is Rectangle)
            {
                ///////
                return;
            }
            if (figure is Triangle)
            {
                ///////
                return;
            }
            if (figure is Triangle)
            {
                ///////
                return;
            }
        }

Увидели ошибку? А я заметил её только в рантайме. Даункастинг — это всеми признаный дурной тон, путь к нарушению LSP и т. д. и т.п… Есть языки, система типов которых решает нашу задачу «из коробки» (см. мультиметоды), но C# к ним не относится.

Вот тут и приходит на помощь Визитор ака Посетитель. Суть вот в чём: есть класс – визитор, который содержит методы для работы с каждой из конкретных реализаций нашей абстракции. А каждая конкретная реализация содержит метод, который делает одну единственную вещь — передаёт себя соответствующему методу визитора.

Некоторые мысли о паттерне Visitor - 3

Немного запутанно, не так ли? Вообще, один из главных недостатков визитора — то, что в него не все сходу въезжают (сужу по себе). Т.е. его использование несколько повышает порог сложности вашей системы.

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

Варианты приготовления

Значение какого типа должны возвращать методы Visit и AcceptVisitor? В классическом варианте они void. Как быть в случае расчёта площади? Можно завести в визиторе свойство и присваивать ему значение, а после вызова Visit его читать. Но гораздо удобнее, чтобы метод AcceptVisitor сразу возвращал результат. В нашем случае тип результата – double, но очевидно что это не всегда так. Сделаем визитор и метод AcceptVisitor дженериками.

public interface IFigure
    {
        T AcceptVisitor<T>(IFiguresVisitor<T> visitor);
    }

public interface IFiguresVisitor<out T>
    {
        T Visit(Rectangle rectangle);
        T Visit(Triangle triangle);
        T Visit(Circle circle);
    }

Такой интерфейс можно использовать во всех кейсах. Для асинхронных операций типом результата будет Task. Если ничего не нужно возвращать, то возвращаемым типом может быть тип-пустышка, известный в функциональных языках как Unit. В C# он тоже определён в некоторых библиотеках, например, в Reactive Extensions.

Бывают ситуации, когда, в зависимости от типа объекта нам нужно выполнить какое-то тривиальное действие, да всего в одном месте программы. Например, на практике выводить название фигуры вряд ли нам где-то понадобится, кроме как в тестовом примере. Или в каком-нибудь юнит-тесте надо определить, что фигура – окружность либо прямоугольник. Что же, для каждого такого примитивного случая создавать новую сущность – специализированный визитор? Можно поступить по-другому:

public class FiguresVisitor<T> : IFiguresVisitor<T>
    {
        private readonly Func<Circle, T> _ifCircle;
        private readonly Func<Rectangle, T> _ifRectangle;
        private readonly Func<Triangle, T> _ifTriangle;

        public FiguresVisitor(Func<Rectangle, T> ifRectangle, Func<Triangle, T> ifTrian-gle, Func<Circle, T> ifCircle)
        {
            _ifRectangle = ifRectangle;
            _ifTriangle = ifTriangle;
            _ifCircle = ifCircle;
        }

        public T Visit(Rectangle rectangle) => _ifRectangle(rectangle);

        public T Visit(Triangle triangle) => _ifTriangle(triangle);

        public T Visit(Circle circle) => _ifCircle(circle);
    } 

public double CalcArea(IFigure figure)
        {
            var visitor = new FiguresVisitor<double>(
                r => r.Height * r.Width,
                t =>
                {
                    var p = (t.A + t.B + t.C) / 2;
                    return Math.Sqrt(p * (p - t.A) * (p - t.B) * (p - t.C));
                },
                c => Math.PI * c.Radius * c.Radius);

            return figure.AcceptVisitor(visitor);
        }

Как видите, получилось нечто, напоминающее паттерн-матчинг. Не тот, который добавили в C# 7 и который, по сути, лишь припудренный даункастинг, а типизированный и контролируемый компилятором.

А что, если у нас с десяток фигур, и нам нужно лишь для одной или двух выполнить нечто-особенное, а для остальных – какое-то действие «по умолчанию»? Копипастить в конструктор десяток одинаковых выражений – лениво и некрасиво. Как насчёт такого синтаксиса?

string description = figure
                .IfRectangle(r => $"Rectangle with area={r.Height * r.Width}")
                .Else(() => "Not rectangle");

bool isCircle = figure
                .IfCircle(_=>true)
                .Else(() => false);

В последнем примере получился настоящий аналог оператора «is»! Реализация данной фабрики для нашего набора фигур, как все остальные исходники — на гитхабе. Напрашивается вопрос – что же, для каждого случая писать этот бойлерплейт? Да. Или можно, вооружившись T4 и Roslyn, написать кодогенератор. Признаться, к моменту публикации статьи я планировал это сделать, но времени в обрез — не успел.

Недостатки

Конечно, визитор имеет достаточно недостатков и ограничений в применении. Взять хотя бы метод AcceptVisitor у IFifgure. Какое отношение он имеет к геометрии? Да никакого. Так что опять имеем нарушение SRP.

Далее, взглянем на схему ещё раз.

Некоторые мысли о паттерне Visitor - 4

Мы видим замкнутую систему, где все знают обо всех. Каждый тип иерархии знает о визиторе – визитор знает обо всех типах – следовательно, каждый тип транзитивно знает обо всех других! Добавление нового типа ( фигуры в нашем примере ) фактически затрагивает всех. А это – снова прямое нарушение ранее упомянутого Open Close Principle. Если мы имеем возможность менять код, то в этом даже есть существенный плюс – если мы добавим новую фигуру, компилятор заставит нас добавить соответствующий метод в интерфейс визитора и его реализации – мы ничего не забудем. Но как быть, если мы только пользователи библиотеки, а не авторы, и не можем менять иерархию? Никак. Расширить чужую структуру с визитором мы никак не можем. Не зря во всех определениях паттерна пишут, что его применяют при наличии устоявшейся иерархии. Таким образом, если мы проектируем расширяемую библиотеку геометрических фигур, использовать визитор мы никак не можем.

Итого

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

В случае, если мы пишем библиотеку, которую можно расширять, добавляя новые типы, то визитор использовать не получится. А что тогда? Да всё тот же даункастинг, завёрнутый в паттрн-матчинг в C# 7. Или придумать что-нибудь поинтереснее. Если получится – постараюсь написать и об этом.

И, конечно, буду рад почитать мнения и идеи в камментах.
Спасибо за внимание!

Автор: IL_Agent

Источник

Поделиться

* - обязательные к заполнению поля