本节摘要:异常是携带调用栈的对象,沿调用链逐层上抛直到有人捕获。本节讲 traceback 的读法、try/except/else/finally 的精确语义、异常链与再抛出的正确姿势,以及自定义异常层级的设计——最后你会明白"裸 except"为什么被定性为反模式。
raise ValueError("非法输入") 做了三件事:构造一个 ValueError 实例(异常也是对象,第 1 章世界观再次适用)、抓拍当前调用栈快照存入异常、开始"逐层弹栈"寻找可处理的 except 块。每弹一层,函数的局部状态就作废一层。全部弹完仍无人处理,程序终止并打印 traceback:
Traceback (most recent call last): File "app.py", line 12, in <module> main() File "app.py", line 8, in main parse(raw) File "app.py", line 4, in parse raise ValueError(f"非法输入: {raw!r}") ValueError: 非法输入: 'abc'
读法是从下往上:最后一行是异常类型与消息("发生了什么"),上面各行是传播路径("在哪发生"),最上面是最近的调用帧。定位 bug 的固定套路:先看类型与消息,再找 traceback 里第一个属于你自己代码的行号。
try 语句的完整形态有四段,语义各司其职:
def load(path): try: f = open(path, encoding="utf-8") # 只包"可能失败"的最小片段 except FileNotFoundError: # 捕具体类型,可分支 return None # 缺文件:业务上可接受,给默认值 except (OSError, UnicodeDecodeError) as e: raise RuntimeError("日志文件损坏") from e # 转译并保留原始病因 else: with f: return f.read() # 仅 try 无异常时执行 finally: print("加载流程结束") # 两条路径都执行,不吞异常
两条容易踩的语义细节:else 的价值是让"成功路径"不进 try——避免成功代码里的异常被误捕获;finally 里写 return/raise 会吞掉正在传播的异常(语境切换太隐蔽,属于"永远别写"清单)。
raise ... from e 建立显式因果链,traceback 里出现 The above exception was the direct cause of...。与之相对,在 except 块里裸 raise 是"原样再抛",常用于记日志后放行:
try: payload = decode(raw) except KeyError as e: log.warning("字段缺失: %s", e) # 记录后不吞,让上层决定 raise
两种再抛的对比要刻进肌肉记忆:raise(原样)保留全部现场;raise e(重新抛变量)会重置 traceback 起点到当前行,丢失原始位置——排查问题时差之千里。
内置异常是一棵继承树,捕获父类会连坐所有子类:
BaseException ├── SystemExit / KeyboardInterrupt # 程序级信号 └── Exception # 业务异常的根 ├── ValueError / TypeError / KeyError / IndexError ... ├── ArithmeticError → ZeroDivisionError ├── OSError → FileNotFoundError / PermissionError / TimeoutError └── LookupError → KeyError / IndexError
由此推出工程铁律的机制版本:except Exception 捕获时会放过 Ctrl+C 与退出信号(合理兜底),而 except BaseException 与裸 except: 连这些也拦——用户按 Ctrl+C 没反应的程序,多半是哪里有个裸 except 在作怪。捕获的顺序规则是具体在前、宽泛在后,反了会让具体分支永远够不着(语法虽不报错,逻辑已死)。
自定义异常从 Exception 派生,一个 package 一棵小树、消息字段结构化:
class AppError(Exception): """本项目全部业务异常的根,便于上游一网打尽""" class ConfigError(AppError): def __init__(self, key, reason): super().__init__(f"配置项 {key} 无效: {reason}") self.key, self.reason = key, reason # 结构化字段供处理方使用 class AuthError(AppError): pass def load_config(raw): if "db_url" not in raw: raise ConfigError("db_url", "缺失") return raw try: load_config({}) except AppError as e: print(type(e).__name__, e) # ConfigError 配置项 db_url 无效: 缺失
结构化字段的意义在调用方:except ConfigError as e: if e.key == "db_url" 可以精确分支,而不是去解析错误消息字符串。3.11 新增的异常组(ExceptionGroup)则把"并发任务里多个子任务各自失败"打包成一个异常一次抛出——第 8 章异步批量任务时会看到它的实战。
Python 文化里有一对缩写:LBYL(look before you leap,先检查再动手)与 EAFP(easier to ask forgiveness than permission,先动手、失败再处理)。Python 倾向后者,因为它天然免疫"检查与使用之间的竞态":
# LBYL:检查和使用之间,文件可能被别人删掉(竞态窗口) if os.path.exists(p): with open(p) as f: ... # open 仍可能失败 # EAFP:一步到位,失败即事实 try: with open(p) as f: ... except FileNotFoundError: ...
EAFP 还常更快:try 块无异常时开销近乎为零,而 LBYL 的每次检查都是实打实的系统调用。
⚠️ 反模式清单:裸 except 与
except BaseException(吞掉 Ctrl+C);在 except 里pass无日志(问题进入黑洞);用异常做正常循环退出(StopIteration 应留给迭代器协议);循环里 try 只包住整个循环导致无法定位失败元素——把 try 下沉到迭代单元。
💡 关键直觉:设计异常处理时默念三问——这一层能处理吗(能:转为业务结果);该处理吗(有足够上下文吗);不能就原样上抛(raise,别 raise e)。
把本章知识组织成可执行的决策框架。第一层是捕获策略:模块边界(对外接口的最外层)必须有统一兜底,负责把任何异常转译成业务错误响应或日志条目;内部实现层则尽量少捕获——让异常带着完整调用栈上抛,兜底层一次处理。第二层是信息策略:日志记什么?原则是人能复现——异常类型、消息、关键参数(脱敏后)、完整回溯,用标准库日志模块的 exception 方法在 except 块里一条搞定,它自动附上栈信息。第三层是恢复策略:捕获之后做什么?能重试的(网络抖动)配退避重试、能降级的(缓存失效)给兜底数据、不能恢复的原样上抛。多数捕获之后不知道做什么的代码,问题是压根没想清楚自己处在哪一层——想清楚了,写出来的就是干净的三行,而不是吞异常的黑洞。
raise e 重置栈是坑。第 7 章回到解释器机制主线:迭代器、生成器、装饰器——中级与初级的分水岭三连。