用 Antlr4 实现表达式计算

(本文章为原创,如转载敬请注明出处)
表达式计算是学生们乐于实现的一个课题。有些人简单粗暴,循环扫描;有些人参考数据结构里面用栈来实现;而最优雅强大,逼格最高的办法,自然是运用编译原理来做了。然而编译原理那些模式匹配,甚是繁琐!于是乎,有个叫Terence Parr的大牛出现了!他发愿要拯救世界上被编译器折磨的人们!就这样,Java界做编译器的不二之选:Antlr!闪亮登场!!Antlr能根据您定义的文法解释(Parser)和词法(Lexer)规则,生成代码来把语言source生成语法树。而Antlr4,则是最智能的一个版本。它可以生成Java,C++,Python,C#等等语言的代码,不过本例用Java实现。

有了这么牛逼的工具!做Expr calculation只是 Piece of cake!好了,我们开始牛逼之旅吧!

下载安装

假设您的系统已经装有Java JDK。

苹果系统是这样做的。Windows的话可以把 alias的那两个搞成 BAT,放在PATH下。

$ cd /usr/local/lib
$ sudo curl -O http://www.antlr.org/download/antlr-4.7-complete.jar
$ export CLASSPATH=".:/usr/local/lib/antlr-4.7-complete.jar:$CLASSPATH"
$ alias antlr4='java -jar /usr/local/lib/antlr-4.7-complete.jar'
$ alias grun='java org.antlr.v4.gui.TestRig'

定义一些基本词法

如果以grammar开头,是普遍语法,可以import那些lexer grammarparser grammar开头的module, 而lexer 和parser之间不能混着import。lexer可以import lexer, parser 可以 import parser.

定义操作符, 保存为Operators.g4

lexer grammar Operators;

MUL: '*';
DIV: '/';
ADD: '+';
SUB: '-';
POW: '^';
MOD: '%';

定义字符串,标识符,数字等。保存为BasicTypes.g4

lexer grammar BasicTypes;

fragment DIGIT: [0-9]; 
//Fragment修饰的方法,到时候在程序里不会生成 DIGIT()。
//而下面的NUMBER没有fragment, 则可以通过 NUMBER() 来访问。

fragment S_QUOTE: '\'';
fragment QUOTE: '"';
fragment ALPHABET: [A-Za-z_];

NUMBER: DIGIT+('.'DIGIT+)? ; //数字类型,包括浮点和整型

// 两种模式,一种是单引号,一种是双引号
STRING: S_QUOTE (~'\'')* S_QUOTE //单引号字符串
    | QUOTE ('\\"'|~'"')* QUOTE //双引号字符串
    ;

ID: ALPHABET+(DIGIT(ALPHABET)*)*;

WS: [ \r\t\n]+ -> skip; // Skip all the white spaces.

定义语法

保存为 Expr.g4

grammar Expr;
import Operators, BasicTypes; //导入之前定义的符号 和 数据类型

// 加入`#`,叫Annotation, 生成的Listener和visitor会有相应的方法来触发这个事件。
// 我认为这个应该叫 触发器注解。
// 注意,这个注解要么一个都不写,要么整条规则每一个模式都要写。
expr: SUB NUMBER                #NegativeNumber
    | expr POW expr             #Pow
    | expr (MUL|DIV|MOD) expr   #Mul_Div_Mod
    | expr (ADD|SUB) expr       #Add_Sub
    | ID                        #Identifier
    | NUMBER                    #Number
    | '(' expr ')'              #Parentheses
    ;

生成 Java 代码

运行如下命令。如果没有这个命令,参考官网上面 antlr4 的安装

antlr4 Expr.g4

编译生成的Java代码

javac *.java

测试匹配字符串

运行如下命令:

grun Expr expr -gui
> 1 + 2 * 3
> (CTRL+D 于Unix/Linux,CTRL+Z于Windows)

生成如下的语法树:

    expr
  /  |  \
expr +  expr
  |    /  |  \
  1  expr *  expr
      |       |
      2       3

好了!到目前为止,我们已经成功地定义了语法,并通过了测试。下一步,就是和Java结合来实现计算器了。

写代码来实现计算逻辑

既然生成了语法树,要想实现计算,就要遍历它。遍历的方法有两种, Visitor和Listener。Visitor比较直观,上级节点调用下级节点。
参看此例:例子

不过经过山哥的测试,每次调用Visit,都会遍历所有字节点,那么就造成了反复遍历。所以,山哥还是喜欢用Listener,这样一次成型,一滴永固,永不脱落!我中意!!

以下这种方式,也是Terence Parr比较喜欢的用空间换时间的方式。每个节点的值保存在一个Map中,这个Map是专门定制的。不会有Hashcode问题。(ParseTreeProperty类)

注意,因为默认的遍历模式是深度优先,所以如果你在enterVisitor就想取得子节点的值,那是不科学的!所以每次计算,都是在exitVisitor那里作。比如:

/**
 * Created by sam on 8/30/17.
 */
public class CalcExpr extends ExprBaseListener {
    // 用来对应每个节点,保存它的计算值,于是父级节点可以获取子节点的值
    ParseTreeProperty<BigDecimal> numbers = new ParseTreeProperty<>();
    
    // 退出时,把结果保存到本节点对应的Map里
    @Override
    public void exitEntry(ExprParser.EntryContext ctx) {
        // return the expression's value
        numbers.put(ctx, numbers.get(ctx.expr()));
        
        super.exitEntry(ctx);
    }

    @Override
    public void exitMul_Div_Mod(ExprParser.Mul_Div_ModContext ctx) {
        BigDecimal left = numbers.get(ctx.expr(0));
        BigDecimal right = numbers.get(ctx.expr(1));
        if(ctx.MUL() !=  null) {
            numbers.put(ctx, left.multiply(right));
        } else if(ctx.DIV() != null) {
            // Only keep up to 21 digits after the decimal point
            numbers.put(ctx, left.divide(right, 21, RoundingMode.HALF_UP).stripTrailingZeros());
        } else if(ctx.MOD() != null) {
            numbers.put(ctx, left.remainder(right));
        }
        
        super.exitMul_Div_Mod(ctx);
    }

...
    // 在遇到数字时,把它变成BigDecimal
    @Override
    public void exitNumber(ExprParser.NumberContext ctx) {
        numbers.put(ctx, new BigDecimal(ctx.getText()));
        
        super.exitNumber(ctx);
    }
    ...

调用这个遍历器,计算结果

import org.antlr.v4.runtime.CharStream;
import org.antlr.v4.runtime.CharStreams;
import org.antlr.v4.runtime.CommonTokenStream;
import org.antlr.v4.runtime.tree.ParseTree;
import org.antlr.v4.runtime.tree.ParseTreeWalker;
import org.junit.Test;

/**
 * Created by sam on 8/30/17.
 */
public class CalcExprTest {
    @Test
    public void testExpr() {
        CharStream input = CharStreams.fromString("1 + 2 * 3");
        ExprLexer lexer = new ExprLexer(input);

        CommonTokenStream tokenStream = new CommonTokenStream(lexer);
        ExprParser parser = new ExprParser(tokenStream);
        ParseTree parseTree = parser.entry();
        CalcExpr visitor = new CalcExpr();
        ParseTreeWalker walker = new ParseTreeWalker();
        walker.walk(visitor, parseTree);

        System.out.println(visitor.numbers.get(parseTree));

    }

}

运算结果为

7
Process finished with exit code 0

大功告成!亲个嘴儿!!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,236评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,867评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,715评论 0 340
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,899评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,895评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,733评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,085评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,722评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,025评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,696评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,816评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,447评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,057评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,009评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,254评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,204评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,561评论 2 343

推荐阅读更多精彩内容