C++多态(下)
目录
一、单继承、多继承及菱形虚拟继承中的虚函数表解析
1. 虚函数表基础认知
2. 单继承中的虚函数表
3. 多继承中的虚函数表
4. 菱形虚拟继承中的虚函数表
二、继承与多态常见面试题
1. 选择题
2. 问答题
一、单继承、多继承及菱形虚拟继承中的虚函数表解析
1. 虚函数表基础认知
虚函数表(vtable)是C++实现运行时多态的核心机制,每个包含虚函数的类都会生成一个虚函数指针数组。对象内存布局的第一个位置存储指向这个数组的指针(vptr),通过这个指针可以找到所有虚函数的实际地址。
2. 单继承中的虚函数表
class Base
{
public:virtual void func1() { cout << "Base::func1" << endl; }virtual void func2() { cout << "Base::func2" << endl; }
private:int a; // 占位成员变量
};class Derive : public Base
{
public:// 重写基类虚函数virtual void func1() override { cout << "Derive::func1" << endl; }// 新增虚函数virtual void func3() { cout << "Derive::func3" << endl; }virtual void func4() { cout << "Derive::func4" << endl; }
private:int b; // 占位成员变量
};
派生类对象的虚表结构是在基类虚表基础上进行扩展的。基类 Base 的虚表包含 func1 和 func2 两个虚函数的地址。当派生类 Derive 覆盖了 func1 并新增 func3 和 func4 时,派生类对象的虚表在基类虚表的基础上,替换了 func1 的地址为派生类自己的实现,并添加了 func3 和 func4 的地址。
观察上图中的监视窗口中我们发现看不见func3和func4。这里是编译器的监视窗口故意隐藏了这两个函数,也可以认为是他的一个小bug。那么我们如何查看d的虚表呢?有以下两种方法:
1、使用内存监视窗口
使用内存监视窗口能够直接观察到派生类对象的虚表内容,这是一种非常直观且真实的方式。通过在内存监视窗口中输入派生类对象的虚表指针地址,我们可以清晰地看到虚表中存储的各个虚函数的地址。
2、内存探查法
虚函数表本质上是一个存储虚函数指针的指针数组。每个包含虚函数的类对象都有一个指向虚函数表的指针(虚表指针),位于对象内存布局的最前面。通过取对象头 4 字节(对于 32 位系统而言)的值,可以获取虚表指针的值,从而访问虚函数表。
void PrintVTable(VFPTR vTable[])
{cout << "虚表地址: " << vTable << endl;for (int i = 0; vTable[i] != nullptr; ++i) {printf("索引%d → 地址:0X%p → 执行结果:", i, vTable[i]);vTable[i](); // 通过函数指针调用虚函数}
}int main()
{Base b;VFPTR* vTable = (VFPTR*)(*(int*)&b);PrintVTable(vTable);Derive d;// 关键内存操作步骤:// 1. 取对象地址 → 2. 强转为int* → 3. 解引用得vptr → 4. 转为虚表指针vTable = (VFPTR*)(*(int*)&d);PrintVTable(vTable);return 0;
}
3. 多继承中的虚函数表
在多继承中,派生类对象会包含多个基类子对象。每个基类子对象都有自己独立的虚函数表。对于未被派生类重写的虚函数,它们会被放在第一个继承基类部分的虚函数表中。
class Base1
{
public:virtual void func1() { cout << "Base1::func1" << endl; }virtual void func2() { cout << "Base1::func2" << endl; }
private:int b1;
};class Base2
{
public:virtual void func1() { cout << "Base2::func1" << endl; }virtual void func2() { cout << "Base2::func2" << endl; }
private:int b2;
};class Derive : public Base1, public Base2
{
public:// 重写两个基类的func1virtual void func1() override { cout << "Derive::func1" << endl; }// 新增虚函数virtual void func3() { cout << "Derive::func3" << endl; }
private:int d1;
};
例如,在上述代码中,派生类 Derive 有两个基类 Base1 和 Base2。Derive 自己重写了 func1,并新增了 func3。对于 Base1 的 func2 和 Base2 的 func1、func2 这些未被重写的虚函数,它们会分别放置在对应的基类子对象的虚函数表中。其中,Base1 的 func2 位于 Base1 子对象的虚函数表中,而 Base2 的 func1、func2 位于 Base2 子对象的虚函数表中。
观察上图中的监视窗口中我们发现还是看不见func3,和上面的单继承一样,也有2种解决方法。
1、使用内存监视窗口
2、内存探查法
typedef void(*VFPTR) ();
void PrintVTable(VFPTR vTable[])
{std::cout << " 虚表地址>" << vTable << std::endl;for (int i = 0; vTable[i] != nullptr; ++i){printf(" 第%d个虚函数地址 :0X%x,->", i+1, vTable[i]);VFPTR f = vTable[i];f(); // 调用虚函数,可看出具体是哪个函数}std::cout << std::endl;
}int main()
{Derive d;// 第一个基类虚表VFPTR* vTableb1 = (VFPTR*)(*(int*)&d); // Base1 子对象的虚表指针在派生类对象最前面PrintVTable(vTableb1); // 第二个基类虚表访问方式:// 1. 将对象地址转为char*进行字节级运算// 2. 偏移Base1的大小得到Base2子对象起始位置VFPTR* vTableb2 = (VFPTR*)(*(int*)((char*)&d + sizeof(Base1))); // Base2 子对象的虚表指针在 Base1 子对象之后PrintVTable(vTableb2);
}
4. 菱形虚拟继承中的虚函数表
class A {
public:virtual void funcA() { cout << "A::funcA" << endl; }
private:int _a;
};class B : virtual public A { // 虚拟继承
public:virtual void funcA() override { cout << "B::funcA" << endl; }virtual void funcB() { cout << "B::funcB" << endl; }
private:int _b;
};class C : virtual public A {
public:virtual void funcA() override { cout << "C::funcA" << endl; }virtual void funcC() { cout << "C::funcC" << endl; }
private:int _c;
};class D : public B, public C {
public:virtual void funcA() override { cout << "D::funcA" << endl; }virtual void funcD() { cout << "D::funcD" << endl; }
private:int _d;
};
各类型内存布局对比 :
类类型 | 典型内存组成 |
---|---|
A | vptr → A虚表 + _a |
B | vptr → B虚表 + vbptr + _b + (A成员) |
C | vptr → C虚表 + vbptr + _c + (A成员) |
D | B部分 + C部分 + _d + A部分 |
A 类对象 :包含一个虚表指针(指向存储 A 类虚函数 funcA 地址的虚表)和成员变量 _a。
B 类对象 :由于是虚拟继承 A 类,A 类继承下来的成员被放到最后。B 类对象有虚表指针(指向存储 B 类虚函数 funcB 地址的虚表)、虚基表指针(虚基表中存储两个偏移量,第一个是虚基表指针距离 B 虚表指针的偏移量,第二个是虚基表指针距离虚基类 A 的偏移量)和成员变量 _b。
C 类对象 :与 B 类对象类似,也是虚拟继承 A 类,最后放置 A 类继承下来的成员。C 类对象包含虚表指针(指向存储 C 类虚函数 funcC 地址的虚表)、虚基表指针(虚基表中存储两个偏移量,第一个是虚基表指针距离 C 虚表指针的偏移量,第二个是虚基表指针距离虚基类 A 的偏移量)和成员变量 _c。
D 类对象 :D 类对象较为复杂。A 类继承下来的成员被放到最后。D 类对象包含从 B 类继承的成员、从 C 类继承的成员和成员变量 _d。D 类对象中虚函数 funcD 的地址存储到了 B 类的虚表当中。
虚拟继承主要是为了解决多继承中的重复继承问题,确保所有继承自同一基类的派生类共享该基类的一个实例。在虚拟继承中,虚基类相关内容在派生类对象中的位置比较特殊,通常被放置在最后,同时会引入虚基表来记录相关偏移量信息,以便正确访问虚基类的成员。
菱形虚拟继承结构下,各个类的虚函数表相互关联又各自独立。B 类和 C 类分别有自己的虚函数表,存储各自新增的虚函数(funcB 和 funcC)。而 D 类在继承 B 和 C 的基础上,覆盖了 funcA 并新增了 funcD。D 类对象的结构使得访问各个虚函数时,需要根据不同的路径找到对应的虚表,进而调用正确的虚函数。
实际开发中不建议设计菱形继承及菱形虚拟继承。一方面,其结构复杂,容易导致代码难以理解和维护,开发人员稍有不慎就可能引发各种继承冲突和访问错误等问题。另一方面,这种模型在访问基类成员时有一定的性能损耗,因为需要通过虚基表等额外的信息来确定正确的访问路径,增加了程序的运行开销。
二、继承与多态常见面试题
1. 选择题
1. 下面哪种面向对象的方法可以让你变得富有( )
A: 继承 B: 封装 C: 多态 D: 抽象
答案:A
- 解析:继承允许子类复用父类的代码,提高开发效率,题目中的“变得富有”是一个隐喻,强调代码复用带来的效率提升。
2. ( )是面向对象程序设计语言中的一种机制。这种机制实现了方法的定义与具体的对象无关,而对方法的调用则可以关联于具体的对象。
A: 继承 B: 模板 C: 对象的自身引用 D: 动态绑定
答案:D
- 解析:动态绑定(多态)在运行时根据对象类型确定调用的方法,方法定义与对象解耦。
3. 面向对象设计中的继承和组合,下面说法错误的是?()
A:继承允许我们覆盖重写父类的实现细节,父类的实现对于子类是可见的,是一种静态复用,也称为白盒复用
B:组合的对象不需要关心各自的实现细节,之间的关系是在运行时候才确定的,是一种动态复用,也称为黑盒复用
C:优先使用继承,而不是组合,是面向对象设计的第二原则
D:继承可以使子类能自动继承父类的接口,但在设计模式中认为这是一种破坏了父类的封装性的表现
答案:C
- 解析:面向对象设计原则提倡“组合优于继承”,继承会破坏封装性,组合更灵活。
4. 以下关于纯虚函数的说法,正确的是( )
A:声明纯虚函数的类不能实例化对象 B:声明纯虚函数的类是虚基类
C:子类必须实现基类的纯虚函数 D:纯虚函数必须是空函数
答案:A
- 解析:
- A 选项:正确,含有纯虚函数的类是抽象类,不能实例化对象。
- B 选项:错误,声明纯虚函数的类不一定是虚基类,虚基类是为解决多继承中重复继承问题而引入的概念,与纯虚函数无必然关联。
- C 选项:错误,子类不一定必须实现基类的纯虚函数,若子类未实现该纯虚函数,则子类也是抽象类,不能实例化对象。
- D 选项:错误,纯虚函数可以不是空函数,可有函数体,只是需在派生类中重写实现。
5. 关于虚函数的描述正确的是( )
A:派生类的虚函数与基类的虚函数具有不同的参数个数和类型 B:内联函数不能是虚函数
C:派生类必须重新定义基类的虚函数 D:虚函数可以是一个static型的函数
答案:B
- 解析:
- A 选项:错误,派生类的虚函数与基类虚函数参数个数和类型应一致,否则不是覆盖重写,而是隐藏基类函数。
- B 选项:正确,内联函数在编译时展开,而虚函数需动态绑定,调用时查虚表,编译无法确定具体调用哪个函数实现,所以内联函数不能是虚函数。
- C 选项:错误,派生类不是必须重新定义基类虚函数,若不重写,则调用基类虚函数实现。
- D 选项:错误,虚函数不能是 static 型函数,static 成员函数属类而非对象,无 this 指针,无法通过对象调用,而虚函数需依托对象动态绑定调用。
6. 关于虚表说法正确的是( )
A:一个类只能有一张虚表
B:基类中有虚函数,如果子类中没有重写基类的虚函数,此时子类与基类共用同一张虚表
C:虚表是在运行期间动态生成的
D:一个类的不同对象共享该类的虚表
答案:D
- 解析:
- A 选项:错误,一个类可有多个虚表,若类中存在多个继承关系中的虚函数,每个基类的虚函数可能对应不同虚表。
- B 选项:错误,基类虚函数被子类重写时,子类会有自己的虚表,不会与基类共用同一张虚表。
- C 选项:错误,虚表在编译期间就生成,非运行时动态生成。
- D 选项:正确,一个类的不同对象共享该类的虚表,因为虚表是类的特性,而非单个对象所独有。
7. 假设A类中有虚函数,B继承自A,B重写A中的虚函数,也没有定义任何虚函数,则( )
A:A类对象的前4个字节存储虚表地址,B类对象前4个字节不是虚表地址
B:A类对象和B类对象前4个字节存储的都是虚基表的地址
C:A类对象和B类对象前4个字节存储的虚表地址相同
D:A类和B类虚表中虚函数个数相同,但A类和B类使用的不是同一张虚表
答案:D
- 解析:A 类和 B 类虚表中虚函数个数相同(都包含被重写的那个虚函数),但由于 B 类重写了 A 类的虚函数,所以 A 类和 B 类使用不同虚表,B 类虚表中的对应虚函数指向自身重写后的实现,故不是同一张虚表。
8. 下面程序输出结果是什么? ()
#include<iostream>
using namespace std;class A
{
public:A(char *s) { cout<<s<<endl; }~A(){}
};class B:virtual public A
{
public:B(char *s1,char*s2):A(s1) { cout<<s2<<endl; }
};class C:virtual public A
{
public:C(char *s1,char*s2):A(s1) { cout<<s2<<endl; }
};class D:public B,public C {
public:D(char *s1,char *s2,char *s3,char *s4):B(s1,s2),C(s1,s3),A(s1) { cout<<s4<<endl;}
};int main()
{D *p=new D("class A","class B","class C","class D");delete p;return 0;
}
A:class A class B class C class D
B:class D class B class C class A
C:class D class C class B class A
D:class A class C class B class D
答案:A
- 解析:
程序执行过程如下:
- 创建 D 类对象,执行构造函数时,因 D 继承自 B 和 C,而 B、C 又虚继承自 A,所以首先初始化虚基类 A,输出 "class A";
- 接着按照继承顺序,先构造 B 部分,输出 "class B";
- 再构造 C 部分,输出 "class C";
- 最后执行 D 自身构造函数,输出 "class D"。
所以输出顺序是 "class A class B class C class D",对应选项 A。
9. 多继承中指针偏移问题?下面说法正确的是( )
class Base1 { public: int _b1; };
class Base2 { public: int _b2; };
class Derive : public Base1, public Base2 { public: int _d; };int main()
{Derive d;Base1* p1 = &d;Base2* p2 = &d;Derive* p3 = &d;return 0;
}
A:p1 == p2 == p3
B:p1 < p2 < p3
C:p1 == p3 != p2
D:p1 != p2 != p3
答案:C
- 解析:
在多继承中,对象的内存布局通常是按继承顺序排列基类部分,再是派生类自身成员。对于 Derive 类对象 d:
p1 指向 Base1 部分,即对象起始地址。
p2 指向 Base2 部分,位于 Base1 部分之后,所以 p2 的地址大于 p1。
p3 是 Derive 类型指针,指向整个对象,其值与 p1 相同(因 Base1 在对象最前面),所以 p1 == p3 != p2。
10. 以下程序输出结果是什么()
class A
{
public:virtual void func(int val = 1) { std::cout<<"A->"<< val <<std::endl; }virtual void test() { func(); }
};class B : public A
{
public:void func(int val=0) { std::cout<<"B->"<< val <<std::endl; }
};int main()
{B* p = new B;p->test();return 0;
}
A: A->0 B: B->1 C: A->1 D: B->0 E: 编译出错 F: 以上都不正确
答案:B
- 解析:
- A 类中的 func() 函数是虚函数,且在 B 类中重写了 func() 函数,但 B 类中 func() 函数的参数默认值被改为 0。
- 当通过 B 类对象的指针调用 test() 函数时,test() 是 A 类中的函数,在 test() 函数体内调用 func(),此时会发生动态绑定,调用 B 类中重写后的 func() 函数。
- 但由于在 B 类中 func() 函数的参数默认值是 0,而调用时未给参数,所以输出 "B->0"。但仔细看题目代码:当在 B 类中重写 func() 函数时,参数列表中的默认参数值会覆盖基类中的默认值。所以在 test() 函数中调用 func(),即无参数调用,此时会使用 B 类中 func() 函数的默认参数值 0,所以输出 "B->0",对应选项 B。
2. 问答题
1. 什么是多态?
同一操作作用于不同对象时,表现出不同的行为。C++ 中通过虚函数实现运行时多态。
2. 什么是重载、重写(覆盖)、重定义(隐藏)?
- 重载:同一作用域,同名函数参数不同。
重写(覆盖):派生类重写基类虚函数,函数签名(函数名、参数列表、返回值类型(除 const 修饰外))相同。
重定义(隐藏):派生类定义与基类同名函数(参数可不同),隐藏基类函数。
3. 多态的实现原理?
多态的实现原理主要依托虚函数表(
vtable
)和虚表指针(vptr
)。当类中声明虚函数时,编译器为该类生成一个虚函数表,表中存放虚函数的地址。每个类对象包含一个指向虚函数表的指针(vptr
)。当通过基类指针或引用来调用虚函数时,运行时会根据对象的实际类型,通过 vptr 找到对应的虚函数表,进而调用正确的函数实现,实现动态绑定。
4. inline函数可以是虚函数吗?
可以,但编译器会忽略 inline 属性。因为虚函数需要动态绑定,其地址需在运行时通过虚函数表查找确定,无法在编译时确定并进行内联展开,所以即使声明为 inline,也不会真正内联,而是当作普通虚函数处理。
5. 静态成员可以是虚函数吗?
不能。静态成员函数属类而非对象,调用时通过类名 :: 函数名方式,无需借助对象的 this 指针。而虚函数调用依赖对象的虚函数表指针(vptr),静态成员函数无法放入虚函数表,因此不能作为虚函数。
6. 构造函数可以是虚函数吗?
不能。构造函数用于初始化对象,在对象构造过程中,虚函数表指针(vptr)是在构造函数初始化列表阶段才进行初始化。若构造函数是虚函数,在构造阶段无法确定正确的虚函数表,会导致运行时错误。
7. 析构函数可以是虚函数吗?什么场景下析构函数是虚函数?
可以,并且最好把基类的析构函数定义成虚函数。在多态使用场景中,当通过基类指针删除派生类对象时,若基类析构函数不是虚函数,只会调用基类析构函数,导致派生类部分未被正确销毁,引发内存泄漏等问题。将基类析构函数设为虚函数,可确保派生类析构函数被调用,正确释放资源。
8. 对象访问普通函数快还是虚函数更快?
普通函数调用速度更快。对于普通对象直接调用普通函数,编译时即可确定函数地址,调用速度快。而虚函数调用需通过虚函数表查找,存在额外开销。但对于指针或引用调用普通函数时,也需确定实际对象类型再调用,速度可能与虚函数相近,不过通常普通函数调用还是相对较快。
9. 虚函数表是在什么阶段生成的,存在哪的?
虚函数表在编译阶段生成,一般存放在代码段(常量区)。编译器在编译含有虚函数的类时,会为每个类生成对应的虚函数表,并将其存放在程序的代码段中,供运行时使用。
10. C++菱形继承的问题?虚继承的原理?
C++ 菱形继承问题是指多继承中出现的重复继承基类导致的数据重复和构造、析构顺序混乱等问题。虚继承的原理是通过在派生类中引入虚基类指针,使所有间接继承自虚基类的派生类共享同一个虚基类子对象,从而解决重复继承问题,确保每个虚基类在最终派生类中只有一个实例。
11. 什么是抽象类?抽象类的作用?
抽象类是含有至少一个纯虚函数的类。其作用主要有:一是强制派生类重写纯虚函数,确保多态使用时有具体实现;二是体现接口继承关系,抽象类定义一组接口规范,派生类必须遵循并实现这些接口,便于统一管理和多态操作。