1. BNF范式
下面来自百度百科:
巴科斯范式(BNF)所描述的语法是与上下文无关的。它具有语法简单,表示明确,便于语法分析和编译的特点。
源码解析使用的算法就是BNF或者其改进算法。
什么是上下文无关文法呢?
可以参考本专栏另一篇文章:【编译原理】什么是上下文无关文法?
为什么要学BNF?
因为BNF是描述上下文无关理论的一种具体方法,所以我们需要学习BNF。可以把它看做一门描述语法的编程语言,通过BNF可以实现此种文法的具体化、公式化、科学化,是实现代码解析的必要条件。
怎么学习BNF?
BNF是前端代码解析的难点之一,对于对数学不敏感的人来说,可能觉得公式很难理解。其实我们可以从计算机专业的角度来掌握它,把高大上的知识用浅显的语言表达出来。
例:四则运算的BNF,<>表示非叶子节点,|表示或者关系,:=就是等于的意思。
<expr> ::= <expr> + <term>
| <expr> - <term>
| <term>
<term> ::= <term> * <factor>
| <term> / <factor>
| <factor>
<factor> ::= ( <expr> )
| Num
容易发现,BNF本质上就是树形分解,分解成一棵名为AST的抽象语法树。
关于AST,可以参考本专栏另一篇文章:什么是AST?
2. 产生式
BNF中的表达式叫做产生式。产生式就是将语法的分解规则表达出来的等式。如
句子 = 主 + 谓 + 宾
将语法规则用产生式描述出来是为了便于计算,产生式可以看作是对语法的数学建模。
产生式的特点是 不断向下分解。这种特点和数据结构中的树是一样的。
通过类比,有:
- 每个产生式就是一个子树,在写编译器时,每个子树对应一个解析函数。
- 叶子节点叫做 终结符,终结符表示不再向下分解;非叶子节点叫做 非终结符。
2.1 递归
产生式分成递归的和非递归的,如果一个产生式用自己来表示自己就会产生递归。
递归地调用自己的定义来定义自己,是无穷尽的。
比如:
- GNU项目的命名GUN NOT UNIX;
- A=A’b’('b’表示字符常量b)。
问:
明明代码文本都是有限长的,为什么还要引入递归这种模型来解析,搞得很复杂?
答:
实际的代码文本确实是有限长的。
但是,递归产生式只是定义了一个计算公式,通过这个计算公式,可以生成无穷种字符串。例如,四则运算可以用递归产生式来描述,它表示你可以写出无数个任意长的四则运算表达式,它是为了用一个公式来描述所有情况,是对实际规则的抽象与归纳。实际中,我们总不可能对每一种四则运算表达式都写一段解析代码,相反地,是所有四则运算表达式的解析都只用相同的解析程序。
递归又分为左递归和右递归,下面分别用图形的方式直观地进行描述。
判断产生式是否存在递归的方法
示意图如下:
子节点和父节点相同叫直接递归,子节点和非父节点的祖先相同叫间接递归。
总之,只要一个节点和其后代节点名称相同,就存在递归。
左递归
图上图所示,产生式一直朝左侧延伸,无法结束,永远不会结束,所以叫左递归。
左递归左侧只有非叶子节点,无法分解出首个叶子节点,也就没有起始符号,解析无法开始。任何解析都必须有起始符号。
右递归
与左递归相反,右递归有第一个叶子节点,没有最后一个叶子节点。
递归可以消除,为什么?
因为某些左右递归可以相互转化,注意不是所有左右递归的都可以相互转化。具体是哪些呢,下面会说到,是包含终结符分支的递归表达式,终结符是转化的桥梁。
为什么只需要消除左递归?
我们需要明确第一个叶子节点的重要含义:如果没有第一个叶子节点,那么就永远无法判断此语法是从哪个字符开始的。所以存在左递归的文法,是无法通过程序解析的,这样的程序无法实现。
相反地,右递归有第一个叶子节点,没有最后一个叶子节点。有第一个叶子节点就可以判断语法从哪个字符开始,但是不知道语法在何时结束。
但是,在实际解析中,因为被解析的文本是有限长的,所以右递归一定会停止。
综上,只有左递归需要消除。
如何消除左递归
目前我们连产生式都不会写,怎么消除?所以接下来先把消除左递归的问题放一放,先弄清楚怎么编写产生式。
2.2 怎么编写产生式
要明确一个重要规则:优先级高的产生式必然是需要先被计算的。 所以优先级越高(如乘除运算)的产生式,在BNF树中越靠近叶子节点。优先级越低(如加减运算)的产生式,在BNF树中越靠近根节点。所以优先级低的产生式一定会被分解成优先级高的产生式。
另外,产生式中不可分解的元素一定是在叶子节点,叶子节点没有子节点,无法分解。
综上:
- BNF算法工作过程是,先向下分解到叶子节点,再从叶子节点沿着分解时的路径/调用堆栈向上反向计算。
- 产生式优先级和在BNF树中的深度成正比。
- 叶子节点有两种,一种是不可分解的元素,一种是优先级最高的元素。
以四则运算为例,双括号()的优先级最高,乘除运算*/的优先级其次,加减运算的优先级最低。从下面的四则运算的BNF可以看出,一个表达式是先分解成±运算,然后分解成乘除运算 */,最后再分解为Expr,这表明我们的理解是正确的。
<expr> ::= <expr> + <term>
| <expr> - <term>
| <term>
<term> ::= <term> * <factor>
| <term> / <factor>
| <factor>
<factor> ::= ( <expr> )
| Num
根据以上思想,下面具体描述产生式的编写过程。
四则运算表达式编写
问:
已知Expr支持形如5+3 * (2+1)的运算表达式,即支持运算优先级,乘除高于加减,支持括号括起来的子表达式。求产生式。
答:
- 5为Num,3为Num,(2 + 1)为含括号的子表达式(Expr)
- Num、(Expr)优先级最高,所以做叶子节点,把他们统称为Factor,即
Factor=Num | (Expr)
- 次高优先级的运算为乘除运算*/,表达式称为Term,基本运算元素为上一步的Factor,有
- 当乘除运算符个数为0时,Term = Factor
- 当乘除运算符个数为1时,Term = Factor * Factor | Factor / Factor,将上式Term = Factor带入得 Term = Term * Factor | Term / Factor(左递归)。
- 当乘除运算符个数大于1时,
Term = Factor * Factor / Factor …* Factor = (Factor * Factor / Factor…) * Factor =Term * Factor
或
Term = Factor * Factor / Factor…/ Factor = (Factor * Factor / Factor…) / Factor = Term / Factor。
于是运算符个数 >= 1时,具有相同产生式。综合得出:
Term = Term * Factor | Term / Factor | Factor
- 最低优先级的运算为加减运算±,表达式称为Expr,基本运算元素为上一步的Term,有
- 当加减运算符的个数为0时,Expr = Term
- 当加减运算符的个数为1时,Expr = Term + Term | Term - Term,把上式代入得,
Expr = Expr + Term | Expr - Term- 当加减运算符的个数大于1时,Expr = Term + Term - Term…+ Term = (Term + Term - Term…) + Term = Expr + Term,或 Expr = Term + Term - Term…- Term = (Term + Term - Term…) - Term = Expr - Term
于是运算符个数 >= 1时,具有相同产生式。综合得出:
Expr = Expr + Term | Expr - Term | Term
消除左递归
上面我们已经推导出四则运算的产生式了,但是产生式中存在之前说的左递归。具有左递归的产生式是无法用来解析代码的,所以需要消除左递归。
- 消除直接左递归(省略号…表示无数次重复):
现有直接左递归:
A = Aa | b (式1),即A=Aa…a (式2)
此生成式向左递归生长,无法确定起始符,故无法用于代码解析。
根据式1可知,A可以用b等价替换。这里将式2等号右边的A用b替换掉,即: A = ba…a
现在,b为起始符,a递归向右生长,故为右递归,即消除左递归成功。
由此可见,终结符 b 是左递归转右递归的桥梁。
- 消除间接左递归:
思路就是使用变量代换将间接左递归写成直接左递归,然后消除直接左递归。具体可以参考这篇文章:语法分析之左递归消除一
3. 编码实现
可以参考这篇文章:手把手教你做一个 C 语言编译器(4):递归下降
4. 总结
BNF是一种描述语法结构的公式,它是将一条语法从上向下分解的过程。但是我们的实际需求是,将代码文本文件中的字符串流,从下而上识别并解析出其对应的语法树,实现编译功能。这两者并不矛盾,不管从上而下分解,还是从下而上解析,我们都需要BNF。
需要做特别说明的是:以上只是在已知结论的情况下,对结论的一种理解。能够从无到有地将BNF创建并完善,绝不是一件容易的事情。
本文原创发布于Qt未来工程师。