自己动手写表达式解释器
写在前面
最近需要实现自定义报表的功能,其中有一个需求是要计算用户输入的公式的值。比如用户输入公式:A1 + A2 * 2.4
,其中的A1
、A2
是变量,2.4
是常数,我们需要做的是将A1
和A2
对应的数值带入表达式,计算出最终结果。需要说明的是,公式是用户自己输入的,而不是我们预先设定好的;公式中还可能包含括号,比如:A1 * (A2 - 3)
,它的运算优先级以及加减乘除等的运算优先级均和数学中的优先级一致;再进一步,公式中还可以使用我们预置的函数,如SUM
, MIN
, MAX
, AVG
, COUNT
等,后期可能还会增加。举个例子,这是一个带函数的表达式:B1 * SUM(A1, A2 + 1.2)
,若 B1 = 2, A1 = 1, A2 = 0.8, 我们需要计算出该表达式的最终结果:6。
因此,总地来说,我们需要做的是,计算一个给定表达式的值,这个表达式是数字(包括整数和小数),变量或函数的四则混合运算。
通过一周对编译原理的学习,最终实现了该需求。今记录于此,希望能给同样遇到该问题的人一些帮助。
从整体上看,整个过程分两步:词法分析和语法分析。词法分析将表达式的字符流转换为词法单元流;语法分析依赖词法分析分析出的单元流,来构造表达式对象。
词法分析
我们第一步要做的事情是对整个表达式进行词法分析。
所谓词法分析,简单地讲,就是要把表达式解析成一个一个的词法单元——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结构:
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
,至于究竟是 FUNCTION
还是 VARIABLE
,很容易判断:若我们的预置函数库包含该字符串,则认为是 FUNCTION
,否则是 VARIABLE
;若读取的字符流还没有到末尾,则重复以上的工作。和终止态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 ="https://blog-1251252991.cos.ap-chengdu.myqcloud.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
解释一下上面的产生式的含义:"->"
表示"可由...构成",即它左边的符号可由它右边的符号串构成;|
是“或”的意思,表示左侧的符号有多种构成形式。产生式左侧的单元可以分解为右侧更小的单元,因此被叫做非终结符;产生式右侧的某些单元,其能构成一个词法单元且不能再分解,被叫做终结符,比如 +
, 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,因此文中隐去了许多专业术语,并按我自己理解的通俗意思做了替换。有些概念和算法,由于篇幅和本人水平有限的原因,未作出详尽解释,还请包涵。若想要更加深入地学习 ,还请阅读专业的书籍。
完整代码整理好了给出 :-p。