您可能会发现这很有用 - Python internals: adding a new statement to Python,在此引用:
本文试图更好地理解 Python 前端的工作原理。仅仅阅读文档和源代码可能会有点无聊,所以我在这里采取了动手的方法:我将在 Python 中添加一个until 语句。
本文的所有编码都是针对 Python Mercurial repository mirror 中的尖端 Py3k 分支完成的。
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 的内部工作原理。
修改语法
Python 使用名为 pgen 的自定义解析器生成器。这是一个 LL(1) 解析器,可将 Python 源代码转换为解析树。解析器生成器的输入是文件Grammar/Grammar[1]。这是一个指定 Python 语法的简单文本文件。
[1]:从这里开始,对 Python 源代码中文件的引用相对于源代码树的根目录给出,该目录是您运行 configure 和 make 以构建 Python 的目录。
必须对语法文件进行两次修改。首先是为until 语句添加定义。我找到了 while 语句的定义位置 (while_stmt),并在 [2] 下方添加了 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
[2]:这展示了我在修改我不熟悉的源代码时使用的一种常用技术:通过相似性工作。这个原则不能解决你所有的问题,但它绝对可以简化这个过程。由于必须为while 完成的所有操作也必须为until 完成,因此它是一个非常好的指导方针。
请注意,我决定从我对until 的定义中排除else 子句,只是为了让它有点不同(坦率地说,我不喜欢循环中的else 子句并且不认为它非常适合 Python 之禅)。
第二个更改是修改compound_stmt 的规则以包含until_stmt,如您在上面的sn-p 中所见。又是while_stmt 之后。
修改Grammar/Grammar后运行make时,注意运行pgen程序重新生成Include/graminit.h和Python/graminit.c,然后重新编译了几个文件。
修改AST生成代码
在 Python 解析器创建解析树后,此树将转换为 AST,因为在编译过程的后续阶段 AST 为 much simpler to work with。
因此,我们将访问定义 Python AST 结构的 Parser/Python.asdl,并为我们的新 until 语句添加一个 AST 节点,同样位于 while 的正下方:
| While(expr test, stmt* body, stmt* orelse)
| Until(expr test, stmt* body)
如果您现在运行make,请注意在编译一堆文件之前,运行Parser/asdl_c.py 以从AST 定义文件生成C 代码。这(如Grammar/Grammar)是另一个使用迷你语言(换句话说,DSL)来简化编程的 Python 源代码示例。另请注意,由于Parser/asdl_c.py 是 Python 脚本,因此这是一种 bootstrapping - 要从头开始构建 Python,Python 必须已经可用。
虽然Parser/asdl_c.py 生成了管理我们新定义的 AST 节点的代码(在文件 Include/Python-ast.h 和 Python/Python-ast.c 中),但我们仍然需要编写将相关解析树节点手动转换为它的代码。这是在文件Python/ast.c 中完成的。在那里,一个名为 ast_for_stmt 的函数将语句的解析树节点转换为 AST 节点。再次,在我们的老朋友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 创建函数,例如 ast_for_expr 用于条件表达式,ast_for_suite 用于until 语句的主体。最后返回一个名为Until的新节点。
请注意,我们使用NCH 和CHILD 等宏来访问解析树节点n。这些值得理解——他们的代码在Include/node.h。
题外话:AST 作文
我选择为until 语句创建一种新类型的AST,但实际上这不是必需的。我本可以使用现有 AST 节点的组合来节省一些工作并实现新功能,因为:
until condition:
# do stuff
在功能上等同于:
while not condition:
# do stuff
我可以创建一个带有While 节点的Not 节点,而不是在ast_for_until_stmt 中创建Until 节点。由于 AST 编译器已经知道如何处理这些节点,因此可以跳过该过程的后续步骤。
将 AST 编译成字节码
下一步是将 AST 编译成 Python 字节码。编译有一个中间结果,它是一个 CFG(控制流图),但由于相同的代码处理它,我现在将忽略这个细节并将其留给另一篇文章。
我们接下来要看的代码是Python/compile.c。在while 的引导下,我们找到了函数compiler_visit_stmt,它负责将语句编译成字节码。我们为Until添加一个子句:
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 很奇特。没有多少grep-ping 源树显示它被调用的位置。在这种情况下,只剩下一个选项 - C macro-fu。事实上,简短的调查将我们引向Python/compile.c 中定义的VISIT 宏:
#define VISIT(C, TYPE, V) {\
if (!compiler_visit_ ## TYPE((C), (V))) \
return 0; \
用于在compiler_body 中调用compiler_visit_stmt。然而,回到我们的业务......
正如承诺的那样,这里是compiler_until:
static int
compiler_until(struct compiler *c, stmt_ty s)
{
basicblock *loop, *end, *anchor = NULL;
int constant = expr_constant(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 字节码的深刻理解而编写的。与本文的其余部分一样,它是模仿 kin compiler_while 函数完成的。但是,通过仔细阅读它,记住 Python VM 是基于堆栈的,并查看dis 模块的文档,其中有a list of Python bytecodes 的描述,就可以理解发生了什么。
就是这样,我们已经完成了......不是吗?
完成所有更改并运行 make 后,我们可以运行新编译的 Python 并尝试我们的新 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)
结果如下:
4 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
5 15 LOAD_NAME 0 (print)
18 LOAD_FAST 0 (num)
21 CALL_FUNCTION 1
24 POP_TOP
6 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:如果条件为真,我们跳转到循环之后。这是until 的正确语义。如果没有执行跳转,则循环体继续运行,直到它跳转回操作 35 的条件。
对我的更改感觉良好,然后我尝试运行该函数(执行myfoo(3))而不是显示其字节码。结果并不令人鼓舞:
Traceback (most recent call last):
File "zy.py", line 9, in
myfoo(3)
File "zy.py", line 5, in myfoo
print(num)
SystemError: no locals when loading 'print'
哇……这可不好。那么到底出了什么问题呢?
缺少符号表的情况
Python 编译器在编译 AST 时执行的步骤之一是为其编译的代码创建符号表。在PyAST_Compile 中对PySymtable_Build 的调用调用符号表模块(Python/symtable.c),它以类似于代码生成函数的方式遍历AST。每个作用域都有一个符号表有助于编译器找出一些关键信息,例如哪些变量是全局的,哪些是作用域的局部变量。
为了解决这个问题,我们必须修改Python/symtable.c中的symtable_visit_stmt函数,在while语句的类似代码之后添加处理until语句的代码[3] :
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;
[3]:顺便说一句,如果没有此代码,Python/symtable.c 会出现编译器警告。编译器注意到Until_kind 枚举值没有在symtable_visit_stmt 的switch 语句中处理并抱怨。检查编译器警告总是很重要的!
现在我们真的完成了。在此更改之后编译源代码会使myfoo(3) 的执行按预期工作。
结论
在本文中,我演示了如何向 Python 添加新语句。尽管需要对 Python 编译器的代码进行大量修改,但更改并不难实现,因为我使用了类似的现有语句作为指导。
Python 编译器是一个复杂的软件块,我并不声称自己是这方面的专家。但是,我对 Python 的内部结构非常感兴趣,尤其是它的前端。因此,我发现这个练习对于编译器原理和源代码的理论研究非常有用。它将作为将来深入了解编译器的文章的基础。
参考文献
我使用了一些优秀的参考来构建这篇文章。它们在这里,没有特别的顺序:
-
PEP 339: Design of the CPython compiler - 可能是 Python 编译器的官方文档中最重要和最全面的部分。它非常简短,令人痛苦地表明 Python 内部缺乏良好的文档。
- “Python 编译器内部” - Thomas Lee 的文章
- “Python:设计和实现” - Guido van Rossum 的演讲
- Python (2.5) 虚拟机,导览 - Peter Tröger 的演示
original source