You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

366 lines
24 KiB
Markdown

2 years ago
# 31面向对象编程第3步支持继承和多态
你好,我是宫文学。
经过前面两节课对面向对象编程的学习,今天这节课,我们终于要来实现到面向对象中几个最核心的功能了。
面向对象编程是目前使用最广泛的编程范式之一。通常,我们说面向对象编程的核心特性有封装、继承和多态这几个方面。只要实现了这几点,就可以获得面向对象编程的各种优势,比如提高代码的可重用性、可扩展性、提高编程效率,等等。
这节课,我们就先探讨一下面向对象的这些核心特性是如何实现的,然后我会带着你动手实现一下,破解其中的技术秘密。了解了这些实现机制,能够帮助你深入理解现代计算机语言更深层次的机制。
首先,我们先来分析面向对象的几个核心特性,并梳理一下实现思路。
## 面向对象的核心特性及其实现机制
**第一,是封装特性。**
封装是指我们可以把对象内部的数据和实现细节隐藏起来,只对外提供一些公共的接口。这样做的好处,是提高了代码的复用性和安全性,因为内部实现细节只有代码的作者才能够修改,并且这种修改不会影响到类的使用者。
其实封装特性我们在上两节课已经差不多实现完了。因为我们提供了方法的机制让方法可以访问对象的内部数据。之后我们只需要给属性和方法添加访问权限的修饰成分就可以了。比如我们可以声明某些属性和方法是private的这样属性和方法就只能由内部的方法去访问了。而对访问权限的检查我们在语义分析阶段就可以轻松做到。
上一节课我们已经分析了如何处理点符号表达式。你在程序里可以分析出点号左边的表达式的类型信息也可以获得对象的属性和方法。再进一步我们可以给这些属性和方法添加上访问权限的信息那么这些私有的属性就只可以在内部访问了比如使用this.xxx表达式等等。而公有的属性仍然可以在外部访问跟现在的实现没有区别。
**第二,我们看看继承。**
用直白的话来说继承指的是一个class可以免费获得父类中的属性和方法从而降低了开发工作量提高了代码的复用度。
我写了一个示例程序,你可以看一下:
```
function println(data:any=""){
console.log(data);
}
class Mammal{
weight:number = 0;
// weight2;
color:string;
constructor(weight:number, color:string){
this.weight = weight;
this.color = color;
}
speak(){
println("Hello, I'm a mammal, and my weight is " + this.weight + ".");
}
}
class Human extends Mammal{ //新的语法要素extends
name:string;
constructor(weight:number, color:string, name:string){
super(weight,color); //新的语法要素super
this.name = name;
}
swim(){
println("My weight is " +this.weight + ", so I swimming to exercise.");
}
}
class Cat extends Mammal{
constructor(weight:number, color:string){
super(weight,color);
}
catchMouse(){
println("I caught a mouse! Yammy!");
}
}
function foo(mammal:Mammal){
mammal.speak();
}
let mammal1 : Mammal;
let mammal2 : Mammal;
mammal1 = new Cat(1,"white");
mammal2 = new Human(20, "yellow", "Richard");
foo(mammal1);
foo(mammal2);
```
在这个示例程序中Human和Cat都继承了Mammal类。但人类和猫当然有很大的不同比如人类通常会有一个姓名具备游泳的能力而猫则有抓老鼠的能力。
但因为它们都属于哺乳动物所以也一定有一些共同的特征比如都有体重和颜色也都可以发出声音。这里使用了继承功能以后像weight、color和speak()这样的属性和方法我们就不需要Human和Cat去重复实现了。
那实现继承特性的关键点是什么呢你可以基于我们现在的技术实现来分析一下。你会发现当我们调用cat.color的时候**最关键的是要对color属性进行定位**。在编译期我们需要通过引用消解把color属性定位到声明它的地方也就是在父类Mammal中。而在运行期我们需要知道父类的属性的存储位置这样才可以访问它们。
具体实现,我们在接下来的讲解中依次展开。现在我们分析一下面向对象的第三个特性,也就是多态。
**第三,多态特性。**
多态指的是一个类的不同子类,都实现了某个共同的方法,但具体表现是不同的。多态的好处,是我们可以在一个比较稳定的、抽象的层面上编程,而不被更加具体的、易变的实现细节干扰。
举例来说如果我们想要在手机和电脑之间发送信息。那么在抽象的层面上我们只需要提供sendData和receiveData这样的编程接口。而在具体实现上这些信息可能是通过Wi-Fi传递的也可能是通过5G网络或蓝牙传递的。系统可以根据不同的网络环境选择不同的机制但是我们上层使用sendData和recieveData接口来编写的应用程序不需要根据传输方式的不同而修改应用逻辑这样就降低了整体系统的维护成本。
我们也可以修改一下示例程序来说明多态特性。我们给Human和Cat都增加了一个speak()方法,覆盖掉了父类的缺省实现,分别执行了不同的逻辑。
```plain
class Human extends Mammal{ //新的语法要素extends
name:string;
constructor(weight:number, color:string, name:string){
super(weight,color); //新的语法要素super
this.name = name;
}
swim(){
println("My weight is " +this.weight + ", so I swimming to exercise.");
}
speak(){
println("Hello PlayScript!");
}
}
class Cat extends Mammal{
constructor(weight:number, color:string){
super(weight,color);
}
catchMouse(){
println("I caught a mouse! Yammy!");
}
speak(){
println("Miao~~");
}
}
```
你要注意speak()方法后面的几行代码这里就展现了多态的强大之处。你可以声明多个Mammal类型的变量给每个变量赋予Mammal的不同子类的对象实例。而在foo函数程序里我们接受一个Mammal类型的参数并让它speak()。
你会看到无论Mammal的子类将来扩展到多少种都不会影响到foo函数的逻辑foo函数只要保持它的抽象性就好了。这种在抽象层面上编程的技术是实现可重用的编程框架的基础也通常是一个公司里资深的技术人员的职责。
那如果要实现多态功能其实我们不能在编译期做什么事情这主要是运行期的功能。因为你编译foo函数的时候只知道传进来的参数是Mammal类型去调用Mammal的speak()方法就好了。而在运行期这个speak()方法要正确地定位到具体子类的实现上。这个技术就做动态绑定Dynamic Binding或者后期绑定Late Binding这也是面向对象之父阿伦 · 凯伊Alan Kay所提倡的面向对象应该具备的核心特征。
接下来我们会分别在AST解释器和静态编译的两个版本上讨论运行期绑定的实现细节。
在此之前,我们还是要先修改一下编译器的前端,让它能够支持今天我们讲到的特性。
## 修改编译器前端
在编译器前端方面,我们仍然需要增强一下语法规则,并进行一些语义分析工作。
语法方面,主要是**增加类继承的语法**比如“class Humman extends Mammal”。我们把原来类声明的语法规则稍加修改就行
```plain
classDecl : Class Identifier ('extends' Identifier)? classTail ;
```
另外在实现了继承以后我们还需要用到跟this相对应的另一个关键字super。通过super关键字我们可以调用父类的方法。特别是我们需要在子类的构造方法里通过super()这样的格式,来调用父类的构造方法。
相对来说,我们在语义分析方面要做的工作会更多一点,主要包括:
* 在calss的符号信息里要增加与继承关系有关的信息
* 在NamedType类型信息里也要建立起正确的父子关系便于进行类型计算
* 在子类的构造方法里第一个语句必须用super()调用父类的构造方法。
这些工作倒是没有太复杂的实现难度,你参考一下[semantic.ts](https://gitee.com/richard-gong/craft-a-language/blob/master/31/semantic.ts)中的代码。
接下来我们仍然增强一下AST解释器来支持继承和多态特性。
## 修改AST解释器
AST解释器方面需要增强的工作包括
* 为了实现继承特性,要能够在子类中访问父类的属性和方法;
* 为了实现多态特性,在调用方法时,要能够正确调用具体的子类的实现。
**首先我们看如何继承父类的属性。**对于AST解释器这种运行机制来说属性的继承实现起来比较简单。因为我们是用PlayObject来存放对象数据的具体存储方式是一个Map。无论是父类还是子类的属性都保存在这个Map中就可以了。然后我们再以对象属性的Symbol为Key去访问这些数据就行了。
刚才讲的是属性的继承,那么继承父类的方法也是一样的吗?
不知道你还记不记得在上一节课中我们在PlayObject中包含了该对象所属的类的符号信息。而这个符号里又引用了它的父类的符号。所以我们可以借助这些父子关系的信息逐级向上查找直到在某一级父类里找到这个方法。
你有没有看出这里面的关键点?**我们是在运行时才知道,到底要调用那个层级的父类所实现的方法的。这也说明,我们在前端的语义分析阶段,不能把方法的调用跟具体的实现绑死,绑定的动作要留到运行时。**虽然在做引用消解的时候我们把方法调用指向了某个方法的Symbol但这个Symbol只是用来在运行时去定位真正要调用的方法。
![图片](https://static001.geekbang.org/resource/image/da/2a/da9714cce08d6e95d440fc292ae5f92a.png?wh=956x566)
这个在运行时绑定的原则其实也是实现多态特性的背后机制。在上面的示例程序中虽然foo函数的签名针对的都是Mammal类型但在运行时具体调用方法的时候我们传递给方法的都是PlayObject对象而PlayObject对象中保存的ClassSymbol呢是具体的子类的符号信息这当然就会导致调用不同子类的方法从而也就实现了多态。
所以说,**方法的继承和多态这两件事落实到实现上是同一件事都是根据PlayObject中的符号信息去查找到真正应该调用的方法就好了**。
具体实现上,你可以参考[play.ts](https://gitee.com/richard-gong/craft-a-language/blob/master/31/play.ts)的中的示例程序。
![图片](https://static001.geekbang.org/resource/image/2c/b8/2c0f8aa02fb6cc0d726196554eaedfb8.png?wh=476x154)
整个实现下来,你会感觉到似乎也不太难呀。对于解释执行的运行时来说,确实如此。但对于编译执行的运行时机制来说,就需要更多的技巧才能实现继承和多态的机制。
## 在可执行程序中实现继承和多态
在解释执行的运行机制中,我们在访问对象的属性和方法之前可以做很多工作,能让我们确定属性的地址,或者定位具体的方法。但这个过程会导致不少的额外开销。
比如在AST解释器中去访问一个属性的时候需要查找一个Map。这样的话一次赋值过程可能要导致内部多次函数调用。对比我们生成的汇编语言的代码在访问一个对象数据的时候只需要用几个指令做内存地址的计算和数据的访问就可以了性能很高。
调用方法也是如此。在C++这样的语言中,函数调用不可以有太多的额外开销,因为它毕竟是一门系统级的语言,是用于开发操作系统、数据库这类软件的,所以要求性能尽量高,资源占用尽量少。
所以,我们就要细致地设计一下对象的内存布局和方法的动态绑定机制,让我们的程序既具备面向对象带来的灵活性,又不会导致额外的开销。
在这节课的实现中我们就借鉴一下C++的实现机制。这个实现方式很经典,值得好好掌握。
首先说一下对象的内存布局。在对象继承的情况下,怎么保存所有的属性数据呢?包括自己这一级的属性和各级父类的属性。
你可以稍作思考。有了前面我们设计对象内存布局的经验,我相信你一定会拿出正确的技术方案。这个方案就是,不管父类和子类的数据,都集中在一块内存里连续存放。并且,我们要先放父类的数据,再放子类的数据。你可以参考一下这张图:
![图片](https://static001.geekbang.org/resource/image/2c/37/2c4657922d729039f9cb74ab51c92737.png?wh=524x196)
那为什么要先放父类的数据,再放子类的数据呢?
这是因为这可以让我们在程序中无缝的进行类型的转换。比如你可以把一个Cat对象的引用赋给一个Mammal变量。因为Cat对象的指针和Mammal的指针是同一个地址。反过来你在知道一个Mammal变量的具体类型的情况下也可以把一个Mammal引用强制转换成一个Cat引用来访问cat特有的属性。在TypeScript的前端这种类型转换是用as关键字实现的。在运行时可以从基地址开始加上一定的偏移量就可以计算出各级子类的属性的地址。
好了,如何访问对象属性的机制就搞清楚了。那么如何在运行时找到正确的对象方法呢?这就要提一下著名**vtable机制**了。
vtable的原理是什么呢
我们先回顾一下,对于普通的函数,我们是怎么调用的。这很简单,就是用一个标签标记一下函数的入口,然后从调用者那里就可以直接跳转过来。在编译期,我们就知道对这个函数的调用肯定要跳转到这个标签。而这个标签,最后会变成内存里文本段的确定的代码地址。这就是静态绑定。
那我们如何把它改成动态绑定呢我们可以借鉴AST运行时中的机制。在AST的运行时中我们是在获得了PlayObject对象之后根据PlayObject对象中的ClassSymbol来绑定具体的方法。
所以vtable也是采用了这么一个机制。vtable是一个表格保存了对象的每个可被覆盖的方法的代码地址。vtable中的每个条目都对应了一个方法。
比如一个Human对象的vtable中条目1对应的是speak()方法条目2对应的是swim()方法。如果Human没有重载父类的speak()方法那条目1里存的就是父类的方法地址。那么如果Human重载了这个方法呢那我们就用新的方法地址覆盖掉它。这样在程序执行的时候我们通过Mammal对象的指针再查找vtable来获得speak()方法的地址的时候不同的子类的speak()方法的地址就是不同的。这样也就实现了方法的继承和多态。
那具体要生成什么样的汇编代码来支持我们刚才说到的vtable机制和程序的跳转机制呢我们可以看看C++是怎么实现的。
我写了一个C++的示例程序([class2.cpp](https://gitee.com/richard-gong/craft-a-language/blob/master/31/class2.cpp)仍然实现了Mammal、Human和Cat这三个类。其中父类有两个方法speak()和run()可以被子类覆盖也就是带有virtual关键字它还有一个breath()方法不可以被子类覆盖。在foo函数中我们分别调用了mammal的speak()方法和breath()方法。
```plain
#include "stdio.h"
class Mammal{
public:
double weight;
//这个方法不可被子类覆盖
void breath(){
printf("mammal breath~~\n"); //这里用c语言的库而不是c++的cout是为了让生成的汇编代码更简洁
}
//这个方法以virtual开头可以被子类覆盖
virtual void speak(){
printf("I'm mammal.\n");
}
//第二个virtual方法
virtual void run(){
printf("I'm mammal.\n");
}
};
class Cat : public Mammal{
public:
double jumpHeight;
//覆盖了父类的speak方法
void speak(){
printf("I can jump %lf m.\n", jumpHeight);
}
//子类自己的方法
void catchMouse(){
printf("I can catch mouse.\n");
}
};
class Human : public Mammal{
public:
double age;
//覆盖了父类的speak方法
void speak(){
printf("I'm %lf years old.\n", age);
}
};
void foo(Mammal* mammal){
mammal->breath();
mammal->speak();
}
int main(){
Cat * cat = new Cat();
cat->weight = 10;
cat->jumpHeight = 5;
Human * human = new Human();
human->weight = 80;
human->age = 18;
foo(cat);
foo(human);
delete cat;
delete human;
return 0;
}
```
然后我们用“clang++ class2.cpp -o class2”命令把这个C++程序编译成可执行文件。然后再运行这个class2程序就可以得到下面的输出
![图片](https://static001.geekbang.org/resource/image/af/b6/af91bf2d11f2bae3c60d2267a92d6bb6.png?wh=1032x242)
你能看到对于breath()方法程序调用的是父类Mammal的实现这是继承。而对于speak()方法来说,程序调用的是两个子类各自的实现,这是重载。
接下来你可以用“clang++ -S class2.cpp -o class2.s”命令生成汇编文件[class2.s](https://gitee.com/richard-gong/craft-a-language/blob/master/31/class2.s)观察C++语言是怎么实现vtable的。
这个汇编代码有点长,如果你不熟悉它的结构,可能一下子会有点晕。不过,在你找到了规律,知道了生成汇编代码的思路以后,就会觉得容易接受了。
在这里我带你分析一下它里面的主要代码让你理解C++程序编译后到底是如何形成vtable的又是如何实现动态绑定的。
首先看看main函数所生成的代码。我从里面截取了一段对应于源代码中的前三行代码。
![图片](https://static001.geekbang.org/resource/image/5c/22/5c309c80211b980d5688acf472b28422.png?wh=1216x1256)
从这段代码中你能看出程序为Cat对象申请了24个字节的内存。其中前8个字节存的就是vtable的指针后面16个字节分别是weight和jumpHeight。其中weight来自父类而jumpHeight则是在Cat类中声明的。
其中的\_\_ZN3CatC1Ev是做Cat的对象初始化工作。你跟踪这个函数的代码会发现它又调用了\_\_ZN3CatC2Ev。而在\_\_ZN3CatC2Ev中做了两件重要的事情。
![图片](https://static001.geekbang.org/resource/image/fb/b3/fb81f9e086b1057b0927a8443d1896b3.png?wh=892x1114)
首先是它调用了一个为父类Mammal做初始化的函数。然后是一条重要的语句“movq \_\_ZTV3Cat@GOTPCREL(%rip), %rax”。它的意思是把\_\_ZTV3Cat这个标签的地址相对于代码寄存器中的值的偏移量存到rax寄存器中去。
\_\_ZTV3Cat这个标签指向的是一个数据段里面保存了一些常量就像我们之前用过的double常量和字符串常量一样。而这里的一些常量是关于Cat类的一些描述其中包括类型信息以及vtable。编译后这些信息进入可执行程序的数据区并且可以用刚才的的指令访问。
接下来,后面几条指令,是把\_\_ZTV3Cat的地址值加上16个字节写到了Cat对象的对象头里也就是前8个字节。
那为什么要加上16个字节呢这就需要我们到\_\_ZTV3Cat这个标签下看看这个数据段里到底有一些什么数据。你可以看看下面的图。
![图片](https://static001.geekbang.org/resource/image/52/74/525f677789c763240bb323abfcaa5774.png?wh=1156x478)
你会看到在这个段中一共有4个8字节常量。其中第一个常量是0这个常量我们不用管它。第二个8字节常量则是另一个标签指向另一个数据区里面保存了Cat的一些类型信息包括类型名称等等。这些信息可被用于RTTI功能也就是运行时的类型判断功能。
这里的重点在第三和第四个8字节区域它们分别保存了两个虚函数的标签。第一个虚函数是speak函数这个函数指向的是Cat所实现的speak函数而不是父类的函数。而第二个虚函数则是指向Mammal的实现因为Cat并没有覆盖父类中的实现。这最后的16个字节就是Cat类的vtable。
那么程序里具体是如何使用vtable来调用函数的呢你可以再接着看看foo函数的实现。
![](https://static001.geekbang.org/resource/image/48/6a/4850e2dc23c647892a60e721ed38d76a.png?wh=1190x1156)
函数调用了3个函数。在调用breath()方法的时候是直接使用了breath()方法的标签并没有经过vable。这是由C++的语言特性决定的。如果一个方法前面没有用virtual来修饰那它就不可以被子类覆盖因此自然也就不需要vtable了。
而在调用speak()方法的时候代码里使用了“callq _(%rcx)”。这条指令的意思是,%rcx寄存器里保存了一个内存地址。这个内存地址里保存了要执行的代码的地址然后去执行这个代码。实际上%rcx寄存器就是刚才的数据区的起始地址加上16字节后的值这个值正是vtable的地址。而_(%rcx)实际上就是vtable中记录的第一个虚函数的地址。如果你要调用第二个虚函数那么需要使用callq \*8(%rcx)指令基于vtable开头的位置偏移8个字节。
好了到这里我们就把C++实现继承和多态以及vtable的技术细节都分析清楚了。那么我们在后端同样也可以做一个这样的参考实现。我们实现起来可以比C++的机制更加简化。这是因为首先我们目前还不需要运行时类型机制可以简化掉这部分。第二我们把所有父类的方法都放在vtable中因为目前所有父类的方法都是公共的都是允许被覆盖的。
我提供的参考实现,仍然在[asm\_x86-64.ts](https://gitee.com/richard-gong/craft-a-language/blob/master/31/asm_x86-64.ts)中。
## 课程小结
今天的内容就是这些。关于面向对象的核心特性,我希望你记住以下几个知识点:
首先,继承和多态的核心点,都是来自动态绑定技术。也就是说,在编译期并不知道实际调用的是哪一级的方法,只有在运行期根据对象的具体类型才知道。
第二在AST解释器中我们借助PlayObject中存储的ClassSymbol信息就能动态地找到方法的正确实现。
第三在编译成可执行文件时我们要借助vtable技术来实现继承和多态特性。vtable是在编译时就生成了的保存在一个数据区。在创建对象的时候我们就把数据区中vtable的地址写入到对象头中。最后我们只用一条类似于callq \*8(%rcx)指令就能查找出vtable中记录的函数地址从而跳转过去执行。
今天这节课的内容是非常有用的。如果你使用过C++技术那么今天我带你剖析了C++的汇编代码后你会对C++的实现机制理解得更加深入。如果你没有使用过C++那你也一定要记住vtable这种技术因为它是静态编译的语言实现面向对象特性的关键。
我们用了三节课的时间实现了面向对象最核心的一些特性。在此基础上我们可以扩展到支持更多的特性比如TypeScript是支持接口的我们可以把接口特性添加上。再比如我们还可以把Norminal的类型系统改成Structural的让类型之间的兼容更灵活并且还可以看看这个时候用vtable来实现多态还行不行。
## 思考题
我们今天讨论了用vtable在静态编译中实现多态。那么你能不能挑战一下看能不能提供另外的方案来实现多态你可以天马行空地想一想并在留言区分享你的观点。
并且如果我们的类型系统改成Structural的那么我们需要如何修改现在的vtable机制才能准确地在运行时绑定正确的方法呢对于这个问题你也可以谈谈想法。
欢迎你把这节课分享给更多感兴趣的朋友。我是宫文学,我们下节课见!
## 资源链接
[这节课的代码目录在这里!](https://gitee.com/richard-gong/craft-a-language/tree/master/31)