如何使用 ExceptT 替换大量 IO(要么 a b)

How to use ExceptT to replace lots of IO (Either a b)

我有一个连接到数据库然后运行查询的函数。这些步骤中的每一步都会导致 IO (Either SomeErrorType SomeResultType).

我非常喜欢在学习 Haskell 中使用 Either 和类似 monad 的其中一件事是能够使用像 >>= 这样的 monad 函数和像 mapLeft 以简化对预期错误状态的大量处理。

我在这里阅读博客文章、Control.Monad.Trans 文档和关于 SO 的其他答案的期望是,我必须以某种方式使用转换器/提升从 IO 上下文移动到 Either上下文。

特别好,但我很难将其应用到我自己的案例中。

我的代码的一个更简单的例子:

simpleVersion :: Integer -> Config -> IO ()
simpleVersion id c = 
  connect c >>= \case 
      (Left e)     -> printErrorAndExit e
      (Right conn) -> (run . query id $ conn)
              >>= \case 
                    (Left e)  -> printErrorAndExit e
                    (Right r) -> print r
                                   >> release conn

我的问题是 (a) 我并不真正了解 ExceptT 如何让我到达与 mapLeft handleErrors $ eitherErrorOrResult >>= someOtherErrorOrResult >>= print 世界相似的地方的机制; (b) 我不确定如何确保始终以最好的方式释放连接(即使在我上面的简单示例中),尽管我想我会使用 bracket pattern.

我确定每个(相对)新的 Haskeller 都这么说,但我仍然真的不理解 monad 转换器,我读到的所有内容(除了前面链接的 SO 答案)对我来说太不透明了(还) .

如何将上面的代码转换成可以删除所有这些嵌套和错误处理的代码?

我认为查看 ExceptTMonad 实例的源代码很有启发性:

newtype ExceptT e m a = ExceptT (m (Either e a))

instance (Monad m) => Monad (ExceptT e m) where
    return a = ExceptT $ return (Right a)
    m >>= k = ExceptT $ do
        a <- runExceptT m
        case a of
            Left e -> return (Left e)
            Right x -> runExceptT (k x)

如果忽略newtype的包裹和展开,就更简单了:

m >>= k = do
    a <- m
    case a of
        Left e -> return (Left e)
        Right x -> k x

或者,您似乎不喜欢使用 do:

m >>= k = m >>= \a -> case a of
    Left e -> return (Left e)
    Right x -> k x

你是不是觉得这段代码很眼熟?那和你的代码之间的唯一区别是你写 printErrorAndExit 而不是 return . Left!所以,让我们把那个 printErrorAndExit 移到顶层,并且很高兴 记住 现在的错误而不打印它。

simpleVersion :: Integer -> Config -> IO (Either Err ())
simpleVersion id c = connect c >>= \case (Left e)     -> return (Left e)
                                         (Right conn) -> (run . query id $ conn)
                                                          >>= \case (Left e)  -> return (Left e)
                                                                    (Right r) -> Right <$> (print r
                                                          >> release conn)

除了我提到的改变之外,你还必须在最后粘贴一个 Right <$> 以从 IO () 动作转换为 IO (Either Err ()) 动作。 (稍后会详细介绍。)

好的,让我们尝试用上面的 ExceptT 绑定替换 IO 绑定。我将添加一个 ' 来区分 ExceptT 版本和 IO 版本(例如 >>=' :: IO (Either Err a) -> (a -> IO (Either Err b)) -> IO (Either Err b))。

simpleVersion id c = connect c >>=' \conn -> (run . query id $ conn)
                                             >>=' \r -> Right <$> (print r
                                             >> {- IO >>! -} release conn)

这已经是一个改进,一些空格的更改使它变得更好。我还将包括一个 do 版本。

simpleVersion id c =
    connect c >>=' \conn ->
    (run . query id $ conn) >>=' \r ->
    Right <$> (print r >> release conn)

simpleVersion id c = do
    conn <- connect c
    r <- run . query id $ conn
    Right <$> (print r >> release conn)

对我来说,这看起来很干净!当然,在 main 中,您仍然需要 printErrorAndExit,如:

main = do
    v <- runExceptT (simpleVersion 0 defaultConfig)
    either printErrorAndExit pure v

现在,关于 Right <$> (...)...我说我想从 IO a 转换为 IO (Either Err a)。好吧,这种事情就是 MonadTrans class 存在的原因;让我们看看 ExceptT:

的实现
instance MonadTrans (ExceptT e) where
    lift = ExceptT . liftM Right

嗯,liftM(<$>)是同一个函数,只是名字不同而已。所以如果我们忽略 newtype 包装和展开,我们得到

lift m = Right <$> m

!所以:

simpleVersion id c = do
    conn <- connect c
    r <- run . query id $ conn
    lift (print r >> release conn)

如果您愿意,您也可以选择使用 liftIO。不同之处在于 lift 总是通过一个转换器提升一个单子动作,但适用于任何一对包装类型和转换器类型;虽然 liftIO 通过 monad 转换器堆栈所需的尽可能多的转换器提升 IO 动作,但仅适用于 IO 动作。

当然,到目前为止我们已经省略了所有 newtype 包装和展开。为了使 simpleVersion 像我们这里的最后一个示例一样漂亮,您需要更改 connectrun 以适当地包含这些包装器。

我完全同意@Daniel Wagner 写的所有内容。但是还有另一种选择如何使用 IO (Either err a).

组织工作

有函数try :: Exception e => IO a -> IO (Either e a),我们可以实现它的反转版本(untry :: Exception e => IO (Either e a) -> IO a)吗?让我们这样做:

untry :: Exception e => IO (Either e a) -> IO a
untry action = action >>= either throwIO pure

unliftio named fromEitherM 中有类似的功能。

现在我们可以重新设计您的示例:

simpleVersion :: Integer -> Config -> IO ()
simpleVersion id c = do
    conn <- untry . connect $ c
    r <- untry . run . query id $ conn
    print r
    release conn

为了使这个技巧起作用,错误类型需要是 Exception class.

的实例

printErrorAndExit 没有在这里使用,因为它应该在顶层完成(main 或接近它)。并且可以使用 catchtry.

等函数处理错误

唯一的问题是,如果在打开和关闭之间发生错误,则无法释放连接。让我们用 bracket:

来解决这个问题
simpleVersion :: Integer -> Config -> IO ()
simpleVersion id c = bracket (untry $ connect c) release $ \conn -> do
    r <- untry $ run $ query id conn
    print r

您可以编写用于创建连接的辅助函数:

withConn :: Config -> (Connection -> IO a) -> IO a
withConn c = bracket (untry $ connect c) release

比:

simpleVersion id = flip withConn $ \conn -> do
    r <- untry $ run $ query id conn
    print r

你可以考虑使用 ReaderT Connection IO 来隐式提供与你的查询的连接。之后你的代码可以看起来像:

simpleVersion id conf = withConn conf $ do
    r <- run $ query id
    print r