3. 使用Antlr4的Visitor模式实现一个简单的整数计算器

0. 序言

  • antlr支持Visitor和Listener两种设计模式,本文将介绍如何使用antlr4的visitor模式实现一个简单的整数计算器
  • Visitor模式通过double dispatch(concreteElement.accept(Visitor visitor) → \rightarrow visitor.visit(concreteElement)),实现了Element与操作的解耦。
  • Visitor模式的具体介绍,可以参考之前的博客:《访问者模式(visitor)模式》

1. Antlr4的语法文件

1.1 定义语法文件

  • 创建Calculator.g4文件,使用Antlr4的语法规则定义一个简单的、支持整数的四则运算的计算器

    grammar Calculator; // combined grammars that can contain both lexical and parser rules
    // parser rules,以小写字母开头
    prog: stat+; // 允许一次执行多个表达式
    
    // 定义语句,在每个可选方案后面,使用#开头定义label
    stat: expr EOF # PrintExpr
        | ID '=' expr EOF # Assign
        ;
    
    // 定义表达式,支持加减乘除、括号
    expr: expr op=(MUL|DIV) expr # MulDiv
        | expr op=(ADD|SUB) expr # AddSub
        | INT # Constant
        | ID # Variable
        | '(' expr ')' # Parentheses
        ;
    
    // lexer rules,以大写字母开头,一般全为大写
    // 定义运算符
    MUL: '*';
    DIV: '/';
    ADD: '+';
    SUB: '-';
    
    // 定义常量、变量
    INT: [0-9]+;
    ID: [a-zA-Z]+;
    
    // 定义可以skip的换行符、tab等
    WS: [ \t\r\n]+ -> skip;
    

1.2 编译语法文件,生成Java代码

  • 使用IDEA的antlr4插件,编译Calculator.g4,生成对应的Java代码
    3. 使用Antlr4的Visitor模式实现一个简单的整数计算器_第1张图片
  • 最终将在src/main/java/com/sunrise/calculator目录下生成如下文件:
    3. 使用Antlr4的Visitor模式实现一个简单的整数计算器_第2张图片
  • 基于visitor模式实现计算器,主要通过继承并重写CalculatorBaseVisitor中的方法来实现

2. 重要的类或接口

  • 在介绍如何基于visitor模式实现计算器前,先简单介绍将要使用到的重要类或接口
  • 如果读者觉得铺垫太多,可以直接跳到第3小节,Visitor模式的代码实现

2.1 visitor接口或类

  • Calculator.g4编译后,有两个以Visitor为结尾的Java文件:CalculatorVisitor接口、CalculatorBaseVisitor

CalculatorVisitor接口

  • CalculatorVisitor接口的代码如下:

    • 继承了ParseTreeVisitor接口,并为Calculator.g4中不同parse rule对应的parse tree,创建了相应的visit()方法
    • 泛型通配符T,表示visit操作的返回值类型,如果没有返回值,可以使用Void作为返回值类型
    public interface CalculatorVisitor<T> extends ParseTreeVisitor<T> {
    	// 访问由CalculatorParser.prog()方法生成的parse tree,即访问prog这个parser rule对应的parse tree
    	T visitProg(CalculatorParser.ProgContext ctx);
    	// 访问CalculatorParser.stat()方法生成的、label为PrintExpr的解析树
    	T visitPrintExpr(CalculatorParser.PrintExprContext ctx);
    	// 访问CalculatorParser.stat()方法生成的、label为Assign的解析树
    	T vi,itAssign(CalculatorParser.AssignContext ctx);
    	// 访问CalculatorParser.expr()方法生成的、label为Variable的解析树
    	T visitVariable(CalculatorParser.VariableContext ctx);
    	// 访问CalculatorParser.expr()方法生成的、label为MulDiv的解析树
    	T visitMulDiv(CalculatorParser.MulDivContext ctx);
    	// 访问CalculatorParser.expr()方法生成的、label为AddSub的解析树
    	T visitAddSub(CalculatorParser.AddSubContext ctx);
    	// 访问CalculatorParser.expr()方法生成的、label为Constant的解析树
    	T visitConstant(CalculatorParser.ConstantContext ctx);
    	// 访问CalculatorParser.expr()方法生成的、label为Parentheses的解析树
    	T visitParentheses(CalculatorParser.ParenthesesContext ctx);
    }
    
  • 注意: 使用Void作为返回值类型,方法的返回值应该定义为null,而不能像void方法一样,使用return;或者无return语句

    Callable<Void> callable = new Callable<Void>() {
        @Override
        public Void call() {
            System.out.println("Hello!");
            return null;
        }
    };
    

ParseTreeVisitor接口

  • 由antlr-runtime提供的、访问parse tree的基础接口,其中的每个方法都会返回类型为T的、用户自定义的操作结果(user-defined result of the operation

    public interface ParseTreeVisitor<T> {
    	// visit一棵parse tree的入口方法
    	T visit(ParseTree tree);
        // 访问一个节点的所有孩子节点,这里的孩子特指儿子节点,不包括孙子节点
        // parse tree由一系列的节点(RuleNode)组成,一个节点代表的不只有节点本身,还有其下的子节点
    	T visitChildren(RuleNode node);
    	// 访问terminal节点,是parse tree的叶子节点,也是词法解析后得到的tokens
    	T visitTerminal(TerminalNode node);
    	// 访问一个error节点
    	T visitErrorNode(ErrorNode node);
    
    }
    

抽象类AbstractParseTreeVisitor

  • AbstractParseTreeVisitor是一个抽象类,提供了ParseTreeVisitor接口的默认实现,关键点:

    • visit()方法:内含accept()方法,实现了double dispatch逻辑
    • visitChildren()方法:基于visitor模式、使用accept()方法实现对所有孩子节点的访问
    public abstract class AbstractParseTreeVisitor<T> implements ParseTreeVisitor<T> {
    	// 以visit模式访问parse tree时,通过ParseTree.accept(this) --> ParseTreeVisitor.visit(this)实现double dispatch
    	@Override
    	public T visit(ParseTree tree) {
    		return tree.accept(this);
    	}
    	
    	@Override
    	public T visitChildren(RuleNode node) {
    		T result = defaultResult(); // result的初始值为null
    		int n = node.getChildCount();
    		for (int i=0; i<n; i++) {
    			// 访问孩子节点前,先判断是否可以访问孩子节点。若返回false表示不能访问,则直接退出循环
    			if (!shouldVisitNextChild(node, result)) { 
    				break;
    			}
    			// 获取孩子节点,通过accept()方法,实现孩子节点及其所有子节点的访问,从而获得操作结果
    			ParseTree c = node.getChild(i);
    			T childResult = c.accept(this);
    			result = aggregateResult(result, childResult); // 聚合结果,默认将childResult最为最新的result
    		}
    
    		return result;
    	}
    
    	@Override
    	public T visitTerminal(TerminalNode node) {
    		return defaultResult();
    	}
    
    	@Override
    	public T visitErrorNode(ErrorNode node) {
    		return defaultResult();
    	}
    	
    	protected T defaultResult() {
    		return null;
    	}
    	// 一个RuleContext可能拥有多个孩子节点,需要对每个孩子节点的操作结果做聚合
    	protected T aggregateResult(T aggregate, T nextResult) {
    		return nextResult;
    	}
    	// 返回值true,表示可以继续访问孩子节点;false,则表示需要立即停止访问孩子节点
    	protected boolean shouldVisitNextChild(RuleNode node, T currentResult) {
    		return true;
    	}
    }
    
  • 疑问: AbstractParseTreeVisitor为何被定义为抽象类?

    • AbstractParseTreeVisitor对ParseTreeVisitor接口的所有方法都进行了实现,且没有定义任何的abstract方法,完全可以将其定义一个普通类
    • 不靠谱的猜测: AbstractParseTreeVisitor是所有Visitor类的父类,将其定义为抽象类完全OK

CalculatorBaseVisitor类

  • CalculatorBaseVisitor类,继承了抽象类AbstractParseTreeVisitor,并实现了CalculatorVisitor
  • 准确地说,CalculatorBaseVisitor基于AbstractParseTreeVisitor.visitChildren()方法,为CalculatorVisitor接口中所有的方法提供了一个默认实现
    @SuppressWarnings("CheckReturnValue")
    public class CalculatorBaseVisitor<T> extends AbstractParseTreeVisitor<T> implements CalculatorVisitor<T> {
    	@Override public T visitProg(CalculatorParser.ProgContext ctx) { return visitChildren(ctx); }
    	
    	@Override public T visitPrintExpr(CalculatorParser.PrintExprContext ctx) { return visitChildren(ctx); }
    	... // 其他方法的实现类似,这里不予展示
    }
    

Generated visitors implement this interface and the XVisitor interface for grammar X.

  • 这是ParseTreeVisitor接口注释中的原话,大意:基于grammar X生成的Visitor类,都将实现ParseTreeVisitor接口和对应的XVisitor接口
  • grammar Calculator为例:
    • Visitor类就是CalculatorBaseVisitor,XVisitor接口就是CalculatorVisitor接口
    • CalculatorBaseVisitor对ParseTreeVisitor接口的实现,则是依靠AbstractParseTreeVisitor这个抽象类完成的
      3. 使用Antlr4的Visitor模式实现一个简单的整数计算器_第3张图片

2.2 Parser

  • 介绍Visitor类时,出现的最多的则是CalculatorParser类,它继承了抽象类Parser,是实现parser rule解析的关键类
    public class CalculatorParser extends Parser 
    

2.2.1 ParserRuleContext

  • CalculatorParser类中包含多种ParserRuleContext,它们都是public static类型的inner class,如AddSubContextProgContext
    3. 使用Antlr4的Visitor模式实现一个简单的整数计算器_第4张图片
  • 每个parser rule或者加了label的rule element,都将对应一个ParserRuleContext
  • 或者说,每个ParserRuleContext都对应语法解析树中的parser rule或带标签的rule element节点
    3. 使用Antlr4的Visitor模式实现一个简单的整数计算器_第5张图片
  • 以ProgContext的层次图为例,ParserRuleContext的类层次关系如下:
    3. 使用Antlr4的Visitor模式实现一个简单的整数计算器_第6张图片

2.2.2 ParserRuleContext的关键方法

  • 以ProgContext为例,对应的prog规则只有一个rule element,因此未使用label

  • ProgContext的代码如下,内含获取child节点(StatContext)的getter方法,重写了RuleContext或ParserRuleContext中的一些关键方法

    @SuppressWarnings("CheckReturnValue")
    public static class ProgContext extends ParserRuleContext {
    	// 获取孩子节点的getter方法
    	public List<StatContext> stat() {
    		return getRuleContexts(StatContext.class);
    	}
    	public StatContext stat(int i) {
    		return getRuleContext(StatContext.class,i);
    	}
    	public ProgContext(ParserRuleContext parent, int invokingState) {
    		super(parent, invokingState);
    	}
    	// 重写RuleContext.getRuleIndex()方法,返回prog的index,值为0
    	@Override public int getRuleIndex() { return RULE_prog; }
    	// 重写ParserRuleContext中,与listener模式有关的、进入或退出rule的方法
    	@Override
    	public void enterRule(ParseTreeListener listener) {
    		if ( listener instanceof CalculatorListener ) ((CalculatorListener)listener).enterProg(this);
    	}
    	@Override
    	public void exitRule(ParseTreeListener listener) {
    		if ( listener instanceof CalculatorListener ) ((CalculatorListener)listener).exitProg(this);
    	}
    	// 重写RuleContext.accept()方法,是实现visitor模式的关键
    	@Override
    	public <T> T accept(ParseTreeVisitor<? extends T> visitor) {
    		if ( visitor instanceof CalculatorVisitor ) return ((CalculatorVisitor<? extends T>)visitor).visitProg(this);
    		else return visitor.visitChildren(this);
    	}
    }
    
  • 对于stat这种包含多个rule element,且使用了label的parser rule,会先基于rule创建一个Conext,然后再创建每个rule element的Context

    @SuppressWarnings("CheckReturnValue")
    public static class StatContext extends ParserRuleContext {
    	public StatContext(ParserRuleContext parent, int invokingState) {
    		super(parent, invokingState);
    	}
    	@Override public int getRuleIndex() { return RULE_stat; }
     
    	public StatContext() { }
    	public void copyFrom(StatContext ctx) {
    		super.copyFrom(ctx);
    	}
    }
    @SuppressWarnings("CheckReturnValue")
    public static class AssignContext extends StatContext {
    	public TerminalNode ID() { return getToken(CalculatorParser.ID, 0); }
    	public ExprContext expr() {
    		return getRuleContext(ExprContext.class,0);
    	}
    	public AssignContext(StatContext ctx) { copyFrom(ctx); }
    	@Override
    	public void enterRule(ParseTreeListener listener) {
    		if ( listener instanceof CalculatorListener ) ((CalculatorListener)listener).enterAssign(this);
    	}
    	@Override
    	public void exitRule(ParseTreeListener listener) {
    		if ( listener instanceof CalculatorListener ) ((CalculatorListener)listener).exitAssign(this);
    	}
    	@Override
    	public <T> T accept(ParseTreeVisitor<? extends T> visitor) {
    		if ( visitor instanceof CalculatorVisitor ) return ((CalculatorVisitor<? extends T>)visitor).visitAssign(this);
    		else return visitor.visitChildren(this);
    	}
    }
    

2.3 TerminalNode

  • 所谓TerminalNode,就是parse tree的叶子节点,也是经过词法解析后得到的一系列tokens
    3. 使用Antlr4的Visitor模式实现一个简单的整数计算器_第7张图片

  • 这点从CalculatorParser中ParserRuleContext的定义也能看出
    3. 使用Antlr4的Visitor模式实现一个简单的整数计算器_第8张图片

  • 按照某种方式遍历parse tree,最终将访问TerminalNode,并从TerminalNode获取一些需要的信息或值

3. 基于visitor模式实现计算器

3.1 为什么需要自定义visitor ?

  • 使用stat这个paser rule,构建1+2对应的parse tree

  • 按照箭头所示的方向遍历这棵树,需要从TerminalNode获取常量的值和运算符,才能完成表达式的计算
    3. 使用Antlr4的Visitor模式实现一个简单的整数计算器_第9张图片

  • 然而,AbstractParseTreeVisitorvisitTerminal()方法的默认实现,是直接返回null

  • 若使用visit()方法对上面的parse tree进行访问,按照CalculatorBaseVisitor的访问逻辑,最终TerminalNode均为null,更别提按照语义进行加法计算

    // 遍历stat对应的语法解析树
    CalculatorBaseVisitor<Integer> visitor = new CalculatorBaseVisitor<>();
    visitor.visit(stat);
    
  • 因此,想要实现计算器逻辑,则必须按照实际需求重写CalculatorBaseVisitor中的方法

3.2 自定义visitor

  • 继承CalculatorBaseVisitor,并设置泛型类型为Integer,之后对所有ParserRuleContext的访问,都将返回Integer类型的值

    public class CalculatorVisitorImpl extends CalculatorBaseVisitor<Integer> {
        private final HashMap<String, Integer> variables = new HashMap<>();
    
        // 无需重写该方法,多个孩子节点,通过CalculatorBaseVisitor.visitProg() --> AbstractParseTreeVisitor.visitChildren()遍历整棵树
        @Override
        public Integer visitProg(CalculatorParser.ProgContext ctx) {
            return super.visitProg(ctx);
        }
    
        @Override
        public Integer visitPrintExpr(CalculatorParser.PrintExprContext ctx) {
            // 通过visit()遍历ExprContext,获取表达式的值并打印计算结果
            Integer result = visit(ctx.expr());
            // 如果为constant类型的表达式,则直接打印;否则,打印原始的表达式或变量名
            if (ctx.expr() instanceof CalculatorParser.ConstantContext) {
                System.out.println(result);
            } else {
                System.out.printf("%s = %d\n", ctx.expr().getText(), result);
            }
            return result;
        }
    
        @Override
        public Integer visitAssign(CalculatorParser.AssignContext ctx) {
            // 访问ID类型的TerminalNode,获取变量名
            String variable = ctx.ID().getText();
            // 通过visit()遍历ExprContext,获取表达式的值,从而为变量赋值
            Integer value = visit(ctx.expr());
            variables.put(variable, value);
            return value;
        }
    
        @Override
        public Integer visitVariable(CalculatorParser.VariableContext ctx) {
            String variable = ctx.getText();
            // 从内存中获取变量的值,没有返回0
            return variables.getOrDefault(variable, 0);
        }
    
        @Override
        public Integer visitMulDiv(CalculatorParser.MulDivContext ctx) {
            // 通过visit()遍历左右两个ExprContext,从而获取左右操作数的值
            Integer left = visit(ctx.expr(0));
            Integer right = visit(ctx.expr(1));
            // 根据运算符,进行相应的计算并返回计算结果
            if (ctx.op.getType() == CalculatorParser.MUL) {
                return left * right;
            }
            return left / right;
        }
    
        @Override
        public Integer visitAddSub(CalculatorParser.AddSubContext ctx) {
            Integer left = visit(ctx.expr(0));
            Integer right = visit(ctx.expr(1));
            if (ctx.op.getType() == CalculatorParser.ADD) {
                return left + right;
            }
            return left - right;
        }
    
        @Override
        public Integer visitConstant(CalculatorParser.ConstantContext ctx) {
            // 直接获取常量的值,原本为String,通过Integer.valueOf()转为Integer
            return Integer.valueOf(ctx.INT().getText());
        }
    
        @Override
        public Integer visitParentheses(CalculatorParser.ParenthesesContext ctx) {
            // 括号表达式,需要返回括号内表达式的值。通过visit()访问ExprContext,获取表达式的值
            return visit(ctx.expr());
        }
    }
    

3.3 使用自定义的visitor遍历parse tree

  • 使用visitor遍历parse tree,需要分为以下几步:

    1. 输入字符流,CalculatorLexer将字符流转为tokenStream
    2. 输入tokenStream,CalculatorParser基于指定的parser rule,将tokenStream转为对应的parse tree
    3. 使用自定义的visitor遍历stat对应的parse tree,该parse tree对应的操作可能是为变量赋值,也可能是打印表达式的计算结果
    public static void main(String[] args) {
        // 多个stat,只能识别到第一个stat,属于PrintExpr
        String input = "1 + 2 * 3\n"
                + "b = (5 - 2) / 2\n"
                + "c = a + b\n"
                + "c\n";
        // 1. 词法分析,解析出token;这时tokenStream中还未填充token,因为词法分析由语法分析触发
        // Normally, the Parser is responsible for initiating the lexing of the input stream
        CharStream charStream = CharStreams.fromString(input);
        CalculatorLexer lexer = new CalculatorLexer(charStream);
        CommonTokenStream tokenStream = new CommonTokenStream(lexer);
    
        // 2. 基于tokenStream进行语法分析,得到parse tree
        CalculatorParser parser = new CalculatorParser(tokenStream);
        ParseTree stat = parser.stat();
    	// 打印语法解析树,但并不直观
        System.out.printf("stat对应的语法解析树如下:\n%s\n", stat.toStringTree(parser));
    
        // 3. 使用自定义的visitor遍历parse tree
        CalculatorBaseVisitor<Integer> visitor = new CalculatorVisitorImpl();
        visitor.visit(stat);
    }
    
  • 执行结果如下:
    3. 使用Antlr4的Visitor模式实现一个简单的整数计算器_第10张图片

  • 对输入字符流进行语法分析后,只能识别出1 + 2 * 3这个PrintExpr,与ANTLR Preview得到的结果一致

  • 同时,对parse tree的visit遍历,打印的表达式计算结果也符合预期

  • 注意: main()是基于stat对字符流进行分析,得到是一个不太完善的计算器,要想得到一个功能完备的计算器,可以基于prog对字符流进行分析

    CalculatorBaseVisitor<Integer> visitor = new CalculatorVisitorImpl();
    visitor.visit(prog);
    

3.4 visitor模式的遍历过程详解

  • 使用visitor模式遍历stat规则的parse tree,其大致遍历路径如下图所示,实际就是对一棵树进行DFS(深度优先搜索)
    3. 使用Antlr4的Visitor模式实现一个简单的整数计算器_第11张图片
  • 完整的遍历路径如图所示,可见parse tree的遍历离不开以下要素:
    1. visit()方法,确切地说是AbstractParseTreeVisitor.visit()方法,是parse tree遍历的入口方法
      • parse tree可能是一棵完整的tree,如上图以PrintExprContext开始的tree
      • 也可能是一棵子树,如上图以MulDivContext开始的tree
    2. ctx.accept(visitor) → \rightarrow visitor.visitCtx(ctx)实现了double dispatch,将对某个ParserRuleContext(简写为Ctx)的visit导向了具体的Visitor实现
    3. visitCtx()方法中,会根据情况选择visit(孩子节点),还是visitChildren(),以实现树的DFS
      • 如果Ctx需要遍历的孩子节点个数是固定的,如MulDivContext有2个孩子节点需要遍历:左expr、右expr,这时直接visit(孩子节点)即可
      • 如果Ctx的孩子节点个数不固定,则需要使用visitChildren()依次遍历每个孩子节点。对ProgContext的孩子节点的遍历,实际就是采用的visitChildren()方法
        3. 使用Antlr4的Visitor模式实现一个简单的整数计算器_第12张图片
  • 注意: 上图,对MulDivContext的visit流程有所省略,直接到了visit孩子节点这一步

3.5 对ProgContext的visit

  • 输入字符流如左边所示,使用prog规则进行解析,将获得如右边所示的parse tree
    3. 使用Antlr4的Visitor模式实现一个简单的整数计算器_第13张图片
  • 这时,visit该parse tree,在visit根节点ProgContext时,最终将调用visitChildren()方法
    3. 使用Antlr4的Visitor模式实现一个简单的整数计算器_第14张图片

4. 后记

  • 使用visitor模式实现计算器,有如下感受:
    • 所谓的ParseTree实际是包含父节点、子节点和payload的tree node,这些tree node相互关联形成了parse tree
    • 基于Antlr4的visitor模式实现某个需求,关键点:继承BaseVisitor并按需重写其中的方法
    • visit一棵parse tree的过程非常复杂,但离不开入口方法visit()accept() → \rightarrow visitCtx()的double dispatch,visitCtx() → \rightarrow visit()visitCtx() → \rightarrow visitChildren()的DFS链。
    • 结合ANLTR Preview提供的parse tree,并以debug方式了解遍历过程,是最佳实践
  • TODO:
    • 学习如何打印词法分析得到的tokens,并体会Normally, the Parser is responsible for initiating the lexing of the input stream这句话的含义
    • 学习如何使用listener模式实现一个简单的整数计算器

你可能感兴趣的:(ANTLR,antlr)