编译原理实战二:语法分析之 纯手工实现一个公式计算器、 编译原理实战三:语法分析之 怎么消除左递归、怎么确保正确的优先级和结合性? 两篇文章已经介绍了如何实现表达式的解析,并通过一个简单的解释器实现了公式的计算。但这个解释器还是比较简单的,看上去还不大像一门语言。那么如何让它支持更多的功能,更像一门脚本语言呢?接下来我们来看下如何实现。
将继续带实现一些功能,比如:
- 支持变量声明和初始化语句,就像“int age” “int age = 45”和“int age = 17+8+20”;
- 支持赋值语句“age = 45”;
- 在表达式中可以使用变量,例如“age + 10 *2”;
- 实现一个命令行终端,能够读取输入的语句并输出结果。 实现这些功能之后,会更像一个脚本解释器。而且在这个过程中还可以巩固语法分析中的递归下降算法,一起看下“回溯”这个特征,将会对递归下降算法的特征理解得更加全面。
增加新的语法规则
首先,一门脚本语言是要支持语句的,比如变量声明语句、赋值语句等等。单独一个表达式,也可以视为语句,叫做“表达式语句”。你在终端里输入 2+3 就能回显出 5 来,这就是表达式作为一个语句在执行。按照我们的语法,无非是在表达式后面多了个分号而已。C 语言和 Java 都会采用分号作为语句结尾的标识,我们也可以这样写。
我们用扩展巴科斯范式(EBNF)写出下面的语法规则:
programm: statement+;
statement
: intDeclaration (变量声明)
| expressionStatement (表达式)
| assignmentStatement (赋值语句)
;
变量声明语句以 int 开头,后面跟标识符,然后有可选的初始化部分,也就是一个等号和一个表达式,最后再加分号:
intDeclaration : 'int' Identifier ( '=' additiveExpression)? ';';
表达式语句目前只支持加法表达式,未来可以加其他的表达式,比如条件表达式,它后面同样加分号
expressionStatement : additiveExpression ';';
为了在表达式中可以使用变量,我们还需要把 primaryExpression 改写,除了包含整型字面量以外,还要包含标识符和用括号括起来的表达式:
primaryExpression : Identifier| IntLiteral | '(' additiveExpression ')';
赋值语句是标识符后面跟着等号和一个表达式,再加分号:
assignmentStatement : Identifier '=' additiveExpression ';';
这样,我们就把想实现的语法特性,都用语法规则表达出来了。接下来,我们就一步一步实现这些特性。
让脚本语言支持变量
之前实现的公式计算器只支持了数字字面量的运算,如果能在表达式中用上变量,会更有用,比如能够执行下面两句:
let age = 45;
age + 10 * 2;
这两个语句里面的语法特性包含了变量声明、给变量赋值,以及在表达式里引用变量。为了给变量赋值,我们必须在脚本语言的解释器中开辟一个存储区,记录不同的变量和它们的值:
const variablesHashMap = new Map();
简单地用了一个 HashMap 作为变量存储区。在变量声明语句和赋值语句里,都可以修改这个变量存储区中的数据,而获取变量值可以采用下面的代码:
if(variablesHashMap.has(varName)){
let value = variablesHashMap.ge(varName);
if(value !== null){
reslut = value;
} else {
throw new Error('variable " + varName + " has not been set any valu')
}
} else {
throw new Error("unknown variable: " + varName)
}
通过这样的一个简单的存储机制,我们就能支持变量了。当然,这个存储机制可能过于简单了,后面讲到作用域的时候,这么简单的存储机制根本不够。以后再考虑改进它。
解析赋值语句
接下来,我们来解析赋值语句,例如“age = age + 10 * 2;”:
assignmentStatement(tokens){
let node = null;
let token = tokens.peek(); //预读,看看下面是不是标识符
if(token !== null && token.getType() === TokenType.Identifier){
token = tokens.read(); //读入标识符
node = new SimpleASTNode(ASTNodeType.AssignmentStatement, token.getText())
token = tokens.peek(); //预读,看看下面是不是等号
if(token !== null && token.getType() === TokenType.Assignment){
tokens.read(); //取出等号
let child = this.additive(tokens);
if(child === null) { //出错,等号右面没有一个合法的表达式
throw new Error("invalide assignment statement, expecting an expression");
} else {
node.addChildren(child); //添加子节点
token = tokens.peek(); //预读,看看后面是不是分号
if(token !== null && token.getType() === TokenType.SemiColon){
tokens.read(); //消耗掉这个分号
} else {
throw new Error("invalid statement, expecting semicolon");
}
}
}
} else {
tokens.unread(); //回溯,吐出之前消化掉的标识符
node = null;
}
return node;
}
解读一下上面这段代码的逻辑:
我们既然想要匹配一个赋值语句,那么首先应该看看第一个 Token 是不是标识符。如果不是,那么就返回 null,匹配失败。如果第一个 Token 确实是标识符,我们就把它消耗掉,接着看后面跟着的是不是等号。如果不是等号,那证明我们这个不是一个赋值语句,可能是一个表达式什么的。那么我们就要回退刚才消耗掉的 Token,就像什么都没有发生过一样,并且返回 null。回退的时候调用的方法就是 unread()。 如果后面跟着的确实是等号,那么在继续看后面是不是一个表达式,表达式后面跟着的是不是分号。如果不是,就报错就好了。这样就完成了对赋值语句的解析。
利用上面的代码,我们还可以改造一下变量声明语句中对变量初始化的部分,让它在初始化的时候支持表达式,因为这个地方跟赋值语句很像,例如“int newAge = age + 10 * 2;”。
理解递归下降算法中的回溯
我在设计语法规则的过程中,其实故意设计了一个陷阱,这个陷阱能帮我们更好地理解递归下降算法的一个特点:回溯。理解这个特点能帮助你更清晰地理解递归下降算法的执行过程,从而再去想办法优化它。
考虑一下 age = 45;这个语句。肉眼看过去,你马上知道它是个赋值语句,但是当用算法去做模式匹配时,就会发生一些特殊的情况。看一下我们对 statement 语句的定义:
statement
: intDeclaration
| expressionStatement
| assignmentStatement
;
我们首先尝试 intDeclaration,但是 age = 45;语句不是以 int 开头的,所以这个尝试会返回 null。然后我们接着尝试 expressionStatement,看一眼下面的算法:
expressionStatement(tokens){
let pos = tokens.getPosition();
let node = this.additive(tokens);
if(node !== null){
let token = tokens.peek();
if(token !== null && token.getType() === TokenType.SemiColon){
tokens.read();
} else {
node = null;
tokens.setPosition(pos) // 回溯
}
}
return node; //直接返回子节点,简化了AST。
}
出现了什么情况呢?age = 45;语句最左边是一个标识符。根据我们的语法规则,标识符是一个合法的 addtiveExpresion,因此 additive() 函数返回一个非空值。接下来,后面应该扫描到一个分号才对,但是显然不是,标识符后面跟的是等号,这证明模式匹配失败。
失败了该怎么办呢?我们的算法一定要把 Token 流的指针拨回到原来的位置,就像一切都没发生过一样。因为我们不知道 addtive() 这个函数往下尝试了多少步,因为它可能是一个很复杂的表达式,消耗掉了很多个 Token,所以我们必须记下算法开始时候的位置,并在失败时回到这个位置。尝试一个规则不成功之后,恢复到原样,再去尝试另外的规则,这个现象就叫做“回溯”。
因为有可能需要回溯,所以递归下降算法有时会做一些无用功。在 assignmentStatement 的算法中,我们就通过 unread(),回溯了一个 Token。而在 expressionStatement 中,我们不确定要回溯几步,只好提前记下初始位置。匹配 expressionStatement 失败后,算法去尝试匹配 assignmentStatement。这次获得了成功。
试探和回溯的过程,是递归下降算法的一个典型特征。通过上面的例子对这个典型特征有了更清晰的理解。递归下降算法虽然简单,但它通过试探和回溯,却总是可以把正确的语法匹配出来,这就是它的强大之处。当然,缺点是回溯会拉低一点儿效率。但我们可以在这个基础上进行改进和优化,实现带有预测分析的递归下降,以及非递归的预测分析。有了对递归下降算法的清晰理解,我们去学习其他的语法分析算法的时候,也会理解得更快。
接着再讲回溯牵扯出的另一个问题:什么时候该回溯,什么时候该提示语法错误?
在阅读示例代码的过程中,应该发现里面有一些错误处理的代码,并抛出了异常。比如在赋值语句中,如果等号后面没有成功匹配一个加法表达式,我们认为这个语法是错的。因为在我们的语法中,等号后面只能跟表达式,没有别的可能性。
token = tokens.read(); // 读出等号
node = additive(); // 匹配一个加法表达式
if (node == null) {
// 等号右边一定需要有另一个表达式
throw new Error("invalide assignment expression, expecting an additive expression");
}
你可能会意识到一个问题,当我们在算法中匹配不成功的时候,我们前面说的是应该回溯呀,应该再去尝试其他可能性呀,为什么在这里报错了呢?换句话说,什么时候该回溯,什么时候该提示这里发生了语法错误呢?
其实这两种方法最后的结果是一样的。我们提示语法错误的时候,是说我们知道已经没有其他可能的匹配选项了,不需要浪费时间去回溯。就比如,在我们的语法中,等号后面必然跟表达式,否则就一定是语法错误。你在这里不报语法错误,等试探完其他所有选项后,还是需要报语法错误。所以说,提前报语法错误,实际上是我们写算法时的一种优化。
在写编译程序的时候,我们不仅仅要能够解析正确的语法,还要尽可能针对语法错误提供友好的提示,帮助用户迅速定位错误。
代码实现
到目前为止,已经能够能够处理几种不同的语句,如变量声明语句,赋值语句、表达式语句。用JS实现了了整个解析过程,源码地址:SimpleParser
运行如下测试语句
let script = "let age = 45+2; age= 20; age+10*2;"
运行生成的AST如下图所示:
实现一个简单的 REPL
脚本语言一般都会提供一个命令行窗口,让你输入一条一条的语句,马上解释执行它,并得到输出结果,比如 Node.js、Python 等都提供了这样的界面。这个输入、执行、打印的循环过程就叫做 REPL(Read-Eval-Print Loop)。你可以在 REPL 中迅速试验各种语句,REPL 即时反馈的特征会让你乐趣无穷。所以,即使是非常资深的程序员,也会经常用 REPL 来验证自己的一些思路,它相当于一个语言的 PlayGround(游戏场),是个必不可少的工具。
在 SimpleScript 中,实现了一个简单的 REPL。从终端一行行的读入代码,当遇到分号的时候,就解释执行,代码如下:
运行 node SimpleScript.js,你就可以在终端里尝试各种语句了。如果是正确的语句,系统马上会反馈回结果。如果是错误的语句,REPL 还能反馈回错误信息,并且能够继续处理下面的语句。我们前面添加的处理语法错误的代码,现在起到了作用!下面是在电脑上的运行情况:
退出 REPL 需要在终端输入 ctl+c,或者调用 exit() 函数。我们目前的解释器并没有支持函数,所以我们是在 REPL 里硬编码来实现 exit() 函数的。后面文章会真正地实现函数特性。
总结
本篇通过对三种语句的支持,实现了一个简单的脚本语言。REPL 运行代码的时候,你会有一种真真实实的感觉,这确实是一门脚本语言了,虽然它没做性能的优化。希望你看完后也找到了一点感觉:Shell 脚本也好,PHP 也好,JavaScript 也好,Python 也好,其实都可以这样写出来。
现在我们可以分析词法、语法、进行计算,还解决了左递归、优先级、结合性的问题。甚至,还能处理语法错误,让脚本解释器不会因为输入错误而崩溃。
问答
词法分析、语法分析等和机器学习有什么交集吗? 我想比较两个java文件的匹配度, 或者两段代码的匹配度, 不知道机器学习在这个场景是否可以应用, 以及如何应用呢?
其实,人工智能的发展史经历了两个不同的路径。 早期,更多的是演绎逻辑。就是人为制定规则,比如自然语言翻译的规则,并不断执行这些规则。
第二条路径,是最近复兴的机器学习的方法。它更多的是归纳逻辑。机器学习是通过数据的训练,把规则归纳出来。这些归纳出来的规则目前还是比较黑盒的,人比较难以解读,但却很有用,更加准确。
需求中的场景用这两种方法应该都能解决,只不过落地时还要考虑很多细节和限制因素。