【问题标题】:How can I compose error types in Haskell?如何在 Haskell 中编写错误类型?
【发布时间】:2020-09-03 05:05:26
【问题描述】:

在大型 Haskell 应用程序中,跨多个函数层聚合和处理类型错误是否存在一致的最佳实践?

从介绍性文本和Haskell Wiki 来看,我认为纯函数应该是完全的——也就是说,将错误作为它们共同域的一部分进行评估。运行时异常无法完全避免,但应仅限于 IO 和异步计算。

如何在纯同步函数中构建错误处理?标准建议是使用 Either 作为返回类型,然后为函数可能导致的错误定义代数数据类型 (ADT)。例如:

data OrderError
    = NoLineItems
    | DeliveryInPast
    | DeliveryMethodUnavailable

mkOrder :: OrderDate -> Customer -> [lineIntem] -> DeliveryInfo -> Either OrderError Order

但是,一旦我尝试将多个产生错误的函数组合在一起,每个函数都有自己的错误类型,我该如何组合错误类型?我想将所有错误汇总到应用程序的 UI 层,在那里错误被解释,可能映射到特定于语言环境的错误消息,然后以统一的方式呈现给用户。当然,这个错误呈现应该不会干扰到应用的域环中的功能,应该是纯业务逻辑。

我不想定义一个 uber-type - 一个包含应用程序中所有可能错误的大型 ADT;因为这意味着 (a) 所有域级代码都需要依赖于这种类型,这会破坏所有模块化,并且 (b) 这将创建对于任何给定函数来说都太大的错误类型。

或者,我可以在每个组合函数中定义一个新的错误类型,然后将单个错误映射到组合的错误类型:比如 funA 带有错误 ADT ErrorAfunB 带有 ErrorB .如果funC,错误类型为ErrorC,同时应用funAfunBfunC 需要将来自ErrorAErrorB 的所有错误案例映射到所有属于@987654339 的新案例@。这似乎是很多样板。

第三种选择可能是funC 包装来自funAfunB 的错误:

data ErrorC
    = SomeErrorOfFunC
    | ErrorsFromFunB ErrorB
    | ErrorsFromFunA ErrorA

通过这种方式,映射变得更容易,但 UI 环中的错误处理需要了解应用程序内环中函数的确切嵌套。如果我重构域环,我确实需要在 UI 中触摸错误展开功能。

我确实找到了类似的question,但使用Control.Monad.Exception 的答案似乎暗示了运行时异常而不是错误返回类型。该问题的详细处理方法似乎是由 Matt Parson 撰写的this one。然而,该解决方案涉及几个 GHC 扩展、类型级编程和镜头,对于像我这样的新手来说,这需要消化很多东西,他们只是想使用 Haskell 编写一个具有适当“按书本”错误处理的体面的应用程序表现力类型系统。

我听说 PureScript 的可扩展记录可以更轻松地组合错误枚举。但是在 Haskell 中呢?有直接的最佳实践吗?如果是这样,我在哪里可以找到有关如何操作的文档或教程?

【问题讨论】:

  • 为什么不反转?使用已经完成语言环境转换的单一错误类型。忘记 ADT。无论如何,您的设计必须在某个地方有一个包含所有错误的大表。您失去了“捕获”Java 风格的任意特定异常的能力,但我认为这很好。我发现“出现问题”错误和“正常操作”错误之间是有区别的,你可以给后者一个更特权的表示,这样就有类型压力来处理它们。
  • 我的主要担心是域代码不需要依赖在某些外部层中声明的类型,因为这会破坏干净代码依赖规则。问题不在于某处的大型错误表,而是声明了一个错误类型,然后所有代码都需要将其作为依赖项导入。
  • 经过一番阅读,在我看来,目前 GHC 中标准的可扩展异常机制(参见 original paper)也应该适用于子类型错误。

标签: haskell exception error-handling


【解决方案1】:

对于您的可聚合Error 类型,我建议您查找validation: A data-type like Either but with an accumulating Applicative

这个库就是一个模块,只包含几个定义。包中的Validation 类型本质上是(虽然不是字面意思):

type Validation e a = Either (NonEmpty e) a

值得指出的是,错误的累积是使用应用组合子实现的,即liftA2liftA3zip。您不能monad 理解中累积错误,也就是 do 表示法:

user :: Int -> Validation DomainError User
userId :: User -> Int
post :: Int -> Validation DomainError Post

userAndPost = do 
  u <- user 1
  p <- post . userId $ u
  return $ (u,p)

另一方面,应用版本可能会产生两个错误:

userAndPostA2 = liftA2 (,) (user 1) (post 1)    

上面的 userAndPost 函数的 monad 版本永远不会因为找不到 userpost 而产生两个错误。它总是一个或另一个。应用程序虽然在理论上被认为不如 monad 强大,但在某些实践中具有独特的优势。应用程序相对于 monad 的另一个优势是并发性。再一次看上面的例子,我们可以很容易地推断出为什么理解中的 monad 不能永远同时执行(注意帖子的获取如何依赖于获取的用户的用户 id,从而决定执行一个动作取决于另一个动作的结果)。

至于您在选择为所有域级别错误定义单个脱节联合类型DomainError 时担心破坏代码模块化,我敢说没有更好的方法来建模它,前提是所述域-特定的错误类型仅由领域层中的函数构造和传递。一旦说 HTTP 层从域层调用函数,它就需要将域层的错误转换为它自己的错误,例如通过类似于以下的映射函数:

eDomainToHTTP :: DomainError -> HTTPError
eDomainToHTTP InvalidCredentials = Forbidden403
eDomainToHTTP UserNotFound = NotFound404
eDomainToHTTP PostNotFound = NotFound404

使用一个这样的函数,您可以轻松地将任何input -&gt; Validation DomainError output 转换为input -&gt; Validation HTTPError output,从而在您的代码库中保持封装性和模块化。

【讨论】:

  • 感谢您指出验证库!它确实解决了如何组合多个错误的问题。不过,据我所知——以及你所指出的——各个错误必须仍然属于同一类型。我确实认为这是一个大问题,它迫使我的内部业务逻辑依赖于我的程序架构外环的类型,这违反了 Clean Code/Onoin 架构的依赖规则。我首先在寻找解决这个依赖问题的方法。
  • @UlrichSchuster 我理解 shayan 提到的 DomainError 是域模型中定义的总和类型(即“最内环”)。这如何打破依赖规则?
  • 我认为有两个独立的问题:(a) 来自域模型(最内环)的错误需要以某种方式与来自其他环的错误相结合,例如用例错误或 UI 错误。怎么做? (b) 即使有一个大的域错误类型,这种类型对于大多数函数来说也太大了,因为它们每个可能只返回一个或两个该类型的元素。这个问题被描述为here
  • @UlrichSchuster 您引用的文章反对在整个应用程序中使用AllPossibleErrors 类型。对于分层架构,首先不应考虑所有错误的超级类型。此外,错误案例在外层变得更广泛并不一定是这种情况。请注意我的示例 eDomainToHTTP 函数如何接受 3 个可能的输入并返回 2 个可能的输出。换句话说,错误案例由于从一层过渡到另一层而缩小。
猜你喜欢
  • 1970-01-01
  • 2021-01-23
  • 2018-04-03
  • 2012-01-06
  • 2011-02-08
  • 2013-09-04
  • 2013-12-18
  • 1970-01-01
相关资源
最近更新 更多