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

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

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

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

Попытка №3

abstract class Player 
{
  public Weapon Weapon { get; set; }
}

sealed class Wizard : Player
{
  public new Staff Weapon 
  {
    get { return (Staff)base.Weapon; }
    set { base.Weapon = value; }
  }
}

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

Но у него есть не очень приятные свойства. Мы по-прежнему нарушаем Принцип Подстановки Лисков (LSP): если у нас есть ссылка на Игрока, мы можем без ошибок присвоить Меч в качестве Оружия:

Wizard wizard = new Wizard();
Player player = wizard;
player.Weapon = new Sword(); // Отлично
Staff staff = wizard.Weapon; // Упс!

В этой версии исключение происходит, когда мы пытаемся получить Посох Волшебника! Такую «бомбу замедленного действия» очень сложно отлаживать, и оно нарушает правило, согласно которому геттер никогда не должен выбрасывать исключение.

Мы могли бы решить эту проблему следующим образом:

abstract class Player 
{
  public Weapon Weapon { get; protected set; }
}

Теперь, если вы хотите присвоить оружие, вам нужна ссылка на Волшебника, а не на Игрока, а сеттер обеспечивает безопасность типов.

Это довольно хорошо, но все же не отлично. Если у нас есть ссылка на Волшебника и Посох в руке, то все хорошо. Но Волшебник находится в переменной типа Игрок, тогда нам нужно знать, к какому типу привести Игрока, чтобы сделать то, что является допустимым действием, но не разрешенным для типа Игрока. И, конечно же, та же проблема возникает, если Посох находится в переменной типа Оружие; теперь мы должны знать, к чему его привести, чтобы задать свойство.

На самом деле мы не многого здесь добились; у нас все время будут возникать приведения типов, некоторые из которых могут потерпеть неудачу, и в случае неудачи надо что-то делать.

Попытка №4

Интерфейсы! Да, это вариант.

Любую проблему с иерархией классов можно решить, добавив дополнительные слои абстракции, за исключением, может быть, проблемы слишком большого числа слоев абстракции.

interface IPlayer 
{
  Weapon Weapon { get; set; }
}

sealed class Wizard : IPlayer
{
  Weapon IPlayer.Weapon 
  {
    get { return this.Weapon; }
    set { this.Weapon = (Staff) value; }
  }
  public Staff Weapon { get; set; }
}

Мы потеряли то приятное свойство, что у нас есть удобный контейнер для кода, общий для всех игроков, но это поправимо, созданием базового класса.

Хотя это популярное решение, на самом деле мы просто передвигаем проблему, а не исправляем ее. Полиморфизм по-прежнему полностью сломан, потому что кто-то может иметь ссылку на IPlayer и присвоить Меч в качестве Оружия, что вызовет исключение. Интерфейсы в том виде, в каком мы их использовали здесь, по существу ничем не лучше абстрактных базовых классов.

Попытка №5

Пришло время доставать большие пушки - ограничения обобщенных типов (generic constraints)! Да, детка!

abstract class Player<TWeapon>  where TWeapon : Weapon
{
  TWeapon Weapon { get; set; }
}
sealed class Wizard : Player<Staff> { }
sealed class Warrior : Player<Sword> { }

Это настолько ужасно, что я даже не знаю, с чего начать, но я попытаюсь.

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

Мы могли бы решить эту проблему, наследуя обобщенный тип Игрока<Оружие> от необобщенного типа Игрока, у которого нет свойства Оружия, но теперь вы не можете воспользоваться тем фактом, что у Игрока есть Оружие, если у вас есть ссылка на необобщенного Игрока.

Во-вторых, это полное злоупотребление нашей интуицией в отношении обобщенных типов. Мы хотим, чтобы подтипы представляли отношение «волшебник — это разновидность игрока», а дженерики — для представления отношений «коробка с фруктами и коробка с печеньем — разновидности коробок, но не разновидности друг друга». Я знаю, что корзина с фруктами — это корзина с фруктами, но я вижу, что «волшебник — это игрок с посохом», и я не знаю, что это значит. У меня нет интуитивного понимания, что обобщения в том виде, в каком мы их здесь использовали, означают «волшебник — это игрок, который может использовать только посох».

В-третьих, похоже, что это не будет масштабироваться. Если я хочу также сказать «игрок может носить доспехи, а волшебник может носить только мантию», и «игрок может читать книги, а воин может читать только немагические книги» и так далее, мы собираемся получить полдюжины аргументов типа Игрок?

Попытка №6

Объединим попытки 4 и 5. Проблема возникает, когда мы пытаемся менять оружие. Мы могли бы сделать классы игроков иммутабельными, создавать нового игрока каждый раз, когда меняется оружие, и теперь мы можем сделать интерфейс ковариантным. Хотя я большой поклонник иммутабельности, мне не очень нравится это решение.

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

Ах да, еще кое-что. Увидев Арагорна с его арсеналом, я вспомнил, упоминал ли я ранее, что и волшебники, и воины могут использовать кинжалы?

Возможно, ваши пользователи всегда точно сообщают вам точные ограничения предметной области, прежде чем вы начнете кодировать каждый проект, и они никогда не изменятся в будущем. Ну я не такой пользователь. Мне нужно, чтобы система учитывала тот факт, что и волшебники, и воины могут использовать кинжалы, и нет, кинжалы не являются «особым видом меча», так что даже не спрашивайте.

Попытка №7

Очевидно, что здесь нам нужно объединить все методы, которые мы видели до сих пор в этом эпизоде:

sealed class Wizard : 
  IPlayer<Staff, Robes> , 
  IPlayer<Dagger, Robes>
{ ...

Я думаю на этом закончим. Простое написание этого небольшого отрывка отняло бы у меня лишние несколько часов жизни.

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

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

Публикации

Истории

Работа

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

Weekend Offer в AliExpress
Дата20 – 21 апреля
Время10:00 – 20:00
Место
Онлайн
Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн