前面几篇讲解了词法分析和语法分析,在例子中提到的词法和语法规则也是高度简化的。虽然这些内容便于理解原理,也能实现一个简单的原型,在实际应用中却远远不够。实际应用中,一个完善的编译程序还要在词法方面以及语法方面实现很多工作,如下图可以直观地看一下。
如果让编译程序实现上面这么多工作,完全手写效率会有点儿低,我们可以借助现有的工具。编译器前端工具有很多,比如 Lex(以及 GNU 的版本 Flex)、Yacc(以及 GNU 的版本 Bison)、JavaCC 等等。选择 Antlr 主要有两方面原因:
- 第一个原因是 Antlr 能支持更广泛的目标语言,包括 Java、C#、JavaScript、Python、Go、C++、Swift。无论你用上面哪种语言,都可以用它生成词法和语法分析的功能。
- 第二个原因是 Antlr 的语法更加简单。它能把类似左递归的一些常见难点在工具中解决,对提升工作效率有很大的帮助。
初识 Antlr
Antlr 是一个开源的工具,支持根据规则文件生成词法分析器和语法分析器,它自身是用 Java 实现的。
可以下载 Antlr语法规则库到本地,方便查看。
可以下载 Antlr 工具,并根据说明做好配置。同时,你还需要配置好机器上的 Java 环境(可以在Oracle 官网找到最新版本的 JDK)。
对于Mac电脑为例:
$ cd /usr/local/lib
$ sudo curl -O https://www.antlr.org/download/antlr-4.9.2-complete.jar
// vim .bash_profile 增加如下内容
$ export CLASSPATH=".:/usr/local/lib/antlr-4.9.2-complete.jar:$CLASSPATH"
$ alias antlr4='java -jar /usr/local/lib/antlr-4.9.2-complete.jar'
$ alias grun='java org.antlr.v4.gui.TestRig'
$ source .bash_profile
用 Antlr 生成词法分析器
Antlr 通过解析规则文件来生成编译器。规则文件以.g4 结尾,词法规则和语法规则可以放在同一个文件里。不过为了清晰起见,我们还是把它们分成两个文件,先用一个文件编写词法规则。
先做一个简单的练习,创建一个 Hello.g4 文件,用于保存词法规则,然后把之前用过的一些词法规则写进去。
lexer grammar Hello; //lexer 关键字意味着这是一个词法规则文件,名称是 Hello,要与文件名相同
// 关键字
If : 'if';
Int : 'int';
// 字面量
IntLiteral: [0-9]+;
StringLiteral: '"' .*? '"' ; // 字符串字面量
// 操作符
AssignmentOP: '=' ;
RelationalOP: '>'|'>='|'<' |'<=' ;
Star: '*';
Plus: '+';
Sharp: '#';
SemiColon: ';';
Dot: '.';
Comm: ',';
LeftBracket : '[';
RightBracket: ']';
LeftBrace: '{';
RightBrace: '}';
LeftParen: '(';
RightParen: ')';
// 标识符
Id : [a-zA-Z_] ([a-zA-Z_] | [0-9])*;
// 空白字符,抛弃
Whitespace: [ \t]+ -> skip;
Newline: ( '\r' '\n'?|'\n')-> skip;
每个词法规则都是大写字母开头,这是 Antlr 对词法规则的约定。而语法规则是以小写字母开头的。其中,每个规则都是用我们已经了解的正则表达式编写的。
接下来,我们来编译词法规则,在终端中输入命令:
antlr4 Hello.g4
这个命令是让 Antlr 编译规则文件,并生成 Hello.java 文件和其他两个辅助文件。你可以打开看一看文件里面的内容。接着,我用下面的命令编译 Hello.java:
javac *.java
结果会生成 Hello.class 文件,这就是我们生成的词法分析器。接下来,我们来写个脚本文件,让生成的词法分析器解析一下:
int age = 45;
if (age >= 17+8+20){
printf("Hello old man!");
}
我们将上面的脚本存成 hello.play 文件,然后在终端输入下面的命令:
grun Hello tokens -tokens hello.play
grun 命令实际上是调用了我们刚才生成的词法分析器,即 Hello 类,打印出对 hello.play 词法分析的结果:
从结果中看到,我们的词法分析器把每个 Token 都识别了,还记录了它们在代码中的位置、文本值、类别。上面这些都是 Token 的属性。
以第二行 [@1, 4:6=‘age’,< Id >,1:4] 为例,其中 @1 是 Token 的流水编号,表明这是 1 号 Token;4:6 是 Token 在字符流中的开始和结束位置;age 是文本值,Id 是其 Token 类别;最后的 1:4 表示这个 Token 在源代码中位于第 1 行、第 4 列。
先看看我们之前写的字符串字面量的规则:
StringLiteral: '"' .*? '"' ; // 字符串字面量
我们的版本相当简化,就是在双引号可以包含任何字符。可这在实际中不大好用,因为连转义功能都没有提供。我们对于一些不可见的字符,比如回车,要提供转义功能,如“\n”。同时,如果字符串里本身有双引号的话,也要将它转义,如“\”。Unicode 也要转义。最后,转义字符本身也需要转义,如“\”。
下面这一段内容是 Java 语言中的字符串字面量的完整规则。你可以看一下文稿,这个规则就很细致了,把各种转义的情况都考虑进去了:
STRING_LITERAL: '"' (~["\\\r\n] | EscapeSequence)* '"';
fragment EscapeSequence
: '\\' [btnfr"'\\]
| '\\' ([0-3]? [0-7])? [0-7]
| '\\' 'u'+ HexDigit HexDigit HexDigit HexDigit
;
fragment HexDigit
: [0-9a-fA-F]
;
在这个规则文件中,fragment 指的是一个语法片段,是为了让规则定义更清晰。它本身并不生成 Token,只有 StringLiteral 规则才会生成 Token。
当然了,除了字符串字面量,数字字面量、标识符的规则也可以定义得更严密
词法规则中对 Token 归类的问题
在前面练习的规则文件中,我们把 >=、>、< 都归类为关系运算符,算作同一类 Token,而 +、* 等都单独作为另一类 Token。那么,哪些可以归并成一类,哪些又是需要单独列出的呢?
其实,这主要取决于语法的需要。也就是在语法规则文件里,是否可以出现在同一条规则里。它们在语法层面上没有区别,只是在语义层面上有区别。比如,加法和减法虽然是不同的运算,但它们可以同时出现在同一条语法规则中,它们在运算时的特性完全一致,包括优先级和结合性,乘法和除法可以同时出现在乘法规则中。你把加号和减号合并成一类,把乘号和除号合并成一类是可以的。把这 4 个运算符每个都单独作为一类,也是可以的。但是,不能把加号和乘号作为同一类,因为它们在算术运算中的优先级不同,肯定出现在不同的语法规则中。
我们再来回顾一下在“编译原理实战一:如何用JS实现一个词法分析器?”里做词法分析时遇到的一个问题。当时,我们分析了词法冲突的问题,即标识符和关键字的规则是有重叠的。Antlr 是怎么解决这个问题的呢?很简单,它引入了优先级的概念。在 Antlr 的规则文件中,越是前面声明的规则,优先级越高。所以,我们把关键字的规则放在 ID 的规则前面。算法在执行的时候,会首先检查是否为关键字,然后才会检查是否为 ID,也就是标识符。
这跟我们当时构造有限自动机做词法分析是一样的。那时,我们先判断是不是关键字,如果不是关键字,才识别为标识符。而在 Antlr 里,仅仅通过声明的顺序就解决了这个问题,省了很多事儿啊!
Antlr 生成语法分析器
现在已经知道如何用 Antlr 做一个词法分析器,还知道可以借鉴成熟的规则文件,让自己的词法规则文件变得更完善、更专业。接下来,试着用 Antlr 生成一个语法分析器,替代之前手写的语法分析器吧!
这一次的文件名叫做 PlayScript.g4。PlayScript 是为我们的脚本语言起的名称,文件开头是这样的:
grammar PlayScript;
import CommonLexer; // 导入词法定义
/* 下面的内容加到所生成的 Java 源文件的头部,如包名称,import 语句等。*/
@header {
package antlrtest;
}
然后把之前做过的语法定义放进去。Antlr 内部有自动处理左递归的机制,你可以放心大胆地把语法规则写成下面的样子:
expression
: assignmentExpression
| expression ',' assignmentExpression
;
assignmentExpression
: additiveExpression
| Identifier assignmentOperator additiveExpression
;
assignmentOperator
: '='
| '*='
| '/='
| '%='
| '+='
| '-='
;
additiveExpression
: multiplicativeExpression
| additiveExpression '+' multiplicativeExpression
| additiveExpression '-' multiplicativeExpression
;
multiplicativeExpression
: primaryExpression
| multiplicativeExpression '*' primaryExpression
| multiplicativeExpression '/' primaryExpression
| multiplicativeExpression '%' primaryExpression
;
你可能会问:“既然用 Antlr 可以不管左递归问题,那之前为什么要费力气解决它呢?”那是因为当你遇到某些问题却没有现成工具时,还是要用纯手工的方法去解决问题。而且,有的工具可能没有这么智能,你需要写出符合这个工具的规则文件,比如说不能有左递归的语法规则。还是那句话:懂得基础原理,会让你站得更高。
我们继续运行下面的命令,生成语法分析器:
antlr4 PlayScript.g4
javac antrl_grammer/*.java
然后测试一下生成的语法分析器:
grun antrl_grammer.PlayScript expression -gui
这个命令的意思是:测试 PlayScript 这个类的 expression 方法,也就是解析表达式的方法,结果用图形化界面显示。
我们在控制台界面中输入下面的内容
age + 10 * 2 + 10
^D
其中 ^D 是按下 Ctl 键的同时按下 D,相当于在终端输入一个 EOF 字符,即文件结束符号(Windows 操作系统要使用 ^Z)。当然,你也可以提前把这些语句放到文件中,把文件名作为命令参数。之后,语法分析器会分析这些语法,并弹出一个窗口来显示 AST:
看得出来,AST 完全正确,优先级和结合性也都没错。所以,Antlr 生成的语法分析器还是很靠谱的。以后,你专注写语法规则就行了,可以把精力放在语言的设计和应用上。
源码地址 antrl_grammer
课程内容参考自 极客时间 编译原理课程。