我们可以在前面计算器的基础上继续补充代码,实现一门简单的脚本语言。
添加的功能
- 支持变量声明和初始化语句。"int age;","int age = 45;",“int age = 4 + 5;”
- 支持赋值语句。"age = 45;"
- 在表达式中可以使用变量。"age + 10 * 2;"
- 完成一个命令行终端的实现。
完善语法规则
声明语句
以 int 开头,后面跟标识符,然后有可选的初始化部分,也就是一个等号和一个表达式,最后再加分号:
intDeclaration : 'int' Identifier ( '=' additiveExpression)? ';';
表达式语句
目前只支持加法表达式,未来可以加其他的表达式,比如条件表达式,它后面同样加分号:
expressionStatement : additiveExpression ';';
赋值语句
标识符后面跟着等号和一个表达式,再加分号:
assignmentStatement : Identifier '=' additiveExpression ';';
标识符和括号
为了在表达式中可以使用变量,我们还需要把 primaryExpression 改写,除了包含整型字面量以外,还要包含标识符和用括号括起来的表达式:
primaryExpression : Identifier| IntLiteral | '(' additiveExpression ')';
让脚本语言支持变量
要想让脚本语言支持变量,那需要有一定的存储空间,也就是能完成下面形式的功能。
int age = 45;
age + 10 * 2;
为了给变量赋值,我们必须在脚本语言的解释器中开辟一个存储区,记录不同的变量和它们的值:
private HashMap<String, Integer> variables = new HashMap<String, Integer>();
我们简单地用了一个 HashMap 作为 变量存储区。在变量声明语句和赋值语句里,都可以修改这个变量存储区中的数据。
赋值语句实现
"age = age + 2 * 10;"
要匹配一个赋值语句,那么首先应该看看第一个 Token 是不是标识符。如果不是,那么就返回 null,匹配失败。
如果第一个 Token 确实是标识符,我们就把它消耗掉,接着看后面跟着的是不是等号。
如果不是等号,那证明我们这个不是一个赋值语句,可能是一个表达式什么的。那么我们就要回退刚才消耗掉的 Token,就像什么都没有发生过一样,并且返回 null。回退的时候调用的方法就是 unread()。
如果后面跟着的确实是等号,那么在继续看后面是不是一个表达式,表达式后面跟着的是不是分号。如果不是,就报错就好了。这样就完成了对赋值语句的解析。
private SimpleASTNode assignmentStatement(TokenReader tokens) throws Exception {
SimpleASTNode node = null;
Token token = tokens.peek(); //预读,看看下面是不是标识符
if (token != null && token.getType() == TokenType.Identifier) {
token = tokens.read(); //读入标识符
node = new SimpleASTNode(ASTNodeType.AssignmentStmt, token.getText());
token = tokens.peek(); //预读,看看下面是不是等号
if (token != null && token.getType() == TokenType.Assignment) {
tokens.read(); //取出等号
SimpleASTNode child = additive(tokens);
if (child == null) { //出错,等号右面没有一个合法的表达式
throw new Exception("invalide assignment statement, expecting an expression");
}
else{
node.addChild(child); //添加子节点
token = tokens.peek(); //预读,看看后面是不是分号
if (token != null && token.getType() == TokenType.SemiColon) {
tokens.read(); //消耗掉这个分号
} else { //报错,缺少分号
throw new Exception("invalid statement, expecting semicolon");
}
}
}
else {
tokens.unread(); //回溯,吐出之前消化掉的标识符
node = null;
}
}
return node;
}
同样,我们可以按照相似的逻辑,对变量声明的语句进行改写。
失败需要回溯
在对语句进行匹配的时候,可能会出现匹配到一半发现匹配失败的问题。那么这时候需要进行下一个语法的匹配,因此对于我们的Token流,就应当回溯到初始状态。因为我们并不知道匹配前一个语法进行了多少步,因此最好的办法就是直接恢复原样,再去尝试别的规则。
试探 和 回溯 的过程,是递归下降算法的一个典型特征。递归下降算法虽然简单,但它通过试探和回溯,却总是可以把正确的语法匹配出来,这就是它的强大之处。缺点是回溯会拉低一点儿效率。但我们可以在这个基础上进行改进和优化,实现带有预测分析的递归下降,以及非递归的预测分析。
回溯与报错
我们需要知道,什么时候应该报错,什么时候应该回溯。
我们提示 语法错误 的时候,是说我们知道已经没有其他可能的匹配选项了,不需要浪费时间去回溯。因此,在语法错误的时候,越早报错越好。提前报语法错误,是对我们写算法的一种优化。
在写编译程序的时候,我们不仅仅要能够解析正确的语法,还要尽可能针对语法错误提供友好的提示,帮助用户迅速定位错误。
交互式界面 REPL
脚本语言一般都会提供一个命令行窗口,让你输入一条一条的语句,马上解释执行它,并得到输出结果,比如 Node.js、Python 等都提供了这样的界面。这个输入、执行、打印的循环过程就叫做 REPL(Read-Eval-Print Loop)。
我们也实现了一个简单的 REPL。基本上就是从终端一行行的读入代码,当遇到分号的时候,就解释执行:
SimpleParser parser = new SimpleParser();
SimpleScript script = new SimpleScript();
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in)); //从终端获取输入
String scriptText = "";
System.out.print("\n>"); //提示符
while (true) { //无限循环
try {
String line = reader.readLine().trim(); //读入一行
if (line.equals("exit();")) { //硬编码退出条件
System.out.println("good bye!");
break;
}
scriptText += line + "\n";
if (line.endsWith(";")) { //如果没有遇到分号的话,会再读一行
ASTNode tree = parser.parse(scriptText); //语法解析
if (verbose) {
parser.dumpAST(tree, "");
}
script.evaluate(tree, ""); //对AST求值,并打印
System.out.print("\n>"); //显示一个提示符
scriptText = "";
}
} catch (Exception e) { //如果发现语法错误,报错,然后可以继续执行
System.out.println(e.getLocalizedMessage());
System.out.print("\n>"); //提示符
scriptText = "";
}
}
如果是正确的语句,系统马上会反馈回结果。如果是错误的语句,REPL 还能反馈回错误信息,并且能够继续处理下面的语句。
我的编译器!
变量声明、初始化 与 在表达式中使用标识符
语法规则错误
完整代码:https://github.com/SongJain/TheBeautyOfCompiling/tree/master/MySimpleParser/src