异常、资源清理与 ExceptionGroup
目标:能够用精确异常表达失败,用异常链保留原因,并通过 context manager 与 except* 正确处理清理和成组错误。
异常不是普通返回值的另一种拼写。它把控制权沿调用栈转移到能够处理该失败的边界。设计时区分三件事:
- 错误类别:调用者传错类型通常是
TypeError,值不合法通常是ValueError,业务失败可定义领域异常。 - 处理位置:只有能恢复、补充上下文或映射边界的层才捕获;否则让异常继续传播。
- 资源清理:文件、锁、连接和事务无论成功失败都要释放,优先使用
with封装协议。
完整示例:带上下文的批处理
保存为 errors_lab.py:
from __future__ import annotations
from contextlib import contextmanager
from pathlib import Path
from tempfile import TemporaryDirectory
from collections.abc import Iterator
class MessageLoadError(Exception):
"""A message file could not be converted into a usable message."""
@contextmanager
def traced(operation: str) -> Iterator[list[str]]:
events = [f"start:{operation}"]
try:
yield events
finally:
events.append(f"finish:{operation}")
print(events)
def load_message(path: Path) -> str:
try:
text = path.read_text(encoding="utf-8").strip()
except OSError as exc:
raise MessageLoadError(f"cannot read {path.name}") from exc
if not text:
raise MessageLoadError(f"{path.name} is empty")
return text
if __name__ == "__main__":
with TemporaryDirectory() as directory:
path = Path(directory) / "message.txt"
path.write_text(" hello ", encoding="utf-8")
with traced("load"):
print(load_message(path))
运行:
uv run python errors_lab.py
预期输出:
hello
['start:load', 'finish:load']
finally 保证 finish 事件发生。raise ... from exc 把底层 OSError 设为显式原因;日志或 traceback 能看见两层,而 API 层可以只向用户展示领域消息。
except 与 except*
Python 3.11 的 ExceptionGroup 可以保存多个互不相关的异常。普通 except 处理单个异常对象;except* ValueError 会从组中取出所有匹配的 ValueError 子异常,其他未处理成员继续传播。
try:
raise ExceptionGroup("batch", [ValueError("bad input"), TimeoutError("slow")])
except* ValueError as group:
print([str(item) for item in group.exceptions])
except* TimeoutError as group:
print([str(item) for item in group.exceptions])
不要写 except* Exception: pass。它会丢失每个失败的类别与来源。
引导实验:检查异常链
把文件名改成不存在的路径,捕获 MessageLoadError:
try:
load_message(Path("missing.txt"))
except MessageLoadError as exc:
assert isinstance(exc.__cause__, OSError)
print(exc)
若 __cause__ 是 None,检查是否遗漏 from exc。若你确实想隐藏底层上下文,可 raise PublicError(...) from None,但应是边界层的有意决定。
独立练习
实现 load_many(paths):收集所有失败,成功文本照常返回;只要有失败,就抛 ExceptionGroup("load failures", failures)。
参考实现
from collections.abc import Iterable
def load_many(paths: Iterable[Path]) -> list[str]:
results: list[str] = []
failures: list[Exception] = []
for path in paths:
try:
results.append(load_message(path))
except MessageLoadError as exc:
failures.append(exc)
if failures:
raise ExceptionGroup("load failures", failures)
return results
验收时创建两个空/缺失文件,用 except* MessageLoadError 断言 len(group.exceptions) == 2。
常见失败
- 宽泛捕获
Exception后继续:先确认能否真正恢复;否则记录上下文后重新抛出。 return写在finally:它可能覆盖原返回值或异常,应避免。- context manager 的
__exit__/生成器错误地返回真值:可能意外压制异常。