本系列讲义适用于:被强迫学习编译原理前端,或者希望弄明白如何做科研的人
前言
词法分析是编译原理对初学者的下马威,是一个马上就开始让人迷惑的章节。"到底学这些什么3型文法概念干嘛的,我不用这些概念不行吗?"类似的疑问想必不绝于耳。因此,本文将从一个极简单的例子出发,逐步提高难度,最终解释清楚为什么特事特办的民科方法没有前途,终究要研究一点理论,才能让自己在遇到同类问题的时候遇神杀神遇佛杀佛。
因此,如果你在快速浏览本文的过程中看到一些和课本上完全不一样的式子,不要害怕,这是还在推导,最后出来的一定是你熟悉的那个正则问题。
第一式:见微知著,从简单示例开始研究问题
先来看一个最简单的程序:
int main() { int a = 15 + 2; //This is a comment }
就这么一个简单的程序,它在硬盘里是这样的

可以看到,原本在人眼中像画一样以二维形式铺开的代码,在底层看来只不过就是一串符号。之所以能看到美妙的格式,无非是因为机器知道遇到\r\n之后要从新的一行打印后续符号。
要精确和高效识别这堆什么都有的混合物中每一种成份,你首先应该了解你即将要面对的是什么,才能更好地定义你问题的边界,而不会考虑太多实际上并不会遇到的问题
对于我们的代码而言,首先要做的就是筛选出单词的种类,再分别研究它们的特点,分而治之。
第二式:分而治之
通过分类,这个文件中的符号可以整理成以下几类:
1。关键词(保留字)。int。这一类的单词特点是,绝对不会和其他类别的单词重复,他的种类有限,就是一些英文字符串。
2。运算符。+。作用就是参与运算,种类也是极其有限。
3。界限符。{}, (), 空格,;。作用就是让机器在后续分析的时候,知道当前分析的这个语法单元从哪开始到哪结束。他的种类也是极其有限。
4。空白字符,换行。\r\n。对代码几乎完全没影响的符号,可以在识别到之后直接忽略。
5。标识符。main, a。允许用户自定义起名。组成的规则有限制,长度不定,种类也不确定。
6。常量。15, 2。除了数字外,还有可能包含16进制数所附加的a-fx;另外还有一类常量叫字符串,里面是什么符号都可以包含。所以常量和标识符一样,种类和长度也都完全不确定。
7。注释。 //This is a comment。对于注释而言,只要找到他标记的开始和结束,再长也都可以看作一个整体。
对于繁杂的分类,一类一类治理只会浪费精力和时间。所以接下来就需要对它们进行归类,从而降低自己思考的难度并发现共性方法,最终统一处理他们,合而治之。
容易观察到:
对于前四类,由于都是种类与长度也有限,通过简单的字符串匹配就可以识别出来,这一类实现起来没有难度,先不考虑。
对于后三类,则需要通过特定规则进行匹配。那么,这些规则是什么?
a. 标识符:只要第一位不是数字,后面随便你放数字、下划线或者字母。
b. 十进制无符号常数:从前到后全部都是数字。
下面请读者独立完成对应的识别程序。
啪的一声,很快啊,gemini马上就给出了一个样例伪代码:
kotlin
获取 单词的 第一个字符
if 第一个字符 不是 数字:
// 可能是标识符,检查一下后面
for 第二个到最后一个字符:
if 这个字符 不是 (字母 or 数字 or 下划线):
return "无效标识符 (含非法字符)"
// if循环检查完毕都没问题
return "标识符"
else
if 第一个字符 不是 数字:
// 可能是十进制常数,检查一下
for 单词中的每一个字符:
if 这个字符 不是 数字:
return "无效单词 (数字开头却有非数字)"
// 如果循环检查完毕都没问题
return "十进制无符号常数"
else
return "无效标识符 (含非法字符)"
Emmmm,很好。看来我们已经可以很好地识别这两类单词了。词法分析也不过如此嘛,那我们继续开始语法分析吧!
老板:稍等一下,那个谁,我这程序里有几类新的常量,规则也给到你了,你帮我也实现一下。
c. 十六进制常数:0x开头的,后面跟着0-9a-f;
d. 带符号整数:比无符号整数可能会多了一个正负号;
你:好的老板,这几个好弄,我马上弄好。
噗嗤噗嗤一阵捣鼓。
你:老板我弄好了。
老板:好的谢谢。那啥,这里又来几类新的,你再处理一下:
e. 带下划线的整数:在整数中间允许下划线存在,但下划线不会出现在最后和最前,以及不会出现连续两个下划线
你:???还能这样?
老板:Python早就用上了呀
你:不干了不干了!!!什么鬼,乱加需求,不能一开始就想好吗??
老板:不好意思,真的不行,需求是不断变化的。那这个工作你是干还是不干?
你:不是老板你这需求加得太离谱了呀,后面来的需求还会和前面的需求打架,我这代码改来改去都成浆糊了呀!!
老板:那有没有可能,有更加简单的代码实现呢?
你:
编辑
曹老板别急着把天聊死。没准拨开迷雾后会有惊喜呢。
第三式:规范化与抽象
下面通过逐步的抽象,来让我们逐渐到达事情的本质。
首先要抽象/化简的,就是这些该死的,用自然语言描述的abcde五条规则。他们最大的问题就是都各有各的说法。所以为了找到共同点,首先要让他们用统一的方式进行描述,也即所谓的规范化,算是某种程度上的对齐颗粒度。
a. 标识符:只要第一位不是数字,后面随便你放数字、下划线或者字母。 b. 十进制无符号常数:从前到后全部都是数字。 c. 十六进制常数:0x开头的,后面跟着0-9a-f; d. 带符号整数:比无符号整数可能会多了一个正负号; e. 带下划线的整数:在整数中间允许下划线存在,但下划线不会出现在最后和最前,以及不会出现连续两个下划线
可以隐约看出来,一旦前面的符号确定了,那么下一位符号的可选范围也就确定了。
由此,这几条规则都规范化成:当前如果是某个符号,那么它后面可以是什么符号。
但等一下,最开始的时候字符串还没有生成,并没有什么符号。那我们假设句子的最前面有个表示空气的符号ε(音:epsilon,主要是对应于Empty,但直接用E又容易惹误会。有的教材使用λ,意为虚无),最后也有一个文件结束符EOF或特殊符号$作为结束符,这样就可以规范化为:
a:如果当前符号是ε,那么后面可以是字母或下划线;如果当前符号是字母或下划线或数字,那么后面可以是字母或者下划线或者数字或者结束符; b:如果当前符号是ε,那么后面可以是0-9之一;如果当前符号是0-9之一,那么后面可以是0-9之一或者结束符; c:如果当前符号是ε,那么后面可以是0;如果当前符号是0,后面可以是x;如果当前符号是x,后面可以是0-9a-fA-F之一;如果当前符号是0-9a-fA-F之一,后面可以是0-9a-fA-F之一或者结束符; d:如果当前符号是ε,后面可以是正负号或者0-9;如果当前符号是正负号,后面可以是0-9之一;如果当前符号是0-9之一,后面可以是0-9之一或者结束符; e:如果当前符号是ε,后面可以是0-9之一;如果当前符号是0-9之一,后面可以是0-9之一或者下划线或者结束符;如果当前符号是下划线,后面可以是数字。
念经一样枯燥无味对吧,但绝对比之前的版本要清晰和无歧义。
第四式:合而治之
有了规则之后,我们来想象一台生成这些单词的机器,这台机器会像雪糕机那样不停地屙出来符号,关键在于特定机器屙出来的单词最后一定符合规则。
上面规范化的规则倒是有那么一回事了,但你想要让机器听明白,好像还差那么一点意思。机器只看得懂符号,看不懂自然语言。
聪明的你一定想到了,那我用公式来表示这些规则不就好了么。问题是,你要怎么定义你的公式规则呢?
如果当前符号是x,那么后面可以是y
→if ch==x then y //由于if ch==这样的开头在每条式子里都是一样的,可以省略。
→x->y //由于x then y还是太麻烦了,把中间的then换成箭头表示跟随
对于x或y有多个选项的场合,用集合来表示,如:
x->[a-f0-9]
另外还是用希腊字母ε表示开始。那我们就可以把上面的规则a以如下形式展示(其他几个请读者自行转换):
a:ε->[a-zA-Z_];[a-zA-Z_0-9]->[a-zA-Z_0-9$];
啊,一身清爽,这下规则也有了,那差不多可以小试一下牛刀了吧?
f:以a开头,b做结尾,中间要有偶数个c的符号串。 f:ε->a;a->[cb];c->[bc];b->$;
聪明的你估计已经发现问题了,这套表示方法好像无法表示c的奇偶性。他会产生出acb这样的错误单词出来。
所以,规则的左边出了问题:不应该让特定符号出现在左边,因为在不同的场合下,即使是相同符号的后跟符号也存在差异。
那这样吧,我们挨个把这些场合通过字母+下标的方式区分,这样就既知道当前处于哪个小写符号,又知道这是针对哪个特殊场景。这样我们就有:
f:ε->a;a->[b]; ->; ->[b]; b->$;
恭喜你,发明了一种描述单词结构的规则。
先不论这种规则的表示方法符不符合主流写法,我说这种规则无歧义地描述了我想要的单词的结构,应该不会有人有意见。
第五式:吐槽与迭代
构建出来是一回事,好不好用又是另一回事。下面开启吐槽大会:
首先,这个下标用起来不方便(用公式编辑器打一个试试),还有一般用不到的莫名其妙的希腊字母ε;(作者注:希腊字母这个属于约定俗成,吐槽你也没办法╮( ̄▽ ̄)╭)
第二,也是最重要的一点,这个规则不方便进行推导。比如,我现在想要知道这个规则集到底能生成哪些单词,你只能编写一个程序,然后从ε出发,然后找出后面是什么符号,之后改变当前符号后,再查一下当前符号对应的规则。最麻烦的是,由于下标的存在,还不能直接根据当前符号来确定使用的哪个规则,还要用一个辅助变量来记录。导致查表的过程非常麻烦。
注意到,这个带下标的符号,实际上代表了两层意思:1. 符号本身;2.这个符号所处的特殊场景。
当处于某个节点,实际上是由2而不是1来决定下一个符号应该是啥。在选定了下一个符号后,那么我们所处的节点将会更新成这个符号所对应的特殊场景(我们称为"状态")中。
因此,有必要把符号代表的状态和符号本身进行抽离。把具体的象抽离,只剩下纯粹的概念,这就是真正的抽象。
在实际操作中,可以简单地把单纯的一个实义符号,拆解成实际的符号加上它所处的状态名称。如a拆解成aA, 拆解成cC, 拆解成cD。我们并不真的关心到底要处理什么符号,所以式子中的小写字母代表在单词中真实出现的字母;大写字母表示虚拟的、抽象的状态,而不是这个字母本身。
前面提到,对于后面即将产生什么符号,实际上我们可以只关心当前处于什么状态,并不用关心符号本身是什么。所以对于产生式左边的符号而言,可以直接写出当前我们想讨论的状态本身,而不用带上对应的实义符号。
比如,原先的a->b, 可以写作A->bB。其中A表示在生成a之后所处的状态,除了A以外还可以用其他自定义符号。B表示在选择了b之后所进入到的新状态。
对于原来类似c->[a-zA-Z_]的式子,集合中的字母都对应了同一个新状态,因此可以简写为:C->[a-zA-Z_]B。
对于a->[b]这样两个符号分别对于不同状态的情况,我们可以对集合进行拆解,得到:A->cC|bX,中间的竖线表示"或"。你也可以理解为:你现在看到左右两条路被中间的竖线分隔,现在只能挑一条路来走。
最后讨论两个特殊情况:开始和结束。
万事必须要有头,因此我们一般情况下用(Start)的首个字母S表示开始状态,当然也可以指定别的大写字母。
对于结束时刻的状态,由于 <math xmlns="http://www.w3.org/1998/Math/MathML"> 直接就代表结束,我们不再关心其后的状态,因此 直接就代表结束,我们不再关心其后的状态,因此 </math>直接就代表结束,我们不再关心其后的状态,因此后面可以什么都不接,比如上面的A->bX之后紧跟一条X-> <math xmlns="http://www.w3.org/1998/Math/MathML"> 。甚至可以更极端一点,省略那些只会生出 。甚至可以更极端一点,省略那些只会生出 </math>。甚至可以更极端一点,省略那些只会生出的状态,比如上面就可以写作A->b。另外,既然ε表示无,那用ε直接表示结束也是可以的。
由此,可以改写出a的一个新版规则集:
A->[a-zA-Z_]B B->[a-zA-Z_0-9]B | ε;
换句话说,对于B这个状态后续实际上有两个支线:一种是"后面还能接更多符号"(B->[a-zA-Z_0-9]B);另一种是"这个符号就是结尾了"(B->ε)。
由于这两种情况有共同的左部B,所以可以合并在一起,用"|"分隔,从而让我们在一个规则里同时表达多种可能性。
由此我们终于得到一个相对好用的稳定规则方案。
最终成果:用新范式重写全部规则
css
a(标识符):A->[a-zA-Z_]B | [a-zA-Z_];B->[a-zA-Z_0-9]B | [a-zA-Z_0-9]
b(十进制无符号整数):A->[0-9]A | [0-9]
c(十六进制无符号整数):A->0B;B->xC; C->[0-9a-fA-F]C | [0-9a-fA-F]
d(有符号十进制整数):S -> +A | -A | A; A->[0-9]A | [0-9]
e(带下划线整数):A->[0-9]B;B->[0-9]B | [0-9] | _C; C->[0-9]B | [0-9]
你定义规则的过程,就是在定义一门新语言的过程。
在后面会看到,还会有其他奇奇怪怪的语法糖去提高这套规则的易用性(包括把中间的[a-z]替换成(a|b|...|z)从而确保正则文法中出现更少的特殊符号,我之所以不在上面提及,一个是这个点不太重要,读者可以自行推导,另一个是我不想引入太多希腊字母等一些约定俗成的东西扰乱读者理解)。但其核心万变不离其宗,就是这一套:从一个状态跳转到另一个状态的规则描述方法。
小结
本节从基本问题入手,让读者朋友发现总是特事特办(也称作Ad Hoc方法,拉丁语,意为特例)终究不是个法子,有必要找到要处理问题的共性,从而先提出一个统一的基本模型,之后再进行泛化处理。
实话实说,这一套方法算是我看了答案,知道终点在哪里,再去从零进行推导,难免有作弊的嫌疑,在此先谢罪一个。
但我相信其中的几个关键步骤:从简单问题入手,分而治之,抽象提取共性,合而治之,以及吐槽与迭代,是每个问题都绕不过去的必经之路。
希望本文能对你们有帮助,也欢迎各位多多点赞支持,也欢迎通过各种方法不惜赐教,在此谢过。
下一章将会从这套规则出发,讲清楚如何借助一点点理论,完成我们万法归一的终极目的,用一套方法打遍词法分析无敌手。
附录:在线算法与离线算法
在很多教材中,普遍都是先介绍词法分析,再介绍语法分析。
但其实,两者并没有必然的先后顺序。
先进行词法分析再进行语法分析,是希望能从一堆符号中,先把单词token全部识别出来之后,再去对这些单词进行语法分析。
这样的好处就像是流水线,前面把信息过滤了一遍之后,后面的流程就只需要针对提纯后的信息流进行处理,要考虑的杂质要少很多。
这固然是一种很好的方法,但并不是唯一的方法。想想我们自己读一句英文,是一边读单词一边理解这句话,说明语法分析和词法分析是交替/并行进行的。
对于前者,由于得整句话输入完后才能进行词法分析,再进行语法分析。所以这其实是一种离线算法。对于后者,由于可以一边输入一边进行分析,在输入完最后一个单词后几乎马上就可以得出最终的结果(语法树),所以我们把这种方法称为在线算法。
在线算法和离线算法并没有绝对的谁优谁劣,只有是否适用具体的场景。
比如,在一个计算平均值的场景中,如果你采用在线算法,可以只使用两个内存空间,一个用来累加,一个用来记数,从而在接收到用户输入结束信号之后,马上算出来最终结果。但如果你采用离线算法,就需要与数据个数等大的空间去临时存放这些数据,再在最后一次性计算平均值。如果用户是一个一个输入数据,那么不可避免地中间会出现对于电脑而言极大的性能浪费,在线算法一方面能充分利用这些时间,一方面又能节约空间;但如果数据是从文件中一次性读取,程序又是个单线程的话,减少程序的上下文切换的离线算法将会是一种效率更高的算法。
为了行文方便,本系列也将以离线算法的方式介绍,先介法分析,再到语法分析。读者在掌握核心原理后,可自行组装出对应的在线算法。