K KASS 返回文章列表
公开文章

READING APPEARANCE

选择阅读主题

选择会保存在当前设备,下次阅读自动沿用。

C++ / 2026-08-01

CS106L 第 7 讲:类、继承与动态多态

从封装、构造函数和类设计出发,理解继承、虚函数与动态多态。

CS106L 第 7 讲:类、继承与动态多态 的封面
C++ · CLASS-C

从“装数据的盒子”到真正的对象、继承与动态多态

本课程根据 Stanford CS106L Lecture 7 的完整课件重新组织,覆盖其中关于类、构造函数、封装、继承、虚函数和虚继承的主要内容,并修正课件中部分容易误导初学者的简化说法。


1. 这一节课真正要解决什么问题?

到目前为止,我们已经使用过很多 C++ 类型:

int
double
std::string
std::vector<int>
std::set<std::string>

其中 intdouble 是语言自带的类型,而 std::stringstd::vectorstd::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++ 语法来看,这完全合法。

但从“学生证”这个概念来看,负数编号显然不应该存在。

我们真正需要的,不只是一个能把数据装在一起的盒子。我们需要一种机制,能够做到:

  1. 把相关数据组织在一起;
  2. 把操作这些数据的函数也放在一起;
  3. 阻止使用者随意破坏数据;
  4. 保证对象创建出来时就是有效的;
  5. 向使用者提供清晰、稳定的操作接口。

这正是类(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 + nit[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
    ↓
根据模具制作出来的两块独立饼干

c1c2 类型相同,但它们是两个不同对象,各自拥有自己的状态。

类似地:

std::vector<int> first;
std::vector<int> second;

firstsecond 都按照 std::vector<int> 这个“模具”创建,但它们保存的是两份不同的数据。


4. structclass 到底有什么区别?

课件使用“透明背包”和“有很多封闭口袋的背包”来类比:

struct:所有东西默认都暴露在外面
class :内部数据默认被保护起来

这个类比可以帮助建立直觉,但需要给出更准确的正式结论:

在 C++ 中,structclass 的能力几乎完全相同。

两者都可以拥有:

  • 成员变量;
  • 成员函数;
  • 构造函数;
  • 析构函数;
  • publicprotectedprivate
  • 继承;
  • 虚函数。

它们最主要的区别只有默认访问权限不同。

使用 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. privatepublic

类可以划分为不同访问区域:

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
);

构造函数有两个明显特征:

  1. 名字与类名完全相同;
  2. 没有返回类型,连 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};

请回答:

  1. 参数 x 的值是什么?
  2. 成员 x_ 最终是什么?
  3. 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 被销毁时:

  1. 编译器生成的析构函数开始执行;
  2. name_ 的析构函数被调用;
  3. sunet_ 的析构函数被调用;
  4. int 不需要特殊清理;
  5. 对象生命周期结束。

因此,最好的写法通常是什么都不写:

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::vectorstd::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; // 无法访问私有类型别名

所以标准库希望使用者访问的 iteratorvalue_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& 可以引用 CircleRectangle 的思路相同:

不同具体对象
共享统一接口

标准库中的真实继承图更复杂,但课件中的图主要想表达:

我们现在学习的继承不是孤立语法,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 描述的是继承方式。

继承方式会决定基类中的 publicprotected 成员,在派生类中以什么权限继续存在。


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 同时继承自 BC

多重继承本身不一定错误,但它会带来更复杂的对象结构。

最经典的问题是菱形继承(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”,但这里 BC 并没有自己定义 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 部分

BC 不再各自携带独立的 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}

BC 中对 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. 课件中的 stateage 是复制遗留错误

课件某个 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

要求:

  1. Shape 是抽象基类;
  2. 提供纯虚函数 area()
  3. 提供纯虚函数 name()
  4. 每个派生类维护自己的尺寸;
  5. 编写统一函数:
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;

允许 BCD 中共享一份 A,解决经典菱形问题。


本节课的核心结论

类(class)让我们创建用户自定义类型,把数据和操作组织在一起,并通过访问控制维护对象的有效状态。

构造函数负责建立对象的初始状态。成员初始化列表是初始化成员的首选方式。

private 隐藏实现,public 构成使用者能够依赖的接口。封装的目标不是单纯“不让别人看”,而是防止对象进入无效状态。

析构函数在对象生命周期结束时自动调用。对于由 std::stringstd::vector 等自动管理资源的成员组成的类,通常不需要手写析构函数。

继承用于表达类型之间的关系。公开继承通常应当表示真正的“is-a”关系;仅仅为了复用实现时,组合通常更加合适。

虚函数使程序能够通过基类引用或指针,根据对象的动态类型调用派生类实现。C++ 仍然是静态类型语言,只是在虚函数调用处进行动态分派。

纯虚函数建立接口,并使基类成为抽象类。多态基类通常应当提供虚析构函数。

虚继承与虚函数是两个不同概念。虚函数解决“运行时调用哪个实现”,虚继承解决“菱形结构中保留几份基类对象”。


与后续知识的连接

我们现在已经能够设计一个拥有私有状态、公开接口和继承层次的类,但仍然留下了一些重要问题:

对象被复制时,成员怎样复制?
对象移动时发生什么?
类自己管理资源时,需要实现哪些特殊函数?
怎样让自定义类型支持 <<、==、< 等运算符?
怎样编写像 std::vector<T> 一样可以适配不同类型的类?
怎样安全地保存一组不同派生类型的对象?

这些问题会自然带出:

  • 拷贝构造函数;
  • 拷贝赋值运算符;
  • 移动构造与移动赋值;
  • RAII;
  • 智能指针;
  • 运算符重载;
  • 类模板;
  • 多态对象的安全所有权管理。

到这里,我们不再只是“使用 C++ 已经提供的类型”,而是开始学习如何设计自己的类型系统。