C++虚函数的简单理解

为何要用

在了解虚函数的原理、实现之前,我们肯定心里会有所疑惑,为什么需要用到虚函数呢?普通函数存在什么弊病吗?
带着这个疑惑,我们可以来看看官方给出的解释:

虚函数的作用,用专业术语来解释就是实现多态性(Polymorphism),多态性是将接口与实现进行分离;用形象的语言来解释就是实现以共同的方法,但因个体差异,而采用不同的策略。

很迷惑对吧?
我们再来看看别的博主给出的解释:

在继承关系中,从this指针角度出发,可以这样考虑:指向基类和子类的指针,在运行时类型是不同的(分别指向不同的类)。 但是在编译时,他们都要指向基类的类型(从函数执行顺序可以看出,在子类初始化时,首先要进入基类的构造函数),去继承public方法,在遇到“virtual”关键字时,“编译指针(类似this的感觉)”会自动跳到对应子类中的函数实现处。而不是直接跳到基类中的函数实现。所以,子类中的方法就覆盖了基类中的方法。基类中的方法形成虚设,所以虚函数是也~

所以,作者高亮的部分的是我认为的答案。关于这个,我们来看如下的代码:

class A
{
public:
	A()
	{
		cout << "A created " << this << endl;
	}
	~A()
	{
		cout << "A destroyed " << this << endl;
	}
	void print()
	{
		cout << "This is A" << endl;
	}
};

首先我们设计了一个非常简单的类A,除了构造函数和析构函数之外就只有一个打印函数void print()

class B:public A
{
public:
	B()
	{
		cout << "B created " << this << endl;
	}
	~B()
	{
		cout << "B destroyed " << this << endl;
	}
	void print()
	{
		cout << "This is B" << endl;
	}
};

接下来我们设计了另一个类B,公有继承类A,和A保持一样,除了构造函数和析构函数之外,只有一个打印函数void print()

int main()
{
	A a;
	B b;
	A* pa = &a;
	B* pb = &b;

	pa->print();
	pb->print();

	// 基类指针
	A* t = new B();
	t->print();
	delete t;

	return 0;
}

接下来我们就分别定义了A类和B类的对象a和b,并且使用指针调用成员函数。输出结果如下:
在这里插入图片描述
我们可以发现,对于子类B来说,每次创建一个新对象时总会先去调用父类的构造函数,销毁一个对象时会先调用自身的析构函数随后调用父类的析构函数。
最关键的,父类的指针指向子类的对象时,调用成员函数是调用父类的
这个时候我们修改代码如下:

class A
{
public:
	A()
	{
		cout << "A created " << this << endl;
	}
	~A()
	{
		cout << "A destroyed " << this << endl;
	}
	virtual void print()
	{
		cout << "This is A" << endl;
	}
};

可以看到,与之前唯一的不同就是:增加了virtual关键字,使类A的成员函数变成了虚函数,运行结果如下:
在这里插入图片描述
可以看到,此时基类的指针调用了派生类的函数。

事实上,问题的所在就是:
通过基类指针只能访问派生类的成员变量,但是不能访问派生类的成员函数。

故,我们简单总结,为什么要引入虚函数?
就是要实现多态的机制,允许用基类的指针来调用派生类的函数

工作原理

通常,编译器处理虚函数的方法是:给每个对象添加一个隐藏成员。隐藏成员中保存了一个指向函数地址数组的指针。这种数组称为虚函数表(virtual function table,vtbl)。虚函数表中村蠢了为类对象进行声明的虚函数的地址。例如,基类对象包含一个指针,该指针指向基类中所有虚函数的地址表。派生类对象将包含一个指向独立地址表的指针。如果派生类提供了虚函数的新定义,该虚函数表将保存新函数的地址;如果派生类没有重新定义虚函数,该vtbl将保存函数原始版本的地址。如果派生类定义了新的虚函数,则该函数的地址也将被添加到vtbl中。
参见下图:
在这里插入图片描述

内存布局

要想知道C++对象的内存布局,可以有多种方式,比如:

  • 输出成员变量的偏移,可以通过offsetof宏来得到;
  • 通过调试器查看,比如常见的VS.

1.只有数据成员的对象

类实现如下:

class Base1
{
public:
	int base_1;
	int base_2;
};

对象大小及偏移:

sizeof (base1)8
offsetof (Base1, base_1)0
offsetof (Base1, base_2)4

故我们可以知道对象的布局如下:
在这里插入图片描述
可以看到,成员变量按照定义的顺序来保存,最先声明的在最上边,类对象的大小就是所有成员变量的大小之和。

2.仅拥有一个虚函数的类对象

类实现如下:

class Base1
{
public:
	int base_1;
	int base_2;

	virtual void base1_fun1(){}
};

对象大小及偏移如下:

sizeof (base1)12
offsetof (Base1, base_1)4
offsetof (Base1, base_2)8

我们会发现在成员变量base_1之前多了4个字节,并且base_1base_2的偏移都各自向后了4个字节,这说明类对象的最前面被多加了4个字节的东西。这个是什么呢?
我们打开VS2019 的编译器来看看:
在这里插入图片描述
可以看到,base_1前面多了一个变量_vfptr
这个呢,就是我们上面提到的指向虚函数表virtual function table(vtbl)的指针。其类型是void **,这说明它是一个void *指针。
【0】元素其实就是Base1::base1_fun1()函数的地址。
至此,我们可以得到该类的对象大小及偏移信息:

sizeof (base1)12
offsetof (_vfptr)0
offsetof (Base1, base_1)4
offsetof (Base1, base_2)8

故现在的对象布局如下:
在这里插入图片描述

3.拥有多个虚函数的类对象

类实现如下:

class Base1
{
public:
	int base_1;
	int base_2;

	virtual void base1_fun1() {}
	virtual void base1_fun2() {}
};

对象大小及偏移如下:

sizeof (base1)12
offsetof (_vfptr)0
offsetof (Base1, base_1)4
offsetof (Base1, base_2)8

我们可以发现,多了一个虚函数,但是类对象的大小却依然是12字节!
再来看看VS编译器下的调试:
在这里插入图片描述
我们可以看到,此时 _vfptr 所指向的函数指针数组中多了一个元素【1】,它的值就是新增加的Base1::base1_fun2()函数的地址。

简单总结

通过以上内存布局的整理,我们可以得到如下结论:

  • _vfptr 只是一个指针,它指向了一个函数指针数组(虚函数表);
  • 增加一个虚函数只是简单地向该类对应的虚函数表增加了一项而已,并不会因此而影响到类对象的大小以及布局情况。

参考资料

【1】虚函数(百度百科)
【2】草上爬.C++面试题之虚函数(表)实现机制.CSDN.2018.04.27
【3】zch9081.C++为什么要引入虚函数.CSDN.2014.09.17
【4】Stephen Prata. C++ Primer Plus(第6版)中文版. 北京:人民邮电出版社,2020:35

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值