楔子
既然虚拟机内建的异常处理动作我们已经了解了,那么接下来就看看异常捕获是如何实现的,还有它又是如何影响虚拟机的异常处理流程的。毕竟在大部分情况下,我们都不会将异常抛出去,而是将它捕获起来。
异常捕获语句
这里先来回顾一下异常捕获语句,首先一个完整的异常捕获语句如下。
情况可以分为以下几种:
1)如果 try 里面的代码在执行时没有出现异常,那么会执行 else ,然后执行 finally。
2)如果 try 里面的代码在执行时出现异常了(异常会被设置在线程状态对象中),那么会依次判断 except(可以有多个)能否匹配发生的异常。如果某个 except 将异常捕获了,那么会将异常给清空,然后执行 finally。
except 子句可以有很多个,发生异常时会从上往下依次匹配。但是注意:多个 except 子句最多只有一个被执行,比如当前的 IndexError 和 Exception 都能匹配发生的异常,但只会执行匹配上的第一个 except 子句。
另外只要发生异常了,else 就不会执行了。不管 except 有没有将异常捕获到,都不会执行 else,因为 else 只有在 try 里面没有发生异常的时候才会执行。
3)如果 try 里面的代码在执行时出现异常了,但 except 没有将异常捕获掉,那么异常仍然被保存在线程状态对象中,然后执行 finally。如果 finally 子句中没有出现 return、break、continue 等关键字,再将异常抛出来。
except 没有将异常捕获掉,所以执行完 finally 之后,异常又被抛出来了。但如果 finally 里面出现了 return、break、continue 等关键字,也不会抛出异常,而是将异常丢弃掉。
由于 finally 里面出现了 return 和 continue,所以异常并没有发生,而是被丢弃掉了。这个特性相信有很多小伙伴之前还是没有发现的。
然后 try、except、else、finally 这几个关键字不需要同时出现,可以有以下几种组合。
注意里面的 except,可以出现多次,但其它关键字在一个 try 语句内只能出现一次。
返回值问题
如果这几个关键字对应的代码块都指定了返回值,那么听谁的呢?下面解释一下。
由于没有发生异常,所以返回了 try 指定的返回值。
虽然指定了 else,但是 try 里面已经执行 return 了,所以打印的仍是 try 的返回值。
由于发生异常,所以返回了 except 指定的返回值。
一旦发生异常,else 就不可能执行,所以此时仍然返回 456。
finally 永远会执行,但它没有指定返回值,所以此时返回的是 123。
一旦 finally 中出现了 return,那么返回的都是 finally 指定的返回值。并且此时即便出现了没有捕获的异常,也不会报错,因为会将异常丢弃掉。
函数一旦 return,就表示要返回了,但如果这个 return 是位于出现了 finally 的异常捕获语句中,那么会先执行 finally,然后再返回。所以最后的 return 789 是不会执行的,因为已经出现 return 了,finally 执行完毕之后就直接返回了。
没有异常,所以 except 里的 return 不会执行,而 try 和 finally 里面也没有 return,因此返回 789。
一个简单的异常捕获,总结起来还稍微有点绕呢。
从 Python 的层面理解完异常捕获之后,再来看看虚拟机是如何实现这一机制的?想要搞清楚这一点,还是得从字节码入手。
异常捕获对应的字节码
随便写一段代码,然后反编译一下。
抛异常有两种方式,一种是虚拟机执行的时候出现错误而抛出异常,另一种是使用 raise 关键字手动抛出异常。这里我们就用第二种方式,来看一下反编译的结果(为了清晰,省略掉了源代码行号)。
字节码指令还是比较多的,我们来分段解释。
try 子句的指令
try 子句的指令如下。
6 LOAD_NAME 指令会将 <class 'Exception'> 压入运行时栈。8 LOAD_CONST 指令会将字符串常量压入运行时栈。然后 10 CALL 指令将运行时栈里的元素弹出,进行调用。可以看到不管是调用函数,还是调用类,执行的都是 CALL 指令,然后将返回值(这里就是 Exception 对象,即异常)压入栈中。
接着执行 18 RAISE_VARARGS,这是一条新指令,看一下它的逻辑。
因为 RAISE_VARARGS 指令的参数是 1,所以 case 1 成立,于是将异常从运行时栈中弹出,并赋值给变量 exc,然后调用 do_raise 函数。
在 do_raise 中,最终会调用之前说过的 PyErr_Restore 函数,将异常对象存储到当前的线程状态对象中,然后跳转到标签为 exception_unwind 的地方开始异常捕获。
该指令执行后,异常会被压入栈中,虚拟机也知道该跳转到什么地方了。
except 子句的指令
try 子句的指令我们说完了,再来看看 except 子句。
首先执行 20 PUSH_EXC_INFO 指令,内部逻辑如下。
该指令做的事情是将旧异常和新异常压入运行时栈。
22 LOAD_NAME 加载 Exception,然后执行 24 CHECK_EXC_MATCH,逻辑如下。
26 POP_JUMP_IF_FALSE 会弹出栈顶元素,如果为 False,说明异常无法被捕获,那么要跳转到下一个 except 或者 finally。如果可以被捕获,那么执行 28 STORE_NAME,再将栈里的异常对象弹出,赋值给变量 e。
到此 except Exception as e 这一行语句便已经完成,至于接下来的 4 条指令应该不需要解释了。
图片
很好理解,这 4 条就是 print(e) 对应的指令,然后执行 46 POP_EXCEPT,逻辑如下。
但是接下来的几条指令是干嘛的,估计有人会感到困惑。
图片
这几条指令的具体作用,我们稍后解释。
异常跳转表
finally 子句对应的指令比较简单,我们就不看了。相比之前版本,3.12 的异常捕获变得简单许多,因为相关信息都静态化了。在以前的版本中是使用 SETUP_FINALLY 等指令来处理异常,而 3.12 换成了更高效的异常表结构,类似于 Java 的异常表。
我们来看一下异常表的结构,它由 PyCodeObject 对象的 co_exceptiontable 字段负责维护。
图片
4 to 18 -> 20
表示 try 子句内部对应偏移量为 4 ~ 18 的指令,并且如果出现异常,那么跳转到偏移量为 20 的指令。
20 to 28 -> 66
偏移量为 20 ~ 28 的指令对应 except 子句本身,如果执行出错,跳转到偏移量为 66 的指令去清理异常。
30 to 44 -> 56
偏移量为 30 ~ 44 的指令对应 except 子句内部的处理逻辑,如果执行出错则跳转到第 56 条指令。
图片
注意里面的 DELETE_NAME,它是 del 语句对应的指令。所以跳转之后的这几条指令,负责删除变量 e,怎么理解呢?我们举个例子。
奇怪,为什么在外面打印变量 e 报错了呢?其实 Python 会对 except 子句内部做一些处理,以上代码最终会变成下面这个样子。
至于这么做的原因,稍后解释。
46 to 54 -> 92
del e 相关指令,但它对应的是存在 finally 的情况,删除之后会跳转到偏移量为 92 的指令。
56 to 64 -> 66
del e 相关指令,对应不存在 finally 的情况。
66 to 70 -> 92
异常清理相关指令。
92 to 110 -> 112
finally 子句对应的指令。
我们看到 try / except / finally 块的范围信息、异常处理的起始位置、需要执行的清理操作都被静态化了,在编译阶段就已经确定,所以性能方面比之前要更高。并且当 try 子句内的代码没有出现错误时,和不使用异常捕获之间基本没有性能差异。
总之 Python 中一旦出现异常了,那么会将异常类型、异常值、异常回溯栈设置在线程状态对象中,然后栈帧一步一步地回退,寻找异常捕获代码(从内向外)。如果退到了模块级别还没有发现异常捕获,那么从外向内打印 traceback 中的信息,当走到最内层的时候再将线程中设置的异常类型和异常值打印出来。
模块中调用了函数 f,函数 f 调用了函数 g,函数 g 调用了函数 h。然后在函数 h 中执行出错了,但又没有异常捕获,那么会将执行权交给函数 g 对应的栈帧,但是函数 g 也没有异常捕获,那么再将执行权交给函数 f 对应的栈帧。所以调用的时候栈帧一层一层创建,当执行完毕或者出现异常时,栈帧再一层层回退。
图片
因此栈帧的遍历顺序是从函数 h 到模块,traceback 的遍历顺序是从模块到函数 h。
为什么要执行 del
前面说了,在 except 语句块内,如果将异常赋给了某个变量,那么 except 结束时会将变量删掉。
所以在附加了回溯信息的情况下,它们会形成堆栈帧的循环引用,在下一次垃圾回收执行之前,会使所有变量都保持存活。
小结
本篇文章我们就分析了异常捕获的实现原理,总的来说并不难,因为所有的信息都静态保存在了异常跳转表(简称异常表)中。并且在不报错时,异常捕获对程序性能没有任何影响,所以放心使用。