【小白打造编译器系列5】实现一门简单的脚本语言

我们可以在前面计算器的基础上继续补充代码,实现一门简单的脚本语言。


添加的功能

  • 支持变量声明和初始化语句。"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;

为了给变量赋值,我们必须在脚本语言的解释器中开辟一个存储区,记录不同的变量和它们的值:

扫描二维码关注公众号,回复: 10463080 查看本文章
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

发布了62 篇原创文章 · 获赞 34 · 访问量 2万+

猜你喜欢

转载自blog.csdn.net/weixin_41960890/article/details/105191488