异常是 Python 的一等控制流:EAFP 风格建立在它之上,try/except/else/finally 四段各司其职,raise from 与异常组(3.11+)补齐链式与并发场景的表达力。

异常层次树

所有异常继承 BaseException,日常只该捕 Exception 一支:

BaseException
├── SystemExit / KeyboardInterrupt / GeneratorExit
└── Exception ── ValueError、TypeError、KeyError、IndexError、StopIteration、OSError …

裸 except: 匹配 BaseException,会把退出与中断信号一并吞掉——程序杀不死、退不出。底线是 except Exception:,且只在能处理时才捕获。

try 四段语义

try:
    cfg = load(path)             # 只包住可能抛错的段
except FileNotFoundError:
    cfg = DEFAULT                # 命中特定异常才走这里
except (KeyError, ValueError) as e:
    log(e)
    raise                        # 处理不了,原样重抛
else:
    validate(cfg)                # try 全程无异常才执行
finally:
    release()                    # 无论如何执行:正常/异常/return/break
  • except 自上而下匹配,子类先被父类子句拦截——具体在前、宽泛在后
  • else 的价值是收窄 try 范围:后续代码若也塞进 try,其异常会被本块 except 意外吞掉
  • finally 管确定性清理;结构化资源释放优先 Python with 语句与上下文管理器

EAFP vs LBYL

LBYL(Look Before You Leap)先检查再动手;EAFP(Easier to Ask Forgiveness than Permission)直接做、出错再捕——Python 文化整体偏后者。

if os.path.exists(path):    # LBYL:检查与打开之间文件可能消失(TOCTOU)
    data = open(path).read()
try:                        # EAFP:一次到位,竞争安全
    data = open(path).read()
except FileNotFoundError:
    data = ""

异常触发成本可控,且 duck typing 下「能不能做」只有试了才知道。分寸在于——可预期的普通分支(如取默认值)用 d.get(k, default) 更直白,异常留给异常路径。

raise 与异常链

try:
    data = json.loads(raw)
except json.JSONDecodeError as e:
    raise ConfigError(f"配置损坏: {raw!r}") from e
  • raise New from e:显式链,__cause__ 记录底层原因,traceback 双层呈现——外层给语义、内层留现场
  • except 块内裸 raise 原样重抛、保留完整栈;raise X from None 切断隐式上下文、对外隐藏实现细节
  • 3.11+ e.add_note("..."):往异常对象附加诊断信息,随 traceback 打印

自定义异常

继承 Exception(勿碰 BaseException),命名以 Error 结尾,空类即获得捕获与格式化能力:

class ConfigError(Exception):
    """配置加载失败。"""
 
class MissingKeyError(ConfigError):
    def __init__(self, key):
        super().__init__(f"缺少配置项: {key}")
        self.key = key    # 结构化载荷,调用方可编程处理

异常层次即错误语义分类:调用方可粗捕 ConfigError 兜底,也可细捕子类分别处置。

contextlib.suppress

确定某异常该被静默忽略时,suppress 比 try/pass 直白:

from contextlib import suppress
with suppress(FileNotFoundError):
    os.remove(tmp)     # 文件不存在就当无事发生

只 suppress 确定无害的一种异常;suppress(Exception) 等于把裸 except 的黑暗请回来。

两个经典坑

def f():
    try:
        return 1
    finally:
        return 2   # f() 返回 2,try 中 pending 的异常也被静默丢弃

finally 段禁用 return / break / continue——它持有最后一票否决权,会吞掉返回值与异常。第二个坑是 except Exception: pass:现场全毁,bug 无迹可寻——最低限度记录日志,或直接 raise 上抛。

异常组与 except*(3.11+)

并发结构一次可能产生多个异常,单个 exception 对象表达不了。3.11 引入 ExceptionGroup 与 except*:

try:
    raise ExceptionGroup("tasks failed", [ValueError("a"), TypeError("b")])
except* ValueError as eg:
    handle(eg.exceptions)   # 按类型拆组;各 except* 子句分别命中自己那部分

asyncio 的 TaskGroup 在多个子任务同时失败时抛 ExceptionGroup,是这一机制的主场景。最外层出口的习惯写法见 Python import 机制:if __name__ == "__main__": 包住入口,顶层捕异常后 sys.exit(1)——进程退出码即最外层的异常通道。