当你的Haskell应用程序的线程抛出一个没有被捕获的异常时,Haskell运行时系统会根据Show 实例来处理并打印。
这是默认的行为,可以使用 setUncaughtExceptionHandler函数来定制。
就我个人而言,当我注意到Show 类型类是用来渲染的时候,我感到相当惊讶。在我的Haskell开发经验的早期,我注意到Exception 类型类有displayException 方法,并假设它是用来打印未捕获的异常的。几年后,我意识到我的假设是错误的,并感到相当惊讶。 默认的未捕获异常处理程序使用的是Show 类型类中的showsPrec ,而不是displayException 。当我与我的同事分享这一信息时,他们中的一些人也很惊讶。我倾向于认为displayException 是一个更好的默认值,事实证明,我不是唯一这样想的人。 由于人们支持这一想法,我决定做两件事。
- 在GHC问题追踪器中提出这个话题。
- 创建一个小的库,其中有一个函数可以仔细地修改未捕获异常的处理,以使用
displayException。 我们的代码库中已经有了这样一个助手,并想把它提取到一个库中。这个函数并不完全是琐碎的,因为,例如,ExitCode异常应该被特别处理(在这种情况下程序应该以适当的退出代码退出)。
我开始研究这个话题,发现早在2014年就已经讨论过了。
历史
相关的GHC问题是#9822。 它指的是电子邮件线程,其中提出了两件事:
- 将
displayException添加到Exception。 - 修改默认的未捕获异常处理程序,使用
displayException。
第一件事被接受并实施了,而且displayException 已经是Exception 的一部分了。然而,第二件事似乎是有争议的,没有达成共识。 你可以阅读整个线程的细节,因为它不是那么大。 它的两个基本点是:
- 对于未捕获的异常,有一个很好的理由使用
show。未捕获的异常通常是由程序员遇到的,所以最好以 "程序员友好 "的方式格式化它们,即使用show。所以这应该是默认的。然而,有时人们可能更喜欢displayException,所以应该很容易使用它来代替。 - 即使有
setUncaughtExceptionHandler,修改未捕获的异常处理程序以使用displayException,这并不容易,因为。
- stdout必须被刷新,在刷新过程中忽略任何异常。
- 输出必须转到stderr,而不是stdout。
ExitCode异常应被正确处理,程序应以正确的退出代码退出。
在那之后,有人讨论过添加useDisplayExceptionHandler 或displayExceptionHandler ,它可以做 "正确的事情",但这个讨论很快就转向了另一个方向,没有添加这样的函数。在2020年,我在Hackage上找不到一个做 "正确的事情 "的函数。
当我第一次实现这个助手时(在阅读这个主题之前),我没有考虑到ExitCode ,并且有一个微妙的小错误。我的程序在用--help 启动时以非零代码退出,因为optparse-applicative 库在这种情况下抛出(可能是间接地)ExitSuccess ,但我没有考虑到这一点。
为什么是displayException ?
displayException 和show 的本意区别在于,前者应该返回一个 "对人类友好 "的字符串,而后者没有说什么 "对人类友好",通常是自动生成反映 Haskell 类型内部结构的 "对程序员友好 "的字符串。 前面提到的电子邮件主题中的一个很好的类比是 Python 中的str() 和repr() 函数。
根据我的经验,我承认,displayException 通常被实现为show ,这是默认的实现。我认为有3个可能的原因:
Show是自动生成的,而且它产生的字符串足够人性化,不需要自定义格式。 我相信这是一种罕见的情况,因为自动生成的 通常看起来相当冗长。show- 一个程序员只是懒得实现
displayException。也许他们忘了这样做(编译器不检查),也许他们很懒,也许他们甚至不知道这个方法。 - 有一个自定义的
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 ()
我本来可以自己实现一个类似的处理程序,但决定不这样做,因为我并不完全理解这个定义,具体来说:
ExitCode是如何处理的,Haskell程序如何确定它应该以哪种代码退出?它是由errorBelch处理的吗?可能不是,因为errorBelch只接受两个字符串。所以也许它是在这个帮助器之外处理的。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 的异常,原因有两个:
- 这样的异常不多,而且它们的
show表示法应该或多或少对人友好。 - 我担心通过将它们封装到
DisplayExceptionInShow中,我们可能会破坏一些东西,就像我们封装ExitCode时破坏一样。
我们也忽略了Deadlock ,因为它是由defaultHandler 专门处理的。
请注意,万一在使用这些函数之一的线程以外的线程中发生了未捕获的异常,就会有本质的区别。displayUncaughtException 只影响它被调用的线程,所有其他线程都不受影响(默认使用Show 进行打印)。withDisplayExceptionHandler 更新持有未捕获异常处理器的全局变量,从而影响应用程序中的所有线程。 setDisplayExceptionHandler 也是如此。由于未捕获的异常处理程序被存储在一个全局变量中,当涉及到多个线程时,可能会出现竞赛条件。例如,如果withDisplayExceptionHandler 中的一个动作分叉了一个线程,而这个线程以一个异常结束,错误打印可能是不可预测的(取决于哪个动作先完成)。 一个好的做法是,使用来自 async包中的函数,如concurrently 或withAsync 。
withAsync确保子线程总是在父线程之前停止。concurrently另外确保子线程抛出的异常会被传播到其父线程。
关于async 包的进一步讨论超出了本文的范围。
总结
几年前,一个新的方法被添加到Exception 类型类中:displayException 。它应该被用来打印未捕获的异常,但这个想法似乎是有争议的,并没有得到足够的支持。作为一个替代方案,有人建议添加帮助器,人们可以用它来改变默认的未捕获异常处理程序,以使用displayException 。然而,据我所知,现在没有这样的帮助器存在,无论是在base 还是在一个专门的库。
在这篇文章中,我介绍了一个带有这种帮助器的新库。 我不是100%确定我的函数能正确处理所有的情况,也许还有其他需要特殊处理的异常。 如果你对这个库的实现有任何建议,请不要犹豫,在repo中开一个issue/PR。