Трансформеры монад

Реальному коду зачастую нужно несколько эффектов сразу: состояние + ошибки + IO. Но монады, в отличие от функторов, не композируются автоматически: m(na)m\,(n\,a) в общем случае не монада. Трансформер — версия монады со «слотом» для другой монады.

Определяются трансформеры как те же монады, но с параметром-монадой внутри:

newtype MaybeT  m a   = MaybeT  (m (Maybe a))
newtype ExceptT e m a = ExceptT (m (Either e a))
newtype StateT  s m a = StateT  (s -> m (a, s))
Вложенные контексты IO (Maybe Int) эквивалентны стеку трансформеров MaybeT IO Int
Рис. 2.7. Вложенные коробки — это стек трансформеров: IO  (Maybe  Int)\mathtt{IO\;(Maybe\;Int)} MaybeT  IO  Int\cong \mathtt{MaybeT\;IO\;Int}; lift\mathtt{lift} поднимает действие базовой монады вверх по стеку.

Существует такая монада Identity, определяется она так:

newtype Identity a = Identity { runIdentity :: a }

instance Monad Identity where
  return           = Identity     -- обернуть значение
  Identity x >>= f = f x          -- и сразу развернуть

Обычные монады — те же трансформеры над тождественной монадой:

type State s = StateT s Identity
MaybeT Identity aMaybe a       -- Maybe не type-синоним, но изоморфна

Через слои стека действия базовой монады поднимает lift\mathtt{lift}; его законы требуют согласованности с pure\mathtt{pure} и >>=\mathbin{\mathtt{>>=}}:

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

lift . pure == pure
lift (m >>= f) == lift m >>= (lift . f)

Порядок стека — это семантика: один и тот же набор эффектов, разный порядок — разное поведение при ошибке:

StateT s (Except e) as -> Either e (a, s)    -- ошибка съедает состояние
ExceptT e (State s) as -> (Either e a, s)     -- состояние переживает ошибку

Соберём сказанное на живом примере: цепочка IO-действий, каждое из которых может не дать результата (переменной окружения нет, файла нет). Без трансформера пришлось бы проверять Nothing\mathtt{Nothing} после каждого шага; MaybeT  IO\mathtt{MaybeT\;IO} прячет эти проверки в do-нотацию — первый же Nothing\mathtt{Nothing} обрывает всю цепочку.

import Control.Monad.Trans.Maybe (MaybeT(..), runMaybeT)
import Control.Monad.Trans.Class (lift)
import System.Environment        (lookupEnv)      -- :: String -> IO (Maybe String)
import System.Directory          (doesFileExist)

readConfig :: FilePath -> IO (Maybe String)       -- Nothing, если файла нет
readConfig path = do
  exists <- doesFileExist path
  if exists then Just <$> readFile path else pure Nothing

-- IO + возможный провал в одной do-нотации:
loadConfig :: MaybeT IO String
loadConfig = do
  lift (putStrLn "reading config...")   -- обычное IO поднимаем через lift
  path <- MaybeT (lookupEnv "CONFIG")   -- нет переменной -> вся цепочка Nothing
  MaybeT (readConfig path)              -- нет файла      -> вся цепочка Nothing

main :: IO ()
main = do
  result <- runMaybeT loadConfig        -- runMaybeT :: MaybeT IO a -> IO (Maybe a)
  case result of
    Just cfg -> putStrLn ("ok: " ++ cfg)
    Nothing  -> putStrLn "config not found"