ANTLR v4 学习笔记(三)-特性拾遗

继续学习解释器构造和 ANTLR。在系列博文的上一篇 ANTLR v4 学习笔记(二)-实现变种计算器,我们用 ANTLR 实现了一个变种计算器,从而对 ANTLR 语法、访问器(Visitor)机制、集成以及 ANLTR 的错误处理机制有了更深的理解。

上篇博文对应着《The Definitive ANTLR 4 Reference》中的 Chapter 4 A Quick Tour,但没有覆盖整个 Chapter 4 的所有内容。因此这篇博文将介绍一下 Chapter 4 的剩余内容——监听器(Listener)机制、代码片段嵌入语法,以及词法分析的一些很棒的特性。

监听器机制

监听器机制和访问器机制有着很多共通的特点,我们也需要通过监听语法分析树遍历器触发的“事件”来完成某些工作。它们之间最大的区别在于,监听器的方法会被 ANTLR 提供的遍历器对象自动调用,而在访问器的方法中,必须显式调用visit方法来访问子节点,否则对应的子树将不会被访问。

ANTLR 会自动生成一个接口 xxListener,它定义了 ANTLR 的运行库中的 ParseTreeWalker 类在遍历语法分析树时能够触发的全部方法。当然,和访问器机制一样,我们无需实现接口中的全部方法。ANTLR 自动生成一个名为 xxBaseListener 的类,我们可以继承这个类并只重写那些我们感兴趣的方法。

代码片段嵌入语法

监听器机制和访问器一样,能够使语法分析过程和应用程序本身保持低耦合,也让语法更具有可读性。但有时候,为了满足比较苛刻的需求,我们需要将代码片段(动作)嵌入到语法中,这些动作会被拷贝到 ANTLR 自动生成的递归下降语法分析器的代码中。

将任意动作嵌入语法

拿下面这一份用于解析类表格文件的语法为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
grammar Rows;

@parser::members { // 在生成的 RowsParser 中添加一些成员
int col;
public RowsParser(TokenStream input, int col) { // 自定义的构造器
this(input);
this.col = col;
}
}

file: (row NL)+ ;

row
locals [int i=0]
: ( STUFF
{
$i++;
if ($i == col) System.out.println($STUFF.text);
}
)+
;

TAB : '\t' -> skip ; // 匹配但是不将其传递给语法分析器
NL : '\r'? '\n' ; // 匹配并将其传递给语法分析器
STUFF : ~[\t\r\n]+ ; // 匹配除 tab 符和换行符之外的任何字符

@parser::members表示在生成的 RowsParser 中添加一些成员,即大括号里的所有元素(包括)会原封不动的添加到 RowsParser 类中。我们通过加入的构造器来传入希望提取的列号。

row 规则的(...)+循环放置了一些动作,访问了之前使用 locals 子句定义的局部变量$i。同时 row 规则也使用了$STUFF.text来获得刚刚匹配的 STUFF 词法符号中包含的文本。关于动作的更多内容会在 Chapter 10 深入研究。

用语义判定改变语法分析过程

用一个读取一列整数的语法作为例子。首先看一下需求:输入的其中的一部分整数指定了接下来的多少个整数分为一组。例如输入:

1
2 8 43 3 1 4 6

第一个数字 2 表示匹配接下来的两个数字 8 和 43,数字 3 表示匹配接下来的三个数字。

语法文件如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
grammar Data;

file : group+ ;

group: INT sequence[$INT.int] ;

sequence[int n]
locals [int i = 1;]
: ( {$i<=$n}? INT {$i++;} )* // 匹配 n 个整数
;

INT : [0-9]+ ; // 匹配整数
WS : [ \t\n\r]+ -> skip ; // 丢弃所有的空白字符

Data 语法的关键在于{$i<=$n}?,这段动作的值是布尔类型的,它被称为一个语义判定。在匹配到 n 个输入整数之前,它的值保持为 true,其中 n 是 sequence 语法中的参数。当语义判定的值为 false 时,对应的备选分支就从语法中“消失”了,因此,它也就从生成的语法分析器中“消失”了。在本例中,语义判定的值为 false 时,(...)*循环终止,从 sequence 规则返回。

词法分析特性

ANTLR 有三个与词法符号有关非常棒的特性。

孤岛语法:处理同一文件中的不同格式

有很多常见的文件格式包含了多重语言,例如 Java 文件中有注释和 Java 代码,ejs 等模板引擎有模板语言表达式和文本。不同格式的语言需要按照不同的方式进行处理,这样的现象被称为“孤岛语法”

ANTLR 提供了一个名为词法分析模式(lexical modes)的词法分析器特性,帮助我们方便地处理混合了不同格式数据的文件。它的基本思想是,当遇到特殊的“哨兵”字符序列时,执行不同模式的切换。

我们不妨以 XML 作为例子。一个 XML 解析器会将除标签和实体转义(例如&pound;)之外的内容全部当作普通文本。当看到<时,词法分析器会切换到 “INSIDE” 模式;当看到>或者/>时,它就切换回默认模式。

下面的语法展示了 XML 解析器的工作方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/**
* Define a lexer grammar called XMLLexer
*/
lexer grammar XMLLexer;

// 默认模式:所有在标签之外的东西
OPEN : '<' -> pushMode(INSIDE) ;
COMMENT : '<!--' .*? '-->' -> skip ;
EntityRef : '&' [a-z]+ ';' ;
TEXT : ~('<'|'&')+ ; // 匹配任意除 < 和 & 之外的 16 位字符

// ---------------------- 所有在标签之内的东西 ----------------------
mode INSIDE;
CLOSE : '>' -> popMode ; // 回到默认模式
SLASH_CLOSE : '/>' -> popMode ;
EQUALS : '=' ;
STRING : '"' .*? '"' ;
SlashName : '/' Name ;
Name : ALPHA (ALPHA|DIGIT)* ;
S : [ \t\r\n] -> skip ;

fragment
ALPHA : [a-zA-Z] ;

fragment
DIGIT : [0-9] ;

提供一份如下所示的样例输入文件:

1
2
3
<tools>
<tool name="ANTLR">A parser generator</tool>
</tools>

测试步骤与结果如下所示:

值得一提的是,在上述启动测试组件的命令行中,使用的参数是 XML tokens,在正常情况下,这里应该是一个语法名加一个起始规则名。如果需要令测试组件只运行词法分析器而不运行语法分析器,我们可以指定参数为语法名加上一个特殊的规则名tokens

重写输入流

我们可以通过 TokenStreamRewriter 对象对输入流进行修改,然后再加以输出,从而实现对源代码插桩或重构。例如,我们可以通过以下代码实现一个监听器,来给每个类定义中加上一行序列化版本标示符(serialVersionUID):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import org.antlr.v4.runtime.TokenStream;
import org.antlr.v4.runtime.TokenStreamRewriter;

public class InsertSerialIDListener extends JavaBaseListener {
TokenStreamRewriter rewriter;
public InsertSerialIDListener(TokenStream tokens) {
rewriter = new TokenStreamRewriter(tokens);
}

@Override
public void enterClassBody(JavaParser.ClassBodyContext ctx) {
String field = "\n\tpublic static final long serialVersionUID = 1L;";
rewriter.insertAfter(ctx.start, field);
}
}

之后,我们在 main 程序中初始化一个 InsertSerialIDListener,并当遍历结束时打印词法符号流:

1
2
3
4
5
6
ParseTreeWalker walker = new ParseTreeWalker();  // 新建一个标准的遍历器
InsertSerialIDListener extractor = new InsertSerialIDListener(tokens);
walker.walk(extractor, tree); // 使用监听器初始化对语法分析树的遍历

// 打印出修改后的词法符号流
System.out.println(extractor.rewriter.getText());

注意,TokenStreamRewriter 实际上修改的是词法符号流的“视图”而非词法符号流本身。它认为所有对修改方法的调用都只是一个“指令”,然后将这些修改放入一个队列;在未来词法符号流被重新渲染为文本时,这些修改才会被执行。因此每次我们调用getText()的时候,rewriter 对象都会执行上述队列中的指令。

将 Tokens 送入不同通道

语法分析器只处理一个通道,因此当我们想要忽略但保留某些 tokens 时(例如注释和空白字符),我们可以通过在语法文件书写特殊的指令,来将其送入其他通道。

1
2
3
4
5
COMMENT
: '/*' .*? '*/' -> channel(HIDDEN) // 匹配 /* 和 */ 之间的任何东西
;
WS : [ \r\t\u000c\n]+ -> channel(HIDDEN)
;

->channel(HIDDEN)->skip类似,也是一个词法分析器指令。在这里,它设置了这些 tokens 的通道号。这样,这些 tokens 就会被语法分析器忽略。token 流中仍然保存着这些原始的 tokens 序列,但在向语法分析器提供数据时忽略了那些处于已关闭通道的 tokens。

结语

ANTLR 学习的第三篇笔记到这里就结束了。尽管这一篇笔记很短,但是涵盖了 ANTLR 实践中一些比较现实的问题的解决方式。在了解这三篇笔记所涉及的学习内容后,如果还有兴趣,就可以正式展开对使用 ANTLR 开发语言类应用程序的学习了。

参考资料

  • 《The Definitive ANTLR 4 Reference》