目录
- 前言:同一个窗口,不同的人买不同的票
- 一、多态的概念:静与动
- 二、多态的构成条件:缺一不可
- 三、虚函数与重写:规则虽简单,踩坑也容易
- 四、override 和 final:编译器帮你查错
- 五、协变:返回值可以"子类化"
- 六、析构函数与虚函数:内存泄漏的无声杀手
- 七、纯虚函数与抽象类:定义接口,不定义实现
- 八、多态的底层原理:虚函数表到底长什么样
- 九、重载、重写、隐藏对比
- 写在最后
前言:同一个窗口,不同的人买不同的票
想象你正在写一个票务系统。窗口只有一个 BuyTicket() 函数,但不同身份的人买票,行为和价格完全不同:
- 普通人买票 → 全价
- 学生买票 → 打五折
- 军人买票 → 优先出票
你肯定不想写成这样:
void BuyTicket(Person* p) {
if (p 是普通人) 全价买票();
else if (p 是学生) 打折买票();
else if (p 是军人) 优先买票();
// 每新增一种身份,就得改这段代码——噩梦!
}
C++ 给出的答案是多态(Polymorphism)——用基类指针调用同一个函数,实际执行的代码由指向的对象类型在运行时决定。这正是 C++ 面向对象三大特性中最"高级"的一个,也是面试必考题。
这篇文章将带你从概念到原理、从代码到底层内存模型,一步步吃透 C++ 多态。
一、多态的概念:静与动
C++ 中的多态分为两类:
| 类型 | 别称 | 绑定时机 | 典型实现 |
|---|---|---|---|
| 静态多态 | 编译时多态 | 编译期间确定函数地址 | 函数重载、函数模板 |
| 动态多态 | 运行时多态 | 运行期间到虚表中查找函数地址 | 虚函数 + 基类指针/引用 |
静态多态你已经很熟悉了——同一个函数名,传 int 调一个版本,传 double 调另一个,编译器在编译时就帮你匹配好了。
动态多态才是本文的主角。它的核心是:传不同的对象给同一个基类指针/引用,完成不同的行为。
二、多态的构成条件:缺一不可
要实现运行时多态,必须同时满足三个条件:
- 必须是基类的指针或引用调用虚函数
- 被调用的函数必须是虚函数(
virtual修饰)- 派生类必须完成虚函数的重写/覆盖
为什么必须是指针或引用?因为只有指针和引用能指向派生类对象(还记得继承中的切片吗),值传递会把派生类对象切成基类,多态就失效了。
2.1 第一个多态程序
class Person {
public:
virtual void BuyTicket() {
cout << "买票-全价" << endl;
}
};
class Student : public Person {
public:
virtual void BuyTicket() {
cout << "买票-打折(五折)" << endl;
}
};
class Soldier : public Person {
public:
virtual void BuyTicket() {
cout << "买票-优先" << endl;
}
};
void Func(Person* ptr) {
ptr->BuyTicket(); // 同一行代码,不同行为!
}
int main() {
Person ps;
Student st;
Soldier sr;
Func(&ps); // 输出:买票-全价
Func(&st); // 输出:买票-打折(五折)
Func(&sr); // 输出:买票-优先
return 0;
}
关键点:ptr->BuyTicket() 这行代码始终没变,但实际调用的函数由 ptr 指向的对象的真实类型决定,而不是由 ptr 的声明类型 Person* 决定。这就是多态。
2.2 经典例子:动物的叫声
class Animal {
public:
virtual void talk() const {}
};
class Dog : public Animal {
public:
virtual void talk() const {
cout << "汪汪" << endl;
}
};
class Cat : public Animal {
public:
virtual void talk() const {
cout << "(>^ω^<)喵" << endl;
}
};
void letsHear(const Animal& animal) {
animal.talk(); // 同一行代码,传猫猫叫,传狗狗叫
}
int main() {
Cat cat;
Dog dog;
letsHear(cat); // (>^ω^<)喵
letsHear(dog); // 汪汪
return 0;
}
注意这里我们用的是引用(const Animal&)而不是指针,效果完全一样——指针和引用在多态中是等价的。
三、虚函数与重写:规则虽简单,踩坑也容易
3.1 虚函数的基本语法
类成员函数前加 virtual 关键字即可:
class Person {
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
注意:非成员函数不能加 virtual,virtual 只能修饰类的成员函数。
3.2 虚函数重写(Override)的规则
派生类中定义一个与基类虚函数返回值类型相同、函数名相同、参数列表完全相同的函数,就构成重写(也叫覆盖)。
class Person {
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person {
public:
// 返回值相同 + 函数名相同 + 参数相同 = 构成重写
virtual void BuyTicket() { cout << "买票-打折" << endl; }
};
3.3 一个关于 virtual 关键字的坑
派生类重写虚函数时,不加 virtual 也能构成重写——因为继承下来的虚函数在派生类中仍然保持虚函数属性:
class Student : public Person {
public:
// 即使没写 virtual,这也构成重写!(不推荐)
void BuyTicket() { cout << "买票-打折" << endl; }
};
考试选择题经常拿这点挖坑:选项里给你一个没加
virtual的派生类函数,问你"这构成多态吗"——答案是构成,函数体后面的重写才是关键。但实际写代码时强烈建议加上virtual,读代码的人一眼就知道这是虚函数。
3.4 一个经典面试题:虚函数 + 默认参数
class A {
public:
virtual void func(int val = 1) {
cout << "A->" << val << endl;
}
virtual void test() { func(); }
};
class B : public A {
public:
void func(int val = 0) {
cout << "B->" << val << endl;
}
};
int main() {
B* p = new B;
p->test(); // 请问输出什么?
return 0;
}
思考 30 秒…答案在下面揭晓。
答案:B->1
分析:
p->test()调用的是继承自 A 的test(),A::test() 中func()相当于this->func()。this是A*类型,指向的是 B 对象,满足多态条件(基类指针 + 虚函数),所以调用 B::func()。那为什么 val 是 1 不是 0?因为默认参数的值是在编译时根据调用者的静态类型决定的。
this的静态类型是A*,所以 val 取的是 A::func 的默认参数 1,而不是 B::func 的默认参数 0。这个题考察的知识点组合非常精妙:多态调用 + 默认参数的静态绑定特性。
四、override 和 final:编译器帮你查错
4.1 override:让编译器帮你检查是否真的重写了
C++ 对重写的要求很严格:返回值类型、函数名、参数列表必须完全一样。如果不小心写错了呢?
class Car {
public:
virtual void Drive() {} // 注意函数名是 D-r-i-v-e
};
class Benz : public Car {
public:
// 手滑写成了 Dirve——这不是重写,这是定义了一个新函数!
virtual void Dirve() { cout << "Benz-舒适" << endl; }
};
上面的代码编译不会报错,但多态调用时永远走基类的 Drive(),Bug 极难排查。
C++11 引入 override 关键字来解决这个问题:
class Benz : public Car {
public:
virtual void Dirve() override { // 编译报错!编译器告诉你 Dirve 没有重写任何基类虚函数
cout << "Benz-舒适" << endl;
}
};
最佳实践:所有重写虚函数的函数,都加上
override。
4.2 final:禁止后代再重写
如果某个虚函数不希望被后续的派生类进一步重写:
class Car {
public:
virtual void Drive() final {} // 到此为止,不允许再被重写
};
class Benz : public Car {
public:
virtual void Drive() {} // 编译错误!Drive 被标记为 final
};
final 也可以修饰整个类,表示这个类不能被继承(在继承篇中已经讲过)。
五、协变:返回值可以"子类化"
一般的重写要求返回值类型完全相同,但 C++ 允许一个例外——协变(Covariance):
基类虚函数返回基类对象的指针或引用时,派生类重写的虚函数可以返回派生类对象的指针或引用。
class A {};
class B : public A {};
class Person {
public:
virtual A* BuyTicket() {
cout << "买票-全价" << endl;
return nullptr;
}
};
class Student : public Person {
public:
virtual B* BuyTicket() { // 返回 B* 而不是 A*——构成协变!
cout << "买票-打折" << endl;
return nullptr;
}
};
协变在实际开发中用得不多,了解即可。但面试中偶尔会作为"虚函数重写有没有例外"的考点出现。
六、析构函数与虚函数:内存泄漏的无声杀手
这是面试中最高频的多态考点之一,一定要结合代码讲清楚。
6.1 问题场景
class A {
public:
~A() { cout << "~A()" << endl; }
};
class B : public A {
public:
~B() {
cout << "~B()->delete:" << _p << endl;
delete _p;
}
protected:
int* _p = new int[10];
};
int main() {
A* p1 = new A;
A* p2 = new B; // 基类指针指向派生类对象
delete p1; // OK,调用 ~A()
delete p2; // 只调用了 ~A(),没有调用 ~B()!
// B::_p 指向的内存泄漏了!
return 0;
}
问题出在哪里?delete p2 时,编译器看到 p2 是 A* 类型,就只调用了 A::~A()——而 B::~B() 中释放 _p 的代码根本没有执行。
6.2 解决方案:虚析构函数
class A {
public:
virtual ~A() { cout << "~A()" << endl; }
// 加了 virtual,构成虚函数
};
基类析构函数加了 virtual 后:
- 编译器会将
~A()和~B()统一命名(编译后都叫destructor) ~B()自动构成对~A()的重写delete p2时通过多态机制从虚表中找到~B()的地址,正确调用
delete p2;
// 输出:
// ~B()->delete:0x... ← 先调派生类析构,释放资源
// ~A() ← 再调基类析构
铁律:如果一个类被设计为基类(要被继承),那么它的析构函数必须是 virtual 的。
这保证了无论通过什么类型的指针 delete,都能正确调用到实际对象类型的析构函数。
一个常见的判断方法:如果类中有虚函数(说明这个类本来就被设计为多态基类),析构函数必须也是虚函数。
七、纯虚函数与抽象类:定义接口,不定义实现
7.1 语法
在虚函数声明的末尾加上 = 0,这个函数就变成了纯虚函数:
class Car {
public:
virtual void Drive() = 0; // 纯虚函数,不需要实现
};
包含纯虚函数的类叫抽象类。抽象类不能实例化对象:
Car car; // 编译错误!抽象类不能被实例化
7.2 用途:强制派生类实现接口
class Car {
public:
virtual void Drive() = 0;
};
class Benz : public Car {
public:
virtual void Drive() override {
cout << "Benz-舒适" << endl;
}
};
class BMW : public Car {
public:
virtual void Drive() override {
cout << "BMW-操控" << endl;
}
};
int main() {
// Car car; // 不能实例化
Car* pBenz = new Benz; // 但可以用基类指针指向
pBenz->Drive(); // Benz-舒适
Car* pBMW = new BMW;
pBMW->Drive(); // BMW-操控
return 0;
}
如果派生类继承了抽象类但没有重写纯虚函数,那派生类自己也变成抽象类,同样无法实例化。这就起到了强制派生类实现特定接口的作用。
设计启示:纯虚函数 + 抽象类 = C++ 中的"接口"。虽然 C++ 没有像 Java 那样的
interface关键字,但纯虚函数可以完美实现接口语义。
八、多态的底层原理:虚函数表到底长什么样
这是整个多态知识体系中最硬核的部分,也是区分"会用"和"理解"的分水岭。
8.1 一个令人困惑的 sizeof
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;
}
先算一下:int 占 4 字节,char 占 1 字节,内存对齐下应该是 8 字节。但实际运行结果是 12(32位)或 16(64位)。
多出来的那部分就是一个隐藏的指针——__vfptr(virtual function pointer table pointer),即虚函数表指针。
8.2 虚函数表的结构
Base对象内存布局(32位):
┌─────────────────┐
│ __vfptr ───────→ 虚函数表(虚表/vftable)
│ (4 bytes) │ ┌──────────────────────┐
├─────────────────┤ │ &Base::Func1 (地址) │
│ _b = 1 │ ├──────────────────────┤
│ (4 bytes) │ │ 0x00000000 (结束标记)│
├─────────────────┤ └──────────────────────┘
│ _ch = 'x' │
│ (1 byte) │ 虚函数表是一个存函数指针的数组,
│ + 对齐3byte │ 最后一般有一个 0x00000000 结束标记。
└─────────────────┘
关键概念:
- 每个含有虚函数的类,它的所有对象都共享同一张虚函数表
- 虚函数表是一个函数指针数组,存储该类所有虚函数的地址
- 每个对象有自己的
__vfptr,指向所属类的虚函数表
8.3 派生类的虚函数表——覆盖是如何发生的
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::func3" << endl; } // 新增虚函数
void func4() { cout << "Derive::func4" << endl; } // 普通函数
protected:
int b = 2;
};
派生类的虚函数表是这样生成的:
- 先拷贝基类的虚函数表(func1 的地址, func2 的地址)
- 把重写的虚函数地址覆盖上去(把 func1 的位置替换成 Derive::func1 的地址)
- 追加派生类新增的虚函数地址(func3 的地址追加到后面)
Base 虚表: Derive 虚表:
┌──────────────────┐ ┌──────────────────────┐
│ &Base::func1 │ │ &Derive::func1 ←覆盖 │
├──────────────────┤ ├──────────────────────┤
│ &Base::func2 │ │ &Base::func2 ←保持 │
├──────────────────┤ ├──────────────────────┤
│ 0x00000000 │ │ &Derive::func3 ←新增 │
└──────────────────┘ ├──────────────────────┤
│ 0x00000000 │
└──────────────────────┘
注意:func5() 和 func4() 是普通函数,不在虚函数表中。它们通过编译时静态绑定直接调用。
8.4 多态调用到底是怎么发生的
回到最开始的例子:
void Func(Person* ptr) {
ptr->BuyTicket(); // 这一行,底层做了什么?
}
静态绑定(不满足多态条件时):
; 如果 BuyTicket 不是虚函数,编译器直接生成:
mov ecx, dword ptr [ptr]
call Person::BuyTicket ; 地址在编译时就确定了
动态绑定(满足多态条件时):
; ptr 是基类指针 + BuyTicket 是虚函数 → 运行时查虚表
mov eax, dword ptr [ptr] ; (1) 取 ptr 指向的对象首地址
mov edx, dword ptr [eax] ; (2) 取对象首地址处的 __vfptr 值(即虚表地址)
mov ecx, dword ptr [ptr] ; (3) 准备 this 指针
mov eax, dword ptr [edx] ; (4) 取虚表中第一个函数的地址
call eax ; (5) 调用这个地址——这时才确定是哪个函数
步骤 (4) 是关键:到虚表中取函数地址,而不是用编译时固定的地址。
- 如果
ptr指向 Person 对象 → 虚表存的是Person::BuyTicket→ 执行全价买票 - 如果
ptr指向 Student 对象 → 虚表存的是Student::BuyTicket→ 执行打折买票
多态的本质:在运行时通过虚函数表间接查找函数地址,而不是在编译时直接硬编码函数地址。
8.5 虚函数和虚函数表存在内存的哪里?
运行这段代码可以得到验证:
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("Base虚表地址:%p\n", *(int*)p3); // 和常量区地址接近
printf("Derive虚表地址:%p\n", *(int*)p4); // 和常量区地址接近
return 0;
}
典型运行结果(VS 下):
栈: 010FF954
静态区: 0071D000
堆: 0126D740
常量区: 0071ABA4
Base虚表: 0071AB44 ← 和常量区地址非常接近
Derive虚表:0071AB84 ← 和常量区地址非常接近
虚函数地址:00711488 ← 和常量区地址也非常接近
普通函数地址:007114BF
结论(VS 环境下):
- 虚函数表存在代码段/常量区
- 虚函数(编译后的指令)同样存在代码段
- 虚函数和普通函数在存储位置上没有本质区别——区别只在于虚函数的地址被额外存入了虚表
九、重载、重写、隐藏对比
| 对比维度 | 重载(Overload) | 重写/覆盖(Override) | 隐藏(Hide) |
|---|---|---|---|
| 作用域 | 同一作用域(同一个类内) | 不同作用域(基类和派生类之间) | 不同作用域(基类和派生类之间) |
| 函数名 | 相同 | 相同 | 相同 |
| 参数列表 | 必须不同 | 必须相同(协变除外) | 可以相同也可以不同 |
| 返回值 | 可以不同 | 必须相同(协变除外) | 可以不同 |
| virtual | 不需要 | 基类必须 virtual | 不需要 |
| 调用方式 | 编译时根据参数确定 | 运行时通过虚表确定 | 编译时根据静态类型确定 |
一个例子把三者串起来:
class Base {
public:
void func(int) { cout << "Base::func(int)" << endl; } // 1
void func(double) { cout << "Base::func(double)" << endl; } // 2: 和1构成重载
virtual void virt() { cout << "Base::virt()" << endl; } // 3: 虚函数
};
class Derive : public Base {
public:
void func(int) { cout << "Derive::func(int)" << endl; } // 4: 隐藏了1和2!
virtual void virt() override // 5: 重写了3
{ cout << "Derive::virt()" << endl; }
};
- 1 和 2 → 重载(同一个类,函数名相同,参数不同)
- 3 和 5 → 重写(基类 virtual + 派生类相同签名)
- 4 → 隐藏了 1 和 2(派生类同名函数掩盖了基类的所有同名函数,不管参数一不一样)
写在最后
多态是 C++ 面向对象能力的核心引擎。从表层的 virtual 关键字到底层的虚函数表指针间接寻址,理解多态能让你在面试中自信地画出内存布局,也能让你在工作中设计出真正可扩展的系统。
1251

被折叠的 条评论
为什么被折叠?



