基于ANTLR实现语法自动分析与转换

2,059 阅读9分钟

ANTLR是什么

ANTLR是一款强大的语法分析器生成工具,可用于读取、处理、执行和翻译结构化的文本或二进制文件。

如果要求开发人员自己编写一个编译期前端,那无疑对开发者的理论基础、技术功底的要求都非常高。但是,有了ANTLR,一切都变得易如反掌。开发人员需要做的只是定义一份描述该语言的语法文件,然后ANTLR会自动生成词法分析器和语法分析器,并把任意输入文本处理为可视化的语法树。你还可以访问语法树中的任意节点,实现自定义的业务逻辑。

它被广泛应用于学术领域和工业生产实践,是众多语言、工具和框架的基石。Twitter搜索使用ANTLR进行语法分析,每天处理超过20亿次查询;Hadoop生态系统中的Hive、Pig、数据仓库和分析系统所使用的语言都用到了ANTLR;Oracle公司在SQL开发者IDE和迁移工具中使用了ANTLR;NetBeans公司的IDE使用ANTLR来解析C++...

编译器(compiler):所有在计算机上运行的软件都是由某种程序设计语言编写的,常见的如C++语言、Java语言、SQL语言等。但是,在一个程序可以执行之前,它都需要被翻译成一种能够被计算机执行的形式(机器码)。完成这项翻译工作的软件系统被称为编译器(compiler)。

相关名词定义

语言的组成与分解

  • 语言(language)由一系列有意义的语句组成
  • 语句(sentence)由词组组成
  • 词组(phrase)是由更小的子词组(subphrase)和词汇符号(vocabulary symbol)组成。
  • 解释器(interpreter):一般来说,如果一个程序能够分析计算或者“执行”语句,我们就称之为解释器(interpreter)。这样的例子包括计算器、读取配置文件的程序和Python解释器。
  • 翻译器(translator):如果一个程序能够将一门语言的语句转换为另外一门语言的语句,我们称之为翻译器(translator)。例如 Java到C#的转换器和普通的编译器。

词法分析器

  • 词法符号(token):词法符号包含至少两部分信息:词法符号的类型(如INT、FLOAT等)和该词法符号对应的文本。
  • 词法分析(lexical analysis):将字符聚集为词法符号(token)的过程称为词法分析或者词法符号化(tokenizing)。
  • 词法分析器(lexer):我们把可以将输入文本转换为词法符号的程序称为词法分析器。词法分析器可以将相关的词法符号归类,例如INT(整数)、ID(标识符)、FLOAT(浮点数)等。

语法分析器

  • 语法分析器(parser):识别语言的程序称为语法分析器或者句法分析器(syntax analyzer)。
  • 句法(syntax)是指约束语言中的各个组成部分之间关系的规则。我们会通过ANTLR语法来指定语言的句法。
  • 语法(grammar)是一系列规则的集合,每条规则表示一种词汇结构。

项目实践

目标

使用ANTLR识别包裹在花括号或者嵌套的花括号中的一些整数,像{1,2,3}和{1,{2,3},4}这样。这样的结构可以作为int数组或者C语言中的结构体的初始化语句

设计语法

首先需要定义目标语言的词法规则和语法规则。词法规则以大写字母开头,语法规则以小写字母开头。

词法规则定义

ANTLR支持正则表达式中的表达法。常见的词法结构如下

  • 匹配字母

ID: ('a'..'z' | 'A'..'Z')+ ; //匹配1个或多个大小写字母

或者

ID: [a-zA-Z]+ ; //匹配1个或多个大小写字母

  • 匹配数字:

INT:[0-9]+;

  • 匹配浮点数:

FLOAT:DIGIT+'.'DIGIT*;//匹配1.123

fragment

DIGIT:[0-9] ;//匹配单个数字

fragment表示该规则不是词法符号,只是会被其他的词法规则使用。也就是说在语法规则中无法使用

  • 匹配注释和空白字符

当词法分析器匹配到注释和空白字符的时候,我们通常希望将它们丢弃。否则文法规则就变成了一团乱麻,且十分容易出错。

WS: [ \t\n\r]+ -> skip; //匹配换行或空格

COMMENT_INPUT: '/' .? '/' -> skip; //匹配“/”或“*/”

语法规则定义

grammar ArrayInit;

//一条名为 init 的规则,它匹配一对花括号中的、逗号分隔的value 
init : '{' value (','value)*'}';//必须匹配至少一个 value

//一个value 可以是嵌套的花括号结构,也可以是一个简单的整数,即INT词法符号
value : init
			|INT
    	;

INT: [0-9]+ ;           // 定义词法符号 INT,它由一个或多个数字组成 
WS: [\t\r\n]+ -> skip;  //定义词法规则“空白符号”,丢弃

生成语法分析树

第二个阶段是实际的语法分析过程:在这个过程中,输入的词法符号被“消费”以识别语句结构,在上例中即为赋值语句。

默认情况下,ANTLR生成的语法分析器会建造一种名为语法分析树parse tree)或者句法树syntax tree)的数据结构, 该数据结构记录了语法分析器识别出输入语句结构的过程,以及该结构的各组成部分。下图展示了数据在一个语言类应用程序中的基本流动过程。

  • 根节点:根节点是最抽象的一个名字,在本例中即stat(statement的简写)。
  • 内部节点:语法分析树的内部节点是词组名,这些名字用于识别它们的子节点,并将子节点归类。
  • 叶子节点:语法分析树的叶子节点永远是输入的词法符号。
  • 句子:也即符号的线性组合,本质上是语法分析树在人脑中的串行化

主程序实现

ANTLR的运行库提供了两种遍历语法分析树的机制,一种是通过监听器访问,一种是通过访问器访问。

通过监听器实现

生成语法分析树后,会对树进行遍历。ANTLR为每条规则都定义了监听器,并且提供了enterXXX和exitXXX的方法,分别对应进入某条规则和退出某条规则的动作,并且会将当前对应的语法分析树节点,也就是XXXContext的实例当作参数传递给它。

将上述词法文件和语法文件放入单独文件夹,然后运行antlr工具,会自动生成很多文件,如下图所示。

文件ArrayInitListener类给出了一些回调方法的定义,我们可以实现它来完成自定义的功能。 ArrayInitBaseListener是该接口的默认实现类,为其中的enter方法和exit方法提供了一个空实现。实现时只需要继承ArrayInitBaseListener类并覆盖需要的方法。下面是一个监听器的实现类。

//将类似{1,2,3}的short 数组初始化语句翻译为 "\u0001\u0002\u0003"

public class ShortToUnicodestring extends ArrayInitBaselistener {
	//将{翻译为"
	@override
	public void enterInit (ArrayInitParser. Initcontext ctx) {
		System.out.print('"');
  	}

    //将}翻译为"
    @override
    public void exitInit (Array InitParser. Initcontext ctx) { 
    	System.out.print('"');
    }
    
    //将每个整数翻译为四位的十六进制形式,然后加前缀\U
    @override
    public void enterValue (ArrayInitParser.Valuecontext ctx) {
    	//假定不存在嵌套结构
    	int value = Integer.valueOf(ctx.INT().getText());
    	System.out.printf(“\ug04x", value);
    }
}

主程序

//导入 ANTLR 运行库
import org.antlr.v4.runtime.*;
import org.antlr.v4 .runtime .tree .*;

public class Translate {
	public static void main(String[J args) throws Exception {
		//新建一个 Charstream,从标淮输入读取数据
        ANTLRInputStream input = new ANTLRInputStream (System.in);
        
        // 新建一个词法分析器,处理输入的 Charstream
        ArrayInitlexer lexer = new ArrayInitLexer (input);
        
        //新建一个词法符号的缓冲区,用于存储词法分析器将生成的词法符号 
    	CommonTokenstream tokens = new CommonTokenstream (lexer);
        
        //新建一个语法分析器,处理词法符号缓冲区中的内容
        ArrayInitParser parser = new ArrayInitparser(tokens);
        ParseTree tree = parser.init();//针对 init规则,开始语法分析
        
        //新建一个通用的、能够触发回调函数的语法分析树遍历器 
    	ParseTreewalker walker = new ParseTreewalker();
        
        //遍历语法分析过程中生成的语法分析树,触发回调 
    	walker.walk (new ShortTounicodestring (), tree);
        System.out.printin();// 翻译完成后,打印一个\n
    }
}

通过访问器实现

有时候,我们希望控制遍历语法分析树的过程,通过显式的方法调用来访问子节点。下图是使用常见的访问者模式对我们的语法分析树进行操作的过程。

其中,粗虚线显示了对语法分析树进行深度优先遍历的过程。细虚线标示出访问器方法的调用顺序。我们可以在自己的程序代码中实现这个访问器接口,然后调用 visit()方法来开始对语法分析树的一次遍历。

ParseTree tree = . . . ; // tree 是语法分析得到的结果 
MyVisitor v = new MyVisitor();
v.visit(tree);

ANTLR内部为访问者模式提供的支持代码会在根节点处调用visitStat()方法。接下来,visitStat()方法的实现将会调用visit()方法,并将所有子节点当作参数传递给它,从而继续遍历的过程。或者,visitMethod()方法可以显式调用 visitAssign()方法等。

在命令行中加入-visitor选项可以指示ANTLR为一个语法生成访问器接口 (visitor interface),语法中的每条规则对应接口中的一个visit方法。ANTLR会提供访问器接口和一个默认实现类,免去我们一切都要自行实现的麻烦。这样,我们就可以专注于那些我们感兴趣的方法,而无须覆盖接口中的方法

ANTLR语法和正则表达式的区别

熟悉正则表达式的读者可能会产生疑问,使用ANTLR来解决这么简单的识别问题是不是有点小题大做了?

实际上,由于嵌套的括号结构的存在,正则表达式无法识别这样的初始化语句。正则表达式没有存储的概念,它们无法记住之前匹配过的输入。因此,它们不能将左右括号正确配对,尤其是多层嵌套括号匹配

参考资料

www.antlr.org/

《ANTLR 4 权威指南》Terence Parr著,机械工业出版社

《编译原理》第二版,Alfred V.Aho等著,机械工业出版社