C++之虚函数与多态的实现原理分析
作者:一枝小雨
1.多态的原理
1.1 虚函数表(简称虚表)
class Base
{
public:
virtual void Func1()
{
cout << "Func1()" << endl;
}
private:
int _b = 1;
};问:sizeof(Base) 是多少?
- 32位系统下,他是 8。
int main()
{
Base a;
cout << sizeof(a) << endl; // 8
return 0;
}
观察监视窗口,我们发现除了_b成员,还多一个__vfptr放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(一般是__vftptr,即virtual function table ptr)。
一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表。
针对上面的代码我们做出以下改造:
- 我们增加一个派生类Derive去继承Base
- Derive中重写Func1
- Base再增加一个虚函数Func2和一个普通函数Func3
class Base
{
public:
virtual void Func1(){cout << "Base::Func1()" << endl;}
virtual void Func2(){cout << "Base::Func2()" << endl;}
void Func3(){cout << "Base::Func3()" << endl;}
private:
int _b = 1;
};
class Derive : public Base
{
public:
virtual void Func1(){cout << "Derive::Func1()" << endl;}
private:
int _d = 2;
};
int main()
{
Base b;
Derive d;
return 0;
}
观察监控窗口的信息,我们可以发现:
- 派生类对象d中也有一个虚表指针,d对象由两部分构成,一部分是父类继承下来的成员(Base 部分),一类是自己的成员(_d)。虚表指针就是图中的__vfptr。
- 基类b对象和派生类d对象虚表是不一样的,Func1 在 d 类中完成了重写,所以d的虚表中存的是重写的Derive::Func1,通过对比可以发现,d对象中的Func1的虚表指针和b对象中的Func1虚表指针指向地址不一样。所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法。
- Func2继承下来后是虚函数,所以放进了虚表,但是因为Func2没有重写覆盖,所以b对象中的Func2的虚表地址和d对象的Func2虚表地址一致。父类中的Func3也继承下来了,但是不是虚函数,所以不会放进虚表。
1.2 虚函数表本质
虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个 nullptr 标志数组结束。
派生类的虚表生成:
- 先将基类中的虚表内容拷贝一份到派生类虚表中。
- 如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数。
- 派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。
- 注意:对象中存的是虚表指针,不是虚表本身。虚表中存的是虚函数指针,不是虚函数,虚函数和普通函数一样的,都是存在代码段的,vs下虚表本身是存在代码段(常量区)的。
- 同类型的对象共用一个虚表。

1.3 多态的原理
多态是如何实现指向谁就调用谁的虚函数的?
在运行时,多态会到指向对象的虚表中查找要调用的虚函数的地址,父类对象的虚表中的虚函数指针指向的是父类虚函数,而子类对象的虚表中的虚函数指针指向的是子类重写后的虚函数。
class Person {
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person {
public:
virtual void BuyTicket() { cout << "买票-半价" << endl; }
};
void Func(Person& p)
{
p.BuyTicket();
}
int main()
{
Person Mike;
Func(Mike);
Student Johnson;
Func(Johnson);
return 0;
}
- 当 p 是指向mike对象时,p->BuyTicket在mike的虚表中找到虚函数是Person::BuyTicket。
- 当 p 是指向johnson对象时,p->BuyTicket在johson的虚表中找到虚函数是Student::BuyTicket。
也就是说,调用函数时,他其实并不知道自己要调用的是子类还是父类的虚函数,他只需要到这个对象的虚表里面找就行了,找到是哪个就是哪个。如果是父类对象,那就直接通过虚表指针到虚表里找。
如果是子类对象,我们知道:
派生类对象 可以赋值给 基类的对象 / 基类的指针 / 基类的引用。这里有个形象的说法叫切片或者切割。寓意把派生类中父类那部分切来赋值过去。
子类对象中的父类部分会被切割赋给 p,p 还是按照父类对象找虚函数的方式去虚表里找。这样就实现了不同对象去完成同一行为时,展现出不同的形态。
PS:满足多态以后的函数调用,不是在编译时确定的,是运行起来以后到指向对象的虚函数表中查找对应的虚函数的地址。不满足多态的函数调用是编译时直接确定的,通过 p 的类型确定要调用函数的地址。
1.4 动态绑定与静态绑定
- 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载
- 动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。
int i = 0; double d = 1.1; // 静态绑定 静态的多态(静态:编译时确定函数) f1(i); f1(d); // 动态绑定 动态的多态(一般的多态指的就是动态多态)(动态:运行时去虚表找函数) Base* p = new Base; p->Func1(); p = new Derive; p->Func1();
2. 单继承和多继承关系的虚函数表
需要注意的是在单继承和多继承关系中,下面我们去关注的是派生类对象的虚表模型,因为基类的虚表模型前面我们已经看过了,没什么需要特别研究的
2.1 单继承中的虚函数表
class Base {
public:
virtual void func1() { cout << "Base::func1" << endl; }
virtual void func2() { cout << "Base::func2" << endl; }
private:
int a;
};
class Derive :public Base {
public:
virtual void func1() { cout << "Derive::func1" << endl; }
virtual void func3() { cout << "Derive::func3" << endl; }
virtual void func4() { cout << "Derive::func4" << endl; }
private:
int b;
};
int main()
{
Base b;
Derive d;
return 0;
}
从监视窗口我们可以看到虚表的内容,但是我们 d 对象的 func3 和 func4 呢?监视窗口好像没有,实际上不是虚表中没有,而是监视窗口没有展示,它认为不需要展示,就没有显示出来。
我们可以自己打印虚表,看看到底有没有 func3 和 func4 :
typedef void(*VF_PTR)(); // 函数指针类型重定义
// 定义完成后可以使用 VF_PTR p;来创建一个函数指针对象
void PrintVFTable(VF_PTR* pTable)
{
for (size_t i = 0; pTable[i] != 0; ++i)
{
printf("vfTable[%d]:%p->", i, pTable[i]);
VF_PTR f = pTable[i];
f(); // 调用函数指针指向的这个函数
}
cout << endl;
}
int main()
{
Base b;
Derive d;
// 取对象中前四个字节存的虚表指针打印虚表
PrintVFTable((VF_PTR*)(*(int*)&b));
PrintVFTable((VF_PTR*)(*(int*)&d));
return 0;
}
事实证明 func3 和 func4 确实存在于虚表中,我们成功打印并且调用了。
2.2 多继承中的虚函数表
typedef void(*VF_PTR)(); // 函数指针类型重定义
// 定义完成后可以使用 VF_PTR p;来创建一个函数指针对象
void PrintVFTable(VF_PTR pTable[])
{
for (size_t i = 0; pTable[i] != 0; ++i)
{
printf("vfTable[%d]:%p->", i, pTable[i]);
VF_PTR f = pTable[i];
f(); // 调用函数指针指向的这个函数
}
cout << endl;
}
class Base1 {
public:
virtual void func1() { cout << "Base1::func1" << endl; }
virtual void func2() { cout << "Base1::func2" << endl; }
private:
int b1;
};
class Base2 {
public:
virtual void func1() { cout << "Base2::func1" << endl; }
virtual void func2() { cout << "Base2::func2" << endl; }
private:
int b2;
};
class Derive : public Base1, public Base2 {
public:
virtual void func1() { cout << "Derive::func1" << endl; }
virtual void func3() { cout << "Derive::func3" << endl; }
private:
int d1;
};
int main()
{
// base1虚表4 + int4 + base2虚表4 + int4 + int4 = 20字节
cout << sizeof(Derive) << endl; // 20
Derive d;
// base1 的虚表
PrintVFTable((VF_PTR*)(*(int*)&d));
// base2 的虚表
PrintVFTable((VF_PTR*)(*(int*)((char*)&d + sizeof(Base1))));
return 0;
}
- 这说明子类 d 对象的虚函数 func3 是往第一个继承的父类 base1 的虚表里放的。
- 另外也说明了先继承的父类,它的虚表放在前面(即低地址处)。
2.3 菱形继承、菱形虚拟继承
实际中不建议设计出菱形继承及菱形虚拟继承,一方面太复杂容易出问题,另一方面这样的模型,访问基类成员有一定得性能损耗。
3. Q&A
3.1 内联函数为什么不能是虚函数?
内联函数会在调用位置直接展开,所以内联函数没有地址,也不需要地址,没有函数地址就无法放入虚表,所以内联函数不能是虚函数。
3.2 静态函数为什么不能是虚函数?
1. 调用方式与对象绑定的根本差异
静态函数:
- 不依赖于任何类的实例(对象)
- 可以直接通过类名调用(
ClassName::StaticFunction()) - 没有
this指针,无法访问对象的非静态成员
虚函数:
- 完全依赖于类的实例(对象)
- 必须通过对象或对象指针调用
- 有
this指针,可以访问对象的非静态成员 - 通过虚函数表(vTable)实现动态绑定,而vTable是每个对象实例的一部分
2. 虚函数机制依赖于对象实例
虚函数的实现依赖于:
- 每个对象内部的虚函数表指针(vPtr)
- 通过vPtr在运行时查找正确的函数实现
静态函数没有this指针,因此无法访问对象的vPtr,也就无法实现动态绑定。如果静态函数是虚的,编译器无法知道应该使用哪个类的虚函数表。
3.3 虚表?虚基表?虚基类?
- 虚表是虚函数表,存储的是虚函数指针,是一个函数指针数组。
- 虚基表存储的是偏移量,是解决菱形继承的数据冗余和二义性问题的。
- 虚基类是在继承中给父类前面加 virtual 关键字,是为了解决菱形继承的数据冗余和二义性问题而存在的。
总结
以上为个人经验,希望能给大家一个参考,也希望大家多多支持脚本之家。
