first commit

This commit is contained in:
张乾 2024-10-16 10:02:01 +08:00
parent 249864d679
commit 1121fdaab5
5 changed files with 1267 additions and 0 deletions

View File

@ -0,0 +1,111 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
00 开篇词 在真实世界的编译器中游历
你好我是宫文学一名技术创业者现在是北京物演科技的CEO很高兴在这里跟你见面。
我在IT领域里已经工作有20多年了。这其中我个人比较感兴趣的也是倾注时间精力最多的是做基础平台类的软件比如国内最早一批的BPM平台、BI平台以及低代码/无代码开发平台(那时还没有这个名字)等。这些软件之所以会被称为平台,很重要的原因就是拥有很强的定制能力,比如流程定制、界面定制、业务逻辑定制,等等。而这些定制能力,依托的就是编译技术。
在前几年我参与了一些与微服务有关的项目。我发现前些年大家普遍关注那些技术问题比如有状态的服务Stateful Service的横向扩展问题在云原生、Serverless、FaaS等新技术满天飞的时代不但没能被很好地解决反而更恶化了。究其原因就是状态管理还是被简单地交给数据库而云计算的场景使得数据库的压力更大了数据库原来在性能和扩展能力上的短板就更加显著了。
而比较好的解决思路之一,就是大胆采用新的计算范式,发明新的计算机语言,所以我也有意想自己动手搞一下。
我从去年开始做设计已经鼓捣了一阵了采用了一些很前卫的理念比如云原生的并发调度、基于Actor的数据管理等。总的目标是要让开发云原生的、有状态的应用像开发一个简单的单机应用一样容易。那我们就最好能把云架构和状态管理的细节给抽象掉从而极大地降低成本、减少错误。而为编程提供更高的抽象层次从来就是编译技术的职责。
Serverless和FaaS已经把无状态服务的架构细节透明掉了。但针对有状态的服务目前还没有答案。对我而言这是个有趣的课题。-
在我比较熟悉的企业应用领域ERP的鼻祖SAP、SaaS的鼻祖SalesForce都用自己的语言开发应用很可惜国内的企业软件厂商还没有做到这一点。而在云计算时代设计这样一门语言绕不过去的一个问题就是解决有状态服务的云化问题。我希望能为解决这个问题提供一个新工具。当然这个工具必须是开源的。
正是因为给自己挖了这么大一个坑,也促使我更关心编译技术的各种前沿动态,也非常想把这些前沿的动态、理念,以及自己的一些实战经验都分享出来。
所以去年呢,我在极客时间上开了一门课程《编译原理之美》,帮你系统梳理了编译技术最核心的概念、理论和算法。不过在做第一季的过程中呢,我发现很多同学都跟我反馈:我确实理解了编译技术的相关原理、概念、算法等,但是有没有更直接的方式,能让我更加深入地把知识与实践相结合呢?
为什么要解析真实编译器?
说到把编译技术的知识与实践相结合,无外乎就是解决以下问题:
我已经知道,语法分析有自顶向下的方法和自底向上的方法,但要自己动手实现的话,到底该选择哪个方法呢?是应该自己手写,还是用工具生成呢?
我已经知道,在语义分析的过程中要做引用消解、类型检查,并且会用到符号表。那具体到自己熟悉的语言,这些工作是如何完成的呢?有什么难点和实现技巧呢?符号表又被设计成什么样子呢?
我已经知道编译器中会使用IR但实际使用中的IR到底是什么样子的呢使用什么数据结构呢完成不同的处理任务是否需要不同的IR呢
我已经知道,编译器要做很多优化工作,但针对自己熟悉的语言,这些优化是如何发生的?哪些优化最重要?又要如何写出便于编译器优化的代码呢?
类似的问题还有很多,但总结起来其实就是:真实世界的编译器,到底是怎么写出来的?
那弄明白了这个问题,到底对我们有什么帮助呢?
第一,研究这些语言的编译机制,能直接提高我们的技术水平。
一方面深入了解自己使用的语言的编译器会有助于你吃透这门语言的核心特性更好地运用它从而让自己向着专家级别的工程师进军。举个例子国内某互联网公司的员工就曾经向Oracle公司提交了HotSpot的高质量补丁因为他们在工作中发现了JVM编译器的一些不足。那么你是不是也有可能把一门语言吃得这么透呢
另一方面IT技术的进化速度是很快的作为技术人我们需要迅速跟上技术更迭的速度。而这些现代语言的编译器往往就是整合了最前沿的技术。比如Java的JIT编译器和JavaScript的V8编译器它们都不约而同地采用了“Sea of Nodes”的IR来做优化这是为什么呢这种IR有什么优势呢这些问题我们都需要迅速弄清楚。
第二,阅读语言编译器的源码,是高效学习编译原理的重要路径。
传统上,我们学习编译原理,总是要先学一大堆的理论和算法,理解起来非常困难,让人望而生畏。
这个方法本身没有错,因为我们学习任何知识,都要掌握其中的原理。不过,这样可能离实现一款实用的编译器还有相当的距离。
那么根据我的经验,学习编译原理的一个有效途径,就是阅读真实世界中编译器的源代码,跟踪它的执行过程,弄懂它的运行机制。因为只要你会写程序,就能读懂代码。既然能读懂代码,那为什么不直接去阅读编译器的源代码呢?在开源的时代,源代码就是一个巨大的知识宝库。面对这个宝库,我们为什么不进去尽情搜刮呢?想带走多少就带走多少,没人拦着。
当然,你可能会犯嘀咕:编译器的代码一般都比较难吧?以我的水平,能看懂吗?
是会有这个问题。当我们面对一大堆代码的时候,很容易迷路,抓不住其中的重点和核心逻辑。不过没关系,有我呢。在本课程中,我会给你带路,并把地图准备好,带你走完这次探险之旅。而当你确实把握了编译器的脉络以后,你对自己的技术自信心会提升一大截。这些计算机语言,就被你摘掉了神秘的面纱。
俗话说“读万卷书,行万里路”。如果说了解编译原理的基础理论和算法是读书的过程,那么探索真实世界里的编译器是什么样子,就是行路的过程了。根据我的体会,当你真正了解了身边的语言的编译器是怎样编写的之后,那些抽象的理论就会变得生动和具体,你也就会在编译技术领域里往前跨出一大步了。
我们可以解析哪些语言的编译器?
那你可能要问了,在本课程中,我都选择了哪些语言的编译器呢?选择这些编译器的原因又是什么呢?
这次我要带你解析的编译器还真不少包括了Java编译器javac、Java的JIT编译器Graal、Python编译器CPython、JavaScript编译器V8、Julia语言的编译器、Go语言的编译器gc以及MySQL的编译器并且在讲并行的时候还涉及了Erlang的编译器。
我选择剖析这些语言的编译器,有三方面的原因:
第一它们足够有代表性是你在平时很可能会用到的。这些语言中除了Julia比较小众外都比较流行。而且虽然Julia没那么有名但它使用的LLVM工具很重要。因为LLVM为Swift、Rust、C++、C等多种语言提供了优化和后端的支持所以Julia也不缺乏代表性。
第二它们采用了各种不同的编译技术。这些编译器有的是编译静态类型的语言有的是动态类型的语言有的是即时编译JIT有的是提前编译AOT有高级语言也有DSLSQL解释执行的话有的是用栈机Stack Machine有的是用寄存器机等等。不同的语言特性就导致了编译器采用的技术会存在各种差异从而更加有利于你开阔视野。
第三,通过研究多种编译器,你可以多次迭代对编译器的认知过程,并通过分析对比,发现这些编译器之间的异同点,探究其中的原因,激发出更多的思考,从而得到更全面的、更深入的认知。
看到这里,你可能会有所疑虑:有些语言我没用过,不怎么了解,怎么办?其实没关系。因为现代的高级语言,其实相似度很高。
一方面,对于不熟悉的语言,虽然你不能熟练地用它们来做项目,但是写一些基本的、试验性的程序,研究它的实现机制,是没有什么问题的。
另一方面,学习编译原理的人会练就一项基本功,那就是更容易掌握一门语言的本质。特别是我这一季的课程,就是要帮你成为钻到了铁扇公主肚子里的孙悟空。研究某一种语言的编译器,当然有助于你通过“捷径”去深入地理解它。
我是如何规划课程模块的?
这门课程的目标,是要让你对现代语言的编译器的结构、所采用的算法以及设计上的权衡,都获得比较真切的认识。其最终结果是,如果要你使用编译技术来完成一个项目,你会心里非常有数,知道应该在什么地方使用什么技术。因为你不仅懂得原理,更有很多实际编译器的设计和实现的思路作为你的决策依据。
为了达到本课程的目标,我仔细规划了课程的内容,将其划分为预备知识篇、真实编译器解析篇和现代语言设计篇三部分。
在预备知识篇,我会简明扼要地帮你重温一下编译原理的知识体系,让你对这些关键概念的理解变得更清晰。磨刀不误砍柴工,你学完预备知识篇后,再去看各种语言编译器的源代码和相关文档时,至少不会被各种名词、术语搞晕,也能更好地建立具体实现跟原理之间的关联,能互相印证它们。
在真实编译器解析篇我会带你研究语言编译器的源代码跟踪它们的运行过程分析编译过程的每一步是如何实现的并对有特点的编译技术点加以分析和点评。这样我们在研究了Java、Java JIT、Python、JavaScript、Julia、Go、MySQL这7个编译器以后就相当于把编译原理印证了7遍。
在现代语言设计篇,我会带你分析和总结前面已经研究过的编译器,进一步提升你对相关编译技术的认知高度。学完这一模块以后,你对于如何设计编译器的前端、中端、后端、运行时,都会有比较全面的了解,知道如何在不同的技术路线之间做取舍。
好了,以上就是这一季课程的模块划分思路了。你会发现,这次的课程设计,除了以研究真实编译器为主要手段外,会更加致力于扩大你的知识版图、增加你的见识,达到“行万里路”的目的。
可以说,我在设计和组织这一季课程时,花了大量的时间准备。因此这一季课程的内容,不说是独一无二的,也差不多了。你在市面上很少能找到解析实际编译器的书籍和资料,这里面的很多内容,都是在我自己阅读源代码、跟踪源代码执行过程的基础上梳理出来的。
写在最后
近些年编译技术在全球范围内的进步速度很快。比如你在学习Graal编译器的时候你可以先去看看市面上有多少篇围绕它的高质量论文。所以呢作为老师我觉得我有责任引导你去看到、理解并抓住这些技术前沿。
我也有一个感觉在未来10年左右中国在编译技术领域也会逐步有拿得出手的作品出来甚至会有我们独特的创新之处就像我们当前在互联网、5G等领域中做到的一样。
虽然这个课程不可能涵盖编译技术领域所有的创新点,但我相信,你在其中投入的时间和精力是值得的。你通过我课程中教给你的方法,可以对你所使用的语言产生更加深入的认知,对编译器的内部结构和原理有清晰理解。最重要的是,对于如何采用编译技术来解决实际问题,你也会有能力做出正确的决策。
这样,这个课程就能起到抛砖引玉的作用,让我们能够成为大胆探索、勇于创新的群体的一份子。未来中国在编译技术的进步,就很可能有来自我们的贡献。我们一起加油!
最后,我还想正式认识一下你。你可以在留言区里做个自我介绍,和我聊聊,你目前学习编译原理的最大难点在哪?或者,你也可以聊聊你对编译原理都有哪些独特的思考和体验,欢迎在留言区和我交流讨论。
好了,让我们正式开始编译之旅吧!

View File

@ -0,0 +1,272 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
01 编译的全过程都悄悄做了哪些事情?
你好,我是宫文学。
正如我在开篇词中所说的,这一季课程的设计,是要带你去考察实际编译器的代码,把你带到编译技术的第一现场,让你以最直观、最接地气的方式理解编译器是怎么做出来的。
但是毕竟编译领域还是有很多基本概念的。对于编译原理基础不太扎实的同学来说在跟随我出发探险之前最好还是做一点准备工作磨刀不误砍柴工嘛。所以在正式开始本课程之前我会先花8讲的时间用通俗的语言帮你把编译原理的知识体系梳理一遍。
当然,对于已经学过编译原理的同学来说,这几讲可以帮助你复习以前学过的知识,把相关的知识点从遥远的记忆里再调出来,重温一下,以便更好地进入状态。
今天这一讲,我首先带你从宏观上理解一下整个编译过程。后面几讲中,我再针对编译过程中的每个阶段做细化讲解。
好了,让我们开始吧。
编译,其实就是把源代码变成目标代码的过程。如果源代码编译后要在操作系统上运行,那目标代码就是汇编代码,我们再通过汇编和链接的过程形成可执行文件,然后通过加载器加载到操作系统里执行。如果编译后是在解释器里执行,那目标代码就可以不是汇编代码,而是一种解释器可以理解的中间形式的代码即可。
我举一个很简单的例子。这里有一段C语言的程序我们一起来看看它的编译过程。
int foo(int a){
int b = a + 3;
return b;
}
这段源代码,如果把它编译成汇编代码,大致是下面这个样子:
.section __TEXT,__text,regular,pure_instructions
.globl _foo ## -- Begin function foo
_foo: ## @foo
pushq %rbp
movq %rsp, %rbp
movl %edi, -4(%rbp)
movl -4(%rbp), %eax
addl $3, %eax
movl %eax, -8(%rbp)
movl -8(%rbp), %eax
popq %rbp
retq
你可以看出,源代码和目标代码之间的差异还是很大的。那么,我们怎么实现这个翻译呢?
其实,编译和把英语翻译成汉语的大逻辑是一样的。前提是你要懂这两门语言,这样你看到一篇英语文章,在脑子里理解以后,就可以把它翻译成汉语。编译器也是一样,你首先需要让编译器理解源代码的意思,然后再把它翻译成另一种语言。
表面上看,好像从英语到汉语,一下子就能翻译过去。但实际上,大脑一瞬间做了很多个步骤的处理,包括识别一个个单词,理解语法结构,然后弄明白它的意思。同样,编译器翻译源代码,也需要经过多个处理步骤,如下图所示。
图1编译的各个阶段
我来解释一下各个步骤。
词法分析Lexical Analysis
首先,编译器要读入源代码。
在编译之前源代码只是一长串字符而已这显然不利于编译器理解程序的含义。所以编译的第一步就是要像读文章一样先把里面的单词和标点符号识别出来。程序里面的单词叫做Token它可以分成关键字、标识符、字面量、操作符号等多个种类。把字符串转换为Token的这个过程就叫做词法分析。
图2把字符串转换为Token注意其中的空白字符代表空格、tab、回车和换行符EOF是文件结束符
语法分析Syntactic Analysis
识别出Token以后离编译器明白源代码的含义仍然有很长一段距离。下一步我们需要让编译器像理解自然语言一样理解它的语法结构。这就是第二步语法分析。
上语文课的时候,老师都会让你给一个句子划分语法结构。比如说:“我喜欢又聪明又勇敢的你”,它的语法结构可以表示成下面这样的树状结构。
图3把一个句子变成语法树
那么在编译器里语法分析阶段也会把Token串转换成一个体现语法规则的、树状的数据结构这个数据结构叫做抽象语法树ASTAbstract Syntax Tree。我们前面的示例程序转换为AST以后大概是下面这个样子
图4foo函数对应的语法树
这样的一棵AST反映了示例程序的语法结构。比如说我们知道一个函数的定义包括了返回值类型、函数名称、0到多个参数和函数体等。这棵抽象语法树的顶部就是一个函数节点它包含了四个子节点刚好反映了函数的语法。
再进一步函数体里面还可以包含多个语句如变量声明语句、返回语句它们构成了函数体的子节点。然后每个语句又可以进一步分解直到叶子节点就不可再分解了。而叶子节点就是词法分析阶段生成的Token图中带边框的节点。对这棵AST做深度优先的遍历你就能依次得到原来的Token。
语义分析Semantic Analysis
生成AST以后程序的语法结构就很清晰了编译工作往前迈进了一大步。但这棵树到底代表了什么意思我们目前仍然不能完全确定。
比如说表达式“a+3”在计算机程序里的完整含义是“获取变量a的值把它跟字面量3的值相加得到最终结果。”但我们目前只得到了这么一棵树完全没有上面这么丰富的含义。
图5a+3对应的AST
这就好比西方的儿童,很小的时候就能够给大人读报纸。因为他们懂得发音规则,能念出单词来(词法分析),也基本理解语法结构(他们不见得懂主谓宾这样的术语,但是凭经验已经知道句子有不同的组成部分),可以读得抑扬顿挫(语法分析),但是他们不懂报纸里说的是什么,也就是不懂语义。这就是编译器解读源代码的下一步工作,语义分析。
那么,怎样理解源代码的语义呢?
实际上语言的设计者在定义类似“a+3”中加号这个操作符的时候是给它规定了一些语义的就是要把加号两边的数字相加。你在阅读某门语言的标准时也会看到其中有很多篇幅是在做语义规定。在ECMAScript也就是JavaScript标准2020版中Semantic这个词出现了657次。下图是其中加法操作的语义规则它对于如何计算左节点、右节点的值如何进行类型转换等都有规定。
图6ECMAScript标准中加法操作的语义规则
所以我们可以在每个AST节点上附加一些语义规则让它能反映语言设计者的本意。
add节点把两个子节点的值相加作为自己的值
变量节点(在等号右边的话):取出变量的值;
数字字面量节点:返回这个字面量代表的值。
这样的话如果你深度遍历AST并执行每个节点附带的语义规则就可以得到a+3的值。这意味着我们正确地理解了这个表达式的含义。运用相同的方法我们也就能够理解一个句子的含义、一个函数的含义乃至整段源代码的含义。
这也就是说AST加上这些语义规则就能完整地反映源代码的含义。这个时候你就可以做很多事情了。比如你可以深度优先地遍历AST并且一边遍历一边执行语法规则。那么这个遍历过程就是解释执行代码的过程。你相当于写了一个基于AST的解释器。
不过在此之前,编译器还要做点语义分析工作。那么这里的语义分析是要解决什么问题呢?
给你举个例子如果我把示例程序稍微变换一下加一个全局变量的声明这个全局变量也叫a。那你觉得“a+3”中的变量a指的是哪个变量
int a = 10; //全局变量
int foo(int a){ //参数里有另一个变量a
int b = a + 3; //这里的a指的是哪一个
return b;
}
我们知道编译程序要根据C语言在作用域方面的语义规则识别出“a+3”中的a所以这里指的其实是函数参数中的a而不是全局变量的a。这样的话我们在计算“a+3”的时候才能取到正确的值。
而把“a+3”中的a跟正确的变量定义关联的过程就叫做引用消解Resolve。这个时候变量a的语义才算是清晰了。
变量有点像自然语言里的代词,比如说,“我喜欢又聪明又勇敢的你”中的“我”和“你”,指的是谁呢?如果这句话前面有两句话,“我是春娇,你是志明”,那这句话的意思就比较清楚了,是“春娇喜欢又聪明又勇敢的志明”。
引用消解需要在上下文中查找某个标识符的定义与引用的关系,所以我们现在可以回答前面的问题了,语义分析的重要特点,就是做上下文相关的分析。
在语义分析阶段编译器还会识别出数据的类型。比如在计算“a+3”的时候我们必须知道a和3的类型是什么。因为即使同样是加法运算对于整型和浮点型数据其计算方法也是不一样的。
语义分析获得的一些信息引用消解信息、类型信息等会附加到AST上。这样的AST叫做带有标注信息的ASTAnnotated AST/Decorated AST用于更全面地反映源代码的含义。
图7带有标注信息的AST
好了,前面我所说的,都是如何让编译器更好地理解程序的语义。不过在语义分析阶段,编译器还要做很多语义方面的检查工作。
在自然语言里,我们可以很容易写出一个句子,它在语法上是正确的,但语义上是错误的。比如,“小猫喝水”这句话,它在语法和语义上都是对的;而“水喝小猫”这句话,语法是对的,语义上则是不对的。
计算机程序也会存在很多类似的语义错误的情况。比如说对于“int b = a+3”的这个语句语义规则要求等号右边的表达式必须返回一个整型的数据或者能够自动转换成整型的数据否则就跟变量b的类型不兼容。如果右边的表达式“a+3”的计算结果是浮点型的就违背了语义规则就要报错。
总结起来,在语义分析阶段,编译器会做语义理解和语义检查这两方面的工作。词法分析、语法分析和语义分析,统称编译器的前端,它完成的是对源代码的理解工作。
做完语义分析以后,接下来编译器要做什么呢?
本质上编译器这时可以直接生成目标代码因为编译器已经完全理解了程序的含义并把它表示成了带有语义信息的AST、符号表等数据结构。
生成目标代码的工作叫做后端工作。做这项工作有一个前提就是编译器需要懂得目标语言也就是懂得目标语言的词法、语法和语义这样才能保证翻译的准确性。这是显而易见的只懂英语不懂汉语是不可能做英译汉的。通常来说目标代码指的是汇编代码它是汇编器Assembler所能理解的语言跟机器码有直接的对应关系。汇编器能够将汇编代码转换成机器码。
熟练掌握汇编代码对于初学者来说会有一定的难度。但更麻烦的是对于不同架构的CPU还需要生成不同的汇编代码这使得我们的工作量更大。所以我们通常要在这个时候增加一个环节先翻译成中间代码Intermediate RepresentationIR
中间代码Intermediate Representation
中间代码IR是处于源代码和目标代码之间的一种表示形式。
我们倾向于使用IR有两个原因。
第一个原因是很多解释型的语言可以直接执行IR比如Python和Java。这样的话编译器生成IR以后就完成任务了没有必要生成最终的汇编代码。
第二个原因更加重要。我们生成代码的时候需要做大量的优化工作。而很多优化工作没有必要基于汇编代码来做而是可以基于IR用统一的算法来完成。
优化Optimization
那为什么需要做优化工作呢?这里又有两大类的原因。
第一个原因,是源语言和目标语言有差异。源语言的设计目的是方便人类表达和理解,而目标语言是为了让机器理解。在源语言里很复杂的一件事情,到了目标语言里,有可能很简单地就表达出来了。
比如“I want to hold your hand and with you I will grow old.” 这句话挺长的吧用了13个单词但它实际上是诗经里的“执子之手与子偕老”对应的英文。这样看来还是中国文言文承载信息的效率更高。
同样的情况在编程语言里也有。以Java为例我们经常为某个类定义属性然后再定义获取或修改这些属性的方法
Class Person{
private String name;
public String getName(){
return name;
}
public void setName(String newName){
this.name = newName
}
}
如果你在程序里用“person.getName()”来获取Person的name字段会是一个开销很大的操作因为它涉及函数调用。在汇编代码里实现一次函数调用会做下面这一大堆事情
#调用者的代码
保存寄存器1 #保存现有寄存器的值到内存
保存寄存器2
...
保存寄存器n
把返回地址入栈
把person对象的地址写入寄存器作为参数
跳转到getName函数的入口
#_getName 程序
在person对象的地址基础上添加一个偏移量得到name字段的地址
从该地址获取值,放到一个用于保存返回值的寄存器
跳转到返回地
你看了这段伪代码就会发现简单的一个getName()方法,开销真的很大。保存和恢复寄存器的值、保存和读取返回地址,等等,这些操作会涉及好几次读写内存的操作,要花费大量的时钟周期。但这个逻辑其实是可以简化的。
怎样简化呢就是跳过方法的调用。我们直接根据对象的地址计算出name属性的地址然后直接从内存取值就行。这样优化之后性能会提高好多倍。
这种优化方法就叫做内联inlining也就是把原来程序中的函数调用去掉把函数内的逻辑直接嵌入函数调用者的代码中。在Java语言里这种属性读写的代码非常多。所以Java的JIT编译器把字节码编译成本地代码很重要的工作就是实现内联优化这会让整体系统的性能提高很大的一个百分比
总结起来,我们在把源代码翻译成目标代码的过程中,没有必要“直译”,而是可以“意译”。这样我们完成相同的工作,对资源的消耗会更少。
第二个需要优化工作的原因是程序员写的代码不是最优的而编译器会帮你做纠正。比如下面这段代码中的bar()函数里面就有多个地方可以优化。甚至整个对bar()函数的调用也可以省略因为bar()的值一定是101。这些优化工作都可以在编译期间完成。
int bar(){
int a = 10*10; //这里在编译时可以直接计算出100这个值这叫做“常数折叠”
int b = 20; //这个变量没有用到,可以在代码中删除,这叫做“死代码删除”
if (a>0){ //因为a一定大于0所以判断条件和else语句都可以去掉
return a+1; //这里可以在编译器就计算出是101
}
else{
return a-1;
}
}
int a = bar(); //这里可以直接换成 a=101
综上所述,在生成目标代码之前,需要做的优化工作可以有很多,这通常也是编译器在运行时,花费时间最长的一个部分。
图8多个前端和多个后端可以采用统一的IR
而采用中间代码来编写优化算法的好处是可以把大部分的优化算法写成与具体CPU架构无关的形式从而大大降低编译器适配不同CPU的工作量。并且如果采用像LLVM这样的工具我们还可以让多种语言的前端生成相同的中间代码这样就可以复用中端和后端的程序了。
生成目标代码
编译器最后一个阶段的工作,是生成高效率的目标代码,也就是汇编代码。这个阶段,编译器也有几个重要的工作。
第一,是要选择合适的指令,生成性能最高的代码。
第二是要优化寄存器的分配让频繁访问的变量比如循环变量放到寄存器里因为访问寄存器要比访问内存快100倍左右。
第三是在不改变运行结果的情况下对指令做重新排序从而充分运用CPU内部的多个功能部件的并行计算能力。
目标代码生成以后,整个编译过程就完成了。
课程小结
本讲我从头到尾概要地讲解了编译的过程希望你能了解每一个阶段存在的原因Why以及要完成的主要任务What。编译是一个比较复杂的过程但如果我们能够分而治之那么每一步的挑战就会降低很多。这样最后针对每个子任务我们就都能找到解决的办法。
我希望这一讲能帮你在大脑里建立起一个概要的地图。在后面几讲中,我会对编译过程的各个环节展开讨论,让你有越来越清晰的理解。
一课一思
你觉得做计算机语言的编译和自然语言的翻译,有哪些地方是相同的,哪些地方是不同的?
欢迎在留言区分享你的见解,也欢迎你把今天的内容分享给更多的朋友。感谢阅读,我们下一讲再见。

View File

@ -0,0 +1,252 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
02 词法分析:用两种方式构造有限自动机
你好,我是宫文学。
上一讲,我带你把整个编译过程走了一遍。这样,你就知道了编译过程的整体步骤,每一步是做什么的,以及为什么要这么做。
进一步地,你就可以研究一下每个环节具体是如何实现的、有哪些难点、有哪些理论和算法。通过这个过程,你不仅可以了解每个环节的原理,还能熟悉一些专有词汇。这样一来,你在读编译原理领域的相关资料时,就会更加顺畅了。
不过,编译过程中涉及的算法和原理有些枯燥,所以我会用尽量通俗、直观的方式来给你解读,让你更容易接受。
本讲我主要跟你讨论一下词法分析Lexical Analysis这个环节。通过这节课你可以掌握词法分析这个阶段是如何把字符串识别成一个个Token的。进而你还会学到如何实现一个正则表达式工具从而实现任意的词法解析。
词法分析的原理
首先,我们来了解一下词法分析的原理。
通过上一讲你已经很熟悉词法分析的任务了输入的是字符串输出的是Token串。所以词法分析器在英文中一般叫做Tokenizer。
图1把字符串转换为Token注意其中的空白字符代表空格、tab、回车和换行符EOF是文件结束符
但具体如何实现呢这里要有一个计算模型叫做有限自动机Finite-state AutomatonFSA或者叫做有限状态自动机Finite-state MachineFSM
有限自动机这个名字,听上去可能比较陌生。但大多数程序员,肯定都接触过另一个词:状态机。假设你要做一个电商系统,那么订单状态的迁移,就是一个状态机。
图2状态机的例子订单的状态和迁移过程
有限自动机就是这样的状态机,它的状态数量是有限的。当它收到一个新字符的时候,会导致状态的迁移。比如说,下面的这个状态机能够区分标识符和数字字面量。
图3一个能够识别标识符和数字字面量的有限自动机
在这样一个状态机里我用单线圆圈表示临时状态双线圆圈表示接受状态。接受状态就是一个合格的Token比如图3中的状态1数字字面量和状态2标识符。当这两个状态遇到空白字符的时候就可以记下一个Token并回到初始态状态0开始识别其他Token。
可以看出,词法分析的过程,其实就是对一个字符串进行模式匹配的过程。说起字符串的模式匹配,你能想到什么工具吗?对的,正则表达式工具。
大多数语言以及一些操作系统的命令都带有正则表达式工具来帮助你匹配合适的字符串。比如下面的这个Linux命令可以用来匹配所有包含“sa”“sb” … “sh”字符串的进程。
ps -ef | grep 's[a-h]'
在这个命令里“s[a-h]”是用来描述匹配规则的,我们把它叫做一个正则表达式。
同样地正则表达式也可以用来描述词法规则。这种描述方法我们叫做正则文法Regular Grammar。比如数字字面量和标识符的正则文法描述是这样的
IntLiteral : [0-9]+; //至少有一个数字
Id : [A-Za-z][A-Za-z0-9]*; //以字母开头,后面可以是字符或数字
与普通的正则表达式工具不同的是词法分析器要用到很多个词法规则每个词法规则都采用“Token类型: 正则表达式”这样一种格式用于匹配一种Token。
然而当我们采用了多条词法规则的时候有可能会出现词法规则冲突的情况。比如说int关键字其实也是符合标识符的词法规则的。
Int : int; //int关键字
For : for; //for关键字
Id : [A-Za-z][A-Za-z0-9]*; //以字母开头,后面可以是字符或数字
所以词法规则里面要有优先级比如排在前面的词法规则优先级更高。这样的话我们就能够设计出区分int关键字和标识符的有限自动机了可以画成下面的样子。其中状态1、2和3都是标识符而状态4则是int关键字。
图4一个能够识别int关键字和标识符的有限自动机
从正则表达式生成有限自动机
现在,你已经了解了如何构造有限自动机,以及如何处理词法规则的冲突。基本上,你就可以按照上面的思路来手写词法分析器了。但你可能觉得,这样手写词法分析器的步骤太繁琐了,我们能否只写出词法规则,就自动生成相对应的有限自动机呢?
当然是可以的实际上正则表达式工具就是这么做的。此外词法分析器生成工具lex及GNU版本的flex也能够基于规则自动生成词法分析器。
它的具体实现思路是这样的把一个正则表达式翻译成NFA然后把NFA转换成DFA。对不起我这里又引入了两个新的术语NFA和DFA。
先说说DFA它是“Deterministic Finite Automaton”的缩写即确定的有限自动机。它的特点是该状态机在任何一个状态基于输入的字符都能做一个确定的状态转换。前面例子中的有限自动机都属于DFA。
再说说NFA它是“Nondeterministic Finite Automaton”的缩写即不确定的有限自动机。它的特点是该状态机中存在某些状态针对某些输入不能做一个确定的转换。
这又细分成两种情况:
对于一个输入,它有两个状态可以转换。
存在ε转换的情况也就是没有任何字符输入的情况下NFA也可以从一个状态迁移到另一个状态。
比如“a[a-zA-Z0-9]*bc”这个正则表达式对字符串的要求是以a开头以bc结尾a和bc之间可以有任意多个字母或数字。可以看到在图5中状态1的节点输入b时这个状态是有两条路径可以选择的一条是迁移到状态2另一条是仍然保持在状态1。所以这个有限自动机是一个NFA。
图5一个NFA的例子识别“a[a-zA-Z0-9]*bc”的自动机
这个NFA还有引入ε转换的画法如图6所示它跟图5的画法是等价的。实际上图6表示的NFA可以用我们下面马上要讲到的算法通过正则表达式自动生成出来。
图6另一个NFA的例子同样能识别“a[a-zA-Z0-9]*bc”其中有ε转换
需要注意的是无论是NFA还是DFA都等价于正则表达式。也就是说所有的正则表达式都能转换成NFA或DFA而所有的NFA或DFA也都能转换成正则表达式。
理解了NFA和DFA以后接下来我再大致说一下算法。
首先一个正则表达式可以机械地翻译成一个NFA。它的翻译方法如下
识别字符i的NFA。
当接受字符i的时候引发一个转换状态图的边上标注i。其中第一个状态iinitial是初始状态第二个状态(ffinal)是接受状态。
图7识别i的NFA
转换“s|t”这样的正则表达式。
它的意思是或者s或者t二者选一。s和t本身是两个子表达式我们可以增加两个新的状态开始状态和接受状态。然后用ε转换分别连接代表s和t的子图。它的含义也比较直观要么走上面这条路径那就是s要么走下面这条路径那就是t
图8识别s|t的NFA
转换“st”这样的正则表达式。
s之后接着出现t转换规则是把s的开始状态变成st整体的开始状态把t的结束状态变成st整体的结束状态并且把s的结束状态和t的开始状态合二为一。这样就把两个子图衔接了起来走完s接着走t。
图9识别st的NFA
对于“?”“*”和“+”这样的符号它们的意思是可以重复0次、0到多次、1到多次转换时要增加额外的状态和边。以“s*”为例,我们可以做下面的转换:
图10识别s*的NFA
你能看出它可以从i直接到f也就是对s匹配0次也可以在s的起止节点上循环多次。
如果是“s+”那就没有办法跳过ss至少要经过一次
图11识别s+的NFA
通过这样的转换所有的正则表达式都可以转换为一个NFA。
基于NFA你仍然可以实现一个词法分析器只不过算法会跟基于DFA的不同当某个状态存在一条以上的转换路径的时候你要先尝试其中的一条如果匹配不上再退回来尝试其他路径。这种试探不成功再退回来的过程叫做回溯Backtracking
小提示:下一讲的递归下降算法里,也会出现回溯现象,你可以对照着理解。
基于NFA你也可以写一个正则表达式工具。实际上我在示例程序中已经写了一个简单的正则表达式工具使用了Regex.java中的regexToNFA方法。如下所示我用了一个测试用的正则表达式它能识别int关键字、标识符和数字字面量。在示例程序中这个正则表达式首先被表示为一个内部的树状数据结构然后可以转换成NFA。
int | [a-zA-Z][a-zA-Z0-9]* | [0-9]*
示例程序也会将生成的NFA打印输出下面的输出结果中列出了所有的状态以及每个状态到其他状态的转换比如“0 ε -> 2”的意思是从状态0通过 ε 转换到达状态2
NFA states:
0 ε -> 2
ε -> 8
ε -> 14
2 i -> 3
3 n -> 5
5 t -> 7
7 ε -> 1
1 (end)
acceptable
8 [a-z]|[A-Z] -> 9
9 ε -> 10
ε -> 13
10 [0-9]|[a-z]|[A-Z] -> 11
11 ε -> 10
ε -> 13
13 ε -> 1
14 [0-9] -> 15
15 ε -> 14
ε -> 1
我用图片来直观展示一下输出结果分为上、中、下三条路径你能清晰地看出解析int关键字、标识符和数字字面量的过程
图12由算法自动生成的NFA
那么生成NFA之后我们要如何利用它来识别某个字符串是否符合这个NFA代表的正则表达式呢
还是以图12为例当我们解析“intA”这个字符串时首先选择最上面的路径进行匹配匹配完int这三个字符以后来到状态7若后面没有其他字符就可以到达接受状态1返回匹配成功的信息。
可实际上int后面是有A的所以第一条路径匹配失败。失败之后不能直接返回“匹配失败”的结果因为还有其他路径所以我们要回溯到状态0去尝试第二条路径在第二条路径中我们尝试成功了。
运行Regex.java中的matchWithNFA()方法你可以用NFA来做正则表达式的匹配。其中在匹配“intA”时你会看到它的回溯过程
NFA matching: 'intA'
trying state : 0, index =0
trying state : 2, index =0 //先走第一条路径即int关键字这个路径
trying state : 3, index =1
trying state : 5, index =2
trying state : 7, index =3
trying state : 1, index =3 //到了末尾,发现还有字符'A'没有匹配上
trying state : 8, index =0 //回溯,尝试第二条路径,即标识符
trying state : 9, index =1
trying state : 10, index =1 //在10和11这里循环多次
trying state : 11, index =2
trying state : 10, index =2
trying state : 11, index =3
trying state : 10, index =3
true
从中你可以看到用NFA算法的特点因为存在多条可能的路径所以需要试探和回溯在比较极端的情况下回溯次数会非常多性能会变得非常差。特别是当处理类似“s*”这样的语句时因为s可以重复0到无穷次所以在匹配字符串时可能需要尝试很多次。
NFA的运行可能导致大量的回溯那么能否将NFA转换成DFA让字符串的匹配过程更简单呢如果能的话那整个过程都可以自动化从正则表达式到NFA再从NFA到DFA。
方法是有的,这个算法就是子集构造法。不过我这里就不展开介绍了,如果你想继续深入学习的话,可以去看看本讲最后给出的参考资料。
总之只要有了准确的正则表达式是可以根据算法自动生成对字符串进行匹配的程序的这就是正则表达式工具的基本原理也是有些工具比如ANTLR和flex能够自动给你生成一个词法分析器的原理。
课程小结
本讲涵盖了词法分析所涉及的主要知识点。词法分析跟你日常使用的正则表达式关系很密切,你可以用正则表达式来表示词法规则。
在实际的编译器中词法分析器一般都是手写的依据的基本原理就是构造有限自动机。不过有一些地方也会用手工编码的方式做一些优化如javac编译器有些编译器会做用一些特别的技巧来提升解析速度如JavaScript的V8编译器你在后面的课程中会看到。
基于正则表达式构造NFA再去进行模式匹配是一个很好的算法思路它不仅仅可以用于做词法分析其实还可以用于解决其他问题比如做语法分析值得你去做举一反三的思考。
一课一思
你可以试着写出识别整型字面量和浮点型字面量的词法规则,手工构造一个有限自动机。
欢迎在留言区谈谈你的实践体会,也欢迎你把今天的内容分享给更多的朋友。
参考资料
关于从NFA转DFA的算法你可以参考_Compilers - Principles, Techniques & Tools_龙书第2版第3.7.1节或者《编译原理之美》的第16讲。

View File

@ -0,0 +1,393 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
03 语法分析:两个基本功和两种算法思路
你好,我是宫文学。
通过第1讲的学习现在你已经清楚了语法分析阶段的任务依据语法规则把Token串转化成AST。
今天,我就带你来掌握语法分析阶段的核心知识点,也就是两个基本功和两种算法思路。理解了这些重要的知识点,对于语法分析,你就不是外行了。
两个基本功:第一,必须能够阅读和书写语法规则,也就是掌握上下文无关文法;第二,必须要掌握递归下降算法。
两种算法思路:一种是自顶向下的语法分析,另一种则是自底向上的语法分析。
上下文无关文法Context-Free Grammar
在开始语法分析之前,我们要解决的第一个问题,就是如何表达语法规则。在上一讲中,你已经了解了,我们可以用正则表达式来表达词法规则,语法规则其实也差不多。
我还是以下面这个示例程序为例,里面用到了变量声明语句、加法表达式,我们看看语法规则应该怎么写:
int a = 2;
int b = a + 3;
return b;
第一种写法是下面这个样子,它看起来跟上一讲的词法规则差不多,都是左边是规则名称,右边是正则表达式。
startblockStmts ; //起始
block : '{' blockStmts '}' ; //语句块
blockStmts : stmt* ; //语句块中的语句
stmt = varDecl | expStmt | returnStmt | block; //语句
varDecl : type Id varInitializer ';' ; //变量声明
type : Int | Long ; //类型
varInitializer : '=' exp ; //变量初始化
expStmt : exp ';' ; //表达式语句
returnStmt : Return exp ';' ; //return语句
exp : add ; //表达式
add : add '+' mul | mul; //加法表达式
mul : mul '*' pri | pri; //乘法表达式
pri : IntLiteral | Id | '(' exp ')' ; //基础表达式
在语法规则里我们把冒号左边的叫做非终结符Non-terminal又叫变元Variable。非终结符可以按照右边的正则表达式来逐步展开直到最后都变成标识符、字面量、运算符这些不可再展开的符号也就是终结符Terminal。终结符其实也是词法分析过程中形成的Token。
提示:-
1.在本课程,非终结符以小写字母开头,终结符则以大写字母开头,或者是一个原始的字符串格式。-
2.在谈论语法分析的时候我们可以把Token和终结符这两个术语互换使用。
像这样左边是非终结符右边是正则表达式的书写语法规则的方式就叫做扩展巴科斯范式EBNF。你在ANTLR这样的语法分析器生成工具中经常会看到这种格式的语法规则。
对于EBNF的严格定义你可以去参考Wikipedia上的解释。
在教科书中我们还经常采用另一种写法就是产生式Production Rule又叫做替换规则Substitution Rule。产生式的左边是非终结符变元它可以用右边的部分替代中间通常会用箭头连接。
为了避免跟EBNF中的“*”号、“+”号等冲突在本节课中凡是采用EBNF格式就给字符串格式的终结符加引号左右两边用“::=”或冒号分隔开;凡是采用产生式,字符串就不加引号,并且采用“->”分隔产生式的左右两侧。
add -> add + mul
add -> mul
mul -> mul * pri
mul -> pri
也有个偷懒的写法,就是把同一个变元的多个产生式写在一起,用竖线分隔(但这时候,如果产生式里面原本就要用到“|”终结符,那么就要加引号来进行区分)。但也就仅此为止了,不会再引入“*”和“+”等符号否则就成了EBNF了。
add -> add + mul | mul
mul -> mul * pri | pri
产生式不用“ * ”和“+”来表示重复而是用迭代并引入“ε”空字符串。所以“blockStmts : stmt*”可以写成下面这个样子:
blockStmts -> stmt blockStmts | ε
总结起来语法规则是由4个部分组成的
一个有穷的非终结符(或变元)的集合;
一个有穷的终结符的集合;
一个有穷的产生式集合;
一个起始非终结符(变元)。
那么符合这四个特点的文法规则就叫做上下文无关文法Context-Free GrammarCFG
你可能会问,上下文无关文法和词法分析中用到的正则文法是否有一定的关系?
是的,正则文法是上下文无关文法的一个子集。其实,正则文法也可以写成产生式的格式。比如,数字字面量(正则表达式为“[0-9]+”)可以写成:
IntLiteral -> Digit IntLiteral1
IntLiteral1 -> Digit IntLiteral1
IntLiteral1 -> ε
Digit -> [0-9]
但是在上下文无关文法里产生式的右边可以放置任意的终结符和非终结符而正则文法只是其中的一个子集叫做线性文法Linear Grammar。它的特点是产生式的右边部分最多只有一个非终结符比如X->aYb其中a和b是终结符。
图1正则文法是上下文无关文法的子集
你可以试一下把上一讲用到的正则表达式“a[a-zA-Z0-9]*bc”写成产生式的格式它就符合线性文法的特点。
S0 -> aS1bc
S1 -> [a-zA-Z0-9]S1
S1 -> ε
但对于常见的语法规则来说正则文法是不够的。比如你最常用的算术表达式的规则就没法用正则文法表示因为有的产生式需要包含两个非终结符如“add + mul”。你可以试试看能把“2+3”“2+3*5”“2+3+4+5”等各种可能的算术表达式用一个正则表达式写出来吗实际是不可能的。
add -> add + mul
add -> mul
mul -> mul * pri
mul -> pri
好,现在你已经了解了上下文无关文法,以及它与正则文法的区别。可是,为什么它会叫“上下文无关文法”这样一个奇怪的名字呢?难道还有上下文相关的文法吗?
答案的确是有的。举个例子来说,在高级语言里,本地变量必须先声明,才能在后面使用。这种制约关系就是上下文相关的。
不过,在语法分析阶段,我们一般不管上下文之间的依赖关系,这样能使得语法分析的任务更简单。而对于上下文相关的情况,则放到语义分析阶段再去处理。
好了,现在你已经知道,用上下文无关文法可以描述程序的语法结构。学习编译原理,阅读和书写语法规则是一项基本功。针对高级语言中的各种语句,你要都能够手写出它们的语法规则来才可以。
接下来我们就要依据语法规则编写语法分析程序把Token串转化成AST。语法分析的算法有很多但有一个算法也是你必须掌握的一项基本功这就是递归下降算法。
递归下降算法Recursive Descent Parsing
递归下降算法其实很简单它的基本思路就是按照语法规则去匹配Token串。比如说变量声明语句的规则如下
varDecl : types Id varInitializer ';' ; //变量声明
varInitializer : '=' exp ; //变量初始化
exp : add ; //表达式
add : add '+' mul | mul; //加法表达式
mul : mul '*' pri | pri; //乘法表达式
pri : IntLiteral | Id | '(' exp ')' ; //基础表达式
如果写成产生式格式,是下面这样:
varDecl -> types Id varInitializer ';'
varInitializer -> '=' exp
varInitializer -> ε
exp -> add
add -> add + mul
add -> mul
mul -> mul * pri
mul -> pri
pri -> IntLiteral
pri -> Id
pri -> ( exp )
而基于这个规则做解析的算法如下:
匹配一个数据类型(types)
匹配一个标识符(Id),作为变量名称
匹配初始化部分(varInitializer),而这会导致下降一层,使用一个新的语法规则:
匹配一个等号
匹配一个表达式(在这个步骤会导致多层下降exp->add->mul->pri->IntLiteral)
创建一个varInitializer对应的AST节点并返回
如果没有成功地匹配初始化部分,则回溯,匹配ε,也就是没有初始化部分。
匹配一个分号
创建一个varDecl对应的AST节点并返回
用上述算法解析“int a = 2”就会生成下面的AST
图2“int a = 2”对应的AST
那么总结起来,递归下降算法的特点是:
对于一个非终结符,要从左到右依次匹配其产生式中的每个项,包括非终结符和终结符。
在匹配产生式右边的非终结符时,要下降一层,继续匹配该非终结符的产生式。
如果一个语法规则有多个可选的产生式那么只要有一个产生式匹配成功就行。如果一个产生式匹配不成功那就回退回来尝试另一个产生式。这种回退过程叫做回溯Backtracking
所以说,递归下降算法是非常容易理解的。它能非常有效地处理很多语法规则,但是它也有两个缺点。
第一个缺点就是著名的左递归Left Recursion问题。比如在匹配算术表达式时产生式的第一项就是一个非终结符add那么按照算法要下降一层继续匹配add。这个过程会一直持续下去无限递归下去。
add -> add + mul
所以,递归下降算法是无法处理左递归问题的。那么有什么解决办法吗?
你可能会说把产生式改成右递归不就可以了吗也就是add这个递归项在右边
add -> mul + add
这样确实可以避免左递归问题,但它同时也会导致结合性的问题。
举个例子来说我们按照上面的语法规则来解析“2+3+4”这个表达式会形成如下所示的AST。
图3结合性错误的AST
它会先计算“3+4”而不是先计算“2+3”。这破坏了加法的结合性规则加法运算本来应该是左结合的。
其实有一个标准的方法能避免左递归问题。我们可以改写原来的语法规则也就是引入add',把左递归变成右递归:
add -> mul add'
add' -> + mul add' | ε
接下来,我们用刚刚改写的规则再次解析一下 “2+3+4”这个表达式会得到下图中的AST
图4基于改写后的文法所生成的AST
你能看出这种改写方法虽然能够避免左递归问题但由于add'的规则是右递归的,采用标准的递归下降算法,仍然会出现运算符结合性的错误。那么针对这点,我们有没有解决办法呢?
有的,方法就是把递归调用转化成循环。这里利用了很多同学都知道的一个原理,即递归调用可以转化为循环。
其实我把上面的规则换成用EBNF方式来表达就很清楚了。在EBNF格式里允许用“*”号和“+”号表示重复:
add mul ('+' mul)*
所以说,对于('+'mul)*这部分我们其实可以写成一个循环。而在循环里我们可以根据结合性的要求手工生成正确的AST。它的伪代码如下
左子节点 = 匹配一个mul
while(下一个Token是+){
消化掉+
右子节点 = 匹配一个mul
用左、右子节点创建一个add节点
左子节点 = 该add节点
}
采用上面的算法就可以创建正确的AST如下图所示
图5结合性正确的AST
递归下降算法的第二个缺点,就是当产生式匹配失败的时候,必须要“回溯”,这就可能导致浪费。
这个时候我们有个针对性的解决办法就是预读后续的一个Token判断该选择哪个产生式。
以stmt变元为例考虑它的三个产生式分别是变量声明语句、表达式语句和return语句。那么在递归下降算法中我们可以在这里预读一个Token看看能否根据这个Token来选择某个产生式。
经过仔细观察你发现如果预读的Token是Int或Long就选择变量声明语句如果是IntLiteral、Id或左括号就选择表达式语句而如果是Return则肯定是选择return语句。因为这三个语句开头的Token是不重叠的所以你可以很明确地做出选择。
如果我们手写递归下降算法可以用肉眼识别出每次应该基于哪个Token选择用哪个产生式。但是对于一些比较复杂的语法规则我们要去看好几层规则这样比较辛苦。
那么能否有一个算法来自动计算出选择不同产生式的依据呢当然是有的这就是LL算法家族。
LL算法计算First和Follow集合
LL算法的要点就是计算First和Follow集合。
First集合是每个产生式开头可能会出现的Token的集合。就像stmt有三个产生式它的First集合如下表所示。
而stmt的First集合就是三个产生式的First集合的并集也是Int Long IntLiteral Id ( Return。
总体来说针对非终结符x它的First集合的计算规则是这样的
如果产生式以终结符开头那么把这个终结符加入First(x)
如果产生式以非终结符y开头那么把First(y)加入First(x);
如果First(y)包含ε那要把下一个项的First集合也加入进来以此类推
如果x有多个产生式那么First(x)是每个产生式的并集。
在计算First集合的时候具体可以采用“不动点法”。相关细节这里就不展开了你可以参考示例程序FirstFollowSet类的CalcFirstSets()方法运行示例程序能打印各个非终结符的First集合。
不过,这样是不是就万事大吉了呢?
其实还有一种特殊情况我们需要考虑那就是对于某个非终结符它自身会产生ε的情况。比如说示例文法中的blockStmts它是可能产生ε的也就是块中一个语句都没有。
block : '{' blockStmts '}' ; //语句块
blockStmts : stmt* ; //语句块中的语句
stmt = varDecl | expStmt | returnStmt; //语句
语法解析器在这个时候预读的下一个Token是什么呢是右花括号。这证明blockStmts产生了ε所以才读到了后续跟着的花括号。
对于某个非终结符后面可能跟着的Token的集合我们叫做Follow集合。如果预读到的Token在Follow中那么我们就可以判断当前正在匹配的这个非终结符产生了ε。
Follow的算法也比较简单以非终结符x为例
扫描语法规则看看x后面都可能跟着哪些符号
对于后面跟着的终结符都加到Follow(x)集合中去;
如果后面是非终结符y就把First(y)加Follow(x)集合中去;
最后如果First(y)中包含ε,就继续往后找;
如果x可能出现在程序结尾那么要把程序的终结符$加入到Follow(x)中去。
这样在计算了First和Follow集合之后你就可以通过预读一个Token来完全确定采用哪个产生式。这种算法就叫做LL(1)算法。
LL(1)中的第一个L是Left-to-right的缩写代表从左向右处理Token串。第二个L是Leftmost的缩写意思是最左推导。最左推导是什么呢就是它总是先把产生式中最左侧的非终结符展开完毕以后再去展开下一个。这也就相当于对AST从左子节点开始的深度优先遍历。LL(1)中的1指的是预读一个Token。
LR算法移进和规约
前面讲的递归下降和LL算法都是自顶向下的算法。还有一类算法是自底向上的其中的代表就是LR算法。
自顶向下的算法是从根节点逐层往下分解形成最后的AST而LR算法的原理呢则是从底下先拼凑出AST的一些局部拼图并逐步组装成一棵完整的AST。所以其中的关键之处在于如何“拼凑”。
假设我们采用下面的上下文无关文法,来推演一个实例,具体语法规则如下所示:
start->add
add->add+mul
add->mul
mul->mul*pri
mul->pri
pri->Int
pri->(add)
如果用于解析“2+3*5”最终会形成下面的AST
图62+3*5对应的AST
那算法是怎么从底部凑出这棵AST来的呢
LR算法和LL算法一样也是从左到右地消化掉Token。在第1步它会取出“2”这个Token放到一个栈里这个栈是用来组装AST的工作区。同时它还会预读下一个Token也就是“+”号,用来帮助算法做判断。
在下面的示意图里我画了一条橙色竖线竖线的左边是栈右边是预读到的一个Token。在做语法解析的过程中竖线会不断地往右移动把Token放到栈里这个过程叫做“移进”Shift
图7第1步移进一个Token
注意我在图7中还用虚线框推测了AST的其他部分。也就是说如果第一个Token遇到的是整型字面量而后面跟着一个+号那么这两个Token就决定了它们必然是这棵推测出来的AST的一部分。而图中右边就是它的推导过程其中的每个步骤都使用了一个产生式加了一个点如“.add”。这个点就相当于图中左边的橙色竖线。
所以你就可以根据这棵假想的AST也就是依据假想的推导过程给它反推回去。把Int还原为pri。这个还原过程就叫做“规约”Reduce。工作区里的元素也随之更新成pri。
图8第2步Int规约为pri
按照这样的思路不断地移进和规约这棵AST中推测出来的节点会不断地被证实。而随着读入的Token越来越多这棵AST也会长得越来越高整棵树变得更大。下图是推导过程中间的一个步骤。
图9移进和规约过程中的一个步骤
最后整个AST构造完毕而工作区里也就只剩了一个Start节点。
图10最后一步add规约为start
通过上面的介绍你应该已经建立了对LR算法的直觉认识。如果要把这个推导过程写成严密的算法你可以参考《编译原理之美》的第18讲。
从示例中你应该已经看出来了相对于LL算法LR算法的优点是能够处理左递归文法。但它也有缺点比如不利于输出全面的编译错误信息。因为在没有解析完毕之前算法并不知道最后的AST是什么样子所以也不清楚当前的语法错误在整体AST中的位置。
最后我再提一下LR的意思来帮你更完整地理解LR算法。L还是代表从左到右读入Token而R是最右推导Rightmost的意思。我把“2+3*5”最右推导的过程写在了下面而如果你从最后一行往前一步步地看它恰好就是规约的过程。
如果你见到LR(k)那它的意思就是会预读k个Token我们在示例中采用的是LR(1)。
课程小结
今天花了一讲的时间,把语法分析的要点给你讲解了一下。
对于上下文无关的文法你要知道产生式、非终结符、终结符、EBNF这几个基本概念能够熟练阅读各种语言的语法规则这是一个基本功。
递归下降算法是另一项基本功,所以也一定要掌握。你要注意,递归下降是深度优先的,只有最左边的子树都生成完了,才会往右生成它的兄弟节点。有的同学会在没有把左侧的非终结符匹配完毕的情况下,就开始匹配右边的项,从而不自觉地采用了宽度优先的思路,这是我发现很多同学会容易陷入的一个思维误区。
对于LL算法和LR算法我只做了简单的讲解目的是为了帮助你建立直观的理解。我们在后面的课程中还会遇到使用它们的实际例子到时你可以与这一讲的内容相互印证。
一课一思
你可以计算一下示例文法中block、blockStmts、stmt、varDecl、returnStmt和expStmt的First和Follow集合吗这样你也可以熟悉一下First和Follow集合的计算方法。
欢迎在留言区分享你的答案。如果觉得有收获,也欢迎你把这节课分享给你的朋友。
参考资料
1.线性文法Linear Grammar参见Wikipedia。-
2.左递归及其消除方法参见Wikipedia。

View File

@ -0,0 +1,239 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
04 语义分析:让程序符合语义规则
你好,我是宫文学。这一讲,我们进入到语义分析阶段。
对计算机程序语义的研究,是一个专门的学科。要想很简单地把它讲清楚,着实不是太容易的事情。但我们可以退而求其次,只要能直观地去理解什么是语义就可以了。语义,就是程序要表达的意思。
因为计算机最终是用来做计算的,那么理解程序表达的意思,就是要知道让计算机去执行什么计算动作,这样才好翻译成目标代码。
那具体来说语义分析要做什么工作呢我们在第1讲中说过每门计算机语言的标准中都会定义很多语义规则比如对加法运算要执行哪些操作。而在语义分析阶段就是去检查程序是否符合这些语义规则并为后续的编译工作收集一些语义信息比如类型信息。
再具体一点,这些语义规则可以分为两大类。
第一类规则与上下文有关。因为我们说了,语法分析只能处理与上下文无关的工作。而与上下文有关的工作呢,自然就放到了语义分析阶段。
第二类规则与类型有关。在计算机语言中,类型是语义的重要载体。所以,语义分析阶段要处理与类型有关的工作。比如,声明新类型、类型检查、类型推断等。在做类型分析的时候,我们会用到一个工具,就是属性计算,也是需要你了解和掌握的。
补充:某些与类型有关的处理工作,还必须到运行期才能去做。比如,在多态的情况,调用一个方法时,到底要采用哪个子类的实现,只有在运行时才会知道。这叫做动态绑定。
在语义分析过程中会使用两个数据结构。一个还是AST但我们会把语义分析时获得的一些信息标注在AST上形成带有标注的AST。另一个是符号表用来记录程序中声明的各种标识符并用于后续各个编译阶段。
那今天这一讲,我就会带你看看如何完成与上下文有关的分析、与类型有关的处理,并带你认识符号表和属性计算。
首先,我们来学习如何处理与上下文有关的工作。
上下文相关的分析
那什么是与上下文有关的工作呢?在解析一个程序时,会有非常多的分析工作要结合上下文来进行。接下来,我就以控制流检查、闭包分析和引用消解这三个场景和你具体分析下。
场景1控制流检查
像return、break和continue等语句都与程序的控制流有关它们必须符合控制流方面的规则。在Java这样的语言中语义规则会规定如果返回值不是void那么在退出函数体之前一定要执行一个return语句那么就要检查所有的控制流分支是否都以return语句结尾。
场景2闭包分析
很多语言都支持闭包。而要正确地使用闭包,就必须在编译期知道哪些变量是自由变量。这里的自由变量是指在本函数外面定义的变量,但被这个函数中的代码所使用。这样,在运行期,编译器就会用特殊的内存管理机制来管理这些变量。所以,对闭包的分析,也是上下文敏感的。
场景3引用消解
我们重点说一下引用消解,以及相关的作用域问题。
引用消解Reference Resolution有时也被称作名称消解Name Resolution或者标签消解Label Resolution。对变量名称、常量名称、函数名称、类型名称、包名称等的消解都属于引用消解。因此引用消解是一种非常重要的上下文相关的语义规则我来重点讲解下。
在高级语言里,我们会做变量、函数(或方法)和类型的声明,然后在其他地方使用它们。这个时候,我们要找到定义和使用之间的正确引用关系。
我们来看一个例子。在语法分析阶段对于“int b = a + 3”这样一条语句无论a是否提前声明过在语法上都是正确的。而在实际的计算机语言中如果引用某个变量这个变量就必须是已经声明过的。同时当前这行代码要处于变量a的作用域中才行。
图1变量引用的消解
对于变量来说为了找到正确的引用就需要用到作用域Scope这个概念。在编译技术里面作用域这个词有两个稍微有所差异的使用场景。
作用域的第一个使用场景,指的是变量、函数等标识符可以起作用的范围。下图列出了三个变量的作用域,每个变量声明完毕以后,它的下一句就可以引用它。
图2变量的作用域
作用域的第二个使用场景是词法作用域Lexical Scope也就是程序中的不同文本区域。比如一个语句块、参数列表、类定义的主体、函数方法的主体、模块主体、整个程序等。
到这里,咱们来总结下这两个使用场景。标识符和词法的作用域的差异在于:一个本地变量(标识符)的作用域,虽然属于某个词法作用域(如某个函数体),但其作用范围只是在变量声明之后的语句。而类的成员变量(标识符)的作用域,跟词法作用域是一致的,也就是整个类的范围,跟声明的位置无关。如果这个成员变量不是私有的,它的作用域还会覆盖到子类。
那具体到不同的编程语言它们的作用域规则是不同的。比如C语言里允许你在一个if语句块里定义一个变量覆盖外部的变量而Java语言就不允许这样。所以在给Java做语义分析时我们要检查出这种错误。
void foo(){
int a = 2;
if (...){
int a = 3; //在C语言里允许在Java里不允许
...
}
}
在做引用消解的时候,为了更好地查找变量、类型等定义信息,编译器会使用一个辅助的数据结构:符号表。
符号表Symbol Table
在写程序的时候我们会定义很多标识符比如常量名称、变量名称、函数名称、类名称等等。在编译器里我们又把这些标识符叫做符号Symbol。用来保存这些符号的数据结构就叫做符号表。
比如对于变量a来说符号表中的基本信息可以包括
名称a
分类:变量
类型int
作用域foo函数体
其他必要的信息。
符号表的具体实现,每个编译器可能都不同。比如,它可能是一张线性的表格,也可能是按照作用域形成的一种有层次的表格。以下面这个程序为例,它包含了两个函数,每个函数里面都定义了多个变量:
void foo(){
int a
int b
if (a>0){
int c;
int d;
}
else{
int e;
int f;
}
}
void bar(){
int g;
{
int h;
int i;
}
}
它的符号表可能是下面这样的分成了多个层次每个层次对应了一个作用域。在全局作用域符号表里包含foo和bar两个函数。在foo函数体里有两个变量a和b还有两个内部块每个块里各有两个变量。
图3一种层次化的符号表
那针对引用消解,其实就是从符号表里查找被引用的符号的定义,如下图所示:
图4利用符号表帮助做引用消解
更进一步地,符号表除了用于引用消解外,还可以辅助完成语义分析的其他工作。比如,在做类型检查的时候,我们可以从符号表里查找某个符号的类型,从而检查类型是否兼容。
其实不仅仅是在语义分析阶段会用到符号表其他的编译阶段也会用到。比如早在词法分析阶段你就可以为符号表建立条目在生成IR、做优化和生成目标代码的时候都会用到符号表里的信息。
图5编译过程中的每个阶段都可能会使用符号表
有的编译器在前期做语法分析的时候如果不依赖符号表的话它是不可能完整地做语法分析的。甚至除了编译阶段在链接阶段我们也要用到符号表。比如在foo.c中定义了一个函数foo()并编译成目标文件foo.o在bar.c中使用了这个foo()函数。那么在链接的时候链接器需要找到foo()函数的地址。为了满足这个场景你必须在目标文件中找到foo符号的相关信息。
同样的道理在Java的字节码文件里也需要保存符号信息以便在加载后我们可以定位其中的类、方法和成员变量。
好了,以上就是语义分析的第一项重要工作上下文相关的分析,以及涉及的数据结构符号表的重点内容了。我们再来考察一下语义分析中第二项重要的工作:类型分析和处理。
类型分析和处理
语义分析阶段的一个重要工作就是做类型检查,现代语言还普遍增加了类型推断的能力。那什么是类型呢?
通常来说在计算机语言里类型是数据的一个属性它的作用是来告诉编译器或解释器程序可以如何使用这些数据。比如说对于整型数据它可能占32或者64位存储我们可以对它做加减乘除操作。而对于字符串它可能占很多个字节并且通过一定的编码规则来表示字符。字符串可以做连接、查找、获取子字符串等操作但不能像整数一样做算术运算。
一门语言的类型系统是包含了与类型有关的各种规则的一个逻辑系统。类型系统包含了一系列规则,规定了如何把类型用于变量、表达式和函数等程序元素,以及如何创建自定义类型,等等。比如,如果你定义了某个类有哪些方法,那你就只能通过调用这些方法来使用这个类,没有别的方法。这些强制规定减少了程序出错的可能性。
所以在语义分析阶段,一个重要的工作就是做类型检查。
那么,类型检查是怎样实现的呢?我们要如何做类型检查呢?
关于类型检查编译器一般会采用属性计算的方法来计算出每个AST节点的类型属性然后检查它们是否匹配。
属性计算
以“int b = a+3”为例它的AST如下图所示。编译器会计算出b节点所需的类型和init节点的实际类型比较它们是否一致或者可以自动转换
图6“int b = a+3”对应的AST
我们首先要计算等号右边“a+3”的类型。其中3是个整型字面量我们可以据此把它的类型标注为整型a是一个变量它的类型可以从符号表中查到也是整型。
那么“a+3”是什么类型呢根据加法的语义两个整型数据相加结果仍然是整型因此“a+3”这个表达式整体是整型的。因为init只有一个子节点add所以init的类型也一样是整型。
在刚才这段推理中我们实际上是依据“a+3”的AST从下级节点的类型计算出上级节点的类型。
那么我们能否以同样的方法计算b节点的类型呢答案是不可以。因为b根本没有子节点。但声明变量b的时候有个int关键字所以在AST中b有一个兄弟节点就是int关键字。根据变量声明的语义b的类型就是int因此它的类型是从AST的兄弟节点中获得的。
你看同样是计算AST节点的类型等号右边和左边的计算方法是不一样的。
实际上,我们刚才用的分析方法,就是属性计算。其中,有些属性是通过子节点计算出来的,这叫做 S属性Synthesized Attribute综合出来的属性比如等号右边的类型。而另一些属性则要根据父节点或者兄弟节点计算而来这种属性叫做 I属性Inherited Attribute继承到的属性比如等号左边的b变量的类型。
计算出来的属性我们可以标注在AST上这就形成我第1讲曾经提过的带有标注信息的ASTAnnotated Tree也有人称之为Decorated Tree或者Attributed Tree。虽然叫法有很多但都是一个意思都是向AST中添加了语义信息。
图7带有标注信息的AST
属性计算的方法就是基于语法规则来定义一些属性计算的规则在遍历AST的时候执行这些规则我们就可以计算出属性值。这种基于语法规则定义的计算规则被叫做属性文法Attribute Grammar
补充:基于属性计算的方法可以做类型检查,那其实也可以做类型推断。有些现代语言在声明一个变量的时候,可以不明确指定的类型,那么它的类型就可以通过变量声明语句的右边部分推断出来。
你可能会问,属性计算的方法,除了计算类型,还可以计算什么属性呢?
根据不同语言的语义可能有不同的属性需要计算。其实value也可以看做是一个属性你可以给每个节点定义一个“value”属性。对表达式求值也就是对value做属性计算比如“a + 3”的值我们就可以自下而上地计算出来。这样看起来value是一个S属性。
针对value这个属性的属性文法你可以参考下面这个例子在做语法解析或先解析成AST再遍历AST的时候执行方括号中的规则我们就可以计算出AST的值了。
add1 → add2 + mul [ add1.value = add2.value + mul.value ]
add → mul [ add.value = mul.value ]
mul1 → mul2 * primary [ mul1.value = mul2.value * primary.value ]
mul → primary [ mul.value = primary.value ]
primary → ( add ) [ primary.value = add.value ]
primary → integer [ primary.value = strToInt(integer.str) ]
这种在语法规则上附加一系列动作在解析语法的时候执行这些动作的方式是一种编译方法在龙书里有一个专门的名字叫做语法制导的翻译Syntax Directed TranslationSDT。使用语法制导的翻译可以做很多事情包括做属性计算、填充符号表以及生成IR。
课程小结
在实际的编译器中,语义分析相关的代码量往往要比词法分析和语法分析的代码量大。因为一门语言有很多语义规则,所以要做的语义分析和检查工作也很多。
并且,因为每门语言之间的差别主要都体现在语义上,所以每门语言在语义处理方面的工作差异也比较大。比如,一门语言支持闭包,另一门语言不支持;有的语言支持泛型,另一门语言不支持;一门语言的面向对象特性是基于继承实现的,而另一门语言则是基于组合实现的,等等。
不过,这没啥关系。我们主要抓住它们的共性就好了。这些共性,就是我们本讲的内容:
做好上下文相关的分析,比如对各种引用的消解、控制流的检查、闭包的分析等;
做好与类型有关的分析和处理,包括类型检查、类型推断等;
掌握属性计算这个工具,用于计算类型、值等属性;
最后把获得的语义信息保存到符号表和AST里。
我把本讲的知识点也整理成了脑图,供你参考:
一课一思
你能否阅读你所熟悉的编程语言的标准查看其中的语义规则并选择一组有意思的语义规则比如第1讲提到的ECMAScript中加法操作符的语义规则分析一下在语义分析阶段要针对这组语义规则做哪些处理工作
欢迎在留言区分享你的答案,也欢迎你把今天的内容分享给更多的朋友。
参考资料
关于计算机程序的语义进行处理的形式化方法你可以参考The Formal Semantics of Programming Languages: An Introduction
关于数据类型、类型系统、类型理论的定义你可以参考Wikipedia。
《编译原理之美》的第8讲中有关于如何在计算机语言里实现作用域的介绍可以加深你对作用域的理解。