如果你已经照着系列第一篇把环境装好、跑通了第一个 Hello 级别的语法文件,那么此刻你大概率处于一个很微妙的阶段:Demo 能跑,生成的代码也能看个大概,但一翻开官方文档,或者看一眼别人项目里的 .g4 文件,马上又回到满脑子问号的状态。这是我见过的每个 ANTLR4 新手都会经历的坎,我自己当年也没少在这里耗时间。
这篇文章专门用来拆解 ANTLR4 的基本概念。我会用一份经典的表达式语法 Calc 当贯穿全文的例子,把字符流、词法分析、Token 流、语法分析、语法树、Listener/Visitor、左递归与运算优先级这几件事串起来讲。读完你应该能做到:拿到任何一份不复杂的 .g4 文件,能说出每个片段的作用;知道生成的那堆 Java 类各自是什么角色;也能判断自己下一个需求到底该用 Listener 还是 Visitor。如果你是刚跑完第一个 Demo、准备真正开始理解 ANTLR4 的读者,这篇就是给你准备的。
1. 从"Demo能跑"到"看懂门道":ANTLR4的处理链路先装进脑子
1.1 解析器生成器到底替你省了什么
先说一个大前提:我们为什么要用 ANTLR4?因为它把"写解析器"这件事从"手写一堆状态机、递归下降代码"变成了"写一份语法描述文件,然后让工具生成代码"。正则表达式只能处理"平面"的匹配,遇到嵌套结构——JSON、SQL、编程语言、配置文件里的表达式——就无能为力了,因为正则没有栈,记不住"我现在在第几层括号里"。而手写递归下降解析器也不是不行,但代码量大、边界情况多、维护起来很痛苦。
ANTLR4 的定位是解析器生成器。你给它一份 .g4 语法文件,它帮你生成一个能识别这种语言的程序,支持的 target 包括 Java、Python、JavaScript、Go、C# 等。这里有个关键的思维转换:你写的不是"解析逻辑",而是"这门语言的语法规范"。解析逻辑是生成出来的,你真正要做的是把语法描述清楚。
1.2 四段流水线,每一段的职责都不同
ANTLR4 的运行过程可以简化成一条四段流水线:
字符流 -> 词法分析器(Lexer) -> Token流 -> 语法分析器(Parser) -> 语法树(ParseTree) -> Listener/Visitor业务逻辑每一段的输入输出和职责都不一样:
- 字符流(CharStream):就是原始文本,ANTLR 会顺带记录文件名、行号、列号,这是后面报错定位的基础。
- 词法分析器(Lexer):按照词法规则,把字符流切成一个个 Token。它关心的是"这个单词是什么类型",比如
123是数字、abc是标识符、+是加号。 - 语法分析器(Parser):按照语法规则,把一串 Token 组织成一棵有结构的树。它关心的是"这些 Token 按什么顺序组合是合法的"。
- 语法树(ParseTree):是分析的最终产物,但它本身不带任何语义。这棵树到底表示什么意思——是求值还是翻译成别的语言——完全由你在最后一步通过 Listener 或 Visitor 实现。
新手最容易犯的一个认知错误,是以为 ANTLR 生成的 Parser 会帮你"理解"输入内容。不会。它只负责回答"这段输入符不符合语法、如果符合,结构是什么样的",至于"这段代码算出来的结果是多少",那是你自己在遍历语法树时的事。
1.3 为什么语义一定要留到最后一步
词法分析管"字面",语法分析管"结构",语义分析完全放在树遍历里做——这个分层是 ANTLR4 贯穿始终的设计哲学。它的实际收益是:词法规则和语法规则可以各自独立演进。比如你后面想给语言加注释语法,只需要动词法部分;想新增一种语句结构,只需要动语法部分,两者互不干扰。
用生活化的类比来说,就像读英文句子:词法分析是把句子拆成一个个单词和标点,语法分析是分出主谓宾和从句结构,最后"理解句子的意思"是在脑子里对结构做处理。如果你把这三步全揉在一起,写起来也许一时爽,但后面每加一个新语法特性都要重新理一遍纠缠的逻辑,调试体验会非常崩溃。
所以这一篇后续所有概念,都是围绕这条流水线展开的:词法规则和语法规则负责前两段,Token 流是中间的交接物,语法树是最终产物,Listener/Visitor 是你在产物上干活的入口。
2. 词法规则与语法规则:大小写约定不是风格问题
2.1 词法规则:给字符流"切词"
打开一份 .g4 文件,你最先注意到的是规则名字的大写和小写。这不是个人风格,而是 ANTLR 的硬性约定:以大写字母开头的规则是词法规则,以小写字母开头的规则是语法规则。
词法规则描述的是"一个单词由哪些字符构成"。比如:
IF: 'if' ; ID: [a-zA-Z]+ ; INT: [0-9]+ ; WS: [ \t\r\n]+ -> skip ;每条词法规则定义一种 Token 类型。Lexer 扫描字符流时,从左往右,尽量吃进最长的匹配(这叫最长匹配原则);如果两条规则都能匹配同样长度的文本,则先定义的那条优先。这个顺序问题很关键:比如关键字if和标识符规则ID,如果ID写在前面,那么输入if就会被识别成标识符而不是关键字。所以关键字规则必须放在ID之前。
词法规则里还有fragment关键字,用来声明"纯片段"规则。fragment 规则本身不产生 Token,只是给其他词法规则复用的积木:
fragment DIGIT: [0-9] ; INT: DIGIT+ ;这里DIGIT不会成为一个 Token 类型,INT才是。这种拆法在数字、字符串字面量等规则比较复杂时尤其好用。
2.2 语法规则:给Token流"搭结构"
以小写字母开头的语法规则,描述的是"一组 Token 按什么顺序组合是合法的结构"。比如:
prog: stat+ ; stat: ID EQ expr NEWLINE # AssignStat | expr NEWLINE # ExprStat ;stat+表示一个或多个语句;ID EQ expr NEWLINE表示"标识符、等号、表达式、换行"依次出现;|表示可选分支。语法规则里可以引用 Token(如ID、EQ)、子规则(如expr)、字面量,而且规则本身可以递归——这正是它能表达任意嵌套结构的根本原因。
为什么大小写能区分规则类型?因为 ANTLR 根据首字母决定这条规则该交给 Lexer 还是 Parser 处理。你把规则名首字母写错,ANTLR 要么报错,要么会把一条本该是语法规则的规则当成词法规则,行为完全跑偏。所以这不是"代码风格偏好",是语法本身的一部分。
2.3 字符串字面量与隐式Token:图方便之前先想清楚
在语法规则里,你可以直接写单引号字面量,比如'=':
stat: ID '=' expr NEWLINE ;ANTLR 会自动为这个字面量生成一个隐式 Token 类型,名字通常长成T__0这种,非常难读。少量使用没问题,但一旦你还需要在词法规则里定义同名的 Token,或者同一个字面量在多个语法规则里重复出现,就会触发 "implicit definition of token" 之类的报错或者让人困惑的行为。
我的建议是从第一份语法开始就养成习惯:运算符、关键字这类你会在代码里引用的符号,全部显式定义成词法规则,比如:
EQ: '=' ; MUL: '*' ; DIV: '/' ; ADD: '+' ; SUB: '-' ;然后语法规则里写ID EQ expr,而不是ID '=' expr。这样 Token 类型有名字、可读、可复用,后面写 Listener/Visitor 时也能直接用ctx.EQ()这种访问器。字面量留给那种"纯粹是标点、不需要在代码里单独引用"的场景。
顺便提醒一个词法设计上的坑:像换行符这种有语法意义的字符,不要无脑 skip。拿 Calc 这个例子来说,stat规则依赖NEWLINE来结束一条语句,所以换行必须作为一个真正的 Token 保留下来;只有空格和 Tab 这种纯粹的分隔符才适合 skip。
2.4 给分支起名字:labeled alternatives是给代码铺路
语法规则的分支默认是匿名的,ANTLR 会为整条规则生成一个 Context 类,你在 Listener/Visitor 里要判断"当前匹配的是哪个分支",只能靠数子节点或者判断运算符文本来猜,很痛苦。解决办法是给分支加标签,也就是 labeled alternatives:
expr: expr (MUL | DIV) expr # MulDiv | expr (ADD | SUB) expr # AddSub | INT # Int | ID # Id ;每个#后面跟一个标签名,ANTLR 会为每个带标签的分支生成一个独立的 Context 子类:MulDivContext、AddSubContext、IntContext、IdContext,它们都继承ExprContext。Listener 里会出现enterMulDiv、enterAddSub这样的独立回调方法,Visitor 里会出现visitMulDiv、visitAddSub这样的独立方法,代码逻辑一下子清晰很多。
我把 labeled alternatives 放在基本概念里讲,是因为它在入门阶段太容易被忽略,而它恰恰是最能提升编码体验的一个特性。你后面写的每个实际语法,几乎都离不开它。
3. Token流:那个夹在词法和语法之间的"中间人"
3.1 一个Token身上挂了多少信息
词法分析的结果不是简单地把字符串切成一段段,而是产出一个 Token 对象。用上面的 Calc 语法处理输入a = 1\n,把 Token 流打印出来长这样:
[@0,0:0='a',<ID>,1:0] [@1,2:2='=',<EQ>,1:2] [@2,4:4='1',<INT>,1:4] [@3,5:5='\n',<NEWLINE>,1:5]这行输出看着密,拆开其实很直观:
@0是 Token 在流里的下标;0:0表示这个 Token 在原始字符流里从第 0 个字符到第 0 个字符;'a'是 Token 的文本;<ID>是 Token 类型名;1:0是行号和列号。
一个 Token 对象在接口层面的关键信息包括:getType()取类型编号、getText()取文本、getLine()和getCharPositionInLine()取位置、getChannel()取通道、getStartIndex()和getStopIndex()取字符流中的起止位置。类型编号本身是个 int,但 ANTLR 会维护一个词汇表(Vocabulary),你随时可以把 int 转成可读的类型名,这也是调试时最常用的功能之一。
3.2 skip和channel(HIDDEN):空白和注释到底该怎么处理
词法规则处理空白有两种常见姿势:-> skip和-> channel(HIDDEN)。
-> skip是直接把该 Token 丢弃,后面谁都不知道这里曾经有过空白或注释。适合空格、Tab 这种纯粹无意义的字符。
-> channel(HIDDEN)则是把这个 Token 留在流里,但放进一个隐藏通道。默认情况下 Parser 只消费默认通道的 Token,所以隐藏通道的内容对语法分析不可见,但它并没有真正消失。需要保留注释的场景——比如写代码格式化工具、代码生成器想保留用户原始注释、实现 IDE 的语法高亮——就应该用channel(HIDDEN),而不是简单 skip。
这背后是一个很实用的小知识:CommonTokenStream默认会把所有 Token(包括隐藏通道)缓存起来,Parser 只是在逻辑上"看不见"它们。如果你想做后面这些高级功能,这些 Token 随时可以再取出来用。
3.3 用工具把Token流"看"出来,比背概念管用
概念背再多,不如亲手看一次 Token 流。ANTLR4 自带的 TestRig(大家一般都叫它 grun)就是干这个的。配置好 classpath 之后,命令大概是:
grun Calc prog -tokens然后输入a = 1,回车,再按 Ctrl+D(Linux/macOS)或 Ctrl+Z(Windows)结束输入,你就会看到上面那几行 Token dump。-tree参数可以打印 LISP 风格的语法树,-gui会弹出一个 Swing 窗口,把树可视化出来。
IDEA 的 ANTLR4 插件也有类似的预览窗口,你在编辑器里写语法文件时,可以直接输入一段样例,它会同步展示 Token 流和语法树,还能用鼠标点击节点看对应关系,非常适合入门阶段建立"规则到产物"的映射感。
我自己的经验是,排查问题有一套固定的思路:先看 Token 流对不对。如果 Token 流里冒出了你没想到的 Token,或者你预期的 Token 没出现,那问题出在词法规则;如果 Token 流完全正确但语法树不对,再去查语法规则。绝大多数入门期的报错,用这个思路五分钟就能定位。
4. 生成代码里那一堆类,谁在干哪个活
4.1 一个.g4文件对应哪些产物
跑完 ANTLR 生成命令后,你会在目标目录看到一堆名字相似的文件。以Calc.g4为例:
| 文件 | 角色 | 生成条件 |
|---|---|---|
| CalcLexer.java | 词法分析器,负责字符流到 Token 流 | 默认生成 |
| CalcParser.java | 语法分析器,内部包含所有 Context 类 | 默认生成 |
| Calc.tokens / CalcLexer.tokens | Token 类型编号映射表 | 默认生成,跨语言/多语法协作时有用 |
| CalcListener.java / CalcBaseListener.java | 监听器接口和空实现 | 默认生成 |
| CalcVisitor.java / CalcBaseVisitor.java | 访问器接口和默认实现 | 需要加-visitor参数或构建工具里打开对应开关 |
注意两个容易忽略的点:一是默认只生成 Listener,不生成 Visitor。你需要在命令行加-visitor,或者在使用 Maven/Gradle 插件时设置generateVisitor = true,否则你写监听器时找不到 Visitor 相关类。二是所有 Context 子类都嵌套在CalcParser.java这个文件里,不是一个个单独文件。很多新手在 IDE 里点开CalcParser.java,看到里面密密麻麻的内部类就吓到了,其实你只需要关心自己用得到的那几个 Context。
4.2 启动代码为什么永远是那四行
无论语法多复杂,Java 侧的启动代码几乎永远是同一个模式:
CharStream input = CharStreams.fromFileName("test.calc"); CalcLexer lexer = new CalcLexer(input); CommonTokenStream tokens = new CommonTokenStream(lexer); CalcParser parser = new CalcParser(tokens); CalcParser.ProgContext tree = parser.prog();前四行就是把流水线串起来:字符流喂给 Lexer,Lexer 产出 Token 流,Token 流交给 Parser。唯一随语法变化的是最后一行——你要调用的"起始规则"方法,在 Calc 里是prog()。如果语法文件的起始规则叫别的名字,比如file_input或translationUnit,最后一行就相应换成parser.file_input()或parser.translationUnit()。
其他语言 target 的思路一样,只是 API 的写法略有差异。把这段启动代码理解透,后面写任何项目都只是在重复这个流程。
4.3 Context类:你写Listener/Visitor时的"节点手册"
语法树的每一个节点,在生成代码里都是一个 Context 对象。ANTLR 会为每条语法规则生成一个 Context 类,为每个带标签的分支生成子类,并为规则里引用的每个元素生成对应的访问器方法。
举个例子,stat: ID EQ expr NEWLINE # AssignStat这条带标签规则,生成的AssignStatContext里会有这些方法:
ID()返回TerminalNode,对应IDToken;EQ()返回TerminalNode,对应EQToken;expr()返回ExprContext,对应子规则引用;NEWLINE()返回TerminalNode,对应换行 Token。
TerminalNode是语法树的叶子节点,挂着一个 Token;ParserRuleContext是内部节点,对应某条规则。你在写 Listener/Visitor 时的绝大多数取值操作,都是通过 Context 类的方法完成的。
所以我的建议是:动手写任何业务逻辑之前,先在 IDE 里跳转到对应 Context 类的定义,花五分钟看看它提供了哪些访问器。别靠猜。养成这个习惯之后,效率会提升非常多。
有一个小技巧能进一步改善生成代码的可读性:给规则元素起名字。比如把规则写成lhs=expr '=' rhs=expr,生成的 Context 里就会有lhs和rhs对应的访问器。对于复杂规则,这个命名能力很值钱。
4.4 语法树和AST不是一回事
这里值得澄清一个常见混淆:ANTLR4 生成的语法树(ParseTree)和编译器教材里说的抽象语法树(AST)不是同一个东西。语法树会完整保留所有 Token,包括括号、分号、逗号这些纯语法符号;AST 则通常会去掉这些符号,把节点合并成更精简的结构。
大多数 ANTLR4 项目其实不需要转 AST,直接拿语法树配合 Listener/Visitor 就够用了。只有在语法树确实太啰嗦、或者你需要一份独立于原语法的中间表示时,才值得自己构建 AST。入门阶段不用被"要不要转 AST"这个问题困扰,先用好语法树本身。
5. Listener与Visitor:遍历语法树的两种姿势怎么选
5.1 Listener:被动回调,适合"看一眼记个数"
Listener 的工作方式是你写一个继承CalcBaseListener的类,重写感兴趣的回调方法,然后交给一个 Walker 去遍历整棵树:
public class CountAssignListener extends CalcBaseListener { public int assignCount = 0; @Override public void enterAssignStat(CalcParser.AssignStatContext ctx) { assignCount++; } }使用时的代码长这样:
ParseTree tree = parser.prog(); CountAssignListener listener = new CountAssignListener(); ParseTreeWalker.DEFAULT.walk(listener, tree); System.out.println(listener.assignCount);ParseTreeWalker负责深度优先地遍历整棵树,每进入一个节点就调用对应的enterXxx方法,离开时调用exitXxx方法。你不需要自己写递归,也不需要管遍历顺序,只需要回答"到这个节点时我该干什么"。
Listener 的核心特征是回调方法没有返回值。你想从遍历里拿到结果,必须靠对象字段累积。所以它特别适合"统计信息、收集引用、做只读检查"这类场景——进去看一眼,记个数,完事。
顺带提一个很实用的点:exitXxx方法天然对应后序遍历时机。比如你想在离开一个作用域的时候检查"这个作用域里的变量是否都声明了",写在exitXxx里就正合适。
5.2 Visitor:自己控制递归,结果可以往上带
Visitor 是另一种姿势:你继承CalcBaseVisitor<T>,重写visitXxx方法,T 是返回值类型。它和 Listener 最本质的区别是——遍历的控制权在你手里。默认情况下visitChildren会继续走访子节点,但如果你不调用它,子树就不会被访问;反过来,你调用它之后,子节点的返回值也由你自己决定怎么聚合。
看一个经典场景:表达式求值。
public class EvalVisitor extends CalcBaseVisitor<Integer> { @Override public Integer visitMulDiv(CalcParser.MulDivContext ctx) { int left = visit(ctx.expr(0)); int right = visit(ctx.expr(1)); return ctx.MUL() != null ? left * right : left / right; } @Override public Integer visitAddSub(CalcParser.AddSubContext ctx) { int left = visit(ctx.expr(0)); int right = visit(ctx.expr(1)); return ctx.ADD() != null ? left + right : left - right; } @Override public Integer visitInt(CalcParser.IntContext ctx) { return Integer.valueOf(ctx.INT().getText()); } }调用方式也很直接:
Integer result = new EvalVisitor().visit(tree);每个节点把子节点的值求出来,再向上返回,最终根节点返回整个表达式的结果。这就是"结果可以往上带"的含义。Visitor 适合构建 AST、生成字节码、做类型检查这类需要聚合子节点结果的场景。
必须提醒一个新手高频 bug:在 Visitor 方法里忘记调用visitChildren,导致子树只走了一部分;或者调用了visitChildren却忽略了它的返回值。这两种情况都会让结果莫名其妙。写 Visitor 时,每一个分支都要想清楚"我到底要不要继续往下走、走完之后拿返回值干什么"。
5.3 选型就回答三个问题
怎么在 Listener 和 Visitor 之间选?我一般让团队里的新人回答三个问题:
- 我需要方法的返回值吗?需要,选 Visitor;不需要,Listener 更省心。
- 我需要控制遍历顺序或剪枝吗?需要,选 Visitor;纯线性收集信息,Listener 够用。
- 我有多个彼此独立的关注点吗?有,Listener 天然适合拆分——符号表、类型检查、统计,各写一个 Listener,各走一遍遍历就行,性能损失通常可以接受。
附加一个经验:Listener 的exitXxx天然是后序时机,而 Visitor 需要自己在visitChildren返回后再做聚合。如果你的核心逻辑是"先处理孩子,再处理自己",两种都能用;但如果你特别在意代码的直观性,Visitor 的后序表达更显式。
6. 左递归与运算优先级:ANTLR4最让我服气的一块设计
6.1 左递归在传统递归下降解析器里是禁区
expr: expr ADD expr | INT这种写法叫直接左递归:规则的第一个元素就调用了自身。如果你手写一个递归下降解析器,把这条规则直接翻译成代码,parseExpr()方法的第一件事就是调用parseExpr(),然后无限递归,栈直接溢出。
经典的解决办法是把左递归改写成右递归或迭代形式,比如把表达式规则拆成等价的非左递归写法。问题是改写之后语法可读性很差,而且左结合(1 - 2 - 3应该按(1-2)-3算)改完之后往往变得别扭。这也是很多人当年用手写解析器时最头疼的部分。
6.2 ANTLR4怎么把禁区变成日常
ANTLR4 直接支持直接左递归。它在内部把左递归规则改写成等价的非递归形式,并自动实现优先级攀升(precedence climbing)逻辑;运行时则通过adaptivePredict机制,基于当前已扫描到的完整上下文来决定走哪条分支。你写出来的语法就是"人话",跟数学表达式的写法一致,ANTLR 自动把优先级和结合性都处理好。
这不是玄学,是有实际收益的:语法文件的可读性直接决定了后续维护成本。一个能直接按"正常人思路"书写的语法,比一个为绕过左递归而扭曲过的语法,不知道好维护到哪里去。
唯一需要注意的是,ANTLR4 只支持直接左递归。间接左递归——比如a调用b、b又调用a——ANTLR 会直接报错。所以写规则时注意别绕圈子。
6.3 优先级、结合性与前后缀运算符的控制方法
在一条左递归规则内,越靠前的 alternative 优先级越高,也就是绑得越紧。拿前面的表达式规则来说:
expr: expr (MUL | DIV) expr # MulDiv | expr (ADD | SUB) expr # AddSub | INT # Int | ID # Id ;MUL、DIV那行写在前面,所以*和/的优先级高于+和-。这跟数学直觉一致:1 + 2 * 3会被解析成1 + (2 * 3)。
左递归天然产生左结合,符合大多数二元运算符的预期。如果你需要右结合,ANTLR 提供了显式的assoc选项:
expr: <assoc=right> expr POW expr ;前缀和后缀运算符也有固定的写法套路。前缀运算符用左递归加单目形式,后缀运算符类似;它们的优先级同样由 alternative 顺序控制。我建议你每加一层运算符,就用插件预览窗口跑一个混合运算的表达式,亲眼确认树对不对,而不是全写完再一次性调。
6.4 歧义不是洪水猛兽,但要知道底线
ANTLR4 的 ALL(*) 算法允许语法里有相当程度的歧义,运行时看输入再决定走哪条分支。很多以前需要在语法里写死消歧逻辑的场景,现在直接留给运行时就行。这也是 ANTLR4 比早期解析器生成器"宽容"很多的原因。
但这个宽容是有底线的。如果语法在某个决策点上无论往后看多少 Token 都无法区分,ANTLR 会报 "non-LL(*) decision" 之类的错误。入门阶段遇到这类问题,先别急着上"语义谓词"这种高级武器,按顺序尝试:调整 alternative 顺序、提取公共左因子、把规则拆细。这几种方法能解决绝大多数初级的歧义问题。
7. 概念消化后的练习路线与高频坑清单
7.1 照着做就能见效的五个练习
概念讲完了,真正内化靠练。我给新人的建议是按照下面这个顺序,每个步骤都亲手做一遍:
- 抄一遍上面的 Calc 语法,用 grun 或 IDEA 插件跑几个输入,分别用
-tokens和-tree看输出,把"规则"和"产物"对应上。 - 写一个 Listener,统计输入里出现多少次赋值语句
a = 1,验证字段累积结果的写法。 - 写一个 Visitor,对纯整数表达式求值,比如
1 + 2 * 3,确认优先级处理是否正确。 - 给语法加一条行注释规则,用
channel(HIDDEN)保留注释,再写一个 Listener 把注释的行号和文本打印出来。 - 改一下
expr规则的 alternative 顺序,把ADD那行放到MUL前面,重新生成,再看1 + 2 * 3的树长什么样。
这五个练习分别对应 Token 流、Listener、Visitor、hidden channel、优先级这五个核心概念。做完之后,你对 ANTLR4 的基本模型会有非常扎实的体感。
7.2 高频报错和排查方向
下面这份对照表来自我见过的高频问题,新人可以直接收藏:
| 报错/现象 | 常见原因 | 排查方向 |
|---|---|---|
| grammar 文件里声明的名字和文件名不一致 | ANTLR 强制要求两者一致 | 改 grammar 声明或文件名 |
| implicit definition of token ... | 语法规则字面量与词法规则冲突 | 把符号显式定义成词法规则 |
| rule ... contains a closure ... empty string | 某个(...)*或...?里存在可匹配空串的情况 | 检查递归规则的终止分支 |
| ID 吞掉了关键字或符号 | 词法规则顺序问题 | 把更具体的规则放前面 |
| mismatched input '...' expecting ... | Token 流和语法规则期望不符 | 先看-tokens输出再判断 |
| 生成代码里没有 Visitor | 没有加-visitor | 在命令行或构建工具里开启 |
每种错误的排查其实都是有套路的:先确认 Token 流对不对,再确认语法树对不对。绝大多数报错都能落在这两步之一。
7.3 几条长期有用的实践经验
最后分享几条我长期做 ANTLR4 项目的习惯:
第一,把 .g4 文件当架构文档来维护。注释写清楚每个规则的意图、每个分支的业务含义,因为三个月后的你大概率不记得当初为什么这么写。
第二,运算符和关键字坚持显式定义成词法规则,少用隐式 Token。短期看是多打几行字,长期看是给可读性和可维护性上保险。
第三,把样例输入和它对应的期望语法树(或期望求值结果)一起放进版本库,做成回归测试。语法文件是会被反复改的,没有这份测试兜底,改坏一处往往很难发现。
第四,生成代码不要提交进版本库。用 Maven、Gradle 或对应的构建插件在编译期自动生成,能避免一堆无意义的 diff。
第五,卡住的时候,把语法文件删到最小复现。这个原则对一切解析器工作都适用,语法文件尤其如此——规则越少,问题越容易暴露。
我自己学 ANTLR4 时的最大体会是:概念和实操是分不开的。第一次看官方文档里"左递归"三个字心里发怵,后来对着插件预览窗口把每个表达式都点开看树,才算真正明白它讲的是什么。概念不是用来背的,是让你在遇到问题时知道往哪个方向查。希望这篇能让你少走一些我当初绕过的弯路。