之前文章中,介绍了D爷的双栈法。而这篇文章会用会简单实现一个“迷你解释器”的方式去实现。实现的语法就是类似 c语言 进行运算的表达式,1*2+4/2 结果会是 4 ,不需要空格,而且语法的检验的。
不同于完整的解释器。由于语法非常简单(只有数字也只能是正整数),所以只需下面几部即可。
-
读入字符串
-
词法分析(Lexer):将字符串分割成一个个的词法单元(token)。比如,
1*2+4/2
,这阶段会把字符串变成1
、*
、2
等7个 token -
语法分析(parser): 根据文法,将 token 弄成一棵树,并将之作为中间表现。比如:
1*2+4/2
会变成这样的一颗树。
- Eval: 遍历树,获得结果。
用代码来表示大概是这样吧。
public static Double Eval(String input) {
Lexer lexer = new Lexer(input);
Parser parser = new Parser(lexer);
AST ast = parser.expr();
return ast.eval();
}
复制代码
Lexer(词法分析)
词法分析,阶段会把 字符串 转成 Tokens
Token 的定义,也比较简单。
public class Token {
public TokenType type;
public String text;
public Token( String text,TokenType type) {
this.text = text;
this.type = type;
}
}
复制代码
而 TokenType 的定义也简单。用 java 的枚举类就 ok 了。
public enum TokenType {
EOF,ADD,SUB,PLUS,DIV,NUM,LB,RB
}
复制代码
至于如何将字符串转成 tokens 数组,只要用一个指针遍历一次字符串就行了,
- 遇到'+'等运算字符就将创建对应的Token对象加到数组中
- 遇到的字符是如果是数字(0-9),还得看后面是否也是数字,才能保证数字的完整性。
- 遇到 '\r','\n','\t' 这类的字符跳过就是了。
但真正的Lexer
不会这样的写的,不会直接返回一个 token 数组的。 因为这样的效率很低。你想想编译(解释)项目往往会一次就编译多个文件,如果这样做,一下子就把所有的 token 都堆在内存中。编译通过还好,如果编译不通过,还要用那么多内存,是否有点不太合理了。所以一般来讲 Lexer 不是直接返回一个 Token 数组,而是提供一个 getNext()
的函数每次只返回一个语法单元。如果需要回溯什么的就用个数组缓存几个 Token 就 ok 了,那就节省了很多内存了。所以龙书中介绍的结构会是这样的。
所以,一般的 Lexer 会是这样定义的。
private String input;//输入字符串
private int p=0; //当前的输入字符串
char c; //当前字符
public Lexer(String input) {
this.input = input;
this.c = input.charAt(p);
}
public Token getNextToken(){
//...
}
复制代码
更正规点,不会用String
作为输入,而是用 CharStream 之类的结构。
先来试试应该如何调用 Lexer 吧
public static void main(String[] args) {
Scanner scan = new Scanner(System.in);
while (true) {
System.out.print(">");
String command = scan.nextLine();
try {
if(command.length() == 0){
continue;
}
Lexer lexer = new Lexer(command);
Token t = lexer.getNextToken();
//EOF End Of File 文件结束符,这里用来标记字符串结束
while (t.type != TokenType.EOF){
System.out.println(t.text+","+t.type);
t = lexer.getNextToken();
}
} catch (Exception e) {
e.printStackTrace();
System.out.println(e.getMessage());
}
}
}
复制代码
关键在于getNetToken
如何写,其实根据上面的思路去实现还是也挺简单的。
public Token getNextToken() throws InvalidCharacterException {
switch (c){
case '+':
consume();
return new Token("+",TokenType.ADD);
case '-':
consume();
return new Token("-",TokenType.SUB);
case '*':
consume();
return new Token("*",TokenType.PLUS);
case '/':
consume();
return new Token("/",TokenType.DIV);
case '(':
consume();
return new Token("(",TokenType.LB);
case ')':
consume();
return new Token(")",TokenType.RB);
case EOF:
return new Token("<EOF>",TokenType.EOF);
default:
if(c>='0'&&c<='9'){
StringBuilder num = new StringBuilder();
while (isDigital(c)){
num.append(c);
consume();
}
return new Token(num.toString(),TokenType.NUM);
}
throw new Error("invalid character: " + c);
}
}
复制代码
consume
函数的作用是,让字符串指针向前挪一个字符。
public void consume(){
p++ ;
if( p >= input.length()){
c = EOF;
} else {
c = input.charAt(p);
}
}
复制代码
其中 EOF 的意思是 End of file,这里是字符串没有下一个字符的意思。
private final static char EOF = (char) -1;
复制代码
parser(语法分析)
这部分任务是,用文法将 token 组织起来,变成一颗树。
树的定义就比较简单,明显二叉树就能解决了。
public class AST {
Token token;
AST left;
AST right;
public AST(Token token, AST left, AST right) {
this.token = token;
this.left = left;
this.right = right;
}
public Double eval(){
//...
}
}
复制代码
而只有加减运算的文法(grammar) 能会是这样:
expr -> digit
expr -> digit + digit
expr -> digit - digit
digit -> [1-9][0-9]*
复制代码
能看懂吗?
digit
数字是用正则描述。意思是,开头是 1或者2或者。。。9 (简写就是 [1-9])的是数字,如果后面还有0个或者0个以上的 [0-9] 也是数字。
expr
意思是说,digit 是四则运算,digit+digit 也是四则运算的语法,digit-digit 也是一条四则运算的语法。
但是上面一条文法,是无法解析1+1+1
这样的表达式的。
要递归一下。所以改成这样就可以了。
expr -> digit
expr -> digit + expr
expr -> digit - expr
digit -> [1-9][0-9]*
复制代码
而这文法中 expr 还能组合起来,就变成这样了
expr -> digit + expr | digit - expr | digit
digit -> [1-9][0-9]*
复制代码
利用这文法,那么如何解析像是1+3-5
这样的表达式呢?
Parser
类一般会是这样定义的
public class Parser {
Token token;
private Lexer lexer;
public Parser(Lexer lexer) {
this.lexer = lexer;
token = lexer.getNextToken();
}
//一般文法的开始符和函数名直接对应
public AST expr(){
//...
}
public AST digit(){
//...
}
}
复制代码
当然,digit
函数是最容易写的
public AST digit(){
if(token.type == TokenType.NUM){
AST ast = new AST(num,null,null);
token = lexer.getNextToken();
return ast;
} else {
throw new Error("expecting "+ tokenType +"; but is" + token.type);
}
}
复制代码
根据上面的文法,写加减法的就有点问题,因为要回溯。如果按文法来 expr
函数会是这样的。
public AST expr(){
if(addExpr()){
if(token.type == TokenType.Num){
AST digit = digit();
token = lexer.getNextToken();
AST op = new AST(token,digit,null);
op.right = expr();
return op;
}
}
if(subExpr()){
//...
}
if(token.type == TokenType.NUM){
}
}
复制代码
这里只用了一个 token 且从左往右做解析的,叫 LL(1) 递归下降。这种要用向前看多个 token 判断语法,判断错误还要回到原来的位置。这种回溯明显是 LL(1) 递归下降做不到的。
所以,我们改下文法吧。改成这样。
expr -> digit | ((+|-) expr)*
digit -> [1-9][0-9]*
复制代码
这就可以用 LL(1) 递归下降解决问题了。
写起来函数会是这样的。
public AST expr() throws NoMatchToken, InvalidCharacterException {
AST digit = digit()
if(token.type == TokenType.ADD){
//...
AST op = new AST(token,digit,null);
token = lexer.getNextToken();
op->right = expr();
return op;
}else if(token.type == TokenType.SUB){
//...
}else if(token.type == TokenType.NUM){
return digit;
}else {
throw new Error("expecting "+ tokenType +"; but is" + token.type);
}
}
复制代码
当然,上面代码写得太难看了。函数中夹杂着 token = lexer.getNextToken();
和抛出错误等语言,最好将之抽出来。所以就有了这两个函数了
public void match(TokenType tokenType) throws InvalidCharacterException {
if(token.type == tokenType){
consume();
}else{
throw new Error("expecting "+ tokenType +"; but is" + token.type);
}
}
public void consume() throws InvalidCharacterException {
token = lexer.getNextToken();
}
复制代码
代码也变得清晰起来了。
public AST expr() throws NoMatchToken, InvalidCharacterException {
AST digit = digit();
if(token.type == TokenType.ADD ){
AST op = new AST(token,digit,null);
match(TokenType.ADD);
op.right = expr();
return op;
}else if(token.type == TokenType.SUB){
AST op = new AST(token,digit,null);
match(TokenType.SUB);
op.right = expr();
return op;
}else {
return digit;
}
}
public AST digit() throws NoMatchToken, InvalidCharacterException {
Token num = token;
match(TokenType.NUM);
return new AST(num,null,null);
}
复制代码
至于乘法、除法、括号也能按文法,就能处理优先级了。最后的文法如下:
expr -> term ((*|/) expr)*
term -> factor ((+|-) term)*
factor -> '(' expr ')' | digit
digit -> [1-9][0-9]*
复制代码
可以参考下我的源码
eval
实现 eval 就非常简单了。遍历一次二叉树就 ok 了。
public class AST {
public Double eval() throws NoSuchOpException {
if(token.type == TokenType.NUM){
return Double.valueOf(token.text);
}else {
switch (token.type){
case ADD:
return this.left.eval() + this.right.eval();
case SUB:
return this.left.eval() - this.right.eval();
case PLUS:
return this.left.eval() * this.right.eval();
case DIV:
return this.left.eval() / this.right.eval();
default:
//自定义的异常
throw new NoSuchOpException("没有这种运算"+token.text);
}
}
}
复制代码
最后
一个超简单的解释器就这样完成了。大家有没有发觉,Parser 的部分其实和文法相关,基本有了文法其实就知道了对应的 Parser 该怎么写了。那么有没有可以自动生成 Parser 的东西呢?有的,还挺多,最常用的 yacc,javacc,最好用的当然是 ANTLR 了。