前言
在 C++ 多态的语法层面,我们已经认识了虚函数、重写、纯虚函数等关键知识点。多态的底层依赖于 vptr(虚指针)与 vtable(虚函数表)。但在学习时,常有以下疑问:
- 为什么带虚函数的类,sizeof 大小会多出 4/8 字节?
- 基类指针指向不同派生类对象,是如何在运行时找到对应函数的?
- 虚表、虚指针、虚函数分别存在内存哪个区域?
- 静态绑定和动态绑定到底有什么区别?
本篇从内存布局、对象模型、汇编视角、虚表结构出发,把 C++ 多态的底层原理彻底讲透。
正文
一、虚函数与普通函数的区别
下面我们通过一道题来阐明这个问题:
下面编译为 32 位程序的运行结果是什么? A. 编译报错 B. 运行报错 C. 8 D. 12
class Base {
public:
virtual void Func1() { cout << "Func1()" << endl; }
protected:
int _b = 1;
char _ch = 'x';
};
int main() {
Base b;
cout << sizeof(b) << endl;
return 0;
}
正常对于一个类来说,其成员函数所占内存总和再内存对齐之后就是其类内存大小。我们试着来看一下这个带虚函数类的内存大小:
12
正常来说是 1+5 然后内存对齐为 8,可是其运行结果是 12,为什么会这样呢?
监视窗口调试一下:

咦?这个 _vfptr 是什么东西?没错,像是上方提到的,对象中的这个指针我们叫做虚函数表指针(v 代表 virtual,f 代表 function)。一个含有虚函数的类中至少都有一个虚函数表指针,因为一个类所有虚函数的地址要被放到这个类对象的虚函数表中,虚函数表也简称虚表。
二、多态的原理
多态是如何实现的



从底层的角度 Func 函数中 ptr->BuyTicket(),是如何作为 ptr 指向 Person 对象调用 Person::BuyTicket,ptr 指向 Student 对象调用 Student::BuyTicket 的呢?通过上图我们可以看到,满足多态条件后,底层不再是编译时通过调用对象确定函数的地址,而是运行时到指向的对象的虚表中确定对应的虚函数的地址,这样就实现了指针或引用指向基类就调用基类的虚函数,指向派生类就调用派生类对应的虚函数。
第一张图,ptr 指向的 Person 对象,调用的是 Person 的虚函数;第二张图,ptr 指向的 Student 对象,调用的是 Student 的虚函数。
class Person {
public:
virtual void BuyTicket() { cout << "买票 - 全价" << endl; }
private:
string _name;
};
class Student : public Person {
public:
virtual void BuyTicket() { cout << "买票 - 打折" << endl; }
private:
string _id;
};
class Soldier : public Person {
public:
virtual void BuyTicket() { cout << "买票 - 优先" << endl; }
private:
string _codename;
};
void Func(Person* ptr) {
// 这里可以看到虽然都是 Person 指针 Ptr 在调用 BuyTicket
// 但是跟 ptr 没关系,而由 ptr 指向的对象决定的。
ptr->BuyTicket();
}
int main() {
// 其次多态不仅仅发生在派生类对象之间,多个派生类继承基类,重写虚函数后
// 多态也会发生在多个派生类之间。
Person ps;
Student st;
Soldier sr;
Func(&ps);
Func(&st);
Func(&sr);
return 0;
}
动态绑定与静态绑定
- 对不满足多态条件 (指针或者引用 + 调用虚函数) 的函数调用是在编译时绑定,也就是编译时确定调用函数的地址,叫做静态绑定。
- 满足多态条件的函数调用是在运行时绑定,也就是在运行时到指向对象的虚函数表中找到调用函数的地址,也就做动态绑定。
// ptr 是指针+BuyTicket 是虚函数满足多态条件。
// 这里是动态绑定,编译在运行时到 ptr 指向对象的虚函数表中确定调用函数地址
ptr->BuyTicket();
00EF2001 mov eax,dword ptr [ptr]
00EF2004 mov edx,dword ptr [eax]
00EF2006 mov esi,esp
00EF2008 mov ecx,dword ptr [ptr]
00EF200B mov eax,dword ptr [edx]
00EF200D call eax // BuyTicket 不是虚函数,不满足多态条件。
// 这里是静态绑定,编译器直接确定调用函数地址
ptr->BuyTicket();
00EA2C91 mov ecx,dword ptr [ptr]
00EA2C94 call Student::Student(0EA153Ch)
虚函数表
- 基类对象的虚函数表中存放基类所有虚函数的地址。同类型的对象共用同一张虚表,不同类型的对象各自有独立的虚表,所以,基类和派生类有各自独立的虚表。
- 派生类由两部分构成,继承下来的基类和自己的成员,一般情况下,继承下来的基类中有虚函数表指针,自己就不会再生成虚函数表指针。但是要注意的这里继承下来的基类部分虚函数表指针和基类对象的虚函数表指针不是同一个,就像基类对象的成员和派生类对象中的基类对象成员也独立的。
- 派生类中重写的基类的虚函数,派生类的虚函数表中对应的虚函数就会被覆盖成派生类重写的虚函数地址。
- 派生类的虚函数表中包含,(1) 基类的虚函数地址,(2) 派生类重写的虚函数地址完成覆盖,派生类自己的虚函数地址三个部分。
- 虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个 0x00000000 标记。(这个 C++ 并没有进行规定,各个编译器自行定义的,vs 系列编译器会在后面放个 0x00000000 标记,g++ 系列编译不会放)
- 虚函数存在哪的?虚函数和普通函数一样的,编译好后是一段指令,都是存在代码段的,只是虚函数的地址又存到了虚表中。
- 虚函数表存在哪的?这个问题严格说并没有标准答案 C++ 标准并没有规定,我们写下面的代码可以对比验证一下。vs 下是存在代码段 (常量区)


class Base {
public:
virtual void func1() { cout << "Base::func1" << endl; }
virtual void func2() { cout << "Base::func2" << endl; }
void func5() { cout << "Base::func5" << endl; }
protected:
int a = 1;
};
class Derive : public Base {
public:
// 重写基类的 func1
virtual void func1() { cout << "Derive::func1" << endl; }
virtual void func3() { cout << "Derive::func1" << endl; }
void func4() { cout << "Derive::func4" << endl; }
protected:
int b = 2;
};
int main() {
Base b;
Derive d;
return 0;
}
int main() {
int i = 0;
static int j = 1;
int* p1 = new int;
const char* p2 = "xxxxxxxx";
printf("栈:%p\n", &i);
printf("静态区:%p\n", &j);
printf("堆:%p\n", p1);
printf("常量区:%p\n", p2);
Base b;
Derive d;
Base* p3 = &b;
Derive* p4 = &d;
printf("Person 虚表地址:%p\n", *(int*)p3);
printf("Student 虚表地址:%p\n", *(int*)p4);
printf("虚函数地址:%p\n", &Base::func1);
printf("普通函数地址:%p\n", &Base::func5);
return 0;
}
运行结果:
栈:010FF954
静态区:0071D000
堆:0126D740
常量区:0071ABA4
Person 虚表地址:0071AB44
Student 虚表地址:0071AB84
虚函数地址:00711488
普通函数地址:007114BF
显然,虚表地址在常量区。


