断言焦虑
大多数 production codebases 都会分成两派。A 派把 assert 当成装饰性调料,每隔一行就撒一点,直到函数读起来像个偏执律师写的法律合同。B 派把断言当作只在开发阶段使用的辅助轮,构建时全部剥离,然后祈祷代码能在生产环境跑起来,因为测试曾经通过过一次。
两派都错了。问题不在于要不要写断言,而在于断言到底意味着什么。
断言不是错误处理。不是输入校验。不是客气的建议。断言是在声明某件事不可能发生。 如果断言触发了,说明你对程序的心智模型已经崩坏。这个区别决定了断言该放在哪里、你该写多少个。
断言是为 Invariants 服务的,不是为错误
当用户给你的 API 传入一个负数的年龄,这叫错误。错误是预期之中的。错误值得被真正处理、记录日志、并给出面向用户的提示。当你的内部计算在一次本该成功的查询之后,得出了一个负数的数据库行数,这叫 invariant violation。这种事永远不该发生。这才是断言存在的意义。
这听起来显而易见,直到你真正去读 production code。我见过这样的函数:先断言某个字符串非空,三行之后又检查 if (!str) 并抛出一个格式化异常。开发者对同一个条件使用了两种工具,因为他们从来没决定哪个才是真正的 contract。
规则如下。如果某个条件可能被外部输入触发,那它就不是断言。 如果它只能被你自己的代码中的 bug 触发,那它就是断言。
def process_payment(user_id: str, amount_cents: int) -> Receipt:
# NOT an assertion. Users or upstream services can send bad data.
if amount_cents <= 0:
raise ValueError("amount_cents must be positive")
# NOT an assertion. The user_id comes from the outside world.
if not user_id:
raise ValueError("user_id is required")
receipt = _charge_card(user_id, amount_cents)
# THIS is an assertion. If charge_card returned None after
# succeeding, our understanding of the universe is wrong.
assert receipt is not None, "charge_card succeeded but returned None"
# THIS is an assertion. A receipt with zero items after a
# successful charge means our internal logic is broken.
assert len(receipt.items) > 0, "receipt has no items after successful charge"
return receipt
前两个检查守卫的是 boundary。后两个守卫的是系统的内部一致性。把它们混在一起,会让人搞不清到底该谁负责什么。
三个断言的上限
如果你发现自己在单个函数里写了超过三个断言,那你只有两种可能的问题:要么你的函数做了太多事,要么你的 invariants 太模糊、根本无法强制执行。
一个带有十二个断言的函数不是防御性的,它是没底气的。作者不相信调用它的代码、不相信它调用的代码、也不相信它们之间流动的数据。这种不确定性应该通过 refactor 来解决,而不是加更多 assert 语句。
实际的限制来自于开发者能在脑子里同时容纳多少东西。一个函数应该有一个清晰的 contract。这个 contract 意味着少量 invariants。如果你需要一打断言才能安心,那你的函数很可能吸走了本该属于别处的职责。
拆分这个函数。把数据转换的部分抽出来。把调用外部服务的部分抽出来。让每个被提取出来的函数拥有自己的一小套 invariants。每个函数三个断言是警示灯。五个就是爆胎。
生产环境中的断言:开还是关?
不同语言做出了不同选择。Python 在你使用 -O 标志运行时会剥离 assert 语句。C 和 C++ 编译器通常会在 release build 中移除断言。JavaScript 根本没有内置的 assert。你要么用 polyfill,要么用一个在生产环境保持活跃的库。
这制造了一个真正的两难困境。如果你剥离断言,你会在最需要安全网的时候失去它。只在生产环境出现的 bug 会悄无声息地损坏数据,而不是快速失败。如果你保留它们,你又可能因为一个理论上不可能、但实际上并不致命的条件而让整个生产进程崩溃。
答案取决于继续执行的代价。如果违反 invariant 意味着下一个操作会损坏数据库或泄露敏感数据,那断言应该让这个进程崩溃。 硬停止比 silent breach 更好。如果违反 invariant 只意味着一条稍微错误的日志条目或一个轻微的 UI glitch,那就记录日志并继续。
// This should probably crash. Continuing with a null user
// after auth succeeded is a security hole waiting to happen.
assert(user !== null, "auth middleware returned null user after success");
// This should probably not crash. A stale cache timestamp
// is annoying but not dangerous.
if (cache.timestamp > Date.now()) {
logger.warn("cache timestamp is in the future, ignoring");
}
不是每个 invariant 都值得同样的姿态。学会分辨”这必须停下来”和”这很奇怪但还能忍”之间的区别。
我们试过的、但没用的做法
在一个项目早期,我们试过对每一个函数前置条件都写断言。每个参数都要检查 null、type、range 和 format。结果是可预见的。测试漂亮地通过了。生产环境在第三方 API 第一次把某个字段从 number 变成 string 时就崩溃了。
问题不在于断言本身。问题在于我们对不受我们控制的数据做了断言,然后在生产环境编译时启用了断言。一个 malformed 的外部响应直接终止了我们的进程,而不是被清理和处理掉。我们构建了一个 internally consistent 但 externally fragile 的系统。
我们学会了把 boundary 和内部隔离开。在 boundary 上,严格地 parse 和 validate。把外部 chaos 转化为内部 certainty。在 boundary 内部,断言那些定义这种 certainty 的 invariants。断言留了下来。输入校验则转移到了显式的 parsing 函数中,这些函数返回 Result 类型而不是抛出异常。
实用检查清单
在添加断言之前,先过一遍这个清单:
- 外部输入可能触发这个条件吗? 如果是,用 validation,不要用 assertion。
- 如果这个断言在生产环境触发,进程应该停止吗? 如果否,改成记录 warning。
- 这个函数里已经有三个或更多断言了吗? 如果是,在加下一个之前先考虑 refactor。
- 六个月后再有人读这段代码时,这个断言还能说得通吗? 晦涩的断言会在 refactor 中被删掉。清晰的才能活下来。
断言既是安全工具,也是沟通工具。它们告诉下一个开发者:“这个条件在设计上不可能发生。” 如果这个条件实际上在设计上并非不可能,那断言就是在撒谎。而在 production code 里,撒谎的代价是昂贵的。
常见问题
我应该对函数参数做断言吗?
只有当调用者也是你自己的代码、并且参数是内部逻辑的产物而非外部输入时,才可以。Public API 函数应该做 validate。Private helper 函数可以对接收到的值断言其 invariants。
那 TypeScript 呢?它在编译时就能捕获 null。
TypeScript 的 type system 是一个强大的 assertion layer,但它在运行时就会消失。把它用在编译器能证明的一切事情上。在缺口处补充运行时断言:API responses、反序列化后的数据,以及任何绕过类型检查器的 as cast。
断言会影响性能吗?
在大多数语言中,一个放置得当的断言只消耗 microseconds。如果你在处理数百万条数据的紧凑循环内部做断言,把它移到循环外面。在 batch 层面检查 invariant,而不是对每个元素都检查。
我应该写自定义的 assert 函数吗?
只有当内置的断言信息不够有用时才需要。一个能打印出实际数组长度的自定义 assertNonEmpty,比那种不带任何上下文就直接崩溃的通用 assert len(items) > 0 更有用。保持它们小巧。不要构建一个 assertion framework。