自己動手寫表達式解釋器

寫在前面

最近需要實現自定義報表的功能,其中有一個需求是要計算用戶輸入的公式。比如用戶輸入公式:A1 + A2 * 2.4,我們需要將A1A2替換成對應的值,然後算出結果;公式中還可能包含括號,比如:A1 * (A2 - 3);再進一步,公式中還可以有我們內置的的幾個函數(SUM, MIN, MAX, AVG, COUNT),如:B1 * SUM(A1, A2 + 1.2)。總的來說,我們需要計算一個給定表達式的值,這個表達式可以是數字(包括整數和小數),變量或函數的四則運算。

通過一週對編譯原理的學習,最終完成了任務。今記錄於此,希望能給同樣遇到該問題的人一些幫助。

從整體上看,整個過程分兩步:詞法分析語法分析。詞法分析將表達式的字符流轉換爲詞法單元流;語法分析依賴詞法分析分析出的單元流,來構造表達式對象。

詞法分析

我們第一步要做的事情是對整個表達式進行詞法分析

所謂詞法分析,簡單地講,就是要把表達式解析成一個一個的詞法單元——Token。而所謂的Token,就是表達式中一個“有意義”的最短的子串。比如對於表達式A1 * (SUM(A2, A3, 2) + 2.5),第一個解析出的Token應該是A1,而不是A,或者A1*等。因爲顯然A1纔是我們想要表達的一個量,而AA1 *都是“無意義”的組合結果。另外,像數字、括號、逗號和四則運算符都會作爲一個獨立的詞法單元。因此,最終解析出的Token集合應該是:{ A1, *, (, SUM, (, A2, A3, 2, ), +, 2.5 }。另外在進行詞法分析時,我們除了要記錄每個Token的字面值,最好還要記錄一下Token的類型,來標識這個Token是啥類型的,比如是變量,是數字,還是邊界符等。於是,可以定義如下的Token結構:

public class Token {
    private TokenType type;
    private Object value;
    
    // getter and setter
}

TokenType的取值如下:

public enum TokenType {
    VARIABLE, NUMBER, FUNCTION, OPERATOR, DELIMITER, END 
}

其中,VARIABLE, NUMBER, FUNCTION, OPERATOR自不用多說;DELIMITER是邊界符,包括, (, )END 是我們額外添加的,它標誌Token流的末尾。

下面分析如何將表達式字符串解析爲一個一個的Token。大致的工作流程是從字符流中逐一讀取字符,當發現當前字符不再能與之前讀取的字符連在一起構成一個“有意義”的字符串時,便將之前讀到的字符串作爲一個Token;不斷進行上述操作,知道讀到字符流的末尾爲止;當讀到末尾時,我們再加一個 END Token。

以上操作關鍵之處在於如何判斷當前字符不再能和之前讀到的字符構成一個“有意義”的字符串。其實分析一下各個Token類型不難發現:OPERATORDELIMITER 均只包含一個字符,可以枚舉出全部的情況;而END是當讀完表達式後加上的;NUMBER 是一定是數字開頭,並且只包含數字和小數點,也就是說當讀到一連串數字或小數點後,若再讀到一個非數字或小數點,這時則認爲之前讀到的字符串是一個完整的數字了;而 VARIABLEFUNCTION 均以字母開頭,包含字母、數字和下劃線。

我們可以畫出狀態轉換圖來更加形象地展示處理過程:

狀態轉換圖

在上圖中,狀態0是起始狀態,當讀到一個字母時,轉移到狀態1;若接下來一直讀到的是字母或數字,則一直停留在狀態1,直到讀到一個非字母或數字則轉移到狀態2;狀態2是兩個同心圓,這表示它是一個終止態,到這裏這一輪的識別就結束了,這一輪可識別出一個 VARIABLE 或 一個 FUNCTION。若讀取的字符流還沒有到末尾,我們接着重複以上的工作。和終止態2類似,當到達終止態4時會識別出一個 Number ;到達終止態5時會識別出一個 OPERATORDELIMITER

下面給出以上過程的完整的Java代碼:

import java.io.IOException;
import java.io.Reader;
import java.io.StringReader;
import java.util.Arrays;
import java.util.HashSet;
import java.util.NoSuchElementException;
import java.util.Set;

/**
 * 公式詞法分析器
 * <p>
 * DFA: <img alt="DFA text" src ="http://qiniu.derker.cn/production.png" />
 *
 * @author derker
 * @date 2018-10-04 14:51
 */
public class Lexer {

    private static final Set<Character> OPERATOR = new HashSet<>(Arrays.asList('+', '-', '*', '/'));
    private static final Set<Character> DELIMITER = new HashSet<>(Arrays.asList('(', ')', ','));
    private static final Set<Character> BLACK = new HashSet<>(Arrays.asList(' ', '\r', '\n', '\f'));


    public TokenStream scan(Reader reader) {
        return new TokenStream(reader);
    }

    public class TokenStream {

        private final Reader reader;

        private boolean isReachedEnd;
        private Character peek;
        private int row = 1;
        private int col = 0;


        public TokenStream(Reader reader) {
            this.reader = reader;
        }

        public Token next() {
            // 流中已沒有字符
            if (isReachedEnd) {
                throw new NoSuchElementException();
            }

            if (peek == null) {
                read();
            }

            if (peek == Character.MIN_VALUE) {
                isReachedEnd = true;
                return new Token(TokenType.END, '$');
            }

            // 捨棄空白符
            if (BLACK.contains(peek)) {
                if (peek == '\n') {
                    row++;
                    col = 0;
                }
                peek = null;
                return next();
            }

            Token token = null;

            // 當前字符是數字
            if (Character.isDigit(peek)) {
                token = readNumber();
            }

            // 當前字符是字母
            else if (Character.isLetter(peek)) {
                token = readWord();
            }

            // 當前字符是操作符
            else if (OPERATOR.contains(peek)) {
                token = new Token(TokenType.OPERATOR, peek);
                peek = null;
            }

            // 當前字符是邊界符
            else if (DELIMITER.contains(peek)) {
                token = new Token(TokenType.DELIMITER, peek);
                peek = null;
            }

            if (token == null) {
                throw new LexerException(row, col, "" + peek);
            }
            return token;
        }

        /**
         * 匹配一個數字
         */
        private Token readNumber() {
            int intValue = Character.digit(peek, 10);
            for (read(); Character.isDigit(peek); read()) {
                intValue = intValue * 10 + Character.digit(peek, 10);
            }

            if (peek != '.') {
                return new Token(TokenType.NUMBER, intValue);
            }

            // 掃描到小數點
            double floatValue = intValue;
            float rate = 10;
            for (read(); Character.isDigit(peek); read()) {
                floatValue = floatValue + Character.digit(peek, 10) / rate;
                rate *= 10;
            }
            return new Token(TokenType.NUMBER, floatValue);
        }

        /**
         * 匹配單詞
         */
        private Token readWord() {
            StringBuilder builder = new StringBuilder(peek + "");
            for (read(); Character.isLetterOrDigit(peek) || peek == '_'; read()) {
                // 若出現下劃線 或 中間現過數字
                builder.append(peek);
            }
            String word = builder.toString();
            // 優先匹配函數
            FunctionType functionType = FunctionType.valueOfName(word);
            if (functionType != null) {
                return new Token(TokenType.FUNCTION, functionType);
            }
            // 匹配單元格名字
            return new Token(TokenType.VARIABLE, word);
        }

        /**
         * 從流中讀取一個字符到peek
         */
        private void read() {
            Integer readResult;
            try {
                readResult = reader.read();
            } catch (IOException e) {
                throw new LexerException(e);
            }
            col++;
            peek = readResult == -1 ? Character.MIN_VALUE : (char) readResult.intValue();
        }
    }

    /**
     * 測試
     */
    public static void main(String[] args) {
        Lexer lexer = new Lexer();
        TokenStream tokenStream = lexer.scan(new StringReader("a + 1"));
        for (Token token = tokenStream.next(); token.getType() != TokenType.END; token = tokenStream.next()) {
            System.out.println(token);
        }
    }
}

語法分析

做完了詞法分析的工作,接下來就要做語法分析了。

在詞法分析階段,我們將整個表達式“劃分”成了一個一個的“有意義”的字符串,但我們沒有去做表達式是否合法的檢查。也就是說,對於給定的一個表達式,比如A1 + + B1,我們只管將其解析爲<VARIABLE, A1><OPERATOR, +><OPERATOR, +> , <VARIABLE, B1>,而不會去管它是否符合表達式的語法規則。當然,我們知道這個表達式是不合法的,因爲中間多了一個加號。校驗和將Token按規則組合構成一個更大的“有意義體”的工作將在語法分析這一階段要做。

先來分析一下之前的那個例子 A1 * (SUM(A2, A3, 2) + 2.5)。對於任何一名受過九年義務教育的同學,一眼掃過去就知道該怎麼計算:先算SUM(A2, A3, 2),將其結果加上2.5,再用A1乘以前面的結果。以上過程可以用一個樹狀圖形象的表達出來:

從上圖中可以發現:帶圓圈的節點都是操作符或函數,而它們的子節點都是變量或數字;以每一個帶圓圈的節點爲根節點的子樹也是一個表達式;若我們能夠構造出這棵樹,便能很輕鬆的計算出整個表達式了。下面着手構建這棵樹。

在此以前,先介紹一種用來描述每棵子樹構成規則的表達方式——產生式。舉個例子,對於一個只包含加減乘除的四則運算式,例如:A1 + 2 * A2, 它的最小單元(factor)是一個變量或數字;若將兩個操作單元用加減乘除號連接起來,如A1 + 2,又可構成一個新的更大的操作單元,該操作單元又可以和其他的操作單元用加減乘除號連接…… 這實際上是一個遞歸的構造,而用產生式很容易去描述這種構造:

unit   ->   factor+unit
          | factor-unit 
          | factor*unit 
          | factor/unit 
          | factor
factor -> VARIABLE | NUMBER

簡單解釋一下產生式的含義,"->" 表示"可由...構成",即它左邊的符號可由它右邊的符號串構成;| 表示“或”的意思,表示左側的符號有多種構成形式。產生式左側的單元可以根據產生式繼續分解,因此我們把它叫做非終結符,而右側的,能構成一個Token的單元,比如 +, VARIABLE 等是不能再分解的,我們把它叫做終結符

以上兩個產生式所代表的意思是:factor可由 VARIABLENUMBER 構成;而 unit 可由factor加一個加號或減號或乘號或除號,再加另一個 unit構,或者可以直接由一個factor構成。

根據以上介紹,下面給出我們需要求值的表達式的產生式:

E  ->  E+T | E-T | T
T  ->  T*U | T/U | U
U  ->  -F | F
F  ->  (E) | FUNCTION(L) | VARIABLE  | NUMBER
L  ->  EL' | ε
L' ->  ,EL' | ε

各個單元的含義如下:

E: expression, 表達式
T: term, 表達式項
U: unary, 一元式
F: factor, 表達式項的因子
L: expression list,表達式列表
ε:空

有了產生式,我們就可以根據它來指導寫代碼了。但目前它們是不可用的,因爲它們當中有些是左遞歸的,而我們待會會使用一種叫做自頂向下遞歸的預測分析技術來做語法分析,運用該技術前必須先消除產生式中的左遞歸(下面會明白這是爲什麼)。於是,在消除左遞歸後,可得到如下產生式:

 E  ->  TE'
 E' ->  +TE' | -TE' | ε
 T  ->  UT'
 T' -> *UT' | /UT' | ε
 U  ->  -F | F
 F  -> (E) | function(L) | variable  | number
 L  -> EL' | ε
 L' -> ,EL' | ε

下面正式開始做語法分析。分析的過程其實很簡單,爲每個非終結符寫一個分析過程即可

在此之前我們先來定義一些數據結構來表示這些非終結符。我們可以將每一個非終結符都看成一個表達式,爲此抽象出一個表達式的對象:

public abstract class Expr {
    /**
     * 操作符
     */
    protected final Token op;
    
    protected Expr(Token op) {
        this.op = op;
    }
    
    /**
     * 計算表達式的值
     */
    public final Object evaluate(Map<String, Object> values) {
        return this.evaluate(values::get);
    }
    
    // op getter ...
}

以上表達式對象有一個evaluate方法,它用來計算自身的值;還有一個叫做 op 的屬性,它表示操作符。例如我們下面要定義的代表一個二目運算表達式(如: 1 + 2A1 * 4等) 的 Arith對象,它繼承自 Expr,它的 op 屬性可能是 +-*/,以下是它的定義:

public class Arith extends Expr {

    private Expr leftExpr;
    private Expr rightExpr;

    public Arith(Token op, Expr leftExpr, Expr rightExpr) {
        super(op);
        this.leftExpr = leftExpr;
        this.rightExpr = rightExpr;
    }
    
    @Override
    public Object evaluate(VariableValueCalculator calculator) {
        Object left = leftExpr.evaluate(calculator);
        Object right = rightExpr.evaluate(calculator);

        left = attemptCast2Number(left);
        right = attemptCast2Number(right);

        char operator = (char) op.getValue();
        switch (operator) {
            case '+':
                return plus(left, right);
            case '-':
                return minus(left, right);
            case '*':
                return multiply(left, right);
            case '/':
                return divide(left, right);
        }
        return null;
    }
    
    /**
     * 加法
     */
    protected Object plus(Object left, Object right) {
        // 若是列表,取第一個
        if (left instanceof List && !((List) left).isEmpty()) {
            left = ((List) left).get(0);
        }
        if (right instanceof List && !((List) right).isEmpty()) {
            right = ((List) right).get(0);
        }
        // 有一個是字符串
        if (isString(left) || isString(right)) {
            return stringValue(left) + stringValue(right);
        }
        // 都是數字
        if (isNumber(left) && isNumber(right)) {
            if (isDouble(left) || isDouble(right)) {
                return doubleValue(left) + doubleValue(right);
            }
            return longValue(left) + longValue(right);
        }
        return null;
    }
    
    // setter and getter ...
}

正如 Arith 的名字中“二目”所代表的一樣,它有兩個運算量:leftExprrightExpr,分別代表操作符左邊的和操作符右邊的表達式;在它的 evaluate 實現方法中,需要根據運算符 op 來進行加,或減,或乘,或除操作。

Arith 類似,我們還會定義一目運算表達式 Unary,像一個單純的數字,比如5(此時 opnull),或者一個負數,比如-VARIABLE(此時 op 爲 負號)就屬於此類;還會定義 Func,它代表一個函數表達式;會定義 Num,它代表數字表達式;會定義 Var,它代表變量表達式。

有了以上定義後,下面給出語法分析器Parser的代碼。先看整體邏輯:

public class Parser {

    /**
     * 詞法分析器
     */
    private final Lexer lexer;

    private String source;
    private TokenStream tokenStream;
    private Token look;

    public Parser(Lexer lexer) {
        this.lexer = lexer;
    }

    public Expr parse(Reader reader) throws LexerException, IOException, ParserException {
        tokenStream = lexer.scan(reader);
        move();
        return e();
    }
    
    /**
     * 移動遊標到下一個token
     */
    private void move() throws LexerException, IOException {
        look = tokenStream.next();
    }

    private void match(char c) throws LexerException, IOException, ParserException {
        if ((char) look.getValue() == c) {
            move();
        } else {
            throw exception();
        }
    }

    private ParserException exception() {
        return new ParserException(source, tokenStream.getRow(), "syntax exception");
    }
}

Parser依賴Lexer,每次會從Lexer分析得到的Token流中獲取一個Token(move方法),然後調用根產生式(即第一條產生式)E -> TE'對應的方法 e 去推導整個表達式,得到一個表達式對象,並返回出去。作爲調用者,在拿到這個表達式對象後,只需執行evaluate方法便可以計算得到表達式的值了。

下面問題的關鍵是各產生式的推導過程怎麼寫。由於篇幅原因,舉其中幾個產生式推導方法的例子。

PS: 產生式對應推導方法的方法名命名規則是:取對應產生式左側的非終結符的小寫字符串作爲名字,若非終結符帶有 '符號,方法名中用數字1代替。

比如對於產生式E => TE',我們這麼去寫:

    private Expr e() {
        Expr expr = t();
        if (look.getType() == TokenType.OPERATOR) {
            while (((char) look.getValue()) == '+' || ((char) look.getValue()) == '-') {
                Token op = look;
                move();
                expr = new Arith(op, expr, t());
            }
        }
        return expr;
    }

根據該產生式右側的 TE',我們先調用方法t,來推導出一個T。緊接着就是推導 E',調用方法 e1 即可。但以上代碼並沒有調用 e1,這是因爲產生式 E => TE' 足夠簡單,並且E'只會出現在該產生式中(即 方法 e1 只可能被方法 e 調用),因此把方法 e1 的邏輯直接寫到方法e中。根據產生式 E' => +TE' | -TE' | εE'可推導出3種情況,這三種情況的前兩種只會在當前Token分別是 +- 的情況下發生,這也正是以上代碼 while 循環中的條件。之所以會有循環是因爲產生式 E' => +TE'E' => +TE',右側也包含 E',它自身就是一個遞歸定義。

想一想,爲啥之前說,我們需要把左遞歸的產生式轉化爲右遞歸?

當完成 E' => +TE'E' => +TE'的推導時,就得到了一個二目表達式 new Arith(op, expr, t())

注意 new Arith(op, expr, t()) 中,exprt() 的位置 😃

到此,就完成了 產生式 E => TE' 的推導過程。其他的產生式的推導過程與此類似,這裏就不一一給出了。完整代碼見文末GitHub地址。

下面簡單測試一下:

@Test
public void test3() throws LexerException, ParserException {
    Map<String, Object> values = new HashMap<>();
    values.put("B1", 1.2);
    Assert.assertEquals(1.2, Evaluators.evaluate("SUM(2 * (1 - 3), 1, 3, B1)", values));
}

寫在最後

本文試圖站在一個從未接觸過《編譯原理》的同學的角度去介紹一些皮毛知識,事實上,我自己也只是在國慶假期時簡單學了一下 :-p,因此文中隱去了許多相關的專業術語,並按我自己理解的通俗意思做了替換。有些概念和算法,由於篇幅和本人水平有限的原因,未作出詳盡解釋,還請包涵。若想要更加深入地學習 ,還請閱讀專業的書籍。

**完整代碼GitHub地址:過兩天整理好了給出 :-p **

參考

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