跳到正文

06 异常处理与上下文管理 ​

一句话:异常把「错误」从返回值里拆出来,沿调用栈向上传播;with 把「无论成功失败都要收尾」固化成协议,二者共同保证资源不泄漏、根因不丢失。

适用:CPython 3.11+。ExceptionGroup / except* 需要 3.11+;contextlib 的 @contextmanager 3.2+。

为什么需要 ​

返回错误码要求每一层都检查,漏一处就带着坏数据继续跑;异常让「处理不了的错误」自动向上抛,只有能处理的边界才拦截。资源问题则相反:文件、连接、锁必须在异常路径也释放,手写 try/finally 容易漏,with 把释放逻辑绑定到对象生命周期。两者合起来,代码才能在失败时可诊断、在退出时不泄漏。

核心机制 ​

异常体系 ​

BaseException 是根,业务代码只应捕获 Exception 及其子类;KeyboardInterrupt、SystemExit 继承 BaseException,捕获它们会破坏 Ctrl+C 和进程退出。自定义异常继承 Exception 即可获得完整机制。

python
class ModelNotReadyError(Exception):
    """模型尚未加载完成"""

try / except / else / finally ​

python
try:
    value = int("123")
except ValueError as e:
    value = None
    print("转换失败:", e)
else:
    print("没出错才执行 else")      # 会执行
finally:
    print("无论如何都执行")          # 会执行
  • except 只捕获指定类型及其子类;多个类型用 except (ValueError, TypeError)。
  • else 在 try 没有异常时执行,适合放「只有成功后才做」的逻辑,避免把它的异常误归因到 try。
  • finally 在 return、异常、break 时都会执行;不要在 finally 里 return,它会覆盖 try 的返回值。

保留根因:raise ... from e ​

python
try:
    int("abc")
except ValueError as e:
    raise RuntimeError("配置解析失败") from e

from e 会把原异常挂到 __cause__,traceback 显示 “The above exception was the direct cause”。不加 from 会得到 “During handling of the above exception...”,根因仍在但关系较弱;跨层转换错误时优先显式 from e。

LBYL 与 EAFP:先检查还是先动手 ​

python
import json
from pathlib import Path

path = Path("config.json")

# LBYL(Look Before You Leap):先检查再操作
if path.exists():
    data = json.loads(path.read_text(encoding="utf-8"))
else:
    data = {}

# EAFP(Easier to Ask for Forgiveness than Permission):先做,出错再处理
try:
    data = json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError:
    data = {}
except json.JSONDecodeError:
    data = {}

EAFP 的优势:exists() 与 open() 之间文件可能被删除(竞态),而且它只挡「不存在」一种错误;EAFP 能同时处理权限、编码、解析等问题。Python 社区通常推荐 EAFP,但「检查本身廉价且能给出更好错误信息」时 LBYL 也合理。

上下文管理器与 @contextmanager ​

with obj as x 的执行顺序是:x = obj.__enter__() → 执行块 → 无论是否异常都调用 obj.__exit__(exc_type, exc, tb)。__exit__ 返回 True 会吞掉异常,返回 None/False 则继续传播。

python
from contextlib import contextmanager
import time

@contextmanager
def timed(name):
    start = time.perf_counter()
    try:
        yield                     # with 块在这里执行
    finally:
        print(f"{name}: {time.perf_counter() - start:.4f}s")

with timed("work"):
    time.sleep(0.1)

用 @contextmanager 时,yield 必须放在 try/finally 里,否则块内抛异常时收尾逻辑不会执行。

最小上手 ​

保存为 errors.py 后运行,预期输出 42 None 和一行 work: 0.05...s:

python
import json
import time
from contextlib import contextmanager

def safe_int(text):
    try:
        return int(text)
    except (TypeError, ValueError):
        return None

print(safe_int("42"), safe_int("x"))     # 42 None

@contextmanager
def timed(name):
    start = time.perf_counter()
    try:
        yield
    finally:
        print(f"{name}: {time.perf_counter() - start:.4f}s")

with timed("work"):
    time.sleep(0.05)

生产动作 ​

  • 捕获边界:只在能恢复或能补充上下文的地方捕获;except Exception: pass 禁止。记录日志时用 logger.exception(...) 保留 traceback。
  • 错误转换:底层异常转换成领域异常时用 raise DomainError(...) from e;不要只保留字符串消息。
  • 资源释放:文件、连接、锁、临时目录用 with;自定义资源实现 __enter__/__exit__ 或用 @contextmanager。
  • 超时与重试:重试只对幂等操作;限制次数并加退避;超时用 asyncio.timeout(3.11+)或客户端超时参数。
  • 排障三段:
    • 现象:报错但不知道从哪来 → 命令:读 traceback 最后一行确定异常类型,再自下而上读调用栈 → 判断:是语法层、类型/数据层、对象层、环境层还是容器层。
    • 现象:异常被吞、只剩「失败」 → 命令:搜索 except.*pass、检查是否 raise ... from e → 判断:是否丢失了根因。
    • 现象:结果不对但不报错 → 命令:最小化复现、在数据链中间打印 shape/dtype/head、二分定位 → 判断:从哪一步开始偏离预期。

边界 ​

  • 不要用异常做常规控制流;能用 dict.get、if 表达的,不要靠 try/except。
  • finally 适合收尾,不适合放业务逻辑;它可能掩盖 try 的返回值或异常。
  • @contextmanager 不是异步上下文管理器;异步资源用 async with 和 asynccontextmanager。
  • 捕获 BaseException、在库代码里打印并吞异常、把异常转换成 None 返回,都会让上层失去判断依据。

讲清楚 ​

异常是对象,沿调用栈向上传播;业务代码只捕获 Exception 的子类,KeyboardInterrupt 和 SystemExit 不要拦。try/except/else/finally 里,else 只在成功时跑,finally 无条件跑且不要 return。跨层转换错误时用 raise ... from e 保留根因。资源释放靠 with:__enter__ 返回绑定值,__exit__ 无论是否异常都执行,返回 True 会吞异常。LBYL 先检查、EAFP 先动手;Python 通常推荐 EAFP,因为它少竞态、能覆盖更多错误类型。

适用 CPython 3.11+ | 初稿 2026-08,2026-10 补齐示例导入与排障

把一门技术讲透到既懂也会用