通过本篇文章的学习你可以掌握语法分析的原理和递归下降算法(Recursive Descent Parsing),并初步了解上下文无关文法(Context-free Grammar,CFG)。
本篇内容借鉴自极客时间编译原理课程,有兴趣的可以去购买课程啊,本篇主要是希望更多的人可以了解编译,并通过学习用js实现了公式计算器。源码在最下方。
所实现的公式计算器支持简单的加减乘除算数运算,可以支持
2 + 3 * 5这样的运算。这个表达式看上去很简单,但你能借此学到很多语法分析的原理,例如左递归、优先级和结合性等问题。
要实现上面的表达式,我们必须能分析它的语法。不过在此之前,我想先来解析一下变量声明语句的语法,以便让可以循序渐进地掌握语法分析。
解析变量声明语句:理解“下降”的含义
语法分析的结果是生成 AST。算法分为自顶向下和自底向上算法,其中,递归下降算法是一种常见的自顶向下算法。
下面图中展示了 int age = 45(下面的图示和讲解都是基于Java的int类型,实际代码包括以后的文章中的代码都是用js 中let)这个语句的语法分析算法的示意图:
我们首先把变量声明语句的规则,用形式化的方法表达一下。它的左边是一个非终结符(Non-terminal)。右边是它的产生式(Production Rule)。在语法解析的过程中,左边会被右边替代。如果替代之后还有非终结符,那么继续这个替代过程,直到最后全部都是终结符(Terminal),也就是 Token。只有终结符才可以成为 AST 的叶子节点。这个过程,也叫做推导(Derivation)过程:
intDeclaration : Int Identifier ('=' additiveExpression)?;
可以看到,int 类型变量的声明,需要有一个 Int 型的 Token,加一个变量标识符,后面跟一个可选的赋值表达式。我们把上面的文法翻译成程序语句,伪代码如下:
// 伪代码
MatchIntDeclare(){
MatchToken(Int); // 匹配 Int 关键字
MatchIdentifier(); // 匹配标识符
MatchToken(equal); // 匹配等号
MatchExpression(); // 匹配表达式
}
我们对刚才提到的几个名次做一下解释:
-
非终结符(nonterminal) 是用来表示语法成分的符号, 有时也称为“语法变量”。出现在规则的左部、用<>括起来、表示一定语法概念的词。例如
<句子>, <名词短语>, <动词短语>, <名词>。 -
产生式( production)描述了将终结符和非终结符组合成串的方法 产生式的一般形式。α→βα→β,读作:α定义为β。例如
<句子> -><名词短语> <动词短>
具体可参考编译原理基础这篇文章有对编译原理基础知识做详细的讲解
实际的代码片段如下
intDeclare(tokens) {
let node = null;
// 预读
let token = tokens.peek();
if (token !== null && token.getType() === TokenType.Int) {
//匹配Int
token = tokens.read(); //消耗掉int
token = tokens.peek(); //消耗掉int
if (token.getType() === TokenType.Identifier) {
token = tokens.read(); //消耗掉标识符
node = new SimpleASTNode(ASTNodeType.IntDeclaration, token.getText());
token = tokens.peek(); //预读
if (token !== null && token.getType() === TokenType.Assignment) {
tokens.read(); //消耗掉等号
const child = this.additive(tokens); // 匹配一个表达式
if (child === null) {
throw new Error(
"invalide variable initialization, expecting an expression"
);
} else {
node.addChildren(child);
}
}
} else {
throw new Error("variable name expected");
}
if (node !== null) {
token = tokens.peek();
if (token !== null && token.getType() === TokenType.SemiColon) {
tokens.read();
} else {
throw new Error("invalid statement, expecting semicolon");
}
}
}
return node;
}
简单描述一下上面的算法:
解析变量声明语句时,我先看第一个 Token 是不是 int。如果是,那我创建一个 AST 节点,记下 int 后面的变量名称,然后再看后面是不是跟了初始化部分,也就是等号加一个表达式。我们检查一下有没有等号,有的话,接着再匹配一个表达式。
我们通常会对产生式的每个部分建立一个子节点,比如变量声明语句会建立四个子节点,分别是 int 关键字、标识符、等号和表达式。后面的工具就是这样严格生成 AST 的。但是我这里做了简化,只生成了一个子节点,就是表达式子节点。变量名称记到 ASTNode 的文本值里去了,其他两个子节点没有提供额外的信息,就直接丢弃了。
另外,从上面的代码中我们看到,程序是从一个 Token 的流中顺序读取。代码中的 peek() 方法是预读(通过 peek() 方法来预读,实际上是对代码的优化),只是读取下一个 Token,但并不把它从 Token 流中移除。在代码中,我们用 peek() 方法可以预先看一下下一个 Token 是否是等号,从而知道后面跟着的是不是一个表达式。而 read() 方法会从 Token 流中移除,下一个 Token 变成了当前的 Token。
我们把解析变量声明语句和表达式的算法分别写成函数。在语法分析的时候,调用这些函数跟后面的 Token 串做模式匹配。匹配上了,就返回一个 AST 节点,否则就返回 null。如果中间发现跟语法规则不符,就报编译错误。
在这个过程中,上级文法嵌套下级文法,上级的算法调用下级的算法。表现在生成 AST 中,上级算法生成上级节点,下级算法生成下级节点。这就是“下降”的含义。
分析上面的伪代码和程序语句,你可以看到这样的特点:程序结构基本上是跟文法规则同构的。这就是递归下降算法的优点,非常直观。
我们运行这个示例程序,输出 AST:
Programm Calculator
IntDeclaration age
AssignmentExp =
IntLiteral 45
前面的文法和算法都很简单,这样级别的文法没有超出正则文法。也就是说,并没有超出我们做词法分析时用到的文法。
好了,解析完变量声明语句,带你理解了“下降”的含义之后,我们来看看如何用上下文无关文法描述算术表达式?。
用上下文无关文法描述算术表达式
在解析算术表达式的时候,会遇到更复杂的情况,这时,正则文法不够用,我们必须用上下文无关文法来表达。那么:“正则文法为什么不能表示算术表达式?”先来分析一下算术表达式的语法规则。
算术表达式要包含加法和乘法两种运算(简单起见,我们把减法与加法等同看待,把除法也跟乘法等同看待),加法和乘法运算有不同的优先级。我们的规则要能匹配各种可能的算术表达式:
- 2+3*5
- 2*3+5
- 2*3
- ……
我们把规则分成两级:第一级是加法规则,第二级是乘法规则。把乘法规则作为加法规则的子规则,这样在解析形成 AST 时,乘法节点就一定是加法节点的子节点,从而被优先计算。
additiveExpression
: multiplicativeExpression
| additiveExpression Plus multiplicativeExpression
;
multiplicativeExpression
: IntLiteral
| multiplicativeExpression Star IntLiteral
;
以上这种DSL(领域专用语言)怎么理解?
这个实际上就是语法规则,是用BNF表达的。以addtive为例,它有两个产生式。
产生式1:一个乘法表达式
产生式2:一个加法表达式 + 乘法表达式。
通过上面两个产生式的组合,特别是产生式2的递归调用,就能推导出所有的加减乘数算术表达式。
比如,对于2*3这个表达式,运用的是产生式1。
对于2+3*5,运用的是产生式2。
我上面用的语法规则的写法,实际上是后面会用到的Antlr工具的写法。你也可以这样书写,就是一般教材上的写法:
A -> M | A + M
M -> int | M * int
我们每个非终结符只用了一个大写字母代表,比较简洁。我在文稿中用比较长的单词,是为了容易理解其含义。
其中的竖线,是选择其一。你还可以拆成最简单的方式,形成4条规则:
A -> M
A -> A + M
M -> int
M -> M * int
上面这些不同的写法,都是等价的。你要能够看习惯,在不同的写法中自由切换
可以通过文法的嵌套,实现对运算优先级的支持。这样我们在解析“2 + 3 * 5”这个算术表达式时会形成类似下面的 AST:
如果要计算表达式的值,只需要对根节点求值就可以了。为了完成对根节点的求值,需要对下级节点递归求值,所以我们先完成“3 * 5 = 15”,然后再计算“2 + 15 = 17”。在解析算术表达式的时候,便能拿加法规则去匹配。在加法规则中,会嵌套地匹配乘法规则。通过文法的嵌套实现了计算的优先级。
应该注意的是,加法规则中还递归地又引用了加法规则。通过这种递归的定义,我们能展开、形成所有各种可能的算术表达式。比如“2+3*5” 的推导过程:
-->additiveExpression + multiplicativeExpression
-->multiplicativeExpression + multiplicativeExpression
-->IntLiteral + multiplicativeExpression
-->IntLiteral + multiplicativeExpression * IntLiteral
-->IntLiteral + IntLiteral * IntLiteral
这种文法已经没有办法改写成正则文法了,它比正则文法的表达能力更强,叫做“上下文无关文法”。正则文法是上下文无关文法的一个子集。它们的区别呢,就是上下文无关文法允许递归调用,而正则文法不允许。
上下文无关的意思是,无论在任何情况下,文法的推导规则都是一样的。比如,在变量声明语句中可能要用到一个算术表达式来做变量初始化,而在其他地方可能也会用到算术表达式。不管在什么地方,算术表达式的语法都一样,都允许用加法和乘法,计算优先级也不变。好在我们见到的大多数计算机语言,都能用上下文无关文法来表达它的语法。
其实也有上下文相关的情况需要处理,但那不是语法分析阶段负责的,而是放在语义分析阶段来处理的。
解析算术表达式:理解“递归”的含义
我们之前的算法只算是用到了“下降”,没有涉及“递归”,现在,我们就来看看如何用递归的算法翻译递归的文法。
先按照前面说的把文法直观地翻译成算法。但是会出现了无穷多次调用的情况。为了简单化,我们采用下面这个简化的文法,去掉了乘法的层次:
additiveExpression
: IntLiteral
| additiveExpression Plus IntLiteral
;
在解析 “2 + 3”这样一个最简单的加法表达式的时候,我们直观地将其翻译成算法,结果出现了如下的情况:
- 首先匹配是不是整型字面量,发现不是;
- 然后匹配是不是加法表达式,这里是递归调用;
- 会重复上面两步,无穷无尽。 具体过程如下所示:
- additive -> IntLiteral | additive Intliteral ;
- 第一遍:additive->IntLiteral,但因为后面还有Token没处理完,所以这个推导过程会失败,要退回来。每次匹配失败以后,要把已经读出来的token再退回去,尝试别的产生式。这就是回溯。
- 第二遍:additive->additive->IntLiteral,还是一样失败。
- 第三遍:additive->additive->additive->IntLiteral。
- 第四遍:....
“additiveExpression Plus multiplicativeExpression”这个文法规则的第一部分就递归地引用了自身,这种情况叫做左递归。通过上面的分析,我们知道左递归是递归下降算法无法处理的,这是递归下降算法最大的问题。
怎么解决呢?把“additiveExpression”调换到加号后面怎么样?我们来试一试。
additiveExpression
: multiplicativeExpression
| multiplicativeExpression Plus additiveExpression
;
接着改写成算法,这个算法确实不会出现无限调用的问题:
additive(tokens) {
let child1 = this.multiplicative(tokens);
let node = child1;
let token = tokens.peek();
if (child1 !== null && token != null) {
if (
token.getType() === TokenType.Plus ||
token.getType() === TokenType.Minus
) {
token = tokens.read();
const child2 = this.additive(tokens);
if (child2) {
node = new SimpleASTNode(ASTNodeType.Additive, token.getText());
node.addChildren(child1);
node.addChildren(child2);
} else {
throw new Error(
"invalid additive expression, expecting the right part."
);
}
}
}
return node;
}
解读一下上面的算法:
先尝试能否匹配乘法表达式,如果不能,那么这个节点肯定不是加法节点,因为加法表达式的两个产生式都必须首先匹配乘法表达式。遇到这种情况,返回 null 就可以了,调用者就这次匹配没有成功。如果乘法表达式匹配成功,那就再尝试匹配加号右边的部分,也就是去递归地匹配加法表达式。如果匹配成功,就构造一个加法的 ASTNode 返回。
同样的,乘法的文法规则也可以做类似的改写:
multiplicativeExpression
: IntLiteral
| IntLiteral Star multiplicativeExpression
;
现在貌似解决了左递归问题,运行这个算法解析 “2+3*5”,得到下面的 AST:
Programm Calculator
AdditiveExp +
IntLiteral 2
MulticativeExp *
IntLiteral 3
IntLiteral 5
是不是看上去一切正常?可如果让这个程序解析“2+3+4”呢?
Programm Calculator
AdditiveExp +
IntLiteral 2
AdditiveExp +
IntLiteral 3
IntLiteral 4
问题是什么呢?计算顺序发生错误了。连续相加的表达式要从左向右计算,这是加法运算的结合性规则。但按照我们生成的 AST,变成从右向左了,先计算了“3+4”,然后才跟“2”相加。这可不行!
为什么产生上面的问题呢?是因为我们修改了文法,把文法中加号左右两边的部分调换了一下。造成的影响是什么呢?你可以推导一下“2+3+4”的解析过程:
- 首先调用乘法表达式匹配函数 multiplicative(),成功,返回了一个字面量节点 2。
- 接着看看右边是否能递归地匹配加法表达式。
- 匹配的结果,真的返回了一个加法表达式“3+4”,这个变成了第二个子节点。错误就出在这里了。这样的匹配顺序,“3+4”一定会成为子节点,在求值时被优先计算。 所以,我们前面的方法其实并没有完美地解决左递归,因为它改变了加法运算的结合性规则。那么,我们能否既解决左递归问题,又不产生计算顺序的错误呢?答案是肯定的。不过要在下一讲再来解决它。
实现表达式求值
上面帮助你理解了“递归”的含义,接下来,我要带你实现表达式的求值。其实,要实现一个表达式计算,只需要基于 AST 做求值运算。这个计算过程比较简单,只需要对这棵树做深度优先的遍历就好了。
深度优先的遍历也是一个递归算法。以上文中“2 + 3 * 5”的 AST 为例看一下。
- 对表达式的求值,等价于对 AST 根节点求值。
- 首先求左边子节点,算出是 2。
- 接着对右边子节点求值,这时候需要递归计算下一层。计算完了以后,返回是 15(3*5)。
- 把左右节点相加,计算出根节点的值 17。
还是以“2+3*5”为例。它的求值过程输出如下,你可以看到求值过程中遍历了整棵树:
Calculating: AdditiveExp // 计算根节点
Calculating: IntLiteral // 计算第一个子节点
Result: 2 // 结果是 2
Calculating: MulticativeExp // 递归计算第二个子节点
Calculating: IntLiteral
Result: 3
Calculating: IntLiteral
Result: 5
Result: 15 // 忽略递归的细节,得到结果是 15
Result: 17 // 根节点的值是 17
来总结一下本文的重点:
- 初步了解上下文无关文法,知道它能表达主流的计算机语言,以及与正则文法的区别。
- 理解递归下降算法中的“下降”和“递归”两个特点。它跟文法规则基本上是同构的,通过文法一定能写出算法。
- 通过遍历 AST 对表达式求值,加深对计算机程序执行机制的理解。
递归算法是很好的自顶向下解决问题的方法,是计算机领域的一个核心的思维方式。拥有这种思维方式,可以说是程序员相对于非程序员的一种优势。
代码实现
本篇用js实现了一版公式计算器,源码地址:SimpleCalculator
执行如下测试代码及运行结果:
let str = 'let a = b + 3;';
console.log('解析变量声明语句:', str);
const lexer = new SimpleLexer();
let tokens =lexer.tokenize(str)
dump(tokens);
try {
tokens = lexer.tokenize(str);
const node = this.intDeclare(tokens);
console.log('let a = b + 3 的 AST:')
this.dumpAST(node);
} catch (error) {
console.log(error);
}
//测试表达式
str = "2+3*5";
console.log("\n计算: " + str + "的AST");
this.evaluate(str);
问答
语法和文法有什么区别和联系?
文法英文叫做Grammar,是形式语言(Formal Language)的一个术语。所以也有Formal Grammar这样的说法。这里的文法有定义清晰的规则。比如,我们的词法规则、语法规则和属性规则,使用形式文法来定义的。我们的课程里讲解了正则文法(Regular Grammar)、上下文无关文法(Context-free Grammar)等不同的文法规则,用来描述词法和语法。
语法分析中的这个语法,英文是Syntax,主要是描述词是怎么组成句子的。一个语言的语法规则,通常指的是这个Syntax。
问题是,Grammar这个词,在中文很多应用场景中也叫做语法。这是会引起混淆的地方。我们在使用的时候要小心一点就行了。 比如,我做了一个规则文件,里面都是一些词法规则(Lexer Grammar),我会说,这是一个词法规则文件,或者词法文法文件。这个时候,把它说成是一个语法规则文件,就有点含义模糊。因为这里面并没有语法规则(Syntax Grammar)。
Babel、Node.js的编译机制
Babel,只是做语言翻译,只需要前端技术就可以了。翻译成AST,做完语义分析,再转成另一个版本的js。 Node.js基于v8,不仅仅做前端工作,更重要的是在后端运行时做各种优化。
参考资料: 最新编译原理学习全攻略