Antlr v4日志语法定义和解析流程

892 阅读7分钟

Antlr介绍

Antlr4(Another Tool for Language Recognition)是一款基于Java开发的开源的语法分析器生成工具,能够根据语法规则文件生成对应的语法分析器,广泛应用于DSL构建,语言词法语法解析等领域。

使用Antlr可以根据指定的语法规则生成Lexcer(词法分析)和Parser(语法解析)文件,同时其支持两种两种listener和visitor遍历模式。

编译过程

想了解词法解析,先得复习一下编译原理里的代码编译过程。编译过程即为代码转化为机器语言的过程,在机器上一般可以拆分为如下几个步骤:

lexing(词法分析) -> parsing(语法解析) -> resolution(定义和符号绑定) -> optimization(语句优化) -> code generation(代码生成)

  • lexing

由lexer工具完成。传入一段代码/字符串,返回一系列的Token(Token中的属性包括序号,Type,Text等)。token是组成字符串/程序的最小有意义单位。比如传入parser代码:

var average = (min + max) / 2; // this is a comment

parser会解析出一系列token:

image.png

  • parsing

由parser工具完成,会将token按照一定的语法规则组装起来,形成一棵树

image.png

以上面为例,可以看到这棵树没有包含全部的token,只包含代码中使用的部分token,通常将这棵树称为Abstract syntax tree(AST),与Concrete syntax tree(CST)不同。 若parser无法将token正确组装,则会抛出一个语法错误。

  • resolution

此阶段每当遇见一个标识符(identifier)时,找到起定义,将二者关联起来

  • optimization

比如一个表达式总返回相同的结果,则compiler会直接用结果来代替这个表达式。

  • code generation

此过程一般包含两种代码翻译:

  1. 生成 CPU 可以理解的 native code,OS 可以直接运行这些代码。native code 的运行速度超级快,不过生成过程也比较麻烦。不同OS需要不同native code。
  2. 生成 virtual machine code。不过 CPU 并不能理解 virtual machine code,所以还需要一个 virtual machine 来运行这些代码。现在我们一般把这些代码叫做 bytecode (因为一般一个指令的长度是一个 byte),bytecode 和 native code 很接近了,不过 bytecode 不针对某个特定的 OS。

语法文件整体结构

/** Optional javadoc style comment */

grammar Name; #生成的语法名

options {...}

import ... ;
tokens {...}

channels {...} // lexer only

@actionName {...}
//不同的语法规则
rule1 // parser and lexer rules, possibly intermingled

...

ruleN

常用语法

语法文件自上向下定义。

  • 注释:和Java的注释完全一致,也可参考C的注释,只是增加了JavaDoc类型的注释;

  • 标志符:针对Lexer的Token的定义,采用全大写字母的形式;针对parser规则,推荐首字母小写的驼峰命名;

  • Action(行为),主要有@header 和@members,用来定义一些需要生成到目标代码中的行为。可以通过@header设置生成的代码的package信息,@members可以定义额外的一些变量到Antlr4语法文件中。

  • Antlr4语法中,支持的关键字有:import, fragment, lexer, parser, grammar, returns, locals, throws, catch, finally, mode, options, tokens。

  • 每条语法规则结构如:

    ruleName: alternative1 | alternative2 | alternative3 ;

    这条语法规则申明一条名为ruleName的规则,其中|表名为分支、即改规则可以匹配三个分支中的任何一个。

    规则包括词法分析规则(lexer)和语法解析规则(parser),规则不太复杂时可写在一个语法文件内;若规则较多较复杂,可将二者拆分为两个文件。lexer定义了怎么将代码字符串序列转换成标记序列(拆分成token);语法规则定义怎么将标记序列转换成语法树(parser tree)。通常,lexer的规则名以大写字母命名,而parser的规则名以小写字母命名。

  • 替代标签

通过在语句后面,添加 #替代标签,可以将语句转换为这些替代标签,从而加以区分。

expr
   : BR_OPEN expr BR_CLOSE                           # expressionWithBr
  • 操作符优先级处理

默认情况下,ANTLR从左到右结合运算符,然而某些像指数群这样的运算符则是从右到左。可以使用选项assoc手动指定运算符记号上的相关性。如下面的操作:

expr : expr '^'<assoc=right> expr
  • 隐藏通道

很多信息,例如注释、空格等,是结果信息生成不需要处理的,但是我们又不适合直接丢弃,安全地忽略掉注释和空格的方法是把这些发送给语法分析器的记号放到一个“隐藏通道”中,语法分析器仅需要调协到单个通道即可。我们可以把任何我们想要的东西传递到其它通道中。

COMMENT
    : '--[' NESTED_STR ']' -> channel(HIDDEN)
    ;
LINE_COMMENT
    : '--'
    (                                               // --
    | '[' '='*                                      // --[==
    | '[' '='* ~('='|'['|'\r'|'\n') ~('\r'|'\n')*   // --[==AA
    | ~('['|'\r'|'\n') ~('\r'|'\n')*                // --AAA
    ) ('\r\n'|'\r'|'\n'|EOF)
    -> channel(HIDDEN)
    ;
WS
    : [ \t\u000C\r\n]+ -> skip
    ;
SHEBANG
    : '#' '!' ~('\n'|'\r')* -> channel(HIDDEN)
    ;

放到 channel(HIDDEN) 中的 Token,不会被语法解析阶段处理,但是可以通过Token遍历获取到。

Antlr4语法规则调试

在IDEA中安装Antlr V4插件,在语法文件v4上右键点击,选择Test Rule expr即可。

image.png

解析结果:

image.png

语法树访问

构建好语法树后,需对其上的节点进行遍历求值。Antlr提供了两种机制访问生成的语法树:Listenor和Visitor。

Listener特点

  • 访问AST的所有节点
  • 重写(Override)进入时(enterXXX方法)和退出时(exitXXX方法)要执行的方法
  • 要重写的方法没有返回值,因此需要在属性中保留所需的值

对于监听器模式,就是通过监听某对象,如果该对象上有特定的事件发生,则触发该监听行为执行。比如有个监控(监听器),监控的是大门(事件对象),如果发生了闯门的行为(事件源),则进行报警(触发操作行为)。

在Antlr4中,如果使用监听器模式,首先需要开发一个监听器,该监听器可以监听每个AST节点(例如表达式、语句等)的不同的行为(例如进入该节点、结束该节点)。在使用时,Antlr4会对生成的AST进行遍历(ParseTreeWalker),如果遍历到某个具体的节点,并且执行了特定行为,就会触发监听器的事件。

监听器方法是没有返回值的(即返回类型是void)。因此需要一种额外的数据结构(可以通过Map或者栈)来存储当次的计算结果,供下一次计算调用。

Visitor特点

  • 并非所有 AST 节点都被访问
  • 根据目的重写进入节点时要执行的过程(visitXXX方法)
  • 重写方法有一个返回值,因此不必在属性中保存所需的值

Vistor模式遍历语法树的用法使用较多,因此本文以Visitor为例写Demo。Visitor的使用可直接继承baseVisitor基类,重新实现内部的访问方法即可。

简易计算器实现

  • 创建语法文件Expression.g4
grammar Expression;
//实现一个计算器
@header {
    package antrlExample.Calculate;
}

calc
     : (expr)* EOF                                    # calculationBlock
     ;

 expr
    : BR_OPEN expr BR_CLOSE                           # expressionWithBr
    | sign=(PLUS|MINUS)? num=(NUMBER|PERCENT_NUMBER)  # expressionNumeric
    | expr op=(TIMES | DIVIDE) expr                   # expressionTimesOrDivide
    | expr op=(PLUS | MINUS) expr                     # expressionPlusOrMinus
//    | expr TIMES | DIVIDE expr                                 # expressionTest
   ;

BR_OPEN:   '(';
BR_CLOSE:  ')';
PLUS:      '+';
MINUS:     '-';
TIMES:     '*';
DIVIDE:    '/';
PERCENT:   '%';
POINT:     '.';


// 定义百分数
PERCENT_NUMBER
    : NUMBER ((' ')? PERCENT)
    ;

NUMBER
    : DIGIT+ ( POINT DIGIT+ )?
    ;

DIGIT
   : [0-9]+
   ;

// 定义了空白字符,后面的 skip 是一个特殊的标记,标记空白字符会被忽略
SPACE
   : ' ' -> skip
   ;
  • 利用Idea生成lexer和parser文件

image.png

  • 实现visitor
package antrlExample.Calculate.defination;

import antrlExample.Calculate.gen.ExpressionLexer;
import antrlExample.Calculate.gen.ExpressionParser;
import org.antlr.v4.runtime.CharStream;
import org.antlr.v4.runtime.CharStreams;
import org.antlr.v4.runtime.CommonTokenStream;

import java.math.BigDecimal;

public class Calculator {
    public BigDecimal execute(String expression){
        CharStream cs = CharStreams.fromString(expression);
        //生成一个针对表达式的lexer
        ExpressionLexer lexer = new ExpressionLexer(cs);
        CommonTokenStream tokens = new CommonTokenStream(lexer);
        ExpressionParser parser = new ExpressionParser(tokens);
        ExpressionParser.CalcContext context = parser.calc();
        BigDecimalCalculationVisitor visitor = new BigDecimalCalculationVisitor();
        return visitor.visit(context);

    }

    public static void main(String[] args) {

    }
}
  • 写个测试类验证
package antrlExample.Calculate.defination;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

import static org.junit.Assert.assertEquals;

public class CalculatorUnitTest {

    private final Calculator calculator = new Calculator();

    @DisplayName("Test Calculator")
    @ParameterizedTest
    @CsvSource({
            "1 + 2, 3",
            "3 - 2, 1",
            "2 * 3, 6",
            "6 / 3, 2",
            "6 / (1 + 2) , 2",
            "50%, 0.5",
            "100 * 30%, 30.0"
    })
    void testCalculation(String expression, String expected) {
        assertEquals(expected, calculator.execute(expression).toPlainString());
    }

}

补充:Listenor(监听器)的使用

与访问器不同的是,监听器的方法会被ANTLR提供的遍历器对象(比如ParseTreeWalker)自动调用,而在访问器的方法中,必须显示调用visit方法来访问子节点。如果没有调用visit方法就会导致对应的子树不被访问。而且监听器方法是没有返回值的(即返回类型是void)。因此我们需要一种额外的数据结构来存储我们的计算结果,供下一次计算调用。

参考文章

juejin.cn/post/684490… juejin.cn/post/701852… juejin.cn/post/684490… bbs.huaweicloud.com/blogs/22687…