摘要:本文將首先介紹Antlr4 grammer的定義方式,如何通過Antlr4 grammer生成對應的AST,以及Antlr4 的兩種AST遍歷方式:Visitor方式和Listener方式。
1. Antlr4簡單介紹
Antlr4(Another Tool for Language Recognition)是一款基於Java開發的開源的語法分析器生成工具,能夠根據語法規則檔案生成對應的語法分析器,廣泛應用於DSL構建,語言詞法語法解析等領域。現在在非常多的流行的框架中都用使用,例如,在構建特定語言的AST方面,CheckStyle工具,就是基於Antlr來解析Java的語法結構的(當前Java Parser是基於JavaCC來解析Java檔案的,據說有規劃在下個版本改用Antlr來解析),還有就是廣泛應用在DSL構建上,著名的Eclipse Xtext就有使用Antlr。
Antlr可以生成不同target的AST(https://www.antlr.org/download.html),包括Java、C++、JS、Python、C#等,可以滿足不同語言的開發需求。當前Antlr最新穩定版本為4.9,Antlr4官方github倉庫中,已經有數十種語言的grammer(https://github.com/antlr/grammars-v4,不過雖然這麼多語言的規則文法定義都在一個倉庫中,但是每種語言的grammer的license是不一樣的,如果要使用,需要參考每種語言自己的語法結構的license)。
本文將首先介紹Antlr4 grammer的定義方式(簡單介紹語法結構,並介紹如何基於IDEA Antlr4外掛進行除錯),然後介紹如何通過Antlr4 grammer生成對應的AST,最後介紹Antlr4 的兩種AST遍歷方式:Visitor方式和Listener方式。
2. Antlr4規則文法
下面簡單介紹一部分Antlr4的g4(grammar)檔案的寫法(主要參考Antlr4官方wiki:https://github.com/antlr/antlr4/blob/master/doc/index.md)。最有效的學習Antlr4的規則文法的寫法的方法,就是參考已有的規則文法,大家在學習中,可以參考已有語言的文法。而且Antlr4已經實現了數十種語言的文法,如果需要自己定義,可以參考和自己的語言最接近的文法來開發。
2.1 Antlr4規則基本語法和關鍵字
首先,如果有一點兒C或者Java基礎,對上手Antlr4 g4的文法非常快。主要有下面的一些文法結構:
- 註釋:和Java的註釋完全一致,也可參考C的註釋,只是增加了JavaDoc型別的註釋;
- 標誌符:參考Java或者C的標誌符命名規範,針對Lexer 部分的 Token 名的定義,採用全大寫字母的形式,對於parser rule命名,推薦首字母小寫的駝峰命名;
- 不區分字元和字串,都是用單引號引起來的,同時,雖然Antlr g4支援 Unicode編碼(即支援中文編碼),但是建議大家儘量還有英文;
- Action,行為,主要有@header 和@members,用來定義一些需要生成到目的碼中的行為,例如,可以通過@header設定生成的程式碼的package資訊,@members可以定義額外的一些變數到Antlr4語法檔案中;
- Antlr4語法中,支援的關鍵字有:import, fragment, lexer, parser, grammar, returns, locals, throws, catch, finally, mode, options, tokens。
2.2 Antlr4語法介紹
2.2.1語法檔案的整體結構及寫法示例
Antlr4整體結構如下:
/** Optional javadoc style comment */ grammar Name; options {...} import ... ; tokens {...} channels {...} // lexer only @actionName {...} rule1 // parser and lexer rules, possibly intermingled ... ruleN
一般如果語法非常複雜,會基於Lexer和Parser寫到兩個不同的檔案中(例如Java,可參考:https://github.com/antlr/grammars-v4/tree/master/java/java8),如果語法比較簡單,可以只寫到一個檔案中(例如Lua,可參考:https://github.com/antlr/grammars-v4/blob/master/lua/Lua.g4)。
下面我們結合Lua.g4中的一部分語法結構,介紹使用方法。寫Antlr4的文法,需要依據原始碼的結構來決定。定義時,依據原始碼檔案的寫法,從上到下開始構造語法結構。例如,下面是Lua.g4的一部分:
chunk : block EOF ; block : stat* retstat? ; stat : ';' | varlist '=' explist | functioncall | label | 'break' | 'goto' NAME | 'do' block 'end' | 'while' exp 'do' block 'end' | 'repeat' block 'until' exp | 'if' exp 'then' block ('elseif' exp 'then' block)* ('else' block)? 'end' | 'for' NAME '=' exp ',' exp (',' exp)? 'do' block 'end' | 'for' namelist 'in' explist 'do' block 'end' | 'function' funcname funcbody | 'local' 'function' NAME funcbody | 'local' attnamelist ('=' explist)? ; attnamelist : NAME attrib (',' NAME attrib)* ;
如上語法中,整個檔案被表示成一個chunk,chunk表示為一個block和一個檔案結束符(EOF);block又被表示為一系列的語句的集合,而每一種語句又有特定的語法結構,包含了特定的表示式、關鍵字、變數、常量等資訊,然後遞迴表示式的文法組成,變數的寫法等,最終全部都歸結到Lexer(Token)上,遞迴樹結束。
上面其實已經可以看到Antlr4規則的寫法,下面介紹一部分比較重要的規則的寫法。
2.2.2 替代標籤
首先,如2.2.1節的程式碼所示,stat可以有非常多的型別,例如變數定義、函式定義、if、while等,這些都沒有進行區分,這樣解析出來語法樹時,會很不清晰,需要結合很多的標記完成具體語句的識別,這種情況下,我們可以結合替代標籤完成區分,如下程式碼:
stat : ';' | varlist '=' explist #varListStat | functioncall #functionCallStat | label #labelStat | 'break' #breakStat | 'goto' NAME #gotoStat | 'do' block 'end' #doStat | 'while' exp 'do' block 'end' #whileStat | 'repeat' block 'until' exp #repeatStat | 'if' exp 'then' block ('elseif' exp 'then' block)* ('else' block)? 'end' #ifStat | 'for' NAME '=' exp ',' exp (',' exp)? 'do' block 'end' #forStat | 'for' namelist 'in' explist 'do' block 'end' #forInStat | 'function' funcname funcbody #functionDefStat | 'local' 'function' NAME funcbody #localFunctionDefStat | 'local' attnamelist ('=' explist)? #localVarListStat ;
通過在語句後面,新增 #替代標籤,可以將語句轉換為這些替代標籤,從而加以區分。
2.2.3 操作符優先順序處理
預設情況下,ANTLR從左到右結合運算子,然而某些像指數群這樣的運算子則是從右到左。可以使用選項assoc手動指定運算子記號上的相關性。如下面的操作:
expr : expr '^'<assoc=right> expr
^ 表示指數運算,增加 assoc=right,表示該運算子是右結合。
實際上,Antlr4 已經對一些常用的操作符的優先順序進行了處理,例如加減乘除等,這些就不需要再特殊處理。
2.2.4 隱藏通道
很多資訊,例如註釋、空格等,是結果資訊生成不需要處理的,但是我們又不適合直接丟棄,安全地忽略掉註釋和空格的方法是把這些傳送給語法分析器的記號放到一個“隱藏通道”中,語法分析器僅需要調協到單個通道即可。我們可以把任何我們想要的東西傳遞到其它通道中。在Lua.g4中,這類資訊的處理如下:
COMMENT : '--[' NESTED_STR ']' -> channel(HIDDEN) ; LINE_COMMENT : '--' ( // -- | '[' '='* // --[== | '[' '='* ~('='|'['|'\r'|'\n') ~('\r'|'\n')* // --[==AA | ~('['|'\r'|'\n') ~('\r'|'\n')* // --AAA ) ('\r\n'|'\r'|'\n'|EOF) -> channel(HIDDEN) ; WS : [ \t\u000C\r\n]+ -> skip ; SHEBANG : '#' '!' ~('\n'|'\r')* -> channel(HIDDEN) ;
放到 channel(HIDDEN) 中的 Token,不會被語法解析階段處理,但是可以通過Token遍歷獲取到。
2.2.5 常見詞法結構
Antlr4採用BNF正規化,用’|’表示分支選項,’*’表示匹配前一個匹配項0次或者多次,’+’ 表示匹配前一個匹配項至少一次。下面介紹幾種常見的詞法舉例(均來自Lua.g4檔案):
1) 註釋資訊
COMMENT : '--[' NESTED_STR ']' -> channel(HIDDEN) ; LINE_COMMENT : '--' ( // -- | '[' '='* // --[== | '[' '='* ~('='|'['|'\r'|'\n') ~('\r'|'\n')* // --[==AA | ~('['|'\r'|'\n') ~('\r'|'\n')* // --AAA ) ('\r\n'|'\r'|'\n'|EOF) -> channel(HIDDEN) ;
2) 數字
INT : Digit+ ; Digit : [0-9] ;
3) ID(命名)
NAME : [a-zA-Z_][a-zA-Z_0-9]* ;
3. 基於IDEA除錯Antlr4語法規則(文法視覺化)
如果要安裝Antlr4,選擇 File -> Settings -> Plugins,然後在搜尋框搜尋 Antlr安裝即可,可以選擇安裝搜尋出來的最新版本,下圖是剛剛安裝的ANTLR v4,版本是v1.15,支援最新的Antlr 4.9版本。
基於IDEA除錯Antlr4語法一般步驟:
1) 建立一個除錯工程,並建立一個g4檔案
這裡,我自己測試用Java開發,所以建立的是一個Maven工程,g4檔案放在了src/main/resources 目錄下,取名 Test.g4
2)寫一個簡單的語法結構
這裡我們參考寫一個加減乘除操作的表示式,然後在賦值操作對應的Rule上右鍵,可選擇測試:
如上圖,expr 表示的是一個乘法操作,所以我們如下測試:
但是,如果改成一個加法操作,則無法識別,只能識別到第一個數字。
這種情況下,就需要繼續擴充 expr的定義,豐富不同的語法,來繼續支援其他的語法,如下:
還可以繼續擴充其他型別的支援,這樣一步步將整個語言的語法都支援完整。這裡,我們形成的一個完整的格式如下(表示整形數字的加減乘除):
grammar Test; @header { package zmj.test.antlr4.parser; } stmt : expr; expr : expr NUL expr # Mul | expr ADD expr # Add | expr DIV expr # Div | expr MIN expr # Min | INT # Int ; NUL : '*'; ADD : '+'; DIV : '/'; MIN : '-'; INT : Digit+; Digit : [0-9]; WS : [ \t\u000C\r\n]+ -> skip; SHEBANG : '#' '!' ~('\n'|'\r')* -> channel(HIDDEN);
4. Antlr4生成並遍歷AST
4.1 生成原始碼檔案
這一步介紹兩種生成解析語法樹的兩種方法,供參考:
- Maven Antlr4外掛自動生成(針對Java工程,也可以用於Gradle)
pom.xml設定Antlr4 Maven外掛,可以通過執行 mvn generate-sources自動生成需要的程式碼(參考連結: https://www.antlr.org/api/maven-plugin/latest/antlr4-mojo.html,主要的意義在於,程式碼入庫的時候,不需要再將生成的這些語法檔案入庫,減少庫裡面的程式碼冗餘,只包含自己開發的程式碼,不會有自動生成的程式碼,也不需要做clean code整改),下面是一個示例:
<build> <plugins> <plugin> <groupId>org.antlr</groupId> <artifactId>antlr4-maven-plugin</artifactId> <version>4.3</version> <executions> <execution> <id>antlr</id> <goals> <goal>antlr4</goal> </goals> <phase>generate-sources</phase> </execution> </executions> <configuration> <sourceDirectory>${basedir}/src/main/resources</sourceDirectory> <outputDirectory>${project.build.directory}/generated-sources/antlr4/zmj/test/antlr4/parser</outputDirectory> <listener>true</listener> <visitor>true</visitor> <treatWarningsAsErrors>true</treatWarningsAsErrors> </configuration> </plugin> </plugins> </build>
按照上面設定後,只需要執行 mvn generate-sources 即可在maven工程中自動生成程式碼。
- 命令列方式
主要參考連結(https://www.antlr.org/download.html),有每種語言的語法配置,我們這裡考慮下載Antlr4完整jar:
下載好後(antlr-4.9-complete.jar),可以使用如下命令來生成需要的資訊:
java -jar antlr-4.9-complete.jar -Dlanguage=Python3 -visitor Test.g4
這樣就可以生成Python3 target的原始碼,支援的原始碼可以從上面連結檢視,如果不希望生成Listener,可以新增引數 -no-listener
4.2 訪問者模式遍歷Antlr4語法樹
Antlr4在AST遍歷時,支援兩種設計模式:訪問者設計模式 和 監聽器模式。
對於 訪問者設計模式,我們需要自己定義對 AST 的訪問(https://xie.infoq.cn/article/5f80da3c014fd69f8dbe09b28,這是一篇針對訪問者設計模式的介紹,大家可以參考)。下面直接通過程式碼展示訪問者模式在Antlr4中使用(基於第3章的例子):
import org.antlr.v4.runtime.CharStream; import org.antlr.v4.runtime.CharStreams; import org.antlr.v4.runtime.CommonTokenStream; import zmj.test.antlr4.parser.TestBaseVisitor; import zmj.test.antlr4.parser.TestLexer; import zmj.test.antlr4.parser.TestParser; public class App { public static void main(String[] args) { CharStream input = CharStreams.fromString("12*2+12"); TestLexer lexer=new TestLexer(input); CommonTokenStream tokens = new CommonTokenStream(lexer); TestParser parser = new TestParser(tokens); TestParser.ExprContext tree = parser.expr(); TestVisitor tv = new TestVisitor(); tv.visit(tree); } static class TestVisitor extends TestBaseVisitor<Void> { @Override public Void visitAdd(TestParser.AddContext ctx) { System.out.println("========= test add"); System.out.println("first arg: " + ctx.expr(0).getText()); System.out.println("second arg: " + ctx.expr(1).getText()); return super.visitAdd(ctx); } } }
如上,main方法中,解析出了表示式的AST結構,同時在原始碼中也定義了一個Visitor:TestVisitor,訪問AddContext,並且列印該加表示式的前後兩個表示式,上面例子的輸出為:
========= test add first arg: 12*2 second arg: 12
4.2 監聽器模式(觀察者模式)
對於監聽器模式,就是通過監聽某物件,如果該物件上有特定的事件發生,則觸發該監聽行為執行。比如有個監控(監聽器),監控的是大門(事件物件),如果發生了闖門的行為(事件源),則進行報警(觸發操作行為)。
在Antlr4中,如果使用監聽器模式,首先需要開發一個監聽器,該監聽器可以監聽每個AST節點(例如表示式、語句等)的不同的行為(例如進入該節點、結束該節點)。在使用時,Antlr4會對生成的AST進行遍歷(ParseTreeWalker),如果遍歷到某個具體的節點,並且執行了特定行為,就會觸發監聽器的事件。
監聽器方法是沒有返回值的(即返回型別是void)。因此需要一種額外的資料結構(可以通過Map或者棧)來儲存當次的計算結果,供下一次計算呼叫。
一般來說,面向程式靜態分析時,都是使用訪問者模式的,很少使用監聽器模式(無法主動控制遍歷AST的順序,不方便在不同節點遍歷之間傳遞資料),用法對我們們也不友好,所以本文不介紹監聽器模式,如果有興趣,可以自己搜尋測試使用。
5. Antlr4詞法解析和語法解析
這部分實際上,算是Antlr4最基礎的內容,但是放到最後一部分來講,有特定的目的,就是探討一下詞法解析和語法解析的界限,以及Antlr4的結果的處理。
5.1 Antlr4執行階段
如前面的語法定義,分為Lexer和Parser,實際上表示了兩個不同的階段:
- 詞法分析階段:對應於Lexer定義的詞法規則,解析結果為一個一個的Token;
- 解析階段:根據詞法,構造出來一棵解析樹或者語法樹。
如下圖所示:
5.2 詞法解析和語法解析的調和
首先,我們應該有個普遍的認知:語法解析相對於詞法解析,會產生更多的開銷,所以,應該儘量將某些可能的處理在詞法解析階段完成,減少語法解析階段的開銷,主要下面的這些例子:
- 合併語言不關心的標記,例如,某些語言(例如js)不區分int、double,只有 number,那麼在詞法解析階段,就不需要將int和double區分開,統一合併為一個number;
- 空格、註釋等資訊,對於語法解析並無大的幫助,可以在詞法分析階段剔除掉;
- 諸如標誌符、關鍵字、字串和數字這樣的常用記號,均應該在詞法解析時完成,而不要到語法解析階段再進行。
但是,這樣的操作在節省了語法分析的開銷之外,其實對我們也產生了一些影響:
- 雖然語言不區分型別,例如只有 number,沒有 int 和 double 等,但是面向靜態程式碼分析,我們可能需要知道確切的型別來幫助分析特定的缺陷;
- 雖然註釋對程式碼幫助不大,但是我們有時候也需要解析註釋的內容來進行分析,如果無法在語法解析的時候獲取,那麼就需要遍歷Token,從而導致靜態程式碼分析開銷更大等;
- …
這樣的一些問題該如何處理呢?
5.3 解析樹vs語法樹
大部分的資料中,都把Antlr4生成的樹狀結構,稱為解析樹或者是語法樹,但是,如果我們細究的話,可能說成是解析樹更加準確,因為Antlr4的結果,只是簡單的文法解析,不能稱之為語法樹(語法樹應該是能夠體現出來語法特性的資訊),如上面的那些問題,就很難在Antlr4生成的解析樹上獲取到。
所以,現在很多工具,基於Antlr4進行封裝,然後進行了更進一步地處理,從而獲取到了更加豐富的語法樹,例如CheckStyle。因此,如果通過Antlr4解析語言簡單使用,可以直接基於Antlr4的結果開發,但是如果要進行更加深入的處理,就需要對Antlr4的結果進行更進一步的處理,以更符合我們的使用習慣(例如,Java Parser格式的Java的AST,Clang格式的C/C++的AST),然後才能更好地在上面進行開發。
本文分享自華為雲社群《Antlr4簡明使用教程》,原文作者:maijun 。