C++的多态和虚函数你真的了解吗
作者:山顶夕景
一、C++的面试常考点
阿里虽然是国内Java的第一大厂但是并非所有的业务都是由Java支撑,很多服务和中下层的存储,计算,网络服务,大规模的分布式任务都是由C++编写。在阿里所有部门当中对C++考察最深的可能就是阿里云。
阿里对C++的常考点:
1.STL 容器相关实现
2.C++新特性的了解
3.多态和虚函数的实现
4.指针的使用
二、阿里真题
2.1 真题一
现在假设有一个编译好的C++程序,编译没有错误,但是运行时报错,报错如下:你正在调用一个纯虚函数(Pure virtual function call error),请问导致这个错误的原因可能是什么?
纯虚函数调用错误一般由以下几种原因导致:
- 从基类构造函数直接调用虚函数。(直接调用是指函数内部直接调用虚函数)
- 从基类析构函数直接调用虚函数。
- 从基类构造函数间接调用虚函数。(间接调用是指函数内部调用其他的非虚函数,其内部直接或间接地调用了虚函数)
- 从基类析构函数间接调用虚函数。
- 通过悬空指针调用虚函数。
注意:其中1,2编译器会检测到此类错误。3,4,5编译器无法检测出此类情况,会在运行时报错。
(1)虚函数表vtbl
编译器在编译时期为每个带虚函数的类创建一份虚函数表
实例化对象时, 编译器自动将类对象的虚表指针指向这个虚函数表
(2)构造一个派生类对象的过程
1.构造基类部分:
- 构造虚表指针,将实例的虚表指针指向基类的vtbl
- 构造基类的成员变量
- 执行基类的构造函数函数体
2.递归构造派生类部分:
- 将实例的虚表指针指向派生类vtbl
- 构造派生类的成员变量
- 执行派生类的构造函数体
(3)析构一个派生类对象的过程
1.递归析构派生类部分:
- 将实例的虚表指针指向派生类vtbl
- 执行派生类的析构函数体
- 析构派生类的成员变量(这里的执行函数体,析构派生类成员变量,两者的顺序和构造的步骤是相反的)
2.析构基类部分:
- 将实例的虚表指针指向基类的vtbl
- 执行基类的析构函数函数体
- 析构基类的成员变量
构造函数和析构函数执行函数体时,实例的虚函数表指针,指向构造函数和析构函数本身所属的类的虚函数表,此时执行的虚函数,即调用的本身的该类本身的虚函数,下面是一个【间接调用】的栗子:基类中的析构函数中,调用纯虚函数(该虚函数就在基类中定义)。
#include <iostream> using namespace std; class Parent { public: //纯虚函数 virtual void virtualFunc() = 0; void helper() { virtualFunc(); } virtual ~Parent(){ helper(); } }; class Child : public Parent{ public: void virtualFunc() { cout << "Child" << endl; } virtual ~Child(){} }; int main() { Child child; //system("pause"); return 0; }
运行时报错:libc++abi.dylib: Pure virtual function called
:
2.2 真题二
在构造实例过程当中一部分是初始化列表一部分是在函数体内,你能说一下这些的顺序是什么?差别是什么和this指针构造的顺序
顺序:
(1)初始化列表中的先初始化。
(2)执行函数体代码。
- 执行类中函数体,如执行构造函数时,所有成员已经初始化完毕了;
this
指针属于对象,而对象还没构造完成前,若使用this
指针,编译器会无法识别。在初始化列表中显然不能使用this
指针,注意:在构造函数体内部可以使用this
指针。
构造函数的执行可以分成两个阶段:
- 初始化阶段:所有类类型的成员都会在初始化阶段初始化,即使该成员没有出现在构造函数的初始化列表中。
- 计算赋值阶段:一般用于执行构造函数体内的赋值操作。
#include <iostream> using namespace std; class Test1 { public: Test1(){ cout << "Construct Test1" << endl; } //拷贝构造函数 Test1& operator = (const Test1& t1) { cout << "Assignment for Test1" << endl; this->a = t1.a; return *this; } int a ; }; class Test2 { public: Test1 test1; //Test2的构造函数 Test2(Test1 &t1) { cout << "构造函数体开始" << endl; test1 = t1 ; cout << "构造函数体结束" << endl; } }; int main() { Test1 t1; Test2 test(t1); system("pause"); return 0; }
分析上面的结果:
(1)第一行结果即Test t1
实例化对象时,执行Test1
的构造函数;
(2)第二行代码,实例化Test2
对象时,在执行Test2
构造函数时,正如上面所说的,构造函数的第一步是初始化阶段:所有类类型的成员都会在初始化阶段初始化,即使该成员没有出现在构造函数的初始化列表中。所以Test2在构造函数体执行之前已经使用了Test1的默认构造函数初始化好了t1。打印出Construct Test1
。
这里的拷贝构造函数中可以使用
this
指针,指向当前对象。
(3)第三四五行结果:执行Test2
的构造函数。
2.3 真题三
初始化列表的写法和顺序有没有什么关系?
构造函数的初始化列表中的前后位置,不影响实际标量的初始化顺序。成员初始化的顺序和它们在类中的定义顺序一致。
必须使用初始化列表的情况:数据成员是const
、引用,或者属于某种未提供默认构造函数的类类型。
2.4 真题四
在普通的函数当中调用虚函数和在构造函数当中调用虚函数有什么区别?
普调函数当中调用虚函数是希望运行时多态。而在构造函数当中不应该去调用虚函数因为构造函数当中调用的就是本类型当中的虚函数,无法达到运行时多态的作用。
2.5 真题五
成员变量,虚函数表指针的位置是怎么排布?
如果一个类带有虚函数,那么该类实例对象的内存布局如下:
- 首先是一个虚函数指针,
- 接下来是该类的成员变量,按照成员在类当中声明的顺序排布,整体对象的大小由于内存对齐会有空白补齐。
- 其次如果基类没有虚函数但是子类含有虚函数:
- 此时内存子类对象的内存排布也是先虚函数表指针再各个成员。
如果将子类指针转换成基类指针此时编译器会根据偏移做转换。在visual studio,x64环境下测试,下面的Parent p = Child();
是父类对象,由子类来实例化对象。
#include <iostream> using namespace std; class Parent{ public: int a; int b; }; class Child:public Parent{ public: virtual void test(){} int c; }; int main() { Child c = Child(); Parent p = Child(); cout << sizeof(c) << endl;//24 cout << sizeof(p) << endl;//8 Child* cc = new Child(); Parent* pp = cc; cout << cc << endl;//0x7fbe98402a50 cout << pp << endl;//0x7fbe98402a58 cout << endl << "子类对象abc成员地址:" << endl; cout << &(cc->a) << endl;//0x7fbe98402a58 cout << &(cc->b) << endl;//0x7fbe98402a5c cout << &(cc->c) << endl;//0x7fbe98402a60 system("pause"); return 0; }
结果如下:
24
8
0000013AC9BA4A40
0000013AC9BA4A48子类对象abc成员地址:
0000013AC9BA4A48
0000013AC9BA4A4C
0000013AC9BA4A50
请按任意键继续. . .
分析上面的结果:
(1)第一行24为子类对象的大小,首先是虚函数表指针8B,然后是2个继承父类的int
型数值,还有1个是该子类本身的int
型数值,最后的4是填充的。
(2)第二行的8为父类对象的大小,该父类对象由子类初始化,含有2个int
型成员变量。
(3)子类指针cc
指向又new
出来的子类对象(第三个),然后父类指针pp
指向这个子类对象,这两个指针的值:
- 父类指针
pp
值:0000013AC9BA4A48 - 子类指针
cc
值:0000013AC9BA4A40
即发现如之前所说的:如果将子类指针转换成基类指针此时编译器会根据偏移做转换。我测试环境是64位,所以指针为8个字节。转换之后pp和cc相差一个虚表指针的偏移。
(4)&(cc->a)
的值即 0000013AC9BA4A48,和pp
值是一样的,注意前面的 0000013AC9BA4A40到0000013AC9BA4A47其实就是子类对象的虚函数表指针了。
三、小结
阿里常考的C++的问题集中在以下几点:
- 虚函数的实现
- 虚函数使用出现的问题原因
- 带有虚函数的类对象的构造和析构过程
- 对象的内存布局
- 虚函数的缺点:相比普通函数,虚函数调用需要2次跳转(即需要先找到对象的虚函数表,再查找该表项,即虚函数指针,即真正的虚函数地址),会降低CPU缓存的命中率。运行时绑定,编译器不好优化。
总结
本篇文章就到这里了,希望能够给你带来帮助,也希望您能够多多关注脚本之家的更多内容!