如何在Haskell中处理未捕获的异常

120 阅读9分钟

当你的Haskell应用程序的线程抛出一个没有被捕获的异常时,Haskell运行时系统会根据Show 实例来处理并打印。

这是默认的行为,可以使用 setUncaughtExceptionHandler函数来定制。

就我个人而言,当我注意到Show 类型类是用来渲染的时候,我感到相当惊讶。在我的Haskell开发经验的早期,我注意到Exception 类型类有displayException 方法,并假设它是用来打印未捕获的异常的。几年后,我意识到我的假设是错误的,并感到相当惊讶。 默认的未捕获异常处理程序使用的是Show 类型类中的showsPrec ,而不是displayException 。当我与我的同事分享这一信息时,他们中的一些人也很惊讶。我倾向于认为displayException 是一个更好的默认值,事实证明,我不是唯一这样想的人。 由于人们支持这一想法,我决定做两件事。

  1. 在GHC问题追踪器中提出这个话题。
  2. 创建一个小的库,其中有一个函数可以仔细地修改未捕获异常的处理,以使用displayException 。 我们的代码库中已经有了这样一个助手,并想把它提取到一个库中。这个函数并不完全是琐碎的,因为,例如,ExitCode 异常应该被特别处理(在这种情况下程序应该以适当的退出代码退出)。

我开始研究这个话题,发现早在2014年就已经讨论过了。

历史

相关的GHC问题是#9822。 它指的是电子邮件线程,其中提出了两件事:

  1. displayException 添加到Exception
  2. 修改默认的未捕获异常处理程序,使用displayException

第一件事被接受并实施了,而且displayException 已经是Exception 的一部分了。然而,第二件事似乎是有争议的,没有达成共识。 你可以阅读整个线程的细节,因为它不是那么大。 它的两个基本点是:

  1. 对于未捕获的异常,有一个很好的理由使用show 。未捕获的异常通常是由程序员遇到的,所以最好以 "程序员友好 "的方式格式化它们,即使用show 。所以这应该是默认的。然而,有时人们可能更喜欢displayException ,所以应该很容易使用它来代替。
  2. 即使有 setUncaughtExceptionHandler,修改未捕获的异常处理程序以使用displayException ,这并不容易,因为。
  • stdout必须被刷新,在刷新过程中忽略任何异常。
  • 输出必须转到stderr,而不是stdout。
  • ExitCode 异常应被正确处理,程序应以正确的退出代码退出。

在那之后,有人讨论过添加useDisplayExceptionHandlerdisplayExceptionHandler ,它可以做 "正确的事情",但这个讨论很快就转向了另一个方向,没有添加这样的函数。在2020年,我在Hackage上找不到一个做 "正确的事情 "的函数。

当我第一次实现这个助手时(在阅读这个主题之前),我没有考虑到ExitCode ,并且有一个微妙的小错误。我的程序在用--help 启动时以非零代码退出,因为optparse-applicative 库在这种情况下抛出(可能是间接地)ExitSuccess ,但我没有考虑到这一点。

为什么是displayException

displayExceptionshow 的本意区别在于,前者应该返回一个 "对人类友好 "的字符串,而后者没有说什么 "对人类友好",通常是自动生成反映 Haskell 类型内部结构的 "对程序员友好 "的字符串。 前面提到的电子邮件主题中的一个很好的类比是 Python 中的str()repr() 函数。

根据我的经验,我承认,displayException 通常被实现为show ,这是默认的实现。我认为有3个可能的原因:

  1. Show 是自动生成的,而且它产生的字符串足够人性化,不需要自定义格式。 我相信这是一种罕见的情况,因为自动生成的 通常看起来相当冗长。show
  2. 一个程序员只是懒得实现displayException 。也许他们忘了这样做(编译器不检查),也许他们很懒,也许他们甚至不知道这个方法。
  3. 有一个自定义的Show 实例,具有人类可读的漂亮格式。我认为这是一个坏主意,因为这样就没有办法以 "对程序员友好 "的方式呈现一个异常。"对程序员友好 "的方式通常包含Haskell标识符,这样你就可以快速找到相关数据类型的文档。 另外,你应该能够看到构成异常的所有值。"对人类友好 "的方式可能返回一个字符串,其中一些字段被省略了,要弄清抛出的异常类型可能很麻烦。

所以目前,在很多情况下,调用哪个函数来打印未捕获的异常其实并不重要,因为displayException 经常被实现为show 。然而,我认为在理想的世界中,大多数具有Exception 实例的类型应该为displayException 提供一个自定义的定义,本文提出的话题将很重要。

哪个函数更合适确实值得商榷。我认为没有明显的赢家,所以使用它们中的任何一个来处理未捕获的异常都应该很容易。我的一个想法是,displayException 更适合于控制台应用程序,因为未捕获的异常对终端用户是可见的,而show 更适合于 UI 应用程序,因为 UI 应用程序中的错误对终端用户是隐藏的。 无论如何,这只是一个想法,我并不打算推荐使用displayException 来处理未捕获的异常,我只是想如果有人想这样做,就可以很容易做到。

未捕获的异常

由于有人讨论过提供一个函数,将未捕获的异常处理改为使用displayException ,但没有采取任何行动,所以我继续为这个用例实现了一个小小的库。这个库被称为uncaught-exception。你可以在GitHub上查看它的源代码。

默认的异常处理程序是在GHC中实现的,如下所示:

defaultHandler :: SomeException -> IO ()
defaultHandler se@(SomeException ex) = do
   (hFlush stdout) `catchAny` (\ _ -> return ())
   let msg = case cast ex of
         Just Deadlock -> "no threads to run:  infinite loop or deadlock?"
         _                  -> showsPrec 0 se ""
   withCString "%s" $ \cfmt ->
    withCString msg $ \cmsg ->
      errorBelch cfmt cmsg

-- don't use errorBelch() directly, because we cannot call varargs functions
-- using the FFI.
foreign import ccall unsafe "HsBase.h errorBelch2"
   errorBelch :: CString -> CString -> IO ()

我本来可以自己实现一个类似的处理程序,但决定不这样做,因为我并不完全理解这个定义,具体来说:

  1. ExitCode 是如何处理的,Haskell程序如何确定它应该以哪种代码退出?它是由errorBelch 处理的吗?可能不是,因为errorBelch 只接受两个字符串。所以也许它是在这个帮助器之外处理的。
  2. errorBelch 是做什么的,为什么它是用外国语言(很可能是C/C++)实现的? 它不能用Haskell实现吗? 我应该在我的处理程序中也使用FFI吗?

我想我可以找到这些问题的答案,但是我选了一个更简单的方法,实际上是两个方法。 如果这个默认的处理程序在GHC中被更新,我将不必在我的库中做任何事情。 我定义了下面的包装器:

-- | Helper data type used by 'displayUncaughtException'.
-- It causes @show@ to call @displayException@.
-- When an exception of this type is caught, it will be @show@n and
-- that will call @displayException@ of the wrapped exception.
newtype DisplayExceptionInShow = DisplayExceptionInShow SomeException

instance Show DisplayExceptionInShow where
  show (DisplayExceptionInShow se) = displayException se

instance Exception DisplayExceptionInShow

连同将异常包入这个包装器的wrapException :: SomeException -> SomeException 函数。 并定义了两个函数:

displayUncaughtException :: IO a -> IO a
displayUncaughtException = handle (throwIO . wrapException)

withDisplayExceptionHandler :: IO a -> IO a
withDisplayExceptionHandler action = do
  handler <- getUncaughtExceptionHandler
  setUncaughtExceptionHandler (handler . wrapException)
  action <* setUncaughtExceptionHandler handler

它们的作用相同,但我不确定哪一个更好,因此我提供了这两个。 如果你有好的论据支持其中一个函数,请让我知道。

在第一个版本中,我们完全不碰默认的处理程序。 在第二个版本中,我们只更新它,把它的参数包进DisplayExceptionInShow ,然后按原样应用。另外,还有setDisplayExceptionHandler ,它更新处理程序的方式与withDisplayExceptionHandler 相同,但最后不恢复它。 在所有情况下,所有的问题都应该得到处理。

除了一个问题:如果我们把ExitCode 包裹到DisplayExceptionInShow 中,我们会破坏像exitSuccess 这样的函数,因为它们是通过抛出一个ExitCode 异常来工作的,而我们改变了它的类型。所以我们小心地实现wrapException ,以忽略ExitCode

wrapException :: SomeException -> SomeException
wrapException e
  | isSyncException e
  , Nothing <- fromException @Deadlock e
  , Nothing <- fromException @ExitCode e =
      toException $ DisplayExceptionInShow e
  | otherwise = e
  where
    isSyncException :: SomeException -> Bool
    isSyncException = isNothing . fromException @SomeAsyncException

请注意,我们也忽略了那些可以被投到SomeAsyncException 的异常,原因有两个:

  1. 这样的异常不多,而且它们的show 表示法应该或多或少对人友好。
  2. 我担心通过将它们封装到DisplayExceptionInShow 中,我们可能会破坏一些东西,就像我们封装ExitCode 时破坏一样。

我们也忽略了Deadlock ,因为它是由defaultHandler 专门处理的。

请注意,万一在使用这些函数之一的线程以外的线程中发生了未捕获的异常,就会有本质的区别。displayUncaughtException 只影响它被调用的线程,所有其他线程都不受影响(默认使用Show 进行打印)。withDisplayExceptionHandler 更新持有未捕获异常处理器的全局变量,从而影响应用程序中的所有线程。 setDisplayExceptionHandler 也是如此。由于未捕获的异常处理程序被存储在一个全局变量中,当涉及到多个线程时,可能会出现竞赛条件。例如,如果withDisplayExceptionHandler 中的一个动作分叉了一个线程,而这个线程以一个异常结束,错误打印可能是不可预测的(取决于哪个动作先完成)。 一个好的做法是,使用来自 async包中的函数,如concurrentlywithAsync

  • withAsync 确保子线程总是在父线程之前停止。
  • concurrently 另外确保子线程抛出的异常会被传播到其父线程。

关于async 包的进一步讨论超出了本文的范围。

总结

几年前,一个新的方法被添加到Exception 类型类中:displayException 。它应该被用来打印未捕获的异常,但这个想法似乎是有争议的,并没有得到足够的支持。作为一个替代方案,有人建议添加帮助器,人们可以用它来改变默认的未捕获异常处理程序,以使用displayException 。然而,据我所知,现在没有这样的帮助器存在,无论是在base 还是在一个专门的库。

在这篇文章中,我介绍了一个带有这种帮助器的新库。 我不是100%确定我的函数能正确处理所有的情况,也许还有其他需要特殊处理的异常。 如果你对这个库的实现有任何建议,请不要犹豫,在repo中开一个issue/PR。