第八章 异常处理及程序调试

第八章 异常处理及程序调试

上一章结尾我说过,类里那些 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 print 打印变量的值
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 无条件执行,里面不要写 return
  • raise 主动抛,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 那一段。调试器这东西,看别人用和自己上手完全是两回事。

相关推荐
源图客1 小时前
Python环境安装与PyCharm的第一个项目
开发语言·python·pycharm
朝朝辞暮i1 小时前
VLA 系统学习第 20 课——Diffusion Policy 为什么要先给动作加噪,再学习把噪声去掉?
人工智能·python·深度学习·transformer·vla
全栈弄潮儿1 小时前
Python实战第3期:输入输出与字符串操作
python·agent
goehou2 小时前
MCP 传输方式实战:stdio、Streamable HTTP、WebSocket 该选哪个
python·llm·agent·教程·mcp
用户0332126663672 小时前
如何快速拆分超大 Excel 文件【Python 教程】
python·excel
kimnoic2 小时前
Python中使用封装类还是函数以及它们的区别
开发语言·python
青柠之夏cc2 小时前
Python Java PHP底层对比:内存并发性能全解析
开发语言·python·线程
TomEval2 小时前
【测AI】第10篇:AI 模型功能测试实战 —— 测试用例怎么设计?
人工智能·python·功能测试·测试用例
半亩码田2 小时前
C#转Python第5.1篇:异步编程:asyncio vs async/await,语法相似但机制不同
开发语言·python·c#