Course Builder个人学习工作台 · 本地模式
Modern Python:面向有经验开发者的回归路线

异常、资源清理与 ExceptionGroup

目标:能够用精确异常表达失败,用异常链保留原因,并通过 context manager 与 except* 正确处理清理和成组错误。

异常不是普通返回值的另一种拼写。它把控制权沿调用栈转移到能够处理该失败的边界。设计时区分三件事:

  1. 错误类别:调用者传错类型通常是 TypeError,值不合法通常是 ValueError,业务失败可定义领域异常。
  2. 处理位置:只有能恢复、补充上下文或映射边界的层才捕获;否则让异常继续传播。
  3. 资源清理:文件、锁、连接和事务无论成功失败都要释放,优先使用 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__/生成器错误地返回真值:可能意外压制异常。

参考资料