第 13 章 · 异常处理与调试
这一章要解决什么:让程序遇到脏数据时不崩,真崩了也知道该看哪儿。 要打掉两个坑:报错信息要从下往上看;别写裸
except(它会连 Ctrl+C 和你自己的手滑一起吞掉)。
本章你需要完成的代码:demos/ch13-error.py
13.1 不处理异常:一行出错,整个程序报废
cap_now = float("") # 空字符串不能转 float结果不是"这条跳过、继续跑",而是:
Traceback (most recent call last):
File "demos/ch13-error.py", line 1, in <module>
cap_now = float("")
ValueError: could not convert string to float: ''Python 遇到无法继续的情况,会创建一个异常对象沿调用链往上抛;一路没人接住, 就抛到最外层、打印现场信息、结束整个程序。关键认知:异常不等于 bug —— 上面这个 ValueError 是数据的问题,代码本身完全正确,它只是没打算处理"空字符串"。 而真实数据里,空值是常客。
13.2 你会反复遇到的 6 种异常
| 异常类型 | 典型触发 | 你该检查什么 |
|---|---|---|
ZeroDivisionError | 1 / 0 | 分母是不是 0 或缺失 |
ValueError | float("")、int("abc") | 字符串能不能转数字;空值先拦 |
KeyError | {}["k"] | 键名拼错?这一列本来就可能没有? |
IndexError | [1][5] | 下标越界、空列表、行列数对不上 |
TypeError | None + 1、"2" + 2 | 类型不对或是 None(第 03 章) |
FileNotFoundError | open("no_such.csv") | 路径、当前工作目录(第 12 章) |
记忆法:ValueError 管"值不对",TypeError 管"类型不对" —— 这两个占了日常报错的七成。 示例把这 6 种挨个制造出来,用 except expect as exc 接住并打印类型名与消息。真实输出:
=== ② 你会反复遇到的异常 ===
除零 ZeroDivisionError division by zero
空值转数字 ValueError could not convert string to float: ''
字典取不存在的键 KeyError 'k'
列表越界 IndexError list index out of range
None 参与运算 TypeError unsupported operand type(s) for +: 'NoneType' and 'int'
打开不存在的文件 FileNotFoundError [Errno 2] No such file or directory: 'no_such.csv'注意 exc 只在 except 块内有效(type(exc).__name__ 是类型名,适合写日志分类; exc 是消息,适合给人看)。出了块 Python 会清掉它 —— 要留信息就在块内先 str(exc)。
13.3 怎么读报错:从下往上三步法
新手最常见的动作是"看到一片红就从头读"——这段文本的顺序恰恰是反的。
Traceback (most recent call last):
File "/tmp/tb_demo.py", line 9, in <module>
sohs = [read_row(r) / 2.5 for r in rows]
~~~~~~~~^^^
File "/tmp/tb_demo.py", line 5, in read_row
return load_capacity(row["capacity_ah"])
File "/tmp/tb_demo.py", line 2, in load_capacity
return float(raw)
ValueError: could not convert string to float: ''① 看最后一行 —— 什么错、什么消息。ValueError: could not convert string to float: '':值不对,而且这个值是空字符串。 八成的问题到这一步就有方向了。
② 找 File "...", line N —— 哪一行。most recent call last 意思是"最后发生的在最下面",所以最后一个 File 段 (line 2, in load_capacity)才是出错点;上面几段是调用栈,回答"这一行是怎么被跑到的"。
③ 看 ^ 箭头 —— 具体哪个子表达式。 箭头指着 read_row(r) / 2.5, 说明是这次除法(或它的左边)出的问题;Python 3.11+ 指得更精确。
补充两点:调用栈里常混着几十层"不是你写的代码"(库内部),先扫有没有你自己的文件名; 报错会把你的名字原样回显(name 'cap_reate' is not defined),这是最快的线索。
13.4 try / except / else / finally 完整形态
| 子句 | 什么时候执行 | 放什么代码 |
|---|---|---|
try | 总是先执行 | 可能出错的代码(只放必要的几行) |
except | try 里抛出匹配的异常时 | 兜底、记日志、返回安全值 |
else | try 里没出错时 | 依赖 try 结果的后续动作 |
finally | 无论如何都执行 | 收尾:关文件、释放锁 |
def safe_soh(cap_now, cap_rated):
try:
soh = calc_soh(cap_now, cap_rated) # 可能出错的代码
except (TypeError, ZeroDivisionError) as exc: # 兜住可预料的错误
print(f" [except] 算出错了:{type(exc).__name__}: {exc}")
return None
except ValueError as exc: # 更「业务」的错误,分开处理
print(f" [except] 参数不合法:{exc}")
return None
else: # 没出错才执行
print(f" [else] 正常算完 soh={soh:.4f}")
return soh
finally: # 无论如何都执行(收尾用)
print(" [finally] 这一段永远会跑(关文件、释放资源)")真实输出(三种输入,三条不同路径):
=== ③ 完整形态 ===
safe_soh(2.3, 2.5):
[else] 正常算完 soh=0.9200
[finally] 这一段永远会跑(关文件、释放资源)
safe_soh(2.3, 0):
[except] 参数不合法:额定容量必须为正,收到 0
[finally] 这一段永远会跑(关文件、释放资源)
safe_soh('abc', 2.5):
[except] 算出错了:TypeError: unsupported operand type(s) for /: 'str' and 'float'
[finally] 这一段永远会跑(关文件、释放资源)注意最后一条:'abc' / 2.5 报的是 TypeError(还没进除法就被拦住),不是 ValueError —— 类型错误总是先于值错误发生。
三条纪律:try 的范围要小(小到能一眼说出会出哪种错,否则出错时不知道是哪一行); else 放"不该被 except 兜住"的代码(print(f"{soh:.4f}") 若写在 try 里, 它自己的 TypeError 会被误判成"计算失败");finally 里不要写 return —— 它会覆盖 try/except 的返回值(try: return 1 / finally: return 2 永远返回 2)。
13.5 别写裸 except
=== ④ 不要用裸 except ===
❌ try: ... except: pass # 吞掉一切错误,包括你自己手滑打错的变量名
❌ except Exception: pass # 同样的问题,还不留日志
✅ except (ValueError, TypeError) as exc: 明确列出你能处理的类型
✅ 确实要兜底时,至少把异常记下来:logging.exception(...)裸 except: 会捕获一切,包括 KeyboardInterrupt。 后果非常具体: 你按 Ctrl+C 想停掉一个跑了半天的脚本 —— 它停不下来。
更阴险的后果:它把你自己的拼写错误一起吞掉。
try:
soh = cap_now / cap_ratd # 名字拼错了 → NameError
except:
pass # 被吞掉,soh 从来没被定义
print(soh) # 这里才报 NameError,离真正的原因已经很远你花两小时找的 bug 其实是个拼写错误 —— 信息在你写下 except: pass 的那一刻就销毁了。
判断标准:看到这个异常,我知道该怎么办吗? 知道 → 写具体类型(except (ValueError, TypeError) as exc:);不知道 → 让它往上抛。 只有"最外层兜底"才用 except Exception as exc:,而且必须配 logging.exception(...) 留日志。
13.6 raise 与自定义异常
自己发现问题就主动 raise,比悄悄返回一个错误结果好一万倍。
class DataQualityError(Exception):
"""数据质量问题(值不可能是真的)。"""
def validate_capacity(cap):
"""容量必须为正且不超过额定值 —— 物理规则先拦一道。"""
if cap <= 0:
raise DataQualityError(f"容量不能为负或零:{cap}")
if cap > 3.0:
raise DataQualityError(f"容量超出物理上限 3.0 Ah:{cap}")
return cap=== ⑤ 主动 raise + 自定义异常 ===
2.3 → 通过校验
-0.5 → 拦截:容量不能为负或零:-0.5
999.0 → 拦截:容量超出物理上限 3.0 Ah:999.0① class DataQualityError(Exception) 就是自定义异常:成本 2 行代码, 收益是报错信息里出现一个你的业务概念 —— 谁看到都知道发生了什么。
② 为什么不用 return None 表示失败? 调用方忘了检查返回值时, cap = validate_capacity(row) 拿到 None,下一行 cap / 2.5 就在一个完全无关的地方报 TypeError: unsupported operand type(s) for /: 'NoneType' and 'float'。 raise 是当场中断,现场就是案发现场。
③ "物理规则先拦,统计规则后补":值域(容量不能为负、不能超 3.0 Ah)是物理常识, 不依赖统计;Z 分数 / IQR 只能发现"相对这批数据偏离"的点(第 32/33 章)—— 顺序不能反,统计量本身会被异常值带偏。
异常怎么往上抛:底层函数(parse_row)发现不合法就 raise,不要打印、不要吞; 最外层的批处理循环负责接住、记录、决定"跳过这条还是终止任务"。底层负责判断,上层负责决策。
13.7 assert 与 logging
assert 条件, 消息:条件为 False 时抛 AssertionError(带消息),程序立即停止。 它写的是"我确信这里一定成立"(assert len(caps) == 3, "容量条数应该是 3"), 而 raise 写的是"这里可能不成立,我必须拦住" —— 前者面向开发者自己(内部不变量), 后者面向调用方和用户数据。
关键差别:python -O 会把所有 assert 删掉,raise 照常执行。 所以 assert cap > 0, "容量必须为正" ❌(生产跑就少了这条校验); if cap <= 0: raise ValueError(f"容量必须为正:{cap}") ✅。
logging 则是"留下来给别人看"的输出:
import logging
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
log = logging.getLogger("ch13")
log.debug("debug 级别:细节,默认不显示")
log.info("开始处理 %d 支电芯", 12)
log.warning("有 %d 条容量为负,将被拦截", 70)
log.error("这一批数据全部缺失,任务终止")INFO 开始处理 12 支电芯
WARNING 有 70 条容量为负,将被拦截
ERROR 这一批数据全部缺失,任务终止print 的三个问题:关不掉(正式跑起来满屏都是)、没有时间戳、没法同时写文件。 级别从低到高:DEBUG(变量细节,默认不显示)→ INFO(正常进度)→ WARNING(能继续,但有东西不对) → ERROR / CRITICAL(任务失败)。 log.info("处理 %d 支", n) 用的是延迟格式化,这条日志不显示时连拼接都省了; logging 默认写 stderr、print 写 stdout,混着重定向时顺序会和你想的不一样。
13.8 用断点调试代替 print
print 的极限在这里:你要看的是循环第 837 次时的 5 个变量。于是你在循环里加了一堆 print,被几十万行输出淹没,最后还得一行行删掉。
| 操作 | VS Code 快捷键 | 效果 |
|---|---|---|
| 打断点 | F9 | 程序跑到这一行暂停,此刻所有变量的值都还在 |
| 开始调试 | F5 | 停在第一个断点,左侧出现"变量 / 监视 / 调用栈" |
| 单步跳过 / 进入 | F10 / F11 | 执行这一行 / 进入这行调用的函数内部 |
| 条件断点 | 右键断点 → 条件 | 只在 row["cell_id"] == "C008" 时暂停 |
条件断点是杀手锏:上面那个"第 837 次循环"的问题,一条条件就精确定位了。 第 15 章之后你的对象会越来越复杂,能在"变量"面板里展开对象看它存了什么非常值钱。 ⚠ 别一遇到错就上调试器:先用 13.3 节的三步法读报错,一半的问题在这一步就解决了。
13.9 实战:容错的批处理
good, bad = [], []
for row in raw_rows:
try:
cap = validate_capacity(float(row["capacity_ah"]))
except (ValueError, DataQualityError) as exc:
bad.append((row["cell_id"], str(exc))) # 记「谁 + 为什么」
continue # ← 关键:这条坏了,循环继续
good.append((row["cell_id"], cap))真实输出:
=== ⑧ 实战:容错的批处理 ===
通过 2 条:[('C001', 2.3), ('C005', 2.41)]
拦截 3 条:
C002: could not convert string to float: ''
C003: could not convert string to float: 'abc'
C004: 容量不能为负或零:-0.5
这条「好的留下、坏的记账」的思路,就是数据清洗章节的核心动作。
(顺便:math.isfinite(2.3) = True,用来识别 inf / nan)"好的留下、坏的记账" 正是第 32~34 章数据清洗的核心动作:good 拿去算, bad 留下来解释"为什么这 3 条没进统计"。一份能追溯的报告就是靠这一步撑起来的。
常见坑
- 报错从第一行开始读:顺序是反的 —— 最后一行才是异常类型和消息,最后一个
File才是出错行。 - 裸
except/except Exception: pass:吞掉Ctrl+C,也吞掉你自己的拼写错误。 try包住一大段代码:出错时不知道是哪一行,还会接住不该处理的异常。- 用
return None表示失败:调用方忘了判空,就在别处报TypeError: ... 'NoneType' and 'float'。 - 用
assert做必须执行的校验:python -O会把assert全删掉,改用raise。 except块外还想用exc:Python 在块结束时就删掉了它,之后print(exc)报NameError。- 以为"没报错"就是"处理对了":
except: pass之后soh可能是None,最后在完全无关的地方炸掉 —— 每一层都要问"出错时返回值是什么语义"。
本章练习
练习 1:把"会崩的转换"改成"不崩的转换"
写一个 safe_float(raw):转成 float;转不了返回 None,不要让程序崩。 覆盖三类输入:"2.30"、""、None。期望输出:'2.30' → 2.3、'' → None、None → None。
参考答案
def safe_float(raw):
"""转成 float;转不了返回 None(不抛异常)。"""
if raw is None or raw == "":
return None # ① 先拦"空"
try:
return float(raw)
except (TypeError, ValueError): # ② 两个都要兜
return None
for raw in ("2.30", "", None):
print(f"{raw!r:<7}→ {safe_float(raw)}")要点:float(None) 抛 TypeError,float("") 抛 ValueError —— 只写 except ValueError,遇到 None 照样崩,这是本练习唯一的真坑。 另外注意语义:这里把"失败"表达成 None,调用方必须判空; 若希望"失败就立刻停",应该 raise(练习 3 会用到)。
练习 2:预测 else 和 finally 的执行顺序
不要运行,先写下输出:
def f(x):
try:
y = 10 / x
except ZeroDivisionError as exc:
print("A", type(exc).__name__)
return -1
else:
print("B", y)
return y
finally:
print("C 收尾")
print(f(0))
print(f(2))参考答案
A ZeroDivisionError
C 收尾
-1
B 5.0
C 收尾
5.0关键点:finally 在 return 之后、把值交给调用方之前执行。f(0):① except 打印 A;② 遇到 return -1,先记下"要返回 -1"但不立刻返回; ③ 执行 finally 打印 C 收尾;④ 才把 -1 交给 print —— 所以 C 收尾 在 -1 之前。
⚠ 危险写法:finally 里的 return 会覆盖 try/except 的返回值。 纪律:finally 只做收尾。
练习 3:给批处理加上"坏的记账"
① 定义自定义异常 DataQualityError;② 写 validate_capacity(cap): cap <= 0 或 cap > 3.0 就 raise DataQualityError(...);③ 处理下面 5 行, 把通过的和拦截的分别打印,每条坏数据都要带原因:
raw_rows = [
{"cell_id": "C001", "capacity_ah": "2.30"}, {"cell_id": "C002", "capacity_ah": ""},
{"cell_id": "C003", "capacity_ah": "abc"}, {"cell_id": "C004", "capacity_ah": "-0.5"},
{"cell_id": "C005", "capacity_ah": "2.41"},
]期望输出:
通过 2 条:[('C001', 2.3), ('C005', 2.41)]
拦截 3 条:
C002: could not convert string to float: ''
C003: could not convert string to float: 'abc'
C004: 容量不能为负或零:-0.5参考答案
class DataQualityError(Exception):
"""数据质量问题(值不可能是真的)。"""
def validate_capacity(cap):
if cap <= 0:
raise DataQualityError(f"容量不能为负或零:{cap}")
if cap > 3.0:
raise DataQualityError(f"容量超出物理上限 3.0 Ah:{cap}")
return cap
good, bad = [], []
for row in raw_rows:
try:
cap = validate_capacity(float(row["capacity_ah"]))
except (ValueError, DataQualityError) as exc: # ① 转换错误 + 业务错误一起兜
bad.append((row["cell_id"], str(exc))) # ② 记「谁 + 为什么」
continue # ③ 这条坏了,循环继续
good.append((row["cell_id"], cap))四个要点:① except 里两种异常 —— ValueError 是脏值、DataQualityError 是物理规则; ② continue 是灵魂:一条坏数据不能让整批任务停下; ③ bad 存 (谁, 为什么) 而不只是计数,"C004 为什么被拦"才答得出来; ④ 键不存在时抛的是 KeyError,把它也加进 except,或用 row.get(...)(第 09 章)。
生产版只多两行:log.warning("跳过 %s:%s", cid, exc) + 把 bad 写进 outputs/rejected.csv。 完整练习在 exercises/ex04-13-error。
面向 AI 开发:数据管道的容错
① 脏数据杀死的不是一条记录,而是整批任务。caps = [float(r["capacity_ah"]) for r in rows] 碰到第 3 行的空字符串就 ValueError → 全停。 管道里的标准写法是"好的留下、坏的记账"(13.9 节):一条坏数据只影响它自己。
② 失败必须可追溯,否则"清洗"就等于"毁尸灭迹"。
| 只能说出 | 能追溯的说法 |
|---|---|
| "清洗后剩 13900 条" | "13967 行中拦截 70 条容量为负、68 条内阻越界,清单见 outputs/rejected.csv" |
所以真实管道最后总会多做一步:用 csv.DictWriter 把 bad 落盘(字段 cell_id / raw / reason),让它成为可交付的证据。
③ 训练脚本要"失败得响",不要静默。if not np.isfinite(loss): raise RuntimeError(f"step {step} loss 变成 {loss}") —— 而 except Exception: pass 让 loss 变成 nan 也照跑 8 小时,是 AI 项目里最贵的错误之一。
④ 三个层次的容错要分清:单条数据脏了 → 跳过 + 记账 + 计数; 某一批 / 某个文件失败 → 记 ERROR 日志、继续下一批; 配置错、路径错、依赖缺失 → 立刻崩,越早暴露越好。
一句话总结:能跑的脚本靠语法,能维护的脚本靠异常处理 —— 从这一章起,你的代码要能回答:"如果这批数据里有 3 条是坏的,我的程序会怎样?"