AIGC标识 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 连常量表都没进。

这件事有两个直接后果:

  1. 零开销:生产环境开 -O,断言不花一个指令周期,这是它被设计成这样的初衷。
  2. 表达式整体不求值:断言里如果写了函数调用、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 输出与字节码长度均可直接复现)

posted @ 2026-11-04 09:29  小小张说故事  阅读(5)  评论(0)    收藏  举报