多态的概念
多态的概念:通俗来说,就是多种形态, 具体点就是去完成某个行为,当不同的对象去完成时会产生出不同 的状态 。
举个简单的例子,比如我们去买票,普通人去买就是全价买票,学生去就是半价学生票,这就是两种不同的对象去调用买票这个函数时得到的不同结果,又称多态
多态的定义及实现
多态的构成条件
多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。比如 Student 继承了 Person 。Person 对象买票全价, Student 对象买票半价。那么在继承中要 构成多态还有两个条件 :1. 必须通过基类的指针或者引用调用虚函数2. 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写
我们可以看到,在Person与Student中,都有virtual修饰的BuyTicket函数,其实是子类对父类中的BuyTicket进行了重写,而我们在父类的Fun函数中也用父类的引用调用了我们的虚函数,完成了多态的实现
虚函数
虚函数:即被 virtual 修饰的类成员函数称为虚函数
虚函数的重写
虚函数的重写 ( 覆盖 ) : 派生类中有一个跟基类完全相同的虚函数 ( 即派生类虚函数与基类虚函数的返回值类 型、函数名字、参数列表完全相同 ) ,称子类的虚函数重写了基类的虚函数。
也就是上图我们的BuyTicket函数,他子类中有一个与父类除过函数体完全一致的虚函数,这就称为子类重写了父类的虚函数
多态的实现
我们来看这段代码,在我们多态调用中,Func函数的型参类型为Person,但实际上在我们多态中,谁去调就匹配谁的类型,与这里写的无关
我们再来演示非多态的情况
我们将多态去除了之后,调用的就都是Person类型了,将指针或者引用去除也是一样的
虚函数重写的两个例外
1. 协变 ( 基类与派生类虚函数返回值类型不同 )派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。
我们在这里改变了两个虚函数的返回值,分别改成了各自(这里只要是一组满足父子关系的类就可以)的指针(引用也可以),这样也是满足多态的。注意:普通类型不行
2.析构函数的重写 ( 基类与派生类析构函数的名字不同 )如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加 virtual 关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同。虽然函数名不相同,看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor。
其实我们不止析构函数,当我们的父类是虚函数,而子类不是虚函数的时候,其实也是可以的,只是这样写并不规范,不建议
还有一种就是析构函数的重写
我们可以看到,上面我们没有对析构函数加virtual,在分别创建Person与Student之后,我们发现,在结束时并没有对Studnet进行析构,实际对于p2的过程却是先调用Person,再完成对Student的调用,析构时未对Student进行析构,只对Person进行析构,这就会引起内存泄漏,所以我们需要加上virtual,构成多态,完成析构函数的重写
这样就加上了对Student的析构,其实我们会有一些疑问,析构函数的函数名也不相同啊,怎么就重写了呢,事实上,编译器在编译后会将析构函数名称都转换为destructor,所以是可以构成重写的
这里我们显示一道题
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(int argc ,char* argv[])
{
B*p = new B;
p->test();
return 0; }
A: A->0 B: B->1 C: A->1 D: B->0 E: 编译出错 F: 以上都不正确
这个答案是什么呢?实际上是B,原因是我们的B去继承A时继承了func的返回值,函数名,形参列表,而后再对这个继承过来的函数中的函数体才进行的重写,最后由因为调用的是B中的func,而函数名以及形参列表缺是A的,所以我们得到的结果就是B->1
C++11 override 和 fifinal
从上面可以看出, C++ 对函数重写的要求比较严格,但是有些情况下由于疏忽,可能会导致函数名字母次序写反而无法构成重载,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来debug会得不偿失,因此:C++11 提供了 override 和 fifinal 两个关键字,可以帮助用户检测是否重写
class Car
{
public:
virtual void Drive() final {}
};
class Benz :public Car
{
public:
virtual void Drive() {}
};
当我们在virtual函数后加上了final,他就不能被重写,不满足继承了,会产生报错
//override检查子类的虚函数是否重写了父类的虚函数
class Car{
public:
virtual void Drive(){}
};
class Benz :public Car {
public:
virtual void Drive() override {cout << "Benz-舒适" << endl;}
};
事实上我们如果没有对某个虚函数进行重写,或者我们写的函数并不是重写的,有错误时,此时编译器并不会进行报错,因为它会认为两个函数分别属于不同的类,是可以接受的,但是当我们加上了override关键字时,只要未完成重写,就会报错,想当于加了一个判断闸口
重载、覆盖(重写)、隐藏(重定义)的对比
抽象类
概念在虚函数的后面写上 =0 ,则这个函数为纯虚函数。 包含纯虚函数的类叫做抽象类(也叫接口类),抽象类 不能实例化出对象 。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。
class Car//抽象类无法进行实例化
{
public:
virtual void Drive() = 0;//纯虚函数说明抽象类,不需要实现
};
class Benz :public Car
{
public:
virtual void Drive()//重写虚函数,若不进行重写则无法调用(当子类继承了抽象类后子类也为抽象类,若不进行重写虚函数,则仍无法调用)
{
cout << "Benz-舒适" << endl;
}
};
class BMW :public Car
{
public:
virtual void Drive()//重写虚函数
{
cout << "BMW-操控" << endl;
}
};
void Test()
{
Car* pBenz = new Benz;
pBenz->Drive();
Car* pBMW = new BMW;
pBMW->Drive();
}
接口继承和实现继承
普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。
多态的原理
虚函数表
了解虚函数表之前我们先来看下这段代码
class Base//sizeof(Base)是多少?
{
public:
virtual void Func1()
{
cout << "Func1()" << endl;
}
private:
int _b = 1;
};
当我们以一般类对这个类进行计算时,会得到4的结果,因为类大小=成员变量大小的和(不考虑成员函数,因为成员函数最后都会编译完成放到代码段)
但是因为这个是虚函数,所以在类中还隐藏了一个指向虚函数表的指针,所以正确大小为8
通过观察测试我们发现 b 对象是 8bytes , 除了 _b 成员,还多一个 __vfptr 放在对象的前面 ( 注意有些平台可能会 放到对象的最后面,这个跟平台有关 ) ,对象中的这个指针我们叫做虚函数表指针 (v 代表 virtual , f 代表 function) 。一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表,。那么派生类中这个表放了些什么呢?我们接着往下分析
事实上,我们上面代码的那个指针,叫做虚函数表指针,我们在这里加了一个虚函数,实际内存中虚函数指针是存在一个指针数组中的,第一个函数占[0]位,第二个[1]位,分别存储了每个函数所对应的虚函数表指针
那么虚函数表指针到底有什么作用呢?其实作用就是存储公共区域--虚函数表位置的指针
我们见识过上面的代码拷下来,进行分析,实际上,在我们main函数调用中,分别创建了Person与Student的实例化对象,并且调用函数,我们会发现,用Person类去调用Func函数时,调用的就是Person的BuyTicket,访问的就是Person中的虚函数地址,而用Student类去调用Func函数时,编译器就会调Student的虚函数表指针去找Student的虚函数地址,指向谁就去谁的虚表里面去找对应的虚函数
我们可以看到,当我们将虚函数屏蔽掉,使其不构成多态,没有完成重写时,子类的虚函数表指针和父类是一样的,这其实也说明了重写其实是对于函数的覆盖,当没有重写时,函数从父类中继承下来,是一个函数,而完成重写,则是将父类的函数继承下来,将继承下来的函数覆盖完成重写
总结起来就是,在满足多态时,为了分情况的调用子类与父类中相同的虚函数,我们在每个类中添加了一个虚函数表指针,在父类调用函数时,去父类中找父类的虚函数表指针,根据虚函数表指针的地址找到父类的虚函数并且调用,子类同理
这是在汇编中,多态底层实现的代码
我们对这一过程再进行复盘
我们可以看到,我们的子类对Func1函数进行了重写,所以Func1在子父类中虚函数地址不同了,Func为进行重写,所以子类的Func2就是从父类上继承下来的,地址相同,Func3非虚函数,所以不进行指针存储,编译时通过类型确定地址
我们再来看几个问题
1.虚函数在哪里?在代码段,它与其它函数一样,在编译后都变成指令被存在了代码段。误区:虚函数不是存在虚表,虚表中存的是虚函数的指针
2.虚函数表存在哪?也在代码段,因为我们同类型的对象是公用一个虚表的,所以无法存入栈,因为可能会不断复用的缘故,放入代码段中最合适
我们怎么确定的呢?
动态绑定与静态绑定
1. 静态绑定又称为前期绑定 ( 早绑定 ) , 在程序编译期间确定了程序的行为 , 也称为静态多态 ,比如:函数重载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() { cout << "Derive::func1" << endl; }
virtual void func3() { cout << "Derive::func3" << endl; }
virtual void func4() { cout << "Derive::func4" << endl; }
private:
int b;
};
//void(*p)()定义一个函数指针变量
typedef void(*VF_PTR)();//函数指针类型typedef
//打印虚表->虚表本质是一个虚函数指针数组
void PrintVFTable(VF_PTR* pTable)
{
for (size_t i = 0; pTable[i] != 0; ++i)
{
printf("vfTable[%d]:%p->", i, pTable[i]);
VF_PTR f = pTable[i];
f();
}
}
int main()
{
void(*p)();
Base b;
Derive d;
//取对象中前四个字节存的虚表指针
PrintVFTable((VF_PTR*)(*(int*)&b));
cout << endl;
PrintVFTable((VF_PTR*)(*(int*)&d));
system("pause");
return 0;
}
// 思路:取出b、d对象的头4bytes,就是虚表的指针,前面我们说了虚函数表本质是一个存虚函数指
// 1.先取b的地址,强转成一个int*的指针
// 2.再解引用取值,就取到了b对象头4bytes的值,这个值就是指向虚表的指针
// 3.再强转成VFPTR*,因为虚表就是一个存VFPTR类型(虚函数指针类型)的数组。
// 4.虚表指针传递给PrintVTable进行打印虚表
// 5.需要说明的是这个打印虚表的代码经常会崩溃,因为编译器有时对虚表的处理不干净,虚表最后面
没有放nullptr,导致越界,这是编译器的问题。我们只需要点目录栏的-生成-清理解决方案,再编译就好
了。
我们将虚函数表打印了出来,这是我们的运行结果,我们可以看到的是,B的func1与fun2,D覆盖B的func1,D继承B的func2,以及自己的func3,func4
多继承中的虚函数表
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;
};
//void(*p)(); // 定义一个函数指针变量
typedef void(*VF_PTR)(); // 函数指针类型typedef
// 打印虚表->虚表本质是一个虚函数指针数组
//void PrintVFTable(VF_PTR* pTable)
void PrintVFTable(VF_PTR pTable[])
{
for (size_t i = 0; pTable[i] != 0; ++i)
{
printf("vfTable[%d]:%p->", i, pTable[i]);
VF_PTR f = pTable[i];
f();
}
cout << endl;
}
int main()
{
cout << sizeof(Derive) << endl;
Derive d;
cout << sizeof(Base1) << endl;
PrintVFTable((VF_PTR*)(*(int*)&d));
PrintVFTable((VF_PTR*)(*(int*)((char*)&d + sizeof(Base1))));
system("pause");
return 0;
}
我们先来问一个问题,d的大小是多少?这里我们直接进入调试窗口
我们会发现,在d中,包含了B1与B2的两份虚表,所以大小为16+自己的4=20
我们可以发现,我们的Base2虚表中是没有func3的,说明如果是多继承,自己的func3是往第一个虚表中放的,也说明了先继承的放在前面
菱形继承、菱形虚拟继承
实际中我们不建议设计出菱形继承及菱形虚拟继承,一方面太复杂容易出问题,另一方面这样的模型,访问基类成员有一定得性能损耗。所以菱形继承、菱形虚拟继承我们的虚表我们就不看了,一般我们也不需要研究清楚,因为实际中很少用。
再次回顾:
//虚函数:->概念:虚函数重写是多态的条件之一
// ->多态原理:虚函数地址放到对象的虚表(虚函数表)中,多态指向谁,调用本质是运行到对象虚表中找到要调用的虚函数
//2.虚继承. ->概念:解决菱形继承中的数据冗余和二义性
// ->原理:将虚基类对象放到公共位置(vs放到整个对象尾部),虚基类中存偏移量,来计算虚基类对象的位置
//总结:这里两个地方都用了virtual关键字,但它们之间没有关联,不要联系到一起
class A
{
public:
int a;
};
class B : public A
{
public:
int b;
};
class C : public A
{
public:
int c;
};
class D : public B, public C
{
public:
int d;
};
我们将内存图调出来,发现我们赋值了两次a,在内存中两块地方分别存储,这体现了数据的冗余,我们调用的是同一个a,但是却可以有两个被赋的值,这体现了数据的二义性
我们解决这个问题的方式就是虚继承
class A
{
public:
int a;
};
class B : virtual public A
{
public:
int b;
};
class C : virtual public A
{
public:
int c;
};
class D : public B, public C
{
public:
int d;
};
在我们的继承语句中加入virtual关键字,代表了其虚继承的关系
此时我们的a就被单放到了一个公共的区域,不会再放在B或者C中,而是存在整个数据段的最下方内存,我们的B,C访问时就会访问这个公共的a,我们在虚继承中将A通常叫做虚基类
那么我们的B,C类又是如何去找到这个a的呢?取代开辟它的就是在我们的B,C中会分别创建一个虚基表,虚基表中存的是偏移量(找到那个公共的a所需要的内存偏移大小),用来计算虚基类对象的位置(其实这个位置直接存所需对象的地址也可以,不过我们的编译器选择的是存储偏移量)