IT博客汇
  • 首页
  • 精华
  • 技术
  • 设计
  • 资讯
  • 扯淡
  • 权利声明
  • 登录 注册

    [原]Python编译器实现内幕:添加一个新语句

    caimouse发表于 2015-08-06 08:28:06
    love 1

    Python编译器实现内幕:添加一个新语句

    本文尝试理解Python前端的编译实现内幕,如果仅仅是读取文档,或者查看Python的实现代码,会让人感觉迷迷糊糊的,所以我的想法是动手来做一些事情:添加一个新语句until到Python编译器实现里。

     

    所有跟本文相关的代码,都是使用Python3.4.3版本的代码,可以从CSDN的代码托管里找到,连接如下:

    https://code.csdn.net/caimouse/milang/tree/master

    打开上面连接下载代码,就是简单直接打包下载,然后解压到一个目录即可。

     

    until语句

    在一些编译语言里,比如像Ruby,它有实现一个until语句,它的功能与while语句有点像(until num == 0 等同于while num != 0)。在Ruby语言里,语法使用的例子可以写成下面这样:

    num = 3

    until num == 0 do

      puts num

      num -= 1

    end

     

    在上面例子里将会输出如下:

     

    3

    2

    1

     

    所以我想添加一个与上面相同语法的语句到Python里,它的例子代码如下所示:

    num = 3

    until num == 0:

      print(num)

      num -= 1

     

    跟本次修改无关的一些讨论

    本文不是建议往官方Python实现里添加一个until语句,让Python发行版本添加一个新语法,而是作为一个理解Python内部实现的例子,动手的基本材料。尽管until语句使用起来更简洁一些,并且本文添加这个实现进去也不困难,但我还是完全遵守Python的极简主义的,语法数量上的最简单化。实现这个语法的目的,只是为了理解上更容易一些。

     

    修改Python语法

    Python使用一个叫做pgen分析生成器来处理语法定义,它是一个LL(1)的语法分析器,负责把Python源码转换为分析树。pgen分析器主要读取一个文件Grammar/Grammar,而它只是一个简单的文本文件,定义整个Python语法规则。

     

    为了增加until语句,需要在语法文件里修改两个地方。第一个修改的地方,就是添加until语句。我发现添加在while语句后面位置最为合适,而while语句定义为while_stmt,我也照猫画虎一样,在后面添加了until_stmt语句,如下所示:

    compound_stmt: if_stmt | while_stmt | until_stmt | for_stmt | try_stmt | with_stmt | funcdef | classdef | decorated

    if_stmt: 'if' test ':' suite ('elif' test ':' suite)* ['else' ':' suite]

    while_stmt: 'while' test ':' suite ['else' ':' suite]

    until_stmt: 'until' test ':' suite  

     

    或许你已经注意到上面的语法里,until的语法与while的语法有点不同,在until语法里没有else语句。这样做的原因,主要就是为了与上面while语法要有点区别,同时我也不喜欢在循环之后,还要有一个else语句成分,本次这样做也不必要考虑Python宗旨了,因为这里只是作为教学。

     

    第二个改变,就是在compound_stmt语句里包括until_stmt语句,正如你上面看到那样,它同样是添加在while_stmt语句后面。

     

    在Linux系统里,当修改Grammar/Grammar文件之后,调用make程序来编译,就会注意到使用pgen程序来重新把语法文件生成两个文件:Include/graminit.h和Python/graminit.c,然后与此相关的文件就进行重新编译。

    在Windows系统里,当修改Grammar/Grammar文件之后,并没有相应的工程来生成pgen程序,也没有相应的工程来对语法进行处理。这时,就需要先建立一个pgen的工程,把pgen相关的文件添加到工程里,进行编译生成pgen程序。在目录cpython\PCbuild\Pgen建立一个工程Pgen.sln,这个工程是一个终端的应用程序,需要把下面的文件添加进去:

    acceler.c

    bitset.c

    dynamic_annotations.c

    firstsets.c

    grammar.c

    grammar1.c

    listnode.c

    metagrammar.c

    mysnprintf.c

    node.c

    obmalloc.c

    parser.c

    parsetok_pgen.c

    pgen.c

    pgenmain.c

    printgrammar.c

    tokenizer_pgen.c

    最后编译这个工程,就会生成pgen.exe可执行文件。为了从Grammar/Grammar文件重新生成相关的文件,需要在目录cpython下面编写一个批处理文件gram.bat,这个文件内容如下:

    .\PCbuild\Pgen\Debug\pgen.exe Grammar/Grammar Include/graminit.h Python/graminit.c

    如果修改一次语法文件,就需要运行一次这个批处理文件,以便新修改的语法文件起作用。

     

    修改AST的代码生成

    经过Python词法分析器处理之后,就会生成一棵分析树,然后再把分析树转换为AST树。因为AST树在后面编译阶段处理时变得更加简单。

     

    因此,需要修改定义AST树的文件Parser/Python.asdl文件,这个文件详细描述了Python的AST树的结构,同时我添加了until语句到这个文件里,如下所示:

    | While(expr test, stmt* body, stmt* orelse)

    | Until(expr test, stmt* body)

    如果你现在去运行make程序,会注意到要编译这些文件就需要运行Parser/asdl_c.py文件,此文件的作用就是把AST的定义文件生成C代码文件。这是另外一个使用定义语言来简化Python的源码生成的例子,像之前的Grammar/Grammar一样,它这里是采用定义语言DSL。由于Parser/asdl_c.py是一个Python脚本文件,要运行它就得先安装有Python运行版本。你也许会问,本来是为了生成Python编译器的,结果还需要Python编译器来运行,那么在没有Python编译器之前,怎么样生成这个文件。其实这个问题,就是编译器的自举问题。一般情况是这样,先使用别的语言来编写第一个版本,然后再从第一个版本来编译第二个版本,这样就完成自举的问题了。因此,这里要编译Python3.4版本,必须安装Python2.7的版本来运行Parser/asdl_c.py文件。

    在Linux系统下,运行make来编译,就可以生成AST定义节点代码(Include/Python-ast.h和Python/Python-ast.c)。在Windows系统下,采用批处理文件来完成这个工作,创建一个批处理文件asdl.bat,内容如下:

    c:\python27\python.exe Parser/asdl_c.py -h Include Parser/Python.asdl

    c:\python27\python.exe Parser/asdl_c.py -c Python Parser/Python.asdl

     

    当生成这些代码之后,还需要手工地添加一些代码,才可以把分析树转换为AST节点。跟前面的做法一样,在文件Python/ast.c的函数ast_for_stmt里找到while语句的地方,然后在那个switch大循环里添加until_stmt:

    case while_stmt:

        return ast_for_while_stmt(c, ch);

    case until_stmt:

        return ast_for_until_stmt(c, ch);

    接着下来就是实现ast_for_until_stmt函数的功能,它的代码如下:

    static stmt_ty

    ast_for_until_stmt(struct compiling *c, const node *n)

    {

    /* until_stmt: 'until' test ':' suite */

    REQ(n, until_stmt);

     

    if (NCH(n) == 4) {

    expr_ty expression;

    asdl_seq *suite_seq;

     

    expression = ast_for_expr(c, CHILD(n, 1));

    if (!expression)

    return NULL;

    suite_seq = ast_for_suite(c, CHILD(n, 3));

    if (!suite_seq)

    return NULL;

    return Until(expression, suite_seq, LINENO(n), n->n_col_offset, c->c_arena);

    }

     

    PyErr_Format(PyExc_SystemError,

    "wrong number of tokens for 'until' statement: %d",

    NCH(n));

    return NULL;

    }

     

    从这段代码看来,它与ast_for_while_stmt大体相同,但是until语句不支持else部分,所以就删除相应处理代码。由于AST代码处理是使用递归方式,可以使用其它相应的代码来处理,比如ast_for_expr来处理条件表达式,ast_for_suite来处理until语句的主体部分。在函数的最后把until语句生成的新节点返回。

     

    注意到这里使用分析树的节点n时,需要使用宏处理函数NCH和CHILD。要查看这些宏的定义,可以到文件Include/node.h文件头里查看。

     

    离题讨论:AST的设计

    在这里我选择了创建新的AST节点来处理until语句,其实这样做是不必要的。并且这样可以节省一些工作,实现新的语法功能完全可以使用以前存在的AST节点代码来实现,如下这样:

    until condition:

       # do stuff

    与上面代码相同功能代码,可写成这样:

    while not condition:

      # do stuff

    因此,如果只想简单起见,在函数ast_for_until_stmt里直接使用Not while的AST节点的代码,就可以了,而不必要创建until语句的新的AST节点代码。从而连后面生成字节码部分也可省略掉,不过本文主要就是为了演示和教学作用,因此采用目前这种方式来做。

     

    编译AST为字节码

    接着下来就是把AST转换为Python字节码。在编译过程中,Python会把AST转换为CFG(Control Flow Graph),由于这里为了显示出大体处理流程,故意把这些细节忽略掉,这样就不会有太多其它信息干扰着添加语法的流程。

     

    下一步需要添加代码的文件是Python/compile.c,在这个文件里找到while语句处理地方,主要在函数compiler_visit_stmt来处理,这个函数作用就是编译语句为字节码。我在下面的语句下面添加了一个case语句:

    case While_kind:

        return compiler_while(c, s);

    case Until_kind:

        return compiler_until(c, s);

    如果你看到Until_kind有点疑问,它到底是什么样得来的。其实它是一个常量(实际上是_stmt_kind枚举值),这个值是通过编译AST定义文件时自动生成的,保存在文件Include/Python-ast.h里。从上面也会发现函数compiler_until很陌生,没错,这个函数还没有告诉你是怎么样编写它,后面接着就来介绍这个函数。

     

    如果你跟我一样有好奇心,也会发现函数compiler_visit_stmt在所有Python源文件里查找不到,感觉到它是一个很奇怪的函数。当遇到这种情况下,使用一个编译选项-C,就可以生成宏替换的临时文件出来,然后在这个临时文件里就可以找到相应的函数名称。实际上,经过一段时间查找之后,就会发现它是使用宏定义的机制来动态生成函数名称的,这个宏叫做VISIT名称,定义在源文件Python/compile.c里:

    #define VISIT(C, TYPE, V) {\

        if (!compiler_visit_ ## TYPE((C), (V))) \

            return 0; \

    这个宏就是用来在函数compiler_body里调用compiler_visit_stmt函数。明白这个宏之后,再回到添加AST转换为字节码的方面上,根据上面流程,compiler_until函数定义如下:

    static int

    compiler_until(struct compiler *c, stmt_ty s)

    {

    basicblock *loop, *end, *anchor = NULL;

    int constant = expr_constant(c, s->v.Until.test);

     

    if (constant == 1) {

    return 1;

    }

    loop = compiler_new_block(c);

    end = compiler_new_block(c);

    if (constant == -1) {

    anchor = compiler_new_block(c);

    if (anchor == NULL)

    return 0;

    }

    if (loop == NULL || end == NULL)

    return 0;

     

    ADDOP_JREL(c, SETUP_LOOP, end);

    compiler_use_next_block(c, loop);

    if (!compiler_push_fblock(c, LOOP, loop))

    return 0;

    if (constant == -1) {

    VISIT(c, expr, s->v.Until.test);

    ADDOP_JABS(c, POP_JUMP_IF_TRUE, anchor);

    }

    VISIT_SEQ(c, stmt, s->v.Until.body);

    ADDOP_JABS(c, JUMP_ABSOLUTE, loop);

     

    if (constant == -1) {

    compiler_use_next_block(c, anchor);

    ADDOP(c, POP_BLOCK);

    }

    compiler_pop_fblock(c, LOOP, loop);

    compiler_use_next_block(c, end);

     

    return 1;

    }

     

    在这里得说明一下:这些代码编写不是深度理解Python字节码的生成过程之下写出来的,可能存某方面优化的问题。就像本文其它地方一样,都是模仿while语句的情况下写出来,这里主要模仿了函数compiler_while。因此,理解这些代码需要比较小心一些,由于Python的虚拟机VM都基于栈的方式,查看dis模块的相关文档,对应着反汇编的指令码来看,可能会更加明了。

     

    添加新语句到符号表

    由于添加until语句,但没有添加到符号表,这时需要做这个工作了。当编译AST时,就会生成符号表。在符号表管理的源文件Python/symtable.c里,可以找到函数PySymtable_Build,而这个函数被PyAST_Compile调用来创建符号表。这个符号表记录了那些变量是全局的,那些变量是局部的作用域。

     

    为了解决符号表的问题,需要修改源文件Python/symtable.c里的函数symtable_visit_stmt,添加相应的until语句处理代码,也跟while语句类似:

    case While_kind:

        VISIT(st, expr, s->v.While.test);

        VISIT_SEQ(st, stmt, s->v.While.body);

        if (s->v.While.orelse)

            VISIT_SEQ(st, stmt, s->v.While.orelse);

        break;

    case Until_kind:

        VISIT(st, expr, s->v.Until.test);

        VISIT_SEQ(st, stmt, s->v.Until.body);

        break;

    到目前为止,所有相关的代码全部添加完成了。这样时再编译成功之后,就可以使用until语句了。

     

    验证新添加的语法

    当我们编译整个Python代码之后,就会生成Python可执行文件。在Linux之下采用make的方式来编译,在 Windows之下采用VC的工程进行编译。然后打开Python.exe程序,运行之后在交互界面输入until语句测试:

    >>> until num == 0:

    ...   print(num)

    ...   num -= 1

    ...

    3

    2

    1

    哗,居然可以运行了。下面让我们再来查看一下这段代码生成什么样的字节码,这需要使用dis模块的功能:

    >>> import dis

    >>> def myfoo(num):

    ...     until num == 0:

    ...             print(num)

    ...             num -= 1

    ...

    字节码的指令如下:

    >>> dis.dis(myfoo)

      2           0 SETUP_LOOP              36 (to 39)

            >>    3 LOAD_FAST                0 (num)

                  6 LOAD_CONST               1 (0)

                  9 COMPARE_OP               2 (==)

                 12 POP_JUMP_IF_TRUE        38

     

      3          15 LOAD_GLOBAL              0 (print)

                 18 LOAD_FAST                0 (num)

                 21 CALL_FUNCTION            1 (1 positional, 0 keyword pair)

                 24 POP_TOP

     

      4          25 LOAD_FAST                0 (num)

                 28 LOAD_CONST               2 (1)

                 31 INPLACE_SUBTRACT

                 32 STORE_FAST               0 (num)

                 35 JUMP_ABSOLUTE            3

            >>   38 POP_BLOCK

            >>   39 LOAD_CONST               0 (None)

                 42 RETURN_VALUE

    >>>

    在这段字节码指令里,最为关键的操作码地址是12的代码行:当条件为true时,就跳到这个循环后面,这个语意是符合until语句的。如果没有符合条件,就继续执行循环体,从操作码地址35跳回来继续判断。

     

    总结

    在本文里主要演示了怎么样添加一个新语句到Python里。在Python编译器添加新语法尽管有比较多的地方要修改,但是增加功能是不困难的,因为这里都采用相似或存在的语句作为参考。

     

    Python编译器是一类复杂的软件,并且我不是这方面的专家。但是我对Python的内部实现很感兴趣,特别它的前端实现方式。因此,通过这个练习之后,对比只是理论地学习编译器原理和代码要好很多。通过本文的学习可以打下一个稳固的基础,为下一步学习编译器内部实现提供非好的参考。

     

    参考文档:

    http://eli.thegreenplace.net/2010/06/30/python-internals-adding-a-new-statement-to-python



    蔡军生  QQ:9073204 深圳



沪ICP备19023445号-2号
友情链接