虚函数详解
文章目录
- 一、多态与重载
- 1、多态的概念
- 2、重载—编译期多态的体现
- 3、虚函数—运行期多态的体现
- 4.重载和重写和覆盖的区别
- 二、虚函数实例
- 三、虚函数的实现(内存布局)
- 1、无继承情况
- 2、单继承情况(无虚函数覆盖)
- 3、单继承情况(有虚函数覆盖)
- 4、多重继承情况(无虚函数覆盖)
- 5、多重继承情况(有虚函数覆盖)
- 四、虚函数的相关问题
- 1、构造函数为什么不能定义为虚函数
- 2、析构函数为什么要定义为虚函数?
- 3、如何去验证虚函数表的存在
- 五、虚函数和纯虚函数的区别
一、多态与重载
1、多态的概念
面向对象的语言有三大特性:继承、封装、多态。虚函数作为多态的实现方式,重要性毋庸置疑。
多态意指相同的消息给予不同的对象会引发不同的动作(一个接口,多种方法)。其实更简单地来说,就是“在用父类指针调用函数时,实际调用的是指针指向的实际类型(子类)的成员函数”。多态性使得程序调用的函数是在运行时动态确定的,而不是在编译时静态确定的。
2、重载—编译期多态的体现
重载,是指在一个类中的同名不同参数的函数调用,这样的方法调用是在编译期间确定的。
3、虚函数—运行期多态的体现
运行期多态发生的三个条件:继承关系、虚函数重写、父类指针或引用指向子类对象。
4.重载和重写和覆盖的区别
重写和重载的区别是什么,主要就如下几点区别:
1、定义不同:重载是定义相同的方法名、参数不同,重写是子类重写父类的方法
2、范围不同:重载是在一个类中,重写是子类与父类之间的
3、多态不同:重载是编译时的多态性,重写是运行时的多态性
4、参数不同:重载的参数个数、参数类型、参数的顺序可以不同,重写父类子方法参数必须相同
5、修饰不同:重载对修饰范围没有要求,重写要求重写方法的修饰范围大于被重写方法的修饰范围
1.重载:同一个类内(同一个类的作用域内),函数名相同,形参不同(个数不同或者名称不同)
2.重写(覆盖):基类中定义一个函数,在派生类中定义一个相同的函数名、形参的数目/名称相同、返回值相同的函数,必须有virtual
3.重定义(隐藏):基类中定义一个函数,在派生类中定义一个相同的函数名,但形参或返回值不同,不构成重写基类的函数就是重定义(隐藏)
二、虚函数实例
在上述例子中,我们首先定义了一个基类base,基类有一个名为vir_func的虚函数,和一个名为func的普通成员函数。而类A,B都是由类base派生的子类,并且都对成员函数进行了重写跟覆盖。然后我们定义三个base类型的指针Base、a、b分别指向类base、A、B。可以看到,当使用这三个指针调用func函数时,调用的都是基类base的函数。而使用这三个指针调用虚函数vir_func时,调用的是指针指向的实际类型的函数。最后,我们将指针b做强制类型转换,转换为A类型指针,然后分别调用func和vir_func函数,发现普通函数调用的是类A的函数,而虚函数调用的是类B的函数。
以上,我们可以得出结论当使用类的指针调用成员函数时,普通函数由指针类型决定(覆盖的跟指针类型一致),而虚函数由指针指向的实际类型决定(重写的跟实际指向的类型一致)。
虚函数的实现过程:通过对象内存中的vptr找到虚函数表vtbl,接着通过vtbl找到对应虚函数的实现区域并进行调用。
三、虚函数的实现(内存布局)
虚函数表中只存有一个虚函数的指针地址,不存放普通函数或是构造函数的指针地址。只要有虚函数,C++类都会存在这样的一张虚函数表,不管是普通虚函数亦或是纯虚函数,亦或是派生类中隐式声明的这些虚函数都会生成这张虚函数表。
虚函数表创建的时间:在一个类构造的时候,创建这张虚函数表,而这个虚函数表是供整个类所共有的。虚函数表存储在对象最开始的位置。虚函数表其实就是函数指针的地址。函数调用的时候,通过函数指针所指向的函数来调用函数。
1、无继承情况
#include <iostream>
using namespace std;
class Base
{
public:
Base(){cout<<"Base construct"<<endl;}
virtual void f() {cout<<"Base::f()"<<endl;}
virtual void g() {cout<<"Base::g()"<<endl;}
virtual void h() {cout<<"Base::h()"<<endl;}
virtual ~Base(){}
};
int main()
{
typedef void (*Fun)(); //定义一个函数指针类型变量类型 Fun
Base *b = new Base();
//虚函数表存储在对象最开始的位置
//将对象的首地址输出
cout<<"首地址:"<<*(int*)(&b)<<endl;
Fun funf = (Fun)(*(int*)*(int*)b);
Fun fung = (Fun)(*((int*)*(int*)b+1));//地址内的值 即为函数指针的地址,将函数指针的地址存储在了虚函数表中了
Fun funh = (Fun)(*((int *)*(int *)b+2));
funf();
fung();
funh();
cout<<(Fun)(*((int*)*(int*)b+4))<<endl; //最后一个位置为0 表明虚函数表结束 +4是因为定义了一个 虚析构函数
delete b;
return 0;
}
2、单继承情况(无虚函数覆盖)
假设有如下所示的一个继承关系:
请注意,在这个继承关系中,子类没有重载任何父类的函数。那么,在派生类的实例中,其虚函数表如下所示:
【Note】:
虚函数按照其声明顺序放于表中。
父类的虚函数在子类的虚函数前面。
3、单继承情况(有虚函数覆盖)
覆盖父类的虚函数是很显然的事情,不然,虚函数就变得毫无意义。下面,我们来看一下,如果子类中有虚函数重载了父类的虚函数,会是一个什么样子?假设,我们有下面这样的一个继承关系。
为了让大家看到被继承过后的效果,在这个类的设计中,我只覆盖了父类的一个函数:f()。那么,对于派生类的实例,其虚函数表会是下面的一个样子:
【Note】:
覆盖的f()函数被放到了虚表中原来父类虚函数的位置。
没有被覆盖的函数依旧在原来的位置。
这样,我们就可以看到对于下面这样的程序,
Base *b = new Derive();
b->f();
由b所指的内存中的虚函数表的f()的位置已经被Derive::f()函数地址所取代,于是在实际调用发生时,是Derive::f()被调用了。这就实现了多态。
4、多重继承情况(无虚函数覆盖)
下面,再让我们来看看多重继承中的情况,假设有下面这样一个类的继承关系。注意:子类并没有覆盖父类的函数。
对于子类实例中的虚函数表,是下面这个样子:
【Note】:
每个父类都有自己的虚表(有几个基类就有几个虚函数表)。
子类的成员函数被放到了第一个父类的表中。(所谓的第一个父类是按照声明顺序来判断的)。
5、多重继承情况(有虚函数覆盖)
下面我们再来看看,如果发生虚函数覆盖的情况。下图中,我们在子类中覆盖了父类的f()函数。
下面是对于子类实例中的虚函数表的图:
**我们可以看见,三个父类虚函数表中的f()的位置被替换成了子类的函数指针。这样,我们就可以任一静态类型的父类来指向子类,并调用子类的f()了。**如:
Derive d;
Base1 *b1 = &d;
Base2 *b2 = &d;
Base3 *b3 = &d;
b1->f(); //Derive::f()
b2->f(); //Derive::f()
b3->f(); //Derive::f()
b1->g(); //Base1::g()
b2->g(); //Base2::g()
b3->g(); //Base3::g()
四、虚函数的相关问题
C++的虚函数主要作用是“运行时多态”,父类中提供虚函数的实现,为子类提供默认的函数实现。
子类可以重写父类的虚函数实现子类的特殊化。
1、构造函数为什么不能定义为虚函数
构造函数不能是虚函数。
首先,我们已经知道虚函数的实现则是通过对象内存中的vptr来实现的。而构造函数是用来实例化一个对象的,通俗来讲就是为对象内存中的值做初始化操作。那么在构造函数完成之前,vptr是没有值的,也就无法通过vptr找到作为虚函数的构造函数所在的代码区。
2、析构函数为什么要定义为虚函数?
析构函数可以是虚函数且推荐最好设置为虚函数。
class B
{
public:
B() { printf("B()\n"); }
virtual ~B() { printf("~B()\n"); }
private:
int m_b;
};
class D : public B
{
public:
D() { printf("D()\n"); }
~D() { printf("~D()\n"); }
private:
int m_d;
};
int main()
{
B* pB = new D();
delete pB;
return 0;
}
如果~B没有virtual,则下面结果
C++中有这样的约束:执行子类构造函数之前一定会执行父类的构造函数;同理,执行子类的析构函数后,一定会执行父类的析构函数,这也是为什么我们一直建议类的析构函数写成虚函数的原因。
如果基类析构函数设置为虚函数,则父类指针实际指向的是子类的析构函数,执行子类的析构函数必然会执行父类析构函数,这样父类跟子类资源都得到释放。
3、如何去验证虚函数表的存在
typedef void(*Fun)(void);
// 取类的一个实例
Base b;
Fun pFun = NULL;
// 把&b转成int ,取得虚函数表的地址
cout << "虚函数表地址:" << (int*)(&b) << endl;
// 再次取址就可以得到第一个虚函数的地址了
cout << "虚函数表 — 第一个函数地址:" << (int*)*(int*)(&b) << endl;
pFun = (Fun)*((int*)*(int*)(&b));
pFun();
五、虚函数和纯虚函数的区别
1、纯虚函数只有定义,没有实现;而虚函数既有定义,也有实现的代码。
2、包含纯虚函数的类不能定义其对象,而包含虚函数的则可以。
上一篇: C++ 虚拟函数的概念和用法(基础)
下一篇: c++ 虚函数详解(你一定懂) - 前言
推荐阅读
-
MatLab 函数 xlsread、xlswrite 和 xlsfinfo-2. xlswrite 函数
-
C++ 仿函数外设和封装器 - 绑定函数
-
人工智能面试题:为什么二元分类不使用 MSE 损失函数?-人工智能面试问题:为什么二元分类不使用 MSE 损失函数?
-
在 keil (mdk) 中,在编译时删除未使用的函数,以减少代码量。
-
typeerror jquery__webpack_imported_module_5__default(...)(...).modal不是函数-掘金
-
基于 PG16.2 的 IvorySQL 3.2 版新增 Oracle XML 函数兼容性
-
ARM 嵌入式 C 字符串系列 23.6 -- 字符串转换为数值函数的实现
-
35 岁实现财务*,腾讯程序员手握2300万提前退休?-1000万房产、1000万腾讯股票、加上300万的现金,一共2300万的财产。有网友算了一笔账,假设1000万的房产用于自住,剩下1300万资产按照平均税后20-50万不等进行计算,大约花上26-60年左右的时间才能赚到这笔钱。也就是说,普通人可能奋斗一辈子,才能赚到这笔钱。在很多人还在为中年危机而惶惶不可终日的时候,有的人的35岁,就已经安全着陆,试问哪个打工人不羡慕?但问题是有这样财富积累必然有像样的实力做靠山。没有人可以不劳而获。 看到这里,肯定有人说,那么对于普通人来说,卷可能真就成了唯一的出路。但是卷也有轻松的卷,“偷懒”的卷法,对于程序员而言,刨除掉一时无法改掉的开会传统占用的大部分时间,如何把有限的时间和精力放在真正重要的架构设计、需求设计上,而不是重复的造*,编码、改bug、手动测试。因此在科技改变生活的今天,学会使用AI工具成为程序员们的必备技能。 以全栈式全自动的软件开发工具飞算SoFlu软件机器人为例,作为全球首款面向微服务架构设计和最佳实践的软件机器人,SoFlu软件机器人改变了原来手工编码的作业模式,通过可视化拖拽方式以及参数配置就能实现等同于编写复杂代码的业务逻辑,在设计业务逻辑时就完成了微服务应用开发,做到“业务即图,图即代码”,实现“软件开发,十倍提效”。 而作为飞算SoFlu软件机器人的一个重要组成部分,FuncGPT(慧函数)支持所有类型函数创建。通过自然语言描述Java函数需求,实时生成高质量、高可读性的Java函数代码。生成代码可直接复制到IDEA,或一键导入Java全自动开发工具函数库。 FuncGPT(慧函数)具有五大核心能力: ● 自然语言:通过自然语言即可生成函数,降低软件开发门槛。 ● 秒级函数开发:全自动秒级开发函数,秒级完成,效率千倍提升。 ● 一站式函数开发应用:函数生成即可直接应用,效率、安全有保障。 ● 质量可靠:生成的函数代码符合业界规范、具备良好的可读性,符合最优解。 ● 灵活生成:可以根据开发人员的具体需求生成并支持调整和修改。 以“将文件流转换为File对象”这个需求为例,以下是FuncGPT(慧函数)秒级生成的代码: / 类名:FileConverter // 函数名:convertStreamToFile // 函数功能:将文件流转换为File对象 // POM依赖包:无 import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; public class FileConverter { /** * 将文件流转换为File对象 * @param inputStream 文件流 * @param filePath 文件路径 * @param fileName 文件名 * @return 转换后的File对象 * @throws IOException 如果发生I/O异常 */ public static File convertStreamToFile(InputStream inputStream, String filePath, String fileName) throws IOException { File file = new File(filePath + File.separator + fileName); // 创建File对象 try (FileOutputStream outputStream = new FileOutputStream(file)) { // 创建文件输出流 byte buffer = new byte[1024]; int bytesRead; while ((bytesRead = inputStream.read(buffer)) != -1) { // 从文件流读取数据并写入文件 outputStream.write(buffer, 0, bytesRead); } } return file; // 返回转换后的File对象 } } // 函数示例 // 将文件流转换为File对象示例 // 入参:inputStream,文件流 // 入参:filePath,文件路径 // 入参:fileName,文件名 // 出参:file,转换后的File对象 // 调用示例: // InputStream inputStream = new FileInputStream("example.txt"); // String filePath = "C:\\Users\\User\\Documents"; // String fileName = "example.txt"; // File file = FileConverter.convertStreamToFile(inputStream, filePath, fileName); // System.out.println(file.getAbsolutePath); // 输出结果:例如,将文件流转换为File对象后,文件的绝对路径为:C:\Users\User\Documents\example.txt // 则输出结果为:C:\Users\User\Documents\example.txt 通过分析,不难发现以上代码:
-
nodejs 函数缓存库 memoizeOne 和 micro-memoize Memoizee。
-
ORCA 教程:使用双杂交广义函数