最近須要實現自定義報表的功能,其中有一個需求是要計算用戶輸入的公式。好比用戶輸入公式:A1 + A2 * 2.4
,咱們須要將A1
和A2
替換成對應的值,而後算出結果;公式中還可能包含括號,好比:A1 * (A2 - 3)
;再進一步,公式中還能夠有咱們內置的的幾個函數(SUM
, MIN
, MAX
, AVG
, COUNT
),如:B1 * SUM(A1, A2 + 1.2)。
總的來講,咱們須要計算一個給定表達式的值,這個表達式能夠是數字(包括整數和小數),變量或函數的四則運算。html
經過一週對編譯原理的學習,最終完成了任務。今記錄於此,但願能給一樣遇到該問題的人一些幫助。java
從總體上看,整個過程分兩步:詞法分析和語法分析。詞法分析將表達式的字符流轉換爲詞法單元流;語法分析依賴詞法分析分析出的單元流,來構造表達式對象。git
咱們第一步要作的事情是對整個表達式進行詞法分析。算法
所謂詞法分析,簡單地講,就是要把表達式解析成一個一個的詞法單元——Token。而所謂的Token,就是表達式中一個「有意義」的最短的子串。好比對於表達式A1 * (SUM(A2, A3, 2) + 2.5)
,第一個解析出的Token應該是A1
,而不是A
,或者A1*
等。由於顯然A1
纔是咱們想要表達的一個量,而A
,A1 *
都是「無心義」的組合結果。另外,像數字、括號、逗號和四則運算符都會做爲一個獨立的詞法單元。所以,最終解析出的Token集合應該是:{ A1, *, (, SUM, (, A2, A3, 2, ), +, 2.5 }
。另外在進行詞法分析時,咱們除了要記錄每一個Token的字面值,最好還要記錄一下Token的類型,來標識這個Token是啥類型的,好比是變量,是數字,仍是邊界符等。因而,能夠定義以下的Token結構:express
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流的末尾。app
下面分析如何將表達式字符串解析爲一個一個的Token。大體的工做流程是從字符流中逐一讀取字符,當發現當前字符再也不能與以前讀取的字符連在一塊兒構成一個「有意義」的字符串時,便將以前讀到的字符串做爲一個Token;不斷進行上述操做,知道讀到字符流的末尾爲止;當讀到末尾時,咱們再加一個 END
Token。ide
以上操做關鍵之處在於如何判斷當前字符再也不能和以前讀到的字符構成一個「有意義」的字符串。其實分析一下各個Token類型不難發現:OPERATOR
和 DELIMITER
均只包含一個字符,能夠枚舉出所有的狀況;而END
是當讀完表達式後加上的;NUMBER
是必定是數字開頭,而且只包含數字和小數點,也就是說當讀到一連串數字或小數點後,若再讀到一個非數字或小數點,這時則認爲以前讀到的字符串是一個完整的數字了;而 VARIABLE
和 FUNCTION
均以字母開頭,包含字母、數字和下劃線。函數
咱們能夠畫出狀態轉換圖來更加形象地展現處理過程:學習
在上圖中,狀態0是起始狀態,當讀到一個字母時,轉移到狀態1;若接下來一直讀到的是字母或數字,則一直停留在狀態1,直到讀到一個非字母或數字則轉移到狀態2;狀態2是兩個同心圓,這表示它是一個終止態,到這裏這一輪的識別就結束了,這一輪可識別出一個 VARIABLE
或 一個 FUNCTION
。若讀取的字符流尚未到末尾,咱們接着重複以上的工做。和終止態2相似,當到達終止態4時會識別出一個 Number
;到達終止態5時會識別出一個 OPERATOR
或 DELIMITER
。
下面給出以上過程的完整的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
可由 VARIABLE
或 NUMBER
構成;而 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 + 2
,A1 * 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
的名字中「二目」所表明的同樣,它有兩個運算量:leftExpr
和 rightExpr
,分別表明操做符左邊的和操做符右邊的表達式;在它的 evaluate
實現方法中,須要根據運算符 op
來進行加,或減,或乘,或除操做。
同 Arith
相似,咱們還會定義一目運算表達式 Unary
,像一個單純的數字,好比5
(此時 op
爲 null
),或者一個負數,好比-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())
中,expr
和t()
的位置 :-)
到此,就完成了 產生式 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