gitbook/编译原理之美/docs/134978.md

300 lines
19 KiB
Markdown
Raw Permalink Normal View History

2022-09-03 22:05:03 +08:00
# 13 | 继承和多态:面向对象运行期的动态特性
面向对象是一个比较大的话题。在“[09 | 面向对象:实现数据和方法的封装](https://time.geekbang.org/column/article/130422)”中,我们了解了面向对象的封装特性,也探讨了对象成员的作用域和生存期特征等内容。本节课,我们再来了解一下面向对象的另外两个重要特征:**继承和多态。**
你也许会问,为什么没有在封装特性之后,马上讲继承和多态呢?那是因为继承和多态涉及的语义分析阶段的知识点比较多,特别是它对类型系统提出了新的概念和挑战,所以我们先掌握语义分析,再了解这部分内容,才是最好的选择。
继承和多态对类型系统提出的新概念,就是子类型。我们之前接触的类型往往是并列关系,你是整型,我是字符串型,都是平等的。而现在,一个类型可以是另一个类型的子类型,比如我是一只羊,又属于哺乳动物。这会导致我们在编译期无法准确计算出所有的类型,从而无法对方法和属性的调用做完全正确的消解(或者说绑定)。这部分工作要留到运行期去做,也因此,面向对象编程会具备非常好的优势,因为它会导致多态性。这个特性会让面向对象语言在处理某些类型的问题时,更加优雅。
而我们要想深刻理解面向对象的特征,就必须了解子类型的原理和运行期的机制。所以,接下来,我们从类型体系的角度理解继承和多态,然后看看在编译期需要做哪些语义分析,再考察继承和多态的运行期特征。
## 从类型体系的角度理解继承和多态
**继承的意思是一个类的子类,自动具备了父类的属性和方法,除非被父类声明为私有的。**比如一个类是哺乳动物它有体重weight的属性还会做叫(speak)的操作。如果基于哺乳动物这个父类创建牛和羊两个子类,那么牛和羊就自动继承了哺乳动物的属性,有体重,还会叫。
所以继承的强大之处,就在于重用。也就是有些逻辑,如果在父类中实现,在子类中就不必重复实现。
**多态的意思是同一个类的不同子类,在调用同一个方法时会执行不同的动作。**这是因为每个子类都可以重载掉父类的某个方法,提供一个不同的实现。哺乳动物会“叫”,而牛和羊重载了这个方法,发出“哞~”和“咩~”的声音。这似乎很普通,但如果创建一个哺乳动物的数组,并在里面存了各种动物对象,遍历这个数组并调用每个对象“叫”的方法时,就会发出“哞~”“咩~”“喵~”等各种声音,这就有点儿意思了。
下面这段示例代码演示了继承和多态的特性a的speak()方法和b的speak()方法会分别打印出牛叫和羊叫,调用的是子类的方法,而不是父类的方法:
```
/**
mammal.play 演示面向对象编程:继承和多态。
*/
class Mammal{
int weight = 20;
boolean canSpeak(){
return true;
}
void speak(){
println("mammal speaking...");
}
}
class Cow extends Mammal{
void speak(){
println("moo~~ moo~~");
}
}
class Sheep extends Mammal{
void speak(){
println("mee~~ mee~~");
println("My weight is: " + weight); //weight的作用域覆盖子类
}
}
//将子类的实例赋给父类的变量
Mammal a = Cow();
Mammal b = Sheep();
//canSpeak()方法是继承的
println("a.canSpeak() : " + a.canSpeak());
println("b.canSpeak() : " + b.canSpeak());
//下面两个的叫声会不同,在运行期动态绑定方法
a.speak(); //打印牛叫
b.speak(); //打印羊叫
```
所以,多态的强大之处,在于虽然每个子类不同,但我们仍然可以按照统一的方式使用它们,做到求同存异。**以前端工程师天天打交道的前端框架为例,这是最能体现面向对象编程优势的领域之一。**
前端界面往往会用到各种各样的小组件比如静态文本、可编辑文本、按钮等等。如果我们想刷新组件的显示没必要针对每种组件调用一个方法把所有组件的类型枚举一遍可以直接调用父类中统一定义的方法redraw(),非常简洁。即便将来添加新的前端组件,代码也不需要修改,程序也会更容易维护。
**总结一下:**面向对象编程时,我们可以给某个类创建不同的子类,实现一些个性化的功能;写程序时,我们可以站在抽象度更高的层次上,不去管具体的差异。
如果把上面的结论抽象成一般意义上的类型理论,就是**子类型subtype。**
子类型(或者动名词:子类型化),是对我们前面讲的类型体系的一个补充。
子类型的核心是提供了is-a的操作。也就是对某个类型所做的所有操作都可以用子类型替代。因为子类型 is a 父类型,也就是能够兼容父类型,比如一只牛是哺乳动物。
这意味着只要对哺乳动物可以做的操作,都可以对牛来做,这就是子类型的好处。它可以放宽对类型的检查,从而导致多态。你可以粗略地把面向对象的继承看做是子类型化的一个体现,它的结果就是能用子类代替父类,从而导致多态。
子类型有两种实现方式一种就是像Java和C++语言需要显式声明继承了什么类或者实现了什么接口。这种叫做名义子类型Nominal Subtyping
另一种是结构化子类型Structural Subtyping又叫鸭子类型Duck Type。也就是一个类不需要显式地说自己是什么类型只要它实现了某个类型的所有方法那就属于这个类型。鸭子类型是个直观的比喻如果我们定义鸭子的特征是能够呱呱叫那么只要能呱呱叫的就都是鸭子。
了解了继承和多态之后,我们看看在编译期如何对继承和多态的特性做语义分析。
## 如何对继承和多态的特性做语义分析
针对哺乳动物的例子,我们用前面语义分析的知识,看看如何在编译期针对继承和多态做语义分析,也算对语义分析的知识点进行应用和复盘。
首先从类型处理的角度出发我们要识别出新的类型Mammal、Cow和Sheep。之后就可以用它们声明变量了。
第二,我们要设置正确的作用域。
从作用域的角度来看一个类的属性或者说成员变量是可以规定能否被子类访问的。以Java为例除了声明为private的属性以外其他属性在子类中都是可见的。所以父类的属性的作用域可以说是以树状的形式覆盖到了各级子类
![](https://static001.geekbang.org/resource/image/c9/24/c94acfea0ea44dcff839b80c77d3e224.jpg)
第三,要对变量和函数做类型的引用消解。
也就是要分析出a和b这两个变量的类型。那么a和b的类型是什么呢是父类Mammal还是Cow或Sheep
注意代码里是用Mammal来声明这两个变量的。按照类型推导的算法a和b都是Mammal这是个I属性计算的过程。也就是说在编译期我们无法知道变量被赋值的对象确切是哪个子类型只知道声明变量时它们是哺乳动物类型至于是牛还是羊就不清楚了。
你可能会说“不对呀我在编译的时候能知道a和b的准确类型啊因为我看到了a是一个Cow对象而b是一个Sheep代码里有这两个对象的创建过程我可以推导出a和b的实际类型呀。”
没错语言的确有自动类型推导的特性但你忽略了限制条件。比如强类型机制要求变量的类型一旦确定在运行过程就不能再改所以要让a和b能够重新指向其他的对象并保持类型不变。从这个角度出发a和b的类型只能是父类Mammal。
所以说编译期无法知道变量的真实类型可能只知道它的父类型也就是知道它是一个哺乳动物但不知道它具体是牛还是羊。这会导致我们没法正确地给speak()方法做引用消解。正确的消解是要指向Cow和Sheep的speak方法而我们只能到运行期再解决这个问题。
所以接下来,我们就讨论一下如何在运行期实现方法的动态绑定。
## 如何在运行期实现方法的动态绑定
在运行期我们能知道a和b这两个变量具体指向的是哪个对象对象里是保存了真实类型信息的。具体来说在playscript中ClassObject的type属性会指向一个正确的Class这个类型信息是在创建对象的时候被正确赋值的
![](https://static001.geekbang.org/resource/image/c1/0d/c1a3070da8053b4a67065e58d2149f0d.jpg)
在调用类的属性和方法时,我们可以根据运行时获得的,确定的类型信息进行动态绑定。下面这段代码是从本级开始,逐级查找某个方法的实现,如果本级和父类都有这个方法,那么本级的就会覆盖掉父类的,**这样就实现了多态:**
```
protected Function getFunction(String name, List<Type> paramTypes){
//在本级查找这个这个方法
Function rtn = super.getFunction(name, paramTypes); //TODO 是否要检查visibility
//如果在本级找不到,那么递归的从父类中查找
if (rtn == null && parentClass != null){
rtn = parentClass.getFunction(name,paramTypes);
}
return rtn;
}
```
如果当前类里面没有实现这个方法,它可以直接复用某一级的父类中的实现,**这实际上就是继承机制在运行期的原理。**
你看,只有了解运行期都发生了什么,才能知道继承和多态是怎么发生的吧。
这里延伸一下。我们刚刚谈到在运行时可以获取类型信息这种机制就叫做运行时类型信息Run Time Type Information, RTTI。C++、Java等都有这种机制比如Java的instanceof操作就能检测某个对象是不是某个类或者其子类的实例。
汇编语言是无类型的所以一般高级语言在编译成目标语言之后这些高层的语义就会丢失。如果要在运行期获取类型信息需要专门实现RTTI的功能这就要花费额外的存储开销和计算开销。就像在playscript中我们要在ClassObject中专门拿出一个字段来存type信息。
现在,我们已经了解如何在运行期获得类型信息,实现方法的动态绑定。接下来,我带你了解一下运行期的对象的逐级初始化机制。
## 继承情况下对象的实例化
在存在继承关系的情况下创建对象时不仅要初始化自己这一级的属性变量还要把各级父类的属性变量也都初始化。比如在实例化Cow的时候还要对Mammal的成员变量weight做初始化。
所以我们要修改playscript中对象实例化的代码从最顶层的祖先起对所有的祖先层层初始化
```
//从父类到子类层层执行缺省的初始化方法,即不带参数的初始化方法
protected ClassObject createAndInitClassObject(Class theClass) {
ClassObject obj = new ClassObject();
obj.type = theClass;
Stack<Class> ancestorChain = new Stack<Class>();
// 从上到下执行缺省的初始化方法
ancestorChain.push(theClass);
while (theClass.getParentClass() != null) {
ancestorChain.push(theClass.getParentClass());
theClass = theClass.getParentClass();
}
// 执行缺省的初始化方法
StackFrame frame = new StackFrame(obj);
pushStack(frame);
while (ancestorChain.size() > 0) {
Class c = ancestorChain.pop();
defaultObjectInit(c, obj);
}
popStack();
return obj;
}
```
在逐级初始化的过程中我们要先执行缺省的成员变量初始化也就是变量声明时所带的初始化部分然后调用这一级的构造方法。如果不显式指定哪个构造方法就会执行不带参数的构造方法。不过有的时候子类会选择性地调用父类某一个构造方法就像Java可以在构造方法里通过super()来显式地调用父类某个具体构造方法。
## 如何实现this和super
现在我们已经了解了继承和多态在编译期和运行期的特性。接下来我们通过一个示例程序把本节课的所有知识复盘检验一下加深对它们的理解也加深对this和super机制的理解。
这个示例程序是用Java写的在Java语言中为面向对象编程专门提供了两个关键字this和super这两个关键字特别容易引起混乱。
比如在下面的ThisSuperTest.Java代码中Mammal和它的子类Cow都有speak()方法。如果我们要创建一个Cow对象会调用Mammal的构造方法Mammal(int weight)而在这个构造方法里调用的this.speak()方法是Mammal的还是Cow的呢
```
package play;
public class ThisSuperTest {
public static void main(String args[]){
//创建Cow对象的时候会在Mammal的构造方法里调用this.reportWeight(),这里会显示什么
Cow cow = new Cow();
System.out.println();
//这里调用,会显示什么
cow.speak();
}
}
class Mammal{
int weight;
Mammal(){
System.out.println("Mammal() called");
this.weight = 100;
}
Mammal(int weight){
this(); //调用自己的另一个构造函数
System.out.println("Mammal(int weight) called");
this.weight = weight;
//这里访问属性是自己的weight
System.out.println("this.weight in Mammal : " + this.weight);
//这里的speak()调用的是谁,会显示什么数值
this.speak();
}
void speak(){
System.out.println("Mammal's weight is : " + this.weight);
}
}
class Cow extends Mammal{
int weight = 300;
Cow(){
super(200); //调用父类的构造函数
}
void speak(){
System.out.println("Cow's weight is : " + this.weight);
System.out.println("super.weight is : " + super.weight);
}
}
```
运行结果如下:
```
Mammal() called
Mammal(int weight) called
this.weight in Mammal : 200
Cow's weight is : 0
super.weight is : 200
Cow's weight is : 300
super.weight is : 200
```
答案是Cow的speak()方法而不是Mammal的。怎么回事代码里不是调用的this.speak()吗怎么这个this不是Mammal却变成了它的子类Cow呢
其实在这段代码中this用在了三个地方
* this.weight 是访问自己的成员变量,因为成员变量的作用域是这个类本身,以及子类。
* this()是调用自己的另一个构造方法,因为这是构造方法,肯定是做自身的初始化。换句话说,构造方法不存在多态问题。
* this.speak()是调用一个普通的方法。这时多态仍会起作用。运行时会根据对象的实际类型来绑定到Cow的speak()方法上。
只不过在Mammal的构造方法中调用this.speak()时虽然访问的是Cow的speak()方法打印的是Cow中定义的weight成员变量但它的值却是0而不是成员变量声明时“int weight = 300;”的300。为什么呢
要想知道这个答案我们需要理解多层继承情况下对象的初始化过程。在Mammal的构造方法中调用speak()的时候Cow的初始化过程还没有开始呢所以“int weight = 300;”还没有执行Cow的weight属性还是缺省值0。
怎么样?一个小小的例子,却需要用到三个方面的知识:面向对象的成员变量的作用域、多态、对象初始化。**Java程序员可以拿这个例子跟同事讨论一下看看是不是很好玩。**
讨论完thissuper就比较简单了它的语义要比this简单不会出现歧义。super的调用也是分成三种情况
* super.weight。这是调用父类或更高的祖先的weight属性而不是Cow这一级的weight属性。不一定非是直接父类也可以是祖父类中的。根据变量作用域的覆盖关系只要是比Cow这一级高的就行。
* super(200)。这是调用父类的构造方法,必须是直接父类的。
* super.speak()。跟访问属性的逻辑一样是调用父类或更高的祖先的speak()方法。
## 课程小结
这节课我带你实现了面向对象中的另两个重要特性:继承和多态。在这节课中,我建议你掌握的重点内容是:
* 从类型的角度,面向对象的继承和多态是一种叫做子类型的现象,子类型能够放宽对类型的检查,从而支持多态。
* 在编译期,无法准确地完成对象方法和属性的消解,因为无法确切知道对象的子类型。
* 在运行期,我们能够获得对象的确切的子类型信息,从而绑定正确的方法和属性,实现继承和多态。另一个需要注意的运行期的特征,是对象的逐级初始化过程。
面向对象涉及了这么多精彩的知识点,拿它作为前端技术原理篇的最后一讲,是正确的选择。到目前为止,我们已经讲完了前端技术的原理篇,也如约拥有了一门具备丰富特性的脚本语言,甚至还支持面向对象编程、闭包、函数式编程这些很高级的特性。一般的应用项目所需要的语言特性,很难超过这个范围了。接下来的两节,我们就通过两个具体的应用案例,来检验一下学到的编译原理前端技术,看看它的威力!
## 一课一思
本节课我们深入讨论了面向对象的继承和多态特征。那么你所熟悉的框架,有没有充分利用继承和多态的特点实现一些很有威力的功能?或者,你有没有利用多态的特点,写过一些比较有用的类库或框架呢?欢迎在留言区分享你的经验。
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。
本节课的示例代码我放在了文末,供你参考。
* playscript-java项目目录 [码云](https://gitee.com/richard-gong/PlayWithCompiler/tree/master/playscript-java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/tree/master/playscript-java)
* ASTEvaluator.java解释器请找一下运行期方法和属性动态绑定以及对象实例逐级初始化的代码 [码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/playscript-java/src/main/play/ASTEvaluator.java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/main/play/ASTEvaluator.java)
* ThisSuperTest.java测试Java的this和super特性[码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/lab/oop/ThisSuperTest.java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/oop/ThisSuperTest.java)
* this-and-super.play (playscript的this和super特性)[码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/playscript-java/src/examples/this-and-super.play) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/examples/this-and-super.play)