
如果你已经照着系列第一篇把环境装好、跑通了第一个 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 alternativesexpr: 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:0a,ID,1:0] [1,2:2,EQ,1:2] [2,4:41,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回车再按 CtrlDLinux/macOS或 CtrlZWindows结束输入你就会看到上面那几行 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.tokensToken 类型编号映射表默认生成跨语言/多语法协作时有用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();前四行就是把流水线串起来字符流喂给 LexerLexer 产出 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对应IDTokenEQ()返回TerminalNode对应EQTokenexpr()返回ExprContext对应子规则引用NEWLINE()返回TerminalNode对应换行 Token。TerminalNode是语法树的叶子节点挂着一个 TokenParserRuleContext是内部节点对应某条规则。你在写 Listener/Visitor 时的绝大多数取值操作都是通过 Context 类的方法完成的。所以我的建议是动手写任何业务逻辑之前先在 IDE 里跳转到对应 Context 类的定义花五分钟看看它提供了哪些访问器。别靠猜。养成这个习惯之后效率会提升非常多。有一个小技巧能进一步改善生成代码的可读性给规则元素起名字。比如把规则写成lhsexpr rhsexpr生成的 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 是另一种姿势你继承CalcBaseVisitorT重写visitXxx方法T 是返回值类型。它和 Listener 最本质的区别是——遍历的控制权在你手里。默认情况下visitChildren会继续走访子节点但如果你不调用它子树就不会被访问反过来你调用它之后子节点的返回值也由你自己决定怎么聚合。看一个经典场景表达式求值。public class EvalVisitor extends CalcBaseVisitorInteger { 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: assocright 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 时的最大体会是概念和实操是分不开的。第一次看官方文档里左递归三个字心里发怵后来对着插件预览窗口把每个表达式都点开看树才算真正明白它讲的是什么。概念不是用来背的是让你在遇到问题时知道往哪个方向查。希望这篇能让你少走一些我当初绕过的弯路。