【ANTLR學習筆記】4:語法導入和訪問者(Visitor)模式

這節以四則運算語句的解析爲例學習語法導入和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,符合預期。因爲前兩條是賦值語句:
a=2×(3+4)5=9b=2 \begin{aligned} a&=2\times(3+4)-5=9 \\ b&=2 \end{aligned}

最後一條是表達式語句,要把計算值打印出來,aabb相加計算得到的值就是11。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章