Python从入门到实战(十二):错误与异常处理
目录
一、错误与异常
编写 Python 代码时,我们不仅要规划程序的正常执行流程,更要妥善处理各类异常情况
本文将深入 Python 的错误与异常处理机制。在开始系统化地捕获和处理它们之前,我们先来解剖它们的本质
1. 程序为什么会报错
在现实世界中,程序运行在复杂的物理环境和多变的用户输入中。一个程序会报错,本质上是因为现实情况偏离了代码的预期设想
常见的报错成因可以归结为以下三类:
-
程序员的疏忽:写错了单词、写漏了符号、算错了边界
-
外界环境的不可控:网络突然中断、目标文件被用户删除了、数据库连接超时
-
用户的误操作:要求输入数字却输入了英文字母、上传了格式不对的头像
报错是计算机的自我保护机制。当程序检测到后续指令存在安全隐患时,会立即停止执行而非继续运行错误数据,从而有效防止更严重的数据损坏
2. 基本概念
什么是错误
在 Python 的语境中,错误是指代码不符合 Python 解释器的语法规范
-
发生阶段:发生在代码编译/解析阶段,此时程序甚至还没有真正开始运行
-
Python 解释器在把你的代码翻译成字节码时,发现代码不合规,解释器会直接拒绝执行
# 语法错误示例:if 后面丢了冒号
if True
print("Hello")
注意:这类错误是无法通过运行时的 try...except 捕获处理,因为程序在语法检查阶段就被终止了。唯一有效的解决方法是修正代码中的语法问题
什么是异常
异常是指代码语法完全正确,但在运行过程中由于外部条件不满足或逻辑漏洞,导致无法继续执行
-
发生阶段:发生在代码的运行阶段
-
从语法上看,这行代码没有错误;但在实际运行时,底层数据或环境配置却引发了问题
# 语法正确,但运行时会产生异常
x = 10
y = 0
result = x / y
3. 异常也是对象
在 Python 的世界里,"万物皆对象",异常也不例外
当程序在运行期发生错误时,Python 解释器并不仅仅是在控制台打印几行红字,它在后台其实做了两件事:
-
实例化一个异常对象:Python 会根据错误的类型,自动在堆内存中创建该异常类的一个实例。这个对象里封装了详细的报错信息(报错原因、发生在哪一行代码、调用栈信息等)
-
抛出对象:将这个创建好的异常对象抛给当前的运行环境。如果我们的代码没有捕获这个对象,程序就会崩溃并退出
# 我们可以手动拿到并打印这个异常对象
try:
num = int("abc") # 这会产生 ValueError
except ValueError as e:
# 这里的 e 就是被创建出来的异常对象
print(f"异常的类型是: {type(e)}")
print(f"异常的内容是: {e}")

二、Python 异常体系
在 Python 的世界里,异常并不是孤立存在的。既然异常是对象,那么它们必然是由对应的类实例化出来的
为了高效、有序地管理这些异常,Python 采用面向对象的继承机制,构建了一棵异常家族树
1. 异常家族继承树
在 Python 中,所有的异常类都必须直接或间接地继承自最顶层的基类。以下是 Python 异常体系中最核心的继承关系图:

2. BaseException 与 Exception
BaseException(终极祖先)
-
本质:它是 Python 中所有异常、错误、系统信号类的绝对父类。在业务代码中,绝对不要轻易去捕获 BaseException
-
原因:像 SystemExit(系统退出)和 KeyboardInterrupt(用户在控制台按 Ctrl+C 强行中止程序)都是直接继承自 BaseException。如果你用 except BaseException: 去捕获,就会导致用户按 Ctrl+C 也无法关掉程序,或者代码内部正常的退出指令被强行拦截,导致程序变成无法关闭的僵尸进程
Exception(常规异常)
-
本质:它是所有与 "程序本身逻辑、数据或外界环境相关" 的常规运行期异常的共同基类
-
工程原则:当我们需要捕获 "几乎所有可能会发生的常规异常"时,应该且只应该捕获 Exception
-
原因:捕获 Exception 会安全地避开 SystemExit 和 KeyboardInterrupt,既能保证代码出错时不崩溃,又能保障系统控制权的正常交接
3. 父类捕获时的向下兼容
异常家族树的继承关系遵循面向对象的多态:捕获一个父类异常时,该父类之下的所有子类异常都会被自动捕获
例如,从继承树中可以看到:
-
ZeroDivisionError 继承自 ArithmeticError,而 ArithmeticError 继承自 Exception
-
如果你写 except ArithmeticError:,那么一旦代码抛出 ZeroDivisionError,它也会被成功捕获
-
如果你写 except Exception:,那么树中属于 Exception 分支下的所有子类都会被它捕获
# 验证用父类捕获子类异常
try:
# 抛出 IndexError (子类)
names = ["Alice"]
print(names[100])
except LookupError as e: # LookupError 是 IndexError 的父类
print(f"成功捕获!异常具体类型是: {type(e)}")

在编写异常捕获逻辑时,异常类的书写顺序至关重要。如果把父类写在前面,子类的捕获分支将永远没有机会被执行
三、异常处理
在 Python 中,try...except 是处理异常的核心机制。它能够将可能引发错误的代码包裹起来,并在异常发生时执行备用方案,从而避免程序直接崩溃
1. 为什么需要异常处理
异常处理并不是去简单的抹除错误,而是给程序赋予其以下能力:
-
容错与降级:当核心流程因不可抗力出错时,程序可以迅速切换到备用方案(例如:主数据库挂了,自动切换到只读备用数据库;获取用户头像失败,自动展示默认头像)
-
资源释放:无论程序是正常结束还是中途崩溃,都必须保障断开网络、关闭文件,确保服务器物理资源的安全归还
-
日志回溯:在保护程序不崩溃的同时,将详尽的报错堆栈信息记录到日志系统,供开发人员在不影响生产运行的前提下进行复盘与 Debug
2. 基础捕获
最简单的捕获方式是:指定要捕获的异常类型,并使用 as 关键字给创建出来的异常实例起一个别名(通常用 e),以便在捕获后读取具体的错误信息
# 简单示例:将用户输入转换为整数
input = "abc"
try:
num = int(input)
except ValueError as e:
# 这里的 e 就是被创建出来的 ValueError 异常对象
print(f"数据转换失败!错误原因: {e}")
尽量避免写不带任何异常类型的 except。这样会隐式地捕获包括 SystemExit 和 KeyboardInterrupt 在内的所有异常,导致程序无法被正常关闭
3. 多个 except
在实际业务中,一段代码可能会抛出多种不同类型的异常。我们可以像写 if...elif 一样,为不同的异常准备不同的处理方案(即多分支捕获)
这里有一个重要的顺序原则:子类异常必须写在父类异常的前面
因为 Python 在匹配 except 时是自上而下进行的。如果父类写在前面,由于继承的 "向下兼容" 特性,它会把子类异常也顺手捕获,导致写在后面的子类捕获分支直接失效
# 读取并计算数据
data = [10, 0] # 第二个元素为 0,会触发除0异常
try:
# 尝试读取第二个元素作为分母进行除法计算
result = 100 / data[1]
except IndexError as e:
# 1. 针对索引越界的特定处理
print(f"索引越界了: {e}")
except ZeroDivisionError as e:
# 2. 针对除零错误的特定处理
print(f"分母不能为零: {e}")
except Exception as e:
# 3. 兜底捕获:其他常规异常在这里处理
print(f"发生了其他未知错误: {e}")
试想一下:如果把 except Exception as e: 放在最上面,那么 IndexError 和 ZeroDivisionError 都进不去各自的专属分支了,因为它们都会在第一层被 Exception 拦截
4. 合并捕获
如果业务逻辑中有几种不同的异常可以用同一种方式来处理,此时不需要写一堆重复的 except 块,可以用元组将它们合并
# 语法格式:except (异常类1, 异常类2, ...) as e:
try:
# 一个既可能类型错误,也可能值错误的操作
val = int("abc") + "10"
except (ValueError, TypeError) as e:
print(f"输入的数据或者类型不合法: {e}")
5. 验证异常继承关系
我们在前文提到了 Python 的异常家族树。那么在代码里,如何去验证我们捕获到的异常对象的父子继承身份呢?
最直接的方法就是使用内置函数 isinstance。通过它,我们可以直观看到异常的多态性:
try:
# 除零错误
num = 10 / 0
except ZeroDivisionError as e:
print(f"捕获到了 ZeroDivisionError 的实例 e")
# 验证 e 是不是 ArithmeticError 的实例?
print(f"e 是 ArithmeticError 的实例吗? {isinstance(e, ArithmeticError)}")
# 验证 e 是不是 Exception 的实例?
print(f"e 是 Exception 的实例吗? {isinstance(e, Exception)}")
# 验证 e 是不是 BaseException 的实例?
print(f"e 是 BaseException 的实例吗? {isinstance(e, BaseException)}")
# 验证 e 是不是 ValueError 的实例?
print(f"e 是 ValueError 的实例吗? {isinstance(e, ValueError)}")

四、完整异常处理结构
在 Python 中,异常处理不仅仅是 try...except 这么简单。Python 为提供了功能完备的四部结构:try-except-else-finally
这个结构明确划分了 "正常运行"、"出错处理"、"成功后的后续操作" 以及 "最后的清理工作"
1. else 语句
-
机制:else 块中的代码,仅当 try 块中没有发生任何异常时才会执行。如果 try 块报错进入了 except,else 就会被跳过
-
存在的意义:很多开发者习惯把所有代码都塞进 try 块里。但如果在处理 "成功后的结果" 时,后续的代码不小心报错了,它就会被前面的 except 误捕获,导致定位困难。把不需要保护的后续逻辑放进 else,可以缩小 try 的保护范围,让代码意图更清晰
# 示例:尝试将字符串转换为数字,成功后再进行复杂的数学计算
def safe_square(text: str) -> None:
try:
# 这里只放可能报错的转换操作
num = int(text)
except ValueError:
print("错误:输入的不是合法数字!")
else:
# 转换成功才执行这里的计算,即使这里报错,也不会被上面的 ValueError 拦截
res = num ** 2
print(f"计算成功,平方值为: {res}")
2. finally 语句
-
机制:无论 try 块里是正常执行,、被 except 捕获异常,还是发生未知错误导致崩溃,finally 块中的代码都会确保执行,绝不会被跳过
-
所有资源释放操作——包括关闭文件、断开网络连接、释放数据库锁等关键收尾工作——都必须严格写在finally代码块中
finally 执行顺序
哪怕你在 try 或者 except 块里写了 return、break 甚至是再次抛出异常,Python 解释器在底层的物理控制流出该函数之前,都会强行先去把 finally 块里的代码执行一遍
def check_finally() -> str:
try:
print("1. 执行 try")
return "返回 try 的结果"
finally:
print("2. 执行 finally")
res = check_finally()
print(f"3. 最终收到的返回值: {res}")
运行结果:

3. 完整写法示例
我们用一个 "模拟读取并处理文件" 的案例,把这四个关键字串联起来
def read_and_parse(file_name: str) -> None:
file = None
try:
print(f"1. 尝试打开物理文件: {file_name}")
# 模拟打开文件(如果文件不存在会抛出 FileNotFoundError)
file = open(file_name, "r", encoding="utf-8")
data = file.read()
except FileNotFoundError as err:
# 只有报错时才执行
print(f"[Except] 捕获错误:找不到文件!具体原因: {err}")
else:
# 只有成功读取时才执行
print("[Else] 读取成功!开始解析文件内容...")
print(f"文件内容为: {data}")
finally:
# 无论成功还是失败,只要文件被打开了,就必须物理关闭它
print("[Finally] 开始清理资源...")
if file:
file.close()
print("文件已安全关闭。")
五、主动抛出异常
在编写代码时,除了等待 Python 解释器在出错时自动抛出异常外,开发人员还可以根据特定的业务规则,利用 raise 关键字 主动在代码中制造并抛出异常对象
1. raise 关键字
raise 关键字的核心是将一个指定的异常对象显式地提交给当前的运行环境。其语法格式为:
raise 异常类名("具体的描述信息")
当执行到这一行代码时,Python 解释器会立即中断当前的正常控制流,将传入的异常类实例化,并开始执行异常传递与寻找处理器的匹配机制
2. 为什么要主动抛出异常
在软件工程领域,主动报错机制具有关键的防御价值,主要体现在两个方面:
-
逻辑校验:Python 解释器只能感知底层的错误(如除零、类型不匹配),但它无法感知业务层面的不合规。例如,从技术上看,存款金额为 -100 是一个完全合法的浮点数,但在银行结算业务中这是绝对不允许的。此时必须由人工逻辑介入并抛出异常
-
快速失败原则:在数据进入系统的入口处(如函数参数输入端)立即进行强类型或强值校验。一旦发现数据不合规,立刻主动报错切断执行流,防止错误的脏数据向系统深层蔓延,从而避免引发难以排查的链式隐蔽错误
3. raise 应用案例
以下通过一个简化的 "银行账户提现" 函数示例,展示如何使用raise语句来防范违规操作:
def withdraw(balance: float, amount: float) -> float:
"""
执行账户提现操作
"""
# 1. 校验提现金额是否合法
if amount <= 0:
# 主动抛出值错误,阻止后续逻辑执行
raise ValueError("提现金额必须大于 0 元。")
# 2. 校验余额是否充足
if amount > balance:
# 主动抛出常规异常,提示业务逻辑冲突
raise RuntimeError("账户余额不足,提现失败。")
# 3. 校验通过,执行业务扣款
new_balance = balance - amount
return new_balance
# 测试用例验证
try:
# 模拟一次余额不足的非法提现
current_wallet = 100.0
current_wallet = withdraw(current_wallet, 150.0)
except RuntimeError as error:
print(f"业务警报: {error}")
try:
# 模拟一次输入负数金额的请求
current_wallet = 100.0
current_wallet = withdraw(current_wallet, -50.0)
except ValueError as error:
print(f"参数警报: {error}")
六、异常传递机制
当异常被抛出时,程序并不会立刻无条件崩溃。Python 拥有一套完善的异常向上传递机制。理解这一机制,是编写多层架构、模块化系统时进行合理异常设计的基础
1. 什么是异常传递
当一个函数在执行时发生了异常(无论是系统自动抛出还是使用 raise 主动抛出),如果该函数内部没有对应的 try...except 语句来捕获并消化这个异常,这个异常对象就会像水底的气泡一样,沿着函数的调用链路,自底向上地层层冒泡
如果在整条调用链路上,没有任何一个函数准备了except 处理器,那么异常最终会飘到最外层的全局作用域。此时,Python 解释器只能选择终止整个进程,并在控制台打印出调用栈回溯信息
2. 异常传递图解
为了更直观地理解异常在函数调用链中的流动方向,我们用一张图来展示:

搜索逻辑:
-
异常在 func_c 中产生,解释器首先检查 func_c 内部是否有匹配的 except
-
如果没有,解释器立即清理并退出 func_c 的执行上下文,把异常抛给其直接调用者 func_b
-
如果 func_b 内部也没有,继续出栈,抛给 func_a
-
沿着调用栈一路逆向溯源,直到在某一层碰到了匹配的 except,异常才会被拦截。程序随后在该捕获点之后继续正常执行
3. 代码验证
我们编写一段嵌套调用代码,并在最底层的函数中手动抛出异常,观察它是如何在最外层被拦截的
def func_c():
print(" [C] 开始执行底层计算...")
# 在最底层函数主动抛出异常
raise ValueError("底层物理设备读数异常!")
print(" [C] 这行代码永远不会被执行")
def func_b():
print(" [B] 调用 func_c 之前")
# func_b没有任何 try-except
func_c()
print(" [B] 调用 func_c 之后")
def func_a():
print("[A] 调用 func_b 之前")
try:
func_b()
except ValueError as e:
# 在 func_a 这一层成功捕获了从 func_c的异常
print(f"[A] 成功拦截底层异常: {e}")
print("[A] 调用 func_b 之后,程序继续安全运行")
# 调用链
print("--- 启动程序 ---")
func_a()
print("--- 程序结束 ---")
七、自定义异常
Python 内置了丰富的异常类,但在大型开发中,这些通用异常很难表达复杂的业务含义。为了让代码更贴近真实工程场景,我们需要自定义异常
1. 为什么需要自定义异常
-
区分错误:如果系统抛出了一个 ValueError,排查人员很难一眼看出这是底层的技术 Bug(如字符串转数字失败),还是上层的业务规则冲突(如用户输入了负数年龄)
-
拦截与分流:高层调度系统可以针对特定的业务异常(如密码太短)做出对应的UI弹窗提示,而把真正的技术错误(如数据库挂了)统一导流到系统崩溃日志中
2. 如何定义自定义异常
在 Python 中定义自定义异常很简单:只需创建一个类,并让继承 Exception 即可(切记不要继承 BaseException)
# 自定义异常类
class DomainBusinessError(Exception):
"""业务逻辑异常的基类"""
pass
3. 自定义异常案例
我们通过一个 "年龄、密码与成绩校验" 案例来直观理解自定义异常的应用
# 1. 声明异常类
class InvalidAgeError(Exception):
"""年龄非法异常"""
pass
class WeakPasswordError(Exception):
"""密码长度不足异常"""
pass
class InvalidGradeError(Exception):
"""学生成绩越界异常"""
pass
# 2. 编写函数
def create_account(age: int, password: str) -> None:
"""创建系统账户"""
if age < 0 or age > 130:
# 抛出年龄异常
raise InvalidAgeError(f"非法的年龄: {age} 岁。有效范围为 0-130。")
if len(password) < 6:
# 抛出密码异常
raise WeakPasswordError(f"密码太短: 仅有 {len(password)} 位。至少需要 6 位。")
print("账户安全校验通过")
def record_score(score: float) -> None:
"""录入期末成绩"""
if score < 0.0 or score > 100.0:
# 抛出成绩异常
raise InvalidGradeError(f"成绩不合规: {score} 分。满分 100,不能为负。")
print(f"成绩 {score} 分已录入。")
我们模拟提交各种不合规的数据,观察上层系统是如何依靠自定义异常进行拦截的:
# 1.输入负数年龄
try:
create_account(age=-5, password="secret_admin")
except InvalidAgeError as e:
print(f"年龄输入有误 -> {e}")
# 2.输入过于简单的简短密码
try:
create_account(age=25, password="123")
except WeakPasswordError as e:
print(f"密码强度不足 -> {e}")
# 3.录入超出上限的异常成绩
try:
record_score(score=150.0)
except InvalidGradeError as e:
print(f"拒绝录入该分数 -> {e}")
# 4.合规的输入
try:
create_account(age=18, password="super_password")
record_score(score=95.5)
except Exception as e:
print(f"发生未知未知系统故障: {e}")
输出示例:

总结
本章围绕 Python 的错误与异常处理机制展开,学习了异常的基本概念、异常继承体系、异常捕获与处理方式、异常传递机制以及自定义异常类。通过合理地使用异常处理机制,我们能够在程序发生错误时及时发现问题、优雅地处理异常,提高程序的健壮性和可维护性。同时,理解异常的传播过程和继承关系,也有助于我们在实际开发中更加准确地定位和解决问题
下一篇文章我们将继续学习 Python 模块与包,了解模块的导入方式、包的组织结构以及代码复用与项目管理的方法,为后续进行工程化开发打下基础

更多推荐


所有评论(0)