异常注解:现状概览

Lobsters Hottest 工具

摘要

本技术博客详细介绍了 GHC 版本 9.10 至 10.0 中异常注解的状态和用法,涵盖了回溯类型及针对 Haskell 开发者的调试策略。

<p><a href="https://lobste.rs/s/slndvk/exception_annotations_lay_land">评论</a></p>
查看原文 导出为 Word 导出为 PDF
查看缓存全文

缓存时间: 2026/05/11 13:02

# 异常注解:现状概览 来源:https://well-typed.com/blog/2026/05/lay-annotation-land/ 异常注解在 GHC 9.10 中引入,可以成为调试棘手问题的宝贵工具。最初的实现在实践中存在一些重要限制,使其不如人们希望的那样有用,但幸运的是,这种情况后来已经得到了很大改善。在这篇博客文章中,我们将详细概述截至 GHC 9.12/9.14 的现状,指出一些你应该注意的陷阱并提供如何处理它们的建议,并简要展望 GHC 10.0 中的变化。我们还专门安排了一节讨论 GHC 9.10 中的问题,供那些暂时无法升级的人士参考。 尽管我们将回顾所有必要的定义,但这篇博客*并非*旨在作为异常注解的介绍;如果你以前从未使用过它们,可能首先想观看 [Haskell Unfolder 第 29 集:异常、注解和回溯](https://www.youtube.com/watch?v=SwOkh9N41Y8&list=PLD8gywOEY4HaG5VSrKVnHxCptlJv2GAn7&index=30)\。 ## 回溯 在我们查看异常注解的总体框架之前,让我们先简要回顾一下*回溯*(backtraces)的概念,这是 GHC 对其他语言中堆栈跟踪的解决方案。由于惰性(laziness),Haskell 中的情况更为复杂,实际上有四种不同类型的回溯: - 基于 `HasCallStack` 注解 - 基于成本中心(这需要启用剖析编译你的程序) - 基于 [IPE 信息](https://well-typed.com/blog/2021/01/first-look-at-hi-profiling-mode/) - 基于 [DWARF 信息](https://well-typed.com/blog/2020/04/dwarf-1/) 在这篇博客文章中,我们只使用前两种,但就我们这里的讨论而言,选择其实影响不大;详见 GHC 提案 [为异常添加回溯信息](https://github.com/ghc-proposals/ghc-proposals/blob/master/proposals/0330-exception-backtraces.rst)。如果你对 IPE 回溯特别感兴趣,你可能还会对我们关于 [通过用户注解改进 Haskell 堆栈跟踪](https://well-typed.com/blog/2025/09/better-haskell-stack-traces/) 的博客文章感兴趣,该文章讨论了我们近期为实现这些改进而实施的一些扩展。 ### `HasCallStack` 回溯 考虑这个简单的 Haskell 程序,其中 `main` 调用 `top`,`top` 调用 `middle`,`middle` 调用 `bottom`: `` bottom :: HasCallStack => IO () bottom = do bt <- collectBacktraces putStrLn $ displayBacktraces bt middle :: HasCallStack => IO () middle = bottom top :: HasCallStack => IO () top = middle main :: IO () main = top `` `HasCallStack` 本质上是一个额外的函数参数,GHC 会在调用点自动填充它,以包含有关函数被调用位置的信息。当我们运行此程序时,会看到如下内容: `` HasCallStack backtrace: collectBacktraces, called at exe/DemoCallStack.hs:13:11 in (..) bottom, called at exe/DemoCallStack.hs:18:10 in (..) middle, called at exe/DemoCallStack.hs:22:7 in (..) top, called at exe/DemoCallStack.hs:25:8 in (..) `` 这里值得注意的是,一旦 `HasCallStack` 链中断,回溯就会在那里截断。例如,如果 `middle` 没有 `HasCallStack` 约束,我们就再也看不到 `middle` 是从哪里被调用的: `` HasCallStack backtrace: collectBacktraces, called at exe/DemoCallStack.hs:19:11 in (..) bottom, called at exe/DemoCallStack.hs:24:10 in (..) `` 事实是 `top` 仍然具有 `HasCallStack` 约束并不重要:调用栈会在第一个缺失的环节处切断。 ### 成本中心回溯 成本中心是 GHC 实现剖析的方式:粗略地说,计算的成本归因于其封闭的成本中心(参见 GHC 手册中的 [剖析](https://downloads.haskell.org/ghc/latest/docs/users_guide/profiling.html) 章节)。像 `HasCallStack` 一样,这也依赖于源代码注解: `` {-# SCC bottom #-} bottom :: HasCallStack => IO () bottom = do bt <- collectBacktraces putStrLn $ displayBacktraces bt {-# SCC middle #-} middle :: IO () middle = bottom {-# SCC top #-} top :: HasCallStack => IO () top = middle {-# SCC main #-} main :: IO () main = do setBacktraceMechanismState CostCentreBacktrace True top `` 然而,与 `HasCallStack` 不同的是,GHC 提供了自动插入此类注解的方法,这通常使得基于成本中心的调用栈比 `HasCallStack` 更实用。最常用的标志是 `-fprof-auto` 或(在近期的 GHC 中)`-fprof-late`(参见 [延迟成本中心剖析](https://well-typed.com/blog/2023/03/prof-late/))。这会围绕所有顶层函数插入成本中心,就像我们在上面手动做的那样。 成本中心回溯必须通过调用 `setBacktraceMechanismState` (https://hackage-content.haskell.org/package/base-4.22.0.0/docs/Control-Exception-Backtrace.html#v:setBacktraceMechanismState%5D) 显式启用,并且你需要启用剖析来编译代码;`cabal` 选项 `--enable-profiling` (https://cabal.readthedocs.io/en/stable/how-to-enable-profiling.html) 既可以启用剖析,也可以启用自动成本中心插入。此示例的回溯可能如下所示: `` Cost-centre stack backtrace: DemoCallStack.main (exe/DemoCallStack.hs:(32,1)-(34,7)) DemoCallStack.top (exe/DemoCallStack.hs:28:1-12) DemoCallStack.middle (exe/DemoCallStack.hs:24:1-15) DemoCallStack.bottom (exe/DemoCallStack.hs:(18,1)-(20,35)) `` 但是请注意,优化可能会删除成本中心,特别是在像这样的简单示例中 (\#27225 (https://gitlab.haskell.org/ghc/ghc/-/issues/27225))。 ### 成本中心与异常处理 考虑以下示例:如前所述,`main` 调用 `top`,`top` 调用 `middle`,`middle` 调用 `bottom`,打印回溯;但是 `bottom` 随后抛出一个异常。同时,`main` 安装了一个名为 `handlerTop` 的异常处理器,它进而调用 `handlerMiddle` 调用 `handlerBottom`,后者打印其自己的回溯: `` bottom :: HasCallStack => IO () bottom = do bt <- collectBacktraces putStrLn $ displayBacktraces bt throwIO $ userError "Uhoh" middle :: HasCallStack => IO () middle = bottom top :: HasCallStack => IO () top = middle handlerBottom :: HasCallStack => SomeException -> IO () handlerBottom _e = do bt <- collectBacktraces putStrLn $ displayBacktraces bt handlerMiddle :: HasCallStack => SomeException -> IO () handlerMiddle e = handlerBottom e handlerTop :: HasCallStack => SomeException -> IO () handlerTop e = handlerMiddle e main :: IO () main = do setBacktraceMechanismState CostCentreBacktrace True top `catch` handlerTop `` `bottom` 打印的 `HasCallStack` 回溯如前所述: `` HasCallStack backtrace: collectBacktraces, called at exe/DemoCCS.hs:24:11 in (..) bottom, called at exe/DemoCCS.hs:29:10 in (..) middle, called at exe/DemoCCS.hs:32:7 in (..) top, called at exe/DemoCCS.hs:41:5 in (..) `` `handlerBottom` 打印的 `HasCallStack` 非常相似: `` HasCallStack backtrace: collectBacktraces, called at exe/DemoCCS.hs:13:11 in (..) handlerBottom, called at exe/DemoCCS.hs:17:19 in (..) handlerMiddle, called at exe/DemoCCS.hs:20:16 in (..) handlerTop, called at exe/DemoCCS.hs:41:18 in (..) `` 对于基于成本中心的回溯,`bottom` 中显示的那个与之前相同: `` Cost-centre stack backtrace: DemoCCS.main (exe/DemoCCS.hs:(39,1)-(41,27)) DemoCCS.top (exe/DemoCCS.hs:32:1-12) DemoCCS.middle (exe/DemoCCS.hs:29:1-15) DemoCCS.bottom (exe/DemoCCS.hs:(23,1)-(26,30)) `` 但在 `handlerBottom` 中显示的那个更令人惊讶: `` Cost-centre stack backtrace: DemoCCS.main (exe/DemoCCS.hs:(39,1)-(41,27)) DemoCCS.top (exe/DemoCCS.hs:32:1-12) DemoCCS.middle (exe/DemoCCS.hs:29:1-15) DemoCCS.bottom (exe/DemoCCS.hs:(23,1)-(26,30)) DemoCCS.handlerTop (exe/DemoCCS.hs:20:1-30) DemoCCS.handlerMiddle (exe/DemoCCS.hs:17:1-33) DemoCCS.handlerBottom (exe/DemoCCS.hs:(12,1)-(14,35)) `` 这是否是预期/正确的行为是有争议的,但规则是:只有当我们离开 `catch` 的作用域时,成本中心栈才会*恢复*。换句话说:成本中心栈反映了 `bottom`“调用”了 `handlerTop` 的事实,尽管是间接的。这传递性地适用:如果 `handlerTop` 抛出异常,然后被其他异常处理器捕获,那么*它的*回溯将反映 `top`“调用”了 `handlerTop`“调用”了那个其他异常处理器。这种情况自然会发生,例如在使用释放资源然后*重新抛出*异常的处理器时。 ## 基本定义 在我们查看实际捕获和抛出(或重新抛出)异常时出现的细微差别之前,我们先理清基本定义。这些在最近几个 GHC 版本之间几乎没有变化, hopefully 不会有争议。 ### 异常注解 异常注解基本上可以是任何东西;唯一的要求是我们能够显示它们: `` class Typeable a => ExceptionAnnotation a where displayExceptionAnnotation :: a -> String `` 该类的一个重要实例是 `Backtraces` (https://hackage-content.haskell.org/package/base-4.22.0.0/docs/Control-Exception-Backtrace.html#t:Backtraces),它封装了一组不同种类的回溯: `` instance ExceptionAnnotation Backtraces where displayExceptionAnnotation = Base.displayBacktraces `` ### 异常上下文 异常上下文本质上只是异常注解的列表。但是,由于这些注解可能有不同的类型,我们需要将它们包装在一个存在量词中: `` data ExceptionContext = ExceptionContext [SomeExceptionAnnotation] data SomeExceptionAnnotation = forall a. ExceptionAnnotation a => SomeExceptionAnnotation a `` 有一些用于操作异常上下文的函数。最重要的是 `emptyExceptionContext` (https://hackage-content.haskell.org/package/base-4.22.0.0/docs/Control-Exception-Context.html#v:emptyExceptionContext) 和 `addExceptionAnnotation` (https://hackage-content.haskell.org/package/base-4.22.0.0/docs/Control-Exception.html#v:addExceptionContext),分别用于创建空上下文和向现有上下文插入注解。 `` emptyExceptionContext :: ExceptionContext addExceptionAnnotation :: ExceptionAnnotation a => a -> ExceptionContext -> ExceptionContext `` ### 关键变化:`SomeException` 所有这些中的关键变化在于 `SomeException` 的定义,从 GHC 9.10 开始,它现在有一个相关的注解列表: `` data SomeException = forall e. (Exception e, HasExceptionContext) => SomeException e type HasExceptionContext = (?exceptionContext :: ExceptionContext) `` 使用 [隐式参数](https://downloads.haskell.org/ghc/latest/docs/users_guide/exts/implicit_parameters.html#extension-ImplicitParams) 意味着对 `SomeException` 进行模式匹配仍然可能以与之前相同的方式进行(尽管注解会被静默丢弃)。 有各种函数用于提取和操作与异常关联的异常上下文,例如 `someExceptionContext` (https://hackage-content.haskell.org/package/base-4.22.0.0/docs/Control-Exception.html#v:someExceptionContext) 和 `addExceptionContext` (https://hackage-content.haskell.org/package/base-4.22.0.0/docs/Control-Exception.html#v:addExceptionContext): `` someExceptionContext :: SomeException -> ExceptionContext addExceptionContext :: ExceptionAnnotation a => a -> SomeException -> SomeException `` 然而,可能是扩展异常上下文最重要的函数是 `annotateIO` (https://hackage-content.haskell.org/package/base-4.22.0.0/docs/Control-Exception.html#v:annotateIO),它安装了一个异常处理器,该处理器会使用指定的注解扩展任何被抛出的异常: `` annotateIO :: forall e a. ExceptionAnnotation e => e -> IO a -> IO a annotateIO ann (IO io) = IO (PrimOp.catch# io handler) where handler se = PrimOp.raiseIO# (addExceptionContext ann se) `` 需要强调的是,这是用 [原语操作(primops)](https://hackage-content.haskell.org/package/ghc-prim-0.13.0/docs/GHC-PrimopWrappers.html) 实现的,而不是常规的 `catch` 和 `throwIO` 函数,正如我们将看到的,常规函数所做的远不止捕获和抛出。 ## `Exception` 类型类 `Exception` 类型类是 Haskell 异常生态系统的核心抽象。作为异常注解工作的一部分,它获得了一个小的扩展,并在两个虽不重大但颇为微妙的方式上发生了变化。让我们先处理*未*变化的部分:如果我们看不见异常,那就没有什么用处: `` class (Typeable e, Show e) => Exception e where displayException :: e -> String displayException = show -- (..) `` ### `backtraceDesired` 较小的扩展是一个名为 `backtraceDesired` (https://hackage-content.haskell.org/package/base-4.22.0.0/docs/Control-Exception.html#v:backtraceDesired) 的新函数,它指示是否应该将回溯附加到该类型的异常;当我们讨论 `throwIO` 的 [实现](https://well-typed.com/blog/2026/05/lay-annotation-land/#implementation) 时,会看到这个函数是如何使用的。 `` class (Typeable e, Show e) => Exception e where -- (..) backtraceDesired :: e -> Bool backtraceDesired _ = True `` `backtraceDesired` 的参数已经是完全构造好的异常;问题是要否*添加*回溯到该异常。在大多数情况下,参数可以直接忽略,但也不*必须*忽略。除了少数几种特殊情况外,默认实现(指示是的,我们需要回溯)应该是可以的。 ### `fromException` 虽不重大但颇为微妙的变化在于 `fromException` 和 `toException`,它们分别从异常中移除和添加 `SomeException` 包装器。让我们先看 `fromException`: `` class (Typeable e, Show e) => Exception e where -- (..) fromException :: SomeException -> Maybe e fromException (SomeException e) = cast e `` 这可能*看起来*与 [9.10 之前](https://hackage.haskell.org/package/base-4.19.2.0/docs/src/GHC.Exception.Type.html#Exception) 的实现没什么不同,但要记住 `SomeException` 现在有一个额外的字段:异常注解。如上所述,像这样的模式匹配将静默丢弃这些注解。 ### `toException` `Exception` 类中的最后一个函数是 `toException`,旨在添加 `SomeException` 包装器。 `` class (Typeable e, Show e) => Exception e where -- (..) toException :: e -> SomeException `` 在 9.10 之前,默认实现字面上只是添加了 `SomeException` 构造器: `` -- 9.10 之前的实现 toException = SomeException `` 然而,从 9.10 开始,我们还需要为异常上下文提供一个初始值。默认实现很合理,选择了空上下文: `` -- 9.10, 9.12, 9.14 和 10.0 中的实现 toException e = let ?exceptionContext = emptyExceptionContext in SomeException e `` 不幸的是,`toException` 的*文档*也被修改了,现在声明 `toException` 应生成一个没有附加 `ExceptionContext` 的 `SomeException` (https://hackage-content.haskell.org/package/base-4.22.0.0/docs/Control-Exception.html#v:toException)。我个人认为这是一个错误 (\#27194 (https://gitlab.haskell.org/ghc/ghc/-/issues/27194));我们将在下一节讨论这个问题。 #### ⚠️ 注意:`SomeException` 自身的实例 `SomeException` *本身* 也是 `Exception` 的一个实例;`fromException` 是琐碎的,`backtraceDesired` 和 `displayException` 则依赖于所包装的任何异常的定义: `` instance Exceptio

相似文章

在 LLVM 中对抗 Hyrum 定律

Lobsters Hottest

本文概述了 LLVM 编译器基础设施中旨在防止依赖未指定行为(即 Hyrum 定律)以保障构建可重现性的机制。

Hoot 0.9.0 发布

Lobsters Hottest

Hoot 0.9.0,一个用于 Guile 的 Scheme 到 WebAssembly 编译器后端,已发布,包含新功能和错误修复,包括 DWARF 支持、标准 Wasm 异常,以及为 Lisp Game Jam 提供的游戏 jam 模板。

通过可解释性理解标注员安全策略

arXiv cs.AI

本文介绍了苹果公司提出的标注员策略模型(APMs),该模型利用可解释性技术,无需额外标注努力即可从标注行为中推断标注员内部的安全策略。作者证明,APMs 能够准确地建模这些策略,并区分标注分歧的来源,例如操作失误、策略模糊性和价值观多元性。