zoukankan      html  css  js  c++  java
  • 编译原理(工具篇)

    写在前面

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

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

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

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

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

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

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

    词法分析

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

    1. fragment
    2. mode

    第一层:常见的词

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

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

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

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

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

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

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

    TEXT:~[, "]+

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

    第二层:预测和动作

    用书上的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	:	[ 	
    
    ]+ -> channel(WHITESPACE) ; // channel(1)
    SL_COMMENT	:	'//' .*? '
    ' -> 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 + "
    " + $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---

  • 相关阅读:
    Hibernate关系映射(一) 基于外键的单向一对一
    Hibernate开发环境搭建
    Java创建和读取Json
    Json 简易教程
    jQuery Validate验证框架详解
    asp.net Core 3.1配置log4net
    HTTP 错误 500.21
    SQL中数据库 无法访问,并且数据库的属性中 使用人数、大小、可用空间全是不可用
    What is a CGFloat?
    Swift中的CGPoint ,CGSize 、CGRect、CGFloat
  • 原文地址:https://www.cnblogs.com/antispam/p/4143478.html
Copyright © 2011-2022 走看看