Berry 异常处理 1: 语法和字节码设计

语法

最近在实现 Berry 的异常处理特性,进过初步的调查后决定使用类似 Python 的 try-except 异常处理模式,为此要引入三个新的关键字:

  • try:表示异常捕获块的开始,位于异常捕获块中的代码抛出的异常将会被捕获,并由 except 语句指定的代码来处理。
  • except:由该关键字构成的语句后跟随一个用于处理指定异常的代码块。
  • raise:该语句用于抛出一个异常。

异常处理的常见写法类似这样:

try
    ...
    raise error
except ErrorName:
    ...
end

在 Berry 中,raise 语句后允许跟 1 到 2 个表达式,第一个表达式为抛出的异常值,第二个可选参数为额外的参数。except 语句的写法则比较多:

  • excpet Exception1 {, Exception2}::捕获 Exception1Exception2 等异常。
  • excpet Exception1 {, Exception2} as e [, arg]:捕获 Exception1Exception2 等异常。捕获到的异常对象存储在变量 e 中,同时获取一个可选的额外参数 arg
  • except .. as e [, arg]::将捕获的异常对象存入变量 e,任何异常都会被捕获。同时获取一个可选的额外参数 arg
  • except::捕获所有异常。

字节码设计

功能定义

我们新增了 3 个指令用于运行时的异常处理特性支持。这三个指令是:

  • EXBLK:开辟或关闭异常捕获块。所有在异常捕获块中的发生的异常都会被该块捕获,之后会跳转到指定的异常处理代码中。
  • CATCH:检查被捕获的异常是否在给定的捕获异常值列表中,如果是则应执行相应的处理代码。
  • RAISE:抛出异常值及其附加参数。

这里可能要解释一些概念:“异常值”是指用于表示某种异常的值,它可以是一个数、字符串或者是类,在进行异常捕获时,只有抛出的异常值和捕获列表中的异常值存在匹配时才能执行相应的异常处理代码;“异常附加参数”是指在抛出异常值的同时可以额外传递的一个值,通常用来说明异常的详细信息。

指令参数定义

EXBLK

该指令存在两种模式:

EXBLK 0 sBx

在这个模式下,指令的 A 操作数为 0,该模式下 EXBLK 指令会创建一个异常捕获块。如果在该块中捕获到异常,VM 将会恢复到执行该指令时的状态并跳转到地址为 pc + sBx 的位置执行代码。

EXBLK 1 Bx

在该模式下,指令的 A 操作数为 1,该模式将关闭 Bx 个异常捕获块。使用该指令即表明异常捕获块结束。

在没有 breakreturn 一类的跳转语句时,EXBLK 0 sBxEXBLK 1 Bx 大致分别出现在 try 和第一个 except 语句之间(这是一个异常捕获快的范围)。

CATCH

CATCH 指令对应源码中的 except 语句。其指令格式为:

CATCH A B C

CATCH 指令会到由 AA + B - 1 索引的寄存器中查找是否有匹配的异常,如果有,则:

  1. 从栈顶开始拷贝 C 个值到从 A 开始的寄存器中,这些值就是异常值和异常参数(因此最多有两个)。
  2. 跳过下一条指令(通常是一个跳转到下一个 CATCH 块的指令)。

如果匹配不成功,则不会执行上述操作,因此 CATCH 指令后的一条指令将被执行。

RAISE

该指令对应于脚本中的 raise 语句,其指令格式为:

RAISE 0 B
RAISE 1 B C
RAISE 2

第一种模式中,操作数 A 的值为 0,此时会将 B 操作数索引的寄存器中的值作为异常值抛出。第二种模式中,操作数A为 1,此时会将 B 寄存器中的值作为异常值抛出,同时将 C 寄存器中的值作为额外参数抛出。第三种模式下,RAISE 指令会将现有的异常值和额外参数抛出(它们通常由先前的 RAISE 指令产生)。

字节码的使用

现在,我们通过一段简单的代码来说明字节码的生成方式:

try
    raise 'my_except', 'test'
except 'my_except' as e, v:
    print(e, v)
end

这段代码会生成下面的字节码(假设该代码段在一个函数中):

Line 1   0: EXBLK   0   [4]         ; jump to 4
Line 2   1: RAISE   1   R256  R257  ; R256: 'my_except', R257: 'test'
         2: EXBLK   1   1
         3: JMP         [13]        ; jump to 13
Line 3   4: MOVE    R0  R256        ; R256: 'my_except'
         5: CATCH   R0  1     2
         6: JMP         [12]        ; jump to 12
Line 4   7: GETGBL  R2  G:14        ; G14: <function: print>
         8: MOVE    R3  R0
         9: MOVE    R4  R1
        10: CALL    R2  2
        11: JMP         [13]        ; jump to 13
        12: RAISE   2
Line 5  13: RET     0   R0

其中第 0,1,2,5,12 条指令是为异常处理新增的指令。

  • 第 0 条指令由 try 语句翻译而成,它开辟一个异常处理块并继续向下执行,如果在该块被关闭前发生了异常,VM 状态将回到该条指令并跳转到第 4 条指令。
  • 第 1 条指令由 raise 语句翻译成,它抛出一个异常,异常值和异常参数分别位于 R256 和 R257 中(也就是常量 0 和常量 1)。异常抛出后,VM 将会返回第 0 条 EXBLK 指令并跳转到第 4 条指令。而以下指令不会被执行。
    • 第 2 条用于实现在离开异常处理块时对其的销毁。这里的指令对应于顺序流程中异常处理块的退出,在跳出外层循环或者函数返回时也要使用该指令退出异常处理块(如果嵌套了多级 try 语句则要退出多层异常处理快)。
    • 第 3 条指令用于跳过和该 try 语句配合的所有 except 语句块。代码如果执行到第 2,3 条指令则说明没有发生异常,因此不必进行异常捕获。
  • Line 3 中的 3 行指令由源代码中的 except 行翻译成,首先使用 MOVE 指令将函数常量表中的 'my_except' 字符串加载到寄存器 R0 中,随后 CATCH 指令会进行异常捕获。
    • 从第 5 条的 CATCH 指令参数中可以看出,第 1 个待匹配异常值存储在 R0 中,总共有 1 个待匹配的异常值(也就是 'my_except' 字符串)。该指令还获取 2 个捕获值(由操作数 C 给出),它们分别对应变量 ev
    • 代码运行时,由于这个 excpet 分支能成功捕获到一个 'my_except' 异常值,因此会被执行。异常捕获的整个过程是:
      1. 第 1 条 RAISE 指令抛出异常
      2. VM 状态重置到 第 0 条 EXBLK 处,并跳转到第 4 条指令(由第 0 条指令的 sBx 操作数给出)
      3. 第 4 ~ 5 条指令被执行,后者匹配到 'my_except' 异常,因此将异常值和异常参数存储到变量 ev
      4. 第 5 条指令 CATCH 匹配成功后会跳过下一条指令,因此 VM 接下来执行指令 7,这里对应源代码第 4 行的异常信息打印代码
      5. 执行到第 11 条指令,跳出当前的 except 分支,也就是跳出整个异常捕获语句。异常捕获和处理的流程结束

这里简单说一下异常处理过程中捕获失败时的情况(从第 5 条指令开始):

  • CATCH 指令匹配异常值失败,不会获取异常变量和异常参数,也不跳过下一条指令
  • 第 6 条的 JMP 语句跳转到第 12 条语句
  • 此时分几种情况:
    • 例子中只有一个 excpet 分支,因此第 12 条指令直处接用 RAISE 重新把当前异常抛给上级异常处理机制。
    • 实际上可能存在多个 except 分支,此时第 12 条指令对应下一个分支的开始。在最后一条分支后总会有一个用于捕获失败时重新抛出异常的 RAISE 指令。

总结

到此,字节码层面和源码之间对应的转换关系已经说完。接下来我们需要根据这些关系来设计编译器的相关部分。当然,异常处理机制的实现还离不开运行时的支持,因此我们还要实现这三条字节码的运行时功能。这些内容将在后面的文章中讲解。

posted @ 2019-10-26 23:17  Skiars  阅读(330)  评论(0)    收藏  举报