你好,我是宫文学。

到目前为止,我们已经学习了一些语法分析的算法。不过,我们主要是分析了如何来解析语句,比如函数声明、函数调用,没有把重点放在解析表达式上。

其实我是刻意为之的,故意把表达式的解析往后推迟一下。原因是表达式解析,特别是像“2+3*5”这样的看似特别简单的二元运算的表达式解析,涉及的语法分析技术反而是比较复杂的。所以,从循序渐进的角度来说,我们要把它们放在后面。

表达式的解析复杂在哪里呢?是这样,我们在解析二元表达式的时候,会遇到递归下降算法最大的短板,也就是不支持左递归的文法。如果遇到左递归的文法,会出现无限循环的情况。

在这一节里,我会给你分析这种左递归的困境,借此加深你对递归下降算法运算过程的理解。

同时,我也要给出避免左递归问题的方法。这里,我没有采用教科书上经常推荐的改写文法的方法,而是使用了业界实际编译器中更常用的算法:运算符优先级解析器(Operator-precedence parser)。JDK的Java编译器、V8的JaveScript编译器和Go语言的GC编译器,都毫无例外地采用了这个算法,所以这个算法非常值得我们掌握。

好了,那我们首先来了解一下用递归下降算法解析算术表达式会出现的这个左递归问题。

左递归问题

我们先给出一种简化的加法表达式的语法规则:

add : add '+' IntLiteral
    | IntLiteral
    ;

对这个规则的解读是这样的:一个加法表达式,它要么是一个整型字面量,要么是另一个加法表达式再加上一个整型字面量。在这个规则下,2、2+3、2+3+4都是合格的加法表达式。

那如果用递归下降算法去解析2+3,我们会采用“add ‘+’ IntLiteral”的规则。而这个规则呢,又要求匹配出一个add来,从而算法又会递归地再次调用“add ‘+’ IntLiteral”规则,导致无限递归下去。

2+3是不是一个add表达式?
  ->先匹配出一个add表达式来,再是+号,再是整型字面量
    ->先匹配出一个add表达式来,再是+号,再是整型字面量
      ->先匹配出一个add表达式来,再是+号,再是整型字面量
        ->无限递归...

这就是著名的左递归问题,是递归下降算法或者LL算法都无法解决的。

你可能会问,如果把产生式的写法换一下,把add放在后面,不就会避免左递归了吗?

add : IntLiteral '+' add
    | IntLiteral
    ;

这个也是不行的,因为这样会导致运算的结合性出错。如果执意按照这个语法解析,解析2+3+4这个表达式所形成的AST会是右结合的:

图片

你会看到,基于使用右递归文法生成的AST,实际是先计算3+4,再跟2相加。这违背了加法运算的结合性的规定。正确的运算顺序,应该是先计算2+3,然后再加上4,是左结合,对应的AST应该是右边的那个。这种结合性的错误,看上去对于加法影响不大,但如果换成减法或者除法,那计算结果就完全错误了。

好了,现在你已经理解了左递归问题了。那我们要如何解决这个问题呢?一个可行的解决方法就是改写文法,并且要在解析算法上做一些特殊的处理,你可以参考《编译原理之美》课程04讲。除了改写文法的方法以外,还有一些研究者提出了其他一些算法,也能解决左递归问题。

不过,针对二元表达式的解析,今天我要采用的是被实际编译器广泛采用的运算符优先级算法

运算符优先级算法

这是个怎么样的算法呢?我想先用简单的方式帮你理解运算符优先级算法的原理,然后再一步步深化。

01讲介绍递归下降算法的时候,我提到,它对应的是人类的一种思维方式,也就是从顶向下逐步分解。但人类还有另一种思维方式:自底向上逐步归纳。而运算符优先级算法,对应的就是自底向上的一种思维方式。

首先我们来看2+3+4这个表达式,如果我们用自底向上归纳的思路做语法分析是怎么样一个思考过程呢?

第1步,首先看到2。你心里想,这里有一个整数了,那它是不是一个算术表达式的组成部分呀?是一个加法表达式的,还是乘法表达式的一部分呢?我们再往下看一看就知道。

第2步,看到一个+号。噢,你说,原来是一个加法表达式呀。这时候,我们知道,2肯定是要参与加法运算的,所以是加法的左子树。但加法后面可以跟很多东西的,比如另一个整数,或者是一个乘法表达式什么的,都有可能。那我们继续向下看。
图片
第3步,看到整数3。奥,你心里想,原来是2+3呀。那我现在根据这三个Token是不是可以先凑出一棵AST的子树来呢?先等一等,我们现在还不知道3后面跟的是什么。

如果3后面跟的是+号或者-号,那没问题,3是先参与前面这个+号的计算,再把2+3的结果一起,去参与后面的计算的。

但如果3后面遇到的是 * 号呢?那么3就要先参与乘法运算,计算完的才参与前面的加法的。这两个不同的计算顺序,导致AST的结构是不一样的,而影响AST结构的,其实就是3前后的两个运算符的优先级。
图片
第4步,看到第2个+号。这个时候,你心里知道了,原来3后面的运算符的优先级跟前面的是一样的呀,那么按照结合性的规定,应该先算前面的加法,再算后面的加法,所以3应该跟前面的2和+号一起凑成一个AST子树。并且,这棵子树会作为一个稳定的单元,参与后面的AST的构建。
图片
第5步,看到整数4。现在的情况跟第3步是一样的,我们不知道4后面跟着的是什么。如果4后面跟着一个 * 号,那么4还要先参与后面的计算,然后再跟前面这一堆做加法。如果4后面也是一个加法运算符,那4就要先参与前面的计算,4在AST中的位置也就会变得确定。
图片
第6步,再往下看,发现后面的Token既不是+号,也不是 * 号,而是EOF,也就是Token串的结尾。这样的话,整个AST就可以确定下来了。
图片
好了,这是一个比较简单的算法运行的场景。你可以多读几遍,借此找找自底向上分析的直观感觉。

接下来,我们再换一个任务,分析一下2+3*5。你会发现,跟前一个例子相比,一直到第5步的时候,也就是读入了5以后,仍然没有形成一棵稳定的AST子树:
图片
这是为什么呢?

因为根据5后面读入的Token的不同,形成的AST的结构会有很大的区别。这里我们展示3种情形:

图片

情形1,第6个Token是+号:它的优先级不高于最后一个运算符 * 号,所以3 * 5这棵子树的结构就是确定的;进一步看,它也不高于第一个运算符的优先级,所以整个2+3 * 5这棵子树的结构都可以确定下来,并且肯定是最后一个+号的左子树。

情形2,第6个Token是 * 号:这时候它的优先级仍然不高于最后一个运算符 * 号,所以3 * 5这棵子树的结构也可以确定下来,并成为新的 * 号的左子树。

情形3,如果第6个Token是 ** 号:也就是做幂运算,那么5就会首先参与幂运算,而不是参与前面的乘法运算。

当然,还有最后一种情形,就是第六个Token是EOF:这会跟第一种情形相似,形成一棵确定的AST。

好了,这就是对第二个例子的分析。它比第一个例子要复杂一些,也能让你对运算符优先级算法的理解更深化。

目前我们的表达式,用到了两个优先级的运算符。你还可以增加更多的优先级进来,比如“2+3 * 5>10”这个关系表达式,有3个优先级;“2+3 * 5>10 && ture”是一个逻辑表达式,有4个优先级。

那么怎么把它实现成算法呢?其实我在上面的图里已经有所铺垫。在每个图里,我都画了两个工作区,也就是两个栈。一个栈用来放运算符,一个栈用来放待装配的AST片段。

算法的设计也很简单,每次新取出一个运算符的时候,都跟栈顶的运算符做比较。如果新的运算符的优先级高于栈顶的运算符,就把该运算符继续压栈;否则,就从栈里弹出所存的运算符,并把它跟AST片段栈中的元素组合,变成一棵新的AST子树,重新压入AST片段栈。怎么样,很简单吧?

从上面的描述中,你还能得出一个推论:运算符栈里的元素,总是一个比一个的优先级高

具体实现算法的时候,可以按照刚才描述的逻辑,用两个栈作为工作区,来实现解析。不过,你可能知道,基于栈的算法往往有等价的递归算法,而且递归算法会更简洁。这里我把等价的递归算法写出来。你可以用这个算法把前面的两个例子推演一遍,看看它们为什么是等价的。

/**
 * 解析一个二元表达式,形成一棵AST子树,其根节点的优先级不超过prec
 */
parseBinary(prec:number):Expression|null{
    // 首先解析一个基础表达式,作为左子节点
    let exp1 = this.parsePrimary();
    if (exp1 != null){
        //预读运算符
        let t = this.scanner.peek();
        //获取运算符的优先级
        let tprec = this.getPrec(t.text);
        //只要优先级比当前要求的优先级大
        while (t.kind == TokenKind.Operator &&  tprec > prec){
            this.scanner.next();  //跳过运算符
            //针对优先级更高的运算符,获取一棵AST子树
            let exp2 = this.parseBinary(tprec);
            if (exp2 != null){
                //创建一棵新的AST,把刚刚获取的AST子树作为右子树
                let exp:Binary = new Binary(t.text, exp1, exp2);
                exp1 = exp;
                t = this.scanner.peek()
                tprec = this.getPrec(t.text);
            }
            else{//报编译错误}
        }
        return exp1;
    }
    else{//报编译错误}
}

在实际的编译器中,有的采用的是基于栈的算法,如JDK(以JDK14为例);有的采用的是递归算法,如Go语言编译器(以1.14.2版本为例)。你可以根据自己的喜好采用。

注意,我们这节课只讨论了二元运算。而一元运算,比如++i,或i++,用我们之前02讲中讲过的LL算法已经能够解决,你再去复习就好了。

掌握了运算符优先级算法以后,我们语法解析器实际上就混合了两种算法,主体是用LL算法,能够解析语句等各种成分;唯独留下二元表达式,用运算符优先级算法来实现。这也是像Java、V8、Go语言等这些编译器采用的较为成熟的实现方法。

在完成了这节课的主要任务之后,我再基于运算符优先级算法,把自底向上算法的知识面给你稍微扩展一下,这也让你能够基于更大的背景来理解运算符优先级算法。

了解LR算法

前面也说了,我们今天介绍的运算符优先级算法,属于自底向上的算法。而自底向上的算法,除了运算符优先级算法之外,最著名的就是LR算法家族。LR算法在工作的时候和运算符优先级算法一样,都是采用一个工作区来组装AST的片段。其中读取Token的过程叫做Shift,也就是移进;把工作区里的Token组装成AST片段的过程叫做Reduce,也就是规约。所以,这种特点的算法又被叫做移进-规约算法。

说到LR,你会想起上一节课,我还有一个名词没有给你展开介绍,就是LL。那在这里,我结合LR一起给你解释一下。

LL是中的第一个L是“Left-to-right” ”的意思,代表从左向右处理输入的字符串;第二个 L,是“Left-most Derivation”的意思,也就是最左推导。

那什么是最左推导呢?就是对于语法规则而言,我们每次总展开最左边的非终结符,之后再处理右边的非终结符。

我们还是拿简化版的加法计算的文法做例子。为了便于推导,我这次采用了产生式的写法。此文法要求加法表达式必须用括号括起来,这样就是一个合格的LL文法,避免了左递归:

add -> (add + add)   
add -> IntLiteral

基于这个规则,如果要推导出“((2+3)+4)”这个串,推导顺序是:

add -> (add + add)   //采用第一个产生式展开
    //展开最左边的元素,仍然采用第一个产生式
    -> ((add + add)+add) 
    //从左到右依次展开每个add,采用第二个产生式
    -> ((IntLiteral + add) + add)
    -> ((IntLiteral + IntLiteral) + add)
    -> ((IntLiteral + IntLiteral) + IntLiteral)

理解了LL的意思,那么LR的意思你大概也能猜出来了。第一个字母L仍然是“Left-to-right”的意思,而第二字母R的意思是“Right-most Derivation”,也就是最右推导。你可以把上面的例子用最右推导来试一下其展开过程。

而LR算法的实际执行过程,是最右推导过程的逆过程。也就是从((IntLiteral + IntLiteral) + IntLiteral)一步步的反向做规约,最后规约成一个add非终结符的过程。

如果你想了解LR算法的细节,可以看看《编译原理之美》的18讲

课程小结

今天这节课,我们借助解析二元表达式的任务,首先介绍了递归下降算法的一个短板,就是不能处理左递归语法。

接着,我们介绍了业界编译器为解决二元表达式的解析问题而普遍采用的一个算法,也就是运算符优先级算法。我希望你能像这节课里示范的一样,去推导解析二元表达式的过程,从而帮助你建立直觉认知。在建立了这种直觉认知以后,写成算法就不是难事了。借此,你也能触摸到所有自底向上的算法的思维方式,比如LR算法家族。

最后,我花了很小的篇幅,介绍了LR和LL这两个词汇的准确含义。再次强调一下,我们这门课没有专门花精力追求理论方面的全面性,而是更重视最佳实现技术。

思考题

在这节课中,我们提到了加减乘除等运算是左结合的。那么有没有右结合的运算呢?对于右结合的运算,我们应该如何实现呢?说说你的想法。你也可以看看我们本节的示例代码,看看跟你的想法是否一致。

欢迎你和我一起学习,如果你觉得这节课讲得还不错,也欢迎分享给更多感兴趣的朋友。我是宫文学,我们下节课见。