mtl-стиль и ReaderT-паттерн
В прошлом уроке мы собрали стек трансформеров и упёрлись в его цену: башня
lift’ов, жёсткая привязка к конкретному порядку слоёв, перепиши всё при вставке слоя посередине. Сегодня лечим это дважды. Сначалаmtl-стиль: вместо конкретного стека код требует от монады лишь способности через классы, иliftисчезает из глаз. Потом честно посмотрим, во что это обходится (проблема n квадрат инстансов), как её закрываютDefaultSignaturesиderiving. А в финале развернём ReaderT-паттерн Сноймана и Three Layer Cake Парсонса: ту архитектуру, на которой стоит большинство прикладного Haskell и на которой мы построим бэкенд проекта 3. Это урок не про новую теорию, а про то, как из неё собирают реальное приложение.
Идея: требуй способность, а не стек
Вернёмся к боли. Функция в явном стеке привязана к нему намертво:
guardLimit :: StateT Int (ExceptT String IO) () -- прибита к ровно этому стеку
guardLimit = do
n <- get
when (n > 3) (lift (throwE "перебор")) -- lift руками
mtl-стиль переворачивает это. Вместо конкретного типа требуем от монады m лишь то, что нам нужно, через классы способностей:
guardLimit :: (MonadState Int m, MonadError String m) => m ()
guardLimit = do
n <- get
when (n > 3) (throwError "перебор") -- никакого lift
Прочитай сигнатуру как контракт: “дай мне любую монаду, которая умеет состояние Int и ошибку String”. Эта функция запустится в StateT Int (ExceptT String IO), в стеке поглубже, в тестовом моке, в чём угодно с нужными способностями. Код отвязался от стека: место вызова выбирает конкретную монаду, а lift’ы попрятались в инстансы классов (именно туда, где мы видели рекурсивный liftIO = lift . liftIO в прошлом уроке).
Сами классы устроены просто:
class Monad m => MonadReader r m | m -> r where
ask :: m r
local :: (r -> r) -> m a -> m a
class Monad m => MonadState s m | m -> s where
get :: m s
put :: s -> m ()
class Monad m => MonadError e m where
throwError :: e -> m a
catchError :: m a -> (e -> m a) -> m a
Есть важное ограничение, спрятанное в | m -> s. Это функциональная зависимость: монада однозначно определяет тип состояния, поэтому в одном стеке может быть ровно один MonadState (и один Reader, один Writer). Два MonadState Int и MonadState String не уживутся, компилятор скажет Couldn't match type. У MonadError такой зависимости нет, поэтому несколько ExceptT ведут себя иначе. Обходят это ограничение tagged-способностями из библиотеки capability, но в типичном приложении одного State на стек хватает.
Проблема n квадрат инстансов
За удобство mtl-стиля платят, и стоит увидеть, чем. Чтобы способность проходила сквозь промежуточные слои (MonadState работал, даже когда StateT спрятан под ReaderT и ExceptT), каждый трансформер обязан иметь инстанс каждого класса. С N классами и M трансформерами это порядка N на M инстансов, и почти все они это одинаковое тело из одного lift. Покрути виджет: матрица и счётчик показывают масштаб лучше слов.
По диагонали стоят содержательные инстансы (StateT сам несёт состояние, ExceptT сам несёт ошибки), всё остальное это пробросы. Алексис Кинг в разборе Lifts for free называет это “небольшим зоопарком предельно шаблонных инстансов, большинство которых просто реализуют каждый метод через lift”. mtl всего лишь переносит lift с места вызова в инстансы.
И это не разовая плата, а открытая проблема. Нажми в виджете “добавить эффект”: один новый класс MonadFoo дорисовывает целый столбец, инстанс на каждый существующий трансформер, и все пробросные. Зеркально: новый трансформер обязан реализовать все существующие классы. Множество не замкнуто, библиотека не может предугадать ни твой класс, ни твой трансформер. Вот структурная причина, по которой mtl плохо масштабируется на много эффектов, и почему дальше выросли системы эффектов (о них в уроке про карту эффектов).
DefaultSignatures: схлопнуть бойлерплейт
Большую часть этого зоопарка убирает один приём из той же статьи Кинга. DefaultSignatures дают методу класса запасную, более узкую сигнатуру: она предполагает, что монада это t m1 с каким-то трансформером t, и тогда тело по умолчанию это просто lift.
{-# LANGUAGE DefaultSignatures #-}
class Monad m => MonadReader r m | m -> r where
ask :: m r
default ask :: (MonadTrans t, MonadReader r m1, m ~ t m1) => m r
ask = lift ask -- запасное тело: просто подъём
Теперь каждый пробросный инстанс становится пустым, тело берётся из default’а:
instance MonadReader r m => MonadReader r (ExceptT e m) -- тела нет
instance MonadReader r m => MonadReader r (StateT s m) -- тела нет
Это и есть переключатель DefaultSignatures в виджете: пробросные клетки гаснут до “пусто”, руками остаётся лишь содержательная диагональ. Принципиальный ответ на n квадрат: тело lift написано один раз как default, инстансы схлопываются до пустых.
Заворачиваем стек в newtype
В реальном приложении ты не таскаешь голый ReaderT Env IO по сигнатурам, а заворачиваешь его в свой newtype и наследуешь нужные инстансы:
{-# LANGUAGE GeneralizedNewtypeDeriving, DerivingStrategies #-}
newtype App a = App { unApp :: ReaderT Env IO a }
deriving newtype
( Functor, Applicative, Monad
, MonadIO, MonadReader Env, MonadUnliftIO )
GeneralizedNewtypeDeriving переиспользует инстансы внутреннего ReaderT Env IO, поэтому App получает все способности даром. Стратегий deriving четыре, и на одном типе их мешают: stock (встроенные Eq/Show/Functor), newtype (переиспользовать внутренний), anyclass (пустое тело через default-методы, ровно те DefaultSignatures выше), via (вывести через тип-представитель).
Зачем вообще обёртка, если можно жить прямо в ReaderT Env IO? Три причины. Можно дать кастомные инстансы отдельным классам, а остальные вывести. Можно спрятать стек: снаружи виден только App, а ReaderT Env IO это деталь реализации. И главное: инстансы теперь живут на твоём типе, а не на чужом ReaderT, поэтому нет сиротских инстансов.
премиум-урок
Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.
Оплата пока не подключена: премиум и курсы открываются промокодом.