《Python编程入门》7.1 异常层次与 try/except/else/finally

本节讲 Python 的异常机制。先从异常沿调用栈向上传播说起,梳理基类与普通异常的分层结构,解释为什么不要写裸 except、也不该捕获最顶层的基类异常;再用可运行代码演示四段捕获结构的执行顺序、返回值与清理块的交互,以及多重 except 中子类必须写在父类前面的原因。最后介绍异常组与带星号的分支语法、assert 在优化模式下被整体删除的陷阱,以及异常相对返回值的性能代价。

本节目标:理解 Python 异常的分层结构与传播机制,掌握 try/except/else/finally 的精确执行顺序,学会用 ExceptionGroup / except* 同时处理多个异常,并知道什么时候不该用异常。
适用版本:Python 3.12+(实测 3.14.6)

7.1 异常层次与 try/except/else/finally

上一章我们给类写满了魔术方法,让自定义对象能参与运算、比较与迭代。但程序并不总是顺着写好的路径走:文件可能不存在、网络可能超时、用户输入可能是一串乱码。Python 用异常统一表达这些「计划外情况」。这一节先把异常这个机制本身讲透——它的类型体系、它的捕获语法、它的执行时机。

7.1.1 异常是沿调用栈向上传播的对象

当某行代码出错,解释器会构造一个异常对象,然后沿着调用栈一层层往上找:有没有哪一层用 try 把它接住了?找到了就把控制权交给对应的 except 分支;一路找到顶层还没人接,程序就带着 traceback 退出。

def read_port(config):
    return int(config["port"])       # 可能 KeyError,也可能 ValueError

def start_service(config):
    return read_port(config)          # 自己不做处理,交给上层

try:
    start_service({"port": "abc"})
except ValueError as e:
    print("启动失败:", e)
启动失败: invalid literal for int() with base 10: 'abc'

read_port 里 int("abc") 抛出的 ValueError,穿过 read_port、穿过 start_service,最终被最外层的 except 接住。异常不需要在出错的那一层处理——可以在真正有能力决定怎么办的那一层处理。这正是异常相比「返回错误码」最核心的价值。

7.1.2 异常类的层次:BaseException 与 Exception

异常不是字符串,而是类的实例,所有异常类都继承自 BaseException。关键的分叉在第二层:

BaseException
 ├── SystemExit           # sys.exit() 抛出,代表「正常退出」
 ├── KeyboardInterrupt    # 用户按 Ctrl-C
 ├── GeneratorExit        # 生成器被 close()
 └── Exception            # ← 我们平时打交道的都在这
      ├── ArithmeticError → ZeroDivisionError
      ├── LookupError     → KeyError / IndexError
      ├── OSError         → FileNotFoundError
      ├── ValueError / TypeError / AttributeError
      └── ...

用代码确认这层关系:

print(issubclass(ValueError, Exception))       # True
print(issubclass(ValueError, BaseException))   # True
print(issubclass(SystemExit, Exception))       # False
print(issubclass(SystemExit, BaseException))   # True
print(issubclass(FileNotFoundError, OSError))  # True
print([c.__name__ for c in ZeroDivisionError.__mro__[:4]])
True
True
False
True
True
['ZeroDivisionError', 'ArithmeticError', 'Exception', 'BaseException']

SystemExit 和 KeyboardInterrupt 刻意不继承 Exception。原因是:Ctrl-C 与 sys.exit() 不是「程序出错了」,而是「程序该停下来了」。把它们排除在 Exception 之外,就是为了让普通的 except Exception 不会顺手把这些信号吞掉。这一点下面还会用到。

7.1.3 为什么不要裸 except:,也不要 except BaseException

裸 except: 等价于 except BaseException:,它会连 KeyboardInterrupt、SystemExit 一起接住。后果是:用户按 Ctrl-C 想中断脚本,脚本却「假装没看见」继续跑;sys.exit() 想退出进程,却被拦下来。

def safe_run():
    try:
        raise SystemExit(0)          # 想退出进程
    except Exception:
        print("被 Exception 接住")   # 不会执行
    except BaseException:
        print("被 BaseException 接住")  # 会执行,进程没能退出

safe_run()
被 BaseException 接住

注意 except Exception 没有接住 SystemExit,而 except BaseException 接住了——这正是分层的意义。实际工程里的规矩是:

写法评价说明
except:禁止连 Ctrl-C、退出信号都吞,掩盖一切 bug
except BaseException:极少用只在框架顶层清理资源时,且必须 raise 回去
except Exception:兜底可接受只该出现在进程/线程/请求的最外层
except ValueError:推荐精确捕获你真正能处理的错误

越是内层的代码,越应该捕得具体。 只有最外层的调度器、任务运行器才需要 except Exception 兜底,且它通常紧接着要记录日志并把异常重新抛出。

7.1.4 try/except/else/finally 四段的执行顺序

一个完整的 try 语句最多有四段,各自的职责是:

  • try:放可能出错的代码。
  • except:出错且类型匹配时执行。
  • else:没有出错时执行(注意:是 try 块没抛异常才执行)。
  • finally:无论有没有出错、有没有被捕获,一定执行。

用真实输出把时机看清楚:

def order(n):
    try:
        print("1. try")
        result = 10 // n
    except ZeroDivisionError:
        print("2. except")
    else:
        print("2. else, result =", result)
    finally:
        print("3. finally")

order(2)
print("---")
order(0)
1. try
2. else, result = 5
3. finally
---
1. try
2. except
3. finally

两个必须记住的结论:

  1. else 与 except 互斥——抛异常走 except,不抛才走 else。
  2. finally 永远最后执行,即使异常没有被任何 except 匹配、要穿透到外层,finally 也会先跑完。

else 的存在意义是缩小 try 的范围。如果写成下面这样,process(result) 里万一抛出 ZeroDivisionError,会被误以为来自 10 // n:

try:
    result = 10 // n
    process(result)      # 它的异常会被同一个 except 吞掉,难定位
except ZeroDivisionError:
    ...

把 process 挪进 else,except 就只负责它该负责的那一句。

7.1.5 return 与 finally 的交互

这是异常处理里最容易踩的坑:finally 里的 return 会覆盖 try 里的 return。

def keep():
    try:
        return "from try"
    finally:
        print("finally 仍然执行")     # try 的返回值先暂存,finally 跑完再返回

def override():
    try:
        return "from try"
    finally:
        return "from finally"         # 覆盖前面的返回值

print(keep())       # 先打印「finally 仍然执行」,再打印「from try」
print(override())   # from finally
finally 仍然执行
from try
from finally

try 里算出的返回值会先被暂存,finally 跑完后再真正返回;但只要 finally 里也有 return,前者就被彻底丢弃。Python 3.14 起这种写法会给出明确警告(PEP 765,finally 中的控制流降级为警告,而不是报错):

SyntaxWarning: 'return' in a 'finally' block

结论:绝不要在 finally 里写 return / break / continue。 finally 只用来做清理——关闭文件、释放锁、恢复状态,不要改变控制流。

7.1.6 多重 except 的顺序:子类必须在前

except 是按从上到下顺序匹配的,第一个匹配上的分支获胜,后面的不再检查。由于子类 isinstance 判断也会命中父类,具体(子类)异常必须写在宽泛(父类)异常前面。

try:
    raise FileNotFoundError("config.yaml")
except OSError as e:                    # FileNotFoundError 是 OSError 子类
    print("按 OSError 处理:", type(e).__name__)
except Exception as e:
    print("兜底:", type(e).__name__)
按 OSError 处理: FileNotFoundError

如果顺序写反——except Exception 写在前面——宽泛分支会先把 FileNotFoundError 吃掉,后面的精确分支变成死代码。记住:子类在前,父类在后。

多个类型相同处理时,写成一个元组,别叠多个 except:

try:
    n = int("abc")
except (ValueError, TypeError) as e:    # 两种错误统一处理
    print("输入非法:", e)

Python 3.14 起,元组外的括号在只有一个 except 时还可省略(PEP 758):

try:
    raise KeyError("k")
except KeyError, IndexError:            # 3.14 起合法,等价于 (KeyError, IndexError)
    print("捕获到")

7.1.7 ExceptionGroup 与 except*(3.11 起)

有些场景会同时产生多个错误,比如批量校验一批字段、并发发起多个任务。过去只能「遇到第一个就抛出」,其余错误丢失。3.11 引入 ExceptionGroup 把多个异常打包成一个,配套的 except* 语法可以按类型分组处理。

def validate(data):
    errors = []
    if "name" not in data:
        errors.append(ValueError("缺少 name 字段"))
    if not isinstance(data.get("age"), int):
        errors.append(TypeError("age 必须是整数"))
    if errors:
        raise ExceptionGroup("校验失败", errors)

try:
    validate({"age": "abc"})
except* ValueError as eg:
    print("ValueError 组:", [str(e) for e in eg.exceptions])
except* TypeError as eg:
    print("TypeError 组:", [str(e) for e in eg.exceptions])
ValueError 组: ['缺少 name 字段']
TypeError 组: ['age 必须是整数']

两个关键点:

  1. except* 不是普通的 except:它会扫描整棵异常树,把所有匹配的类型各收进一个子组,逐个交给对应分支。一个 ExceptionGroup 里既有 ValueError 又有 TypeError 时,两个分支都会执行。
  2. except* 后面不能写裸 except*:,也不允许普通 except 与 except* 混用在同一个 try 里。

except* 与 asyncio.TaskGroup 是一对——后者会把并发子任务里所有失败聚合成一个 ExceptionGroup,这正是它相比 asyncio.gather 更安全的地方,第 13 章会展开。

7.1.8 assert 会被 -O 优化掉,别拿它做输入校验

assert 条件, 消息 在条件为假时抛 AssertionError。它看起来像校验,但当 Python 以 -O 运行(优化模式)时,所有 assert 会被整体删除。

def parse_age(s):
    assert isinstance(s, str), "必须是字符串"
    n = int(s)
    assert n >= 0, f"年龄不能为负: {n}"
    return n

用 python3 -O assert_test.py 运行(优化模式)时,两个 assert 都会被删掉;实测 parse_age(-5) 会直接返回 -5,校验形同虚设。所以:

用途该用什么
检查程序员自己的假设(不该发生的 bug)assert,可被 -O 移除
校验用户输入、外部数据、配置if ...: raise ValueError(...),永远执行
校验函数参数类型类型注解 + 运行时校验库,见 9.3 运行时校验与 Pydantic

一句话:assert 是给开发者看的,不是给用户看的。

7.1.9 异常的开销:什么时候该用返回值

异常在不抛出时几乎不花钱,但抛出并捕获时开销明显更大——解释器要构造对象、填充 traceback、展开栈。在 3.14.6 上实测 20 万次调用:

写法每次耗时相对倍数
返回值表示失败(成功路径)约 59 ns1.0x
异常路径但不抛出(成功)约 60 ns1.0x
抛异常并捕获(失败路径)约 261 ns约 4.4x

结论很明确:异常是为「罕见、计划外」的情况设计的,正常控制流别用它。

# 反例:把「没找到」这种常态也用异常表达
def find_user(users, uid):
    try:
        return users[uid]
    except KeyError:
        raise NotFoundError(uid)     # 常态结果却走了异常路径

# 正例:常态用返回值,异常只留给真正异常的情况
def find_user(users, uid):
    return users.get(uid)            # 找不到返回 None,调用方自己判断

判断标准很简单:如果某种结果在正常运行时经常发生,用返回值;只有「本不该发生、发生了说明有 bug 或环境有问题」时才用异常。 具体怎么设计一套异常类型,是下一节的主题。

小结

  • 异常是沿调用栈向上传播的对象,不必在出错处处理,可以在有能力决策的那一层处理。
  • 层次:BaseException → Exception(日常异常)与 SystemExit / KeyboardInterrupt(进程级信号,刻意排除在外)。
  • 不要裸 except:,也不要 except BaseException;内层捕得越具体越好,只有最外层才用 except Exception 兜底。
  • try/except/else/finally:else 与 except 互斥,finally 永远最后执行;finally 里绝不写 return/break/continue。
  • 多重 except 按顺序匹配,子类必须写在父类前面;同处理逻辑用元组 (A, B)。
  • 3.11 起的 ExceptionGroup + except* 能同时携带并分类处理多个异常,与 asyncio.TaskGroup 配套。
  • assert 会被 -O 优化掉,只能用于开发者自检,不能用于输入校验;异常有开销,常态结果请用返回值。

下一节我们把这一节的机制落到工程实践上:如何设计一套自己的异常类型、如何用异常链保留原始错误现场、以及库作者应该怎样暴露异常给调用方。

阅读导航:上一节:6.3 魔术方法与运算符重载 · 下一节:7.2 自定义异常、异常链与错误设计 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时