技术预计阅读 14 分钟5439 字0 次浏览

Antlr4系列⑬:Listener模式对比

用Listener重新实现部分能力、实测对比if/else下Listener与Visitor的行为差异

目录

本系列从第一篇开始,一直用的都是antlr4提供的两种业务逻辑实现方式之一——访问器(Visitor)。第①篇文章末尾其实提到过,antlr4还提供了另一种方式:监听器(Listener)。这篇文章我们就用Listener重新实现一部分语言能力,通过实际跑起来的例子,直观地看看这两种模式的核心区别,以及为什么这个系列从头到尾都选择了Visitor。

一、Listener和Visitor的核心区别

  • Visitor:由你自己的代码调用visit(ctx)来决定"要不要访问某个子节点、访问哪个、什么时候访问、访问完之后返回什么"。整个遍历过程的控制权在你手里。
  • Listener:由antlr4提供的ParseTreeWalker来遍历整棵语法树,规则是固定的——从根节点开始,按照子节点在树上的顺序,无条件地把每一个节点都走一遍,进入节点时调用enterXxx,离开节点时调用exitXxx。这两个方法都是void,没有返回值,Listener本身也没有任何办法告诉ParseTreeWalker"这个子树不用走了"。

这个区别听起来有点抽象,我们直接上代码看会更清楚。

二、Listener能做什么:用栈模拟"返回值"

enterXxx/exitXxx没有返回值,但我们又需要把子节点算出来的中间结果传递给父节点(比如两个数相加,得先知道这两个数分别是多少)。经典的做法是用一个栈来模拟返回值的传递ParseTreeWalker对语法树做的是后序遍历,也就是说一个节点的所有子节点一定会先于这个节点被exit。等父节点的exitXxx被调用时,它的子节点早就已经把各自的计算结果压进栈里了,直接按顺序弹出来用即可。

public class MyRuleSetListener extends RuleSetBaseListener {

    private final Deque<VisitorResult> stack = new ArrayDeque<>();

    public VisitorResult getResult() {
        return stack.peek();
    }

    @Override
    public void exitNumber(RuleSetParser.NumberContext ctx) {
        stack.push(VisitorResult.value(Double.parseDouble(ctx.getText())));
    }

    @Override
    public void exitStringValue(RuleSetParser.StringValueContext ctx) {
        String text = ctx.getText();
        stack.push(VisitorResult.value(text.substring(1, text.length() - 1)));
    }

    @Override
    public void exitBoolValue(RuleSetParser.BoolValueContext ctx) {
        stack.push(VisitorResult.value(Boolean.parseBoolean(ctx.getText())));
    }

    @Override
    public void exitMulAndDiv(RuleSetParser.MulAndDivContext ctx) {
        // 右操作数在左操作数之后被visit,所以先出栈的是右操作数
        double right = stack.pop().getNumber();
        double left = stack.pop().getNumber();
        if (ctx.opt.getType() == RuleSetLexer.MUL) {
            stack.push(VisitorResult.value(left * right));
        } else {
            stack.push(VisitorResult.value(left / right));
        }
    }

    @Override
    public void exitAddAndSub(RuleSetParser.AddAndSubContext ctx) {
        VisitorResult right = stack.pop();
        VisitorResult left = stack.pop();
        if (ctx.opt.getType() == RuleSetLexer.SUB) {
            stack.push(VisitorResult.value(left.getNumber() - right.getNumber()));
            return;
        }
        if (left.isVarchar() || right.isVarchar()) {
            stack.push(VisitorResult.value(left.toString() + right.toString()));
        } else {
            stack.push(VisitorResult.value(left.getNumber() + right.getNumber()));
        }
    }

    /**
     * print在Listener模式里能做到的极限就是这样:把栈顶(也就是括号里那个calcu的结果)打印出来。
     * 至于要不要打印、打印哪个分支——Listener完全说了不算,ParseTreeWalker会无条件地把语法树上
     * 每一个print都走一遍,不管它是不是应该在这次执行中被跳过
     */
    @Override
    public void exitPrintValue(RuleSetParser.PrintValueContext ctx) {
        System.out.println(stack.pop());
    }
}

注意这里括号parens)不需要任何处理——括号内的calcu在这之前已经exit过了,结果已经在栈顶,括号本身对结果没有任何影响,什么都不用做。这份代码没有继承任何东西和Scope相关的逻辑,因为一旦涉及到变量读写、函数调用这些需要维护状态的能力,Listener能做的事和Visitor其实也差不太多(无非是把状态维护逻辑搬到Listener类的字段里),真正的差异不在"能不能维护状态",而在下面这一节。

拿两个纯计算的表达式验证一下:

String numExpression = "(3 + 4) * 2 - 5";
System.out.println("Listener计算结果: " + walkCalcu(numExpression));

String strExpression = "\"Hello, \" + \"Listener\"";
System.out.println("Listener计算结果: " + walkCalcu(strExpression));

其中walkCalcu直接用parser.calcu()单独解析一段calcu规则(这个技巧在第⑨篇文章设置条件断点时用过),然后用ParseTreeWalker.DEFAULT.walk(listener, calcuTree)触发遍历:

private static VisitorResult walkCalcu(String expression) {
    RuleSetLexer lexer = new RuleSetLexer(CharStreams.fromString(expression));
    RuleSetParser parser = new RuleSetParser(new CommonTokenStream(lexer));
    RuleSetParser.CalcuContext calcuTree = parser.calcu();
    MyRuleSetListener listener = new MyRuleSetListener();
    ParseTreeWalker.DEFAULT.walk(listener, calcuTree);
    return listener.getResult();
}

执行结果:

表达式: (3 + 4) * 2 - 5
Listener计算结果: 9.0

表达式: "Hello, " + "Listener"
Listener计算结果: Hello, Listener

纯计算、没有分支的场景下,Listener和Visitor算出来的结果完全一样。

三、Listener做不到什么:无法"跳过"一个分支

现在来看真正的核心差异。写一段最简单的if/else

if (false) {
    print("A - if分支,条件为false,不应该被打印")
} else {
    print("B - else分支,应该被打印")
}

按正常的语义,false让条件不成立,应该只执行else分支,只打印B。分别用Listener和Visitor跑同一段代码:

RuleSetParser.ProgContext prog = parser.prog();

System.out.println("[Listener方式的执行结果]");
MyRuleSetListener listener = new MyRuleSetListener();
ParseTreeWalker.DEFAULT.walk(listener, prog);

System.out.println("[Visitor方式的执行结果]");
MyRuleSetVisitor visitor = new MyRuleSetVisitor();
visitor.visit(prog);

执行结果如下:

[Listener方式的执行结果]
A - if分支,条件为false,不应该被打印
B - else分支,应该被打印

[Visitor方式的执行结果]
B - else分支,应该被打印

Listener把AB打印出来了!原因就在开头说的那句话:ParseTreeWalker不管条件是true还是false,都会无条件地把语法树上的每一个节点走一遍,包括if分支的blockelse分支的block,我们的MyRuleSetListener压根没有、也没办法拿到"条件求值结果"来决定该不该执行exitPrintValue里的打印逻辑——cond节点即使被enter/exit了,Listener也只能被动地"看着"它过去,不能拿这个结果去控制接下来该不该继续走某个子树。

反观我们从第①篇用到现在的Visitor实现,visitIfStmt里的逻辑是这样的(这是第④篇文章实现的代码):

@Override
public VisitorResult visitIfStmt(RuleSetParser.IfStmtContext ctx) {
    List<RuleSetParser.CondContext> conds = ctx.cond();
    List<RuleSetParser.BlockContext> blocks = ctx.block();
    for (int i = 0; i < conds.size(); i++) {
        if (visit(conds.get(i)).getBool()) {   // 关键:条件不满足,压根不会去visit对应的block
            return visit(blocks.get(i));
        }
    }
    if (blocks.size() > conds.size()) {
        return visit(blocks.get(blocks.size() - 1));
    }
    return VisitorResult.nil();
}

visit(conds.get(i)).getBool()先求出条件的真假,只有为真时才会调用visit(blocks.get(i))——根本不满足条件的那个block,Visitor连进都不会进去,这才是if/else应该有的语义。while循环、break/continue、函数调用/return、短路的&&/||,本系列实现过的所有控制流能力,无一例外都依赖这种"由代码自己决定要不要访问某个子节点"的能力,而这恰恰是Listener从设计上就不具备的。

四、什么时候该用Listener

看到这里可能会觉得Listener像是个"残缺"的方案,但它并不是为了实现解释器而设计的,它更适合的场景是:需要把语法树完整地过一遍、单纯地收集信息或者做一些互不依赖的检查,不需要根据某个节点的值决定要不要处理其他节点。典型的例子:

  • 静态检查/语法风格检查(linter):比如扫描代码里有没有定义了却从来没用过的变量,需要把每一个变量的定义和使用都看一遍,和"运行到哪一行"完全无关。
  • 代码格式化、语法高亮:同样需要遍历到每一个token/节点,按节点类型输出对应的格式,不涉及"选择性执行"。
  • 生成符号表、做简单的重命名/代码转换:收集信息或者做等价的文本替换,本质上也是要把每个相关节点都处理一遍。

另外,多个不相关的Listener还可以用同一个ParseTreeWalker合并在一次遍历里跑(antlr4支持给walk传入组合多个Listener的场景),如果你需要同时做几件互不相关的静态分析,用Listener可以避免多次遍历同一棵树,这是Visitor不太容易做到的一个优势——但这和"实现一门带控制流的语言"是两个完全不同的需求。

一句话总结:需要"选择性地""带着状态和结果"去驱动树的遍历(比如解释执行一门语言)用Visitor;只需要"完整地"遍历一遍树、单纯做观察或收集用Listener。这也是本系列从第一篇到现在,实现语言解释器的核心逻辑都构建在MyRuleSetVisitor之上、而不是Listener之上的根本原因。

花开空白

西安

相关文章

评论(0)

还没有评论,来抢沙发吧

发表评论