Как стать автором
Обновить

Комментарии 48

Я может чего то не понял, но чем плох самый первый предложенный вариант — когда объект сам решает, как ему считать свою площадь, рисоваться и прочая прочая?
По моему именно такая реализация и ожидается от ООП модели.
И каждый раз, когда понадобится новый функционал, добавлять методы? Это нарушение ocp. А если потом нам фигуры понадобятся в проекте, где не нужны добавленные нами возможности?

Написать экстеншены — плохой вариант?

Экстеншны к чему? У нас на входе — IFigure, конкретный тип неизвестен. Можно только написать экстеншн к IFigure, но как внутри него мы определим, какая именно фигура у нас?
А потом понадобятся в другом проекте — глупая отмазка.
Вы либо пишете либу уровня фреймворка, где у вас есть четко определенные цели и задачи и вы их закрываете, либо пишете свой проект, где реализация будет соответствовать требованиям.

Пытаться совместить эти две вещи, усложняя реализацию — плохая идея. И да, визитор в данном случае — бессмысленное усложнение, код стал сложнее, пользы — не заметно.
Никто не говорит о преждевременной оптимизации. Здесь на нарочно упрощенном примере показано как сделать расширение. Предполагается что в реальной жизни вам это уже понадобилось, и теперь вы знаете как это сделать.
Ок, как скажите. Я же продолжаю считать, что надо всегда стремиться писать переиспользуемый код, и что solid — это полезные рекомендации.
Это нарушение ocp.

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

Лично я, в данной конкретной задаче, не вижу ни одного преимущества visitor'а в сравнении с добавлением некоторого SRP-интерфейса ISquareCalculator, который может как быть частью самого класса, так и существовать отдельно.

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

IoC-контейнер + interface ISquareCalculator<T> where T: IFigure. Получается рафинированный SOLID без единого нарушения.

Только надо ISquareCalculator<in T> where T: IFigure — иначе ISquareCalculator<T> не будет совместим с иначе ISquareCalculator&lFigure>
Вариантность интерфейсов решает вопрос заведомо лучше, чем сам паттерн.

Вы же сами пожертвовали OCP, заметив, что расширения через новые типы фигуры можно и не ждать.

OCP нарушается, но если у вас есть возможность править визитор, то, благодарая этому нарушению, при добавлении новой фигуры вы не забудете написать методы для её обработки. Если же не можем править — то да, решение становится нерасширяемым в направлении новых фигур.
А в отличие от абстрактного «нового функционала» это — наиболее вероятный кейс.

Не факт, зависит от задачи. Если так — то можно модель «перевернуть» — сделать визиторами фигуры )).
IoC-контейнер + interface ISquareCalculator where T: IFigure. Получается рафинированный SOLID без единого нарушения.

Как вы будете резолвить ваш калькулятор, не зная T?

Не факт, зависит от задачи. Если так — то можно модель «перевернуть» — сделать визиторами фигуры )).

Речь идёт о конкретной задаче. Можно, конечно, многое перевернуть с ног на голову, но это только лишний раз доказывает, что пример выбран неудачный)) Преимущества Visitor из него не видны.

Как вы будете резолвить ваш калькулятор, не зная T?

У любого экземпляра фигуры есть метод GetType().

Допустим, у вас реализована эта модель.
Далее понадобилась новая функциональность: считать площадь в определенных единицах измерения на выбор.
Придется добавлять в методы IFiguresVisitor'а новый параметр, который будет абсолютно лишним для WriteToConsoleVisitor и DrawVisitor?

Нет конечно. Как вариант, в конструктор визитора передавать единицы измерения. Или на кажый вариант — свой визитор.
В программе, сама геометрическая фигура, будет выражена исключительно value object.
Если мне нужно работать с прямоугольником в калькуляторе, то я напишу класс Rectangle, который будет находится в пространстве имен geom и именно он будет работать с тем самым vo. Если мне нужно нарисовать прямоугольник, то я создам класс Rectangle в пространстве имен shape и этот класс будет производить расчеты с помощью класса rectangle из пространства имен geom. Разве нет? Мне вообще кажется, что применять визитер уместно только в структурной архитектуре, как например, деревья, как например рендер или физичиские движки, да и то в самых низкоуровневых реализациях.
Лично мне не очень понятно, зачем создавать разные прямоугольники с разными возможностями, когда можно описать отдельно прямоугольник и отдельно — возможности. Но даже если и так — как вы узнаете, что надо создать именно прямоугольник из geom или shape, если на входе — абстрактная IFigure?
интерфейс это гарантия наличия api, либо выполнения контракта. а абстракция, это абстракция, которая выражается ключевым словом abstract. То есть для меня словосочетание абстрактная ЯФигура, немного дико.
и прямоугольник один, он выражен членами структуры, которые описывают информационную модель конкретной фигуры. два одноименных класса в разных пространствах это модели содержащие логику. одна считает, другая рисует. Поэтому мне не совсем понятно как в виде класса содержащего Rectangle.getArea может быть совместима с интерфейсом Rectangle.draw. Ну а так, если это дерево, то визитер, а если что-то другое, то тоже, что-то другое.
интерфейс это гарантия наличия api, либо выполнения контракта. а абстракция, это абстракция, которая выражается ключевым словом abstract. То есть для меня словосочетание абстрактная ЯФигура, немного дико.

Не очень понял вашу мысль. У нас есть контракт IFigure, который выполняют все фигуры. Абстрактная фигура — другими словами. Что значит «гарантия наличия апи» и зачем тут абстрактный класс — не понятно.

Поэтому мне не совсем понятно как в виде класса содержащего Rectangle.getArea может быть совместима с интерфейсом Rectangle.draw.

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

А статья классная, кратко, но очень информативно.
Конечно, модель должна зависеть от задачи и от выбранного подхода к её решению.

Спасибо, приятно!
Паттерн Visitor позволяет сделать универсальный обход структуры. Он становится полезен, когда мы имеем не один объект из семейства, а структуру из множества объектов. Например, внутри треугольника могут лежать квадраты, в которых есть другие треугольники. А у вас Visitor знает только то, как работать с одним треугольником и с одним квадратом. То, как они друг в друга входят, посетитель не знает. Как только Visitor обработал одну фигуру, управление передается в метод accept, а там уже наш Visitor получит для обработки следующую фигуру.

Основная идея данного приема — избавиться от god-метода со свитчом:

switch (figure.type) {
    case Triangle:
        visitTriangle(figure);
        break;
    case Circle:
        visitCircle(figure);
        break;
    case Rectangle(figure);
        visitRectangle(figure);
        break;
}


Если figure — простая фигура, то этот switch можно расширять до бесконечности и особых проблем не заметить. Но если figure состоит из других фигур, то внутри методов visitRectangle, visitCircle, visitTriangle придется сделать отдельные свитчи, и вот тут-то уже возникают реальные сложности, когда можно в 3 объектах запутаться. И тогда на помощь приходит Visitor.
Пример из жизни

OutlookVisitor
public interface OutlookVisitor {

    void visitFolder(FolderElement folder);

    void visitAppointment(ItemElement<Appointment> appointment);

    void visitCalendarMessage(ItemElement<CalendarMessage> calendarMessage);

    void visitReportMessage(ItemElement<ReportMessage> reportMessage);

    void visitTaskRequest(ItemElement<TaskRequest> taskRequest);

    void visitMessage(ItemElement<Message> message);

    void visitItem(ItemElement<Item> item);

    void visitTask(ItemElement<Task> task);

    void visitPost(ItemElement<Post> post);

    void visitNote(ItemElement<Note> note);

    void visitContact(ItemElement<Contact> contact);

    void visitDistributionList(ItemElement<DistributionList> distributionList);

    void visitDocument(ItemElement<Document> document);

    void visitJournal(ItemElement<Journal> journal);

}



OutlookElement
public interface OutlookElement {
    
    void accept(OutlookVisitor visitor) throws IOException;

}



ItemElement
public class ItemElement<T extends Item> implements OutlookElement {
    private final Method visit;
    protected final T item;

    public ItemElement(T item) {
        this.item = item;
        Class<? extends Item> cl = item.getClass();
        String methodName = "visit" + cl.getSimpleName();
        try {
            this.visit = OutlookVisitor.class.getMethod(methodName, ItemElement.class);
        } catch (NoSuchMethodException | SecurityException | IllegalArgumentException e) {
            throw new IllegalArgumentException(e);
        }
    }

    @Override
    public void accept(OutlookVisitor visitor) throws IOException {
        try {
            visit.invoke(visitor, this);
        } catch (SecurityException | IllegalAccessException | IllegalArgumentException | InvocationTargetException e) {
            throw new IOException(e);
        }
    }

    public T getItem() {
        return item;
    }
}



FolderElement
public class FolderElement implements OutlookElement {
    private final Folder folder;

    public FolderElement(Folder folder) {
        this.folder = folder;
    }

    @Override
    public void accept(OutlookVisitor visitor) throws IOException {
        int itemCount = folder.getItemCount();
        List<Item> items = folder.getItems(0, itemCount);
        for (Item item: items) {
            OutlookElement element = new ItemElement<>(item);
            element.accept(visitor);
        }
        List<Folder> folders = folder.getFolders();
        for (Folder child: folders) {
            FolderElement folderElement = new FolderElement(child);
            folderElement.accept(visitor);
        }
        visitor.visitFolder(this);
    }

    public Folder getFolder() {
        return folder;
    }
}



Обход всего, что есть в аутлуке, сверху вниз.

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

Чтобы не писать много однотипных методов, при помощи reflection сделан один универсальный метод accept.

Visitor работает с отдельными элементами, содержимое каталога извлекает FolderElement.

Мне всегда казалось, что большой switch надо разруливать паттерном Commander

Visitor — хороший паттерн, но проблемы начинаются, когда нужно расширять количество обходимых классов в другой сборке, отличной от сборки, в которой определен сам Visitor. Тогда в этих наследниках приходится ожидать один тип Visitor'а и делать cast к наследнику, определенному в этой сборке, что не очень приятно. В такой ситуации уже удобнее бывает просто сделать Dictioary<Type, Action>. С таким подходом потеряем в производительности и возможно в читабельности (хотя и не факт, если такой подход используется часто в кодовой базе), зато получим больше гибкости.
Visitor — хороший паттерн, но проблемы начинаются, когда нужно расширять количество обходимых классов в другой сборке, отличной от сборки, в которой определен сам Visitor.

Спасибо, я как раз про это написал в разделе «недостатки», в конце.

В C# реально сделать визитор для классов из разных сборок.


+    public interface IVisitorItem<in TV>
+    {
+        void Accept(TV visitor);
+    }
+
+    public interface IVisitorBase<in TI>
+    {
+        void Visit(TI item);
+    }

public class Rectangle : IVisitorItem<IVisitorBase<Rectangle>>
{
    public void Accept(IVisitorBase<Rectangle> visitor) => visitor.Visit(this);
}

public class ConsoleWriter
    : IVisitorBase<Rectangle>
    , IVisitorBase<Circle>
{
    public void Visit(Rectangle item) => Console.WriteLine("Rectangle");
    public void Visit(Circle item) => Console.WriteLine("Circle");
}
public void Test(List<IVisitorItem<ConsoleWriter>> list)
    => list.ForEach(_ => _.Accept(ConsoleWriter.Instance));

Классы круга и прямоугольника могут не знать друг о друге и о конкретном визиторе.
Работает за счет внутренних механизмов CLR
Можно написать обертку:


+    public class VisitorTargret<T>
+        : IVisitorItem<IVisitorBase<T>>
+    {
+        private readonly T data;
+
+        public VisitorTargret(T data)
+        {
+            this.data = data;
+        }
+
+        #region Implementation of IVisitorItem<in IVisitorBase<T>>
+
+        public void Accept(IVisitorBase<T> visitor)
+        {
+            visitor.Visit(data);
+        }
+
+        #endregion
+    }
Как будет выглядеть интерфейс IFigure?

Этого интерфейса вообще не будет. Вместо него будет использоваться


IVisitorItem<IFiguresVisitor> item;

Общий посетитель собирается из базовых:


public interface IFiguresVisitor
    : IVisitorBase<Rectangle>
    , IVisitorBase<Circle>
{}

Прямоугольник — цель посетителя прямоугольника.
За счет контрвариации: прямоугольник — цель посетителя фигуры

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

HashMap<Class, Function> dispatch;


Вот вам и весь визитор.

Спасибо за статью.
Хоть выше уже многие и написали, скажу ещё раз:
1) Ваши примеры не совсем подходят к патерну visitor, так как последний чаще используют для обхода структур, имеющие вложенные структуры, те в свою очередь содержат ещё структуры и т.д.
2) Конкретно в Вашем случае, уместны 2 решения — (1) или фигуры содержат методы для их отрисовки, вывода на консоль и расчёт площади в виде реализаций т.е. по 3 метода в каждой из фигур, что иногда позволяет сокрыть данные внутри фигуры не давая к ним доступ даже на чтение (2) или добавить тип фигуры а по этому типу делать связывание с нужным действием, причём замечу, что реализации действий могут быть сделаны по одному экземпляру, быть потокобезопасными и определять функциональные слои (слой отрисовки данных, слой описания данных, слой расчета площади и иных метрик фигур) Вашего фреймворка/библиотеки. И (1) и (2) делают Ваш Фреймворк расширяемым, гибким и предсказуемым, 2ой вариант ещё легко позволит определять действие по умолчанию для конкретного слоя — что значит не обязательность в реализации всех функциональных слоёв для новой фигуры, хотя этот факт может быть и минусом.

Спасибо.
1. По-моему, здесь нет связи: обход структуры — это одна задача, обработка элемента — другая. Визитор — это выбор обработки конкретного типа элемента.
2. Первый вариант мне не нравится, т.к. фигуры получаются жирными и не переиспользуемыми в других моделях. Но этот вариант имеет полное право на жизнь и может быть вполне уместен.
Второй вариант — тип у фигуры и так есть, его можно получить методом GetType :). Т.е. это всё тот же даункастинг. Главный недостаток даункастинга, а также словарей, рефлексии, о которых здесь многие пишут — это то, что мы выбираем метод «руками» в рантайме, что чревато ошибками. К этому следует прибегать, только когда по-другому нельзя. Визитор — это статика и проверка при компиляции.

А почему бы вместо Visitor не сделать отдельный интерфейс DrawableShape и классы, его реализующие. Например так:


public interface DrawableShape
{
    public void draw();
}

public class DrawableRectangle : DrawableShape
{
    public DrawableRectangle(Rectangle rectangle)
    {
        this.rectangle = rectangle;
    }

    public void draw()
    {
         // тут как-то рисуем
    }
}

"предпочитайте композицию наследованию" :)

Так это и есть композиция)

В таком случае у Вас все равно придется написать код, который будет сопоставлять Вашу вторую иерархию отрисовки с основной иерархией, что приводит к той же проблеме.
А со временем это разрастается в такое, что поддерживать становится невозможно. Особенно учитывая кучи разработчиков, что засунут свои грязные ручки в данный код. Код будет раскидан по куче мест. Отпинываться от ООП ради спорных идиологий и терять ресурсы на поддержке, это всё же пахнет глупым фанатизмом.
Где же вы увидели отпинывание от ООП? И какую модель, на ваш взгляд, проще поддерживать?
Предположу, что под «отпиныванием от ООП» вы имели в виду сам факт, что описываемый паттерн предлагает альтернативу пополнению базового интерфейса IFigure функциями на все случаи жизни (что более «естественно» и, действительно, в ряде ситуаций является лучшей альтернативой).

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

Если столбцов мало, и они редко добавляются / меняются, а строк много, и они чаще добавляются / меняются, в линейном программном коде удобно группировать строки (реализовывать «естественный» подход, описанный выше). Полное описание строки — класса, реализующего IFigure, расположено в одном файле, где рядом сидит и рисование данной фигуры, и вывод на консоль, и вычисление площади… Немного разношёрстно, но зато — вот всё про эту фигуру, в одном месте! Правда, в случае с деревьями вложенных объектов логика обхода дерева объектов может оказаться многократно продублированной (для разных методов IFigure), но это не страшно, пока IFigure остаётся достаточно компактным и стабильным интерфейсом (т.е., повторяясь, когда в воображаемой таблице набор столбцов не слишком велик и достаточно стабилен).

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

То, что при первой встрече паттерн Визитор выглядит переусложнённым, неудивительно — структура классических ООП языков подталкивает к группировке по строкам, она как бы встраивается в арсенал программиста с первых шагов изучения ООП. Паттерны же вроде Визитора были изобретены и пере-изобретены для улучшения структуры кода в ситуациях, когда решения в лоб приводят к накапливающимся проблемам («разрастается в такое, что поддерживать становится невозможно»). Да, и, кстати, группировку по столбцам можно организовать и по-другому, в конкретной ситуации применение Визитора может быть, действительно, не самой удачной идеей. Тем не менее, последняя фраза вашего комментария («Отпинываться от ООП ради спорных идиологий и терять ресурсы на поддержке, это всё же пахнет глупым фанатизмом.») мне не кажется обоснованной без рассмотрения конкретных ситуаций. Повторюсь, имел дело с двумя широкими областями (синтаксический разбор, деловая графика) где использование Визитора вполне уместно и де-факто является стандартной для этих областей практикой.
Спасибо за развёрнутый комментарий.
Предположу, что под «отпиныванием от ООП» вы имели в виду сам факт, что описываемый паттерн предлагает альтернативу пополнению базового интерфейса IFigure функциями на все случаи жизни (что более «естественно» и, действительно, в ряде ситуаций является лучшей альтернативой).

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

Мой пример про 3 фигуры — упрощённый. Я не призываю в первой же лабораторке по полиморфизму прикручиваться визитор ))
Спасибо за статью! У меня ещё два пункта для обсуждения, пользуюсь случаем:

1. Вы написали, что для генерации обвязки думали взять Roslyn — но не найдётся ли способов проще, например, основанных на атрибутах, использовании рефлексии, code rewrite? [ Я — C++ программист, c C# знаком неплохо, но не на экспертном уровне. Думаю полистать книгу «Metaprogramming in .Net» (2013). Использую в работе генерацию кода в небольшом DSL-фреймворке, созданном коллегами, там для фронтенда (построения AST) используется Antlr, для шаблонов генерации — T4; DSL-языки похожи синтаксисом на C#. Я думаю об освоении других фронтенд-технологий, и использование структуры Roslyn-а — один из вариантов (им пользуется, например, разработчик HlslTools для DSL «High Level Shading Language», он перешёл на такую структуру с Antlr-а, чтобы улучшить скорость разбора и восстановление при синтаксических ошибках). Есть ещё работоспособные альтернативы для фронтенда, но более экзотические (у меня на примете их примерно 3-4). Короче, меня эта тема довольно-таки занимает, пользуюсь случаем попробовать обменяться мнениями. ]

2. В статье и комментариях затрагивается проблема с расширяемостью системы при невозможности обновления исходной сборки библиотеки, в которой определены конкретные классы (или, скорее, специализирующие производные интерфейсы), наследующие IFigure. Мне кажется, если эту проблему предвидеть в дизайне библиотеки, она может решаться, хотя бы частично; детально я идею в этом направлении не обдумал ещё, правда. Автор библиотеки для того, чтобы допустить возможность расширения, оставляет один интерфейс-наследник IFigure (специально для этого предназначенный и названный, скажем, IOtherFugure) незапечатанным. Для пользователей библиотеки, или авторов новых версии библиотеки, исходный (v1) набор фигур не меняется, обслуживающие их визиторы стабильны и не нуждаются в изменениях при переходе на v2 с новыми фигурами — но нуждаются в подвеске дополнительных визиторов для новых фигур, представленных интерфейсами, наследующими от IOtherFugure. Для этого семейства в v2 создаётся новый интерфейс визиторов. Результирующая картина смеси v1 и v2 выглядит причудливо, но даёт возможность сохранить код, написанный для v1, без модификаций. Параллельно v2 предоставляет новый свежий «100% чистый v2» API без разделения на фигуры v1 / новые фигуры v2, чтобы при свежем старте на v2 или возможности переписать v1-зависимый код можно было бы иметь чистую картину, без кудрявостей. Мне кажется, такой подход может работать в реальных проектах, хотя он и «сложный».
1. Мне особо и не приходилось сталкиваться с кодогенерацией прежде, поэтому ничего интересного рассказать не могу. Roslyn — первое, что приходит в голову, т.к. очень популярен сейчас. Нашёл вот такой пример https://daveaglick.com/posts/compiler-platform-in-t4, из него можно идею стянуть.

2. Мы получим метод Visit(IOtherFigure otherFigure). И эту самую otherFigure придётся кастить руками к чему-то. Так ведь?
1. Спасибо за ссылку, посмотрел и буду иметь в виду.
2. Да, нужно будет кастить. Я покрутил в голове, и решил эту тему бросить — не стоит усилий в данный момент.

Спасибо за обсуждение!
Много кода как-то, а что если у вас будет до 1000 разных геометрических объектов? Ад же…
Если программисту доступен multiple dispatch, то зачем ему визитор? А если программист не понимает, что multiple dispatch иногда надобится — зачем такой программист? )))
Причем тут multiple? В C# доступен dynamic dispatch, вы наверное это имели ввиду. Да, доступен, но жутко тормозит.
tl;dr
В некоторых случаях применение паттерн матчинга целесообразней/удобней применения визитора. (кто юзал хаскель или раст давно об этом знают)
НЛО прилетело и опубликовало эту надпись здесь
согласен, примерно это я и имел ввиду.
Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации

Истории