前言
最近项目来了一个新需求,要求根据用户输入的表达式进行计算,除了需要支持一般的数学运算,还需要支持在表达式中调用一些预置的函数。为了实现这个需求,特意把编译原理一书拿出来翻了一遍,这些知识对理解接下来的文章很有帮助。我们的程序要能够理解用户的输入,就必须涉及到语法解析了,于是ANTLR进入了我们的视野。
简介
ANTLR(ANother Tool for Language Recognition)是一个强大的解析器生成器,用于读取、处理、执行或翻译结构化文本或二进制文件。 它被广泛用于构建语言、工具和框架。ANTLR 根据语法定义生成解析器,解析器可以构建和遍历解析树。
所有编程语言的语法,都可以用ANTLR来定义。ANTLR提供了大量的官方 grammar 示例,包含了各种常见语言,比如Java、SQL、Javascript、PHP等等。
主要用户
- Twitter搜索使用ANTLR进行语法分析,每天处理超过20亿次查询;
- Hadoop生态系统中的Hive、Pig、数据仓库和分析系统所使用的语言都用到了ANTLR;
- Lex Machina将ANTLR用于分析法律文本;Oracle公司在SQL开发者IDE和迁移工具中使用了ANTLR;
- NetBeans公司的IDE使用ANTLR来解析C++;
- Hibernate对象-关系映射框架(ORM)使用ANTLR来处理HQL语言
- 其他还有Oracle、Presto、Elasticsearch、Spark
基本概念
- 词法分析器 (Lexer)
词法分析
是指在计算机科学中,将字符序列
转换为单词(Token
)的过程。词法分析器(Lexer)
一般是用来供语法解析器(Parser)
调用的。 - 语法解析器 (Parser) 语法解析器通常作为
编译器
或解释器
出现。它的作用是进行语法检查,并构建由输入单词(Token
)组成的数据结构(即抽象语法树)。语法解析器通常使用词法分析器(Lexer)
从输入字符流中分离出一个个的单词(Token
),并将单词(Token
)流作为其输入。实际开发中,语法解析器可以手工编写,也可以使用工具自动生成。 - 抽象语法树 (Abstract Syntax Tree,AST) 抽象语法树是源代码结构的一种抽象表示,它以树的形状表示语言的语法结构。抽象语法树一般可以用来进行
代码语法的检查
,代码风格的检查
,代码的格式化
,代码的高亮
,代码的错误提示
以及代码的自动补全
等等。
其他的语法解析器
JavaCC
JavaCC,即Java Cmopiler Compiler,为了简化基于Java语言的词法分析器或者语法分析器的开发,Sun公司的开发人员开发了JavaCC(Java Compiler Compiler)。JavaCC是一个基于Java语言的分析器的自动生成器。用户只要按照JavaCC的语法规范编写JavaCC的源文件,然后使用JavaCC的编译器编译,就能够生成基于Java语言的某种特定语言的分析器。JavaCC已经成为最受欢迎的Java解析器创建工具。
YACC
YACC(Yet Another Compiler-Compiler): 1975 年由贝尔实验室 Mike Lesk & Eric Schmidt 开发,UNIX 标准实用工具 (utility)。
使用方法
安装插件
最简单的方法就是使用IntelliJ IDEA作为开发工具,在Preference->Plugins中搜索antlr
然后安装即可。
定义语法和词法
需要创建一个.g4
文件,用于定义词法分析器(lexer)和语法解析器(Parser)。具体语法参见官方文档。下面是一个简单的例子:Hello.g4
。
// file Hello.g4
// Define a grammar called Hello
grammar Hello; // 1. grammer name
@header { package pers.me.expression.parser; } // 2. java package
r : 'hello' ID ; // 3. match keyword hello followed by an identifier
ID : [a-z]+ ; // match lower-case identifiers
WS : [ \t\r\n]+ -> skip ; // 4. skip spaces, tabs, newlines
- 定义了 grammar 的名字,名字需要与文件名对应
- 定义生成的Java类的package
r
定义的语法,会使用到下方定义的正则表达式词法- 定义了空白字符,后面的 skip 是一个特殊的标记,标记空白字符会被忽略
生成代码
在IDEA中右键点击.g4
文件,选择Generate ANTLR Recognizer
,插件会自动在gen
目录下生成一堆Java代码,我们需要移动到对应的package中。如果定义了@header
,IDEA也会自动生成package信息。
测试
我们可以利用下面这段代码来测试一下ParseTree。
public class HelloTest {
public static void main(String[] args) throws Exception {
HelloLexer lexer = new HelloLexer(CharStreams.fromString("hello parrt"));
CommonTokenStream tokens = new CommonTokenStream(lexer);
HelloParser parser = new HelloParser(tokens);
ParseTree tree = parser.r();
System.out.println(tree.toStringTree(parser));
}
}
运行上面的代码可以得到如下输出,程序识别出输入的字符串符合r
的语法。
(r hello parrt)
Listener和Visitor
ANTLR提供了两种方法来访问ParseTree:
- 一种是通过Parse-Tree Listener的方法
- 另一种是通过Parse-Tree Visitor的方法
Listener有以下特点:
- 访问AST的所有节点
- 重写(Override)进入时(enterXXX方法)和退出时(exitXXX方法)要执行的方法
- 要重写的方法没有返回值,因此需要在属性中保留所需的值
Visitor有以下特点:
- 并非所有 AST 节点都被访问
- 根据目的重写进入节点时要执行的过程(visitXXX方法)
- 重写方法有一个返回值,因此您不必在属性中保存所需的值
最大的区别是Listener会自动访问 AST 的所有节点,而Visitor如果要访问当前节点的子树,则需要手工实现。
Visitor 较为简单方便,继承 HelloBaseVisitor 类即可,内部的方法与 g4 文件定义相对应,对照看即可理解。实现了 visitor 之后,就可以完成一个简单的自定义解析器了。自动生成的HelloBaseVisitor.java
如下:
// Generated from Hello.g4 by ANTLR 4.9.1
import org.antlr.v4.runtime.tree.AbstractParseTreeVisitor;
/**
* This class provides an empty implementation of {@link HelloVisitor},
* which can be extended to create a visitor which only needs to handle a subset
* of the available methods.
*
* @param <T> The return type of the visit operation. Use {@link Void} for
* operations with no return type.
*/
public class HelloBaseVisitor<T> extends AbstractParseTreeVisitor<T> implements HelloVisitor<T> {
/**
* {@inheritDoc}
*
* <p>The default implementation returns the result of calling
* {@link #visitChildren} on {@code ctx}.</p>
*/
@Override public T visitR(HelloParser.RContext ctx) { return visitChildren(ctx); }
}
实践
接下来,我们来实现一个简单的计算器。
定义语法和词法
创建Expression.g4
。
// 定义了 grammar 的名字,名字需要与文件名对应
grammar Expression;
@header {
// 定义package
package pers.me.expression.parser;
}
/**
* parser
* calc 和 expr 就是定义的语法,会使用到下方定义的词法
* 注意 # 后面的名字,是可以在后续访问和处理的时候使用的。
* 一个语法有多种规则的时候可以使用 | 来进行配置。
*/
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
;
/**
* lexer
*/
BR_OPEN: '(';
BR_CLOSE: ')';
PLUS: '+';
MINUS: '-';
TIMES: '*';
DIVIDE: '/';
PERCENT: '%';
POINT: '.';
// 定义百分数
PERCENT_NUMBER
: NUMBER ((' ')? PERCENT)
;
NUMBER
: DIGIT+ ( POINT DIGIT+ )?
;
DIGIT
: [0-9]+
;
// 定义了空白字符,后面的 skip 是一个特殊的标记,标记空白字符会被忽略
SPACE
: ' ' -> skip
;
实现Visitor
生成Java文件之后,我们来实现自己的Visitor,用于支持BigDecimal。
public class BigDecimalCalculationVisitor extends ExpressionBaseVisitor<BigDecimal> {
/**
* 100
*/
private static final BigDecimal HUNDRED = BigDecimal.valueOf(100);
/**
* DECIMAL128のMathContext (桁数34、RoundingMode.HALF_EVEN)
*/
private static final MathContext MATH_CONTEXT = MathContext.DECIMAL128;
@Override
public BigDecimal visitCalculationBlock(ExpressionParser.CalculationBlockContext ctx) {
BigDecimal calcResult = null;
for (ExpressionParser.ExprContext expr : ctx.expr()) {
calcResult = visit(expr);
}
return calcResult;
}
@Override
public BigDecimal visitExpressionTimesOrDivide(ExpressionParser.ExpressionTimesOrDivideContext ctx) {
BigDecimal left = visit(ctx.expr(0));
BigDecimal right = visit(ctx.expr(1));
switch (ctx.op.getType()) {
case ExpressionLexer.TIMES: return left.multiply(right, MATH_CONTEXT);
case ExpressionLexer.DIVIDE: return left.divide(right, MATH_CONTEXT);
default: throw new RuntimeException("unsupported operator type");
}
}
@Override
public BigDecimal visitExpressionPlusOrMinus(ExpressionParser.ExpressionPlusOrMinusContext ctx) {
// 此处加减法的实现类似上面的乘除法,省略
}
@Override
public BigDecimal visitExpressionWithBr(ExpressionParser.ExpressionWithBrContext ctx) {
return visit(ctx.expr());
}
@Override
public BigDecimal visitExpressionNumeric(ExpressionParser.ExpressionNumericContext ctx) {
BigDecimal numeric = numberOrPercent(ctx.num);
if (Objects.nonNull(ctx.sign) && ctx.sign.getType() == ExpressionLexer.MINUS) {
return numeric.negate();
}
return numeric;
}
private BigDecimal numberOrPercent(Token num) {
String numberStr = num.getText();
switch (num.getType()) {
case ExpressionLexer.NUMBER: return decimal(numberStr);
case ExpressionLexer.PERCENT_NUMBER: return decimal(numberStr.substring(0, numberStr.length() - 1).trim()).divide(HUNDRED, MATH_CONTEXT);
default: throw new RuntimeException("unsupported number type");
}
}
private BigDecimal decimal(String decimalStr) {
return new BigDecimal(decimalStr);
}
}
调用解析器
在Calculator类中调用Expression。
public class Calculator {
public BigDecimal execute(String expression) {
CharStream cs = CharStreams.fromString(expression);
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);
}
}
测试
最后,我们用一个jUnit来测试一下我们的计算器。
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());
}
}