面向科研启蒙的编译原理讲义:绪论

16 阅读19分钟

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

所谓前端,就是一般中国大学在课上教的词法分析、语法分析、语义计算、代码生成;后端一般是指代码生成及代码优化。

前言

……我们知道,只有很少读者将会去构建甚至维护一个主流程序设计语言的编译器。 但和编译器相关的模型、理论和算法可以被应用到软件设计和开发中出现的各种各样的问题上……

——《龙书》前言

在任教编译原理的这几年中,如何在学生理解能力有限的情况下,尽可能教到他们有用的东西,成为了我面前最大的挑战。

在知乎问题:如何学习编译原理 的帖子中,高赞回答里面讲的很多东西,都是偏向底层,或者是编译器后端。

然而我有充分的把握可以打包票:这些东西绝大部分不会用在日常工作上,对大部分人更是毫无意义。这一部分的工具属性太强了,真就应了龙书的封面,屠龙之技,对于大部分学生没有半毛钱作用。

而即使大学课堂上教的是前端,更多的是偏重具体的算法本身,而并不会教你背后怎么思考才能得到这样的算法。

就像我当年(本科是某末流985),考试前脑子里面只剩下一些基本的算法,考完后啥都不剩了。

我是谁,我在哪,我要干什么,完全不清楚。

所以自从我执教编译原理以来,主要目标就是把它当作一个科研启蒙课:我们要解决什么问题,我们如何拆分这个问题,要怎么逐步解决?

改革的最终目的,是希望当学生能掌握思考的范式,以后就可以推广到其他复杂问题上。

所以,想要学明白编译原理,就要确保时刻知道自己在哪里where,要准备做什么what,应该怎么做how。

下面从总体到局部一步步拆解。

课程总体目标

以70年代的技术,实现"人说指令,机器执行"的效果。

问题分析

首先,为什么要说70年代,为的是防止有些学生问为什么不使用LLM等高级技术。记住本文的一切实现都要用最基本的方法进行,自找苦吃的根本目的就一个,重走长征路,从已成熟的项目搭建过程中掌握未来大项目的思考与实现流程。

其二,机器的复杂程度限制在《图灵完备》这个游戏构建的单片机系统以下。也就是说,它逻辑足够简单,但又可以执行一些指令。只是这些指令非常基础,非常具体,稍稍一个抽象一点复杂一点的功能都要好几条才能解决。这里强烈推荐图灵完备这款游戏,建议在玩完这个游戏,或者对计算机组成原理有基本了解后,再进行编译原理的学习。其他推荐的同类游戏还包括《程序员升职记》。

其三,当你对底层有一定了解,也用类似汇编的底层语言写过代码之后,相信你一定会体会到,由于前述前述机器偏弱的原因,你被迫把精力花在了琐碎的“翻译”工作上。这意味着在你想明白自己想做什么大方向怎么做之后,还得想想要怎么用稀碎的指令去实现。由于很接近机器底层,因此考虑的东西很多,就像是一个管家,又要管理物流又要管理库存,最终一个原本很简单的问题变得异常复杂。因此,为了降低使用成本,需要设计一个翻译机,把接近自然语言甚至就是用自然语言描述的指令,翻译成机器能直接执行的指令序列,隐藏底层实现的细节,从而达成解放生产力的效果。

尽管问题好像分析清楚了,但对于如何实现依然没有思路,只知道问题好像有了大概的方向。

我认为解决这个困境的方法就是:追问。不断往细了追问。

就像海龟汤,what why how 轮着来。差别在于,海龟汤是有谜底的,所以可以和你一问一答。但在真实的场景中,问答都得你自己分析出来。

于我而言,在首轮分析完成后,脑海中马上浮现出三个问题:

1.为什么翻译是可行的?

2.如何翻译?

3.人为什么要用不同的语言和机器交流?

第一个问题:为什么翻译是可行的?

凭什么我们设计的语言,最终一定能被翻译成机器懂的指令?

这是一个触及计算机科学根基的深刻问题。要完全证明它需要复杂的数学,但幸运的是,我们可以通过一个比喻来理解它的核心。

首先,如果一种语言可以实现逻辑分支,循环,以及数据读写这三点,则称为图灵完备。任何图灵完备的语言之间可以相互模拟。

你可以把图灵完备的语言(指令集)理解为,他像正常人一样是四肢健全的。而四肢健全的人,是可以模仿另一个人的行为的。即使模仿者可能模仿得不好,会走样,会迟钝,但总有方法可以让他们对外实现几乎一样的功能。因此,一个图灵完备的指令集可以去模拟另一个图灵完备的指令集(语言)。

至于为什么偏偏是这三样东西就‘够用’,这背后是图灵、丘奇等一代天才巨匠的智慧结晶。我们今天的重点是应用这个结论去造翻译器。如果大家对这个理论根基感兴趣,可以深入学习《计算理论》等课程。

第二个问题:如何翻译?

如何翻译的问题初看毫无头绪,那么我们就把这个问题先进行拆解:翻译,就是先理解对方的意思,再进行一个相对无损的转换。

继而又多了两个新的子问题:怎么理解一句话?理解了之后怎么进行同义转换?

通过不断追问产生的这第一个子问题,已经很接近我们要做的事情本身,遗憾的是目前依然毫无头绪。

对于完全没有想法的问题,我通常会问自己:我自己是怎么做到这个事情的?从而把问题转化为:

人类是怎样理解一句话的?

以I Love you.这句话为例,英语有个好处,词和词之间是间隔的,所以可以很容易先把单词识别出来。在知道词的含义之后,只要套上主谓宾的模版,就基本上知道谁对谁做了什么,这句话我们也就理解了。

进一步对上述过程进行抽象,那就可以得到如下流程:分析单词(所谓词法分析),套语法模版(所谓语法分析),生成一棵语法树。

那么,如何把一种语言翻译成另一种语言?很简单,就是把一棵树映射到另一棵树上。俗称套模版机翻。

比如,汉语里面的主谓宾,对应到日语里面的主宾谓。那么我(私は)爱(愛している)你(きみ)就可以在分别换成对应的日语单词后,得到私は(我)きみ(你)愛している(爱)。我知道这很不地道,正常的表白应该是说“好きです(好喜欢你)”,或者“ずっと一緒にいたい(希望能一直在一起)”,甚至是“月が綺麗ですね”(今晚的月色很美)。

好在,机魂很宽容的,只要能运行就没人批评你语法不地道。

到底怎么翻译?

回到翻译的问题上,我们要处理的问题其实无非就是三大类:读写,分支,循环,只要我们能设定好模版,分别让多条低级的指令对应上高级复杂的语言的这些功能,那么我们就可以完成翻译任务。

下面以分支结构为例,看看这个翻译模板具体长什么样。

已知,在汇编里面有一个分支指令为JECXZ,功能是先把结果算出来放到寄存器CX里面,然后通过指令去判断这个寄存器的值是否等于0,如果为真则执行逻辑跳转。

那么对于一个if(exp)then {S1} else {S2}的分支语句,就可以很容易创建出如下模版(伪代码):

// 原始高级语言: if(exp) then {S1} else {S2}

ECX := exp // 对应C代码里的 (exp) 这个条件的计算,结果存入ECX

JECXZ S2_LABEL // 如果ECX为0 ,跳转到S2代码块

               // 如果ECX不为0则不跳转,执行 S1 部分

// ... S1 的代码 ...

JMP END_LABEL // S1执行完后,必须无条件跳过S2,直接到本段结尾

S2_LABEL: // S2代码块的“门牌号”

// ... S2 的代码 ...

END_LABEL: // 整个if语句的结束“门牌号”

// 后续代码

其他情况同理。在模版制定好之后,剩下的交给机器去翻译即可。

(关于如何翻译,我还有一个关于线性代数的比喻,不过被众AI给劝退了,不放在正文。有兴趣的可以浏览附录)

第三个问题:人为什么要用不同的语言和机器交流?

这个问题,很难会在其他编译原理的教材上讨论。

因为这些书只会跟你讨论怎么翻译,不会跟你讨论这个语言是什么本身。

但我觉得这个问题是如此重要,重要到如果你不了解这个问题,编译原理差不多等于白学了。

第一层境界:为了更好的语法糖

首先,针对这个问题冒出第一层问题:为什么大部分的编程语言都是英文?中国都发展了那么久的计算机了,为什么还没有很多广受好评的中文编程语言,而至今只有一个易语言广为流传?

真就是人不行?

答案就藏在词法分析语法分析中。回顾I love you.的例子,容易发现空格的作用简直是举足轻重,为我们天然地进行了分词。

回到中文,对于“我爱你”,这样的短句,我们固然可以轻易识别主谓宾。那“海参炒饭”,到底是一个四字标识符,还是说“海参”这个东西去“炒”“饭”,这样的面向对象编程语句呢?

没错,说到底是汉语太复杂了,开发语法解析器非常困难。远的不说,在LLM出现之前连英文的语法解析器都还容易出错。

你指望花大量资金,开发一个没有错误的中文编译器,就只是为了所谓的自研招牌,那是真正的没有必要。本身就只是为了能跟编译器能好好说话而已,没必要这样死磕中文语法分析器。这不,还没研发出来,LLM就出来拯救地球了。同样是为了让人能跟电脑对话,让人学会英语或者说那几个关键词及其对应语法所需的成本,远远低于去开发一个既不赚钱研发成本又奇高的汉语编译器。真要有那本事,自然语言处理器早就做好了,也不至于某搜索一哥沦为网络连通测试器。关于已有汉语编程语言的讨论,可详见我与gemini的对话。(需翻墙)

扯远了,相信现在我们可以达成共识,在70年代这个语境下,用英文编程是唯一选择。

那下一个问题就是,既然都用英文了,那怎么编程不都一样吗?

还真不一样。

比如,对于简单的分支语句,某语言喜欢说if(exp)then{S;}else{S;};有的语言喜欢说if (exp) {S;} else {S;};有的喜欢说switch(exp) {case true: S; case false: ;}...

条条大路通罗马,你要选择哪一条?

神说,要有方便的语法,于是便有了各种语法糖。

以上,是开发新语言的第一层原因:为了获得不同的语法便利性。

第二层境界:为了开发更就手的工具。

开发语法糖就能解决的事,为什么要创造新的语言那么麻烦?

这就是我认为学习编译原理对一般人的最大意义:你可以创建一个在你所在领域具有特效的语言。

举个现实的例子。你可以选择开汽油车,也可以选择开电车,从而在我们这个少汽油但煤多的国家中,以更高的舒适度和更低的成本到达目的地。

这就是工具特化的意义。

每个人以后要面对的问题都是不同的,你当然可以使用通用工具来解决,但如果这些工具都不合适,难道我们就要一直忍受这种不方便吗?编译原理给了我们另一种选择:如果没有合适的工具,我们就亲手创造一个,从而加快你的工作效率。

比如,我们知道C无所不能,但你现在会用它去写网页服务器吗?70年代的时候那是没得选,现在谁还会用,图时间多吗?

再想一下,我们用什么来和数据库打交道?SQL。它能写操作系统吗?不能。能做图形界面吗?不能。它的功能极其有限,可以说‘偏科’到了极致。

但是,在‘和数据打交道’这个极其专门的领域里,SQL就是王。一句SELECT * FROM users WHERE age > 20,清晰、简洁、高效。

你能用C语言实现同样的功能吗?当然可以,但你需要打开文件、解析格式、遍历数据、进行比对……写上上百行代码,最后可能还有bug。

SQL的存在,完美地诠释了‘工具特化’的意义。 它就是数据库领域的‘电动车’。

创造一门新语言,不一定是要再造一个像C或Python那样的‘全能巨人’,更多时候,是打造一个像SQL这样,在特定领域小而美的‘高效利器’。

第三层境界:为了实现更厉害的模型

然而,创造新语言的雄心不止于此。最顶尖的设计者追求的是第三个境界:提出全新的编程范式,用新的思想模型来重塑我们与代码的交互方式。

举个例子,C语言都那么通用了,为什么跟C那么像的go语言还能后来居上?

虽然go看着跟C很像,但它的后台藏了很深的哲学思想:协程。也就是说,你当然可以在C里面进行多线程编程,但那是系统层面的事,一切都需要你手工定义,而且系统级的线程操作带来的效率损耗在如今的时代已经无法让人接受了。而对于go,它语言内本身就已经预想到,用户一定会大批量用类似多线程的方式来编程。那既然系统级的调度慢,那我就来包揽这一切吧,然后它就自己弄了一个协程管理器,从而用比系统更高效的方式实现了和多线程一样的效果。

再比如,面向过程编程,就认为一切都可以用过程式来进行编程。但你看这个模型和我们现实世界就很不搭:A对象对B对象发起了攻击,那就只能Attack(A, B);但如果用面向对象的方式编程,那就有A.Attack(B),相比前者,关系一目了然。

不同的人,对世界的理解就会有不同的模型,由此可以有不同的理解。

有的人可以认为,我要操作的东西就是一个内存上存储的数。它要+7,我就把它挪到EAX寄存器之后,加上7,再运回到内存中。这是比较底层的理解。

一个小学生,他可能只关心下周的今天的日期。这个时候他只想求得x+7,根本不想关心内存上的一系列操作。

可以看到,前后两者本质上都是算的同一个数,但两人对计算的理解却存在明显的侧重。

当你有了一个全新的、更优的解决问题的模型后,你可以选择用已有的语言(比如C)去“模拟”它,就像是用英文字母去拼写中文(拼音),从而传递思想。理论上可行,但极其别扭、冗长,且容易出错。而一门新语言,则像是直接提供了“汉字”,它让你的思想能够被更直接、更优雅的方式表达出来。这带来的开发效率和代码质量的提升,是无可估量的。

为了让大家理解领悟一个“新模型”带来的认知飞跃,我有一个压箱底的比喻:

本来世间只有泥巴。后来,有人提出了‘神’的概念,并用泥巴塑了一尊雕像,说这就是‘神’在世间的化身。

对于一个纯粹的物理主义者来说,雕像的化学成分和泥巴并无二致。但对于一个理解了其背后思想体系的人而言,这尊雕像承载了信仰,是精神的寄托。

一个新的编程范式也是如此。 从语法上看,它可能只是现有语言元素的重新组合(还是泥巴);但从思想上看,它提供了一套观察和解决问题的全新世界观(信仰的诞生)。能否看到“神”,取决于你是否理解了那个模型。

对于那个终极的问题,我决定换一个问法:我们到底要用什么语言和机器交流?

答案是:取决于你,和你要面对的问题。首先,你需要深入理解你要解决的问题,为它建立一个最恰当的抽象模型。然后,基于这个模型,去设计一套无歧义、易学习、能精确描述模型内一切行为的语法。

这个最终被设计出来的、你思想的结晶,就是你要和机器交流的、最好的语言。

小结:为什么编译原理是最好的“科研启蒙沙盒”?

诚然,大一大二的C语言、数据结构、算法等课程,是在为各位的职业生涯“打地基”,一砖一瓦都需严谨,不容自由发挥。而计算机网络、操作系统、数据库等课程,知识体系庞大且环环相扣,是支撑起整个现代计算机科学的“核心支柱”,也必须稳扎稳打。相比之下,编译原理的位置似乎有些“尴尬”。它在很多学校都是或曾经是必修课,但对多数同学而言,其知识点的直接应用场景似乎不多,叫做“鸡肋”也不为过。

然而,也正是这种“鸡肋”特性,赋予了它独一无二的、其他课程无法比拟的改造潜力,使它成为了一个完美的“科研启蒙沙盒”。

它是一个完整的微缩项目:从设计一门语言,到用代码将其实现,编译原理天然地覆盖了项目全流程,这是学习模块化、工程化思维的最佳演练场。

它是一座连接理论与实践的桥梁:你将亲手用代码,将离散数学中那些抽象的“自动机理论”,转化为一个能真正运行的“翻译程序”,切身感受理论如何指导实践。

它能赋予你“创造工具”的上帝视角:不同于其他课程教你如何“使用”工具,这门课将带你深入后台,理解并亲手“创造”工具。这个视角的转变,是踏出科研思维的第一步。

所以,我选择它,是因为它提供了一个最安全、最全面、也最富创造性的训练场,来培养各位的分析、拆解并解决一个复杂未知问题的重要能力。

绝不是因为我害怕由于这个课没人关注而无课可上。

以上是我对编译原理绪论的讲解,欢迎大家留言评论多提意见。如对你有帮助,还请多多点赞支持,给予我一点免费的鼓励。谢谢!

附录

我想借用一下线性代数和前面的图灵完备来对翻译的本质进行比喻

我们可以把所有要计算的问题理解为空间上的一个点。那么从0出发,可以沿着无穷多种组合到达一个目标点。而我们不同的指令集,实际上就是对应了不同的基底。比如对于指令集A,它有三个基底(0,0,1),(0,1,0),(1,0,0);那对于指令集B,它可以有令一堆基底(0,10,5),(20,100,0),(0,0,1),(0,60,80)。已知,这两组基底的秩实际上都是3(读写数据、循环和分支),所以它们的能力是完全一样的,但如果用指令集B来表示一个很大的向量(1000,1000,1000),那么它所需的基向量个数就会远远少于指令集A,从而让整个编程变得更加简单。

同时注意到,即使是对于上面这样简单的两个指令集而言,任意的起点和终点之间就有无数条可能的道路。比如

3×(0,0,1)+3×(0,1,0)=3×(0,0,1)+4×(0,1,0)-1×(0,1,0)。。。

你看,第二种情况就要比第一种情况多走了两步。这里其实为了简明,只展示了最容易被看出来可以化简的一种情况。对于更多更复杂的情况,往往凭肉眼并不能看出来可以优化的地方。

诚然,我们是要追求更高更快更强,但目前可以先简单一点,只要让它能跑起来就可以了。