写给程序员的编程语言科普——(一)解释器的基本工作流程和构造方法

来源:互联网 发布:.net微信js支付 编辑:程序博客网 时间:2024/05/18 01:12

在这篇文章里我将介绍常见的解释器主要的工作流程,由于一个解释器通常有很多复杂的细节,在这里我仅仅是进行一些粗略的介绍。此外我将介绍人们通常是用怎样的方法来构建一个想要的解释器的。关于为什么我只介绍解释器而不介绍编译器的原因,请参见前言部分。

在上一篇文章里我曾经提到过,不论是解释器还是编译器,在运行的初期都需要做两件事:逐字逐句地解析程序员写的代码,并将其转换为一种内部的数据结构。做这些工作的被我们称为一个解释器的“前端”(front-end),而我们前面说的两件事则分别被称为“扫描(scan)”和“解析(parse)”。

首先,我先解释一下,为什么需要把程序员的代码转换为一种内部的数据结构?这主要是因为转换为一种数据结构后,解释器可以更快速,更方便地访问这个数据结构的某一部分,在这种数据结构中,我们向下访问一组数据中的一部分的时候,就可以像访问一个C语言的结构体成员或者Java的类成员一样容易(实际上很多解释器就是这么做的);需要优化或者转换我们的语言时,只需要修改这一数据结构即可。为什么不是逐条解析并执行呢?理论上讲如果你的语言足够简单,(例如汇编语言)确实是可以做到的,但实际上我们设计的语言远比汇编复杂得多,表达式a的结果可能依赖表达式b,因此我们不得不先求得b的值再求a的值;系统的状态也难以直接从代码中获知,例如,你可能可以用类似goto的语句指定解释器跳到指定行的代码,但如果我们要跟踪的代码在if/while语句中呢?在一个递归函数的某一层递归中呢?显然这些状态无法直接从代码的某一位置获得。

用什么数据结构来描述要解释的代码比较合适呢?例如,我们想要用数据结构来保存表达式1 * (2 + 3),这个表达式包含了一次乘法运算和一次加法运算。为什么不把整个表达式当作一次运算?因为这种区分运算的“粒度”太大,我们无法跟踪其中每一步的计算结果,而且这种划分会使得运算的种类变得很多,(想想光是四则运算的排列组合就有24种!)令人难以逐一处理。

我们会很自然地想到了一种数据结构——树来表示代码,比如说上面的1*(2+3)就可以表示成一颗树,这颗树的根是乘法运算符,左边的叶子是1,右边的叶子又是一棵树,其左边的叶子是2,右边的叶子是3。这种表式方法即能保存表达式之间的依赖关系,又能保存表达式内部的构造。函数调用也是一样,调用函数foo(a, b)就是一棵根是foo,叶子分别是a和b的树。这种树在专业上被称为抽象语法树(abstract syntax tree, AST)

好,回到之前的话题,对于解释器来说,要将代码转换为一棵抽象语法树,第一步就是扫描。扫描的过程,实际上就是解释器在“认字”,因为对于解释器来说,一段代码不过是一串字符串而已,那么首先应该读出每个字的类型,例如,100是一个数字,var是一个识别符(identifier),“foo”是一个字符串,那么解释器会将它们存成相应的数据类型,//以及后面跟着的东西是一条注释,解释器就不去理会它。这些存储的一条一条数据,叫作token。

扫描完毕后,就会开始解析工作。在这一步,解释器通常会用一串特殊的正则表达式去尝试匹配每段代码,不同的正则表达式匹配到的结果分别被存储为不同的数据类型,这种数据类型将成为抽象语法树的一部分。解释器会识别出“这是一条加法表达式”、“这是一个方法声明”、“这是一条打印语句”。在这一过程中,解释器会利用扫描过程中得到的token来帮助它进行抽象语法树的构建,例如它可以识别:假如一个数字(token),后面是一个“+”的字符,再后面还是一个数字(token)的话,那就是一条加法表达式。解析完毕后,关于这段代码的整个抽象语法树就被构建起来了。

假如我们得到的抽象语法树已经可以执行了(也就是说没有优化或者编译的任务时),解释器就会对整颗抽象语法树按一定的顺序进行“求值”(evaluate),例如对于1 * (2 + 3),可能会先对“2+3”进行求值,这样抽象语法树就变成了1 * 5,可以看到,抽象语法树随着求值的进行渐渐变小了,这一过程专业上称为“归约”(reduce),当抽象语法树缩小到只有一个值的时候,这段代码就执行完毕了。


OK,说完解释器的工作流程再说说人们是怎么构建解释器的。要构建一门编程语言的解释器,首先要有关于这门语言的设计,这主要包括语言的两方面:语法(grammer)和语义(semantics)。我在这里用grammer而不是syntax是因为这些语法在实现之前仍然是我们的思维的产物而还没有成为解释器的具体规则。

对于语法的设计又可分为抽象语法和具体语法。具体语法指的是这一语法在文字上的具体表现形式,比如在python里定义一个函数我们会用def …(…),而在PHP里我们会用function …(…)这样不同的形式。而抽象语法则包含了其具体的内容,例如,一个减法表达式会包含两个子表达式,分别表示减法运算的减数和被减数。

事实上,对于抽象语法的设计直接影响到了我们是如何将各种元素组成一个程序的。比如说,对于一门类似于C语言的程序,抽象语法会首先定义程序(program)的语法:一个程序是一个声明(statement),而对于一个声明,则可能有好几种“变种”(production),比如,一个序列声明:

{

statement1;

statement2;

statement3;

}

或者一个赋值声明:identifier = expression。这里的program、statement、expression在语言学里被称为“非终结符”(nonterminal)。实际上抽象语法树中的每一个子树都是这些非终结符的一个变种。对于一个非终结符的定义里,可能会包含这个非终结符本身。这种定义被称为“递归定义”。递归定义在抽象语法中是非常常见的。

有了语法的定义之后,我们就可以开始构建解释器的前端了(前面说到的,负责扫描和解析代码)。由于代码的复杂性,手动去构建一个解释器是非常痛苦的,这意味着我们得为不同的非终结符书写各种各样极其复杂的正规表达式、还必须考虑每个正规表达式之间不能冲突、debug起来也非常痛苦。好在人们事先就想到了这个问题,发明了解析器生成器(parser-generator),这东西本质上就是一个函数库,你按照一定的规则输入设计的语法,它就能生成一个解析函数用来解析你的代码,并返回解析完成的抽象语法树。

有了抽象语法树后,我们就要开始指定解释器在遇到每种非终结符时应该做什么,也就是它们的语义。

首先我们需要考虑的是,我们应该用怎样的手法去执行抽象语法树?我们可以考虑,这是一个类似遍历的过程,比如要得到一个子树的值,则应该先去访问子树的叶子。(当然并不是每个叶子都要访问到,例如一个if语句,在条件为false里则只要访问else部分的语句即可)人们会很自然地想到用一个递归函数来进行这种类似遍历的行为。

通常,我们指定语义时,也是用这种递归函数的形式来书写的,即将一种非终结符的变种当作参数传入这个递归函数时,会发生什么。例如,一个减法表达式diff-exp {exp1, exp2},和递归函数value-of,就可以写:value-of(diff-exp) = value-of(exp1) - value-of(exp2)。以这种方式书写的语义,也被称为“操作语义”。

接下来,我们只要按照我们规定的语义在递归函数中书写相应的代码,这个解析器就基本上构建完成了。当然,这里其实还缺少很多中间过程和重要的数据结构,我将会在接下来的文章里慢慢介绍。


0 0
原创粉丝点击