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. Развитие соответствующей интуиции предлагается читателю в качестве упражнения.

В любом случае, это хороший шаг к модульности. Давайте посмотрим, куда он нас заведёт.

Зачем Has

Пусть мы работаем над каким-то веб-сервисом, у которого, среди прочего, могут быть следующие компоненты:

  • слой доступа к БД,
  • веб-сервер,
  • активируемый по таймеру 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? Это тот адрес, который должен слушать веб-сервер, или адрес сервера БД? Использование одного большого типа для всех опций увеличивает шанс подобных коллизий.
  • Мы больше не можем по одному взгляду на сигнатуры функций сделать вывод о том, какие модули пользуются какой частью конфигурации. Всё имеет доступ ко всему!
  • Так какое же решение для этого всего? Как можно догадаться по названию статьи, это

    Паттерн Has

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

    Рассмотрим модуль для работы с БД и предположим, что он определяет тип, содержащий всю нужную модулю конфигурацию:

    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

    Пока что выглядит неплохо. Однако, у этого подхода есть одна проблема — слишком много писанины, и её мы подробнее рассмотрим в следующем посте.

    Оставить комментарий