异常处理的核心原则可以压成两句话:只捕获你能处理的异常,让不能处理的继续向上传播;错误尽早抛出,捕获尽量延迟。原文总结了七篇异常处理内容后,给出10个高频坑点和检查清单。下面不按原文逐条复述,而是按“错误现象—原因—修正代码—适用场景”重组,代码示例保留原文逻辑并做必要精简。
一、10个常见坑点与修正
1. 裸except什么都吞。裸except会连KeyboardInterrupt和SystemExit一起捕获,用户按Ctrl+C都可能关不掉程序。至少捕获Exception并记录日志:- try:
- do_something()
- except Exception as e:
- logger.error(f'操作失败: {e}')
复制代码 这里的关键不是“多写一个Exception”,而是明确捕获边界,避免把退出信号当成业务异常。
2. 捕获了但不处理。except里只写pass会让调用者完全不知道失败。捕获后至少记录日志、使用默认值、重试或重新抛出:- try:
- result = risky_operation()
- except ValueError as e:
- logger.warning(f'操作失败,使用默认值: {e}')
- result = default_value
复制代码
3. 捕获范围太宽。一个except Exception包住取用户、建订单、支付三段逻辑,出错后无法判断是用户不存在、库存不足还是支付失败。应按异常类型分别处理:- try:
- user = get_user(user_id)
- order = create_order(user, items)
- payment = process_payment(order)
- except UserNotFoundError:
- print('用户不存在')
- except InsufficientStockError:
- print('库存不足')
- except PaymentFailedError as e:
- print(f'支付失败: {e}')
复制代码 精确捕获的前提是底层能抛出有区分度的异常类型。
4. 在finally中使用return。finally里的return会吞掉try中正在传播的异常:- def bad():
- try:
- return 1 / 0
- finally:
- return '看起来正常'
- def good():
- try:
- return 1 / 0
- finally:
- cleanup()
复制代码 finally只做清理,不改变控制流。bad()会返回“看起来正常”,ZeroDivisionError不再向外传播,这是排查时很容易误判的行为。
5. 异常处理中的异常。except块里如果再次写错,原始异常会丢失。例如处理代码引用未定义变量会抛NameError。修正思路是让except块保持简洁,复杂处理放到函数里,并保护处理函数:- try:
- result = 1 / 0
- except ZeroDivisionError as e:
- try:
- handle_division_error(e)
- except Exception as handle_error:
- print(f'错误处理也失败了: {handle_error}')
复制代码 必要时用traceback记录原始异常,避免“处理失败”覆盖“最初失败”。
6. 循环中的异常处理位置不对。把所有循环包在一个try里,遇到第一个非法值就会终止后续处理:- data = ['1', '2', 'abc', '3', 'xyz', '4']
- numbers = []
- for item in data:
- try:
- numbers.append(int(item))
- except ValueError:
- print(f'跳过无效数据: {item}')
复制代码 这样numbers得到[1, 2, 3, 4]。如果业务要求“遇到错误立即停止”,才应把try放到循环外,二者是语义差异。
7. 忘记重新抛出。验证错误可以转成返回结果,但数据库错误如果本层无法恢复,不应吞掉:- def process_request(request):
- try:
- validate(request)
- result = execute(request)
- return result
- except ValidationError as e:
- return {'error': str(e)}
复制代码 数据库错误不在这里捕获,交给上层事务管理处理,避免把可恢复与不可恢复异常混在一起。
8. 忽略异常链。异常转换时只抛新异常,会丢失原始连接错误。使用from保留__cause__:- def business_api():
- try:
- raw_query('SELECT ...')
- except ConnectionError as e:
- raise BusinessError(f'查询失败: {e}') from e
复制代码 这样BusinessError.__cause__指向原始ConnectionError,日志和调试能看到完整因果链。
9. 用异常做流程控制。把KeyError当if-else用既慢也不清晰。dict自带get:- def get_value(data, key):
- return data.get(key)
复制代码 异常应服务于意外情况,而不是替代正常分支。
10. 忘记清理资源。open后如果read抛异常,close不会执行。使用with或finally:- def process_file(path):
- with open(path) as f:
- return f.read()
复制代码 如果受限于旧代码不能用with,就在finally中关闭,并判断文件对象是否已创建。
二、最佳实践
原文给出的黄金法则可以落到七个动作:早抛出,在入口处验证;晚捕获,在真正能处理异常的业务逻辑层或API入口捕获;精确捕获,只为能恢复的异常类型写except;不吞异常,捕获后记录日志、回退、重试或重新抛出;资源释放用with或finally;异常消息要包含可操作信息,例如raise ValueError('年龄必须>0'),而不是“无效输入”;用自定义异常表达领域概念,例如InsufficientBalanceError(current=100, required=500),比通用ValueError更有区分度。
入口验证可以这样写:- def validate_input(data):
- if not data:
- raise ValueError('数据不能为空')
- if 'name' not in data:
- raise ValueError('缺少name字段')
复制代码
异常也要文档化。比如转账函数应在docstring中列出可能抛出的ValueError、InsufficientBalanceError、AccountFrozenError,让调用方知道要处理什么。
三、终极检查清单
所有try都有对应的except或finally;except捕获的是能处理的异常类型;没有被吞掉的异常,每个except都做了记录、回退、重试或重新抛出;资源释放用了with或finally;异常消息清晰有用;异常层次结构合理,项目有自定义异常;关键异常记录到日志。
Python异常处理的哲学是EAFP:先做,再处理意外。但EAFP不等于滥用异常。异常处理是区分“能用的代码”和“健壮的代码”的关键:好的异常处理让程序面对意外时优雅降级,而不是崩溃,也不能把Ctrl+C、原始异常链和资源泄漏一起吞掉。 |