這節以四則運算語句的解析爲例學習語法導入和Visitor模式。相比筆記1,這裏的語法更通用,允許加減乘除、圓括號、整數出現,並且允許賦值表達式。
1 四則運算解析
1.1 語法規則文件
從下面的文件中可以看到,整體是要匹配若干條語句,每條語句都是以NEWLINE
換行符結束的。然後語句可以是表達式語句、賦值語句、空語句。
表達式的語法規則定義比較自然,因爲這裏沒有手動消除左遞歸,ANTLR4可以自己消除直接左遞歸(文件中13/14行分支expr左側直接調用自身),這是相比其它工具的一大優勢,讓語法編寫更簡單易懂。
grammar Expr;
// 頂層規則:一條至多條語句
prog: stat+ ;
// 語句
stat: expr NEWLINE // 表達式語句(表達式後跟換行)
| ID '=' expr NEWLINE // 賦值語句(左值是標識符,右值是表達式)
| NEWLINE // 空語句(直接一個換行)
;
// 表達式
expr: expr ('*'|'/') expr // 表達式乘除表達式
| expr ('+'|'-') expr // 表達式加減表達式
| INT // 一個整形值
| ID // 一個標識符
| '(' expr ')' // 表達式外加一對括號
;
ID : [a-zA-Z]+ ; // 標識符:一個到多個英文字母
INT : [0-9]+ ; // 整形值:一個到多個數字
NEWLINE:'\r'? '\n' ; // 換行符
WS : [ \t]+ -> skip ; // 跳過空格和tab
詞法符號NEWLINE
匹配換行符,其中符號?
也就是正則裏的匹配出現一次或多次,在這裏就表示整個NEWLINE
匹配的是\r
或者\r\n
。這樣做的目的是因爲Windows中的換行符是\r\n
, 而Linux/Unix下的換行符是\n
。
最後的-> skip
在前面學習中也有接觸到,這個是一條ANTLR指令,告訴詞法分析器匹配時忽略這些字符,這樣就不用嵌入代碼來做忽略了(書上的意思是不用嵌入代碼也就不用和特定編程語言綁定)。
1.2 從主類調用
import anrlr.ExprLexer;
import anrlr.ExprParser;
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;
public class ExprJoyRide {
public static void main(String[] args) {
CharStream input = CharStreams.fromString("1+(2*3)+4\n");
// 詞法分析->Token流->生成語法分析器對象
ExprLexer lexer = new ExprLexer(input);
CommonTokenStream tokens = new CommonTokenStream(lexer);
ExprParser parser = new ExprParser(tokens);
// 真正啓動語法分析,並將語法樹輸出
ParseTree tree = parser.prog();
System.out.println(tree.toStringTree(parser));
}
}
2 語法導入
實際使用時經常會遇到非常大的語法,可以考慮將它拆分成多個小的語法文件。例如,可以將語法規則和詞法符號規則拆分開,因爲不同語言的詞法符號規則很大部分是重複的,這樣就可以把它抽象成一個單獨的模塊,以應用於多個語言的分析器。
2.1 CommonLexerRules.g4
注意這個文件中的的第一行用lexer grammar,表示這裏只存放詞法符號規則。
lexer grammar CommonLexerRules;
ID : [a-zA-Z]+ ; // 標識符:一個到多個英文字母
INT : [0-9]+ ; // 整形值:一個到多個數字
NEWLINE:'\r'? '\n' ; // 換行符
WS : [ \t]+ -> skip ; // 跳過空格和tab
2.2 LibExpr.g4
這個就是從最開始的語法規則文件裏把詞法符號規則去掉,再把2.1
文件導入。測試語法和生成代碼的功能都是直接在這個文件上做,而不用在被導入的文件上操作。
grammar LibExpr;
// 導入單獨分離出去的詞法符號規則文件
import CommonLexerRules;
// 頂層規則:一條至多條語句
prog: stat+ ;
// 語句
stat: expr NEWLINE // 表達式語句(表達式後跟換行)
| ID '=' expr NEWLINE // 賦值語句(左值是標識符,右值是表達式)
| NEWLINE // 空語句(直接一個換行)
;
// 表達式
expr: expr ('*'|'/') expr // 表達式乘除表達式
| expr ('+'|'-') expr // 表達式加減表達式
| INT // 一個整形值
| ID // 一個標識符
| '(' expr ')' // 表達式外加一對括號
;
3 訪問者(Visitor)模式
在筆記3中使用的是監聽器(Listener)模式,這裏嘗試ANTLR支持的另外一種遍歷語法樹的模式,訪問者模式。
3.1 LabeledExpr.g4
爲了讓每條備選分支都能有一個訪問方法,這裏爲每個備選分支都加上標籤(類似Python的註釋,用# 標籤名
表示)。
另外,加減乘除等符號在之前的語法是是字面值,現在也給它們設置名字,其實也就是讓它們也成爲詞法符號。這樣就可以直接用這個詞法符號名以Java常量的方式引用這些符號。
grammar LabeledExpr;
prog: stat+ ;
// -------------給每個備選分支打標籤
stat: expr NEWLINE # printExpr
| ID '=' expr NEWLINE # assign
| NEWLINE # blank
;
expr: expr op=('*'|'/') expr # MulDiv
| expr op=('+'|'-') expr # AddSub
| INT # int
| ID # id
| '(' expr ')' # parens
;
// -------------給運算符號設置名字,也形成詞法符號
MUL : '*' ;
DIV : '/' ;
ADD : '+' ;
SUB : '-' ;
// -------------剩下的是和之前一樣的詞法符號
ID : [a-zA-Z]+ ; // 標識符:一個到多個英文字母
INT : [0-9]+ ; // 整形值:一個到多個數字
NEWLINE:'\r'? '\n' ; // 換行符
WS : [ \t]+ -> skip ; // 跳過空格和tab
3.2 生成解析器代碼
在生成的時候勾選上generate parse tree visitor,這樣才能生成訪問者模式相關的類和接口。
如果是用命令行,就要用antlr4 -visitor LabeledExpr.g4
命令來生成。不過默認還是會帶有Listener的,如果想要去掉Listener,還要加上-no-listener
參數。
3.2.1 LabeledExprVisitor.java
首先生成了訪問器泛型接口,並爲每個未標籤的語法或帶標籤的備選分支生成了一個方法:
// LabeledExpr語法的訪問器接口
public interface LabeledExprVisitor<T> extends ParseTreeVisitor<T> {
// 訪問頂層語法
T visitProg(LabeledExprParser.ProgContext ctx);
// 訪問stat語法的第一個分支(對應# PrintExpr)
T visitPrintExpr(LabeledExprParser.PrintExprContext ctx);
// 訪問stat語法的第二個分支(對應# Assign)
T visitAssign(LabeledExprParser.AssignContext ctx);
...
}
這些方法都是泛型返回值的T visit*(*Context)
格式,以便讓實現類爲具體功能去實現不同的返回值類型。
3.2.2 LabeledExprBaseVisitor.java
另外還生成了一個默認的實現類,在訪問每個結點時直接調用訪問孩子結點的方法:
// 生成的默認實現類
public class LabeledExprBaseVisitor<T> extends AbstractParseTreeVisitor<T> implements LabeledExprVisitor<T> {
@Override public T visitProg(LabeledExprParser.ProgContext ctx) { return visitChildren(ctx); }
@Override public T visitPrintExpr(LabeledExprParser.PrintExprContext ctx) { return visitChildren(ctx); }
@Override public T visitAssign(LabeledExprParser.AssignContext ctx) { return visitChildren(ctx); }
...
}
這個訪問孩子結點的方法visitChildren()
就是繼承自它所繼承的抽象類AbstractParseTreeVisitor<T>
。
3.3 實現計算功能的訪問器類
爲了實現自定義的計算功能,要去繼承剛剛生成的LabeledExprBaseVisitor
泛型類,因爲是整數計算器(計算時候返回整數),所以泛型參數這裏指定爲Integer
即可。
/*
* Excerpted from "The Definitive ANTLR 4 Reference",
* published by The Pragmatic Bookshelf.
* Copyrights apply to this code. It may not be used to create training material,
* courses, books, articles, and the like. Contact us if you are in doubt.
* We make no guarantees that this code is fit for any purpose.
* Visit http://www.pragmaticprogrammer.com/titles/tpantlr2 for more book information.
*/
import antlr.LabeledExprBaseVisitor;
import antlr.LabeledExprParser;
import java.util.HashMap;
// 實現計算功能的訪問器類
public class EvalVisitor extends LabeledExprBaseVisitor<Integer> {
// 模擬計算器的內存,存放"變量名->值"的映射,即在賦值時候往這裏寫
HashMap<String, Integer> memory = new HashMap<>();
// 訪問賦值語句:ID '=' expr NEWLINE
@Override
public Integer visitAssign(LabeledExprParser.AssignContext ctx) {
String id = ctx.ID().getText(); // 獲取左值標識符
int value = visit(ctx.expr()); // 對右值表達式訪問求值
memory.put(id, value); // 存儲賦值
return value;
}
// 訪問表達式語句:expr NEWLINE
@Override
public Integer visitPrintExpr(LabeledExprParser.PrintExprContext ctx) {
Integer value = visit(ctx.expr()); // 對錶達式訪問求值
System.out.println(value); // 把值打印出來
return 0; // 反正用不到這個返回值,這裏返回假值
}
// 訪問單個整數構成的表達式:INT
@Override
public Integer visitInt(LabeledExprParser.IntContext ctx) {
return Integer.valueOf(ctx.INT().getText()); // 把這個數返回
}
// 訪問單個標識符構成的表達式:ID
@Override
public Integer visitId(LabeledExprParser.IdContext ctx) {
String id = ctx.ID().getText(); // 獲取標識符名字
if (memory.containsKey(id)) // 查表,找到就返回
return memory.get(id);
return 0; // 找不到返回0
}
// 訪問乘除法表達式:expr op=('*'|'/') expr
@Override
public Integer visitMulDiv(LabeledExprParser.MulDivContext ctx) {
int left = visit(ctx.expr(0)); // 被除數,或乘法因子1
int right = visit(ctx.expr(1)); // 除數,或乘法因子2
if (ctx.op.getType() == LabeledExprParser.MUL) // 檢查操作符
return left * right; // 乘法
return left / right; // 除法
}
// 訪問加減法表達式:expr op=('+'|'-') expr
@Override
public Integer visitAddSub(LabeledExprParser.AddSubContext ctx) {
int left = visit(ctx.expr(0)); // 項1
int right = visit(ctx.expr(1)); // 項2
if (ctx.op.getType() == LabeledExprParser.ADD) // 檢查操作符
return left + right; // 加法
return left - right; // 減法
}
// 訪問表達式加括號:'(' expr ')'
@Override
public Integer visitParens(LabeledExprParser.ParensContext ctx) {
return visit(ctx.expr()); // 其實就是把括號裏表達式的值算出來返回
}
}
注意這裏按照書上的代碼visitAssign()
方法也把賦值後的值返回了,實際上它也和visitPrintExpr()
一樣是語句stat
的一個分支罷了,而語句的返回值是用不到的,所以這裏返回0也可以。因爲這裏的語法裏不會出現連續賦值的情況,賦值就是語句,賦值後的值不會再被用到了。
當然實際的程序語言裏則可能會用到,比如
a=b=3
這種連續賦值。
當需要計算表達式的值的時候,代碼裏是直接調用了visit()
方法,這個方法的源碼沒看到,不過看起來就是直接去調用傳入的結點的訪問方法就可以了。
還有就是檢查操作符的地方值得注意,ctx.op.getType()
這裏可以通過op
屬性獲取到操作符,這個就是需要在語法裏給操作符設置op=('*'|'/')
而不是直接('*'|'/')
的原因了。緊隨其後的判斷== LabeledExprParser.MUL
就是在3.1
中要爲原本的操作符字面值設置名字的一大好處。
另外就是除法除0的檢查,這裏沒做檢查,我覺得是可以的,相當於除0的時候靠JVM給報錯,也沒什麼大問題。
3.4 從主類調用
import antlr.LabeledExprLexer;
import antlr.LabeledExprParser;
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;
public class Calc {
public static void main(String[] args) {
CharStream input = CharStreams.fromString("a=2*(3+4)-5\nb=2\na+b\n");
// 詞法分析->Token流->生成語法分析器對象
LabeledExprLexer lexer = new LabeledExprLexer(input);
CommonTokenStream tokens = new CommonTokenStream(lexer);
LabeledExprParser parser = new LabeledExprParser(tokens);
// 啓動語法分析,獲取語法樹(根節點)
ParseTree tree = parser.prog();
// 創建自定義的能進行四則運算的訪問者類
EvalVisitor evalVisitor = new EvalVisitor();
// 訪問這棵語法樹,在訪問同時即可進行計算獲取結果
evalVisitor.visit(tree);
}
}
運行結果是11,符合預期。因爲前兩條是賦值語句:
最後一條是表達式語句,要把計算值打印出來,和相加計算得到的值就是11。