Глянь мой новый курс по Git! Привет! Глянь мой новый курс по Git! Привет! Глянь мой новый курс по Git на GitByBit.com! Привет! Хочешь круто подтянуть Git? Глянь мой новый курс на GitByBit.com!

Посетитель и Double Dispatch

Рассмотрим пример, в котором у нас есть небольшая иерархия классов геометрических фигур (осторожно, псевдокод):

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()
    // ...

Нам нужно добавить внешнюю операцию над всеми этими компонентами, например, экспорт. В нашем языке (Java, C#, ...) есть перегрузка методов, поэтому мы создаём такой класс:

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);

С созданием объекта всё ясно. Как насчёт вызова метода export? В классе Exporter у нас есть пять версий метода с таким именем, которые отличаются только типом параметра. Похоже, здесь тоже придётся динамически отслеживать тип передаваемого параметра и по нему определять какой из методов выбрать.

Но здесь нас ждёт засада. Что если кто-то подаст в метод exportShape такой объект, для которого не существует метода export в классе Exporter? Например, объект Ellipse, для которого у нас нет экспорта. Действительно, у нас нет гарантии что необходимый метод будет существовать, как это было с переопределенными методами. А значит, возникнет неоднозначная ситуация.

Именно поэтому все разработчики компиляторов выбирают безопасную тропинку и применяют раннее или статическое связывание для перегруженных методов:

  • Раннее, потому что оно происходит ещё на этапе компиляции программы.
  • Статическое, потому что его уже не изменить во время выполнения.

Вернемся к нашему примеру. Мы уверены в том, что имеем параметр с типом Shape. Мы знаем что в Exporter существует подходящая реализация: export(s: Shape). Значит, этот участок кода мы жёстко связываем с известной реализацией метода.

И поэтому даже если мы подадим в параметрах один из подклассов Shape, всё равно будет вызвана реализация export(s: Shape).

Double dispatch

Двойная диспетчеризация или double dispatch — это трюк, позволяющий обойти ограниченность раннего связывания в перегруженных методах. Вот как это делается:

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`.
        v.visit(this)

class Dot extends Shape is
    method accept(v: Visitor)
        // Компилятор знает, что здесь `this` это `Dot`.
        // А значит можно статически связать этот вызов
        // с реализацией visit(d: Dot).
        v.visit(this)


Visitor v = new Visitor();
Graphic g = new Dot();

// Метод accept() — переопределен, но не перегружен. А значит, связан
// динамически. Поэтому реализация `accept` будет выбрана во время выполнения
// уже из того класса, объект которого его вызвал (класс Dot).
g.accept(v);

// Выведет "Visited dot".

Послесловие

Хотя паттерн Посетитель и построен на механизме двойной диспетчеризации, это не основная его идея. Посетитель позволяет добавлять операции к целой иерархии классов, без надобности менять код этих классов.