ANTLR4权威指南 - 第6章 尝试一些实际中的语法

第6章

尝试一些实际中的语法

在前一章,我们学习了通用词法结构和语法结构,并学习了如何用ANTLR的语法来表述这些结构。现在,是时候把我们学到的这些用来构建一些现实世界中的语法了。我们的主要目标是,怎样通过筛选引用手册,样例输入文件和现有的非ANTLR语法来构建一个完整语法。这一章,我们要实现五种语言,难度依次递增。现在,你不需要将它们全部都实现了,挑一个你最喜欢的实现,当你在实践过程中遇到问题了再回过头来看看就好了。当然,也可以看看上一章学习到的模式和ANTLR代码片段。

我们要实现的第一个语言就是逗点分割值格式(CSV),这种格式经常会在Excel这样的电子表格以及数据库中用到。从CSV开始是一个很好的选择,因为它不仅简单,而且应用非常广泛。第二种要实现的语言也是一种数据格式,叫做JSON,它包含嵌套数据元素,实现它可以让我们掌握实际语言中递归规则的用法。

下一个,我们要实现一个说明性语言,叫做DOT,用来描述图形(网络上的)。在这个说明性语言中,我们只感受下其中的逻辑结构而不指定控制流。DOT语言能够让我们实践更加复杂的词法结构,比如不区分大小写的关键字。

我们要实现的第四个语言是一种简单的非面向对象的编程语言,叫做Cymbol(在《语言实现模式》这本书的第6章也会讨论到这个语言)。这种语法可以作为一种典型的语法,我们可以将其作为参考,或者是实现其它编程语言的入手点(那些由函数,变量,语句和表达式组成的编程语言)。

最后,我们要实现一个函数式编程语言,R语言。(函数式编程语言通过计算表达式进行计算。)R语言是一种用在统计上的语言,现在多用于数据分析。我选择R语言作为例子,是因为其语法主要由巨型表达式规则组成。这对于我们加深对算符优先的理解,并结合到实际语言中,有着极大的好处。

当我们对建立语法比较熟练之后,我们就可以撇下语法识别,而去研究当程序看到自己感兴趣的输入时,应该怎样采取行动。在下一章,我们会创建分析监听器来创建数据结构,并通过符号表来追踪变量和函数定义,并实现语言的翻译。

那么,就让我们先从CSV文件开始吧。

6.1 解析逗点分割值

虽然,我们在第5章的序列模式中曾经介绍过一个简单的CSV语法,现在,让我们对其添加点规则:首行作为标题行,并且允许某一格的值为空。下面是一个具有代表性的输入文件的例子:

examples/data.csv

Details,Month,Amount

Mid Bonus,June,"$2,000"

,January,"""zippo"""

Total Bonuses,"","$5,000"

标题行和数据行基本上没什么差别,我们只是简单地将标题行里面的字段作为标题使用。但是,我们需要将其单独分离出来,而不是简单地使用row+这样的ANTLR片段去匹配。这是因为,当我们在这个语法上建立实际应用的时候,我们往往都需要区别对待标题行。这样,我们就可以很好地对第一行进行特殊处理了。下面是这个语法的一部分:

examples/CSV.csv

grammar CSV;

file : hdr row+ ;

hdr : row ;

注意到我们在上面引入了一个特殊的规则hdr来表示首行。但是这个规则在语法上就是一个row规则。我们通过将其分离出来使其作用更加清晰。你可以仔细对比下这种写法与直接在规则file右边写一个“row+”或者“row*”之间的差别。

row规则和前面介绍的一样:是一系列由逗号分隔开的字段,由换行符结束。

examples/CSV.csv

row : field (',' field)*'\r'?'\n';

为了让我们的字段比前面介绍的更具有通用性,我们允许这个字段出现任意文本,字符串甚至什么都不出现(两个逗号之间什么也没有,也就是空字段)。

examples/CSV.csv

field

   : TEXT

   | STRING

   | ;

符号的定义不算太坏。TEXT符号就是一个字符的序列,这个字符的序列在遇到下一个逗号或者换行符之前结束。STRING符号就是用双引号引起来的字符序列。下面是这两个符号的定义:

examples/CSV.csv

TEXT : ~[,\n\r"]+ ;

STRING : '"'('""'|~'"')* '"' ; // quote-quote is an escaped quote

如果要在双引号引起来的字符串中间出现双引号,CSV格式采用的是使用两个双引号表示,这就是STRING规则中“(‘””’|~’”’)”子规则所代表的意义。注意,我们在这里不能使用像“(‘””’|.)*?”这样的非贪婪循环的通配符,因为这种情况下,通配符的匹配会在遇到第一个“””的时候而结束。像”x””y”这样的输入,将会被匹配为两个字符串,而不会被匹配为一个字符串中出现一个“”””。记住,非贪婪子规则就算是匹配了内部规则的时候也会尽可能地匹配最少的字符。

在测试我们的语法规则之前,我们最好先看下解析得到的符号流,以确保我们的词法分析器能够正确地分割字符流。利用重命名为grun的TestRig工具,加上-tokens选项,我们能够得到下面的结果:

$ antlr4 CSV.g4

$ javac CSV*.java

$ grun CSV file -tokens data.csv

<[@0,0:6='Details',<4>,1:0]

[@1,7:7=',',<1>,1:7]

[@2,8:12='Month',<4>,1:8]

[@3,13:13=',',<1>,1:13]

[@4,14:19='Amount',<4>,1:14]

[@5,20:20='\n',<2>,1:20]

[@6,21:29='Mid Bonus',< 4>,2:0]

[@7,30:30=',',<1>,2:9]

[@8,31:34='June',<4>,2:10]

[@9,35:35=',',<1>,2:14]

[@10,36:43='"$2,000"',<5>,2:15]

[@11,44:44='\n',<2>,2:23]

[@12,45:45=',',<1>,3:0]

[@13,46:52='January',<4>,3:1]

...

 

结果看起来不错,标点符号,文本,字符串都像预期的那样被正确分割开了。

接下来,让我们看看应该怎样去识别输入的语法结构。使用-tree选项,测试工具就会以文本的方式打印出语法分析树(书中对其做了删减)。

$ grun CSV file -tree data.csv

<(file

    (hdr (row (field Details) , (field Month) ,(field Amount) \n))

    (row (field Mid Bonus) , (field June) , (field"$2,000") \n)

    (row field , (field January) , (field"""zippo""") \n)

    (row (field Total Bonuses) , (field"") , (field "$5,000") \n)

)

树的根节点代表了file规则匹配的所有内容,包括一个开始的标题行规则以及许多行规则作为子节点。下面是这棵语法树的可视化显示(使用-ps file.ps选项):

ANTLR4权威指南 - 第6章 尝试一些实际中的语法_第1张图片

 

CSV格式非常的简单直观,但是却无法实现在一个字段中包含很多值这种需求。为此,我们需要一种支持嵌套元素的数据格式。

6.2 解析JSON

JSON是一种文本的数据格式,它包含了键值对的集合,并且,值本身也可以是一个键值对的集合,所以JSON是一种嵌套的结构。在设计JSON的时候,我们可以学习到如何从语言的参考手册中推导语法,并可以尝试更多的复杂词法结构。把问题具体化,下面是一个简单的JSON数据文件:

examples/t.json

{

   "antlr.org": {

      "owners": [],

      "live": true,

      "speed": 1e100,

      "menus": ["File","Help\nMenu"]

   }

}

我们的目标是根据JSON的参考手册以及参考一些已有语法的图表来建立一个ANTLR语法。我们将提取出手册中的关键短语,并指出如何将其表述成ANTLR规则。那么从语法结构开始吧。

JSON语法规则

JSON的语法手册是这么写的:一个JSON文件可以是一个对象,也可以是一个值的数组。从语法上说,这显然是一个选项模式,于是我们便可以向下面这样来指定规则:

examples/JSON.g4

json: object

   | array

   ;

下一步就应该将json中的引用规则继续向下推导。对于object规则,参考手册中是这么写的:

一个对象(object)就是一个键值对的无序集合。它由左大括号“{”开始,以右大括号“}”结束。每一个键的后面都跟着一个冒号“:”,并且键值对是用逗号“,”分割开的。

JSON官网中的语法图中也指明,键一定是一个字符串。

将这段文字表述转换为语法结构,我们将这段表述拆开并寻找能够符合我们所了解的模式(序列,选项,符号约束和嵌套短语)的短语。最开始的那句话“一个对象就是…”显然指出了我们需要定义一个叫做object的规则。然后,“一个键值对的无序集合”其实指的就是键值对的序列。“无序集合”是指键的语义上的意义;具体来说,就是键的顺序并没有意义。这也意味着,在解析的过程中,我们只需要匹配任何出现的键值对列表就可以了。

第二句话说object是由大括号包含起来的,这显然是在说明一个符号约束模式。最后一句话定义了我们的键值对序列是一个由逗号分隔开的序列。总结起来,我们将这些用ANTLR来表示是这个样子的:

examples/JSON.g4

object

   : '{' pair (','pair)*'}'

   | '{' '}' // empty object

   ;

pair: STRING ':' value;

为了清晰并减少代码重复,最好将键值对也定义成一个规则,不然的话,object的第一个选项就会看起来像这个样子:

object : '{' STRING':'value (','STRING ':'value)*'}'| ... ;

注意,我们将STRING作为一个符号来处理,而不是一个语法规则。我们已经非常确定,我们的程序只处理完整的字符串,而不会进一步拆成字符来处理。关于这部分,详细可以参考第5.6节。

JSON参考手册也会有一些非正式的语法规则,我们来将这些规则和ANTLR的规则做个对比。下面是从参考手册中找到的语法定义:

object

   {}

   { members }

members

   pair

   pair , members

pair

   string : value

参考手册中也将pair规则给单独提取出来了,但是参考手册中定义了member规则,我们没有定义这个。在“循环对抗尾递归”一节中会具体描述如果没有“(…)*”循环的时候,语法是怎么解析序列的。

接下来再看另一个高层结构,数组。参考手册中是这样描述数组的:

数组(array)是一个有序的值的集合。数组由左中括号“[”开始,由右中括号“]”结束。不同的数值之间用逗号“,”分割开。

就像object规则一样,array规则也是一个逗号分割的序列,并且由中括号构成符号约束。

循环对抗尾递归

JSON参考手册中的members规则看起来非常奇怪,因为它看起来并没有直接像描述的那样“由一系列的逗号分割开的pair组成”,并且,它引用到了自己。

members

   pair

   pair , members

会有这样的差别,是因为ANTLR支持扩展的BNF语法(EBNF),而JSON中的规则遵循的是直接的BNF语法。BNF并不支持像“(…)*”这样的循环结构,所以,其使用尾递归(在规则的一个选项中的最后一个元素调用自己)来实现这种循环。

为了更好地说明文字描述的规则和这种尾递归形式之间的区别,下面是members规则匹配1个,2个,3个pair的例子:

members => pair

 

members => pair , members

      => pair , pair

 

members => pair , members

      => pair , pair , members

      => pair , pair , pair

这一现象体现了我们在5.2节给出的警告,现有的语法只能作为一个参考,而不能将其作为绝对真理来使用。

 

examples/JSON.g4

array

   : '[' value (','value)*']'

   | '[' ']' // empty array

   ;

再继续往下走,我们就得到了规则value,这个规则在参考手册中北描述为一种选项模式。

value可以是一个用双引号引起来的字符串,或者是一个数字,或者是true和false,或者是null,或者是一个object,或者是一个array。这些结构可以嵌套。

其中的术语嵌套自然就是指我们的嵌套短语模式,这也就意味着我们需要使用一些递归的规则引用。在ANTLR中,value规则看起来像图4所展示的那样。

通过引用object或array规则,value规则就变成了(非直接)递归规则。不管调用value中的object规则还是array规则,最终都会再次调用到value规则。

examples/JSON.g4

value

   :STRING

   |NUMBER

   |object // recursion

   |array // recursion

   | 'true' // keywords

   | 'false'

   | 'null'

   ;

ANTLR4权威指南 - 第6章 尝试一些实际中的语法_第2张图片

图4 ANTLR中的value规则

value规则直接引用字符串来匹配JSON的关键字。我们同样将数字作为一个符号来处理,这是因为我们的程序同样只需要将数字作为整体来处理。

这就是所有的语法规则了。我们已经完全指定了一个JSON文件的结构了。下面是针对之前给出的例子,我们的语法分析树的样子:

 

当然,我们在完成词法之前是无法生成上面所示的这棵语法树的。我们需要为STRING和NUMBER这两个关键字指定规则。

JSON词法规则

在JSON的参考手册中,字符串是这么被定义的:

字符串(string)是由零个或多个Unicode字符组成的序列,被双引号括起来,可以使用反斜杠转义字符。单个字符可以被看成只有一个字符的字符串。JSON中的字符串和C语言或Java中的字符串非常相似。

看吧,就像我们在上一章讨论的那样,字符串在大部分语言中都是非常相似的。JSON中的字符串与我们之前讨论过的字符串非常相似,只是需要添加对Unicode转义字符的支持。看一下现有的JSON语法,我们能看出其描述是不完整的。语法描述如下所示:

char

   any-Unicode-character-except-"-or-\-or-control-character

   \"

   \\

   \/

   \b

   \f

   \n

   \r

   \t

   \u four-hex-digits

这个语法定义了所有的转义字符,也定义了我们需要匹配除了双引号和反斜杠之外的所有Unicode字符。这种匹配,我们可以使用“~[“\\]”来反转字符集。(“~”操作符代表“除了”。)我们的STRING规则定义如下所示:

examples/JSON.g4

STRING : '"' (ESC |~["\\])* '"' ;

ESC规则既可以匹配一个预定义的转义字符,也可以匹配一个Unicode序列。

examples/JSON.g4

fragment ESC :'\\' (["\\/bfnrt] | UNICODE) ;

fragment UNICODE : 'u' HEX HEX HEX HEX ;

fragment HEX : [0-9a-fA-F] ;

我们将UNICODE规则中的十六进制数单独提取出来,成为一个HEX规则。(规则的前面如果加上fragment前缀的话,这条规则就只能被其它规则引用,而不会单独被匹配成符号。)

最后一个需要的符号就是NUMBER。JSON手册中是这么定义数字的:

数字(number)非常类似于C语言或Java中的数字,但是JSON中不使用八进制或十六进制的数字。

JSON的语法中有相当复杂的数字的规则,但是我们可以把这些规则总结成三个主要的选项。

examples/JSON.g4

NUMBER

    : '-'? INT '.'INTEXP? // 1.35, 1.35E-9, 0.3, -4.5

    | '-'? INT EXP // 1e10 -3e4

    | '-'? INT // -3, 45

    ;

fragment INT :'0' | [1-9] [0-9]* ; // no leading zeros

fragment EXP :[Ee] [+\-]? INT ;// \- since - means "range"inside [...]

这里再说明一次,使用片段规则INT和EXP可以减少代码重复率,并且可以提高语法的可读性。

我们从JSON的非正式语法中可以得知,INT不会匹配0开始的整数。

int

    digit

    digit1-9 digits

    - digit

    - digit1-9 digits

我们在NUMBER中已经很好地处理了“-”符号操作符,所以我们只需要好好关注开头的两个选项:digit和digit1-9 digits。第一个选项匹配任何单个数码的数字,所以可以完美匹配0。第二个选项说明数字的开始只能是1到9,而不能是0。

译者注:依照本书中所写的JSON的NUMBER规则,则像1.03这样的输入不会被正确匹配,这一点有待于证实。

不同于上一节中的CSV的例子,JSON需要考虑空白字符。

空白字符(whitespace)可以出现在任何键值对的符号之间。

这是对空白字符的非常经典的定义,所以,我们可以直接利用前面“词法新人工具包”中的语法。

examples/JSON.g4

WS : [ \t\n\r]+ -> skip ;

现在,我们有JSON的完整的语法和词法规则了,接下来让我们测试下。以样例输入“[1,”\u0049”,1.3e9]”为例,测试其符号分析结果如下:

$ antlr4 JSON.g4

$ javac JSON*.java

$ grun JSON json -tokens

[1,"\u0049",1.3e9]

➾EOF

<  [@0,0:0='[',<5>,1:0]

    [@1,1:1='1',<11>,1:1]

    [@2,2:2=',',<4>,1:2]

    [@3,3:10='"\u0049"',<10>,1:3]

    [@4,11:11=',',<4>,1:11]

    [@5,12:16='1.3e9',<11>,1:12]

    [@6,17:17=']',<1>,1:17]

    [@7,19:18='',<-1>,2:0]

可以看出,词法分析器正确地将输入流切分成符号流了,接下来,再试试看语法规则的测试结果。

$ grun JSON json -tree

[1,"\u0049",1.3e9]

➾EOF

<(json (array [ (value 1) , (value"\u0049") , (value 1.3e9) ]))

语法成功地被解释为含有三个值的数组了,如此看来,一切工作正常。要此时一个更加复杂的语法,我们需要测试更多的输入才能保证其正确性。

到目前为止,我们已经实践了两个数据语言的语法了(CSV和JSON),下面,让我们尝试下一个叫做DOT的声明式语言,这个实践增加了语法结构的复杂性,同时引进了一种新的词法模式:大小写不敏感的关键字。

6.3 解析DOT

DOT是一种用来描述图结构的声明式语言,用它可以描述网络拓扑图,树结构或者是状态机。(之所以说DOT是一种声明式语言,是因为这种语言只描述图是怎么连接的,而不是描述怎样建立图。)这是一个非常普遍而有用的图形工具,尤其是你的程序需要生成图像的时候。例如,ANTLR的-atn选项就是使用DOT来生成可视化的状态机的。

先举个例子感受下这个语言的用途,比如我们需要将一个有四个函数的程序的调用树进行可视化。当然,我们可以用手在纸上将它画出来,但是,我们可以像下面那样用DOT将它们之间的关系指定出来(不管是手画而是自动生成,都需要从程序源文件中计算出函数之间的调用关系):

examples/t.dot

digraph G{

    rankdir=LR;

    main [shape=box];

    main -> f -> g;           // main calls f which calls g

    f -> f [style=dotted] ; // f isrecursive

    f -> h;                 // f calls h

}

下图是使用DOT的可视化工具graphviz生成的图像结果:

ANTLR4权威指南 - 第6章 尝试一些实际中的语法_第3张图片

幸运的是,DOT的参考手册中有我们需要的语法规则,我们几乎可以将它们全部直接引用过来,翻译成ANTLR的语法就行了。不幸的是,我们需要自己指定所有的词法规则。我们不得不通读整个文档以及一些例子,从而找到准确的规则。首先,让我们先从语法规则开始。

DOT的语法规则

下面列出了用ANTLR翻译的DOT参考手册中的核心语法:

examples/DOT.g4

graph : STRICT? (GRAPH | DIGRAPH) id? '{'stmt_list '}' ;

stmt_list : ( stmt ';'? )* ;

stmt : node_stmt

    |edge_stmt

    |attr_stmt

    | id '=' id

    |subgraph

    ;

attr_stmt : (GRAPH | NODE | EDGE) attr_list ;

attr_list : ('[' a_list?']')+ ;

a_list : (id ('=' id)?','?)+ ;

edge_stmt : (node_id | subgraph) edgeRHS attr_list? ;

edgeRHS : ( edgeop (node_id | subgraph) )+ ;

edgeop : '->' '--';

node_stmt : node_id attr_list? ;

node_id : id port? ;

port : ':' id (':'id)? ;

subgraph : (SUBGRAPH id?)? '{' stmt_list '}' ;

id : ID

    |STRING

    |HTML_STRING

    |NUMBER

    ;

其中,唯一一个和参考手册中语法有点不同的就是port规则。参考手册中是这么定义这个规则的。

port: ':' ID [ ':' compass_pt ]

    | ':' compass_pt

compass_pt

    : (n | ne | e | se| s | sw | w | nw)

如果说指南针参数是关键字而不是合法的变量名,那么这些规则这么写是没问题的。但是,手册中的这句话改变了语法的意思。

注意,指南针参数的值并不是关键字,也就是说指南针参数的那些字符串也可以当作是普通的标识符在任何地方使用…

这意味着我们必须接受像“n ->sw”这样的边语句,而这句话中的n和sw都只是标识符,而不是指南针参数。手册后面还这么说道:“…相反的,编译器需要接受任何标识符。”这句话说的并不明确,但是这句话听起来像是编译器需要将指南针参数也接受为标识符。如果真是这样的话,那么我们也不用去考虑语法中的指南针参数;我们可以直接用id来替换规则中的compass_pt就可以了。

port: ':' id (':'id)? ;

为了验证我们的假设,我们不妨用一些DOT的查看器来尝试下这个假设,比如用Graphviz网站上的一些查看器。事实上,DOT也的确接受下面这样的图的定义,所以我们的port规则是没问题的:

digraph G { n -> sw; }

现在,我们的语法规则已经就位了,假设我们的词法定义也实现了,那么我们来看看t.dot这个样例输入的语法分析树长什么样子(使用grun DOT graph -gui t.dot)。

ANTLR4权威指南 - 第6章 尝试一些实际中的语法_第4张图片

好,让我们接下来定义词法规则。

DOT词法规则

由于手册中没有提供正式的词法规则,我们只能自己从文本描述中提取出词法规则。关键字非常简单,所以就让我们从关键字开始吧。

手册中是这么描述的:“node,edge,graph,digraph,subgraph,strict关键都是大小写不敏感的。”如果它们是大小写敏感的话,我们只需要简单地将单词列出来就可以了,比如’node’这样。但是为了接受像’nOdE’这样多种多样的输入,我们需要将词法规则中的每个字母都附上大小写。

examples/DOT.g4

STRICT   : [Ss][Tt][Rr][Ii][Cc][Tt] ;

GRAPH    : [Gg][Rr][Aa][Pp][Hh] ;

DIGRAPH  :[Dd][Ii][Gg][Rr][Aa][Pp][Hh] ;

NODE     : [Nn][Oo][Dd][Ee] ;

EDGE     : [Ee][Dd][Gg][Ee] ;

SUBGRAPH :[Ss][Uu][Bb][Gg][Rr][Aa][Pp][Hh] ;

标识符的定义和大多数编程语言中的定义一致。

标识符由任何字母([a-zA-Z\200-\377]),下划线和数字组成,且不能以数字开头。

\200-\377是八进制范围,用十六进制范围表示就是80到FF,所以,我们的ID规则看起来就应该像这样:

examples/DOT.g4

ID : LETTER (LETTER|DIGIT)*;

fragment

LETTER : [a-zA-Z\u0080-\u00FF_] ;

辅助规则DIGIT同时也是我们在匹配数字的时候需要用到的一个规则。手册中说,数字遵循下面这个正则表达式:

[-]?(.[0-9]+ | [0-9]+(.[0-9]*)? )

把其中的[0-9]替换成DIGIT,那么DOT中的数字规则就如下所示:

examples/DOT.g4

NUMBER : '-'? ('.'DIGIT+ | DIGIT+ ('.' DIGIT*)? ) ;

fragment

DIGIT : [0-9] ;

DOT的字符串非常的寻常。

双引号引起来的任何字符序列(”…”),包括转义的引号(\”),就是字符串。

我们使用点通配符来匹配双引号内部的任意字符,直到遇到结束字符串的双引号为止。当然,我们也将转义的双引号作为子规则循环中的一个选项进行匹配。

examples/DOT.g4

STRING : '"' ('\\"'|.)*?'"' ;

DOT同时也支持HTML字符串。尽可能简单地说,HTML字符串就是双引号内部的字符串还用尖括号括起来的字符串。手册中使用“<…>”符号,并这样描述:

…在HTML字符串中,尖括号必须成对匹配,并且允许非转义的换行符。另外,HTML字符串的内容必须符合XML标准,所以一些特殊的XML转义序列(”,&,<,>)可能就会非常重要,因为我们可能会需要将其嵌入到属性值或原始文本中。

这段描述告诉了我们大部分我们需要的信息,但是却没有说明我们是否可以在HTML元素内部使用尖括号。这似乎意味着我们可以在尖括号中这样包含字符序列:“<hi>”。从用DOT查看器来做实验的结果来看,事实确实是这样的。DOT似乎允许尖括号之间出现任何字符串,只要括号匹配就行。所以,出现在HTML元素内部的尖括号并不会像其它XML解析器那样被忽略掉。HTML字符串“ -->>”就会被看成是“foo

你可能感兴趣的:(json,r语言,c/c++)