目录
第一个阶段是词法分析,主要负责将符号文本分组成符号类tokens
第二个阶段就是真正的语法解析,目标就是构建一个语法解析树。
语法解析的输入是tokens,输出就是一颗语法解析树。
树形结构易于遍历和处理,并且容易被程序员理解,方便了应用代码做进一步处理。
多种解释或翻译的应用代码都可以重用一个解析器。但 ANTLR 也支持像传统解析器生成器那样,将应用处理代码直接嵌入到语法中。
对于因为计算依赖而需要多趟处理的翻译器来说,比起多次调用解析器去解析,遍历语法树多次更加高效。
根据推导策略的不同,语法解析分为LL与LR两种
LR自低向上(bottom-up)的语法分析方法
LL是自顶向下(top-down)的语法分析方法
举个例子,比如如何描述一个Json字符串?
Json字符串是一个key:Value字符串,其中key是字符串,value可以是String、数字、或者又是一个Json。csv也是这样,类似一种递归定义。
exp1 = x1; exp2 = x2; result = exp1 'operator' exp2 |
两类分析器各有其优势,适用不同的场景,很难说谁要更好一些。普遍的说法是LR可以解析的语法形式更多,LL的语法定义更简单易懂。
抽象语法树是将源代码根据其语法结构,省略一些细节(比如:括号没有生成节点),抽象成树形表达。
抽象语法树在计算机科学中有很多应用,比如编译器、IDE、压缩优化代码等。
如配置文件读取器,遗留代码转换器,wiki 标记渲染器和 JSON 解析器
[抽象语法树] https://tech.meituan.com/abstract-syntax-tree.html 该文中也举了一个遗留代码转换器的例子
Json解析器:Twitter
note:一些比较简单的其实可以用正则来处理,但是规则容易误中,表达能力也非常受限。
源文件--->抽象,结构化------->parse
编写语法规则文件的同时,其实就是一个抽象的过程。示例如下:
从上图可以看出主要语法文件分为四块,第二块一般可以不写,最重要的就是语法规则。
语法规则是以小写字母组成。如prog,stat。词法规则由大写字母组成。如ID:[a-zA-Z]+。
通过使用 | 运算符来将不同的规则分割,还可以使用括号构成子规则。例如(‘*’ | ‘/’)会匹配多个乘号或除号。注释: // /* */其他空格连接符: 表示顺序连接| 选择符: 表示或者的关系重复次数: + *可选: ?
https://github.com/antlr/grammars-v4 这类有各种语法文件示例。
FAQ:
Q: 为啥大家都是换行写分号?这个是
A: 【TODO】
Q: stat+ 是必须的表示语句一次可以有多个吗?
A:表示可以一次解析多个语句。比如 ANTLRInputStream inputStream = new ANTLRInputStream("ord=(3*4)/2 price=(4*3)/2"); 这两个stat就可以解析。当然如果我们是每次只解析一个,也可以不写+。
Q: 那么多规则,规则内还有分支,谁先谁后?具体的逻辑。
A: 这个谁先谁后还是看具体场景的逻辑,整体就是希望先被识别成哪种模式,就把该标签放在前面。
是自顶向下语法解析器的一种。
顾名思义,递归下降指的就是解析过程是从语法树的根开始,向叶子(token)递归。还是以前面的赋值表达式解析为例,其递归下降语法分析器的代码大概是下面这个样子:
stat()、assign()、expr()等方法调用所形成的调用栈能与语法分析树的内部节点一一对应。match()的调用对应树的叶子,而assign()方法直接顺序读取输入字符,而不用做任何选择。相比之下,stat()方法要复杂一些,因为在解析时,它需要向前看一些字符才能确认走哪个代码分支,有时甚至要读取完所有输入才能得出预测结果。
ANTLR从4.0开始生成的是ALL(*)解析器,其中A是自适应(Adaptive)的意思。对传统的LL(*)解析器有很大的改进,ANTLR是目前唯一可以生成ALL(*)解析器的工具。
在ANTLR4中已经不再提供生成AST的功能(在ANTLR3中可以通过Options指定output=AST来生成AST),而是生成了分析树(Parse Tree)。
自定义文法--->词法分析器(Lexical)------>语法分析器(Parse)—→Grammer
draw.io evaluation version
Lexical
文法文件
Parse
处理后的文件
输入
g4文件
Parse
输出
antlr
自定义词法分析器 (g4后缀)文件
用工具生成Java文件
继承xxBaseVisitor类实现自己的Visitor
在整个Antlr的使用过程中,最重要的就是编写规则文件。
规则文件的编写:
Grammer文件:
grammar Cal; prog: stat+; //一个程序由至少一条语句组成 /*为了以后的运算的方便性,我们需要给每一步规则打上标签,标签以”#”开头,出现在每一条规则的右边。打上标签后,antlr会为每一个规则都生成一个事件;在没有标签的情况下,每个规则会生成一个Context以及一个事件。 如下述语句,如果没有标签,那么只会生成一个StatContext和一个visitStat方法。 */ stat: ID '=' expr ';' #Assign //变量赋值语句 | 'print' '(' expr ')' ';' #printExpr //输出语句 ; expr: expr op=('*'|'/') expr #MulDiv //表达式可以是表达式之间乘除 | expr op=('+'|'-') expr #AddSub //表达式可以是表达式之间加减 | NUM #NUM //表达式可以是一个数字 | ID #ID //表达式可以是一个变脸 | '(' expr ')' #parens //表达式可以被括号括起来 ; MUL:'*'; DIV:'/'; ADD:'+'; SUB:'-'; ID: [a-zA-Z][a-zA-Z0-9]*; //变量可以是数字和字母,但必须以字母开头 //负数必须要用"()"括起来 NUM: [0-9]+ //正整数 | '(' '-' [0-9]+ ')' //负整数 | [0-9]+'.'[0-9]+ //正浮点数 | '(' '-' [0-9]+'.'[0-9]+ ')' //负浮点数 ; WS: [ \t\r\n] -> skip; //跳过空格、制表符、回车、换行 |
生成命令:
编辑完 Antlr 文件后,我们在安装有 Antlr plugin 的 Intellij 上,可以通过右键语法规则对语法规则进行测试,并可以在配置生成中间代码的包名、路径等选项后,直接生成中间代码。
生成的文件说明:
Hello.tokens和HelloLexver.tokens为文法中用到的各种符号做了数字化编号,对于我们定义的每个 token,ANTLR 分配了一个 token 类型码并将这些值保存在 ArrayInit.tokens。因为这个文件的存在,当我们将较大规模的语法分割为各种小型的语法表达时,ANTLR 能够使同种 token 的类型码保持一致。
HelloLexer.java 包含专用的词法分析程序(lexer)类的定义
HelloParse.java 包含了专用于 ArrayInit 语法的解析器(parser)类的定义。
HelloVisitor 和 HelloBaseVisitor 分别是语法解析树的vistor的接口和类,用于遍历整个语法树。一般情况下,我们通过继承 HelloBaseVisitor 来实现自己对于语法树遍历的处理。
HelloListener和HelloBaseListener是用listen方式来遍历树的解析方法。
HelloParse说明:
ANTLR为每个子规则创建一个同名函数,因此可以方便地取到其子规则的context。即每个规则对应一个Context。
Context对象包含以下内容:
语法分析时生成
起始Token,终止Token
children: 可以得到子语法规则中的内容。
异常信息: 可以得到解析失败的信息。
查看语法解析树:
在ANTLR 4以前,有两种开发方式:一是将目标语言的代码直接硬编码到语法定义文件中,在生成分析器时会插入这些代码到生成文件中,这也是大多数语法分析器生成工具的做法。
在上边的语法判定与通道的例子中,就有将Java代码硬编码到语法定义的情况。将目标代码和语法定义耦合在了一起,当需要生成不同目标语言的分析器时,就需要维护多份语法定义文件,
‘增加了维护成本,同时在编写复杂逻辑时,由于IDE没有对目标语言的支持,开发和测试都很幸苦。另一种方式是让ANTLR生成语法分析树,然后写程序遍历语法树,对语法树的遍历是一个很复杂的工作。
ANTLR 4开始会生成监听器(Listener)与访问者(Visitor),将语法定义与目标代码完全的解耦。监听器可以被动的接受语法分析树遍历的事件,对于每一个语法节点,
都会生成进入enterSomeNodeName与退出exitSomeNodeName两个方法。访问者机制生成对语法节点的访问方法visitSomeNodeName,在访问方法中需要手动调用visit方法来对子节点进行遍历,
使用访问者模式可以控制语法树的遍历,略过某些的分枝。ANTLR默认只生成监听器,生成访问者类需要在生成时添加-visitor选项。
Visitor和Listener是antlr提供的两种树遍历机制。Listener是默认的机制,可以被antlr提供的遍历器对象调用;如果要用Visitor机制,在生成识别器时就需要显式说明
antlr4 -no-listener -visitor Calc.g4,并且必须显示的调用visitor方法遍历它们的子节点,在一个节点的子节点上如果忘记调用visit方法,就意味着那些子数没有得到访问
核心区别:两者各有优劣,Listener模式适合全局查找,默认是深度优先遍历,而Visitor模式适合指定某个节点作遍历。
listener默认是遍历每个节点,对于每个节点都有enter和exit事件。对于visitor则是需要自己手动用写代码去进行指定节点遍历。
示例如下
public class WlgsParseListener extends MerakDslBaseListener{
private ParseTreeProperty<String> property = new ParseTreeProperty<>();
@Override
public void exitArithmeticBinary(MerakDslParser.ArithmeticBinaryContext ctx) {
MerakDslParser.ExpressionContext right = ctx.right;
MerakDslParser.ExpressionContext left = ctx.left;
String formula = left.getText() + ctx.operator.getText() + right.getText();
property.put(ctx,formula);
super.exitArithmeticBinary(ctx);
}
}
Listener模式的exitXX方法的返回值都是空,在遍历过程所有有临时数据需要保存,可以存近一个自定义数据结构中或者使用antrl的ParseTreeProperty中或者自定义一个数据结果保存都是OK的。
从上面可以看出,即使我们不使用super调用父类的同名方法,也不调用自定义baseListener中的其他方法,其实listen也是可以完成遍历的。因此listener是被动进行遍历的。
visitor不一样,是手动进行遍历,必须手动使用visit调用其他的visitor方法,或者使用super,父类中该方法是调用所有的子类visitor。
从以下源码可以看出,super.visitXX都是一样的,都是visit 参数Context的 Children。
@Override public T visitAssignment(MerakDslParser.AssignmentContext ctx) { return visitChildren(ctx); }
/**
* {@inheritDoc}
*
* <p>The default implementation returns the result of calling
* {@link #visitChildren} on {@code ctx}.</p>
*/
@Override public T visitCaseWhen(MerakDslParser.CaseWhenContext ctx) { return visitChildren(ctx); }
/**
整体流程就是,
visitProg(){
super.visitProg(ctx) //visit children就是所有的stat.accept(visitor)
stat.accept(visitor) ==> visitor.visitStat()
visitStat(ctx){super.visitStat} // visitStat的child
以此类推。。
}
Q:如果Prog下有多个stat,那么用super visit所有的children的结果如何合并为一个结果(该结果是visitStat的返回值)。
A: visitChildren源码可以看出,多个child的结果是,后续的结果会覆盖前面的结果,也就是visitProg的返回值就是最后一个visitStat的结果。
public T visitChildren(RuleNode node) {
T result = this.defaultResult();
int n = node.getChildCount();
for(int i = 0; i < n && this.shouldVisitNextChild(node, result); ++i) {
ParseTree c = node.getChild(i);
T childResult = c.accept(this);
result = this.aggregateResult(result, childResult);
}
return result;
}
比如对于(3*4)/2 ;方法visitParens,如果方法实现是使用super.visitParens,则会返回")"。 Parens的children数组元素有三个,(、exp、);最后的“)”会覆盖前面两个。这个时候我们自定义visit的作用就体现出来了,一般对于四则运算的visitParens我们会重写如下
visit(ctx.expression());
重写的visitParens中,我们就手动指定了节点的访问。因此可以认为visitor模式就是主动遍历的模式。
接下来还需要明确下 Context的层级结构,比如
1 语法文件中的#意味着什么?和生成的Context结构如何对应?
#号的含义在上文语法文件说明部分已经解释了,没有#规则名对应一个Context,有#能以更细的分支来管理规则,#后面的别名对应着一个Context,也对应着一个访问方法。
2 大部分时候都是有children,到底什么节点下有children?
目前这个Expression的children下面没有数组,而是left 和right的属性。
因此猜测,凡是有 left = xx;有这种描述语句的时候都是没有数组的。这个时候不能使用 super.xxx进行遍历
其他细节:因为 字符串和null用+号拼接的时候,就相当于字符串 + “null”因此可能会出现 (null)的现象。有两种办法,一种在遍历括号节点的时候加判断,或者解析公式之前就加个校验,保证不出出现这种问题,
天璇V3Manager工程 物理公式解析就是前端控件进行了校验,保证了不会出现这个问题。
if (ctx.expression() != null){
return visitExpression(ctx.expression());
} else{
return Double.parseDouble(ctx.INT().getText());
}
使用 Maven
Antlr4 提供了 Maven Plugin,可以通过配置来进行编译。语法文件 g4 放置在 src/main/antlr4 目录下即可,配置依赖的 antlr4 和 plugin 即可。生成 visitor 在 plugin 配置 visitor 参数为 true 即可。
注意:antlr4 的库版本要与 plugin 版本对应,antlr4 对生成文件用的版本与库本身的版本会进行对照,不匹配会报错。
...
<properties>
<antlr4.version>4.7.2</antlr4.version>
</properties>
<dependencies>
<dependency>
<groupId>org.antlr</groupId>
<artifactId>antlr4</artifactId>
<version>${antlr4.version}</version>
</dependency>
</dependencies>
...
<build>
<plugins>
<plugin>
<groupId>org.antlr</groupId>
<artifactId>antlr4-maven-plugin</artifactId>
<version>${antlr4.version}</version>
<configuration>
<visitor>true</visitor>
</configuration>
<executions>
<execution>
<id>antlr</id>
<goals>
<goal>antlr4</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
...
规则这么多,也许一个表达式能够命中多个规则分支,最终命中哪个是什么策略决定的。以下进行说明。
【TODO】
一般的处理对象有:
SQL语句
算数表达式
编程语言的源文件
可以看出来其实编写grammar文件的过程其实就是对某种场景的抽象,定义一些基础的元素,然后Parse能够针对输入生成某种输入的树结构,然后遍历这种树结构,在遍历的过程中可以做一些处理,比如翻译。
note:
[各种语言的antlr解析] https://blog.csdn.net/xiyue_ruoxi/article/details/38925091
关于SQL的antlr解析
将查询参数解析成一个语法解析树
antlr在hibernate、presto以及SparkSQL中都是使用了的。
antlr在presto及SparkSQL中作用是相似的,都是就是生成了生成一个SQL语法解析树(一个未处理的逻辑执行计划)。
后续是对逻辑执行计划是进行了优化的。
也就是说presto和spark是从SQL到逻辑执行计划;天璇的逻辑执行计划是从查询参数构建出来,也就是手动完成了antlr的过程。
[官网 看 antlr 4 reference手册,权威 必看] http://www.antlr.org/
[visitor] https://dohkoos.gitbooks.io/antlr4-short-course/content/calculator-visitor.html
[使用语法错误异常做补全] https://www.liangshuang.name/2017/08/20/antlr/
[各种语法文件] https://github.com/antlr/grammars-v4