ANTLR 入门篇 2

本文最后更新于 2021年4月4日 晚上

入门 ANTLR 系列文章第 2 篇, 主要介绍几个简单的例子, 用于展示 ANTLR 的大部分功能:

  1. Grammar 文件的编写入门.
  2. 使用 Visitor 模式编写计算器.
  3. 编写一个 Translator 应用, 实现从 Java Class 中抽取出 Interface.
  4. 直接在 Grammar 中内嵌代码的示例(针对 Listener 和 Visitor 无法实现的特殊能力).
  5. 其他功能, 包括 lexical 层面(token) 上的一些额外功能(比如一个文件中包含两种语言的情况).
  6. 如何做到忽略空白符, 但可以在后续处理中继续保留空白符信息的手段.

本章内容来源: The Definitive ANTLR 4 Reference.

本章所有示例均采用 C# 实现, 如何生成 C# Target 详见本系列的第一篇文章.

数学表达式计算示例: 实现 ANTLR 版计算器

前提: 为保证示例足够简单, 仅实现加减乘除符号, 括号表达式, 整数和变量的支持.

完成后, 我们的语言程序将支持如下式子的计算结果输出:

1
2
3
4
5
193
a=5
b=6
a+b*2
(1+2)*3

将这些表达式保存为一个 t.expr 文件, 之后会用到.

定义 Grammar

针对上述需求定义 Grammar 文件 Expr.g4:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// grammar 名称, 必须和文件名一致
grammar Expr;

// start rule, 从这里开始解析.
prog: stat+ ;

stat: expr NEWLINE
| ID '=' expr NEWLINE
| NEWLINE
;

expr: expr ('*'|'/') expr
| expr ('+'|'-') expr
| INT
| ID
| '(' expr ')'
;

ID : [a-zA-Z]+ ;
INT : [0-9]+ ;
NEWLINE: '\r'? '\n' ;
WS : [ \t]+ -> skip ;

Grammar 由一系列 rule 构成, 这些 rule 用于描述 Language Syntax(即待处理语言的语法).

Grammar rule 包括两种:

  • Parser rule: 使用小写字母开头
  • Lexical(token) rule 使用大写字母开头

在 Parser rule 的定义中, 使用 |(逻辑或) 分隔该 rule 中的不同表现形式. 同时我们可以使用括号来包裹 subrule, 比如上面的 ('*'|'/'), 它用于匹配乘号或除号.

左递归(Left-Recursive) rule:

指定义 grammar 规则的时候可以在规则定义位置引用它本身, 比如上面 expr 的第一个定义和第二个定义.

左递归的作用非常明显, 使用它就不需要对每条规则都按上下顺序书写了, 比如上面的 expr rule 定义.

在 lexical rule 定义中, 写法就和正则表达式比较类似了.

通过上面定义的 Expr.g4 Grammar 文件, 执行 antlr4 Expr.g4 进行生成操作.

然后使用 grun Expr prog -gui t.expr 测试是否正常, 结果如下图所示:

使用 grammar 文件, 生成一套 C# 版本的程序, 过程可以参考 C# target 生成一节, 只是这里生成代码时需要加上 -visitor 选项, 并且不需要 listener, 所以又添加了 -no-listener:

1
antlr4 -Dlanguage=CSharp -visitor -no-listener Expr.g4

实现计算器程序骨架

下面我们可以实现一个 C# 版本的入口程序(其他语言也类似):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
using System.IO;
using Antlr4.Runtime;

namespace C4CalculatorCSharp
{
class Program {
static void Main(string[] args) {
string inputFile = "";
if (args.Length > 0) inputFile = args[0];
var txt = File.ReadAllText(inputFile);
System.Console.WriteLine(txt);
AntlrInputStream inputStream = new AntlrInputStream(txt);
ExprLexer lexer = new ExprLexer(inputStream);
CommonTokenStream tokenStream = new CommonTokenStream(lexer);
ExprParser parser = new ExprParser(tokenStream);
var tree = parser.prog();
var result = tree.ToStringTree(parser);
System.Console.WriteLine(result);
}
}
}

当执行 dotnet run ./t.expr 后(t.expr 在工程根目录), 输出如下所示:

1
2
3
4
5
6
7
193
a=5
b=6
a+b*2
(1+2)*3

(prog (stat (expr 193) \n) (stat a = (expr 5) \n) (stat b = (expr 6) \n) (stat (expr (expr a) + (expr (expr b) * (expr 2))) \n) (stat (expr (expr ( (expr (expr 1) + (expr 2)) )) * (expr 3)) \n))

Grammar 文件中的 import: 分割大型的 Grammar 文件到不同的 module (可选)

在某些场景下, 可能 Grammar 文件很大, 为了便于管理, 可以把 Grammar 的定义分割到多个独立文件中, 再通过 import 将它们组合起来.

之前的 Grammar 文件可以按下面这样分割:

lexical rule 定义文件: CommonLexerRules.g4

1
2
3
4
5
6
lexer grammar CommonLexerRules;

ID : [a-zA-Z]+ ;
INT : [0-9]+ ;
NEWLINE: '\r'? '\n' ;
WS : [ \t]+ -> skip ;

现在新的 Expr.g4 文件内容就可以是这样(为了和之前的文件区分, 这里将它命名为 LibExpr.g4):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
grammar LibExpr;
import CommonLexerRules;

prog: stat+ ;

stat: expr NEWLINE
| ID '=' expr NEWLINE
| NEWLINE
;

expr: expr ('*'|'/') expr
| expr ('+'|'-') expr
| INT
| ID
| '(' expr ')'
;

只需要保证它们在同一个目录下, 再运行生成命令:

1
antlr4 -Dlanguage=CSharp -visitor -no-listener LibExpr.g4

可以看到和之前的 Expr.g4 文件是同样效果.

目前仅介绍 grammar 文件的 module 分割和 import 的用法, 下面的例子中不准备分割这个文件, 因为它还比较小.

修改 Grammar 定义: 添加 Label 以支持生成多个 Visitor 入口

默认情况下, ANTLR 只会为每一个 rule 生成一个 Visitor, 而我们的语法文件中实际是在一个 rule 中通过 | 符号进行分割并定义了该 rule 的多个不同表现形式, 为了针对不同的表现形式生成不同的 Visitor, 我们就需要在 Grammar 中为每个表现形式声明 Label.

Label 声明使用 # 符号, 将 Expr.g4 文件中 parser rule 部分添加 Label, 并封装一些 subrule (仅列出修改的部分):

1
2
3
4
5
6
7
8
9
10
11
stat:   expr NEWLINE            # printExpr
| ID '=' expr NEWLINE # assign
| NEWLINE # blank
;

expr: expr op=('*'|'/') expr # MulDiv
| expr op=('+'|'-') expr # AddSub
| INT # int
| ID # id
| '(' expr ')' # parens
;

另外可以将 token 定义再细化, 如下所示:

1
2
3
4
MUL :   '*' ;
DIV : '/' ;
ADD : '+' ;
SUB : '-' ;

修改后的完整 Expr.g4 Grammar 文件如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// grammar 名称, 必须和文件名一致
grammar Expr;

// start rule, 从这里开始解析.
prog: stat+ ;

stat: expr NEWLINE # printExpr
| ID '=' expr NEWLINE # assign
| NEWLINE # blank
;

expr: expr op=('*'|'/') expr # MulDiv
| expr op=('+'|'-') expr # AddSub
| INT # int
| ID # id
| '(' expr ')' # parens
;

MUL : '*' ;
DIV : '/' ;
ADD : '+' ;
SUB : '-' ;
ID : [a-zA-Z]+ ;
INT : [0-9]+ ;
NEWLINE: '\r'? '\n' ;
WS : [ \t]+ -> skip ;

执行 antlr4 -Dlanguage=CSharp -visitor -no-listener Expr.g4 重新生成相关文件, 可以看到在生成的 ExprBaseVisitor 类中, 包含了所有 Label 对应的 visit 方法:

1
2
3
4
5
6
7
8
9
10
11
public partial class ExprBaseVisitor<Result> : AbstractParseTreeVisitor<Result>, IExprVisitor<Result> {
public virtual Result VisitProg([NotNull] ExprParser.ProgContext context) { return VisitChildren(context); }
public virtual Result VisitPrintExpr([NotNull] ExprParser.PrintExprContext context) { return VisitChildren(context); }
public virtual Result VisitAssign([NotNull] ExprParser.AssignContext context) { return VisitChildren(context); }
public virtual Result VisitBlank([NotNull] ExprParser.BlankContext context) { return VisitChildren(context); }
public virtual Result VisitParens([NotNull] ExprParser.ParensContext context) { return VisitChildren(context); }
public virtual Result VisitMulDiv([NotNull] ExprParser.MulDivContext context) { return VisitChildren(context); }
public virtual Result VisitAddSub([NotNull] ExprParser.AddSubContext context) { return VisitChildren(context); }
public virtual Result VisitId([NotNull] ExprParser.IdContext context) { return VisitChildren(context); }
public virtual Result VisitInt([NotNull] ExprParser.IntContext context) { return VisitChildren(context); }
}

继承 ExprBaseVisitor 类来实现 EvalVisitor:

1
2
3
4
5
6
7
8
namespace C4CalculatorCSharp.Visitors {
/// <summary>
/// 自定义的 Visitor, 计算器目前输出的肯定是整数, 所以暂时规定泛型参数为 int.
/// </summary>
public class EvalVisitor : ExprBaseVisitor<int> {
// TODO: 待实现
}
}

Main 函数中添加 Visit 调用:

1
2
3
// 添加 Visit 调用
EvalVisitor visitor = new EvalVisitor();
visitor.Visit(tree)

现在整个程序就准备完成了, 下一步来看我们自定义的 EvalVisitor 内部具体应该怎么实现.

为自定义类 EvalVisitor 添加实现

如下是整体实现, 具体说明详见注释:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
 /// <summary>
/// 自定义的 Visitor, 计算器目前输出的肯定是整数, 所以暂时规定泛型参数为 int.
/// 这个参数也代表所有的 visit 方法返回值类型为 int.
/// </summary>
public class EvalVisitor : ExprBaseVisitor<int> {
// 为计算器准备一个 "内存", 用于存放 assign 的值对应, 比如 a = 3
readonly Dictionary<string, int> _memory = new Dictionary<string, int>();

/// <summary>
/// 对应语法文件中 ID '=' expr NEWLINE 这个规则的处理, 即 a = 3 这类赋值语句
/// </summary>
public override int VisitAssign([NotNull] ExprParser.AssignContext context) {
// 获取在等号左边的字符串
var id = context.ID().GetText();
// 计算等号右边的值, 因为等号右边可能是 expr, 调用 Visit 的目的是递归计算右边表达式的值
int value = Visit(context.expr());
_memory[id] = value; // 如果没有就添加, 如果有就更新
return value;
}

/// <summary>
/// 对应的是 stat 中 expr NEWLINE 这条 rule
/// </summary>
public override int VisitPrintExpr([NotNull] ExprParser.PrintExprContext context) {
var value = Visit(context.expr()); // 递归计算这个 expr 的值
// 直接将值打印出来( expr )
System.Console.WriteLine(value);
return 0;
}

/// <summary>
/// 对应直接是 INT 的 expr, 它的值可以直接取得.
/// </summary>
public override int VisitInt([NotNull] ExprParser.IntContext context) {
return int.Parse(context.INT().GetText());
}

/// <summary>
/// 对应遍历到 ID 的情况, 返回字典中的值即可, 如果没有, 则返回 0
/// </summary>
public override int VisitId(ExprParser.IdContext context) {
return _memory.GetValueOrDefault(context.ID().GetText());
}

/// <summary>
/// 对应 expr op=('*'|'/') expr
/// </summary>
public override int VisitMulDiv([NotNull] ExprParser.MulDivContext context) {
// 获取左操作数的值和右操作数, 然后根据操作符进行计算
// 这里获取操作数的时候通过 0,1 这样的序号来获取
var leftVal = Visit(context.expr(0));
var rightVal = Visit(context.expr(1));

var result = context.op.Type == ExprParser.MUL ? leftVal * rightVal : leftVal / rightVal;
return result;
}

public override int VisitAddSub([NotNull] ExprParser.AddSubContext context) {
var leftVal = Visit(context.expr(0));
var rightVal = Visit(context.expr(1));
var result = context.op.Type == ExprParser.ADD ? leftVal + rightVal : leftVal - rightVal;
return result;
}

/// <summary>
/// 括号中是表达式的情况, 同样返回 expr 的递归计算结果即可.
/// </summary>
/// <param name="context"></param>
/// <returns></returns>
public override int VisitParens([NotNull] ExprParser.ParensContext context) {
return Visit(context.expr());
}
}

再次执行 dotnet run ./t.expr, 结果如下所示:

1
2
3
4
5
6
7
8
9
193
a=5
b=6
a+b*2
(1+2)*3

193
17
9

添加 clear 功能支持

clear 功能实际就是把自定义 visitor 中临时存储清空, 修改 grammar 定义, 添加 clear token, 并将这个 token 作为 statement:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// grammar 名称, 必须和文件名一致
grammar Expr;

// start rule, 从这里开始解析.
prog: stat+ ;

stat: expr NEWLINE # printExpr
| ID '=' expr NEWLINE # assign
| CLEAR # clear
| NEWLINE # blank
;

expr: expr op=('*'|'/') expr # MulDiv
| expr op=('+'|'-') expr # AddSub
| INT # int
| ID # id
| '(' expr ')' # parens
;

CLEAR: 'clear' ;
MUL : '*' ;
DIV : '/' ;
ADD : '+' ;
SUB : '-' ;
ID : [a-zA-Z]+ ;
INT : [0-9]+ ;
NEWLINE: '\r'? '\n' ;
WS : [ \t]+ -> skip ;

重新生成文件: antlr4 -Dlanguage=CSharp -visitor -no-listener Expr.g4

在 visitor 中添加 clear 的处理:

1
2
3
4
5
6
7
/// <summary>
/// 清空临时存储.
/// </summary>
public override int VisitClear(ExprParser.ClearContext context) {
_memory.Clear();
return 0;
}

将输入修改为如下(注意末尾的回车):

1
2
3
4
5
6
7
8
9
10
193
a=5
b=6
a+b*2
(1+2)*3

c = 12
clear
1 + c * 2

则输出是:

1
2
3
4
193
17
9
1

可以看到, 输出符合预期, Clear 功能实现完成.

Java Interface 转换

下面这个例子是将 Java class 转换为 interface 的例子.

假设需求是想把 Java 类转换为接口, 一般来说可以使用 java 的反射, javap, 或者是字节码库(比如 ASM 等), 但如果需要在转换的时候保留空白符号, 并保留类和方法的注释, 那这些方法就都不可用了, 只有一条路, 就是解析 Java 源码.

比如有一个类:

1
2
3
4
5
public class Demo {
void f(int x, String y) { }
int[ ] g(/*no args*/) { return null; }
List<Map<String, Integer>>[] h() { return null; }
}

我们需要转换为如下接口:

1
2
3
4
5
    interface IDemo {
void f(int x, String y);
int[ ] g(/*no args*/);
List<Map<String, Integer>>[] h();
}

我们仍然是先写 grammar 文件, 书中已为我们提供了这个语法定义文件, 在这个链接, 在 tour 目录中找到的 Java.g4, 利用这个文件生成相关代码: antlr4 -Dlanguage=CSharp Java.g4.

在语法文件中, 有两段是我们需要用到的:

1
2
3
4
5
6
7
8
9
10
classDeclaration
: 'class' Identifier typeParameters? ('extends' type)?
('implements' typeList)?
classBody
;

methodDeclaration
: type Identifier formalParameters ('[' ']')* methodDeclarationRest
| 'void' Identifier formalParameters methodDeclarationRest
;

生成文件后, 只需要继承实现 Listener, 如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
namespace C4JavaInterfaceTranslator.Listeners {
/// <summary>
/// 用于将 Java 类转换为接口的 Listener
/// </summary>
public class ExtractInterfaceListener : JavaBaseListener {
private readonly JavaParser parser;

public ExtractInterfaceListener(JavaParser parser) {
this.parser = parser;
}

/// <summary>
/// 在进入 class 定义时, 打印对应接口的 header(仅示例, 没有考虑泛型参数等许多情况)
/// </summary>
public override void EnterClassDeclaration(JavaParser.ClassDeclarationContext context) {
System.Console.WriteLine($"interface I{context.Identifier()} {{");
}

/// <summary>
/// 退出类定义的时候, 添加末尾的大括号
/// </summary>
public override void ExitClassDeclaration(JavaParser.ClassDeclarationContext context) {
System.Console.WriteLine("}");
}

/// <summary>
/// 进入方法声明时的处理
/// </summary>
public override void EnterMethodDeclaration(JavaParser.MethodDeclarationContext context) {
// 仅示例, 实际处理比这个复杂
var tokens = parser.TokenStream;
string returnType = "void";
if (context.type() != null) {
returnType = tokens.GetText(context.type());
}
var args = tokens.GetText(context.formalParameters());
System.Console.WriteLine($"\t{returnType} {context.Identifier()}{args}");
}
}
}

运行后, 可以看到正常输出:

1
2
3
4
5
6
7
8
9
10
public class Demo<T> {
void f(int x, String y) { }
int[ ] g(/*no args*/) { return null; }
List<Map<String, Integer>>[] h() { return null; }
}
interface IDemo {
void f(int x, String y)
int[ ] g(/*no args*/)
List<Map<String, Integer>>[] h()
}

不过要注意的是, 由于语法文件中有时会内嵌相关的处理代码, 如果和当前使用语言有冲突, 就需要修改语法定义文件, 或处理生成之后的文件.


ANTLR 入门篇 2
https://blog.rayy.top/2020/06/21/2020-06-21-antlr-c4-prjs/
作者
貘鸣
发布于
2020年6月21日
许可协议