Дизайн и архитектура в ФП. Часть 3

Свойства и законы. Сценарии. Inversion of Control в Haskell.Совсем немного теории В прошлой части мы убедились, что очень легко запутаться в плохо спроектированном коде. К счастью, с древних времен нам известен принцип «разделяй и властвуй», — он широко применяется при построении архитектуры и дизайна больших систем. Мы знаем разные воплощения этого принципа, как-то: разделение на компоненты, уменьшение зависимости между модулями, интерфейсы взаимодействия, абстрагирование от деталей, выделение специфических языков. Это хорошо работает для императивных языков, и надо полагать, что будет работать в функциональных, за тем исключением, что средства реализации будут другими. Какими же? Рассмотрим принцип Inversion of Control (детальное описание этого принципа можно легко найти в сети, например, здесь и здесь). Он помогает уменьшить связанность между частями программы путем инверсии потока выполнения. Буквально это значит, что мы внедряем в иное место свой код, чтобы там его когда-нибудь вызвали; при этом внедренный код рассматривается как черный ящик с абстрактным интерфейсом. Покажем, что в любом функциональном коде сочетаются оба признака IoC — «внедрение кода» и «черный ящик», для этого рассмотрим простой пример: progression op = iterate (`op` 2) 1 geometricProgression,  arithmeticalProgression ::  Integer → [Integer]geometricProgression = progression (*)arithmeticalProgression = progression (+) geometricals,  arithmeticals ::  [Integer]geometricals = take10 $ geometricProgression 1arithmeticals = take10 $ arithmeticalProgression 1

Здесь на вход одним функциям (iterate, progression) передаются другие функции ((*), (+), `op` 2), то есть, внедряется какой-то код. И внутри принимающих функций этот код рассматривается как черный ящик, для которого известен лишь тип. В случае iterate, например, второй аргумент должен быть типа Integer → Integer, и неважно, насколько сложным будет его устройство. Таким образом, инверсия управления лежит в основе функционального программирования; в теории, функции высших порядков позволяют построить сколь угодно большое приложение. Есть только одна проблема: подобное толкование IoC слишком наивное, и это ведет, конечно же, к наивному коду. Уже в приведенном выше примере видно, что код представляет собой монолитную пирамиду, а в реальном приложении она бы разрослась до гигантских размеров и стала бы абсолютно неподдерживаемой.Посмотрим на IoC с другой стороны, — то есть, со стороны «гостеприимного» клиентского кода. В нем мы получаем какой-то внешний артефакт, служащий определенной цели. Снаружи данный артефакт может быть подменен другим, но для принимающей стороны подмена должна быть незаметной. Это так называемый принцип подстановки Лисков. Он служит ориентиром в ООП-мире и предписывает, чтобы у артефактов было предсказуемое поведение. «Предписывает», а не «гарантирует», поскольку в ООП-языках такой гарантии дать нельзя, — в любом артефакте может внезапно появиться любой побочный эффект, который и нарушит принцип. Применим ли этот принцип в функциональных языках? Да, конечно. Более того, при условии, что код чистый, мы получим более сильные гарантии, — особенно если язык со строгой статической типизацией.

В конце статьи приводится краткое описание разных реализаций Inversion of Control на языке Haskell. Некоторые шаблоны являются практически полными аналогами таковых в императивном мире (например, монадическая инъекция состояния есть Dependency Injection), а какие-то лишь в незначительной степени напоминают IoC. Но все они в равной степени полезны для хорошего дизайна.

Много практики Настало время писать хороший код. В этой статье мы продолжим изучать дизайн игры «The Amoeba World», — целую его эпоху, очерченную этим и этим коммитами. Эпоха была насыщенная. Кроме полностью переписанной игровой логики, были испробованы такие инструменты как линзы, введено тестирование с помощью QuickCheck, придуман язык сценариев, написан его интерпретатор, интегрирован A* — алгоритм поиска по графу мира, и найден еще один специфический антипаттерн, который и положил конец этой эпохе. В этой статье наш разговор коснется только свойств и сценариев, все остальное оставим для следующих частей.Свойства и объекты

Из прошлого опыта стало ясно, чем объекты являются на самом деле, из чего они состоят. Главная идея, заложенная в этот дизайн, такова: объект — это сущность, составленная из некоторых свойств. Объекты «Karyon», «Plasma», «Border» и другие были расчленены, и получен такой набор свойств:

Уникальный идентификатор Название Прочность (максимум и текущее количество HP) Владелец (игрок) Слой (подземелье, земля, небо) Расположение (на карте) Возраст (максимум и текущий возраст) Батарейка (максимум и текущее количество энергии) Запрет движения (по определенному слою в этой ячейке) Направление Движение Фабрика (возможность создавать другие объекты) Самоуничтожение Коллизии (с другими объектами) Дотошный читатель может увидеть здесь несовершенство, например, почему-то «слой» и «расположение» разделены на два свойства, хотя вроде бы они про одно и то же. И что за свойство такое «коллизии»? А «Фабрика»? А «Возраст» и «Самоуничтожение»? И зачем каждому объекту строковое название, которое будет пожирать память? Претензии обоснованные, — и уже в следующей эпохе список был еще раз пересмотрен, причем тем же способом: выделением свойств у свойств. В итоге осталось только шесть, самых важных, «рантаймовых» и «статических», а остальные логичным образом превратились во внешние эффекты и действия…Для примера словесно опишем пару реальных объектов, которые могли бы находиться на игровой карте:

Ядро:     Имя = «Karyon»    Расположение = (1,  1,  1)    Слой = Земля    Владелец = Игрок1    Прочность = 100 / 100    Батарейка = 300 / 2000    Фабрика = Плазма,  Игрок1 Плазма:     Имя = «Plasma»    Расположение = (2,  1,  1)    Слой = Земля    Владелец = Игрок1    Прочность = 30 / 40

Так как свойств конечное число, решено было сделать для каждого тип-обертку и разместить их всех под одним алгебраическим типом (код):  — Object.hs: data Property = PNamed Named              | PDurability (Resource Durability)              | PBattery (Resource Energy)              | POwnership Player              | PLayer Layer              …  deriving (Show,  Read,  Eq)

Определим тип абстрактного объекта:  — Object.hs: type PropertyKey = Inttype PropertyMap = M.Map PropertyKey Property data Object = Object { propertyMap ::  PropertyMap }  deriving (Show,  Read,  Eq)

Первая мысль, которая напрашивается при виде Property, — что мы вернулись к тому, с чего начинали, то есть, к проблеме God ADT (в тот момент это был тип Item). Однако это не так. Существенное различие — в уровне абстракции, который предоставляет нам тип Object. У нас появилось то, что можно назвать «комбинаторной свободой»: небольшое количество свойств дает комбинаторный взрыв возможностей по компоновке новых объектов. Каких-то иных свойств не планируется, —, а если таковые и появятся, изменения не будут распространяться по коду, словно волна по доминошкам. Мы убедимся в этом, когда поговорим о сценариях, а пока зададимся вопросом: как же создавать эти самые конкретные объекты? Самый простой способ — заполнить список свойств и преобразовать его в Data.Map:

-- Objects.hs: import Object karyon = Object $ M.fromList [ (1,  PObjectId 1) ,  (4,  PNamed «Karyon») ,  (2,  PDurability (Resource 100 100)) ,  (3,  PBattery (Resource 300 2000)) ,  (10,  POwnership Player1) ,  (5,  PDislocation (Point 1 1 1)) ,  …]

…, но стоп! По какой такой логике мы прописываем PObjectId, Dislocation и Ownership? Ведь о них имеет смысл говорить только для объектов, находящихся на карте! С другой стороны, есть свойства общие, которые задают класс объектов и потом не изменяются: PNamed и PLayer, PFabric и PPassRestriction (запрет движения). У Karyon слой может быть только Ground, а свойство PNamed «Plasma» может принадлежать, соответственно, только плазме. Здесь мы сталкиваемся с проблемой, что объекты должны создаваться при непосредственном помещении на карту, и при этом нужно иметь шаблоны с первоначальными данными. В качестве шаблонов подойдут так называемые «умные конструкторы» — функции, которые будут создавать нам готовый объект по готовым лекалам и небольшому набору входных параметров. Вот как выглядит более умная функция karyon:  — Objects.hs: import Object karyon pId player point = Object $ M.fromList [ (1,  PObjectId pId) ,  (4,  PNamed «Karyon») ,  (2,  PDurability (Resource 100 100)) ,  (3,  PBattery (Resource 300 2000)) ,  (10,  POwnership player) ,  (5,  PDislocation point) ,  …]

Данный синтаксис трудно назвать изящным, слишком много «шума» и телодвижений. Haskell — лаконичный язык, и мы должны стремиться к простоте и функциональному минимализму, тогда код будет красивее, понятнее и удобнее. Ах, как бы было хорошо, если бы словесное описание шаблона, представленное несколькими абзацами выше, можно было перенести в код… Нет ничего невозможного!  — Objects.hsplasmaFabric ::  Player → Point → FabricplasmaFabric pl p = makeObject $ do    energyCost   .= 1    scheme       .= plasma pl p    producing    .= True    placementAlg .= placeToNearestEmptyCell karyon ::  Player → Point → Objectkaryon pl p = makeObject $ do    namedA       |= karyonName    layerA       |= ground    dislocationA |= p    batteryA     |= (300,  Just 2000)    durabilityA  |= (100,  Just 100)    ownershipA   |= pl    fabricA      |= plasmaFabric pl p

Понятность кода зависит от того, насколько знания и мышление читающего совпали со знаниями и мышлением автора. Понятен ли этот код? Ясно, что он делает, но как он работает? Что, например, здесь значат операторы ».=» и »|=»? Как работает функция makeObject? Почему у некоторых названий есть буква «A», а у некоторых ее нет? И это что, монада, что ли?…Туманный ответ на эти правильные вопросы звучит так: в этом коде используется внутренний язык по компоновке объектов. Его дизайн основан на применении линз совместно с монадой State. Функции с «A»-постфиксами — это умные конструкторы («аксессоры») самих свойств, знающие порядковый номер конкретного свойства и умеющие валидировать значения. Функции без «А» — это линзы. Оператор ».=» принадлежит библиотеке линз и позволяет внутри монады State задать значение, находящееся «под увеличением». Функция plasmaFabric заполняет АДТ Fabric, а функция karyon заполняет PropertyMap и Object. Во втором примере аксессоры и данные передаются в кастомный оператор |=, для корректности будем называть его «оператором заполнения». Оператор заполнения работает внутри монады State. Он вытаскивает текущую PropertyMap и помещает в нее провалидированное аксессором свойство:

-- Object.hs: makeObject ::  Default a => State a () → amakeObject = flip execState def data PAccessor a = PAccessor { key ::  PropertyKey                             ,  constr ::  a → Property } — Оператор заполнения свойств:(|=) accessor v = do    props <- get    let oldPropMap = _propertyMap props    let newPropMap = insertProperty (key accessor) (constr accessor v) oldPropMap    put $ props { _propertyMap = newPropMap } -- Аксессор для свойства Named:isNamedValid (Named n) = not . null $ nnamedValidator n | isNamedValid n = n                 | otherwise      = error $ "Invalid named property: " ++ show n namedA = PAccessor 0 $ PNamed . namedValidator

Этот дизайн не идеален. Очень опасной выглядит валидация свойств, так как она может упасть с ошибкой в рантайме. Мы также не следим за тем, есть ли уже такое свойство в наборе, — просто записываем поверх него новое. И тот, и другой недостаток можно легко исправить, создав стек из монад Either и State, и обрабатывать исключительные ситуации безопасным образом. При этом код в модуле с шаблонами (Objects.hs) изменится незначительно. Плюсов много, но есть одно возражение: пока язык компоновки объектов используется лишь для создания шаблонов, и пока их можно протестировать, лишняя логика будет только мешаться. С другой стороны, когда этот код пойдет в сценарии, безопасность станет важной.Наш последний вопрос, связанный с объектами, таков: как теперь выглядит тип данных World? Здесь особых изменений не произошло, мир по-прежнему является типом Map:

type World = M.Map Point Object

У структуры Data.Map страдает производительность. Более подходящим решением здесь видится двумерный массив; в Haskell существуют эффективные реализации векторов, такие как vector или repa. Когда станет ясно, что производительность игры недостаточно высокая, можно будет вернуться и пересмотреть хранилище мира, но пока скорость разработки важнее.Сценарии

Сценарии — это законы мира. Сценарии описывают то или иное явление. Явления в мире локальные; в одном явлении участвуют только нужные свойства на определенном участке карты. Например, при взрыве бомбы нас интересует прочность объектов в радиусе N, — именно ее мы должны уменьшить на величину урона, и если прочность упала ниже 0, нужно убрать объекты с карты. Если же у нас работает фабрика, мы должны сначала обеспечить ее ресурсом, затем получить продукт и разместить его где-то неподалеку. Прочность не важна, но важны ресурсы, сама фабрика и пустое пространство под продукт.

Сценарии должны выполняться относительно базовых свойств. Если на карте есть объект со свойством «Движение», — запустим сценарий движения. Если работает фабрика, — запустим сценарий по производству боевых единиц. Сценариям не позволено изменять текущий мир; они работают поочередно и накапливают результаты в общей структуре данных. При этом нужно учесть, что иногда работа одних сценариев влияет на работу других, вплоть до полной отмены.

Проиллюстрируем это примерами. Пусть у нас имеется две фабрики, которые производят по одному танку стоимостью в 1 единицу. В запасе у нас есть всего 1 единица ресурса. Первый сценарий отработает успешно, но второй должен узнать, что все ресурсы израсходованы, и прекратить работу. Или другая ситуация: два объекта движутся встречными курсами. Когда между ними остается одна клетка, что должно произойти? Столкновение или невозможность движения одного из объектов? Подобных нюансов может быть очень много; хотелось бы, чтобы сценарии были полными, но оставались предельно простыми для чтения и написания.

Очертим требования к подсистеме сценариев:

надежность; ориентированность на свойства; последовательность; простота; сценарии могут фэйлиться; быстродействие; сценарии могут запускать другие сценарии; … В игре «The Amoeba World» был задизайнен язык Scenario DSL, и написан его интерпретатор (код). Вот как выглядит кусок сценария для свойства Fabric (код):  — Scenario.hs: createProduct ::  Energy → Object → Eval ObjectcreateProduct eCost sch = do    pl <- read ownership    d  <- read dislocation    withdrawEnergy pl eCost    return $ adjust sch [ownership .~ pl, dislocation .~ d] placeProduct prod plAlg = do    l   <- withDefault ground $ getProperty layer prod    obj <- getActedObject    p   <- evaluatePlacementAlg plAlg l obj    save $ objectDislocation .~ p $ prod produce f = do    prodObj <- createProduct (f ^. energyCost) (f ^. scheme)    placeProduct prodObj (f ^. placementAlg)    return "Successfully produced." producingScenario :: Eval StringproducingScenario = do    f <- read fabric    if f ^. producing        then produce f        else return "Producing paused."

Во второй части, а именно в разделе «let-функции», мы видели код громоздкий и непонятный. Теперь же мы видим код легкий, по-прежнему непонятный, но в нем уже просматривается определенная система. Попробуем в ней разобраться.Scenario DSL делится на две части: язык запросов к игровым данным и среда исполнения. В основе всего лежит тип Eval — стек из монад Either и State:

-- Evaluation.hs: type EvalType ctx res = EitherT EvalError (State ctx) restype Eval res = EvalType EvaluationContext res

Внутренняя монада State позволяет хранить и изменять контекст исполнения. Текущий мир, оперативные данные, рандом-генератор, — все это лежит в контексте: data DataContext = DataContext { dataObjects ::  Eval Objects                               ,  dataObjectGraph ::  Eval (NeighboursFunc → ObjectGraph)                               ,  dataObjectAt ::  Point → Eval (Maybe Object) } data EvaluationContext = EvaluationContext { ctxData ::  DataContext                                           ,  ctxTransactionMap ::  TransactionMap                                           ,  ctxActedObject ::  Maybe Object                                           ,  ctxNextRndNum ::  Eval Int }

Внешняя монада Either позволяет безопасным образом обрабатывать ошибки исполнения. Самая распространенная ситуация — когда происходят коллизии, и какой-то сценарий должен оборваться на середине работы. Чтобы состояние игры оставалось правильным, нужно откатить все его изменения, а если сценарий был вызван из другого сценария, — то и там следует как-то реагировать на проблему. Поэтому многие функции имеют тип Eval, который скрывает за собой монаду Either. Фактически, все функции с типом Eval являются сценариями. Даже функции интерпретатора (evalTransact, getTransactionObjects) и функции языка запросов (single, find) работают в этом типе и, по факту, тоже являются сценариями. Иными словами, язык Scenario DSL унифицирован типом Eval, что делает код консистентным и монадно-компонующимся.Так как любая функция с типом Eval — это сценарий, то каждую из них можно запускать и тестировать. Интерпретация сценария — это всего лишь выполнение стека монад:

-- Evaluation.hs: evaluate scenario = evalState (runEitherT scenario)execute scenario = execState (runEitherT scenario)run scenario = runState (runEitherT scenario)

Для игровых сценариев есть одна точка входа — обобщающая функция mainScenario:  — Scenario.hs: mainScenario ::  Eval ()mainScenario = do    forProperty fabric producingScenario    forProperty moving movingScenario    return () — Где-то в главном коде — один тик всей игры: stepGame gameContext = runScenario mainScenario gameContext

Точно так же запускаются и отдельные сценарии, а значит, можно ввести модульное и функциональное тестирование кода. Вот, например, отладочный код из модуля ScenarioTest.hs, — при необходимости его можно трансформировать в полноценный тест QuickCheck или HUnit: main = do    let ctx = testContext $ initialGame 1    let result = execute (placeProduct (plasma player1 point1) nearestEmptyCell) ctx    print result

Теперь, когда мы познакомились с некоторыми особенностями среды исполнения Scenario DSL, препарируем следующую функцию: withdrawEnergy pl cnt = do    obj <- singleActual $ named `is` karyonName ~&~ ownership `is` pl ~&~ batteryCharge `suchThat` (>= cnt)    batRes <- getProperty battery obj    save $ batteryCharge .~ modifyResourceStock batRes cnt $ obj

Это тоже сценарий, служащий определенной цели: для игрока pl изъять из ядра энергию в количестве cnt. Что нужно сделать для этого? Прежде всего, найти на карте объект с такими свойствами: Named == «Karyon» и Ownership == pl. В коде выше мы видим вызов singleActual — эта функция ищет для нас объект по предикату. Благодаря языку запросов словесное описание почти точно переводится в код: named `is` karyonName~&~ ownership `is` pl~&~ batteryCharge `suchThat` (>= cnt)

Нетрудно догадаться, что оператор (~&~) означает «И», а оператор `is` задает равенство определенного свойства значению. Третье условие предиката выбирает только те объекты, для которых батарея заряжена достаточно, чтобы оттуда изъять еще энергии. Конечно же, энергия может кончиться, и тогда объект не будет найден, — в этом случае, начнется fail-ветка монады Either, и весь сценарий будет отменен. Но если энергию можно изъять, то изымаем и накапливаем изменения: save $ batteryCharge .~ modifyResourceStock batRes cnt $ obj

Стоит упомянуть, что в Scenario DSL активно используются линзы, что весьма сокращает код. Например, вместо лаконичного (batteryCharge .~ 10) нам бы пришлось заниматься археологическими раскопками по цепочке: Object → PropertyMap → PBattery → Resource → изменить stock → сохранить все обратно.В языке запросов есть много полезных функций. Можно искать множество объектов по предикату (функция query), можно искать одиночный объект (функция single), а если таковых найдется много, — фэйлить сценарий. Также есть стратегии поиска: искать только старые данные, искать только новые, или все вместе, — и пусть клиентский код сам разбирается. В целом, Scenario DSL хорошо справлялся со своей функцией, и были возможности по его расширению. И была лишь одна серьезная проблема, по которой снова пришлось пересмотреть основу основ — дизайн типа Object. Имя этой проблеме…

Антипаттерн Lens + NoMonomorphismRestriction

Причина всех бед лежит в типе данных PropertyMap и в линзах для свойств:

property k l = propertyMap. at k. traverse. l named            = property (key namedA)            _nameddurability       = property (key durabilityA)       _durabilitybattery          = property (key batteryA)          _battery…

Функция property во всех случаях возвращает разные линзы, что нельзя сделать при включенной проверке мономорфизма. Поэтому пришлось включить расширение языка NoMonomorphismRestriction. К сожалению, из-за этого вывод типов стал ломаться в самых неожиданных местах, и приходилось искать обходные пути. Хуже того: режим NoMonomorphismRestriction начал распространяться по коду. Он появлялся везде, где использовались линзы модуля Object.hs, и заражал безумием тайпчекер. В конце концов, дизайн Scenario DSL стал прогибаться под ограничениями тайпчекера, — что привело к нескольким не очень хорошим решениям.Проблему можно искоренить, отказавшись от типа PropertyMap. Тогда в типе Object окажутся все свойства, — даже те, которые конкретному объекту не понадобятся. Возможно, есть и другие решения, но в следующей версии дизайна было сделано именно так:

data Object = Object { — Properties:                          objectId ::  ObjectId — static property                       ,  objectType ::  ObjectType — predefined property — Runtime properties,  resources:                        ,  ownership ::  Player — runtime property… or can be effect!                         ,  lifebound  ::  IntResource — runtime property                       ,  durability ::  IntResource — runtime property                       ,  energy     ::  IntResource — runtime property                       }

Нет худа без добра, — в результате пересмотра другие свойства превратились во внешние эффекты и действия. Дизайн стал более правильным, хотя и пришлось выбросить большую часть наработок по Scenario DSL…Вместо заключения Новый движок сценариев, предположительно, будет основан уже на иных принципах. В частности, планируется сделать не внутренний DSL, а внешний, — тогда сценарии можно будет писать в обычных текстовых файлах. На данный момент автор работает над слоями Application и View, над поиском оптимальной модели использования FRP. В следующих главах будет рассказано о том, какая идея стоит за FRP, и как с помощью реактивного программирования можно соединить разрозненные части большого приложения.Реализации Inversion of Control в Haskell Disclaimer: автор не успел закончить исследования для данного раздела. Продолжение будет в следующих статьях.Монадическая инъекция состояния (Monadic state injection)

Чем является: Инъекция зависимости (Dependency Injection).Для чего используется: Для абстрагированной работы с внешним состоянием в клиентском коде.Описание: Внешнее состояние внедряется через монаду State как контекст. Клиентский код запускается в монаде State с этим контекстом. При обращении к контексту клиентский код получает данные из внешнего состояния.Структура:

Определяем тип данных Context — он будет содержать внешнее состояние в виде монады State:

data Context = Context { ctxNextId ::  State Context Int }

Определяем конкретные экземпляры внедряемого кода. Код может выдавать константный результат:

constantId ::  State Context IntconstantId = return 42

Или же может выдавать разные результаты на каждый вызов:

nextId ::  Int → State Context IntnextId prevId = do let nId = prevId + 1                   modify (\ctx → ctx { ctxNextId = nextId nId })                   return nId

Создаем клиентский код в монаде State:

        client = do            externalId <- get >>= ctxNextId            doStuff externalId            return externalId

Запускаем клиентский код, внедряя конкретный экземпляр внешнего состояния:

print $ evalState client (Context constantId)print $ evalState client (Context (nextId 0))

Полный пример: gistВывод программы-примера: Sequental ids:

[(1, «GNVOERK»),(2, «RIKTIG YOGLA»)]Random ids:[(59, «GNVOERK»),(64, «RIKTIG YOGLA»)]

Модульная абстракция (Module Abstraction)Чем является: Черный ящик.Для чего используется: Выбирать реализацию алгоритма в рантайме.Описание: Есть модуль-фасад, в котором подключены несколько модулей, реализующих одну и ту же функцию. По определенному алгоритму в функции-переключателе фасадного модуля выбирается та или иная реализация. В клиентском коде подключается фасадный модуль, и через функцию-переключатель используется нужный алгоритм.Полный пример: gist

© Habrahabr.ru