В последних четырех эпизодах мы изо всех сил пытались закодировать правила нашей предметной области — которые, напомню, могут быть волшебниками и воинами, заказами и оплатами или чем-то еще — в систему типов 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 великолепен.

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

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


  1. Naf2000
    20.01.2023 17:49
    +1

    Начали за здравие, закончили за упокой. По мне так просто перенесли проблему в другую область - правила. С типами ничего не решено - всё равно явно проверять типы в правилах? Как не запутаться в этих правилах? Волшебник не может иметь меч, кроме одетых в белую мантию или на поляне единорогов? Считаю тема не раскрыта, ушёл думать.


    1. gandjustas Автор
      20.01.2023 18:22

      Так и есть. Разработчик компилятора c# в итоге пришел к выводу, что моделировать предметную область с помощью типов c# - плохая идея и вряд ли получится что-то хорошее. Он предлагает моделировать задачу, отражая в виде классов в первую очередь то, как пользователь (клиент) программы взаимодействует с данными.

      Комментаторы в предыдущих выпусках, которые имели опыт гймдева, настойчиво предлагали ввести список разрешённого оружия для игрока. Это и есть правила, о которых пишет Липперт.


      1. Naf2000
        20.01.2023 18:49
        +1

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


        1. gandjustas Автор
          21.01.2023 01:04

          Скорее ООП, как оно сделано в C-подобных языках, не годится для моделирования предметной области. Нужен другой язык. Возможно DSL.

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


          1. heartdevil
            23.01.2023 09:16

            Получается, DDD который практикуют в .net c# -- это тоже все, в принципе, не подходит? И нужен DSL? Или это к игровой индустрии относится?


            1. gandjustas Автор
              23.01.2023 09:53

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

              Игроки управляют персонажами типа Маг, Воин итд, у которых есть оружие. Ровно как описано в этой серии постов.

              Как DDD поможет решить проблемы, описанные в этой серии? В чем принципиально такая игра отличается от корпоративного приложения? Как бы вы спроектировали такую игру?