Python异常处理的正确方法
1. 概述
Python提倡EAFP风格:先尝试做,有错就处理。因此异常在Python代码中无处不在。使用不当会导致异常被吞掉、追踪栈难以阅读和日志重复。
本文介绍核心技术:try/except/else/finally、捕获正确的异常类型、自定义异常系统、使用raise ... from连接异常、日志记录、ExceptionGroup以及如何编写测试。读完后,你会掌握一套清晰的规则立即应用到项目中。示例代码使用Python 3.11及以上版本。
2. try/except/else/finally的结构
一个完整的异常处理块有四个部分:
def parse_quantity(raw: str) -> int:
try:
quantity = int(raw)
except ValueError:
print(f"数量无效: {raw!r}")
return 0
else:
print("解析成功")
return quantity
finally:
print("解析完成")
print(parse_quantity("3"))
print(parse_quantity("ba"))
各部分的作用:
try:只包含可能抛出异常的代码,这里是int(raw)。except ValueError:当int()无法解析字符串时运行。else:当try没有抛出异常时运行。“正常路径”代码应该放在这里,而不是塞在try里。finally:总是运行,即使有return或异常。这部分适合清理资源。
输出:
解析成功
解析完成
3
数量无效: 'ba'
解析完成
0
注意finally在返回值被打印之前运行。Python在离开函数之前立即执行finally。
2.1. 为什么要用else
比较以下两种写法:
# 不好的做法:try范围太宽
try:
quantity = int(raw)
total = quantity * unit_price # 如果unit_price是字符串会怎样?
except ValueError:
total = 0
如果unit_price是字符串,乘法不会抛出ValueError。但其他计算可能会抛出ValueError,而你会把它误认为是“数量错误”。应该让try尽可能窄。把其他部分放在else里。
3. 捕获具体的异常,不要捕获所有异常
最常见的错误是使用except:或except Exception:然后忽略它们:
import time
def fetch_order_status(order_id: str) -> str:
try:
time.sleep(10) # 模拟缓慢的API调用
return "PAID"
except: # 非常糟糕
return "UNKNOWN"
except:会捕获KeyboardInterrupt和SystemExit。当按下Ctrl+C时,程序不会停止,反而返回"UNKNOWN"。Python的异常系统分层如下:
BaseException
├── SystemExit
├── KeyboardInterrupt
├── GeneratorExit
└── Exception
├── ValueError
├── KeyError
├── OSError
└── ...
规则:
- 捕获你能真正处理的最具体的异常类型,例如
KeyError、TimeoutError。 - 只在应用边界捕获
Exception:worker循环、高层HTTP处理器。使用时,务必记录日志。 - 除非立即重新抛出,否则不要使用
except BaseException或except:。
你可以用元组在一个分支中捕获多个异常类型:
def read_price(prices: dict[str, str], sku: str) -> float:
try:
return float(prices[sku])
except (KeyError, ValueError) as exc:
print(f"无法读取{sku}的价格: {type(exc).__name__}")
return 0.0
print(read_price({"A1": "12.5", "B2": "abc"}, "B2"))
print(read_price({"A1": "12.5"}, "Z9"))
无法读取B2的价格: ValueError
0.0
无法读取Z9的价格: KeyError
0.0
4. 为你的域定义异常
对于特定的业务需求,你应该为模块创建一个基础异常,然后从它继承:
class PaymentError(Exception):
"""支付模块的基础异常。"""
class InsufficientFundsError(PaymentError):
def __init__(self, order_id: str, balance: float, amount: float) -> None:
super().__init__(
f"订单{order_id}:余额{balance}不足以支付{amount}"
)
self.order_id = order_id
self.balance = balance
self.amount = amount
class CardDeclinedError(PaymentError):
pass
class PaymentService:
def __init__(self, balance: float) -> None:
self.balance = balance
def charge(self, order_id: str, amount: float) -> None:
if amount > self.balance:
raise InsufficientFundsError(order_id, self.balance, amount)
self.balance -= amount
用法:
service = PaymentService(balance=100)
try:
service.charge("ORD-42", 250)
except InsufficientFundsError as exc:
print(exc)
print("缺少:", exc.amount - exc.balance)
except PaymentError:
print("其他支付错误")
订单ORD-42:余额100不足以支付250
缺少: 150
这种组织方式有三个好处:
- 调用代码可以捕获
PaymentError来处理所有支付错误,或单独捕获InsufficientFundsError(如需要)。 - 异常携带结构化数据(
order_id、balance、amount)而不是仅一个字符串。处理端无需解析消息。 - 你的异常与系统异常(如
ValueError)分离,因此不会被误捕获。
注意: 继承自
Exception,不要继承BaseException。根据PEP 8惯例,类名应以Error结尾。
5. 使用raise ... from连接异常
当将低级错误转换为域异常时,你应该保留原始原因:
import json
class ConfigError(Exception):
pass
def load_config(text: str) -> dict:
try:
return json.loads(text)
except json.JSONDecodeError as exc:
raise ConfigError("配置文件无效") from exc
load_config("{port: 8080}")
追踪栈将显示两个错误:
json.decoder.JSONDecodeError: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
...
ConfigError: 配置文件无效
from exc将原始错误分配给__cause__属性。调试者立即看到JSON错误在哪一列。
如果你省略from exc,Python仍会通过__context__连接错误,但消息会是*“During handling of the above exception, another exception occurred”*。这暗示你的处理器出错了,而实际上并非如此。应该始终明确写出from。
当原始错误只是无用的内部细节时,使用from None来隐藏它:
def get_customer_tier(tiers: dict[str, str], customer_id: str) -> str:
try:
return tiers[customer_id]
except KeyError:
raise LookupError(f"找不到客户{customer_id}") from None
6. 用context manager清理资源
对于文件、连接或锁,with比手写try/finally更安全:
from pathlib import Path
def count_orders(path: Path) -> int:
with path.open(encoding="utf-8") as f:
return sum(1 for line in f if line.strip())
即使循环抛出异常,文件也总是被关闭。
当你真的想忽略某个错误时,contextlib.suppress比try/except: pass更清晰表达意图:
from contextlib import suppress
from pathlib import Path
with suppress(FileNotFoundError):
Path("cache/orders.tmp").unlink()
读这一行,其他人立即明白是“删除文件(如果存在的话)”。
7. 记录日志并重新抛出异常
在except块中,使用logger.exception()来记录完整的追踪栈:
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("orders")
def process_order(service: PaymentService, order_id: str, amount: float) -> bool:
try:
service.charge(order_id, amount)
except InsufficientFundsError:
logger.warning("订单%s因余额不足被拒绝", order_id)
return False
except PaymentError:
logger.exception("订单%s的未知支付错误", order_id)
raise
return True
注意事项:
logger.exception(...)等同于logger.error(..., exc_info=True)。此函数只应在except内调用。- 裸
raise重新抛出当前异常并保留追踪栈。不要写raise exc,因为追踪栈会添加一行指向这里。 - 已知且预期的错误(
InsufficientFundsError)只需要warning,无需追踪栈。
注意: 在每一层都避免“记录然后重新抛出”的模式。一个错误会在日志中出现五次,造成噪音。只在真正处理错误的地方或应用最外层记录日志。
8. 使用ExceptionGroup同时处理多个错误
从Python 3.11起,你可以同时抛出多个异常。这在验证数据或并行运行多个任务时很有用:
def validate_order(order: dict) -> None:
errors: list[Exception] = []
if order.get("quantity", 0) <= 0:
errors.append(ValueError("quantity必须大于0"))
if not order.get("customer"):
errors.append(KeyError("customer"))
if errors:
raise ExceptionGroup("订单无效", errors)
try:
validate_order({"quantity": 0})
except* ValueError as group:
for e in group.exceptions:
print("值无效:", e)
except* KeyError as group:
for e in group.exceptions:
print("缺少字段:", e)
值无效: quantity必须大于0
缺少字段: 'customer'
与常规except不同,多个except*分支可以同时运行。每个分支接收一个仅包含匹配类型错误的ExceptionGroup子集。如果还有未被任何分支处理的错误,它们会自动被重新抛出。
asyncio.TaskGroup也使用此机制。当多个子任务失败时,你会收到一个ExceptionGroup。
9. 使用pytest测试异常行为
安装pytest:
pip install pytest
为PaymentService和load_config写测试:
# test_payment.py
import pytest
from payment import (
ConfigError,
InsufficientFundsError,
PaymentService,
load_config,
)
def test_charge_raises_when_balance_is_not_enough():
service = PaymentService(balance=100)
with pytest.raises(InsufficientFundsError, match="ORD-42") as exc_info:
service.charge("ORD-42", 250)
assert exc_info.value.amount == 250
assert service.balance == 100 # 余额未被扣除
def test_load_config_keeps_original_cause():
with pytest.raises(ConfigError) as exc_info:
load_config("{port: 8080}")
assert exc_info.value.__cause__ is not None
assert type(exc_info.value.__cause__).__name__ == "JSONDecodeError"
解释:
pytest.raises(...)如果没有异常或异常类型错误,会导致测试失败。match=接收一个正则表达式并与str(exception)匹配。exc_info.value是实际的异常对象。这样你可以检查amount、__cause__等属性。- 第一个测试还检查错误后的状态:余额必须保持不变。这是常被遗忘的部分。
运行测试:
pytest -q
.. [100%]
2 passed in 0.02s
10. 常见错误
吞掉异常(无声地忽视)。
try:
service.charge(order_id, amount)
except Exception:
pass # 订单"成功"了,尽管还没扣款
这种错误几乎无法调试。如果确实需要忽略,请使用suppress和特定的异常类型并注释原因。
finally中的return。
def risky() -> str:
try:
raise RuntimeError("数据丢失")
finally:
return "ok" # exception消失了!
finally中的return、break或continue会丢弃正在传播的异常。从Python 3.14开始,解释器对此情况会发出SyntaxWarning。永远不要这样写。
抛出通用Exception。 raise Exception("错误")迫使调用者捕获Exception,即捕获其他一切。应该使用适当的内置异常(ValueError、TypeError)或域异常。
在热循环中使用异常处理正常流程。 EAFP是Pythonic风格。但如果“错误”情况大多数时间都发生,使用dict.get()或if检查既快又清晰:
# 当key经常不存在时更好
discount = discounts.get(customer_id, 0)
使用assert验证输入。 assert在运行python -O时被禁用。对于来自外部的数据应该使用raise ValueError(...)。
11. 结论
Python中正确的异常处理围绕几个简单的原则:
- 保持
try小,将后续代码放在else中,用finally或with清理。 - 捕获最具体的异常类型。只在应用边界捕获
Exception,并始终记录日志。 - 为你的域创建异常系统,携带结构化数据。
- 使用
raise ... from exc保留原始原因,用裸raise重新抛出。 - 当多个错误同时发生时使用
ExceptionGroup和except*。 - 用
pytest.raises写测试来检查错误类型和错误后的状态。
应用这些规则,你的追踪栈会更易读,日志更整洁,错误不再无声地被吞掉。