你必须修复你的断言

Lobsters Hottest 工具

摘要

本文认为在生产环境中禁用断言是一种不好的实践,以Zig的断言机制为例,说明即使在生产版本中也应保持断言启用以捕获编程错误的好处。

<p><a href="https://lobste.rs/s/ycplhh/you_must_fix_your_asserts">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/05/31 14:20

# 你必须修复你的断言 来源:https://kristoff.it/blog/fix-your-asserts/ 恐惧是心灵的杀手……也是代码库的杀手。 一位用户在讨论平台上写道: > 我认为“在生产环境中禁用断言”是一种相当常见的做法,对吧? 据我所知,这说法可能没错,但**我认为这是一种不可救药的坏习惯**。我们先来点背景介绍,因为这场讨论源于 Zig 中 `std.debug.assert` 的工作方式。 ## 泛论断言 断言是一行代码,它为程序引入一个新的事实,比如“这个参数永远不可能是 null”,或者“这个整数永远不可能是偶数”,它们看起来像这样: ``` assert(my_arg != null); assert(my_num % 2 != 0); ``` 如果你的类型系统可以强制实施这些约束之一,那么你可能更愿意使用语言提供的机制,而不是断言。 例如,在 Zig 中,普通指针(如 `*Foo`)永远不可能是 null,而可选指针(`?*Foo`)可以为 null,但它们也强制你在访问值之前进行检查(Zig 为此有专门的惯用法)。 断言可用于显式声明代码中的前置/后置条件以及不变量。这很有用,因为如果你选择了合适的断言,它们就能比单元测试更好地保护你免受编程错误的影响,尤其是当你对代码进行模糊测试时。 **一个断言抵得上一千个单元测试**(如果进行模糊测试,则更是高出数个数量级),但这是后续文章的话题了。 ## Zig 中的断言 Zig 中的断言基于 `unreachable`,这是一个标记无效代码路径的语言特性。 ``` const Op = enum { a, b, c }; fn execute(orig_op: Op) void { var op = orig_op; if (op == .a) { op = .b; // 将 .a 转为 .b } const op_cost = switch(op) { .a => unreachable, // 不可能到达 .b => 50, .c => 100, }; // 最终处理 op } ``` 在这个例子中,`.a` 情况总是会被 `if` 语句变成 `.b` 情况,这意味着,一旦我们到达 `switch`,就不可能进入 `.a` 情况。 `unreachable` 的另一个简洁特性是,它不仅可以作为语句使用,而且在任何期望表达式(任何类型)的位置也同样有效。 在上面的例子中,我们正在计算操作的“成本”,而 `.a` 可能根本不需要有关联的成本。多亏了 `unreachable`,我们甚至不必为不可能发生的情况编造一个尴尬的占位符值。 Zig 的标准库断言函数也利用了 `unreachable`,实现如下: ``` pub fn assert(ok: bool) void { if (!ok) unreachable; // 断言失败 } ``` ### 构建模式 Zig 有多个构建模式: - Debug - ReleaseSafe - ReleaseFast - ReleaseSmall 这个设置不一定全局适用于你的程序:每个依赖都可以用不同的模式构建,你甚至可以使用 `@setRuntimeSafety` 在单个函数内实现块级别的粒度。 当断言触发时,会发生“非法行为”。检查模式(Debug、ReleaseSafe、`@setRuntimeSafety(true)`)会通过恐慌保证程序崩溃,而未经检查的模式(ReleaseFast、ReleaseSmall、`@setRuntimeSafety(false)`)会导致“未经检查的非法行为”。 简而言之,未经检查的非法行为意味着程序会出现异常行为。 在这个特定例子中,今天发生的情况是,由于机器码的生成方式,为 `op_cost` 赋值的 `switch` 语句会“落入”其他某个分支。但这并不保证,编译器的不同版本可能生成导致不同异常行为的机器码。 这里有一个 godbolt 链接,你可以亲自看看。 这是一个锋利的工具,但它正是许多强大优化的动力来源,例如在我们的例子中,实现 `switch` 语句第一个分支所需的机器码实际上已从最终可执行文件中省略了。 这里有另一个 godbolt 链接,你可以看到断言如何与后续的 `switch` 语句在 ReleaseSafe 和 ReleaseFast 中交互(注意在 ReleaseFast 中,函数跳过了所有比较,直接返回 `true`)。 这就是视频游戏和其他实时媒体应用严重依赖的那种东西。 并非每个断言都会带来性能提升,但优化编译器有能力传播 `unreachable` 信息,从而产生你可能难以轻易预见的非局部优化。 ### Zig 断言不是宏 接触 Zig 时,一件特别让 C/C++ 开发者惊讶的事情是 `std.debug.assert` 不是宏(并且告诉你,Zig 没有宏)。 在那些语言中,通常禁用断言的方式实际上就像将每个对 `assert` 的调用都注释掉了一样,包括传递给宏的任何表达式。 这意味着在 C/C++ 中,你永远不应该将带有副作用的表达式放入 assert 调用中,因为当断言被禁用时,整个操作都会被注释掉。 在 Zig 中,这根本不是问题,因为 `std.debug.assert` 是一个普通函数,这意味着无论函数内部逻辑如何,其参数都会在调用之前被求值。 结果是,你可以放心地在 assert 调用中放入带有副作用的表达式: ``` // 断言 remove 操作不是空操作: assert(my_map.remove("expected-to-exist")); ``` 另一方面,这也意味着如果你有一个依赖复杂计算的断言,那么在未经检查的模式下构建时,这些计算不一定会被省略,在这种情况下,你需要小心地用 comptime `if` 来保护代码: ``` const builtin = @import("builtin"); if (builtin.mode == .Debug) { var condition = ...; // 任何必要的簿记工作 // 来计算条件 assert(condition == .ok); } ``` 如果你习惯了 C/C++ 的语义,这可能会让人惊讶,但同时,如果你是一位经验丰富的开发者,你应该最终能够理解函数调用语义。 这是一个摆脱一些宏引发的创伤后应激障碍并拥抱简单性的好机会,尤其是在 Zig 中你通常不会禁用断言,这引出了本文的主要观点。 ## 在生产环境中禁用断言 回顾一下,你可以对断言做三件事: 1. 将它们保留为运行时检查,在触发时使进程崩溃。 2. 利用它们进行性能优化,代价是如果断言出错,程序会出现异常行为。 3. 完全禁用它们。`std.debug.assert` 默认不支持此操作,但你可以实现自己的版本,在内部检查一个构建时标志,近似于 C/C++ 的行为[^1]。 正如我在开头提到的,我认为 (3) 是一种不可救药的糟糕选择。 想要禁用断言的原因是什么?本质上是另外两种情况的反面组合: 1. 你不想保留运行时检查,要么是因为性能开销,要么是因为你不希望应用程序崩溃。 2. 你不想利用断言进行优化,因为你不相信它们是正确的,因而害怕程序异常行为。 正如 asmatklad 在最近关于该主题的讨论中提醒我的那样,可能存在某些情况,你有合理的工程理由想要避免崩溃,但就一般软件而言,在我看来这是一个相当糟糕的默认选择。 禁用断言意味着,当那些被认为不可能的条件实际上发生时,程序会继续运行而不是崩溃。所以现在你有一个在错误假设下继续运行的程序,这仍然是一种异常行为,即使不是由上述未经检查的非法行为(UIB)引起的。 天真的内存安全倡导者可能会争辩说,UIB(或者 C 语言中的未定义行为)**要糟糕得多**,但我不同意。 UIB 之所以危险,是因为它是将程序变成**怪异机器**的途径,但在足够复杂的软件中,你并不一定需要 UIB 才能将程序扭曲成怪异机器。在运行时伪造断言,按定义就是偏离了规范,并且很容易让程序执行从未打算执行的操作。 这不仅仅是技术上的吹毛求疵:SQL 注入就是一个具体且广泛的怪异机器级异常行为示例,它不需要 UIB。 如果程序异常行为的代价高到你不愿冒险,那么你应该保持断言开启;如果性能重要到你愿意冒异常行为的风险,那么你只是在白白浪费性能,同时却以为自己比实际更安全。 但还有一个更大的原因,说明系统地禁用所有生产环境的断言是适得其反的。 ## 自我欺骗 回顾一下,这里的核心问题是断言可能错误以及由此产生的后果。如果我们能保证所有断言总是为真,那么始终利用它们进行性能优化将毫无争议。同样,如果我们能保证测试能够捕获所有错误的断言,那么生产环境也总能安全地优化。 你正在阅读这篇文章的原因,是我们知道我们可能会写出错误的断言,而且无法保证测试能捕捉到它,这不仅仅是假设。有很多项目都有通过测试但在生产环境中触发的断言,我可以想到一个最近离开 Zig 生态系统的备受瞩目的例子。 如果你处于这种情况,那么你最应该做的就是尽快发现代码中所有错误的断言,**因为否则你会继续编写更多错误地依赖那些错误断言的代码,使问题恶化**。 想象一个大型代码库中有这样一段代码: ``` fn processThing(thing: Thing) void { // 此函数必须始终在 // 已经启动的 thing 上调用 assert(thing.is_started); // ... } ``` 一段时间过去了,这个断言似乎成立,因为它在测试中从未触发,而且你从未发现这个断言实际上是可伪造的,因为你禁用了生产环境中的断言。但也许情况没那么糟,没有用户报告生产环境中的异常行为,日子照常过。 过了一段时间,有人在下面添加了更多代码: ``` fn processThing(thing: Thing) void { // 此函数必须始终在 // 已经启动的 thing 上调用 assert(thing.is_started); // ... // 既然 thing 已经启动,我们不需要 // 在 bazzing qux 之前 foo the bar。 // 否则 baz the qux 会很糟糕, // 所以我们添加一个断言以防万一。 assert(thing.is_fooed); thing.baz(qux); } ``` 假设第二位开发者编写了一个正确的断言(即 `thing` 已启动意味着在调用 `processThing` 时 `thing` 已经被 fooed),由于测试中第一个断言从未触发,第二个也从未触发,但讽刺的是,**这可能就是一个可利用的漏洞被引入代码库而你却没有意识到的时刻,因为你禁用了生产环境中的断言**。 **首先,编写正确的代码本来就很难,而当代码中包含实际上在欺骗你的断言时,要编写正确的代码就变得异常困难**。 ## 结论 根据不同的环境,不同的程序会有不同的优先级,对于某些程序来说,选择性能而非最小化异常行为风险是合理的决定,在这种情况下,将断言转化为优化机会是完全合理的。 常规性地禁用生产环境的断言,与保持断言开启并优化性能相比,都是次优选项。我发现人们不加批判地采用这种做法,同时又**极度**批评 ReleaseFast,这很荒谬。 我正在开发两个严肃的项目。 第一个是 **Zine**,一个静态站点生成器。我还没有为它定义威胁模型,而且这也不是我的首要任务,因为它目前主要用于构建个人博客。我也喜欢看到它比 Hugo 快一个数量级的运行速度,所以我发布 ReleaseFast 构建。 第二个是 **Awebo**,一个预 alpha 阶段、可自托管的 Discord 替代品。在这种情况下,我已经知道这是一个处理个人信息且旨在暴露于互联网的软件。时机成熟时,我会发布 ReleaseSafe 构建,但即便如此,我仍然会将一些关键依赖(FFmpeg、Xiph Opus、SQLite 等)构建为 ReleaseFast,因为对于它们来说,性能提升绝对比进一步降低程序异常行为风险更可取。 再举两个有趣的例子:**TigerBeetle**(金融数据库)始终保持断言开启,而 **Ghostty**(终端模拟器)为 macOS 分发 ReleaseFast 构建,并建议下游消费者(如 Linux 发行版维护者)也这样做。 有趣的是,迄今为止 Ghostty 发布的两个相对严重的 CVE 都是关于**在没有内存损坏**的情况下实现任意命令执行,尽管 Ghostty 是以 ReleaseFast 分发的。 - https://github.com/ghostty-org/ghostty/security/advisories/GHSA-4jxv-xgrp-5m3r - https://github.com/ghostty-org/ghostty/security/advisories/GHSA-5hcq-3j4q-4v6p 对于“在生产环境中禁用断言”这一普遍做法,我敢打赌有很多项目中的错误断言在代码库中滋生和蔓延,这既增加了对 UIB 的偏执,也让开发者潜意识里害怕重新开启断言并面对自己行为的后果。 现实是,别无他法:**你必须修复你那该死的断言**,并努力实现程序的正确性,而不仅仅是其一部分。 [^1]: 你可以使用 `@setRuntimeSafety(false)` 来禁用单个块或依赖中的安全检查,但无法将断言本身作为语言特性禁用。这是经过设计的,因为 Zig 的理念是运行时检查有助于快速发现错误,即使是在发布版本中。

相似文章

Ciao - 断言及其使用

Lobsters Hottest

本文档描述了Ciao Prolog系统中的断言语言,它允许使用类型和实例化模式声明来注解代码,用于调试、测试、优化和自动文档生成。

回归构建模块的构建模块

Lobsters Hottest

本文类比了C/C++中的安全漏洞与Verilog中的安全漏洞,指出硬件描述语言的设计导致了缺陷,并认为行业应投资于更安全的替代方案,类似于软件领域对内存安全编程语言的推动。

假设弱化性质

Hillel Wayne — Computer Things

本文探讨了为什么在规范或测试中添加假设会从逻辑上弱化所得性质,使用了逻辑蕴含以及来自形式化方法和 Rust 的示例。此外,还讨论了尽管存在这种弱化,仍使用假设的实际原因。

最小可行的Zig错误上下文

matklad

一篇博文,详细介绍了使用errdefer日志在Zig中添加错误上下文的最小模式,并将其与完整的诊断接收器(diagnostics sinks)和catch块进行比较,讨论了权衡取舍。