可以为Python的语法添加新语句吗?

141 投票
13 回答
38330 浏览
提问于 2025-04-11 09:30

你能在Python的语法中添加新的语句吗,比如 printraisewith 这些?

比如说,允许..

mystatement "Something"

或者,

new_if True:
    print "example"

这里不是在讨论你是否应该这样做,而是问这样做是否可能(不包括修改Python解释器的代码)

13 个回答

22

是的,在某种程度上这是可能的。网上有一个模块,它利用了sys.settrace()这个功能来实现gotocomefrom这两个“关键字”:

from goto import goto, label
for i in range(1, 10):
  for j in range(1, 20):
    print i, j
    if j == 3:
      goto .end # breaking out from nested loop
label .end
print "Finished"
59

一种处理这类事情的方法是先对源代码进行预处理,然后修改它,把你添加的语句转换成Python代码。虽然这种方法会带来一些问题,我不建议在一般情况下使用,但在语言实验或者特定目的的元编程中,有时还是挺有用的。

比如说,我们想引入一个“myprint”语句,它不是打印到屏幕上,而是记录到一个特定的文件中。也就是说:

myprint "This gets logged to file"

这相当于

print >>open('/tmp/logfile.txt','a'), "This gets logged to file"

关于如何进行替换,有多种选择,从正则表达式替换到生成抽象语法树(AST),再到根据你的语法与现有Python的相似程度编写自己的解析器。一个不错的中间方法是使用tokenizer模块。这样可以在解释源代码时,像Python解释器一样添加新的关键字、控制结构等,从而避免粗糙的正则表达式解决方案带来的问题。对于上面的“myprint”,你可以写出以下转换代码:

import tokenize

LOGFILE = '/tmp/log.txt'
def translate(readline):
    for type, name,_,_,_ in tokenize.generate_tokens(readline):
        if type ==tokenize.NAME and name =='myprint':
            yield tokenize.NAME, 'print'
            yield tokenize.OP, '>>'
            yield tokenize.NAME, "open"
            yield tokenize.OP, "("
            yield tokenize.STRING, repr(LOGFILE)
            yield tokenize.OP, ","
            yield tokenize.STRING, "'a'"
            yield tokenize.OP, ")"
            yield tokenize.OP, ","
        else:
            yield type,name

(这确实使得myprint变成了一个关键字,所以在其他地方作为变量使用可能会引发问题)

接下来的问题是如何使用它,使得你的代码可以在Python中使用。一种方法是自己写一个导入函数,用它来加载用你自定义语言编写的代码。也就是说:

import new
def myimport(filename):
    mod = new.module(filename)
    f=open(filename)
    data = tokenize.untokenize(translate(f.readline))
    exec data in mod.__dict__
    return mod

不过,这要求你以不同于普通Python模块的方式处理你的自定义代码。也就是说,使用"some_mod = myimport("some_mod.py")"而不是"import some_mod"。

另一个相对不错(虽然有点黑科技)的解决方案是创建一个自定义编码(参见PEP 263),正如这个示例所演示的。你可以这样实现:

import codecs, cStringIO, encodings
from encodings import utf_8

class StreamReader(utf_8.StreamReader):
    def __init__(self, *args, **kwargs):
        codecs.StreamReader.__init__(self, *args, **kwargs)
        data = tokenize.untokenize(translate(self.stream.readline))
        self.stream = cStringIO.StringIO(data)

def search_function(s):
    if s!='mylang': return None
    utf8=encodings.search_function('utf8') # Assume utf8 encoding
    return codecs.CodecInfo(
        name='mylang',
        encode = utf8.encode,
        decode = utf8.decode,
        incrementalencoder=utf8.incrementalencoder,
        incrementaldecoder=utf8.incrementaldecoder,
        streamreader=StreamReader,
        streamwriter=utf8.streamwriter)

codecs.register(search_function)

现在,在这段代码运行后(例如,你可以把它放在你的.pythonrc或site.py中),任何以注释"# coding: mylang"开头的代码都会自动通过上述预处理步骤进行转换。例如:

# coding: mylang
myprint "this gets logged to file"
for i in range(10):
    myprint "so does this : ", i, "times"
myprint ("works fine" "with arbitrary" + " syntax" 
  "and line continuations")

注意事项:

预处理器的方法有一些问题,如果你曾经使用过C预处理器,可能会比较熟悉。主要的问题是调试。Python看到的只是预处理后的文件,这意味着在堆栈跟踪中打印的文本等将会引用这个文件。如果你进行了大量的转换,这可能与源文本差别很大。上面的例子没有改变行号等,所以不会差太多,但你改变得越多,理解起来就越困难。

196

你可能会觉得这个内容有用 - Python内部机制:向Python添加新语句,这里引用了一部分:


这篇文章旨在更好地理解Python的前端是如何工作的。光看文档和源代码可能会有点无聊,所以我采取了动手实践的方法:我要在Python中添加一个until语句。

这篇文章的所有代码都是在最新的Py3k分支上完成的,代码可以在Python Mercurial代码库镜像中找到。

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源代码中的文件引用是相对于源树的根目录的,也就是你运行配置和编译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,正如你在上面的代码片段中看到的。它就在while_stmt之后。

在修改完Grammar/Grammar后运行make时,请注意会运行pgen程序来重新生成Include/graminit.hPython/graminit.c,然后会重新编译几个文件。

修改AST生成代码

在Python解析器创建了解析树后,这棵树会被转换成AST,因为在编译过程的后续阶段,AST要更容易处理

所以,我们要访问Parser/Python.asdl,它定义了Python的AST结构,并为我们新的until语句添加一个AST节点,同样是在while的下面:

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

如果你现在运行make,请注意在编译一堆文件之前,会运行Parser/asdl_c.py来根据AST定义文件生成C代码。这(和Grammar/Grammar一样)是Python源代码使用一种迷你语言(换句话说,是一种DSL)来简化编程的另一个例子。还要注意,由于Parser/asdl_c.py是一个Python脚本,这是一种bootstrapping - 要从头构建Python,Python必须已经可用。

虽然Parser/asdl_c.py生成了管理我们新定义的AST节点的代码(到Include/Python-ast.hPython/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的新节点。

注意,我们使用一些宏如NCHCHILD来访问解析树节点n。理解这些是很重要的 - 它们的代码在Include/node.h中。

插曲:AST组合

我选择为until语句创建一种新的AST类型,但实际上这并不是必要的。我本可以节省一些工作,使用现有AST节点的组合来实现新功能,因为:

until condition:
   # do stuff

在功能上等同于:

while not condition:
  # do stuff

我本可以在ast_for_until_stmt中创建一个Not节点,并将一个While节点作为子节点。由于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搜索源树也找不到它被调用的地方。当出现这种情况时,只有一个选项 - C宏的魔法。确实,经过短暂的调查,我们找到了在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字节码的深刻理解而写的。和文章的其他部分一样,它是模仿compiler_while函数写的。不过,通过仔细阅读它,考虑到Python虚拟机是基于栈的,并查看dis模块的文档,其中有Python字节码的列表及其描述,还是能理解发生了什么。

就这样,我们完成了... 不是吗?

在做完所有修改并运行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。为每个作用域创建符号表有助于编译器确定一些关键信息,比如哪些变量是全局的,哪些是局部的。

要解决这个问题,我们需要修改symtable_visit_stmt函数,在Python/symtable.c中添加处理until语句的代码,放在处理while语句的类似代码之后[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:CPython编译器设计 - 可能是关于Python编译器最重要和全面的官方文档。虽然很简短,但痛苦地显示了Python内部机制良好文档的稀缺。
  • “Python编译器内部机制” - Thomas Lee的一篇文章
  • “Python:设计与实现” - Guido van Rossum的演讲
  • Python(2.5)虚拟机,导览 - Peter Tröger的演讲

原始来源

撰写回答