【问题标题】:How to use ExceptT to replace lots of IO (Either a b)如何使用 exceptT 替换大量 IO(a b)
【发布时间】:2021-11-18 11:49:38
【问题描述】:

我有一个连接到数据库的函数,然后运行查询。每个步骤都会产生IO (Either SomeErrorType SomeResultType)

我真正喜欢在学习 Haskell 时使用 Either 和类似的 monad 的一件事是能够使用像 >>= 这样的单子函数和像 mapLeft 这样的组合子来简化很多预期的处理错误状态。

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

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

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

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 答案)对我来说都太不透明了(还)。

我怎样才能将上面的代码转换成可以消除所有这些嵌套和错误处理的东西?

【问题讨论】:

标签: haskell error-handling monads monad-transformers


【解决方案1】:

我认为查看MonadExceptT 实例的源代码非常有启发性:

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 &lt;$&gt; 才能将 IO () 操作转换为 IO (Either Err ()) 操作。 (稍后会详细介绍。)

好的,让我们尝试用上面的ExceptT 绑定替换IO 绑定。我将添加一个' 以区分ExceptT 版本和IO 版本(例如&gt;&gt;=' :: IO (Either Err a) -&gt; (a -&gt; IO (Either Err b)) -&gt; 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 &lt;$&gt; (...)... 我说我想从 IO a 转换为 IO (Either Err a)。好吧,这种事情就是MonadTrans 类存在的原因;让我们看看它对ExceptT 的实现:

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

嗯,liftM(&lt;$&gt;) 是同一个函数,但名称不同。因此,如果我们忽略 newtype 包装和展开,我们会得到

lift m = Right <$> m

!所以:

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

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

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

【讨论】:

  • 我花了一些时间才完全掌握了这一点,但感谢您的出色回答。一个快速的后续问题:您将如何合并bracket 之类的内容以确保连接始终关闭?我是否需要将覆盖connect 调用的ExceptT 拆分为IO (Either ConnectionError Connection),以便我可以处理左侧的情况?
  • @GTF 合而为一!
  • 类似hackage.haskell.org/package/errors-ext?感觉必须有一种通用的方式来做类似括号的事情而不是 exceptT?
  • @GTF MonadBaseControl 有点争议,但对于像这样的简单案例,它可能会让你到达你需要去的地方。
  • 感谢指点,我会阅读 Snoyman 的教程 (yesodweb.com/book/monad-control)
【解决方案2】:

我完全同意@Daniel Wagner 所写的一切。但是还有另一种选择如何使用IO (Either err a) 组织工作。

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

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

类似的函数在包unliftio中,名为fromEitherM

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

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

要使这个技巧起作用,错误类型需要是Exception 类的实例。

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

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2018-05-31
    • 1970-01-01
    相关资源
    最近更新 更多