В этой серии мы исследуем проблему «игрок может использовать оружие, волшебник — разновидность игрока, посох — разновидность оружия, а волшебник может использовать только посох». Лучшее решение, которое мы придумали до сих пор — выбросить исключение преобразования типа во время выполнения, если разработчик допустил ошибку. Это не кажется оптимальным решением.
Попытка №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)
denis_pesherin
17.01.2023 16:24+1Интересно как вы будете решать проблемы, которые очевидно следуют далее:
Посох не один, их много типов, так как как и условный меч
Для использования конкретного посоха/меча необходим N уровень персонажа
Персонаж определяется не типом жестко фиксированным, а набором статов
Игрок может перекидывать статы, что может в любой момент превратить персонажа из война в мага и наоборот
Маг все таки может иметь меч, но зачарованный допустим
Мне кажется, в этом примере ограничение средствами системы типов - изначально провальная идея, потому что получится такой клубок, который при критическом кол-ве условий просто невозможно будет распутать.
Oceanshiver
17.01.2023 17:11Очевидно, что проблема решается иначе - всё оружие разбивается на типы, например "одноручное", "двуручное", "посохи", "огнестрел", etc. Под каждый тип заводится интерфейс/базовый класс, и эти интерфейсы используются в потомках базового класса игрока, но никак не в базовом.
Да, я понимаю, что тут не совсем удачный пример выбран, но это решение для данной задачи.
gandjustas Автор
17.01.2023 17:13Можете привести пример кода?
Нам достаточно одноручного и посохов и те же два класса - волшебник и воин. Так чтобы работать с ними можно было полиморфно и нельзя было дать волшебнику не посох.
Naf2000
17.01.2023 21:30public interface IPlayer { Weapon Weapon { get; } } public class Wizard : IPlayer { public Weapon Weapon => Staff; public Staff Staff { get; set; } }
Так не подойдёт? всё равно при "установке" оружия нужно знать тип
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); }
Keeper9
Сделайте уже список допустимых классов оружия, и успокойтесь.
gandjustas Автор
Что вы имеете ввиду?
shai_hulud
Либо на своем визарде сделайте список классов оружия, которое он может надевать. Либо на оружии список классов игрока, которые его могут надевать.
Всё это надо выражать в данных, а не коде. Иначе геймдизайнер ничего не сможет настроить без программиста.
gandjustas Автор
Это правильный подход. Но статья о другом - как задать правила и ограничения с помощью системы типов языка C#.
Автор статьи не разработчик игр, он разработчик компилятора.