Паттерн Посетитель и двойная диспетчеризация

Добавлено 4 мая 2022 в 23:38

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

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".

Послесловие

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

Теги

Банда четырех / Gang of Four / GoFДвойная диспетчеризация / Double DispatchОбъектно-ориентированное программирование (ООП)Паттерн Посетитель (Visitor)Паттерны проектирования / Design PatternsПоведенческие паттерныПрограммирование

На сайте работает сервис комментирования DISQUS, который позволяет вам оставлять комментарии на множестве сайтов, имея лишь один аккаунт на Disqus.com.

В случае комментирования в качестве гостя (без регистрации на disqus.com) для публикации комментария требуется время на премодерацию.