JavaLYG

Python开了-O后参数校验消失:用显式异常替代assert守门

同一份参数检查,直接运行会报错,加上 -O 却放过非法值。这里用 Linux 上的 CPython 3.14.3 对照默认、-O 和 -OO,定位 assert 消失的原因,再把入口改为显式异常,核对拒绝与放行两条路径。

一、先分清报错消失和问题解决

例如数量必须是正整数,入口用断言挡住零。启动参数一变,零却走到后面。这不是输入自动变合法,也不是异常被修复。解释器可能根本没有生成那条断言对应的代码。先核对实际启动命令,再看条件表达式,比追着日志猜原因有效。

本例只验证本机语言行为和命令行退出结果,不连接网络,不修改生产配置。它不是一次真实 Web 请求、授权检查或生产发布验收。拒绝样本得到报错,只能证明这条测试路径按预期拒绝,不能证明业务已经安全。

二、assert 是调试断言,不是必经关卡

官方把 assert 定义为插入调试断言的便捷方式。通常运行时,条件为假会抛出 AssertionError;带消息的形式还会计算消息表达式。条件为真时,消息不会求值。因此,不能靠消息函数承担正常流程里的操作。

在这里的 CPython 实现中,优化编译会删除整条 assert,包括条件和消息表达式。把计数、写文件、扣减库存、提交记录藏在里面,优化后这些动作都可能不发生。断言应描述供开发期检查的内部假设,不负责业务参数校验、权限授权或必需的写入副作用。

三、启动选项影响的是编译行为

默认情况下 __debug__ 为真;请求优化时为假。-O 会移除断言及依赖 __debug__ 的调试代码,-OO 在此基础上还移除文档字符串。不要把必需的校验放进 if __debug__ 分支。__debug__ 也不是一个可以通过赋值切换的普通开关。

优化选项不保证更快。出现差异时,同时检查启动器、容器命令和环境中的 PYTHONOPTIMIZE。环境变量也能启用优化,进程参数中不必出现 -O。

assert优化与显式异常的对照
控制流示意,非可执行代码。CPython优化编译会移除assert及条件、消息求值;显式校验须实际执行、失败不被吞掉,不代表业务验收。

四、用同一解释器跑三个真实子进程模式

下面完整代码保存为“随文实验.py”,用自己的 Python 3 运行。只用标准库,以 sys.executable 启动真实子进程,不靠修改父进程标志模拟优化。临时源码放在临时目录,退出自动清理;测试不接触业务文件。

复制当前环境后删除全部 PYTHON 开头的变量,避免优化级别、模块路径等继承值污染对照。临时路径由 tempfile.gettempdir() 取得,TMPDIR 只设置在复制的环境里,不修改系统环境。本机运行前指定了隔离的 scratch 目录;读者可使用自己的可写临时目录。

import json, os, subprocess, sys, tempfile
from pathlib import Path

环境 = {键: 值 for 键, 值 in os.environ.items()
      if not 键.startswith("PYTHON")}
环境["TMPDIR"] = tempfile.gettempdir()
Path(环境["TMPDIR"]).mkdir(parents=True, exist_ok=True)

def 检查(成立, 说明):
    if not 成立:
        raise RuntimeError(说明)

# 子进程执行的源码,不在父进程里模拟优化。
源码 = '''import json, sys
动作, 数量 = sys.argv[1], json.loads(sys.argv[2])
if 动作 == "断言":
    assert False, "拒绝"
if 动作 == "副作用":
    事件 = []
    def 条件():
        事件.append("条件")
        return False
    def 消息():
        事件.append("消息")
        return "拒绝"
    try:
        assert 条件(), 消息()
    except AssertionError:
        pass
    print(json.dumps(事件, ensure_ascii=False))
if 动作 == "校验":
    try:
        if type(数量) is not int:
            raise TypeError("必须是整数且不是布尔值")
        if 数量 <= 0:
            raise ValueError("必须为正数")
    except (TypeError, ValueError) as 错误:
        print(str(错误), file=sys.stderr)
        sys.exit(2)
    print("接受")
'''

用例 = [("断言", 0), ("副作用", 0)]
用例 += [("校验", 值) for 值 in (False, 0, -1, "3", 3)]
通过 = 0
with tempfile.TemporaryDirectory(dir=环境["TMPDIR"]) as 目录:
    文件 = Path(目录) / "子程序.py"
    文件.write_text(源码, encoding="utf-8")
    for 参数 in ([], ["-O"], ["-OO"]):
        for 动作, 值 in 用例:
            结果 = subprocess.run(
                [sys.executable, *参数, str(文件), 动作, json.dumps(值)],
                env=环境, cwd=目录, capture_output=True,
                text=True, encoding="utf-8", timeout=10)

            if 动作 == "断言":
                检查(结果.returncode == (0 if 参数 else 1), "断言退出码")
                检查(("AssertionError: 拒绝" in 结果.stderr) == (not 参数), "断言诊断")
            elif 动作 == "副作用":
                检查(结果.returncode == 0, "副作用进程失败")
                检查(json.loads(结果.stdout) == ([] if 参数 else ["条件", "消息"]), "副作用")
            else:
                合法 = type(值) is int and 值 > 0
                检查(结果.returncode == (0 if 合法 else 2), "校验退出码")
                检查(结果.stdout == ("接受\n" if 合法 else ""), "校验输出")
                检查(bool(结果.stderr) == (not 合法), "错误传播至命令行")
            通过 += 1
            print(参数 or ["默认"], 动作, repr(值), "通过")

print("通过用例:", 通过)

子程序提供断言、副作用、显式校验三个入口。副作用入口故意在条件和消息里记事件,只用于观察“整句删除”,不能照搬为实际写入设计。数量通过 JSON 传入,避免把所有输入都当作字符串。

每种模式跑同一组样本,父进程收集退出码、标准输出和标准错误。超时也会让实验失败,不把卡住当成通过。父进程的检查使用显式判断,即使实验本身被优化启动,也不能靠会消失的断言判定结果。

默认模式下,必假断言应非零退出,并包含对应异常;另外两种模式直接跳过。事件列表应从“条件、消息”变成空列表。这里捕获断言只为了观察记录,不代表业务入口应该吞掉异常。

校验入口则相反:三个模式都必须拒绝非法值,且不能打印“接受”。合法正整数需要正常返回,避免把“所有输入都报错”错算成修复。最后统计的是样本数,不是性能分数。

五、显式异常要配合明确的输入策略

示例先判断类型,再判断范围。这里策略是只收内置 int,且大于零。bool 是 int 的子类,所以 isinstance(值, int) 会接受布尔值;使用 type(值) is int 才与本例策略一致,同时也会拒绝整数子类。若业务允许子类,必须重新定义边界,不能机械替换。

字符串“3”没有自动转整数,小数也不接受。是否转换、是否允许零、最大数量是多少,都属于业务规则。显式 if/raise 在三个模式仍执行,前提是控制流确实走到这里,并且异常没有被上层吞掉。只换关键字,漏掉类型或范围,仍然可能放过错误输入。

六、捕获之后,命令行仍须失败退出

异常可以直接向上传播,由命令行进程以非零状态结束。若为了友好提示而捕获,就应明确 sys.exit(2),不能打印一句错误后继续执行并返回零。调度器通常先看退出码;看到零可能把本次作业当成功,而不理解中文错误日志。

本机随文实验三种模式共二十一项通过。独立扩展测试另外覆盖未捕获异常、捕获退出、吞异常反例、正整数、布尔值及其他类型,还检查模块和函数文档字符串。它也验证 PYTHONOPTIMIZE=1、-E 忽略该环境值,以及 -E 不会取消显式 -O。后者不是“关闭优化”开关。

另一个语法坑是 assert (条件, 消息):括号内构成非空元组,即使条件为假,元组仍为真。正确的断言消息写法是 assert 条件, 消息。不过语法写对,也不能把它升级为业务守门。扩展测试还对照 compile 的显式优化级别,提醒编译入口同样可能改变断言行为。

七、按现象回查,不先改生产参数

现象优先检查处理方向
加 -O 后不拒绝是否用 assert 或 __debug__ 守门改必经的显式校验
没有 -O 仍跳过PYTHONOPTIMIZE 与实际解释器清理实验环境后重现
计数或写入消失操作是否藏在断言表达式里移出断言,独立检查结果
日志报错却退出零捕获后是否继续返回命令行非零退出
-OO 后帮助文本缺失是否依赖文档字符串核对启动模式与文档依赖

八、验收要同时看拒绝、放行和业务结果

  • 默认、-O、-OO 使用同一解释器,记录实际版本与环境隔离方式。
  • 非法类型和越界值全部拒绝,布尔值按明确策略处理;合法样本能够放行。
  • 需要失败的命令行返回非零,不只检查错误文本,也检查没有输出成功标记。
  • 真实系统还要核对请求状态、调用方处理、授权边界、写入和回滚结果。本实验没有覆盖这些业务验收。

修复目标不是强迫所有部署都关闭优化,而是让必要规则不依赖优化开关。内部调试仍可使用断言;对外承诺的参数、授权和写入路径,应由显式逻辑负责,并由实际调用链验证。

参考资料:Python 3.14:assert 语句;Python 3.14:命令行 -O、-OO 与环境选项。

🔕 评论已关闭