多态是 C++ 面向对象的三大特性之一,也是面试和开发中最易踩坑的知识点。本文不仅会梳理多态的核心规则,还会通过可运行的实战代码,带你从语法层面穿透到底层虚表实现,彻底搞懂多态的本质。
一、多态的核心语法规则(必懂)
1. 多态的实现前提
动态多态(运行时多态)必须满足三个条件:
- 基类声明
virtual虚函数,派生类重写该函数; - 通过基类指针 / 引用调用虚函数;
- 继承关系成立。
反例:值传递导致对象切割
cpp
#include<iostream>
using namespace std;
class Person {
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
virtual void Func1() { cout << "Person::Func1()" << endl; }
virtual void Func2() { cout << "Person::Func2()" << endl; }
int _a = 0; // 方便后续内存布局验证
};
class Student : public Person {
public:
// 重写基类虚函数
virtual void BuyTicket() { cout << "买票-半价" << endl; }
private:
// 派生类新增虚函数
virtual void Func3() { cout << "Student::Func3()" << endl; }
protected:
int _b = 1;
};
// 值传递:发生对象切割,多态失效
void Func(Person p) {
p.BuyTicket(); // 始终调用Person::BuyTicket
}
int main() {
Person ps;
Student st;
// 正确:引用传递,触发多态
Person& ref = st;
ref.BuyTicket(); // 输出:买票-半价
// 错误:值传递,对象切割
Func(st); // 输出:买票-全价
// 指针赋值验证:值传递vs指针传递
ps = st; // 值拷贝,ps仍是Person类型,_a被赋值,但虚表不变
Person* ptr = &st; // 指针指向Student对象,虚表指向Student的虚表
ptr->BuyTicket(); // 输出:买票-半价
return 0;
}
运行结果:
plaintext
买票-半价
买票-全价
买票-半价
核心解释:
- 值传递时,
st(Student)会被 "切割" 成 Person 对象,仅保留基类成员,虚表指针也指向 Person 的虚表; - 指针 / 引用传递时,仅传递地址,不会改变对象本身的类型和虚表指向,因此能触发多态。
2. 虚函数重写的 "隐藏坑":默认参数不参与重写
很多新手会忽略:虚函数的默认参数是编译时决议的,不会随多态一起动态变化。
cpp
class A {
public:
virtual void func(int val = 1) { std::cout << "A->" << val << std::endl; }
};
class B : public A {
public:
// 重写func,但默认参数是0(仅编译时生效)
void func(int val = 0) { std::cout << "B->" << val << std::endl; }
virtual void test() {
func(); // 类内直接调用,使用B的默认参数0
}
};
int main() {
B* p = new B;
p->test(); // 输出:B->0
A* ptr = new B;
ptr->func(); // 输出:B->1(函数体是B的,但默认参数是A的1)
delete p;
delete ptr;
return 0;
}
运行结果:
plaintext
B->0
B->1
核心解释:
- 虚函数的 "重写" 仅针对函数体,默认参数由调用时的指针 / 引用类型决定(编译时确定);
- 这是多态中极易踩坑的细节,建议尽量避免给虚函数设置默认参数。
二、多态的底层实现:虚表(vtable)与虚表指针(vptr)
多态的底层依赖编译器生成的虚函数表(vtable) 和对象中的虚表指针(vptr)。我们可以通过实战代码直接 "看到" 虚表的存在和布局。
1. 验证虚表的存在:打印内存地址
cpp
#include<iostream>
#include<cstdio>
using namespace std;
class Person {
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
virtual void Func1() { cout << "Person::Func1()" << endl; }
virtual void Func2() { cout << "Person::Func2()" << endl; }
int _a = 0;
};
class Student : public Person {
public:
virtual void BuyTicket() { cout << "买票-半价" << endl; }
private:
virtual void Func3() { cout << "Student::Func3()" << endl; }
protected:
int _b = 1;
};
// 定义函数指针类型,匹配虚函数的签名(无参、无返回值)
typedef void(*FUNC_PTR) ();
// 打印虚表内容(遍历虚函数指针数组,直到遇到nullptr)
void PrintVFT(FUNC_PTR* table) {
for (size_t i = 0; table[i] != nullptr; i++) {
printf("[%d]:%p-> ", i, table[i]);
// 调用虚函数,验证函数体
FUNC_PTR f = table[i];
f();
}
printf("\n");
}
int main() {
// 先打印不同区域的内存地址,对比虚表位置
int a = 0;
printf("栈区变量地址:%p\n", &a);
static int b = 0;
printf("静态区变量地址:%p\n", &b);
int* p = new int;
printf("堆区变量地址:%p\n", p);
const char* str = "hello world";
printf("常量区字符串地址:%p\n", str);
// 验证虚表存在:Person和Student的虚表地址不同
Person ps;
Student st;
printf("Person虚表指针指向的地址:%p\n", *((int*)&ps));
printf("Student虚表指针指向的地址:%p\n", *((int*)&st));
// 解析并打印Person的虚表
int vft1 = *((int*)&ps);
printf("===== Person虚表内容 =====\n");
PrintVFT((FUNC_PTR*)vft1);
// 解析并打印Student的虚表
int vft2 = *((int*)&st);
printf("===== Student虚表内容 =====\n");
PrintVFT((FUNC_PTR*)vft2);
delete p;
return 0;
}
运行结果(示例,地址因环境而异):
plaintext
栈区变量地址:006FF784
静态区变量地址:0049B000
堆区变量地址:00701E90
常量区字符串地址:00498008
Person虚表指针指向的地址:0049A2B8
Student虚表指针指向的地址:0049A2E0
===== Person虚表内容 =====
[0]:004118A0-> 买票-全价
[1]:00411900-> Person::Func1()
[2]:00411960-> Person::Func2()
===== Student虚表内容 =====
[0]:004119C0-> 买票-半价
[1]:00411900-> Person::Func1()
[2]:00411960-> Person::Func2()
[3]:00411A20-> Student::Func3()
核心结论:
- 虚表存储在常量区(地址与字符串常量接近),而非栈 / 堆 / 静态区;
- 派生类虚表会继承基类虚表的内容,并将 "重写的虚函数" 替换为自身版本,新增的虚函数追加到虚表末尾;
- 即使
Func3是 private 权限,仍会出现在虚表中(权限不影响虚表布局,仅影响调用)。
2. 多继承下的虚表布局:最易踩坑的场景
单继承的虚表布局简单,但多继承的虚表会生成多个虚表指针,这是面试高频考点,也是开发中的易坑点。
cpp
#include<iostream>
#include<cstdio>
using namespace std;
class Base1 {
public:
virtual void func1() { cout << "Base1::func1" << endl; }
virtual void func2() { cout << "Base1::func2" << endl; }
private:
int b1; // 4字节
};
class Base2 {
public:
virtual void func1() { cout << "Base2::func1" << endl; }
virtual void func2() { cout << "Base2::func2" << endl; }
private:
int b2; // 4字节
};
class Derive : public Base1, public Base2 {
public:
// 同时重写Base1和Base2的func1
virtual void func1() { cout << "Derive::func1" << endl; }
// 派生类新增虚函数
virtual void func3() { cout << "Derive::func3" << endl; }
private:
int d1; // 4字节
};
typedef void(*FUNC_PTR) ();
void PrintVFT(FUNC_PTR* table) {
for (size_t i = 0; table[i] != nullptr; i++) {
printf("[%d]:%p-> ", i, table[i]);
FUNC_PTR f = table[i];
f();
}
printf("\n");
}
int main() {
// 第一步:验证Derive对象的大小
cout << "Base1大小:" << sizeof(Base1) << endl; // 8字节(vptr+int b1)
cout << "Base2大小:" << sizeof(Base2) << endl; // 8字节(vptr+int b2)
cout << "Derive大小:" << sizeof(Derive) << endl; // 20字节(Base1(8)+Base2(8)+d1(4))
// 第二步:解析多继承的虚表
Derive d;
// 第一个虚表(Base1的虚表)
int vft1 = *((int*)&d);
printf("===== Derive中Base1的虚表 =====\n");
PrintVFT((FUNC_PTR*)vft1);
// 第二个虚表(Base2的虚表):跳过Base1的大小(8字节)
int vft2 = *((int*)((char*)&d + sizeof(Base1)));
printf("===== Derive中Base2的虚表 =====\n");
PrintVFT((FUNC_PTR*)vft2);
// 第三步:验证不同指针调用func1的结果
Base1* ptr1 = &d;
ptr1->func1(); // 输出:Derive::func1
Base2* ptr2 = &d;
ptr2->func1(); // 输出:Derive::func1
Derive* ptr3 = &d;
ptr3->func1(); // 输出:Derive::func1
return 0;
}
运行结果:
plaintext
Base1大小:8
Base2大小:8
Derive大小:20
===== Derive中Base1的虚表 =====
[0]:00411A80-> Derive::func1
[1]:00411AE0-> Base1::func2
[2]:00411B40-> Derive::func3
===== Derive中Base2的虚表 =====
[0]:00411A80-> Derive::func1
[1]:00411B00-> Base2::func2
Derive::func1
Derive::func1
Derive::func1
核心解释:
- 多继承时,派生类对象会包含多个虚表指针 (每个基类对应一个),因此
Derive大小 = Base1 (8)+Base2 (8)+d1 (4)=20 字节; - 派生类重写的
func1会替换所有基类虚表中对应的函数地址,因此无论通过 Base1/Base2/Derive 指针调用,都执行Derive::func1; - 派生类新增的虚函数(如
func3)会追加到第一个基类(Base1) 的虚表末尾。
3. 多继承指针转换的坑:地址偏移
多继承中,基类指针指向派生类对象时,指针地址可能发生偏移(保证虚表指针的正确指向):
cpp
int main() {
Derive d;
printf("Derive对象地址:%p\n", &d); // 假设:006FF6E0
Base1* ptr1 = &d;
printf("Base1指针地址:%p\n", ptr1); // 006FF6E0(与Derive地址一致)
Base2* ptr2 = &d;
printf("Base2指针地址:%p\n", ptr2); // 006FF6E8(偏移8字节,跳过Base1)
// 强制转换验证:需手动偏移才能正确转换
Derive* ptr3 = (Derive*)((char*)ptr2 - sizeof(Base1));
printf("恢复后的Derive指针地址:%p\n", ptr3); // 006FF6E0(正确)
return 0;
}
运行结果:
plaintext
Derive对象地址:006FF6E0
Base1指针地址:006FF6E0
Base2指针地址:006FF6E8
恢复后的Derive指针地址:006FF6E0
核心结论:
- 多继承中,非第一个基类的指针(如 Base2*)指向派生类时,地址会自动偏移,保证虚表指针的正确访问;
- 开发中避免手动强制转换多继承的指针,防止地址错误导致程序崩溃。
三、多态的核心注意事项(总结)
1. 语法规则
- 基类析构函数必须声明为虚函数,否则派生类析构函数不执行,导致内存泄漏;
- 虚函数重写必须严格匹配签名(函数名、参数、const 修饰符),建议用
override显式声明; - 构造 / 析构函数中调用虚函数无多态效果,默认参数不参与虚函数重写。
2. 底层实现
- 虚表存储在常量区,每个包含虚函数的类有独立虚表,对象通过 vptr 指向虚表;
- 单继承虚表会继承基类内容并替换重写函数,多继承会生成多个虚表指针;
- 多继承指针转换可能发生地址偏移,避免手动强制转换。
3. 实战建议
- 避免给虚函数设置默认参数,减少多态歧义;
- 多继承场景尽量简化,优先用组合替代多继承;
- 用
final禁止不必要的继承 / 重写,用override避免重写错误。
总结
- 语法层面:多态依赖 "虚函数 + 基类指针 / 引用",重写需严格匹配签名,值传递会导致对象切割;
- 底层层面:多态通过虚表实现,单继承只有一个虚表指针,多继承有多个且指针可能偏移;
- 实战层面 :通过本文的验证代码可直观看到虚表布局,多继承需注意指针地址偏移问题,建议用
override/final提升代码健壮性。