likes
comments
collection
share

CPython开发实战:添加loop语法

作者站长头像
站长
· 阅读数 4

熟悉Rust和Golang语法的同学肯定对loop用法不陌生,说白了它是While-True的语法糖,即任何写在loop作用域内的代码都会被无限循环执行,直到遇见break。

比如在Golang中可以通过for和大括号的组合实现loop效果——

import "fmt"

func main() {
	sum := 0
	for {
		sum += 1
		if sum == 10 {
			break
		}
	}
	fmt.Println(sum)
}

而在Rust中可以直接使用loop关键字——

fn main() {
    let mut count = 0u32;

    loop {
        count += 1;
        if count == 10 {
            break;
        }
    }
    
    println!("{}", count);
}

这种语法固然清晰可读,但在其他语言中非常少见。因为大部分编译器开发团队都想尽可能的少设置关键字,减少对程序编写的干扰。不过,本篇文章打算魔改cpython项目,在Python中加入loop关键字。

CPython开发实战:添加loop语法

本系列实战是基于Windows平台开发的,因此在实战之前需要准备以下几个工具——

  1. GIT:用来下载cpython依赖,比如sqlite、bzip、zlib等

  2. MSVC:用来编译cpython项目,可以直接下载Visual Studio 2022,也可以下载Build Tools

  3. 低版本的Python:用来生成部分编译文件

  4. 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行添加如下代码

          | Loop(stmt* body)

asdl文件描述了python的抽象语法树(AST)的结构,它是一种通过树状结构刻画一门编程语言的结构。不仅仅是Python,其它编程语言都会采用AST来刻画自己的语法。

在第43行添加的语句表示在AST树中增加一个Loop节点,该节点只包含0个或多个节点stmt。stmt节点表示语句,在上面已经定义过了。同时,把Loop节点挂在stmt节点下表示Loop本身也是一个stmt。

3.在Grammar/python.gram文件中第135行添加如下代码

    | &'loop' loop_stmt

并在第391行添加如下代码

# Loop statement
# --------------

loop_stmt[stmt_ty]:
    | 'loop' &&':' b=block {
        _PyAST_Loop(b, EXTRA) }

gram文件描述了python的语法,编译器会根据该文件生成对应的语法分析器(Parser)。程序员在编写Python代码时,编译器会以此来检查写下的Python代码是否有语法错误。

在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代码——

n = 0
loop:
    n += 1
    print(n)   

然后运行python.exe -m ast test.py,通过ast模块解析该代码的AST。如果生成的结果如下说明是成功的:

CPython开发实战:添加loop语法

这就是这段代码的AST表示。根节点是Module代表以模块的形式运行的。其次,模块的body部分有两个stmt,分别是Assign和Loop。Assign是赋值语句,对应n = 0,这个暂且不管。Loop就是我们的Loop语句,和asdl文件中设计的一样,它只有一个body,包含多个stmt,比如AugAssign和Expr,分别对应n += 1print(n)

6. 在Python/ast.c文件中第802行添加如下代码

    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行添加如下代码

    case Loop_kind:
        VISIT_SEQ(st, stmt, s->v.Loop.body);
        break;

现在进入解析AST树环节。Python解释器会多次遍历AST树,第一次遍历会生成符号表。当遍历到Loop节点时,解释器会递归遍历里面的stmt语句。

VISIT_SEQ是生成符号表阶段中非常重要的宏之一,其他的还有VISITVISIT_QUIT等。拿VISIT宏举例,它的定义如下:

#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行修改成如下代码

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行添加如下代码

    case Loop_kind:
        return compiler_loop(c, s);

再在第3232行添加如下代码

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用来收集当前块的编译状态,如下——

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结构体,它是最后生成的字节码。结构如下——

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”都是临时命名的)。

CPython开发实战:添加loop语法

  • 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减一。

这两个函数和breakcontinue语句有关,这里不写也没事。

到现在为止可以理解本步骤最初写下代码的意义了。首先定义三个标签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可以看到效果如下——

CPython开发实战:添加loop语法

但是,如果在Loop中添加一个break则会crash——

i = 0
loop:
    i += 1
    print(i)
    if i == 10:
        break

因为我们还没修改break语句。

10. 在Python/compile.c文件中第1700行修改成如下

if (loop != NULL && (top->fb_type == WHILE_LOOP || top->fb_type == FOR_LOOP || top->fb_type == LOOP_LOOP)) {
        *loop = top;
        return SUCCESS;
    }

并在1591行修改成如下

        case WHILE_LOOP:
        case LOOP_LOOP:
        case EXCEPTION_HANDLER:
        case EXCEPTION_GROUP_HANDLER:
        case ASYNC_COMPREHENSION_GENERATOR:
            return SUCCESS;

在了解这两处代码之前需要理解break语句是如何处理的。其逻辑如下——

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_nfblocksu_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步骤修改后的程序的字节码了——

CPython开发实战:添加loop语法

可以看到,这里多了一个标签L2,也就是第9步骤的end标签。这里由于程序还有可能执行loop后面的语句,所以没有办法优化了。

运行程序,可以看到正确的输出——

CPython开发实战:添加loop语法

本次实战就到此为止了。文章详细介绍了从词法分析到IR生成的种种细节,几乎涉及了cpython前端开发主要流程。结合前面几篇文章,相信读者对cpython编译有了全面的认识了。

转载自:https://juejin.cn/post/7311228906835623948
评论
请登录