Python 系列(四十三) 别只会处理异常!学会自定义异常,让程序更容易维护
1 | 作者:李晓辉 |
- Python 系列(一) 从零开始认识 Python,入门其实没那么难
- Python 系列(二) 学 Python 的第一步,把开发环境搭建好
- Python 系列(三) 基础语法避坑指南,缩进、大小写、续行与注释
- Python 系列(四) Python 输入输出详解,print()、input() 与常见避坑指南
- Python 系列(五) 数值类型与运算符详解,整除、浮点精度与进制转换实战
- Python 系列(六) 字符串处理实战,查找、替换、切片与日志分析
- Python 系列(七) 类型转换详解,int()、float()、str() 与金额计算避坑
- Python 系列(八) Python 流程控制入门,掌握 if、elif 和 else
- Python 系列(九) Python 逻辑运算符详解,and、or、not 一篇搞懂
- Python 系列(十) while、for 循环实战,彻底搞懂 range、break 与 continue
- Python 系列(十一) list、tuple、set、dict,到底该怎么选?
- Python 系列(十二) List 列表实战,搞定数据增删改查与批量处理
- Python 系列(十三) Tuple 元组实战,搞懂不可变特性与 enumerate() 遍历
- Python 系列(十四) Set 集合实战,搞定数据去重与集合运算
- Python 系列(十五) Dict 字典详解,掌握快速查找与键值操作
- Python 系列(十六) sort() 和 sorted() 详解,掌握数据排序技巧
- Python 系列(十七) 自定义函数入门,让重复代码一次编写多次使用
- Python 系列(十八) 函数参数详解,搞懂位置参数、关键字参数和默认参数
- Python 系列(十九) 从 *args 到 **kwargs,让函数调用更灵活
- Python 系列(二十) 变量作用域是什么?一文搞懂 global 和 nonlocal
- Python 系列(二十一) 搞懂变量、对象和引用,理解函数参数传递机制
- Python 系列(二十二) map() 和 filter() 详解,轻松实现批量数据处理与筛选
- Python 系列(二十三) 嵌套函数与 lambda 表达式详解,搞懂作用域与闭包
- Python 系列(二十四) 搞懂递归函数,从基线条件到实际应用
- Python 系列(二十五) 聊聊模块与 import,让代码告别重复编写
- Python 系列(二十六) Python 模块入门,从 import 到模块复用
- Python 系列(二十七) Python dir() 函数详解,快速查看模块中的函数与属性
- Python 系列(二十八) 不会用 Python 标准库?教你快速查找模块和函数
- Python 系列(二十九) 搞懂 Python sys 模块,让脚本更适合自动化运维
- Python 系列(三十) 为什么 0.1 + 0.2 不等于 0.3?彻底搞懂 Decimal
- Python 系列(三十一) 搞懂 datetime 日期时间,轻松处理时区与时间计算
- Python 系列(三十二) 面向对象编程入门,从零理解类、对象、封装与继承
- Python 系列(三十三) 什么是类和对象?零基础理解 Python class
- Python 系列(三十四) Python 属性详解,搞懂类属性、实例属性和 setter
- Python 系列(三十五) 学会特殊方法,让对象打印出你想要的内容
- Python 系列(三十六) 多个对象如何共享数据?彻底搞懂类变量
- Python 系列(三十七) 搞懂父类、子类和方法重写,真正理解继承与多态
- Python 系列(三十八) 如何判断数据类型?三个内置函数一次讲明白
- Python 系列(三十九) 搞懂异常模型与常见异常类型,读懂程序报错
- Python 系列(四十) 从零学会异常处理,掌握 try、except、else 和 finally
- Python 系列(四十一) ValueError、TypeError 到底是什么?一文搞懂常见异常
- Python 系列(四十二) ValueError、TypeError 到底是什么?一文搞懂常见异常
- Python 系列(四十三) 别只会处理异常!学会自定义异常,让程序更容易维护
- Python 系列(四十四) 从零认识 assert 断言,让程序学会检查自己
- Python 系列(四十五) 使用 sys 模块处理标准输入与输出
- Python 系列(四十六) 使用 open() 读取和写入文件
- Python 系列(四十七) 学会读写文本文件,让程序保存运行结果
- Python 系列(四十八) 搞懂 bytes、bytearray 和二进制读写
- Python 系列(四十九) 使用 seek() 和 tell() 灵活控制文件读取位置
- Python 系列(五十) 掌握 os 和 os.path,实现文件与目录自动化管理
- Python 系列(五十一) 掌握列表推导式,轻松实现列表生成与数据筛选
- Python 系列(五十二) 掌握字典推导式,让字典创建更简单
- Python 系列(五十三) 从零理解生成器,轻松处理大量数据
- Python 系列(五十五) 使用 zip() 同时遍历多个集合
为什么需要自定义异常
前面我们学习了 Python 的异常处理,知道可以使用 try 和 except 捕获程序运行过程中出现的异常,也了解了如何使用 raise 主动抛出异常。
但在实际开发中,Python 内置的异常并不一定能够准确表达业务中出现的问题。
假设我们正在编写一个 Linux 服务器巡检程序,需要检查服务器的运行时间。如果服务器运行时间过短,可能意味着它刚刚发生过重启,需要进一步确认是否存在异常。
例如,我们规定服务器运行时间至少应该达到 24 小时。如果运行时间不足 24 小时,程序就需要生成一条告警信息。
当然,我们可以直接使用 Python 内置的 ValueError 表示运行时间不符合要求。但如果程序中还需要处理磁盘空间不足、服务状态异常等问题,仅仅看到一个通用异常类型,就不容易快速判断具体发生了什么。
能不能创建一个专门表示服务器运行时间异常的异常类型?
当然可以。
Python 允许我们定义自己的异常类型,这就是自定义异常。
创建自定义异常
在 Python 中,自定义异常通常通过继承内置的 Exception 类来实现。
先来看一个最简单的例子:
1 | class ServerUptimeTooShortError(Exception): |
将这段代码保存到 main.py 文件中。
这里的 ServerUptimeTooShortError 表示服务器运行时间不足,是我们自己定义的异常类型。
class是定义类的关键字。ServerUptimeTooShortError是自定义异常的名称。Exception是 Python 内置的常规异常基类。继承它后,我们定义的类就可以作为异常使用。pass表示暂时不需要添加其他代码。
为什么要继承 Exception?
因为我们希望这个异常能够与 Python 原有的异常处理机制配合使用,例如使用 raise 抛出异常,再通过 except 捕获异常。
这样,一个简单的自定义异常就创建完成了。
主动抛出自定义异常
定义异常类型之后,我们还需要编写检查逻辑,决定什么时候抛出异常。
继续修改 main.py,写入下面的完整代码:
1 | # 定义自定义异常 |
保存文件后,执行:
1 | python3 main.py |
程序会输出类似下面的异常信息:
1 | Traceback (most recent call last): |
实际文件路径和行号可能有所不同。
我们来分析程序的执行过程。
首先,程序将 uptime_hours 设置为 12,表示模拟服务器已经连续运行了 12 小时。
接下来,程序判断:
1 | if uptime_hours < 24: |
由于 12 小于 24,条件成立,程序执行 raise 语句,抛出 ServerUptimeTooShortError 异常。
因为当前代码没有捕获这个异常,程序就会显示异常追踪信息,最后的 print() 不会执行。
需要注意的是,自定义异常不会自动检查服务器状态。我们仍然需要编写条件判断,并根据检查结果决定是否抛出异常。
另外,这里的运行时间是模拟数据。实际项目中,需要通过系统接口或命令获取真实的运行时间,再进行检查。运行时间不足也不一定意味着服务器发生了故障,是否告警应根据具体业务需求确定。
捕获自定义异常
前面的例子展示了如何抛出自定义异常,但在实际程序中,我们往往不希望遇到错误就直接终止整个程序。
例如,服务器巡检程序可能需要依次检查多台服务器。如果其中一台服务器的检查结果不符合要求,我们希望记录问题并继续检查其他服务器,而不是让整个巡检任务提前结束。
这时,就可以结合之前学习的 try 和 except 来捕获自定义异常。
继续修改 main.py:
1 | # 定义自定义异常 |
执行命令:
1 | python3 main.py |
预期输出:
1 | 服务器运行时间检查通过:48 小时 |
这一次,程序没有因为第二台服务器的运行时间不足而直接终止。
我们结合执行过程来看。
- 第一次循环,
uptime_hours的值是48。条件uptime_hours < 24不成立,函数正常输出检查通过的信息。 - 第二次循环,
uptime_hours的值是12。条件成立,函数使用raise抛出ServerUptimeTooShortError异常。 - 异常被
except ServerUptimeTooShortError as e捕获。变量e保存异常对象,我们通过{e}输出其中的错误信息。 - 异常被处理后,程序继续执行下一次循环,因此第三台服务器仍然能够正常检查。
- 循环结束后,程序继续执行最后一条
print()语句。
这里有一个重要的知识点:异常被捕获并处理后,程序会继续执行异常处理结构之后的代码,而不是返回到抛出异常的位置重新执行。
在这个例子中,异常处理结构位于循环内部,因此捕获异常后,循环能够继续处理下一台服务器。
理解异常处理流程
前面分别学习了自定义异常的定义、抛出和捕获。现在,我们把这些步骤串联起来,看看一个异常从产生到被处理,究竟经历了什么。
flowchart TD
A["使用 raise 抛出异常"] --> B{"当前代码能否捕获异常?"}
B -->|能| C["执行匹配的 except 处理逻辑"]
C --> D["继续执行异常处理结构之后的代码"]
B -->|不能| E["异常向外层调用代码传播"]
E --> F{"是否找到其他异常处理代码?"}
F -->|是| C
F -->|否| G["输出未处理异常的追踪信息"]
G --> H["终止当前线程的正常执行"]
H --> I["主线程中通常导致程序退出"]这张流程图可以帮助我们理解几个关键点。
异常并不是自动产生的。
在自定义异常的场景中,程序通常先通过 if 判断业务条件,再决定是否执行 raise。如果条件不成立,程序就按照正常流程继续执行。
异常抛出后,会改变正常的执行顺序。
当程序执行 raise 时,会立即离开当前代码的正常执行路径,并尝试寻找能够处理该异常的代码。如果当前函数没有处理它,异常就会向调用它的上层代码传播。
捕获异常后,程序可以继续运行。
如果 except 匹配到了异常,程序就会执行对应的异常处理代码。处理完成后,会继续执行异常处理结构之后的代码,而不是返回到抛出异常的位置继续执行。
没有捕获的异常不一定立即终止整个系统。
如果异常一直没有被捕获,Python 通常会输出异常追踪信息,并终止当前程序。但在 Web 服务、后台任务等复杂程序中,具体影响范围还取决于异常传播到哪里,以及外层框架如何处理它。
理解这套流程后,我们就可以更有针对性地设计程序中的错误处理逻辑。
自定义异常应该怎么设计
初学阶段,使用 pass 创建一个简单的自定义异常通常就足够了。不过,在编写更复杂的程序时,还需要注意几个问题。
优先继承 Exception
对于一般的业务异常,可以直接继承 Exception:
1 | class ServerUptimeTooShortError(Exception): |
不建议为了表示普通业务错误而直接继承 BaseException。BaseException 还包含 KeyboardInterrupt、SystemExit 等用于控制程序退出或中断的异常,通常不应该把它们当作普通业务错误处理。
异常名称应该清楚地表达问题
例如:
1 | class ServerUptimeTooShortError(Exception): |
这些名称分别表示服务器运行时间不足、服务器配置无效和磁盘空间不足。
在实际项目中,可以根据业务需要设计异常类型,但不必为每一个简单的判断都创建一个新异常。只有当某类错误需要被单独识别、捕获或处理时,自定义异常才更有价值。
不要把所有异常都混在一起捕获
例如:
1 | try: |
这里明确捕获了 ServerUptimeTooShortError,因此程序能够针对这种错误执行特定的处理逻辑。
如果不加区分地捕获所有异常,可能会把真正的程序缺陷也当成普通业务问题处理,导致故障难以发现。
动手练习
现在,我们把刚才的知识应用到一个简单的磁盘巡检场景中。
假设巡检程序需要检查磁盘使用率。当磁盘使用率超过 90% 时,程序应该抛出一个自定义异常 DiskUsageTooHighError。
请尝试完成下面的任务:
- 定义
DiskUsageTooHighError异常类。 - 编写
check_disk_usage()函数,接收一个磁盘使用率参数。 - 当使用率超过 90 时,抛出自定义异常,并提示“磁盘使用率过高”。
- 使用
try和except捕获这个异常。 - 分别测试使用率为 75 和 95 时的执行结果。
提示:你可以参考前面 check_server_uptime() 函数的结构,将运行时间判断替换为磁盘使用率判断。
建议先自己编写代码并运行,再对照下面的参考答案检查。
动手练习参考答案
编写完整代码
在当前目录下创建 disk_check.py 文件,将下面的代码写入文件:
1 | # 定义自定义异常,用于表示磁盘使用率过高 |
运行程序
打开终端,进入 disk_check.py 所在的目录,执行:
1 | python3 disk_check.py |
预期输出:
1 | 磁盘使用率正常:75% |
分析程序的执行过程
检查第一次数据:75%
程序第一次循环时,变量 usage 的值为 75。
函数执行以下判断:
1 | if usage > 90: |
因为 75 没有超过 90,条件不成立,因此不会执行 raise 语句,程序继续执行 print(),输出磁盘使用率正常的信息。
检查第二次数据:95%
第二次循环时,变量 usage 的值为 95。
此时,95 > 90 成立,程序执行:
1 | raise DiskUsageTooHighError( |
这条语句创建并抛出一个 DiskUsageTooHighError 异常,同时携带当前磁盘使用率的错误信息。
由于函数调用位于 try 代码块中,异常会被下面的代码捕获:
1 | except DiskUsageTooHighError as e: |
这里的 e 表示捕获到的异常对象。通过 {e},我们可以输出异常携带的错误信息。
异常被捕获后,程序不会因为这次磁盘检查失败而直接终止,而是继续执行下一次循环。
最后输出任务完成信息
两次检查结束后,循环退出,程序继续执行:
1 | print("磁盘巡检任务已完成") |
因此,即使第二次检查触发了异常,程序仍然能够完成整个巡检流程。
为什么要使用自定义异常
这个例子中,我们也可以直接使用 Python 内置的 ValueError。但使用 DiskUsageTooHighError,能够让程序更加明确地表达磁盘使用率超标这一业务问题。
例如,当程序需要分别处理磁盘空间不足、配置错误和网络连接失败时,就可以针对不同的异常类型编写不同的处理逻辑,而不必将所有问题混在一起。
需要注意的是,这里使用的是模拟数据,并没有真正读取 Linux 服务器的磁盘使用率。在实际运维脚本中,可以先通过系统命令或 Python 库获取磁盘使用率,再调用 check_disk_usage() 进行检查。
另外,当前示例只判断使用率是否超过 90%,没有验证输入是否为有效的数字。实际项目中,如果数据来源不可靠,还应该根据需求增加参数校验。
写在最后
通过本文,我们学习了如何使用 class 和 Exception 定义自定义异常,使用 raise 主动抛出异常,以及使用 try 和 except 捕获自定义异常。
回顾一下这几个关键点:
class用于定义自定义异常类。Exception是常规自定义异常通常继承的基类。raise用于主动抛出异常。try和except可以捕获指定的异常,并执行相应的处理逻辑。
到这里,我们已经了解了 Python 异常处理的基本方法,包括捕获内置异常、主动抛出异常,以及创建自己的异常类型。
不过,在某些情况下,我们希望程序在运行时检查一个条件是否成立。例如,我们可能希望确认服务器的 CPU 使用率没有超过预设阈值,或者确认传入函数的参数符合预期。
除了使用 if 编写条件判断,Python 还提供了一个专门用于调试和检查条件的语句:assert。
下一篇,我们就来学习 Python 中的 assert 断言,看看它与普通的 if 判断有什么区别,以及在实际开发中应该如何正确使用。
