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-
