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

Попытка №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>
{ ...

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

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

Комментарии (12)


  1. Keeper9
    17.01.2023 11:57

    Сделайте уже список допустимых классов оружия, и успокойтесь.


    1. gandjustas Автор
      17.01.2023 12:01

      Что вы имеете ввиду?


      1. shai_hulud
        17.01.2023 12:11
        +6

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

        Всё это надо выражать в данных, а не коде. Иначе геймдизайнер ничего не сможет настроить без программиста.


        1. gandjustas Автор
          17.01.2023 13:17

          Это правильный подход. Но статья о другом - как задать правила и ограничения с помощью системы типов языка C#.
          Автор статьи не разработчик игр, он разработчик компилятора.


  1. naneutral
    17.01.2023 13:07
    +1

    Спасибо за перевод. Жду следующие части.


  1. denis_pesherin
    17.01.2023 16:24
    +1

    Интересно как вы будете решать проблемы, которые очевидно следуют далее:

    • Посох не один, их много типов, так как как и условный меч

    • Для использования конкретного посоха/меча необходим N уровень персонажа

    • Персонаж определяется не типом жестко фиксированным, а набором статов

    • Игрок может перекидывать статы, что может в любой момент превратить персонажа из война в мага и наоборот

    • Маг все таки может иметь меч, но зачарованный допустим

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


  1. Oceanshiver
    17.01.2023 17:11

    Очевидно, что проблема решается иначе - всё оружие разбивается на типы, например "одноручное", "двуручное", "посохи", "огнестрел", etc. Под каждый тип заводится интерфейс/базовый класс, и эти интерфейсы используются в потомках базового класса игрока, но никак не в базовом.

    Да, я понимаю, что тут не совсем удачный пример выбран, но это решение для данной задачи.


    1. gandjustas Автор
      17.01.2023 17:13

      Можете привести пример кода?

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


  1. Naf2000
    17.01.2023 21:30

    public interface IPlayer 
    {
      Weapon Weapon { get; }
    }
    
    public class Wizard : IPlayer
    {
      public Weapon Weapon => Staff;
      public Staff Staff { get; set; }
    }

    Так не подойдёт? всё равно при "установке" оружия нужно знать тип


    1. lexxpavlov
      17.01.2023 21:58

      А кинжал как взять в руку?


  1. mayorovp
    18.01.2023 10:58

    Что-то я так и не увидел своего варианта, позволяющего сохранить и LSP, и полиморфизм подтипов.


    abstract class Player
    {
        public abstract bool CanHoldWeapon(Weapon weapon);
    
        /// <exception cref="WeaponNotSupprtedException"></exception>
        public abstract Weapon Weapon { get; set; }
    }

    Попытка выдать меч волшебнику всё ещё исключение, но теперь оно ожидаемое.


    Ну или даже вот так можно:


    abstract class Player
    {
        public abstract bool CanHoldWeapon(Weapon weapon);
    
        public abstract Weapon Weapon { get; }
    
        public abstract bool TrySetWeapon(Weapon weapon);
    }


    1. gandjustas Автор
      18.01.2023 11:38

      Про это будет в финальной 5ой части