原创

Antlr4系列-语法检查和多规则的优先级

在上一篇文章中我们实现了简单的四则运算,所定义的规则并不能进行混合运算,本篇文章我们将继续完善语法规则,实现四则混合运算并遵循混合运算规则。

一、 语法检查

首先来解决上一篇文章最后的报错问题,当我们再g4文件中定义完语法规则后,通过idea的插件进行语法测试分析时,右侧的语法树中可以提示具体哪个地方有错误,那么在代码中如何检测语法的错误呢,答案就是BaseErrorListener,
接下来我们建两个监听类继承BaseErrorListener,分别监听语法的错误和词法的错误。如下:

语法监听类:

public class GrammarErrorListener extends BaseErrorListener {

    private List<String> errMessage = new ArrayList<>(0);

    @Override
    public void syntaxError(Recognizer<?, ?> recognizer, Object offendingSymbol, int line, int charPositionInLine, String msg, RecognitionException e) {
        String message = String.format("[语法错误]行%s列%s: %s. 原始原因:%s", line, charPositionInLine, offendingSymbol, msg);
        errMessage.add(message);
    }

    public List<String> getErrMessage() {
        return errMessage;
    }
}

词法监听类:

public class LexicalErrorListener extends BaseErrorListener {

    private List<String> errMessage = new ArrayList<>(0);

    @Override
    public void syntaxError(Recognizer<?, ?> recognizer, Object offendingSymbol, int line, int charPositionInLine, String msg, RecognitionException e) {
        Lexer lexer = (Lexer) recognizer;
        String text = lexer._input.getText(Interval.of(lexer._tokenStartCharIndex, lexer._input.index()));
        String errorDisplay = lexer.getErrorDisplay(text);
        String message = String.format("[词法错误] 行%s 列%s 错误词: %s", line, charPositionInLine, errorDisplay);
        errMessage.add(message);
    }

    public List<String> getErrMessage() {
        return errMessage;
    }
}

接着改造我们的调用代码,在解析前分别加入两个监听器,如果在解析语法树时有语法、词法错误则返回,不进行最终的调用,如下:

private static void calcute(String expression) {
        System.out.println("执行:" + expression);
        MyRuleSetVisitor visitor = new MyRuleSetVisitor();
        RuleSetLexer lexer = new RuleSetLexer(CharStreams.fromString(expression));
        lexer.removeErrorListeners();
        LexicalErrorListener lexicalListener = new LexicalErrorListener();
        //添加词法错误监听
        lexer.addErrorListener(lexicalListener);
        RuleSetParser parser = new RuleSetParser(new CommonTokenStream(lexer));
        parser.removeErrorListeners();
        GrammarErrorListener errorListener = new GrammarErrorListener();
        //添加语法法错误监听
        parser.addErrorListener(errorListener);
        RuleSetParser.CalcuContext calcu = parser.calcu();
        List<String> lexicalError = lexicalListener.getErrMessage();
        List<String> grammarError = errorListener.getErrMessage();
        if (!lexicalError.isEmpty()){
            System.out.println(lexicalError);
            return;
        }
        if (!grammarError.isEmpty()){
            System.out.println(grammarError);
            return;
        }
        Double result = visitor.visit(calcu);
        System.out.println("结果:" + result);
    }

最后执行测试代码 ,运行结果如下:

> Task :Test.main()
执行:3 - 1
结果:2.0
执行:3.5 + 1
结果:4.5
执行:3 * 5
结果:15.0
执行:10 / 5
结果:2.0
执行:10 / a
[[词法错误] 行1 列5 错误词: a]

通过结果可以发现,已经没有运行时的错误了,语法的检查在实际应用中通常是在将我们定义的领域语言持久化到数据库前进行的静态检查,语法错误时不予保存,以防止在运行时因语法不合格而无法运行。

二、 规则中使用语法

接下来我们继续完善四则混合运算,在定义Antlr4的语法时可以引用其他语法(包含自身),基于这一特性我们对上一篇文章中的规则进行改造,首先将加减乘除两侧的NUMBER词法改为calcu语法,含义就是在运算符号的两侧又可以是一组运算,从而形成不限长度的连续运算;
接着给calcu语法新增单独的NUMBER词法和用括号包裹的calcu语法,从而让整个语法支持数字和括号,最终修改好的g4文件如下:

grammar RuleSet; //程序名称和.g4名称一致即可


calcu: calcu MUL calcu                          # mul
     | calcu DIV calcu                          # div
     | calcu ADD calcu                          # add
     | calcu SUB calcu                          # sub
     | '(' calcu ')'                            # parens
     | NUMBER                                   # number
;

WS : [ \t\n\r]+ -> skip ; // ->skip表示antlr4在分析语言的文本时,符合这个规则的词法将被无视
ADD : '+' ;
SUB : '-' ;
MUL : '*' ;
DIV : '/' ;
NUMBER : '-'? [0-9]+('.'([0-9]+)?)? ;        // 数字正则

重新使用g4文件生成java文件并覆盖原来的文件,接着修改我们自己重写的MyRuleSetVisitor.java文件,如下:

public class MyRuleSetVisitor extends RuleSetBaseVisitor<Double> {

    @Override
    public Double visitMul(RuleSetParser.MulContext ctx) {
        // 乘号左边的calcu语法,
        // 它可能是一组带括号或不带括号甲减乘除运算,也可能是一个数字
        // 直接visit即可访问它的处理方法拿到最终返回结果
        double left = visit(ctx.calcu(0));
        // 乘号右边的calcu语法,与左侧相同
        double right = visit(ctx.calcu(1));
        return left * right;
    }

    @Override
    public Double visitDiv(RuleSetParser.DivContext ctx) {
        double left = visit(ctx.calcu(0));
        double right = visit(ctx.calcu(1));
        return left / right;
    }

    @Override
    public Double visitAdd(RuleSetParser.AddContext ctx) {
        double left = visit(ctx.calcu(0));
        double right = visit(ctx.calcu(1));
        return left + right;
    }

    @Override
    public Double visitSub(RuleSetParser.SubContext ctx) {
        double left = visit(ctx.calcu(0));
        double right = visit(ctx.calcu(1));
        return left - right;
    }

    @Override
    public Double visitNumber(RuleSetParser.NumberContext ctx) {
        // NUMBER词法直接转为double
        return Double.parseDouble(ctx.getText());
    }

    @Override
    public Double visitParens(RuleSetParser.ParensContext ctx) {
        return visit(ctx.calcu());
    }
}

接着编写一些测试代码,进行测试,测试代码和上一篇相同,只需要修改其中的算式,这里不再展示,结果如下:

执行结果

> Task :Test.main()
执行:3 - 1 * 2
结果:1.0
执行:3 * (3.5 + 1)
结果:13.5
执行:1 + 5 * 6 + 12 / 4
结果:34.0
执行:12 / 4 * 3
结果:1.0
执行:10 - 3 + 7
结果:0.0

三、 语法中规则的优先级

我们会发现上述的最后两个计算结果存在问题,同时存在乘除时先算了乘法,同时存在甲减时先算了加法。这是因为Antlr4的同一个语法的多个规则是有优先顺序的,描述起来很简单就是写在上面的规则会优先匹配,在我们的calcu语法规则中自上而下分别是乘除甲减,因此当仅有乘除时会先访问乘法,再访问除法,仅有甲减时也是同理。理解了原因我们对规则进行修改,让乘除处于同样的顺序,甲减处于同样的顺序。如下:

grammar RuleSet; //程序名称和.g4名称一致即可


calcu: calcu opt=(MUL|DIV) calcu                          # mulAndDiv
     | calcu opt=(ADD|SUB) calcu                          # addAndSub
     | '(' calcu ')'                                      # parens
     | NUMBER                                             # number
;

WS : [ \t\n\r]+ -> skip ; // ->skip表示antlr4在分析语言的文本时,符合这个规则的词法将被无视
ADD : '+' ;
SUB : '-' ;
MUL : '*' ;
DIV : '/' ;
NUMBER : '-'? [0-9]+('.'([0-9]+)?)? ;        // 数字正则

在上述的语法中出现了新的写法,解释如下:

  1. 语法的规则中可以使用自定义别名给组成规则的语法或词法取名,在语法或词法的前面加上名称=即可,如上述的opt=,opt为别名。
  2. 语法的规则中可以使用括号将语法或词法包裹表示一组子规则,括号中用|分割,表示匹配其中任意一个。

重新使用g4文件生成java文件并覆盖原来的文件,接着修改我们自己重写的MyRuleSetVisitor.java文件,如下:

public class MyRuleSetVisitor extends RuleSetBaseVisitor<Double> {

    @Override
    public Double visitAddAndSub(RuleSetParser.AddAndSubContext ctx) {
        // 符号左边的calcu语法,
        // 它可能是一组带括号或不带括号甲减乘除运算,也可能是一个数字
        // 直接visit即可访问它的处理方法拿到最终返回结果
        double left = visit(ctx.calcu(0));
        // 符号右边的calcu语法
        double right = visit(ctx.calcu(1));
        //通过别名获取opt类型判断是加还是减
        if (ctx.opt.getType() == RuleSetLexer.ADD) {
            return left + right;
        } else {
            return left - right;
        }
    }

    @Override
    public Double visitMulAndDiv(RuleSetParser.MulAndDivContext ctx) {
        double left = visit(ctx.calcu(0));
        double right = visit(ctx.calcu(1));
        if (ctx.opt.getType() == RuleSetLexer.MUL) {
            return left * right;
        } else {
            return left / right;
        }
    }

    @Override
    public Double visitNumber(RuleSetParser.NumberContext ctx) {
        // NUMBER词法直接转为double
        return Double.parseDouble(ctx.getText());
    }

    @Override
    public Double visitParens(RuleSetParser.ParensContext ctx) {
        return visit(ctx.calcu());
    }
}

这样就将优先级相同的规则放到了相同的优先级上,并在同一个方法中根据符号的不同进行分别处理。接着我们再来运行测试代码,结果如下:

> Task :Test.main()
执行:3 - 1 * 2
结果:1.0
执行:3 * (3.5 + 1)
结果:13.5
执行:1 + 5 * 6 + 12 / 4
结果:34.0
执行:12 / 4 * 3
结果:9.0
执行:12 / ( 4 * 3 )
结果:1.0
执行:10 - 3 + 7
结果:14.0

由结果可以看出,计算正确,至此支持小括号的四则混合运算已完成。下一节将继续讲解变量定义及使用的实现。

正文到此结束
该篇文章的评论功能已被站长关闭