C++ | 虚函数初探
虚函数
虚函数 是在基类中使用关键字 virtual 声明的函数。在派生类中重新定义基类中定义的虚函数时,会告诉编译器不要静态链接到该函数。
我们想要的是在程序中任意点可以根据所调用的对象类型来选择调用的函数,这种操作被称为动态链接,或后期绑定。
1、普通的继承关系
#include <iostream>
class Base //定义基类
{
public:
Base(int a) :ma(a) {}
void Show()
{
std::cout << "Base: ma = " << ma << std::endl;
}
protected:
int ma;
};
class Deriver : public Base //派生类
{
public:
Deriver(int b) :mb(b), Base(b) {}
void Show()
{
std::cout << "Deriver: mb = " << mb << std::endl;
}
protected:
int mb;
};
int main()
{
Base* pb = new Deriver(10);
std::cout << sizeof(Base) << std::endl; // 4
std::cout << sizeof(Deriver) << std::endl; // 8
std::cout << typeid(Base).name() << std::endl; // class Base
std::cout << typeid(Deriver).name() << std::endl; // class Deriver
pb->Show(); // Base : ma = 10
return 0;
}
运行结果与我们预想的一样。其中 pb变量为指针类型,指针类型被认为是内置类型,且只与定义点有关,所以 Base * 类型的指针解引用之后是 Base 类型。
查看内存布局
打开命令行的开发者命令提示窗口
命令如下:(记得切换到该项目文件夹下)
cl file.cpp /d1reportSingleClassLayoutXXX
其中 file.cpp 是cpp文件名,XXX是文件中要查看的类名
分别输入命令查看 基类Base、派生类Derive 的内存布局
/* Base内存布局 */
class Base size(4):
+---
0 | ma
+---
/* Derive内存布局 */
class Derive size(8):
+---
0 | +--- (base class Base)
0 | | ma
| +---
4 | mb
+---
成员变量依据声明的顺序进行排列(类内偏移为0开始),成员函数不占内存空间。
我们可以看到,在 Derive 的内存布局中,有继承自 Base 类的数据成员。
可以看到派生类继承了基类的成员变量,在内存排布上,先是排布了基类的成员变量,接着排布派生类的成员变量,同样,成员函数不占字节
2、使用 virtual 关键字
给基类的 Show() 函数加上 virtual 关键字
class Base //定义基类
{
public:
Base(int a) :ma(a) {}
virtual void Show()
{
std::cout << "Base: ma = " << ma << std::endl;
}
protected:
int ma;
};
重新编译并运行,可以看到运行结果发生了改变。
查看内存布局
在基类中加入 virtual 关键字,基类内存布局中增加了一个{vfptr}指针(指向Base的虚表),Base 所占字节数也从 4 字节变成了 8 字节。同时,Base 还增加一个虚表(vftable),在该虚表中写入了Base中所有虚函数的地址。
在派生类中也有一个虚表指针和一个虚表,需要说明的是,派生类中的虚表继承自基类,派生类通过将自己的虚函数写入继承的虚表, 覆盖 掉原来的虚表(基类与派生类各自拥有一个虚表)。因此,在 Derive 的内存布局中只有一个虚表指针。
/* Base 内存布局 */
class Base size(8):
+---
0 | {vfptr}
4 | ma
+---
Base::$vftable@:
| &Base_meta
| 0
0 | &Base::Show
/* Derive 内存布局 */
class Derive size(12):
+---
0 | +--- (base class Base)
0 | | {vfptr}
4 | | ma
| +---
8 | mb
+---
Derive::$vftable@:
| &Derive_meta
| 0
0 | &Derive::Show
内存布局如图所示
3、虚函数表机制
基类指针指向派生类对象实质上是指向派生类对象中基类的起始部分,在虚函数表结构中有三部分组成,分别是:
- RTTI(Run-Time Type Identification)信息:通过保存在其中的类型信息在运行时能够使用基类的指针或引用来检查这些指针或引用所指的对象的实际派生类型。
- 偏移: 成员变量与类对象的偏移地址(在 vs 中,vfptr 相对于成员变量的优先级更大,位于内存分布的首位,所以偏移量为0)。
- 虚函数入口地址:在调用函数时通过 call 指令跳转到函数,在虚表中保存虚函数的入口地址可以在运行阶段通过查虚表的方式实现动多态。
vfptr是在构造函数的栈帧进行初始化的时候:在构造函数初始化列表之后并调用构造函数第一行代码之前,函数栈帧开辟后进行赋值虚表地址赋值给vfptr的,This指针的赋值也是在构造函数的栈帧进行。
4、基类指针指向派生类对象
在实例2 中有这样一段代码
Base* pb = new Derive(10);
虚函数调用:基类指针 pb 指向 派生类对象,而在派生类对象的内存布局中有一个虚表指针,其中虚表指针指向的 Derive 的虚表结构。因此,在 pb->Show()
调用时,实际上是 pb -> vfptr -> Derive::Show()
,最终在屏幕上输出了 “Derive: mb = 10” 。
可以这么理解,Base pb = new Derived();生成的是子类的对象,在构造时,子类对象的虚指针指向的是子类的虚表,接着由Derived到Base*的转换并没有改变虚表指针,pb 所指向的对象它在构造的时候就已经指向了子类的Derive::Show(),所以调用的是子类的虚函数,这就是多态了。
另外,在基类指针 pb 解引用时,优先查看 RTTI 信息中保存的类型信息。因此,*pb
的类型被解析成 Derive 类型。
5、虚函数与析构
在 main 函数中,派生类是在 new 形成的,也就是说在堆内存上开辟的,但在结束时我们并没有手动的释放就会造成内存泄漏。修改上述代码如下:
#include <iostream>
class Base //定义基类
{
public:
Base(int a) :ma(a)
{
std::cout << "Base()" << std::endl;
}
virtual void Show()
{
std::cout << "Base: ma = " << ma << std::endl;
}
///////////////////////////////////////////////////
/* 以下内容为新添加 */
~Base()
{
std::cout << "~Base()" << std::endl;
}
///////////////////////////////////////////////////
protected:
int ma;
};
class Derive : public Base //派生类
{
public:
Derive(int b) :mb(b), Base(b)
{
std::cout << "Derive()" << std::endl;
}
void Show()
{
std::cout << "Derive: mb = " << mb << std::endl;
}
///////////////////////////////////////////////////
/* 以下内容为新添加 */
~Derive()
{
std::cout << "~Derive()" << std::endl;
}
///////////////////////////////////////////////////
protected:
int mb;
};
int main()
{
Base* pb = new Derive(10);
delete pb;
return 0;
}
在原有的 Base类 和 Derive类 中,添加了析构函数,可以看到运行结果中类构造了两次,最后却被析构了一次。Derive 类是继承自 Base 类的,因此,在构造 Derive 对象时会先构造他的父类 Base 类,析构的时候理应也析构两次才对,这里却只析构了一次。
分析:
-
析构函数跟普通成员没有什么不同,只是编译器在会在特定的时候自动调用析构函数(离开作用域或者执行delete操作);甚至我们可以通过对象手动调用析构。
-
由于 Base 类中析构函数不是虚函数,就不满足动多态发生的条件,在 delete pb 时就会被当做普通函数调用,而 pb 本质上是 Base* 类型指针,因此在释放基类指针时调用了基类析构(~Base() ),而派生类对象还没有被析构造成了内存泄露。
综上,在 Derive类 与 Base类 中,在 delete pb 时,可以看做是发生了 pb -> ~Base() 的函数调用(对象指针调用析构函数,这里的pb为 Base* 类型),而我们希望执行的是 pb -> Derive() 的函数调用(这里的 pb 是Derive 类型),因此我们可以设法让 pb 在调用时实现动多态,而调用派生类的函数。
基类的析构函数声明为虚函数
将基类 Base 的析构函数申明为虚函数,这样析构函数就会被写入虚函数表,可以实现析构时的动多态。
/* Base */
virtual ~Base()
{
std::cout << "~Base()" << std::endl;
}
内存布局
/* Base */
Base::$vftable@:
| &Base_meta
| 0
0 | &Base::Show
1 | &Base::{dtor}
/* Derive */
Derive::$vftable@:
| &Derive_meta
| 0
0 | &Derive::Show
1 | &Derive::{dtor}
运行测试,和我们预想的结果一致。
其中,destroy()函数就是我们平时说的析构函数。添加了 virtual 关键字后基类中析构函数是虚函数,派生类的析构函数自动成为虚函数。基类就有一张虚函数表,派生类继承基类的时候会把自己的析构函数覆盖到虚函数表中,delete基类指针的时候,调用的就是该派生类析构函数而该派生类析构函数会先释放派生类对象再释放基类对象。这样的话就不会造成派生类的资源没有释放的问题。
因此,在继承中做父类(基类),并且可能会发生动多态时(比如 Base * pb = new Derive() )父类的析构函数要声明为虚函数,如果不用虚函数,子类的析构函数不能得到调用,会造成内存泄漏。但并不是要把所有类的析构函数都写成虚函数。只有当一个类被用来作为基类的时候,才把析构函数写成虚函数。
那么是不是所有类的析构函数都可以设置成虚析构呢?
可以,但没必要,也不建议这么做。设置成虚析构并不影响析构函数的调用,但设置虚析构会产生额外的开销,因为系统会产生虚表和虚表指针占用类的存储空间。