这一章把你从「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(分类树)。
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
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」,让你顺着链一路看到根因,而不是只看到最外层那句含糊的「配置错误」。
墨叔:看「没找到」在业务里算不算异常。查订单详情,订单 ID 是用户从 URL 点进来的,理论上都存在,找不到说明「要么 ID 拼错、要么数据丢了」——这偏向异常,抛 OrderNotFound 让框架统一转 404 很合理。但「搜索用户返回空列表」,空结果本来就是搜索的合法输出,绝不能抛异常。一句话:结果在业务语义里是「合法的空集」就返回空;是「不该发生却发生了」才抛异常。你 Go 里也是这个直觉——搜索返回空切片不报错,查单条却找不到你可能返回 ErrNotFound,两边逻辑一致。
六、一个贴近项目的完整例子
把上面四件串起来,看一个真实「配置加载 + 业务校验」的异常处理设计:
# errors.pyclass 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.pyimport jsonfrom errors import ConfigError, OrderNotFoundErrordef 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 edef 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}")
你在 Go 里写 if err != nil { return fmt.Errorf("查订单: %w", err) }。Python 里 raise OrderError("查订单") from e 的 from e 和 Go 的 %w 在「保留根因、可向下 unwrap」上是否等价?Python 里如果不写 from,只写 raise OrderError("查订单"),根因还会保留吗?保留在哪、以什么形式?这会对排障造成什么差异?
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,否则线上排障时根因会被噪音淹没。
倾向「返回 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,根因(用户不存在)丢失,排障困难。判断核心:「空集是合法输出」返回空,「不该发生」才抛。