接上篇,我们了解完了继承后,这次我们来学习一下关于多态的知识点。
多态
概念:
1.什么是多态?简单来讲就是多种形态。具体点就是完成某种行为,不同对象完成这个事时,会产生不同的形态。(不同对象传递过去,调用不同的函数)
用生活中的例子也就是:买票时,分为学生票和成人票等等。而当学生去买票的时候,会有打7折的优惠,而买成年人票需要全价,军人买票时会有优先级,这几种情况都是买票,但是会产生不同的形态状况,这也是符合多态的概念的。
2.多态调用看的是指向的对象,而普通对象看的是当前类型。
多态形成的条件:
1.因为多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。那么,在继承的基础上,要构成多态的条件还需要:
1)必须通过基类的指针或引用来调用虚函数。
2)被调用的的函数必须是虚函数,并且派生类对基类的虚函数必须构成重写。
了解完了多态的条件后,我们再具体对上面的条件的新概念进行分析:
什么是虚函数?
1.即被virtual修饰的类成员函数称为虚函数(这个跟继承里的虚继承用同一个关键词virtual)。
2.ps:只有类的成员函数才可变虚函数
虚函数的重写(也叫覆盖):
派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了基类的虚函数
ps:必须是在虚函数的基础上
ps:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写(因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议
class People
{
public:
virtual void BuyTicket()
{
cout << "全价购票" << endl;
}
};
class Student:public People
{
public:
virtual void BuyTicket()
{
cout << "半价购票" << endl;
}
};
void Func(People& people)
{
people.BuyTicket();
}
void func2(people* p)
{
p->buyticket();
}
int main()
{
Student Bai;
Func(Bai);
People Zhang;
Func(Zhang);
return 0;
}
虚函数重写的两个例外:
1.协变:
即派生类的虚函数与基类的返回类型不一样
派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用不同时
ps:简单来讲:协变,返回的值可以不同,但要求返回的类型必须是父子关系的指针或引用
class A {}; class B:public A {}; class People { public: virtual A* Func() { } }; class Student:public People { public: virtual B* Func() { } };
2.构函数的重写ps:(基类与派生类析构函数的名字不同)
我们在继承里面有说到过:
讲解:如果基类的析构函数是虚函数,那么派生类的析构函数只要定义了,无论它是否有写virtual,它都会与基类的析构函数进行重写。这时,有人可能会疑惑:它函数名都不相同,这不是不符合重写的条件吗,为什么它会构成重写?没错,这里看似违背了重写的定义,但是实则是编译器对析构函数的名称进行了统一的处理。编译后,编译器会统一处理destructor
那么现在我们来证明一下:上面说的是不是真就这样?
class Person { public: virtual void BuyTicket() { cout << "买票-全价" << endl; } virtual ~Person() { cout << "~Person()" << endl; } }; class Student : public Person { public: virtual void BuyTicket() { cout << "买票-半价" << endl; } virtual ~Student() { cout << "~Student()" << endl; } }; int main() { Person* a = new Person; a->BuyTicket(); Person* b = new Student; b->BuyTicket(); delete a; delete b; return 0; }
我们来看看析构函数不是虚函数的情况是怎么样子的?
解释本意:让a去调用基类的析构函数,b先去调用子类再去调用基类的析构函数。但是经过下图我们发现,a并没有调用子类的析构函数。这就可能造成内存泄漏。
这是因为这里构成了隐藏(重定义)
~Person变为this->destructor() ~Student变为this->destructor()
编译器将它们两个的函数名统一变成destructor,因此调用的时候就只会看自身的类型,即Person就调用Person的析构函数,Student就调用Student的析构函数,它们并没有构成多态,所以不会再去调用 另一个。
从汇编角度观察:
相同的
从上面我们可以看到,确实跟我们上面所说的:编译器会统一将析构函数的虚函数名称变成destructor,没错,是正确的!
那么,我们来想想:编译器为什么要这么做?有什么作用呢?
1.destructor实质上是(~加上类名)
2.它可以保证对象的正确销毁:在多态的情景中,通过基类的指针删除派生类的对象时,如果析构函数不是虚函数且名称不统一,可能会导致只调用析构函数,而派生类的部分资源未能释放,造成内存泄漏问题。统一为特定的析构函数名称后,并将基类析构函数定义为虚函数,就能确保在删除对象时,会根据对象的具体实际类型调用正确的析构函数:从派生类到基类,确保了对象资源的正确释放。
class Person { public: virtual void BuyTicket() { cout << "买票-全价" << endl; } virtual ~Person() { cout << "~Person()" << endl; } }; class Student : public Person { public: virtual void BuyTicket() { cout << "买票-半价" << endl; } ~Student() { cout << "~Student()" << endl; delete[] ptr; } protected: int* ptr = new int[10]; }; int main() { Person* p = new Person; p->BuyTicket(); delete p; p = new Student; p->BuyTicket(); delete p; // p->destructor() + operator delete(p) // 这里我们期望p->destructor()是一个多态调用,而不是普通调用, //因为多态调用看的指向的对象 // 普通对象,看当前者类型 return 0; }
3.符合面向对象编程规范:统一的析构函数名称有利于遵循面向对象编程中封装和多态的原则,它将对象的销毁操作封装在类中,通过虚函数机制实现多态行为,使得代码吗更加容易维护与扩展。
防止出现重写的错误
1.有时候我们可能在想要重写的时候,会因为单词字符的拼写顺序,导致构不成重写,这时候编译器并不会报错,让我们不容易发现,这时候我们就可以借助C++11的两个关键字:
C++11 override 和 final
final:修饰虚函数,表示该虚函数不能再被重写
override:检查派生类虚函数是否重写了基类的某个虚函数,若没有就报错
对比重载,重写,隐藏
抽象类:
1.概念:在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。
2.抽象类有虚表吗?有没有对象,决定有没有虚表。
接口继承与实现继承
普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。
多态的原理
1.什么是虚函数表
首先,我们先来看一段代码:
先复习一下
那么,我们来看看,8字节那里存的是什么?
我们可以看到除了对象成员a之外,这里有一个叫_vfptr的一个指针:对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function,ptr代表指针)。
一个含有虚函数的类中都至少都有一个虚函数表指针(不是只有一个),因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表。
测试代码:
class Base { public: virtual void Func1() { cout << "Func1()" << endl; } virtual void Func2() { cout << "Func1()" << endl; } void Func3() { cout << "Func1()" << endl; } private: char _b = 1; }; class Derive : public Base { public: virtual void Func1() { cout << "Derive::Func1()" << endl; } private: int _d = 2; }; int main() { Base b1; Derive d1; return 0; }
发现规律:
1.基类有一个虚表指针,派生类对象d1中也有一个虚表指针,d1对象由两部分构成,一部分是父类继承下来的成员,虚表指针也就是存在部分的另一部分是自己的成员(看下图)
2.基类b1对象和派生类d1对象虚表是不一样的,这里我们发现Func1完成了重写,所以d1的虚表中存的是重写的Derive::Func1(看上图),所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法。
3.另外Func1和Func2继承下来因为是虚函数,所以放进了虚表,但是,我们仔细看了看发现Func3虽然也继承下来了,但是不是虚函数,是普通函数,所以不会放进虚表。
我们再用内存来看一下:
4.虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个nullptr。
5.总结一下派生类的虚表生成:
a.先将基类中的虚表内容拷贝一份到派生类虚表中
b.如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数
c.派生类己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后
现在我们来验证一下:
我们上面是多态的条件之一:父类的指针或引用。
思考:为什么不能是子类(派生类)的指针或者引用呢?为什么不能是父类(基类)的对象?
观察下图:
分析:
为什么对象切片与指针或引用切片是不同的?
重写:虚表这个位置会完成覆盖
派生类的虚表首先是把父类这个虚表拷贝下来,完了之后,再把重写虚函数的这个地址进行覆盖,指向父类的看到父类的虚函数表,,指向子类的就看到子类的虚函数表。那么,这个指向子类,对我而言,这个指针/引用看到的都是一个父类对象,只是自然而然看到这个父类对象(如果就是父类),子类中的父类对象就是子类对象。
所以指针/引用不存在拷贝这些的,而对象切片需要拷贝
那又有一个问题:为什么子类赋值给父类对象切片,不拷贝虚表呢?
如果拷贝了,你再用这个父类的指针去指向父类对象,反而会调用子类,这是不是就乱套了。
我们可以得出结论:
子类赋值给父类对象切片,不会拷贝虚表。
如果拷贝了虚表,那么父类对象虚表中是父类虚函数还是子类虚函数就不确定了,乱套了。
为什么只拷贝成员,不拷贝虚函数表指针?(更深入分析)
1.假设拷贝构造和赋值重载会拷贝虚函数表指针。
这样就不能保证多态调用时,指向父类调用的是父类的虚函数表,指向子类调用子类的虚函数表。
甚至,当析构函数的时候更为恶劣:
Base b1; Derive d1=new Derive; *d1=b1; delete b1;
如果拷贝了虚函数表,这时候你去delete后,是不是会引发一系列的为题。因此C++中,使用引用或者指针作为多态的条件之一,而不拷贝虚函数表。
5.同类型的对象是共用虚表的:
通过上面代码我们发现:两次的虚表地址都没变,所以得出:同类型对象它们是共有虚表的
6.证明func3的位置:
看下,我们在监视窗口(第一图)中并没有看到派生类func4,但是那里的内存窗口(第二图),有一个地址,我们有理由去怀疑它就是func4,现在让我们来证明一下吧!
tip1:有时候监视并不靠谱,所以我们来借用内存来看看:
为了验证这个猜想:我们采用打印虚函数表的地址的方法。
我们知道,虚函数表实质上就是一个函数指针数组。
![]()
下面给出验证代码:(下面代码难理解,只需要简单看看结果就行)
class Base { public: virtual void func1(){ cout << "func1()" << endl; } virtual void func2(){ cout << "func2()" << endl;} virtual void func3() { cout << "func3()" << endl; } private: int _b=1; }; class Derive : public Base { public: virtual void func1(){ cout << "derive::func1()" << endl;} virtual void func4() { cout << "derive::func4()" << endl; } private: int _d = 2; }; void Func(Base& b) { b.func1(); } typedef void(*FUNC_PTR) (); //定义函数指针类型,指向无参数,无返回类型的函数。 // 打印函数指针数组 // void PrintVFT(FUNC_PTR table[]) void PrintVFT(FUNC_PTR* table,int count) //table虚函数表的首地址,即对象的虚表指针(vptr), //count是虚函数表里面函数的个数 { for (size_t i = 0; i<count; i++) { printf("[%d]:%p->", i, table[i]); //打印函数指针的地址 FUNC_PTR f = table[i]; f(); //调用函数 } printf("\n"); } int main() { Base b1; Derive d1; auto* vft1 = *(FUNC_PTR**)&b1; PrintVFT(vft1, 3); auto* vft2 = *(FUNC_PTR**)&d1; PrintVFT(vft2, 4); //1. &d1 :获取派生类对象 d1 的首地址(内存起始位置)。 //2. (FUNC_PTR**)&d1 :将对象地址强制转换为 FUNC_PTR** 类型(即“指向函数指针数组的指针”)。 //3. *(FUNC_PTR**)&d1 :解引用该指针,得到对象的虚表指针(vptr),即虚函数表的首地址。 return 0; }
注意:
1.为什么要弄一个count:需要手动指定,不可依赖nullptr来中止(在VS2022中它是直接报错的)
7.验证虚表的位置:
函数存在哪的?虚表存在哪的?
答:虚函数存在虚表,虚表存在对象中。注意上面的回答的错的。很多人都是这样深以为然的。注意虚表存的是虚函数指针,不是虚函数,虚函数和普通函数一样的,都是存在代码段的,只是他的指针又存到了虚表中。另外对象中存的不是虚表,存的是虚表指针。那么虚表存在哪的呢?
现在让我们来验证一下:
1.分类:栈,堆,数据段(静态区),代码段(常量区)
我们可以看到,它与常量区最为接近,因此我们可以判断它就是属于常量区(代码段)的。
8.派生类有没有必要单独写虚表?
没必要,派生类是继承父类,包含那个父类对象那里就有虚表了,就已经够了,可以实现多态了。我们在来想一下,多态是父类的指针或者引用,那它指向父类指针,就会找到父类里的虚表,若指向子类,它会切片切出子类对象中父类的那一部分,然后去部分虚表里面找到那个虚函数。
ps:继承了之后,父类已经完成了虚函数的重写与覆盖,可以认为虚表也属于我的,所以子类是拷贝,并没有共有一张虚表。
9.观察多态与非多态确定时期:
class Base { public: virtual void func1(){ cout << "func1()" << endl; } virtual void func2(){ cout << "func2()" << endl;} virtual void func3() { cout << "func3()" << endl; } private: int _b=1; }; class Derive : public Base { public: virtual void func1(){ cout << "derive::func1()" << endl;} virtual void func4() { cout << "derive::func4()" << endl; } private: int _d = 2; }; //汇编内容 void Func(Base& b) { b.func1(); 00007FF7B6FB2320 48 8B 85 E0 00 00 00 mov rax,qword ptr [b] 意思:把b指针移动到rax寄存器中 00007FF7B6FB2327 48 8B 00 mov rax,qword ptr [rax] 意思:[rax]就是取rax值指向的内容,这里相当于把b1对象头4个字节(虚表指针)移动到了rax 00007FF7B6FB232A 48 8B 8D E0 00 00 00 mov rcx,qword ptr [b] 意思:把b指针移动到rcx寄存器中 00007FF7B6FB2331 FF 10 call qword ptr [rax] 意思:call rax中存虚函数的指针。 这里可以看出满足多态的调用,不是在编译时确定的,是运行起来以后到对象的中取找的。 00007FF7B6FB2333 90 nop } int main() { Base b1; Func(b1); b1.func1(); return 0; }
那么,不满足多态的:
证明完毕。
动态绑定与静态绑定
(接上面的图片结合理解)
1. 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载,(根据函数名的修饰规则)2. 动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态
10.观察多继承中的虚函数表
1.若我们的多继承中的派生类里面没有重写的函数,它会放到哪一个虚函数表里面呢?现在我们来一趟究竟。
class Base1 { public: virtual void func1(){ cout << "func1()" << endl; } virtual void func2(){ cout << "func2()" << endl;} virtual void func3() { cout << "func3()" << endl; } private: int _b=1; }; class Base2 { public: virtual void func1() { cout << "func1()" << endl; } }; class Derive : public Base1,public Base2 { public: virtual void func1(){ cout << "derive::func1()" << endl;} virtual void func4() { cout << "derive::func4()" << endl; } private: int _d = 2; }; typedef void(*FUNC_PTR) (); // 打印函数指针数组 // void PrintVFT(FUNC_PTR table[]) void PrintVFT(FUNC_PTR* table,int count) { for (size_t i = 0; i <count; i++) { printf("[%d]:%p->", i, table[i]); FUNC_PTR f = table[i]; f(); } printf("\n"); } int main() { //Base1 b1; Derive d1; auto* vft1 = *(FUNC_PTR**)&d1; PrintVFT(vft1, 4); auto* vft2 = *(FUNC_PTR**)((char*) & d1+sizeof(Base1)); PrintVFT(vft2, 1); //b1 = d1; //Func(d1); //Func(b1); return 0; }
多态的菱形继承我们不学习 !!所以不研究!!!!!
避坑题目:
题目避坑(这道题非常坑,现在我们来分析分析):
我们来分析它是否符合多态的条件:
1.首先,我们先来分析test中的func中的this->func()是否是多态调用?答案是this是父类指针A*,因为它是从父类继承下来的,不会改变的。那么为什么不是派生类呢?实质上编译器继承后,并不会在函数B生成一份,然后调用B。
2.继承对象模型的生成:
对于成员变量而言,B对象生成类分成两部分:一部分是父类部分,B是把父类当作一个成员变量子对象处理,所以在这个过程中父类对象必须调用父类的构造函数初始化函数,调用父类的析构清理资源,从对象模型的角度而言, 派生类是把父类成员当成一个整体(当成一个成员看),并不是把它依次拿下来,而对象只跟成员变量有关,跟成员函数无关,
在B派生类生成建模时,成员函数是怎么样的呢?它是编译好的成员函数都是放到代码段的,即test()只会生成一份,在编译查找编译时,只看语法过不过(至少找到声明),(编译时)先在派生类找(找到了就停),没找到再去父类类域找。这也是之前说的派生类会隐藏父类的知识。
(编译时)找到了,就在链接时,发现是父类的,就会生成修饰函数名去找它就可以了。因此,test不会有两份,所以this是A*。
虚函数重写:它是否符合呢?
答案是符合的,我们知道重写的条件是:虚函数+三同(返回类型,函数名,参数列表),注意了,我们这里说的参数是指参数类型,跟形参无关(无论是函数重载还是现在的重写)。
分析完这里,可能大部分朋友都会以为选D
但是实则不然,答案却是选B。
实质上虚函数重写:重写的是实线,也就是它壳用的是父类的。这也是为什么派生类不写virtual也是虚函数的原因,派生类B只是检查三同,整体的架子用的是父类。所以它形参用的是val=1。
ps:为什么要重写?派生类虚才能是派生类的虚函数。才能做到这个指针指向父类调用父类,子类调用子类。
关于继承的所以知识点就分析到这里,希望你我共进步。
最后,到了本次鸡汤环节:
坚持!!!