你能否向Python语法中添加新的语句(例如print
,raise
,with
)?
比如说,允许……
mystatement "Something"
或者,
new_if True:
print "example"
并不是说你必须这样做,而是如果在不修改Python解释器代码的情况下可能这样做。
你能否向Python语法中添加新的语句(例如print
,raise
,with
)?
比如说,允许……
mystatement "Something"
或者,
new_if True:
print "example"
并不是说你必须这样做,而是如果在不修改Python解释器代码的情况下可能这样做。
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时运行configure和make的目录。
必须对语法文件进行两个修改。第一个是添加until
语句的定义。我找到了while_stmt
的定义位置,并在其下方添加了until_stmt
[2]:
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.h
和Python/graminit.c
,然后重新编译几个文件。
在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.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
和用于until
语句体的ast_for_suite
。最后,返回一个名为Until
的新节点。
请注意,我们使用一些宏(如NCH
和CHILD
)访问解析树节点n
。了解它们是值得的-它们的代码在Include/node.h
中。
我选择为until
语句创建一个新类型的AST,但实际上这并不是必要的。我可以通过组合现有的AST节点来节省一些工作并实现新功能,因为:
until condition:
# do stuff
功能上等同于:
while not condition:
# do stuff
在ast_for_until_stmt
中,我可以创建一个带有While
节点的Not
节点作为子节点,而不是创建Until
节点。由于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
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
函数,添加处理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的内部机制,特别是其前端非常感兴趣。因此,我认为这个练习是理论学习编译器原理和源代码的一个很有用的伴侣。它将成为未来更深入地研究编译器的文章的基础。
我在撰写本文时使用了一些优秀的参考资料。它们按照没有特定的顺序列在下面:
until
是 isa
/isan
,例如 if something isa dict:
或 if something isan int:
。 - Inversus有一种方法可以做到这样的事情,那就是预处理源代码并进行修改,将您添加的语句转换成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函数,并使用它来加载用您的自定义语言编写的代码。例如:
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看到的只是预处理后的文件,这意味着在堆栈跟踪等文本打印中所引用的内容可能与源文本大不相同。如果你进行了重要的翻译,则可能会非常不同。上面的示例并没有更改行号等内容,因此不会有太大差异,但是你进行的更改越多,弄清楚问题就越困难。
print 1
作为唯一代码行的模块上使用myimport
会产生=1 ... SyntaxError: invalid syntax
。 - olamundob=myimport("b.py")
”,并且b.py只包含“print 1
”所做的操作)。是否还有其他错误(堆栈跟踪等)? - Brianimport
使用内置的 __import__
,因此如果您在导入需要修改的模块之前覆盖了它,您就不需要单独使用 myimport
。 - Tobias Kienzlercodecs.StreamReader.__init__(self, *args, **kwargs)
应该改为 super(StreamReader, self).__init__(self, *args, **kwargs)
,对吗? - Tobias Kienzler是的,在某种程度上是可能的。有一个模块利用 sys.settrace()
实现了goto
和comefrom
“关键字”:module。
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"
除非更改并重新编译源代码(这在开源中是可能的),否则不太可能更改基础语言。
即使您重新编译源代码,也不会变成Python,只是您需要非常小心地避免引入错误的修改版本。
然而,我不确定为什么您想要这样做。Python的面向对象特性使得使用当前语言实现类似的结果非常简单。
import EasyExtend
EasyExtend.new_langlet("mystmts", prompt = "my> ", source_ext = "mypy")
如果没有额外的规定,一堆文件将被创建在EasyExtend/langlets/mystmts/下。
ii) 打开mystmts/parsedef/Grammar.ext并添加以下行:
small_stmt: (expr_stmt | print_stmt | del_stmt | pass_stmt | flow_stmt |
import_stmt | global_stmt | exec_stmt | assert_stmt | my_stmt )
my_stmt: 'mystatement' expr
这已经足够定义您的新语句的语法了。small_stmt非终端符是Python语法的一部分,也是新语句挂钩的地方。解析器现在将识别新语句,即包含它的源文件将被解析。但编译器会拒绝它,因为它仍然需要转换为有效的Python代码。
iii)现在必须添加语句的语义。为此,必须编辑msytmts/langlet.py并添加一个my_stmt节点访问者。
def call_my_stmt(expression):
"defines behaviour for my_stmt"
print "my stmt called with", expression
class LangletTransformer(Transformer):
@transform
def my_stmt(self, node):
_expr = find_node(node, symbol.expr)
return any_stmt(CST_CallFunc("call_my_stmt", [_expr]))
__publish__ = ["call_my_stmt"]
iv) 进入 langlets/mystmts 目录并输入命令
python run_mystmts.py
现在可以开始一个会话并使用新定义的语句:
__________________________________________________________________________________
mystmts
On Python 2.5.1 (r251:54863, Apr 18 2007, 08:51:08) [MSC v.1310 32 bit (Intel)]
__________________________________________________________________________________
my> mystatement 40+2
my stmt called with 42
为了得出一个琐碎的陈述,需要走很多步骤,对吧?目前还没有API可以让人们定义简单的事情而不必关心语法。但EE非常可靠,除了一些错误。所以只是时间问题,就会出现一个API,让程序员可以使用方便的OO编程来定义方便的中缀运算符或小语句。对于像通过构建langlet将整个语言嵌入Python这样的更复杂的事情,没有绕过完整语法方法的办法。
这是一种非常简单但很差的方法,仅在解释模式下才能添加新语句。我正在使用它来为编辑基因注释的小型1字母命令提供sys.displayhook,但只是为了回答这个问题,我也添加了sys.excepthook以处理语法错误。后者非常丑陋,需要从readline缓冲区获取原始代码。好处是,用这种方式轻松添加新语句。
jcomeau@intrepid:~/$ cat demo.py; ./demo.py
#!/usr/bin/python -i
'load everything needed under "package", such as package.common.normalize()'
import os, sys, readline, traceback
if __name__ == '__main__':
class t:
@staticmethod
def localfunction(*args):
print 'this is a test'
if args:
print 'ignoring %s' % repr(args)
def displayhook(whatever):
if hasattr(whatever, 'localfunction'):
return whatever.localfunction()
else:
print whatever
def excepthook(exctype, value, tb):
if exctype is SyntaxError:
index = readline.get_current_history_length()
item = readline.get_history_item(index)
command = item.split()
print 'command:', command
if len(command[0]) == 1:
try:
eval(command[0]).localfunction(*command[1:])
except:
traceback.print_exception(exctype, value, tb)
else:
traceback.print_exception(exctype, value, tb)
sys.displayhook = displayhook
sys.excepthook = excepthook
>>> t
this is a test
>>> t t
command: ['t', 't']
this is a test
ignoring ('t',)
>>> ^D
使用 EasyExtend 可以实现这一点:
EasyExtend(EE)是一个预处理器生成器和元编程框架,纯Python编写并与CPython集成。 EasyExtend的主要目的是创建扩展语言,即向Python添加自定义语法和语义。
装饰器可以实现一些功能。比如,假设Python没有with
语句,我们可以通过以下方式实现类似的行为:
# ====== Implementation of "mywith" decorator ======
def mywith(stream):
def decorator(function):
try: function(stream)
finally: stream.close()
return decorator
# ====== Using the decorator ======
@mywith(open("test.py","r"))
def _(infile):
for l in infile.readlines():
print(">>", l.rstrip())
_
设置为None
的行为是出人意料的。为了澄清:这个装饰器等效于编写def _(infile): ...
_ = mywith(open(...))(_) # mywith returns None.
装饰器通常被期望用于修改函数,而非执行函数。
我之前在一个脚本中使用了这样的方法,我需要临时为几个函数设置工作目录。