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

这种组织方式有三个好处:

  1. 调用代码可以捕获PaymentError来处理所有支付错误,或单独捕获InsufficientFundsError(如需要)。
  2. 异常携带结构化数据(order_id、balance、amount)而不是仅一个字符串。处理端无需解析消息。
  3. 你的异常与系统异常(如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写测试来检查错误类型和错误后的状态。

应用这些规则,你的追踪栈会更易读,日志更整洁,错误不再无声地被吞掉。

pythonexceptionerror-handlingpytestbest-practices