Haskell的错误处理方法

42

毫无疑问,Haskell 中有各种机制来处理错误并适当地处理它们。Error monad, Either, Maybe, exceptions 等等。

那么为什么在其他语言中编写可能会出现异常的代码比在 Haskell 中更加直观呢?

假设我想要编写一个命令行工具,用于处理通过命令行传递的文件。我想要:

  • 验证是否提供了文件名
  • 验证文件是否可用且可读
  • 验证文件是否具有有效的标题
  • 创建输出文件夹并验证输出文件是否可写
  • 处理文件,在解析错误、不变式错误等情况下出错
  • 输出文件,在写入错误、磁盘已满等情况下出错

所以这是一个相当简单的文件处理工具。

在 Haskell 中,我将使用一些组合的 monads 来封装此代码,使用 Maybe 和 Either 并根据需要转换和传播错误。最终,所有这些都到达 IO monad,我能够向用户输出状态。

在另一种语言中,我只需抛出异常并在适当的位置捕获即可。简单明了。我不需要花费太多时间在认知上的困境中,试图解开我需要哪些机制的组合。

我是不是在错误的角度看待这个问题,还是有一些实质性的感受?

编辑:好吧,现在我得到的反馈是,这只是感觉上更困难,但实际上并不是。因此这里有一个痛点。在 Haskell 中,我正在处理一堆 monads,并且如果我必须处理错误,则将在此 monad 堆栈中添加另一层。我不知道我要添加多少 lift 和其他语法垃圾才能使代码编译通过,但是它们对语义没有任何影响。没人觉得这增加了复杂性吗?


2
Haskell 以前也有异常,使用 Control.Exception 库。可以在任何地方抛出异常,在 IO monad 中捕获。它们发生了什么事情? - n. m.
3
你是指这些:http://haskell.org/ghc/docs/latest/html/libraries/base/Control-Exception.html ... 它们还在原地。 - Godeke
4
抛出异常并在适当的位置捕获异常并不是一件简单的事情。@sclv的回答完美地展示了这一点。 - John L
4
@Dan Burton:这显然是一个出于善意的问题,受到某些程度上的烦恼推动,并经过过滤。它提到了一些领域,在这些领域中,Haskell会让事情变得更加困难。对于那些在使用其他语言之后开始接触Haskell的人来说,适应确实很困难。这个问题触及了一些Haskell前期难度较高的方面。 - C. A. McCann
1
我发现这些答案非常有启发性。感谢@Muin提出这个问题 - 这是一个我也一直在努力解决的主题! - Tim Perry
显示剩余3条评论
3个回答

33
在Haskell中,我会使用一些单子的组合方式,使用Maybe和Either来翻译和传递错误。最终它都会到IO单子,我可以在那里向用户输出状态。 在另一种语言中,我只需抛出异常并在适当的位置捕获即可。直截了当。我不会花费太多时间在认知限制中,试图解开我需要哪些机制的组合。
我不会说你一定做错了。相反,你的错误在于认为这两种情况是不同的,而它们并不是。“简单地抛出并捕获”等同于在整个程序上强加与Haskell的错误处理方法完全相同的概念结构。确切的组合取决于您要比较的语言的错误处理系统,这就是为什么Haskell看起来更复杂的原因:它允许您根据需要混合和匹配错误处理结构,而不是给您一个隐含的、一种大小适合大多数情况的解决方案。因此,如果您需要特定的错误处理样式,就使用它;并且您只对需要它的代码使用它。不需要它的代码--由于既不生成也不处理相关类型的错误--被标记为这样,这意味着您可以使用该代码,而不必担心该类型的错误被创建。
关于语法笨拙的问题,这是一个尴尬的问题。理论上,它应该是无痛的,但是:
- Haskell已经成为一个研究驱动的语言有一段时间了,在它的早期阶段,许多事情仍然在变化中,有用的惯用语还没有被普及,因此,老代码漂浮在周围可能是一个糟糕的角色模型 - 一些库在处理错误的灵活性方面并不像它们本应该的那样灵活,要么是由于以上原因的旧代码的化石化,要么只是缺乏光彩。 - 我不知道有任何关于如何为错误处理最佳结构化新代码的指南,所以新手只能靠自己的设备。

我猜你在某些方面“做错了”,可能可以避免大部分的语法混乱,但可能不合理期望你(或任何普通的Haskell程序员)自己找到最好的方法。

至于monad变换器栈,我认为标准的方法是为应用程序newtype整个栈,派生或实现相关类型类的实例(例如MonadError),然后使用类型类的函数,这些函数通常不需要lift。您为核心应用程序编写的单调函数都应使用newtype的堆栈,因此也不需要提升。我认为,唯一无法避免的低语义含义是liftIO

处理大量变换器堆栈可能确实会导致头痛,但仅当存在许多嵌套的不同转换器层时才会出现这种情况(将交替层堆叠StateT ErrorT,并在中间添加一个ContT,然后尝试告诉我您的代码实际上会执行什么操作)。然而,这很少是您真正想要的。


编辑:作为一个小补充,我想提醒更一般的观点,在写几个评论时我注意到了这一点。

正如我所指出的,@sclv非常好地展示了正确的错误处理确实是如此复杂。您所能做的就是将该复杂性分散,而不是消除它,因为无论您执行哪些可以独立产生错误的操作,您的程序都需要以某种方式处理每种可能的组合,即使该“处理”仅是跌倒和死亡。

话虽这样说,但Haskell在一个方面与大多数语言 intrinsically(本质上)存在着明显的区别:一般来说,错误处理是明确且first-class(头等重要的),这意味着所有的东西都是公开的并且可以自由地进行操作。这样做的反面是隐式错误处理的丧失,这意味着即使你只想打印出一个错误信息并退出程序,你也必须显式地这样做。因此,在 Haskell 中实际进行错误处理更容易,因为有用于此的头等抽象,但忽略错误则更难。然而,这种“放弃所有”的错误非处理几乎在任何实际的生产应用中都是不正确的,这就是为什么这种笨拙的方式经常被忽略。

因此,尽管在需要显式处理错误时事情一开始可能更加复杂,但重要的是要记住那就是全部。一旦学会了如何使用适当的错误处理抽象,复杂性基本上就达到了一个平稳阶段,并且随着程序扩展,它并没有变得显著更难;而且你使用这些抽象的次数越多,它们就变得越自然。


2
我希望我能够将倒数第二段增加两个。 - John L

28
让我们来看一下您想要做的一些事情:
验证是否提供了文件名
如果没有提供呢?直接退出,对吗?
验证文件是否可用且可读
如果有一些不可用呢?处理剩余的文件,当遇到错误时抛出异常,在错误文件上发出警告并处理好的文件?在执行任何操作之前退出?
验证文件是否具有有效的头部
如果没有呢?同样的问题--跳过错误文件,尽早终止,对错误文件发出警告等等...
处理文件,对解析错误、不变性错误等进行报错。
同样地,如果遇到问题,应该怎么做呢?跳过错误行,跳过错误文件,终止,终止并回滚,打印警告,打印可配置级别的警告?
关键是有选择和选项可用。为了以与命令式方式相似的方式执行您想要的操作,您根本不需要使用任何 Monad 堆栈中的 maybe 或 either。您只需要在 IO 中抛出和捕获异常。
如果您不想在所有地方都使用异常,并且希望获得一定程度的控制,则仍然可以在没有 Monad 堆栈的情况下完成。例如,如果您想处理您可以访问的文件并获取结果,并在无法访问的文件上返回错误,则使用 Either 就非常好--只需编写函数 FilePath -> IO (Either String Result)。然后对输入文件列表应用 mapM。然后将结果列表分为两部分,即 Either 中的左右值,并对结果应用一个类型为 Result -> IO (Maybe String) 的函数,最后使用 catMaybe 进行错误字符串的处理。现在,您可以使用 mapM print <$> (inputErrors ++ outputErrors) 将所有出现在两个阶段中的错误显示出来。

或者,你知道的,也可以做其他事情。无论如何,在单子堆栈中使用MaybeEither都有其用处。但对于典型的错误处理情况,直接明确地处理它们更加方便、也更加强大。只需要适应这些函数的各种操作方式即可。


2
好的回答。我发现自己在Java中更经常地想念Maybe和Either,而不是在Haskell中想念异常的语法糖。 - mokus
2
最终,一切都归结于处理每种可能结果的必要性。模式匹配、partitionEitherscatchError等之间的区别仅在于聚合哪些情况、哪些函数依赖于其他结果以及在代码中处理不同情况的位置。无法避免这种复杂性;Haskell只是劝阻你把它藏起来。 - C. A. McCann
你能指出一个例子,展示Haskell命令行工具如何进行这种级别的异常处理吗? - Muin
@Muin:我还没有查看过他们的源代码,所以我不能保证风格方面的任何事情,但我经常使用pandoccabal-install,两者在处理本质上容易出错的任务方面都非常强大。 - C. A. McCann
2
@Muin 在这里你会找到两个有用的错误处理示例:这里这里 - Michael Steele

7
什么是“Either e a”的评估和模式匹配与“try”和“catch”的区别,除了它会传播异常(如果您使用Either Monad,则可以模拟此过程)?
请记住,大多数时候,某物的monadic使用方法(在我看来)很丑陋,除非您有大量使用可能失败的函数。
如果只有一个可能的故障,那就没问题了。
func x = case tryEval x of
             Left e -> Left e
             Right val -> Right $ val + 1

func x = (+1) <$> trvEval x

这只是一种表示同一事物的功能性方法。


3
写出 fmap (1+) 的方式有点冗长。实际上,你只需要加上 fmap 就可以了,这似乎很简单。在 Maybe monad 中也适用。同样的方法是否也适用于 IO 中的异常呢?也许“使用 fmap”是一个不错的回答。 - Barend Venter

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