C++进阶—>这3个问题难道搞不懂多态???
文章目录
- 🚩前言
- 1、它是什么?
- 2、怎样实现它??
- 2.1、虚函数是个什么来头?✍
- 2.2、虚函数的重写/覆盖特殊点!👀
- 2.3、在了解多态的必要条件以及虚函数后,来看下如何编写吧👀👀
- 2.4、经典面试题
- 3、虚函数的其余问题解析✍
- 3.1、协变?
- 3.2、纯虚函数和抽象类?🔊
- 3.3、析构函数的重写
- 3.4、override和final关键字?
- 4、它为什么是这样的结果(底层原理)???
- 4.1、初识虚函数表?
- 4.2、初看虚函数表指针?
- 4.3、多态实现的原理?
- 4.4、动态绑定和静态绑定
- 4.5、再探虚函数表
🚩前言
该模块属于C++进阶内容,通过三个问题(是什么?——>怎么实现??——>为什么可以这样???)来理解多态这个陌生的词语,以及底层原理?
1、它是什么?
“多态”——>字面意思理解,即多种形态。
既然是多种形态,那么C++多态有哪几种呢?
主要分为编译时多态[静态多态]和运行时多态[动态多态]
编译时多态——》主要体现在函数重载和函数模版。通过传入不同类型的参数就可以调用不同的函数,通过传入参数来实现多种形态。该过程叫编译时多态,是因为实参传给形参的进行参数匹配时候,是在编译的时候完成的。
运行时多态——》主要体现在继承体系过程的,通过传入不同对象,来完成对应对象的函数(方法)。这里有两个例子来描述:①车站买票的时候:成年人、学生、儿童以及军人在C++中可以理解为不同的对象,在买票的时候,从生活中买票都可以知道,不同对象买票是有不同价格的。然而达到不同价格买票,就是实现不同的方法。
②动物发出叫声:我们知道不同动物发出的叫声是不一样的,狗狗🐶是"汪汪……",
猫猫🐱是发出“喵喵……”等不同的动物,这些都是多个对象,但它们同属于动物系,由于对象不同,因此发出声音这一方法(函数)就会不同。这两个就是多态的例子,下面会讲怎样实现多态。
2、怎样实现它??
多态的构成条件,即前提是在继承关系之下的类和对象,不同对象调用同一函数实现不同方法(行为)。
在实现它之前必须得知道到达多态的必要条件,有两个:
①必须基类(父类)的指针或引用来调用虚函数。
②被调用的函数必须是虚函数。注意事项:①必须是基类的指针或引用,因为只有基类的指针或引用才能指向派生类的对象。②派生类必须对基类的虚函数进行重写/覆盖,只有这样派生类才能实现自己的方法,进一步达到多态的效果。
那么虚函数???是什么呢?因此在实现多态之前有必要讲解虚函数是什么?
2.1、虚函数是个什么来头?✍
“虚函数”:它的实现很简单,就是在函数前面加关键字virtual,就成该函数为虚函数。
值得注意的是非成员函数不能加vitrual关键修饰。
【原因在于非成员函数(非类成员函数)只能被overload,而不能被override。虚函数的主要目的是支持类多态,允许子类继承并覆盖父类中的虚函数,实现动态绑定。由于非成员函数不具备这种继承和覆盖的特性,因此将其定义为虚函数没有实际意义,编译器也无法处理这种定义,从而导致无法通过编译。此外,虚函数是在运行时动态绑定的,而非成员函数在编译时就已经确定,这与虚函数的动态绑定机制相冲突,因此非成员函数不能加virtual修饰。】
2.2、虚函数的重写/覆盖特殊点!👀
- 虚函数的重写/覆盖:也就是在派生类中有跟基类完全相同的虚函数,就达成虚函数的重写/覆盖,达成条件就是要“3同”,即①函数名相同。②函数的返回值类型相同。③参数列表类型相同。就完成派生类的虚函数重写了父类的虚函数。
- ==注意:==在重写基类虚函数的时候,派生类的虚函数可以省略关键字“virtual”【在继承之前基类的函数已经有了虚函数的属性,继承后依然保持虚函数属性】,但是为了规范,建议写上。只是在考试或者面试中需要注意埋的这个“坑”。
2.3、在了解多态的必要条件以及虚函数后,来看下如何编写吧👀👀
例1
#define _CRT_SECURE_NO_WARNINGS 1
#include<iostream>
//多态例子1——>买票
//不管哪一个对象买票,都是属于Person这个父类的,因此先创建一个父类
class Person
{
public:
//默认是成年人买票,都是全价票,基类函数必须写为虚函数
virtual void Tickets()
{
std::cout << "成年人——>全价票:" << std::endl;
}
};
//然后是其他对象买票,
//学生对象
class Student :public Person
{
public:
//重写虚函数,可省略关键字,为规范建议加上。
virtual void Tickets()
{
std::cout << "学生——>半价优惠:" << std::endl;
}
};
//买票
void BuyTickets(Person& per)//必须是基类的指针或者引用
{
per.Tickets();
//指针也可以。
//per->Tickets();
}
int main()
{
Person per1;
Student stu1;
BuyTickets(per1);
BuyTickets(stu1);
//也可以写为下面那种。
//Person* per1 = new Person;
//Person* stu1 = new Student;
//per1->Tickets();
//stu1->Tickets();
return 0;
}
例2
#include<iostream>
//多态例子2——>动物叫
//基类——>Animal
class Animal
{
public:
//创建虚函数
virtual void talk() const
{}
};
class Dog :public Animal
{
public:
//重写基类虚函数
virtual void talk() const
{
std::cout << "狗狗叫——>汪汪……" << std::endl;
}
};
class Cat :public Animal
{
public:
//重写
virtual void talk() const
{
std::cout << "猫猫叫——>喵喵……" << std::endl;
}
};
void Animal_talk(const Animal& animal)
{
//animal->talk();//基类指针调用,也可以引用
//写引用,参数记得跟着变
animal.talk();
}
int main()
{
Dog dog1;
Cat cat1;
Animal_talk(dog1);
Animal_talk(cat1);
return 0;
}
2.4、经典面试题
以下程序输出结果是什么()
A: A->0 B: B->1 C: A->1 D: B->0 E: 编译出错 F: 以上都不正确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;
}
3、虚函数的其余问题解析✍
在这里说一说协变、纯虚函数和抽象类。
3.1、协变?
这里说协变,是和多态中“3同”有些冲突的,但是这是特殊情况,作为了解即可。
主要是在派生类重写基类虚函数的时候,出现派生返回类型和基类的类型不一样,即基类返回基类的指针/引用,而派生类返回自己的指针/应用,就称为协变。一般没怎么用,只要提到“协变”这个词后,不陌生就ok了。👌
3.2、纯虚函数和抽象类?🔊
纯虚函数定义:就是在虚函数后面加“=0”即可。
class Car
{
public:
virtual void Drive() = 0;
};
纯虚函数不需要定义实现,只写声明即可。因为定义实现后一样会被派生类重写,所以意义不大,从语法上是可以实现的。
那么包含纯虚函数的类叫作抽象类。
抽象类是不能实例化出对象的,若派生类继承抽象类后,不重写纯虚函数的话,派生类也是抽象类。
可以说纯虚函数在某种程度上强制了派生类必须重写虚函数,不重写无法实例化出对象,就无意义了。
3.3、析构函数的重写
析构函数的重写有这样一个规定:
当基类的析构函数为虚函数的时候,派生类只要定义析构函数后,无论加还是不加virtual,都和基类的析构函数构成重写。有人会说基类的析构函数名和派生类的析构函数名完全不同,怎么构成重写的呢?这就是编译器的作用,实际上编译器对析构函数名做了特殊处理,就是编译后析构函数名称会被统一处理成destructor,所以基类的析构函数写为虚函数后,和派生类定义的析构函数是构成重写的。
注意:在多态条件下,只要基类定义了析构函数,就必须加关键字virtual,不然会出问题,以下面例子为参考👇👇👇👇
为什么基类的中的析构函数建议设计为虚函数呢?
有此类情况:一个基类和一个派生类,两个构成继承关系,那么程序结束后,怎么释放?得调用析构函数,由于在多态中显示写析构函数后,派生类的析构函数和基类的析构函数是构成重写的。main()函数中创建两个对象,基类的对象是基类的指针,派生类的对象也是基类的指针,只是指向的对象不同,因此在析构时候,基类释放基类空间是没问题的,而由于是继承关系,若基类析构函数不加virtual关键字就不构成多态,那么派生类在调用析构时候就会出问题,因为创建的对象都是基类指针类型,调用函数就是指向谁调用谁的函数,在这里就得调用析构函数,但是基类的析构函数没加virtual,就不构成多态,那么派生类在调用析构时候就无法调用到自己重写的析构函数,就会造成无法释放造成内存泄漏。下面是代码实现演示:👇
构成继承,但不构成多态,释放会出问题
#include<iostream>
class A
{
public:
//基类析构不是虚函数
~A()
{
std::cout << "析构:~A()" << std::endl;
}
};
class B :public A
{
public:
virtual ~B()
{
std::cout << "析构:~B()"<< std::endl;
}
};
int main()
{
//两个对象类型都是基类的指针,差别就是指向对象不一样
A* a = new A;
A* b = new B;
//在结束后需要析构,由于是指针需要显示写delete
delete a;
delete b;
return 0;
}
下面程序构成继承和多态,释放正常
代码编写就是在上面代码中的基类析构函前面加关键字virtual,即可。
运行显示结果在下面,释放正常,这里可能会有疑问,为什么a对象析构两次呢?第一个~A()是创建的对象调用的, ~B()是对象b调用的,那么最后一个 ~A()呢?在继承关系中,派生类是合成版本,就是说里面含有父类的一部分,在析构的时候,子类调用自己的析构,父类那一部分就调用父类的析构。因此后面又析构一次。
总结:为什么基类析构要加virtual?
如果基类析构不加virtual,那么在析构派生类的空间的时候就只会调用基类,析构基类那一部分空间,派生类的就会无法释放就出现内存泄漏。
3.4、override和final关键字?
override 关键字是:帮助用户检查之后构成重写。
因为在C++中对虚函数构成重写很严格,在不经意中用户书写时可能会出现错误,比如函数名、参数写错等,导致无法构成重写,改错误在编译期间无法检查出,因此该关键字就起到提示作用。
final 关键字是:就是不想让派生类重写基类的这个虚函数的时候,就可以用final修饰。
4、它为什么是这样的结果(底层原理)???
在知道多态是怎样实现的,就需要知道虚函数表、虚函数表这两个词语!
4.1、初识虚函数表?
先从虚函数表是用来干什么的说起,首先虚函数表,可以简称“虚表”,是用来存储虚函数地址的。
哪里的虚函数呢?就是类中带有virtual关键字的函数的地址。
既然它是存放地址的,并且可以存放多个地址,因此虚函数表的本质就是存放虚函数指针(地址)的指针数组,在数组末尾会以0x00000000标记结尾的(在vs中会,而g++中没有)。
4.2、初看虚函数表指针?
虚函数表指针?看名字可以知道是一个地址,谁的地址?即虚函数表的地址。下面通过一个实例程序来分析:
#include<iostream>
class Base
{
public:
virtual void Func1()
{
std::cout << "Func1()" << std::endl;
}
protected:
int _b = 1;
char _ch = 'x';
};
int main()
{
Base b;
std::cout << sizeof(b) << std::endl;
return 0;
}
从运行结果看怎么会是12呢?根据自己的分析,成员有两个,结合图来看:
这样算下来是8Byte,为什么运行时结果为12Byte?
接下来看下面的调试图片,会看到不一样的东西👀👀
相信可以看到蓝色那一栏吧!_vfptr是什么空间呢?它就是虚函数表指针,简称虚表指针,后面0x00499b34就是虚函数表的地址,_vfptr就是虚函数表指针名。因为是指针,又是在32位下,指针要占4字节,因此总空间就变化为下图的:
相信到达这里搞清楚了为什么运行出来是12Byte,,接下来看一下,上面说过虚函数表是存储虚函数地址的,并且虚函数表也有自己的地址,叫虚函数表指针,因此整个逻辑就是有一个虚函数表,虚函数表指针指向的是虚函数表,在虚函数表中存放着虚函数的地址,下图可见:
4.3、多态实现的原理?
在弄清楚虚函数表和虚函数表指针以及了解了虚函数地址存放位置后,现在看一下怎么实现调用的,下来实现一个完整的多态:
#include<iostream>
class Person
{
public:
//实现多态记得基类要有虚函数
virtual void BuyTicket()
{
std::cout << "Person->全价票" << std::endl;
}
private:
int age;
char gender;
};
class Student :public Person
{
public:
virtual void BuyTicket()
{
std::cout << "Student->半价票" << std::endl;
}
private:
int age;
char gender;
};
class Soldier :public Person
{
public:
virtual void BuyTicket()
{
std::cout << "Soldier->军人优先" << std::endl;
}
private:
int age;
char gender;
};
void Buy_ticket(Person& p)
{
p.BuyTicket();
}
int main()
{
Person per;
Student stu;
Soldier soldier;
Buy_ticket(per);
Buy_ticket(stu);
Buy_ticket(soldier);
return 0;
}
运行结果如上,不同对象调用同一个函数,实现各自的方法,如何做到的?分析如下:
总结一句话
多态实现过程中:指向谁调用谁,指向的对象是哪个,在运行时就指向该对象的虚函数表中去找到对应的虚函数地址,然后根据地址进行函数调用。
4.4、动态绑定和静态绑定
什么是动态绑定和静态绑定?
4.5、再探虚函数表
- 类里面所有的虚函数地址都会存储到虚函数表中去,要注意的是不同类型对象的虚函数表中存放自己对象所对应类中的虚函数地址(不同类型的对象,虚函数表是各自独立的),那么同一类型的对象,虚函数表是共用的。下面通过调试来看:
- 明确派生类是由两部分构成的,即继承下来的基类和自己的成员,根据一般情况下来看,继承下来的基类中含有虚函数表指针,派生类就不会另外生成虚函数表指针了。这里得注意的是,继承下来的虽然是基类中的虚函数表指针,但是和基类中本身的虚函数表指针不是同一个,也就是说不是同一个空间的,都是各自的了。如下面调试图:
- 从第2点的图中看到继承下来的虚函数地址也是和基类中的不一样的,因此可以得出在派生类继承基类的过程中因为是重写了基类的虚函数的,又由于是派生类,从第1条中不同类中的虚函数表是各自独立的这个内容来看,因此可以这样理解:派生类虽然继承了基类的虚函数表指针,但是派生类重写了基类的虚函数,作为派生类也可以说是新的一个类,因此有自己的虚函数表指针和虚函数地址,只不过是把自己的虚函数表指针和虚函数地址写到了继承下来的虚函数表指针中的。一句话说明就是:派生类用自己的虚函数表指针覆盖到继承下来的虚函数表指针中去了。
- 派生类的虚函数表中是包含了基类的虚函数地址、派生类自己重写的虚函数地址(只不过是覆盖到了继承下来的虚函数表中所重写的那个虚函数地址了)、还有就是好派生类自己原本有的虚函数地址。看下图:
5.
代码如下:
#include<iostream>
class Base {
public:
virtual void func1() { std::cout << "Base::func1" << std::endl; }
virtual void func2() { std::cout << "Base::func2" << std::endl; }
void func5() { std::cout << "Base::func5" << std::endl; }
protected:
int a = 1;
};
class Derive : public Base
{
public:
// 重写基类的func1
virtual void func1() { std::cout << "Derive::func1" << std::endl; }
virtual void func3() { std::cout << "Derive::func1" << std::endl; }
void func4() { std::cout << "Derive::func4" << std::endl; }
protected:
int b = 2;
};
int main()
{
int i = 0;
static int j = 1;
int* p1 = new int;
const char* p2 = "xxxxxxxx";
printf("栈:%p\n", &i);
printf("静态区:%p\n", &j);
printf("堆:%p\n", p1);
printf("常量区:%p\n", p2);
Base b;
Derive d;
Base* p3 = &b;
Derive* p4 = &d;
printf("Person虚表地址:%p\n", *(int*)p3);
printf("Student虚表地址:%p\n", *(int*)p4);
printf("虚函数地址:%p\n", &Base::func1);
printf("普通函数地址:%p\n", &Base::func5);
return 0;
}