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_1
和base_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
标签:函数,对象,理解,C++,Base1,base,基类,指针 来源: https://blog.csdn.net/m0_46308273/article/details/114539626