Обложка: ООП паттерн Visitor — объяснение и пример использования

ООП паттерн Visitor — объяснение и пример использования

Данила Голощапов
Данила Голощапов

Senior Software Engineer в EPAM

Полиморфизм и LSP

Рассмотрим классический пример наследования, когда требуется реализовать два класса типа «Прямоугольник» и «Квадрат». Известно, что у каждой фигуры есть периметр и площадь. Будем считать, что объект мутабельный (mutable ― изменчивый) и имеет соответствующие методы (свойства) для изменения размеров. Достаточно легко обобщить формулу для вычисления площади и периметра таких фигур, и, кажется, логично будет использовать наследование. Кто в такой модели должен быть родительским классом, а кто ― потомком?

Казалось бы, со школы мы помним, что квадрат ― частный случай прямоугольника, а значит, квадрат наследует от прямоугольника.

class Rectangle {
  var widht: Int
  var height: Int
  def s = widht * height
  def p = (widht + height) * 2
} 

Пока все просто. Но что же произойдёт, если мы попытаемся создать «частный случай прямоугольника»? Ведь у квадрата по определению width == height. Конечно же, можно использовать приватную переменную, и при модификации ширины менять и высоту фигуры соответственно, но, если задуматься ― а насколько это ожидаемо? Если квадрат ― наследник прямоугольника, то пользователь должен иметь возможность использовать квадрат точно так же, как и прямоугольник. Может ли пользователь предположить, что при установке ширины прямоугольника автоматически поменяется и его длина?

Хорошо, если не получается напрямую, давайте попробуем в обратную сторону. Пусть прямоугольник — это такой специальный квадрат, у которого появилась длина.

class Square {
  var widht: Int
  def s = widht ^ 2
  def p = width * 4
}
class Rectangle extends Square {
  val height: Int
 
  def s = widht * height 
  def p = width * 2 + height * 2
}

Пока выглядит неплохо. Однако, пользователь знает, что площадь квадрата ― квадрат его стороны. Может ли пользователь ожидать, что, установив сторону квадрата в 5, например, он не получит 25 только потому, что его квадрат на самом деле прямоугольник?

Хорошим дизайном вашей модели будет являться такая, в которой не будет исключений и подводных камней. По идее, только посмотрев на сигнатуру интерфейса, вы должны иметь возможность сказать, как экземпляр этого класса себя будет вести и что от него стоит ожидать. Эта идея носит гордое название «принципа замещения Барбары Лисков» или LSP. Можно грубо сказать, что LSP — это такой ad hoc полиморфизм, при котором класс-потомок никогда не врет.

Когда полиморфизм может сделать больно

Как известно, полиморфизм — один из «китов», на которых стоит концепция ООП. Сама идея полиморфного состояния настолько мощная и настолько широко используется, что часто можно встретить наследование даже там, где оно и не нужно. Иногда это приводит к нарушению LSP, что может значительно усложнить поддержку существующей кодовой базы. Например, достаточно часто можно встретить реализацию такого вида:

trait MyTrait {
  def foo(): Int
}
class MyTraitImpl {
  def foo(): Int = throw new NotImplementedException("should not be called!")
}

И ладно, если это часть вашей собственной кодовой базы. Но что, если это некоторый общий модуль, разделяемый между несколькими командами? Или если такой код распространяется в уже скомпилированном виде? Насколько легко будет проверять каждый экземпляр модели на предмет нарушения LSP?

Подчеркну, что страшно не само по себе нарушение принципа, в конце концов программирование — это раздел инженерии, и вся наша работа состоит в выборе подходящего компромисса — а то, что такое поведение крайне трудно предсказать: откуда разработчику знать, что метод, который он вызовет, выбросит Exception? Обратите внимание, что такое исключение неожиданно и не может быть предсказано исходя из здравого смысла — ведь рядом может лежать другая имплементация, которая не имеет подобной проблемы. Некоторые ЯП имеют checked exception, но практика показывает, что их проще завернуть в unchecked, чем пытаться поддерживать. Как следствие, логическая сложность программы с подобным code smell растет неоправданно, и становится все сложнее понять, какая из N имплементаций сервиса будет работать как ожидается, а какая ― нет.

При проектировании модели можно услышать советы вроде «придерживайся tell-don’t-ask», «используй null-object-pattern», или даже «prefer composition over inheritance», которые в целом могут помочь увернуться от описанной проблемы. Последний совет, кстати, вообще несколько неочевиден — не использовать наследование в ООП специфичном языке.

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

«Не узнаю вас в гриме»

Вторая проблема более характерна для строго типизированных языков, где, имея список каких-то объектов, приведенных в базовому типу, нужно проверять тип каждого элемента, чтобы вызвать специфическое поведение. Например:

def foo(vals: List[Any]) {
  for (elem <- vals) {
    if(elem.isInstanceOf[MyController])
      elem.asInstanceOf[MyController].effect()
  }
}

Такое часто можно встретить при вызове сторонних библиотек, или если у вас legacy. Некоторые языки программирования даже имеют более или менее стандартную функциональность, чтобы работать с базовыми типами, проверяя их конкретную реализацию в рантайме, что ломает сразу двух из трех китов ООП ― и полиморфизм, и инкапсуляцию. Например, если вы счастливый программист на Scala ― у вас есть pattern matching. С другой стороны, никто не мешает сделать своего китоломателя на минималках, и это и есть — Visitor.

Идиоматичный Visitor

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

Пора посмотреть, как могла бы выглядеть реализация паттерна:

//базовый тип - корень иерархии
trait Tasty {
  def visit(tastyVisitor: TastyVisitor): Unit
}
//вкусный борщ
class Borshch extends Tasty {
  def visit(tastyVisitor: TastyVisitor): Unit = tastyVisitor.onBorshch(this)
}
//сосисочка
class Sausage extends Tasty {
  def visit(tastyVisitor: TastyVisitor): Unit = tastyVisitor.onSausage(this)
}
//Visitor - потомку этого типа будем делегировать то поведение, которое традиционно объявлялось внутри доменной модели
trait TastyVisitor {
  def onSausage(sausage: Sausage): Unit
  def onBorshch(borshch: Borshch): Unit
}
//создадим коллекцию вкуснях. Результирующее выражение будет иметь тип List[Tasty]
val tasties = List(new Sausage(), new Sausage(), new Borshch())
//а вот и объявление типо-специфичного поведения; в данном случае просто зарегистрируем конкретный тип каждого блюда из коллекции.
var visits = List.empty[String]
val accumulateTypes = new TastyVisitor {
  def onSausage(sausage: Sausage) = visits :+= "yummy sausage!"
  def onBorshch(borshch: Borshch) = visits :+= "delicious borshch!"
}
tasties.foreach(t => t.visit(accumulateTypes))

//проверим, что же мы зарегистрировали 
visits.foreach(println)
/*
> yummy sausage!
> yummy sausage!
> delicious borshch!
*/

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

А вот недостатком будет многословность и не слишком большая очевидность. Для каждого потомка Tasty придется делать реализацию visit, а в самом TastyVisitor ― сделать соответствующий метод.

К тому же этот шаблон не получится использовать, если нет контроля над существующим деревом объектов ― то есть если нельзя внести соответствующие изменения в существующую иерархию.

Рекомендация

Если у вас уже есть не слишком большая цепочка доменных классов, которые могут использовать в разных сценариях — задумайтесь о том, а не нарушите ли вы принцип единичной ответственности уже сейчас, и не выйдет ли так, что полиморфное поведение будет больше мешать, чем помогать? И если все-таки не хочется иметь разные доменные модели под каждый конкретный сценарий использования, например, потому что они будут на 99% совпадать, то может быть имеет смысл отделить данные от поведения — и Visitor в таком случае может сослужить хорошую службу. Ценой одного дополнительного интерфейса на этапе проектирования и изменением привычного способа взаимодействия с классом модели вы сможете добавлять любое необходимое поведение в типобезопасной манере, и каждый такой метод будет изолирован от других. Получается, что соблюдается и принцип единичной ответственности, и LSP не нарушается.