ANTLR 語法設計

下面學習如何編寫語法。

如何定義語法規則

一種語言模式就是一種遞歸的語法結構。

我們需要從一系列有代表性的輸入文件中歸納出一門語言的結構。在完成這樣的歸納工作後,我們就可以正式使用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的規則有時候會和其他詞法規則或者字符串常量值產生衝突,例如ifforwhile等關鍵字。

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權威指南》 —— 機械工業出版社
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章