屠龙日记0x00:语法分析时,我们到底在干嘛

写这个系列的原因

今天在写课后练习时候,写到一半突然开始怀疑人生

为什么要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

这样就干掉回溯问题了!

缺陷

是啊,听着是这么回事,可是具体怎么做这个分析器呢?分析器内部究竟怎么分析呢?总不能像人这么看着推导吧!

没错,人是聪明的,但是机器是死的!我们可以用眼睛看出来怎么推,机器不行!

所以呀,

欲知后事如何,请听下回分解!?

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值