上一篇文章把 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的 grammar,ANTLR4 会生成一组 Java 类(如果你用其他语言目标,会生成对应语言的类)。这些类的关系很容易搞混,我在这里列一下。
核心是ExprLexer和ExprParser。前者负责词法分析,后者负责语法分析。ExprLexer继承自Lexer,ExprParser继承自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] : a=expr op=('*'|'/') b=expr { $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 还是 Visitor
3.1 Token、解析树和 Context 的组装过程
把词法和语法规则写完之后,运行时会发生什么?我完整过一遍。假设输入是"1 + 2 * 3\n"。
第一步,创建CharStream对象读取字符。第二步,创建ExprLexer并把CharStream传进去,然后调用lexer.getAllTokens()或者通过CommonTokenStream来获取 token 流。此时会产生 4 个有效 token:INT(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对应叶子节点,也就是具体的 token;RuleNode对应规则节点,也就是拥有子节点的非叶子节点。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方法可以返回一个值。比如做一个四则运算计算器,直接让ExprBaseVisitor<Integer>的泛型为Integer,每个visitExpr都可以返回子表达式的计算结果。这在监听器模式里会很别扭,因为监听器方法都是void,你只能通过外部变量或者上下文对象来保存结果。
一个典型的 vistor 实现:
public class EvalVisitor extends ExprBaseVisitor<Integer> { @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> <groupId>org.antlr</groupId> <artifactId>antlr4-maven-plugin</artifactId> <version>4.13.1</version> <executions> <execution> <goals> <goal>antlr4</goal> </goals> </execution> </executions> </plugin>依赖配置如下:
<dependency> <groupId>org.antlr</groupId> <artifactId>antlr4-runtime</artifactId> <version>4.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 List<String> 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,就会觉得顺理成章。希望这篇概念解析能帮你把基础打牢,接下来就可以放心去折腾你自己的语言了。