在上一章中(Antlr4入门(一)IDEA中Antlr的安装与使用),我们安装了Antlr,并编写运行了第一个程序“Hello world”。而在本章中,我们将学习语言类应用程序相关的重要过程、术语和数据结构。
一. 术语
语言(language)是由一系列有意义的语句组成,语句(sentence)由词组组成,词组(phrase)是由更小的子词组(subphrase)和词汇符号(vocabulary symbols
)组成。
举个例子,英语就是一种语言,“Keep on going never give up.”这句话就是语句,语句是由词组(keep on、give up等)组成,而词组又是由词汇符号(即单词)组成。
解释器(interpreter)是指能够分析计算或者“执行”语句的程序,比如计算器、读取配置文件的程序、python解释器等。
翻译器(translator)是指能够将一门语言的语句转换成另外一门语言的语句的程序,比如普通的编译器和Java到C#的转换器。
现在,想象下我们的大脑是怎么样分析理解 “Keep on going never give up.” 这句话的,答对了,我们并不是一个字符一个字符的去阅读一个句子,而是将句子看作是一系列的单词的集合,我们在识别整个句子的语法结构之前,大脑会先将字符聚集成单词,然后获取每个单词的意义。
将字符聚集成单词或者词汇符号(token)的过程称为词法分析(lexical analysis)或者词法符号化(tokenizing)。把可以将输入文本转换成词法符号(token)的程序称为词法分析器(lexer)。词法符号包含至少两部分信息:词法符号的类型(从而能够通过类型来识别词法结构)和该词法符号对应的文本。
当我们将字符聚集成单词后,大脑就会根据语法规则(比如主谓宾,时态等)去分析语句。
语法(grammar)是一系列规则的集合,每条规则表述出一种词汇结构。而句法(syntax)是指约束语言中的各个组成部分之间关系的规则。能够识别语言的程序称为语法分析器(parser)或者句法分析器(syntax analysis)。而antlr在进行语法分析的时候,还会生成一种名为语法分析树(parse tree)或者句法树(syntax tree)的数据结构,用来记录语法分析器识别出输入语句结构的过程,以及该结构的各组成部分。
二、Antlr语法识别过程
下面我们将通过编写一个简单 “赋值语句” 语法来深入了解Antlr是如何进行语法分析的。
1. 按照上一章内容新建一个Maven项目。如下是我们 “赋值语句” 的语法文件
// 语法文件通常以grammar关键字开头,并且与文件名同名
grammar Assign;
stat : assign;
// 一条名为assign的规则
assign : ID '=' expr ';' ;
// 语法分析器的规则必须以小写字母开头
// 词法分析器的规则必须以大写字母开头
ID : [a-z]+ ;
expr : NUMBER ;
NUMBER : [1-9][0-9]*|[0]|([0-9]+[.][0-9]+) ;
WS : [ \t\r\n]+ -> skip ;
现在,让我们使用Test Rule来测试 赋值语句 语法
可以看到该赋值语句语法成功生成了一棵语法分析树。下面,让我们使用antlr工具来生成词法分析器和语法分析器等代码。
下面简单介绍下生成的文件:
- AssignParser.java:该文件包含一个语法分析器类的定义,在该类中,每条规则都有对应的方法,除此之外,还有一些辅助方法
- AssignLexer.java:该文件包含一个词法分析器的定义,作用是将输入的字符序列聚集成词汇符号。
- Assign.tokens:ANTLR会给每个我们定义的词法符号指定一个数字形式的类型,然后将他们的对应关系存储于该文件中。通过它,ANTLR可以在多个小型语法间同步全部的词法符号类型。
- AssignListener.java,AssignBaseListener.java:默认情况下,ANTLR生成的语法分析器能将输入文本转换成一棵语法分析树。在遍历语法分析树时,遍历器能够触发一系列“事件”(回调),并通知我们提供的监听器对象。AssignListener是个接口,存放了这些回调方法的定义,我们可以通过实现它来完成自定义的功能。而AssignBaseListener是该接口默认的实现类,为其中的每个方法提供了空实现。
- AssignVisitor.java,AssignBaseVisitor.java:ANTLR提供了两种遍历树的机制,一种是4中所提到的监听器模式,另外一种就是visitor访问者模式。
接着,我们来编写main函数,将生成的语法分析器与Java程序进行集成。
import assign.AssignLexer;
import assign.AssignParser;
import org.antlr.v4.runtime.ANTLRInputStream;
import org.antlr.v4.runtime.CommonTokenStream;
import org.antlr.v4.runtime.tree.ParseTree;
public class AssignMain {
public static void run(String expr) throws Exception{
// 新建一个CharStream,读取数据
ANTLRInputStream input = new ANTLRInputStream(expr);
// 新建一个词法分析器,处理输入的字符流CharStream
AssignLexer lexer = new AssignLexer(input);
// 新建一个词法符号的缓冲区,用于存储词法分析器生成的词法符号(Token)
CommonTokenStream tokens = new CommonTokenStream(lexer);
// 新建一个语法分析器,用于分析词法符号缓冲区中的词法符号
AssignParser parser = new AssignParser(tokens);
// 对assign规则进行语法分析,生成语法分析树
ParseTree tree = parser.assign();
// 使用LISP风格打印生成的树
System.out.println(tree.toStringTree(parser));
}
public static void main(String[] args) throws Exception{
run("sp = 100;");
}
}
这里可以看到,词法分析器(Lexer)会处理字符流(Charstream)并将生成的词法符号(token)提供给语法分析器,语法分析器(parser)随即根据这些信息来检查语法的正确性并建造出一棵语法分析树(parse tree)。而连接词法分析器和语法分析器的“管道”就是TokenStream。如下图所示。
上图中,语法分析树(parse tree)中的RuleNode和TerminalNode分别代表子树的根节点和叶子节点。可以看到,语法分析树的叶子节点永远是输入的词法符号,而子树根节点因为包含了使用规则识别词组过程中的全部信息,它们又被称为上下文(context)对象。
三、备选分支与歧义性文法
我们的赋值语句语法仅包含一种情况,即 ‘=’ 进行赋值,下面我们将学习通过备选分支来将 ‘:=’ 也表示为赋值符号。
修改assign.g4语法规则文件
grammar Assign;
stat : assign;
assign : ID '=' expr ';'
| ID ':=' expr ';' ;
ID : [a-z]+ ;
expr : NUMBER ;
NUMBER : [1-9][0-9]*|[0]|([0-9]+[.][0-9]+) ;
WS : [ \t\r\n]+ -> skip ;
‘|’ 符号是备选分支的分隔符(有关语法规则的更多写法见下一章内容),备选分支(alternative)是指规则的右侧定义的多个方案之一。现在,让我们重新生成代码,然后查看AssignParser的assign方法:
public final AssignContext assign() throws RecognitionException {
AssignContext _localctx = new AssignContext(_ctx, getState());
enterRule(_localctx, 2, RULE_assign);
try {
setState(18);
_errHandler.sync(this);
switch ( getInterpreter().adaptivePredict(_input,0,_ctx) ) {
case 1:
enterOuterAlt(_localctx, 1);
{
setState(8);
match(ID);
setState(9);
match(T__0);
setState(10);
expr();
setState(11);
match(T__1);
}
break;
case 2:
enterOuterAlt(_localctx, 2);
{
setState(13);
match(ID);
setState(14);
match(T__2);
setState(15);
expr();
setState(16);
match(T__1);
}
break;
}
}
catch (RecognitionException re) {
_localctx.exception = re;
_errHandler.reportError(this, re);
_errHandler.recover(this, re);
}
finally {
exitRule();
}
return _localctx;
}
可以看到,对assign规则的解析是通过switch语句。assign方法必须通过检查下一个词法符号来做出语法分析决策(parsing decision)或者预测(prediction)。做出决策的过程实际上就是判断哪一个备选分支是正确的。而前瞻词法符号(lookahead token)是指任何一个在被匹配和消费之前就由语法分析器嗅探出的词法符号,其实也就是下一个输入的词法符号。
有时候,我们输入的语句可能匹配到多个备选分支,这就是歧义性语句或者二义性文法。换句话说,就是歧义性语句中的单词序列能够匹配多种语法结构。最简单的歧义性语句就是把同一条规则写两遍。
大多数情况下,歧义的表现更为微妙。在下面的语法中,stat规则包含两个备选分支,两者都可以匹配一个函数调用语句。
下图展示了stat规则对于输入“f();”的两种解析:
如果语法分析器检测到该词组存在歧义,那么antlr就必须在多个备选分支中做出选择。而antlr解决歧义性的方法是:选择所有匹配的备选分支中的第一条(switch case 到就会break出去)
后记
在下一篇文章中,我们将编写一个简单的计算器,并学习Antlr相关的语法规则和如何遍历语法分析树,从而实现加减乘除功能。