Рассмотрим паттерн программирования известный как Двойная диспетчеризация (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.