Python 异常处理:从入门到工程实践

0 阅读6分钟

异常处理是写出"能跑"和"能用"之间那道真正的分水岭。很多人用了多年 Python,try/except 写得飞起,却在自定义异常、异常链、finally 的边界行为上踩过无数坑。下面这份指南,从核心概念到工程实践,帮你把这块知识彻底理清楚。


一、异常体系的基本结构

Python 的异常本质上是一棵继承树。所有异常都从 BaseException 派生,而我们日常打交道的几乎都是 Exception 的子类。

export_lroain.png

try/except/else/finally 四个关键字各司其职 :

  • try:放可能出错的代码
  • except:捕获并处理异常
  • elsetry没有抛出异常时才执行(常被忽视的好东西)
  • finally:无论如何都执行,用于清理资源
try:
    result = int(input("请输入数字: "))
except ValueError as e:
    print(f"输入不合法: {e}")
else:
    print(f"成功,结果是 {result}")  # 只有没异常时才跑
finally:
    print("无论如何都会执行这里")

二、自定义异常类:正确的打开方式

2.1 为什么要自定义异常?

内置异常太通用了。ValueError 能告诉你"值有问题",但说不清楚是"用户输入非法"还是"配置文件格式错误"。自定义异常让错误有名有姓,调用方可以精准捕获,日志也更清晰。

2.2 层级设计:给项目建一棵异常树

工程上的标准做法是先建一个项目级基类,再按模块细分:

# exceptions.py —— 项目异常统一定义

class AppError(Exception):
    """项目所有自定义异常的根基类"""
    pass

class DatabaseError(AppError):
    """数据库相关错误"""
    def __init__(self, message: str, query: str = None):
        super().__init__(message)
        self.query = query  # 附带上下文信息

class NetworkError(AppError):
    """网络请求相关错误"""
    def __init__(self, message: str, status_code: int = None, url: str = None):
        super().__init__(message)
        self.status_code = status_code
        self.url = url

class ValidationError(AppError):
    """数据校验错误"""
    def __init__(self, field: str, reason: str):
        super().__init__(f"字段 '{field}' 校验失败: {reason}")
        self.field = field
        self.reason = reason

2.3 __str__ 和附加信息

自定义异常可以携带结构化数据,这比把所有信息塞进一个字符串要优雅得多 :

class PCSException(AppError):
    def __init__(self, message: str, reason: str = None):
        super().__init__(message)
        self.reason = reason

    def __str__(self):
        base = f"[PCS_ERROR] {self.args[0]}"
        if self.reason:
            base += f"\n  原因: {self.reason}"
        return base

2.4 Python 3.11+ 的 add_note:动态追加上下文

Python 3.11 引入了 add_note(),可以在不改变异常类型的前提下追加说明,特别适合在异常向上冒泡时层层补充信息 :

try:
    load_config("config.yaml")
except FileNotFoundError as e:
    e.add_note("提示:请先运行 `init` 命令生成默认配置文件")
    raise  # 重新抛出,附带了额外说明

三、异常链:raise from 的精髓

3.1 什么是异常链?

当你在处理一个异常的过程中又触发了另一个异常,Python 会自动把两者关联起来,这叫隐式异常链__context__)。但更推荐的是显式异常链,用 raise ... from ... 明确表达因果关系。

# 隐式链(自动发生,但语义不清晰)
try:
    open("不存在的文件.txt")
except FileNotFoundError:
    raise RuntimeError("初始化失败")  # traceback 会显示两个异常

# 显式链(推荐!语义清晰)
try:
    open("不存在的文件.txt")
except FileNotFoundError as e:
    raise RuntimeError("初始化失败:配置文件缺失") from e

输出的 traceback 会明确写出:The above exception was the direct cause of the following exception,一眼就能看清楚根因。

3.2 用 raise from None 隐藏实现细节

有时候底层异常是内部实现细节,不应该暴露给调用方(比如数据库驱动的原始错误):

try:
    db_driver.execute(sql)
except SomeLowLevelDriverError as e:
    # 不想让调用方看到底层驱动细节
    raise DatabaseError("数据库查询失败", query=sql) from None

from None 会切断异常链,让错误信息更干净。

3.3 异常链的完整示意

export_vdw93f.png


四、finally 的正确姿势与陷阱

4.1 finally 的本质

finally 的承诺是:无论发生什么,我都会跑。不管 try 正常结束、except 捕获了异常、还是遇到 return/break/continuefinally 都不会缺席。这让它成为资源释放的最佳位置。

def read_file(path):
    f = None
    try:
        f = open(path, 'r')
        return f.read()
    except IOError as e:
        print(f"读取失败: {e}")
        return None
    finally:
        if f:
            f.close()  # 即使 return 了,这里也会执行

4.2 三个经典陷阱

陷阱一:finally 里的 return 会吞掉异常

def dangerous():
    try:
        raise ValueError("出错了!")
    finally:
        return "看起来没问题"  # ⚠️ 异常被静默吞掉了!

result = dangerous()  # 不会抛异常,返回 "看起来没问题"

这是最隐蔽的 bug 之一——finally 里的 return无声无息地压制异常

陷阱二:finally 里再次抛异常,原始异常丢失

def also_dangerous():
    try:
        raise ValueError("原始错误")
    finally:
        raise RuntimeError("清理时出错")  # 原始 ValueError 就此消失

陷阱三:finally 不等于"异常被处理了"

finally 执行完后,如果没有 except 捕获,异常依然会继续向上传播。很多初学者以为进了 finally 就万事大吉,其实不然。

4.3 现代写法:用 with 替代手动 finally

绝大多数资源管理场景,with 语句(上下文管理器)比手写 finally 更安全、更优雅:

# 不推荐:手动管理
f = open("data.txt")
try:
    data = f.read()
finally:
    f.close()

# 推荐:with 语句
with open("data.txt") as f:
    data = f.read()  # 退出 with 块时自动关闭,即使抛异常也一样

五、工程实践:让异常处理真正有用

5.1 核心原则速览

原则好的做法坏的做法
精准捕获except ValueErrorexcept Exception 或裸 except:
不要吞异常至少记录日志再 raiseexcept: pass
异常要有意义自定义异常 + 上下文信息只抛 Exception("出错了")
资源管理with 语句手写 finally + close()
异常链raise NewError(...) from eraise NewError(...) 丢失根因

5.2 完整的工程级示例

import logging

logger = logging.getLogger(__name__)

class ServiceError(Exception):
    """服务层统一异常基类"""
    def __init__(self, message: str, code: int = 500):
        super().__init__(message)
        self.code = code

class UserNotFoundError(ServiceError):
    def __init__(self, user_id: int):
        super().__init__(f"用户 {user_id} 不存在", code=404)
        self.user_id = user_id

def get_user(user_id: int) -> dict:
    try:
        # 模拟数据库查询
        raw = db.query(f"SELECT * FROM users WHERE id={user_id}")
        if not raw:
            raise UserNotFoundError(user_id)
        return raw
    except DatabaseConnectionError as e:
        # 将底层异常转换为业务异常,保留因果链
        raise ServiceError("数据库连接失败,请稍后重试", code=503) from e
    except UserNotFoundError:
        raise  # 已经是业务异常,直接向上传
    except Exception as e:
        # 兜底:记录日志,重新抛出
        logger.exception("get_user 发生未预期错误, user_id=%s", user_id)
        raise ServiceError("内部错误") from e

5.3 Python 3.11 异常组:并发场景的新武器

当你用 asyncioconcurrent.futures 跑并发任务,多个子任务可能同时失败。Python 3.11 引入的 ExceptionGroup 专门处理这种情况 :

# 捕获异常组中的特定类型
try:
    async with asyncio.TaskGroup() as tg:
        tg.create_task(task_a())
        tg.create_task(task_b())
except* ValueError as eg:
    for exc in eg.exceptions:
        print(f"捕获到 ValueError: {exc}")
except* NetworkError as eg:
    print(f"共 {len(eg.exceptions)} 个网络错误")

注意这里用的是 except*(带星号),这是专门配合异常组的新语法。


六、一张图总结整个体系

export_o0z5ut.png


小结

Python 异常处理的进阶,核心在于三件事:让异常有意义(自定义异常 + 层级设计)、让因果清晰(显式异常链 raise from)、让资源安全with 语句 + 谨慎使用 finally)。把这三点做好,代码的健壮性和可维护性会有质的飞跃。


参考来源