我们实现Python代码转换

哈Ha

今天,我们为您提供一篇文章的翻译,该文章涉及的话题不是最被讨论的话题:Python中的代码编译,即:使用抽象语法树(AST)和字节码。尽管Python是一种解释型语言,但从优化的角度来看,Python中的此类功能极为重要。今天我们将讨论它们。

您是否曾经想过编译器如何优化代码以使其更快地工作?想知道什么是抽象语法树(AST)以及它可以用于什么?

这篇评论文章介绍了如何将Python代码转换为树形(AST)。构建了程序的AST之后,您可以继续寻找机会来优化和转换代码。但是,请记住,以非平凡的方式优化Python程序非常困难

程序代码像一棵树


计算机如何确保以正确的顺序评估代码中的表达式?
为此,他首先将您的程序代码重新构建为称为AST的树结构。

使用解释型编程语言(例如Python)时,通常会接受的解释是,解释器可以直接通过您的代码并完成遇到的所有事情,而无需以任何方式将Python代码转换为机器代码。然而,实际上,这种执行方案会引起很多问题,这非常不方便。
以一个简单的问题为例,例如运算符的优先级。在视图表达式中3 + 4 * x ,首先计算零件4 * x,然后才能将3加到乘法结果中。也许您可以通过在表达式下绘制这些树来了解数学类中运算符的优先级:



Python使用数学符号的标准规则(首先是乘法,然后是加法)。为了不使任何内容与运算符的优先级混淆,在Python中,首先要像上图中那样构建一棵树。常规运算是加法(在树的根部),而该和的左侧是正数,而在右侧是乘积。产生的数据结构如下所示:

BinOp(
  left  = Num(3),
  op    = Add(),
  right = BinOp(
            left  = Num(4),
            op    = Mult(),
            right = Name('x')
          )
)

BinOp表示二进制运算(Binary Operation),并表示在诸如加法和乘法的运算中,两个操作数。当然,如果表达式的正确部分没有正确的值,您将不会得到任何加法-因此,您必须先相乘。

在编译器和编程语言的理论中,这样的树称为“ 抽象语法树”,简称AST。上例中的AST包括两个节点BinOp,两个节点Num和一个节点Name

Python有一个不错的功能-可以直接查看和显示任何特定Python程序的AST的功能。所需要做的就是导入一个标准模块ast,解析程序,然后在屏幕上显示结果(顺便说一句,解析是将程序源代码转换为AST树的过程)。

import ast
my_tree = ast.parse("3 + 4*x")
print(ast.dump(my_tree))

但是,您会注意到Python生成的AST中将有其他节点和字段,并且将在一行中显示,这乍一看比实际要复杂得多。

Module(body=[Expr(value=BinOp(left=Num(n=3), op=Add(), right=BinOp(left=Num(n=4), op=Mult(), right=Name(id='x', ctx=Load()))))])

让我们将其拆分为单独的节点(例如上次),然后重新打开已经位于顶部的AST,作为整个树的一部分:

Module(body = [
    Expr(
        value = BinOp(
            left  = Num(n=3),
            op    = Add(),
            right = BinOp(
                left  = Num(n=4),
                op    = Mult(),
                right = Name(id='x', ctx=Load())
            )
        )
    )
])

显然,Python“认为”我们为它解析的那一行是整个模块。模块的主体是其中包含的所有指令的列表。在我们的示例中,唯一的指令是一个表达式,Expr其含义恰好是我们上面讨论的含义。

注意:该节点Name还有一个附加字段ctx(缩写为“ context”),其值是Load()。因此,Python表示我们使用存储在变量中的值x,而不是(重新)定义或删除名称x。现在,您尝试尝试解析诸如del x自己的内容x = 123,然后您将看到ctx节点中的字段如何分别Name变为Del()Store()

顺便说一句:如果您安装模块astunparse,则可以使屏幕上的AST输出更加美观,甚至可以将AST转换回实时Python代码。

编译过程:其余


收集了AST程序后,原则上可以通过遍历AST并按照指示的顺序执行操作来完成整个程序。但是,这种方法至少具有两个缺点。首先,AST可以占用相对大量的内存,尤其是如果它包含冗余信息时。其次,AST遍历可能比需要的时间更长。简而言之:可以做到,但是效率低下。
编译器不会直接处理AST,而是准备字节码,然后在Python虚拟机上执行该字节码。尽管讨论此过程的细节超出了本文的范围,但基本原理是编译器将AST转换为反向波兰语表示法(RPN)。而不是放一个运算符+在左右两个操作数之间,我们将其放在两个操作数之后。在3 + 4*x上面的示例中,我们得到了序列3 4 x * +(这种表示法特别好,因为您可以从序列中立即看到:首先需要执行乘法,然后才是加法)。由于该序列中的五个元素中的每一个原则上都可以表示为单个字节,因此这种代码称为字节代码。然后,Python使用堆叠的虚拟机有效执行此代码。

换句话说,用Python编写的程序的编译过程分为两个阶段。首先,解析输入所接收的程序,结果是抽象语法树(AST)。然后,编译器通过AST并生成字节码。之后,Python解释器将执行此字节码。经过优化后,它既可以应用于AST级别,也可以应用于字节码级别。这两种选择都有其自身的优点和缺点。

最后,请记住,尽管AST在任何Python实现中都很常见,但是将AST转换为字节码的过程可能有所不同,并且在某些Python实现中,可能会在中间阶段生成JavaScript而不是字节码。

其他编程语言的范例


并非所有的编程语言都像Python中那样使用后缀符号。在这种情况下,值得注意的两个示例是PostScript和Lisp,后者通常以波兰语的反向符号编写程序,其中,程序直接以波兰语的反向书写方式编写。所以,我们在上面的例子中的表达,用Lisp将采取以下形式:(+ 3 (* 4 x))

AST中的节点转换


拥有AST程序,如何转换此树的各个部分?借助Python的便捷内置功能。

如果我们看一下AST,例如,发现字段leftright节点BinOp都是数字(节点Num),则可以预先执行相应的计算,然后将其替换为BinOp普通节点Num

当然,您需要非常谨慎地执行操作,以免更改程序的行为,进行此类转换。例如,在len([a(), b(), c(), d()]),很显然,结果是4,但是,我们不能代替所有的4,因为四大功能数量的表达abcd还是已正确调用。

同样,从简单的优化开始。只要程序源代码中出现名称pi,就将其替换为值3.14159265。 Python模块ast已经提供了执行此操作所需的数据结构:一个转换器类NodeTransformer该类遍历所有AST,并检查每个节点是否可以替换它。默认情况下,转换方法仅返回每个节点的源节点,以便我们获得与开始时相同的AST。但是我们可以轻松地为node覆盖方法Name,例如,以便检查pi它是否存在,然后返回该节点Num而不是原始名称的节点...

	import ast
 
class MyOptimizer(ast.NodeTransformer):
 
    def visit_Name(self, node: ast.Name):
        if node.id == 'pi':
            return ast.Num(n=3.14159265)
        return node
 
tree = ast.parse("y = 2 * pi")
optimizer = MyOptimizer()
tree = optimizer.visit(tree)
print(ast.dump(tree))

为了使转换器/优化器能够遍历我们的树,有必要调用其方法visit,该方法然后将返回一个新的,经过更改的树。

不幸的是,不可能编译并运行最终的AST,其原因是一个技术细节。这尚不可见,但是(几乎)AST中的所有节点都具有lineno字段col_offset。它们指示源代码中特定节点的确切位置。如果未正确安装它们,则编译器会发誓并拒绝工作。

因此,让我们将适当的字段从源节点复制Name到新节点Num。然后,您可以编译并执行生成的AST:

import ast
 
class MyOptimizer(ast.NodeTransformer):
 
    def visit_Name(self, node: ast.Name):
        if node.id == 'pi':
            result = ast.Num(n=3.14159265)
            result.lineno = node.lineno
            result.col_offset = node.col_offset
            return result
        return node
 
tree = ast.parse("print(2 * pi)")
optimizer = MyOptimizer()
tree = optimizer.visit(tree)
code = compile(tree, "<string>", "exec")
exec(code)

注意:函数编译不仅需要源代码(可以是程序本身,也可以是AST行),还需要文件名(如我们所要求的"<string>")以及以下三个之一:"exec""eval""single"

经常需要复制描述节点在源代码中位置的字段。因此,该模块ast具有专门copy_location用于此目的的功能,我们可以编写:

def visit_Name(self, node: ast.Name):
        if node.id == 'pi':
            result = ast.Num(n=3.14159265)
            return ast.copy_location(result, node)
        return node

最后,您可以扩展前面的示例,以便它实际执行优化,即在node上BinOp根据转换规则,首先我们必须转换/优化左边的节点,然后右边的节点作为BinOp的一部分。如果结果是左节点和右节点都是数字,则可以立即进行计算,并用运算的BinOp数值结果代替原始运算。

class MyVisitor(ast.NodeTransformer):
 
    def visit_BinOp(self, node: ast.BinOp):
        node.left = self.visit(node.left)
        node.right = self.visit(node.right)
        if isinstance(node.left, ast.Num) and isinstance(node.right, ast.Num):
            if isinstance(node.op, ast.Add):
                result = ast.Num(n = node.left.n + node.right.n)
                return ast.copy_location(result, node)
            elif isinstance(node.op, ast.Mult):
                result = ast.Num(n = node.left.n * node.right.n)
                return ast.copy_location(result, node)
        return node
 
    def visit_Name(self, node: ast.Name):
        if node.id == 'pi':
            result = ast.Num(n=3.14159265)
            return ast.copy_location(result, node)
        return node
 
tree = ast.parse("y = 2 * pi + 1")
optimizer = MyOptimizer()
tree = optimizer.visit(tree)
print(ast.dump(tree))

顺便说一下,CPython编译器已经在优化节点BinOp,如下所示。相应的代码用C编写,并在Python / ast_opt.c中给出请注意:CPython优化器更为通用,不仅可以像我们正在考虑的示例中那样使用数字,而且可以使用不同类型的常数。

在AST中检查节点


如何确保我们所做的转换是正确的?首先,您需要完全绕过AST,并检查整个程序。

上面介绍的优化器仍然存在严重缺陷。如果您在程序中的某处重新定义会发生什么pi?试想一下像一样简单明了的东西pi = 4。我们的优化器将简单地将表达式左侧的pi替换为数值3.14159265,然后Python将拒绝编译,因为它无法将任何内容分配给文字值。

也许这恰恰是您想要的行为,使pi成为一个真正的常数,该常数在编译时会被替换,并且永远无法重新分配,也就是说,它无法获得其他值。但是,这肯定违反了Python的语义。

那么,如果我们想坚持使用Python的语义,但是无论如何都要替换pi怎么办?在这种情况下,您首先需要遍历整个程序,并检查for的值是否在某处分配pi。直到我们复杂化为止:如果程序中至少有一点分配了的值,我们将不求助于替换pi pi

现在,我们使用访问者节点,类似于上面描述的转换器节点。与转换器不同,访问者无意更改任何节点,他只是通过AST并检查节点(访问它们)。因此,访问方法不会返回任何内容。

在我们的例子中,我们检查的节点是否涉及Namepi和做以外的任何其他装载值pi(请记住上下文字段ctx)。

import ast
 
class MyVisitor(ast.NodeVisitor):
 
    def __init__(self):
        self.modify_pi = False
 
    def visit_FunctionDef(self, node: ast.FunctionDef):
        if node.name == 'pi':
            self.modify_pi = True
        self.generic_visit(node)
 
    def visit_Name(self, node: ast.Name):
        if node.id == 'pi' and not isinstance(node.ctx, ast.Load):
            self.modify_pi = True
 
program = """
def pi():
    return 3.1415
print(2 * pi())
"""
tree = ast.parse(program)
my_visitor = MyVisitor()
my_visitor.visit(tree)
print("Pi modified:", my_visitor.modify_pi)

该方法generic_visit(node)是由游客对于我们不提供专门的访问方法,每个节点调用。换句话说:visit_FunctionDefNodeVisitor没有可以使用调用的方法super()关于函数定义,我们必须调用通用访问者以确保也正确处理了函数的整个主体。否则,我们可以将指令隐藏在函数中global pi并全局更改值pi,以使我们的优化器不会注意到任何东西。

Python中的本地值


我们的方法可以让我们确定程序员是否更改了pi,但这种方法很粗鲁。但是,Python编译器在确定函数范围内的哪些名称对应于局部变量时,其行为非常相似。如果变量在函数范围内的某个位置发生更改(例如,未使用全局指令将其明确设置为全局变量),则该变量在函数的整个范围内都被视为局部变量。

以下示例将在没有第四行的情况下正常执行。但是,尽管x = 0第四行从未执行过,但仍被认为是对x 因此,x变成了整个函数(甚至在第3行)范围内的局部变量。这就是Python为什么发誓第三行的变量x无关紧要的原因。

x = 1
def print_x():
    print(x)
    if False: x = 0
print_x()

如果您对Python的确切工作方式感兴趣,请查看Python / symtable.c

结论


与大多数编程语言一样,在Python中,特定程序不会直接从源代码执行。实际上,源代码的转换分两个阶段进行:首先,从源代码中提取出抽象语法树(AST),然后是堆叠虚拟机的字节码。Python还提供了许多非常好的功能,用于分析甚至转换任何特定Python程序的AST,之后就可以编译和执行修改后的AST。因此,我们可以轻松实现自己的优化。

当然,我只是在这里省略了许多细节。确保您的优化在所有可能的情况下均能正常工作是一件非常重要的事情。但是,本文的目的不是要告诉您准备在生产环境中使用的优化,而是要给出Python如何分析程序代码的基本思想,以便您可以学习如何正确地对其进行转换然后对其进行优化。

All Articles