Відвідувач і 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".
Післяслово
Хоча патерн Відвідувач і побудований на механіці подвійної диспетчеризації, це не основна його ідея. Відвідувач дає змогу додавати операції до цілої ієрархії класів, без потреби міняти код цих класів.