Раздел 21 · Haskell

Reader, Writer, State

middle-senior~45 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

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-коуча.

Оплата пока не подключена: премиум и курсы открываются промокодом.