用 Python 从零开始写一个简单的解释器(3)

到目前为止,我们已经为解释器写了一个词法分析器一个解析器组合子库。在这里,我们会创建抽象语法树(AST)的数据结构,使用组合子库写一个解析器,组合子库可以实现将词法分析器返回的标记列表转换为一个抽象语法树(AST)。

定义抽象语法树(AST)

在我们正式写解析器之前,需要定义清楚解析器输出的数据结构。我们将以类的形式实现这个数据结构。IMP语言的每个语法元素都有一个对应的类。这些类的对象各自代表了抽象语法树(AST)的一个结点。

IMP 中有三种结构:算术表达式(用于计算数字)、布尔表达式(用于为if-else和while语句计算条件)、声明语句。我们将从算术表达式开始,因为另外两种都依赖于它们。

算术表达式可能是下列三种之一:

  • 文字型整型常量,比如42;
  • 变量,比如x;
  • 二进制操作,比如x+42

这些组成了其他的算术表达式。

我们也可以用括号将表达式分组,像(x+2)*3。这并不是另一种不同的表达式,而是一种解析表达式的方式。

我们将为这三种形式定义三个类,加上为一般算术表达式定义的基类。现在,这些类除了存储数据并没有太多的功能。包含了__repr__函数,我们就可以在调试时打印抽象语法树(AST)。所有的AST类从继承自Equality,这样我们可以比较两个AST对象是不是相同的。这对于测试很有用。

布尔表达式有一点复杂。它分为四种:

  • 关系式表达(如x < 10
  • 与表达式(如x < 10 and y > 20
  • 或表达式
  • 非表达式

关系表达式的左边和右边都是算术表达式。“与”,“或”和“非”的左右两边都是布尔表达式。限制类型可以帮助我们避免类似 “x<10 and 30” 这样的无意义的表达式。

声明语句既可以包含算术表达式,也可以包含布尔表达式。声明语句也分为四种:赋值语句、复合语句、条件语句以及循环语句。

原语

既然已经有了表示抽象语法树(AST)的类和一个简便的解析器组合子,那就该实现解析器了。实现解析器的时候,从语言的最基本的结构开始并继续,通常是最容易的办法。

我们将要研究的第一个解析器是关键字keyword的解析器。这是Reserved组合子的一个具体版本,该版本使用的是标签RESERVED ,而所有的关键字都被标记的是RESERVED 标签。记住,当文本和标签都和给定的一模一样时,Reserved只能匹配一个单一的字符。

keyword实际上也是一个组合子,因为它是一个能返回解析器的函数。我们会在其他的解析器中直接使用它。id解析器通常被用来匹配变量名。它使用Tag组合子,针对具体标签匹配一个字符。

num解析器用来解析整数。除了使用Process组合子外,num解析器和id解析器类似。它使用Process组合子(实际上是一个会调用Process^操作符)将字符转换成实际的整数值。

解析算术表达式

解析算术表达式并不是最简单的事情,但是解析布尔表达式和声明语句都需要解析算术表达式,所以我们从它开始。

首先要定义aexp_value解析器,它将numid解析器返回的值转变为实际的表达式。

我们会在这里使用 | 操作符,这是Alternate组合子的简写。所以它会尝试先解析整数表达式。失败了才会去解析变量表达式。

你会看到我们将aexp_value定义成一个零参数的函数,而不是一个全局量(global value),像处理idnum一样。对于所有其他的解析器,也都是一样处理。原因是我们不希望每个解析器的代码立刻被评估。如果把每个解析器都定义为全局量,每个解析器都不能引用同一个源文件中定义在其之后的解析器了,因为那时他们还没定义。

下一步,我们想支持使用括号为算术表达式分组。尽管分组表达式不需要独自的AST类,但它们也需要一个解析器来处理它们。

process_group是一个使用Process组合子(^操作符)的函数。它会去掉括号并返回其中的表达式。axep_group是实际的解析器。记住,操作符是Concat组合子的简写。所以这将解析‘(’,然后是一个算术表达式(由aexp解析,稍后会定义),接着是‘)’。需要避免直接调用aexp,因为aexp会调用aexp_group,它会导致无限递归。为了做到这一点,我们使用了Lazy组合子,它只有在解析器被实际用于某个输入时才会调用aexp函数。

接下来,我们使用aexp_termaexp_valueaexp_group联系起来。任何独立基本的表达式都是aexp_term表达式,我们不必担心运算符相对于其他表达式的优先级。

现在到了比较棘手的部分:操作符和优先级。对我们而言,只是定义了另一种解析器然后与aexp_term一起处理。这就导致一个简单的表达式,如:

被错误的解析为:

解析器需要知道操作符优先级,进而将优先级较高的操作分组。我们会定义几个辅助函数来实现这部分工作。

实际上构成BinoAexp对象的是process_binop。它读入一个算术操作符并返回一个能联系使用该操作符的一对表达式的函数。

Exp组合子(*操作符)会使用proce_binopExp解析一系列已经分隔好的表达式对。Exp的左操作数是一个解析器,它能匹配表达式列表里的独立元素(在我们的例子中是算术表达式)。Exp的右操作数也是一个解析器,它能匹配分隔符(即操作符)。不论匹配的是哪个分隔符,右边的解析器都会返回一个函数,给定匹配的分隔符,该函数会返回联结函数。联结函数的输入是分隔符左右两边的已分解的表达式,返回的是一个单一的,组合后的表达式。是不是很困惑?我们将会大致看一下Exp的使用方法。左边的解析器事实上返回的就是process_binop

接下来,我们会定义优先级以及一个处理它们的组合子。

any_operator_in_list输入一系列关键字的字符串,返回能匹配它们中任意一个的解析器。这个函数将会在aexp_precedence_levels中调用,其中包含了每个优先级的一系列操作符(最高优先级优先)。

precedence是这个操作的真正的重点。它的第一个参数,value_parser是一个解析器,它可以读取表达式的基本部分:数字,变量和分组。这将是aexp_termprecedence_levels是一个操作符列表,每一个优先级一个列表。我们使用aexp_precedence_levels做到这些。给定一个操作符,combine会返回一个函数,该函数将两个比较小的表达式转换成一个比较大的表达式。那就是process_binop

precedence内部,我们首先定义了op_parser,对于给定的优先级,读取该级别的任意操作符,返回一个能联结两个表达式的函数。op_parser可以作为Exp的右操作符参数。我们从为最高优先级调用op_parserExp开始,因为这些操作应该首先被分组。然后我们用生成的解析器作为下一个优先级的元素解析器(Exp的左参数)。循环结束后,所得到的解析器能够正确解析任何算术表达式。

这在实际中是如何工作的呢?让我们一起来看看。

E0value_parser一样。它可以解析数字,变量和分组,但不包括操作符。任何包含能被E0匹配,由第一优先级的操作符分隔开的内容的表达式都能被E1解析。所以E1可以匹配a*b/c,但是当它遇到+操作符的时候会报出错误。E2则能匹配任何E1能匹配,由下一优先级的操作符分隔开的表达式。由于我们只有两种优先级,E2能匹配任何我们能支持的算术表达式。

一起来看个例子。以一个复杂的表达式为例,逐步以匹配的方式取代各部分。

使用precedence直接定义aexp:

我们也能以一种不太抽象的方式定义优先级,但它的优势在于它可以适用于任何操作符优先级是个问题的场景。在解析布尔表达式的时候还会再使用它。

解析布尔表达式

解决了算术表达式,我们可以转移到布尔表达式了。实际上布尔表达式比算术表达式简单,我们不需要任何新的工具来解析它们。我们将从最基本的布尔表达式,关系式,开始:

process_relop只是一个使用了Process组合子的函数。它需要三个连接值并从中创建出RelopBexp。在bexp_relop中,解析了两个由关系操作符分隔开的算术表达式(aexp)。使用了之前比较熟悉的any_operator_in_list,这样我们就不必为每个操作符都单独写一个处理程序。也没有必要使用Exp或是precedence之类的组合子,因为IMP里的关系表达式并不能像其他语言里那样链接使用。

接下来,我们定义了not(非)表达式。not(非)是一个具有高优先级的一元运算。这使得它比and(与)和or(或)表达式更容易解析。

这里,我们只是将关键字not与一个布尔表达式(下一步将会定义)串连在一起。由于bexp_term将用bexp_not来定义,我们需要使用lazy组合子来避免无限递归。

对于算术等式,我们几乎以相同的方式定义bexp_group和bexp_term。这里并没有什么新东西。接下来,我们需要定义包含and(与)和or(或)操作符的表达式。这些操作符实际上和算术操作符一样的工作原理;二者都是从左往右解析,and(与)有比较高的优先级。

就像process_binop,process_logic本意是与Exp组合子一起使用。它需要一个操作符,返回一个函数,该函数使用前面的操作符将两个子表达式联结成一个表达式。在这个过程中还有优先级precedence,就像aexp那样。编写泛型代码在这里就体现价值了,因为我们不必重复编写复杂的表达式处理代码。

解析声明语句

aexp和bexp已经完成了,我们可以开始解析IMP声明语句了。我们将从低级的赋值语句开始。

这个就没有什么特别有趣的。接下来,我们先看看stmt_list。它会解析一系列以分号分隔的语句。

记住,这里我们需要使用Exp组合子,而不能只简单处理以避免左递归,就像stmt() + keyword(‘;’) + stmt()这样。

下一步是if语句:

这里唯一复杂的地方是else从句是可选的。这使得process函数有点复杂。

最后,开始处理while循环语句:

我们用stmt来包装它:

你会发现无论是if语句还是while语句都用的是stmt_list作为它们的主体而不是stmt。stmt_list实际上是我们高级定义。我们不能让stmt直接依赖于stmt_list,因为这样的解析器会导致左递归。而且由于我们希望if和while语句都能够把复合语句作为主体,所以我们直接使用stmt_list。

整合

现在我们对语言的每个部分都建立了解析器。我们只需要做几个高级定义:

parser会解析整个程序。一个程序只不过是一个语句列表,但是Phrase组合子保证我们用到了文件的每一个标记符,而不是在最终遇到无用标记符后提前结束。

客户端需要解析程序时可以调用函数imp_parse 。它需要一个标记符列表,调用parser,从第一个标记符开始,然后返回结果AST。

为了测试解析器(除了单元测试),这里写了一个简单的驱动程序:

这个程序会读入文件(第一个参数),然后用imp_parse.py(第二个参数)中的解析器解析该文件。例如:

这应该是一个很好的实验方法。

总结

本文中,我们从头开始建立了一个解析器组合子库,并把它用于IMP的解析器。在本系列的下一篇也是最后一篇中,我们会为已解析的AST写一个求值器。

再一次,解释器所有的源码都在这里提供下载。

1 4 收藏 评论

关于作者:fzr

微博:@fzr-fzr) 个人主页 · 我的文章 · 25

相关文章

可能感兴趣的话题



直接登录
跳到底部
返回顶部