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 | grammar Rows; |
@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 | grammar Data; |
Data 语法的关键在于{$i<=$n}?
,这段动作的值是布尔类型的,它被称为一个语义判定。在匹配到 n 个输入整数之前,它的值保持为 true,其中 n 是 sequence 语法中的参数。当语义判定的值为 false 时,对应的备选分支就从语法中“消失”了,因此,它也就从生成的语法分析器中“消失”了。在本例中,语义判定的值为 false 时,(...)*
循环终止,从 sequence 规则返回。
词法分析特性
ANTLR 有三个与词法符号有关非常棒的特性。
孤岛语法:处理同一文件中的不同格式
有很多常见的文件格式包含了多重语言,例如 Java 文件中有注释和 Java 代码,ejs 等模板引擎有模板语言表达式和文本。不同格式的语言需要按照不同的方式进行处理,这样的现象被称为“孤岛语法”。
ANTLR 提供了一个名为词法分析模式(lexical modes)的词法分析器特性,帮助我们方便地处理混合了不同格式数据的文件。它的基本思想是,当遇到特殊的“哨兵”字符序列时,执行不同模式的切换。
我们不妨以 XML 作为例子。一个 XML 解析器会将除标签和实体转义(例如£
)之外的内容全部当作普通文本。当看到<
时,词法分析器会切换到 “INSIDE” 模式;当看到>
或者/>
时,它就切换回默认模式。
下面的语法展示了 XML 解析器的工作方式:
1 | /** |
提供一份如下所示的样例输入文件:
1 | <tools> |
测试步骤与结果如下所示:
值得一提的是,在上述启动测试组件的命令行中,使用的参数是 XML tokens,在正常情况下,这里应该是一个语法名加一个起始规则名。如果需要令测试组件只运行词法分析器而不运行语法分析器,我们可以指定参数为语法名加上一个特殊的规则名tokens
。
重写输入流
我们可以通过 TokenStreamRewriter 对象对输入流进行修改,然后再加以输出,从而实现对源代码插桩或重构。例如,我们可以通过以下代码实现一个监听器,来给每个类定义中加上一行序列化版本标示符(serialVersionUID):
1 | import org.antlr.v4.runtime.TokenStream; |
之后,我们在 main 程序中初始化一个 InsertSerialIDListener,并当遍历结束时打印词法符号流:
1 | ParseTreeWalker walker = new ParseTreeWalker(); // 新建一个标准的遍历器 |
注意,TokenStreamRewriter 实际上修改的是词法符号流的“视图”而非词法符号流本身。它认为所有对修改方法的调用都只是一个“指令”,然后将这些修改放入一个队列;在未来词法符号流被重新渲染为文本时,这些修改才会被执行。因此每次我们调用getText()
的时候,rewriter 对象都会执行上述队列中的指令。
将 Tokens 送入不同通道
语法分析器只处理一个通道,因此当我们想要忽略但保留某些 tokens 时(例如注释和空白字符),我们可以通过在语法文件书写特殊的指令,来将其送入其他通道。
1 | COMMENT |
->channel(HIDDEN)
和->skip
类似,也是一个词法分析器指令。在这里,它设置了这些 tokens 的通道号。这样,这些 tokens 就会被语法分析器忽略。token 流中仍然保存着这些原始的 tokens 序列,但在向语法分析器提供数据时忽略了那些处于已关闭通道的 tokens。
结语
ANTLR 学习的第三篇笔记到这里就结束了。尽管这一篇笔记很短,但是涵盖了 ANTLR 实践中一些比较现实的问题的解决方式。在了解这三篇笔记所涉及的学习内容后,如果还有兴趣,就可以正式展开对使用 ANTLR 开发语言类应用程序的学习了。
参考资料
- 《The Definitive ANTLR 4 Reference》