语法分析(3):用户体验超差的自顶向下语法分析

0 阅读1分钟

本文是本人撰写的编译原理讲义。

本系列讲义适用于:被强迫学习编译原理前端,或者希望弄明白如何做科研的人。

1. 自顶向下语法分析?那不就是深度优先检索?

两人忙活半天,把客户需求简化整理成文法G[S]了:

G[S]:S→while(E)S 
S→if(E)S (else S|ϵ) 
S→{S} 
S→S;S 
S→ϵ 
E→a=3|b=2

师傅:“……所以,所谓语法分析实际上就是把一个已经被一维化的句子,重新恢复它的高维结构。而所谓高维结构,实际上对应的就是语法树。”

1.1 自顶向下分析的直觉

等一下师傅,与其这么麻烦去从废墟中恢复原来的高维结构,为什么不直接重建一个?我意思是,能不能像是词法分析那样,从开始状态出发,一路开枝散叶,一直到所有叶子节点刚好都对应上句子上的单词?

就拿化简后的客户文法G[S]来说,如果要还原if (a=3){;}else{;}这么一个句子的语法树,不如我试着从左到右去构建出来呢。

首先,从S出发,因为要匹配的句子开头是i开头,可以义无反顾选择if(E)S (else S|ϵ)这个路线进行分叉。

接下来的“if(”都能被顺利匹配,直到E这个非终结符。

这时候遇上“a”这个输入符号,之后选择E→a=3,顺利把即将输入的a=3给匹配上了。

这时候E结束了,返回到上一层,再把“)”也匹配了,来到S这个非终结符前面。

这个时候由于即将输入的符号是{,义无反顾只能选S→{S},匹配完{之后,又来到S这里。

接下来输入的符号是;,但S的展开式没有以;开头的,只能先试着用S→ϵ。

啊不太行,这样就完全匹配不了了。。;只出现在S→S;S这句话里面,那应该是先让S→S;S,然后让第一、第二个S→ϵ,这样就可以顺利匹配完后面的}else了。

最后的{;}跟着前面一样的套路,照板煮碗即可。这样我们就构造出了一个语法树了呀,完美!

总结下来,这个过程可以表述为:从开始节点出发,每次检查这个节点有什么样的展开,然后看一眼即将要匹配的符号串,然后选择那个能处理的分支。遇到终结符,就直接匹配;遇到非终结符,就重复上述过程。如果这层成功匹配完成,就返回到上一层。

我甚至感觉,这跟深度优先检索有那么一点像了!就像前面说的,如果匹配不上,就往回走呗。如果每条路都走不通,那就是语法解析失败。

师傅:“好像是那么一回事,不过我总感觉有很多特殊情况没处理好。要不这样吧,你还是按照老方法,找些简单的情况试着总结一些通用方法出来。”

回到工位,一想到要手动构造各种些稀奇古怪的样例就头痛,干脆写一个随机语法生成器,从随机生成的案例中试试水吧!

习题1

还原句子pcad在文法G[S]下的语法树:

𝑆→𝑝𝐴 | 𝑞𝐵 𝐴→𝑐𝐴𝑑 | 𝑎 
𝐵→𝑑𝐵 | 𝑏

啊这个简单,G[S]这个文法里面,

1.同一个非终结符的右部由不同终结符开始,而且

2.每个产生式右部的首个符号都是终结符。

这样直接每次都能百分百选中,选不中直接分析失败。妥妥的!首先看到p就选S1(S的第一条产生式的简写,下同);然后匹配了p之后,来到A,递归;然后匹配即将输入的c,又来到A,递归;匹配了a,这第二层的A结束返回,回到第一层的A,接着把后面的d也匹配了,完美。来吧下一题!

习题2

还原abed在文法G[S]下的语法树:

G[S]:SaAbc|aB 
Aba 
BbeB|d

abed这个句子,第一个符号是a。。。

等一下,怎么上来就把上面的条件1破坏了??S的两个产生式右部都是a开头,那还怎么选?

要不,反正都是a开头的,给文法来个魔改,把a这个公共前缀提出来,等到后面真正出现分叉了再算?也就是写成S'→a(Abc|B)。。阿不行后面一大坨的太难看了,干脆新引入一个非终结符好了:

G[S']:S→aC 
A→ba 
B→beB|d 
C→Abc|B

得,这下第二个条件也被破坏了,现在产生式右部的首个符号不是终结符,那只能递归再不断试错了呗。简简单单,下一题!

习题3

还原dd在文法G[S]下的语法树:

S->XT|YT 
T->S| ϵ 
X->A|B 
Y->C|D 
A->a 
B->b 
C->c 
D->d

怎么又要递归啊,算了无脑过。。

等一下!

现在每个符号我得递归两层才能找到对应的符号,是不是有点效率太低了。。万一哪个不长脑子的客户写了三四层甚至七八层递归,甚至一不小心给我整个回环,那这效率不是逆天了。。有没有什么办法可以提前在递归之前就知道自己选哪条路呢。。

1.2. Select、First与Follow集合

比如现在对于Y->C|D这条分叉路而言,现在是不往下迭代我就不知道这条路后面会有什么终结符,那不如提前把这条路会遇到啥提前算出来,作为路标,再层层上报!这样不就可以避免递归调用,直接根据即将输入的符号选择对应的路线了?

S->【a,b】XT|【c,d】YT 
T->【a,b,c,d】S| 【?】ϵ 
X->【aA|【bB 
Y->【c】C|【d】D 
A->a 
B->b 
C->c 
D->d

Emm, T->【?】ϵ这个地方怎么填?这已经是句子结尾了,按道理是没有符号了。。但似乎引入一个不常用的符号来做结束标记会比较好。(瞄了一眼键盘)那就决定是你了,#!啊好像$也挺顺眼,到时候用到哪个算哪个吧。(实际情况如此,确认两种句子结束符都有人用。他们不表示真正的井号和美元符)

所以T->【?】ϵ应该写成:T->【#, $】ϵ

还有就是,这个中括号的内容怎么命名好呢。。现在是我需要根据即将输入的符号确认要选择哪一条路,而这个符号会出现在两条路各自的路牌标记中,就像是判定一个元素是否出现在集合那样。。那干脆就叫它Select集合好了!

这么说来,如果一旦一个符号同时出现在一个分叉路上的多个Select集合中,那么就会出现选择困难,就必须要进行试错、递归和回溯了。

啊开心,这个新概念貌似挺有搞头。知道怎么用之后想想怎么算出来这个集合吧。

根据刚刚的直觉,对于像A B这种简单的非终结符,简单得很,直接把对应串生成的首个终结符标记出来就好。

首个终结符这个词估计后面也挺常用,干脆也定义一个函数First来表示好了。

让我想想这个First函数有什么显而易见的性质:对于文法A->β| γ,有

  • First(A->β)=First(β) //A->β这条路的首符号可不就是β这个串的首符号嘛,这样可以扩展括号内容的定义域,知道可以放什么东西进去运算

  • First(A)=First(β)∪First(γ)//A可能出现的首符号,可不就是β γ这两个串的首符号并集嘛

Emm,这只是函数的性质,到底它是个啥还是不清不楚,不够接地气。

回想一下在正则文法中用到的几个基础组件:

终结符表:V_T,非终结符表V_N,以及两者的并集总符号表V=V_T ∪ V_N

这下可以借用描述法集合定义的方式,写出First函数的(⚠️草稿)定义了

⚠️First(β) = {a│β->aγ, a∈V_T, β,γ∈V∗ }

其中,V∗代表了终结符和非终结符组成的任意长度的串,包括空串ϵ

Emm,这个First的(草稿)定义简直是清晰明了,直接通过把符号a限制为终结符表的元素,套死了它作为串β的首个终结符的定义;此外,aγ中的γ也说明我不关心它除了头部以外的其他部分。

等一下,那我现在要是拿这个First定义去计算First(ϵ), 这还怎么算。。

算了,为了确保这个函数只是求出括号内容的首个非终结符,干脆直接通过并集简单打个补丁算了。另外,为了确保β即使不是一步就可以推导出空串,也能继续使用这个定义,干脆一步到位,给推导这个步骤也形式化定义一下:

=>*表示任意多步推导

那类比闭包表示0此或任意次,可以得到其他类似概念的定义:

=>+表示至少一步的推导

=>表示单单一步的推导

这样就有了

(正式版)First函数的定义:

First(β) = {a│β=>*aγ, a∈V_T, β,γ∈V∗ }∪{ϵ|若β=>*ϵ}

Emm,好好好,不愧是我。这形式化内容写多了越发显得我专业起来了哈哈哈哈。

但是等一下,这样的话#, $这两个玩意儿又是怎么冒出来的来着??这是First(β)为空的时候,从串β的后面冒出来的首个终结符号?

感觉可以借助上面的First函数定义,直接定义Follow集合:

Follow集合的定义:

若S=>∗μAβ, 则FOLLOW(A) ={a|S=>∗ μAβ, a∈V_T, a∈FIRST(β), μ,β∈V_T*}∪{$|若β=>∗ϵ}

对着First和Follow集合这两堆长长的定义,有点分不清东南西北了。为啥我要定义这么两大坨东西来着?

我最开始想要搞Select集合,是说假如我遇到的实义符号如果在其中的一个集合中,这就成为了我选择这条路的理由。

如果这条路不生成空串,那么自是直接找到这条路径的First集合即可。

但如果如果这条路推出了ε,那么FIRST(α)里面就会包含有{ε}。但在真实的串中是不可能存在ε这个符号的,那这个select集合就不应该包含这个元素了呀!

而且就拿T->ϵ这条来讲,既然这条路为空,那么选择它的理由,应该是它后面出现的符号正是即将要面对的符号所需要的。那对于更一般的情况A→α,现在假定α有可能推出来空串,那么选择这条路的可能就应该是α首个可能冒出来的终结符,外加上α一旦变成空串后,它身后可能冒出来的那些非终结符!

等一下,α身后冒出来的非终结符,应该是FOLLOW(A)还是FOLLOW(α)来着?。。

Emm, 应该是FOLLOW(A), 因为所以如果求FOLLOW(α),α可能出现在其他文法中,这样可能求出来一些和FOLLOW(A)毫不相关的结果,这样就出错了。况且,现在既然α出现在A所产生串的末尾,那么当然是应该从其他产生式中A在哪些,才能看出来FOLLOW(A)有哪些

所以,SELECT(A→α)=(FIRST(α)-{ε})∪FOLLOW(A)!

这下,总算可以完整定义Select集合了:

Select集合的定义:

假设有A→α, α∈V∗。

若α不会推出ε,则SELECT(A→α)=FIRST(α)

若α可能推出ε,则SELECT(A→α)=(FIRST(α)-{ε})∪FOLLOW(A)=FIRST(α)∪FOLLOW(A)-{ε}

2. LL(1)文法:为了效率,拒绝回溯

2.1LL(1)文法定义与条件

“需要回溯可不好啊,这文法可是客户自己天马行空写成的,谁说得准里面会不会藏着回环。在这样的文法中回溯就很可能导致死循环呐!”第二天来上班,师傅没听几句汇报,就提出了自己的担忧。

“既然你都知道发生回溯的条件就是多个支路之间有交集,要不咱在代码那里限制一下,一旦通过计算发现路径的select集合存在交集,就报错说这语法不合规,等以后遇到真有必要的文法咱们再考虑怎么弄。你要不先把这个条件显式列出来,这样跟用户对齐颗粒度也容易。另外,你再给这个算法命个名吧,我隐隐有种感觉这玩意儿得迭代很多次,得有个编号才好讨论。”

好像,也行?想必那些客户也搞不出多么复杂的东东,先这么着吧,把问题清晰定义出来怎么也要比漫无边际的要好。

2.1 LL(1)的命名

不过,起名字吗?我最讨厌起名字了,名字什么的超难想啊,什么名字好咧?

有了,既然以后可能还有其他算法,我就把这个算法的一些通用规则直接写到名字上吧。到时候直接横向对比也容易。

首先,这个算法会从左向右(Left to right)扫描输入串;

另外,由于是从左到右构造出一个可以完全匹配输入串的语法树,那么假如当前处理的串有多个非终结符,这个算法必然是优先处理最左边(Leftmost)的非终结符;

最后,它每次最多只要偷瞄1个即将输入的符号,他就可以无回溯地知道要选的哪一条分支。

所以,就把他叫做LL(1)文法吧!万一以后会需要多看几个符号,咱也可以直接扩展编号。哇我能想到这种命名简直是天才。

2.2 LL(1)满足的条件

那么接下来讨论LL(1)文法对应的条件。既然不允许同一个非终结符上面的多个分叉路的select集合有交集,那么有如下形式化表达:

对A→α_i (0<i≤N)的任意两条产生式,如果下式恒成立:

SELECT(A→α_i )∩SELECT(A→α_j )=∅ (0<i,j≤N)

那么满足上述条件文法的是LL(1)文法。

等一下,前面推导First等集合的时候被空串整怕了,让我再想想,如果A的某个分支可以推出空串会怎么样?

那首先,就只能有一个分支可以推导出空串!否则,A的后面接着的Follow(A)必然存在于这两个分支的select集合中,那么它们必然存在交集!违反了LL(1)文法的条件。

(对题目提问中LL(1)第2条件的证明)

而对于LL(1)文法而言,若有A=>*ε,那么一旦看到待输入串即将输入的符号,假设为a,出现在Follow(A)里,就必然会选择那条让A变成ε的路径。

假如这个符号出现在其他不生成空串的路径的First集合中,那么说明其他路径的select集合中必然会有这个符号,那么必然存在Select(A=>*ε)∩Select(α_i→F)≠∅,这样就和LL(1)文法需要满足的条件相冲突了;

假如这个符号a就出现在这条生成空串的路径(设为α_i)的First集合中,设α_i→E | F, 且E=>*ε。由于按照前面的假设有a∈Follow(A)=Follow(E),而根据本段的假设有a∈First(F)⊆First(A),那么必然会有Select(α_i→E) ∩Select(α_i→F) ≠∅,同样不符合LL(1)文法的条件。

所以总结下来就有:对于每个有ε∈First(A)的非终结符A,必需满足Follow(A)∩First(A)=∅

推导完的瞬间,像是跑完一个全马一样浑身瘫软了。不过伴随着精神劳累的,是充分理解LL(1)的充实感。现在看来,Follow(A)∩First(A)=∅这个更加像是一个二级结论,无需记忆。

对客户只要讲“相同左部的所有分支,两两之间Select集合互不存在交集”这个核心就已足够了。

3 LL(1)用户体验如何提高

自从上次被查总吐槽正则文法难用,用户体验已经成为了心中悬在头顶的那把达摩克利斯之剑。

要不我还是先提前检查一下用户体验吧,LL(1)限制那么多,别到时候又被客户喷一通。

虽说之前通过正规式过了关,但是话说正规式跟正则文法都是无法处理嵌套的,想必对于上下文无关文法是不可能再通过简单的“上下文无关式子”就能搞定。。算了硬着头皮先用着吧,毕竟正则文法之前被嫌弃我觉得是因为那么简单的规则写上好几十条产生式不免让人难受,但如果是文法这样的,只要可读性强一点,估计问题不大。

然后接下来,用户定义完文法,我们写的语法分析器就得在后台进行检查是否是LL(1)文法,如果不行就尝试把它变换成LL(1),实在变不了就报错。

再之后用户输入代码,我们就照着预计算的程序进行分析。

所以整个流程走下来最容易让我挨骂的就是假如用户写的不是LL(1),我到底有多大的能力把它变化成LL(1)。只要我不是表现得像是一个无能的程序员,啥都报错,应该问题还不会太大。。

3.1 第一式:提取左公因子

前面习题2的时候就知道了,如果遇到Select集合是有交集的,就想办法提取公因子。以防万一,多刷一些随机文法看看。

习题4

A→ad | Bc 
B→aA | bB

。。。怎么B还能隐式产生a这个前缀,这还怎么提取左公因子?怎么跟隐函数一样麻烦。算了,反正我文法都改写了,干脆把这个文法替换为这样好了

A→ad | aAc | bBc B→aA | bB

之后提取左公因子后有:

Aa(d│Ac) | bBc B→aA | bB

引进A'后得到最终产生式:

A→aA' | bBc A'→d | Ac B→aA | bB

Emm,完美,下一题!

习题5

S→Ae | Bd 
A→aAe | b 
B→aBd | b

Emm?怎么按照上面的方法经过多次替换,不但得到越来越多的表达式,而且Select集合还一直有交集的呢??完了,看来提取左公共因子这个操作并不总是work的,到时候大不了循环多几轮发现问题没有解决就报错吧,先这样。。下一题!

3.2 第二式:消除左递归

习题6

分析输入串baaaa#在G[S]下的语法树。

S→Sa S→b

这不S的两个分支的Select集合没有交集嘛,简单得要死。。。等一下,这语法树好像有点不对劲?

如果按照之前深度检索的思路,那我看到第一个b的时候,到底S该往下挖多少层??妈呀,怎么还有这种事。。要是能从右往左读a的个数就好了。。。我想什么呢,这可是LL(1)方法,只能从左到右扫描!

不过还好,既然文法都可以让我随便改写了,我记得之前数据结构那里好像有讲过树的旋转。这左倾的树我无法第一步就确定,那么如果是右倾的树,我每次看一个符号再决定是否需要深挖一层就好了吧。

S→bS' S'→aS'|ε

习题7

A→Bb | aB B→Ac | d

妈呀是隐式左递归,怎么这随机生成器每次都能精准踩在我的痛点上。。。

算了老办法,像习题4那样从后往前代,直到把隐式左递归变成显式之后,再用前面的方法消掉左递归好了。

这么说来,左递归消除算法完全就是纯机械的,整理一下无非就是以下这么几步:

1.设文法有n个非终结符,其中第一个为开始符号:A_1,... A_n 
2.从i=n开始: 
        1.消除A_i的直接左递归; 
        2.把A_i右部代入到A_1~A_(i-1)产生式的A_i中;//消除A_i的间接左递归 
        3. i=i-1, 如果i0, 回到2.1 
3.去掉无用产生式

3.3 三个集合的自动化计算

在消灭完左公因子、左递归之后,基本上就剩下自动化计算的活了。

由于要给所有分叉都计算Select集合,而Select集合又是根据First和Follow集合求出来的,完全可以提前计算所有文法符号x∈V的First和Follow集合,以及每个产生式的右部的First集合。最后只要找出来哪些非终结符会生成ε,选择对应的Select集合计算式就好啦。

为了自动化求出所有的非终结符中有哪些会生成ε,可以这样想:

文法里面肯定会有些符号直接可以判定他是否会生成空串,还是说他绝不会生成空串,这部分直接判定就好了;而如果不能一眼丁真的,肯定是由于它的产生式有很多未定的非终结符。

这个时候就得利用上前面已经确定的符号的结果:

  • 如果已经确定某个非终结符可能产生空串,那就直接求下限,就当它百分百会产生空串,这样就知道某个用过这个非终结符的产生式是否会变成空串;

  • 假如已经确定某个非终结符绝不产生空串,那就反过来,直接把这个非终结符换成终结符,再次一眼丁真试试。

如此循环,最后总能把全部非终结符判定一遍的。

1.为非终结符构建Flag数组,初始
n每个元素只能是"未定""是""否"
n初始时均设为"未定"
2.扫描每条产生式
①删除所有右部含有终结符的产生式。若某非终结符的所有产生式都被删除,则标记对应符号为"否"
②若某非终符的某产生式右部为ε,则将数组中对应该非终结符的标志置为"是",并删除该非终结符的所有产生式
3. 扫描每条产生式右部的每个符号
若所读非终结符号标记为"是",则删去该非终结符; 若这使某产生式右部为空,则标记相应左部为"是",并删除其所有产生式
若所读非终结符号标记为"否",则删去该产生式. 若这使某未定非终结符的产生式都被删除,则标记其为"否"
4. 重复3直到收敛

First集合貌似最容易算,略过。

Follow集合的话,前面讲过,A的Follow还不能在A的产生式上直接计算出来,只能是从类似S→αAβ这样的产生式中知道,FIRST(β)除空串外的元素属于FOLLOW(B)。

等一下,万一β=>*ε,岂不是又要来一遍把FOLLOW(S)也加入到FOLLOW(A)中??

救命,这还是个不停迭代的过程还不能一步到位。。行吧,起码还是有FOLLOW(S)={#}这个开始条件。。

这LL(1)文法完全就是纯折磨呀!!

3.4 编程实现:预测分析法

行吧行吧,千难万难总算是走到实现这一步了。

既然前面已经算出来一堆Select集合,对应上状态转移表,感觉可以直接得出来一个预测分析表了:

首先是这个预测分析表,直接就是让对应的状态,遇上对应的符号,根据select集合填写对应的产生式。换句话来说,只要即将读入的符号 a ∈ SELECT(A →α),就在表格的 (A, a) 坐标处填上A →α这条产生式;

之后在分析代码的时候,每读入一个符号,看一眼栈顶:

如果栈顶是完全一样的终结符,那么直接匹配掉完事;

如果栈顶是一个非终结符,说明现在在子程序中,那就需要以当前状态为基准,在预测分析表中查询接下来该用哪一条产生式,然后进行跳转,并且把对应产生式右部的符号依次逆序压到分析栈里面保存起来,这样后面每来一个新的符号,就会先把它匹配上。

差不多了吧,测试完最后一个案例没问题就结束收工!

3.5 LL(1)文法不可避免的原罪

习题8

还原串i-i-i在如下文法中的语法树

E→ E-F|F F→i|(E)

先来左递归消除,有:

E→FE' E'→ - F E'|ε F→i|(E)

计算FIRST集合

FIRST(E→FE')={"(" ,i } FIRST(E'→ - F E')={-} FIRST(E'→ε)={ε} FIRST(F→i)={i} FIRST(F→(E))={"(" }

计算FOLLOW集合

FOLLOW(E)={")",#} FOLLOW(E')={")",#} FOLLOW(F)={ -,")",#}

求各个产生式的SELECT集:

SELECT(E→F E')={(,i} 
SELECT(E'→ - F E')={-} 
SELECT(E'→ε)={#, )} 
SELECT(F→(E))={(} 
SELECT(F→i)={i}

修改后的文法相同左部的产生式没有交集,是LL(1)文法。这不结束了吗!

得到的语法树如图右边所示。

左边为原始语法树

。。。(开始催逝员式慌乱)诶,这,这不对吧?

左边的语法树,层次和计算顺序都和原式完全一致,每次只要先把深层次的结果算出来传递到上层,反复这个操作就可以了;

现在变成了右倾语法树,减法被放到了右边,为了完成运算我还得把左边树算出来的结果传过来右边,那麻烦程度得上天了救命。

算了先这么着吧,到时间汇报了。

会议室。

叽里咕噜汇报完。

“你们其他几个小组,评价一下他们组的现在搞出来的这个LL(1)文法?看有没有进一步开发的价值?”老板发话。

隔壁组的Leader对着这边客气笑了笑,讲了一下客气场面话,夸完了这个算法的良好效率之后,终于不紧不徐给出了他们看出来的一些问题:

“这个方案让我想起了一考定终身。就只要最开始选择错了,后面都没有改的机会了。

窃以为,用户写的文法都是很复杂的,包括我们自己说一句话,很多时候都要讲半天才知道前面的某个词代表什么意思,也就是说我觉得自然的语法应该是后验的,而不是看一个符号就可以知道。

最简单的例子,以前我们说:‘不能说是AAA,至少也是BBB’这样的句式,传统思维上会认为BBB会比AAA要减弱,但现在流行的通辽语法,却是让BBB变成了AAA的反义词。这样的语法结构用LL(1)想必百分百出事。”

“我同意你的观点,”老板接话“LL(1)这个文法的上限被它的眼界所限制死了,只能向前看一个符号,但其实既然有栈的话,我们大可以把选择产生式的时间推后。

还有一点,感觉这文法改了用户的文法,那么用户理解的语法树和你语法解析器内部的语法树用的很可能不是同一个,到时候调试起来估计用户得骂娘。

不过我也得说个真心话,你们这个工作还是有很强启发性的。即使后面被其他算法替代了,它带来的启发思路也是非常值得称赞的。

回头你们把手头的东西交接一下给工程部,让他们弄个V1版本安抚一下客户。

之后休个几天假,回来再看看是否能有更好的算法。

都忙去吧!”

彩蛋:左公因子提取与系统成本优化

好不容易请假来到银行办事。结果两条队伍选了一条,好不容易快排到,结果那个柜台居然临时关门了!!又要重新排一次!!

后面提了一下意见,把排队的步骤进行公因式提取,切实优化了总体的排队时间。

这就是学以致用啊同志们。