异常体系与处理
程序只要在跑,就免不了出岔子:文件找不到、网络断了、用户输入了奇怪的东西……出错本身并不可怕,可怕的是程序直接躺平——屏幕上甩出一大坨红色 traceback,然后整个进程退场。Python 为此准备了一整套"异常处理"…
程序只要在跑,就免不了出岔子:文件找不到、网络断了、用户输入了奇怪的东西……出错本身并不可怕,可怕的是程序直接躺平——屏幕上甩出一大坨红色 traceback,然后整个进程退场。Python 为此准备了一整套"异常处理"机制:既能优雅地接住错误、就地恢复,也能在必要的时候主动制造错误,把错误的原因一层层传递下去。
上一章拆解 with 语句时你应该已经注意到了,它底层翻译出来赫然写着 try / except / raise。本章就把这套语法正式讲透——它是后面写健壮的数据处理脚本的地基。
1. 接住异常:try / except 全家桶
1.1 最基本的接法
把"可能出事"的代码圈进 try,把"出了事怎么办"写进 except:
try:
count = int("十二") # 这行必然出事
except ValueError:
print("这不是一个能转成整数的字符串")
如果没有 try,int("十二") 会当场把程序打死;现在它只是被拦下来,打印一句话之后,程序还活着。
1.2 多路拦截,顺便拿到异常对象
不同的问题对应不同的异常类型,可以写多个 except 分支分别对待;再加个 as,还能把异常对象本身抓在手里:
try:
ratio = float("95%")
except ValueError as err:
print(f"内容转不成数字: {err}")
except TypeError as err:
print(f"类型根本不对: {err}")
else:
print("一路平安,才轮到我执行")
finally:
print("不管上面闹成什么样,我都会执行")
四个关键字各司其职:
try:圈出"危险区",区内的代码一抛异常就立刻跳走;except:按类型"认领"异常,命中哪个分支就走哪个;else:try 里从头到尾没出过任何事,才会执行;finally:收尾工作,出不出事都执行(关文件、释放连接这类事放这里最合适)。
1.3 异常对象身上有什么
try:
top = [10, 20, 30][5]
except IndexError as err:
print(err.args) # ('list index out of range',) —— 构造异常时传入的参数
print(err.__traceback__) # 堆栈跟踪对象,记录异常是在哪一行冒出来的
2. 内置异常是一棵家族树
Python 的内置异常不是一盘散沙,而是一棵继承树。捕获父类,等于把它的全部子孙一网打尽:
BaseException
├── SystemExit # sys.exit() 触发
├── KeyboardInterrupt # Ctrl+C 触发
└── Exception # 日常异常的总祖先
├── ArithmeticError
│ └── ZeroDivisionError
├── LookupError
│ ├── IndexError
│ └── KeyError
├── TypeError
├── ValueError
│ └── UnicodeError
└── ...
这张图透露出两条实用信息。
其一,父类可以代收子类。 当你并不在乎"到底是哪种查不到"时,拦父类最省事:
profile = {"name": "Alex"}
try:
print(profile["age"])
except LookupError:
print("没查到这个键")
KeyError 是 LookupError 的子类,所以这一个分支就能把它接住。
其二,拦 Exception 等于拦下几乎所有常规异常,写起来最省心:
try:
run_migration() # 什么异常都可能冒出来
except Exception as err:
print(f"迁移失败: {err}")
但省心的代价是"无差别捕获":连本该暴露出来的 bug 也被一并吞掉了。原则上,能拦多窄就拦多窄,只处理你确实有能力处理的异常;其余的让它炸出来,交给上层调用者或日志去管。
另外注意 SystemExit 和 KeyboardInterrupt 挂在 Exception 之外——所以 except Exception 拦不住它们,你按 Ctrl+C 依然能终止程序,这是刻意的设计。
3. 主动抛出:raise
异常不光能被动接,还能主动制造。当函数发现调用者传来的参数不讲道理时,最好的做法不是默默返回一个错误值,而是 raise 一个异常,把问题大声嚷出来:
def deduct_stock(stock: int, ordered: int) -> int:
"""扣减库存,返回剩余数量"""
if ordered <= 0:
raise ValueError("下单数量必须是正数")
if ordered > stock:
raise ValueError("库存不足,无法下单")
return stock - ordered
try:
deduct_stock(3, 5)
except ValueError as err:
print(err) # 库存不足,无法下单
跟返回 None、返回 -1 之类的"暗号"相比,raise 的好处一目了然:错误带类型、带说明,调用方想装看不见都难。
3.1 转手再抛:光杆 raise
有时接住异常只是为了记一笔日志、做点善后,处理完还打算让它继续往上走。这时用不带参数的 raise,把手里正处理着的异常原样抛回去:
try:
fetch_remote_stats()
except Exception:
print("抓取远端数据失败,先记一笔日志")
raise # 原封不动地重新抛出
注意光杆 raise 只在 except 块里有效,它抛的就是当前正在处理的那个异常,堆栈信息一点不丢。
4. 打造你自己的异常
内置异常终究是通用词汇。真实项目里,我们更希望异常能"自报家门"——一看到异常名就知道是哪块业务出了问题。做法很简单:继承 Exception。
class InvalidCodeError(Exception):
"""编号本身不合法"""
pass
class CourseMissingError(Exception):
"""编号在系统里查无此课"""
def __init__(self, course_name: str, code: int):
self.course_name = course_name
self.code = code
super().__init__(f"{course_name}(编号 {code})不在课程表中")
CourseMissingError 的写法值得细看:重写 __init__,把业务数据(课程名、编号)存进实例属性,再调用 super().__init__ 把拼好的消息交给父类。这样一来,print(err) 有可读的输出;except 住之后,还能直接取 err.code 继续编程。
course_table = {
101: "Python 与数据实践",
102: "统计思维入门",
}
def lookup_course(code: int) -> str:
if code <= 0:
raise InvalidCodeError("课程编号必须是正整数")
if code not in course_table:
raise CourseMissingError("未命名课程", code)
return course_table[code]
5. 异常链:把"前因"挂在"后果"上
在 except 块里再抛一个新异常,是极其常见的操作——底层错误往往要"翻译"成上层语言再往上报告。可新异常抛出去之后,最初那个错误去哪了?Python 的答案是:把它们串成链,并且提供了两种串法。
def relay_implicitly():
"""串法一:直接抛新异常(隐式关联)"""
try:
raise KeyError("原始故障:缓存键失效")
except KeyError:
# 这里可以先做一点补救动作。
raise RuntimeError("上层故障:页面渲染中断") # __context__ 自动记录
def relay_explicitly():
"""串法二:用 from 手动指定因果(显式关联)"""
try:
raise KeyError("原始故障:缓存键失效")
except KeyError as err:
# 做了一点补救动作。
raise RuntimeError("上层故障:页面渲染中断") from err # __cause__ 明确记录
两种串法的差别,藏在异常对象的两个属性里:
try:
relay_implicitly()
except RuntimeError as err:
print("隐式关联:")
print(f" __cause__: {err.__cause__}") # None —— 没声明过因果关系
print(f" __context__: {err.__context__}") # KeyError —— 上下文自动记录
try:
relay_explicitly()
except RuntimeError as err:
print("显式关联:")
print(f" __cause__: {err.__cause__}") # KeyError —— from 指认的"元凶"
print(f" __context__: {err.__context__}") # KeyError —— 上下文同样有记录
规律可以这样记:
- 在 except 块里抛异常,Python 会自动把"当时正在处理的异常"记到
__context__上,这叫隐式关联; - 写
raise 新异常 from 旧异常,旧异常会被记到__cause__上,明确宣告"它是因、我是果",这叫显式关联。
终端里那句 During handling of the above exception, another exception occurred: 就是隐式链在说话;而 The above exception was the direct cause of the following exception: 则是显式链。排查问题时顺着链条往下挖,往往一挖一个准。
习题
一、容错的比值计算
def success_rate(hits, trials):
"""
计算命中率,要求:
1. trials 为 0 时捕获 ZeroDivisionError,返回 0
2. 参数类型不对时捕获 TypeError,打印"统计数字类型不对"并返回 None
"""
# 你的代码
pass
print(success_rate(8, 10)) # 0.8
print(success_rate(3, 0)) # 0
print(success_rate(8, "10")) # 统计数字类型不对,None
二、自动重试装饰器 retry
import time
def retry(times, wait=1):
"""
自动重试装饰器
被装饰的函数若抛出异常,先等待 wait 秒再重试;最多尝试 times 次,
全部失败则把最后一次的异常抛出去
"""
# 你的代码
pass
@retry(times=3, wait=1)
def pull_sensor_data():
"""模拟一个时好时坏的数据采集接口"""
import random
if random.random() < 0.7: # 70% 概率失败
raise ConnectionError("采集器无响应")
return "采集成功"
# 应当能扛住失败并自动重试,最终返回"采集成功",或在三次全败后抛出异常
提示: 带参数的装饰器怎么写,可以回看第 10 章;本题的关键在于"最后一次失败就别再吞了,用光杆 raise 抛出去"。
三、网盘配额管理
class QuotaExceededError(Exception):
"""存储配额已用尽"""
pass
class AccountLockedError(Exception):
"""账号处于锁定状态"""
pass
class CloudDrive:
def __init__(self, quota=100, used=0, locked=False):
self.quota = quota
self.used = used
self.locked = locked
def upload(self, size):
"""
上传文件,要求:
1. locked=True 时,抛出 AccountLockedError
2. size <= 0 时,抛出 ValueError
3. used + size > quota 时,抛出 QuotaExceededError
"""
# 你的代码
pass
def remove(self, size):
"""
删除文件以释放空间,要求:
1. locked=True 时,抛出 AccountLockedError
2. size <= 0 时,抛出 ValueError
"""
# 你的代码
pass
# 测试
drive = CloudDrive(quota=100)
drive.upload(30) # used = 30
drive.remove(10) # used = 20
# drive.upload(200) # QuotaExceededError
# drive.upload(-5) # ValueError
locked_drive = CloudDrive(quota=100, locked=True)
# locked_drive.upload(10) # AccountLockedError
四、把杂音翻译成同一种异常
实现一个函数,把数据采集环节可能冒出来的各种异常,统一转换成 PipelineError:
class PipelineError(Exception):
def __init__(self, code, message):
self.code = code
self.message = message
super().__init__(message)
def read_sensor():
"""模拟读取传感器,可能抛出各种异常"""
import random
failures = [
ValueError("字段格式不合法"),
ConnectionError("采集器掉线"),
TimeoutError("读取超时"),
RuntimeError("固件内部崩溃"),
]
raise random.choice(failures)
def guarded_read():
"""
调用 read_sensor(),把异常统一翻译成 PipelineError:
- ValueError → PipelineError(400, "输入数据不合法")
- ConnectionError / TimeoutError → PipelineError(503, "采集服务暂不可用")
- 其他异常 → PipelineError(500, "采集服务内部错误")
"""
# 你的代码
pass
提示: 转换时用 from 把原始异常挂到链上,报错时能看到完整的来龙去脉。
下一章:14 · 类型标注