简单的四则运算(二)迷你解释器

之前文章中,介绍了D爷的双栈法。而这篇文章会用会简单实现一个“迷你解释器”的方式去实现。实现的语法就是类似 c语言 进行运算的表达式,1*2+4/2 结果会是 4 ,不需要空格,而且语法的检验的。

不同于完整的解释器。由于语法非常简单(只有数字也只能是正整数),所以只需下面几部即可。

  1. 读入字符串

  2. 词法分析(Lexer):将字符串分割成一个个的词法单元(token)。比如,1*2+4/2,这阶段会把字符串变成1*2 等7个 token

  3. 语法分析(parser): 根据文法,将 token 弄成一棵树,并将之作为中间表现。比如:1*2+4/2 会变成这样的一颗树。

  1. 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 了。

参考资料

原文在 blog.zhangguojian.com/2018/10/13/…

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
### 回答1: Powerbus迷你主机中继器合一是一款新型的数据传输设备,能够同时实现主机和中继器的两种功能,极大地简化了数据传输的步骤和流程。 该设备拥有精密的芯片和高速的传输线路,能够稳定地传输大量的数据,确保数据的传输速度和传输的准确性。此外,它还拥有多种接口,可以与各种设备和终端进行配合,大大提高了数据传输的适应性和灵活性。 Powerbus迷你主机中继器合一的数据手册详细介绍了该设备的技术参数、功能特点、使用方法和注意事项等方面的内容。用户可以根据手册的指导,快速了解和掌握该设备的使用方法及相关的操作技能,以便更好地利用它完成数据传输的工作。 数据手册的编制采用了规范化的格式和简明易懂的语言,既符合技术规范的要求,又能够满足用户的实际需要,使用户在使用过程中既能够得到技术支持,又能够方便地操作和掌握。此外,数据手册还对该设备的安全性和可靠性进行了重点说明,为用户提供了有效的参考材料。 总之,Powerbus迷你主机中继器合一数据手册是一份非常实用和有价值的参考资料,不仅可以帮助用户快速了解和掌握该设备的使用方法和技术参数,而且还可以提高数据传输的效率和精度,促进数据传输的顺畅进行。 ### 回答2: “Powerbus迷你主机中继器合一”是一款高性能的网络扩展设备,其采用两合一的设计,既能扮演主机的角色,又可以充当中继器的作用,具有在局域网中实现数据传输和网络扩容的优秀性能。 该设备采用了高速传输技术,能实现高速传输数据。同时,它支持自适应传输,能智能匹配各类主流网络设备,无需任何设置就能快速接入网络。此外,它还融合了多重数据加密技术和安全认证机制,能有效的保障网络通信的安全性。 该设备体积小巧,方便携带,可以随时随地接入网络。其外观设计时尚简约,操作简单便捷,拥有人性化的设备指示灯和异常告警功能,便于实时监测和维护。 总之,“Powerbus迷你主机中继器合一”是一款高品质的网络扩展设备,具有高速传输、自适应传输、数据加密、安全认证等优秀性能,是企业网络扩容和数据传输的不之选。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值