Lecture 8 (Week 4 - Thursday): 可选:继承练习 (Optional: Inheritance Practice)
Lecture 8 (Week 4 - Thursday): 可选:继承练习 (Optional: Inheritance Practice)
概述
本讲是可选的继承复习课,从”类背后到底长什么样”讲起:对比 Python 与 C++ 的对象内存布局,揭示 this 作为隐藏参数传递的机制,然后系统梳理继承(is-a 关系)、三种访问修饰符、对象切片(object slicing)、基类指针多态、虚函数与 vtable/vpointer、纯虚函数与抽象类,最后以”组合优于继承”收尾。学习目标是把 Lecture 7 的类知识落实到”运行时行为”层面:为什么 std::vector<Entity> 存不下子类、为什么必须用 Entity* + virtual 才能写出正确的游戏循环。配套练习是 exercise1.h(BankAccount 类复习)与 exercise2.h(Stack : private std::vector<int> 私有继承练习)。
核心特性与语法详解
1. 类的内存布局:C++ 只存数据,Python 存”元信息”
- 定义与目的:理解
class Point { int x; int y; };的对象在内存里到底占什么。C++ 对象只按声明顺序存放数据成员(int x紧跟int y);类型检查全部发生在编译期,运行时不需要类型信息。 - 核心语法:
Point p{1, 2}; // 内存里只有两个 int:x=1, y=2 - 设计意图与最佳实践:对比 Python,同一个
Point对象要存 refcount、type 指针、__dict__(指向"_x"/"_y"两个字符串对象和两个 int 对象)…… 一长串指针。这就是 C++ 更省内存、更高效的原因之一——”你为不需要的灵活性付了代价”。函数本身不存进对象:所有对象的成员函数代码都放在内存的 Text(代码)区,对象里只有数据。
2. this 的机制:隐藏的第一个参数
- 定义与目的:成员函数如何知道”自己在为哪个对象工作”?答案是
this——编译器把成员调用改写为普通函数调用,把对象地址作为隐藏参数传进去。 - 核心语法:
int Point::getX() { return this->x; } // 编译器把它看成: int Point_getX(Point* this) { return this->x; } // 调用点: int x = p.getX(); // ⟶ int x = Point_getX(&p); - 设计意图与最佳实践:
return x;与return this->x;完全等价(成员名自动通过this解析)。- 但
void Point::setX(int x) { x = x; }与this->x = x;不等价——参数x遮蔽了成员x,前者是”自己赋给自己”。 - 在 const 成员函数里,
this的类型变成const Point*,所以无法修改成员(见 Lecture 9)。
3. 继承:is-a 关系与公共基类
- 定义与目的:继承让一个类复用另一个类的成员。核心心智模型是 is-a:
std::ifstream是一个std::istream,是一个std::ios;Player是一个Entity。课堂用游戏对象举例:Player/Projectile/Weapon/Tree/NPC 全都”是”Entity——有位置、有 hitbox、有 update/render。 - 核心语法:
class Entity { /* 位置、hitbox、update、render */ }; class Player : public Entity { double hitpoints; public: void damage(double hp); }; class Projectile : public Entity { double vx, vy, vz; }; class Actor : public Entity { double hitpoints; public: void damage(double hp); }; class NPC : public Actor {}; // 多层继承:NPC 是 Actor,也是 Entity - 设计意图与最佳实践:把公共部分(位置、hitbox、update/render)上提到基类,消除五个类里的重复代码;再抽出
Actor(有血量、会受伤)形成中间层。这样”给所有实体加一个overlapsWith(const Entity&)“只需要在Entity里写一次——player.overlapsWith(bullet)即可,任何实体都能互查。继承树定义的是一组 is-a 命题:”A Weapon is an Entity”、”An NPC is an Actor, and is also an Entity”。
4. 访问修饰符:默认私有继承、protected
- 定义与目的:
class Player : Entity默认是 private 继承——Entity的 public 成员(如overlapsWith)在Player里变成 private,外部调player.overlapsWith(bullet)会报”inaccessible”。只有public继承才真正表达 is-a。 - 核心语法:
class Player : /* private */ Entity { ... }; // 默认:public 成员变成 private class Player : public Entity { ... }; // ✅ is-a:public 仍是 public class Projectile : public Entity { public: void move() { x += vx; y += vy; z += vz; } // 需要 x,y,z 可见 ⟹ protected }; - 设计意图与最佳实践:
protected成员”对子类可见、对外部不可见”——想让Projectile直接读写x,y,z就得在Entity里把它们标为protected(注意类成员默认private)。三者取舍:public给用户,protected给子类,private只给自己。只有 public 继承是 is-a;private继承是”实现复用”(见exercise2.h:Stack : private std::vector<int>——栈复用了 vector 的实现,但绝不向用户暴露 vector 的insert等接口)。
5. 对象切片(Object Slicing)
- 定义与目的:
std::vector<Entity>的每个元素都是Entity;把Player按值放进去时,Player多出来的部分(hitpoints等)被丢弃——对象切片。切片只发生在拷贝时;指针/引用不会切片。 - 核心语法:
std::vector<Entity> bad{player, tree, bullet}; // ❌ 全部被切成 Entity std::vector<Entity*> good{&player, &tree, &bullet}; // ✅ 指针保留真实类型 - 设计意图与最佳实践:切片后容器里全是纯
Entity,循环调用entity.update()永远执行Entity::update()(空实现),”游戏什么都没发生”。改用Entity*数组后,指针仍指向完整的Player/Tree/Projectile对象,才谈得上多态。
6. 虚函数与动态分派:vtable / vpointer
- 定义与目的:
Entity*只告诉我们”编译期类型是 Entity”,而对象运行时类型可能是 Player 或 Projectile。虚函数通过 vtable 实现动态分派:按对象真实类型决定调用哪个update()。 - 核心语法:
class Entity { public: virtual void update() {} // virtual ⟹ 动态分派 virtual void render() {} }; class Projectile : public Entity { public: void update() override {} // override 非必需但强烈建议 }; - 设计意图与最佳实践:
virtual会给每个对象附加一个 vpointer,指向该类的 vtable——表中记录了”这个类的update应该调哪个函数”。p->update()的流程是:取p的 vpointer → 查 vtable → 跳到对应函数。C++ 中虚函数不是默认开启的,因为它有成本:对象内存变大(多一个指针)、调用多一次间接寻址;在量化金融等对纳秒敏感的场景甚至会刻意避免虚函数。override让编译器校验覆盖正确性。
7. 纯虚函数与抽象类
- 定义与目的:当基类”没有合理的默认实现”时(”Shape 的默认体积是多少?”),用纯虚函数把实现责任下放给子类。
- 核心语法:
class Shape { public: virtual double volume() = 0; }; Shape s; // ❌ 抽象类不能实例化 class Box : public Shape { /* 实现 volume() */ }; Box b; // ✅ 覆盖全部纯虚函数后是具体类 - 设计意图与最佳实践:含一个及以上纯虚函数的类是抽象类,只能被继承;子类覆盖所有纯虚函数后才可实例化。这是”接口设计”的工具:强制所有子类提供统一接口。
8. 组合优于继承(Composition over Inheritance)
- 定义与目的:继承树过深会变慢、难以推理(课堂展示了”继承失控”的梗图)。现代游戏引擎很少为每种对象类型建子类;组合(has-a)往往更灵活。
- 核心语法:
class Car : public Engine, public SteeringWheel, public Brakes { /* 不对劲 */ }; class Car { Engine engine; SteeringWheel wheel; Brakes brakes; }; // ✅ has-a - 设计意图与最佳实践:”A car is an engine”是错的,”A car has an engine”才对。组合 + 继承可结合使用(
Car拥有Engine*,而Engine下再挂CombustionEngine/ElectricEngine继承树);想深入可查 PIMPL 惯用法。
代码示例与逐步解说(核心)
示例 1:Point 类与 this 的机制(C++11)
代码:
// C++11
#include <iostream>
class Point {
private:
int x, y;
public:
Point(int x, int y) : x{x}, y{y} {}
int getX() const { return this->x; } // this 是 const Point*
void setX(int x) { this->x = x; } // 参数遮蔽成员,必须 this->
};
int main() {
Point p{1, 2};
p.setX(42);
std::cout << p.getX() << "\n"; // 42
std::cout << sizeof(Point) << "\n"; // 通常 8:只有两个 int
}
代码做什么:构造 Point,setX(42) 修改成员,getX() 读回;sizeof(Point) 说明对象里只有数据、没有函数指针等元信息。
特性机制解说:
- 内存中
p就是连续的两个int(x=1, y=2之后变成x=42, y=2)。成员函数代码存放在 Text 区,所有Point对象共享同一份代码。 p.setX(42)被编译器改写成Point_setX(&p, 42):this就是隐藏的第一个参数,类型Point*。p.getX()则是Point_getX(&p),因为在 const 成员函数中,this的类型是const Point*,所以函数体内不能写this->x = ...。- 对比 Python:
p.getX()等价于Point.getX(p)——Python 的self是显式参数;C++ 的this是隐式的,二者机制同源。区别是 Python 对象还背着 refcount/type/__dict__一整套运行时元信息,C++ 则把这些开销全部移到编译期。
示例 2:is-a 继承与 protected(游戏实体,C++11)
代码:
// C++11
#include <iostream>
#include <vector>
class Entity {
protected:
double x = 0, y = 0, z = 0; // protected:子类可见、外部不可见
public:
virtual void update() {}
virtual void render() {}
};
class Player : public Entity {
private:
double hitpoints = 100;
public:
void damage(double hp) { hitpoints -= hp; }
void update() override {
x += 1.0; // ✅ 子类可以读写 protected 的 x
std::cout << "Player moves\n";
}
};
class Projectile : public Entity {
public:
void update() override { std::cout << "Projectile flies\n"; }
};
int main() {
Player p;
Projectile b;
std::vector<Entity*> entities{&p, &b}; // 基类指针,不切片
for (Entity* e : entities) e->update(); // Player moves / Projectile flies
}
代码做什么:Player 与 Projectile 公开继承 Entity;Player::update 通过 protected 的 x 移动自己;main 用 Entity* 容器统一驱动每个实体的 update。
特性机制解说:
- public 继承:
Player完整继承Entity的公共接口(update/render),所以Entity* e = &p;合法——is-a 成立的编译期证据。 - protected:
x, y, z标protected后,Player内部可访问,但main里写p.x = 1;会编译失败。 - 指针不切片:
&p指向完整的Player对象(含hitpoints与x),vector<Entity*>只存地址,对象本体完好。
示例 3:对象切片演示与修复(C++11)
代码:
// C++11
#include <iostream>
#include <string>
#include <vector>
class Entity {
public:
virtual std::string kind() const { return "Entity"; }
};
class Player : public Entity {
public:
std::string kind() const override { return "Player"; }
};
class Tree : public Entity {
public:
std::string kind() const override { return "Tree"; }
};
int main() {
Player p;
Tree t;
std::vector<Entity> byValue{p, t}; // ❌ 切片:只拷走 Entity 部分
for (const Entity& e : byValue)
std::cout << e.kind() << " "; // 输出: Entity Entity
std::vector<Entity*> byPtr{&p, &t}; // ✅ 指针保留完整对象
for (const Entity* e : byPtr)
std::cout << e->kind() << " "; // 输出: Player Tree
std::cout << "\n";
}
代码做什么:同一个 Player 与 Tree,分别按值存入 vector<Entity>、按指针存入 vector<Entity*>,观察调用 kind() 的结果差异。
特性机制解说:
- 切片机制:
byValue{p, t}调用的是Entity的拷贝构造——编译器把Player的Entity基类子对象”切”出来拷贝,Player独有的部分直接丢弃。存进去的元素是货真价实的Entity,其 vpointer 指向Entity的 vtable,所以kind()永远是Entity::kind()。 - 指针方案:
&p是Player*,隐式转换为Entity*只发生指针类型转换,对象本体不动;e->kind()通过对象真实 vpointer 分派到Player::kind()。切片只发生在拷贝时——传参按值、容器按值存储、返回按值都会切片;传引用/指针则安全。
示例 4:虚函数与 vtable 动态分派(C++11)
代码:
// C++11
#include <iostream>
#include <vector>
class Entity {
public:
virtual void update() {} // 默认实现:什么都不做
virtual void render() {}
};
class Player : public Entity {
public:
void update() override { std::cout << "Player update\n"; }
void render() override { std::cout << "Player render\n"; }
};
class Projectile : public Entity {
public:
void update() override { std::cout << "Projectile update\n"; }
void render() override { std::cout << "Projectile render\n"; }
};
int main() {
Player p;
Projectile b;
std::vector<Entity*> entities{&p, &b};
for (Entity* ent : entities) { // 游戏主循环的雏形
ent->update();
ent->render();
}
}
代码做什么:模拟课堂的”游戏主循环”——遍历 Entity* 容器,每帧对每个实体调用 update() 与 render()。输出按真实类型分派:
Player update
Player render
Projectile update
Projectile render
特性机制解说:
- 没有
virtual时,ent->update()在编译期就绑定为Entity::update()(空实现)——这就是上一节”还是没工作”的原因。 - 加上
virtual后,每个Player对象多一个 vpointer,指向Player的 vtable:{update → Player::update, render → Player::render};Projectile的 vtable 则是{update → Projectile::update, render → Projectile::render}。ent->update()的机器码是:取 ent 的 vpointer → 查表偏移 → 间接调用,这就是动态分派。 - 成本意识:vpointer 让每个对象变大;查表是额外一次内存访问。这正是 C++ 要求”显式 opt-in”(写
virtual)而 Python/Java 默认全虚的原因。编译期类型(Entity*)与运行时类型(Player)在这里分道扬镳,虚函数是唯一让两者重新对齐的机制。
示例 5:纯虚函数与抽象类(C++11)
代码:
// C++11
#include <iostream>
class Shape {
public:
virtual double volume() = 0; // 纯虚:"默认体积"没有意义
virtual ~Shape() = default;
};
class Box : public Shape {
double w, h, d;
public:
Box(double w, double h, double d) : w{w}, h{h}, d{d} {}
double volume() override { return w * h * d; }
};
class Sphere : public Shape {
double r;
public:
explicit Sphere(double r) : r{r} {}
double volume() override { return 4.0 / 3.0 * 3.14 * r * r * r; }
};
int main() {
Box b{2, 3, 4};
Sphere s{1.0};
Shape* shapes[] = {&b, &s};
for (Shape* sh : shapes) std::cout << sh->volume() << "\n"; // 24 / 4.18667
// Shape x; // ❌ 抽象类无法实例化
}
代码做什么:Shape::volume() 用 = 0 声明为纯虚,Box 与 Sphere 各自实现;通过 Shape* 数组多态调用各自的 volume。
特性机制解说:
= 0使Shape成为抽象类:编译器禁止Shape x;(没有完整的 vtable 可用)。Box/Sphere覆盖全部纯虚函数后 vtable 填满,成为具体类,可以实例化。- 与普通虚函数的区别在于默认实现是否存在:
virtual void update() {}表示”默认什么都不做”(可覆盖可不覆盖);virtual void volume() = 0表示”本类根本没有默认行为,子类必须实现”。这正对应课堂问题:”What’s the default volume of a Shape?”——没有,所以纯虚。
示例 6:组合优于继承(C++11)
代码:
// C++11
#include <iostream>
// 错误直觉:class Car : public Engine, public SteeringWheel, public Brakes
// 正确做法:Car 拥有(has-a)这些部件
class Engine {
public:
void start() const { std::cout << "engine started\n"; }
};
class SteeringWheel {};
class Brakes {};
class Car {
private:
Engine engine; // 组合:成员对象
SteeringWheel wheel;
Brakes brakes;
public:
void start() const { engine.start(); } // 委托给部件
};
int main() {
Car c;
c.start(); // engine started
}
代码做什么:用成员对象而非多重继承组装 Car;Car::start() 委托给内部的 Engine::start()。
特性机制解说:
- “A car is an engine”是语义错误——组合把”拥有关系”编码成成员变量,天然可替换、可测试(换一个
Engine子类即可换动力系统)。 - 组合 + 继承可以并用:
Car持有Engine*,Engine之下再有CombustionEngine/ElectricEngine的继承树,兼得灵活性与复用性(PIMPL 惯用法正是这种思想的体现)。
与旧标准(如 C++98)的对比
| 本讲新特性(C++11+) | C++98 的做法 | 新特性优势 |
|---|---|---|
override 关键字 | 没有;靠函数名一致 | 覆盖写错签名/拼写时编译器报错,而不是静默新建虚函数 |
类内默认成员初始化 double x = 0; | 只能在每个构造器初始化列表写 | 所有构造器自动获得默认值,减少遗漏 |
基于范围的 for for (Entity* e : entities) | 手写 for (size_t i = 0; ...) 或迭代器 | 更短、更不易出错(示例 3/4 均用到) |
= default / = delete(如 ~Shape() = default) | 手写空函数体 / private 声明禁拷贝 | 明确表达意图,编译器生成更优代码 |
nullptr | NULL/0 | 类型安全,不会与整数 0 混淆 |
std::vector<Entity*> v{&p, &t}; 初始化列表构造 | 先默认构造再逐个 push_back | 一步到位、更高效 |
注意:虚函数、继承、vtable、纯虚函数等机制本身在 C++98 就存在——本讲复习的多是”老机制”,C++11 主要改善的是书写与校验(override、范围 for、= default)。动态分派的概念模型(编译期类型 vs 运行时类型)在任何标准下都成立。
关键要点
- C++ 对象只存数据、按声明顺序布局;函数在 Text 区,类型检查在编译期——这是 C++ 比 Python 省内存的核心原因(Python 还要背 refcount/type/
__dict__)。 this是隐藏参数:p.getX()⟶Point_getX(&p);参数遮蔽成员时this->x = x;是唯一正解;const 成员函数里this是const Point*。- 对象切片只发生在拷贝时:
vector<Entity>装不下Player;要多态就用vector<Entity*>(或引用、unique_ptr)。 virtual开启动态分派:vpointer → vtable → 按运行时类型调用;有内存与速度成本,所以 C++ 默认不开启(对比 Python 全虚)。- 纯虚函数 = 抽象类(不可实例化);继承表达 is-a(默认却是 private,要显式
public);能用组合就别滥用继承。
常见陷阱与注意事项
- 把派生类按值放进基类容器:
std::vector<Entity> v{p, t};静默切片,运行时调用的是Entity::update()(空实现)——不报错但行为全错,极难排查。 class B : A忘记public:默认 private 继承,player.overlapsWith(bullet)编译报 “inaccessible”;is-a 必须显式public。同理,protected只面向”子类设计者”,标得过多会削弱封装。- 多态基类析构函数非
virtual:delete基类指针时只析构基类部分,派生类资源泄漏(未定义行为);把~Shape()写成virtual或= default。 override写错签名(漏const、参数不同):不报错地”新建”了一个函数,动态分派悄悄失效;让override关键字把关。- 过度继承:为每种对象建一个子类会让继承树失控;先想 “has-a 是否更合适”(组合优于继承)。
关联作业提示
本讲继续支撑 A3: Make a Class!。几个直接可用的点:
- const 正确的 getter:A3 要求 getter 标
const(int getData() const;)。参考课堂exercise1.h的double getBalance() const;——只读查询不修改对象,就该进 const 接口(机制上this是const Point*,见本讲示例 1)。 - 私有继承的直觉:A3 若你想做”受限容器”类的思路,可参考
exercise2.h的Stack : private std::vector<int>——复用实现但不暴露 vector 的公共接口,这正是 Lecture 7 课堂MyStack : private MyVector的结论。 - 内存布局意识:A3 的类建议只存必要的数据成员(简单类型 +
std::string等),体会”对象小而高效”的设计;构造函数用成员初始化列表保证对象一出生就合法。 - 面向对象设计:A3 要求一个 private 成员函数——它是”引擎盖下的实现细节”(比如
sanitize()),不必暴露给用户;这正是封装思想的落地。 - 若选择加分项(把类写成类模板),请按 Lecture 9 的规则:
.h底部#include实现、去掉class.cpp的编译命令。
