例外和单子变换器

12

我正在使用EitherT单子变换器。将它与IO单子结合使用时,我担心会出现异常并且不会被捕获。

事实上,异常会直接穿过:

import Control.Monad.Trans
import Control.Error
import System.Directory

main = runEitherT testEx >>= print

testEx :: EitherT String IO ()
testEx = lift $ removeFile "non existing filename"

但是EitherT非常适合传达错误信息给调用者。因此,我希望使用它而不是抛出异常...

我研究了Control.Exception中的try函数:

try :: Exception e => IO a -> IO (Either e a) 

看起来正是我想要的东西,它可以适用于我的 EitherT IO 堆栈...(可能需要添加 hoistEitherfmapL ,虽然这样开始变得冗长)但是一个天真的 lift $ try 无法通过类型检查。

我相信这个问题已经被解决了成千上万次,但我找不到任何描述这个确切问题的好链接。这应该如何解决?

编辑 通过“这应该如何解决”,我对惯用解决方案感兴趣,什么是处理 Haskell 中这种情况的标准方式。从目前为止的答案来看,习惯的方式是让异常被抛出并在更高层面上处理它们。似乎有两个控制流和返回路径有点反直觉,但显然这是应该这样做的方式。


lifted-base 似乎是你正在寻找的,我是通过这篇文章发现的。 - bheklilr
谢谢,这非常有趣。可惜他没有提供在haskell-cafe上引发讨论的链接。我希望还有更好的解决方案,否则如果答案不是立即的并被几乎所有人使用,忽略这个问题似乎是惯用的。 - Emmanuel Touzery
3个回答

18

我实际认为EitherT在这里不是正确的选择。你想要表达的是"IO用于副作用,而EitherT用于异常"。但这并不是真的: IO始终存在导致异常的可能性,因此你所做的只是给你的API添加了一种虚假的安全感,并引入了两种抛出异常的方式而不是一种。此外,你还将使用IO喜欢的良好结构化的SomeException简化为String,这会丢失信息。

无论如何,如果你确信这是你想要做的事情,那么它并不太困难。它看起来像:

eres <- liftIO $ try x
case eres of
    Left e -> throwError $ show (e :: SomeException)
    Right x -> return x

注意,这样做也会吞噬异步异常,而这通常不是你想要的结果。我认为更好的方法是使用enclosed-exceptions


嗯,无论如何,我需要Either来处理函数的其他方面。如果我不需要IO代码,那将是一个Either函数。我开始使用纯粹的Either函数,然后添加了IO部分,现在我有了一个EitherT IO,并且我在思考,既然可以利用EitherT,那就没有必要再使用异常了。另一方面,确实如果我开始用try包装所有的IO函数,那么我就没有使用语言的本意了。所以看起来答案是,按照惯例的方式就是放手一搏,将try放在更高层次。尽管我还没有阅读你给出的链接,但我会尽快阅读的。 - Emmanuel Touzery
1
最终,我想我对Haskell中的双重消息异常和求和类型有点困惑。究竟该使用哪一个,为什么? - Emmanuel Touzery
2
我还建议您查看exceptions包。不要让您的纯代码直接存在于Either中,而是让它存在于任何MonadThrow实例中。 - Michael Snoyman
1
看起来我有很多研究要做。Either 中的纯代码有什么问题吗?但我想我可以通过谷歌搜索和阅读来回答自己的问题... - Emmanuel Touzery
2
唯一的问题是它无法与IO中的代码统一。通过针对类型类进行编程,您的代码将自动适用于Either和IO,无需任何转换。 - Michael Snoyman

5

如果你尝试计算并不成功,那么你会得到一个异常:Exception e => EitherT a IO (Either e ())。因此,你应该避免这样的情况发生。

testEx :: (Exception e, MonadTrans m) => m IO (Either e ())
testEx = lift . try $ fails

您希望将错误集成到EitherT中,而不是在结果中产生错误。您希望将tryEitherT结合使用。

testEx :: (Exception e) => EitherT e IO ()
testEx = EitherT . try $ fails

我们将首先进行总体概述,然后仅获取您想要的消息。

使用EitherT集成try

您可以提取将tryEitherT集成的思想。

tryIO :: (Exception e) => IO a -> EitherT e IO a
tryIO = EitherT . try

或者对于任何底层的MonadIO,您可以这样做
tryIO :: (Exception e, MonadIO m) => IO a -> EitherT e m a
tryIO = EitherT . liftIO . try

(tryIOControl.Error 中的一个名称冲突。我无法为此想出另一个名称。)

您可以表示愿意捕获任何异常。SomeException 将捕获所有异常。如果您只对特定的异常感兴趣,请使用不同的类型。有关详细信息,请参见Control.Exception。如果您不确定要捕获什么,您可能只想捕获 IOException;这就是来自 Control.ErrortryIO 所做的事情;请参见最后一节。

anyException :: EitherT SomeException m a -> EitherT SomeException m a
anyException = id

你只想从异常中保留错误信息。
message :: (Show e, Functor m) => EitherT e m a -> EitherT String m a
message = bimapEitherT show id

那么您可以编写代码:

然后您可以编写

testEx :: EitherT String IO ()
testEx = message . anyException . tryIO $ fails

将try与MonadError集成

您可以使用MonadErrorMonadIO渗透转换器堆栈,将任何MonadErrortry集成。

import Control.Monad.Except

tryIO :: (MonadError e m, MonadIO m, Exception e) => IO a -> m a
tryIO = (>>= either throwError return) . liftIO . try

您可以利用上一节中的 tryIO, anyException, 和 message,编写出 testEx 相关的代码。

testEx :: EitherT String IO ()
testEx = message . anyException . tryIO $ fails

来自Control.Error的tryIO函数

tryIO 函数来自于 Control.Error 库,与我们的第一个 tryIO 函数类似,只是它只捕获 IOException 异常。其实函数定义如下:

tryIO :: (MonadIO m) => IO a -> EitherT IOException m a
tryIO = EitherT . liftIO . try

我们可以使用message来编写testEx,并将其输出。
testEx :: EitherT String IO ()
testEx = message . tryIO $ fails

谢谢。我会尽快详细审查解释。我认为我没有很好地表达我的问题。我假设这是我应该做的事情,这是好的和惯用的。现在我开始认为惯用的方式是在更高层次上处理这些异常。但是你的答案仍然很有趣,谢谢! - Emmanuel Touzery
你可能会对读Gabriel Gonzalez的帖子感兴趣,他在那里宣布了errors包,这是你正在使用的Control.Error来自的地方。http://www.haskellforall.com/2012/07/errors-10-simplified-error-handling.html 评论中讨论了很多关于哪些异常可以捕获的问题,这影响了Control.ErrortryIO的定义。有趣的是,我们几乎用完全相同的名称达到了几乎相同的定义。 - Cirdec
使用类型类来处理变换器堆栈是惯用的,但不必要。transformers包有意省略了类型类,因为有多种不同的处理方式:mtlmonads-tfmonads-fd等,尽管我们似乎正在标准化mtl。对于像EitherT这样的东西,有多个类型类。mtl有两个:在Control.Monad.Except中有MonadError,在Control.Monad.Error.Class中还有另一个MonadError。我会使用Control.Monad.Except中的那个。我不知道Michael建议的MonadThrow类型类来自哪里。 - Cirdec
正如我的评论所提到的,MonadThrow 来自于 exceptions 包。 - Michael Snoyman

1
这是另一种简单的方法:让我们定义一个自定义的Monad变换器,就像EitherT一样被定义的那样:
{-# LANGUAGE FlexibleInstances, FunctionalDependencies #-}
import Control.Arrow (left)
import Control.Exception
import Control.Monad
import Control.Monad.Trans
import Control.Monad.Error
import Control.Monad.IO.Class

newtype ErrT a m b = ErrT { runErrT :: m (Either a b) }

instance (Monad m) => Monad (ErrT a m) where
    -- ...

instance (Monad m) => MonadError a (ErrT a m) where
    -- ...

instance MonadTrans (ErrT a) where
    lift = ErrT . liftM Right

与适当的ApplicativeMonadMonadError实例一起。

现在让我们添加一种方式,使IOError可以转换为我们的错误类型。我们可以有一个类型类来实现这个功能,这样我们就可以自由地使用变换器。

class FromIOError e where
    fromIOException :: IOError -> e

最后,我们将实现MonadIO,使得liftIO始终捕获IOError并将其转换为左部的纯数据类型:

instance (MonadIO m, FromIOError a) => MonadIO (ErrT a m) where
    liftIO = ErrT . liftIO . liftM (left fromIOException)
                  . (try :: IO a -> IO (Either IOError a))

现在,如果我们将所有这些内容放入一个模块中,并仅导出数据类型runErrT,而不是构造函数ErrT,那么在ErrT内部执行IO操作的所有异常都将被正确处理,因为只能通过liftIO引入IO操作。

如果需要,也可以用SomeException替换IOError并处理所有异常。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接