Can I haz? Рассматриваем ФП-паттерн Has
Привет, Хабр.
Сегодня мы рассмотрим такой ФП-паттерн, как Has
-класс. Это довольно любопытная штука по нескольким причинам: во-первых, мы лишний раз убедимся, что паттерны в ФП таки есть. Во-вторых, оказывается, что реализацию этого паттерна можно поручить машине, что вылилось в довольно любопытный трюк с тайпклассами (и библиотеку на Hackage), который лишний раз демонстрирует практическую полезность расширений системы типов вне Haskell 2010 и ИМХО куда интереснее самого этого паттерна. В-третьих, повод для котиков.
Однако начать, пожалуй, стоит всё же с описания того, что же такое Has
-класс, тем более, что какого-то краткого (и, тем более, русскоязычного) описания сходу не нашлось.
Итак, как в хаскеле решается проблема управления некоторым глобальным окружением, доступным только для чтения, которое необходимо нескольким различным функциям? Как, например, выражается глобальная конфигурация приложения?
Самое очевидное и прямое решение — если функции нужно значение типа Env
, то можно просто передавать значение типа Env
в эту функцию!
iNeedEnv :: Env -> Foo
iNeedEnv env = -- опа, в env нужное нам окружение
Однако, к сожалению, такая функция не очень композабельна, особенно по сравнению с некоторыми другими объектами, к которым мы привыкли в хаскеле. Например, по сравнению с монадами.
Собственно, более обобщённое решение — обернуть функции, которым нужен доступ к окружению Env
, в монаду Reader Env
:
import Control.Monad.Reader
data Env = Env
{ someConfigVariable :: Int
, otherConfigVariable :: [String]
}
iNeedEnv :: Reader Env Foo
iNeedEnv = do
-- получаем всё окружение целиком:
env <- ask
-- или еcли нам нужен только кусочек:
theInt <- asks someConfigVariable
...
Это можно обобщить ещё сильнее, для чего достаточно воспользоваться тайпклассом MonadReader
и всего лишь поменять тип функции:
iNeedEnv :: MonadReader Env m => m Foo
iNeedEnv = -- тут всё точно так же, как и раньше
Теперь нам совершенно неважно, в каком именно монадическом стеке мы находимся, покуда мы из него можем достать значение типа Env
(и мы явно выражаем это в типе нашей функции). Нам неважно, обладает ли весь стек целиком какими-то другими возможностями вроде IO
или обработки ошибок через MonadError
:
someCaller :: (MonadIO m, MonadReader Env m, MonadError Err m) => m Bar
someCaller = do
theFoo <- iNeedEnv
...
И, к слову, чуть выше я на самом деле соврал, когда говорил, что подход с явной передачей аргумента в функцию не так композабелен, как монады: «частично применённый» функциональный тип r ->
является монадой, и, более того, является вполне законным экземпляром класса MonadReader r
. Развитие соответствующей интуиции предлагается читателю в качестве упражнения.
В любом случае, это хороший шаг к модульности. Давайте посмотрим, куда он нас заведёт.
Пусть мы работаем над каким-то веб-сервисом, у которого, среди прочего, могут быть следующие компоненты:
- слой доступа к БД,
- веб-сервер,
- активируемый по таймеру cron-подобный модуль.
Каждый из этих модулей может иметь свою собственную конфигурацию:
- реквизиты доступа к БД,
- хост и порт для веб-сервера,
- интервал работы таймера.
Можно сказать, что общая конфигурация всего приложения является объединением всех этих настроек (и, вероятно, чего-то ещё).
Предположим для простоты, что API каждого модуля состоит всего из одной функции:
setupDatabase
startServer
runCronJobs
Каждая из этих функций требует соответствующей конфигурации. Мы уже узнали, что MonadReader
— хорошая практика, но каким будет тип окружения?
Самым очевидным решением будет что-то вроде
data AppConfig = AppConfig
{ dbCredentials :: DbCredentials
, serverAddress :: (Host, Port)
, cronPeriodicity :: Ratio Int
}
setupDatabase :: MonadReader AppConfig m => m Db
startServer :: MonadReader AppConfig m => m Server
runCronJobs :: MonadReader AppConfig m => m ()
Скорее всего, эти функции будут требовать MonadIO
и, возможно, что-то ещё, но это не столь важно для нашей дискуссии.
На самом деле мы сейчас сделали ужасную вещь. Почему? Ну, навскидку:
- Мы добавили ненужную связь между совершенно различными компонентами. В идеале БД-слой вообще ничего не должен знать про какой-то там веб-сервер. И, конечно, мы не должны перекомпилировать модуль для работы с БД при изменениях списка конфигурационных опций веб-сервера.
- Так вообще не получится сделать, если мы не можем редактировать исходный код части модулей. Например, что делать, если cron-модуль реализован в какой-то сторонней библиотеке, которая ничего не знает о нашем конкретном юзкейсе?
- Мы добавили возможностей ошибиться. Например, что такое
serverAddress
? Это тот адрес, который должен слушать веб-сервер, или адрес сервера БД? Использование одного большого типа для всех опций увеличивает шанс подобных коллизий. - Мы больше не можем по одному взгляду на сигнатуры функций сделать вывод о том, какие модули пользуются какой частью конфигурации. Всё имеет доступ ко всему!
Так какое же решение для этого всего? Как можно догадаться по названию статьи, это
На самом деле каждому модулю неважен тип всего окружения, покуда в этом типе есть нужные для модуля данные. Проще всего это показать на примере.
Рассмотрим модуль для работы с БД и предположим, что он определяет тип, содержащий всю нужную модулю конфигурацию:
data DbConfig = DbConfig
{ dbCredentials :: DbCredentials
, ...
}
Has
-паттерн представляется в виде следующего тайпкласса:
class HasDbConfig rec where
getDbConfig :: rec -> DbConfig
Тогда тип setupDatabase
будет выглядеть как
setupDatabase :: (MonadReader r m, HasDbConfig r) => m Db
и в теле функции мы лишь должны использовать asks $ foo . getDbConfig
там, где мы раньше использовали asks foo
, из-за дополнительного слоя абстракции, который мы только что добавили.
Аналогично у нас будут тайпклассы HasWebServerConfig
и HasCronConfig
.
Что, если какая-то функция использует два различных модуля? Просто совместим констрейнты!
doSmthWithDbAndCron :: (MonadReader r m, HasDbConfig r, HasCronConfig r) => ...
Что насчёт реализаций этих тайпклассов?
У нас всё ещё есть AppConfig
на самом верхнем уровне нашего приложения (просто теперь модули о нём не знают), и для него мы можем написать:
data AppConfig = AppConfig
{ dbConfig :: DbConfig
, webServerConfig :: WebServerConfig
, cronConfig :: CronConfig
}
instance HasDbConfig AppConfig where
getDbConfig = dbConfig
instance HasWebServerConfig AppConfig where
getWebServerConfig = webServerCOnfig
instance HasCronConfig AppConfig where
getCronConfig = cronConfig
Пока что выглядит неплохо. Однако, у этого подхода есть одна проблема — слишком много писанины, и её мы подробнее рассмотрим в следующем посте.