Skip to content

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。

还有一个写法上的陷阱:

python
assert (amount > 0, "amount must be positive")     # 永远通过

加了括号之后,断言的对象变成了一个两个元素的元组,非空元组为真。实测 assert (1 > 2, 'msg') 执行通过,编译时给出 SyntaxWarning。这个警告只在编译时出现,之后从字节码缓存加载就看不到了,很容易被忽略。

二、异常是怎么被吞掉的 ​

finally 里的 return。

python
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(...) 保证离开代码块时文件被关闭,包括抛异常的时候。它不保证文件内容是完整的。

原地重写config.json完整的旧内容被清空,正在写入半份新内容:既不是旧的也不是新的open(path, 'w')中途抛异常临时文件 + os.replaceconfig.json完整的旧内容,全程没有被动过config.json.tmp正在写入出错:删除临时文件正常路径:临时文件写完 → flush 与 fsync → os.replace(tmp, path),替换是一次原子操作临时文件要和目标在同一个文件系统上,替换才是原子的
图 1 · 以写模式打开文件的那一刻,旧内容就被清空了;写到一半出错,with 会把文件关好,留下的却是半份新内容,旧的也回不来。先写到同目录的临时文件,全部写完并落盘之后再用 os.replace 换上去:任何时刻读到的要么是完整的旧文件,要么是完整的新文件

实测原地重写一个配置文件,写到一半抛出异常:

写法出错之后文件的内容
open(path, "w") 直接写{"version": 2, :半份新内容,旧内容已经没了
写临时文件,成功后 os.replace{"version": 1, "limit": 100}:完整的旧内容,没有残留临时文件

以 "w" 模式打开的那一刻,文件就被清空了。之后任何一步失败(序列化出错、磁盘满、进程被杀),留下的都是一个残缺的文件,下次启动时解析失败。

python
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。多个进程同时写同一个文件时,各自要用不同的临时文件名。

四、子进程失败了,没有异常 ​

python
subprocess.run(["some-tool", "--migrate"])       # 退出码 2,这一行正常返回

实测运行一个以退出码 2 结束的命令:subprocess.run 没有抛异常,只是 returncode 为 2;加上 check=True 才抛 CalledProcessError。脚本里一连串这样的调用,中间某一步失败了,后面的步骤照常执行,最后打印「完成」。

调用外部命令默认加 check=True,并设置 timeout。确实允许失败的命令,显式检查 returncode 并说明原因。

五、检查清单 ​

  1. 外部输入的校验不用 assert;在 CI 里用 python -O 跑一遍测试,可以找出依赖 assert 的地方。
  2. finally 里不写 return;不用裸 except;捕获之后至少记录。
  3. 把警告当回事:测试时用 -W error 让 SyntaxWarning 直接失败。
  4. 线程池里每个任务的结果都要有人取。
  5. 覆盖已有文件时用「临时文件加 os.replace」。
  6. subprocess.run 加 check=True 和 timeout。
  7. 为失败路径写测试:让依赖抛异常、让文件写到一半,看结果是不是预期的。

Java 里对应的一组问题,见出了错,日志里却没有可用的信息。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权