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

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

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

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

Я классифицирую исключения как фатальные, по-глупости, раздражающие и экзогенные (напишите к комментах если эту статью тоже надо перевести - прим. пер.). Очевидно, что исключение здесь не является ни фатальным, ни экзогенным. Я против создания раздражающих исключений, то есть за обертывание каждого присваивания оружия игроку в try-catch, перехватывающий полностью ожидаемое исключение.

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

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

Второй: Сделать метод Player.TryChangingWeapon, который возвращает булево значение вместо выбрасывания исключения, и тогда вызывающий код должен что-то делать в случае неудачи.

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

Каждый раз, когда я думаю о том, как справиться с ошибкой во время выполнения, я спрашиваю себя: «Являются ли условия ошибки действительно исключительными?» Что, если мы скажем, подождите минутку, желание, чтобы волшебник владел мечом, не было чем-то исключительным. Это может быть запрещено нашей политикой в отношении разрешенного оружия, но попытка не является исключением.

Я много думал об этом, когда проектировал семантический анализатор для Roslyn. Мы могли бы использовать исключения в качестве нашей модели для сообщения об ошибках компилятора, но мы сразу же отказались от этого. Когда вы пишете код в среде IDE, правильный код является исключением! Код, который вы печатаете, почти всегда неверен; предметная область анализатора связана с некорректным кодом и его анализом для целей IntelliSense. Последнее, что мы хотели сделать, это сделать невозможным представление в системе типов некорректных программ C#.

В третьем и четвертом эпизодах этой серии мы увидели, что также было трудно понять как вызвать правильный код для обработки различных конкретных правил, а также куда поместить этот код. Даже если оставить в стороне проблемы с очень многословным и сложным шаблоном посетителя и опасным шаблоном динамического вызова, у нас все еще есть фундаментальная проблема: почему «Паладин в церкви атакует оборотня с мечом» является ответственностью одного из этих типов, а другого? Почему этот код должен быть в классе Paladin, а не, скажем, в классе Sword?

Фундаментальная проблема заключается в моем первоначальном предположении, что бизнес-правила системы должны быть выражены путем написания кода внутри методов, связанных с классами в модели предметной области — волшебниками, кинжалами и вампирами.

Мы продолжаем говорить о «правилах», и, очевидно, предметная область этой программы включает нечто, называемое «правилом», и эти правила взаимодействуют со всеми другими объектами в предметной области. Так должно ли «правило» быть классом? Я не вижу причин почему бы и нет! Это то, о чем программа в основном. Кажется вероятным, что таких правил могут быть сотни или тысячи, и они могут меняться со временем, поэтому кажется разумным реализовать их как классы.

Как только мы осознаем, что «правило» должно быть классом, внезапно становится ясно, что начинать наш дизайн с

  • Волшебник — это разновидность игрока.

  • Воин — это разновидность игрока.

  • У игрока есть оружие.

  • Посох — это разновидность оружия.

  • Меч — это разновидность оружия.

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

Было бы лучше начать с:

  • Основными объектами программы являются пользователи, команды, состояние игры и правила.

  • Пользователь предоставляет последовательность команд.

  • Команда обрабатывается в контексте правил и текущего состояния игры и производит эффект.

Что такое эффект?

  • Не делать ничего — это эффект.

  • Изменение состояния игры — это эффект.

  • Воспроизведение звука — это эффект.

  • Последовательная композиция любого количества эффектов является эффектом.

И что мы знаем о правилах?

  • Правила определяют эффекты, возникающие в результате выполнения конкретным игроком определенного действия; действие может включать произвольное количество других игровых элементов.

  • Некоторые правила описывают универсально применимые инварианты состояния, которые никогда нельзя нарушать.

  • Некоторые правила описывают обработку команд «по умолчанию»; действия этих правил могут быть изменены другими правилами.

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

  • Некоторые правила усиливают другие правила, добавляя дополнительные ограничения.

Теперь все наши прежние проблемы исчезают. У игрока есть оружие, отлично, отлично, сделаем класс Player со свойством типа Weapon. Этот код не пытается представить, что волшебник может владеть только посохом или кинжалом; все, что делает этот код, — сохраняет состояние игры, потому что сохранять состояние — это ответственность.

Далее мы создаем Команду с названием Взять-в-руки, которая принимает два параметра - Игрок и Оружие. Когда пользователь отдает системе команду "Этот Волшебник должен Взять-в-руки этот Меч", то команда выполняется в рамках набора правил, которые создают последовательность эффектов. Одно из правил гласит, что когда Игрок пытается взять в руки Оружие, то существующее оружие, если оно есть, выбрасывается на землю, а новое становится оружием игрока. Другое правило говорит, что эффекты первого правила отменяются если Волшебник хочет взять в руки Меч, а вместо этого применяются другие эффекты: игрок теряет ход, проигрывается звук sad trombone. Когда пользователь снова отдает команду «этот паладин должен атаковать этого оборотня», соответствующие объекты правил проверяются в контексте состояния игры (а именно, паладин владеет мечом и стоит в церкви), и эффекты произведено (заставить меч светиться, оборотень уничтожен, добавить десять очков Гриффиндору, что угодно.)

Какие проблемы мы решили?

У нас больше нет проблемы, связанной с попытками вписать «волшебник может использовать только посох или кинжал» в систему типов языка C#. У нас нет оснований полагать, что система типов C# была разработана так, чтобы иметь достаточную универсальность для кодирования правил Dungeons & Dragons, так зачем же мы вообще пытаемся?

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

И мы решили, вернее, набросали вариант решения проблемы «как понять, какие правила применимы в той или иной ситуации?» Опять же, у нас нет оснований предполагать, что правила разрешения перегрузок в C# и правила разрешения атак Dungeons & Dragons имеют что-то общее. Если мы строим последнее, то нам нужно спроектировать систему, которая правильно выбирает действительные правила из базы данных правил и разумно комбинирует эффекты этих правил. Да, вам нужно построить свою собственную логику разрешения, но разрешение этих правил — забота программы, поэтому, конечно, вам придется написать для этого код.

Что еще мы можем сделать в такой архитектуре? Правила теперь больше похожи на данные, чем на код, и это здорово!

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

  • Мы можем написать небольшой DSL, который кодирует правила в виде удобочитаемого текста.

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

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

Такая система, где бизнес-логика - данные, а не код, будет более тяжеловесной, чем простое кодирование правил в C# и его системе типов, но она также более гибкая. Я говорю, что когда логика программы программы вычисляет сложные правила и определяет их действия, и особенно когда эти правила могут меняться с течением времени быстрее, чем меняется сама программа, тогда имеет смысл задать правила как класс объектов в самой программе.

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

A wizard is a kind of person.
A warrior is a kind of person.
A weapon is a kind of thing.
A dagger is a kind of weapon.
A sword is a kind of weapon.
A staff is a kind of weapon.

Wielding is a thing based rulebook. The wielding rules have outcomes 
allow it (success), it is too heavy (failure), it is too magical (failure).
The wielder is a person that varies.
To consult the rulebook for (C - a person) wielding (W - a weapon):
        now the wielder is C;
        follow the wielding rules for W.

Wielding a sword: if the wielder is not a warrior, it is too heavy.
Wielding a staff: if the wielder is not a wizard, it is too magical.
Wielding a dagger: allow it.

Instead of giving a weapon (called W) to someone (called C):
        consult the rulebook for C wielding W;
        if the rule failed:
                let the outcome text be "[outcome of the rulebook]" in sentence case;
                say "[C] declines. '[outcome text].'";
        otherwise:
                now C carries W;
                say "[C] gladly accepts [the W]."

А также вы можете писать правила, которые модифицируют другие правила

Rule for attacking a werewolf when the time is after 
midnight: decrease the chance of success by 20.

Rule for attacking a werewolf which is not the Werewolf King 
when the player is a paladin and the player wields the Holy Moon Sword: 
increase the attack power by 8.

Нет необходимости решать, «к какому классу относится правило о паладинах и оборотнях?» Правило входит в книгу правил, конец истории. Как я уже сказал, Inform7 великолепен.

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

Источник: https://habr.com/ru/post/711500/


Интересные статьи

Интересные статьи

Это вторая часть моих копаний во внутренностях MySQL. В первой части [habr] были затронуты запись страниц данных на диск (с промежуточной записью в DoubleWrite buffer) и запись бинлогов (с батчингом в...
Обширное руководство из двух частей о создании привлекательного освещения в играх на Unreal Engine, представленное старшим художником по освещению Юрием Воробьевым.
Данной статьей я открываю серию дневников разработки уже третьей реинкарнации моей будущей игры KARC. Первая версия дошла до минимально играбельного вида и выложена в открытый доступ. Однако, мне не п...
Рецепт легкого перекуса для «Telegram - монстра Франкенштейна»Всем привет, это вторая часть создания телеграмм-бота (ссылка на первую часть), в ней мы реализуем Kafka Consumer, который будет ловить л...
Отладка makefile - это что-то из черной магии. К несчастью, не существует такой вещи как makefile отладчик, чтобы изучить ход выполнения конкретного правила или как разворачивается переме...