代码改变世界

编译原理(工具篇)

2014-12-04 17:48  郭志通  阅读(2458)  评论(0编辑  收藏  举报

写在前面

我们构建的分析器有两部分构成:

  • 词法分析器(lexer)
  • 语法分析器(parser)

当然你可以将这两个放在同一个描述文件里面,也可以放在一起。他们之间的区别是:语法以小写字母开头、词法以大写字母开头。我们来看个CSV分析器的例子:

// 词法规则
TEXT : ~[,\n\r"]+ ;	// TEXT可以是除了回车、逗号之外的任意字符
STRING : '"' ('""'|~'"')* '"' ; // 双引号之间的为一个STRING
// 语法规则
file : hdr row+ ;// 文件 = 头+多行
hdr : row ;// 头
row : field (',' field)* '\r'? '\n' ;// 行=field,field...
field // TEXT 或者STRING
    :   TEXT
    |   STRING
    |
    ;

输入1,2,3\na,b,c\na,b,c\n后得到的语法树如下:

本文中都是用IntelliJ IDEA的插件来实现的,有了语法文件就可以生成解析器的代码了,在这之前可以根据自己的需要进行设置:

到这里已经知道怎么弄ANTLR来做一个CSV的分析器了,下面来看看细节。

词法分析

在一切开始之前需要明白:词法分析器生成TOKEN流给语法分析器使用。也就是说词法分析的字符流来生成TOKEN流,然后语法分析器根据TOKEN流来生成语法规则,在生成代码的时候Visitor、Listener中只有语法规则对应的方法。首先来看一些词法相关的关键字:

  1. fragment
  2. mode

第一层:常见的词

有些词法规则比较通用,比如:

  1. 空白字符:WS:[ \t\n\r] -> skip
  2. 变量名:ID:[a-zA-Z_]
  3. 字符串:STRING:'"' ('\\"' | '\\\\' | .)*? '"'
  4. 注释:COMMENT:'//' .*? '\r'? '\n' | '/*' .*? '*/' ->skip

注意到.*?能匹配的到所有的字符,那么注释的为什么能正确地执行?ANTLR在处理该规则的时候会用.*?来匹配最短的字符。用一个简单的词法规则测试一下:

d : A+;
A : 'A'.*? 'B';

输入AABAAAAAB的时候有两种分解的方法:一个A或者两个A。而从结果上来看是后者(在写规则的时候需要注意下):

另外,由于这种优先关系,在STRING我们也不需要关心""之间怎么把'"'排除掉,用起来还是很简单的,感觉有点像优先级。另外,词法分析器中的优先级是先出现的先匹配。

在上面所有的规则都是用来描述包含的关系,但是在一些时候我们需要排除逻辑,如果是要排除某些字符:

TEXT:~[,\n\r"]+

接下来看高级一点的东西:

第二层:预测和动作

用书上的Enum作为例子来看,关键部分如下:

enumDecl : 'enum' name=ID '{' ID (',' ID)* '}' {System.out.println("enum "+$name.text);};
ENUM :   'enum' {java5}? ;
ID :   [a-zA-Z]+ ;

需要注意的是:

  1. ENUM要写在ID前面
  2. enumDecl后面应该是'enum'而不是ENUM

这样达到的效果就是:{java5}?预测失败的时候'enum'为undefined,而不是ID。说的更直白一点就是为了将'enum'从ID词法规则里面踢掉,这样的话就不会去匹配语法规则stat,但是如果换一下顺序:

ENUM : {java5}? 'enum';
ID : [a-zA-Z]+ ;

此时'enum'会有两种可能:ID和undified,然后parser会使用后面的语法规则做进一步的判断,那么此时不管{java5}?能不能验证通过,在输入"enum c{a, b}"的时候都能解析完成,这显然和预期的效果不一样。

第三层:将TOKEN发送给不同的频道 

有时候想通过分析注释来生成代码的文档,怎么办?用ANTLR可以将TOKEN分发到不同的channel中:

他们之间互不干涉,而只有CommonTokenStream是用来交给语法分析器,在词法分析中用下面的方法来设置channel:

@lexer::members {
	public static final int WHITESPACE = 1;
	public static final int COMMENTS = 2;
}
WS	:	[ \t\n\r]+ -> channel(WHITESPACE) ; // channel(1)
SL_COMMENT	:	'//' .*? '\n' -> channel(COMMENTS); // channel(2)

如果只是将一些TOKEN丢掉直接用skip就可以了,一般用channel就会涉及到不同频道中TOKEN的访问,在BufferedTokenStream中提供了API来对其进行访问:

  1. getHiddenTokensToRight
  2. getHiddenTokensToLeft

在获取到对应的Token列表就可以做相应的操作了。

第四层:MODE

很多时候需要将相同的字符串根据不同的环境生成不同类型的TOKEN,如果没有MODE的话只能是根据优先级来做,但是这样会让整体的结构变得非常杂乱,代码的可读性非常差,而且不一定能实现。这种情况下用MODE应该是个不错的选择。定义词法规则如下:

lexer grammar Test;
OPEN  : '<'     -> mode(ISLAND) ;
TEXT  : [a-z] ;
mode ISLAND;
CLOSE : '>'     -> mode(DEFAULT_MODE) ;
ID    : [a-z]+ ;

该规则的目的是实现将"<>"内的字符串定义为类型为ID的TOKEN,此时生成的Test.tokens如下:

OPEN=1
CLOSE=3
TEXT=2
ID=4
'<'=1
'>'=3

随便定义一个语法规则,将词法规则用options{tokenVocab=Test;}引入后生成代码进行测试,对于"<abc>"生成的Token列表为:

< 1(OPEN)
abc 4(ID)
> 3(CLOSE)

如果没有MODE很多解析做起来还是很头痛的,毕竟字符串的形式就那么几种,而TOKEN的类型是随着你的想法的增多而增多的。在书中给出XML的例子:Lexer&Parser

 

 

 

 

 

语法分析

在规则的写法上和词法分析器差别不大,但是搞完之后的效果可就十万八千里了:

第一层:和词法分析器比较

对语法规则rule : 'A' .*? 'BC'进行测试,在输入AABCBC的时候,解析出来如下:

可以看到在语法规则中.*?是跟前后的TOKEN有关系的,也就是说此时匹配的实际上是TOKEN。

第二层:预测和动作

用书上的Enum作为一个例子来演示语法中预测代码的用法,语法部分有:

enumDecl : {java5}? 'enum' name=id '{' id (',' id)* '}' {System.out.println("enum "+$name.text);};

那么在生成的Parser中就会出现:

public final EnumDeclContext enumDecl() throws RecognitionException {
	if (!(java5))
		throw new FailedPredicateException(this, "java5");
}

也就是说{}?中所写的代码,会在Parser中用if包起来做判断,如果结果为false就不会匹配到后面的规则了。预测语句最好是能保证重复执行也不会出错,如果你写的预测语句如下:

{$i++ < 10}?

这样的可能不是一个很好的选择,这种计数类型的一个不错的写法是(匹配指定数目的TOKEN):

vec5
locals [int i=1]
	: ( {$i<5}? INT {$i++;} )* // 匹配5个INT
	;

需要注意的一点是,在match的时候会调用consume对TOKEN进行消费。在ACTION中可以访问符号使用变量,如下:

// 访问词法、语法符号
variable : type ID ';' {System.out.println($type.text + " " + $ID.text);};
// 使用变量
variable : t=type id=ID ';' {System.out.println("type: " + $t.text + " ID: " + $id.text);};
// 使用+=将符号收集到集合中
variable : type ids+=ID (',' ids+=ID)* ';'
{
System.out.println($type.text);
for(Object t : $ids)
	System.out.print(" " + ((Token)t).getText()); 
};

在生成的代码中语法规则其实就是一个方法,既然是一个方法那么应该可以设置参数返回值,如下:

variable : type idList[$type.text] {System.out.println($idList.retList + "\r\n" + $idList.count);}';';
// 带有参数的语法规则
idList[String typeName] returns [List retList, int count]
	: ids+=ID (',' ids+=ID)* { $retList = $ids; $count = $ids.size();};

 

 

 

第三层:错误提示

自己做一个解析器也并不是一件难事,但是如果别人用你的解析器在输入错误的情况下你单单返回一个ERROR,显然是不能接受的,你总得告诉我是在哪里、为什么出错了。在前面写的代码中ANTLR在输出框中打印的错误提示如下:

在测试语法规则的时候也能给出不错的提示:

上面这些只是报错的时候才给提示,有时候我想知道语法中的歧义,那么需要:

parser.getInterpreter().setPredictionMode(PredictionMode.LL_EXACT_AMBIG_DETECTION);
parser.addErrorListener(new DiagnosticErrorListener());

很多时候我们需要自己的错误提示,比如:解析程序是在服务端运行,需要将错误提示返回给客户端展示。此时最简单的做法是自己实现一个ANTLRErrorListener

public interface ANTLRErrorListener {
	void syntaxError(...);// 语法错误
	void reportAmbiguity(...);// 歧义
	void reportAttemptingFullContext(...);// SLL(*)失败,调用ALL(*)的时候调用该方法
	void reportContextSensitivity(...);// 无歧义
}

在使用时调用parser.addErrorListener即可。

 

 

 

 

 

 

 

 

 

Visitor和Listener

一般情况下是通过Visitor和Listener两种方式来使用解析的结果。下面通过计算器的实际例子来看,语法文件定义如下:

s : e ;

e : e MULT e 		# Mult
  | e ADD e 		# Add
  | INT        		# Int
  ;

这里使用了一个技巧:#Mult使得Visitor中有相应的方法,为了实现加法和乘法,我们在对应的方法中实现逻辑:

    public static class EvalVisitor extends LExprBaseVisitor<Integer> {
        public Integer visitMult(LExprParser.MultContext ctx) {
            return visit(ctx.e(0)) * visit(ctx.e(1));
        }
        public Integer visitAdd(LExprParser.AddContext ctx) {
            return visit(ctx.e(0)) + visit(ctx.e(1));
        }
        public Integer visitInt(LExprParser.IntContext ctx) {
            return Integer.valueOf(ctx.INT().getText());
        }
    }

下面写代码来对计算器进行测试:

// 对输入进行分析
ANTLRInputStream input = new ANTLRInputStream("1 + 2");
LExprLexer lexer = new LExprLexer(input);
CommonTokenStream tokens = new CommonTokenStream(lexer);
LExprParser parser = new LExprParser(tokens);
ParseTree tree = parser.s(); // parse
// 遍历树并计算结果
EvalVisitor evalVisitor = new EvalVisitor();
int result = evalVisitor.visit(tree);
System.out.println("result = " + result);// result = 3

在这里用到一个小技巧:使用#Mult标记可以使得最后的Visitor中生成对应的方法,也就是说只有visitE跟visitS。。。

 

 

其他

1. @header{}用来将大括号内部的代码插入到XXXParser或者XXXLexer类的头部,通常用来设置package、import。

2. @members{}用来将代码插入XXXParser或者XXXLexer类内部,是其类的属性,在分析过程中全局可见,通常和ACTION配合实现一些复杂的逻辑。

3. @init定义了规则函数的初始化代码。

4. @after定义规则最后执行的代码,通常用来做一些删除缓存、输出等扫尾操作。

 

 

 

编写过程中遇到的问题

1、使用locals和returns报错:expecting ARG_ACTION while matching a rule。

代码如下:

r
locals[int i=0]
	:   (TAB {$i++;})* {$i == depth}? 'b' {depth++;}
	|   'a'
	|   {depth--;}
	;

找到的解决办法在这里,在ANTLR中要把语法规则放在词法规则前面,不然的话会当成词法规则的关键字来处理。这个明显不合理啊。。。

2、词法解析时找不到对应的TOKEN,而实际上已经定义过了,代码如下:

testPath    :   PATH;
ID          :   [A-Za-z0-9]+;
PATH        :   ID ('.' | ID)*;

输入abc.abc的时候可以正常解析,输入abc的时候报错:mismatched input 'abc' expecting PATH。其实这个就是典型的优先级导致的,因为abc可以解析成两种:ID 和 PATH,但是根据优先级会被解析成ID,这样语法规则testPath就报这个错误。解决办法是将PATH放在ID前面。

 

 

 

 

 

 

 

 

 

---UPDATING---