Python 语法篇 · 第10章

异常处理与程序调试

程序出错不可怕,可怕的是不知道错在哪。本章学习捕获与抛出异常、读懂报错信息,并掌握多种调试手段。

🎯 学习目标

  • 区分语法错误与运行时异常,能读懂 Traceback 调用栈
  • 掌握 Python 常见异常类型及继承体系
  • 熟练使用 try/except/else/finally 处理异常
  • 会用 raise 主动抛异常、会自定义异常类
  • 理解 EAFP 风格与异常处理最佳实践
  • 掌握 print、断言、logging、pdb、IDE 断点等调试手段

1两类错误

语法错误(SyntaxError):根本跑不起来

if True        # SyntaxError: expected ':'  少了冒号
   print("hi")

print("未闭合)   # 括号/引号未闭合、缩进错误都属于此类

语法错误在解释器解析阶段就会被发现,程序一行都不会执行。VS Code 通常在编写时就用红线标出。

运行时异常(Exception):执行到某处才崩

nums = [1, 2]
print(nums[5])      # IndexError: list index out of range
print(10 / 0)       # ZeroDivisionError: division by zero
int("abc")          # ValueError: invalid literal
print(undefined)    # NameError: name 'undefined' is not defined

读懂 Traceback(报错栈)

Traceback (most recent call last):
  File "app.py", line 10, in <module>      ← 最外层
    main()
  File "app.py", line 7, in main           ← 调用链向下追
    process(data)
  File "app.py", line 3, in process        ← 出错的最内层(重点看这里!)
    return int(data)
ValueError: invalid literal for int() with base 10: 'abc'
   ↑异常类型              ↑具体错误原因

读报错的顺序:从最后一行看异常类型和原因,从倒数第一个 File 行定位出错文件与行号,再向上看调用链。不要被一大片红字吓到。

2常见异常与继承体系

BaseException
 └── Exception                # 所有"常规异常"的基类
      ├── ValueError           # 值的类型对但内容非法:int("abc")
      ├── TypeError            # 类型不对:"a" + 1
      ├── KeyError             # 字典键不存在
      ├── IndexError           # 序列下标越界
      ├── AttributeError       # 对象没有该属性/方法
      ├── NameError            # 变量名未定义
      ├── ZeroDivisionError    # 除数为 0
      ├── FileNotFoundError    # 文件不存在
      ├── ImportError/ModuleNotFoundError  # 导入失败
      ├── StopIteration        # 迭代结束
      └── ...
异常触发场景例子
ValueError值无法转换/不合法int("3.5")
TypeError类型不支持该操作len(123)
KeyErrordict 取不存在的键{}["a"]
IndexErrorlist/tuple 越界[1][2]
AttributeError属性/方法不存在None.strip()
FileNotFoundError打开不存在的文件open("x.txt")
UnicodeDecodeError解码编码不匹配GBK 字节按 UTF-8 解

3try-except:优雅地处理异常

# 基础结构
try:
    num = int(input("输入一个整数:"))
    print(10 / num)
except ValueError:
    print("输入的不是合法整数")
except ZeroDivisionError:
    print("0 不能作除数")
except Exception:
    print("发生了其他错误")
else:
    print("没有异常时才执行")
finally:
    print("无论是否异常都会执行(常用于关闭资源)")

各代码块职责

块执行时机典型用途
try放可能出错的代码只包"可能失败"的最小片段
except 类型发生匹配异常时针对性处理/降级/提示
else没发生异常时成功后的后续逻辑(保持 try 精简)
finally无论如何都执行(即使 return/崩之前)关闭文件、释放连接

一次捕获多种异常

try:
    ...
except (ValueError, TypeError) as e:     # 元组合并捕获
    print("数据有问题:", e)             # as e 拿到异常对象,e 就是错误信息
    print(type(e).__name__)              # 'ValueError'

# 捕获顺序:先具体后宽泛!
try:
    ...
except FileNotFoundError:                # 子类异常写前面
    print("文件不存在")
except OSError:                          # 父类兜底写后面
    print("其他 IO 错误")
except Exception:                       # 最宽泛的常规异常
    print("未知错误")

不要写裸 except:(连 Ctrl+C 的 KeyboardInterrupt 都能吞掉)或 except Exception: pass——它会隐藏所有错误,让 bug 极难排查。至少要记录日志或只捕获你预期的具体类型。

finally 与 return 的执行顺序

def f():
    try:
        return 1
    finally:
        print("finally 仍会执行")    # return 前先打印这句,再真正返回

print(f())

4raise:主动抛出异常

def set_age(age):
    if not isinstance(age, int):
        raise TypeError("年龄必须是整数")
    if age < 0 or age > 150:
        raise ValueError(f"年龄不合法:{age}")
    return age

try:
    set_age(-1)
except ValueError as e:
    print("捕获:", e)        # 捕获: 年龄不合法:-1

也可以把捕获到的异常重新抛出(记录日志后向上传播):

try:
    int("abc")
except ValueError:
    print("记录日志:转换失败")
    raise                     # 不带参数:原样继续向上抛

5自定义异常

业务错误用自定义异常表达,语义清晰、方便上层分别处理:

# 约定:继承 Exception(而不是 BaseException)
class NLPError(Exception):
    """NLP 模块基础异常。"""

class EmptyTextError(NLPError):
    """输入文本为空。"""

class UnsupportedLangError(NLPError):
    """不支持的语言。"""


def tokenize(text, lang="zh"):
    if not text or not text.strip():
        raise EmptyTextError("待分词文本不能为空")
    if lang not in ("zh", "en"):
        raise UnsupportedLangError(f"暂不支持语言:{lang}")
    return list(text)


try:
    tokenize("   ")
except NLPError as e:          # 一次接住本模块所有自定义异常
    print("处理失败:", type(e).__name__, e)

6异常处理最佳实践

LBYL vs EAFP

LBYL(先看再跳,C 风格)

d = {}
if "key" in d and isinstance(d["key"], int):
    print(d["key"] + 1)
else:
    print("数据不可用")

EAFP(先做再说,Python 风格)

d = {}
try:
    print(d["key"] + 1)
except (KeyError, TypeError):
    print("数据不可用")

Python 官方推荐 EAFP(Easier to Ask Forgiveness than Permission):异常开销低,代码更关注主流程。但用户输入、文件、网络等"正常也会失败"的场景,优先校验给出友好提示。

实战原则:① 只捕获你能处理的异常;② except 范围尽量小、具体;③ 在边界层(用户输入、读文件、请求接口)统一处理并给出友好提示,内部代码让异常快速暴露;④ 用 logging.exception() 记录完整堆栈。

7程序调试五件套

① print 大法

# f-string 的 = 说明符是调试利器(3.8+)
def buggy(a, b):
    print(f"{a=}, {b=}")           # a=1, b=0
    result = a / b
    print(f"{result=}")
    return result
# buggy(1, 0)

② assert 断言

def avg(nums):
    assert len(nums) > 0, "nums 不能为空"      # 条件不成立立即 AssertionError
    return sum(nums) / len(nums)
# 断言用于开发期捕捉"绝不该发生"的情况;不要用它做用户输入校验
# (python -O 优化模式会跳过断言)

③ logging 日志(项目级首选)

import logging
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    filename="app.log",            # 不加则输出到控制台
    encoding="utf-8"
)
logging.debug("细节信息")     # 默认不显示
logging.info("普通流程信息")
logging.warning("警告")
logging.error("出错了")
try:
    1 / 0
except ZeroDivisionError:
    logging.exception("除零现场")  # 自动附带完整堆栈,排查神器

④ pdb 交互式调试(标准库自带)

import pdb

def f(n):
    total = 0
    for i in range(n):
        total += i
        pdb.set_trace()      # 程序在此暂停,进入交互式调试
    return total

# f(3)
# 常用命令:n(下一行)  s(进入函数)  c(继续)  p 变量名(查看)
#           l(看代码)  b 行号(断点)  q(退出)  w(调用栈)
# Python 3.7+ 也可直接用命令行:python -m pdb app.py
# VS Code 中在代码里写 breakpoint() 即可触发可视化调试

⑤ IDE 断点调试(日常效率最高)

  1. 在 VS Code 行号左侧点一下,出现红点 = 断点。
  2. 按 F5(或菜单 运行→启动调试)选择 Python File。
  3. 程序停在断点后,可:悬停查看变量、在左侧面板监视表达式、F10 单步跳过 / F11 单步进入、在调试控制台直接输入表达式求值。

8综合案例:健壮的文件读取函数

import logging

def read_text_file(path, encoding="utf-8"):
    """安全读取文本文件,失败时返回 None 并记录日志。"""
    try:
        with open(path, "r", encoding=encoding) as f:
            return f.read()
    except FileNotFoundError:
        logging.warning("文件不存在:%s", path)
    except UnicodeDecodeError:
        logging.error("编码错误,请确认文件是否为 %s 编码", encoding)
    except OSError as e:
        logging.exception("读取文件发生 IO 错误:%s", e)
    return None

content = read_text_file("maybe_exist.txt")
if content is not None:
    print("读取成功,长度", len(content))
else:
    print("使用默认内容继续运行…")

9本章小结与练习

📌 关键结论

  • 看 Traceback:最后一行看类型原因,最内层 File 行看出错位置。
  • except 先具体后宽泛;禁止裸 except: pass 吞错误。
  • finally 一定执行,适合释放资源;raise 主动报错,自定义异常继承 Exception。
  • 边界层统一处理异常;内部代码宁可让问题早暴露。
  • 调试组合:print(f"{x=}") → 断点调试 → logging 长期保留。
练习1:d = {}; print(d["a"]) 会抛什么异常?给出两种不让程序崩溃的写法。
KeyError。写法一:print(d.get("a")),键不存在返回 None 不报错;写法二:try: print(d["a"]) except KeyError: print("无此键")。前者适合取默认值,后者适合更复杂的失败处理。
练习2:except 的排列顺序有什么要求?为什么不推荐裸 except?
多个 except 要先写具体子类异常(FileNotFoundError),后写宽泛父类(OSError、Exception),否则子类永远匹配不到。裸 except 会捕获包括 KeyboardInterrupt、SystemExit 在内的一切异常并静默吞掉,导致程序无法正常中断、bug 被隐藏,应至少捕获 Exception 并记录处理。
练习3:写一个 safe_int(s) 函数,能转则转,不能转返回 None(不抛异常)。
def safe_int(s): try: return int(s) except (ValueError, TypeError): return None 注意还要捕获 TypeError(s 为 None 等非字符串时 int(None) 抛 TypeError)。