熟悉Rust和Golang语法的同学肯定对loop用法不陌生,说白了它是While-True的语法糖,即任何写在loop作用域内的代码都会被无限循环执行,直到遇见break。
比如在Golang中可以通过for和大括号的组合实现loop效果------
python
import "fmt"
func main() {
sum := 0
for {
sum += 1
if sum == 10 {
break
}
}
fmt.Println(sum)
}
而在Rust中可以直接使用loop关键字------
ini
fn main() {
let mut count = 0u32;
loop {
count += 1;
if count == 10 {
break;
}
}
println!("{}", count);
}
这种语法固然清晰可读,但在其他语言中非常少见。因为大部分编译器开发团队都想尽可能的少设置关键字,减少对程序编写的干扰。不过,本篇文章打算魔改cpython项目,在Python中加入loop关键字。
今年早些时候本人也写过《CPython开发实战》系列文章,其涵盖了大部分cpython前端的知识,感兴趣的可以反复阅读------CPython开发实战:魔改lambda函数(一)。它事无巨细的讲述了cpython前端开发步骤,并且完成了对lambda内置函数的魔改。
本系列实战是基于Windows平台开发的,因此在实战之前需要准备以下几个工具------
-
GIT:用来下载cpython依赖,比如sqlite、bzip、zlib等
-
MSVC:用来编译cpython项目,可以直接下载Visual Studio 2022,也可以下载Build Tools
-
低版本的Python:用来生成部分编译文件
-
Visual Studio 2022(可选):用来debug,打断点
另外本次实战采用了GitHub上当前最新的cpython分支,Python 3.13.0 alpha2版本。
好了,万事俱备,开搞!
1.在命令行中运行PCBuild/build.bat
编译cpython
第一次克隆下来的cpython需要执行这条命令,确保在本地环境可以编译通过。这个批处理脚本是Windows环境下的编译脚本,需要通过git下载依赖(openssl、bz等等),因此确保网络畅通。
2.在Parser/Python.asdl
文件中第43行添加如下代码
scss
| Loop(stmt* body)
asdl文件描述了python的抽象语法树(AST)的结构,它是一种通过树状结构刻画一门编程语言的结构。不仅仅是Python,其它编程语言都会采用AST来刻画自己的语法。
在第43行添加的语句表示在AST树中增加一个Loop节点,该节点只包含0个或多个节点stmt。stmt节点表示语句,在上面已经定义过了。同时,把Loop节点挂在stmt节点下表示Loop本身也是一个stmt。
3.在Grammar/python.gram
文件中第135行添加如下代码
arduino
| &'loop' loop_stmt
并在第391行添加如下代码
css
# Loop statement
# --------------
loop_stmt[stmt_ty]:
| 'loop' &&':' b=block {
_PyAST_Loop(b, EXTRA) }
gram文件描述了python的语法,编译器会根据该文件生成对应的语法分析器(Parser)。程序员在编写Python代码时,编译器会以此来检查写下的Python代码是否有语法错误。
Python的gram文件遵循pegen规则,具体的规则描述参考本系列这文章------CPython开发实战:魔改lambda函数(二)。
在135行添加的内容意味着将loop_stmt挂在compound_stmt下,表示loop_stmt是一个复合语句。同样compound_stmt挂在statement下(99行),表示是个语句。以此类推,它们最终都会挂在根节点file下,表示以文件的形式开发Python程序的。
loop_stmt的规则内容在第391行,方括号内说明是stmt_ty类型。底下挂了一个规则:
如果是loop关键字开头,然后紧跟冒号,并且后面跟了block,那么就调用_PyAST_Loop
函数。这个函数会根据asdl文件自动生成,并且将block和EXTRA宏作为参数。而block规则在gram文件其他地方定义了。
4.在命令行中运行PCBuild/build.bat --regen
生成词法分析程序
重新生成编译所需的依赖函数,比如上一步所需的_PyAST_Loop
函数就被生成后写入Parser.c
内的。到此为止,AST树的编译已经完成了。
5. 在命令行中运行PCBuild/build.bat
生成Python程序
如果现在在命令行中直接写loop语句不会有结果,有时会抛一个CFG的异常,因为我们没有解析生成的AST。AST生成以后需要被解析成CFG,然后生成对应的字节码。这样才算一个完整的cpython前端编译流程。
但是我们仍然可以运用现在的Python程序验证我们之前的步骤。首先在test.py
中写下如下Python代码------
ini
n = 0
loop:
n += 1
print(n)
然后运行python.exe -m ast test.py
,通过ast模块解析该代码的AST。如果生成的结果如下说明是成功的:
这就是这段代码的AST表示。根节点是Module代表以模块的形式运行的。其次,模块的body部分有两个stmt,分别是Assign和Loop。Assign是赋值语句,对应n = 0
,这个暂且不管。Loop就是我们的Loop语句,和asdl文件中设计的一样,它只有一个body,包含多个stmt,比如AugAssign和Expr,分别对应n += 1
和print(n)
。
6. 在Python/ast.c
文件中第802行添加如下代码
ini
case Loop_kind:
ret = validate_body(state, stmt->v.Loop.body, "Loop");
break;
在之前的系列文章中是没有这一步的,因为这是一个可选的步骤。在新版的pegen诞生之前,这个文件被用来解析AST树,然而现在只是用于debug。如果你是用Visual Studio 2022编译Python的debug版本的话必须修改这里,否则会报Syntax Error异常。
7. 在Python/symtable.c
文件中第1798行添加如下代码
arduino
case Loop_kind:
VISIT_SEQ(st, stmt, s->v.Loop.body);
break;
现在进入解析AST树环节。Python解释器会多次遍历AST树,第一次遍历会生成符号表。当遍历到Loop节点时,解释器会递归遍历里面的stmt语句。
VISIT_SEQ
是生成符号表阶段中非常重要的宏之一,其他的还有VISIT
和VISIT_QUIT
等。拿VISIT
宏举例,它的定义如下:
C
#define VISIT(ST, TYPE, V) \
if (!symtable_visit_ ## TYPE((ST), (V))) \
VISIT_QUIT((ST), 0);
在展开的过程中,它会拼接中间的参数,比如VISIT(st, stmt, s->v.Loop.body)
会展开成调用symtable_visit_stmt(st, s->v.Loop.body)
函数,而这个函数就是解析stmt节点的函数,也是本步骤添加的代码所在的函数!
VISIT_SEQ
则是VISIT
几条语句。
cpython非常聪明的利用宏实现了递归解析AST树,后续步骤也用到了这个思想。
8. 在Python/compile.c
文件中第113-115行修改成如下代码
vbnet
enum fblocktype { WHILE_LOOP, FOR_LOOP, LOOP_LOOP, TRY_EXCEPT, FINALLY_TRY, FINALLY_END,
WITH, ASYNC_WITH, HANDLER_CLEANUP, POP_VALUE, EXCEPTION_HANDLER,
EXCEPTION_GROUP_HANDLER, ASYNC_COMPREHENSION_GENERATOR };
并在第4050行添加如下代码
arduino
case Loop_kind:
return compiler_loop(c, s);
再在第3232行添加如下代码
scss
static int
compiler_loop(struct compiler *c, stmt_ty s)
{
NEW_JUMP_TARGET_LABEL(c, loop);
NEW_JUMP_TARGET_LABEL(c, body);
NEW_JUMP_TARGET_LABEL(c, end);
USE_LABEL(c, loop);
RETURN_IF_ERROR(compiler_push_fblock(c, LOC(s), LOOP_LOOP, loop, end, NULL));
USE_LABEL(c, body);
VISIT_SEQ(c, stmt, s->v.Loop.body);
ADDOP_JUMP(c, NO_LOCATION, JUMP, body);
compiler_pop_fblock(c, LOOP_LOOP, loop);
USE_LABEL(c, end);
return SUCCESS;
}
符号表生成后,解释器又会遍历一遍AST树进行编译操作。这一步会针对Loop节点生成字节码。
在4050行添加的代码表示遍历到Loop节点会调用compiler_loop
函数做处理,具体逻辑在3232行展示。在讲逻辑前,需要了解以下几个宏。
NEW_JUMP_TARGET_LABEL
: 要想理解这个宏必须对cpython编译有了解。cpython编译是将AST树转换成字节码,并做优化供后端(虚拟机)执行。为了将结构化的Python语句转化为指令形式的字节码,它需要将字节码分成不同的块。Python语句中的跳转本质是块之间的跳转。
为了实现块之间的跳转,cpython用了一个结构体compiler来刻画编译器。这个结构体包含一个结构体叫compiler_unit用来收集当前块的编译状态,如下------
arduino
struct compiler_unit {
PySTEntryObject *u_ste;
int u_scope_type;
PyObject *u_private; /* 处理私有成员 */
instr_sequence u_instr_sequence; /* 最终生成的字节码 */
int u_nfblocks; /* frame block的个数,后面有用 */
...
struct fblockinfo u_fblock[CO_MAXBLOCKS]; /*u_nfblocks对应的frame block列表*/
...
};
其中最重要的是instr_sequence
结构体,它是最后生成的字节码。结构如下------
arduino
typedef _PyCompile_InstructionSequence instr_sequence;
typedef struct {
_PyCompile_Instruction *s_instrs;
int s_allocated;
int s_used;
int *s_labelmap; /* 标签和指令位移的映射 */
int s_labelmap_size;
int s_next_free_label; /* 下一个空标签 */
} _PyCompile_InstructionSequence;
这个宏的意思是用来添加一个标签,比如下图中的L1。实际上是让上面的s_next_free_label
字段加一,表示新增一个已用标签(标签没有名称,"L1"、"loop","body"都是临时命名的)。
USE_LABEL
: 将标签指向即将执行的下一条指令。实际上是修改上面的s_labelmap
字段,让其指向s_used
。VISIT_SEQ
:和生成符号表阶段的VISIT_SEQ
宏一样,递归处理内部的语句ADDOP_JUMP
: 添加一个跳转指令,后面两个参数最重要,分别是如何跳转(比如JUMP
表示强制跳转)以及跳转的目标。
此外还需要理解两个函数------
compiler_push_fblock
: 新建(压入)一个Frame Block,并明确起止范围(标签),让u_nfblocks
加一。compiler_pop_fblock
: 弹出一个Frame Block。本质上让u_nfblocks
减一。
这两个函数和break
和continue
语句有关,这里不写也没事。
到现在为止可以理解本步骤最初写下代码的意义了。首先定义三个标签loop、body和end。然后将loop标签和当前指令绑定(也就是loop上一条语句执行完的指令)。然后新建一个Frame Block,范围是loop标签到end标签之前。然后将body标签和当前指令绑定(实际上可以不需要,因为没有新的指令插入,但为了和While语句统一)。再是处理Loop内部的语句,这时候有新的指令插入。再是一个强制跳转到body标签,也就是内部语句之前的地方。然后弹出Frame Block。最后将end标签和当前指令绑定,代表Frame Block的范围到此为止。
9. 在命令行中运行PCBuild/build.bat
生成Python程序
到此为止,Python可以理解并处理Loop语句了。运行python -m dis test.py
生成对应的字节码,效果即为上图所示。其实可以发现loop和body标签都被优化成L1标签了,由于不涉及break
,所以end标签也被优化了。
运行test.py
可以看到效果如下------
但是,如果在Loop中添加一个break
则会crash------
ini
i = 0
loop:
i += 1
print(i)
if i == 10:
break
因为我们还没修改break语句。
10. 在Python/compile.c
文件中第1700行修改成如下
ini
if (loop != NULL && (top->fb_type == WHILE_LOOP || top->fb_type == FOR_LOOP || top->fb_type == LOOP_LOOP)) {
*loop = top;
return SUCCESS;
}
并在1591行修改成如下
arduino
case WHILE_LOOP:
case LOOP_LOOP:
case EXCEPTION_HANDLER:
case EXCEPTION_GROUP_HANDLER:
case ASYNC_COMPREHENSION_GENERATOR:
return SUCCESS;
在了解这两处代码之前需要理解break语句是如何处理的。其逻辑如下------
arduino
static int
compiler_break(struct compiler *c, location loc)
{
struct fblockinfo *loop = NULL;
location origin_loc = loc;
/* Emit instruction with line number */
ADDOP(c, loc, NOP);
RETURN_IF_ERROR(compiler_unwind_fblock_stack(c, &loc, 0, &loop));
if (loop == NULL) {
return compiler_error(c, origin_loc, "'break' outside loop");
}
RETURN_IF_ERROR(compiler_unwind_fblock(c, &loc, loop, 0));
ADDOP_JUMP(c, loc, JUMP, loop->fb_exit);
return SUCCESS;
}
里面包含两个重要的函数,如下------
compiler_unwind_fblock_stack
: 根据当前的u_nfblocks
在u_fblock[CO_MAXBLOCKS]
中找到当前的Frame Block,并返回它的止位置。这个在第8个步骤中compiler_push_fblock
函数中设置。compiler_unwind_fblock
: 根据不同的循环添加指令,本篇文章不涉及。
本步骤新增的两处代码分别对应上述两个函数,由于不需要额外处理逻辑,所以保持默认的输出即可。这样compiler_break
的逻辑是先添加一个空指令NOP,目的是生成一个指令序号。然后获取循环开始的位置,也就是第9步骤的end标签。最后强制跳转到该标签。
11. 在命令行中运行PCBuild/build.bat
生成Python程序
现在再在命令行中运行python -m dis test.py
可以正确显示第9步骤修改后的程序的字节码了------
可以看到,这里多了一个标签L2,也就是第9步骤的end标签。这里由于程序还有可能执行loop后面的语句,所以没有办法优化了。
运行程序,可以看到正确的输出------
本次实战就到此为止了。文章详细介绍了从词法分析到IR生成的种种细节,几乎涉及了cpython前端开发主要流程。结合前面几篇文章,相信读者对cpython编译有了全面的认识了。