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

Рассмотрим шаблон проектирования Visitor и покажем, что использовать его при программировании на Kotlin не стоит. Будет теория, минималистичная реализация, реализация замены и доводы в пользу замены, подкрепленные практическими изысканиями. Не будет диаграмм классов. Все попробовать можно онлайн в play.kotlinlang.org
Шаблон Visitor устарел для Kotlin, но знать его стоит — IT-МИР. ПОМОЩЬ В IT-МИРЕ 2020

На моем крайнем последнем месте работы в руки попал микросервис, который преобразовывал один дурной 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 ввиду особенностей языка.

Специально для сайта ITWORLD.UZ. Новость взята с сайта Хабр