Посетитель и двойная диспетчеризация.
Давайте рассмотрим следующую иерархию классов геометрических фигур (осторожно, псевдокод):
interface Graphic is
method draw()
class Shape implements Graphic is
field id
method draw()
// ...
class Dot extends Shape is
field x, y
method draw()
// ...
class Circle extends Dot is
field radius
method draw()
// ...
class Rectangle extends Shape is
field width, height
method draw()
// ...
class CompoundGraphic implements Graphic is
field children: array of Graphic
method draw()
// ...
Код прекрасно работает, и приложение уже в продакшене. Но однажды вы решили сделать функцию экспорта. Код экспорта выглядел бы чужеродным, если поместить его в эти классы. Поэтому вместо добавления экспорта во все классы этой иерархии вы решили создать новый класс, внешний по отношению к иерархии, и поместить всю логику экспорта внутрь него. У класса будут методы для экспорта публичного состояния каждого объекта в строки XML:
class Exporter is
method export(s: Shape) is
print("Exporting shape")
method export(d: Dot)
print("Exporting dot")
method export(c: Circle)
print("Exporting circle")
method export(r: Rectangle)
print("Exporting rectangle")
method export(cs: CompoundGraphic)
print("Exporting compound")
Код выглядит хорошо, но давайте попробуем его в деле:
class App() is
method export(shape: Shape) is
Exporter exporter = new Exporter()
exporter.export(shape);
app.export(new Circle());
// К сожалению, это выведет "Exporting shape".
Стоп! Почему?!
Думая как компилятор
Примечание: приведённая ниже информация верна для большинства современных объектно-ориентированных языков программирования (Java, C#, PHP и других).
Позднее/динамическое связывание
Представьте, что вы — компилятор. Вам нужно решить, как скомпилировать следующий код:
method drawShape(shape: Shape) is
shape.draw();
Посмотрим... метод draw определён в классе Shape. Погодите-ка, но есть ещё и четыре подкласса, которые переопределяют этот метод. Можем ли мы надёжно решить, какую из реализаций здесь вызвать? Похоже, что нет. Единственный способ узнать наверняка — запустить программу и проверить класс объекта, переданного в метод. Единственное, что мы знаем точно, — у объекта будет реализация метода draw.
Поэтому результирующий машинный код будет проверять класс объекта, переданного в параметр shape, и выбирать реализацию draw из соответствующего класса.
Такая динамическая проверка типа называется поздним (или динамическим) связыванием:
- Позднее, потому что мы связываем объект и его реализацию после компиляции, во время выполнения.
- Динамическое, потому что каждый новый объект может потребоваться связать с другой реализацией.
Раннее/статическое связывание
Теперь давайте «скомпилируем» следующий код:
method exportShape(shape: Shape) is
Exporter exporter = new Exporter()
exporter.export(shape);
Со второй строкой всё ясно: у класса Exporter нет собственного конструктора, так что мы просто создаём объект. А что насчёт вызова export? У Exporter есть пять методов с одинаковым именем, различающихся типами параметров. Какой из них вызвать? Похоже, здесь нам тоже понадобится динамическое связывание.
Но есть и другая проблема. Что, если существует класс фигуры, у которого нет подходящего метода export в классе Exporter? Например, объект Ellipse. Компилятор не может гарантировать наличие подходящего перегруженного метода, в отличие от переопределённых методов. Возникает неоднозначная ситуация, которую компилятор не может допустить.
Поэтому разработчики компиляторов идут безопасным путём и используют для перегруженных методов раннее (или статическое) связывание:
- Раннее, потому что оно происходит во время компиляции, до запуска программы.
- Статическое, потому что его нельзя изменить во время выполнения.
Вернёмся к нашему примеру. Мы уверены, что входящий аргумент будет из иерархии Shape: либо класс Shape, либо один из его подклассов. Мы также знаем, что у класса Exporter есть базовая реализация экспорта, поддерживающая класс Shape: export(s: Shape).
Это единственная реализация, которую можно безопасно связать с данным кодом, не создавая неоднозначности. Вот почему, даже если мы передадим объект Rectangle в exportShape, экспортёр всё равно вызовет метод export(s: Shape).
Двойная диспетчеризация
Двойная диспетчеризация — это приём, позволяющий использовать динамическое связывание вместе с перегруженными методами. Вот как это делается:
class Visitor is
method visit(s: Shape) is
print("Visited shape")
method visit(d: Dot)
print("Visited dot")
interface Graphic is
method accept(v: Visitor)
class Shape implements Graphic is
method accept(v: Visitor)
// Компилятор точно знает, что `this` — это `Shape`.
// А значит, можно безопасно вызвать `visit(s: Shape)`.
v.visit(this)
class Dot extends Shape is
method accept(v: Visitor)
// Компилятор знает, что `this` — это `Dot`.
// А значит, можно безопасно вызвать `visit(s: Dot)`.
v.visit(this)
Visitor v = new Visitor();
Graphic g = new Dot();
// Метод `accept` переопределён, а не перегружен. Компилятор связывает его
// динамически. Поэтому `accept` будет выполнен у того класса, который
// соответствует объекту, вызывающему метод (в нашем случае — класса `Dot`).
g.accept(v);
// Вывод: "Visited dot"
Послесловие
Хотя паттерн Посетитель построен на принципе двойной диспетчеризации, это не его основное назначение. Посетитель позволяет добавлять «внешние» операции ко всей иерархии классов, не меняя существующий код этих классов.