C++ / 2026-08-01
CS106L 第 7 讲:类、继承与动态多态
从封装、构造函数和类设计出发,理解继承、虚函数与动态多态。
从“装数据的盒子”到真正的对象、继承与动态多态
本课程根据 Stanford CS106L Lecture 7 的完整课件重新组织,覆盖其中关于类、构造函数、封装、继承、虚函数和虚继承的主要内容,并修正课件中部分容易误导初学者的简化说法。
1. 这一节课真正要解决什么问题?
到目前为止,我们已经使用过很多 C++ 类型:
int
double
std::string
std::vector<int>
std::set<std::string>
其中 int、double 是语言自带的类型,而 std::string、std::vector、std::set 则是标准库提供的类型。
现在假设我们要在程序里表示一张学生证。它至少包含:
姓名
SUNet 用户名
学生编号
我们可以使用三个互相独立的变量:
std::string name = "Preston Seay";
std::string sunet = "pseay";
int idNumber = 1243425;
但这样会立刻产生一个问题:这三个变量在逻辑上属于同一个学生,却没有被程序组织在一起。
于是我们可能想到使用 struct:
struct StanfordID {
std::string name;
std::string sunet;
int idNumber;
};
现在,一张学生证可以表示成一个完整对象:
StanfordID student;
student.name = "Preston Seay";
student.sunet = "pseay";
student.idNumber = 1243425;
这已经比三个独立变量好很多。然而新的问题很快就会出现:
student.idNumber = -12345;
从 C++ 语法来看,这完全合法。
但从“学生证”这个概念来看,负数编号显然不应该存在。
我们真正需要的,不只是一个能把数据装在一起的盒子。我们需要一种机制,能够做到:
- 把相关数据组织在一起;
- 把操作这些数据的函数也放在一起;
- 阻止使用者随意破坏数据;
- 保证对象创建出来时就是有效的;
- 向使用者提供清晰、稳定的操作接口。
这正是类(class)要解决的问题。
2. 从上一讲的迭代器到这一讲的类
课件首先回顾了迭代器,因为我们之前使用的 STL 容器本身就是类。
例如:
std::vector<int> numbers;
std::set<int> values;
这里的 std::vector<int> 和 std::set<int> 都是由标准库定义的类类型。
它们不仅保存数据,还提供了一整套操作:
numbers.push_back(10);
numbers.size();
numbers.begin();
numbers.end();
这些函数不是散落在程序中的普通函数,而是属于容器对象的成员函数(member function)。
2.1 迭代器类别回顾
经典 STL 迭代器大致可以按照能力理解为:
输入迭代器(Input Iterator)
↓
前向迭代器(Forward Iterator)
↓
双向迭代器(Bidirectional Iterator)
↓
随机访问迭代器(Random Access Iterator)
输出迭代器(Output Iterator)则主要用于写入数据,是另一条用途不同的分支。
不同类别的核心区别如下:
| 迭代器类别 | 主要能力 |
|---|---|
| 输入迭代器 | 向前读取,通常只保证单趟访问 |
| 前向迭代器 | 可以多趟向前访问 |
| 双向迭代器 | 除了 ++it,还可以使用 --it |
| 随机访问迭代器 | 可以执行 it + n、it[n],直接跨越多个元素 |
因此:
std::set<int>::iterator
属于双向迭代器。
你可以向前走:
++it;
也可以向后走:
--it;
但不能像 std::vector 的迭代器一样随意跳跃:
it += 5; // 对 std::set<int>::iterator 不合法
2.2 iterator 究竟是什么类型?
我们经常写:
std::vector<int>::iterator it;
这里的 iterator 实际上是 std::vector<int> 类内部提供的一个类型名称。
它的实现可以粗略想象成:
template <typename T>
class vector {
public:
using iterator = /* 某种迭代器类型 */;
// 其他实现……
};
也就是说,类里面不仅可以放变量和函数,还可以定义新的类型别名。
这正好把我们从迭代器自然带到了类。
第一部分:为什么需要类?
3. 类与对象:饼干模具和饼干
类(class)是一种用户自定义类型(user-defined type)。
例如:
class Cookie {
// 描述 Cookie 对象应该包含什么
};
这段代码定义了一种新的类型 Cookie。
接下来可以创建多个这种类型的对象:
Cookie c1;
Cookie c2;
可以把它理解成:
class Cookie
↓
饼干模具
Cookie c1
Cookie c2
↓
根据模具制作出来的两块独立饼干
c1 和 c2 类型相同,但它们是两个不同对象,各自拥有自己的状态。
类似地:
std::vector<int> first;
std::vector<int> second;
first 和 second 都按照 std::vector<int> 这个“模具”创建,但它们保存的是两份不同的数据。
4. struct 和 class 到底有什么区别?
课件使用“透明背包”和“有很多封闭口袋的背包”来类比:
struct:所有东西默认都暴露在外面
class :内部数据默认被保护起来
这个类比可以帮助建立直觉,但需要给出更准确的正式结论:
在 C++ 中,
struct和class的能力几乎完全相同。
两者都可以拥有:
- 成员变量;
- 成员函数;
- 构造函数;
- 析构函数;
public、protected和private;- 继承;
- 虚函数。
它们最主要的区别只有默认访问权限不同。
使用 struct
struct Example {
int value;
};
等价于:
struct Example {
public:
int value;
};
使用 class
class Example {
int value;
};
等价于:
class Example {
private:
int value;
};
继承时也有类似区别:
struct B : A {
};
默认是公开继承。
class B : A {
};
默认是私有继承。
通常的编程习惯是:
- 只表示简单数据集合时,常使用
struct; - 需要维护规则、隐藏状态、提供操作接口时,常使用
class。
这只是惯例,不是语言强制规定。
5. 问题不在于“能否修改”,而在于“能否破坏对象”
考虑最初的结构体:
struct StanfordID {
std::string name;
std::string sunet;
int idNumber;
};
所有字段都可以被外部直接修改:
StanfordID student;
student.name = "";
student.sunet = "???";
student.idNumber = -500;
我们无法保证这个对象始终表示一张有效的学生证。
这样的对象可能处于:
姓名为空
SUNet 格式错误
学生编号为负数
这类“不应该存在的状态”称为无效状态(invalid state)。
类的一个重要目标,就是建立并维护对象的不变量(invariant)。
不变量可以理解为:
只要这个对象存在,就应该始终满足的规则。
对于 StanfordID,我们暂时规定:
学生编号必须大于 0
姓名不能为空
SUNet 用户名不能为空
为了保证这些规则,我们不能再允许使用者直接操作内部数据。
第二部分:从零设计一个类
6. private 与 public
类可以划分为不同访问区域:
class ClassName {
private:
// 类的内部实现
public:
// 提供给使用者的接口
};
public
公开成员可以被类外部代码访问:
object.publicFunction();
private
私有成员只能由这个类自己的成员函数访问。
类外部不能写:
object.privateValue = 10; // 编译错误
可以把一个类想象成一台机器:
使用者
│
│ 只能操作按钮、显示屏和接口
▼
┌───────────────────┐
│ public │
│ 构造函数、查询函数 │
│ 修改函数 │
├───────────────────┤
│ private │
│ 内部数据 │
│ 辅助函数 │
└───────────────────┘
使用者不需要知道机器内部每个齿轮怎样转动,只需要知道可以按哪些按钮。
这种隐藏内部状态、只提供受控接口的设计称为封装(encapsulation)。
7. 头文件和源文件为什么要分开?
一个类通常会被拆成两个文件:
StanfordID.h
StanfordID.cpp
头文件 .h
头文件主要描述类对外“长什么样”:
- 类的声明;
- 成员函数声明;
- 类型别名;
- 常量;
- 使用者能够看到的接口。
源文件 .cpp
源文件负责提供函数的具体实现:
- 构造函数如何初始化;
- getter 返回什么;
- setter 如何验证数据;
- 其他执行代码。
可以把它们理解成:
头文件:说明书
源文件:机器内部的实现
使用者读头文件,就能知道这个类怎么使用;通常不需要阅读所有实现细节。
8. 第一步:写出类的声明
先创建 StanfordID.h:
#pragma once
#include <string>
class StanfordID {
public:
StanfordID(
std::string name,
std::string sunet,
int idNumber
);
std::string getName() const;
std::string getSunet() const;
int getID() const;
private:
std::string name_;
std::string sunet_;
int idNumber_;
};
这里出现了几个新东西,我们逐个拆开。
9. 成员变量:每个对象自己的状态
private:
std::string name_;
std::string sunet_;
int idNumber_;
这三个变量称为成员变量(member variable)或字段(field)。
创建两个对象:
StanfordID alice{"Alice", "alice1", 1001};
StanfordID bob{"Bob", "bob2", 1002};
可以理解成内存中存在两组独立状态:
alice
┌────────────────────┐
│ name_ "Alice" │
│ sunet_ "alice1" │
│ idNumber_ 1001 │
└────────────────────┘
bob
┌────────────────────┐
│ name_ "Bob" │
│ sunet_ "bob2" │
│ idNumber_ 1002 │
└────────────────────┘
修改 alice 不会自动修改 bob。
成员变量名称末尾的下划线不是 C++ 要求,只是一种常见命名习惯:
name_
sunet_
idNumber_
这样可以很容易地区分:
name_ → 对象的成员变量
name → 函数参数或局部变量
10. 构造函数:对象出生时发生什么?
我们希望一张学生证被创建时,就立刻拥有姓名、SUNet 和编号。
这需要构造函数(constructor)。
StanfordID(
std::string name,
std::string sunet,
int idNumber
);
构造函数有两个明显特征:
- 名字与类名完全相同;
- 没有返回类型,连
void都不能写。
错误写法:
void StanfordID(...); // 这不是构造函数
正确写法:
StanfordID(...);
创建对象时:
StanfordID student{"Preston Seay", "pseay", 1243425};
编译器会根据参数寻找匹配的构造函数。
调用过程可以理解为:
传入参数
name = "Preston Seay"
sunet = "pseay"
idNumber = 1243425
│
▼
调用 StanfordID 构造函数
│
▼
初始化对象内部的
name_
sunet_
idNumber_
11. 一个非常常见的构造函数错误
初学者经常会写出:
StanfordID::StanfordID(
std::string name,
std::string sunet,
int idNumber
) {
name = name;
sunet = sunet;
idNumber = idNumber;
}
这段代码能够编译,却什么也没有完成。
以这一行举例:
name = name;
函数参数 name 遮蔽了同名成员变量。左右两边都表示参数 name。
它实际做的是:
把参数 name 的值赋给参数 name 自己
成员变量完全没有被修改。
12. this:当前正在操作哪个对象?
每个普通成员函数内部都有一个隐含的指针:
this
它指向当前正在操作的对象。
因此,可以写:
this->name_ = name;
this->sunet_ = sunet;
this->idNumber_ = idNumber;
这里:
this->name_
表示当前对象内部的 name_ 成员。
假设执行:
StanfordID student{"Alice", "alice1", 1001};
构造过程中:
this
↓
student 对象
┌─────────────────────┐
│ this->name_ │
│ this->sunet_ │
│ this->idNumber_ │
└─────────────────────┘
由于我们使用了尾部下划线,参数与成员名称本来就不同:
name_ = name;
因此没有必要强制写 this->。
两种写法都合法:
this->name_ = name;
name_ = name;
13. 更正确的方式:成员初始化列表
虽然在构造函数体中赋值可以工作:
StanfordID::StanfordID(
std::string name,
std::string sunet,
int idNumber
) {
name_ = name;
sunet_ = sunet;
idNumber_ = idNumber;
}
但更推荐使用成员初始化列表(member initializer list):
StanfordID::StanfordID(
std::string name,
std::string sunet,
int idNumber
)
: name_{name},
sunet_{sunet},
idNumber_{idNumber} {
}
注意冒号后面的部分:
: name_{name},
sunet_{sunet},
idNumber_{idNumber}
每一项都可以读作:
使用参数 name 初始化成员 name_
使用参数 sunet 初始化成员 sunet_
使用参数 idNumber 初始化成员 idNumber_
左边是成员变量:
name_
右边花括号中的表达式是初始化值:
{name}
初始化与赋值并不完全相同
使用构造函数体:
name_ = name;
大致经历:
1. name_ 先被默认构造
2. 再执行赋值
使用成员初始化列表:
name_{name}
则是:
直接用 name 构造 name_
对于 const 成员、引用成员,以及没有默认构造函数的成员对象,成员初始化列表甚至是必须的。
课件把它称为“list initialization constructor”,更准确的名称是:
构造函数的成员初始化列表,其中使用了花括号初始化。
14. 在构造过程中检查数据
封装的价值不只是“外面不能访问”,还在于我们可以集中验证数据。
#include "StanfordID.h"
#include <stdexcept>
#include <string>
StanfordID::StanfordID(
std::string name,
std::string sunet,
int idNumber
)
: name_{name},
sunet_{sunet},
idNumber_{idNumber} {
if (name_.empty()) {
throw std::invalid_argument{"name cannot be empty"};
}
if (sunet_.empty()) {
throw std::invalid_argument{"sunet cannot be empty"};
}
if (idNumber_ <= 0) {
throw std::invalid_argument{"ID must be positive"};
}
}
现在:
StanfordID valid{"Alice", "alice1", 1001};
可以正常创建。
但:
StanfordID invalid{"Alice", "alice1", -1001};
会抛出异常,而不是留下一个编号为负数的学生证对象。
核心目标是:
要么成功创建一个有效对象
要么创建失败
不要创建出一个半有效、半损坏的对象
15. 一个容易忽略的数字字面量陷阱
课件中的示例使用了:
01243425
在 C++ 中,以 0 开头的整数字面量会被解释为八进制,而不是普通十进制。
因此不要为了“看起来像编号”而写:
int id = 01243425;
应该写:
int id = 1243425;
此外,学生编号本质上是标识符,而不是用来计算的数字。
如果编号可能包含前导零:
00001234
使用字符串通常更加合理:
std::string idNumber = "00001234";
本节为了跟随课件继续使用 int,但实际建模时要思考:
这个东西真的是“数字”,还是只是由数字字符组成的“编号”?
16. getter:在不暴露成员变量的情况下读取数据
成员变量是私有的:
private:
std::string name_;
所以外部不能这样做:
std::cout << student.name_; // 编译错误
我们可以提供公开的查询函数:
std::string getName() const;
std::string getSunet() const;
int getID() const;
在 StanfordID.cpp 中实现:
std::string StanfordID::getName() const {
return name_;
}
std::string StanfordID::getSunet() const {
return sunet_;
}
int StanfordID::getID() const {
return idNumber_;
}
使用:
std::cout << student.getName() << '\n';
std::cout << student.getSunet() << '\n';
std::cout << student.getID() << '\n';
末尾的 const 是什么意思?
观察:
std::string getName() const;
函数括号后的 const 表示:
这个成员函数承诺不修改当前对象的可观察状态。
因此,getter 通常应该写成 const 成员函数。
在这样的函数中,不能随意写:
std::string StanfordID::getName() const {
name_ = "changed"; // 编译错误
return name_;
}
课件中的 getter 没有写末尾的 const。代码仍然可以工作,但现代 C++ 中通常应当补上。
17. setter:受控地修改数据
我们也可以提供修改函数:
void setName(std::string name);
void setSunet(std::string sunet);
void setID(int idNumber);
实现时检查输入:
void StanfordID::setName(std::string name) {
if (name.empty()) {
throw std::invalid_argument{"name cannot be empty"};
}
name_ = name;
}
void StanfordID::setSunet(std::string sunet) {
if (sunet.empty()) {
throw std::invalid_argument{"sunet cannot be empty"};
}
sunet_ = sunet;
}
void StanfordID::setID(int idNumber) {
if (idNumber <= 0) {
throw std::invalid_argument{"ID must be positive"};
}
idNumber_ = idNumber;
}
现在外部不能直接破坏编号:
student.idNumber_ = -1; // 编译错误
只能调用:
student.setID(-1);
而 setID 会拒绝无效数据。
不过,设计类时并不要求每个成员都必须拥有 setter。
例如,学生编号创建后不应该再变化,那么最好的设计可能是根本不提供:
setID(...)
封装不是机械地写一堆 getter 和 setter,而是只暴露真正合理的操作。
18. 私有辅助函数
如果构造函数和 setter 都要验证编号,我们可以把检查逻辑放进私有成员函数。
头文件:
class StanfordID {
public:
StanfordID(
std::string name,
std::string sunet,
int idNumber
);
std::string getName() const;
std::string getSunet() const;
int getID() const;
void setName(std::string name);
void setSunet(std::string sunet);
void setID(int idNumber);
private:
static bool isValidID(int idNumber);
std::string name_;
std::string sunet_;
int idNumber_;
};
实现:
bool StanfordID::isValidID(int idNumber) {
return idNumber > 0;
}
构造函数中:
if (!isValidID(idNumber_)) {
throw std::invalid_argument{"ID must be positive"};
}
setter 中:
void StanfordID::setID(int idNumber) {
if (!isValidID(idNumber)) {
throw std::invalid_argument{"ID must be positive"};
}
idNumber_ = idNumber;
}
外部使用者不需要调用 isValidID,它只是类内部组织代码的工具,所以应该放在 private 中。
19. 默认构造函数与构造函数重载
有时我们希望允许不传参数就创建对象:
StanfordID student;
这需要默认构造函数(default constructor):
StanfordID();
实现:
StanfordID::StanfordID()
: StanfordID{"John Appleseed", "jappleseed", 1} {
}
这里使用了委托构造(delegating constructor):
StanfordID()
: StanfordID{...}
它表示:
默认构造函数把真正的初始化工作交给另一个构造函数。
这样可以避免重复验证逻辑。
现在这个类拥有两个构造函数:
StanfordID();
StanfordID(
std::string name,
std::string sunet,
int idNumber
);
使用时:
StanfordID defaultStudent;
匹配无参数构造函数。
StanfordID alice{"Alice", "alice1", 1001};
匹配三个参数的构造函数。
多个同名函数根据参数数量或类型区分,这称为函数重载(function overloading)。
构造函数也可以重载。
20. 完整的 StanfordID 类
StanfordID.h
#pragma once
#include <string>
class StanfordID {
public:
StanfordID();
StanfordID(
std::string name,
std::string sunet,
int idNumber
);
std::string getName() const;
std::string getSunet() const;
int getID() const;
void setName(std::string name);
void setSunet(std::string sunet);
void setID(int idNumber);
private:
static bool isValidID(int idNumber);
std::string name_;
std::string sunet_;
int idNumber_;
};
StanfordID.cpp
#include "StanfordID.h"
#include <stdexcept>
#include <string>
StanfordID::StanfordID()
: StanfordID{"John Appleseed", "jappleseed", 1} {
}
StanfordID::StanfordID(
std::string name,
std::string sunet,
int idNumber
)
: name_{name},
sunet_{sunet},
idNumber_{idNumber} {
if (name_.empty()) {
throw std::invalid_argument{"name cannot be empty"};
}
if (sunet_.empty()) {
throw std::invalid_argument{"sunet cannot be empty"};
}
if (!isValidID(idNumber_)) {
throw std::invalid_argument{"ID must be positive"};
}
}
std::string StanfordID::getName() const {
return name_;
}
std::string StanfordID::getSunet() const {
return sunet_;
}
int StanfordID::getID() const {
return idNumber_;
}
void StanfordID::setName(std::string name) {
if (name.empty()) {
throw std::invalid_argument{"name cannot be empty"};
}
name_ = name;
}
void StanfordID::setSunet(std::string sunet) {
if (sunet.empty()) {
throw std::invalid_argument{"sunet cannot be empty"};
}
sunet_ = sunet;
}
void StanfordID::setID(int idNumber) {
if (!isValidID(idNumber)) {
throw std::invalid_argument{"ID must be positive"};
}
idNumber_ = idNumber;
}
bool StanfordID::isValidID(int idNumber) {
return idNumber > 0;
}
main.cpp
#include "StanfordID.h"
#include <iostream>
#include <stdexcept>
int main() {
try {
StanfordID student{
"Preston Seay",
"pseay",
1243425
};
std::cout << "Name: " << student.getName() << '\n';
std::cout << "SUNet: " << student.getSunet() << '\n';
std::cout << "ID: " << student.getID() << '\n';
student.setName("Rachel Fernandez");
std::cout << "Updated name: "
<< student.getName()
<< '\n';
student.setID(-12345);
} catch (const std::invalid_argument& error) {
std::cout << "Invalid StanfordID: "
<< error.what()
<< '\n';
}
}
编译:
g++ -std=c++20 main.cpp StanfordID.cpp -o main
运行:
./main
可能输出:
Name: Preston Seay
SUNet: pseay
ID: 1243425
Updated name: Rachel Fernandez
Invalid StanfordID: ID must be positive
21. StanfordID:: 是什么?
在源文件中,我们写:
std::string StanfordID::getName() const
StanfordID:: 表示这个函数属于 StanfordID 类的作用域。
如果只写:
std::string getName() {
return name_;
}
那么它会被当作一个普通的全局函数。
编译器不知道它是 StanfordID 的成员函数,也就不能直接访问 name_。
作用域解析运算符 :: 也出现在:
std::string
std::cout
std::vector
这里:
std::
表示名称位于 std 命名空间中。
而:
StanfordID::
表示名称属于 StanfordID 类的作用域。
它们的具体机制不完全相同,但阅读方式很接近:
std::string
→ std 作用域中的 string
StanfordID::getName
→ StanfordID 作用域中的 getName
小练习:构造函数发生了什么?
观察:
class Point {
public:
Point(int x, int y)
: x_{x}, y_{y} {
}
private:
int x_;
int y_;
};
执行:
Point point{3, 5};
请回答:
- 参数
x的值是什么? - 成员
x_最终是什么? x_{x}左右两边分别表示什么?
答案与解释
参数状态:
x = 3
y = 5
初始化列表:
x_{x}
左边的 x_ 是成员变量,右边花括号中的 x 是构造函数参数。
最终:
point
┌───────┐
│ x_ 3 │
│ y_ 5 │
└───────┘
第三部分:对象的生命周期
22. 析构函数:对象离开时发生什么?
构造函数负责对象的出生。
析构函数(destructor)负责对象生命周期结束时的清理。
语法是类名前加 ~:
class StanfordID {
public:
~StanfordID();
};
实现:
StanfordID::~StanfordID() {
// 清理资源
}
析构函数同样没有返回类型,也不能带普通参数。
23. 析构函数什么时候被调用?
局部对象离开作用域时,析构函数会被自动调用。
#include <iostream>
class Tracer {
public:
Tracer() {
std::cout << "constructed\n";
}
~Tracer() {
std::cout << "destroyed\n";
}
};
int main() {
std::cout << "before block\n";
{
Tracer object;
std::cout << "inside block\n";
}
std::cout << "after block\n";
}
输出:
before block
constructed
inside block
destroyed
after block
执行过程:
进入作用域
↓
调用构造函数
↓
对象存活
↓
离开作用域
↓
自动调用析构函数
通常不应该手动调用:
object.~Tracer(); // 普通代码中不要这样做
24. StanfordID 需要自己写析构函数吗?
它的成员是:
std::string name_;
std::string sunet_;
int idNumber_;
这些成员都能够自行清理。
当 StanfordID 被销毁时:
- 编译器生成的析构函数开始执行;
name_的析构函数被调用;sunet_的析构函数被调用;int不需要特殊清理;- 对象生命周期结束。
因此,最好的写法通常是什么都不写:
class StanfordID {
// 不声明析构函数
};
编译器会自动生成合适的析构函数。
这体现了现代 C++ 中的重要思想:
如果成员对象能够自己管理资源,外层类通常也不需要手动管理资源。
这常被称为零规则(Rule of Zero)。
25. 关于课件中的 delete[]
课件展示了类似代码:
StanfordID::~StanfordID() {
delete[] my_array;
}
它只是为了说明:如果一个类使用 new[] 分配了动态数组,就需要在适当位置释放。
但必须注意:
delete[] my_array;
只有在 my_array 确实来自:
my_array = new T[size];
时才合法。
不能对普通数组、栈对象或不属于当前对象的内存随意使用 delete[]。
现代 C++ 中,更推荐使用:
std::vector<T>
std::string
std::unique_ptr<T[]>
而不是手动配对:
new[]
delete[]
如果一个类只包含 std::vector、std::string 等标准库对象,一般不需要手写析构函数。
第四部分:类中的类型别名
26. using:给类型起一个别名
我们可以写:
using String = std::string;
从此以后:
String
就可以作为 std::string 的同义名称。
放在类中:
class StanfordID {
public:
using String = std::string;
StanfordID(String name, String sunet, int idNumber);
String getName() const;
String getSunet() const;
private:
String name_;
String sunet_;
int idNumber_;
};
类外可以使用:
StanfordID::String name = "Alice";
这和我们之前见到的:
std::vector<int>::iterator
形式非常相似。
可以把标准库实现粗略想象成:
template <typename T>
class vector {
public:
using value_type = T;
using iterator = /* 迭代器类型 */;
};
于是:
std::vector<int>::value_type
表示 int。
std::vector<int>::iterator
表示这个 vector<int> 对应的迭代器类型。
类型别名放在 private 会怎样?
class StanfordID {
private:
using String = std::string;
};
这时类内部可以使用 String,类外却不能写:
StanfordID::String value; // 无法访问私有类型别名
所以标准库希望使用者访问的 iterator、value_type 等别名通常是公开的。
第五部分:类的综合练习
27. Wizard 与 Spellbook
课件给出的练习目标是:
实现一个巫师类和一本法术书类,使巫师能够学习并释放法术。
这个问题很适合练习:
- 类与对象;
- 私有数据;
- 构造函数;
- 成员函数;
- 一个对象包含另一个对象;
- 通过公开接口维护内部规则。
先思考两个类分别负责什么。
Spellbook
法术书负责:
保存法术
添加法术
判断是否拥有某个法术
Wizard
巫师负责:
保存名字
拥有一本法术书
学习法术
释放法术
注意:
Wizard 是 Spellbook 吗?
不是。
更准确的关系是:
Wizard 拥有一个 Spellbook
这叫组合(composition)或“has-a”关系。
练习要求
完成以下类,使程序能够运行:
int main() {
Wizard wizard{"Mishi"};
wizard.learnSpell("Fireball");
wizard.learnSpell("Ice Storm");
wizard.castSpell("Fireball");
wizard.castSpell("Teleport");
}
期望输出类似:
Mishi casts Fireball!
Mishi does not know Teleport.
参考实现
#include <algorithm>
#include <iostream>
#include <string>
#include <vector>
class Spellbook {
public:
void addSpell(const std::string& spell) {
if (!contains(spell)) {
spells_.push_back(spell);
}
}
bool contains(const std::string& spell) const {
return std::find(
spells_.begin(),
spells_.end(),
spell
) != spells_.end();
}
private:
std::vector<std::string> spells_;
};
class Wizard {
public:
explicit Wizard(std::string name)
: name_{name} {
}
void learnSpell(const std::string& spell) {
spellbook_.addSpell(spell);
}
void castSpell(const std::string& spell) const {
if (spellbook_.contains(spell)) {
std::cout << name_
<< " casts "
<< spell
<< "!\n";
} else {
std::cout << name_
<< " does not know "
<< spell
<< ".\n";
}
}
private:
std::string name_;
Spellbook spellbook_;
};
int main() {
Wizard wizard{"Mishi"};
wizard.learnSpell("Fireball");
wizard.learnSpell("Ice Storm");
wizard.castSpell("Fireball");
wizard.castSpell("Teleport");
}
编译:
g++ -std=c++20 main.cpp -o main
这里:
Spellbook spellbook_;
表示每个 Wizard 对象内部都包含一本自己的法术书。
外部不能直接修改:
wizard.spellbook_.spells_; // 不合法
必须通过:
wizard.learnSpell(...)
wizard.castSpell(...)
这正是封装带来的控制。
第六部分:为什么还需要继承?
28. 新的问题:不同对象需要相同接口
类已经能够把数据和操作封装在一起。
现在考虑一个绘图程序,它包含:
圆形
矩形
三角形
每种形状保存的数据不同:
圆形:半径
矩形:宽度和高度
三角形:底和高
但它们都能回答同一个问题:
你的面积是多少?
我们希望写出一个统一函数:
printArea(shape);
无论传入圆还是矩形,都能得到正确结果。
这带出了两个需求。
需求一:可扩展性
我们希望在已有概念上创建更加具体的类型:
Shape
├── Circle
├── Rectangle
└── Triangle
需求二:动态多态
不同类型的对象能够通过同一个接口使用:
shape.area()
但具体执行哪个面积公式,应当根据对象的真实类型决定。
为了解决这些问题,我们需要继承(inheritance)和虚函数(virtual function)。
29. 基类与派生类
继承关系中,较通用的类称为:
基类(base class)
更具体的类称为:
派生类(derived class)或子类(subclass)
例如:
Shape 是基类
Circle 是派生类
Rectangle 是派生类
代码结构:
class Circle : public Shape {
};
可以读作:
Circle公开继承自Shape。
公开继承通常表达“is-a”关系:
Circle is a Shape
圆是一种形状
Rectangle is a Shape
矩形是一种形状
30. 一个形状到底应该包含什么?
我们可能试图给 Shape 写成员变量:
class Shape {
private:
double radius;
double width;
double height;
};
但这不合理。
矩形不需要半径,圆也不需要宽度和高度。
所有形状真正共有的,不一定是某一组数据,而可能是一组能力:
每种形状都可以计算面积
因此,Shape 更适合定义一个共同接口:
class Shape {
public:
virtual double area() const = 0;
};
这段声明需要逐部分理解。
31. 纯虚函数
virtual double area() const = 0;
double
函数返回一个浮点数面积。
area()
函数名为 area,不接收参数。
const
计算面积不会修改形状对象。
virtual
通过基类引用或指针调用时,允许程序在运行时选择派生类版本。
= 0
表示这是纯虚函数(pure virtual function)。
它的含义不是“返回 0”,而是:
基类要求所有具体派生类提供这个函数,但基类本身不提供普通的具体实现。
课件将它描述为“在基类实例化、在子类中覆盖”,这个说法不够准确。
更准确地说:
Shape 声明了 area 接口
Circle 和 Rectangle 分别实现这个接口
只要一个类仍然包含未实现的纯虚函数,它就是抽象类(abstract class)。
不能直接创建:
Shape shape; // 编译错误
因为“一个不知道具体形状的 Shape”无法计算面积。
32. 抽象类也需要虚析构函数
一个用于多态的基类通常应写:
class Shape {
public:
virtual double area() const = 0;
virtual ~Shape() = default;
};
virtual ~Shape() 的意义是:
当未来通过
Shape*删除派生类对象时,确保派生类析构函数也会正确执行。
即使目前没有手动使用指针,这也是多态基类的标准设计习惯。
33. 实现 Circle
#include <numbers>
#include <stdexcept>
class Circle : public Shape {
public:
explicit Circle(double radius)
: radius_{radius} {
if (radius_ < 0.0) {
throw std::invalid_argument{
"radius cannot be negative"
};
}
}
double area() const override {
return std::numbers::pi
* radius_
* radius_;
}
private:
double radius_;
};
继承声明
class Circle : public Shape
表示 Circle 是一种 Shape。
构造函数
explicit Circle(double radius)
: radius_{radius}
使用参数初始化私有成员 radius_。
explicit 用来阻止一些意外的隐式转换,现在只需要把它理解为:
单参数构造函数通常值得加上
explicit。
覆盖虚函数
double area() const override
override 表示:
我打算覆盖基类中的一个虚函数,请编译器帮我检查函数签名是否匹配。
如果不小心写成:
double area() // 忘记 const
加了 override 后,编译器会立即指出它没有正确覆盖:
virtual double area() const = 0;
因此,现代 C++ 中覆盖虚函数时应该使用 override。
34. 实现 Rectangle
#include <stdexcept>
class Rectangle : public Shape {
public:
Rectangle(double height, double width)
: height_{height},
width_{width} {
if (height_ < 0.0 || width_ < 0.0) {
throw std::invalid_argument{
"dimensions cannot be negative"
};
}
}
double area() const override {
return width_ * height_;
}
private:
double height_;
double width_;
};
现在两个类保存的数据完全不同:
Circle
└── radius_
Rectangle
├── height_
└── width_
但它们都提供:
double area() const;
35. 动态多态真正发生在哪里?
先写一个函数:
#include <iostream>
void printArea(const Shape& shape) {
std::cout << shape.area() << '\n';
}
参数的类型是:
const Shape&
这意味着它可以引用任何公开继承自 Shape 的对象。
完整使用:
Circle circle{2.0};
Rectangle rectangle{3.0, 4.0};
printArea(circle);
printArea(rectangle);
执行 printArea(circle) 时:
shape 的静态类型:const Shape&
实际引用的对象:Circle
调用:shape.area()
│
▼
因为 area 是 virtual
运行时选择 Circle::area()
执行 printArea(rectangle) 时:
shape 的静态类型:const Shape&
实际引用的对象:Rectangle
调用:shape.area()
│
▼
运行时选择 Rectangle::area()
这就是动态多态(dynamic polymorphism)。
36. 静态类型与动态类型
考虑:
void printArea(const Shape& shape);
在函数内部,表达式 shape 的静态类型是:
const Shape&
静态类型在编译时确定。
如果传入:
Circle circle{2.0};
printArea(circle);
shape 实际引用的对象类型是:
Circle
这称为动态类型。
因此 C++ 并没有因为虚函数而“告别静态类型”。
更准确的说法是:
C++ 仍然是静态类型语言,但虚函数允许根据对象的动态类型执行运行时分派。
课件中“Goodbye to static typing”是一种活跃课堂气氛的夸张说法,不应当当作正式结论。
37. 完整的 Shape 示例
#include <iostream>
#include <numbers>
#include <stdexcept>
class Shape {
public:
virtual double area() const = 0;
virtual ~Shape() = default;
};
class Circle : public Shape {
public:
explicit Circle(double radius)
: radius_{radius} {
if (radius_ < 0.0) {
throw std::invalid_argument{
"radius cannot be negative"
};
}
}
double area() const override {
return std::numbers::pi
* radius_
* radius_;
}
private:
double radius_;
};
class Rectangle : public Shape {
public:
Rectangle(double height, double width)
: height_{height},
width_{width} {
if (height_ < 0.0 || width_ < 0.0) {
throw std::invalid_argument{
"dimensions cannot be negative"
};
}
}
double area() const override {
return width_ * height_;
}
private:
double height_;
double width_;
};
void printArea(const Shape& shape) {
std::cout << shape.area() << '\n';
}
int main() {
Circle circle{2.0};
Rectangle rectangle{3.0, 4.0};
printArea(circle);
printArea(rectangle);
}
编译:
g++ -std=c++20 main.cpp -o main
第一行面积约为:
12.5664
第二行是:
12
38. 继承带来的可扩展性
现在添加三角形:
class Triangle : public Shape {
public:
Triangle(double base, double height)
: base_{base},
height_{height} {
}
double area() const override {
return base_ * height_ / 2.0;
}
private:
double base_;
double height_;
};
原来的函数不需要修改:
void printArea(const Shape& shape) {
std::cout << shape.area() << '\n';
}
直接使用:
Triangle triangle{6.0, 4.0};
printArea(triangle);
这就是继承和多态带来的扩展能力:
新增一种 Shape
不需要重写所有处理 Shape 的函数
39. 标准库中的继承关系
课件展示了一张 C++ 输入输出流的继承树。
可以把它简化理解为:
ios_base
│
basic_ios
├───────────────┐
basic_istream basic_ostream
│ │
ifstream ofstream
istringstream ostringstream
\ /
iostream
例如:
std::ifstream
是一种输入流。
std::istringstream
也是一种输入流。
因此,接受:
std::istream&
的函数可以同时处理:
std::cin
std::ifstream
std::istringstream
这和 Shape& 可以引用 Circle、Rectangle 的思路相同:
不同具体对象
共享统一接口
标准库中的真实继承图更复杂,但课件中的图主要想表达:
我们现在学习的继承不是孤立语法,C++ 标准库本身就在大量使用类层次结构。
小练习:补全派生类
补全:
class Square : public Shape {
public:
explicit Square(double side)
: side_{side} {
}
// 在这里实现 area
private:
double side_;
};
答案
double area() const override {
return side_ * side_;
}
完整类:
class Square : public Shape {
public:
explicit Square(double side)
: side_{side} {
}
double area() const override {
return side_ * side_;
}
private:
double side_;
};
必须保留 const,因为基类声明是:
virtual double area() const = 0;
第七部分:公开、保护和私有继承
40. 两种不同的访问问题
初学继承时,很容易把两种权限混在一起。
第一种:成员自身的访问权限
在基类里:
class A {
public:
int publicValue;
protected:
int protectedValue;
private:
int privateValue;
};
第二种:继承方式
class B : public A {
};
这里的 public 描述的是继承方式。
继承方式会决定基类中的 public 和 protected 成员,在派生类中以什么权限继续存在。
41. 三种继承方式
| 基类成员权限 | public 继承后 |
protected 继承后 |
private 继承后 |
|---|---|---|---|
public |
public |
protected |
private |
protected |
protected |
protected |
private |
private |
派生类不能直接访问 | 派生类不能直接访问 | 派生类不能直接访问 |
对应语法:
class B : public A {
};
class B : protected A {
};
class B : private A {
};
需要特别注意:
“派生类不能直接访问基类的 private 成员”不等于这些成员不存在。
基类的私有成员仍然位于派生类对象中的基类部分,只是派生类代码不能直接读写。
例如:
class A {
public:
void setValue(int value) {
value_ = value;
}
protected:
int readValue() const {
return value_;
}
private:
int value_ = 0;
};
class B : public A {
public:
void test() {
setValue(10); // 可以,public
int x = readValue(); // 可以,protected
// value_ = 20;
// 不可以,value_ 是 A 的 private 成员
}
};
B 仍然可以通过 A 提供的公开或保护函数间接操作 value_。
42. 什么时候使用公开继承?
公开继承通常表达真正的“is-a”关系:
Circle is a Shape
ifstream is an istream
它意味着:
任何需要基类对象的地方,都应当能够合理地使用派生类对象。
这就是里氏替换原则背后的基本直觉。
如果某个类型虽然内部复用了另一个类,却并不真的“是一种”那个类,公开继承往往不合适。
43. Stack 应该继承 Vector 吗?
课件比较了:
Vector
push_back
pop_back
任意位置插入
随机访问
……
Stack
push
pop
top
……
我们可能想复用 Vector,于是写:
class MyStack : public MyVector {
};
但公开继承会让使用者看到所有 MyVector 操作。
例如:
MyStack stack;
stack.insert(...);
stack.erase(...);
stack[3] = 100;
这些操作可能破坏栈“只能操作顶部”的规则。
栈并不是一种向量:
Stack is not a Vector
栈只是可以借助向量保存数据:
Stack has a Vector
44. 课件中的答案:私有继承
课件给出的选择是:
class MyStack : private MyVector {
};
这样 MyVector 的公开接口会在 MyStack 中变成私有接口,外部无法直接调用。
从语法和封装角度看,这可以工作。
但是在现代 C++ 设计中,更常见、更清晰的方案是组合:
#include <stdexcept>
#include <vector>
class MyStack {
public:
void push(int value) {
data_.push_back(value);
}
void pop() {
if (data_.empty()) {
throw std::runtime_error{"stack is empty"};
}
data_.pop_back();
}
int top() const {
if (data_.empty()) {
throw std::runtime_error{"stack is empty"};
}
return data_.back();
}
bool empty() const {
return data_.empty();
}
private:
std::vector<int> data_;
};
这里:
std::vector<int> data_;
是类内部的实现细节。
外部只能使用:
push
pop
top
empty
这和标准库的 std::stack 思路一致:std::stack 是容器适配器,而不是 std::vector 的公开子类。
因此,看到课件选择 private inheritance 时,应理解它在展示继承权限;实际设计时还应问一句:
我真的需要继承吗,还是组合更自然?
第八部分:虚函数到底是什么?
45. 没有 virtual 会发生什么?
考虑:
#include <iostream>
class Base {
public:
void speak() const {
std::cout << "Base\n";
}
};
class Derived : public Base {
public:
void speak() const {
std::cout << "Derived\n";
}
};
void makeSpeak(const Base& object) {
object.speak();
}
int main() {
Derived derived;
makeSpeak(derived);
}
输出是:
Base
因为:
object
的静态类型是:
const Base&
而 speak 不是虚函数,所以编译器根据静态类型选择:
Base::speak()
46. 加上 virtual
class Base {
public:
virtual void speak() const {
std::cout << "Base\n";
}
virtual ~Base() = default;
};
class Derived : public Base {
public:
void speak() const override {
std::cout << "Derived\n";
}
};
再次运行:
Derived derived;
makeSpeak(derived);
输出变成:
Derived
因为虚函数调用会查看对象的动态类型。
静态类型:Base
动态类型:Derived
虚函数调用:选择 Derived::speak
47. vtable:一种常见实现方式
许多编译器使用虚函数表(virtual table,简称 vtable)实现动态分派。
可以粗略想象为:
Derived 对象
┌──────────────────┐
│ 隐藏的 vptr ─────────────┐
│ 其他成员数据 │ │
└──────────────────┘ │
▼
Derived 的 vtable
┌─────────────────┐
│ speak → Derived::speak
│ 其他虚函数地址
└─────────────────┘
调用:
baseReference.speak();
时,程序通过隐藏信息找到正确的函数。
不过需要区分:
语言保证
C++ 保证虚函数表现出动态分派行为。
常见实现
编译器经常使用 vptr 和 vtable 实现。
C++ 标准并没有要求所有编译器必须使用某一种固定的表结构。
因此不要把“virtual 的语言含义”简单等同于“必须创建某种特定内存表”。
48. 什么时候不会发生动态多态?
动态分派通常需要通过基类的引用或指针调用。
例如:
void printArea(const Shape& shape);
这里发生多态。
但如果直接拥有具体对象:
Circle circle{2.0};
circle.area();
编译器已经知道对象是 Circle,虽然 area 仍是虚函数,但没有真正的类型选择困难。
另外,按值传递基类会产生对象切片(object slicing)问题:
void badFunction(Shape shape); // Shape 还是抽象类,无法这样传
对于非抽象基类:
void badFunction(Base object);
传入派生类时,只会复制基类部分。
多态接口通常使用:
Base&
const Base&
Base*
而不是按值接收 Base。
第九部分:多重继承与菱形问题
49. 一个类可以继承多个基类
C++ 支持多重继承:
class D : public B, public C {
};
这表示 D 同时继承自 B 和 C。
多重继承本身不一定错误,但它会带来更复杂的对象结构。
最经典的问题是菱形继承(diamond inheritance)。
50. 菱形结构
假设有:
A
/ \
B C
\ /
D
代码:
class A {
public:
void hello() const;
};
class B : public A {
};
class C : public A {
};
class D : public B, public C {
};
B 内部包含一个 A 基类部分。
C 内部也包含一个 A 基类部分。
因此,普通继承下的 D 对象结构大致是:
D
├── B 部分
│ └── A 部分
└── C 部分
└── A 部分
也就是说,D 中存在两份 A。
51. 为什么 hello() 会产生歧义?
完整代码:
#include <iostream>
class A {
public:
void hello() const {
std::cout << "hello from A\n";
}
};
class B : public A {
};
class C : public A {
};
class D : public B, public C {
};
int main() {
D object;
object.B::hello();
object.C::hello();
// object.hello();
}
前两个调用合法:
object.B::hello();
表示沿着 B 这条继承路径,调用对应 A 子对象的 hello。
object.C::hello();
表示沿着 C 这条路径调用。
课件将它们写成“调用 B 的 hello”和“调用 C 的 hello”,但这里 B 和 C 并没有自己定义 hello。更准确地说,是:
通过 B 路径调用 A::hello
通过 C 路径调用 A::hello
直接写:
object.hello();
编译器无法判断要使用哪一份 A:
D → B → A
还是
D → C → A
所以会产生二义性错误。
52. 虚继承:让两条路径共享同一份基类
解决经典菱形问题的方法是虚继承(virtual inheritance):
class B : virtual public A {
};
class C : virtual public A {
};
也可以写成:
class B : public virtual A {
};
两种顺序都合法。
现在 D 的结构可以理解为:
D
├── B 部分 ──┐
├── C 部分 ──┤
└── 一份共享的 A 部分
B 和 C 不再各自携带独立的 A,而是共同引用 D 中的同一份虚基类 A。
完整代码:
#include <iostream>
class A {
public:
void hello() const {
std::cout << "hello from A\n";
}
};
class B : virtual public A {
};
class C : virtual public A {
};
class D : public B, public C {
};
int main() {
D object;
object.hello();
}
输出:
hello from A
不再有歧义。
53. 虚函数和虚继承不是同一件事
这两个功能都使用关键字:
virtual
但解决的是完全不同的问题。
虚函数
virtual double area() const = 0;
解决:
通过基类引用或指针调用函数时,
运行时应该执行哪个派生类版本?
虚继承
class B : virtual public A {
};
解决:
多条继承路径到达同一个基类时,
最下层对象应该包含几份基类子对象?
可以记成:
virtual function
→ 共享接口,运行时选函数
virtual inheritance
→ 菱形结构,共享基类子对象
它们使用相同关键字,是因为都在改变普通静态继承或调用规则,但不能混为一个概念。
54. 虚基类由谁构造?
假设 A 没有默认构造函数:
class A {
public:
explicit A(int value)
: value_{value} {
}
private:
int value_;
};
采用虚继承:
class B : virtual public A {
public:
B()
: A{1} {
}
};
class C : virtual public A {
public:
C()
: A{2} {
}
};
最底层类:
class D : public B, public C {
public:
D()
: A{100}, B{}, C{} {
}
};
当创建 D 时,真正负责构造共享虚基类 A 的是最派生类 D:
A{100}
B 和 C 中对 A 的初始化在构造 D 时不会决定最终值。
这也是虚继承比普通继承复杂的原因之一。
日常程序中不要为了“代码复用”随意引入多重继承和虚继承。只有关系本身确实合理时才使用。
第十部分:容易混淆和出错的地方
55. struct 并不是“不能封装”
错误理解:
struct 没有 private
class 才有 private
正确理解:
struct Example {
private:
int value_;
};
完全合法。
区别只是默认权限:
struct 默认 public
class 默认 private
56. private 不意味着对象内部不存在这个成员
派生类不能直接访问基类私有成员,但基类部分仍然存在于对象中。
不能直接访问
≠
没有这个成员
57. 构造函数没有返回类型
错误:
void StanfordID();
这声明的是一个名字恰好和类名相同的普通函数形式,不是合法的构造函数声明方式。
正确:
StanfordID();
58. name = name 通常什么都没有做
当参数和成员同名时:
name = name;
左右都是参数。
可以写:
this->name = name;
更推荐使用清晰命名和初始化列表:
StanfordID(std::string name)
: name_{name} {
}
59. 课件中的 state 和 age 是复制遗留错误
课件某个 StanfordID 构造函数示例写成:
this->name = name;
this->state = state;
this->age = age;
但 StanfordID 的成员应当是:
name
sunet
idNumber
因此正确对应关系应当是:
this->name = name;
this->sunet = sunet;
this->idNumber = idNumber;
或者使用成员初始化列表。
60. = 0 不是返回零
virtual double area() const = 0;
结尾的 = 0 表示纯虚函数。
它不是:
return 0;
61. 覆盖虚函数时使用 override
不推荐只写:
double area() const {
// ...
}
推荐:
double area() const override {
// ...
}
override 能让编译器检查:
- 函数名;
- 参数类型;
const;- 引用限定;
- 返回类型兼容性。
62. 多态基类通常需要虚析构函数
不完整:
class Shape {
public:
virtual double area() const = 0;
};
推荐:
class Shape {
public:
virtual double area() const = 0;
virtual ~Shape() = default;
};
63. 继承并不等于代码复用工具
看到两个类使用相似数据结构,不应立即继承。
先问:
Derived 真的是一种 Base 吗?
如果只是:
MyStack 使用 vector 保存数据
通常应该组合:
class MyStack {
private:
std::vector<int> data_;
};
而不是公开继承:
class MyStack : public std::vector<int> {
};
64. virtual 不会把 C++ 变成动态类型语言
C++ 中变量和表达式仍然拥有编译期静态类型。
虚函数只是允许:
在特定成员函数调用中,
根据对象的动态类型选择最终实现
这称为动态分派,而不是取消静态类型。
第十一部分:综合练习
65. 练习一:找出构造函数的问题
class Account {
public:
Account(std::string owner, double balance) {
owner = owner;
balance = balance;
}
private:
std::string owner;
double balance;
};
请找出问题并修正。
答案
参数遮蔽了成员变量:
owner = owner;
balance = balance;
两行都在给参数自己赋值。
可以使用:
Account(std::string owner, double balance)
: owner{owner},
balance{balance} {
}
更推荐让成员带后缀:
class Account {
public:
Account(std::string owner, double balance)
: owner_{owner},
balance_{balance} {
}
private:
std::string owner_;
double balance_;
};
66. 练习二:维护账户不变量
要求:
账户余额不能为负数
完成构造函数:
class Account {
public:
Account(std::string owner, double balance);
private:
std::string owner_;
double balance_;
};
答案
#include <stdexcept>
#include <string>
Account::Account(std::string owner, double balance)
: owner_{owner},
balance_{balance} {
if (owner_.empty()) {
throw std::invalid_argument{
"owner cannot be empty"
};
}
if (balance_ < 0.0) {
throw std::invalid_argument{
"balance cannot be negative"
};
}
}
对象构造完成后,始终满足:
owner_ 非空
balance_ >= 0
67. 练习三:判断继承关系是否合理
判断下面关系更适合继承还是组合。
情况 A
Dog 与 Animal
情况 B
Car 与 Engine
情况 C
Square 与 Shape
情况 D
TextEditor 与 std::string
答案
A:继承
Dog is an Animal
B:组合
Car has an Engine
车不是一种发动机。
C:继承
Square is a Shape
D:通常组合
文本编辑器内部可能使用字符串保存文本,但文本编辑器并不是一种字符串。
class TextEditor {
private:
std::string text_;
};
68. 练习四:预测虚函数输出
#include <iostream>
class Animal {
public:
virtual void sound() const {
std::cout << "Animal\n";
}
virtual ~Animal() = default;
};
class Cat : public Animal {
public:
void sound() const override {
std::cout << "Cat\n";
}
};
void playSound(const Animal& animal) {
animal.sound();
}
int main() {
Animal animal;
Cat cat;
playSound(animal);
playSound(cat);
}
输出是什么?
答案
Animal
Cat
第一次:
静态类型 Animal
动态类型 Animal
→ Animal::sound
第二次:
静态类型 Animal
动态类型 Cat
→ Cat::sound
69. 练习五:如果去掉 virtual 呢?
将:
virtual void sound() const
改为:
void sound() const
其他代码不变,输出是什么?
答案
Animal
Animal
playSound 中参数静态类型是:
const Animal&
没有虚函数后,编译器按照静态类型调用:
Animal::sound()
70. 练习六:分析菱形继承
class A {
public:
int value = 0;
};
class B : public A {
};
class C : public A {
};
class D : public B, public C {
};
下面代码是否合法?
D object;
object.value = 10;
答案
不合法。
D 中有两份 A:
D → B → A::value
D → C → A::value
编译器不知道修改哪一份。
可以明确指定:
object.B::value = 10;
object.C::value = 20;
也可以根据设计需要使用虚继承:
class B : virtual public A {
};
class C : virtual public A {
};
这样 D 中只有一份共享的 A::value。
71. 综合设计题:图形打印器
实现:
class Shape
class Circle
class Rectangle
class Triangle
要求:
Shape是抽象基类;- 提供纯虚函数
area(); - 提供纯虚函数
name(); - 每个派生类维护自己的尺寸;
- 编写统一函数:
void describe(const Shape& shape);
输出类似:
Circle has area 12.5664
Rectangle has area 12
Triangle has area 10
参考实现
#include <iostream>
#include <numbers>
#include <string>
class Shape {
public:
virtual double area() const = 0;
virtual std::string name() const = 0;
virtual ~Shape() = default;
};
class Circle : public Shape {
public:
explicit Circle(double radius)
: radius_{radius} {
}
double area() const override {
return std::numbers::pi
* radius_
* radius_;
}
std::string name() const override {
return "Circle";
}
private:
double radius_;
};
class Rectangle : public Shape {
public:
Rectangle(double width, double height)
: width_{width},
height_{height} {
}
double area() const override {
return width_ * height_;
}
std::string name() const override {
return "Rectangle";
}
private:
double width_;
double height_;
};
class Triangle : public Shape {
public:
Triangle(double base, double height)
: base_{base},
height_{height} {
}
double area() const override {
return base_ * height_ / 2.0;
}
std::string name() const override {
return "Triangle";
}
private:
double base_;
double height_;
};
void describe(const Shape& shape) {
std::cout << shape.name()
<< " has area "
<< shape.area()
<< '\n';
}
int main() {
Circle circle{2.0};
Rectangle rectangle{3.0, 4.0};
Triangle triangle{5.0, 4.0};
describe(circle);
describe(rectangle);
describe(triangle);
}
这里 describe 完全不知道每个形状的面积公式。
它只依赖 Shape 的公开接口:
name()
area()
具体执行哪个实现,由对象的动态类型决定。
第十二部分:把整节课串起来
72. 从数据集合到受控对象
最开始的 struct 只是把数据放在一起:
struct StanfordID {
std::string name;
std::string sunet;
int idNumber;
};
但任何代码都能破坏它:
student.idNumber = -1;
于是我们引入类:
private 保存内部状态
public 提供受控操作
构造函数保证对象正确出生
成员函数维护对象规则
析构函数负责生命周期结束时的清理
这解决了单个类型内部的组织问题。
73. 从单个类型到类型之间的关系
接着,我们发现:
Circle
Rectangle
Triangle
虽然数据不同,却共享“计算面积”这一能力。
于是引入继承:
Shape
├── Circle
├── Rectangle
└── Triangle
基类规定统一接口,派生类提供具体实现。
74. 从统一接口到运行时选择
只有继承还不够。
我们希望:
void printArea(const Shape& shape);
接收不同形状时自动执行正确版本。
于是引入虚函数:
virtual double area() const = 0;
调用:
shape.area();
时,程序根据动态类型选择:
Circle::area
Rectangle::area
Triangle::area
这就是动态多态。
75. 从单继承到复杂继承结构
最后,多重继承带来了新的结构问题:
A
/ \
B C
\ /
D
普通继承使 D 包含两份 A。
虚继承:
class B : virtual public A;
class C : virtual public A;
允许 B 和 C 在 D 中共享一份 A,解决经典菱形问题。
本节课的核心结论
类(class)让我们创建用户自定义类型,把数据和操作组织在一起,并通过访问控制维护对象的有效状态。
构造函数负责建立对象的初始状态。成员初始化列表是初始化成员的首选方式。
private 隐藏实现,public 构成使用者能够依赖的接口。封装的目标不是单纯“不让别人看”,而是防止对象进入无效状态。
析构函数在对象生命周期结束时自动调用。对于由 std::string、std::vector 等自动管理资源的成员组成的类,通常不需要手写析构函数。
继承用于表达类型之间的关系。公开继承通常应当表示真正的“is-a”关系;仅仅为了复用实现时,组合通常更加合适。
虚函数使程序能够通过基类引用或指针,根据对象的动态类型调用派生类实现。C++ 仍然是静态类型语言,只是在虚函数调用处进行动态分派。
纯虚函数建立接口,并使基类成为抽象类。多态基类通常应当提供虚析构函数。
虚继承与虚函数是两个不同概念。虚函数解决“运行时调用哪个实现”,虚继承解决“菱形结构中保留几份基类对象”。
与后续知识的连接
我们现在已经能够设计一个拥有私有状态、公开接口和继承层次的类,但仍然留下了一些重要问题:
对象被复制时,成员怎样复制?
对象移动时发生什么?
类自己管理资源时,需要实现哪些特殊函数?
怎样让自定义类型支持 <<、==、< 等运算符?
怎样编写像 std::vector<T> 一样可以适配不同类型的类?
怎样安全地保存一组不同派生类型的对象?
这些问题会自然带出:
- 拷贝构造函数;
- 拷贝赋值运算符;
- 移动构造与移动赋值;
- RAII;
- 智能指针;
- 运算符重载;
- 类模板;
- 多态对象的安全所有权管理。
到这里,我们不再只是“使用 C++ 已经提供的类型”,而是开始学习如何设计自己的类型系统。