Восходящее проектирование в ФП. Идея — основа хорошего дизайна. Антипаттерны в Haskell.
Немного теории
В прошлой части мы построили высокоуровневую архитектуру приложения. Мы определили подсистемы и их связи, а также разделили программу на три слоя: Application, Game Logic, Views. По логике, следующий этап — дизайн приложения. По важности этот этап не уступает предыдущему, так как именно в ходе дизайна мы должны поддержать все функциональные требования, определить фактическую структуру подсистем, описать основные технические проблемы, применить какие-либо типовые решения или придумать другие. Но прежде попробуем ответить на вопрос: каков он, хороший дизайн ПО? По каким критериям мы определяем «хорошесть» дизайна?
Вот три возможных ответа:
- Полнота. Хороший дизайн — тот, в котором учтены все требования при минимальных фактических затратах на имплементацию, сопровождение и внесение новой функциональности.
- Результативность. Хороший дизайн — это тот, при котором достигаются цели разработки.
- Простота. Хороший дизайн — тот, при котором объективная сложность предметной области успешно отображена на простые рабочие концепции.
Из последнего вытекает следствие, что хороший дизайн должно быть легко объяснить за 10 минут, не жертвуя точностью и полнотой (С. Тепляков, «О дизайне»).
Вышесказанное можно проиллюстрировать диаграммой:
Здесь внешний треугольник описывает идеальную гипотетическую систему, в которой каждый из трех критериев удовлетворен на 100%. Красный треугольник показывает текущее состояние дел. Центр тяжести внешнего треугольника при этом — нулевая точка. Заметим, что при сдвиге критерия в «отрицательную» сторону мы видим различные проблемные ситуации, которых не должно быть ни в коем случае, так как это уже больше похоже на саботаж.
Примеры проблемных ситуаций:
- Требования не соблюдены, но цели разработки достигнуты: значит, команда имитирует бурную деятельность в погоне за бонусами или из-за иной мотивации. Команда достигла целей, своих, внутренних; они не совпадают с целями рынка или компании, а продукт не делает того, что от него ожидают.
- Цели не достигнуты, но соблюдены все требования: мы два года делали игру «Крестики-нолики», она всем хороша, там даже можно грабить корованы… Но на конкурс мы не успели, — он уже давно закончился, и теперь мы не у дел.
- Требования соблюдены, цели достигнуты, код быстро написан, но… положенный в основу дизайна фреймворк чрезмерно переусложнен, падуч, написан под текущую ситуацию, и только два человека из команды понимают, как он работает.
Теперь стало ясно, почему так важен хороший дизайн. Но ни один из приведенных критериев ничего не говорит о том, что же нужно сделать, чтобы его получить. В литературе обычно ответа на этот вопрос тоже нет; можно встретить общие советы вроде: «соблюдайте принципы такие-то», «используйте паттерны», «моделируйте по процессу имени того-то». Советы правильные: так можно получить отличный код. Есть только одна проблема: код — это не дизайн. Даже при самом вылизанном коде нет никакой гарантии удовлетворить все три критерия «хорошести» дизайна. Особенно — сделать дизайн простым и полным.
Откуда это следует? Возьмем для примера сквозной процесс моделирования при помощи UML. Разработчик раскрывает по цепочке диаграммы: Use Cases → User Scenarios → Sequence Diagrams → Object Diagrams → Communication Diagrams → Class Diagrams →…. При этом термины предметной области становятся классами, модулями, пакетами и взаимодействиями (например, вот так или вот так). Цель проста: определить все сущности и действия с ними; то есть, разработать систему «в лоб», как мы ее видим и понимаем. Мы просто транслируем наши знания о системе в модель этой системы, и нельзя заранее сказать, сколько будет сущностей, сколько элементов, и какова будет паутина связей, — даже с учетом всяких обобщений. При этом очень трудно ввести DSL, поскольку предметно-ориентированный язык напрямую ни из одной диаграммы не
вытекает. Получится ли модель простой и полной? Неизвестно.
На верхнем уровне абстракции наш мир является императивным. У нас всегда есть состояние, всегда есть очередность действий с этим состоянием. «Подойдя к перекрестку, посмотрите налево, потом направо, и если нет машин, — переходите». Перенося этот уровень абстракции один-к-одному в дизайн, мы получим императивное решение, что не соответствует функциональной парадигме. Напротив, более глубокие уровни абстракции мира не являются императивными, поскольку основаны на свойствах и законах, а не на сущностях и действиях. «Переходить дорогу можно тогда, когда нет опасности». Было бы ошибкой для описания физического мира применять императивный подход: «Если в следующую секунду земля еще не достигнута, увеличить скорость падения на величину g». В применении к ФП эта проблема только обостряется. Мы увидим далее, что подход «в лоб» ведет к серьезным концептуальным проблемам. Код получается не идиоматичным, трудноуправляемым, он становится переусложненным, и все преимущества ФП просто теряются. Но если разложить предметную
область на свойства и законы, мы получим решение, которое прекрасно ложится на ФП.
Зафиксируем это в четвертом критерии «хорошести» дизайна:
4. Фундаментальность. Хороший дизайн — это тот, в основе которого лежит правильная идея, на фундаментальном уровне описывающая предметную область. Иными словами — «чем это является», а не «как это работает».
Правильные идеи вообще важны везде. Любая научная концепция — суть некая идея, из которой выводятся все следствия. В основе интеграла Римана лежит идея о сумме бесконечно малых. ОТО Эйнштейна зиждется на эквивалентности инерционной и гравитационной масс. В основе языка Scala — положение, что всё есть объект, а любой объект есть функция. А в Lisp’e всё есть списки. Идея играет решающую роль при построении дизайна ПО.
Для примера рассмотрим парсинг ini-файла. В чем разница между хорошим и плохим дизайном? В первом случае применена идея, что конфигурация и машина состояний могут существовать отдельно. В итоге конечный автомат может работать с любой конфигурацией, — безотносительно того, что она делает. Во втором случае эти сущности смешаны, код выглядит безыдейно, переиспользовать его уже нельзя. А вот как могло бы выглядеть решение той же задачи с применением другой идеи: Parsec, Boost.Spirit. Мы воспользовались комбинаторными DSL, и просто перенесли свойства ini-файла — его грамматику — в код.
Придумывая DSL, мы реализуем идею о том, как можно отобразить сложность предметной области на понятный формальный язык. Благодаря правильной идее мы можем закодировать не сущности как таковые, а наборы их свойств; тогда любую сущность можно будет выразить комбинированием свойств. При этом свойств будет заведомо меньше, и взаимодействий меньше (к слову, именно к этому стремятся физики в поисках фундаментальных основ Вселенной).
Таким образом, четвертый критерий позволяет нам на практике прийти к хорошему дизайну. Но это уже будет подход проектирования «снизу», начиная с самых нижних уровней, о которых нам стало известно благодаря удачной идее.
Немного практики
Чтобы не быть голословными, проследим эволюцию дизайнерских идей для игры «The Amoeba World». Это стратегическая игра про мир, населенный амебами. Игроку предлагается раз в 10 секунд отдавать распоряжения своей колонии амеб и затем просто смотреть, как будут развиваться события. Амебы производят и поглощают энергию, растут, борются за территорию и ресурсы. Ресурсом служит энергия. Амеба почти полностью состоит из плазмы со вкраплениями органелл: ядро, работающее как батарея, митохондрии — источники энергии, а также органеллы для защиты и нападения. Мир игры бесконечен и представляет собой квадратную сетку. Помимо амеб в нем есть каньоны, реки, камни, источники дополнительной энергии и прочие объекты. У мира три слоя: наземный, воздушный и подземный, со своими объектами и законами.
Какие ставились требования к содержимому игры?
- Расширяемость: можно легко добавлять объекты игрового мира.
- Вариативность: объекты могут быть сложносоставные и простые, активные и пассивные, дорогие и дешевые, прочные и хрупкие.
При этом хотелось бы какого-нибудь полиморфизма во взаимодействии объектов. Как это можно реализовать?
Лобовое решение. Антипаттерн God ADT
Первая же пришедшая на ум идея — сделать единый универсальный алгебраический тип данных для объектов игрового мира, и держать эти объекты в структуре Data.Map. Вот так:
module GameLogic.Types where
import qualified Data.Map as M
data Item = Karyon ...
| Plasma ...
| Mitochondrion ...
| Stone …
type World = M.Map Point Item
type OperatedWorld = World
Итерации мира представим в виде функции stepWorld. В ней мы будем проходить по каждому объекту на карте и звать для него функцию apply. В ней значение типа World представляет собой мир на прошлой итерации, а в значении типа OperatedWorld накапливаются результаты функции apply.
module GameLogic.Logic where
import GameLogic.Types
stepWorld :: World -> World
apply :: World -> Item -> OperatedWorld -> OperatedWorld
Решение выглядит просто и так же просто реализуется. Однако, в скором будущем нам понадобится еще ряд каких-нибудь функций, в которых будет сопоставление с образцом типа Item. И мы неизбежно попадем в проблемную ситуацию: при каждом обновлении типа Item нам придется править все эти функции:
apply w (Karyon ...) ow = ...
apply w (Plasma ...) ow = ...
apply w (Mitochondrion ...) ow = ...
...
getEnergy :: Item -> Int
getEnergy (Karyon e b c) = e
getEnergy (Mitochondrion e f g) = e
getEnergy _ = error "getEnergy unsupported."
Мы слишком многое завязали на тип данных Item. Если мы пишем простую игру вроде «Крестиков-ноликов» или «Game of Life» — можно здесь и остановиться. Но в случае игры «The Amoeba World» этот подход кажется неуместным. Нужно придумать что-то еще, чтобы убрать pattern matching, добавить генеричности и избавиться от наивного кода.
Расширим наши требования:
- Модульность: объекты находятся в разных модулях.
- Локальность: добавление объекта не требует обновления других модулей.
- Полиморфизм: объекты, обладающие одним интерфейсом, взаимодействуют с миром по-разному.
Что ж, теперь требования выглядят уже серьезнее. Попробуем разобраться.
Антипаттерн «Экзистенциальный класс типов»
Чтобы выполнить одновременно требование модульности и локальности, нам придется сделать так, чтобы модуль конкретного объекта содержал все необходимое для взаимодействия с другими объектами на игровой карте. При этом вышеозначенные модули GameLogic.Logic и GameLogic.Types не должны меняться при добавлении нового модуля. Чтобы это стало возможным, рассмотрим следующую идею дизайна: каждый объект на карте — отдельный тип (Plasma, Karyon), для которого существует функция activate — наследница функции apply.
-- module GameLogic.Karyon:
activate :: Karyon -> Point -> World -> OperatedWorld -> OperatedWorld
-- module GameLogic.Plasma:
activate :: Plasma -> Point -> World -> OperatedWorld -> OperatedWorld
Генерализуются эти функции классом типов. Назовем его Active:
class Active i where
activate :: i -> Point -> World -> OperatedWorld -> OperatedWorld
Имея данный интерфейс, мы будем проходить по всем объектам карты и активировать их, — какого бы типа они ни были. В ООП-языках это обычное решение, и оно работает благодаря динамической диспетчеризации, но попробовав адаптировать тип World в Haskell, мы столкнемся трудностями компиляции.
type World = M.Map Point ????
Мы хотим держать в контейнере Map объекты разных типов: Karyon, Plasma или что-то еще, и это проблема, потому что в Haskell коллекции не гетерогенные. Проблема известная, у нее есть несколько решений; нас интересует решение, основанное на экзистенциальном типе данных. В этой статье ограничимся только кратким изложением идеи и объяснением, почему это антипаттерн. За более подробными объяснениями можно обратиться к статье Романа Душкина «Мономорфизм, полиморфизм и экзистенциальные типы».
Для начала, сделаем следующие заготовки интересующих нас типов и реализуем для них функцию activate.
module World.Plasma where
data Plasma = Plasma { plasmaPlayer :: Player }
instance Active Karyon where
activate (Plasma pl) = undefined
module World.Karyon where
data Karyon = Karyon { karyonPlayer :: Player
, karyonEnergy :: Energy }
instance Active Plasma where
activate (Karyon pl e) = undefined
Теперь необходимо создать гетерогенную карту мира. Для этого определим экзистенциальный тип-обертку ActiveItem, который-то уже и можно будет хранить в карте мира:
-- module GameLogic.Types:
{-# LANGUAGE ExistentialQuantification #-}
data ActiveItem = forall i. Active i => MkActiveItem i
type World = M.Map Point ActiveItem
packItem :: Active i => i -> ActiveItem
packItem = MkActiveItem
Типу ActiveItem ничего не известно о типе i кроме того, что для последнего существует экземпляр класса типов Active. Упаковывая любой объект типа Karyon или Plasma, мы теряем информацию о типе, но зато можем поместить это в любую коллекцию:
packedKaryon = packItem (Karyon 1 100)
packedPlasma = packItem (Plasma 1)
world = M.fromList [ (Point 1 1 1, packedKaryon)
, (Point 1 1 2, packedPlasma) ]
Теперь можно проходить по объектам карты и вызывать для каждого свою версию activate как-то так:
stepWorld world = M.foldrWithKey f M.empty world
where
f point (MkActiveItem i) operatedWorld = activate i point world operatedWorld
Можно чуть сократить запись, определив экземпляр класса типов Active для ActiveItem:
instance Active ActiveItem where
activate (MkActiveItem i) = activate i
…
stepWorld world = M.foldrWithKey activate M.empty world
where
f point i = activate i point world
Мы добились желаемого — разнесли функциональность изменения карты по разным типам и модулям. Что же не так с этим кодом? Паттерн «Экзистенциальный класс типов» полезен, если все, что нужно сделать с объектами в коллекции — это сериализовать их (класс типов Show), отрендерить на 3D-сцене (класс типов render — как в статье Р. Душкина), или еще как-то использовать в немутабельных целях. Антипаттерн «Экзистенциальный класс типов» показывает неприменимость ООП-мышления в функциональном мире. Понимая элементы карты как объекты, мы неизбежно приходим к тому, что нам нужны новые и новые функции по работе с ними, и класс типов становится неконсистентным, а также появляются другие классы типов. В случае игры «The Amoeba World», такая практика привела к тому, что очень хотелось как-нибудь вернуть из «интерфейса» Active другой «интерфейс» — класс типов Interactable, чтобы заставить разные объекты взаимодействовать генеричным образом. (См. коммит eafd64de12)
Возможно, решение этой ООП-задачи и существует в Haskell, но автору найти его не удалось, — что и к лучшему, так как это тупиковый путь.
Let-функции и антипаттерн «Объектный тип»
Let-функции представляют собой неподъемные функции с большим количеством аргументов, let- и where- выражений. Let-функции сами по себе не антипаттерн, но признак того, что текущий дизайн не дает нам других инструментов для выражения логики. Очень часто let-функции являются функциональной оберткой над императивным кодом, -, а это, как мы уже выяснили, не идиоматичный подход.
Пример из «The Amoeba World» (коммит 3a0500a217):
activatePieceGrowing :: Player -> Bounds -> Point -> Direction -> (World, Annotations, Energy) -> (World, Annotations, Energy)
activatePieceGrowing _ _ _ _ actRes@(w, anns, 0) = actRes
activatePieceGrowing pl bounds p dir (w, anns, e) = case growPlasmaFunc of
Left ann -> (w, anns ++ [ann], e)
Right (w', anns') -> (w', anns ++ anns', e-1)
where
growPlasmaFunc = growPlasma pl bounds p dir wactivatePiece :: Point -> Karyon -> Shift -> (World, Annotations, Energy) -> (World, Annotations, Energy)
activatePiece _ (KaryonFiller{}) _ r = r
activatePiece p k@(Karyon _ pl _ _ bound) sh activationData | isCornerShift sh = let
actFunc = activatePieceGrowing pl [bound p] p (subDirection2 sh)
. activatePieceGrowing pl [bound p] p (subDirection1 sh)
in actFunc activationData
activatePiece p k@(Karyon _ pl _ _ bound) sh activationData = let
actFunc = activatePieceGrowing pl [bound p] p (direction sh)
in actFunc activationData
Частично код можно улучшить, вынеся неизменяемые данные в контекст при помощи монады Reader, но от императивности это не избавит (коммит 8df9b7a224):
activatePiece :: ActivationData -> R.Reader ActivationContext ActivationData
activatePiece actData = do
isCornerPiece <- askIsCornerPiece
if isCornerPiece then activateCornerPiece actData
else activateOrdinaryPiece actDataactivatePieceGrowing :: Direction -> ActivationData -> R.Reader ActivationContext ActivationData
activatePieceGrowing _ actData@(w, anns, 0) = return actData
activatePieceGrowing dir actData@(w, anns, e) = do
(pl, bounds, p) <- askLocals
case growPlasma pl bounds p dir w of
Left ann -> return (w, anns ++ [ann], e)
Right (w', anns') -> return (w', anns ++ anns', e-1)
where
askLocals = do
(ActivationContext k p _) <- R.ask
return (karyonPlayer k, [karyonBound k p], p)
Приведенный выше код возник по причине того, что весь дизайн строится на объектных АТД Karyon, Border и других. «Объектный тип» — главный антипаттерн раннего дизайна игры «The Amoeba World». Из-за него родился громоздкий неподдерживаемый код с плохой структурой. Идея объектных типов, очевидно, проистекает из ООП-мышления, что приводит к печальным последствиям, когда мы пробуем реализовать ее в функциональном программировании. Поэтому требования модульности, локальности и полиморфизма не следует понимать прямолинейно. Они не накладывают явного ограничения, что объекты должны быть типами, а интерфейсы — классами типов.
В следующих частях мы рассмотрим более «функциональные» идеи дизайна, когда предметная область описывается набором свойств и законов, но не объектов и взаимодействий.
Автор: graninas