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

底层异常的完整信息保留着,顶层日志一条不丢。两个语义:

不写 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

判断一个操作「能不能做」最可靠的方式就是做一次试试——检查永远可能过时,异常不会撒谎。

自检三题

  1. except Exception 为什么接不住 KeyboardInterrupt?这说明了 except 匹配的什么规则?
  2. raise X from exc 和 raise X(在 except 块内)在 traceback 输出上有何区别?
  3. 把「底层不吞、中层翻译、顶层兜底」套到你的项目里:读配置、发请求、处理用户输入各属于哪层?

下一章

06 章讲模块与包:import 到底做了什么、__init__.py 的作用、循环导入怎么破——以及「延迟导入」这个工程手段。

进入 keel 阅读