前言
原文链接:https://ruslanspivak.com/lsbasi-part7/
之前的那几小结,我们都是把interpreter的代码和parser的代码混在一起,而且interpreter在parser识别出一个如加减乘除之类 的特定的语言结构(language construct)后,就会立刻对它进行求值。这种 interpreter 被称为 语法导向解释器(syntax-directed interpreter)。
他们通常在输 入上做一个 pass 且只适合基础的语言应用。
为了分析更复杂的编程语言 Pascal 的结构, 我们需要建立一个 中间表示 (intermediate representation, IR)。
parser 会 负责构建 IR
interpreter 会用来解释由 IR 所代表的输入。
事实证明树是一个表示 IR 非常合适的数据结构。关于数的一些术语terminology:
- 树是一个包含一个或多个结点组成的层次数据结构。
- 树有一个根结点,就是顶部结点。
- 除根结点外的所有结点有唯一 一个父结点。
- 下图中结点为*的是一个父结点。结点为 2 和 7 的是它的子结点;子结点从左到右排序。
- 没有子结点的结点称为叶子结点。
- 有一个或多个子结点的非根结点被称为中间结点。
- 子结点也可以是完全子树。下图中结点+的左子树(结点为*)就是一个有自己子结点的 完全子树。
- 在计算机科学中我们把树倒过来画,根结点在最上边,分枝向下生长。
下面是表达式 2 * 7 + 3 的带有解释的树形表示:
本系列中我们会用到的 IR 被称为 抽象语法树 (abstract-syntax tree, AST)。但在深 入了解 AST 之前让我们简单聊聊 解析树 (parse tree)。
尽管我们不会在解释器和编译 器中用到解析树,但它会通过可视化 parser 执行轨迹的方法,加深你对 parser 如何解释 输入的理解。我们也会将它和 AST 做比较,来表明为什么 AST 比解析树更适合用来做 IR。
那么,什么是解析树呢?
- 解析树(有时叫做 具体语法树concrete syntax tree )是一个根据我们的语法定义来 表示一门语言的句法结构的树形结构。它基本上展示了你的 parser 如何识别语言结构或者, 换句话说,它展示了你语法的开始符号怎么派生出该编程语言中一个特定的字符串的。
parser 的调用栈隐式地代表了一个解析树,且当parser 在试图识别一个特定的语言结构时,解析树 就会自动地在内存中构建出来。下面是表达式 2 * 7 + 3 的解析树:
在上面的图片中可以看到:
- 解析树记录了 parser 用来识别输入的一系列规则。
- 解析树的根结点的标签是语法的开始符号(start symbol)。
- 每个中间结点表示一个非终结符(non-terminal),代表应用了一条语法规则,像我们的情况里的
expr
,term
和factor
.- 每个叶子结点代表了一个 token.
我们不会手动构建解析树且在我们的解释器中用到它,但解析树可以通过可视化 调用过程帮助我们理解 parser 怎么解释输入。
你可以使用一个名为 genptdot.py 的小应用(我很快写完用来帮助你的),来查看不同的 算术表达式看起来什么样。要使用这个应用你首先需要安装 Graphviz包,然后运行下面的 命令,你可以打开生成的图片文件 parsetree.png 查看你从命令行传入的表达式的解析树:
1 2
$ python genptdot.py "14 + 2 * 3 - 6 / 2" > \ parsetree.dot && dot -Tpng -o parsetree.png parsetree.dot
下面是由表达式 14 + 2 * 3 - 6 / 2 生成的图片 parsetree.png:
parser
抽象语法树(AST)
现在我们来聊聊抽象语法树(AST)。它是在余下的文章中会大量用到的中间表示(IR)。它是 我们的解释器和未来编译器项目的核心数据结构。
让我们以把表达式 2 * 7 + 3 的 AST 和解析树放在一起看来开始我们的讨论:
抽象语法树(AST)是表示一个语言结构的抽象句法结构的树形表示,它的中间结点和根结点代表了一个操作符,子结点代表了该操作符的操作数。
如何将操作符的优先级(precedence)编码进 AST 呢?
为了把操作符优先级编码进 AST,即,为了表示“X 在 Y 之前发生”你只需要在树中把 X 放在低于 Y 的位置。你在前面 的图片中已经见过到了。
代码实现AST
好了,让我们写些代码来实现不同的 AST 结点类 并修改我们的 parser 来生成包含这些结点的 AST 树:
-
1 2
class AST(): pass #pass 不做任何事情,一般用做占位语句。因为如果定义一个空函数程序会报错,当你没有想好函数的内容是可以用 pass 填充,使程序可以正常运行。
-
1 2 3 4 5
class BinOp(AST): def __init__(self, left, op, right): self.left = left self.token = self.op = op self.right = right
为了在 AST 中表示整数,我们定义一个
Num
类,它将保存一个INTEGER
token 和该 token 的值:1 2 3 4
class Num(AST): def __init__(self, token): self.token = token self.value = token.value
回忆一下表达式 2 * 7 + 3 的 AST。我们会在代码中手工创建该表达式:
|
|
以下是在新定义的结点类下 AST 的样子。下面的图片也遵循了上面手工创建的过程:
parser 代码
下面是我们修改过的 parser 代码,在识别输入(算术表达式)时建立和返回一个 AST:
|
|
遍历
你可以使用后序遍历*postorder traversal* (深度优先遍历depth-first traversal 的一个特例) 。
下面是后序遍历的伪代码,其中 << postorder actions >>
是一些操作的占位符,如 BinOp
结点的加减乘除操作或 Num
结点返回整数的简单操作:
-
在下面的图片中,可以看到使用后序遍历时我们会首先对表达式 2*7 进行 求值,而只有在对 14 + 3 求值之后,我们才会得到正确答案 17:
为了完整起见,三种深度优先遍历的方式:先序遍历,中序遍历和后序遍历。这 些遍历方式名字的来自于遍历代码中操作的位置:
有时你可能需要在所有地方(先序,中序和后序)都执行一些操作。你会在本文的源代码仓 库中找到一些例子。
Interpreter
好了,让我们写一些代码来遍历和解释由 parser 建立的抽象语法树,好吗?
|
|
NodeVisitor一个结点访问器的基类:它遍历抽象语法树并为找到的每个节点调用一个访问器函数。(这个函数可能会返回一个由visit()方法转发 的值)。This class is meant to be subclassed, with the subclass adding visitor methods.
成员函数:
- 1️⃣ visit(node):访问一个结点。它默认调用self.visit_classname(其中的classname是结点类的名
字,或者如果这个方法不存在时,classname就是 generic_visit() )的方法。- 2️⃣ generic_visit(node):这个访问器(visitor)对结点的所有子节点调用visit()。
【注意:只有这个访问器调用generic_visit() 或者访问它本身,否则自定义访问器的结点的子节点将不会被访问】
1
getattr(object, name[, default])
官方文档中说这个函数作用是返回对象的一个属性,第一个参数是对象实例
obj
,name
是个字符串,是对象的成员函数名字或者成员变量,default
当对象没有这个属相的时候就返回默认值,如果没有提供默认值就返回异常。如:
- 提供不默认写法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
>>> class Test(object): ... def func(self): ... print 'I am a test' ... >>> test = Test() # 实例化一个对象 >>> func = getattr(test, 'func') # 使用getattr函数获取func的值 >>> func() I am a test >>> func = getattr(test, 'f') # 使用对象没有的属性,则会出现异常 Traceback (most recent call last): File "<stdin>", line 1, in <module> AttributeError: 'Test' object has no attribute 'f' >>>
- 提供默认写法
如果对象没有该属性可以提供一个默认值。
1 2 3 4
>>> func = getattr(test, 'f', None) >>> print func None >>>
Interpreter 类的源代码
|
|
第一,操作 AST 结点的访问器(也就是对AST数据的操作)的代码 和 AST 结点(即,AST的数据结构)本身 分离(解耦了)。
第二,在NodeVisitor的访问函数中,不像这样使用一个巨大的if语句:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
def visit(node): node_type = type(node).__name__ if node_type == 'BinOp': return self.visit_BinOp(node) elif node_type == 'Num': return self.visit_Num(node) elif ... # ... #####或者########## def visit(node): if isinstance(node, BinOp): return self.visit_BinOp(node) elif isinstance(node, Num): return self.visit_Num(node) elif ...
NodeVisitor 的 visit 方法非常通用,能根据传入的结点类型来调度适当的方法。正如前面提到的,为了利用这一点,我们的解释器继承了 NodeVisitor 类并实现了必要的方法。 因此:
花此时间研究一下这个方法(Python 的标准模块 ast 也使用了相同的机制来遍历结点), 因为我们将来会用很多新的 visit_NodeType
方法来扩展我们的解释器。
现在,让我们手工为表达式 2 * 7 + 3 建立一个 AST 并把它传递给解释器,通过对该表达式求值看看运行中的 visit 方法。下面是你从 Python shell 中尝试的方法:
|
|
如你所见,我把表达式树的根结点传递给了 visit
方法,这一行为触发了树的遍历,遍历调用了 Interpreter
类正确的方法(visit_BinOp
和 visit_Num
)并生成了结果。
完整代码
|
|
将以上代码保存到名为 spi.py
的文件中,或者直接从 GitHub 下载。自己试一试,确认 你的新的基于树的解释器可以正确地对算术表达式进行求值。
下面是某次运行过程:
|
|
小结
你可以把它读作“parser 从 lexer 中 得到 token 然后返回生成的 AST 给 Interpreter 进行遍历并解释执行所给输入”。
递归
这就是今天的所有内容,但在总结之前我还想简单地聊一聊递归下降 (recursive-descent) parser,即是仅仅给出它的定义。
定义就是:一个 递归下降parser 就 是一个自顶向下的 parser,它使用一组递归过程来处理输入。自顶向下反映了 parser 从 构建解析树的顶部结点开始逐渐构建更低的结点这一事实。
梳理
Lexer
与part6中的是一样的,还是将输入的text分析转换为token。(这也是词法分析器的功能)Parser
相比part6,- 它添加了构造AST的内容:添加了三个类:
AST()
:是一个基类BinOp(AST)
:继承于AST()
,主要功能是实现二元操作符binary operator 。(这里只有四种:加、减、乘、除法)Num(AST)
:继承于AST()
,它主要是表示AST中的整数integer token(它将保存一个INTEGER
token 和该 token 的值)
- 第二个变化就是实现语法解析的这三个函数
term
、factor
、expr
中的返回的不在是result变量了,而是返回一个结点node。
- 它添加了构造AST的内容:添加了三个类:
Interpreter
:通过词法分析、语法分析之后,开始解释语法分析之后的算式,计算出它的结果,用Interpreter
来解释:- 增添了访问者模式:将 对数据的操作(对数据的操作在interpreter中完成:访问、解释) 和 数据结构(数据结构 在parser中的三个类中构建) 进行分离(解耦合)。使得操作集合可相对自由地演化。
- 这里通过创建
NodeVisitor()
类 实现访问者模式。实现了通过什么方式去访问生成的AST - 又通过
Interpreter
(继承于NodeVisitor()
类)来实现解释生成的AST
- 这里通过创建
- 增添了访问者模式:将 对数据的操作(对数据的操作在interpreter中完成:访问、解释) 和 数据结构(数据结构 在parser中的三个类中构建) 进行分离(解耦合)。使得操作集合可相对自由地演化。
最后,再次提醒一下:之前的那几小结,我们都是把interpreter的代码和parser的代码混在一起,但这一小节我们把interpreter的代码和parser的代码分开了。