前言
语法层面的try与except,几乎没人不会写;但把它放到一个真实项目里,麻烦立刻来了:这个函数该不该捕获?捕获了之后是处理掉还是往上抛?日志该在哪一层打?为什么线上日志里满屏是「出错了」却看不到堆栈?
这些问题的共同点是——它们都不是语法问题,而是边界问题。语法告诉你「可以怎么捕获」,工程实践要回答的是「应该在哪一层捕获、捕获之后做什么」。
本文不谈try语句的细枝末节,专讲四件工程上的事:EAFP 与 LBYL 两种风格的取舍、异常边界的划分原则、怎样用日志把现场留住、以及真实项目里异常层次该长什么样。文中出现的日志接口均以 Python 官方文档为准。
一、EAFP 与 LBYL:两种风格的取舍
Python 术语表里把这两种风格定义得很清楚:
- EAFP:Easier to ask for forgiveness than permission,先假定键或属性存在,出问题再捕获异常。它的特征是「大量
try/except」。 - LBYL:Look before you leap,先显式检查前置条件,再执行。它的特征是「大量
if」。
同一个操作,两种写法:
# 适用于 Python 3.8+
config = {"timeout": 30}
# LBYL:先查再取
if "timeout" in config:
timeout = config["timeout"]
else:
timeout = 5
# EAFP:直接取,出错再兜
try:
timeout = config["timeout"]
except KeyError:
timeout = 5术语表里还点出了 LBYL 的一个硬伤:在多线程环境下,「看」和「跳」之间存在竞态。文档举的例子正是if key in mapping: return mapping[key]——在in判断和[]取值之间,另一个线程可能已经把键删掉了。EAFP 没有这个窗口,因为检查和取值是同一个原子操作。
那么是不是无脑选 EAFP?不是。取舍要看三件事:
| 判断维度 | 倾向 EAFP | 倾向 LBYL |
|---|
| 操作是否幂等、代价是否低 | 是 | 检查本身有成本时反而不划算 |
| 失败是否罕见 | 正常流程、失败罕见 | 失败是常态路径 |
| 检查条件是否为真前置条件 | 无法可靠预判 | 能明确判定(如文件权限) |
举个具体的:如果「键不存在」是很正常的一种输入(比如可选配置项),EAFP 更简洁;如果一段数据有七成概率不符合条件,那么先判断再决定走哪条分支,可读性更好。
但有一条红线:不管选哪种风格,都不要用异常来做「正常流程的主要分派手段」。如果一个for循环的正常退出要靠抛异常来实现,代码就该重写了。
二、异常边界:在哪一层捕获、在哪一层上抛
工程里最该建立的一个直觉是:异常不是「在哪儿出错就在哪儿处理」,而是「在哪儿知道该怎么办,才在哪儿处理」。这就引出了「异常边界」的概念——一个边界,就是一次「异常被翻译成结果」的转换点。
典型的边界有三类:
- 对外的入口(HTTP 处理函数、命令行
main、消息消费者)。这里必须捕获一切业务异常,把它们翻译成对外的错误响应或退出码——因为外面是别人,不能把堆栈甩给他。 - 与外部系统的交界(数据库、第三方接口、文件系统)。这里捕获底层异常,翻译成自己领域的异常,把「对方怎么坏的」变成「我这边遇到了什么情况」。
- 不可恢复的致命点(启动时配置校验、数据库迁移)。这里不该捕获,让它直接失败、直接终止,因为带着坏配置继续跑只会造成更难查的问题。
反过来,中间的业务逻辑层通常不该捕获。它的职责是把上层拿到的数据算对,遇到自己处理不了的情况就往上抛。中间层到处try/except只有一个结果:错误的原始信息在每一层都被磨掉一点,最后到边界时已经面目全非。
一条实用的自问清单:
| 问题 | 如果答案是「否」 |
|---|
| 我能在这里做点有意义的事吗(转换、重试、降级、补充信息)? | 不要捕获,让它往上走 |
| 我捕获之后知道该返回什么吗? | 不要捕获,往上走 |
| 这个异常在我这一层是「正常分支」还是「异常情况」? | 若是异常情况,往上走 |
「能做点有意义的事」是唯一的捕获理由。纯粹为了「让程序别崩」而捕获,恰恰是让程序在错误状态下继续跑,比崩掉更危险。
三、记录现场:logging.exception 与 traceback
捕获之后最该做的一件事是记录。这里有一个关键区别:记录「出错了」和记录「怎么错的」,是两件事。
logging模块提供了专门的接口。官方文档对logger.exception()的说明是:消息的解释方式与debug()相同,并且会把异常信息加进日志消息;这个方法只应在异常处理程序中被调用。它的效果等价于用error()再加上exc_info=True。
# 适用于 Python 3.8+
import logging
logger = logging.getLogger(__name__)
def load_profile(user_id):
try:
return read_from_store(user_id)
except OSError as exc:
# ❌ 只记一句话,堆栈全丢了
logger.error("读取档案失败")
# ✅ 连堆栈一起记下来
logger.exception("读取档案失败,user_id=%s", user_id)
raise两个细节值得强调:
logger.exception只在except块里有意义。文档写得很明确,它需要当前正在处理的异常;在except之外调用,它拿到的是「没有异常信息」,等于白打一行日志。- 参数化写法用
%s占位,不要把值拼进字符串。日志模块会延迟到真的需要输出时才做格式化,而logger.error("失败:" + str(user_id))无论如何都要先拼一遍。官方文档里还提到一个更隐蔽的坑:如果你预先拼好字符串并且内容里带有%,日志模块还会拿它再格式化一次,可能直接抛出格式化异常。
还有一点:堆栈要记在「离事发最近」的那一层。如果你打算在边界统一记录,那中间层就不要再抛出新异常覆盖它,否则原始堆栈就丢了。确实需要转换语义时,用raise NewError("...") from exc把原始异常挂上,这样边界处打印的堆栈里会包含完整的两段。
raise单独一个词(不带表达式)的意思是「原样重新抛出当前异常」,它保留原始堆栈,是在except块里「记录完再往上抛」的标准写法。
四、真实项目里的异常层次与三条纪律
一个可用的工程化异常结构,通常长这样:
# 适用于 Python 3.8+
# errors.py —— 处在依赖图最底层,只依赖内置异常
class AppError(Exception):
"""本项目所有业务异常的根。调用方只需捕获这一个类即可兜底。"""
class ValidationError(AppError):
"""输入不合法。"""
class NotFoundError(AppError):
"""资源不存在。"""
class UpstreamError(AppError):
"""依赖的外部系统不可用。"""
class PermissionDeniedError(AppError):
"""权限不足。"""配套三条纪律:
纪律一:绝不写except: pass。裸except:会连KeyboardInterrupt、SystemExit一起吞掉,用户按 Ctrl+C 都停不下来。即使换成except Exception:,什么都不做的捕获依然是坏味道——它把一条本该送达的信息半路截杀了。至少要做一件事:记录,或者转换语义后重抛。
纪律二:区分「预期内的失败」与「预期外的 bug」。前者用自定义异常承载,是正常的业务分支,边界处翻译成用户能看懂的错误(比如「库存不足」);后者(KeyError、AttributeError、TypeError这类)说明代码有缺陷,应当让它冒出来并配上完整堆栈,而不是被同一段except一起吞成「操作失败」。很多团队的做法是在边界处留一个except Exception:兜底,但兜底之后一定要logger.exception,绝不能静默。
纪律三:异常层次要服务于调用方的决策。划分新异常类的唯一理由是「调用方需要区别对待」。如果两个异常在每一处except里都归到同一个分支,那它们就该合并。
常见坑点
1. 用except: pass吞掉一切
❌try: do_something()后面except: pass,连KeyboardInterrupt都吞了。 ✅ 明确异常类型;确实要兜底就写except Exception:,并且至少logger.exception(...)记一笔或转换后重抛。
2. 把整个函数体塞进一个try
❌ 用一个try从函数第一行包到最后一行,出了异常根本分不清是哪一步出的。 ✅ 只把真正可能抛出、且你打算处理的那几行放进try;范围越小,异常归属越清楚,except里能做的事也越明确。
3. 用logger.error代替logger.exception
❌logger.error("读取失败"),日志里只有一句话,看不出在哪一行、因为什么崩的。 ✅ 在except块里用logger.exception("读取失败,id=%s", item_id),堆栈自动带上。
4. 预先把日志字符串拼好
❌logger.info("处理完成:" + str(n) + " 条"),无论是否输出都要先拼一次,内容里有%还可能二次格式化出错。 ✅ 写成logger.info("处理完成:%s 条", n),交给日志模块延迟格式化。
5. 在中间层捕获后返回None
❌ 业务函数里except Exception: return None,调用方拿到None只能靠猜,还常常继续往下传。 ✅ 中间层不捕获,让异常往上走;必须在边界处转换时,返回结构化的错误对象,别用None当哨兵。
6. 用异常做正常流程的主分派
❌ 一个循环靠抛异常来退出,正常路径也走except。 ✅ 正常分支用条件判断,异常留给真正的例外情况。
7. 边界处把堆栈返回给调用方
❌ 接口里return str(traceback.format_exc()),把文件路径、依赖版本都暴露出去。 ✅ 对外只给一个稳定的错误码与简短描述,完整堆栈写进服务端日志。
8. 兜底之外还继续执行
❌ 捕获后记录一句日志,然后接着往下跑,用一个不完整的状态继续业务。 ✅ 想清楚「捕获之后这一层是否还能安全继续」;不能就raise或返回明确的失败结果。
总结
| 主题 | 结论 |
|---|
| EAFP / LBYL | EAFP 更 Pythonic 且无竞态窗口;失败是常态路径时 LBYL 可读性更好 |
| 异常边界 | 只在对外的入口、外部系统交界、致命点处理;中间层不捕获 |
| 捕获的理由 | 只有「能做点有意义的事」才捕获 |
| 日志 | logger.exception()只在except块里用,参数化写%s |
| 兜底纪律 | 不写except: pass;兜底必须记录,绝不能静默 |
| 层次设计 | 一个根异常 + 按「调用方是否需要区别对待」设子类 |
工程级错误处理其实只有一句话:在知道该怎么办的地方处理,在知道该怎么记的地方记录,在什么都不知道的地方让它往上走。把这三条摆正,你会发现需要写try的地方比想象中少——而剩下的每一处,都是真正需要它的地方。