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把A和B都打印出来了!原因就在开头说的那句话:ParseTreeWalker不管条件是true还是false,都会无条件地把语法树上的每一个节点走一遍,包括if分支的block和else分支的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之上的根本原因。