Монадные трансформеры
В прошлом уроке каждая монада давала ровно один эффект: 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-коуча.
Оплата пока не подключена: премиум и курсы открываются промокодом.