前言
多态真正落到机器层面,靠的不是语法糖,而是 vptr 虚指针和 vtable 虚函数表。很多人第一次学这块都会卡在几个点上:为什么带虚函数的类 sizeof 会变大、基类指针怎么在运行时找到派生类实现、虚表和虚指针到底放在哪、静态绑定和动态绑定差在哪。把这些问题串起来看,C++ 多态就没那么玄乎了。
虚函数和普通函数的区别
普通类里,成员函数不计入对象大小,对象的内存占用主要由成员变量和对齐决定。可一旦类里出现虚函数,对象里通常会多出一个虚函数表指针 vptr,在 32 位环境下常见是 4 字节,64 位环境下常见是 8 字节。这个指针指向当前类型对应的虚函数表。
虚函数本身并不'住'在对象里。它和普通函数一样,编译后都是代码段里的函数入口,只是这些入口地址会被收进虚表。
多态是怎么工作的
C++ 的运行时多态,关键在'通过基类指针或引用调用虚函数'这个前提上。编译器不会在编译期把调用目标彻底定死,而是先从对象里取出 vptr,再顺着虚表找到对应函数地址,最后完成调用。对象实际是谁,调用的就是谁的实现。
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 指向对象实际类型对应的 BuyTicket
ptr->BuyTicket();
}
int main() {
Person ps;
Student st;
Soldier sr;
Func(&ps);
Func(&st);
Func(&sr);
return 0;
}
这段代码里,Func 只认识 Person*,但真正执行哪个版本的 BuyTicket,取决于指针指向的对象类型。这个行为就是多态。
静态绑定和动态绑定
不是所有函数调用都会等到运行时再决定。
- 不满足多态条件的调用,通常在编译期就确定目标地址,这叫静态绑定。
- 满足多态条件的调用,会在运行时根据对象实际类型查表,这叫动态绑定。
从汇编角度看,动态绑定就是多了一层'先取虚表,再取函数地址'的过程。静态绑定则直接跳到已知地址,编译器不会绕这一步。
动态绑定示例:
; ptr 是指针 + BuyTicket 是虚函数,满足多态条件
mov eax, dword ptr [ptr]
mov edx, dword ptr [eax]
mov esi, esp
mov ecx, dword ptr [ptr]
mov eax, dword ptr [edx]
call eax
静态绑定示例:
; BuyTicket 不是虚函数,不满足多态条件
; 编译器直接确定调用地址
mov ecx, dword ptr [ptr]
call Student::Student
虚函数表里放了什么
虚函数表本质上就是一个保存函数指针的数组。基类和派生类各自有自己的虚表,同一个类型的对象通常共享同一张虚表。
派生类对象里,继承来的那部分通常仍然带着虚表指针,但这个指针已经指向派生类自己的虚表了。虚函数一旦被重写,虚表中对应位置就会被替换成派生类版本的地址;派生类新增的虚函数,也会按顺序放进虚表。
常见情况下,虚表末尾还会看到一个 0x00000000 标记,不过这不是 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:
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;
Base* p3 = &b;
Derive* p4 = &d;
printf("Base 虚表地址:%p\n", *(int*)p3);
printf("Derive 虚表地址:%p\n", *(int*)p4);
printf("虚函数地址:%p\n", &Base::func1);
printf("普通函数地址:%p\n", &Base::func5);
return 0;
}
这段代码的目的很直接:把对象首地址强转后取出虚表指针,再和普通成员函数地址做对比。运行结果通常能看出,虚表地址和对象首部绑定得很紧,而普通函数地址只是代码段里的一个入口。
小结
多态不是'对象会变身',而是对象里多了一个能指路的表头,调用时根据实际类型查到正确的函数入口。理解了 vptr、vtable 和动态绑定,很多看起来模糊的现象——比如对象大小变化、虚函数调用路径、派生类覆盖行为——就都能对上号了。


