脚本语言编译前端算法笔记

一、NFADFA:正则表达式处理

1. 在词法分析阶段,可以手工构造有限自动机(FSA,或 FSM)实现词法解析,过程比较简单。现在不需要再手工构造词法分析器,而是直接用正则表达式解析词法:

(1)首先,把正则表达式翻译成非确定的有限自动机(Nondeterministic Finite Automaton,NFA)。

(2)其次,基于NFA处理字符串,看看它有什么特点。

(3)然后,把非确定的有限自动机转换成确定的有限自动机(Deterministic Finite Automaton,DFA)。

(4)最后,运行DFA,看看它有什么特点。

在词法分析阶段,有限自动机(FSA)有有限个状态,识别Token的过程就是FSA状态迁移的过程。其中,FSA分为确定的有限自动机(DFA)和非确定的有限自动机(NFA)。DFA的特点是在任何一个状态,基于输入的字符串都能做一个确定的转换,比如:

NFA的特点是,它存在某些状态,针对某些输入不能做一个确定的转换,这又细分成两种情况:

(1)对于一个输入,它有两个状态可以转换。

(2)存在某种转换,在没有任何输入的情况下,也可以从一个状态迁移到另一个状态。

比如,“a[a-zA-Z0-9]*bc”这个正则表达式对字符串的要求是以a开头,以bc结尾,a和bc之间可以有任意多个字母或数字。在下图中状态1的节点输入b时,这个状态是有两条路径可以选择的,所以这个有限自动机是一个NFA:

这个NFA还有引入ε转换的画法,它们是等价的。实际上第二个NFA可以用接下来提到的算法,通过正则表达式自动生成出来,如下图所示:

需要注意的是,无论是NFA还是DFA都等价于正则表达式,也就是所有的正则表达式都能转换成NFA或DFA,反之亦然。

2. 理解了NFA和DFA之后,来看看如何从正则表达式生成NFA。需要把它分为两个子任务:

(1)把正则表达式解析成一个内部的数据结构,便于后续的程序使用。因为正则表达式也是个字符串,所以要先做一个小的编译器,去理解代表正则表达式的字符串。这里偷个懒直接针对例子的正则表达式生成相应的数据结构,不需要做出这个编译器。用来测试的正则表达式可以是int关键字、标识符,或者数字字面量,如下所示:

int | [a-zA-Z][a-zA-Z0-9]* | [0-9]+

下面这段代码创建了一个树状的数据结构,来代表该正则表达式:

private static GrammarNode sampleGrammar1() {
    GrammarNode node = new GrammarNode("regex1",GrammarNodeType.Or);

    //int关键字
    GrammarNode intNode = node.createChild(GrammarNodeType.And);
    intNode.createChild(new CharSet('i'));
    intNode.createChild(new CharSet('n'));
    intNode.createChild(new CharSet('t'));

    //标识符
    GrammarNode idNode = node.createChild(GrammarNodeType.And);
    GrammarNode firstLetter = idNode.createChild(CharSet.letter);

    GrammarNode letterOrDigit = idNode.createChild(CharSet.letterOrDigit);
    letterOrDigit.setRepeatTimes(0, -1);


    //数字字面量
    GrammarNode literalNode = node.createChild(CharSet.digit);
    literalNode.setRepeatTimes(1, -1);

    return node;
}

打印输出的结果如下:

RegExpression
  Or
    Union
      i
      n
      t
    Union
      [a-z]|[A-Z]
      [0-9]|[a-z]|[A-Z]*
    [0-9]+

更直观的示意图如下所示:

(2)测试数据生成之后,把表示正则表达式的数据结构,转换成一个NFA。这个过程比较简单,因为针对正则表达式中的每一个结构,都可以按照一个固定的规则做转换。有这样的几个规则步骤:

a. 识别ε的NFA:不接受任何输入,也能从一个状态迁移到另一个状态,状态图的边上标注ε,如下图所示:

b. 识别i的NFA:当接受字符i时引发一个转换,状态图的边上标注i。如下图所示:

c. 转换“s|t”这样的正则表达式:意思是或者s或者t,二者选一。s和t本身是两个子表达式,可以增加两个新的状态:开始状态和接受状态(最终状态)也就是下图中带双线的状态,它意味着被检验的字符串此时符合正则表达式。然后用ε转换分别连接代表s和t的子图,即要么走上面路径那就是s,要么走下面路径那就是t:

d. 对于“?”“*”和“+”这样的操作:意思是可以重复0次、0到多次、1到多次,转换时要增加额外的状态和边。以“s*”为例,做下面的转换:

也就是它可以从i直接到 f,也就是对s匹配零次,也可以在s的起止节点上循环多次。

3. 按照这些规则,可以编写程序进行转换。如下列代码所示:

public static State[] regexToNFA(GrammarNode node) {
        State beginState = null;
        State endState = null;

        switch (node.getType()) {
            //转换s|t
            case Or:
                beginState = new State(); //新的开始状态
                endState = new State(true); //新的接受状态
                for (GrammarNode child : node.children()) {
                    //递归,生成子图,返回头尾两个状态
                    State[] childState = regexToNFA(child);
                    //beginState,通过ε接到子图的开始状态
                    beginState.addTransition(new CharTransition(), childState[0]);
                    //子图的结束状态,通过ε接到endState
                    childState[1].addTransition(new CharTransition(), endState);
                    childState[1].setAcceptable(false);
                }
                break;
            //转换st
            case And:
                State[] lastChildState = null;
                for (int i = 0; i < node.getChildCount(); i++) {
                    State[] childState = regexToNFA(node.getChild(i)); //生成子图
                    if (lastChildState != null) {
                        //把前一个子图的接受状态和后一个子图的开始状态合并,把两个子图接到一起
                        lastChildState[1].copyTransitions(childState[0]);
                        lastChildState[1].setAcceptable(false);
                    }
                    lastChildState = childState;

                    if (i == 0) {
                        beginState = childState[0];  //整体的开始状态
                        endState = childState[1];
                    } else {
                        endState = childState[1];    //整体的接受状态
                    }
                }
                break;
            //处理普通的字符
            case Char:
                beginState = new State();
                endState = new State(true);
                //图的边上是当前节点的charSet,也就是导致迁移的字符的集合,比如所有字母
                beginState.addTransition(new CharTransition(node.getCharSet()), endState);
                break;

        }

        State[] rtn = null;

        //考虑重复的情况,增加必要的节点和边
        if (node.getMinTimes() != 1 || node.getMaxTimes() != 1) {
            rtn = addRepitition(beginState, endState, node);
        } else {
            rtn = new State[]{beginState, endState};
        }

        //为命了名的语法节点做标记,后面将用来设置Token类型。
        if (node.getName() != null) {
            rtn[1].setGrammarNode(node);
        }
        return rtn;
}

转换完毕以后,将生成的NFA打印输出,列出了所有的状态,以及每个状态到其他状态的转换,比如“0 ε -> 2”的意思是从状态0通过ε转换,到达状态2 :

NFA states:
0  ε -> 2
  ε -> 8
  ε -> 14
2  i -> 3
3  n -> 5
5  t -> 7
7  ε -> 1
1  (end)
  acceptable
8  [a-z]|[A-Z] -> 9
9  ε -> 10
  ε -> 13
10  [0-9]|[a-z]|[A-Z] -> 11
11  ε -> 10
  ε -> 13
13  ε -> 1
14  [0-9] -> 15
15  ε -> 14
  ε -> 1

下面图片直观地展示了输出结果,图中分为上中下三条路径,能清晰地看出解析int关键字、标识符和数字字面量的过程:

生成NFA之后,如何利用它识别某个字符串是否符合这个NFA代表的正则表达式呢?以上图为例,当解析intA这个字符串时,首先选择最上面的路径去匹配,匹配完int这三个字符以后来到状态7,若后面没有其他字符,就可以到达接受状态1,返回匹配成功的信息。可实际上,int后面是有A的,所以第一条路径匹配失败。失败之后不能直接返回“匹配失败”的结果,因为还有其他路径,所以要回溯到状态0去尝试第二条路径,在第二条路径中尝试成功了。

运行Regex.java中的matchWithNFA()方法,可以用NFA来做正则表达式的匹配:

/**
 * 用NFA来匹配字符串
 * @param state 当前所在的状态
 * @param chars 要匹配的字符串,用数组表示
 * @param index1 当前匹配字符开始的位置。
 * @return 匹配后,新index的位置。指向匹配成功的字符的下一个字符。
 */
private static int matchWithNFA(State state, char[] chars, int index1){
    System.out.println("trying state : " + state.name + ", index =" + index1);

    int index2 = index1;
    for (Transition transition : state.transitions()){
        State nextState = state.getState(transition);
        //epsilon转换
        if (transition.isEpsilon()){
            index2 = matchWithNFA(nextState, chars, index1);
            if (index2 == chars.length){
                break;
            }
        }
        //消化掉一个字符,指针前移
        else if (transition.match(chars[index1])){
            index2 ++; //消耗掉一个字符

            if (index2 < chars.length) {
                index2 = matchWithNFA(nextState, chars, index1 + 1);
            }
            //如果已经扫描完所有字符
            //检查当前状态是否是接受状态,或者可以通过epsilon到达接受状态
            //如果状态机还没有到达接受状态,本次匹配失败
            else {
                if (acceptable(nextState)) {
                    break;
                }
                else{
                    index2 = -1;
                }
            }
        }
    }

    return index2;
}

其中在匹配“intA”时,会看到它的回溯过程,如下所示:

NFA matching: 'intA'
trying state : 0, index =0
trying state : 2, index =0    //先走第一条路径,即int关键字这个路径
trying state : 3, index =1
trying state : 5, index =2
trying state : 7, index =3
trying state : 1, index =3    //到了末尾了,发现还有字符'A'没有匹配上
trying state : 8, index =0    //回溯,尝试第二条路径,即标识符
trying state : 9, index =1
trying state : 10, index =1   //在10和11这里循环多次
trying state : 11, index =2
trying state : 10, index =2
trying state : 11, index =3
trying state : 10, index =3
true

从中可以看到用NFA算法的特点:因为存在多条可能的路径,所以需要试探和回溯,在比较极端的情况下,回溯次数会非常多,性能会变得非常慢。特别是当处理类似s*这样的语句时,因为s可以重0 到无穷次,所以在匹配字符串时,可能需要尝试很多次。注意在生成的NFA中,如果一个状态有两条路径到其他状态,算法会依据一定的顺序来尝试不同的路径

9和11两个状态都有两条向外走的线,其中红色的线是更优先的路径,也就是尝试让*号匹配尽量多的字符。这种算法策略叫做“贪婪(greedy)”策略。在有的情况下,会希望让算法采用非贪婪策略,或者叫“忽略优先”策略,以便让效率更高。有的正则表达式工具会支持多加一个?,比如??、*?、+?来表示非贪婪策略。

4. NFA的运行可能导致大量的回溯,所以能否将NFA转换成DFA,让字符串的匹配过程更简单呢?如果能的话,那整个过程都可以自动化,从正则表达式NFA,再从 NFA到DFA。这样的算法就是子集构造法,它的思路如下:

(1)首先NFA有一个初始状态(从状态0通过ε转换可以到达的所有状态,也就是说在不接受任何输入的情况下,从状态0也可以到达的状态)。这个状态的集合叫做“状态0的ε闭包”,简单一点称之为s0,s0包含0、2、8、14这几个状态,如下图所示:

(2)将字母i给到s0中的每一个状态,看它们能转换成什么状态,再把这些状态通过ε转换就能到达的状态也加入进来,形成一个包含“3、9、10、13、1”这5个状态的集合s1。其中3和9是接受了字母i所迁移到的状态,10、13、1是在状态9的ε闭包中。如下图所示:

(3)在s0和s1中间画条迁移线,标注上i,意思是s0接收到i的情况下,转换到s1:

在这里把s0和s1分别看成一个状态。也就是说要生成的DFA,它的每个状态,就是原来的NFA的某些状态的集合。在上面的推导过程中,有两个主要的计算:

(1)ε-closure(s),即集合s的ε闭包。也就是从集合s中的每个节点,加上从这个节点出发通过ε转换所能到达的所有状态。

(2)move(s,’i’),即从集合s接收一个字符i,所能到达的新状态的集合。

按照上面的思路继续推导,识别int关键字的识别路径也就推导出来了,如下图所示:

把上面这种推导的思路写成算法,NFA2DFA()函数的伪代码如下所示:

计算s0,即状态0的ε闭包
把s0压入待处理栈
把s0加入所有状态集的集合S
循环:待处理栈内还有未处理的状态集
   循环:针对字母表中的每个字符c
      循环:针对栈里的每个状态集合s(i)(未处理的状态集)
          计算s(m) = move(s(i), c)(就是从s(i)出发,接收字符c能够
                                   迁移到的新状态的集合)
          计算s(m)的ε闭包,叫做s(j)
          看看s(j)是不是个新的状态集,如果已经有这个状态集了,把它找出来
                  否则,把s(j)加入全集S和待处理栈
          建立s(i)到s(j)的连线,转换条件是c

运行NFA2DFA()方法,然后打印输出生成的DFA。画成图就能很直观地看出迁移的路径了,如下图所示:

从初始状态开始,如果输入是i那就走int识别这条线,也就是按照19、21、22这条线依次迁移,如果中间发现不符合int模式,就跳转到20也就是标识符状态。注意,在上面的DFA中,只要包含接受状态1的,都是DFA的接受状态。进一步区分的话,22是int关键字的接受状态,因为它包含了int关键字原来的接受状态7。同理,17是数字字面量的接受状态,18、19、20、21都是标识符的接受状态。

可以运行一下示例程序Regex.java中的matchWithDFA()的方法,看看效果:

private static boolean matchWithDFA(DFAState state, char[] chars, int index){
    System.out.println("trying DFAState : " + state.name + ", index =" + index);
    //根据字符,找到下一个状态
    DFAState nextState = null;
    for (Transition transition : state.transitions()){
        if (transition.match(chars[index])){
            nextState = (DFAState)state.getState(transition);
            break;
        }
    }

    if (nextState != null){
        //继续匹配字符串
        if (index < chars.length-1){
            return matchWithDFA(nextState,chars, index + 1);
        }
        else{
            //字符串已经匹配完毕
            //看看是否到达了接受状态
            if(state.isAcceptable()){
                return true;
            }
            else{
                return false;
            }
        }
    }
    else{
        return false;
    }
}

运行时会打印输出匹配过程,而执行过程中不产生任何回溯。现在可以自动生成DFA了,可以根据DFA做更高效的计算。不过有利就有弊,DFA也存在一些缺点。比如DFA可能有很多个状态。假设原来NFA的状态有n个,那么把它们组合成不同的集合,可能的集合总数是2的n次方个。针对上面示例的NFA有13个状态,所以最坏的情况下,形成的DFA可能有2的13次方,也就是8192个状态,会占据更多的内存空间。而且生成这个DFA本身也需要消耗一定的计算时间。当然这种最坏的状态很少发生,上面示例的NFA生成DFA后,只有7个状态。

因此,NFA和DFA有各自的优缺点:NFA通常状态数量比较少,可以直接用来进行计算,但可能会涉及回溯,从而性能低下;DFA的状态数量可能很大,占用更多的空间,并且生成DFA本身也需要消耗计算资源。一般来说,正则表达式工具可以直接基于NFA。而词法分析器(如Lex)则是基于DFA,因为在生成词法分析工具时,只需要计算一次DFA,就可以基于这个DFA做很多次词法分析。

二、FirstFollow集合:用LL算法推演

5. 递归下降算法在语法分析阶段生成AST时很常用,但会有回溯的现象,在性能上会有损失。所以要把算法升级一下,实现带有预测能力的自顶向下分析算法,避免回溯。自顶向下分析的算法是一大类算法。总体来说,它是从一个非终结符出发,逐步推导出跟被解析的程序相同的Token串。这个过程可以看做是一张图的搜索过程,这张图非常大,因为针对每一次推导,都可能产生一个新节点,如下图所示:

算法的任务就是在大图中,找到一条路径,能产生某个句子(Token串)。比如上图找到了三条红色的路径,都能产生“2+3*5”这个表达式。

根据搜索的策略,有深度优先(Depth First)和广度优先(Breadth First)两种,这两种策略的推导过程是不同的。深度优先是沿着一条分支把所有可能性探索完,以“add->mul+add”产生式为例,它会先把mul这个非终结符展开,比如替换成pri,然后再把它的第一个非终结符pri展开。只有把这条分支都向下展开之后,才会回到上一级节点,去展开它的兄弟节点。递归下降算法就是深度优先的,这也是它不能处理左递归的原因,因为左边的分支永远也不能展开完毕

而针对“add->add+mul”这个产生式,广度优先会把add和mul这两个都先展开,这样就形成了四条搜索路径,分别是mul+mul、add+mul+mul、add+pri和add+mul*pri。接着把它们的每个非终结符再一次展开,会形成18条新的搜索路径。所以广度优先遍历需要探索的路径数量会迅速爆炸,成指数级上升。哪怕用下面这个最简单的语法,去匹配“2+3”表达式,都需要尝试20多次,更别提针对更复杂表达式或采用更复杂的语法规则了:

//一个很简单的语法
add -> pri          //1
add -> add + pri    //2
pri -> Int          //3
pri -> (add)        //4

这样看来,指数级上升的内存消耗和计算量,使得广度优先在这方面根本没有实用价值。虽然上面的算法有优化空间,但无法从根本上降低算法复杂度。当然它也有可以使用左递归文法的优点,不过并不会为了这个优点去忍受算法的性能问题。而深度优先算法在内存占用上是线性增长的。考虑到回溯的情况,在最坏情况下它的计算量也会指数式增长,但可以通过优化让复杂度降为线性增长。

针对深度优先算法的优化方向是减少甚至避免回溯,思路就是给算法加上预测能力。比如在解析statement时,看到一个if,就知道肯定这是一个条件语句,不用再去尝试其他产生式了。LL算法就属于这类预测性的算法。第一个L是Left-to-right,代表从左向右处理程序代码。第二个L是Leftmost,意思是最左推导。

按照语法规则,一个非终结符展开后会形成多个子节点,其中包含终结符和非终结符。最左推导是指,从左到右依次推导展开这些非终结符。采用Leftmost的方法,在推导过程中句子的左边逐步都会被替换成终结符,只有右边的才可能包含非终结符。以“2+3*5”为例,它的推导顺序从左到右,非终结符逐步替换成了终结符:

下图是上述推导过程建立起来的AST,“1、2、3……”等编号是AST节点创建的顺序:

6. 上面把自顶向下分析算法做了总体概述,并讲了最左推导的含义,现在来看看LL算法到底是什么。LL算法是带有预测能力的自顶向下算法。在推导时希望当存在多个候选的产生式时,瞄一眼下一个(或多个)Token,就知道采用哪个产生式。如果只需要预看一个Token,就是LL(1)算法。拿statement的语法举例,它有好几个产生式,分别产生if 语句、while语句、switch语句等,如下所示:

statement
    : block
    | IF parExpression statement (ELSE statement)?
    | FOR '(' forControl ')' statement
    | WHILE parExpression statement
    | DO statement WHILE parExpression ';'
    | SWITCH parExpression '{' switchBlockStatementGroup* switchLabel*                
    | RETURN expression? ';'
    | BREAK IDENTIFIER? ';'
    | CONTINUE IDENTIFIER? ';'
    | SEMI
    | statementExpression=expression ';'
    | identifierLabel=IDENTIFIER ':' statement
    ;

如果看到下一个Token是if,那么后面跟着的肯定是if语句,这样就实现了预测,不需要一个个产生式去试。问题来了,if语句的产生式的第一个元素就是一个终结符,这自然很好判断,可如果是一个非终结符比如表达式语句,该怎么判断呢?

其实可以为statement的每条分支计算一个集合,集合包含了这条分支所有可能的起始Token。如果每条分支的起始Token是不一样的,也就是这些集合的交集是空集,那么就很容易根据这个集合来判断该选择哪个产生式,把这样的集合就叫做这个产生式的First集合。First集合的计算很直观,假设要计算的产生式是x:

(1)如果x以Token开头,那么First(x)包含的元素就是这个Token,比如if语句的First集合就是{IF}。

(2)如果x的开头是非终结符a,那么First(x)要包含First(a)的所有成员。比如expressionStatment是以expression开头,因此它的First集合要包含First(expression)的全体成员。

(3)如果x的第一个元素a能够产生ε,那么还要再往下看一个元素b,把First(b)的成员也加入到First(x),以此类推。如果所有元素都可能返回ε,那么First(x)也应该包含ε,意思是x也可能产生ε。比如下面的blockStatements产生式,它的第一个元素是blockStatement*,也就意味着blockStatement的数量可能为0,因此可能产生ε。那么First(blockStatements) 除了要包含First(blockStatement)的全部成员,还要包含后面的“;”,如下所示:

blockStatements
        : blockStatement*
        ;

(4)如果x是一个非终结符,它有多个产生式可供选择,那么First(x)应包含所有产生式的First()集合的成员。比如statement的First集合要包含if、while等所有产生式的First集合的成员。并且如果这些产生式只要有一个可能产生ε,那么x就可能产生ε,因此First(x)就应该包含ε。

在这里的示例中,可以用SampleGrammar.expressionGrammar()方法获得一个表达式的语法,把它dump()一下,这其实是消除了左递归的表达式语法:

expression  : assign ;
assign  : equal | assign1 ;
assign1 : '=' equal assign1 | ε;  
equal  : rel equal1 ;
equal1  : ('==' | '!=') rel equal1 | ε ;
rel    : add rel1 ;
rel1  : ('>=' | '>' | '<=' | '<') add rel1 | ε ;
add    : mul add1 ;
add1  : ('+' | '-') mul add1 | ε ;
mul    : pri mul1 ;
mul1  : ('*' | '/') pri mul1 | ε ;
pri    : ID | INT_LITERAL | LPAREN expression RPAREN ;

用GrammarNode类代表语法的节点,形成一张语法图(蓝色节点的下属节点之间是“或”的关系,也就是语法中的竖线),如下图所示:

基于这个数据结构能计算每个非终结符的First集合,在计算时要注意,因为上下文无关文法是允许递归嵌套的,所以这些GrammarNode节点构成的是一个图而不是树,不能通过简单的遍历树的方法来计算First集合。比如pri节点是expression的后代节点,但pri又引用了expression(pri->(expression))。这样,计算First(expression)需要用到First(pri),而计算First(pri)又需要依赖First(expression)。

破解这个僵局的方法是用“不动点法”来计算。多次遍历图中的节点,看看每次有没有计算出新的集合成员。比如第一遍计算时,当求First(pri)的时候,它所依赖的First(expression)中的成员可能不全,等下一轮继续计算时,发现有新的集合成员再加进来就好了,直到所有集合的成员都没有变动为止。

7. 现在可以用First集合进行分支判断了,不过还要处理产生式可能为ε的情况,比如“+mul add1 | ε”或“blockStatement*”都会产生ε。对ε的处理分成两种情况:

(1)产生式中的部分元素会产生ε。比如在Java语法里声明一个类成员时,可能会用public、private等来修饰,但也可以省略不写。在语法规则中这个部分是“accessModifier?”,它就可能产生ε,如下所示:

memberDeclaration : accessModifier? type identifier ';' ;
accessModifier : 'public' | 'private' ;
type : 'int' | 'long' | 'double' ;

所以当遇到下面这两个语句时,都可以判断为类成员的声明:

public int a;
int b;

这时,type能够产生的终结符‘int’、‘long’和‘double’也在memberDeclaration的First集合中。这样实际上把accessModifier给穿透了,直接到了下一个非终结符type。所以这类问题依靠First集合仍然能解决。在解析的过程中,如果下一个Token是‘int’,可以认为accessModifier返回了ε,忽略它继续解析下一个元素type,因为它的First集合中才会包含‘int’。

(2)产生式本身(而不是其组成部分)产生ε。这类问题仅仅依靠First集合是无法解决的,要引入另一个集合:Follow集合。它是所有可能跟在某个非终结符之后的终结符的集合。以block语句为例,在PlayScript.g4中,大致是这样定义的:

block
    : '{' blockStatements '}'
    ;

blockStatements
    : blockStatement*
    ;
    
blockStatement
    : variableDeclarators ';'
    | statement
    | functionDeclaration
    | classDeclaration
    ;

也就是说,block是由blockStatements构成的,而blockStatements可以由0到n个blockStatement构成,因此可能产生ε。接下来看看解析block时会发生什么,假设花括号中一个语句也没有,也就是blockStatments实际上产生了ε,那么在解析block时,首先读取了一个Token即“{”,然后处理blockStatements,再预读一个Token发现是“}”,那这个右花括号是blockStatement的哪个产生式的呢?实际上它不在任何一个产生式的First集合中,下面是进行判断的伪代码:

nextToken = tokens.peek();                //得到'}'
nextToken in First(variableDeclarators) ? //no
nextToken in First(statement) ?           //no
nextToken in First(functionDeclaration) ? //no
nextToken in First(classDeclaration) ?    //no

找不到任何一个可用的产生式。这可怎么办呢?除了可能是blockStatments本身产生了ε之外,还有一个可能性就是出现语法错误了。而要继续往下判断,就需要用到Follow集合。像blockStatements的Follow集合只有一个元素,就是右花括号“}”。所以只要再检查一下nextToken是不是花括号就行了:

//伪代码
nextToken = tokens.peek();                //得到'}'
nextToken in First(variableDeclarators) ? //no
nextToken in First(statement) ?           //no
nextToken in First(functionDeclaration) ? //no
nextToken in First(classDeclaration) ?    //no

if (nextToken in Follow(blockStatements)) //检查Follow集合
  return Epsilon;                         //推导出ε
else
  error;                                  //语法错误

计算非终结符x的Follow集合,有如下的规则:

(1)扫描语法规则,看看x后面都可能跟哪些符号。

(2)对于后面跟着的终结符,都加到Follow(x)集合中去。

(3)如果后面是非终结符,就把它的First集合加到自己的Follow集合中去。

(4)最后,如果后面的非终结符可能产出ε,就再往后找,直到找到程序终结符号。

这个符号通常记做$,意味一个程序的结束。比如在表达式的语法里,expression后面可能跟这个符号,expression的所有右侧分支的后代节点也都可能跟这个符号,也就是它们都可能出现在程序的末尾。但另一些非终结符后面不会跟这个符号,如blockstatements,因为它后面肯定会有“}”。这里也要用到不动点法做计算。运行程序可以打印出示例语法的的Follow集合。程序打印输出的First和follow集合整理如下(打印输出还包含一些中间节点,这里省略):

在表达式的解析中,会综合运用First和Follow集合。比如对于“add1 -> + mul add1 | ε”,如果预读的下一个Token是+,那就按照第一个产生式处理,因为+在First(“+ mul add1”)集合中。如果预读的Token是>号,那它肯定不在First(add1)中,而要看它是否属于Follow(add1),如果是那么add1就产生一个ε,否则就报错。

8. 现在已经建立了对First集合、Follow集合和LL算法计算过程的直觉认知,这样再写出算法的实现就比较容易了。用LL算法解析语法时,可以选择两种实现方式:

(1)还是采用递归下降算法,只不过现在的递归下降算法是没有任何回溯的。无论走到哪一步,都能准确地预测出应该采用哪个产生式。

(2)采用表驱动的方式。这个时候需要基于计算出来的First和Follow集合构造一张预测分析表。根据这个表查找在遇到什么Token的情况下,应该走哪条路径。

这两种方式是等价的,接下来谈谈如何设计符合LL(k)特别是LL(1)算法的文法。前面已经知道左递归的文法是要避免的,除此之外要尽量抽取左公因子,这样可以避免First集合产生交集。举例来说,变量声明和函数声明的规则在前半截都差不多,都是类型后面跟着标识符:

statement : variableDeclare | functionDeclare | other;
variableDeclare : type Identifier ('=' expression)? ;
funcationDeclare : type Identifier '(' parameterList ')' block ;

具体代码例子如下:

int age;
int cacl(int a, int b){
  return a + b;
}

这样的语法规则如果按照LL(1)算法,First(variableDeclare)和First(funcationDeclare)是相同的,没法决定走哪条路径。就算用LL(2)也是一样的,要用到LL(3)才行。但对于LL(k) k > 1来说,程序开销有点大,因为要计算更多的集合,构造更复杂的预测分析表。不过这个问题容易解决,只要把它们的左公因子提出来就可以了:

statement: declarator | other;
declarator : declarePrefix (variableDeclarePostfix                
                            |functionDeclarePostfix) ;
variableDeclarePostfix : ('=' expression)? ;
functionDeclarePostfix : '(' parameterList ')' block ;

这样,解析程序先解析它们的公共部分即declarePrefix,然后再看后面的差异。这时它俩的First集合,一个是{ =  ; },一个是{  (  },两者没有交集能够很容易区分。

三、移进和归约:用LR算法推演

9. 前面讨论的语法分析算法都是自顶向下的。与之对应的是自底向上的算法,比如LR算法,它能够支持更多的语法,而且没有左递归的问题。第一个字母L与LL算法的第一个L一样,代表从左向右读入程序。第二个字母R,指的是RightMost(最右推导),也就是在使用产生式时,是从右往左依次展开非终结符,例如对于“add->add+mul”这样一个产生式,是优先把mul展开然后再是add。

自顶向下的算法,是递归地做模式匹配,从而逐步地构造出AST。那么自底向上的算法是如何构造出AST的呢?答案是用移进-规约的算法。先通过一个例子看自底向上语法分析的过程,如下所示:

add -> mul
add -> add + mul
mul -> pri
mul -> mul * pri  
pri -> Int | (add)

然后来解析“2+3*5”这个表达式,AST如下,分步骤看一下解析的具体过程:

(1)看到第一个Token是Int即2,把它作为AST的第一个节点,同时把它放到一个栈里(下图红线左边的部分)。这个栈代表着正在处理的一些AST节点,把Token移到栈里的动作叫做移进(Shift)。

(2)根据语法规则,Int是从pri推导出来的(pri->Int),那么它的上级AST肯定是pri,所以给它加了一个父节点pri,同时也把栈里的Int替换成了pri。这个过程是语法推导的逆过程,叫做规约(Reduce),如下图所示:

具体来讲,它是从工作区里倒着取出1到n个元素,根据某个产生式组合出上一级的非终结符,也就是AST的上级节点,然后再放进工作区(也就是上面竖线的左边)。这个时候栈里可能有非终结符,也可能有终结符,它仿佛是组装AST的一个工作区,竖线的右边全都是Token(即终结符),它们在等待处理。

(3)与第2步一样,因为pri只能是mul推导出来的,即产生式是“mul->pri”,所以又做了一次规约,如下图所示:

(4)根据“add->mul”产生式,将mul规约成add。至此对第一个Token做了3次规约,已经到头了。这里为什么做规约,而不是停在mul上移进+号,是有原因的。因为没有一个产生式是mul后面跟+号,而add后面却可以跟+号,如下图所示:

(5)移进+号。现在栈里有两个元素了,分别是add和+,如下图所示:

(6)移进Int也就是数字3。栈里现在有3个元素,如下图所示:

(7)&(8)将Int规约到pri再规约到mul。到目前为止做规约的方式都比较简单,就是对着栈顶的元素,把它反向推导回去,如下图所示:

(9)面临3个选择。第一个选择是继续把mul规约成add,第二个选择是把“add+mul”规约成add。这两个选择都是错误的,因为它们最终无法形成正确的AST,如下图所示:

第三个选择,也就是按照“mul->mul*pri”继续移进*号而不是做规约。只有这样才能形成正确的AST,就像下图中的虚线:

(10)移进Int也就是数字5,如下图所示:

(11)Int规约成pri,如下图所示:

(12)mul*pri规约成mul。注意这里也有两个选择,比如把pri继续规约成mul,但它显然也是错误的选择,如下图所示:

(13)add+mul规约成add,如下图所示:

至此就构建完成了一棵正确的AST,并且栈里也只剩下了一个元素,就是根节点。

10. 上面整个语法解析过程,实质是反向最右推导(Reverse RightMost Derivation),即如果把AST节点根据创建顺序编号,就是下面这张图呈现的样子,根节点编号最大是13:

但这是规约的过程,如果是从根节点开始的推导过程,顺序恰好是反过来的,先是13号再是右子节点12号,再是12号的右子节点11号,以此类推。这个最右推导过程如下:

在语法解析时是从底下反推回去,所以叫做反向的最右推导过程。从这个意义上讲,LR算法中的R带有反向(Reverse)和最右(Reightmost)这两层含义。在最右推导过程中,加了下划线的部分叫做一个句柄(Handle)。句柄是一个产生式的右边部分,以及它在一个右句型(最右推导可以得到的句型)中的位置。以最底下一行为例,这个句柄“Int”是产生式“pri->Int”的右边部分,它的位置是句型“Int + Int * Int”的第一个位置。简单来说,句柄就是产生式是在这个位置上做推导的,如果需要做反向推导的话,也是从这个位置去做规约

11. 要把这种判断过程变成严密的算法,做到在每一步都知道该做移进还是规约,做规约该按照哪个产生式,这就是LR算法要解决的核心问题了。那么如何找到正确的句柄呢?最右推导是从最开始的产生式出发,经过多步推导(记作->*),一步步形成当前的局面(即左边栈里有一些非终结符和终结符,右边还可以预看1到k个Token),如下所示:

add ->* 栈 | Token

根据手头掌握的信息,可以反向推导出这个多步推导的路径,从而获得正确的句柄。这里依据的是左边栈里的信息,以及右边的Token串。对于LR(0)算法来说,只依据左边的栈就能找到正确的句柄,对于LR(1)算法来说,可以从右边预看一个Token。思路是根据语法规则复现这条推导路径。以上面第8步为例,下图是它的推导过程,红色的路径是唯一能够到达第8步的路径。知道了正向推导的路径,在第8步正确的选择是做移进:

为了展示这个推导过程,这里引入一个新概念:项目(Item)。Item代表带有“.”符号的产生式。比如“pri->(add)”可以产生4个Item,“.”分别在不同的位置。“.”可以看做是之前步骤示意图中的竖线,左边的看做已经在栈里的部分,“.”右边的看做是期待获得的部分:

pri->.(add)
pri->(.add)
pri->(add.)
pri->(add).

上图其实是一个NFA,利用这个NFA表达了所有可能的推导步骤。每个Item(或者状态)在接收到一个符号时,就迁移到下一个状态,比如“add->.add+mul”在接收到一个add时就迁移到“add->add.+mul”,再接收到一个“+”就迁移到“add->add+.mul”。

在这个状态图的左上角,用一个辅助性的产生式“start->add”作为整个 NFA 的唯一入口。从这个入口出发,可以用这个NFA来匹配栈里内容,比如在第 8 步的时候,栈以及右边下一个 Token 的状态如下,其中竖线左边是栈的内容:

add + mul | *

在NFA中,从start开始遍历,基于栈里的内容能找到图中红色的多步推导路径。在这个状态迁移过程中,导致转换的符号分别是“ε、add、+、ε、mul”,忽略其中的ε就是栈里的内容。在NFA中,查找到的Item是“mul->mul.*pri”。这个时候“.”在Item的中间。因此下一个操作只能是一个Shift操作,也就是把下一个Token即*号移进到栈里。如果“.”在Item的最后则对应一个规约操作,比如在第12步,栈里的内容是:

add + mul | $    //$代表Token串的结尾

这个时候的Item是“add->add+mul.”。对于所有点符号在最后面的Item,已经没有办法继续向下迁移了,这个时候需要做一个规约操作,也就是基于“add + mul”规约到add,也就是到“add->.add+mul”这个状态。对于任何的ε转换,其逆向操作也是规约,比如图中从“add->.add+mul”规约到“start->.add”。但做规约操作之前,仍然需要检查后面跟着的Token是不是在Follow(add)中。对于add来说,它的Follow集合包括{ $ + ) },如果是这些Token那就做规约,否则就报编译错误。

当然,每个NFA都可以转换成一个DFA。所以可以直接在上面的NFA里去匹配,也可以把NFA转成DFA,避免NFA的回溯现象,让算法效率更高。转换完毕的DFA如下:

在这个DFA中同样标注了在第8步时的推导路径。为了更清晰地理解LR算法的本质,基于这个DFA再把语法解析的过程推导一遍:

(1)移进一个Int,从状态1迁移到9。Item是“pri->Int.”。

(2)依据“pri->Int”做规约,从状态9回到状态1。因为现在栈里有个pri元素,所以又迁移进了状态8。

(3)依据“mul->pri”做规约,从状态8回到状态1,再根据栈里的mul元素进入状态7。注意在状态7的时候,下一步的走向有两个可能的方向,分别是“add->mul.”和“mul->mul.*pri”这两个Item代表的方向。基于“add->mul.”会做规约,而基于“mul->mul.*pri”会做移进,这就需要看看后面的Token了。如果后面的Token是*号,那其实要选第二个方向。但现在后面是+号,所以意味着这里只能做规约。

(4)依据“add->mul”做规约,从状态7回到状态1,再依据add元素进入状态2。

(5)移进+号。这对应状态图上的两次迁移,首先根据栈里的第一个元素add从1迁移到2。然后再根据“+”从2到3。Item的变化是:

状态 1:start->.add
状态 1:add->.add+mul
状态 2:add->add.+mul
状态 3:add->add+.mul

通过移进这个加号,实际上知道了这个表达式顶部必然有一个“add+mul”的结构。

(6)~(8)移进Int,并一直规约到mul。状态变化是先从状态3到状态9,然后回到状态3,再进到状态4。

(9)移进一个*。根据栈里的元素,迁移路径是 1->2->3->4->5。

(10)移进Int进入状态9。

(11)根据“pri->Int”规约到pri,先退回到状态5,接着根据pri进入状态6。

(12)根据“mul->mul*pri”规约到mul,从而退回到状态4。

(13)根据“add->add+mul”规约到add,从而退回到状态2。

从状态2再根据“start->add”再规约一步就变成了start,回到状态1解析完成。

12. LR算法根据能力的强弱和实现的复杂程度,可以分成多个级别,分别是LR(0)、SLR(k)(即简单 LR)、LALR(k)(Look ahead LR)和LR(k),其中k表示要在Token队列里预读k个Token。下面讲解一下这几种类型算法的特点:

(1)LR(0)不需要预看右边的 Token,仅仅根据左边的栈进行反向推导。比如,前面DFA中的状态8只有一个Item:“mul->pri.”。如果处在这个状态,那接下来操作是规约。假设存在另一个状态,它也只有一个Item,点符号不在末尾,比如“mul->mul.*pri”,那接下来的操作就是移进,把下一个输入放到栈里。

但实际使用的语法规则很少有这么简单的。所以LR(0)的表达能力太弱,能处理的语法规则有限,不太有实用价值。就像在前面的例子中,如果不往下预读一个Token,仅仅利用左边工作区的信息,是找不到正确的句柄的。比如在状态7中,可以做两个操作:对于第一个Item即“add->mul.”,需要做一个规约操作;对于第二个Item即“mul->mul.*pri”,实际上需要做一个移进操作。

这里发生的冲突,就叫做“移进/规约”冲突(Shift/Reduce Conflict),意思是又可以做移进又可以做规约,对于状态7来说,到底做哪个操作实际上取决于右边的Token。

(2)SLR(Simple LR)是在LR(0)的基础上做了增强。对于状态7的这种情况要加一个判断条件:右边下一个输入的Token,是不是在add的Follow集合中。因为只有这样做规约才有意义。在例子中,add的Follow集合是{+ ) $},如果不在这个范围内,那么做规约肯定是不合法的。因为Follow集合的意思,就是哪些Token可以出现在某个非终结符后面。所以如果在状态7中下一个Token是*,它不在add的Follow集合中,那么就只剩了一个可行的选择就是移进。这样就不存在两个选择,也不存在冲突。

实际上,就这里所用的示例语法而言,SLR就足够了,但是对于一些更复杂的语法,采用SLR仍然会产生冲突,比如:

start -> exp
exp -> lvalue = rvalue
exp -> rvalue
lvalue -> Id
lvalue -> *rvalue
rvalue -> lvalue

这个语法说的是关于左值和右值的情况,在这里的示例语法里,右值只能出现在赋值符号右边。在状态2如果下一个输入是“=”,那么做移进和规约都是可以的。因为“=”在rvalue的Follow集合中,如下图所示:

(3)怎么来处理这种冲突呢?仅仅根据Follow集合来判断是否Reduce不太严谨。因为在上图状态2的情况下,即使后面跟着的是“=”仍然不能做规约。因为一规约就成了一个右值,但它在等号的左边,显然是跟上面的语法定义冲突的。办法是Follow集合拆了,把它的每个成员都变成Item的一部分。这样就能做更细致的判断。如下图所示,这样细化以后,发现在状态2中只有下一个输入是“$”的时候才能做规约。这就是LR(1)算法的原理,它更加强大:

LR(1)算法也有一个缺点,就是DFA可能会很大。在语法分析阶段,DFA的大小会随着语法规则的数量呈指数级上升,一个典型的语言的DFA状态可能达到上千个,这会使语法分析的性能很差,从而也丧失了实用性

(4)LALR(k)是基于这个缺点做的改进。它用了一些技巧能让状态数量变得比较少,但处理能力没有太大的损失,YACC和Bison这两个工具就是基于LALR(1)算法的。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值