对于我们的第一个项目,让我们构建一个用于识别 C 或其衍生语言(如 Java)的一个小型子集的语法。具体来说,我们希望能够识别可能嵌套的大括号中的整数,例如 {1, 2, 3} 和 {1, {2, 3}, 4}。这些结构可以是整数数组或结构体的初始化器。这样的语法在各种情况下都会很有用。例如,我们可以使用它来构建一个用于 C 代码重构的工具,将整数数组转换为字节数组,如果所有初始化的值都适合一个字节的范围内。我们还可以使用这个语法将初始化的 Java 短整型数组转换为字符串。例如,我们可以将以下代码转换为:
static short[] data = {1,2,3};
转换为以下等价的字符串,其中包含 Unicode 常量:
static String data = "\u0001\u0002\u0003"; // Java char are unsigned short
其中,Unicode 字符指定符(例如 \u0001)使用四个十六进制数字表示一个16位字符值,即一个 short 值。
我们之所以希望进行这种转换,是为了克服 Java .class 文件格式的限制。Java 类文件将数组初始化器存储为一系列显式的数组元素初始化器,相当于 data[0]=1; data[1]=2; data[2]=3;,而不是紧凑的字节块。由于 Java 限制了初始化方法的大小,因此也限制了我们可以初始化的数组大小。相比之下,Java 类文件将字符串存储为连续的 short 序列。将数组初始化器转换为字符串可以得到一个更紧凑的类文件,并避免了 Java 的初始化方法大小限制。
通过完成这个起始示例,你将学习一些 ANTLR 语法的语法规则,了解 ANTLR 从语法生成的内容,以及如何将生成的解析器集成到 Java 应用程序中,以及如何使用解析树监听器构建一个翻译器。
ANTLR 工具、运行时和生成的代码
开始之前,让我们来看一下 ANTLR 的 JAR 包内部。ANTLR 有两个关键组件:ANTLR 工具本身和 ANTLR 运行时(解析时)API。当我们说 "在语法上运行 ANTLR" 时,我们指的是运行 ANTLR 工具,即 org.antlr.v4.Tool 类。运行 ANTLR 会生成代码(解析器和词法分析器),用于识别由语法描述的语言中的句子。词法分析器将输入的字符流分割成标记(tokens),并将它们传递给语法分析器,以检查语法的正确性。运行时是由生成的代码所需的类和方法的库,例如 Parser、Lexer 和 Token。首先,我们对语法运行 ANTLR,然后将生成的代码与 JAR 包中的运行时类进行编译。最终,编译后的应用程序与运行时类一起运行。
构建语言应用程序的第一步是创建一个描述语言的句法规则(有效句子集)的语法。我们将在第5章 "设计语法"(第57页)中学习如何编写语法规则,但现在先来看一个可以满足我们要求的语法规则示例:
/** Grammars always start with a grammar header. This grammar is called
* ArrayInit and must match the filename: ArrayInit.g4
*/
grammar ArrayInit;
/** A rule called init that matches comma-separated values between {...}. */
init : '{' value (',' value)* '}' ; // must match at least one value
/** A value can be either a nested array/struct or a simple integer (INT) */
value : init
| INT
;
// parser rules start with lowercase letters, lexer rules with uppercase
INT : [0-9]+ ; // Define token INT as one or more digits
WS : [ \t\r\n]+ -> skip ; // Define whitespace rule, toss it out
让我们将语法文件 ArrayInit.g4 放在一个独立的目录中,例如 /tmp/array(通过复制粘贴或从书籍网站下载源代码)。然后,我们可以在语法文件上运行 ANTLR(工具)。
$ cd /tmp/array
$ antlr4 ArrayInit.g4 # Generate parser and lexer using antlr4 alias
从语法文件 ArrayInit.g4 中,ANTLR 会生成许多文件,通常我们需要手动编写这些文件。
在这一点上,我们只是试图了解开发过程的要点,所以这里快速描述一下生成的文件:
ArrayInitParser.java:该文件包含特定于 ArrayInit 语法的解析器类定义,用于识别我们的数组语言语法。
public class ArrayInitParser extends Parser { ... }
它包含了语法中每个规则的方法以及一些辅助代码。
ArrayInitLexer.java:ANTLR 会根据我们的语法自动提取独立的解析器和词法分析器规范。该文件包含词法分析器类定义,ANTLR 通过分析词法规则 INT 和 WS,以及语法中的文本 '{'、',' 和 '}' 生成该类。请记住,词法分析器将输入进行标记化,将其分割成词汇符号。以下是该类的概述:
public class ArrayInitLexer extends Lexer { ... }
ArrayInit.tokens:ANTLR 为我们定义的每个标记类型分配一个标记类型编号,并将这些值存储在该文件中。当我们将一个大的语法拆分成多个较小的语法时,ANTLR 需要这些编号来同步所有标记类型。详见第36页的 "导入语法"。
ArrayInitListener.java、ArrayInitBaseListener.java:默认情况下,ANTLR 解析器会从输入构建一棵树。通过遍历该树,树遍历器可以向我们提供的监听器对象触发“事件”(回调)。ArrayInitListener 是描述我们可以实现的回调的接口。ArrayInitBaseListener 是一组空的默认实现。该类使我们可以轻松地仅覆盖我们感兴趣的回调函数。(参见第7.2节 "使用解析树监听器实现应用程序",第112页。)ANTLR 还可以使用 -visitor 命令行选项为我们生成树访问器。(参见第119页的 "使用访问器遍历解析树"。)
我们将使用监听器类来将短数组初始化器转换为字符串对象(对双关语抱歉),但首先让我们验证一下我们的解析器是否能正确匹配一些示例输入。
测试生成的解析器
在我们对语法运行 ANTLR 后,我们需要编译生成的 Java 源代码。我们可以通过简单地编译 /tmp/array 目录中的所有内容来完成这个过程。
$ cd /tmp/array
$ javac *.java # Compile ANTLR-generated code
如果编译器出现 ClassNotFoundException 错误,那很可能是因为您没有正确设置 Java CLASSPATH。在 UNIX 系统上,您需要执行以下命令(并可能将其添加到启动脚本,如 .bash_profile):
$ export CLASSPATH=".:/usr/local/lib/antlr-4.13.0-complete.jar:$CLASSPATH"
为了测试我们的语法,我们可以使用前一章中介绍的 TestRig(通过别名 grun)工具。以下是如何打印词法分析器创建的标记(tokens)的方法:
➾ $ grun ArrayInit init -tokens
➾ {99, 3, 451}
➾ EOF
❮ [@0,0:0='{',<1>,1:0]
[@1,1:2='99',<4>,1:1]
[@2,3:3=',',<2>,1:3]
[@3,5:5='3',<4>,1:5]
[@4,6:6=',',<2>,1:6]
[@5,8:10='451',<4>,1:8]
[@6,11:11='}',<3>,1:11]
[@7,13:12='<EOF>',<-1>,2:0]
在输入数组初始化器 {99, 3, 451} 后,我们需要在单独的一行上输入 EOF2。默认情况下,ANTLR 在处理之前会加载整个输入。(这是最常见和最高效的情况。) 输出的每一行代表一个单独的标记,并显示我们对该标记的了解。例如,[@5,8:10='451',<4>,1:8] 表示它是索引为5的标记(从0开始索引),从字符位置8到10(包括从0开始),文本为451,标记类型为4(INT),位于第1行(从1开始计数),字符位置为8(从零开始计数,将制表符视为单个字符)。请注意,空格和换行符没有标记。
我们的语法中的规则 WS 通过 -> skip 指令将它们排除在外。 为了了解解析器如何识别输入,我们可以使用 -tree 选项来请求解析树的输出。
➾ $ grun ArrayInit init -tree
➾ {99, 3, 451} ➾EOF
❮ (init { (value 99) , (value 3) , (value 451) })
选项 -tree 以类似 LISP 的文本形式(根节点 子节点)打印出解析树。或者,我们可以使用 -gui 选项在对话框中可视化树形结构。尝试使用嵌套的整数组作为输入来查看效果:{1,{2,3},4}。
➾ $ grun ArrayInit init -gui
➾ {1,{2,3},4}
➾ EOF
这是弹出的解析树对话框:
解析树的英文解释如下:“输入是一个由大括号包围的包含三个值的初始化器。第一个和第三个值是整数1和4。第二个值本身是一个由大括号包围的初始化器,其中包含两个值。这些值是整数2和3。”
那些内部节点 init 和 value 非常有用,因为它们通过名称标识了所有不同的输入元素。这有点像在英语句子中标识动词和主语。最好的部分是,ANTLR 根据我们语法中的规则名称自动为我们创建这棵树。我们将在本章末尾基于这个语法构建一个翻译器,使用内置的树遍历器来触发像 enterInit() 和 enterValue() 这样的回调函数。
既然我们可以在语法上运行 ANTLR 并对其进行测试,那么现在是时候思考如何从 Java 应用程序中调用这个解析器了。
将生成的解析器集成到 Java 程序中
一旦我们对语法有了良好的起点,就可以将ANTLR生成的代码集成到更大的应用程序中。在本节中,我们将看一个简单的Java主函数(main()),它调用我们的初始化器解析器,并像TestRig的 -tree 选项那样打印出解析树。以下是一个样板文件 Test.java,它体现了我们在第2.1节“让我们开始吧!”(第9页)中看到的整体识别器数据流:
// import ANTLR's runtime libraries
import org.antlr.v4.runtime.*; import org.antlr.v4.runtime.tree.*;
public class Test {
public static void main(String[] args) throws Exception {
// create a CharStream that reads from standard input
ANTLRInputStream input = new ANTLRInputStream(System.in); // create a lexer that feeds off of input CharStream
ArrayInitLexer lexer = new ArrayInitLexer(input); // create a buffer of tokens pulled from the lexer
CommonTokenStream tokens = new CommonTokenStream(lexer); // create a parser that feeds off the tokens buffer
ArrayInitParser parser = new ArrayInitParser(tokens);
ParseTree tree = parser.init(); // begin parsing at init rule
System.out.println(tree.toStringTree(parser)); // print LISP-style tree
}
}
该程序使用了一些类,如 CommonTokenStream 和 ParseTree,这些类来自ANTLR的运行时库,我们将从第4.1节“匹配算术表达式语言”(第32页)开始学习更多相关内容。 以下是编译所有内容并运行 Test 的步骤:
➾ $ javac ArrayInit*.java Test.java
➾ $ java Test
➾ {1,{2,3},4}
➾ EOF
❮ (init { (value 1) , (value (init { (value 2) , (value 3) })) , (value 4) })
ANTLR 解析器还会自动报告和从语法错误中恢复。例如,如果我们输入一个缺少最后一个右花括号的初始化器,会发生以下情况:
➾ $ java Test
➾ {1,2
➾ EOF
❮ line 2:0 missing '}' at '<EOF>'
(init { (value 1) , (value 2) <missing '}'>)
到目前为止,我们已经学习了如何在语法上运行 ANTLR,并将生成的解析器集成到一个简单的Java应用程序中。然而,仅仅检查语法的应用程序并不太令人印象深刻,所以让我们最后来构建一个翻译器,将短数组初始化器转换为字符串对象。
构建语言应用程序
在我们继续进行数组初始化器的示例时,我们的下一个目标是进行翻译,而不仅仅是识别初始化器。例如,让我们将类似于 {99, 3, 451} 的 Java 短整型数组翻译为 "\u0063\u0003\u01c3",其中 63 是 99 的十六进制表示。
要进行翻译,应用程序需要从解析树中提取数据。最简单的方法是让ANTLR的内置解析树遍历器在执行深度优先遍历时触发一系列回调。正如我们之前所见,ANTLR会自动为我们生成一个监听器基础设施。这些监听器类似于 GUI 小部件上的回调(例如,按下按钮时会通知我们),或者类似于 XML 解析器中的 SAX 事件。
为了编写一个根据输入做出反应的程序,我们只需要在 ArrayInitBaseListener 的子类中实现一些方法即可。基本策略是当树遍历器调用时,每个监听器方法打印出输入的翻译片段。
监听器机制的优势在于我们不需要自己进行任何树遍历。实际上,我们甚至不需要知道运行时是如何遍历树来调用我们的方法。我们只知道我们的监听器在与语法规则相关的短语的开始和结束时会收到通知。正如我们将在第7.2节 "使用解析树监听器实现应用程序"(第112页)中看到的,这种方法减少了我们对ANTLR的学习量——除了短语识别外,我们回到了熟悉的编程语言领域。
开始一个翻译项目意味着要找出如何将每个输入标记或短语转换为输出字符串。为了做到这一点,最好手动翻译一些代表性的样本,以便确定常见的短语转换方式。在这种情况下,翻译是相当直接的。
翻译成英文,翻译是一系列的 "X 转为 Y" 规则。
- 将 { 转为 "。
- 将 } 转为 "。
- 将整数转为以 \u 为前缀的四位十六进制字符串。
为了编写翻译器,我们需要编写方法,在看到适当的输入标记或短语时打印出转换后的字符串。内置的树遍历器在看到各种短语的开始和结束时会触发监听器中的回调方法。以下是我们翻译规则的监听器实现:
/** Convert short array inits like {1,2,3} to "\u0001\u0002\u0003" */
public class ShortToUnicodeString extends ArrayInitBaseListener {
/** Translate { to " */
@Override
public void enterInit(ArrayInitParser.InitContext ctx) {
System.out.print('"');
}
/** Translate } to " */
@Override
public void exitInit(ArrayInitParser.InitContext ctx) {
System.out.print('"');
}
/** Translate integers to 4-digit hexadecimal strings prefixed with \\u */
@Override
public void enterValue(ArrayInitParser.ValueContext ctx) {
// Assumes no nested array initializers
int value = Integer.valueOf(ctx.INT().getText());
System.out.printf("\\u%04x", value);
}
}
我们不需要覆盖每个 enter/exit 方法,只需要关注我们关心的部分。唯一不熟悉的表达式是 ctx.INT(),它向上下文对象请求与该值调用匹配的整数 INT 标记。上下文对象记录了在识别规则过程中发生的所有事件。
唯一剩下的任务是创建一个基于之前显示的 Test 样板代码的翻译器应用程序。
// import ANTLR's runtime libraries
import org.antlr.v4.runtime.*; import org.antlr.v4.runtime.tree.*;
public class Translate {
public static void main(String[] args) throws Exception {
// create a CharStream that reads from standard input
ANTLRInputStream input = new ANTLRInputStream(System.in);
// create a lexer that feeds off of input CharStream
ArrayInitLexer lexer = new ArrayInitLexer(input);
// create a buffer of tokens pulled from the lexer
CommonTokenStream tokens = new CommonTokenStream(lexer);
// create a parser that feeds off the tokens buffer
ArrayInitParser parser = new ArrayInitParser(tokens);
ParseTree tree = parser.init(); // begin parsing at init rule
// Create a generic parse tree walker that can trigger callbacks
ParseTreeWalker walker = new ParseTreeWalker();
// Walk the tree created during the parse, trigger callbacks
walker.walk(new ShortToUnicodeString(), tree);
System.out.println(); // print a \n after translation
}
}
与样板代码的唯一区别是突出显示的部分,它创建了一个树遍历器并要求它遍历从解析器返回的树。当树遍历器遍历时,它会调用我们的 ShortToUnicodeString 监听器。
请注意:为了集中注意力和减少冗余,本书的剩余部分通常只显示重要或新颖的代码片段,而不是整个文件。如果您阅读电子版的本书,您可以随时单击代码片段的标题;标题栏是指向完整源代码的网页链接。您还可以在本书的网站上获取完整的源代码包。
让我们构建这个翻译器,并在我们的示例输入上尝试它。
➾ $ javac ArrayInit*.java Translate.java
➾ $ java Translate
➾ {99, 3, 451}
➾ EOF
❮ "\u0063\u0003\u01c3"
成功了!我们刚刚构建了我们的第一个翻译器,甚至没有触碰语法。我们只需要实现一些打印适当短语翻译的方法。此外,我们可以通过传入不同的监听器来生成完全不同的输出。监听器有效地将语言应用程序与语法隔离开来,使得语法可以在其他应用程序中重复使用。
在下一章中,我们将快速浏览ANTLR语法符号和关键特性,这些特性使得ANTLR强大且易于使用。