多态="同一句话,不同反应"——同一个函数调用,作用在不同对象上,跑出不同的结果。9.2.3 说的三大特点,第三个(多态)从这里正式登场。C++ 的多态有两条路:编译期多态(重载、模板——编译时就定好了调哪个)和运行期多态(虚函数——跑起来才知道调哪个)。本章讲的是第二条,它是"可扩展"这件事的技术核心。
这是这一章的高潮。往回看:11.1.5 讲了"子类同名函数会隐藏父类的"——那是编译期按类型找名字;本节开始讲 virtual,让"同名函数"能按对象的真实身份在运行期选——同样是同名函数,加一个词,行为天翻地覆。往前看:11.4.1 讲虚函数机制,11.4.2 用实例06演示动态绑定,11.4.3 用实例07解决 11.3.2 的菱形问题,最后 11.5 的抽象类完全建立在虚函数之上。
一、先看一个"奇怪"的现象
这段代码,先猜输出什么:
#include <iostream>
using namespace std;
class Base {
public:
void hello() { cout << "我是 Base" << endl; } // 注意:没有 virtual
};
class Derived : public Base {
public:
void hello() { cout << "我是 Derived" << endl; }
};
int main() {
Derived d;
Base *p = &d; // 父类指针指向子类对象(合法:子类"是一种"父类,11.1)
p->hello(); // 猜猜打印哪个?
d.hello();
return 0;
}
实际输出:
我是 Base
我是 Derived
同一个对象 d,用两种方式叫它,行为不一样:
d.hello()→Derived::hello(它就是Derived对象,理所当然)p->hello()→Base::hello(p是Base*,编译器就认这个类型)
这算"多态"吗?不算——这是"没有多态"的样子。 因为编译器在编译时就把 p->hello() 定死成 Base::hello 了(它只看 p 的类型,不看 p 实际指向谁)。这叫静态绑定(早绑定)。
要让"同一句话,不同反应"成立,需要在 Base::hello 前面加一个词:
class Base {
public:
virtual void hello() { cout << "我是 Base" << endl; }
// ↑ 就加这一个词
};
加了之后,p->hello() 输出变成 我是 Derived——编译器改口了:"我不知道 p 到底指谁,等运行时再看"。这就是虚函数,也是动态绑定(晚绑定)。
这个"一个词的差别"就是本节到 11.5 的全部内容。
二、两种多态,一张表分清
| 编译期多态(静态多态) | 运行期多态(动态多态) | |
|---|---|---|
| 靠什么实现 | 函数重载(6.5)、模板 | 虚函数(virtual) |
| 什么时候定 | 编译时(编译器看着代码就能定) | 运行时(要看对象真实身份) |
| 调用形态 | 直接按实参/类型选版本 | 通过指针或引用调用虚函数 |
| 开销 | 零开销(编译期就定好了) | 有一点点(一次间接跳转、每个对象多一个隐藏指针) |
| 本章/本书位置 | 6.5 已经学过 | 本节的第 11.4 组 |
| 经典例子 | add(int,int) 和 add(double,double) |
Shape* 指向 Circle / Rect,调 area() |
一句话记住:"编译期多态——编译器替你选;运行期多态——等到跑起来才选。"
三、为什么"运行期多态"这么重要
因为它买来的是"可扩展"。看一个具体的场景:
没有虚函数时:写一个"算所有图形总面积"的函数——
double totalArea(Circle *cs, int n, Rect *rs, int m) { // 痛苦开始
double t = 0;
for (int i = 0; i < n; i++) t += cs[i].area();
for (int i = 0; i < m; i++) t += rs[i].area(); // 加一种形状,就加一个循环
return t;
}
每加一种新形状(三角形、椭圆……),这个函数都要改、要重编。而"函数的代码本来是对的"——它只是不认识新类型。
有了虚函数之后:
double totalArea(Shape *list[], int n) { // 一个循环,够了
double t = 0;
for (int i = 0; i < n; i++) t += list[i]->area();
return t;
}
加一百种形状,这个函数一行都不用改。因为 list[i]->area() 会在运行时按每个对象的真实类型去调各自的 area()。
这就是"开放-封闭"的感觉:对扩展开放(加新形状不用动老代码),对修改封闭(老代码不需要改)。多态的价值就在这一句。
四、本章路线图
| 节 | 讲什么 |
|---|---|
| 11.4.1 | 虚函数概述:virtual 怎么起作用、三个必要条件、虚析构函数铁律 |
| 11.4.2 | 动态绑定:用 Shape* 数组装各种形状,一次遍历各显神通;含实例06 |
| 11.4.3 | 虚继承:virtual 的另一个用法——解决 11.3.2 的菱形问题;含实例07 |
注意
virtual的两个"同名不同用":在函数前 → 虚函数(动态绑定);在继承方式前(class B : virtual public A)→ 虚继承(共享基类)。同一个关键字,干两件毫不相干的事,看到virtual先看它在哪个位置。
五、动手 Demo:一个词带来的差别
#include <iostream>
using namespace std;
class Base1 { // 没有 virtual
public:
void hello() { cout << "Base1::hello" << endl; }
};
class Derived1 : public Base1 {
public:
void hello() { cout << "Derived1::hello" << endl; }
};
class Base2 { // 有 virtual
public:
virtual void hello() { cout << "Base2::hello" << endl; }
};
class Derived2 : public Base2 {
public:
void hello() { cout << "Derived2::hello" << endl; }
};
int main() {
cout << "── 非虚 ──" << endl;
Derived1 d1;
Base1 *p1 = &d1;
p1->hello(); // 按 p1 的类型(Base1)定 → Base1::hello
d1.hello(); // 直接调对象 → Derived1::hello
cout << "── 虚函数 ──" << endl;
Derived2 d2;
Base2 *p2 = &d2;
p2->hello(); // 按对象的真实身份 → Derived2::hello
d2.hello();
return 0;
}
看到什么算成功:
── 非虚 ──
Base1::hello
Derived1::hello
── 虚函数 ──
Derived2::hello
Derived2::hello
读结果:
| 非虚组 | 虚函数组 | |
|---|---|---|
p->hello() |
Base1::hello(被"类型"定死) |
Derived2::hello(按"对象身份"选) |
d.hello() |
Derived1::hello |
Derived2::hello |
唯一改动就是 virtual 一个词——这就是多态的开机键。
30 秒实验:把 Derived2::hello 的 virtual 去掉(只去子类里的),重新编译运行 → 输出不变。
- 因为"虚"这个性质会往下遗传:父类某函数是虚的,子类同名同参数的那个自动也是虚的(不管写不写 virtual)。
- 反过来试:父类不写 virtual、子类写 virtual → 父类不是虚的,多态不成立(p->hello() 还是父类版本)。"虚"必须从父类开始,才行。
六、常见误区
- "父类指针指向子类对象,调用同名函数就会调子类的"——只有函数是
virtual才会。非虚函数按"指针的类型"定,虚函数按"对象的真实类型"定(本节 Demo 的对照)。 - "多态=函数重载"——那是编译期多态。重载在编译时就定死了(按实参类型选版本);本书讲的多态是运行期多态(虚函数,跑起来才知道调谁)。
- "多态要求我用对象本身调用"——恰好相反。动态绑定只在"通过指针或引用调用"时生效(11.4.1 会讲清三个必要条件)。用对象(值)调用,编译器早就知道它是谁了,不需要"动态"。
- "子类里要写
virtual才是虚函数"——不用。父类声明了虚,子类的同名同参数函数自动是虚的(遗传)。但建议还是写上virtual(或现代写法的override),让人一眼看出"这是重写"。 - "虚函数有性能代价,能不用就不用"——这点代价在绝大多数场合可以直接忽略(一次间接跳转)。真正的代价是"必须用指针/引用"和"对象多一个隐藏指针"。用错地方的代价(无法扩展)远大于"那一点点开销"。
自测三问(点开对答案)
1. 编译期多态和运行期多态分别靠什么实现?——**编译期多态**靠**函数重载(6.5)和模板**(编译时就定好调哪个);**运行期多态**靠**虚函数 `virtual`**(运行时按对象真实类型选)。 2. 为什么说"运行期多态买来的是可扩展"?——因为**调用方的代码不需要知道具体有多少种类型**:`list[i]->area()` 一个循环就能处理任意多种形状。**加新类型只写新类,老代码一行不改。** 3. `virtual` 在继承处还能干别的事吗?——**能,但那是另一回事**:写在**继承方式前**(`class B : virtual public A`)表示**虚继承**,用来解决菱形继承的"两份祖先数据"问题(11.4.3)。**同一个词,两个用途。**- ☐ 11.4.1 虚函数概述(下一节)
相关笔记
- 虚函数概述 —— 下一节:virtual 到底做了什么
- 子类隐藏父类的成员函数(含实例03) —— 不加 virtual 时,同名函数会发生什么
- 面向对象的特点 —— 三大特点:多态是第三个
- 重载函数 —— 编译期多态的那条路
- 二义性 —— 菱形继承的问题现场
- 🔷 C++基础 · 索引 —— 全课路线图