Шаблон Visitor устарел для Kotlin, но знать его стоит

    Рассмотрим шаблон проектирования Visitor и покажем, что использовать его при программировании на Kotlin не стоит. Будет теория, минималистичная реализация, реализация замены и доводы в пользу замены, подкрепленные практическими изысканиями. Не будет диаграмм классов. Все попробовать можно онлайн в play.kotlinlang.org



    На моем крайнем последнем месте работы в руки попал микросервис, который преобразовывал один дурной JSON в менее дурной. Разработчик вкрутил туда Finite State Machine, а сверху набросил шаблон Visitor. Про State Machine не так интересно, но вот когда я увидел Visitor, я выдохнул — "Ого!". Это большая редкость, потому что я считаю, что этот прием редко кто может понять правильно. Тут ярко выраженный феномен ложного понимания — вроде понятно, но в упор не видят его применимости и принцип работы ускользает, о нем некомфортно думать. Нормально принимают его разработчики компиляторов и подобного — что говорит о высоком уровне, необходимом для освоения. Но область применения гораздо шире — ее просто сложно распознать.


    Пространная теория


    Я люблю Visitor. Платонической любовью и на расстоянии. Мои коллеги знают, где я живу и мой профиль на LinkedIn активен, если вы понимаете, о чем я говорю. Visitor прекрасно иллюстрирует полиморфизм, отличие перегрузки от переопределения методов и является примером разворота управления (это я так инверсию контроля замаскировал). Я бы его назвал Hollywood Agent — он как бы говорит "ты мне не звони — я тебе сам позвоню". Визитор также не является поведенческим шаблоном объектно-ориентированного программирования. На самом деле это шаблон статически разрешаемого языка программирования, в котором есть перегрузка методов и переопределение сигнатур методов. В Python и JavaScript надобности в нем нет. Я бы его назвал "трюком", но трюк — по большому счету — может быть шаблоном, раз применияется в типичных ситуациях. В языках типа Java он еще и повышает читабельность.


    Проблему, которую решает шаблон, обычно описывают как невозможность (или сложность) определить тип аргумента метода. Если для типа объекта, на котором вызывается метод, у нас есть полиморфизм (таблица виртуальных методов), то для его аргументов все разрешается только на этапе компиляции. В идеальном объектном подходе это не обязательно так — объекты якобы обмениваются сообщениями и дальше как хотите. В Kotlin "как хотите" идет так, что может возникнуть неопределенность на этапе компиляции (или в процессе выполнения, если попытаться схитрить).


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


    Все закодируем


    Примеры с геометрическими фигурами и калькуляторами являются классикой, так что попробуем новое: Форма жизни. Я импользую sealed class для уменьшения количества условий проверки в будущем, для простоты.
    Ситуация: мы пришли в гости и нас встречают кот и человек.


    fun main() {
        Guest().visit( Human("Сергей"))
        Guest().visit( Cat("рыжий"))
    }
    
    sealed class LivingCreature
    class Human(val name: String): LivingCreature()
    class Cat(val colour: String): LivingCreature()
    
    interface Visitor {
        fun visit(creature : Human)
        fun visit(creature : Cat)
    }
    class Guest: Visitor {
        override fun visit(human: Human) = println("Поздоровайся с ${human.name}")
        override fun visit(pet: Cat) = println("Погладь котика (это который ${pet.colour})")
    }

    Внешняя функциональность — поведение гостя при встрече. Пока все отлично, метод visit идет куда следует. Увы, часто нам Human и Cat приходят как аргументы метода с типом LivingCreature. Это можно эмулировать так:


    fun main() {
        val human: LivingCreature = Human("Сергей")
        val cat  : LivingCreature = Cat("рыжий")
        Guest().visit( human) 
        Guest().visit( cat )
    }
    
    sealed class LivingCreature
    class Human(val name: String): LivingCreature()
    class Cat(val colour: String): LivingCreature()
    
    interface Visitor {
        fun visit(creature : Human)
        fun visit(creature : Cat)
    }
    class Guest: Visitor {
        override fun visit(human: Human) = println("Поздоровайся с ${human.name}")
        override fun visit(pet: Cat) = println("Погладь котика (это который ${pet.colour})")
    }

    Это уже не сможет скомпилироваться, так как нет метода visit(LivingCreature). Если его добавить, то все вызовы в этом куске кода пойдут в него, так как методы переопределены — а это разрешается на этапе компиляции, в отличие от перегрузки, которая в рантайме. А нам хочется, чтобы они шли в методы, соответствующие реальному типу аргумента.


    Тут нам пришел бы на помощь Visitor. Идея в том, что сам объект, который мы хотим обработать — Human или Cat, знают свой класс и при вызове из своего метода могли бы сказать компилятору, какой именно переопределенный метод на Guest (Visitor) вызывать. То есть мы передаем в объект подтипа LivingCreature сам Visitor и на нем вызываем visit, который уже знает тип аргумента.


    fun main() {
        val human: LivingCreature = Human("Сергей")
        val cat  : LivingCreature = Cat("рыжий")
        human.accept( Guest() ) // вызов байткод LivingCreature.accept( Visitor ), который перегружен и пойдет в нужный потомок в рантайм
        cat.accept( Guest() ) // 
    }
    
    sealed class LivingCreature {
        abstract fun accept(visitor: Visitor) // не надо пытаться inline fun - не поможет в разрешении переопределения
    }
    interface Visitor {
        fun visit(creature : Human)
        fun visit(creature : Cat)
    }
    class Human(val name: String): LivingCreature() {
        override fun accept(visitor: Visitor) = visitor.visit(this) // мы перегружены в Human, так что это visit(Human) в байт-коде
    }
    class Cat(val colour: String): LivingCreature(){
        override fun accept(visitor: Visitor) = visitor.visit(this) // мы перегружены а Cat, так что это visit(Cat) в байт-коде
    }
    
    class Guest : Visitor{
        override fun visit(creature : Human) = println("Поздоровайся с ${creature.name}")
        override fun visit(creature : Cat) = println("Погладь котика (это который ${creature.colour})")
    }

    Обратите внимание на комментарий — inline fun бы не сработала, потому что это не какой-то макрос и не вставляется как кусок исходного кода в место использования перед компиляцией.
    Еще раз акцентирую внимание — методы visit являются переопределенными, они должны быть разрешены на этапе компиляции — им можно (и нужно в целях понимания) дать отличающиеся имена. Метод accept, с другой стороны — перегружен и разрешается в рантайме. Именно эта комбинация и составляет суть реализации Visitor.


    Какова цена — код сложно читать. Это самое главное в мире разработке. Из конкретики — куча вспомогательного кода вроде копипасты accept. Для человека это копипаста, а вот для компилятора это различный код и различный результат на выходе. Все, что мы сделали — ублажили компилятор. Это скрытая суть Visitor.


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


    Так что там насчет "не нужен"?


    Ну а если пойти простым путем и просто проверять класс объекта LivingCreature на входе метода Visitor.visit(LivingCreature)?


    fun main() {
        val human: LivingCreature = Human("Сергей")
        val cat  : LivingCreature = Cat("рыжий")
        Guest().visit(human )
        Guest().visit( cat )
    }
    
    sealed class LivingCreature 
    interface Visitor {
        fun visit(creature: LivingCreature)
    }
    class Human(val name: String): LivingCreature()
    class Cat(val colour: String): LivingCreature()
    
    class Guest : Visitor{
        override fun visit(creature : LivingCreature) = when(creature) {
            is Human -> println( "Поздоровайся с ${creature.name}")
            is Cat -> println( "Погладь котика (это который ${creature.colour} ) ")
        }
    }

    А это, оказывается, работает просто отлично. И читать это гораздо легче. В чем подвох? Подвох в том, что на Java это был бы макаронный код из условий с instanceof и приведением типов, а так как мы, как правило, на JVM — то в голову приходит тяжелое наследие Java. Кому-то — С++. Благодаря им "шаблон" и захватил свою долю. А еще нам как-то давно говорили, что проверки instanceof очень медленные, дорогие и напрягают JVM. Однако очевидно, что за пару десятков лет JVM переписали очень хорошо и подобные вещи уже неактуальны.


    Оказывается, на Kotlin можно писать и тесты производительности Java на JHM, что я и сделал. Результат меня удивил — вариант с Visitor работает в разы медленнее, чем вариант с when/smart cast. Посмотреть можно тут:
    мой репозиторий на GitHub


    Выводы


    • Visitor — отличное упражнение для ума и я всячески рекомендовал бы его попробовать в неожиданных ситуациях, но из академического интереса.
    • Visitor улучшает визуально код на Java и позволяет выразить поведение более четко. Хороший вариант для генераторов, шаблонов обработчиков событий (см ANTLR).
    • Visitor проигрывает по читабельности и производительности "наивному" решению на Kotlin ввиду особенностей языка.
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

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

      +3
      Вообще говоря Visitor нужен не просто как аляповатая замена instanceof а для обнаружения ошибок при рефакторинге.
      А именно: в случае добавления/удаления классов все контракты просто перестают компилироваться в отличие от instanceof-проверок.
        0

        Всегда рассматривал это как побочный эффект. Но, пожалуй, тоже можно записать в плюс шаблона.
        Если же использовать sealed class в when реализации, как приведено тут, то тоже будет валиться с ошибкой компиляции, как и в классическом Visitor. Конечно, не всегда sealed удобен.

          +1
          Вероятно вы путаете вполне себе жёстко определённый Visitor с более мягкими Dispatch
          +2
          Как раз sealed классы отлично покрывают данный случай.
          Почему-то сам автор не подчеркнул этого.
            0
            из-за модерации коментарий устарел
          0
          По-моему, если бы вы сделали класс
          interface Visitor {
              fun visitHuman(creature: LivingCreature)
              fun visitAnimal(creature: LivingCreature)
          }
          

          то стало бы понятно, что преимущество шаблона посетитель в том, что вы не зависите от конкретных классов. И экземпляр Human, если ему вдруг захочется, может сам решить — мяукать ему или разговаривать))
            +1

            в этом случае нет проверки на тип аргумента и возможна ошибка передачи типа visitHuman( Cat() )
            А даже без ошибки программиста пришлось бы приводить тип аргумента, что является плохой практикой и классичиский Visitor ее устраняет.
            Экземпляр Human может решать, мяукать или разговаривать, но речь идет о функционале вне Human, потому что с помощью Visitor мы реализовываем то, что не относится к обязанностям объекта. Нам нужны Visitor для того, чтобы оригинальный объект не инкаплулировал всю возможную логику во вселенной — смотрите часть статьи отношение Visitor и ООП

              +1
              Вы сводите паттерн Посетитель к элементарному вызову функции f(o). Зачем вообще какой-то паттерн, чтобы передать объект в функцию? Не надо приводить типы, это необязательно (в супер классе обычно есть общие методы).

              Я каюсь, что не штудировал перед ответом GoF, да и Котлин не особо знаю. Но использую этот паттерн в одном проекте. И Посетитель как раз позволяет избавиться от конструкций switch (и их аналогов) в клиентском коде, предоставляя возможность объекту самостоятельно выбрать нужный метод посетителя.

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

                >> Не надо приводить типы, это необязательно (в супер классе обычно есть общие методы).


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


                Visitor избавляет от switch и аналогов — это его прелесть. Однако это имеет смысл, когда switch/when/if читаются хуже (или по производительности проседают). Например, в Java реализация Visitor имеет смысл — он просто красив по сравнению с альтернативой. То же и в С++. В данной статье я рассматриваю только Kotlin и только для него вывод — код без Visitor более читаемый (и производительный). Я не свожу Visitor к элементарному вызову функции — я предлагаю заменить его элементарным вызовом функции.

                  0
                  Эх… Без примера не разобраться. Мы немного о разном.

                  Вот скажите, как ваша итоговая реализация будет ввести себя для класса, который одновременно расширяет и Cat и Human?

                  Как будет работать эта реализация для класса SuperHuman: Human? Который днём ведёт себя как обычный человек, а ночью летает?
                    0

                    одновременно расширяет и Cat и Human?


                    Множественное наследование не поддерживается в Kotlin. Это если кратко. Если уйти далеко от изначальной постановки вопроса и начать играться с интерфейсами, то ответ зависит от порядка опций is в блоке when


                    Как будет работать эта реализация для класса SuperHuman: Human?
                    Если предусмотреть блок is SuperHuman ->, то он будет отрабатывать, если нет, то будет проваливаться в блок для Human. Ничего неожиданного, по-моему.

                    0
                    Короче, Посетитель нужен там, где instance of не справляется. Если по-простому
                      0

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

              0
              Отличный примет visitor в некотором виде это парсинг (XML например). У вас есть интерфейс NodeVisitor и методы (visitComment, visitElement, visitAttribute ...).
              Вы никак не можете написать instanceof, потому что библиотека парсинга XML ничего не знает о вашей бизнес логике!
              P.S. Вообще-то, основное преимущество ООП и паттернов проектирования, это inversion of control, т.е. вы строите абстракцию и делайте универсальное решение, что позволяет упаковывать 100К кода в разумные модули. Конечно, если проект состоит из 500-1000 LOC, тут можно и в один класс все запихать.
                0
                Это уже не сможет скомпилироваться, так как нет метода visit(LivingCreature)


                Я, наверное, из другого мира (С#), но почему-то вот такой пример компиляется на ура:

                using System;
                					
                public class Program
                {
                	public class LivingCreature
                	{}
                	
                	public class Human: LivingCreature
                	{ 
                		public string name;
                	}
                	
                	public class Cat: LivingCreature
                	{ 
                		public string color;
                	}
                	
                	interface Visitor {
                		void visit( Human creature);
                		void visit(Cat creature );
                	}
                	
                	public class Guest: Visitor {
                		public void visit( Human human){
                			Console.WriteLine("Hello " + human.name);
                		}
                		public void visit(Cat cat ){
                			Console.WriteLine("Pet " + cat.color);
                		}
                	}
                	
                	public static void Main()
                	{
                		var human = new Human() {name = "Jorge" };
                		var cat = new Cat() {color = "pink" };
                		
                		
                		
                		var g = new Guest();
                		g.visit(human);
                		g.visit(cat);
                	}
                }
                


                Поясните, плиз, почему у Вас не скомпиляется?
                  0

                  Ваш код идентичен первому листингу статьи. Не компилируется второй листинг, где переменные human и cat явно объявлены с типом суперкласса. Попробуйте что-то вроде этого :


                  LivingCreature human = new Human() {name = "Jorge" };
                    0
                    Да, спасибо за уточнение. Теперь проблема и в C# всплыла.

                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.