Почему нет экземпляра MonadMask для ExceptT?

Библиотека исключений Edward Kmett не предоставляет MonadMask экземпляр для ExceptT.

Бен Гамари однажды спросил об этом, а затем пришел к выводу, что это объясняется документацией. Это самый близкий вид, который я могу найти:

Обратите внимание, что этот пакет предоставляет экземпляр MonadMask для CatchT. Этот экземпляр действителен только в том случае, если базовая монада не обеспечивает возможность множественного выхода. Например, IO или Either будут недопустимыми базовыми монадами, но допустимы Reader или State.

Но его значение для меня не самоочевидно. Что означает "множественный выход" и почему он запрещает экземпляр MonadMask?

Майкл Снойман также пишет:

[...] "MonadMask", который позволяет гарантировать выполнение определенных действий даже при наличии исключений (как синхронных, так и асинхронных). Чтобы обеспечить эту гарантию, стек монады должен иметь возможность контролировать ход выполнения. В частности, это исключает экземпляры для [...] Monads с несколькими точками выхода, например ErrorT над IO.

Возможно, было бы более понятно задать этот альтернативный вопрос: если мы отложим трансформаторы и рассмотрим несколько более простой тип:

data IOEither a = IOEither { unIOEither :: IO (Either String a) }
    deriving Functor

Кажется, что мы действительно можем написать экземпляр MonadMask:

instance Applicative IOEither where
    pure = IOEither . return . Right
    IOEither fIO <*> IOEither xIO = IOEither $
        fIO >>= either (return . Left) (\f -> (fmap . fmap) f xIO)

instance Monad IOEither where
    IOEither xIO >>= f = IOEither $
        xIO >>= either (return . Left) (\x -> unIOEither (f x))

instance MonadThrow IOEither where
    throwM e = IOEither (throwM @IO e)

instance MonadCatch IOEither where
    catch (IOEither aIO) f = IOEither $ catch @IO aIO (unIOEither . f)

instance MonadMask IOEither where
    mask f = IOEither $ mask @IO $ \restore ->
        unIOEither $ f (IOEither . restore . unIOEither)
    uninterruptibleMask f = IOEither $ uninterruptibleMask @IO $ \restore ->
        unIOEither $ f (IOEither . restore . unIOEither)

Является ли этот экземпляр, который я написал, не работает должным образом?

Ответ 1

Ниже приведена программа, демонстрирующая проблему с вашими экземплярами: вы можете выйти с раннего запуска с Left и тем самым никогда не запускать финализатор. Это противоречит закону, указанному в документах для MonadMask, которые требуют, чтобы для f `finally` g g выполнялось независимо от того, что происходит в f. Причина, по которой финализатор никогда не запускается, довольно проста: если исключение не выбрано finally (или bracket, которое реализовано finally), просто использует >>= для запуска финализатора, но >>= не выполняет выполните правый аргумент, если слева возвращает Left.

data IOEither a = IOEither { unIOEither :: IO (Either String a) }
    deriving Functor

instance Applicative IOEither where
    pure = IOEither . return . Right
    IOEither fIO <*> IOEither xIO = IOEither $
        fIO >>= either (return . Left) (\f -> (fmap . fmap) f xIO)

instance Monad IOEither where
    IOEither xIO >>= f = IOEither $
        xIO >>= either (return . Left) (\x -> unIOEither (f x))

instance MonadThrow IOEither where
    throwM e = IOEither (throwM @IO e)

instance MonadCatch IOEither where
    catch (IOEither aIO) f = IOEither $ catch @IO aIO (unIOEither . f)

instance MonadMask IOEither where
    mask f = IOEither $ mask @IO $ \restore ->
        unIOEither $ f (IOEither . restore . unIOEither)
    uninterruptibleMask f = IOEither $ uninterruptibleMask @IO $ \restore ->
        unIOEither $ f (IOEither . restore . unIOEither)

instance MonadIO IOEither where
  liftIO x = IOEither (Right <$> x)

main :: IO ()
main = void $ unIOEither $ finally (IOEither (return (Left "exit")))
                                   (liftIO (putStrLn "finalizer"))

Ответ 2

Класс для монад, которые обеспечивают возможность учета всех возможных точек выхода из расчета и маскировать асинхронные исключения. Моноды на основе продолжения и стеки, такие как ErrorT e IO, которые обеспечивают множественные режимы отказа, являются недопустимыми экземплярами этого класса.

Когда вы используете ErrorT/ExceptT с IO, наличие "нескольких точек выхода" относится к тому факту, что вы можете иметь либо исключение во время выполнения, либо исключение, созданное в Monad. Любой из них завершит вычисление.

runExceptT $ do
  error "This is an exit point."
  throwError "This is another exit point."
  return 23

Можно было бы написать MonadMask для ExceptT, который был бы действителен для всех ExceptT e m a с предварительным условием, что основная монада m не является IO. Отсюда огромное предупреждение об использовании CatchT с IO (это делает недействительным экземпляр MonadMask).