Рассмотрим паттерн программирования известный как Двойная диспетчеризация (Double Dispatch).
Выбор вызываемого метода зависит только от объекта, получающего вызов. В большинстве случаев этого достаточно. Однако иногда нам нужно, чтобы выбор также зависел от аргумента, передаваемого в метод.
Представьте, что у вас есть две иерархии объектов, взаимодействующих друг с другом, и выбор этих взаимодействий зависит не от одного из них, а от обоих объектов. Позже, на примерах, разберем это.
Пройдемся по этому паттерну через TDD с помощью фреймоворка для тестов Pest. Все классы находятся в том же файле, что и сам тест.
Пример: сложение целых чисел и чисел с плавающей запятой
В первом примере рассмотрим сложение чисел. Представим, что мы создаем базовые классы для чисел на языке, который не может складывать примитивы различных типов.
Начнем со сложения только целых чисел:
declare(strict_types = 1);
test('adds integers', function () {
$first = new IntegerNumber(40);
$second = new IntegerNumber(2);
$this->assertSame(42, $first->add($second)->value);
});
Чтобы тест прошёл, добавим класс IntegerNumber в начало тестового файла, прямо за вызовом declare():
class IntegerNumber
{
public function __construct(public int $value) {}
public function add($number)
{
return new IntegerNumber($this->value + $number->value);
}
}
Работает. Обратите внимание, что мы добавили declare(strict_types = 1); в PHP-файл. Это сделано потому, что PHP очень умен и может суммировать целые числа и числа с плавающей запятой, а нам, для примера, нужно вручную приводить значения.
Добавим тест на сложение чисел с плавающей запятой:
test('adds floats', function () {
$first = new FloatNumber(40.0);
$second = new FloatNumber(2.0);
$this->assertSame(42.0, $first->add($second)->value);
});
Чтобы тест прошёл, добавим класс FloatNumber:
class FloatNumber
{
public function __construct(public float $value) {}
public function add($number)
{
return new FloatNumber($this->value + $number->value);
}
}
Наши тесты должны отработать успешно. Теперь добавим наше первое кросс-дополнение: сложение целых чисел и чисел с плавающей запятой.
test('adds integers and floats', function () {
$first = new IntegerNumber(40);
$second = new FloatNumber(2.0);
$this->assertSame(42, $first->add($second)->value);
$this->assertSame(42.0, $second->add($first)->value);
});
Так, как же мы можем заставить его работать? Ответ: Двойная Диспетчеризация. Про этот паттерн говорится следующее:
Отправьте сообщение аргументу. Добавьте к селектору имя класса получателя. Передайте получателя в качестве аргумента. (Кент Бек в «Smalltalk Best Practice Patterns», стр. 56)
Это было в Smalltalk. Для нас селектор — это имя метода. Применим паттерн. Для начала давайте займемся первым случаем: сложением целых чисел:
class IntegerNumber
{
public function __construct(public int $value) {}
public function add($number)
{
return $number->addInteger($this);
}
public function addInteger(IntegerNumber $number)
{
return new IntegerNumber($this->value + $number->value);
}
}
Если мы запустим первый тест, то он все равно выполнится успешно. Это потому, что мы добавляем два экземпляра класса IntegerNumber. Получатель сообщения add() вызовет аргумент addInteger и передаст себя ему. На этот момент у нас есть два целочисленных примитива и мы можем вернуть новый экземпляр, суммирующий их.
Внесем аналогичные изменения в класс FloatNumber:
class FloatNumber
{
public function __construct(public float $value) {}
public function add($number)
{
return $number->addFloat($this);
}
public function addFloat(FloatNumber $number)
{
return new FloatNumber($this->value + $number->value);
}
}
Наши первые два теста должны теперь пройти успешно. Отлично! Теперь добавим перекрестные методы. Сейчас целые числа могут складываться с другими целыми числами (примитивы), а числа с плавающей запятой могут складываться со своими примитивами. Но целые числа должны иметь возможность преобразовывать себя в числа с плавающей запятой и наоборот. Это позволит нам складывать числа с плавающей запятой и целые числа.
Когда экземпляр Float Number получает сообщение add() с экземпляром класса IntegerNumber,
то он вызывает аргумент addFloat и передает ему себя. Итак, нам нужен метод addFloat(FloatNumber $number) класса IntegerNumber. Как мы уже говорили, число IntegerNumber не умеет суммировать числа с плавающей запятой, но знает, как преобразовать себя в число с плавающей запятой. А кто знает, как сложить два числа с плавающей запятой вместе? Экземпляр FloatNumber! Таким образом, в этот момент экземпляр IntegerNumber будет преобразован в Float и вызовет addFloat(). Затем будет выполнено сложение и возвращен новый экземпляр FloatNumber.
Точно так же, когда экземпляр Integer Number получает сообщение add() с экземпляром класса FloatNumber, он вызывает addInteger, передавая себя ему. Затем Float Number будет преобразовано в целое число и возвращён в addInteger. Опять же, в этот момент Integer может выполнить примитивное сложение и вернуть новый экземпляр класса IntegerNumber.
Вот окончательный вариант для классов IntegerNumber и FloatNumber:
class IntegerNumber
{
public function __construct(public int $value) {}
public function add($number)
{
return $number->addInteger($this);
}
public function addInteger(IntegerNumber $number)
{
return new IntegerNumber($this->value + $number->value);
}
public function addFloat(FloatNumber $number)
{
return $number->addFloat($this->asFloat());
}
private function asFloat()
{
return new FloatNumber(floatval($this->value));
}
}
class FloatNumber
{
public function __construct(public float $value) {}
public function add($number)
{
return $number->addFloat($this);
}
public function addFloat(FloatNumber $number)
{
return new FloatNumber($this->value + $number->value);
}
public function addInteger(IntegerNumber $number)
{
return $number->addInteger($this->asInteger());
}
public function asInteger()
{
return new IntegerNumber(intval($this->value));
}
}

Работает! Супер! Если вы хоть немного похожи на меня, то и вы в полном восторге от такой сложной реализации.
Разве не круто?
Пример: Star Trek
Хорошо, пример c числами был классным, но есть вероятность того, что мы создаём новый язык довольно мала. Вряд ли он где-то пригодится. Ну в паттернах важен дизайн, а не реализация. Вы можете повторно использовать один и тот же дизайн в разных контекстах.
Давайте представим, что мы пишем игру «Звездный путь». Мы будем управлять космическим кораблем. По пути нам могут встретиться враги, с которыми придётся сражаться. Одни враги будут сильными, а другие вообще не смогут нанести нам урон.
Соответственно задействуем две иерархии: космические корабли (Spaceships) и враги (Enemies). Расчет боя зависит от них обоих. Идеальный вариант для использования паттерна Двойная диспетчеризация.
Начнем с простого случая: астероид и космический корабль. Астероид повреждает корабль, но не критично:
test('asteroid damages shuttle', function () {
$spaceship = new Shuttle(hitpoints: 100);
$enemy = new Asteroid();
$spaceship->fight($enemy);
$this->assertEquals(90, $spaceship->hitpoints);
});
Реализация будет примерно такой:
class Shuttle
{
public function __construct(public int $hitpoints) {}
public function fight($enemy)
{
$this->hitpoints -= $enemy->damage();
}
}
class Asteroid
{
public function damage()
{
return 10;
}
}
Тест должен выполнится успешно. Хорошо. Добавим еще один космический корабль. USS Voyager вообще не должен получить повреждений от астероида.
test('asteroid does not damage uss voyager', function () {
$spaceship = new UssVoyager(hitpoints: $initialHitpoints = 100);
$enemy = new Asteroid();
$spaceship->fight($enemy);
$this->assertSame($initialHitpoints, $spaceship->hitpoints);
});
Реализация нового космического корабля:
class UssVoyager {
public function __construct(public int $hitpoints) {}
public function fight($enemy) {
// Ничего не случилось.
}
}
Наши тесты должны выполнится успешно. Хм… выглядит немного странно, не так ли? Давайте добавим еще одного врага и посмотрим, работает ли этот дизайн. Наш новый враг — Куб Борга. Борги ассимилируют любой космический корабль (сопротивление бесполезно).
Начнем с теста для Шаттла встретившего Куб Борга:
test('borg cube critically damages the shuttle', function () {
$spaceship = new Shuttle(hitpoints: 100);
$enemy = new BorgCube();
$spaceship->fight($enemy);
$this->assertSame(0, $spaceship->hitpoints);
});
Реализуем Куб Борга:
class BorgCube
{
public function damage()
{
return 100;
}
}
Ok, тест должен быть успешным. Добавим еще один тест перед рефакторингом. Борги также ассимилируют USS Voyager:
test('borg cube critically damages the uss voyager', function () {
$spaceship = new UssVoyager(hitpoints: 100);
$enemy = new BorgCube();
$spaceship->fight($enemy);
$this->assertSame(0, $spaceship->hitpoints);
});
И… тесты провалены. Потому что до сих пор ничего не наносило вред USS Voyager. Думаю, пора применить паттерн. Для начала, давайте отправим сообщение врагу, добавим к нему имя космического корабля и передадим его в качестве аргумента:
class Shuttle
{
public function __construct(public int $hitpoints) {}
public function fight($enemy)
{
$enemy->fightShuttle($this);
}
}
class UssVoyager
{
public function __construct(public int $hitpoints) {}
public function fight($enemy)
{
$enemy->fightUssVoyager($this);
}
}
class Asteroid
{
public function fightShuttle(Shuttle $shuttle)
{
$shuttle->hitpoints -= 10;
}
public function fightUssVoyager(UssVoyager $ussVoyager)
{
// Ничего не делает...
}
}
class BorgCube
{
public function fightShuttle(Shuttle $shuttle)
{
$shuttle->hitpoints = 0;
}
public function fightUssVoyager(UssVoyager $ussVoyager)
{
$ussVoyager->hitpoints = 0;
}
}
Если мы сейчас извлечем интерфейс Enemy, то получим что-то вроде этого:
interface Enemy
{
public function fightShuttle(Shuttle $shuttle);
public function fightUssVoyager(UssVoyager $ussVoyager);
}
Если мы добавляем в систему нового врага, то знаем, что нам достаточно только реализовать его интерфейс и он просто начнёт работать. Добавляем новый космический корабль? Нам также нужно добавить его в Enemy интерфейс.
Заключение
Однако не всегда всё так просто. В качестве альтернативы можно было бы использовать несколько операторов if/switch, но, я думаю, оно того стоило.
Вы в праве задаться вопросом, а не похоже ли это на паттерн Посетитель и будете правы. Он может решить проблемы, когда нет возможности использовать Двойную диспетчеризацию (см. Википедию).
Также можете посмотреть это видео по этой теме.
Автор: Tony Messias
Перевод: Алексей Широков
Наш Телеграм-канал — следите за новостями о Laravel.
