1. 异常处理的本质:先别把它当“错误”看
写了这么多年 Python,我见过太多把异常处理写成“try 一下然后打印堆栈”的代码。异常处理看上去是 Python 里最好懂的一部分——try、except、finally 三个关键字,谁都能写几行——但真正能在生产环境里扛住事的异常处理,远不止这几个关键字那么简单。裸 except 吞掉错误、日志只记一个字符串、线程里的异常主线程毫无感知,这些问题几乎每个稍微有点规模的 Python 项目里都能翻出来几个。
我写这篇关于 Python 中异常处理的文章,就是想把我这几年踩过的坑、排过的障、改过的烂代码,一次性讲透。它适合刚入门不久、只会写简单 try-except 的初学者,也适合写过一段时间却总遇到“诡异 Bug”的进阶用户。我会先讲异常机制本身是怎么回事,再讲捕获时最容易翻车的写法,然后用一个真实事故的排查过程告诉你“错误为什么不该被吞”,最后聊清楚自定义异常、异常链、并发场景和性能边界。看完之后,你应该能建立起一套自己的异常处理判断标准。
1.1 从 raise 到 except,中间发生了一次栈展开
很多人以为异常就是“程序出错了,打印一段红色文字”,其实异常在 Python 里是一种非常标准的控制流。它的运行机制是这样:某一行代码执行时发现状况不对,主动raise一个异常对象,当前这个函数帧立刻停止执行,异常顺着函数调用链一层一层往上“冒泡”。这个过程叫栈展开(stack unwinding)。每一层函数在退出前,都会先执行自己的finally块,然后继续往上找有没有能匹配上的except分支。
看一个最简单的例子:
def build_user(user_id): return int(user_id) def get_user_name(user_id): user = build_user(user_id) return "user-" + str(user) try: get_user_name("abc") except ValueError: print("字符串转整数失败")当int("abc")抛 ValueError 时,build_user立刻中断,异常上浮到get_user_name,它也没有处理,继续上浮到try处,被except ValueError接住。整个过程里,get_user_name里那句return根本没有机会执行。理解了这一点,你才能明白为什么“把一大段业务逻辑塞进 try 里”是个坏主意——你根本不知道异常是从哪个调用点冒上来的,也就不知道该去哪里修。
我还特意强调过:异常是给“意外情况”准备的,不是给“常规分支”准备的。如果你写的代码里充斥着“try 一下看看会不会出错,出错就换个方向”,那说明你的业务逻辑需要的是 if/else 判断,而不是异常。这个度的把握,后面第六章会专门展开。
1.2 BaseException 与 Exception 之间隔着一堵墙
Python 的内置异常体系看起来像一棵树,根是BaseException,它的直接子类包括Exception以及几个“非错误型”的异常:KeyboardInterrupt、SystemExit、GeneratorExit。大多数人闭着眼睛写的except Exception只能捕获到Exception及其子孙,捕获不到这三兄弟。这个设计是有意的:KeyboardInterrupt代表用户按了 Ctrl+C,SystemExit代表程序主动退出,它们都属于“控制流信号”,不应该被普通的业务异常处理器拦走。
这是新手最容易懵的地方。举个例子:
while True: try: print("工作中...") except Exception: continue你按 Ctrl+C,程序照样能退出,因为KeyboardInterrupt不是Exception的子类,except Exception接不住它。但如果你把except Exception换成裸except:,那就惨了,Ctrl+C 会被吞掉,进程怎么都杀不死。这个场景我后面会用一个事故案例详细讲,这里先记住一条铁律:
注意:绝大多数情况下都不要写裸
except:。它不是用来“捕获所有错误”的正确姿势,而是在“连 Ctrl+C 都拦”的错误姿势。
常用异常的捕获范围我整理成了一张表,方便你随手对照:
| 写法 | 捕获范围 | 生产环境建议 |
|---|---|---|
except: | 所有异常,包括 KeyboardInterrupt / SystemExit | 不要用 |
except BaseException: | 同上 | 不要用 |
except Exception: | 所有常规业务异常 | 慎用,要清楚自己在兜什么底 |
except ValueError: | 单类异常 | 优先按具体类型捕获 |
except (ValueError, TypeError) as e: | 多类异常统一处理 | 常用,适合处理方式相同的场景 |
2. 捕获异常最容易翻车的三种写法
try-except 看似简单,但我在 Code Review 里见过太多问题代码。今天把最常见的三种拿出来一个一个讲,每一种都有真实项目的影子。
2.1 裸 except:连 Ctrl+C 都救不了
裸 except 的危害,我在 1.2 里已经预告过了。这里给它一个完整的“罪状清单”:
- 吞掉
KeyboardInterrupt,导致进程无法被 Ctrl+C 中断; - 吞掉
SystemExit,程序自己想退出都退不掉; - 吞掉所有真正的业务异常,排错信息全部石沉大海;
- 给人造成“这段代码很健壮”的错觉,实际上只是把故障从明处挪到了暗处。
我以前接手过一个内部工具,里面有个循环判断用户输入是否合法,写的是:
try: result = validate(input_text) except: result = None这段代码在开发环境怎么跑都对,一上生产环境就出现“明明输入错了,程序却继续往下走,最后落了一堆脏数据”的情况。查了半天,发现是validate内部因为网络超时抛了一个TimeoutError,也被裸 except 兜住转成了None。问题不是出在输入校验逻辑本身,而是异常被一股脑全吞了。
裸 except 的正确替代方案永远都是:写出你预期可能出现的异常类型,然后用具体的 except 去接。比如这里应该只接ValueError:
try: result = validate(input_text) except ValueError: result = None这样,网络超时这种“意外之敌”才有机会往外抛,日志才会响。
2.2 异常到底该“吃掉”还是“往上抛”
很多人的异常处理只有两条路:要么print一下当作无事发生,要么raise把异常原样扔回去。这两条路本身没错,错的是选择标准。
先说“只打印不处理”的问题:
try: do_something() except Exception as e: print(e)print(e)只打印了异常对象的字符串表示,比如“list index out of range”。你只知道出了问题,但不知道是哪一个列表、哪一行、在什么上下文里出的。生产环境里这种日志等于没有。正确做法是记录完整堆栈:
import logging logger = logging.getLogger(__name__) try: do_something() except Exception: logger.exception("调用业务模块失败")logger.exception会把你当前所在位置的完整 traceback 一起写进日志。排查问题的时候,堆栈信息远比那句“失败”值钱。
再说往上抛。很多人不知道raise和raise e的区别:
try: run() except ValueError as e: raise # 保留原始 traceback,从最底层开始try: run() except ValueError as e: raise e # 生成新的 traceback,原始信息会被截断在绝大多数情况下,你应该用裸raise,这样才能保持异常的完整传播链路。raise e等于把原本从底层函数一路冒上来的堆栈给“洗”掉了,排错时看到的起点变成了你raise的这一行。尤其当调用链有三层以上时,这个差别直接决定你排查问题花十分钟还是两个小时。
到底什么时候该处理,什么时候该抛?我的经验是:只有在当前这一层你还能“做点什么”的时候,才去 catch 它。比如你 catch 到一个数据库连接错误,决定切换备用库重试,这时候 catch 是有意义的;如果你 catch 之后既没有补偿逻辑,也没有额外上下文可以补充,那不如直接让异常往上走,让有能力处理的那一层去接。
2.3 多 except 分支的顺序是个排序题
Python 的多个 except 分支是从上往下依次匹配的,第一个匹配上的生效。下面这段代码是典型反例:
try: fetch_user() except Exception: print("通用错误") except ValueError: print("值错误")第二个except ValueError永远走不到,因为ValueError本身就是Exception的子类,往上匹配的时候先撞上第一个分支。Python 不会报语法错误,但这段代码的意图已经废了。
正确的顺序是“具体优先,通用兜底”:
try: fetch_user() except ValueError: print("值错误") except KeyError: print("键错误") except Exception: print("兜底错误")我自己的习惯是:能写出具体类型的一定写具体类型,多个具体类型用元组合并处理,最后才用Exception兜底,而且兜底分支里至少要有logger.exception,不许静默。写代码时脑海里把异常继承树过一遍,比你事后凭借“感觉”排顺序靠谱得多。
3. 一次批量任务“无限重投”事故的完整排查链路
理论讲再多,不如一次实战来的记忆深刻。这一节我完整复盘一个我印象很深的事故:一个跑在凌晨的批量消费任务,突然从“正常三分钟跑完”变成“永远跑不完”,而且 Ctrl+C 都杀不掉。
3.1 现象与第一眼印象:日志干净得像什么都没发生
那个任务从消息队列里拉取待处理的记录,逐条处理。某天早上运维反馈,任务已经跑了一个半小时还没结束,队列积压越来越多,单核 CPU 一直 100%。
我上去第一件事是看日志。日志里每隔几秒就会出现同一行业务打印:
processing msg id=20240811-0015这行重复了几百遍。奇怪的是,没有任何 traceback,没有任何异常日志。一条消息反复出现在日志里,说明这个msg id=20240811-0015一直在被拉取、被处理、然后又回来。当时的直觉告诉我:这条消息的处理一定在某个地方不断失败,而失败原因被某种机制吞掉了。
3.2 用栈采样工具让卡住的地方现形
日志看不出来异常,人又没法跟在代码里跑,这时候我选择让运行中的进程“自己开口说话”。Python 生态里有个栈采样思路:给运行中的进程发信号,让它把当前每个线程正在执行的代码栈打印出来。简单说就是让程序告诉我们它此刻“正停在哪一行”。
我采到的堆栈长这样(省略了无关帧):
Thread 0x7f3c...: File "consume.py", line 24, in <module> consume_forever() File "consume.py", line 18, in consume_forever handler.handle(msg) File "consume.py", line 11, in consume_forever continue虽然采样打到的具体行在不断跳动,但有一个信息非常明确:主线程一直在consume_forever的while True循环里转,而且每次经过handler.handle(msg)之后,都进入了continue。一个continue本身没什么,但结合“同一个 msg id 反复出现”,基本可以确定循环体在处理这条固定消息时不断抛错、又不断被吞掉。
3.3 根因定位:裸 except 把队列重投与中断信号一起吞了
打开源码,问题全貌立刻浮出水面:
while True: try: msg = broker.receive() handler.handle(msg) broker.ack(msg) except: continue这段代码有三个连环错误。
第一,handler.handle(msg)对那条坏消息必然抛异常,裸except:把所有异常都吞了,处理失败的真实原因完全没有记录。
第二,broker.ack(msg)和handler.handle(msg)挤在同一个 try 块里。处理失败时,ack根本没有机会执行。队列服务是按 at-least-once 语义投递消息的:不 ack 就当作没消费成功,过一会儿重新投递。于是这条消息不停地被重新投递,任务就陷入无限重试。
第三,裸except:连KeyboardInterrupt都吞掉了。我们按 Ctrl+C 想终止进程,进程毫无反应,因为中断信号被这个 except 当成普通异常接住,然后continue又回到循环顶部继续跑。
我把症状和原因对应起来,一目了然:
| 现象 | 根因 |
|---|---|
| 日志只有重复的业务打印,没有 traceback | 异常被except: continue吞掉 |
| 同一 msg_id 反复出现 | ack 未执行,队列按语义重投 |
| Ctrl+C 无效 | 裸 except 捕获了 KeyboardInterrupt |
| CPU 打满 | 循环内快速失败、快速重试,无限空转 |
3.4 修复方案:让异常在该响的地方响起来
改法其实不复杂,核心是拆分职责:接收消息时的超时属于“正常等待”,处理失败属于“业务异常”,确认消费应该放在处理成功之后。
while True: try: msg = broker.receive() except TimeoutError: # 短时间内没有新消息,正常 continue try: handler.handle(msg) except Exception: logger.exception("处理消息失败, msg_id=%s", getattr(msg, "id", None)) broker.move_to_dlq(msg) # 送死信队列,避免无限重投 continue broker.ack(msg)改动点逐个说:
- 用
except TimeoutError替代裸 except——TimeoutError才是这个场景里预期会出现的异常,continue是合理的正常行为; handle失败时调用logger.exception,把 traceback 和 msg_id 一起记录,让它成为一个“响的异常”;- 失败消息进死信队列,后续人工排查,而不是让队列服务一遍遍重投;
ack放在handle成功之后、自己的 try 块之外,保证“只有真正处理成功才确认消费”。
复盘时我们给团队立了一条规矩:异常默认应该大声。吞掉异常的那一刻,必须写清楚为什么可以吞,以及配套的告警在哪。我现在看别人代码,第一个搜索的就是except: pass和except Exception: pass,出现一处审一处,基本没有误伤。
4. finally、return 与上下文管理器:资源清理该放哪边
异常处理不只包括“捕获什么”,还包括“退出时干什么”。Python 里负责“退出清理”的主要是finally和with语句。这一节说的都是我在代码里真实见到过的坑。
4.1 finally 里的 return 会悄悄覆盖 try 里的 return
finally的语义是:无论 try 块是正常结束、异常结束、还是执行了 return 结束,finally里的代码都一定会跑。很多人以为这是“收尾专用”,确实没错,但收尾代码里如果写了return,就会引发一个反直觉的行为。
def compute(): try: return 42 finally: return 0 print(compute()) # 输出 0try里的return 42已经把返回值放进寄存器了,但在真正交出控制权之前,Python 会先去执行finally,finally里的return 0直接把这个返回值替换掉了。最终函数返回的是 0,不是 42。我最初见到这段代码时以为是 IDE 显示问题,后来才意识到这是 CPython 的明确语义。
同样危险的是finally里的continue和break,它们也会覆盖 try 块里已有的跳转意图。所以我的建议很干脆:finally里只做清理动作,比如关文件、关连接、恢复配置,绝对不要 return、break、continue。
还有个更隐蔽的坑:finally里的清理代码本身如果又抛异常,会覆盖原始异常。
try: do_risky() finally: cleanup() # 假如这里抛了 OSError原本的异常会被清理代码的异常顶掉,你查日志时只能看到OSError,真正的业务异常反而被覆盖了。这也是为什么现代 Python 推荐用with语句而不是手动 try/finally 来做资源清理。
4.2 with 语句为什么比手动 try/finally 更省心
with语句本质上调用对象的__enter__和__exit__两个魔术方法。文件对象之所以能配合with使用,是因为它的__exit__里封装了“关闭文件”的逻辑:
with open("data.txt", "r", encoding="utf-8") as f: content = f.read()这段代码等价于:
f = open("data.txt", "r", encoding="utf-8") try: content = f.read() finally: f.close()差异在于:with把“退出时该做什么”固化在对象自身里,调用方不需要每次手写清理逻辑。文件对象、锁对象、数据库连接、socket 连接等,只要实现了上下文管理协议,你就应该优先用with。它能保证即使函数提前 return、即使函数中途抛异常,资源也会被释放。
__exit__有个特性值得说一句:它会收到exc_type、exc_value、exc_tb三个参数。如果__exit__返回了True,异常会被抑制,不再往外传播。很多初学者自己实现上下文管理器时容易在这里踩坑:__exit__返回一个非 None 的值,异常被莫名吞掉。除非你是故意要抑制,否则__exit__应该返回False或 None。
4.3 contextlib 里的三个实用工具
Python 标准库contextlib里有一批好用的工具,能让你少写很多样板代码。
第一个是contextlib.suppress,适合“明确知道某个异常可以忽略”的场景:
from contextlib import suppress import os with suppress(FileNotFoundError): os.remove("temp.log")等价于:
try: os.remove("temp.log") except FileNotFoundError: pass它比 try/except/pass 的优势是意图清晰:正在表达“这个文件删除失败是因为它本来就不存在,这不重要”。注意 suppress 里的异常类型要尽量具体,别在这里放Exception,否则又变成裸吞了。
第二个是contextlib.closing,适合那些只有close()方法、没有实现__exit__的资源对象:
from contextlib import closing with closing(get_legacy_client()) as client: client.query(...)第三个是我用得最多的contextlib.contextmanager,它允许你用生成器函数写自定义上下文管理器。比如我想临时改一组配置、用完自动恢复,就可以这样写:
from contextlib import contextmanager @contextmanager def patch_config(config, **kwargs): backup = {key: config.get(key) for key in kwargs} config.update(kwargs) try: yield finally: for key, value in backup.items(): if value is None: config.pop(key, None) else: config[key] = value with patch_config(config, timeout=10, retries=3): run_experiment()这里的关键点是:yield必须放在try里面。因为with块里的代码一旦抛异常,异常会从yield处重新抛进生成器函数,你才有机会在finally里做恢复。如果把try放在yield外面,异常进来时清理逻辑根本不会执行。
5. 自定义异常、异常链与日志:让报错信息真正可诊断
生产环境里,最有价值的异常处理产出物是一句“能把问题定位到具体上下文”的报错信息。这方面有三个工具极其重要:异常链、自定义异常体系,以及正确的日志姿势。
5.1 异常链:raise from 与cause
Python 在异常传播时天然支持“链式上下文”。当你在except块里再raise一个新异常时,新异常会自动带上原始异常作为上下文,打印出来就是两段 traceback。但这样产生的上下文有时不够精确,应该用raise ... from ...来显式声明因果关系。
看一个我经常举的配置加载例子:
import json class ConfigError(Exception): pass try: with open("config.json", encoding="utf-8") as fp: cfg = json.load(fp) except FileNotFoundError as e: raise ConfigError("配置文件不存在: config.json") from e except json.JSONDecodeError as e: raise ConfigError("配置文件不是合法JSON") from efrom e会设置新异常的__cause__属性,traceback 里会出现一行“The above exception was the direct cause of the following exception”,把底层原因和上层表现串起来。排查线上问题时,这种链式信息能让你从“配置文件不是合法JSON”一路追到具体是哪一行解析失败。
有时候你想彻底切断上下文,比如底层只是一个单纯的字符串转整数失败,上层错误信息已经完全说明了情况,这时可以用from None:
try: return int(user_input) except ValueError: raise InvalidInput("必须输入整数") from Nonefrom None会设置__suppress_context__,Traceback 只显示新的异常,不再展示背景里那个白噪声一样的转换错误。用什么时机?我的标准是:底层异常信息对解决当前问题没有增量价值时,可以用from None切断;否则尽量保留。
5.2 自定义异常体系怎么搭
给一个稍微上点规模的项目写异常处理,量身定制的异常类比直接用内置异常合适得多。内置异常表达的是“语法级/运行级”的错误,业务异常应该表达“这个业务领域里发生了什么不满足预期的事”。
我常用的搭建方式是:定义一个项目根异常,然后按模块或业务领域分派子类。
class ToolError(Exception): """整个工具所有异常的基类""" class ConfigError(ToolError): pass class NetworkError(ToolError): pass class DataValidError(ToolError): pass这样设计有立竿见影的好处:调用方如果只想整体兜住业务异常,可以except ToolError;如果只想处理配置错误,可以except ConfigError;两全其美。
自定义异常还可以带上结构化属性。比如配置异常不仅要说明“路径不对”,还要把路径字符串本身存起来,方便后续自动化处理:
class ConfigError(ToolError): def __init__(self, message, path): self.path = path super().__init__(f"{message} (path={path})")带属性的异常在排障时尤其好用,日志关键字可以直接提取。但要提醒一点:不要在异常体系里继承多个内置异常搞“多重身份”,那会让匹配规则变成一团浆糊。一个项目一个根,根下面分层,就足够解决绝大多数问题。
5.3 日志里只记一个 str(e) 等于白记
异常处理最常见的败笔之一,就是把异常对象转成字符串塞进普通日志。举个反例:
try: result = pay_service.pay(order_id, amount) except PayTimeout: logger.error(f"支付超时: {PayTimeout}")这段日志记录了“支付超时”四个字,仅此而已。没有订单号、没有金额、没有发起支付的时间、没有当时重试到第几次、没有堆栈。事后你想复盘,连从哪一行发起支付都查不到。
正确写法是:
import logging logger = logging.getLogger(__name__) try: result = pay_service.pay(order_id, amount) except PayTimeout: logger.exception("支付超时, order_id=%s, amount=%s, attempt=%s", order_id, amount, attempt) raise OrderHandleError("支付结果未知,需要人工确认") from None两个细节值得展开。
一是logger.exception只能在except块里调用,因为它是基于sys.exc_info()获取当前异常的。如果你在 except 外面调用logger.exception,日志里只会出现一句NoneType: None,没有任何价值。
二是日志参数用占位符而不是 f-string。logger.exception("... order_id=%s", order_id)会在日志系统内部按需格式化,而 f-string 会在传入前就拼接好字符串。两者在结果上差不多,但占位符方式不会额外创建字符串对象,在高频日志路径上更省内存,也能避免某些奇怪的类型转换异常把日志系统也带崩。
如果你用的 Python 版本是 3.11 及以上,还有个非常好的原生气笽:BaseException.add_note。它可以在异常向上传播的过程中给异常不断追加上下文:
try: response = http_get(url) except TimeoutError as e: e.add_note(f"request_url={url}, attempt={retry}, timeout_ms={timeout_ms}") raise每一层都可以往里加自己观察到的信息,最后打出来的 traceback 会把这些 note 一并展示。这比在异常字符串里手工拼上下文优雅得多,排查跨层问题时尤其好使。
6. 线程、asyncio 与性能边界:异常处理的高阶必修课
把单线程流程里的异常处理写对,已经能解决 80% 的问题。剩下 20% 集中在并发场景和性能权衡上,这两个方向是我见过最多“代码看着没问题,跑起来就是不对”的重灾区。
6.1 线程里抛异常,主线程根本接不到
Python 的线程各自跑各自的函数栈,所谓的“主线程 try/except 包住子线程函数”是完全无效的。看这段:
import threading def worker(): raise RuntimeError("booom") t = threading.Thread(target=worker) t.start() t.join() print("任务执行完成")worker里的RuntimeError只会在子线程内部打印出 traceback,然后线程退出。t.join()不会把它抛到主线程,主线程照样接着打印“任务执行完成”。如果你的排障方式是在join()外面包一层 try/except,那我可以直接告诉你:白包了,异常根本不会经过那个 try。
解决这个问题,最简单的路径是让concurrent.futures帮你管理线程,并利用Future.result()把异常带回调用方:
from concurrent.futures import ThreadPoolExecutor def handle(item): return 100 // item with ThreadPoolExecutor(max_workers=4) as pool: futures = [pool.submit(handle, item) for item in items] for fut in futures: value = fut.result() # 这里的异常会抛回主线程fut.result()在任务执行异常时会重新抛出子线程里发生的异常,这时候你的 try/except 才有意义。如果你坚持用裸threading.Thread,那就得自己用队列把异常传回来,代码会繁琐很多,除非有特殊理由,否则推荐直接上线程池。
6.2 asyncio 任务里的异常:gather 与取消阻塞
asyncio 的异常传播路径同样不直观。asyncio.create_task创建的任务会被事件循环调度,如果你创建任务后既不await也不cancel,任务里的异常就会被静默吞掉,等任务对象被垃圾回收时,控制台才会冒出一句“Task exception was never retrieved”。
看这个例子:
import asyncio async def fetch(i): if i == 2: raise RuntimeError(f"fetch {i} failed") return i async def main(): tasks = [asyncio.create_task(fetch(i)) for i in range(3)] # 如果直接走,task 2 的异常被静默丢弃 # 正确姿势: results = await asyncio.gather(*tasks, return_exceptions=True) for result in results: if isinstance(result, Exception): print("任务异常:", type(result).__name__, result) asyncio.run(main())asyncio.gather提供了两种处理模式:默认模式下,只要有一个任务抛异常,整个 gather 立刻传播异常;设置return_exceptions=True之后,异常对象会作为返回值塞进 results 列表里,由你逐个判断。批量并发任务时用后者非常顺手,因为你通常不想因为一条失败就放弃其它已成功的任务。
如果你在create_task和await之间还做了取消操作,要格外小心CancelledError。它继承自BaseException,不是Exception,所以except Exception也接不到,通常也不应该去接。正确的做法是让取消信号正常穿透,如果需要做清理,用try/finally在 finally 里收尾。
6.3 别用异常当业务分支,也别因为“性能”不敢用异常
性能这个事被讨论过很多次,很多人对 Python 异常的印象是“异常很慢,别用”。这句话只说对了一半。
try块本身在正常情况下几乎零开销,因为 Python 解释器在执行字节码时并不会预支检查。真正贵的是raise的那一刻:解释器要构建异常对象、填写 traceback 栈帧信息、再执行栈展开。所以“写 try/except 会拖慢程序”这个说法不准确,准确说法是“频繁抛出异常会拖慢程序”。
这个特性给了我们一个清晰的分寸感:
- 如果一个错误情况几乎不会发生,它是真正的“意外”,放心用异常,别为了省这点开销去写一堆防御性 if;
- 如果一个错误情况经常发生,比如用户字段缺失、数据缺失是常规业务状态,那就用 if/else 或默认值,不要用异常来传业务信号。
Python 社区有个经典讨论叫 LBYL(Look Before You Leap)和 EAFP(Easier to Ask for Forgiveness than Permission),后者更贴合 Python 风格。看这个例子:
# LBYL 风格 if "age" in data: age = int(data["age"]) else: age = 0 # EAFP 风格 try: age = int(data["age"]) except KeyError: age = 0两种写法都能用,但我更推荐把“try”缩小到真正可能出错的表达式上。比如上面 EAFP 的写法可以再细分:int(data["age"])这一步可能抛KeyError、TypeError、ValueError三种异常,如果只用except KeyError接,其它两种会正常冒出来,这反而帮了你——数据存在但格式不对,是另一个等级的问题,应该让它发出声音。
再补一个高频坏习惯:把互不相关的多行操作塞进同一个 try 里。比如:
try: do_a() do_b() except OSError: cleanup()如果do_b抛了OSError,你很难判断问题出在 a 还是 b,而且你的cleanup()可能只对 a 的状态有效。把 try 块尽量收窄到单条可能失败的操作,catch 到异常后,你才能准确知道“是谁失败了、需要清理什么”。
最后说个我自己的经验:异常处理和日志一样,属于“平时多花一分钟,排查省两小时”的活儿。我写代码时的自检顺序很简单——先问这段代码会不会发生预期外的失败;再问失败后我希望它继续沉默还是向上报;最后问报出去的信息是否足以让一个不看代码的人定位问题。三个问题过完,异常处理基本不会出错。