本节我们来学习C++中的多态。首先我们得先理解什么是多态:
通俗来讲就是不同的状态。在程序中就是不同对象调用同一个函数最终会有不同的结果。举个例子:同样是抢红包的行为,张三能抢到10元,而李四只能抢到2元,这就是多态的一种体现。
必须是一个继承。
被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写。
用父类的引用或者指针去调用虚函数。
父类和子类都是虚函数。
父类和子类满足三同:函数名相同、参数相同、返回值相同。
#include
using namespace std;
class Person {
public:
virtual void BuyTicket() { cout << "Person-买票-全价" << endl; }
};
class Student:public Person
{
public:
virtual void BuyTicket() { cout << "Student-买票-全价" << endl; }
};
class Soilder :public Person
{
public:
virtual void BuyTicket() { cout << "Soilder买票-优先买票" << endl; }
};
void Func(Person& p)
{
p.BuyTicket();
}
int main()
{
Person a;
Student b;
Soilder c;
Func(a);
Func(b);
Func(c);
return 0;
}
代码的运行结果:
普通调用:和调用的对象类型有关。
多态调用:父类引用或者指针的指向有关。
当我们把子类的virtual删除掉会发现依旧能实现多态:
原因:
子类会把父类的virtual继承下来,进行函数重写可以理解为是将函数实现重写其他部分保持不变。
这样的设计其实比太好,我们提倡子类的函数都加上virtual。
在三同的前提下返回值可以不同,可以是父类或者子类的指针/引用。
我们正常写析构函数是这样写的:
class Person {
public:
~Person()
{
cout << "~Person():" << _p << endl;
delete[] _p;
}
private:
int* _p = new int[10];
};
class Student :public Person
{
public:
~Student()
{
cout << "~Stuent():" << _s << endl;
delete[] _s;
}
private:
int* _s = new int[20];
};
int main()
{
Person p;
Student s;
return 0;
}
运行结果正确:
现在我们不动Person和Student类的代码,我们修改main函数中的代码:
此时我们发现并没有调用Student的析构函数,造成了内存泄漏!造成内存泄漏的原因:
delete会通过指针来调用析构函数,因为是普通调用,关注的是调用对象的类型。两个指针都是Person*类型,所以只会调用关于Person的析构函数。那么如何解决内存泄露的问题呢?
解决办法:
我们可以在父类的析构函数面前加上virtual关键字。由于多态的需求,析构函数的函数名会被统一处理成destructor,因此所有析构的函数名相同。这样既满足了三同又是虚函数,就实现了父类和子类的函数重写。当代码运行到delete时,正好是通过父类的指针调用析构函数,满足了多态的所有需求,因此这是一次多态调用。多态调用和普通调用的区别是它关注的是父类指针或者引用指向的对象。因为ptr2
指向的是Student,那么它就会去调用Student的析构函数:
总结:当面试官问你析构函数是虚函数好不好时,你要义正言辞自信的回答说好!因为你不是虚函数就不能实现多态,不能实现多态调用析构函数的时候可能会造成内存泄漏。而你的析构函数是虚函数时怎么都不会错!因此在父类的析构函数前面无脑+virtual我是支持的!
final修饰虚函数表示虚函数不能被重写。
class Person {
public:
virtual ~Person() final
{
cout << "~Person():" << _p << endl;
delete[] _p;
}
private:
int* _p = new int[10];
};
class Student :public Person
{
public:
~Student()
{
cout << "~Stuent():" << _s << endl;
delete[] _s;
}
private:
int* _s = new int[20];
};
运行结果:
2.被final修饰过了类不能被继承。
那还有什么方法可以使一个类不能够被继承呢?
可以将类的构造函数设为私有,调用子类时必须调用父类的构造函数,因为是私有成员所以在类外面调用不了,因此类不够被继承。就算使用new也不行,调用new也需要调用构造函数初始化:
class A
{
private:
A()
{
}
};
class B :public A
{
};
int main()
{
B b;
return 0;
}
运行结果:
override是用来检查是否重写了虚函数,如果没有重写编译报错。
class Car{
public:
virtual void Drive(){}
};
class Benz :public Car {
public:
virtual void Drive() override {cout << "Benz-舒适" << endl;}
}
在虚函数的后面写上=0,我们称这个函数为纯虚函数。包含纯虚函数的类我们称为抽象类。
不能实例出对象,继承下来的子类需要重写虚函数才能实例化出对象。
可以看出Car不能被实例化,而它的子类可以(重写了虚函数)。纯虚函数强制了它的子类必须重写虚函数,因为不能被实例化的类是没有意义的。
对于普通类的继承我们称它为实现继承,因为子类继承了父类的所有实现。
虚函数的继承我们称它为接口继承,派生类继承的是基类的接口,目的是为了重写,继承的是接口。
如果不需要实现多态就不推荐写虚函数。接下来我们通过一道题再次理解一下:
10. 以下程序输出结果是什么()
class A
{
public:
virtual void func(int val = 1){ std::cout<<"A->"<< val <"<< val <test();
return 0;
}
A: A->0 B: B->1 C: A->1 D: B->0 E: 编译出错 F: 以上都不正确
答案选B。这道题非常的坑需要深层次的理解刚刚讲的接口继承。有一个指向B类型的指针p,p调用B类型的test()函数,test函数又调用了func()函数,this->func()中的this指针是A*,因为test函数在A类中。因为func构成虚函数满足三同(和缺省值相不相同无关)完成了虚函数的重写并且是用父类的指针调用的虚函数,满足所有多态的条件。p指针指向的对象是B,所以会调用B中的虚函数。函数的重写重写的是函数实现的部分,虚函数的继承是接口继承,所以B会把A中的接口继承下来 ,因此val的值是1,cout打印出来的是B中重写的那部分也即是"B->"。
在弄清楚多态原理之前,我先给大假出一道题,请问运行以下代码的结果是什么?
我们来看结果:
我们估算的结果应该是8(有内存对齐),但答案却是12,多出来的四个字节在哪呢?接下来我们进行调试看看Base内部到底多出来什么:
Base的内部存了一个指针,我们称这个指针为虚表指针。该指针指向一个虚表,虚表的本质是一个函数指针数组,虚表里存放的是一个一个虚函数的地址。在32为机器下,指针的大小为4个字节,所以上面的结果就得到了很好的解释。
那么我在多写一个虚函数,Base的大小是否改变?
答案是不变的,因为指针的大小不会改变,改变的是虚表中存储的指针个数:
那说了这么多,和多态的原理又有什么关系呢?
其实多态的原理也是借助虚表来完成的,子类中的虚表会拷贝一份父类的虚表,继承过后,如果没有函数重写,子类的虚表和父类的虚表一个样。但如果进行了函数重写,虚表对应位置就会覆盖成重写的虚函数。
所以虚函数的重写也称覆盖,这里就是覆盖的一种体现。所以在使用多态的时候,父类的指针指向父类就会在父类的虚表里找对应函数,指向子类就在子类里找对应的函数。接下来我们来看下面的代码:
class Base1
{
public:
int _b1;
virtual void func()
{
cout << "Base1:func" << endl;
}
};
class Derive :public Base1
{
public:
virtual void func()
{
cout << "Derive:func" << endl;
}
virtual void func1()
{
cout << "Derive:func1" << endl;
}
int _d;
};
然后我们通过调试看一看Derive里面存储的数据:
我们发现虚表里存储的只有func的函数指针,func1的函数指针为什么没有被存放呢?这是因为我们使用的调试窗口是被编译器优化过的,我们可以写一个函数来打印出虚表里面的内容:
typedef void* (*st)();
void Printable(st arr[])
{
for (int i = 0; arr[i] != nullptr; ++i)
{
printf("[%d]:%p\n", i, arr[i]);
arr[i]();
}
cout << endl;
}
int main()
{
Derive d;
Printable((st*)*((int*)(&d)));
}
代码的运行结果:
由此可以看出只要是虚函数都会存放在虚表中, 只是调试中的监视窗口没有显示出来。现在问一个问题:有没有一种方法既能适应32位机器,又能适应64位机器?
因为想要打印出虚表就要取对象的第一个指针大小,而在64位机器下指针的大小是8个字节,32位机器下指针的大小位4个字节,所以我们可以使用这种方法:
因为是二级指针,解引用后是一级指针,所以看到的是一个指针的大小,同时适应32、64位机器。
静态绑定又称为前期绑定,在编译期间就确定程序的行为,也称静态多态。例如:函数重载。
动态绑定是又称后期绑定,在运行期间根据程序具体拿到的类型来确定程序的具体行为,调用具体的函数,也称动态多态。
所以普通调用是静态绑定,多态调用是动态绑定。
接下来我们思考一个问题,这个虚表是存放在哪的呢?有什么办法知道它的存放位置呢?接下来我来写一段代码来解决这个问题:
class Base {
public:
virtual void func1() { cout << "Base::func1" << endl; }
virtual void func2() { cout << "Base::func2" << endl; }
private:
int a;
char b;
};
class Derive :public Base {
public:
virtual void func1() { cout << "Derive::func1" << endl; }
virtual void func3() { cout << "Derive::func3" << endl; }
private:
int b;
};
int main()
{
int a = 0;
cout << "栈:" << &a << endl;
int* p1 = new int;
cout << "堆:" << p1 << endl;
const char* str = "hello world";
cout << "代码段/常量区:" << (void*)str << endl;
static int b = 0;
cout << "静态区/数据段:" << &b << endl;
Base be;
cout << "虚表:" << (void*)*((int*)&be) << endl;
Derive de;
cout << "虚表:" << (void*)*((int*)&de) << endl;
return 0;
}
运行结果:
此时我们发现虚表的地址最靠近代码段/常量区,因此答案就非常明确了。
可是获得虚表的地址为什么是这样的一句代码:
(void*)*((int*)&be)
我们通过调试可以发现虚表指针存放在类中第一位,所以我们只要取到类开始的前四个字节就行。
&be表示指针指向整个类,强转成int*指向的就是类中的前四个字节,在*解引用就可以拿到相应的地址,但解引用后的数据类型是int类型,使用cout打印时会将地址转换成int类型打印出来,所以我们在代码的最前面加上(void*)让它原封不动以地址的形式打印。
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:
virtual void func1() { cout << "Derive::func1" << endl; }
virtual void func3() { cout << "Derive::func3" << endl; }
private:
int d1;
};
我们通过监视窗口看看Derive的内部有什么:
可见Derive继承了两个虚表,并且每个虚表中的func1都是被重写过的,但是没有被重写的func3存储在哪里了呢?我们通过打印虚表来请确认位置:
因为打印第二个虚表需要进行指针的偏移,我们可以用切片的方法也可以用指针的加法,但是用指针的加法需要将&d强制转换为char*进行偏移。
总结:子类中没有被继承的虚函数存放在第一个继承的虚表里面。
实际中我们不建议设计出菱形继承及菱形虚拟继承,一方面太复杂容易出问题,另一方面这样的
模型,访问基类成员有一定得性能损耗。所以菱形继承、菱形虚拟继承我们的虚表我们就不看
了,一般我们也不需要研究清楚,因为实际中很少用。
10. 以下程序输出结果是什么()
using namespace std;
class A{
public:
A(char *s) { cout<
首先我们得先知道,函数分为函数体和初始化列表部分,先走完初始化列表部分后再走函数体部分。所以我们可以先排除B、C,因为D必定是最后一个打印出来的。其次我们得先知道初始化列表中初始化的顺序。如果是继承关系,谁先被继承谁先被初始化。如果不是继承关系,谁先被定义谁就先初始化。因为这是个菱形虚拟继承所以A是被所有的类所共有的一份,对于A的初始化编译器会认为B或者C来初始化不太好,因为到后面可能会被其他类修改,所以用D初始化最好。因此A的初始化在代码的22行。因为声明的顺序是ABCD,所以答案选A。我们将上一道题再进行修改:
using namespace std;
class A{
public:
A(char *s) { cout<
我们将菱形虚拟继承改成了菱形继承,这时初始化列表中初始化的顺序是BC最后是D。因为B和C中都有对A的初始化,所以最终的结果为:ABACD。
问答题:
1. 什么是多态?答:参考本节课件内容
2. 什么是重载、重写(覆盖)、重定义(隐藏)?答:参考本节课件内容
3. 多态的实现原理?答:参考本节课件内容
4. inline函数可以是虚函数吗?答:可以,不过编译器就忽略inline属性,这个函数就不再是
inline,因为虚函数要放到虚表中去。
5. 静态成员可以是虚函数吗?答:不能,因为静态成员函数没有this指针,使用类型::成员函数
的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表。
6. 构造函数可以是虚函数吗?答:不能,因为对象中的虚函数表指针是在构造函数初始化列表
阶段才初始化的。
7. 析构函数可以是虚函数吗?什么场景下析构函数是虚函数?答:可以,并且最好把基类的析
构函数定义成虚函数。参考本节课件内容
8. 对象访问普通函数快还是虚函数更快?答:首先如果是普通对象,是一样快的。如果是指针
对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函
数表中去查找。
9. 虚函数表是在什么阶段生成的,存在哪的?答:虚函数表是在编译阶段就生成的,一般情况
下存在代码段(常量区)的。
10. C++菱形继承的问题?虚继承的原理?答:参考继承课件。注意这里不要把虚函数表和虚基
表搞混了。
11. 什么是抽象类?抽象类的作用?答:参考(3.抽象类)。抽象类强制重写了虚函数,另外抽
象类体现出了接口继承关系。