python -O 之后,参数校验全没了
withdraw(100, -50):开发环境抛出AssertionError,「金额必须为正」。生产环境的启动命令里多了一个-O,同一个调用返回 150,账户里凭空多了 50。代码一行没变。
assert amount > 0 看起来是一行校验,Python 把它当作调试用的语句:解释器带 -O 参数启动(或者设置了 PYTHONOPTIMIZE)时,所有 assert 在编译阶段被整条去掉。类似的还有几种写法,正常路径上完全没问题,只有出错的时候才显出差别:异常被吞掉了、文件写了一半、子进程失败了没人知道。
本文把这几种写法在 CPython 3.14.8 上各跑一遍。实验见文末。
一、assert 不是校验
| 校验的写法 | 普通模式 | python -O |
|---|---|---|
assert amount > 0, "…" | 抛 AssertionError | 返回 150 |
if amount <= 0: raise ValueError("…") | 抛 ValueError | 抛 ValueError |
-O 模式下 __debug__ 为 False,assert 语句不生成任何字节码。开发和测试时通常不带 -O,所以测试全部通过;有的镜像或启动脚本为了「优化」加上了它,校验就在生产环境消失了。
assert 的用途是表达「如果代码没有 bug,这里一定成立」的内部假设,帮助在开发时尽早发现错误。凡是外部来的东西都不能用它检查:请求参数、文件内容、用户权限、配置项、下游返回值。这些用 if 加 raise。
还有一个写法上的陷阱:
assert (amount > 0, "amount must be positive") # 永远通过加了括号之后,断言的对象变成了一个两个元素的元组,非空元组为真。实测 assert (1 > 2, 'msg') 执行通过,编译时给出 SyntaxWarning。这个警告只在编译时出现,之后从字节码缓存加载就看不到了,很容易被忽略。
二、异常是怎么被吞掉的
finally 里的 return。
def f():
try:
raise RuntimeError("lost")
finally:
return "ok"实测调用返回 'ok',异常消失了。finally 里的 return、break、continue 会丢弃正在传播的异常。Python 3.14 起这种写法在编译时给出 SyntaxWarning(PEP 765),更早的版本没有任何提示。finally 只放清理代码。
裸 except。
try 里调用 sys.exit(3) | 结果 |
|---|---|
except: | 被拦住,进程没有退出 |
except Exception: | 拦不住,SystemExit 继续传播 |
裸 except: 等同于 except BaseException:,连 SystemExit、KeyboardInterrupt 和 asyncio.CancelledError 都会拦下:进程退不出去、Ctrl+C 没反应、任务取消不掉。后者的后果见 asyncio.gather 里一个任务失败了,其他任务还在跑。要兜底就写 except Exception,并且至少记录异常;except Exception: pass 只在确实可以忽略、并且写了注释说明原因时使用。
线程池里没人取的结果。
| 写法 | 任务抛出 ValueError 之后 |
|---|---|
pool.submit(task),不调用 result() | 没有任何异常;异常留在 future.exception() 里 |
pool.map(task, items),不遍历结果 | 没有任何异常;开始遍历时才抛出 |
提交到线程池的任务,异常保存在 Future 里,取结果时才重新抛出。提交之后不管结果的任务,失败是完全无声的。要么收集所有 Future 并调用 result(),要么用 add_done_callback 记录异常。Java 的 ExecutorService.submit 也是这样:异常存在 Future 里,不调用 get() 就看不到。
三、with 保证关闭,不保证写完
with open(...) 保证离开代码块时文件被关闭,包括抛异常的时候。它不保证文件内容是完整的。
with 会把文件关好,留下的却是半份新内容,旧的也回不来。先写到同目录的临时文件,全部写完并落盘之后再用 os.replace 换上去:任何时刻读到的要么是完整的旧文件,要么是完整的新文件实测原地重写一个配置文件,写到一半抛出异常:
| 写法 | 出错之后文件的内容 |
|---|---|
open(path, "w") 直接写 | {"version": 2, :半份新内容,旧内容已经没了 |
写临时文件,成功后 os.replace | {"version": 1, "limit": 100}:完整的旧内容,没有残留临时文件 |
以 "w" 模式打开的那一刻,文件就被清空了。之后任何一步失败(序列化出错、磁盘满、进程被杀),留下的都是一个残缺的文件,下次启动时解析失败。
def write_atomic(path, content):
tmp = path + ".tmp"
try:
with open(tmp, "w", encoding="utf-8") as f:
f.write(content)
f.flush()
os.fsync(f.fileno()) # 内容落盘之后再替换
os.replace(tmp, path) # 同一个文件系统内是原子的
finally:
if os.path.exists(tmp):
os.remove(tmp)os.replace 把临时文件换到目标位置,读的一方看到的要么是旧文件,要么是新文件。临时文件要建在目标文件的同一个目录里,跨文件系统的替换不是原子的。实验只模拟了「写入过程中抛异常」;进程被杀或断电时的保证还取决于 fsync 和文件系统,对持久性要求更高时,替换之后还要对所在目录做一次 fsync。多个进程同时写同一个文件时,各自要用不同的临时文件名。
四、子进程失败了,没有异常
subprocess.run(["some-tool", "--migrate"]) # 退出码 2,这一行正常返回实测运行一个以退出码 2 结束的命令:subprocess.run 没有抛异常,只是 returncode 为 2;加上 check=True 才抛 CalledProcessError。脚本里一连串这样的调用,中间某一步失败了,后面的步骤照常执行,最后打印「完成」。
调用外部命令默认加 check=True,并设置 timeout。确实允许失败的命令,显式检查 returncode 并说明原因。
五、检查清单
- 外部输入的校验不用
assert;在 CI 里用python -O跑一遍测试,可以找出依赖assert的地方。 finally里不写return;不用裸except;捕获之后至少记录。- 把警告当回事:测试时用
-W error让SyntaxWarning直接失败。 - 线程池里每个任务的结果都要有人取。
- 覆盖已有文件时用「临时文件加
os.replace」。 subprocess.run加check=True和timeout。- 为失败路径写测试:让依赖抛异常、让文件写到一半,看结果是不是预期的。
Java 里对应的一组问题,见出了错,日志里却没有可用的信息。
配套实验
- codesphere-labs/engineering/python-failure-paths:CPython 3.14.8 普通模式与
-O模式下的assert、finally里的return、裸except、原地写与原子替换、子进程返回码、线程池里的异常(验证记录)
参考资料