Паттерн «Двойная диспетчеризация»

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

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