Python 的 assert 为什么不能用来校验参数?拆开 -O 模式与那行会被整段删掉的代码
先看一段很像「标准写法」的代码:
def divide(a, b):
assert b != 0, "除数不能为 0"
return a / b
用 python main.py 跑,传 b=0 会抛 AssertionError: 除数不能为 0,看着挺稳妥。
用 python -O main.py 跑,这一行根本不存在。传 b=0 会抛 ZeroDivisionError,连那句提示都没了。
如果这段代码负责的是校验用户输入、检查接口参数、判断权限,那么 -O 一开,校验就整段消失,而且不会有任何报错告诉你它消失了。这不是概率问题,是设计如此。
一、assert 是语句,不是函数
assert 是 Python 语法里的语句,跟 if、return 同级,所以它不能被赋值、不能当参数传、也不能加括号当函数调用。
它的语法是:
assert 表达式, 错误信息
大致等价于:
if __debug__:
if not 表达式:
raise AssertionError(错误信息)
等价写法里那个 __debug__ 是关键。它是解释器内置的一个常量,正常启动为 True,加 -O(或环境变量 PYTHONOPTIMIZE=1)就变 False。而且你没法在代码里改它 —— 给 __debug__ 赋值会直接 SyntaxError。
二、决定权在编译期,不是运行期
很多人以为 -O 是「运行时跳过断言」,其实更彻底:编译器压根不生成那段字节码。
用 compile(..., optimize=0/1) 分别编译同一个函数,再反汇编(CPython 3.13.14 实测):
import dis
src = 'def f(x):\n assert x > 0, "must be positive"\n return x\n'
for opt in (0, 1):
code = compile(src, '<s>', 'exec', optimize=opt)
print('optimize =', opt)
dis.dis(code.co_consts[0])
optimize=0 的函数体,字节码 32 字节:
LOAD_FAST 0 (x)
LOAD_CONST 1 (0)
COMPARE_OP
POP_JUMP_IF_TRUE -> L1
LOAD_ASSERTION_ERROR
LOAD_CONST 2 ('must be positive')
RAISE_VARARGS 1
L1: LOAD_FAST 0 (x)
RETURN_VALUE
optimize=1 的函数体,字节码只剩 6 字节:
LOAD_FAST 0 (x)
RETURN_VALUE
断言、比较、错误信息、raise,全没了。ASSERTION_ERROR 连常量表都没进。
这件事有两个直接后果:
- 零开销:生产环境开
-O,断言不花一个指令周期,这是它被设计成这样的初衷。 - 表达式整体不求值:断言里如果写了函数调用、
x.pop()、自增这类有副作用的表达式,-O下它们一次都不会执行。把业务逻辑塞进 assert,等于赌用户永远不会开优化。
顺便说 -OO:它在 -O 基础上还会删掉文档字符串,__doc__ 会变成 None。依赖 docstring 的框架(部分 CLI / ORM)在 -OO 下会出问题。
三、AssertionError 能被 except 接住,这更麻烦
try:
assert user.is_admin, "无权限"
except AssertionError:
pass # 悄悄放行
AssertionError 是 Exception 的普通子类,可以被捕获、被忽略。相比之下,权限校验一般会用专门的异常类型,或者干脆返回错误响应,不会被一句宽泛的 except 顺手吞掉。
再叠加上「-O 下异常根本不抛」,同一段校验代码在不同启动参数下可能表现成三种结果:正常拦截、被捕获放行、压根不检查。这种不确定性不该出现在安全边界上。
四、正确姿势:校验一律显式 raise
def divide(a, b):
if b == 0:
raise ValueError("除数不能为 0")
if not isinstance(a, (int, float)):
raise TypeError("a 必须是数字")
return a / b
if + raise 不受 -O 影响,异常类型可控,信息能带进日志,也不会被 except AssertionError 误吞。
参数校验、权限判断、外部输入检查、文件存在性、配置合法性 —— 这些都属于程序运行时的正常分支,哪怕断言写起来更短,也必须用显式 if。判断标准很简单:如果这件事在「正确的程序」里也可能发生,就不能用 assert。
五、那 assert 到底该用在哪儿
它适合表达「这件事按理不可能发生,真发生了就是我的代码写错了」这类内部不变式:
def binary_search(arr, lo, hi, target):
...
assert 0 <= mid < len(arr), "索引越界,算法有 bug"
def parse(tokens):
result = build(tokens)
assert result is not None # build 的契约:永远返回对象
return result
还有两个常见用途:给静态检查器收窄类型(assert isinstance(x, str) 帮 mypy 推断)、在算法中间步骤验证推导没走偏。
这些用法的共同点是:删掉它们,正确程序的行为完全不变。-O 关掉它们正是设计意图,不是副作用。
六、三个额外的坑
| 坑 | 现象 | 说明 |
|---|---|---|
assert (x > 0, "oops") |
断言永远为真,从不抛错 | 括号里是个非空元组,真值恒为 True;CPython 3.13 会直接给 SyntaxWarning: assertion is always true |
| 断言里带副作用 | -O 下副作用消失 |
如 assert cache.set(k, v),优化模式下这行一次都不执行 |
| 拿 assert 做单元测试 | 测试脚本被 -O 跑,断言全空 |
pytest 自己会重写 assert,但裸 python -O 跑脚本不行,测试请用 unittest / pytest |
第一条尤其常见,见到「断言明明该失败却没反应」,先检查是不是多写了一对括号。
七、对照表
| 场景 | 用 assert | 用 if + raise |
|---|---|---|
| 校验函数入参 | 不该 | ✅ |
| 检查用户输入 / 权限 | 不该 | ✅ |
| 文件、网络、配置是否可用 | 不该 | ✅ |
| 内部算法不变式 | ✅ | 可以但啰嗦 |
| 给类型检查器收窄类型 | ✅ | 不必 |
| 生产环境零开销 | ✅ | 有极小开销 |
八、一句话记住
assert 是写给开发者的调试声明,不是写给用户的错误检查。-O 会在编译期把它整段删掉,所以任何程序正确性依赖的检查,都必须显式 if + raise。
下次写下 assert 之前,问自己一句:这行被删掉,程序还能正确拒绝非法输入吗?答案是否,就换成 raise。
(实测环境:CPython 3.13.14,Windows;文中 dis 输出与字节码长度均可直接复现)

浙公网安备 33010602011771号