Rust 实现一个表达式 Parser(6) 文法定义及其简单优化

2,185 阅读11分钟

至此我们已经完成了 Lexer 的部分

接下来就可以进入到 Parser

但是在正式动手之前还需要先简单的定义一下文法

PS:

考虑到读者不一定接触过, 因此以下的内容

  1. 文法描述语言均不是标准的 BNF 范式
  2. 文法的推导过程不一定标准
  3. 尽量减少理论概念的引入
  4. 间接左递归放到文末介绍

一切都是通俗易懂为优先

文法是什么

文法是实现一个编译工具必不可缺的部分, 设想我们在解析的是自然语言

我不是程序员
他是老师
它是电脑

以上三句话都是完整的中文句子, 然而我们可以发现, 通过组合不同的字词, 句子可以产生完全不同的意思, 但以上三句话都是在对某个事物下定义, 存在一定的共性

中文存在数不清的字词组合, 倘若我们需要编写一个能够解析中文的编译前端, 在这里能够穷举么?

穷举是不现实并且不聪明的, 以上句子能够被人们读懂的原因是因为它们都符合中文语法, 我们将其简单拆解, 如下

我 不是 程序员
他 是  老师
它 是  电脑

如上便可以掏出我们小学学习的语法知识, 分别是 主语, 谓语, 宾语, 而 主谓宾 也就构成了一个完整的简单句子

这里我们将 , 不是, , 程序员 等字词视为 Token, 可以简单推导出下面的语法, 以下的管道符 |或者 的意思

定义句 -> 主语 谓语 宾语

主语 -> 名词
谓语 -> 系动词
宾语 -> 名词

名词 -> "我" | "他" | "它" | "程序员" | "老师" | "电脑"
系动词 -> "是" | "不是"

如上简单的语法定义就可以代表几乎所有的简单定义句, 我们就算不拓展名词和系动词, 也可以重新组合出其他句子

他 不是 程序员
他 是   程序员
它 是   程序员
...

这就是文法, 简单下个定义, 文法是一种用来描述合法句子的构成规则的形式化语言

而文法中有两个简单的小概念, 简单区分一下

  • 能展开或者能继续推导的, 则称为 非终止符, 如上例中的 主语, 名词
  • 无法再进一步展开或者推导的, 则称为 终止符, 如上例中的 "我", "程序员"

文法工作方式

那么具体的, 文法该如何对句子产生约束作用呢

以上面的例子为例, 我们会发现在前面我们很自然的就将后面的 名词, 系动词 套入 主语, 谓语, 宾语, 再将他们套入前面的定义句, 最终得到我们期望的句子

而在本专栏的系列文章中, 文法的工作方式是以上这个过程的逆过程

文法从起点开始, 不断的按照定义的规则以及某种规律进行推导展开, 最终若能够得到给定的句子, 则认为该句子合法, 否则不合法

举例如下

文法定义如下

定义句 -> 主语 谓语 宾语

主语 -> 名词
谓语 -> 系动词
宾语 -> 名词

名词 -> "我" | "他" | "它" | "程序员" | "老师" | "电脑"
系动词 -> "是" | "不是"
"它不是老师" 这个句子是合法的定义句么

定义句 => 主语 谓语 宾语
      => 名词 谓语 宾语
      => "它" 谓语 宾语
      => "它" 系动词 宾语
      => "它" "不是" 宾语
      => "它" "不是" 名词
      => "它" "不是" "老师"

可以从定义句推导出 "它不是老师" 这个句子, 是合法的

文法定义

上面简单介绍了文法的定义, 接下来我们来推广到编程语言中, 先从 表达式 开始

我们的表达式支持四则运算, Token 类型已经提前定义好了, 那么很快速的就可以写出下列文法

Expr -> Add
      | Mul
      | Number
      | "(" Expr ")"
      ;

Add -> Expr ("+" | "-") Expr ;

Mul -> Expr ("*" | "/") Expr ;

Number -> NUM

如上就是初步的文法定义, 我们直接定义出三种语法, 分别是 Expr, Add, Mul

这里的重点在于, Expr 可以产生 Add, Add 又可以产生 Expr, 这样的定义方式是递归的, 因此在进行文法展开的时候, 会出现下面的情况

"1 + 2 + 3" 是不是符合文法的表达式??

Expr => Add
     => Expr ("+" | "-") Expr
     => Expr "+" Expr
     => Add "+" Expr
     => Expr "+" Expr "+" Expr
     => Number "+" Expr "+" Expr
     => "1" "+" Expr "+" Expr
     => "1" "+" Number "+" Number
     => "1" "+" "2" "+" "3"

是符合文法的

处理算符优先级

为什么

以上定义出来的文法目前有一个很明显的问题, AddMul 是同一层级的, 这意味着 +, - 拥有和 *, / 相同的优先级, 我们直接看看当前文法处理表达式 "1 + 2 * 3"

"1 + 2 * 3"

Expr => Add
     => Expr "+" Expr
     => Number "+" Mul
     => Number "+" Expr "*" Expr
     => Number "+" Number "*" Number
     => "1" "+" "2" "*" "3"

我们将语法推导过程简单转化为树形结构, 如下

grammar_right_tree.jpg

看似没有问题, 但还存在下面这种推导方式

"1 + 2 * 3"

Expr => Add
     => Expr "*" Expr
     => Add "*" Expr
     => Number "+" Expr "*" Expr
     => Number "+" Number "*" Number
     => "1" "+" "2" "*" "3"

其推导树如下

grammar_left_tree.jpg

这下出现大问题了, 我们都知道遍历 AST 会以 DFS 的方式, 这意味着若是遍历下面这棵树, 会产生错误的计算顺序

即, 1 + 2 * 3 算成 (1 + 2) * 3

怎么做

我们目前的问题是文法的定义无法明确计算顺序以及不同表达式的优先级问题, 在树形结构中就体现在我们总是希望优先级高的表达式作为优先级低的表达式的子节点

那么我们可以将优先级高的表达式作为优先级低的表达式的产生式, 文法修改如下

修改前

Expr -> Add
      | Mul
      | Number
      | "(" Expr ")"
      ;

Add -> Expr ("+" | "-") Expr ;

Mul -> Expr ("*" | "/") Expr ;

Number -> NUM

修改后

Expr   -> Add ;

Add    -> Add ("+" | "-") Mul
        | Mul
        ;

Mul    -> Mul ("*" | "/") Factor
        | Factor
        ;

Factor -> "(" Expr ")"
        | Number
        ;

Number -> NUM ;

这样修改之后, 加减法表达式总是优先展开, 这意味着加减法表达式总是作为父节点, 而乘除法表达式再次展开时就会作为加减法表达式的子节点, 也就达到了我们的目的

修改后的文法推导刚才的表达式, 如下

"1 + 2 * 3"

Expr => Add
     => Add "+" Mul
     => Mul "+" Mul
     => Factor "+" Mul
     => Number "+" Mul
     => "1" "+" Mul
     => "1" "+" Mul "*" Factor
     => "1" "+" Number "*" Number
     => "1" "+" "2" "*" "3"

消除左递归

目前的文法还存在一个大大大问题

是什么

我们抛开例子, 结合前文提到的 文法从起点开始, 不断的按照定义的规则以及某种规律进行推导展开, 最终若能够得到给定的句子, 则认为该句子合法, 否则不合法, 来观察一下现在的文法

Expr   -> Add ;

Add    -> Add ("+" | "-") Mul
        | Mul
        ;

Mul    -> Mul ("*" | "/") Factor
        | Factor
        ;

Factor -> "(" Expr ")"
        | Number
        ;

Number -> NUM ;

我们对以上文法的进行局部抽离, 单独来看更加清晰, 此处为了进一步简化, 将 Mul 替换为 Number, 且 ("+" | "-") 简化为 "+"

Add -> Add "+" Number ;

我们按照之前的流程, 对该文法定义进行推导展开, 如下

Add => Add "+" Number
    => Add "+" Number "+" Number
    => Add "+" Number "+" Number "+" Number
    => Add "+" Number "+" Number "+" Number "+" Number
    => ...

可以发现, Add 可以无止境的展开自己, 最终陷入一个死循环, 这个问题会导致 Parser 解析过程爆栈, 这就是著名的 左递归(Left Recursion)问题

为什么

再次观察这个出现左递归问题的文法

Add -> Add "+" Number ;

我们发现, 箭头左边的非终结符和右边的第一项一模一样, 并且在推导过程中, 也总是由箭头右边的非终止符 Add 展开, 产生一个 Add 再产生一个 "+" Number, 而推导前后只会增加 "+" Number, 不会导致任何符号的减少, 这意味着, 这是一匹不需要吃草就能跑的马

怎么做

已经得知问题出现在箭头右边的第一项 Add, 那么我们想办法将其替换掉或者移动到别的地方就可以了, 但是要怎么保证不改变原有语义呢

直接上公式, 这个公式几乎可以解决所有的左递归问题

A>AαβA -> A \alpha | \beta

改写为

A>βAA -> \beta A'

A>αAϵA' -> \alpha A' | \epsilon

对公式进行简单的解释

  • α\alphaβ\beta 表示任意终结符
  • AAAA' 表示任意非终结符
  • ϵ\epsilon, 表示空匹配, 简单视为空即可

做一下

上面引出了最重要的解决左递归的公式, 下面直接实践, 现有文法

Add    -> Add ("+" | "-") Mul
        | Mul
        ;

Mul    -> Mul ("*" | "/") Factor
        | Factor
        ;

Factor -> "(" Expr ")"
        | Number
        ;

Number -> NUM ;

我们仔细观察会发现, AddMul 的语法定义存在左递归的问题

此处直接将 Mul 视为终结符

Add -> Add ("+" | "-") Mul | Mul ;
       --- ---------------   ---
        |         |           |
        A         a           b

带入公式得到

Add  -> Mul Add' ;
Add' -> ("+" | "-") Mul Add'
      | <empty>
      ;

Mul 进行类似的处理, 得到如下文法

Mul  -> Factor Mul' ;
Mul' -> ("*" | "/") Factor Mul'
      | <empty>
      ;

最终文法如下

Expr   -> Add ;

Add    -> Mul Add' ;
Add'   -> ("+" | "-") Mul Add'
        | <empty>
        ;

Mul    -> Factor Mul' ;
Mul'   -> ("*" | "/") Factor Mul'
        | <empty>
        ;

Factor -> "(" Expr ")"
        | Number
        ;

Number -> NUM ;

化简文法

目前的文法已经可以正常工作了, 但是还存在优化的空间, 如下

我们观察一下这两个文法定义

Add  -> Mul Add' ;
Add' -> ("+" | "-") Mul Add'
      | <empty>
      ;

若是将 Add' 展开, 我们会发现它会长成这样

Add' => ("+" | "-") Mul Add'
     => ("+" | "-") Mul ("+" | "-") Mul Add'
     => ("+" | "-") Mul ("+" | "-") Mul ("+" | "-") Mul Add'
     => ...

上面这个式子会一直循环, 直到展开时选择了一个 ϵ\epsilon, 也就是空匹配 <empty>, 或者从一开始就直接匹配 ϵ\epsilon, 我们用正则表达式来描述这种情景, 就是下面这样

Add' -> (("+" | "-") Mul )* ;

正则表达式中, * 通常表示匹配 0 次或者更多次, 刚好对应了这个场景, 我们再把这条文法合并到 Add 的文法中, 如下

Add -> Mul (("+" | "-") Mul)* ;

Mul 的文法也进行类似的处理, 我们就得到了最终化简过后的文法

Expr   -> Add ;

Add    -> Mul (("+" | "-") Mul)* ;

Mul    -> Factor (("*" | "/") Factor)* ;

Factor -> "(" Expr ")"
        | Number
        ;

Number -> NUM ;

直接左递归 & 间接左递归

直接对比一下, 还是很好理解的, 实际上原本并不打算介绍间接左递归的解决方法, 但是想起自己写代码格式化工具项目的时候, 碰到的左递归问题基本上都是间接左递归问题, 因此还是在这里提一嘴

直接左递归
A -> Aa | b ;

间接左递归
A -> B | b;
B -> Aa ;

间接左递归的消除其实大体上来说还是正文提到的公式

A>AαβA -> A \alpha | \beta

改写为

A>βAA -> \beta A'

A>αAϵA' -> \alpha A' | \epsilon

但是间接左递归需要简单的将中间项消去, 以上面那个例子来说

A -> B | b ;
B -> Aa ;

改写为

A -> Aa | b ;

消除左递归

A  -> b A' ;
A' -> aA' | <empty> ;

在这里列举另一种经常碰到的情况, 没有很好的例子, 就随手写了

A -> Bb | Cc | a ;
B -> C  | a ;
C -> Aa ;

可以发现这里存在一个间接左递归, A -> B -> C -> A, 解决方案如下

A -> Bb | Cc | a ;
B -> C  | a ;
C -> Aa ;

B 带入 A 得到
A -> Cb | ab | Cc | a ;
C -> Aa ;

C 带入 A 得到
A -> Aab | Aac | ab | a ;

发现这里存在两个直接左递归, 直接上公式

A -> Aab | Aac | ab | a ;
      --    --   --   -
      |     |    |    |
      a     a    b    b

消除左递归后如下
A -> abA' | aA' ;
A' -> abA' | acA' | <empty> ;

总而言之, 解决间接左递归的方式就是转化为直接左递归

总结

本文完成了基础的文法定义及其相关的处理和简单的化简工作, 但是实际上这里面有很多很复杂的操作, 也存在很多很晦涩的理论, 为了降低阅读门槛, 在正文的描述中会存在一些不严谨不规范的地方

不过从实践角度来考虑的话, 好歹是正确实现了, 就是说起码能跑

Q&A

Q: 怎么讲的这么难懂?

A: 这部分真的很麻烦, 本文已经尽力避免引入过多的理论, 尽量从实操出发了, 但是不可避免的需要用到一些公式和形式化描述之类的东西, 这些实在无法避免

Q: 怎么觉得你写的像是在读 PPT 似的?

A: 你别说, 这部分真的就大部分都是机械性的工作, 我甚至觉得我列个提纲然后把关键公式和模板之类的给出来会更好, 文中提到的优先级的处理方法和左递归的处理方法基本都是固定的, 完全可以把目前这个处理流程作为一个模板, 是的没错, 我写其他编程语言的文法的时候, 用的也是这三板斧