Как стать автором
Обновить

Воины и волшебники, часть третья

Время на прочтение6 мин
Количество просмотров3K
Автор оригинала: Eric Lippert

Итак, давайте отвлечемся на несколько эпизодов. Мы временно оставим в стороне проблему того, как мы можем иметь и Игрока с Оружием, и Волшебника с Посохом (или Кинжалом). Предположим, что мы можем все это представить в виде типов.

У нас есть еще одна проблема. Предположим, у нас также есть классы Оборотней и Вампиров, которые являются разновидностью Монстров. Нам нужно правило, которое гласит, что если Воин попытается ударить Оборотня после полуночи, то вероятность успеха будет снижена. (У волшебников нет такого штрафа, потому что… магия?)

Подождите минутку — разве текущий момент времени это не после полуночи всегда? Короче, когда можно безопасно кормить могваев? Хотя это увлекательная проблема, я уверен, что это не та проблема, о которой я хочу говорить сегодня.

Проблема на сегодня: где именно находится код, определяющий, успешно ли Воин попадает в Оборотня? У нас задействованы четыре класса: Игрок, Воин, Монстр, Оборотень. Где-то есть метод, назовем его Атака. Как выглядит этот метод?

Мы объектно-ориентированные программисты, программирующие объекты на объектно-ориентированном языке, методы — это глаголы, а классы — существительные. Игрок — это то, что атакует, поэтому, очевидно, Атака должна быть методом для Игрока. Поскольку правила для Воина явно отличается от правила для Волшебника, лучше бы Атака была виртуальным методом:

abstract class Player
{
  public virtual void Attack(Monster monster)
  {
    // Basic rules go here
  }
  ...
}
sealed class Warrior : Player
{
  public override void Attack(Monster monster)
  {
    if (monster is Werewolf)
       this.ResolveWerewolfAttack((Werewolf)monster);
    else
       base.Attack(monster);
  }
  private void ResolveWerewolfAttack(Werewolf monster)
  {
    // Special rules go here
  }

Мне не нравится.

По сути, Attack принимает ссылки на Player и Monster времени компиляции, а затем выбирает нужную атаку на основе типов времени выполнения двух аргументов. Виртуальные методы диспетчеризуют вызов метода на основе типа времени выполнения только одного из двух аргументов. Затем этот метод должен сам выполнить вторую диспетчеризацию путем явной проверки типа второго аргумента.

Почему Player (первый аргумент) настолько уникален, что его диспетчеризацию обрабатывает сам язык?

Язык C# (и C++, и Java, и многие подобные языки) называют языком «одинарной диспетчеризации». То есть метод выбирается по типу времени выполнения одного особенного аргумента, а все остальные аргументы имеют тип времени компиляции. Этот аргумент во всех языках, которые я упомянул, настолько особенный, что для него зарезервировано ключевое слово — this — и этот аргумент нигде не появляется в ни в объявлении метода, ни в списке аргументов, для которых имеет значение только тип времени компиляции.

Вот почему наше решение кажется таким неэлегантным в C#; чтобы изящно решить эту проблему, нам нужен язык, поддерживающий двойную диспетчеризацию.

Прежде чем я продолжу, короткое жаргонное примечание. Под диспетчеризацией я подразумеваю принятие решения либо во время компиляции, либо во время выполнения, о том, какой метод вызывать из нескольких вариантов. Под одинарной, двойной или множественной диспетчеризацией я подразумеваю, что решение основано на типе времени выполнения одного, двух или нескольких аргументов. Под виртуальным вызовом я подразумеваю особый способ, который C# использует для достижения одинарной диспетчеризации: вызов метода ищет, какой метод фактически вызывать, в таблице, связанной с this. «Виртуальный» (virtual) — неудачный термин; его более четко можно было бы назвать «косвенным» (indirect) вызовом метода, но приклеился именно первый.

C# не поддерживает двойную диспетчеризацию, но мы можем эмулировать это следующим образом:

abstract class Player
{
  public abstract void Attack(Monster monster);
}

sealed class Warrior : Player
{
  public override void Attack(Monster monster) 
  {
    monster.ResolveAttack(this);
  }
}

sealed class Wizard : Player
{
  public override void Attack(Monster monster) 
  {
    monster.ResolveAttack(this);
  }
}
 
abstract class Monster
{
  private void ResolveAttack(Player player) 
  {
    // default implementation goes here
  }

  public virtual void ResolveAttack(Warrior player) 
  {
    this.ResolveAttack((Player)player);
  }

  public virtual void ResolveAttack(Wizard player) 
  {
    this.ResolveAttack((Player)player);
  }
}

sealed class Werewolf : Monster
{
  public override void ResolveAttack(Warrior player) 
  {
    // special logic for Warrior vs Werewolf goes here
  }
}

sealed class Vampire : Monster
{
}

Следуя этой логике, предположим что у нас есть:

Player player = new Warrior();
Monster monster = new Werewolf();
player.Attack(monster);

У во время компиляции у нас нет информации о том, как диспетчеризовать вызов в соответствующий метод. Мы вызываем абстрактный метод Player.Attack(Monster), который во время выполнения выполняет виртуальный вызов Warrior.Attack(Monster). Этот метод вызывает Monster.ResolveAttack(Warrior), который выполняет виртуальный вызов Werewolf.ResolveAttack(Warrior), и все, готово. Мы вызвали метод, который имеет нужную нам логику.

Если бы вместо этого у нас было:

Player player = new Wizard();
Monster monster = new Vampire();
player.Attack(monster);

Тогда Player.Attack(Monster) выполняет виртуальный вызов Wizard.Attack(Monster), который вызывает Monster.ResolveAttack(Wizard), который выполняет виртуальный вызов. Vampire не переопределяет этот метод, поэтому мы получаем реализацию базового класса, которая, в свою очередь, вызывает Monster.ResolveAttack(Player), и мы получаем логику без специальных правил.

Этот паттерн, который имеет множество вариаций, называется паттерном посетителя (Visitor). Возможно вас смутило название. Не ясно, почему эмуляция двойной диспетчеризации с использованием серии вызовов одинарной диспетчеризации связана с «посещением» чего-либо.

Одно из наиболее распространенных применений этого паттерна — сценарий, в котором у вас есть сложное дерево, в котором узлы дерева могут быть разных типов, и вы хотите пройти по дереву — то есть посетить каждый узел в дереве — выполнив действие или вычисления, пока вы обходите дерево. «Двойная диспетчеризация» заключается в том, что действия, которые вы предпринимаете при посещении узлов в дереве, могут зависеть от типов времени выполнения как узлов в дереве, так и кода, выполняющего действие.

Этот шаблон очень распространен в компиляторах, потому что, конечно, у нас часто есть деревья с множеством различных типов узлов в деревьях (выражения, операторы и типы...), и мы хотим выполнять различные действия с этими деревьями (оптимизация, упрощение, генерация кода, поиск ошибок). Семантические анализаторы компиляторов Roslyn C# и VB интенсивно используют этот шаблон.

Итак, мы действительно решили проблему диспетчеризации с правильной логикой для нашей проблемы воина и оборотня? Это решение работает, но имеет ряд существенных недостатков.

  • Кажется немного странным, что мы начали с того, что «атака — это глагол для игроков, поэтому логика должна идти в иерархии классов игроков», а затем сделали прямо противоположное. Весь код для обработки основных и специальных атак оказался в иерархии Monster. Конечно, есть много способов реорганизовать код, чтобы исправить это, но все равно это странно. По сути, Monster — это то, что в Roslyn было бы анализатором, а Player — синтаксическим деревом, поэтому вполне логично, что причудливая логика будет реализована в Monster. Но в нашем примере кажется совершенно не понятно, является ли Monster посетителем или тем кого посещают.

  • Боже мой, посмотри на этот "водопроводный" код! Недавно я уже говорил, что считаю нормальным использовать принцип WET, а не DRY, когда вы создаете "водопроводный" код, но, тем не менее, в этом примере очень много лишнего. И это очень простой случай!

    Для Roslyn мы сделали небольшой язык описания типов из XML, а затем построили генератор кода, чтобы автоматически выдавать весь код диспетчеризации для посетителей, потому что мы не были уверены, что всегда правильно вносим изменения в посетителей при изменений иерархии синтаксических узлов.
    Весьма вероятно, что "водопроводный" код перегрузит читателя, затенит бизнес-логику, запутает начинающего программиста, не привыкшего к паттерну, и предоставит широкие возможности для ошибок.

  • Я упоминал, что нам также важен тип используемого оружия во время выполнения? И локация, в которой происходит битва? И Погода. И… Хорошо, может быть, некоторые из них не требуют полных механизмов виртуальной диспетчеризации, но предположим, что мы хотим сделать множественную диспетчеризацию? Вероятно, вы видите, как можно расширить этот шаблон, чтобы использовать больше одиночных вызовов диспетчеризации для эмуляции тройных, четверных и т. д., но это не будет красиво.

Этот шаблон работает, и, как я уже сказал, я активно использовал его в прошлом. Но он кажется очень тяжеловесным и очень негибким во многих отношениях. Есть ли более простой способ получить множественную диспетчеризациию на С#?

В следующей части мы рассмотрим совершенно другой способ множественной диспетчеризации в C#.

Теги:
Хабы:
+6
Комментарии1

Публикации

Изменить настройки темы

Истории

Работа

Ближайшие события

Weekend Offer в AliExpress
Дата20 – 21 апреля
Время10:00 – 20:00
Место
Онлайн