第八章 异常处理及程序调试
上一章结尾我说过,类里那些 raise ValueError("余额不足") 的另一半还没讲。这就是本章的内容。
目录
python
第八章 异常处理及程序调试
│
├── 1. 异常概述 → 异常与错误 / 读懂 traceback / 常见内置异常 / 异常如何传播
├── 2. 异常处理语句 → try except / else / finally / raise / assert / 自定义异常
├── 3. 程序调试 → print 大法 / logging / pdb / IDE 断点 / 定位思路
├── 4. 继承 → 异常的继承体系 / 该继承谁 / 捕获顺序 / 异常链
└── 5. 小结 → 知识清单 + 避坑清单 + 5 道题 + 下一章预告
照旧建个目录第八章,记得多敲多跑。
1. 异常概述
1.1 先让程序崩一次
学异常最快的方式是先看看不处理会怎样。建一个 crash.py,内容就三行。
python
def divide(a, b):
return a / b
print(divide(10, 0))
跑一下。
text
Traceback (most recent call last):
File "crash.py", line 5, in <module>
print(divide(10, 0))
~~~~~~^^^^^^^
File "crash.py", line 2, in divide
return a / b
~~^~~
ZeroDivisionError: division by zero
程序停在第 5 行,后面的代码一行都不会执行。这就是异常的默认行为,中断。
前面七章我为了省地方,所有报错都只贴最后一行。这章专门讲异常,所以把完整的调用栈放出来了,因为它是排查问题最重要的线索来源。
1.2 语法错误不算异常
新手最容易混淆的一对概念,报错信息里都带 Error,可它们根本不是一回事。
| 语法错误 SyntaxError | 运行时异常 Exception | |
|---|---|---|
| 什么时候发生 | 代码执行之前,解析阶段 | 代码跑起来的过程中 |
| 能不能被 try 抓住 | 抓不住,语法都不对 | 能抓住 |
| 典型原因 | 括号没闭合、缩进错、关键字拼错 | 除零、下标越界、键不存在、类型不对 |
| 遇到时怎么办 | 改代码,没别的办法 | 可以捕获处理,也可以让它崩 |
举个例子。少写一个右括号。
python
>>> print("少了个括号"
File "<stdin>", line 1
print("少了个括号"
^
SyntaxError: '(' was never closed
注意那个尖头 ^,它指的是左括号的位置,说的是"这个括号没闭合",不是"末尾缺东西"。很多人第一眼看到这行会去末尾找,找半天找不着。
这种错误,try 是拦不住的。因为 Python 得先把整段代码解析成语法树,解析都过不去,压根轮不到执行。有人不死心,把整段代码塞进 try 里想"统一兜底",括号写错了照样崩。语法层面的问题只能靠改,没有任何运行时的技巧能绕过。
1.3 逐行拆一遍 traceback
回到刚才那个除零。信息量最大的就是这几行,值得一句一句读。
text
Traceback (most recent call last):
File "crash.py", line 5, in <module>
print(divide(10, 0))
~~~~~~~~~~~~^
File "crash.py", line 2, in divide
return a / b
~~^~~
ZeroDivisionError: division by zero
拆开看。
text
Traceback (most recent call last): ← 回溯开始,last 表示最近一次调用在最后
File "crash.py", line 5, in <module> ← 文件名、行号、所在函数名
print(divide(10, 0)) ← 那一行的源码
~~~~~~^^^^^^^ ← 波浪线和尖头指出具体是哪个表达式
File "crash.py", line 2, in divide ← 上一层,调用栈往上走
return a / b
~~^~~
ZeroDivisionError: division by zero ← 异常类型加说明,这行才是结论
读 traceback 的正确顺序是从下往上。最底下的那一行告诉你出了什么事,中间那一串告诉你事情是在哪条调用链上发生的,最上面那行是起点。
我以前是从上往下读的,读到最后才知道出了什么事,前面的信息早就忘了,等于白读一遍。改成从下往上之后,效率完全不同。
那堆波浪线 ~~~ 和尖头 ^^^ 是 Python 3.11 之后才加的,用来精确定位到出错的表达式。3.10 及以前只有一行的纯文本,定位要模糊不少。如果你在老教程里看到没有波浪线的 traceback,那是版本差异,不是你眼花了。
另外注意 <module> 这个特殊名字,它表示这一行在模块顶层,也就是不在任何函数里面。
1.4 常见的内置异常,按出场频率排
Python 内置的异常有六七十个,真正高频遇到的不超过十五个。下面这张表按我这些年的体感排了个序。
| 异常 | 什么时候冒出来 | 典型场景 |
|---|---|---|
SyntaxError |
代码写得不合法 | 括号没闭合、缩进乱了 |
IndentationError |
缩进不对 | 冒号后面忘了缩进,或混用空格和 Tab |
NameError |
名字没定义 | 变量拼错、用了还没赋值的变量 |
TypeError |
类型不对 | 字符串加数字、给函数传多了参数 |
ValueError |
值不对,但类型对 | int("abc")、list.remove(不存在的) |
IndexError |
下标越界 | lst[10] 而列表只有 3 个元素 |
KeyError |
字典键不存在 | d["不存在的键"] |
AttributeError |
对象没有这个属性 | 对象上是 name,你写了 .names |
ZeroDivisionError |
除数为 0 | a / b 而 b 是 0 |
FileNotFoundError |
文件不存在 | open("没有的文件.txt") |
ModuleNotFoundError |
模块没装或名字错 | import reqeusts(拼错了) |
ImportError |
导入了但拿不到名字 | from os import 不存在的东西 |
UnboundLocalError |
局部变量还没赋值就用 | 函数里先打印后赋值 |
StopIteration |
迭代器取完了 | 手动 next() 取过头 |
RecursionError |
递归太深 | 递归忘了写终止条件 |
AssertionError |
assert 断言失败 |
单元测试、内部一致性检查 |
有几个值得单独说。
TypeError 和 ValueError 的区别 ,我记了很久才分清。类型不对是 TypeError,类型对但值不合适是 ValueError。
python
>>> int("abc")
ValueError: invalid literal for int() with base 10: 'abc'
>>> "abc" + 1
TypeError: can only concatenate str (not "int") to str
int("abc") 传的是字符串,这个函数收字符串没问题,可这个字符串的内容没法转成整数,所以是 ValueError。"abc" + 1 是字符串加整数,这个操作本身没定义,属于类型层面就不对,所以是 TypeError。
ModuleNotFoundError 是 ImportError 的子类 。我第一次写 except ImportError 的时候以为抓不到模块缺失,其实能抓到。这两者的关系是下一节第 4 节要讲的重点。
UnboundLocalError 是 NameError 的子类。函数里写了个变量,在赋值之前先读它,报的就是这个。
python
>>> def counter():
... print(total)
... total = 0
...
>>> counter()
UnboundLocalError: cannot access local variable 'total' where it is not associated with a value
这里有个反直觉的地方,total 明明在函数最后一行赋了值,为什么还说是"本地变量"。因为 Python 在编译这个函数的时候,看到函数体里有 total = 0,就把 total 定成了局部变量,整个函数里所有 total 都按局部算。可第一行的 print 跑在赋值之前,局部变量表里还没这个值,就报错了。
这个坑我踩过不止一次。解决办法也简单,要么把赋值提到前面,要么老老实实用不同的变量名。
1.5 异常是沿着调用栈往上冒的
一个函数里出了异常,如果自己不处理,它会往上一层抛,上一层也不处理就继续往上,一直抛到最外层。没人接住的话,程序就崩。
用图来表示这个过程。
scss
调用栈(自下而上)
┌─────────────────────────────────┐
│ main() │ ← 第 3 层,还是不处理 → 程序终止
├─────────────────────────────────┤
│ process(order) │ ← 第 2 层,不处理 → 继续抛
├─────────────────────────────────┤
│ calc_rate(done, total) │ ← 第 1 层,异常在这产生
│ return done / total 💥 │ ZeroDivisionError
└─────────────────────────────────┘
异常冒泡方向 ↑↑↑ 一层一层往外传
写成代码验证一下。
python
def calc_rate(done, total):
return done / total
def process(order):
return "完成率 %.0f%%" % (calc_rate(order["done"], order["total"]) * 100)
def main():
print(process({"done": 3, "total": 0}))
main()
text
Traceback (most recent call last):
File "bubble.py", line 13, in <module>
main()
~~~~^^
File "bubble.py", line 10, in main
print(process({"done": 3, "total": 0}))
~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^
File "bubble.py", line 6, in process
return "完成率 %.0f%%" % (calc_rate(order["done"], order["total"]) * 100)
~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "bubble.py", line 2, in calc_rate
return done / total
~~~~~^~~~~~~
ZeroDivisionError: division by zero
四层调用栈,异常在第一层产生,一路冒到最外层。traceback 把每一层都列出来了,顺序是"最外层在上,出事的地方在下"。
这个"从下往上读"的规则,在这里体现得最清楚。最下面 return done / total 是案发地点,最上面 main() 是程序的入口。中间那些是传递路径。
异常只会被最近的一层接住。 这一点特别重要。
python
def calc_rate(done, total):
return done / total
def process(order):
try:
rate = calc_rate(order["done"], order["total"])
except ZeroDivisionError:
return "完成率 暂无数据"
return "完成率 %.0f%%" % (rate * 100)
print(process({"done": 3, "total": 0}))
print(process({"done": 3, "total": 4}))
text
完成率 暂无数据
完成率 75%
calc_rate 自己没写 try,异常冒到 process 就被接住了,再往上的 main 压根不知道出过事。这就是异常处理的意义,把"在哪里出问题"和"在哪里决定怎么办"分开。
我写代码时的习惯是,底层函数负责报告(raise),中间层或顶层负责决策(try except)。底层不该偷偷把错误吞掉,因为同样的 calc_rate,在报表里要显示"暂无数据",在告警里可能要发通知,决策权应该在调用方手里。
1.6 异常也是对象,它身上带着信息
异常被 as e 抓到之后就是个普通对象,能看能摸。写日志就得靠它身上的这些信息。
python
>>> try:
... int("abc")
... except ValueError as e:
... print("类型", type(e).__name__)
... print("说明", e)
... print("原始", repr(e))
... print("参数", e.args)
...
类型 ValueError
说明 invalid literal for int() with base 10: 'abc'
原始 ValueError("invalid literal for int() with base 10: 'abc'")
参数 ("invalid literal for int() with base 10: 'abc'",)
三样东西各有各的用处。
| 写法 | 得到什么 | 用在哪 |
|---|---|---|
type(e).__name__ |
异常类名,如 ValueError |
日志里分类统计 |
str(e) |
人类可读的说明 | 展示给用户看 |
repr(e) |
类名加说明,带引号 | 写进日志,排查用 |
e.args |
构造时传进去的参数元组 | 需要拿到原始值时 |
KeyError 有个细节值得留意,str(e) 会带引号,repr(e) 不带歧义。
python
>>> try:
... {"a": 1}["b"]
... except KeyError as e:
... print("str ", str(e))
... print("repr", repr(e))
... print("args", e.args)
...
str 'b'
repr KeyError('b')
args ('b',)
str(e) 拿到的是 'b',带引号。直接把这个拼进日志,读的人会以为是字符串 b,其实它是键名。我写日志一律用 repr(e),就是被这个坑过。
2. 异常处理语句
2.1 try 加 except,最小骨架
先看最朴素的写法。
python
try:
# 可能出事的代码
pass
except 异常类型:
# 出事之后的处理
pass
换成能跑的例子。让用户输一个数字,输错了别让程序崩。
python
>>> def to_int(text):
... try:
... return int(text)
... except ValueError:
... return None
...
>>> to_int("42")
42
>>> to_int("abc")
>>> print(to_int("abc"))
None
to_int("abc") 那一行没有输出,是因为 REPL 只在返回值不是 None 的时候才打印。函数走到了 except 分支,返回 None。
try 块里只放可能出事的那几行。 这是我最想强调的一条。
python
import json
def save(x):
print("保存", x)
# 不好的写法,把一大段都包进去了
def run_all(raw):
try:
cfg = json.loads(raw)
port = cfg["port"]
save(port)
except Exception as e:
print("出错了", e)
# 好的写法,谁可能出事就包谁
def run_better(raw):
try:
cfg = json.loads(raw)
except json.JSONDecodeError as e:
# 只有这一步会抛 JSONDecodeError
print("数据格式不对", repr(e))
return
try:
port = cfg["port"]
except KeyError as e:
# 只有这一步会抛 KeyError
print("配置里缺字段", repr(e))
return
save(port)
run_all('{"host": "localhost"}')
run_better('{坏掉的')
run_better('{"host": "localhost"}')
run_better('{"port": 8080}')
text
出错了 'port'
数据格式不对 JSONDecodeError('Expecting property name enclosed in double quotes', line 1 column 2 (char 1))
配置里缺字段 KeyError('port')
保存 8080
第一行的 出错了 'port' 就是宽泛捕获的代价,你只能看到一个 'port',既不知道是哪一步出的,也不知道该去哪改。后面三行是分开写的版本,每一步的失败都落到自己该去的地方,报错信息直接指到病灶。
我给自己定的标准是,try 块超过十行就该想想是不是包太宽了。
2.2 一个 try 配多个 except
不同的异常想用不同的办法处理,就写多个 except。
python
>>> def convert(text):
... try:
... return int(text)
... except ValueError:
... return "这不是整数"
... except TypeError:
... return "这不是字符串"
...
>>> convert("42")
42
>>> convert("abc")
'这不是整数'
>>> convert(None)
'这不是字符串'
int(None) 抛的是 TypeError,int("abc") 抛的是 ValueError,各自落到对应的分支。
匹配是从上往下依次试的,命中第一个就停。 这一点在第 4 节讲继承的时候会要命,先记住。
多个异常可以用同一个办法处理,写成元组。
python
>>> def convert2(text):
... try:
... return int(text)
... except (ValueError, TypeError):
... return "转不了"
...
>>> convert2("abc")
'转不了'
>>> convert2(None)
'转不了'
2.3 else 子句,没出事才跑
try 后面可以跟 else,里面的代码只在没抛异常的时候执行。
python
>>> def safe_div(a, b):
... try:
... result = a / b
... except ZeroDivisionError:
... print("除数不能为 0")
... else:
... print("结果是", result)
... return result
... return None
...
>>> safe_div(10, 2)
结果是 5.0
5.0
>>> safe_div(10, 0)
除数不能为 0
很多人问,else 里的代码直接写在 try 里面不也行吗。
python
>>> def show_all_in_try(a, b):
... try:
... result = a / b
... print("结果是", result)
... except ZeroDivisionError:
... print("除数不能为 0")
...
>>> show_all_in_try(10, 2)
结果是 5.0
区别在这儿,else 里的代码不会被这个 try 的 except 抓住 。上面这种写法里,print 也在 try 里面,万一它抛了 ZeroDivisionError(虽然极少见),会被下面的 except 误抓成"除数不能为 0",错误信息就指错方向了。
更实际的例子。
python
>>> import json
>>> def load_config(text):
... try:
... cfg = json.loads(text)
... except json.JSONDecodeError:
... return {}
... else:
... # 这里的 KeyError 不会被上面那个 except 吞掉
... return {"port": cfg["port"]}
...
>>> load_config('{"port": 8080}')
{'port': 8080}
>>> load_config("坏掉的 JSON")
{}
>>> load_config('{"host": "x"}')
KeyError: 'port'
最后一行 cfg["port"] 抛了 KeyError,它没被 except json.JSONDecodeError 抓住,老老实实报出来了。要是把这段写在 try 里面,这个 KeyError 会被静默转成 {},你就永远查不出配置文件里到底缺了什么字段。
这个区别很微妙,可它决定了你的程序是"诚实报错"还是"假装没事"。
2.4 finally,无论如何都要跑
finally 里的代码不管出没出异常都会执行,通常用来做清理,关文件、关连接、释放锁。
python
>>> with open("notes.txt", "w", encoding="utf-8") as f:
... f.write("第一行内容\n")
...
>>> def read_first_line(path):
... f = None
... try:
... f = open(path, encoding="utf-8")
... return f.readline().strip()
... except FileNotFoundError:
... return "文件不存在"
... finally:
... print("收尾时 f =", f)
... if f is not None:
... f.close()
... print(" 并且已经关掉了")
...
>>> read_first_line("不存在的.txt")
收尾时 f = None
'文件不存在'
>>> read_first_line("notes.txt")
收尾时 f = <_io.TextIOWrapper name='notes.txt' mode='r' encoding='utf-8'>
并且已经关掉了
'第一行内容'
注意第一次调用,文件打开失败,f 还是那个 None,finally 照样执行了,只是没东西可关。这就是 finally 的意思,无条件执行,跟成功失败无关。
顺便说一句,日常写文件操作别这么麻烦,用 with 就行,它内部就是靠 finally 实现的。
python
>>> with open("notes.txt", encoding="utf-8") as f:
... print(f.readline().strip())
...
第一行内容
>>> f.closed
True
出了 with 块之后 f.closed 是 True,文件已经被自动关掉了。
finally 真正的价值在 with 覆盖不到的地方,比如数据库连接的归还、临时目录的清理、计时器的结束。
2.5 finally 里的 return 会吃掉异常
这个坑我踩过两次,值得单独讲。
python
>>> def tricky():
... try:
... return 1
... finally:
... return 2
...
>>> tricky()
2
try 里明明 return 1,最后拿到的却是 2。因为 finally 一定会执行,它里面的 return 把前面的返回值覆盖掉了。
更严重的,它能把异常整个吞掉。
python
>>> def swallow():
... try:
... raise ValueError("重要错误")
... finally:
... return "没事"
...
>>> swallow()
'没事'
ValueError 就这么消失了,调用方拿到一个"没事",后面所有基于这个返回值的逻辑全错。这种 bug 能查到你怀疑人生。
结论很简单,永远不要在 finally 里写 return 。finally 只做清理,不做返回。
2.6 抓住之后什么都不做,等于把头埋进沙子
这条我要说得重一点。
python
try:
do_something()
except Exception:
pass
这四行代码是我在 code review 里最常打回的写法。它把所有的错误都吞了,你永远不会知道 do_something 有没有成功。程序继续往下跑,用的是一个残缺的状态,等崩的时候已经是几百行之后了,现场早没了。
如果确实需要忽略某个异常,至少做两件事,一是只忽略你确切知道的那一类 ,二是写清楚为什么。
python
>>> import os
>>> temp_file = "cache_tmp.txt"
>>> try:
... os.remove(temp_file)
... except FileNotFoundError:
... # 临时文件可能已经被别的进程清掉了,不影响主流程
... pass
...
>>> print("主流程继续往下走")
主流程继续往下走
except FileNotFoundError 加一句注释,这就能接受了。因为读代码的人知道,这里忽略的是一个明确的、可预期的情况。
对比一下这两种写法的差别。
| 写法 | 后果 |
|---|---|
except Exception: pass |
所有错误被吞,含拼写错误和逻辑 bug |
except FileNotFoundError: pass |
只吞"文件本来就不在"这一种 |
except Exception as e: print(e) |
至少留下痕迹,但还是继续跑了 |
except Exception as e: log.error(...) |
有记录可查,可接受 |
| 不写 try | 程序崩溃,但现场完整,traceback 齐全 |
最后一行那条,很多人觉得不处理就是最差的。其实不是,一个干净的崩溃比一个带病运行的程序好排查得多。
2.7 主动抛异常用 raise
前面的异常都是 Python 自己抛的,你也可以自己抛。
python
>>> def set_age(age):
... if age < 0:
... raise ValueError("年龄不能是负数 %d" % age)
... if age > 150:
... raise ValueError("年龄太大了 %d" % age)
... return age
...
>>> set_age(25)
25
>>> set_age(-1)
ValueError: 年龄不能是负数 -1
raise 后面跟一个异常实例。也可以跟异常类,Python 会自动实例化。
python
>>> raise ValueError
ValueError
>>> raise ValueError("带说明")
ValueError: 带说明
我建议总是带说明 。裸的 raise ValueError 在日志里只有一行 ValueError,没有任何上下文,等于没说。
第七章的银行账户里我写过 raise ValueError("余额不足"),就是这么用的。校验放在最前面,不合法就抛,别让脏数据进到对象里。这个思路叫"快速失败",出问题越早越好。
别用 raise 做流程控制。 有人这么写。
python
>>> class LoginRequired(Exception):
... pass
...
>>> def handle_request(user):
... try:
... if not user:
... raise LoginRequired
... return "首页内容"
... except LoginRequired:
... return "跳登录页"
...
>>> handle_request(None)
'跳登录页'
能跑,可这就是拿异常当 goto 用。同样的逻辑用 if 写只有三行,读起来也不绕。
python
>>> def handle_request_better(user):
... if not user:
... return "跳登录页"
... return "首页内容"
...
>>> handle_request_better(None)
'跳登录页'
异常的设计目的是处理"不该发生的事",不是"另一种正常路径"。if 能说清楚的事别用 raise。
2.8 raise 不带参数,原样重新抛出
在 except 块里写一个光秃秃的 raise,会把当前这个异常原封不动地重新抛出去。
python
>>> def handle():
... try:
... int("abc")
... except ValueError:
... print("记一条日志")
... raise
...
>>> handle()
记一条日志
ValueError: invalid literal for int() with base 10: 'abc'
这个写法的典型用途是,我想在这里记一笔,但我不打算处理它,交给上层决定。
日志系统里很常见这种分层,底层记下现场,然后继续往上抛,让最外层的统一处理器决定是重试、告警还是崩溃。
对比一下三种重抛写法。
| 写法 | 效果 |
|---|---|
raise |
重新抛出当前异常,保留原始 traceback |
raise e |
抛出同一个对象,traceback 会重新记录 |
raise OtherError(...) from e |
换一个类型,用 from 保留因果关系 |
第二种 raise e 我要说一下,它虽然能用,但会丢失一部分原始栈信息。想原样往上抛,就用一个字的 raise。
2.9 assert,给自己看的检查
assert 的语法是 assert 条件, 说明,条件为假就抛 AssertionError。
python
>>> def apply_discount(price, rate):
... assert 0 < rate < 1, "折扣率必须在 0 到 1 之间,收到 %r" % rate
... return price * rate
...
>>> apply_discount(100, 0.8)
80.0
>>> apply_discount(100, 80)
AssertionError: 折扣率必须在 0 到 1 之间,收到 80
assert 和 raise 的区别在哪,我一开始也搞不清。
assert |
raise |
|
|---|---|---|
| 用途 | 检查"这不可能发生"的内部假设 | 报告"确实可能发生"的错误 |
| 面向谁 | 程序员自己 | 调用方 |
| 能不能被关掉 | 能,python -O 会跳过所有 assert |
不能 |
| 典型场景 | 调试、测试、内部不变量 | 参数校验、业务规则 |
最关键的是第三条。python -O 跑的时候,所有 assert 都会被忽略。
拿一个文件试。建个 assert_demo.py,就两行。
python
# assert_demo.py
assert False, "这条会被 -O 跳过"
print("走到了这里")
正常跑,assert 拦住你。
text
$ python assert_demo.py
AssertionError: 这条会被 -O 跳过
加上 -O 再跑一次。
text
$ python -O assert_demo.py
走到了这里
同样一份代码,加不加 -O 行为完全不同。我第一次知道这事的时候后背有点凉,因为我想起自己写过 assert user.is_admin 这种校验。
所以 assert 绝不能用来做参数校验 。设想一下,有人拿 -O 跑你的程序,用户输入了负数,你的 assert age > 0 被跳过了,脏数据就进去了。这种事发生过真实的安全事故。
参数校验一律用 raise ValueError,assert 只用来检查你自己代码内部的假设。
2.10 自定义异常,先把最简单的写法学会
写一个类继承 Exception,就得到了自己的异常类型。
python
>>> class InsufficientFunds(Exception):
... pass
...
>>> try:
... raise InsufficientFunds("余额只剩 30,取 100 不够")
... except InsufficientFunds as e:
... print("交易失败", e)
...
交易失败 余额只剩 30,取 100 不够
为什么不直接 raise Exception("余额不足"),因为类型就是信息。调用方可以针对性地捕获。
python
>>> def withdraw(balance, amount):
... if amount > balance:
... raise InsufficientFunds("余额 %d,取 %d" % (balance, amount))
... return balance - amount
...
>>> try:
... withdraw(30, 100)
... except InsufficientFunds:
... print("提示用户充值")
... except ValueError:
... print("参数有问题")
...
提示用户充值
如果用 raise Exception,就没法只抓住"余额不足"这一种情况,只能一把全抓。
自定义异常该继承谁、怎么写才规范,是第 4 节的内容,这里先会写就行。
2.11 try 的代价,以及 EAFP 和 LBYL
有个说法是"try 很慢,别滥用"。这话在 Python 里只对了一半。
Python 里 try 的进入成本几乎为零,只有异常真的发生、需要展开调用栈的时候才慢。所以有一种被广泛接受的写法叫 EAFP(Easier to Ask for Forgiveness than Permission),翻译过来是"先干了再说,出事再道歉"。
对比两种风格。
python
>>> d = {"a": 1}
>>> def lbyl(d, key, default):
... if key in d:
... return d[key]
... return default
...
>>> def eafp(d, key, default):
... try:
... return d[key]
... except KeyError:
... return default
...
>>> lbyl(d, "a", 0)
1
>>> eafp(d, "a", 0)
1
>>> lbyl(d, "z", 0)
0
>>> eafp(d, "z", 0)
0
在我的机器上跑一百万次,两种写法在"键存在"这个常见情况下的耗时。
python
import timeit
setup = "d = {'a': 1}"
lbyl = timeit.timeit("d['a'] if 'a' in d else 0", setup, number=1000000)
eafp = timeit.timeit("""
try:
v = d['a']
except KeyError:
v = 0
""".strip(), setup, number=1000000)
print("LBYL %.3f 秒" % lbyl)
print("EAFP %.3f 秒" % eafp)
我这边跑出来是 LBYL 0.060 秒 和 EAFP 0.037 秒(Python 3.13)。具体数字跟你机器的性能有关,每次跑也不完全一样,重要的是两者的快慢关系,EAFP 略快,因为它少做了一次查找。
这个差距在绝大多数场景下根本不重要。真正值得在意的是下面这一点。
在多线程和文件操作里,LBYL 有天生的竞态问题。
python
>>> import os
>>> def lbyl_open(path):
... if os.path.exists(path):
... # 检查通过到这里打开,中间有时间窗
... with open(path, encoding="utf-8") as f:
... return f.read()
... return "文件不在"
...
>>> def eafp_open(path):
... try:
... with open(path, encoding="utf-8") as f:
... return f.read()
... except FileNotFoundError:
... return "文件不在"
...
>>> lbyl_open("notes.txt")
'第一行内容\n'
>>> eafp_open("notes.txt")
'第一行内容\n'
>>> eafp_open("不存在的.txt")
'文件不在'
检查通过到真正打开之间有个时间窗,这个窗口里什么都可能发生。所以涉及 IO 的操作,EAFP 是唯一正确的选择。
2.12 一张表总结这一节
| 写法 | 作用 |
|---|---|
try ... except X |
捕获 X 类型的异常 |
try ... except X as e |
捕获并拿到异常对象 |
except (X, Y) |
多个异常同一处理 |
except X ... except Y |
多个异常分别处理,从上往下匹配 |
try ... except ... else |
else 在无异常时执行,且不被本 try 的 except 抓 |
try ... finally |
finally 无条件执行,用于清理 |
raise X("说明") |
主动抛出 |
raise |
在 except 中重新抛出当前异常 |
raise X from e |
抛出新异常并声明原因 |
assert 条件, "说明" |
内部假设检查,-O 会跳过 |
with |
靠 finally 实现的自动清理语法糖 |
3. 程序调试
写代码这件事,调试占的时间比写多。我粗略估过自己的比例,差不多是四比六,写四成,查六成。这一节讲的就是怎么把这六成压下来。
3.1 别小看 print,它是最好使的工具
很多人觉得 print 调试low,非要上调试器。我的看法是,能解决问题的方法就是好方法。程序半夜在服务器上跑挂了,你连不上 IDE,能用的就只有日志和 print。
先看个有 bug 的例子,算购物车总价。
python
def total_price(items):
total = 0
for name, price, count in items:
total += price * count
return total
cart = [("苹果", 5.5, 3), ("牛奶", 3.0, 2), ("面包", 8.0, 1)]
print("总价", total_price(cart))
text
总价 30.5
手算一下是 16.5 + 6 + 8 = 30.5,对的。现在把需求改一下,单价超过 5 块的打九折。
python
def total_price(items):
total = 0
for name, price, count in items:
if price > 5:
price = price * 0.9
total += price * count
return total
cart = [("苹果", 5.5, 3), ("牛奶", 3.0, 2), ("面包", 8.0, 1)]
print("总价", total_price(cart))
text
总价 28.05
数对不对心里没底,那就插 print 看看每一步。
python
def total_price(items):
total = 0
for name, price, count in items:
print("循环开始", name, "原价", price, "数量", count)
if price > 5:
price = price * 0.9
print(" 打折后", price)
total += price * count
print(" 累计", total)
return total
cart = [("苹果", 5.5, 3), ("牛奶", 3.0, 2), ("面包", 8.0, 1)]
print("总价", total_price(cart))
text
循环开始 苹果 原价 5.5 数量 3
打折后 4.95
累计 14.85
循环开始 牛奶 原价 3.0 数量 2
累计 20.85
循环开始 面包 原价 8.0 数量 1
打折后 7.2
累计 28.05
总价 28.05
每一行都摆得清清楚楚,苹果打折后 4.95 乘 3 得 14.85,牛奶不打折 6 块,累计 20.85,面包打折后 7.2,最后 28.05。跟前面那个总数对得上,逻辑没问题。
顺带说个我自己栽过的事。写这段的时候我第一版贴的是 26.15,那个数字是我心算出来的,压根没跑。加上 print 一看,实际是 28.05。凭印象写下的数字,跟代码跑出来的数字,经常不是一回事。这份教程里的每一个输出,我都实跑过一遍才贴上来,就是因为吃过这个亏。
print 调试的价值就在这儿,它把黑盒里发生的事摊开给你看。
3.2 print 的四个升级用法
同样是 print,有人打出来的是噪音,有人打出来的是线索。差别在这几条。
一,加标记。 一堆数字滚过去,你根本不知道哪个是哪个。
python
>>> price = 19.9
>>> count = 3
>>> discount = 0.9
>>> print(price, count, discount)
19.9 3 0.9
>>> print("price=%r count=%r discount=%r" % (price, count, discount))
price=19.9 count=3 discount=0.9
三个数字滚过去,过两天你自己都记不清哪个是单价哪个是折扣。加上名字之后,几个月后回来看也一目了然。
二,用 repr 而不是 str。 字符串里的空格、换行、制表符,用 print 直接打看不出来。
python
>>> s = "hello\nworld\t"
>>> print(s)
hello
world
>>> print(repr(s))
'hello\nworld\t'
>>> data = {"a": 1, "b": " x "}
>>> print(data)
{'a': 1, 'b': ' x '}
>>> print(repr(data))
{'a': 1, 'b': ' x '}
第三行那个 world 后面有个制表符,屏幕上就是一个空格大小的空白,肉眼完全看不出。加上 repr 之后 \t 原形毕露。
我排查过一个解析 bug,两个字符串看着一模一样,== 就是不相等。用 repr 打出来才发现一个是 abc,另一个是 abc 末尾带空格。
三,打印变量的名字和值一起。 最简单的办法。
python
>>> def debug(name, value):
... print("[调试] %s = %r" % (name, value))
...
>>> price = 19.9
>>> debug("price", price)
[调试] price = 19.9
四,Python 3.8 之后有个更省事的写法,f-string 里加等号。
python
>>> price = 19.9
>>> count = 3
>>> print(f"{price=} {count=}")
price=19.9 count=3
>>> print(f"{price * count=}")
price * count=59.699999999999996
最后这行顺便演示了个老问题,19.9 * 3 在二进制浮点里算出来是 59.699999999999996。这不是 bug,是浮点数的固有误差,第二章讲过。这里提一句是因为,调试的时候看到这种数字别以为是自己的代码错了。
f"{price=}" 会自动展开成 price=19.9,变量名和值一起出来。这个语法我天天用,比手写标签省事太多。
print 调试最大的毛病是,加的时候容易,删的时候容易漏 。线上日志里留着一句 print("here111") 的事也不新鲜。所以关键项目里我一律用下一节的 logging。
3.3 logging,print 的正式版
logging 是标准库自带的,不用装。它比 print 强在四个地方,能分级别、能带时间、能写文件、能一键关掉。
最基础的用法。
python
import logging
import sys
logging.basicConfig(level=logging.DEBUG,
format="[%(levelname)s] %(message)s",
stream=sys.stdout)
logging.debug("进入 divide,a=%s b=%s", 10, 0)
logging.info("开始计算")
logging.warning("b 是 0,可能有麻烦")
logging.error("算不出来")
logging.critical("放弃治疗")
text
[DEBUG] 进入 divide,a=10 b=0
[INFO] 开始计算
[WARNING] b 是 0,可能有麻烦
[ERROR] 算不出来
[CRITICAL] 严重错误,程序要退出了
五个级别,从轻到重。
| 级别 | 数值 | 什么时候用 |
|---|---|---|
DEBUG |
10 | 调试细节,变量值、走到哪一步了 |
INFO |
20 | 正常流程的关键节点,服务启动了、任务完成了 |
WARNING |
30 | 有点不对但还能跑,磁盘快满了、配置项缺失用了默认值 |
ERROR |
40 | 某个功能失败了,但程序还能继续 |
CRITICAL |
50 | 严重错误,程序马上要崩 |
级别是道闸门。 把 level 调高,低级别的就不输出了。
python
import logging
import sys
logging.basicConfig(level=logging.WARNING,
format="[%(levelname)s] %(message)s",
stream=sys.stdout)
logging.debug("看不到")
logging.info("也看不到")
logging.warning("这条能看到")
logging.error("这条也能看到")
text
[WARNING] 这条能看到
[ERROR] 这条也能看到
开发的时候开 DEBUG,上线之后调到 WARNING,一行配置的事。print 想做到这一点,只能一行一行去注释。
写进文件 也很简单,加个 filename 参数。
python
import logging
logging.basicConfig(filename="app.log",
level=logging.WARNING,
format="%(levelname)s %(message)s",
encoding="utf-8")
logging.debug("这条不会进文件")
logging.info("这条也不会")
logging.warning("磁盘剩余空间不足")
logging.error("写入失败")
print(open("app.log", encoding="utf-8").read())
text
WARNING 磁盘剩余空间不足
ERROR 写入失败
格式化字符串用逗号传参,别用百分号。 这两行的区别很实在。
python
import logging
import sys
logging.basicConfig(level=logging.WARNING, stream=sys.stdout)
name, count = "张三", 3
logging.debug("用户 %s 下单 %d 件", name, count) # 推荐,不输出时不拼接
logging.debug("用户 %s 下单 %d 件" % (name, count)) # 不推荐,每次都先拼好
logging.warning("用户 %s 下单 %d 件", name, count) # 这条会输出
text
WARNING:root:用户 张三 下单 3 件
第一种写法,只有当这条日志真的要输出的时候,才会去做格式化。第二种不管要不要输出,每次都先把字符串拼好。日志级别调高之后,第一种能省下大量无谓的字符串拼接。
顺带说一句,logging 默认输出到 stderr 而不是 stdout。我上面为了能在文档里展示输出,特意传了 stream=sys.stdout。你自己写的时候不用管这个,默认行为就是对的,因为日志不算程序的正常输出。
别在库代码里调 basicConfig。 它是给整个进程配一次全局配置,谁先调谁说了算。写给别人用的模块,只该 logger = logging.getLogger(__name__) 然后 logger.info(...),配置的事交给用你这个模块的人。
3.4 用 assert 当调试的哨兵
assert 在第 2 节讲过,那里说的是它的局限。在调试阶段它其实很好用,相当于在函数入口出口各装一个检查站。
python
def binary_search(lst, target):
# 这个函数要求 lst 必须是有序的,先确认一下
assert lst == sorted(lst), "二分查找要求列表有序,收到 %r" % lst
lo, hi = 0, len(lst) - 1
while lo <= hi:
mid = (lo + hi) // 2
if lst[mid] == target:
return mid
if lst[mid] < target:
lo = mid + 1
else:
hi = mid - 1
return -1
print(binary_search([1, 3, 5, 7, 9], 7))
print(binary_search([9, 1, 5, 3, 7], 7))
text
3
AssertionError: 二分查找要求列表有序,收到 [9, 1, 5, 3, 7]
第二个调用直接被拦下来了,错误信息写明了"这个列表得是有序的"。要是没有这行 assert,二分查找在乱序列表上会返回一个错的下标或者 -1,你就得从头去查数据是怎么来的。
我在写算法类代码的时候习惯在函数开头放几个 assert,把前提条件写清楚。等确认没问题了再决定留不留。
3.5 pdb,命令行下的调试器
pdb 是 Python 自带的调试器,不需要装任何东西。它的好处是任何环境都能用,服务器上、容器里,只要能进命令行就行。
先准备一个有问题的脚本。
python
# pdbdemo.py
def divide(a, b):
result = a / b
return result
print(divide(10, 4))
用 python -m pdb 启动。
text
$ python -m pdb pdbdemo.py
> pdbdemo.py(1)<module>()
-> def divide(a, b):
(Pdb)
停在了第一行,还没执行。提示符变成了 (Pdb),在这里可以敲命令。
我在第 2 行设个断点,然后让它继续跑。
text
(Pdb) b 2
Breakpoint 1 at pdbdemo.py:2
(Pdb) c
> pdbdemo.py(2)divide()
-> result = a / b
(Pdb)
b 2 是 break,在第 2 行设断点。c 是 continue,跑到下一个断点停下。现在停在 divide 函数里,可以看看参数。
text
(Pdb) p a
10
(Pdb) p b
4
(Pdb) n
> pdbdemo.py(3)divide()
-> return result
(Pdb) p result
2.5
(Pdb) p a / b
2.5
(Pdb) c
2.5
The program finished and will be restarted
p 是 print,打印变量。n 是 next,执行当前行并停到下一行。最后 c 让它跑完,print 输出了 2.5。
pdb 里还能直接算表达式。 p a / b 这种,它就当 Python 代码执行。这一点在排查的时候特别有用,你可以拿当前变量随便试。
常用命令就这几个。
| 命令 | 全称 | 作用 |
|---|---|---|
l |
list | 显示当前位置附近的代码 |
n |
next | 执行下一行,不进函数内部 |
s |
step | 执行下一行,遇到函数就进去 |
c |
continue | 继续跑到下一个断点 |
p x |
打印变量的值 | |
pp x |
pretty print | 格式化打印,列表字典更好看 |
b 行号 |
break | 设断点,b 函数名 也行 |
b 行号 条件 |
条件断点 | 只有条件成立才停,如 b 5 count > 100 |
cl 编号 |
clear | 删除断点 |
w |
where | 打印当前调用栈 |
u / d |
up / down | 在调用栈里上下移动 |
a |
args | 显示当前函数的参数 |
q |
quit | 退出调试 |
h |
help | 帮助,h 命令 看某一条 |
w 这个命令我要多说一句,它打印的是调用栈,跟 traceback 的内容是一回事。程序停住的时候敲一下 w,你就知道是怎么走到这里的。
text
(Pdb) w
pdbdemo.py(6)<module>()
-> print(divide(10, 4))
> pdbdemo.py(2)divide()
-> result = a / b
3.6 崩溃之后再看现场,post mortem
pdb 还有个用法,程序已经崩了,再进去看现场。这对排查线上的问题特别有用。
把脚本改回会崩的版本。
python
# pdbbad.py
def divide(a, b):
result = a / b
return result
print(divide(10, 0))
直接 c 让它跑。
text
$ python -m pdb pdbbad.py
> pdbbad.py(1)<module>()
-> def divide(a, b):
(Pdb) c
Traceback (most recent call last):
...
ZeroDivisionError: division by zero
Uncaught exception. Entering post mortem debugging
> pdbbad.py(2)divide()
-> result = a / b
(Pdb)
程序崩了,pdb 自动把现场保留在出事的那一帧。这时候去看变量。
text
(Pdb) p a
10
(Pdb) p b
0
(Pdb) w
pdbbad.py(6)<module>()
-> print(divide(10, 0))
> pdbbad.py(2)divide()
-> result = a / b
b 是 0,原因清楚了。这种"先崩溃,后查看"的模式叫 post mortem,字面意思是验尸。Python 在异常发生的那一刻把栈帧都留着没丢,所以你能看到出事瞬间所有变量的值。
真实排查的时候,我会这么用。
python
import pdb
def risky(data):
return sum(x["value"] for x in data)
try:
risky([{"value": 1}, {"price": 2}])
except Exception:
pdb.post_mortem()
pdb.post_mortem() 在 except 里调用,就会在异常现场停下来。这段代码我不能放进文档里自动跑,因为它会卡住等输入。你自己试的时候跑一下,崩了之后敲 p x 看看,再敲 q 退出。
3.7 breakpoint(),一行插桩
Python 3.7 之后有个内置函数 breakpoint(),写在代码的哪一行,程序跑到那一行就自动进 pdb。
python
def process(items):
total = 0
for item in items:
breakpoint() # 每次循环都会停在这
total += item
return total
process([1, 2, 3])
跑起来会停在断点处,跟手动 python -m pdb 效果一样。调试完把那一行删掉就行。
它比 import pdb; pdb.set_trace() 短,而且有个实用的特性,可以用环境变量关掉。
text
$ PYTHONBREAKPOINT=0 python app.py
设成 0 之后,所有 breakpoint() 都不起作用。这个设计太贴心了,以前我总担心调试代码忘删就提交上去,现在只要上线命令里带上这个环境变量就不用怕。
PYTHONBREAKPOINT 还能设成别的调试器,比如 PYTHONBREAKPOINT=ipdb.set_trace 会用 IPython 的调试器,带语法高亮和补全,体验好很多(需要先 pip install ipdb)。
3.8 IDE 里打断点,比命令行舒服
日常开发我基本用 IDE 的图形化调试,PyCharm 或者 VS Code 都行。操作大同小异。
流程是这样的。
markdown
1. 在行号左边点一下 → 出现红点,这就是断点
2. 点调试按钮(小虫子图标) → 程序启动,跑到断点停住
3. 底部/侧边看变量面板 → 当前作用域所有变量的值
4. 用这几个按钮控制往下走
┌──────────────────────────────────────────────┐
│ Step Over F8 执行当前行,不进函数内部 │
│ Step Into F7 执行当前行,进到函数里面 │
│ Step Out ⇧F8 从当前函数跳回调用处 │
│ Resume F9 继续跑到下一个断点 │
└──────────────────────────────────────────────┘
5. 加监视(Watch) → 盯着某个表达式的变化
条件断点是排查循环 bug 的神器。 假设第 1000 次循环才开始出错,你不可能手动按一千次。右键点断点,设个条件 i == 999,程序只在条件成立的时候停。
PyCharm 和 VS Code 的一点差别,两个我都用,说说感受。
| PyCharm | VS Code | |
|---|---|---|
| 开箱即用 | 装好就能调试,不用配 | 要装 Python 扩展,可能要配 launch.json |
| 变量面板 | 默认就很好用,能展开看对象内部 | 也够用,看数据结构稍弱 |
| 调试控制台 | 能在断点处直接执行代码 | 同样可以 |
| 远程调试 | 配置稍复杂 | 配 SSH 挺方便 |
| 资源占用 | 比较重 | 轻一些 |
新手我建议先用 PyCharm,配置少,踩坑少。
3.9 定位 bug 的思路,比工具重要
工具讲完了,讲点更根本的东西。同样是查一个 bug,有人十分钟,有人一整天,差别在思路。
一,先读完整 traceback。 这一条看着简单,可真有人不看。从最后一行开始读,确认异常类型,再往上找案发地点。
二,做最小复现。 把出错的那段代码单独抠出来,用能触发问题的最小输入跑一遍。
python
>>> # 原来的代码里,bug 藏在一大堆逻辑中
>>> def handle_order(order):
... # ... 五十行前置处理 ...
... rate = order["done"] / order["total"] # 这里崩了
... # ... 又是五十行后续处理 ...
... return rate
...
>>> # 抠出来的最小复现,两行就够
>>> def minimal():
... done, total = 3, 0
... return done / total
...
>>> minimal()
ZeroDivisionError: division by zero
两行代码就把问题复现出来了。带着这个去查,比在五十行里翻强得多。而且最小复现还有个用处,你拿它去问别人的时候,别人一眼就能看懂,不用读你那一百行业务代码。
最小复现跑通了,问题就定位了一半。跑不出来,说明你对触发条件的理解是错的。
三,二分法。 不知道哪一步出的问题,就在中间插个 print 或者断点,看看到这里的时候数据还是不是对的。对的上半段没问题,问题在下半段,继续二分。一千行的代码,二分十次就能定位到一行。
四,打印中间状态而不是只看结果。 循环类的 bug,把每次循环的中间值打出来,规律自己就出来了。前面那个购物车的例子就是这么找到的。
五,橡皮鸭调试法。 对着一个东西(不一定是鸭子,也可以是同事)把代码从头讲一遍,讲到你卡住说不清楚的地方,bug 往往就在那里。听起来玄学,可真的有效,因为讲的时候你被迫把隐含的假设说出来,而 bug 常常就藏在那些假设里。
我有过好几次,同事刚走过来我开口说了两句,自己就"哦"了一声。
六,版本对比。 代码昨天还好好的,今天不行了。那就看这两天的改动。用 git 的话,git diff 一眼的事。改动多的情况下,git bisect 能自动二分定位到哪次提交引入的问题。
3.10 常见 bug 和它们的排查入口
| 现象 | 大概率是什么 | 从哪下手 |
|---|---|---|
| 结果差一点,比如少算了最后一个 | 边界条件,循环范围不对 | 打印最后一次循环的中间值 |
| 偶尔出现,复现不了 | 依赖外部状态、并发、随机 | 固定随机种子,加日志记下当时输入 |
| 改了变量,别处的值也变了 | 可变对象被共享(第六章的坑) | 检查是不是用了同一个列表或字典 |
| 数据莫名被改 | 有地方改了同一个对象 | 打印 id(obj) 看是不是同一个 |
| 明明赋值了还是报未定义 | 作用域问题,或者是 UnboundLocalError |
检查函数内有没有同名赋值 |
| 中文乱码 | 编码不一致 | 检查读写时的 encoding 参数 |
| 文件找不到 | 相对路径的基准目录不是你以为的那个 | print(os.getcwd()) 看当前目录 |
| 循环里删元素漏了几个 | 边遍历边修改 | 遍历副本,或者倒着删 |
| 浮点算不准 | 二进制浮点固有误差 | 用 decimal 或整数分 |
| 对象属性改了没生效 | 改的是副本,或者被 property 拦了 | 打印 id(),检查 setter |
最后一条我遇到过一次,折磨了我一下午。类里定义了 @property,我在 setter 里少写了个下划线,赋值的时候无限递归,报 RecursionError。第七章讲 property 的时候提过这个坑,这里再提一次,因为它真的很常见。
4. 继承
这一节讲异常的继承体系。第七章讲过类的继承,异常完完全全遵守那套规则,而且把它用到了极致。理解了这层关系,前面好几个疑问都能迎刃而解。
4.1 异常也是类,而且是成体系的
异常不是一串字符串,它们是一个个类,彼此之间有父子关系。
python
>>> type(ValueError)
<class 'type'>
>>> ValueError.__mro__
(<class 'ValueError'>, <class 'Exception'>, <class 'BaseException'>, <class 'object'>)
>>> issubclass(ZeroDivisionError, ArithmeticError)
True
>>> issubclass(ArithmeticError, Exception)
True
>>> issubclass(KeyError, LookupError)
True
>>> issubclass(IndexError, LookupError)
True
所以 ZeroDivisionError 是 ArithmeticError 的子类,ArithmeticError 是 Exception 的子类。KeyError 和 IndexError 是亲兄弟,共同的父亲是 LookupError。
内置异常一共有 72 个,它们组成一棵树。
4.2 家谱长什么样
从顶上往下看。
php
BaseException ← 所有异常的祖宗
│
├── SystemExit ← sys.exit() 触发
├── KeyboardInterrupt ← Ctrl+C 触发
├── GeneratorExit ← 生成器被关闭
├── BaseExceptionGroup ← 3.11 新增,异常组
│ └── ExceptionGroup
│
└── Exception ← 日常打交道的都在这下面
│
├── StopIteration / StopAsyncIteration
├── ArithmeticError
│ ├── ZeroDivisionError
│ ├── OverflowError
│ └── FloatingPointError
├── LookupError
│ ├── IndexError
│ └── KeyError
├── ValueError
│ └── UnicodeError
│ ├── UnicodeDecodeError
│ ├── UnicodeEncodeError
│ └── UnicodeTranslateError
├── TypeError
├── NameError
│ └── UnboundLocalError
├── AttributeError
├── OSError ← 系统相关,这一类有别名
│ ├── FileNotFoundError
│ ├── PermissionError
│ ├── IsADirectoryError
│ ├── TimeoutError
│ └── ConnectionError
│ ├── ConnectionRefusedError
│ └── ConnectionResetError
├── ImportError
│ └── ModuleNotFoundError
├── RuntimeError
│ ├── RecursionError
│ └── NotImplementedError
├── AssertionError
├── SyntaxError
│ └── IndentationError
│ └── TabError
├── MemoryError
├── BufferError
├── EOFError
└── Warning ← 警告不属于错误,但在同一棵树上
├── UserWarning
├── DeprecationWarning
└── RuntimeWarning
这棵树不用背,用的时候查就行。值得记住的是几个中间层,因为它们是"分类"用的。
| 中间层 | 管着哪些 | 什么时候用它 |
|---|---|---|
ArithmeticError |
算术类错误 | 想一把抓住所有计算问题 |
LookupError |
IndexError 和 KeyError |
容器取值失败,不关心是哪种容器 |
OSError |
文件、网络、权限 | 所有跟操作系统打交道的问题 |
ValueError |
值不对 | 含 UnicodeError 这一支 |
LookupError 这个设计我很喜欢。写个通用的取值函数,管你传进来的是列表还是字典,都能兜住。
python
>>> def get_or_default(container, key, default=None):
... try:
... return container[key]
... except LookupError:
... return default
...
>>> get_or_default([1, 2, 3], 10, "没有")
'没有'
>>> get_or_default({"a": 1}, "b", "没有")
'没有'
>>> get_or_default([1, 2, 3], 1)
2
一个 except LookupError 同时管住了下标越界和键不存在。要是分开写,得两个 except。
4.3 三个不在 Exception 底下的
树顶上那三个,SystemExit、KeyboardInterrupt、GeneratorExit,它们直接继承 BaseException,不是 Exception 的子类。
python
>>> for name in ("SystemExit", "KeyboardInterrupt", "GeneratorExit"):
... print(name, issubclass(eval(name), Exception))
...
SystemExit False
KeyboardInterrupt False
GeneratorExit False
这么设计是有道理的。Exception 的语义是"程序运行时出了点状况",而这三位是"程序该停了",性质完全不同。
这个区别直接影响你写代码的方式。
python
def worker():
for i in range(3):
try:
do_one_task(i)
except Exception as e:
print("任务 %d 失败,继续下一个" % i, repr(e))
def do_one_task(i):
if i == 1:
raise ValueError("这个任务坏了")
print("完成第 %d 个" % i)
worker()
text
完成第 0 个
任务 1 失败,继续下一个 ValueError('这个任务坏了')
完成第 2 个
这种"吞掉所有异常继续跑"的循环,用 except Exception 就还能用 Ctrl+C 停下来,因为 KeyboardInterrupt 不在捕获范围内。
要是写成 except BaseException 或者裸的 except:,Ctrl+C 也会被吞掉,程序就变成一个你按什么键都停不下来的东西。我干过这事,最后只能开任务管理器杀进程。
python
>>> try:
... raise KeyboardInterrupt
... except Exception:
... print("抓不到")
... except BaseException as e:
... print("只有 BaseException 抓得住", type(e).__name__)
...
只有 BaseException 抓得住 KeyboardInterrupt
结论很简单,永远不要写 except BaseException 或者裸 except: 。真要兜底,用 except Exception,它是"所有正常错误"的父类,同时给 Ctrl+C 留了条活路。
顺带说一句,SystemExit 也是同理。sys.exit() 的本质就是抛一个 SystemExit 异常,如果你用裸 except 把它接住了,程序就退不出去。
python
>>> import sys
>>> try:
... sys.exit("要退出了")
... except Exception:
... print("Exception 抓不住 SystemExit")
... except SystemExit as e:
... print("只有 SystemExit 自己接得住,code 是", e.code)
...
只有 SystemExit 自己接得住,code 是 要退出了
sys.exit() 的本质就是 raise SystemExit(...),参数是几,退出码就是几,退出码存在 code 属性上。要是你写了个裸 except: 在外面接住它,程序就退不出去了,会继续往下跑。这种 bug 真出过,脚本明明调了 sys.exit(1),结果还在继续执行,就是被某个兜底的 except 拦了。
4.4 捕获顺序,子类必须写在父类前面
这是继承带来的第一个实战坑,也是本章最重要的结论之一。
except 的匹配是从上往下依次比对,命中第一个就停 ,而匹配规则用的是 isinstance 那套,子类算父类的一种。
python
>>> def demo(x):
... try:
... return int(x)
... except ValueError:
... return "抓到了 ValueError"
... except Exception:
... return "抓到了 Exception"
...
>>> demo("abc")
'抓到了 ValueError'
>>> demo(None)
'抓到了 Exception'
顺序正确,ValueError 走第一个分支,TypeError(int(None) 抛的)走第二个。
现在把顺序倒过来。
python
>>> def demo_reversed(x):
... try:
... return int(x)
... except Exception:
... return "被 Exception 截胡"
... except ValueError:
... return "这条永远走不到"
...
>>> demo_reversed("abc")
'被 Exception 截胡'
>>> demo_reversed(None)
'被 Exception 截胡'
ValueError 分支成了死代码。Python 不会报错提醒你,它就静静地放在那里,永远不执行。
这种 bug 特别隐蔽。你写好了精细的处理逻辑,测的时候发现走的都是那个粗糙的兜底分支,查半天才发现是顺序问题。
我自己定的规矩是,写多个 except 的时候,从最具体的排到最宽泛的。就像筛子,先细后粗。
python
except 顺序示意
异常进来
↓
┌──────────────────────┐
│ except InsufficientFunds │ ← 最具体,先试这个
├──────────────────────┤
│ except PaymentError │ ← 次具体
├──────────────────────┤
│ except BizError │ ← 更宽泛
├──────────────────────┤
│ except Exception │ ← 兜底,永远放最后
└──────────────────────┘
顺带提一句,PyCharm 会对这种顺序问题给出警告,VS Code 装了 Pylint 也会。IDE 能帮你抓到一部分。
4.5 自定义异常该继承谁
第七章讲过怎么定义类,自定义异常就是继承一个现成的异常类。
python
>>> class MyError(Exception):
... pass
...
>>> issubclass(MyError, Exception)
True
>>> issubclass(MyError, BaseException)
True
继承 Exception,不要继承 BaseException。 这是唯一正确的默认选择。继承 BaseException 的后果上一节说过,你的异常会跟 Ctrl+C 混在一起。
如果你这个异常在语义上确实属于某一类,可以继承得更具体一点。
| 你的异常是什么性质 | 继承谁 |
|---|---|
| 通用的业务错误 | Exception |
| 参数值不合法 | ValueError |
| 参数类型不对 | TypeError |
| 查不到东西 | LookupError 或 KeyError |
| 跟文件或 IO 有关 | OSError |
| 功能还没实现 | NotImplementedError |
继承 ValueError 的好处是,别人写 except ValueError 的时候能顺带抓住你的异常。这既是好处也是责任,你得确定自己的异常真的"属于"那一类。
python
>>> class InvalidAgeError(ValueError):
... pass
...
>>> try:
... raise InvalidAgeError("年龄不能是负数")
... except ValueError as e:
... print("按 ValueError 处理也抓得住", e)
...
按 ValueError 处理也抓得住 年龄不能是负数
我的习惯是,写业务代码的时候直接继承 Exception 或者自己项目的基类,不去蹭内置类型。蹭内置类型的场景主要是写库,你想让调用方用熟悉的方式捕获。
4.6 带错误码的自定义异常
真实项目里的异常通常要带更多信息,错误码、时间戳、原始原因这些。
python
>>> class BizError(Exception):
... def __init__(self, message, code=0):
... super().__init__(message)
... self.message = message
... self.code = code
... def __str__(self):
... return "[%d] %s" % (self.code, self.message)
...
>>> e = BizError("订单状态不对", 3001)
>>> print(e)
[3001] 订单状态不对
>>> e.code
3001
>>> e.message
'订单状态不对'
三个要点。
一,super().__init__() 里传什么,str(e) 就显示什么。
python
>>> class WithSuper(Exception):
... def __init__(self, a, b):
... super().__init__("两数之和 %d" % (a + b))
... self.a, self.b = a, b
...
>>> e = WithSuper(1, 2)
>>> print(e)
两数之和 3
>>> e.args
('两数之和 3',)
>>> e.a, e.b
(1, 2)
外层的 print(e) 拿到的是加工过的消息,内部还留着原始的两个数。排查的时候两头都有。
二,千万别手动给 self.args 赋值。 它是个特殊属性,赋值的时候会被当成可迭代对象拆开。
python
>>> class BadArgs(Exception):
... def __init__(self, message):
... self.args = {"msg": message} # 想存个字典
...
>>> e = BadArgs("测试")
>>> e.args
('msg',)
>>> print(e)
msg
我本来想存 {"msg": "测试"},结果 args 把字典当可迭代对象,只取了键,变成了 ('msg',)。那条消息彻底丢了,print(e) 只剩一个 msg。踩过这个坑的人,查半天都不知道自己的错误信息去哪了。
三,重写 __str__ 是为了让日志好看。 不重写的话,str(e) 拿到的就是 args[0],错误码那些额外字段是看不到的。
4.7 用继承把业务异常组织起来
一个项目里的异常多了,就该给它们分层。举个电商的例子。
python
class BizError(Exception):
"""所有业务异常的基类"""
def __init__(self, message, code=0):
super().__init__(message)
self.message = message
self.code = code
def __str__(self):
return "[%d] %s" % (self.code, self.message)
class PaymentError(BizError):
"""支付相关"""
pass
class InsufficientFunds(PaymentError):
pass
class CardExpired(PaymentError):
pass
class InventoryError(BizError):
"""库存相关"""
pass
for exc in [InsufficientFunds("余额不足", 1001),
CardExpired("卡片过期", 1002),
InventoryError("库存不足", 2001)]:
try:
raise exc
except PaymentError as e:
print("支付类 →", e)
except BizError as e:
print("其他业务 →", e)
text
支付类 → [1001] 余额不足
支付类 → [1002] 卡片过期
其他业务 → [2001] 库存不足
层次关系一目了然。
yaml
BizError ← 项目里所有业务异常的爸爸
├── PaymentError ← 支付大类,错误码 1xxx
│ ├── InsufficientFunds 1001
│ └── CardExpired 1002
└── InventoryError ← 库存大类,错误码 2xxx
└── OutOfStock 2001
这么组织有什么好处,看这段代码就明白了。
python
class BizError(Exception):
def __init__(self, message, code=0):
super().__init__(message)
self.message = message
self.code = code
class PaymentError(BizError):
pass
class InsufficientFunds(PaymentError):
pass
class InventoryError(BizError):
pass
def place_order(user, items):
try:
pay(user, items)
deduct_stock(items)
except InsufficientFunds:
# 只想针对"钱不够"给个专门提示
return "余额不足,请先充值"
except PaymentError:
# 其他支付问题统一处理
return "支付失败,请更换支付方式"
except InventoryError:
return "库存不足,请修改订单"
except BizError as e:
# 兜底,凡是 BizError 都记一笔
return "下单失败 [%d]" % e.code
return "下单成功"
def pay(user, items):
raise InsufficientFunds("余额不足", 1001)
def deduct_stock(items):
pass
print(place_order("张三", ["苹果"]))
text
余额不足,请先充值
调用方想精确到什么程度,就写到哪一层。想全兜住就 except BizError,想只处理钱不够就 except InsufficientFunds。这个自由度是继承带来的。
层次别超过三层。 有人搞出七八层的异常树,每加一个错误码就新建一个类,最后连他自己都记不清该捕获哪个。异常类不是越多越好,三层够用,大部分项目两层就够。
4.8 异常链,出事的时候要能追到根上
程序里经常有这种情况,底层抛了个 ValueError,你在上层想换个更贴近业务的说法抛出去。直接 raise 新异常会把原始信息丢掉。
python
def load_config():
try:
return int("abc")
except ValueError:
raise TypeError("配置解析失败")
load_config()
text
TypeError: 配置解析失败
只看到"配置解析失败",不知道底下到底出了什么事。明明是 int("abc") 转失败了,可这个信息一点没留下。
用 raise ... from ... 把原因挂上去。
python
def load_config():
try:
return int("abc")
except ValueError as e:
raise TypeError("配置解析失败") from e
load_config()
text
ValueError: invalid literal for int() with base 10: 'abc'
The above exception was the direct cause of the following exception:
TypeError: 配置解析失败
这下清楚了,先有 ValueError,它直接导致了 TypeError。两个异常的信息都会打印出来。
from 建立的因果关系存在 __cause__ 属性上。
python
>>> def probe(fn):
... try:
... fn()
... except TypeError as e:
... print("cause ", repr(e.__cause__))
... print("context ", repr(e.__context__))
... print("suppress", e.__suppress_context__)
...
>>> def with_from():
... try:
... int("abc")
... except ValueError as e:
... raise TypeError("配置解析失败") from e
...
>>> def no_from():
... try:
... int("abc")
... except ValueError:
... raise TypeError("配置解析失败")
...
>>> def from_none():
... try:
... int("abc")
... except ValueError:
... raise TypeError("配置解析失败") from None
...
>>> probe(with_from)
cause ValueError("invalid literal for int() with base 10: 'abc'")
context ValueError("invalid literal for int() with base 10: 'abc'")
suppress True
>>> probe(no_from)
cause None
context ValueError("invalid literal for int() with base 10: 'abc'")
suppress False
>>> probe(from_none)
cause None
context ValueError("invalid literal for int() with base 10: 'abc'")
suppress True
三种写法,三个属性。
| 属性 | 含义 | 什么时候有值 |
|---|---|---|
__cause__ |
显式声明的原因,from 设置的 |
只有用了 raise ... from x 才有,from None 时是 None |
__context__ |
隐式上下文,处理 A 的时候抛了 B | 在 except 块里抛新异常时自动填上 |
__suppress_context__ |
打印时是否隐藏 __context__ |
用了 from 就是 True |
from None 是个特殊用法,它把 __cause__ 设成 None 但把 __suppress_context__ 设成 True,效果是打印的时候只显示新异常,不显示旧的那串。
python
def clean():
try:
int("abc")
except ValueError:
raise TypeError("配置解析失败") from None
clean()
text
TypeError: 配置解析失败
对比一下,不用 from None 的时候会多打印一段。
python
def noisy():
try:
int("abc")
except ValueError:
raise TypeError("配置解析失败")
noisy()
text
ValueError: invalid literal for int() with base 10: 'abc'
During handling of the above exception, another exception occurred:
TypeError: 配置解析失败
这两段的区别在措辞上,from e 说的是 "The above exception was the direct cause",不用 from 说的是 "During handling of the above exception, another exception occurred"。前者是我主动声明的因果,后者是 Python 观察到的先后。
什么时候用 from None,我的标准是这个底层异常对读日志的人没有价值,而且你已经在新的异常里说清楚了原因。反之,底层信息有帮助就一定要留着。
4.9 这节的四句话
如果只记四条,就这四条。
一,自定义异常默认继承 Exception。
二,except 从具体到宽泛,子类写前面,父类写后面。
三,别写 except BaseException 和裸 except:,给 Ctrl+C 留条活路。
四,换类型抛出的时候用 raise ... from e,别把现场弄丢。
5. 小结
5.1 这一章的知识清单
异常概述
- 语法错误在解析阶段发生,
try拦不住;运行时异常才是本章的对象 - traceback 从下往上读,最后一行是结论,中间是调用链
- Python 3.11 之后 traceback 带波浪线和尖头,能精确定位到出错的表达式
- 异常沿着调用栈往上冒,被最近的那一层
try接住 - 异常对象身上有
args、str(e)、repr(e)、type(e).__name__
异常处理语句
try / except / else / finally四个关键字各有分工else里抛的异常不会被本try的except抓住finally无条件执行,里面不要写 returnraise主动抛,raise单独用是原样重抛,raise X from e声明因果assert是给程序员自己看的,python -O会全部跳过with是finally的语法糖,日常优先用它
程序调试
print加标签和repr,f-string 的{x=}语法最省事logging分五个级别,能写文件,能一键调级别pdb是命令行调试器,常用命令b / c / n / s / p / w / q- 崩溃之后用
pdb.post_mortem()看现场 breakpoint()一行插桩,PYTHONBREAKPOINT=0能全局关掉- 定位思路比工具重要,最小复现和二分法是两个最有效的招
异常的继承
- 内置异常 72 个,成一棵树,树根是
BaseException KeyboardInterrupt、SystemExit、GeneratorExit不是Exception的子类- 捕获顺序错了不会报错,只是子类分支永远走不到
- 业务项目里给异常分两三层就够,别搞太深
__cause__是显式原因,__context__是隐式上下文
5.2 避坑清单
| 坑 | 后果 | 怎么办 |
|---|---|---|
except Exception: pass |
所有错误被吞 | 只抓具体的那一类,或者至少记日志 |
裸 except: 或 except BaseException |
Ctrl+C 失效,程序停不下来 | 用 except Exception |
finally 里写 return |
吞掉异常,返回值被覆盖 | finally 只做清理 |
多个 except 顺序反了 |
子类分支变死代码 | 具体在前,宽泛在后 |
try 块包得太宽 |
不知道哪一步出的问题 | 只包可能出事的那几行 |
用 assert 做参数校验 |
-O 模式下校验失效 |
参数校验用 raise ValueError |
自定义异常继承 BaseException |
跟系统信号混在一起 | 继承 Exception |
手动给 self.args 赋值 |
数据被拆成元组,信息丢失 | 用 super().__init__() 传参 |
自定义异常忘了调 super().__init__() |
args 和 str(e) 对不上 |
一定要调 |
用 raise 做流程控制 |
逻辑绕,异常语义被滥用 | 用 if |
日志里用 % 预先拼接 |
关掉日志也在拼字符串 | 用 logging 的逗号传参 |
5.3 五道题
先把答案写在心里,再看我给的。
第一题 ,下面这个 outer(),调用的时候会抛什么异常,发生在第几行,怎么改?
python
def outer():
items = [1, 2, 3]
print(items[5])
第二题 ,写一个 safe_int(text, default=0),转不了就返回默认值。要考虑哪几种异常?
第三题 ,按这个结构定义异常类,AppError 是基类,NetworkError 和 ParseError 是它的子类,RequestTimeout 是 NetworkError 的子类。然后写一段代码,能区分处理网络类错误和其他错误。
第四题 ,下面这个函数,check(-1) 和 check(5) 分别返回什么?
python
def check(x):
try:
if x < 0:
raise ValueError("不能为负")
finally:
return "ok"
return x
第五题,这个函数有两个 bug,找出来并修好。
python
def average(nums):
total = 0
for i in range(len(nums) + 1):
total += nums[i]
return total / len(nums)
5.4 答案
第一题。 抛 IndexError,消息是 list index out of range,发生在 print(items[5]) 那一行,也就是函数体的第二行。
python
def outer():
items = [1, 2, 3]
print(items[5])
try:
outer()
except IndexError as e:
print("抓住", type(e).__name__, e)
text
抓住 IndexError list index out of range
改法有几种,下标改成合法值,或者先判断长度,或者捕获处理。看你的业务需要哪种。
第二题。 要考虑两种,ValueError 和 TypeError。
python
def safe_int(text, default=0):
try:
return int(text)
except (TypeError, ValueError):
return default
print(safe_int("42"), safe_int("abc"), safe_int(None), safe_int("3.14"))
text
42 0 0 0
int("abc") 是 ValueError,int(None) 是 TypeError。只写其中一个都漏。int("3.14") 也是 ValueError,带小数点的字符串不能直接转 int。
第三题。
python
class AppError(Exception):
pass
class NetworkError(AppError):
pass
class RequestTimeout(NetworkError):
pass
class ParseError(AppError):
pass
for exc in (RequestTimeout("超时"), ParseError("解析失败")):
try:
raise exc
except NetworkError as e:
print("网络类", e)
except AppError as e:
print("其他", e)
text
网络类 超时
其他 解析失败
有个细节要注意,RequestTimeout 千万别起名叫 TimeoutError,那会把内置的 TimeoutError 覆盖掉。内置的那位是 OSError 的子类,跟你的业务异常八竿子打不着,覆盖之后别的代码就乱了。
第四题。 两个都返回 ok。
python
def check(x):
try:
if x < 0:
raise ValueError("不能为负")
finally:
return "ok"
return x
print("check(-1) =", check(-1))
print("check(5) =", check(5))
text
check(-1) = ok
check(5) = ok
finally 里的 return 无条件执行,它把 ValueError 吞了,也把正常的返回值覆盖了。这就是 2.5 节说的那个坑,看别人代码的时候要留个心眼。
第五题。 两个 bug,range(len(nums) + 1) 多走一次导致下标越界;空列表的时候 len(nums) 是 0,除法也会崩,只是这个版本里它先崩在下标上。
python
def average_bad(nums):
total = 0
for i in range(len(nums) + 1):
total += nums[i]
return total / len(nums)
for data in ([1, 2, 3], []):
try:
average_bad(data)
except IndexError as e:
print("%r 崩了" % (data,), repr(e))
text
[1, 2, 3] 崩了 IndexError('list index out of range')
[] 崩了 IndexError('list index out of range')
修好的版本。
python
def average(nums):
if not nums:
return 0
total = 0
for n in nums:
total += n
return total / len(nums)
print(average([1, 2, 3]), average([]))
text
2.0 0
改了两点,遍历改成直接迭代元素(本来就用不着下标),空列表提前返回。顺带说一句,sum(nums) / len(nums) 一行就能搞定,这里写成循环是为了让你看清楚问题在哪。
5.5 下一章预告
异常到这儿就讲完了。下一章是文件及目录操作 ,讲 open 的各种模式、路径处理、目录遍历,以及 os 和 pathlib 这两个模块的取舍。
文件操作是异常的高发区,FileNotFoundError、PermissionError、UnicodeDecodeError 这几个老面孔到时候会集中出现。这一章学的东西正好派上用场。
在那之前,我建议你把这一章的代码片段自己敲一遍,尤其是 pdb 那一段。调试器这东西,看别人用和自己上手完全是两回事。