Can I haz? Рассматриваем ФП-паттерн Has

    Привет, Хабр.


    Сегодня мы рассмотрим такой ФП-паттерн, как Has-класс. Это довольно любопытная штука по нескольким причинам: во-первых, мы лишний раз убедимся, что паттерны в ФП таки есть. Во-вторых, оказывается, что реализацию этого паттерна можно поручить машине, что вылилось в довольно любопытный трюк с тайпклассами (и библиотеку на Hackage), который лишний раз демонстрирует практическую полезность расширений системы типов вне Haskell 2010 и ИМХО куда интереснее самого этого паттерна. В-третьих, повод для котиков.


    image


    Однако начать, пожалуй, стоит всё же с описания того, что же такое 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 и, возможно, что-то ещё, но это не столь важно для нашей дискуссии.


    На самом деле мы сейчас сделали ужасную вещь. Почему? Ну, навскидку:


    1. Мы добавили ненужную связь между совершенно различными компонентами. В идеале БД-слой вообще ничего не должен знать про какой-то там веб-сервер. И, конечно, мы не должны перекомпилировать модуль для работы с БД при изменениях списка конфигурационных опций веб-сервера.
    2. Так вообще не получится сделать, если мы не можем редактировать исходный код части модулей. Например, что делать, если cron-модуль реализован в какой-то сторонней библиотеке, которая ничего не знает о нашем конкретном юзкейсе?
    3. Мы добавили возможностей ошибиться. Например, что такое serverAddress? Это тот адрес, который должен слушать веб-сервер, или адрес сервера БД? Использование одного большого типа для всех опций увеличивает шанс подобных коллизий.
    4. Мы больше не можем по одному взгляду на сигнатуры функций сделать вывод о том, какие модули пользуются какой частью конфигурации. Всё имеет доступ ко всему!

    Так какое же решение для этого всего? Как можно догадаться по названию статьи, это


    Паттерн 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

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

    • +28
    • 3,3k
    • 3
    Поделиться публикацией
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 3

      +5

      … на самом интересном месте!

        +3

        Я уже почти дописал следующую часть, так что на днях опубликую.

        +3
        Ещё вариант — на верхнем уровне вместо MonadReader использовать голый ReaderT. Сам решил эту задачу так:
        -- Пока всё так же.
        data Config = Config { serverConfig :: ServerConfig, rabbitConfig :: RabbitConfig }
        data RabbitConfig
        data ServerConfig
        
        -- Здесь MonadReader и всё красиво.
        runServer :: (MonadReader ServerConfig m, MonadIO m) => m ()
        runServer = ...
        
        runRabbit :: (MonadReader RabbitConfig m, MonadIO m) => m ()
        runRabbit = ...
        
        -- Здесь ReaderT, делаем потоньше.
        -- withReaderT :: (r' -> r) -> ReaderT r m a -> ReaderT r' m a
        runApp :: (MonadIO m) => ReaderT Config m ()
        runApp = do
          withReaderT serverConfig runServer
          withReaderT rabbitConfig runRabbit
        


        Минус — не можем использовать MonadReader наверху, плюс — меньше boilerplate. У меня подмодули стартуют в одном месте у самого main, так что минус несущественный.

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое