虚函数表浅析
一、什么是虚函数表
1.1 非多态函数与多态函数的区别
在C++中,函数分为非多态函数和多态函数两种类型。
-
非多态函数:通常我们在类中定义的普通成员函数就是非多态函数。这些函数在编译时就确定了调用的具体函数,不会发生动态绑定。调用非多态函数时,编译器会根据函数名和参数类型准确地选择调用的函数。
-
多态函数:多态函数是通过虚函数来实现的。虚函数是在基类中声明为虚拟的,表示它可以被派生类重写,并在运行时实现动态绑定。这意味着在调用虚函数时,实际调用的函数取决于对象的实际类型,而不是变量的声明类型。这是C++中实现多态性的基础。
1.2 虚函数的定义与声明
在C++中,使用virtual
关键字来声明一个虚函数。虚函数可以在基类中声明,并在派生类中进行重写,以实现运行时多态性。
例如,考虑一个基类Shape
和一个派生类Circle
的示例:
#include <iostream>
class Shape {
public:
virtual void draw() {
std::cout << "Drawing a shape." << std::endl;
}
};
class Circle : public Shape {
public:
void draw() override {
std::cout << "Drawing a circle." << std::endl;
}
};
int main() {
Shape* shape = new Circle();
shape->draw(); // 输出:Drawing a circle.
delete shape;
return 0;
}
二、虚函数表的产生原理
2.1 类的虚函数表指针
当一个类中包含虚函数时,编译器会在类的对象中添加一个特殊的指针,称为虚函数表指针(或简称vptr)。虚函数表指针是一个指向虚函数表的指针,虚函数表是一个存储着虚函数地址的数组。
在类的对象中,虚函数表指针是一个隐藏的成员变量,通常位于对象的起始位置。每个含有虚函数的类都有其独立的虚函数表,而所有从同一个类派生的对象都共享同一个虚函数表。
2.2 编译器如何创建虚函数表
编译器是如何创建虚函数表呢?让我们来了解一下它的实现过程:
-
定义虚函数:在类的声明中,使用
virtual
关键字声明一个函数为虚函数。 -
创建虚函数表:对于每个包含虚函数的类,编译器会在编译阶段创建一个虚函数表。虚函数表是一个包含了虚函数地址的数组,数组中的每个元素对应一个虚函数。
-
虚函数表指针:编译器会在包含虚函数的类中添加一个隐藏成员变量,即虚函数表指针(vptr)。这个指针指向该类的虚函数表。
-
赋值虚函数表指针:在创建对象时,编译器会在构造函数中将虚函数表指针(vptr)初始化为指向正确的虚函数表。
-
虚函数的调用:当通过基类指针或引用调用虚函数时,实际调用的函数是由虚函数表决定的。编译器会根据虚函数表指针(vptr)找到正确的虚函数,并进行调用。
注意:虚函数表(vtable)通常存储在类的只读数据段(如代码段或全局数据段)中。每个类都有一个对应的虚函数表,其中存储了该类所有虚函数的函数指针。
虚函数表的存储位置是在编译时就确定的,而不是在对象被创建时动态分配的。这意味着不同的类实例共享相同的虚函数表。当你创建一个类的多个对象时,它们会共享相同的虚函数表,这是因为类的虚函数表是固定的,与特定对象无关。
三、虚函数表在内存布局
class Base
{
public:
virtual void fun1() {};
virtual void fun2() {};
virtual void fun3() {};
void fun4() {};
private:
int m_A;
int m_B;
};
查看内存布局的方法:
msvr编译器:QMAKE_CXXFLAGS += /d1reportAllClassLayout
gcc编译器:QMAKE_CXXFLAGS += -fdump-class-hierarchy
内存布局如下
可以看出总共12个字节,虚函数表4个字节,m_A,m_B各占4个字节
虚函数表中记录了三个虚函数的地址
class Child :public Base
{
public:
virtual void fun1() {};
virtual void fun5() {};
private:
int m_C;
};
布局如下:
除了基类的12个字节外,另外多了m_C 4个字节总共16个字节。
虚函数表中继承了基类的虚函数表,派生类中重写的虚函数会覆盖基类的虚函数表地址。虚函数表指针也成了派生类作用域下,除了继承过来的虚函数外,还另外添加了派生类的虚函数fun5.
Question :为什么基类必须是虚函数,才能通过基类指针调用派生类的函数。
如果基类中不是虚函数,则无法添加到虚函数表中,自然也就不能被派生类的函数地址覆盖,所以无法调用到派生类函数。
四、析构函数定义为虚函数的意义
class Base {
public:
Base() {
qDebug() << "Base structor";
}
~Base() {
qDebug() << "Base destructor";
}
virtual void foo() {
qDebug() << "Base::foo";
}
};
class Derived : public Base {
public:
Derived() {
qDebug() << "Derived structor";
}
~Derived() {
qDebug() << "Derived destructor";
}
void foo() {
qDebug() << "Derived::foo";
}
};
int main(int argc, char *argv[]) {
Base* ptr = new Derived();
ptr->foo();
delete ptr;
return 0;
};
上面的例子中会发生内存泄漏吗?
输出结果
Base structor
Derived structor
Derived::foo
Base destructor
如果将ptr的类型修改为Derived* 呢?
利用基类指针生成派生类对象,在运行时会将对象类型转换为基类,因此在对象销毁时只会调用基类的析构函数,从而造成内存泄露;而利用派生类指针生成派生类对象,对象类型为派生类,在对象销毁时会先调用派生类的析构函数,但是派生类继承了基类的所有非静态数据成员,会继续回调基类的析构函数来释放基类的数据成员(编译器规定的),因此利用派生类指针生成派生类对象是可行的。
如果将~Base() 函数前加上关键字virtual。
利用基类指针指向派生类对象,运行时对象类型转换为基类,但不会改变虚函数表的地址,因此,在对象销毁时会自动调用子类的析构函数,然后又会回调基类的析构函数(编译器规定的),这样就销毁了对象中所有的数据成员,就不会发生内存泄露。
输出结果
Base structor
Derived structor
Derived::foo
Derived destructor
Base destructor
从结果可以看出虚析构函数会先调用子类的析构函数然后调用基类的析构函数,而普通虚函数则只是调用子类重写的函数
讨论:当多重继承的时候虚函数表和虚函数表指针是怎么样的?
多重继承时,会产生多个虚函数指针指向多个继承自不同基类的虚函数表, 如果派生类的虚函数将会添加到第一张虚函数表中。