写这个系列的原因
今天在写课后练习时候,写到一半突然开始怀疑人生
为什么要first集?为什么要follow集?啥是LL(1)?我在干嘛?语法分析是到底是在干嘛?
好像成了写作业✍的机器了,不行!!!!!!!
一定要搞明白!!!
不能做不明不白的事儿!!!
语法分析的地位
编译器前端不外乎就是:
- 词法分析
- 语法分析
- 语义分析
而其中最重要的,当属语法分析
我们前面学过了词法分析,吃进去的是lex
(词素),吐出来的是token
那我们的词法分析呢,就是拿着前面词法分析输出的Tokens,通过比对语法规则,输出可供语义分析使用的AST
(抽象语法树),大概是这样:
Tokens -> 语法分析器 -> AST
↑
语法规则
输入是有两个的
所以到底什么是语法分析?我举个例子:
- 原句:王帅真喜欢听周杰伦的音乐
- 词法分析后:{王帅真,喜欢,听,周杰伦,的,音乐}
- 语法分析后:(这个是有错的,我在这里只是想突出“结构化”这个特性)
喜欢
/ \
王帅真 听
|
的
/ \
周杰伦 音乐
词法分析,做分词(Tokens当然不止是分词,这边只是便于理解这么说);语法分析,把分完的词结构化(在分析的过程中发现错误会报错,也就是我们经常看到的syntax error!
)
我们甚至能把它函数化!
like("王帅真",listen(music("周杰伦")))
说的再土一点,学外语的时候不是得学语法嘛,英语语法,分主谓宾,什么7788的从句,对吧
语法规则是个啥
语法规则是啥?也就是一堆通过数学化过后的产生式,也就是我们说的上下文无关文法,比如:(例子不严谨,我只是说个大概)
开始 -> 主语 谓语 宾语
主语 -> n. | null
谓语 -> v. | adv. v.
宾语 -> n. | adj. n. | null
n. v. adj. adv. 属于终结符,也就是推导到这里,该停了(null表示可为空)
开始、主语、谓语、宾语属于非终结符,还得继续推
|
代表或者,选择其一即可
我来做个示范:
输入是:【牛吃草】
开始 -> 主语 谓语 宾语
主语 -> 牛(n.)
谓语 -> 吃(v.)
宾语 -> 草(n.)
符合语法规则,我们可以输出true,
并生成这样一棵树:
开始
/ | \
主语 谓语 宾语
| | |
牛 吃 草
再来试一个有null的:
输入是:【我草】
开始 -> 主语 谓语 宾语
主语 -> 我(n.)
谓语 -> 草(v.)
宾语 -> null
符合语法规则,我们可以输出true,
并生成这样一棵树:
开始
/ | \
主语 谓语 宾语
| | |
我 草 null
我甚至可以:
输入是:【草】
开始 -> 主语 谓语 宾语
主语 -> null
谓语 -> 草(v.)
宾语 -> null
符合语法规则,我们可以输出true,
并生成这样一棵树:
开始
/ | \
主语 谓语 宾语
| | |
null 草 null
我们只要从左到右遍历树的叶子节点,就能得到原来的句子
- 牛吃草
- 我草
- 草
(最左)推导是个啥
草,你说的例子太简单了,那我们就搞复杂点的!
文法:
E -> E+T | T
T -> T*F | F
F -> (E) | id
其中除了id(标识符,通常作为我们的变量名,函数名),+,*,(,),都是非终结符
输入是:【3+4*5】
E -> E+T
-> T+T
-> T+T
-> F+T
-> id+T
-> id+T*F
-> id+F*F
-> id+id*F
-> id+id*id
符合语法规则,我们可以输出true,
并生成这样一棵树:
E
/ | \
E + T
| /|\
T T * F
| | |
F F id
| |
id id
叶子节点从左到右:id + id * id
懵了没?咱们一步步分析:
-
不知道你有没有发现,第一个式子E的本质是:
T+T+...+T+T
-
第二个式子T的本质是:
F*F*...*F*F
stop!我们代回去!
- E -> F * F * …* F F + F * F * … F F + … F * F * … F F + F * F * … F *F
正是我们平时写的那种有优先级的四则运算!!!别急!
- 第三个式子F算是个完善:数字 或是 (E),也就是一个式子,计算完后的那个结果!(因为括号优先级更高)
有感觉了没?我上面用的推导方法其实就是我们说的自顶向下的最左推导
开始时把最左边的推导到到底,撞了南墙再回头,推第二条路,以此类推,直到推完,这有点像一个DFS的过程
敌人:无限递归
我们理解起来容易,因为我们人聪明,懂得变通,机器可不是,你有没有想过分析器可能是这么玩的:
E -> E+T
-> E+T+T
-> E+T+T+T
-> E+T+T+T+T
-> ...
-> E+T+T+T+T+...
-> ...
没完了xdm,递归没有base-case,一直这么下去出不来就GG了,所以我们需要消灭它!!干掉递归!
我们看看上面那个产生式E -> E+T | T
,我们可以这么玩:
E -> TE'
E' -> +TE' | null
这样一来左递归就被挡掉了(我只能保证在E这没有左递归了,T那里有没有要另说了,如果仍有递归,继续要消去)
再举个例子:A -> Ab | c
怎么消去
A -> cA'
A' -> bA' | null
这样就行了
为什么行?你说行就行?我们可以来验证一下:
我们先假设我们左递归了
A -> Ab
-> Abb
-> Abbb
-> Abbbb
-> ...
-> Abbbb...
-> ...
如果我们要让它停下,就是走一个c
,形如cbbbbbb...
(0-n个b)
再上面那个例子就是走一个T
让它停下来,形如T+T+T+T+T+..,+T
(1-n个T),其实我们上面也说过了
冷静一下,我们确实解决了左递归的问题,那你想想,为什么会有这种情况?
敌人打哪来,怎么打赢的
递归的产生
回想一下前面说的条件:
-
自顶向下分析
-
最左推导(死磕左+DFS)
-
右部最左竟是我自己(比如A->Ab)
为什么我们可以解决这种情况呢?因为我们有备选方案!
比如A->Aa|B
,备选方案只要不是A打头就行,不然提取个公因式A,又绕回来了
(【A->AB|AC】 ==【A->A(B|C)】 == 【A->AE E->B|C】)
因为我们知道它最后肯定是Aaaaaaaa...aaa
,并且要让他停下,就得在开头开个B
所以我们可以直接把B放在开头,形如A->BA'
,而A'
就代表了aaa...aaa
那部分
而我们知道a是0到n个,怎么表示呢?要么直接为空停下,要么继续递归,但是是右递归!
???右递归???这不还是递归吗????凭什么左递归不行右递归在这里就行?气抖冷!
别急,听我解释
代码解释
我用代码写给你看,保证一目了然
(代码为go,很清晰的,getNextToken()
表示匹配成功并吃入一个token,当前token是输入tokens的第一个元素,比如abbb
就是a,getNextToken()
完就是b,像指针一样的)
我们继续看这个例子A->Ab|c
// A->Ab
func A() {
A()
if token == "b" {
getNextToken()
} else {
// syntax error
}
}
假如我们的输入是:bbbbbbbbb
,我们就会一直在里边出不来,疯狂调用A()
又假如我们的输入是:abbbbbbbbb
,我们一样会一直在里边出不来,疯狂调用A()
但如果这条语句是这样
// A->bA
func A() {
if token == "b" {
getNextToken()
A()
} else {
// syntax error
}
}
这样一来,左递归一消除,就带来了预测性
假如我们的输入是:bbbbbbbbb
,我们会先判断当前token是不是b,当然在这边是b,取下个b并调用A()
,然后持续一直进行下去,那这样就不会递归吗?不会,因为token吃完就没有了,就停止了,EOF了
再接着看,又假如我们的输入是:abbbbbbbbb
,我们先判断当前token是不是b,当前token是a,好的,不是,报错,函数结束!
递归迎刃而解!
代回
回到上面那个产生式E -> E+T | T
的递归消除:
E -> TE’
E' -> +TE' | null
我们把T简化为终结符num(即数字),方便我们看
E -> numE’
E' -> +numE' | null
我们尝试用代码看看:
// E -> numEE(这里用EE表示E')
func E() {
if token == "num" {
getNextToken()
EE()
} else {
// syntax error
}
}
// E' -> +numE' | null
func EE() {
if token == "+" {
getNextToken()
if token == "num" {
getNextToken()
EE()
} else {
// syntax error
}
}
}
再来看几个例子
假如我们的输入是:2+3+4
,我们会先判断当前token是不是数字,这边是2,正确,取下个token并调用EE()
;接着匹配加号,匹配上了;取下个token并匹配数字,匹配上了,递归调用EE()
…
又假如我们的输入是:+33
,我们先判断当前token是不是数字,当前token是+,好的,不是,报错,函数结束!
又假如我们的输入是:2++3
,我们先判断当前token是不是数字,当前token是2,好的,走EE();继续判断是不是加号,是加号,继续判断是不是数字,不是!!!还是加号!!这时候递归停止,我们就可以报错了!
干掉左递归爆的东东
- 防止无限递归爆栈,程序挂掉
- 预测性
- 报错
容易被忽略的敌人:回溯
什么?还有敌人?是的,因为它容易被忽略
来看这么一个文法:
S -> xAy
A -> **|*
其实就是python的一个语法糖,比如3*4
表示3乘4,3**4
表示3的4次幂
这会存在什么问题呢?假设我们输入3*4
x匹配3,成功;*去匹配A,假设开始走**
,但它只有一个,你能说他错了吗,不能,它还能试一下旁边的的 * 呢!
这其中,就存在失败重试,原路返回,也就是我们说的回溯
也就是我们匹配的成功,没结束前,永远都是暂时的成功,如果匹配了一长串,到最后发现失败了回溯,那个开销是非常非常大的!
所以我们不仅要消除左递归,还要消除回溯
产生原因
当同一个非终结符的多个候选式存在共同前缀时,将导致回溯
A -> **|*
有共同前缀 * ,所以我们可能就会存在开始可以,到后面发现不行的情况
万一这是个A -> aaaaaaaaaac |aaaaaaaaaab
,那就难受了,开销大的一批
解决
很简单,大家小学二年级就学过的(不是毕导
提取公因式!!!!
比如上面的A -> **|*
,可以改写成:
A -> *A'
A' -> * | null
这样就干掉回溯问题了!
缺陷
是啊,听着是这么回事,可是具体怎么做这个分析器呢?分析器内部究竟怎么分析呢?总不能像人这么看着推导吧!
没错,人是聪明的,但是机器是死的!我们可以用眼睛看出来怎么推,机器不行!
所以呀,
欲知后事如何,请听下回分解!?