C++ / 2026-08-01
CS106L 第 8 讲(选修):继承实践
通过游戏对象和完整练习掌握继承、对象切片、虚函数与运行时多态。
从“重复的游戏对象”到继承、虚函数与运行时多态
0. 本节课真正要解决什么问题?
假设我们正在编写一个小游戏。
游戏世界里有很多不同种类的对象:
- 玩家
Player - 敌人
NPC - 子弹
Projectile - 武器
Weapon - 树木
Tree
它们显然并不完全相同:
- 玩家和敌人有生命值;
- 子弹有速度;
- 武器有弹药;
- 树木可能什么特殊数据都没有。
但是,它们又共享很多特征:
- 都有空间坐标;
- 都有碰撞箱;
- 每一帧都需要更新;
- 每一帧都需要绘制。
如果为每一种对象分别编写一套完整的类,就会出现大量重复代码。更麻烦的是,我们希望把它们放进同一个容器,然后写出这样的游戏循环:
for (每一个游戏对象) {
更新这个对象;
绘制这个对象;
}
问题是:
容器里既有玩家,又有树木和子弹。编译器怎样知道每个对象应该调用哪一个版本的
update()和render()?
这会带出本节课的完整问题链:
类如何存储数据?
↓
成员函数和对象是什么关系?
↓
如何让多个类共享数据与功能?
↓
继承
↓
为什么把子类放进父类容器会丢失信息?
↓
对象切片
↓
为什么改用父类指针后,仍然调用了错误的函数?
↓
编译期类型与运行期类型
↓
虚函数与动态分派
↓
没有合理默认实现时怎么办?
↓
纯虚函数与抽象类
↓
什么时候不应该使用继承?
↓
组合优于继承
1. 学习本节课前需要知道什么?
这节课默认你已经见过:
- 类和对象;
public与private;- 构造函数;
- 引用和指针;
std::vector;.与->;- 头文件与源文件的基本概念。
不需要提前熟悉:
- 多态;
- 虚函数表;
- 抽象类;
- 对象切片;
- 复杂的内存模型。
这些都会从具体问题开始解释。
第一部分:重新认识类
2. 类不只是“一堆变量”
我们先写一个二维点类:
class Point {
public:
Point(int x, int y);
int getX() const;
int getY() const;
void setX(int x);
void setY(int y);
private:
int x;
int y;
};
一个类通常同时描述两类东西:
Point
├── 数据
│ ├── x
│ └── y
│
└── 行为
├── getX()
├── getY()
├── setX()
└── setY()
数据描述对象“现在是什么状态”,成员函数描述“可以对对象做什么”。
因此,我们可以把类理解成一种抽象(abstraction):
它把某个概念需要保存的数据,以及可以执行的操作组织到一起。
这个概念可以是现实中的汽车、书、狗,也可以是程序中的向量、图、矩阵或迭代器。
3. public 和 private 分别负责什么?
class Point {
public:
Point(int x, int y);
int getX() const;
private:
int x;
int y;
};
public 区域是类对外提供的接口:
Point p{1, 2};
std::cout << p.getX();
外部代码知道它可以调用 getX(),但不需要知道 getX() 内部如何实现。
private 区域是实现细节:
p.x = 100; // 编译错误:x 是 private
这样设计的意义不只是“禁止别人访问”。
它允许类控制自己的状态。例如,一个银行账户不应该允许外部代码随意写:
account.balance = -9999999;
更合理的做法是只提供受控制的操作:
account.deposit(100);
account.withdraw(50);
类可以在这些函数中检查金额是否合法。
4. 构造函数负责建立一个有效对象
Point::Point(int x, int y)
: x{x}, y{y} {
}
冒号后面的部分叫作成员初始化列表(member initializer list):
: x{x}, y{y}
它表示:
用参数 x 初始化成员变量 x
用参数 y 初始化成员变量 y
完整的多文件版本如下。
Point.h
#pragma once
class Point {
public:
Point(int x, int y);
int getX() const;
int getY() const;
void setX(int x);
void setY(int y);
private:
int x;
int y;
};
Point.cpp
#include "Point.h"
Point::Point(int x, int y)
: x{x}, y{y} {
}
int Point::getX() const {
return x;
}
int Point::getY() const {
return y;
}
void Point::setX(int x) {
this->x = x;
}
void Point::setY(int y) {
this->y = y;
}
main.cpp
#include <iostream>
#include "Point.h"
int main() {
Point p{1, 2};
std::cout << p.getX() << ' ' << p.getY() << '\n';
p.setX(10);
p.setY(20);
std::cout << p.getX() << ' ' << p.getY() << '\n';
}
编译:
g++ -std=c++20 Point.cpp main.cpp -o main
./main
输出:
1 2
10 20
5. 析构函数什么时候出现?
析构函数(destructor)在对象生命周期结束时执行:
class Point {
public:
~Point();
};
它通常负责释放对象占有的资源,例如:
- 动态分配的内存;
- 文件;
- 网络连接;
- 互斥锁;
- 操作系统句柄。
但普通的 Point 只包含两个 int,编译器自动生成的析构函数已经足够,所以不必手写:
class Point {
public:
Point(int x, int y);
private:
int x;
int y;
};
后面处理继承时,析构函数会重新出现,而且会变得非常重要。
小练习 1:判断访问是否合法
class Counter {
public:
void increment() {
++value;
}
int getValue() const {
return value;
}
private:
int value = 0;
};
int main() {
Counter counter;
counter.increment();
counter.value = 100;
}
哪一行会报错?为什么?
答案与解释
counter.value = 100;
会报错,因为 value 是 private 成员,只能由 Counter 自己的成员函数以及被授权的代码访问。
increment() 可以修改 value,因为它是 Counter 的成员函数。
6. 我们还留下了什么问题?
类把数据和函数组织在一起,但“组织在一起”不意味着它们真的以完全相同的方式存放在每一个对象中。
例如:
Point a{1, 2};
Point b{3, 4};
a 和 b 各自保存自己的 x、y。
那么:
getX()是否也在a和b中各保存了一份?
为了理解继承和虚函数,我们需要先看看对象大致怎样存放在内存中。
第二部分:对象、成员函数与 this
7. 一个普通 C++ 对象里保存了什么?
对于:
class Point {
private:
int x;
int y;
public:
Point(int x, int y)
: x{x}, y{y} {
}
int getX() const {
return x;
}
};
创建:
Point p{1, 2};
可以先建立这样的概念图:
p
┌─────────────┐
│ x = 1 │
├─────────────┤
│ y = 2 │
└─────────────┘
对象主要保存每个实例独有的数据成员。
成员函数的机器指令通常存放在程序的代码区域,而不是在每个对象里重复保存一遍:
程序代码区域
┌────────────────────────┐
│ Point::Point(...) │
│ Point::getX() │
│ Point::setX(...) │
└────────────────────────┘
对象 p
┌────────────────────────┐
│ x = 1 │
│ y = 2 │
└────────────────────────┘
对象 q
┌────────────────────────┐
│ x = 10 │
│ y = 20 │
└────────────────────────┘
这也解释了为什么创建一万个 Point,不会复制一万份 getX() 机器代码。
实际布局可能包含对齐产生的填充字节,编译器也可能内联函数。上图表达的是本节课需要的概念,而不是对所有编译器实现的逐字节保证。
8. C++ 与 Python 对象布局的直觉差异
以常见的 CPython 实现为例,Python 对象通常需要保存额外的运行时信息,例如:
Python Point 对象
┌──────────────────┐
│ 引用计数 │
│ 类型信息 │
│ 属性字典等信息 │
│ _x 对应的对象 │
│ _y 对应的对象 │
└──────────────────┘
Python 需要在程序运行时判断一个对象是什么类型、有哪些属性,因此会保留较多元数据。
普通的非多态 C++ 对象通常更接近:
C++ Point 对象
┌───────────┐
│ int x │
│ int y │
└───────────┘
C++ 编译器在编译阶段已经知道大部分类型信息,因此普通对象不需要像动态语言对象一样保存完整的运行时类型系统。
代价是:
当我们后来希望“在运行时根据对象真实类型选择函数”时,必须明确告诉 C++:这里需要运行时多态。
这正是后面 virtual 要做的事情。
9. 函数只有一份,它怎么知道正在操作哪个对象?
考虑:
Point a{1, 2};
Point b{10, 20};
std::cout << a.getX() << '\n';
std::cout << b.getX() << '\n';
同一个 Point::getX(),第一次应该读取 a.x,第二次应该读取 b.x。
成员函数内部需要知道:
这次调用是针对哪个对象?
C++ 使用一个隐含的指针完成这件事,这个指针叫作 this。
int Point::getX() const {
return this->x;
}
this 指向当前正在操作的对象。
执行:
a.getX();
时,可以在概念上理解为:
Point_getX(&a);
而成员函数:
int Point::getX() const {
return this->x;
}
可以在概念上理解为一个接收当前对象地址的普通函数。
这只是帮助理解的等价模型,编译器不一定真的生成一个名字叫作 Point_getX 的函数。
10. x 和 this->x 什么时候相同?
在下面的函数中:
int Point::getX() const {
return x;
}
编译器发现函数内部没有局部变量叫作 x,于是 x 会被理解成当前对象的成员:
int Point::getX() const {
return this->x;
}
这两个版本含义相同。
但是下面的情况不同:
void Point::setX(int x) {
x = x;
}
这里同时出现了:
- 参数
x - 成员变量
x
函数体中直接写 x 时,距离更近的参数会遮蔽成员变量。
于是:
x = x;
实际是在把参数赋值给它自己:
参数 x ← 参数 x
对象里的成员变量没有改变。
正确写法是:
void Point::setX(int x) {
this->x = x;
}
现在两边含义清楚了:
当前对象的成员 x ← 参数 x
11. .、-> 与指针解引用
假设:
Point p{1, 2};
Point* ptr = &p;
通过对象访问成员:
p.setX(10);
通过指针访问成员:
ptr->setX(10);
-> 可以理解为:
(*ptr).setX(10);
因此:
this->x
也可以理解为:
(*this).x
这里:
this是指针;*this是当前对象;(*this).x是当前对象的成员x。
小练习 2:预测输出
#include <iostream>
class Box {
public:
explicit Box(int value)
: value{value} {
}
void setValue(int value) {
value = value;
}
int getValue() const {
return value;
}
private:
int value;
};
int main() {
Box box{5};
box.setValue(20);
std::cout << box.getValue() << '\n';
}
程序输出什么?
答案与解释
输出:
5
setValue() 中的:
value = value;
两边都是参数 value,成员变量没有改变。
应改为:
void setValue(int value) {
this->value = value;
}
12. 从单个类走向多个类
我们已经知道:
- 每个对象保存自己的数据;
- 成员函数通常不在每个对象中重复存储;
this告诉成员函数当前操作的是哪个对象。
现在回到游戏问题。
如果很多类包含相同的数据和函数,我们是否必须复制这些代码?
第三部分:重复代码为什么会逼出继承?
13. 第一版游戏对象设计
我们暂时定义一个简单的碰撞箱:
struct HitBox {
double radius = 1.0;
};
然后分别设计游戏对象:
class Player {
private:
double x;
double y;
double z;
HitBox hitbox;
double hitpoints;
public:
void damage(double amount);
void update();
void render();
};
class Projectile {
private:
double x;
double y;
double z;
HitBox hitbox;
double vx;
double vy;
double vz;
public:
void update();
void render();
};
class Weapon {
private:
double x;
double y;
double z;
HitBox hitbox;
std::size_t ammo;
public:
void fire();
void update();
void render();
};
class Tree {
private:
double x;
double y;
double z;
HitBox hitbox;
public:
void update();
void render();
};
class NPC {
private:
double x;
double y;
double z;
HitBox hitbox;
double hitpoints;
public:
void damage(double amount);
void update();
void render();
};
其中反复出现:
double x;
double y;
double z;
HitBox hitbox;
void update();
void render();
这不仅让代码变长,还会造成维护问题。
假设我们决定将三维坐标改成:
struct Position {
double x;
double y;
double z;
};
那么每一个类都要修改。
如果忘记修改其中一个类,程序里的游戏对象就会使用不一致的表示方式。
14. 添加共同功能时,问题更加严重
现在希望判断两个对象是否发生碰撞:
bool overlapsWith(...);
没有共同基类时,可能会出现:
class Player {
public:
bool overlapsWith(const Player& other);
bool overlapsWith(const NPC& other);
bool overlapsWith(const Tree& other);
bool overlapsWith(const Projectile& other);
bool overlapsWith(const Weapon& other);
};
而 NPC、Tree、Projectile、Weapon 还需要编写类似的重载。
如果有 (N) 种对象,最糟糕时需要处理大量种类组合:
Player × Player
Player × NPC
Player × Tree
...
NPC × Player
NPC × NPC
...
每增加一种对象,许多旧类都要跟着修改。
真正的问题是:
程序虽然知道这些对象共享“位置”和“碰撞箱”这一概念,但类型系统还不知道。
为了让类型系统表达这种共同点,我们需要引入一个更一般的类型。
第四部分:继承让类之间形成 “is-a” 关系
15. 找到共同的父类:Entity
游戏中的玩家、子弹、武器、树木和敌人都可以被看作一个实体(Entity)。
class Entity {
protected:
double x;
double y;
double z;
HitBox hitbox;
public:
void update();
void render();
};
然后让其他类继承它:
class Player : public Entity {
private:
double hitpoints;
public:
void damage(double amount);
};
class Projectile : public Entity {
private:
double vx;
double vy;
double vz;
};
class Weapon : public Entity {
private:
std::size_t ammo;
public:
void fire();
};
class Tree : public Entity {
};
class NPC : public Entity {
private:
double hitpoints;
public:
void damage(double amount);
};
这里:
Entity叫作基类(base class)或父类;Player叫作派生类(derived class)或子类;Player : public Entity表示Player公有继承Entity。
16. 继承表达的是 “is-a”
公有继承通常应该表达一种 “is-a” 关系:
Player is an Entity
玩家是一种实体
Projectile is an Entity
子弹是一种实体
NPC is an Actor
NPC 是一种角色
Actor is an Entity
角色是一种实体
因此:
NPC is an Actor
Actor is an Entity
──────────────────
NPC is also an Entity
继承树可以继续分层:
Entity
├── Projectile
├── Weapon
├── Tree
└── Actor
├── Player
└── NPC
对应代码:
class Entity {
// 所有实体共有的内容
};
class Actor : public Entity {
protected:
double hitpoints;
};
class Player : public Actor {
};
class NPC : public Actor {
};
class Projectile : public Entity {
};
class Weapon : public Entity {
};
class Tree : public Entity {
};
这样,生命值和受伤逻辑只需要放在 Actor 中,而不必在 Player 与 NPC 里各写一次。
17. 父类部分确实存在于子类对象中
考虑:
class Entity {
protected:
double x;
double y;
double z;
HitBox hitbox;
};
class Projectile : public Entity {
private:
double vx;
double vy;
double vz;
};
一个 Projectile 对象在概念上包含:
Projectile 对象
┌──────────────────┐
│ Entity 子对象 │
│ ├── x │
│ ├── y │
│ ├── z │
│ └── hitbox │
├──────────────────┤
│ Projectile 自有 │
│ ├── vx │
│ ├── vy │
│ └── vz │
└──────────────────┘
因此,Projectile 对象不仅包含自己的速度,也包含从 Entity 继承而来的位置和碰撞箱。
这里的关键不是“把父类源码复制到子类中”,而是:
派生类对象中包含一个基类子对象(base subobject)。
18. 构造子类时,必须先构造父类部分
下面是一个可编译的例子:
#include <iostream>
struct HitBox {
double radius;
};
class Entity {
public:
Entity(double x, double y, double z, double radius)
: x{x}, y{y}, z{z}, hitbox{radius} {
std::cout << "Entity constructed\n";
}
protected:
double x;
double y;
double z;
HitBox hitbox;
};
class Projectile : public Entity {
public:
Projectile(
double x,
double y,
double z,
double vx,
double vy,
double vz
)
: Entity{x, y, z, 0.2},
vx{vx},
vy{vy},
vz{vz} {
std::cout << "Projectile constructed\n";
}
private:
double vx;
double vy;
double vz;
};
int main() {
Projectile projectile{0, 0, 0, 1, 2, 3};
}
输出:
Entity constructed
Projectile constructed
构造顺序是:
1. 先构造 Entity 子对象
2. 再构造 Projectile 自己的成员
3. 最后执行 Projectile 构造函数体
子类构造函数通过初始化列表调用父类构造函数:
: Entity{x, y, z, 0.2}
19. 共同功能现在只需要编写一次
我们可以把碰撞检测放进 Entity:
#include <cmath>
class Entity {
public:
bool overlapsWith(const Entity& other) const {
const double dx = x - other.x;
const double dy = y - other.y;
const double dz = z - other.z;
const double distanceSquared =
dx * dx + dy * dy + dz * dz;
const double combinedRadius =
hitbox.radius + other.hitbox.radius;
return distanceSquared <=
combinedRadius * combinedRadius;
}
protected:
double x;
double y;
double z;
HitBox hitbox;
};
于是可以写:
Player player{/* ... */};
Projectile bullet{/* ... */};
bool hit = player.overlapsWith(bullet);
虽然参数类型是:
const Entity& other
但是 Projectile 公有继承 Entity,所以它可以被当作一个 Entity 引用使用。
概念上:
bullet 是 Projectile
↓
Projectile 是 Entity
↓
可以把 bullet 的 Entity 部分绑定给 const Entity&
因为使用的是引用,没有复制对象,也不会发生后面要讲的对象切片。
小练习 3:判断是否适合使用继承
下面哪些关系适合使用公有继承?
Dog与AnimalCar与EngineCircle与ShapeStack与std::vector<int>
答案与解释
适合:
Dog is an Animal
Circle is a Shape
所以 1 和 3 通常适合公有继承。
不适合:
Car is an Engine
Stack is a vector
汽车不是一种发动机,而是拥有发动机。
栈也不应该向使用者公开 vector 的所有操作,例如在中间插入元素。因此它们通常更适合使用组合。
第五部分:继承中的访问控制
20. 为什么 class Player : Entity 可能不能正常使用?
下面两种写法并不相同:
class Player : Entity {
};
class Player : public Entity {
};
对于 class,如果没有写继承方式,默认是私有继承:
class Player : /* private */ Entity {
};
这会影响父类 public 成员在子类中的可见性。
假设:
class Entity {
public:
bool overlapsWith(const Entity& other) const;
};
使用私有继承:
class Player : private Entity {
};
那么 Entity 原来的 public 接口会成为 Player 的私有接口。
外部代码不能写:
Player player;
Projectile projectile;
player.overlapsWith(projectile); // 可能不可访问
如果我们想表达:
Player真的是一种Entity
通常应该写:
class Player : public Entity {
};
21. public、protected、private 的区别
对于类中的成员:
| 访问级别 | 类自身成员函数 | 派生类 | 外部代码 |
|---|---|---|---|
public |
可以访问 | 可以访问 | 可以访问 |
protected |
可以访问 | 可以访问 | 不可以访问 |
private |
可以访问 | 不可以直接访问 | 不可以访问 |
例如:
class Entity {
protected:
double x;
double y;
double z;
public:
void render() const;
};
派生类可以访问 x:
class Projectile : public Entity {
public:
void move() {
x += vx;
y += vy;
z += vz;
}
private:
double vx = 1;
double vy = 0;
double vz = 0;
};
但外部代码不可以:
Projectile projectile;
projectile.x = 100; // 编译错误
22. private 成员并没有从子类对象中消失
考虑:
class Entity {
private:
double x;
};
再定义:
class Player : public Entity {
};
Player 对象仍然包含 Entity 的 x。
只是 Player 的成员函数不能直接写:
x = 10;
可以让父类提供受保护或公开的接口:
class Entity {
public:
double getX() const {
return x;
}
protected:
void setX(double newX) {
x = newX;
}
private:
double x = 0;
};
派生类通过接口操作:
class Player : public Entity {
public:
void moveRight() {
setX(getX() + 1);
}
};
这通常比把所有数据直接设为 protected 更安全,因为父类仍然能够控制数据修改方式。
23. 两个不同位置的 public 不要混淆
下面有两个 public:
class Player : public Entity {
public:
void damage(double amount);
};
第一个:
: public Entity
是继承方式。
它回答:
Entity的接口以什么方式出现在Player中?
第二个:
public:
是成员访问级别。
它回答:
Player::damage()是否允许外部调用?
这是两套不同的访问控制。
小练习 4:哪些代码合法?
class Parent {
public:
int a = 1;
protected:
int b = 2;
private:
int c = 3;
};
class Child : public Parent {
public:
void test() {
a = 10;
b = 20;
c = 30;
}
};
int main() {
Child child;
child.a = 100;
child.b = 200;
child.c = 300;
}
答案与解释
在 Child::test() 中:
a = 10; // 合法,父类 public
b = 20; // 合法,父类 protected
c = 30; // 非法,父类 private
在 main() 中:
child.a = 100; // 合法,公有继承后仍为 public
child.b = 200; // 非法,protected 对外不可见
child.c = 300; // 非法,private 对外不可见
24. 继承已经解决了重复代码,但还没有解决游戏循环
现在所有游戏对象都是 Entity:
Player is an Entity
Tree is an Entity
Projectile is an Entity
于是我们可能很自然地想写:
std::vector<Entity> entities{
Player{},
Tree{},
Projectile{}
};
然后:
for (Entity& entity : entities) {
entity.update();
entity.render();
}
看起来非常合理。
可惜,这里会出现继承中最容易忽略的问题之一:对象切片。
第六部分:对象切片——子类装不进父类对象
25. 先观察一个最小例子
#include <iostream>
#include <vector>
class Entity {
public:
void update() {
std::cout << "Entity::update\n";
}
};
class Player : public Entity {
public:
void update() {
std::cout << "Player::update\n";
}
private:
int hitpoints = 100;
};
int main() {
std::vector<Entity> entities{
Player{}
};
entities[0].update();
}
输出:
Entity::update
而不是:
Player::update
为什么?
26. std::vector<Entity> 的每个格子只能保存 Entity
假设概念上的对象布局是:
Player
┌──────────────────┐
│ Entity 部分 │
├──────────────────┤
│ hitpoints = 100 │
└──────────────────┘
而 std::vector<Entity> 的每个元素必须具有完全相同的类型和大小:
std::vector<Entity>
┌────────────┬────────────┬────────────┐
│ Entity │ Entity │ Entity │
└────────────┴────────────┴────────────┘
把 Player 复制进一个 Entity 格子时,只能复制它的 Entity 部分:
原始 Player
┌──────────────────┐
│ Entity 部分 │ ───────────┐
├──────────────────┤ │ 只复制这部分
│ hitpoints │ ↓
└──────────────────┘ ┌──────────────┐
│ Entity 对象 │
└──────────────┘
Player 自己增加的数据和类型身份都被切掉了。
这种现象叫作对象切片(object slicing)。
27. 切片发生后,对象真的已经变成 Entity
这是非常关键的一点:
Entity entity = Player{};
变量 entity 的类型不是“外表像 Entity、内部还是 Player”。
它就是一个独立的 Entity 对象。
Player 的额外部分已经不在这个新对象里。
因此,即使后面把 update() 改成虚函数,已经被切片的对象也无法恢复成 Player。
复制前动态对象:Player
复制后新对象:Entity
28. 切片什么时候发生?
典型情况是派生类对象被按值复制给基类对象:
Entity e = Player{};
按值传参也可能切片:
void process(Entity entity);
Player player;
process(player);
返回值也可能发生类似问题:
Entity createEntity() {
return Player{};
}
避免方法通常是使用:
- 基类引用;
- 基类指针;
- 智能指针。
例如:
void process(const Entity& entity);
引用不会创建新的基类对象,因此不会切掉派生类部分。
小练习 5:找出发生切片的位置
class Base {
};
class Derived : public Base {
};
void f1(Base value) {
}
void f2(const Base& value) {
}
int main() {
Derived derived;
Base a = derived;
Base& b = derived;
f1(derived);
f2(derived);
}
答案与解释
会发生切片:
Base a = derived;
f1(derived);
它们都创建了一个新的 Base 对象。
不会发生切片:
Base& b = derived;
f2(derived);
引用仍然指向原来的 Derived 对象,没有复制。
29. 指针可以保留完整的子类对象
我们可以让容器保存地址:
Player player;
Tree tree;
Projectile projectile;
std::vector<Entity*> entities{
&player,
&tree,
&projectile
};
此时容器保存的是三个相同大小的指针:
std::vector<Entity*>
┌────────────┬────────────┬────────────┐
│ Entity* │ Entity* │ Entity* │
└─────┬──────┴─────┬──────┴─────┬──────┘
│ │ │
↓ ↓ ↓
Player Tree Projectile
完整对象 完整对象 完整对象
没有复制对象,所以没有切片。
游戏循环可以写成:
for (Entity* entity : entities) {
entity->update();
entity->render();
}
现在子类对象还完整存在。
问题应该解决了吧?
还没有。
第七部分:指针保留了对象,却“忘记”了具体类型
30. 指针有两个相关类型
考虑:
Player player;
Entity* entity = &player;
这里有两个不同的类型概念。
编译期类型(static type)
表达式 entity 在源代码中声明为:
Entity*
因此编译器在编译时把它看作 Entity*。
运行期类型(dynamic type)
entity 实际指向的对象是:
Player
所以对象的运行期类型是 Player。
可以画成:
编译期看到:
entity 的类型是 Entity*
运行时实际:
entity
│
↓
Player 对象
31. 普通成员函数根据编译期类型选择
考虑:
#include <iostream>
class Entity {
public:
void update() {
std::cout << "Entity update\n";
}
};
class Player : public Entity {
public:
void update() {
std::cout << "Player update\n";
}
};
int main() {
Player player;
Entity* entity = &player;
entity->update();
}
输出:
Entity update
编译器看到:
entity
的类型是 Entity*,于是选择:
Entity::update()
它不会仅凭普通函数自动在运行时调查指针到底指向哪个子类。
这种在编译期间决定调用目标的方式,可以称为静态绑定或静态分派(static dispatch)。
32. 为什么编译器默认这样做?
一个 Entity* 可能指向:
Entity
Player
Projectile
Tree
NPC
Weapon
编译器唯一可以确定的是:
无论它具体指向哪一种对象,那一定至少是一个
Entity。
因此,对于普通成员函数,它选择编译期类型中确定存在的:
Entity::update()
但是我们的游戏循环想要的是:
如果实际对象是 Player
调用 Player::update()
如果实际对象是 Projectile
调用 Projectile::update()
如果实际对象是 Tree
调用 Tree::update()
也就是说,函数调用应该依赖对象的运行期类型。
这种能力叫作动态分派(dynamic dispatch)。
为了获得动态分派,我们需要虚函数。
第八部分:虚函数让调用取决于对象的真实类型
33. 在父类中加入 virtual
class Entity {
public:
virtual void update() {
std::cout << "Entity update\n";
}
virtual void render() const {
std::cout << "Entity render\n";
}
};
派生类重写函数:
class Player : public Entity {
public:
void update() override {
std::cout << "Player update\n";
}
void render() const override {
std::cout << "Render player\n";
}
};
现在:
Player player;
Entity* entity = &player;
entity->update();
entity->render();
输出:
Player update
Render player
虽然指针类型是 Entity*,但因为函数是虚函数,C++ 会查看对象的运行期类型。
Entity* entity
│
↓
实际对象是 Player
│
↓
调用 Player::update()
34. virtual 写在父类,override 写在子类
推荐形式:
class Entity {
public:
virtual void update();
};
class Player : public Entity {
public:
void update() override;
};
virtual 表示:
这个接口允许派生类提供不同实现,并且通过基类指针或引用调用时,要进行动态分派。
override 表示:
我明确打算重写一个父类虚函数,请编译器检查我的函数签名是否正确。
35. 为什么强烈建议使用 override?
假设父类函数是:
class Entity {
public:
virtual void render() const {
}
};
初学者可能在子类中忘记 const:
class Player : public Entity {
public:
void render() {
}
};
这不是对原函数的重写,因为函数签名不同:
void render();
void render() const;
如果没有 override,编译器可能把它当作一个新的成员函数。
于是:
Entity* entity = new Player;
entity->render();
仍可能调用父类版本。
加上:
void render() override {
}
编译器会立即报错,提醒它没有重写任何虚函数。
正确写法是:
void render() const override {
}
override 不只是提高可读性,它还是一项非常有价值的编译器检查。
36. 一份可以直接运行的动态分派示例
#include <iostream>
#include <vector>
class Entity {
public:
virtual void update() {
std::cout << "Entity update\n";
}
virtual void render() const {
std::cout << "Render entity\n";
}
virtual ~Entity() = default;
};
class Player : public Entity {
public:
void update() override {
std::cout << "Read controller input\n";
}
void render() const override {
std::cout << "Draw player\n";
}
};
class Tree : public Entity {
public:
void update() override {
std::cout << "Tree sways in the wind\n";
}
void render() const override {
std::cout << "Draw tree\n";
}
};
class Projectile : public Entity {
public:
void update() override {
std::cout << "Move projectile\n";
}
void render() const override {
std::cout << "Draw particle effect\n";
}
};
int main() {
Player player;
Tree tree;
Projectile projectile;
std::vector<Entity*> entities{
&player,
&tree,
&projectile
};
for (Entity* entity : entities) {
entity->update();
entity->render();
std::cout << '\n';
}
}
编译:
g++ -std=c++20 -Wall -Wextra -pedantic main.cpp -o main
./main
输出:
Read controller input
Draw player
Tree sways in the wind
Draw tree
Move projectile
Draw particle effect
循环只知道每个元素是 Entity*:
for (Entity* entity : entities)
但每一次调用都会根据真实对象选择对应函数。
这就是运行时多态(runtime polymorphism)。
37. 动态分派一步步发生了什么?
执行:
entity->update();
可以按下面的顺序理解。
第一步:读取指针
entity 是 Entity*
第二步:找到它指向的对象
entity
│
↓
Projectile 对象
第三步:确认 update() 是虚函数
父类中写了:
virtual void update();
因此不能只根据 Entity* 直接决定函数。
第四步:查找该对象对应的 update() 实现
对象的运行期类型是 Projectile,所以选择:
Projectile::update()
第五步:调用函数,并把当前对象作为 this
概念上类似:
Projectile::update(this = entity 指向的 Projectile)
小练习 6:预测输出
#include <iostream>
class Base {
public:
virtual void print() const {
std::cout << "Base\n";
}
};
class Derived : public Base {
public:
void print() const override {
std::cout << "Derived\n";
}
};
int main() {
Derived derived;
Base byValue = derived;
Base& byReference = derived;
Base* byPointer = &derived;
byValue.print();
byReference.print();
byPointer->print();
}
答案与解释
输出:
Base
Derived
Derived
byValue 是经过切片后创建的独立 Base 对象,所以它的运行期类型也是 Base。
byReference 和 byPointer 都仍然指向原始 Derived 对象,因此虚函数动态分派到 Derived::print()。
第九部分:虚函数背后大致怎样工作?
38. 普通对象原本没有运行时函数选择信息
普通的非多态对象可以概念化为:
Projectile
┌─────────────────┐
│ Entity 的数据 │
│ x │
│ y │
│ z │
│ hitbox │
├─────────────────┤
│ vx │
│ vy │
│ vz │
└─────────────────┘
一个 Entity* 只保存地址:
Entity* ─────→ 某个对象
地址本身没有直接告诉调用点:
这是 Player?
这是 Tree?
这是 Projectile?
加入虚函数后,编译器通常会为多态对象增加一些运行时元数据。
39. vptr 与 vtable 的直觉模型
常见实现中,多态对象里会有一个隐藏指针,常称为虚函数表指针(virtual table pointer,vptr)。
它指向一个虚函数表(virtual table,vtable)。
概念图:
Projectile 对象
┌────────────────────┐
│ Entity 数据 │
│ x, y, z, hitbox │
├────────────────────┤
│ 隐藏的 vptr │──────────┐
├────────────────────┤ │
│ vx, vy, vz │ │
└────────────────────┘ │
↓
Projectile 的虚函数表
┌────────────────────────┐
│ update → Projectile::update
│ render → Projectile::render
│ 析构 → Projectile::~Projectile
└────────────────────────┘
调用:
entity->update();
可以大致理解为:
1. 通过 entity 找到对象
2. 通过对象的 vptr 找到虚函数表
3. 从表中找到 update 对应的函数
4. 调用那个函数
C++ 标准没有要求编译器必须使用名为 vptr 和 vtable 的具体结构,但主流编译器通常采用类似机制。
40. 虚函数不是完全没有代价
可能的开销包括:
- 多态对象通常需要额外保存一个隐藏指针;
- 虚函数调用通常需要一次间接查找;
- 某些情况下,间接调用会让编译器更难内联函数;
- 继承关系复杂后,程序更难推理和维护。
不过,不应把它理解成:
虚函数一定很慢,所以不要使用。
更准确的判断是:
当程序确实需要通过统一接口操作多种运行期类型时,虚函数提供了直接而清晰的解决方案。只有在性能测量证明它成为瓶颈时,才需要针对性优化。
现代编译器有时还能根据上下文判断真实类型,将虚调用优化成直接调用,这叫作去虚拟化(devirtualization)。
第十部分:如果父类根本没有合理的默认行为呢?
41. Entity::update() 应该做什么?
我们之前写过:
class Entity {
public:
virtual void update() {
// 默认什么都不做
}
virtual void render() const {
// 默认什么都不做
}
};
但这会留下一个设计问题。
一个脱离具体类型的“实体”究竟应该怎样更新、怎样绘制?
Player 更新:读取输入
Projectile 更新:移动
Tree 更新:摇摆
不存在一个适用于所有实体的默认实现。
如果忘记在某个子类中重写函数,程序可能悄悄调用“什么都不做”的父类版本。
我们真正想表达的是:
所有实体都必须支持
update()和render(),但具体做什么必须由子类决定。
这就是纯虚函数(pure virtual function)。
42. 使用 = 0 声明纯虚函数
class Entity {
public:
virtual void update() = 0;
virtual void render() const = 0;
virtual ~Entity() = default;
};
这里:
= 0
不代表“返回 0”。
它是特殊语法,表示这个函数是纯虚函数。
纯虚函数定义了一个必须实现的接口,但不提供普通的默认实现。
43. 含有纯虚函数的类是抽象类
class Entity {
public:
virtual void update() = 0;
virtual void render() const = 0;
};
现在不能创建:
Entity entity; // 编译错误
因为编译器会问:
entity.update() 应该执行什么?
entity.render() 应该执行什么?
Entity 没有提供答案。
这种不能直接实例化、主要用于定义共同接口的类叫作抽象类(abstract class)。
44. 实现全部纯虚函数后,子类成为具体类
class Projectile : public Entity {
public:
void update() override {
// 移动子弹
}
void render() const override {
// 绘制子弹
}
};
Projectile 已经实现所有必须实现的纯虚函数,因此可以创建对象:
Projectile projectile;
这种可以被实例化的类叫作具体类(concrete class)。
如果漏掉一个:
class Projectile : public Entity {
public:
void update() override {
}
// 忘记 render()
};
那么 Projectile 仍然是抽象类:
Projectile projectile; // 编译错误
45. Shape 是纯虚函数的典型例子
所有三维形状都有体积:
class Shape {
public:
virtual double volume() const = 0;
virtual ~Shape() = default;
};
但是“一个普通 Shape 的体积”没有合理答案。
必须由具体形状决定:
#include <numbers>
class Box : public Shape {
public:
Box(double width, double height, double depth)
: width{width},
height{height},
depth{depth} {
}
double volume() const override {
return width * height * depth;
}
private:
double width;
double height;
double depth;
};
class Sphere : public Shape {
public:
explicit Sphere(double radius)
: radius{radius} {
}
double volume() const override {
return 4.0 / 3.0
* std::numbers::pi
* radius * radius * radius;
}
private:
double radius;
};
统一处理:
Box box{2, 3, 4};
Sphere sphere{1};
Shape* shapes[]{
&box,
&sphere
};
for (const Shape* shape : shapes) {
std::cout << shape->volume() << '\n';
}
小练习 7:判断能否创建对象
class A {
public:
virtual void f() = 0;
};
class B : public A {
};
class C : public A {
public:
void f() override {
}
};
以下哪些合法?
A a;
B b;
C c;
A* ptr = &c;
答案与解释
不合法:
A a;
B b;
A 有纯虚函数,是抽象类。
B 没有实现 f(),仍然是抽象类。
合法:
C c;
A* ptr = &c;
C 实现了全部纯虚函数,是具体类。
抽象类不能直接创建对象,但可以声明指针和引用:
A* ptr;
A& ref = c;
第十一部分:多态对象应该怎样安全存入容器?
46. 原始指针示例存在生命周期问题
下面的代码在局部范围内是可以工作的:
Player player;
Tree tree;
Projectile projectile;
std::vector<Entity*> entities{
&player,
&tree,
&projectile
};
但指针不拥有对象。
如果对象先销毁,容器中的地址就会失效:
std::vector<Entity*> entities;
{
Player player;
entities.push_back(&player);
} // player 在这里销毁
entities[0]->update(); // 悬空指针,危险
因此,真正的程序还需要明确:
谁负责拥有和销毁这些对象?
现代 C++ 常使用智能指针。
47. 使用 std::unique_ptr<Entity>
#include <memory>
#include <vector>
std::vector<std::unique_ptr<Entity>> entities;
添加不同子类对象:
entities.push_back(std::make_unique<Player>());
entities.push_back(std::make_unique<Tree>());
entities.push_back(std::make_unique<Projectile>());
遍历:
for (const auto& entity : entities) {
entity->update();
entity->render();
}
这里:
entity
是一个 std::unique_ptr<Entity> 的引用。
它通过 -> 访问对象:
entity->update();
容器销毁时,智能指针会自动销毁对应对象。
48. 一份完整的现代 C++ 游戏对象示例
#include <cmath>
#include <iostream>
#include <memory>
#include <string>
#include <utility>
#include <vector>
struct HitBox {
double radius;
};
class Entity {
public:
Entity(
std::string name,
double x,
double y,
double z,
double radius
)
: name{std::move(name)},
x{x},
y{y},
z{z},
hitbox{radius} {
}
virtual void update() = 0;
virtual void render() const = 0;
virtual ~Entity() = default;
bool overlapsWith(const Entity& other) const {
const double dx = x - other.x;
const double dy = y - other.y;
const double dz = z - other.z;
const double distanceSquared =
dx * dx + dy * dy + dz * dz;
const double combinedRadius =
hitbox.radius + other.hitbox.radius;
return distanceSquared <=
combinedRadius * combinedRadius;
}
const std::string& getName() const {
return name;
}
protected:
void moveBy(double dx, double dy, double dz) {
x += dx;
y += dy;
z += dz;
}
private:
std::string name;
double x;
double y;
double z;
HitBox hitbox;
};
class Player final : public Entity {
public:
Player(std::string name, double x, double y, double z)
: Entity{std::move(name), x, y, z, 0.5},
hitpoints{100.0} {
}
void update() override {
std::cout << getName()
<< " reads controller input\n";
}
void render() const override {
std::cout << "Draw player "
<< getName() << '\n';
}
void damage(double amount) {
hitpoints -= amount;
}
private:
double hitpoints;
};
class Tree final : public Entity {
public:
Tree(std::string name, double x, double y, double z)
: Entity{std::move(name), x, y, z, 1.5} {
}
void update() override {
std::cout << getName()
<< " sways in the wind\n";
}
void render() const override {
std::cout << "Draw tree "
<< getName() << '\n';
}
};
class Projectile final : public Entity {
public:
Projectile(
std::string name,
double x,
double y,
double z,
double vx,
double vy,
double vz
)
: Entity{std::move(name), x, y, z, 0.1},
vx{vx},
vy{vy},
vz{vz} {
}
void update() override {
moveBy(vx, vy, vz);
std::cout << getName()
<< " moves\n";
}
void render() const override {
std::cout << "Draw projectile "
<< getName() << '\n';
}
private:
double vx;
double vy;
double vz;
};
int main() {
std::vector<std::unique_ptr<Entity>> entities;
entities.push_back(
std::make_unique<Player>(
"Mishi", 0.0, 0.0, 0.0
)
);
entities.push_back(
std::make_unique<Tree>(
"Oak", 5.0, 0.0, 0.0
)
);
entities.push_back(
std::make_unique<Projectile>(
"Bullet",
0.2, 0.0, 0.0,
1.0, 0.0, 0.0
)
);
for (const auto& entity : entities) {
entity->update();
entity->render();
std::cout << '\n';
}
const bool firstTouchesThird =
entities[0]->overlapsWith(*entities[2]);
std::cout
<< "Player overlaps projectile: "
<< std::boolalpha
<< firstTouchesThird
<< '\n';
}
编译:
g++ -std=c++20 -Wall -Wextra -pedantic main.cpp -o main
./main
这里同时使用了:
- 公有继承;
- 抽象基类;
- 纯虚函数;
override;- 基类指针;
- 动态分派;
std::unique_ptr;- 共同功能
overlapsWith(); - 派生类自己的额外数据。
第十二部分:为什么多态基类需要虚析构函数?
49. 通过基类指针销毁子类对象
考虑:
Entity* entity = new Player;
delete entity;
对象真实类型是 Player,但删除表达式中的指针类型是 Entity*。
正确销毁顺序应该是:
1. Player::~Player()
2. Entity::~Entity()
3. 释放对象占用的内存
如果基类析构函数不是虚函数,通过基类指针删除派生类对象会产生未定义行为。
常见后果是派生类析构函数没有正确执行,导致其资源泄漏。
50. 正确写法
只要一个类准备被用作多态基类,通常应该写:
class Entity {
public:
virtual ~Entity() = default;
virtual void update() = 0;
virtual void render() const = 0;
};
= default 表示:
请编译器生成默认析构行为,但这个析构函数必须是虚函数。
这样:
std::unique_ptr<Entity> entity =
std::make_unique<Player>();
在智能指针销毁对象时,能够正确调用:
Player::~Player()
Entity::~Entity()
51. 为什么智能指针不能替代虚析构函数?
std::unique_ptr<Entity> 知道自己保存的是一个 Entity*。
当它销毁对象时,本质上仍需要通过基类指针执行删除。
因此,下面的基类仍然有问题:
class Entity {
public:
~Entity() = default; // 非虚析构
};
智能指针不会自动猜出基类设计错误。
正确做法仍然是:
virtual ~Entity() = default;
小练习 8:哪一个基类需要虚析构?
class MathHelper {
public:
int add(int a, int b) const {
return a + b;
}
};
class Shape {
public:
virtual double area() const = 0;
};
答案与解释
Shape 明显是多态基类,应当加入:
virtual ~Shape() = default;
MathHelper 没有虚函数,也没有表现出被当作多态基类使用的意图,通常不需要虚析构函数。
第十三部分:继承并不总是正确答案
52. “汽车是一种发动机”听起来就不对
错误设计发动机”听起来就:
class Car : public Engine {
};
这表达:
Car is an Engine
汽车是一种发动机
但汽车并不是发动机。
汽车只是拥有发动机:
Car has an Engine
汽车拥有一个发动机
这种 “has-a” 关系通常应使用组合(composition)。
53. 使用组合表示“拥有”
class Engine {
public:
void start() {
}
};
class SteeringWheel {
};
class Brakes {
};
class Car {
private:
Engine engine;
SteeringWheel wheel;
Brakes brakes;
};
概念图:
Car
├── has an Engine
├── has a SteeringWheel
└── has Brakes
而不是:
Car
├── is an Engine
├── is a SteeringWheel
└── is Brakes
54. 组合通常比深继承树更灵活
假设使用继承构建所有车型:
Vehicle
└── Car
├── GasCar
│ ├── ManualGasCar
│ └── AutomaticGasCar
└── ElectricCar
├── ManualElectricCar
└── AutomaticElectricCar
如果再加入:
- 两驱与四驱;
- 普通刹车与赛车刹车;
- 自动驾驶与非自动驾驶;
继承树会迅速膨胀。
组合可以改成:
class Car {
private:
std::unique_ptr<Engine> engine;
std::unique_ptr<Transmission> transmission;
std::unique_ptr<DriveSystem> driveSystem;
};
每辆车可以自由组合部件,而不用为每种组合新建一个子类。
55. 继承和组合可以一起使用
发动机之间确实可能存在 “is-a” 关系:
GasEngine is an Engine
DieselEngine is an Engine
ElectricEngine is an Engine
于是:
class Engine {
public:
virtual void start() = 0;
virtual ~Engine() = default;
};
class GasEngine : public Engine {
public:
void start() override {
std::cout << "Start gas engine\n";
}
};
class ElectricEngine : public Engine {
public:
void start() override {
std::cout << "Start electric motor\n";
}
};
汽车与发动机之间使用组合:
class Car {
public:
explicit Car(std::unique_ptr<Engine> engine)
: engine{std::move(engine)} {
}
void start() {
engine->start();
}
private:
std::unique_ptr<Engine> engine;
};
创建不同汽车:
Car gasCar{
std::make_unique<GasEngine>()
};
Car electricCar{
std::make_unique<ElectricEngine>()
};
最终关系是:
GasEngine is an Engine → 继承
ElectricEngine is an Engine → 继承
Car has an Engine → 组合
这种设计同时利用了两种工具的优势。
56. 判断继承是否合理的几个问题
准备写:
class Child : public Parent
之前,可以问:
问题一:真的存在 “is-a” 关系吗?
Dog is an Animal ✓
Circle is a Shape ✓
Car is an Engine ✗
Stack is a Vector ✗
问题二:子类是否能够替代父类?
如果某个函数需要 Shape&,传入 Circle& 是否合理?
如果需要 Engine&,传入 Car& 是否合理?
后一种明显不合理。
问题三:只是想复用几行代码吗?
仅仅为了少写几行代码而继承,通常不够。
继承会建立类型关系,并让基类接口成为子类承诺的一部分。
问题四:组合是否更自然?
如果关系是“拥有”“使用”“由……构成”,优先考虑成员变量。
第十四部分:三个课件练习的重新设计
课件开头提供了三组练习:一个银行账户类、一个通过私有继承实现的栈,以及一个练习纯虚函数、运行时多态和虚析构函数的虚拟动物园。
57. 练习一:实现 BankAccount
原练习给出的接口包含账户所有者、余额、存款、取款、查询余额和打印账单等操作。
#include <string>
class BankAccount {
private:
std::string owner;
double balance;
public:
BankAccount(
std::string owner,
double initialBalance
);
void deposit(double amount);
bool withdraw(double amount);
double getBalance() const;
void printStatement() const;
};
任务
完成类的实现,并满足:
- 初始余额不能为负;
- 存入金额必须大于零;
- 余额不足时,
withdraw()返回false; - 取款成功时返回
true; - 查询余额不修改对象,因此应为
const成员函数。
参考实现
#include <iomanip>
#include <iostream>
#include <stdexcept>
#include <string>
#include <utility>
class BankAccount {
public:
BankAccount(
std::string owner,
double initialBalance
)
: owner{std::move(owner)},
balance{initialBalance} {
if (initialBalance < 0) {
throw std::invalid_argument{
"Initial balance cannot be negative"
};
}
}
void deposit(double amount) {
if (amount <= 0) {
throw std::invalid_argument{
"Deposit amount must be positive"
};
}
balance += amount;
}
bool withdraw(double amount) {
if (amount <= 0) {
throw std::invalid_argument{
"Withdrawal amount must be positive"
};
}
if (amount > balance) {
return false;
}
balance -= amount;
return true;
}
double getBalance() const {
return balance;
}
void printStatement() const {
std::cout
<< "Owner: " << owner << '\n'
<< "Balance: $"
<< std::fixed
<< std::setprecision(2)
<< balance
<< '\n';
}
private:
std::string owner;
double balance;
};
int main() {
BankAccount account{"Mishi", 100.0};
account.deposit(50.0);
if (!account.withdraw(200.0)) {
std::cout << "Insufficient funds\n";
}
account.withdraw(30.0);
account.printStatement();
}
58. 练习二:私有继承实现 Stack
原练习定义了:
class Stack : private std::vector<int> {
public:
Stack();
void push(int item);
void pop();
int top();
bool isEmpty() const;
};
它使用私有继承隐藏 std::vector<int> 的公开接口,只暴露栈操作。
使用私有继承的实现
#include <stdexcept>
#include <vector>
class Stack : private std::vector<int> {
public:
Stack() = default;
void push(int item) {
push_back(item);
}
void pop() {
if (empty()) {
throw std::out_of_range{
"Cannot pop an empty stack"
};
}
pop_back();
}
int top() {
if (empty()) {
throw std::out_of_range{
"Cannot read the top of an empty stack"
};
}
return back();
}
bool isEmpty() const {
return empty();
}
};
私有继承后,外部不能使用:
Stack stack;
stack.push_back(10); // 不可访问
但 Stack 内部可以使用继承来的 vector 成员。
为什么组合通常更合适?
Stack 并不是一种 vector。
它只是使用 vector 保存数据。
因此更自然的实现是:
#include <stdexcept>
#include <vector>
class Stack {
public:
void push(int item) {
data.push_back(item);
}
void pop() {
if (data.empty()) {
throw std::out_of_range{
"Cannot pop an empty stack"
};
}
data.pop_back();
}
int top() const {
if (data.empty()) {
throw std::out_of_range{
"Cannot read the top of an empty stack"
};
}
return data.back();
}
bool isEmpty() const {
return data.empty();
}
private:
std::vector<int> data;
};
关系现在是:
Stack has a vector
而不是:
Stack is a vector
私有继承可以作为一种实现复用技术,但组合通常更直观,也更少暴露父类与子类之间的耦合。
59. 练习三:虚拟动物园
原练习要求构建一个 Animal 抽象基类,以及 Dog、Cat、Bird 三个派生类。它重点练习纯虚函数、通过基类指针进行多态调用、override 和虚析构函数。
第一步:设计抽象基类
class Animal {
public:
explicit Animal(std::string name);
const std::string& getName() const;
virtual std::string speak() const = 0;
virtual std::string move() const = 0;
virtual std::string getType() const = 0;
void introduce() const;
virtual ~Animal() = default;
private:
std::string name;
};
这里:
speak()、move()、getType()是纯虚函数;Animal不能直接实例化;introduce()只实现一次;introduce()内部调用虚函数,实际行为由子类决定;- 虚析构函数保证通过
Animal*正确销毁子类。
第二步:实现不同动物
class Dog : public Animal {
public:
using Animal::Animal;
std::string speak() const override {
return "Woof!";
}
std::string move() const override {
return "runs on all fours";
}
std::string getType() const override {
return "Dog";
}
void fetch() const {
std::cout << "...fetches the ball!\n";
}
};
Cat 和 Bird 以同样方式实现不同的行为。
完整单文件参考实现
展开参考答案
#include <iostream>
#include <memory>
#include <string>
#include <utility>
#include <vector>
class Animal {
public:
explicit Animal(std::string name)
: name{std::move(name)} {
}
const std::string& getName() const {
return name;
}
virtual std::string speak() const = 0;
virtual std::string move() const = 0;
virtual std::string getType() const = 0;
void introduce() const {
std::cout
<< "Hi, I'm " << name << "! "
<< speak()
<< " ...and I "
<< move()
<< ".\n";
}
virtual ~Animal() = default;
private:
std::string name;
};
class Dog final : public Animal {
public:
explicit Dog(std::string name)
: Animal{std::move(name)} {
}
std::string speak() const override {
return "Woof!";
}
std::string move() const override {
return "run on all fours";
}
std::string getType() const override {
return "Dog";
}
void fetch() const {
std::cout << "...fetches the ball!\n";
}
};
class Cat final : public Animal {
public:
explicit Cat(std::string name)
: Animal{std::move(name)} {
}
std::string speak() const override {
return "Meow!";
}
std::string move() const override {
return "slink gracefully";
}
std::string getType() const override {
return "Cat";
}
void purr() const {
std::cout << "Purrrrr...\n";
}
};
class Bird final : public Animal {
public:
explicit Bird(std::string name)
: Animal{std::move(name)} {
}
std::string speak() const override {
return "Tweet!";
}
std::string move() const override {
return "soar through the air";
}
std::string getType() const override {
return "Bird";
}
void migrate() const {
std::cout << "Heading south for winter!\n";
}
};
int main() {
std::vector<std::unique_ptr<Animal>> zoo;
zoo.push_back(
std::make_unique<Dog>("Buddy")
);
zoo.push_back(
std::make_unique<Cat>("Whiskers")
);
zoo.push_back(
std::make_unique<Bird>("Tweety")
);
zoo.push_back(
std::make_unique<Dog>("Rex")
);
std::cout
<< "=== Welcome to the Virtual Zoo ===\n\n";
for (const auto& animal : zoo) {
animal->introduce();
}
}
故意破坏实验:删除 virtual
把:
virtual std::string speak() const = 0;
暂时改成一个普通函数:
std::string speak() const {
return "";
}
然后观察 introduce() 中:
speak()
会调用哪一个版本。
没有虚函数时,Animal::introduce() 中的调用会静态绑定到 Animal::speak(),不会根据真实动物类型选择 Dog::speak() 或 Cat::speak()。
这个实验能直接展示:
普通函数:看编译期类型
虚函数:看运行期类型
故意破坏实验:删除虚析构
将:
virtual ~Animal() = default;
改为:
~Animal() = default;
如果再通过 Animal* 删除 Dog 对象:
Animal* animal = new Dog{"Buddy"};
delete animal;
程序会产生未定义行为。
即使某次运行看起来“没出问题”,也不能依赖这种结果。
第十五部分:初学者最容易犯的错误
60. 忘记写 public 继承
错误:
class Player : Entity {
};
对于 class,这默认是私有继承。
表达 “Player is an Entity” 时应写:
class Player : public Entity {
};
61. 把对象按值放进基类容器
错误:
std::vector<Entity> entities{
Player{},
Tree{}
};
这会发生对象切片。
多态对象通常使用:
std::vector<std::unique_ptr<Entity>>
或在不拥有对象时使用:
std::vector<Entity*>
但必须保证被指向对象的生命周期足够长。
62. 以为“用了指针就自动多态”
仅仅使用:
Entity* entity = &player;
还不够。
父类函数必须是虚函数:
virtual void update();
否则:
entity->update();
仍根据 Entity* 的编译期类型调用父类版本。
63. 子类函数签名没有真正匹配父类
父类:
virtual void render() const;
错误子类:
void render();
少了 const,不是同一个函数。
始终推荐:
void render() const override;
这样编译器会帮你检查。
64. 多态基类忘记虚析构
错误:
class Entity {
public:
virtual void update() = 0;
~Entity() = default;
};
正确:
class Entity {
public:
virtual void update() = 0;
virtual ~Entity() = default;
};
65. 以为父类 private 成员不存在于子类中
父类私有成员仍然是子类对象中基类子对象的一部分。
只是子类成员函数不能直接访问它们。
应通过父类提供的接口访问。
66. 滥用 protected
把所有数据设为:
protected:
虽然方便,却会让大量子类直接依赖父类的内部表示。
父类以后想把:
double x;
double y;
double z;
改成:
Position position;
所有直接访问这些变量的子类都可能需要修改。
更稳健的设计常常是:
private:
Position position;
protected:
void moveBy(...);
const Position& getPosition() const;
67. 只为了复用代码而建立错误继承关系
错误思路:
Engine 有 start()
Car 也需要 start()
所以 Car 继承 Engine
正确判断应先问:
Car 真的是一种 Engine 吗?
不是,因此应使用组合。
68. 认为虚函数表是语言标准强制的结构
vptr 和 vtable 是理解动态分派的优秀模型,也是主流编译器的常见实现。
但 C++ 语言规定的是行为:
通过基类指针或引用调用虚函数时,
根据对象的运行期类型选择最终重写函数。
语言标准并不要求所有编译器必须用完全相同的表结构实现它。
第十六部分:综合练习
69. 设计一个媒体播放器
请设计一个程序,支持三种媒体:
Song
Video
Podcast
它们都需要:
play()
getDuration()
getTitle()
但播放方式不同。
要求:
- 创建抽象基类
Media; - 使用纯虚函数定义统一接口;
- 三个派生类使用
override; - 使用
std::vector<std::unique_ptr<Media>>保存对象; - 遍历容器并调用
play(); - 基类包含虚析构函数;
Media本身不能实例化。
参考实现
#include <iostream>
#include <memory>
#include <string>
#include <utility>
#include <vector>
class Media {
public:
Media(std::string title, int durationSeconds)
: title{std::move(title)},
durationSeconds{durationSeconds} {
}
const std::string& getTitle() const {
return title;
}
int getDuration() const {
return durationSeconds;
}
virtual void play() const = 0;
virtual ~Media() = default;
private:
std::string title;
int durationSeconds;
};
class Song final : public Media {
public:
Song(
std::string title,
int durationSeconds,
std::string artist
)
: Media{
std::move(title),
durationSeconds
},
artist{std::move(artist)} {
}
void play() const override {
std::cout
<< "Playing song: "
<< getTitle()
<< " by "
<< artist
<< '\n';
}
private:
std::string artist;
};
class Video final : public Media {
public:
Video(
std::string title,
int durationSeconds,
std::string resolution
)
: Media{
std::move(title),
durationSeconds
},
resolution{std::move(resolution)} {
}
void play() const override {
std::cout
<< "Playing video: "
<< getTitle()
<< " at "
<< resolution
<< '\n';
}
private:
std::string resolution;
};
class Podcast final : public Media {
public:
Podcast(
std::string title,
int durationSeconds,
int episode
)
: Media{
std::move(title),
durationSeconds
},
episode{episode} {
}
void play() const override {
std::cout
<< "Playing podcast: "
<< getTitle()
<< ", episode "
<< episode
<< '\n';
}
private:
int episode;
};
int main() {
std::vector<std::unique_ptr<Media>> playlist;
playlist.push_back(
std::make_unique<Song>(
"Algorithm Dreams",
210,
"The Debuggers"
)
);
playlist.push_back(
std::make_unique<Video>(
"C++ Inheritance",
1800,
"1080p"
)
);
playlist.push_back(
std::make_unique<Podcast>(
"ACMer Radio",
2400,
42
)
);
for (const auto& media : playlist) {
media->play();
std::cout
<< "Duration: "
<< media->getDuration()
<< " seconds\n\n";
}
}
70. 修改题:找出程序中的五个设计问题
#include <vector>
class Animal {
public:
void speak() {
}
~Animal() {
}
};
class Dog : Animal {
public:
void speak() {
}
};
int main() {
std::vector<Animal> animals;
animals.push_back(Dog{});
Animal* animal = new Dog;
animal->speak();
delete animal;
}
答案与解释
问题一:默认私有继承
class Dog : Animal
应改为:
class Dog : public Animal
问题二:speak() 不是虚函数
父类应写:
virtual void speak() {
}
问题三:子类没有使用 override
推荐:
void speak() override {
}
问题四:std::vector<Animal> 会切片
应使用:
std::vector<std::unique_ptr<Animal>>
问题五:基类析构函数不是虚函数
应写:
virtual ~Animal() = default;
修改后的核心设计:
class Animal {
public:
virtual void speak() = 0;
virtual ~Animal() = default;
};
class Dog : public Animal {
public:
void speak() override {
}
};
第十七部分:把整节课压缩成一条完整思维链
71. 从重复代码开始
多个类共享数据和行为:
Player
Projectile
Tree
NPC
于是抽取共同基类:
Entity
72. 公有继承建立类型关系
class Player : public Entity {
};
它表达:
Player is an Entity
因此 Player 可以在需要 Entity& 或 Entity* 的地方使用。
73. 按值转换会切掉派生类部分
Entity entity = Player{};
产生对象切片:
Player → 只保留 Entity 部分 → 新的 Entity 对象
因此多态对象不能直接按值放进:
std::vector<Entity>
74. 指针和引用避免复制
Entity* entity = &player;
Entity& entityRef = player;
原始 Player 对象仍完整存在。
但普通函数仍根据编译期类型调用。
75. 虚函数启用动态分派
class Entity {
public:
virtual void update();
};
现在通过 Entity* 或 Entity& 调用时,会根据对象运行期类型选择函数:
指向 Player → Player::update()
指向 Projectile → Projectile::update()
指向 Tree → Tree::update()
76. override 让编译器检查重写关系
void update() override;
它可以发现:
- 参数类型写错;
- 漏掉
const; - 函数名拼错;
- 父类函数根本不是虚函数。
77. 纯虚函数定义必须实现的接口
virtual void update() = 0;
含有纯虚函数的类是抽象类,不能直接创建对象。
它表达:
所有 Entity 都必须能 update,
但具体行为由派生类决定。
78. 多态基类通常需要虚析构函数
virtual ~Entity() = default;
它确保通过基类指针销毁对象时,派生类析构函数也会执行。
79. 继承只适合 “is-a”
Player is an Entity → 继承
Circle is a Shape → 继承
组合适合 “has-a”:
Car has an Engine → 组合
Stack has a vector → 组合
继承是一种强大的类型关系,不应只为了少写几行代码而使用。
第十八部分:本节知识会连接到哪里?
继承和虚函数解决的是:
如何用统一接口操作多种运行期类型?
但它也留下了新的问题:
谁拥有这些多态对象?
对象什么时候销毁?
如何避免手动 new 和 delete?
如何复制一个由基类指针指向的多态对象?
深继承树为什么越来越难维护?
能否不用运行时虚函数,也实现统一算法?
这些问题会自然连接到几条后由基类指针指续学习路线:
对象生命周期
↓
RAII 与智能指针
统一操作多种类型
↓
运行时多态与编译期多态
复杂继承树
↓
组合、接口与更好的类设计
隐藏类的实现细节
↓
PIMPL 等工程设计技巧
学习完本节后,看到下面的代码时,你应该能够完整解释它:
std::vector<std::unique_ptr<Entity>> entities;
entities.push_back(
std::make_unique<Player>()
);
for (const auto& entity : entities) {
entity->update();
}
解释链应当是:
Player 公有继承 Entity
↓
Player 可以通过 Entity* 使用
↓
unique_ptr<Entity> 保存基类指针并拥有对象
↓
没有按值复制,所以不会发生对象切片
↓
Entity::update() 是虚函数
↓
调用根据对象运行期类型动态分派
↓
实际执行 Player::update()
↓
容器销毁时,通过虚析构函数正确销毁 Player
这条链条,就是本节继承实践课最核心的内容。