多态(Polymorphism)的实现机制
阅读原文时间:2023年07月09日阅读:1

1. 我理解的广义的 override 是指抛开各种访问权限,子类重定义(redefine)父类的函数(即函数签名相同)。

2. C++中的三个所谓的原则:never redefine base class‘ non-virtual function;重写虚函数;如果要 redefine 父类的 static 函数的话,就同样也写成static函数。

虽然C++允许很多事情,但并不意味着我们就要这样做,相反,很多事情是需要极力避免的。

除了virtual funtion 的 override 的函数返回值需要相同或者协变(即 incompatible),其余情况均对函数返回值无要求。

函数签名相同即意味着隐藏父类函数,当然需要注意C++中的name hiding问题。

3. Java中的大部分方法都是virtual method,除了构造方法,static method,private method,final method。

Java中秉承一个原则:不能继承的就不能重写。但是C++并不是,所以C++的virtual function可以是private访问权限的。

与Java中override父类方法必须放宽访问权限而C++无此要求一样,我个人认为这些都体现了Java的安全性。

4. 在Java中有几种很特殊的情况:

① redefine父类的 private 方法时,因为 private 方法是所谓的隐式 final 的,是不会参与多态的,也对应着上面提到的不能继承的就不能重写。

所以相当于在子类中 redefine 了一个相同签名的方法,返回值当然无要求,因为本来就没有继承父类的方法所以也没有隐藏一说,可以算作我上面所说的广义的override,哈哈。

② 子类不在父类所在的包中,父类中包权限访问的方法子类无法继承,也就无法重写。

但是包访问权限方法却毫无疑问是virtual method,当子类确实 redefine 了父类的包访问权限方法时,这时候用指向子类对象的父类引用来调用这个方法时,实际上也是动态

调用,但是调用到的还是父类的方法,关于这个的实现原理,我还专门上知乎咨询了R大,见:知乎

当然返回值同样无要求,因为本来就没有继承父类的包权限方法所以同样也没有隐藏一说,也可以算作我上面所说的广义的override,哈哈。

注意:在实际的编码过程中,上面的这两种情况是应该尽量杜绝的,因为容易给人造成误解,写出来的代码也不 effective 。

③ redefine 父类的 static 方法时,JLS的官方说法是 hide,当然这也可以算作我上面所说的 广义的 override。

这时候Java的要求 是返回值 也不能不 incompatible,实际上是不需要的,但是规定就这么定了,那就这样咯。

5. 关于多态的实现,大家都知道就是靠虚函数表,网上这篇流传甚广的文章:C++虚函数表解析

Java的实现原理与C++类似,JVM中有一块内存区域叫做方法区,JVM也会为每一个类建立一个 method table 来存放方法的索引地址。

两者有很多相同之处:
     1、它们的作用是相同的,同样用来辅助实现方法的动态绑定。
     2、同样是类级别的数据结构,一个类的所有对象共享一个方法表。
     3、都是通过偏移量在该数据结构中查找某一个方法。
     4、同样保证所有派生类中继承于基类的方法在方法表中的偏移量跟该方法在基类方法表中的偏移量保持一致。
     5、方法表中都只能存放多态方法(即virtual function)。

但是但是归根结底,C++是一门编译型的语言,而Java更加偏向于解析型的,因此上述数据结构的生成和维护是有所不同的,表现在:
     1、C++中 VTable 和 vptr 是在编译阶段由编译器自动生成的,也就是说,在C++程序载入内存以前,在.obj(.o)文件中已经有这些结构的信息;Java中的方法表是由JVM生成的,因此,使用 javac 命令编译后生成的.class文件中并没有方法表的信息。只有等JVM把.class文件载入到内存中时,才会为该.class文件动态生成一个与之关联的方法表,放置在JVM的方法区中。
    2、C++中某个函数在 VTable 的索引号是在编译阶段已经明确知道的,并不需要在运行过程中动态获知;Java中的方法初始时都只是一个符号,并不是一个明确的地址,只有等到该方法被第一次调用时,才会被解析成一个方法表中的偏移量,也就是说,只有在这个时候,实例方法才明确知道自己在方法表中的偏移量了,在这之前必须经历一个解析的过程。

流程:调用方法时,虚拟机通过对象引用得到方法区中类型信息的方法表的指针入口,查询类的方法表 ,根据实例方法的符号引用解析出该方法在方法表的偏移量,子类对象声明为父类类型时,形式上调用的是父类的方法,此时虚拟机会从实际的方法表中找到方法地址,从而定位到实际类的方法。 
注:所有引用为父类,但方法区的类型信息中存放的是子类的信息,所以调用的是子类的方法表。

  参考:多态的实现机制(Java篇)Java多态的实现原理

6. 关于父类是抽象类,多态的实现,可以简单的理解为:在虚函数表中,纯虚函数(抽象方法)同样有自己对应的位置(即索引),可以在调用时利用偏移量来动态调用,

但是不同的是,这些指针的值是NULL,因为他们在父类中并没有给出实现。

7. C++和Java在多态实现中,最大的不同就在于C++的多重继承与Java的接口。

① 先说Java,Java调用接口的方法时JVM是有一个单独的指令的,就是invokeinterface,根据知乎R大所说,实际上每个接口的方法也是相当于有一个方法表,

就是ITable,只不过可以看做这里面的函数指针都指向NULL。但是因为Java的类可以实现多个接口,所以一个接口的方法在子类方法表中的偏移量总是会不一样的,

所以我们没有办法像调用普通类的virtual function一样通过偏移量来实现多态,所以需要另一个新的指令来调用接口方法,就是invokeinterface。

而通过接口引用来调用子类重写的方法时,基本的方法是采用的遍历搜索的思想,遍历找寻子类方法表,直到找到对应的方法实现。

所以一般来讲调用接口方法都会比较慢,但是各大VM实现肯定都会有自己大量的优化方案,只不过R大所说的我还一点都看不懂。

② 而C++的多重继承采取的方式就很巧妙了,借用上面陈皓文章里的一幅图,一目了然:

每个父类都有自己对应的虚表,相当于建立了三张表,这样就可以继续通过父类指针,然后确定偏移量,然后高效地调用子类的虚函数表。

设计比较精巧。当然C++的多重继承是一个很复杂的问题,要严格慎用,我理解的还非常肤浅。

参考IBM社区的这篇文章:多态在C++和Java编程语言中的实现比较

8. 关于Java8接口中的 default 方法

众所周知,Java8中接口可以定义default方法,而 default 方法毫无疑问是 virtual 的,实现类或继承的接口可以覆写。

关于接口的 default 方法,也有三个原则:

类优先于接口;继承的深度会起到作用(顺便一提在C++中只有使用了虚继承才会体现出来继承的深度,默认情况下的多继承并不会体现出来);继承或实现多个接口

时有相同方法签名的方法冲突时必须加以重写覆盖(顺便C++并不强制要求)。

关于 有 默认方法的情况下的 实现了接口的 类的方法表的结构,我猜测是这样的:

最前面是直接父类的方法表,然后接着是实现的第一个接口的方法表,先是默认方法,然后是 抽象方法的实现,接着按顺序是实现的其他的接口的方法表。

所以这时候可以猜测 invokeinterface的基本方法还是 搜索方法表,因为同样不能确定偏移量。

而相同签名的 默认方法 必须覆盖掉,这就是语言设计者 的 taste 不同了,感觉 C++ 多重继承时的不强制要求覆盖,但是调用的时候会有二义性,需要加上父类作用域

访问符也比较人性化,当然Java的强制覆盖一定程度上也体现了 C++--的特征。哈哈

9. 关于下面这种情况,个人感觉是无解的情形:

interface A{
default int fun(){
return 0;
}
}

interface B{
default int[] fun(){
int[] a = {0};
return a;
}
}
public class Test implements A, B{
public int fun()
{
System.out.println("World");
return 0;
}
public static void main(String[] agrs) {
Test obj = new MyClass();
obj.fun();
}
}

上述代码是有错的,因为两个接口中相同签名的方法的 返回值 不同,所以没有方法对它们进行重写,Java 和 C++ 中都是如此。

自己编码过程中要尽量避免这种情况。

10. 一个可能没啥用的 tips,Java:packA.Aclass.fun;C++:Anasp::Aclass::fun,当然Java8中配合Lambda使用的方法引用也引入了::这个符号,

总的来说,就是符号的问题,不值一提()。

11. 以上就是全部的自己最近一段时间的关于多态的理解,很多地方都是自己的类比与猜测,限于知识水平还没有较好的办法进行查证。

随着学习的深入以及知识的增多,自己难免会发现以上的很多文字可能会存在疏漏甚至错误,也很正常,改之即可。

最后,别忘了学习编程的奥义:Keep Coding!Keep Coding!!Keep Coding!!!

2017.2.25更新:

Java的接口继承本质上还是类似于C++的多继承,尤其是在接口中引入默认方法以后就更像了。

只不过Java是单继承体系,所以在接口引用调用虚方法时只能是遍历的方法,而不是像C++多继承那样的多层虚函数表直接采用偏移量。

有两种情况自己纠结了很久要特别注意一下,就是所谓的两者(多者)择一(指向的方法相同选哪个作为偏移量都无妨),两者(多者)中必须选某一个,(指由于

继承链的深度导致不同接口(父类C++)中的相同的虚方法隐藏与被隐藏的关系、或者JAVA中的类优先于接口)。

还有就是所谓的方法引用(虚函数指针)在一个类中是不能为NULL的,这一点在实现接口或者继承抽象类的时候要注意。

以上所说的都是自己纠结了不短时间的点,这些小tip在自己心中也都对应有代码片段,自己以后看到了也能马上想起来。

最后想说的还是,写代码写代码写代码写代码写代码,Keep Coding!!!

附上知乎R大对自己理解虚函数表有用的一个回答:知乎