上一篇:實現可以解析表達式的計算器
本文已經同步到公眾號「極客起源」,輸入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; } }
而Listener方式是由系統自動訪問當前節點的子節點的,並不需要顯式訪問子節點。而且Listener可以攔截當前節點的開始處理和結束處理動作。開始處理動作的事件方法以enter開頭,結束處理動作的事件方法以exit開頭。例如,處理MulDiv動作時,會生成兩個事件方法:enterMulDiv和exitMulDiv,分別表示開始處理MulDiv和結束處理MulDiv,這兩個方法的代碼如下:
@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,再執行程序,就會拋出異常,出錯的表達式沒有輸出任何值,異常會指示出錯的位置(行和列),如下圖所示:
請關注微信公眾號「
極客起源」,更多精彩內容期待您的光臨!
