小许那天给一个内部管理系统加了个权限校验。
他的思路很简单:在修改用户信息的接口入口,检查当前登录用户是不是管理员。如果不是,就拒绝操作。他写下了这样一行代码:
def update_user(request, user_id, data):
assert request.user.is_admin, "只有管理员才能修改用户信息"
# 后续的修改逻辑
save_user(user_id, data)
本地测试时,他用普通用户账号调接口,程序确实报了错:
AssertionError: 只有管理员才能修改用户信息
接口返回了 500,修改被拒绝了。测试通过,小许很满意。
上线之后,一切正常。直到有一天,一个普通用户通过这个接口改掉了别人的资料。小许接到投诉,第一反应是:“不可能啊,我写了assert,非管理员进不来。”
他重新读了自己的代码,又在本机跑了一遍,assert明明生效。他去查生产环境的启动命令,发现运维在启动脚本里加了一个 -O 参数:
python -O main.py
他当时不知道 -O 是什么意思。查了文档才明白:-O 会让 Python 运行在“优化模式”下,所有 assert 语句会被全部移除。他写的权限检查,在生产环境里根本不存在。
“我明明写了校验,为什么上线就没了?”
问题出在他把 assert 当成了错误处理工具。assert 的定位是开发者自检,不是业务逻辑校验。它可以在启动时被整体关闭,而 raise 不会。
先看一个最直观的对比
def check_age(age):
assert age >= 0, "年龄不能为负"
print(f"年龄: {age}")
def check_age_raise(age):
if age < 0:
raise ValueError("年龄不能为负")
print(f"年龄: {age}")
check_age(25) # 年龄: 25
check_age(-1) # AssertionError: 年龄不能为负
check_age_raise(25) # 年龄: 25
check_age_raise(-1) # ValueError: 年龄不能为负
两者的表面行为很相似:条件不满足时,都会抛出一个异常。但它们的本质完全不同。
assert 是一个调试工具,用来确认“这里的情况一定符合我的假设”。如果不符合,说明代码有 bug,应该立刻停下来。
raise 是一个错误处理机制,用来在程序运行时主动报告一个错误。它属于程序的正常逻辑,无论是否优化模式,都会执行。
用一个生活场景来理解。
你在自己家里装了一个“煤气泄漏检测器”。
assert 的做法是:你在装修时贴了一张便签纸,上面写着“出门前必须关煤气”。这张便签是你给自己看的提醒,只有你自己记得看。如果你把它撕了,或者换了一个不看便签的人来住,提醒就失效了。
raise 的做法是:你装了一个正式的报警器,一旦检测到煤气泄漏,就大声报警,而且这个报警器是焊接在墙上的,谁也拆不掉。无论谁来住,报警器都在工作。
区别一目了然:assert 是开发者给自己贴的便签,可能被撕掉;raise 是正式安装的报警器,永远在线。
assert 的本质:开发者自检
assert 的语义是:“我确信这里的情况一定满足某个条件。如果不满足,说明我的代码写错了,请立刻告诉我。”
它适合用来检查只可能因为代码 bug 而违反的条件:
def calculate_average(numbers):
assert len(numbers) > 0, "列表不能为空"
return sum(numbers) / len(numbers)
这个函数的契约是“传入非空列表”。如果调用者传了空列表,说明调用者的代码有 bug,assert 帮你立刻发现。
assert 的另一个常见用途是检查内部不变量:
def process(data):
assert isinstance(data, dict), "内部错误:data 应该是 dict"
# ...
这类检查是给开发者自己看的,不是给最终用户看的。
raise 的本质:正式的错误报告
raise 的语义是:“现在发生了一个错误,我需要报告给上层调用者,让上层决定怎么处理。”
它适合用来处理运行时可能发生的、调用者应该知道的错误:
def withdraw(account, amount):
if amount <= 0:
raise ValueError("取款金额必须大于 0")
if amount > account.balance:
raise InsufficientFundsError("余额不足")
account.balance -= amount
这些错误不是“代码 bug”,而是“业务逻辑中可能发生的情况”。调用者需要知道这些错误,并做出相应的处理。
关键区别一:-O 优化模式会移除所有 assert
这是 assert 和 raise 最本质、也最危险的差异。
Python 启动时如果加了 -O 参数,或者设置了环境变量 PYTHONOPTIMIZE,所有 assert 语句都会被移除。
# test.py
assert 1 == 2, "这行会报错"
print("程序继续")
正常运行:
$ python test.py
AssertionError: 这行会报错
优化模式运行:
$ python -O test.py
程序继续
assert 1 == 2 被直接删掉了,程序完全不知道曾经有过这行代码。
这个设计是有意为之的。assert 的定位是“开发阶段的检查”,在生产环境里,这些检查应该已经被验证过了,不需要再花时间执行。所以 Python 允许用 -O 把它们全部去掉。
但问题在于:很多人把 assert 当成了业务逻辑的一部分。权限检查、参数校验、数据完整性检查,全都用 assert 写。一旦生产环境用 -O 启动,这些检查全部消失,系统带着漏洞裸奔。
raise 没有这个问题。无论是否优化模式,raise 都会执行。
关键区别二:assert 只能抛 AssertionError
assert 只能抛出 AssertionError,没有其他选择。raise 可以抛出任何异常类型:
raise ValueError("值错误")
raise TypeError("类型错误")
raise KeyError("键不存在")
raise RuntimeError("运行时错误")
raise PermissionError("没有权限")
raise CustomError("自定义错误")
异常类型是调用者处理错误的重要依据。调用者可以这样写:
try:
withdraw(account, amount)
except InsufficientFundsError:
show_balance_warning()
except ValueError:
show_input_error()
不同的异常类型走不同的处理分支。assert 只能抛 AssertionError,调用者无法区分“权限不足”和“参数为空”,只能笼统地捕获 AssertionError。
关键区别三:assert 不能带自定义异常信息之外的东西
assert 的第二个参数只能是一个表达式,通常是一个字符串:
assert x > 0, f"x 必须大于 0,当前值是 {x}"
这个字符串会成为 AssertionError 的 args。但它不能指定异常类型,不能附加结构化信息,不能链式关联其他异常。
raise 可以做得更多:
raise ValueError(f"x 必须大于 0,当前值是 {x}") from original_error
from 可以保留原始异常的上下文,调试时能看到完整的异常链。
坑一:用 assert 做参数校验
这是最常见的误用。
def create_user(username, age):
assert username, "用户名不能为空"
assert age >= 0, "年龄不能为负"
# ...
用户输入校验是业务逻辑的一部分,不是开发者自检。用户可能输入空用户名,可能输入负数年龄,这是正常的运行时错误,应该用 raise:
def create_user(username, age):
if not username:
raise ValueError("用户名不能为空")
if age < 0:
raise ValueError("年龄不能为负")
# ...
用 assert 的后果是:在 -O 模式下,所有校验消失,非法数据直接进入系统。
坑二:用 assert 做权限检查
这就是小许踩的坑。
def delete_post(request, post_id):
assert request.user.is_admin, "只有管理员才能删除"
# ...
权限检查是安全逻辑,绝对不能依赖 assert。因为 assert 可能被移除,权限检查就可能被绕过。
正确的做法:
def delete_post(request, post_id):
if not request.user.is_admin:
raise PermissionError("只有管理员才能删除")
# ...
坑三:用 assert 检查外部数据
def parse_config(config):
assert "host" in config, "配置缺少 host"
assert "port" in config, "配置缺少 port"
配置文件可能来自外部,缺失字段是正常的运行时情况,不是代码 bug。应该用 raise,让调用者能够捕获并处理。
def parse_config(config):
if "host" not in config:
raise ConfigError("配置缺少 host")
if "port" not in config:
raise ConfigError("配置缺少 port")
坑四:用 assert 检查返回值
def fetch_data(url):
response = requests.get(url)
assert response.status_code == 200, "请求失败"
return response.json()
HTTP 请求失败是正常的运行时情况,不是代码 bug。网络可能断开,服务器可能返回 500。应该用 raise:
def fetch_data(url):
response = requests.get(url)
if response.status_code != 200:
raise HTTPError(f"请求失败: {response.status_code}")
return response.json()
什么时候用 assert,什么时候用 raise?
判断标准很清晰:
条件只可能因为代码 bug 而违反:用 assert。
条件可能因为用户输入、外部数据、运行时环境而违反:用 raise。
内部不变量、算法前提条件、开发阶段的自检:用 assert。
参数校验、权限检查、业务规则、外部数据校验:用 raise。
测试代码里的断言:用 assert。测试本身就是开发阶段的检查,用 assert 完全合理。
生产代码里的错误处理:用 raise。
一个简单的判断方法:问自己“如果这行检查被删掉,程序还能不能正确运行?”如果答案是“不能”,那就不能用 assert,因为 assert 真的可能被删掉。
那 assert 还有用吗?
有,但用途很窄。
assert 最适合用来检查内部不变量和算法前提条件,而且这些检查在开发阶段有价值,在生产环境可以省略。
比如:
def binary_search(arr, target):
assert arr == sorted(arr), "二分查找要求数组有序"
# ...
这个断言检查的是调用者是否遵守了函数的契约。如果数组没排序,二分查找的结果是错的,这是调用者的 bug。开发阶段用 assert 能快速发现,生产环境省略也无所谓——如果调用者真的传了无序数组,程序会返回错误结果,但不会崩溃。
再比如:
def merge(left, right):
assert isinstance(left, list) and isinstance(right, list)
# ...
这类检查在开发阶段帮助发现类型错误,生产环境省略也不影响正常逻辑。
还有一种用法是在测试代码里:
def test_add():
assert add(1, 2) == 3
assert add(-1, 1) == 0
测试代码本身就是开发阶段的检查,assert 被优化掉也无所谓,因为测试只在开发环境跑。
性能上的差异
assert 在优化模式下会被移除,所以“没有性能开销”。raise 在正常运行时也不会有性能问题,因为异常只在出错时才会创建和抛出。
但要注意:assert 后面的表达式在非优化模式下会被求值。如果表达式有副作用,优化模式下副作用消失,行为会不一致:
assert do_something(), "失败了" # do_something() 在 -O 模式下不会执行
永远不要在 assert 后面写有副作用的表达式。assert 只应该用来检查纯条件,不应该用来执行逻辑。
一个容易忽略的细节:assert 的第二个参数
assert 的第二个参数可以是任何表达式,但通常用字符串:
assert x > 0, f"x 必须大于 0,当前值是 {x}"
这个字符串在断言失败时会成为 AssertionError 的参数:
try:
assert False, "出错了"
except AssertionError as e:
print(e) # "出错了"
如果第二个参数不是字符串,它会被原样作为 AssertionError 的参数:
assert False, {"code": 400, "msg": "错误"}
但这不太常见,也不推荐。assert 的异常信息应该是给人看的,不是给程序解析的。
回到小许的故事
小许最终的代码是这样的:
def update_user(request, user_id, data):
if not request.user.is_admin:
raise PermissionError("只有管理员才能修改用户信息")
# 后续的修改逻辑
save_user(user_id, data)
他把 assert 换成了 if + raise。权限检查不再依赖 -O 是否开启,无论生产环境怎么启动,检查都会执行。
他还顺手检查了项目里其他的 assert,发现了十几处用 assert 做参数校验、配置检查、返回值检查的地方。这些全部改成了 raise。
他后来在团队文档里写了一条:“assert 是给开发者自己看的,用来检查代码 bug,生产环境用 -O 启动时会被全部移除。raise 是给调用者看的,用来报告运行时错误,永远会执行。权限检查、参数校验、外部数据检查,一律用 raise,绝对不能用 assert。”
这个区分看起来简单,但真到写代码的时候,很多人会觉得 assert 写起来更短、更“优雅”,随手就用了。然后在生产环境被 -O 坑一次,才发现问题的严重性。
记住那个煤气报警器的比喻:assert 是贴在墙上的便签,可能被撕掉;raise 是焊死在墙上的报警器,永远在线。你要保护的是安全,不是开发者的记忆。
断言本身不难,难的是想清楚“这个检查是给谁看的、能不能被省略”。想清楚了,assert 和 raise 就不再是坑,而是两个职责分明的工具。