动态绑定最经典的用法:用一个"父类指针数组"装下各种子类对象,然后一次遍历、各显神通。关键是 list[i]->area() 里的 area 是虚函数——编译器不管 list[i] 是圆还是矩形,先记一笔"运行时再查",于是每个对象都调到自己那一版。这带来可扩展:加新形状只写新类,遍历的代码一行不改。另有一个必须知道的陷阱:用"父类对象"(而不是指针/引用)去接子类对象,会发生"对象切片",多态直接失效。
11.4.1 讲了虚函数的机制和三个条件,本节把它用起来。这是 9.2.3"多态"那一栏的真正落地——那时只有一句话"同一句话,不同反应",现在你能写出完整可跑的例子。这里还会用到 7.4.1 指针、8.4 结构体数组(现在是"对象指针数组")。
一、动态绑定怎么"用":父类指针数组
思路三步:
① 定义一个"父类"(Shape),把"会变的行为"做成虚函数(area、show)
② 让各个子类(Circle、Rect)重写这些虚函数
③ 用"Shape* 数组"把它们装在一起,统一遍历调用
Shape *list[3];
list[0] = new Circle(1);
list[1] = new Rect(3, 4);
list[2] = new Circle(2);
for (int i = 0; i < 3; i++)
cout << list[i]->area() << endl; // ★ 同一个写法,三种结果
为什么能成立:Circle* 和 Rect* 都能隐式转成 Shape*(子类"是一种"父类,11.1)——数组里存的其实都是 Shape*,但它们各自指向的对象"身份证"不同。area() 是虚的,调用时按对象的真实身份查表(11.4.1 的机制),于是各调各的。
二、它买来的东西:可扩展
对照看:
| 没有虚函数(各写各的循环) | 有虚函数(一个循环) | |
|---|---|---|
| 处理 3 种形状 | 3 个循环 / 3 个分支 | 1 个循环 |
| 新增第 4 种形状 | 改遍历的代码(加循环/加分支) | 只写新类,遍历代码不动 |
| 遍历代码要不要重新编译 | 要 | 不要 |
第二行是本节的灵魂:"加新东西不用动老代码"——这就是面向对象"可扩展"的具体含义,也是虚函数存在的唯一理由。
三、动手 Demo(含实例06:利用虚函数实现动态绑定)
#include <iostream>
using namespace std;
// ───── 父类:Shape ─────
class Shape {
public:
// ① "会变的行为"做成虚函数。父类先给一个"保底"实现
virtual double area() const {
return 0;
}
// ② 这个函数写一次,所有子类都受益(它内部调的就是虚函数 area)
virtual void show() const {
cout << "面积 = " << area() << endl;
}
virtual ~Shape() { } // ★ 11.4.1 的铁律:有虚函数就要虚析构
};
// ───── 子类 1:圆 ─────
class Circle : public Shape {
private:
double r;
public:
Circle(double radius) : r(radius) {}
double area() const { return 3.14159 * r * r; } // 重写
};
// ───── 子类 2:矩形 ─────
class Rect : public Shape {
private:
double w, h;
public:
Rect(double width, double height) : w(width), h(height) {}
double area() const { return w * h; } // 重写
};
int main() {
// ★ 一个"父类指针数组",里面装的是不同种类的形状
Shape *shapes[3];
shapes[0] = new Circle(1); // 半径 1
shapes[1] = new Rect(3, 4); // 3×4
shapes[2] = new Circle(2); // 半径 2
double total = 0;
for (int i = 0; i < 3; i++) {
cout << "第 " << (i + 1) << " 个形状:";
shapes[i]->show(); // ★ 同一句话,各显神通(动态绑定)
total += shapes[i]->area();
}
cout << "总面积 = " << total << endl;
for (int i = 0; i < 3; i++) delete shapes[i]; // 虚析构保证正确释放
return 0;
}
看到什么算成功:
第 1 个形状:面积 = 3.14159
第 2 个形状:面积 = 12
第 3 个形状:面积 = 12.5664
总面积 = 27.708
读结果:
| 现象 | 为什么 |
|---|---|
同一个 shapes[i]->show() 打出三种不同面积 |
show() 里调的是虚函数 area(),运行时按对象身份查到各自的 area |
show() 只在父类写了一次 |
"不变的框架"写在父类,"会变的部分"交给子类——这是继承 + 多态的标准分工 |
total = 27.708 = 3.14159 + 12 + 12.5664 |
遍历里 area() 每次都调对了版本 |
30 秒实验(这一步能让你彻底分清"虚"与"不虚"):
1. 把 Shape 里的 virtual double area() const 去掉 virtual,重新编译运行。
2. 结果:三次都打印"面积 = 0"——因为现在 area 不是虚的,shapes[i]->area() 按 Shape* 的类型调到了父类那个"保底返回 0"的版本。
3. 加回 virtual,立刻恢复正确。
4. 试着只给最末的 } 前加 virtual?不行——虚函数必须从"父类这一层"开始声明(11.4 的实验试过)。
四、必须知道的陷阱:对象切片
下面这段代码"能编过",但结果一定不是你要的:
int main() {
Circle c(1);
Shape s = c; // ★ 注意:不是指针、不是引用,而是"父类对象"
cout << s.area() << endl; // 输出 0,不是 3.14159!
Shape &r = c; // ← 引用(可以)
cout << r.area() << endl; // 输出 3.14159 ✅
}
为什么:Shape s = c; 这行的意思是"造一个 Shape 对象,用 c 去初始化它"——Shape 只有 Shape 那点内存,装不下 Circle 多出来的 r。于是 Circle 的部分被切掉了(只留下 Shape 那一部分)。这个对象是"货真价实的 Shape",不是"缩水的 Circle" → 调 area() 自然是 Shape::area()(返回 0)。
一句话记住:
要享受多态,就必须"用指针或引用"。一旦用"对象"去装,多态立刻消失,而且数据被切掉。
Shape s = c; 和 Shape &r = c; 的差别,就是"复印一张身份证"和"指着一个真人"的差别。
五、常见误区
- "用父类对象接子类对象也能多态"——不能,而且会切片(上一节的
Shape s = c;)。多态必须走指针或引用(11.4.1 的条件①)。 - "父类那个虚函数不需要写实现"——本节里写了一个"保底实现"(返回 0),是为了让父类能单独使用。如果你希望父类"根本不能被创建对象",就该把它写成纯虚函数——那是 11.5 抽象类的事。
- "数组里存的指针可以直接按子类类型用"——不行。
shapes[i]的静态类型是Shape*,你只能调用Shape里有的成员。子类新增的函数(比如Rect::setW)得先转成Rect*才能用。多态只解决"同名虚函数调谁",不解决"能看见哪些函数"。 - "遍历里
delete不 delete 都行"——必须 delete(否则内存泄漏),而且基类析构必须是virtual(11.4.1),否则子类析构不执行。本节 Demo 结尾的delete shapes[i];不是装饰,是必需。 - "每种形状都在父类里写一个
if分支判断类型更好懂"——那是把多态退化成 switch。每次加类型都要改那个分支——又把"可扩展"还回去了。"用虚函数还是用if判断类型"是判断一个设计好不好最灵的一条线索。
自测三问(点开对答案)
1. 怎么用虚函数实现"同一句话,不同反应"?——把**会变的行为**做成虚函数放在父类;子类各自重写;然后**用父类指针(或引用)数组**装各种子类对象,统一遍历调用。 2. 为什么必须用"指针或引用"而不能用"对象"?——因为**用父类对象去接子类对象会发生"对象切片"**:只复制父类那部分数据,多出来的子类成员被切掉,**这个对象就真的是父类对象了**,多态也随之消失。 3. 新增一种形状时,需要改动什么?——**只写新类**(继承 `Shape`、重写 `area()`、`show()` 自动跟着对)。**遍历的代码、父类、已有子类一行都不用改**——这就是虚函数买来的"可扩展"。- ☐ 11.4.3 虚继承(下一节)