第 13 章 异常设计:Python 的错误处理哲学

这一章把你从「Python 异常就是 try/except 包一下别让程序崩」的直觉,提升到「异常是控制流、是类型、是契约」的工程认知。你 Go 那边用 error 返回 + if err != nil + fmt.Errorf("...: %w", err) 那一套玩得很溜,Python 的异常体系在表达力上更重,但用错地方比不用还糟。

墨叔:老哥,先问个扎心的。你在 Go 里一个函数返回 error,调用方必须 if err != nil 显式接住,漏接编译器都不答应。Python 没有这茬——函数可以「悄无声息」地往上抛异常,调用方不接就一路冒到顶层崩掉。那你觉得,Python 这套「不强制接异常」的机制,是更松还是更危险?

老哥:我以前觉得是更危险。不接就崩,调用方容易忘。

墨叔:只对一半。危险是真的,但「更松」是错觉——Python 的异常体系其实比 Go 的 error 更重、表达力更强,关键是你能不能按它的脾气用。今天咱们把四件事掰透:内置异常层级、自定义异常怎么写何时写、EAFP 与 LBYL 的实战取舍、异常链(raise … from …)对照 Go 的 error wrap。把这四件吃透,你看 Python 代码的错误处理,就跟看自己 Go 代码一样有数了。

一、内置异常层级:异常也是一棵树

你 Go 的 error 是个接口,所有错误都是平铺的 error,你想分类得自己定义类型或用 errors.Is/As。Python 反过来——异常是有层级、有继承关系的类,整套内建异常是一棵 taxon(分类树)。

# 几个你天天会碰的内建异常,注意它们的继承关系
print(ValueError.__mro__)
# (ValueError, Exception, BaseException, object)

顶层是 BaseException,它下面分两大支:一支是 Exception(你 99% 该捕获的都在这里),另一支是 KeyboardInterrupt、SystemExit、GeneratorExit 这种「系统级、通常不该你捕获」的。再往下,Exception 分出 ValueError、TypeError、KeyError、OSError 等等,而 OSError 又派生出 FileNotFoundError、PermissionError、ConnectionError……

生活化类比:这就像你公司报障系统——顶层「所有工单」,中间分「业务异常」「系统异常」,业务异常再分「参数错」「状态错」,参数错里又分「格式错」「超范围错」。你接工单时可以「只接参数错这一类」(except ValueError),它下面的子类异常(比如 UnicodeDecodeError 继承自 ValueError)也会一并被你接住——这就是捕获父类会自动覆盖子类的机制。

try:
    with open("/nonexistent", "r") as f:
        f.read()
except OSError as e:
    # FileNotFoundError 是 OSError 的子类,这里能接住
    print(f"文件操作失败: {e}")   # [Errno 2] No such file or directory

老哥:那我干脆 except Exception 一把梭,什么异常都接住,省事?

墨叔:这是新手最常犯的第二个错(第一个是把业务分支写成异常)。except Exception 会把所有业务异常吞掉,包括你根本没预料到的编程错误(比如你代码里真有 bug 抛了 TypeError),结果 bug 被你静默掩盖,排查时一头雾水。正确姿势是只捕获你明知会发生、且知道怎么处理的特定异常。接得太宽,等于给程序贴了张「此处可能藏 bug」的封条。

对照 Go:Go 的 error 是平的,你没法「按类别接」,要么 if err != nil 全接,要么 errors.As(err, &target) 精准判断类型。Python 的继承层级天然给了你「按类别接、子类自动归并」的能力——这是它比 Go error 在「分类处理」上更顺手的地方。

二、自定义异常:什么时候该自己造一个

内建异常够用吗?大部分场景够。但你写的是一个库或服务的内部契约,你就该自定义异常——理由是「内建异常说不清『为什么错』,自定义异常能当领域语言用」。

什么时候写自定义异常:

  1. 你的模块要对外暴露明确的错误类型,调用方需要「按类型分流处理」。比如支付模块抛 PaymentDeclinedError 而不是笼统的 ValueError,调用方才能精准 catch 去做重试或降级。
  2. 一组相关错误要共享行为或归类。比如所有「库存相关错误」继承 InventoryError,调用方 except InventoryError 一把接住整族。
  3. 你想给错误附加结构化字段(订单号、错误码),而不是只塞一句字符串。
# 领域异常:继承 Exception(或你自己的基类),别直接继承 BaseException
class InventoryError(Exception):
    """库存领域所有异常的基类"""
    code: str = "INVENTORY_ERROR"
 
 
class OutOfStockError(InventoryError):
    code = "OUT_OF_STOCK"
 
    def __init__(self, sku: str, requested: int, available: int):
        self.sku = sku
        self.requested = requested
        self.available = available
        # 把结构化信息写进 message,也保留为属性
        super().__init__(
            f"SKU {sku} 库存不足:需 {requested},仅 {available}"
        )
 
 
def deduct(sku: str, qty: int, available: int) -> None:
    if qty > available:
        raise OutOfStockError(sku, qty, available)
 
 
try:
    deduct("A100", 10, 3)
except OutOfStockError as e:
    print(e.code, e.sku, e.available)   # OUT_OF_STOCK A100 3
    # 调用方按类型精准处理:比如返回 409 给前端

注意几个要点:

  • 继承 Exception,不是 BaseException。系统级异常(KeyboardInterrupt 等)你不该随便扩展。
  • 异常名用 Error 结尾是 Python 社区约定,一眼可辨。
  • 把结构化字段(sku、available)作为实例属性,调用方能直接取用,不必去 parse 错误信息字符串——这比 Go 里「err.Error() 是字符串、得自己解析」强太多,也避免了你 Go 里常干的「在 error 字符串里塞字段」那种别扭。

老哥:那是不是所有错误都该自定义?我怕漏。

墨叔:别走极端。只为「需要被调用方按类型区分处理、或需要附加结构化信息」的错误自定义。纯编程错误(bug 导致的 TypeError)不要自定义去「接住」,让它暴露出来才是正道。自定义异常的代价是「多写类、多维护一个错误类型树」,滥用会让你的异常体系比业务逻辑还复杂。一个判断标准:如果调用方永远只会 except Exception 一把接,那你自定义它干嘛? 只有当「不同异常要不同处理路径」时,自定义才值回票价。

三、EAFP 与 LBYL:实战里到底怎么选

第 1 章我们聊过这对概念的大框架,这里落到「异常设计」的具体取舍上。

  • EAFP(请求原谅比请求许可容易):直接做,失败再 except。Python 之禅原话撑腰。
  • LBYL(先看后跳):做事前先 if 检查。
# LBYL:先检查 key 在不在
if "user_id" in payload:
    uid = payload["user_id"]
else:
    uid = None
 
# EAFP:直接取,不在再接
try:
    uid = payload["user_id"]
except KeyError:
    uid = None

什么时候用 EAFP(Python 推荐):

  1. 「检查」和「使用」之间有竞态,典型文件/字典/并发场景。LBYL 里 if key in d 检查完,另一个线程把 key 删了,你 d[key] 照样炸(TOCTOU 问题)。EAFP 把检查+使用合成一步,解释器保证原子,反而更安全。
  2. 正常路径要干净,意外走 except 分支。读代码一眼看清主线,防御代码不糊在主逻辑上。

什么时候用 LBYL(反而更合适):

  1. 「不满足条件时该走另一条业务分支」,而不是「出错了」。比如「用户没登录就跳登录页」——这是正常业务分支,不是异常,用 if 显式分流,别用 except 当 if 用。
  2. 前置条件很贵或很常见,你不想为高频发生的「不满足条件」付出异常的开销(异常在 Python 里构造+捕获有成本,虽比不过 Go 但也不是零)。比如校验用户输入,用 if 提前返回错误比抛异常更直白。

老哥:那「正常业务分支」和「异常」的边界到底在哪?

墨叔:一句话:异常是「你没预料到的、打破了正常流程的不可能情况」;业务分支是「你预料之中、本来就要分流处理的情况」。用户传了非法参数,这是预料之中的输入校验失败,返回错误/用 LBYL 处理;数据库连接突然断了,这是预料之外、打破正常流程的,抛异常。把「用户输错密码」写成异常去 catch,代码会丑得没法看——你 Go 里也不会为了「用户密码错」return 一个 error 然后满世界 if err != nil,你直接返回「登录失败」的业务结果。这条边界,Python 和 Go 的直觉是通的。

四、异常链 raise … from …:Python 的 error wrap

这是 Go 工程师最该学的「异常版 wrap」。你在 Go 里这么写:

if err != nil {
    return fmt.Errorf("查询订单失败: %w", err)  // %w 保留原始 error,可 errors.Unwrap/Is/As
}

Python 的对应物是 raise ... from ...:

def load_config(path: str) -> dict:
    try:
        with open(path) as f:
            return json.load(f)
    except json.JSONDecodeError as e:
        # from e:把原始异常链起来,traceback 能一路回溯到根因
        raise ConfigError(f"配置文件 {path} 格式错误") from e

raise NewError(...) from e 做了什么?它在新异常的 __cause__ 上记下原始异常 e。打印 traceback 时,会显示「The above exception was the direct cause of the following exception」,让你顺着链一路看到根因,而不是只看到最外层那句含糊的「配置错误」。

对照 Go:from e 等价于 fmt.Errorf("...: %w", err) 里那个 %w——都是为了「在包装更高层语义的同时,不丢底层根因」。但 Python 还有个隐式链:如果你在 except 块里直接 raise AnotherError() 而不写 from,Python 也会通过 __context__ 把刚才的异常记下来(作为「上下文」而非「原因」)。区别是:

  • raise X from Y:Y 是 X 的原因(你明确知道因果),__cause__ 被设置,且默认会抑制中间的上下文噪声,traceback 更干净。
  • raise X(在 except 内):Y 是 X 的上下文(你只是恰好在那儿又抛了),__context__ 被设置。

工程实践里,当你明知要把一个底层异常「转换/包装」成更高层语义的异常时,用 from 明说因果;当你只是「顺手又抛了个不相关的异常」,不写 from,让上下文自动记录即可。这条纪律能让你线上排障时,从顶层错误一路 .__cause__ 钻到根因,跟你在 Go 里 errors.Unwrap 顺着 %w 往下扒一模一样。

# 排障时顺着链挖根因
try:
    load_config("bad.json")
except ConfigError as e:
    cause = e.__cause__          # 原始 JSONDecodeError
    print(type(cause).__name__, cause)

五、异常 vs 返回值:什么时候不该用异常

Go 的 error 是返回值,天然「必须显式接」。Python 可以抛异常,于是有个永恒问题:什么错该抛异常,什么错该当返回值(或返回 None/特例对象)?

我的判断框架:

  1. 真正「异常」、且调用方大概率无能为力、只能上抛的——抛异常。比如网络断了、文件没了、解析崩溃。这类符合 Python 的设计意图:异常是控制流,用于「跨多层传播一个你处理不了的问题」。
  2. 调用方大概率要就地处理的普通失败、且是正常业务的一部分——别抛,用返回值。比如「查找用户,没找到返回 None」比抛 UserNotFoundError 更自然;调用方紧接着就要 if user is None 分流,「没找到」根本不是异常,是预期结果。
  3. 性能敏感的热路径、且失败很频繁——倾向返回值。异常有开销,高频失败场景下用返回值(或 Optional)更划算。

老哥:那「没找到」到底是返回 None 还是抛异常,有没有硬标准?

墨叔:看「没找到」在业务里算不算异常。查订单详情,订单 ID 是用户从 URL 点进来的,理论上都存在,找不到说明「要么 ID 拼错、要么数据丢了」——这偏向异常,抛 OrderNotFound 让框架统一转 404 很合理。但「搜索用户返回空列表」,空结果本来就是搜索的合法输出,绝不能抛异常。一句话:结果在业务语义里是「合法的空集」就返回空;是「不该发生却发生了」才抛异常。你 Go 里也是这个直觉——搜索返回空切片不报错,查单条却找不到你可能返回 ErrNotFound,两边逻辑一致。

六、一个贴近项目的完整例子

把上面四件串起来,看一个真实「配置加载 + 业务校验」的异常处理设计:

# errors.py
class AppError(Exception):
    """应用所有业务异常的基类,便于调用方一把接住"""
    code: str = "APP_ERROR"
 
 
class ConfigError(AppError):
    code = "CONFIG_ERROR"
 
 
class OrderError(AppError):
    code = "ORDER_ERROR"
 
 
class OrderNotFoundError(OrderError):
    code = "ORDER_NOT_FOUND"
 
    def __init__(self, order_id: str):
        self.order_id = order_id
        super().__init__(f"订单不存在: {order_id}")
 
 
# service.py
import json
from errors import ConfigError, OrderNotFoundError
 
 
def load_orders(path: str) -> list[dict]:
    # EAFP:直接读,IO 失败是「真异常」,用 from 链住根因
    try:
        with open(path) as f:
            return json.load(f)
    except FileNotFoundError as e:
        raise ConfigError(f"订单数据文件缺失: {path}") from e
    except json.JSONDecodeError as e:
        raise ConfigError(f"订单数据格式错误: {path}") from e
 
 
def get_order(orders: list[dict], order_id: str) -> dict:
    # 「找不到」是合法空集语义,用返回值(这里抛是业务约定,演示异常分支)
    for o in orders:
        if o.get("id") == order_id:
            return o
    # 注意:这里选择抛,因为「按 ID 查单条却找不到」在业务里算异常路径
    raise OrderNotFoundError(order_id)
 
 
# 调用方:按类型精准分流
try:
    orders = load_orders("orders.json")
    order = get_order(orders, "O-999")
except ConfigError as e:
    # 配置类问题:告警 + 启动失败
    print(f"[FATAL] {e.code}: {e}")
except OrderNotFoundError as e:
    # 订单类问题:返回 404 给前端
    print(f"[404] {e.order_id}")

这里:异常有层级(AppError 基类,业务子类自动归并)、自定义异常带结构化字段(order_id)、「找不到」按业务语义走了异常路径并用专属类型、from 把 IO 根因链到配置错误。对照你 Go 的写法:AppError 类似你定义的 var ErrOrderNotFound = errors.New(...),但 Python 的继承让「except AppError 接住整族」免去了你 errors.Is 逐个判断;from 对应 %w;结构化字段对应你往 error 里塞的字段。表达力上 Python 这套更省样板,但代价是「不强制调用方接」,得靠纪律补。

七、收益与代价:Python 异常体系的账

收益:

  1. 错误能携带类型与结构化数据,调用方按类型分流、直接取字段,比 Go 的「error 是平接口、字段靠解析字符串」更顺手。
  2. 异常链(from)保留根因,多层包装下排障能顺藤摸瓜,等价于 Go 的 %w。
  3. EAFP 让正常路径干净,主线不被防御代码淹没;继承层级让「按类捕获、子类自动归并」天然可用。
  4. 跨多层自动传播,不用像 Go 每层显式 return err,深层错误能直达顶层 handler。

代价:

  1. 不强制调用方接异常,漏接就崩到顶层,比 Go 编译期强制处理更易藏 bug——靠「只捕获特定异常、不 except Exception 一把梭」的纪律补。
  2. 异常有运行时开销,热路径高频失败时不如返回值轻;滥用 EAFP 在性能敏感处会拖慢。
  3. 控制流被异常隐式打断,读代码时若到处抛,主线反而难追——所以「正常业务分支别用异常」是铁律。
  4. **自定义异常用错(过度 or 不足)**会增加维护负担,得按「是否需要类型分流/结构化字段」严格判断。

老哥:总结一句?

墨叔:Python 的异常不是「兜底保险丝」,是「带类型、带层级、带因果链的显式控制流」。你 Go 用 error 返回练出的「错误是值、要显式传播、要 wrap 保留根因」的本能,在 Python 里全都有对应,而且因为动态+继承,表达力更强——但「不强制接」这把双刃剑,得用你的工程纪律去压住。

八、你现在该带走的四句话

  1. 异常是棵树,只捕获你明知能处理的特定异常,别 except Exception 一把梭掩盖 bug。
  2. 自定义异常只为「需要按类型分流或带结构化字段」的错误而写,命名以 Error 结尾、继承 Exception。
  3. 真异常用 EAFP + from 链根因;正常业务分支用 LBYL / 返回值,别把异常当 if 用。
  4. 「合法空集」返回空/None,「不该发生却发生」才抛异常——这条边界 Go 和 Python 直觉相通。

九、把这一章变成肌肉记忆

  1. 翻你一个现有 Python 函数,找里面 except Exception 或裸 except: 的地方,改成只捕获具体异常(如 ValueError/KeyError),再跑一遍,体会「漏接的 bug 重新暴露」的安全感。
  2. 给项目定义一个 AppError 基类加两三个业务子类,把原来 raise ValueError("订单不存在") 之类的地方换成自定义异常并带 order_id 字段,再用 from 把底层异常链上去,打印一次 traceback 看根因是否清晰可追溯。

【思考题】

  1. 你在 Go 里写 if err != nil { return fmt.Errorf("查订单: %w", err) }。Python 里 raise OrderError("查订单") from e 的 from e 和 Go 的 %w 在「保留根因、可向下 unwrap」上是否等价?Python 里如果不写 from,只写 raise OrderError("查订单"),根因还会保留吗?保留在哪、以什么形式?这会对排障造成什么差异?
  2. 一个 find_user(username) 函数,用户不存在时,你倾向「返回 None」还是「抛 UserNotFoundError」?分别给出适用场景,并说明如果选错(该返回 None 却抛了异常 / 该抛却返回 None)会带来什么具体麻烦。
  3. 你看到一段代码 try: ... except: pass,作者意图是「忽略所有错误」。从异常设计角度,这行代码的三个具体问题是什么?如果要保留「忽略某些预期错误」的意图,应该怎么改写才合规?对照 Go 里你不会 err == nil 直接忽略,而是会 _ = err 或显式判断,差异说明了什么?

【参考答案】

  1. from e 在「保留根因并显式声明因果」上与 Go 的 %w 高度等价:两者都让你能从顶层错误向下追溯到原始错误(e.__cause__ 对应 errors.Unwrap(err))。但不写 from 时根因依然会保留——Python 会自动把刚才 except 里捕获的异常记到新异常的 __context__(上下文)里,traceback 仍会显示。差异在语义与可读性:from 设置的是 __cause__,表达「Y 是 X 的直接原因」,且会抑制中间噪声,traceback 干净利落;不写 from 设置的是 __context__,表达「X 恰好在 Y 的 except 块里被抛出」,可能夹杂无关上下文,排障时你要区分「哪个是根因、哪个只是顺手抛的」。工程上:只要是「把底层异常转换包装成高层语义」,就该用 from 明说因果,别依赖隐式 context,否则线上排障时根因会被噪音淹没。
  2. 倾向「返回 None」的场景:find_user 是搜索/查询类接口,「用户名不存在」是合法的正常结果(比如登录页校验、列表过滤),调用方紧接着就要 if u is None 分流,此时抛异常会让正常业务流被异常打断、且调用方被迫 try/except 一个本属预期的事件,代码变丑。倾向「抛 UserNotFoundError」的场景:get_user_by_id 是「按主键查单条」,业务上该 ID 一定存在(从 URL/外键来的),找不到说明数据不一致或调用方传错,属于「不该发生」,抛专属异常让框架统一转 404、且带 user_id 字段便于排障。选错的具体麻烦:该返回 None 却抛——调用方被迫到处 try,正常分支被异常污染,且「用户不存在」被当成系统故障告警,误报。该抛却返回 None——调用方忘了 if is None 检查,拿 None 当对象继续用,在深处抛 AttributeError,根因(用户不存在)丢失,排障困难。判断核心:「空集是合法输出」返回空,「不该发生」才抛。
  3. 三个具体问题:其一,吞掉一切异常包括编程错误(TypeError、NameError 这类 bug 也被静默忽略),bug 被永久掩盖,程序带着错误状态继续跑,最危险;其二,违反「只捕获能处理的特定异常」原则,你根本不知道忽略了什么,排障时无迹可寻;其三,丢失异常上下文,连日志都没留,出事后完全无法回溯。合规改写应只忽略「你明确预期且无害」的具体异常:
    try:
        risky_call()
    except (FileNotFoundError, ResourceBusyError):
        # 仅忽略这两种预期内的、可安全跳过的情况,并记录
        logger.warning("资源暂不可用,跳过")
    对照 Go:你不会写 err == nil 然后无视,而是 _ = err(显式丢弃且明确知道是哪个 err)或 if err != nil { 具体处理 },Go 的显式性逼你「至少看一眼错误」。Python 的 except: pass 则是「闭眼全吞」,这正说明 Python 把「是否处理错误」的自由(和代价)完全交给了你,越自由越要靠纪律——这也是本章反复强调「只捕获特定异常、不裸 except」的根因。