本章我们将会学习词法及语法规则,以及四种抽象的计算机语言模式。因为ANTLR的语法规则跟正则表达式是很类似的,所以还是推荐先阅读下正则表达式的相关内容,这样在编写语法文件时可以事半功倍。
虽然在过去的50年里人们发明了许许多多的编程语言,但是,相对而言,基本的语言模式种类并不多。之所以如此,是因为人们在设计编程语言的时候,倾向于将它们设计成与脑海中的自然语言相类似。我们希望符号按照有效的顺序排列,并且符号之间拥有着特定的依赖关系。举个例子,{(}) 就是不符合语法的,因为符号的顺序不对。
单词之间的顺序和依赖性约束是来自于自然语言的,基本上可以总结成四种抽象的计算机语言模式。
常用的通配符如下所示:
下面通过识别一些常见的词法符号来学习下通配符的用法:
1. 关键字、运算符和标点符号:对于关键字、运算符和标点符号,我们无须声明词法规则,只需在语法规则中直接使用单引号将他们括起来即可,比如 'while'、'+'。
2. 标识符:一个基本的标识符就是一个由大小写字母组成的字符序列。需要注意的是,下面的ID规则也能够匹配关键字(比如‘while’)等,上章中我们查看了Parser代码,知道ANTLR是如何处理这种歧义性的——选择所有匹配的备选分支中的第一条。因此,ID标识符应该放在关键字等定义之后。
// 匹配一个或者多个大小写字母
ID : [a-zA-Z]+;
3. 整数:整数是包括正数和负数的不以零开头的数字。
// 匹配一个整数
INTEGER : '-'?[1-9][0-9]*
| '0'
;
4. 浮点数:一个浮点数以一列数字为开头,后面跟着一个小数点,然后是可选的小数部分。浮点数的另外一个格式是,以小数点开头,后面是一串数字。基于以上定义,我们可以得到以下词法规则
FLOAT : DIGIT+ '.' DIGIT* // 1.39、3.14159等
| '.' DIGIT+ // .12 (表示0.12)
;
fragment DIGIT : [0-9]; // 匹配单个数字
这里我们使用了一条辅助规则DIGIT,将一条规则声明为fragment可以告诉ANTLR,该规则本身不是一个词法符号,它只会被其他的词法规则使用。这意味着在语法规则中不能引用它。这也是一条片段规则(fragment rule)。
5. 字符串常量:一个字符串就是两个双引号之间的任意字符序列。
// 匹配"……"之间的任意文本
STRING : '"' .*? '"';
点号通配符(.)匹配任意的单个字符,.* 表示匹配零个或多个字符组成的任意字符序列。显然,这是个贪婪匹配,它会一直匹配到文件结束,为解决这个问题,ANTLR通过标准正则表达式的标记(?后缀)提供了对非贪婪匹配子规则(nongreedy subrule)的支持。
非贪婪匹配的基本含义是:获取一些字符,直到发现匹配后续子规则的字符为止。更准确的描述是,在保证整个父规则完成匹配的前提下,非贪婪的子规则匹配数量最少的字符。
回到我们的字符串常量定义中来,这里的定义其实并不完善,因为它不允许其中出现双引号。为了解决这个问题,很多语言都定义了以 \ 开头的转义序列,因此我们可以使用 \" 来对字符串中的双引号进行转义。
STRING : '"' (ESC|.)*? '"';
// 表示\" 或者 \\
fragment ESC : '\\"' | '\\\\';
其中,ANTLR语法本身需要对转义字符 \ 进行转义,因此我们需要 \\ 来表示单个反斜杠字符。
6. 注释和空白字符:对于注释和空白字符,大多数情况下对于语法分析器是无用的(Python是一个例外,它的换行符表示一条命令的终止,特定数量的缩进指明嵌套的层级),因此我们可以使用ANTLR的skip指令来通知词法分析器将它们丢弃。
// 单行注释(以//开头,换行结束)
LINE_COMMENT : '//' .*? '\r'?'\n' -> skip;
// 多行注释(/* */包裹的所有字符)
COMMENT : '/*' .*? '*/' -> skip;
词法分析器可以接受许多 -> 操作符之后的指令,skip只是其中之一。例如,如果我们需要在语法分析器中对注释做一定处理,我们可以使用channel指令将某些词法符号送入一个“隐藏的通道”并输送给语法分析器。
大多数编程语言将空白符看成是词法符号间的分隔符,并将他们忽略。
// 匹配一个或者多个空白字符并将他们丢弃
WS : [ \t\r\n]+ -> skip;
至此,我们已经学会了通配符的用法和如何编写常见的词法规则,下面我们将学习如何编写语法规则。
语法(grammar)包含了一系列描述语言结构的规则。这些规则不仅包括描述语法结构的规则,也包括描述标识符和整数之类的词汇符号(词法符号Token)的规则,即包含词法规则和语法规则。注意:语法分析器的规则必须以小写字母开头,词法分析器的规则必须以大写字母开头。
1. 语法文件声明
语法由一个为该语法命名的头部定义和一系列可以互相引用的语言规则组成。grammar关键字用于语法文件命名,需要注意的是,命名须与文件名一致。
2. 语法导入
前两章的例子中,我们都是将词法规则和语法规则放在一个语法文件中,然而一个优雅的写法是将词法规则和语法规则进行拆分。lexer grammar关键字用于声明一个词法规则文件。如下是一个通用的词法规则文件定义。
// 通用的词法规则,注意是 lexer grammar
lexer grammar CommonLexerRules;
// 匹配标识符(+表示匹配一次或者多次)
ID : [a-zA-Z]+;
// 匹配整数
INT : [0-9]+;
// 匹配换行符(?表示匹配零次或者一次)
NEWLINE : '\r'?'\n';
// 丢弃空白字符
WS : [ \t]+ -> skip;
然后我们只需要import关键字,就可以轻松的将词法规则进行导入。如下是一个计算器的语法文件。
grammar LibExpr;
// 引入 CommonLexerRules.g4 中全部的词法规则
import CommonLexerRules;
prog : stat+;
stat : expr NEWLINE # printExpr
| ID '=' expr NEWLINE # assign
| NEWLINE # blank
;
expr : expr op=('*' | '/') expr # MulDiv
| expr op=('+' | '-') expr # AddSub
| INT # int
| ID # id
| '(' expr ')' # parens
| 'clear' # clear
;
// 为上诉语法中使用的算术符命名
MUL : '*';
DIV : '/';
ADD : '+';
SUB : '-';
3. 备选分支命名(标签)
如果备选分支上面没有标签,ANTLR就只会为每条规则生成一个方法(监听器和访问器中的方法,用于对不同的输入进行不同的操作)。为备选分支添加一个标签,我们只需要在备选分支的右侧,以 # 开头,后面跟上任意的标识符即可。如上所示。需要注意的是,为一个规则的备选分支添加标签,要么全部添加,要么全部不添加。
4. 优先级
在第二章中我们讲述了ANTLR是如何处理歧义性语句(二义性文法)的:选择所有匹配的备选分支中的第一条。即ANTLR通过优先选择位置靠前的备选分支来解决歧义性问题,这也隐式地允许我们指定运算符优先级。例如,在上诉的例子中,乘除的优先级会比加减高。因此,ANTLR在解决1+2*3的歧义问题时,会优先处理乘法。
5. 结合性
默认情况下,ANTLR是左结合的,即将运算符从左到右地进行结合。但是有些情况下,比如指数运算符是从右向左结合的。1^2^3应该是3^(2^1)而不是(3^2)^1。我们可以使用assoc来手动指定结合性。
expr : expr '^' expr // ^ 是右结合的
| INT
;
注意,在ANTLR4.2之后,
expr : expr '^' expr // ^ 是右结合的
| INT
;
6. 词法分析器与语法分析器的界限
由于ANTLR的词法规则可以使用递归,因此从技术角度上看,词法分析器可以和语法分析器一样强大。这意味着我们甚至可以在词法分析器中匹配语法结构。或者,在另一个极端,我们可以把字符当作词法符号,然后使用语法分析器去分析整个字符流(这种被称为无扫描的语法分析器scannerless parser)。因此,我们需要去界定词法分析器和语法分析器具体需要处理的界限。
本章我们学习了如何编写语法文件,但是单独的语法并没有用处,而与其相关的语法分析器仅能告诉我们输入的语句是否遵循该语言的规范。为了构建一个语言类应用程序,这是不够的,我们还需要相应的“动作”去执行语法规则。而这就是下一章的内容——监听器和访问器。