打破國外壟斷,開發中國人自己的程式語言(2):使用監聽器實現計算器
阿新 • • 發佈:2020-08-21
上一篇:實現可以解析表示式的計算器
本文已經同步到公眾號「極客起源」,輸入379404開始學習!
本文是《打破國外壟斷,開發中國人自己的程式語言》系列文章的第2篇。本系列文章的主要目的是教大家學會如何從零開始設計一種程式語言(marvel語言),並使用marvel語言開發一些真實的專案,如移動App、Web應用等。marvel語言可以通過下面3種方式執行: 1. 解釋執行 2. 編譯成Java Bytecode,利用JVM執行 3. 編譯成二進位制檔案,本地執行(基於LLVM)
本文詳細講解如何用Listener方式實現一個可以計算表示式的程式,該程式不僅可以計算表示式,也可以識別表示式的錯誤,如果某一個表示式出錯,那麼該表示式不會輸出任何結果。
1. Visitor與Listener 在上一篇文章中使用Antlr和Visitor實現了一個可以計算表示式的程式MarvelCalc。這個程式非常簡單,相當於Antlr的HelloWorld。不過Antlr除了Visitor方式外,還支援Listener方式,也就是監聽器方式。不管是哪種方式,其目的都是遍歷AST(抽象語法樹),只是Visitor方式需要顯式訪問子節點(通過visit方法訪問),例如,下面的程式碼訪問了MulDiv的兩個子節點,也就是MulDiv的左右運算元(ctx.expr(0)和ctx.expr(1))。
// expr op=('*'|'/') expr # MulDiv public Integer visitMulDiv(CalcParser.MulDivContext ctx) { int left = visit(ctx.expr(0)); // 訪問MulDiv的左運算元 int right = visit(ctx.expr(1)); // 訪問MulDiv的右運算元 if ( ctx.op.getType() == CalcParser.MUL ) return left * right; return left / right; } }
@Override public void enterMulDiv(CalcParser.MulDivContext ctx) { } @Override public void exitMulDiv(CalcParser.MulDivContext ctx) { }
那麼開始處理動作和結束處理動作有什麼區別呢?如果是原子表示式(內部不包含其他表示式的表示式),如id、數值等,這兩個事件方法沒什麼不同的(用哪一個處理表達式都可以)。但如果是非原子表示式,就要考慮下使用enter還是exit了。例如,下面的表示式:
3 * (20 / x * 43)這個表示式明顯是非原子的。編譯器會從左向右掃描整個表示式,當掃描到第一個乘號(*)時,會將右側的所有內容(20 / x * 43)當做一個整體處理,這就會第一次呼叫enterMulDiv方法和exitMulDiv方法。只不過在呼叫enterMulDiv方法後,還會做很多其他的工作,最後才會呼叫exitMulDiv方法。那麼中間要做什麼工作呢?當然是處理表達式(20 / x * 43)了。由於這個表示式中有一個變數x,所以在掃描到x時,需要搜尋該變數是否存在,如果存在,需要提取該變數的值。也就是說,在第一次呼叫enterMulDiv方法時還沒有處理這個變數x,如果在enterMulDiv方法中要計算整個表示式的值顯然是不可能的(因為x的值還沒有確定),所以正確的做法應該是在exitMulDiv方法中計算整個表示式的值,因為在該方法被呼叫時,整個表示式的每一個子表示式的值都已經計算完了。 enterXxx和exitXxx方法也經常被用於處理作用域,例如,在掃描到下面的函式時, 在該函式對應的enterXxx方法中會將當前作用域切換到myfun函式(通常用Stack處理),而在exitXxx方法中,會恢復myfun函式的parent作用域。類、條件語句、迴圈語句也同樣涉及到作用域的問題。關於作用域的問題,在後面的文章中會詳細介紹作用域的實現方法。
void myfun() { }
從前面的介紹可知,Listener比Visitor更靈活,Listener也是我推薦的遍歷AST的方式,後面的文章也基本上使用Listener的方式實現編譯器。 2. Listener對應的介面和基類 現在回到本文的主題上來,本文的目的是使用Listener的方式取代Visitor的方式實現計算器。在編譯Calc.g4時,除了生成CalcVisitor.java和CalcBaseVisitor.java,還生成了另外兩個檔案:CalcListener.java和CalcBaseListener.java。其中CalcListener.java檔案是Listener的介面檔案,介面中的方法會根據Calc.g4檔案中的產生式生成,該檔案的程式碼如下:
import org.antlr.v4.runtime.tree.ParseTreeListener; public interface CalcListener extends ParseTreeListener { void enterProg(CalcParser.ProgContext ctx); void exitProg(CalcParser.ProgContext ctx); void enterPrintExpr(CalcParser.PrintExprContext ctx); void exitPrintExpr(CalcParser.PrintExprContext ctx); void enterAssign(CalcParser.AssignContext ctx); void exitAssign(CalcParser.AssignContext ctx); void enterBlank(CalcParser.BlankContext ctx); void exitBlank(CalcParser.BlankContext ctx); void enterParens(CalcParser.ParensContext ctx); void exitParens(CalcParser.ParensContext ctx); void enterMulDiv(CalcParser.MulDivContext ctx); void exitMulDiv(CalcParser.MulDivContext ctx); void enterAddSub(CalcParser.AddSubContext ctx); void exitAddSub(CalcParser.AddSubContext ctx); void enterId(CalcParser.IdContext ctx); void exitId(CalcParser.IdContext ctx); void enterInt(CalcParser.IntContext ctx); void exitInt(CalcParser.IntContext ctx); }
通常來講,並不需要實現CalcListener介面中的所有方法,所以antlr還為我們生成了一個預設實現類CalcBaseListener,該類位於CalcBaseListener.java檔案中。CalcListener介面的每一個方法都在CalcBaseListener類中提供了一個空實現,所以使用Listener方式遍歷AST,只需要從CalcBaseListener類繼承,並且覆蓋必要的方法即可。 3. 用Listener方式實現可計算器 現在建立一個MyCalcParser.java檔案,並在該檔案中編寫一個名為MyCalcParser的空類,程式碼如下:
public class MyCalcParser extends CalcBaseListener{ ... ... }
現在的問題是,在MyCalcParser類中到底要覆蓋CalcBaseListener中的哪一個方法,而且如何實現這些方法呢? 要回答這個問題,就要先分析一下上一篇文章中編寫的EvalVisitor類的程式碼了。其實在EvalVisitor中覆蓋了哪一個動作對應的方法,在MyCalcParser類中也同樣需要覆蓋該動作對應的方法,區別只是使用enterXxx,還是使用exitXxx,或是都使用。 現在將EvalVisitor類的關鍵點提出來: (1) 在EvalVisitor類中有一個名為memory的Map物件,用來儲存變數的值,這在Listener中同樣需要; (2)在EvalVisitor類中有一個error變數,用來標識分析的過程中是否有錯誤,在Listener中同樣需要; (3)每一個visitXxx方法都有返回值,其實這個返回值是向上一層節點傳遞的值。而Listener中的方法並沒有返回值,但仍然需要將值向上一層節點傳遞,所以需要想其他的方式實現向上傳值; 那麼為什麼要向上傳值呢?先來舉一個例子,看下面的表示式:
4 * 5這是一個乘法表達式,編譯器對這個表示式掃描時,會先識別兩個整數(4和5),這兩個整數是兩個原子表示式。如果使用Listener的方式,需要在這兩個整數對應的enterInt方法(exitInt方法也可以)中將'4'和'5'轉換為整數,這是因為不管值是什麼型別,編譯器讀上來的都是字串,所以需要進行型別轉換。 包含4和5的表示式是MulDiv,對應的動作方法是exitMulDiv(不能用enterMulDiv,因為這時4和5還沒有掃描到)。在exitMulDiv方法中要獲取乘號(*)左右兩個運算元的值(ctx.expr(0)和ctx.expr(1))。而這兩個運算元的值在enterInt方法中已經獲取了,我們要做的只是將獲取的值傳遞給上一層表示式,也就是MulDiv表示式。向上一層傳值的方法很多,這裡採用一個我非常推薦的方式,通過用一個Map物件儲存所有需要傳遞的值,key就是上一層節點的ParseTree物件(每一個enterXxx和exitXxx方法的ctx引數的型別都實現了ParseTree介面),而value則是待傳遞的值,可以使用下面的方式定義這個Map物件。
private Map<ParseTree,Integer> values = new HashMap<>();同時還需要兩個方法來設定和獲取值,分別是setValue和getValue,程式碼如下:
public void setValue(ParseTree node, int value) { values.put(node,value); } public int getValue(ParseTree node) { try { return values.get(node); } catch (Exception e) { return 0; } }下面給出MyCalcParser類的完整程式碼:
import org.antlr.v4.runtime.tree.ParseTree; import java.util.HashMap; import java.util.Map; public class MyCalcParser extends CalcBaseListener{ private Map<ParseTree,Integer> values = new HashMap<>(); // 用於儲存向上一層節點傳遞的值 Map<String, Integer> memory = new HashMap<String, Integer>(); // 用於儲存變數的值 boolean error = false; // 用於標識分析的過程是否出錯 // 設定值 public void setValue(ParseTree node, int value) { values.put(node,value); } // 獲取值 public int getValue(ParseTree node) { try { return values.get(node); } catch (Exception e) { return 0; } } @Override public void enterPrintExpr(CalcParser.PrintExprContext ctx) { // 當開始處理表達式時,預設沒有錯誤 error = false; } @Override public void exitPrintExpr(CalcParser.PrintExprContext ctx) { if(!error) { // 只有在沒有錯誤的情況下,才會輸出表達式的值 System.out.println(getValue(ctx.expr())); } } // 必須要放在exitAssign裡 @Override public void exitAssign(CalcParser.AssignContext ctx) { String id = ctx.ID().getText(); // 獲取變數名 int value = getValue(ctx.expr()); // 獲取右側表示式的值 memory.put(id, value); // 儲存變數 } // 必須在exitParens中完成 @Override public void exitParens(CalcParser.ParensContext ctx) { setValue(ctx,getValue(ctx.expr())); } // 計算乘法和除法(必須在exitMulDiv中完成) @Override public void exitMulDiv(CalcParser.MulDivContext ctx) { int left = getValue(ctx.expr(0)); // 獲取左運算元的值 int right = getValue(ctx.expr(1)); // 獲取右運算元的值 if ( ctx.op.getType() == CalcParser.MUL ) setValue(ctx,left * right); // 向上傳遞值 else setValue(ctx,left / right); // 向上傳遞值 } // 計算加法和減法(必須在exitAddSub中完成) @Override public void exitAddSub(CalcParser.AddSubContext ctx) { int left = getValue(ctx.expr(0)); // 獲取左運算元的值 int right = getValue(ctx.expr(1)); // 獲取右運算元的值 if ( ctx.op.getType() == CalcParser.ADD ) setValue(ctx,left + right); else setValue(ctx,left - right); } // 在enterId方法中也可以 @Override public void exitId(CalcParser.IdContext ctx) { String id = ctx.ID().getText(); if ( memory.containsKey(id) ) { setValue(ctx,memory.get(id)); // 將變數的值向上傳遞 } else { // 變數不存在,輸出錯誤資訊(包括行和列), System.err.println(String.format("行:%d, 列:%d, 變數<%s> 不存在!",ctx.getStart().getLine(),ctx.getStart().getCharPositionInLine() + 1, id)); error = true; } } // 處理int型別的值 @Override public void enterInt(CalcParser.IntContext ctx) { int value = Integer.valueOf(ctx.getText()); setValue(ctx, value); // 將整數值向上傳遞 } }
現在編寫用於遍歷AST和計算結果的MarvelListenerCalc類,程式碼如下:
import org.antlr.v4.runtime.CharStream; import org.antlr.v4.runtime.CharStreams; import org.antlr.v4.runtime.CommonTokenStream; import org.antlr.v4.runtime.tree.ParseTree; import org.antlr.v4.runtime.tree.ParseTreeWalker; import java.io.FileInputStream; import java.io.InputStream; public class MarvelListenerCalc { public static void main(String[] args) throws Exception { String inputFile = null; if ( args.length>0 ) { inputFile = args[0]; } else { System.out.println("語法格式:MarvelCalc inputfile"); return; } InputStream is = System.in; if ( inputFile!=null ) is = new FileInputStream(inputFile); CharStream input = CharStreams.fromStream(is); // 建立詞法分析器 CalcLexer lexer = new CalcLexer(input); CommonTokenStream tokens = new CommonTokenStream(lexer); // CalcParser parser = new CalcParser(tokens); ParseTree tree = parser.prog(); MyCalcParser calc = new MyCalcParser(); ParseTreeWalker walker = new ParseTreeWalker(); // 開始遍歷AST walker.walk(calc, tree); } }我們仍然使用上一篇文章使用的測試用例:
1+3 * 4 - 12 /6; x = 40; y = 13; x * y + 20 - 42/6; z = 12; 4; x + 41 * z - y;執行MarvelListenerCalc的執行結果如下圖所示: 本文實現的程式還支援錯誤捕捉,例如,將最後一個表示式的變數x改成xx,再執行程式,就會丟擲異常,出錯的表示式沒有輸出任何值,異常會指示出錯的位置(行和列),如下圖所示: