27 异常(上):优雅高效地处理运行时发生的错误
你好,我是海纳。
到目前为止,我们的虚拟机已经具备很多功能了,比如控制流、函数、对象、模块系统都已经构建完了。在这些机制的基础之上,我们才能完成迭代这一个基本功能。
迭代是 Python 中非常重要的一个机制,在实现列表和字典的时候,我们花了很大的精力介绍它们的迭代器。实际上,在 Python 中,自定义类型也可以定义迭代器。这种自定义迭代器需要依赖很多重要的功能作为基础,到目前为止,我们还差一个功能尚未完成,就是异常。因为 Python 虚拟机使用 StopIteration 异常来标志迭代结束。
这节课,我们将重点实现异常的处理机制。异常的处理需要增加新的控制流处理方式,我们从最简单的情况开始,逐步实现它。
实现 finally 子句
我们先看一个例子。
def foo():
try:
print("hello")
return
print("hi") # will not be executed
finally:
# will be executed
print("world")
foo()
上述代码的执行结果是打印“hello”和“world”两个字符串。也就是说,return 语句(第 4 行)执行以后,函数并没有立即返回,而是打印完 “world”(第 8 行)以后才结束。
为了分析这个问题,我们还是得从字节码入手,再次使用 show_file 工具查看测试用例的字节码。
2 0 SETUP_FINALLY 16 (to 18)
3 2 LOAD_GLOBAL 0 (print)
4 LOAD_CONST 2 ('hello')
6 CALL_FUNCTION 1
8 POP_TOP
4 10 POP_BLOCK
12 CALL_FINALLY 4 (to 18)
14 LOAD_CONST 0 (None)
16 RETURN_VALUE
8 >> 18 LOAD_GLOBAL 0 (print)
20 LOAD_CONST 1 ('world')
22 CALL_FUNCTION 1
24 POP_TOP
26 END_FINALLY
28 LOAD_CONST 0 (None)
30 RETURN_VALUE
在 Python3 中,编译器前端做了很多优化工作,像打印字符串 hi 的操作位于 return 指令之后,所以它就是一段死代码,已经被编译器前端删除了。这一点和 Python2 很不一样,Python2 要自己处理 try 语句里的 continue 和 break 关键字,在 Python3 里,编译器在生成字节码的时候就已经做好了优化。
接下来,我们看一下 Python3 在处理异常时引入了哪些新的特性。
首先是 SETUP_FINALLY 这条指令,它的作用是创建一个 Block 对象,里面记录了 Block 开始时的栈状态,以及在这个 Block 发生了异常时应该跳转到哪条指令继续执行。
Block 对象中要记录类型、操作数栈深度和跳转目标三个参数,所以 Block 的定义就比较简单,它只不过是三个数字的集合而已。你可以看一下它的实现。
class Block {
public:
unsigned char _type;
unsigned int _target;
int _level;
Block() {
_type = 0;
_target = 0;
_level = 0;
}
~Block() {
_type = 0;
_target = 0;
_level = 0;
}
Block(unsigned char b_type,
unsigned int b_target,
int b_level):
_type(b_type),
_target(b_target),
_level(b_level) {
}
Block(const Block& b) {
_type = b._type;
_target = b._target;
_level = b._level;
}
}
SETUP_FINALLY 的参数是一个跳转的相对地址,就像例子中当前 pc 是 2,参数是 16,这就意味着从偏移地址 2 到偏移地址 18(代码行号是 3 至 11),形成了一个新的 Block,这就是 Finally Block。
不管是以何种方式离开 Finally Block,都必须跳到偏移地址 18 处去执行,那里是真正的 finally 子句的逻辑。
在执行 SETUP_FINALLY 时,我们会把这个相对地址记录到 Block 对象的 _target 里。所以 SETUP_FINALLY 的实现就很简单了,你可以看一下它的具体代码。
// frameObject.cpp
BlockList* FrameObject::blocks() {
if (!_blocks) {
_blocks = new BlockList();
}
return _blocks;
}
void FrameObject::setup_block(unsigned char btype, unsigned int target, int level) {
blocks()->add(Block(btype, target, level));
}
Block FrameObject::pop_block() {
assert(_blocks && _blocks->length() > 0);
return _blocks->pop();
}
// interpreter.cpp
void Interpreter::eval_frame() {
...
while (_frame->has_more_codes()) {
unsigned char op_code = _frame->get_op_code();
...
switch (op_code) {
...
case ByteCode::SETUP_FINALLY:
_frame->setup_block(ByteCode::SETUP_FINALLY,
_frame->get_pc() + op_arg, STACK_LEVEL());
break;
...
}
}
}
如果在 Finally Block 的范围内出现了异常,虚拟机就会跳转到 Block 中所记录的 target 地址处执行。如果 Block 内的语句都正确执行了,虚拟机就会执行 CALL_FINALLY 指令,通过这条指令,控制流正常地进入 finally 子句执行。
CALL_FINALLY 指令是一个跳转指令,它会把当前指令的地址记录在栈上,等 finally 子句执行完以后再跳转回来继续执行,所以这条指令的行为和 CALL_FUNCTION 非常相似,这也是它被命名为 CALL 指令的原因。
有两种情况可以进入 finally 子句执行,一种是发生了异常通过 Finally Block 进入,另外一种是try 语句自然结束,通过 CALL_FINALLY 进入。
如果是因为异常而进入的,我们得把异常状态保存起来,以备后面真正执行 return 的时候再恢复出来。Python 字节码在设计的时候就明确规定了使用操作数栈保存状态,恢复状态使用 END_FINALLY,这条字节码所要求保存的状态是有格式的。
如果虚拟机执行时发生异常,操作数栈上需要保存异常对象、异常类型和 Traceback 对象。其中 Traceback 对象的作用是记录调用栈信息。
如果是通过 CALL_FINALLY 进入 finally 子句,我们要把当前指令地址保存到栈上。由于操作数栈里只能存放对象的引用,直接往里面放一个整数是不行的,必须进行强制类型转换,但这样的话,怎么才能区分这到底是一个异常对象还是一个整数呢?
还记得我们在实现自动内存管理的时候,要求所有对象都是 8 字节对齐吗?这样做可以保证任何一个对象指针的低三位都是 0,我们可以充分利用低三位进行类型标记。倒数第二位已经被我们拿来标记 forwarding 指针了,这里可以使用倒数第一位来标记这是一个直接整数而不是指针。
所以我们可以这样实现 CALL_FINALLY 指令:
// interpreter.cpp
void Interpreter::eval_frame() {
...
while (_frame->has_more_codes()) {
unsigned char op_code = _frame->get_op_code();
...
switch (op_code) {
...
case ByteCode::CALL_FINALLY:
PUSH((HiObject*)((long long)(_frame->get_pc() << 1) | 0x1));
_frame->set_pc(_frame->get_pc() + op_arg);
break;
...
}
}
}
这条指令做了两件事情,一是把当前指令地址左移 1 位,然后与 0x1 做或操作,并把这个结果转换成 HiObject 指针存入操作数栈。与 0x1 做或操作可以保证最后一位一定是 1,通过这个标记位就可以区分普通的对象指针和指令地址了。
进入 finally 子句以后,就和平常执行普通逻辑是相同的,直到遇到那条 END_FINALLY,这提示我们 finally 所对应的逻辑已经执行完了,我们应该把进入 finally 子句之前的所有状态都拿回来了,包括返回值和解释器状态,这两个东西被我们放在操作数栈里了,现在再从栈里恢复就好了。
void Interpreter::eval_frame() {
...
while (_frame->has_more_codes()) {
unsigned char op_code = _frame->get_op_code();
...
switch (op_code) {
...
case ByteCode::BEGIN_FINALLY: {
PUSH(nullptr);
break;
}
// ...
case ByteCode::END_FINALLY: {
v = POP();
long long t = (long long)v();
if (t == 0) {
// do nothing.
}
else if (t & 0x1) {
_frame->set_pc(t >> 1);
}
else {
}
break;
}
}
}
}
END_FINALLY 指令先从操作数栈上取出一个整数值(第 16 行),并判断这个值是不是 0。这个值是为了配合 BEGIN_FINALLY 指令而加的。BEGIN_FINALLY 是虚拟机为了平衡栈深度而引入的一个字节码,并不重要,它只是简单地向栈上放入一个 0 而已。
接下来,通过整数值的最后一个比特,判断它是一个地址还是一个对象指针。如果最后一个比特置位,说明这个值是一个地址,这正是 CALL_FINALLY 指令所存在栈上的地址,只需要把 frame 的 pc 值恢复为这个值就可以了(第 20 行至 22 行)。
如果这个值是普通的对象,就说明已经发生了异常。这时候需要将异常对象从栈上取出来,这里的具体实现先放一下,等异常对象实现完了以后,再来补全。
实现了这几个字节码,开头的那个例子就能正常执行了。因为这个例子中只有 finally 子句,并且不会发生异常。接下来,我们实现异常产生的过程。首先,我们要先搞明白异常对象是什么。
异常对象
在 Python 中,Exception 类代表异常,在 Python 编程中常见的异常类都是它的子类,比如 StopIteration 和 ZeroDivisionError 等。
首先,我们要在虚拟机中增加 Exception 类型。这一章开始之前,如果想要在虚拟机中增加一种内建类型,只能像整数类型、字符串类型等内建类型那样创建对象类,创建它所对应的 Klass,再维护 Klass 的各种继承关系等等,十分繁琐。
但在上一节课引入了模块以后,我们就有了第二种选择,可以在 builtin 模块中定义这些异常类。
# [lib/builtin.py]
class Exception(object):
def __init__(self, *args):
self.info = args
self.__context__ = None
self.__traceback__ = None
def __repr__(self):
return "Error"
class ZeroDivisionError(Exception):
def __init__(self, *args):
self.info = args
def __repr__(self):
return "divide by zero"
这里只用了 15 行代码,我们就搭建起了基本的异常对象结构了。可以看到使用 Python 来开发模块是非常简洁高效的,这也体现出了模块功能的巨大优势。
有了 Exception 的定义以后,我们来研究 Python 中的异常是如何产生,又是如何影响控制流的。
我们先看下面这个简短的例子。
这个例子的逻辑是主动发起一个异常,然后通过 except 语句抓住这个异常,并把它赋值给变量 e。然后打印这个异常。这个例子涉及一些新的语法,我们通过查看它的字节码来研究它。
像以前一样,我们还是把它编译成 pyc 文件,并且使用 show_file 工具查看它的字节码。我把它的字节码放在下面,供你参考。
1 0 SETUP_FINALLY 60 (to 62)
2 SETUP_FINALLY 12 (to 16)
2 4 LOAD_CONST 1 (1)
6 LOAD_CONST 2 (0)
8 BINARY_TRUE_DIVIDE
10 POP_TOP
12 POP_BLOCK
14 JUMP_FORWARD 42 (to 58)
3 >> 16 DUP_TOP
18 LOAD_NAME 1 (Exception)
20 COMPARE_OP 10 (exception match)
22 POP_JUMP_IF_FALSE 56
24 POP_TOP
26 STORE_NAME 2 (e)
28 POP_TOP
30 SETUP_FINALLY 12 (to 44)
4 32 LOAD_NAME 0 (print)
34 LOAD_NAME 2 (e)
36 CALL_FUNCTION 1
38 POP_TOP
40 POP_BLOCK
42 BEGIN_FINALLY
>> 44 LOAD_CONST 3 (None)
46 STORE_NAME 2 (e)
48 DELETE_NAME 2 (e)
50 END_FINALLY
52 POP_EXCEPT
54 JUMP_FORWARD 2 (to 58)
>> 56 END_FINALLY
>> 58 POP_BLOCK
60 BEGIN_FINALLY
6 >> 62 LOAD_NAME 0 (print)
64 LOAD_CONST 0 ('hello')
66 CALL_FUNCTION 1
68 POP_TOP
70 END_FINALLY
72 LOAD_CONST 3 (None)
74 RETURN_VALUE
可能超出很多人的意料,短短的 6 行 Python 代码,竟然生成了这么多的字节码。虽然字节码比较长,但是这里面并没有我们还没有实现的字节码。我们先来解释一下这里的每一行字节码的意义。
第一个 SETUP_FINALLY,会创建一个 Finally Block,而且目标地址是偏移为 62 的地方(第 36 行),正好对应了 Python 源码里的 finally 子句。
第二个 SETUP_FINALLY,也创建了一个 Finally Block,而且目标地址是偏移为 16 的地方(第 11 行),对应了 Python 源码里的 except 子句。这就意味着,一旦 try 语句中出现了异常,虚拟机就会先跳到 except 语句里处理。
偏移地址为 8 的那个除法指令(第 6 行),会产生一个除零异常。一旦解释器发现某条指令发生异常了,就会从 Block 列表里取出最后一项,并跳转到它的目标地址,也就是偏移地址为 16 的地方。
这个时候,虚拟机会假设操作数栈上已经存储了三个对象,分别是异常的类型对象、异常的实例对象和异常发生时的栈帧,也就是 Traceback 对象。
偏移地址为 20 的那条 COMPARE_OP 指令(第 13 行),是为了比较异常的类型与目标类型是否匹配。这里是与 Exception 类型进行匹配。由于第 6 行的除法指令会产生一个 ZeroDivisionError,而 ZeroDivisionError 是 Exception 的子类,所以这里的匹配是会成功的。如果匹配成功了,就会打印异常对象 e。
偏移地址 52 的指令是 POP_EXCEPT,这条指令的作用和 END_FINALLY 指令的作用比较像,它是从栈上将异常对象都恢复到解释器的状态里。这里我们先不详细解释这个过程。等到上面提到的机制都实现完了,你会对这条字节码有更深入的理解。
分析完这段字码的基本含义以后,我们来实现相关的数据结构。
处理异常
首先,我们在解释器里添加 4 个域用来记录异常发生时的状态,第一个域是 _exception_class,记录异常的类型,第二个域是 _pending_exception,记录异常的实例,第三个域是 _trace_back,记录异常发生时的栈帧,还有一个是 _int_status,记录解释器的状态。
然后,我们在 Integer 的 div 方法中来处理除 0 异常。
// hiInteger.cpp
HiObject* IntegerKlass::div(HiObject* x, HiObject* y) {
int ix = x->as<HiInteger>()->value();
int iy = y->as<HiInteger>()->value();
if (iy == 0) {
Interpreter::get_instance()->set_error_str(ST(div_zero));
return nullptr;
}
return new HiInteger(ix / iy);
}
// interpreter.cpp
void Interpreter::set_error_str(HiString* name) {
Handle<HiObject*> exc = _builtins->get(name);
assert(exc != Universe::HiNone);
set_error_object(exc, nullptr, nullptr);
_int_status = IS_EXCEPTION;
}
void Interpreter::set_error_object(HiObject* raw_exc, HiObject* raw_val, HiObject* raw_tb) {
normalize_errors(&raw_exc, &raw_val, &raw_tb);
_pending_exception = raw_val;
_exception_class = raw_exc;
_trace_back = raw_tb;
_pending_exception->setattr(ST(context), _old_exception);
_int_status = IS_EXCEPTION;
}
void Interpreter::normalize_errors(HiObject** raw_exc, HiObject** raw_val, HiObject** raw_tb) {
assert(raw_exc != nullptr);
Handle<HiObject*> exc(*raw_exc);
Handle<HiObject*> val(*raw_val);
Handle<HiObject*> tb(*raw_tb);
if (tb == nullptr) {
tb = Traceback::new_instance();
}
if (exc->klass() == TypeKlass::get_instance()) {
val = exc->call(nullptr, nullptr);
}
else {
val = exc;
exc = val->klass()->type_object();
}
val->setattr(ST(tb), tb);
// 可能已经发生过GC了,所以要把地址重新刷新一下
*(raw_exc) = exc();
*(raw_val) = val();
*(raw_tb) = tb();
}
在做除法之前,先检查除数是不是 0, 如果是 0,则产生一个除零异常(第 6 到 8 行)。
通过调用 set_error_str 从内建模块中找到 ZeroDivisionError,把这个类所对应的类型对象赋给_exception_class。并且把 _int_status 设成了 IS_EXCEPTION,代码指示了当前有一个要处理的异常。
调用 set_error_obj 时还要再使用 normalize_erros 方法对异常对象进行一次标准化处理。因为有些时候,用户的代码里并不是按照异常类型、异常对象、Traceback 对象这样的顺序传入的,所以要再对异常对象进行处理,以保证它们的顺序。normalize_erros 的逻辑相对比较简单,这里就不再解释了。
第二步,当我们结束了 eval_frame 的那个巨大的 switch 之后,进入了非 IS_OK 的状态,就要根据 Block 的情况做一些处理了。这意味着异常处理的时机正是在两条字节码执行的中间。
void Interpreter::eval_frame() {
...
while (_frame->has_more_codes()) {
unsigned char op_code = _frame->get_op_code();
...
switch (op_code) {
...
}
error_handling:
while (_int_status != IS_OK && _frame->blocks()->length() > 0) {
Block b = _frame->blocks()->pop();
if (b._type == ByteCode::EXCEPT_HANDLER) {
assert(STACK_LEVEL() >= b._level + 3);
while (STACK_LEVEL() > b._level + 3) POP();
_exception_class = POP();
_pending_exception = POP();
_trace_back = POP();
continue;
}
while (STACK_LEVEL() > b._level) {
POP();
}
if (b._type == ByteCode::SETUP_FINALLY) {
_frame->setup_block(ByteCode::EXCEPT_HANDLER, -1, STACK_LEVEL());
PUSH(_trace_back);
PUSH(_pending_exception);
PUSH(_exception_class);
normalize_errors(&_trace_back, &_pending_exception, &_exception_class);
PUSH(_trace_back);
PUSH(_pending_exception);
PUSH(_exception_class);
_old_exception = _pending_exception;
_trace_back = nullptr;
_pending_exception = nullptr;
_exception_class = nullptr;
_frame->_pc = b._target;;
_int_status = IS_OK;
break;
}
} // end of while
if (_int_status == IS_EXCEPTION && _frame->blocks()->length() == 0) {
_trace_back->as<Traceback>()->record_frame(_frame);
if (_frame->is_first_frame() ||
_frame->is_entry_frame())
return;
leave_frame();
goto error_handling;
}
}
}
这份代码看上去有点复杂,我们慢慢看。
如果发生了异常,解释器就会跳转到 error_handling 处继续执行,可以想象,在 try 语句中发生了异常,解释器会因为 SETUP_FINALLY 这个 Block 而执行到第 29 行。
这时只需要把这个 Block 取出来,再重新设置 EXCEPT_HANDLER 这个 Block。这个 Block 的作用是告诉解释器,当前已经是在异常处理的流程中,如果此时再发生异常就得从栈上恢复异常状态(第 14 至 23 行)。
参考我们这节课的例子,也就是说当解释器执行 except 语句时,Block Stack 上还有两个 Block,分别是一个 SETUP_FINALLY 和一个 EXCEPT_HANDLER。如果在 EXCEPT_HANDLER 的范围内再次发生异常,那就只能从栈上将最早的那个异常恢复出来。然后再由解释器决定是应该需要转向 finally 子句执行,还是直接从函数中退出(第 54 至 62 行)。
如果当前的 Block 是 Finally Block(第 29 行),就要把异常相关的对象都保存到栈里(第 30 至 49 行),这个顺序是不能乱的,因为后面的字节码执行依赖于这个顺序。
回到这节课例子里的字节码,我们通过 SETUP_FINALLY Block 的 target 属性,就跳到了第 11 行继续执行,这里已经是 except 子句了。
DUP_TOP 的作用是将栈顶元素复制一份,通过刚刚的分析我们可以知道,栈顶现在存的是异常的类型对象,这主要是为了接下来的对比。我们看一下下面这个例子。
try:
1 / 0
except StopIteration as e:
print("stop iteration")
except ZeroDivisionError as e:
print("zero division")
这个例子的执行结果是打印 zero division,而不会打印 stop iteration。这是因为实际的异常类型是 ZeroDivisionError ,与第 3 行的 StopIteration 不匹配。
这里匹配操作所使用的字节码就是 COMPARE_OP。这个字节码我们已经很熟悉了,大于、小于、in、is 等比较操作都是依赖于这个字节码的,不同的比较操作,对应的参数是不同的,异常匹配所对应的字节操作参数是 10。
判断异常是否匹配,只需要检查实际发生的异常的类型是不是目标异常的子类型。我们可以这样实现 exception match 操作:
void Interpreter::eval_frame() {
...
while (_frame->has_more_codes()) {
unsigned char op_code = _frame->get_op_code();
...
switch (op_code) {
...
case ByteCode::COMPARE_OP:
w = POP();
v = POP();
switch(op_arg) {
...
case ByteCode::EXC_MATCH: {
if (v->as<HiTypeObject>()->mro()->index(w) >= 0) {
PUSH(HI_TRUE);
}
else {
PUSH(HI_FALSE);
}
break;
}
...
}
...
}
}
}
COMPARE_OP 指令对实际发生的异常的类型和目标类型进行比较,如果目标类型是实际异常类型的父类(第 15 行),就说明匹配成功了,如果没有找到,就说明匹配失败。
好了,到这里我们就完成了 except 语句的功能。下一节课我们会继续介绍在发生异常的情况下, finally 子句的处理流程。
总结
这节课我们先从 finally 子句开始,讲解了在正常情况下控制流是如何在执行完 try 语句之后再跳入 finally 子句的。
在未发生异常的情况下,Python 主要通过 CALL_FINALLY 字节码转入 finally 子句执行。当 finally 子句的字节码执行完以后,就会通过 END_FINALLY 再转回到原 block 中继续执行。这就像是发生了一次短的跳转而已。
在发生了异常的情况下,解释器就需要通过 SETUP_FINALLY Block 跳转入 finally 子句执行。在跳转进入之前,解释器会把异常对象放入栈上,以便于 finally 子句正常执行。当 finally 子句执行完以后,END_FINALLY 会把异常状态全部恢复,以便于解释器带着异常状态退出当前函数栈帧。
在这个过程中,我们演示了如何通过 Python 源码定义异常对象。使用 Python 定义异常对象,无疑比使用 C++ 在虚拟机中定义高效了很多。
最后,我们又介绍了 except 语句匹配异常对象所使用的指令。从而完成了处理异常对象的基本流程。下节课,我们再来实现 END_FINALLY 和 POP_EXCEPT 等字节码,从而实现退出栈帧、清理异常对象的能力。这样异常处理的全部功能才算完成。
思考题
不同虚拟机在处理异常时使用的数据结构各不相同,在这节课的基础上,你也可以进一步研究JVM是如何处理异常的。欢迎你把你的研究成果分享到评论区,也欢迎你把这节课的内容分享给其他朋友,我们下节课再见!
- 冯某 👍(1) 💬(0)
这节有点难懂
2024-12-11 - ifelse 👍(0) 💬(0)
学习打卡
2024-11-11 - Geek_3d35fd 👍(0) 💬(0)
cpython中POP_EXCEPT字节码实现是pop block之后就直接continue,执行紧挨着的下一个字节码了
2024-10-05 - Geek_3d35fd 👍(0) 💬(0)
看代码有一个疑惑, 很长字节码部分执行到POP_EXCEPT弹出栈上异常对象,设置执行器状态是异常,会触发pop Block(type=setup_finally),重置状态为IS_OK,然后执行finally部分代码,改部分代码执行完紧接着执行END_FINALLY字节码,会重新设置状态为IS_EXCEPTION, 弹完BLOCK后状态始终是IS_EXCEPTION, 这样异常并没有捕获接着逐渐向上抛出了,正常应该在本函数捕获了啊
2024-10-05