1. 为什么你会走到Antlr这一步
先从一个特别常见的需求说起。你手里有个项目,要支持用户自定义公式,比如输入 price * (1 + taxRate) 然后系统自动计算;或者要做一套规则引擎,让业务方写 if age > 18 && score >= 60 then pass;再或者,你要解析一批日志,提取中间特定的字段组合。这时候你面临一个选择:是正则表达式硬凑,还是自己写一个完整的解析器?
正则表达式确实是很多人的第一反应,但它有两个很致命的问题:一是嵌套结构(括号套括号)它根本处理不了,二是它只能告诉你"匹配或不匹配",没办法给你一棵结构化的树。而手写解析器呢,状态机、递归下降、回溯处理、错误恢复……一套写下来,少说几百行,多则上千行,而且每改一个语法规则,就要跟着改一堆代码。说实话,我早年写过一次手写解析器,从分词器到语法分析,再到AST节点设计,折腾了两周,最后遇到一个边界问题直接心态崩了。
Antlr就是解决这个问题的。它是一个开源语法分析工具,全称是ANother Tool for Language Recognition,Java生态里用得最多,但它实际上能生成Java、C#、Python、JavaScript、Go、Swift、Dart等十来种目标语言的解析器代码。你要做的,就是定义一套文法规则(通常是.g4文件),然后Antlr帮你生成词法分析器(Lexer)和语法分析器(Parser),你再基于生成的语法树(Parse Tree)去写自己的业务逻辑。
如果你正在学编译原理,或者工作中遇到了"需要解析自定义语法/DSL"的需求,这篇文章应该能帮到你。我会从Antlr是怎么工作的讲起,用实战例子带你写一个能跑的表达式求值器,再结合编译原理里的符号表概念,讲讲Antlr之外你还需要做哪些事。全程不讲虚的,都是可以直接复现的内容。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Antlr的核心运行机制:从一串字符到一棵语法树
2.1 文法是什么:先忘掉"语法分析"这四个字
要理解Antlr,绕不开一个概念:上下文无关文法(Context-Free Grammar,CFG)。听着学术,其实道理很简单,它就是用一套递归的产生式规则,描述一门语言"什么句子是合法的"。比如我们要描述"加减法算术表达式",可以这样写:
code复制expr -> expr '+' expr
expr -> expr '-' expr
expr -> INT
读起来就是:一个表达式可以是"两个表达式相加"、“两个表达式相减”、或者一个整数。这很像中文语法里"句子 = 主语 + 谓语 + 宾语"的递归描述。编译原理课上,你会学到终结符(Terminal,比如这里的+、-、INT这种不可再拆的最小单元)和非终结符(Nonterminal,比如expr这种可以继续展开的符号)的概念。实践中你不用背术语,但得理解这个"递归展开"的直觉。
在Antlr的世界里,你把这个文法写进.g4文件,它就会根据这套规则生成一个"语法检查器"。这个检查器读入一段文本,从起始规则开始,尝试用产生式去匹配输入。匹配成功,它就会生成一棵和输入对应的树,树上的每一个节点,对应一条文法规则。这棵树,就是Antlr交给你的核心产出。
2.2 LL(*)解析与前瞻机制:解析器是怎么"未卜先知"的
Antlr 4的解析算法是Adaptive LL(*),这个词比传统编译原理教材里的LL(1)复杂一点,但你只需要抓住关键词:"前瞻"(lookahead)。
你可以把解析过程想象成一个人走在岔路口,每条路都是一个候选规则。普通LL(1)解析器只能看当前这一个"路标"(一个Token)来决定往哪走,遇到需要多看几个Token才能判断的情况,就卡死了。而Antlr的LL(*)可以向前看任意多个Token——它是在运行时动态构造一个"NFA模拟器"来探测每条候选路径能不能走通,直到找到唯一一条能匹配完全部输入的路径。如果多条路径都能走通,它就按照你在文法里写的规则顺序,默认选第一条,同时报告一个歧义警告。
这个机制带来的实际好处是:你写的文法规则只要尽量消除歧义,剩下的交给Antlr去"试验",它比你手写递归下降时反复回溯要高效得多,而且生成的分析表不用像传统的LL/K算法那样提前静态构造。对我这种不是研究算法出身的开发者来说,这套设计直接把写解析器的门槛降到了"会写文法"级别。
2.3 词法分析和语法分析:两件活,两条规则流
Antlr的.g4文件里其实有两类规则:词法规则(大写字母开头)和语法规则(小写字母开头)。你可能觉得"不就是一个文件嘛,分那么清干嘛",但这两者的分工真不一样。
词法分析的活儿,是把原始字符流切成一个一个的Token。你写下INT: [0-9]+;这条词法规则,Antlr就会生成一个词法分析器,把输入字符串里的连续数字识别成一个INT类型的Token。它还负责跳过空白、注释(WS: [ \t\r\n]+ -> skip;),以及区分不同种类的字面量。词法规则的执行顺序很敏感:Antlr会按照你在文件里写词法规则的先后顺序去匹配,先写到的最先尝试。所以IF: 'if';必须写在IDENTIFIER: [a-zA-Z]+;之前,否则"if"这个单词会被识别成标识符而不是关键字。
语法分析的活儿呢,是拿到词法分析器产出的Token流,按照语法规则去组织成树。它关心的是结构:这个表达式是加法,那个语句是if判断,等等。你写语法规则的时候,用的"原料"就是Token类型和别的语法规则的名字。
这里有一个实践中最常踩的坑:词法规则里尽量不要用字符串字面量,特别是带空格的,比如PUNCT: '==';这种没问题,但如果你在词法规则里写了一串包含空格的字符串,空格匹配会非常容易出错。而且词法规则一旦定义了,Token类型就有全局固定编号,编码时如果通过tokenNames去查名字,会绕得很晕。
2.4 Antlr生成的四样东西:Lexer、Parser、Listener、Visitor
你在.g4文件里写好规则,用Antlr工具跑一遍,会得到四类核心文件。理解这四样东西,就理解了Antlr的使用全貌。
- Lexer文件(比如
ExprLexer.java):负责把字符流切成Token流。 - Parser文件(比如
ExprParser.java):负责把Token流组织成语法树。它还内置了一个递归下降解析器的骨架,你可以在它的子类里覆写方法,直接嵌入动作代码。 - Listener接口(比如
ExprBaseListener):基于观察者模式,它提供了一堆enterXxx()和exitXxx()回调方法。你遍历语法树时,每进入/离开一个语法规则节点,就会触发对应的方法。这是默认最容易上手的遍历方式,不用你手动控制递归顺序。 - Visitor接口(比如
ExprBaseVisitor<T>):基于访问者模式,它有一个visitXxx()方法对应每种语法规则,并且需要你显式用visitChildren(ctx)来控制递归进去。好处是你可以为每个节点自定义返回类型,想做表达式求值、生成代码这类"自底向上"的计算,用Visitor更顺手。
初学者常搞不清楚Listener和Visitor的区别。我打个比方:Listener像摄像头,装在每个房间门口,人一进一出就自动记录,你不用管它下一步干嘛;Visitor像你自己拿着钥匙,你决定先开哪个房间的门、进去之后干什么、要不要往下一层走。表达式求值这种需要子节点算完、父节点才能算的场景,Visitor的显式控制会舒服得多。
3. 实战案例:用Antlr写一个支持变量的表达式求值器
3.1 环境准备:不是只有Maven一条路
Antlr是一个Java写的工具,所以最正统的用法是在Java项目里通过构建工具去调用它。Maven配antlr4-maven-plugin,Gradle配antlr插件,都能在编译期自动把.g4文件生成成Java代码。如果你只是快速试一下,可以不用构建工具,直接去官网下载antlr-4.x-complete.jar,用命令行跑:
bash复制java -jar antlr-4.13.2-complete.jar -visitor Expr.g4
javac Expr*.java
注意我加了-visitor参数,这样才会生成Visitor文件。如果你不打算用Visitor,默认的Listener也够用,但为了让下面的求值代码更简洁,建议加上这个参数。
Maven项目的pom.xml核心部分大概长这样:
xml复制<build>
<plugins>
<plugin>
<groupId>org.antlr</groupId>
<artifactId>antlr4-maven-plugin</artifactId>
<version>4.13.2</version>
<executions>
<execution>
<goals><goal>antlr4</goal></goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
<dependencies>
<dependency>
<groupId>org.antlr</groupId>
<artifactId>antlr4-runtime</artifactId>
<version>4.13.2</version>
</dependency>
</dependencies>
3.2 编写Expr.g4:一条规则一个坑
下面这份文法,我实际跑通过,你直接抄就能用:
antlr复制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]+ ;
INT: [0-9]+ ;
NEWLINE: '\r'? '\n' ;
WS: [ \t]+ -> skip;
有几个点值得单独说说。第一,Antlr 4支持直接左递归,所以expr: expr '*' expr这种写法没毛病,不需要像老版本那样拆成expr -> term -> factor。第二,我在规则里给运算符取了个别名op=,这样后面在Visitor里可以直接取出ctx.op.getText(),省事。第三,NEWLINE作为语句结束标记,是为了让求值器知道"这一行算完了",这种行级约束在表达式类DSL里非常常见。如果你不想要换行符,也可以换成EOF结尾。第四,潜规则顺序很重要,ID规则放在INT之前也不冲突,因为字母和数字的字符集不同,但如果你写个WORD: [a-zA-Z0-9]+就会把数字也吞掉,这种词法冲突在实际项目里几乎天天发生。
3.3 生成代码并理解生成的类
写完.g4文件后,执行构建,在target/generated-sources/antlr4目录下会看到:
ExprLexer.javaExprParser.javaExprListener.java、ExprBaseListener.javaExprVisitor.java、ExprBaseVisitor.java
其中ExprParser内部会有一堆嵌套类,比如ExprContext、StatContext、ProgContext,每一个都对应文法里的一条规则。这些Context类是你在业务代码里打交道的主要对象,它们暴露了一堆方法:ctx.expr()返回子节点列表、ctx.INT()返回匹配到的Token、ctx.getText()返回这段树的文本内容。
我还建议你打开ExprParser.java看一眼,不需要全部看懂,但你会看到类似public final ExprContext expr() throws RecognitionException {...}这样的方法,然后方法体里是一个个try块加setState、match调用。这是经典的递归下降解析器代码骨架,读一读能直观感受"你写的文法——变成了一堆真实的方法调用"这件事,对理解编译原理很有帮助。
3.4 用Visitor实现求值:从叶子往上算
接下来是爽的部分。我要写一个表达式求值器,让Antlr生成的语法树真正跑起来。这里的关键是visitExpr方法:先递归算左右子树,再拿运算符做运算。由于Antlr已经处理好了优先级(乘除在文法里比加减更深一层),你不用在代码里关心"先乘除后加减",树的结构已经把优先级体现出来了。
java复制public class EvalVisitor extends ExprBaseVisitor<Integer> {
private final Map<String, Integer> vars = new HashMap<>();
@Override
public Integer visitProg(ExprParser.ProgContext ctx) {
for (ExprParser.StatContext stat : ctx.stat()) {
visit(stat);
}
return 0;
}
@Override
public Integer visitStat(ExprParser.StatContext ctx) {
if (ctx.ID() != null && ctx.expr() != null) {
// 赋值语句:ID '=' expr
String name = ctx.ID().getText();
int value = visit(ctx.expr());
vars.put(name, value);
return value;
}
if (ctx.expr() != null) {
return visit(ctx.expr());
}
return 0; // 空行
}
@Override
public Integer visitExpr(ExprParser.ExprContext ctx) {
if (ctx.INT() != null) {
return Integer.parseInt(ctx.INT().getText());
}
if (ctx.ID() != null) {
String name = ctx.ID().getText();
Integer v = vars.get(name);
if (v == null) {
throw new RuntimeException("未定义的变量: " + name);
}
return v;
}
if (ctx.expr().size() == 1) {
// 括号表达式 '(' expr ')'
return visit(ctx.expr(0));
}
// 二元运算
int left = visit(ctx.expr(0));
int right = visit(ctx.expr(1));
String op = ctx.op.getText();
return switch (op) {
case "+" -> left + right;
case "-" -> left - right;
case "*" -> left * right;
case "/" -> {
if (right == 0) {
throw new RuntimeException("除零错误");
}
yield left / right;
}
default -> throw new RuntimeException("未知运算符: " + op);
};
}
}
看到这里你会发现,所谓"写一个编程语言解释器",在Antlr的框架下其实就是"写几个visit方法"。每个方法处理一种语法规则,子节点算完返回给父节点,这就是教科书上说的语法制导翻译(Syntax-Directed Translation)——只不过翻译的动作不是生成目标代码,而是直接算出数值。
主程序入门的代码也很简单:
java复制public class Main {
public static void main(String[] args) throws IOException {
String input = "a = 3\nb = a * 2 + 1\nb\n";
CharStream cs = CharStreams.fromString(input);
ExprLexer lexer = new ExprLexer(cs);
CommonTokenStream tokens = new CommonTokenStream(lexer);
ExprParser parser = new ExprParser(tokens);
ParseTree tree = parser.prog(); // 从起始规则开始解析
EvalVisitor visitor = new EvalVisitor();
visitor.visit(tree);
}
}
运行之后,程序会依次给a赋值为3,给b赋值为7,然后打印7。整个链路:字符串 → 字符流 → Token流 → 语法树 → 计算结果,就是编译原理课程里"词法分析+语法分析+语义分析"最精简的落地版。
3.5 添加函数调用支持:看看怎么扩展语法树
表达式求值器太基础了,我们加一点更实战的东西:支持函数调用。比如输入sum(1, 2, 3)返回6。改文法只需三步。
第一步,在expr规则里加一个分支:
antlr复制| ID '(' exprList? ')' # FuncCall
加个exprList规则:
antlr复制exprList: expr (',' expr)* ;
(用# FuncCall给规则加标签,这样Antlr会为这个分支单独生成一个FuncCallContext子类,否则你得在visitExpr里靠ctx.getChildCount()去判断这是在解析函数调用还是括号,很别扭。给替代分支加标签是Antlr里非常重要的一个习惯,强烈建议每个|分支都标注出来。)
第二步,在Visitor里覆写visitFuncCall:
java复制@Override
public Integer visitFuncCall(ExprParser.FuncCallContext ctx) {
String funcName = ctx.ID().getText();
List<Integer> args = ctx.exprList().expr().stream()
.map(this::visit).toList();
return switch (funcName) {
case "sum" -> args.stream().mapToInt(Integer::intValue).sum();
case "min" -> args.stream().min(Integer::compare).orElse(0);
case "max" -> args.stream().max(Integer::compare).orElse(0);
default -> throw new RuntimeException("未知函数: " + funcName);
};
}
第三步,重新生成代码,跑一下。整个过程五分钟以内搞定。这就是用Antlr做DSL的日常:你扩展一门语言的语法,只需要改文法文件加一个Visitor分支,业务代码的改动量非常小。这也是我为什么在项目里越来越喜欢用Antlr替掉手写解析器的原因——你的语言还在演进期,文法改动最频繁的时期,Antlr能帮你把痛苦降到最低。
4. 符号表与语义分析:从热搜词里挖出的编译原理必修课
4.1 为什么Antlr不帮你做符号表
很多第一次接触Antlr的同学会问一句:"我变量的类型检查、作用域管理,Antlr是不是也帮我搞定了?"答案是否定的。Antlr只负责词法和语法,它知道你的代码"长什么样",但不知道你的a=3这个a和后面那个a是不是同一个变量,也不知道a+true这个表达式类型对不对、该报错误还是警告、应该报在哪个行列号。这些语义层面的检查,需要你自己实现,而实现它们的核心数据结构,就是符号表(Symbol Table)。
编译器处理一个程序大体分前端和后端:前端做词法分析、语法分析、语义分析,输出中间表示;后端做优化和代码生成。词法分析和语法分析正好是Antlr擅长的,语义分析是你要写的。语义分析的第一个核心任务,就是构造和查询符号表:记录每一个标识符(变量、函数、类、参数)的名字、类型、作用域、声明位置等信息。你在热搜词里看到"编译原理符号表",说明很多人学到这块就开始卡壳了——正常,因为本科教材讲符号表时往往用很抽象的C语言伪代码。咱们用Antlr的Context树来落地它,反而直观得多。
4.2 符号表的基本设计:一个Map走天下,再叠作用域
最简单的情况下,符号表就是一张哈希表:
java复制Map<String, Symbol> symbols = new HashMap<>();
Symbol对象里放名字、类型、行号列号,没别的。但真实语言有作用域问题:两个代码块里可以各有一个叫i的变量,甚至内层可以遮蔽外层,那简单的全局Map就搞不定了。
常用设计是"作用域链"(Scope Chain),最外层是全局作用域,函数或代码块创建子作用域,子作用域通过引用指向父作用域。查询变量时,先查当前作用域,查不到就往父作用域找,直到找到或到顶。Antlr的Listener为我们提供了完美的插入点:enterBlock方法在进入一个代码块时触发,这时我们创建一个新的作用域;exitBlock方法离开时撤掉这个作用域。由于Antlr的语法树天然嵌套,它的enter/exit回调顺序正好是深度优先的,和"作用域进入/退出"的语义完全对应。
4.3 用Antlr的enter/exit实现作用域管理
我们给前面的表达式求值器加一个"块作用域"功能。先扩展一下文法,让它支持代码块和变量声明:
antlr复制stat: 'let' ID '=' expr # LetStat
| 'if' expr 'then' stat+ 'end' # IfStat
| expr # ExprStat
;
然后在Listener里这样管理作用域:
java复制public class SymbolTableListener extends ExprBaseListener {
private final Deque<Scope> scopes = new ArrayDeque<>();
private Scope current;
@Override
public void enterIfStat(ExprParser.IfStatContext ctx) {
// 进入if块,创建一个子作用域
Scope scope = new Scope(current);
scopes.push(scope);
current = scope;
}
@Override
public void exitIfStat(ExprParser.IfStatContext ctx) {
// 离开if块,恢复父作用域
scopes.pop();
current = current.parent;
}
@Override
public void exitLetStat(ExprParser.LetStatContext ctx) {
String name = ctx.ID().getText();
if (current.lookupLocal(name) != null) {
throw new RuntimeException("变量重复声明: " + name);
}
current.define(new VariableSymbol(name));
}
}
每次进入一个嵌套结构,就往栈顶压入一个新Scope,离开时弹出。查询时从current开始溯源。这个模式在写解释器的时候太常用了,以至于后来每次遇到"需要作用域"的语言特性,我都是这个套路。你可别小看这段代码,很多主流语言的编译器前端,作用域管理的基本原理也就是这几行。
4.4 类型检查与错误报告:错误信息要不要带上行列号
作用域有了,接下来可以做类型检查。比如我们想让这个迷你语言只允许整数运算(像现在的实现),还想加个布尔类型,那么在visitExpr或者Listener的exitExpr里,检查一下两个操作数的类型是否一致。不一致就报错。
这里有个特别容易忽略的细节:错误信息一定带上行列号。Antlr的Token自带getLine()和getCharPositionInLine(),你要在自己定义的异常里把这些信息带出来,不要只丢一个"类型不匹配"给用户。用户在几千行DSL配置里,没有行列号的错误提示几乎等于谋杀。
我在实际项目里是这么组织的:自己定义一个SemanticError异常,带上行、列、消息三个字段,然后在全局异常处理里统一格式化输出:
java复制class SemanticError extends RuntimeException {
final int line;
final int col;
SemanticError(int line, int col, String msg) {
super(msg);
this.line = line;
this.col = col;
}
}
这样报错出来是第3行第5列: 变量 b 未定义,用户按图索骥,很快就能定位。别小看这个细节,它决定你的工具是"能用"还是"好用"。
4.5 符号表设计中的三个常见坑
第一,变量声明和引用检查的时机。声明时,遇到let x = ...,要先把x加入当前作用域,再去检查右边表达式的引用。如果顺序反了,右边表达式里引用自身的递归定义(如果语言不支持递归声明的话)就会出错。第二,遮蔽(shadowing)语义。大多数语言允许内层变量遮蔽外层同名变量,你查符号表时要返回"最近的定义",而不是"第一个定义"。第三,遍历顺序。如果你用Listener做语义检查,记住是在exitXxx里检查(此时子节点都处理完了)还是在enterXxx里检查(子节点还没动),取决于你的语言语义。比如变量声明,我通常在exitLetStat里定义符号,但有些语言要求"先声明后使用",那你就得在进语句块时先做一轮声明收集,再在同一个块里做引用检查。这类问题用Listener的enter/exit两个阶段能优雅解决,但前提是你得想好每一类检查放在哪个阶段。
5. 真实项目中的Antlr适配与避坑记录
5.1 错误恢复机制:怎么让用户少骂你两句
Antlr默认的解析器遇到语法错误时,会尝试从错误点恢复(错误恢复机制是"单Token删除"和"重新同步"),但默认的ConsoleErrorListener只往控制台打一行信息,不够友好。生产环境我建议你替换掉默认错误监听器,自己实现一个ANTLRErrorListener。
java复制public class CollectingErrorListener extends BaseErrorListener {
private final List<SyntaxError> errors = new ArrayList<>();
@Override
public void syntaxError(Recognizer<?, ?> recognizer,
Object offendingSymbol,
int line,
int charPositionInLine,
String msg,
RecognitionException e) {
errors.add(new SyntaxError(line, charPositionInLine, msg));
}
}
然后在创建parser后挂上去:
java复制parser.removeErrorListeners();
parser.addErrorListener(new CollectingErrorListener());
这样把错误全部收集起来,统一展示,而不是边解析边往控制台吐,浏览器端和IDE插件集成时体验会好很多。注意还要监听Lexer的错误,因为词法错误同样会导致解析失败,别只处理了parser就把词法错误漏了。
Antlr的错误恢复其实是双刃剑:它能从错误里"恢复"过来继续解析,有助于一次性报告多处错误;但也可能因为恢复策略,让后面的语法树内容和你预期的不一样。我自己的习惯是:有错误就停止语义分析,因为一棵不完整的语法树去做类型检查,很容易产生一连串"假错误"。
5.2 性能与内存:不是所有场景都适合完整语法树
Antlr默认会构造一棵完整的语法树,所有Token都留在内存里。这对于配置文件、DSL脚本、源码文件来说毫无压力,但如果你要解析很大的文件——比如几十MB的日志、上百万行的数据文件——默认方案就会有点吃力。我遇到过一次,解析一个几百万行的表达式文件,堆内存直接飙到好几个GB。
解决方案有几种。一是用UnbufferedTokenStream替换CommonTokenStream,让Token不再全部缓存。但注意它有一些限制:不支持任意回溯和向后看太远,所以不是所有文法都能无脑切。二是遍历模式的选择,如果你用Listener做流式处理,不用担心语法树驻留,但如果用ParseTreeWalker,它还是会把树建出来。Antlr 4提供了Parser.getParseTree()的惰性构建机制,实际上默认已经比较省了,真正占内存的不是树结构本身,而是Token流。三是可以考虑用SAX式的处理思路,用BailErrorStrategy加自定义的动作代码,在解析过程中直接打印或输出结果,完全不生成树。这条路适合极端场景,但对文法本身有要求(不能有需要回溯的结构)。
我给你的建议是:常规项目放心用默认方案,服务器内存通常不在乎那几十MB;真到超大文件场景,先测一测再说,别提前优化。
5.3 三个必踩的文法坑
坑一:隐式Token定义。你在语法规则里直接写了'let'这类字符串字面量,Antlr会自动为它创建一个匿名词法Token。这很方便,但如果你后面又想在词法规则里引用这个Token,或者在语法规则里同时写了'let'和ID: [a-z]+,就会遇到优先级问题——'let'这个隐式Token的优先级可能会比你的ID规则更高或更低,导致let永远识别不成标识符。尽量在词法规则里显式定义所有关键字的Token,命名成LET: 'let';,再在语法规则里引用LET,让一切都在掌控之中。
坑二:左右递归和优先级。Antlr 4支持直接左递归,但不支持间接左递归。也就是说expr -> expr '+' expr可以,但expr -> term、term -> expr这种"绕一圈回来的左递归"会让Antlr直接报错。碰到间接左递归,你得手动替换成直接左递归或者拆分规则。另外,Antlr解决优先级的方法是"按替代分支的出现顺序",越靠前的分支优先级越高。如果你把加减法写在乘除法前面,那1 + 2 * 3可能就变成了(1 + 2) * 3。这是我见过最多人翻车的地方。
坑三:空白和注释的处理。很多人忘了在词法规则里跳过空白:
antlr复制WS: [ \t\r\n]+ -> skip;
如果不加这条,遇到空格就会报告"token recognition error"。还有注释,如果你解析的是某种配置文件语言,记得也要加注释词法规则,否则配置里一出现//就会炸。
5.4 与Java、Swift等语言生态的结合
Antlr是Java的亲儿子,Java生态集成最顺。除了直接嵌在业务代码里,还有一套很成熟的"语言服务器"玩法:把Antlr生成的解析器包在一个后端服务里,通过Language Server Protocol(LSP)对接IDE,这样你就能用自己的DSL获得代码补全、错误提示、跳转定义等IDE能力。我和朋友做过一个内部配置DSL的LSP,核心就是Antlr解析加上符号表,工作量比想象的小——Antlr把那块最难啃的解析部分包圆了。
热搜词里还出现了"Swift编译原理"。Swift编译器(swiftc)自己内部的语法分析并不直接使用Antlr,它有自己的一套手写解析器(SwiftParser),但Swift生态里不少开源工具(比如SwiftSyntax,使用Swift编写的高性能语法分析库)也同样基于类似"文法定义 + 语法树 + 访问者"的模式。你要是掌握了Antlr,再去看SwiftSyntax这类工具,会发现思路都是通的。换句话说,Antlr的通用价值不在于"某一门语言必用它",而在于它给了你一套通用的"用文法去描述语言、自动生成解析器"的思维模型。换到哪个语言,底层逻辑都是词法、语法、语义三步走。
5.5 测试策略:解析器本质上是状态机,必须快照测试
解析器这类代码,最容易出现的问题是"改了一个文法规则,别的地方悄悄地错了"。正则表达式有回归问题,文法更严重。我的实践是快照测试(Snapshot Testing)加模糊测试(Fuzz Testing)配合使用。
快照测试:准备一批典型输入(正常的、带空格的、带注释的、嵌套很深的、故意写错的),把Antlr生成的语法树(用tree.toStringTree(parser)打印出来)或求值结果存成快照文件,每次改动后跑一遍,和快照对比。这样一来,文法的任何结构性变化,立刻能看出来。toStringTree()这个调试方法非常实用,输出类似(prog (stat (expr 3 + 4) NEWLINE)),一眼能看出树长什么样。
模糊测试:用随机生成器产生大量输入字符串喂给解析器,看它会不会抛异常。Antlr的解析器理论上对任何输入都不会抛未捕获的异常,但如果你在Visitor里写了ctx.expr(0)这种假设"一定有第0个子节点"的代码,遇到语法不完整的输入就可能炸。模糊测试能帮你把这些野路子边界全部找出来。
我到今天都还留着一个习惯:每次写完.g4文件,先跑一遍样例输入看语法树形状,再补两个错误输入确认错误恢复不崩溃,最后才写业务逻辑。这个流程帮我省了无数debug的夜晚。
最后再说点实际的
说了这么多,最后分享点自己折腾Antlr的体会。
第一,不要一上来就想着写一个通用编程语言。绝大部分人用Antlr是为了做DSL——配置文件、规则引擎、查询语言。DSL的文法控制在几百行内,Antlr完全够用,再复杂就该想想是不是设计出问题了。第二,认真读生成的代码和调试树。花一天时间把ExprParser.java里的递归下降方法读明白,比看十篇博客都管用。语法树的世界里,"这棵树长什么样"决定一切,多用toStringTree()打印出来看。第三,符号表和类型检查这些语义分析,才是一个语言的灵魂,Antlr解决的是门槛,不是终点。但反过来,正因为Antlr把门槛降到这个程度,我们这些普通应用开发者,才敢去碰"自己设计一门语言"这种事。
如果你现在正卡在某个语法分析需求上,别犹豫,用Antlr先把解析跑通,再考虑优化和扩展。跑通一次,你就会理解为什么这个叫"另一个语言识别工具"的小项目,能在编译器工具链里火这么多年。
