ООП паттерн Visitor — объяснение и пример использования
Рассматриваем поведенческий шаблон Visitor с примерами на Scala.
11К открытий12К показов
Данила Голощапов
Senior Software Engineer в EPAM
Полиморфизм и LSP
Рассмотрим классический пример наследования, когда требуется реализовать два класса типа «Прямоугольник» и «Квадрат». Известно, что у каждой фигуры есть периметр и площадь. Будем считать, что объект мутабельный (mutable ― изменчивый) и имеет соответствующие методы (свойства) для изменения размеров. Достаточно легко обобщить формулу для вычисления площади и периметра таких фигур, и, кажется, логично будет использовать наследование. Кто в такой модели должен быть родительским классом, а кто ― потомком?
Казалось бы, со школы мы помним, что квадрат ― частный случай прямоугольника, а значит, квадрат наследует от прямоугольника.
Пока все просто. Но что же произойдёт, если мы попытаемся создать «частный случай прямоугольника»? Ведь у квадрата по определению width == height. Конечно же, можно использовать приватную переменную, и при модификации ширины менять и высоту фигуры соответственно, но, если задуматься ― а насколько это ожидаемо? Если квадрат ― наследник прямоугольника, то пользователь должен иметь возможность использовать квадрат точно так же, как и прямоугольник. Может ли пользователь предположить, что при установке ширины прямоугольника автоматически поменяется и его длина?
Хорошо, если не получается напрямую, давайте попробуем в обратную сторону. Пусть прямоугольник — это такой специальный квадрат, у которого появилась длина.
Пока выглядит неплохо. Однако, пользователь знает, что площадь квадрата ― квадрат его стороны. Может ли пользователь ожидать, что, установив сторону квадрата в 5, например, он не получит 25 только потому, что его квадрат на самом деле прямоугольник?
Хорошим дизайном вашей модели будет являться такая, в которой не будет исключений и подводных камней. По идее, только посмотрев на сигнатуру интерфейса, вы должны иметь возможность сказать, как экземпляр этого класса себя будет вести и что от него стоит ожидать. Эта идея носит гордое название «принципа замещения Барбары Лисков» или LSP. Можно грубо сказать, что LSP — это такой ad hoc полиморфизм, при котором класс-потомок никогда не врет.
Когда полиморфизм может сделать больно
Как известно, полиморфизм — один из «китов», на которых стоит концепция ООП. Сама идея полиморфного состояния настолько мощная и настолько широко используется, что часто можно встретить наследование даже там, где оно и не нужно. Иногда это приводит к нарушению LSP, что может значительно усложнить поддержку существующей кодовой базы. Например, достаточно часто можно встретить реализацию такого вида:
И ладно, если это часть вашей собственной кодовой базы. Но что, если это некоторый общий модуль, разделяемый между несколькими командами? Или если такой код распространяется в уже скомпилированном виде? Насколько легко будет проверять каждый экземпляр модели на предмет нарушения LSP?
Подчеркну, что страшно не само по себе нарушение принципа, в конце концов программирование — это раздел инженерии, и вся наша работа состоит в выборе подходящего компромисса — а то, что такое поведение крайне трудно предсказать: откуда разработчику знать, что метод, который он вызовет, выбросит Exception? Обратите внимание, что такое исключение неожиданно и не может быть предсказано исходя из здравого смысла — ведь рядом может лежать другая имплементация, которая не имеет подобной проблемы. Некоторые ЯП имеют checked exception, но практика показывает, что их проще завернуть в unchecked
, чем пытаться поддерживать. Как следствие, логическая сложность программы с подобным code smell растет неоправданно, и становится все сложнее понять, какая из N имплементаций сервиса будет работать как ожидается, а какая ― нет.
При проектировании модели можно услышать советы вроде «придерживайся tell-don’t-ask», «используй null-object-pattern», или даже «prefer composition over inheritance», которые в целом могут помочь увернуться от описанной проблемы. Последний совет, кстати, вообще несколько неочевиден — не использовать наследование в ООП специфичном языке.
Я предлагаю посмотреть в корень — и он в том, что данные хранятся вместе с поведением. В нашей жизни мы часто думаем об окружающих вещах как об объектах, поэтому такое положение дел кажется более или менее естественным. С другой стороны, при достаточно большой и сложной доменной области наследование может начать нести некоторую опасность ― ведь описать формальным языком поведение, не нарушая LSP, бывает очень сложно. На мой взгляд, эта проблема возникает в первую очередь из-за того, что в реальной жизни мы, скорее, создаем свою иерархию наследований под каждый конкретный случай, тогда как при проектировании доменной области зачастую стараемся сделать из объекта некий «швейцарский нож», обладающий разным и порой никак не связанным друг с другом поведением — потому что чем меньше вспомогательных сущностей мы имеем, тем проще понять модель.
«Не узнаю вас в гриме»
Вторая проблема более характерна для строго типизированных языков, где, имея список каких-то объектов, приведенных в базовому типу, нужно проверять тип каждого элемента, чтобы вызвать специфическое поведение. Например:
Такое часто можно встретить при вызове сторонних библиотек, или если у вас legacy. Некоторые языки программирования даже имеют более или менее стандартную функциональность, чтобы работать с базовыми типами, проверяя их конкретную реализацию в рантайме, что ломает сразу двух из трех китов ООП ― и полиморфизм, и инкапсуляцию. Например, если вы счастливый программист на Scala ― у вас есть pattern matching. С другой стороны, никто не мешает сделать своего китоломателя на минималках, и это и есть — Visitor.
Идиоматичный Visitor
Идея достаточно простая — вместо того, чтобы объявлять поведение внутри класса, мы делегируем это поведение некоторому внешнему объекту. При этом объект-делегат называется посетителем (Visitor), и в нем должны быть объявлены методы посещения для каждого конкретного типа из иерархии. Экземпляр такого объекта мне нравится называть глаголом, описывающим нужный эффект, тем самым подчеркивая, что визитор — скорее поведение, отделенное от данных, чем полноценный объект (makeSomething vs somethingMaker).
Пора посмотреть, как могла бы выглядеть реализация паттерна:
Приведенный пример легко можно переделать, чтобы собирать только фастфуд (Sausage
) или сериализовать в json, или добавлять любое другое поведение в существующую иерархию объектов. В этом самое большое преимущество паттерна.
А вот недостатком будет многословность и не слишком большая очевидность. Для каждого потомка Tasty
придется делать реализацию visit
, а в самом TastyVisitor
― сделать соответствующий метод.
К тому же этот шаблон не получится использовать, если нет контроля над существующим деревом объектов ― то есть если нельзя внести соответствующие изменения в существующую иерархию.
Рекомендация
Если у вас уже есть не слишком большая цепочка доменных классов, которые могут использовать в разных сценариях — задумайтесь о том, а не нарушите ли вы принцип единичной ответственности уже сейчас, и не выйдет ли так, что полиморфное поведение будет больше мешать, чем помогать? И если все-таки не хочется иметь разные доменные модели под каждый конкретный сценарий использования, например, потому что они будут на 99% совпадать, то может быть имеет смысл отделить данные от поведения — и Visitor в таком случае может сослужить хорошую службу. Ценой одного дополнительного интерфейса на этапе проектирования и изменением привычного способа взаимодействия с классом модели вы сможете добавлять любое необходимое поведение в типобезопасной манере, и каждый такой метод будет изолирован от других. Получается, что соблюдается и принцип единичной ответственности, и LSP не нарушается.
11К открытий12К показов