编译原理(工具篇)
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中只有语法规则对应的方法。首先来看一些词法相关的关键字:
- fragment
- mode
第一层:常见的词
有些词法规则比较通用,比如:
- 空白字符:WS:[ \t\n\r] -> skip
- 变量名:ID:[a-zA-Z_]
- 字符串:STRING:'"' ('\\"' | '\\\\' | .)*? '"'
- 注释: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]+ ;
需要注意的是:
- ENUM要写在ID前面
- 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来对其进行访问:
- getHiddenTokensToRight
- 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---