KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
05 · 异常:层次、传播与 EAFP 世界观 — keel 龙骨
## 这一章解决什么问题
这一章解决什么问题
异常处理不是「try/except 一把梭」。这一章讲清三件事:异常的类层次决定 except 怎么选;异常的传播规则决定你在哪一层接最合适;raise from 和异常链是排查嵌套 bug 的关键工具。最后讲 Python 的 EAFP 风格——它和别的语言世界观不同,直接影响你怎么写代码。
异常是一个类层次
所有异常都是 BaseException 的子类,工程上你需要记住这一条主干:
BaseException
├── KeyboardInterrupt # Ctrl+C,永远不要用 except Exception 接它
├── SystemExit # sys.exit() 抛的
└── Exception # ★ 几乎所有业务异常的祖先
├── LookupError
│ ├── KeyError
│ └── IndexError
├── ValueError
├── TypeError
├── OSError
│ ├── FileNotFoundError
│ └── ConnectionError
└── RuntimeError
except 的匹配规则是 isinstance 判断:except LookupError 能接住 KeyError 和 IndexError。所以写 except 时按「从具体到宽泛」排列:
try:
data = load(path)
except FileNotFoundError:
return default # 最常见的情况给特殊处理
except OSError as exc:
log(f"io error: {exc}") # 其他 io 错误次之
反过来,先写 except Exception 会让后面所有分支变成死代码。
裸 except 是双重错误:它等价于 except BaseException,连 Ctrl+C 和 sys.exit 都吞掉——用户按 Ctrl+C 程序毫无反应,就是这类代码干的。
传播规则:异常会一路往上飞
一个函数抛异常后,Python 沿调用栈向上找最近的匹配 except;找不到就一路飞到顶层,程序崩溃并打印 traceback。理解这一点,就理解了在哪一层接异常是个架构决策:
def read_config(path): # 底层:只负责炸得清楚
with open(path) as f:
return json.load(f) # 可能抛 FileNotFoundError / JSONDecodeError
def load_app_config(path): # 中层:翻译成业务语言
try:
return read_config(path)
except FileNotFoundError as exc:
raise ConfigError(f"config not found: {path}") from exc
except json.JSONDecodeError as exc:
raise ConfigError(f"config is not valid json: {path}") from exc
def main(): # 顶层:统一兜底
try:
cfg = load_app_config("app.json")
except ConfigError as exc:
print(f"startup failed: {exc}")
sys.exit(1)
经典分层:底层不吞、中间层翻译、顶层兜底。最差的做法是底层 except Exception: pass——把错误捂死在摇篮里,出问题时 traceback 里什么都不剩。
自定义异常:给业务一个可捕获的词表
直接 raise ValueError("...") 的问题:调用方无法区分「这是参数写错了还是业务规则不允许」。自定义异常类几乎零成本:
class ConfigError(Exception):
"""配置加载失败。"""
class ConfigKeyMissing(ConfigError):
"""缺少必需的配置项。"""
继承一个业务基类(如 ConfigError)的好处:调用方可以 except ConfigError 一次接住整个家族,也可以精确接某个子类。异常类是 API 的一部分——你抛什么异常,就是在对调用方承诺可捕获的词表。
raise from:异常链
中间层翻译异常时,原始异常怎么办?raise ... from exc 把两个异常链接起来:
raise ConfigError("config is not valid json") from exc
traceback 里会多出一段:
The above exception was the direct cause of the following exception:
Traceback ... ConfigError: config is not valid json
底层异常的完整信息保留着,顶层日志一条不丢。两个语义:
raise X from exc——「X 是由 exc 直接引起的」(翻译场景用这个)raise X from None——「主动切断链接」,不希望底层异常细节打扰用户时用(比如把实现细节异常翻译成纯用户向错误)
不写 from 也会有隐式链(During handling of the above exception...),但语义含混——它表示「处理过程中又炸了新的」。明确写 from,是给读日志的人指路。
finally 与 else:完整语法
try:
result = risky()
except ValueError:
handle_bad_value()
else:
use(result) # 只在"没抛异常"时执行——成功路径的专属区域
finally:
cleanup() # 无论成败都执行——清理的专属区域
else 的价值是缩小 try 的范围:try 里只放真正可能炸的语句,成功后的逻辑放 else,让读代码的人一眼看出你在防哪一段。
finally 记一个陷阱:不要在 finally 里 return——它会静默吞掉正在传播的异常(包括 return 值也被覆盖)。finally 里只放清理代码。
EAFP:Python 的世界观
LBYL(Look Before You Leap)先检查再操作;EAFP(Easier to Ask Forgiveness than Permission)先操作,炸了再处理:
# LBYL
if "port" in cfg:
port = cfg["port"]
else:
port = 6379
# EAFP —— Python 惯用法
try:
port = cfg["port"]
except KeyError:
port = 6379
# 实际最常用的是 get / 默认值
port = cfg.get("port", 6379)
单键取值用 .get() 更简洁,但复杂判断 EAFP 更安全:
# LBYL 有竞态:检查和读取之间文件可能被删
if os.path.exists(path):
data = open(path).read()
# EAFP 没有窗口
try:
data = open(path).read()
except FileNotFoundError:
data = default
判断一个操作「能不能做」最可靠的方式就是做一次试试——检查永远可能过时,异常不会撒谎。
自检三题
except Exception为什么接不住 KeyboardInterrupt?这说明了 except 匹配的什么规则?raise X from exc和raise X(在 except 块内)在 traceback 输出上有何区别?- 把「底层不吞、中层翻译、顶层兜底」套到你的项目里:读配置、发请求、处理用户输入各属于哪层?
下一章
06 章讲模块与包:import 到底做了什么、__init__.py 的作用、循环导入怎么破——以及「延迟导入」这个工程手段。