本节目标:理解 Python 异常的分层结构与传播机制,掌握
try/except/else/finally的精确执行顺序,学会用ExceptionGroup/except*同时处理多个异常,并知道什么时候不该用异常。
适用版本:Python 3.12+(实测 3.14.6)
7.1 异常层次与 try/except/else/finally
上一章我们给类写满了魔术方法,让自定义对象能参与运算、比较与迭代。但程序并不总是顺着写好的路径走:文件可能不存在、网络可能超时、用户输入可能是一串乱码。Python 用异常统一表达这些「计划外情况」。这一节先把异常这个机制本身讲透——它的类型体系、它的捕获语法、它的执行时机。
7.1.1 异常是沿调用栈向上传播的对象
当某行代码出错,解释器会构造一个异常对象,然后沿着调用栈一层层往上找:有没有哪一层用 try 把它接住了?找到了就把控制权交给对应的 except 分支;一路找到顶层还没人接,程序就带着 traceback 退出。
def read_port(config):
return int(config["port"]) # 可能 KeyError,也可能 ValueError
def start_service(config):
return read_port(config) # 自己不做处理,交给上层
try:
start_service({"port": "abc"})
except ValueError as e:
print("启动失败:", e)
启动失败: invalid literal for int() with base 10: 'abc'
read_port 里 int("abc") 抛出的 ValueError,穿过 read_port、穿过 start_service,最终被最外层的 except 接住。异常不需要在出错的那一层处理——可以在真正有能力决定怎么办的那一层处理。这正是异常相比「返回错误码」最核心的价值。
7.1.2 异常类的层次:BaseException 与 Exception
异常不是字符串,而是类的实例,所有异常类都继承自 BaseException。关键的分叉在第二层:
BaseException
├── SystemExit # sys.exit() 抛出,代表「正常退出」
├── KeyboardInterrupt # 用户按 Ctrl-C
├── GeneratorExit # 生成器被 close()
└── Exception # ← 我们平时打交道的都在这
├── ArithmeticError → ZeroDivisionError
├── LookupError → KeyError / IndexError
├── OSError → FileNotFoundError
├── ValueError / TypeError / AttributeError
└── ...
用代码确认这层关系:
print(issubclass(ValueError, Exception)) # True
print(issubclass(ValueError, BaseException)) # True
print(issubclass(SystemExit, Exception)) # False
print(issubclass(SystemExit, BaseException)) # True
print(issubclass(FileNotFoundError, OSError)) # True
print([c.__name__ for c in ZeroDivisionError.__mro__[:4]])
True
True
False
True
True
['ZeroDivisionError', 'ArithmeticError', 'Exception', 'BaseException']
SystemExit 和 KeyboardInterrupt 刻意不继承 Exception。原因是:Ctrl-C 与 sys.exit() 不是「程序出错了」,而是「程序该停下来了」。把它们排除在 Exception 之外,就是为了让普通的 except Exception 不会顺手把这些信号吞掉。这一点下面还会用到。
7.1.3 为什么不要裸 except:,也不要 except BaseException
裸 except: 等价于 except BaseException:,它会连 KeyboardInterrupt、SystemExit 一起接住。后果是:用户按 Ctrl-C 想中断脚本,脚本却「假装没看见」继续跑;sys.exit() 想退出进程,却被拦下来。
def safe_run():
try:
raise SystemExit(0) # 想退出进程
except Exception:
print("被 Exception 接住") # 不会执行
except BaseException:
print("被 BaseException 接住") # 会执行,进程没能退出
safe_run()
被 BaseException 接住
注意 except Exception 没有接住 SystemExit,而 except BaseException 接住了——这正是分层的意义。实际工程里的规矩是:
| 写法 | 评价 | 说明 |
|---|---|---|
except: | 禁止 | 连 Ctrl-C、退出信号都吞,掩盖一切 bug |
except BaseException: | 极少用 | 只在框架顶层清理资源时,且必须 raise 回去 |
except Exception: | 兜底可接受 | 只该出现在进程/线程/请求的最外层 |
except ValueError: | 推荐 | 精确捕获你真正能处理的错误 |
越是内层的代码,越应该捕得具体。 只有最外层的调度器、任务运行器才需要 except Exception 兜底,且它通常紧接着要记录日志并把异常重新抛出。
7.1.4 try/except/else/finally 四段的执行顺序
一个完整的 try 语句最多有四段,各自的职责是:
try:放可能出错的代码。except:出错且类型匹配时执行。else:没有出错时执行(注意:是try块没抛异常才执行)。finally:无论有没有出错、有没有被捕获,一定执行。
用真实输出把时机看清楚:
def order(n):
try:
print("1. try")
result = 10 // n
except ZeroDivisionError:
print("2. except")
else:
print("2. else, result =", result)
finally:
print("3. finally")
order(2)
print("---")
order(0)
1. try
2. else, result = 5
3. finally
---
1. try
2. except
3. finally
两个必须记住的结论:
else与except互斥——抛异常走except,不抛才走else。finally永远最后执行,即使异常没有被任何except匹配、要穿透到外层,finally也会先跑完。
else 的存在意义是缩小 try 的范围。如果写成下面这样,process(result) 里万一抛出 ZeroDivisionError,会被误以为来自 10 // n:
try:
result = 10 // n
process(result) # 它的异常会被同一个 except 吞掉,难定位
except ZeroDivisionError:
...
把 process 挪进 else,except 就只负责它该负责的那一句。
7.1.5 return 与 finally 的交互
这是异常处理里最容易踩的坑:finally 里的 return 会覆盖 try 里的 return。
def keep():
try:
return "from try"
finally:
print("finally 仍然执行") # try 的返回值先暂存,finally 跑完再返回
def override():
try:
return "from try"
finally:
return "from finally" # 覆盖前面的返回值
print(keep()) # 先打印「finally 仍然执行」,再打印「from try」
print(override()) # from finally
finally 仍然执行
from try
from finally
try 里算出的返回值会先被暂存,finally 跑完后再真正返回;但只要 finally 里也有 return,前者就被彻底丢弃。Python 3.14 起这种写法会给出明确警告(PEP 765,finally 中的控制流降级为警告,而不是报错):
SyntaxWarning: 'return' in a 'finally' block
结论:绝不要在 finally 里写 return / break / continue。 finally 只用来做清理——关闭文件、释放锁、恢复状态,不要改变控制流。
7.1.6 多重 except 的顺序:子类必须在前
except 是按从上到下顺序匹配的,第一个匹配上的分支获胜,后面的不再检查。由于子类 isinstance 判断也会命中父类,具体(子类)异常必须写在宽泛(父类)异常前面。
try:
raise FileNotFoundError("config.yaml")
except OSError as e: # FileNotFoundError 是 OSError 子类
print("按 OSError 处理:", type(e).__name__)
except Exception as e:
print("兜底:", type(e).__name__)
按 OSError 处理: FileNotFoundError
如果顺序写反——except Exception 写在前面——宽泛分支会先把 FileNotFoundError 吃掉,后面的精确分支变成死代码。记住:子类在前,父类在后。
多个类型相同处理时,写成一个元组,别叠多个 except:
try:
n = int("abc")
except (ValueError, TypeError) as e: # 两种错误统一处理
print("输入非法:", e)
Python 3.14 起,元组外的括号在只有一个 except 时还可省略(PEP 758):
try:
raise KeyError("k")
except KeyError, IndexError: # 3.14 起合法,等价于 (KeyError, IndexError)
print("捕获到")
7.1.7 ExceptionGroup 与 except*(3.11 起)
有些场景会同时产生多个错误,比如批量校验一批字段、并发发起多个任务。过去只能「遇到第一个就抛出」,其余错误丢失。3.11 引入 ExceptionGroup 把多个异常打包成一个,配套的 except* 语法可以按类型分组处理。
def validate(data):
errors = []
if "name" not in data:
errors.append(ValueError("缺少 name 字段"))
if not isinstance(data.get("age"), int):
errors.append(TypeError("age 必须是整数"))
if errors:
raise ExceptionGroup("校验失败", errors)
try:
validate({"age": "abc"})
except* ValueError as eg:
print("ValueError 组:", [str(e) for e in eg.exceptions])
except* TypeError as eg:
print("TypeError 组:", [str(e) for e in eg.exceptions])
ValueError 组: ['缺少 name 字段']
TypeError 组: ['age 必须是整数']
两个关键点:
except*不是普通的except:它会扫描整棵异常树,把所有匹配的类型各收进一个子组,逐个交给对应分支。一个ExceptionGroup里既有ValueError又有TypeError时,两个分支都会执行。except*后面不能写裸except*:,也不允许普通except与except*混用在同一个try里。
except* 与 asyncio.TaskGroup 是一对——后者会把并发子任务里所有失败聚合成一个 ExceptionGroup,这正是它相比 asyncio.gather 更安全的地方,第 13 章会展开。
7.1.8 assert 会被 -O 优化掉,别拿它做输入校验
assert 条件, 消息 在条件为假时抛 AssertionError。它看起来像校验,但当 Python 以 -O 运行(优化模式)时,所有 assert 会被整体删除。
def parse_age(s):
assert isinstance(s, str), "必须是字符串"
n = int(s)
assert n >= 0, f"年龄不能为负: {n}"
return n
用 python3 -O assert_test.py 运行(优化模式)时,两个 assert 都会被删掉;实测 parse_age(-5) 会直接返回 -5,校验形同虚设。所以:
| 用途 | 该用什么 |
|---|---|
| 检查程序员自己的假设(不该发生的 bug) | assert,可被 -O 移除 |
| 校验用户输入、外部数据、配置 | if ...: raise ValueError(...),永远执行 |
| 校验函数参数类型 | 类型注解 + 运行时校验库,见 9.3 运行时校验与 Pydantic |
一句话:assert 是给开发者看的,不是给用户看的。
7.1.9 异常的开销:什么时候该用返回值
异常在不抛出时几乎不花钱,但抛出并捕获时开销明显更大——解释器要构造对象、填充 traceback、展开栈。在 3.14.6 上实测 20 万次调用:
| 写法 | 每次耗时 | 相对倍数 |
|---|---|---|
| 返回值表示失败(成功路径) | 约 59 ns | 1.0x |
| 异常路径但不抛出(成功) | 约 60 ns | 1.0x |
| 抛异常并捕获(失败路径) | 约 261 ns | 约 4.4x |
结论很明确:异常是为「罕见、计划外」的情况设计的,正常控制流别用它。
# 反例:把「没找到」这种常态也用异常表达
def find_user(users, uid):
try:
return users[uid]
except KeyError:
raise NotFoundError(uid) # 常态结果却走了异常路径
# 正例:常态用返回值,异常只留给真正异常的情况
def find_user(users, uid):
return users.get(uid) # 找不到返回 None,调用方自己判断
判断标准很简单:如果某种结果在正常运行时经常发生,用返回值;只有「本不该发生、发生了说明有 bug 或环境有问题」时才用异常。 具体怎么设计一套异常类型,是下一节的主题。
小结
- 异常是沿调用栈向上传播的对象,不必在出错处处理,可以在有能力决策的那一层处理。
- 层次:
BaseException→Exception(日常异常)与SystemExit/KeyboardInterrupt(进程级信号,刻意排除在外)。 - 不要裸
except:,也不要except BaseException;内层捕得越具体越好,只有最外层才用except Exception兜底。 try/except/else/finally:else与except互斥,finally永远最后执行;finally里绝不写return/break/continue。- 多重
except按顺序匹配,子类必须写在父类前面;同处理逻辑用元组(A, B)。 - 3.11 起的
ExceptionGroup+except*能同时携带并分类处理多个异常,与asyncio.TaskGroup配套。 assert会被-O优化掉,只能用于开发者自检,不能用于输入校验;异常有开销,常态结果请用返回值。
下一节我们把这一节的机制落到工程实践上:如何设计一套自己的异常类型、如何用异常链保留原始错误现场、以及库作者应该怎样暴露异常给调用方。
阅读导航:上一节:6.3 魔术方法与运算符重载 · 下一节:7.2 自定义异常、异常链与错误设计 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。