- 编程语言
- 编译器
- 语言运行时
- 开发工具
【免费下载链接】unison
A friendly programming language from the future
本文以 Unison 仓库中的回归测试 unison-src/transcripts-using-base/fix2297.md 为切入点,讲解 Unison 语言中 ability(能力/代数效应)系统的一个关键语义:handler 在处理某个具体能力时,必须保留调用者拥有的其余能力,而不是在类型层面静默丢弃它们。读完本文,你将理解这段测试代码的每一行含义、类型检查器为何报出needs the {IO} ability错误,以及 Unison 的 transcript 测试框架如何用:error标签把"预期报错"固化为可自动回归的断言。
1. 这个测试在测什么:一个"丢弃能力"的 bug
fix2297.md的开篇说明只有一句话:
This tests a case where a function was somehow discarding abilities.
翻译过来就是:该测试覆盖了一个"函数在某种程度上丢弃了 abilities"的场景。它对应 Unison 历史上的 issue #2297。问题的表象是:一段明明调用了printLine(需要IO能力)和trivial(需要Trivial能力)的代码,在经过某个 handler 处理后,类型检查器竟然把IO、Exception这两个能力"忘掉"了——仿佛 handler 的签名把额外效果静默吞掉了。
在 Unison 的能力系统中,这属于严重的安全隐患:如果 handler 可以随意抹掉调用方持有的能力,那么对IO等效果的保护就形同虚设。这个测试的目的,就是把该 bug 的复现路径固定下来,确保类型检查器在修复后依然拒绝这种不安全的代码,而不是"修复过头"变成放行。
2. 前置概念:Unison 的 ability 与 handler
在深入代码前,先厘清 Unison 的四个基础概念(本测试全部用到了):
- Ability 声明:
structural ability Trivial where trivial : ()定义一个结构化的能力类型。structural表示该能力在相等性判断时按结构比较。 - 能力集合类型:
'{e, Trivial} a中的{e, Trivial}是能力的集合。{e}是一个类型变量形式的能力集合,表示"任意一组能力"。 - Request:
Request {Trivial} a是"挂起的能力请求",handler 通过模式匹配它来决定如何响应trivial请求。 - handler:
handle !action with h把动作action产生的能力请求交给处理函数h逐一响应;处理完一个请求后,通过resume(续体)继续执行被中断的计算。
其中最关键的一条语义是:一个 handler 的类型签名应当体现"能力保留"——'{e, Trivial} a -> {e} a表示该 handler 只处理Trivial,而把e代表的其他能力原样留给外层。这正是 fix2297 的核心检查点。
3. 逐行解读 fix2297 的测试代码
测试主体是fix2297.md第 4 行开始的unison :error代码块,完整内容如下:
structural ability Trivial where trivial : () -- This handler SHOULD leave any additional effects alone and unhandled handleTrivial : '{e, Trivial} a -> {e} a handleTrivial action = h : Request {Trivial} a -> a h = cases {trivial -> resume} -> handle !resume with h {a} -> a handle !action with h testAction : '{Exception, IO, Trivial} () testAction = do printLine "hi!" trivial wat : () wat = handleTrivial testAction -- Somehow this completely forgets about Exception and IO > handleTrivial testAction下面逐段拆解。
3.1 定义一个"什么都做不了"的能力
structural ability Trivial where trivial : ()Trivial是一个极简的结构化能力:只有一个操作trivial,且不携带任何参数、不产生返回值(()是 unit 类型)。之所以叫"Trivial",是因为它纯粹是为了制造一次能力请求而存在,方便聚焦 handler 行为本身。类似的最小能力还出现在同仓库的 unison-src/transcripts/idempotent/fix1696.md(Ask/Zoot)等测试中。
3.2 handler:应当只处理 Trivial,放行其余能力
-- This handler SHOULD leave any additional effects alone and unhandled handleTrivial : '{e, Trivial} a -> {e} a handleTrivial action = h : Request {Trivial} a -> a h = cases {trivial -> resume} -> handle !resume with h {a} -> a handle !action with h- 类型签名
'{e, Trivial} a -> {e} a是这段代码的灵魂。它明确承诺:输入动作可能使用Trivial加上e中的任意能力;输出时Trivial被处理掉(从集合中消失),但e中的能力(比如IO、Exception)必须被保留并暴露给调用方。注释SHOULD leave any additional effects alone and unhandled直接点明了这个预期。 h : Request {Trivial} a -> a是处理函数。Request {Trivial} a是 Unison 内部表示"能力请求挂起点"的类型:当trivial被调用时,计算会暂停并构造一个请求,交给h决定如何继续。- 模式匹配的两个分支:
{trivial -> resume} -> handle !resume with h:收到trivial请求后,取出续体resume,用!resume激活它,并继续用同一个h包裹(递归地处理后续请求)。这是"原地响应"的标准写法。{a} -> a:兜底分支,直接把纯值a原样返回。
handle !action with h:启动动作action,并把所有Trivial请求交给h。
从源码结构看,handleTrivial的实现是教科书式的"递归续体处理",本身没有问题;问题出在使用它的位置(第 3.4 节)。
3.3 测试动作:同时需要三种能力
testAction : '{Exception, IO, Trivial} () testAction = do printLine "hi!" trivialtestAction的类型是'{Exception, IO, Trivial} (),即它需要三个能力:
Exception:用于抛出/捕获异常;IO:因为printLine(打印一行文本)是 IO 操作,它在 base 库中的签名需要{IO}能力;Trivial:因为调用了trivial。
do块依次执行printLine "hi!"和trivial。这个动作既是"体验 handler 的目标",也是"检验能力保留的标尺"——如果 handler 正确处理,那么handleTrivial testAction应当把Trivial消化掉,但仍然需要{Exception, IO}才能运行。
3.4 出错点:wat 处的错误
wat : () wat = handleTrivial testAction -- Somehow this completely forgets about Exception and IO > handleTrivial testActionwat的类型是(),而右侧handleTrivial testAction的类型应当是{Exception, IO} ()(Trivial被 handler 消除,e实例化为{Exception, IO})。wat : ()与这个类型不匹配——一个没有能力要求的纯值位置,无法容纳一个需要{IO}的计算。注释Somehow this completely forgets about Exception and IO描述的正是历史 bug 的表现:错误地认为经过handleTrivial后所有能力都被清空。
最后一行> handleTrivial testAction是 transcript 中常见的"运行查看"指令,用来把表达式的值打印出来;但由于类型错误,整个代码块在类型检查阶段就被拦下,不会真正执行。
4. 期望的错误输出与类型检查器的判定逻辑
fix2297.output.md中记录了 UCM 实际生成的输出,关键错误信息是:
The expression in red needs the {IO} ability, but this location does not have access to any abilities. 19 | wat = handleTrivial testAction -- Somehow this completely forgets about Exception and IO这条消息不是随手的文案,而是由类型检查器的AbilityCheckFailure(能力检查失败)路径精确生成的。在 parser-typechecker/src/Unison/PrintError.hs 中,该分支的渲染逻辑为:
- 若被请求的能力集合只有一个元素,输出
"needs the {" <> e <> "} ability,"; - 若有多个,则输出
"needs these abilities: {…},"; - 若当前位置可用的能力集合为空,输出
"this location does not have access to any abilities."(第 647 行); - 若不为空,则输出
"this location only has access to the {…} ability,"之类的描述。
对照本测试:表达式wat = handleTrivial testAction需要{IO}(Exception与Trivial已被处理或并入上下文),而wat被注解为(),所在位置没有任何能力可访问,于是报出与.output.md完全一致的文案。
这里值得强调的是检查发生的位置:错误被定位在wat的定义处(第 19 行),而不是handleTrivial内部。这说明类型检查器认可handleTrivial的签名——'{e, Trivial} a -> {e} a是合法且正确的"能力保留型" handler;真正被拒绝的,是使用方试图在一个无能力环境下消费一个仍需要{Exception, IO}的表达式。换句话说,回归测试确认的是:能力的"传递性"和"保留性"都被正确地纳入了检查范围,没有被静默吞掉。
5. transcript 机制::error与:added-by-ucm如何把"报错"变成断言
fix2297.md的代码块带有unison :error标记,而输出块带有ucm :added-by-ucm标记。这套机制位于 transcript 测试框架中,理解它才能明白"这个测试为什么会自动失败"。
5.1 标签解析:Parser.hs
在 unison-cli/src/Unison/Codebase/Transcript/Parser.hs 中:
- 第 203–206 行:
formatExpectingError把布尔值渲染为:error,expectingError解析代码块开头的:error标记,得到"该块预期出错"的标志; - 第 215–218 行:
formatGenerated/generated对应:added-by-ucm标记,表示该块不是手写的期望输出,而是由 UCM 在首次运行时自动追加的(fix2297.output.md正是这样生成的)。
5.2 执行与断言:Runner.hs
在 unison-cli/src/Unison/Codebase/Transcript/Runner.hs 中:
- 第 400–432 行的
startProcessedBlock统一处理三种代码块(Unison、API、Ucm),对每一种都会把allowErrors写为expectingError infoTags(第 408/420/428 行)——即"这个块允许报错"; - 对
Unison块,还会通过updateVirtualFile sourceName txt把代码写入内存中的scratch.u,触发 UCM 的UnisonFileChanged事件,从而运行类型检查(第 410–416 行);sourceName默认就是scratch.u,这也解释了错误输出里Loading changes detected in scratch.u.的来源; - 结束时若某个
:error块没有产生错误,Runner 会报告"expecting an error in the stanza above, but did not encounter one"(第 542 行附近),测试失败。
因此fix2297.md的语义是:这段代码必须产生类型错误。假如未来某次改动让类型检查器放行了wat = handleTrivial testAction(bug 回归),transcript 测试会立刻红掉——这正是回归测试的意义。
6. 同类场景:能力检查失败的其他回归案例
fix2297不是孤例。仓库中同一目录及unison-src/transcripts/idempotent/下有多份以能力检查为主题的回归测试,可以作为对照阅读:
- unison-src/transcripts/idempotent/fix1696.md:定义
Ask/Zoot两个能力,Ask.provide声称只用Ask处理Zoot请求,却在dialog处报出needs the {Zoot} ability。它与 fix2297 共享"handler 处理能力 A 时必须保留能力 B"的检查路径,区别在于一个用IO/Exception(来自 base),一个用自定义能力。 - unison-src/transcripts/idempotent/fix2238.md:以
Abort能力为例,在同一文件多处报needs the {Abort} ability。 - unison-src/transcripts/idempotent/blocks.md:在
SpaceAttack场景下验证能力可用性报错。
这些案例的共同点是:报错文案都由AbilityCheckFailure统一渲染(parser-typechecker/src/Unison/PrintError.hs),且都通过:error标签把"拒绝类型不安全代码"固化为持续运行的断言。
7. 小结:能力保留是 Unison 类型安全的地基
从fix2297这 25 行的回归测试可以提炼出三条对 Unison 开发者有直接价值的结论:
- handler 签名要显式写出能力保留:
'{e, Trivial} a -> {e} a这类"集合变量 + 固定能力"的签名,是声明"只处理指定能力、其余放行"的标准范式,也是类型检查器判断 handler 行为是否安全的主要依据。 - 使用方同样受检:即使 handler 写得正确,调用处若在能力不足的位置使用结果表达式,仍会被
AbilityCheckFailure拦截;fix2297验证的正是"IO/Exception不被静默遗忘"。 - transcript 是测试能力语义的利器:借助
unison :error与:added-by-ucm标签(解析见 unison-cli/src/Unison/Codebase/Transcript/Parser.hs,执行见 unison-cli/src/Unison/Codebase/Transcript/Runner.hs),编译器团队可以像测试普通行为一样测试"编译器应当拒绝什么",从根本上防止能力系统在演进中悄悄退步。
如果你在 Unison 中编写自定义 handler,请记住 fix2297 的教训:用{e, YourAbility}形式保留未知能力,并让调用方的能力约束自然流动——类型检查器会替你守好最后一道门。
- 编程语言
- 编译器
- 语言运行时
- 开发工具
【免费下载链接】unison
A friendly programming language from the future
相关推荐
深入解析 Pandoc `$...$` 行内公式解析:从 `9193` 回归测试看换行保留与 `%` 注释
深入解析 Pandoc $...$ 行内公式解析:从 9193 回归测试看换行保留与 % 注释 导读 在 Pandoc 中,用 $...$ 包裹的内容会被识别为
文档开发工具CLIPandoc 命令测试与 GFM 有序列表起始编号保留:从 test/command/7009.md 看回归测试与列表渲染实现
Pandoc 命令测试与 GFM 有序列表起始编号保留:从 test/command/7009.md 看回归测试与列表渲染实现 导读 本文以 pandoc 仓库
文档开发工具CLIPandoc 将 HTML 自动链接转 LaTeX 时如何保留百分号编码 URL:回归测试 5340 全解析
Pandoc 将 HTML 自动链接转 LaTeX 时如何保留百分号编码 URL:回归测试 5340 全解析 导读 本文以 pandoc 仓库中的回归测试 te
文档开发工具CLI
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考