对于虚函数表和多态的掌握,意味着在实际的开发过程中,程序员能够充分利用C++面向对象编程中的多态技术,写出高拓展性以及耦合度比较低的优质代码。
另外,在C++面试的过程中,这个问题也是问的比较多的,如果不懂这个问题,就代表面试者对C++的掌握还是比较初级的阶段。
接下来,我们就站在编译器的角度去探究C++幕后的秘密。
首先,我们观察一下,虚函数引入之后,类会产生什么变化。接着,就会引出虚函数表的生成时机和生成原因。然后再涉及到虚函数表指针被赋值的时机,之后,就要看一看类对象在内存中的布局,最后把这些知识点串起来,再引出 虚函数表在支持多态方面的工作原理。
虚函数
首先创建一个空类A。
#include <iostream>
class A{
};
int main() {
A a;
cout<<"sizeof(a) = "<<sizeof(a)<<endl;
return 0;
}
运行结果:
sizeof(a) = 1
千万不要以为一个空类的sizeof值为0,一个对象,只要它占用内存空间, 那么这个对象占用的内存空间至少是1,哪怕是空类。接下来,继续向类A中加入两个普通的成员函数。
#include <iostream>
using namespace std;
class A{
public:
void func1(){}
void func2(){}
};
int main() {
A a;
cout<<"sizeof(a) = "<<sizeof(a)<<endl;
return 0;
}
再次执行程序,运行结果:
sizeof(a) = 1
这时,发现A类对象a的sizeof值还是1,这说明这个类A的普通成员函数,它并不占用类对象的内存空间。接着继续再向类A中放入一个虚函数。
#include <iostream>
using namespace std;
class A{
public:
void func1(){}
void func2(){}
public:
virtual void vFunc(){}
};
int main() {
A a;
cout<<"sizeof(a) = "<<sizeof(a)<<endl;
return 0;
}
再次运行,运行结果:
sizeof(a) = 8
发现,对象a的sizeof值,突然变成8了。
要想弄明白sizeof(a)的值为什么从1变成了8,可以分析一下,正是由于加入虚函数 virtual void vFunc(){} 才引起的变化。
虚函数引入之后,类会发生一系列的变化。比如刚才的sizeof(a)的值改变了,这个其实就是属于C++对象模型知识的一小部分。
当一个或多个虚函数加入到一个类中之后,编译器就会向类中插入一个看不见的成员变量。
虚函数表
当类中虚函数大于等于1个的时候,编译器就会为类生成一个虚函数表(virtual table),简称vtbl,这个虚函数表会一直伴随着类A。
在经过编译、链接,直到生成一个可执行文件后,这个类A以及伴随类A的虚函数表都会保存到这个可执行文件中,在这个可执行文件在执行的时候,也会被一并装在到内存中来。
虚函数表指针
那么对于这种有虚函数的类A,在编译的时候,编译器会向类A的构造函数中,安插为vptr赋值的语句。
A(){
vptr=&A::vftable;//编译器在编译期间做的
//...
}
这个是编译器是在编译期间做的,是编译器默默在背后为程序员所做的事情。伪代码大概是上面的感觉。
程序运行起来之后,当创建一个类A对象的时候,会执行类A的构造函数,因为构造函数中,有给vptr赋值的语句,从而呢,能够使vptr指向类A的vtbl。
当然,如果程序员没有书写自己的关于类A的构造函数的话,这个时候编译器就会默默为程序员生成一个类A的构造函数,并会默默地在这个构造函数中,安插给vptr赋值语句,只不过这种动作是背着程序员进行的,程序员看不到这个构造函数。
类对象在内存中的布局
以上A类中的内容太简单,我们添加两个成员变量,如下:
#include <iostream>
using namespace std;
class A{
public:
void func1(){}
void func2(){}
public:
virtual void vFunc(){}
private:
int m_a;
int m_b;
};
int main() {
A a;
cout<<"sizeof(a) = "<<sizeof(a)<<endl;
return 0;
}
运行结果:
sizeof(a) = 16
再添加两个虚函数,如下:
#include <iostream>
using namespace std;
class A{
public:
void func1(){}
void func2(){}
public:
virtual void vFunc(){}
virtual void vFunc1(){}
virtual ~A(){}
private:
int m_a;
int m_b;
};
int main() {
A a;
cout<<"sizeof(a) = "<<sizeof(a)<<endl;
return 0;
}
运行结果:
sizeof(a) = 16
在有m_a、m_b的前提下,可以发现,不论类A中的虚函数有几个,类A的实例a的sizeof值都为16,其中m_a、m_b都为int类型,每个int类型占4个字节,两个即为8个字节,虚函数表指针占8个字节。共16个字节。
此时类A对象a的内存布局:
虚函数表指针vptr | 8字节 |
int m_a; | 4字节 |
int m_b; | 4字节 |
如图所示:
普通成员函数属于类A的组成部分,并不占用类A对象的内存空间。
虚函数表在支持多态方面的工作原理
多态性:父类中有一个虚函数,子类中也有一个同名的虚函数,当通过父类指针new一个子类对象的时候,或者是通过父类引用来绑定一个子类对象的时候,如果用这个父类指针来调用虚函数,调用的其实是子类的虚函数。
谈到多态,多态必须存在虚函数,没有虚函数,绝不可能存在多态。那么类中定义了虚函数,并且要调用虚函数,才存在多态性的可能,仅仅是可能。
当调用虚函数的时候,可以看一下调用路线,是不是利用vptr找到vtbl,然后通过查询vtbl来找到虚函数表的入口地址并执行虚函数。如果调用虚函数的路线是这个路线,那么就是多态,如果走的不是这个路线,而是像调用普通成员函数那样直接调用,那么就不是多态。所以,从这个角度来讲,就不用管有没有继承关系,也不用管什么子类。
class Base{
public:
virtual void virFuc(){}
};
int main() {
Base* ba=new Base();
ba->virFuc(); //是多态
Base base;
base.virFuc(); //不是多态
Base* bas=&base;
bas->virFuc(); //是多态
}
1.程序中既存在父类,也存在子类,父类中必须含有虚函数,子类中也必须重写父类中的虚函数。
2.父类指针指向子类对象,或者父类引用绑定(指向)子类对象。
3.当通过父类的指针,或者引用,调用子类中重写的虚函数时,就能看出多态性的表现了。最终调用的是子类的虚函数。
class Base{
public:
virtual void virFunc(){}
};
class Device:public Base{
public:
virtual void virFunc(){}
};
int main() {
Device device;
Base* pBase = &device; //父类指针指向子类对象
pBase->virFunc(); //调用Device::virFunc()
Base* pBase1 = new Device();
pBase1->virFunc(); //调用Device::virFunc()
delete pBase1;
Device device2;
Base& ba = device2; //父类引用绑定子类对象
ba.virFunc(); //调用Device::virFunc()
}