文章目录
- 1. 多态的概念和实现
- 1.1 概念
- 1.2 实现
- 1.2.1 协变
- 1.2.2 析构函数
- 1.2.3 子类虚函数不加`virtual`
- 2. C++11 `final`和`override`
- 3.1 `final`
- 3.2 `override`
- 3. 函数重载、重写与隐藏
- 4. 多态的原理
- 5. 抽象类
- 6.单继承和多继承的虚表
- 6.1 单继承
- 6.2 多继承
- 7. 菱形继承的虚表(了解)
- 7.1 菱形继承
- 7.2 菱形虚拟继承
- 8. 多态面试题
1. 多态的概念和实现
1.1 概念
面向对象语言三大特性之一:多态,意思是多种形态,当不同的对象去做同一件事时会有不同的状态/结果
1.2 实现
多态要在继承的关系中实现,需要满足2个条件:
- 子类必须完成父类虚函数的重写
- 必须通过父类的指针/引用去调用虚函数
被virtual
修饰的成员函数叫做虚函数
子类对父类虚函数的重写需要满足三同,即函数名、返回值、参数类型相同,但有三种情况例外
- 协变:返回值可以是其他父子类或自身的指针/引用
- 析构函数
- 子类重写父类虚函数时可以不加
virtual
我们分别对这三点做解释
1.2.1 协变
父子类的返回值可以不相同,但必须是其他父子类后者自身类的指针/引用
1.2.2 析构函数
在继承的章节,我们说析构函数名被统一处理为destructor()
,但没说具体原因,其实是要对析构函数设计多态的原因
在上面的场景中,ptr1和ptr2分别指向动态开辟出来的A对象和B对象;B对象中的一个成员,指向动态开辟出来的内存;将来我们要手动释放ptr1和ptr2指向的内存,于是使用delete ptr1和delete ptr2,但根据结果,没能释放掉B对象,于是导致了内存泄漏的问题
我们希望的是指针指向哪个对象,就delete哪个对象的析构,这就要求我们对析构函数实现多态调用,而多态调用要满足三同,其中函数名已经不满足了,于是编译器就将父子类的析构函数重命名为destructor()
,只要我们将析构函数定义为虚函数,就满足多态了
因此,父子类的析构函数推荐加上virtual
1.2.3 子类虚函数不加virtual
可以看到,子类虚函数可以不加virtual
修饰,此时BuyTicket函数也满足多态,这是因为重写的本质是对父类虚函数实现的重写;用一道例题来更好的理解这点
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: 以上都不正确
2. C++11 final
和override
3.1 final
如果希望一个类不能被继承,有两种方式:
- 将类的构造函数私有化,这是C++98的做法
- 将类进行
final
修饰,被修饰的类叫做最总类,这是C++11的做法
3.2 override
override
关键字用于检查子类的虚函数是否完成父类虚函数的重写
3. 函数重载、重写与隐藏
4. 多态的原理
上述代码的结果出乎我们的意料,为什么A对象的大小是12字节?经过调试,我们发现A对象中多了一个指针_vfptr
直接给出结论,如果类中有虚函数,那么该类实例化出的对象都要有一个指针,我们把该指针称为虚表指针(在构造函数初始化列表中初始化);该指针指向一个虚表,虚表是一个函数指针数组,存放着虚函数的地址
子类对象会将父类的虚函数拷贝到自己的虚表中,并检查是否完成虚函数的重写,如果完成,将虚函数覆盖为自己的虚函数,当使用父类的引用/指针去调用虚函数时,由于发生切片,如果指向父类,去调用父类的虚函数,如果指向子类,去调用子类中父类部分的虚函数,这就是多态的原理
多态调用和正常调用区别:当进行编译时,编译器会检查函数是否满足多态,如果满足,那么会在执行代码时去对象的虚表中找虚函数调用,如果不满足,那么在编译时就已经确定要调用函数的地址
在这里要分清楚几个概念:
- 虚函数不在虚表中,虚表中存放的是虚函数的地址,虚函数存放在常量区(代码段)上
- 虚表存放在常量区上,下面有代码验证
- 虚基表是继承中存放虚基类偏移量的,用来解决菱形继承数据冗余和二义性的问题
5. 抽象类
在虚函数后加上 =0,表示该虚函数是纯虚函数,有纯虚函数的类叫做抽象类
抽象类在现实生活中没有对应的实体,因此无法实例化出对象;继承抽象类的子类同样也无法实例化出对象,这就强制要求我们完成父类虚函数的重写
6.单继承和多继承的虚表
6.1 单继承
运行上述代码,对比监视窗口和内存窗口,父类的func1和func2都继承了下来,重写了func1,于是将父类的func1覆盖;子类自身的虚函数直接往父类部分的虚表中放,监视窗口没显示func3和func4是因为vs监视窗口的设计问题,在内存中是能看到还有两个指针,这两个指针指向的就是func3和func4,用一份代码来证明
6.2 多继承
在多继承中,子类中有两个虚表指针,分别在A类的部分和B类的部分中,A类的部分继承A类的虚函数,B类的部分继承B类的虚函数,再看是否完成重写,有就拿自身的覆盖;自身的虚函数默认往先继承类的虚表中放
为什么要有多继承对象要有两张虚表,不能把所有的虚函数放在一张虚表中吗?有这样的场景,分别有两个父类的指针指向同一个子类对象,通过任何一个父类指针都应该调用子类的虚函数,但如果只有一张虚表,就变成只有其中一个父类指针能调用子类的虚函数
7. 菱形继承的虚表(了解)
7.1 菱形继承
7.2 菱形虚拟继承
8. 多态面试题
class A
{
public:
A(const char* s)
{
cout << s << endl;
}
};
class B :virtual public A
{
public:
B(const char* s1,const char* s2)
:A(s1)
{
cout << s2 << endl;
}
};
class C :virtual public A
{
public:
C(const char* s1, const char* s2)
:A(s1)
{
cout << s2 << endl;
}
};
class D :public B, public C
{
public:
D(const char* s1, const char* s2, const char* s3, const 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
由此可以证明:初始化列表的初始化顺序是按变量在内存中声明的顺序或类在内存中继承的顺序(先继承的类先初始化);同时同一个对象中,同一个类只会初始化一次
-
inline函数可以是虚函数吗?
可以,但是编译器会忽略inline属性,因为虚函数要放到虚表中
-
静态成员函数可以是虚函数吗?
不可以,静态成员函数属于整个类,无论是通过父类或子类调用,调用的都是同一个静态成员函数;静态成员函数在编译时就已经确定地址,而虚函数要到运行时去虚函数表中找
-
构造函数可以是虚函数吗?
不可以,虚表指针在构造函数的初始化列表中初始化,如果构造函数时虚函数,虚表指针就无法初始化了
-
对象访问普通函数快还是虚函数快?
如果是普通对象,一样快;如果是对象的指针/引用且满足多态,则调用普通函数快,调用虚函数需要在虚表中找
-
虚函数表在什么阶段生成?存在哪里?
虚函数表在编译阶段生成,存放在常量区