入门 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 版计算器

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

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

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

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

定义 Grammar

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

plaintext
// 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:

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

实现计算器程序骨架

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

cs
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 在工程根目录), 输出如下所示:

plaintext
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

plaintext
lexer grammar CommonLexerRules;

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

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

plaintext
grammar LibExpr;
import CommonLexerRules;

prog: stat+ ;

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

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

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

bash
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 (仅列出修改的部分):

plaintext
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 定义再细化, 如下所示:

plaintext
MUL :   '*' ;
DIV :   '/' ;
ADD :   '+' ;
SUB :   '-' ;

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

plaintext
// 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 方法:

cs
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:

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

Main 函数中添加 Visit 调用:

cs
// 添加 Visit 调用
EvalVisitor visitor = new EvalVisitor();
visitor.Visit(tree)

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

为自定义类 EvalVisitor 添加实现

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

cs
 /// <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, 结果如下所示:

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

193
17
9

添加 clear 功能支持

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

plaintext
// 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 的处理:

cs
/// <summary>
/// 清空临时存储.
/// </summary>
public override int VisitClear(ExprParser.ClearContext context) {
    _memory.Clear();
    return 0;
}

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

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

c = 12
clear
1 + c * 2

则输出是:

plaintext
193
17
9
1

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

Java Interface 转换

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

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

比如有一个类:

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

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

java
    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.

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

plaintext
classDeclaration
    :   'class' Identifier typeParameters? ('extends' type)?
        ('implements' typeList)?
        classBody
    ;

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

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

cs
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}");
        }
    }
}

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

plaintext
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()
}

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