3

Python 默认是没有 goto 语句的,但是有一个第三方库支持在 Python 里面实现类似于
goto 的功能:https://github.com/snoack/pyt...

比如在下面这个例子里,

from goto import with_goto

@with_goto
def func():
    for i in range(2):
        for j in range(2):
            goto .end
    label .end
    return (i, j, k)

func() 在执行第一遍循环时,就会从最内层的 for j in range(2) 跳到函数的
return 语句前面。

按理说本文到此就该完了,但是这个库有一个限制,如果嵌套的循环层次太深,就无法工作
。比如下面这几行代码:

@with_goto
def func():
    for i in range(2):
        for j in range(2):
            for k in range(2):
                for m in range(2):
                    for n in range(2):
                        goto .end
    label .end
    return (i, j, k, m, n)

会让它抛出 SyntaxError

本文接下来的内容,就是如何打破这个限制。

python-goto 是如何工作的

python-goto 这个库,通过 decorator 的方式修改了传进来的函数 func
__code__ 属性,把插入的字节码暗桩替换成相关的 JMP 语句。具体的琐碎实现细节,
可以参考该项目下 goto.py 这个文件,一共也就不到两百行。

本文开头的例子中,func 函数的字节码可以用

import dis
dis.dis(func)

打印出来。

下面贴出不带 @with_goto 时的输出(# 号后面的内容是我加的):实际上

# for i in range(2):
# 7 是源代码行号(跟示例不太对得上,不要太在意细节XD)
# 0/2/4 这些是 offset,在这里每条字节码长度都是 2。
# >> 表示会跳到这里。
  7           0 SETUP_LOOP              40 (to 42)
              2 LOAD_GLOBAL              0 (range)
              4 LOAD_CONST               1 (2)
              6 CALL_FUNCTION            1
              8 GET_ITER
        >>   10 FOR_ITER                28 (to 40)
             12 STORE_FAST               0 (i)

# for j in range(2):
  8          14 SETUP_LOOP              22 (to 38)
             16 LOAD_GLOBAL              0 (range)
             18 LOAD_CONST               1 (2)
             20 CALL_FUNCTION            1
             22 GET_ITER
        >>   24 FOR_ITER                10 (to 36)
             26 STORE_FAST               1 (j)

# goto .end
  9          28 LOAD_GLOBAL              1 (goto)
             30 LOAD_ATTR                2 (end)
             32 POP_TOP
# 结束循环 j
             34 JUMP_ABSOLUTE           24
        >>   36 POP_BLOCK
# 结束循环 i
        >>   38 JUMP_ABSOLUTE           10
        >>   40 POP_BLOCK

# label .end
 10     >>   42 LOAD_GLOBAL              3 (label)
             44 LOAD_ATTR                2 (end)
             46 POP_TOP

# return (i, j, k)
 11          48 LOAD_FAST                0 (i)
             50 LOAD_FAST                1 (j)
             52 LOAD_GLOBAL              4 (k)
             54 BUILD_TUPLE              3

跟带 @with_goto 时的输出比较,只有这两点差别:

# goto .end
-  9          28 LOAD_GLOBAL              1 (goto)
-             30 LOAD_ATTR                2 (end)
-             32 POP_TOP
+  9          28 POP_BLOCK
+             30 POP_BLOCK
+             32 JUMP_FORWARD            14 (to 48)
# label .end
- 10     >>   42 LOAD_GLOBAL              3 (label)
-             44 LOAD_ATTR                2 (end)
-             46 POP_TOP
+ 10     >>   42 NOP
+             44 NOP
+             46 NOP

- 11          48 LOAD_FAST                0 (i)
+ 11     >>   48 LOAD_FAST                0 (i)

在没有引入 @with_goto 时,goto .end 在 Python 解释器的眼里,其实就是
goto.end,即访问某个叫 goto 的全局域里的对象的 end 属性。该语句会被编译成
三条语句:LOAD_GLOBALLOAD_ATTRPOP_TOP。这就是插入在字节码里的暗桩。

在引入 @with_goto 之后,这三条语句会被替换成一条 JMP 语句外加若干条辅助的语句
。这样在执行到这些字节码时,就会跳到指定的地方了,比如在上面例子中跳到 offset 48
,也即原来 label .end 的下一条字节码。

(关于 Python 字节码的官方文档并不显眼,藏在 dis 这个模块下。
注意它不是按字母表顺序介绍每个字节码的,所以要想查特定的字节码,需要 Ctrl+F 一下。)

JMP 语句只需要一条,如果要向前跳,就用 JUMP_FORWARD;向后跳,就用
JUMP_ABSOLUTE。但是辅助的语句可能不止一条,比如要想从一个 for loop 或者 try
block 跳出来,需要加 POP_BLOCK 语句。有多少层循环就需要加多少条 POP_BLOCK,比如前面
的示例里是两层循环,就是两条 POP_BLOCK

另外,由于 Python 字节码的长度固定为两个 byte,一个 byte 用于表示字节码的类型,
另一个用于表示参数。如果要想放下超过字节码预留的空位的参数,需要用 EXTENDED_ARG
语句。比如

EXTENDED_ARG             7
EXTENDED_ARG          2046
OP                       x

那么语句 OP 的参数就是 7 << 16 + 2046 << 8 + x。

对于 JUMP_FORWARD,它的参数是 offset。所以当目标地址离当前位置的 offset 超过
256 时,需要额外生成 EXTENDED_ARGJUMP_ABSOLUTE 也是同样的道理,只是该语句
的参数是绝对地址。

所以对于深层嵌套内、需要跳到很远的 goto 语句,就要加不少辅助语句。而
python-goto 这个库,在替换暗桩时,并不会额外增加语句。如果所需的语句超过暗桩的
大小,会抛出 SyntaxError。

在 Python 3.6 之前,不带参数的语句只需要 1 个字节,同样 6 个字节的地方,可以
容纳 1 条必需的 JMP 语句和 4 条 POP_BLOCK。除非你是在一个五层循环里用 goto
不太会碰到这个限制。但是 Python 3.6 之后,POP_BLOCK 也要用 2 个字节了,顿时连
三层循环都 hold 不住了,这个问题就显得尖锐起来。上面还没考虑到需要加
EXTENDED_ARG 的情况。

如何绕过字节码大小的限制

那么一个显而易见的解决方案就浮出水面了:为何不试试在修改字节码的时候,动态改变字
节码的大小,让它有足够的位置容纳新增的辅助语句?这样一来,就能彻底地解决问题了。

这个就是开头说到的,打破限制的方法。

Python 本身是允许动态增大/缩小 __code__ 属性里的字节码的。但是有个问题,Python
里许多字节码依赖特定的位置或者偏移。如果我们挪动了涉及的字节码,需要同步修改这些
语句的参数。(包括我们新生成的 goto 语句里面的 JUMP_ABSOLUTEJUMP_FORWARD

这个听起来简单,似乎只要把参数 patch 成实际修改后的值就好了。然而 Python 是
通过在字节码前面插入 EXTENDED_ARG 来实现定长字节码里支持不定长参数的功能。修改
参数的值可能需要动态调整 EXTENDED_ARG 语句的数量;而调整 EXTENDED_ARG 又反过
来影响到各个语句的参数…… 所以这里需要一个 while True 循环,直到某一次调整不会
触发 EXTENDED_ARG 语句的变化为止。

好在如果我们只单方面增大字节码,就只需要增加 EXTENDED_ARG 语句。而每在一个地方
增加完 EXTENDED_ARG 语句,就意味着对应的 OP 语句参数能缩小 256。后面无论怎么
调整,都不太可能需要再增加多一个 EXTENDED_ARG 语句。这么一来,调整的次数就不会
多。

虽然说起来好像就那么两三段话的事,但是开发难度会很大。因为需要 patch 的字节码类型很多,
大约十来种吧。而且逻辑上较为复杂,牵连的地方很多。实际上我没有实现前述的方案,只是设计了
下而已。如果你要实现它,请在编码时保持内心的平静,另外多写测试用例,不然很容易出问题。


spacewander
5.6k 声望1.5k 粉丝

make building blocks that people can understand and use easily, and people will work together to solve the very largest problems.