You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

16 KiB

03支持表达式解析表达式和解析语句有什么不同

你好,我是宫文学。

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

其实我是刻意为之的故意把表达式的解析往后推迟一下。原因是表达式解析特别是像“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这两个词汇的准确含义。再次强调一下我们这门课没有专门花精力追求理论方面的全面性而是更重视最佳实现技术。

思考题

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

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