要点
异常表达失败,with 保证按协议退出和清理。只捕获你能够处理的异常,不能把所有失败悄悄改成空数据。
1. 异常体系:一切皆对象
BaseException
├── KeyboardInterrupt # Ctrl+C(不该被捕获)
├── SystemExit # sys.exit() 触发
└── Exception # 业务异常的根
├── ValueError # 值类型正确但内容不合法
├── TypeError # 类型用错
├── KeyError / IndexError
├── FileNotFoundError # OSError 家族
└── 自定义异常应挂在这里
两个设计要点:
- 捕获时写
except Exception起步,永远不要裸except:(会把 Ctrl+C 也吞掉) - 捕获顺序从具体到一般,因为 except 按顺序命中第一个匹配项
Python 的 EAFP 哲学(Easier to Ask Forgiveness than Permission):先尝试,失败再处理,与 Go/C 的先检查(LBYL)相反:
# LBYL:先检查
if "price" in product and isinstance(product["price"], int):
total += product["price"]
1. EAFP:Python 地道写法
try:
total += product["price"]
except (KeyError, TypeError):
pass
2. try 的完整结构
import json
def load_config(path: str) -> dict:
try:
with open(path, encoding="utf-8") as f:
config = json.load(f)
except FileNotFoundError:
print(f"配置 {path} 不存在,使用默认配置")
return {}
except json.JSONDecodeError as e: # as 拿到异常实例
print(f"配置不是合法 JSON: {e}")
return {}
else:
print("配置加载成功") # 无异常时才执行
return config
finally:
print("清理动作(无论如何都执行)")
可直接运行的异常链与上下文管理器示例
这段完整程序验证两件事:__exit__ 即使在块内抛异常时也会执行;raise ... from e 会把底层 ValueError 放在新异常的 __cause__ 中。
events = []
class Marker:
def __enter__(self):
events.append("enter")
return self
def __exit__(self, exc_type, exc_val, exc_tb):
events.append(exc_type.__name__)
return False
def parse_port(text: str) -> int:
try:
return int(text)
except ValueError as error:
raise RuntimeError("invalid port") from error
try:
with Marker():
parse_port("not-a-number")
except RuntimeError as error:
print("|".join(events))
print(f"{error}|{type(error.__cause__).__name__}")
各块职责:
| 块 | 何时执行 | 典型用途 |
|---|---|---|
try | 总是 | 可能出错的操作 |
except | 匹配的异常发生 | 恢复/降级/转换 |
else | try 无异常 | 成功路径逻辑(缩小 try 范围) |
finally | 无论如何 | 释放资源(通常交给 with) |
重新抛出与异常链:
def parse_int(text: str) -> int:
try:
return int(text)
except ValueError as e:
raise ValueError(f"非法数字: {text!r}") from e # 保留原始异常
raise ... from e 在日志中同时显示两个异常栈,排查链式故障必需;raise 单独一行则在 except 块中原样重抛当前异常。
3. 自定义异常:建模业务错误
class BookmarkError(Exception):
"""书签模块异常基类。"""
class DuplicateBookmark(BookmarkError):
"""URL 已存在。"""
class InvalidUrl(BookmarkError):
"""URL 格式不合法。"""
def add_bookmark(url: str, store: set[str]) -> None:
if not url.startswith("http"):
raise InvalidUrl(f"非 http(s) 地址: {url}")
if url in store:
raise DuplicateBookmark(f"已收藏: {url}")
store.add(url)
设计准则:
- 每个模块一个异常基类,调用方可以
except BookmarkError一网打尽 - 异常名以
Error结尾,描述信息放进异常参数 - 库代码抛异常、命令行入口统一捕获转成退出码
4. with 与上下文管理器
资源管理的标准答案是 with,它在代码块退出时保证调用清理逻辑——无论正常结束还是异常退出:
# 文件:退出时自动 close,异常也不会泄漏句柄
with open("data.txt", encoding="utf-8") as f:
content = f.read()
1. 多个资源
with open("src.txt") as src, open("dst.txt", "w") as dst:
dst.write(src.read())
自己实现上下文管理器:__enter__ 返回资源,__exit__ 负责清理:
import time
class Timer:
def __enter__(self) -> "Timer":
self.start = time.perf_counter()
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
self.cost = time.perf_counter() - self.start
print(f"耗时 {self.cost:.3f}s")
return False # False=异常继续传播;True=吞掉异常(慎用)
with Timer():
sum(range(10_000_000))
简单场景用 contextlib.contextmanager 装饰器更省事:
from contextlib import contextmanager
@contextmanager
def open_db(uri: str):
conn = create_connection(uri) # 生成前 = __enter__
try:
yield conn # yield 的值 = as 变量
finally:
conn.close() # 生成后 = __exit__
最佳实践
try 的范围应覆盖同一个需要处理的失败操作,而非机械限制成一行;范围过大会把后续无关错误误当作同一种输入问题。捕获尽可能明确的类型,补上下文后保留原因,或交给能够处理它的上层。
支持上下文管理协议的资源优先用 with,其他资源仍需按契约关闭。Python 中异常有时就是正常协议的一部分,如迭代结束,不应一概禁止;关键是不要吞掉未知错误或靠异常掩盖本来可清楚表达的控制流。
当配置文件“缺失”允许使用默认值,而内容损坏必须阻止启动时,不能用 except Exception: return {} 把两者合并。最小决策是只对 FileNotFoundError 返回默认配置,对 JSONDecodeError 转换为 ConfigError 并保留原因;权限错误仍交给上层处理。
验收: 分别使用不存在的文件、合法 JSON、损坏 JSON 和无法读取的路径;前三者应得到默认值、配置对象、带原因链的错误,第四种不能伪装成默认配置。日志可记录配置路径,但不要输出包含令牌的完整配置内容。异常链与精确捕获规则见 Python 官方异常教程。
常见问题
Q1: finally 和 with 都能清理资源,用哪个?
A: 有现成上下文管理器的资源(文件/锁/连接)用 with;finally 留给确实需要”无论如何执行”的自定义逻辑。
练习与实践
练习一:健壮的配置加载
- 实现
load_config(path):文件缺失返回{},JSON 损坏抛自定义异常ConfigError并用from e链接原始异常 - 编写调用方,统一
except ConfigError打印友好提示
练习二:实现计数上下文管理器
- 用
@contextmanager实现count_calls(name):进入计数 +1、退出打印总次数,并验证块内抛异常时计数依然正确