Reader, Writer, State
В уроке про монаду мы научились складывать эффект из пустоты, ошибки и списка в один интерфейс
>>=. Сегодня три монады, которые описывают три самых частых паттерна повседневного кода: читать общий контекст (конфиг, окружение, зависимости), накапливать что-то по дороге (лог, дельты), протаскивать изменяемое состояние без единой мутации. Reader, Writer, State. У каждой простое устройство, у каждой своя ниша, и у двух из них есть громкие ловушки, на которые наступали целые продакшены. Разберём механику, нишу и ловушку каждой, а в конце честно скажем, какую из них в реальном приложении лучше не брать.
Идея: один тип, три обёртки
Начнём с разоблачающего факта, который держит весь урок. Все три монады это одна и та же конструкция трансформера поверх Identity:
type Reader r = ReaderT r Identity -- внутри r -> a
type Writer w = WriterT w Identity -- внутри (a, w)
type State s = StateT s Identity -- внутри s -> (a, s)
Identity это монада, которая не делает ничего: её >>= просто применяет функцию. Поэтому ReaderT r Identity это чистый Reader, без всякого эффекта под ним. Трансформеры мы разберём в следующем уроке, а пока важна мысль: выучишь одну форму, получишь три монады даром.
И ещё резче. Newtype-обёртки существуют только чтобы навесить разные инстансы Monad на одну и ту же форму данных. Голая функция e -> a это просто функция, заверни её в newtype и дай инстанс, который протаскивает e, получится Reader. Голая пара (a, w) это просто пара, дай ей инстанс, склеивающий w через Monoid, получится Writer. Дальше увидим самое наглядное доказательство этого тезиса: чтобы починить Writer от утечки, его newtype переписывают так, что он буквально становится State.
Зачем вообще эти три, если можно протаскивать аргументы руками? Затем же, зачем любая монада: чтобы >>= делал рутину за тебя. Reader избавляет от ручного протаскивания конфига через каждую сигнатуру, Writer от ручного склеивания лога, State от ручной передачи состояния из вызова в вызов. Эффект не исчезает, он прячется в >>=.
Reader: читать общий контекст
Reader r a это вычисление, которое умеет читать значение типа r (окружение) и вернуть a. Внутри это просто функция r -> a.
ask :: MonadReader r m => m r -- получить всё окружение
asks :: MonadReader r m => (r -> a) -> m a -- получить одно поле
local :: MonadReader r m => (r -> r) -> m a -> m a -- запустить под изменённым окружением
runReader :: Reader r a -> r -> a -- скормить окружение, получить ответ
Вот разоблачение номер два, и оно стоит того, чтобы его прочувствовать. Reader это функция, ничего больше. Тип (->) r (функция от r) сам по себе уже монада: ask это id, а >>= раздаёт общий аргумент r обоим подвычислениям. Документация mtl так и говорит: частично применённая функция это и есть простейший reader. Поэтому Reader не добавляет никаких вычислений, он лишь даёт имя тому, что и так делает функция: берёт окружение и возвращает результат.
Главное применение это внедрение зависимостей: собираешь запись окружения и читаешь из неё поля там, где они нужны, не таская их через каждый аргумент.
data Env = Env
{ envBaseUrl :: String
, envTimeout :: Int
}
greetUrl :: Reader Env String
greetUrl = do
base <- asks envBaseUrl -- читаем поле, не зная, откуда оно
pure (base <> "/greet")
withRetry :: Reader Env a -> Reader Env a
withRetry = local (\e -> e { envTimeout = envTimeout e * 2 }) -- удвоить таймаут локально
local это самая интересная часть. Он запускает под-вычисление под временно изменённым окружением, а снаружи всё остаётся как было. Это в точности безопасная динамическая область видимости: в уроке про окружения Lisp мы разбирали, как динамический скоупинг резолвит переменную по вызывающему, а не по месту определения, и почему лексический победил. local даёт ровно эту динамическую силу, но под надзором типов: изменение видно всем вызовам внутри блока и аккуратно исчезает на выходе. Лексический, привязанный к месту определения двойник local это неявные параметры (ImplicitParams), но они куда нишевее, и в проде почти всегда хватает Reader.
Writer: накапливать по дороге
Writer w a возвращает результат a и попутно копит выход w, который складывается через Monoid. Внутри это просто пара (a, w).
tell :: MonadWriter w m => w -> m () -- добавить кусок к выходу
listen :: MonadWriter w m => m a -> m (a, w) -- подсмотреть, что накопил блок
censor :: MonadWriter w m => (w -> w) -> m a -> m a -- преобразовать накопленное
runWriter :: Writer w a -> (a, w) -- результат и весь накопленный выход
execWriter :: Writer w a -> w -- только выход
buildReport :: Writer [String] Int
buildReport = do
tell ["начали"]
tell ["посчитали 40"]
tell ["добавили 2"]
pure 42
-- runWriter buildReport == (42, ["начали","посчитали 40","добавили 2"])
Выглядит идеально под логирование, и именно так его и хочется применить в первый раз. Не делай этого. Writer для логов это классическая ловушка, и она того стоит, чтобы понять её до конца.
Ловушка Writer: утечка по построению
Утечка первая: mappend ждёт весь хвост
Посмотрим на >>= ленивого Writer (источник это разбор Карла Хауэллса Writer Monads and Space Leaks):
m >>= f = WriterT $ do
(x, w) <- runWriterT m
(y, w') <- runWriterT (f x)
pure (y, w `mappend` w') -- вот здесь беда
Чтобы вычислить w mappend w', нужны оба куска: и w от текущего шага, и w' от всего остатка вычисления. Значит mappend не может сработать, пока не отработает весь хвост программы. В результате в куче копится длинная цепочка отложенных mappend-thunk’ов, ровно как накапливался бы ленивый foldl из урока про ленивость в проде. На большом или потоковом логе это съедает память.
И вот ключевое: строгость это не лекарство. Строгая версия Writer форсирует пару (a, w), но не саму структуру зависимостей: mappend по-прежнему ждёт выхода всего хвоста. Хауэллс показывает, что починить можно, только изменив, как данные зависят друг от друга, то есть протаскивая накопитель как состояние. А накопитель, который протаскивают вперёд через вычисление, это s -> (a, s), то есть буквально State. Лучшего доказательства тезиса из вступления (newtype’ы это лишь разные инстансы на одной форме) не придумать: чинишь Writer и получаешь State.
Утечка вторая: квадратичный список
Writer [a] с tell [x] на каждом шаге строит левовложенный (++): ((([1] ++ [2]) ++ [3]) ++ [4]). Каждый ++ копирует левый аргумент целиком, итог квадратичный. Лечится сменой моноида на DList или Data.Sequence с дешёвым добавлением в конец, но это лечит только квадратичность, а не структурную утечку выше.
Если интерфейс Writer всё-таки нужен, единственная версия с постоянной памятью это Control.Monad.Writer.CPS: она копит выход через continuation-passing-style и намеренно прячет конструктор WriterT, чтобы ты не собрал ленивую версию руками. Но честный вывод индустрии короче: для логов не бери Writer. Бери StateT, или IORef в окружении, или просто пиши в IO. У Writer остаётся узкая честная ниша, где выход это небольшой моноид, считается до конца и не растёт потоково: накопить дельты, собрать набор предупреждений валидатора, сгенерировать описание. Логирование в неё не входит.
премиум-урок
Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.
Оплата пока не подключена: премиум и курсы открываются промокодом.