136-编译原理实战课

学习指南 | 如何学习这门编译原理实战课？

你好，欢迎来到《编译原理实战课》，我是专栏编辑王惠，很高兴认识你。

我们都知道，“编译原理”是一门特别硬核的计算机基础专业课。你是不是也觉得编译原理知识就像是一片望不到头的大海，任自己在里面怎么扑腾、怎么挣扎都游不到学成的对岸。但是没关系，现在我们可以跟着宫老师的脚步一起探索编译的旅程了。

不过在正式开始学习这门课程之前，我想先和你聊聊这门课程的一些设计思路和特设板块，帮你找到最适合自己的学习方式，让你后面的学习能达到事半功倍的效果。

我们有“学习委员”了

首先来说个好消息，咱这门课呢有学习委员陪伴我们一起学。担当学委的是我们的资深用户朱英达同学，他曾就职于百度，履任资深研发工程师，擅长 Web 前后端相关领域技术，对编译技术在业务场景下的应用也有自己的理解。

他的经历可能和你很相似：作为一名计算机科班出身的程序员，在大学课堂中学习过编译原理这门课，但面对教科书上庞杂的知识体系、晦涩的抽象概念、陈旧的代码用例，无奈只学了个一知半解；工作以后，作为一名一线的 Coder，在大厂的环境里，看惯了层出不穷的“造轮子怪象”，最终才发现只有掌握像编译原理这样的底层技术，才是真正的精进之道。所以，他想把编译原理这门课重新捡起来，再学一次。

然而，目前市面上编译方面的技术资料却非常匮乏，被学界奉为经典的“龙书”“虎书”“鲸书”，对初学者来说又不够友好。后来，他遇到了宫老师的《编译原理之美》，跟着老师的思路重走了一遭编译之旅，发现自己之前对于编译技术的很多困惑点都迎刃而解了。比如说，宫老师在阐释虚拟机架构时，谈到了栈机和寄存器架构的优劣，这就对他理解 V8 引擎在虚拟机架构选型上提供了非常好的参考。

朱学委最终也发现，编译技术的学习绝对不能纸上谈兵，只有把学到的理论知识与自己从事的相关技术领域结合起来，才会真正有所感悟。

你看，编译原理或者说所有的技术，都有这么一个反复学习、反复印证的过程。所以在这门课程中，学委将会基于积累的编译原理基础，以及对这门新课程内容的学习，不定期地分享他学习编译原理的方法和思路，和你一起探讨课程要掌握的要点和难点。当然了，学委也会在留言区督促你交作业，和你一起交流讨论。

有了学委的陪伴，相信你再学习这门课，一定可以事半功倍。

如何学习预备知识模块？

接下来，我来说说怎么利用好预备知识模块。

那我先来交代下为什么要特别设计这个模块。就像老师在开篇词中所说的，这门课程会带你一起阅读真实语言编译器的源码，跟踪它们的运行过程，分析编译过程的每一步是如何实现的，并会对有特点的编译技术点加以分析和点评。

但在解析编译器的过程中，一定会涉及到很多编译原理的基础概念、理论和算法，如果你从来没有接触过或者不够了解这些编译原理知识，那必然会在一定程度上影响你后面的学习效果。所以，预备知识模块就是帮你先建立起一个初步的编译原理知识体系，打好基础，为后面的学习做好准备。

如果你已经学过老师的第一季课程《编译原理之美》，预备篇的内容也建议你不要跳过。和第一季课程相比，在这个模块里宫老师会以更加高屋建瓴的方式，来重新交付编译基础知识。所以，你一定要利用这个模块来查漏补缺。

那具体怎么做呢？建议你先看每一讲的标题，然后回顾自己已经学过的、掌握了的知识要点，写下来，写好后再开始学习，学完后对比总结心得。千万不要错过这个再学一次的机会。我们都知道重复是学习的关键一环，相信通过这个模块，你一定能在编译技术的理解上更上层楼。

你可以把预备知识理解为编译基础的一个串讲，涉及到的概念会比较多。所以学习这个模块的时候，我建议你每学完一讲都要自己动手画一下这一讲的知识地图。等 8 篇结束后，学习委员也会总结一张编译原理的核心基础知识大地图。到时候你可以对比来看，给自己一个直接的反馈。然后一定要利用这张图，在脑子里构建起编译原理的知识框架。这样，你就做好了进入下个模块的学习准备啦。

解析 7 种语言编译器的过程中，你需要做什么？

下面我来说说课程的重头戏，也就是解析 7 种语言的编译器，包括 Java 编译器（javac）、Java 的 JIT 编译器（Graal）、Python 编译器（CPython）、JavaScript 编译器（V8）、Julia 语言的编译器、Go 语言的编译器（gc），以及 MySQL 的编译器。

这些编译器都是宫老师精选出来的，具有一定的代表性、采用了不同的编译技术，而且其中某一门语言也非常可能就是你在使用的。我们的课程就是从实战的角度切入，用你最擅长的方式（写代码、读代码）带你分析这些编译器。所以学好这门课的关键就是要动手实践，跟随老师的脚步来亲身体验不同编译器的实现机制。

我建议你最好在学习的过程中手边备着一台电脑，或者是一台能查看到源代码的其他设备，工具不重要，趁手最有效。你在自己上手修改源码的时候，就会发现对编译原理的概念理解得更加深入了。

期中复习周，停下来是为了跑得更快

接着来说说期中复习周。这一周安排在“真实编译器解析篇”之后，也就是建立在你已经学习并理解了 7 种不同语言编译器的运行机制之后。设置复习周的目的，就是想要让你能及时、系统地了解自己前半段课程内容的掌握情况，发现学习上的漏洞，并及时弥补。

在这一周，学委首先会帮你划出复习的重点，给你总结前面解析的 7 种语言编译器所涉及到的核心知识。总结复习的过程，也就是你在提高编译技术能力的过程。

接下来，老师会给你出一套考试题。通过这次测试，你可以验证一下自己的学习方式是否有效，希望你能够及时调整学习心态和方法，更有效率地进行下一阶段的学习。

另外，在消化知识的同时，你还可以通过其他同学分享的心得，去看看他是如何学习、掌握编译原理知识的，毕竟通过借鉴别人来完善自己也是一种很好的学习方法嘛。

Learning by Sharing，分享了才知道自己那么优秀

再接下来，我必须得说说“一课一思”这个学习环节了。

一课一思是每一讲最后的固定模块，具体内容呢，要么是给你留了一道动手实践的作业，要么就是抛出一个开放性的问题，引导你发散思考。如果你对这些问题都有自己的见解或者看法，那就不妨在留言区分享出来。这样渐渐地，你会发现自己就能解答一些同学的问题了，这是非常好的自检学习成果的方式。

另外别忘记了，极客时间还有一个社区交流的版块“部落”。在日常工作中，你一定会经常接触各种代码，也一定有自己非常熟悉的一门或多门编程语言。那么在解析了不同语言的编译器以后，你可以在部落里分享自己对于熟悉的或不熟悉的语言编译器的理解。

比如说，你原来深耕在 Java 领域，那么在学完了 javac 编译器和 Graal 编译器以后，你对 Java 是不是就有更深刻的理解了？在学完了 Python 的编译器以后，你是不是对这两门语言之间的共性和特性都更加清晰了？这些思考你都可以分享在部落里，通过分享自己所习得的知识，你会获得更好的成长。

如何验收学习成果？

最后，在课程的收尾阶段呢，老师还会跟你一起关注一个热点话题，那就是华为的方舟编译器。相信很多同学对于国产的编译器，一直都是翘首以盼的。华为已经公开了一部分源代码，虽然资料仍然很缺乏，但是通过我们课程的学习，你是否有能力看懂华为的编译器呢？从掌握书本上的原理，到读懂流行的语言，再到理解方舟编译器的实现思路，这会是你能力一步步提升的过程。最终，你甚至可以参与到一款严肃的编译器的研发当中了。

好了，以上就是我想让你重点关注的课程设计和特设板块内容。编译原理是个难啃的硬骨头，但是我相信，只要你保有这份一定要吃透编译技术核心知识的决心，有计划、有重点，结合实践进行学习，就没有什么是看不懂、学不会的了。加油吧，祝你学有所成！

开篇词 | 在真实世界的编译器中游历

你好，我是宫文学，一名技术创业者，现在是北京物演科技的 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 等领域中做到的一样。

虽然这个课程不可能涵盖编译技术领域所有的创新点，但我相信，你在其中投入的时间和精力是值得的。你通过我课程中教给你的方法，可以对你所使用的语言产生更加深入的认知，对编译器的内部结构和原理有清晰理解。最重要的是，对于如何采用编译技术来解决实际问题，你也会有能力做出正确的决策。

这样，这个课程就能起到抛砖引玉的作用，让我们能够成为大胆探索、勇于创新的群体的一份子。未来中国在编译技术的进步，就很可能有来自我们的贡献。我们一起加油！

最后，我还想正式认识一下你。你可以在留言区里做个自我介绍，和我聊聊，你目前学习编译原理的最大难点在哪？或者，你也可以聊聊你对编译原理都有哪些独特的思考和体验，欢迎在留言区和我交流讨论。

好了，让我们正式开始编译之旅吧！

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）。编译是一个比较复杂的过程，但如果我们能够分而治之，那么每一步的挑战就会降低很多。这样最后针对每个子任务，我们就都能找到解决的办法。

我希望这一讲能帮你在大脑里建立起一个概要的地图。在后面几讲中，我会对编译过程的各个环节展开讨论，让你有越来越清晰的理解。

一课一思

你觉得做计算机语言的编译和自然语言的翻译，有哪些地方是相同的，哪些地方是不同的？

欢迎在留言区分享你的见解，也欢迎你把今天的内容分享给更多的朋友。感谢阅读，我们下一讲再见。

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 讲。

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。

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 讲中，有关于如何在计算机语言里实现作用域的介绍，可以加深你对作用域的理解。

05 | 运行时机制：程序如何运行，你有发言权

你好，我是宫文学。在语义分析之后，编译过程就开始进入中后端了。

经过前端阶段的处理分析，编译器已经充分理解了源代码的含义，准备好把前端处理的结果（带有标注信息的 AST、符号表）翻译成目标代码了。

我在第 1 讲也说过，如果想做好翻译工作，编译器必须理解目标代码。而要理解目标代码，它就必须要理解目标代码是如何被执行的。通常情况下，程序有两种执行模式。

第一种执行模式是在物理机上运行。针对的是 C、C++、Go 这样的语言，编译器直接将源代码编译成汇编代码（或直接生成机器码），然后生成能够在操作系统上运行的可执行程序。为了实现它们的后端，编译器需要理解程序在底层的运行环境，包括 CPU、内存、操作系统跟程序的互动关系，并要能理解汇编代码。

第二种执行模式是在虚拟机上运行。针对的是 Java、Python、Erlang 和 Lua 等语言，它们能够在虚拟机上解释执行。这时候，编译器要理解该语言的虚拟机的运行机制，并生成能够被执行的 IR。

理解了这两种执行模式的特点，我们也就能弄清楚用高级语言编写的程序是如何运行的，进而也就理解了编译器在中后端的任务是什么。接下来，我们就从最基础的物理机模式开始学习吧。

在物理机上运行

在计算机发展的早期，科学家们确立了计算机的结构，并一直延续至今，这种结构就是冯·诺依曼结构。它的主要特点是：数据和指令不加区别，混合存储在同一个存储器中（即主存，或叫做内存）；用一个指令指针指向内存中指令的位置，CPU 就能自动加载这个位置的指令并执行。

在 x86 架构下，这个指针是 eip 寄存器（32 位模式）或 rip 寄存器（64 位模式）。一条指令执行完毕，指令指针自动增加，并执行下一条指令。如果遇到跳转指令，则跳转到另一个地址去执行。

图 1：计算机的运行机制

这其实就是计算机最基本的运行原理。这样，你就可以在大脑中建立起像图 1 那样的直观结构。

通过图 1，你会看到，计算机指令的执行基本上只跟两个硬件相关：一个是 CPU，一个是内存。

CPU

CPU 是计算机的核心。从硬件构成方面，我们需要知道它的三个信息：

第一，CPU 上面有寄存器，并且可以直接由指令访问。寄存器的读写速度非常快，大约是内存的 100 倍。所以我们编译后的代码，要尽量充分利用寄存器，而不是频繁地去访问内存。

第二，CPU 有高速缓存，并且可能是多级的。高速缓存也比内存快。CPU 在读取指令和数据的时候，不是一次读取一条，而是读取相邻的一批数据，放到高速缓存里。接下来要读取的数据，很可能已经在高速缓存里了，通过这种机制来提高运行性能。因此，编译器要尽量提高缓存的命中率。

第三，CPU 内部有多个功能单元，有的负责计算，有的负责解码，等等。所以，一个指令可以被切分成多个执行阶段，每个阶段在不同的功能单元上运行，这为实现指令级并行提供了硬件基础。在第 8 讲，我还会和你详细解释这个话题。

好了，掌握了这个知识点，我们可以继续往下学习了。我们说，CPU 是运行指令的地方，那指令到底是什么样子的呢？

我们知道，CPU 有多种不同的架构，比如 x86 架构、ARM 架构等。不同架构的 CPU，它的指令是不一样的。不过它们的共性之处在于，指令都是 01 这样的机器码。为了便于理解，我们通常会用汇编代码来表示机器指令。比如，b=a+2 指令对应的汇编码可能是这样的：

movl  -4(%rbp), %eax    #把%rbp-4内存地址的值拷贝到%eax寄存器
addl  $2, %eax          #把2加到%eax寄存器
movl  %eax, -8(%rbp)    #把%eax寄存器的值保存回内存，地址是%rbp-8

上面的汇编代码采用的是 GNU 汇编器规定的格式。每条指令都包含了两部分：操作码（opcode）和操作数（oprand）。

图 2：汇编代码示例

操作码是让 CPU 执行的动作。这段示例代码中，movl、addl 是助记符（Assembly Mnemonic），其中的 mov 和 add 是指令，l 是后缀，表示操作数的位数。

而操作数是指令的操作对象，它可以是常数、寄存器和某个内存地址。图 2 示例的汇编代码中，“$2”就是个常数，在指令里我们把它叫做立即数；而“%eax”是访问一个寄存器，其中 eax 是寄存器的名称；而带有括号的“-4(%rbp)”，则是对内存的访问方式，这个内存的地址是在 rbp 寄存器的值的基础上减去 4。

如果你还想对指令、汇编代码有更多的了解，可以再去查阅些资料学习，比如去参考下我的《编译原理之美》中的第22、23、31这几讲。

这里要提一下，虽然程序觉得自己一直在使用 CPU，但实际上，背后有操作系统在做调度。操作系统是管理系统资源的，而 CPU 是计算机的核心资源，操作系统会把 CPU 的时间划分成多个时间片，分配给不同的程序使用，每个程序实际上都是在“断断续续”地使用 CPU，这就是操作系统的分时调度机制。在后面课程里讨论并发的时候，我们会更加深入地探讨这个机制。

内存

好了，接下来我说说执行指令相关的另一个硬件：内存。

程序在运行时，操作系统会给它分配一块虚拟的内存空间，让它可以在运行期内使用。内存中的每个位置都有一个地址，地址的长度决定了能够表示多大空间，这叫做寻址空间。我们目前使用的都是 64 位的机器，理论上，你可以用一个 64 位的长整型来表示内存地址。

不过，由于我们根本用不了这么大的内存，所以 AMD64 架构的寻址空间只使用了 48 位。但这也有 256TB，远远超出了一般情况下的需求。所以，像 Windows 这样的操作系统还会给予进一步的限制，缩小程序的寻址空间。

图 3：48 位寻址空间有多大

但即使是在加了限制的情况下，程序在逻辑上可使用的内存一般也会大于实际的物理内存。不过进程不会一下子使用那么多的内存，只有在向操作系统申请内存的时候，操作系统才会把一块物理内存，映射成进程寻址空间内的一块内存。对应到图 4 中，中间一条是物理内存，上下两条是两个进程的寻址空间，它们要比物理内存大。

对于有些物理内存的内容，还可以映射进多个进程的地址空间，以减少内存的使用。比如说，如果进程 1 和进程 2 运行的是同一个可执行文件，那么程序的代码段是可以在两个进程之间共享的。你在图中可以看到这种情况。

图 4：物理内存和逻辑内存的关系

另外，对于已经分配给进程的内存，如果进程很长时间不用，操作系统会把它写到磁盘上，以便腾出更多可用的物理内存。在需要的时候，再把这块空间的数据从磁盘中读回来。这就是操作系统的虚拟内存机制。

当然，也存在没有操作系统的情况，这个时候你的程序所使用的内存就是物理内存，我们必须自己做好内存的管理。

那么从程序角度来说，我们应该怎样使用内存呢？

本质上来说，你想怎么用就怎么用，并没有什么特别的限制。一个编译器的作者，可以决定在哪儿放代码，在哪儿放数据。当然了，别的作者也可能采用其他的策略。比如，C 语言和 Java 虚拟机对内存的管理和使用策略就是不同的。

不过尽管如此，大多数语言还是会采用一些通用的内存管理模式。以 C 语言为例，会把内存划分为代码区、静态数据区、栈和堆，如下所示。

图 5：C 语言的内存布局方式

其中，代码区（也叫做文本段），主要存放编译完成后的机器码，也就是 CPU 指令；静态数据区会保存程序中的全局变量和常量。这些内存是静态的、固定大小的，在编译完毕以后就能确定清楚所占用空间的大小、代码区每个函数的地址，以及静态数据区每个变量和常量的地址。这些内存在程序运行期间会一直被占用。

而堆和栈，属于程序动态、按需获取的内存。我来和你分析下这两种内存。

我们先看看栈（Stack）。使用栈的一个好处是，操作系统会根据程序使用内存的需求，自动地增加或减少栈的空间。通常来说，操作系统会用一个寄存器保存栈顶的地址，程序可以修改这个寄存器的值，来获取或者释放空间。有的 CPU，还有专门的指令来管理栈，比如 x86 架构，会使用 push 和 pop 指令，把数据写入栈或弹出栈，并自动修改栈顶指针。

在程序里使用栈的场景是这样的，程序的运行可以看做是在逐级调用函数（或者叫过程）。像下面的示例程序，存在着 main->bar->foo 的调用结构，这也就是控制流转移的过程。

int main(){
  int a = 1;
  foo(3);
  bar();
}

int foo(int c){
    int b = 2;
    return b+c;
}

int bar(){
    return foo(4) + 1;

图 6：程序逐级调用的过程

每次调用函数的过程中，都需要一些空间来保存一些信息，比如参数、需要保护的寄存器的值、返回地址、本地变量等，这些信息叫做这个过程的活动记录（Activation Record）。

注意，活动记录是个逻辑概念。在物理实现上，一些信息可以保存在寄存器里，使得性能更高。比如说依据一些约定，返回值和少于 6 个的参数，是通过寄存器传递的。这里所说的“依据约定”，是指在调用一个函数时，如何传递参数、如何设定返回地址、如何获取返回值的这种约定，我们把它称之为 ABI（Application Binary Interface，应用程序二进制接口）。利用 ABI，使得我们可以用一种语言写的程序，去调用另外的语言写的程序。

另一些信息会保存在栈里。每个函数（或过程）在栈里保存的信息，叫做栈帧（Stack Frame）。我们可以自由设计栈帧的结构，比如，下图就是一种常见的设计：

图 7：一种可能的栈帧结构

返回值：一般放在最顶上，这样它的地址是固定的。foo 函数返回以后，它的调用者可以到这里来取到返回值。在实际情况中，ABI 会规定优先通过寄存器来传递返回值，比通过内存传递性能更高。

参数：在调用 foo 函数时，我们把它所需要一个整型参数写到栈帧的这个位置。同样，我们也可以通过寄存器来传递参数，而不是通过内存。

控制链接：就是上一级栈帧（也就是 main 函数的栈帧）的地址。如果该函数用到了上一级作用域中的变量，那么就可以顺着这个链接找到上一级作用域的栈帧，并找到变量的值。

返回地址： foo 函数执行完毕以后，继续执行哪条指令。同样，我们可以用寄存器来保存这个信息。

本地变量： foo 函数的本地变量 b 的存储空间。

寄存器信息：我们还经常在栈帧里保存寄存器的数据。如果在 foo 函数里要使用某个寄存器，可能需要先把它的值保存下来，防止破坏了别的代码保存在这里的数据。这种约定叫做被调用者责任，也就是使用寄存器的函数要保护好寄存器里原有的信息。某个函数如果使用了某个寄存器，但它又要调用别的函数，为了防止别的函数把自己放在寄存器中的数据覆盖掉，这个函数就要自己把寄存器信息保存在栈帧中。这种约定叫做调用者责任。

对于示例程序，在多级调用以后，栈里的信息可能是下面这个样子。如果你想看到这个信息，通常可以在调试程序的时候打印出来。

图 8：一个运行中的栈的示例

理解了栈的机制以后，我们再来看看动态获取内存的第二种方式：堆（Heap）。

操作系统一般会提供一个 API，供应用申请内存。当应用程序用完之后，要通过另一个 API 释放。如果忘记释放，就会造成内存越用越少，这叫做内存泄漏。

相对于栈来说，这是堆的一个缺点。不过，相应的好处是，应用在堆里申请的对象的生存期，可以由自己控制，不会像栈里的内存那样，在退出作用域之后就被自动收回。所以，如果数据的生存期超过了创建它的作用域的生存期，就必须在堆中申请内存。

扩展：反之，如果数据的生存期跟创建它的作用域一致的话，那么在栈里和堆里申请都是可以的。当然，肯定在栈里申请更划算。所以，编译优化中的逃逸分析，本质就是分析出哪些对象的生存期是跟函数或方法的生存期一致的，那么就不需要到堆里申请了。

另外，在并发的场景下，由于栈是线程独享的，而堆是多个线程共享的，所以在堆里申请内存的效率会更低，因为需要在多个线程之间同步，避免出现竞争。

那为了避免内存泄漏，在设计一门语言的时候，通常需要提供内存管理的方案。

一种方案是像 C 和 C++ 那样，由程序员自己负责内存的释放，这对程序员的要求就比较高。另一种方案是，像 Java 语言那样自动地管理内存，这个特性也叫做垃圾收集。垃圾收集是语言的运行时功能，能够通过一定的算法来回收不用的内存。

总结起来，在计算机上运行一个程序，我们需要跟两个硬件打交道：一个是 CPU，它能够从内存中读取指令并顺序执行；第二个硬件是内存，内存使用模式有栈和堆两种方式，两种方式有各自的优点和适用场景。

运行时系统

除了硬件支撑，程序的运行还需要软件，这些软件叫做运行时系统（Runtime System），或者叫运行时（Runtime）。前面我们提到的垃圾收集器，就是一个运行时的软件。进行并发调度的软件，也是运行时的组成部分。

实际上，对于把源代码编译成机器码在操作系统上运行的语言来说（比如 C、C++），操作系统本身就可以看做是它们的运行时系统。它可以帮助程序调度 CPU 资源、内存资源，以及其他一些资源，如 IO 端口。

但也有很多语言，比如 Java、Python、Erlang 和 Lua 等，它们不是直接在操作系统上运行的，而是运行在虚拟机上。那么它们的执行模式有什么特点？对编译有什么影响呢？

在虚拟机上运行

虚拟机是计算机语言的一种运行时系统。虚拟机上运行的是中间代码，而不是 CPU 可以直接认识的指令。

虚拟机有两种模型：一种叫做栈机（Stack Machine），一种叫做寄存器机（Register Machine）。它们的区别，主要在于如何获取指令的操作数。

栈机是从栈里获取，而寄存器机是从寄存器里获取。这两种虚拟机各有优缺点。

基于栈的虚拟机

首先说说栈机。JVM 和 Python 中的解释器，都采用了栈机的模型。在本讲中，我主要介绍 Java 的虚拟机的运行机制。

JVM 中，每一个线程都有一个 JVM 栈，每次调用一个方法都会生成一个栈帧，来支持这个方法的运行。这跟 C 语言很相似。但 JVM 的栈帧比 C 语言的复杂，它包含了一个本地变量数组（包括方法的参数和本地变量）、操作数栈、到运行时常量池的引用等信息。

对比 JVM 的栈帧和 C 语言栈帧的设计，你应该得到一些启示：栈帧的结构是语言的作者可以自己设计的，没有什么死规定。所以我们学知识也不要学死了，以为栈帧只有一种结构。

注意，我们这里提到了两个栈，一个是类似于 C 语言的栈的方法栈，另一个是方法栈里每个栈帧中的操作数栈。而我们说的栈机中的“栈”，指的是这个操作数栈，不要弄混了。

图 9：JVM 中一个栈帧的结构

对于每个指令，解释器先要把它的操作数压到栈里。在执行指令时，从栈里弹出操作数，计算完毕以后，再把结果压回栈里。

以“2+3*5”为例，它对应的栈机的代码如下：

push 2  //把操作数2入栈
push 3  //把操作数3入栈
push 5  //把操作数5入栈, 栈里目前是2、 3、 5
imul    //弹出5和3，执行整数乘法运算，得到15，然后把结果入栈，现在栈里是2、15
iadd    //弹出15和2，执行整数加法运算，得到17，然后把结果入栈，最后栈里是17

提示：对于不同大小的常量操作数，实际上生成的指令会不同。这里只是示意。

注意一点，要从 AST 生成上面的代码，你只需要对 AST 做深度优先的遍历即可。先后经过的节点是：2->3->5->*->+（注：这种把操作符放在后面的写法，叫做逆波兰表达式，也叫后缀表达式）。

图 10：2+3*5 对应的 AST

生成上述栈机代码，只需要深度优先地遍历 AST，并且只需要进行两种操作：

在遇到字面量或者变量的时候，生成 push 指令；

在遇到操作符的时候，生成相应的操作指令即可。

你能看出，这个算法相当简单，这也是栈机最大的优点。

你还会注意到，像 imul 和 iadd 这样的指令，不需要带操作数，因为指令所需的操作数就在栈顶。这是栈机的指令跟汇编语言的指令的最大区别。

注意：imul 和 iadd 中的 i，代表这两个指令是对整型值做操作。对浮点型、长整型等不同类型，分别对应不同的指令前缀。

好了，现在你已经了解了栈机的原理。基于对栈机的认知，你再去阅读 Java 和 Python 的字节码，就会更加容易了。而关于 Python 的虚拟机，我还会在后续课程中详细展开。

基于寄存器的虚拟机

除了栈机之外，另一种虚拟机是寄存器机。寄存器机使用寄存器名称来表示操作数，所以它的指令也跟汇编代码相似，像 add 这样的操作码后面要跟操作数。

在实践中，早期版本的安卓系统中，用于解释执行代码的 Dalvik 虚拟机，就采用了寄存器模式，而 Erlang 和 Lua 语言的虚拟机也是寄存器机。JavaScript 引擎 V8 的比较新的版本中，也引入了一个解释器 Ignition，它也是个寄存器机。

与栈机相比，利用寄存器机编译所生成的代码更少，因为省去了很多 push 指令。

不过，寄存器机所指的寄存器，不一定是真正的物理寄存器，有可能只是栈帧中的一个位置。当然，有的寄存器机在实现的时候，确实会用到物理寄存器，从而提高计算性能。我们在后面研究 V8 的 Ignition 解释器时，会看到这种实现。

课程小结

本讲我带你了解了代码是如何被运行的，以及是在什么样的环境中运行的。这样，你才会知道如何让编译器生成正确的代码。

现有的程序有两大类执行模式。一类是编译成本地代码（机器码），运行在物理机和操作系统上，这时候你需要掌握目标机器的汇编代码，知道指令是如何跟 CPU 和内存打交道的，也需要知道操作系统在其中扮演了什么角色。另一大类是在虚拟机上运行的，虚拟机又分为栈机和寄存器机两大类，你需要明确它们之间的区别，才能知道为什么它们的 IR 是不同的，又分别有什么优缺点。

不过，现代程序的运行环境往往比较复杂。像 Java 等语言，既可以解释执行字节码，又能够即时编译成本地代码运行，所以它们的运行时机制就更复杂一些。你要综合两种运行时机制的知识，才能完整地理解 JVM。

一课一思

我们现在已经知道，栈是一种自动管理内存的机制，你只要修改栈顶指针，就可以获得所需的内存。那么，你能否结合操作系统的知识，研究一下这个过程是如何实现的呢？

欢迎在留言区分享你的答案，如果这节课对你有帮助，也欢迎你把它分享给你的朋友。

参考资料

1. 关于 JVM 栈帧的结构，可以参考JVM Specification。

2. 关于 Java 字节码的指令集，可以参考Java Language Specification。

06 | 中间代码：不是只有一副面孔

你好，我是宫文学。今天这一讲，我来带你认识一下中间代码（IR）。

IR，也就是中间代码（Intermediate Representation，有时也称 Intermediate Code，IC），它是编译器中很重要的一种数据结构。编译器在做完前端工作以后，首先就是生成 IR，并在此基础上执行各种优化算法，最后再生成目标代码。

所以说，编译技术的 IR 非常重要，它是运行各种优化算法、代码生成算法的基础。不过，鉴于 IR 的设计一般与编译器密切相关，而一些教科书可能更侧重于讲理论，所以对 IR 的介绍就不那么具体。这就导致我们对 IR 有非常多的疑问，比如：

IR 都有哪些不同的设计，可以分成什么类型？

IR 有像高级语言和汇编代码那样的标准书写格式吗？

IR 可以采用什么数据结构来实现？

为了帮助你把对 IR 的认识从抽象变得具体，我今天就从全局的视角和你一起梳理下 IR 有关的认知。

首先，我们来了解一下 IR 的用途，并一起看看由于用途不同导致 IR 分成的多个层次。

IR 的用途和层次

设计 IR 的目的，是要满足编译器中的各种需求。需求的不同，就会导致 IR 的设计不同。通常情况下，IR 有两种用途，一种是用来做分析和变换的，一种是直接用于解释执行的。我们先来看第一种。

编译器中，基于 IR 的分析和处理工作，一开始可以基于一些抽象层次比较高的语义，这时所需要的 IR 更接近源代码。而在后面，则会使用低层次的、更加接近目标代码的语义。

基于这种从高到低的抽象层次，IR 可以归结为 HIR、MIR 和 LIR 三类。

HIR：基于源语言做一些分析和变换

假设你要开发一款 IDE，那最主要的功能包括：发现语法错误、分析符号之间的依赖关系（以便进行跳转、判断方法的重载等）、根据需要自动生成或修改一些代码（提供重构能力）。

这个时候，你对 IR 的需求，是能够准确表达源语言的语义就行了。这种类型的 IR，可以叫做 High IR，简称 HIR。

其实，AST 和符号表就可以满足这个需求。也就是说，AST 也可以算作一种 IR。如果你要开发 IDE、代码翻译工具（从一门语言翻译到另一门语言）、代码生成工具、代码统计工具等，使用 AST（加上符号表）就够了。

当然，有些 HIR 并不是树状结构（比如可以采用线性结构），但一般会保留诸如条件判断、循环、数组等抽象层次比较高的语法结构。

基于 HIR，可以做一些高层次的代码优化，比如常数折叠、内联等。在 Java 和 Go 的编译器中，你可以看到不少基于 AST 做的优化工作。

MIR：独立于源语言和 CPU 架构做分析和优化

大量的优化算法是可以通用的，没有必要依赖源语言的语法和语义，也没有必要依赖具体的 CPU 架构。

这些优化包括部分算术优化、常量和变量传播、死代码删除等，我会在下一讲和你介绍。实现这类分析和优化功能的 IR 可以叫做 Middle IR，简称 MIR。

因为 MIR 跟源代码和目标代码都无关，所以在讲解优化算法时，通常是基于 MIR，比如三地址代码（Three Address Code，TAC）。

TAC 的特点是，最多有三个地址（也就是变量），其中赋值符号的左边是用来写入的，而右边最多可以有两个地址和一个操作符，用于读取数据并计算。

我们来看一个例子，示例函数 foo：

int foo (int a){
  int b = 0;
  if (a > 10)
    b = a;
  else
    b = 10;
  return b;
}

对应的 TAC 可能是：

BB1:
  b := 0
  if a>10 goto BB3   //如果t是false(0),转到BB3
BB2:
  b := 10
  goto BB4
BB3:
  b := a
BB4:
  return b

可以看到，TAC 用 goto 语句取代了 if 语句、循环语句这种比较高级的语句，当然也不会有类、继承这些高层的语言结构。但是，它又没有涉及数据如何在内存读写等细节，书写格式也不像汇编代码，与具体的目标代码也是独立的。

所以，它的抽象程度算是不高不低。

LIR：依赖于 CPU 架构做优化和代码生成

最后一类 IR 就是 Low IR，简称 LIR。

这类 IR 的特点，是它的指令通常可以与机器指令一一对应，比较容易翻译成机器指令（或汇编代码）。因为 LIR 体现了 CPU 架构的底层特征，因此可以做一些与具体 CPU 架构相关的优化。

比如，下面是 Java 的 JIT 编译器输出的 LIR 信息，里面的指令名称已经跟汇编代码很像了，并且会直接使用 AMD64 架构的寄存器名称。

图 1：Java 的 JIT 编译器的 LIR

好了，以上就是根据不同的使用目的和抽象层次，所划分出来的不同 IR 的关键知识点了。

HIR、MIR 和 LIR 这种划分方法，主要是参考“鲸书（Advanced Compiler Design and Implementation）”的提法。对此有兴趣的话，你可以参考一下这本书。

在实际操作时，有时候 IR 的划分标准不一定跟鲸书一致。在有的编译器里（比如 Graal 编译器），把相对高层次的 IR 叫做 HIR，相对低层次的叫做 LIR，而没有 MIR。你只要知道它们代表了不同的抽象层次就足够了。

其实，在一个编译器里，有时候会使用抽象层次从高到低的多种 IR，从便于“人”理解到便于“机器”理解。而编译过程可以理解为，抽象层次高的 IR 一直 lower 到抽象层次低的 IR 的过程，并且在每种 IR 上都会做一些适合这种 IR 的分析和处理工作，直到最后生成了优化的目标代码。

扩展：lower 这个词的意思，就是把对计算机程序的表示，从抽象层次比较高的、便于人理解的格式，转化为抽象层次比较低的、便于机器理解的格式。

有些 IR 的设计，本身就混合了多个抽象层次的元素，比如 Java 的 Graal 编译器里就采用了这种设计。Graal 的 IR 采用的是一种图结构，但随着优化阶段的进展，图中的一些节点会逐步从语义比较抽象的节点，lower 到体现具体架构特征的节点。

P-code：用于解释执行的 IR

好了，前 3 类 IR 是从抽象层次来划分的，它们都是用来做分析和变换的。我们继续看看第二种直接用于解释执行的 IR。这类 IR 还有一个名称，叫做 P-code，也就是 Portable Code 的意思。由于它与具体机器无关，因此可以很容易地运行在多种电脑上。这类 IR 对编译器来说，就是做编译的目标代码。

到这里，你一下子就会想到，Java 的字节码就是这种 IR。除此之外，Python、Erlang 也有自己的字节码，.NET 平台、Visual Basic 程序也不例外。

其实，你也完全可以基于 AST 实现一个全功能的解释器，只不过性能会差一些。对于专门用来解释执行 IR，通常会有一些特别的设计，跟虚拟机配合来尽量提升运行速度。

需要注意的是，P-code 也可能被进一步编译，形成可以直接执行的机器码。Java 的字节码就是这样的例子。因此，在这门课程里，我会带你探究 Java 的两个编译器，一个把源代码编译成字节码，一个把字节码编译成目标代码（支持 JIT 和 AOT 两种方式）。

好了，通过了解 IR 的不同用途，你应该会对 IR 的概念更清晰一些。用途不同，对 IR 的需求也就不同，IR 的设计自然也就会不同。这跟软件设计是由需求决定的，是同一个道理。

接下来的一个问题是，IR 是怎样书写的呢？

IR 的呈现格式

虽然说是中间代码，但总得有一个书写格式吧，就像源代码和汇编代码那样。

其实 IR 通常是没有书写格式的。一方面，大多数的 IR 跟 AST 一样，只是编译过程中的一个数据结构而已，或者说只有内存格式。比如，LLVM 的 IR 在内存里是一些对象和接口。

另一方面，为了调试的需要，你可以把 IR 以文本的方式输出，用于显示和分析。在这门课里，你也会看到很多 IR 的输出格式。比如，下面是 Julia 的 IR：

图 2：Julia 语言输出的 IR 信息

在少量情况下，IR 有比较严格的输出格式，不仅用于显示和分析，还可以作为结果保存，并可以重新读入编译器中。比如，LLVM 的 bitcode，可以保存成文本和二进制两种格式，这两种格式间还可以相互转换。

我们以 C 语言为例，来看下 fun1 函数，及其对应的 LLVM IR 的文本格式和二进制格式：

//fun1.c
int fun1(int a, int b){
    int c = 10;
    return a+b+c;
}

LLVM IR 的文本格式（用“clang -emit-llvm -S fun1.c -o fun1.ll”命令生成，这里只节选了主要部分）：

; ModuleID = 'fun1.c'
source_filename = "function-call1.c"
target datalayout = "e-m:o-i64:64-f80:128-n8:16:32:64-S128"
target triple = "x86_64-apple-macosx10.14.0"

; Function Attrs: noinline nounwind optnone ssp uwtable
define i32 @fun1(i32, i32) #0 {
  %3 = alloca i32, align 4
  %4 = alloca i32, align 4
  %5 = alloca i32, align 4
  store i32 %0, i32* %3, align 4
  store i32 %1, i32* %4, align 4
  store i32 10, i32* %5, align 4
  %6 = load i32, i32* %3, align 4
  %7 = load i32, i32* %4, align 4
  %8 = add nsw i32 %6, %7
  %9 = load i32, i32* %5, align 4
  %10 = add nsw i32 %8, %9
  ret i32 %10
}

二进制格式（用“clang -emit-llvm -c fun1.c -o fun1.bc”命令生成，用“hexdump -C fun1.bc”命令显示）：

图 3：LLVM IR 的二进制格式

IR 的数据结构

既然我们一直说 IR 会表现为内存中的数据结构，那它到底是什么结构呢？

在实际的实现中，有线性结构、树结构、有向无环图（DAG）、程序依赖图（PDG）等多种格式。编译器会根据需要，选择合适的数据结构。在运行某些算法的时候，采用某个数据结构可能会更顺畅，而采用另一些结构可能会带来内在的阻滞。所以，我们一定要根据具体要处理的工作的特点，来选择合适的数据结构。

那我们接下来，就具体看看每种格式的特点。

第一种：类似 TAC 的线性结构（Linear Form）

你可以把代码表示成一行行的指令或语句，用数组或者列表保存就行了。其中的符号，需要引用符号表，来提供类型等信息。

这种线性结构有时候也被称作 goto 格式。因为高级语言里的条件语句、循环语句，要变成用 goto 语句跳转的方式。

第二种：树结构

树结构当然可以用作 IR，AST 就是一种树结构。

很多资料中讲指令选择的时候，也会用到一种树状的结构，便于执行树覆盖算法。这个树结构，就属于一种 LIR。

树结构的缺点是，可能有冗余的子树。比如，语句“a=5; b=(2+a)+a*3; ”形成的 AST 就有冗余。如果基于这个树结构生成代码，可能会做两次从内存中读取 a 的值的操作，并存到两个临时变量中。

图 4：冗余的子树

第三种：有向无环图（Directed Acyclic Graph，DAG）

DAG 结构，是在树结构的基础上，消除了冗余的子树。比如，上面的例子转化成 DAG 以后，对 a 的内存访问只做一次就行了。

图 5：DAG 结构消除了冗余的子树

在 LLVM 的目标代码生成环节，就使用了 DAG 来表示基本块内的代码。

第四种：程序依赖图（Program Dependence Graph，PDG）

程序依赖图，是显式地把程序中的数据依赖和控制依赖表示出来，形成一个图状的数据结构。基于这个数据结构，我们再做一些优化算法的时候，会更容易实现。

所以现在，有很多编译器在运行优化算法的时候，都基于类似 PDG 的数据结构，比如我在课程后面会分析的 Java 的 JIT 编译器和 JavaScript 的编译器。

这种数据结构里，因为会有很多图节点，又被形象地称为“节点之海（Sea of Nodes）”。你在很多文章中，都会看到这个词。

以上就是常用于 IR 的数据结构了。接下来，我再介绍一个重要的 IR 设计范式：SSA 格式。

SSA 格式的 IR

SSA 是 Static Single Assignment 的缩写，也就是静态单赋值。这是 IR 的一种设计范式，它要求一个变量只能被赋值一次。我们来看个例子。

“y = x1 + x2 + x3 + x4”的普通 TAC 如下：

y := x1 + x2;
y := y + x3;
y := y + x4;

其中，y 被赋值了三次，如果我们写成 SSA 的形式，就只能写成下面的样子：

t1 := x1 + x2;
t2 := t1 + x3;
y  := t2 + x4;

那我们为什么要费力写成这种形式呢，还要为此多添加 t1 和 t2 两个临时变量？

原因是，使用 SSA 的形式，体现了精确的“使用 - 定义（Use-def）”关系。并且由于变量的值定义出来以后就不再变化，使得基于 SSA 更容易运行一些优化算法。在后面的课程中，我会通过实际的例子带你体会这一点。

在 SSA 格式的 IR 中，还会涉及一个你经常会碰到的，但有些特别的指令，叫做 phi 指令。它是什么意思呢？我们看一个例子。

同样对于示例代码 foo：

int foo (int a){
  int b = 0;
  if (a > 10)
    b = a;
  else
    b = 10;
  return b;
}

它对应的 SSA 格式的 IR 可以写成：

BB1:
  b1 := 0
  if a>10 goto BB3
BB2:
  b2 := 10
  goto BB4
BB3:
  b3 := a
BB4:
  b4 := phi(BB2, BB3, b2, b3)
  return b4

其中，变量 b 有 4 个版本：b1 是初始值，b2 是 else 块（BB2）的取值，b3 是 if 块（BB3）的取值，最后一个基本块（BB4）要把 b 的最后取值作为函数返回值。很明显，b 的取值有可能是 b2，也有可能是 b3。这时候，就需要 phi 指令了。

phi 指令，会根据控制流的实际情况确定 b4 的值。如果 BB4 的前序节点是 BB2，那么 b4 的取值是 b2；而如果 BB4 的前序节点是 BB3，那么 b4 的取值就是 b3。所以你会看到，如果要满足 SSA 的要求，也就是一个变量只能赋值一次，那么在遇到有程序分支的情况下，就必须引入 phi 指令。关于这一点，你也会在课程后面经常见到它。

最后我要指出的是，由于 SSA 格式的优点，现代语言用于优化的 IR，很多都是基于 SSA 的了，包括我们本课程涉及的 Java 的 JIT 编译器、JavaScript 的 V8 编译器、Go 语言的 gc 编译器、Julia 编译器，以及 LLVM 工具等。所以，你一定要高度重视 SSA。

课程小结

今天这一讲，我希望你能记住关于 IR 的几个重要概念：

根据抽象层次和使用目的不同，可以设计不同的 IR；

IR 可能采取多种数据结构，每种结构适合不同的处理工作；

由于 SSA 格式的优点，主流的编译器都在采用这种范式来设计 IR。

通过学习 IR，你会形成看待编译过程的一个新视角：整个编译过程，就是生成从高抽象度到低抽象度的一系列 IR，以及发生在这些 IR 上的分析与处理过程。

我还展示了三地址代码、LLVM IR 等一些具体的 IR 设计，希望能给你增加一些直观印象。在有的教科书里，还会有三元式、四元式、逆波兰格式等不同的设计，你也可以参考。而在后面的课程里，你会接触到每门编译器的 IR，从而对 IR 的理解更加具体和丰满。

本讲的思维导图如下：

一课一思

你能试着把下面这段简单的程序，改写成 TAC 和 SSA 格式吗？

int bar(a){
    int sum = 0;
    for (int i = 0; i< a; i++){
        sum = sum+i;
    }
    return sum;
}

欢迎在留言区分享你的见解，也欢迎你把今天的内容分享给更多的朋友。

参考资料

关于程序依赖图的论文参考：The Program Dependence Graph and its Use in Optimization。

更多的关于 LLVM IR 的介绍，你可以参考《编译原理之美》的第25、26讲，以及LLVM 官方文档。

对 Java 字节码的介绍，你可以参考《编译原理之美》的第 32 讲，还可以参考Java Language Specification。

鲸书（Advanced Compiler Design and Implementation）第 4 章。

07 | 代码优化：跟编译器做朋友，让你的代码飞起来

你好，我是宫文学。

一门语言的性能高低，是它能否成功的关键。拿 JavaScript 来说，十多年来，它的性能多次得到成倍的提升，这也是前端技术栈如此丰富和强大的根本原因。

因此，编译器会无所不用其极地做优化，而优化工作在编译器的运行时间中，也占据了很大的比例。

不过，对编译技术的初学者来说，通常会搞不清楚编译器到底做了哪些优化，这些优化的实现思路又是怎样的。

所以今天这一讲，我就重点给你普及下编译器所做的优化工作，及其工作原理。在这个过程中，你还会弄明白很多似曾相识的术语，比如在前端必须了解的 AST、终结符、非终结符等，在中后端必须熟悉的常数折叠、值编号、公共子表达式消除等。只有这样，你才算是入门了。

首先，我带你认识一些常见的代码优化方法。

常见的代码优化方法

对代码做优化的方法有很多。如果要把它们分一下类的话，可以按照下面两个维度：

第一个分类维度，是机器无关的优化与机器相关的优化。机器无关的优化与硬件特征无关，比如把常数值在编译期计算出来（常数折叠）。而机器相关的优化则需要利用某硬件特有的特征，比如 SIMD 指令可以在一条指令里完成多个数据的计算。

第二个分类维度，是优化的范围。本地优化是针对一个基本块中的代码，全局优化是针对整个函数（或过程），过程间优化则能够跨越多个函数（或过程）做优化。

但优化算法很多，仅仅按照这两个维度分类，仍显粗糙。所以，我就按照优化的实现思路再分分类，让你了解起来更轻松一些。

思路 1：把常量提前计算出来

程序里的有些表达式，肯定能计算出一个常数值，那就不要等到运行时再去计算，干脆在编译期就计算出来。比如 “x=2*3”可以优化成“x=6”。这种优化方法，叫做常数折叠（Constant Folding）。

而如果你一旦知道 x 的值其实是一个常量，那你就可以把所有用到 x 的地方，替换成这个常量，这叫做常数传播（Constant Propagation）。如果有“y=x*2”这样一个语句，那么就能计算出来“y=12”。所以说，常数传播会导致更多的常数折叠。

就算不能引起新的常数折叠，比如说“z=a+x”，替换成“z=a+6”以后，计算速度也会更快。因为对于很多 CPU 来说，“a+x”和“a+6”对应的指令是不一样的。前者可能要生成两条指令（比如先把 a 放到寄存器上，再把 x 加上去），而后者用一条指令就行了，因为常数可以作为操作数。

更有用的是，常数传播可能导致分支判断条件是常量，因此导致一个分支的代码不需要被执行。这种优化叫做稀疏有条件的常数传播（Sparse Conditional Constant Propagation）。

a = 2
b = 3
if(a<b){  //判断语句去掉
  ...     //直接执行这个代码块
}
else{
  ...     //else分支会去掉
}

思路 2：用低代价的方法做计算

完成相同的计算，可以用代价更低的方法。比如“x=x+0”这行代码，操作前后 x 没有任何变化，所以这样的代码可以删掉；又比如“x=x*0” 可以简化成“x=0”。这类利用代数运算的规则所做的简化，叫做代数简化（Algebra Simplification）。

对于很多 CPU 来说，乘法运算改成移位运算，速度会更快。比如，“x*2”等价于“x<<1”，“x*9”等价于“x<<3+1”。这种采用代价更低的运算的方法，也叫做强度折减（Strength Reduction）。

思路 3：消除重复的计算

下面的示例代码中，第三行可以被替换成“z:=2*x”， 因为 y 的值就等于 x。这个时候，可能 x 的值已经在寄存器中，所以直接采用 x，运算速度会更快。这种优化叫做拷贝传播（Copy Propagation）。

x := a + b
y := x
z := 2 * y

值编号（Value Numbering）也能减少重复计算。值编号是把相同的值，在系统里给一个相同的编号，并且只计算一次即可。比如，Wikipedia上的这个案例：

w := 3
x := 3
y := x + 4
z := w + 4

其中 w 和 x 的值是一样的，因此编号是相同的。这会进一步导致 y 和 z 的编号也是相同的。进而，它们可以简化成：

w := 3
x := w
y := w + 4
z := y

值编号又可以分为两种，本地值编号（在一个基本块中）和全局值编号（GVN，在一个函数范围内）。

还有一种优化方法叫做公共子表达式消除（Common Subexpression Elimination，CSE），也会减少计算次数。下面这两行代码，x 和 y 右边的形式是一样的，如果这两行代码之间，a 和 b 的值没有发生变化（比如采用 SSA 形式），那么 x 和 y 的值一定是一样的。

x := a + b
y := a + b

那我们就可以让 y 等于 x，从而减少了一次对“a+b”的计算，这就是公共子表达式消除。

x := a + b
y := x

部分冗余消除（Partial Redundancy Elimination，PRE），是公共子表达式消除的一种特殊情况。比如，这个来自Wikipedia的例子中，一个分支有“x+4”这个公共子表达式，而另一个分支则没有。

if (some_condition) {
   // some code that does not alter x
   y = x + 4;
 }
 else {
   // other code that does not alter x
 }
 z = x + 4;

但是，上述代码仍然可以优化，使得在 if 结构中，“x+4”这个值肯定会被计算一次，因此“z=x+4”就可以被优化。

if (some_condition) {
   // some code that does not alter x
   t = x + 4;
   y = t;
 }
 else {
   // other code that does not alter x
   t = x + 4;
 }
 z = t;

思路 4：化零为整，向量计算

很多 CPU 支持向量运算，也就是 SIMD（Single Instruction Multiple Data）指令。这就可以在一条指令里计算多个数据。比如 AVX-512 指令集，可以使用 512 位的寄存器做运算，这个指令集的一条 add 指令相当于一次能把 16 个整数加到另 16 个整数上，以 1 当 16 呀。

比如，把 16 万个整数相加，应该怎样写程序呢？普通方法，是循环 16 万次，每次读 1 个数据，并做累加。向量化的方法，是每次读取 16 个，用 AVX-512 指令做加法计算，一共循环计算 1 万次，最后再把得到的 16 个数字相加就行了。

向量优化的一个例子是超字级并行（Superword-Level Parallelism，SLP)。它是把基本块中的多个变量组成一个向量，用一个指令完成多个变量的计算。

向量优化的另一个例子是循环向量化（Loop Vectorization），我会在下面针对循环的优化思路中讲到它。

思路 5：化整为零，各个优化

另一个思路是反着的，是化整为零。

很多语言都有结构和对象这样的复合数据类型，内部包含了多个成员变量，这种数据类型叫做聚合体（aggregates）。通常，为这些对象申请内存的时候，是一次就申请一整块，能放下里面的所有成员。但这样做，非常不利于做优化。

通常的优化算法都是针对标量（Scalar）的。如果经过分析，发现可以把聚合体打散，像使用单个本地变量（也就是标量）一样使用聚合体的成员变量，那就有可能带来其他优化的机会。比如，可以把聚合体的成员变量放在寄存器中进行计算，根本不需要访问内存。

这种优化叫做聚合体的标量替换（Scalar Replacement of Aggregates，SROA）。在研究 Java 的 JIT 编译器时，我们会见到一个这类优化的例子。

思路 6：针对循环，重点优化

在编译器中，对循环的优化从来都是重点，因为程序中最多的计算量都是被各种循环消耗掉的。所以，对循环做优化，会起到事半功倍的效果。如果一个循环执行了 10000 次，那么你的优化效果就会被扩大 10000 倍。

对循环做优化，有很多种方法，我来和你介绍几种常用的。

第一种：归纳变量优化（Induction Variable Optimization）。

看下面这个循环，其中的变量 j 是由循环变量派生出来的，这种变量叫做该循环的归纳变量。归纳变量的变化是很有规律的，因此可以尝试做强度折减优化。示例代码中的乘法可以由加法替代。

int j = 0;
for (int i = 0; i < 100; i++) {
    j = 2*i;  //2*i可以替换成j+2
}
return j;

第二种：边界检查消除（Unnecessary Bounds-checking Elimination）。

当引用一个数组成员的时候，通常要检查下标是否越界。在循环里面，如果每次都要检查的话，代价就会相当高（例如做多个数组的向量运算的时候）。如果编译器能够确定，在循环中使用的数组下标（通常是循环变量或者基于循环变量的归纳变量）不会越界，那就可以消除掉边界检查的代码，从而大大提高性能。

第三种：循环展开（Loop Unrolling）。

把循环次数减少，但在每一次循环里，完成原来多次循环的工作量。比如：

for (int i = 0; i< 100; i++){
  sum = sum + i;
}

优化后可以变成：

for (int i = 0; i< 100; i+=5){
  sum = sum + i;
  sum = sum + i + 1;
  sum = sum + i + 2;
  sum = sum + i + 3;
  sum = sum + i + 4;
}

进一步，循环体内的 5 条语句就可以优化成 1 条语句：“sum = sum + i*5 + 10;”。

减少循环次数，本身就能减少循环条件的执行次数。同时，它还会增加一个基本块中的指令数量，从而为指令排序的优化算法创造机会。指令排序会在下一讲中介绍。

第四种：循环向量化（Loop Vectorization）。

在循环展开的基础上，我们有机会把多次计算优化成一个向量计算。比如，如果要循环 16 万次，对一个包含了 16 万个整数的数组做汇总，就可以变成循环 1 万次，每次用向量化的指令计算 16 个整数。

第五种：重组（Reassociation）。

在循环结构中，使用代数简化和重组，能获得更大的收益。比如，如下对数组的循环操作，其中数组a[i,j]的地址是“a+i*N+j”。但这个运算每次循环就要计算一次，一共要计算M*N次。但其实，这个地址表达式的前半截“a+i*N”不需要每次都在内循环里计算，只要在外循环计算就行了。

for (i = 0; i< M; i++){
  for (j = 0; j<N; j++){
    a[i,j] = b + a[i,j];
  }
}

优化后的代码相当于：

for (i = 0; i< M; i++){
  t=a+i*N;
  for (j = 0; j<N; j++){
    *(t+j) = b + *(t+j);
  }
}

第六种：循环不变代码外提（Loop-Invariant Code Motion，LICM）。

在循环结构中，如果发现有些代码其实跟循环无关，那就应该提到循环外面去，避免一次次重复计算。

第七种：代码提升（Code Hoisting，或 Expression Hoisting）。

在下面的 if 结构中，then 块和 else 块都有“z=x+y”这个语句，它可以提到 if 语句的外面。

  if (x > y)
    ...
    z = x + y
    ...
  }
  else{
    z = x + y
    ...
  }

这样变换以后，至少代码量会降低。但是，如果这个 if 结构是在循环里面，那么可以继续借助循环不变代码外提优化，把“z=x+y”从循环体中提出来，从而降低计算量。

z = x + y
for(int i = 0; i < 10000; i++){
  if (x > y)
    ...
  }
  else{
    ...
  }
}

另外，前面说过的部分冗余优化，也可能会产生可以外提的代码，借助这一优化方法，可以形成进一步优化的效果。

针对循环能做的优化还有不少，因为对循环做优化往往是收益很高的！

思路 7：减少过程调用的开销

你知道，当程序调用一个函数的时候，开销是很大的，比如保存原来的栈指针、保存某些寄存器的值、保存返回地址、设置参数，等等。其中很多都是内存读写操作，速度比较慢。

所以，如果能做一些优化，减少这些开销，那么带来的优化效果会是很显著的，具体的优化方法主要有下面几种。

第一种：尾调用优化（Tail-call Optimization）和尾递归优化（Tail-recursion Elimination）。

尾调用就是一个函数的最后一句，是对另一个函数的调用。比如，下面这段示例代码：

f(){
  ...
  return g(a,b);
}

而如果 g() 本身就是 f() 的最后一行代码，那么 f() 的栈帧已经没有什么用了，可以撤销掉了（修改栈顶指针的值），然后直接跳转到 g() 的代码去执行，就像 f() 和 g() 是同一个函数一样。这样可以让 g() 复用 f() 的栈空间，减少内存消耗，也减少一些内存读写操作（比如，保护寄存器、写入返回地址等）。

如果 f() 和 g() 是同一个函数，这就叫做尾递归。很多同学都应该知道，尾递归是可以转化为一个循环的。我们在第 3 讲改写左递归文法为右递归文法的时候，就曾经用循环代替了递归调用。尾递归转化为循环，不但可以节省栈帧的开销，还可以进一步导致针对循环的各种优化。

第二种：内联（inlining）。

内联也叫做过程集成（Procedure Integration），就是把被调用函数的代码拷贝到调用者中，从而避免函数调用。

对于我们现在使用的面向对象的语言来说，有很多短方法，比如 getter、settter 方法。这些方法内联以后，不仅仅可以减少函数调用的开销，还可以带来其他的优化机会。在探究 Java 的 JIT 编译器时，我就会为你剖析一个内联的例子。

第三种：内联扩展（In-Line Expansion）。

内联扩展跟普通内联类似，也是在调用的地方展开代码。不过内联扩展被展开的代码，通常是手写的、高度优化的汇编代码。

第四种：叶子程序优化（Leaf-Routine Optimization）。

叶子程序，是指不会再调用其他程序的函数（或过程）。因此，它也可以对栈的使用做一些优化。比如，你甚至可以不用生成栈帧，因为根据某些调用约定，程序可以访问栈顶之外一定大小的内存。这样就省去了保存原来栈顶、修改栈顶指针等一系列操作。

思路 8：对控制流做优化

通过对程序的控制流分析，我们可以发现很多优化的机会。这就好比在做公司管理，优化业务流程，就会提升经营效率。我们来看一下这方面的优化方法有哪些。

第一种：不可达代码消除（Unreacheable-code Elimination）。根据控制流的分析，发现有些代码是不可能到达的，可以直接删掉，比如 return 语句后面的代码。

第二种：死代码删除（Dead-code Elimination）。通过对流程的分析，发现某个变量赋值了以后，后面根本没有再用到这个变量。这样的代码就是死代码，就可以删除。

第三种：If 简化（If Simplification)。在讲常量传播时我们就见到过，如果有可能 if 条件肯定为真或者假，那么就可以消除掉 if 结构中的 then 块、else 块，甚至整个消除 if 结构。

第四种：循环简化（Loop Simplification）。也就是把空循环或者简单的循环，变成直线代码，从而增加了其他优化的机会，比如指令的流水线化。

第五种：循环反转（Loop Inversion）。这是对循环语句常做的一种优化，就是把一个 while 循环改成一个 repeat…until 循环（或者 do…while 循环）。这样会使基本块的结构更简化，从而更有利于其他优化。

第六种：拉直（Straightening）。如果发现两个基本块是线性连接的，那可以把它们合并，从而增加优化机会。

第七种：反分支（Unswitching）。也就是减少程序分支，因为分支会导致程序从一个基本块跳到另一个基本块，这样就不容易做优化。比如，把循环内部的 if 分支挪到循环外面去，先做 if 判断，然后再执行循环，这样总的执行 if 判断的次数就会减少，并且循环体里面的基本块不那么零碎，就更加容易优化。

这七种优化方法，都是对控制流的优化，有的减少了基本块，有的减少了分支，有的直接删除了无用的代码。

代码优化所依赖的分析方法

前面我列举了很多优化方法，目的是让你认识到编译器花费大量时间去做的，到底都是一些什么工作。当然了，我只是和你列举了最常用的一些优化方法，不过这已经足够帮助你建立对代码优化的直觉认知了。我们在研究具体的编译器的时候，还会见到其他一些优化方法。不过你不用担心，根据上面讲到的各种优化思路，你可以举一反三，非常快速地理解这些新的优化方法。

上述优化方法，有的比较简单，比如常数折叠，依据 AST 或 MIR 做点处理就可以完成。但有些优化，就需要比较复杂的分析方法做支撑才能完成。这些分析方法包括控制流分析、数据流分析、依赖分析和别名分析等。

控制流分析（Control-Flow Analysis，CFA）。控制流分析是帮助我们建立对程序执行过程的理解，比如哪里是程序入口，哪里是出口，哪些语句构成了一个基本块，基本块之间跳转关系，哪个结构是一个循环结构（从而去做循环优化），等等。

前面提到的控制流优化，就是要基于对控制流的正确理解。下面要讲的数据流分析算法，在做全局分析的时候，也要基于控制流图（CFG），所以也需要以控制流分析为基础。

数据流分析（Data-Flow Analysis，DFA）。数据流分析，能够帮助我们理解程序中的数据变化情况。我们看一个分析变量活跃性的例子。

如下图所示，它从后到前顺序扫描代码，花括号中的是在当前位置需要的变量的集合。如果某个变量不被需要，那就可以做死代码删除的优化。

经过多遍扫描和删除后，最后的代码会精简成一行：

关于数据流分析框架的详细描述，你可以再参考下其他资料（比如，《编译原理之美》专栏第27和28两讲）。

除了做变量活跃性分析以外，数据流分析方法还可以做很多有用的分析。比如，可达定义分析（Reaching Definitions Analysis）、可用表达式分析（Available Expressions Analysis）、向上暴露使用分析（Upward Exposed Uses Analysis）、拷贝传播分析（Copy-Propagation Analysis）、常量传播分析（Constant-Propagation Analysis）、局部冗余分析（Partial-Redundancy Analysis）等。

就像基于变量活跃性分析可以做死代码删除的优化一样，上述分析是做其他很多优化的基础。

依赖分析（Dependency Analysis）。依赖分析，就是分析出程序代码的控制依赖（Control Dependency）和数据依赖（Data Dependency）关系。这对指令排序和缓存优化很重要。

指令排序会在下一讲介绍。它能通过调整指令之间的顺序来提升执行效率。但指令排序不能打破指令间的依赖关系，否则程序的执行就不正确。

别名分析（Alias Analysis）。在 C、C++ 等可以使用指针的语言中，同一个内存地址可能会有多个别名，因为不同的指针都可能指向同一个地址。编译器需要知道不同变量是否是别名关系，以便决定能否做某些优化。

好了，你已经了解了优化的方法和所依赖的分析方法。那么，这些方法这么多，哪些优化方法更重要，优化的顺序又是什么呢？

优化方法的重要性和顺序

我们先看看哪些优化方法更重要。

有些优化，比如对循环的优化，对每门语言都很重要，因为循环优化的收益很大。

而有些优化，对于特定的语言更加重要。在课程后面分析像 Java、JavaScript 这样的面向对象的现代语言时，你会看到，内联优化和逃逸分析的收益就比较大。而对于某些频繁使用尾递归的函数式编程语言来说，尾递归的优化就必不可少，否则性能损失太大。

至于优化的顺序，有的优化适合在早期做（基于 HIR 和 MIR），有的优化适合在后期做（基于 LIR 和机器代码）。并且，你通过前面的例子也可以看到，一般做完某个优化以后，会给别的优化带来机会，所以经常会在执行某个优化算法的时候，调用了另一个优化算法，而同样的优化算法也可能会运行好几遍。

课程小结

今天这讲，我带你认识了很多常见的优化方法和背后的分析方法。我们很难一下子记住所有的方法，但完全可以先对这些概念建立总体印象。这样可以避免在研究具体编译器时，我们产生“瞎子摸象”的感觉。

另外，熟悉我提到的那些名词术语也很重要，因为它们经常在代码注释和相关文献里出现。这些名词要成为你的一项基本功。

我把今天的课程内容，也整理成了思维导图，供你复习、参考。

在课程的第二个模块“真实编译器解析篇”的时候，我会和你分析某些优化算法具体的实现细节，并带你跟踪编译优化的过程。

根据我的经验，当你写的程序对性能要求很高的时候，你需要能够跟踪了解编译优化的过程，看看如何才能达到最好的优化效果。我之前写过与内存计算有关的程序，就特别关注如何才能让编译器做向量优化。因为是否使用向量，性能差别很大。现在做 AI 工作的同学，一定也有类似的需求。

还有些开源项目，它们的性能与内联关系密切。这就要做一定的调优，以确保使用频率最高、性能影响最大的函数全部内联。

还有，Chrome、Android 和 Flutter 共同使用的二维图形引擎 Skia 对性能很敏感，所以即使在 Windows 平台上，仍然要求用 Clang 编译。为啥坚持用 Clang 编译呢？因为 Skia 跟 LLVM 的优化方法是紧密配合的，换了其他编译器就达不到这么好的优化效果。

类似的例子还有很多。了解优化，能够充分利用编译器的优化能力，应该是我们想拥有的一项高级技能。

一课一思

你可以比较一下值编号和公共子表达式消除这两个优化方法，说说它们的相同点和不同点吗？你能举出一个例子来，是其中一个算法能做优化，而另一个算法不能的吗？

欢迎在留言区中分享你的思考，也欢迎你把这节课分享给你的朋友。

参考资料

龙书（Compilers Principles, Techniques and Tools）：第 9 章，机器无关的优化，里面介绍了各种优化算法。

鲸书（Advanced Compiler Design and Implementation）中讲优化的算法有很多，第 7~15 章你都可以看看。

08 | 代码生成：如何实现机器相关的优化？

你好，我是宫文学。我们继续来学习编译器后端的技术。

在编译过程的前几个阶段之后，编译器生成了 AST，完成了语义检查，并基于 IR 运行了各种优化算法。这些工作，基本上都是机器无关的。但编译的最后一步，也就是生成目标代码，则必须是跟特定 CPU 架构相关的。

这就是编译器的后端。不过，后端不只是简单地生成目标代码，它还要完成与机器相关的一些优化工作，确保生成的目标代码的性能最高。

这一讲，我就从机器相关的优化入手，带你看看编译器是如何通过指令选择、寄存器分配、指令排序和基于机器代码的优化等步骤，完成整个代码生成的任务的。

首先，我们来看看编译器后端的任务：生成针对不同架构的目标代码。

生成针对不同 CPU 的目标代码

我们已经知道，编译器的后端要把 IR 翻译成目标代码，那么要生成的目标代码是什么样子的呢？

我以 foo.c 函数为例：

int foo(int a, int b){
    return a + b + 10;
}

执行“clang -S foo.c -o foo.x86.s”命令，你可以得到对应的 x86 架构下的汇编代码（为了便于你理解，我进行了简化）：

#序曲
pushq  %rbp
movq  %rsp, %rbp     #%rbp是栈底指针

#函数体
movl  %edi, -4(%rbp) #把第1个参数写到栈里第一个位置（偏移量为4）
movl  %esi, -8(%rbp) #把第2个参数写到栈里第二个位置（偏移量为8）
movl  -4(%rbp), %eax #把第1个参数写到%eax寄存器
addl  -8(%rbp), %eax #把第2个参数加到%eax
addl  $10, %eax      #把立即数10加到%eax，%eax同时是放返回值的地方

#尾声
popq  %rbp
retq

小提示：上述汇编代码采用的是 GNU 汇编器的代码格式，源操作数在前面，目的操作数在后面。

我在第 1 讲中说过，要翻译成目标代码，编译器必须要先懂得目标代码，就像做汉译英一样，我们必须要懂得英语。可是，通常情况下，我们会对汇编代码比较畏惧，觉得汇编语言似乎很难学。其实不然。

补充说明：有些编译器，是先编译成汇编代码，再通过汇编器把汇编代码转变成机器码。而另一些编译器，是直接生成机器码，并写成目标文件，这样编译速度会更快一些。但这样的编译器一般就要带一个反汇编器，在调试等场合把机器码转化成汇编代码，这样我们看起来比较方便。因此，在本课程中，我有时会不区分机器码和汇编代码。我可能会说，编译器生成了某机器码，但实际写给你看的是汇编代码，因为文本化的汇编代码才方便阅读。你如果看到这样的表述，不要感到困惑。

那为什么我说汇编代码不难学呢？你可以去查阅下各种不同 CPU 的指令。然后，你就会发现这些指令其实主要就那么几种，一类是做加减乘除的（如 add 指令），一类是做内存访问的（如 mov、lea 指令），一类是控制流程的（如 jmp、ret 指令），等等。说得夸张一点，这就是个复杂的计算器。

只不过，相比于高级语言，汇编语言涉及的细节比较多。它是啰嗦，但并不复杂。那我再分享一个我学习汇编代码的高效方法：让编译器输出高级语言的汇编代码，多看一些各种情况下汇编代码的写法，自然就会对汇编语言越来越熟悉了。

不过，虽然针对某一种 CPU 的汇编并不难，但问题是不同架构的 CPU，其指令是不同的。编译器的后端每支持一种新的架构，就要有一套新的代码。这对写一个编译器来说，就是很大的工作量了。

我来举个例子。我们使用“clang -S -target armv7a-none-eabi foo.c -o foo.armv7a.s”命令，生成一段针对 ARM 芯片的汇编代码：

//序曲
sub sp, sp, #8    //把栈扩展8个字节，用于放两个参数，sp是栈顶指针

//函数体
str r0, [sp, #4]  //把第1个参数写到栈顶+4的位置
str r1, [sp]      //把第2个参数写到栈顶位置
ldr r0, [sp, #4]  //把第1个参数从栈里加载到r0寄存器
ldr r1, [sp]      //把第2个参数从站立加载到r1寄存器
add r0, r0, r1    //把r1加到r0,结果保存在r0
add r0, r0, #10   //把常量10加载到r0，结果保存在r0,r0也是放返回值的地方

//尾声
add sp, sp, #8    //缩减栈
bx  lr            //返回

把这段代码，与前面生成的针对 x86 架构的汇编代码比较一下，你马上就会发现一些不同。这两种 CPU，完成相同功能所使用的汇编指令和寄存器都不同。我们来分析一下其中的原因。

x86 的汇编，mov 指令的功能很强大，可以从内存加载到寄存器，也可以从寄存器保存回内存，还可以从内存的一个地方拷贝到另一个地方、从一个寄存器拷贝到另一个寄存器。add 指令的操作数也可以使用内存地址。

而在 ARM 的汇编中，从寄存器到内存要使用 str（也就是 Store）指令，而从内存到寄存器要使用 ldr（也就是 Load）指令。对于加法指令 add 而言，两个操作数及计算结果都必须使用寄存器。

知识扩展：ARM 的这种指令风格叫做 Load-Store 架构。在这种架构下，指令被分为内存访问（Load 和 Store）和 ALU 操作两大类，而后者只能在寄存器上操作。各种 RISC 指令集都是 Load-Store 架构的，比如 PowerPC、RISC-V、ARM 和 MIPS 等。

而像 x86 这种 CISC 指令，叫做 Register-Memory 架构，在指令里可以混合使用内存地址和寄存器。

为了支持不同的架构，你可以通过手写算法来生成目标代码，但这样工作量显然会很大，维护负担也比较重。

另一种方法，是编写“代码生成器的生成器”。也就是说，你可以把 CPU 架构的各种信息（比如有哪些指令、指令的特点、有哪些寄存器等）描述出来，然后基于这些信息生成目标代码的生成器，就像根据语法规则，用 ANTLR、bison 这样的工具来生成语法解析器一样。

经过这样的处理，虽然我们生成的目标代码是架构相关的，但中间的处理算法却可以尽量做成与架构无关的。

生成目标代码时的优化工作

生成目标代码的过程要进行多步处理。比如，你一定注意到了，前面 foo.c 函数示例程序生成的汇编代码是不够优化的：它把参数信息从寄存器写到栈里，然后再从栈里加载到寄存器，用于计算。实际上，改成更优化的算法，是不需要内存访问的，从而节省了内存读写需要花费的大量时间。

所以接下来，我就带你一起了解在目标代码生成过程中进行的优化处理，包括指令选择、寄存器分配、指令排序、基于机器代码的优化等步骤。在这个过程中，你会知道编译器的后端，是如何充分发挥硬件的性能的。

首先，我们看看指令选择，它的作用是在完成相同功能的情况下，选择代价更低的指令组合。

指令选择

为了理解指令选择有什么用，这里我和你分享三个例子吧。

第一个例子：对于 foo.c 示例代码，在编译时加上“-O2”指令，就会得到如下的优化代码：

#序曲
pushq   %rbp
movq    %rsp, %rbp

#函数体
leal    10(%rdi,%rsi), %eax

#尾声
popq    %rbp
retq

它使用了 lea 指令，可以一次完成三个数的相加，并把结果保存到 %eax。这样一个 lea 指令，代替了三条指令（一条 mov，两条 add），显然更优化。

这揭示了我们生成代码时面临的一种情况：对于相同的源代码和 IR，编译器可以生成不同的指令，而我们要选择代价最低的那个。

第二个例子：对于“a[i]=b”这样一条语句，要如何生成代码呢？

你应该知道数组寻址的原理，a[i]的地址就是从数组 a 的起始地址往后偏移 i 个单位。对于整型数组来说，a[i]的地址就是 a+i*4。所以，我可以用两条指令实现这个赋值操作：第一条指令，计算 a[i]的地址；第二条指令，把 b 的值写到这个地址。

数组操作是很常见的现象，于是 x86 芯片专门提供了一种寻址方式，简化了数组的寻址，这就是间接内存访问。间接内存访问的完整形式是：偏移量（基址，索引值，字节数），其地址是：基址 + 索引值 * 字节数 + 偏移量。

所以，如果我们把 a 的地址放到 %rdi，i 的值放到 %rax，那么 a[i]的地址就是 (%rdi,%rax,4)。这样的话，a[i]=b 用一条 mov 指令就能完成。

第三个例子。我们天天在用的 x86 家族的芯片，它支持很多不同的指令集，比如 SSE、AVX、FMA 等，每个指令集里都有能完成加减乘除运算的指令。当然，每个指令集适合使用的场景也不同，我们要根据情况选择最合适的指令。

好了，现在你已经知道了指令选择的作用了，它在具体实现上有很多算法，比如树覆盖算法，以及 BURS（自底向上的重写系统）等。

我们再看一下刚刚这段优化后的代码，你是不是发现了，优化后的算法对寄存器的使用也更加优化了。没错，接下来我们就分析下寄存器分配。

寄存器分配

优化后的代码，去掉了内存操作，直接基于寄存器做加法运算，比优化之前的运行速度要快得多（我在第 5 讲提到过，内存访问比寄存器访问大约慢 100 倍）。

同样的，ARM 的汇编代码也可以使用“-O2”指令优化。优化完毕以后，最后剩下的代码只有三行。而且因为不需要访问内存，所以连栈顶指针都不需要挪动，进一步减少了代码量。

add  r0, r0, r1
add  r0, r0, #10
bx  lr

对于编译器来说，肯定要尽量利用寄存器，不去读写内存。因为内存读写对于 CPU 来说就是 IO，性能很低。特别是像函数中用到的本地变量和参数，它们在退出作用域以后就没用了，所以能放到寄存器里，就放寄存器里吧。

在 IR 中，通常我们会假设寄存器是无限的（就像 LLVM 的 IR），但实际 CPU 中的寄存器是有限的。所以，我们就要用一定的算法，把寄存器分配给使用最频繁的变量，比如循环中的变量。而对于超出物理寄存器数量的变量，则“溢出”到栈里，通过内存来保存。

寄存器分配的算法有很多种。一个使用比较广泛的算法是寄存器染色算法，它的特点是计算结果比较优化，但缺点是计算量比较大。

另一个常见的算法是线性扫描算法，它的优点是计算速度快，但缺点是有可能不够优化，适合需要编译速度比较快的场景，比如即时编译。在解析 Graal 编译器的时候，你会看到这种算法的实现。

寄存器分配算法对性能的提升是非常显著的。接下来我要介绍的指令排序，对性能的提升同样非常显著。

指令排序

首先我们来看一个例子。下面示例程序中的 params 函数，有 6 个参数：

int params(int x1,int x2,int x3,int x4,int x5,int x6）{
    return x1 + x2 + x3 + x4 + x5 + x6 + 10;
}

把它编译成 ARM 汇编代码，如下：

//序曲
push    {r11, lr}     //把r11和lr保存到栈中，lr里面是返回地址
mov r11, sp           //把栈顶地址保存到r11

//函数体
add r0, r0, r1        //把参数2加到参数1，保存在r0
ldr lr, [r11, #8]     //把栈里的参数5加载到lr，这里是把lr当通用寄存器用
add r0, r0, r2        //把参数3加到r0
ldr r12, [r11, #12]   //把栈里的参数6加载到r12
add r0, r0, r3        //把参数4加到r0
add r0, r0, lr        //把参数5加到r0
add r0, r0, r12       //把参数6加到r0
add r0, r0, #10       //把立即数加到r0

//尾声
pop {r11, pc}         //弹出栈里保存的值。注意，原来lr的值直接赋给了pc，也就是程序计数器，所以就跳转到了返回地址

根据编译时使用的调用约定，其中有 4 个参数是通过寄存器传递的（r0~r3），还有两个参数是在栈里传递的。

值得注意的是，在把参数 5 和参数 6 用于加法操作之前，它们就被提前执行加载（ldr）命令了。那，为什么会这样呢？这就涉及到 CPU 执行指令的一种内部机制：流水线（Pipeline）。

原来，CPU 内部是分成多个功能单元的。对于一条指令，每个功能单元处理完毕以后，交给下一个功能单元，然后它就可以接着再处理下一条指令。所以，在同一时刻，不同的功能单元实际上是在处理不同的指令。这样的话，多条指令实质上是并行执行的，从而减少了总的执行时间，这种并行叫做指令级并行。

在下面的示意图中，每个指令的执行被划分成了 5 个阶段，每个阶段占用一个时钟周期，如下图所示：

图 1：多个功能单元并行

因为每个时钟周期都可以开始执行一条新指令，所以虽然一条指令需要 5 个时钟周期才能执行完，但在同一个时刻，却可以有 5 条指令并行执行。

但是有的时候，指令之间会存在依赖关系，后一条指令必须等到前一条指令执行完毕才能运行（在上一讲，我们曾经提到过依赖分析，指令排序就会用到依赖分析的结果）。比如，前面的示例程序中，在使用参数 5 的值做加法之前，必须要等它加载到内存。这样的话，指令就不能并行了，执行时间就会大大延长。

图 2：缺少充分的并行，会导致总执行时间变长

讲到这里，你就明白了，为什么在示例程序中，要把 ldr 指令提前执行，目的就是为了更好地利用流水线技术，实现指令级并行。

补充：这里我把执行阶段分为 5 段，只是给你举个例子。很多实际的 CPU 架构，划分了更多的阶段。比如，某类型的奔腾芯片支持 21 段，那理论上也就意味着可以有 21 条指令并行执行，但它的前提是必须做好指令排序的优化。

另外，现代一些 CISC 的 CPU 在硬件层面支持乱序执行（Out-of-Order）。一批指令给到 CPU 后，它也会在内部打乱顺序去优化执行。而 RISC 芯片一般不支持乱序执行，所以像 ARM 这样的芯片，做指令排序就更加重要。

另外，在上一讲，我提到过对循环做优化的一种技术，叫做循环展开（Loop Unroll），它会把循环体中的代码重复多次，与之对应的是减少循环次数。这样一个基本块中就会有更多条指令，增加了通过指令排序做优化的机会。

指令排序的算法也有很多种，比如基于数据依赖图的 List Scheduling 算法。在后面的课程中，我会带你考察一下真实世界中的编译器都使用了什么算法。

OK，了解完指令排序以后，还有什么优化可以做呢？

窥孔优化（Peephole Optimization）

基于 LIR 或目标代码，代码还有被进一步优化的可能性。这就是代码优化的特点。比如，你在前面做了常数折叠以后，后面的处理步骤修改了代码或生成新的代码以后，可能还会产生出新的常数折叠的机会。另外，有些优化也只有在目标代码的基础上才方便做。

给你举个例子吧：假设相邻两条指令，一条指令从寄存器保存数据到栈里，下一条指令又从栈里原封不动地把数据加载到原来的寄存器，那么这条加载指令就是冗余的，可以去掉。

str r0, [sp, #4]  //把r0的值保存到栈顶+4的位置
ldr r0, [sp, #4]  //把栈顶+4位置的值加载到r0寄存器

基于目标代码的优化，最常用的方法是窥孔优化（Peephole Optimization）。窥孔优化的思路，是提供一个固定大小的窗口，比如能够容纳 20 条指令，并检查窗口内的指令，看看是否可以优化。然后再往下滑动窗口，再次检查优化机会。

最后，还有一个因素会影响目标代码的生成，就是调用约定。

调用约定的影响

还记得前面示例的 x86 的汇编代码吗？其中的 %edi 寄存器用来传递第一个参数，%esi 寄存器用来传递第二个参数，这就是遵守了一种广泛用于 Unix 和 Linux 系统的调用约定“System V AMD64 ABI”。这个调用约定规定，对于整型参数，前 6 个参数可以用寄存器传递，6 个之后的参数就要基于栈来传递。

#序曲
pushq  %rbp
movq  %rsp, %rbp     #%rbp是栈底指针

#函数体
movl  %edi, -4(%rbp) #把第1个参数写到栈里第一个位置（偏移量为4）
movl  %esi, -8(%rbp) #把第2个参数写到栈里第二个位置（偏移量为8）
movl  -4(%rbp), %eax #把第1个参数写到%eax寄存器
addl  -8(%rbp), %eax #把第2个参数加到%eax
addl  $10, %eax      #把立即数10加到%eax，%eax同时是放返回值的地方。

#尾声
popq  %rbp
retq

知识扩展：ABI 是 Application Binary Interface 的缩写，也就是应用程序的二进制接口。通常，ABI 里面除了规定调用约定外，还要包括二进制文件的格式、进程初始化的方式等更多内容。

而在看 ARM 的汇编代码时，我们会发现，它超过了 4 个参数就要通过栈来传递。实际上，它遵循的是一种不同 ABI，叫做 EABI（嵌入式应用程序二进制接口）。在调用 Clang 做编译的时候，-target 参数“armv7a-none-eabi”的最后一部分，就是指定了 EABI。

//序曲
sub sp, sp, #8    //把栈扩展8个字节，用于放两个参数，sp是栈顶指针

//函数体
str r0, [sp, #4]  //把第1个参数写到栈顶+4的位置
str r1, [sp]      //把第2个参数写到栈顶位置
ldr r0, [sp, #4]  //把第1个参数从栈里加载到r0寄存器
ldr r1, [sp]      //把第2个参数从站立加载到r1寄存器
add r0, r0, r1    //把r1加到r0,结果保存在r0
add r0, r0, #10   //把常量10加载到r0，结果保存在r0,r0也是放返回值的地方

//尾声
add sp, sp, #8    //缩减栈
bx  lr            //返回

在实现编译器的时候，你可以发明自己的调用约定，比如哪些寄存器用来放参数、哪些用来放返回值，等等。但是，如果你要使用别的语言编译好的目标文件，或者你想让自己的编译器生成的目标文件被别人使用，那你就要遵守某种特定的 ABI 标准。

后端处理的整体过程

好了，到这里，我已经介绍完了生成目标代码过程中所做的各种优化处理。那么，我们怎么把它们串成一个整体呢？

图 3：典型的后端处理过程

在实际实现时，我们通常是先做指令选择，然后做一次指令排序。在分配完寄存器以后，还要再做一次指令排序，因为寄存器分配算法会产生新的指令排序优化的机会。比如，一些变量会溢出到栈里，从而增加了一些内存访问指令。

这个处理过程，其实也是 IR 不断 lower 的过程。一开始是 MIR，在做了指令选择以后，就变成了具体架构相关的 LIR 了。在没做寄存器分配之前，我们在 LIR 中用到寄存器还是虚拟的，数量是无限的，做完分配以后，就变成具体的物理寄存器的名称了。

与机器相关的优化（如窥孔优化）也会穿插在整个过程中。最后一个步骤，是通过一个 Emit 目标代码的程序生成目标代码。因为 IR 已经被 lower 得很接近目标代码了，所以这个翻译程序是比较简单的。

课程小结

今天这一讲，我带你认识了编译器在后端的主要工作，也就是生成目标代码时，所需要的各种优化和处理。你需要注意理解每一步处理的原理，比如到底为什么需要做指令选择，形成直观认识。

这一讲，我没有带你去深入算法的细节，而是希望先带你建立一个整体的认知。在我们考察真实的编译器时，你要注意研究它们的后端是如何实现的。

我把今天的课程内容，也整理成了思维导图，供你参考。

一课一思

用 Clang（或 gcc）来生成汇编代码，对研究生成目标代码时的优化效果非常有帮助。你可以设计一个 C 语言的简单函数，测试出编译器在指令选择、寄存器分配或指令排序的任意方面的优化效果吗？

你可以比较下，带和不带“-O2”参数生成的汇编代码，有什么不同。你还可以查看手册，使用更多的选项（比如对于x86 架构，你可以控制是否使用 AVX 指令集）。这个练习，会帮助你获得更多的直观理解。

在留言区，把你动手实验的成果分享出来吧，我们一起交流讨论。如果觉得有收获，也欢迎你把今天的内容分享给更多的朋友。

参考链接

关于汇编代码、寄存器、调用约定等内容更详细的介绍，你可以参考《编译原理之美》的第23、24讲。

关于指令选择的算法，你可以参考《编译原理之美》的第 29 讲，我介绍了一个树覆盖算法。

关于寄存器分配的算法，你也可以参考《编译原理之美》的第 29 讲，我介绍了一个寄存器染色算法。

关于指令排序的算法，你可以参考《编译原理之美》的第 30 讲，深入去看一下基于数据依赖图的 List Scheduling 算法。

09 | Java编译器（一）：手写的编译器有什么优势？

你好，我是宫文学。

从今天开始呢，我会带着你去考察实际编译器的具体实现机制，你可以从中学习和印证编译原理的基础知识，进而加深你对编译原理的理解。

我们探险的第一站，是很多同学都很熟悉的 Java 语言，我们一起来看看它的编译器里都有什么奥秘。我从 97 年就开始用它，算是比较早了。当时，我就对它的“一次编译，到处运行”留下了很深的印象，我在 Windows 下写的程序，编译完毕以后放到 Solaris 上就能跑。现在看起来这可能不算什么，但在当年，我在 Windows 和 Unix 下写程序用的工具可是完全不同的。

到现在，Java 已经是一门非常成熟的语言了，而且它也在不断进化，与时俱进，泛型、函数式编程、模块化等特性陆续都增加了进来。在服务端编程领域，它也变得非常普及。

与此同时，Java 的编译器和虚拟机中所采用的技术，也比 20 年前发生了天翻地覆的变化。对于这么一门成熟的、广泛普及的、又不断焕发新生机的语言来说，研究它的编译技术会带来两个好处：一方面，Java 编译器所采用的技术肯定是比较成熟的、靠谱的，你在实现自己的编译功能时，完全可以去参考和借鉴；另一方面，你可以借此深入了解 Java 的编译过程，借此去实现一些高级的功能，比方说，按需生成字节码，就像 Spring 这类工具一样。

因此，我会花 4 讲的时间，跟你一起探索 Java 的前端编译器（javac）。然后再花 4 讲的时间在 Java 的 JIT 编译器上。

那么，针对 Java 编译器，你可能会提出下面的问题：

Java 的编译器是用什么语言编写的？

Java 的词法分析器和语法分析器，是工具生成的，还是手工编写的？为什么会这样选择？

语法分析的算法分为自顶向下和自底向上的。那么 Java 的选择是什么呢？有什么道理吗？

如何自己动手修改 Java 编译器？

这些问题，在今天的旅程结束后，你都会获得解答。并且，你还会获得一些额外的启发：噢，原来这个功能是可以这样做的呀！这是对你探险精神的奖励。

好吧，让我们开始吧。

第一步，我们先初步了解一下 Java 的编译器。

初步了解 Java 的编译器

大多数 Java 工程师是通过 javac 命令来初次接触 Java 编译器的。假设你写了一个 MyClass 类：

public class MyClass {
    public int a = 2+3;
    public int foo(){
        int b = a + 10;
        return b;
    }
}

你可以用 javac 命令把 MyClass.java 文件编译成字节码文件：

javac MyClass.java

那这个 javac 的可执行文件就是 Java 的编译器吗？并不是。javac 只是启动了一个 Java 虚拟机，执行了一个 Java 程序，跟我们平常用“java”命令运行一个程序是一样的。换句话说，Java 编译器本身也是用 Java 写的。

这就很有趣了。我们知道，计算机语言是用来编写软件的，而编译器也是一种软件。所以，一门语言的编译器，竟然可以用自己来实现。这种现象，叫做“自举”(Bootstrapping)，这就好像一个人抓着自己的头发，要把自己提起来一样，多么神奇！实际上，一门语言的编译器，一开始肯定是要用其他语言来实现的。但等它成熟了以后，就会尝试实现自举。

既然 Java 编译器是用 Java 实现的，那意味着你自己也可以写一个程序，来调用 Java 的编译器。比如，运行下面的示例代码，也同样可以编译 MyClass.java 文件，生成 MyClass.class 文件：

import javax.tools.JavaCompiler;
import javax.tools.ToolProvider;

public class CompileMyClass {
    public static void main(String[] args) {
        JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
        int result = compiler.run(null, null, null, "MyClass.java");
        System.out.println("Compile result code = " + result);
    }
}

其中，javax.tools.JavaCompiler 就是 Java 编译器的入口，属于 java.compiler 模块。这个模块包含了 Java 语言的模型、注解的处理工具，以及 Java 编译器的 API。

javax.tools.JavaCompiler 的实现是 com.sun.tools.javac.main.JavaCompiler。它在 jdk.compiler 模块中，这个模块里才是 Java 编译器的具体实现。

不过，在探索 Java 编译器的实现原理之前，你还需要从 openjdk.java.net 下载 JDK 的源代码，我使用的版本是 JDK14。在 IDE 中跟踪 JavaCompiler 的执行过程，你就会看到它一步一步地都是使用了哪个类的哪个方法。Java 的 IDE 工具一般都比较友好，给我们的探索提供了很多便利。

不仅如此，你还可以根据 openjdk 的文档，从源代码构建出 JDK。你还可以修改源代码并构建你自己的版本。

获得了源代码以后，我建议你重点关注这几个地方的源代码，这能帮助你迅速熟悉 Java 编译器的源代码结构。

首先是 com.sun.source.tree 包，这个包里面是 Java 语言的 AST 模型。我们在写一个编译器的时候，肯定要设计一个数据结构来保存 AST，那你就可以去参考一下 Java 是怎么做的。接下来，我就挑其中几个比较常用的节点，给你解释一下：

ExpressionTree 指的是表达式，各种不同的表达式继承了这个接口，比如 BinaryTree 代表了所有的二元表达式；

StatementTree 代表了语句，它的下面又细分了各种不同的语句，比如，IfTree 代表了 If 语句，而 BlockTree 代表的是一个语句块。

图 1：com.sun.source.tree 包里的 Java 语言的 AST 模型

然后是 com.sun.tools.javac.parser.Lexer（词法解析器接口），它可以把字符流变成一个个的 Token，具体的实现在 Scanner 和 JavaTokenizer 类中。

接下来是 com.sun.tools.javac.parser.Parser（语法解析器接口），它能够解析类型、语句和表达式，具体的实现在 JavacParser 类中。

总结起来，Java 语言中与编译有关的功能放在了两个模块中：其中，java.compiler 模块主要是对外的接口，而 jdk.compiler 中有具体的实现。不过你要注意，像 com.sun.tools.javac.parser 包中的类，不是 Java 语言标准的组成部分，如果你直接使用这些类，可能导致代码在不同的 JDK 版本中不兼容。

现在，我们已经熟悉了 Java 编译器的概要信息。在浏览这两个模块的代码时，我们会发现里面的内容非常多。为了让自己不会迷失在其中，我们需要找到一个方法。你已经知道，编译器的前端分为词法分析、语法分析、语义分析等阶段，那么我们就可以按照这个阶段一块一块地去探索。

首先，我们看看 Java 的词法分析器。

词法分析器也是构造了一个有限自动机吗？

通过跟踪执行，你会发现词法分析器的具体实现在 JavaTokenizer 类中。你可以先找到这个类，在 readToken() 方法里打个断点，让程序运行到这里，然后查看词法分析的执行过程。

在学词法分析的时候，你肯定知道要构造一个有限自动机，而且当输入的字符发生变化的时候，自动机的状态也会产生变化。

图 2：一个有限自动机，能够区分数字字面量（状态 1）和标识符（状态 2）

那么实战中，Java 做词法分析的逻辑是什么呢？你可以先研究一下 readToken() 方法，这个方法实现了主干的词法分析逻辑，它能够从字符流中识别出一个个的 Token 来。

readToken 的逻辑变成伪代码是这样的：

循环读取字符
  case 空白字符
    处理，并继续循环
  case 行结束符
    处理，并继续循环
  case A-Za-z$_
    调用scanIden()识别标识符和关键字，并结束循环
  case 0之后是X或x，或者1-9
    调用scanNumber()识别数字，并结束循环
  case , ; ( ) [ ]等字符
    返回代表这些符号的Token，并结束循环
  case isSpectial()，也就是% * + - | 等特殊字符
    调用scanOperator()识别操作符
  ...

如果画成有限自动机，大致是这样的：

图 3：Java 词法分析器的有限自动机

在第 2 讲中我提到过，关键字和标识符的规则是冲突的：

标识符的规则是以A-Za-z$_开头，后续字符可以是A-Za-z$_、数字和其他的合法字符；

关键字（比如 if）也符合标识符的规则，可以说是标识符的子集。

这种冲突是词法分析的一个技术点，因为不到最后你不知道读入的是一个关键字，还是一个普通的标识符。如果单纯按照有限自动机的算法去做词法分析，想要区分 int 关键字和其他标识符的话，你就会得到图 4 那样的一个有限自动机。

当输入的字符串是“int”的时候，它会进入状态 4。如果这个时候遇到结束字符，就会提取出 int 关键字。除此之外，“i”（状态 2）、“in”（状态 3）和“intA”（状态 5）都属于标识符。

图 4：能够处理 int 和标识符语法的有限自动机

但是关键字有很多，if、else、int、long、class…如果按照这个方式构造有限自动机，就会很啰嗦。那么 java 是怎么处理这个问题的呢？

Java 编译器的处理方式比较简单，分成了两步：首先把所有的关键字和标识符都作为标识符识别出来，然后再从里面把所有预定义的关键字挑出来。这比构造一个复杂的有限自动机实现起来更简单！

通过这样的代码分析，你可以发现：Java 的词法解析程序在主干上是遵循有限自动机的算法的，但在很多局部的地方，为了让词法分析的过程更简单高效，采用了手写的算法。

我建议你在 IDE 中，采用调试模式跟踪执行，看看每一步的执行结果，这样你能对 Java 词法分析的过程和结果有更直观的理解。另外，你还可以写一个程序，直接使用词法分析器做解析，并打印出一个个 Token。这会很有趣，你可以试试看！

接下来，我们进一步研究一下 Java 的语法分析器。

语法分析器采用的是什么算法？

跟所有的语法分析器一样，Java 的语法分析器会把词法分析器生成的 Token 流，生成一棵 AST。

下面的 AST 就是 MyClass.java 示例代码对应的 AST（其中的 JCXXX 节点都是实现了 com.sun.source.tree 中的接口，比如 JCBinary 实现了 BinaryTree 接口，而 JCLiteral 实现了 LiteralTree 接口）。

图 5：MyClass.java 对应的 AST

我想你应该知道，语法分析的算法分为自顶向下和自底向上两种：

以 LL 算法为代表的自顶向下的算法，比较直观、容易理解，但需要解决左递归问题；

以 LR 算法为代表的自底向上算法，能够避免左递归问题，但不那么直观，不太容易理解。

那么，Java 编译器用的是什么算法呢？

你可以打开 com.sun.tools.javac.parser.JavacParser 这个类看一下代码。比如，你首先查看一下 parseExpression() 方法（也就是解析一个表达式）。阅读代码，你会看到这样的调用层次：

图 6：解析表达式时的调用层次

我们以解析“2+3”这样一个表达式，来一层一层地理解下这个解析过程。

第 1 步，需要匹配一个 term。

term 是什么呢？其实，它就是赋值表达式，比如“a=2”或“b=3”等。算法里把这样一个匹配过程又分为两部分，赋值符号左边的部分是 term1，其他部分是 termRest。其中，term1 是必须匹配上的，termRest 是可选的。如果匹配上了 termRest，那么证明这是个赋值表达式；否则就只是左边部分，也就是 term1。

如果你比较敏感的话，那仅仅分析第一步，你差不多就能知道这是什么算法了。

另外，你可能还会对 Rest 这个单词特别敏感。你还记得我们在什么地方提到过 Rest 这个词汇吗？是的，在第 3 讲中，我把左递归改写成右递归的时候，那个右递归的部分，我们一般就叫做 XXXRest 或 XXXTail。

不过没关系，你可以先保留着疑问，我们继续往下看，来印证一下看法是不是对的。

第 2 步，匹配 term1。

term1 又是什么呢？term1 是一个三元表达式，比如 a > 3 ? 1 : 2。其中，比较操作符左边的部分是 term2，剩下的部分叫做 term1Rest。其中 term2 是必须匹配的，term1Rest 是可选的。

第 3 步，匹配 term2。

term2 代表了所有的二元表达式。它再次分为 term3 和 term2Rest 两部分，前者是必须匹配的，后者是可选的。

第 4 步，匹配 term3。

term3 往下我就不深究了，总之，是返回一个字面量 2。

第 5 步，匹配 term2Rest。

首先匹配“+”操作符；然后匹配一个 term3()，这里是返回一个字面量 3。

第 6 步，回到 term1() 方法，试图匹配 term1Rest，没有匹配上。

第 7 步，回到 term() 方法，试图匹配 termRest，也没有匹配上。

第 8 步，从 term() 方法返回一个代表“2+3”的 AST，如下图所示：

图 7：“2+3”对应的 AST

讲到这儿，我想问问你：你从这样的分析中，得到了什么信息？

第一，这是一个递归下降算法。因为它是通过逐级下降的方法来解析，从 term 到 term1、term2、term3，直到最后是字面量这样最基础的表达式。

在第 3 讲里我说过，递归下降算法是每个程序员都应该掌握的语法分析算法。你看，像 Java 这么成熟的语言，其实采用的也是递归下降算法。

第二，Java 采用了典型的消除左递归的算法。我带你回忆一下，对于：

add -> add + mul

这样的左递归的文法，它可以改成下面的非左递归文法：

add -> mul add'
add' -> + add' | ε

如果我再换一下表达方式，就会变成 Java 语法解释器里的代码逻辑：

term2 -> term3 term2Rest
term2Rest -> + term3 | ε

第三，Java 编译器对优先级和结合性的处理，值得深究。

首先看看优先级。我们通常是通过语法逐级嵌套的方式来表达优先级的。比如，按照下面的语法规则生成的 AST，乘法节点会在加法节点下面，因此先于加法节点计算，从而优先级更高。实际上，Java 做语法分析的时候，term1->term2->term3 的过程，也是优先级逐步提高的过程。

add -> mul add'
add' -> + mul add' | ε
mul -> pri mul'
mul' -> * pri mul' | ε

可是，在 term2 中，实际上它解析了所有的二元表达式，在语法规则上，它把使用“&&”“ >”“+”“*” 这些不同优先级的操作符的表达式，都同等看待了。

term2 -> term3 term2Rest
term2Rest -> (&& | > | + | * |...) term3 | ε

不过，这里面包含了多个优先级的运算符，却并没有拆成很多个级别，这是怎么实现的呢？

我们再来看看结合性。对于“2+3+4”这样一个表达式，我在第 3 讲，是把右递归调用转换成一个循环，让新建立的节点成为父节点，从而维护正确的结合性。

如果你阅读 term2Rest 的代码，就会发现它的处理逻辑跟第 3 讲是相同的，也就是说，它们都是用循环的方式，来处理连续加法或者连续乘法，并生成结合性正确的 AST。

不过，Java 编译器的算法更厉害。它不仅能用一个循环处理连续的加法和连续的乘法，对于“2+3*5”这样采用了多种不同优先级的操作符的表达式，也能通过一个循环就处理掉了，并且还保证了优先级的正确性。

在 term2Rest 中，可以使用多个优先级的操作符，从低到高的顺序如下：

 "||"
 "&&"
 "|"
 "^"
 "&"
 "==" | "!="
 "<" | ">" | "<=" | ">="
 "<<" | ">>" | ">>>"
 "+" | "-"
 "*" | "/" | "%"

如果按照常规的写法，我们处理上面 10 级优先级的操作符，需要写 10 级嵌套的结构。而 Java 用一级就解决了。这个秘密就在 term2Rest() 的实现中。我们以“2*3+4*5”为例分析一下。

term2Rest() 算法维护了一个操作数的栈（odStack）和操作符的栈（opStack），作为工作区。算法会根据 odStack、opStack 和后续操作符这三个信息，决定如何生成优先级正确的 AST。我把解析“2*3+4*5”时栈的变化，画成了一张图。

图 8：解析“2*3+4*5”的时候，odStack、opStack 和后续操作符的变化

在一步一步解析的过程中，当 opStack 的栈顶运算符的优先级大于等于后续运算符的优先级时，就会基于 odStack 栈顶的两个元素创建一棵二元表达式的子树，就像第 2 步那样。

反过来的话，栈顶运算符的优先级小于后续运算符的优先级（像第 4 步那样），就会继续把操作数和操作符入栈，而不是创建二元表达式。

这就可以保证，优先级高的操作符形成的子树，总会在最后的 AST 的下层，从而优先级更高。

再仔细研究一下这个算法，你会发现，它是借助一个工作区，自底向上地组装 AST。是不是觉得很眼熟？是不是想到了 LR 算法？没错，这就是一个简单 LR 算法。操作数栈和操作符栈是工作区，然后要向后预读一个运算符，决定是否做规约。只不过做规约的规则比较简单，依据相邻的操作符的优先级就可以了。

其实，这种处理表达式优先级的解析方法，有一个专有的名字，就叫做“运算符优先级解析器（Operator-Precedence Parser）”。Java 编译器用这一个算法处理了 10 个优先级的二元表达式的解析，同时又不用担心左递归问题，确实很棒！

课程小结

本节课，我带你揭秘了 Java 编译器的一角，我想强调这样几个重点。

第一，你要大致熟悉一下 Java 语言中与编译有关的模块、包和类。这样，在你需要的时候，可以通过编程来调用编译器的功能，在运行时动态编译 Java 程序，并动态加载运行。

第二，Java 的词法分析总体上是遵循有限自动机的原理，但也引入了不少的灵活性。比如，在处理标识符和关键字的词法规则重叠的问题上，是先都作为标识符识别出来，然后再把其中的关键词挑出来。

第三，Java 的语法分析总体上是自顶向下的递归下降算法。在解决左递归问题时，也采用了标准的改写文法的方法。但是，在处理二元表达式时，局部采用了自底向上的运算符优先级解析器，使得算法更简洁。

当然了，我没有覆盖所有的词法解析和语法解析的细节。但你按照今天这一讲的分析思路，完全能看懂其他部分的代码。通过我帮你开的这个头，我期待你继续钻研下去，搞清楚 Java 的词法和语法解析功能的每个细节。

比如，递归下降算法中最重要的是要减少试错次数，一下子就能精准地知道应该采用哪个产生式。而你通过阅读代码，会了解 Java 的编译器是如何解决这个问题的：它在一些语法上会预读一个 Token，在另外的语法上会预读两个、三个 Token，以及加上一些与上下文有关的代码，通过种种方式来减少回溯，提高编译性能。这，实际上就是采用了 LL(k) 算法的思路，而 k 值是根据需要来增加的。

通过今天的分析，你会发现 Java 编译器在做词法和语法分析的时候，总体上遵循了编译原理中的知识点，比如构造有限自动机、改写左递归文法等等，但又巧妙地引入了不少的变化，包括解决词法规则冲突、融合了自顶向下算法和自底向上算法、根据情况灵活地预读 1 到多个 Token 等。我相信对你会大有启发！像这样的实战知识，恐怕只有分析实际编译器才能获得！更进一步地，你以后也可以用这样漂亮的方法解决问题。这就是对你这次探险的奖励。

我把这一讲的知识点用思维导图整理出来了，供你参考：

一课一思

运算符优先级解析器非常实用，我们通过练习巩固一下对它的认识。你能推导一下解析“a>b*2+3”的时候，odStack、opStack 和后续运算符都是什么吗？你也可以跟踪 Java 编译器的执行过程，验证一下你的推导结果。

你可以在留言区交一下作业。比如像这样：

step1: a
step2: a,b  >  *     //用逗号分隔栈里的多个元素
...

我会在下一讲的留言区，通过置顶的方式公布标准答案。好了，这节课就到这里，感谢你的阅读，欢迎你把今天的内容分享给更多的朋友。

10 | Java编译器（二）：语法分析之后，还要做些什么？

你好，我是宫文学。

上一讲，我带你了解了 Java 语言编译器的词法分析和语法分析功能，这两项工作是每个编译器都必须要完成的。那么，根据第 1 讲我对编译过程的介绍，接下来就应该是语义分析和生成 IR 了。对于 javac 编译器来说，生成 IR，也就是字节码以后，编译器就完成任务了。也就是说，javac 编译器基本上都是在实现一些前端的功能。

不过，由于 Java 的语法特性很丰富，所以即使只是前端，它的编译功能也不少。那么，除了引用消解和类型检查这两项基础工作之外，你是否知道注解是在什么时候处理的呢？泛型呢？还有各种语法糖呢？

所以，今天这一讲，我就带你把 Java 编译器的总体编译过程了解一遍。然后，我会把重点放在语义分析中的引用消解、符号表的建立和注解的处理上。当你学完以后，你就能真正理解以下这些问题了：

符号表是教科书上提到的一种数据结构，但它在 Java 编译器里是如何实现的？编译器如何建立符号表？

引用消解会涉及到作用域，那么作用域在 Java 编译器里又是怎么实现的？

在编译期是如何通过注解的方式生成新程序的？

为了方便你理解 Java 编译器内部的一些对象结构，我画了一些类图（如果你不习惯看类图的话，可以参考下面的图表说明，比如我用方框表示一个类，用小圆圈表示一个接口，几种线条分别代表继承关系、引用关系和接口实现）。

图 1 ：课程中用到的类图的图表说明

在课程开始之前，我想提醒几点：建议你在一个良好的学习环境进入今天的学习，因为你需要一步步地，仔细地跟住我的脚步，避免在探索过程中迷路；除此之外，你的手边还需要一个电脑，这样随时可以查看我在文章中提到的源代码。

了解整个编译过程

现在，你可以打开 jdk.compiler 模块中的 com.sun.tools.javac.comp 包对应的源代码目录。

comp 应该是 Compile 的缩写。这里面有一个 com.sun.tools.javac.comp.CompileStates 类，它的意思是编译状态。其中有一个枚举类型 CompileState，里面列出了所有的编译阶段。

你会看到，词法和语法分析只占了一个环节（PARSE），生成字节码占了一个环节，而剩下的 8 个环节都可以看作是语义分析工作（建立符号表、处理注解、属性计算、数据流分析、泛型处理、模式匹配处理、Lambda 处理和去除其他语法糖）。

public enum CompileState {
    INIT(0),          //初始化
    PARSE(1),         //词法和语法分析
    ENTER(2),         //建立符号表
    PROCESS(3),       //处理注解
    ATTR(4),          //属性计算
    FLOW(5),          //数据流分析
    TRANSTYPES(6),    //去除语法糖：泛型处理
    TRANSPATTERNS(7), //去除语法糖：模式匹配处理
    UNLAMBDA(8),      //去除语法糖：LAMBDA处理(转换成方法)
    LOWER(9),         //去除语法糖：内部类、foreach循环、断言等。
    GENERATE(10);     //生成字节码
    ...
}

另外，你还可以打开 com.sun.tools.javac.main.JavaCompiler 的代码，看看它的 compile() 方法。去掉一些细节，你会发现这样的代码主干，从中能看出编译处理的步骤：

processAnnotations(                             //3：处理注解
  enterTrees(stopIfError(CompileState.PARSE,    //2：建立符号表
    initModules(stopIfError(CompileState.PARSE,
      parseFiles(sourceFileObjects))            //1：词法和语法分析
    ))
  ),classnames);

...
case SIMPLE:
  generate(                                       //10：生成字节码
    desugar(                                      //6~9：去除语法糖
      flow(                                       //5：数据流分析
        attribute(todo))));                       //4：属性计算

其中，PARSE 阶段的成果就是生成一个 AST，后续的语义分析阶段会基于它做进一步的处理：

enterTrees()：对应 ENTER，这个阶段的主要工作是建立符号表。

processAnnotations()：对应 PROCESS 阶段，它的工作是处理注解。

attribute()：对应 ATTR 阶段，这个阶段是做属性计算，我会在下一讲中给你做详细的介绍。

flow()：对应 FLOW 阶段，主要是做数据流分析。我在第 7 讲中就提到过数据流分析，那时候是用它来做代码优化。那么，难道在前端也需要做数据流分析吗？它会起到什么作用？这些问题的答案我也会在下一讲中为你揭晓。

desugar()：去除语法糖，其实这里包括了 TRANSTYPES（处理泛型）、TRANSPATTERNS（处理模式匹配）、UNLAMBDA（处理 Lambda）和 LOWER（处理其他所有的语法糖，比如内部类、foreach 循环等）四个阶段，我会在第 12 讲给你介绍。

generate()：生成字节码，对应了 GENERATE 阶段，这部分内容我也会在第 12 讲详细介绍。

在今天这一讲，我会给你介绍前两个阶段的工作：建立符号表和处理注解。

首先，我们来看看 Enter 阶段，也就是建立符号表的过程。

ENTER 阶段：建立符号表

Enter 阶段的主要代码在 com.sun.tools.javac.comp.Enter 类中。在这个阶段，会把程序中的各种符号加到符号表中。

建立符号表

在第 5 讲中，我已经介绍了符号表的概念。符号表是一种数据结构，它保存了程序中所有的定义信息，也就是你定义的每个标识符，不管是变量、类型，还是方法、参数，在符号表里都有一个条目。

那么，我们再深入看一下，什么是符号。

其实，符号代表了一门语言的基础构成元素。在 java.compiler 模块中定义了 Java 语言的构成元素（Element），包括模块、包、类型、可执行元素、变量元素等。这其中的每个元素，都是一种符号。

图 2：Java 语言的基本构成元素

而在 jdk.compiler 模块中，定义了这些元素的具体实现，也就是 Symbol，符号。

图 3：Symbol 及其子类

符号里记录了一些重要的属性信息，比如名称（name）、类型（type）、分类（kind）、所有者（owner）等，还有一些标记位，标志该符号是否是接口、是否是本地的、是否是私有的，等等，这些信息在语义分析和后续编译阶段都会使用。另外，不同的符号还有一些不同的属性信息，比如变量符号，会记录其常数值（constValue），这在常数折叠优化时会用到。

那么，Enter 过程是怎样发生的呢？你可以看一下 com.sun.tools.javac.comp.MemberEnter 类中的 visitVarDef() 方法。

实际上，当看到一个方法使用 visit 开头的时候，你应该马上意识到，这个方法是被用于一个 Visitor 模式的调用中。也就是说，Enter 过程是一个对 AST 的遍历过程，遍历的时候，会依次调用相应的 visit 方法。visitVarDef() 是用于处理变量声明的。

我们还以 MyClass 的编译为例来探索一下。MyClass 有一个成员变量 a，在 Enter 阶段，编译器就会为 a 建立符号。

我们来看看它的创建过程：

public class MyClass {
    public int a = 2+3;
    public int foo(){
        int b = a + 10;
        return b;
    }
}

我从 visitVarDef() 中挑出了最重要的三行代码，需要你着重关注。

...
//创建Symbol
VarSymbol v = new VarSymbol(0, tree.name, vartype, enclScope.owner);
...
tree.sym = v;       //关联到AST节点
...
enclScope.enter(v); //添加到Scope中
...

第一行，是创建 Symbol。

第二行，是把 Symbol 关联到对应的 AST 节点（这里是变量声明的节点 JCVaraibleDecl）。

你可以看一下各个 AST 节点的定义，其中的类、方法、变量声明、编译单元，以及标识符，都带有一个 sym 成员变量，用来关联到一个符号。这样后续在遍历树的时候，你就很容易查到这个节点对应的 Symbol 了。

不过你要注意，各种声明节点（类声明、方法声明等）对应的符号，是符号的定义。而标识符对应的 Symbol，是对符号的引用。找到每个标识符对应的定义，就是语义分析中的一项重要工作：引用消解。不过，引用消解不是在 Enter 阶段完成的，而是在 ATTR 阶段。

你在跟踪编译器运行的时候，可以在 JCClassDecl 等 AST 节点的 sym 变量上打个中断标记，这样你就会知道 sym 是什么时候被赋值的，从而也就了解了整个调用栈，这样会比较省事。

延伸一句：当你调试一个大的系统时，选择好恰当的断点很重要，会让你事半功倍。

图 4：一些重要的 AST 节点的属性和方法，其中多个 AST 节点中都有对 Symbol 的引用

最后来看一下第三行代码，这行代码是把 Symbol 添加到 Scope 中。

什么是 Scope？Scope 就是作用域。也就是说，在 Enter 过程中，作用域也被识别了出来，每个符号都是保存在相应的作用域中的。

在第 4 讲，我们曾经说过，符号表可以采用与作用域同构的带层次的表格。Java 编译器就是这么实现的。符号被直接保存进了它所在的词法作用域。

在具体实现上，Java 的作用域所涉及的类比较多，我给你整理了一个类图，你可以参考一下：

图 5：与 Scope 有关的类

其中有几个关键的类和接口，需要给你介绍一下。

首先是 com.sun.tools.javac.code.Scope$ScopeImpl 类：这是真正用来存放 Symbol 的容器类。通过 next 属性来指向上一级作用域，形成嵌套的树状结构。

图 6：AST 中的作用域

但是，在处理 AST 时，如何找到当前的作用域呢？这就需要一个辅助类：Env< AttrContext>。Env 的意思是环境，用来保存编译过程中的一些上下文信息，其中就有当前节点所处的作用域（Env.info.scope）。下图展示的是在编译过程中，所使用的 Env 的情况，这些 Env 也构成了一个树状结构。

图 7：Env< AttrContext> 构成的树状结构

然后是 com.sun.source.tree.Scope 接口：这是对作用域的一个抽象，可以获取当前作用域中的元素、上一级作用域、上一级方法以及上一级类。

好了，这就是与符号表有关的数据结构，后续的很多处理工作都要借助这个数据结构。比如，你可以思考一下，如何基于作用域来做引用消解？在下一讲，我会给你揭晓这个问题的答案。

两阶段的处理过程

前面讨论的是符号表的数据结构，以及建立符号表的大致过程。接下来，我们继续深究一下建立符号表算法的一个重要特点：Enter 过程是分两个阶段完成的。

你可以打开 Enter 类，看看 Enter 类的头注释，里面对这两个阶段做了说明。

第一个阶段：只是扫描所有的类（包括内部类），建立类的符号，并添加到作用域中。但是每个类定义的细节并没有确定，包括类所实现的接口、它的父类，以及所使用的类型参数。类的内部细节也没有去扫描，包括其成员变量、方法，以及方法的内部实现。

第二个阶段：确定一个类所缺失的所有细节信息，并加入到符号表中。

这两个阶段，第一个阶段做整个程序的扫描，把所有的类都识别出来。而第二个阶段是在需要的时候才进行处理的。

这里的问题是：为什么需要两个阶段？只用一个阶段不可以吗？

我借一个例子给你解释一下原因。你看看下面这段示例代码，在 Enter 过程中，编译器遍历了 MyClass1 的 AST 节点（JCClassDecl），并建立了一个 ClassSymbol。但在遍历到它的成员变量 a 的时候，会发现它不认识 a 的类型 MyClass2，因为 MyClass2 的声明是在后面的。

public class MyClass1{
   MyClass2 a;
}
class MyClass2{
}

怎么办呢？我们只好分成两个阶段去完成扫描。在第一个阶段，我们为 MyClass1 和 MyClass2 都建立符号，并且都放到符号表中；第二阶段，我们再去进一步扫描 MyClass1 的内部成员的时候，就能为成员变量 a 标注正确的类型，也就是 MyClass2。

我在第 4 讲中说过，语义分析的特点是上下文相关的。通过对比，你会发现，处理上下文相关情况和上下文无关情况的算法，它们是不一样的。

语法解析算法处理的是上下文无关的情况，因此无论自顶向下还是自底向上，整个算法其实是线性执行的，它会不断地消化掉 Token，最后产生 AST。对于上下文相关的情况，算法就要复杂一些。对 AST 各个节点的处理，会出现相互依赖的情况，并且经常会出现环形依赖，因为两个类互相引用在 Java 语言里是很常见的。加上这些依赖关系以后，AST 就变成了一张图。

而语义分析算法，实质上就是对图的遍历算法。我们知道，图的遍历算法的复杂度是比较高的。编译器一般要采用一定的启发式（Heuristic）的算法，人为地找出代价较低的遍历方式。Java 编译器里也采用了启发式的算法，我们尽量把对图的遍历简化为对树的遍历，这样工作起来就会简单得多。

对 AST 的遍历采用了 Visitor 模式。下图中我列出了一些采用 Visitor 模式对 AST 进行处理的程序。Enter 程序是其中的一个。

图 8：对 AST 进行处理的 Visitor 模式的部分程序

所以，语义分析就是由各种对 AST 进行遍历的算法构成的。在跟踪 Java 编译器执行的过程中，你还会发现多个处理阶段之间经常发生交错。比如，对于方法体中声明的局部变量，它的符号不是在 ENTER 阶段创建的，而是在 ATTR 阶段又回过头来调用了与建立符号表有关的方法。你可以先想想这又是什么道理。这里留下一个伏笔，我会在下一讲中给你解答。

系统符号表

前面说的符号表，保存了用户编写的程序中的符号。可是，还有一些符号，是系统级的，可以在不同的程序之间共享，比如原始数据类型、java.lang.Object 和 java.lang.String 等基础对象、缺省的模块名称、顶层的包名称等。

Java 编译器在 Symtab 类中保存这些系统级的符号。系统符号表在编译的早期就被初始化好，并用于后面的编译过程中。

以上就是 ENTER 阶段的所有内容。接着，编译器就会进入下一个阶段：PROCESS 阶段，也就是处理注解。

PROCESS 阶段：处理注解

注解是 Java 语言中的一个重要特性，它是 Java 元编程能力的一个重要组成部分。所谓元编程，简单地说，就是用程序生成或修改程序的能力。

Java 的注解需要在编译期被解析出来。在 Java 编译器中，注解被看作是符号的元数据，所以你可以看一下 SymbolMetadata 类，它记录了附加在某个符号上的各种注解信息。

然后呢，编译器可以在三个时机使用这些注解：一是在编译时，二是在类加载时，三是在类运行时。

对于后两者，编译器需要做的工作比较简单，把注解内容解析出来，放到 class 文件中。这样的话，PROCESS 阶段不需要做什么额外的工作。

而有些注解是要在编译期就处理的，这些注解最后就没必要保存到 class 文件。因为它们的使命在编译阶段就完成了。

那在编译阶段会利用注解做什么呢？最主要的用法，是根据注解动态生成程序，并且也被编译器编译。在后面探索 Java 的 JIT 编译器时，你会看到采用这种思路来生成程序的实例。你可以用简单的注解，就让注解处理程序生成很长的、充满“刻板代码”的程序。

我写了一个非常简单的示例程序，来测试 Java 编译器处理注解的功能。该注解叫做 HelloWorld：

@Retention(RetentionPolicy.SOURCE)  //注解用于编译期处理
@Target(ElementType.TYPE)           //注解是针对类型的
public @interface HelloWorld {
}

针对这个注解，需要写一个注解处理程序。当编译器在处理该注解的时候，就会调用相应的注解处理程序。你可以看一下 HelloWorldProcessor.java 程序。它里面的主要逻辑是获取被注解的类的名称，比如说叫 Foo，然后生成一个 HelloFoo.java 的程序。这个程序里有一个 sayHello() 方法，能够打印出“Hello Foo”。如果被注解的类是 Bar，那就生成一个 HelloBar.java，并且打印“Hello Bar”。

我们看一下 Foo 的代码。你注意，这里面有一个很有意思的现象：在 Foo 里调用了 HelloFoo，但 HelloFoo 其实当前并没有生成！

@HelloWorld
public class Foo {
    //HelloFoo类是处理完注解后才生成的。
    static HelloFoo helloFoo = new HelloFoo();
    public static void main(String args[]){
        helloFoo.sayHello();
    }
}

你可以在命令行编译这三个程序。其中编译 Foo 的时候，要用 -processor 选项指定所采用的注解处理器。

javac HelloWorld.java
javac HelloWorldProcessor.java
javac -processor HelloWorldProcessor Foo.java

在这个编译过程中，你会发现当前目录下生成了 HelloFoo.java 文件，并且在编译 Foo.java 之前就被编译了，这样在 Foo 里才能调用 HelloFoo 的方法。

你可以在 IDE 里跟踪一下编译器对注解的处理过程。借此，你也可以注意一下编译器是如何管理编译顺序的，因为 HelloFoo 一定要在 Foo 之前被编译。

扩展：Debug 对注解的处理过程需要有一定的技巧，请参考我为你整理的配置指南。

你会发现，在 Enter 之后，声明 helloFoo 这个成员变量的语句的 vartype 节点的类型是 ErrorType，证明这个时候编译器是没有找到 HelloFoo 的定义的。

图 9：在处理注解之前，还没有生成 HelloFoo

不过，在编译器处理完毕注解以后，HelloFoo 就会被生成，Foo 类的 ENTER 过程还会重走一遍，这个时候相关类型信息就正确了。

课程小结

好了，本讲我们首先对 Java 的编译过程做了一个顶层的介绍，然后分析了 ENTER 和 PROCESS 阶段所做的工作。希望你能有以下收获：

对前端编译过程可以有更加细致的了解，特别是对语义分析阶段，会划分成多个小阶段。由于语法分析的本质是对图做处理，所以实际执行过程不是简单地逐个阶段顺序执行，而是经常交织在一起，特别是 ENTER 阶段和 ATTR 阶段经常互相交错。

ENTER 阶段建立了符号表这样一个重要的数据结构。我们现在知道 Java 的符号表是按照作用域的结构建立的，而 AST 的每个节点都会对应某个作用域。

PROCESSOR 阶段完成了对注解的处理。你可以在代码里引用将要生成的类，做完注解处理后，这些类会被生成并编译，从而使得原来的程序能够找到正确的符号，不会报编译错误。

在最后，为了帮你将今天的内容做一个梳理，我提供一张思维导图，供你参考，整理知识：

一课一思

在 Java 语言中，对于下面的示例代码，会产生几个作用域，你觉得它们分别是什么？

public class ScopeTest{
    public int foo(int a){
        if(a>0){
          //一些代码
        }
        else{
          //另一些代码
        }
    }
}

欢迎在留言区分享你的答案，如果觉得有收获，也欢迎你把今天的内容分享给更多的朋友。

参考资料

关于注解的官方教程，你可以参考这个链接。

扩展知识

Java 编译器的功能很多。如果你有精力，还可以探索一些有趣的细节功能，比如：你知道 Java 在编译阶段，会自动生成缺省的构造函数吗？

ENTER 的第二个阶段任务是由 TypeEnter 类来完成的，你可以查看一下这个类的说明。它内部划分成了 4 个小的阶段，每个阶段完成一个更小一点的任务。其中的 MemberPhase 阶段，会把类的成员都建立符号，但 MemberPhase 还会做一件有趣的事情，就是生成缺省的构造函数。

为什么说这个细节很有趣呢？因为这是你第一次遇到在语义分析的过程中，还要对 AST 做修改。下面我们看看这个过程。

首先，你需要重新回顾一下缺省构造函数的意思。

在编写 Java 程序时，你可以不用写构造函数。对于下面这个 MyClass5 类，我们没有写构造函数，也能正常地实例化：

public class MyClass5{
}

但在语义分析阶段，实际上编译器会修改 AST，插入一个缺省构造函数（相当于下面的代码）。缺省的构造函数不带参数，并且调用父类的一个不带参数构造方法（对于 MyClass5 类来说，父类是 java.lang.Object 类，“super()”引用的就是 Object 类的不带参数的构造方法）。

public class MyClass3{
    public MyClass3(){
        super();
    }
}

对应的 AST 如下，其中 JCMethodDecl 这棵子树，就是语义分析程序插入的。

图 10：生成缺省构造函数之后的 AST，以及关联的 Symbol

新插入的构造方法是以 JCMethodDecl 为根节点的一棵子树。对于这个 JCMethodDecl 节点，在属性标注做完以后，形成了下面的属性。

名称：<init>。

类型：()void，也就是没有参数，返回值为 void。

符号：生成了一个方法型的符号（sym 属性），它的名称是<init>，如果调用 sym.isConstructor() 方法，返回 true，也就是说，这个符号是一个构造方法。

在这个缺省构造方法里，调用了“super();”这样一个语句，让父类有机会去做初始化工作，进而也让父类的父类有机会去做初始化工作，依次类推。

“super()”语句的叶子节点是一个 JCIndent 节点，也就是标识符。这个标识符的名称是”super“，而符号（sym 属性），则通过变量引用消解，指向了 Object 类的构造方法。

最后，我们声明 MyClass5 类的时候，也并没有声明它继承自 Object。这个信息也是自动推断出来的，并且会在类节点（JCClassDecl）的 type 属性中标注清楚。在图中你可以看到，type.supertype_field 指向了 Object 这个类型。

除了在自动生成的缺省构造函数里会调用 super()，你还知道，当我们手写一个构造函数的时候，也可以在第一句里调用父类的一个构造方法（并且必须是在第一句）。

public class MyClass4 extends MyClass3{
    public MyClass4(int a){
        super(a);   //这句可以省略，编译器可以自动生成
        ...
    }
}

如果你不显式地调用 super()，编译器也会自动加入这样的一个调用，并生成相应的 AST。这个时候，父类和子类的构造方法的参数也必须一致。也就是说，如果子类的构造方法的签名是（int, String），那么父类也必须具备相同签名的一个构造方法，否则没有办法自动生成对父类构造方法的调用语句，编译器就会报错。我相信你很可能在编程时遇到过这种编译信息，不过现在你应该就能清晰地了解，为什么编译器会报这些类型的编译错误了。

总体来说，Java 的编译器会根据需要加入一些 AST 节点，实现一些缺省的功能。其中包括缺省的构造方法、对父类构造方法的缺省调用，以及缺省的父类（Object）。

11 | Java编译器（三）：属性分析和数据流分析

你好，我是宫文学。

在上一讲，我们主要讨论了语义分析中的 ENTER 和 PROCESS 阶段。今天我们继续往下探索，看看 ATTR 和 FLOW 两个阶段。

ATTR 的字面意思是做属性计算。在第 4 讲中，我已经讲过了属性计算的概念，你应该还记得什么是 S 属性，什么是 I 属性。那么，Java 编译器会计算哪些属性，又会如何计算呢？

FLOW 的字面意思是做数据流分析。通过第 7 讲，你已经初步了解了数据流分析的算法。但那个时候是把数据流分析用于编译期后端的优化算法，包括删除公共子表达式、变量传播、死代码删除等。而这里说的数据流分析，属于编译器前端的工作。那么，前端的数据流分析会做什么工作呢？

这些问题的答案，我今天都会为你一一揭晓。好了，我们进入正题，首先来看看 ATTR 阶段的工作：属性分析。

ATTR：属性分析

现在，你可以打开 com.sun.tools.javac.comp.Attr 类的代码。在这个类的头注释里，你会发现原来 ATTR 做了四件事，分别在 4 个辅助类里实现：

Check：做类型检查。

Resolve：做名称的消解，也就是对于程序中出现的变量和方法，关联到其定义。

ConstFold：常量折叠，比如对于“2+3”这种在编译期就可以计算出结果的表达式，就直接计算出来。

Infer：用于泛型中的类型参数推导。

我们首先来看 Check，也就是类型检查。

类型检查

类型检查是语义分析阶段的一项重要工作。静态类型系统的语言，比如 Java、C、Kotlin、Swift，都可以通过类型检查，避免很多编译错误。

那么，一个基础的问题是：Java 都有哪些类型？

你是不是会觉得这个问题挺幼稚？Java 的类型，不就是原始数据类型，再加上类、接口这些吗？

说得对，但是并不全面。你已经看到，Java 编译器中每个 AST 节点都有一个 type 属性。那么，一个模块或者一个包的类型是什么？一个方法的类型又是什么呢？

在 java.compile 模块中，定义了 Java 的语言模型，其中有一个包，是对 Java 的类型体系做了设计，你可以看一下：

图 1：Java 的类型体系

这样你就能理解了：原来模块和包的类型是 NoType，而方法的类型是可执行类型（ExecutableType）。你可以看一下源代码，会发现要刻画一个可执行类型是比较复杂的，竟然需要 5 个要素：

returnType：返回值类型；

parameterTypes：参数类型的列表；

receiverType：接收者类型，也就是这个方法是定义在哪个类型（类、接口、枚举）上的；

thrownTypes：所抛出异常的类型列表；

typeVariables：类型参数的列表。

如果你学过 C 语言，你应该记得描述一个函数的类型只需要这个列表中的前两项，也就是返回值类型和参数类型就可以了。通过这样的对比，想必你会对 Java 的可执行类型理解得更清楚。

然而，通过一个接口体系来刻画类型还是不够细致，Java 又提供了一个 TypeKind 的枚举类型，把某些类型做进一步的细化，比如原始数据类型进一步细分为 BOOLEAN、BYTE、SHORT 等。这种设计方式可以减少接口的数量，使类型体系更简洁。你也可以在编程中借鉴这种设计方式，避免产生过多的、没有什么实际意义的子类型。

同样，在 jdk.compiler 模块中，有一些具体的类实现了上述类型体系的接口：

图 2：类型体系的实现

好了，现在你已经了解了 Java 的类型体系。那么，编译器是如何实现类型检查的呢？

我用一个 Java 程序的例子，来给你做类型检查的说明。在下面这段代码中，变量 a 的声明语句是错误的，因为等号右边是一个字符串字面量“Hello”，类型是 java.lang.String，跟变量声明语句的类型“int”不相符。在做类型检查的时候，编译器应该检查出这个错误来。

而后面那句“float b = 10”，虽然变量 b 是 float 型的，而等号右边是一个整型的字面量，但 Java 能够自动把整型字面量转化为浮点型，所以这个语句是合法的。

public class TypeCheck{
    int a = "Hello"; //等号两边的类型不兼容，编译报错
    float b = 10;    //整型字面量可以赋值给浮点型变量
}

对于“int a = "hello"”这个语句，它的类型检查过程分了四步，如下图所示：

图 3：类型检查的过程

第 1 步，计算 vartype 子节点的类型。这一步是在把 a 加入符号表的时候（MemberEnter）就顺便一起做了（调用的是“Attr.attribType() 方法”）。计算结果是 int 型。

第 2 步，在 ATTR 阶段正式启动以后，深度优先地遍历整棵 AST，自底向上计算每个节点的类型。自底向上是 S 属性的计算方式。你可以看一下 Attr 类中的各种 attribXXX() 方法，大多数都是要返回一个类型值，也就是处理完当前子树后的类型。这个时候，能够知道 init 部分的类型是字符串型（java.lang.String）。

第 3 步，检查 init 部分的类型是否正确。这个时候，比对的就是 vartype 和 init 这两棵子树的类型。具体实现是在 Check 类的 checkType() 方法，这个方法要用到下面这两个参数。

final Type found：“发现”的类型，也就是“Hello”字面量的类型，这里的值是 java.lang.String。这个是自底向上计算出来的，属于 S 属性。

final Type req：“需要”的类型，这里的值是 int。也就是说，a 这个变量需要初始化部分的类型是 int 型的。这个变量是自顶向下传递下来的，属于 I 属性。

所以你能看出，所谓的类型检查，就是所需类型（I 属性）和实际类型（S 属性）的比对。

这个时候，你就会发现类型不匹配，从而记录下错误信息。

下面是在做类型检查时整个的调用栈：

JavaCompiler.compile()
->JavaCompiler.attribute()
->Attr.attib()
->Attr.attribClass()      //计算TypeCheck的属性
->Attr.attribClassBody()
->Attr.attribStat()       //int a = "Hello";
->Attr.attribTree()       //遍历声明成员变量a的AST
->Attr.visitVarDef()      //访问变量声明节点
->Attr.attribExpr(TCTree,Env,Type)//计算"Hello"的属性,并传入vartype的类型
->Attr.attribTree()       //遍历"Hello"AST，所需类型信息在ResultInfo中
->Attr.visitLiteral()     //访问字面量节点，所需类型信息在resultInfo中
->Attr.check()            //把节点的类型跟原型类型(需要的类型)做比对
->Check.checkType()       //检查跟预期的类型是否一致

第 4 步：继续自底向上计算类型属性。这个时候会把变量声明语句 JCVariableDecl 的类型设置为 vartype 的类型。

上面是对变量 a 的声明语句的检查过程。对于“float b = 10”的检查过程也类似，但整型是允许赋值给浮点型的，所以编译器不会报错。

说完了类型检查，我们继续看一下 Resolve，也就是引用的消解。

引用消解

在第 5 讲中，我就介绍过了引用消解的概念。给你举个例子，当我们在程序中用到一个变量的时候，必须知道它确切的定义在哪里。比如下面代码中，第 4 行和第 6 行都用到了一个变量 a，但它们指的不是同一个变量。第 4 行的 a 是类的成员变量，第 6 行的 a 是 foo() 函数中的本地变量。

public class RefResolve extends RefResolveParent {
    int a = 2;
    void foo(int d){
        int b = a + f;   //这里的a是RefResolve的成员变量
        int a = 3;       //本地变量a,覆盖了类的成员变量a
        int c = a + 10;  //这里的a是前一句中声明的本地变量
    }
}

class RefResolveParent{
    int f = 4;          //父类中的成员变量
}

在编译器中，这两行中的 a 变量，都对应一个标识符（JCIdent）节点，也都会关联一个 Symbol 对象。但这两个 Symbol 对象不是同一个。第 4 行的 a 指的是类的成员变量，而第 6 行的 a 指的是本地变量。

所以，具体到 Java 编译器，引用消解实际上就是把标识符的 AST 节点关联到正确的 Symbol 的过程。

引用消解不仅仅针对变量，还针对类型、包名称等各种用到标识符的地方。如果你写了“System.out.println()”这样一个语句，就要引用正确的包符号。

你可以打开 com.sun.tools.javac.comp.Resolve 类的 findIdentInternal 方法，能看到对几种不同的符号做引用消解的入口。

...

if (kind.contains(KindSelector.VAL)) {   //变量消解
    sym = findVar(env, name);
    ...
}

if (kind.contains(KindSelector.TYP)) {   //类型消解
    sym = findType(env, name);
    ...
}

if (kind.contains(KindSelector.PCK))     //包名称消解
    return lookupPackage(env, name);
...

引用消解的实现思路也很清晰。在上一讲，你知道编译器在 Enter 阶段已经建立了作用域的嵌套结构。那么在这里，编译器只需要沿着这个嵌套结构逐级查找就行了。

比如，对于“int b = a + f”这个变量声明语句，在查找变量 a 时，沿着 Scope 的嵌套关系往上查找两级就行。但对于变量 f，还需要沿着类的继承关系，在符号表里找到父类（或接口），从中查找有没有名称为 f 的成员变量。

图 4：引用消解的实现

不过，这里还有一个细节需要深究一下。还记得我在前一讲留了一个问题吗？这个问题是：对于方法体中的本地变量，不是在 ENTER 阶段创建符号，而是在 ATTR 阶段。具体来说，就是在 ATTR 的 Resolve 环节。这是为什么呢？为什么不在 ENTER 环节把所有的符号都识别出来，并且加到作用域中就行了？

我来解答一下这个问题。我们把 RefResolve 类中的“int a = 2;”这行注释掉，会发生什么事情呢？foo() 函数的第一行“int b = a + f”应该报错，因为找不到 a 的定义。

public class RefResolve extends RefResolveParent{
    //int a = 2;          //把这行注释掉
    void foo(int d){
        int b = a + f;   //这里找不到a，应该报错
        int a = 3;        //本地变量a,覆盖了类的成员变量a
        int c = a + 10;   //这里的a是前一句中声明的本地变量
    }
}

但是，如果编译器在 ENTER 阶段就把所有的符号建立起来了，那么会发生什么情况呢？foo() 的方法体所对应的 Scope 就会有一个符号 a。按照前面描述的逐级查找算法，它就会认为“int b = a + f”里的这个 a，就是本地变量 a。这当然是错误的。

所以，为了保证消解算法不出错，必须保证在做完“int b = a + f”这句的引用消解之后，才会启动下一句“int a = 3”的 ENTER 过程，把符号 a 添加的 foo() 方法体的作用域中。引用消解都处理完毕以后，符号表才会填充完整，如下图所示：

图 5：引用消解后，符号表中添加了本地变量

常数折叠

在 ATTR 阶段，还会做一项优化工作：Constant Fold，即常数折叠。

我们知道，优化工作通常是在编译器的后端去做的。但因为 javac 编译器只是个前端编译器，生成字节码就完成任务了。不过即使如此，也要保证字节码是比较优化的，减少解释执行的消耗。

因为常数折叠借助属性计算就可以实现，所以在 ATTR 阶段顺便就把这个优化做了。

Java 在什么情况下会做常数折叠呢？我们来看看下面这个例子。变量 a 和 b 分别是一个整型和字符串型的常数。这样的话，“c=b+a*3”中 c 的值，是可以在编译期就计算出来的。这要做两次常数折叠的计算，最后生成一个“Hello 6”的字符串常数。

public class ConstFold {
    public String foo(){
        final int a = 2;          //int类型的常数
        final String b = "Hello "; //String类型的常数
        String c = b + a * 3;     //发生两次折叠
        return c;
    }
}

触发上述常数折叠的代码，在 com.sun.tools.javac.comp.Attr 类的 visitBinary() 方法中，具体实现是在 com.sun.tools.javac.comp.ConstFold 类。它的计算逻辑是：针对每个 AST 节点的 type，可以通过 Type.constValue() 方法，看看它是否有常数值。如果二元表达式的两个子节点都有常数值，那么就可以做常数折叠，计算出的结果保存在父节点的 type 属性中。你可以看看下图。

图 6：AST 节点对应的常数值属性

扩展：你看了这个图，可能会有一个疑问：常数值为什么不是保存在 AST 节点中，而是保存在类型对象中？类型带上一个值是什么意思？常数值为 2 的整型和常数值为 3 的整型是不是一个类型？这是因为 Type 里保存的信息本来就比较杂。我们前面分析过，一个可执行类型（比如方法）里包含返回值、参数类型等各种信息。一个类型的元数据信息（通常指标注），也是存在 Type 里面的。所以，一个方法的类型信息，跟另一个方法的类型信息，是迥然不同的。在这里，不要把 Type 叫做“类型”，而是叫“类型信息”比较好。每个类型信息对象只针对某个 AST 节点，包含了该节点与类型有关的各种信息。因此，在这里面再多放一个常数值，也就无所谓了。

你能看出，常数折叠实质上是针对 AST 节点的常数值属性来做属性计算的。

推导类型参数

ATTR 阶段做的最后一项工作，也是跟类型相关，那就是对泛型中的类型参数做推导。

这是什么意思呢？在 Java 语言中，如果你前面声明了一个参数化类型的变量，那么在后面的初始化部分，你不带这个参数化类型也是可以的，编译器会自动推断出来。

比如下面这句：

List<String> lines = new ArrayList<String>();

你可以去掉初始化部分中的类型参数，只保留一对尖括号就行了：

List<String> lines = new ArrayList<>();

甚至更复杂的参数化类型，我们也可以这样简化：

Map<String, List<String>> myMap = new HashMap<String, List<String>>();
//简化为：
Map<String, List<String>> myMap = new HashMap<>();

你可以在 Infer.instantiateMethod() 方法中打个断点，观察一下泛型的推断。关于泛型这个主题，我会在“揭秘泛型编程的实现机制”这一讲，去展开讲一些关于类型计算的算法，这里就不详细展开了。

好了，到这里，你已经知道了属性分析所做的四项工作，它们分别针对了四个属性：

类型检查针对的是类型属性；

引用消解是针对标识符节点的符号（sym）属性，也就是要找到正确的符号定义；

常数折叠针对的是常数值属性；

类型参数的推导，针对的是类型参数属性。

所以，现在你就可以解答出学教科书时通常会遇到的一个疑问：属性计算到底是计算了哪些属性。我们用实战知识印证了理论 。

接下来我们看看编译器下一个阶段的工作：数据流分析。

FLOW：数据流分析

Java 编译器在 FLOW 阶段做了四种数据流分析：活跃性分析、异常分析、赋值分析和本地变量捕获分析。我以其中的活跃性分析方法为例，来给你做讲解，这样其他的几个分析方法，你就可以举一反三了。

首先，我们来看看活跃性分析方法对 return 语句的检测。

举个最简单的例子。下面这段代码里，foo 函数的返回值是 int，而函数体中，只有在 if 条件中存在一个 return 语句。这样，代码在 IDE 中就会报编译错误，提示缺少 return 语句。

public class NoReturn{
    public int foo(int a){  //在a<=0的情况下，不会执行return语句
        if (a> 0){
            return a;
        }
        /*
        else{
            return -a;
        }
        */
    }
}

想要检查是否缺少 return 语句，我们就要进行活跃性分析。活跃性分析的具体实现是在 Flow 的一个内部类 LiveAnalyzer 中。

在分析过程中，编译器用了一个 alive 变量来代表代码是否会执行到当前位置。打开 Flow$LiveAnalyzer 类，你会看到 visitMethodDef 中的部分代码，如下所示。如果方法体里有正确的 return 语句，那么扫描完方法体以后，alive 的取值是“DEAD”，也就是这之后不会再有可执行的代码了；否则就是“ALIVE”，这意味着 AST 中并不是所有的分支，都会以 return 结束。

public void visitMethodDef(JCMethodDecl tree) {
    ...
    alive = Liveness.ALIVE;  //设置为ALIVE
    scanStat(tree.body);     //扫描所有的语句

    //如果仍然是ALIVE，但返回值不是void，那么说明缺少Return语句
    if (alive == Liveness.ALIVE && !tree.sym.type.getReturnType().hasTag(VOID))
        log.error(TreeInfo.diagEndPos(tree.body), Errors.MissingRetStmt);
   ...
}

你可以看到下面的代码示例中，当递归下降地扫描到 if 语句的时候，只有同时存在 then 的部分和 else 的部分，并且两个分支的活跃性检查的结果都是“DEAD”，也就是两个分支都以 return 语句结束的时候，if 节点执行后 alive 就会变成“DEAD”，也就是后边的语句不会再被执行。除此之外，都是“ALIVE”，也就是 if 后边的语句有可能被执行。

public void visitIf(JCIf tree) {
    scan(tree.cond);         //扫描if语句的条件部分
    //扫描then部分。如果这里面有return语句，alive会变成DEAD
    scanStat(tree.thenpart);
    if (tree.elsepart != null) {
        Liveness aliveAfterThen = alive;
        alive = Liveness.ALIVE;
        scanStat(tree.elsepart);
        //只有then和else部分都有return语句，alive才会变成DEAD
        alive = alive.or(aliveAfterThen);
    } else {  //如果没有else部分，那么把alive重新置为ALIVE
        alive = Liveness.ALIVE;
    }
}

看代码还是比较抽象。我把数据流分析的逻辑用控制流图的方式表示出来，你看着会更直观。

图 7：活跃性分析

我们通过活跃性分析，可以学习到数据流分析框架的 5 个要素：

V：代表被分析的值，这里是 alive，代表了控制流是否会到达这里。

I：是 V 的初始值，这里的初始值是 LIVE；

D：指分析方向。这个例子里，是从上到下扫描基本块中的代码；而有些分析是从下往上的。

F：指转换函数，也就是遇到每个语句的时候，V 如何变化。这里是在遇到 return 语句的时候，把 alive 变为 DEAD。

Λ：meet 运算，也就是当控制流相交的时候，从多个值中计算出一个值。你看看下图，在没有 else 块的时候，两条控制流中 alive 的值是不同的，最后的取值是 LIVE。

图 8：当没有 else 块的时候，两条控制流中的 alive 值不同

在做 meet 运算的时候，会用到一个叫做半格的数学工具。你可以参考本讲末尾的链接。

好了，我借助活跃性分析给你简要地讲解了数据流分析框架，我们接着往下看。

再进一步，活跃性分析还可以检测不可到达的语句。

如果我们在 return 语句后面再加一些代码，那么这个时候，alive 已经变成“DEAD”，编译器就会报“语句不可达”的错误。

Java 编译器还能检测更复杂的语句不可达的情况。比如在下面的例子中，a 和 b 是两个 final 类型的本地变量，final 修饰词意味着这两个变量的值已经不会再改变。

public class Unreachable{
  public void foo(){
        final int a=1;
    final int b=2;
    while(a>b){ //a>b的值可以在编译期计算出来
      System.out.println("Inside while block");
    }
    System.out.println("Outside while block");
  }
}

这种情况下，在扫描 while 语句的时候，条件表达式“a>b”会被计算出来，是 false，这意味着 while 块内部的代码不会被执行。注意，在第 7 讲的优化算法中，这种优化叫做稀疏有条件的常数折叠。因为这里是用于编译器前端，所以只是报了编译错误。如果是在中后端做这种优化，就会直接把不可达的代码删除。

//Flow$AliveAnalyzer
public void visitWhileLoop(JCWhileLoop tree) {
    ListBuffer<PendingExit> prevPendingExits = pendingExits;
    pendingExits = new ListBuffer<>();
    scan(tree.cond);        //扫描条件
    alive = Liveness.from(!tree.cond.type.isFalse());  //如果条件值为false,那么alive为DEAD
    scanStat(tree.body);    //扫描while循环体
    alive = alive.or(resolveContinues(tree));
    alive = resolveBreaks(tree, prevPendingExits).or(
        !tree.cond.type.isTrue());
}

void scanStat(JCTree tree) { //扫描语句
    //如果在扫描语句的时候，alive是DEAD，那么该语句就不可到达了
    if (alive == Liveness.DEAD && tree != null) {
        log.error(tree.pos(), Errors.UnreachableStmt);
        if (!tree.hasTag(SKIP)) alive = Liveness.RECOVERY;
    }
    scan(tree);
}

还有一种代码不可达的情况，就是无限循环后面的代码。你可以思考一下，在上面的例子中，如果把 while 条件的“a>b”改成“a<b”，会发生什么情况呢？

编译器会扫描 while 里面有没有合适的 break 语句（通过 resolveBreaks() 方法）。如果找不到，就意味着这个循环永远不会结束，那么循环体后面的语句就永远不会到达，从而导致编译器报错。

除了活跃性分析，Flow 阶段还做了其他三项分析：异常分析、赋值分析和本地变量捕获分析。

为了方便你的学习，我把 Java 编译器用到的几个数据流分析方法整理了一下，放在下面的表格中：

这几种分析方法，我建议你可以做几个例子，跟踪代码并研究一下，会加深你对数据流分析的直观理解。

异常分析、赋值分析和本地变量捕获的思路，与活跃性分析类似，它们都是按照数据流分析框架来处理的。也就是说，对于每个分析方法，你都要注意识别出它的五大要素：值、初始值、转换规则、扫描方向，以及 meet 运算规则。

课程小结

今天这一讲，我们研究了 Java 编译过程中的属性分析和数据流分析两个阶段。

在属性分析阶段，你能够看到 Java 是如何做类型检查、引用消解、常量折叠和推导类型参数的，它们实际上是对类型（type）、符号（sym）、常量值（constValue）和类型参数这 4 类属性的处理工作。

我们也见识到了在编译器前端的数据流分析阶段，是如何使用数据流分析方法的。通过数据流分析，编译器能够做一些更加深入的语义检查，比如检查控制流是否都经过了 return 语句，以及是否有不可到达的代码、每个异常是否都被处理，变量在使用前是否肯定被赋值，等等。

总体来说，在 ATTR 和 FLOW 这两个阶段，编译器完成了主要的语义检查工作。如果你在设计一门语言的时候，遇到了如何做语义检查的问题，那你就可以参考一下这一讲的内容。

在最后，是本节课程知识点的思维导图，供你参考：

一课一思

数据流分析框架很重要，你可以借助实例对它熟悉起来。那么，你能针对赋值分析，把它的 5 个元素列出来吗？欢迎在留言区分享你的思考，我会在下一讲的留言区，通过置顶的方式公布标准答案。

如果你觉得有收获，欢迎你把今天的内容分享给你的朋友。

参考资料

关于数据流分析的理论性内容，可以参考龙书（Compilers Principles, Techniques and Tools）第二版的 9.2 和 9.3 节。你也可以参考《编译原理之美》 的第27、28讲，那里进行了比较直观的讲述。

关于半格这个数学工具，你可以参考龙书第二版的 9.3.1 部分，也同样可以参考《编译原理之美》的第 28 讲。

12 | Java编译器（四）：去除语法糖和生成字节码

你好，我是宫文学。今天是 Java 编译器的最后一讲，我们来探讨编译过程最后的两个步骤：去除语法糖和生成字节码。

其实今天要讲的这两个编译步骤，总体上都是为生成字节码服务的。在这一阶段，编译器首先会把语法糖对应的 AST，转换成更基础的语法对应的 AST，然后基于 AST 和符号表，来生成字节码。

从 AST 和符号表，到变成字节码，这可是一个很大的转变，就像把源代码转化成 AST 一样。那么，这个过程的实现思路是什么？有什么难点呢？

今天这一讲，我们就一起来解决以上这些问题，在这个过程中，你对 Java 编译器的认识会变得更加完整。

好了，我们首先来看看去除语法糖这一处理步骤。

去除语法糖（Syntactic Sugar）

Java 里面提供了很多的语法糖，比如泛型、Lambda、自动装箱、自动拆箱、foreach 循环、变长参数、内部类、枚举类、断言（assert），等等。

你可以这么理解语法糖：它就是提高我们编程便利性的一些语法设计。既然是提高便利性，那就意味着语法糖能做到的事情，用基础语法也能做到，只不过基础语法可能更啰嗦一点儿而已。

不过，我们最终还是要把语法糖还原成基础语法结构。比如，foreach 循环会被还原成更加基础的 for 循环。那么，问题来了，在编译过程中，究竟是如何去除语法糖的？基础语法和语法糖又有什么区别呢？

在第 10 讲中，我提到过，在 JDK14 中，去除语法糖涵盖了编译过程的四个小阶段。

TRANSTYPES：泛型处理，具体实现在 TransTypes 类中。

TRANSPATTERNS：处理模式匹配，具体实现在 TransPattern 类中。

UNLAMBDA：把 LAMBDA 转换成普通方法，具体实现在 LambdaToMethod 类中。

LOWER：其他所有的语法糖处理，如内部类、foreach 循环、断言等，具体实现在 Lower 类中。

以上去除语法糖的处理逻辑都是相似的，它们的本质都是对 AST 做修改和变换。所以，接下来我挑选了两个比较有代表性的语法糖，泛型和 foreach 循环，和你分析它们的处理过程。

首先是对泛型的处理。

Java 泛型的实现比较简单，LinkedList<String>和LinkedList对应的字节码其实是一样的。泛型信息<String>，只是用来在语义分析阶段做类型的检查。检查完之后，这些类型信息就会被去掉。

所以，Java 的泛型处理，就是把 AST 中与泛型有关的节点简单地删掉（相关的代码在 TransTypes 类中）。

对于“ List<String> names = new ArrayList<String>() ”这条语句，它对应的 AST 的变化过程如下，其中，橙色的节点就是被去掉的泛型。

图 1：对泛型的处理

然后，我们分析下对 foreach 循环的处理。

foreach 循环的意思是“遍历每一个成员”，它能够以更简洁的方式，遍历集合和数组等数据结构。在下面的示例代码中，foreach 循环和基础 for 循环这两种处理方式的结果是等价的，但你可以看到，foreach 循环会更加简洁。

public static void main(String args[]) {
    List<String> names = new ArrayList<String>();
    ...
    //foreach循环
    for (String name:names)
        System.out.println(name);

    //基础for循环
    for ( Iterator i = names.iterator(); i.hasNext(); ) {
        String name = (String)i.next();
        System.out.println(name);
    }
}

Java 编译器把 foreach 循环叫做增强 for 循环，对应的 AST 节点是 JCEnhancedForLoop。

针对上面的示例代码，我们来对比一下增强 for 循环的 AST 和去除语法糖之后的 AST，如下图所示：

图 2：foreach 循环被改造成普通的 for 循环

你可以通过反编译，来获得这些没有语法糖的代码，它跟示例代码中用到的基础 for 循环语句是一样的。

对 foreach 循环的处理，是在 Lower 类的 visitForeachLoop 方法中。

其实，你在阅读编译技术相关的文献时，应该经常会看到 Lower 这个词。它的意思是，让代码从对人更友好的状态，变换到对机器更友好的状态。比如说，语法糖对编程人员更友好，而基础的语句则相对更加靠近机器实现的一端，所以去除语法糖的过程是 Lower。除了去除语法糖，凡是把代码向着机器代码方向所做的变换，都可以叫做 Lower。以后你再见到 Lower 的时候，是不是就非常清楚它的意思了呢。

好了，通过对泛型和 foreach 循环的处理方式的探讨，现在你应该已经大致了解了去除语法糖的过程。总体来说，去除语法糖就是把 AST 做一些变换，让它变成更基础的语法要素，从而离生成字节码靠近了一步。

那么接下来，我们看看编译过程的最后一个环节：生成字节码。

生成字节码（Bytecode Generation）

一般来说，我们会有一个错觉，认为生成字节码比较难。

实际情况并非如此，因为通过前面的建立符号表、属性计算、数据流分析、去除语法糖的过程，我们已经得到了一棵标注了各种属性的 AST，以及保存了各种符号信息的符号表。最难的编译处理工作，在这几个阶段都已经完成了。

在第 8 讲中，我就介绍过目标代码的生成。其中比较难的工作，是指令选择、寄存器分配和指令排序。而这些难点工作，在生成字节码的过程中，基本上是不存在的。在少量情况下，编译器可能会需要做一点指令选择的工作，但也都非常简单，你在后面可以看到。

我们通过一个例子，来看看生成字节码的过程：

public class MyClass {
    public int foo(int a){
        return a + 3;
    }
}

这个例子中，foo 函数对应的字节码有四个指令：

public int foo(int);
  Code:
     0: iload_1     //把下标为1的本地变量(也就是参数a)入栈
     1: iconst_3    //把常数3入栈
     2: iadd        //执行加法操作
     3: ireturn     //返回

生成字节码，基本上就是对 AST 做深度优先的遍历，逻辑特别简单。我们在第 5 讲曾经介绍过栈机的运行原理，也提到过栈机的一个优点，就是生成目标代码的算法比较简单。

你可以看一下我画的示意图，里面有生成字节码的步骤：

图 3：生成字节码

第 1 步，把 a 的值入栈（iload_1）。

第 2 步，把字面量 3 入栈（iconst_3）。

第 3 步，生成加法运算指令（iadd）。这个操作会把前两个操作数出栈，把结果入栈。

第 4 步，生成 return 指令（ireturn）。

这里面有没有指令选择问题？有的，但是很简单。

首先，你注意一下 iconst_3 指令，这是把一个比较短的操作数压缩到了指令里面，这样就只需要生成一个字节码。如果你把 3 改成一个稍微大一点的数字，比如 7，那么它所生成的指令就要改成“bipush 7”，这样就需要生成两个字节的字节码，一个字节是指令，一个字节是操作数。但这个操作数不能超过“2^7-1”，也就是 127，因为一个字节只能表示 -128~127 之间的数据。

如果字面量值变成 128，那指令就要变成“sipush 128”，占据三个字节，表示往栈里压入一个 short 数据，其中操作数占据两个字节。

如果该常数超过了两个字节能表示的范围，比如“32768”，那就要改成另一个指令“ldc #2”，这是把常数放到常量池里，然后从常量池里加载。

这几个例子反映了由于字面量的长度不同，而选用了不同的指令。接着，我们再来看看数据类型对指令的影响。

前面例子中生成的这四个指令，全部都是针对整数做运算的。这是因为我们已经在语义分析阶段，计算出了各个 AST 节点的类型，它们都是整型。但如果是针对长整型或浮点型的计算，那么生成的字节码就会不一样。下面是针对单精度浮点型所生成的字节码。

第三，数据类型影响指令生成的另一个情况，是类型转换。

一方面，阅读字节码的规范，你会发现对 byte、short、int 这几种类型做运算的时候，使用的指令其实是一样的，都是以 i 开头的指令。比如，加载到栈机都是用 iload 指令，加法都是用 iadd 指令。

在示例代码中，我们把 foo 函数的参数 a 的类型改成 byte，生成的字节码与之前也完全一样，你可以自己去试一下。

public class MyClass {
    public int foo(byte a){
        return a + 3;
    }
}

另一方面，在 Java 里把整型和浮点型做混合运算的时候，编译器会自动把整型转化成浮点型。比如我们再把示例代码改成下面这样：

public class MyClass {
    public double foo(int a){
        return a + 3.0;
    }
}

这个时候，foo 函数对应的字节码如下，其中 i2d 指令就是把参数 a 从 int 型转换成 double 型：

OK，到这里，我已经总结了影响指令生成的一些因素，包括字面量的长度、数据类型等。你能体会到，这些指令选择的逻辑都是很简单的，基于当前 AST 节点的属性，编译器就可以做成正确的翻译了，所以它们基本上属于“直译”。而我们在第 8 讲中介绍指令选择算法的时候，遇到的问题通常是结合了多个 AST 节点生成一条指令，它的难度要高得多。所以在第 16 讲，讲解 Java 的 JIT 编译器生成目标代码的时候，我会带你去看看这种复杂的指令选择算法的实现方式。

现在你对生成字节码的基本原理搞清楚了以后，再来看 Java 编译器的具体实现，就容易多了。

生成字节码的程序入口在 com.sun.tools.javac.jvm.Gen 类中。这个类也是 AST 的一个 visitor。这个 visitor 把 AST 深度遍历一遍，字节码就生成完毕了。

在 com.sun.tools.javac.jvm 包中，有两个重要的辅助类。

第一个辅助类是 Item。包的内部定义了很多不同的 Item，代表了在字节码中可以操作的各种实体，比如本地变量（LocalItem）、字面量（ImmediateItem）、静态变量（StaticItem）、带索引的变量（IndexedItem，比如数组）、对象实例的变量和方法（MemberItem）、栈上的数据（StackItem）、赋值表达式（AssignItem），等等。

图 4：生成字节码过程中的辅助类 Item 及其子类

每种 Item 都支持一套标准的操作，能够帮助生成字节码。我们最常用的是 load()、store()、invoke()、coerce() 这四个。

load()：生成把这个 Item 加载到栈上的字节码。

我们刚才已经见到了两种 Item 的 load 操作，一个是本地变量 a 的（LocalItem），一个是立即数 3 的（ImmediateItem。在字节码和汇编代码里，如果一个指令的操作数是一个常数，就叫做立即数）。

你可以看一下 ImmediateItem 的 load() 方法，里面准确反映了我们前面分析的指令选择逻辑：根据字面量长度的不同，分别选择 iconst_X、bipush、sipush 和 ldc 指令。

Item load() {
    switch (typecode) {
    //对int、byte、short、char集中类型来说，生成的load指令是相同的。
    case INTcode: case BYTEcode: case SHORTcode: case CHARcode:
        int ival = numericValue().intValue();
        if (-1 <= ival && ival <= 5)
            code.emitop0(iconst_0 + ival); //iconst_X指令
        else if (Byte.MIN_VALUE <= ival && ival <= Byte.MAX_VALUE)
            code.emitop1(bipush, ival);    //bipush指令
        else if (Short.MIN_VALUE <= ival && ival <= Short.MAX_VALUE)
            code.emitop2(sipush, ival);    //sipush指令
        else
            ldc();                         //ldc指令
        break;
        ...
}

load() 方法的返回值，是一个 StackItem，代表加载到栈上的数据。

store()：生成从栈顶保存到该 Item 的字节码。

比如 LocalItem 的 store() 方法，能够把栈顶数据保存到本地变量。而 MemberItem 的 store() 方法，则会把栈顶数据保存到对象的成员变量中。

invoke() : 生成调用该 Item 代表的方法的字节码。

coerce()：强制类型转换。

我们之前讨论的类型转换功能，就是在 coerce() 方法里完成的。

第二个辅助类是 Code 类。它里面有各种emitXXX() 方法，会生成各种字节码的指令。

总结起来，字节码生成的总体框架如下面的类图所示：

Gen 类以 visitor 模式访问 AST，生成字节码；最后生成的字节码保存在 Symbol 的 code 属性中。

在生成字节码的过程中，编译器会针对不同的 AST 节点，生成不同的 Item，并调用 Item 的 load()、store()、invoke() 等方法，这些方法会进一步调用 Code 对象的 emitXXX() 方法，生成实际的字节码。

图 5：生成字节码过程中涉及的类

好了，这就是生成字节码的过程，你会发现它的思路是很清楚的。你可以写一些不同的测试代码，观察它生成的字节码，以及跟踪生成字节码的过程，很快你就能对各种字节码是如何生成的了然于胸了。

代码优化

到这里，我们把去除语法糖和生成字节码两部分的内容都讲完了。但是，在 Java 编译器里，还有一类工作是分散在编译的各个阶段当中的，它们也很重要，这就是代码优化的工作。

总的来说，Java 编译器不像后端编译器那样会做深度的优化。比如像下面的示例代码，“int b = a + 3”这行是无用的代码，用一个“死代码删除”的优化算法就可以去除掉。而在 Java 编译器里，这行代码照样会被翻译成字节码，做一些无用的计算。

int foo(){
  int a = 2;
  int b = a + 3;  //这行是死代码，可以优化掉
  return a;
}

不过，Java 编译器还是在编译过程中，顺便做了一些优化：

1.ATTR 阶段：常数折叠

在属性分析阶段做了常数折叠优化。这样，在生成字节码的时候，如果一个节点有常数值，那么就直接把该常数值写入字节码，这个节点之下的子树就都被忽略。

2.FLOW 阶段：不可达的代码

在 FLOW 阶段，通过活跃性分析，编译器会发现某些代码是不可达的。这个时候，Java 编译器不是悄悄地优化掉它们，而是会报编译错误，让程序员自己做调整。

3.LOWER 阶段：代数简化

在 LOWER 阶段的代码中，除了去除语法糖，你还能看到一些代数简化的行为。给你举个例子，在 Lower.visitBinary() 方法中，也就是处理二元操作的 AST 的时候，针对逻辑“或（OR）”和“与（AND）”运算，有一些优化代码。比如，针对“或”运算，如果左子树的值是 true，那么“或”运算对应的 AST 用左子树代替；而如果左子树是的值是 false，那么 AST 可以用右子树代替。

图 6：对 AST 做代数简化

4.GEN 阶段：代数简化和活跃性分析

在生成字节码的时候，也会做一些代数简化。比如在 Gen.visitBinary() 方法中，有跟 Lower.visitBinary() 类似的逻辑。而整个生成代码的过程，也有类似 FLOW 阶段的活跃性分析的逻辑，对于不可达的代码，就不再生成字节码。

看上去 GEN 阶段的优化算法是冗余的，跟前面的阶段重复了。但是这其实有一个好处，也就是可以把生成字节码的部分作为一个单独的库使用，不用依赖前序阶段是否做了某些优化。

总结起来，Java 编译器在多个阶段都有一点代码优化工作，但总体来看，代码优化是很不足的。真正的高强度的优化，还是要去看 Java 的 JIT 编译器。这些侧重于做优化的编译器，有时就会被叫做“优化编译器（Optimizing Compiler）”。

课程小结

今天，我带你分析了 Java 编译过程的最后两个步骤：去除语法糖和字节码生成。你需要记住以下几点：

语法糖是现代计算机语言中一个友好的特性。Java 语言很多语法上的升级，实际上都只是增加了一些语法糖而已。语法糖在 Java 编译过程中的去除语法糖环节会被还原成基础的语法。其实现机制，是对 AST 做修改和转换。

生成字节码是一个比较机械的过程，编译器只需要对 AST 进行深度优先的遍历即可。在这个过程中会用到前几个阶段形成的属性信息，特别是类型信息。

我把本讲的知识点整理成了思维导图，供你参考：

之所以我花了 4 讲去介绍 Java 编译器的核心机制，是因为像 Java 这样成熟的静态类型语言，它的编译器的实现思路有很多借鉴意义，比如词法分析和语法分析采用的算法、语义分析中多个阶段的划分和之间的相互关系、如何用各种方法检查语义错误、符号表的实现、语法糖和基础语法的关系，等等。当你把 Java 编译器的脉络看清楚以后，再去看其他静态类型语言的编译器的代码，就会发现其中有很多地方是共通的，你就能更快地熟悉起来。这样下来，你对静态语言编译器的前端，都会有个清晰的了解。

当然，只了解前端部分是不够的，Java 还有专注于中后端功能的编译器，也就是 JIT 编译器。我们这讲也已经说过了，前端编译器的优化功能是有限的。那么，如果想让 Java 代码高效运行，就要依靠 JIT 编译器的优化功能和生成机器码的功能了。在后面的四讲中，我会接着给你揭秘 Java 的 JIT 编译器。

一课一思

针对 Java 编译器这 4 讲的内容，我们来做一个综合的思考题。假设你现在要写一个简单的 DSL 引擎，比如让它能够处理一些自定义的公式，最后要生成字节码，你会如何让它最快地实现？是否可以复用 Java 编译器的功能？

欢迎你留言分享自己的观点。如果觉得有收获，也欢迎你把今天的内容分享给更多的朋友。

参考资料

Java 语言规范第六章：Java 虚拟机指令集。

13 | Java JIT编译器（一）：动手修改Graal编译器

你好，我是宫文学。

在前面的 4 讲当中，我们已经解析了 OpenJDK 中的 Java 编译器，它是把 Java 源代码编译成字节码，然后交给 JVM 运行。

用过 Java 的人都知道，在 JVM 中除了可以解释执行字节码以外，还可以通过即时编译（JIT）技术生成机器码来执行程序，这使得 Java 的性能很高，甚至跟 C++ 差不多。反之，如果不能达到很高的性能，一定会大大影响一门语言的流行。

但是，对很多同学来说，对于编译器中后端的了解，还是比较模糊的。比如说，你已经了解了中间代码、优化算法、指令选择等理论概念，那这些知识在实际的编译器中是如何落地的呢？

所以从今天开始，我会花 4 讲的时间，来带你了解 Java 的 JIT 编译器的组成部分和工作流程、它的 IR 的设计、一些重要的优化算法，以及生成目标代码的过程等知识点。在这个过程中，你还可以印证关于编译器中后端的一些知识点。

今天这一讲呢，我首先会带你理解 JIT 编译的基本原理；然后，我会带你进入 Graal 编译器的代码内部，一起去修改它、运行它、调试它，让你获得第一手的实践经验，消除你对 JIT 编译器的神秘感。

认识 Java 的 JIT 编译器

我们先来探究一下 JIT 编译器的原理。

在第 5 讲中，我讲过程序运行的原理：把一个指令指针指向一个内存地址，CPU 就可以读取其中的内容，并作为指令来执行。

所以，Java 后端的编译器只要生成机器码就行了。如果是在运行前一次性生成，就叫做提前编译（AOT）；如果是在运行时按需生成机器码，就叫做即时编译（JIT）。Java 以及基于 JVM 的语言，都受益于 JVM 的 JIT 编译器。

在 JDK 的源代码中，你能找到 src/hotspot 目录，这是 JVM 的运行时，它们都是用 C++ 编写的，其中就包括 JIT 编译器。标准 JDK 中的虚拟机呢，就叫做 HotSpot。

实际上，HotSpot 带了两个 JIT 编译器，一个叫做 C1，又叫做客户端编译器，它的编译速度快，但优化程度低。另一个叫做 C2，又叫做服务端编译器，它的编译速度比较慢，但优化程度更高。这两个编译器在实际的编译过程中，是被结合起来使用的。而字节码解释器，我们可以叫做是 C0，它的运行速度是最慢的。

在运行过程中，HotSpot 首先会用 C0 解释执行；接着，HotSpot 会用 C1 快速编译，生成机器码，从而让运行效率提升。而对于运行频率高的热点（HotSpot）代码，则用 C2 深化编译，得到运行效率更高的代码，这叫做分层编译（Tiered Compilation）。

图 1：分层编译

由于 C2 会做一些激进优化，比如说，它会根据程序运行的统计信息，认为某些程序分支根本不会被执行，从而根本不为这个分支生成代码。不过，有时做出这种激进优化的假设其实并不成立，那这个时候就要做一个逆优化（Deoptimization），退回到使用 C1 的代码，或退回到用解释器执行。

触发即时编译，需要检测热点代码。一般是以方法为单位，虚拟机会看看该方法的运行频次是否很高，如果运行特别频繁，那么就会被认定为是热点代码，从而就会被触发即时编译。甚至如果一个方法里，有一个循环块是热点代码（比如循环 1.5 万次以上），这个时候也会触发编译器去做即时编译，在这个方法还没运行完毕的时候，就被替换成了机器码的版本。由于这个时候，该方法的栈帧还在栈上，所以我们把这个技术叫做栈上替换（On-stack Replacement，OSR）。栈上替换的技术难点，在于让本地变量等数据无缝地迁移，让运行过程可以正确地衔接。

Graal：用 Java 编写的 JIT 编译器

如果想深入地研究 Java 所采用的 JIT 编译技术，我们必须去看它的源码。可是，对于大多数 Java 程序员来说，如果去阅读 C++ 编写的编译器代码，肯定会有些不适应。

一个好消息是，Oracle 公司推出了一个完全用 Java 语言编写的 JIT 编译器：Graal，并且也有开放源代码的社区版，你可以下载安装并使用。

用 Java 开发一款编译器的优点是很明显的。

首先，Java 是内存安全的，而 C++ 程序的很多 Bug 都与内存管理有关，比如可能不当地使用了指针之类的。

第二，与 Java 配套的各种工具（比如 IDE）更友好、更丰富。

第三，Java 的性能并不低，所以能够满足对编译速度的需求。

最后，用 Java 编译甚至还能节省内存的占用，因为 Java 采用的是动态内存管理技术，一些对象没用了，其内存就会被回收。而用 C++ 编写的话，可能会由于程序员的疏忽，导致一些内存没有被及时释放。

从 Java9 开始，你就可以用 Graal 来替换 JDK 中的 JIT 编译器。这里有一个 JVMCI（JVM Compiler Interface）接口标准，符合这个接口标准的 JIT 编译器，都可以被用于 JVM。

Oracle 公司还专门推出了一款 JVM，叫做 GraalVM。它除了用 Graal 作为即时编译器以外，还提供了一个很创新的功能：在一个虚拟机上支持多种语言，并且支持它们之间的互操作。你知道，传统的 JVM 上已经能够支持多种语言，比如 Scala、Clojure 等。而新的 GraalVM 会更进一步，它通过一个 Truffle 框架，可以支持 JavaScript、Ruby、R、Python 等需要解释执行的语言。

再进一步，它还通过一个 Sulong 框架支持 LLVM IR，从而支持那些能够生成 LLVM IR 的语言，如 C、C++、Rust 等。想想看，在 Java 的虚拟机上运行 C 语言，还是有点开脑洞的！

图 2：GraalVM 的架构

最后，GraalVM 还支持 AOT 编译，这就让 Java 可以编译成本地代码，让程序能更快地启动并投入高速运行。我听说最近的一些互联网公司，已经在用 Graal 做 AOT 编译，来生成本地镜像，提高应用的启动时间，从而能够更好地符合云原生技术的要求。

修改并运行 Graal

好，那接下来，我就带你一起动手修改一下 Graal 编译器，在这个过程中，你就能对 Graal 的程序结构熟悉起来，消除对它的陌生感，有助于后面深入探索其内部的实现机制。

在本课程中，我采用了 Graal 的 20.0.1 版本的源代码。你可以参考 Graal 中的文档来做编译工作。

首先，下载源代码（指定了代码的分支）：

git clone -b vm-20.0.1 https://github.com/oracle/graal.git

接着，下载 GraalVM 的构建工具 mx，它是用 Python2.7 编写的，你需要有正确的 Python 环境：

git clone https://github.com/graalvm/mx.git
export PATH=$PWD/mx:$PATH

你需要在自己的机器上设置好 JDK8 或 11 的环境。我这里是在 macOS 上，采用 JDK8。

export PATH="/Library/Java/JavaVirtualMachines/openjdk1.8.0_252-jvmci-20.1-b02-fastdebug/Contents/Home/bin:$PATH"
export JAVA_HOME=/Library/Java/JavaVirtualMachines/openjdk1.8.0_252-jvmci-20.1-b02-fastdebug/Contents/Home

好了，现在你就可以编译 Graal 了。你可以在 Graal 源代码的 compiler 子目录中，运行 mx build：

mx build

编译完毕以后，你可以写一个小小的测试程序，来测试 Graal 编译器的功能。

javac Foo.java    //编译Foo.java
mx vm Foo         //运行Foo.java，相当于执行java Foo

“mx vm”命令在第一次运行的时候，会打包出一个新的 GraalVM，它所需要的 HotSpot VM，是从 JDK 中拷贝过来的，然后它会把 Graal 编译器等其他模块也添加进去。

Foo.java 的源代码如下。在这个示例程序中，main 方法会无限次地调用 add 方法，所以 add 方法就成为了热点代码，这样会逼迫 JIT 编译器把 add 方法做即时编译。

public class Foo{
    public static void main(String args[]){
        int i = 0;
        while(true){
           if(i%1000==0){
                System.out.println(i);
                try{
                    Thread.sleep(100);  //暂停100ms
                }catch(Exception e){}
            }

            i++;
            add(i,i+1);
        }
    }

    public static int add(int x, int y){
        return x + y;
    }

由于我们现在已经有了 Graal 的源代码，所以我们可以在源代码中打印一点信息，来显示 JIT 是什么时候被触发的。

org.graalvm.compiler.hotspot.HotspotGraalCompiler.compileMethod() 方法，是即时编译功能的入口，你可以在里面添加一行输出功能，然后用“mx build”命令重新构建。

public CompilationRequestResult compileMethod(CompilationRequest request) {
    //打印被编译的方法名和字节码
    System.out.println("Begin to compile method: " + request.getMethod().getName() + "\nbytecode: " + java.util.Arrays.toString(request.getMethod().getCode()));

    return compileMethod(request, true, graalRuntime.getOptions());
}

你在 compiler 目录里，打出“mx ideinit”命令，就可以为 Eclipse、IntelliJ Idea 等编译器生成配置信息了。你可以参照文档来做好 IDE 的配置。

注意：我用 Eclipse 和 IntelliJ Idea 都试了一下。Idea 的使用体验更好一些。但用 mx ideinit 命令为 Idea 生成的配置文件，只是针对 JDK8 的，如果要改为 JDK11，还需要手工修改不少配置信息。

在使用 Idea 的时候，你要注意安装 python 插件，文档中建议的其他插件可装可不装。

在使用 Eclipse 时，我曾经发现有一些报错信息，是因为 IDE 不能理解一些注解。你如果也遇到了类似情况，稍微修改一下头注释就能正常使用了。

mx ideinit

然后，你可以运行下面的命令来执行示例程序：

mx vm \
  -XX:+UnlockExperimentalVMOptions \
  -XX:+EnableJVMCI \
  -XX:+UseJVMCICompiler \
  -XX:-TieredCompilation \
  -XX:CompileOnly=Foo.add \
Foo

你会看到，命令中包含了很多不同的参数，它们分别代表了不同的含义。

-XX:+UnlockExperimentalVMOptions：启用试验特性。

-XX:+EnableJVMCI：启用 JVMCI 功能。

-XX:+UseJVMCICompiler ：使用 JVMCI 编译器，也就是 Graal。

-XX:-TieredCompilation ：禁用分层编译。

-XX:CompileOnly=Foo.add：只编译 add 方法就行了。

当程序运行以后，根据打印的信息，你就能判断出 JIT 编译器是否真的被调用了。实际上，它是在 add 方法执行了 15000 次以后才被调用的。这个时候，JVM 会认为 add 方法是一个热点。因为 JIT 是在另一个线程启动执行的，所以输出信息要晚一点。

好了，通过这个实验，你就能直观地了解到，JVM 是如何判断热点并启动 JIT 机制的了。

另外，在这个实验中，你还可以通过“-XX:CompileThreshold”参数，来修改热点检测的门槛。比如说，你可以在“-XX:CompileThreshold=50”，也就是让 JVM 在被 add 方法执行了 50 次之后，就开始做即时编译。你还可以使用“-Xcomp”参数，让方法在第一次被调用的时候就开始做编译。不过这样编译的效果会差一些，因为让方法多运行一段时间再编译，JVM 会收集一些运行时的信息，这些信息会有助于更好地做代码优化。这也是 AOT 编译的效果有时会比 JIT 差的原因，因为 AOT 缺少了运行时的一些信息。

好了，接下来，我们再来看看 JIT 编译后的机器码是什么样子的。

JIT 所做的工作，本质上就是把字节码的 Byte 数组翻译成机器码的 Byte 数组，在翻译过程中，编译器要参考一些元数据信息（符号表等），再加上运行时收集的一些信息（用于帮助做优化）。

前面的这个示例程序，它在运行时就已经打印出了字节码：[26, 27, 96, -84]。如果我们转换成 16 进制，就是[1a, 1b, 60, ac]。它对应的字节码是：[iload_0, iload_1, iadd, ireturn]。

我们暂时忽略掉这中间的编译过程，先来看看 JIT 编译后生成的机器码。

Graal 编译完毕以后，是在 org.graalvm.compiler.hotspot.CompilationTask 的 performCompilation 方法中，把编译完毕的机器码安装到缓存区，用于后续执行。在这里，你可以加一点代码，打印编译后的结果。

...
installMethod(debug, result);   //result是编译结果
System.out.println("Machine code: " + java.util.Arrays.toString(result.getTargetCode()));
...

打印输出的机器码数组如下：

我们光看这些机器码数组，当然是看不出来有什么含义的，但 JDK 可以把机器码反编译成汇编码，然后打印输出，就会更方便被我们解读。这就需要一个反汇编工具 hsdis。

运行“mx hsdis”命令，你可以下载一个动态库（在 macOS 上是 hsdis-amd64.dylib，在 Linux 上以 so 结尾，在 Windows 上以 dll 结尾）。这个动态库会被拷贝到 JDK 的 lib 目录下，这样我们就可以通过命令行参数，让 JVM 输出编译生成的汇编码。

sudo -E mx hsdis #用sudo是为了有权限把动态库拷贝到系统的JDK的lib目录

注：由于我使用 mx 命令来运行示例程序，所以使用的 JDK 实际上是 GraalVM 从系统 JDK 中拷贝过来的版本，因此我需要手工把 hsdis.dylib 拷贝到 graal-vm-20.0.1/compiler/mxbuild/darwin-amd64/graaljdks/jdk11-cmp/lib 目录下。

mx vm \
  -XX:+UnlockExperimentalVMOptions \
  -XX:+EnableJVMCI \
  -XX:+UseJVMCICompiler \
  -XX:-TieredCompilation \
  -XX:+PrintCompilation \
  -XX:+UnlockDiagnosticVMOptions \
  -XX:+PrintAssembly \
  -XX:CompileOnly=Foo.add \
Foo

输出的汇编码信息如下：

我来解释一下这段汇编代码的含义：

好了，现在你已经能够直观地了解 JIT 启动的时机：检测出热点代码；以及它最后生成的结果：机器码。

但我们还想了解一下中间处理过程的细节，因为这样才能理解编译器的工作机制。所以这个时候，如果能够跟踪 Graal 的执行过程就好了，就像调试一个我们自己编写的程序那样。那么我们能做到吗？

当然是可以的。

跟踪 Graal 的运行

Graal 是用 Java 编写的，因此你也可以像调试普通程序一样调试它。你可以参考源代码中的这篇与调试有关的文档。

由于 Graal 是在 JVM 中运行的，所以你要用到 JVM 的远程调试模式。我们仍然要运行 Foo 示例程序，不过要加个“-d”参数，表示让 JVM 运行在调试状态下。

mx -d vm \
  -XX:+UnlockExperimentalVMOptions \
  -XX:+EnableJVMCI \
  -XX:+UseJVMCICompiler \
  -XX:-TieredCompilation \
  -XX:CompileOnly=Foo.add \
Foo

这个时候，在 JVM 启动起来之后，会在 8000 端口等待调试工具跟它连接。

你可以使用 Eclipse 或 Idea 做调试工具。我以 Eclipse 为例，在前面运行“mx ideinit”的时候，我就已经设置了一个远程调试的配置信息。

你可以打开“run>debug configurations…”菜单，在弹出的对话框中，选择 Remote Java Application，可以看到几个预制好的配置。

然后，点击“compiler-attach-localhost-8000”，你可以看到相关属性。其中，连接信息正是本机的 8000 端口。

把 Project 改成“org.graalvm.compiler.hotspot”，然后点击 Debug 按钮。

补充：如果你使用的是 Idea，你也会找到一个预制好的远程调试配置项：GraalDebug。直接点击就可以开始调试。

为了方便调试，我在 org.graalvm.compiler.hotspot.compileMethod() 方法中设置了断点，所以到了断点的时候，程序就会停下来，而不是一直运行到结束。

当你点击 Debug 按钮以后，Foo 程序会继续运行。在触发了 JIT 功能以后，JVM 会启动一个新线程来运行 Graal，而 Foo 则继续在主线程里运行。因为 Foo 一直不会结束，所以你可以从容地进行调试，不用担心由于主线程的退出，而导致运行 Graal 的线程也退出。

现在，你可以跟踪 Graal 的编译过程，看看能发现些什么。在这个过程中，你需要一点耐心，慢慢理解整个代码结构。

Graal 执行过程的主要结构如下图所示。

图 3：Graal 执行过程的主要结构

首先，你会发现，在编译开始的时候，Graal 编译器要把字节码转化成一个图的数据结构。而后续的编译过程，都是对这个图的处理。这说明了这个图很重要，而这个图就是 Graal 要用到的 IR，在 Graal 编译器中，它也被叫做 HIR。

接着，你会看到，整个编译过程被分成了前端和后端两个部分。前端部分使用的 IR 是 HIR。而且在前端部分，HIR 又分成了高（HighTier）、中（MidTier）、低（LowTier）三层。在每个层次里，都要执行很多遍（Phase）对图的处理。这些处理，指的就是各种的优化和处理算法。而从高到低过渡的过程，就是不断 Lower 的过程，也就是把 IR 中，较高抽象度的节点替换成了更靠近底层实现的节点。

在后端部分，则要生成一种新的 IR，也就是我们在第 6 讲中提到过的 LIR，并且 Graal 也要对它进行多遍处理。最后一步，就是生成目标代码。

下图中，我举了一个例子，列出了编译器在前端的三个层次以及在后端所做的优化和处理工作。

你要注意的是，在编译不同的方法时，所需要的优化工作也是不同的，具体执行的处理也就不一样。并且这些处理执行过程也不是线性执行的，而可能是一个处理程序调用了另一个处理程序，嵌套执行的。

图 4：一个例子，前端三个层次和后端所做的处理

不过通过跟踪 Graal 的运行过程，你可以留下一个直观的印象：Graal 编译器的核心工作，就是对图（IR）的一遍遍处理。

在下一讲中，我就会进一步讲述 Graal 的 IR，并会带你一起探讨优化算法的实现过程，你可以了解到一个真实编译器的 IR 是怎样设计的。

课程小结

今天这一讲，我带你大致了解了 Java 的 JIT 编译器。你需要重点关注以下几个核心要点：

JIT 可能会用到多个编译器，有的编译速度快但不够优化（比如 C1，客户端编译器），有的够优化但编译速度慢（比如 C2，服务端编译器），所以在编译过程中会结合起来使用。

你还需要理解逆优化的概念，以及导致逆优化的原因。

另外，我还带你了解了 Graal 这个用 Java 编写的 Java JIT 编译器。最重要的是，通过查看它的代码、修改代码、运行和调试的过程，你能够建立起对 Graal 编译器的亲切感，不会觉得这些技术都是很高冷的，不可接近的。当你开始动手修改的时候，你就踏上了彻底掌握它的旅途。

你要熟练掌握调试方法，并且熟练运用 GraalVM 的很多参数，这会有利于你去做很多实验，来深入掌握 Graal。

本讲的思维导图我放在这里了，供你参考：

一课一思

你能否把示例程序的 add 函数改成一个需要计算量的函数，然后，你可以比较一下，看看 JIT 前后性能相差了多少倍？通过这样的一个例子，你可以获得一些感性认识。

有相关的问题或者是思考呢，你都可以给我留言。如果你觉得有收获，你也可以把今天的内容分享给更多的朋友。

参考资料

GraalVM 项目的官方网站：graalvm.org。

Graal 的Github 地址。

Graal 项目的出版物。有很多围绕这个项目来做研究的论文，值得一读。

14 | Java JIT编译器（二）：Sea of Nodes为何如此强大？

你好，我是宫文学。这一讲，我们继续来研究 Graal 编译器，重点来了解一下它的 IR 的设计。

在上一讲中，我们发现 Graal 在执行过程中，创建了一个图的数据结构，这个数据结构就是 Graal 的 IR。之后的很多处理和优化算法，都是基于这个 IR 的。可以说，这个 IR 是 Graal 编译器的核心特性之一。

那么，为什么这个 IR 采用的是图结构？它有什么特点和优点？编译器的优化算法又是如何基于这个 IR 来运行的呢？

今天，我就带你一起来攻破以上这些问题。在揭晓问题答案的过程中，你对真实编译器中 IR 的设计和优化处理过程，也就能获得直观的认识了。

基于图的 IR

IR 对于编译器非常重要，因为它填补了高级语言和机器语言在语义上的巨大差别。比如说，你在高级语言中是使用一个数组，而翻译成最高效的 x86 机器码，是用间接寻址的方式，去访问一块连续的内存。所以 IR 的设计必须有利于实现这种转换，并且还要有利于运行优化算法，使得生成的代码更加高效。

在上一讲中，通过跟踪 Graal 编译器的执行过程，我们会发现它在一开始，就把字节码翻译成了一种新的 IR，这个 IR 是用图的结构来表示的。那这个图长什么样子呢？非常幸运的是，我们可以用工具来直观地看到它的结构。

你可以从 Oracle 的网站上，下载一个 idealgraphvisualizer 的工具。下载之后，解压缩，并运行它：

export PATH="/<上级目录>/idealgraphvisualizer/bin:$PATH"
idealgraphvisualizer &

这时，程序会启动一个图形界面，并在 4445 端口上等待 GraalVM 发送数据过来。

接着，还是运行 Foo 示例程序，不过这次你要增加一个参数“-Dgraal.Dump”，这会让 GraalVM 输出编译过程的一些中间结果。并且在这个示例程序当中，我还增加了一个“-Xcomp”参数，它能让 JIT 编译器在第一次使用某个方法的时候，就去做编译工作。

mx vm \
  -XX:+UnlockExperimentalVMOptions \
  -XX:+EnableJVMCI \
  -XX:+UseJVMCICompiler \
  -XX:-TieredCompilation \
  -XX:CompileOnly=Foo \
  -Dgraal.Dump \
  -Xcomp \
Foo

GraalVM 会在终端输出“Connected to the IGV on 127.0.0.1:4445”，这表明它连接上了 idealgraphvisualizer。接着，在即时编译之后，idealgraphvisualizer 就接收到了编译过程中生成的图，你可以点击显示它。

这里我展示了其中两个阶段的图，一个是刚解析完字节码之后（After parsing），一个是在处理完中间层之后（After mid tier）。

图 1：After parsing

图 2：After mid tier

Graal IR 其实受到了“程序依赖图”的影响。我们在第 6 讲中提到过程序依赖图（PDG），它是用图来表示程序中的数据依赖和控制依赖。并且你也知道了，这种 IR 还有一个别名，叫做节点之海（Sea of Nodes）。因为当程序稍微复杂一点以后，图里的节点就会变得非常多，我们用肉眼很难看得清。

基于 Sea of Nodes 的 IR 呢，算是后起之秀。在 HotSpot 的编译器中，就采用了这种 IR，而且现在 Java 的 Graal 编译器和 JavaScript 的 V8 编译器中的 IR 的设计，都是基于了 Sea of Nodes 结构，所以我们必须重视它。

这也不禁让我们感到好奇了：Sea of Nodes 到底强在哪里？

我们都知道，数据结构的设计对于算法来说至关重要。IR 的数据结构，会影响到算法的编写方式。好的 IR 的设计，会让优化算法的编写和维护都更加容易。

而 Sea of Nodes 最大的优点，就是能够用一个数据结构同时反映控制流和数据流，并且尽量减少它们之间的互相依赖。

怎么理解这个优点呢？在传统的编译器里，控制流和数据流是分开的。控制流是用控制流图（Control-flow Graph，CFG）来表示的，比如 GNU 的编译器、LLVM，都是基于控制流图的。而 IR 本身，则侧重于表达数据流。

以 LLVM 为例，它采用了 SSA 格式的 IR，这种 IR 可以很好地体现值的定义和使用关系，从而很好地刻画了数据流。

而问题在于，采用这种比较传统的方式，控制流和数据流会耦合得比较紧，因为 IR 指令必须归属于某个基本块。

举个例子来说明一下吧。在下面的示例程序中，“int b = a*2;”这个语句，会被放到循环体的基本块中。

int foo(int a){
    int sum = 0;
    for(int i = 0; i< 10; i++){
        int b = a*2;   //这一句可以提到外面
        sum += b;
    }
}

可是，从数据流的角度看，变量 b 只依赖于 a。所以这个语句没必要放在循环体内，而是可以提到外面。在传统的编译器中，这一步是要分析出循环无关的变量，然后再把这条语句提出去。而如果采用 Sea of Nodes 的数据结构，变量 b 一开始根本没有归属到特定的基本块，所以也就没有必要专门去做代码的移动了。

另外，我们之前讲本地优化和全局优化的时候，也提到过，它们的区别就是，在整个函数范围内，优化的范围是在基本块内还是会跨基本块。而 Sea of Nodes 没有过于受到基本块的束缚，因此也就更容易做全局优化了。

好，那在概要地理解了 Graal IR 的数据结构之后，接下来，我们就具体了解一下 Graal IR，包括认识一下数据流与控制流的特点，了解两种不同的节点：浮动节点和固定节点，以及认识一种特殊的节点：FrameState。

数据流和控制流

我们已经知道，Graal IR 整合了两种图结构：数据流图和控制流图。

首先，我们来看看它的数据流。

在下图中，蓝色的边代表的是数据流，也就是数据之间的依赖关系。参数 1（“P(0)”节点）和参数 2（“P(1)”节点）的值流入到 + 号节点，再流入到 Return 节点。

图 3：Foo.add() 的数据流

在 Graal IR 的设计中，Add 节点有两个输入，分别是 x 和 y，这两个输入是 AddNode 的两个属性。注意，这个图中的箭头方向代表的是数据依赖关系，也就是 Add 节点保持着对它的两个输入节点的引用，这其实跟 AST 是一致的。而数据流向，则是反过来的，从 x 和 y 流向 Add 节点。

图 4：Add 节点的数据依赖关系

查看 AddNode 的设计，你会发现其父类中有两个成员变量，x 和 y。它们用 @input 做了注解，这就意味着，这两个成员变量代表的是数据流图中的两条边。

图 5：Add 节点及其各级父节点

另外，Graal IR 的数据流图是符合 SSA 格式的。也就是说，每个节点代表了 SSA 中的一个值，它只被定义一次，也就相当于 SSA 中的每个变量只被赋值一次。

我们再来看看控制流。

下图中，红色的边代表的是控制流，控制流图代表的是程序执行方向的改变。进入或退出一个函数、条件分支语句、循环语句等，都会导致程序的执行从一个地方跳到另一个地方。

图 6：Foo.add() 的控制流

数据流加上控制流，就能完整表达程序的含义，它等价于字节码，也等价于更早期的 AST。你可以从 Start 节点，沿着控制流遍历这个图。当到达 Return 节点之前，Return 所依赖的数据（x+y）也需要计算出来。

add() 方法的控制流很简单，只有 Start 和 Return 两个节点。我们做一个稍微复杂一点的例子，在 Foo.add2() 示例程序中，调用两个函数 getX() 和 getY()，分别获取 x 和 y 成员变量。

public int add2(){
    return getX() + getY();
}

对应的 Graal 图如下。它增加了两个节点，分别是调用方法 getX 和 getY，这就导致了控制流发生变化。

图 7：Foo.add2() 对应的 IR

注意：对于这个例子，在使用 GraalVM 时，要使用 -XX:-Inline 选项，避免编译器做内联优化，否则 Foo.getX() 和 Foo.getY() 会被内联。我们在下一讲中就会探讨内联优化。

除了调用其他函数，if 语句、循环语句等，也会导致控制流的变化。我们看看这个例子：

public int doif(int x, int y){
    int z;
    if (x < 2)
        z=x+y;
    else
        z=x*y;
    return z;
}

它对应的 Graal 图如下，if 语句会让控制流产生分支，分别对应 if 块和 else 块，最后在 Merge 节点合并起来。

图 8：doif() 方法对应的 IR

IfNode 作为一种控制流节点，它保存着对下级节点的引用，并用 @Successor 注解来标注。这意味着 trueSuccessor 和 falseSuccessor 两个成员变量，代表着控制流中的两条边。当然，你也会注意到，If 节点有一个数据流的输入，这就是 If 的判断条件。IR 会基于这个判断条件，来决定控制流的走向。

图 9：IfNode 及其各级父节点

跟控制流类似，数据流也产生了两个分支，分别是x+y和x*y。最后用一个 Phi 节点合并到一起。

Phi 节点是 SSA 的一个特性。在 doif 示例程序中，z 可能有两个取值。如果控制流走的是 if 块，那么z=x+y；而如果走的是 else 块，则z=x*y。Phi 节点就起到这个作用，它根据控制流来选择值。

总结一下：控制流图表达的是控制的流转，而数据流图代表的是数据之间的依赖关系。二者双剑合璧，代表了源程序完整的语义。

接下来，我再给你介绍一下浮动节点和固定节点的概念。

浮动节点和固定节点

注意，在 Graal IR，数据流与控制流是相对独立的。你看看前面的 doif 示例程序，会发现x+y和x*y的计算，与 if 语句的控制流没有直接关系。所以，你其实可以把这两个语句挪到 if 语句外面去执行，也不影响程序运行的结果（要引入两个临时变量 z1 和 z2，分别代表 z 的两个取值）。

对于这些在执行时间上具有灵活性的节点，我们说它们是浮动的（Floating）。你在 AddNode 的继承层次中，可以看到一个父类：FloatingNode，这说明这个节点是浮动的。它可以在最后生成机器码（或 LIR）的环节，再去确定到底归属哪个基本块。

除了浮动节点以外，还有一些节点是固定在控制流中的，前后顺序不能乱，这些节点叫做固定节点。除了那些流程控制类的节点（如 IfNode）以外，还有一些节点是固定节点，比如内存访问的节点。当你访问一个对象的属性时，就需要访问内存。

内存是个共享资源，同一个内存地址（比如对象的属性），可以被多次读写。也就是说，内存位置不是 SSA 中的值，所以也不受单赋值的约束。

对同一个内存地址的读写操作，顺序是不能乱的。比如下面代码中，第二行和第三行的顺序是不能变的，它们被固定在了控制流中。

x := 10
store x to 地址a
y := load 地址a
z := y + 10

不过，在运行某些优化算法的时候，某些固定节点会被转化成浮动节点，从而提供了更大的代码优化空间。我们在下一讲的“内联和逃逸分析”中，会见到这样的例子。

FrameState 节点

在看 Graal IR 的时候，你经常会遇到一个绿色的节点插在图中。为避免你产生困惑，接下来我就专门给你解释一下这个节点，我们一起来认识一下它。

在 Foo.add() 新生成的 IR 中，如果你不勾选“Remove State”选项，就会显示出一个绿色的节点。这个节点就是 FrameState 节点。

图 10：Foo.add() 中的 FrameState 节点

FrameState 比较特殊。它保存了栈帧的状态，而且这里我指的是 Java 字节码解释器的栈帧的状态，包括了本地变量和操作数栈里的值。

为什么要保存栈帧的状态呢？

第一个用途，是用于逆优化。上一讲我们说过，编译器有时候会基于推测做一些激进的优化，比如忽略掉某些分支。但如果推测依据的前提错了，那么就要做逆优化，重新回到解释器去执行。而 FrameState 的作用就是在代码中一些叫做安全点的地方，记录下栈帧的状态，便于逆优化以后，在解释器里去接着执行程序。

第二个用途，是用于 debug。编译器会用 FrameState，来记录程序执行的一些中间状态值，以方便程序的调试。

对于 Foo.add() 方法的 IR，通过后面的一些优化处理，你会发现 Foo.add() 并不需要逆优化，那么 FrameState 节点就会被去掉。否则，FrameState 就会转化成一个逆优化节点，生成与逆优化有关的代码。

如果你并不关心逆优化，那你在平常看 IR 的过程中，可以勾选“Remove State”选项，不用关注 FrameState 节点就行了。

好了，我们已经大致了解了 Graal IR。进一步，编译器要基于 IR 做各种处理和优化。

对 Graal IR 的处理和优化

通过上一讲，我们已经知道在编译过程中要对图进行很多遍的处理。还是以 Foo.add() 示例程序为例，在运行 GraalVM 的时候，我们加上“-Dgraal.Dump=:5”选项，程序就会详细地 dump 出所做的处理步骤，你可以在 idealgraphvisualizer 中看到这些处理环节，点击每个环节可以看到相对应的 IR 图。

图 11：对 Foo.add() 所做的处理

在这些处理阶段的名称中，你会看到我们在第 7 讲中提到的一些代码优化算法的名称（如死代码删除）。有了前面课程的铺垫，你现在看它们应该就消除了一些陌生感。

另外，你会发现，在这些处理阶段中，有一个 Canonicalizer 的阶段出现了好几次，并且你可能对这个词也比较陌生，所以下面我们不妨来看看，这个阶段都做了些什么。

规范化（Canonicalizer）

Canonicalize 的意思是规范化。如果某段程序有多种写法，那么编译器会处理成一种统一的、标准的写法。

比如，对于下面这个简单的函数，它是把 a 乘以 2。在 CanonicalizerPhase 运行之后，乘法运算被替换成了移位运算，也就是a<<1。它的效果与乘以 2 是相同的，但运行效率更高。

public int doDouble(int a){
    return 2*a;
}

图 12：未做规范化优化之前，是乘法运算

图 13：做完规范化优化之后，变成移位运算

你还可以试一下对某个变量取两次负号的操作。在规范化阶段以后，两个负号就会被去掉，直接返回 a。

public int negneg(int a){
  return -(-a);
}

规范化需要的操作，都是对本节点进行修改和替换，一般都不太复杂。某节点如果实现了 Canonicalizable 接口，在 CanonicalizerPhase 就会对它做规范化。

在规范化阶段实现的优化算法包括：常数折叠（Constant Folding）、强度折减（Strength reduction）、全局值编号（Global Value Numbering，GVN），等等。它们的原理，我在第 7 讲都介绍过，这里就不赘述了。

课程小结

这一讲，我给你介绍了 Graal 的 IR：它整合了控制流图与数据流图，符合 SSA 格式，有利于优化算法的编写和维护。

我还带你了解了对 IR 的一个优化处理过程：规范化。规范化所需要的操作一般并不复杂，它都是对本节点进行修改和替换。在下一讲中，我会带你分析另外两个重要的算法，内联和逃逸分析。

另外，Graal 的 IR 格式是声明式的（Declarative），它通过描述一个节点及其之间的关系来反映源代码的语义。而我们之前见到的类似三地址代码那样的格式，是命令式的（Imperative），它的风格是通过命令直接告诉计算机，来做一个个的动作。

声明式和命令式是编程的两种风格，在 Graal 编译器里，我们可以看到声明式的 IR 会更加简洁，对概念的表达也更加清晰。我们在后面介绍 MySQL 编译器的实现机制当中，在讲 DSL 的时候，还会再回到这两个概念，到时你还会有更加深刻的认识。

本讲的思维导图我也放在了这里，供你参考：

一课一思

了解了 Graal IR 的特点以后，通过对比我们在第 7 讲中学过的优化算法，你觉得哪些优化算法在 Graal IR 上实现起来会更方便？为什么？欢迎在留言区分享你的看法。

如果你觉得有收获，也欢迎你把今天的内容分享给更多的朋友。

参考资料

基于图的 IR，有三篇论文必须提到：

程序依赖图：J. Ferrante, K. J. Ottenstein, and J. D. Warren. The program dependence graph and its use in optimization. July 1987。有关程序依赖图的概念在 1987 年就提出来了。

Click 的论文：A Simple Graph-Based Intermediate Representation。这篇文章比较易读，属于必读文献。Click 还发表了一些论文，讲述了基于图的 IR 上的优化算法。

介绍 Graal IR 的论文：Graal IR: An Extensible Declarative Intermediate Representation。这篇论文也很易读，建议你一定要读一下。

15 | Java JIT编译器（三）：探究内联和逃逸分析的算法原理

你好，我是宫文学。

基于 Graal IR 进行的优化处理有很多。但有些优化，针对 Java 语言的特点，会显得更为重要。

今天这一讲，我就带你来认识两个对 Java 来说很重要的优化算法。如果没有这两个优化算法，你的程序执行效率会大大下降。而如果你了解了这两个算法的机理，则有可能写出更方便编译器做优化的程序，从而让你在实际工作中受益。这两个算法，分别是内联和逃逸分析。

另外，我还会给你介绍一种 JIT 编译所特有的优化模式：基于推理的优化。这种优化模式会让某些程序比 AOT 编译的性能更高。这个知识点，可能会改变你对 JIT 和 AOT 的认知，因为通常来说，你可能会认为 AOT 生成的机器码速度更快，所以通过这一讲的学习，你也会对“全生命周期优化”的概念有所体会。

好，首先，我们来看看内联优化。

内联（Inlining）

内联优化是 Java JIT 编译器非常重要的一种优化策略。简单地说，内联就是把被调用的方法的方法体，在调用的地方展开。这样做最大的好处，就是省去了函数调用的开销。对于频繁调用的函数，内联优化能大大提高程序的性能。

执行内联优化是有一定条件的。第一，被内联的方法要是热点方法；第二，被内联的方法不能太大，否则编译后的目标代码量就会膨胀得比较厉害。

在 Java 程序里，你经常会发现很多短方法，特别是访问类成员变量的 getter 和 setter 方法。你可以看看自己写的程序，是否也充斥着很多对这种短方法的调用？这些调用如果不做优化的话，性能损失是很厉害的。你可以做一个性能对比测试，通过“-XX:-Inlining”参数来阻止 JVM 做内联优化，看看性能降低得会有多大。

但是这些方法有一个好处：它们往往都特别短，内联之后，实际上并不会显著增加目标代码长度。

比如，针对 add2 示例方法，我们采用内联选项优化后，方法调用被替换成了 LoadField（加载成员变量）。

public int add2(){
    return getX() + getY();
}

图 1：将 getter 方法内联

在做了 Lower 处理以后，LoadField 会被展开成更底层的操作：根据 x 和 y 的地址相对于对象地址的偏移量，获取 x 和 y 的值。

图 2：计算字段 x 和 y 的地址

而要想正确地计算字段的偏移量，我们还需要了解 Java 对象的内存布局。

在 64 位平台下，每个 Java 对象头部都有 8 字节的标记字，里面有对象 ID 的哈希值、与内存收集有关的标记位、与锁有关的标记位；标记字后面是一个指向类定义的指针，在 64 位平台下也是 8 位，不过如果堆不是很大，我们可以采用压缩指针，只占 4 个字节；在这后面才是 x 和 y 字段。因此，x 和 y 的偏移量分别是 12 和 16。

图 3：内存中，Java 对象头占据的空间

在 Low Tier 编译完毕以后，图 2 还会进一步被 Lower，形成 AMD64 架构下的地址。这样的话，编译器再进一步翻译成汇编代码就很容易了。

图 4：生成 AMD64 架构的地址计算节点

内联优化除了会优化 getter、setter 这样的短方法，它实际上还起到了另一个重要的作用，即跨过程的优化。一般的优化算法，只会局限在一个方法内部。而启动内联优化后，多个方法会合并成一个方法，所以就带来了更多的优化的可能性。

我们来看看下面这个 inlining 示例方法。它调用了一个 atLeastTen 方法，这个方法对于 <10 的参数，会返回 10；对于≥10 的参数，会返回该参数本身。所以你用肉眼就可以看出来，inlining 方法的返回值应该是 10。

public int inliningTest(int a){
    return atLeastTen(3);    //应该返回10
}

//至少返回10
public int atLeastTen(int a){
    if (a < 10)
        return 10;
    else
        return a;
}

如果不启用编译器的内联选项，那么 inliningTest 方法对应的 IR 图，就是常规的方法调用而已：

图 5：不启用内联时调用 atLeastTen() 方法

而一旦启用了内联选项，就可以触发一系列的优化。在把字节码解析生成 IR 的时候，编译器就启动了内联分析过程，从而会发现 this 参数和常量 3 对于 inliningTest 方法根本是无用的，在图里表现成了一些孤岛。在 Mid Tier 处理完毕之后，inliningTest 方法就直接返回常量 10 了。

图 6：启用内联后，调用 atLeastTen() 方法

另外，方法的类型也会影响 inlining。如果方法是 final 的，或者是 private 的，那么它就不会被子类重载，所以可以大胆地内联。

但如果存在着多重继承的类体系，方法就有可能被重载，这就会导致多态。在运行时，JVM 会根据对象的类型来确定到底采用哪个子类的具体实现。这种运行时确定具体方法的过程，叫做虚分派（Virtual Dispatch）。

在存在多态的情况下，JIT 编译器做内联就会遇到困难了。因为它不知道把哪个版本的实现内联进来。不过编译器仍然没有放弃。这时候所采用的技术，就叫做“多态内联（Polymorphic inlining）”。

它的具体做法是，在运行时，编译器会统计在调用多态方法的时候，到底用了哪几个实现。然后针对这几个实现，同时实现几个分支的内联，并在一开头根据对象类型判断应该走哪个分支。这个方法的缺陷是生成的代码量会比较大，但它毕竟可以获得内联的好处。最后，如果实际运行中遇到的对象，与提前生成的几个分支都不匹配，那么编译器还可以继续用缺省的虚分派模式来做函数调用，保证程序不出错。

这个案例也表明了，JIT 编译器是如何充分利用运行时收集的信息来做优化的。对于 AOT 模式的编译来说，由于无法收集到这些信息，因此反倒无法做这种优化。

如果你想对多态内联做更深入的研究，还可以参考这一篇经典论文《Inlining of Virtual Methods》。

总结起来，内联优化不仅能降低由于函数调用带来的开销，还能制造出新的优化机会，因此带来的优化效果非常显著。接下来，我们看看另一个能带来显著优化效果的算法：逃逸分析。

逃逸分析（Escape Analysis, EA）

逃逸分析是 JVM 的另一个重要的优化算法，它同样可以起到巨大的性能提升作用。

逃逸分析能够让编译器判断出，一个对象是否能够在创建它的方法或线程之外访问。如果只能在创建它的方法内部访问，我们就说这个对象不是方法逃逸的；如果仅仅逃逸出了方法，但对这个对象的访问肯定都是在同一个线程中，那么我们就说这个对象不是线程逃逸的。

判断是否逃逸有什么用呢？用处很大。只要我们判断出了该对象没有逃逸出方法或线程，就可以采用更加优化的方法来管理该对象。

以下面的示例代码为例。我们有一个 escapeTest() 方法，这个方法可以根据输入的年龄，返回年龄段：小于 20 岁的返回 1，否则返回 2。

在示例程序里，我们创建了一个 Person 对象，并调用它的 ageSegment 方法，来返回年龄段。

public int escapeTest(int age){
    Person p = new Person(age);
    return p.ageSegment();
}

public class Person{
    private int age;
    private float weight;

    public Person(int age){
       this.age = age;
    }

    //返回年龄段
    final public int ageSegment(){
        if (age < 20)
            return 1;
        else
            return 2;
    }

    public void setWeight(float weight){
        this.weight = weight;
    }

    public float getWeidht(){
        return weight;
    }
}

你可以分析一下，针对这段程序，我们可以做哪些优化工作？

首先是栈上分配内存。

在 Java 语言里，对象的内存通常都是在堆中申请的。对象不再被访问以后，会由垃圾收集器回收。但对于这个例子来说，Person 对象的生命周期跟 escapeTest() 方法的生命周期是一样的。在退出方法后，就不再会有别的程序来访问该对象。

换句话说，这个对象跟一个 int 类型的本地变量没啥区别。那么也就意味着，我们其实可以在栈里给这个对象申请内存就行了。

你已经知道，在栈里申请内存会有很多好处：可以自动回收，不需要浪费 GC 的计算量去回收内存；可以避免由于大量生成小对象而造成的内存碎片；数据的局部性也更好，因为在堆上申请内存，它们的物理地址有可能是不相邻的，从而降低高速缓存的命中率；再有，在并发计算的场景下，在栈上分配内存的效率更高，因为栈是线程独享的，而在堆中申请内存可能需要多线程之间同步。所以，我们做这个优化是非常有价值的。

再进一步，还可以做标量替换（Scalar Replacement）。

这是什么意思呢？你会发现，示例程序仅仅用到了 Person 对象的 age 成员变量，而 weight 根本没有涉及。所以，我们在栈上申请内存的时候，根本没有必要为 weight 保留内存。同时，在一个 Java 对象的标准内存布局中，还要有一块固定的对象头的内存开销。在 64 位平台，对象头可能占据了 16 字节。这下倒好，示例程序本来只需要 4 个字节的一个整型，最后却要申请 24 个字节，是原需求的 6 倍，这也太浪费了。

通过标量替换的技术，我们可以根本不生成对象实例，而是把要用到的对象的成员变量，作为普通的本地变量（也就是标量）来管理。

这么做还有一个好处，就是编译器可以尽量把标量放到寄存器里去，连栈都不用，这样就避免了内存访问所带来的性能消耗。

Graal 编译器也确实是这么做的。在 Mid Tier 层处理完毕以后，你查看 IR 图，会发现它变成了下面的这个样子：

图 7：对 Person 对象做标量替换

你会看到，编译器连 Person 的 ageSegement 方法也内联进来了。最后优化后的函数相当于：

public int escapeTest(int age){
  return age<20 ? 1 : 2;
}

图 7 中的 Conditional 是一个条件计算的三元表达式。你看到这个优化结果的时候，有没有感到震惊？是的。善用编译器的优化算法，就是会达到如此程度的优化。优化前后的代码的功能是一样的，但优化后的代码变得如此简洁、直奔最终计算目标，忽略中间我们自己编程所带来的冗余动作。

上面讲的都是没有逃逸出方法的情况。这种情况被叫做 NoEscape。还有一种情况，是虽然逃逸出了方法，但没有逃逸出当前线程，也就是说不可能被其他线程所访问，这种逃逸叫做 ArgEscape，也就是它仅仅是通过方法的参数逃逸的。最后一种情况就是 GlobalEscape，也就是能够被其他线程所访问，因此没有办法优化。

对于 ArgEscape 的情况，虽然编译器不能做内存的栈上分配，但也可以做一定的优化，这就是锁消除或者同步消除。

我们知道，在并发场景下，锁对性能的影响非常之大。而很多线程安全的对象，比如一些集合对象，它们的内部都采用了锁来做线程的同步。如果我们可以确定这些对象没有逃逸出线程，那么就可以把这些同步逻辑优化掉，从而提高代码的性能。

好了，现在你已经理解了逃逸分析的用途。那么逃逸分析的算法是怎么实现的呢？这方面你可以去参考这篇经典论文《Escape Analysis for Java》。论文里的算法利用了一种数据结构，叫做连接图（Connection Graph）。简单地说，就是分析出了程序中对象之间的引用关系。

整个分析算法是建立在这样一种直觉认知上的：基于一个连接图，也就是对象之间的引用关系图，如果 A 引用了 B，而 A 能够被线程外的程序所访问（线程逃逸），那么也就意味着 B 也是线程逃逸的。也就是说，逃逸性是有传染能力的。通过这样的分析，那些完全没被传染的对象，就是 NoEscape 的；只被 ArgEscape 对象传染的，那就也是 ArgEscape 的。原理说起来就是这么简单。

另外，我们前面所做的分析都是静态分析，也就是基于对代码所做的分析。对于一个对象来说，只要存在任何一个控制流，使得它逃逸了，那么编译器就会无情地把它标注为是逃逸对象，也就不能做优化了。但是，还会出现一种情况，就是有可能这个分支的执行频率特别少，大部分情况下该对象都是不逃逸的。

所以，Java 的 JIT 编译器实际上又向前迈进了一步，实现了部分逃逸分析（Partial Escape Analysis）。它会根据运行时的统计信息，对不同的控制流分支做不同的处理。对于没有逃逸的分支，仍然去做优化。在这里，你能体会到，编译器为了一点点的性能提升，简直无所不用其极呀。

如果你还想对部分逃逸分析做进一步的研究，那你可以参考这篇论文：《Partial Escape Analysis and Scalar Replacement for Java》。

总结起来，逃逸分析能够让对象在栈上申请内存，做标量替换，从而大大减少对象处理的开销。这个算法对于对象生命周期比较短的场景，优化效果是非常明显的。

在讲内联和逃逸算法的时候，我们都发现，编译器会根据运行时的统计信息，通过推断来做一些优化，比如多态内联、部分逃逸分析。而这种优化模式，就叫做基于推理的优化。

基于推理的优化（Speculative Optimization）

我刚刚说过，一般情况下，编译器的优化工作是基于对代码所做的分析，也就是静态分析。而 JIT 编译还有一个优势，就是会根据运行时收集的统计信息来做优化。

我还是以 Foo.atLeastTen() 方法举例。在正常情况下，它生成的 HIR 是下面的样子，根据条件表达式的值（a<10），分别返回 10 和 a。

图 8：基于静态分析编译 atLeastTen() 方法

而如果我们在主程序里调用 atLeastTen 方法是采用下面示例代码中的逻辑，在启动 JIT 编译器时，已经循环了上万次。而在这上万次当中，只有 9 次 i 的值是小于 10 的，那么编译器就会根据运行时的统计信息判断，i 的值大概率是大于 10 的。所以，它会仅针对大于 10 的分支做编译。

而如果遇到小于 10 的情况，则会导致逆优化。你会看到，IR 中有一个绿色的 FrameState 节点，这个节点保存了栈帧的状态，在逆优化时会被用到。

int i = 0;
while(true){
    i++;
    foo.atLeastTen(i);
    ...
}

图 9：基于推理优化，只编译 if 语句的第 1 个分支，返回 10

我们把主程序修改一下，再做一次实验。这次，我们传给 Foo.atLeastTen 方法的参数是 i%10，也就是参数 a 的取值范围永远是在 0 到 9 之间。这一次，JIT 编译器会反过来，仅针对 a 小于 10 的分支做编译，而对 a 大于 10 的情况做逆优化处理。

int i = 0;
while(true){
    i++;
    foo.atLeastTen(i%10);
    ...
}

图 10：基于推理优化，只编译 if 语句的第 2 个分支，返回 a

通过这个简单的例子，你对 JIT 编译器基于推理的优化情况就有了一个直观的了解。对于 atLeastTen 这个简单的方法，这样的优化似乎并不会带来太多的好处。但对于比较复杂的程序，那既可以节省编译时间，也能减少编译后的代码大小，收益是很高的。比如对于程序中仅用于输出 Debug 信息的分支，就根本不需要生成代码。

另外，这种基于推理的优化，还会带来其他额外的优化机会。比如对于逃逸分析而言，去掉了一些导致逃逸的分支以后，在剩下的分支中，对象并没有逃逸，所以也就可以做优化了！

总结起来，基于运行时的统计信息进行推理的优化，有时会比基于静态分析的 AOT 产生出性能更高的目标代码。所以，现代编译技术的实践，会强调“全生命周期”优化的概念。甚至即使是 AOT 产生的目标代码，仍然可以在运行期通过 JIT 做进一步优化。LLVM 项目的发起人之一，Chris Lattner 就曾写了一篇论文来提倡这个理念，这也是 LLVM 的设计目标之一。

课程小结

今天我带你了解了 Java JIT 编译器中两个重要的优化算法，这两个优化算法都会大大提升程序的执行效率。另外，你还了解了在 JIT 编译中，基于运行时的信息做推理优化的技术。

在课程中，我不可能带你去分析所有的优化算法，但你可以根据课程的内容去举一反三，研究一下里面的其他算法。如果你对这些算法掌握得比较清晰，那你就可以胜任编译器的开发工作了。因为编译器开发的真正的工作量，都在中后端。

另外，熟悉这些重要的优化算法的原理，还有助于你写出性能更高的程序。比如说，你要让高频使用的代码易于内联；在使用对象的时候，让它的作用范围清晰一些，不要做无用的关联，尽量不要逃逸出方法和线程之外，等等。

本讲的思维导图我也放在下面了，供你参考。

一课一思

今天的思考题，还是想请你设计一个场景，测试内联 vs 无内联，或者做逃逸优化 vs 无逃逸优化的性能差异。借此，你也可以熟悉一下如何控制 JVM 的优化选项。欢迎在留言区分享你在测试中的发现。

关闭内联优化: -XX:-Inlining。JDK8 缺省是打开的。

显示内联优化详情：-XX:+PrintInlining。

关闭逃逸分析：-XX:-DoEscapeAnalysis。JDK8 缺省是打开的。

显示逃逸分析详情：-XX:+PrintEscapeAnalysis。

关闭标量替换：-XX:-EliminateAllocations。JDK8 缺省是打开的。

显示标量替换详情：-XX:+PrintEliminateAllocations。

参考资料

多态内联：Inlining of Virtual Methods。

逃逸分析：Escape Analysis for Java。

部分逃逸分析：Partial Escape Analysis and Scalar Replacement for Java。

16 | Java JIT编译器（四）：Graal的后端是如何工作的？

你好，我是宫文学。

前面两讲中，我介绍了 Sea of Nodes 类型的 HIR，以及基于 HIR 的各种分析处理，这可以看做是编译器的中端。

可编译器最终还是要生成机器码的。那么，这个过程是怎么实现的呢？与硬件架构相关的 LIR 是什么样子的呢？指令选择是怎么做的呢？

这一讲，我就带你了解 Graal 编译器的后端功能，回答以上这些问题，破除你对后端处理过程的神秘感。

首先，我们来直观地了解一下后端处理的流程。

后端的处理流程

在第 14 讲中，我们在运行 Java 示例程序的时候（比如atLeastTen()方法），使用了“-Dgraal.Dump=:5”的选项，这个选项会 dump 出整个编译过程最详细的信息。

对于 HIR 的处理过程，程序会通过网络端口，dump 到 IdealGraphVisualizer 里面。而后端的处理过程，缺省则会 dump 到工作目录下的一个“graal_dumps”子目录下。你可以用文本编辑器打开查看里面的信息。

//至少返回10
public int atLeastTen(int a){
    if (a < 10)
        return 10;
    else
        return a;
}

不过，你还可以再偷懒一下，使用一个图形工具c1visualizer来查看。

补充：c1visualizer 原本是用于查看 Hopspot 的 C1 编译器（也就是客户端编译器）的 LIR 的工具，这也就是说，Graal 的 LIR 和 C1 的是一样的。另外，该工具不能用太高版本的 JDK 运行，我用的是 JDK1.8。

图 1：atLeatTen() 方法对应的 LIR

在窗口的左侧，你能看到后端的处理流程。

首先是把 HIR 做最后一次排序（HIR Final Schedule），这个处理会把 HIR 节点分配到基本块，并且排序；

第二是生成 LIR，在这个过程中要做指令选择；

第三，寄存器分配工作，Graal 采用的算法是线性扫描（Linear Scan）；

第四，是基于 LIR 的一些优化工作，比如 ControlFlowOptimizer 等；

最后一个步骤，是生成目标代码。

接下来，我们来认识一下这个 LIR：它是怎样生成的，用什么数据结构保存的，以及都有什么特点。

认识 LIR

在对 HIR 的处理过程中，前期（High Tier、Mid Tier）基本上都是与硬件无关。到了后期（Low Tier），你会看到 IR 中的一些节点逐步开始带有硬件的特征，比如上一讲中，计算 AMD64 地址的节点。而 LIR 就更加反映目标硬件的特征了，基本上可以跟机器码一对一地翻译。所以，从 HIR 生成 LIR 的过程，就要做指令选择。

我把与 LIR 相关的包和类整理成了类图，里面划分成了三个包，分别包含了与 HIR、LIR 和 CFG 有关的类。你可以重点看看它们之间的相互关系。

图 2：HIR、LIR 和 CFG 的关联关系

在 HIR 的最后的处理阶段，程序会通过一个 Schedule 过程，把 HIR 节点排序，并放到控制流图中，为生成 LIR 和目标代码做准备。我之前说过，HIR 的一大好处，就是那些浮动节点，可以最大程度地免受控制流的约束。但在最后生成的目标代码中，我们还是要把每行指令归属到某个具体的基本块的。而且，基本块中的 HIR 节点是按照顺序排列的，在 ScheduleResult 中保存着这个顺序（blockToNodesMap 中顺序保存了每个 Block 中的节点）。

你要注意，这里所说的 Schedule，跟编译器后端的指令排序不是一回事儿。这里是把图变成线性的程序；而编译器后端的指令排序（也叫做 Schedule），则是为了实现指令级并行的优化。

当然，把 HIR 节点划分到不同的基本块，优化程度是不同的。比如，与循环无关的代码，放在循环内部和外部都是可以的，但显然放在循环外部更好一些。把 HIR 节点排序的 Schedule 算法，复杂度比较高，所以使用了很多启发式的规则。刚才提到的把循环无关代码放在循环外面，就是一种启发式的规则。

图 2 中的 ControlFlowGraph 类和 Block 类构成了控制流图，控制流图和最后阶段的 HIR 是互相引用的。这样，你就可以知道 HIR 中的每个节点属于哪个基本块，也可以知道每个基本块中包含的 HIR 节点。

做完 Schedule 以后，接着就会生成 LIR。与声明式的 HIR 不同，LIR 是命令式的，由一行行指令构成。

图 1 显示的是 Foo.atLeatTen 方法对应的 LIR。你会看到一个控制流图（CFG），里面有三个基本块。B0 是 B1 和 B2 的前序基本块，B0 中的最后一个语句是分支语句（基本块中，只有最后一个语句才可以是导致指令跳转的语句）。

LIR 中的指令是放到基本块中的，LIR 对象的 LIRInstructions 属性中，保存了每个基本块中的指令列表。

OK，那接下来，我们来看看 LIR 的指令都有哪些，它们都有什么特点。

LIRInstruction 的子类，主要放在三个包中，你可以看看下面的类图。

图 3：LIR 中的指令类型

首先，在 org.graalvm.compiler.lir 包中，声明了一些与架构无关的指令，比如跳转指令、标签指令等。因为无论什么架构的 CPU，一定都会有跳转指令，也一定有作为跳转目标的标签。

然后，在 org.graalvm.compiler.lir.amd64 包中，声明了几十个 AMD64 架构的指令，为了降低你的阅读负担，这里我只列出了有代表性的几个。这些指令是 LIR 代码中的主体。

最后，在 org.graalvm.compiler.hotspot.amd64 包中，也声明了几个指令。这几个指令是利用 HotSpot 虚拟机的功能实现的。比如，要获取某个类的定义的地址，只能由虚拟机提供。

好了，通过这样的一个分析，你应该对 LIR 有更加具体的认识了：LIR 中的指令，大多数是与架构相关的。这样才适合运行后端的一些算法，比如指令选择、寄存器分配等。你也可以据此推测，其他编译器的 LIR，差不多也是这个特点。

接下来，我们就来了解一下 Graal 编译器是如何生成 LIR，并且在这个过程中，它是如何实现指令选择的。

生成 LIR 及指令选择

我们已经知道了，Graal 在生成 LIR 的过程中，要进行指令选择。

我们先看一下 Graal 对一个简单的示例程序 Foo.add1，是如何生成 LIR 的。

public static int add1(int x, int y){
    return x + y + 10;
}

这个示例程序，在转 LIR 之前，它的 HIR 是下面这样。其中有两个加法节点，操作数包括了参数（ParameterNode）和常数（ConstantNode）两种类型。最后是一个 Return 节点。这个例子足够简单。实际上，它简单到只是一棵树，而不是图。

图 4：add1 方法对应的 HIR

你可以想一下，对于这么简单的一棵树，编译器要如何生成指令呢？

最简单的方法，是做一个语法制导的简单翻译。我们可以深度遍历这棵树，针对不同的节点，分别使用不同的规则来生成指令。比如：

在遇到参数节点的时候，我们要搞清楚它的存放位置。因为参数要么是在寄存器中，要么是在栈中，可以直接用于各种计算。

遇到常数节点的时候，我们记下这个常数，用于在下一条指令中作为立即数使用。

在遇到加法节点的时候，生成一个 add 指令，左右两棵子树的计算结果分别是其操作数。在处理到 6 号节点的时候，可以不用 add 指令，而是生成一个 lea 指令，这样可以直接把结果写入 rax 寄存器，作为返回值。这算是一个优化，因为可以减少一次从寄存器到寄存器的拷贝工作。

遇到 Return 节点的时候，看看其子树的计算结果是否放在 rax 寄存器中。如果不是，那么就要生成一个 mov 指令，把返回值放入 rax 寄存器，然后再生成一条返回指令（ret）。通常，在返回之前，编译器还要做一些栈帧的处理工作，把栈指针还原。

对于这个简单的例子来说，按照这个翻译规则来生成代码，是完全没有问题的。你可以看下，Graal 生成 LIR，然后再基于 LIR 生成的目标代码的示例程序，它只有三行，足够精简和优化：

add    esi,edx       #将参数1加到参数0上，结果保存在esi寄存器
lea    eax,[rsi+0xa] #将rsi加10,结果放入eax寄存器
ret                  #返回

补充： 1. 我去掉了一些额外的汇编代码，比如用于跟 JVM 握手，让 JVM 有机会做垃圾收集的代码。 2. lea 指令原本是用于计算地址的。上面的指令的意思是把 rsi 寄存器的值作为地址，然后再偏移 10 个字节，把新的地址放到 eax 寄存器。 x86 计算机支持间接寻址方式：偏移量（基址，索引值，字节数）其地址是：基址 + 索引值 * 字节数 + 偏移量所以，你可以利用这个特点，计算出a+b*c+d的值。但 c（也就是字节数）只能取 1、2、4、8。就算让 c 取 1，那也能完成a+b+c的计算。并且，它还可以在另一个操作数里指定把结果写到哪个寄存器，而不像 add 指令，只能从一个操作数加到另一个操作数上。这些优点，使得 x86 汇编中经常使用 lea 指令做加法计算。

Graal 编译器实际上大致也是这么做的。

首先，它通过 Schedule 的步骤，把 HIR 的节点排序，并放入基本块。对于这个简单的程序，只有一个基本块。

接着，编译器会对基本块中的节点做遍历（参考：NodeLIRBuilder.java 中的代码）。针对每个节点转换（Lower）成 LIR。

把参数节点转换成了 MoveFromRegOp 指令，在示例程序里，其实这是冗余的，因为可以直接把存放参数的两个寄存器，用于加法计算；

把第一个加法节点转换成了 CommutativeTwoOp 指令；

把第二个加法节点转换成了 LeaOp 指令，并且把常数节点变成了 LeaOp 指令的操作数；

Return 节点生成了两条指令，一条是把加法运算的值放到 rax 寄存器，作为返回值，这条我们知道是冗余的，所以就要看看后面的优化算法如何消除这个冗余；第二条是返回指令。

一开始生成的 LIR，使用的寄存器都是虚拟的寄存器名称，用 v1、v2、v3 这些来表示。等把这些虚拟的寄存器对应到具体的物理寄存器以后，就可以消除掉刚才提到的冗余代码了。

我们在 c1visualizer 中检查一下优化过程，可以发现这是在 LinearScanAssignLocationsPhase 做的优化。加法指令中对参数 1 和参数 2 的引用，变成了对物理寄存器的引用，从而优化掉了两条指令。lea 指令中的返回值，也直接赋给了 rax 寄存器。这样呢，也就省掉了把计算结果 mov 到 rax 的指令。这样优化后的 LIR，基本上已经等价于目标代码了。

好了，通过这样一个分析，你应该理解了从 HIR 生成 LIR 的过程。但是还有个问题，这中间似乎也没有做什么指令选择呀？唯一的一处，就是把加法操作优化成了 lea 指令。而这个也比较简单，基于单独的 Add 节点就能做出这个优化选择。那么，更复杂的模式匹配是怎么做的呢？

不要着急，我们接下来就看看 Graal 是如何实现复杂一点的指令选择的。这一次，我们用了另一个示例程序：Foo.addMemory 方法。它把一个类成员变量 m 和参数 a 相加。

public class Foo{
    static int m = 3;
    public static int addMemory(int a){
        return m + a;
    }
    ...
}

这跟 add1 方法有所不同，因为它要使用一个成员变量，所以一定要访问内存。而 add1 方法的所有操作，都是在寄存器里完成的，是“空中作业”，根本不在内存里落地。

我们来看一下这个示例程序对应的 HIR。其中一个黄色节点“Read#Foo.m”，是读取内存的节点，也就是读取成员变量 m 的值。而这又需要通过 AMD64Address 节点来计算 m 的地址。由于 m 是个静态成员，所以它的地址要通过类的地址加上一定的偏移量来计算。

图 5：addMemory() 方法对应的 HIR

这里有一个小的知识点，我在第 14 讲中也提过：对内存操作的节点（如图中的 ReadNode），是要加入控制流中的。因为内存里的值，会由于别的操作而改变。如果你把它变成浮动节点，就有可能破坏对内存读写的顺序，从而出现错误。

回到主题，我们来看看怎么为 addMemory 生成 LIR。

如果还是像处理 add1 方法一样，那么你就会这么做：

计算 m 变量的地址，并放入一个寄存器；

基于这个地址，取出 m 的值，放入另一个寄存器；

把 m 的值和参数 a 做加法。

不过这样做，至少要生成 3 条指令。

在第 8 讲中我曾经讲过，像 AMD64 这样使用复杂指令集（CICS）的架构，具有强大的地址表达能力，并且可以在做算术运算的时候，直接使用内存。所以上面的三条指令，其实能够缩减成一条指令。

这就需要编译器把刚才这种基于内存访问做加法的模式识别出来，以便生成优化的 LIR，进而生成优化的目标代码。这也是指令选择算法要完成的任务。可是，如何识别这种模式呢？

跟踪 Graal 的执行，你会发现 HIR 在生成 LIR 之前，有一个对基本块中的节点做模式匹配的操作，进而又调用匹配复杂表达式（matchComplexExpressions）。在这里，编译器会把节点跟一个个匹配规则（MatchStatement）做匹配。注意，匹配的时候是逆序做的，相当于从树根开始遍历。

在匹配加法节点的时候，Graal 匹配上了一个 MatchStatement，这个规则的名字叫“addMemory”，是专门针对涉及内存操作的加法运算提供的一个匹配规则。这个 MatchStatement 包含了一个匹配模式（MatchPattern），该模式的要求是：

节点类型是 AddNode；

第一个输入（也就是子节点）是一个值节点（value）；

第二个输入是一个 ReadNode，而且必须只有一个使用者（singleUser=true）。

图 6：匹配规则和匹配模式

这个 MatchStatement 是在AMD64NodeMatchRules中用注解生成的。利用这样的一个匹配规则，就能够匹配示例程序中的 Add 节点。

匹配上以后，Graal 会把 AddNode 和 ReadNode 做上特殊标记，这样在生成 LIR 的时候，就会按照新的生成规则。生成的 LIR 如下：

你可以发现，优化后，编译器把取参数 a 的指令省略掉了，直接使用了传入参数 a 的寄存器 rsi：

最后生成的目标代码如下：

movabs rax,0x797b00690          #把Foo类的地址放入rax寄存器
add    esi,DWORD PTR [rax+0x68] #偏移0x68后，是m的地址。做加法
mov    eax,esi                  #设置返回值
ret                             #返回

到目前为止，你已经了解了 Graal 是如何匹配一个模式，并选择优化的指令的了。

你可以看看AMD64NodeMatchRules类，它的里面定义了不少这种匹配规则。通过阅读和理解这些规则，你就会对为什么要做指令选择有更加具体的理解了。

Graal 的指令选择算法算是比较简单的。在 HotSpot 的 C2 编译器中，指令选择采用的是 BURS（Bottom-Up Rewrite System，自底向上的重写系统）。这个算法会更加复杂一点，消耗的时间更长，但优化效果更好一些。

这里我补充一个分享，我曾经请教过 ARM 公司的研发人员，他们目前认为 Graal 对针对 AArch64 的指令选择是比较初级的，你可以参考这个幻灯片。所以，他们也正在帮助 Graal 做改进。

后端的其他功能

出于突出特色功能的目的，这一讲我着重讲了 LIR 的特点和指令选择算法。不过在考察编译器的后端的时候，我们通常还要注意一些其他功能，比如寄存器分配算法、指令排序，等等。我这里就把 Graal 在这些功能上的实现特点，给你简单地介绍一下，你如果有兴趣的话，可以根据我的提示去做深入了解：

寄存器分配：Graal 采用了线性扫描（Linear Scan）算法。这个算法的特点是速度比较快，但优化效果不如图染色算法。在 HotSpot 的 C2 中采用的是后者。

指令排序：Graal 没有为了实现指令级并行而去做指令排序。这里一个主要原因，是现在的很多 CPU 都已经支持乱序（out-of-order）执行，再做重排序的收益不大。

窥孔优化：Graal 在生成 LIR 的时候，会做一点窥孔优化（AMD64NodeLIRBuilder 类的peephole方法）。不过它的优化功能有限，只实现了针对除法和求余数计算的一点优化。

从 LIR 生成目标代码：由于 LIR 已经跟目标代码很接近了，所以这个翻译过程已经比较简单，没有太难的算法了，需要的只是了解和熟悉汇编代码和调用约定。

课程小结

这一讲，我带你对 Graal 的后端做了一个直观的认识，让你的后端知识有了第一个真实世界中编译器的参考系。

第一，把 LIR 从比较抽象的概念中落地。你现在可以确切地知道哪些指令是必须跟架构相关的，而哪些指令可以跟架构无关。

第二，把指令选择算法落地。虽然 Graal 的指令选择算法并不复杂，但这毕竟提供了一个可以借鉴的思路，是你认知的一个阶梯。如果你仔细阅读代码，你还可以具象地了解到，符合哪些模式的表达式，是可以从指令选择中受益的。这又是一个理论印证实践的点。

我把这讲的思维导图也放在了下面，供你参考。

同时，这一讲之后，我们对 Java 编译器的探讨也就告一段落了。但是，我希望你对它的研究不要停止。

我们讨论的两个编译器（javac 和 Graal）中的很多知识点，你只要稍微深入挖掘一下，就可以得出不错的成果了。比如，我看到有国外的硕士学生研究了一下 HotSpot，就可以发表不错的论文。如果你是在校大学生，我相信你也可以通过顺着这门课程提供的信息做一些研究，从而得到不错的成果。如果是已经工作的同学，我们可以在极客时间的社群（比如留言区和部落）里保持对 Java 编译技术的讨论，也一定会对于你的工作有所助益。

一课一思

请你阅读AMD64NodeMatchRules中的匹配规则，自己设计另一个例子，能够测试出指令选择的效果。如果降低一下工作量的话，你可以把它里面的某些规则解读一下，在留言区发表你的见解。

好，就到这里。感谢你的阅读，欢迎你把今天的内容分享给更多的朋友，我们下一讲再见。

17 | Python编译器（一）：如何用工具生成编译器？

你好，我是宫文学。

最近几年，Python 在中国变得越来越流行，我想可能有几个推动力：第一个是因为人工智能热的兴起，用 Python 可以很方便地使用流行的 AI 框架，比如 TensorFlow；第二个重要的因素是编程教育，特别是很多面对青少年的编程课程，都是采用的 Python 语言。

不过，Python 之所以变得如此受欢迎，虽然有外在的机遇，但也得益于它内在的一些优点。比如说：

Python 的语法比较简单，容易掌握，它强调一件事情只能用一种方法去做。对于老一代的程序员来说，Python 就像久远的 BASIC 语言，很适合作为初学者的第一门计算机语言去学习，去打开计算机编程这个充满魅力的世界。

Python 具备丰富的现代语言特性，实现方式又比较简洁。比如，它既支持面向对象特性，也支持函数式编程特性，等等。这对于学习编程很有好处，能够带给初学者比较准确的编程概念。

我个人比较欣赏 Python 的一个原因，是它能够充分利用开源世界的一些二进制的库，比如说，如果你想研究计算机视觉和多媒体，可以用它调用 OpenCV 和 FFmpeg。Python 跟 AI 框架的整合也是同样的道理，这也是 Python 经常用于系统运维领域的原因，因为它很容易调用操作系统的一些库。

最后，Python 还有便于扩展的优势。如果你觉得 Python 有哪方面能力的不足，你也可以用 C 语言来写一些扩展。而且，你不仅仅可以扩展出几个函数，你还能扩展出新的类型，并在 Python 里使用这些新类型。比如，Python 的数学计算库是 NumPy，它的核心代码是用 C 语言编写的，性能很高。

看到这里，你自然会好奇，这么一门简洁有力的语言，是如何实现的呢？吉多·范罗苏姆（Python 初始设计者）在编写 Python 的编译器的时候，脑子里是怎么想的呢？

从这一讲开始，我们就进入到 Python 语言的编译器内部，去看看它作为一门动态、解释执行语言的代表，是如何做词法分析、语法分析和语义分析的，又是如何解释执行的，以及它的运行时有什么设计特点，让它可以具备这些优势。你在这个过程中，也会对编译技术的应用场景了解得更加全面。这也正是我要花 3 讲的时间，带领你来解析 Python 编译器的主要原因。

今天这一讲，我们重点来研究 Python 的词法分析和语法分析功能，一起来看看它在这两个处理阶段都有什么特点。你会学到一种新的语法分析实现思路，还能够学到 CST 跟 AST 的区别。

好了，让我们开始吧。

编译源代码，并跟踪调试

首先，你可以从python.org 网站下载3.8.1 版本的源代码。解压后你可以先自己浏览一下，看看能不能找到它的词法分析器、语法分析器、符号表处理程序、解释器等功能的代码。

Python 源代码划分了多个子目录，每个子目录的内容整理如下：

首先，你会发现 Python 编译器是用 C 语言编写的。这跟 Java、Go 的编译器不同，Java 和 Go 语言的编译器是支持自举的编译器，也就是这两门语言的编译器是用这两门语言自身实现的。

实际上，用 C 语言实现的 Python 编译器叫做 CPython，是 Python 的几个编译器之一。它的标准库也是由 C 语言和 Python 混合编写的。我们课程中所讨论的就是 CPython，它是 Python 语言的参考实现，也是 macOS 和 Linux 缺省安装的版本。

不过，Python 也有一个编译器是用 Python 本身编写的，这个编译器是 PyPy。它的图标是一条咬着自己尾巴的衔尾蛇，表明这个编译器是自举的。除此之外，还有基于 JVM 的 Jython，这个版本的优势是能够借助成熟的 JVM 生态，比如可以不用自己写垃圾收集器，还能够调用丰富的 Java 类库。如果你觉得理解 C 语言的代码比较困难，你也可以去看看这两个版本的实现。

在 Python 的“开发者指南”网站上，有不少关于 Python 内部实现机制的技术资料。请注意，这里的开发者，指的是有兴趣参与 Python 语言开发的程序员，而不是 Python 语言的使用者。这就是像 Python 这种开源项目的优点，它欢迎热爱 Python 的程序员来修改和增强 Python 语言，甚至你还可以增加一些自己喜欢的语言特性。

根据开发者指南的指引，你可以编译一下 Python 的源代码。注意，你要用调试模式来编译，因为接下来我们要跟踪 Python 编译器的运行过程。这就要使用调试工具 GDB。

GDB 是 GNU 的调试工具，做 C 语言开发的人一般都会使用这个工具。它支持通过命令行调试程序，包括设置断点、单步跟踪、观察变量的值等，这跟你在 IDE 里调试程序的操作很相似。

开发者指南中有如何用调试模式编译 Python，并如何跟 GDB 配合使用的信息。实际上，GDB 现在可以用 Python 来编写扩展，从而给我们带来更多的便利。比如，我们在调试 Python 编译器的时候，遇到 Python 对象的指针（PyObject*），就可以用更友好的方式来显示 Python 对象的信息。

好了，接下来我们就通过跟踪 Python 编译器执行过程，看看它在编译过程中都涉及了哪些主要的程序模块。

在 tokenizer.c 的 tok_get() 函数中打一个断点，通过 GDB 观察 Python 的运行，你会发现下面的调用顺序（用 bt 命令打印输出后整理的结果）：

这个过程是运行 Python 并执行到词法分析环节，你可以看到完整的程序执行路径：

首先是 python.c，这个文件很短，只是提供了一个 main() 函数。你运行 python 命令的时候，就会先进入这里。

接着进入 Modules/main.c 文件，这个文件里提供了运行环境的初始化等功能，它能执行一个 python 文件，也能启动 REPL 提供一个交互式界面。

之后是 Python/pythonrun.c 文件，这是 Python 的解释器，它调用词法分析器、语法分析器和字节码生成功能，最后解释执行。

再之后来到 Parser 目录的 parsetok.c 文件，这个文件会调度词法分析器和语法分析器，完成语法分析过程，最后生成 AST。

最后是 toknizer.c，它是词法分析器的具体实现。

拓展：REPL 是 Read-Evaluate-Print-Loop 的缩写，也就是通过一个交互界面接受输入并回显结果。

通过上述的跟踪过程，我们就进入了 Python 的词法分析功能。下面我们就来看一下它是怎么实现的，再一次对词法分析的原理做一下印证。

Python 的词法分析功能

首先，你可以看一下 tokenizer.c 的 tok_get() 函数。你一阅读源代码，就会发现，这是我们很熟悉的一个结构，它也是通过有限自动机把字符串变成 Token。

你还可以用另一种更直接的方法来查看 Python 词法分析的结果。

./python.exe -m tokenize -e foo.py

补充：其中的 python.exe 指的是 Python 的可执行文件，如果是在 Linux 系统，可执行文件是 python。

运行上面的命令会输出所解析出的 Token：

其中的第二列是 Token 的类型，第三列是 Token 对应的字符串。各种 Token 类型的定义，你可以在 Grammar/Tokens 文件中找到。

我们曾在研究Java 编译器的时候，探讨过如何解决关键字和标识符的词法规则冲突的问题。那么 Python 是怎么实现的呢？

原来，Python 在词法分析阶段根本没有区分这两者，只是都是作为“NAME”类型的 Token 来对待。

补充：Python 里面有两个词法分析器，一个是用 C 语言实现的（tokenizer.c），一个是用 Python 实现的（tokenizer.py）。C 语言版本的词法分析器由编译器使用，性能更高。

所以，Python 的词法分析功能也比较常规。其实你会发现，每个编译器的词法分析功能都大同小异，你完全可以借鉴一个比较成熟的实现。Python 跟 Java 的编译器稍微不同的一点，就是没有区分关键字和标识符。

接下来，我们来关注下这节课的重点内容：语法分析功能。

Python 的语法分析功能

在 GDB 中继续跟踪执行过程，你会在 parser.c 中找到语法分析的相关逻辑：

那么，Python 的语法分析有什么特点呢？它采用的是什么算法呢？是自顶向下的算法，还是自底向上的算法？

首先，我们到 Grammar 目录，去看一下 Grammar 文件。这是一个用 EBNF 语法编写的 Python 语法规则文件，下面是从中节选的几句，你看是不是很容易读懂呢？

//声明函数
funcdef: 'def' NAME parameters ['->' test] ':' [TYPE_COMMENT] func_body_suite
//语句
simple_stmt: small_stmt (';' small_stmt)* [';'] NEWLINE
small_stmt: (expr_stmt | del_stmt | pass_stmt | flow_stmt |
             import_stmt | global_stmt | nonlocal_stmt | assert_stmt)

通过阅读规则文件，你可以精确地了解 Python 的语法规则。

这个规则文件是给谁用的呢？实际上 Python 的编译器本身并不使用它，它是给一个pgen 的工具程序（Parser/pgen）使用的。这个程序能够基于语法规则生成解析表（Parse Table），供语法分析程序使用。有很多工具能帮助你生成语法解析器，包括 yacc（GNU 版本是 bison）、ANTLR 等。

有了 pgen 这个工具，你就可以通过修改规则文件来修改 Python 语言的语法，比如，你可以把函数声明中的关键字“def”换成“function”，这样你就可以用新的语法来声明函数。

pgen 能给你生成新的语法解析器。parser.c 的注释中讲解了它的工作原理。它是把 EBNF 转化成一个 NFA，然后再把这个 NFA 转换成 DFA。基于这个 DFA，在读取 Token 的时候，编译器就知道如何做状态迁移，并生成解析树。

这个过程你听上去是不是有点熟悉？实际上，我们在第 2 讲讨论正则表达式工具的时候，就曾经把正则表达式转化成了 NFA 和 DFA。基于这个技术，我们既可以做词法解析，也可以做语法解析。

实际上，Python 用的是 LL(1) 算法。我们来回忆一下 LL(1) 算法的特点：针对每条语法规则，最多预读一个 Token，编译器就可以知道该选择哪个产生式。这其实就是一个 DFA，从一条语法规则，根据读入的 Token，迁移到下一条语法规则。

我们通过一个例子来看一下 Python 的语法分析特点，这里采用的是我们熟悉的一个语法规则：

add: mul ('+' mul)*
mul: pri ('*' pri)*
pri: IntLiteral | '(' add ')'

我把这些语法规则对应的 DFA 画了出来。你会看到，它跟采用递归下降算法的思路是一样的，只不过换了种表达方式。

add: mul ('+' mul)*对应的DFA

mul: pri ('*' pri)*对应的DFA

pri: IntLiteral | '(' add ')'对应的DFA

不过，跟手写的递归下降算法为解析每个语法规则写一个函数不同，parser.c 用了一个通用的函数去解析所有的语法规则，它所依据的就是为每个规则所生成的 DFA。

主要的实现逻辑是在 parser.c 的 PyParser_AddToken() 函数里，你可以跟踪它的实现过程。为了便于你理解，我模仿 Python 编译器，用上面的文法规则解析了一下“2+3*4+5”，并把整个解析过程画成图。

在解析的过程，我用了一个栈作为一个工作区，来保存当前解析过程中使用的 DFA。

第 1 步，匹配 add 规则。把 add 对应的 DFA 压到栈里，此时该 DFA 处于状态 0。这时候预读了一个 Token，是字面量 2。

第 2 步，根据 add 的 DFA，走 mul-1 这条边，去匹配 mul 规则。这时把 mul 对应的 DFA 入栈。在示意图中，栈是从上往下延伸的。

第 3 步，根据 mul 的 DFA，走 pri-1 这条边，去匹配 pri 规则。这时把 pri 对应的 DFA 入栈。

第 4 步，根据 pri 的 DFA，因为预读的 Token 是字面量 2，所以移进这个字面量，并迁移到状态 3。同时，为字面量 2 建立解析树的节点。这个时候，又会预读下一个 Token，'+'号。

第 5 步，从栈里弹出 pri 的 DFA，并建立 pri 节点。因为成功匹配了一个 pri，所以 mul 的 DFA 迁移到状态 1。

第 6 步，因为目前预读的 Token 是'+'号，所以 mul 规则匹配完毕，把它的 DFA 也从栈里弹出。而 add 对应的 DFA 也迁移到了状态 1。

第 7 步，移进'+'号，把 add 的 DFA 迁移到状态 2，预读了下一个 Token：字面量 3。这个 Token 是在 mul 的 First 集合中的，所以就走 mul-2 边，去匹配一个 mul。

按照这个思路继续做解析，直到最后，可以得到完整的解析树：

总结起来，Python 编译器采用了一个通用的语法分析程序，以一个栈作为辅助的数据结构，来完成各个语法规则的解析工作。当前正在解析的语法规则对应的 DFA，位于栈顶。一旦当前的语法规则匹配完毕，那语法分析程序就可以把这个 DFA 弹出，退回到上一级的语法规则。

所以说，语法解析器生成工具，会基于不同的语法规则来生成不同的 DFA，但语法解析程序是不变的。这样，你随意修改语法规则，都能够成功解析。

上面我直观地给你解读了一下解析过程。你可以用 GDB 来跟踪一下 PyParser_AddToken() 函数，从而了解得更具体。你在这个函数里，还能够看到像下面这样的语句，这是对外输出调试信息。

D(printf(" Push '%s'\n", d1->d_name));   //把某DFA入栈

你还可以用“-d”参数运行 python，然后在 REPL 里输入程序，这样它就能打印出这些调试信息，包括什么时候把 DFA 入栈、什么时候出栈，等等。我截取了一部分输出信息，你可以看一下。

在 Python 的语法规则里，arith_expr 指的是加减法的表达式，term 指的是乘除法的表达式，atom 指的是基础表达式。这套词汇也经常被用于语法规则中，你可以熟悉起来。

好了，现在你已经知道了语法解析的过程。不过你可能注意到了，上面的语法解析过程形成的结果，我没有叫做是 AST，而是叫做解析树（Parse Tree）。看到这里，你可能会产生疑问：解析源代码不就会产生 AST 吗？怎么这里是生成一个叫做解析树的东西？什么是解析树，它跟 AST 有啥区别？别着急，下面我就来为你揭晓答案。

解析树和 AST 的区别

解析树又可以叫做 CST（Concret Syntax Tree，具体语法树），与 AST（抽象语法树）是相对的：一个具体，一个抽象。

它俩的区别在于：CST 精确地反映了语法规则的推导过程，而 AST 则更准确地表达了程序的结构。如果说 CST 是“形似”，那么 AST 就是“神似”。

你可以看看在前面的这个例子中，所形成的 CST 的特点。

首先，加法是个二元运算符，但在这里 add 节点下面对应了两个加法运算符，跟原来加法的语义不符。第二，很多节点都只有一个父节点，这个其实可以省略，让树结构更简洁。

所以，我们期待的 AST 其实是这样的：

这就是 CST 和 AST 的区别。

理解了这个知识点以后，我们拿 Python 实际的 CST 和 AST 来做一下对比。在 Python 的命令行中，输入下面的命令：

>>> from pprint import pprint
>>> import parser
>>> cst = parser.expr('2+3+4')  //对加法表达式做解析
>>> pprint(parser.st2list(cst)) //以美观的方式打印输出CST

你会得到这样的输出结果：

这是用缩进的方式显示了 CST 的树状结构，其中的数字是符号和 Token 的编号。你可以从 Token 的字典（dict）里把它查出来，从而以更加直观的方式显示 CST。

我们借助一个 lex 函数来做美化的工作。现在再显示一下 CST，就更直观了：

那么，Python 把 CST 转换成 AST，会是什么样子呢？

你可以在命令行敲入下面的代码，来显示 AST。它虽然是以文本格式显示的，但你能发现它是一个树状结构。这个树状结构就很简洁：

如果你嫌这样不够直观，还可以用另一个工具“instaviz”，在命令行窗口用 pip 命令安装 instaviz 模块，以图形化的方式更直观地来显示 AST。instaviz 是“Instant Visualization”（立即可视化）的意思，它能够图形化显示 AST。

$ pip install instaviz

然后启动 Python，并敲入下面的代码：

instaviz 会启动一个 Web 服务器，你可以在浏览器里通过 http://localhost:8080 来访问它，里面有图形化的 AST。你可以看到，这个 AST 比起 CST 来，确实简洁太多了。

点击代表“2+3*4+5”表达式的节点，你可以看到这棵子树的各个节点的属性信息：

总结起来，在编译器里，我们经常需要把源代码转变成 CST，然后再转换成 AST。生成 CST 是为了方便编译器的解析过程。而转换成 AST 后，会让树结构更加精简，并且在语义上更符合语言原本的定义。

那么，Python 是如何把 CST 转换成 AST 的呢？这个过程分为两步。

首先，Python 采用了一种叫做 ASDL 的语言，来定义了 AST 的结构。ASDL是“抽象语法定义语言（Abstract Syntax Definition Language）”的缩写，它可以用于描述编译器中的 IR 以及其他树状的数据结构。你可能不熟悉 ASDL，但可能了解 XML 和 JSON 的 Schema，你可以通过 Schema 来定义 XML 和 JSON 的合法的结构。另外还有 DTD、EBNF 等，它们的作用都是差不多的。

这个定义文件是 Parser/Python.asdl。CPython 编译器中包含了两个程序（Parser/asdl.py 和 Parser/asdl_c.py）来解析 ASDL 文件，并生成 AST 的数据结构。最后的结果在 Include/Python-ast.h 文件中。

到这里，你可能会有疑问：这个 ASDL 文件及解析程序不就是生成了 AST 的数据结构吗？为什么不手工设计这些数据结构呢？有必要采用一种专门的 DSL 来做这件事情吗？

确实如此。Java 语言的 AST，只是采用了手工设计的数据结构，也没有专门用一个 DSL 来生成。

但 Python 这样做确实有它的好处。上一讲我们说过，Python 的编译器有多种语言的实现，因此基于统一的 ASDL 文件，我们就可以精准地生成不同语言下的 AST 的数据结构。

在有了 AST 的数据结构以后，第二步，是把 CST 转换成 AST，这个工作是在 Python/ast.c 中实现的，入口函数是 PyAST_FromNode()。这个算法是手写的，并没有办法自动生成。

课程小结

今天这一讲，我们开启了对 Python 编译器的探究。我想给你强调下面几个关键要点：

非自举。CPython 的编译器是用 C 语言编写的，而不是用 Python 语言本身。编译器和核心库采用 C 语言会让它性能更高，并且更容易与各种二进制工具集成。

善用 GDB。使用 GDB 可以跟踪 CPython 编译器的执行过程，加深对它的内部机制的理解，加快研究的速度。

编译器生成工具 pgen。pgen 能够根据语法规则生成解析表，让修改语法的过程变得更加容易。

基于 DFA 的语法解析过程。基于 pgen 生成的解析表，通过 DFA 驱动完成语法解析过程，整个执行过程跟递归下降算法的原理相同，但只需要一个通用的解析程序即可。

从 CST 到 AST。语法分析首先生成 CST，接着生成 AST。CST 准确反映了语法推导的过程，但会比较啰嗦，并且可能不符合语义。AST 同样反映了程序的结构，但更简洁，并且支持准确的语义。

本讲的思维导图我也放在这里了，供你参考：

一课一思

这一讲我们提到，Python 的词法分析器没有区分标识符和关键字，但这样为什么没有影响到 Python 的语法分析的功能呢？你可以结合语法规则文件和对语法解析过程的理解，谈谈你的看法。如果你能在源代码里找到确定的答案，那就更好了！

欢迎你在留言区中分享你的见解，也欢迎你把今天的内容分享给更多的朋友，我们下一讲再见。

参考资料

GDB 的安装和配置：参考这篇文章。

18 | Python编译器（二）：从AST到字节码

你好，我是宫文学。

今天这一讲，我们继续来研究 Python 的编译器，一起来看看它是如何做语义分析的，以及是如何生成字节码的。学完这一讲以后，你就能回答出下面几个问题了：

像 Python 这样的动态语言，在语义分析阶段都要做什么事情呢，跟 Java 这样的静态类型语言有什么不同？

Python 的字节码有什么特点？生成字节码的过程跟 Java 有什么不同？

好了，让我们开始吧。首先，我们来了解一下从 AST 到生成字节码的整个过程。

编译过程

Python 编译器把词法分析和语法分析叫做“解析（Parse）”，并且放在 Parser 目录下。而从 AST 到生成字节码的过程，才叫做“编译（Compile）”。当然，这里编译的含义是比较狭义的。你要注意，不仅是 Python 编译器，其他编译器也是这样来使用这两个词汇，包括我们已经研究过的 Java 编译器，你要熟悉这两个词汇的用法，以便阅读英文文献。

Python 的编译工作的主干代码是在 Python/compile.c 中，它主要完成 5 项工作。

第一步，检查future 语句。future 语句是 Python 的一个特性，让你可以提前使用未来版本的特性，提前适应语法和语义上的改变。这显然会影响到编译器如何工作。比如，对于“8/7”，用不同版本的语义去处理，得到的结果是不一样的。有的会得到整数“1”，有的会得到浮点数“1.14285…”，编译器内部实际上是调用了不同的除法函数。

第二步，建立符号表。

第三步，为基本块产生指令。

第四步，汇编过程：把所有基本块的代码组装在一起。

第五步，对字节码做窥孔优化。

其中的第一步，它是 Python 语言的一个特性，但不是我们编译技术关注的重点，所以这里略过。我们从建立符号表开始。

语义分析：建立符号表和引用消解

通常来说，在语义分析阶段首先是建立符号表，然后在此基础上做引用消解和类型检查。

而 Python 是动态类型的语言，类型检查应该是不需要了，但引用消解还是要做的。并且你会发现，Python 的引用消解有其独特之处。

首先，我们来看看 Python 的符号表是一个什么样的数据结构。在 Include/symtable.h 中定义了两个结构，分别是符号表和符号表的条目：

图 1：符号表和符号表条目

在编译的过程中，针对每个模块（也就是一个 Python 文件）会生成一个符号表（symtable）。

Python 程序被划分成“块（block）”，块分为三种：模块、类和函数。每种块其实就是一个作用域，而在 Python 里面还叫做命名空间。每个块对应一个符号表条目（PySTEntryObject），每个符号表条目里存有该块里的所有符号（ste_symbols）。每个块还可以有多个子块（ste_children），构成树状结构。

在符号表里，有一个 st_blocks 字段，这是个字典，它能通过模块、类和函数的 AST 节点，查找到 Python 程序的符号表条目，通过这种方式，就把 AST 和符号表关联在了一起。

我们来看看，对于下面的示例程序，它对应的符号表是什么样子的。

a = 2 #模块级变量
class myclass:
    def __init__(self, x):
        self.x = x
    def foo(self, b):
        c = a + self.x + b  #引用了外部变量a
        return c

这个示例程序有模块、类和函数三个级别的块。它们分别对应一条符号表条目。

图 2：示例程序对应的符号表

你可以看到，每个块里都有 ste_symbols 字段，它是一个字典，里面保存了本命名空间涉及的符号，以及每个符号的各种标志位（flags）。关于标志位，我下面会给你解释。

然后，我们再看看针对这个示例程序，符号表里的主要字段的取值：

好了，通过这样一个例子，你大概就知道了 Python 的符号表是怎样设计的了。下面我们来看看符号表的建立过程。

建立符号表的主程序是 Python/symtable.c 中的 PySymtable_BuildObject() 函数。

Python 建立符号表的过程，需要做两遍处理，如下图所示。

图 3：Python 建立符号表的过程

第一遍，主要做了两件事情。第一件事情是建立一个个的块（也就是符号表条目），并形成树状结构，就像示例程序那样；第二件事情，就是给块中的符号打上一定的标记（flag）。

我们用 GDB 跟踪一下第一遍处理后生成的结果。你可以参考下图，看一下我在 Python 的 REPL 中的输入信息：

我在 symtable_add_def_helper() 函数中设置了断点，便于调试。当编译器处理到 foo 函数的时候，我在 GDB 中打印输出了一些信息：

在这些输出信息中，你能看到前面我给你整理的表格中的信息，比如，符号表中各个字段的取值。

我重点想让你看的，是 foo 块中各个符号的标志信息：self 和 b 是 20，c 是 2，a 是 16。这是什么意思呢？

ste_symbols = {'self': 20, 'b': 20, 'c': 2, 'a': 16}

这就需要看一下 symtable.h 中，对这些标志位的定义：

我给你整理成了一张更容易理解的图，你参考一下：

图 4：符号标志信息中每个位的含义

根据上述信息，你会发现 self 和 b，其实是被标记了 3 号位和 5 号位，意思是这两个变量是函数参数，并且在 foo 中被使用。而 a 只标记了 5 号位，意思是 a 这个变量在 foo 中被使用，但这个变量又不是参数，所以肯定是来自外部作用域的。我们再看看 c，c 只在 2 号位被标记，表示这个变量在 foo 里被赋值了。

到目前为止，第一遍处理就完成了。通过第一遍处理，我们会知道哪些变量是本地声明的变量、哪些变量在本块中被使用、哪几个变量是函数参数等几方面的信息。

但是，现在还有一些信息是不清楚的。比如，在 foo 中使用了 a，那么外部作用域中是否有这个变量呢？这需要结合上下文做一下分析。

还有，变量 c 是在 foo 中赋值的。那它是本地变量，还是外部变量呢？

在这里，你能体会出 Python 语言使用变量的特点：由于变量在赋值前，可以不用显式声明。所以你不知道这是新声明了一个变量，还是引用了外部变量。

正由于 Python 的这个特点，所以它在变量引用上有一些特殊的规定。

比如，想要在函数中给全局变量赋值，就必须加 global 关键字，否则编译器就会认为这个变量只是一个本地变量。编译器会给这个符号的 1 号位做标记。

而如果给其他外部作用域中的变量赋值，那就必须加 nonlocal 关键字，并在 3 号位上做标记。这时候，该变量就是一个自由变量。在闭包功能中，编译器还要对自由变量的存储做特殊的管理。

接下来，编译器会做第二遍的分析（见 symtable_analyze() 函数）。在这遍分析里，编译器会根据我们刚才说的 Python 关于变量引用的语义规则，分析出哪些是全局变量、哪些是自由变量，等等。这些信息也会被放到符号的标志位的第 12~15 位。

ste_symbols = {'self': 2068, 'b': 2068, 'c': 2050, 'a': 6160}

图 5：symtable.h 中对作用域的标志位

以变量 a 为例，它的标志值是 6160，也就是二进制的 1100000010000。其标记位设置如下，其作用域的标志位是 3，也就是说，a 是个隐式的全局变量。而 self、b 和 c 的作用域标志位都是 1，它们的意思是本地变量。

图 6：作用域的标志位

在第二遍的分析过程中，Python 也做了一些语义检查。你可以搜索一下 Python/symtable.c 的代码，里面有很多地方会产生错误信息，比如“nonlocal declaration not allowed at module level（在模块级不允许非本地声明）”。

另外，Python 语言提供了访问符号表的 API，方便你直接在 REPL 中，来查看编译过程中生成的符号表。你可以参考我的屏幕截图：

好了，现在符号表已经生成了。基于 AST 和符号表，Python 编译器就可以生成字节码。

生成 CFG 和指令

我们可以用 Python 调用编译器的 API，来观察字节码生成的情况：

>>> co = compile("a+2", "test.py", "eval")  //编译表达式"a+2"
>>> dis.dis(co.co_code)                     //反编译字节码
          0 LOAD_NAME                0 (0)  //装载变量a
          2 LOAD_CONST               0 (0)  //装载常数2
          4 BINARY_ADD                      //执行加法
          6 RETURN_VALUE                    //返回值

其中的 LOAD_NAME、LOAD_CONST、BINARY_ADD 和 RETURN_VALUE 都是字节码的指令。

对比一下，Java 的字节码的每个指令只有一个字节长，这意味着指令的数量不会超过 2 的 8 次方（256）个。

Python 的指令一开始也是一个字节长的，后来变成了一个字（word）的长度，但我们仍然习惯上称为字节码。Python 的在线文档里有对所有字节码的说明，这里我就不展开了，感兴趣的话你可以自己去看看。

并且，Python 和 Java 的虚拟机一样，都是基于栈的虚拟机。所以，它们的指令也很相似。比如，加法操作的指令是不需要带操作数的，因为只需要取出栈顶的两个元素相加，把结果再放回栈顶就行了。

进一步，你可以对比一下这两种语言的字节码，来看看它们的异同点，并且试着分析一下原因。

这样对比起来，你可以发现，它们主要的区别就在于，Java 的字节码对不同的数据类型会提供不同的指令，而 Python 则不加区分。因为 Python 对所有的数值，都会提供统一的计算方式。

所以你可以看出，一门语言的 IR，是跟这门语言的设计密切相关的。

生成 CFG 和字节码的代码在 Python/compile.c 中。调用顺序如下：

总的逻辑是：以 visit 模式遍历整个 AST，并建立基本块和指令。对于每种 AST 节点，都由相应的函数来处理。

以 compiler_visit_expr1() 为例，对于二元操作，编译器首先会递归地遍历左侧子树和右侧子树，然后根据结果添加字节码的指令。

compiler_visit_expr1(struct compiler *c, expr_ty e)
{
    switch (e->kind) {
...
.
    case BinOp_kind:
        VISIT(c, expr, e->v.BinOp.left);    //遍历左侧子树
        VISIT(c, expr, e->v.BinOp.right);   //遍历右侧子树
        ADDOP(c, binop(c, e->v.BinOp.op));  //添加二元操作的指令
        break;
...
}

那么基本块是如何生成的呢？

编译器在进入一个作用域的时候（比如函数），至少要生成一个基本块。而像循环语句、if 语句，还会产生额外的基本块。

所以，编译的结果，会在 compiler 结构中保存一系列的基本块，这些基本块相互连接，构成 CFG；基本块中又包含一个指令数组，每个指令又包含操作码、参数等信息。

图 7：基本块和指令

为了直观理解，我设计了一个简单的示例程序。foo 函数里面有一个 if 语句，这样会产生多个基本块。

def foo(a):
    if a > 10 :
        b = a
    else:
        b = 10
    return b

通过 GDB 跟踪编译过程，我们发现，它生成的 CFG 如下图所示：

图 8：示例程序对应的 CFG

在 CFG 里，你要注意两组箭头。

实线箭头是基本块之间的跳转关系，用 b_next 字段来标记。虚线箭头能够基于 b_list 字段把所有的基本块串起来，形成一个链表，每一个新生成的基本块指向前一个基本块。只要有一个指针指向最后一个基本块，就能访问所有的基本块。

你还可以通过 GDB 来查看每个基本块中的指令分别是什么，这样你就会理解每个基本块到底干了啥。不过我这里先给你留个小伏笔，在下一个小节讲汇编的时候一起给你介绍。

到目前为止，我们已经生成了 CFG 和针对每个基本块的指令数组。但我们还没有生成最后的字节码。这个任务，是由汇编（Assembly）阶段负责的。

汇编（Assembly）

汇编过程是在 Python/compiler.c 中的 assemble() 函数中完成的。听名字，你会感觉这个阶段做的事情似乎应该比较像汇编语言的汇编器的功能。也确实如此。汇编语言的汇编器，能够生成机器码；而 Python 的汇编阶段，是生成字节码，它们都是生成目标代码。

具体来说，汇编阶段主要会完成以下任务：

把每个基本块的指令对象转化成字节码。

把所有基本块的字节码拼成一个整体。

对于从一个基本块跳转到另一个基本块的 jump 指令，它们有些采用的是相对定位方式，比如往前跳几个字的距离。这个时候，编译器要计算出正确的偏移值。

生成 PyCodeObject 对象，这个对象里保存着最后生成的字节码和其他辅助信息，用于 Python 的解释器执行。

我们还是通过示例程序，来直观地看一下汇编阶段的工作成果。你可以参照下图，使用 instaviz 工具看一下 foo 函数的编译结果。

在 PyCodeObject 对象中，co_code 字段是生成的字节码（用 16 进制显示）。你还能看到常量表和变量表，这些都是在解释器中运行所需要的信息。

如果把 co_code 字段的那一串字节码反编译一下，你会得到下面的信息：

你会看到，一共 11 条指令，其中 BB1 是 7 条，BB2 和 BB3 各 2 条。BB1 里面是 If 条件和 if 块中的内容，BB2 对应的是 else 块的内容，BB3 则对应 return 语句。

不过，如果你对照基本块的定义，你其实会发现，BB1 不是一个标准的基本块。因为一般来说，标准的基本块只允许在最后一个语句做跳转，其他语句都是顺序执行的。而我们看到第 4 个指令“POP_JUMP_IF_FALSE 14”其实是一个条件跳转指令。

尽管如此，因为 Python 把 CFG 只是作为生成字节码的一个中间结构，并没有基于 CFG 做数据流分析和优化，所以虽然基本块不标准，但是对 Python 的编译过程并无影响。

你还会注意到第 7 行指令“JUMP_FORWARD”，这个指令是一个基于相对位置的跳转指令，它往前跳 4 个字，就会跳到 BB3。这个跳转距离就是在 assemble 阶段去计算的，这个距离取决于你如何在代码里排列各个基本块。

好了，到目前为止，字节码已经生成了。不过，在最后放到解释器里执行之前，编译器还会再做一步窥孔优化工作。

窥孔优化

说到优化，总体来说，在编译的过程中，Python 编译器的优化功能是很有限的。在 compiler.c 的代码中，你会看到一点优化逻辑。比如，在为 if 语句生成指令的时候，编译器就会看看 if 条件是否是个常数，从而不必生成 if 块或者 else 块的代码。

另一个优化机会，就是在字节码的基础上优化，这就是窥孔优化，其实现是在 Python/peephole.c 中。它能完成的优化包括：

把多个 LOAD_CONST 指令替换为一条加载常数元组的指令。

如果一个跳转指令，跳到 return 指令，那么可以把跳转指令直接替换成 return 指令。

如果一个条件跳转指令，跳到另一个条件跳转指令，则可以基于逻辑运算的规则做优化。比如，“x:JUMP_IF_FALSE_OR_POP y”和“y:JUMP_IF_FALSE_OR_POP z”可以直接简化为“x:JUMP_IF_FALSE_OR_POP z”。这是什么意思呢？第一句是依据栈顶的值做判断，如果为 false 就跳转到 y。而第二句，继续依据栈顶的值做判断，如果为 false 就跳转到 z。那么，简化后，可以直接从第一句跳转到 z。

去掉 return 指令后面的代码。

……

在做优化的时候，窥孔优化器会去掉原来的指令，替换成新的指令。如果有多余出来的位置，则会先填充 NOP 指令，也就是不做任何操作。最后，才扫描一遍整个字节码，把 NOP 指令去掉，并且调整受影响的 jump 指令的参数。

课程小结

今天这一讲，我们继续深入探索 Python 的编译之旅。你需要记住以下几点：

Python 通过一个建立符号表的过程来做相关的语义分析，包括做引用消解和其他语义检查。由于 Python 可以不声明变量就直接使用，所以编译器要能识别出正确的“定义 - 使用”关系。

生成字节码的工作实际上包含了生成 CFG、为每个基本块生成指令，以及把指令汇编成字节码，并生成 PyCodeObject 对象的过程。

窥孔优化器在字节码的基础上做了一些优化，研究这个程序，会让你对窥孔优化的认识变得具象起来。

按照惯例，我把这一讲的思维导图也整理出来了，供你参考：

一课一思

在语义分析过程中，针对函数中的本地变量，Python 编译器没有像 Java 编译器那样，一边添加符号，一边做引用消解。这是为什么？请在留言区分享你的观点。

如果你觉得有收获，欢迎你把今天的内容分享给更多的朋友。

参考资料

Python字节码的说明。

19 | Python编译器（三）：运行时机制

你好，我是宫文学。

在前面两讲中，我们已经分析了 Python 从开始编译到生成字节码的机制。但是，我们对 Python 只是了解了一半，还有很多问题需要解答。比如：Python 字节码是如何运行的呢？它是如何管理程序所用到的数据的？它的类型体系是如何设计的，有什么特点？等等。

所以今天这一讲，我们就来讨论一下 Python 的运行时机制。其中的核心，是 Python 对象机制的设计。

我们先来研究一下字节码的运行机制。你会发现，它跟 Python 的对象机制密切相关。

理解字节码的执行过程

我们用 GDB 跟踪执行一个简单的示例程序，它只有一行：“a=1”。

这行代码对应的字节码如下。其中，前两行指令实现了“a=1”的功能（后两行是根据 Python 的规定，在执行完一个模块之后，缺省返回一个 None 值）。

你需要在 _PyEval_EvalFrameDefault() 函数这里设置一个断点，在这里实际解释指令并执行。

首先是执行第一行指令，LOAD_CONST。

你会看到，解释器做了三件事情：

从常数表里取出 0 号常数。你知道，编译完毕以后会形成 PyCodeObject，而在这个对象里会记录所有的常量、符号名称、本地变量等信息。常量 1 就是从它的常量表中取出来的。

把对象引用值加 1。对象引用跟垃圾收集机制相关。

把这个常数对象入栈。

从这第一行指令的执行过程，你能得到什么信息呢？

第一个信息，常量 1 在 Python 内部，它是一个对象。你可以在 GDB 里显示这个对象的信息：该对象的类型是 PyLong_Type 型，这是 Python 的整型在内部的实现。

另外，该对象的引用数是 126 个，说明这个常量对象其实是被共享的，LOAD_CONST 指令会让它的引用数加 1。我们用的常数是 1，这个值在 Python 内部也是会经常被用到，所以引用数会这么高。你可以试着选个不那么常见的常数，看看它的引用数是多少，都是在哪里被引用的。

进一步，我们会发现，往栈里放的数据，其实是个对象指针，而不像 Java 的栈机那样，是放了个整数。

总结上述信息，我其实可以告诉你一个结论：在 Python 里，程序中的任何符号都是对象，包括整数、浮点数这些基础数据，或者是自定义的类，或者是函数，它们都是对象。在栈机里处理的，是这些对象的引用。

我们再继续往下分析一条指令，也就是 STORE_NAME 指令，来加深一下对 Python 运行机制的理解。

执行 STORE_NAME 指令时，解释器做了 5 件事情：

根据指令的参数，从名称表里取出变量名称。这个名称表也是来自于 PyCodeObject。前面我刚说过了，Python 程序中的一切都是对象，那么 name 也是对象。你可以查看它的类型，是 PyUnicode_Type，也就是 Unicode 的字符串。

从栈顶弹出上一步存进去的常量对象。

获取保存了所有本地变量的字典，这也是来自 PyCodeObject。

在字典里，设置 a 的值为该常量。如果你深入跟踪其执行过程，你会发现在存入字典的时候，name 对象和 v 对象的引用都会加 1。这也是可以理解的，因为它们一个作为 key，一个作为 value，都要被字典所引用。

减少常量对象的引用计数。意思是栈机本身不再引用该常量。

好了，通过详细解读这两条指令的执行过程，我相信你对 Python 的运行机制摸到一点头绪了，但可能还是会提出很多问题来，比如说：

既然栈里的操作数都是对象指针，那么如何做加减乘除等算术运算？

如果函数也是对象，那么执行函数的过程又是怎样的？

……

别着急，我在后面会带你探究清楚这些问题。不过在此之前，我们有必要先加深一下对 Python 对象的了解。

Python 对象的设计

Python 的对象定义在 object.h 中。阅读文件头部的注释和对各类数据结构的定义，你就可以理解 Python 对象的设计思路。

首先是 PyObject 和 PyVarObject 两个基础的数据结构，它们分别表示定长的数据和变长的数据。

typedef struct _object {          //定长对象
    Py_ssize_t ob_refcnt;         //对象引用计数
    struct _typeobject *ob_type;  //对象类型
} PyObject;

typedef struct {         //变长对象
    PyObject ob_base;
    Py_ssize_t ob_size;  //变长部分的项目数量，在申请内存时有确定的值，不再变
} PyVarObject;

PyObject 是最基础的结构，所有的对象在 Python 内部都表示为一个 PyObject 指针。它里面只包含两个成员：对象引用计数（ob_refcnt）和对象类型（ob_type），你在用 GDB 跟踪执行时也见过它们。可能你会问，为什么只有这两个成员呢？对象的数据（比如一个整数）保存在哪里？

实际上，任何对象都会在一开头包含 PyObject，其他数据都跟在 PyObject 的后面。比如说，Python3 的整数的设计是一个变长对象，会用一到多个 32 位的段，来表示任意位数的整数：

#define PyObject_VAR_HEAD      PyVarObject ob_base;
struct _longobject {
    PyObject_VAR_HEAD       //PyVarObject
    digit ob_digit[1];      //数字段的第一个元素
};
typedef struct _longobject PyLongObject;    //整型

它在内存中的布局是这样的：

图 1：Python3 的整数对象的内存布局

所以你会看出，PyObject*、PyVarObject*和PyLongObject*指向的内存地址是相同的。你可以根据 ob_type 的类型，把PyObject*强制转换成PyLongObject*。

实际上，像 C++ 这样的面向对象语言的内存布局也是如此，父类的成员变量在最前面，子类的成员变量在后面，父类和子类的指针之间可以强制转换。懂得了这个原理，我们用 C 语言照样可以模拟出面向对象的继承结构出来。

你可以注意到，我在图 1 中标出了每个字段所占内存的大小，总共是 28 个字节（这是 64 位 macOS 下的数值，如果是另外的环境，比如 32 位环境，数值会有所不同）。

你可以用 sys.getsizeof() 函数，来测量对象占据的内存空间。

>>> a = 10
>>> import sys
>>> sys.getsizeof(a)
28       #ob_size = 1
>>> a = 1234567890
>>> sys.getsizeof(a)
32       #ob_size = 2，支持更大的整数

到这里，我们总结一下 Python 对象设计的三个特点：

1. 基于堆

Python 对象全部都是在堆里申请的，没有静态申请和在栈里申请的。这跟 C、C++ 和 Java 这样的静态类型的语言很不一样。

C 的结构体和 C++ 的对象都既可以在栈里，也可以在堆里；Java 也是一样，除了原生数据类型可以在栈里申请，未逃逸的 Java 对象的内存也可以在栈里管理，我们在讲Java 的 JIT 编译器的时候已经讲过了。

2. 基于引用计数的垃圾收集机制

每个 Python 对象会保存一个引用计数。也就是说，Python 的垃圾收集机制是基于引用计数的。

它的优点是可以实现增量收集，只要引用计数为零就回收，避免出现周期性的暂停；缺点是需要解决循环引用问题，并且要经常修改引用计数（比如在每次赋值和变量超出作用域的时候），开销有点大。

3. 唯一 ID

每个 Python 对象都有一个唯一 ID，它们在生存期内是不变的。用 id() 函数就可以获得对象的 ID。根据 Python 的文档，这个 ID 实际就是对象的内存地址。所以，实际上，你不需要在对象里用一个单独的字段来记录对象 ID。这同时也说明，Python 对象的地址在整个生命周期内是不会变的，这也符合基于引用计数的垃圾收集算法。对比一下，如果采用“停止和拷贝”的算法，对象在内存中会被移动，地址会发生变化。所以你能看出，ID 的算法与垃圾收集算法是环环相扣的。

>>> a = 10
>>> id(a)
140330839057200

接下来，我们看看 ob_type 这个字段，它指向的是对象的类型。以这个字段为线索，我们就可以牵出 Python 的整个类型系统的设计。

Python 的类型系统

Python 是动态类型的语言。它的类型系统的设计相当精巧，Python 语言的很多优点，都来自于它的类型系统。我们来看一下。

首先，Python 里每个 PyObject 对象都有一个类型信息。保存类型信息的数据结构是 PyTypeObject（定义在 Include/cpython/object.h 中）。PyTypeObject 本身也是一个 PyObject，只不过这个对象是用于记录类型信息的而已。它是一个挺大的结构体，包含了对一个类型的各种描述信息，也包含了一些函数的指针，这些函数就是对该类型可以做的操作。可以说，只要你对这个结构体的每个字段的作用都了解清楚了，那么你对 Python 的类型体系也就了解透彻了。

typedef struct _typeobject {
    PyObject_VAR_HEAD
    const char *tp_name;  /* 用于打印的名称格式是"<模块>.<名称>" */
    Py_ssize_t tp_basicsize, tp_itemsize; /* 用于申请内存 */

    /* 后面还有很多字段，比如用于支持数值计算、序列、映射等操作的函数，用于描述属性、子类型、文档等内容的字段，等等。 */
    ...
} PyTypeObject

因为这个结构非常重要，所以我把一些有代表性的字段整理了一下，你可以重点关注它们：

你会看到，这个结构里的很多部分都是一个函数插槽（Slot），你可以往插槽里保存一些函数指针，用来实现各种标准操作，比如对象生命周期管理、转成字符串、获取哈希值等。

在上面的表格中，你还会看到像“__init__”这样的方法，它的两边都是有两个下划线的，也就是“double underscore”，简称 dunder 方法，也叫做“魔术方法”。在用 Python 编写自定义的类的时候，你可以实现这些魔术方法，它们就会被缺省的tp_*函数所调用，比如，“__init__”会被缺省的tp_init函数调用，完成类的初始化工作。

现在我们拿整型对象来举个例子，一起来看看它的 PyTypeObject 是什么样子。

对于整型对象来说，它的 ob_type 会指向一个 PyLong_Type 对象。这个对象是在 longobject.c 中初始化的，它是 PyTypeObject 的一个实例。从中你会看到一些信息：类型名称是“int”，转字符串的函数是long_to_decimal_string，此外还有比较函数、方法描述、属性描述、构建和析构函数等。

我们运行 type() 函数，可以获得一个对象的类型名称，这个名称就来自 PyTypeObject 的tp_name。

>>> a = 10
>>> type(a)
<type 'int'>

我们用 dir() 函数，可以从 PyTypeObject 中查询出一个对象所支持的所有属性和方法。比如，下面是查询一个整型对象获得的结果：

好，我们刚才讲了整型，它对应的 PyTypeObject 的实例是 PyLong_Type。Python 里其实还有其他一些内置的类型，它们分别都对应了一个 PyTypeObject 的实例。你可以参考一下这个表格。

上面列出的这些都是 Python 的内置类型。有些内置类型跟语法是关联着的，比如说，“a = 1”就会自动创建一个整型对象，“a = [2, 'john', 3]”就会自动创建一个 List 对象。这些内置对象都是用 C 语言实现的。

Python 比较有优势的一点，是你可以用 C 语言，像实现内置类型一样实现自己的类型，并拥有很高的性能。

当然，如果性能不是你最重要的考虑因素，那么你也可以用 Python 来创建新的类型，也就是以 class 关键字开头的自定义类。class 编译以后，也会形成一个 PyTypeObject 对象，来代表这个类。你为这个类编写的各种属性（比如类名称）和方法，会被存到类型对象中。

好了，现在你已经初步了解了 Python 的类型系统的特征。接下来，我就带你更深入地了解一下类型对象中一些重要的函数插槽的作用，以及它们所构成的一些协议。

Python 对象的一些协议

前面在研究整型对象的时候，你会发现 PyLong_Type 的 tp_as_number 字段被赋值了，这是一个结构体（PyNumberMethods），里面是很多与数值计算有关的函数指针，包括加减乘除等。这些函数指针是实现 Python 的数值计算方面的协议。任何类型，只要提供了这些函数，就可以像整型对象一样进行计算。这实际上是 Python 定义的一个针对数值计算的协议。

既然如此，我们再次用 GDB 来跟踪一下 Python 的执行过程，看看整数的加法是怎么实现的。我们的示例程序增加了一行代码，变成：

a = 1
b = a + 2

它对应的字节码如下：

我们重点来关注 BINARY_ADD 指令的执行情况，如下图所示：

可以看到，如果 + 号两边是字符串，那么编译器就会执行字符串连接操作。否则，就作为数字相加。

我们继续跟踪进入 PyNumber_Add 函数。在这个函数中，Python 求出了加法函数指针在 PyNumberMethods 结构体中的偏移量，接着就进入了 binary_op1() 函数。

在 binary_op1 函数中，Python 首先从第一个参数的类型对象中，取出了加法函数的指针。你在 GDB 中打印出输出信息，就会发现它是 binaryfunc 类型的，函数名称是 long_add。

binaryfunc 类型的定义是：

typedef PyObject * (*binaryfunc)(PyObject *, PyObject *);

也就是说，它是指向的函数要求有两个 Python 对象（的指针）作为参数，返回值也是一个 Python 对象（的指针）。

你再继续跟踪下去，会发现程序就进入到了 long_add 函数。这个函数是在 longobject.c 里定义的，是 Python 整型类型做加法计算的内置函数。

这里有一个隐秘的问题，为什么是使用了第一个参数（也就是加法左边的对象）所关联的加法函数，而不是第二个参数的加法函数？

在我们的示例程序中，由于加法两边的对象的类型是相同的，都是整型，所以它们所关联的加法函数是同一个。但是，如果两边的类型不一样怎么办呢？这个其实是一个很有意思的函数分派问题，你可以先思考一下答案，我会在后面讲 Julia 的编译器时再回到这个问题上。

好了，现在我们就能理解了，像加减乘除这样运算，它们在 Python 里都是怎么实现的了。Python 是到对象的类型中，去查找针对这些运算的函数来执行。

除了内置的函数，我们也可以自己写这样的函数，并被 Python 所调用。来看看下面的示例程序，我们定义了一个“__add__”魔术方法。这个方法会被 Python 作为 SimpleComplex 的加法函数所使用，实现了加法操作符的重载，从而支持复数的加法操作。

class SimpleComplex(object):
    def __init__(self, x, y):
        self.x = x
        self.y = y
    def __str__(self):
        return "x: %d, y: %d" % (self.x, self.y)
    def __add__(self, other):
        return SimpleComplex(self.x + other.x, self.y + other.y)

a = SimpleComplex(1, 2)
b = SimpleComplex(3, 4)
c = a + b
print(c)

那么对于这么一个自定义类，在执行 BINARY_ADD 指令时会有什么不同呢？通过 GDB 做跟踪，你会发现几点不同：

首先，在 SimpleComplex 的 type 对象中，add 函数插槽里放了一个 slot_nb_add() 函数指针，这个函数会到对象里查找“__add__”函数。因为 Python 的一切都是对象，因此它找到的是一个函数对象。

所以，接下来，Python 需要运行这个函数对象，而不是用 C 语言写的内置函数。那么怎么运行这个函数对象呢？

这就需要用到 Python 的另一个协议，Callable 协议。这个协议规定，只要为对象的类型中的 tp_call 属性定义了一个合法的函数，那么该对象就是可被调用的。

对于自定义的函数，Python 会设置一个缺省的 tp_call 函数。这个函数所做的事情，实际上就是找到该函数所编译出来的 PyCodeObject，并让解释器执行其中的字节码！

好了，通过上面的示例程序，我们加深了对类型对象的了解，也了解了 Python 关于数值计算和可调用性（Callable）方面的协议。

Python 还有其他几个协议，比如枚举协议和映射协议等，用来支持对象的枚举、把对象加入字典等操作。你可以利用这些协议，充分融入到 Python 语言的框架中，比如说，你可以重载加减乘除等运算。

接下来，我们再运用 Callable 协议的知识，来探究一下 Python 对象的创建机制。

Python 对象的创建

用 Python 语言，我们可以编写 class，来支持自定义的类型。我们来看一段示例代码：

class myclass:
    def __init__(self, x):
        self.x = x
    def foo(self, b):
        c = self.x + b
        return c
a = myclass(2);

其中，myclass(2) 是生成了一个 myclass 对象。

可是，你发现没有，Python 创建一个对象实例的方式，其实跟调用一个函数没啥区别（不像 Java 语言，还需要 new 关键字）。如果你不知道 myclass 是一个自定义的类，你会以为只是在调用一个函数而已。

不过，我们前面已经提到了 Python 的 Callable 协议。所以，利用这个协议，任何对象只要在其类型中定义了 tp_call，那么就都可以被调用。

我再举个例子，加深你对 Callable 协议的理解。在下面的示例程序中，我定义了一个类型 Bar，并创建了一个对象 b。

class Bar:
    def __call__(self):
            print("in __call__: ", self)
b = Bar()
b()                              #这里会打印对象信息，并显示对象地址

现在，我在 b 对象后面加一对括号，就可以调用 b 了！实际执行的就是 Bar 的“__call__”函数（缺省的 tp_call 函数会查找“__call__”属性，并调用）。

所以，我们调用 myclass()，那一定是因为 myclass 的类型对象中定义了 tp_call。

你还可以把“myclass(2)”这个语句编译成字节码看看，它生成的是 CALL_FUNCTION 指令，与函数调用没有任何区别。

可是，我们知道，示例程序中 a 的类型对象是 myclass，但 myclass 的类型对象是什么呢？

换句话说，一个普通的对象的类型，是一个类型对象。那么一个类型对象的类型又是什么呢？

答案是元类（metaclass），元类是类型的类型。举例来说，整型的 metaclass 是 PyType_Type。其实，大部分类型的 metaclass 是 PyType_Type。

所以说，调用类型来实例化一个对象，就是调用 PyType_Type 的 tp_call 函数。那么 PyType_Type 的 tp_call 函数都做了些什么事情呢？

这个函数是 type_call()，它也是在 typeobject.c 中定义的。Python 以 type_call() 为入口，会完成创建一个对象的过程：

创建

tp_call 会调用类型对象的 tp_new 插槽的函数。对于 PyLong_Type 来说，它是 long_new。

如果我们是创建一个 Point 对象，如果你为它定义了一个“__new__”函数，那么就将调用这个函数来创建对象，否则，就会查找基类中的 tp_new。

初始化

tp_call 会调用类型对象的 tp_init。对于 Point 这样的自定义类型来说，如果定义了“__init__”函数，就会执行来做初始化。否则，就会调用基类的 tp_init。对于 PyBaseType_Object 来说，这个函数是 object_init。

除了自定义的类型，内置类型的对象也可以用类型名称加括号的方式来创建。我还是以整型为例，创建一个整型对象，也可以用“int(10)”这种格式，其中 int 是类型名称。而且，它的 metaclass 也是 PyType_Type。

当然，你也可以给你的类型指定另一个 metaclass，从而支持不同的对象创建和初始化功能。虽然大部分情况下你不需要这么做，但这种可定制的能力，就为你编写某些特殊的功能（比如元编程）提供了可能性。

好了，现在你已经知道，类型的类型是元类（metaclass），它能为类型的调用提供支持。你可能进一步会问，那么元类的类型又是什么呢？是否还有元元类？直接调用元类又会发生什么呢？

缺省情况下，PyType_Type 的类型仍然是 PyType_Type，也就是指向它自身。对元类做调用，也一样会启动上面的 tp_call() 过程。

到目前为止，我们谈论 Python 中的对象，还没有谈论那些面向对象的传统话题：继承啦、多态啦，等等。这些特性在 Python 中的实现，仍然只是在类型对象里设置一些字段即可。你可以在 tp_base 里设定基类（父类）来支持继承，甚至在 tp_bases 中设置多个基类来支持多重继承。所有对象缺省的基类是 object，tp_base 指向的是一个 PyBaseObject_Type 对象。

>>> int.__base__     #查看int类型的基类
<class 'object'>

到目前为止，我们已经对于对象的类型、元类，以及对象之间的继承关系有了比较全面的了解，为了方便你重点复习和回顾，我把它们画成了一张图。

图 2：Python 对象的类型关系和继承关系

你要注意，图中我用两种颜色的箭头区分了两种关系。一种是橙色箭头，代表的是类型关系，比如 PyLong_Type 是 PyLongObject 的类型，而 PyType_Type 是 PyLong_Type 的类型；另一种是黑色箭头，代表的是继承关系，比如 int 的基类是 object，所以 PyLong_Type 的 tp_base 指向 PyBaseObject_Type。

到这里，你可能会觉得有点挑战认知。因为通常我们谈面向对象的体系结构，只会涉及图中的继承关系线，不太会考虑其中的类型关系线。Python 的类型关系，体现了“数据即程序”的概念。Java 语言里，某个类型对应于一个 class 的字节码，而在 Python 里，一个类型只是一个 Python 对象而已。

并且，在 Java 里也不会有元类，因为对象的创建和初始化过程都是语言里规定死的。而在 Python 里，你却拥有一定的掌控能力。

这些特点，都体现了 Python 类型体系的强大之处。

课程小结

好了，我们来总结一下 Python 运行期的特征。你会发现，Python 的运行期设计的核心，就是 PyObject 对象，Python 对象所有的特性都是从 PyObject 的设计中延伸出来的，给人一种精巧的美感。

Python 程序中的符号都是 Python 对象，栈机中存的也都是 Python 对象指针。

所有对象的头部信息是相同的，而后面的信息可扩展。这就让 Python 可以用 PyObject 指针来访问各种对象，这种设计技巧你需要掌握。

每个对象都有类型，类型描述信息在一个类型对象里。系统内有内置的类型对象，你也可以通过 C 语言或 Python 语言创建新的类型对象，从而支持新的类型。

类型对象里有一些字段保存了一些函数指针，用于完成数值计算、比较等功能。这是 Python 指定的接口协议，符合这些协议的程序可以被无缝集成到 Python 语言的框架中，比如支持加减乘除运算。

函数的运行、对象的创建，都源于 Python 的 Callable 协议，也就是在类型对象中制定 tp_call 函数。面向对象的特性，也是通过在类型对象里建立与基类的链接而实现的。

我照例把本讲的重点知识，整理成了一张思维导图，供你参考和回顾：

一课一思

今天给你的思考题是很有意思的。

我前面讲到，当 Python 做加法运算的时候，如果对象类型相同，那么只有一个加法函数可选。但如果两边的对象类型是不同的，该怎么办呢？你可以看看 Python 是怎么实现的。这其实是编译技术的一个关注点，我们在后面课程中还会提及这个问题。

参考资料

Python 的内置类型。

20 | JavaScript编译器（一）：V8的解析和编译过程

你好，我是宫文学。从这一讲开始，我们就进入另一个非常重要的编译器：V8 编译器。

V8 是谷歌公司在 2008 年推出的一款 JavaScript 编译器，它也可能是世界上使用最广泛的编译器。即使你不是编程人员，你每天也会运行很多次 V8，因为 JavaScript 是 Web 的语言，我们在电脑和手机上浏览的每个页面，几乎都会运行一点 JavaScript 脚本。

扩展：V8 这个词，原意是 8 缸的发动机，换算成排量，大约是 4.0 排量，属于相当强劲的发动机了。它的编译器，叫做 Ignition，是点火装置的意思。而它最新的 JIT 编译器，叫做 TurboFan，是涡轮风扇发动机的意思。

在浏览器诞生的早期，就开始支持 JavaScript 了。但在 V8 推出以后，它重新定义了 Web 应用可以胜任的工作。到今天，在浏览器里，我们可以运行很多高度复杂的应用，比如办公套件等，这些都得益于以 V8 为代表的 JavaScript 引擎的进步。2008 年 V8 发布时，就已经比当时的竞争对手快 10 倍了；到目前，它的速度又已经提升了 10 倍以上。从中你可以看到，编译技术有多大的潜力可挖掘！

对 JavaScript 编译器来说，它最大的挑战就在于，当我们打开一个页面的时候，源代码的下载、解析（Parse）、编译（Compile）和执行，都要在很短的时间内完成，否则就会影响到用户的体验。

那么，V8 是如何做到既编译得快，又要运行得快的呢？所以接下来，我将会花两讲的时间，来带你一起剖析一下 V8 里面的编译技术。在这个过程中，你能了解到 V8 是如何完成前端解析、后端优化等功能的，它都有哪些突出的特点；另外，了解了 V8 的编译原理，对你以后编写更容易优化的程序，也会非常有好处。

今天这一讲，我们先来透彻了解一下 V8 的编译过程，以及每个编译阶段的工作原理，看看它跟我们已经了解的其他编译器相比，有什么不同。

初步了解 V8

首先，按照惯例，我们肯定要下载 V8 的源代码。按照官方文档中的步骤，你可以下载源代码，并在本地编译。注意，你最好把它编译成 Debug 模式，这样便于用调试工具去跟踪它的执行，所以你要使用下面的命令来进行编译。

tools/dev/gm.py x64.debug

编译完毕以后，进入 v8/out/x64.debug 目录，你可以运行./d8，这就是编译好的 V8 的命令行工具。如果你用过 Node.js，那么 d8 的使用方法，其实跟它几乎是完全一样的，因为 Node.js 就封装了一个 V8 引擎。你还可以用 GDB 或 LLDB 工具来调试 d8，这样你就可以知道，它是怎么编译和运行 JavaScript 程序了。

而 v8/src 目录下的，就是 V8 的源代码了。V8 是用 C++ 编写的。你可以重点关注这几个目录中的代码，它们是与编译有关的功能，而别的代码主要是运行时功能：

V8 的编译器的构成跟 Java 的编译器很像，它们都有从源代码编译到字节码的编译器，也都有解释器（叫 Ignition），也都有 JIT 编译器（叫 TurboFan）。你可以看下 V8 的编译过程的图例。在这个图中，你能注意到两个陌生的节点：流处理节点（Stream）和预解析器（PreParser），这是 V8 编译过程中比较有特色的两个处理阶段。

图 1：V8 的编译过程

注意：这是比较新的 V8 版本的架构。在更早的版本里，有时会用到两个 JIT 编译器，类似于 HotSpot 的 C1 和 C2，分别强调编译速度和优化效果。在更早的版本里，还没有字节码解释器。现在的架构，引入了字节码解释器，其速度够快，所以就取消了其中一级的 JIT 编译器。

下面我们就进入到 V8 编译过程中的各个阶段，去了解一些编译器的细节。

超级快的解析过程（词法分析和语法分析）

首先，我们来了解一下 V8 解析源代码的过程。我在开头就已经说过，V8 解析源代码的速度必须要非常快才行。源代码边下载边解析完毕，在这个过程中，用户几乎感觉不到停顿。那它是如何实现的呢？

有两篇文章就非常好地解释了 V8 解析速度快的原因。

一个是“optimizing the scanner”这篇文章，它解释了 V8 在词法分析上做的优化。V8 的作者们真是锱铢必较地在每一个可能优化的步骤上去做优化，他们所采用的技术很具备参考价值。

那我就按照我对这篇文章的理解，来给你解释一下 V8 解析速度快的原因吧：

第一个原因，是 V8 的整个解析过程是流（Stream）化的，也就是一边从网络下载源代码，一边解析。在下载后，各种不同的编码还被统一转化为 UTF-16 编码单位，这样词法解析器就不需要处理多种编码了。

第二个原因，是识别标识符时所做的优化，这也让 V8 的解析速度更快了一点。你应该知道，标识符的第一个字符（ID_START）只允许用字母、下划线和 $ 来表示，而之后的字符（ID_CONTINUE）还可以包括数字。所以，当词法解析器遇到一个字符的时候，我们首先要判断它是否是合法的 ID_START。

那么，这样一个逻辑，通常你会怎么写？我一般想也不想，肯定是这样的写法：

if(ch >= 'A' && ch <= 'Z' || ch >='a' && ch<='z' || ch == '$' || ch == '_'){
    return true;
}

但你要注意这里的一个问题，if 语句中的判断条件需要做多少个运算？

最坏的情况下，要做 6 次比较运算和 3 次逻辑“或”运算。不过，V8 的作者们认为这太奢侈了。所以他们通过查表的方法，来识别每个 ASCII 字符是否是合法的标识符开头字符。

这相当于准备了一张大表，每个字符在里面对应一个位置，标明了该字符是否是合法的标识符开头字符。这是典型的牺牲空间来换效率的方法。虽然你在阅读代码的时候，会发现它调用了几层函数来实现这个功能，但这些函数其实是内联的，并且在编译优化以后，产生的指令要少很多，所以这个方法的性能更高。

第三个原因，是如何从标识符中挑出关键字。

与 Java 的编译器一样，JavaScript 的 Scanner，也是把标识符和关键字一起识别出来，然后再从中挑出关键字。所以，你可以认为这是一个最佳实践。那你应该也会想到，识别一个字符串是否是关键字的过程，使用的方法仍然是查表。查表用的技术是“完美哈希（perfect hashing）”，也就是每个关键字对应的哈希值都是不同的，不会发生碰撞。并且，计算哈希值只用了三个元素：前两个字符（ID_START、ID_CONTINUE），以及字符串的长度，不需要把每个字符都考虑进来，进一步降低了计算量。

文章里还有其他细节，比如通过缩窄对 Unicode 字符的处理范围来进行优化，等等。从中你能体会到 V8 的作者们在提升性能方面，无所不用其极的设计思路。

除了词法分析，在语法分析方面，V8 也做了很多的优化来保证高性能。其中，最重要的是“懒解析”技术（lazy parsing）。

一个页面中包含的代码，并不会马上被程序用到。如果在一开头就把它们全部解析成 AST 并编译成字节码，就会产生很多开销：占用了太多 CPU 时间；过早地占用内存；编译后的代码缓存到硬盘上，导致磁盘 IO 的时间很长，等等。

所以，所有浏览器中的 JavaScript 编译器，都采用了懒解析技术。在 V8 里，首先由预解析器，也就是 Preparser 粗略地解析一遍程序，在正式运行某个函数的时候，编译器才会按需解析这个函数。你要注意，Preparser 只检查语法的正确性，而基于上下文的检查则不是这个阶段的任务。你如果感兴趣的话，可以深入阅读一下这篇介绍 Preparser 的文章，我在这里就不重复了。

你可以在终端测试一下懒解析和完整解析的区别。针对 foo.js 示例程序，你输入“./d8 – ast-print foo.js”命令。

function add(a,b){
    return a + b;
}

//add(1,2)    //一开始，先不调用add函数

得到的输出结果是：

里面有一个没有名字的函数（也就是程序的顶层函数），并且它记录了一个 add 函数的声明，仅此而已。你可以看到，Preparser 的解析结果确实够粗略。

而如果你把 foo.js 中最后一行的注释去掉，调用一下 add 函数，再次让 d8 运行一下 foo.js，就会输出完整解析后的 AST，你可以看看二者相差有多大：

最后，你可以去看看正式的 Parser（在 parser.h、parser-base.h、parser.cc 代码中）。学完了这么多编译器的实现机制以后，以你现在的经验，打开一看，你就能知道，这又是用手写的递归下降算法实现的。

在看算法的过程中，我一般第一个就会去看它是如何处理二元表达式的。因为二元表达式看上去很简单，但它需要解决一系列难题，包括左递归、优先级和结合性。

V8 的 Parser 中，对于二元表达式的处理，采取的也是一种很常见的算法：操作符优先级解析器（Operator-precedence parser）。这跟 Java 的 Parser 也很像，它本质上是自底向上的一个 LR(1) 算法。所以我们可以得出结论，在手写语法解析器的时候，遇到二元表达式，采用操作符优先级的方法，算是最佳实践了！

好了，现在我们了解了 V8 的解析过程，那 V8 是如何把 AST 编译成字节码和机器码并运行的呢？我们接着来看看它的编译过程。

编译成字节码

我们在执行刚才的 foo.js 文件时，加上“–print-bytecode”参数，就能打印出生成的字节码了。其中，add 函数的字节码如下：

怎么理解这几行字节码呢？我来给你解释一下：

Ldar a1：把参数 1 从寄存器加载到累加器（Ld=load，a=accumulator, r=register）。

Add a0, [0]：把参数 0 加到累加器上。

Return：返回（返回值在累加器上）。

不过，要想充分理解这几行简单的字节码，你还需要真正理解 Ignition 的设计。因为这些字节码是由 Ignition 来解释执行的。

Ignition 是一个基于寄存器的解释器。它把函数的参数、变量等保存在寄存器里。不过，这里的寄存器并不是物理寄存器，而是指栈帧中的一个位置。下面是一个示例的栈帧：

图 2：Ignition 的栈帧

这个栈帧里包含了执行函数所需要的所有信息：

参数和本地变量。

临时变量：它是在计算表达式的时候会用到的。比如，计算 2+3+4 的时候，就需要引入一个临时变量。

上下文：用来在函数闭包之间维护状态。

pc：调用者的代码地址。

栈帧里的 a0、a1、r0、r1 这些都是寄存器的名称，可以在指令里引用。而在字节码里，会用一个操作数的值代替。

整个栈帧的长度是在编译成字节码的时候就计算好了的。这就让 Ignition 的栈帧能适应不同架构对栈帧对齐的要求。比如 AMD64 架构的 CPU，它就要求栈帧是 16 位对齐的。

Ignition 也用到了一些物理寄存器，来提高运算的性能：

累加器：在做算术运算的时候，一定会用到累加器作为指令的其中一个操作数，所以它就不用在指令里体现了；指令里只要指定另一个操作数（寄存器）就行了。

字节码数组寄存器：指向当前正在解释执行的字节码数组开头的指针。

字节码偏移量寄存器：当前正在执行的指令，在字节码数组中的偏移量（与 pc 寄存器的作用一样）。

…

Ignition 是我们见到的第一个寄存器机，它跟我们之前见到的 Java 和 Python 的栈机有明显的不同。所以，你可以先思考一下，Ignition 会有什么特点呢？

我来给你总结一下吧。

它在指令里会引用寄存器作为操作数，寄存器在进入函数时就被分配了存储位置，在函数运行时，栈帧的结构是不变的。而对比起来，栈机的指令从操作数栈里获取操作数，操作数栈随着函数的执行会动态伸缩。

Ignition 还引入了累加器这个物理寄存器作为缺省的操作数。这样既降低了指令的长度，又能够加快执行速度。

当然，Ignition 没有像生成机器码那样，用一个寄存器分配算法，让本地变量、参数等也都尽量采用物理寄存器。这样做的原因，一方面是因为，寄存器分配算法会增加编译的时间；另一方面，这样不利于代码在解释器和 TurboFan 生成的机器代码之间来回切换（因为它要在调用约定之间做转换）。采用固定格式的栈帧，Ignition 就能够在从机器代码切换回来的时候，很容易地设置正确的解释器栈帧状态。

我把更多的字节码指令列在了下面，你可以仔细看一看 Ignition 都有哪些指令，从而加深对 Ignition 解释运行机制的理解。同时，你也可以跟我们已经学过的 Java 和 Python 的字节码做个对比。这样呀，你对字节码、解释器的了解就更丰富了。

来源：Ignition Design Doc

编译成机器码

好，前面我提到了，V8 也有自己的 JIT 编译器，叫做 TurboFan。在学过 Java 的 JIT 编译器以后，你可以预期到，TurboFan 也会有一些跟 Java JIT 编译器类似的特性，比如它们都是把字节码编译生成机器码，都是针对热点代码才会启动即时编译的。那接下来，我们就来验证一下自己的想法，并一起来看看 TurboFan 的运行效果究竟如何。

我们来看一个示例程序 add.js：

function add(a,b){
    return a+b;
}

for (i = 0; i<100000; i++){
    add(i, i+1);
    if (i%1000==0)
        console.log(i);
}

你可以用下面的命令，要求 V8 打印出优化过程、优化后的汇编代码、注释等信息。其中，“–turbo-filter=add”参数会告诉 V8，只优化 add 函数，否则的话，V8 会把 add 函数内联到外层函数中去。

./d8 --trace-opt-verbose \
    --trace-turbo \
    --turbo-filter=add \
    --print-code \
    --print-opt-code \
    --code-comments \
    add.js

注释：你用./d8 --help，就能列出 V8 可以使用的各种选项及其说明，我把上面几个选项的含义解释一下。 –trace-opt-verbose：跟踪优化过程，并输出详细信息 –trace-turbo：跟踪 TurboFan 的运行过程 –print-code：打印生成的代码 –print-opt-code：打印优化的代码 –code-comment：在汇编代码里输出注释

程序一开头是解释执行的。在循环了 24000 次以后，V8 认为这是热点代码，于是启动了 Turbofan 做即时编译。

最后生成的汇编代码有好几十条指令。不过你可以看到，大部分指令是用于初始化栈帧，以及处理逆优化的情况。真正用于计算的指令，是下面几行指令：

对这些汇编代码的解读，以及这些指令的产生和优化过程，我会在下一讲继续给你讲解。

课程小结

今天这讲，我们从总体上考察了 V8 的编译过程，我希望你记住几个要点：

首先，是编译速度。由于 JavaScript 是在浏览器下载完页面后马上编译并执行，它对编译速度有更高的要求。因此，V8 使用了一边下载一边编译的技术：懒解析技术。并且，在解析阶段，V8 也比其他编译器更加关注处理速度，你可以从中学到通过查表减少计算量的技术。

其次，我们认识了一种新的解释器 Ignition，它是基于寄存器的解释器，或者叫寄存器机。Ignition 比起栈机来，更有性能优势。

最后，我们初步使用了一下 V8 的即时编译器 TurboFan。在下一讲中，我们会更细致地探讨 TurboFan 的特性。

按照惯例，这一讲的思维导图我也给你整理出来了，供你参考：

一课一思

你能否把 Ignition 的字节码和 Java、Python 的字节码对比一下。看看它们有哪些共同之处，有哪些不同之处？

欢迎在留言区分享你的答案，也欢迎你把今天的内容分享给更多的朋友。

参考资料

这两篇文章分析了 V8 的解析器为什么速度非常快：Blazingly fast parsing, part 1: optimizing the scanner，Blazingly fast parsing, part 2: lazy parsing

这篇文章描述了 Ignition 的设计：Ignition Design Doc，我在 GitHub 上也放了一个拷贝

这篇文章有助于你了解 Ignition 的字节码：Understanding V8’s bytecode

V8 项目的官网，这里有一些重要的博客文章和文档

21 | JavaScript编译器（二）：V8的解释器和优化编译器

你好，我是宫文学。通过前一讲的学习，我们已经了解了 V8 的整个编译过程，并重点探讨了一个问题，就是 V8 的编译速度为什么那么快。

V8 把解析过程做了重点的优化，解析完毕以后就可以马上通过 Ignition 解释执行了。这就让 JavaScript 可以快速运行起来。

今天这一讲呢，我们重点来讨论一下，V8 的运行速度为什么也这么快，一起来看看 V8 所采用的优化技术。

上一讲我也提及过，V8 在 2008 年刚推出的时候，它提供了一个快速编译成机器码的编译器，虽然没做太多优化，但性能已经是当时其他 JavaScript 引擎的 10 倍了。而现在，V8 的速度又是 2008 年刚发布时候的 10 倍。那么，是什么技术造成了这么大的性能优化的呢？

这其中，一方面原因，是 TurboFan 这个优化编译器，采用了很多的优化技术。那么，它采用了什么优化算法？采用了什么 IR？其优化思路跟 Java 的 JIT 编译器有什么相同点和不同点？

另一方面，最新的 Ignition 解释器，虽然只是做解释执行的功能，但竟然也比一个基础的编译器生成的代码慢不了多少。这又是什么原因呢？

所以今天，我们就一起把这些问题都搞清楚，这样你就能全面了解 V8 所采用的编译技术的特点了，你对动态类型语言的编译，也就能有更深入的了解，并且这也有助于你编写更高效的 JavaScript 程序。

好，首先，我们来了解一下 TurboFan 的优化编译技术。

TurboFan 的优化编译技术

TurboFan 是一个优化编译器。不过它跟 Java 的优化编译器要完成的任务是不太相同的。因为 JavaScript 是动态类型的语言，所以如果它能够推断出准确的类型再来做优化，就会带来巨大的性能提升。

同时，TurboFan 也会像 Java 的 JIT 编译器那样，基于 IR 来运行各种优化算法，以及在后端做指令选择、寄存器分配等优化。所有的这些因素加起来，才使得 TurboFan 能达到很高的性能。

我们先来看看 V8 最特别的优化，也就是通过对类型的推理所做的优化。

基于推理的优化（Speculative Optimazition）

对于基于推理的优化，我们其实并不陌生。在研究Java 的 JIT 编译器时，你就发现了 Graal 会针对解释器收集的一些信息，对于代码做一些推断，从而做一些激进的优化，比如说会跳过一些不必要的程序分支。

而 JavaScript 是动态类型的语言，所以对于 V8 来说，最重要的优化，就是能够在运行时正确地做出类型推断。举个例子来说，假设示例函数中的 add 函数，在解释器里多次执行的时候，接受的参数都是整型，那么 TurboFan 就处理整型加法运算的代码就行了。这也就是上一讲中我们生成的汇编代码。

function add(a,b){
    return a+b;
}

for (i = 0; i<100000; i++){
    if (i%1000==0)
        console.log(i);

    add(i, i+1);
}

但是，如果不在解释器里执行，直接要求 TurboFan 做编译，会生成什么样的汇编代码呢？

你可以在运行 d8 的时候，加上“–always-opt”参数，这样 V8 在第一次遇到 add 函数的时候，就会编译成机器码。

./d8 --trace-opt-verbose \
    --trace-turbo \
    --turbo-filter=add \
    --print-code \
    --print-opt-code \
    --code-comments \
    --always-opt \
    add.js

这一次生成的汇编代码，跟上一讲生成的就不一样了。由于编译器不知道 add 函数的参数是什么类型的，所以实际上，编译器是去调用实现 Add 指令的内置函数，来生成了汇编代码。

这个内置函数当然支持所有加法操作的语义，但是它也就无法启动基于推理的优化机制了。这样的代码，跟解释器直接解释执行，性能上没太大的差别，因为它们本质上都是调用一个全功能的内置函数。

而推理式优化的版本则不同，它直接生成了针对整型数字进行处理的汇编代码：

我来给你解释一下这几行指令的意思：

第 1 行和第 3 行，是把参数 1 和参数 2 分别拷贝到 r8 和 r9 寄存器。注意，这里是从物理寄存器里取值，而不是像前一个版本一样，在栈里取值。前一个版本遵循的是更加保守和安全的调用约定。

第 2 行和第 4 行，是把 r8 和 r9 寄存器的值向右移 1 位。

第 5 行，是把 r8 和 r9 相加。

看到这里，你可能就发现了一个问题：只是做个简单的加法而已，为什么要做移位操作呢？实际上，如果你熟悉汇编语言的话，要想实现上面的功能，其实只需要下面这两行代码就可以了：

movq rax, rdi   #把参数1拷贝到rax寄存器
addq rax, rcx   #把参数2加到rax寄存器上，作为返回值

那么，多出来的移位操作是干什么的呢？

这就涉及到了 V8 的内存管理机制。原来，V8 对象都保存在堆中。在栈帧中保存的数值，都是指向堆的指针。垃圾收集器可以通过这些指针，知道哪些内存对象是不再被使用的，从而把它们释放掉。我们前面学过，Java 的虚拟机和 Python 对于对象引用，本质上也是这么处理的。

但是，这种机制对于基础数据类型，比如整型，就不太合适了。因为你没有必要为一个简单的整型数据在堆中申请内存，这样既浪费内存，又降低了访问效率，V8 需要访问两次内存才能读到一个整型变量的值（第一次读到地址，第二次根据该地址到堆里读到值）。你记得，Python 就是这么访问基础数据的。

V8 显然不能忍受这种低效的方式。它采用的优化机制，是一种被广泛采用的技术，叫做标记指针（Tagged Pointer）或者标记值（Tagged Value）。《Pointer Compression in V8》这篇文章，就介绍了 V8 中采用 Tagged Pointer 技术的细节。

比如说，对于一个 32 位的指针值，它的最低位是标记位。当标记位是 0 的时候，前 31 位是一个短整数（简写为 Smi）；而当标记位是 1 的时候，那么前 31 位是一个地址。这样，V8 就可以用指针来直接保存一个整数值，用于计算，从而降低了内存占用，并提高了运行效率。

图 1：用标记指针技术来表示一个整数

好了，现在你已经理解了 V8 的推理式编译的机制。那么，还有什么手段能提升代码的性能呢？

当然，还有基于 IR 的各种优化算法。

IR 和优化算法

在讲Java 的 JIT 编译器时我就提过，V8 做优化编译时采用的 IR，也是基于 Sea of Nodes 的。

你可以回忆一下 Sea of Nodes 的特点：合并了数据流图与控制流图；是 SSA 形式；没有把语句划分成基本块。

它的重要优点，就是优化算法可以自由调整语句的顺序，只要不破坏数据流图中的依赖关系。在 Sea of Nodes 中，没有变量（有时也叫做寄存器）的概念，只有一个个数据节点，所以对于死代码删除等优化方法来说，它也具备天然的优势。

说了这么多，那么要如何查看 TurboFan 的 IR 呢？一个好消息是，V8 也像 GraalVm 一样，提供了一个图形化的工具，来查看 TurboFan 的 IR。这个工具是turbolizer，它位于 V8 源代码的 tools/turbolizer 目录下。你可以按照该目录下的 README.md 文档，构建一下该工具，并运行它。

python -m SimpleHTTPServer 8000

它实际启动了一个简单的 Web 服务。你可以在浏览器中输入“0.0.0.0:8000”，打开 turbolizer 的界面。

在运行 d8 的时候，如果带上参数“–trace-turbo”，就会在当前目录下输出一个.json 文件，打开这个文件就能显示出 TurboFan 的 IR 来。比如，上一讲的示例程序 add.js，所显示出的 add 函数的 IR：

图 2：在 turbolizer 中显示 add 函数的 IR

界面中最左边一栏是源代码，中间一栏是 IR，最右边一栏是生成的汇编代码。

上图中的 IR 只显示了控制节点。你可以在工具栏中找到一个按钮，把所有节点都呈现出来。在左侧的 Info 标签页中，还有一些命令的快捷键，你最好熟悉一下它们，以便于控制 IR 都显示哪些节点。这对于一个大的 IR 图来说很重要，否则你会看得眼花缭乱：

图 3：完整展开的 add 函数的 IR

在这个图中，不同类型的节点用了不同的颜色来区分：

黄色：控制流的节点，比如 Start、End 和 Return；

淡蓝色：代表一个值的节点；

红色：JavaScript 层级的操作，比如 JSEqual、JSToBoolean 等；

深蓝色：代表一种中间层次的操作，介于 JavaScript 层和机器层级中间；

绿色：机器级别的语言，代表一些比较低层级的操作。

在 turbolizer 的界面上，还有一个下拉菜单，里面有多个优化步骤。你可以挨个点击，看看经过不同的优化步骤以后，IR 的变化是什么样子的。

图 4：TurboFan 对 IR 的处理步骤

你可以看到，在第一步“v8.TFBytecodeGraphBuilder”阶段显示的 IR 中，它显示的节点还是有点儿多。我们先隐藏掉与计算功能无关的节点，得到了下面的主干。你要注意其中的绿色节点，这里已经进行了类型推测，因此它采用了一个整型计算节点：SpeculativeSafeIntegerAdd。

这个节点的功能是：当两个参数都是整数的时候，那就符合类型推断，做整数加法操作；如果不符合类型推断，那么就执行逆优化。

图 5：v8.TFBytecodeGraphBuilder”阶段的 IR

你可以再去点击其他的优化步骤，看看图形会有什么变化。

在 v8.TFGenericLowering 阶段，我们得到了如下所示的 IR 图，这个图只保留了计算过程的主干。里面增加了两个绿色节点，这两个节点就是把标记指针转换成整数的；还增加了一个深蓝色的节点，这个节点是在函数返回之前，把整数再转换成标记指针。

图 6：v8.TFGenericLowering 阶段的 IR

在 v8.TFLateGraphTrimming 阶段，图中的节点增加了更多的细节，它更接近于具体 CPU 架构的汇编代码。比如，我们把前面图 6 中的标记指针，转换成 32 位整数的操作，就变成了两个节点：

TruncateInt64ToInt32：把 64 位整型截短为 32 位整型；

Word32Sar：32 位整数的移位运算，用于把标记指针转换为整数。

图 7：v8.TFLateGraphTrimming 阶段的 IR

这三个阶段就形象地展示出了 TurboFan 的 IR 是如何 Lower 的，从比较抽象的、机器无关的节点，逐步变成了与具体架构相关的操作。

所以，基本上 IR 的节点可以分为四类：顶层代表复杂操作的 JavaScript 节点、底层代表简单操作的机器节点、处于二者之间做了一定简化的节点，以及可以被各个层次共享的节点。

刚才我们对 V8 做优化编译时，所采用的 IR 的分析，只关注了与加法计算有关的主干节点。你还可以用同样的方法，来看看其他的节点。这些节点主要是针对异常情况的处理。比如，如果发现参数类型不是整型，那么就要去执行逆优化。

在做完了所有的优化之后，编译器就会进入指令排序、寄存器分配等步骤，最后生成机器码。

现在，你就了解了 TurboFan 是如何借助 Sea of Nodes 来做优化和 Lower 的了。但我们还没有涉及具体的优化算法。那么，什么优化算法会帮助 V8 提升性能呢？

前面在研究Java 的 JIT 编译器的时候，我们重点关注了内联和逃逸分析的优化算法。那么，对于 JavaScript 来说，这两种优化也同样非常重要，一样能带来巨大的优化效果。

我们先来看看内联优化。对于之前的示例程序，由于我们使用了“–turbo-filter=add”选项来运行代码，因此 TurboFan 只会编译 add 方法，这就避免了顶层函数把 add 函数给内联进去。而如果你去掉了这个选项，就会发现 TurboFan 在编译完毕以后，程序后面的运行速度会大大加快，一闪而过。这是因为整个顶层函数都被优化编译了，并且在这个过程中，把 add 函数给内联进去了。

然后再说说逃逸分析。V8 运用逃逸分析算法，也可以像 Java 的编译器一样，把从堆中申请的内存优化为从栈中申请（甚至使用寄存器），从而提升性能，并避免垃圾收集带来的消耗。

不过，JavaScript 和 Java 的对象体系设计毕竟是不一样的。在 Java 的类里，每个成员变量相对于对象指针的偏移量都是固定的；而 JavaScript 则在内部用了隐藏类来表示对象的内存布局。这也引出 V8 的另一个有特色的优化主题：内联缓存。

那接下来，我就带你详细了解一下 V8 的隐藏类和内联缓存机制。

隐藏类（Shapes）和内联缓存（Inline Caching）

隐藏类，学术上一般叫做 Hidden Class，但不同的编译器的叫法也不一样，比如 Shapes、Maps，等等。

隐藏类有什么用呢？你应该知道，在 JavaScript 中，你不需要先声明一个类，才能创建一个对象。你可以随时创建对象，比如下面的示例程序中，就创建了几个表示坐标点的对象：

point1 = {x:2, y:3};
point2 = {x:4, y:5};
point3 = {y:7, x:6};
point4 = {x:8, y:9, z:10};

那么，V8 在内部是怎么来存储 x 和 y 这些对象属性的呢？

如果按照 Java 的内存布局方案，一定是在对象头后面依次存放 x 和 y 的值；而如果按照 Python 的方案，那就需要用一个字典来保存不同属性的值。但显然用类似 Java 的方案更加节省内存，访问速度也更快。

所以，V8 内部就采用了隐藏类的设计。如果两个对象，有着相同的属性，并且顺序也相同，那么它们就对应相同的隐藏类。

在上面的程序中，point1 和 point2 就对应着同一个隐藏类；而 point3 和 point4 要么是属性的顺序不同，要么是属性的数量不同，对应着另外的隐藏类。

所以在这里，你就会得到一个编写高性能程序的要点：对于相同类型的对象，一定要保证它们的属性数量和顺序完全一致，这样才会被 V8 当作相同的类型，从而那些基于类型推断的优化才会发挥作用。

此外，V8 还用到了一种叫做内联缓存的技术，来加快对象属性的访问时间。它的原理是这样的：当 V8 第一次访问某个隐藏类的属性的时候，它要到隐藏类里，去查找某个属性的地址相对于对象指针的偏移量。但 V8 会把这个偏移量缓存下来，这样下一次遇到同样的 shape 的时候，直接使用这个缓存的偏移量就行了。

比如下面的示例代码，如果对象 o 是上面的 point1 或 point2，属性 x 的地址偏移量就是相同的，因为它们对应的隐藏类是一样的：

function getX(o){
  return o.x;
}

有了内联优化技术，那么 V8 只有在第一次访问某个隐藏类的属性时，速度会慢一点，之后的访问效率就跟 Java 的差不多了。因为 Java 这样的静态类型的代码，在编译期就可以确定每个属性相对于对象地址的偏移量。

好，现在你已经了解了 TurboFan 做优化的一些关键思路。接下来，我们再返回来，重新探讨一下 Ignition 的运行速度问题。

提升 Ignition 的速度

最新版本的 V8 已经不需要多级的编译器了，只需要一个解释器（Ignition）和一个优化编译器（TurboFan）就行。在公开的测试数据中，Ignition 的运行速度，已经接近一个基线编译器生成的机器码的速度了，也就是那种没有做太多优化的机器码。

这听上去似乎不符合常理，毕竟，解释执行怎么能赶得上运行机器码呢？所以，这里一定有一些值得探究的技术原理。

让我们再来看看 Ignition 解释执行的原理吧。

在上一讲中你已经了解到，V8 的字节码是很精简的。比如，对于各种加法操作，它只有一个 Add 指令。

但是我们知道，Add 指令的语义是很复杂的，比如在 ECMAScript 标准中，就对加法的语义有很多的规定，如数字相加该怎么做、字符串连接该怎么做，等等。

图 8：ECMAScript 标准中加法操作的语义规则

这样的话，V8 在解释执行 Add 指令的时候，就要跳到一个内置的函数去执行，其他指令也是如此。这些内置函数的实现质量，就会大大影响解释器的运行速度。

那么如果换做你，你会怎么实现这些内置函数呢？

选择 1：用汇编语言去实现。这样，我们可以针对每种情况写出最优化的代码。但问题是，这样做的工作量很大。因为 V8 现在已经支持了 9 种架构的 CPU，而要为每种架构编写这些内置功能，都需要敲几万行的汇编代码。

选择 2：用 C++ 去实现。这是一个不错的选择，因为 C++ 代码最后编译的结果也是很优化的。不过这里也有一个问题：C++ 有它自己的调用约定，跟 V8 中 JavaScript 的调用约定是不同的。

比如，在调用 C++ 的内置函数之前，解释器要把自己所使用的物理寄存器保护起来，避免被 C++ 程序破坏，在调用完毕以后还要恢复。这使得从解释器到内置函数，以及从内置函数回到解释器，都要做不少的转换工作。你还要写专门的代码，来对标记指针进行转换。而如果要使用 V8 的对象，那要处理的事情就更多了，比如它要去隐藏类中查找信息，以及能否通过优化实现栈上内存分配，等等。

那么，我们还有别的选择吗？

有的。你看，V8 已经有了一个不错的优化编译器 TurboFan。既然它能产生很高效的代码，那么我们为什么不直接用 TurboFan 来生成机器码呢？这个思路其实是可行的。这可以看做是 V8 编译器的一种自举能力，用自己的编译器，来生成自己内部要使用的内置函数的目标代码。

毕竟，TurboFan 本来就是要处理标记指针、JavaScript 对象的内存表示等这些问题。这个方案还省去了做调用约定的转换的工作，因为本来 V8 执行的过程中，就要不断在解释执行和运行机器码之间切换，V8 内部对栈桢和调用约定的设计，就是要确保这种切换的代价最低。

在具体实现的时候，编写这些内置函数是用 JavaScript 调用 TurboFan 提供的一些宏。这些宏可以转化为 TurboFan 的 IR 节点，从而跟 TurboFan 的优化编译功能无缝衔接。

好了，分析到这里，你就知道为什么 Ignition 的运行速度会这么快了：它采用了高度优化过的内置函数的实现，并且没有调用约定转换的负担。而一个基线编译器生成的机器码，因为没有经过充分的优化，反倒并没有那么大的优势。

再补充一点，V8 对字节码也提供了一些优化算法。比如，通过优化，可以减少对临时变量的使用，使得代码可以更多地让累加器起到临时变量的作用，从而减少内存访问次数，提高运行效率。如果你有兴趣在这个话题上去做深入研究，可以参考我在文末链接中给出的一篇论文。

课程小结

本讲，我围绕运行速度这个主题，给你讲解了 V8 在 TurboFan 和 Ignition 上所采用的优化技术。你需要记住以下几个要点：

第一，由于 JavaScript 是动态类型的语言，所以优化的要点，就是推断出函数参数的类型，并形成有针对性的优化代码。

第二，同 Graal 一样，V8 也使用了 Sea of Nodes 的 IR，而且对 V8 来说，内联和逃逸优化算法仍然非常重要。我在解析 Graal 编译器的时候已经给你介绍过了，所以这一讲并没有详细展开，你可以自己去回顾复习一下。

第三，V8 所采用的内联缓存技术，能够在运行期提高对象属性访问的性能。另外你要注意的是，在编写代码的时候，一定要避免对于相同的对象生成不同的隐藏类。

第四，Ignition 采用了 TurboFan 来编译内置函数，这种技术非常聪明，既省了工作量，又简化了系统的结构。实际上，在 Graal 编译器里也有类似的技术，它叫做 Snippet，也是用自身的中后端功能来编译内置函数。所以，你会再次发现，多个编译器之间所采用的编译技术，是可以互相印证的。

这节课的思维导图我同样帮你整理出来了，供你参考和复习：

一课一思

我们已经学了两种动态类型的语言的编译技术：Python 和 JavaScript。那我现在问一个开脑洞的问题：如果你要给 Python 加一个 JIT 编译器，那么你可以从 JavaScript 这里借鉴哪些技术呢？在哪些方面，编译器会得到巨大的性能提升？

参考资料

V8 的指针压缩技术：Pointer Compression in V8

介绍 V8 基于推理的优化机制：An Introduction to Speculative Optimization in V8

对 Ignition 字节码做优化的论文：Register equivalence optimization，我在 GitHub 上也放了一份拷贝

22 | Julia编译器（一）：如何让动态语言性能很高？

你好，我是宫文学。

Julia 这门语言，其实是最近几年才逐渐获得人们越来越多的关注的。有些人会拿它跟 Python 进行比较，认为 Julia 已经成为了 Python 的劲敌，甚至还有人觉得它在未来可能会取代 Python 的地位。虽然这样的说法可能是有点夸张了，不过 Julia 确实是有它的可取之处的。

为什么这么说呢？前面我们已经研究了 Java、Python 和 JavaScript 这几门主流语言的编译器，这几门语言都是很有代表性的：Java 语言是静态类型的、编译型的语言；Python 语言是动态类型的、解释型的语言；JavaScript 是动态类型的语言，但可以即时编译成本地代码来执行。

而 Julia 语言却声称同时兼具了静态编译型和动态解释型语言的优点：一方面它的性能很高，可以跟 Java 和 C 语言媲美；而另一方面，它又是动态类型的，编写程序时不需要指定类型。一般来说，我们很难能期望一门语言同时具有动态类型和静态类型语言的优点的，那么 Julia 又是如何实现这一切的呢？

原来它是充分利用了 LLVM 来实现即时编译的功能。因为 LLVM 是 Clang、Rust、Swift 等很多语言所采用的后端工具，所以我们可以借助 Julia 语言的编译器，来研究如何恰当地利用 LLVM。不过，Julia 使用 LLVM 的方法很有创造性，使得它可以同时具备这两类语言的优点。我将在这一讲中给你揭秘。

此外，Julia 编译器的类型系统的设计也很独特，它体现了函数式编程的一些设计哲学，能够帮助你启迪思维。

还有一点，Julia 来自 MIT，这里也曾经是 Lisp 的摇篮，所以 Julia 有一种学术风和极客风相结合的品味，也值得你去仔细体会一下。

所以，接下来的两讲，我会带你来好好探究一下 Julia 的编译器。你从中能够学习到 Julia 编译器的处理过程，如何创造性地使用 LLVM 的即时编译功能、如何使用 LLVM 的优化功能，以及它的独特的类型系统和方法分派。

那今天这一讲，我会先带你来了解 Julia 的编译过程，以及它高性能背后的原因。

初步认识 Julia

Julia 的性能有多高呢？你可以去它的网站上看看与其他编程语言的性能对比：

图 1：Julia 和各种语言的性能对比

可以看出，它的性能是在 C、Rust 这一个级别的，很多指标甚至比 Java 还要好，比起那些动态语言（如 Python、R 和 Octave），那更是高了一到两个数量级。

所以，Julia 的编译器声称它具备了静态类型语言的性能，确实是不虚此言的。

你可以从Julia 的官网下载 Julia 的二进制版本和源代码。如果你下载的是源代码，那你可以用 make debug 编译成 debug 版本，这样比较方便用 GDB 或 LLDB 调试。

Julia 的设计目的主要是用于科学计算。过去，这一领域的用户主要是使用 R 语言和 Python，但麻省理工（MIT）的研究者们对它们的性能不够满意，同时又想要保留 R 和 Python 的友好性，于是就设计出了这门新的语言。目前这门语言受到了很多用户的欢迎，使用者也在持续地上升中。

我个人对它感兴趣的点，正是因为它打破了静态编译和动态编译语言的边界，我认为这体现了未来语言的趋势：编译和优化的过程是全生命周期的，而不局限在某个特定阶段。

好了，让我们先通过一个例子来认识 Juia，直观了解一下这门语言的特点：

julia> function countdown(n)
           if n <= 0
               println("end")
           else
               print(n, " ")
               countdown(n-1)
           end
       end
countdown (generic function with 1 method)

julia> countdown(10)
10 9 8 7 6 5 4 3 2 1 end

所以从这段示例代码中，可以看出，Julia 主要有这样几个特点：

用 function 关键字来声明一个函数；

用 end 关键字作为块（函数声明、if 语句、for 语句等）的结尾；

函数的参数可以不用指定类型（变量声明也不需要），因为它是动态类型的；

Julia 支持递归函数。

那么 Julia 的编译器是用什么语言实现的呢？又是如何支持它的这些独特的特性的呢？带着这些好奇，让我们来看一看 Julia 编译器的源代码。

图 2：Julia 的源代码目录

其实 Julia 的实现会让人有点困扰，因为它使用了 4 种语言：C、C++、Lisp 和 Julia 自身。相比而言，CPython 的实现只用了两种语言：C 语言和 Python。这种情况，就对社区的其他技术人员理解这个编译器和参与开发，带来了不小的困难。

Julia 的作者用 C 语言实现了一些运行时的核心功能，包括垃圾收集器。他们是比较偏爱 C 语言的。C++ 主要是用来实现跟 LLVM 衔接的功能，因为 LLVM 是用 C++ 实现的。

但是，为什么又冒出了一个 Lisp 语言呢？而且前端部分的主要功能都是用 Lisp 实现的。

原来，Julia 中用到 Lisp 叫做femtolisp（简称 flisp），这是杰夫·贝赞松（Jeff Bezanson）做的一个开源 Lisp 实现，当时的目标是做一个最小的、编译速度又最快的 Lisp 版本。后来 Jeff Bezanson 作为 Julia 的核心开发人员，又把 flisp 带进了 Julia。

实际上，Julia 语言本身也宣称是继承了 Lisp 语言的精髓。在其核心的设计思想里，在函数式编程风格和元编程功能方面，也确实是如此。Lisp 在研究界一直有很多的追随者，Julia 这个项目诞生于 MIT，同时又主要服务于各种科研工作者，所以它也就带上了这种科学家的味道。它还有其他特性，也能看出这种科研工作者的倾向，比如说：

对于类型系统，Julia 的开发者们进行了很好的形式化，是我在所有语言中看到的最像数学家做的类型系统。

在它的语法和语义设计上，带有 Metalab 和 Mathematics 这些数学软件的痕迹，科研工作者们应该很熟悉这种感觉。

在很多特性的实现上，都带有很强的前沿探索的特征，锋芒突出，不像我们平常使用的那些商业公司设计的计算机语言一样，追求四平八稳。

以上就是我对 Julia 的感觉，一种结合了数据家风格的自由不羁的极客风。实际上，Lisp 最早的设计者约翰·麦卡锡（John McCarthy）就是一位数学博士，所以数学上的美感是 Lisp 给人的感受之一。而且，Lisp 语言本身也是在 MIT 发源的，所以 Julia 可以说是继承了这个传统、这种风格。

Julia 的编译过程

刚刚说了，Julia 的前端主要是用 Lisp 来实现的。你在启动 Julia 的时候，通过“–lisp”参数就可以进入 flisp 的 REPL：

./julia --lisp

在这个 REPL 界面中调用一个 julia-parse 函数，就可以把一个 Julia 语句编译成 AST。

> (julia-parse "a = 2+3*5")
(= a (call + 2
       (call * 3 5)))
> (julia-parse "function countdown(n)
                    if n <= 0
                        println(\"end\")
                    else
                        print(n, \" \")
                        countdown(n-1)
                    end
                end")
(function (call countdown n) (block (line 2 none)
                    (if (call <= n 0)
                    (block (line 3 none)
                           (call println "end"))
                    (block (line 5 none)
                           (call print n " ")
                           (line 6 none)
                           (call countdown (call - n 1))))))

编译后的 AST，采用的也是 Lisp 那种括号嵌套括号的方式。

Julia 的编译器中，主要用到了几个“.scm”结尾的代码，来完成词法和语法分析的功能：julia-parser.scm、julia-syntax.scm 和 ast.scm。（.scm文件是 Scheme 的缩写，而 Scheme 是 Lisp 的一种实现，特点是设计精简、语法简单。著名的计算机教科书SICP就是用Scheme作为教学语言，而 SICP 和 Scheme 也都是源自 MIT。）它的词法分析和语法分析的过程，主要是在 parser.scm 文件里实现的，我们刚才调用的“julia-parse”函数就是在这个文件中声明的。

Julia 的语法分析过程仍然是你非常熟悉的递归下降算法。因为 Lisp 语言处理符号的能力很强，又有很好的元编程功能（宏），所以 Lisp 在实现词法分析和语法分析的任务的时候，代码会比其他语言更短。但是不熟悉 Lisp 语言的人，可能会看得一头雾水，因为这种括号嵌套括号的语言对于人类的阅读不那么友好，不像 Java、JavaScript 这样的语言一样，更像自然语言。

julia-parser.scm 输出的成果是比较经典的 AST，Julia 的文档里叫做“表面语法 AST”（surface syntax AST）。所谓表面语法 AST，它是跟另一种 IR 对应的，叫做Lowered Form。

“Lowered”这个词你应该已经很熟悉了，它的意思是更靠近计算机的物理实现机制。比如，LLVM 的 IR 跟 AST 相比，就更靠近底层实现，也更加不适合人类阅读。

julia-syntax.scm 输出的结果就是 Lowered Form，这是一种内部 IR。它比 AST 的节点类型更少，所有的宏都被展开了，控制流也简化成了无条件和有条件跳转的节点（“goto”格式）。这种 IR 后面被用来做类型推断和代码生成。

你查看 julia-syntax.scm 的代码，会发现 Julia 编译器的处理过程是由多个 Pass 构成的，包括了去除语法糖、识别和重命名本地变量、分析变量的作用域和闭包、把闭包函数做转换、转换成线性 IR、记录 Slot 和标签（label）等。

这里，我根据 Jeff Bezanson 在 JuliaCon 上讲座的内容，把 Julia 编译器的工作过程、每个阶段涉及的源代码和主要的函数给你概要地梳理了一下，你可以只看这张图，就能大致把握 Julia 的编译过程，并且可以把它跟你学过的其他几个编译器做一下对比：

图 3：Julia 的编译过程

Julia 有很好的反射 (Reflection) 和自省 (Introspection)的能力，你可以调用一些函数或者宏来观察各个阶段的成果。

比如，采用 @code_lowered 宏，来看 countdown(10) 产生的代码，你会看到 if…else…的结构被转换成了“goto”语句，这个 IR 看上去已经有点像 LLVM 的 IR 格式了。

进一步，你还可以用 @code_typed 宏，来查看它做完类型推断以后的结果，每条语句都标注了类型：

接下来，你可以用 @code_llvm 和 @code_native 宏，来查看生成的 LLVM IR 和汇编代码。这次，我们用一个更简单的函数 foo()，让生成的代码更容易看懂：

julia> function foo(x,y)   #一个简单的函数，把两个参数相加
           x+y        #最后一句的结果就是返回值，这里可以省略return
       end

通过 @code_llvm 宏生成的 LLVM IR，如下图所示：

通过 @code_native 宏输出的汇编代码是这样的：

最后生成的汇编代码，可以通过汇编器迅速生成机器码并运行。

通过上面的梳理，你应该已经了解了 Julia 的编译过程脉络：通过 Lisp 的程序，把程序变成 AST，然后再变成更低级一点的 IR，在这个过程中编译器要进行类型推断等语义层面的处理；最后，翻译成 LLVM 的 IR，并生成可执行的本地代码。

对于静态类型的语言来说，我们根据准确的类型信息，就可以生成高效的本地代码，这也是 C 语言性能高的原因。比如，我们用 C 语言来写一下 foo 函数：

long foo(long x, long y){
    return x+y;
}

Clang 的 LLVM IR 跟 Julia 生成的基本一样：

生成的汇编代码也差不多：

所以，对于这样的程序，Julia 的编译后的本地代码，跟 C 语言的比起来可以说是完全一样。那性能高也就不足为奇了。

你可能由此就会下结论：因为 Julia 能够借助 LLVM 生成本地代码，这就是它性能高的原因。

且慢！事情没有这么简单。为什么这么说？因为在基于前面生成的机器码的这个例子中，当参数是整型的时候，运行效率自然是会比较快。但是，你别忘了 Julia 是动态类型的语言。我们在 Julia 中声明 foo 函数的时候，并没有指定参数的数据类型。如果参数类型变了，会怎样呢？

Julia 的最大突破：生成多个版本的目标代码

实际上，我们可以给它传递不同的参数，比如可以传递两个浮点数给它，甚至传递两个向量或者矩阵给它，都能得到正确的结果：

julia> foo(2.1, 3.2)
5.300000000000001

julia> foo([1,2,3], [3,4,5])
3-element Array{Int64,1}:
 4
 6
 8

显然，如果上面两次对 foo() 函数的调用，我们也是用之前生成的汇编代码，那是行不通的。因为之前的汇编代码只能用于处理 64 位的整数。

实际上，如果我们观察调用 foo(2.1, 3.2) 时，Julia 生成的 LLVM IR 和汇编代码，就会发现，它智能地适应了新的数据类型，生成了用于处理浮点数的代码，使用了不同的指令和寄存器。

你可以用同样的方法，来试一下 foo([1,2,3], [3,4,5]) 对应的 LLVM IR 和汇编代码。这个就要复杂一点了，因为它要处理数组的存储。但不管怎样，Julia 生成的代码确实是适应了它的参数类型的。

数学中的很多算法，其实是概念层面的，它不关心涉及的数字是 32 位整数、64 位整数，还是一个浮点数。但同样是实现一个加法操作，对于计算机内部实现来说，不同的数据类型对应的指令则是完全不同的，那么编译器就要弥合抽象的算法和计算机的具体实现之间的差别。

对于 C 语言这样的静态语言来说，它需要针对 x、y 的各种不同的数据类型，分别编写不同的函数。这些函数的逻辑是一样的，但就是因为数据类型不同，我们就要写很多遍。这是不太合理的，太啰嗦了。

对于 Python 这样的动态类型语言来说呢，倒是简洁地写一遍就可以了。但在运行时，对于每一次运算，我们都要根据数据类型来选择合适的操作。这样就大大拉低了整体的运行效率。

所以，这才是 Julia 真正的突破：它能针对同一个算法，根据运行时获得的数据，进行类型推断，并编译生成最优化的本地代码。在每种参数类型组合的情况下，只要编译一次，就可以被缓存下来，可以使用很多次，从而使得程序运行的总体性能很高。

你对比一下 JavaScript 编译器基于类型推断的编译优化，就会发现它们之间的明显的不同。JavaScript 编译器一般只会针对类型推断生成一个版本的目标代码，而 Julia 则会针对每种参数类型组合，都生成一个版本。

不过，既然 Julia 编译器存在多个版本的目标代码，那么在运行期，就要有一个程序来确定到底采用哪个版本的目标代码，这就是 Julia 的一个非常重要的功能：函数分派算法。

函数分派，就是指让编译器在编译时或运行时来确定采用函数的哪个实现版本。针对函数分派，我再给你讲一下 Julia 的一个特色功能，多重分派。这个知识点有助于你加深对于函数分派的理解，也有助于你理解函数式编程的特点。

Julia 的多重分派功能

我们在编程的时候，经常会涉及同一个函数名称的多个实现。比如在做面向对象编程的时候，同一个类里可以有多个相同名称的方法，但参数不同，这种现象有时被叫做重载（Overload）；同时，在这个类的子类里，也可以定义跟父类完全相同的方法，这种现象叫做覆盖（Override）。

而程序在调用一个方法的时候，到底调用的是哪个实现，有时候我们在编译期就能确定下来，有时候却必须到运行期才能确定（就像多态的情形），这两种情形就分别叫做静态分派（Static Dispatch）和动态分派（Dynamic Dispatch）。

方法的分派还有另一个分类：单一分派（Single Dispatch）和多重分派（Multiple Dispatch）。传统的面向对象的语言使用的都是单一分派。比如，在面向对象语言里面，实现加法的运算：

a.add(b)

这里我们假设 a 和 b 都有多个 add 方法的版本，但实际上，无论怎样分派，程序的调用都是分派到 a 对象的方法上。这是因为，对于 add 方法，实质上它的第一个参数是对象 a（编译成目标代码时，a 会成为第一个参数，以便访问封装在 a 里面的数据），也就是相当于这样一个函数：

add(a, b)

所以，面向对象的方法分派相当于是由第一个参数决定的。这种就是单一分派。

实际上，采用面向对象的编程方式，在方法分派时经常会让人觉得很别扭。你回顾一下，我在讲Python 编译器的时候，讲到加法操作采用的实现是第一个操作数对象的类型里，定义的与加法有关的函数。但为什么它是用第一个对象的方法，而不是第二个对象的呢？如果第一个对象和第二个对象的类型不同怎么办呢？（这就是我在那讲中留给你的问题）

还有一个很不方便的地方。如果你增加了一种新的数据类型，比如矩阵（Matrix），它要能够跟整数、浮点数等进行加减乘除运算，但你没有办法给 Integer 和 Float 这些已有的类增加方法。

所以，针对这些很别扭的情况，Julia 和 Lisp 等函数式语言，就支持多重分派的方式。

你只需要定义几个相同名称的函数（在 Julia 里，这被叫做同一个函数的多个方法），编译器在运行时会根据参数，决定分派给哪个方法。

我们来看下面这个例子，foo 函数有两个方法，根据调用参数的不同，分别分派给不同的方法。

julia> foo(x::Int64, y::Int64) = x + y  #第一个方法
foo (generic function with 1 method)

julia> foo(x, y) = x - y                #第二个方法
foo (generic function with 2 methods)

julia> methods(foo)                     #显示foo函数的所有方法
# 2 methods for generic function "foo":
[1] foo(x::Int64, y::Int64) in Main at REPL[38]:1
[2] foo(x, y) in Main at REPL[39]:1

julia> foo(2, 3)                        #分派到第一个方法
5

julia> foo(2.0, 3)                      #分派到第二个方法
-1.0

你可以发现，这种分派方法会公平对待函数的所有参数，而不是由一个特殊的参数来决定。这种分派方法就叫做多重分派。

在 Julia 中，其实“+”操作符（以及其他操作符）也是函数，它有上百个不同的方法，分别处理不同数据类型的加法操作。

julia> methods(+)
# 166 methods for generic function "+":
[1] +(x::Bool, z::Complex{Bool}) in Base at complex.jl:282
[2] +(x::Bool, y::Bool) in Base at bool.jl:96
[3] +(x::Bool) in Base at bool.jl:93
[4] +(x::Bool, y::T) where T<:AbstractFloat in Base at bool.jl:104
[5] +(x::Bool, z::Complex) in Base at complex.jl:289
[6] +(a::Float16, b::Float16) in Base at float.jl:398
[7] +(x::Float32, y::Float32) in Base at float.jl:400
[8] +(x::Float64, y::Float64) in Base at float.jl:401
[9] +(z::Complex{Bool}, x::Bool) in Base at complex.jl:283
...
[165] +(J::LinearAlgebra.UniformScaling, F::LinearAlgebra.Hessenberg) in LinearAlgebra ... at hessenberg.jl:518
[166] +(a, b, c, xs...) in Base at operators.jl:529

最重要的是，当你引入新的数据类型，想要支持加法运算的时候，你只需要为加法函数定义一系列新的方法，那么编译器就可以正确地分派了。这种实现方式就方便多了。这也是某些函数式编程语言的一个优势，你可以体会一下。

而且在 Julia 中，因为方法分派是动态实现的，所以分派算法的性能就很重要。你看，不同的语言特性的设计，它的运行时就要完成不同的任务。这就是真实世界中，各种编译器的魅力所在。

课程小结

这一讲我给你介绍了一门并不是那么大众的语言，Julia。介绍它的原因，就是因为这门语言有自己非常独特的特点，非常值得我们学习。我希望你能记住以下几点核心的知识：

编译器的实现语言：编译器在选择采用什么实现的语言上，拥有很大的自由度。Julia 很别具一格地采用了 Lisp 作为主要的前端语言。不过，我个人猜测，既然 Julia 本身也算是一种 Lisp 实现，未来可能就可以用 Julia 取代 flisp，来实现前端的功能，实现更大程度的自举（Bootstraping）了。当然，这仅仅是我自己的猜测。

又是递归下降算法：一如既往地，递归下降算法仍然是最常被用来实现语法分析的方法，Julia 也不例外。

多种 IR：Julia 在 AST 之外，采用了“goto”格式的 IR，还用到了 LLVM 的 IR（实际上，LLVM 内部在转换成本地代码之前，还有一个中间格式的 IR）。

多版本的目标代码：Julia 创造性地利用了 LLVM 的即时编译功能。它可以在运行期通过类型推断确定变量的类型，进行即时编译，根据不同的参数类型生成多个版本的目标代码，让程序员写一个程序就能适应多种数据类型，既降低了程序员的工作量，同时又保证了程序的高性能。这使得 Julia 同时拥有了动态类型语言的灵活性和静态类型语言的高性能。

多重分派功能：多重分派能够根据方法参数的类型，确定其分派到哪个实现。它的优点是容易让同一个操作，扩展到支持不同的数据类型。

你学了这讲有什么体会呢？深入探究 Julia 这样的语言的实现过程，真的有助于我们大开脑洞，突破思维的限制，更好地融合编译原理的各方面的知识，从而为你的实际工作带来更加创新的思路。

这一讲的思维导图我也给你整理出来了，供你参考和复习回顾：

一课一思

一个很有意思的问题：为什么 Julia 会为一个函数，根据不同的参数类型组合，生成多个版本的目标代码，而 JavaScript 的引擎一般只会保存一个版本的目标代码？这个问题你可以从多个角度进行思考，欢迎在留言区分享你的观点。

感谢你的阅读，如果你觉得有收获，欢迎把今天的内容分享给更多的朋友。

23 | Julia编译器（二）：如何利用LLVM的优化和后端功能？

你好，我是宫文学。

上一讲，我给你概要地介绍了一下 Julia 这门语言，带你一起解析了它的编译器的编译过程。另外我也讲到，Julia 创造性地使用了 LLVM，再加上它高效的分派机制，这就让一门脚本语言的运行速度，可以跟 C、Java 这种语言媲美。更重要的是，你用 Julia 本身，就可以编写需要高性能的数学函数包，而不用像 Python 那样，需要用另外的语言来编写（如 C 语言）高性能的代码。

那么今天这一讲，我就带你来了解一下 Julia 运用 LLVM 的一些细节。包括以下几个核心要点：

如何生成 LLVM IR？

如何基于 LLVM IR 做优化？

如何利用内建（Intrinsics）函数实现性能优化和语义个性化？

这样，在深入解读了这些问题和知识点以后，你对如何正确地利用 LLVM，就能建立一个直观的认识了，从而为自己使用 LLVM 打下很好的基础。

好，首先，我们来了解一下 Julia 做即时编译的过程。

即时编译的过程

我们用 LLDB 来跟踪一下生成 IR 的过程。

$ lldb                      #启动lldb
(lldb)attach --name julia   #附加到julia进程
c                           #让julia进程继续运行

首先，在 Julia 的 REPL 中，输入一个简单的 add 函数的定义：

julia> function add(a, b)
            x = a+b
            x
       end

接着，在 LLDB 或 GDB 中设置一个断点“br emit_funciton”，这个断点是在 codegen.cpp 中。

(lldb) br emit_function    #添加断点

然后在 Julia 里执行函数 add：

julia> add(2,3)

这会触发 Julia 的编译过程，并且程序会停在断点上。我整理了一下调用栈的信息，你可以看看，即时编译是如何被触发的。

通过跟踪执行和阅读源代码，你会发现 Julia 中最重要的几个源代码：

gf.c：Julia 以方法分派快速而著称。对于类似加法的这种运算，它会有上百个方法的实现，所以在运行时，就必须能迅速定位到准确的方法。分派就是在 gf.c 里。

interpreter.c：它是 Julia 的解释器。虽然 Julia 中的函数都是即时编译的，但在 REPL 中的简单的交互，靠解释执行就可以了。

codegen.cpp：生成 LLVM IR 的主要逻辑都在这里。

我希望你能自己动手跟踪执行一下，这样你就会彻底明白 Julia 的运行机制。

Julia 的 IR：采用 SSA 形式

在上一讲中，你已经通过 @code_lowered 和 @code_typed 宏，查看过了 Julia 的 IR。

Julia 的 IR也经历了一个发展演化过程，它的 IR 最早不是 SSA 的，而是后来才改成了SSA 形式。这一方面是因为，SSA 真的是有优势，它能简化优化算法的编写；另一方面也能看出，SSA 确实是趋势呀，我们目前接触到的 Graal、V8 和 LLVM 的 IR，都是 SSA 格式的。

Julia 的 IR 主要承担了两方面的任务。

第一是类型推断，推断出来的类型被保存到 IR 中，以便于生成正确版本的代码。

第二是基于这个 IR 做一些优化，其实主要就是实现了内联优化。内联优化是可以发生在比较早的阶段，你在 Go 的编译器中就会看到类似的现象。

你可以在 Julia 中写两个短的函数，让其中一个来调用另一个，看看它所生成的 LLVM 代码和汇编代码是否会被自动内联。

另外，你还可以查看一下传给 emit_function 函数的 Julia IR 是什么样子的。在 LLDB 里，你可以用下面的命令来显示 src 参数的值（其中，jl_(obj)是 Julia 为了调试方便提供的一个函数，它能够更好地显示 Julia 对象的信息，注意显示是在 julia 窗口中）。src 参数里面包含了要编译的 Julia 代码的信息。

(lldb) expr jl_(src)

为了让你能更容易看懂，我稍微整理了一下输出的信息的格式：

你会发现，这跟用 @code_typed(add(2,3)) 命令打印出来的信息是一致的，只不过宏里显示的信息会更加简洁：

接下来，查看 emit_function 函数，你就能够看到生成 LLVM IR 的整个过程。

生成 LLVM IR

LLVM 的 IR 有几个特点：

第一，它是 SSA 格式的。

第二，LLVM IR 有一个类型系统。类型系统能帮助生成正确的机器码，因为不同的字长对应的机器码指令是不同的。

第三，LLVM 的 IR 不像其他 IR，一般只有内存格式，它还有文本格式和二进制格式。你完全可以用文本格式写一个程序，然后让 LLVM 读取，进行编译和执行。所以，LLVM 的 IR 也可以叫做 LLVM 汇编。

第四，LLVM 的指令有丰富的元数据，这些元数据能够被用于分析和优化工作中。

基本上，生成 IR 的程序没那么复杂，就是用简单的语法制导的翻译即可，从 AST 或别的 IR 生成 LLVM 的 IR，属于那种比较幼稚的翻译方法。

采用这种方法，哪怕一开始生成的 IR 比较冗余，也没有关系，因为我们可以在后面的优化过程中继续做优化。

在生成的 IR 里，会用到 Julia 的内建函数（Intrinsics），它代表的是一些基础的功能。

在JavaScript 的编译器里，我们已经接触过内置函数（Built-in）的概念了。而在 Julia 的编译器中，内建函数和内置函数其实是不同的概念。

内置函数是标准的 Julia 函数，它可以有多个方法，根据不同的类型来分派。比如，取最大值、最小值的函数 max()、min() 这些，都是内置函数。

而内建函数只能针对特定的参数类型，没有多分派的能力。Julia 会把基础的操作，都变成对内建函数的调用。在上面示例的 IR 中，就有一个 add_in() 函数，也就是对整型做加法运算，它就是内建函数。内建函数的目的是生成 LLVM IR。Julia 中有近百个内置函数。在intrinsics.cpp中，有为这些内置函数生成 LLVM IR 的代码。

这就是 Julia 生成 LLVM IR 的过程：遍历 Julia 的 IR，并调用 LLVM 的 IRBuilder 类，生成合适的 IR。在此过程中，会遇到很多内建函数，并调用内建函数输出 LLVM IR 的逻辑。

运行 LLVM 的 Pass

我们之所以会使用 LLVM，很重要的一个原因就是利用它里面的丰富的优化算法。

LLVM 的优化过程被标准化成了一个个的 Pass，并由一个 PassManager 来管理。你可以查看 jitlayers.cpp 中的addOptimizationPasses() 函数，看看 Julia 都使用了哪些 Pass。

LoopRotate：官方文档 / SLPVectorizer：官方文档 /《编译原理之美》：第 31 讲

上面表格中的 Pass 都是 LLVM 中自带的 Pass。你要注意，运用好这些 Pass，会产生非常好的优化效果。比如，某个开源项目，由于对性能的要求比较高，所以即使在 Windows 平台上，仍然强烈建议使用 Clang 来编译，而 Clang 就是基于 LLVM 的。

除此之外，Julia 还针对自己语言的特点，写了几个个性化的 Pass。比如：

这些个性化的 Pass 是针对 Julia 本身的语言特点而编写的。比如对于垃圾收集，每种语言的实现策略都不太一样，因此就必须自己实现相应的 Pass，去插入与垃圾收集有关的代码。再比如，Julia 是面向科学计算的，比较在意数值计算的性能，所以自己写了两个 Pass 来更好地利用 CPU 的一些特殊指令集。

emit_function 函数最后返回的是一个模块（Module）对象，这个模块里只有一个函数。这个模块会被加入到一个JuliaOJIT对象中进行集中管理。Julia 可以从 JuliaOJIT 中，查找一个函数并执行，这就是 Julia 能够即时编译并运行的原因。

不过，我们刚才说的都是生成 LLVM IR 和基于 IR 做优化。那么，LLVM 的 IR 又是如何生成机器码的呢？对于垃圾收集功能，LLVM 是否能给予帮助呢？在使用 LLVM 方面还需要注意哪些方面的问题呢？

利用 LLVM 的正确姿势

在这里，我给你总结一下 LLVM 的功能，并带你探讨一下如何恰当地利用 LLVM 的功能。

通过这门课，你其实已经能够建立这种认识：编译器后端的工作量更大，某种意义上也更重要。如果我们去手工实现每个优化算法，为每种架构、每种 ABI 来生成代码，那不仅工作量会很大，而且还很容易遇到各种各样需要处理的 Bug。

使用 LLVM，就大大降低了优化算法和生成目标代码的工作量。LLVM 的一个成功的前端是 Clang，支持对 C、C++ 和 Objective-C 的编译，并且编译速度和优化效果都很优秀。既然它能做好这几种语言的优化和代码生成，那么用来支持你的语言，你也应该放心。

总体来说，LLVM 能给语言的设计者提供这样几种帮助：

程序的优化功能

你可以通过 LLVM 的 API，从你的编译器的前端生成 LLVM IR，然后再调用各种分析和优化的 Pass 进行处理，就能达到优化目标。

LLVM 还提供了一个框架，让你能够编写自己的 Pass，满足自己的一些个性化需求，就像 Julia 所做的那样。

LLVM IR 还有元数据功能，来辅助一些优化算法的实现。比如，在做基于类型的别名分析（TPAA）的时候，需要用到在前端解析中获得类型信息的功能。你在生成 LLVM IR 的时候，就可以把这些类型信息附加上，这样有助于优化算法的运行。

目标代码生成功能

LLVM 支持对 x86、ARM、PowerPC 等各种 CPU 架构生成代码的功能。同时，你应该还记得，在第 8 讲中，我说过 ABI 也会影响代码的生成。而 LLVM，也支持 Windows、Linux 和 macOS 的不同的 ABI。

另外，你已经知道，在目标代码生成的过程中，一般会需要三大优化算法：指令选择、寄存器分配和指令排序算法。LLVM 对此同样也给予了很好的支持，你直接使用这些算法就行了。

最后，LLVM 的代码生成功能对 CPU 厂家也很友好，因为这些算法都是目标独立（Target-independent）的。如果硬件厂家推出了一个新的 CPU，那它可以用 LLVM 提供的 TableGen 工具，来描述这款新 CPU 的架构，这样我们就能使用 LLVM 来为它生成目标代码了。

对垃圾收集的支持

LLVM 还支持垃圾收集的特性，比如会提供安全点、读屏障、写屏障功能等。这些知识点我会在第 32 讲“垃圾收集”的时候带你做详细的了解。

对 Debug 的支持

我们知道，代码的跟踪调试对于程序开发是很重要的。如果一门语言是生成机器码的，那么要实现跟踪调试，我们必须往代码里插入一些调试信息，比如目标代码对应的源代码的位置、符号表等。这些调试信息是符合 DWARF（Debugging With Attributed Record Formats，使用有属性的记录格式进行调试）标准的，这样 GDB、LLDB 等各种调试工具，就可以使用这些调试信息进行调试了。

对 JIT 的支持

LLVM 内置了对 JIT 的支持。你可以在运行时编译一个模块，生成的目标代码放在内存里，然后运行该模块。实际上，Julia 的编译器能够像普通的解释型语言那样运行，就是运用了 LLVM 的 JIT 机制。

其他功能

LLVM 还在不断提供新的支持，比如支持在程序链接的时候进行过程间的优化，等等。

总而言之，研究 Julia 的编译器，就为我们使用 LLVM 提供了一个很好的样本。你在有需要的时候，也可以作为参考。

课程小结

今天这一讲，我们主要研究了 Julia 如何实现中后端功能的，特别是在这个过程中，它是如何使用 LLVM 的，你要记住以下要点：

Julia 自己的 IR 也是采用 SSA 格式的。这个 IR 的主要用途是类型推断和内联优化。

Julia 的 IR 会被转化成 LLVM 的 IR，从而进一步利用 LLVM 的功能。在转换过程中，会用到 Julia 的内建函数，这些内建函数代表了 Julia 语言中，抽象度比较高的运算功能，你可以拿它们跟 V8 的 IR 中，代表 JavaScript 运算的高级节点作类比，比如加法计算节点。这些内建函数会生成体现 Julia 语言语义的 LLVM IR。

你可以使用 LLVM 的 Pass 来实现代码优化。不过使用哪些 Pass、调用的顺序如何，是由你自己安排的，并且你还可以编写自己个性化的 Pass。

LLVM 为程序优化和生成目标代码提供了可靠的支持，值得重视。而 Julia 为使用 LLVM，就提供了一个很好的参考。

本讲的思维导图我也给你整理出来了，供你参考和复习回顾知识点：

一课一思

LLVM 强调全生命周期优化的概念。那么我们来思考一个有趣的问题：能否让 Julia 也像 Java 的 JIT 功能一样，在运行时基于推理来做一些激进的优化？如何来实现呢？欢迎在留言区发表你的观点。

参考资料

LLVM 的官网：llvm.org。如果你想像 Julia、Rust、Swift 等语言一样充分利用 LLVM，那么应该会经常到这里来查阅相关资料。

LLVM 的源代码。像 LLVM 这样的开源项目，不可能通过文档或者书籍来获得所有的信息。最后，你还是必须去阅读源代码，甚至要根据 Clang 等其他前端使用 LLVM 的输出做反向工程，才能掌握各种细节。LLVM 的核心作者也推荐开发者源代码当作文档。

Working with LLVM：Julia 的开发者文档中，有对如何使用 LLVM 的介绍。

LLVM’s Analysis and Transform Passes：对 LLVM 中的各种 Pass 的介绍。要想使用好 LLVM，你就要熟悉这些 Pass 和它们的使用场景。

在《编译原理之美》的第 25 讲和第 26 讲，我对 LLVM 后端及其命令行工具做了介绍，并且还手工调用 LLVM 的 API，示范了针对不同的语法结构（比如 if 结构）应该如何生成 LLVM IR，最后即时编译并运行。你可以去参考看看。

24 | Go语言编译器：把它当作教科书吧

你好，我是宫文学。今天这一讲，我来带你研究一下 Go 语言自带的编译器，它可以被简称为 gc。

我之所以要来带你研究 Go 语言的编译器，一方面是因为 Go 现在确实非常流行，很多云端服务都用 Go 开发，Docker 项目更是巩固了 Go 语言的地位；另一方面，我希望你能把它当成编译原理的教学参考书来使用。这是因为：

Go 语言的编译器完全用 Go 语言本身来实现，它完全实现了从前端到后端的所有工作，而不像 Java 要分成多个编译器来实现不同的功能模块，不像 Python 缺少了后端，也不像 Julia 用了太多的语言。所以你研究它所采用的编译技术会更方便。

Go 编译器里基本上使用的都是经典的算法：经典的递归下降算法、经典的 SSA 格式的 IR 和 CFG、经典的优化算法、经典的 Lower 和代码生成，因此你可以通过一个编译器就把这些算法都贯穿起来。

除了编译器，你还可以学习到一门语言的其他构成部分的实现思路，包括运行时（垃圾收集器、并发调度机制等）、标准库和工具链，甚至连链接器都是用 Go 语言自己实现的，从而对实现一门语言所需要做的工作有更完整的认识。

最后，Go 语言的实现继承了从 Unix 系统以来形成的一些良好的设计哲学，因为 Go 语言的核心设计者都是为 Unix 的发展，做出过重要贡献的极客。因此了解了 Go 语言编译器的实现机制，会提高你的软件设计品味。

扩展：每种语言都有它的个性，而这个个性跟语言设计者的背景密切相关。Go 语言的核心设计者，是 Unix 领域的极客，包括 Unix 的创始人和 C 语言的共同发明人之一，Ken Tompson。Rob Pike 也是 Unix 的核心作者。

Go 语言的作者们显然希望新的语言体现出他们的设计哲学和口味。比如，致力于像 Unix 那样的简洁和优雅，并且致力于让 Go 再次成为一款经典作品。

所以，在已经研究了多个高级语言的编译器之后，我们可以拿 Go 语言的编译器，把整个编译过程再重新梳理和印证一遍。

好了，现在就开始我们今天探索的旅途吧。

首先，我们来看看 Go 语言编译器的前端。

重要提示：照例，你要下载 Go 语言的源代码，本讲采用的是 1.14.2 版本。并且，你最好使用一个 IDE，便于跟踪调试编译器的执行过程。

Go 的源代码中附带的介绍编译器的文档，写得很好、很清晰，你可以参考一下。

词法分析和语法分析

Go 的编译器的词法分析和语法分析功能的实现，是在 cmd/compile/internal/syntax 目录下。

词法分析器是 scanner.go。其实大部分编程语言的词法分析器的算法，都已经很标准了，我们在Java 编译器里就曾经分析过。甚至它们处理标识符和关键字的方式也都一致，都是先作为标识符识别出来，然后再查表挑出关键字来。Go 的词法分析器并没有像 V8 那样在不遗余力地压榨性能，它跟你平常编码的方式是很一致的，非常容易阅读。

语法分析器是 parser.go。它是一个标准的手写的递归下降算法。在解析二元表达式的时候，Go 的语法分析器也是采用了运算符优先级算法，这个已经是我们第 N 次见到这个算法了，所以你一定要掌握！不过，每个编译器的实现都不大一样，而 Go 的实现方式相当的简洁，你可以去自己看一下，或者用调试器来跟踪一下它的执行过程。

图 1：用 IDE 工具 Goland 跟踪调试编译过程

Go 的 AST 的节点，是在 nodes.go 中定义的，它异常简洁，可以说简洁得让你惊讶。你可以欣赏一下。

Go 的语法分析器还有一个很有特色的地方，就是对错误的处理。它在处理编译错误时，有一个原则，就是不要遇到一个错误就停止编译，而是要尽可能跳过当前这个出错的地方，继续往下编译，这样可以一次多报几个语法错误。

parser.go 的处理方式是，当语法分析器在处理某个产生式的时候，如果发现了错误，那就记录下这个错误，并且往下跳过一些 Token，直到找到一个 Token 是属于这个产生式的 Follow 集合的。这个时候编译器就认为找到了这个产生式的结尾。这样分析器就可以跳过这个语法单元，继续处理下面的语法单元。

比如，在解析函数声明语句时，如果 Go 的语法分析器没有找到函数名称，就报错“expecting name or (”，然后往后找到“{”或者“;”，这样就跳过了函数名称的声明部分，继续去编译后面的函数体部分。

在 cmd/compile/internal/syntax 目录下，还有词法分析器和语法分析器的测试程序，你可以去运行测试一下。

最后，如果你还想对 Go 语言的语法分析有更加深入地了解，我建议你去阅读一下Go 语言的规范，它里面对于每个语法单元，都有 EBNF 格式的语法规则定义，比如对语句的定义。你通过看代码、看语言规范，积累语法规则的第一手经验，以后再看到一段程序，你的脑子里就能反映出它的语法规则，并且能随手画出 AST 了，这是你学习编译原理需要建立的硬功夫。比如说，这里我节选了一段 Go 语言的规范中针对语句的部分语法规则。

Statement =
  Declaration | LabeledStmt | SimpleStmt |
  GoStmt | ReturnStmt | BreakStmt | ContinueStmt | GotoStmt |
  FallthroughStmt | Block | IfStmt | SwitchStmt | SelectStmt |
    ForStmt | DeferStmt .

SimpleStmt = EmptyStmt | ExpressionStmt | SendStmt | IncDecStmt |
    Assignment | ShortVarDecl .

好，在了解了 Go 语言编译器的语法分析工作以后，接下来，我们再来看看它的语义分析阶段。

语义分析（类型检查和 AST 变换）

语义分析的程序，是在 cmd/compile/internal/gc 目录下（注意，gc 的意思是 Go Compiler，不是垃圾收集的意思）。在入口代码 main.go 中，你能看到整个编译过程的主干步骤。

语义分析的主要程序是在typecheck.go中。这里你要注意，不要被“typecheck”的名称所误导，它其实不仅是做类型检查，还做了名称消解（Name Resolution）和类型推导。

你已经知道，名称消解算法的特点，是分阶段完成。举个例子，在给表达式“a=b”中的变量 b 做引用消解之前，编译器必须先处理完 b 的定义，比如“var b Person”，这样才知道符号 b 指的是一个 Person 对象。

另外，在前面学习Java 编译器的时候，你已经知道，对方法体中的本地变量的消解，必须放在最后，才能保证变量的使用总是引用到在它前面的变量声明。Go 的编译器也是采用了相同的实现思路，你可以借此再回顾一下这个知识点，加深认识。

在语义分析阶段，Go 的编译器还做了一些 AST 变换的工作。其中就有内联优化和逃逸分析这两项工作。在我们之前解析的编译器当中，这两项工作都是基于专门做优化的 IR（比如 Sea of Nodes）来做的，而在 Go 的编译器里，却可以基于 AST 来做这两项优化。你看，是不是真实世界中的编译器，才能让你如此开阔眼界？

你可以用“-m”参数来编译程序，它会打印出与内联和逃逸方面有关的优化。你可以带上多个“-m”参数，打印出嵌套层次更深的算法步骤的决策。

go build -gcflags '-m -m' hello.go

好了，现在我们借 gc 编译器，又复习了一遍语义分析中的一些关键知识点：名称消解算法要分阶段，在语义分析阶段会对 AST 做一些变换。我们继续来看 gc 编译器下一步的处理工作。

生成 SSA 格式的 IR

gc 编译器在做完语义分析以后，下一步就是生成 IR 了。并且，gc 的 IR 也是 SSA 格式的。你可以通过 gc，来进一步了解如何生成和处理 SSA 格式的 IR。

好，首先，我们来看看 Go 语言的 IR 是什么样子的。针对下面的示例代码 foo.go，我们来看下它对应的 SSA 格式的 IR：

package main

func Foo(a int) int {
   var b int
   if (a > 10) {
      b = a
   } else {
      b = 10
   }
   return b
}

在命令行中输入下面的命令，让 gc 打印出为 foo 函数生成的 IR。在当前目录下，你能看到一个 ssa.html 文件，你可以在浏览器中打开它。

GOSSAFUNC=Foo go build -gcflags '-S' foo.go

在这个文件当中，你能看到编译器对 IR 做了多步的处理，也能看到每次处理后所生成的 IR。

gc 的 IR 是基于控制流图（CFG）的。一个函数会被分成多个基本块，基本块中包含了一行行的指令。点击某个变量，你能看到它的定义和使用情况（def-use 链，图中显示成绿色）。你还能看到，图中灰色的变量，根据定义和使用关系，会发现它们没有被使用，所以是死代码，可以删除。

图 2：foo 示例程序各个处理阶段的 IR

针对第一个阶段（Start 阶段），我来给你解释一下每行指令的含义（可参考 genericOps.go），帮助你了解 Go 语言的 IR 的设计特点。

你可以参考代码库中介绍 SSA 的文档，里面介绍了 Go 的 SSA 的几个主要概念。

下面我来给你解读一下。

首先是值（Value）。Value 是 SSA 的最主要构造单元，它可以定义一次、使用多次。在定义一个 Value 的时候，需要一个标识符（ID）作为名称、产生该 Value 的操作码（Op）、一个类型（Type，就是代码中 <> 里面的值），以及一些参数。

操作码有两类。一类是机器无关的，其定义在genericOps.go中；一类是机器相关的，它是面向特定的 CPU 架构的，其定义在 XXXOps.go 中。比如，AMD64Ops.go中是针对 AMD64 架构 CPU 的操作码信息。

在做 Lower 处理时，编译器会把机器无关的操作码转换为机器相关的操作码，有利于后序生成目标代码。机器无关的优化和机器相关的优化，分别作用在采用这两类不同操作码的 IR 上。

Value 的类型信息，通常就是 Go 语言中的类型。但有几个类型是只会在 SSA 中用到的特殊类型，就像上面语句中的，即内存 (TypeMem) 类型；以及 TypeFlags，也就是 CPU 的标志位类型。

这里我要特别讲一下内存类型。内存类型代表的是全局的内存状态。如果一个操作码带有一个内存类型的参数，那就意味着该操作码依赖该内存状态。如果一个操作码的类型是内存类型，则意味着它会影响内存状态。

SSA 的介绍文档中有一个例子，能帮助你理解内存类型的用法。

在这个例子中，程序首先会向地址 a 写入 3 这个值。这个时候，内存状态就修改了（从 v1 到了 v10）。接着，把地址 a 的值写入地址 b，内存状态又发生了一次修改。在 IR 中，第二行代码依赖第一行代码的内存状态（v10），因此就导致这行代码只能出现在定义了 v10 之后。

// *a = 3       //向a地址写入3
// *b = *a      //向b地址写入a的值
v10 = Store <mem> {int} v6 v8 v1
v14 = Store <mem> {int} v7 v8 v10

这里你需要注意，对内存的读和写（各种 IR 一般都是使用 Load 和 Store 这两个词汇）是一类比较特殊的指令。其他的 Value，我们都可以认为它们是在寄存器中的，是计算过程中的临时变量，所以它们在代码中的顺序只受数据流中依赖关系的制约。而一旦中间有读写内存的操作，那么代码顺序就会受到一定的限制。

我们可以跟在Graal 编译器中学到的知识印证一下。当你读写一个 Java 对象的属性的时候，也会涉及内存的读写，这些操作对应的 IR 节点，在顺序上也是受到限制的，我们把它们叫做固定节点。

此外，Value 结构中还包含了两个辅助信息字段：AuxInt 和 Aux。AuxInt 是一个整型值，比如，在使用 Const64 指令中，AuxInt 保存了常量的值；而 Aux 则可能是个复杂的结构体，用来保存每个操作码的个性化的信息。

在 IR 中你还能看到基本块（Block），这是第二个重要的数据结构。Go 编译器中的基本块有三种：简单（Plain）基本块，它只有一个后继基本块；退出（Exit）基本块，它的最后一个指令是一个返回指令；还有 if 基本块，它有一个控制值，并且它会根据该值是 true 还是 false，跳转到不同的基本块。

第三个数据结构是函数（Func）。函数是由多个基本块构成的。它必须有一个入口基本块（Entry Block），但可以有 0 到多个退出基本块，就像一个 Go 函数允许包含多个 Return 语句一样。

现在，你已经知道了 Go 的 IR 的关键概念和相关的数据结构了。Go 的 IR 在运行时就是保存在 Value、Block、Func 等内存结构中，就像 AST 一样。它不像 LLVM 的 bitcode 还有文本格式、二进制格式，可以保存在文件中。

那么接下来，编译器就可以基于 IR，来做优化了。

基于 SSA 格式的 IR 做优化

SSA 格式的 IR 对编译器做优化很有帮助。

以死代码删除为例，Value 结构中有一个 Uses 字段，记录了它的使用数。如果它出现在另一个 Value 的操作码的参数里，或者是某个基本块的控制变量，那么使用数就会加 1；而如果 Uses 字段的值是 0，那就证明这行代码没什么用，是死代码，可以删掉。

而你应该记得，在第 7 讲中曾提到过，我们需要对一个函数的所有基本块都扫描一遍甚至多遍，才能知道某个变量的活跃性，从而决定是否可以删除掉它。那相比起来，采用 SSA 格式，可以说简单太多了。

基于这样的 IR 来做优化，就是对 IR 做很多遍（Pass）的处理。在cmd/compile/internal/ssa/compile.go的代码里，列出了所有这些 Pass，有将近 50 个。你能看到每个处理步骤执行的是哪个优化函数，你还可以在 ssa.html 中，看到每个 Pass 之后，IR 都被做了哪些修改。

图 3：compiler.go 中的 Pass

这些处理算法都是在cmd/compile/internal/ssa目录下。比如cse.go里面是消除公共子表达式的算法，而nilcheck.go是被用来消除冗余的 nil 检查代码。

有些算法还带了测试程序（如cse_test.go，nilcheck_test.go）。你可以去阅读一下，看看测试程序是如何构造测试数据的，并且你还可以通过 Debugger 来跟踪测试程序的执行过程，从而理解相关优化算法是如何实现的，这是一个很有效的学习方式。

另外，gc 还有一些比较简单的优化算法，它们是基于一些规则，对 IR 做一些重写（rewrite）。Go 的编译器使用了自己的一种 DSL，来描述这些重写规则：针对机器无关的操作码的重写规则，是在generic.rules文件中；而针对机器有关的操作码的重写规则是在 XXX.rules 中，比如AMD64.rules。

我们来看几个例子：在 generic.rules 中，有这样一个机器无关的优化规则，它是把 x*1 的运算优化为 x。

图 4：把 x*1 的运算优化为 x 的规则

在 AMD64.rules 中，有一个机器相关的优化规则，这个规则是把 MUL 指令转换为 LEA 指令，LEA 指令比 MUL 指令消耗的时钟周期更少。

(MUL(Q|L)const [ 3] x) -> (LEA(Q|L)2 x x)

generic.rules 中的规则会被rulegen.go解析，并生成 Go 代码rewritegeneric.go。而 AMD64.rules 中的规则，被解析后会生成rewriteAMD64.go。其中，Lower 的过程，也就是把机器无关的操作码转换为机器相关的操作码，它也是用这种重写规则实现的。

通过 gc 这种基于规则做指令转换的方法，你应该产生一个感悟，也就是在写软件的时候，我们经常要设计自己的 DSL，让自己的软件更具灵活性。比如，gc 要增加一个新的优化功能，只需要增加一条规则就行了。我们还可以再拿 Graal 编译器印证一下。你还记得，Graal 在生成 LIR 的时候，要进行指令的选择，那些选择规则是用注解来生成的，而那些注解规则，也是一种 DSL。

好了，谈完了优化，我们继续往下看。

生成机器码

最后，编译器就可以调用 gc/ssa.go 中的genssa方法，来生成汇编码了。

在 ssa.html 的最右边一栏，就是调用 genssa 方法以后生成的汇编代码（采用的是 Go 编译器特有的格式，其中有些指令，如 PCDATA 和 FUNCDATA 是用来与垃圾收集器配合的）。

你可能会问，编译器在生成机器码之前，不是还要做指令选择、寄存器分配、指令排序吗？那我们看看 gc 是如何完成这几项任务的。

寄存器分配（regalloc.go）作为一个 Pass，已经在生成机器码之前执行了。它采用的是线性扫描算法（Linear Scan Register Allocator）。

指令选择会分为两部分的工作。一部分工作，是在优化算法中已经做了一些指令选择，我们前面提到的重写规则，就蕴含了根据 IR 的模式，来生成合适的指令的规则；另一部分工作，则放到了汇编器当中。

这就是 Go 的编译器与众不同的地方。原来，gc 生成的汇编代码，是一种“伪汇编”，它是一种半抽象的汇编代码。在生成特定 CPU 的机器码的时候，它还会做一些转换，这个地方可以完成另一些指令选择的工作。

至于指令排序，我没看到过在 gc 编译器中的实现。我请教了谷歌的一位研究员，他给我的信息是：像 AMD64 这样的 CPU，已经能够很好地支持乱序执行了，所以指令重排序给 gc 编译器的优化工作，带来的好处很有限。

而 gc 目前没有做指令排序，还有一个原因就是，指令重排序算法的实现代价比较高，而 gc 的一个重要设计目标，就是要求编译速度要快。

扩展：Go 语言的另外两个编译器，gccgo 和 GoLLVM 都具备指令重排序功能。

课程小结

这一讲，我给你介绍了 gc 编译器的主要特点。之所以能压缩在一讲里面，是因为你已经见识了好几款编译器，渐渐地可以触类旁通、举一反三了。

在 gc 里面，你能看到很多可以借鉴的成熟实践：

语法分析：递归下降算法，加上针对二元表达式的运算符优先级算法；

语义分析：分阶段的名称消解算法，以及对 AST 的转换；

优化：采用了 SSA 格式的 IR、控制流图（CFG）、多个 Pass 的优化框架，以及通过 DSL 支持的优化规则。

所以在这一讲的开头，我还建议你把 Go 语言的编译器作为你学习编译原理的“教学参考书”，建议你在图形化的 IDE 界面里，来跟踪调试每一个功能，这样你就能很方便去观察它的算法执行过程。

本讲的思维导图如下：

一课一思

在 gc 编译器里面，内联优化是基于 AST 去做的。那么，它为什么没有基于 SSA 格式的 IR 来做呢？这两种不同的实现会有什么差异？欢迎你在留言区发表你的看法。

参考资料

Introduction to the Go compiler 官方文档，介绍了 gc 的主要结构。

Introduction to the Go compiler’s SSA backend 官方文档，介绍了 gc 的 SSA。

Go compiler internals: adding a new statement to Go - Part 1、Part2。在这两篇博客里，作者做了一个实验：如果往 Go 里面增加一条新的语法规则，需要做哪些事情。你能贯穿性地了解一个编译器的方法。

Go compiler: SSA optimization rules description language这篇博客，详细介绍了 gc 编译器的 SSA 优化规则描述语言的细节。

A Primer on Go Assembly和A Quick Guide to Go’s Assembler 。gc 编译器采用的汇编语言是它自己的一种格式，是“伪汇编”。这两篇文章中有 Go 汇编的细节。

25 | MySQL编译器（一）：解析一条SQL语句的执行过程

你好，我是宫文学。现在，就到了我们编译之旅的最后一站了，我们一起来探索一下 MySQL 编译器。

数据库系统能够接受 SQL 语句，并返回数据查询的结果，或者对数据库中的数据进行修改，可以说几乎每个程序员都使用过它。

而 MySQL 又是目前使用最广泛的数据库。所以，解析一下 MySQL 编译并执行 SQL 语句的过程，一方面能帮助你加深对数据库领域的编译技术的理解；另一方面，由于 SQL 是一种最成功的 DSL（特定领域语言），所以理解了 MySQL 编译器的内部运作机制，也能加深你对所有使用数据操作类 DSL 的理解，比如文档数据库的查询语言。另外，解读 SQL 与它的运行时的关系，也有助于你在自己的领域成功地使用 DSL 技术。

那么，数据库系统是如何使用编译技术的呢？接下来，我就会花两讲的时间，带你进入到 MySQL 的内部，做一次全面的探秘。

今天这一讲，我先带你了解一下如何跟踪 MySQL 的运行，了解它处理一个 SQL 语句的过程，以及 MySQL 在词法分析和语法分析方面的实现机制。

好，让我们开始吧！

编译并调试 MySQL

按照惯例，你要下载MySQL 的源代码。我下载的是 8.0 版本的分支。

源代码里的主要目录及其作用如下，我们需要分析的代码基本都在 sql 目录下，它包含了编译器和服务端的核心组件。

图 1：MySQL 的源代码包含的主要目录

MySQL 的源代码主要是.cc 结尾的，也就是说，MySQL 主要是用 C++ 编写的。另外，也有少量几个代码文件是用 C 语言编写的。

为了跟踪 MySQL 的执行过程，你要用 Debug 模式编译 MySQL，具体步骤可以参考这篇开发者文档。

如果你用单线程编译，大约需要 1 个小时。编译好以后，先初始化出一个数据库来：

./mysqld --initialize --user=mysql

这个过程会为 root@localhost 用户，生成一个缺省的密码。

接着，运行 MySQL 服务器：

./mysqld &

之后，通过客户端连接数据库服务器，这时我们就可以执行 SQL 了：

./mysql -uroot -p   #连接mysql server

最后，我们把 GDB 调试工具附加到 mysqld 进程上，就可以对它进行调试了。

gdb -p `pidof mysqld`  #pidof是一个工具，用于获取进程的id，你可以安装一下

提示：这一讲中，我是采用了一个 CentOS 8 的虚拟机来编译和调试 MySQL。我也试过在 macOS 下编译，并用 LLDB 进行调试，也一样方便。

注意，你在调试程序的时候，有两个设置断点的好地方：

dispatch_command：在 sql/sql_parse.cc 文件里。在接受客户端请求的时候（比如一个 SQL 语句），会在这里集中处理。

my_message_sql：在 sql/mysqld.cc 文件里。当系统需要输出错误信息的时候，会在这里集中处理。

这个时候，我们在 MySQL 的客户端输入一个查询命令，就可以从雇员表里查询姓和名了。在这个例子中，我采用的数据库是 MySQL 的一个示例数据库 employees，你可以根据它的文档来生成示例数据库。

mysql> select first_name, last_name from employees;    #从mysql库的user表中查询信息

这个命令被 mysqld 接收到以后，就会触发断点，并停止执行。这个时候，客户端也会老老实实地停在那里，等候从服务端传回数据。即使你在后端跟踪代码的过程会花很长的时间，客户端也不会超时，一直在安静地等待。给我的感觉就是，MySQL 对于调试程序还是很友好的。

在 GDB 中输入 bt 命令，会打印出调用栈，这样你就能了解一个 SQL 语句，在 MySQL 中执行的完整过程。为了方便你理解和复习，这里我整理成了一个表格：

我也把 MySQL 执行 SQL 语句时的一些重要程序入口记录了下来，这也需要你重点关注。它反映了执行 SQL 过程中的一些重要的处理阶段，包括语法分析、处理上下文、引用消解、优化和执行。你在这些地方都可以设置断点。

图 2：MySQL 执行 SQL 语句时的部分重要程序入口

好了，现在你就已经做好准备，能够分析 MySQL 的内部实现机制了。不过，由于 MySQL 执行的是 SQL 语言，它跟我们前面分析的高级语言有所不同。所以，我们先稍微回顾一下 SQL 语言的特点。

SQL 语言：数据库领域的 DSL

SQL 是结构化查询语言（Structural Query Language）的英文缩写。举个例子，这是一个很简单的 SQL 语句：

select emp_no, first_name, last_name from employees;

其实在大部分情况下，SQL 都是这样一个一个来做语句执行的。这些语句又分为 DML（数据操纵语言）和 DDL（数据定义语言）两类。前者是对数据的查询、修改和删除等操作，而后者是用来定义数据库和表的结构（又叫模式）。

我们平常最多使用的是 DML。而 DML 中，执行起来最复杂的是 select 语句。所以，在本讲，我都是用 select 语句来给你举例子。

那么，SQL 跟我们前面分析的高级语言相比有什么不同呢？

第一个特点：SQL 是声明式（Declarative）的。这是什么意思呢？其实就是说，SQL 语句能够表达它的计算逻辑，但它不需要描述控制流。

高级语言一般都有控制流，也就是详细规定了实现一个功能的流程：先调用什么功能，再调用什么功能，比如 if 语句、循环语句等等。这种方式叫做命令式（imperative）编程。

更深入一点，声明式编程说的是“要什么”，它不关心实现的过程；而命令式编程强调的是“如何做”。前者更接近人类社会的领域问题，而后者更接近计算机实现。

第二个特点：SQL 是一种特定领域语言（DSL，Domain Specific Language），专门针对关系数据库这个领域的。SQL 中的各个元素能够映射成关系代数中的操作术语，比如选择、投影、连接、笛卡尔积、交集、并集等操作。它采用的是表、字段、连接等要素，而不需要使用常见的高级语言的变量、类、函数等要素。

所以，SQL 就给其他 DSL 的设计提供了一个很好的参考：

采用声明式，更加贴近领域需求。比如，你可以设计一个报表的 DSL，这个 DSL 只需要描述报表的特征，而不需要描述其实现过程。

采用特定领域的模型、术语，甚至是数学理论。比如，针对人工智能领域，你完全就可以用张量计算（力学概念）的术语来定义 DSL。

好了，现在我们分析了 SQL 的特点，从而也让你了解了 DSL 的一些共性特点。那么接下来，顺着 MySQL 运行的脉络，我们先来了解一下 MySQL 是如何做词法分析和语法分析的。

词法和语法分析

词法分析的代码是在 sql/sql_lex.cc 中，入口是 MYSQLlex() 函数。在 sql/lex.h 中，有一个 symbols[]数组，它定义了各类关键字、操作符。

MySQL 的词法分析器也是手写的，这给算法提供了一定的灵活性。比如，SQL 语句中，Token 的解析是跟当前使用的字符集有关的。使用不同的字符集，词法分析器所占用的字节数是不一样的，判断合法字符的依据也是不同的。而字符集信息，取决于当前的系统的配置。词法分析器可以根据这些配置信息，正确地解析标识符和字符串。

MySQL 的语法分析器是用 bison 工具生成的，bison 是一个语法分析器生成工具，它是 GNU 版本的 yacc。bison 支持的语法分析算法是 LALR 算法，而 LALR 是 LR 算法家族中的一员，它能够支持大部分常见的语法规则。bison 的规则文件是 sql/sql_yacc.yy，经过编译后会生成 sql/sql_yacc.cc 文件。

sql_yacc.yy 中，用你熟悉的 EBNF 格式定义了 MySQL 的语法规则。我节选了与 select 语句有关的规则，如下所示，从中你可以体会一下，SQL 语句的语法是怎样被一层一层定义出来的：

select_stmt:
          query_expression
        | ...
        | select_stmt_with_into
        ;
query_expression:
          query_expression_body opt_order_clause opt_limit_clause
        | with_clause query_expression_body opt_order_clause opt_limit_clause
        | ...
        ;
query_expression_body:
          query_primary
        | query_expression_body UNION_SYM union_option query_primary
        | ...
        ;
query_primary:
          query_specification
        | table_value_constructor
        | explicit_table
        ;
query_specification:
          ...
        | SELECT_SYM             /*select关键字*/
          select_options         /*distinct等选项*/
          select_item_list       /*select项列表*/
          opt_from_clause        /*可选：from子句*/
          opt_where_clause       /*可选：where子句*/
          opt_group_clause       /*可选：group子句*/
          opt_having_clause      /*可选：having子句*/
          opt_window_clause      /*可选：window子句*/
        ;
...

其中，query_expression 就是一个最基础的 select 语句，它包含了 SELECT 关键字、字段列表、from 子句、where 子句等。

你可以看一下 select_options、opt_from_clause 和其他几个以 opt 开头的规则，它们都是 SQL 语句的组成部分。opt 是可选的意思，也就是它的产生式可能产生ε。

opt_from_clause:
          /* Empty. */
        | from_clause
        ;

另外，你还可以看一下表达式部分的语法。在 MySQL 编译器当中，对于二元运算，你可以大胆地写成左递归的文法。因为它的语法分析的算法用的是 LALR，这个算法能够自动处理左递归。

一般研究表达式的时候，我们总是会关注编译器是如何处理结合性和优先级的。那么，bison 是如何处理的呢？

原来，bison 里面有专门的规则，可以规定运算符的优先级和结合性。在 sql_yacc.yy 中，你会看到如下所示的规则片段：

你可以看一下 bit_expr 的产生式，它其实完全把加减乘数等运算符并列就行了。

bit_expr :
         ...
         | bit_expr '+' bit_expr %prec '+'
         | bit_expr '-' bit_expr %prec '-'
         | bit_expr '*' bit_expr %prec '*'
         | bit_expr '/' bit_expr %prec '/'
         ...
         | simple_expr

如果你只是用到加减乘除的运算，那就可以不用在产生式的后面加 %prec 这个标记。但由于加减乘除这几个还可以用在其他地方，比如“-a”可以用来表示把 a 取负值；减号可以用在一元表达式当中，这会比用在二元表达式中有更高的优先级。也就是说，为了区分同一个 Token 在不同上下文中的优先级，我们可以用 %prec，来说明该优先级是上下文依赖的。

好了，在了解了词法分析器和语法分析器以后，我们接着来跟踪一下 MySQL 的执行，看看编译器所生成的解析树和 AST 是什么样子的。

在 sql_class.cc 的 sql_parser() 方法中，编译器执行完解析程序之后，会返回解析树的根节点 root，在 GDB 中通过 p 命令，可以逐步打印出整个解析树。你会看到，它的根节点是一个 PT_select_stmt 指针（见图 3）。

解析树的节点是在语法规则中规定的，这是一些 C++ 的代码，它们会嵌入到语法规则中去。

下面展示的这个语法规则就表明，编译器在解析完 query_expression 规则以后，要创建一个 PT_query_expression 的节点，其构造函数的参数分别是三个子规则所形成的节点。对于 query_expression_body 和 query_primary 这两个规则，它们会直接把子节点返回，因为它们都只有一个子节点。这样就会简化解析树，让它更像一棵 AST。关于 AST 和解析树（也叫 CST）的区别，我在解析 Python 的编译器中讲过了，你可以回忆一下。

query_expression:
          query_expression_body
          opt_order_clause
          opt_limit_clause
          {
            $$ = NEW_PTN PT_query_expression($1, $2, $3); /*创建节点*/
          }
        | ...

query_expression_body:
          query_primary
          {
            $$ = $1;   /*直接返回query_primary的节点*/
          }
        | ...

query_primary:
          query_specification
          {
            $$= $1;  /*直接返回query_specification的节点*/
          }
        | ...

最后，对于“select first_name, last_name from employees”这样一个简单的 SQL 语句，它所形成的解析树如下：

图 3：示例 SQL 解析后生成的解析树

而对于“select 2 + 3”这样一个做表达式计算的 SQL 语句，所形成的解析树如下。你会看到，它跟普通的高级语言的表达式的 AST 很相似：

图 4：“select 2 + 3”对应的解析树

图 4 中的 PT_query_expression 等类，就是解析树的节点，它们都是 Parse_tree_node 的子类（PT 是 Parse Tree 的缩写）。这些类主要定义在 sql/parse_tree_nodes.h 和 parse_tree_items.h 文件中。

其中，Item 代表了与“值”有关的节点，它的子类能够用于表示字段、常量和表达式等。你可以通过 Item 的 val_int()、val_str() 等方法获取它的值。

图 5：解析树的树节点（部分）

由于 SQL 是一个个单独的语句，所以 select、insert、update 等语句，它们都各自有不同的根节点，都是 Parse_tree_root 的子类。

图 6：解析树的根节点

好了，现在你就已经了解了 SQL 的解析过程和它所生成的 AST 了。前面我说过，MySQL 采用的是 LALR 算法，因此我们可以借助 MySQL 编译器，来加深一下对 LR 算法家族的理解。

重温 LR 算法

你在阅读 yacc.yy 文件的时候，在注释里，你会发现如何跟踪语法分析器的执行过程的一些信息。

你可以用下面的命令，带上“-debug”参数，来启动 MySQL 服务器：

mysqld --debug="d,parser_debug"

然后，你可以通过客户端执行一个简单的 SQL 语句：“select 2+3*5”。在终端，会输出语法分析的过程。这里我截取了一部分界面，通过这些输出信息，你能看出 LR 算法执行过程中的移进、规约过程，以及工作区内和预读的信息。

我来给你简单地复现一下这个解析过程。

第 1 步，编译器处于状态 0，并且预读了一个 select 关键字。你已经知道，LR 算法是基于一个 DFA 的。在这里的输出信息中，你能看到某些状态的编号达到了一千多，所以这个 DFA 还是比较大的。

第 2 步，把 select 关键字移进工作区，并进入状态 42。这个时候，编译器已经知道后面跟着的一定是一个 select 语句了，也就是会使用下面的语法规则：

query_specification:
          ...
        | SELECT_SYM             /*select关键字*/
          select_options         /*distinct等选项*/
          select_item_list       /*select项列表*/
          opt_from_clause        /*可选：from子句*/
          opt_where_clause       /*可选：where子句*/
          opt_group_clause       /*可选：group子句*/
          opt_having_clause      /*可选：having子句*/
          opt_window_clause      /*可选：window子句*/
        ;

为了给你一个直观的印象，这里我画了 DFA 的局部示意图（做了一定的简化），如下所示。你可以看到，在状态 42，点符号位于“select”关键字之后、select_options 之前。select_options 代表了“distinct”这样的一些关键字，但也有可能为空。

图 7：移进 select 后的 DFA

第 3 步，因为预读到的 Token 是一个数字（NUM），这说明 select_options 产生式一定生成了一个ε，因为 NUM 是在 select_options 的 Follow 集合中。

这就是 LALR 算法的特点，它不仅会依据预读的信息来做判断，还要依据 Follow 集合中的元素。所以编译器做了一个规约，也就是让 select_options 为空。

也就是，编译器依据“select_options->ε”做了一次规约，并进入了新的状态 920。注意，状态 42 和 920 从 DFA 的角度来看，它们是同一个大状态。而 DFA 中包含了多个小状态，分别代表了不同的规约情况。

图 8：基于“select_options->ε”规约后的 DFA

你还需要注意，这个时候，老的状态都被压到了栈里，所以栈里会有 0 和 42 两个状态。栈里的这些状态，其实记录了推导的过程，让我们知道下一步要怎样继续去做推导。

图 9：做完前 3 步之后，栈里的情况

第 4 步，移进 NUM。这时又进入一个新状态 720。

图 10：移进 NUM 后的 DFA

而旧的状态也会入栈，记录下推导路径：

图 11：移进 NUM 后栈的状态

第 5~8 步，依次依据 NUM_literal->NUM、literal->NUM_literal、simple_expr->literal、bit_expr->simple_expr 这四条产生式做规约。这时候，编译器预读的 Token 是 + 号，所以你会看到，图中的红点停在 + 号前。

图 12：第 8 步之后的 DFA

第 9~10 步，移进 + 号和 NUM。这个时候，状态又重新回到了 720。这跟第 4 步进入的状态是一样的。

图 13：第 10 步之后的 DFA

而栈里的目前有 5 个状态，记录了完整的推导路径。

图 14：第 10 步之后栈的状态

到这里，其实你就已经了解了 LR 算法做移进和规约的思路了。不过你还可以继续往下研究。由于栈里保留了完整的推导路径，因此 MySQL 编译器最后会依次规约回来，把栈里的元素清空，并且形成一棵完整的 AST。

课程小结

这一讲，我带你初步探索了 MySQL 编译 SQL 语句的过程。你需要记住几个关键点：

掌握如何用 GDB 来跟踪 MySQL 的执行的方法。你要特别注意的是，我给你梳理的那些关键的程序入口，它是你理解 MySQL 运行过程的地图。

SQL 语言是面向关系数据库的一种 DSL，它是声明式的，并采用了领域特定的模型和术语，可以为你设计自己的 DSL 提供启发。

MySQL 的语法分析器是采用 bison 工具生成的。这至少说明，语法分析器生成工具是很有用的，连正式的数据库系统都在使用它，所以你也可以大胆地使用它，来提高你的工作效率。我在最后的参考资料中给出了 bison 的手册，希望你能自己阅读一下，做一些简单的练习，掌握 bison 这个工具。

最后，你一定要知道 LR 算法的运行原理，知其所以然，这也会更加有助于你理解和用好工具。

我依然把本讲的内容给你整理成了一张知识地图，供你参考和复习回顾：

一课一思

我们今天讲到了 DSL 的概念。你能分享一下你的工作领域中的 DSL 吗？它们是否也是采用声明式的？里面用到了哪些特定领域的术语？欢迎在留言区分享。

感谢你的阅读。如果有收获，欢迎你把今天的内容分享给更多的朋友。

参考资料

MySQL 的内行手册（MySQL Internals Manual）能提供一些重要的信息。但我发现文档内容经常跟源代码的版本不同步，比如介绍源代码的目录结构的信息就过时了，你要注意这点。

bison 的手册。

26 | MySQL编译器（二）：编译技术如何帮你提升数据库性能？

你好，我是宫文学。今天这一讲，我们继续来探究 MySQL 编译器。

通过上一讲的学习，你已经了解了 MySQL 编译器是怎么做词法和语法分析的了。那么在做完语法分析以后，MySQL 编译器又继续做了哪些处理，才能成功地执行这个 SQL 语句呢？

所以今天，我就带你来探索一下 MySQL 的实现机制，我会把重点放在 SQL 的语义分析和优化机制上。当你学完以后，你就能真正理解以下这些问题了：

高级语言的编译器具有语义分析功能，那么 MySQL 编译器也会做语义分析吗？它有没有引用消解问题？有没有作用域？有没有类型检查？

MySQL 有没有类似高级语言的那种优化功能呢？

好，让我们开始今天的探究吧。不过，在讨论 MySQL 的编译过程之前，我想先带你了解一下 MySQL 会用到的一些重要的数据结构，因为你在解读代码的过程中经常会见到它们。

认识 MySQL 编译器的一些重要的数据结构

第一组数据结构，是下图中的几个重要的类或结构体，包括线程、保存编译上下文信息的 LEX，以及保存编译结果 SELECT_LEX_UNIT 和 SELECT_LEX。

图 1：MySQL 编译器的中的几个重要的类和结构体

首先是 THD，也就是线程对象。对于每一个客户端的连接，MySQL 编译器都会启动一个线程来处理它的查询请求。

THD 中的一个重要数据成员是 LEX 对象。你可以把 LEX 对象想象成是编译 SQL 语句的工作区，保存了 SQL 语句编译过程中的上下文信息，编译器会把编译的成果放在这里，而编译过程中所需要的信息也是从这里查找。

在把 SQL 语句解析完毕以后，编译器会形成一些结构化的对象来表示一个查询。其中 SELECT_LEX_UNIT 结构体，就代表了一个查询表达式（Query Expression）。一个查询表达式可能包含了多个查询块，比如使用 UNION 的情况。

而 SELECT_LEX 则代表一个基本的查询块（Query Block），它里面的信息包括了所有的列和表达式、查询用到的表、where 条件等。在 SELECT_LEX 中会保存查询块中涉及的表、字段和表达式等，它们也都有对应的数据结构。

第二组需要了解的数据结构，是表示表、字段等信息的对象。Table_ident 对象保存了表的信息，包括数据库名、表名和所在的查询语句（SELECT_LEX_UNIT 对象）。

图 2：Table_indent 对象，代表一个表

而字段和表达式等表示一个值的对象，用 Item 及其子类来表示。SQL 语句中的每个字段、每个计算字段，最后都对应一个 Item。where 条件，其实也是用一个 Item 就能表示。具体包括：

字段（Item_field）。

各种常数，包括数字、字符和 null 等（Item_basic_constant）。

能够产生出值的运算（Item_result_field），包括算术表达式（Item_num_op）、存储过程（Item_func_sp）、子查询（Item_subselect）等。

在语法分析过程中产生的 Item（Parse_tree_item）。它们是一些占位符，因为在语法分析阶段，不容易一下子创建出真正的 Item，这些 Parse_tree_item 需要在上下文分析阶段，被替换成真正的 Item。

图 3：Item 及其子类

好了，上面这些就是 MySQL 会用到的最核心的一些数据结构了。接下来的编译工作，就会生成和处理上述的数据结构。

上下文分析

我们先来看一下 MySQL 编译器的上下文分析工作。

你已经知道，语法分析仅仅完成的是上下文无关的分析，还有很多的工作，需要基于上下文来做处理。这些工作，就属于语义分析。

MySQL 编译器中，每个 AST 节点，都会有一个 contextualize() 方法。从这个方法的名称来看，你就能知道它是做上下文处理的（contextualize，置于上下文中）。

对一个 Select 语句来说，编译器会调用其根节点 PT_select_stmt 的 contextualize() 方法，从而深度遍历整个 AST，并调用每个节点的 contextualize() 方法。

那么，MySQL 编译器的上下文处理，都完成了什么工作呢？

首先，是检查数据库名、表名和字段名是否符合格式要求（在 table.cc 中实现）。

比如，MySQL 会规定表名、字段名等名称不能超过 64 个字符，字段名不能包含 ASCII 值为 255 的字符，等等。这些规则在词法分析阶段是不检查的，要留在语义分析阶段检查。

然后，创建并填充 SELECT_LEX_UNIT 和 SELECT_LEX 对象。

前面我提到了，SELECT_LEX_UNIT 和 SELECT_LEX 中，保存了查询表达式和查询块所需的所有信息，依据这些信息，MySQL 就可以执行实际的数据库查询操作。

那么，在 contextualize 的过程中，编译器就会生成上述对象，并填充它们的成员信息。

比如，对于查询中用到的表，在语法分析阶段就会生成 Table_ident 对象。但其中的数据库名称可能是缺失的，那么在上下文的分析处理当中，就会被编译器设置成当前连接所采用的默认数据库。这个信息可以从线程对象（THD）中获得，因为每个线程对应了一个数据库连接，而每个数据库连接是针对一个具体的数据库的。

好了，经过上下文分析的编译阶段以后，我们就拥有了可以执行查询的 SELECT_LEX_UNIT 和 SELECT_LEX 对象。可是，你可能会注意到一个问题：为什么在语义分析阶段，MySQL 没有做引用的消解呢？不要着急，接下来我就给你揭晓这个答案。

MySQL 是如何做引用消解的？

我们在 SQL 语句中，会用到数据库名、表名、列名、表的别名、列的别名等信息，编译器肯定也需要检查它们是不是正确的。这就是引用消解（或名称消解）的过程。一般编译器是在语义分析阶段来做这项工作的，而 MySQL 是在执行 SQL 命令的时候才做引用消解。

引用消解的入口是在 SQL 命令的的 prepare() 方法中，它会去检查表名、列名都对不对。

通过 GDB 调试工具，我们可以跟踪编译器做引用消解的过程。你可以在 my_message_sql() 函数处设个断点，然后写个 SQL 语句，故意使用错误的表名或者列名，来看看 MySQL 是在什么地方检查出这些错误的。

比如说，你可以执行“select * from fake_table”，其中的 fake_table 这个表，在数据库中其实并不存在。

下面是打印出的调用栈。你会注意到，MySQL 在准备执行 SQL 语句的过程中，会试图去打开 fake_table 表，这个时候编译器就会发现这个表不存在。

你还可以再试一下“select fake_column from departments”这个语句，也一样会查出，fake_column 并不是 departments 表中的一列。

那么，MySQL 是如何知道哪些表和字段合法，哪些不合法的呢？

原来，它是通过查表的定义，也就是数据库模式信息，或者可以称为数据字典、元数据。MySQL 在一个专门的库中，保存了所有的模式信息，包括库、表、字段、存储过程等定义。

你可以跟高级语言做一下类比。高级语言，比如说 Java 也会定义一些类型，类型中包含了成员变量。那么，MySQL 中的表，就相当于高级语言的类型；而表的字段（或列）就相当于高级语言的类型中的成员变量。所以，在这个方面，MySQL 和高级语言做引用消解的思路其实是一样的。

但是，高级语言在做引用消解的时候有作用域的概念，那么 MySQL 有没有类似的概念呢？

有的。举个例子，假设一个 SQL 语句带了子查询，那么子查询中既可以引用本查询块中的表和字段，也可以引用父查询中的表和字段。这个时候就存在了两个作用域，比如下面这个查询语句：

select dept_name from departments where dept_no in
    (select dept_no from dept_emp
        where dept_name != 'Sales'  #引用了上一级作用域中的字段
        group by dept_no
        having count(*)> 20000)

其中的 dept_name 字段是 dept_emp 表中所没有的，它其实是上一级作用域中 departments 表中的字段。

提示：这个 SQL 当然写得很不优化，只是用来表现作用域的概念。

好。既然要用到作用域，那么 MySQL 的作用域是怎么表示的呢？

这就要用到 Name_resolution_context 对象。这个对象保存了当前作用域中的表，编译器可以在这些表里查找字段；它还保存了对外层上下文的引用（outer_context），这样 MySQL 就可以查找上一级作用域中的表和字段。

图 4：MySQL 用来表示作用域的对象

好了，现在你就对 MySQL 如何做引用消解非常了解了。

我们知道，对于高级语言的编译器来说，接下来它还会做一些优化工作。那么，MySQL 是如何做优化的呢？它跟高级语言编译器的优化工作相比，又有什么区别呢？

MySQL 编译器的优化功能

MySQL 编译器的优化功能主要都在 sql_optimizer.cc 中。就像高级语言一样，MySQL 编译器会支持一些常见的优化。我来举几个例子。

第一个例子是常数传播优化（const propagation）。假设有一个表 foo，包含了 x 和 y 两列，那么 SQL 语句：“select * from foo where x = 12 and y=x”，会被优化成“select * from foo where x = 12 and y = 12”。你可以在 propagate_cond_constants() 函数上加个断点，查看常数传播优化是如何实现的。

第二个例子是死代码消除。比如，对于 SQL 语句：“select * from foo where x=2 and y=3 and x<y”，编译器会把它优化为“select * from foo where x=2 and y=3”，把“x<y”去掉了，这是因为 x 肯定是小于 y 的。该功能的实现是在 remove_eq_conds() 中。

第三个例子是常数折叠。这个优化工作我们应该很熟悉了，主要是对各种条件表达式做折叠，从而降低计算量。其实现是在 sql_const_folding.cc 中。

你要注意的是，上述的优化主要是针对条件表达式。因为 MySQL 在执行过程中，对于每一行数据，可能都需要执行一遍条件表达式，所以上述优化的效果会被放大很多倍，这就好比针对循环体的优化，是一个道理。

不过，MySQL 还有一种特殊的优化，是对查询计划的优化。比如说，我们要连接 employees、dept_emp 和 departments 三张表做查询，数据库会怎么做呢？

最笨的办法，是针对第一张表的每条记录，依次扫描第二张表和第三张表的所有记录。这样的话，需要扫描多少行记录呢？是三张表的记录数相乘。基于我们的示例数据库的情况，这个数字是 8954 亿。

上述计算其实是做了一个笛卡尔积，这会导致处理量的迅速上升。而在数据库系统中，显然不需要用这么笨的方法。

你可以用 explain 语句，让 MySQL 输出执行计划，下面我们来看看 MySQL 具体是怎么做的：

explain select employees.emp_no, first_name,
               departments.dept_no dept_name
        from employees, dept_emp, departments
        where employees.emp_no = dept_emp.emp_no
              and dept_emp.dept_no = departments.dept_no;

这是 MySQL 输出的执行计划：

从输出的执行计划里，你能看出，MySQL 实际的执行步骤分为了 3 步：

第 1 步，通过索引，遍历 departments 表；

第 2 步，通过引用关系（ref），找到 dept_emp 表中，dept_no 跟第 1 步的 dept_no 相等的记录，平均每个部门在 dept_emp 表中能查到 3.7 万行记录；

第 3 步，基于第 2 步的结果，通过等值引用（eq_ref）关系，在 employees 表中找到相应的记录，每次找到的记录只有 1 行。这个查找可以通过 employees 表的主键进行。

根据这个执行计划来完成上述的操作，编译器只需要处理大约 63 万行的数据。因为通过索引查数据相比直接扫描表来说，处理每条记录花费的时间比较长，所以我们假设前者花费的时间是后者的 3 倍，那么就相当于扫描了 63*3=189 万行表数据，这仍然仅仅相当于做笛卡尔积的 47 万分之一。我在一台虚拟机上运行该 SQL 花费的时间是 5 秒，而如果使用未经优化的方法，则需要花费 27 天！

通过上面的例子，你就能直观地理解做查询优化的好处了。MySQL 会通过一个 JOIN 对象，来为一个查询块（SELECT_LEX）做查询优化，你可以阅读 JOIN 的方法，来看看查询优化的具体实现。关于查询优化的具体算法，你需要去学习一下数据库的相关课程，我在本讲末尾也推荐了一点参考资料，所以我这里就不展开了。

从编译原理的角度来看，我们可以把查询计划中的每一步，看做是一条指令。MySQL 的引擎，就相当于能够执行这些指令的一台虚拟机。

如果再做进一步了解，你就会发现，MySQL 的执行引擎和存储引擎是分开的。存储引擎提供了一些基础的方法（比如通过索引，或者扫描表）来获取表数据，而做连接、计算等功能，是在 MySQL 的执行引擎中完成的。

好了，现在你就已经大致知道了，一条 SQL 语句从解析到执行的完整过程。但我们知道，普通的高级语言在做完优化后，生成机器码，这样性能更高。那么，是否可以把 SQL 语句编译成机器码，从而获得更高的性能呢？

能否把 SQL 语句编译成机器码？

MySQL 编译器在执行 SQL 语句的过程中，除了查找数据、做表间连接等数据密集型的操作以外，其实还有一些地方是需要计算的。比如：

where 条件：对每一行扫描到的数据都需要执行一次。

计算列：有的列是需要计算出来的。

聚合函数：像 sum、max、min 等函数，也是要对每一行数据做一次计算。

在研究 MySQL 的过程中，你会发现上述计算都是解释执行的。MySQL 做解释执行的方式，基本上就是深度遍历 AST。比如，你可以对代表 where 条件的 Item 求值，它会去调用它的下级节点做递归的计算。这种计算过程和其他解释执行的语言差不多，都是要在运行时判断数据的类型，进行必要的类型转换，最后执行特定的运算。因为很多的判断都要在运行时去做，所以程序运行的性能比较低。

另外，由于 MySQL 采用的是解释执行机制，所以它在语义分析阶段，其实也没有做类型检查。在编译过程中，不同类型的数据在运算的时候，会自动进行类型转换。比如，执行“select'2' + 3”，MySQL 会输出 5，这里就把字符串'2'转换成了整数。

那么，我们能否把这些计算功能编译成本地代码呢？

因为我们在编译期就知道每个字段的数据类型了，所以编译器其实是可以根据这些类型信息，生成优化的代码，从而提升 SQL 的执行效率。

这种思路理论上是可行的。不过，目前我还没有看到 MySQL 在这方面的工作，而是发现了另一个数据库系统 PostgreSQL，做了这方面的优化。

PostgreSQL 的团队发现，如果解释执行下面的语句，表达式计算所用的时间，占到了处理一行记录所需时间的 56%。而基于 LLVM 实现 JIT 以后（编译成机器码执行），所用的时间只占到总执行时间的 6%，这就使得 SQL 执行的整体性能整整提高了一倍。

select count(*) from table_name where (x + y) > 100

中国用户对 MySQL 的用量这么大，如果能做到上述的优化，那么仅仅因此而减少的碳排放，就是一个很大的成绩！所以，你如果有兴趣的话，其实可以在这方面尝试一下！

课程小结

这一讲我们分析了 MySQL 做语义分析、优化和执行的原理，并探讨了一下能否把 SQL 编译成本地代码的问题。你要记住以下这些要点：

MySQL 也会做上下文分析，并生成能够代表 SQL 语句的内部数据结构；

MySQL 做引用消解要基于数据库模式信息，并且也支持作用域；

MySQL 会采用常数传播等优化方法，来优化查询条件，并且要通过查询优化算法，形成高效的执行计划；

把 SQL 语句编译成机器码，会进一步提升数据库的性能，并降低能耗。

我把相应的知识点总结成了思维导图，供你参考：

总结这两讲对 MySQL 所采用的编译技术介绍，你会发现这样几个很有意思的地方：

第一，编译技术确实在数据库系统中扮演了很重要的作用。

第二，数据库编译 SQL 语句的过程与高级语言有很大的相似性，它们都包含了词法分析、语法分析、语义分析和优化等处理。你对编译技术的了解，能够指导你更快地看懂 MySQL 的运行机制。另外，如果你也要设计类似的系统级软件，这就是一个很好的借鉴。

一课一思

关系数据库是通过内置的 DSL 编译器来驱动运行的软件。那么，你还知道哪些软件是采用这样的机制来运行的？你如果去实现这样的软件，能从 MySQL 的实现思路里借鉴哪些思路？欢迎在留言区分享你的观点。

参考资料

如果要加深对 MySQL 内部机制的了解，我推荐两本书：一本是 OReilly 的《Understanding MySQL Internals》，第二本是《Expert MySQL》。

27 | 课前导读：学习现代语言设计的正确姿势

你好，我是宫文学。

到目前为止，你就已经学完了这门课程中前两个模块的所有内容了。在第一个模块“预备知识篇”，我带你梳理了编译原理的关键概念、算法等核心知识点，帮你建立了一个直观的编译原理基础知识体系；在第二个模块“真实编译器解析篇”，我带你探究了 7 个真实世界的编译器，让你对编译器所实际采用的各种编译技术都有所涉猎。那么在接下来的第三个模块，我会继续带你朝着提高编译原理实战能力的目标前进。这一次，我们从计算机语言设计的高度，来印证一下编译原理的核心知识点。

对于一门完整的语言来说，编译器只是其中的一部分。它通常还有两个重要的组成部分：一个是运行时，包括内存管理、并发机制、解释器等模块；还有一个是标准库，包含了一些标准的功能，如算术计算、字符串处理、文件读写，等等。

再进一步来看，我们在实现一门语言的时候，首先要做的，就是确定这门语言所要解决的问题是什么，也就是需求问题；其次，针对需要解决的问题，我们要选择合适的技术方案，而这些技术方案正是分别由编译器、运行时和标准库去实现的。

所以，从计算机语言设计的高度来印证编译原理知识，我们也能更容易理解编译器的任务，更容易理解它是如何跟运行时环境去做配合的，这也会让你进一步掌握编译技术。

好了，那接下来就一起来看看，到底用什么样的方式，我们才能真正理解计算机语言的设计思路。

首先，我们来聊一聊实现一门计算机语言的关键因素：需求和设计。

如何实现一门计算机语言？

我们学习编译原理的一个重要的目标，就是能够实现一门计算机语言。这种语言可能是你熟悉的某些高级语言，也可能是某个领域、为了解决某个具体问题而设计的 DSL。就像我们在第二个模块中见到的 SQL，以及编译器在内部实现时用到的一些 DSL，如 Graal 生成 LIR 时的模式匹配规则、Python 编译器中的 ASDL 解析器，还有 Go 语言编译器中 IR 的重写规则等。

那么要如何实现一门优秀的语言呢？我们都知道，要实现一个软件，有两个因素是最重要的，一个是需求，一个是设计。计算机语言作为一种软件，具有清晰的需求和良好的设计，当然也是至关重要的。

我先来说说需求问题，也就是计算机语言要解决的问题。

这里你要先明确一件事，如果需求不清晰、目标不明确，那么想要实现这门语言其实是很难成功的。通常来说，我们不能指望任何一种语言是全能的，让它擅长解决所有类型的问题。所以，每一门语言都有其所要解决的针对性问题。

举个例子，JavaScript 如果单从设计的角度来看，有很多细节值得推敲，有不少的“坑”，比如 null、undefined 和 NaN 几个值就很令人困惑，你知道“null==undefined”的值是 true 还是 false 吗？但是它所能解决的问题也非常清晰，就是作为浏览器的脚本语言，提供 Web 的交互功能。在这个方面，它比同时期诞生的其他竞争技术，如 ActiveX 和 Java Applet，都更具优势，所以它才能胜出。

历史上的计算机语言，都是像 JavaScript 那样，在满足了那个时代的某个需求以后而流行起来的。其中，根据“硅谷创业之父”保罗·格雷厄姆（Paul Graham）在《黑客与画家》中的说法，这些语言往往是一个流行的系统的脚本。比如说，C 语言是 Unix 系统的脚本，COBOL 是大型机的脚本，SQL 是数据库系统的脚本，JavaScript、Java 和 C# 都是浏览器的脚本，Swift 和 Objective-C 是苹果系统的脚本，Kotlin 是 Android 的脚本。让一门语言成为某个流行的技术系统的脚本，为这个生态提供编程支持，就是一种定位很清晰的需求。

各种编程语言的产生，以及与技术生态的关联

好，明确了语言的需求以后，我们再来说说设计问题。

设计是实现计算机语言这种软件所要做的技术选择。你已经看到，我们研究的不同语言，其实现技术都各有特点，分别服务于该语言的需求问题，或者说设计目标。

我还是拿 JavaScript 来举例子。JavaScript 被设计成了一门解释执行的语言，这样的话，它就能很方便地嵌入到 HTML 文本中，随着 HTML 下载和执行，并且支持不同的客户端操作系统和浏览器平台。而如果是需要静态编译的语言，就没有这么方便。

再进一步，由于 HTML 下载完毕后，JavaScript 就要马上执行，从而也对 JavaScript 的编译速度有了更高的要求，所以我们才会看到 V8 里面的那些有利于快速解析的技术，比如通过查表做词法分析、懒解析等。

另外，因为 JavaScript 早期只是在浏览器里做一些比较简单的工作，所以它一开始没有设计并发计算的能力。还有，由于每个页面运行的 JavaScript 都是单独的，并且在页面退出时就可以收回内存，因此 JavaScript 的垃圾收集功能也不需要太复杂。

作为对比，Go 语言的设计主要是用来编写服务端程序的，那么它的关键特性也是与这个定位相适应。

并发：服务端的软件最重要的一项能力就是支持大量并发任务。Go 在语言设计上把并发能力作为第一等级的语言要素。

垃圾收集：由于垃圾收集会造成整个应用程序停下来，所以 Go 语言特别重视降低由于垃圾收集而产生的停顿。

那么总结起来，我们要想成功地实现一门语言，要把握两个要点：第一，要弄清楚该语言的需求，也就是它要去解决的问题；第二，要确定合适的技术方案，来更好地解决它要面对的问题。

计算机语言的设计会涉及到比较多的内容，为了防止你在学习时抓不到重点，我在第三个模块里，挑了一些重点的内容来做讲解，比如前面提到的垃圾收集的特性等。我会以第二个模块所研究的多门语言和编译器作为素材，一起探讨一下，各门语言都是采用了什么样的技术方案来满足各自的设计目标的，从而让你对计算机语言设计所考虑的因素、编译技术如何跟其他相关技术打配合，形成一个宏观的认识。

“现代语言设计篇”都会讲哪些内容？

这个模块的内容，我根据计算机语言的组成和设计中的关键点，将其分成了三个部分。

第一部分，是对各门语言的编译器的前端、中端和后端技术做一下对比和总结。

这样，通过梳理和总结，我们就可以找出各种编译器之间的异同点。对于其共同的部分，我们可以看作是一些最佳实践，你在自己的项目中可以大胆地采用；而有差异的部分，则往往是某种编译器为了实现该语言的设计目标而采用的技术策略，你可以去体会各门语言是如何做取舍的，这样也能变成你自己的经验储备。

第二部分，主要是对语言的运行时和标准库的实现技术做一个解析。

我们说过，一门语言要包括编译器、运行时和标准库。在学习第二个模块的时候，你应该已经有了一些体会，你能发现编译器的很多特性是跟语言的运行时密切相关的。比如，Python 有自己独特的对象体系的设计，那么 Python 的字节码就体现了对这些对象的操作，字节码中的操作数都是对象的引用。

那么在这一部分，我就分为了几个话题来进行讲解：

第一，是对语言的运行时和标准库的宏观探讨。我们一起来看看不同的语言的运行时和它的编译器之间是如何互相影响的。另外，我还会和你探讨语言的基础功能和标准库的实现策略，这是非常值得探讨的知识点，它让一门语言具备了真正的实用价值。

第二，是垃圾收集机制。本课程分析、涉及的几种语言，它们所采用的垃圾收集机制都各不相同。那么，为什么一门语言会选择这个机制，而另一种语言会选择另一种机制呢？带着这样的问题所做的分析，会让你把垃圾收集方面的原理落到实践中去。

第三，是并发模型。对并发的支持，对现代语言来说也越来越重要。在后面的课程中，我会带你了解线程、协程、Actor 三种并发模式，理解它们的优缺点，同时你也会了解到，如何在编译器和运行时中支持这些并发特性。

第三部分，是计算机语言设计上的 4 个高级话题。

第一，是元编程技术。元编程技术是一种对语言做扩展的技术，相当于能够定制一门语言，从而更好地解决特定领域的问题。Java 语言的注解功能、Python 的对象体系的设计，都体现了元编程功能。而 Julia 语言，更是集成了 Lisp 语言在元编程方面的强大能力。因此我会带你了解一下这些元编程技术的具体实现机制和特点，便于你去采纳和做好取舍。

第二，是泛型编程技术。泛型，或者说参数化类型，大大增强了现代语言的类型体系，使得很多计算逻辑的表达变得更简洁。最典型的应用就是容器类型，比如列表、树什么的，采用泛型技术实现的容器类型，能够方便地保存各种数据类型。像 Java、C++ 和 Julia 等语言都支持泛型机制，但它们各自实现的技术又有所不同。我会带你了解这些不同实现技术背后的原因，以及各自的特点。

第三，是面向对象语言的实现机制。面向对象特性是当前很多主流语言都支持的特性。那么要在编译器和运行时上做哪些工作，来支持面向对象的特性呢？对象在内存里的表示都有哪些不同的方式？如何实现继承和多态的特性？为什么 Java 支持基础数据类型和对象类型，而有些语言中所有的数据都是对象？要在编译技术上做哪些工作来支持纯面向对象特性？这些问题，我会花一讲的时间来带你分析清楚，让你理解面向对象语言的底层机制。

第四，是函数式编程语言的实现机制。函数式编程这个范式出现得很早，不少人可能不太了解或者不太关注它，但最近几年出现了复兴的趋势。像 Java 等面向对象语言，也开始加入对函数式编程机制的支持。在第三个模块中，我会带你分析函数式编程的关键特征，比如函数作为一等公民、不变性等，并会一起探讨函数式编程语言实现上的一些关键技术，比如函数类型的内部表示、针对函数式编程特有的优化算法等，让你真正理解函数式编程语言的底层机制。

该模块的最后一讲，也是本课程的最后一讲，是对我们所学知识的一个综合检验。这个检验的题目，就是解析方舟编译器。

方舟编译器，应该是第一个引起国内 IT 界广泛关注的编译器。俗话说，外行看热闹，内行看门道。做一个编译器，到底有哪些关键的技术点？它们在方舟编译器里是如何体现的？我们在学习了编译原理的核心基础知识，在考察了多个编译器之后，应该能够有一定的能力去考察方舟编译器了。这也是学以致用、紧密结合实际的表现。通过这样的分析，你能了解到中国编译技术崛起的趋势，甚至还可能会思考如何参与到这个趋势中来。这一讲，我希望同学们都能发表自己的看法，而我的看法呢，只是一家之言，你作为参考就好了。

小结

总结一下。咱们课程的名称是《编译原理实战课》，而最体现实战精神的，莫过于去实现一门计算机语言了。而在第三个模块，我就会带你解析实现一门计算机语言所要考虑的那些关键技术，并且通过学习，你也能够根据语言的设计目标来选择合适的技术方案。

从计算机语言设计的高度出发，这个模块会带你对编译原理形成更全面的认知，从而提高你把编译原理用于实战的能力。

28 | 前端总结：语言设计也有人机工程学

你好，我是宫文学。

正如我在上一讲的“课程导读”中所提到的，在“现代语言设计篇”，我们会开始探讨现代语言设计中的一些典型特性，包括前端、中后端、运行时的特性等，并会研究它们与编译技术的关系。

今天这一讲，我先以前面的“真实编译器解析篇”所分析的 7 种编译器作为基础，来总结一下它们的前端技术的特征，为你以后的前端工作做好清晰的指引。

在此基础上，我们还会进一步讨论语言设计方面的问题。近些年，各种新语言都涌现出了一个显著特征，那就是越来越考虑对程序员的友好性，运用了人机工程的思维。比如说，自动类型推导、Null 安全性等。那么在这里，我们就一起来分析一下，要支持这些友好的语法特征，在编译技术上都要做一些什么工作。

好，首先，我们就来总结一下各个编译器的前端技术特征。

前端编译技术总结

通过前面课程中对 7 个编译器的解读分析，我们现在已经知道了，编译器的前端有一些共性的特征，包括：手写的词法分析器、自顶向下分析为主的语法分析器和差异化的语义分析功能。

手写的词法分析器

我们分析的这几个编译器，全部都采用了手写的词法分析器。主要原因有几个：

第一，手写的词法分析实现起来比较简单，再加上每种语言的词法规则实际上是大同小异的，所以实现起来也都差不多。

第二，手写词法分析器便于做一些优化。典型的优化是把关键字作为标识符的子集来识别，而不用为识别每个关键字创建自动机。V8 的词法分析器还在性能上做了调优，比如判断一个字符是否是合法的标识符字符，是采用了查表的方法，以空间换性能，提高了解析速度。

第三，手写词法分析器便于处理一些特殊的情况。在 MySQL 的词法分析器中，我们会发现，它需要根据当前字符集来确定某个字符串是否是合法的 Token。如果采用工具自动生成词法分析器，则不容易处理这种情况。

结论：如果你要实现词法分析器，可以参考这些编译器，来实现你自己手写的版本。

自顶向下分析为主的语法分析器

在“解析篇”中，我们还见到了多个语法分析器。

手写 vs 工具生成

在前面解析的编译器当中，大部分都是手写的语法分析器，只有 Python 和 MySQL 这两个是用工具生成的。

一方面，手写实现能够在某些地方做一些优化的实现，比如在 Java 语言里，我们可以根据需要，预读一到多个 Token。另外，手写实现也有利于编译错误的处理，这样可以尽量给用户提供更友好的编译错误信息，并且当一个地方发生错误以后，也能尽量不影响对后面的语句的解析。手写的语法分析器在这些方面都能提供更好的灵活性。

另一方面，Python 和 MySQL 的编译器也证明了，用工具生成的语法分析器，也是完全可以用于高要求的产品之中的。所以，如果你的项目时间和资源有限，你要优先考虑用工具生成语法分析器。

自顶向下 vs 自底向上

我们知道，语法分析有两大算法体系。一是自顶向下，二是自底向上。

从我们分析过的 7 种编译器里可以发现，自顶向下的算法体系占了绝对的主流，只有 MySQL 的语法分析器，采用的是自底向上的 LALR 算法。

而在自顶向下的算法中，又几乎全是采用了递归下降算法，Java、JavaScript 和 Go 三大语言的编译器都是如此。并且对于左递归这个技术点，我们用标准的改写方法就可以解决。

不过，我们还看到了自顶向下算法和自底向上算法的融合。Java 语言和 Go 语言在处理二元表达式时，引入了运算符优先级解析器，从而避免了左递归问题，并且在处理优先级和结合性的问题上，也会更加容易。而运算符优先级解析器，实际上采用的是一种 LR 算法。

差异化的语义分析功能

不同编译器的语义分析功能有其共性，那就是都要建立符号表、做引用消解。对于静态类型的语言来说，还一定要做类型检查。

语义分析最大的特点是上下文相关，AST 加上这些上下文相关的关系，就从树变成了图。由于处理图的算法一般比较复杂，这就给引用消解带来了困难，因此我们在算法上必须采用一定的启发式规则，让算法简化。

比如，我们可以先把类型加入符号表，再去消解用到这些类型的地方：变量声明、方法声明、类继承的声明，等等。你还需要注意的是，在消解本地变量的时候，还必须一边消解，一边把本地变量加入符号表，这样才能避免形成错误的引用关系。

不过，在建立符号表，并做完引用消解以后，上下文相关导致的复杂性就被消除了。所以，后续的语义分析算法，我们仍然可以通过简单地遍历 AST 来实现。所以，你会看到这些编译器当中，大量的算法都是实现了 Visitor 模式。

另外，除了建立符号表、做引用消解和类型检查等语义分析功能，不同的编译器还要去处理自己特有的语义。比如说，Java 编译器花了很多的工作量在处理语法糖上，还有对注解的处理上；Julia 的编译器会去做类型推断；Python 的编译器会去识别变量的作用域范围，等等。

这其中，很多的语义处理功能，都是为了支持更加友好的语言特性，比如 Java 的语法糖。在现代语言中，还增加了很多的特性，能够让程序员的编程工作更加容易。接下来，我就挑几个共性的特性，跟你一起探讨一下它们的实现。

支持友好的语言特性

自动类型推导、Null 安全性、通过语法糖提高语法的友好性，以及提供一些友好的词法规则，等等。这些都是现代语言努力提高其友好性的表现。

自动类型推导

自动类型推导可以减少编程时与类型声明有关的工作量。我们来看看下面这几门语言，都是如何声明变量的。

C++ 语言是一门不断与时俱进的语言。在 C++ 11 中，采用了 auto 关键字做类型推导。比如：

int a = 10;
auto b = a;   //能够自动推导b的类型是int
cout << typeid(b).name() << endl;  //输出int

你可能会觉得，这看上去似乎也没啥呀，把 int 换成了 auto 好像并没有省多少事儿。但在下面这个例子中，你会发现用于枚举的变量的类型很长（std::vector<std::string>::iterator），那么你就大可以直接用一个 auto 来代替，省了很多事，代码也更加整洁。所以实际上，auto 关键字也成为了在 C++ 中使用枚举器的标准用法：

std::vector<std::string> vs;
for(std::vector<std::string>::iterator i=vs.begin(); i!=vs.end();i++){
        //...
}
//使用auto以后，简化为：
fora(auto i=vs.begin(); i!=vs.end();i++){
        //...
}

我们接着来看看其他的语言，都是如何做类型推导的。

Kotlin 中用 var 声明变量，也支持显式类型声明和类型推导两种方式。

var a : Int = 10; //显式声明
var b = 10;       //类型推导

Go 语言，会用“:=” 让编译器去做类型推导：

var i int = 10;   //显示声明
i := 10;          //类型推导

而 Swift 语言是这样做的：

let a : Int = 10;  //常量类型显式声明
let b = 10;        //常量类型推导
var c : Int = 10;  //变量类型显式声明
var c = 10;        //变量类型推导

实际上，连 Java 语言也在 Java 10 版本加上了类型推导功能，比如：

Map<String, User> a = new HashMap<String, User>(); //显式声明
var b = new HashMap<String, User>();               //类型推导

你在学习了语义分析中，基于属性计算做类型检查的机制以后，就会发现实现类型推导，其实是很容易的。只需要把等号右边的初始化部分的类型，赋值给左边的变量就行了。

可以看到，在不同的编译器的实现当中，类型推导被如此广泛地接受，所以如果你要设计一门新的语言，你也一定要考虑类似的做法。

好，我们接着再来探讨下一个有趣的特性，它叫做“Null 安全性”。

Null 安全性

在 C++ 和 Java 等语言里，会用 Null 引用，来表示某个变量没有指向任何对象。这个特性使得语言里充满了 Null 检查，否则运行时就会报错。

给你举个例子。下面这段代码中，我们想要使用 student.teacher.name 这个成员变量，因此程序要逐级检查 student、teacher 和 name 是否为 Null。不检查又不行，检查又太啰嗦。你在自己写程序的时候，肯定也遇到过这种困扰。

if (student != null
    && student.teacher != null
    && student.teacher.name !=null){
  ...
}

Null 引用其实是托尼·霍尔（Tony Hoare）在 1960 年代在设计某一门语言（ALGOL W）时引入的，后来也纷纷被其他语言所借鉴。但 Hoare 后来却认为，这是一个“价值亿万美元的错误”，你可以看看他在QCon 上的演讲。因为大量的软件错误都是由 Null 引用引起的，而计算机语言的设计者本应该从源头上消除它。

其实我觉得 Hoare 有点过于自责了。因为在计算机语言发展的早期，很多设计决定的后果都是很难预料的，当时的技术手段也很有限。而在计算机语言已经进化了这么多年的今天，我们还是有办法消除或者减少 Null 引用的不良影响的。

以 Kotlin 为例，在缺省情况下，它不允许你把 Null 赋给变量，因此这些变量就不需要检查是否为 Null。

var a : String = "hello";
a = null;  //报编译错误

不过有的时候，你确实需要用到 Null，那该怎么办？

你需要这样的声明变量，在类型后面带上问号，告诉编译器这个变量可为空：

var a : String? = "hello";
a = null;  //OK

但接下来，如果你要使用 a 变量，就必须进行 Null 检查。这样，编译器会跟踪你是否做了所有的检查。

val l = b.length;  //编译器会报错，因为没有做null检查

if (b != null){
  println(b.length);  //OK，因为已经进行了null检查
}

或者，你可以进行安全调用（Safe Call），采用“?.”操作符来访问 b.length，其返回值是一个 Int? 类型。这样的话，即使 b 是 Null，程序也不会出错。

var l : Int? = b?.length;

并且，如果你下一步要使用 l 变量的话，就要继续进行 Null 的检查。编译器会继续保持跟踪，让整个过程不会有漏洞。

而如果你对一个本身可能为 Null 的变量赋值，编译器会生成 Null 检查的代码。如果该变量为 Null，那么赋值操作就会被取消。

在下面的示例代码中，如果 student 或是 teacher，或者是 name 的值为 Null，赋值操作都不会发生。这大大减少了那种啰嗦的 Null 检查：

student?.teacher?.name=course.getTeacherName();

你可以看到，Kotlin 通过这样的机制，就大大降低了 Null 引用可能带来的危害，也大大减少了 Null 检查的代码量，简直是程序员的福音。

而且，不仅是 Kotlin 语言具有这个特性，Dart、Swift、Rust 等新语言都提供了 Null 安全性。

那么，Null 安全性在编译器里应该怎样实现呢？

最简单的，你可以给所有的类型添加一个属性：Nullable。这样就能区分开 Int? 和 Int 类型，因为对于后者来说，Null 不是一个合法的取值。之后，你再运用正常的属性计算的方法，就可以实现 Null 安全性了。

接下来，我们再看看现代语言会采用的一些语法糖，让语法更友好。

一些友好的语法糖

1. 分号推断

分号推断的作用是在编程的时候，让程序员省略掉不必要的分号。在 Java 语言中，我们用分号作为一个语句的结尾。而像 Kotlin 等语言，在一个语句的最后，可以加分号，也可以不加。但如果两个语句在同一行，那么就要加分号了。

2. 单例对象

在程序中，我们经常使用单例的数据模式。在 Java、C++ 等语言中，你需要写一些代码来确保只生成类的一个实例。而在 Scala、Kotlin 这样的语言中，可以直接声明一个单例对象，代码非常简洁：

object MyObject{
  var field1...
  var field2...
}

3. 纯数据的类

我们在写程序的时候，经常需要处理一些纯粹的数据对象，比如数据库的记录等。而如果用传统的类，可能编写起来会很麻烦。比如，使用 Java 语言的话，你需要为这些类编写 toString() 方法、hashCode() 方法、equals() 方法，还要添加很多的 setter 和 getter 方法，非常繁琐。

所以，在 JDK 14 版本，就增加了一个实验特性，它可以支持 Record 类。比如，你要想定义一个 Person 对象，只需要这样一句话就行了：

public record Person(String firstName, String lastName, String gender, int age){}

这样一个语句，就相当于下面这一大堆语句：

public final class Person extends Record{
  private final String firstName;
  private final String lastName;
  private final String gender;
  private final int age;

  public Person(String firstName, String lastName, String gender, int age){
    this.firstName = firstName;
    this.lastName = lastName;
    this.gender = gender;
    this.age = age;
  }

  public String getFirstName(){
    return this.firstName;
  }

  public String getLastName(){
    return this.lastName;
  }

  public String getGender(){
    return this.gender;
  }

  public String getAge(){
    return this.age;
  }

  pulic String toString(){
    ...
  }

  public boolean equals(Object o){
    ...
  }

  public int hashCode(){
    ...
  }
}

所以你可以看到，Record 类真的帮我们省了很多的事儿。Kotlin 也有类似的 data class，而 Julia 和 Swift 内置支持元组，对纯数据对象的支持也比较好。

4. 没有原始类型，一切都是对象

像 Java、Go、C++、JavaScript 等面向对象的语言，既要支持基础的数据类型，如整型、浮点型，又要支持对象类型，它们对这两类数据的使用方式是不一致的，因此也就增加了我们的编程负担。

而像 Scala、Kotlin 等语言，它们可以把任何数据类型都看作是对象。比如在 Kotlin 中，你可以直接调用一个整型或浮点型数字的方法：

不过你要注意的是，如果你要把基础数据类型也看作是对象，在编译器的实现上要做一些特殊的处理，因为如果把这些基础数据当作普通对象一样保存在堆里，那显然要占据太多的空间（你可以回忆一下Java 对象头所需要的空间），并且访问性能也更低。

那么要如何解决这些问题呢？这里我先留一个伏笔，我们在“综合实现（一）：如何实现面向对象编程？”这一讲再来讨论吧！

除了语法上的一些友好设计之外，一些现代语言还在词法规则方面，提供了一些友好的设计。我们一起来看一下。

一些友好的词法规则

1. 嵌套的多行注释

编程语言一般都支持多行注释。比如，你可以把暂时用不到的一段代码给注释起来。这些代码里如果有单行注释也不妨碍。

但是，像 Kotlin、Swift 这些语言又更进了一步，它们可以支持在多行注释里嵌套多行注释。这是一个很贴心的功能。这样的话，你就可以把连续好几个函数或方法给一起注释掉。因为函数或方法的头部，一般都有多行的头注释。支持嵌套注释的话，我们就可以把这些头注释一起包含进去。

你可以去看看它们的词法分析器中处理注释的逻辑，了解下它们是如何支持嵌套的多行注释的。

2. 标识符支持 Unicode

现代的大部分语言，都支持用 Unicode 来声明变量，甚至可以声明函数或类。这意味着什么呢？你可以用中文来声明变量和函数名称。而对于科学工作者来说，你也可以使用π、α、β、θ这些希腊字母，会更符合自己的专业习惯。下面是我在 Julia 中使用 Unicode 的情况：

3. 多行字符串字面量

对于字符串字面量来说，支持多行的书写方式，也会给我们的编程工作带来很多的便利。比如，假设你要把一个 JSON 字符串或者一个 XML 字符串赋给一个变量，用多行的书写方式会更加清晰。如下所示：

现在，很多的编程语言都可以支持多行的字符串字面量，比如：

课程小结

今天这一讲，我带你一起总结了一下编译原理的前端技术。在解析了这么多个编译器以后，你现在对于实现前端功能时，到底应该选择什么技术、不同的技术路线有什么优缺点，就都心里有数了。

另外，很多我们可以感知得到的现代语言特性，都是一些前端的功能。比如，更友好的词法特性、更友好的语法特性，等等。你可以借鉴当前语言的一些最佳实践。以你现在的知识积累来说，理解上述语言特性在前端的实现过程，应该不难了。如果你对哪个特性特别感兴趣，也可以按照课程的思路，去直接研究它的编译器。

最后，我把本讲的思维导图也整理了出来，供你参考：

一课一思

你比较推崇哪些友好的前端语言特性？它们是怎么实现的？欢迎在留言区分享你的看法。另外，如果你觉得哪些前端特性的设计是失败的，也可以拿来探讨，我们共同吸取教训。

感谢你的阅读，欢迎你把今天的内容分享给更多的朋友。

29 | 中端总结：不遗余力地进行代码优化

你好，我是宫文学。

今天这一讲，我继续带你来总结前面解析的 7 种真实的编译器中，中端部分的特征和编译技术。

在课程的第 1 讲，我也给你总结过编译器的中端的主要作用，就是实现各种优化。并且在中端实现的优化，基本上都是机器无关的。而优化是在 IR 上进行的。

所以，今天这一讲，我们主要来总结以下这两方面的问题：

第一，是对 IR 的总结。我在第 6 讲中曾经讲过，IR 分为 HIR、MIR 和 LIR 三个层次，可以采用线性结构、图、树等多种数据结构。那么基于我们对实际编译器的研究，再一起来总结一下它们的 IR 的特点。

第二，是对优化算法的总结。在第 7 讲，我们把各种优化算法做了一个总体的梳理。现在就是时候，来总结一下编译器中的实际实现了。

通过今天的总结，你能够对中端的两大主题 IR 和优化，形成更加深入的理解，从而更有利于你熟练运用编译技术。

好了，我们先来把前面学到的 IR 的相关知识，来系统地梳理和印证一下吧。

对 IR 的总结

通过对前面几个真实编译器的分析，我们会发现 IR 方面的几个重要特征：SSA 已经成为主流；Sea of Nodes 展现出令人瞩目的优势；另外，一个编译器中的 IR，既要能表示抽象度比较高的操作，也要能表示抽象度比较低的、接近机器码的操作。

SSA 成为主流

通过学习前面的课程，我们会发现，符合 SSA 格式的 IR 成为了主流。Java 和 JavaScript 的 Sea of Nodes，是符合 SSA 的；Golang 是符合 SSA 的；Julia 自己的 IR，虽然最早不是 SSA 格式的，但后来也改成了 SSA；而 Julia 所采用的 LLVM 工具，其 IR 也是 SSA 格式的。

SSA 意味着什么呢？源代码中的一个变量，会变成多个版本，每次赋值都形成一个新版本。在 SSA 中，它们都叫做一个值（Value），对变量的赋值就是对值的定义（def）。这个值定义出来之后，就可以在定义其他值的时候被使用（use），因此就形成了清晰的“使用 - 定义”链（use-def）。

这种清晰的 use-def 链会给优化算法提供很多的便利：

如果一个值定义了，但没有被使用，那就可以做死代码删除。

如果对某个值实现了常数折叠，那么顺着 def-use 链，我们就可以马上把该值替换成常数，从而实现常数传播。

如果两个值的定义是一样的，那么这两个值也一定是一样的，因此就可以去掉一个，从而实现公共子表达式消除；而如果不采取 SSA，实现 CSE（公共子表达式消除）需要做一个数据流分析，来确定表达式的变量值并没有发生变化。

针对最后一种情况，也就是公共子表达式消除，我再给你展开讲解一下，让你充分体会 SSA 和传统 IR 的区别。

我们知道，基于传统的 IR，要做公共子表达式消除，就需要专门做一个“可用表达式”的分析。像下图展示的那样，每扫描一遍代码，就要往一个集合里增加一个可用的表达式。

为什么叫做可用表达式呢？因为变量可能被二次赋值，就像图中的变量 c 那样。在二次赋值以后，之前的表达式“c:=a+b”就不可用了。

图 1：变量 c 二次赋值后，各个位置的可用表达式集合

在后面，当替换公共子表达式的时候，我们可以把“e:=a+b”替换成“e:=d”，这样就可以少做一次计算，实现了优化的目标。

而如果采用 SSA 格式，上面这几行语句就可以改写为下图中的样子：

图 2：用 SSA 格式的 IR 改写的程序

可以看到，原来的变量 c 被替换成了 c1 和 c2 两个变量，而 c1、d 和 e 右边的表达式都是一样的，并且它们的值不会再发生变化。所以，我们可以马上消除掉这些公共子表达式，从而减少了两次计算，这就比采用 SSA 之前的优化效果更好了。最重要的是，整个过程根本不需要做数据流分析。

图 3：把公共子表达式 a+b 消除掉

好，在掌握了 SSA 格式的特点以后，我们还可以注意到，Java 和 JavaScript 的两大编译器，在做优化时，竟然都不约而同地用到了 Sea Of Nodes 这种数据结构。它看起来非常重要，所以，我们再接着来总结一下，符合 SSA 格式的 Sea of Nodes，都有什么特点。

Sea of Nodes 的特点总结

其实在解析 Graal 编译器的时候，我就提到过，Sea of Nodes 的特点是把数据流图和控制流图合二为一，从而更容易实现全局优化。因为采用这种 IR，代码并没有一开始就被限制在一个个的基本块中。直到最后生成 LIR 的环节，才会把图节点 Schedule 到各个基本块。作为对比，采用基于 CFG 的 IR，优化算法需要让代码在基本块内部和基本块之间移动，处理起来会比较复杂。

在这里，我再带你把生成 IR 的过程推导一遍，你能从中体会到生成 Sea of Nodes 的思路，并且还会有一些惊喜的发现。

示例函数或方法是下面这样：

int foo(int b){
  a = b;
  c = a + b;
  c = b;
  d = a + b;
  e = a + b;
  return e;
}

那么，为它生成 IR 图的过程是怎么样的呢？

第 1 步，对参数 b 生成一个节点。

第 2 步，对于 a=b，这里并没有形成一个新的值，所以在后面在引用 a 和 b 的时候，都指向同一个节点就好。

第 3 步，对于 c=a+b，生成一个加法节点，从而形成一个新的值。

第 4 步，对于 c=b，实际上还是直接用 b 这个节点就行了，并不需要生成新节点。

第 5 步和第 6 步，对于 d=a+b 和 e=a+b，你会发现它们都没有生成新的值，还是跟 c1 用同一个节点表示就行。

第 7 步，对于 return 语句，这时候生成一个 return 节点，返回上面形成的加法节点即可。

从这个例子中你发现了什么呢？原来，采用 Sea of Nodes 作为 IR，在生成图的过程中，顺带就可以完成很多优化了，比如可以消除公共子表达式。

所以我希望，通过上面的例子，你能进一步抓住 Sea of Nodes 这种数据结构的特点。

但是，Sea of Nodes 只有优点，没有缺点吗？也不是的。比如：

你在检查生成的 IR、阅读生成的代码的时候，都会更加困难。因为产生的节点非常多，会让你头晕眼花。所以，这些编译器都会特别开发一个图形化的工具，来帮助我们更容易看清楚 IR 图的脉络。

对图的访问，代价往往比较大。当然这也可以理解。因为你已经知道，对树的遍历是比较简单的，但对图的遍历算法就要复杂一些。

还有，当涉及效果流的时候，也就是存在内存读写等操作的时候，我们对控制流做修改会比较困难，因为内存访问的顺序不能被打乱，除非通过优化算法把固定节点转换成浮动节点。

总体来说，Sea of Nodes 的优点和缺点都来自图这种数据结构。一方面，图的结构简化了程序的表达；另一方面，要想对图做某些操作，也会更困难一些。

从高到低的多层次 IR

对于 IR 来说，我们需要总结的另一个特点，就是编译器需要从高到低的多个层次的 IR。在编译的过程中，高层次的 IR 会被不断地 Lower 到低层次的 IR，直到最后翻译成目标代码。通过这样层层 Lower 的过程，程序的语义就从高级语言，一步步变到了汇编语言，中间跨越了巨大的鸿沟：

高级语言中对一个数组元素的访问，到汇编语言会翻译成对内存地址的计算和内存访问；

高级语言中访问一个对象的成员变量，到汇编语言会以对象的起始地址为基础，计算成员变量相对于起始地址的偏移量，中间要计算对象头的内存开销；

高级语言中对于本地变量的访问，到汇编语言要转变成对寄存器或栈上内存的访问。

在采用 Sea of Nodes 数据结构的时候，编译器会把图中的节点，从代表高层次语义的节点，逐步替换到代表低层次语义的节点。

以 TurboFan 为例，它的 IR 就包含了几种不同层次的节点：

抽象度最高的，是复杂的 JavaScript 节点；

抽象度最低的，是机器节点；

在两者之间的，是简化的节点。

伴随着编译的进程，我们有时还要进行 IR 的转换。比如 GraalVM，会从 HIR 转换到 LIR；而 Julia 的编译器则从自己的 IR，转换成 LLVM 的 IR；另外，在 LLVM 的处理过程中，其 IR 的内部数据结构也进行了切换。一开始使用的是便于做机器无关的优化的结构，之后转换成适合生成机器码的结构。

好，总结完了 IR，我们再来看看编译器对 IR 的处理，比如各种分析和优化算法。

对优化算法的总结

编译器基于 IR，主要做了三种类型的处理。第一种处理，就是我们前面说的层层地做 Lower。第二种处理，就是对 IR 做分析，比如数据流分析。第三种处理，就是实现各种优化算法。编译器的优化往往是以分析为基础。比如，活跃性分析是死代码消除的基础。

前面我也说过，编译器在中端所做的优化，基本上都是机器无关的优化。那么在考察了 7 种编译器以后，我们来总结一下这些编译器做优化的特点。

第一，有些基本的优化，是每个编译器都会去实现的。

比如说，我们见过的常数折叠、代数简化、公共子表达式消除等。这些优化还可能发生在多个阶段，比如从比较早期的语义分析阶段，到比较晚期的基于目标代码的窥孔优化，都使用了这些优化算法。

第二，对于解释执行的语言，其编译器能做的优化是有限的。

前面我们见到了代码在 JVM 的解释器、Python 的解释器、V8 的解释器中运行的情况，现在我们来总结一下它们的运行时的特点。

Python 对代码所做的优化非常有限，在解释器中执行的性能也很低。最重要的原因，是所有的类型检查都是在运行期进行的，并且会根据不同的类型选择执行不同的功能。另外，Python 所有的对象都是在堆里申请内存的，没有充分利用栈来做基础数据类型的运算，这也导致了它的性能损耗。

JVM 解释执行的性能要高一些，因为 Java 编译器已经做了类型检查，并针对不同数据类型生成了不同的指令。但它只做了一些简单的优化，一些无用的代码并没有被消除掉，对 Java 程序性能影响很大的内联优化和逃逸分析也都没有做。它基于栈的运行机制，也没有充分发挥寄存器的硬件能力。

V8 的 Ignition 解释器在利用寄存器方面要比 JVM 的解释器有优势。不过，它的动态类型拖了后腿，这跟 Python 是一样的。

第三，对于动态类型的语言，优化编译的首要任务是做类型推断。

以 V8 的 TurboFan 为例，它对类型信息做不同的推断的时候，优化效果是不同的。如果你一开始运行程序，就逼着 TurboFan 马上做编译，那么 TurboFan 其实并不知道各个变量的类型信息，因此只能生成比较保守的代码，它仍然是在运行时进行类型检查，并执行不同的逻辑。

而一旦通过运行积累了一定的统计数据，TurboFan 就可以大胆地做出类型的推断，从而生成针对某种类型的优化代码。不过，它也一定要为自己可能产生的推理错误做好准备，在必要的时候执行逆优化功能。

Julia 也是动态类型的语言，但它采取了另一个编译策略。它会为一个函数不同的参数类型组合，编译生成对应的机器码。在运行时，根据不同的函数参数，分派到不同的函数版本上去执行，从而获得高性能。

第四，JIT 编译器可以充分利用推理性的优化机制，这样既节省了编译时间，又有可能带来比 AOT 更好的优化效果。

第五，对于面向对象的语言，内联优化和逃逸分析非常重要。

在分析 Graal 编译器和 V8 的 TurboFan 编译器的过程中，我都特别强调了内联优化和逃逸分析的作用。内联优化不仅能减少对若干短方法调用的开销，还能导致进一步的优化；而逃逸分析能让很多对象在栈上申请内存，并实现标量替换、锁消除等优化，从而获得极大的性能提升。

第六，对于静态类型的语言，不同的编译器的优化程度也是不同的。

很多工程师经常会争论哪个语言的性能更高。不过在学了编译原理之后，其实可以发现这根本不用争论。你可以设计一些示例程序，测试不同的编译器优化后生成的汇编代码，从而自己得出结论。

现在，我用一个示例程序，来带你测试一下 Graal、Go 和 Clang 三个编译器处理数组加法的效率，你可以借此了解一下它们各自的优化力度，特别是看看它们有没有自动向量化的支持，并进一步了解不同语言的运行机制。

首先来看看 Java，示例代码在 SIMD.java 中。其中的 add 方法，是把一个数组的所有值汇总。

private static int add(int a[]){
    int sum = 0;
    for (int i=0; i<a.length; i++){
        sum = sum + a[i];
    }
    return sum;
}

我们还是用 Graal 做即时编译，并打印出生成的汇编代码。这里我截取了其中的主要部分，给你做了分析：

分析这段汇编代码，你能得到下面的信息：

Java 中的数组，其头部（在 64 位环境下）占据 16 个字节，其中包含了数组长度的信息。

Java 生成的汇编代码，在每次循环开始的时候，都要检查下标是否越界。这是一个挺大的运算开销。其实我们使用的数组下标 i，永远不会越界，所以本来可以优化得更好。

上述汇编代码并没有使用 SIMD 指令，没有把循环自动向量化。

我们再来看一下 Go 语言的优化效果，示例代码在 SIMD.go 中。

package main

func add(a []int) int {
    sum := 0;
    for i:=0; i<len(a); i++{
        sum = sum + a[i]
    }
    return sum;
}

我们生成 Go 语言特有的伪汇编以后，是下面这个样子：

我们拿它跟 Graal 生成的汇编代码做比较，会发现其中最重要的差别，是 Go 的编译器消除了下标检查，这是一个挺大的进步，能够提升不少的性能。不过，你也可以测试一下，当代码中的“len(a)”替换成随意的一个整数的时候，Go 的编译器会生成什么代码。它仍然会去做下标检查，并在下标越界的时候报错。

不过，令人遗憾的是，Go 语言的编译器仍然没有自动生成向量化的代码。

最后，我们来看一下 Clang 是如何编译同样功能的一个 C 语言的程序的（SIMD.c）。

int add(int a[], int length){
    int sum = 0;
    for (int i=0; i<length; i++){
        sum = sum + a[i];
    }
    return sum;
}

编译生成的汇编代码在 SIMD.s 中。我截取了其中的骨干部分：

你已经知道，Clang 是用 LLVM 做后端的。在它生成的汇编代码中，对循环做了三种优化：

自动向量化：用 movdqu 指令，一次能把 4 个整数，也就是 16 个字节、128 位数据拷贝到寄存器。用 paddd 指令，可以一次实现 4 个整数的加法。

循环展开：汇编代码里，在一次循环里执行了 8 次 SIMD 的 add 指令，因为每次相当于做了 4 个整数的加法，因此每个循环相当于做了源代码中 32 次循环的工作。

指令排序：你能看到，由于一个循环中有很多个指令，所以这就为指令排序提供了机会。另外你还能看到，在这段汇编代码中，集中做了多次的 movdqu 操作，这可以更好地让指令并行化。

通过这样的对比，你会发现 LLVM 做的优化是最深入的。所以，如果你要做计算密集型的软件，如果能做到像 LLVM 这样的优化程度，那就比较理想了。

不过，做比较深入的优化也不是没有代价的，那就是编译时间会更长。而 Go 语言的编译器，在设计之初，就把编译速度当成了一个重要的目标，因此它没有去实现自动向量化的功能也是可以理解的。

如果你要用 Go 语言开发软件，又需要做密集的计算，那么你有两个选择。一是用 Go 语言提供的内置函数（intrincics）去实现计算功能，这些内置函数是直接用汇编语言实现的。二是 Go 语言也提供了一个基于 LLVM 的编译器，你可以用这个编译器来获得更好的优化效果。

课程小结

这一讲，我带你全面系统地总结了一下“解析篇”中，各个实际编译器的 IR 和优化算法。通过这样的总结，你会对如何设计 IR、如何做优化，有一个更加清晰的认识。

从 IR 的角度来看，你一定要采用 SSA 格式的 IR，因为它有显著的优点，没有理由不采用。不过，如果你打算自己编写各种优化算法，也不妨进一步采用 Sea of Nodes 这样的数据结构，并借鉴 Graal 和 V8 的一些算法实现。

不过，自己编写优化算法的工作量毕竟很大。在这种情况下，你可以考虑复用一些后端工具，包括 LLVM、GraalVM 和 GCC。

本讲的思维导图我也放在了下面，供你参考：

一课一思

今天我带你测试了 Graal、Go 和 Clang 三个编译器，在 SIMD 方面编译结果的差异。那么，你能否测试一下这几个编译器在其他方面的优化表现？比如循环无关代码外提，或者你比较感兴趣的其他优化。欢迎在留言区分享你的测试心得。

如果你还有其他的问题，欢迎在留言区提问，我会逐一解答。最后，感谢你的阅读，如果今天的内容让你有所收获，也欢迎你把它分享给更多的朋友。

30 | 后端总结：充分发挥硬件的能力

你好，我是宫文学。

后端的工作，主要是针对各种不同架构的 CPU 来生成机器码。在第 8 讲，我已经对编译器在生成代码的过程中，所做的主要工作进行了简单的概述，你现在应该对编译器的后端工作有了一个大致的了解，也知道了后端工作中的关键算法包括指令选择、寄存器分配和指令排序（又叫做指令调度）。

那么今天这一讲，我们就借助在第二个模块中解析过的真实编译器，来总结、梳理一下各种编译器的后端技术，再来迭代提升一下原有的认知，并加深对以下这些问题的理解：

首先，在第 8 讲中，我只讲了指令选择的必要性，但对于如何实现指令选择等步骤，我并没有展开介绍。今天这一讲，我就会带你探索一下指令选择的相关算法。

其次，关于寄存器分配算法，我们探索过的好几个编译器，比如 Graal、gc 编译器等，采用的都是线性扫描算法，那么这个算法的原理是什么呢？我们一起来探究一下。

最后，我们再回到计算机语言设计的主线上来，一起分析一下不同编译器的后端设计，是如何跟该语言的设计目标相匹配的。

OK，我们先来了解一下指令选择的算法。

指令选择算法

回顾一下，我们主要是在Graal和Go 语言的编译器中，分析了与指令选择有关的算法。它们都采用了一种模式匹配的 DSL，只要找到了符合模式的指令组合，编译器就生成一条低端的、对应于机器码的指令。

那为什么这种算法是有效的呢？这种算法的原理是什么呢？都有哪些不同的算法实现？接下来，我就给你揭晓一下答案。

我先给你举个例子。针对表达式“a[i]=b”，它是对数组 a 的第 i 个元素赋值。假设 a 是一个整数数组，那么地址的偏移量就是a+4*i，所以，这个赋值表达式用 C 语言可以写成“*(a+4*i)=b”，把它表达成 AST 的话，就是下图所示的样子。其中，赋值表达式的左子树的计算结果，是一个内存地址。

图 1：a[i]=b 的 AST

那么，我们要如何给这个表达式生成指令呢？

如果你熟悉 x86 汇编，你就会知道，上述语句可以非常简单地表达出来，因为 x86 的指令对数组寻址做了优化（参见第 8 讲的内容）。

不过，这里为了让你更容易理解算法的原理，我设计了一个新的指令集。这个指令集中的每条指令，都对应了一棵 AST 的子树，我们把它叫做模式树（Pattern Tree）。在有的算法里，它们也被叫做瓦片（Tiling）。对一个 AST 生成指令，就是用这样的模式树或瓦片来覆盖整个 AST 的过程。所以，这样的算法也叫做基于模式匹配的指令生成算法。

图 2：指令集中的指令和对应的模式树

你可以看到，在图 2 中，对于每棵模式树，它的根节点是这个指令产生的结果的存放位置。比如，Load_Const 指令执行完毕以后，常数会被保存到一个寄存器里。这个寄存器，又可以作为上一级 AST 节点的操作数来使用。

图 2 中的指令包含：把常数和内存中的值加载到寄存器、加法运算、乘法运算等。其中有两个指令是特殊设计的，目的就是为了让你更容易理解接下来要探究的各种算法。

第一个指令是 #4（Store_Offset），它把值保存到内存的时候，可以在目的地址上加一个偏移量。你可以认为这是为某些场景做的一个优化，比如你在对象地址上加一个偏移量，就能获得成员变量的地址，并把数值保存到这个地址上。

第二个指令是 #9（Lea），它相当于 x86 指令集中的 Lea 指令，能够计算一个地址值，特别是能够利用间接寻址模式，计算出一个数组元素的地址。它能通过一条指令完成一个乘法计算和一个加法计算。如果你忘记了 Lea 指令，可以重新看看第 8 讲的内容。

基于上述的指令和模式树，我们就可以尝试来做一下模式匹配，从而选择出合适的指令。那么都可以采用什么样的算法呢？

第一个算法，是一种比较幼稚的算法。我们采取深度优先的后序遍历，也就是按照“左子节点 -> 右子节点 -> 父节点”的顺序遍历，针对每个节点去匹配上面的模式。

第 1 步，采用模式 #2，把内存中 a 的值，也就是数组的地址，加载到寄存器。因为无论加减乘除等任何运算，都是可以拿寄存器作为操作数的，所以做这个决策是很安全的。

第 2 步，同上，采用模式 #1，把常量 4 加载到寄存器。

第 3 步，采用模式 #2，把内存中 i 的值加载到寄存器。

第 4 步，采用模式 #8，把两个寄存器的值相乘，得到（4*i）的值。

第 5 步，采用模式 #5，把两个寄存器的值相加，得到 a+4*i 的值，也就是 a[i]的地址。

第 6 步，采用模式 #2，把内存中 b 的值加载到寄存器。

第 7 步，采用模式 #3，把寄存器中 b 的值写入 a[i]的地址。

图 3：用比较幼稚的算法做模式匹配

最后形成的汇编代码是这样的：

Load_Mem a, R1
Load_Const 4, R2
Load_Mem i, R3
Mul_Reg R2, R3
Add_Reg R3, R1
Load_Mem b, R2
Store R2, (R1)

这种方法，是自底向上的做树的重写。它的优点是特别简单，缺点是性能比较差。它一共生成了 7 条指令，代价是 19（3+1+3+4+1+3+4）。

在上述步骤中，我们能看到很多可以优化的地方。比如，4*i 这个子表达式，我们是用了 3 条指令来实现的，总的 Cost 是 1+3+4=8，而如果改成两条指令，也就是使用 Mul_mem 指令，就不用先把 i 加载到寄存器，Cost 可以是 1+6=7。

Load_Const 4, R1
Mul_Mem i, R1

第二种方法，是类似 Graal 编译器所采用的方法，自顶向下的做模式匹配。比如，当我们处理赋值节点的时候，算法会尽量匹配更多的子节点。因为一条指令包含的子节点越多，那么通过一条指令完成的操作就越多，从而总的 Cost 就更低。

所以，算法的大致步骤是这样的：

第 1 步，在 #3 和 #4 两个模式中做选择的话，选中了 #4 号。

第 2 步，沿着 AST 继续所深度遍历，其中 + 号节点第 1 步被处理掉了，所以现在处理变量 a，采用了模式 #2，把变量加载到寄存器。

第 3 步，处理 * 节点。这个时候要在 #7 和 #8 之间做对比，最后选择了 #7，因为它可以包含更多的节点。

第 4 步，处理常量 4。因为上级节点在这里需要一个寄存器作为操作数，所以我们采用了模式 #1，把常量加载到寄存器。

第 5 步，处理变量 b。这里也要把它加载到寄存器，因此采用了模式 #2。

图 4：Maximal Munch 算法的匹配结果

到此为止，我们用了 5 条指令就做完了所有的运算，生成的汇编代码是：

Load_Mem a, R1
Load_Const 4, R2
Mul_Mem R2, i
Load_Mem b, R3
Store_Offset R3, (R1,R2)

这 5 条指令总的 Cost 是 18（3+1+6+3+5）。

上述算法的特点，是在每一步都采用了贪婪策略，这种算法策略有时候也叫做“Maximal Munch”，意思就是每一步都去咬最大的一口。

贪婪策略会生成比幼稚的算法更优化的代码，但它不一定是最优的。你看下图中的匹配策略，它也是用了 5 条指令。

图 5：最优的匹配策略

生成的汇编代码如下：

Lead_Mem a, R1
Load_Mem i, R2
Lea (R1,R2,4), R1
Load_Mem b, R2
Store R2, (R1)

这个新的匹配结果，总的 Cost 是 17（3+3+4+3+4），比前一个算法的结果更优化了。那我们用什么算法能得到这样一个结果呢？

一个思路，是找出用模式匹配来覆盖 AST 的所有可能的模式，并找出其中 Cost 最低的。你可以采用暴力枚举的方法，在每一个节点，去匹配所有可能的模式，从而找出多组解。但显然，这种算法的计算量太大，所需的时间会根据 AST 的大小呈指数级上升，导致编译速度无法接受。

所以我们需要找到一个代价更低的算法，这就是 BURS 算法，也就是“自底向上重写系统，Bottom-Up Rewriting System”。在HotSpot 的 C2 编译器中，就采用了 BURS 算法。这个算法采用了动态规划（Dynamic Programming）的数学方法来获取最优解，同时保持了较低的算法复杂度。

那么，要想理解 BURS 算法，你就必须要弄懂动态规划的原理。如果你之前没有学过这个数学方法，请不要紧张，因为动态规划的原理其实是相当简单的。

我在网上发现了一篇能够简洁地说清楚动态规划的文章。它举了一个例子，用最少张的纸币，来凑出某个金额。

比如说，假设你要凑出 15 元，怎么做呢？你还是可以继续采用贪婪算法。首先，拿出一张 10 元的纸币，也就是小于 15 的最大金额，然后再拿出 5 元来。这样你用两张纸币就凑出了 15 这个数值。这个时候，贪婪策略仍然是有效的。

但是，如果某个奇葩的国家发行的货币，不是按照中国货币的面额，而是发行 1、5、11 元三种面额的纸币。那么如果你仍然使用贪婪策略，一开始拿出一张 11 元的纸币，你就还需要再拿出 4 张 1 元的，这样就一共需要 5 张纸币。

但这显然不是最优解。最优解是只需要三张 5 元的纸币就可以了，这就像我们用贪婪算法去做指令生成，得到的可能不是最优解，是同样的道理。

那如何采用动态规划的方法来获取最优解呢？它的思路是这样的，假设我们用 f(n) 来代表凑出 n 元钱最少的纸币数，那么：

当一开始取 11 元的话，Cost = f(4) + 1；

当一开始取 5 元的话，Cost = f(10) + 1；

当一开始取 1 元的话，Cost = f(14) + 1。

所以，我们只需要知道 f(4)、f(10) 和 f(14) 哪个值最小就行了。也就是说，f(15)=min(f(4), f(10), f(14)) + 1。 而 f(4)、f(10) 和 f(14) 三个值，也可以用同样的方法递归地求出来，最后得到的值分别是 4、2、4。所以 f(15)=3，这就是最优解。

这个算法最棒的一点，是整个计算中会遇到的 f(14)、f(13)、f(12)、f(11) … f(3)、f(2) 这些值，一旦计算过一遍，就可以缓存下来，不必重复计算，从而让算法的复杂性降低。

所以，动态规划的特点，是通过子问题的最优解，得到总的问题的最优解。这种方法，也可以用于生成最优的指令组合。比如，对于示例程序来说，假设 f(=) 是以赋值运算符为根节点的 AST 所生成的指令的总的最低 Cost，那么：

当采用 #3 的时候，Cost = 4 + f(+) + f(b)；

当采用 #4 的时候，Cost = 5 + f(a) + f(*) + f(b)。

所以你能看出，通过动态规划方法，也能像凑纸币一样，求出树覆盖的最优解。

BURS 算法在具体执行的时候，需要进行三遍的扫描。

第一遍扫描是自底向上做遍历，也就是后序遍历，识别出每个节点可以进行的转换。我在图 6 中给你标了出来。以 a 节点为例，我们可以对它做两个操作，第一个操作是保持一个 mem 节点不动，第二个操作是按照模式 #1 把它转换成一个 reg 节点。

图 6：识别 AST 的每个节点可以进行的转换

第二遍扫描是自顶向下的，运用动态规划的方法找出最优解。

第三遍扫描又是自底向上的，用于生成指令。

好了，那么到目前为止，你就已经了解了指令生成的算法思路了。这里我再补充几点说明：

示例中的指令和 Cost 值，是为了便于你理解算法而设计的。在这个示例中，最优解和最差解的 Cost 只差了 2，也就是大约 12% 的性能提升。而在实际应用中，优化力度往往会远远大于这个值。

在第 6 讲探究 IR 的数据结构时，我提到过有向无环图（DAG），它比起刚才例子中用到的树结构，能够消除一些冗余的子树，从而减少生成的代码量。LLVM 里在做指令选择的时候，就是采用了 DAG，但算法思路是一样的。

示例中到的两个算法，贪婪算法和 BURS 算法，它们花费的时间都与节点数呈线性关系，所以性能都是很高的。其中 BURS 算法的线性系数更大一点，做指令选择所需的时间也更长一点。

OK，那么接下来，我们来探究第二个算法，寄存器分配算法。

寄存器分配算法

在解析 Graal 编译器和 Go 的编译器的时候，我都提到过它们的寄存器分配算法是线性扫描算法。我也提到过，线性扫描算法的性能比较高。

那么，线性扫描算法的原理是什么呢？总的来说，线性扫描算法理解起来其实相当简单。我用一个例子来带你了解下。

假设我们的程序里有从 a 到 g 共 7 个变量。通过数据流分析中的变量活跃性分析，你其实可以知道每个变量的生存期。现在，我们已知有 4 个物理寄存器可用，那么我们来看一下要怎么分配这几个物理寄存器。

在第 1 个时间段，a、b、c 和 d 是活跃的，那我们刚好把 4 个物理寄存器分配给这四个变量就行了。

在第 2 个时间段，a 的生存期结束，而一个新的变量 e 变得活跃，那么我们就把 a 原来占用的寄存器刚好给到 e 就可以了。

在第 3 个时间段，我们把 c 占用的寄存器给到 f，目前仍然是使用 4 个寄存器。

在第 4 个时间段，b 的生存期结束。这时候只需要用到 3 个寄存器。

在最后一个时间段，只有变量 d 和 g 是活跃的，占用两个寄存器。

可以看到，在上面这个例子中，所有的变量都可以分配到物理寄存器。而且你也会发现，这个例子中存在多个变量因为生存期是错开的，因此也可以共享同一个寄存器。

但是，如果没有足够的物理寄存器的话，我们要怎么办呢？那就需要把某个变量溢出到内存里了。也就是说，当用到这个变量的时候，才把这个变量加载到寄存器，或者有一些指令可以直接用内存地址作为操作数。

给你举另一个例子，我们来看看物理寄存器不足的情况会是什么样子。在这个例子中，我们有三个物理寄存器。

在第 1 个时间段，物理寄存器是够用的。

在第 2 个时间段，变量 d 变得活跃，现在有 4 个活跃变量，所以必须选择一个溢出到内存。我们选择了 a。

在第 3 个时间段，e 和 f 变得活跃，现在又需要溢出一个变量才可以。这次选择了 c。

在第 4 个时间段，g 也变得活跃，这次把 d 溢出了。

以上就是线性扫描算法的思路：线性扫描整个代码，并给活跃变量分配寄存器。如果物理寄存器不足，那么就选择一个变量，溢出到内存中。你看，是不是很简单？

在掌握了线性扫描算法的思路以后，我再给你补充一点信息：

第一，线性扫描算法并不能获得寄存器分配的最优解。所谓最优解，是要让尽量多的操作在寄存器上实现，尽量少地访问内存。因为线性扫描算法并没有去确定一个最优值的目标，所以也就谈不上最优解。

第二，线性扫描算法可以采用一些策略，让一些使用频率低的变量被溢出，而像高频使用的循环中的变量，就保留在寄存器里。

第三，还有一些其他提升策略。比如，当存在多余的物理寄存器以后，还可以把之前已经溢出的变量重新复活到寄存器里。

好了，上述就是线性扫描的寄存器分配算法。另外我们再来复习一下，在第 8 讲中，我还提到了另一个算法，是图染色算法，这个算法的优化效果更好，但是计算量比较大，会影响编译速度。

接下来，让我们再回到计算机语言设计的主线上，一起讨论一下编译器的后端与语言设计的关系。

编译器后端与语言的设计

编译器后端的目的，是要能够针对不同架构的硬件来生成目标代码，并尽量发挥硬件的能力。那么为了更好地支持语言的设计，在编译器后端的设计上，我们需要考虑到三个方面的因素。

平衡编译速度和优化效果

通常，我们都希望编译后的代码越优化越好。但是，在有些场景下，编译速度也很重要。比如像 JVM 这样需要即时编译的运行时环境，编译速度就比较重要。这可能就是 Graal 的指令选择算法和编译器分配算法都比较简单的原因吧。

Go 语言一开始也把编译速度作为一个重要的设计考虑，所以它的后端算法也比较简单。我估计是因为 Go 语言的发起者（Robert Griesemer、Rob Pike 和 Ken Tompson）都具有 C 和 C++ 的背景，甚至 Ken Tompson 还是 C 语言的联合发明人，他们都深受编译速度慢之苦。类似浏览器、操作系统这样比较大的软件，即使是用很多台机器做编译，还是需要编译很久。这可能也是他们为什么想让 Go 的编译速度很快的原因。

而 Julia 的设计目标是用于科学计算的，所以其使用场景主要就是计算密集型的。Julia 采用了 LLVM 做后端，做了比较高强度的优化，即使会因此导致运行时由于 JIT 而引起短暂停顿。

确定所支持的硬件平台

确定了一门语言主要运行在什么平台上，那么首先就要支持该平台上的机器码。由于 Go 语言主要是用于写服务端程序的，而服务端采用的架构是有限的，所以 Go 语言支持的架构也是有限的。

硬件平台也影响算法的选择，比如现在很多 CPU 都支持指令的乱序执行，那你在实现编译器的时候就可以省略指令重排序（指令调度）功能。

设计后端 DSL

虽然编译器后端要支持多种硬件，但我们其实会希望算法是通用的。所以，各个编译器通常会提供一种 DSL，去描述硬件的特征，从而自动生成针对这种硬件的代码。

在 Graal 中，我们看到了与指令选择有关的注解，在 Go 的编译器中，我们也看到了对 IR 进行转换的 DSL，而 LLVM 则提供了类似的机制。

课程小结

今天这一讲，我把后端的两个重要的算法拿出来给你单独介绍了一下，并一起讨论了后端技术策略与计算机语言的关系。你需要记住这几个知识点：

关于指令选择：从 IR 生成机器码（或 LIR），通常是 AST 或 DAG 中的多个节点对应一条指令，所以你要找到一个最佳的组合，把整个 AST 或 DAG 覆盖住，并且要找到一个较优的或最优的解。其中，你还要熟悉贪婪算法和动态规划这两种不同的算法策略，这两种算法不仅仅会用于指令选择，还会用于多种场景。理解了这两种算法之后，就会给你的工具库添加两个重要的工具。

关于寄存器分配：线性扫描算法比较简单。不过在一些技术点上我们去深入挖掘一下，其实会发现还挺有意思的。比如，当采用 SSA 格式的 IR 的时候，寄存器分配算法会有什么不同，等等。你可以参考看看文末我给出的资料。

关于编译器后端的设计：我们要考虑编译速度和优化程度的平衡，要考虑都能支持哪些硬件。因为要支持多种硬件，通常要涉及后端的 DSL，以便让算法尽量中立于具体的硬件架构。

我把本讲的知识点也整理成了思维导图，供你复习和参考：

一课一思

动态规划算法是这节课的一个重要知识点。在学过了这个知识点以后，你能否发现它还可以被用于解决哪些问题？欢迎分享你的经验和看法。

参考资料

对动态规划方法的理解，我建议你读一下这篇文章，通俗易懂。

在《编译原理之美》的第 29 讲，有对寄存器分配算法中的图染色算法的介绍，你可以去参考一下。

这两篇关于线性扫描算法的经典论文，你可以去看一下：论文 1，论文 2。

这篇文章介绍了针对SSA 格式的 IR 的线性扫描算法，值得一看。

31 | 运行时（一）：从0到语言级的虚拟化

你好，我是宫文学。今天，我会带你去考察现代语言设计中的运行时特性，并讨论一下与标准库有关的话题。

你可能要问了，咱们这门课是要讲编译原理啊，为什么要学运行时呢。其实，对于一门语言来说，除了要提供编译器外，还必须提供运行时功能和标准库：一是，编译器生成的目标代码，需要运行时的帮助才能顺利运行；二是，我们写代码的时候，有一些标准的功能，像是读写文件的功能，自己实现起来太麻烦，或者根本不可能用这门语言本身来实现，这时就需要标准库的支持。

其实，我们也经常会接触到运行时和库，但可能只是停留在使用层面上，并不太会关注它们的原理等。如果真要细究起来、真要对编译原理有更透彻的理解的话，你可能就会有下面这些问题了：

到底什么是运行时？任何语言都有运行时吗？运行时和编译器是什么关系？

什么是标准库？标准库和运行时库又是什么关系？库一般都包含什么功能？

今天，我们就来探讨一下这些与运行时和标准库有关的话题。这样，你能更加充分地理解设计一门语言要完成哪些工作，以及这些工作跟编译技术又有什么关系，也就能对编译原理有更深一层的理解。

首先，我们来了解一下运行时，以及它和编译技术的关系。

什么是运行时（Runtime）？

我们在第 5 讲说过，每种语言都有一个特定的执行模型（Execution Model）。而这个执行模型就需要运行时系统（Runtime System）的支持。我们把这种可以支撑程序运行的运行时系统，简称为运行时。

那运行时都包含什么功能呢？通常，我们最关心的是三方面的功能：程序运行机制、内存管理机制和并发机制。接下来，我就分别以 Java、Python 以及 C、C++、Go 语言的运行时机制为例，做一下运行时的分析，因为它们的使用者比较多，并且体现了一些有代表性的运行时特征。

Java 的运行时

我们先看看 Java 语言的运行时系统，也就是 JVM。

其实，JVM 不仅为 Java 提供了运行时环境，还为其他所有基于 JVM 的语言提供了支撑，包括 Scala、Clojure、Groovy 等。我们可以通过JVM 的规范来学习一下它的主要特点。

第一，JVM 规定了一套程序的运行机制。JVM 支持基于字节码的解释执行机制，还包括了即时编译成机器码并执行的机制。

针对基于字节码的解释执行机制，JVM 规范定义下面这些内容：

定义了一套字节码来运行程序。这些字节码能支持一些基本的运算。超出这些基本运算逻辑的，就要自己去实现。比如，idiv 指令用于做整数的除法，当除数为零的时候，虚拟缺省的操作是抛出异常。如果你自己的语言是专注于数学计算的，想让整数除以零的结果为无穷大，那么你需要自己去实现这个逻辑。

规定了一套类型系统，包括基础数据类型、数组、引用类型等。所以说，任何运行在 JVM 上的语言，不管它设计的类型系统是什么样子，编译以后都会变成字节码规定的基础类型。

定义了 class 文件的结构。class 文件规定了把某个类的符号表放在哪里、把字节码放在哪里，所以写编译器的时候要遵守这个规范才能生成正确的 class 文件。JVM 在运行时会加载 class 文件并执行。

提供了一个基于栈的解释器，来解释执行字节码。编译器要根据这个执行模型来生成正确的字节码。

除了解释执行字节码的机制，JVM 还支持即时编译成机器码并执行的机制。它可以调度多个编译器，生成不同优化级别的机器码，这就是分层编译机制。在需要的时候，还可以做逆优化，在不同版本的机器码以及解释执行模式之间做切换。

最后，Java 程序之间的互相调用，需要遵循一定的调用约定或二进制标准，包括如何传参数等等。这也是运行机制的一部分。

总体来说，JVM 代表了一种比较复杂的运行机制，既可以解释执行，又可以编译成机器码执行。V8 的运行时机制也跟 JVM 也很类似。

第二，JVM 对内存做了统一的管理。它把内存划分为程序计数器、虚拟机栈、堆、方法区、运行时常量池和本地方法栈等不同的区域。

对于栈来说，它的栈桢既可以服务于解释执行，又可以用于执行机器码，并且还可以在两种模式之间转换。在解释执行的时候，栈桢里会有一个操作数栈，服务于解释器。我们提到过 OSR，也就是在运行一个方法的时候，把这个方法做即时编译，并且把它的栈桢从解释执行的状态切换成运行机器码的状态。而如果遇到逆优化的场景，栈桢又会从运行机器码的状态，切换成解释执行的状态。

对于堆来说，Java 提供了垃圾收集器帮助进行内存的自动管理。减少整体的停顿时间，是垃圾收集器设计的重要目标。

第三，JVM 封装了操作系统的线程模型，为应用程序提供了并发处理的机制。我会在讲并发机制的时候再展开。

以上就是 JVM 为运行在其上的任何程序提供的支撑了。在提供这些支撑的同时，运行时系统也给程序运行带来了一些限制。

第一，JVM 实际上提供了一个基础的对象模型，JVM 上的各种语言必须遵守。所以，虽然 Clojure 是一个函数式编程语言，但它在底层却不得不使用 JVM 规定的对象模型。

第二，基于 JVM 的语言程序要去调用 C 语言等生成的机器码的库，会比较难。不过，对于同样基于 JVM 的语言，则很容易实现相互之间的调用，因为它们底层都是类和字节码。

第三，在内存管理上，程序不能直接访问内存地址，也不能手动释放内存。

第四，在并发方面，JVM 只提供了线程机制。如果你要使用其他并发模型，比如我们会在 34 讲中讲到的协程模型和 35 讲中的 Actor 模型，需要语言的实现者绕着弯去做，增加一些自己的运行时机制（我会在第 34 讲来具体介绍）。

好了，以上就是我要通过 JVM 的例子带你学习的 Java 的运行时，以及其编译器的影响了。我们再来看看 Python 的运行时。

Python 的运行时

在解析 Python 语言的时候，已经讲了 Python 的字节码和解释器，以及 Python 对象模型和程序调用的机制。这里，我再从程序运行机制、内存管理机制、并发机制这三个方面，给你梳理下。

第一，Python 也提供了一套字节码，以及运行该字节码的解释器。这套字节码，也是跟 Python 的类型体系互相配合的。字节码中操作的那些标识符，都是 Python 的对象引用。

第二，在内存管理方面，Python 也提供了自己的机制，包括对栈和堆的管理。

首先，我们看看栈。Python 运行程序的时候，有些时候是运行机器码，比如内置函数，而有些时候是解释执行字节码。

运行机器码的时候，栈帧跟 C 语言程序的栈帧是没啥区别的。而在解释执行字节码的时候，栈帧里会包含一个操作数栈，这点跟 JVM 的栈机是一样的。如果你再进一步，去看看操作数栈的实现，会发现解释器本身主要就是一个 C 语言实现的函数，而操作数栈就是这个函数里用到的本地变量。因此操作数栈也会像其他本地变量一样，被优化成尽量使用物理寄存器，从而提高运行效率。这个知识点你要掌握，也就是说，栈桢中的操作数栈，其实是有可能基于物理寄存器的。

然后，Python 还提供了对堆的管理机制。程序从堆里申请内存的时候，不是直接从操作系统申请，而是通过 Python 提供的一个 Arena 机制，使得内存的申请和释放更加高效、灵活。Python 还提供了基于引用的垃圾收集机制（我会在下一讲为你总结垃圾收集机制）。

第三，是并发机制。Python 把操作系统的线程进行了封装，让 Python 程序能支持基于线程的并发。同时，它也实现了协程机制（我会在 34 讲详细展开）。

好了，我们再继续看看第三类语言，也就是 C、C++、Go 这样的直接编译成二进制文件执行的语言的运行时。

C、C++、Go 的运行时

一个有意思的问题是，C 语言有没有运行时呢？我们对 C 语言的印象，是一旦编译完成以后，就是一段完全可以自主运行的二进制代码了，你也可以看到输出的完整的汇编代码。除此之外没有其他，C 语言似乎不需要运行时的支持。

所以，C 语言最主要的运行时，实际上就是操作系统。C 语言和现代的各种操作系统可以说是伴生关系，就像 Java 和 JVM 是伴生关系一样。所以，如果我们要深入使用 C 语言，某种意义上就是要深入了解操作系统的运行机制。

在程序执行机制方面，C 语言编译完毕的程序是完全按照操作系统的运行机制来执行的。

在内存管理方面，C 语言使用了操作系统提供的线程栈，操作系统能够自动帮助程序管理内存。程序也可以从堆里申请内存，但必须自己负责释放，没有自动内存管理机制。

在并发机制方面，当然也是直接用操作系统提供的线程机制。因为操作系统没有提供协程和 Actor 机制，所以 C 语言也没有提供这种并发机制。

不过有一个程序 crt0.o，有时被称作是 C 语言的运行时。它是一段汇编代码（crt0.s），由链接器自动插入到程序里面，主要功能是在调用 main 函数之前做一些初始化工作，比如设置 main 函数的参数（argc 和 argv）、环境变量的地址、调用 main 函数、设置一些中断向量用于处理程序异常等。所以，这个所谓的运行时所做的工作也特别简单。

不同系统的 crt0.s 会不太一样，因为 CPU 架构和 ABI 是不同的。下面是一个 crt0.s 的示例代码：

.text
.globl _start
_start: # _start是链接器需要用到的入口
    xor %ebp, %ebp            # 让ebp置为0，标记栈帧的底部
    mov (%rsp), %edi          # 从栈里获得argc的值
    lea 8(%rsp), %rsi         # 从栈里获得argv的地址
    lea 16(%rsp,%rdi,8), %rdx # 从栈里获得envp的地址
    xor %eax, %eax            # 按照ABI的要求把eax置为0，并与icc兼容
    call main                 # 调用main函数，%edi, %rsi, %rdx是传给main函数的三个参数

    mov %eax, %edi    # 把main函数的返回值提供给_exit作为第一个参数
    xor %eax, %eax    # 按照ABI的要求把eax置为0，并与icc兼容
    call _exit        # 终止程序

可以说，C 语言的运行时是一个极端，提供了最少的功能。反过来呢，这也就是给了程序员最大的自由度。C++ 语言的跟 C 是类似的，我就不再展开了。总的来说，它们都没有 Java 和 Python 那种意义上的运行时。

不过，Go 语言虽然也是编译成二进制的可执行文件，但它的运行时要复杂得多。比如，它有垃圾收集器；再比如，Go 语言最显著的特点是提供了自己的并发机制，也就是 goroutine。对 goroutine 的运行管理，也是 go 的运行时的一部分。

无独有偶，在 Android 平台上，你可以把 Java 程序以 AOT 的方式编译成可执行文件。但这个可执行文件其实仍然包含了一个运行时，比如垃圾收集功能，所以与 C 语言编译形成的可执行文件，也是不一样的。

总结起来，运行时系统提供了程序的运行机制、内存管理机制、并行机制等功能。运行时和编译器的关系就是，编译器要跟这些运行时做配合，生成符合运行时要求的目标代码。

接下来，我们再看看语言的另一个重要组成部分，也就是标准库，并看看它跟编译器的关系。

库和标准库

我们知道，任何一门编程语言，要想很好地投入实际应用，必须有良好的库来支撑。这些库的作用就是封装了常用的、标准的功能，让开发者可以直接使用。

根据库的使用场景和与编译器的关系，这些库可以分为标准库、运行时库和内置函数三类。

第一，标准库，供用户的程序调用。我们在写一段 C 语言程序的时候，总要在源代码一开头的部分 include 几个库进来，比如 stdio.h、stdlib.h 等等。C++ 的 STL 库和标准库让程序员拥有比 C 语言里面更多的工具，比如各种标准的容器类。Java 刚面世的时候，就在 JDK 里打包了很多标准库。正是因为这些丰富又好用的库，使得 Java 能够被迅速接受。当然了，这些库也成了 JDK 标准的组成部分。而 Python 语言声称是“自带电池”的，也就是说有很多库的支持，可以迅速上手做很多事情。

第二类，运行时库，它们不是由用户直接调用的，而是运行时的组成部分。比如，Python 实现整数运算的功能很强大，支持任意长度整数的加减乘除。这些功能是由一些库函数实现的，并由 Python 的解释器来调用，实现 Python 程序中的加减乘除操作。

第三类，是一些叫做 Built-in 或者 Intrincics 的内置函数，它们是用来辅助生成机器码的。它们往往由汇编代码实现，也有的是用编译器的 LIR 实现的，在编译的时候直接内联进去。这些函数有时开发者也可以调用，比如在 C 语言中，可以像调用普通函数一样，调用 CPU 厂家提供的与 SIMD 指令有关的 Intrincics。但这些函数会直接生成汇编码，不像 C 语言编写的程序那样需要经过优化和代码生成的过程。

好了，我们了解了库的三种分类，也就是标准库、运行时库和内置函数。不过我要提醒你的是，这些分类有时候是模糊的，比如有的语言（比如微软的 C 和 C++ 语言）谈到运行时库的时候，实际上就包括了标准库。

接下来，我们主要看看与标准库相关的几个问题。

标准库的特殊性

与普通程序相比，标准库主要有以下三个方面的不同。

第一，有的库可以用本语言来实现，而有的库必须要用其他语言来实现，因为用本语言实现有困难。这就要求库的编写者要具备更高的技能，能够掌握更加底层的语言。

比如，Java 有少量库（比如网络通讯模块）就需要用 C 语言来编写，而 Python、PHP、Node.js 等语言的大量库都是用 C 语言编写的。甚至，标准库中的某些底层功能会采用汇编语言来写。

第二，标准库的接口不可以经常变化，甚至是要保持一直不变。因此，标准库的设计一定要慎重，这就要求设计者有更高的规划和设计能力。因为几乎每个程序都会用到标准库的功能，库的接口如果变化的话，就会影响到所有已经写好的程序。

第三，标准库往往集中体现了一门语言的核心特点。同样的功能，面向对象编程语言、函数式编程语言、基于 Actor 的语言，会采用各自的方式来实现。库的编写者要写出教科书级的代码，充分发挥这门语言的优势。这样的话，编程人员使用这些标准库的过程，实际上就是潜移默化地学习这门语言的编程思想的过程。

好了，看来编写一个好的标准库确实是有挑战的事情。但是标准库一般需要包含哪些内容呢？

标准库需要包含什么功能？

第一，包含 IO 功能，包括文件 IO、网络 IO 等。

还记得吧，我们学习每一门新语言的时候，都会在终端上打印出一个“Hello World!”，这似乎已经成了一种具有仪式感的行为。可是你注意到没有，你在打印输出到终端的时候，通常就是调用了一个标准的 IO 库。因为终端本身就相当于一个文件，这实际上是用了文件 IO 功能。

除了文件 IO，网络 IO 也必不可少，这样的话手机上的 App 程序才能够跟服务端的程序通讯。

第二，支持内置的数据类型。

首先是针对整型、浮点型等基础数据类型做运算的功能。比如有的数学库的数学计算功能支持任意长度的整数的运算，并支持准确的小数运算（计算机内置的浮点数计算功能是不精确的）。此外数据类型转换、对字符串操作等，也是必不可少的。

像 Java、Python 这样的语言，提供了一些标准的内置类型，比如 String 等。像 Scala 这种纯面向对象语言，连整型、浮点型等基础数据类型，也是通过标准库来提供的。

第三，支持各种容器型的数据结构。

有的语言（比如 Go），会在语法层面提供 map 等容器型的数据结构，并通过运行时库做支持；还有些语言（比如 Java、C++），是在标准库里提供这些数据结构。

此外，标准库还要包含一些其他功能，比如对日期、图形界面等各种不同的功能支持。

课程小结

今天，我们一起学习了一门语言除编译器之外的一些重要组成部分，包括运行时和各种库。编译器拥有运行时和库的知识，并根据这些知识作出正确的编译。当你设计一门语言的时候，应该首先要把它的运行机制设计清楚，然后才能设计出正确的语法、语义，并实现出相应的编译器。

所以，我们这一讲的目标，就是帮你从一个更高的维度来理解编译技术的使用环境，从而更加全面地理解和使用编译技术。

我把今天的知识点也整理成了思维导图，供你参考：

一课一思

挑你熟悉的一门语言，分享一下它的运行时和标准库的设计特征，以及对编译器的影响。

欢迎你在留言区表达自己的见解，也非常欢迎你把今天的内容分享给更多的朋友。感谢阅读，我们下一讲再见。

32 | 运行时（二）：垃圾收集与语言的特性有关吗？

你好，我是宫文学。今天，我们继续一起学习垃圾收集的实现机制以及与编译器的关系。

对于一门语言来说，垃圾收集机制能够自动管理从堆中申请的内存，从而大大降低程序员的负担。在这门课的第二大模块“真实编译器解析篇”中，我们学习 Java、Python、Go、Julia 和 JavaScript 这几门语言，都有垃圾收集机制。那在今天这一讲，我们就来学习一下，这些语言的垃圾收集机制到底有什么不同，跟语言特性的设计又是什么关系，以及编译器又是如何配合垃圾收集机制的。

这样如果我们以后要设计一门语言的话，也能清楚如何选择合适的垃圾收集机制，以及如何让编译器来配合选定的垃圾收集机制。

在讨论不同语言的垃圾收集机制之前，我们还是需要先了解一下，通常我们都会用到哪些垃圾收集算法，以及它们都有什么特点。这样，我们才能深入探讨应该在什么时候采用什么算法。如果你对各种垃圾收集算法已经很熟悉了，也可以从这一讲的“Python 与引用计数算法”开始学习；如果你还想理解垃圾收集算法的更多细节，也可以去看看我的第一季课程《编译原理之美》的第 33 讲的内容。

垃圾收集算法概述

垃圾收集主要有标记 - 清除（Mark and Sweep）、标记 - 整理（Mark and Compact）、停止 - 拷贝（Stop and Copy）、引用计数、分代收集、增量收集和并发收集等不同的算法，在这里我简要地和你介绍一下。

首先，我们先学习一下什么是内存垃圾。内存垃圾，其实就是一些保存在堆里的、已经无法从程序里访问的对象。

我们看一个具体的例子。

在堆中申请一块内存时（比如 Java 中的对象实例），我们会用一个变量指向这块内存。但是，如果给变量赋予一个新的地址，或者当栈桢弹出时，该栈桢的变量全部失效，这时，变量所指向的内存就没用了（如图中的灰色块）。

图 1：A 是内存垃圾

另外，如果 A 对象有一个成员变量指向 C 对象，那么 A 不可达，C 也会不可达，也就失效了。但 D 对象除了被 A 引用，还被 B 引用，仍然是可达的。

图 2：A 和 C 是内存垃圾

那么，所有不可达的内存就是垃圾。所以，垃圾收集的重点就是找到并清除这些垃圾。接下来，我们就看看不同的算法是怎么完成这个任务的。

标记 - 清除

标记 - 清除算法，是从 GC 根节点出发，顺着对象的引用关系，依次标记可达的对象。这里说的 GC 根节点，包括全局变量、常量、栈里的本地变量、寄存器里的本地变量等。从它们出发，就可以找到所有有用的对象。那么剩下的对象，就是内存垃圾，可以清除掉。

标记 - 整理

采用标记 - 清除算法，运行时间长了以后，会形成内存碎片。这样在申请内存的时候，可能会失败。

图 3：内存碎片导致内存申请失败

为了避免内存碎片，你可以采用变化后的算法，也就是标记 - 整理算法：在做完标记以后，做一下内存的整理，让存活的对象都移动到一边，消除掉内存碎片。

图 4：内存整理以后，可以更有效地利用内存

停止 - 拷贝

停止和拷贝算法把内存分为新旧空间两部分。你需要保持一个堆指针，指向自由空间开始的位置。申请内存时，把堆指针往右移动就行了。

图 5：在旧空间中申请内存

当旧空间内存不够了以后，就会触发垃圾收集。在收集时，会把可达的对象拷贝到新空间，然后把新旧空间互换。

图 6：新旧空间互换

停止 - 拷贝算法，在分配内存的时候，不需要去查找一块合适的空闲内存；在垃圾收集完毕以后，也不需要做内存整理，因此速度是最快的。但它也有缺点，就是总有一半内存是闲置的。

引用计数

引用计数方法，是在对象里保存该对象被引用的数量。一旦这个引用数为零，那么就可以作为垃圾被收集走。

有时候，我们会把引用计数叫做自动引用计数（ARC），并把它作为跟垃圾收集（GC）相对立的一个概念。所以，如果你读到相关的文章，它把 ARC 和 GC 做了对比，也不要吃惊。

引用计数实现起来简单，并且可以边运行边做垃圾收集，不需要为了垃圾收集而专门停下程序。可是，它也有缺陷，就是不能处理循环引用（Reference Cycles）的情况。在下图中，四个对象循环引用，但没有 GC 根指向它们。它们已经是垃圾，但计数却都为 1。

图 7：循环引用

另外，由于在程序引用一个对象的前后，都要修改引用计数，并且还有多线程竞争的可能性，所以引用计数法的性能开销比较大。

分代收集

在程序中，新创建的对象往往会很快死去，比如，你在一个方法中，使用临时变量指向一些新创建的对象，这些对象大多数在退出方法时，就没用了。这些数据叫做新生代。而如果一个对象被扫描多次，发现它还没有成为垃圾，那就会标记它为比较老的时代。这些对象可能 Java 里的静态数据成员，或者调用栈里比较靠近根部的方法所引用的，不会很快成为垃圾。

对于新生代对象，可以更频繁地回收。而对于老一代的对象，则回收频率可以低一些。并且，对于不同世代的对象，还可以用不同的回收方法。比如，新生代比较适合复制式收集算法，因为大部分对象会被收集掉，剩下来的不多；而老一代的对象生存周期比较长，拷贝的话代价太大，比较适合标记 - 清除算法，或者标记 - 整理算法。

增量收集和并发收集

垃圾收集算法在运行时，通常会把程序停下。因为在垃圾收集的过程中，如果程序继续运行，可能会出错。这种停下整个程序的现象，被形象地称作“停下整个世界（STW）”。

可是让程序停下来，会导致系统卡顿，用户的体验感会很不好。一些对实时性要求比较高的系统，根本不可能忍受这种停顿。

所以，在自动内存管理领域的一个研究的重点，就是如何缩短这种停顿时间。增量收集和并发收集算法，就是在这方面的有益探索：

增量收集可以每次只完成部分收集工作，没必要一次把活干完，从而减少停顿。

并发收集就是在不影响程序执行的情况下，并发地执行垃圾收集工作。

好了，理解了垃圾收集算法的核心原理以后，我们就可以继续去探索各门语言是怎么运用这些算法的了。

首先，我们从 Python 的垃圾收集算法学起。

Python 与引用计数算法

Python 语言选择的是引用计数的算法。除此之外，Swift 语言和方舟编译器，采用的也是引用计数，所以值得我们重视。

Python 的内存管理和垃圾收集机制

首先我们来复习一下 Python 内存管理的特征。在 Python 里，每个数据都是对象，而这些对象又都是在堆上申请的。对比一下，在 C 和 Java 这样的语言里，很多计算可以用本地变量实现，而本地变量是在栈上申请的。这样，你用到一个整数的时候，只占用 4 个字节，而不像 Python 那样有一个对象头的固定开销。栈的优势还包括：不会产生内存碎片，数据的局部性又好，申请和释放的速度又超快。而在堆里申请太多的小对象，则会走向完全的反面：太多次系统调用，性能开销大；产生内存碎片；数据的局部性也比较差。

所以说，Python 的内存管理方案，就决定了它的内存占用大、性能低。这是 Python 内存管理的短板。而为了稍微改善一下这个短板，Python 采用了一套基于区域（Region-based）的内存管理方法，能够让小块的内存管理更高效。简单地说，就是 Python 每次都申请一大块内存，这一大块内存叫做 Arena。当需要较小的内存的时候，直接从 Arena 里划拨就好了，不用一次次地去操作系统申请。当用垃圾回收算法回收内存时，也不一定马上归还给操作系统，而是归还到 Arena 里，然后被循环使用。这个策略能在一定程度上提高内存申请的效率，并且减少内存碎片化。

接下来，我们就看看 Python 是如何做垃圾回收的。回忆一下，在第 19 讲分析 Python 的运行时机制时，其中提到了一些垃圾回收的线索。Python 里每个对象都是一个 PyObject，每个 PyObject 都有一个 ob_refcnt 字段用于记录被引用的数量。

在解释器执行字节码的时候，会根据不同的指令自动增加或者减少 ob_refcnt 的值。当一个 PyObject 对象的 ob_refcnt 的值为 0 的时候，意味着没有任何一个变量引用它，可以立即释放掉，回收对象所占用的内存。

现在你已经知道，采用引用计数方法，需要解决循环引用的问题。那 Python 是如何实现的呢？

Python 在 gc 模块里提供了一个循环检测算法。接下来我们通过一个示例，来看看这个算法的原理。在这个例子中，有一个变量指向对象 A。你能用肉眼看出，对象 A、B、C 不是垃圾，而 D 和 E 是垃圾。

图 8：把容器对象加入待扫描列表

在循环检测算法里，gc 使用了两个列表。一个列表保存所有待扫描的对象，另一个列表保存可能的垃圾对象。注意，这个算法只检测容器对象，比如列表、用户自定义的类的实例等。而像整数对象这样的，就不用检测了，因为它们不可能持有对其他对象的引用，也就不会造成循环引用。

在这个算法里，我们首先让一个 gc_ref 变量等于对象的引用数。接着，算法假装去掉对象之间的引用。比如，去掉从 A 到 B 的引用，这使得 B 对象的 gc_ref 值变为了 0。在遍历完整个列表以后，除了 A 对象以外，其他对象的 gc_ref 都变成了 0。

图 9：扫描列表，修改 gc_ref 的值

gc_ref 等于零的对象，有的可能是垃圾对象，比如 D 和 E；但也有些可能不是，比如 B 和 C。那要怎么区分呢？我们先把这些对象都挪到另一个列表中，怀疑它们可能是垃圾。

图 10：认为 gc_ref 为 0 的对象可能是垃圾

这个时候，待扫描对象区只剩下了对象 A。它的 gc_ref 是大于零的，也就是从 gc 根是可到达的，因此肯定不是垃圾对象。那么顺着这个对象所直接引用和间接引用到的对象，也都不是垃圾。而剩下的对象，都是从 gc 根不可到达的，也就是真正的内存垃圾。

图 11：去除其中可达的对象，剩下的是真正的垃圾

另外，基于循环检测的垃圾回收算法是定期执行的，这会跟 Java、Go 等语言的垃圾收集器一样，导致系统的停顿。所以，它也会像 Java、Go 等语言的垃圾收集器一样，采用分代收集的策略来减少垃圾收集的工作量，以及由于垃圾收集导致的停顿。

好了，以上就是 Python 的垃圾收集算法。我们前面提过，除了 Python 以外，Swift 和方舟编译器也使用了引用计数算法。另外，还有些分代的垃圾收集器，在处理老一代的对象时，也会采用引用计数的方法，这样就可以在引用计数为零的时候收回内存，而不需要一遍遍地扫描。

编译器如何配合引用计数算法？

对于 Python 来说，引用计数的增加和减少，是由运行时来负责的，编译器并不需要做额外的工作。它只需要生成字节码就行了。而对于 Python 的解释器来说，在把一个对象赋值给一个变量的时候，要把对象的引用数加 1；而当该变量超出作用域的时候，要把对象的引用数减 1。

不过，对于编译成机器码的语言来说，就要由编译器介入了。它要负责生成相应的指令，来做引用数的增减。

不过，这只是高度简化的描述。实际实现时，还要解决很多细致的问题。比如，在多线程的环境下，对引用数的改变，必须要用到锁，防止超过一个线程同时修改引用数。这种频繁地对锁的使用，会导致性能的降低。这时候，我们之前学过的一些优化算法就可以派上用场了。比如，编译器可以做一下逃逸分析，对于没有逃逸或者只是参数逃逸的对象，就可以不使用锁，因为这些对象不可能被多个线程访问。这样就可以提高程序的性能。

除了通过逃逸分析优化对锁的使用，编译器还可以进一步优化。比如，在一段程序中，一个对象指针被调用者通过参数传递给某个函数使用。在函数调用期间，由于调用者是引用着这个对象的，所以这个对象不会成为垃圾。而这个时候，就可以省略掉进入和退出函数时给对象引用数做增减的代码。

还有不少类似上面的情况，需要编译器配合垃圾收集机制，生成高效的、正确的代码。你在研究 Swift 和方舟编译器时，可以多关注一下它们对引用计数做了哪些优化。

接下来，我们再看看其他语言是怎么做垃圾收集的。

其他语言是怎么做垃圾收集的？

除了 Python 以外，我们在第二个模块研究的其他几门语言，包括 Java、Go、JavaScript（V8）和 Julia，都没有采用引用计数算法（除了在分代算法中针对老一代的对象），它们基本都采用了分代收集的策略。针对新生代，通常是采用标记 - 清除或者停止拷贝算法。

它们不采用引用计数的原因，其实我们可以先猜测一下，那就是因为引用计数的缺点。比如增减引用计数所导致的计算量比较多，在多线程的情况下要用到锁，就更是如此；再比如会导致内存碎片化、局部性差等。

而采用像停止 - 拷贝这样的算法，在总的计算开销上会比引用计数的方法低。Java 和 Go 语言主要是用于服务端程序开发的。尽量减少内存收集带来的性能损耗，当然是语言的设计者重点考虑的问题。

再进一步看，采用像停止 - 拷贝这样的算法，其实是用空间换时间，以更大的内存消耗换来性能的提升。如果你的程序需要 100M 内存，那么虚拟机需要给它准备 200M 内存，因为有一半空间是空着的。这其实也是为什么 Android 手机比 iPhone 更加消耗内存的原因之一。

在为 iPhone 开发程序的时候，无论是采用 Objective C 还是 Swift，都是采用引用计数的技术。并且，程序员还负责采用弱引用等技术，来避免循环引用，从而进一步消除了在运行时进行循环引用检测的开销。

通过上面的分析，我们能发现移动端应用和服务端应用有不同的特点，因此也会导致采用不同的垃圾收集算法。那么方舟编译器采用引用计数的方法，来编译原来的 Android 应用，是否也是借鉴了 iPhone 的经验呢？我没有去求证过，所以不得而知。但我们可以根据自己的知识去做一些合理的猜测。

好，回过头来，我们继续分析一下用 Java 和 Go 语言来写服务端程序对垃圾收集的需求。对于服务器端程序来说，垃圾收集导致的停顿，是一个令程序员们头痛的问题。有时候，一次垃圾收集会让整个程序停顿一段非常可观的时间（比如上百毫秒，甚至达到秒级），这对于实时性要求较高或并发量较大的系统来说，就会引起很大的问题。也因此，一些很关键的系统很长时间内无法采用 Java 和 Go 语言编写。

所以，Java 和 Go 语言一直在致力于减少由于垃圾收集而产生的停顿。最新的垃圾收集器，已经使得垃圾收集导致的停顿降低到了几毫秒内。

在这里，你需要理解的要点，是为什么在垃圾收集时，要停下整个程序？又有什么办法可以减少停顿的时间？

为什么在垃圾收集时，要停下整个程序？

其实，对于引用计数算法来说，是不需要停下整个程序的，每个对象的内存在计数为零的时候就可以收回。

而采用标记 - 清除算法时，你就必须要停下程序：首先做标记，然后做清除。在做标记的时候，你必须从所有的 GC 根出发，去找到所有存活的对象，剩下的才是垃圾。所以，看上去，这是一项完整的工作，程序要一直停顿到这项完整的工作做完。

让事情更棘手的是，你不仅要停下当前的线程，扫描栈里的所有 GC 根，你还要停下其他的线程，因为其他线程栈里的对象，也可能引用了相同的对象。最后的结果，就是你停下了整个世界。

当然也有例外，就是如果别的线程正在运行的代码，没有可能改变对象之间的引用关系，比如仅仅是在做一个耗费时间的数学计算，那么是不用停下来的。你可以参考 Julia 的gc 程序中的一段注释，来理解什么样的代码必须停下来。

更麻烦的是，不仅仅在扫描阶段你需要停下整个世界，如果垃圾收集算法需要做内存的整理或拷贝，那么这个时候仍然要停下程序。而且，程序必须停在一些叫做安全点（SafePoint）的地方。

在这些地方，修改对象的地址不会破坏程序数据的一致性。比如说，假设代码里有一段逻辑，是访问对象的某个成员变量，而这个成员变量的地址是根据对象的地址加上一个偏移量计算出来的。那么如果你修改了对象的地址，而这段代码仍然去访问原来的地址，那就出错了。而当代码停留在安全点上，就不会有这种不一致。

安全点是编译器插入到代码中一个片段。在查看Graal 生成的汇编代码时，我们曾经看到过这样的指令片段。

好了，到目前为止，你了解了为什么要停下整个世界，以及要停在哪里才合适。那么我们继续研究，如何能减少停顿时间。

如何能减少停顿时间？

第一招，分代收集可以减少垃圾收集的工作量，不用每次都去扫描所有的对象，因此也会减少停顿时间。像 Go、Java、Julia 和 V8 的垃圾收集器都是分代的。

第二招，可以尝试增量收集。你可能会问了，怎样才能实现增量呀？不是说必须扫描所有的 GC 根，才能确认一个对象是垃圾吗？

其实是有方法可以实现增量收集的，比如三色标记（Tri-color Marking）法。这种方法的原理，是用三种颜色来表示不同的内存对象的处理阶段：

白色，表示算法还没有访问的对象。

灰色，表示这个节点已经被访问过，但子节点还没有被访问过。

黑色，表示这个节点已经被访问过，子节点也已经被访问过了。

我们用一个例子来了解一下这个算法的原理。这个例子中有 8 个对象。你可以看出，其中三个对象是内存垃圾。在垃圾收集的时候，一开始所有对象都是白色的。

图 12：所有对象标记为白色

然后，扫描所有 GC 根所引用的对象，把这些对象加入到一个工作区，并标记为灰色。在例子中，我们把 A 和 F 放入了灰色区域。

图 13：正在处理，但没有处理完子节点的对象，标记为灰色

如果这个对象的所有子节点都被访问过之后，就把它标记为黑色。在例子中，A 和 F 已经被标记为黑色，而 B、C、D 被标记为灰色。

图 14：已经处理完子节点的对象，标记为黑色用

继续上面的过程，B、C、D 也被标记为黑色。这个时候，灰色区域已经没有对象了。那么剩下的白色对象 E、G 和 H 就能确定是垃圾了。

图 15：处理完所有灰色节点后，剩下的白色节点就是垃圾

回收掉 E、G 和 H 以后，就可以进入下一次循环。重新开始做增量收集。

图 16：开始下一次垃圾收集

从上面的原理还可以看出这个算法的特点：黑色对象永远不能指向白色对象，顶多指向灰色对象。我们只要始终保证这一条，就可以去做增量式的收集。

具体来说，垃圾收集器可以做了一段标记工作后，就让程序再运行一段。如果在程序运行期间，一个黑色对象被修改了，比如往一个黑色对象 a 里新存储了一个指针 b，那么把 a 涂成灰色，或者把 b 涂成灰色，就可以了。等所有的灰色节点变为黑色以后，就可以做垃圾清理了。

总结起来，三色标记法中，黑色的节点是已经处理完毕的，灰色的节点是正在处理的。如果灰色节点都处理完，剩下的白色节点就是垃圾。而如果在两次处理的间隙，有黑色对象又被改了，那么要重新处理。

那在增量收集的过程中，需要编译器做什么配合？肯定是需要的，编译器需要往生成的目标代码中插入读屏障（Read Barrier）和写屏障（Write Barrier）的代码。也就是在程序读写对象的时候，要执行一些逻辑，保证三色的正确性。

好了，你已经理解了增量标识的原理，知道了它可以减少程序的整体停顿时间。那么，能否再进一步减少停顿时间呢？

这就涉及到第三招：并发收集。我们再仔细看上面的增量式收集算法：既然垃圾收集程序和主程序可以交替执行，那么是否可以一边运行主程序，一边用另一个或多个线程来做垃圾收集呢？

这是可以的。实际上，除了少量的时候需要停下整个程序（比如一开头处理所有的 GC 根），其他时候是可以并发的，这样就进一步减少了总的停顿时间。

课程小结

今天这一讲，我带你了解运行时中的一个重要组成部分：垃圾收集器。采用什么样的垃圾收集算法，是实现一门语言时要着重考虑的点。

垃圾收集算法包含的内容有很多，我们这一讲并没有展开所有的内容，而是聚焦在介绍常用的几种算法（比如引用计数、分代收集、增量收集等）的原理，以及几种典型语言的编译器是如何跟选定的垃圾收集算法配合的。比如，在生成目标代码的时候，生成安全点、写屏障和读屏障的代码，修改引用数的代码，以及能够减少垃圾收集工作的一些优化工作。

我把今天的知识点做成了思维导图，供你参考：

一课一思

我们说垃圾收集是跟语言的设计有关的。那么，你是否可以想一下，怎样设计语言可以减少垃圾收集工作呢？欢迎分享你的观点。

33 | 并发中的编译技术（一）：如何从语言层面支持线程？

你好，我是宫文学。

现代的编程语言，开始越来越多地采用并发计算的模式。这也对语言的设计和编译技术提出了要求，需要能够更方便地利用计算机的多核处理能力。

并发计算需求的增长跟两个趋势有关：一是，CPU 在制程上的挑战越来越大，逼近物理极限，主频提升也越来越慢，计算能力的提升主要靠核数的增加，比如现在的手机，核数越来越多，动不动就 8 核、12 核，用于服务器的 CPU 核数则更多；二是，现代应用对并发处理的需求越来越高，云计算、人工智能、大数据和 5G 都会吃掉大量的计算量。

因此，在现代语言中，友好的并发处理能力是一项重要特性，也就需要编译技术进行相应的配合。现代计算机语言采用了多种并发技术，包括线程、协程、Actor 模式等。我会用三讲来带你了解它们，从而理解编译技术要如何与这些并发计算模式相配合。

这一讲，我们重点探讨线程模式，它是现代计算机语言中支持并发的基础模式。它也是讨论协程和 Actor 等其他话题的基础。

不过在此之前，我们需要先了解一下并发计算的一点底层机制：并行与并发、进程和线程。

并发的底层机制：并行与并发、进程与线程

我们先来学习一下硬件层面对并行计算的支持。

假设你的计算机有两颗 CPU，每颗 CPU 有两个内核，那么在同一时间，至少可以有 4 个程序同时运行。

后来 CPU 厂商又发明了超线程（Hyper Threading）技术，让一个内核可以同时执行两个线程，增加对 CPU 内部功能单元的利用率，这有点像我们之前讲过的流水线技术。这样一来，在操作系统里就可以虚拟出 8 个内核（或者叫做操作系统线程），在同一时间可以有 8 个程序同时运行。这种真正的同时运行，我们叫做并行（parallelism）。

图 1：虚拟内核与 CPU 真实内核的对应关系

可是仅仅 8 路并行，也不够用呀。如果你去查看一下自己电脑里的进程数，会发现运行着几十个进程，而线程数就更多了。

所以，操作系统会用分时技术，让一个程序执行一段时间，停下来，再让另一个程序运行。由于时间片切得很短，对于每一个程序来说，感觉上似乎一直在运行。这种“同时”能处理多个任务，但实际上并不一定是真正同时执行的，就叫做并发（Concurrency）。

实际上，哪怕我们的计算机只有一个内核，我们也可以实现多个任务的并发执行。这通常是由操作系统的一个调度程序（Scheduler）来实现的。但是有一点，操作系统在调度多个任务的时候，是有一定开销的：

一开始是以进程为单位来做调度，开销比较大。

在切换进程的时候，要保存当前进程的上下文，加载下一个进程的上下文，也会有一定的开销。由于进程是一个比较大的单位，其上下文的信息也比较多，包括用户级上下文（程序代码、静态数据、用户堆栈等）、寄存器上下文（各种寄存器的值）和系统级上下文（操作系统中与该进程有关的信息，包括进程控制块、内存管理信息、内核栈等）。

相比于进程，线程技术就要轻量级一些。在一个进程内部，可以有多个线程，每个线程都共享进程的资源，包括内存资源（代码、静态数据、堆）、操作系统资源（如文件描述符、网络连接等）和安全属性（用户 ID 等），但拥有自己的栈和寄存器资源。这样一来，线程的上下文包含的信息比较少，所以切换起来开销就比较小，可以把宝贵的 CPU 时间用于执行用户的任务。

总结起来，线程是操作系统做并发调度的基本单位，并且可以跟同一个进程内的其他线程共享内存等资源。操作系统会让一个线程运行一段时间，然后把它停下来，把它所使用的寄存器保存起来，接着让另一个线程运行，这就是线程调度原理。你要在大脑里记下这个场景，这样对理解后面所探讨的所有并发技术都很有帮助。

图 2：进程的共享资源和线程私有的资源

我们通常把进程作为资源分配的基本单元，而把线程作为并发执行的基本单元。不过，有的时候，用进程作为并发的单元也是比较好的，比如谷歌浏览器每打开一个 Tab 页，就新启动一个进程。这是因为，浏览器中多个进程之间不需要有互动。并且，由于各个进程所使用的资源是独立的，所以一个进程崩溃也不会影响到另一个。

而如果采用线程模型的话，由于它比较轻量级，消耗的资源比较少，所以你可以在一个操作系统上启动几千个线程，这样就能执行更多的并发任务。所以，在一般的网络编程模型中，我们可以针对每个网络连接，都启动一条线程来处理该网络连接上的请求。在第二个模块中我们分析过的MySQL就是这样做的。你每次跟 MySQL 建立连接，它就会启动一条线程来响应你的查询请求。

采用线程模型的话，程序就可以在不同线程之间共享数据。比如，在数据库系统中，如果一个客户端提交了一条 SQL，那么这个 SQL 的编译结果可以被缓存起来。如果另一个用户恰好也执行了同一个 SQL，那么就可以不用再编译一遍，因为两条线程可以访问共享的内存。

但是共享内存也会带来一些问题。当多个线程访问同样的数据的时候，会出现数据处理的错误。如果使用并发程序会造成错误，那当然不是我们所希望的。所以，我们就要采用一定的技术去消除这些错误。

Java 语言内置的并发模型就是线程模型，并且在语法层面为线程模型提供了一些原生的支持。所以接下来，我们先借助 Java 语言去了解一下，如何用编译技术来配合线程模型。

Java 的并发机制

Java 从语言层面上对并发编程提供了支持，简化了程序的开发。

Java 对操作系统的线程进行了封装，程序员使用 Thread 类或者让一个类实现 Runnable 接口，就可以作为一个线程运行。Thread 类提供了一些方法，能够控制线程的运行，并能够在多个线程之间协作。

从语法角度，与并发有关的关键字有 synchronized 和 volatile。它们就是用于解决多个线程访问共享内存的难题。

synchronized 关键字：保证操作的原子性

我们通过一个例子，来看看多个线程访问共享数据的时候，为什么会导致数据错误。

public class TestThread {
    public static void main(String[] args) {
        Num num = new Num();

        for (int i = 0; i < 3; i++) {
            new NewThread(num).start();
        }
    }
}

//线程类NewThread 对数字进行操作
class NewThread extends Thread {
    private Num num;

    public NewThread(Num num) {
        this.num = num;
    }

    @Override
    public void run() {
        for (int i = 0; i < 1000; i++)
            num.add();
        System.out.println("num.num:" + num.value);
    }
}

//给数字加1
class Num {
    public int value = 0;
    public void add() {
        value += 1;
    }
}

在这个例子中，每个线程对 Num 中的 value 加 1000 次。按说总有一个线程最后结束，这个时候打印出来的次数是 3000 次。可实际运行的时候，却发现很难对上这个数字，通常都要小几百。下面是几次运行的结果：

要找到其中的原因，最直接的方法，是从 add 函数的字节码入手研究。学习过编译原理之后，你要养成直接看字节码、汇编码来研究底层机制的习惯，这样往往会对问题的研究更加透彻。add 函数的字节码如下：

 0: aload_0    #加载Num对象
 1: dup        #复制栈顶对象(Num)
 2: getfield   #弹出一个Num对象，从内存取出value的值，加载到栈
 5: iconst_1   #加载整数1到栈
 6: iadd       #执行加法，结果放到栈中
 7: putfield   #栈帧弹出加法的结果和Num对象，写字段值，即把value的值写回内存
10: return

看着这一段字节码，你是不是会重新回忆起加法的计算过程？它实际上是 4 个步骤：

从内存加载 value 的值到栈；

把 1 加载到栈；

从栈里弹出 value 的值和 1，并做加法；

把新的 value 的值存回到内存里。

这是一个线程执行的过程。如果是两个以上的线程呢？你就会发现有问题了。线程 1 刚执行 getfield 取回 value 的值，线程 2 也做了同样的操作，那么它们取到的值是同一个。做完加法以后，写回内存的时候，写的也是同一个值，都是 3。

图 3：多个线程访问共享内存的情况

这样一分析，你就能理解数字计算错误的原因了。总结起来，出现这种现象是因为对 value 的加法操作不符合原子性（Atomic）。原子性的意思是一个操作或者多个操作，要么全部执行，并且执行的过程不会被任何因素打断，要么就都不执行。如果对 value 加 1 是一个原子操作，那么线程 1 一下子就操作完了，value 的值从 2 一下子变成 3。线程 2 只能接着对 3 再加 1，无法在线程 1 执行到一半的时候，就已经介入。

解决办法就是，让这段代码每次只允许一个线程执行，不会出现多个线程交叉执行的情况，从而保证对 value 值修改的原子性。这个时候就可以用到 synchronized 关键字了：

public void add() {
    synchronized(this){
        value += 1;
    }
}

这样再运行示例程序，就会发现，总有一个线程打印出来的值是 3000。这证明确实一共对 value 做了 3000 次加 1 的运算。

那 synchronized 关键字作用的原理是什么呢？要回答这个问题，我们还是要研究一下 add() 方法的字节码。

在字节码中，你会发现两个特殊的指令：monitorenter 和 monitorexit 指令，就是它们实现了并发控制。

查看字节码的描述，我们可以发现 monitorenter 的作用，是试图获取某个对象引用的监视器（monitor）的所有权。什么是监视器呢？在其他文献中，你可能会读到“锁”的概念。监视器和锁其实是一个意思。这个锁是关联到一个 Num 对象的，也就是代码中的 this 变量。只有获取了这把锁的程序，才能执行块中的代码，也就是“value += 1”。

具体来说，当程序执行到 monitorenter 的时候，会产生下面的情况：

如果监视器的进入计数是 0，线程就会进入监视器，并将进入计数修改为 1。这个时候，该线程就拥有了该监视器。

如果该线程已经拥有了该监视器，那么就重新进入，并将进入计数加 1。

如果其他线程拥有该监视器，那么该线程就会被阻塞（block）住，直到监视器的进入计数变为 0，然后再重新试图获取拥有权。

monitorexit 指令的机制则比较简单，就是把进入计数减 1。如果一段程序被当前线程进入了多次，那么也会退出同样的次数，直到进入计数为 0。

总结起来，我们用了锁的机制，保证被保护的代码块在同一时刻只能被一个线程访问，从而保证了相关操作的原子性。

到这里了，你可能会继续追问：如何保证获取锁的操作是原子性的？如果某线程看到监视器的进入计数是 0，这个时候它就进去，但在它修改进入计数之前，如果另一个线程也进去了怎么办，也修改成 1 怎么办？这样两个线程会不会都认为自己获得了锁？

这个担心是非常有必要的。实际上，要实现原子操作，仅仅从软件角度做工作是不行的，还必须要有底层硬件的支持。具体是如何支持的呢？我们还是采用一贯的方法，直接看汇编代码。

你可以用第 13 讲学过的方法，获取 Num.add() 方法对应的汇编代码，看看在汇编层面，监视器是如何实现的。我截取了一段汇编代码，并标注了其中的一些关键步骤，你可以看看。

汇编代码首先会跳到一段代码去获取监视器。如果获取成功，那么就跳转回来，执行后面对 value 做加法的运算。

我们再继续看一下获取监视器的汇编代码：

你特别需要注意的是 cmpxchg 指令：它能够通过一条指令，完成比较和交换的操作。查看Intel 的手册，你会发现更详细的解释：把 rax 寄存器的值与 cmpxchg 的目的操作数的值做对比。如果两个值相等，那么就把源操作数的值设置到目的操作数；否则，就把目的操作数的值设置到 rax 寄存器。

那 cmpxchg 指令有什么用呢？原来，通过这样一条指令，计算机就能支持原子操作。

比如，监视器的计数器的值，一开始是 0。我想让它的值加 1，从而获取这个监视器。首先，我根据 r11 寄存器中保存的地址，从内存中读出监视器初始的计数，发现它是 0；接着，我就把这个初始值放入 rax；第三步，我把新的值，也就是 1 放入 r10 寄存器。最后，我执行 cmpxchg 指令：

cmpxchg QWORD PTR [r11],r10

这个指令把当前的监视器计数，也就是内存地址是 r11 的值，跟 rax 的值做比较。如果它俩相等，仍然是 0，那就意味着没有别的程序去修改监视器计数。这个时候，该指令就会把 r10 的值设置到监视器计数中，也就是修改为 1。如果有别的程序已经修改了计数器的值，那么就会把计数器现在的值写到 rax 中。

补充：实际执行的时候，r10 中的值并不是简单的 0 和 1，而是获取了 Java 对象的对象头，并设置了其中与锁有关的标志位。

所以，通过 cmpxchg 指令，要么获得监视器成功，要么失败，肯定不会出现两个程序都以为自己获得了监视器的情况。

正因为 cmpxchg 在硬件级把原来的两个指令（比较指令和交换指令，Compare and Swap）合并成了一个指令，才能同时完成两个操作：首先看看当前值有没有被改动，然后设置正确的值。这也是 Java 语言中与锁有关的 API 得以运行的底层原理，也是操作系统和数据库系统加锁的原理。

不过，在汇编代码中，我们看到 cmpxchg 指令前面还有一个 lock 的前缀。这是起什么作用的呢？

原来呀，cmpxchg 指令在一个内核中执行的时候，可以保证原子性。但是，如果两个内核同时执行这条指令，也可能再次发生两个内核都去写入，从而都认为自己写成功了的情况。lock 前缀的作用，就是让这条指令在同一时间，只能有一个内核去执行。

所以说，要从根本上保证原子性，真不是一件容易的事情。不过，不管怎么说，通过 CPU 的支持，我们确实能够实现原子操作了，能让一段代码在同一个时间只让一个线程执行，从而避免了多线程的竞争现象。

上面说的 synchronized 关键字，是采用了锁的机制，保证被保护的代码块在同一时刻只能被一个线程访问，从而保证了相关操作的原子性。Java 还有另一个与并发有关的关键字，就是 volatile。

volatile 关键字：解决变量的可见性问题

那 volatile 关键字是针对什么问题的呢？我先来告诉你答案，它解决的是变量的可见性（Visibility）。

你可以先回想一下自己是不是遇到过这个问题：在并发计算的时候，如果两个线程都需要访问同一个变量，其中线程 1 修改了变量的值，那在多个 CPU 的情况下，线程 2 有的时候就会读不到最新的值。为什么呢？

图 4：不同线程使用不同的高速缓存的情形

因为 CPU 里都有高速缓存，用来提高 CPU 访问内存数据的速度。当线程 1 写一个值的时候，它不一定会马上被写回到内存，这要根据高速缓存的写策略来决定（这有点像你写一个文件到磁盘上，其实不会即时写进去，而是会先保存到缓冲区，然后批量写到磁盘，这样整体效率是最高的）。同样，当线程 2 读取这个值的时候，它可能是从高速缓存读取的，而没有从内存刷新数据，所以读到的可能是个旧数据，即使内存中的数据已经更新了。

volatile 关键字就是来解决这个问题的。它会告诉编译器：有多个线程可能会修改这个变量，所以当某个线程写数据的时候，要写回到内存，而不仅仅是写到高速缓存；当读数据的时候，要从内存中读，而不能从高速缓存读。

在下面的示例程序中，两个线程共享了同一个 Num 对象，其中线程 2 会去修改 Num.value 的值，而线程 1 会读取 Num.value 的值。

public class TestVolatile {
    public static void main(String[] args) {
       new TestVolatile().doTest();
    }

    public void doTest(){
        Num num = new Num();
        new MyThread1(num).start();
        new MyThread2(num).start();
    }

    //线程1:读取Num.value的值。如果该值发生了变化，那么就打印出来。
    class MyThread1 extends Thread {
        private Num num;
        public MyThread1(Num num) {
            this.num = num;
        }

        @Override
        public void run() {
            int localValue = num.value;
            while (localValue < 10){
                if (localValue != num.value){ //发现num.value变了
                    System.out.println("Value changed to: " + num.value);
                    localValue = num.value;
                }
            }
        }
    }

    //线程2：修改Num.value的值。
    class MyThread2 extends Thread {
        private Num num;
        public MyThread2(Num num) {
            this.num = num;
        }

        @Override
        public void run() {
            int localValue = num.value;
            while(num.value < 10){
                localValue ++;
                System.out.println("Change value to: " + localValue);
                num.value = localValue;
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    class Num {
        public volatile int value = 0;  //用volatile关键字修饰value
    }
}

如果 value 字段的前面没有 volatile 关键字，那么线程 1 经常不能及时读到 value 的变化：

而如果加了 volatile 关键字，那么每次 value 的变化都会马上被线程 1 检测到：

通过这样一个简单的例子，你能更加直观地理解为什么可见性是一个重要的问题，并且能够看到 volatile 关键字的效果。所以，volatile 关键字的作用，是让程序在访问被修饰的变量的内存时，让其他处理器能够见到该变量最新的值。那这是怎么实现的呢？

原来这里用到了一种叫做内存屏障（Memory Barriers）的技术。简单地说，编译器要在涉及 volatile 变量读写的时候，执行一些特殊的指令，让其他处理器获得该变量最新的值，而不是自己的一份拷贝（比如在高速缓存中）。

根据内存访问顺序的不同，这些内存屏障可以分为四种，分别是 LoadLoad 屏障、StoreStore 屏障、LoadStore 屏障和 StoreLoad 屏障。以 LoadLoad 屏障为例，它的指令序列是：

Load1指令
LoadLoad屏障
Load2指令

在这种情况下，LoadLoad 屏障会确保 Load1 的数据在 Load2 和后续 Load 指令之前，被真实地加载。

我们看一个例子。在下面的示例程序中，列出了用到 Load1 指令和 Load2 指令的场景。这个时候，编译器就要在这两条指令之间插入一个 LoadLoad 屏障：

class Foo{
  volatile int a;
  int b, c;
  void foo(){
    int i, j;
    i = a; // Load1指令,针对volatile变量
    j = b; // Load2指令，针对普通变量
  }
}

关于另几种内存屏障的说明，以及在什么时候需要插入内存屏障指令，你可以看下这篇文章。

另外，不同的 CPU，对于这四类屏障所对应的指令是不同的。下图也是从上面那篇文章里摘出来的：

可以看到，对于 x86 芯片，其中的 LoadStore、LoadLoad 和 StoreStore 屏障，都用一个 no-op 指令，等待前一个指令执行完毕即可。这就能确保读到正确的值。唯独对于 StoreLoad 的情况，也就是我们 TestVolatile 示例程序中一个线程写、另一个线程读的情况，需要用到几个特殊的指令之一，比如 mfence 指令、cpuid 指令，或者在一个指令前面加锁（lock 前缀）。

总结起来，其实 synchronized 关键字也好，volatile 关键字也好，都是用来保证线程之间的同步的。只不过，synchronized 能够保证操作的原子性，但付出的性能代价更高；而 volatile 则只同步数据的可见性，付出的性能代价会低一点。

在 Java 语言规范中，在多线程情况下与共享变量访问有关的内容，被叫做 Java 的内存模型，并单独占了一节。这里面规定了在对内存（包括类的字段、数组中的元素）做操作的时候，哪些顺序是必须得到保证的，否则程序就会出错。

这些规定跟编译器的实现，有比较大的关系。编译器在做优化的时候，会对指令做重排序。在重排序的时候，一定要遵守 Java 内存模型中对执行顺序的规定，否则运行结果就会出错。

课程小结

今天这一讲，我主要以 Java 语言为例，讲解了多线程的原理，以及相关的程序语义。多个线程如果要访问共享的数据，通常需要进行同步。synchronized 关键字能通过锁的机制，保证操作的原子性。而 volatile 关键字则能通过内存屏障机制，在不同的处理器之间同步共享变量的值。

你会发现，在写编译器的时候，只有正确地理解了这些语义和原理，才能生成正确的目标代码，所以这一讲的内容你必须要理解。学会今天这讲，还有一个作用，就是能够帮助你加深对多线程编程的底层机制的理解，更好地编写这方面的程序。

其他语言在实现多线程机制时，所使用的语法可能不同，但底层机制都是相同的。通过今天的讲解，你可以举一反三。

我把今天这讲思维导图也整理出来了，供你参考：

一课一思

在你之前的项目经验中，有没有遇到并发处理不当而导致的问题？你是怎么解决的呢？欢迎分享你的经验。

参考资料

The JSR-133 Cookbook for Compiler Writers，介绍了编译器如何为 Java 语言实现内存屏障。

Java 语言规范中对内存模型的相关规定。

34 | 并发中的编译技术（二）：如何从语言层面支持协程？

你好，我是宫文学。

上一讲我们提到了线程模式是当前计算机语言支持并发的主要方式。

不过，在有些情况下，线程模式并不能满足要求。当需要运行大量并发任务的时候，线程消耗的内存、线程上下文切换的开销都太大。这就限制了程序所能支持的并发任务的数量。

在这个背景下，一个很“古老”的技术重新焕发了青春，这就是协程（Coroutine）。它能以非常低的代价、友好的编程方式支持大量的并发任务。像 Go、Python、Kotlin、C# 等语言都提供了对协程的支持。

今天这一讲，我们就来探究一下如何在计算机语言中支持协程的奇妙功能，它与编译技术又是怎样衔接的。

首先，我们来认识一下协程。

协程（Coroutine）的特点与使用场景

我说协程“古老”，是因为这个概念是在 1958 年被马尔文 · 康威（Melvin Conway）提出来、在 20 世纪 60 年代又被高德纳（Donald Ervin Knuth）总结为两种子过程（Subroutine）的模式之一。一种是我们常见的函数调用的方式，而另一种就是协程。在当时，计算机的性能很低，完全没有现代的多核计算机。而采用协程就能够在这样低的配置上实现并发计算，可见它是多么的轻量级。

有的时候，协程又可能被称作绿色线程、纤程等，所采用的技术也各有不同。但总的来说，它们都有一些共同点。

首先，协程占用的资源非常少。你可以在自己的笔记本电脑上随意启动几十万个协程，而如果你启动的是几十万个线程，那结果就是不可想象的。比如，在 JVM 中，缺省会为每个线程分配 1MB 的内存，用于线程栈等。这样的话，几千个线程就要消耗掉几个 GB 的内存，而几十万个线程理论上需要消耗几百 GB 的内存，这还没算程序在堆中需要申请的内存。当然，由于底层操作系统和 Java 应用服务器的限制，你也无法启动这么多线程。

其次，协程是用户自己的程序所控制的并发。也就是说，协程模式，一般是程序交出运行权，之后又被另外的程序唤起继续执行，整个过程完全是由用户程序自己控制的。而线程模式就完全不同了，它是由操作系统中的调度器（Scheduler）来控制的。

我们看个 Python 的例子：

def running_avg():
    total = 0.0
    count = 0
    avg = 0
    while True:
        num = yield avg
        total += num
        count += 1
        avg = total/count

#生成协程，不会有任何输出
ra = running_avg()
#运行到yield
next(ra)

print(ra.send(2))
print(ra.send(3))
print(ra.send(4))
print(ra.send(7))
print(ra.send(9))
print(ra.send(11))

#关掉协程
ra.close

可以看到，使用协程跟我们平常使用函数几乎没啥差别，对编程人员很友好。实际上，它可以认为是跟函数并列的一种子程序形式。和函数的区别是，函数调用时，调用者跟被调用者之间像是一种上下级的关系；而在协程中，调用者跟被调用者更像是互相协作的关系，比如一个是生产者，一个是消费者。这也是“协程”这个名字直观反映出来的含义。

我们用一张图来对比下函数和协程中的调用关系。

图 1：函数（左）与协程（右）的控制流

细想一下，编程的时候，这种需要子程序之间互相协作的场景有很多，我们一起看两种比较常见的场景。

第一种比较典型的场景，就是生产者和消费者模式。如果你用过 Unix 管道或者消息队列编程的话，会非常熟悉这种模式。但那是在多个进程之间的协作。如果用协程的话，在一个进程内部就能实现这种协作，非常轻量级。

就拿编译器前端来说，词法分析器（Tokenizer）和语法分析器（Parser）就可以是这样的协作关系。也就是说，为了更好的性能，没有必要一次把词法分析完毕，而是语法分析器消费一个，就让词法分析器生产一个。因此，这个过程就没有必要做成两个线程了，否则就太重量级了。这种场景，我们可以叫做生成器（Generator）场景：主程序调用生成器，给自己提供数据。

特别适合使用协程的第二种场景是 IO 密集型的应用。比如做一个网络爬虫同时执行很多下载任务，或者做一个服务器同时响应很多客户端的请求，这样的任务大部分时间是在等待网络传输。

如果用同步阻塞的方式来做，一个下载任务在等待的时候就会把整个线程阻塞掉。而用异步的方式，协程在发起请求之后就把控制权交出，调度程序接收到数据之后再重新激活协程，这样就能高效地完成 IO 操作，同时看上去又是用同步的方式编程，不需要像异步编程那样写一大堆难以阅读的回调逻辑。

这样的场景在微服务架构的应用中很常见，我们来简化一个实际应用场景，分析下如何使用协程。

在下面的示例中，应用 A 从客户端接收大量的并发请求，而应用 A 需要访问应用 B 的服务接口，从中获得一些信息，然后返回给客户端。

图 2：应用间通讯的场景

要满足这样的场景，我们最容易想到的就是，编写同步通讯的程序，其实就是同步调用。

假设应用 A 对于每一个客户端的请求，都会起一个线程做处理。而你呢，则在这个线程里发起一个针对应用 B 的请求。在等待网络返回结果的时候，当前线程会被阻塞住。

图 3：采用同步编程实现应用间的通讯

这个架构是最简单的，你如果采用 Java 的 Servlet 容器来编写程序的话，很可能会采用这个结构。但它有一些缺陷：

对于每个客户端请求，都要起一个线程。如果请求应用 B 的时延比较长，那么在应用 A 里会积压成千上万的线程，从而浪费大量的服务器资源。而且，当线程超过一定数量，应用服务器就会拒绝后续的请求。

大量的请求毫无节制地涌向应用 B，使得应用 B 难以承受负载，从而导致响应变慢，甚至宕机。

因为同步调用的这种缺点，近年来异步编程模型得到了更多的应用，典型的就是 Node.js。在异步编程模型中，网络通讯等 IO 操作不必阻塞线程，而是通过回调来让主程序继续执行后续的逻辑。

图 4：使用异步编程实现应用间通讯

上图中，我们只用到了 4 个线程，对应操作系统的 4 个真线程，可以减少线程切换的开销。在每个线程里，维护一个任务队列。首先，getDataFromApp2() 会被放到任务队列；当数据返回以后，系统的调度器会把 sendBack() 函数放进任务队列。

这个例子比较简单，只有一层回调，你还能读懂它的逻辑。但是，采用这种异步编程模式，经常会导致多层回调，让代码很难阅读。这种现象，被叫做“回调地狱（Callback Hell）”。

这时候，就显示出协程的优势了。协程可以让你用自己熟悉的命令式编程的风格，来编写异步的程序。比如，对于上面的示例程序，用协程可以这样写，看上去跟编写同步调用的代码没啥区别。

requestHandler(){
  ...;
  await getDataFromApp2();
  ...;
  sendBack();
}

当然，我要强调一下，在协程用于同步和异步编程的时候，其调度机制是不同的。跟异步编程配合的时候，要把异步 IO 机制与协程调度机制关联起来。

好了，现在你已经了解了协程的特点和适用场景。那么问题来了，如何让一门语言支持协程呢？要回答这个问题，我们就要先学习一下协程的运行原理。

协程的运行原理

当我们使用函数的时候，简单地保持一个调用栈就行了。当 fun1 调用 fun2 的时候，就往栈里增加一个新的栈帧，用于保存 fun2 的本地变量、参数等信息；这个函数执行完毕的时候，fun2 的栈帧会被弹出（恢复栈顶指针 sp），并跳转到返回地址（调用 fun2 的下一条指令），继续执行调用者 fun1 的代码。

图 5：调用函数时的控制流和栈桢管理

但如果调用的是协程 coroutine1，该怎么处理协程的栈帧呢？因为协程并没有执行完，显然还不能把它简单地丢掉。

这种情况下，程序可以从堆里申请一块内存，保存协程的活动记录，包括本地变量的值、程序计数器的值（当前执行位置）等等。这样，当下次再激活这个协程的时候，可以在栈帧和寄存器中恢复这些信息。

图 6：调用协程时的控制流和栈桢管理

把活动记录保存到堆里，是不是有些眼熟？其实，这有点像闭包的运行机制。

程序在使用闭包的时候，也需要在堆里保存闭包中的自由变量的信息，并且在下一次调用的时候，从堆里恢复。只不过，闭包不需要保存本地变量，只保存自由变量就行了；也不需要保存程序计数器的值，因为再一次调用闭包函数的时候，还是从头执行，而协程则是接着执行 yield 之后的语句。

fun1 通过 resume 语句，让协程继续运行。这个时候，协程会去调用一个普通函数 fun2，而 fun2 的栈帧也会加到栈上。

图 7：在协程里调用普通函数时的栈桢情况

如果 fun2 执行完毕，那么就会返回到协程。而协程也会接着执行下一个语句，这个语句是一个专门针对协程的返回语句，我们叫它 co_return 吧，以便区别于传统的 return。在执行了 co_return 以后，协程就结束了，无法再 resume。这样的话，保存在堆里的活动记录也就可以销毁了。

图 8：协程结束时对栈桢的处理

通过上面的例子，你应该已经了解了协程的运行原理。那么我们学习编译原理会关心的问题是：实现协程的调度，包括协程信息的保存与恢复、指令的跳转，需要编译器的帮忙吗？还是用一个库就可以实现？

实际上，对于 C 和 C++ 这样的语言来说，确实用一个库就可以实现。因为 C 和 C++ 比较灵活，比如可以用 setjmp、longjmp 等函数，跨越函数的边界做指令的跳转。但如果用库实现，通常要由程序管理哪些状态信息需要被保存下来。为此，你可能要专门设计一个类型，来参与实现协程状态信息的维护。

而如果用编译器帮忙，那么就可以自动确定需要保存的协程的状态信息，并确定需要申请的内存大小。一个协程和函数的区别，就仅仅在于是否使用了 yield 和 co_return 语句而已，减轻了程序员编程的负担。

好了，刚才我们讨论了，在实现协程的时候，要能够正确保存协程的活动记录。在具体实现上，有 Stackful 和 Stackless 两种机制。采用不同的机制，对于协程能支持的特性也很有关系。所以接下来，我带你再进一步地分析一下 Stackful 和 Stackless 这两种机制。

Stackful 和 Stackless 的协程

到目前为止，看上去协程跟普通函数（子程序）的差别也不大嘛，你看：

都是由一个主程序调用，运行一段时间以后再把控制流交回给主程序；

都使用栈来管理本地变量和参数等信息，只不过协程在没有完全运行完毕时，要用堆来保存活动记录；

在协程里也可以调用其他的函数。

可是，在有的情况下，我们没有办法直接在 coroutine1 里确定是否要暂停线程的执行，可能需要在下一级的子程序中来确定。比如说，coroutine1 函数变得太大，我们重构后，把它的功能分配到了几个子程序中。那么暂停协程的功能，也会被分配到子程序中。

图 9：在辅助函数里暂停协程时的控制流和栈桢情况

这个时候，在 helper() 中暂停协程，会让控制流回到 fun1 函数。而当在 fun1 中调用 resume 的时候，控制流应该回到 helper() 函数中 yield 语句的下一条，继续执行。coroutine1() 和 helper() 加在一起，起到了跟原来只有一个 coroutine1() 一样的效果。

这个时候，在栈里不仅要加载 helper() 的活动记录，还要加载它的上一级，也就是 coroutine1() 的活动记录，这样才能维护正确的调用顺序。当 helper() 执行完毕的时候，控制流会回到 coroutine1()，继续执行里面的逻辑。

在这个场景下，不仅要从堆里恢复多个活动记录，还要维护它们之间的正确顺序。上面的示例中，还只有两级调用。如果存在多级的调用，那就更麻烦了。

那么，怎么解决这个技术问题呢？你会发现，其实协程的逐级调用过程，形成了自己的调用栈，这个调用栈需要作为一个整体来使用，不能拆成一个个单独的活动记录。

既然如此，那我们就加入一个辅助的运行栈好了。这个栈通常被叫做 Side Stack。每个协程，都有一个自己专享的协程栈。

图 10：协程的 Side Stack

好了，现在是时候给你介绍两个术语了：这种需要一个辅助的栈来运行协程的机制，叫做 Stackful Coroutine；而在主栈上运行协程的机制，叫做 Stackless Coroutine。

对于 Stackless 的协程来说，只能在顶层的函数里把控制权交回给调用者。如果这个协程调用了其他函数或者协程，必须等它们返回后，才能去执行暂停协程的操作。从这种角度看，Stackless 的特征更像一个函数。

而对于 Stackful 的协程来说，可以在协程栈的任意一级，暂停协程的运行。从这个角度看，Stackful 的协程像是一个线程，不管有多少级的调用，随时可以让这个协程暂停，交出控制权。

除此之外，我们再仔细去想，因为设计上的不同，Stackless 和 Stackful 的协程其实还会产生其他的差别：

Stackless 的协程用的是主线程的栈，也就是说它基本上会被绑定在创建它的线程上了。而 Stackful 的协程，可以从一个线程脱离，附加到另一个线程上。

Stackless 的协程的生命周期，一般来说受制于它的创建者的生命周期。而 Stackful 的协程的生命周期，可以超过它的创建者的生命周期。

好了，以上就是对 Stackless 和 Stackful 的协程的概念和区别了。其实，对于协程，我们可能还会听说一种分类方法，就是对称的和非对称的。

到目前为止，我们讲到的协程都是非对称的。有一个主程序，而协程像是子程序。主程序和子程序控制程序执行的原语是不同的，一个用于激活协程，另一个用于暂停协程。而对称的协程，相互之间是平等的关系，它们使用相同的原语在协程之间移交控制权。

那么，C++、Python、Java、JavaScript、Julia 和 Go 这些常见语言中，哪些是支持协程的？是 Stackless 的 ，还是 Stackful 的？是对称的，还是非对称的？需要编译器做什么配合？

接下来，我们就一起梳理下。

不同语言的协程实现和差异

C++ 语言的协程实现

今年发布的 C++20 标准中，增加了协程特性。标准化组织经过长期的讨论，采用了微软的 Stackless 模式。采纳的原因也比较简单，就是因为它实现起来简单可靠，并且已经在微软有多年的成熟运行的经验了。

在这个方案里，采用了 co_await、co_yield 和 co_return 三个新的关键字让程序员使用协程，并在编译器层面给予了支持。

而我们说过，C 和 C++ 的协程功能，只用库也可以实现。其中，腾讯的微信团队就开源了一套协程库，叫做 libco。这个协程库是支撑微信背后海量并发调用的基础。采用这个协程库，单机竟然可以处理千万级的连接！

并且，libco 还做了一点创新。因为 libco 是 Stackful 的，对每个协程都要分配一块栈空间，在 libco 中给每个协程分配的是 128KB。那么，1 千万协程就需要 1.2TB 的内存，这样服务器的内存就会成为资源瓶颈。所以，libco 发明了共享栈的机制：当一个协程不用栈的时候，把里面的活动记录保存到协程私有的内存中，把协程栈腾出来给其他协程使用。一般来说，一个活动记录的大小要远小于 128KB，所以总体上节省了内存。

另外，libco 还跟异步通讯机制紧密整合，实现了用同步的编程风格来实现异步的功能，使得微信后台的处理能力大大提升。微信后台用协程做升级的案例，你可以看看这篇文章。

接下来，我们说说 Python 语言的协程实现。

Python 语言的协程实现

我们前面讲协程的运行原理用的示例程序，就是用 Python 写的。这是 Python 的一种协程的实现，支持的是同步处理，叫做 generator 模式。3.4 版本之后，Python 支持一种异步 IO 的协程模式，采用了 async/await 关键字，能够以同步的语法编写异步程序。

总体来说，Python 是一种解释型的语言，而且内部所有成员都是对象，所以实现协程的机制是很简单的，保存协程的执行状态也很容易。只不过，你不可能把 Python 用于像刚才微信那样高并发的场景，因为解释型语言对资源的消耗太高了。尽管如此，在把 Python 当作脚本语言使用的场景中，比如编写网络爬虫，采用它提供的协程加异步编程的机制，还是能够带来很多好处的。

我们再来说说 Java 和 JavaScript 语言的协程实现。

Java 的协程实现

其实，Java 原生是不支持协程的，但是也有几种方法可以让 Java 支持协程：

方法 1：给虚拟机打补丁，从底层支持协程。

方法 2：做字节码操纵，从而改变 Java 缺省的控制流执行方式，并保存协程的活动记录。

方法 3：基于 JNI。比如，C 语言可以实现协程，然后再用 JNI 去调用 C 语言实现的功能。

方法 4：把线程封装成协程。这种实现技术太过于重量级，因为没有体现出协程占据资源少的优点。

现在有一些第三方库实现了协程功能，基本上都是基于方法 2，也就是做字节码操纵。目前还没有哪一个库被广泛接受。如果你不想冒险的话，可能还是要等待官方的实现了。

JavaScript 中的协程

JavaScript 从 ES6（ECMAScript 6.0）引入了 generator 功能，ES7 引入了支持异步编程的 async/await。由于 JavaScript 本来就非常重视异步编程，所以协程的引入，会让异步编程变得更友好。

Julia 和 Go 语言的协程实现

Julia 语言的协程机制，跟以上几种语言都不同。它提供的是对称的协程机制。多个协程可以通过 channel 通讯，当从 channel 里取不出信息时，或者 channel 已满不能再发信息时，自然就停下来了。

当我谈到 channel 的时候，熟悉 Go 语言的同学马上就会想到 Goroutine。Goroutine 是 Go 语言的协程机制，也是用 channel 实现协程间的协作的。

我把对 Go 语言协程机制的介绍放在最后，是因为 Goroutine 实在是很强大。我觉得，所有对并发编程有兴趣的同学，都要看一看 Goroutine 的实现机制，都会得到很大的启发。

我的感受是，Goroutine 简直是实现轻量级并发功能的集大成者，几乎考虑到了你能想到的所有因素。介绍 Goroutine 的文章有很多，我就不去重复已有的内容了，你可以看看“How Stacks are Handled in Go”这篇文章。现在，我就顺着本讲的知识点，对 Goroutine 的部分特点做一点介绍。

首先我们来看一下，Goroutine 是 Stackful 还是 Stackless？答案是 Stackful 的。就像我们前面已经总结过的，Stackful 协程的特点主要是两点：协程的生命周期可以超过其创建者，以及协程可以从一个线程转移到另一个线程。后者在 Goroutine 里特别有用。当一个协程调用了一个系统功能，导致线程阻塞的时候，那么排在这条线程上的其他 Goroutine 岂不是也要被迫等待？为了避免这种尴尬，Goroutine 的调度程序会把被阻塞的线程上的其他 Goroutine 迁移到其他线程上。

我们讲 libco 的时候还讲过，Stackful 的缺点是要预先分配比较多的内存用作协程的栈空间，比如 libco 要为每个协程分配 128K 的栈。而 Go 语言只需要为每个 Goroutine 分配 2KB 的栈。你可能会问了，万一空间不够了怎么办，不会导致内存访问错误吗？

不会的。Go 语言的函数在运行的时候，会有一小块序曲代码，用来检查栈空间够不够用。如果不够用，就马上申请新的内存。需要注意的是，像这样的机制，必须有编译器的配合才行，编译器可以为每个函数生成这样的序曲代码。如果你用库来实现协程，就无法实现这样的功能。

通过这个例子，你也可以体会到把某个特性做成语言原生的，以及用库去实现的差别。

我想说的 Go 语言协程机制的第二个特点，就是 channel 机制。channel 提供了 Goroutine 之间互相通讯，从而能够协调行为的机制。Go 语言的运行时保证了在同一个时刻，只有一个 Goroutine 能够读写 channel，这就避免了我们前一讲提到的，用锁来保证多个线程访问共享数据的难题。当然，channel 在底层也采用了锁的机制，毕竟现在不需要程序员去使用这么复杂且容易出错的机制了。

Go 语言协程机制的第三个特点，是关于协程的调度时机。今天这一讲，我们其实看到了两种调度时机：对于 generator 类型的协程，基本上是同步调度的，协程暂停以后，控制立即就回到主程序；第二个调度机制，是跟异步 IO 机制配合。

而我关心的，是能否实现像线程那样的抢占式（preemptive）的调度。操作系统的线程调度器，在进行调度的时候，可以不管当前线程运行到了什么位置，直接中断它的运行，并把相关的寄存器的值保存下来，然后去运行另一个线程。这种抢占式的调度的一个最大的好处，是不会让某个程序霸占 CPU 资源不放，而是公平地分配给各个程序。而协程也存在着类似的问题。如果一个协程长时间运行，那么排在这条线程上的其他协程，就被剥夺了运行的机会。

Goroutine 在解决这个问题上也做了一些努力。比如，在下面的示例程序中，foo 函数中的循环会一直运行。这时候，编译器就可以在 bar() 函数的序曲中，插入一些代码，检查当前协程是否运行时间太久，从而主动让出控制权。不过，如果 bar() 函数被内联了，处理方式就要有所变化。但总的来说，由于有编译器的参与，这种类似抢占的逻辑是可以实现的。

func foo(){
  while true{
    bar();   //可以在bar函数的序曲中做检查。
  }
}

在 Goroutine 实现了各种丰富的调度机制以后，它已经变得不完全由用户的程序来主导协程的调度了，而是能够更加智能、更加优化地实现协程的调度，由操作系统的线程调度器、Go 语言的调度器和用户程序三者配合实现。这也是 Go 语言的一个重要优点。

那么，我们从 C、C++、Python、Java、JavaScript、Julia 和 Go 语言中，就能总结出协程实现上的特点了：

除了 Julia 和 Go，其他语言采用的都是非对称的协程机制。Go 语言是采用协程最彻底的。在采用了协程以后，已经不需要用过去的线程。

像 C++、Go 这样编译成机器码执行的语言，对协程栈的良好管理，能够大大降低内存占用，增加支持的协程的数量。

协程与异步 IO 结合是一个趋势。

课程小结

今天这一讲，我们学习了协程的定义、使用场景、实现原理和不同语言的具体实现机制。我们特别从编译技术的角度，关注了协程对栈的使用机制，看看它与传统的程序有什么不同。

在这个过程中，一方面，你会通过今天的课程对协程产生深入的认识；另一方面，你会更加深刻地认识到编译技术是如何跟语言特性的设计和运行时紧密配合的。

协程可以用库实现，也可以借助编译技术成为一门语言的原生特性。采用编译技术，能帮助我们自动计算活动记录的大小，实现自己独特的栈管理机制，实现抢占式调度等功能。

本讲的思维导图我也放在了下面，供你参考：

一课一思

上一讲我们讨论的是线程模式。我们知道，当并发访问量非常大的时候，线程模式消耗的资源会太多。那么你会如何解决这个问题？是否会采用协程？如果你使用的是 Java 语言，其原生并不支持协程，你会怎么办？欢迎发表你的观点。

参考资料

How Stacks are Handled in Go，这篇文章介绍了 Goroutine 使用栈的机制，你可以看看它是如何很节约地使用内存的。

Coroutines in Java，这篇文章探讨了在 Java 中实现协程的各种技术考虑。

35 | 并发中的编译技术（三）：Erlang语言厉害在哪里？

你好，我是宫文学。

在前面两讲，我们讨论了各门语言支持的并发计算的模型。线程比进程更加轻量级，上下文切换成本更低；协程则比线程更加轻量级，在一台计算机中可以轻易启动几十万、上百万个并发任务。

但不论是线程模型、还是协程模型，当涉及到多个线程访问共享数据的时候，都会出现竞争问题，从而需要用到锁。锁会让其他需要访问该数据的线程等待，从而导致系统整体处理能力的降低。

并且，编程人员还要特别注意，避免出现死锁。比如，线程 A 持有了锁 x，并且想要获得锁 y；而线程 B 持有了锁 y，想要获得锁 x，结果这两个线程就会互相等待，谁也进行不下去。像数据库这样的系统，检测和消除死锁是一项重要的功能，以防止互相等待的线程越来越多，对数据库操作不响应，并最终崩溃掉。

既然使用锁这么麻烦，那在并发计算中，能否不使用锁呢？这就出现了 Actor 模型。那么，什么是 Actor 模型？为什么它可以不用锁就实现并发？这个并发模型有什么特点？需要编译技术做什么配合？

今天这一讲，我们就从这几个问题出发，一起学习并理解 Actor 模型。借此，我们也可以把用编译技术支持不同的并发模型的机制，理解得更深刻。

首先，我们看一下什么是 Actor 模型。

什么是 Actor 模型？

在线程和协程模型中，之所以用到锁，是因为两个线程共享了内存，而它们会去修改同一个变量的值。那，如果避免共享内存，是不是就可以消除这个问题了呢？

没错，这就是 Actor 模型的特点。Actor 模型是 1973 年由 Carl Hewitt 提出的。在 Actor 模型中，并发的程序之间是不共享内存的。它们通过互相发消息来实现协作，很多个一起协作的 Actor 就构成了一个支持并发计算的系统。

我们看一个有三个 Actor 的例子。

图 1：三个 Actor 的例子

你会注意到，每个 Actor 都有一个邮箱，用来接收其他 Actor 发来的消息；每个 Actor 也都可以给其他 Actor 发送消息。这就是 Actor 之间交互的方式。Actor A 给 Actor B 发完消息后就返回，并不会等着 Actor B 处理完毕，所以它们之间的交互是异步的。如果 Actor B 要把结果返回给 A，也是通过发送消息的方式。

这就是 Actor 大致的工作原理了。因为 Actor 之间只是互发消息，没有共享的变量，当然也就不需要用到锁了。

但是，你可能会问：如果不共享内存，能解决传统上需要对资源做竞争性访问的需求吗？比如，卖电影票、卖火车票、秒杀或者转账的场景。我们以卖电影票为例讲解一下。

在用传统的线程或者协程来实现卖电影票功能的时候，对票的状态进行修改，需要用锁的机制实现同步互斥，以保证同一个时间段只有一个线程可以去修改票的状态、把它分配给某个用户，从而避免多个线程同时访问而出现一张票卖给多个人的情况。这种情况下，多个程序是串行执行的，所以系统的性能就很差。

如果用 Actor 模式会怎样呢？

你可以把电影院的前半个场地和后半个场地的票分别由 Actor B 和 C 负责销售：Actor A 在接收到定前半场座位的请求的时候，就发送给 Actor B，后半场的就发送给 Actor C，Actor B 和 C 依次处理这些请求；如果 Actor B 或 C 接收到的两个信息都想要某个座位，那么针对第二个请求会返回订票失败的消息。

图 2：Actor 用于订票场景

你发现没有？在这个场景中，Actor B 和 C 仍然是顺序处理各个请求。但因为是两个 Actor 并发地处理请求，所以系统整体的性能会提升到原来的两倍。

甚至，你可以让每排座位、每个座位都由一个 Actor 负责，使得系统的性能更高。因为在系统中创建一个 Actor 的成本是很低的。Actor 跟协程类似，很轻量级，一台服务器里创建几十万、上百万个 Actor 也没有问题。如果每个 Actor 负责一个座位，那一台服务器也能负责几十万、上百万个座位的销售，也是可以接受的。

当然，实际的场景要比这个复杂，比如一次购买多张相邻的票等，但原理是一样的。用这种架构，可以大大提高并发能力，处理海量订票、秒杀等场景不在话下。

其实，我个人比较喜欢 Actor 这种模式，因为它跟现实世界里的分工协作很相似。比如，餐厅里不同岗位的员工，他们通过互相发信息来实现协作，从而并发地服务很多就餐的顾客。

分析到这里，我再把 Actor 模式跟你非常熟悉的一个概念，面向对象编程（Object Oriented Programming，OOP）关联起来。你可能会问：Actor 和面向对象怎么还有关联？

是的。面向对象语言之父阿伦 · 凯伊（Alan Kay），Smalltalk 的发明人，在谈到面向对象时是这样说的：对象应该像生物的细胞，或者是网络上的计算机，它们只能通过消息互相通讯。对我来说 OOP 仅仅意味着消息传递、本地保留和保护以及隐藏状态过程，并且尽量推迟万物之间的绑定关系。

I thought of objects being like biological cells and/or individual computers on a network, only able to communicate with messages (so messaging came at the very beginning – it took a while to see how to do messaging in a programming language efficiently enough to be useful)

…

OOP to me means only messaging, local retention and protection and hiding of state-process, and extreme late-binding of all things. It can be done in Smalltalk and in LISP.

总结起来，Alan 对面向对象的理解，强调消息传递、封装和动态绑定，没有谈多态、继承等。对照这个理解，你会发现 Actor 模式比现有的流行的面向对象编程语言，更加接近面向对象的实现。

无论如何，通过把 Actor 和你熟悉的面向对象做关联，我相信能够拉近你跟 Actor 之间的距离，甚至会引发你以新的视角来审视目前流行的面向对象范式。

好了，到现在，你可以说是对 Actor 模型比较熟悉了，也可以这么理解：Actor 有点像面向对象程序里的对象，里面可以封装一些数据和算法；但你不能调用它的方法，只能给它发消息，它会异步地、并发地处理这些消息。

但是，你可能会提出一个疑问：Actor 模式不用锁的机制就能实现并发程序之间的协作，这一点很好，那么它有没有什么缺点呢？

我们知道，任何设计方案都是一种取舍。一个方案有某方面的优势，可能就会有其他方面的劣势。采用 Actor 模式，会有两方面的问题。

第一，由于 Actor 之间不共享任何数据，因此不仅增加了数据复制的时间，还增加了内存占用量。但这也不完全是缺点：一方面，你可以通过在编写程序时，尽量降低消息对象的大小，从而减少数据复制导致的开销；另一方面，消息传递的方式对于本机的 Actor 和集群中的 Actor 是一样的，这就使得编写分布式的云端应用更简单，从而在云计算时代可以获得更好的应用。

第二，基于消息的并发机制，基本上是采用异步的编程模式，这就和通常程序的编程风格有很大的不同。你发出一个消息，并不会马上得到结果，而要等待另一个 Actor 发送消息回来。这对于习惯于编写同步代码的同学，可能是一个挑战。

好了，我们已经讨论了 Actor 机制的特点。接下来我们再看看，什么语言和框架实现了 Actor 模式。

支持 Actor 模型的语言和框架

支持 Actor 的最有名的语言是 Erlang。Erlang 是爱立信公司发明的，它的正式版本是在 1987 年发布，其核心设计者是乔 · 阿姆斯特朗（Joe Armstrong），最早是用于开发电信领域的软件系统。

在 Erlang 中，每个 Actor 叫作一个进程（Process）。但这个“进程”其实不是操作系统意义上的进程，而是 Erlang 运行时的并发调度单位。

Erlang 有两个显著的优点：首先，对并发的支持非常好，所以它也被叫做面向并发的编程语言（COP）。第二，用 Erlang 可以编写高可靠性的软件，可以达到 9 个 9。这两个优点都与 Actor 模式有关：

Erlang 的软件由很多 Actor 构成；

这些 Actor 可以分布在多台机器上，相互之间的通讯跟在同一台机器上没有区别；

某个 Actor 甚至机器出现故障，都不影响整体系统，可以在其他机器上重新启动该 Actor；

Actor 的代码可以在运行时更新。

所以，由 Actor 构成的系统真的像一个生命体，每个 Actor 像一个细胞。细胞可以有新陈代谢，而生命体却一直存在。可以说，用 Erlang 编写的基于 Actor 模式的软件，非常好地体现了复杂系统的精髓。到这里，你是不是就能解释“Erlang 语言厉害在哪里”这个问题了。

鉴于 Actor 为 Erlang 带来的并发能力和高可靠性，有一些比较流行的开源系统就是用 Erlang 编写的。比如，消息队列系统 RabbitMQ、分布式的文档数据库系统 CouchDB，都很好地体现了 Erlang 的并发能力和健壮性。

除了 Erlang 以外，Scala 语言也提供了对 Actor 的支持，它是通过 Akka 库实现的，运行在 JVM 上。我还关注了微软的一个 Orleans 项目，它在.NET 平台上支持 Actor 模式，并进一步做了一些有趣的创新。

那接下来我们继续探讨一下，这些语言和框架是如何实现 Actor 机制的，以及需要编译器做什么配合。

Actor 模型的实现

在上一讲研究过协程的实现机制以后，我们现在再分析 Actor 的实现机制时，其实就应该会把握要点了。比如说，我们会去看它的调度机制和内存管理机制等。鉴于 Erlang 算是支持 Actor 的最有名、使用最多的语言，接下来我会以 Erlang 的实现机制带你学习 Actor 机制是如何实现的。

首先，我们知道，肯定要有个调度器，把海量的 Actor 在多个线程上调度。

并发调度机制

那我们需要细究一下：对于 Actor，该如何做调度呢？什么时候把一个 Actor 停下，让另一个 Actor 运行呢？

协程也好，Actor 也好，都是在应用级做调度，而不是像线程那样，在应用完全不知道的情况下，就被操作系统调度了。对于协程，我们是通过一些像 yield 这样的特殊语句，触发调度机制。那，Actor 在什么时候调度比较好呢？

前面我们也讲过了，Actor 的运行规律，是每次从邮箱取一条消息并进行处理。那么，我们自然会想到，一个可选的调度时机，就是让 Actor 每处理完一条消息，就暂停一下，让别的 Actor 有机会运行。当然，如果处理一条消息所花费的时间太短，比如有的消息是可以被忽略的，那么处理多条消息，累积到一定时间再去调度也行。

了解了调度时机，我们再挑战第二个比较难的话题：如果处理一条消息就要花费很长时间怎么办呢？能否实现抢占式的调度呢，就像 Goroutine 那样？

当然可以，但这个时候就肯定需要编译器和运行时的配合了。

Erlang 的运行机制，是基于一个寄存器机解释执行。这使得调度器可以在合适的时机，去停下某个 Actor 的运行，调度其他 Actor 过来运行。

Erlang 做抢占式调度的机制是对 Reduction 做计数，Reduction 可以看作是占时不长的一小块工作量。如果某个 Actor 运行了比较多的 Reduction，那就可以对它做调度，从而提供了软实时的能力（具体可以参考这篇文章）。

在比较新的版本中，Erlang 也加入了编译成本地代码的特性，那么在生成的本地代码中，也需要编译器加入对 Reduction 计数的代码，这就有点像 Goroutine 了。

这也是 Erlang 和 Scala/Akka 的区别。Akka 没有得到编译器和 JVM 在底层的支持，也就没办法实现抢占式的调度。这有可能让某些特别耗时的 Actor 影响了其他 Actor，使得系统的响应时间不稳定。

最后一个涉及调度的话题，是 I/O 与调度的关系。这个关系如果处理得不好，那么对系统整体的性能影响会很大。

通常我们编写 I/O 功能时，会采用同步编程模式来获取数据。这个时候，操作系统会阻塞当前的线程，直到成功获取了数据以后，才可以继续执行。

getSomeData();    //操作系统会阻塞住线程，直到获得了数据。
do something else //继续执行

采用这种模式开发一个服务端程序，会导致大量线程被阻塞住，等待 I/O 的结果。由于每个线程都需要不少的内存，并且线程切换的成本也比较高，因此就导致一台服务器能够服务的客户端数量大大降低。如果这时候，你在运行时查看服务程序的状态，就会发现大量线程在等待，CPU 利用率也不高，而新的客户端又连接不上来，造成服务器资源的浪费。

并且，如果采用协程等应用级的并发机制，一个线程被阻塞以后，排在这个线程上的其他协程也只能等待，从而导致服务响应时间变得不可靠，有时快，有时慢。我们在前一讲了解过 Goroutine 的调度器。它在遇到这种情况的时候，就会把这条线程上的其他 Goroutine 挪到没被阻塞的线程上，从而尽快得到运行机会。

由于阻塞式 I/O 的缺点，现在很多语言也提供了非阻塞 I/O 的机制。在这种机制下，程序在做 I/O 请求的时候并不能马上获得数据。当操作系统准备好数据以后，应用程序可以通过轮询或被回调的方式获取数据。Node.js 就是采用这种 I/O 模式的典型代表。

上一讲提到的 C++ 协程库 libco，也把非阻塞的网络通讯机制和协程机制做了一个很好的整合，大大增加了系统的整体性能。

而 Erlang 在很早以前就解决了这个问题。在 Erlang 的最底层，所有的 I/O 都是用事件驱动的方式来实现的。系统收到了一块数据，就调用应用来处理，整个过程都是非阻塞的。

说完了并发调度机制，我们再来看看运行时的另一个重要特征，内存管理机制。

内存管理机制

内存管理机制要考虑栈、堆都怎么设计，以及垃圾收集机制等内容。

图 3：Erlang 的内存模型

首先说栈。每个 Actor 也需要有自己的栈空间，在执行 Actor 里面的逻辑的时候，用于保存本地变量。这跟上一节讲过的 Stateful 的协程很像。

再来看看堆。Erlang 的堆与其他语言有很大的区别，它的每个 Actor 都有自己的堆空间，而不是像其他编程模型那样，不同的线程共享堆空间。这也很容易理解，因为 Actor 模型的特点，就是并发的程序之间没有共享的内存，所以当然也就不需要共享的堆了。

再进一步，由于每个 Actor 都有自己的堆，因此会给垃圾收集带来很大的便利：

因为整个程序划分成了很多个 Actor，每个 Actor 都有自己的堆，所以每个 Actor 的垃圾都比较少，不用一次回收整个应用的垃圾，所以回收速度会很快。

由于没有共享内存，所以垃圾收集器不需要停下整个应用，而只需要停下被收集的 Actor。这就避免了“停下整个世界（STW）”问题，而这个问题是 Java、Go 等语言面临的重大技术挑战。

如果一个 Actor 的生命周期结束，那么它占用的内存会被马上释放掉。这意味着，对于有些生命周期比较短的 Actor 来说，可能压根儿都不需要做垃圾收集。

好了，基于 Erlang，我们学习了 Actor 的运行时机制的两个重要特征：一是并发调度机制，二是内存管理机制。那么，与此相配合，需要编译器做什么工作呢？

编译器的配合工作

我们说过，Erlang 首先是解释执行的，是用一个寄存器机来运行字节码。那么，编译器的任务，就是生成正确的字节码。

之前我们已经分别研究过 Graal、Python 和 V8 Ignition 的字节码了。我们知道，字节码的设计很大程度上体现了语言的设计特点，体现了与运行时的交互过程。Erlang 的字节码设计当然也是如此。

比如，针对消息的发送和接收，它专门提供了 send 指令和 receive 指令，这体现了 Erlang 的并发特征。再比如，Erlang 还提供了与内存管理有关的指令，比如分配一个新的栈桢等，体现了 Erlang 在内存管理上的特点。

不过，我们知道，仅仅以字节码的方式解释执行，不能满足计算密集型的需求。所以，Erlang 也正在努力提供编译成机器码运行的特性，这也需要编译器的支持。那你可以想象出，生成的机器码，一定也会跟运行时配合，来实现 Erlang 特有的并发机制和内存管理机制。

课程小结

今天这一讲，我们介绍了另一种并发模型：Actor 模型。Actor 模型的特点，是避免在并发的程序之间共享任何信息，从而程序就不需要使用锁机制来保证数据的一致性。但是，采用 Actor 机制也会因为数据拷贝导致更大的开销，并且你需要习惯异步的编程风格。

Erlang 是实现 Actor 机制的典型代表。它被称为面向并发的编程语言，并且能够提供很高的可靠性。这都源于它善用了 Actor 的特点：由 Actor 构成的系统更像一个生命体一般的复杂系统。

在实现 Actor 模型的时候，你要在运行时里实现独特的调度机制和内存管理机制，这些也需要编译器的支持。

本讲的思维导图我也放在了下面，供你参考：

好了，今天这一讲加上第 33和34 讲，我们用了三讲，介绍了不同计算机语言是如何实现并发机制的。不难看出，并发机制确实是计算机语言设计中的一个重点。不同的并发机制，会非常深刻地影响计算机语言的运行时的实现，以及所采用的编译技术。

一课一思

你是否也曾经采用过消息传递的机制，来实现多个系统或者模块之间的调度？你从中获得了什么经验呢？欢迎你和我分享。

参考资料

Carl Hewitt 关于 Actor 的论文

微软Orleans 项目介绍

介绍 Erlang 虚拟机原理的在线电子书

介绍 Erlang 字节码的文章

36 | 高级特性（一）：揭秘元编程的实现机制

你好，我是宫文学。

作为一名技术人员，我想你肯定知道什么是编程，那你有没有听说过“元编程（Meta-Programming）”这个概念呢？

元编程是计算机语言提供的一项重要能力。这么说吧，如果你要编写一些比较厉害的程序，像是 Java 世界里的 Spring、Hibernate 这样的库，以及 C++ 的 STL 库等这样级别的程序，也就是那些通用性很强、功能强大的库，元编程功能通常会给予你巨大的帮助。

我还可以从另一个角度来评价元编程功能。那就是善用计算机语言的元编程功能，某种意义上能让你修改这门语言，让它更满足你的个性化需求，为你量身打造！

是不是觉得元编程还挺有意思的？今天这一讲，我就带你来理解元编程的原理，并一起探讨如何用编译技术来支持元编程功能的实现。

首先，我们需要透彻地了解一下什么是元编程。

什么是元编程（Meta-Programming）？

元编程是一种把程序当做数据来处理的技术。因此，采用元编程技术，你可以把一个程序变换成另一个程序。

图 1：元编程处理的对象是程序

那你可能要问了，既然把程序作为处理对象的技术就是元编程技术，那么编译器不就是把程序作为处理对象的吗？经过处理，编译器会把源代码转换成目标代码。类似的还有对源代码的静态分析工具、代码生成工具等，都算是采用了元编程技术。

不过，我们在计算机语言里说的元编程技术，通常是指用这门语言本身提供的功能，就能处理它自己的程序。

比如说，在 C 语言中，你可以用宏功能。经过 C 语言的预处理器处理以后，那些宏就被转换成了另外的代码。下面的 MUL 宏，用起来像一个函数，但其实它只是做了一些字符串的替换工作。它可以说是最原始的元编程功能了。你在阅读像 Python 和 Julia 的编译器时，就会发现有不少地方采用了宏的功能，能让代码更简洁、可读性更好。

#define MUL(a,b) (a*b)
MUL(2,3)   //预处理后变成(2*3)

再拿 Java 语言举个例子。Java 语言对元编程提供了多重支持，其中之一是注解功能。我们在解析Java 编译器的时候已经发现，Java 编译器会把所编译的程序表示成一个对象模型。而注解程序可以通过这个对象模型访问被注解的程序，并进行一些处理，比如生成新的程序。所以，这也是把程序作为数据来处理。

除了注解以外，Java 还提供了反射机制。通过反射机制，Java 程序可以在运行时获取某个类有哪些方法、哪些属性等信息，并可以动态地运行该程序。你看，这同样是把程序作为数据来处理。

像 Python 和 JavaScript 这样的脚本语言，其元编程能力就更强了。比如说，你用程序可以很容易地查询出某个对象都有哪些属性和方法，甚至可以给它们添加新的属性和方法。换句话说，你可以很容易地把程序作为数据进行各种变换，从而轻松地实现一些灵活的功能。这种灵活性，是很多程序员特别喜欢 Python 和 JavaScript 这样的语言的原因。

图 2：各种不同的元编程技术起作用的时机

好了，到现在为止，你已经了解了元编程的基本特征：把程序当做数据去处理。接下来，我再带你更深入地了解一下元编程，并把不同的元编程技术做做分类。

理解 Meta 的含义、层次以及作用

首先，我们来注意一下 Meta 这个词缀的意思。维基百科中的解释是，Meta 来自希腊文，意思是“在……之后（after）”和“超越……（beyond）”。加上这个词缀后，Meta-Somthing 所形成的新概念就会比原来的 Somthing 的概念的抽象度上升一层。

举例来说，Physics 是物理学的意思，表示看得见摸得着的物理现象。而 Metaphysics 就代表超越了物理现象的学问，也就是形而上学。Data 是数据，而 Metadata 是元数据，是指对数据特性的描述，比如它是什么数据类型、取值范围是什么，等等。

还有，一门语言我们叫做 Language，而语法规则（Grammar）是对一门语言的特点的描述，所以语法规则可以看做是 Metalanguage。

其次，在理解了 Meta 的概念以后，我再进一步告诉你，Meta 是可以分层次的。你可以对 Meta 再超越一层、抽象一层，就是 Meta-Meta。理解 Meta 的层次，对于你深入理解元编程的概念非常重要。

拿你很熟悉的关系数据库来举个例子吧，看看不同的 Meta 层次都是什么意思。

首先是 M0 层，也就是关系数据库中的数据。比如一条人员数据，编号是“001”，姓名是“宫文学”等。一个数据库的使用者，从数据库中查出了这条数据，我们说这个人是工作在 M0 层的。

比 M0 抽象一层的是 M1 层，也就是 Metadata，它描述了数据库中表的结构。比如，它定义了一张人员表，并且规定里面有编号、姓名等字段，以及每个字段的数据类型等信息。这样看来，元数据实际上是描述了一个数据模型，所以它也被叫做 Model。一个工程师设计了这个数据库表的结构，我们说这个工程师是工作在 M1 层的。基于该工程师设计的数据库表，你可以保存很多 M0 层的人员数据：张三、李四、王五，等等。

比 M1 再抽象一层的是 M2 层。因为 M1 层可以叫做 Model，所以 M2 层可以叫做 Metamodel，也就是元模型。在这个例子中，Metamodel 描述的是关系数据模型：它是由一张张的表（Table）构成的；而每张表，都是由字段构成的；每个字段，都可以有数据类型、是否可空等信息。发明关系数据模型，以及基于这个模型设计出关系数据库的大师，是工作在 M2 层的。基于关系模型，你可以设计出很多 M1 层的数据库表：人员表、订单表、商品表，等等。

那么有没有比 Metamodel 更抽象的层次呢？有的。这就是 M3 层，叫做 Meta-Metamodel。这一层要解决的问题是，如何去描述关系数据模型和其他的元模型？在 UML 标准中，有一个 MOF（Meta Object Facility）的规范，可以用来描述关系数据库、数据仓库等元模型。它用类、关联、数据类型和包这些基本要素来描述一个元模型。

好，通过关系数据库这个例子，现在你应该理解了不同的 Meta 层次是什么概念。那我们再把这个概念应用到计算机语言领域，也是一样的。

假设你使用一门面向对象的语言写了一个程序。这个程序运行时，在内存里创建了一个 Person 对象。那这个对象属于 M0 层。

而为了创建这个 Person 对象，你需要用程序设计一个 Person 类。从这个意义上来看，我们平常写的程序属于 M1 层，也就是相当于建立了一个模型来描述现实世界。你编写的订票程序就是对真实世界中的购票行为建立了一个模型，而你编写的游戏当然也是建立了一个逼真的游戏模型。

那么，你要如何才能设计一个 Person 类，以及一个完整的程序呢？这就需要用到计算机语言。计算机语言对应着 M2 层。它提供了类、成员变量、方法、数据类型、本地变量等元素，用于设计你的程序。我们对一门计算机语言的词法规则、语法规则和语义规则等方面的描述，就属于 M2 层，也就是一门计算机语言的元模型。而编译器就是工作在 M2 层的程序，它会根据元模型，也就是词法规则、语法规则等，来做程序的翻译工作。

我们在描述词法规则、语法规则的时候，曾经用到产生式、EBNF 这些工具。这些工具是属于 M3 层的。你可以用我们前面说过的一个词，也就是 Metalanguage 来称呼这一层次。

这里我用了一个表格，来给你展示下关系数据模型与 Java 程序中不同的 Meta 层次。

元编程技术的分类

理解了 Meta 层次的概念以后，我们再来总结一下元编程技术都有哪些分类。

第一，元编程可以通过生成语义层对象来生成程序。

当我们操纵 M1 层的程序时，我们通常需要透过 M2 层的对象来完成，比如读取类和方法的定义信息。类和方法就是 M2 层的对象。Java 的注解功能和反射机制，就是通过读取和操纵 M2 层的对象来完成的。

在学习编译原理的过程中，你知道了类、方法这些都是语义层次的概念，编译器保证了编译后的程序在语义上的正确性，所以你可以大胆地使用这些信息，不容易出错。如果你要在运行时动态地调用方法，运行时也会提供一定的检查机制，减少出错的可能性。

第二，元编程可以通过生成 AST 来生成程序。

你同样知道，一个程序也可以用 AST 来表达。所以，我们能不能让程序直接读取、修改和生成 AST 呢？这样对 AST 的操纵，就等价于对程序的操纵。

答案是可以的。所有 Lisp 家族的语言都采用了这种元数据技术，Julia 就是其中之一。Lisp 语言可以用 S 表达式来表示程序。S 表达式是那种括号嵌套括号的数据结构，其实就是一棵 AST。你可以用宏来生成 S 表达式，也就是生成 AST。

不过，让程序直接操作比较底层的数据结构，其代价是可能生成的 AST 不符合语义规则。毕竟，AST 只表达了语法规则。所以，用这种方式做元编程需要小心一些，不要生成错误的程序。同时，这种元编程技术对程序员来说，学习的成本也更高，因为他们要在比较低的概念层次上工作。

第三，元编程可以通过文本字符串来生成程序。

当然，你还可以把程序表达成更加低端的格式，就是一些文本字符串而已。我们前面说过，C 语言的宏，其实就是做字符串的替换。而一些脚本语言，通常也能接受一个文本字符串作为程序来运行，比如 JavaScript 的 eval() 函数就可以接受一个字符串作为参数，然后把字符串作为程序来运行。所以，在 JavaScript 里的一项非常灵活的功能，就是用程序生成一些字符串，然后用 eval() 函数来运行。当然你也能预料到，用越原始的模型来表示程序，出错的可能性就越大。所以有经验的程序员，都会很谨慎地使用类似 eval() 这样的功能。但无论如何，这也确实是一种元编程技术。

第四，元编程可以通过字节码操纵技术来生成字节码。

那么，除了通过生成语义层对象、AST 和文本来生成程序以外，对于 Java 这种能够运行字节码的语言来说，你还可以通过字节码操纵技术来生成字节码。这种技术一般不是由语言本身提供的能力，而是由第三方工具来实现的，典型的就是 Spring。

好，到这里，我们就探讨完了通过元编程技术由程序生成程序的各种方式。下面我们再通过另一个维度来讨论一下元编程技术。这个维度是元编程技术起作用的时机，我们可以据此分为静态元编程和动态元编程。

静态元编程技术只在编译期起作用。比如 C++ 的模板技术和把 Java 注解技术用在编译期的情况（在下面会具体介绍这两种技术）。一旦编译完毕以后，元程序跟普通程序一样，都会变成机器码。

动态元编程技术会在运行期起作用。这方面的例子是 Java 的反射机制。你可以在运行期加载一个类，来查看它的名称、都有哪些方法，然后打印出来。而为了实现这种功能，Java 程序必须在 class 文件里保存这个类的 Model，比如符号表，并通过 M2 层的接口，来查询类的信息。Java 程序能在运行期进行类型判断，也是基于同样的原理。

好，通过上面的介绍，我想你对元编程的概念应该有比较清晰的理解了。那接下来，我们就来看看不同语言具体实现元编程的方式，并且一起探讨下在这个过程中应该如何运用编译技术。

不同语言的元编程技术

我们讨论的语言包括几大类，首先是 Java，接着是 Python 和 JavaScript 这样的脚本语言，然后是 Julia 这样的 Lisp 语言，最后是 C++ 的模板技术等一些很值得探讨的元编程技术。

Java 的元编程技术

在分析Java 的编译器的时候，我们已经解析了它是如何处理注解的，注解就是一种元编程技术。在我们举的例子中，注解是在编译期就被处理掉了。

@Retention(RetentionPolicy.SOURCE)  //注解用于编译期处理
@Target(ElementType.TYPE)           //注解是针对类型的
public @interface HelloWorld {
}

当时我们写了一个简单的注解处理程序，这个程序，能够获取被注解的代码的元数据（M1 层的信息），比如类名称、方法名称等。这些元数据是由编译器提供的。然后，注解处理程序会基于这些元数据生成一个新的 Java 源代码，紧接着该源代码就会被编译器发现并编译掉。

通过这个分析，你会发现注解处理过程自始至终都借助了编译器提供的能力：先是通过编译器查询被注解的程序的元数据，然后生成的新程序也会被编译器编译掉。所以你能得出一个结论：所谓元编程，某种意义上就是由程序来调用编译器提供的能力。

刚刚我们探究的是在编译期使用元编程技术。那么在运行期，Java 提供了反射机制，来动态地获取程序的元数据，并操纵程序的执行。

举个例子。假设你写了一个简单的 ORM（Object-Relational Mapping）程序，能够把 Java 对象自动保存到数据库中。那么你就可以通过反射机制，来获取这个对象都有哪些属性，然后读取这些属性的值，并生成一个正确的 SQL 语句来完成对象的保存动作。比如，对于一个 Person 对象，ORM 程序通过反射机制会得知它有 name 和 country 两个字段，再从对象里读取 name 和字段的值，就会生成类似"Insert into Person (name, age), values(“Richard”, “China”)"这样的 SQL 语句。

从这个例子中，你能看出元编程的强大：只需要写一个通用的程序，就能用于各种不同的类。这些类在你写 ORM 程序的时候，根本不需要提前知道，因为 ORM 程序工作在 M2 层。给你任何一个类，你都能获得它的方法和属性信息。

不过这种反射机制也是有短板的，就是性能比较低。基于反射机制编写的程序的效率，比同样功能的静态编译的程序要低好几倍。所以，如何提升运行期元编程功能的性能，是编译技术研究的一个重点。

OK，接下来我们看看 Python、JavaScript 等脚本语言的元编程技术。

Python、JavaScript 等脚本语言的元编程技术

对于像 Python、JavaScript 和 Ruby 这样的脚本语言，它们实现起元编程技术来就更加简单。

最简单的元编程方式，我们前面也提到过，就是动态生成程序的文本字符串，然后动态编译并执行。这种方式虽然简单粗暴，容易出错，有安全隐患，但在某些特殊场景下还确实很有用。

不过如有可能，我们当然愿意使用更优雅的元编程方式。这几种脚本语言都有几个特点，使得操纵和修改已有程序的步骤会变得特别简单：

第一个特点，就是用程序可以很方便地获取对象的元数据，比如某个对象有什么属性、什么方法，等等。

第二个特点，就是可以很容易地为对象添加属性和方法，从而修改对象。

这些脚本语言做元编程究竟有多么容易呢？我给你举个 Python 语言的例子。

我们在解析 Python 编译器的时候，曾提到过 metaclass（元类）。metaclass 能够替代缺省的 Type 对象，控制一个类创建对象的过程。通过你自己的 metaclass，你可以很容易地为所创建的对象的方法添加修饰，比如输出调试信息这样的 AOP 功能。

所以，很多喜欢 Python、JavaScript 和 Ruby 语言的工程师，很大一部分原因，都是因为这些语言非常容易实现元编程，因此能够实现出很多强大的库。

不过，在灵活的背后，脚本语言的元编程技术通常要付出性能的代价。比如，采用元编程技术，程序经常会用 Decorator 模式对原有的函数或方法做修饰，这样会增加函数调用的层次，以及其他一些额外的开销，从而降低程序的性能。

好，接下来，我们说说 Julia 等类 Lisp 语言的元编程技术。

Julia 等类 Lisp 语言的元编程技术

前面我们已经说过，像 Julia 等类似 Lisp 的语言，它本来就是把程序看做数据的。它的程序结构，本来就是一个嵌套的树状结构，其实跟 AST 没啥区别。因此，只要在语言里提供一种方式，能够生成这些树状结构的数据，就可以很好地实现元编程功能了。

比如，下面的一段示例程序是用 Common Lisp 编写的。你能看出，程序的结构完全是一层层的括号嵌套的结构，每个括号中的第一个单词，都是一个函数名称，后面跟着的是函数参数。这个例子采用了 Lisp 的宏功能，把 pred 替换成合适的函数名称。当替换成 > 时，实现的是求最大值功能；而替换成 < 时，实现的是求最小值功能。

(defmacro maxmin(list pred)           ；定义一个宏
  `(let ((rtn (first ,list)))         ;`后面是作为数据的程序
     (do ((i 1 (1+ i)))
         ((>= i (length ,list)) rtn)
       (when (,pred (nth i ,list) rtn);pred可以被替换成一个具体的函数名
         (setf rtn (nth i ,list))))))

(defun mymax2 (list)  ;定义一个函数，取一个列表的最大值
    (maxmin list >))

(defun mymin2 (list)  ;定义一个函数，取一个列表的最小值。
    (maxmin list <)

这种能够直接操纵 AST 的能力让 Lisp 特别灵活。比如，在 Lisp 语言里，根本没有原生的面向对象编程模型，但你完全可以用它的元编程功能，自己构造一套带有类、属性、方法、继承、多态的编程模型，这就相当于构建了一个新的 M2 层的元模型。通常一个语言的元模型，也就是编程时所能使用的结构，比如是否支持类呀什么的，在设计语言的时候就已经固定了。但 Lisp 的元编程功能竟然能让你自己去定义这些语言特性，这就是一些小众的程序员特别热爱 Lisp 的原因。

C++ 的元编程技术

提到元编程，就不能不提一下 C++ 的模板元编程（Template Metaprogramming）技术，它大大增强了 C++ 的功能。

模板元编程技术属于静态元编程技术，也就是让编译器尽量在编译期做一些计算。这在很多场景中都有用。一个场景，就是提供泛型的支持。比如，List是整型这样的值类型的列表，而 List是 Student 这种自定义类型的列表，你不需要为不同的类型分别开发 List 这样的容器类（在下一讲，我还会对泛型做更多的讲解）。

但模板元编程技术不仅仅可以支持泛型，也就是模板的参数可以不仅仅是类型，还可以是普通的参数。模板引擎可以在编译期利用这些参数做一些计算工作。我们来看看下面这个例子。这个例子定义了一个数据结构，它可以根据你传入的模板参数获得阶乘值。

不过，如果这个参数是一个编译期的常数，那么模板引擎会直接把这个阶乘值计算出来，而不是等到运行期才做这个计算。这样能降低程序在运行时的计算量，同时又保持编程的灵活性。

template<int n>
struct Fact {
  enum { RET = n * Fact<n-1>::RET };  //用一个枚举值代表阶乘的计算结果
};

template<>              //当参数为1时，阶乘值是1
struct Fact<1> {
  enum { RET = 1 };
};

int b = Fact<5>::RET;   //在编译期就计算出阶乘值，为120
int c = Fact<a>::RET;   //在运行期才去计算阶乘

看到这里，利用你学过的编译原理，你能不能猜测出 C++ 模板的实现机制呢？

我们也看到过在编译器里做计算的情况，比如说常数折叠，会在编译期计算出表达式的常数值，不用在运行期再去计算了。而在 C++ 的模板引擎里，把这种编译器的计算能力大大地丰富了。不过，你仍然可以猜测出它的实现机制，它仍然是基于 AST 来做计算，生成新的 AST。在这个过程中，像 Fact<5> 这种情况甚至会被计算出最终的值。C++ 模板引擎支持的计算如此复杂，以至于可以执行递归运算。

课程小结

今天这一讲，我们围绕元编程这个话题做了比较深入的剖析。

元编程，对于我们大多数程序员来说，是一个听上去比较高深的概念。但是，在学过编译原理以后，你会更容易理解元编程技术，因为编译器就是做元编程的软件。而各门语言中的元编程特性，本质上就是对编译器的能力的释放和增强。编译器要获得程序的结构信息，并对它们进行修改、转换，元编程做的是同样的事情。

我们学好编译原理以后，在元编程方面其实拥有巨大的优势。一方面，我们可以更加了解某门语言的元编程机制是如何工作的；另一方面，即使某些语言没有提供原生的元编程功能，或者是元编程功能不够强大，我们也仍然可以自己做一些工具，来实现元编程功能，这就是类似 Spring 这样的工具所做的事情。

本讲中关于 Meta 的层次的概念，是我特别向你推荐的一个思维模型。采用这个模型，你就知道不同的工作，是发生在哪一个抽象层级上。因而你也就能明白，为什么学习编译原理中用到的那些形式语言会觉得更加抽象。因为计算机语言的抽象层级就挺高的了，而用于描述计算机语言的词法和语法规则的语言，当然抽象层级更高。

我把这讲的思维导图也放在了这里，供你复习和参考。

一课一思

我在本讲举了 ORM 的例子。如果用你熟悉的语言来实现 ORM 功能，也就是自动根据对象的类型信息来生成合适的 SQL 语句，你会怎么做？

欢迎分享你的观点，也欢迎你把今天的内容分享给更多的朋友。感谢阅读，我们下一讲再见。

37 | 高级特性（二）：揭秘泛型编程的实现机制

你好，我是宫文学。

对泛型的支持，是现代语言中的一个重要特性。它能有效地降低程序员编程的工作量，避免重复造轮子，写很多雷同的代码。像 C++、Java、Scala、Kotlin、Swift 和 Julia 这些语言都支持泛型。至于 Go 语言，它的开发团队也对泛型技术方案讨论了很久，并可能会在 2021 年的版本中正式支持泛型。可见，泛型真的是成为各种强类型语言的必备特性了。

那么，泛型有哪些特点？在设计和实现上有哪些不同的方案？编译器应该进行什么样的配合呢？今天这一讲，我就带你一起探讨泛型的实现原理，借此加深你对编译原理相关知识点的认知，让你能够在自己的编程中更好地使用泛型技术。

首先，我们来了解一下什么是泛型。

什么是泛型？

在日常编程中，我们经常会遇到一些代码逻辑，它们除了类型不同，其他逻辑是完全一样的。你可以看一下这段示例代码，里面有两个类，其中一个类是保存 Integer 的列表，另一个类是保存 Student 对象的列表。

public class IntegerList{
    List data = new ArrayList();
    public void add(Integer elem){
        data.add(elem);
    }
    public Integer get(int index){
        return (Integer) data.get(index);
    }
}

public class StudentList{
    List data = new ArrayList();
    public void add(Student elem){
        data.add(elem);
    }
    public Student get(int index){
        return (Student) data.get(index);
    }
}

我们都知道，程序员是很不喜欢重复的代码的。像上面这样的代码，如果要为每种类型都重新写一遍，简直会把人逼疯！

泛型的典型用途是针对集合类型，能够更简单地保存各种类型的数据，比如 List、Map 这些。在 Java 语言里，如果用通用的集合类来保存特定类型的对象，就要做很多强制转换工作。而且，我们还要小心地做类型检查。比如：

List strList = new ArrayList();       //字符串列表
strList.add("Richard");
String name = (String)strList.get(i); //类型转换
for (Object obj in strList){
  String str = (String)obj;           //类型转换
  ...
}

strList.add(Integer.valueOf(1));      //类型错误

而 Java 里的泛型功能，就能完全消除这些麻烦工作，让程序更整洁，并且也可以减少出错机会。

List<String> strList = new ArrayList<String>(); //字符串列表
strList.add("Richard");
String name = strList.get(i);                   //类型转换
for (String str in strList){                    //无需类型转换
  ...
}

strList.add(Integer.valueOf(1));                //编译器报错

像示例程序里用到的List<String>，是在常规的类型后面加了一个参数，使得这个列表变成了专门存储字符串的列表。如果你再查看一下 List 和 ArrayList 的源代码，会发现它们比普通的接口和类的声明多了一个类型参数<E>，而这个参数可以用在接口和方法的内部所有需要类型的地方：变量的声明、方法的参数和返回值、类所实现的接口，等等。

public interface List<E> extends Collection<E>{
  E get(int index);
  boolean add(E e);
  ...
}

所以说，泛型就是把类型作为参数，出现在类 / 接口 / 结构体、方法 / 函数和变量的声明中。由于类型是作为参数出现的，因此泛型也被称作参数化类型。

参数化类型还可以用于更复杂的情况。比如，你可以使用 1 个以上的类型参数，像 Map 就可以使用两个类型参数，一个是 key 的类型（K），一个是 value 的类型（V）。

public interface Map<K,V> {
  ...
}

另外，你还可以对类型参数添加约束条件。比如，你可以要求类型参数必须是某个类型的子类，这是指定了上界（Upper Bound）；你还可以要求类型参数必须是某个类型的一个父类，这是指定了下界（Lower Bound）。实际上，从语言设计的角度来看，你可以对参数施加很多可能的约束条件，比如必须是几个类型之一，等等。

基于泛型的程序，由于传入的参数不同，程序会实现不同的功能。这也被叫做一种多态现象，叫做参数化多态（Parametric Polymorphism）。它跟面向对象中的多态一样，都能让我们编写更加通用的程序。

好了，现在我们已经了解了泛型的含义了。那么，它们是如何在语言中实现的呢？需要用到什么编译技术？

泛型的实现

接下来，我们一起来看一看几种有代表性的语言实现泛型的技术，包括 Java、C#、C++ 等。

类型擦除技术

在 Java 里，泛型是通过类型擦除（Type Erasure）技术来实现的。前面在分析Java 编译器时，你就能发现，其实类型参数只存在于编译过程中，用于做类型检查和类型推断。在此之后，这些类型信息就可以被擦除。ArrayList 和 ArrayList对应的字节码是一样的，在运行时没有任何区别。

所以，我们可以说，在 Java 语言里，泛型其实是一种语法糖，有助于减少程序员的编程负担，并能提供额外的类型检查功能。

除了 Java 以外，其他基于 JVM 的语言，比如 Scala 和 Kotlin，其泛型机制基本上都是类型擦除技术。

类型擦除技术的优点是实现起来特别简单。运用我们学过的属性计算、类型检查和推断等相关技术基本就够用了。

不过类型擦除技术也有一定的局限性。

问题之一，是它只能适用于引用类型，也就是对象，而不适用于值类型，也就是 Java 中的基础数据类型（Primitive Type）。比如，你不能声明一个List<int>，来保存单纯的整型数据，你在列表里只能保存对象化的 Integer。而我们学习过 Java 对象的内存模型，知道一个 Integer 对象所占的内存，是一个 int 型基础数据的好几倍，因为对象头要有十几个字节的固定开销。再加上由此引起的对象创建和垃圾收集的性能开销，导致用 Java 的集合对象来保存大量的整型、浮点型等基础数据是非常不划算的。我们在这种情况下，还是要退回到使用数组才行。

问题之二，就是因为类型信息在编译期被擦除了，所以程序无法在运行时使用这些类型信息。比如，在下面的示例代码中，如果你想要根据传入的类型 T 创建一个新实例，就会导致编译错误。

public static <T> void append(ArrayList<T> a) {
  T b= new T(); // 编译错误
  a.add(b);
}

同样，由于在运行期没有类型信息，所以如果要用反射机制来调用程序的时候，我们也没有办法像在编译期那样进行类型检查。所以，你完全可以往一个旨在保存 String 的列表里添加一个 Interger 对象。而缺少类型检查，可能会导致程序在执行过程中出错。

另外，还有一些由于类型擦除而引起的问题。比如，在使用参数化类型的情况下，方法的重载（Overload）会失败。再比如，下面的示例代码中，两个 foo 方法看似参数不同。但如果进行了类型擦除以后，它们就没什么区别，所以是不能共存的。

public void foo(List<Integer> p) { ... }
public void foo(List<Double> p)  { ... }

你要注意，不仅仅是 Java 语言的泛型技术有这样的缺点，其他基于 JVM 实现的语言也有类似的缺点（比如没有办法在运行时使用参数化类型的信息）。这其实是由于 JVM 的限制导致的。为了理解这个问题，我们可以看一下基于.NET 平台的语言 ，比如 C# 所采用的泛型技术。C# 使用的不是类型擦除技术，而是一种叫做具体化（reification）的技术。

具体化技术（Reification）

说起来，C# 语言的设计者，安德斯 · 海尔斯伯格（Anders Hejlsberg），是一位令人尊敬的传奇人物。像我这一代的程序员，差不多都使用过他在 DOS 操作系统上设计的 Pascal 编译器。后来他在此基础上，设计出了 Delphi，也是深受很多人喜爱的一款开发工具。

出于对语言设计者的敬佩，虽然我自己从没用 C# 写过程序，但我从来没有低估过 C# 的技术。在泛型方面，C# 的技术方案成功地避免了 Java 泛型的那些缺点。

C# 语言编译也会形成 IR，然后在.NET 平台上运行。在 C# 语言中，对应于 Java 字节码的 IR 被叫做 IL，是中间语言（Intermediate Language）的缩写。

我们知道了，在 Java 的泛型实现中，编译完毕以后类型信息就会被擦除。而在 C# 生成的 IL 中，则保留了类型参数的类型信息。所以，List<Student>和List<Teacher>是两个完全不同的类型。也因为 IL 保存了类型信息，因此我们可以在运行时使用这些类型信息，比如根据类型参数创建对象；而且如果通过反射机制来运行 C# 程序的话，也会进行类型检查。

还有很重要的一点，就是 C# 的泛型能够支持值类型，比如基础的整型、浮点型数据；再比如，针对List<int>和List<long>，C# 的泛型能够真的生成一份完全不同的可运行的代码。它也不需要把值类型转换成对象，从而导致额外的内存开销和性能开销。

把参数化类型变成实际的类型的过程，是在运行时通过 JIT 技术实现的。这就是具体化（Reification）的含义。把一个参数化的类型，变成一个运行时真实存在的类型，它可以跟非参数化的类型起到完全相同的作用。

不过，为了支持泛型，其实.NET 扩展了 C# 生成的 IL，以便在 IL 里能够记录参数化类型信息。而 JVM 则没有改变它的字节码，从而完全是靠编译器来处理泛型。

好了，现在我们已经见识到了两种不同的泛型实现机制。还有一种泛型实现机制，也是经常被拿来比较的，这就是 C++ 的泛型机制，它的泛型机制依托的是模板元编程技术。

基于元编程技术来支持泛型

在上一讲，我们介绍过 C++ 的模板元编程技术。模板元编程很强大，程序里的很多要素都可以模板化，那么类型其实也可以被模板化。

你已经知道，元编程技术是把程序本身作为处理对象的。采用 C++ 的模板元编程技术，我们实际上是为每一种类型参数都生成了新的程序，编译后生成的目标代码也是不同的。

所以，C++ 的模板技术也能做到 Java 的类型擦除技术所做不到的事情，比如提供对基础数据类型的支持。在 C++ 的标准模板库（STL）中，提供了很多容器类型。它们能像保存对象一样保存像整型、浮点型这样的基础数据类型。

不过使用模板技术来实现泛型也有一些缺点。因为本质上，模板技术有点像宏，它是把程序中某些部分进行替换，来生成新的程序。在这个过程中，它并不会检查针对参数类型执行的某些操作是否是合法的。编译器只会针对生成后的程序做检查，并报错。这个时候，错误信息往往是比较模糊的，不太容易定位。这也是模板元编程技术固有的短板。

究其原因，是模板技术不是单单为了泛型的目的而实现的。不过，如果了解了泛型机制的原理，你会发现，其实可以通过增强 C++ 编译器，来提升它的类型检查能力。甚至，对类型参数指定上界和下界等约束条件，也是可以的。不过这要看 C++ 标准委员会的决定了。

总的来说，C++ 的泛型技术像 Java 的一样，都是在运行期之前就完成了所有的工作，而不像.NET 那样，在运行期针对某个参数化的类型产生具体的本地代码。

好了，了解了泛型的几种实现策略以后，接下来，我们接着讨论一个更深入的话题：把类型参数化以后，对于计算机语言的类型系统有什么挑战？这个问题很重要，因为在语义分析阶段，我们已经知道如何做普通类型的分析和处理。而要处理参数化的类型，我们还必须更加清楚支持参数化以后，类型体系会有什么变化。

泛型对类型系统的增强

在现代语言中，通常会建立一个层次化的类型系统，其中一些类型是另一些类型的子类型。什么是子类型呢？就是在任何一个用到父类型的地方，都可以用其子类型进行替换。比如，Cat 是 Animal 的子类型，在任何用到 Animal 的地方，都可以用 Cat 来代替。

不过，当类型可以带有参数之后，类型之间的关系就变得复杂了。比如说：

Collection<Cat>和List<Cat>是什么关系呢？

List<Animal>和List<Cat>之间又是什么关系呢？

对于第一种情况，其实它们的类型参数是一样的，都是 Cat。而 List 本来是 Collection 的子类型，那么List<Cat>也是Collection<Cat>的子类型，我们永远可以用List<Cat>来替换Collection<Cat>。这种情况比较简单。

但是对于第二种情况，List<Cat>是否是List<Animal>的子类型呢？这个问题就比较难了。不同语言的实现是不一样的。在 Java、Julia 等语言中，List<Cat>和List<Animal>之间没有任何的关系。

在由多个类型复合而形成的类型中（比如泛型），复合类型之间的关系随其中的成员类型的关系而变化的方式，分为不变（Invariance）、协变（Covariance）和逆变（Contravariance）三种情况。理解清楚这三种变化，对于我们理解引入泛型后的类型体系非常重要，这也是编译器进行正确的类型计算的基础。

首先说说不变。在 Java 语言中，List<Animal>和List<Cat>之间并没有什么关系，在下面的示例代码中，如果我们把List<Cat>赋值给List<Animal>，编译器会报错。因此，我们说List<T>基于 T 是不变的。

List<Cat> catList = new ArrayList<>();
List<Animal> animalList = catList;  //报错，不是子类型

那么协变是什么呢？就是复合类型的变化方向，跟成员类型是相同的。我给你举两个在 Java 语言中关于协变的例子。

第一个例子。假设 Animal 有个 reproduce() 方法，也就是繁殖。而 Cat 覆盖（Override）了这个方法，但这个方法的返回值是 Cat 而不是 Animal。因为猫肯定繁殖出的是小猫，而不是其他动物。这样，当我们调用 Cat.reproduce() 方法的时候，就不用对其返回值做强制转换。这个时候，我们说 reproduce() 方法的返回值与它所在类的类型，是协变的，也就是一起变化。

class Animal{
    public abstract Animal reproduce();
}

class Cat extends Animal{
    @Override
    public Cat reproduce() {  //方法的返回值可以是Animal的子类型
        ...
    }
}

第二个例子。在 Java 语言中，数组是协变的。也就是Cat[]其实是Animal[]的子类型，在下面的示例代码中，一个猫的数组可以赋值给一个动物数组。

Cat[] cats = {new Cat(), new Cat()}; //创建Cat数组
Animal[] animals = cats;             //赋值给Animal数组
animals[0] = new Dog();              //修改第一个元素的值
Cat aCat = cats[0];                  //运行时错误

但你接下来会看到，Animal 数组中的值可以被修改为 Dog，这会导致 Cat 数组中的元素类型错误。至于为什么 Java 语言要把数组设计为协变的，以及由此导致的一些问题，我们暂且不管它。我们要问的是，List<T>这样的泛型可以变成协变关系吗？

答案是可以的。我前面也提过，我们可以在类型参数中指定上界。List<Cat>是List<? Extends Animal>的子类型，List<? Extends Animal>的意思，是任何以 Animal 为祖先的子类。我们可以把一个List<Cat>赋值给List<? Extends Animal>。你可以看一下示例代码：

List<Cat> catList = new ArrayList<>();
List<? extends Animal> animalList = catList;  //子类型
catList.add(new Cat());
Animal animal = animalList.get(0);

实际上，不仅仅List<Cat>是List<? extends Animal>的子类型，连List<Animal>也是List<? extends Animal>的子类型。你可以自己测试一下。

我们再来说说逆变。逆变的意思是：虽然 Cat 是 Animal 的子类型，但包含了 Cat 的复合类型，竟然是包含了 Animal 的复合类型的父类型！它们颠倒过来了？

这有点违反直觉。在真实世界里有这样的例子吗？当然有。

比如，假设有两个函数，getWeight<Cat>()函数是返回 Cat 的重量，getWeight<Animal>()函数是返回 Animal 的重量。你知道，从函数式编程的观点，每个函数也都是有类型的。那么这两个函数，谁是谁的子类型呢？

实际上，求 Animal 重量的函数，其实是求 Cat 重量的函数的子类型。怎么说呢？

来假设一下。如果你想用一个 getTotalWeight() 函数，求一群 Cat 的总重量，你会怎么办呢？你可以把求 Cat 重量的函数作为参数传进去，这肯定没问题。但是，你也可以把求 Animal 重量的函数传进去。因为既然它能返回普通动物的重量，那么也一定能返回猫的重量。

//伪代码，求Cat的总重量
getTotalWeight(List<Cat> cats, function fun)

而根据类型理论，如果类型 B 能顶替类型 A 的位置，那么 B 就是 A 的子类型。

所以，getWeigh<Animal>()反倒是getWeight<Cat>()的子类型，这种情况就叫做逆变。

总的来说，加入泛型以后，计算机语言的类型体系变得更加复杂了。我们在编写编译器的时候，一定要弄清楚这些变化关系，这样才能执行正确的类型计算。

那么，在了解了加入泛型以后对类型体系的影响后，我们接着借助 Julia 语言，来进一步验证一下如何进行正确的类型计算。

Julia 中的泛型和类型计算

Julia 设计了一个精巧的类型体系。这个类型体系有着共同的根，也就是 Any。在这个类型层次中，橙色的类型是叶子节点，它们是具体的类型，也就是可以创建具体的实例。而中间层次的节点（蓝色），都是抽象的，主要是用于类型的计算。

Julia的类型体系

你在第 22 讲中，已经了解到了 Julia 做函数编译的特点。在编写函数的时候，你可以根本不用指定参数的类型，编译器会根据传入的参数的实际类型，来编译成相应版本的机器码。另外，你也可以为函数编写多个版本的方法，每个版本的参数采用不同的类型。编译器会根据实际参数的类型，动态分派到不同的版本。而这个动态分派机制，就需要用到类型的计算。

比如说，有一个函数 foo()，定义了三个版本的方法，其参数分别是没有指定类型（也就是 Any）、Real 类型和 Float64 类型。如果参数是 Float64 类型，那它当然会被分派到第三个方法。如果是 Float32 类型，那么就会被分派到第二个方法。如果是一个字符串类型呢，则会被分派到第一个方法。

julia> function foo(x)          #方法1
           ...
       end

julia> function foo(x::Real)    #方法2
           ...
       end

julia> function foo(x::Float64) #方法3
           ...
       end

再进一步，Julia 还支持在定义结构体和函数的时候使用泛型。比如，下面的一个 Point 结构中，坐标 x 和 y 的类型是参数化的。

julia> struct Point{T}
           x::T
           y::T
       end

julia> Point{Float64}
Point{Float64}

julia> Point{Float64} <: Point     #在Julia里，如果一个类型更具体，则<:为真
true

julia> Point{Float64} <: Point{Real} #Invariant
false

julia> p1 = Point(1.0,2.3)   #创建一个Point实例
Point{Float64}(1.0, 2.3)     #自动推断出类型

如果我们再为 foo() 函数添加几个方法，其参数类型分别是 Point 类型、Point{Real}类型和 Point{Float64}类型，那动态分派的算法也必须能够做正确的分派。所以，在这里，我们就必须能够识别出带有参数的类型之间的关系。

julia> function foo(x::Point)          #方法4
           ...
       end

julia> function foo(x::Point{Real})    #方法5
           ...
       end

julia> function foo(x::Point{Float64}) #方法6
           ...
       end

通过以上的示例代码你可以看到，Point{Float64} <: Point，也就是 Point{Float64}是 Point 的子类型。这个关系是有意义的。

Julia 的逻辑是，Point{Float64} 比 Point 更具体，能够在程序里替代 Point。而 Point{Float64} 和 Point{Real}之间是没有什么关系的，虽然 Float64 是 Real 的子类型。这说明，Point{T}基于 T 是不变的（Invariant），这跟 Java 语言的泛型处理是一样的。

所以，在 Julia 编译的时候，如果我们给 foo() 传递一个 Point{Float64}参数，那么应该被分派到方法 6。而如果传递一个 Point{Float32}参数呢？分派算法不会选择方法 5，因为 Point{Float32}不是 Point{Real}的子类型。因此，分配算法会选择方法 4，因为 Point{Float32}是 Point 的子类型。

那么，如何让 Point{T}基于 T 协变呢？这样我们就可以针对 Real 类型写一些通用的算法，让采用 Float32、Float16 等类型的 Point，都按照这个算法去编译了。

答案就是需要指定上界。我们可以把 Point{Real}改为 Point{<:Real}，它是 Point{Float32}、Point{Float16}等的父类型。

好，总结起来，Julia 的泛型和类型计算是很有特点的。泛型提供的参数化多态（Parametric Polymorphism）跟 Julia 原来的方法多态（Method Polymorphism）很好地融合在了一起，让我们能够最大程度地去编写通用的程序。而被泛型增强后的类型体系，也对动态分派算法提出了更高的要求。

课程小结

这一讲，我们学习了泛型这个现代语言中非常重要的特性的实现机制。在实现泛型机制的时候，我们首先必须弄清楚引入泛型以后，对类型体系的影响。你要掌握不变、协变和逆变这三个基本概念和它们的应用场景，从而能够正确地用于类型计算的过程中。

在泛型的具体实现机制上，有类型擦除、具体化和模板元编程等不同的方法。好的实现机制应该有能力同时兼顾值类型和复合类型，同时又便于调试。

按照惯例，我也把本讲的内容总结成了思维导图，供你参考：

一课一思

今天，我想给你留两道思考题，你可以根据你熟悉的语言，选择其一。

如果你对 Java 语言比较熟悉，那么针对 Java 的泛型不支持基础数据类型的问题，你能否想出一种技术方案，来弥补这个短板呢？你思考一下。我在下一讲会借助面向对象的话题，给出一个技术方案。

而如果你对 Go 语言有所了解，那么你对 Go 语言的泛型技术方案会提出什么建议？能否避免已有语言在实现泛型上的短板呢？你也可以参考我在文末给出的 Go 语言泛型方案的草案，来印证你的想法。

欢迎在留言区分享你的观点，也非常欢迎你把今天的内容分享给更多的朋友。

参考资料

Go 语言泛型方案的草案。

Julia 的泛型。

C# 泛型的文档，你可以看看它在运行期是如何支持泛型的，这跟 Java 有很大的不同。

38 | 综合实现（一）：如何实现面向对象编程？

你好，我是宫文学。

从 20 世纪 90 年代起，面向对象编程的范式逐渐成为了主流。目前流行度比较高的几种语言，比如 Java、JavaScript、Go、C++ 和 Python 等，都支持面向对象编程。

那么，为了支持面向对象编程，我们需要在语言的设计上，以及编译器和运行时的实现上，考虑到哪些问题呢？

这一讲，我就带你来探讨一下如何在一门语言里支持面向对象特性。这是一个很综合的话题，会涉及很多的知识点，所以很有助于帮你梳理和贯通与编译原理有关的知识。

那么，我们就先来分析一下，面向对象特性都包括哪些内容。

面向对象语言的特性

日常中，虽然我们经常会使用面向对象的语言，但如果要问，到底什么才是面向对象？我们通常会说得含含糊糊。最常见的情况，就是会拿自己所熟悉的某种语言的面向对象特性，想当然地认为这就是面向对象语言的全部特性。

不过，在我们的课程里，我想从计算机语言设计的角度，带你重新梳理和认识一下面向对象的编程语言，把面向对象按照清晰的逻辑解构，这样也便于讨论它的实现策略。在这个过程中，你可能会对面向对象产生新的认识。

特征 1：对象

面向对象编程语言的核心，是把世界看成了一个个的对象，比如汽车、动物等。这些对象包含了数据和代码。数据被叫做字段或属性，而代码通常又被叫做是方法。

此外，这些对象之间还会有一定的关系。比如，汽车是由轮子、发动机等构成的，这叫做聚合关系。而某个班级会有一个班主任，那么班级和作为班主任的老师之间，会有一种引用关系。

对象之间还可以互相发送消息。比如，司机会“通知”汽车，让它加速或者减速。在面向对象的语言中，这通常是通过方法调用来实现的。但也并不局限于这种方式，比如对象之间还可以通过异步的消息进行互相通讯，不过一般的编程语言都没有原生支持这种通讯方式。我们在讨论Actor 模式的时候，曾经提到过 Actor 之间互相通讯的方式，就有点像对象之间互发消息。

特征 2：类和类型体系

很多面向对象的语言都是基于类（class）的，并且类也是一种自定义的类型。这个类型是对象的模板。而对象呢，则是类的实例。我们还可以再印证一下，前面在探究元编程的实现机制时，学过的 Meta 层次的概念。对象属于 M0 层，而类属于 M1 层，它为对象制定了一个标准，也就是对象中都包含了什么数据和方法。

其实，面向对象的语言并不一定需要类这个概念，这个概念更多是来自于类型理论，而非面向对象的语言一样可以支持类型和子类型。类型的好处主要是针对静态编译的语言的，因为这样就可以通过类型，来限制可以访问的对象属性和方法，从而减少程序的错误。

而有些面向对象的语言，比如 JavaScript 并没有类的概念。也有的像 Python，虽然有类的概念，但你可以随时修改对象的属性和方法。

特征 3：重用–继承（Inheritance）和组合（Composition）

在软件工程里，我们总是希望能重用已有的功能。像 Java、C++ 这样的语言，能够让子类型重用父类型的一些数据和逻辑，这叫做继承。比如 Animal 有 speak() 方法，Cat 是 Animal 的子类，那么 Cat 就可以继承这个 speak() 方法。Cat 也可以重新写一个方法，把父类的方法覆盖掉，让叫声更像猫叫。

不过，并不是所有的面向对象编程语言都喜欢通过继承的方式来实现重用。你在网上可以找到很多文章，都在分析继承模式的缺陷。像 Go 语言，采用的是组合方式来实现重用。在这里，我引用了一篇文章中的例子。在这个例子中，作者首先定义了一个 author 的结构体，并给这个结构体定义了一些方法：

type author struct {    //结构体：author(作者)
    firstName string    //作者的名称
    lastName  string
    bio       string    //作者简介
}

func (a author) fullName() string {   //author的方法：获取全名
    return fmt.Sprintf("%s %s", a.firstName, a.lastName)
}

type post struct {      //结构体：文章
    title     string    //文章标题
    content   string    //文章内容
    author              //文章作者
}

func (p post) details() {  //文章的方法：获取文章的详细内容。
    fmt.Println("Title: ", p.title)
    fmt.Println("Content: ", p.content)
    fmt.Println("Author: ", p.author.fullName())
    fmt.Println("Bio: ", p.author.bio)
}

关于 struct，这里我想再给你强调几个知识点。熟悉 C 语言的同学应该都了解结构体（struct）。有一些比较新的语言，比如 Go、Julia 和 Rust，也喜欢用结构体来作为复合数据类型，而不愿意用 class 这个关键字，而且它们也普遍摒弃了用继承来实现重用的思路。Go 提倡的是组合；而我们上一讲提到的泛型，也开始在重用方面承担了越来越重要的角色，就像在 Julia 语言里那样；Rust 和另外一些语言（如 Scala），则使用了一种叫做 Trait（特征）的技术。

Trait 有点像 Java 和 Go 语言中的接口，它也是一种类型。不过它比接口还多了一些功能，那就是 Trait 里面的方法可以具体地实现。

我们用 Trait 可以替代传统的继承结构，比如，一个 Cat 可以实现一个 Speakable 的 Trait，而不需要从 Animal 那里继承。如果 Animal 还有其他的特征，比如说 reproduce()，繁殖，那也可以用一个 Trait 来代替。这样，Cat 就可以实现多个 Trait。这会让类型体系更加灵活，比如实现 Speakable 的不仅仅有动物，还可以是机器人。

在像 Scala 这样的语言中，Trait 里不仅仅可以有方法，还可以有成员变量；而在 Ruby 语言中，我们把这种带有变量和方法的可重用的单元，叫做 Mixin（混入）。

无论 Trait 还是 Mixin，都是基于组合的原理来实现重用的。而且由于继承、接口、Trait 和 Mixin 都可以看做是实现子类型的方式，因此也都可以支持多态。因为继承、接口、Trait 和 Mixin 一般都有多个具体的实现类，所以在调用相同的方法时，会有不同的功能。

特征 4：封装（Encapsulation）

我们知道，软件工程中的一个原则是信息隐藏，我们通常会称为封装（encapsulation），意思是软件只把外界需要知道的信息和功能暴露出来，而内部具体的实现机制，只有作者才可以修改，并且不会影响到它的使用者。

同样的，实现信息封装其实也不是面向对象才有的概念。有的语言的模块（Module）和包（Package）等，都可以作为封装的单元。

在面向对象的语言里，通常对象的一些方法和属性可以被公共访问的，而另一些方法和属性是内部使用的，其访问是受限的。比如，Java 语言会对可以公共访问的成员加 public 关键字，对只有内部可以访问的成员加 private 关键字。

好了，以上就是我们总结的面向对象语言的特征了。这里你要注意，面向对象编程其实是一个比较宽泛的概念。对象的概念是它的基础，然后语言的设计者再把类型体系、软件重用机制和信息封装机制给体现出来。在这个过程中，不同的设计者会有不同的取舍。所以，希望你不要僵化地理解面向对象的概念。比如，以为面向对象就必须有类，就必须有继承；以为面向对象才导致了多态，等等。这些都是错误的理解。

接下来，我们再来看看各门语言在实现这些面向对象的特征时，都要解决哪些关键技术问题。

如何实现面向对象的特性？

要实现一门面向对象的语言，我们重点要了解三个方面的关键工作：一是编译器在语法和语义处理方面要做哪些工作；二是运行期对象的内存布局的设计；三是在存在多态的情况下，如何实现方法的绑定。

我们首先从编译器在语法和语义处理上所做的工作开始学起。

编译器前端的工作

我们都知道，编译器的前端必须完成与类和对象有关的语法解析、符号表处理、引用消解、类型分析等工作。那么要实现一门面向对象的语言，编译器也需要完成这些工作。

第一，从语法角度来看，语言的设计者要设计与类的声明和使用有关的语法。

比如：

如何声明一个类？毕竟每种语言的风格都不同。

如何声明类的构造方法？

如何声明类与父类、接口、Trait 等的关系？

如何实例化一个对象？像 Java 语言就需要 new 关键字，而 Python 就不需要。

……

也就是说，编译器在语法分析阶段，至少要能够完成上述的语法分析工作。

第二，是要维护符号表，并进行引用消解。

在语义分析阶段，每个类会作为自定义类型被加入到符号表里。这样，在其他引用到该类型的地方，比如用该类型声明了一个变量，或者一个方法的参数或返回值里用到了该类型，编译器就能够做正确的引用消解。

另外，面向对象程序的引用消解还有一个特殊之处。因为父类中的成员变量、方法甚至类型的作用域会延伸到子类，所以编译器要能够在正确的作用域内做引用消解。比如，在一个方法体内，如果发现某个变量既不是本地变量，又不是参数，那么程序就要去找类的成员变量。在当前的类里找不到，那就要到父类中逐级去找。

还有一点，编译器在做引用消解的时候，还可以完成访问权限的检查。我们知道，对象要能够实现信息封装。对于编译器来说，这个功能实现起来很简单。在做引用消解的时候，检查类成员的访问权限就可以了。举个例子，假设你用代码访问了某个私有的成员变量，或者私有的方法，此时程序就可以报错；而在这个类内部的代码中，就可以访问这些私有成员。这样就实现了封装的机制。

第三，要做类型检查和推断。

使用类型系统的信息，在变量赋值、函数调用等地方，会进行类型检查和推断。我们之前学过的关于子类型、泛型等知识，在这里都可以用上。

OK，以上就是编译器前端关于实现面向对象特性的重点工作了，我们接下来看看编译器在运行时的一个设计重点，就是对象的内存布局。

对象的内存布局

在第二个模块，研究几个不同的编译器的时候，我们已经考察过各种编译器在保存对象时所采用的内存布局。像 Java、Python 和 Julia 的对象，一般都有一个固定的对象头。对象头里可以保存各种信息，比如到类定义的指针、与锁有关的标志位、与垃圾收集有关的标志位，等等。

对象头之后，通常就是该类的数据成员。如果存在父类，那么就既要保存父类中的成员变量，也要保存子类中的成员变量。像 Python 这样的语言，对内存的使用比较浪费，通常是用一个内部字典来保存成员变量；但对于 Java 这样的语言，则要尽量节约着用内存。

我举个例子。假设某个 Java 类里有两个成员变量，那么这两个成员变量会根据声明的顺序，排在对象头的后面。如果成员变量是像 Int 这样的基础数据，那么程序就要保存它的值；而如果是 String 等对象类型，那么就要保存一个指向该对象的指针。

在 Java 语言中，当某个类存在父类的情况下，那么父类的数据成员一定要排在前面。

这是为什么呢？我给你举一个例子。在这个例子中，有一个父类 Base，有两个子类分别是 DerivedA 和 DerivedB。

图 1：Base 类有两个子类

如果两个子类分别有一个实例 a 和 b，那么它们的内存布局就是下面的样子：

图 2：两个子类的实例的内存布局

那么你可能要问了，为什么父类的数据成员要放在前面，子类的要放在后面？这也要从编译的角度说起。

我们知道，在生成的汇编代码里，如果要访问一个类的成员变量，其实都是从对象地址加上一个偏移量，来得到成员变量的地址。而这样的代码，针对父类和各种不同的子类的对象，要都能正常运行才行。所以，该成员变量在不同子类的对象中的位置，最好是固定的，这样才便于生成代码。

不过像 C++ 这样的语言，由于它经常被用来编写系统级的程序，所以它不愿意浪费任意一点内存，因此就不存在对象头这样的开销。但是由于 C++ 支持多重继承，所以当某个类存在多个父类的情况下，在内存里安排不同父类的成员变量，以及生成访问它们的正确代码，就要比 Java 复杂一些。

比如下面的示例代码中，c 同时继承了 a 和 b。你可以把对象 obj 的地址分别转换为 a、b 和 c 的指针，并把这个地址打印出来。

class a { int a_; };
class b { int b_; };
class c : public a, public b { };
int main(){
  c obj;
  printf("a=0x%08x, b=0x%08x, c=0x%08x\n", (a*)&obj,(b*)&obj,(c*)&obj);
}

看到这段代码，你发现什么了呢？

你会发现，a 和 c 的指针地址是一样的，而 b 的地址则要大几个字节。这是因为，在内存里程序会先排 a 的字段，再排 b 的字段，最后再排 c 的字段。编译器做指针的类型转换（cast）的时候，要能够计算出指针的正确地址。

图 3：多重继承情况下的内存布局

好，现在你就已经见识到了编译器在运行期针对对象的内存布局的设计了。接下来，我们再来看看针对多态的情况，编译器对实现方法的绑定是怎么做的。

方法的静态绑定和动态绑定

当你的程序调用一个对象的方法的时候，这个方法到底对应着哪个实现，有的时候在编译期就能确定了，比如说当这个方法带有 private、static 或 final 关键字的时候。这样，你在编译期就知道去执行哪段字节码，这被叫做静态绑定（Static Binding），也可以叫做早期绑定（Early Binding）或者静态分派（Static Dispathing）。

另外，对于重载（Overload）的情况，也就是方法名称一样、参数个数或类型等不一样的情况，也是可以在编译期就识别出来的，所以也可以通过静态绑定。

而在存在子类型的情况下，我们到底执行哪段字节码，只有在运行时，根据对象的实际类型才能确定下来。这个时候就叫做动态绑定（Dynamic binding），也可以叫做后期绑定（Late binding）或者动态分派（Dynamic Dispatching）。

动态绑定也是面向对象之父阿伦 · 凯伊（Alan Kay）所提倡的面向对象的特征：绑定时机要尽量地晚。绑定时机晚，意味着你在编程的时候，可以编写尽量通用的代码，也就是代码里使用的是类型树中更靠近树根的类型，这种类型更通用，也就可以让使用这种类型编写的代码能适用于更多的子类型。

那么动态绑定在运行时是怎么实现的呢？对于 Java 语言来说，其实现机制对于每个 JVM 可以是不同的。不过，我们可以参考 C++ 的实现机制，就可以猜测出 JVM 的实现机制。

在 C++ 语言中，动态绑定是通过一个 vtable 的数据结构来实现的。vtable 是 Virtual Method Table 或 Virtual Table 的简称。在 C++ 里，如果你想让基类中的某个方法可以被子类覆盖（Override），那么你在声明该方法的时候就要带上 virtual 关键字。带有虚方法的类及其子类的对象实例，都带有一个指针，指向一个表格，这也就是 vtable。

vtable 里保存的是什么呢？是每个虚方法的入口地址。我们来看一个例子，这个例子中有 Base、DerivedA 和 DerivedB 三个类：

图 4：vtable 和动态绑定的原理

在对象 a 和 b 的 vtable 中，各个方法的指针都会指向这个对象实际调用的代码的入口地址。

那么编译器的工作就简单了。因为它可以基于对象的地址，得到 vtable 的地址，不用去管这个对象是哪个子类的实例。

然后，编译器只需要知道，当前调用的方法在 vtable 中的索引值就行了，这个索引值对于不同的子类的实例，也都是一样的，但是具体指向的代码地址可能会不同。这种调用当然需要比静态绑定多用几条指令，因为对于静态绑定而言，只需要跳转到某个特定的方法的代码地址就行了，不需要通过 vtable 这个中间数据结构。不过，这种代价是值得的，因为它支持了面向对象中基于子类型的多态，从而可以让你编写更具通用性的代码。

图 4 中，我展示的只是一个示意结构。实际上，vtable 中还包含了一些其他信息，比如能够在运行时确定对象类型的信息，这些信息也可以用于在运行时进行指针的强制转换。

上面的例子是单一继承的情况。对于多重继承，还会有多个 vptr 指针，指向多个 vtable。你参考一下多重继承下的内存布局和指针转换的情况，应该就可以自行脑补出多重继承下的方法绑定技术。

我们接着回到 Java。Java 语言中调用这种可被重载的方法，生成的是 invokevirtual 指令，你在之前阅读 Java 字节码的时候一定遇到过这个指令。那么我现在可以告诉你，这个 virtual 就是沿用了 C++ 中虚方法的概念。

OK，理解了实现面向对象特性所需要做的一系列重点工作以后，我们来挑战一个难度更高的目标，这也是一个有技术洁癖的人会非常想实现的目标，就是让一切数据都表达为对象。

如何实现一切数据都是对象？

在 Java、C++ 和 Go 这些语言中，基础数据类型和对象类型是分开的。基础数据类型包括整型、浮点型等，它们不像对象类型那样有自己的方法，也没有类之间的继承关系。

这就给我们的编程工作造成了很多不便。比如，针对以上这两类不同数据类型的编程方式是不一致的。在 Java 里，你不能声明一个保存 int 数据的 ArrayList。在这种情况下，你只能使用 Integer 类型。

不过，Java 语言的编译器还是尽量提供了一些便利。举个例子，下面示例的两个语句都是合法的。在需要用到一个 Interger 对象的时候，你可以使用一个基础的 int 型数据；反过来亦然。

Integer b = 2;
int c =  b + 1;

在研究Java 编译器的时候，你已经发现它在语义分析阶段提供了自动装箱（boxing）和拆箱（unboxing）的功能。比如说，如果发现代码里需要的是一个 Interger 对象，而代码里提供的是一个 int 数据，那么程序就自动添加相应的 AST 节点，基于该 int 数据创建一个 Integer 对象，这就叫做装箱功能。反之呢，把 Integer 对象转换成一个 int 数据，就叫做拆箱功能。装箱和拆箱功能属于一种语法糖，它能让编程更方便一些。

说到这里，你可能会想到，既然编译器可以实现自动装箱和拆箱，那么在 Java 语言里，是不是根本就不用提供基础数据类型了，全部数据都用对象表达行不行？这样的话，语言的表达性会更好，我们写起程序来也更简单。

不过，现在要想从头修改 Java 的语法是不可能了。但也有其他基于 JVM 的语言做到了这一点，比如 Scala。在 Scala 里，所有数据都是对象，各个类型构成了一棵有着相同根节点的类型树。对于对象化的整型和浮点型数据，编译器可以把它们直接编译成 JVM 的基础数据类型。

可仅仅这样还不够。在 Java 里面，需要自动装箱和拆箱机制，很大一部分原因是 Java 的泛型机制。那些使用泛型的 List、Map 等集合类，只能保存对象类型，不能保存基础数据类型。但这对于非常大的一个集合来说，用对象保存整型数据要多消耗几倍的内存。那么，我们能否优化集合类的实现，让它们直接保存基础数据，而不是保存一个个整型对象的引用呢？

通过上一讲的学习，我们也知道了，Java 的泛型机制是通过类型擦除来实现的，所以集合类里面只能保存对象引用，无法保存基础数据。既然 JVM 平台缺省的类型擦除技术行不通，那么是否可以对类型参数是值类型的情况下做特殊处理呢？

这是可以做到的。你还记得，C++ 实现泛型采用的是元编程技术。那么在 JVM 平台上，你其实也可以通过元编程技术，针对值类型生成不同的代码，从而避免创建大量的小对象，降低内存占用，同时减少 GC 的开销。Scala 就是这么做的，它会通过注解技术来完成这项任务。如果你对 Scala 的具体实现机制感兴趣，可以参考这篇文章。

课程小结

这一讲我通过面向对象这个话题，带你一起综合性地探讨了语言设计、编译器和运行时的多个知识点。你可以带走这几个关键知识点：

第一，要正确地理解面向对象编程的内涵，知道其实面向对象的语言可以有多种不同的设计选择，体现在类型体系、重用机制和信息封装等多个方面。对于不同的设计选择，你要都能够把它们解构，并对应到编译期和运行时的设计上。

第二，面向对象的各种特性，大多都是要在语义分析阶段进行检查和验证。

第三，对于静态编译的面向对象语言来说，理解其内存布局是关键。编译期要保证能够正确地访问对象的属性，并且巧妙地实现方法的动态绑定。

第四，如有可能，尽量让一切数据都表达为对象。让编译器完成自动装箱和拆箱的工作。

按照惯例，我把这节课的核心内容整理成了思维导图，供你参考和回顾知识点。

一课一思

有人曾经在技术活动上问 Java 语言之父詹姆斯 · 高斯林（James Gosling），如果重新设计 Java 语言，他会怎么做？他回答说，他会去掉 class，也就是会取消类的继承机制。那么，对于你熟悉的面向对象语言，如果有机会重新设计的话，你会怎么建议？为什么？欢迎分享你的观点。

参考资料

介绍 Trait 机制的论文。

在类型参数是值类型的情况下，Scala 以特殊的方式做实例化的文章。

39 | 综合实现（二）：如何实现函数式编程？

你好，我是宫文学。

近些年，函数式编程正在复兴。除了一些纯函数式编程语言，比如 Lisp、Clojure、Erlang 等，众多的主流编程语言，如 Python、JavaScript、Go 甚至 Java，它们都有对函数式编程的支持。

你应该会发现，现在人们对于函数式编程的讨论有很多，比如争论函数式编程和面向对象编程到底哪个更强，在语言里提供混合的编程模式到底对不对等等。

这些论战一时半会儿很难停息。不过我们的这一讲，不会涉及这些有争议的话题，而是试图从编译技术的角度，来探讨如何支持函数式编程，包括如何让函数作为一等公民、如何针对函数式编程的特点做优化、如何处理不变性，等等。通过函数式编程这个综合的主题，我们也再一次看看，如何在实现一门语言时综合运用编译原理的各种知识点，同时在这个探究的过程中，也会加深你对函数式编程语言的理解。

好，我们先来简单了解一下函数式编程的特点。

函数式编程的特点

我想，你心里可能多多少少都会有一点疑问，为什么函数式编程开始变得流行了呢？为什么我在开篇的时候，说函数式编程正在“复兴”，而没有说正在兴起？为什么围绕函数式编程会有那么多的争论？

要回答这几个问题，我会建议你先去了解一点历史。

我们都知道，计算机发展历史上有一个重要的人物是阿兰 · 图灵（Alan Turing）。他在 1936 年提出了一种叫做图灵机的抽象模型，用来表达所有的计算。图灵机有一个无限长的纸带，还有一个读写头，能够读写数据并根据规则左右移动。这种计算过程跟我们在现代的计算机中，用一条条指令驱动计算机运行的方式很相似。

不过，计算模型其实不仅仅可以用图灵机来表达。早在图灵机出现之前，阿隆佐 · 邱奇（Alonzo Church）就提出了一套 Lambda 演算的模型。并且，计算机科学领域中的很多人，其实都认为用 Lambda 演算来分析可计算性、计算复杂性，以及用来编程，会比采用图灵机模型更加简洁。而 Lambda 演算，就是函数式编程的数学基础。

补充：实际上，邱奇是图灵的导师。当年图灵发表他的论文的时候，编辑看不懂，所以找邱奇帮忙，并推荐图灵成为他的学生，图灵机这个词也是邱奇起的。所以师生二人，对计算机科学的发展都做出了很大的贡献。

因为有 Lambda 演算的数学背景，所以函数式编程范式的历史很早。上世纪 50 年代出现的 Lisp 语言，就是函数式编程语言。Lisp 的发明人约翰 · 麦卡锡（John McCarthy）博士，是一位数学博士。所以你用 Lisp 语言和其他函数式编程语言的时候，都会感觉到有一种数学思维的味道。

也正因如此，与函数式编程有关的理论和术语其实是有点抽象的，比如函子（Functor）、单子（Monad）、柯里化（Currying）等。当然，对它们的深入研究不是我们这门课的任务。这里我想带你先绕过这些理论和术语，从我们日常的编程经验出发，来回顾一下函数式编程的特点，反倒更容易一些。

我前面也说过，目前流行的很多语言，虽然不是纯粹的函数式编程语言，但多多少少都提供了对函数式编程的一些支持，比如 JavaScript、Python 和 Go 等。就连 Java 语言，也在 Java8 中加入了对函数式编程的支持，很多同学可能已经尝试过了。

我们使用函数式编程最多的场景，恐怕是对集合的处理了。举个例子，假设你有一个 JavaScript 的数组 a，你想基于这个数组计算出另一个数组 b，其中 b 的每个元素是 a 中对应元素的平方。如果用普通的方式写程序，你可能会用一个循环语句，遍历数组 a，然后针对每个数组元素做处理：

var b = [];
for (var i = 0; i< a.length; i++){  //遍历数组a
    b.push(a[i]*a[i]);              //把计算结果加到数组b中
}

不过你也可以采用更简单的实现方法。

这次我们使用了 map 方法，并给它传了一个回调函数。map 方法会针对数组的每个元素执行这个回调函数，并把计算结果组合成一个新的数组。

function sq(item){            //计算平方值的函数
    return item*item;
}
var b = a.map(sq);            //把函数作为参数传递

它还可以写成一种更简化的方式，也就是 Lambda 表达式的格式：

var b = a.map(item=>item*item);

通过这个简单的例子，我们可以体会出函数式编程的几个特点：

1. 函数作为一等公民

也就是说，函数可以像一个数值一样，被赋给变量，也可以作为函数参数。如果一个函数能够接受其他函数作为参数，或者能够把一个函数作为返回值，那么它就是高阶函数。像示例程序中的 map 就是高阶函数。

那函数式编程语言的优势来自于哪里呢？就在于它可以像数学那样使用函数和变量，这会让软件的结构变得特别简单、清晰，运行结果可预测，不容易出错。

根据这个特点，我们先来看看函数式编程语言中的函数，跟其他编程语言中的函数有什么不同。

2. 纯函数（Pure Function）

在函数式编程里面，有一个概念叫做纯函数。纯函数是这样一种函数，即相同的输入，永远会得到相同的输出。

其实你对纯函数应该并不陌生。你在中学时学到的函数，就是纯函数。比如对于 f(x)=ax+b，对于同样的 x，所得到的函数值肯定是一样的。所以说，纯函数不应该算是个新概念，而是可以回归到你在学习计算机语言之前的那个旧概念。

在 C 语言、Java 等语言当中，由于函数或方法里面可以引用外面的变量，比如全局变量、对象的成员变量，使得其返回值与这些变量有关。因此，如果有其他软件模块修改了这些变量的值，那么该函数或方法的返回值也会受到影响。这就会让多个模块之间基于共享的变量耦合在一起，这种耦合也使得软件模块的依赖关系变得复杂、隐秘，容易出错，牵一发而动全身。这也是像面向对象语言这些命令式编程语言最令人诟病的一点。

而对于纯函数来说，它不依赖外部的变量，这个叫做引用透明（Reference Transparency）。纯函数的这种“靠谱”、可预测的特征，就给我们的编程工作带来了很多的好处。

举个例子。既然函数的值只依赖输入，那么就跟调用时间无关了。假设有一个函数式 g(f(x))，如果按照传统的求值习惯，我们应该先把 f(x) 的值求出来，再传递给 g()。但如果 f(x) 是纯函数，那么早求值和晚求值其实是无所谓的，所以我们可以延迟求值（Lazy Evaluation）。

延迟求值有很大的好处。比如，在下面的伪代码中，unless 是一个函数，f(x) 是传给它的一个参数。在函数式编程语言中，只有当 condition 为真时，才去实际对 f(x) 求值。这实际上就降低了工作量。

//在满足条件时，执行f(x)
unless(condition, f(x));

//伪代码
int unless(bool condition, f(x)){
  if (condition)
    return f(x);
}

再回到纯函数。我说纯函数的输出仅依赖输入，有一点需要说明，就是函数只有返回值这一种输出，没有其他的输出。换句话说，纯函数没有副作用（Side Effect）。

什么是副作用呢？简单地说，就是函数在运行过程中影响了外界环境。比如，修改了一个全局变量或者是对象的属性、往文件里写入内容、往屏幕上打印一行字、往数据库插入一条记录、做了一次网络请求，等等。也就是说，纯函数要求程序除了计算，其他的事情都不要做。

如果函数有副作用的话，那么我们前面说的时间无关性就被破坏了。比如说，原来 a 函数是在屏幕上打印“欢迎：”，b 函数是屏幕输出你的名字，最后形成“欢迎：XXX”。那么 a 和 b 的前后顺序就不能颠倒。

你可能会说，一个有用的程序，哪能没有副作用呀。你说得对。在函数式编程里，程序会尽量把产生副作用的函数放在调用的外层，而完成内部功能的大部分函数，都保持是纯函数。比如，最外层的函数接受网络请求，并对客户端返回结果，它是有副作用的。而程序所使用的其他函数，都没有副作用。

纯函数的功能是如此地简单纯粹，以至于它还能继续带来一些好处。比如说，像 Erlang 这样的语言，可以在运行时给某些函数升级，而不用重启整个系统。为什么呢？因为这些升级后的函数，针对相同的输入，程序得到的结果是一样的，那么对这个函数的使用者来说，就没有任何影响。这也是用 Erlang 写的系统会具有很高的可靠性的原因之一。

不过，函数式编程语言里使用的也不全都是纯函数，比如有的函数要做一些 IO 操作。另外，闭包，是函数引用了词法作用域中的自由变量而引起的，所以也不是纯函数。

总结起来，在函数式编程中，会希望函数像数学中的函数那样纯粹，即不依赖外部（引用透明），也不改变外部（无副作用），从而带来计算时间、运行时替换等灵活性的优势。

好，说完了函数的不同，我们再来看看函数式编程语言里使用变量跟其他语言的不同。

3. 不变性（Immutability）

我们都知道，在数学里面，当我们用到 x 和 y 这样的变量的时候，它所代表的值在计算过程中是不变的。

没错，这也是函数式编程的一个重要原则，不变性。它的意思是，程序会根据需要来创建对象并使用它们，但不会去修改对象的状态。如果有需要修改对象状态的情况，那么去创建一个新对象就好了。

在前面的示例程序中，map 函数返回了一个新的数组，而原来的数组保持不变。这就体现了不变性的特点。

不变性也会带来巨大的好处。比如说，由于函数不会修改对象的状态，所以就不存在并发程序中的竞争情况，进而也就不需要采用锁的机制。所以说，函数式编程更适合编写并发程序。这个优势，也是导致这几年函数式编程复兴的重要原因。

好，那么最后，我们再来注意一下函数式编程语言在编程风格上的不同。

4. 声明式（Declarative）的编程风格

在计算机语言中，实现编程的方式主要有几种。

第一种实现方式，我们会一步步告诉计算机该去怎么做计算：循环访问 a 的元素，计算元素的平方值，并加到 b 中。这种编程风格叫做命令式（Imperative）编程，即命令计算机按照你要求的步骤去做。命令式编程风格植根于现代计算机的结构，因为机器指令本质上就是命令式的。这也是图灵机模型的特点。

而第二种实现方式叫做声明式（Declarative）编程。这种编程风格，会要求计算机给出你想要的结果，而不关心过程。比如在前面的示例程序中，你关心的是对数组中的每个元素计算出平方值。至于具体的处理步骤，是对数组 a 的元素顺序计算，还是倒序计算，你并不关心。

声明式编程风格的另一个体现，是递归函数的大量使用。这是因为我们描述一个计算逻辑的时候，用递归的方式表达通常会更简洁。

举个例子。你可能知道，斐波纳契（Fibonacci）数列中的每个数，是前两个数字的和。这个表达方式就是递归式的。写成公式就是：Fibonacci(n)=Fibonacci(n-1)+Fibonacci(n-2)。这个公式与我们用自然语言的表达完全同构，也更容易理解。

我把计算斐波纳契数列的程序用 Erlang 这种函数式语言来写一下，你可以进一步体会到声明式编程的那种简洁和直观的特点：

%% 计算斐波那契的第N个元素
fibo(1) -> 1;                      %%第一个元素是1
fibo(2) -> 1;                      %%第二个元素也是1
fibo(N) -> fibo(N-1) + fibo(N-2).  %%递归

好了，现在我们已经了解了函数式编程的一些关键特征。它的总体思想呢，就是像数学那样去使用函数和值，使可变动部分最小化，让软件的结构变得简单、可预测，从而获得支持并发、更简洁的表达等优势。那么下面，我们就一起来看看如何结合编译原理的相关知识点，来实现函数式编程的这些特征。

函数式编程语言的编译和实现

为了实现函数式语言，我们在编译期和运行时都要做很多工作。比如，要在编译器前端做分析和各种语义的检查； 要以合适的方式在程序内部表示一个函数；要针对函数式编程的特点做特别的优化，等等。接下来我们就从编译器的前端工作开始学起。

编译器前端的工作

函数式编程语言，在编译器的前端也一样要做很多的语法分析和语义分析工作。

你应该知道，语言的设计者，需要设计出如何声明一个函数。像是 JavaScript 语言会用 function 关键字，Go 语言用 func 关键字，Rust 语言用的是 fn 关键字，而 C 语言根本不需要一个关键字来标识一个函数的定义；另外，如何声明函数的参数和返回值也会使用不同的语法。编译器都要能够正确地识别出来。

语义分析的工作则更多，包括：

符号表和引用消解：当声明一个函数时，要把它加入到符号表。而当程序中用到某个函数的时候，要找到该函数的声明。

类型检查和推导：既然函数可以被当做一个值使用，那么它一定也是有类型的，也要进行类型检查和推导。比如，在程序的某个地方只能接受返回值为 int，有一个参数为 String 的函数，那么就需要被使用的函数是否满足这个要求。关于函数的类型，一会儿我还会展开讲解。

语法糖处理：在函数式编程中经常会使用一些语法糖。最常见的语法糖就是 Lambda 表达式，Lambda 表达式可以简化匿名函数的书写。比如，前面 JavaScript 的示例代码中，对数组元素求平方的函数可以写成一个 Lambda 表达式，从而把原来的代码简化成了一行：

var d = a.map(item=>item*item);   //括号中是一个lambda表达式

在这个示例程序中，=> 左边的是匿名函数的参数，右边的是一个表达式，这个表达式的计算结果就是匿名函数的返回值。你看，通过一个 Lambda 表达式，代替了传统的函数声明，代码也变得更简洁了。

OK，因为在编译器前端还要对函数做类型分析，所以我们再来探究一下函数的类型是怎么一回事。

把函数纳入类型系统

这里我要先提一个问题，就是在函数式编程语言里，既然它能够把函数当做一个值一样去看待，那么也应该有相应的类型吧？这就要求语言的类型系统能够把函数包含进来。因此函数式编程语言在编译的时候，也要进行类型检查和类型推断。

不过，我们在谈论类型时，比较熟悉的是值类型（如整型、浮点型、字符型），以及用户自定义的类型（如结构、类这些），如果函数也是一种类型，那跟它们是什么关系呢？如果由你来设计，那么你会怎么设计这个类型体系呢？

在不同的语言里，设计者们是以不同的方式来解决这个问题的。拿 Python 来说，Python 中一切都是对象，函数也不例外。函数对象的 ob_type 字段也被设置了合适的类型对象。这里，你可以再次回味一下，Python 的类型系统设计得是如何精巧。

我们再看看 Scala 的类型系统。上一讲我提出过，Scala 实现了一个很漂亮的类型系统，把值类型和引用类型（也就是自定义类）做了统一。它们都有一个共同的根，就是 Any。由于 Scala 是基于 JVM 的，所以这些类型最后都是以 Java 的类来实现的。

那么函数也不例外。因为 Scala 的函数最多支持 22 个参数，所以 Scala 里有内置的 Function1、Function2…Function22 这些类，作为函数的类型，它们也都是 Any 的子类型。每个 Scala 函数实际上是这些类的实例。

另外，Swift 语言的文档对类型的定义也比较清楚。它以产生式的方式列出了 type 的语法定义。根据该语法，类型可以是函数类型、数组类型、字典类型、元组类型等等，这些都是类型。

Swift语言中对类型的语法定义

并且，它还把所有类型分成了两个大类别：命名类型（Named Type）和复合类型（Compound Type）。

命名类型包括类、结构体、枚举等，它们都有一个名称，比如自定义类的类名就是类型名称。

复合类型则没有名称，它是由多个其他类型组合而成的。函数和元组都属于复合类型。函数的类型是由参数的类型和返回值的类型组合而成的，它们都是编译器对函数类型进行计算的依据。

举例来说，假设一个函数有两个参数，分别是类型 A 和 B，而返回值的类型是 C，那么这个函数的类型可以计为 (A, B)->C。这就是对函数的类型的形式化的表达。

那么进一步，我们如何在编译期里针对函数的类型做类型分析呢？它跟非复合的类型还真不太一样，因为编译器需要检查复合类型中的多个元素。

举个例子。在一个高阶函数 g() 里，能够接收一个函数类型的参数 f(A,B)，要求其类型是 (A, B)->C，而实际提供的函数 f2 的类型是 (A1, B1)->C1，那么你在编译器里如何判断函数的类型是否合法呢？这里的算法要做多步的检查：

第一，f2 也必须有两个参数，这点是符合的。

第二，检查参数的类型。A1 和 B1 必须是跟 A 和 B 相同的类型，或者是它们的父类型，这样 f1 才能正确地给 f2 传递参数。

第三，检查返回值的类型。C1，则必须是 C 的子类型，这样 f1 才能接收 f2 的返回值。

好，说完了编译器的前端工作，我们再来看看函数在语言内部的实现。

函数的内部实现

在函数式编程里，所有一切都围绕着函数。但是在编译完毕以后，函数在运行时中是怎么表示的呢？

就像不同的面向对象的语言，在运行时是以不同的方式表示一个对象的，不同的函数式编程语言，在运行时中去实现一个函数的机制也是不太一样的。

在 Python 中，一切都是对象，所以函数也是一种对象，它是实现了 Callable 协议的对象，能够在后面加上一对括号去调用它。

在 Scala 和 Java 这种基于 JVM 的语言中，函数在 JVM 这个层次没有获得原生支持，因此函数被编译完毕以后，其实会变成 JVM 中的类。

在 Julia、Swift、Go、Rust 这样编译成机器码的语言中，函数基本上就是内存中代码段（或文本段）的一个地址。这个地址在编译后做链接的时候，会变成一个确定的地址值。在运行时，跳转到这个地址就可以执行函数的功能。

补充：再具体一点的话，编译成机器码的函数有什么特点呢？我们再来回顾一下。

首先，函数的调用者要根据调用约定，通过栈或者寄存器设置函数的参数，保护好自己负责保护的寄存器以及返回地址，然后调用函数。

在被调用者的函数体内，通常会分为三个部分。头尾两个部分叫做序曲（prelude）和尾声（epilogue），分别做一些初始化工作和收尾工作。在序曲里会保存原来的栈指针，以及把自己应该保护的寄存器存到栈里、设置新的栈指针等，接着执行函数的主体逻辑。最后，到尾声部分，要根据调用约定把返回值设置到寄存器或栈，恢复所保护的寄存器的值和栈顶指针，接着跳转到返回地址。

返回到调用者以后，会有一些代码恢复被保护起来的寄存器，获取返回值，然后继续执行后面的代码。

这样，把上述整个过程的细节弄清楚了，你就知道如何为函数生成代码了。

最后，我们必须提到一种特殊情况，就是闭包。闭包是纯函数的对立面，它引用了上级作用域中的一些自由变量。闭包在运行时不仅是代码段中的一个函数地址，还必须保存自由变量的值。为了实现闭包的运行时功能，编译器需要生成相应的代码，以便在生成闭包的时候，可以在堆里申请内存来保存自由变量的值。而当该闭包不再被引用了，那么就会像不再被引用的对象一样，成为了内存垃圾，要被垃圾回收机制回收。

好了，到这里你可能会觉得，看上去函数的实现似乎跟命令式语言也没有什么不同。不过，接下来你就会看到不同点了，这就是延迟求值的实现。

延迟求值（Lazy Evaluation）

在命令式语言里，我们对表达式求值，是严格按照顺序对 AST 求值。但对于纯函数来说，由于在任何时候求值结果都是一样的，因此可以进行一定的优化，比如延迟求值（Lazy Evaluation），从而有可能减少计算工作量，或者实现像 unless() 那样的特别的控制结构。

那么针对这种情况，编译器需要做什么处理呢？

我举个例子，对于下面的示例程序（伪代码）：

g(condition, x){
  if (condition)
    return x;
  else return 0;
}

如果我们调用的时候，在 x 参数的位置传入的是另一个函数调用 f(y)，也就是 g(condition, f(y))，那么编译器就会把 g() 的函数体内用到 x 的地方，都转换成对 f(y) 的调用：

  if (condition)
    return f(y);
  else return 0;

这种把对参数的引用替换成对函数调用的技术，叫做换名调用。

不过换名调用有一个缺点，就是 f(y) 有可能会被多次调用，而且每次调用的结果都是一样的。这就产生了浪费。那么这时，编译器就要更聪明一点。

怎么办呢？那就是在第一次调用的时候，记录下它的值。如果下次再调用，则使用第一次调用的结果。这种方式叫做按需调用。

总而言之，纯函数的特征就导致了延迟求值在编译上的不同。而函数式编程另一个重要的特征，不变性，也会对编译和运行过程造成影响。

不变性对编译和运行时的影响

在遵守不变性原则的情况下，对程序的编译也会有很大的不同。

第一，由于函数不会修改对象的状态，所以就不存在并发程序中的竞争情况，进而也就不需要采用锁的机制，编译器也不需要生成与锁有关的代码。Java、JavaScript 等语言中关于参数逃逸的分析，也变得不必要了，因为反正别的线程获得了某个对象或结构体，也不会去修改它的状态。

第二，不变性就意味着，只可能是新的对象引用老的对象，老的对象不可能引用新的对象。这对于垃圾收集算法的意义很大。在分代收集的算法中，如果老对象被新对象引用，那必须等到新对象回收之后老对象才可能被回收，所以函数式编程的程序现在可以更容易做出决定，把老对象放到老一代的区域，从而节省垃圾收集算法的计算量；另外，由于对象不会被改变，因此更容易实现增量收集和并行收集；由于不可能存在循环引用，因此如果采用的是引用计数法的话，就没有必要进行循环引用的检测了。

第三，不变性还意味着，在程序运行过程中可能要产生更多的新对象。在命令式语言中，程序需要对原来的对象修改状态。而函数式编程，只能每次创建一个新对象。所以，垃圾收集算法需要能够尽快地收集掉新对象。

OK，了解了不变性，我们再来看看，针对函数式编程语言的优化算法。其中最重要的就是对递归函数的优化。

对递归函数的优化

虽然命令式的编程语言也会用到递归函数，但函数式编程里对递归函数的使用更加普遍，比如通常会用递归来代替循环。如果要对一个整型数组求和，命令式编程语言会做一个循环，而函数式编程语言则更习惯于用递归的方式表达：sum(a, i) = a[i] + sum(a, i-1)。

按照传统的函数调用的运行方式，对于每一次函数调用，程序都要增加一个栈桢。递归调用一千次，就要增加一千个栈桢。这样的话，程序的栈空间很快就会被耗尽。并且，函数调用的时候，每次都要有一些额外的开销，比如保护寄存器的值、保存返回地址、传递参数等等。

我在第 7 讲的优化算法里，提到过尾调用优化，也就是执行完递归函数后，马上用 return 语句返回的情况。

f(x){
  ....
  return g(...);   //尾调用
}

在尾调用的场景下，上一级函数的栈桢已经没什么用了，程序可以直接复用。函数调用的过程，可以被优化成指令的跳转，不需要那些函数调用的开销。

不过对于递归调用的情况，往往还需要对递归函数返回值做进一步的计算。比如在下面的求阶乘的函数示例中，返回值是 x*fact(x-1)。

//fact.c 求阶乘
int fact(int x){
    if (x == 1)
        return 1;
    else
        return x*fact(x-1);   //对递归值要做进一步的计算
}

对于编译器来说，它可以经过分析，把这种情况转换成一个单纯的尾调用。具体地说，就是它相当于引入了一个临时的递归函数 fact2()，并且用第一个参数 acc 来记录累计值：

int fact(x){
    if (x == 1)
        return 1;
    else
        return fact2(x, x-1);       //调用一个临时的递归函数
}

int fact2(int acc, int x){          //参数acc用来保存累计值
    if (x == 1){
        return acc;
    }
    else{
        return fact2(acc * x, x-1); //一个单纯的尾调用
    }
}

如果我们调用 fact(5)，其实际执行过程就会在 acc 参数中连续地做乘法，从而实现阶乘：

->fact(5)
->fact2(5,4)
->fact2(5*4,3)
->fact2(5*4*3,2)
->fact2(5*4*3*2,1)
->5*4*3*2

你可以观察一下编译器实际生成的汇编程序，看看优化后的成果。如果用“clang -O1 -S -o fact.s fact.c”来编译 fact 函数，就会得到一个汇编代码文件。我对这段代码做了注释，你可以理解下它的逻辑。你可以发现，优化后的函数没有做任何一次递归调用。

_fact:                                  ## @fact
    pushq   %rbp       # 保存栈底指针
    movq    %rsp, %rbp # 把原来的栈顶，设置为新栈桢的栈底
    movl    $1, %eax   # %eax是保存返回值的。这里先设置为1
    cmpl    $1, %edi   # %edi是fact函数的第一个参数，相当于if(x==1)
    je  LBB0_3         # 如果相等，跳转到LBB0_3，就会直接返回1
    movl    $1, %eax   # 设置%eax为1，这里%eax会保存累计值
LBB0_2:
    imull   %edi, %eax # 把参数乘到%eax来
    decl    %edi       # x = x-1
    cmpl    $1, %edi   # x是否等于1？
    jne LBB0_2         # 如果不等，跳到LBB0_2，做连乘
LBB0_3:
    popq    %rbp       # 回复原来的栈底指针
    retq               # 返回

要想完成这种转换，就要求编译器能够基于 IR 分析出其中的递归结构，然后进行代码的变换。

课程小结

这一讲，我们一起讨论了实现函数式编程特性的一些要点。我希望你能记住这些关键知识点：

第一，函数式编程的理论根基，可以追溯到比图灵机更早的 Lambda 演算。要理解函数式编程的特点，你可以回想一下中学时代数学课中的内容。在函数式编程中，函数是一等公民。它通过强调纯函数和不变性，大大降低了程序的复杂度，使软件不容易出错，并且能够更好地支持并发编程。并且，由于采用声明式的编程风格，往往程序可以更简洁，表达性更好。

第二，不同的语言实现函数的机制是不同的。对于编译成机器码的语言来说，函数就是一个指向代码的指针。对于闭包，还需要像面向对象的语言那样，管理它在内存中的生存周期。

第三，函数仍然要纳入类型体系中，编译器要支持类型的检查和推断。

第四，针对函数式编程的特点，编译器可以做一些特别的优化，比如延迟求值、消除与锁有关的分析、对递归的优化等等。

同样，我把这一讲的知识点梳理成了思维导图，供你参考：

一课一思

这节课中我提到，在很多情况下，用函数式编程表达一个计算逻辑会更简洁。那么，你能不能找到这样的一些例子？欢迎分享你的经验。

如果你身边也有对函数式编程感兴趣的朋友，那么也非常欢迎你把这节课分享给 TA。感谢你的阅读，下一讲我们会一起解析华为的方舟编译器，到时候再见！

40 | 成果检验：方舟编译器的优势在哪里？

你好，我是宫文学。到这里，咱们的课程就已经进入尾声了。在这门课程里，通过查看真实的编译器，你应该已经积累了不少对编译器的直观认识。前面我们研究的各种编译器，都是国外的产品或项目。而这一讲呢，我们则要看看一个有中国血统的编译器：方舟编译器。

通过阅读方舟编译器已经公开的代码和文档，在解析它的过程中，你可以检验一下自己的所学，谈谈你对它的认识。比如，跟你了解的其他编译器相比，它有什么特点？先进性如何？你是否有兴趣利用方舟编译器做点实际项目？等等。

不过，到目前为止，由于方舟编译器开源的部分仍然比较有限，所以这一讲我们只根据已经掌握的信息做一些分析。其中涉及两个大的话题，一是对方舟编译器的定位和设计思路的分析，二是对方舟编译器所使用的 Maple IR 的介绍。

好，首先，我借助 Android 对应用开发支持的缺陷，来谈一下为什么方舟编译器是必要的。

Android 的不足

为什么要研发一款自己的编译器？对于一个大的技术生态而言，语言的编译和运行体系非常重要。它处在上层应用和下层硬件之间，直接决定了应用软件能否充分地发挥出硬件的性能。对于移动应用生态而言，我国拥有体量最大的移动用户和领先的移动应用，也有着最大的手机制造量。可是，对于让上层应用和底层硬件得以发挥最大能力的编译器和运行时，我们却缺少话语权。

实际上，我认为 Android 对应用开发的支持并不够好。我猜测，掌控 Android 生态的谷歌公司，对于移动应用开发和手机制造都没有关系到切身利益，因此创新的动力不足。

我之所以说 Android 对应用开发的支持不够好，这其实跟苹果的系统稍加对比就很清楚了。同样的应用，在苹果手机上会运行得更流畅，且消耗的内存也更低。所以 Android 手机只好增加更多的 CPU 内核和更多的内存。

你可能会问，谷歌不是也有自己的应用吗？对应用的支持也关系到谷歌自己的利益呀。那我这里其实要补充一下，我说的应用开发，指的是用 Java 和 Kotlin 开发的应用，这也是大部分 Android 平台上的应用开发者所采用的语言。而像谷歌这样拥有强大技术力量的互联网巨头们，通常对于性能要求比较高的代码，是用 C 开发的。比如微信的关键逻辑就是用 C 编写的；像手机游戏这种对性能要求比较高的应用，底层的游戏引擎也是基于 C/C++ 实现的。

这些开发者们不采用 Java 的原因，是因为 Java 在 Android 平台上的编译和运行方式有待提高。Android 为了提升应用的运行速度，一直在尝试升级其应用运行机制。从最早的仅仅解释执行字节码，到引入 JIT 编译机制，到当前版本的 ART（Android Runtime）支持 AOT、JIT 和基于画像的编译机制。尽管如此，Android 对应用的支持仍然存在明显的短板。

第一个短板，是垃圾收集机制。我们知道，Java 基于标记 - 拷贝算法的垃圾收集机制有两个缺陷。一是要占据更多的内存，二是在垃圾收集的时候会有停顿，导致应用不流畅。在系统资源紧张的时候，更是会强制做内存收集，引起整个系统的卡顿。

实际上，Java 的内存管理机制使得它一直不太适合编写客户端应用。就算在台式机上，用 Java 编写的客户端应用同样会占用很大的内存，并且时不时会有卡顿。你如果使用过 Eclipse 和 IDEA，应该就会有这样的体会。

第二个短板，是不同语言的融合问题。Android 系统中大量的底层功能都是 C/C++ 实现，而 Java 应用只是去调用它们。比如，图形界面的绘制和刷新，是由一个叫做 Skia 的库来实现的，这个库是用 C/C++ 编写的，各种窗口控件都是在 Skia 的基础上封装出来的。所以，用户在界面上的操作，背后就有大量的 JNI 调用。

问题是，Java 通过 JNI 调用 C 语言的库的时候，实现成本是很高的，因为两种不同语言的数据类型、调用约定完全不同，又牵涉到跨语言的异常传播和内存管理，所以 Java 不得不通过虚拟机进行昂贵的处理，效率十分低下。

据调查，95% 的顶级移动应用都是用 Java 和 C、C++ 等混合开发的。所以，让不同语言开发的功能能够更好地互相调用，是一个具有普遍意义的问题。

第三个短板，就是 Android 的运行时一直还是受 Java 虚拟机思路的影响，一直摆脱不了虚拟机。虚拟机本身要占据内存资源和 CPU 资源。在做即时编译的时候，也要消耗额外的资源。

那么如何解决这些问题呢？我们来看看方舟编译器的解决方案。

方舟编译器的解决方案

方舟编译器的目标并不仅仅是为了替代 Android 上的应用开发和运行环境。但我们可以通过方舟是如何解决 Android 应用开发的问题，来深入了解一下方舟编译器。

我们先来看看，方舟编译器是怎么解决垃圾收集的问题的。

不过，在讨论方舟的方案之前，我们不妨先参考一下苹果的方案做个对照。苹果采用的开发语言，无论是 Objective-C，还是后来的 Swift，都是采用引用计数技术。引用计数可以实时回收内存垃圾，所以没有卡顿。并且它也不用像标记 - 拷贝算法那样，需要保留额外的内存。而方舟编译器，采用的是跟苹果一样的思路，同样采用了引用计数技术。

当然，这里肯定会有孰优孰劣的争论。我们之前也讲过，采用引用计数法，每次在变量引用对象的时候都要增加引用计数，而在退出变量的作用域或者变量不再指向该对象时，又要减少引用计数，这会导致一些额外的性能开销。当对象在多个线程之间共享的时候，增减引用计数的操作还要加锁，从而进一步导致了性能的降低。

不过，针对引用计数对性能的损耗，我们可以在编译器中通过多种优化算法得到改善，尽量减少不必要的增减计数的操作，也减少不必要的锁操作。另外，有些语言在设计上也会做一些限制，比如引入弱引用机制，从而降低垃圾收集的负担。

无论如何，在全面考察了引用计数方法的优缺点以后，你仍然会发现它其实更适合开发客户端应用。

关于第二个问题，也就是不同语言的融合问题。华为采取的方法是，让 Java 语言的程序和基于 C、C++ 等语言的程序按照同一套框架做编译。无论前端是什么语言，都统一编译成机器码，同时不同语言的程序互相调用的时候，也没有额外的开销。

下图是方舟编译器的文档中所使用的架构图。你能看到它的设计目标是支持多种语言，都统一转换成方舟 IR，然后进行统一的优化处理，再生成机器码的可执行文件。

方舟编译器架构示意图

这个技术方案其实非常大胆。它不仅解决了不同语言之间的互相调用问题，也彻底抛弃了植根于 JVM 的虚拟机思路。方舟编译器新的思路是不要虚拟机，最大程度地以机器码的方式运行，再加上一个非常小的运行时。

我说这个技术方案大胆，是因为方舟编译器彻底抛弃了 Java 原有的运行方案，包括内存布局、调用约定、对象结构、分层编译机制等。我们在第二个模块讲过Graal，在仍然基于 JVM 运行的情况下，JIT 只是尽力做改良，它随时都有一个退路，就是退到用字节码解释器去执行。就算采用 AOT 以后，运行时可以变得小一些，但 Java 运行机制的大框架仍然是不变的。

我也介绍过，GraalVM 支持对多种语言做统一编译，其中也包含了对 C 语言的支持，并且也支持语言之间的互相调用。但即便如此，它仍是改良主义，它不会抛弃 Java 原来的技术积累。

而方舟编译器不是在做改良，而是在做革命。它对 Java 的编译更像是对 C/C++ 等语言的编译，抛弃了 JVM 的那一套思路。

这个方案不仅大胆，而且难度更高。因为这样就不再像分层编译那样有退路，方舟编译器需要把所有的 Java 语义都静态编译成机器码。而对于那些比较动态的语义，比如运行时的动态绑定、Reflection 机制等，是挑战比较大的。

那方舟编译器目前的成果如何呢？根据华为官方的介绍，方舟编译器可以使安卓系统的操作流畅度提升 24%，响应速度提升 44%，第三方应用操作流畅度提升高达 60%。这就是方舟编译器的厉害之处，这也证明方舟编译器的大胆革新之路是走对了的。

我们目前只讨论了方舟编译器对 Android 平台的改进。其实，方舟编译器的目标操作系统不仅仅是 Android 平台，它本质上可移植所有的操作系统，也包括华为自己的鸿蒙操作系统。对于硬件平台也一样，它可以支持从手机到物联网设备的各种硬件架构。

所以，你能看出，方舟编译器真的是志存高远。它不是为了解决某一个小问题，而是致力于打造一套新的应用开发生态。

好了，通过上面的介绍，你应该对方舟编译器的定位有了一个了解。接下来的问题是，方舟编译器的内部到底是怎样的呢？

方舟编译器的开源项目

要深入了解方舟编译器，还是必须要从它的源代码入手。从去年 9 月份开源以来，方舟编译器吸引了很多人的目光。不过方舟编译器是逐步开源的，由于开放出来的源代码必须在知识产权等方面能够经得起严格的审查，因此到现在为止，我们能看到的开源版本号还只是 0.2 版，开放出来的功能并不多。

我参照方舟的环境配置文档，在 Ubuntu 16.04 上做好了环境配置。

注意：请尽量完全按照文档的要求来配置环境，避免出现不必要的错误。不要嫌某些软件的版本不够新。

接着，你可以继续根据开发者指南来编译方舟编译器本身。方舟编译器本身的代码是用 C++ 写的，需要用 LLVM 加 Clang 编译，这说明它到目前还没有实现自举。然后，你可以编译一下示例程序。比如，用下面的四个命令，可以编译出 HelloWorld 样例。

source build/envsetup.sh; make; cd samples/helloworld/; make

这个“hellowold”目录原来只有一个 HelloWorld.java 源代码，经过编译后，形成了下面的文件：

如果你跟踪查看编译过程，你会发现中间有几步的操作：

第一步，执行 java2jar，这一步是调用 Java 的编译器，把 Java 文件先编译成 class 文件，然后打包成 jar 文件。

补充：java2jar 实际上是一个简单的脚本文件，你可以查看里面的内容。

第二步，执行 jbc2mpl，也就是把 Java 字节码转换成 Maple IR。Maple IR 是方舟编译器的 IR，我下面会展开介绍。编译后生成的 Maple IR 保存到了 HelloWorld.mpl 中。

第三步，通过 maple 命令，执行 mpl2mpl 和 mplme 这两项对 Maple IR 做分析和优化的工作。这其中，很重要的一个步骤，就是把 Java 方法的动态绑定，用 vtable 做了实现，并生成了一个新的 Maple IR 文件：HelloWorld.VtableImpl.mpl。

最后一步，调用 mplcg 命令，将 Maple IR 转换成汇编代码，保存到一个以.s 结尾的文件里面。

注意，我们目前还没有办法编译成直接可以执行的文件。当前开源的版本，既没有编译器前端部分的代码，也没有后端部分的代码，甚至基于 Maple IR 的一些常见的优化，比如内联、公共子表达式消除、常量传播等等，都是没有的。目前开源的版本主要展现了 Maple IR，以及对 Maple IR 做的一些变换，比如转换成 SSA 格式，以便进行后续的分析处理。

到这里，你可能会有一点失望，因为当前开放出来的东西确实有点少。但是不要紧，方舟编译器既然选择首先开放 Maple IR 的设计，这说明 Maple IR 在整个方舟编译器的体系中是很重要的。

事实也确实如此。方舟编译器的首席科学家，Fred Chow（周志德）先生，曾发表过一篇论文：The increasing significance of intermediate representations in compilers。他指出，IR 的设计会影响优化的效果；IR 的调整，会导致编译器实现的重大调整。他还提出：如果不同体系的 IR 可以实现转换的话，就可以加深编译器之间的合作。

基于这些思想，方舟编译器特别重视 IR 的设计，因为方舟编译器的设计目标，是将多种语言翻译成统一的 IR，然后共享优化算法和后端。这就要求 Maple IR 必须要能够兼容各种不同语言的差异性才行。

那接下来，我们就具体看看 Maple IR 的特点。

志存高远的 Maple IR

方舟开放的资料中有一个 doc 目录，Maple IR 的设计文档就在其中。这篇文档写得很细致，从中你能够学习到 IR 设计的很多思想，值得仔细阅读。

文档的开头一段指出，由于源代码中的任何信息，在后续的分析和优化过程中都可能有用，所以 Maple IR 的目标是尽可能完整地呈现源代码中的信息。

这里，我想提醒你注意不要忽略这一句话。它作为文档的第一段，可不是随意而为。实际上，像 LLVM 的作者 Chris Lattner，就认为 LLVM 的 IR 损失了一些源代码的信息，而很多语言的编译器都会在转换到 LLVM IR 之前，先做一个自己的 IR，做一些体现自己语言特色的分析工作。为了方便满足这些需求，他后来又启动了一个新项目：MLIR。你可以通过这篇论文了解 Lattner 的观点。

Maple IR 则在一开头就注意到了这种需求，它提供了对高、中、低不同层次的 IR 的表达能力。我们在分析别的编译器的时候，比如 Graal 的编译器，也曾经讲过它的 IR 也是分层次的，但其实 Graal 对特定语言的高层次 IR（HIR）的表达能力是不够强的。

HIR 特别像高级语言，特定于具体语言的分析和优化都可以在 HIR 上进行。它的特点是提供了很多语言结构，比如 if 结构、循环结构等；因为抽象层次高，所以 IR 比较简洁。

与 Graal 和 V8 一样，Maple IR 也用了一种数据结构来表达从高到低不同抽象层次的操作。不过不同于 Graal 和 V8 采用了图的结构，Maple IR 采用的是树结构。在 HIR 这个层次，这个树结构跟原始语言的结构很相似。这听上去跟 AST 差不多。

随着编译过程的深化，抽象的操作被 Lower 成更低级的操作，代码也就变得更多，同时树结构也变得越来越扁平，最后变成了指令的列表。

那么，既然 Maple IR 是用同一个数据结构来表达不同抽象层次的语义，它是以什么来划分不同的抽象层次呢？答案是通过下面两个要素：

被允许使用的操作码：抽象层次越高，操作码的种类就越多，有一些是某些语言特有的操作。而在最低的层次，只允许那些与机器码几乎一一对应的操作码。

代码结构：在较高的抽象层次上，树的层级也比较多；在最低的抽象层次上，会变成扁平的指令列表。

再进一步，Maple IR 把信息划分成了两类。一类是声明性的信息，用于定义程序的结构，比如函数、变量、类型等，这些信息其实也就是符号表。另一类是用于执行的代码，它们表现为三类节点：叶子节点（常量或存储单元）、表达式节点、语句节点。

我用一个简单的示例程序 Foo.java，带你看看它所生成的 Maple IR 是什么样子的。

public class Foo{
  public int atLeastTen(int x){
    if (x < 10)
      return 10;
    else
      return x;
  }

  public int exp(int x, int y){
    return x*3+y+1;
  }
}

示例程序编译后，会生成.mpl 文件。这个文件是用文本格式来表示 Maple IR，你甚至可以用这种格式来写程序，然后编译成可执行文件。当这个格式被读入方舟编译器后，就会变成内存格式。另外，Maple IR 也可以表示成二进制格式。到这里，你是不是会有似曾相识的感觉，会联想到 LLVM 的 IR？对了，LLVM 也可以用三种方式来表示 IR（文本格式、内存格式、二进制格式）。

打开.mpl 文件，你首先会在文件顶部看到一些符号表信息，包括类、方法等符号。

javaclass $LFoo_3B <$LFoo_3B> public
func &LFoo_3B_7C_3Cinit_3E_7C_28_29V public constructor (var %_this <* <$LFoo_3B>>) void
func &LFoo_3B_7CatLeastTen_7C_28I_29I public virtual (var %_this <* <$LFoo_3B>>, var %Reg3_I i32) i32
var $__cinf_Ljava_2Flang_2FString_3B extern <$__class_meta__>
func &MCC_GetOrInsertLiteral () <* <$Ljava_2Flang_2FString_3B>>

接下来就是每个方法具体的定义了。比如，exp 方法对应的 IR 如下：

func &LFoo_3B_7Cexp_7C_28II_29I public virtual (var %_this <* <$LFoo_3B>>, var %Reg3_I i32, var %Reg4_I i32) i32 {
  funcid 48155      #函数id
  var %Reg2_R43694 <* <$LFoo_3B>>
  var %Reg0_I i32   #伪寄存器
  var %Reg1_I i32

  dassign %Reg2_R43694 0 (dread ref %_this)
  #INSTIDX : 0||0000:  iload_1
  #INSTIDX : 1||0001:  iconst_3
  dassign %Reg0_I 0 (constval i32 3)
  #INSTIDX : 2||0002:  imul
  dassign %Reg0_I 0 (mul i32 (dread i32 %Reg3_I, dread i32 %Reg0_I))
  #INSTIDX : 3||0003:  iload_2
  #INSTIDX : 4||0004:  iadd
  dassign %Reg0_I 0 (add i32 (dread i32 %Reg0_I, dread i32 %Reg4_I))
  #INSTIDX : 5||0005:  iconst_1
  dassign %Reg1_I 0 (constval i32 1)
  #INSTIDX : 6||0006:  iadd
  dassign %Reg0_I 0 (add i32 (dread i32 %Reg0_I, dread i32 %Reg1_I))
  #INSTIDX : 7||0007:  ireturn
  return (dread i32 %Reg0_I)
}

这里我给你稍加解释一下示例代码中的 IR。

以 func 关键字开头定义一个函数。函数名称里体现了原来 Java 类的名称和方法名称。public 和 virtual 关键字也是继承自原来的 Java 方法，在 Java 中这类 public 的方法都是 virtual 的，需要动态绑定。

接下来要注意的是用 var 声明，以 % 开头的三个伪寄存器。伪寄存器相当于本地变量，它的数量是无限的，在后端做寄存器分配的时候才对应成物理寄存器。

在这后面的是 6 个 dassign 语句。这是 6 个赋值语句，其中的 d，是直接寻址的意思。有的 dassgin 操作符后面跟着的是常数（constval），有的跟着的是加法（add）或乘法（mul）表达式。而加法和乘法表达式里面，又可能进一步用到其他的表达式。这里就体现出了 Maple IR 的特点，即它是树状结构的。

那么，总结起来，示例函数体现了 Maple IR 最基本的结构特点：程序被分成一个个的函数。函数里呢，是顺序的一条条语句，而每条语句都是一个树状结构，树的节点可以是叶子节点、表达式，或者其他的语句。如果把函数内的每条语句作为函数的子节点，那么整个函数就是一个树状的数据结构。

另外，在示例程序中，还有一些以 # 开头的注释。这些注释代表了原来 class 文件中的字节码。目前方舟编译器里有一个字节码的前端，能够把字节码翻译成 Maple IR。这个注释就体现了字节码和 Maple IR 的对应关系。

不过，上面的示例函数并没有体现出流程控制类的语句。我们再来看一下 atLeastTen() 方法对应的 IR。atLeastTen() 方法中有一个 if 语句，它能否被翻译成 Maple IR 的 if 语句呢？

func &LFoo_3B_7CatLeastTen_7C_28I_29I public virtual (var %_this <* <$LFoo_3B>>, var %Reg3_I i32) i32 {
  funcid 48154
  var %Reg2_R43694 <* <$LFoo_3B>>
  var %Reg0_I i32

  dassign %Reg2_R43694 0 (dread ref %_this)
  #INSTIDX : 0||0000:  iload_1
  #INSTIDX : 1||0001:  bipush
  dassign %Reg0_I 0 (constval i32 10)
  #INSTIDX : 3||0003:  if_icmpge
  brtrue @label0 (ge i32 i32 (dread i32 %Reg3_I, dread i32 %Reg0_I))
  #INSTIDX : 6||0006:  bipush
  dassign %Reg0_I 0 (constval i32 10)
  #INSTIDX : 8||0008:  ireturn
  return (dread i32 %Reg0_I)
@label0   #INSTIDX : 9||0009:  iload_1
  #INSTIDX : 10||000a:  ireturn
  return (dread i32 %Reg3_I)
}

在 Maple IR 中，提供了 if 语句，其语法跟 C 语言或 Java 语言的语法差不多：

if (<cond-expr>) {
               <then-part> }
            else {
               <else-part>}

像 if 这样的控制流语句，还有 doloop、dowhile 和 while，它们都被叫做层次化的控制流语句。

不过，在阅读了 atLeastTen() 对应的 IR 以后，你可能要失望了。因为这里面并没有提供 if 语句，而是通过一个 brture 语句做了跳转。brtrue 被叫做平面化的控制流语句，它在满足某个条件的时候，会跳转到另一个语句去执行。类似的控制流语句还有 brfalse、goto、return、switch 等。

补充：由于这个.mpl 文件是从字节码直接翻译过来的，但字节码里已经没有 HIR 级别的 if 结构了，而是使用了比较低级的 if_icmpge 指令，所以方舟编译器也就把它翻译成了同样等级的 brtrue 指令。

好了，通过这样的示例，你就直观地了解了 Maple IR 的特点。那么问题来了，当前的开源项目都基于 Maple IR 做了哪些处理呀？

你可以打开源代码中的src/maple_driver/defs/phases.def文件。这里面定义了一些对 Maple IR 的处理过程。比如：

classhierarchy：对类的层次结构进行分析；

vtableanalysis：为实现动态绑定而做的分析；

reflectionanalysis：对使用 Reflection 的代码做分析，以便把它们静态化。

ssa：把 IR 变成 SSA 格式

……

总的来说，当前对 Maple IR 的这些处理，有相当一部分是针对 Java 语言的特点，来做一些分析和处理，以便把 Java 完全编译成机器码。更多的分析和优化算法还没有开源，我们继续期待吧。

课程小结

这一讲我主要跟你探讨了方舟编译器的定位、设计思路，以及方舟编译器中最重要的数据结构：Maple IR。

对于方舟编译器的定位和设计思路，我认为它体现了一种大无畏的创新精神。与之相比，脱离不了 JVM 模子的 Android 运行时，倒有点裹足不前，使得 Android 在使用体验上多年来一直有点落后。

但在大胆创新的背后，也必须要有相应的实力支撑才行。据我得到的资料，华为的方舟编译器依托的是早年在美国设立的实验室所积累下来的团队，这个团队从 2009 年开始就依托编译技术做了很多研发，为内部的芯片设计也提供了一种语言。最重要的是，在这个过程中，华为积累了几百人来做编译和虚拟机的团队。前面提到的首席科学家，周志德先生，就是全球著名的编译技术专家，曾参与了 Open64 项目的研发。这些优秀的专家和人才，是华为和国内其他团队，未来可以在编译技术上有所作为的基础。那么，我也非常希望学习本课程的部分同学，以后也能参与其中呢。

对于 Maple IR 中分层设计的思想，我们在 Graal、V8 等编译器中都见到过。Maple IR 的一个很大的优点，就是对 HIR 有更好地支持，从而尽量不丢失源代码中的信息，更好地用于分析和优化。

对于方舟编译器，根据已开源的资料和代码，我们目前只做了一些初步的了解。不过，只分享这么多的话，我觉得还不够满意，你也会觉得很不过瘾。并且，你可能还心存了很多疑问。比如说，Graal 和 V8 都选择了图的数据结构，而 Mapple IR 选择了树。那么，在运行分析和优化算法上会有什么不同呢？我希望后续随着方舟编译器有更多部分的开源，我会继续跟你分享！

这节课的思维导图我也放在了这里，供你参考：

一课一思

你认为，用一种 IR 来表示所有类型的语言的话，都会有哪些挑战？你能否通过对 Maple IR 的阅读，找到 Maple IR 是如何应对这种挑战的？欢迎在留言区分享你的观点。

如果你身边也有对华为的方舟编译器十分感兴趣的朋友，非常欢迎把这节课的内容分享给他，我们一起交流探讨。感谢你的阅读，我们期末答疑再见！

不定期加餐1 | 远程办公，需要你我具备什么样的素质？

你好，我是宫文学。到这里，咱们课程的第一模块“预备知识篇”就已经更新完了。通过这么多讲的学习，这些编译技术的核心基础知识，你掌握得怎么样了呢？是不是感觉自己已经构建了一个编译原理的知识框架了？

不过我也知道，要理解编译技术的这些核心概念和算法，可能不是一件很容易的事儿，在跟随我一起探索编译之旅的过程中，估计也耗费了你不少的脑细胞，那咱们是时候来轻松一下了。

今天，我就想借着这个加餐的环节，跟你聊一聊一个很有意思的话题：远程办公。

之所以选择这个话题，主要有两方面的原因。

首先，由于疫情的影响，春节之后，很多公司都采取了远程办公的方式。所以，如何在远程办公的情况下做好工作，对于员工和公司来说，都是一个挑战。

第二个原因，是我个人一直对于远程办公这种工作模式很感兴趣，这些年来也一直在做这方面的思考，关注这方面的实践，所以有了一些心得体会，想跟你分享一下。

不过，要想把远程办公这个话题聊清楚，确实不容易，分歧也比较大。有一些朋友会比较悲观，觉得远程办公根本不切实际；而另一些朋友则会很乐观，觉得远程办公马上就会普及。

今天，我就来分享一下我看待远程办公的一些视角。我会从公司和员工这两个角度，来分析远程办公带来的机遇和挑战，希望能给你带来一些启发，让你以更积极和务实的姿态，迎接远程办公的浪潮，甚至在这种工作模式转换的趋势下，抓住更多的发展机遇。

首先，我来聊一聊远程办公的那些吸引人的地方。

远程办公的好处

我对远程办公的了解，最早是透过开源圈的朋友了解了一些故事，后来自己也接触了一些酷酷的公司。很多做开源软件产品和技术服务的公司都是远程办公的，他们的员工可能来自世界各地。我曾经接触过一个芬兰公司的 CEO，他们主要做嵌入式 Linux 的技术服务。一百多人的公司，平常办公室是没什么人的。据说他们公司有的员工，可以一边上班，一边去全世界旅游。

我当时认为，一百多人的公司，全部都能远程办公，并且管理良好，就已经很不错了。但后来看了一篇文章，讲到 WordPress 的母公司 Automattic 有上千名员工，分布在全球 75 个国家，全部都是远程办公。这就有点令人吃惊了！我才意识到，在互联网时代，原来远程办公可以适用于任何规模的企业。

这次疫情中，IT 领域的很多公司都大量地采用了远程办公模式，包括谷歌、Facebook、微软等大型企业。

现在新闻上说，疫情之后，世界再也回不到过去了。其实我觉得，在很多领域，我倒是宁愿它回不去了。比如，远程教育；再比如，远程工作。

因为远程，意味着你获得了一个难得的自由：位置自由。

现代社会，我们苦“位置”久已！因为很多资源都是跟位置绑定在一起的，比如说，教育资源与学区房。

我在北京的很多朋友，他们在孩子上学期间，一直都是租房子住的，因为要住得离孩子学校近，而自己的房子会租出去。这种状态要持续到孩子上大学为止。

而对于若干都市白领来说，在大城市上班，就意味着要把整个肉体在办公室和家之间移动，所以每天可能要在路上花两三个小时，很多时候还会在路上堵个半天。

如果我们真的获得了位置自由，那么整个生活的幸福指数会提高一大截吧！

对于远程教育来说，我比较希望见到的现象，是在偏远的乡村，一样能够通过线上教育获得最优质的知识资源。至于线下的老师，更多的是关注孩子的健康成长，多带着孩子亲近大自然，扮演“育”的角色，而不是“教”的角色。

工作也是一样。现在越来越多的工作，都可以在网上进行了。互联网电商的发展，虽然让一些线下店铺的营业状况受到了影响，但只要能通网络，很多人在网上就可以卖东西了呀。另外，随着外卖的兴起，很多餐饮企业也不再需要临街的店面了。

所以，通过远程办公，我们可能就不需要北漂、深漂等各种漂了，可以选择离自己的亲人更近一些，或者可以反过来，四海为家。并且，你还可能获得更多、更好的工作机会，你可以从全世界的公司里选择你喜欢的那份工作，并且也不需要离开你喜欢居住的地方。

并且，伴随着位置自由，往往也会给我们带来时间自由。因为远程后不再需要按时上班打卡了（很多在全球都招揽人才的公司，大家的作息时间都不一样，当然不可能统一打卡），所以管理体系会更加面向绩效，而不会管你到底是在什么时间来完成这些工作的（通常也没法管理）。这就意味着，你可以在家人需要你的时候，出现在他们身边（比如接孩子），然后选择自己合适的时间段来工作。

上面说的是远程办公对员工的好处。从企业的角度来看，远程办公其实也会带来一些潜在的好处。

首先，有些员工可能会在工作上做更多的投入（这跟某些员工会偷懒恰恰相反，所以可能出现两极分化）。这些人很享受自己的工作，每天上下班反倒降低了他可能做出的贡献。如果公司有一套良好的管理体系，那就可能会因此筛选出更适合自己的员工，而避开那些混日子、划水的员工，整个团队的素质反倒会得到提高。

我曾经跟 MySQL 的前 CEO Mårten Mickos 聊天。我问他，管理远程办公的员工，需要注意些什么？

他思考了一下，说要建议员工跟家人一起住，至少要养条狗什么的。因为家人能帮助管理这些极客们的作息。不然由着这些极客们的性子，他们会昏天黑地、毫无规律地作息，不利于健康。就算养条狗，你也会因为要照料它们，而让自己的生活节奏健康一点。

他的回答其实出乎我的意料，我原本以为他会说什么公司的管理措施之类的。你体会一下，如果你是公司老板，你是不是会因为拥有这样热爱工作的员工而感到欣慰呢？

第二，因为没有了地域限制，公司也就可以充分任用全球各地的人才。这个方面在很多做开源软件的公司那里，得到了很好的体现。如果你喜欢某个开源产品，在社区里贡献自己的力量，那你很可能就会被邀请加入到该公司。

在互联网时代，企业的组织方式也正在重构。滴滴打车、美团外卖这些采用新雇佣方式的公司，不但可以更好地利用各地的人力资源，TA 们也提供了一些自由工作的机会。

第三，没有了地域的限制，公司也可能更容易拓展自己的市场。这个好处也是显而易见的，就不用我多说了。

远程办公的挑战

上面是我对远程的一些美好的憧憬。还是回到现实吧，因为更改现有的教育体制，可能是很难的。而让企业老板们改变公司的管理方式，难度也不低。

老板们都是理性的。真金白银投入做企业，是要见到效益的。可是，如何能保证采用远程办公模式，不会让企业变成一团散沙，纪律涣散、效率低下呢？

你可以问问，在春节后不得已实行远程办公的企业，对经营有没有产生影响。

说实话，在没做好充分的准备之前，仓促地采用远程办公，肯定会产生各种不适。

因为远程办公，对于管理体系，有着更高的要求。很多工作是难以直接度量绩效的，比如说研发工作就比销售工作更难衡量绩效。

而没有良好的管理体系，仅凭员工的自觉，是不可能产生良好的效果的。其实，硅谷有一些 IT 公司很早就实行过远程办公，但后来又取消了，原因就是绩效不理想，混日子的员工太多。

反过来，站在员工的角度，你真能做好自己的工作管理吗？在办公室工作的时候，迫于同事们的目光，你总得做点事情吧。可是，如果脱离了直接的监督，有多少人能够真正管好自己呢？

好，你说你的自我管理能力强，那么请问，有多少人能控制住自己每天刷手机的时间呢？据说，超过 50% 的成年人，都有手机上瘾症。在办公室的时候，尚且见缝插针地刷手机。如果在家办公，又会怎样呢？

有过远程工作经历的人，都会经历这么一个时期。即使是你很有责任心、很有事业心，但也要每天花费很多的精力来管理自己的行为。我认识的一个朋友，她在 IT 行业，主要做售前支持工作。之前跟她闲聊的时候，她说自己花了 3 年的时间跟自己搏斗，才养成了良好的居家工作习惯。而管理自己这件事情，也是消耗注意力的。注意力本身，又是个稀缺资源。所以在初期，你会觉得，对比在办公室里，居家办公会更累，在公司你不需要花精力来控制自己的行为，因为环境和同事帮忙做了这件事情，实际上节省了你的注意力。

我也听说，有的工程师会在网上直播自己编码的过程。这样做的一个原因，就是为了帮助管理自己的行为，因为这时候你必须更加集中注意力在自己的工作上。

还有一个是办公环境的因素。我们中国人的居住状态比较拥挤，在自己家里开辟出一个安静的、不被打搅的书房并不容易，这可能还跟中国的文化有关。而西方的文化，可能会更尊重个人的空间。

再说了，我们跟外国人的居住条件也确实不同。西方发达国家很早就开始了郊区化发展，大部分人会住在郊区和小城镇，自然环境比较好。而我们中国呢，大部分住在小区的楼房里。

不过，如果真的远程工作了，你也可以不住在大城市呀。网上有些视频经常会吸引我，某夫妇在乡村翻新出一栋漂亮的别墅，还拥有美丽的花园，等等。其实我目前就住在一个自然环境良好的山上，只不过从这个村子去办公室也很方便就是了。

远程办公还会产生心理上的挑战：白天晚上都在家里，会容易心理疲劳。而换个环境，反倒会让人兴奋起来。我就有个感觉，在家里工作久了，效率就会降低。而这时候再回公司工作的话，反倒更容易集中注意力。

而且，远程办公肯定也会降低沟通的效率。一些互联网公司，在设计办公室的时候，会故意设计一些空间，方便大家偶遇，闲聊几句。而做研发工作的同学都知道，这种看似随意的交流，有时候能激发出很多创新的思维。而如果总是自己苦思冥想，往往很快就会走入死胡同。这种线下偶遇式的沟通，往往见到了就会聊个几句。但在远程办公时，如果大家互相见不到面，还真就不聊了。

面对远程办公，我们要做好什么准备？

所以，我们需要实行一些积极的操作，来更好地应对远程办公给我们带来的挑战，这样也能更好地抓住远程办公给我们带来的机遇。

从公司的角度出发

那首先，我们来看看，对于企业来说，都需要做好什么准备。

第一，我觉得企业管理者要建立一个意识：远程办公是企业必须要面对的管理考验。

其实只要企业做大了，几乎都要面对远程管理的问题。比如你有了分公司，或者在各个城市设门店，甚至把生意做到国外。那么，突破地域的限制拓展业务，本来就是对企业能力的考验，是企业发展中必须踏过的门槛。

所以说，企业也一样需要获得位置自由。这些分公司、派出机构工作的人员，对于总部来说，本来就是远程工作的。有了这个意识，管理者就会开始放弃旧的思维，拥抱远程办公。

第二，从看住人，转换到管绩效。

很多比较传统的企业，他们的绩效标准都比较模糊，所以在远程办公的形势下，我们需要把绩效标准的清晰化、准确化放到第一位。像滴滴、外卖这些新职业，之所以能够迅速扩展规模，充分利用社会化人力资源，就是因为他们的工作绩效的标准是清晰而准确的。

第三，建立拥抱远程办公的文化，给员工授权和赋能。

像软件研发类的工作，它是知识密集型的，对员工的绩效评估比较难，人员更换的成本也相对较高。那么对于这类工作，我们可以多向那些开源软件公司学习，建立一个拥抱远程办公的公司文化，去吸引那些对工作充满兴趣和热爱的人参与进来。这些人，也会更加珍视公司给予的授权和自由。

第四，充分利用 IT 技术。

管理，一定要落实在工具上。我接触的那家芬兰公司，就花了很多年的时间，积累了一套成熟的内部管理系统。比如说，作为软件公司，你肯定要对项目进度、代码量、Bug 数等基础指标有所管理才行吧？

信息技术成本的降低，也大大降低了远程管理的开销。这次疫情，促进了视频会议在全世界的普及。对于中国的中小企业来说，甚至可以 0 成本享受高品质的远程会议服务，这真是一个了不起的福利！

从员工的角度出发

OK，说完了公司，那我们再来看看，从员工的角度出发，我们都要具备什么素质，才能更好地迎接远程办公模式。

第一，员工也要建立一个意识：无论是否远程办公，都要向绩效负责，管理好自己的工作。

即使你仍然在传统的办公模式下工作，如果你能像一个远程工作者那样对绩效负责，管理好自己的注意力，我想你很快就会获得领导的注意，从而赋予你更大的工作自由。你有没有听说过，张小龙经常睡懒觉迟到，而马化腾从来不管他？因为马化腾需要的是一个能做出微信来的张小龙，而不是一个每天按时打卡的张小龙。

第二，正视远程办公对自我管理的高要求，养成良好的工作习惯。

在办公室工作，会有环境来约束你。而当真的给了你位置自由以后，你其实要珍视这种自由，给自己定一些规矩，甚至给自己找一些监督（就像前面说的在网上直播），从而养成良好的工作习惯。

第三，建立激进的协作习惯。

由于远程工作对于协作的挑战，你必须建立激进的协作习惯，而不是仅仅停留在我们目前使用即时通讯工具和视频会议工具的习惯上。比如，你可以全时间视频在线、主动找人线上闲聊一小会儿、主动创造一些与人沟通的机会，等等。

第四，可能是最重要的：为兴趣而工作，为自己而工作。

人在没有很多督促的情况下，真正能驱动自己前行的动力，就是兴趣了。这个时候，你会把工作看作是促进自己成长的必要因素，从工作中成长，从成长中获得快乐。这个时候，你已经不是在为公司工作，而是为自己而工作。这样的人，才算获得了真正的自由。

小结

今天，我们讨论了远程办公对公司和员工的好处、挑战，以及我们需要做好的准备。我讲了两个主要的观点。第一个观点是对企业的：远程办公管理能力是企业未来必须具备的能力。第二个观点是对个人的：只有能够管理好自己的人，才能抓住远程办公带来的机遇。

那总体来说呢，信息技术的进步是不可阻挡的，它对于工作和生活方式的重塑，也绝不会停止。所以，远程办公一定会越来越成为现实，不管是对于企业还是员工，我们都要积极拥抱这样的未来。

讨论一下

你有想过，有一天会通过远程办公的方式上班吗？远程办公有哪些地方会吸引你？你有通过远程办公的方式上班的朋友吗？你觉得，为什么远程办公现在还没有真正普及开呢？你可以在留言区里，跟大家交流交流你的看法。

感谢你的阅读，欢迎你把今天的内容分享给更多的朋友。

不定期加餐2 | 学习技术的过程，其实是训练心理素质的过程

你好，我是宫文学。

最近，高考刚刚结束。每年一度的高考都牵动了很多人的心，学生和家长们都把高考看作是人生的一大关键关口。可是，等上了大学以后呢？很多同学也会感到不适应，因为缺少了一个像高考那样明确的学习目标，也没有老师和家长在旁边不停地鞭策和关注。到了工作以后，就更是如此了。

对于进入计算机领域的人而言呢，很多人迫于找一份好工作的压力，会刻苦学习一段时间，包括参加各种学习班。而一旦获得了一份工作，融入职场以后，很容易就进入舒适区。反正当前的工作也能应付过去，为什么还要费力再去学呢？毕竟，工作已经够辛苦了。

在这种情况下，人生的第二次转折点就出现了。

有的人，能够管理好自己，充分利用各种时间和机会，不断地加深自己对技术的理解。虽然短时间看上去进步并不大，但成年累月地积累下来，效果就逐渐出现了，TA 们开始能够胜任一些关键岗位，成了技术领头人。而另一些人，则只能掌握那些比较容易掌握的技术，时间一长就会显得平庸，等年轻人赶上来的时候，就更加没有竞争优势了。虽然这不是像高考一样，能马上分出重点大学和普通大学的差别来，但在进入职场 5 年、10 年以后，这两类人在发展上的差别并不比高考带来的差别小。

我说这些，不是在贩卖焦虑，而是想引出我们今天要讨论的话题：从心理的角度看待学习技术的过程。特别是自己管理自己学习的过程、跟自己相处的过程。

学习没有轻松的。尤其是学习像编译原理这样的基础技术，就会显得挑战更大。想要学好它，调整和保持一个良好的心态是非常重要的。而通常，我们在心理上会面对三大问题：

第一，我为什么要学习这么难的技术？学一些比较容易的、应用层面的技术不就行了吗？这是学习的目的和动力问题。

第二，以我的能力，能学会这么难的技术吗？这是自信心和勇气的问题。

第三，如何看待学习中经常遇到的挫折？总是找不到时间怎么办？等等。这是学习过程中的心态调节和习惯养成问题。

如果对这三方面的问题，你都获得了清晰的答案，那么你应该就能保持好持续学习、终生学习的心态，从而对自己的人生有更好的掌控力。

那接下来，我就给你解读一下，我对于这三类问题的理解。

首先，我们来说说学习目的问题。

为什么要学这么难的技术？

在做课程设计的时候，我和编辑同学都会尽量想着如何让这样的基础技术和你的日常工作关联起来，让你觉得它不是屠龙之术，而是能够在日常工作中发挥实际效用的。这确实是学习基础技术的收获之一。

不过，如果想长期坚持下去，我会建议你把心态调整成一种更高级的模式。用中国文化中的一句话来形容，就是“用出世的态度，做入世的事情”。如果一件事情你觉得应该去做，那就去做，不要太斤斤计较一些功利层面的东西。

那么对于学计算机而言，什么是我们应该去做的呢？那当然是要了解计算机领域的那些最基础的原理呀。如果搞了一辈子 IT 技术，却不明白其中的道理，那岂不是一辈子活得稀里糊涂的？

我知道，大部分人不注重基础性知识的原因，可能是觉得它们不会马上发挥作用。可是，那些最重要的知识、那些构成你知识结构的底蕴的东西，往往就是那些看上去不会马上有用的东西。

我个人非常欣赏复旦大学做教育的一种态度，就是教给学生无用之学。哲学、艺术、写作、演讲、逻辑学、历史等知识，在西方教育中被称作 Liberal Arts，我们有时候翻译成通识教育，或者博雅教育。这些教育对于我们从事任何专业的工作，都是有用的。

比如说，美学素养。一个设计良好的系统架构，一定是优美的。新东方的元老之一王强，在美国学习计算机的时候，会把写完的程序拉开一定的距离看。如果看上去不够美观，那一定是程序设计得不够好。

你乍一听上去，可能会感觉是无稽之谈，但有经验的程序员一定会认同这个看法。那些写得有问题的程序，往往本身就是又臭又长、非常难读；而高质量的程序，往往是模块划分清晰、简洁易读的。做不出好的系统设计的人，肯定美学素养也不够高。像爱因斯坦等大科学家，往往驱动他们做出某个研究成果的动力，就是想去发现一条更加简洁、更具优美感的公式，因为真理往往是简洁的、优美的。

我之前公司的一名股东，他以前是一位很厉害的软件工程师，后来被一个外企挖走，担任了多年的销售副总。挖他去外企的原因，就是因为当时该外企刚开始在中国推广中间件的概念，他听了介绍以后就说，那不就跟我写的某软件的原理是一样的吗？并且一下子就说出了这类软件的关键技术点。于是，该外企下定决心要把他挖过去，并且是去负责销售。去年，他突然又写了一套科幻小说，名称是《云球》。我这里不是为他打广告，我是想说，做一个优秀的软件工程师、担任销售副总和小说家，这三个职业从表面上看相差很大，但其实背后依赖的基础素质都是一样的，都是那些乍一看上去没用的基础知识、基础素质。

所以，从这个角度，我是同意素质教育的理念的。一个缺乏美学素养、哲学素养和沟通能力等素质的软件工程师，潜力可能是有限的。

说到基础素养，我补充一个例子。有一次，我和前面说到的这位朋友在一起聊天，结果一个软件公司的老总给我们吹嘘他们公司开发的某软件平台。在说到一些特性的时候，听得我俩目瞪口呆。后来我们告诉这位老总，他声称的东西违背了基本的物理学和信息学的规律。在违背科学的底层规律的方向上做事情，那就相当于去造永动机，根本是虚妄的。这是很多项目失败的根本原因。

而另一些人，却具备抓住事情本质的能力。众所周知，马云并不懂技术。但就是不懂技术的马云，在懂技术的马化腾、李彦宏都认为云计算不是趋势，只不过是新瓶装旧酒的时候，果断拍板做云计算技术。期间，来自内部的反对声一直很强，大家都不愿意在内部使用尚不成熟的云计算技术。然而时间证明，马云的眼光更准。并且，力主开发云计算技术的王坚博士，他自己的专业也不是计算机专业。那么，为什么一拨非科班人士会比科班的技术大佬们看问题还准呢？我想可能是他们的无用之学学得更好，基础素质更全面吧。

所以，这就是我对于像编译原理、操作系统、算法等基础知识的态度。你就把它们看做是无用之学好了。我不仅鼓励你把这些基础知识学明白，并且我也希望你可以尽量再往深里挖一挖。比如，像图灵那样去思考一下，计算的本质到底是什么；编译原理用到的形式语言，也可以被继续深挖，从而跟整个西方科学体系底层的形式逻辑体系挂钩，以此去深入地理解希尔伯特猜想和哥德尔定理；了解面向对象、函数式编程这样的编程范式，跟人类的认知和思维模式的关系，跟 Lamda 计算、范畴论等数学工具的关系；你还可以去了解复杂科学领域的成果，并用这样的思维去看待大型复杂的信息系统。

如果你觉得编译原理这样的技术没啥用，那你一定会觉得我刚才说的那些更加没用。但我知道，一个优秀的软件工程师，其实一定是对我说的那些话题有所涉猎、有兴趣的。

总结起来，一个人的基础素质，决定了他的思维方式、思维质量和眼光，那些看上去没用的基础知识、基础原理，其实是真正做大事、承担重任所需要的素质。那，你到底要不要去学习呢？

好，如果你认可我的观点，那么我们接下来再探讨第二个话题，关于学习的信心问题。

我能学得会吗？

很多人都会有一个担心，说某些基础技术、基础原理太难，自己的基础又不够好，那么能学得会吗？如果学了半天又学不会，那不是白费力气吗？

从能力角度，我必须承认，我们每个人都是有天赋的差异的。你让一个普通人去跟姚明比赛打篮球，那不是难为人吗？

学习这件事情也一样有天赋的问题。

我本人当年在高考的时候，是省里的前几名，但是等我到了北大，看到周围的同学通常也都是身手不凡；在记忆力方面，我也比不过很多同学，有的同学对普通的词汇书根本不感兴趣，会去背词典，甚至背专业领域的词典；在数学等需要逻辑思维的领域，我又比不过另一些同学，直到今天，对于一些涉及数学的问题，我都会去咨询这些同学的意见。

但从另一个角度讲，一些基础知识、基础原理，一定要有很强的天赋才能学会吗？

不是的。在人类知识的殿堂中，你要想增加一点新的理论、新的原理，那是非常难的。所以我们必须对那些大科学家们，那些计算机领域的先驱们顶礼膜拜。那些顶尖的工作，确实需要天赋，再加上努力和机缘。

不过，即使狭义相对论和广义相对论发明起来那么困难，但一般的理工科学生只要想学，都是可以弄明白的。这就证明了，发现知识和学习知识所需要的能力，是极大的不对称的。在高考季，经常会出现妈妈级、奶奶级的考生，从陪考到变成跟儿孙辈一起上大学的故事。人家奶奶都能考上大学，我们年轻大学生学不会本专业的一些基础原理，这个道理说得通吗？

同理，你常常会听到的一个理由也是不成立的，这个理由就是：我不是科班出身。这个我就不认真去反驳了。你想想看吧，费马的本职是律师，而他“业余”是个大数学家；数学家罗素却获得过诺贝尔文学奖；比尔·盖茨进的是哈佛大学商学院；我前面说的王坚博士是学心理的；罗永浩的专业也肯定跟 IT 没关系；刘慈欣是业余写小说的。

所以，那些所谓的困难，只是你给自己设的玻璃天花板。这不是个能力问题，而是个心理问题。儒家提倡“智、仁、勇”三种最高的道德标准，勇气是其中之一，它也是我们应该训练的一种品质呀。

好，如果你又一次认同了我的观点，那么我们再来讨论第三个问题，如何克服学习过程中的困难。

如何持之以恒？

在我看来，如果理顺了前两个问题，也就是为什么要学，以及信心和勇气的问题，那么你最大的心魔其实就破除了。

但毕竟，学习贵在持之以恒的坚持。在这个过程中，我们可能会遇到很多的困难。但对于这些困难，我们也要用正确的心法来对待。所以，接下来我就针对如何面对学习中的困难、如何保证学习时间、如何找到学习的乐趣等常见问题，谈谈我的看法。

困难是必须的

首先你得明白，有价值的东西，一定是要克服困难才能得到的，这是公平的。所以不要指望学知识而不需要付出努力，再好的教程和老师，也只是起到辅助作用。这里你得注意一个问题，就是不要被某些书籍和课程收了智商税，比如说，“7 天学会 XXX”，“学英语其实不用背单词”，等等。这种标题，就是违背学习的基本规律的。

所以，当你知道了苦难不可避免这个道理，那你剩下的就只有面对这些苦难。在学习中，你可能经常会被一个难点阻碍住，这很正常。你正确的心态应该是这样的：

没有我拿不下的山头，正面拿不下从侧面，侧面不行走背面。多换几个角度，多几次尝试，多看点参考资料，总会成功；

那么多人都能学会，我没有道理学不会，一定有更好的方法；

这个问题既然有难度，那价值一定也大，所以一定不要放弃。

有了这样的心态，其实再苦再难的事儿都好说了。

在旅途中发现乐趣

我一个朋友最近正在从新疆骑行到西藏，全程 3000 公里，中间需要穿越无人区。这是他第三次做这样的骑行，之前已经骑过川藏线、青藏线。虽然过程很艰苦，但沿途美丽的风景，和跟自己相处的过程，就是这个旅途给他的回报。

我自己也喜欢户外。我家人有时不理解我，问我为什么要开着一辆大房车去那么远，累不累呀。我说，这就是旅行的意义呀。如果直接飞机过去，那有什么意思。

我用这两个例子作类比，是想告诉你：当我们学习那些有难度的知识的时候，其实肯定能发现出其中的乐趣来。比如，在学编译原理的时候，你去动手实现几个小例子，哪怕还不到实用的程度，但是好玩呀！当你找到了其中的乐趣，那么别人看你是在艰苦地学习，但其实你是乐在其中呢。就好像，别人看着一个人是在顶风冒雪一个人骑行，但他也是乐在其中呢！

另外呢，在互联网时代，各种不需要动脑的娱乐方式层出不穷。普通的人会在这种廉价的快乐中流连忘返。而如果你的目标是持续进步，那要培养自己另一种习惯，就是习惯于获得那些艰难的乐趣，这种乐趣是真正的充实的乐趣。

跟自己相处

我前面举的朋友骑行的例子，他是自己一个人。我也喜欢自己开车出去，因为没有了其他人，也就避免了因为人际关系而导致的分神，你只需要关注大自然和你自己。你能感受到自己跟自己对话的过程，自己跟大自然对话的过程。

学习在大多数情况下也是一个人前行的过程，学到的知识也只属于你一个人。在这个时候，就只剩下了你要去攻克的知识，和你自己。你能感受到自己跟自己对话的过程，自己跟知识对话的过程。当遇到困难了，你能发现自己的苦闷和焦虑；当解决问题了，你能感受到自己的欣喜。

真正有价值的成绩，都是在这样的跟自己独处、跟自己对话的过程中做出来的。这是一种值得追求的素质。

跟志同道合者相伴

独行难，众行易。除了那些内心特别强大的、从来都不屑于与普通人同行的天才，我们大部分普通人还是愿意有一些同伴一起结伴而行的，这样会大大降低驱动自己所需的努力。

我在读研时曾报过 GRE 的培训班。我感觉报班的最大作用，其实不是跟着老师学到多少知识，而是培训班乌泱乌泱的一大堆的同学，给我提供了一种气场，让我每天不想别的，赶紧学习就是了。

这样的群体还会有效改变自己的学习标准。在学 GRE 之前，我觉得一天背几十个单词已经挺辛苦的了。但到了 GRE 班，我很快就接受了每天背 200 个的新标准，因为其他人也是采用这个标准的。关键是，就算每天背 200 个，我也没觉得有多困难。所以你看，人的潜力有多大的弹性，而一个好的群体就是能无形中给人提供这种心理上的能量。

而且那时的同学都会有这种体会，就是每天如果不背单词就不舒服，上瘾。那段时间，随便看到一个单词，脑子里就会出现几个近义词和反义词，这种感觉很奇妙。再次印证了我前面说到的那种奋斗中的乐趣。

在软件领域，有很多技术社区，这些社区也能起到对人的心理加持作用，你可以善加利用。

最后，如果有要好的朋友和导师，能够鞭策你，那也非常难得。有管理经验的人都知道，虽然我们希望每个员工都有自我驱动的能力，但合适的外部驱动能降低员工驱动自己所需要消耗的努力。毕竟，我们大部分人其实是愿意工作在“低功耗模式”，能节省能量就节省能量。

使用运营思维

在互联网时代，各种 App 在功能设计和运营上，充满了心理学的套路，以便培养用户的习惯。游戏公司更是会雇佣心理学专家，来设计各种套路。

那么，与其让别人套路你，不如自己套路自己，同样利用心理学的知识来培养自己的学习习惯，把自己的时间、自己的命运把握在自己手里，不是更好吗？

心理学的基础原理并不难，你自己就能从各种 App 的使用套路里体会到一些。比如说对取得的成绩即时给予奖励。从心理学的角度、从各种 App 背后的运营者的角度来看，我们每个人其实就是巴甫洛夫实验室里的动物而已。通过这样的自我训练，你可以达到一些很好的效果：

建立良好的学习流程，有明确的开始和结束时间；确认一下每天的学习目标和学习成果，或者可以建立学习过程的仪式感；给自己一个良好的环境。

没有学习的时间？那是不可能的。这是因为你没有给学习安排出专门的时间来。

以输出带动输入。很多同学有写技术博客的习惯，这个习惯非常好。因为你要写出东西来，所以会逼迫自己把思路理清楚。

激进一点的：直播自己的学习过程，给自己提供外部监督和激励机制。

小结

今天这一讲，我聊了聊对于学习比较难的、比较基础的知识的心法的理解。总结起来，主要有三点：

第一，那些基础知识的素养，决定了一个人的发展潜力，这是你要学习它们的原因；

第二，没有学不懂的知识，真正的障碍是心理上的自我设限；

第三，学习的过程，就是砥砺前行的过程，经常能自省和调整自己的状态，就能养成自己的学习能力。

那么，你对于学习，有没有什么好的心法？欢迎在留言区跟大家交流！

感谢阅读。如果你也觉得很有收获，非常欢迎你把今天的内容分享出去，跟你身边的朋友一起做会学习的人。

我们接下来就要进入到期中复习周了，到时候你就可以来看看，在前半段的课程中，你都学习得怎么样！

不定期加餐3 | 这几年，打动我的两本好书

你好，我是宫文学。

在互联网时代，读书好像变成了一件挺奢侈的事情。因为我们现在获取信息的主要渠道，已经变成了网络。不过，在互联网统治力如此强劲的今天，我偶尔仍能发现一些好书，让我可以放下电脑和手机，对着厚厚的一大本，从头看到尾，甚至还会看很多遍。可见这些书确实是真正打动了我，让我这个理科背景的人，能以新的视角来看待世界，理解这个世界背后的运行规律。

我觉得一本书籍能达到这个阅读境界就很值得推荐了，因为这相当于是在帮助我们树立世界观、沉淀方法论。所以今天的加餐环节，我想给你分享两本打动我的好书，或者说是以其为代表的两类好书，跟你聊聊我读这些书的感受和收获，希望也能给你一些启迪。

那第一本书呢，就是《失控》。

《失控》

失控这本书的作者是《连线》杂志的主编凯文 · 凯利，于 1994 年出版。这本书被很多人推崇，据说张小龙就曾说过，谁能看懂《失控》这本书，谁就可以到他那里工作。

这本书的神奇之处，在于它虽然成书于上个世纪 90 年代初，但准确预测了后来互联网时代的一系列的创新，更厉害的是它揭示了互联网时代背后蕴藏的道理。就如这本书的副标题所说的，它揭示了“全人类的最终命运和结局”。

我自己呢，是在读过这本书后，对其中的内容感觉极为惊讶。我甚至怀疑，凯文 · 凯利到底是何方神圣，为何他能够写出这样的惊世之作。

我就拿《失控》中第二章的内容，跟你一起分享一下，做一次管中窥豹。

第二章的标题是“蜂群思维”。蜜蜂是一种社会性昆虫，它们总是一大群一起生活。在研究蜂群的时候，你会发现，一群蜜蜂相当于构成了一个单独的生命体，这个生命体比单只的蜜蜂更加高级。举个例子，单只蜜蜂只有 6 天的记忆力，而一个蜂群却拥有三个月的记忆时间（这是个体记忆与群体记忆的区别之处）。另外这个生命体会比单只蜜蜂拥有更长的寿命，且具有繁殖能力，能分化出新的蜂群。

这样看起来，它似乎符合一个生命体的所有特征。而这种把很多个体连接起来，构成一个更高级的存在的现象，就叫做涌现（Emergence）。

另一个能很好地解释涌现的例子，就是人类的大脑。大脑中的神经元，实际上就是一个很简单的个体，它们只知道在接收到信号的时候，对其他神经元发送信号。而基于很多亿的神经元所涌现出来的大脑，却具备记忆、推理、情感等很高级的能力。试想，如果你是一个神经元，你其实是根本无法理解，以亿万个你构成的一个超级生命体，竟然会拥有情感、逻辑推理这种东西。因为在一个神经元的世界里，它只有接收信号和发送信号这两件事情。

你再往下思考，就会发现人类社会正是由亿万个你我构成的，那人类社会是不是一个超级生命体呢？这个生命体在思考什么，我们作为一个神经元级别的存在，如何能理解呢？或者说，思考仅仅是我们这个级别的个体所能理解的事情。而这个超级生命体所做的事情，可能已经根本不是人类的思考这种层面的事情了呢？早期人类的宗教，以及宗教中的神，也是高于单个的人类个体的。那么，它们是不是这个超级生命体在人类历史中早期的一种呈现方式呢？

我们再来假设一下，当前的互联网时代，连接网络的计算机、各种智能手机、智能设备越来越多，甚至已经开始接近大脑神经元的数量了。那么，它们会不会涌现出一个超级生命体？这个生命体是否会具备自己难以撼动的意志，而我们必须屈服于这种意志呢？

怎么样？这本书里的观点，是否也能同样给你带来启发，开一个大大的脑洞？是不是也引起了你去一读的兴趣呢？

这个级别的内容，在《失控》里还有很多。再举一个例子：活系统的特征是“摇摇晃晃的平衡”，而处于稳定的系统就进入了死寂。从这个角度看，如果我们的生活中问题不断，其实正是活系统的特征，因为我们要谋求持续的不均衡，这样我们才有机会去改变它，这总好过稳定的、死寂的生活。你看，这样的结论都已经带有了哲学的特征，让我们在面对生活中的挫折时，会采取更加积极的心态。

于是，还是回到我前头提到的那个疑惑：为什么凯文 · 凯利会有这么深刻的洞察力，远远超越我们这些普通人呢？

经过研究，我发现原来书中的很多观点，其实是对从上个世纪中叶以来，各学科的科研成果的总结，然后通过一个资深科技编辑的叙述普及给大众。看到这里，我才放心了：原来并不是出了一个多么逆天的天才，而是我自己对科技发展的新成果，以及其中蕴含的新思想缺少了解。这些思想或理论呢，包括了很多同学都知道的系统论、控制论和信息论三大论，以及后来的协同学、博弈论、突变论、混沌理论、分型理论、耗散结构和复杂性理论，等等。它们在过去的几十年间不断地发展，并正在形成一个宏大的、崭新的世界观和方法论体系。现在的一个新兴学科——复杂科学，似乎就是这些元素的集大成者。

我以前对复杂科学了解得不多，但我觉得其实也不能怪我。因为我们在中学、大学学的那些知识，大部分都是用来描述简单系统的。比如在大多数情况下，天体的运行就是一个简单系统，我们用相对论这样的理论就能准确地预测天体的行为。

而复杂系统，其构成部分之间的相互作用关系比较复杂，难以预测。我还是拿天体来说，三颗星体的相互作用，就变得难以预测了，这就是著名的三体现象，也是刘慈欣小说名称的来源。蝴蝶效应、混沌系统，等等，说的也是这个现象。

可以说，复杂系统破除了对还原论的迷信。也就是，即使你把一个系统分割成一个个的“零件”，并且完全理解了每个“零件”，你也可能无法理解整体系统。因为整体不等于部分的简单相加，比如说，就算你理解了一个社会的经济体中的每个企业和消费者的行为，你也无法准确掌控宏观经济。

可是，了解这些，对你我有什么意义呢？

我先讲一个小的用途。作为软件架构师，你其实应该知道，当一个软件系统复杂到一定程度的时候，你要把它看成一个动态演化的有机体。你对系统做的任何改动，都可能会引起一些你完全预料不到的结果。这就是为什么，你可以花一点儿钱甚至是免费就能搭建一套简单的电商系统，但是像淘宝这样的大型系统，则需要几千人来建设和维护它。

再举个例子。我们现在都非常熟悉的微服务架构，它的理念是，一个大型软件系统是从一个个分布式的、自治的单元中涌现出来的。流媒体巨头 NetFlix，他们也是微服务架构的首批推动者之一。在 NetFlix，软件工程师们会设计一些叫做 Monkey 的程序，随机地杀死一些服务，看看系统能否正常地自动修复。发现了吗？像微服务这样的复杂系统，它的冗余和自愈的能力已经像是一个生命体了，即使出现了一些突发的故障，比如某些服务的宕机，它也不会一下子全部瘫痪。

除了软件领域，与人类社会密切相关的系统，包括天气系统、生态系统、经济系统、社会系统，甚至包括人体本身，它们也都是复杂系统，所以现在的很多学科都在采用复杂系统的思维做研究。比如，采用演化的思维做心理学的研究，就形成了进化心理学的分支（其实更恰当的翻译方法是演化心理学，因为演化是没有方向性的）。这个学科的基本逻辑，就是现在人类具有的某种心理特质（比如为什么恋爱中男人更主动，女人更矜持），都是在进化中形成的。因为凡是没有这种心理特质的人类，都已经在进化过程中被淘汰了。

再进一步，其实你根据上面的信息可以得出一个结论：原来文科生研究的对象都是复杂系统。你一旦意识到这一点，你就可以通过复杂系统的研究成果，去研究原来属于文科生的研究范畴，比如说社会学、经济学、文学和哲学，从而拥有方法论上的优势。

给你简单举个例子，经济学中的宏观经济学部分，就是针对复杂系统的。这也是为什么大家总是说经济学家都是事后诸葛亮的原因：复杂系统是很难被简单地驾驭的。

甚至，你也可以用复杂科学的视角来重新审视哲学，特别是一些古代的哲学思维。因为基本上这些古老的哲学思想都是复杂系统的描述，是让你能够更好地适应自然系统和人类社会这两个复杂系统的一些解。比如说，儒家的思想，是理顺人际间的互动关系，从而缔造一个稳定的社会系统；而道家的思想，则是描述了包含人类社会和自然界的一个更大的系统规律。

有意思的是，凯文 · 凯利在《失控》的最后一讲，总结了复杂系统的特征，有很多地方跟道家的思想非常契合。比如说，“世界是从无中创造出来的”“从无数神经元的连接中，涌现出了大脑；而分子层面的连接，则涌现出了细胞”。

可以说，从《失控》这本书开始，就引起了我对复杂科学的兴趣，这个主题下的其他书籍，比如《复杂》，也非常值得你一读。

好，接下来，我再给你分享另一类好书，是关于文化的。而且它跟复杂科学这个主题，也是存在联系的。

文化与地方志

我从大学起，就对“文化”这个主题非常感兴趣，跟东西方文化有关的东西我都乱看了一气。大学时我读过一本书，是房龙的《人类的故事》，非常喜欢，因为它不但描述了历史事实，还描述了推动历史发展背后的原因和规律。我当时想，如果历史都这么写，那么大家学历史的时候肯定不会觉得枯燥。

因为我的思维特点是非常理科生的，我很难记住那些相互之间没有逻辑关系的事实，我也很难接受强加过来的一套体系，除非我能弄清楚它背后的逻辑。而如果一本书，它能讲清楚事实背后的因果关系的脉络，就比较令人愉悦了。

而我前面所说的复杂系统的一些研究成果，就可以用来理解这些文化背后的逻辑规律。我挺喜欢的一个独立学者王东岳，他写了一本书叫做《物演通论》。王东岳很喜欢解读东西方文化背后的脉络，看他写的书就让人有一下子把厚厚的书读薄的感受，非常过瘾。

不过我想，如果我没有读过《失控》及其相关理论，我可能又会对王东岳此人惊为天人，对其著作惊为天书。但在有了前面的知识积累以后，我就不会那么惊讶了。因为王东岳先生的思考，也是建立在大半个世纪以来的科研成果的基础上的。物演的“演”字，就是演化思维的体现。当然，他能够进行提炼和再创造，构造一个完整的知识体系，也相当值得敬佩。

其实说了这么多，我的意思是，文化可以用复杂科学的思维来解构。这个方法，特别适合像我这样的、擅长逻辑思维的理科生们。每当你观察到一个文化现象，你都能解构出这背后的原因，岂不是很有意思呢？

作为一个北方人，我这几年大部分的时间都在厦门，对这里的闽南文化做了饶有兴趣的观察。去过厦门旅游的同学，应该都知道厦门的文艺气氛还挺浓厚的。那为什么厦门会有这种调调呢？还有，你在旅游的时候，应该会发现厦门的一种小吃，叫做沙茶面。那为什么沙茶面会在厦门文化中涌现出来呢？

这就需要结合闽南这个地方的地理、历史等各个要素及其互动关系来做分析。不过，我准备在课后的留言里，再分享我对这几个问题的看法。你有兴趣的话，也可以发表你的观点。

类似的文化方面的问题，还能提出很多来，比如：

为什么泉州会成为海上丝绸之路的起点？

为什么孔圣人出在山东，而历代出状元最多的省份，却都在南方？

中国有很多古镇，每个古镇在历史上肯定都是富甲一方的地方，那究竟是什么因素才促使它们兴盛起来的？

如果某个地方有一个地理标识产品，是某种柿子，你能推测出那里的地质特点吗？

……

去年的时候，我因为一个项目，翻阅了某县的县志，结果没想到我会对县志如此感兴趣，读得津津有味。我才发现，通过县志我能了解一个地方的地理、历史、经济、文化、重要人物等各种信息。通过这些信息，我基本上就能看到一个由很多要素相互作用构造出来的一个复杂系统，就能读懂当地各种文化的成因，这非常有意思。

中国的很多文化积淀很丰富。如果有机会能够一点一点地解读过去，那该多好。我估计我会一直保持阅读并解读地方志的兴趣。最近回老家，家人又给了我一本我们县在民国时代的县志。看着这些书籍，我有一种强烈的感觉：即使你是在这里生、这里长的，你也不一定真的了解本地的文化。

我为什么会推荐你去读地方志和其他讲解文化现象的书，读懂自己的本地文化呢？

第一层原因，是呼应我在加餐 2“学习技术的过程，其实是训练心理素质的过程”中，提倡你多学点“无用之学”的观点。哪怕只是让你的灵魂更有趣，不是也挺好的吗？

第二层原因，是我作为一个理科生的思维方式。把自己所处的社会系统理解清楚，能够透过现象看到后面的逻辑，不是很有意思吗？

第三层原因，如果你能够运用复杂科学的思维，来理解现在的社会系统，其实是有实际意义的。举个例子，如果你要撰写一个商业计划，或者想给一个企业写一套软件，这就需要你理解其当前的商业系统、理解一个企业组织具体是如何运行的。而你之前的这些阅读积累，就会成为你的底蕴，成为你的智慧源泉呀！

小结

今天这一讲，我推荐了两本书，或者说是两类书。一类书，是以《失控》为代表，讲述与复杂性相关的话题。另一类书，是以地方志为代表的文化载体。之所以给你推荐这两类书，是因为它们给我如何观察和理解这个世界开启了一扇窗户，并且给我这样一个严谨的理科生，提供了一条去打开文史哲的大门的独特的、有优势的途径，希望能对你有所启发。

思考一下

你有没有阅读过《失控》？你对复杂科学有什么了解？复杂科学在你的领域里有什么应用？

你对自己出生地的文化了解吗？你有没有曾经发现一个文化现象背后的逻辑脉络？你觉得多研究点文化现象对于自己的职业生涯有好处吗？

欢迎在留言区发表你的观点。如果今天的加餐让你有所启发，也欢迎把它分享给你的朋友。

不定期加餐4 | 从身边的牛人身上，我学到的一些优秀品质

你好，我是宫文学。

今天的加餐环节，我想跟你分享一下让我很敬佩的那些牛人，以及从他们身上我所能学到的优秀品质。我希望你也能从这些人的故事上得到启发。这里为了叙述方便，我就不提具体的名字了。你只需要了解他的故事，从中有所感悟就好了。

我把这些牛人分为了两类，一类是搞技术的，一类是创业的。由于我自己也身兼两重身份，所以我很关注这两类人中能带给我启发的人。

首先来说说第一类人，搞技术的，也就是我们常说的极客们。

我所理解的极客

我曾经在技术圈子里参加过比较多的活动，特别是开源圈子的活动，因此也接触了不少技术大牛，国内国外的都有。

早在 2000 年的时候，我就听过理查德·斯托曼（Richard Stallman）的讲座，听他布道自由软件。Stallman 是 GNU 这个组织的创始人，他也发起了 GPL 开源协议。更重要的是，他是 GCC 编译器的主要作者，所以跟我们这门课也是很有渊源的。记得当时他给我们放一个幻灯片的时候，用的是 Linux 上的一个软件，界面没有微软的 PowerPoint 那么酷炫。但你能想到，Stallman 是绝对不会用 PowerPoint 的。

后来在参加和组织开源技术活动的过程中，我也接触了不少国内国外的技术团队，他们在很多方面刷新了我的认知、拓宽了我的视野，也让我更理解极客都是一些什么样的人。

在我看来，这些人应该就是合格的极客。那么，怎样才能被称为极客？是技术水平高吗？我想不是的。技术水平高，其实只是一个结果。真正让极客显得与众不同的，其实是他们对待技术的态度，乃至是对待人生的态度。这些特质，也能给所有做技术的人一些启发。

首先，是热爱技术。

跟普通人只是把技术作为一个谋生的手段不同，极客们是真心喜欢技术，热衷于钻研和探讨各种技术细节。他们在对待工作的时候，绝不会把某项工作做到能交差就行，他们想要做到完美。

我之前公司的一位股东，他在做程序员的时候，曾经接到领导的一项任务，给了他一块语音板子，让他研究一下能否做呼叫中心的功能。两个星期以后，再问他，技术上是否可行？他说，已经做完了。不仅做完了，他还考虑了各种扩展性。比如，给他的板子只有八个语音口，但他写的程序考虑了用不同的板子，有不同的口的情况。以至于后来很多年，基于他的程序做的呼叫中心系统，底层都不用做很大的改动。

我这位朋友，我在加餐 2中也提到过。他因为对于底层软件的深刻理解力，被挖到中间件公司做老总。后来又在创业什么的，最近又写了一套科幻小说。不管什么时候，我总能从他身上吸取到一些东西。

另一个例子，是我一个在苹果工作的同学提到的。这位同学负责苹果的文件系统的开发，我下面还会给你讲他的故事。这里是他讲的另一件事情。一次，一位博士分配到他们组，一时没有合适的工作给他干，就先让他做一阵测试。结果这位老兄，彻底升级了测试系统，对于大量的工作都实现了自动化测试，给整个团队带来了巨大的价值。

这个故事也让我更新了看待测试工作的视角。我现在基本上不会去招聘那些因为对自己的技术能力没有信心，而选择去做测试工作的人。我认为测试工作需要极大的技术热情才能做好。

我想，不管是从事什么岗位，能够热爱自己所做的事情，都是非常值得庆幸的。反过来，如果不喜欢自己所做的事情，为什么要去凑合呢？

换句话说，能够做自己喜欢的事情，其实是有所取舍、有所牺牲的。林纳斯·托瓦兹（Linus Torvalds）就喜欢领着一拨人折腾 Linux。如果他去做某个大公司的 CTO 甚至是创业合伙人，也无不可。但他选择的是自己喜欢的生活方式。他没有太去想自己因此损失了多少发财的机会。

这就涉及到了第二点，就是极客们洒脱的生活态度。

极客们所展现出来的这个特质，从某种意义上来看是更具魅力的。很多极客，都是不愿意以“生活所迫”为借口，选择自己不喜欢的工作和生活方式。

我在加餐 1分享远程办公话题的时候，就提到过一家芬兰公司。这家公司都是远程办公的，其中有的员工，是一边全球旅游，一边工作的。这些技术型的公司，正是以这种方式，吸引那些真正的极客加入。

还有一次我参加一个技术活动，我的朋友 C 指着一个老外说，这家伙在泰国买了一个小岛自己住，还弄了个度假村什么的。说实话，这样的归园田居的生活方式，对像我们这样浸染在中华文化中的人来说，是有很大的吸引力的。但我们有多少人敢于不从众，去选择自己喜欢的生活方式呢？

我还有的朋友是依托自己的技术创业的。创业这件事当然很不容易，但他们通常都会保持积极乐观的态度，并没有因为自己的项目没有及时被社会认可，就变得垂头丧气。

那第三点，就是极客们看待这个世界的方式：用代码说话。

极客这群人，是不大讲政治的。他们一般只认真实的本事。Linus 就有一句名言“Talk is cheap, show me the code.”，这也代表了极客们的精神。一个人的水平如何，看看他写的代码，或者至少看看他发表的文章，其实差不多就有数了，这个是伪装不了的。

早在智能手机流行前，有一次聚会，我一个搞 Linux 的朋友 F，就拿出了一台手机，里面装着 Linux、图形界面、App 什么的，看着都还行。这都是他鼓捣出来的。其实再加把劲，比如支持用 Java 开发应用，这就是一个 Android 系统了。而 Android 的创始人安迪·鲁宾（Andy Rubin），差不多也是这样一个极客。前一阵，我跟一个公司的老总聊天。他问我，为什么中国搞不出安卓来？我给他解释了原因。其实不是我们没有这样的技术，在极客们的眼里，最早的那个安卓版本也没什么大不了的。只不过我们没有掌握技术生态而已。

极客们一般对系统底层的技术都比较熟悉。像安卓系统这种看似很高大上的东西，不会让他们心生畏惧。这些人在互相交流的时候，也会谈论一些底层技术。几句话下来，心里已经有数。

然后呢？他们之间会缔结惺惺相惜的友谊。两个极客之间的交往可以极其简单，他们甚至不需要见过面，只需要见过对方的代码，或者读过对方的文章，就会认可彼此。如果有事情，直接打招呼就行。

某互联网大厂是如何把自己的底层技术搞扎实的呢？据我了解，就是找到一个开源圈的大牛。这位大牛进去了以后，又给技术社区的其他人打招呼，说这里有什么技术难题需要解决，过来吧。于是就聚集了一个小组的牛人，搞出了非常好的成绩。这就是极客们的特殊的社交方式：他们知道 who is who，并且志同道合的人愿意聚到一起。如果光靠 HR 部门和猎头公司来做，要想达成这样的结果是很难的。

Github 在某种意义上也是把极客们的这种倾向给充分地引导了出来。它从一个代码托管工具，几乎已经变成了程序员的社交网站。

这里我是想说明一个观点，那就是技术人并没有怀才不遇这一说。把真本事亮出来，所有的事情会变得简单很多。

好了，这就是我总结的极客们给我的三点印象：热爱技术、生活洒脱、凭本事说话。这些特质，都是我很欣赏的，也常常作为参照来调整自己。

比如说，我觉得自己也挺热爱技术的，但是在前些年，我觉得自己不够洒脱，做不好取舍，总是想各方面都兼顾，结果哪方面都顾不好。所以还不如在自己喜欢的事情上全情投入，不去计较太多得失，反倒会更加心情愉快，做事情的结果也更好。

你可能会问，那这些极客都发展得怎么样呢？

我所认识的极客，有的是在小公司工作，有的是在大公司工作，还有的是在创业。不过，不管从事什么岗位，似乎都发展得不错。我想，这是因为他们从底层上选择了一个更好的发展逻辑：首先是做好取舍，让自己能够专注技术；在拥有了比较好的技术底蕴以后，他们也有更好的施展自己才华的平台；在专注于技术价值的同时，他们的生活也变得简单和健康。

OK，讲完了搞技术的，我再讲讲搞创业的朋友的故事，以及他们给我的启迪。

创业者这个物种

我周围的朋友有不少是搞创业的。这些人往往都有一些很特别的点，让我欣赏、赞叹乃至仰慕。

首先一点，是坚韧不拔的意志力。

我们都知道，创业肯定不是简单的事情。而让一个企业能够穿越惊涛骇浪，最重要的就是创始人坚韧不拔的意志品质。

我本科的同班同学中，就有一个创业者，他公司的主营业务是户外运动用品，目前已经做到上市了。他的性格就很坚韧，我给你讲两个故事。

第一个，在他成为我北大的同学之前，其实曾经考上了一所技术类的军校。但后来他发现自己并不喜欢那里，于是就想退学。可是，军校岂可以当作儿戏，想来就来，想走就走？为了能够退学，他想了很多办法，包括自己注射大剂量的抗生素，产生精神恍惚的效果，以便让校医诊断为精神疾病；此法不成，又从 3 楼的阳台上滚下来，想把胳膊摔断，以此理由退学……后来校领导实在看他态度坚决，也就同意了他退学。他又重新参加了高考，选择了他喜欢的学校和专业。

第二个，大约在 2006 年，我们一些同学因为毕业十周年又聚到了一起，去内蒙古草原上玩，其中一项活动就是骑马。我的这位同学骑术很好，在草原上策马狂奔。不过，在一个地方，突然马失前蹄，他从马背上摔了下来。这真是很惊险的一个意外，我们在场一群人看了都心惊肉跳。不过，他休息了一会以后，又要了一匹马，上马继续策马狂奔。晚上，我们问他，为什么刚摔了又骑？他说，如果今天不骑，以后就不敢骑了。

说到这，我想再多讲一个例子。这是我同一级的另一个同学的故事，他是社会学系的。如果我说他的名字，很多同学应该都会知道。他从 2000 年开始做一个与汽车有关的网站，结果后来互联网泡沫破裂，然后投资人撤资。他就自己筹了 2000 万买下了投资人的股份，坚持做了下去，直到 2011 年上市。想想看，那个年头的 2000 万，是多大的压力。但他就是咬着牙挺过来了。

我不知道有多少人能拥有像他们这样钢铁般的意志力。并且，令我沮丧的是，我怀疑这种个性可能主要是天生的？反正我是万万难以做到的。所以，创业这件事情，其实不是每个人都适合去做。而我这两个同学能做到上市，也绝不是偶然。

不过，为了不让自己的希望完全破灭，我还是倾向于相信意志和勇气这样的事情，至少在部分上是可以后天磨炼的。我在大学的时候练习过拳击，因为我觉得拳击可以锻炼人的勇气。来拳的时候不能眨眼，是拳击运动员的基本素质。那么在创业中，如果我们每次都去积极地面对挑战，那面对困难的能力也会越来越强。

我认识的其他几个创业者，虽然不像这两位那么夸张，但在意志力方面，也都属于罕见的。比如，某个技术社区，其创始人能够做到天天更新内容，十年如一日，这就是常人所不及的。最近我通过写编译原理的课程，也对内容编写这件事有了一定的体会。这样的事情，做一个星期、一个月、一个季度，是凭着兴趣和热情就可以做到的。而长年累月地去做，你要说没有意志力的因素，那是不可能的。

说完了强大的意志力，我再来说说我钦佩的这些人的第二点品质，就是有主见，不从众。

我观察，这些创业成功的人，往往判断事情都有自己的标准，这些标准往往与大众是不一致的。

还是说我同班的那个同学。在学校读书的时候，他经常就会消失不见了，过一阵再重新出现，他告诉我们，这次去陕北了，有什么感受，怎样怎样。过一段时间又会消失，回来后，说自己在新疆沙漠里独自走了几天，遇到被人追赶，差点殒命，等等等等。

等到快期末考试的时候，他拉着我在未名湖边给他补习高数，说能及格就行。几年以后，在创业的过程中，他还读了个清华的 MBA 班，也是连毕业证都没要。按他的意思来说，就是：学到知识就行了。证书什么的，不重要。

而我们这些俗人，天天使劲读书。等到毕业以后，又根据习惯和潮流，很多人又去出国，虽然我敢说，大部分同学那时候都想不清楚出国到底要干嘛。

所以从某种意义上来讲，比尔·盖茨、马克·扎克伯格等人敢于辍学创业，本身就意味着他们不是一般人。

而作为对比，还有一些人，都不管自己什么年纪了还在花高价去混文凭，不停地想往自己身上贴一些标签，来为自己壮胆。我觉得，这些人不要说创业了，给他一个重要的职位都是一件很冒险的事情。

前面我也提到了有些极客，会基于自己的兴趣爱好来创业。他们喜欢的技术和产品，往往在很长的时间内都不会得到社会的认可，不能变成有经济价值的商品。然而他们就是会坚持自己的方向。这些人，也是我学习的榜样。

这些技术创业者，有的发展比较顺利，但似乎也不是刻意为之。比如上海的小 X，我跟他在技术活动上有几面之缘。他搞了一个用于物联网的小小的 OS，搞了很多年了，前两年突然听说融了很多资，估值不错。我觉得资本投在这些人身上是投对了。

也有的朋友，会经历一些坎坷。但是他们总是按照自己的方式去折腾，保持对科技发展趋势的敏锐观察。每隔一段时间，我总能从他们那里听到一些新的思想和动态。就拿我一个做移动端底层平台朋友来说，他做这个方向已经很多年了。我相信他肯定会做成。不过先不说未来结果如何，至少我觉得他的生活状态是洒脱的、阳光的、不纠结的。

小结

今天的加餐，我给你分享了周围搞技术的和做创业的一些朋友的故事。这些故事跟你有什么关联呢？

首先，你选择了编译原理这门课程，基本上已经说明你有成为一名极客的潜质，否则也不会给自己这个挑战。但是在这个过程中呢，你可能会遇到很多的困难和心理上的纠结。我希望通过我分享的故事，能够帮助你做好取舍，丢掉包袱，健康阳光地拥抱作为一个技术从业者的职业生涯。

而如果你不小心选择了创业这条路，我也希望你能够像故事中的人物一样，去磨炼自己的意志力，以及坚持自己的主见。成功不成功不敢保证，至少你的生活会是很有价值的，不会后悔的。

以上也是对我自己的勉励，希望能跟你共勉。如果你或你身边也有类似的故事，欢迎在留言区分享出来。同样，也非常欢迎你把这一讲分享出去。

感谢你的阅读，我们结束语见！

划重点 | 7种编译器的核心概念与算法

你好，我是编辑王惠。

阶段性的总结复习和验证成果是非常重要的。所以，在 8 月 7 日到 8 月 12 日这为期一周的期中复习时间里，我们先来巩固一下“真实编译器解析篇”中的重点知识。你可以通过学习委员朱英达总结梳理的划重点内容，以及涵盖了关键知识点的 7 张思维导图，来回顾 7 种语言编译器的核心概念与算法。

另外，宫老师还精心策划了 10 道考试题，让你能在行至半程之时，做好自检，及时发现知识漏洞，到时候一起来挑战一下吧！

在期中复习周的最后，我还会邀请一位优秀的同学来做一次学习分享。通过他的学习故事，你也可以借此对照一下自己的编译原理学习之路。

好，下面我们就一起来复习这些核心的编译原理概念与算法知识吧。

Java 编译器（javac）

Java 是一种广泛使用的计算机编程语言，主要应用于企业级 Web 应用开发、大型分布式系统以及移动应用开发（Android）。到现在，Java 已经是一门非常成熟的语言了，而且它也在不断进化、与时俱进，泛型、函数式编程、模块化等特性陆续都增加了进来。与此同时，Java 的编译器和虚拟机中所采用的技术，也比 20 年前发生了天翻地覆的变化。

Java 的字节码编译器（javac）是用 Java 编写的，它实现了自举。启动 Java 编译器需要 Java 虚拟机（默认是 HotSpot 虚拟机，使用 C++ 编写）作为宿主环境。

javac 编译器的编译过程，主要涉及到了这样一些关键概念和核心算法：

词法分析阶段：基于有限自动机的理论实现。在处理标识符与关键字重叠的问题上，采用了先都作为标识符识别出来，然后再把其中的关键词挑出来的方式。

语法分析阶段：使用了自顶向下的递归下降算法、LL(k) 方式以及多 Token 预读；处理左递归问题时，采用了标准的改写文法的方法；处理二元表达式时，采用了自底向上的运算符优先级解析器。

语义分析阶段：会分为多个小的阶段，且并不是顺序执行的，而是各阶段交织在一起。

语义分析阶段主要包含：ENTER（建立符号表）、PROCESS（处理注解）、ATTR（属性分析）、FLOW（数据流分析）、TRANSTYPES（处理泛型）、TRANSPATTERNS（处理模式匹配）、UNLAMBDA（处理 Lambda）和 LOWER（处理其他所有的语法糖，比如内部类、foreach 循环等）、GENERATE 阶段（生成字节码）等。在 ATTR 和 FLOW 这两个阶段，编译器完成了主要的语义检查工作。

注意：生成字节码是一个比较机械的过程，编译器只需要对 AST 进行深度优先的遍历即可。在这个过程中会用到前几个阶段形成的属性信息，特别是类型信息。

参考资料：

关于注解的官方教程，参考这个链接。

关于数据流分析的理论性内容，参考龙书（Compilers Principles, Techniques and Tools）第二版的 9.2 和 9.3 节。也可以参考《编译原理之美》 的第27、28讲，那里进行了比较直观的讲述。

关于半格这个数学工具，可以参考龙书第二版的 9.3.1 部分，也可以参考《编译原理之美》的第 28 讲。

Java 语言规范第六章，参考Java 虚拟机指令集。

Java JIT 编译器（Graal）

对于编译目标为机器码的 Java 后端的编译器来说，主要可以分 AOT 和 JIT 两类：如果是在运行前一次性生成，就叫做提前编译（AOT）；如果是在运行时按需生成机器码，就叫做即时编译（JIT）。Java 以及基于 JVM 的语言，都受益于 JVM 的 JIT 编译器。

在JDK 的源代码中，你能找到 src/hotspot 目录，这是 JVM 的运行时：HotSpot 虚拟机，它是用 C++ 编写的，其中就包括 JIT 编译器。

Graal 是 Oracle 公司推出的一个完全用 Java 语言编写的 JIT 编译器。Graal 编译器有两个特点：内存安全（相比 C++ 实现的 Java JIT 编译器而言）；与 Java 配套的各种工具（比如 ID）更友好、更丰富。

Java JIT 编译器的编译过程，主要涉及到了这样一些关键概念和核心算法：

分层编译：C0（解释器）、C1（客户端编译器）、C2（服务端编译器）。不同阶段的代码优化激进的程度不同，且存在 C2 降级回 C1 的逆优化。

IR 采用了“节点之海（Sea of Nodes）”，整合了控制流图与数据流图，符合 SSA 格式，有利于优化算法的编写和维护。

两个重要的优化算法：内联优化和逃逸分析。

几个重要的数据结构：HIR（硬件无关的 IR）、LIR（硬件相关的 IR）、CFG（控制流图）。

寄存器分配算法：LinearScan。

金句摘录：“编译器开发的真正的工作量，都在中后端。”

参考资料：

GraalVM 项目的官方网站；Graal 的Github 地址；Graal 项目的出版物。

基于图的 IR 的必读论文：程序依赖图 -J. Ferrante, K. J. Ottenstein, and J. D. Warren. The program dependence graph and its use in optimization. July 1987；Click 的论文 -A Simple Graph-Based Intermediate Representation；介绍 Graal IR 的论文 -Graal IR: An Extensible Declarative Intermediate Representation。

关于优化算法：多态内联 -Inlining of Virtual Methods；逃逸分析 -Escape Analysis for Java；部分逃逸分析 -Partial Escape Analysis and Scalar Replacement for Java。

Python 编译器（CPython）

Python 诞生于上个世纪 90 年代初，作者是荷兰计算机程序员吉多·范罗苏姆（Guido van Rossum）。Python 语言的特点是：自身语法简单，容易掌握，强调一件事情只能用一种方法去做；具备丰富的现代语言特性，如 OOP、FP 等；其实现机制决定了易于集成 C++ 扩展，不仅便于利用一些已有的、经典开源的高性能的 C/C++ 库，同时也可以很方便地编写自己的 C++ 扩展，实现一些高性能模块。

另外，Python 使用了 pgen 这样的生成编译器的工具。pgen 能够基于语法规则生成解析表（Parse Table），供语法分析程序使用。你可以通过修改规则文件来修改 Python 语言的语法，pgen 能给你生成新的语法解析器。它是把 EBNF 转化成一个 NFA，然后再把这个 NFA 转换成 DFA。基于这个 DFA，在读取 Token 的时候，编译器就知道如何做状态迁移，并生成解析树。Python 用的是 LL(1) 算法。

CPython 编译器编译器的编译过程，主要涉及到了这样一些关键概念和核心算法：

语法分析：首先是生成 CST（Concret Syntax Tree，具体语法树），接着生成 AST（抽象语法树）。CST 的特点是完全基于源程序结构构建出树结构，它比 AST 啰嗦，但更精确地反映了语法推导的过程。而 AST 的特点是去掉了树结构上繁杂冗余的树枝节点，更简明扼要，更准确地表达了程序的结构。

语义分析：Python 通过一个建立符号表的过程来做相关的语义分析，包括做引用消解等。Python 语言使用变量的特点是不存在变量声明，每个变量都是赋值即声明，因此在给一个变量赋值时需要检查作用域，确认当前操作是给全局的变量赋值，还是在局部给声明新变量。

生成字节码：这个工作实际上包含了生成 CFG、为每个基本块生成指令，以及把指令汇编成字节码，并生成 PyCodeObject 对象的过程。另外，生成的字节码在最后放到解释器里执行之前，编译器还会再做一步窥孔优化工作。

运行时机制：Python 的运行时设计的核心，就是 PyObject 对象，Python 对象所有的特性都是从 PyObject 的设计中延伸出来的。其虚拟机采用了栈机的架构。

参考资料：

python.org 网站：下载3.8.1 版本的源代码。

GDB 的安装和配置：参考这篇文章。

Python 的开发者指南网站。

pgen 的工具程序：Parser/pgen。

注：由于 CPython 最新的 Master 分支上的代码调整，此处 pgen 的链接地址调整为 CPython3.9 版本分支上的 pgen 相关代码。

Python 的字节码的说明。

Python 的内置类型。

JavaScript 编译器（V8）

V8 是谷歌公司在 2008 年推出的一款 JavaScript 编译器，主要由 C++ 编写而成。V8 主要应用于 Chrome 浏览器，后来也被开源社区中诸如 Node.js 等项目所使用。其最为突出的特点就是“快”，由于 JavaScript 是在浏览器下载完页面后马上编译并执行，它对编译速度有更高的要求。因此，V8 采用了一系列技术手段优化编译和启动阶段运行速度。

在设计上，V8 结合了分阶段懒解析、空间换时间等设计思路，突出了解析、启动阶段运行的时间开销。

对源代码的 Parse，进行了流（Stream）化处理，也就是边下载边解析。

预解析（PreParse）处理，也就是所谓懒解析最核心的设计思想，每个 JS 函数只有被执行的时候才会解析函数体的内部逻辑。

另外，V8 的很多地方体现出了与 Java 编译器异曲同工之处。比如，它将 JavaScript 源代码的编译，分为了由 Ignition 字节码解释执行和 TurboFan 的 JIT 编译机器代码执行两部分组成，类似于 Java 编译器的字节码解释执行和 Graal 优化编译后执行两阶段；TurboFan 编译器的 IR 也采用了 Sea of Nodes，这一点类似于 Java 的 Graal 编译器，且也涉及到了内联优化和逃逸分析算法。

其运行方式分为两类：

常规情况下，Ignition 字节码解释执行；

编译器判定热点代码，TurboFan JIT 编译成机器码执行，并且 TurboFan 会依赖一些 Ignition 解释执行过程中的运行时数据，来进行进一步优化，使机器码尽可能高效。

因为 JavaScript 是动态类型语言，因此对函数参数类型的推断以及针对性优化是一个 V8 的核心技术。V8 涉及到的其他优化算法有：

隐藏类（Hidden Class）。相同“形状”的 JS 对象会被以同一个隐藏类维护其数据结构。

内联缓存（Inline Caching）。针对隐藏类查找属性值时的指针偏移量，进行内联缓存，这属于结合隐藏类技术做进一步性能的优化。

参考资料：

V8 项目的官网，以及 V8 的源代码 -官方文档。

了解 V8 的解析器为什么速度非常快：Blazingly fast parsing, part 1: optimizing the scanner；Blazingly fast parsing, part 2: lazy parsing。

了解 Ignition 的设计：Ignition Design Doc，宫老师在 Github 上也放了一个拷贝。

了解 Ignition 的字节码：Understanding V8’s bytecode。

V8 的指针压缩技术：Pointer Compression in V8。

介绍 V8 基于推理的优化机制：An Introduction to Speculative Optimization in V8。

关于 Ignition 字节码做优化的论文：Register equivalence optimization，宫老师在 Github 上也放了一份拷贝。

Julia 的编译器

Julia 语言最初发行于 2012 年，其最初是为了满足高性能数值分析和计算科学的需要而设计的。Julia 同时兼具了静态编译型和动态解释型语言的优点：一方面它的性能很高，可以跟 Java 和 C 语言媲美；另一方面，它又是动态类型的，编写程序时不需要指定类型。

Julia 编译器的特点是：

作为动态类型语言，却能拥有静态类型语言的执行速度，最关键的原因是使用了 LLVM 作为编译器后端，针对动态类型的变量，在运行时由编译器 JIT 编译生成多个版本的目标代码，保证程序的高性能；

由 C、C++、Lisp 和 Julia 四种语言开发而成，编译器前端主要采用 Lisp 实现；

考虑到对函数式编程的支持，有别于面向对象编程的“单一分派”，Julia 的编译器提供了“多重分派”的功能。

Julia 编译器的编译过程，主要涉及到了这样一些关键概念和核心算法：

Julia 的编译器也是采用了递归下降算法来实现语法分析。

其内部 IR 采用了 SSA 格式，主要作用是完成类型推断和内联优化。

Julia 的 IR 会被转化成 LLVM 的 IR，从而进一步利用 LLVM 的功能。在转换过程中，会用 Julia 的内建函数，这些内建函数代表了 Julia 语言中抽象度比较高的运算功能。

参考资料：

LLVM 的官网，以及 LLVM 的源代码。

Julia 的开发者文档中有对如何使用 LLVM 的介绍：Working with LLVM。

对 LLVM 中的各种 Pass 的介绍：LLVM’s Analysis and Transform Passes。

《编译原理之美》的第 25 讲和第 26 讲：宫老师对 LLVM 后端及其命令行工具做了介绍，并且还手工调用 LLVM 的 API，示范了针对不同的语法结构（比如 if 结构）应该如何生成 LLVM IR，最后即时编译并运行。你可以参考一下。

Go 语言编译器（gc）

Go 语言是 Google 开发的一种静态强类型、编译型、并发型，并具有垃圾回收功能的编程语言，又名 Golang。Go 广泛应用于 Google 的产品以及许多其他组织和开源项目，其创建的初衷就是主要面向于部署于大量服务器之间的分布式程序，也就是我们今天所说的“云”。因此，Go 的主要优势聚焦于服务端高并发场景。

Go 语言编译器的特点是：

gc 编译器除了少量标准库的内容是用汇编写的以外，其绝大部分逻辑，都是用 Go 语言本身写的，因此实现了较为完整的自举（Bootstraping），从前端到后端的整个流程都使用 Go 语言实现，在编程语言中是较为罕见的；

教科书级别的设计，源代码完全自举、代码干净利索，因此非常适合作为学习参考。

Go 语言编译器的编译过程，主要涉及到了这样一些关键概念和核心算法：

编译器前端：gc 编译器的词法分析和语法分析使用的都是非常经典、传统的算法，如手写的递归下降算法、处理二元表达式时使用操作符优先级算法。

中间代码阶段：SSA 格式的 IR；基于 CFG 的 IR，利于“死代码”的发现与删除；多遍（Pass）的优化框架。

机器码生成阶段：线性扫描算法；官方的 gc 编译器并没做指令重排，这是基于编译过程中时间开销的考虑。

参考资料：

介绍 gc 编译器的主要结构：Introduction to the Go compiler官方文档。

介绍 gc 编译器的 SSA：Introduction to the Go compiler’s SSA backend官方文档。

Go compiler internals: adding a new statement to Go - Part 1、Part2。在这两篇博客里，作者做了一个实验：如果往 Go 里面增加一条新的语法规则，需要做哪些事情。我们能够很好地、贯穿性地了解一个编译器的方法。

介绍 gc 编译器的 SSA 优化规则描述语言的细节：Go compiler: SSA optimization rules description language。

介绍 Go 汇编的细节：A Primer on Go Assembly和A Quick Guide to Go’s Assembler。gc 编译器采用的汇编语言是它自己的一种格式，是“伪汇编”。

MySQL 的编译器

MySQL 是一个开放源码的关系数据库管理系统，原开发者为瑞典的 MySQL AB 公司，后几经辗转，目前归属于 Oracle 旗下产品。在过去，MySQL 性能高、成本低、可靠性好，因此成为了最流行的开源数据库。SQL 可以称得上是最成功的 DSL（特定领域语言）之一，MySQL 中的 SQL 解析模块则是这门 DSL 的非常具有可参考性的一个实现。MySQL 使用 C++ 编写，有少量几个代码文件是用 C 语言编写的。

MySQL 的编译器的特点是：

SQL 作为 DSL 中最具有代表性的一种语言，学习它的编译器的实现，可以为我们自己设计面向业务的 DSL 提供参考。

手写的词法分析、用工具（bison，GNU 的 yacc）生成的语法分析。

基于 LALR 处理二元表达式。

中后端优化方面：MySQL 是解释执行，并没有做太多的机器相关优化；在机器无关优化方面，除了一些编译领域传统的优化技术之外，还做了一些针对数据库特定场景的优化方式。

MySQL 的编译器的编译过程，主要涉及到了这样一些关键概念和核心算法：

词法分析和语法分析

词法分析：手写的词法分析器。

语法分析：由 bison 生成。bison 是一种基于 EBNF 生成语法分析程序的工具，可视为 GNU 版的 yacc。

bison 支持的语法分析算法：LALR 算法。

语义分析

MySQL 中一些重要的数据结构：THD 线程对象、Table_ident 对象。

上下文处理：基于 contextualize 的上下文处理。

基于数据库“业务逻辑”的引用消解：库名、表名、列名、入口、查找和作用域（子查询）

机器无关优化

典型的优化：常量传播、死代码消除、常量折叠。

针对数据库场景的优化：产生执行计划、生成 JOIN 对象。

机器相关优化

MySQL 实际上对表达式是解释执行，所以并没有真正意义上做任何机器相关优化。

列举了 PG 另一种类似 MySQL 的 DB，有通过 LLVM 的 JIT 优化。

参考资料：

下载MySQL 的源代码；跟踪 MySQL 的执行过程，要用 Debug 模式编译 MySQL，具体步骤可以参考这篇开发者文档。

MySQL 的内行手册：MySQL Internals Manual。它能给我们提供一些重要的信息，但文档内容经常跟源代码的版本不同步，比如介绍源代码的目录结构的信息就过时了。需要注意一下。

bison 的手册。

如果要加深对 MySQL 内部机制的了解，宫老师推荐了两本书：OReilly 的《Understanding MySQL Internals》，以及《Expert MySQL》。

期中考试 | 这些编译原理知识，你都掌握了吗？

你好，我是宫文学。

到这里，我们的课程就已经更新一半了，今天，我们来进行一场期中考试。我出了一套测试题，共有 5 道判断题、5 道多选题，满分 100，核心考点都出自前面的“预备知识”模块和“真实编译器解析”模块。

我建议你来认真地做一下这套题目，检验一下自己的学习效果。答完题之后，你也可以回顾试卷的内容，对不太理解或答错的问题，进行深入思考和学习。如果有不明白的，欢迎随时在留言区提问，我会知无不言。还等什么？一起来做下这套题吧！

期末答疑与总结 | 再次审视学习编译原理的作用

你好，我是宫文学。到这里，咱们这门课程的主要内容就要结束了。有的同学在学习课程的过程中呢，提出了他感兴趣的一些话题，而我自己也会有一些想讲的话题，这个我也会在后面，以加餐等方式再做一些补充。接下来，我还会给你出一套期末测试题，帮你检测自己在整个学习过程中的所学所得。

那么，在今天这一讲，我们就来做个期末答疑与总结。在这里，我挑选了同学们提出的几个有代表性的问题，给你解答一下，帮助你更好地了解和掌握本课程的知识内容。

问题 1：学习了编译原理，对于我学习算法有什么帮助？

@无缘消受人间富贵：老师，想通过编译器学算法，单独学算法总是不知道有什么意义，每次都放弃，老师有什么建议吗？但是看到评论说用到的都是简单的数据结构，编译器用不到复杂的数据结构和算法？

针对这位同学提出的问题，我想谈一谈我对算法学习的感受。

前一阵，我在跟同事聊天时，提到了一个观点。我说，大部分的程序员，其实从来都没写过一个像样的算法。他们写的程序，都是把业务逻辑简单地翻译成代码。那么，如果一个公司写出来的软件全是这样的代码，就没有什么技术壁垒了，很容易被复制。

反之，一些优秀的软件，往往都是有几个核心的算法的。比如，对于项目管理软件，那么网络优化算法就很关键；对于字处理软件，那么字体渲染算法就很关键，当年方正的激光照排系统，就是以此为基础的；对于电子表格软件，公式功能和自动计算的算法很关键；对于视频会议系统，也必须掌握与音视频有关的核心算法。这样，因为有了算法的技术壁垒，很多软件就算是摆在你的面前，你也很难克隆它。

所以说，作为一名软件工程师，你就必须要有一定的算法素养，这样才能去挑战那些有难度的软件功能。而作为一个软件公司，其实要看看自己在算法上有多少积淀，这样才能构筑自己的技术壁垒。

那么，编译原理对于提升你的算法素养，能带来什么帮助呢？我给你梳理一下。

编译原理之所以硬核，也是因为它涉及了很多的算法。

在编译器前端，主要涉及到的算法有 3 个：

有限自动机构造算法：这是在讲词法分析时提到的。这个算法可以根据正则文法，自动生成有限自动机。它是正则表达式工具的基础，也是像 grep 等强大的 Linux 命令能够对字符串进行模式识别的核心技术。

LL 算法：这是在讲自顶向下的语法分析时涉及的。根据上下文无关文法，LL 算法能够自动生成自顶向下的语法分析器，中间还涉及对 First 和 Follow 集合的计算。

LR 算法：这是在讲自底向上的语法分析时涉及的。根据上下文无关文法，LR 算法能自动生成自底向上的语法分析器，中间还涉及到有限自动机的构造算法。

总的来说，编译器前端的算法都是判断某个文本是否符合某个文法规则，它对于各种文本处理工作都很有效。有些同学也在留言区里分享，他在做全文检索系统时就会用到上述算法，使得搜索引擎能更容易地理解用户的搜索请求。

在编译器后端，主要涉及到的算法也有 3 个：

指令选择算法；

寄存器分配算法；

指令重排序（指令调度）算法。

这三个算法也有共同点，它们都是寻找较优解或最优解，而且它们都是 NP Complete（NP 完全）的。简单地说，就是这类问题能够很容易验证一个解对不对（多项式时间内），但求解过程的效率却可能很低。对这类问题会采用各种方法求解。在讲解指令选择算法时，我介绍了贪婪策略和动态规划这两种不同的求解思路；而寄存器选择算法的图染色算法，则采用了一种启发式算法，这些都是求解 NP 完全问题的具体实践。

在日常工作中，我们其实也会有很多需要求较优解或最优解的需求。比如，在文本编辑软件中，需要把一个段落的文字分成多行。而如何分行，就需要用到一个这样的算法。再比如，当做一个报表软件，并且需要分页打印的时候，如何分页也是同类型的问题。

其他类似的需求还有很多。如果你没有求较优解或最优解的算法思路，对这样的问题就会束手无策。

而在编译器的中端部分，涉及的算法数量就更多了，但是由于这些算法都是对 IR 的各种分析和变换，所以 IR 采用不同的数据结构的时候，算法的实现也会不同。它不像前端和后端算法那样，在不同的编译器里都具有很高的一致性。

不过，IR 基本上就是三种数据结构：树结构、图结构和基于 CFG 的指令列表。所以，这些算法会训练你处理树和图的能力，比如你可以在树和图中发现一些模式，以此对树和图进行变换，等等。这在你日常的很多编程工作中也是非常重要的，因为这两种数据结构是编程中最常使用的数据结构。

那么，总结起来，认真学好编译原理，一定会给你的算法素养带来不小的提升。

问题 2：现代编程语言这么多，我们真的需要一门新语言吗？

@蓝士钦：前不久看到所谓的国产编程语言“木兰”被扒皮后，发现是 Python 套层壳，真的是很气愤。想要掌握编译原理设计一门自己的语言，但同时又有点迷茫，现代编程语言这么多，真的需要再多一门新语言吗？从人机交互的角度来看，任何语言都是语法糖。

关于是否需要一门新语言的话题，我也想跟你聊聊我自己的看法，主要有三个方面。当然，你也可以在此过程中思考一下，看看有没有什么跟我不同的见解，欢迎与我交流讨论。

第一，编程语言其实比我们日常看到的要多，很多的细分领域都需要自己的语言。

我们平常了解的都是一些广泛流行的通用编程语言，而进入到每个细分领域，其实都需要各自领域的语言。比如 SaaS 的鼻祖 Salesforce，就设计了自己的 Apex 语言，用于开发商业应用。华为的实验室在研发方舟编译器之前，也曾经研发了一门语言 Cm，服务于 DSP 芯片的研发。

第二，中国技术生态的健康发展，都需要有自己的语言。

每当出现一个新的技术生态的时候，总是有一门语言会成为这个技术生态的“脚本”，服务于这个技术生态。比如，C 语言就是 Unix 系统的脚本语言；JavaScript、Java、PHP 等等，本质上都是 Web 的脚本语言；而 Objective-C 和 Swift 显然是苹果设备的脚本语言；Android 虽然一开始用了 Java，但最近也在转成 Kotlin，这样 Google 更容易掌控。

那么，从这个角度看，当中国逐步发展起自己的技术生态的时候，也一定会孕育出自己的语言。以移动计算生态而言，我们有全球最大的移动互联网用户群和最丰富的应用，手机的制造量也是全球最高的。而位于应用和硬件之间的应用开发平台，我们却没有话语权，这会使中国的移动互联网技术生态受到很大的掣肘。

我在第 40 讲，也已经分析过了，Android 系统经过了很多年的演化，但技术上仍然有明显的短板，使得 Android 平台的使用体验始终赶不上苹果系统。为了弥补这些短板，各个互联网公司都付出了很大的成本，比如一些头部应用的核心功能采用了 C/C++ 开发。

并且，Android 系统的编译器，在支持新的硬件上也颇为保守和封闭，让中国厂商难以参与。这也是华为之所以要做方舟编译器的另一个原因。因为华为现在自研的芯片越来越多，要想充分发挥这些芯片的能力，就必须要对编译器有更大的话语权。方舟编译器的问世，也证明了我们其实是有技术能力的，可以比国外的厂商做得更好。既然如此，我们为什么要受别人的制约？华为方舟编译器团队其实也很渴望，在方舟编译器之后推出自己的语言。至于华为内部是否已经立项，这就不太清楚了，但我觉得这是顺理成章的事情。

另外，除了在移动端的开发上会受到很多掣肘，在云端其实也一样。比如说，Java 是被大量后端开发的工程师们所掌握的语言，但现在 Java 是被 Oracle 掌控的。你现在使用 Java 的时候，可能已经多多少少感受到了一种不愉快。先不说 Java8 之后的收费政策，就说我们渴望的特性（如协程、泛型中支持基础数据类型等），一直没有被满足，就会感觉不爽。

我在讲到协程的时候，就指出 Java 语言目前支持协程其实是很别扭的一种状态，它都是一些第三方的实现，并没有官方的支持。而如果 Java 的技术生态是由我们主导，可能就不是这样了。因为我国互联网的并发用户数如此之多，我们对更好的并发特性其实是更关切的。到目前为止，像微信团队解决高并发的问题，是用 C++ 加上自己开发的协程库才实现的。而对于很多没有如此强大的技术能力的公司来说，就只能凑合了。

第三，实现一款优秀的软件，一定会用到编译技术。

每一款软件，当发展到极致的时候，都会变得像一个开发平台。这也是《黑客与画家》的作者保罗·格雷厄姆（Paul Graham）表达的思维。他原来的意思是，每个软件写到最后，都会包含一个 Lisp 的变种。实际上，他所要表达的意思就跟我说的一样。

我前一段时间，在北京跟某公司的老总探讨一个优秀的行业应用软件。这个软件在上世纪 90 年代就被开发出来了，也被我国广泛采用。一方面它是一个应用软件，另一方面它本身也是一个开发平台。所以它可以经过定制，满足不同行业的需求。

但是，我们国内的软件行业的情况是，在去客户那里实施的时候，几乎总是要修改源代码，否则就不能满足用户的个性化需求。

很多软件公司想去克隆一下我刚才说的那套软件，结果都放弃了。除了有对领域模型理解的困难以外，缺少把一个应用软件做成软件开发平台的能力，是其中很大的一个障碍。

实际上，目前在很多领域都是这样。国外的软件就是摆在那里，但中国的工程师就是做不出自己的来。而对于编译技术的掌握和运用，就是能够提升国内软件水平的重要途径。

我在开头跟同事交流的时候，也提出了软件工程师技术水平修养提升的几个境界。其中一个境界，就是要能够利用编译技术，做出在更大范围内具有通用性的软件。如果你能达到这个境界，那么也一定有更大的发展空间。

问题 3：如何判断某门语言是否适合利用 LLVM 作为后端？

@ヾ(◍°∇°◍)ﾉﾞ：老师，很多语言都声称使用 LLVM 提升性能，但是在 Lua 领域好像一直是 LuaJIT 无法超越？

这个问题涉及到了如何利用后端工具的问题，比较有代表性。

LLVM 是一个通用的后端工具。在它诞生之初，首先是用于支持 C/C++ 语言的。所以一门语言，在运行机制上越接近 C/C++ 语言，用 LLVM 来做后端就越合适。

比如 Rust 用 LLVM 就很成功，因为 Rust 语言跟 C/C++ 一样，它们的目标都是编写系统级的程序，支持各种丰富的基础数据类型，并且也都不需要有垃圾收集机制。

那么，如果换成 Python 呢？你应该记得，Python 不会对基础数据类型进行细粒度的控制，不需要把整型区分成 8 位、16 位、32 位和 64 位的，它的整型计算可以支持任意长度。这种语义就跟 C/C++ 的相差比较远，所以采用 LLVM 的收益相对就会小一些。

而对于 JavaScript 语言来说，浏览器的应用场景要求了编译速度要尽量地快，但在这方面 LLVM 并没有优势。像我们讲过的隐藏类（Shapes）和内联缓存（Inline Caching）这样的对 JavaScript 很重要的机制，LLVM 也帮不上忙。所以，如果在项目时间比较紧张的情况下，你可以暂时拿 LLVM 顶一顶，Safari 浏览器中的 JavaScript 引擎之前就这么干过。但是，要想达到最好的效果，你还是编写自己的后端更好一些。

那对于 Lua 语言，其实你也可以用这个思路来分析一下，是采用 LLVM，还是自己写后端会更好一些。不过，由于 Lua 语言比较简单，所以实现后端的工作量应该也相对较小。

小结

这一讲，我主要回答了几个比较宏观的问题，它们都涉及到了编译原理这门课的作用。

第一个问题，我是从提升算法素养的角度来展开介绍的。编译原理知识里面涉及了大量的算法，我把它总结成了三大类，每类都有自己的特点，希望能对你宏观把握它们有所帮助。

第二个问题，其实是这门课程的一条暗线。我并没有在课程里去情绪化地鼓吹，一定要有自己的编译器、自己的语言。我的方式其实是想做一点具体的事情，所以在第二个模块中，我带着你一起探究了现有语言的编译器都是怎么实现的，破除你对编译器的神秘感、距离感；在第三个模块，我们又一起探讨了一下实现一门语言中的那些关键技术点，比如垃圾收集、并行等，它们都是如何实现的。

在课程最后呢，我又带你了解了一下具有中国血统的方舟编译器。我想说的是，其实我们不但能做出编译器和语言来，而且可能会做得更好。虽然我们对方舟编译器的分析还没有做完，但通过分析它的技术思路，你应该或多或少地感受到了它的优秀。所以，针对“我们真的需要一门新语言吗”这个问题，我的回答是确定的。并且，即使你不去参与实现一门通用的语言，在实现自己领域的语言，以及把自己的软件做得更具通用性这点上，编译原理仍然能发挥巨大的作用，对你的职业生涯也会有切实的帮助。

好，请你继续给我留言吧，我们一起交流讨论。同时我也希望你能多多地分享，做一个知识的传播者。感谢你的阅读，我们下一讲再见。

期末考试 | “编译原理实战课”100分试卷等你来挑战！

你好，我是宫文学。

咱们课程到这里就算正式更新完了，在临近告别前，我还给你准备了一份期末考试题，这套试卷共有 8 道单选题和 12 道多选题，满分 100，核心考点都出自前面讲到的所有重要知识，希望可以帮助你进行一场自测。

除此之外，我也很想知道你对这门课的建议，所以我也给你准备了一份问卷。欢迎你在问卷里聊一聊你的想法，也许就有机会获得礼物或者是课程阅码哦。

好了，话不多说，请你来做一做这套期末测试题吧！

热点问题答疑 | 如何吃透7种真实的编译器？

你好，我是宫文学。

到这里，咱们就已经解析完 7 个编译器了。在这个过程中，你可能也积累了不少问题。所以今天这一讲，我就把其中有代表性的问题，给你具体分析一下。这样，能帮助你更好地掌握本课程的学习思路。

问题 1：如何真正吃透课程中讲到的 7 种编译器？

在课程中，我们是从解析实际编译器入手的。而每一个真实的编译器里面都包含了大量的实战技术和知识点，所以你在学习的时候，很容易在某个点被卡住。那第一个问题，我想先给你解答一下，“真实编译器解析篇”这个模块的学习方法。

我们知道，学习知识最好能找到一个比较缓的坡，让自己可以慢慢爬上去，而不是一下子面对一面高墙。那么对于研究真实编译器，这个缓坡是什么呢？

我的建议是，你可以把掌握一个具体的编译器的目标，分解成四个级别的任务，逐步提高难度，直到最后吃透。

第一个级别，就是听一听文稿，看一看文稿中给出的示例程序和源代码的链接就可以了。

这个级别最重要的目标是什么？是掌握我给你梳理出来的这个编译器的技术主线，掌握一张地图，这样你就能有一个宏观且直观的把握，并且能增强你对编译原理的核心基础知识点的认知，就可以了。

小提示：关于编译器的技术主线和知识地图，你可以期待一下在期中复习周中，即将发布的“划重点：7 种编译器的核心概念和算法”内容。

在这个基础上，如果你还想再进一步，那么就可以挑战第二级的任务。

第二个级别，是要动手做实验。

你可以运行一下我给出的那些使用编译器的命令，打印输出调试信息，或者使用一下课程中提到的图形化工具。

比如，在 Graal 和 V8 编译器，你可以通过修改命令行的参数，观察生成的 IR 是什么样子。这样你就可以了解到，什么情况下才会触发即时编译、什么时候才会触发内联优化、生成的汇编代码是什么样子的，等等。

这样，通过动手做练习，你对这些编译器的认识就会更加具体，并且会有一种自己可以驾驭的感觉，赢得信心。

第三个级别，是看源代码，并跟踪源代码的运行过程，从而进入到编译器的内部，去解析一个编译器的真相。

完成这一级的任务，对你动手能力的要求更高。你最容易遇到的问题，是搭建一个调试环境。比如，调试 Graal 编译器要采用远程调试的模式，跟你调试一个普通应用还是不大一样的。而采用 GDB、LLDB 这样的工具，对很多同学来说可能也是一个挑战。

而且，你在编译源代码和调试的过程中也会遇到很多与配置有关的问题。比如，我用 GDB 来调试 Julia 和 MySQL 的时候，就发现最好是使用一个 Linux 虚拟机，因为 macOS 对 GDB 的支持不够好。

不过，上述困难都不是说真的有多难，而是需要你的耐心。遇到问题就解决问题，最终搭建出一个你能驾驭的环境，这个过程也会大大提升你的动手实践能力。

环境搭建好了，在跟踪程序执行的过程中，一样要需要耐心。你可能要跟踪执行很多步，才能梳理出程序的执行脉络和实现思路。我在课程中建议的那些断点的位置和梳理出来程序的入口，可以给你提供一些帮助。

可以说，只要你能做好第三级的工作，终归是能吃透编译器的运行机制的。这个时候，你其实已经差不多进入了高手的行列。比如，在实际编程工作中，当遇到一个特别棘手的问题的时候，你可以跟踪到编译器、虚拟机的内部实现机制上去定位和解决问题。

而我前面说了，掌握一个具体的编译器的目标，是有四个级别的任务。那你可能要问，都能剖析源代码了，还要进一步挑战什么呢？

这第四个级别呢，就是把代码跟编译原理和算法结合起来，实现认识的升华。

在第三级，当你阅读和跟踪程序执行的时候，会遇到一个认知上的挑战。对于某些程序，你每行代码都能看懂，但为什么这么写，你其实不明白。

像编译器这样的软件，在解决每一个关键问题的时候，肯定都是有理论和算法支撑的。这跟我们平常写一些应用程序不大一样，这些应用程序很少会涉及到比较深入的原理和算法。

我举个例子，在讲 Java 编译器中的语法分析器的时候，我提到几点。第一，它是用递归下降算法的；第二，它在避免左递归时，采用了经典的文法改写的方法；第三，在处理二元表达式时，采用了运算符优先级算法，它是一种简单的 LR 算法。

我提到的这三点中的每一点，都是一个编译原理的知识点或算法。如果对这些理论没有具体的了解，那你看代码的时候就看不出门道来。类似的例子还有很多。

所以，如果你其实在编译原理的基础理论和算法上都有不错的素养的话，你会直接带着自己的假设去代码里进行印证，这样你就会发现每段程序，其实都是有一个算法去对应的，这样你就真的做到融会贯通了。

那如何才能达到第四级的境界，如何才能理论和实践兼修且互相联系呢？

第一，你要掌握“预备知识”模块中的编译原理核心基础知识和算法。

第二，你要阅读相关的论文和设计文档。有一些论文是一些经典的、奠基性的论文。比如，在讲Sea of Nodes 类型的 IR的时候，我介绍了三篇重要的论文，需要你去看。还有一些论文或设计文档是针对某个编译器的具体的技术点的，这些论文对于你掌握该编译器的设计思路也很有帮助。

达到第四级的境界，你其实已经可以参与编译器的开发，并能成为该领域的技术专家了。针对某个具体的技术点加以研究和钻研，你也可以写出很有见地的论文。

当然，我不会要求每个同学都成为一个编译器的专家，因为这真的要投入大量的精力和实践。你可以根据自己的技术领域和发展规划，设定自己的目标。

我的建议是：

首先，每个同学肯定要完成第一级的目标。这一级目标的要求是能理解主线，有时候要多读几遍才行。

对于第二级目标，我建议你针对 2~3 门你感兴趣的语言，上手做一做实验。

对于第三级目标，我希望你能够针对 1 门语言，去做一下深入探索，找一找跟踪调试一个编译器、甚至修改编译器的源代码的感觉。

对于第四级目标，我希望你能够针对那些常见的编译原理算法，比如前端的词法分析、语法分析，能够在编译器里找到并理解它们的实现。至于那些更加深入的算法，可以作为延伸任务。

总的来说呢，“真实编译器”这个模块的课程内容，为你的学习提供了开放式的各种可能性。

好，接下来，我就针对同学们的提问和课程的思考题，来做一下解析。

问题 2：多重分派是泛型实现吗？

@d：“多重分派能够根据方法参数的类型，确定其分派到哪个实现。它的优点是容易让同一个操作，扩展到支持不同的数据类型。”宫老师，多重分派是泛型实现吗？

由于大多数同学目前使用的语言，采用的都是面向对象的编程范式，所以会比较熟悉像这样的一种函数或方法派发的方式：

Mammal mammal = new Cow();  //Cow是Mammal的一个子类
mammal.speak();

这是调用了 mammal 的一个方法：speak。那这个 speak 方法具体指的是哪个实现呢？根据面向对象的继承规则，这个方法可以是在 Cow 上定义的。如果 Cow 本身没有定义，就去它的父类中去逐级查找。所以，speak() 具体采用哪个实现，是完全由 mammal 对象的类型来确定的。这就是单一分派。

我们认为，mammal 对象实际上是 speak 方法的第一个参数，虽然在语法上，它并没有出现在参数列表中。而Java 的运行时机制，也确实是这么实现的。你可以通过查看编译生成的字节码或汇编代码来验证一下。你如果在方法中使用“this”对象，那么实际上访问的是方法的 0 号参数来获取对象的引用或地址。

在采用单一分派的情况下，对于二元（或者更多元）的运算的实现是比较别扭的，比如下面的整型和浮点型相加的方法，你需要在整型和浮点型的对象中，分别去实现整型加浮点型，以及浮点型加整型的计算：

Integer a = 2;
Float b = 3.1;
a.add(b);    //采用整型对象的add方法。
b.add(a);    //采用浮点型对象的add方法。

但如果再增加新的类型怎么办呢？那么所有原有的类都要去修改，以便支持新的加法运算吗？

多重分派的情况，就不是仅仅由第一个参数来确定函数的实现了，而是会依赖多个参数的组合。这就能很优雅地解决上述问题。在增加新的数据类型的时候，你只需要增加新的函数即可。

add(Integer a, Float b);
add(Float b, Integer a);
add(Integer a, MyType b);   //支持新的类型

不过，这里又有一个问题出现了。如果对每种具体的类型，都去实现一个函数的话，那么实现的工作量也很大。这个时候，我们就可以用上泛型了，或者叫参数化类型。

通过泛型的机制，我们可以让相同的实现逻辑只书写一次。在第三个模块“现代语言设计篇”中，专门有一讲给你进一步展开泛型的实现机制，到时你可以去深入学习下。

问题 3：安全点是怎么回事？为什么编译器生成的某些汇编代码我看不懂？

@智昂张智恩震：请问老师，和 JVM 握手就是插入 safepoint 的过程吗？具体的握手是在做什么？

你在查看编译器生成的汇编代码的时候，经常会看到一些辅助性的代码逻辑。它们的作用不是要把你的代码翻译成汇编代码才生成的，而是要去实现一些运行时机制。

我举几个例子。

第一个例子，是做逆优化。比如 V8 中把函数编译成机器码，是基于对类型的推断。如果实际执行的时候，编译器发现类型跟推断不符，就要执行逆优化，跳转到解释器去执行。这个时候，你就会看到汇编代码里有一些指令，是用于做逆优化功能的。

第二个例子，是在并行中会遇到的抢占式调度问题。协程这种并发机制，是应用级的并发。一个线程上会有多个协程在运行。但是，如果其中一个协程的运行时间很长，就会占据太多的计算资源，让这个线程上的其他协程没有机会去运行。对于一些比较高级的协程调度器，比如 Go 语言的调度器，就能够把一个长时间运行的协程暂停下来，让其他协程来运行。怎么实现这种调度呢？那就要编译器在生成的代码里，去插入一些逻辑，配合调度器去做这种调度。

第三个例子，是垃圾收集。根据编译器所采用的垃圾收集算法，在进行垃圾收集时，可能会做内存的拷贝，把一个对象从一个地方拷贝到另一地方。这在某些情况下，会导致程序出错。比如，当你读一个 Java 对象的成员变量的值的时候，生成的汇编代码会根据对象的地址，加上一定的偏移量，得到该成员变量的地址。但这个时候，这个对象的地址被垃圾收集器改变了，那么程序的逻辑就错了。所以在做垃圾回收的时候，相关的线程一定要停在叫做“安全点（safepoint）”的地方，在这些地方去修改对象的地址，程序就不会出错。

@智昂张智恩震 同学提出的问题，就针对垃圾收集这种场景的。在 Java 生成的汇编代码里，程序要在安全点去跟运行时做一下互动（握手）。如果需要的话，当前线程就会被垃圾收集器停下，以便执行垃圾收集操作。

所以你看，只有了解了一门语言的运行时机制，才能懂得为什么要生成这样的代码。关于垃圾收集和并发机制，我也会在第三个模块中跟你去做进一步的探讨。

问题 4：SSA 只允许给变量赋一次值，循环中的变量是多次赋值的，不是矛盾了吗？

@qinsi：关于思考题，SSA 只允许给变量赋一次值，如果是循环的话就意味着要创建循环次数那么多的临时变量了？

@qinsi 同学问的这个问题其实挺深入，也很有意思。

是这样的。我们在做编译的时候，大部分时候是做静态的分析，也就是仅仅基于程序从词法角度（Lexically）的定义，不看它运行时的状态。注意，我这里说的词法，不是指词法分析的词法，而是指程序文本中体现的“使用和定义”（use-def）关系、控制流等。词法作用域（Lexical Scope）中的词法，也是同一个意思。

所以，SSA 中说的赋值，实际上是对该变量（或称作值）做了一个定义，体现了变量之间的“使用和定义”（use-def）关系，也就是体现了变量之间的数据依赖，或者说是数据流，因此可以用来做数据流分析，从而实现各种优化算法。

小结

这一讲的答疑，我首先帮你梳理了学习真实世界编译器的方法。一个真实的编译器里涉及的技术和知识点确实比较多，但有挑战就有应对方法。我给你梳理了四级的学习阶梯，你探索内容的多少，也可以根据自己的需求和兴趣来把握。按照这个学习路径，你既可以去做一些宏观的了解，也可以在某个具体点上去做深入，这是一个有弹性的学习体系。

另外，我也挑了几个有意思的问题做了解答，在解答中也对涉及的知识点做了延伸和扩展。其中一些知识点，我还会在第三个模块中做进一步的介绍，比如垃圾收集机制、并发机制，以及泛型等。等你学完第三个模块，再回头看实际编译器的时候，你的认知会再次迭代。

好，请你继续给我留言吧，我们一起交流讨论。同时我也希望你能多多地分享，做一个知识的传播者。感谢你的收听，我们下一讲再见。

用户故事 | 易昊：程序员不止有Bug和加班，还有诗和远方

你好，我是编辑王惠。在处理这门课的留言时，我注意到易昊同学一直在跟随着宫老师的脚步，学习和实践编译原理的相关知识，留言的内容十分有见地、提出的问题也能看出是经过了他深入的思考。同时，咱们这门课也具有很强的互动性，所以我邀请他来和我们分享一下他的心得体会。

Hi，我是易昊，目前在武汉做 Android 开发，已经工作 12 年了。很高兴能在这里跟你分享，关于我学习编译原理的一些心得体会。

为什么我要再学编译原理？

首先，我想给你解释一下，我为什么会起“程序员不止有 Bug 和加班，还有诗和远方”这样一个标题呢？

这是因为，作为一名应用开发者，我经常会觉得，自己只是在和源源不断的 Bug 以及项目进度作斗争，日常工作好像已经无法给我带来技术上的成就感了。但我能肯定的是，我对于技术的情怀并没有消失。我也认为，我不应该只满足于完成日常的普通开发任务，而是应该去做点更有挑战性的事情，来满足自己精神上的追求。

那么，我为什么会选择学习编译技术呢？

首要的原因，是这门课的内容不像编程语言、数据结构那样清晰直观。加上在大学时期，学校安排的课时较短，只有半个学期，自己又没有对它足够重视起来，导致这门课只学了个一知半解，从而造成自己对计算机底层工作原理没有掌握透彻，留下了遗憾。

《程序员的自我修养–链接、装载与库》里，有句话让我印象深刻：“真正了不起的程序员对自己程序的每一个字节都了如指掌。”虽然说得有点夸张，但一个优秀的程序员确实应该理解自己的程序为什么能在计算机上运行起来。而不是在写完代码后，忐忑不安地看着 IDE 在进行编译，等终于能运行起来时，大喊一声：“哇，能编译运行了，好神奇”。所以工作了几年之后，我一直想找机会能够弥补一下大学时期的遗憾，把编译知识学扎实。

另外，编译技术的巨大挑战性，也是我想重拾学习的重要原因之一。

你可能听过一个段子：程序员的三大浪漫，是自己实现编译器、操作系统和渲染引擎。

其实一开始，我并不理解为什么编译器会在这其中，我猜大概是因为特别困难吧。

我刚接触编程的时候，觉得能学好 C、Java 这样的编程语言，已经很不容易了，更何况要自己去用编译器实现一门语言。

而且编译原理这门课中，还有相当多比较深奥、晦涩的理论，想要掌握起来非常困难，它完全不像学习普通技术那样，几行代码就能运行一个 hello world。同时你光有理论又完全不够，编译器中包含了很多巧妙的工程化处理。比如，会用抽象语法树来表示整个代码的语法结构；在函数调用时，参数和返回值在栈帧中的内存地址顺序等。

所以，不得不说，编译器是一个非常复杂的工程，编译原理是一个非常不容易消化、掌握的基础技术，让人望而生畏。

但是一旦掌握了之后，可以说就打通了计算机技术的任督二脉，因为你已经掌握了计算机运行中相当底层部分的原理，那么再去看其他技术就不会有什么大的障碍了。

而且在工作中，即使没有什么机会需要自己去创造语言或者写编译器，编译原理对我也很有帮助。举个例子吧，Android 有很多动态化的技术，像 ReactNative、Weex 都会使用 JavaScript 作为脚本语言来调用原生接口，那么为了实现原生语言（如 Java、Objective-C）和 JavaScript 的通信，ReactNative 和 Weex 都在框架中嵌入了 JavaScriptCore 这个组件，因为 JavaSciptCore 包含了一个 JavaScript 的解释器，可以在运行时执行 JavaScript。

那么，如果你也想在自己的项目中使用类似的动态化技术，但又不愿意被 ReactNative 和 Weex 技术绑死，就需要自己去剖析 JavaScriptCore 的工作原理，甚至要去实现一个类似的脚本语言执行框架。

而真的要自己去下载 JavaScriptCore 的源码来看，你就会发现，如果没有一定的编译原理知识作为基础，是很难看懂的。但是如果你具备了编译原理知识基础，其实会发现，这些源码里面也不外乎就是词法分析、语法分析、IR 等，这些都是编译原理中的常用概念和算法。

还有就是日常工作中会碰到的某些比较棘手的问题，如果你不理解编译技术，可能就无法找到出现问题的根源。

比如我早期的工作中，在开发 C++ 代码的时候，经常会遇到链接时找不到符号的问题，那个时候我对“符号是怎么产生的”并不理解，所以遇到这类问题，我就会在 IDE 或者代码里盲目地尝试修改。

后来重新学习了编译技术之后，我就理解了编译器在编译过程中会产生符号表，每个符号包含了符号的名称、类型、地址等信息。之所以出现这类问题，可能是依赖的静态库没有包含这些符号，或者是类型不正确，再或者是这些符号的地址无法被正确解析，所以我用相应的工具去检查一下静态库文件的符号表，一般就可以解决问题了。

你看，编译技术总能帮我解决一些，我之前挠破头都想不出解决方案的问题。到现在我工作了十几年以后，就会有一个越来越强烈的感悟：最重要的还是底层知识。以前我也曾经感慨过计算机技术发展之快，各种技术层出不穷，就拿 Android 技术来说，各种什么插件化、组件化、动态化技术让人眼花缭乱，要不就是今天谷歌在提倡用 Kotlin，明天又开始推 Flutter 了。

所以有一段时间我比较迷茫，“我究竟该学什么呢”，但我后来发现，虽然那些新技术层出不穷，但万变不离其宗，计算机核心的部分还是编译原理、操作系统那些底层技术。如果你对底层技术真正吃透了，再来看这些时髦的新技术，就不会感到那么神秘了，甚至是可以马上就弄明白它背后的技术原理。原理都搞懂了，那掌握起来也就非常快了。

我是怎么学习专栏的？

我开始意识到自己需要重新学习编译技术是在 2015 年，当时为了能够在 Android 的原生代码里运行 JavaScript 脚本，我依次尝试了 WebView、JavaScriptCore、Rhino 等方案，觉得这种多语言混合开发的技术挺强大，也许能够改变主流的应用开发模式，于是就想继续研究这些框架是怎么工作的。但是我发现，自己对编译原理知识掌握得不牢，导致学习这些技术的时候有点无从下手。

那个时候还没有极客时间这样针对性较强的学习平台，我是自己买了些书，有权威的龙书和虎书，也有《两周自制脚本语言》这样的速成书籍，但总是不得要领。

像龙书、虎书这样的，主要花了大量的篇幅在讲理论。但面对工作和家庭的压力，又不允许我有那么大片的时间去学习理论，而且没有实践来练习的话，也很容易忘掉。

像速成书籍这样的，虽然实现了一个很简单的编译器，但它大量的篇幅是在讲代码的一些细节，原理又讲得太少，知其然而不知其所以然。后来我就没有继续深入地学下去了。

直到偶然地在极客时间上看到了《编译原理之美》这门课，我简单看了下目录之后，就立马买了下来，因为感觉非常实用，既对理论部分的重点内容有深入浅出地讲解，也有与实际问题的紧密联系。学完这门课后，我感觉有信心去尝试写一点东西了，正好临近春节，就计划着在春节期间自己写些代码来验证学习的成果。结果不成想遇到了疫情封城，因为没法复工，就索性在家自己照着龙书和课程中给出的思路，看看能否自己实现个编译器出来。

结果我花了快两个月的时间，真的写出来了一个简单的编译器，能够把类似 C 语言风格的代码编译成汇编执行。

回想那段时间，虽然疫情很让人焦虑，但当我全身心地投入到对编译技术的钻研时，可以暂时忘记疫情带来的困扰。通过写这个小项目，我算是对编译器的工作过程有了个切身的体会，还把多年未碰的汇编又重新拾起来投入使用，可以说是收获颇丰，疫情带给我的回忆也没有那么痛苦了，从另一个角度看，甚至还有一定的成就感。

这个简单的编译器项目完成了之后，就激发了我更大的兴趣。因为我毕竟只是实现了一个玩具型的编译器，那么工业界的编译器是如何工作的呢？

这个编译器，我是用 LL 算法来实现的语法分析，但龙书上还大篇幅地讲了 LR、SLR、LALR，那么实际中到底是使用 LL 算法还是 LR 算法呢？

还有，我的编译器没有什么优化的功能，真实的编译器都有哪些优化措施呢？

这些问题吸引着我要去寻找答案。结果正巧，宫老师又推出了《编译原理实战课》，深入浅出地讲解各大编译器的工作原理，这可正好对我的胃口，于是又毫不犹豫地买下了。

上了几节课之后，觉得收获很大，特别是讲解 javac 和 Graal 编译器的部分。这两部分都给出了如何基于源码去剖析编译器的原理，实际操作性很强，我跟着宫老师给出的步骤，下载和一步步地调试、跟踪源码，印象十分深刻。特别是宫老师介绍的 javac 在处理运算符优先级时，引入了 LR 算法，从而避免了教科书上介绍的：当使用 LL 方式时，为了消除左递归，要写多级 Tail 函数的问题，这些都让我对编译技术的实际应用理解得更加深刻了。在学习 Graal 时，我也是花了不少的时间去配置 Windows 环境，包括下载安装 Kali Linux、OpenJDK 等，才终于把 Graal 跑起来。通过这样的实际操作，体验到了“折腾”的乐趣，对动手能力也是一种锻炼。

除此之外，课程中还有丰富的流程图、类图和思维导图，因此我可以按图索骥，去研究自己感兴趣的知识点，不用再苦苦地从海量的源码中去大海捞针，学习效率得到了很大提升。

如何更好地学习编译原理？

通过这段时间的学习后，我发现，编译原理其实并没有想象中的那么困难。我觉得计算机技术的一个特点就是像在搭积木，有时候只是知识点多、层次关系复杂而已。

编译技术尤其如此，从前端到后端，从词法分析到语法分析到语义分析、IR，最后到优化。这些地方包含着各种知识点，但这些知识也不是凭空变出来的，而是环环相扣、层层叠加出来的。因此你在学习编译技术时一定要静下心来，一点一点地去吃透里面的技术细节，并且还需要不断地总结和提炼，否则对知识的理解可能就不够透彻，浮于表面。

另外，你需要多去动手实践，特别是和算法相关的部分。比如，在编译器的语法分析阶段，有个内容是计算 First 集合和 Follow 集合，其实理解起来并不困难，但它包含的细节不少，容易算错，所以需要在课后多去练习。

我就是在课下，把宫老师布置的习题和龙书上相关的题目都算了一遍，还写了计算 First 集合和 Follow 集合的程序。不过就算是做到了这些，我感觉对这部分的理解还不够透彻，但是我对编译技术的恐惧感已经消除了，对后续进一步的深入挖掘也打下了基础。所以说，静下心来学，勤动手去练，对学习编译原理这门课程来说非常重要。

我还有一个体会就是，学习编译原理没有捷径可走。因为编译技术是一个复杂而精密的工程，它的知识是环环相扣的。比如说，如果你对词法分析和语法分析的知识掌握得不够牢固，不熟悉一些常用语法规则的推导过程，那后面的 IR、三地址代码、CFG 等，你就会学得一头雾水。

所以，我们只能够一步一个脚印地去学习。

当然了，如果你和我一样是一个有家有口的上班族，只利用碎片时间可能不好做到连续学习，那我建议你在学习课程的时候，可以稍微花点时间去参考一下宫老师介绍的开源编译器代码，比如JavaCompiler 的源码、Graal 的源码。这样就可以和课程中的内容结合起来。因为看代码和调试代码会更加直观，也更容易理解。

同时，你也可以看看别人的代码设计，有哪些地方是可以做成组件的，哪些函数是可以自己去实现来练手的。然后，你可以先给自己定一个小目标，就是利用业余时间去完成这些组件或者函数的开发，因为单个组件的工作量并不是太大，因此还是可以尝试完成的。这样在巩固理论知识的同时，还能锻炼自己的动手能力，我想热爱编程的你，应该可以从中得到不少快乐。

我相信，只要最终坚持下来，你和我，都可以掌握好编译这门“屠龙”技术。

知识地图 | 一起来复习编译技术核心概念与算法

你好，我是学习委员朱英达。

在“预备知识篇”这个模块，宫老师系统地梳理了编译过程中各个阶段的核心要点，目的就是让我们建立一个编译原理的基础知识体系。那到今天为止，我们就学完了这部分内容，迈出了编译之旅中扎实的第一步。不知道你对这些知识掌握得怎样了？

为了复习，也为了检测我们的学习成果，我根据自己的知识积累和学习情况，整理了一张知识大地图，你可以根据这张地图中标记的七大编译阶段，随时速查常用的编译原理概念和关键算法。

如果你也总结了知识地图，那你可以对照着我这个，给自己一个反馈，看看它们之间有哪些异同点，我们可以在留言区中一起交流和讨论。

不过知识地图的形式，虽然便于你保存、携带、速查，但考虑到图中涉及的概念等内容较多，不方便查看和检索。所以，我还把地图上的知识点，用文字的形式帮你梳理出来了。你可以对照着它，来复习和回顾编译技术的核心概念和算法的知识点，构建自己的知识框架。

你在学习这些预备知识的过程中，可能会发现，宫老师并没有非常深入地讲解编译原理的具体概念、理论和算法。所以，如果你想继续深入学习这些基础知识，可以根据宫老师在每讲最后给出的参考资料，去学习龙书、虎书、鲸书等经典编译原理书籍。当然，你也可以去看看宫老师的第一季专栏课《编译原理之美》。

在我看来，相较于编译方面的教科书而言，《编译原理之美》这门课的优势在于，更加通俗易懂、与时俱进，既可以作为新手的起步指导，也能够帮助已经熟悉编译技术的工程师扩展视野，我很推荐你去学习这门课。所以，我邀请编辑添加了相应的知识点到《编译原理之美》的文章链接，如果你有深入学习的需要，你会很方便地找到它。

好了，一起开始复习吧！

一、词法分析：根据词法规则，把字符串转换为 Token

核心概念：正则文法

正则文法：词法分析工作的主要文法，它涉及的重要概念是正则表达式。

正则表达式：正则文法的一种直观描述，它是按照指定模式匹配字符串的一种字符组合。

正则表达式工具：字符串的模式匹配工具。大多数程序语言都内置了正则表达式的匹配方法，也可以借助一些编译工具，自动化根据正则表达式生成字符串匹配程序，例如 C++ 的 Lex/Yacc 以及 Java 的 ANTLR。

具体实现：手工构造词法分析器、自动生成词法分析器

手工构造词法分析器

构造词法分析器使用的计算模型：有限自动机（FSA）。它是用于识别正则文法的一种程序实现方案。

其组成的词法单元是 Token，也就是指程序中标记出来的单词和标点符号，它可以分成关键字、标识符、字面量、操作符号等多个种类。

在实际的编译器中，词法分析器一般都是手写的。

自动生成词法分析器

具体实现思路：把一个正则表达式翻译成 NFA，然后把 NFA 转换成 DFA。

DFA：确定的有限自动机。它的特点是：该状态机在任何一个状态，基于输入的字符，都能做一个确定的状态转换。

NFA：不确定的有限自动机。它的特点是：该状态机中存在某些状态，针对某些输入，不能做一个确定的转换。这里可以细分成两种情况：一种是对于一个输入，它有两个状态可以转换；另一种是存在ε转换的情况，也就是没有任何字符输入的情况下，NFA 也可以从一个状态迁移到另一个状态。

技术难点

首先，你需要注意，NFA 和 DFA 都有各自的优缺点，以及不同的适用场景。

NFA：优点是在设计上更简单直观，缺点是它无法避免回溯问题，在某些极端的情况下可能会造成编译器运行的性能低下。主要适用于状态较为简单，且不存在回溯的场景。

DFA：优点是它可以避免回溯问题，运行性能较高，缺点是 DFA 通常不容易直接设计出来，需要通过一系列方案，基于 NFA 的转换而得到，并且需要占用额外的空间。主要适用于状态较为复杂，或者对时间复杂度要求较为严苛的工业级词法分析器。

其次，你需要了解基于正则表达式构造 NFA，再去进行模式匹配的算法思路。

从正则表达式到 NFA：这是自动生成词法分析器的一种算法思路。它的翻译方法是，匹配一个字符 i —> 匹配“或”模式 s|t —> 匹配“与”模式 st —> 重复模式，如“?”“*”和“+”等符号，它们的意思是可以重复 0 次、0 到多次、1 到多次，注意在转换时要增加额外的状态和边。

从 NFA 到 DFA：NFA 的运行可能导致大量的回溯，所以我们可以把 NFA 转换成 DFA，让字符串的匹配过程更简单。从 NFA 转换成 DFA 的算法是子集构造法，具体的算法思路你可以参考第 16 讲。

二、语法分析：依据语法规则，编写语法分析程序，把 Token 串转化成 AST

核心概念：上下文无关文法

上下文无关的意思：在任何情况下，文法的推导规则都是一样的。

语法规则由 4 个部分组成：一个有穷的非终结符（或变元）的集合、一个有穷的终结符的集合、一个有穷的产生式集合、一个起始非终结符（变元）。符合这四个特点的文法规则就是上下文无关文法。

两种描述形式：一种是巴科斯范式（BNF），另一种是巴科斯范式的一种扩展形式（EBNF），它更利于自动化生成语法分析器。其中，产生式、终结符、非终结符、开始符号是巴科斯范式的基本组成要素。

上下文无关文法与正则文法的区别：上下文无关文法允许递归调用，而正则文法不允许。上下文无关文法比正则文法的表达能力更强，正则文法是上下文无关文法的一个子集。

具体实现：自顶向下、自底向上

一种是自顶向下的算法思路，它是指从根节点逐层往下分解，形成最后的 AST。

递归下降算法：它的算法思路是按照语法规则去匹配 Token 串。优点：程序结构基本上是跟文法规则同构的。缺点：会造成左递归和回溯问题。注意，递归下降是深度优先（DFS）的，只有最左边的子树都生成完了，才会往右生成它的兄弟节点。

LL 算法：对于一些比较复杂的语法规则来说，这个算法可以自动计算出选择不同产生式的依据。方法：从左到右地消化掉 Token。要点：计算 First 和 Follow 集合。

另一种是自底向上的算法思路，它是指从底下先拼凑出 AST 的一些局部拼图，并逐步组装成一棵完整的 AST。

自底向上的语法分析思路：移进，把 token 加入工作区；规约，在工作区内组装 AST 的片段。

LR 算法和 LL 算法一样，也是从左到右地消化掉 Token。

技术难点

首先，你需要掌握 LL 算法的要点，也就是计算 First 和 Follow 集合。

其次，你要了解 LL 算法与 LR 算法的异同点。

LL 算法：优点是较为直观、容易实现，缺点是在一些情况下不得不处理左递归消除和提取左因子问题。

LR 算法：优点是不怕左递归，缺点是缺少完整的上下文信息，编译错误显示不友好。

三、语义分析：检查程序是否符合语义规则，并为后续的编译工作收集语义信息

核心概念：上下文相关文法

属性文法：上下文相关文法对 EBNF 进行了扩充，在上下文无关的推导过程中，辅助性解决一些上下文相关的问题。

注意：上下文相关文法没有像状态图、BNF 那样直观的分析范式。

应用场景：控制流检查、闭包分析、引用消解等。

场景案例

1. 控制流检查

像 return、break 和 continue 等语句，都与程序的控制流有关，它们必须符合控制流方面的规则。在 Java 这样的语言中，语义规则会规定：如果返回值不是 void，那么在退出函数体之前，一定要执行一个 return 语句，那么就要检查所有的控制流分支，是否都以 return 语句结尾。

2.闭包分析

很多语言都支持闭包。而要正确地使用闭包，就必须在编译期知道哪些变量是自由变量。这里的自由变量是指在本函数外面定义的变量，但被这个函数中的代码所使用。这样，在运行期，编译器就会用特殊的内存管理机制来管理这些变量。所以，对闭包的分析，也是上下文敏感的。

具体实现：引用消解、符号表、类型系统、属性计算

引用消解

概念解释：引用消解是一种非常重要的上下文相关的语义规则，它其实就是从符号表里查找被引用的符号的定义。

作用域：指计算机语言中变量、函数、类等起作用的范围。对于变量来说，为了找到正确的引用，就需要用到作用域。一般来说，它有两种使用场景，一种是标识符作用域，一种是词法作用域。

符号表

符号表内包含的信息：名称、分类、类型、作用域等。

存储形式：线性表格、层次化表格。

符号表的作用：维护程序中定义的标识符（ID 类 Token），提供给编译器的各个环节进行操作。

建立符号表的过程：整个编译器前端都会涉及到，词法分析阶段将 ID 类 Token 收集到符号表中，语法分析阶段可进行读取和补充，语义分析阶段做引用消解时符号表的作用至关重要。

注意：符号表跟编译过程的多个阶段都相关。

类型系统

类型：在计算机语言里，类型是数据的一个属性，它的作用是来告诉编译器或解释器，程序可以如何使用这些数据。

类型系统：类型系统是一门语言所有的类型的集合，操作这些类型的规则，以及类型之间怎么相互作用的（比如一个类型能否转换成另一个类型）。

类型检查：这是与类型有关的分析和处理工作之一。主要用于对源程序代码中的一些类型不匹配的情况进行隐式类型转换或直接抛错。

子类型：面向对象编程时，我们可以给某个类创建不同的子类，实现一些个性化的功能；写程序时，我们可以站在抽象度更高的层次上，不去管具体的差异。把这里的结论抽象成一般意义上的类型理论，就是子类型。

类型转换：比如说，表达式“a=b+10”，如果 a 的类型是浮点型，而右边传过来的是整型，那么一般就要进行缺省的类型转换。

参数化类型 / 泛型：泛型是程序设计语言的一种风格或范式。泛型允许程序员在强类型程序设计语言中编写代码时使用一些以后才指定的类型，在实例化时作为参数指明这些类型。

属性计算

编译器一般会采用属性计算的方法，来计算出每个 AST 节点的类型属性，然后检查它们是否匹配。

属性文法：属性计算的方法，就是基于语法规则，来定义一些属性计算的规则，在做语法解析或遍历 AST 的时候执行这些规则，我们就可以计算出属性值。这种基于语法规则定义的计算规则，被叫做属性文法（Attribute Grammar）。

属性计算：S 属性（综合属性）、I 属性（继承属性）。

形成的数据结构：Annotated AST（带有标注信息的 AST）。

语法制导的翻译：属性计算的特点是会基于语法规则，增加一些与语义处理有关的规则，我们把这种语义规则的定义叫做语法制导的定义，如果变成计算动作，就叫做语法制导的翻译。

四、运行时机制：程序的两种不同的执行模式

通常情况下，程序有两种执行模式：基于物理机、基于虚拟机。

在物理机上运行

举例：C、C++、Golang。

程序运行的原理：基于指令指针寄存器的控制，顺序从内存读取指令执行。

CPU：运行指令的地方。

多种架构：x86、ARM、MIPS、RISC-V、PowerPC 等。

关键构成：寄存器、高速缓存、功能单元。

汇编代码：操作码（让 CPU 执行的动作）、操作数（指令的操作对象，可以是常数、寄存器和某个内存地址）。

内存：执行指令相关的另一个硬件。

1. 代码区：存放编译完成以后的机器码。

2. 静态数据区：保存程序中全局的变量和常量。

3. 栈：适合保存生存期比较短的数据，比如函数和方法里的本地变量。

重要知识点：栈帧的构成、活动记录、逐级调用过程。

栈的特点：申请和释放—修改栈顶指针，生存期与作用域相同。

4. 堆：适合管理生存期较长的一些数据，这些数据在退出作用域以后也不会消失。

重要知识点：通过操作系统 API 手动申请和释放。

管理机制：自动管理、手动管理。

操作系统：除了硬件支撑，程序的运行还需要软件，这就是运行时系统。

定义：除了硬件支撑，程序的运行还需要软件，这些软件叫做运行时系统。

操作系统：对于把源代码编译成机器码在操作系统上运行的语言来说（比如 C、C++），操作系统本身就可以看做是它们的运行时系统。

管理 CPU 资源：分时执行。比如，时间片轮转算法，将 CPU 以时钟周期为单元执行多进程任务，实现并发。

管理内存资源：逻辑内存（系统内核对内存地址的划定）、物理内存（硬件中具体每一个 bit 实际的硬件存储情况）、虚拟内存（基于操作系统内核对内存管理问题的抽象，通常有一部分虚拟内存实际上是存在磁盘上的）。

在虚拟机上运行

举例：Java、Python、Erlang、Lua。

程序运行的原理：虚拟机是计算机语言的另一种运行时系统。虚拟机上运行的是中间代码，而不是 CPU 可以直接认识的指令。

基于栈的虚拟机：指令在操作数栈的栈顶获取操作数（如 JVM、Python 虚拟机）。

优点：易于生成代码。

缺点：代码数量较多、不能充分利用寄存器。

基于寄存器的虚拟机：类似于物理机，从寄存器取操作数（如 Erlang、Lua、Dalvik、Ignition）。

优点与缺点：与栈机相反。

二者的区别：主要在于如何获取指令的操作数。

五、中间代码：运行各种优化算法、代码生成算法的基础

在这门课程中，宫老师主要从用途和层次、解释执行、呈现格式和数据结构等角度来给你讲解 IR 这一关键概念。如果你想要更深入地了解 IR 的特点，理解如何生成 IR 来实现静态编译的语言，你可以去看《编译原理之美》的第24、25、26讲。

IR 的用途和层次（从抽象层次的角度来划分）

第一类用途：基于源语言做一些分析和变换（HIR）。

第二类用途：独立于源语言和 CPU 架构做分析和优化（MIR）。

第三类用途：依赖于 CPU 架构做优化和代码生成（LIR）。

IR 的解释执行

P-code：直接用于解释执行的 IR。由于它与具体机器无关，因此可以很容易地运行在多种电脑上。这类 IR 对编译器来说，就是做编译的目标代码。

注意：P-code 也可能被进一步编译，形成可以直接执行的机器码。如 Java 的字节码。

IR 的呈现格式

大部分 IR 没有像源代码和汇编代码那样的书写格式。

大多数的 IR 跟 AST 一样，只是编译过程中的一个数据结构而已，或者说只有内存格式。比如，LLVM 的 IR 在内存里是一些对象和接口。

为了调试的需要，你可以把 IR 以文本的方式输出，用于显示和分析。

IR 的数据结构

第一种：类似 TAC 的线性结构。

第二种：树结构。

第三种：DAG- 有向无环图。

第四种：PDG- 程序依赖图。

SSA 格式的 IR

概念：SSA，即静态单赋值。这是 IR 的一种设计范式，它要求一个变量只能被赋值一次。

要点：使用 SSA 的形式，体现了精确的“使用 - 定义（Use-def）”关系，并且由于变量的值定义出来以后就不再变化，使得基于 SSA 更容易运行一些优化算法。

注意：现代语言用于优化的 IR，很多都是基于 SSA 的，包括 Java 的 JIT 编译器、JavaScript 的 V8 编译器、Go 语言的 gc 编译器、Julia 编译器，以及 LLVM 工具等。

六、代码分析与优化：优化程序对计算机资源的使用，以提高程序的性能

优化分类

是否与机器有关

机器无关：指与硬件特征无关，比如把常数值在编译期计算出来（常数折叠）。

机器有关：需要利用某硬件特有的特征，比如 SIMD 指令可以在一条指令里完成多个数据的计算。

优化范围

本地优化 / 局部优化：基本块内。

全局优化：函数（过程）内。

过程间优化：跨函数（过程）。

优化方法

1. 把常量提前计算出来

常数折叠：程序里的有些表达式，肯定能计算出一个常数值，那就不要等到运行时再去计算，干脆在编译期就计算出来。比如“x=2*3”可以优化成“x=6” 。

常数传播：如果你一旦知道 x 的值其实是一个常量，那你就可以把所有用到 x 的地方，替换成这个常量。

稀疏有条件的常数传播：基于常数传播，还可以进一步导致分支判断条件转化为常量，导致一个分支的代码不会被执行。

2. 用低代价的方法做计算

代数简化：利用代数运算规则所做的简化，比如“x=x*0”可以简化成“x=0”。

3. 消除重复的计算

拷贝传播：遇到相同引用的变量，拷贝替换为同一个，节省内存到寄存器的操作，以此提升运算速度。

值编号（VN 和 GVN）：把相同的值，在系统里给一个相同的编号，并且只计算一次。

公共子表达式消除（CSE）：也会减少程序的计算次数。比如“x:=a+b”和“y:=a+b”，x 和 y 右边的形式是一样的，就可以让 y 等于 x，从而减少了一次对“a+b”的计算。

部分冗余消除（PRE）：是公共子表达式消除的一种特殊情况。

4. 化零为整，向量计算

超字级并行（SLP）：把基本块中的多个变量组成一个向量，用一个指令完成多个变量的计算。

循环向量化：在循环展开的基础上，把多次计算优化成一个向量计算。

5. 化整为零，各个优化

聚合体的标量替换（SROA）：很多语言都有结构和对象这样的复合数据类型，内部包含了多个成员变量，这种数据类型叫做聚合体（aggregates）。

编译器可以把聚合体的成员变量放在寄存器中进行计算，不需要访问内存。

6. 针对循环，重点优化

归纳变量优化：归纳变量是指在循环体内由循环变量派生出来的变量，其变化是很有规律的，因此可以尝试做强度折减优化。

边界检查消除：在循环体内每次循环都会执行的边界检查代码，将其整合抽离出来，避免每次重复判断。

循环展开：通过把循环次数减少，但在每一次循环里，完成原来多次循环的工作量。

循环向量化：在循环展开的基础上，把多次计算优化成一个向量计算。

重组：在循环结构中，使用代数简化和重组，能获得更大的收益。

循环不变代码外提（LICM）：在循环结构中，如果发现有些代码其实跟循环无关，那就应该提到循环外面去，避免一次次重复计算。

代码提升：在条件语句中，如果多个分支条件里都执行了同一句代码，可将其提升至判断条件之前；如果是在循环体内，还可以继续借助循环不变代码外提优化，进一步提升到循环体之外，从而降低计算量。

7. 减少过程调用的开销

尾调用优化和尾递归优化：尾调用就是一个函数的最后一句，是对另一个函数的调用。如果函数最后一句调用的函数是自己，就称为尾递归。尾调用可以将函数调用栈合并，尾递归还可以转换成循环，从而进一步做一系列针对循环语句的优化工作。

内联：内联也叫做过程集成，就是把被调用函数的代码拷贝到调用者中，从而避免函数调用。

内联扩展：内联扩展跟普通内联类似，也是在调用的地方展开代码。不过内联扩展被展开的代码，通常是手写的、高度优化的汇编代码。

叶子程序优化：叶子程序，是指不会再调用其他程序的函数（或过程）。因此，它也可以对栈的使用做一些优化。

8. 对控制流做优化

不可达代码的消除：根据控制流的分析，发现有些代码是不可能到达的，可以直接删掉，比如 return 语句后面的代码。

死代码删除：通过对流程的分析，发现某个变量赋值了以后，后面根本没有再用到这个变量。这样的代码就是死代码，就可以删除。

if 简化：在讲常量传播时我们就见到过，如果有可能 if 条件肯定为真或者假，那么就可以消除掉 if 结构中的 then 块、else 块，甚至整个消除 if 结构。

循环简化：也就是把空循环或者简单的循环，变成直线代码，从而增加了其他优化的机会，比如指令的流水线化。

循环反转：这是对循环语句常做的一种优化，就是把一个 while 循环改成一个 repeat…until 循环（或者 do…while 循环）。这样会使基本块的结构更简化，从而更有利于其他优化。

拉直：如果发现两个基本块是线性连接的，那可以把它们合并，从而增加优化机会。

反分支：也就是减少程序分支，因为分支会导致程序从一个基本块跳到另一个基本块，这样就不容易做优化。比如，把循环内部的 if 分支挪到循环外面去，先做 if 判断，然后再执行循环，这样总的执行 if 判断的次数就会减少，并且循环体里面的基本块不那么零碎，就更加容易优化。

分析方法

控制流分析（CFA）： 基于程序的控制语句（如条件语句、循环语句、分支语句和基本块语句等）进行分析，建立对程序执行过程的理解，从而进一步做出优化。

数据流分析（DFA）：基于数据流分析框架（包含“方向 (D)”“值 (V)”“转换函数 (F)”“初始值 (I)”和“交运算 (Λ)”5 个元素）等方式，建立对程序中数据变化情况的理解，从而进一步做出优化。

依赖分析：分析出程序代码的控制依赖（Control Dependency）和数据依赖（Data Dependency）关系。这对指令排序和缓存优化很重要。

别名分析：在 C、C++ 等可以使用指针的语言中，同一个内存地址可能会有多个别名，因为不同的指针都可能指向同一个地址。编译器需要知道不同变量是否是别名关系，以便决定能否做某些优化。

优化方法的重要性和顺序

重要性

对所有语言都重要：循环优化等。

面向对象语言：内联、逃逸等。

函数式语言：尾递归优化等。

顺序

要点：机器无关 - 早期，机器相关 - 后期。

注意：一个优化会导致另一个优化，同一个优化会多遍运行。

七、目标代码生成：编译器最后一个阶段的工作，生成针对不同架构的目标代码，也就是生成汇编代码

生成针对不同架构的目标代码

x86：CISC 指令，Register-Memory 架构。在指令里可以混合使用内存地址和寄存器。

ARM：RISC 指令，Load-Store 架构。在 ARM 的汇编中，从寄存器到内存要使用 str（也就是 Store）指令，而从内存到寄存器要使用 ldr（也就是 Load）指令。在这种架构下，指令被分为内存访问（Load 和 Store）和 ALU 操作两大类，且后者只能在寄存器上操作。

策略：编写“代码生成器的生成器”。也就是把 CPU 架构的各种信息描述出来，基于这些信息生成目标代码的生成器，就像根据语法规则，用 ANTLR、bison 这样的工具来生成语法解析器一样。

生成目标代码时的优化工作

1. 指令选择

做指令选择的原因：生成更精简、性能更高的代码；使得同一个功能可以由多种方式实现。

算法：树覆盖算法、自底向上的重写系统（BURS）

2. 寄存器分配

原理：两个变量，不同时活跃，可以共享寄存器。

算法：图染色算法（优点 - 更优化，缺点 - 速度慢）、线性扫描算法（优点 - 不够优化，缺点 - 速度快）

3.指令排序

原理：CPU 内部的单元可并行。

实现：基于数据依赖图的 List Scheduling 算法。

4. 窥孔优化

思路：提供一个固定大小的窗口，比如能够容纳 10 条指令，并检查窗口内的指令，看看是否可以优化；然后往下滑动窗口，再次检查优化机会。

调用约定的影响

调用约定：你可以发明自己的调用约定，比如哪些寄存器用来放参数、哪些用来放返回值。但是如果要使用别的语言编译好的目标文件，或者想让自己的编译器生成的目标文件被别人使用，那就要遵守某种特定的 ABI 标准。

Unix 和 Linux 系统的调用约定：System V AMD64 ABI。

ABI：即 Application Binary Interface，应用程序的二进制接口。

整体的处理过程

典型过程：指令选择 -> 指令排序 -> 寄存器分配 -> 指令排序 ->Emit 目标代码

要点：基于 LIR，并且不断 lower。

知识地图

结束语 | 实战是唯一标准！

你好，我是宫文学。

转眼之间，“编译原理实战课”计划中的内容已经发布完毕了。在这季课程中，你的感受如何？有了哪些收获？遇到了哪些困难？

很多同学可能会觉得这一季的课程比上一季的“编译原理之美”要难一些。不过为什么一定要推出这么一门课，来研究实际编译器的实现呢？这是因为我相信，实战是检验你是否掌握了编译原理的唯一标准，也是学习编译原理的真正目标。

计算机领域的工程性很强。这决定了我们学习编译原理，不仅仅是掌握理论，而是要把它付诸实践。在我们学习编译原理的过程中，如果遇到内心有疑惑的地方，那不妨把实战作为决策的标准。

这些疑惑可能包括很多，比如：

词法分析和语法分析工具，应该手写，还是用工具生成？

应该用 LL 算法，还是 LR 算法？

后端应该用工具，还是自己手写？

我是否应该学习后端？

IR 应该用什么数据结构？

寄存器分配采用什么算法比较好？

……

上述问题，如果想在教科书里找到答案，哪怕是“读万卷书”，也是比较难的。而换一个思路，“行万里路”，那就很容易了。你会发现每种语言，因为其适用的领域和设计的目标不同，对于上述问题会采用不同的技术策略，而每种技术策略都有它的道理。从中，你不仅仅可以为上述问题找到答案，更重要的是学会权衡不同技术方案的考虑因素，从而对知识点活学活用起来。

我们说实战是标准。那你可能会反问，难道掌握基础理论和原理就不重要了吗？这是在很多领域都在争论的一个话题：理论重要，还是实践重要。

理论重要，还是实践重要？

理论和原理当然重要，在编译原理中也是如此。形式语言有关的理论，以及前端、中端和后端的各个经典算法，构筑了编译原理这门课坚实的理论基础。

但是，在出现编译原理这门课之前，在出现龙书虎书之前，工程师们已经在写编译器了。

你在工作中，有时候就会遇到理论派和实践派之争。举例来说，有时候从理论角度，某一个方案会“看上去很美”。那到底是否采用该方案呢？这个时候，就需要拿实践来说话了。

我拿 Linux 内核的发展举个例子。当年 Linus 推出 Linux 内核的时候，并没有采用学术界推崇的微内核架构，为此 Linus 还跟 Minix 的作者有一场著名的辩论。而实践证明，Linux 内核发展得很成功，而 GNU 的另一个采用微架构的内核 Hurd 发展了 20 多年还没落地。

客观地说，Linux 内核后来也吸收了很多微内核的设计理念。而声称采用微内核架构的 Windows 系统和 macOS 系统，其实在很多地方也已经违背了微内核的原则，而具备 Linux 那样的单内核的特征。之所以有上述的融合，其实都是一个原因，就是为了得到更好的实用效果。所以，实践会为很多历史上的争论划上句号。

在编译技术和计算机语言设计领域，也存在着很多的理论与实践之争。比如，理论上，似乎函数式编程更简洁、更强大，学术界也很偏爱它，但是纯函数的编程语言，至今没有成为主流，这是为什么呢？

再比如，是否一定要把龙书虎书都读明白，才算学会了编译原理呢？

再进一步，如果你使用编译技术的时候，遇到一个实际的问题，是跟着龙书、虎书还有各种课本走，还是拿出一个能解决问题的方案就行？

在课程里，我鼓励你抛弃一些传统上学习编译原理的困扰。如果龙书、虎书看不明白，那也不用过于纠结，这并不会挡住你学习的道路。多看实际的编译器，多自己动手实践，在这个过程中，你自然会明白课本里原来不知所云的知识点。

那么如何以实践为指导，从而具备更好的技术方案鉴别力呢？在本课程里，我们有三个重点。包括研究常用语言的编译器、从计算机语言设计的高度来理解编译原理，以及从运行时的实现来理解编译原理。

对于你所使用的语言，应该把它的编译器研究透

这门课程的主张是，你最好把自己所使用语言的编译器研究透。这个建议有几个理由。

第一，因为这门语言是你所熟悉的，所以你研究起来速度会更快。比如，可以更快地写出测试用的程序。并且，由于很多语言的编译器都已经实现了自举，比如说 Go 语言和 Java 语言的编译器，所以你可以更快地理解源代码，以及对编译器本身做调试。

第二，这门语言的编译器所采用的实现技术，一定是体现了该语言的特性的。比如 V8 会强调解析速度快，Java 编译器要支持注解特性等，值得你去仔细体会。

第三，研究透编译器，会加深你对这门语言的理解。比如说，你了解清楚了 Java 的编译器是如何处理泛型的，那你就会彻底理解 Java 泛型机制的优缺点。而 C++ 的模板机制，对于学习 C++ 的同学是有一定挑战的。但一旦你把它在编译期的实现机制弄明白，就会彻底掌握模板机制。我也计划在后续用一篇加餐，把 C++ 的模板机制给你拆解一下。

那么，既然编译器是为具体语言服务的，所以，我们也在课程里介绍了计算机语言设计所考虑的那些关键因素，以及它们对编译技术的影响。

从计算机语言设计的高度，去理解编译技术

在课程里你已经体会到了，语言设计的不同，必然导致采用编译技术不同。

其实，从计算机语言设计的高度上看，编译器只是实现计算机语言的一块底层基石。计算机语言设计本身有很多的研究课题，比如类型系统、所采用的编程范式、泛型特性、元编程特性等等，我们在课程里有所涉猎，但并没有在理论层面深挖。有些学校会从这个方向上来培养博士生，他们会在理论层面做更深入的研究。

什么样的计算机语言是一个好的设计？这是一个充满争议的话题，我们这门课程尽量不参与这个话题的讨论。我们的任务，是要知道当采用不同的语言设计时，应该如何运用编译技术来落地。特别是，要了解自己所使用的语言的实现机制。

如果说计算机语言设计，是一种偏理论性的视角，那么程序具体的运行机制，则是更加注重落地的一种视角。

从程序运行机制的角度，去理解编译技术

学习编译原理的一个挑战，就在于你必须真正理解程序是如何运行的，以及程序都可以有哪几种运行方式。这样，你才能理解如何生成服务于这种运行机制的目标代码。

最最基础的，你需要了解像 C 语言这样的编译成机器码直接运行的语言，它的运行机制是怎样的。代码放在哪里，又是如何一步步被执行的。在执行过程中，栈是怎么变化的。函数调用的过程中，都发生了些什么事情。什么数据是放在栈里的，什么数据是放在堆里的，等等。

在此基础上，如果从 C 语言换成 C++ 呢？C++ 多了个对象机制，那对象在内存里是一个什么结构？多重继承的时候是一个什么结构？在存在多态的时候，如何实现方法的正确绑定？这些 C++ 比 C 语言多出来的语义，你也要能够在运行时机制中把它弄清楚。

再进一步，到了 Go 语言，仍然是编译成机器码运行的，但跟 C 和 C++ 又有本质区别。因为 Go 语言的运行时里包含了垃圾收集机制和并发调度机制，这两个机制要跟你的程序编译成的代码互相配合，所以编译器生成的目标代码里要体现内存管理和并发这两大机制。像 Go 语言这种特殊的运行机制，还导致了跨语言调用的难度。用 Go 语言调用 C 语言的库，要有一定的转换和开销。

然后呢，语言运行时的抽象度进一步增加。到了 Java 语言，就用到一个虚拟机。字节码也正式登台亮相。你需要知道栈机和寄存器机这两种不同的运行字节码的解释器，也要知道它们对应的字节码的差别。而为了提升运行速度，JIT、分层编译和逆优化机制又登场，栈上替换（OSR）技术也产生。这个时候，你需要理解解释执行和运行 JIT 生成的本地代码，是如何无缝衔接的。这个时候的栈桢，又有何不同。

然后是 JavaScript 的运行时机制，就更加复杂了。V8 不仅具备 JVM 的那些能力，在编译时还要去推断变量的类型，并且通过隐藏类的方式安排对象的内存布局，以及通过内联缓存的技术去加快对象属性的访问速度。

这样从最简单的运行时，到最复杂的虚拟机，你都能理解其运行机制的话，你其实不仅知道在不同场景下如何使用编译技术，甚至可以参与虚拟机等底层软件的研发了。

不再是谈论，来参与实战吧！

今天，我们学习编译原理，目标不能放在考试考多少分上。中国的技术生态，使得我们已经能够孕育自己的编译器、自己的语言、自己的虚拟机。方舟编译器已经带了个头。我想，中国不会只有方舟编译器孤军奋战的！

就算是开发普通的应用软件，我们也要运用编译技术，让它们平台化，让中国制造的软件，具有更高的技术含量，颠覆世界对于“中国软件”的品牌认知。这样的颠覆，在手机、家电等制造业已经发生了，也应该轮到软件业了。

而经验告诉我们，一旦中国的厂商和工程师开始动起来，那么速度会是非常快的。编译技术并没有多么难。我相信，只要短短几年时间，中国软件界就会在这个领域崭露头角！

这就是我们这门课程的目的。不是为了学习而学习，而是为了实战而学习。

当然，课程虽然看似结束了，但也代表着你学习的重新开始。后面我计划再写几篇加餐，会针对 C++、Rust 等编译器再做一些解析，拓展你的学习地图。并且，针对方舟编译器，我还会进一步跟你分享我的一些研究成果，希望我们可以形成一个持续不断地对编译器进行研究的社群，让学习和研究不断深入下去，不断走向实用。

另外，我还给你准备了一份毕业问卷，题目不多，希望你能在问卷里聊一聊你对这门课的看法。欢迎你点击下面的图片，用 1～2 分钟时间填写一下，期待你畅所欲言。当然了，如果你对课程内容还有什么问题，也欢迎你在留言区继续提问，我会持续回复你的留言。

我们江湖再见！