編譯原理——表示式計算
寫在前面
最近需要實現自定義報表的功能,其中有一個需求是要計算使用者輸入的公式。比如使用者輸入公式:A1 + A2 * 2
.4,我們需要將A1
和A2
替換成對應的值,然後算出結果;公式中還可能包含括號,比如: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
才是我們想要表達的一個量,而A
,A1 *
都是“無意義”的組合結果。另外,像數字、括號、逗號和四則運算子都會作為一個獨立的詞法單元。因此,最終解析出的Token集合應該是:{ A1, *, (, SUM, (, A2, A3, 2, ), +, 2.5 }
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型別不難發現: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://ojapxw8c8.bkt.clouddn.com/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地址。
寫在最後
本文試圖站在一個從未接觸過《編譯原理》的同學的角度去介紹一些皮毛知識,事實上,我自己也只是在國慶假期時簡單學了一下 :-p,因此文中隱去了許多相關的專業術語,並按我自己理解的通俗意思做了替換。有些概念和演算法,由於篇幅和本人水平有限的原因,未作出詳盡解釋,還請包涵。若想要更加深入地學習 ,還請閱讀專業的書籍。
完整程式碼GitHub地址:過兩天整理好了給出 :-p