从定义到AST及其遍历方式,一文带你搞懂Antlr4
摘要:本文将首先介绍Antlr4 grammer的定义方式,如何通过Antlr4 grammer生成对应的AST,以及Antlr4 的两种AST遍历方式:Visitor方式和Listener方式。
1. Antlr4简单介绍
Antlr4(Another Tool for Language Recognition)是一款基于Java开发的开源的语法分析器生成工具,能够根据语法规则文件生成对应的语法分析器,广泛应用于DSL构建,语言词法语法解析等领域。现在在非常多的流行的框架中都用使用,例如,在构建特定语言的AST方面,CheckStyle工具,就是基于Antlr来解析Java的语法结构的(当前Java Parser是基于JavaCC来解析Java文件的,据说有规划在下个版本改用Antlr来解析),还有就是广泛应用在DSL构建上,著名的Eclipse Xtext就有使用Antlr。
Antlr可以生成不同target的AST(https://www.antlr.org/download.html),包括Java、C++、JS、Python、C#等,可以满足不同语言的开发需求。当前Antlr最新稳定版本为4.9,Antlr4官方github仓库中,已经有数十种语言的grammer(https://github.com/antlr/grammars-v4,不过虽然这么多语言的规则文法定义都在一个仓库中,但是每种语言的grammer的license是不一样的,如果要使用,需要参考每种语言自己的语法结构的license)。
本文将首先介绍Antlr4 grammer的定义方式(简单介绍语法结构,并介绍如何基于IDEA Antlr4插件进行调试),然后介绍如何通过Antlr4 grammer生成对应的AST,最后介绍Antlr4 的两种AST遍历方式:Visitor方式和Listener方式。
2. Antlr4规则文法
下面简单介绍一部分Antlr4的g4(grammar)文件的写法(主要参考Antlr4官方wiki:https://github.com/antlr/antlr4/blob/master/doc/index.md)。最有效的学习Antlr4的规则文法的写法的方法,就是参考已有的规则文法,大家在学习中,可以参考已有语言的文法。而且Antlr4已经实现了数十种语言的文法,如果需要自己定义,可以参考和自己的语言最接近的文法来开发。
2.1 Antlr4规则基本语法和关键字
首先,如果有一点儿C或者Java基础,对上手Antlr4 g4的文法非常快。主要有下面的一些文法结构:
- 注释:和Java的注释完全一致,也可参考C的注释,只是增加了JavaDoc类型的注释;
- 标志符:参考Java或者C的标志符命名规范,针对Lexer 部分的 Token 名的定义,采用全大写字母的形式,对于parser rule命名,推荐首字母小写的驼峰命名;
- 不区分字符和字符串,都是用单引号引起来的,同时,虽然Antlr g4支持 Unicode编码(即支持中文编码),但是建议大家尽量还有英文;
- Action,行为,主要有@header 和@members,用来定义一些需要生成到目标代码中的行为,例如,可以通过@header设置生成的代码的package信息,@members可以定义额外的一些变量到Antlr4语法文件中;
- Antlr4语法中,支持的关键字有:import, fragment, lexer, parser, grammar, returns, locals, throws, catch, finally, mode, options, tokens。
2.2 Antlr4语法介绍
2.2.1语法文件的整体结构及写法示例
Antlr4整体结构如下:
/** Optional javadoc style comment */ grammar Name; options {...} import ... ; tokens {...} channels {...} // lexer only @actionName {...} rule1 // parser and lexer rules, possibly intermingled ... ruleN
一般如果语法非常复杂,会基于Lexer和Parser写到两个不同的文件中(例如Java,可参考:https://github.com/antlr/grammars-v4/tree/master/java/java8),如果语法比较简单,可以只写到一个文件中(例如Lua,可参考:https://github.com/antlr/grammars-v4/blob/master/lua/Lua.g4)。
下面我们结合Lua.g4中的一部分语法结构,介绍使用方法。写Antlr4的文法,需要依据源码的结构来决定。定义时,依据源码文件的写法,从上到下开始构造语法结构。例如,下面是Lua.g4的一部分:
chunk : block EOF ; block : stat* retstat? ; stat : ';' | varlist '=' explist | functioncall | label | 'break' | 'goto' NAME | 'do' block 'end' | 'while' exp 'do' block 'end' | 'repeat' block 'until' exp | 'if' exp 'then' block ('elseif' exp 'then' block)* ('else' block)? 'end' | 'for' NAME '=' exp ',' exp (',' exp)? 'do' block 'end' | 'for' namelist 'in' explist 'do' block 'end' | 'function' funcname funcbody | 'local' 'function' NAME funcbody | 'local' attnamelist ('=' explist)? ; attnamelist : NAME attrib (',' NAME attrib)* ;
如上语法中,整个文件被表示成一个chunk,chunk表示为一个block和一个文件结束符(EOF);block又被表示为一系列的语句的集合,而每一种语句又有特定的语法结构,包含了特定的表达式、关键字、变量、常量等信息,然后递归表达式的文法组成,变量的写法等,最终全部都归结到Lexer(Token)上,递归树结束。
上面其实已经可以看到Antlr4规则的写法,下面介绍一部分比较重要的规则的写法。
2.2.2 替代标签
首先,如2.2.1节的代码所示,stat可以有非常多的类型,例如变量定义、函数定义、if、while等,这些都没有进行区分,这样解析出来语法树时,会很不清晰,需要结合很多的标记完成具体语句的识别,这种情况下,我们可以结合替代标签完成区分,如下代码:
stat : ';' | varlist '=' explist #varListStat | functioncall #functionCallStat | label #labelStat | 'break' #breakStat | 'goto' NAME #gotoStat | 'do' block 'end' #doStat | 'while' exp 'do' block 'end' #whileStat | 'repeat' block 'until' exp #repeatStat | 'if' exp 'then' block ('elseif' exp 'then' block)* ('else' block)? 'end' #ifStat | 'for' NAME '=' exp ',' exp (',' exp)? 'do' block 'end' #forStat | 'for' namelist 'in' explist 'do' block 'end' #forInStat | 'function' funcname funcbody #functionDefStat | 'local' 'function' NAME funcbody #localFunctionDefStat | 'local' attnamelist ('=' explist)? #localVarListStat ;
通过在语句后面,添加 #替代标签,可以将语句转换为这些替代标签,从而加以区分。
2.2.3 操作符优先级处理
默认情况下,ANTLR从左到右结合运算符,然而某些像指数群这样的运算符则是从右到左。可以使用选项assoc手动指定运算符记号上的相关性。如下面的操作:
expr : expr '^'<assoc=right> expr
^ 表示指数运算,增加 assoc=right,表示该运算符是右结合。
实际上,Antlr4 已经对一些常用的操作符的优先级进行了处理,例如加减乘除等,这些就不需要再特殊处理。
2.2.4 隐藏通道
很多信息,例如注释、空格等,是结果信息生成不需要处理的,但是我们又不适合直接丢弃,安全地忽略掉注释和空格的方法是把这些发送给语法分析器的记号放到一个“隐藏通道”中,语法分析器仅需要调协到单个通道即可。我们可以把任何我们想要的东西传递到其它通道中。在Lua.g4中,这类信息的处理如下:
COMMENT : '--[' NESTED_STR ']' -> channel(HIDDEN) ; LINE_COMMENT : '--' ( // -- | '[' '='* // --[== | '[' '='* ~('='|'['|'\r'|'\n') ~('\r'|'\n')* // --[==AA | ~('['|'\r'|'\n') ~('\r'|'\n')* // --AAA ) ('\r\n'|'\r'|'\n'|EOF) -> channel(HIDDEN) ; WS : [ \t\u000C\r\n]+ -> skip ; SHEBANG : '#' '!' ~('\n'|'\r')* -> channel(HIDDEN) ;
放到 channel(HIDDEN) 中的 Token,不会被语法解析阶段处理,但是可以通过Token遍历获取到。
2.2.5 常见词法结构
Antlr4采用BNF范式,用’|’表示分支选项,’*’表示匹配前一个匹配项0次或者多次,’+’ 表示匹配前一个匹配项至少一次。下面介绍几种常见的词法举例(均来自Lua.g4文件):
1) 注释信息
COMMENT : '--[' NESTED_STR ']' -> channel(HIDDEN) ; LINE_COMMENT : '--' ( // -- | '[' '='* // --[== | '[' '='* ~('='|'['|'\r'|'\n') ~('\r'|'\n')* // --[==AA | ~('['|'\r'|'\n') ~('\r'|'\n')* // --AAA ) ('\r\n'|'\r'|'\n'|EOF) -> channel(HIDDEN) ;
2) 数字
INT : Digit+ ; Digit : [0-9] ;
3) ID(命名)
NAME : [a-zA-Z_][a-zA-Z_0-9]* ;
3. 基于IDEA调试Antlr4语法规则(文法可视化)
如果要安装Antlr4,选择 File -> Settings -> Plugins,然后在搜索框搜索 Antlr安装即可,可以选择安装搜索出来的最新版本,下图是刚刚安装的ANTLR v4,版本是v1.15,支持最新的Antlr 4.9版本。
基于IDEA调试Antlr4语法一般步骤:
1) 创建一个调试工程,并创建一个g4文件
这里,我自己测试用Java开发,所以创建的是一个Maven工程,g4文件放在了src/main/resources 目录下,取名 Test.g4
2)写一个简单的语法结构
这里我们参考写一个加减乘除操作的表达式,然后在赋值操作对应的Rule上右键,可选择测试:
如上图,expr 表示的是一个乘法操作,所以我们如下测试:
但是,如果改成一个加法操作,则无法识别,只能识别到第一个数字。
这种情况下,就需要继续扩充 expr的定义,丰富不同的语法,来继续支持其他的语法,如下:
还可以继续扩充其他类型的支持,这样一步步将整个语言的语法都支持完整。这里,我们形成的一个完整的格式如下(表示整形数字的加减乘除):
grammar Test; @header { package zmj.test.antlr4.parser; } stmt : expr; expr : expr NUL expr # Mul | expr ADD expr # Add | expr DIV expr # Div | expr MIN expr # Min | INT # Int ; NUL : '*'; ADD : '+'; DIV : '/'; MIN : '-'; INT : Digit+; Digit : [0-9]; WS : [ \t\u000C\r\n]+ -> skip; SHEBANG : '#' '!' ~('\n'|'\r')* -> channel(HIDDEN);
4. Antlr4生成并遍历AST
4.1 生成源码文件
这一步介绍两种生成解析语法树的两种方法,供参考:
- Maven Antlr4插件自动生成(针对Java工程,也可以用于Gradle)
pom.xml设置Antlr4 Maven插件,可以通过执行 mvn generate-sources自动生成需要的代码(参考链接: https://www.antlr.org/api/maven-plugin/latest/antlr4-mojo.html,主要的意义在于,代码入库的时候,不需要再将生成的这些语法文件入库,减少库里面的代码冗余,只包含自己开发的代码,不会有自动生成的代码,也不需要做clean code整改),下面是一个示例:
<build> <plugins> <plugin> <groupId>org.antlr</groupId> <artifactId>antlr4-maven-plugin</artifactId> <version>4.3</version> <executions> <execution> <id>antlr</id> <goals> <goal>antlr4</goal> </goals> <phase>generate-sources</phase> </execution> </executions> <configuration> <sourceDirectory>${basedir}/src/main/resources</sourceDirectory> <outputDirectory>${project.build.directory}/generated-sources/antlr4/zmj/test/antlr4/parser</outputDirectory> <listener>true</listener> <visitor>true</visitor> <treatWarningsAsErrors>true</treatWarningsAsErrors> </configuration> </plugin> </plugins> </build>
按照上面设置后,只需要执行 mvn generate-sources 即可在maven工程中自动生成代码。
- 命令行方式
主要参考链接(https://www.antlr.org/download.html),有每种语言的语法配置,我们这里考虑下载Antlr4完整jar:
下载好后(antlr-4.9-complete.jar),可以使用如下命令来生成需要的信息:
java -jar antlr-4.9-complete.jar -Dlanguage=Python3 -visitor Test.g4
这样就可以生成Python3 target的源码,支持的源码可以从上面链接查看,如果不希望生成Listener,可以添加参数 -no-listener
4.2 访问者模式遍历Antlr4语法树
Antlr4在AST遍历时,支持两种设计模式:访问者设计模式 和 监听器模式。
对于 访问者设计模式,我们需要自己定义对 AST 的访问(https://xie.infoq.cn/article/5f80da3c014fd69f8dbe09b28,这是一篇针对访问者设计模式的介绍,大家可以参考)。下面直接通过代码展示访问者模式在Antlr4中使用(基于第3章的例子):
import org.antlr.v4.runtime.CharStream; import org.antlr.v4.runtime.CharStreams; import org.antlr.v4.runtime.CommonTokenStream; import zmj.test.antlr4.parser.TestBaseVisitor; import zmj.test.antlr4.parser.TestLexer; import zmj.test.antlr4.parser.TestParser; public class App { public static void main(String[] args) { CharStream input = CharStreams.fromString("12*2+12"); TestLexer lexer=new TestLexer(input); CommonTokenStream tokens = new CommonTokenStream(lexer); TestParser parser = new TestParser(tokens); TestParser.ExprContext tree = parser.expr(); TestVisitor tv = new TestVisitor(); tv.visit(tree); } static class TestVisitor extends TestBaseVisitor<Void> { @Override public Void visitAdd(TestParser.AddContext ctx) { System.out.println("========= test add"); System.out.println("first arg: " + ctx.expr(0).getText()); System.out.println("second arg: " + ctx.expr(1).getText()); return super.visitAdd(ctx); } } }
如上,main方法中,解析出了表达式的AST结构,同时在源码中也定义了一个Visitor:TestVisitor,访问AddContext,并且打印该加表达式的前后两个表达式,上面例子的输出为:
========= test add first arg: 12*2 second arg: 12
4.2 监听器模式(观察者模式)
对于监听器模式,就是通过监听某对象,如果该对象上有特定的事件发生,则触发该监听行为执行。比如有个监控(监听器),监控的是大门(事件对象),如果发生了闯门的行为(事件源),则进行报警(触发操作行为)。
在Antlr4中,如果使用监听器模式,首先需要开发一个监听器,该监听器可以监听每个AST节点(例如表达式、语句等)的不同的行为(例如进入该节点、结束该节点)。在使用时,Antlr4会对生成的AST进行遍历(ParseTreeWalker),如果遍历到某个具体的节点,并且执行了特定行为,就会触发监听器的事件。
监听器方法是没有返回值的(即返回类型是void)。因此需要一种额外的数据结构(可以通过Map或者栈)来存储当次的计算结果,供下一次计算调用。
一般来说,面向程序静态分析时,都是使用访问者模式的,很少使用监听器模式(无法主动控制遍历AST的顺序,不方便在不同节点遍历之间传递数据),用法对咱们也不友好,所以本文不介绍监听器模式,如果有兴趣,可以自己搜索测试使用。
5. Antlr4词法解析和语法解析
这部分实际上,算是Antlr4最基础的内容,但是放到最后一部分来讲,有特定的目的,就是探讨一下词法解析和语法解析的界限,以及Antlr4的结果的处理。
5.1 Antlr4执行阶段
如前面的语法定义,分为Lexer和Parser,实际上表示了两个不同的阶段:
- 词法分析阶段:对应于Lexer定义的词法规则,解析结果为一个一个的Token;
- 解析阶段:根据词法,构造出来一棵解析树或者语法树。
如下图所示:
5.2 词法解析和语法解析的调和
首先,我们应该有个普遍的认知:语法解析相对于词法解析,会产生更多的开销,所以,应该尽量将某些可能的处理在词法解析阶段完成,减少语法解析阶段的开销,主要下面的这些例子:
- 合并语言不关心的标记,例如,某些语言(例如js)不区分int、double,只有 number,那么在词法解析阶段,就不需要将int和double区分开,统一合并为一个number;
- 空格、注释等信息,对于语法解析并无大的帮助,可以在词法分析阶段剔除掉;
- 诸如标志符、关键字、字符串和数字这样的常用记号,均应该在词法解析时完成,而不要到语法解析阶段再进行。
但是,这样的操作在节省了语法分析的开销之外,其实对我们也产生了一些影响:
- 虽然语言不区分类型,例如只有 number,没有 int 和 double 等,但是面向静态代码分析,我们可能需要知道确切的类型来帮助分析特定的缺陷;
- 虽然注释对代码帮助不大,但是我们有时候也需要解析注释的内容来进行分析,如果无法在语法解析的时候获取,那么就需要遍历Token,从而导致静态代码分析开销更大等;
- …
这样的一些问题该如何处理呢?
5.3 解析树vs语法树
大部分的资料中,都把Antlr4生成的树状结构,称为解析树或者是语法树,但是,如果我们细究的话,可能说成是解析树更加准确,因为Antlr4的结果,只是简单的文法解析,不能称之为语法树(语法树应该是能够体现出来语法特性的信息),如上面的那些问题,就很难在Antlr4生成的解析树上获取到。
所以,现在很多工具,基于Antlr4进行封装,然后进行了更进一步地处理,从而获取到了更加丰富的语法树,例如CheckStyle。因此,如果通过Antlr4解析语言简单使用,可以直接基于Antlr4的结果开发,但是如果要进行更加深入的处理,就需要对Antlr4的结果进行更进一步的处理,以更符合我们的使用习惯(例如,Java Parser格式的Java的AST,Clang格式的C/C++的AST),然后才能更好地在上面进行开发。
本文分享自华为云社区《Antlr4简明使用教程》,原文作者:maijun 。