<audio id="audio" title="13 | 继承和多态：面向对象运行期的动态特性" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/22/91/22097f5f1f9ea54bbc55a3c573e1ff91.mp3"></audio>

面向对象是一个比较大的话题。在“[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(&quot;mammal speaking...&quot;);
    }
}

class Cow extends Mammal{
    void speak(){
        println(&quot;moo~~ moo~~&quot;);
    }
}

class Sheep extends Mammal{
    void speak(){
        println(&quot;mee~~ mee~~&quot;);
        println(&quot;My weight is: &quot; + weight); //weight的作用域覆盖子类
    }
}

//将子类的实例赋给父类的变量
Mammal a = Cow();
Mammal b = Sheep();

//canSpeak()方法是继承的
println(&quot;a.canSpeak() : &quot; + a.canSpeak());
println(&quot;b.canSpeak() : &quot; + 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的属性以外，其他属性在子类中都是可见的。所以父类的属性的作用域，可以说是以树状的形式覆盖到了各级子类：

<img src="https://static001.geekbang.org/resource/image/c9/24/c94acfea0ea44dcff839b80c77d3e224.jpg" alt="">

第三，要对变量和函数做类型的引用消解。

也就是要分析出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，这个类型信息是在创建对象的时候被正确赋值的：

<img src="https://static001.geekbang.org/resource/image/c1/0d/c1a3070da8053b4a67065e58d2149f0d.jpg" alt="">

在调用类的属性和方法时，我们可以根据运行时获得的，确定的类型信息进行动态绑定。下面这段代码是从本级开始，逐级查找某个方法的实现，如果本级和父类都有这个方法，那么本级的就会覆盖掉父类的，**这样就实现了多态：**

```
protected Function getFunction(String name, List&lt;Type&gt; paramTypes){
    //在本级查找这个这个方法
    Function rtn = super.getFunction(name, paramTypes);  //TODO 是否要检查visibility

    //如果在本级找不到，那么递归的从父类中查找
    if (rtn == null &amp;&amp; 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&lt;Class&gt; ancestorChain = new Stack&lt;Class&gt;();

    // 从上到下执行缺省的初始化方法
    ancestorChain.push(theClass);
    while (theClass.getParentClass() != null) {
        ancestorChain.push(theClass.getParentClass());
        theClass = theClass.getParentClass();
    }

    // 执行缺省的初始化方法
    StackFrame frame = new StackFrame(obj);
    pushStack(frame);
    while (ancestorChain.size() &gt; 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(&quot;Mammal() called&quot;);
        this.weight = 100;
    }

    Mammal(int weight){
        this();   //调用自己的另一个构造函数
        System.out.println(&quot;Mammal(int weight) called&quot;);
        this.weight = weight;

        //这里访问属性，是自己的weight
        System.out.println(&quot;this.weight in Mammal : &quot; + this.weight);

        //这里的speak()调用的是谁，会显示什么数值
        this.speak();
    }

    void speak(){
        System.out.println(&quot;Mammal's weight is : &quot; + this.weight);
    }
}


class Cow extends Mammal{
    int weight = 300;

    Cow(){
        super(200);   //调用父类的构造函数
    }

    void speak(){
        System.out.println(&quot;Cow's weight is : &quot; + this.weight);
        System.out.println(&quot;super.weight is : &quot; + 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程序员可以拿这个例子跟同事讨论一下，看看是不是很好玩。**

讨论完this，super就比较简单了，它的语义要比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)


