如何用 ANTLR 4 實現自己的指令碼語言?

OneAPM官方技術部落格發表於2016-03-29

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 幫我們完成以上的規則:

addExpression
: mulExpression (('+' | '-') mulExpression)*
;
mulExpression
: primaryExpression (('*' | '/') primaryExpression)*
;
primaryExpression
: Decimal
| '(' addExpression ')'
;

上面展示的 ANTLR 規則,在 primaryExpression 中,包括兩個可選項,要麼是數字,要麼是括號表示式,是最高優先順序,然後是 mulExpression,優先順序最低的是 addExpression 。括號表示式內,是一個 addExpression ,所以,這是一個迴圈結構,可以處理無限長的四則運算式,比如 1+2*3-(4+5)/6+7+8,會被解析為如下的語法樹:

addExpression : 1 + child1_1 - child1_2 + 7 + 8
child1_1 mulExpression : 2 * 3
child1_2 mulExpression : child1_2_1 / 6
child1_2_1 addExpression : 4 + 5

以上的語法樹,其實是我簡化了的,比如,其中的數字 1 其實應該是 ·mulExpression ,而這個 mulExpression 只有一項 primaryExpression,而這個 primaryExpression,是 Decimal,其值為 1 。

PS: 在 ANTLR 中,大寫字母開頭的識別符號,如上面的 Decimal,是詞法分析器解析的,而小寫字母開頭的識別符號,如 addExpression,是語法分析器解析的,它可以通過 override Visitor 的相應函式,改成我們自己的處理。因為預設情況下,ANTLR 4 生成的是 listener,而我想要使用 visitor,所以命令列輸入為:

   java -jar C:\Projects\ScriptParser\ts\antlr-4.5.2-complete.jar -visitor -no-listener TinyScript.g4

用上面的命令生成程式碼後,我們需要知道怎麼才能啟動它,可惜這裡,至少對於 C#,文件寫的要麼不全,要麼不正確,最後,我找到了正確的開啟方式:

using (var ais = new AntlrInputStream(new FileStream(fileName,     FileMode.Open)))
{
var lexer = new TinyScriptLexer(ais);
var tokens = new CommonTokenStream(lexer);
var parser = new TinyScriptParser(tokens);
parser.BuildParseTree = true;
var tree = parser.program();
var visitor = new MyVisitor();
visitor.Visit(tree);
}

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

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

public override object VisitMulExpression([NotNull]     TinyScriptParser.MulExpressionContext context)
{
var a = VisitPrimaryExpression(context.primaryExpression(0));
for (int i = 1; i < context.ChildCount; i += 2)
{
var op = context.GetChild(i).GetText();
var b =     (decimal)VisitPrimaryExpression((TinyScriptParser.PrimaryExpressionContext)context.GetChild(i + 1));
switch (op)
{
case "*":
a = (decimal)a * b;
break;
case "/":
a = (decimal)a / b;
break;
}
}
return a;
}

因為 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 中的一致,如果不一致,則丟擲異常。

public override object VisitAssign([NotNull] TinyScriptParser.AssignContext     context)
{
var name = context.Identifier().GetText();
object obj;
if (!Variables.TryGetValue(name, out obj))
{
throw context.Exception("Variable [{0}] should be definded first.", name);
}
var r = base.VisitAssign(context);
if (obj != null)
{
if (obj.GetType() != r.GetType())
{
throw context.Exception("Cannot assign [{1}] type value to a variable with  type [{0}].", obj.GetType().Name, r.GetType().Name);
}
}
Variables[name] = r;
return null;
}

當然,我們也可以選擇不在乎賦值語句兩邊是否型別相同,這樣,它的行為方式就和很多指令碼語言如 JavaScript 比較類似,變數在使用中可以改變型別。

不知道你是否注意到了,在上面的描述中,我們說到,我們其實知道表示式的結果的型別,並能在型別不匹配的時候丟擲異常,那麼,如果我們選擇在定義型別時,如果變數型別是 var 的話,我們就不處理型別不匹配的問題,就是實現了自動型別推斷!有點小顛覆吧?似乎很高階的這個語言特性,其實是順理成章就可以得到的,不需要什麼高大上的技術。在我們的指令碼里,要做到這一點,只要在 VisitDeclareExpression 函式中,遇到 var 時,在插入變數時,變數值是 null 就可以了。

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

ifStatement
: 'if' quoteExpr blockStatement
| 'if' quoteExpr blockStatement 'else' blockStatement
;

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

ifStatement
: 'if' quoteExpr blockStatement ('else' blockStatement)?
;

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

public override object VisitIfStatement([NotNull]     TinyScriptParser.IfStatementContext context)
{
var condition = (bool)VisitQuoteExpr(context.quoteExpr());
if (condition)
{
VisitBlockStatement(context.blockStatement(0));
}
else if (context.ChildCount == 5)
{
VisitBlockStatement(context.blockStatement(1));
}
return null;
}

最後那個 return null 是表明,我們的 if 語句不產生任何值。加上對 Visitor 內取值遍歷等的理解,這個 if 語句的處理是否看起來非常清晰明瞭?

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

forStatement
: 'for' '(' commonExpression ';' expression ';' assignAbleStatement ')'   blockStatement
;

再看實現:

public override object VisitForStatement([NotNull] TinyScriptParser.ForStatementContext context)
{
for (VisitCommonExpression(context.commonExpression());
(bool)VisitExpression(context.expression());
VisitAssignAbleStatement(context.assignAbleStatement()))
{
VisitBlockStatement(context.blockStatement());
}
return null;
}

嗯,你沒看錯,我們真的用了一個 for 迴圈來實現 for 迴圈 :slight_smile:

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

var sum = 0;
for(var i=1; i<=100; i=i+1) {
sum = sum + i;
}
print("sum 1 to 100 is : " + sum);

執行 ts sum.ts ,控制檯輸出:

sum 1 to 100 is : 5050

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

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

本文轉自 OneAPM 官方部落格

相關文章