Python 错误处理
Python 以异常为主错误路径:try/except/else/finally、捕获粒度、raise from 因果链,以及何时翻译异常、何时让其冒泡。
[!info] 关联笔记
Python 错误处理
这个概念为什么出现
真实系统里失败是常态:
- 用户输入不是整数
- 余额不足
- 下游 JSON 坏了
- 磁盘满了
若没有统一错误模型,代码会变成:
- 到处
if err != None风格却又不彻底 print后继续跑,数据写一半- 捕获过宽,把
KeyboardInterrupt和业务失败一锅炖 - 日志只有
something failed,没有因果链
Python 选择:异常是常规失败路径的一等机制。成功走返回值,失败抛出,由合适的边界捕获、翻译或记录。
这与 Go “error 值显式返回”不同,但目标一致——让失败可见、可分类、可清理。
[!abstract] 一句话理解 用
try/except/else/finally管理失败控制流;捕获要具体;清理放finally或上下文管理器;跨边界翻译错误时用raise ... from ...保留根因。
最小可运行示例
先把示例放进业务场景,再看代码:
场景:积分兑换既要防坏输入,也要表达业务失败
兑换服务收到字符串形式的积分花费:
- 解析失败 → 输入错误(可映射 HTTP 400)
- 余额不足 → 业务错误(可映射 409/400 业务码)
- 无论成败 → 写审计收尾(对应
finally)
# points_redeem_errors.py
# 业务意图:兑换积分,区分输入错误与业务失败,并保证审计收尾。
# 教学点:
# - 捕获具体异常;
# - else 仅成功路径;
# - finally 必执行;
# - raise from 保留因果。
from __future__ import annotations
class InsufficientPointsError(Exception):
"""余额不足:可识别的业务失败。"""
def parse_points(raw: str) -> int:
# 输入边界:必须是正整数。
points = int(raw) # 非数字 -> ValueError
if points <= 0:
raise ValueError("points must be positive")
return points
def redeem(balance: int, raw_cost: str) -> int:
print("audit: start")
try:
cost = parse_points(raw_cost)
if cost > balance:
raise InsufficientPointsError(f"need {cost}, have {balance}")
except ValueError as exc:
# 翻译为统一输入错误,同时保留 cause 便于排障。
raise ValueError(f"invalid cost: {raw_cost!r}") from exc
else:
# 仅当 try 未抛异常:执行扣减。
new_balance = balance - cost
print(f"audit: success cost={cost}")
return new_balance
finally:
# 成功、业务失败、输入失败都会到这里。
print("audit: end")
def main() -> None:
print("case ok:", redeem(100, "30"))
try:
redeem(100, "x")
except ValueError as exc:
print("case bad input:", exc)
print(" cause:", type(exc.__cause__).__name__)
try:
redeem(10, "30")
except InsufficientPointsError as exc:
print("case business:", exc)
if __name__ == "__main__":
main()
建议运行:
python points_redeem_errors.py
期望输出:
audit: start
audit: success cost=30
audit: end
case ok: 70
audit: start
audit: end
case bad input: invalid cost: 'x'
cause: ValueError
case business: need 30, have 10
(audit: end 在失败路径也会打印;业务失败路径同样有 start/end。)
结合场景再看四个关注点
- 类型区分失败模式:输入 vs 余额不足,调用方可分别映射。
else避免把成功逻辑放进try导致误捕获。finally保证审计收尾,不依赖每条 return 前手写。raise from让日志能挖到int('x')根因。
核心概念与准确模型
1. 语句结构
try:
...
except SomeError as exc:
...
except AnotherError:
...
else:
... # try 完全未异常
finally:
... # 几乎总是执行(清理)
执行直觉:
flowchart TD
T[try 体] -->|成功| E[else]
T -->|SomeError| X1[except SomeError]
T -->|其它异常| P[向外传播]
E --> F[finally]
X1 --> F
P --> F
F --> R[继续/传播]
2. 异常是对象
- 有类型、消息、可选属性
- 可自定义类表达领域失败
- 内置层次见 异常层次与异常链
3. 抛出
| 写法 | 含义 |
|---|---|
raise ValueError("...") | 抛新异常 |
raise | 在 except 中原样重抛 |
raise New from exc | 显式因果链 __cause__ |
raise New from None | 抑制上下文(少用) |
4. 捕获粒度
| 做法 | 评价 |
|---|---|
| 具体类型 | 首选 |
except Exception | 仅边界层,必须记录 |
裸 except: | 禁止(过宽) |
except BaseException | 几乎不要(含退出/取消类) |
5. EAFP vs LBYL
- EAFP(Easier to Ask Forgiveness than Permission):先做,不行再捕——Pythonic 常见风格
- LBYL(Look Before You Leap):先检查再做——适合检查便宜且避免异常热路径
例:字典键用 in/get 往往比依赖 KeyError 更清晰;文件存在检查则有 TOCTOU 竞态,EAFP 打开更稳。
6. 与上下文管理器
资源清理优先:
with open(path) as f:
...
而不是手写层层 try/finally。见 上下文管理器。
设计动机
- 失败不沉默:异常打断正常返回,迫使边界处理或崩溃暴露问题
- 类型可分类:比布尔失败位更易分支
- 栈轨迹可观测:配合 logging.exception
- 开放扩展:用户可定义领域异常体系
代价:滥用异常做常规分支会难读、偏慢;捕获过宽会隐藏缺陷。
边界情况与反直觉行为
1. finally 中 return
会掩盖 try 的 return/异常——极度不推荐。
2. 异常发生在 except 或 finally
可能改变传播中的异常;需要刻意设计。
3. else 的价值
把“成功后逻辑”放 else,避免被同一 try 的 except 误伤。
4. 生成器与 throw/close
生成器清理与 GeneratorExit 有特殊路径(进阶)。
5. asyncio 取消
CancelledError 不是普通 Exception;错误处理模板不能误吞。见 任务、超时与取消。
6. 性能
异常路径比分支重;不要用异常做超热路径的期望控制流(如逐字符解析)。
常见误区
[!warning] 常见误区:捕获后
pass错误理解:程序不崩就是成功。
正确模型:至少记录、翻译或返回明确领域错误。
[!warning] 常见误区:所有失败都
ValueError/Exception错误理解:省类型。
正确模型:调用方需要分支的失败应可区分。
[!warning] 常见误区:用返回
None表示一切失败 错误理解:简单。
正确模型:None与失败混淆;缺值用 Optional,失败用异常或 Result 型设计。
[!warning] 常见误区:在底层库
basicConfig+ 吞异常 错误理解:对用户好。
正确模型:库少捕多抛;应用边界统一翻译与日志。
[!warning] 常见误区:把 Go 的 error 值思维硬套成“禁止异常” 错误理解:异常不 Pythonic。
正确模型:Python 标准库与生态以异常为主;关键是边界策略,不是消灭异常。
与相邻概念对比
| 文化 | 主路径 | 典型 |
|---|---|---|
| Python 异常 | 抛/捕 | 本篇 |
| Go error 值 | 返回 error | go-error-handling |
| Result 类型 | Ok/Err 容器 | 某些函数式库 |
| HTTP 状态码 | 边界映射 | Web 层 |
文字说明:在服务边界,把领域异常映射为稳定对外错误码;在内部,保留类型与因果链。
工程实践
分层策略
- 核心域:抛领域异常,少捕获
- 适配层(HTTP/CLI):捕获 → 日志 → 用户信息/状态码
- 基础设施:翻译第三方异常,保留
from
日志
logger.exception("redeem failed user=%s", user_id)
带堆栈;对外消息与对内细节分离。
测试
- 成功路径
pytest.raises(ValueError)- 对链式异常可断言
__cause__
API 映射示例(思想)
| 异常 | HTTP |
|---|---|
输入 ValueError | 400 |
InsufficientPointsError | 409 或业务码 |
未知 Exception | 500 + 关联 ID |
可验证实验
实验 1:成功路径
redeem(100, "30") == 70,日志含 success。
实验 2:坏输入
redeem(100, "x") 抛 ValueError,__cause__ 存在。
实验 3:业务失败
redeem(10, "30") 抛 InsufficientPointsError,仍有 audit: end。
实验 4:else vs 放入 try
把 new_balance = ... 放进 try,并故意让其后代码抛 ValueError,观察是否被输入错误分支误伤。
实验 5:裸 except 的危险(只在沙箱)
用 except: 捕一切,尝试 Ctrl+C 行为(交互环境)——理解为何禁止。
本节总结
- Python 错误处理的质量 = 分类 + 清理 + 因果 + 边界翻译。
try/except/else/finally要按职责拆,而不是一个大 try 包全世界。- 与 Go 不同路径,同一工程目标:失败可处理、可观测、不破坏资源。
自测题
概念题
else与把代码放在try末尾的关键差别?- 何时自定义
InsufficientPointsError而不是统一ValueError?
代码推理题
finally是否在return之前执行?
工程思考题
- 库代码捕获
Exception并返回None有什么长期成本?
参考答案
else中的异常不会被同一try的except捕获。- 当调用方需要区分输入错误与业务余额不足并走不同处理时。
- 会;
finally在离开 try 复合语句前执行。 - 失败被静默、调用方无法分支、排障丢失堆栈与类型。
延伸阅读与资料来源
| 资料 | 类型 | 支撑内容 |
|---|---|---|
| Errors and Exceptions | 教程 | 入门 |
| Exception hierarchy | 文档 | 内置树 |
| raise | 规范 | raise/from |
| EAFP glossary | 术语 | EAFP |
笔记元信息
- 角色:原子概念(错误模型主叙事)
- 深度对齐:go-error-handling 的工程完整度
- 专篇:层次/链式、上下文管理器、asyncio 取消