diff --git a/专栏/编译原理实战课/00开篇词在真实世界的编译器中游历.md b/专栏/编译原理实战课/00开篇词在真实世界的编译器中游历.md new file mode 100644 index 0000000..b2fc728 --- /dev/null +++ b/专栏/编译原理实战课/00开篇词在真实世界的编译器中游历.md @@ -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);有高级语言,也有DSL(SQL);解释执行的话,有的是用栈机(Stack Machine),有的是用寄存器机,等等。不同的语言特性,就导致了编译器采用的技术会存在各种差异,从而更加有利于你开阔视野。 +第三,通过研究多种编译器,你可以多次迭代对编译器的认知过程,并通过分析对比,发现这些编译器之间的异同点,探究其中的原因,激发出更多的思考,从而得到更全面的、更深入的认知。 + + +看到这里,你可能会有所疑虑:有些语言我没用过,不怎么了解,怎么办?其实没关系。因为现代的高级语言,其实相似度很高。 + +一方面,对于不熟悉的语言,虽然你不能熟练地用它们来做项目,但是写一些基本的、试验性的程序,研究它的实现机制,是没有什么问题的。 + +另一方面,学习编译原理的人会练就一项基本功,那就是更容易掌握一门语言的本质。特别是我这一季的课程,就是要帮你成为钻到了铁扇公主肚子里的孙悟空。研究某一种语言的编译器,当然有助于你通过“捷径”去深入地理解它。 + +我是如何规划课程模块的? + +这门课程的目标,是要让你对现代语言的编译器的结构、所采用的算法以及设计上的权衡,都获得比较真切的认识。其最终结果是,如果要你使用编译技术来完成一个项目,你会心里非常有数,知道应该在什么地方使用什么技术。因为你不仅懂得原理,更有很多实际编译器的设计和实现的思路作为你的决策依据。 + +为了达到本课程的目标,我仔细规划了课程的内容,将其划分为预备知识篇、真实编译器解析篇和现代语言设计篇三部分。 + +在预备知识篇,我会简明扼要地帮你重温一下编译原理的知识体系,让你对这些关键概念的理解变得更清晰。磨刀不误砍柴工,你学完预备知识篇后,再去看各种语言编译器的源代码和相关文档时,至少不会被各种名词、术语搞晕,也能更好地建立具体实现跟原理之间的关联,能互相印证它们。 + +在真实编译器解析篇,我会带你研究语言编译器的源代码,跟踪它们的运行过程,分析编译过程的每一步是如何实现的,并对有特点的编译技术点加以分析和点评。这样,我们在研究了Java、Java JIT、Python、JavaScript、Julia、Go、MySQL这7个编译器以后,就相当于把编译原理印证了7遍。 + +在现代语言设计篇,我会带你分析和总结前面已经研究过的编译器,进一步提升你对相关编译技术的认知高度。学完这一模块以后,你对于如何设计编译器的前端、中端、后端、运行时,都会有比较全面的了解,知道如何在不同的技术路线之间做取舍。 + +好了,以上就是这一季课程的模块划分思路了。你会发现,这次的课程设计,除了以研究真实编译器为主要手段外,会更加致力于扩大你的知识版图、增加你的见识,达到“行万里路”的目的。 + +可以说,我在设计和组织这一季课程时,花了大量的时间准备。因此这一季课程的内容,不说是独一无二的,也差不多了。你在市面上很少能找到解析实际编译器的书籍和资料,这里面的很多内容,都是在我自己阅读源代码、跟踪源代码执行过程的基础上梳理出来的。 + +写在最后 + +近些年,编译技术在全球范围内的进步速度很快。比如,你在学习Graal编译器的时候,你可以先去看看,市面上有多少篇围绕它的高质量论文。所以呢,作为老师,我觉得我有责任引导你去看到、理解并抓住这些技术前沿。 + +我也有一个感觉,在未来10年左右,中国在编译技术领域,也会逐步有拿得出手的作品出来,甚至会有我们独特的创新之处,就像我们当前在互联网、5G等领域中做到的一样。 + +虽然这个课程不可能涵盖编译技术领域所有的创新点,但我相信,你在其中投入的时间和精力是值得的。你通过我课程中教给你的方法,可以对你所使用的语言产生更加深入的认知,对编译器的内部结构和原理有清晰理解。最重要的是,对于如何采用编译技术来解决实际问题,你也会有能力做出正确的决策。 + +这样,这个课程就能起到抛砖引玉的作用,让我们能够成为大胆探索、勇于创新的群体的一份子。未来中国在编译技术的进步,就很可能有来自我们的贡献。我们一起加油! + +最后,我还想正式认识一下你。你可以在留言区里做个自我介绍,和我聊聊,你目前学习编译原理的最大难点在哪?或者,你也可以聊聊你对编译原理都有哪些独特的思考和体验,欢迎在留言区和我交流讨论。 + +好了,让我们正式开始编译之旅吧! + + + + \ No newline at end of file diff --git a/专栏/编译原理实战课/01编译的全过程都悄悄做了哪些事情?.md b/专栏/编译原理实战课/01编译的全过程都悄悄做了哪些事情?.md new file mode 100644 index 0000000..fbbf2c5 --- /dev/null +++ b/专栏/编译原理实战课/01编译的全过程都悄悄做了哪些事情?.md @@ -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串,转换成一个体现语法规则的、树状的数据结构,这个数据结构叫做抽象语法树(AST,Abstract Syntax Tree)。我们前面的示例程序转换为AST以后,大概是下面这个样子: + + + +图4:foo函数对应的语法树 + +这样的一棵AST反映了示例程序的语法结构。比如说,我们知道一个函数的定义包括了返回值类型、函数名称、0到多个参数和函数体等。这棵抽象语法树的顶部就是一个函数节点,它包含了四个子节点,刚好反映了函数的语法。 + +再进一步,函数体里面还可以包含多个语句,如变量声明语句、返回语句,它们构成了函数体的子节点。然后,每个语句又可以进一步分解,直到叶子节点,就不可再分解了。而叶子节点,就是词法分析阶段生成的Token(图中带边框的节点)。对这棵AST做深度优先的遍历,你就能依次得到原来的Token。 + +语义分析(Semantic Analysis) + +生成AST以后,程序的语法结构就很清晰了,编译工作往前迈进了一大步。但这棵树到底代表了什么意思,我们目前仍然不能完全确定。 + +比如说,表达式“a+3”在计算机程序里的完整含义是:“获取变量a的值,把它跟字面量3的值相加,得到最终结果。”但我们目前只得到了这么一棵树,完全没有上面这么丰富的含义。 + + + +图5:a+3对应的AST + +这就好比西方的儿童,很小的时候就能够给大人读报纸。因为他们懂得发音规则,能念出单词来(词法分析),也基本理解语法结构(他们不见得懂主谓宾这样的术语,但是凭经验已经知道句子有不同的组成部分),可以读得抑扬顿挫(语法分析),但是他们不懂报纸里说的是什么,也就是不懂语义。这就是编译器解读源代码的下一步工作,语义分析。 + +那么,怎样理解源代码的语义呢? + +实际上,语言的设计者在定义类似“a+3”中加号这个操作符的时候,是给它规定了一些语义的,就是要把加号两边的数字相加。你在阅读某门语言的标准时,也会看到其中有很多篇幅是在做语义规定。在ECMAScript(也就是JavaScript)标准2020版中,Semantic这个词出现了657次。下图是其中加法操作的语义规则,它对于如何计算左节点、右节点的值,如何进行类型转换等,都有规定。 + + + +图6:ECMAScript标准中加法操作的语义规则 + +所以,我们可以在每个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叫做带有标注信息的AST(Annotated AST/Decorated AST),用于更全面地反映源代码的含义。 + + + +图7:带有标注信息的AST + +好了,前面我所说的,都是如何让编译器更好地理解程序的语义。不过在语义分析阶段,编译器还要做很多语义方面的检查工作。 + +在自然语言里,我们可以很容易写出一个句子,它在语法上是正确的,但语义上是错误的。比如,“小猫喝水”这句话,它在语法和语义上都是对的;而“水喝小猫”这句话,语法是对的,语义上则是不对的。 + +计算机程序也会存在很多类似的语义错误的情况。比如说,对于“int b = a+3”的这个语句,语义规则要求,等号右边的表达式必须返回一个整型的数据(或者能够自动转换成整型的数据),否则就跟变量b的类型不兼容。如果右边的表达式“a+3”的计算结果是浮点型的,就违背了语义规则,就要报错。 + +总结起来,在语义分析阶段,编译器会做语义理解和语义检查这两方面的工作。词法分析、语法分析和语义分析,统称编译器的前端,它完成的是对源代码的理解工作。 + +做完语义分析以后,接下来编译器要做什么呢? + +本质上,编译器这时可以直接生成目标代码,因为编译器已经完全理解了程序的含义,并把它表示成了带有语义信息的AST、符号表等数据结构。 + +生成目标代码的工作,叫做后端工作。做这项工作有一个前提,就是编译器需要懂得目标语言,也就是懂得目标语言的词法、语法和语义,这样才能保证翻译的准确性。这是显而易见的,只懂英语,不懂汉语,是不可能做英译汉的。通常来说,目标代码指的是汇编代码,它是汇编器(Assembler)所能理解的语言,跟机器码有直接的对应关系。汇编器能够将汇编代码转换成机器码。 + +熟练掌握汇编代码对于初学者来说会有一定的难度。但更麻烦的是,对于不同架构的CPU,还需要生成不同的汇编代码,这使得我们的工作量更大。所以,我们通常要在这个时候增加一个环节:先翻译成中间代码(Intermediate Representation,IR)。 + +中间代码(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)。编译是一个比较复杂的过程,但如果我们能够分而治之,那么每一步的挑战就会降低很多。这样最后针对每个子任务,我们就都能找到解决的办法。 + +我希望这一讲能帮你在大脑里建立起一个概要的地图。在后面几讲中,我会对编译过程的各个环节展开讨论,让你有越来越清晰的理解。 + + + +一课一思 + +你觉得做计算机语言的编译和自然语言的翻译,有哪些地方是相同的,哪些地方是不同的? + +欢迎在留言区分享你的见解,也欢迎你把今天的内容分享给更多的朋友。感谢阅读,我们下一讲再见。 + + + + \ No newline at end of file diff --git a/专栏/编译原理实战课/02词法分析:用两种方式构造有限自动机.md b/专栏/编译原理实战课/02词法分析:用两种方式构造有限自动机.md new file mode 100644 index 0000000..14967fa --- /dev/null +++ b/专栏/编译原理实战课/02词法分析:用两种方式构造有限自动机.md @@ -0,0 +1,252 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 02 词法分析:用两种方式构造有限自动机 + 你好,我是宫文学。 + +上一讲,我带你把整个编译过程走了一遍。这样,你就知道了编译过程的整体步骤,每一步是做什么的,以及为什么要这么做。 + +进一步地,你就可以研究一下每个环节具体是如何实现的、有哪些难点、有哪些理论和算法。通过这个过程,你不仅可以了解每个环节的原理,还能熟悉一些专有词汇。这样一来,你在读编译原理领域的相关资料时,就会更加顺畅了。 + +不过,编译过程中涉及的算法和原理有些枯燥,所以我会用尽量通俗、直观的方式来给你解读,让你更容易接受。 + +本讲,我主要跟你讨论一下词法分析(Lexical Analysis)这个环节。通过这节课,你可以掌握词法分析这个阶段是如何把字符串识别成一个个Token的。进而,你还会学到如何实现一个正则表达式工具,从而实现任意的词法解析。 + +词法分析的原理 + +首先,我们来了解一下词法分析的原理。 + +通过上一讲,你已经很熟悉词法分析的任务了:输入的是字符串,输出的是Token串。所以,词法分析器在英文中一般叫做Tokenizer。 + + + +图1:把字符串转换为Token(注意:其中的空白字符,代表空格、tab、回车和换行符,EOF是文件结束符) + +但具体如何实现呢?这里要有一个计算模型,叫做有限自动机(Finite-state Automaton,FSA),或者叫做有限状态自动机(Finite-state Machine,FSM)。 + +有限自动机这个名字,听上去可能比较陌生。但大多数程序员,肯定都接触过另一个词:状态机。假设你要做一个电商系统,那么订单状态的迁移,就是一个状态机。 + + + +图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。其中,第一个状态(i,initial)是初始状态,第二个状态(f,final)是接受状态。 + + + +图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+”,那就没有办法跳过s,s至少要经过一次: + + + +图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讲。 + + + + \ No newline at end of file diff --git a/专栏/编译原理实战课/03语法分析:两个基本功和两种算法思路.md b/专栏/编译原理实战课/03语法分析:两个基本功和两种算法思路.md new file mode 100644 index 0000000..bb2ff18 --- /dev/null +++ b/专栏/编译原理实战课/03语法分析:两个基本功和两种算法思路.md @@ -0,0 +1,393 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 03 语法分析:两个基本功和两种算法思路 + 你好,我是宫文学。 + +通过第1讲的学习,现在你已经清楚了语法分析阶段的任务:依据语法规则,把Token串转化成AST。 + +今天,我就带你来掌握语法分析阶段的核心知识点,也就是两个基本功和两种算法思路。理解了这些重要的知识点,对于语法分析,你就不是外行了。 + + +两个基本功:第一,必须能够阅读和书写语法规则,也就是掌握上下文无关文法;第二,必须要掌握递归下降算法。 +两种算法思路:一种是自顶向下的语法分析,另一种则是自底向上的语法分析。 + + +上下文无关文法(Context-Free Grammar) + +在开始语法分析之前,我们要解决的第一个问题,就是如何表达语法规则。在上一讲中,你已经了解了,我们可以用正则表达式来表达词法规则,语法规则其实也差不多。 + +我还是以下面这个示例程序为例,里面用到了变量声明语句、加法表达式,我们看看语法规则应该怎么写: + + int a = 2; + int b = a + 3; + return b; + + +第一种写法是下面这个样子,它看起来跟上一讲的词法规则差不多,都是左边是规则名称,右边是正则表达式。 + +start:blockStmts ; //起始 +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 Grammar,CFG)。 + +你可能会问,上下文无关文法和词法分析中用到的正则文法是否有一定的关系? + +是的,正则文法是上下文无关文法的一个子集。其实,正则文法也可以写成产生式的格式。比如,数字字面量(正则表达式为“[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: + + + +图6:2+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。 + + + + \ No newline at end of file diff --git a/专栏/编译原理实战课/04语义分析:让程序符合语义规则.md b/专栏/编译原理实战课/04语义分析:让程序符合语义规则.md new file mode 100644 index 0000000..f22b1d2 --- /dev/null +++ b/专栏/编译原理实战课/04语义分析:让程序符合语义规则.md @@ -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讲曾经提过的带有标注信息的AST,(Annotated 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 Translation,SDT)。使用语法制导的翻译可以做很多事情,包括做属性计算、填充符号表,以及生成IR。 + +课程小结 + +在实际的编译器中,语义分析相关的代码量往往要比词法分析和语法分析的代码量大。因为一门语言有很多语义规则,所以要做的语义分析和检查工作也很多。 + +并且,因为每门语言之间的差别主要都体现在语义上,所以每门语言在语义处理方面的工作差异也比较大。比如,一门语言支持闭包,另一门语言不支持;有的语言支持泛型,另一门语言不支持;一门语言的面向对象特性是基于继承实现的,而另一门语言则是基于组合实现的,等等。 + +不过,这没啥关系。我们主要抓住它们的共性就好了。这些共性,就是我们本讲的内容: + + +做好上下文相关的分析,比如对各种引用的消解、控制流的检查、闭包的分析等; +做好与类型有关的分析和处理,包括类型检查、类型推断等; +掌握属性计算这个工具,用于计算类型、值等属性; +最后,把获得的语义信息保存到符号表和AST里。 + + +我把本讲的知识点也整理成了脑图,供你参考: + + + +一课一思 + +你能否阅读你所熟悉的编程语言的标准,查看其中的语义规则,并选择一组有意思的语义规则(比如,第1讲提到的ECMAScript中加法操作符的语义规则),分析一下在语义分析阶段要针对这组语义规则做哪些处理工作? + +欢迎在留言区分享你的答案,也欢迎你把今天的内容分享给更多的朋友。 + +参考资料 + + +关于计算机程序的语义进行处理的形式化方法,你可以参考:The Formal Semantics of Programming Languages: An Introduction +关于数据类型、类型系统、类型理论的定义,你可以参考Wikipedia。 +《编译原理之美》的第8讲中,有关于如何在计算机语言里实现作用域的介绍,可以加深你对作用域的理解。 + + + + + \ No newline at end of file