主题
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 efrom 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 补齐示例导入与排障