前言
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
如果只看成员变量,int 加 char 再做内存对齐,很多人会先猜到 8。实际是 12,原因就在对象开头多了一个虚函数表指针,通常叫 _vfptr。它指向这类对象对应的虚表。只要类里有虚函数,编译器一般就会给对象塞进这么一个隐藏成员。
这也是为什么'虚函数不是占对象大小的那部分',真正占空间的是那个指针。虚函数本身还是普通函数,代码在代码段里,变化的是对象怎么找到它。
多态是怎么跑起来的
多态的关键不是'写了 virtual',而是调用发生时,编译器不能直接把地址定死。
看这段代码:
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) {
// 调用目标由 ptr 指向的实际对象决定
ptr->BuyTicket();
}
int main() {
Person ps;
Student st;
Soldier sr;
Func(&ps);
Func(&st);
Func(&sr);
return 0;
}
Func(Person* ptr) 里看起来都是 Person* 在调用 BuyTicket(),但真正决定执行哪一个版本的,是 ptr 指向的对象类型。ptr 指向 Person,就走 Person::BuyTicket;指向 Student,就走 Student::BuyTicket。
这里的核心动作很朴素:先从对象里拿到虚表指针,再去虚表里取对应函数地址,最后间接调用。流程比'面向对象很高级'要平淡得多,但就是它在起作用。
动态绑定和静态绑定
这两个词经常被一起讲,其实差别很简单。
- 不满足多态条件的调用,编译时就能决定地址,这叫静态绑定。
- 满足多态条件时,调用地址要等到运行时根据对象类型去虚表里找,这叫动态绑定。
// ptr 是指针 + BuyTicket 是虚函数,满足多态条件。
// 这里是动态绑定,运行时到 ptr 指向对象的虚函数表中确定调用函数地址
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
// BuyTicket 不是虚函数,不满足多态条件。
// 这里是静态绑定,编译器直接确定调用函数地址
00EA2C91 mov ecx,dword ptr [ptr]
00EA2C94 call Student::Student(0EA153Ch)
这段汇编里,动态绑定的路径多了几次取值。代价不大,但它换来的是运行时分派能力。C++ 的多态本质上就是拿这点额外开销,去换灵活性。
虚函数表到底是什么
虚表可以理解成一个函数指针数组,里面放着当前类可见的虚函数入口。类对象里存的是虚表指针,不是整张表。
有几个点容易混:
- 同类型对象通常共享同一张虚表。
- 基类和派生类各自有自己的虚表。
- 派生类重写了基类虚函数后,对应槽位会被替换成派生类版本的地址。
- 派生类如果新增虚函数,虚表后面还会继续挂上新的入口。
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() {
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
这里能看出两件事。第一,虚表地址和普通对象数据不是一回事,它们不在同一个层面上。第二,虚表地址落在常量区这一侧,这和不同编译器的实现有关,不能拿标准去硬卡它的位置。C++ 标准并没有规定虚表必须放哪,能确认的是:虚函数代码本身在代码段,虚表是编译器维护的一张查表结构。
我一般把它记成一句话:对象里放的是'入口',代码段里放的是'实现'。多态只是把这个入口从编译期挪到了运行期。


