ANTLR4核心概念详解:词法规则、语法规则与树遍历入门

发布时间:2026/9/15 16:09:04
ANTLR4核心概念详解:词法规则、语法规则与树遍历入门 上一篇文章把 ANTLR4 的安装、IDEA 插件和第一个 Hello 示例跑通了但说实话很多朋友在写自己的语法文件时还是会卡壳为什么有的规则首字母大写有的必须小写token 和语法树到底是怎么串起来的监听器和访问器该选哪个这篇文章就把这些基础概念一次捋清楚。它是入门系列的第二篇适合已经跑通 HelloWorld、想真正开始写自己的语法文件的读者。我不会去复述官方文档而是从实战角度把概念和代码对应起来讲。毕竟 ANTLR4 这工具很多人卡住的不是工具本身而是不理解它背后那套“词法规则 语法规则 树遍历”的协作机制。下面直接进入正题。1. 一条请求从文本到语义的完整链路1.1 四个阶段到底在干什么学 ANTLR4 最重要的是先把它的工作流程装进脑子里。你给 ANTLR4 一个.g4文件它并不是直接帮你解析文本而是帮你生成一段解析代码。真正运行时你的程序把文本喂给这段代码然后拿到一棵树。整个过程可以切成四个阶段。第一个阶段是字符流读取。就是把字符串或文件内容变成一个CharStream对象这里本质上就是按字符逐个读取原始输入。第二个阶段是词法分析。词法分析器Lexer根据 g4 文件里的词法规则把字符流切成一串 token。token 可以理解成“带类型的小片段”比如数字是数字类型、标识符是标识符类型、运算符号是运算符类型。第三个阶段是语法分析。语法分析器Parser根据语法规则把 token 流组织成一棵有层级结构的树。最后一个阶段是遍历解析树你可以用监听器或访问器对这棵树做任何事比如计算值、翻译代码、检查错误。这四个阶段是层层递进的关系前一步的输出是后一步的输入。很多人混淆词法分析和语法分析认为它们差不多其实差远了。词法分析处理的是“这个东西是什么”而语法分析处理的是“这些东西按什么结构组织”。举个生活例子词法分析像是把一篇文章拆成一个个单词并标注词性语法分析则是判断这些词组合起来是不是一个通顺的句子。这就是为什么 ANTLR4 要分成 Lexer 和 Parser 两类规则。值得注意ANTLR4 里的 Lexer 和 Parser 是分两遍工作的不是一遍搞定。第一遍把字符流搞成 token 流第二遍把 token 流搞成解析树。这样做的好处是让词法规则和语法规则解耦各司其职写起来也更清晰。1.2 一次生成全端覆盖的接口类对于一个名为Expr的 grammarANTLR4 会生成一组 Java 类如果你用其他语言目标会生成对应语言的类。这些类的关系很容易搞混我在这里列一下。核心是ExprLexer和ExprParser。前者负责词法分析后者负责语法分析。ExprLexer继承自LexerExprParser继承自Parser这两个类才是跑解析时真正干活的。除了这两个还会生成ExprListener和ExprBaseListener这是监听器模式的接口和默认空实现。如果你选择 visitor 模式则会生成ExprVisitor和ExprBaseVisitor。ExprParser里还有一个嵌套的规则上下文类。比如你在 g4 里定义了一个规则prog: stat ;那么就会生成ProgContext类定义expr规则就会生成ExprContext类。这些 Context 类就是解析树节点的具体类型它们继承自RuleNode接口。每个 Context 类里都有expr()、stat()、ID()、INT()这样的方法用来到达该节点下的子节点。理解 Context 类对写遍历代码至关重要因为监听器和访问器的方法签名里全是这些 Context 类型。还有个容易忽略的问题ANTLR4 会覆盖已有文件吗如果你往生成目录里塞了自己的类下次运行代码生成器时同名类会被覆盖掉。所以千万不要在生成目录里手写业务代码否则一跑 mvn generate-sources 你的改动全没了。正确做法是把业务逻辑写在单独的包或目录中。2. 吃透 .g4 文件从规则到代码映射2.1 大写与小写规则的本质区别g4 文件是 ANTLR4 的心脏里面最重要的事情就是区分词法规则和语法规则。分词法规则很简单词法规则首字母大写语法规则首字母小写。这个不是随便定的风格偏好而是 ANTLR4 编译器识别规则类型的硬性语法要求。但很多新手只知道“要大写、要小写”却不知道为什么要这么分。词法规则描述的是“单个词长什么样”它直接匹配字符。比如数字可以用[0-9]来匹配标识符可以用[a-zA-Z_][a-zA-Z0-9_]*来匹配。这些规则会在词法分析阶段被匹配成 token。语法规则描述的是“多个 token 怎么组合”它引用的对象是 token 或者其他的语法规则。比如表达式规则可以写成expr: expr expr | INT ;这里expr是语法规则而INT指向一个词法规则。区分清楚之后g4 文件的结构就很好理解了。一个典型的 g4 文件大概是这样的grammar Expr; prog: stat ; stat: expr NEWLINE | ID expr NEWLINE | NEWLINE ; expr: expr op(*|/) expr | expr op(|-) expr | INT | ID | ( expr ) ; MUL: * ; DIV: / ; ADD: ; SUB: - ; ID: [a-zA-Z_][a-zA-Z0-9_]* ; INT: [0-9] ; NEWLINE: \r? \n ; WS: [ \t] - skip ;这里prog、stat、expr首字母小写是语法规则ID、INT、NEWLINE、WS首字母大写是词法规则。同时可以看到在语法规则里可以直接写字符串字面量比如、*、这些字符串字面量也会被自动当成一种匿名词法规则。不过在复杂文法里我建议把常用运算符显式定义成有名字的词法规则而不是到处写字符串字面量。好处是错误提示更友好代码里也更容易判断 token 类型。另外一个重要点词法规则和语法规则的匹配策略完全不同。词法分析器在匹配 token 时会选择最长的匹配如果长度相同则靠前的规则优先。语法分析器则不同它会在多个可能的规则分支中做试探和回溯直到找到能匹配整棵树的路径。这个区别直接导致了一些规则顺序上的坑我会在第 4 部分细讲。2.2 备选分支、子规则与优先级控制语法规则的核心是“备选分支”用|分隔。比如上面的expr规则有 5 个分支任意一个满足就算匹配成功。备选分支里可以写规则引用、token 引用、字符串字面量也可以写子规则。子规则就是用括号包起来的一段规则表达式它可以出现在规则右侧的任何位置并且可以配合量词使用。量词有三个?表示可选0 或 1 次*表示 0 次或多次表示 1 次或多次。这些和正则表达式里的含义完全一致。比如stat就表示 stat 规则出现一次或多次。子规则和量词组合起来能表达很复杂的语法结构比如( expr)?表示“可选地出现一个等号后跟表达式”。说到优先级控制这是 ANTLR4 一个非常有用的特性。如果你写过计算器文法肯定关心乘法除法优先于加法减法的问题。ANTLR4 处理这个问题的标准做法是把不同优先级的运算拆成不同的规则层。但 ANTLR4 也支持 Java 风格的方法调用式左递归可以直接写成expr: expr op(*|/) expr | expr op(|-) expr | INT ;ANTLR4 对直接左递归做了特殊支持它会根据分支书写顺序自动确定优先级越靠前的分支优先级越高。所以在这个例子里*/的优先级高于-。这是 ANTLR4 相对 ANTLR3 的重大改进不用再手动消除左递归了。不过要注意直接左递归只对单个规则内的左递归有效。如果你跨规则左递归比如 A 规则引用 B 规则B 又引用 A 规则ANTLR4 仍然会报错或产生不正确的行为。所以这种写法要谨慎使用。2.3 语义动作与词法命令g4 文件不只是“描述语法”你还可以在规则里嵌入代码片段这叫作语义动作。语义动作用大括号包裹可以是任意目标语言代码。比如expr returns [int value] : aexpr op(*|/) bexpr { $value $op.type MUL ? $a.value * $b.value : $a.value / $b.value; } | INT { $value Integer.parseInt($INT.text); } ;这里returns [int value]定义了一个规则返回值$a.value表示取子规则或 token 的属性值$INT.text表示取 token 的原始文本。语义动作的语法在不同的目标语言里有些差异但基本思路都是一样的在解析到某个节点时执行一段代码。不过我得提醒一句我不建议新手在 g4 文件里堆大量语义动作。原因很简单语义动作会让文法难以复用也会让生成的解析器耦合特定语言的业务逻辑。更好的做法是保持文法纯净用 Visitor 模式在遍历解析树时执行业务逻辑。这样语法定义和业务处理完全分离后续改逻辑不用重新生成代码。只有在性能要求极高、或者需要精确控制解析过程时才值得把动作写进 grammar。词法规则还有一种常见用法叫词法命令最典型的是- skip。WS: [ \t] - skip ;的意思是在词法分析时直接跳过空白字符不生成 token。这在几乎所有语言里都是必须的因为空白字符通常没有语法意义。除了skip还有- channel(HIDDEN)、- type(...)、- mode(...)等命令分别用于把 token 放到隐藏通道、改变 token 类型、切换词法模式。隐藏通道这个特性在实现注释、宏定义、IDE 高亮时非常有用因为它能让解析器正常工作的同时仍然保留这些 token 供其他工具读取。3. 解析树与遍历机制Listener 还是 Visitor3.1 Token、解析树和 Context 的组装过程把词法和语法规则写完之后运行时会发生什么我完整过一遍。假设输入是1 2 * 3\n。第一步创建CharStream对象读取字符。第二步创建ExprLexer并把CharStream传进去然后调用lexer.getAllTokens()或者通过CommonTokenStream来获取 token 流。此时会产生 4 个有效 tokenINT(1)、ADD()、INT(2)、MUL(*)、INT(3)以及一个NEWLINE。空白字符由于- skip被丢弃所以不会出现在 token 流里。CommonTokenStream这个类值得多说一句。它实现了TokenStream接口会在内存里缓存全部 token以便 parser 可以随机访问任意位置的 token。这一点和某些流式解析器不同ANTLR4 的 parser 需要在 token 流上做大量向后回溯和向前预测所以不能做成一次性消费的流。第三步创建ExprParser把CommonTokenStream传进去然后调用某个入口规则方法比如parser.prog()。这个方法会返回一个ProgContext对象这就是解析树的根节点。从根节点往下每个规则调用都会生成对应的 Context 节点每个 token 匹配都会生成对应的 TerminalNode 节点。运行时这些节点对象互相引用形成一棵树这就是解析树。解析树的节点分两种TerminalNode对应叶子节点也就是具体的 tokenRuleNode对应规则节点也就是拥有子节点的非叶子节点。ParseTree是所有解析树节点的统一接口TerminalNode和RuleNode都继承自它。写遍历代码时你真正打交道的大多是各种 Context 类它们都是RuleNode的子类。3.2 Listener 模式让遍历器主动回调你ANTLR4 默认推荐监听器模式。它的工作方式很形象有一个ParseTreeWalker对象它按深度优先的顺序遍历整棵解析树每进入一个规则节点就调用你写的enterXxx方法每离开一个规则节点就调用exitXxx方法。你只需要继承ExprBaseListener并重写感兴趣的方法即可不用手动控制遍历顺序。监听器模式最大的优势是解耦。你不用关心树的完整结构只需要在对应节点上做一些操作。比如你只想知道每行表达式的结果那只需要重写exitStat或exitExpr在这个方法被回调时取出子节点的值做计算就行。一个常见的监听器实现长这样public class CalcListener extends ExprBaseListener { Override public void exitStat(ExprParser.StatContext ctx) { if (ctx.expr() ! null ctx.expr().size() 0) { System.out.println(result: eval(ctx.expr(0))); } } private int eval(ExprParser.ExprContext ctx) { if (ctx.INT() ! null) { return Integer.parseInt(ctx.INT().getText()); } if (ctx.op ! null) { int left eval(ctx.expr(0)); int right eval(ctx.expr(1)); if (ctx.op.getType() ExprParser.MUL) { return left * right; } if (ctx.op.getType() ExprParser.ADD) { return left right; } } return 0; } }然后用ParseTreeWalker.DEFAULT.walk(new CalcListener(), tree)启动遍历。这样写的好处是框架已经帮你处理好了访问顺序你只需要关心“进入节点后做什么”和“离开节点前做什么”这两个时机。缺点是如果你需要精确控制“先访问左子树还是先访问右子树”监听器模式就比较难办因为遍历顺序是ParseTreeWalker固定的。监听器还有一个隐藏机制默认 BaseListener 全是空实现这意味着你不重写的方法会被静默忽略。这个设计很棒因为语法规则一多每个规则都有 enter 和 exit 两个方法全部重写不现实空实现让定制成本降到最低。3.3 Visitor 模式自定遍历节奏与返回值Visitor 模式是另一种遍历方式。它和监听器的思路不同你自己决定要不要访问子节点以及按什么顺序访问。每个规则节点都会生成一个visitXxx方法方法默认调用visitChildren(ctx)来深度优先访问子节点但你可以完全覆盖这个行为。Visitor 模式特别适合“计算结果”这类场景因为visitXxx方法可以返回一个值。比如做一个四则运算计算器直接让ExprBaseVisitorInteger的泛型为Integer每个visitExpr都可以返回子表达式的计算结果。这在监听器模式里会很别扭因为监听器方法都是void你只能通过外部变量或者上下文对象来保存结果。一个典型的 vistor 实现public class EvalVisitor extends ExprBaseVisitorInteger { Override public Integer visitExpr(ExprParser.ExprContext ctx) { if (ctx.INT() ! null) { return Integer.valueOf(ctx.INT().getText()); } if (ctx.op ! null) { int left visit(ctx.expr(0)); int right visit(ctx.expr(1)); switch (ctx.op.getType()) { case ExprParser.MUL: return left * right; case ExprParser.DIV: return left / right; case ExprParser.ADD: return left right; case ExprParser.SUB: return left - right; } } return visitChildren(ctx); } }调用方式也很简单Integer result new EvalVisitor().visit(tree);。这里有个特殊细节visit(tree)会根据树的根节点类型自动派发到对应的visitXxx方法。如果根节点是ProgContext就会调用visitProg。如果你不重写visitProg默认实现会去访问所有子节点并把最后一个子节点的 visit 结果返回所以就算不重写根节点方法也能拿到值。但如果根节点有多个子节点且你想返回特定值最好显式重写它。Listener 和 Visitor 怎么选我的经验是需要返回值时用 Visitor不需要返回值时用 Listener。比如代码格式化、语法检查、符号表收集这些场景只需要在遍历时做副作用操作用 Listener 最省事。而做解释器、求值器、代码生成器返回值是刚需用 Visitor 会自然很多。如果你用的是 IDEA 的 ANTLR4 插件可以在生成代码时同时开启两种模式但项目里只依赖一种避免混用导致代码混乱。4. 实操从零搭一个最小计算器并排掉常见的坑4.1 用 Maven 插件一步到位生成代码说了这么多概念现在动手搭一个最小项目。我用 Maven 做示例因为 Maven 在 Java 社区最普及。项目结构大致如下calculator/ ├── pom.xml ├── src/main/antlr4/com/example/Expr.g4 └── src/main/java/com/example/Calc.java关键在 pom.xml。你需要两样东西antlr4-maven-plugin用于生成代码antlr4-runtime作为运行时依赖。插件配置如下plugin groupIdorg.antlr/groupId artifactIdantlr4-maven-plugin/artifactId version4.13.1/version executions execution goals goalantlr4/goal /goals /execution /executions /plugin依赖配置如下dependency groupIdorg.antlr/groupId artifactIdantlr4-runtime/artifactId version4.13.1/version /dependency注意插件和运行时版本最好一致。我踩过版本不一致的坑运行时用 4.9、插件用 4.13生成的代码会报缺少方法或者类型不兼容的错误排查起来很浪费时间。然后用 IDEA 的 ANTLR4 插件预览语法确认没有语法错误后执行mvn generate-sources。这个命令会把 Expr.g4 编译成 Java 类输出到target/generated-sources/antlr4目录。如果你看到这个目录下出现了ExprLexer.java、ExprParser.java等文件就说明代码生成成功了。4.2 一个可直接运行的 Java 求值入口生成完代码后写一个入口类来验证整个流程。这里我把监听器作为求值器去遍历解析树并打印结果。主函数代码import org.antlr.v4.runtime.CharStreams; import org.antlr.v4.runtime.CommonTokenStream; import org.antlr.v4.runtime.tree.ParseTree; import org.antlr.v4.runtime.tree.ParseTreeWalker; public class Calc { public static void main(String[] args) throws Exception { String input 1 2 * 3\n; ExprLexer lexer new ExprLexer(CharStreams.fromString(input)); CommonTokenStream tokens new CommonTokenStream(lexer); ExprParser parser new ExprParser(tokens); ParseTree tree parser.prog(); ParseTreeWalker.DEFAULT.walk(new CalcListener(), tree); } }如果一切正常运行后应该输出类似这样的结果result: 7这里跑到parser.prog()时如果输入语法有错误ANTLR4 的默认行为是不抛异常而是把错误信息打印到 stderr并尝试从错误中恢复。这个设计初看很反直觉但实际上非常实用解析器不会因为一个错误就整体崩掉而是尽可能多地解析完整个文件这样 IDE 就能同时报告多处错误。如果你想在检测到错误时立即终止需要加一个错误监听器我后面会讲到。跑通主流程之后你可以逐步扩展这个计算器增加变量、赋值语句、括号、优先级更高的运算等。每加一个特性回到 g4 文件里加规则然后重新生成代码再看监听器或访问器里需要补哪些逻辑。这个迭代过程非常好用也是我建议新手入门的核心路径。4.3 常见错误速查与定位技巧ANTLR4 的报错信息不算友好尤其是对新手。我把最常见的几类错误和定位方式整理成一张表方便你遇到时直接查。报错信息含义常见原因解决办法token recognition error at: ...词法分析碰到无法识别的字符词法规则没有覆盖该字符检查字符是否在某个词法规则中被定义或者补充对应规则extraneous input ... expecting ...输入中多出了某个 token解析器不期望它出现通常是漏了词法规则skip比如空白字符没被跳过检查是否有未处理的空白、换行符补上WS: [ \t] - skip;mismatched input ... expecting ...token 类型不对期望 A 却来了 B语法规则引用写错或输入内容本身不符合规则用 IDEA 插件查看 token 流的类型和规则预期对比no viable alternative at input ...输入无法匹配当前规则下的任何备选分支语法规则抽象层级不对或语法规则之间有遗漏进入parser的ErrorListener或者用parser.addErrorListener(...)打印更详细的上下文rule ... contains a left-recursion ...检测到非法左递归跨规则左递归或者写成了间接左递归ANTLR4 只支持单规则直接左递归改写为显式优先级嵌套规则定位语法错误我几乎离不开 IDEA 的 ANTLR4 插件。它可以实时解析 g4 文件并生成预览你用鼠标点解析树节点就能看到输入文本对应的部分。有些问题比如no viable alternative单靠报错信息完全没法定位但用预览树一眼就能看出是哪个分支没被匹配到。还有一个非常隐蔽的问题多个词法规则匹配同一个字符时靠前规则优先。比如你写了ID: [a-zA-Z_][a-zA-Z0-9_]* ;和INT: [0-9] ;数字开头的内容不会匹配到 ID因为ID的第一个字符类不包含数字所以没问题。但如果你写了两个都能匹配同一段文本的规则比如ID: [a-zA-Z_] ;和IF: if ;那么输入if时由于词法分析器选择更长的匹配而两者长度相同所以靠前的规则优先。因此你必须在IF规则前预留出ID不匹配if的特殊处理。ANLTR4 没有内置关键字保护机制需要你手动把关键字规则放在前面。最后一个常见问题是运行时和插件版本不一致导致的诡异行为。我建议固定使用一个版本比如统一 4.13.1。升级版本时先重新生成代码再检查 API 是否有变化避免出现visitXxx方法签名对不上的问题。4.4 隐藏通道与自定义错误监听除了上面这些基础排错手段我再补充两个能显著提升调试效率的技巧。第一个是隐藏通道。默认情况下- skip会让 token 直接消失但如果你是做代码格式化或语法高亮工具往往希望解析器忽略空白的同时还能拿到这些 token。这时候不要用 skip而是用- channel(HIDDEN)WS: [ \t\r\n] - channel(HIDDEN); COMMENT: // ~[\r\n]* - channel(HIDDEN);这样 parser 解析时不会因为空白和注释报错而你的程序还可以通过tokenStream.getTokens()拿到全部 token其中隐藏 token 的 channel 值为Token.HIDDEN_CHANNEL。这个特性在做代码分析工具时极其好用。第二个技巧是自定义错误监听器。默认错误输出到 stderr 且可读性一般你可以实现ANTLRErrorListener接口或者继承BaseErrorListener覆盖syntaxError方法自己收集错误信息。下面是一个最小实现import org.antlr.v4.runtime.BaseErrorListener; import org.antlr.v4.runtime.RecognitionException; import org.antlr.v4.runtime.Recognizer; public class CollectingErrorListener extends BaseErrorListener { public final ListString errors new ArrayList(); Override public void syntaxError(Recognizer?, ? recognizer, Object offendingSymbol, int line, int charPositionInLine, String msg, RecognitionException e) { errors.add(line line : charPositionInLine msg); } }然后把它挂到解析器上parser.addErrorListener(new CollectingErrorListener());或者lexer.addErrorListener(...)。这样你就有了一个结构化的错误列表可以用于测试断言或 IDE 集成。注意默认的 ConsoleErrorListener 仍然会输出到 stderr如果你不想看到多余输出调用parser.removeErrorListener(ConsoleErrorListener.INSTANCE)。从我自己的项目经历来说自定义错误监听器和隐藏通道是让我对 ANTLR4 从“会用”升级到“敢用”的两个转折点。因为前者给了我掌控感后者让我能实现真正有实际价值的语言工具而不仅仅是玩具 Demo。我在实际开发中还有一个经验一开始别追求把 g4 文件写得多完善先让最小功能跑通再逐步加规则。ANTLR4 的迭代成本很低生成代码、编译、运行也就是几十秒的事完全可以根据需求频繁改动文法。等你上手之后再去看官方文档里那些高级特性比如语义谓词、词法模式、自定义 TokenFactory就会觉得顺理成章。希望这篇概念解析能帮你把基础打牢接下来就可以放心去折腾你自己的语言了。