异常注解:现状概览
摘要
本技术博客详细介绍了 GHC 版本 9.10 至 10.0 中异常注解的状态和用法,涵盖了回溯类型及针对 Haskell 开发者的调试策略。
<p><a href="https://lobste.rs/s/slndvk/exception_annotations_lay_land">评论</a></p>
查看缓存全文
缓存时间: 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 定律
本文概述了 LLVM 编译器基础设施中旨在防止依赖未指定行为(即 Hyrum 定律)以保障构建可重现性的机制。
MultiSoc-4D:用于诊断孟加拉语社交媒体封闭集大语言模型标注中指令诱导标签崩溃的基准
本文介绍了 MultiSoc-4D,这是一个用于诊断大语言模型在标注孟加拉语社交媒体数据时出现的指令诱导标签崩溃问题的基准测试。研究揭示,大语言模型系统性地倾向于使用默认标签,导致对仇恨言论和讽刺等少数类别的检测不足。
Hoot 0.9.0 发布
Hoot 0.9.0,一个用于 Guile 的 Scheme 到 WebAssembly 编译器后端,已发布,包含新功能和错误修复,包括 DWARF 支持、标准 Wasm 异常,以及为 Lisp Game Jam 提供的游戏 jam 模板。
第12届Plan 9国际研讨会演讲内容带注释摘要
本文以讽刺的笔调对第12届Plan 9国际研讨会上的演讲进行了带注释的摘要,涵盖了音频制作、安全以及工具链更新等主题。
通过可解释性理解标注员安全策略
本文介绍了苹果公司提出的标注员策略模型(APMs),该模型利用可解释性技术,无需额外标注努力即可从标注行为中推断标注员内部的安全策略。作者证明,APMs 能够准确地建模这些策略,并区分标注分歧的来源,例如操作失误、策略模糊性和价值观多元性。