This commit is contained in:
louzefeng
2024-07-09 18:38:56 +00:00
parent 8bafaef34d
commit bf99793fd0
6071 changed files with 1017944 additions and 0 deletions

View File

@@ -0,0 +1,88 @@
<audio id="audio" title="27 | 课前导读:学习现代语言设计的正确姿势" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b6/b8/b68c22c06137ee17a4ac4017a9cd8db8.mp3"></audio>
你好,我是宫文学。
到目前为止你就已经学完了这门课程中前两个模块的所有内容了。在第一个模块“预备知识篇”我带你梳理了编译原理的关键概念、算法等核心知识点帮你建立了一个直观的编译原理基础知识体系在第二个模块“真实编译器解析篇”我带你探究了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的脚本。让一门语言成为某个流行的技术系统的脚本为这个生态提供编程支持就是一种定位很清晰的需求。
<img src="https://static001.geekbang.org/resource/image/f9/f7/f9034be8cf1e452808154bb16b3c7df7.jpg" alt="" title="各种编程语言的产生,以及与技术生态的关联">
好,明确了语言的需求以后,我们再来说说**设计问题**。
设计是实现计算机语言这种软件所要做的技术选择。你已经看到,我们研究的不同语言,其实现技术都各有特点,分别服务于该语言的需求问题,或者说设计目标。
我还是拿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界广泛关注的编译器。俗话说外行看热闹内行看门道。做一个编译器到底有哪些关键的技术点它们在方舟编译器里是如何体现的我们在学习了编译原理的核心基础知识在考察了多个编译器之后应该能够有一定的能力去考察方舟编译器了。这也是学以致用、紧密结合实际的表现。通过这样的分析你能了解到中国编译技术崛起的趋势甚至还可能会思考如何参与到这个趋势中来。这一讲我希望同学们都能发表自己的看法而我的看法呢只是一家之言你作为参考就好了。
## 小结
总结一下。咱们课程的名称是《编译原理实战课》,而最体现实战精神的,莫过于去实现一门计算机语言了。而在第三个模块,我就会带你解析实现一门计算机语言所要考虑的那些关键技术,并且通过学习,你也能够根据语言的设计目标来选择合适的技术方案。
从计算机语言设计的高度出发,这个模块会带你对编译原理形成更全面的认知,从而提高你把编译原理用于实战的能力。

View File

@@ -0,0 +1,344 @@
<audio id="audio" title="28 | 前端总结:语言设计也有人机工程学" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6c/d5/6cc5f61f2593507bf90ac02507a301d5.mp3"></audio>
你好,我是宫文学。
正如我在上一讲的“课程导读”中所提到的,在“现代语言设计篇”,我们会开始探讨现代语言设计中的一些典型特性,包括前端、中后端、运行时的特性等,并会研究它们与编译技术的关系。
今天这一讲我先以前面的“真实编译器解析篇”所分析的7种编译器作为基础来总结一下它们的前端技术的特征为你以后的前端工作做好清晰的指引。
在此基础上我们还会进一步讨论语言设计方面的问题。近些年各种新语言都涌现出了一个显著特征那就是越来越考虑对程序员的友好性运用了人机工程的思维。比如说自动类型推导、Null安全性等。那么在这里我们就一起来分析一下要支持这些友好的语法特征在编译技术上都要做一些什么工作。
好,首先,我们就来总结一下各个编译器的前端技术特征。
## 前端编译技术总结
通过前面课程中对7个编译器的解读分析我们现在已经知道了编译器的前端有一些共性的特征包括手写的词法分析器、自顶向下分析为主的语法分析器和差异化的语义分析功能。
### 手写的词法分析器
我们分析的这几个编译器,全部都采用了手写的词法分析器。主要原因有几个:
- 第一,手写的词法分析实现起来比较简单,再加上每种语言的词法规则实际上是大同小异的,所以实现起来也都差不多。
- 第二手写词法分析器便于做一些优化。典型的优化是把关键字作为标识符的子集来识别而不用为识别每个关键字创建自动机。V8的词法分析器还在性能上做了调优比如判断一个字符是否是合法的标识符字符是采用了查表的方法以空间换性能提高了解析速度。
- 第三,手写词法分析器便于处理一些特殊的情况。在 [MySQL的词法分析器](https://time.geekbang.org/column/article/266790)中我们会发现它需要根据当前字符集来确定某个字符串是否是合法的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 &lt;&lt; typeid(b).name() &lt;&lt; endl; //输出int
```
你可能会觉得这看上去似乎也没啥呀把int换成了auto好像并没有省多少事儿。但在下面这个例子中你会发现用于枚举的变量的类型很长`std::vector&lt;std::string&gt;::iterator`那么你就大可以直接用一个auto来代替省了很多事代码也更加整洁。所以实际上auto关键字也成为了在C++中使用枚举器的标准用法:
```
std::vector&lt;std::string&gt; vs;
for(std::vector&lt;std::string&gt;::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&lt;String, User&gt; a = new HashMap&lt;String, User&gt;(); //显式声明
var b = new HashMap&lt;String, User&gt;(); //类型推导
```
你在学习了语义分析中,**基于属性计算做类型检查的机制**以后,就会发现实现类型推导,其实是很容易的。只需要把等号右边的初始化部分的类型,赋值给左边的变量就行了。
可以看到,在不同的编译器的实现当中,类型推导被如此广泛地接受,所以如果你要设计一门新的语言,你也一定要考虑类似的做法。
我们接着再来探讨下一个有趣的特性它叫做“Null安全性”。
### Null安全性
在C++和Java等语言里会用Null引用来**表示某个变量没有指向任何对**象。这个特性使得语言里充满了Null检查否则运行时就会报错。
给你举个例子。下面这段代码中我们想要使用student.teacher.name这个成员变量因此程序要逐级检查student、teacher和name是否为Null。不检查又不行检查又太啰嗦。你在自己写程序的时候肯定也遇到过这种困扰。
```
if (student != null
&amp;&amp; student.teacher != null
&amp;&amp; student.teacher.name !=null){
...
}
```
Null引用其实是托尼·霍尔Tony Hoare在1960年代在设计某一门语言ALGOL W时引入的后来也纷纷被其他语言所借鉴。但Hoare后来却认为这是一个“价值亿万美元的错误”你可以看看他在[QCon上的演讲](https://www.infoq.com/presentations/Null-References-The-Billion-Dollar-Mistake-Tony-Hoare/)。因为大量的软件错误都是由Null引用引起的而计算机语言的设计者本应该从源头上消除它。
其实我觉得Hoare有点过于自责了。因为在计算机语言发展的早期很多设计决定的后果都是很难预料的当时的技术手段也很有限。而在计算机语言已经进化了这么多年的今天我们还是有办法消除或者减少Null引用的不良影响的。
以Kotlin为例在缺省情况下它不允许你把Null赋给变量因此这些变量就不需要检查是否为Null。
```
var a : String = &quot;hello&quot;;
a = null; //报编译错误
```
不过有的时候,**你确实需要用到Null那该怎么办**
你需要这样的声明变量,在类型后面带上问号,告诉编译器这个变量可为空:
```
var a : String? = &quot;hello&quot;;
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中你可以直接调用一个整型或浮点型数字的方法
<img src="https://static001.geekbang.org/resource/image/64/5f/64011ee94443f36b0189c4c243f8be5f.jpg" alt="">
**不过你要注意的是**,如果你要把基础数据类型也看作是对象,在编译器的实现上要做一些特殊的处理,因为如果把这些基础数据当作普通对象一样保存在堆里,那显然要占据太多的空间(你可以回忆一下[Java对象头所需要的空间](https://time.geekbang.org/column/article/257504)),并且访问性能也更低。
**那么要如何解决这些问题呢?**这里我先留一个伏笔,我们在“综合实现(一):如何实现面向对象编程?”这一讲再来讨论吧!
除了语法上的一些友好设计之外,一些现代语言还在词法规则方面,提供了一些友好的设计。我们一起来看一下。
### 一些友好的词法规则
**1.嵌套的多行注释**
编程语言一般都支持多行注释。比如,你可以把暂时用不到的一段代码给注释起来。这些代码里如果有单行注释也不妨碍。
但是像Kotlin、Swift这些语言又更进了一步它们可以支持在多行注释里嵌套多行注释。这是一个很贴心的功能。这样的话你就可以把连续好几个函数或方法给一起注释掉。因为函数或方法的头部一般都有多行的头注释。支持嵌套注释的话我们就可以把这些头注释一起包含进去。
你可以去看看它们的词法分析器中处理注释的逻辑,了解下它们是如何支持嵌套的多行注释的。
**2.标识符支持Unicode**
现代的大部分语言都支持用Unicode来声明变量甚至可以声明函数或类。这意味着什么呢**你可以用中文来声明变量和函数名称**。而对于科学工作者来说你也可以使用π、α、β、θ这些希腊字母会更符合自己的专业习惯。下面是我在Julia中使用Unicode的情况
<img src="https://static001.geekbang.org/resource/image/c6/23/c683b95e1ecec10455120b187c61c123.jpg" alt="">
**3.多行字符串字面量**
对于字符串字面量来说支持多行的书写方式也会给我们的编程工作带来很多的便利。比如假设你要把一个JSON字符串或者一个XML字符串赋给一个变量用多行的书写方式会更加清晰。如下所示
<img src="https://static001.geekbang.org/resource/image/d3/8f/d348c5e28b8d9c6be3821f2da93b228f.jpg" alt="">
现在,很多的编程语言都可以支持多行的字符串字面量,比如:
<img src="https://static001.geekbang.org/resource/image/00/77/0026edyyb9f82a188aec144e37283f77.jpg" alt="">
## 课程小结
今天这一讲,我带你一起总结了一下编译原理的前端技术。在解析了这么多个编译器以后,你现在对于实现前端功能时,到底应该选择什么技术、不同的技术路线有什么优缺点,就都心里有数了。
另外,很多我们可以感知得到的现代语言特性,都是一些前端的功能。比如,更友好的词法特性、更友好的语法特性,等等。你可以借鉴当前语言的一些最佳实践。以你现在的知识积累来说,理解上述语言特性在前端的实现过程,应该不难了。如果你对哪个特性特别感兴趣,也可以按照课程的思路,去直接研究它的编译器。
最后,我把本讲的思维导图也整理了出来,供你参考:
<img src="https://static001.geekbang.org/resource/image/ab/a9/ab5684f9bcfd8912d5a86eefaae85da9.jpg" alt="">
## 一课一思
你比较推崇哪些友好的前端语言特性?它们是怎么实现的?欢迎在留言区分享你的看法。另外,如果你觉得哪些前端特性的设计是失败的,也可以拿来探讨,我们共同吸取教训。
感谢你的阅读,欢迎你把今天的内容分享给更多的朋友。

View File

@@ -0,0 +1,259 @@
<audio id="audio" title="29 | 中端总结:不遗余力地进行代码优化" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6d/fd/6df9669f1c99dfd952926d09690b19fd.mp3"></audio>
你好,我是宫文学。
今天这一讲我继续带你来总结前面解析的7种真实的编译器中**中端部分**的特征和编译技术。
在课程的[第1讲](https://time.geekbang.org/column/article/242479)我也给你总结过编译器的中端的主要作用就是实现各种优化。并且在中端实现的优化基本上都是机器无关的。而优化是在IR上进行的。
所以,今天这一讲,我们主要来总结以下这两方面的问题:
- **第一是对IR的总结。**我在[第6讲](https://time.geekbang.org/column/article/247700)中曾经讲过IR分为HIR、MIR和LIR三个层次可以采用线性结构、图、树等多种数据结构。那么基于我们对实际编译器的研究再一起来总结一下它们的IR的特点。
- **第二,是对优化算法的总结。**在[第7讲](https://time.geekbang.org/column/article/248770),我们把各种优化算法做了一个总体的梳理。现在就是时候,来总结一下编译器中的实际实现了。
通过今天的总结你能够对中端的两大主题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意味着什么呢<strong>源代码中的一个变量会变成多个版本每次赋值都形成一个新版本。在SSA中它们都叫做一个**值Value</strong>,对变量的赋值就是**对值的定义def**。这个值定义出来之后,就可以在定义其他值的时候**被使用use**,因此就形成了清晰的“使用-定义”链use-def
这种清晰的use-def链会给优化算法提供很多的便利
- 如果一个值定义了,但没有被使用,那就可以做**死代码删除**。
- 如果对某个值实现了常数折叠那么顺着def-use链我们就可以马上把该值替换成常数从而实现**常数传播**。
- 如果两个值的定义是一样的,那么这两个值也一定是一样的,因此就可以去掉一个,从而实现**公共子表达式消除**而如果不采取SSA实现CSE公共子表达式消除需要做一个**数据流分析**,来确定表达式的变量值并没有发生变化。
针对最后一种情况也就是公共子表达式消除我再给你展开讲解一下让你充分体会SSA和传统IR的区别。
我们知道基于传统的IR要做公共子表达式消除就需要专门做一个“可用表达式”的分析。像下图展示的那样每扫描一遍代码就要往一个集合里增加一个可用的表达式。
**为什么叫做可用表达式呢?**因为变量可能被二次赋值就像图中的变量c那样。在二次赋值以后之前的表达式“c:=a+b”就不可用了。
<img src="https://static001.geekbang.org/resource/image/64/ba/6415ed5ce7c4e2f4d1ee7565d4381fba.jpg" alt="">
在后面当替换公共子表达式的时候我们可以把“e:=a+b”替换成“e:=d”这样就可以少做一次计算实现了优化的目标。
而如果采用SSA格式上面这几行语句就可以改写为下图中的样子
<img src="https://static001.geekbang.org/resource/image/cb/77/cb940936a68c3ecf5e9f444cf1606177.jpg" alt="">
可以看到原来的变量c被替换成了c1和c2两个变量而c1、d和e右边的表达式都是一样的并且它们的值不会再发生变化。所以我们可以马上消除掉这些公共子表达式从而减少了两次计算这就比采用SSA之前的优化效果更好了。最重要的是整个过程根本不需要做数据流分析。
<img src="https://static001.geekbang.org/resource/image/43/4a/43f0107ffec76d69deaeb61cd8a7094a.jpg" alt="">
在掌握了SSA格式的特点以后我们还可以注意到Java和JavaScript的两大编译器在做优化时竟然都不约而同地用到了Sea Of Nodes这种数据结构。它看起来非常重要所以我们再接着来总结一下符合SSA格式的Sea of Nodes都有什么特点。
### Sea of Nodes的特点总结
其实在[解析Graal编译器](https://time.geekbang.org/column/article/256914)的时候,我就提到过,**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生成一个加法节点从而形成一个新的值。
<img src="https://static001.geekbang.org/resource/image/5b/a2/5b6252680b4a2eb98e816556baa92fa2.jpg" alt="">
第4步对于c=b实际上还是直接用b这个节点就行了并不需要生成新节点。
<img src="https://static001.geekbang.org/resource/image/fe/59/fe507edf931c557fbb38cd68fe55c059.jpg" alt="">
第5步和第6步对于d=a+b和e=a+b你会发现它们都没有生成新的值还是跟c1用同一个节点表示就行。
<img src="https://static001.geekbang.org/resource/image/55/3d/5565188f1cfaba3662aed7f31c43d53d.jpg" alt="">
第7步对于return语句这时候生成一个return节点返回上面形成的加法节点即可。
<img src="https://static001.geekbang.org/resource/image/a0/bf/a082908d62e8c0ccbd0b5c2bbcdebdbf.jpg" alt="">
从这个例子中你发现了什么呢?原来,**采用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&lt;a.length; i++){
sum = sum + a[i];
}
return sum;
}
```
我们还是**用Graal做即时编译**,并打印出生成的汇编代码。这里我截取了其中的主要部分,给你做了分析:
<img src="https://static001.geekbang.org/resource/image/53/ed/53026bbf637749c84baa725b2f13e4ed.jpg" alt="">
分析这段汇编代码,你能得到下面的信息:
- Java中的数组其头部在64位环境下占据16个字节其中包含了数组长度的信息。
- Java生成的汇编代码在每次循环开始的时候都要检查下标是否越界。这是一个挺大的运算开销。其实我们使用的数组下标**i**,永远不会越界,所以本来可以优化得更好。
- 上述汇编代码并没有使用SIMD指令没有把循环自动向量化。
我们再来看一下**Go语言**的优化效果示例代码在SIMD.go中。
```
package main
func add(a []int) int {
sum := 0;
for i:=0; i&lt;len(a); i++{
sum = sum + a[i]
}
return sum;
}
```
我们生成Go语言特有的伪汇编以后是下面这个样子
<img src="https://static001.geekbang.org/resource/image/a1/f6/a14f285204e8bd6742550e3460b965f6.jpg" alt="">
我们拿它跟Graal生成的汇编代码做比较会发现其中最重要的差别是Go的编译器消除了下标检查这是一个挺大的进步能够提升不少的性能。不过你也可以测试一下当代码中的“len(a)”替换成随意的一个整数的时候Go的编译器会生成什么代码。它仍然会去做下标检查并在下标越界的时候报错。
不过,**令人遗憾的是Go语言的编译器仍然没有自动生成向量化的代码。**
最后,我们来看一下**Clang**是如何编译同样功能的一个C语言的程序的SIMD.c
```
int add(int a[], int length){
int sum = 0;
for (int i=0; i&lt;length; i++){
sum = sum + a[i];
}
return sum;
}
```
编译生成的汇编代码在SIMD.s中。我截取了其中的骨干部分
<img src="https://static001.geekbang.org/resource/image/d5/2b/d5fd965231ba19e94595ecc2ae24be2b.jpg" alt="">
你已经知道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。
本讲的思维导图我也放在了下面,供你参考:
<img src="https://static001.geekbang.org/resource/image/80/87/80e13cef697dc076c9646b49140b4787.jpg" alt="">
## 一课一思
今天我带你测试了Graal、Go和Clang三个编译器在SIMD方面编译结果的差异。那么你能否测试一下这几个编译器在其他方面的优化表现比如循环无关代码外提或者你比较感兴趣的其他优化。欢迎在留言区分享你的测试心得。
如果你还有其他的问题,欢迎在留言区提问,我会逐一解答。最后,感谢你的阅读,如果今天的内容让你有所收获,也欢迎你把它分享给更多的朋友。

View File

@@ -0,0 +1,278 @@
<audio id="audio" title="30 | 后端总结:充分发挥硬件的能力" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f6/63/f61089067c087774220ee80c9f1a0d63.mp3"></audio>
你好,我是宫文学。
后端的工作主要是针对各种不同架构的CPU来生成机器码。在[第8讲](https://time.geekbang.org/column/article/249261),我已经对编译器在生成代码的过程中,所做的主要工作进行了简单的概述,你现在应该对编译器的后端工作有了一个大致的了解,也知道了后端工作中的关键算法包括指令选择、寄存器分配和指令排序(又叫做指令调度)。
那么今天这一讲,我们就借助在第二个模块中解析过的真实编译器,来总结、梳理一下各种编译器的后端技术,再来迭代提升一下原有的认知,并加深对以下这些问题的理解:
- 首先在第8讲中我只讲了**指令选择**的必要性,但对于如何实现指令选择等步骤,我并没有展开介绍。今天这一讲,我就会带你探索一下指令选择的相关算法。
- 其次,关于**寄存器分配算法**我们探索过的好几个编译器比如Graal、gc编译器等采用的都是线性扫描算法那么这个算法的原理是什么呢我们一起来探究一下。
- 最后,我们再回到**计算机语言设计**的主线上来,一起分析一下不同编译器的后端设计,是如何跟该语言的设计目标相匹配的。
OK我们先来了解一下指令选择的算法。
## 指令选择算法
回顾一下,我们主要是在[Graal](https://time.geekbang.org/column/article/258162)和[Go语言](https://time.geekbang.org/column/article/266379)的编译器中分析了与指令选择有关的算法。它们都采用了一种模式匹配的DSL只要找到了符合模式的指令组合编译器就生成一条低端的、对应于机器码的指令。
**那为什么这种算法是有效的呢?这种算法的原理是什么呢?都有哪些不同的算法实现?**接下来,我就给你揭晓一下答案。
我先给你举个例子。针对表达式“a[i]=b”它是对数组a的第i个元素赋值。假设a是一个整数数组那么地址的偏移量就是`a+4*i`所以这个赋值表达式用C语言可以写成“`*(a+4*i)=b`把它表达成AST的话就是下图所示的样子。其中赋值表达式的左子树的计算结果是一个内存地址。
<img src="https://static001.geekbang.org/resource/image/a2/d3/a234b0a6b2bb153f2857989e16106bd3.jpg" alt="">
那么,我们要如何给这个表达式生成指令呢?
如果你熟悉x86汇编你就会知道上述语句可以非常简单地表达出来因为x86的指令对数组寻址做了优化参见第8讲的内容
不过这里为了让你更容易理解算法的原理我设计了一个新的指令集。这个指令集中的每条指令都对应了一棵AST的子树我们把它叫做**模式树Pattern Tree**。在有的算法里,它们也被叫做**瓦片Tiling**。对一个AST生成指令就是用这样的模式树或瓦片来覆盖整个AST的过程。所以这样的算法也叫做**基于模式匹配的指令生成算法**。
<img src="https://static001.geekbang.org/resource/image/4b/e9/4ba05f685d18635767a7f8f3924b90e9.jpg" alt="">
你可以看到在图2中对于每棵模式树它的根节点是这个指令产生的结果的存放位置。比如Load_Const指令执行完毕以后常数会被保存到一个寄存器里。这个寄存器又可以作为上一级AST节点的操作数来使用。
图2中的指令包含把常数和内存中的值加载到寄存器、加法运算、乘法运算等。其中有两个指令是特殊设计的目的就是为了让你更容易理解接下来要探究的各种算法。
**第一个指令是#4Store_Offset**,它把值保存到内存的时候,可以在目的地址上加一个偏移量。你可以认为这是为某些场景做的一个优化,比如你在对象地址上加一个偏移量,就能获得成员变量的地址,并把数值保存到这个地址上。
**第二个指令是#9Lea**它相当于x86指令集中的Lea指令能够计算一个地址值特别是能够利用间接寻址模式计算出一个数组元素的地址。它能通过一条指令完成一个乘法计算和一个加法计算。如果你忘记了Lea指令可以重新看看第8讲的内容。
基于上述的指令和模式树,我们就可以尝试来做一下模式匹配,从而选择出合适的指令。**那么都可以采用什么样的算法呢?**
**第一个算法,是一种比较幼稚的算法。我们采取深度优先的后序遍历,也就是按照“左子节点-&gt;右子节点-&gt;父节点”的顺序遍历,针对每个节点去匹配上面的模式。**
- 第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]的地址。
<img src="https://static001.geekbang.org/resource/image/8a/5e/8afb39c83db4de7c6758f486ca27395e.jpg" alt="">
最后形成的汇编代码是这样的:
```
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条指令代价是193+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
<img src="https://static001.geekbang.org/resource/image/a4/1b/a4aac19471190a449bab1b947ae5711b.jpg" alt="">
到此为止我们用了5条指令就做完了所有的运算生成的汇编代码是
```
Load_Mem a, R1
Load_Const 4, R2
Mul_Mem R2, i
Load_Mem b, R3
Store_Offset R3, (R1,R2)
```
这5条指令总的Cost是183+1+6+3+5
上述算法的特点,是在每一步都采用了**贪婪策略**这种算法策略有时候也叫做“Maximal Munch”意思就是每一步都去咬最大的一口。
贪婪策略会生成比幼稚的算法更优化的代码但它不一定是最优的。你看下图中的匹配策略它也是用了5条指令。
<img src="https://static001.geekbang.org/resource/image/69/3b/69ffa14568a9f85654a92074458bb43b.jpg" alt="">
生成的汇编代码如下:
```
Lead_Mem a, R1
Load_Mem i, R2
Lea (R1,R2,4), R1
Load_Mem b, R2
Store R2, (R1)
```
这个新的匹配结果总的Cost是173+3+4+3+4比前一个算法的结果更优化了。那我们用什么算法能得到这样一个结果呢
一个思路,是**找出用模式匹配来覆盖AST的所有可能的模式并找出其中Cost最低的**。你可以采用**暴力枚举**的方法在每一个节点去匹配所有可能的模式从而找出多组解。但显然这种算法的计算量太大所需的时间会根据AST的大小呈指数级上升导致编译速度无法接受。
所以我们需要找到一个代价更低的算法,这就是**BURS算法**也就是“自底向上重写系统Bottom-Up Rewriting System”。在[HotSpot的C2编译器](https://time.geekbang.org/column/article/258162)中就采用了BURS算法。这个算法采用了**动态规划**Dynamic Programming的数学方法来获取最优解同时保持了较低的算法复杂度。
**那么要想理解BURS算法你就必须要弄懂动态规划的原理。**如果你之前没有学过这个数学方法,请不要紧张,因为动态规划的原理其实是相当简单的。
我在网上发现了一篇能够简洁地说清楚动态规划的[文章](https://cloud.tencent.com/developer/article/1475703)。它举了一个例子,用最少张的纸币,来凑出某个金额。
比如说假设你要凑出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节点
<img src="https://static001.geekbang.org/resource/image/dd/2d/dddd87f278c8111d4787756380e08b2d.jpg" alt="">
**第二遍扫描**是自顶向下的,运用动态规划的方法找出最优解。
**第三遍扫描**又是自底向上的,用于生成指令。
好了,那么到目前为止,你就已经了解了指令生成的算法思路了。这里我再补充几点说明:
- 示例中的指令和Cost值是为了便于你理解算法而设计的。在这个示例中最优解和最差解的Cost只差了2也就是大约12%的性能提升。而在实际应用中,优化力度往往会远远大于这个值。
- 在[第6讲](https://time.geekbang.org/column/article/247700)探究IR的数据结构时我提到过有向无环图DAG它比起刚才例子中用到的树结构能够消除一些冗余的子树从而减少生成的代码量。LLVM里在做指令选择的时候就是采用了DAG但算法思路是一样的。
- 示例中到的两个算法贪婪算法和BURS算法它们花费的时间都与节点数呈线性关系所以性能都是很高的。其中BURS算法的线性系数更大一点做指令选择所需的时间也更长一点。
OK那么接下来我们来探究第二个算法寄存器分配算法。
## 寄存器分配算法
在解析Graal编译器和Go的编译器的时候我都提到过它们的寄存器分配算法是线性扫描算法。我也提到过线性扫描算法的性能比较高。
**那么,线性扫描算法的原理是什么呢?**总的来说,线性扫描算法理解起来其实相当简单。我用一个例子来带你了解下。
假设我们的程序里有从a到g共7个变量。通过数据流分析中的变量活跃性分析你其实可以知道每个变量的生存期。现在我们已知有4个物理寄存器可用那么我们来看一下要怎么分配这几个物理寄存器。
**在第1个时间段**a、b、c和d是活跃的那我们刚好把4个物理寄存器分配给这四个变量就行了。
<img src="https://static001.geekbang.org/resource/image/39/75/39dc35a5b607cd3066017c08d0191975.jpg" alt="">
**在第2个时间段**a的生存期结束而一个新的变量e变得活跃那么我们就把a原来占用的寄存器刚好给到e就可以了。
<img src="https://static001.geekbang.org/resource/image/b6/4a/b64ae2b4cfd318f9eeeeaa54ab83984a.jpg" alt="">
**在第3个时间段**我们把c占用的寄存器给到f目前仍然是使用4个寄存器。
<img src="https://static001.geekbang.org/resource/image/48/63/48fb912ba5c59f8c04373a91510b5563.jpg" alt="">
**在第4个时间段**b的生存期结束。这时候只需要用到3个寄存器。
<img src="https://static001.geekbang.org/resource/image/ca/04/ca60eb79fedac105f3cbfd6f8c509404.jpg" alt="">
**在最后一个时间段**只有变量d和g是活跃的占用两个寄存器。
<img src="https://static001.geekbang.org/resource/image/04/0a/04b2d9a8cyy51a6916cc286b29ea9a0a.jpg" alt="">
可以看到,在上面这个例子中,所有的变量都可以分配到物理寄存器。而且你也会发现,这个例子中存在多个变量因为生存期是错开的,因此也可以共享同一个寄存器。
但是,如果没有足够的物理寄存器的话,我们要怎么办呢?那就需要把某个变量溢出到内存里了。也就是说,当用到这个变量的时候,才把这个变量加载到寄存器,或者有一些指令可以直接用内存地址作为操作数。
给你举另一个例子,我们来看看物理寄存器不足的情况会是什么样子。在这个例子中,我们有三个物理寄存器。
**在第1个时间段**,物理寄存器是够用的。
<img src="https://static001.geekbang.org/resource/image/0b/d1/0b511374c86b9a14c1bec1d52c718cd1.jpg" alt="">
**在第2个时间段**变量d变得活跃现在有4个活跃变量所以必须选择一个溢出到内存。我们选择了a。
<img src="https://static001.geekbang.org/resource/image/b8/cf/b81c0a71067d26f68f6902e26f1040cf.jpg" alt="">
**在第3个时间段**e和f变得活跃现在又需要溢出一个变量才可以。这次选择了c。
<img src="https://static001.geekbang.org/resource/image/3c/88/3c6f242389253e6f5e9dc43f62a4fa88.jpg" alt="">
**在第4个时间段**g也变得活跃这次把d溢出了。
<img src="https://static001.geekbang.org/resource/image/c6/64/c6a906528d9d8331bb7de9b87163cc64.jpg" alt="">
以上就是**线性扫描算法的思路:线性扫描整个代码,并给活跃变量分配寄存器。如果物理寄存器不足,那么就选择一个变量,溢出到内存中。**你看,是不是很简单?
在掌握了线性扫描算法的思路以后,我再给你补充一点信息:
- 第一,线性扫描算法并不能获得寄存器分配的最优解。所谓最优解,是要让尽量多的操作在寄存器上实现,尽量少地访问内存。因为线性扫描算法并没有去确定一个最优值的目标,所以也就谈不上最优解。
- 第二,线性扫描算法可以采用一些策略,让一些使用频率低的变量被溢出,而像高频使用的循环中的变量,就保留在寄存器里。
- 第三,还有一些其他提升策略。比如,当存在多余的物理寄存器以后,还可以把之前已经溢出的变量重新复活到寄存器里。
好了上述就是线性扫描的寄存器分配算法。另外我们再来复习一下在第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以便让算法尽量中立于具体的硬件架构。
我把本讲的知识点也整理成了思维导图,供你复习和参考:
<img src="https://static001.geekbang.org/resource/image/82/93/82c8e25b80368f16cfb2173b4f00a193.jpg" alt="">
## 一课一思
动态规划算法是这节课的一个重要知识点。在学过了这个知识点以后,你能否发现它还可以被用于解决哪些问题?欢迎分享你的经验和看法。
## 参考资料
- 对动态规划方法的理解,我建议你读一下[这篇文章](https://cloud.tencent.com/developer/article/1475703),通俗易懂。
- 在《编译原理之美》的[第29讲](https://time.geekbang.org/column/article/158315),有对寄存器分配算法中的图染色算法的介绍,你可以去参考一下。
- 这两篇关于线性扫描算法的经典论文,你可以去看一下:[论文1](http://web.cs.ucla.edu/~palsberg/course/cs132/linearscan.pdf)[论文2](https://dash.harvard.edu/bitstream/1/34325454/1/tr-21-97.pdf)。
- 这篇文章介绍了针对[SSA格式的IR的线性扫描算法](http://cgo.org/cgo2010/talks/cgo10-ChristianWimmer.pdf),值得一看。

View File

@@ -0,0 +1,188 @@
<audio id="audio" title="31 | 运行时从0到语言级的虚拟化" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/2a/3f/2af9f2abc8dd3e523814275bfaf50d3f.mp3"></audio>
你好,我是宫文学。今天,我会带你去考察现代语言设计中的运行时特性,并讨论一下与标准库有关的话题。
你可能要问了,咱们这门课是要讲编译原理啊,为什么要学运行时呢。其实,对于一门语言来说,除了要提供编译器外,还必须提供运行时功能和标准库:一是,编译器生成的目标代码,需要运行时的帮助才能顺利运行;二是,我们写代码的时候,有一些标准的功能,像是读写文件的功能,自己实现起来太麻烦,或者根本不可能用这门语言本身来实现,这时就需要标准库的支持。
其实,我们也经常会接触到运行时和库,但可能只是停留在使用层面上,并不太会关注它们的原理等。如果真要细究起来、真要对编译原理有更透彻的理解的话,你可能就会有下面这些问题了:
- 到底什么是运行时?任何语言都有运行时吗?运行时和编译器是什么关系?
- 什么是标准库?标准库和运行时库又是什么关系?库一般都包含什么功能?
今天,我们就来探讨一下这些与运行时和标准库有关的话题。这样,你能更加充分地理解设计一门语言要完成哪些工作,以及这些工作跟编译技术又有什么关系,也就能对编译原理有更深一层的理解。
首先,我们来了解一下运行时,以及它和编译技术的关系。
## 什么是运行时Runtime
我们在[第5讲](https://time.geekbang.org/column/article/246281)说过每种语言都有一个特定的执行模型Execution Model。而这个执行模型就需要运行时系统Runtime System的支持。我们把这种可以支撑程序运行的运行时系统简称为运行时。
那运行时都包含什么功能呢通常我们最关心的是三方面的功能程序运行机制、内存管理机制和并发机制。接下来我就分别以Java、Python以及C、C++、Go语言的运行时机制为例做一下运行时的分析因为它们的使用者比较多并且体现了一些有代表性的运行时特征。
### Java的运行时
我们先看看Java语言的运行时系统也就是JVM。
其实JVM不仅为Java提供了运行时环境还为其他所有基于JVM的语言提供了支撑包括Scala、Clojure、Groovy等。我们可以通过[JVM的规范](https://docs.oracle.com/javase/specs/jvms/se12/html/index.html)来学习一下它的主要特点。
第一,**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++),是在标准库里提供这些数据结构。
此外,标准库还要包含一些其他功能,比如对日期、图形界面等各种不同的功能支持。
## 课程小结
今天,我们一起学习了一门语言除编译器之外的一些重要组成部分,包括运行时和各种库。编译器拥有运行时和库的知识,并根据这些知识作出正确的编译。当你设计一门语言的时候,应该首先要把它的运行机制设计清楚,然后才能设计出正确的语法、语义,并实现出相应的编译器。
所以,我们这一讲的目标,就是帮你从一个更高的维度来理解编译技术的使用环境,从而更加全面地理解和使用编译技术。
我把今天的知识点也整理成了思维导图,供你参考:
<img src="https://static001.geekbang.org/resource/image/51/f0/51d273f47301db60b0dd19480acd9bf0.jpg" alt="">
## 一课一思
挑你熟悉的一门语言,分享一下它的运行时和标准库的设计特征,以及对编译器的影响。
欢迎你在留言区表达自己的见解,也非常欢迎你把今天的内容分享给更多的朋友。感谢阅读,我们下一讲再见。

View File

@@ -0,0 +1,236 @@
<audio id="audio" title="32 | 运行时(二):垃圾收集与语言的特性有关吗?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a0/ae/a030fa552312a3972dbd3bc516b852ae.mp3"></audio>
你好,我是宫文学。今天,我们继续一起学习垃圾收集的实现机制以及与编译器的关系。
对于一门语言来说垃圾收集机制能够自动管理从堆中申请的内存从而大大降低程序员的负担。在这门课的第二大模块“真实编译器解析篇”中我们学习Java、Python、Go、Julia和JavaScript这几门语言都有垃圾收集机制。那在今天这一讲我们就来学习一下这些语言的垃圾收集机制到底有什么不同跟语言特性的设计又是什么关系以及编译器又是如何配合垃圾收集机制的。
这样如果我们以后要设计一门语言的话,也能清楚如何选择合适的垃圾收集机制,以及如何让编译器来配合选定的垃圾收集机制。
在讨论不同语言的垃圾收集机制之前我们还是需要先了解一下通常我们都会用到哪些垃圾收集算法以及它们都有什么特点。这样我们才能深入探讨应该在什么时候采用什么算法。如果你对各种垃圾收集算法已经很熟悉了也可以从这一讲的“Python与引用计数算法”开始学习如果你还想理解垃圾收集算法的更多细节也可以去看看我的第一季课程《编译原理之美》的[第33讲](https://time.geekbang.org/column/article/162854)的内容。
## 垃圾收集算法概述
垃圾收集主要有标记-清除Mark and Sweep、标记-整理Mark and Compact、停止-拷贝Stop and Copy、引用计数、分代收集、增量收集和并发收集等不同的算法在这里我简要地和你介绍一下。
首先,我们先学习一下什么是内存垃圾。内存垃圾,其实就是一些保存在堆里的、已经无法从程序里访问的对象。
我们看一个具体的例子。
在堆中申请一块内存时比如Java中的对象实例我们会用一个变量指向这块内存。但是如果给变量赋予一个新的地址或者当栈桢弹出时该栈桢的变量全部失效这时变量所指向的内存就没用了如图中的灰色块
<img src="https://static001.geekbang.org/resource/image/7a/c0/7a9d301d4b1c9091d99cfe721d01d9c0.jpg" alt="">
另外如果A对象有一个成员变量指向C对象那么A不可达C也会不可达也就失效了。但D对象除了被A引用还被B引用仍然是可达的。
<img src="https://static001.geekbang.org/resource/image/d7/91/d7a15d401fbb3b31b3bd63c014b21391.jpg" alt="">
那么,所有不可达的内存就是垃圾。所以,垃圾收集的重点就是找到并清除这些垃圾。接下来,我们就看看不同的算法是怎么完成这个任务的。
### 标记-清除
标记-清除算法,是从**GC根节点**出发顺着对象的引用关系依次标记可达的对象。这里说的GC根节点包括全局变量、常量、栈里的本地变量、寄存器里的本地变量等。从它们出发就可以找到所有有用的对象。那么剩下的对象就是内存垃圾可以清除掉。
### 标记-整理
采用标记-清除算法,运行时间长了以后,会形成内存碎片。这样在申请内存的时候,可能会失败。
<img src="https://static001.geekbang.org/resource/image/1b/d9/1b5af01923b40566d5fde1a25a0720d9.jpg" alt="">
**为了避免内存碎片,你可以采用变化后的算法,也就是标记-整理算法:**在做完标记以后,做一下内存的整理,让存活的对象都移动到一边,消除掉内存碎片。
<img src="https://static001.geekbang.org/resource/image/5e/b0/5e7530840bfa8077d9f0944142d932b0.jpg" alt="">
### 停止-拷贝
停止和拷贝算法把内存分为新旧空间两部分。你需要保持一个堆指针,指向自由空间开始的位置。申请内存时,把堆指针往右移动就行了。
<img src="https://static001.geekbang.org/resource/image/f9/10/f9f8c968c6a4bb31c274b8eb0e0b6d10.jpg" alt="">
当旧空间内存不够了以后,就会触发垃圾收集。在收集时,会把可达的对象拷贝到新空间,然后把新旧空间互换。
<img src="https://static001.geekbang.org/resource/image/e9/c9/e98d5746653d7dd0927f2006270a7dc9.jpg" alt="">
停止-拷贝算法,在分配内存的时候,不需要去查找一块合适的空闲内存;在垃圾收集完毕以后,也不需要做内存整理,因此速度是最快的。但它也有缺点,就是总有一半内存是闲置的。
### 引用计数
引用计数方法,是在对象里保存该对象被引用的数量。一旦这个引用数为零,那么就可以作为垃圾被收集走。
有时候我们会把引用计数叫做自动引用计数ARC并把它作为跟垃圾收集GC相对立的一个概念。所以如果你读到相关的文章它把ARC和GC做了对比也不要吃惊。
引用计数实现起来简单并且可以边运行边做垃圾收集不需要为了垃圾收集而专门停下程序。可是它也有缺陷就是不能处理循环引用Reference Cycles的情况。在下图中四个对象循环引用但没有GC根指向它们。它们已经是垃圾但计数却都为1。
<img src="https://static001.geekbang.org/resource/image/fe/08/fe6c0082c0ff95780f5b11e935ea9e08.jpg" alt="">
另外,由于在程序引用一个对象的前后,都要修改引用计数,并且还有多线程竞争的可能性,所以引用计数法的性能开销比较大。
### 分代收集
在程序中新创建的对象往往会很快死去比如你在一个方法中使用临时变量指向一些新创建的对象这些对象大多数在退出方法时就没用了。这些数据叫做新生代。而如果一个对象被扫描多次发现它还没有成为垃圾那就会标记它为比较老的时代。这些对象可能Java里的静态数据成员或者调用栈里比较靠近根部的方法所引用的不会很快成为垃圾。
对于新生代对象,可以更频繁地回收。而对于老一代的对象,则回收频率可以低一些。并且,对于不同世代的对象,还可以用不同的回收方法。比如,新生代比较适合复制式收集算法,因为大部分对象会被收集掉,剩下来的不多;而老一代的对象生存周期比较长,拷贝的话代价太大,比较适合标记-清除算法,或者标记-整理算法。
### 增量收集和并发收集
垃圾收集算法在运行时,通常会把程序停下。因为在垃圾收集的过程中,如果程序继续运行,可能会出错。这种停下整个程序的现象,被形象地称作**“停下整个世界STW”**。
可是让程序停下来,会导致系统卡顿,用户的体验感会很不好。一些对实时性要求比较高的系统,根本不可能忍受这种停顿。
所以,在自动内存管理领域的一个研究的重点,就是如何缩短这种停顿时间。增量收集和并发收集算法,就是在这方面的有益探索:
- 增量收集可以每次只完成部分收集工作,没必要一次把活干完,从而减少停顿。
- 并发收集就是在不影响程序执行的情况下,并发地执行垃圾收集工作。
好了,理解了垃圾收集算法的核心原理以后,我们就可以继续去探索各门语言是怎么运用这些算法的了。
首先我们从Python的垃圾收集算法学起。
## Python与引用计数算法
Python语言选择的是引用计数的算法。除此之外Swift语言和方舟编译器采用的也是引用计数所以值得我们重视。
### Python的内存管理和垃圾收集机制
首先我们来复习一下Python内存管理的特征。在Python里每个数据都是对象而这些对象又都是在堆上申请的。对比一下在C和Java这样的语言里很多计算可以用本地变量实现而本地变量是在栈上申请的。这样你用到一个整数的时候只占用4个字节而不像Python那样有一个对象头的固定开销。栈的优势还包括不会产生内存碎片数据的局部性又好申请和释放的速度又超快。而在堆里申请太多的小对象则会走向完全的反面太多次系统调用性能开销大产生内存碎片数据的局部性也比较差。
所以说Python的内存管理方案就决定了它的内存占用大、性能低。这是Python内存管理的短板。而为了稍微改善一下这个短板Python采用了一套基于区域Region-based的内存管理方法能够让小块的内存管理更高效。简单地说就是Python每次都申请一大块内存这一大块内存叫做Arena。当需要较小的内存的时候直接从Arena里划拨就好了不用一次次地去操作系统申请。当用垃圾回收算法回收内存时也不一定马上归还给操作系统而是归还到Arena里然后被循环使用。这个策略能在一定程度上提高内存申请的效率并且减少内存碎片化。
接下来我们就看看Python是如何做垃圾回收的。回忆一下在[第19讲](https://time.geekbang.org/column/article/261063)分析Python的运行时机制时其中提到了一些垃圾回收的线索。Python里每个对象都是一个PyObject每个PyObject都有一个ob_refcnt字段用于记录被引用的数量。
在解释器执行字节码的时候会根据不同的指令自动增加或者减少ob_refcnt的值。当一个PyObject对象的ob_refcnt的值为0的时候意味着没有任何一个变量引用它可以立即释放掉回收对象所占用的内存。
现在你已经知道采用引用计数方法需要解决循环引用的问题。那Python是如何实现的呢
Python在gc模块里提供了一个循环检测算法。接下来我们通过一个示例来看看这个算法的原理。在这个例子中有一个变量指向对象A。你能用肉眼看出对象A、B、C不是垃圾而D和E是垃圾。
<img src="https://static001.geekbang.org/resource/image/5a/bc/5a8efc25bca7b594f6284131674b4ebc.jpg" alt="">
在循环检测算法里gc使用了两个列表。一个列表保存所有待扫描的对象另一个列表保存可能的垃圾对象。注意这个算法只检测容器对象比如列表、用户自定义的类的实例等。而像整数对象这样的就不用检测了因为它们不可能持有对其他对象的引用也就不会造成循环引用。
在这个算法里我们首先让一个gc_ref变量等于对象的引用数。接着算法假装去掉对象之间的引用。比如去掉从A到B的引用这使得B对象的gc_ref值变为了0。在遍历完整个列表以后除了A对象以外其他对象的gc_ref都变成了0。
<img src="https://static001.geekbang.org/resource/image/d8/e8/d825f11087afcf1482dede2920c66ce8.jpg" alt="">
gc_ref等于零的对象有的可能是垃圾对象比如D和E但也有些可能不是比如B和C。那要怎么区分呢我们先把这些对象都挪到另一个列表中怀疑它们可能是垃圾。
<img src="https://static001.geekbang.org/resource/image/3c/0e/3c0e14f3af25de89f96bc1e75fc57d0e.jpg" alt="">
这个时候待扫描对象区只剩下了对象A。它的gc_ref是大于零的也就是从gc根是可到达的因此肯定不是垃圾对象。那么顺着这个对象所直接引用和间接引用到的对象也都不是垃圾。而剩下的对象都是从gc根不可到达的也就是真正的内存垃圾。
<img src="https://static001.geekbang.org/resource/image/2d/2b/2de8166a19741082b3aef0fb2b57502b.jpg" alt="">
另外基于循环检测的垃圾回收算法是定期执行的这会跟Java等语言的垃圾收集器一样导致系统的停顿。所以它也会像Java等语言的垃圾收集器一样采用分代收集的策略来减少垃圾收集的工作量以及由于垃圾收集导致的停顿。
好了以上就是Python的垃圾收集算法。我们前面提过除了Python以外Swift和方舟编译器也使用了引用计数算法。另外还有些分代的垃圾收集器在处理老一代的对象时也会采用引用计数的方法这样就可以在引用计数为零的时候收回内存而不需要一遍遍地扫描。
### 编译器如何配合引用计数算法?
对于Python来说引用计数的增加和减少是由运行时来负责的编译器并不需要做额外的工作。它只需要生成字节码就行了。而对于Python的解释器来说在把一个对象赋值给一个变量的时候要把对象的引用数加1而当该变量超出作用域的时候要把对象的引用数减1。
不过,对于编译成机器码的语言来说,就要由编译器介入了。它要负责生成相应的指令,来做引用数的增减。
不过,这只是高度简化的描述。实际实现时,还要解决很多细致的问题。比如,在多线程的环境下,对引用数的改变,必须要用到锁,防止超过一个线程同时修改引用数。这种频繁地对锁的使用,会导致性能的降低。这时候,我们之前学过的一些优化算法就可以派上用场了。比如,编译器可以做一下逃逸分析,对于没有逃逸或者只是参数逃逸的对象,就可以不使用锁,因为这些对象不可能被多个线程访问。这样就可以提高程序的性能。
除了通过逃逸分析优化对锁的使用,编译器还可以进一步优化。比如,在一段程序中,一个对象指针被调用者通过参数传递给某个函数使用。在函数调用期间,由于调用者是引用着这个对象的,所以这个对象不会成为垃圾。而这个时候,就可以省略掉进入和退出函数时给对象引用数做增减的代码。
还有不少类似上面的情况需要编译器配合垃圾收集机制生成高效的、正确的代码。你在研究Swift和方舟编译器时可以多关注一下它们对引用计数做了哪些优化。
接下来,我们再看看**其他语言是怎么做垃圾收集**的。
## 其他语言是怎么做垃圾收集的?
除了Python以外我们在第二个模块研究的其他几门语言包括Java、JavaScriptV8和Julia都没有采用引用计数算法除了在分代算法中针对老一代的对象它们基本都采用了分代收集的策略。针对新生代通常是采用标记-清除或者停止拷贝算法。
它们不采用引用计数的原因,其实我们可以先猜测一下,那就是因为引用计数的缺点。比如增减引用计数所导致的计算量比较多,在多线程的情况下要用到锁,就更是如此;再比如会导致内存碎片化、局部性差等。
而采用像停止-拷贝这样的算法在总的计算开销上会比引用计数的方法低。Java和Go语言主要是用于服务端程序开发的。尽量减少内存收集带来的性能损耗当然是语言的设计者重点考虑的问题。
再进一步看,采用像停止-拷贝这样的算法其实是用空间换时间以更大的内存消耗换来性能的提升。如果你的程序需要100M内存那么虚拟机需要给它准备200M内存因为有一半空间是空着的。这其实也是为什么Android手机比iPhone更加消耗内存的原因之一。
在为iPhone开发程序的时候无论是采用Objective C还是Swift都是采用引用计数的技术。并且程序员还负责采用弱引用等技术来避免循环引用从而进一步消除了在运行时进行循环引用检测的开销。
通过上面的分析我们能发现移动端应用和服务端应用有不同的特点因此也会导致采用不同的垃圾收集算法。那么方舟编译器采用引用计数的方法来编译原来的Android应用是否也是借鉴了iPhone的经验呢我没有去求证过所以不得而知。但我们可以根据自己的知识去做一些合理的猜测。
回过头来我们继续分析一下用Java和Go语言来写服务端程序对垃圾收集的需求。对于服务器端程序来说垃圾收集导致的停顿是一个令程序员们头痛的问题。有时候一次垃圾收集会让整个程序停顿一段非常可观的时间比如上百毫秒甚至达到秒级这对于实时性要求较高或并发量较大的系统来说就会引起很大的问题。也因此一些很关键的系统很长时间内无法采用Java和Go语言编写。
所以Java和Go语言一直在致力于减少由于垃圾收集而产生的停顿。最新的垃圾收集器已经使得垃圾收集导致的停顿降低到了几毫秒内。
在这里,你需要理解的要点,是**为什么在垃圾收集时,要停下整个程序?**又有什么办法可以减少停顿的时间?
### 为什么在垃圾收集时,要停下整个程序?
其实,对于引用计数算法来说,是不需要停下整个程序的,每个对象的内存在计数为零的时候就可以收回。
而**采用标记-清除算法时,你就必须要停下程序**首先做标记然后做清除。在做标记的时候你必须从所有的GC根出发去找到所有存活的对象剩下的才是垃圾。所以看上去这是一项完整的工作程序要一直停顿到这项完整的工作做完。
让事情更棘手的是,**你不仅要停下当前的线程扫描栈里的所有GC根你还要停下其他的线程**,因为其他线程栈里的对象,也可能引用了相同的对象。最后的结果,就是你停下了整个世界。
当然也有例外就是如果别的线程正在运行的代码没有可能改变对象之间的引用关系比如仅仅是在做一个耗费时间的数学计算那么是不用停下来的。你可以参考Julia的[gc程序中的一段注释](https://github.com/JuliaLang/julia/blob/v1.4.1/src/gc.c#L152),来理解什么样的代码必须停下来。
更麻烦的是,不仅仅在扫描阶段你需要停下整个世界,**如果垃圾收集算法需要做内存的整理或拷贝,那么这个时候仍然要停下程序**。而且程序必须停在一些叫做安全点SafePoint的地方。
在这些地方,修改对象的地址不会破坏程序数据的一致性。比如说,假设代码里有一段逻辑,是访问对象的某个成员变量,而这个成员变量的地址是根据对象的地址加上一个偏移量计算出来的。那么如果你修改了对象的地址,而这段代码仍然去访问原来的地址,那就出错了。而当代码停留在安全点上,就不会有这种不一致。
安全点是编译器插入到代码中一个片段。在查看[Graal生成的汇编代码](https://time.geekbang.org/column/article/255730)时,我们曾经看到过这样的指令片段。
好了,到目前为止,你了解了为什么要停下整个世界,以及要停在哪里才合适。那么我们继续研究,**如何能减少停顿时间**。
### 如何能减少停顿时间?
第一招分代收集可以减少垃圾收集的工作量不用每次都去扫描所有的对象因此也会减少停顿时间。像Java、Julia和V8的垃圾收集器都是分代的。
第二招可以尝试增量收集。你可能会问了怎样才能实现增量呀不是说必须扫描所有的GC根才能确认一个对象是垃圾吗
其实是有方法可以实现增量收集的比如三色标记Tri-color Marking法。这种方法的原理是用三种颜色来表示不同的内存对象的处理阶段
- 白色,表示算法还没有访问的对象。
- 灰色,表示这个节点已经被访问过,但子节点还没有被访问过。
- 黑色,表示这个节点已经被访问过,子节点也已经被访问过了。
我们用一个例子来了解一下这个算法的原理。这个例子中有8个对象。你可以看出其中三个对象是内存垃圾。在垃圾收集的时候一开始所有对象都是白色的。
<img src="https://static001.geekbang.org/resource/image/99/b0/990affb0e6d65610d768f4c6f471c5b0.jpg" alt="">
然后扫描所有GC根所引用的对象把这些对象加入到一个工作区并标记为灰色。在例子中我们把A和F放入了灰色区域。
<img src="https://static001.geekbang.org/resource/image/03/65/03472898d64854b6e55d013d81645365.jpg" alt="">
如果这个对象的所有子节点都被访问过之后就把它标记为黑色。在例子中A和F已经被标记为黑色而B、C、D被标记为灰色。
<img src="https://static001.geekbang.org/resource/image/31/6b/3127a489a54d01fb7ba1f9e9317abf6b.jpg" alt="">
继续上面的过程B、C、D也被标记为黑色。这个时候灰色区域已经没有对象了。那么剩下的白色对象E、G和H就能确定是垃圾了。
<img src="https://static001.geekbang.org/resource/image/2e/01/2ed571bdaa8bca89bbdcf4d7f1c4a501.jpg" alt="">
回收掉E、G和H以后就可以进入下一次循环。重新开始做增量收集。
<img src="https://static001.geekbang.org/resource/image/01/e6/0171377626c9d83bb9b55d6cd68b38e6.jpg" alt="">
从上面的原理还可以看出这个算法的特点:黑色对象永远不能指向白色对象,顶多指向灰色对象。我们只要始终保证这一条,就可以去做增量式的收集。
具体来说垃圾收集器可以做了一段标记工作后就让程序再运行一段。如果在程序运行期间一个黑色对象被修改了比如往一个黑色对象a里新存储了一个指针b那么把a涂成灰色或者把b涂成灰色就可以了。等所有的灰色节点变为黑色以后就可以做垃圾清理了。
总结起来,三色标记法中,黑色的节点是已经处理完毕的,灰色的节点是正在处理的。如果灰色节点都处理完,剩下的白色节点就是垃圾。而如果在两次处理的间隙,有黑色对象又被改了,那么要重新处理。
那在增量收集的过程中需要编译器做什么配合肯定是需要的编译器需要往生成的目标代码中插入读屏障Read Barrier和写屏障Write Barrier的代码。也就是在程序读写对象的时候要执行一些逻辑保证三色的正确性。
好了,你已经理解了增量标识的原理,知道了它可以减少程序的整体停顿时间。那么,能否再进一步减少停顿时间呢?
这就涉及到第三招:并发收集。我们再仔细看上面的增量式收集算法:既然垃圾收集程序和主程序可以交替执行,那么是否可以一边运行主程序,一边用另一个或多个线程来做垃圾收集呢?
这是可以的。实际上除了少量的时候需要停下整个程序比如一开头处理所有的GC根其他时候是可以并发的这样就进一步减少了总的停顿时间。
## 课程小结
今天这一讲,我带你了解运行时中的一个重要组成部分:垃圾收集器。采用什么样的垃圾收集算法,是实现一门语言时要着重考虑的点。
垃圾收集算法包含的内容有很多,我们这一讲并没有展开所有的内容,而是聚焦在介绍常用的几种算法(比如引用计数、分代收集、增量收集等)的原理,以及几种典型语言的编译器是如何跟选定的垃圾收集算法配合的。比如,在生成目标代码的时候,生成安全点、写屏障和读屏障的代码,修改引用数的代码,以及能够减少垃圾收集工作的一些优化工作。
我把今天的知识点做成了思维导图,供你参考:
<img src="https://static001.geekbang.org/resource/image/9f/b7/9fc46118d16472ab4bd5a718e32041b7.jpg" alt="">
## 一课一思
我们说垃圾收集是跟语言的设计有关的。那么,你是否可以想一下,怎样设计语言可以减少垃圾收集工作呢?欢迎分享你的观点。

View File

@@ -0,0 +1,349 @@
<audio id="audio" title="33 | 并发中的编译技术(一):如何从语言层面支持线程?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/94/1b/947c1b645fb4107aae1899e3e904981b.mp3"></audio>
你好,我是宫文学。
现代的编程语言,开始越来越多地采用并发计算的模式。这也对语言的设计和编译技术提出了要求,需要能够更方便地利用计算机的多核处理能力。
并发计算需求的增长跟两个趋势有关一是CPU在制程上的挑战越来越大逼近物理极限主频提升也越来越慢计算能力的提升主要靠核数的增加比如现在的手机核数越来越多动不动就8核、12核用于服务器的CPU核数则更多二是现代应用对并发处理的需求越来越高云计算、人工智能、大数据和5G都会吃掉大量的计算量。
因此在现代语言中友好的并发处理能力是一项重要特性也就需要编译技术进行相应的配合。现代计算机语言采用了多种并发技术包括线程、协程、Actor模式等。我会用三讲来带你了解它们从而理解编译技术要如何与这些并发计算模式相配合。
这一讲我们重点探讨线程模式它是现代计算机语言中支持并发的基础模式。它也是讨论协程和Actor等其他话题的基础。
不过在此之前,我们需要先了解一下并发计算的一点底层机制:并行与并发、进程和线程。
## 并发的底层机制:并行与并发、进程与线程
我们先来学习一下硬件层面对并行计算的支持。
假设你的计算机有两颗CPU每颗CPU有两个内核那么在同一时间至少可以有4个程序同时运行。
后来CPU厂商又发明了超线程Hyper Threading技术让一个内核可以同时执行两个线程增加对CPU内部功能单元的利用率这有点像我们之前讲过的[流水线技术](https://time.geekbang.org/column/article/249261)。这样一来在操作系统里就可以虚拟出8个内核或者叫做操作系统线程在同一时间可以有8个程序同时运行。这种真正的同时运行我们叫做**并行**parallelism
<img src="https://static001.geekbang.org/resource/image/48/77/487d4289970590947915689aeyy5a377.jpg" alt="">
可是仅仅8路并行也不够用呀。如果你去查看一下自己电脑里的进程数会发现运行着几十个进程而线程数就更多了。
所以,操作系统会用分时技术,让一个程序执行一段时间,停下来,再让另一个程序运行。由于时间片切得很短,对于每一个程序来说,感觉上似乎一直在运行。这种“同时”能处理多个任务,但实际上并不一定是真正同时执行的,就叫做**并发**Concurrency
实际上哪怕我们的计算机只有一个内核我们也可以实现多个任务的并发执行。这通常是由操作系统的一个调度程序Scheduler来实现的。但是有一点操作系统在调度多个任务的时候是有一定开销的
- 一开始是以进程为单位来做调度,开销比较大。
- 在切换**进程**的时候,要保存当前进程的上下文,加载下一个进程的上下文,也会有一定的开销。由于进程是一个比较大的单位,其上下文的信息也比较多,包括用户级上下文(程序代码、静态数据、用户堆栈等)、寄存器上下文(各种寄存器的值)和系统级上下文(操作系统中与该进程有关的信息,包括进程控制块、内存管理信息、内核栈等)。
相比于进程,**线程技术就要轻量级一些**。在一个进程内部可以有多个线程每个线程都共享进程的资源包括内存资源代码、静态数据、堆、操作系统资源如文件描述符、网络连接等和安全属性用户ID等但拥有自己的栈和寄存器资源。这样一来线程的上下文包含的信息比较少所以切换起来开销就比较小可以把宝贵的CPU时间用于执行用户的任务。
总结起来,线程是操作系统做并发调度的基本单位,并且可以跟同一个进程内的其他线程共享内存等资源。操作系统会让一个线程运行一段时间,然后把它停下来,把它所使用的寄存器保存起来,接着让另一个线程运行,这就是线程调度原理。你要在大脑里记下这个场景,这样对理解后面所探讨的所有并发技术都很有帮助。
<img src="https://static001.geekbang.org/resource/image/13/2c/13597dc8c5ea3124a1b85b040072242c.jpg" alt="">
我们通常**把进程作为资源分配的基本单元,而把线程作为并发执行的基本单元**。不过有的时候用进程作为并发的单元也是比较好的比如谷歌浏览器每打开一个Tab页就新启动一个进程。这是因为浏览器中多个进程之间不需要有互动。并且由于各个进程所使用的资源是独立的所以一个进程崩溃也不会影响到另一个。
而如果采用线程模型的话,由于它比较轻量级,消耗的资源比较少,所以你可以在一个操作系统上启动几千个线程,这样就能执行更多的并发任务。所以,在一般的网络编程模型中,我们可以针对每个网络连接,都启动一条线程来处理该网络连接上的请求。在第二个模块中我们分析过的[MySQL](https://time.geekbang.org/column/article/267917)就是这样做的。你每次跟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 &lt; 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 &lt; 1000; i++)
num.add();
System.out.println(&quot;num.num:&quot; + num.value);
}
}
//给数字加1
class Num {
public int value = 0;
public void add() {
value += 1;
}
}
```
在这个例子中每个线程对Num中的value加1000次。按说总有一个线程最后结束这个时候打印出来的次数是3000次。可实际运行的时候却发现很难对上这个数字通常都要小几百。下面是几次运行的结果
<img src="https://static001.geekbang.org/resource/image/7f/71/7f3c7229a0c54e49b02edd52c9928271.jpg" alt="">
要找到其中的原因最直接的方法是从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个步骤
1. 从内存加载value的值到栈
1. 把1加载到栈
1. 从栈里弹出value的值和1并做加法
1. 把新的value的值存回到内存里。
这是一个线程执行的过程。如果是两个以上的线程呢你就会发现有问题了。线程1刚执行getfield取回value的值线程2也做了同样的操作那么它们取到的值是同一个。做完加法以后写回内存的时候写的也是同一个值都是3。
<img src="https://static001.geekbang.org/resource/image/ab/a9/abe403a774990ab20b2a0643ce34b8a9.jpg" alt="">
这样一分析你就能理解数字计算错误的原因了。总结起来出现这种现象是因为对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的运算。
<img src="https://static001.geekbang.org/resource/image/57/00/57b2472d34292fddb13b4e5a6418ac00.jpg" alt="">
那synchronized关键字作用的原理是什么呢要回答这个问题我们还是要研究一下add()方法的字节码。
<img src="https://static001.geekbang.org/resource/image/46/ec/464918cdb6c8dcc6bd3a69yy19a437ec.jpg" alt="">
在字节码中,你会发现**两个特殊的指令monitorenter和monitorexit指令**,就是它们实现了并发控制。
查看字节码的描述我们可以发现monitorenter的作用是试图获取某个对象引用的监视器monitor的所有权。什么是监视器呢在其他文献中你可能会读到“锁”的概念。监视器和锁其实是一个意思。这个锁是关联到一个Num对象的也就是代码中的this变量。只有获取了这把锁的程序才能执行块中的代码也就是“value += 1”。
具体来说当程序执行到monitorenter的时候会产生下面的情况
- 如果监视器的进入计数是0线程就会进入监视器并将进入计数修改为1。这个时候该线程就拥有了该监视器。
- 如果该线程已经拥有了该监视器那么就重新进入并将进入计数加1。
- 如果其他线程拥有该监视器那么该线程就会被阻塞block直到监视器的进入计数变为0然后再重新试图获取拥有权。
monitorexit指令的机制则比较简单就是把进入计数减1。如果一段程序被当前线程进入了多次那么也会退出同样的次数直到进入计数为0。
总结起来,**我们用了锁的机制,保证被保护的代码块在同一时刻只能被一个线程访问,从而保证了相关操作的原子性**。
到这里了你可能会继续追问如何保证获取锁的操作是原子性的如果某线程看到监视器的进入计数是0这个时候它就进去但在它修改进入计数之前如果另一个线程也进去了怎么办也修改成1怎么办这样两个线程会不会都认为自己获得了锁
这个担心是非常有必要的。实际上,要实现原子操作,仅仅从软件角度做工作是不行的,还必须要有底层硬件的支持。具体是如何支持的呢?我们还是采用一贯的方法,直接看汇编代码。
你可以用[第13讲](https://time.geekbang.org/column/article/255730)学过的方法获取Num.add()方法对应的汇编代码,看看在汇编层面,监视器是如何实现的。我截取了一段汇编代码,并标注了其中的一些关键步骤,你可以看看。
<img src="https://static001.geekbang.org/resource/image/15/16/15fe669b54091fb3e41763b7e02a7116.jpg" alt="">
汇编代码首先会跳到一段代码去获取监视器。如果获取成功那么就跳转回来执行后面对value做加法的运算。
我们再继续看一下获取监视器的汇编代码:
<img src="https://static001.geekbang.org/resource/image/9f/21/9f19dae9997e1e32fe69a77b59e7cb21.jpg" alt="">
你特别需要注意的是**cmpxchg指令**:它能够通过一条指令,完成比较和交换的操作。查看[Intel的手册](https://software.intel.com/en-us/download/intel-64-and-ia-32-architectures-sdm-combined-volumes-1-2a-2b-2c-2d-3a-3b-3c-3d-and-4)你会发现更详细的解释把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有的时候就会读不到最新的值。为什么呢
<img src="https://static001.geekbang.org/resource/image/94/e4/94fe9bac8e754f33ac46e49270d737e4.jpg" alt="">
因为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 &lt; 10){
if (localValue != num.value){ //发现num.value变了
System.out.println(&quot;Value changed to: &quot; + 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 &lt; 10){
localValue ++;
System.out.println(&quot;Change value to: &quot; + 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的变化
<img src="https://static001.geekbang.org/resource/image/86/5d/861b223beac9b5a837d0ab0dfd91225d.jpg" alt="">
而如果加了volatile关键字那么每次value的变化都会马上被线程1检测到
<img src="https://static001.geekbang.org/resource/image/f9/cb/f989db5c941d67feaea299d0e56779cb.jpg" alt="">
通过这样一个简单的例子你能更加直观地理解为什么可见性是一个重要的问题并且能够看到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指令针对普通变量
}
}
```
关于另几种内存屏障的说明,以及在什么时候需要插入内存屏障指令,你可以看下[这篇文章](http://gee.cs.oswego.edu/dl/jmm/cookbook.html)。
另外不同的CPU对于这四类屏障所对应的指令是不同的。下图也是从上面那篇文章里摘出来的
<img src="https://static001.geekbang.org/resource/image/7d/fd/7db6a74c31023a1ab2aa3316637957fd.jpg" alt="">
可以看到对于x86芯片其中的LoadStore、LoadLoad和StoreStore屏障都用一个no-op指令等待前一个指令执行完毕即可。这就能确保读到正确的值。唯独对于StoreLoad的情况也就是我们TestVolatile示例程序中一个线程写、另一个线程读的情况需要用到几个特殊的指令之一比如mfence指令、cpuid指令或者在一个指令前面加锁lock前缀
总结起来,其实**synchronized关键字也好volatile关键字也好都是用来保证线程之间的同步的。只不过synchronized能够保证操作的原子性但付出的性能代价更高而volatile则只同步数据的可见性付出的性能代价会低一点。**
在Java语言规范中在多线程情况下与共享变量访问有关的内容被叫做Java的内存模型并单独占了[一节](https://docs.oracle.com/javase/specs/jls/se14/html/jls-17.html#jls-17.4)。这里面规定了在对内存(包括类的字段、数组中的元素)做操作的时候,哪些顺序是必须得到保证的,否则程序就会出错。
这些规定跟编译器的实现有比较大的关系。编译器在做优化的时候会对指令做重排序。在重排序的时候一定要遵守Java内存模型中对执行顺序的规定否则运行结果就会出错。
## 课程小结
今天这一讲我主要以Java语言为例讲解了多线程的原理以及相关的程序语义。多个线程如果要访问共享的数据通常需要进行同步。synchronized关键字能通过锁的机制保证操作的原子性。而volatile关键字则能通过内存屏障机制在不同的处理器之间同步共享变量的值。
你会发现,在写编译器的时候,只有正确地理解了这些语义和原理,才能生成正确的目标代码,所以这一讲的内容你必须要理解。学会今天这讲,还有一个作用,就是能够帮助你加深对多线程编程的底层机制的理解,更好地编写这方面的程序。
其他语言在实现多线程机制时,所使用的语法可能不同,但底层机制都是相同的。通过今天的讲解,你可以举一反三。
我把今天这讲思维导图也整理出来了,供你参考:
<img src="https://static001.geekbang.org/resource/image/fa/45/fac71e0cfb6dd720b8a26f08c85f1e45.jpg" alt="">
## 一课一思
在你之前的项目经验中,有没有遇到并发处理不当而导致的问题?你是怎么解决的呢?欢迎分享你的经验。
## 参考资料
1. [The JSR-133 Cookbook for Compiler Writers](http://gee.cs.oswego.edu/dl/jmm/cookbook.html)介绍了编译器如何为Java语言实现内存屏障。
1. Java语言规范中对[内存模型的相关规定](https://docs.oracle.com/javase/specs/jls/se14/html/jls-17.html#jls-17.4)。

View File

@@ -0,0 +1,288 @@
<audio id="audio" title="34 | 并发中的编译技术(二):如何从语言层面支持协程?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0c/cc/0c34dcd5fc349da62ffdc8a234f199cc.mp3"></audio>
你好,我是宫文学。
上一讲我们提到了线程模式是当前计算机语言支持并发的主要方式。
不过,在有些情况下,线程模式并不能满足要求。当需要运行大量并发任务的时候,线程消耗的内存、线程上下文切换的开销都太大。这就限制了程序所能支持的并发任务的数量。
在这个背景下一个很“古老”的技术重新焕发了青春这就是协程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
```
可以看到,使用协程跟我们平常使用函数几乎没啥差别,对编程人员很友好。实际上,它可以认为是跟函数并列的一种子程序形式。和函数的区别是,函数调用时,调用者跟被调用者之间像是一种上下级的关系;而在协程中,调用者跟被调用者更像是互相协作的关系,比如一个是生产者,一个是消费者。这也是“协程”这个名字直观反映出来的含义。
我们用一张图来对比下函数和协程中的调用关系。
<img src="https://static001.geekbang.org/resource/image/5d/4d/5daca741e22f31520ff0d39b5acc8f4d.jpg" alt="">
细想一下,编程的时候,这种需要子程序之间互相协作的场景有很多,我们一起看两种比较常见的场景。
**第一种比较典型的场景,就是生产者和消费者模式**。如果你用过Unix管道或者消息队列编程的话会非常熟悉这种模式。但那是在多个进程之间的协作。如果用协程的话在一个进程内部就能实现这种协作非常轻量级。
就拿编译器前端来说词法分析器Tokenizer和语法分析器Parser就可以是这样的协作关系。也就是说为了更好的性能没有必要一次把词法分析完毕而是语法分析器消费一个就让词法分析器生产一个。因此这个过程就没有必要做成两个线程了否则就太重量级了。这种场景我们可以叫做生成器Generator场景主程序调用生成器给自己提供数据。
**特别适合使用协程的第二种场景是IO密集型的应用**。比如做一个网络爬虫同时执行很多下载任务,或者做一个服务器同时响应很多客户端的请求,这样的任务大部分时间是在等待网络传输。
如果用同步阻塞的方式来做一个下载任务在等待的时候就会把整个线程阻塞掉。而用异步的方式协程在发起请求之后就把控制权交出调度程序接收到数据之后再重新激活协程这样就能高效地完成IO操作同时看上去又是用同步的方式编程不需要像异步编程那样写一大堆难以阅读的回调逻辑。
这样的场景在微服务架构的应用中很常见,我们来简化一个实际应用场景,分析下如何使用协程。
在下面的示例中应用A从客户端接收大量的并发请求而应用A需要访问应用B的服务接口从中获得一些信息然后返回给客户端。
<img src="https://static001.geekbang.org/resource/image/a3/12/a3a7744f2f11c7ec57f4250d927a4112.jpg" alt="">
要满足这样的场景,我们最容易想到的就是,**编写同步通讯的程序**,其实就是同步调用。
假设应用A对于每一个客户端的请求都会起一个线程做处理。而你呢则在这个线程里发起一个针对应用B的请求。在等待网络返回结果的时候当前线程会被阻塞住。
<img src="https://static001.geekbang.org/resource/image/f0/25/f04b7058db480194fa80edd6849cyy25.jpg" alt="">
这个架构是最简单的你如果采用Java的Servlet容器来编写程序的话很可能会采用这个结构。但它有一些缺陷
- 对于每个客户端请求都要起一个线程。如果请求应用B的时延比较长那么在应用A里会积压成千上万的线程从而浪费大量的服务器资源。而且当线程超过一定数量应用服务器就会拒绝后续的请求。
- 大量的请求毫无节制地涌向应用B使得应用B难以承受负载从而导致响应变慢甚至宕机。
因为同步调用的这种缺点,近年来**异步编程模型**得到了更多的应用典型的就是Node.js。在异步编程模型中网络通讯等IO操作不必阻塞线程而是通过回调来让主程序继续执行后续的逻辑。
<img src="https://static001.geekbang.org/resource/image/d6/83/d69d2d5e6c220286716c9bc073523d83.jpg" alt="">
上图中我们只用到了4个线程对应操作系统的4个真线程可以减少线程切换的开销。在每个线程里维护一个任务队列。首先getDataFromApp2()会被放到任务队列当数据返回以后系统的调度器会把sendBack()函数放进任务队列。
这个例子比较简单只有一层回调你还能读懂它的逻辑。但是采用这种异步编程模式经常会导致多层回调让代码很难阅读。这种现象被叫做“回调地狱Callback Hell”。
这时候,就显示出协程的优势了。**协程可以让你用自己熟悉的命令式编程的风格,来编写异步的程序**。比如,对于上面的示例程序,用协程可以这样写,看上去跟编写同步调用的代码没啥区别。
```
requestHandler(){
...;
await getDataFromApp2();
...;
sendBack();
}
```
当然我要强调一下在协程用于同步和异步编程的时候其调度机制是不同的。跟异步编程配合的时候要把异步IO机制与协程调度机制关联起来。
好了,现在你已经了解了协程的特点和适用场景。那么问题来了,如何让一门语言支持协程呢?要回答这个问题,我们就要先学习一下协程的运行原理。
## 协程的运行原理
当我们使用函数的时候简单地保持一个调用栈就行了。当fun1调用fun2的时候就往栈里增加一个新的栈帧用于保存fun2的本地变量、参数等信息这个函数执行完毕的时候fun2的栈帧会被弹出恢复栈顶指针sp并跳转到返回地址调用fun2的下一条指令继续执行调用者fun1的代码。
<img src="https://static001.geekbang.org/resource/image/c1/ea/c1a859d94c48bbb245e3e3a51ee9f3ea.jpg" alt="">
但如果调用的是协程coroutine1该怎么处理协程的栈帧呢因为协程并没有执行完显然还不能把它简单地丢掉。
这种情况下,程序可以从堆里申请一块内存,保存协程的活动记录,包括本地变量的值、程序计数器的值(当前执行位置)等等。这样,当下次再激活这个协程的时候,可以在栈帧和寄存器中恢复这些信息。
<img src="https://static001.geekbang.org/resource/image/0d/7e/0d6986ebfe379694759b84b57eb2877e.jpg" alt="">
把活动记录保存到堆里,是不是有些眼熟?其实,这有点像闭包的运行机制。
程序在使用闭包的时候也需要在堆里保存闭包中的自由变量的信息并且在下一次调用的时候从堆里恢复。只不过闭包不需要保存本地变量只保存自由变量就行了也不需要保存程序计数器的值因为再一次调用闭包函数的时候还是从头执行而协程则是接着执行yield之后的语句。
fun1通过resume语句让协程继续运行。这个时候协程会去调用一个普通函数fun2而fun2的栈帧也会加到栈上。
<img src="https://static001.geekbang.org/resource/image/f5/de/f52a8758cbe86006a7400b29aa9bdede.jpg" alt="">
如果fun2执行完毕那么就会返回到协程。而协程也会接着执行下一个语句这个语句是一个专门针对协程的返回语句我们叫它co_return吧以便区别于传统的return。在执行了co_return以后协程就结束了无法再resume。这样的话保存在堆里的活动记录也就可以销毁了。
<img src="https://static001.geekbang.org/resource/image/df/a1/dfd44eac96784a283eac49750c6ebaa1.jpg" alt="">
通过上面的例子,你应该已经了解了协程的运行原理。那么我们学习编译原理会关心的问题是:**实现协程的调度,包括协程信息的保存与恢复、指令的跳转,需要编译器的帮忙吗?还是用一个库就可以实现?**
实际上对于C和C++这样的语言来说确实用一个库就可以实现。因为C和C++比较灵活比如可以用setjmp、longjmp等函数跨越函数的边界做指令的跳转。但如果用库实现通常要由程序管理哪些状态信息需要被保存下来。为此你可能要专门设计一个类型来参与实现协程状态信息的维护。
而如果用编译器帮忙那么就可以自动确定需要保存的协程的状态信息并确定需要申请的内存大小。一个协程和函数的区别就仅仅在于是否使用了yield和co_return语句而已减轻了程序员编程的负担。
好了刚才我们讨论了在实现协程的时候要能够正确保存协程的活动记录。在具体实现上有Stackful和Stackless两种机制。采用不同的机制对于协程能支持的特性也很有关系。所以接下来我带你再进一步地分析一下Stackful和Stackless这两种机制。
## Stackful和Stackless的协程
到目前为止,看上去协程跟普通函数(子程序)的差别也不大嘛,你看:
- 都是由一个主程序调用,运行一段时间以后再把控制流交回给主程序;
- 都使用栈来管理本地变量和参数等信息,只不过协程在没有完全运行完毕时,要用堆来保存活动记录;
- 在协程里也可以调用其他的函数。
可是在有的情况下我们没有办法直接在coroutine1里确定是否要暂停线程的执行可能需要在下一级的子程序中来确定。比如说coroutine1函数变得太大我们重构后把它的功能分配到了几个子程序中。那么暂停协程的功能也会被分配到子程序中。
<img src="https://static001.geekbang.org/resource/image/93/dd/9317554e9243f9ef4708858fe22322dd.jpg" alt="">
这个时候在helper()中暂停协程会让控制流回到fun1函数。而当在fun1中调用resume的时候控制流应该回到helper()函数中yield语句的下一条继续执行。coroutine1()和helper()加在一起起到了跟原来只有一个coroutine1()一样的效果。
这个时候在栈里不仅要加载helper()的活动记录还要加载它的上一级也就是coroutine1()的活动记录这样才能维护正确的调用顺序。当helper()执行完毕的时候控制流会回到coroutine1(),继续执行里面的逻辑。
在这个场景下,不仅要从堆里恢复多个活动记录,还要维护它们之间的正确顺序。上面的示例中,还只有两级调用。如果存在多级的调用,那就更麻烦了。
那么,怎么解决这个技术问题呢?你会发现,其实协程的逐级调用过程,形成了自己的调用栈,这个调用栈需要作为一个整体来使用,不能拆成一个个单独的活动记录。
既然如此,那我们就加入一个辅助的运行栈好了。这个栈通常被叫做**Side Stack**。每个协程,都有一个自己专享的协程栈。
<img src="https://static001.geekbang.org/resource/image/2b/c0/2bf6a16cf5d7e85ee1db921efe0d0bc0.jpg" alt="">
好了,现在是时候给你介绍两个术语了:这种需要一个辅助的栈来运行协程的机制,叫做**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还跟异步通讯机制紧密整合实现了**用同步的编程风格来实现异步的功能**,使得微信后台的处理能力大大提升。微信后台用协程做升级的案例,你可以看看[这篇文章](https://www.infoq.cn/article/CplusStyleCorourtine-At-Wechat/)。
接下来我们说说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从ES6ECMAScript 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](https://blog.cloudflare.com/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结合是一个趋势。
## 课程小结
今天这一讲,我们学习了协程的定义、使用场景、实现原理和不同语言的具体实现机制。我们特别从编译技术的角度,关注了协程对栈的使用机制,看看它与传统的程序有什么不同。
在这个过程中,一方面,你会通过今天的课程对协程产生深入的认识;另一方面,你会更加深刻地认识到编译技术是如何跟语言特性的设计和运行时紧密配合的。
协程可以用库实现,也可以借助编译技术成为一门语言的原生特性。采用编译技术,能帮助我们自动计算活动记录的大小,实现自己独特的栈管理机制,实现抢占式调度等功能。
本讲的思维导图我也放在了下面,供你参考:
<img src="https://static001.geekbang.org/resource/image/89/38/89f40bc89yyf16f0d855d43e85d9c838.jpg" alt="">
## 一课一思
上一讲我们讨论的是线程模式。我们知道当并发访问量非常大的时候线程模式消耗的资源会太多。那么你会如何解决这个问题是否会采用协程如果你使用的是Java语言其原生并不支持协程你会怎么办欢迎发表你的观点。
## 参考资料
1. [How Stacks are Handled in Go](https://blog.cloudflare.com/how-stacks-are-handled-in-go/)这篇文章介绍了Goroutine使用栈的机制你可以看看它是如何很节约地使用内存的。
1. [Coroutines in Java](http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.1041.3083&amp;rep=rep1&amp;type=pdf)这篇文章探讨了在Java中实现协程的各种技术考虑。

View File

@@ -0,0 +1,195 @@
<audio id="audio" title="35 | 并发中的编译技术Erlang语言厉害在哪里" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c0/19/c0a5ced3a0beaef1170aa16cc9826f19.mp3"></audio>
你好,我是宫文学。
在前面两讲,我们讨论了各门语言支持的并发计算的模型。线程比进程更加轻量级,上下文切换成本更低;协程则比线程更加轻量级,在一台计算机中可以轻易启动几十万、上百万个并发任务。
但不论是线程模型、还是协程模型,当涉及到多个线程访问共享数据的时候,都会出现竞争问题,从而需要用到锁。锁会让其他需要访问该数据的线程等待,从而导致系统整体处理能力的降低。
并且编程人员还要特别注意避免出现死锁。比如线程A持有了锁x并且想要获得锁y而线程B持有了锁y想要获得锁x结果这两个线程就会互相等待谁也进行不下去。像数据库这样的系统检测和消除死锁是一项重要的功能以防止互相等待的线程越来越多对数据库操作不响应并最终崩溃掉。
既然使用锁这么麻烦那在并发计算中能否不使用锁呢这就出现了Actor模型。那么**什么是Actor模型为什么它可以不用锁就实现并发这个并发模型有什么特点需要编译技术做什么配合**
今天这一讲我们就从这几个问题出发一起学习并理解Actor模型。借此我们也可以把用编译技术支持不同的并发模型的机制理解得更深刻。
首先我们看一下什么是Actor模型。
## 什么是Actor模型
在线程和协程模型中,之所以用到锁,是因为两个线程共享了内存,而它们会去修改同一个变量的值。那,如果避免共享内存,是不是就可以消除这个问题了呢?
没错这就是Actor模型的特点。Actor模型是1973年由Carl Hewitt提出的。在Actor模型中并发的程序之间是不共享内存的。它们通过互相发消息来实现协作很多个一起协作的Actor就构成了一个支持并发计算的系统。
我们看一个有三个Actor的例子。
<img src="https://static001.geekbang.org/resource/image/d1/fb/d1494936866024b3c9a687da3a9de1fb.jpg" alt="">
你会注意到每个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 CActor B和C依次处理这些请求如果Actor B或C接收到的两个信息都想要某个座位那么针对第二个请求会返回订票失败的消息。
<img src="https://static001.geekbang.org/resource/image/31/2d/31838a322d9bddd811a53b44e63ac82d.jpg" alt="">
你发现没有在这个场景中Actor B和C仍然是顺序处理各个请求。但因为是两个Actor并发地处理请求所以系统整体的性能会提升到原来的两倍。
甚至你可以让每排座位、每个座位都由一个Actor负责使得系统的性能更高。因为在系统中创建一个Actor的成本是很低的。Actor跟协程类似很轻量级一台服务器里创建几十万、上百万个Actor也没有问题。如果每个Actor负责一个座位那一台服务器也能负责几十万、上百万个座位的销售也是可以接受的。
当然,实际的场景要比这个复杂,比如一次购买多张相邻的票等,但原理是一样的。用这种架构,可以大大提高并发能力,处理海量订票、秒杀等场景不在话下。
其实我个人比较喜欢Actor这种模式因为它跟现实世界里的分工协作很相似。比如餐厅里不同岗位的员工他们通过互相发信息来实现协作从而并发地服务很多就餐的顾客。
分析到这里我再把Actor模式跟你非常熟悉的一个概念面向对象编程Object Oriented ProgrammingOOP关联起来。你可能会问Actor和面向对象怎么还有关联
是的。面向对象语言之父阿伦 · 凯伊Alan KaySmalltalk的发明人在谈到面向对象时是这样说的对象应该像生物的细胞或者是网络上的计算机它们只能通过消息互相通讯。对我来说OOP仅仅意味着消息传递、本地保留和保护以及隐藏状态过程并且尽量推迟万物之间的绑定关系。
>
<p>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)<br>
<br>
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.</p>
总结起来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那就可以对它做调度从而提供了软实时的能力具体可以参考[这篇文章](https://blog.stenmans.org/theBeamBook/#_scheduling_non_preemptive_reduction_counting))。
在比较新的版本中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都是用事件驱动的方式来实现的。系统收到了一块数据就调用应用来处理整个过程都是非阻塞的。
说完了并发调度机制,我们再来看看运行时的另一个重要特征,内存管理机制。
### 内存管理机制
内存管理机制要考虑栈、堆都怎么设计,以及垃圾收集机制等内容。
<img src="https://static001.geekbang.org/resource/image/a3/fe/a36035d18b24bcebb8a4d0a0b191a3fe.jpg" alt="">
首先说栈。每个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模型的时候你要在运行时里实现独特的调度机制和内存管理机制这些也需要编译器的支持。
本讲的思维导图我也放在了下面,供你参考:
<img src="https://static001.geekbang.org/resource/image/c0/5d/c04c32c93280afbea3fdc112285a085d.jpg" alt="">
好了,今天这一讲加上[第33](https://time.geekbang.org/column/article/279019)和[34讲](https://time.geekbang.org/column/article/280269),我们用了三讲,介绍了不同计算机语言是如何实现并发机制的。不难看出,并发机制确实是计算机语言设计中的一个重点。不同的并发机制,会非常深刻地影响计算机语言的运行时的实现,以及所采用的编译技术。
## 一课一思
你是否也曾经采用过消息传递的机制,来实现多个系统或者模块之间的调度?你从中获得了什么经验呢?欢迎你和我分享。
## 参考资料
1. Carl Hewitt关于Actor的[论文](https://arxiv.org/vc/arxiv/papers/1008/1008.1459v8.pdf)
1. 微软[Orleans项目介绍](https://www.microsoft.com/en-us/research/wp-content/uploads/2010/11/pldi-11-submission-public.pdf)
1. 介绍Erlang虚拟机原理的[在线电子书](https://blog.stenmans.org/theBeamBook)
1. 介绍Erlang字节码的[文章](http://beam-wisdoms.clau.se/en/latest/indepth-beam-instructions.html)

View File

@@ -0,0 +1,233 @@
<audio id="audio" title="36 | 高级特性(一):揭秘元编程的实现机制" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/05/89/0506b2cbd06d4987e6a3de8d3331dd89.mp3"></audio>
你好,我是宫文学。
作为一名技术人员,我想你肯定知道什么是编程,那你有没有听说过“**元编程Meta-Programming**”这个概念呢?
元编程是计算机语言提供的一项重要能力。这么说吧如果你要编写一些比较厉害的程序像是Java世界里的Spring、Hibernate这样的库以及C++的STL库等这样级别的程序也就是那些通用性很强、功能强大的库元编程功能通常会给予你巨大的帮助。
我还可以从另一个角度来评价元编程功能。那就是善用计算机语言的元编程功能,某种意义上能让你修改这门语言,让它更满足你的个性化需求,为你量身打造!
是不是觉得元编程还挺有意思的?今天这一讲,我就带你来理解元编程的原理,并一起探讨如何用编译技术来支持元编程功能的实现。
首先,我们需要透彻地了解一下什么是元编程。
## 什么是元编程Meta-Programming
元编程是一种把程序当做数据来处理的技术。因此,采用元编程技术,你可以把一个程序变换成另一个程序。
<img src="https://static001.geekbang.org/resource/image/b7/80/b760b926a4f4b5ab81ed55d1b5039c80.jpg" alt="">
那你可能要问了,既然把程序作为处理对象的技术就是元编程技术,那么编译器不就是把程序作为处理对象的吗?经过处理,编译器会把源代码转换成目标代码。类似的还有对源代码的静态分析工具、代码生成工具等,都算是采用了元编程技术。
不过,我们在计算机语言里说的元编程技术,通常是指用这门语言本身提供的功能,就能处理它自己的程序。
比如说在C语言中你可以用**宏功能**。经过C语言的预处理器处理以后那些宏就被转换成了另外的代码。下面的MUL宏用起来像一个函数但其实它只是做了一些字符串的替换工作。它可以说是最原始的元编程功能了。你在阅读像Python和Julia的编译器时就会发现有不少地方采用了宏的功能能让代码更简洁、可读性更好。
```
#define MUL(a,b) (a*b)
MUL(2,3) //预处理后变成(2*3)
```
再拿Java语言举个例子。Java语言对元编程提供了多重支持其中之一是**注解功能**。我们在解析[Java编译器](https://time.geekbang.org/column/article/252828)的时候已经发现Java编译器会把所编译的程序表示成一个对象模型。而注解程序可以通过这个对象模型访问被注解的程序并进行一些处理比如生成新的程序。所以这也是把程序作为数据来处理。
除了注解以外Java还提供了**反射机制**。通过反射机制Java程序可以在运行时获取某个类有哪些方法、哪些属性等信息并可以动态地运行该程序。你看这同样是把程序作为数据来处理。
像Python和JavaScript这样的脚本语言其元编程能力就更强了。比如说你用程序可以很容易地查询出某个对象都有哪些属性和方法甚至可以给它们添加新的属性和方法。换句话说你可以很容易地把程序作为数据进行各种变换从而轻松地实现一些灵活的功能。这种灵活性是很多程序员特别喜欢Python和JavaScript这样的语言的原因。
<img src="https://static001.geekbang.org/resource/image/b3/74/b3ce49cc8d2f641f4d71c139673f9474.jpg" alt="">
好了,到现在为止,你已经了解了**元编程的基本特征:把程序当做数据去处理**。接下来,我再带你更深入地了解一下元编程,并把不同的元编程技术做做分类。
### 理解Meta的含义、层次以及作用
**首先我们来注意一下Meta这个词缀的意思。**[维基百科](https://en.wikipedia.org/wiki/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标准中有一个MOFMeta Object Facility的规范可以用来描述关系数据库、数据仓库等元模型。它用类、关联、数据类型和包这些基本要素来描述一个元模型。
通过关系数据库这个例子现在你应该理解了不同的Meta层次是什么概念。那我们再**把这个概念应用到计算机语言领域,也是一样的**。
假设你使用一门面向对象的语言写了一个程序。这个程序运行时在内存里创建了一个Person对象。那这个对象属于M0层。
而为了创建这个Person对象你需要用程序设计一个Person类。从这个意义上来看我们平常写的程序属于M1层也就是相当于建立了一个模型来描述现实世界。你编写的订票程序就是对真实世界中的购票行为建立了一个模型而你编写的游戏当然也是建立了一个逼真的游戏模型。
那么你要如何才能设计一个Person类以及一个完整的程序呢这就需要用到计算机语言。计算机语言对应着M2层。它提供了类、成员变量、方法、数据类型、本地变量等元素用于设计你的程序。我们对一门计算机语言的词法规则、语法规则和语义规则等方面的描述就属于M2层也就是一门计算机语言的元模型。而编译器就是工作在M2层的程序它会根据元模型也就是词法规则、语法规则等来做程序的翻译工作。
我们在描述词法规则、语法规则的时候曾经用到产生式、EBNF这些工具。这些工具是属于M3层的。你可以用我们前面说过的一个词也就是Metalanguage来称呼这一层次。
这里我用了一个表格来给你展示下关系数据模型与Java程序中不同的Meta层次。
<img src="https://static001.geekbang.org/resource/image/f5/13/f516f4289cd426af42025668bb92f313.jpg" alt="">
### 元编程技术的分类
理解了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的编译器](https://time.geekbang.org/column/article/252828)的时候,我们已经解析了它是如何处理注解的,注解就是一种元编程技术。在我们举的例子中,注解是在编译期就被处理掉了。
```
@Retention(RetentionPolicy.SOURCE) //注解用于编译期处理
@Target(ElementType.TYPE) //注解是针对类型的
public @interface HelloWorld {
}
```
当时我们写了一个简单的注解处理程序这个程序能够获取被注解的代码的元数据M1层的信息比如类名称、方法名称等。这些元数据是由编译器提供的。然后注解处理程序会基于这些元数据生成一个新的Java源代码紧接着该源代码就会被编译器发现并编译掉。
通过这个分析,你会发现注解处理过程自始至终都借助了编译器提供的能力:先是通过编译器查询被注解的程序的元数据,然后生成的新程序也会被编译器编译掉。所以你能得出一个结论:**所谓元编程,某种意义上就是由程序来调用编译器提供的能力。**
刚刚我们探究的是在编译期使用元编程技术。那么在运行期Java提供了反射机制来动态地获取程序的元数据并操纵程序的执行。
举个例子。假设你写了一个简单的ORMObject-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编译器](https://time.geekbang.org/column/article/261063)的时候曾提到过metaclass元类。metaclass能够替代缺省的Type对象控制一个类创建对象的过程。通过你自己的metaclass你可以很容易地为所创建的对象的方法添加修饰比如输出调试信息这样的AOP功能。
所以很多喜欢Python、JavaScript和Ruby语言的工程师很大一部分原因都是因为这些语言非常容易实现元编程因此能够实现出很多强大的库。
不过在灵活的背后脚本语言的元编程技术通常要付出性能的代价。比如采用元编程技术程序经常会用Decorator模式对原有的函数或方法做修饰这样会增加函数调用的层次以及其他一些额外的开销从而降低程序的性能。
接下来我们说说Julia等类Lisp语言的元编程技术。
### Julia等类Lisp语言的元编程技术
前面我们已经说过像Julia等类似Lisp的语言它本来就是把程序看做数据的。它的程序结构本来就是一个嵌套的树状结构其实跟AST没啥区别。因此只要在语言里提供一种方式能够生成这些树状结构的数据就可以很好地实现元编程功能了。
比如下面的一段示例程序是用Common Lisp编写的。你能看出程序的结构完全是一层层的括号嵌套的结构每个括号中的第一个单词都是一个函数名称后面跟着的是函数参数。这个例子采用了Lisp的宏功能把pred替换成合适的函数名称。当替换成&gt;时,实现的是求最大值功能;而替换成&lt;时,实现的是求最小值功能。
```
(defmacro maxmin(list pred) ;定义一个宏
`(let ((rtn (first ,list))) ;`后面是作为数据的程序
(do ((i 1 (1+ i)))
((&gt;= i (length ,list)) rtn)
(when (,pred (nth i ,list) rtn);pred可以被替换成一个具体的函数名
(setf rtn (nth i ,list))))))
(defun mymax2 (list) ;定义一个函数,取一个列表的最大值
(maxmin list &gt;))
(defun mymin2 (list) ;定义一个函数,取一个列表的最小值。
(maxmin list &lt;)
```
这种能够直接操纵AST的能力让Lisp特别灵活。比如在Lisp语言里根本没有原生的面向对象编程模型但你完全可以用它的元编程功能自己构造一套带有类、属性、方法、继承、多态的编程模型这就相当于构建了一个新的M2层的元模型。通常一个语言的元模型也就是编程时所能使用的结构比如是否支持类呀什么的在设计语言的时候就已经固定了。但Lisp的元编程功能竟然能让你自己去定义这些语言特性这就是一些小众的程序员特别热爱Lisp的原因。
### C++的元编程技术
提到元编程就不能不提一下C++的**模板元编程**Template Metaprogramming技术它大大增强了C++的功能。
模板元编程技术属于静态元编程技术也就是让编译器尽量在编译期做一些计算。这在很多场景中都有用。一个场景就是提供泛型的支持。比如List<int>是整型这样的值类型的列表而List<student>是Student这种自定义类型的列表你不需要为不同的类型分别开发List这样的容器类在下一讲我还会对泛型做更多的讲解</student></int>
但模板元编程技术不仅仅可以支持泛型,也就是模板的参数可以不仅仅是类型,还可以是普通的参数。模板引擎可以在编译期利用这些参数做一些计算工作。我们来看看下面这个例子。这个例子定义了一个数据结构,它可以根据你传入的模板参数获得阶乘值。
如果这个参数是一个编译期的常数,那么模板引擎会直接把这个阶乘值计算出来,而不是等到运行期才做这个计算。这样能降低程序在运行时的计算量,同时又保持编程的灵活性。
```
template&lt;int n&gt;
struct Fact {
enum { RET = n * Fact&lt;n-1&gt;::RET }; //用一个枚举值代表阶乘的计算结果
};
template&lt;&gt; //当参数为1时阶乘值是1
struct Fact&lt;1&gt; {
enum { RET = 1 };
};
int b = Fact&lt;5&gt;::RET; //在编译期就计算出阶乘值为120
```
看到这里利用你学过的编译原理你能不能猜测出C++模板的实现机制呢?
我们也看到过在编译器里做计算的情况比如说常数折叠会在编译期计算出表达式的常数值不用在运行期再去计算了。而在C++的模板引擎里把这种编译器的计算能力大大地丰富了。不过你仍然可以猜测出它的实现机制它仍然是基于AST来做计算生成新的AST。在这个过程中像Fact&lt;5&gt;这种情况甚至会被计算出最终的值。C++模板引擎支持的计算如此复杂,以至于可以执行递归运算。
## 课程小结
今天这一讲,我们围绕元编程这个话题做了比较深入的剖析。
元编程,对于我们大多数程序员来说,是一个听上去比较高深的概念。但是,在学过编译原理以后,你会更容易理解元编程技术,因为编译器就是做元编程的软件。而各门语言中的元编程特性,本质上就是对编译器的能力的释放和增强。编译器要获得程序的结构信息,并对它们进行修改、转换,元编程做的是同样的事情。
我们学好编译原理以后在元编程方面其实拥有巨大的优势。一方面我们可以更加了解某门语言的元编程机制是如何工作的另一方面即使某些语言没有提供原生的元编程功能或者是元编程功能不够强大我们也仍然可以自己做一些工具来实现元编程功能这就是类似Spring这样的工具所做的事情。
本讲中关于Meta的层次的概念是我特别向你推荐的一个思维模型。采用这个模型你就知道不同的工作是发生在哪一个抽象层级上。因而你也就能明白为什么学习编译原理中用到的那些形式语言会觉得更加抽象。因为计算机语言的抽象层级就挺高的了而用于描述计算机语言的词法和语法规则的语言当然抽象层级更高。
我把这讲的思维导图也放在了这里,供你复习和参考。
<img src="https://static001.geekbang.org/resource/image/f7/87/f7fcc8bfee28014bbf173f0160003287.jpg" alt="">
## 一课一思
我在本讲举了ORM的例子。如果用你熟悉的语言来实现ORM功能也就是自动根据对象的类型信息来生成合适的SQL语句你会怎么做
欢迎分享你的观点,也欢迎你把今天的内容分享给更多的朋友。感谢阅读,我们下一讲再见。

View File

@@ -0,0 +1,360 @@
<audio id="audio" title="37 | 高级特性(二):揭秘泛型编程的实现机制" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5c/ff/5c5b97a296bb9f8edd004d2b4b2dd1ff.mp3"></audio>
你好,我是宫文学。
对泛型的支持是现代语言中的一个重要特性。它能有效地降低程序员编程的工作量避免重复造轮子写很多雷同的代码。像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(&quot;Richard&quot;);
String name = (String)strList.get(i); //类型转换
for (Object obj in strList){
String str = (String)obj; //类型转换
...
}
strList.add(Integer.valueOf(1)); //类型错误
```
而Java里的泛型功能就能完全消除这些麻烦工作让程序更整洁并且也可以减少出错机会。
```
List&lt;String&gt; strList = new ArrayList&lt;String&gt;(); //字符串列表
strList.add(&quot;Richard&quot;);
String name = strList.get(i); //类型转换
for (String str in strList){ //无需类型转换
...
}
strList.add(Integer.valueOf(1)); //编译器报错
```
像示例程序里用到的`List&lt;String&gt;`是在常规的类型后面加了一个参数使得这个列表变成了专门存储字符串的列表。如果你再查看一下List和ArrayList的源代码会发现它们比普通的接口和类的声明多了一个类型参数`&lt;E&gt;`,而这个参数可以用在接口和方法的内部所有需要类型的地方:变量的声明、方法的参数和返回值、类所实现的接口,等等。
```
public interface List&lt;E&gt; extends Collection&lt;E&gt;{
E get(int index);
boolean add(E e);
...
}
```
所以说,泛型就是把类型作为参数,出现在类/接口/结构体、方法/函数和变量的声明中。**由于类型是作为参数出现的,因此泛型也被称作参数化类型。**
参数化类型还可以用于更复杂的情况。比如你可以使用1个以上的类型参数像Map就可以使用两个类型参数一个是key的类型K一个是value的类型V
```
public interface Map&lt;K,V&gt; {
...
}
```
另外你还可以对类型参数添加约束条件。比如你可以要求类型参数必须是某个类型的子类这是指定了上界Upper Bound你还可以要求类型参数必须是某个类型的一个父类这是指定了下界Lower Bound。实际上从语言设计的角度来看你可以对参数施加很多可能的约束条件比如必须是几个类型之一等等。
**基于泛型的程序由于传入的参数不同程序会实现不同的功能。这也被叫做一种多态现象叫做参数化多态Parametric Polymorphism。**它跟面向对象中的多态一样,都能让我们编写更加通用的程序。
好了,现在我们已经了解了泛型的含义了。那么,它们是如何在语言中实现的呢?需要用到什么编译技术?
## 泛型的实现
接下来我们一起来看一看几种有代表性的语言实现泛型的技术包括Java、C#、C++等。
### 类型擦除技术
**在Java里泛型是通过类型擦除Type Erasure技术来实现的。**前面在分析[Java编译器](https://time.geekbang.org/column/article/255034)时你就能发现其实类型参数只存在于编译过程中用于做类型检查和类型推断。在此之后这些类型信息就可以被擦除。ArrayList和`ArrayList&lt;String&gt;`对应的字节码是一样的,在运行时没有任何区别。
所以我们可以说在Java语言里泛型其实是一种语法糖有助于减少程序员的编程负担并能提供额外的类型检查功能。
除了Java以外其他基于JVM的语言比如Scala和Kotlin其泛型机制基本上都是类型擦除技术。
**类型擦除技术的优点是实现起来特别简单。**运用我们学过的属性计算、类型检查和推断等相关技术基本就够用了。
不过类型擦除技术也有一定的**局限性**。
问题之一,是**它只能适用于引用类型**也就是对象而不适用于值类型也就是Java中的基础数据类型Primitive Type。比如你不能声明一个`List&lt;int&gt;`来保存单纯的整型数据你在列表里只能保存对象化的Integer。而我们学习过Java对象的内存模型知道一个Integer对象所占的内存是一个int型基础数据的好几倍因为对象头要有十几个字节的固定开销。再加上由此引起的对象创建和垃圾收集的性能开销导致用Java的集合对象来保存大量的整型、浮点型等基础数据是非常不划算的。我们在这种情况下还是要退回到使用数组才行。
问题之二,就是因为类型信息在编译期被擦除了,所以**程序无法在运行时使用这些类型信息**。比如在下面的示例代码中如果你想要根据传入的类型T创建一个新实例就会导致编译错误。
```
public static &lt;T&gt; void append(ArrayList&lt;T&gt; a) {
T b= new T(); // 编译错误
a.add(b);
}
```
同样由于在运行期没有类型信息所以如果要用反射机制来调用程序的时候我们也没有办法像在编译期那样进行类型检查。所以你完全可以往一个旨在保存String的列表里添加一个Interger对象。而缺少类型检查可能会导致程序在执行过程中出错。
另外还有一些由于类型擦除而引起的问题。比如在使用参数化类型的情况下方法的重载Overload会失败。再比如下面的示例代码中两个foo方法看似参数不同。但如果进行了类型擦除以后它们就没什么区别所以是不能共存的。
```
public void foo(List&lt;Integer&gt; p) { ... }
public void foo(List&lt;Double&gt; 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&lt;Student&gt;``List&lt;Teacher&gt;`是两个完全不同的类型。也因为IL保存了类型信息因此我们可以在运行时使用这些类型信息比如根据类型参数创建对象而且如果通过反射机制来运行C#程序的话,也会进行类型检查。
还有很重要的一点,就是**C#的泛型能够支持值类型**,比如基础的整型、浮点型数据;再比如,针对`List&lt;int&gt;``List&lt;long&gt;`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&lt;Cat&gt;``List&lt;Cat&gt;`是什么关系呢?
- `List&lt;Animal&gt;``List&lt;Cat&gt;`之间又是什么关系呢?
对于第一种情况其实它们的类型参数是一样的都是Cat。而List本来是Collection的子类型那么`List&lt;Cat&gt;`也是`Collection&lt;Cat&gt;`的子类型,我们永远可以用`List&lt;Cat&gt;`来替换`Collection&lt;Cat&gt;`。这种情况比较简单。
但是对于第二种情况,`List&lt;Cat&gt;`是否是`List&lt;Animal&gt;`的子类型呢这个问题就比较难了。不同语言的实现是不一样的。在Java、Julia等语言中`List&lt;Cat&gt;``List&lt;Animal&gt;`之间没有任何的关系。
在由多个类型复合而形成的类型中(比如泛型),复合类型之间的关系随其中的成员类型的关系而变化的方式,分为**不变Invariance、协变Covariance和逆变Contravariance**三种情况。理解清楚这三种变化,对于我们理解引入泛型后的类型体系非常重要,这也是编译器进行正确的类型计算的基础。
**首先说说不变。**在Java语言中`List&lt;Animal&gt;``List&lt;Cat&gt;`之间并没有什么关系,在下面的示例代码中,如果我们把`List&lt;Cat&gt;`赋值给`List&lt;Animal&gt;`,编译器会报错。因此,我们说`List&lt;T&gt;`基于T是不变的。
```
List&lt;Cat&gt; catList = new ArrayList&lt;&gt;();
List&lt;Animal&gt; 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&lt;T&gt;`这样的泛型可以变成协变关系吗?**
答案是可以的。我前面也提过,我们可以在类型参数中指定上界。`List&lt;Cat&gt;``List&lt;? Extends Animal&gt;`的子类型,`List&lt;? Extends Animal&gt;`的意思是任何以Animal为祖先的子类。我们可以把一个`List&lt;Cat&gt;`赋值给`List&lt;? Extends Animal&gt;`。你可以看一下示例代码:
```
List&lt;Cat&gt; catList = new ArrayList&lt;&gt;();
List&lt;? extends Animal&gt; animalList = catList; //子类型
catList.add(new Cat());
Animal animal = animalList.get(0);
```
实际上,不仅仅`List&lt;Cat&gt;``List&lt;? extends Animal&gt;`的子类型,连`List&lt;Animal&gt;`也是`List&lt;? extends Animal&gt;`的子类型。你可以自己测试一下。
**我们再来说说逆变。**逆变的意思是虽然Cat是Animal的子类型但包含了Cat的复合类型竟然是包含了Animal的复合类型的父类型它们颠倒过来了
这有点违反直觉。在真实世界里有这样的例子吗?当然有。
比如,假设有两个函数,`getWeight&lt;Cat&gt;()`函数是返回Cat的重量`getWeight&lt;Animal&gt;()`函数是返回Animal的重量。你知道从函数式编程的观点每个函数也都是有类型的。那么这两个函数谁是谁的子类型呢
实际上求Animal重量的函数其实是求Cat重量的函数的子类型。怎么说呢
来假设一下。如果你想用一个getTotalWeight()函数求一群Cat的总重量你会怎么办呢你可以把求Cat重量的函数作为参数传进去这肯定没问题。但是你也可以把求Animal重量的函数传进去。因为既然它能返回普通动物的重量那么也一定能返回猫的重量。
```
//伪代码求Cat的总重量
getTotalWeight(List&lt;Cat&gt; cats, function fun)
```
而根据类型理论,**如果类型B能顶替类型A的位置那么B就是A的子类型**。
所以,`getWeigh&lt;Animal&gt;()`反倒是`getWeight&lt;Cat&gt;()`的子类型,这种情况就叫做逆变。
总的来说,加入泛型以后,计算机语言的类型体系变得更加复杂了。我们在编写编译器的时候,一定要弄清楚这些变化关系,这样才能执行正确的类型计算。
那么在了解了加入泛型以后对类型体系的影响后我们接着借助Julia语言来进一步验证一下如何进行正确的类型计算。
## Julia中的泛型和类型计算
Julia设计了一个精巧的类型体系。这个类型体系有着共同的根也就是Any。在这个类型层次中橙色的类型是叶子节点它们是具体的类型也就是可以创建具体的实例。而中间层次的节点蓝色都是抽象的主要是用于类型的计算。
<img src="https://static001.geekbang.org/resource/image/4b/38/4b88f681a305ecf5010606e3b3a68c38.jpg" alt="" title="Julia的类型体系">
你在[第22讲](https://time.geekbang.org/column/article/264333)中已经了解到了Julia做函数编译的特点。在编写函数的时候你可以根本不用指定参数的类型编译器会根据传入的参数的实际类型来编译成相应版本的机器码。另外你也可以为函数编写多个版本的方法每个版本的参数采用不同的类型。编译器会根据实际参数的类型动态分派到不同的版本。而**这个动态分派机制,就需要用到类型的计算**。
比如说有一个函数foo()定义了三个版本的方法其参数分别是没有指定类型也就是Any、Real类型和Float64类型。如果参数是Float64类型那它当然会被分派到第三个方法。如果是Float32类型那么就会被分派到第二个方法。如果是一个字符串类型呢则会被分派到第一个方法。
```
julia&gt; function foo(x) #方法1
...
end
julia&gt; function foo(x::Real) #方法2
...
end
julia&gt; function foo(x::Float64) #方法3
...
end
```
再进一步,**Julia还支持在定义结构体和函数的时候使用泛型。**比如下面的一个Point结构中坐标x和y的类型是参数化的。
```
julia&gt; struct Point{T}
x::T
y::T
end
julia&gt; Point{Float64}
Point{Float64}
julia&gt; Point{Float64} &lt;: Point #在Julia里如果一个类型更具体则&lt;:为真
true
julia&gt; Point{Float64} &lt;: Point{Real} #Invariant
false
julia&gt; p1 = Point(1.0,2.3) #创建一个Point实例
Point{Float64}(1.0, 2.3) #自动推断出类型
```
如果我们再为foo()函数添加几个方法其参数类型分别是Point类型、Point{Real}类型和Point{Float64}类型,那动态分派的算法也必须能够做正确的分派。所以,在这里,我们就必须能够识别出带有参数的类型之间的关系。
```
julia&gt; function foo(x::Point) #方法4
...
end
julia&gt; function foo(x::Point{Real}) #方法5
...
end
julia&gt; function foo(x::Point{Float64}) #方法6
...
end
```
通过以上的示例代码你可以看到Point{Float64} &lt;: 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{&lt;:Real}它是Point{Float32}、Point{Float16}等的父类型。
总结起来Julia的泛型和类型计算是很有特点的。泛型提供的参数化多态Parametric Polymorphism跟Julia原来的方法多态Method Polymorphism很好地融合在了一起让我们能够最大程度地去编写通用的程序。而被泛型增强后的类型体系也对动态分派算法提出了更高的要求。
## 课程小结
这一讲,我们学习了泛型这个现代语言中非常重要的特性的实现机制。在实现泛型机制的时候,我们首先必须弄清楚引入泛型以后,对类型体系的影响。你要掌握**不变、协变和逆变**这三个基本概念和它们的应用场景,从而能够正确地用于类型计算的过程中。
在泛型的具体实现机制上,有**类型擦除、具体化和模板元编程**等不同的方法。好的实现机制应该有能力同时兼顾值类型和复合类型,同时又便于调试。
按照惯例,我也把本讲的内容总结成了思维导图,供你参考:
<img src="https://static001.geekbang.org/resource/image/c7/d7/c7bf4642ebd4a0253b9ec3b174ef71d7.jpg" alt="">
## 一课一思
今天,我想给你留两道思考题,你可以根据你熟悉的语言,选择其一。
- 如果你对Java语言比较熟悉那么针对Java的泛型不支持基础数据类型的问题你能否想出一种技术方案来弥补这个短板呢你思考一下。我在下一讲会借助面向对象的话题给出一个技术方案。
- 而如果你对Go语言有所了解那么你对Go语言的泛型技术方案会提出什么建议能否避免已有语言在实现泛型上的短板呢你也可以参考我在文末给出的Go语言泛型方案的草案来印证你的想法。
欢迎在留言区分享你的观点,也非常欢迎你把今天的内容分享给更多的朋友。
## 参考资料
1. Go语言[泛型方案的草案](https://go.googlesource.com/proposal/+/refs/heads/master/design/go2draft-type-parameters.md)。
1. [Julia的泛型](https://docs.julialang.org/en/v1/manual/types/#Parametric-Types)。
1. [C#泛型的文档](https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/generics/)你可以看看它在运行期是如何支持泛型的这跟Java有很大的不同。

View File

@@ -0,0 +1,248 @@
<audio id="audio" title="38 | 综合实现(一):如何实现面向对象编程?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/69/77/693b500c38cda61c61e4b01553277a77.mp3"></audio>
你好,我是宫文学。
从20世纪90年代起面向对象编程的范式逐渐成为了主流。目前流行度比较高的几种语言比如Java、JavaScript、Go、C++和Python等都支持面向对象编程。
那么,为了支持面向对象编程,我们需要在语言的设计上,以及编译器和运行时的实现上,考虑到哪些问题呢?
这一讲,我就带你来探讨一下如何在一门语言里支持面向对象特性。这是一个很综合的话题,会涉及很多的知识点,所以很有助于帮你梳理和贯通与编译原理有关的知识。
那么,我们就先来分析一下,面向对象特性都包括哪些内容。
## 面向对象语言的特性
日常中,虽然我们经常会使用面向对象的语言,但如果要问,到底什么才是面向对象?我们通常会说得含含糊糊。最常见的情况,就是会拿自己所熟悉的某种语言的面向对象特性,想当然地认为这就是面向对象语言的全部特性。
不过,在我们的课程里,我想从计算机语言设计的角度,带你重新梳理和认识一下面向对象的编程语言,把面向对象按照清晰的逻辑解构,这样也便于讨论它的实现策略。在这个过程中,你可能会对面向对象产生新的认识。
### 特征1对象
**面向对象编程语言的核心,是把世界看成了一个个的对象**,比如汽车、动物等。这些对象包含了数据和代码。数据被叫做字段或属性,而代码通常又被叫做是方法。
此外,**这些对象之间还会有一定的关系**。比如,汽车是由轮子、发动机等构成的,这叫做**聚合关系**。而某个班级会有一个班主任,那么班级和作为班主任的老师之间,会有一种**引用关系**。
**对象之间还可以互相发送消息。**比如,司机会“通知”汽车,让它加速或者减速。在面向对象的语言中,这通常是通过方法调用来实现的。但也并不局限于这种方式,比如对象之间还可以通过异步的消息进行互相通讯,不过一般的编程语言都没有原生支持这种通讯方式。我们在讨论[Actor模式](https://time.geekbang.org/column/article/280663)的时候曾经提到过Actor之间互相通讯的方式就有点像对象之间互发消息。
### 特征2类和类型体系
**很多面向对象的语言都是基于类class并且类也是一种自定义的类型。**这个类型是对象的模板。而对象呢,则是类的实例。我们还可以再印证一下,前面在探究[元编程](https://time.geekbang.org/column/article/282919)的实现机制时学过的Meta层次的概念。对象属于M0层而类属于M1层它为对象制定了一个标准也就是对象中都包含了什么数据和方法。
其实,**面向对象的语言并不一定需要类这个概念**,这个概念更多是来自于类型理论,而非面向对象的语言一样可以支持类型和子类型。类型的好处主要是针对静态编译的语言的,因为这样就可以通过类型,来限制可以访问的对象属性和方法,从而减少程序的错误。
而有些面向对象的语言比如JavaScript并没有类的概念。也有的像Python虽然有类的概念但你可以随时修改对象的属性和方法。
### 特征3重用继承Inheritance和组合Composition
在软件工程里我们总是希望能重用已有的功能。像Java、C++这样的语言,能够让子类型重用父类型的一些数据和逻辑,这叫做**继承**。比如Animal有speak()方法Cat是Animal的子类那么Cat就可以继承这个speak()方法。Cat也可以重新写一个方法把父类的方法覆盖掉让叫声更像猫叫。
不过并不是所有的面向对象编程语言都喜欢通过继承的方式来实现重用。你在网上可以找到很多文章都在分析继承模式的缺陷。像Go语言采用的是**组合方式**来实现重用。在这里,我引用了[一篇文章](https://golangbot.com/inheritance/)中的例子。在这个例子中作者首先定义了一个author的结构体并给这个结构体定义了一些方法
```
type author struct { //结构体author(作者)
firstName string //作者的名称
lastName string
bio string //作者简介
}
func (a author) fullName() string { //author的方法获取全名
return fmt.Sprintf(&quot;%s %s&quot;, a.firstName, a.lastName)
}
type post struct { //结构体:文章
title string //文章标题
content string //文章内容
author //文章作者
}
func (p post) details() { //文章的方法:获取文章的详细内容。
fmt.Println(&quot;Title: &quot;, p.title)
fmt.Println(&quot;Content: &quot;, p.content)
fmt.Println(&quot;Author: &quot;, p.author.fullName())
fmt.Println(&quot;Bio: &quot;, 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。
<img src="https://static001.geekbang.org/resource/image/5b/01/5b17bc0b506cb17afdd7ca2fcf4e9801.jpg" alt="">
如果两个子类分别有一个实例a和b那么它们的内存布局就是下面的样子
<img src="https://static001.geekbang.org/resource/image/5c/6a/5cbcfb3888dyy3ee8b233667fa0d296a.jpg" alt="">
那么你可能要问了,**为什么父类的数据成员要放在前面,子类的要放在后面?**这也要从编译的角度说起。
我们知道,在生成的汇编代码里,如果要访问一个类的成员变量,其实都是从对象地址加上一个偏移量,来得到成员变量的地址。而这样的代码,针对父类和各种不同的子类的对象,要都能正常运行才行。所以,该成员变量在不同子类的对象中的位置,最好是固定的,这样才便于生成代码。
不过像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(&quot;a=0x%08x, b=0x%08x, c=0x%08x\n&quot;, (a*)&amp;obj,(b*)&amp;obj,(c*)&amp;obj);
}
```
看到这段代码,你发现什么了呢?
你会发现a和c的指针地址是一样的而b的地址则要大几个字节。这是因为在内存里程序会先排a的字段再排b的字段最后再排c的字段。编译器做指针的类型转换cast的时候要能够计算出指针的正确地址。
<img src="https://static001.geekbang.org/resource/image/d0/61/d0893798d4fcb3459b8b4bd7c67daf61.jpg" alt="">
好,现在你就已经见识到了编译器在运行期针对对象的内存布局的设计了。接下来,我们再来看看针对多态的情况,编译器对实现方法的绑定是怎么做的。
### 方法的静态绑定和动态绑定
当你的程序调用一个对象的方法的时候这个方法到底对应着哪个实现有的时候在编译期就能确定了比如说当这个方法带有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三个类
<img src="https://static001.geekbang.org/resource/image/15/90/15c8d9a7bb55807ffb13b2e8d8c69d90.jpg" alt="">
在对象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编译器](https://time.geekbang.org/column/article/255034)的时候你已经发现它在语义分析阶段提供了自动装箱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的具体实现机制感兴趣可以参考[这篇文章](https://scalac.io/specialized-generics-object-instantiation/)。
## 课程小结
这一讲我通过面向对象这个话题,带你一起综合性地探讨了语言设计、编译器和运行时的多个知识点。你可以带走这几个关键知识点:
- 第一,要正确地理解面向对象编程的内涵,知道其实面向对象的语言可以有多种不同的设计选择,体现在类型体系、重用机制和信息封装等多个方面。对于不同的设计选择,你要都能够把它们解构,并对应到编译期和运行时的设计上。
- 第二,面向对象的各种特性,大多都是要在语义分析阶段进行检查和验证。
- 第三,对于静态编译的面向对象语言来说,理解其内存布局是关键。编译期要保证能够正确地访问对象的属性,并且巧妙地实现方法的动态绑定。
- 第四,如有可能,尽量让一切数据都表达为对象。让编译器完成自动装箱和拆箱的工作。
按照惯例,我把这节课的核心内容整理成了思维导图,供你参考和回顾知识点。
<img src="https://static001.geekbang.org/resource/image/6b/aa/6b0c1c2a2d4d045e019d3dcd0356e9aa.jpg" alt="">
## 一课一思
有人曾经在技术活动上问Java语言之父詹姆斯 · 高斯林James Gosling如果重新设计Java语言他会怎么做他回答说他会去掉class也就是会取消类的继承机制。那么对于你熟悉的面向对象语言如果有机会重新设计的话你会怎么建议为什么欢迎分享你的观点。
## 参考资料
1. 介绍Trait机制的[论文](http://rmod.inria.fr/archives/papers/Duca06bTOPLASTraits.pdf)。
1. 在类型参数是值类型的情况下Scala以特殊的方式做实例化的[文章](https://scalac.io/specialized-generics-object-instantiation/)。

View File

@@ -0,0 +1,383 @@
<audio id="audio" title="39 | 综合实现(二):如何实现函数式编程?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/93/9c/9330ecd0ec3f4b46374a054c63862d9c.mp3"></audio>
你好,我是宫文学。
近些年函数式编程正在复兴。除了一些纯函数式编程语言比如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&lt; 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=&gt;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) -&gt; 1; %%第一个元素是1
fibo(2) -&gt; 1; %%第二个元素也是1
fibo(N) -&gt; fibo(N-1) + fibo(N-2). %%递归
```
好了,现在我们已经了解了函数式编程的一些关键特征。它的总体思想呢,就是像数学那样去使用函数和值,使可变动部分最小化,让软件的结构变得简单、可预测,从而获得支持并发、更简洁的表达等优势。那么下面,我们就一起来看看如何结合编译原理的相关知识点,来实现函数式编程的这些特征。
## 函数式编程语言的编译和实现
为了实现函数式语言,我们在编译期和运行时都要做很多工作。比如,要在编译器前端做分析和各种语义的检查; 要以合适的方式在程序内部表示一个函数;要针对函数式编程的特点做特别的优化,等等。接下来我们就从编译器的前端工作开始学起。
### 编译器前端的工作
函数式编程语言,在编译器的前端也一样要做很多的语法分析和语义分析工作。
你应该知道,语言的设计者,需要设计出**如何声明一个函数**。像是JavaScript语言会用function关键字Go语言用func关键字Rust语言用的是fn关键字而C语言根本不需要一个关键字来标识一个函数的定义另外如何声明函数的参数和返回值也会使用不同的语法。编译器都要能够正确地识别出来。
语义分析的工作则更多,包括:
1. **符号表和引用消解**:当声明一个函数时,要把它加入到符号表。而当程序中用到某个函数的时候,要找到该函数的声明。
1. **类型检查和推导**既然函数可以被当做一个值使用那么它一定也是有类型的也要进行类型检查和推导。比如在程序的某个地方只能接受返回值为int有一个参数为String的函数那么就需要被使用的函数是否满足这个要求。关于函数的类型一会儿我还会展开讲解。
1. **语法糖处理**在函数式编程中经常会使用一些语法糖。最常见的语法糖就是Lambda表达式Lambda表达式可以简化匿名函数的书写。比如前面JavaScript的示例代码中对数组元素求平方的函数可以写成一个Lambda表达式从而把原来的代码简化成了一行
```
var d = a.map(item=&gt;item*item); //括号中是一个lambda表达式
```
在这个示例程序中,=&gt;左边的是匿名函数的参数右边的是一个表达式这个表达式的计算结果就是匿名函数的返回值。你看通过一个Lambda表达式代替了传统的函数声明代码也变得更简洁了。
OK因为在编译器前端还要对函数做类型分析所以我们再来探究一下函数的类型是怎么一回事。
### 把函数纳入类型系统
这里我要先提一个问题,就是在函数式编程语言里,既然它能够把函数当做一个值一样去看待,那么也应该有相应的类型吧?这就要求语言的类型系统能够把函数包含进来。因此函数式编程语言在编译的时候,也要进行**类型检查和类型推断**。
不过,我们在谈论类型时,比较熟悉的是值类型(如整型、浮点型、字符型),以及用户自定义的类型(如结构、类这些),如果函数也是一种类型,那跟它们是什么关系呢?如果由你来设计,那么你会怎么设计这个类型体系呢?
在不同的语言里设计者们是以不同的方式来解决这个问题的。拿Python来说Python中一切都是对象函数也不例外。函数对象的ob_type字段也被设置了合适的类型对象。这里你可以再次回味一下[Python的类型系统](https://time.geekbang.org/column/article/261063)设计得是如何精巧。
我们再看看Scala的类型系统。上一讲我提出过Scala实现了一个很漂亮的类型系统把值类型和引用类型也就是自定义类做了统一。它们都有一个共同的根就是Any。由于Scala是基于JVM的所以这些类型最后都是以Java的类来实现的。
那么函数也不例外。因为Scala的函数最多支持22个参数所以Scala里有内置的Function1、Function2…Function22这些类作为函数的类型它们也都是Any的子类型。每个Scala函数实际上是这些类的实例。
另外Swift语言的文档对类型的定义也比较清楚。它以产生式的方式列出了type的语法定义。根据该语法类型可以是函数类型、数组类型、字典类型、元组类型等等这些都是类型。
<img src="https://static001.geekbang.org/resource/image/59/2a/59422fb82f57e8a877d66c6947cab22a.jpg" alt="" title="Swift语言中对类型的语法定义">
并且它还把所有类型分成了两个大类别命名类型Named Type和复合类型Compound Type
- **命名类型**包括类、结构体、枚举等,它们都有一个名称,比如自定义类的类名就是类型名称。
- **复合类型**则没有名称,它是由多个其他类型组合而成的。函数和元组都属于复合类型。函数的类型是由参数的类型和返回值的类型组合而成的,它们都是编译器对函数类型进行计算的依据。
举例来说假设一个函数有两个参数分别是类型A和B而返回值的类型是C那么这个函数的类型可以计为(A, B)-&gt;C。这就是对函数的类型的形式化的表达。
那么进一步,我们**如何在编译期里针对函数的类型做类型分析呢**?它跟非复合的类型还真不太一样,因为编译器需要检查复合类型中的多个元素。
举个例子。在一个高阶函数g()里能够接收一个函数类型的参数f(A,B),要求其类型是(A, B)-&gt;C而实际提供的函数f2的类型是(A1, B1)-&gt;C1那么你在编译器里如何判断函数的类型是否合法呢这里的算法要做多步的检查
- 第一f2也必须有两个参数这点是符合的。
- 第二检查参数的类型。A1和B1必须是跟A和B相同的类型或者是它们的父类型这样f1才能正确地给f2传递参数。
- 第三检查返回值的类型。C1则必须是C的子类型这样f1才能接收f2的返回值。
好,说完了编译器的前端工作,我们再来看看函数在语言内部的实现。
### 函数的内部实现
在函数式编程里,所有一切都围绕着函数。但是在编译完毕以后,函数在运行时中是怎么表示的呢?
就像不同的面向对象的语言,在运行时是以不同的方式表示一个对象的,不同的函数式编程语言,在运行时中去实现一个函数的机制也是不太一样的。
- 在Python中一切都是对象所以函数也是一种对象它是实现了Callable协议的对象能够在后面加上一对括号去调用它。
- 在Scala和Java这种基于JVM的语言中函数在JVM这个层次没有获得原生支持因此函数被编译完毕以后其实会变成JVM中的类。
- 在Julia、Swift、Go、Rust这样编译成机器码的语言中函数基本上就是内存中代码段或文本段的一个地址。这个地址在编译后做链接的时候会变成一个确定的地址值。在运行时跳转到这个地址就可以执行函数的功能。
补充:再具体一点的话,**编译成机器码的函数有什么特点呢?**我们再来回顾一下。
首先,函数的调用者要根据调用约定,通过栈或者寄存器设置函数的参数,保护好自己负责保护的寄存器以及返回地址,然后调用函数。
在被调用者的函数体内,通常会分为三个部分。头尾两个部分叫做**序曲prelude<strong>和**尾声epilogue</strong>,分别做一些初始化工作和收尾工作。在序曲里会保存原来的栈指针,以及把自己应该保护的寄存器存到栈里、设置新的栈指针等,接着执行函数的主体逻辑。最后,到尾声部分,要根据调用约定把返回值设置到寄存器或栈,恢复所保护的寄存器的值和栈顶指针,接着跳转到返回地址。
返回到调用者以后,会有一些代码恢复被保护起来的寄存器,获取返回值,然后继续执行后面的代码。
这样,把上述整个过程的细节弄清楚了,你就知道如何为函数生成代码了。
最后,我们必须提到一种特殊情况,就是**闭包**。闭包是纯函数的对立面,它引用了上级作用域中的一些自由变量。闭包在运行时不仅是代码段中的一个函数地址,还必须保存自由变量的值。为了实现闭包的运行时功能,编译器需要生成相应的代码,以便在生成闭包的时候,可以在堆里申请内存来保存自由变量的值。而当该闭包不再被引用了,那么就会像不再被引用的对象一样,成为了内存垃圾,要被垃圾回收机制回收。
好了,到这里你可能会觉得,看上去函数的实现似乎跟命令式语言也没有什么不同。不过,接下来你就会看到不同点了,这就是延迟求值的实现。
### 延迟求值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讲](https://time.geekbang.org/column/article/248770)的优化算法里,提到过**尾调用优化**也就是执行完递归函数后马上用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参数中连续地做乘法从而实现阶乘
```
-&gt;fact(5)
-&gt;fact2(5,4)
-&gt;fact2(5*4,3)
-&gt;fact2(5*4*3,2)
-&gt;fact2(5*4*3*2,1)
-&gt;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演算。要理解函数式编程的特点你可以回想一下中学时代数学课中的内容。在函数式编程中函数是一等公民。它通过强调纯函数和不变性大大降低了程序的复杂度使软件不容易出错并且能够更好地支持并发编程。并且由于采用声明式的编程风格往往程序可以更简洁表达性更好。
第二,不同的语言实现函数的机制是不同的。对于编译成机器码的语言来说,函数就是一个指向代码的指针。对于闭包,还需要像面向对象的语言那样,管理它在内存中的生存周期。
第三,函数仍然要纳入类型体系中,编译器要支持类型的检查和推断。
第四,针对函数式编程的特点,编译器可以做一些特别的优化,比如延迟求值、消除与锁有关的分析、对递归的优化等等。
同样,我把这一讲的知识点梳理成了思维导图,供你参考:
<img src="https://static001.geekbang.org/resource/image/01/5c/018732d4dc9c1ddf9ef5e3860f9e465c.jpg" alt="">
## 一课一思
这节课中我提到,在很多情况下,用函数式编程表达一个计算逻辑会更简洁。那么,你能不能找到这样的一些例子?欢迎分享你的经验。
如果你身边也有对函数式编程感兴趣的朋友,那么也非常欢迎你把这节课分享给 TA。感谢你的阅读下一讲我们会一起解析华为的方舟编译器到时候再见

View File

@@ -0,0 +1,283 @@
<audio id="audio" title="40 | 成果检验:方舟编译器的优势在哪里?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/dc/aa/dc5fcaec19fe920549d85e711a837faa.mp3"></audio>
你好,我是宫文学。到这里,咱们的课程就已经进入尾声了。在这门课程里,通过查看真实的编译器,你应该已经积累了不少对编译器的直观认识。前面我们研究的各种编译器,都是国外的产品或项目。而这一讲呢,我们则要看看一个有中国血统的编译器:**方舟编译器**。
通过阅读方舟编译器已经公开的代码和文档,在解析它的过程中,你可以检验一下自己的所学,谈谈你对它的认识。比如,跟你了解的其他编译器相比,它有什么特点?先进性如何?你是否有兴趣利用方舟编译器做点实际项目?等等。
不过到目前为止由于方舟编译器开源的部分仍然比较有限所以这一讲我们只根据已经掌握的信息做一些分析。其中涉及两个大的话题一是对方舟编译器的定位和设计思路的分析二是对方舟编译器所使用的Maple IR的介绍。
首先我借助Android对应用开发支持的缺陷来谈一下为什么方舟编译器是必要的。
## Android的不足
为什么要研发一款自己的编译器?**对于一个大的技术生态而言,语言的编译和运行体系非常重要。它处在上层应用和下层硬件之间,直接决定了应用软件能否充分地发挥出硬件的性能。**对于移动应用生态而言,我国拥有体量最大的移动用户和领先的移动应用,也有着最大的手机制造量。可是,对于让上层应用和底层硬件得以发挥最大能力的编译器和运行时,我们却缺少话语权。
实际上我认为Android对应用开发的支持并不够好。我猜测掌控Android生态的谷歌公司对于移动应用开发和手机制造都没有关系到切身利益因此创新的动力不足。
我之所以说Android对应用开发的支持不够好这其实跟苹果的系统稍加对比就很清楚了。同样的应用在苹果手机上会运行得更流畅且消耗的内存也更低。所以Android手机只好增加更多的CPU内核和更多的内存。
你可能会问,谷歌不是也有自己的应用吗?对应用的支持也关系到谷歌自己的利益呀。那我这里其实要补充一下,我说的应用开发,指的是用**Java和Kotlin开发的应用**这也是大部分Android平台上的应用开发者所采用的语言。而像谷歌这样拥有强大技术力量的互联网巨头们通常对于性能要求比较高的代码是用C开发的。比如微信的关键逻辑就是用C编写的像手机游戏这种对性能要求比较高的应用底层的游戏引擎也是基于C/C++实现的。
这些开发者们不采用Java的原因是因为Java在Android平台上的编译和运行方式有待提高。Android为了提升应用的运行速度一直在尝试升级其应用运行机制。从最早的仅仅解释执行字节码到引入JIT编译机制到当前版本的ARTAndroid 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都是采用引用计数技术。引用计数可以实时回收内存垃圾所以没有卡顿。并且它也不用像标记-拷贝算法那样,需要保留额外的内存。而方舟编译器,采用的是跟苹果一样的思路,同样采用了引用计数技术。
当然,这里肯定会有孰优孰劣的争论。我们之前也讲过,采用[引用计数法](https://time.geekbang.org/column/article/277707),每次在变量引用对象的时候都要增加引用计数,而在退出变量的作用域或者变量不再指向该对象时,又要减少引用计数,这会导致一些额外的性能开销。当对象在多个线程之间共享的时候,增减引用计数的操作还要加锁,从而进一步导致了性能的降低。
不过,针对引用计数对性能的损耗,我们可以在编译器中通过多种优化算法得到改善,尽量减少不必要的增减计数的操作,也减少不必要的锁操作。另外,有些语言在设计上也会做一些限制,比如引入弱引用机制,从而降低垃圾收集的负担。
无论如何,在全面考察了引用计数方法的优缺点以后,你仍然会发现它其实更适合开发客户端应用。
关于第二个问题,也就是**不同语言的融合问题**。华为采取的方法是让Java语言的程序和基于C、C++等语言的程序按照同一套框架做编译。无论前端是什么语言,都统一编译成机器码,同时不同语言的程序互相调用的时候,也没有额外的开销。
下图是方舟编译器的文档中所使用的架构图。你能看到它的设计目标是支持多种语言都统一转换成方舟IR然后进行统一的优化处理再生成机器码的可执行文件。
<img src="https://static001.geekbang.org/resource/image/3a/f4/3a33ec779e2e8b86ee375eaa197d56f4.jpg" alt="">
这个技术方案其实非常大胆。**它不仅解决了不同语言之间的互相调用问题也彻底抛弃了植根于JVM的虚拟机思路。**方舟编译器新的思路是不要虚拟机,最大程度地以机器码的方式运行,再加上一个非常小的运行时。
我说这个技术方案大胆是因为方舟编译器彻底抛弃了Java原有的运行方案包括内存布局、调用约定、对象结构、分层编译机制等。我们在第二个模块讲过[Graal](https://time.geekbang.org/column/article/255730)在仍然基于JVM运行的情况下JIT只是尽力做改良它随时都有一个退路就是退到用字节码解释器去执行。就算采用AOT以后运行时可以变得小一些但Java运行机制的大框架仍然是不变的。
我也介绍过GraalVM支持对多种语言做统一编译其中也包含了对C语言的支持并且也支持语言之间的互相调用。但即便如此它仍是改良主义它不会抛弃Java原来的技术积累。
**而方舟编译器不是在做改良,而是在做革命。**它对Java的编译更像是对C/C++等语言的编译抛弃了JVM的那一套思路。
这个方案不仅大胆而且难度更高。因为这样就不再像分层编译那样有退路方舟编译器需要把所有的Java语义都静态编译成机器码。而对于那些比较动态的语义比如运行时的动态绑定、Reflection机制等是挑战比较大的。
**那方舟编译器目前的成果如何呢?**根据华为官方的介绍方舟编译器可以使安卓系统的操作流畅度提升24%响应速度提升44%第三方应用操作流畅度提升高达60%。这就是方舟编译器的厉害之处,这也证明方舟编译器的大胆革新之路是走对了的。
我们目前只讨论了方舟编译器对Android平台的改进。其实方舟编译器的目标操作系统不仅仅是Android平台它本质上可移植所有的操作系统也包括华为自己的鸿蒙操作系统。对于硬件平台也一样它可以支持从手机到物联网设备的各种硬件架构。
所以,你能看出,方舟编译器真的是志存高远。**它不是为了解决某一个小问题,而是致力于打造一套新的应用开发生态。**
好了,通过上面的介绍,你应该对方舟编译器的定位有了一个了解。接下来的问题是,方舟编译器的内部到底是怎样的呢?
## 方舟编译器的开源项目
要深入了解方舟编译器还是必须要从它的源代码入手。从去年9月份开源以来方舟编译器吸引了很多人的目光。不过方舟编译器是逐步开源的由于开放出来的源代码必须在知识产权等方面能够经得起严格的审查因此到现在为止我们能看到的开源版本号还只是0.2版,开放出来的功能并不多。
我参照方舟的环境配置文档在Ubuntu 16.04上做好了环境配置。
注意:请尽量完全按照文档的要求来配置环境,避免出现不必要的错误。不要嫌某些软件的版本不够新。
接着,你可以继续根据[开发者指南](https://code.opensource.huaweicloud.com/HarmonyOS/OpenArkCompiler/files?ref=master&amp;tab=content&amp;filePath=doc%2FDeveloper_Guide.md&amp;isFile=true)来编译方舟编译器本身。方舟编译器本身的代码是用C++写的需要用LLVM加Clang编译这说明它到目前还没有实现自举。然后你可以编译一下示例程序。比如用下面的四个命令可以编译出HelloWorld样例。
```
source build/envsetup.sh; make; cd samples/helloworld/; make
```
这个“hellowold”目录原来只有一个HelloWorld.java源代码经过编译后形成了下面的文件
<img src="https://static001.geekbang.org/resource/image/8a/8b/8aa0b196e426663d88b6672db7025d8b.jpg" alt="">
如果你跟踪查看编译过程,你会发现中间有几步的操作:
第一步执行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](https://link.zhihu.com/?target=https%3A//queue.acm.org/detail.cfm%3Fid%3D2544374)。他指出IR的设计会影响优化的效果IR的调整会导致编译器实现的重大调整。他还提出如果不同体系的IR可以实现转换的话就可以加深编译器之间的合作。
基于这些思想方舟编译器特别重视IR的设计因为**方舟编译器的设计目标是将多种语言翻译成统一的IR然后共享优化算法和后端**。这就要求Maple IR必须要能够兼容各种不同语言的差异性才行。
那接下来我们就具体看看Maple IR的特点。
## 志存高远的Maple IR
方舟开放的资料中有一个doc目录[Maple IR的设计文档](https://code.opensource.huaweicloud.com/HarmonyOS/OpenArkCompiler/file?ref=master&amp;path=doc%2FMapleIRDesign.md)就在其中。这篇文档写得很细致从中你能够学习到IR设计的很多思想值得仔细阅读。
文档的开头一段指出由于源代码中的任何信息在后续的分析和优化过程中都可能有用所以Maple IR的目标是尽可能完整地呈现源代码中的信息。
这里我想提醒你注意不要忽略这一句话。它作为文档的第一段可不是随意而为。实际上像LLVM的作者Chris Lattner就认为LLVM的IR损失了一些源代码的信息而很多语言的编译器都会在转换到LLVM IR之前先做一个自己的IR做一些体现自己语言特色的分析工作。为了方便满足这些需求他后来又启动了一个新项目MLIR。你可以通过[这篇论文](https://arxiv.org/abs/2002.11054)了解Lattner的观点。
Maple IR则在一开头就注意到了这种需求它提供了对高、中、低不同层次的IR的表达能力。我们在分析别的编译器的时候比如Graal的编译器也曾经讲过它的IR也是分层次的但其实Graal对特定语言的高层次IRHIR的表达能力是不够强的。
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 &lt; 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](https://time.geekbang.org/column/article/264643)(文本格式、内存格式、二进制格式)。
打开.mpl文件你首先会在文件顶部看到一些符号表信息包括类、方法等符号。
```
javaclass $LFoo_3B &lt;$LFoo_3B&gt; public
func &amp;LFoo_3B_7C_3Cinit_3E_7C_28_29V public constructor (var %_this &lt;* &lt;$LFoo_3B&gt;&gt;) void
func &amp;LFoo_3B_7CatLeastTen_7C_28I_29I public virtual (var %_this &lt;* &lt;$LFoo_3B&gt;&gt;, var %Reg3_I i32) i32
var $__cinf_Ljava_2Flang_2FString_3B extern &lt;$__class_meta__&gt;
func &amp;MCC_GetOrInsertLiteral () &lt;* &lt;$Ljava_2Flang_2FString_3B&gt;&gt;
```
接下来就是每个方法具体的定义了。比如exp方法对应的IR如下
```
func &amp;LFoo_3B_7Cexp_7C_28II_29I public virtual (var %_this &lt;* &lt;$LFoo_3B&gt;&gt;, var %Reg3_I i32, var %Reg4_I i32) i32 {
funcid 48155 #函数id
var %Reg2_R43694 &lt;* &lt;$LFoo_3B&gt;&gt;
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 &amp;LFoo_3B_7CatLeastTen_7C_28I_29I public virtual (var %_this &lt;* &lt;$LFoo_3B&gt;&gt;, var %Reg3_I i32) i32 {
funcid 48154
var %Reg2_R43694 &lt;* &lt;$LFoo_3B&gt;&gt;
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 (&lt;cond-expr&gt;) {
&lt;then-part&gt; }
else {
&lt;else-part&gt;}
```
像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](https://code.opensource.huaweicloud.com/HarmonyOS/OpenArkCompiler/file?ref=master&amp;path=src%252Fmaple_driver%252Fdefs%252Fphases.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选择了树。那么在运行分析和优化算法上会有什么不同呢我希望后续随着方舟编译器有更多部分的开源我会继续跟你分享
这节课的思维导图我也放在了这里,供你参考:
<img src="https://static001.geekbang.org/resource/image/0c/1c/0cf1bbc49f148e767f89c5e66d52e11c.jpg" alt="">
## 一课一思
你认为用一种IR来表示所有类型的语言的话都会有哪些挑战你能否通过对Maple IR的阅读找到Maple IR是如何应对这种挑战的欢迎在留言区分享你的观点。
如果你身边也有对华为的方舟编译器十分感兴趣的朋友,非常欢迎把这节课的内容分享给他,我们一起交流探讨。感谢你的阅读,我们期末答疑再见!

View File

@@ -0,0 +1,119 @@
<audio id="audio" title="期末答疑与总结 | 再次审视学习编译原理的作用" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/93/yy/930f2c0306d924e8cbcb23f367d0c8yy.mp3"></audio>
你好,我是宫文学。到这里,咱们这门课程的主要内容就要结束了。有的同学在学习课程的过程中呢,提出了他感兴趣的一些话题,而我自己也会有一些想讲的话题,这个我也会在后面,以加餐等方式再做一些补充。接下来,我还会给你出一套期末测试题,帮你检测自己在整个学习过程中的所学所得。
那么,在今天这一讲,我们就来做个期末答疑与总结。在这里,我挑选了同学们提出的几个有代表性的问题,给你解答一下,帮助你更好地了解和掌握本课程的知识内容。
## 问题1学习了编译原理对于我学习算法有什么帮助
>
@无缘消受人间富贵:老师,想通过编译器学算法,单独学算法总是不知道有什么意义,每次都放弃,老师有什么建议吗?但是看到评论说用到的都是简单的数据结构,编译器用不到复杂的数据结构和算法?
针对这位同学提出的问题,我想谈一谈我对算法学习的感受。
前一阵,我在跟同事聊天时,提到了一个观点。我说,大部分的程序员,其实从来都没写过一个像样的算法。他们写的程序,都是把业务逻辑简单地翻译成代码。那么,如果一个公司写出来的软件全是这样的代码,就没有什么技术壁垒了,很容易被复制。
反之,一些优秀的软件,往往都是有几个核心的算法的。比如,对于项目管理软件,那么网络优化算法就很关键;对于字处理软件,那么字体渲染算法就很关键,当年方正的激光照排系统,就是以此为基础的;对于电子表格软件,公式功能和自动计算的算法很关键;对于视频会议系统,也必须掌握与音视频有关的核心算法。这样,因为有了算法的技术壁垒,很多软件就算是摆在你的面前,你也很难克隆它。
所以说,作为一名软件工程师,你就必须要有一定的算法素养,这样才能去挑战那些有难度的软件功能。而作为一个软件公司,其实要看看自己在算法上有多少积淀,这样才能构筑自己的技术壁垒。
**那么,编译原理对于提升你的算法素养,能带来什么帮助呢?**我给你梳理一下。
编译原理之所以硬核,也是因为它涉及了很多的算法。
在**编译器前端**主要涉及到的算法有3个
- **有限自动机构造算法**:这是在讲[词法分析](https://time.geekbang.org/column/article/243685)时提到的。这个算法可以根据正则文法自动生成有限自动机。它是正则表达式工具的基础也是像grep等强大的Linux命令能够对字符串进行模式识别的核心技术。
- **LL算法**:这是在讲[自顶向下的语法分析](https://time.geekbang.org/column/article/244906)时涉及的。根据上下文无关文法LL算法能够自动生成自顶向下的语法分析器中间还涉及对First和Follow集合的计算。
- **LR算法**:这是在讲[自底向上的语法分析](https://time.geekbang.org/column/article/244906)时涉及的。根据上下文无关文法LR算法能自动生成自底向上的语法分析器中间还涉及到有限自动机的构造算法。
总的来说,编译器前端的算法都是判断某个文本是否符合某个文法规则,它对于各种文本处理工作都很有效。有些同学也在留言区里分享,他在做全文检索系统时就会用到上述算法,使得搜索引擎能更容易地理解用户的搜索请求。
在**编译器后端**主要涉及到的算法也有3个
- **指令选择算法;**
- **寄存器分配算法;**
- **指令重排序(指令调度)算法。**
这三个算法也有共同点它们都是寻找较优解或最优解而且它们都是NP CompleteNP完全的。简单地说就是这类问题能够很容易验证一个解对不对多项式时间内但求解过程的效率却可能很低。对这类问题会采用各种方法求解。在讲解[指令选择算法](https://time.geekbang.org/column/article/274909)时,我介绍了**贪婪策略和动态规划**这两种不同的求解思路;而寄存器选择算法的图染色算法,则采用了一种**启发式算法**这些都是求解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讲](https://time.geekbang.org/column/article/286097)也已经分析过了Android系统经过了很多年的演化但技术上仍然有明显的短板使得Android平台的使用体验始终赶不上苹果系统。为了弥补这些短板各个互联网公司都付出了很大的成本比如一些头部应用的核心功能采用了C/C++开发。
并且Android系统的编译器在支持新的硬件上也颇为保守和封闭让中国厂商难以参与。这也是华为之所以要做方舟编译器的另一个原因。因为华为现在自研的芯片越来越多要想充分发挥这些芯片的能力就必须要对编译器有更大的话语权。方舟编译器的问世也证明了我们其实是有技术能力的可以比国外的厂商做得更好。既然如此我们为什么要受别人的制约华为方舟编译器团队其实也很渴望在方舟编译器之后推出自己的语言。至于华为内部是否已经立项这就不太清楚了但我觉得这是顺理成章的事情。
另外除了在移动端的开发上会受到很多掣肘在云端其实也一样。比如说Java是被大量后端开发的工程师们所掌握的语言但现在Java是被Oracle掌控的。你现在使用Java的时候可能已经多多少少感受到了一种不愉快。先不说Java8之后的收费政策就说我们渴望的特性如协程、泛型中支持基础数据类型等一直没有被满足就会感觉不爽。
我在讲到[协程](https://time.geekbang.org/column/article/280269)的时候就指出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语言比较简单所以实现后端的工作量应该也相对较小。
## 小结
这一讲,我主要回答了几个比较宏观的问题,它们都涉及到了编译原理这门课的作用。
第一个问题,我是从提升算法素养的角度来展开介绍的。编译原理知识里面涉及了大量的算法,我把它总结成了三大类,每类都有自己的特点,希望能对你宏观把握它们有所帮助。
第二个问题,其实是这门课程的一条暗线。我并没有在课程里去情绪化地鼓吹,一定要有自己的编译器、自己的语言。我的方式其实是想做一点具体的事情,所以在第二个模块中,我带着你一起探究了现有语言的编译器都是怎么实现的,破除你对编译器的神秘感、距离感;在第三个模块,我们又一起探讨了一下实现一门语言中的那些关键技术点,比如垃圾收集、并行等,它们都是如何实现的。
在课程最后呢,我又带你了解了一下具有中国血统的方舟编译器。我想说的是,其实我们不但能做出编译器和语言来,而且可能会做得更好。虽然我们对方舟编译器的分析还没有做完,但通过分析它的技术思路,你应该或多或少地感受到了它的优秀。所以,针对“我们真的需要一门新语言吗”这个问题,我的回答是确定的。并且,即使你不去参与实现一门通用的语言,在实现自己领域的语言,以及把自己的软件做得更具通用性这点上,编译原理仍然能发挥巨大的作用,对你的职业生涯也会有切实的帮助。
好,请你继续给我留言吧,我们一起交流讨论。同时我也希望你能多多地分享,做一个知识的传播者。感谢你的阅读,我们下一讲再见。