天天看點

如何用 ANTLR 4 實作自己的腳本語言?

antlr 是一個 java 實作的詞法/文法分析生成程式,目前最新版本為 4.5.2,支援 java,c#,javascript 等語言,這裡我們用 antlr 4.5.2 來實作一個自己的腳本語言。

因為某些未知原因,antlr 官方的文檔似乎有些地方和 4.5.2 版的實際情況不太吻合,是以,有些部分,我們必須多方查找和自己實踐得到,所幸 antlr 的文檔比較豐富,其在 github 上例子程式也很多,足夠我們探索的了。

如果你沒有編譯原理的基礎,隻要寫過正規表達式,應該也能很快了解其規則,進而編寫自己的規則檔案,事實上,因為結構更清晰, antlr 的規則檔案,比正規表達式要簡單得多。

我使用 c# 版本,是以下載下傳了 antlr-4.5.2-complete.jar 和 c# 的支援庫 antlr4.runtime.dll。

antlr 官方網址 http://www.antlr.org/

antlr 官方 github https://github.com/antlr/antlr4

大量文法檔案例子 https://github.com/antlr/grammars-v4

因為文章中不适合貼全部的代碼,建議下載下傳了 tinyscript 的代碼後,和此文章對照閱讀和實踐。

本文程式的 github https://github.com/lifeng-liang/tinyscript

好了,進入正題,我們要定義一個解釋型的腳本語言,就起個名叫 tinyscript 好了,規則檔案名 tinyscript.g4 ,簡單起見,暫不實作函數,具體實作的功能如下:

變量,支援的資料類型為 decimal,bool,string,不支援 null

變量指派支援自動類型推斷,用 var 辨別

四則運算,支援字元串通過 + 進行連接配接

支援比較運算符,支援與或非運算符

if 語句,語句塊必須用大括号包裹

while,do/while,for 循環,同樣語句塊必須用大括号包裹

一個内置的輸出函數 print,可以輸出表達式的值到控制台

先說四則運算。四則運算裡,除了括号外,需要先乘除,後加減,這個規則在 antlr 裡怎麼實作呢?

在 antlr 裡,我們寫的規則,會生成解析器的代碼,這個解析器,會把目标腳本,解析成一個抽象文法樹。這顆抽象文法樹上,越是靠近葉子節點的地方,結合優先級越高,越是靠近根的地方,結合優先級越低,根據這個特點,我們就可以讓 antlr 幫我們完成以上的規則:

上面展示的 antlr 規則,在 primaryexpression 中,包括兩個可選項,要麼是數字,要麼是括号表達式,是最高優先級,然後是 mulexpression,優先級最低的是 addexpression 。括号表達式内,是一個 addexpression ,是以,這是一個循環結構,可以處理無限長的四則運算式,比如 1+2*3-(4+5)/6+7+8,會被解析為如下的文法樹:

以上的文法樹,其實是我簡化了的,比如,其中的數字 1 其實應該是 ·mulexpression ,而這個 mulexpression 隻有一項 primaryexpression,而這個 primaryexpression,是 decimal,其值為 1 。

ps: 在 antlr 中,大寫字母開頭的辨別符,如上面的 decimal,是詞法分析器解析的,而小寫字母開頭的辨別符,如 addexpression,是文法分析器解析的,它可以通過 override visitor 的相應函數,改成我們自己的處理。因為預設情況下,antlr 4 生成的是 listener,而我想要使用 visitor,是以指令行輸入為:

用上面的指令生成代碼後,我們需要知道怎麼才能啟動它,可惜這裡,至少對于 c#,文檔寫的要麼不全,要麼不正确,最後,我找到了正确的打開方式:

上面的 myvisitor,是我們需要實作的,它從生成的 tinyscriptbasevisitor 繼承, tinyscriptbasevisitor 是個泛型類,研究後,它的泛型參數是設計用來傳遞傳回值的,因為要支援多種資料類型,是以我把它定義為 object 。

在實作 myvisitor 時,隻要每個節點都做好自己的工作就可以了。下面我們以 visitmulexpression 函數來簡單介紹一下如何實作乘除運算:

因為 mulexpression 的定義中,至少有一個 primaryexpression,然後,可以有任意多乘除運算符及相應的 primaryexpression ,對應在 visitmulexpression 函數中,就是第一個子節點是 primaryexpression ,(如果有的話)第二個子節點是運算符,第三個子節點是 primaryexpression,第四個子節點是運算符……是以,上面的代碼,先通過 visitprimaryexpression 取出第一個節點值,儲存在變量 a 中,然後,通過循環擷取運算符和另一個值,并進行相應的運算,并把結果儲存在 a 中,最後把運算結果 a 傳回。因為在 visitmulexpression 中,隻會處理乘除運算,它們是同等的優先級,我們也就不用考慮這個問題,直接運算下去就可以了。

要注意的是,如果 mulexpression 隻有一個 primaryexpression 節點,它就不一定是 decimal ,是以 a 的類型是 object ,而在進行運算時,才會把它強制類型轉換成 decimal,因為這時我們已經确定它是 decimal 類型了。

ps:在這裡,我們有兩種方式取得子節點的值,如果定義中用了辨別符,就可以直接使用這個辨別符名作為函數調用,如上面的 context.primaryexpression(0) ,表示取第一個 primaryexpression ;另一種方法是調用 getchild 函數,getchild 函數因為是通用函數,是以經常需要強制類型轉換為我們需要的類型。

下面,我們來說說變量定義及自動類型推斷。

為了實作變量,我們在我們的 visitor 中定義一個 dictionary 類型的變量 variables ,用來儲存變量和它的值,在 visitdeclareexpression 函數中,根據變量類型,在 variables 中插入相應的鍵值對,然後,在指派時,檢查要被指派的表達式的值的類型,是否和 variables 中的一緻,如果不一緻,則抛出異常。

當然,我們也可以選擇不在乎指派語句兩邊是否類型相同,這樣,它的行為方式就和很多腳本語言如 javascript 比較類似,變量在使用中可以改變類型。

不知道你是否注意到了,在上面的描述中,我們說到,我們其實知道表達式的結果的類型,并能在類型不比對的時候抛出異常,那麼,如果我們選擇在定義類型時,如果變量類型是 var 的話,我們就不處理類型不比對的問題,就是實作了自動類型推斷!有點小颠覆吧?似乎很進階的這個語言特性,其實是順理成章就可以得到的,不需要什麼高大上的技術。在我們的腳本裡,要做到這一點,隻要在 visitdeclareexpression 函數中,遇到 var 時,在插入變量時,變量值是 null 就可以了。

下面,我們再來看看 if 語句的處理,我們頂一個一個必須用大括号包裹的語句組類型 blockstatement , if 語句定義如下:

當然,其實,上面的定義和下面這種寫法是等價的:

然後,我們在 visitifstatement 函數中,真的寫一個 if 語句,用來執行不同的 blockstatement 就可以了:

最後那個 return null 是表明,我們的 if 語句不産生任何值。加上對 visitor 内取值周遊等的了解,這個 if 語句的處理是否看起來非常清晰明了?

最後,來看看循環語句,我們以 for 循環為例,先看定義:

再看實作:

嗯,你沒看錯,我們真的用了一個 for 循環來實作 for 循環 :slight_smile:

好了,如果你下載下傳了整個程式,并編譯成功,我們現在可以編寫一些腳本來做測試了,比如下面這個計算 1 到 100 的和的程式 sum.ts :

運作 ts sum.ts ,控制台輸出:

當然,這個腳本語言功能還比較弱,比如不支援函數,比如字元串不支援轉義符等;也有一些實作的不太嚴格地方,比如強制類型轉換如果出錯,出錯資訊不準确等。不過,它是一個好的開始,可以讓我們在此基礎上,設計更完善、易用的語言。

oneapm 為您提供端到端的 java 應用性能解決方案,我們支援所有常見的 java 架構及應用伺服器,助您快速發現系統瓶頸,定位異常根本原因。分鐘級部署,即刻體驗,java 監控從來沒有如此簡單。想閱讀更多技術文章,請通路 oneapm 官方技術部落格。

本文轉自 oneapm 官方部落格