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選項):

 

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'

   ;

圖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='<EOF>',<-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生成的圖像結果:

幸運的是,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)。

好,讓我們接下來定義詞法規則。

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元素內部使用尖括號。這似乎意味着我們可以在尖括號中這樣包含字符序列:“<<i>hi</i>>”。從用DOT查看器來做實驗的結果來看,事實確實是這樣的。DOT似乎允許尖括號之間出現任何字符串,只要括號匹配就行。所以,出現在HTML元素內部的尖括號並不會像其它XML解析器那樣被忽略掉。HTML字符串“<foo<!--ksjdf > -->>”就會被看成是“foo<!--ksjdf> --”。

要實現HTML字符串,我們可以使用“'<'.*? '>'”這種結構。但是這種結構不能支持尖括號的嵌套,因為這種結構會將第一個“>”與第一個“<”進行結合,而不是與它最近的“<”結合。下面的規則實現了這種嵌套:

examples/DOT.g4

/** "HTML strings, angle brackets must occur in matchedpairs, and

* unescaped newlines are allowed."

*/

HTML_STRING : '<' (TAG|~[<>])*'>' ;

fragment

TAG : '<' .*? '>';

HTML_STRING規則允許出現帶有一對尖括號的TAG規則,實現了標簽的單層嵌套。“~[<>]”字符集要小心匹配XML字符實體,比如&lt;。這個集合匹配除了左右尖括號以外的所有字符。我們不能在這里使用非貪婪循環的通配符。“(TAG|.)*?”會匹配像“<<foo>”這樣的無效輸入,因為循環內部的通配符是可以匹配上“<foo”的。在非貪婪模式下,HTML_STRING就不會調用TAG去匹配一個標簽或這標簽的一部分。

你可能會試着去用遞歸來匹配尖括號嵌套,就像這樣:

HTML_STRING : '<' (HTML_STRING|~[<>])*'>' ;

但是,這樣僅僅會匹配嵌套標簽,而不會去平衡標簽的起始和結束的位置。嵌套標簽可以匹配這樣的輸入:“<<i<br>>>”,但是這並不是我們應該接受的輸入。

DOT還有最后一種我們之前沒有見過的詞法結構。DOT匹配並丟棄以“#”符號開頭的行,因為DOT將其認為是C語言的預處理器輸出。我們可以將其作為單行的注釋規則來看待。

examples/DOT.g4

PREPROC : '#' .*? '\n'-> skip ;

以上就是DOT的語法(除了一些我們已經非常熟悉的規則以外)。這是我們實現的第一個比較復雜的語法!先不管那些更復雜的語法和詞法結構,這一章主要強調了我們應該查閱多方面的資源來實現一個完整的語言。語言結構越龐大,我們需要的參考資源和代表性輸入就需要越多。有時候翻出一些現有的實現程序才是我們測試邊緣情況的唯一方法。沒有任何的語言手冊會是完美無缺的。

我們也經常要面臨一些選擇,在語法分析過程中怎樣划分才是合理的,哪些又是需要作為分割短語而稍后進行處理的。舉個例子,我們在處理特殊的port的名稱的時候,比如ne和sw,就是將其作為簡單的標識符傳遞給語法分析器。同時,我們也不去翻譯“<…>”內部的HTML信息。從某些方面來說,一個完整的DOT實現應該識別並處理這些HTML元素,但是語法分析器只需要將其視為一整塊就可以了。

下面,是時候嘗試一些編程語言了。在下一節,我們要建立一個傳統的命令式編程語言的語法(比較像C語言)。然后,我們就要開始我們最大的挑戰,實現一個函數式編程語言,R語言。

6.4 解析Cymbol

接下來我們主要說明如何解析從C語言衍生過來的編程語言,我們要實現一個我設計的語言,叫做Cymbol。Cymbol是一個很簡單的,不支持對象的編程語言,它看起來就像是沒有structs的C語言。如果你懶得去從頭到尾設計一門新的語言,你可以將這個語言的語法作為其它新的編程語言的原型。在這里面,我們不會看到新的ANTLR語法,但是我們的語法將實踐怎樣建立簡單的左遞歸表達式規則。

當我們設計新語言的時候,我們就沒有正式語法或語言手冊來參考了。相反的,我們從建立語言的樣例輸入開始。從這里開始,我們就要像5.1節中說的那樣來衍生一個語言的語法了。(當我們要處理一些沒有官方語法規范或參考手冊的現有語言的時候也可以這么做。)下面是一個Cymbol代碼的例子,其中包括全局變量的聲名以及遞歸函數的聲明:

examples/t.cymbol

// Cymbol test

int g = 9; // a global variable

int fact(int x) { // factorial function

   if x==0 then return 1;

   return x * fact(x-1);

}

為了直觀,我們先看看程序的最終效果,從而可以對程序的任務有一個更好的把握。下面的語法樹展示了我們的程序應該怎樣解析輸入程序(通過grun Cymbol file -gui t.cymbol命令):

從最高的層次上來考慮Cymbol程序,我們可以發現其是由一系列的全局變量和函數聲明組成的。

examples/Cymbol.g4

file: (functionDecl | varDecl)+ ;

變量的聲明和C語言十分類似,是由一個類型說明符后面跟上一個標識符組成的,這個標識符后面還可以選擇出現初始化表達式。

examples/Cymbol.g4

varDecl

   : typeID ('=' expr)?';'

   ;

type: 'float' 'int''void' // user-defined types

函數聲明也基本是相同的:類型說明符,后面跟一個函數名,后面跟一個括號括起來的參數列表,后面跟一個函數體。

examples/Cymbol.g4

functionDecl

    : typeID '(' formalParameters?')'block // "void f(int x) {...}"

    ;

formalParameters

    :formalParameter (',' formalParameter)*

    ;

formalParameter

    : typeID

    ;

函數體其實就是由花括號括起來的語句塊。這里,我們考慮以下6種不同的語句:嵌套語句,變量聲明,if語句,return語句,賦值語句以及函數調用。這6種語句用ANTLR語法來寫如下所示:

examples/Cymbol.g4

block: '{' stat* '}' // possibly empty statement block

stat: block

   |varDecl

   | 'if' expr 'then'stat ('else' stat)?

   | 'return' expr? ';'

   | expr '=' expr ';' // assignment

   | expr ';' // func call

   ;

最后一個主要的語法就是表達式語法了。因為Cymbol只是創建其它編程語言的原型,所以我們在表達式中只考慮一些常見的運算符。我們要處理的運算符有:一元取反,布爾非,乘法,加法,減法,函數調用,數組下標,等值比較,變量,整數以及括號表達式。

examples/Cymbol.g4

expr: ID '(' exprList?')'// func call like f(), f(x), f(1,2)

   | expr '[' expr ']' // array index like a[i], a[i][j]

   | '-' expr // unary minus

   | '!' expr // boolean not

   | expr '*' expr

   | expr ('+'|'-') expr

   | expr '==' expr // equality comparison (lowest priority op)

   | ID // variable reference

   | INT

   | '(' expr ')'

   ;

exprList : expr (',' expr)* ; // arg list

在這里我們需要注意的是,我們需要根據優先級從高到低列出每一條選項。(在第14章會詳細討論ANTLR是怎樣處理左遞歸以及符號優先級的。)

更直觀一些來看優先級的處理,我們可以看看輸入“-x+y;”和“-a[i];”的語法分析樹(為了直觀,規則從stat開始分析)。

 

 

左邊的語法樹顯示了一元減符號會先和x結合,因為其優先級比加號要高。這是因為,在語法中,一元減符號選項在加號選項之前出現。在右邊的語法樹中,一元減符號的優先級要比取數組下標符號低,這是因為一元減符號選項出現在取數組下標符號選項的后面。右邊的語法樹清楚地表明,負號是作用在“a[i]”上的,而不是作用在標識符“a”上面的。在下一節,我們會看到一些更復雜的表達式。

我們暫時不去關注這個語言的詞法規則,因為詞法規則都和前面的差不多。這里,我們關注的焦點應該是命令式編程語言中的語法結構。

我們可以想象一下無結構體或無類和對象的Java語言是什么樣子的,這樣有助於我們建立我們的Cymbol語言。並且,如果這個語法你已經理解得十分透徹的話,你可以嘗試着去創建你自己的更復雜的命令式編程語言了。

接下來,我們要去嘗試另一個極端的語言。要實現一個差不多的R語言,我們不得不推導出非常精確的語言結構。我們可以通過參考多個手冊,測試樣例程序以及在現有的一些R編譯器上進行測試的方法來推導其語言結構。

6.5 解析R

R語言是在統計問題領域內非常有表現力的一個編程語言。例如,R語言非常容易創建向量,並支持函數對向量操作以及對向量進行過濾(下面展示了一些R的交互腳本)。

x <- seq(1,10,.5)     # x = 1, 1.5, 2, 2.5, 3, 3.5, ..., 10

y <- 1:5              #y = 1, 2, 3, 4, 5

z <- c(9,6,2,10,-4)      # z = 9, 6, 2, 10, -4

y + z                 #add two vectors

<[1] 10 8 5 14 1 # result is 1-dimensionalvector

z[z<5]                #all elements in z < 5

<[1] 2 -4

mean(z)               #compute the mean of vector z

<[1] 4.6

zero <- function() { return(0) }

zero()

<[1] 0

R語言是一個中小型但是卻又十分復雜的編程語言,並且大部分人都有一個障礙存在:我們都不了解R語言。這意味着我們不可能像寫Cymbol那樣直接憑着直覺來編寫R語言的語法結構了。我們不得不根據大量的文獻資料、例子,以及一個現有實現中的正式的yacc語法來獲取R語言的准確語法結構。

開始之前,最好先看一些R語言的語言概述。同時,我們最好也看一些R語言的例子,從而對R語言有一個更好的把握,並從中選擇一些作為成功測試的例子。Ajay Shah已經建立了一些挺不錯的例子,我們可以直接使用。能夠成功解析這些例子就表明我們能夠處理大部分的R程序了。(在不了解一門語言的前提下要完美實現一門語言怎么想都是不可能。)在R語言的官網主頁上有許多能夠幫助我們建立R語言的文檔,這里我們着重關注於“R-intro”和語言定義文檔“R-lang”。

和前面的一樣,我們從一個非常粗糙的層次開始我們的語法結構。從語言概述中可以得知,R程序就是由一系列的表達式和賦值語句組成的。就算是函數定義也是賦值語句;我們將一個函數賦值給一個變量。唯一我們不熟悉的就是R語言中有三種賦值操作符:<-,=,以及<<-。我們的目的只是建立解析器,所以我們不需要關心這三種操作符的意義。所以我們程序的第一個主要結構就應該看起來這樣:

prog : (expr_or_assign '\n')* EOF ;

 

expr_or_assign

   : expr ('<-' '=' '<<-' ) expr_or_assign

   | expr

   ;

在讀了一些例子之后,我們發現,在同一行內我們可以同時書寫多個表達式,只要用分號分開它們就可以了。“R-intro”中證實了這一點。同時,雖然手冊中沒有寫,但是R編譯器允許並忽略空行。所以,我們需要根據這些規定來調整我們的起始規則。

examples/R.g4

prog: ( expr_or_assign (';'|NL)

   | NL

   )*

   EOF

   ;

 

expr_or_assign

   : expr('<-'|'='|'<<-') expr_or_assign

   | expr

   ;

為了考慮到Windows下的換行符(\r\n),我們使用NL來作為換行符,而不是直接使用’\n’符號,這個我們在之前定義過。

examples/R.g4

// Match both UNIX and Windows newlines

NL : '\r''\n' ;

注意,NL並不像前面那樣可以直接丟棄。因為語法解析器同時需要將其作為表達式的終結符,就像Java語法中的分號一樣,所以,詞法分析器必須將其傳遞給語法分析器。

R語言的主體部分就是表達式,所以我們接下來關注的重點也在表達式上。R語言中主要有三種不同的表達式:語句表達式,操作符表達式和函數關聯表達式。由於R語言的語句和命令式編程語言非常接近,所以就讓我們先從語句表達式開始入手。下面是處理expr規則的語句選項(expr規則中出現在運算符選項后面的):

examples/R.g4

'{' exprlist'}' // compound statement

'if' '(' expr ')' expr

'if' '(' expr ')' expr 'else' expr

'for' '(' ID 'in' expr ')' expr

'while' '(' expr ')' expr

'repeat' expr

'?' expr // get help on expr, usually string or ID

'next'

'break'

根據“R-intro”中描述,第一個選項匹配的是表達式塊。“R-intro”中是這么描述的:“基礎命令可以通過花括號組合成符合表達式。”下面是exprlist的定義:

examples/R.g4

exprlist

   :expr_or_assign ((';'|NL)expr_or_assign?)*

   |

   ;

大部分的R表達式需要處理非常多的運算符。為了獲得這些表達式的准確定義,我們最好的方法就是參考其yacc語法。可執行的代碼通常(但不是所有的都這樣)是了解語言作者意圖的最好方式。要知道運算符的優先級,我們首先需要看一些運算符優先級表,優先級表列出了所有相關的運算符的優先級。例如,下面是yacc語法中對算術運算符的描述(用“%left”列在前面的表示優先級比較低):

%left '+' '-'

%left '*' '/'

“R-lang”文檔中有一節叫做“中綴和前綴操作符”,在這一節中給出了運算符的優先級規則,但是,這一節中似乎沒有關於“:::”運算符的描述,而其卻能在yacc語法中找到。將這些信息全部合起來,我們就能得到下面這些針對二元運算符,前綴運算符以及后綴運算符的規則了:

examples/R.g4

expr: expr '[[' sublist']' ']' // '[[' follows R'syacc grammar

   | expr'[' sublist ']'

   | expr('::'|':::') expr

   | expr('$'|'@') expr

   | expr'^'<assoc=right> expr

   | ('-'|'+') expr

   | expr':' expr

   | exprUSER_OP expr // anything wrappedin %: '%' .* '%'

   | expr('*'|'/') expr

   | expr('+'|'-') expr

   | expr ('>'|'>='|'<'|'<='|'=='|'!=') expr

   | '!' expr

   | expr('&'|'&&') expr

   | expr('|'|'||') expr

   | '~' expr

   | expr'~' expr

   | expr ('->'|'->>'|':=') expr

我們只是想識別輸入的話,就暫時不用管這些操作符到底是什么意思。我們只需要關心我們的語法是否能正確匹配優先級和結合順序。

在上面的expr的規則中,有一種用法我們不常用到,那就是在第一條選項中(expr ‘[[‘ sublist ‘]’ ‘]’)使用’[[‘來代替’[‘ ’[‘。([[…]]的作用是選擇一個單個元素,其中的[…]用於產生一個子列表。)我直接從R語言的yacc語法中抄過來的’[[‘這種表述,這樣寫法大概是要表明兩個左中括號之間不能有空白字符,但是這一點在參考手冊中並沒有任何說明。

“^”運算符跟了一個后綴“<assoc=right>”,這是因為“R-lang”中這樣指定了這個運算符:

冪運算符“^”和左賦值運算符“<-= <<-”的結合順序是從右到左的,剩下的其它運算符都是從左到右結合的。例如,2^2^3的結果應該是2^8,而不是4^3。

語句和運算符表達式都搞定之后,我們可以開始着手我們的最后一個expr規則的構成部分了:定義以及調用函數。我們可以使用下面的兩個選項:

examples/R.g4

'function' '(' formlist?')' expr // define function

| expr '(' sublist')' // call function

formlist和sublist分別定義了聲明過程中的形式參數列表和調用過程中的實際參數列表。我將規則的名字和yacc語法中的規則名字保持一致,這樣可以方便我們對比這兩種語法。

在“R-lang”中,形式參數列表是這樣表述的:

…由逗號分隔開來,其中的每一項可以是一個標識符,也可以是“標識符 = 默認值”這種形式,或者是一個特殊的標記“…”。默認值可以是任何一個有效的表達式。

用ANTLR語法來表述這一點和yacc語法中的formlist比較相似(見圖5)。

examples/R.g4

formlist : form (',' form)* ;

 

form: ID

   | ID '=' expr

   | '...'

   ;

圖5 formlist的ANTLR表述

下面,要調用一個函數,“R-lang”描述的參數列表語法如圖6所示。

每一個參數都可以進行標記(標記名=表達式),或者只是一個簡單的表達式。同時,參數也可以為空,或者是一些特殊的符號,比如’…’,’..2’等。

圖6 調用函數時的參數語法

偷看一下yacc語法中的這一部分,我們對參數的語法就有更明確的認識;yacc語法中表明了,我們也可以使用像“”n”=0”,“n=1”以及“NULL=2”這樣的寫法。結合這些規范,我們就得到了下面的函數調用參數的規則:

examples/R.g4

sublist : sub (',' sub)* ;

sub : expr

   | ID '='

   | ID '=' expr

   | STRING'='

   |STRING '=' expr

   | 'NULL' '='

   | 'NULL' '=' expr

   | '...'

   |

   ;

你可能會奇怪,在sub規則中怎么去匹配像“..2”這樣的輸入。其實,我們並不需要精確地去匹配這些,因為我們的詞法分析器會將其識別成標識符。根據“R-lang”所描述的那樣:

標識符是由字母,數字,小數點(“.”)和下划線組成。標識符不能以數字或下划線打頭,也不能以一個小數點后面跟數字打頭。…注意,以小數點打頭的標識符(比如“…”以及“..1”,“..2”等)都具有特殊意義。

為了表述上面描述的標識符,我們使用下面的標識符規則:

examples/R.g4

ID : '.' (LETTER|'_'|'.')(LETTER|DIGIT|'_'|'.')*

   |LETTER (LETTER|DIGIT|'_'|'.')*

   ;

fragment LETTER: [a-zA-Z] ;

第一個選項指定了以小數點開頭的標識符,我們不得不保證第二個字符不能是數字。對於這一點,我們可以使用子規則“(LETTER|’_’|’.’)”來實現。為了確保標識符不會以數字或者下划線開頭,我們在第二個選項中使用了輔助規則LETTER。要匹配“..2”這樣的輸入,我們使用第一個選項就可以了。其中第一個小數點匹配第一個子規則“’.’”,第二個小數點匹配第二個子規則“(LETTER|’_’|’.’)”,而最后一個子規則匹配了數字“2”。

詞法規則的剩余部分和我們之前寫過的那些規則大同小異,所以我們在這里就不再討論它們了。

下面,讓我們使用grun來測試下目前為止我們的所有工作吧,測試輸入如下:

examples/t.R

addMe <- function(x,y) { return(x+y) }

addMe(x=1,2)

r <- 1:5

下面是針對輸入t.R如何建立可視化語法樹的過程(語法樹見圖7):

$ antlr4 R.g4

$ javac R*.java

$ grun R prog -gui t.R

只要我們將表達式寫在一行里面,我們的R語法就能工作得很好。然而,這樣的假設不合適,因為R語言允許函數或其他表達式拆成多行編寫。盡管如此,我們將先止步於此,因為我們的目的僅僅是了解R語言的語法結構。在code/extras資源目錄中,你可以找到忽略表達式中間的換行符這個小問題的解決方案(參見R.g4,RFilter.g4以及TestR.java)。這個解決方案會根據語法適當地選擇保留或剔除換行符。

這一章中,我們的目的是鞏固我們的ANTLR語法的知識,並學習如何從語言參考手冊、樣例輸入和現有非ANTLR語法中派生語法。最后,我們實現了兩個數據語言(CSV,JSON),一個聲明式語言(DOT),一個命令式語言(Cymbol)和一個函數式語言(R)。對於建立一個中等復雜的語言,這些例子幾乎覆蓋了所有你需要的技能。在你開始繼續學習之前,我建議最好先下載這些語法,並有針對性地做些小小的修改,從而鞏固新學到的知識。例如,你可以給Cymbol語言添加更多的運算符和語句。然后,可以使用TestRig工具來查看你修改后的語法是怎樣工作在樣例輸入上的。

圖7 t.R的語法分析樹

到目前為止,我們已經學習了怎樣識別語言,但是,語法本身必須還能識別只符合語言本身的輸入。下面,我們將學習如何在解析機制中加入特定應用程序的代碼,這將在下一章具體介紹。完成這個之后,我們就可以試着建立真正的語言應用程序了。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM