Antlr4入门(三)如何编写语法文件

本章我们将会学习词法及语法规则,以及四种抽象的计算机语言模式。因为ANTLR的语法规则跟正则表达式是很类似的,所以还是推荐先阅读下正则表达式的相关内容,这样在编写语法文件时可以事半功倍。

一、四种语言模式

虽然在过去的50年里人们发明了许许多多的编程语言,但是,相对而言,基本的语言模式种类并不多。之所以如此,是因为人们在设计编程语言的时候,倾向于将它们设计成与脑海中的自然语言相类似。我们希望符号按照有效的顺序排列,并且符号之间拥有着特定的依赖关系。举个例子,{(}) 就是不符合语法的,因为符号的顺序不对。

单词之间的顺序和依赖性约束是来自于自然语言的,基本上可以总结成四种抽象的计算机语言模式。

  1. 序列(sequence):一列元素,比如一行命令
  2. 选择(choice):在多种可选方案中做选择(备选分支),比如 if else
  3. 词法符号依赖(token dependency):符号总是成对出现,比如左右括号()
  4. 嵌套关系(nested phrase):嵌套的词组是一种自相似的语言结构,即它的子词组也遵循相同的结构。即递归调用本身定义的语法规则,这就是递归规则(自引用规则)。递归规则包括直接递归(directly recursive)和间接递归(indirectly recursive)。

二、通配符(更多见正则表达式

常用的通配符如下所示:

  1. | 表示或(备选分支)
  2. * 表示出现0次或以上
  3. ? 表示出现0次或1次
  4. + 表示出现1次或以上
  5. ~ 表示取反
  6. 范围运算符:.. 或者 -,比如小写字母的表示:'a'..'z' 或者 [a-z]

下面通过识别一些常见的词法符号来学习下通配符的用法:

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 '^' <assoc=right> expr    // ^ 是右结合的
     | INT
     ;

注意,在ANTLR4.2之后,<assoc=right>需要放在备选分支的最左侧,否则会收到警告。

expr : <assoc=right> expr '^' expr    // ^ 是右结合的
     | INT
     ;

6. 词法分析器与语法分析器的界限

由于ANTLR的词法规则可以使用递归,因此从技术角度上看,词法分析器可以和语法分析器一样强大。这意味着我们甚至可以在词法分析器中匹配语法结构。或者,在另一个极端,我们可以把字符当作词法符号,然后使用语法分析器去分析整个字符流(这种被称为无扫描的语法分析器scannerless parser)。因此,我们需要去界定词法分析器和语法分析器具体需要处理的界限。

  • 在词法分析器中匹配并丢弃任何语法分析器无须知晓的东西。例如,需要在词法分析器中识别和扔掉像空格和注释诸如此类的东西。否则,语法分析器必须经常查看是否有空格或注释在词法符号之间。
  • 在词法分析器中匹配诸如标志符、关键字、字符串和数字这样的常用记号。语法分析器比词法分析器有更多的开销,因此我们不必让语法分析器承受把数字放在一起识别成整数的负担。
  • 将语法分析器不需要区分的词法结构归为同一个词法符号类型。例如,如果我们的应用把整数和浮点数当作同一事物对待,那就把它们合并成词法符号类型NUMBER。
  • 将任何语法分析器可以以相同方式处理的实体归为一类。例如,如果语法分析器不在乎XML标签里的内容,词法分析器可以把尖括号中的所有东西合并成一个单独的名为TAG的词法符号类型。
  • 另一方面,如果语法分析器需要把一种类型的文本拆开处理,那么词法分析器就应该将它的各个组成部分作为独立的词法符号输送给语法分析器。例如,如果语法分析器需要处理IP地址中的元素,那么词法分析器应该将IP的各个组成部分(整数和点)作为独立的词法符号送入语法分析器。

后记

本章我们学习了如何编写语法文件,但是单独的语法并没有用处,而与其相关的语法分析器仅能告诉我们输入的语句是否遵循该语言的规范。为了构建一个语言类应用程序,这是不够的,我们还需要相应的“动作”去执行语法规则。而这就是下一章的内容——监听器和访问器。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章