Раздел 21 · Haskell

Монадные трансформеры

senior~45 мин

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

Монадные трансформеры

В прошлом уроке каждая монада давала ровно один эффект: Reader читает окружение, State протаскивает состояние, IO общается с миром. Но реальная задача редко обходится одним. Веб-обработчик хочет читать конфиг (Reader), копить или менять состояние (State), падать с доменной ошибкой (Except) и при этом ходить в базу (IO). По отдельности эти монады не складываются: нельзя быть State и IO одновременно. Трансформеры решают это, складывая эффекты в стек. Сегодня разберём, как трансформер устроен, что такое lift, как распаковывать стек слой за слоем, и почему один и тот же набор слоёв, переставленный местами, даёт две разные программы. Эта перестановка и есть главный сюжет урока.

Идея: трансформер добавляет один слой

Монадный трансформер это конструктор t, который берёт монаду m и возвращает новую монаду t m, добавляя один эффект и сохраняя всё, что умела m. Вот канонические трансформеры, и заметь: каждый несёт m внутри себя.

newtype ReaderT r m a = ReaderT { runReaderT :: r -> m a }
newtype StateT  s m a = StateT  { runStateT  :: s -> m (a, s) }
newtype WriterT w m a = WriterT { runWriterT :: m (a, w) }
newtype ExceptT e m a = ExceptT { runExceptT :: m (Either e a) }
newtype MaybeT    m a = MaybeT  { runMaybeT  :: m (Maybe a) }

Сравни с чистыми монадами из прошлого урока: State s a это s -> (a, s), а StateT s m a это s -> m (a, s). Разница в одном m: результат теперь завёрнут в нижнюю монаду. Поэтому State s = StateT s Identity (под Identity нет никакого эффекта), а StateT s IO это уже состояние плюс возможность ходить в IO. Та незаконченная буква T из прошлого урока раскрылась: вместо Identity снизу подставляем настоящую монаду.

Стек собирается вложением. Хотим состояние, ошибки и IO разом, пишем:

type App a = StateT Int (ExceptT String IO) a
--           ^^^^^^      ^^^^^^^      ^^
--           состояние   ошибки       база (общается с миром)

Снаружи StateT, под ним ExceptT, в самом низу IO. Каждый слой добавил свою способность, не отняв чужих.

lift: подъём на слой выше

Внутри стека возникает практический вопрос: ты в StateT Int (ExceptT String IO), а throwError определён для ExceptT, который слоем ниже. Как до него дотянуться? Подъёмом. lift берёт действие нижней монады и поднимает его на слой выше:

class MonadTrans t where
  lift :: Monad m => m a -> t m a

lift это не магия, а монадный морфизм, и обязан подчиняться двум законам:

lift . return    == return                      -- подъём чистого значения это чистое значение
lift (m >>= f)   == lift m >>= (lift . f)        -- подъём связывания это связывание подъёмов

Эти законы говорят одно: lift сохраняет и return, и >>=, то есть не искажает вычисление при подъёме. Ровно поэтому стек вообще корректен. Сами инстансы тривиальны:

instance MonadTrans (ReaderT r) where lift m = ReaderT (\_ -> m)   -- проигнорировать окружение
instance MonadTrans MaybeT      where lift m = MaybeT (fmap Just m) -- завернуть в Just

Зачем стек на практике

Соберём настоящий маленький стек, чтобы боль и польза были видны на одном экране.

import Control.Monad.Trans.State
import Control.Monad.Trans.Except
import Control.Monad.Trans.Class (lift)

type App a = StateT Int (ExceptT String IO) a

tick :: App ()
tick = modify (+ 1)                       -- StateT, свой слой, lift не нужен

guardLimit :: App ()
guardLimit = do
  n <- get                                -- StateT, свой слой
  when (n > 3) (lift (throwE "перебор"))   -- ExceptT слоем ниже: нужен lift
  lift (lift (putStrLn ("счётчик: " <> show n)))  -- IO двумя слоями ниже: lift . lift

Видно сразу две вещи. Польза: один do-блок жонглирует тремя эффектами, состоянием, ошибкой и печатью, и всё это обычный последовательный код. Боль: чтобы дотянуться до IO, пришлось писать lift (lift ...). Это башня lift’ов: чем глубже стек, тем выше башня, и тем больнее вставить новый слой посередине (придётся пересчитать все подъёмы).

Один lift встречается так часто, что для него завели короткий путь. liftIO поднимает действие IO с любой глубины стека напрямую:

class Monad m => MonadIO m where
  liftIO :: IO a -> m a
-- liftIO (putStrLn "...") вместо lift (lift (putStrLn "..."))

Работает это потому, что каждый трансформер даёт инстанс MonadIO (t m) с определением liftIO = lift . liftIO, который рекурсивно спускается до самого дна. Запомни этот рекурсивный приём: в следующем уроке mtl обобщит ровно его со IO на все остальные эффекты, и башня lift’ов исчезнет насовсем.

Распаковка: снять слои снаружи внутрь

Ты собрал App и написал в нём вычисление. Но у этого вычисления тип App a, а main умеет выполнять только IO: между ними лестница из newtype-обёрток, и её надо снять. Каждый трансформер несёт свою функцию runX, которая снимает ровно один слой. Запускают снаружи внутрь: внешний трансформер распаковывается первым.

runStateT  :: StateT s m a  -> s -> m (a, s)        -- отдаёт пару (результат, финальное состояние)
runExceptT :: ExceptT e m a -> m (Either e a)       -- отдаёт Either
runReaderT :: ReaderT r m a -> r -> m a             -- скормить окружение
evalStateT :: StateT s m a  -> s -> m a             -- только результат
execStateT :: StateT s m a  -> s -> m s             -- только финальное состояние

Мысленная модель это луковица: runX снимает текущую внешнюю кожуру и отдаёт m (то, что добавил этот слой). Для нашего App = StateT Int (ExceptT String IO) распаковка идёт так:

runApp :: App a -> Int -> IO (Either String (a, Int))
runApp app s0 = runExceptT (runStateT app s0)
--                          \_______________/   снимаем StateT: ExceptT String IO (a, Int)
--              \_____________________________/ снимаем ExceptT: IO (Either String (a, Int))

Сначала runStateT снимает внешний StateT и отдаёт ExceptT String IO (a, Int), потом runExceptT снимает ExceptT и отдаёт IO (Either String (a, Int)). Тип результата буквально читается как история стека наизнанку. И вот тут-то всплывает главный сюжет урока: где именно в этом типе оказалась пара (a, Int) и где Either, зависит от того, какой слой был снаружи. Присмотримся.

премиум-урок

Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.

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