下面學習如何編寫語法。
如何定義語法規則
一種語言模式就是一種遞歸的語法結構。
我們需要從一系列有代表性的輸入文件中歸納出一門語言的結構。在完成這樣的歸納工作后,我們就可以正式使用ANTLR
語法來表達這門語言了。
編寫語法和編寫軟件很相似,差異在於我們處理的是語言規則,而非函數或者過程(procedure
)。
在深入研究語法的細節之前,一件大有裨益的事情是:討論語法的整體結構以及如何建立初始的語法框架。
語法由一個為該語法命名的頭部定義和一系列可以相互引用的語言規則組成。
grammar MyG;
rule1: <<stuff>>;
rule2: <<more stuff>>;
...
設計良好的語法反映了編程世界中的功能分解或者自頂向下的設計。這意味着我們對語言結構的辨識是從最粗的粒度開始,一直進行到最詳細的層次,並把它們編寫成為語法規則。所以,我們的第一個任務是找到最粗粒度的語言結構,將它作為我們的起始規則。在英語中,我們可以使用sentence
規則作為起始規則。對於一個XML
文件,我們可以使用document
規則作為起始規則。
設計起始規則的內容實際上就是使用英語偽代碼
來描述輸入文本的整體結構,這和我們編寫軟件的過程有點類似。
例如,一個CSV文件就是一系列以換行符為終止的行(a comma-separated-value file is a sequence of rows terminated by newlines
)。其中,is a
左側的單詞file
就是規則名,右側的全部內容就是規則定義中的<<stuff>>
,即:
file: <<sequence of rows that are terminated by newlines>>;
接着,我們描述起始規則右側所指定的那些元素。它右側的名詞通常是詞法符號或者尚未定義的規則。其中,詞法符號是那些我們的大腦能夠輕易識別出的單詞、標點符號或者運算符。正如英語語句中的單詞是最基本元素一樣,詞法符號是文法的基本元素。起始規則引用了其他的、需要進一步細化的語言結構, 如上面的例子中的“行”row
。
一個行就是一系列由逗號分隔的字段(a row is a sequence of fields separated by commas
)。接下來,一個字段就是一個數字或者字符串(a field is a number or string
)。我們的偽代碼如下所示:
file: <<sequence of rows that are terminated by newlines>> ;
row: <<sequence of fields separated by commas>> ;
field: <<number or string>> ;
當我們完成對規則的定義后,我們的語法草稿就成形了。在剛開始的時候,辨識一條語法規則並使用偽代碼編寫右側的內容是一項充滿挑戰的工作,不過,它會隨着你為不同語言編寫語法的過程變得越來越容易。
使用ANTLR語法表達語言
現在我們擁有了偽代碼,還需要將它翻譯為ANTLR
標記,從而得到一個能夠正常工作的語法。
常見的語言模式包括:序列(sequence
)、選擇(choice
)、詞法符號依賴(token dependency
),以及嵌套結構(nested phrase
)。
在之前的 快速上手 ,我們見過這些模式的一些例子。隨着學習的深入,我們會用正式的語法規則將特定的模式表達出來,通過這種方式,我們就能夠掌握基本的ANTLR
標記的用法。
序列模式
登錄一台POP服務器並獲取第一條消息的指令序列為:
USER parrt
PASS secret
RETR 1
其中指令RETR 1
是由RETR
關鍵字(保留字),一個操作數和一個換行符構成。
使用ANTLR
語法來表述這樣的序列,只需要按照順序將它們列出即可:
retr : 'RETR' INT '\n' ; // 匹配“關鍵字-整數-換行符”序列
INT : [0-9]+ ;
WS : [ \t]+ -> skip ;
注意,可以直接使用類似'RETR'
的常量字符串來表示任意簡單字符序列,諸如關鍵字或者標點符號等。
使用語法規則來為編程語言的特定結構命名,這就好像我們在編程時將若干個語句組合成一個函數。在上例中,我們將RETR
命名為retr
規則。這樣,在語法的其他地方,就可以直接把規則名作為簡稱來引用RETR
。
序列模式的變體包括:
- 帶終止符的序列模式
- 帶分隔符的序列模式
CSV文件同時使用了這兩種模式。上面我們定義出了CSV文件的語法規則,下面用ANTLR
語法來表達:
file : (row '\n')* ; // 以換行符作為終止符的序列
row : field (',' field)* ; // 以','作為分隔符的序列
field: INT | STRING ;
簡單解釋一下:
file
規則使用帶終止符的序列模式來匹配零個或多個row'\n'
序列。其中序列中的每個元素都以\n
字符結束;row
規則使用帶分隔符的序列模式來匹配一個field
后面跟着零個或多個','field
序列的情形。,
隔開了所有的field
;
選擇模式
在ANTLR
的規則中,使用|
符號表示或者
的含義,稱作備選分支(alternatives
)。
比如上面CSV語法中用到的field: INT | STRING ;
,表示字段可以是整數或者字符串。
上文 快速上手的四則運算案例 中,就用到了選擇模式,如下:
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
;
也就是說,當語法規則中有允許這樣也允許那樣的含義時,就能使用選擇模式
。
詞法符號依賴模式
如果在某個語句中看到了某個符號,就必須在同一個語句中找到和它配對的另外一個符號。為表達出這種語義,在語法中,我們使用一個序列來指明所有配對的符號,通常這些符號會把其他元素分組或者包裹起來。
上文 快速上手的數組轉換案例 中,就用到了詞法符號依賴模式,如下:
/** A rule called init that matches comma-separated values between {...}. */
init : '{' value (',' value)* '}' ; // must match at least one value
嵌套模式
如果一條語法規則定義中的偽代碼引用了它自身,就需要一條遞歸規則(自引用規則)。
上文 快速上手的四則運算案例 中,也用到了遞歸,如下:
expr: expr op=('*'|'/') expr # MulDiv
| expr op=('+'|'-') expr # AddSub
| INT # int
| ID # id
| '(' expr ')' # parens
;
語言結構上的遞歸自然而然地使得語言規則發生了遞歸。
總結
語言模式 | 描述 |
---|---|
序列模式 | 它是一個有限長度或者任意長度的序列,序列中的元素可以是詞法符號或者子規則。序列模式的例子包括變量聲明(類型后面緊跟着標識符)和整數序列,例子:retr : 'RETR' INT NEWLINE ; // 匹配“關鍵字-整數-換行符”序列 |
帶終止符的序列模式 | 它是一個任意長的、可能為空的序列,該序列由一個詞法符號分隔開,通常是分號或者換行符,其中的元素可以是詞法符號或者子規則。這樣的例子包括類Java語言的語句集合和一些用換行符來分隔的數據格式。例子:(statement ';')* // Java的語句集合 (row NEWLINE)* // 多行數據 |
帶分隔符的序列模式 | 它是一個任意長的、可能為空的序列,該序列由一個詞法符號分隔開,通常是逗號、分號或是句號,其中的元素可以是詞法符號或者子規則。這樣的例子包括函數定義中的參數表、函數調用時傳遞的參數表、某些語句之間有分隔符卻無終止符的編程語言,以及目錄名。例子: expr (',' expr)* // 函數調用時傳遞的參數 ( expr (',' expr)* )? // 函數調用時傳遞的參數是可選的 '/'? name ('/' name)* // 簡化的目錄名 stat ('.' stat)* // 若干個SmallTalk語句 |
選擇模式 | 它是一組備選分支的集合。這樣的例子包括不同種類的類型、語句、表達式或者XML 標簽。舉例:type : 'int' | 'float' ; stat : ifstat | whilestat | 'return' expr ';' ; expr : '(' expr ')' | INT | ID ; tag : '<' Name attribute* '>' | '<' '/' Name '>' ; |
詞法符號依賴模式 | 一個詞法符號需要和一個或者多個后續詞法符號匹配。這樣的例子包括配對的圓括號、花括號、方括號和尖括號。例子:'(' expr ')' // 嵌套表達式 ID '[' expr ']' // 數組索引表達式 '{' stat* '}' // 花括號包裹的若干個語句 '<' ID (',' ID)* '>' // 泛型聲明 |
嵌套模式 | 它是一種自相似的語言結構。這樣的例子包括表達式、Java的內部類、嵌套的代碼塊以及嵌套的Python函數定義。例子:expr : '(' expr ')' | ID ; classDef : 'class' ID '{' (classDef | method | field)* '}' ; |
常見的詞法結構
和語法分析器一樣,詞法分析器也使用規則來描述種類繁多的語言結構。在ANTLR
中,我們使用的是幾乎完全相同的標記。唯一的差別在於,語法分析器通過輸入的詞法符號流來識別特定的語言結構,而詞法分析器通過輸入的字符流來識別特定的語言結構。
由於詞法規則和文法規則的結構相似,ANTLR
允許二者在同一個語法文件中同時存在。不過,由於詞法分析和語法分析是語言識別過程中的兩個不同階段,我們必須告訴ANTLR
每條規則對應的階段。它是通過這種方式完成的:
詞法規則以大寫字母開頭,而文法規則以小寫字母開頭。
例如,ID
是一個詞法規則名,而expr
是一個文法規則名。
對於關鍵字、運算符和標點符號,我們無須聲明詞法規則,只需要在文法規則中直接使用單引號將它們括起來即可,例如'while'
、'*'
,以及'++'
。有些開發者更願意使用類似MULT
的詞法規則來引用'*'
,以避免對其的直接使用。這樣,在改變乘法運算符的時候,只需修改MULT
規則,而無須逐個修改引用了MULT
的文法規則。
下面看下常見的詞法結構。
詞法符號類型 | 舉例 |
---|---|
匹配標識符 | ID : [a-zA-Z]+ ; // 匹配1個或多個大小寫字母 |
匹配數字 | INT : [0-9]+ ; // 匹配1個或多個數字 |
匹配字符串常量 | STRING : '"' .*? '"' ; // 匹配兩個雙引號之間的任意字符序列 |
匹配注釋和空白字符 | WS : [ \t\r\n]+ -> skip ; // 匹配一個或多個空白字符並將它們丟棄 |
匹配標識符
INT : '0'..'9'+ ; // 匹配1個或者多個數字
ID : ('a'..'z'|'A'..'Z')+ ; // 匹配1個或多個大小寫字母
這個讓我們感到新鮮的是范圍運算符:'a'..'z',它的意思是從a到z的所有字符。
類似ID
的規則有時候會和其他詞法規則或者字符串常量值產生沖突,例如if
、for
、while
等關鍵字。
grammar KeywordTest;
rule : IF | FOR | WHILE | ID ;
IF : 'if' ;
FOR : 'for' ;
WHILE : 'while' ;
ID : [a-zA-Z]+ ; // 不會匹配 if, for, while
ANTLR
對混合了詞法規則和文法規則的語法文件的處理機制:
首先,
ANTLR
從文法規則中篩選出所有的字符串常量,並將它們和詞法規則放在一起。字符串常量被隱式定義為詞法規則,然后放置在文法規則之后、顯式定義的詞法規則之前。ANTLR
詞法分析器解決歧義問題的方法是優先使用位置靠前的詞法規則。這意味着,ID
規則必須定義在所有的關鍵字規則之后。ANTLR
將為字符串常量隱式生成的詞法規則放在顯式定義的詞法規則之前,所以它們總是擁有最高的優先級。
匹配數字
定義一個簡化版的浮點數:
一個浮點數以一列數字為開頭,后面跟着一個點,然后是可選的小數部分;浮點數的另外一種格式是,以點為開頭,后面是一列數字。一個單獨的點不是一個合法的浮點數定義。
FLOAT: DIGIT+ '.' DIGIT* // 匹配 10., 3.14等
| '.' DIGIT+ // 匹配 .618等
;
fragment
DIGIT : [0-9] ; // 匹配單個數字
這里使用了一條輔助規則DIGIT
,這樣就不用重復書寫[0-9]
了。將一條規則聲明為fragment
可以告訴ANTLR
,該規則本身不是一個詞法符號,它只會被其他的詞法規則使用。這意味着我們不能在文法規則中引用DIGIT
。
匹配字符串常量
STRING : '"' .*? '"' ; // 匹配兩個雙引號之間的任意字符序列
其中,點號通配符匹配任意的單個字符。因此,.*
就是一個循環,它匹配零個或多個字符組成的任意字符序列。顯然,它可以一直匹配到文件結束,但這沒有任何意義。
為解決這個問題,ANTLR
通過標准正則表達式的標記(?
后綴)提供了對非貪婪匹配子規則(nongreedy subrule
)的支持。
非貪婪匹配的基本含義是:
獲取一些字符,直到發現匹配后續子規則的字符為止。
更准確的描述是,在保證整個父規則完成匹配的前提下,非貪婪的子規則匹配數量最少的字符。
.*
是貪婪的,因為它貪婪地消費掉一切匹配的字符。
這樣的STRING
規則還不夠完善,因為它不允許其中出現雙引號。為了解決這個問題,很多語言都定義了以\
開頭的轉義序列。
在這些語言中,如果希望在一個被雙引號包圍的字符串中使用雙引號,我們就需要使用\"
。下面規則能夠支持常見的轉義字符:
STRING: '"' (ESC|.)*? '"' ;
fragment
ESC: '\\"' | '\\\\' ; // 雙字符序號\"和\\
ANTLR
語法本身需要對轉義字符\
進行轉義,因此我們需要\\
來表示單個反斜杠字符。
匹配注釋和空白字符
當詞法分析器匹配到我們剛剛定義過的那些詞法符號的時候,它會將匹配到的詞法符號放入詞法符號流,輸送給語法分析器。之后,由語法分析器來檢查詞法符號流的語法結構。
但是,當詞法分析器匹配到注釋和空白字符的時候,我們通常希望將它們丟棄。這樣,語法分析器就不必處理注釋和空白字符了。
定義需要被丟棄的詞法符號的方法和定義正常的詞法符號的方法一樣。我們只需要使用skip
指令通知詞法分析器將它們丟棄即可。
例如,下面匹配類Java
語言中的單行和多行注釋:
LINE_COMMENT : '//' .*? '\r'? '\n' -> skip ; // 匹配單行注釋
COMMENT : '/*' .*? '*/' -> skip ; // 匹配多行注釋
詞法分析器可以接受許多種位於->
操作符之后的指令,skip
只是其中之一。例如,我們能夠使用channel
指令將某些詞法符號放入一個隱藏的通道
並輸送給語法分析器。
大多數編程語言將空白字符看作詞法符號間的分隔符,並將它們忽略(Python
是一個例外,它使用空白字符來達到某些語法上的目的:換行符代表一條命令的終止,特定數量的縮進指明嵌套的層級)。
下列規則告訴ANTLR
丟棄空白字符:
WS : [ \t\r\n]+ -> skip ; // 匹配一個或多個空白字符並將它們丟棄
有了上面這些語法設計的基礎,就能動手寫寫ANTLR
的案例了,更多代碼見: https://github.com/bytesfly/antlr-demo
后續文章將更多專注於ANTLR
的實戰與應用。
參考書籍:
- 《ANTLR 4權威指南》 —— 機械工業出版社