当前位置: 首页 > ai >正文

C++进阶之——多态

1. 多态的概念

多态是用来描述这个世界的

多态的概念:通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会 产生出不同的状态。

这里就很厉害了,能够实现特殊处理,本文章就是来仔细解释多态的原理

举个栗子:比如买票这个行为,当普通人买票时,是全价买票;学生买票时,是半价买票;军人 买票时是优先买票。

再举个栗子: 最近为了争夺在线支付市场,支付宝年底经常会做诱人的扫红包-支付-给奖励金的 活动。那么大家想想为什么有人扫的红包又大又新鲜8块、10块...,而有人扫的红包都是1毛,5 毛....。其实这背后也是一个多态行为。支付宝首先会分析你的账户数据,比如你是新用户、比如 你没有经常支付宝支付等等,那么你需要被鼓励使用支付宝,那么就你扫码金额 = random()%99;比如你经常使用支付宝支付或者支付宝账户中常年没钱,那么就不需要太鼓励你 去使用支付宝,那么就你扫码金额 = random()%1;

总结一下:同样是扫码动作,不同的用户扫 得到的不一样的红包,这也是一种多态行为。ps:支付宝红包问题纯属瞎编,大家仅供娱乐。

2. 多态的定义及实现

2.1多态的构成条件

1. 必须(父类)通过基类的指针或者引用调用虚函数

2. 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写

这两点很重要!!!

多态:不同对象传递过去,调用不同的函数

多态调用看指向的对像(不管指向的对象就是基类还是派生类,都去虚基表里面获得函数地址,基类和派生类里面存的地址是不同的)

普通对象看当前的类型(而且在编译的时候就可以知道函数的地址,直接去call就可以了)

下面就是具体场景

2.2虚函数

简单来说就是被virtual修饰的函数就是虚函数

! 这里不要与虚继承搞混掉,两者没有半毛钱关系

只有成员函数才能变为虚函数(能够进行重写)




2.3虚函数的重写

重写的条件:虚函数+三同(返回值,函数名,参数)(有两个例外)

满足条件就称:子类的虚函数重写了基类的虚函数。

虚函数重写的两个例外:

1协变(基类和派生类的返回值不同)

派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指 针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。

class A{};
class B : public A {};
class Person {
public:virtual A* f() {return new A;}
};
class Student : public Person {
public:virtual B* f() {return new B;}
};
这里虽然返回值不同,但是还是构成重写

还有一点注意,父用父,子用子,用别的父子关系的类也可以

2. 析构函数的重写(基类与派生类析构函数的名字不同)

只要父类的析构函数加上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; }~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;
}

再次提醒 delete p== p->destructor()+operator delete(p) ,先去调用析构函数再去释放空间

2.4 C++11 override 和 final

从上面可以看出,C++对函数重写的要求比较严格,但是有些情况下由于疏忽,可能会导致函数 名字母次序写反而无法构成重载,而这种错误在编译期间是不会报出的,只有在程序运行时没有 得到预期结果才来debug会得不偿失,因此:C++11提供了override和final两个关键字,可以帮 助用户检测是否重写。

1. final:修饰虚函数,表示该虚函数不能再被重写

若不想让一个类被继承的话有哪些办法?

(1)这个final 也可以写在Car的后面,表示这个基类不能被继承(c++11)

(2)基类构造函数私有或者析构函数放到私有(这样派生类就不能去调用基类的构造或者析构函数去处理数据)

那么问题来了,你的构造函数和析构函数都放到私有了,你怎么创建对象呢?

解决办法(很经典):虽然在外部不能调用构造,但是类里面可以啊

class A
{
public:static A Get_A(){return A();}
private:
A(int a = 1):_a(a){ }int _a;
};class B :public A
{
protected:int _b;};
int main()
{A a = A::Get_A();return 0;
}

析构函数放在私有就不再演示了

2. override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。

2.5 重载、覆盖(重写)、隐藏(重定义)的对比

3. 抽象类

在虚函数的后面写上 =0 ,则这个函数为纯虚函数。

包含纯虚函数的类叫做抽象类(也叫接口 类),抽象类不能实例化出对象(没有实体)。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生 类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。

虚函数只有重写了才有意义,纯虚函数:强制派生类重写虚函数

这里有个特例,虽然不可以用父类创建对象(子类重写了纯虚函数的前提),但是可以用父类的指针和引用,这原因很简单,就是为了构成多态调用

4. 多态的原理

4.1虚函数表

虚表(相当于函数指针数组)不等于虚基表(存的是偏移量)

解释多态的原理就要去仔细解释虚函数表

虚函数本质放到代码段里面,虚函数表中存的是虚函数的地址

普通调用在编译的时候就会确定地址

若符合多态调用,就会在运行的时候到指定对象的虚基表中找调用函数的地址若此时指向的对象是派生类,因为虚基表里面存的函数的地址是派生类里面的函数地址,这样就解释了为啥子不同对象调用的时候会表现出特殊处理

// 这里常考一道笔试题:sizeof(Base)是多少?
class Base
{
public:virtual void Func1(){cout << "Func1()" << endl;}
private:int _b = 1;
};

一般人可能就想着,函数不存在类里面啊,那答案就是1啊,可是正确答案是8(x86环境下),这是因为类里面还存着一个叫_vfptr(全名叫virtual function table ptr)的指针,指向func1()这个函数

对象中的这个指针我们叫做虚函数表指针。一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数 的地址要被放到虚函数表中,虚函数表也简称虚表,。那么派生类中这个表放了些什么呢?我们 接着往下分析

class Base
{
public:virtual void Func1(){cout << "Base::Func1()" << endl;}virtual void Func2(){cout << "Base::Func2()" << endl;}void Func3(){cout << "Base::Func3()" << endl;}
private:int _b = 1;
};
class Derive : public Base
{
public:virtual void Func1(){cout << "Derive::Func1()" << endl;}
private:int _d = 2;
};
int main()
{Base b;Derive d;return 0;
}

还有一点:同类型的类公用一个虚表

可见同类型的虚表地址相同

通过观察和测试,我们发现了以下几点问题:

1. 派生类对象d中也有一个虚表指针,d对象由两部分构成,一部分是父类继承下来的成员,虚 表指针也就是存在部分的另一部分是自己的成员。

2. 基类b对象和派生类d对象虚表是不一样的,这里我们发现Func1完成了重写,所以d的虚表 中存的是重写的Derive::Func1,所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数 的覆盖。重写是语法的叫法,覆盖是原理层的叫法。

3. 另外Func2继承下来后是虚函数,所以放进了虚表,Func3也继承下来了,但是不是虚函 数,所以不会放进虚表。

4. 虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个nullptr。

5. 总结一下派生类的虚表生成:a.先将基类中的虚表内容拷贝一份到派生类虚表中 b.如果派生 类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数 c.派生类自己 新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。

而且注意:派生类新增加的虚函数不会在监视窗口看见

这时候可以用地址来查看

可以看见fun3实际是存在的

而且vs有一个小问题就是当你改代码后(没有重新生成解决方案的话,虚表的地址不会变,而且虚表最后一个位置不会被赋值为nullptr)

问题来了,你怎么知道这个位置就是func3呢?我们只是猜测这个地址是func3的地址,要想办法来验证才可以

在此之前先让我们验证一下虚表存在的位置

可能位置 1栈   2 堆  3 静态区(数据段)4 常量区(代码段)

可以写一个小程序,来比较虚表的地址和以上哪一个地址相差小

class Person {
public:virtual void BuyTicket() { cout << "买票-全价" << endl; }virtual void func1() {};virtual void func2(){};int _a=1;
};
class Student : public Person {
public:virtual void BuyTicket() { cout << "买票-半价" << endl; }virtual void func2(){};virtual void func3(){};int _b = 1;
};int main()
{Person ps;Student st;int a = 0;printf("栈:%p\n", &a);int* b = new int;printf("堆:%p\n", b);static int c = 0;printf("静态区:%p\n", &c);const char* str = "hallow world";printf("常量区:%p\n", str);printf("虚表1:%p\n", *((int*) & ps));printf("虚表2:%p\n",*((int*) & st));return 0;
}

答案显而易见,就是存在常量区(也就是代码段)

有一点要说明:类里面是先存的是虚表的地址,是一个指针,在x86环境下是4个字节,因为这个虚表的地址和类的地址起始位置相同,我们就把类的地址强转成int* (访问4个字节),这样就可以得到虚表的地址了

 解下来就是验证这个地址就是func3,因为_vfptr地址指向的是一个数组,并且是一个函数指针数组

我们要想办法得到这个地址,然后去直接用地址调用看看func3是否被调用

这里回忆一下函数指针数组

下面是测试代码

//因为函数指针数组写起来比较麻烦
typedef void (*FUNC_PTR)();
void PrintVFT(FUNC_PTR table[])
{for (size_t i = 0; table[i] != nullptr; i++){printf("[%d]:%p->", i, table[i]);FUNC_PTR f = table[i];f();}cout << endl;
}class Person {
public:virtual void BuyTicket() { cout << "买票-全价" << endl; }virtual void func1() {cout << "func1()" << endl;};virtual void func2(){cout << "func2()" << endl;};int _a=1;
};
class Student : public Person {
public:virtual void BuyTicket() { cout << "买票-半价" << endl; }virtual void func2(){cout << "func2()" << endl;};virtual void func3(){cout << "func3()" << endl;};int _b = 1;
};int main()
{Person ps;Student st;//这里转化成int可以直接将地址存起来,用的时候在强转int pptr = *((int*) & ps);int sptr = *((int*) & st);PrintVFT((FUNC_PTR*)pptr);PrintVFT((FUNC_PTR*)sptr);//这里将地址转化为指针数组的地址return 0;
}

6. 这里还有一个童鞋们很容易混淆的问题:虚函数存在哪的?虚表存在哪的? 答:虚函数存在 虚表,虚表存在对象中。注意上面的回答的错的。但是很多童鞋都是这样深以为然的。注意 虚表存的是虚函数指针,不是虚函数,虚函数和普通函数一样的,都是存在代码段的,只是 他的指针又存到了虚表中。另外对象中存的不是虚表,存的是虚表指针。那么虚表存在哪的 呢?实际我们去验证一下会发现vs下是存在代码段的,Linux g++下大家自己去验证?

我们在重新说一遍:多态的定义,并且引出一些问题

1. 必须(父类)通过基类的指针或者引用调用虚函数

问题一:为什么不能是子类的指针或引用 ? 

因为只有父类的指针或引用才能指向父类和子类(发生切片,指向的任然是子类的一部分)

问题二:为什么不能是父类对象?

子类赋值给父类会进行切片,但是不会拷贝虚表

如果拷贝虚表,那么父类对象虚表中是父类虚函数还是子类就不确定了就乱套了

但是派生类的虚表相当于父类的虚表拷贝过来,若有重写就进行覆盖

2. 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写

5. 单继承和多继承关系中的虚函数表

需要注意的是在单继承和多继承关系中,下面我们去关注的是派生类对象的虚表模型,因为基类 的虚表模型前面我们已经看过了,没什么需要特别研究的

5.1 单继承中的虚函数表

我们之前就测试过了,发现子类里面的虚函数不会在监视窗口显示,这也是一个vscode的小bag,不过我们已经从内存里面看到了子类的虚函数确实存在,而且也通过地址调用了函数

5.2 多继承中的虚函数表

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;
};int main()
{Derive d;FUNC_PTR* vTableb1 = (FUNC_PTR*)(*(int*)&d);PrintVFT(vTableb1);
下面一步比较巧妙因为先存Base1,我们可以跳过Base1去打印Base2的虚表FUNC_PTR* vTableb2 = (FUNC_PTR*)(*(int*)((char*)&d + sizeof(Base1)));PrintVFT(vTableb2);return 0;
}

问题来了,派生类对func1进行重写,按我们的理解,两个Base虚表里面的func1地址应该相同啊(都进行覆盖),为啥func1地址不同?func1只有一个,肯定地址是不变的

这里原因就是编译器对其进行了封装

要通过汇编才能看到真正的原因

也就是相当于Base1里面存了func1重写的地址(这样只用存一份),Base2想用就通过地址偏移去Base1里面调用

5.3菱形继承中的虚函数表


6. 继承和多态常见的面试问题


概念

下面一题就很有难度了

这里要搞明白的点就是

1:继承父对象相当于把他当作子类的一个成员

2:我们去不同类里面去调用函数,都是通过传this指针,通过this指针去调用函数,可见当p去调用test传的this是B*类型,而test只在A中有,我们说过继承的父对象相当于一个成员,这里去A中调test的时候,在A里面this肯定是A*这个类型,说明B*转化为A*发生切片,这时候test()里面调用的又是一个重写的虚函数func()(缺省值不同不影响重写),符合多态调用,那么就会去调B里面的func

3可是那么问题来了,缺省值不同到底用哪个呢?,这里又要知道一个知识点,重写是指重写的实现,说白了就是壳子用的是父类的,函数里面代码逻辑是用的子类的,那么答案为B

问答

1. 什么是多态?

要分为两点来答

1静态多态:函数重载

2动态多态:继承中重写的虚函数+父类指针调用

更方便和灵活多种形态的调用

2. 什么是重载、重写(覆盖)、重定义(隐藏)?

在文章中已经给出详细图片

3. 多态的实现原理?

虚函数表

4. inline函数可以是虚函数吗?

可以,不过编译器就忽略inline属性,这个函数就不再是 inline,因为虚函数要放到虚表中去。

inline 函数没有地址,不会去call这个函数,直接展开,注意:类里面定义默认都是内联

5. 静态成员可以是虚函数吗?

不能,因为静态成员函数没有this指针,使用类型::成员函数 的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表。

6. 构造函数可以是虚函数吗?

不能,因为对象中的虚函数表指针是在构造函数初始化列表 阶段才初始化的。那你放进去,还怎样调用构造函数呢?

7. 析构函数可以是虚函数吗?什么场景下析构函数是虚函数?

可以,并且最好把基类的析 构函数定义成虚函数。因为我们要使析构的时候也满足多态调用,要不然析构的时候子类去用了父类的析构,会有内存泄漏

8. 对象访问普通函数快还是虚函数更快?

首先如果是普通对象,是一样快的。如果是指针 对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函 数表中去查找。

9. 虚函数表是在什么阶段生成的,存在哪的?

虚函数表是在编译阶段就生成的(在构造函数里面初始化),一般情况 下存在代码段(常量区)的。

10. C++菱形继承的问题?虚继承的原理?

问题我在文章里面已经解释清楚了,虚继承的原理使虚基表

注意这里不要把虚函数表和虚基 表搞混了。

11. 什么是抽象类?抽象类的作用?

参考(3.抽象类)。抽象类强制重写了虚函数,另外抽 象类体现出了接口继承关系。

大家可以去积极思考一下哦,都是大厂考过的题

http://www.xdnf.cn/news/4000.html

相关文章:

  • 【C++项目实战】日志系统
  • WEB表单和表格标签综合案例
  • win10启动项管理在哪里设置?开机启动项怎么设置
  • Android工厂模式
  • 抽奖系统(基于Tkinter)
  • 微服务项目中网关服务挂了程序还可以正常运行吗
  • 数学复习笔记 2
  • JAVA在线考试系统考试管理题库管理成绩查询重复考试学生管理教师管理源码
  • JobHistory Server的配置和启动
  • LCD,LED
  • 期末项目Python
  • GoogleTest:GMock初识
  • 嵌入式开发学习日志Day13
  • window 系统 使用ollama + docker + deepseek R1+ Dify 搭建本地个人助手
  • C++笔记之接口`Interface`
  • 恶心的win11更新DIY 设置win11更新为100年
  • 《赤色世界》彩蛋
  • 数据封装的过程
  • 分析atoi(),atol()和atof()三个函数的功能
  • 【今日三题】小红的口罩(小堆) / 春游(模拟) / 数位染色(01背包)
  • 【Bootstrap V4系列】学习入门教程之 组件-卡片(Card)
  • Linux怎么更新已安装的软件
  • sudo useradd -r -s /bin/false -U -m -d /usr/share/ollama ollama解释这行代码的含义
  • 1.openharmony环境搭建
  • osquery在网络安全入侵场景中的应用实战(二)
  • 关于毕业论文,查重,AIGC
  • QT6 源(78):阅读与注释滑动条 QSlider 的源码,其是基类QAbstractSlider 的子类,及其刻度线的属性举例
  • 算法热题——等价多米诺骨牌对的数量
  • 【实战教程】React Native项目集成Google ML Kit实现离线水表OCR识别
  • 【云备份】服务端业务处理模块设计与实现