C++中的多态与多重继承实现与Java的区别
多态问题
笔者校招面试时被问到了著名问题「C++与Java如何实现多态」,然后不幸翻车。过于著名反而没有去准备,只知道跟虚函数表有关。面试之后比较了C++和Java多态的实现的异同,一并记录在这里。
C++多态的虚指针实现
首先讨论C++.多态也即子类对父类成员函数进行了重写(Override)后,将一个子类指针赋值给父类,再对这个父类指针调用成员函数,会调用子类重写版本的成员函数。简单的例子:
classParent1{ public: virtualvoidsayHello(){printf("Hellofromparent1!\n");} }; classChild:publicParent1{ public: virtualvoidsayHello(){printf("Hellofromchild!\n");} }; intmain(){ Parent1*p=newChild(); p->sayHello();//get"Hellofromchild!" }
首先需要明白,对于底层实现而言,成员函数就是第一个参数为对象指针的函数,编译器自动将对象指针添加到函数参数中并命名为this指针,除此之外与普通函数并无本质不同。对于非多态的成员函数调用,与非成员函数调用过程基本是一致的,根据参数列表(参数列表中包含对象指针类型)和函数名在编译时确定实际调用的函数。
为了实现多态,不能只根据对象指针类型推断函数签名,也即例子中,p->sayHello()这一行代码在执行时不能只根据p的类型确认调用的函数应该是Parent::sayHello还是Child:sayHello。在多态机制下,每个类父类和子类都需要在其数据结构中多携带一个指针,这个指针指向该类的虚函数表。
类的虚函数表也即所有可能发生重写的函数指针表,对象创建时根据其实际类型决定其虚函数指针指向的虚函数列表。如在上文的例子中,Parent1和Child类的虚函数列表都只有一个函数,分别是Parent1::sayHello和Child::sayHello.编译器在编译时将会把函数调用翻译为「引用虚函数表中的第N个函数」这样的指令,比如本例中翻译为「引用虚函数表中第一个函数」。在运行时读取虚函数表中真正的函数指针。运行时CPU代价基本是一次指针解引用和一次下表访问。
Parent1和Child对象都没有自定义的数据结构。运行以下代码能够确认Parent1和Child对象的真实数据结构大小都是8字节,也即只有虚函数列表指针。把Parent1和Child1对象作为64位整数输出,可以看到p1,p2的值相同,p3与前两者不同。这个值也即相应类的虚函数表地址。
Parent1*p1=newParent1(); Parent1*p2=newParent1(); Parent1*p3=newChild(); printf("sizeofParent1:%d,sizeofChild:%d\n", sizeof(Parent1),sizeof(Child)); printf("valonp1:%lld\n",*(int64_t*)p1); printf("valonp2:%lld\n",*(int64_t*)p2); printf("valonp3:%lld\n",*(int64_t*)p3);
C++多态与多重继承
有一个非常有意思的问题:C++发生多重继承时,如何支持多态。刚刚提到,多态的原理是编译器将成员函数调用编译为「引用虚函数表中第N个函数」,虚函数表在对象数据结构中的位置和要调用虚函数列表中的第几个函数在编译时都是需要确定的。多重继承对象如果只有一个虚函数列表,那不同父类的虚函数列表中的位置就要发生冲突。如果有多个虚函数列表,编译时就难以确定虚函数列表指针在数据结构中的位置。C++采取了非常精妙的做法:将所有父类的数据结构(包括虚指针列表)在该对象的数据结构上依次排列,该对象的指针正常指向数据结构起始位置。当指针发生类型转换时,C++编译器会对指针的值尽可能的进行调整,使其指向该指针类型应该对应的位置。指针的值在这个过程中发生了变化。
比如,Child类继承了Parent1,Parent2两个类,则在Child指针转换为Parent1指针时,不对指针的值进行调整,因为Parent1是Child的第一个父类。但将Child转换为Parent2时,需要将指针指增加Parent1数据结构长度的值,使指针指向对应Parent2数据结构开始位置。在本例子中,Parent1数据结构只有虚函数列表指针,在64位机器上长度为8.因此,在Child指针转换为Parent2指针时,其值增加了8.
classParent1{ public: virtualvoidsayHello(){printf("Hellofromparent1!\n");} }; classParent2{ public: virtualvoidsayHi(){printf("HifromParent2!\n");} }; classChild:publicParent1,publicParent2{ public: virtualvoidsayHello(){printf("Hellofromchild!\n");} virtualvoidsayHi(){printf("Hifromchild!\n");} }; intmain(){ Child*p=newChild(); printf("sizeofChild:%d",sizeof(Child)); printf("pointervalasChild*:%lld\n",int64_t(p)); printf("pointervalasParent1*:%lld\n",int64_t((Parent1*)p)); printf("pointervalasParent2*:%lld\n",int64_t((Parent2*)p)); }
运行这段代码,会发现Child数据结构大小增长到16,也即两个指针。并且指针的值在后两次类型转换时是不同的,在64位机器上相差8个字节,也即Parent1的数据结构大小。另外如果将p转换成Void指针再转换为Parent指针,此时编译器就不能正确推断这个偏移量,此时就会发生未定义行为。
这个特性其实说明了一个非常有意思的事实:C++编译器在编译时能够推断指针的偏移量,那么编译器也应该可以推断该指针指向对象的真实类型。那么,既然可以编译时推断对象真实类型,那要虚函数表又有何用?直接推断正确的函数调用不就可以了吗?问题在于,如果真的在编译时推断多态函数调用,就意味着要为不同类型的对象生成不一样的二进制代码。同一行代码,根据指针值的不同,产生的函数调用不同。这样一来也意味第三方库需要提供源代码,来进行相关的推断,类似于模板库。这都是不可接受的,因此虚函数列表仍然有必要。借助虚函数列表,使用指针的代码能够生成一致的机器码。
从另一个角度理解,编译器在编译一个完整的App时确实能够推断所有变量的真实类型,但这需要联系过多上下文。编译一段代码却需要这段代码输入参数的除类型之外的上下文信息,并根据上下文信息生成不同的二进制文件,这是不可接受的。
Java多态比较
由于Java的多态机制比C++简单,理论上可以使用C++的机制实现Java多态。但C++跟Java有一点决定性的不同:C++要求父类成员方法必须有Virtual关键字修饰时才能被重写。这就意味着编译器在编译父类时就能确认那些函数可能被重写,于是可以对不可能重写的函数直接在编译时决定调用的具体函数,而对可能重写的函数使用虚指针表处理。而Java的方法默认都是可以重写的,因此可以认为Java方法调用都需要经过查询虚函数列表的过程,会比C++不重写函数多一点开销。
Java不支持多重继承,但Java支持接口Interface,接口跟多重继承有相似之处,不能简单的使用一个虚函数表查找。类需要为其实现的每个Interface生成一个虚函数列表,跟C++的情况类似。OpenJDK文档指出,在类定义中找到Interface的虚函数列表的办法是很粗暴的:在类实现的所有Interface列表中遍历查找。文档中指出,真正的多重继承是罕见的,通常可以归结为单继承。对此遍历过程可能有各种优化,笔者没有深入了解。
思考Java和C++的一点不同:C++没有运行时类型,由编译器在编译时尽力保证指针指向的位置有对象正确的数据结构。将子类指针赋值给父类指针变量时,编译器尽力对其进行调整,但如果发生了Void指针赋值等,则编译器无法保证指针指向的位置有正确的对象数据结构。这一步只要语法上没有错误,就不会立即报错,编译器也无法确认是否会发生问题,一定要等到该指针实际进行解引用等发生异常才会报错。Java有运行时类型,在将对象赋值给不同的类型的变量时,会在运行时进行类型检查,如果没有正确的类型继承关系,会在赋值时报错。
另外,对比Java的Interface和C++的多重继承,会发现Interface的运行时时间开销要比C++多重继承大得多。但是C++多重继承需要为每个父类附加一个指针,并且编译器在编译时需要完成更多的工作。Java相对于C++是更加「强类型」的语言。
到此这篇关于C++中的多态与多重继承实现与Java的区别的文章就介绍到这了,更多相关C++多态与多重继承内容请搜索毛票票以前的文章或继续浏览下面的相关文章希望大家以后多多支持毛票票!