【ANTLR学习笔记】4:语法导入和访问者(Visitor)模式

这节以四则运算语句的解析为例学习语法导入和Visitor模式。相比笔记1,这里的语法更通用,允许加减乘除、圆括号、整数出现,并且允许赋值表达式。

1 四则运算解析

1.1 语法规则文件

从下面的文件中可以看到,整体是要匹配若干条语句,每条语句都是以NEWLINE换行符结束的。然后语句可以是表达式语句、赋值语句、空语句。

表达式的语法规则定义比较自然,因为这里没有手动消除左递归,ANTLR4可以自己消除直接左递归(文件中13/14行分支expr左侧直接调用自身),这是相比其它工具的一大优势,让语法编写更简单易懂。

grammar Expr;

// 顶层规则:一条至多条语句
prog:   stat+ ;

// 语句
stat:   expr NEWLINE            // 表达式语句(表达式后跟换行)
    |   ID '=' expr NEWLINE     // 赋值语句(左值是标识符,右值是表达式)
    |   NEWLINE                 // 空语句(直接一个换行)
    ;

// 表达式
expr:   expr ('*'|'/') expr     // 表达式乘除表达式
    |   expr ('+'|'-') expr     // 表达式加减表达式
    |   INT                     // 一个整形值
    |   ID                      // 一个标识符
    |   '(' expr ')'            // 表达式外加一对括号
    ;

ID  :   [a-zA-Z]+ ;      // 标识符:一个到多个英文字母
INT :   [0-9]+ ;         // 整形值:一个到多个数字
NEWLINE:'\r'? '\n' ;     // 换行符
WS  :   [ \t]+ -> skip ; // 跳过空格和tab

词法符号NEWLINE匹配换行符,其中符号?也就是正则里的匹配出现一次或多次,在这里就表示整个NEWLINE匹配的是\r或者\r\n。这样做的目的是因为Windows中的换行符是\r\n, 而Linux/Unix下的换行符是\n

最后的-> skip在前面学习中也有接触到,这个是一条ANTLR指令,告诉词法分析器匹配时忽略这些字符,这样就不用嵌入代码来做忽略了(书上的意思是不用嵌入代码也就不用和特定编程语言绑定)。

1.2 从主类调用

import anrlr.ExprLexer;
import anrlr.ExprParser;
import org.antlr.v4.runtime.CharStream;
import org.antlr.v4.runtime.CharStreams;
import org.antlr.v4.runtime.CommonTokenStream;
import org.antlr.v4.runtime.tree.ParseTree;

public class ExprJoyRide {
    public static void main(String[] args) {
        CharStream input = CharStreams.fromString("1+(2*3)+4\n");
        // 词法分析->Token流->生成语法分析器对象
        ExprLexer lexer = new ExprLexer(input);
        CommonTokenStream tokens = new CommonTokenStream(lexer);
        ExprParser parser = new ExprParser(tokens);
        // 真正启动语法分析,并将语法树输出
        ParseTree tree = parser.prog();
        System.out.println(tree.toStringTree(parser));
    }
}

2 语法导入

实际使用时经常会遇到非常大的语法,可以考虑将它拆分成多个小的语法文件。例如,可以将语法规则和词法符号规则拆分开,因为不同语言的词法符号规则很大部分是重复的,这样就可以把它抽象成一个单独的模块,以应用于多个语言的分析器。

2.1 CommonLexerRules.g4

注意这个文件中的的第一行用lexer grammar,表示这里只存放词法符号规则。

lexer grammar CommonLexerRules;

ID  :   [a-zA-Z]+ ;      // 标识符:一个到多个英文字母
INT :   [0-9]+ ;         // 整形值:一个到多个数字
NEWLINE:'\r'? '\n' ;     // 换行符
WS  :   [ \t]+ -> skip ; // 跳过空格和tab

2.2 LibExpr.g4

这个就是从最开始的语法规则文件里把词法符号规则去掉,再把2.1文件导入。测试语法和生成代码的功能都是直接在这个文件上做,而不用在被导入的文件上操作。

grammar LibExpr;
// 导入单独分离出去的词法符号规则文件
import CommonLexerRules;

// 顶层规则:一条至多条语句
prog:   stat+ ;

// 语句
stat:   expr NEWLINE            // 表达式语句(表达式后跟换行)
    |   ID '=' expr NEWLINE     // 赋值语句(左值是标识符,右值是表达式)
    |   NEWLINE                 // 空语句(直接一个换行)
    ;

// 表达式
expr:   expr ('*'|'/') expr     // 表达式乘除表达式
    |   expr ('+'|'-') expr     // 表达式加减表达式
    |   INT                     // 一个整形值
    |   ID                      // 一个标识符
    |   '(' expr ')'            // 表达式外加一对括号
    ;

3 访问者(Visitor)模式

在笔记3中使用的是监听器(Listener)模式,这里尝试ANTLR支持的另外一种遍历语法树的模式,访问者模式。

3.1 LabeledExpr.g4

为了让每条备选分支都能有一个访问方法,这里为每个备选分支都加上标签(类似Python的注释,用# 标签名表示)。

另外,加减乘除等符号在之前的语法是是字面值,现在也给它们设置名字,其实也就是让它们也成为词法符号。这样就可以直接用这个词法符号名以Java常量的方式引用这些符号。

grammar LabeledExpr;

prog:   stat+ ;

// -------------给每个备选分支打标签

stat:   expr NEWLINE                # printExpr
    |   ID '=' expr NEWLINE         # assign
    |   NEWLINE                     # blank
    ;

expr:   expr op=('*'|'/') expr      # MulDiv
    |   expr op=('+'|'-') expr      # AddSub
    |   INT                         # int
    |   ID                          # id
    |   '(' expr ')'                # parens
    ;

// -------------给运算符号设置名字,也形成词法符号

MUL :   '*' ;
DIV :   '/' ;
ADD :   '+' ;
SUB :   '-' ;

// -------------剩下的是和之前一样的词法符号

ID  :   [a-zA-Z]+ ;      // 标识符:一个到多个英文字母
INT :   [0-9]+ ;         // 整形值:一个到多个数字
NEWLINE:'\r'? '\n' ;     // 换行符
WS  :   [ \t]+ -> skip ; // 跳过空格和tab

3.2 生成解析器代码

在生成的时候勾选上generate parse tree visitor,这样才能生成访问者模式相关的类和接口。

如果是用命令行,就要用antlr4 -visitor LabeledExpr.g4命令来生成。不过默认还是会带有Listener的,如果想要去掉Listener,还要加上-no-listener参数。

3.2.1 LabeledExprVisitor.java

首先生成了访问器泛型接口,并为每个未标签的语法或带标签的备选分支生成了一个方法

// LabeledExpr语法的访问器接口
public interface LabeledExprVisitor<T> extends ParseTreeVisitor<T> {
	// 访问顶层语法
	T visitProg(LabeledExprParser.ProgContext ctx);
	// 访问stat语法的第一个分支(对应# PrintExpr)
	T visitPrintExpr(LabeledExprParser.PrintExprContext ctx);
	// 访问stat语法的第二个分支(对应# Assign)
	T visitAssign(LabeledExprParser.AssignContext ctx);
	...
}

这些方法都是泛型返回值的T visit*(*Context)格式,以便让实现类为具体功能去实现不同的返回值类型。

3.2.2 LabeledExprBaseVisitor.java

另外还生成了一个默认的实现类,在访问每个结点时直接调用访问孩子结点的方法:

// 生成的默认实现类
public class LabeledExprBaseVisitor<T> extends AbstractParseTreeVisitor<T> implements LabeledExprVisitor<T> {
	@Override public T visitProg(LabeledExprParser.ProgContext ctx) { return visitChildren(ctx); }
	@Override public T visitPrintExpr(LabeledExprParser.PrintExprContext ctx) { return visitChildren(ctx); }
	@Override public T visitAssign(LabeledExprParser.AssignContext ctx) { return visitChildren(ctx); }
	...
}

这个访问孩子结点的方法visitChildren()就是继承自它所继承的抽象类AbstractParseTreeVisitor

3.3 实现计算功能的访问器类

为了实现自定义的计算功能,要去继承刚刚生成的LabeledExprBaseVisitor泛型类,因为是整数计算器(计算时候返回整数),所以泛型参数这里指定为Integer即可。

/*
 * Excerpted from "The Definitive ANTLR 4 Reference",
 * published by The Pragmatic Bookshelf.
 * Copyrights apply to this code. It may not be used to create training material,
 * courses, books, articles, and the like. Contact us if you are in doubt.
 * We make no guarantees that this code is fit for any purpose.
 * Visit http://www.pragmaticprogrammer.com/titles/tpantlr2 for more book information.
 */

import antlr.LabeledExprBaseVisitor;
import antlr.LabeledExprParser;

import java.util.HashMap;

// 实现计算功能的访问器类
public class EvalVisitor extends LabeledExprBaseVisitor<Integer> {
    // 模拟计算器的内存,存放"变量名->值"的映射,即在赋值时候往这里写
    HashMap<String, Integer> memory = new HashMap<>();

    // 访问赋值语句:ID '=' expr NEWLINE
    @Override
    public Integer visitAssign(LabeledExprParser.AssignContext ctx) {
        String id = ctx.ID().getText();  // 获取左值标识符
        int value = visit(ctx.expr());   // 对右值表达式访问求值
        memory.put(id, value);           // 存储赋值
        return value;
    }

    // 访问表达式语句:expr NEWLINE
    @Override
    public Integer visitPrintExpr(LabeledExprParser.PrintExprContext ctx) {
        Integer value = visit(ctx.expr()); // 对表达式访问求值
        System.out.println(value);         // 把值打印出来
        return 0;                          // 反正用不到这个返回值,这里返回假值
    }

    // 访问单个整数构成的表达式:INT
    @Override
    public Integer visitInt(LabeledExprParser.IntContext ctx) {
        return Integer.valueOf(ctx.INT().getText()); // 把这个数返回
    }

    // 访问单个标识符构成的表达式:ID
    @Override
    public Integer visitId(LabeledExprParser.IdContext ctx) {
        String id = ctx.ID().getText(); // 获取标识符名字
        if (memory.containsKey(id)) // 查表,找到就返回
            return memory.get(id);
        return 0; // 找不到返回0
    }

    // 访问乘除法表达式:expr op=('*'|'/') expr
    @Override
    public Integer visitMulDiv(LabeledExprParser.MulDivContext ctx) {
        int left = visit(ctx.expr(0));  // 被除数,或乘法因子1
        int right = visit(ctx.expr(1)); // 除数,或乘法因子2
        if (ctx.op.getType() == LabeledExprParser.MUL) // 检查操作符
            return left * right; // 乘法
        return left / right; // 除法
    }

    // 访问加减法表达式:expr op=('+'|'-') expr
    @Override
    public Integer visitAddSub(LabeledExprParser.AddSubContext ctx) {
        int left = visit(ctx.expr(0));  // 项1
        int right = visit(ctx.expr(1)); // 项2
        if (ctx.op.getType() == LabeledExprParser.ADD) // 检查操作符
            return left + right; // 加法
        return left - right; // 减法
    }

    // 访问表达式加括号:'(' expr ')'
    @Override
    public Integer visitParens(LabeledExprParser.ParensContext ctx) {
        return visit(ctx.expr()); // 其实就是把括号里表达式的值算出来返回
    }
}

注意这里按照书上的代码visitAssign()方法也把赋值后的值返回了,实际上它也和visitPrintExpr()一样是语句stat的一个分支罢了,而语句的返回值是用不到的,所以这里返回0也可以。因为这里的语法里不会出现连续赋值的情况,赋值就是语句,赋值后的值不会再被用到了。

当然实际的程序语言里则可能会用到,比如a=b=3这种连续赋值。

当需要计算表达式的值的时候,代码里是直接调用了visit()方法,这个方法的源码没看到,不过看起来就是直接去调用传入的结点的访问方法就可以了。

还有就是检查操作符的地方值得注意,ctx.op.getType()这里可以通过op属性获取到操作符,这个就是需要在语法里给操作符设置op=('*'|'/')而不是直接('*'|'/')的原因了。紧随其后的判断== LabeledExprParser.MUL就是在3.1中要为原本的操作符字面值设置名字的一大好处。

另外就是除法除0的检查,这里没做检查,我觉得是可以的,相当于除0的时候靠JVM给报错,也没什么大问题。

3.4 从主类调用

import antlr.LabeledExprLexer;
import antlr.LabeledExprParser;
import org.antlr.v4.runtime.CharStream;
import org.antlr.v4.runtime.CharStreams;
import org.antlr.v4.runtime.CommonTokenStream;
import org.antlr.v4.runtime.tree.ParseTree;

public class Calc {
    public static void main(String[] args) {
        CharStream input = CharStreams.fromString("a=2*(3+4)-5\nb=2\na+b\n");
        // 词法分析->Token流->生成语法分析器对象
        LabeledExprLexer lexer = new LabeledExprLexer(input);
        CommonTokenStream tokens = new CommonTokenStream(lexer);
        LabeledExprParser parser = new LabeledExprParser(tokens);
        // 启动语法分析,获取语法树(根节点)
        ParseTree tree = parser.prog();
        // 创建自定义的能进行四则运算的访问者类
        EvalVisitor evalVisitor = new EvalVisitor();
        // 访问这棵语法树,在访问同时即可进行计算获取结果
        evalVisitor.visit(tree);
    }
}

运行结果是11,符合预期。因为前两条是赋值语句:
a = 2 × ( 3 + 4 ) − 5 = 9 b = 2 \begin{aligned} a&=2\times(3+4)-5=9 \\ b&=2 \end{aligned} ab=2×(3+4)5=9=2

最后一条是表达式语句,要把计算值打印出来, a a a b b b相加计算得到的值就是11。

你可能感兴趣的:(#,ANTLR,ANTLR,编译器,词法分析,语法分析,访问者模式)