13.1 一个简单的基类

面向对象编程 (OOP) 的一个核心优势是代码重用。除了通过包含对象成员(组合)之外,C++ 还提供了另一种强大的代码重用机制:**继承 (Inheritance)**。

继承允许我们基于一个已有的类(称为基类 (Base Class)父类 (Parent Class)**)来创建一个新的类(称为派生类 (Derived Class)** 或**子类 (Child Class)**)。派生类会自动获得基类的所有成员(数据和函数),并且可以添加自己的新成员或修改继承来的成员的行为。

这种机制非常适合用来表示现实世界中的“is-a”(是一种)关系。例如,一个“经理”一个“员工”,一个“圆”一个“形状”。

本节我们将从定义一个简单的基类开始,然后演示如何从中派生出新的类。

场景: 假设我们要为一个乒乓球俱乐部编写程序,需要表示球员信息。

基类 TableTennisPlayer:

我们首先创建一个基础的 TableTennisPlayer 类,包含球员的姓名和是否有球桌。

tabtenn0.h (基类定义)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#ifndef TABTENN0_H_
#define TABTENN0_H_
#include <string>

// 简单的基类
class TableTennisPlayer {
private:
std::string firstname;
std::string lastname;
bool hasTable;
public:
// 构造函数
TableTennisPlayer (const std::string & fn = "none",
const std::string & ln = "none",
bool ht = false);
// 方法
void Name() const; // 显示姓名
bool HasTable() const { return hasTable; }; // 是否有球桌
void ResetTable(bool v) { hasTable = v; }; // 重置球桌状态
};
#endif // TABTENN0_H_

tabtenn0.cpp (基类实现)

1
2
3
4
5
6
7
8
9
10
#include "tabtenn0.h"
#include <iostream>

TableTennisPlayer::TableTennisPlayer (const std::string & fn,
const std::string & ln, bool ht)
: firstname(fn), lastname(ln), hasTable(ht) {} // 使用成员初始化列表

void TableTennisPlayer::Name() const {
std::cout << lastname << ", " << firstname;
}

这个基类很简单,包含了球员的基本信息和操作。

13.1.1 派生一个类

现在,假设我们想为一部分球员添加“积分”信息。我们可以从 TableTennisPlayer 派生出一个新的类 RatedPlayer,它将继承 TableTennisPlayer 的所有成员,并添加自己的积分成员和相关方法。

继承语法:

1
2
3
class DerivedClassName : accessSpecifier BaseClassName {
// ... 派生类新增的成员 ...
};
  • DerivedClassName: 派生类的名称。
  • :: 表示继承关系。
  • accessSpecifier: 访问说明符(通常是 public),指定继承类型。
  • BaseClassName: 基类的名称。

RatedPlayer 类的定义 (添加到 tabtenn0.h 或新的头文件)

我们将 RatedPlayer 的定义也放在 tabtenn0.h 中(虽然分成不同文件更好)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// ... (TableTennisPlayer 定义之后) ...

// 简单的派生类
class RatedPlayer : public TableTennisPlayer { // 使用 public 继承
private:
unsigned int rating; // 新增成员:积分
public:
// 派生类构造函数
RatedPlayer (unsigned int r = 0, const std::string & fn = "none",
const std::string & ln = "none", bool ht = false);
// 使用基类对象进行构造
RatedPlayer(unsigned int r, const TableTennisPlayer & tp);

// 新增方法
unsigned int Rating() const { return rating; } // 获取积分
void ResetRating (unsigned int r) { rating = r; } // 重置积分
};

// ... (endif) ...
  • class RatedPlayer : public TableTennisPlayer: 这声明了 RatedPlayer 是一个从 TableTennisPlayer 公有继承 (publicly derived) 而来的类。
  • 公有继承 (public): 这是最常用的继承方式。它建立了一种 “is-a” 关系。基类的公有成员在派生类中仍然是公有的,基类的保护成员在派生类中仍然是保护的。基类的私有成员虽然被继承下来了,但在派生类中是不可直接访问的。
  • RatedPlayer 自动拥有了 firstname, lastname, hasTable 数据成员以及 Name(), HasTable(), ResetTable() 方法(尽管私有成员不能直接访问)。
  • RatedPlayer 添加了自己的私有成员 rating 和公有方法 Rating(), ResetRating()

13.1.2 构造函数:访问权限的考虑

派生类不能直接访问基类的私有成员。那么,派生类的构造函数如何初始化继承来的基类私有成员(如 firstname, lastname)呢?

答案是:派生类构造函数必须调用基类的构造函数来完成基类部分的初始化。这是通过成员初始化列表 (Member Initializer List) 实现的。

派生类构造函数的实现 (添加到 tabtenn0.cpp)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ... (TableTennisPlayer 实现之后) ...

// RatedPlayer 构造函数实现
RatedPlayer::RatedPlayer(unsigned int r, const std::string & fn,
const std::string & ln, bool ht)
: TableTennisPlayer(fn, ln, ht), rating(r) { // 调用基类构造函数
// rating(r) 初始化派生类自己的成员
// 基类部分的初始化委托给 TableTennisPlayer 的构造函数
}

RatedPlayer::RatedPlayer(unsigned int r, const TableTennisPlayer & tp)
: TableTennisPlayer(tp), rating(r) { // 调用基类的复制构造函数 (这里是隐式生成的)
// rating(r) 初始化派生类自己的成员
}
  • : TableTennisPlayer(fn, ln, ht): 这部分是成员初始化列表的关键。它显式地调用了基类 TableTennisPlayer 的构造函数,并将必要的参数 (fn, ln, ht) 传递给它。基类构造函数负责初始化 firstname, lastname, hasTable
  • , rating(r): 初始化列表也用于初始化派生类自己的成员 rating
  • 规则: 在创建派生类对象时,程序会首先调用基类的构造函数来创建和初始化对象的基类部分,然后再执行派生类构造函数体(并初始化派生类成员)。如果你不在成员初始化列表中显式调用基类构造函数,编译器会尝试调用基类的默认构造函数。如果基类没有默认构造函数,或者你需要调用带参数的基类构造函数,就必须在初始化列表中显式调用。

13.1.3 使用派生类

派生类的对象拥有基类和派生类的所有公有成员。

usett0.cpp (使用示例)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// filepath: d:\ProgramData\files_Cpp\250424\usett0.cpp
#include <iostream>
#include "tabtenn0.h" // 包含基类和派生类定义

int main ( void ) {
using std::cout;
using std::endl;
TableTennisPlayer player1("Chuck", "Blizzard", true);
RatedPlayer rplayer1(1140, "Mallory", "Duck", true); // 使用派生类构造函数

// 派生类对象可以使用基类的方法
rplayer1.Name(); // 调用继承来的 TableTennisPlayer::Name()
if (rplayer1.HasTable()) // 调用继承来的 TableTennisPlayer::HasTable()
cout << ": has a table.\n";
else
cout << ": hasn't a table.\n";

// 派生类对象可以使用自己的方法
cout << "Name: ";
rplayer1.Name();
cout << "; Rating: " << rplayer1.Rating() << endl; // 调用 RatedPlayer::Rating()

// 可以使用基类对象初始化派生类对象 (通过特定构造函数)
RatedPlayer rplayer2(1212, player1);
cout << "Name: ";
rplayer2.Name();
cout << "; Rating: " << rplayer2.Rating() << endl;

return 0;
}

编译和运行:

你需要将 tabtenn0.cppusett0.cpp 一起编译链接。

1
2
g++ usett0.cpp tabtenn0.cpp -o usett0
./usett0

输出:

1
2
3
Duck, Mallory: has a table.
Name: Duck, Mallory; Rating: 1140
Name: Blizzard, Chuck; Rating: 1212

13.1.4 派生类和基类之间的特殊关系

公有继承建立了一种非常重要的关系:

  1. 派生类对象也是一个基类对象: 一个 RatedPlayer 对象一个 TableTennisPlayer 对象。这意味着需要 TableTennisPlayer 对象的地方,通常可以使用 RatedPlayer 对象来替代。

  2. 基类指针/引用可以指向/引用派生类对象:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    RatedPlayer rplayer1(1140, "Mallory", "Duck", true);
    TableTennisPlayer * pt = &rplayer1; // 基类指针指向派生类对象 (OK)
    TableTennisPlayer & rt = rplayer1; // 基类引用引用派生类对象 (OK)

    pt->Name(); // 通过基类指针调用基类方法 (OK)
    rt.Name(); // 通过基类引用调用基类方法 (OK)

    // 但是,通过基类指针/引用不能直接调用派生类特有的方法
    // pt->Rating(); // 错误!TableTennisPlayer 没有 Rating() 方法
    // rt.Rating(); // 错误!

    这种指针/引用的兼容性是实现多态 (Polymorphism) 的基础,我们将在后续章节详细讨论。

总结:

  • 继承允许基于现有类(基类)创建新类(派生类)。
  • 派生类继承基类的成员(数据和方法)。
  • 公有继承 (public) 建立 “is-a” 关系,基类的公有成员在派生类中仍是公有。
  • 派生类构造函数必须通过成员初始化列表调用适当的基类构造函数来初始化继承的基类成员。
  • 派生类对象可以使用基类的公有方法。
  • 基类指针或引用可以指向或引用派生类对象,但只能通过它们调用基类中定义的方法(除非使用虚函数和动态联编)。

13.2 继承:is-a 关系

我们在上一节看到了如何从一个基类派生出新类。其中,公有继承 (public) 是最常用的一种继承方式,它建立了一种被称为 “is-a” (或 “is-a-kind-of”)的关系模型。

什么是 “is-a” 关系?

“is-a” 关系意味着派生类的对象本质上也是基类的一种。换句话说,派生类是基类的一个特殊化版本。

  • 在我们的例子中,RatedPlayer 一个 TableTennisPlayer。一个有积分的球员首先是一个乒乓球球员,只是他还额外具有积分属性。
  • 类似地,如果有一个 Employee 类和一个 Manager 类,并且 Manager 公有继承自 Employee,那么一个 Manager 一个 Employee。经理是员工的一种特殊类型,具有额外的职责或属性。
  • 一个 Circle 一个 Shape
  • 一个 Dog 一个 Animal

公有继承如何体现 “is-a”?

公有继承之所以能模拟 “is-a” 关系,关键在于它保证了派生类对象拥有基类的所有公有接口

  • RatedPlayer 公有继承 TableTennisPlayer 时,TableTennisPlayer 的所有 public 成员(如 Name(), HasTable(), ResetTable())在 RatedPlayer 中也保持 public
  • 这意味着任何可以对 TableTennisPlayer 对象执行的操作(通过其公有接口),同样也可以对 RatedPlayer 对象执行。一个 RatedPlayer 对象可以像 TableTennisPlayer 对象一样被使用,因为它具备了 TableTennisPlayer 的所有基本功能。

代码体现:

这种关系最直接的体现就是基类指针或引用可以指向或引用派生类对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
RatedPlayer rp("May", "Lee", true, 1500);
TableTennisPlayer ttp("John", "Doe", false);

// "is-a" 关系允许这种赋值/初始化
TableTennisPlayer & rt = rp; // 基类引用可以引用派生类对象
TableTennisPlayer * pt = &rp; // 基类指针可以指向派生类对象

// 可以通过基类引用/指针调用基类的方法
rt.Name(); // 输出 Lee, May
pt->Name(); // 输出 Lee, May

// 但不能直接通过基类引用/指针调用派生类特有的方法
// rt.Rating(); // 错误! TableTennisPlayer 没有 Rating() 方法
// pt->Rating(); // 错误!

// 也可以将派生类对象传递给需要基类对象的函数
void showPlayerName(const TableTennisPlayer & player) {
player.Name();
std::cout << std::endl;
}

showPlayerName(ttp); // 传递基类对象 (OK)
showPlayerName(rp); // 传递派生类对象 (OK, 因为 RatedPlayer is-a TableTennisPlayer)

与其他关系的对比:

继承(特别是公有继承)与其他类之间的关系不同:

  • “has-a” 关系 (包含/组合 - Composition): 一个类包含另一个类的对象作为其成员。例如,一个 Car 有一个 Engine。这通常通过将 Engine 对象作为 Car 类的数据成员来实现。Car 不是 EngineEngine 也不是 Car

    1
    2
    3
    4
    5
    6
    class Engine { /* ... */ };
    class Car {
    private:
    Engine engine_member; // Car has-a Engine
    // ...
    };
  • “uses-a” 关系 (使用 - Association/Dependency): 一个类在它的方法中使用了另一个类的对象(例如,作为参数、返回值或局部变量),但并不拥有它。例如,一个 Programmer 使用一个 Computer

    1
    2
    3
    4
    5
    6
    7
    class Computer { /* ... */ };
    class Programmer {
    public:
    void writeCode(Computer& pc) { // Programmer uses-a Computer
    // ... 使用 pc 对象 ...
    }
    };

为什么区分很重要?

正确地识别类之间的关系并选择合适的实现方式(公有继承、私有继承、保护继承、组合、关联)对于设计良好、可维护的面向对象系统至关重要。

  • 公有继承 (public) 应该只用于模拟真正的 “is-a” 关系。如果派生类不能完全替代基类(即不符合 Liskov 替换原则 - Liskov Substitution Principle),那么使用公有继承可能是不恰当的。
  • 如果关系是 “has-a”,应该使用组合(成员对象)。
  • 如果只是临时使用,则是 “uses-a” 关系。

不恰当的 “is-a” 示例:

假设你想创建一个 Square 类和一个 Rectangle 类。你可能会想让 Square 继承自 Rectangle,因为正方形“是”矩形。但这里存在问题:如果 RectanglesetWidth()setHeight() 方法,并且它们可以独立设置宽高,那么当你在一个被视为 RectangleSquare 对象上调用 setWidth() 时,为了保持正方形的性质,你可能需要同时修改高度,这违反了 Rectangle 的行为预期(宽度和高度可以独立设置)。在这种情况下,公有继承可能不是最佳选择,可能需要重新考虑设计或使用其他继承方式。

总结:

  • 公有继承 (public) 建立了一种 “is-a” 关系,表示派生类是基类的一种特殊类型。
  • “is-a” 关系的核心是派生类继承了基类的公有接口,因此可以像基类对象一样被使用。
  • 这体现在基类指针或引用可以指向或引用派生类对象。
  • 应将公有继承与 “has-a”(组合)和 “uses-a”(关联)关系区分开。
  • 只有在派生类确实符合基类的行为契约时,才应使用公有继承。

13.3 多态公有继承

我们已经知道,公有继承建立了 “is-a” 关系,允许我们使用基类指针或引用来指向或引用派生类对象。例如:

1
2
RatedPlayer rp("May", "Lee", true, 1500);
TableTennisPlayer * pt = &rp; // 基类指针指向派生类对象

然而,当我们通过基类指针或引用调用一个同时存在于基类和派生类中的方法时,默认情况下会发生什么呢?

1
pt->Name(); // 调用哪个 Name() 方法? TableTennisPlayer::Name() 还是 RatedPlayer::Name()?

在没有特殊处理的情况下,C++ 默认使用静态联编 (Static Binding)早绑定 (Early Binding)**。这意味着编译器在编译时根据指针或引用的静态类型**(声明的类型,这里是 TableTennisPlayer*)来决定调用哪个版本的方法。因此,即使 pt 实际指向一个 RatedPlayer 对象,pt->Name() 也会调用 TableTennisPlayer::Name()

这通常不是我们期望的行为,尤其是在处理不同类型的派生类对象时。我们希望程序能够在运行时根据指针或引用实际指向的对象类型来选择调用相应的方法。这种“多种形态”的行为就是**多态 (Polymorphism)**。

多态是面向对象编程的三大支柱之一(另外两个是封装和继承)。它允许我们以统一的方式(通过基类接口)处理不同类型的对象,而这些对象各自以自己的方式响应相同的消息(方法调用)。

如何实现多态?

C++ 通过使用虚函数 (Virtual Functions)动态联编 (Dynamic Binding)晚绑定 (Late Binding) 来实现多态。

虚函数 (Virtual Functions):

  • 要在派生类中重新定义(覆盖)基类的方法,并且希望通过基类指针/引用调用时能够执行派生类的版本,就必须在基类中将该方法声明为虚函数
  • 通过在基类方法声明前加上 virtual 关键字来实现。

修改 TableTennisPlayer (tabtenn1.h)

让我们创建一个新版本的头文件 tabtenn1.h,在其中将 Name() 方法声明为虚函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#ifndef TABTENN1_H_
#define TABTENN1_H_
#include <string>
#include <iostream> // 添加 iostream 以便在头文件中使用 cout (虽然通常不推荐)

// 基类 - 使用虚函数
class TableTennisPlayer {
private:
std::string firstname;
std::string lastname;
bool hasTable;
public:
TableTennisPlayer (const std::string & fn = "none",
const std::string & ln = "none",
bool ht = false);
// 将 Name() 声明为虚函数
virtual void Name() const;
bool HasTable() const { return hasTable; };
void ResetTable(bool v) { hasTable = v; };
// 添加一个虚析构函数通常是好习惯 (稍后解释)
virtual ~TableTennisPlayer() { }
};

// 派生类
class RatedPlayer : public TableTennisPlayer {
private:
unsigned int rating;
public:
RatedPlayer (unsigned int r = 0, const std::string & fn = "none",
const std::string & ln = "none", bool ht = false);
RatedPlayer(unsigned int r, const TableTennisPlayer & tp);
unsigned int Rating() const { return rating; }
void ResetRating (unsigned int r) { rating = r; }
// 覆盖基类的虚函数 Name()
// C++11 推荐使用 override 关键字明确表示覆盖
virtual void Name() const override; // 也可以只写 virtual void Name() const;
// 或 void Name() const; (如果基类是 virtual,派生类同名同参方法自动也是 virtual)
};

#endif // TABTENN1_H_

修改实现 (tabtenn1.cpp)

我们需要提供 RatedPlayer::Name() 的实现,并更新构造函数以匹配新的类名。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include "tabtenn1.h"
#include <iostream>

// TableTennisPlayer 方法
TableTennisPlayer::TableTennisPlayer (const std::string & fn,
const std::string & ln, bool ht)
: firstname(fn), lastname(ln), hasTable(ht) {}

void TableTennisPlayer::Name() const {
std::cout << lastname << ", " << firstname;
}

// RatedPlayer 方法
RatedPlayer::RatedPlayer(unsigned int r, const std::string & fn,
const std::string & ln, bool ht)
: TableTennisPlayer(fn, ln, ht), rating(r) {
}

RatedPlayer::RatedPlayer(unsigned int r, const TableTennisPlayer & tp)
: TableTennisPlayer(tp), rating(r) {
}

// RatedPlayer 覆盖的 Name() 方法实现
void RatedPlayer::Name() const {
// 可以调用基类的 Name() 方法
TableTennisPlayer::Name();
// 添加自己的输出
std::cout << ", Rating: " << rating;
}

动态联编 (Dynamic Binding):

当通过基类指针或引用调用一个虚函数时,程序会根据指针或引用实际指向的对象类型来决定调用哪个版本的方法。这个决定是在运行时做出的,因此称为动态联编或晚绑定。

使用示例 (usett1.cpp)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <iostream>
#include "tabtenn1.h" // 使用包含虚函数的新版本

int main ( void ) {
using std::cout;
using std::endl;
TableTennisPlayer player1("Tara", "Boomdea", false);
RatedPlayer rplayer1(1140, "Mallory", "Duck", true);

// 通过对象直接调用,总是调用对象所属类的方法 (静态联编)
cout << "Direct call:\n";
player1.Name(); cout << endl; // 调用 TableTennisPlayer::Name()
rplayer1.Name(); cout << endl; // 调用 RatedPlayer::Name()

// 通过指针调用虚函数 (动态联编)
cout << "\nUsing pointers to base type:\n";
TableTennisPlayer * pt1 = &player1;
TableTennisPlayer * pt2 = &rplayer1;

pt1->Name(); cout << endl; // pt1 指向 TableTennisPlayer 对象,调用 TableTennisPlayer::Name()
pt2->Name(); cout << endl; // *** pt2 指向 RatedPlayer 对象,调用 RatedPlayer::Name() ***

// 通过引用调用虚函数 (动态联编)
cout << "\nUsing references to base type:\n";
TableTennisPlayer & rt1 = player1;
TableTennisPlayer & rt2 = rplayer1;

rt1.Name(); cout << endl; // rt1 引用 TableTennisPlayer 对象,调用 TableTennisPlayer::Name()
rt2.Name(); cout << endl; // *** rt2 引用 RatedPlayer 对象,调用 RatedPlayer::Name() ***

return 0;
}

编译和运行:

1
2
g++ usett1.cpp tabtenn1.cpp -o usett1
./usett1

输出:

1
2
3
4
5
6
7
8
9
10
11
Direct call:
Boomdea, Tara
Duck, Mallory, Rating: 1140

Using pointers to base type:
Boomdea, Tara
Duck, Mallory, Rating: 1140

Using references to base type:
Boomdea, Tara
Duck, Mallory, Rating: 1140

可以看到,当通过基类指针 pt2 或基类引用 rt2 调用虚函数 Name() 时,程序在运行时检查到它们实际指向/引用的是 RatedPlayer 对象,因此调用了 RatedPlayer::Name() 版本,实现了多态行为。

为什么需要多态?

多态允许我们编写更通用、更灵活的代码。例如,我们可以创建一个函数,接受一个 TableTennisPlayer 的指针或引用数组,然后遍历这个数组,对每个元素调用 Name() 方法。即使数组中包含不同类型的球员(TableTennisPlayer, RatedPlayer 或其他派生类),只要 Name() 是虚函数,每个对象都会以自己正确的方式显示其名称和信息。

1
2
3
4
5
6
7
8
void ShowPlayerInfo(const TableTennisPlayer & player) {
player.Name(); // 会根据 player 实际类型调用正确的 Name() 版本
cout << endl;
}

// ... in main ...
ShowPlayerInfo(player1);
ShowPlayerInfo(rplayer1);

总结:

  • 多态允许以统一的方式处理不同类型的对象。
  • C++ 通过公有继承虚函数 (virtual)动态联编来实现多态。
  • 在基类中将希望在派生类中重新定义并希望通过基类指针/引用调用的方法声明为 virtual
  • 当通过基类指针或引用调用虚函数时,程序在运行时根据对象的实际类型确定要调用的方法版本(动态联编)。
  • 如果未使用 virtual,则根据指针/引用的声明类型编译时确定调用版本(静态联编)。
  • 多态是实现代码灵活性和可扩展性的关键 OOP 技术。

13.4 静态联编和动态联编

联编 (Binding) 指的是将源代码中的函数调用(或方法调用)与其在可执行代码中的具体实现(函数体)关联起来的过程。C++ 支持两种类型的联编:静态联编和动态联编。理解它们的区别对于掌握多态至关重要。

13.4.1 指针和引用类型的兼容性

我们已经知道,在公有继承下,基类指针或引用可以指向或引用派生类对象。这是实现多态的前提。

1
2
3
4
// 假设 RatedPlayer 公有继承自 TableTennisPlayer
RatedPlayer rp;
TableTennisPlayer * pt = &rp; // OK
TableTennisPlayer & rt = rp; // OK

这种向上转换(将派生类指针/引用转换为基类指针/引用)是自动且安全的,因为派生类对象保证包含了基类的所有成员和接口。

反过来,将基类指针或引用转换为派生类指针或引用(向下转换)通常是不安全的,需要显式类型转换(如 dynamic_cast,将在后面章节讨论),并且只有在指针/引用确实指向一个派生类对象时才有效。

13.4.2 虚成员函数和动态联编

现在考虑通过基类指针或引用调用成员函数:

1
2
pt->SomeMethod();
rt.SomeMethod();

编译器如何决定调用哪个 SomeMethod 的实现(基类的还是派生类的)?这取决于 SomeMethod 是否是虚函数以及联编方式。

静态联编 (Static Binding / Early Binding):

  • 何时发生: 当调用的函数不是虚函数时,或者当通过对象本身(而不是指针或引用)调用函数时(无论是虚函数还是非虚函数)。
  • 决策依据: 编译器在编译时根据指针或引用的声明类型(静态类型)来决定调用哪个函数版本。
  • 行为: 即使基类指针 pt 指向一个派生类对象 rp,如果 SomeMethod 不是虚函数,pt->SomeMethod() 仍然会调用基类SomeMethod 版本。
  • 效率: 静态联编效率较高,因为在编译时就已经确定了要调用的函数地址。

动态联编 (Dynamic Binding / Late Binding):

  • 何时发生: 当通过基类指针或引用调用一个虚函数 (virtual) 时。
  • 决策依据: 程序在运行时检查指针或引用实际指向的对象类型,并调用该对象所属类的相应虚函数版本。
  • 行为: 如果基类指针 pt 指向一个派生类对象 rp,并且 SomeMethod 是虚函数,pt->SomeMethod() 会调用派生类SomeMethod 版本。这就是多态的核心。
  • 实现机制 (概念上): 编译器通常为包含虚函数的类创建一个**虚函数表 (virtual function table, vtable)**。vtable 是一个存储虚函数地址的数组。每个包含虚函数的类的对象内部都有一个隐藏的指针(通常称为 vptr),指向其类的 vtable。当通过基类指针调用虚函数时,程序通过对象的 vptr 找到 vtable,然后在 vtable 中查找并调用正确的函数地址。这个查找过程发生在运行时。
  • 效率: 动态联编比静态联编有轻微的运行时开销(需要查找 vtable),但在现代处理器上这种开销通常很小,而它带来的灵活性是巨大的。

总结对比:

特性 静态联编 (Static Binding) 动态联编 (Dynamic Binding)
发生时间 编译时 运行时
触发条件 调用非虚函数,或通过对象调用任何函数 通过基类指针/引用调用虚函数
决策依据 指针/引用的声明类型 (静态类型) 指针/引用实际指向的对象类型 (动态类型)
行为 调用声明类型的函数版本 调用实际对象类型的函数版本 (多态)
机制 直接函数调用 通常通过虚函数表 (vtable) 实现
效率 较高 略低于静态联编,但通常可接受

13.4.3 有关虚函数注意事项

为了正确使用虚函数和动态联编,需要注意以下几点:

  1. 在基类中声明 virtual: 必须在基类中将希望表现出多态行为的函数声明为 virtual

  2. 派生类覆盖: 如果派生类提供了同名、同参数列表(包括 const 属性)的方法,它将自动覆盖(override)基类的虚函数。这个派生类方法也自动成为虚函数,无论是否显式使用了 virtual 关键字。

  3. override 关键字 (C++11): 强烈建议在派生类覆盖虚函数时使用 override 关键字。这会让编译器检查该方法是否确实覆盖了基类中的某个虚函数。如果签名不匹配(例如,参数类型不同或 const 属性不同),编译器会报错,帮助捕获潜在错误。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    class Base {
    public:
    virtual void func(int) const;
    };

    class Derived : public Base {
    public:
    // virtual void func(int) override; // 错误!const 属性不匹配
    virtual void func(int) const override; // 正确!明确覆盖
    };
  4. final 关键字 (C++11): 如果不希望某个虚函数在更深层次的派生类中被进一步覆盖,可以在其声明后加上 final。也可以将整个类声明为 final,阻止任何类从它派生。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    class Base {
    public:
    virtual void func() const;
    };
    class Derived : public Base {
    public:
    // 覆盖 func,并且不允许 Derived 的子类再覆盖它
    virtual void func() const override final;
    };
    class MoreDerived : public Derived {
    public:
    // virtual void func() const override; // 错误!因为 Derived::func 被标记为 final
    };

    class CannotInherit final : public Base { // 这个类不能被继承
    // ...
    };
    // class Problem : public CannotInherit {}; // 错误!
  5. 构造函数不能是虚函数: 对象的类型在构造完成之前是未确定的,因此构造函数无法实现动态联编。

  6. 析构函数应该是虚函数: 如果一个类打算作为基类(特别是如果可能通过基类指针 delete 派生类对象),那么它的析构函数应该声明为虚函数 (virtual ~Base();)。

    • 原因: 考虑 Base *p = new Derived; delete p;。如果 ~Base() 不是虚函数,delete p; 只会调用 ~Base(),派生类 Derived 的析构函数 ~Derived()不会被调用,可能导致派生类分配的资源(如动态内存)泄漏。如果 ~Base() 是虚函数,delete p; 会通过动态联编正确地调用 ~Derived(),然后再调用 ~Base(),确保所有资源都被释放。
    • 规则: 如果类中有任何虚函数,通常就应该提供一个虚析构函数。即使基类析构函数什么也不做,也应将其声明为 virtual
  7. 友元函数不能是虚函数: 友元函数不是类的成员函数,继承机制不适用于它们。

  8. 静态成员函数不能是虚函数: 静态成员函数与类本身关联,而不是与特定对象关联(没有 this 指针),因此动态联编对其没有意义。

理解静态联编和动态联编的区别,以及何时、如何使用虚函数,是掌握 C++ 多态特性和编写健壮、可扩展的面向对象代码的关键。

13.5 访问控制:protected

到目前为止,我们使用了 publicprivate 访问说明符来控制对类成员的访问。

  • public 成员可以被任何地方的代码访问。
  • private 成员只能被定义它们的类的成员函数(以及友元)访问。

在继承的背景下,private 成员有一个重要的限制:基类的 private 成员不能被派生类的成员函数直接访问。派生类需要通过基类的 publicprotected 接口(方法)来间接与基类的私有数据交互。

C++ 提供了第三个访问说明符:**protected**。

protected 成员的访问规则:

  • 对于类的外部(非成员函数,非派生类),protected 成员的行为与 private 成员相同,即不可访问。
  • 对于类的成员函数(以及友元),protected 成员的行为与 public 成员相同,即可直接访问。
  • 对于派生类的成员函数protected 成员的行为也与 public 成员相同,即派生类可以直接访问基类的 protected 成员。

总结访问权限:

访问来源 public 成员 protected 成员 private 成员
类内部 (成员函数) 可访问 可访问 可访问
派生类 (成员函数) 可访问 可访问 不可访问
类外部 (普通代码) 可访问 不可访问 不可访问

示例:修改 TableTennisPlayer 使用 protected

让我们修改 TableTennisPlayer 类,将 firstnamelastname 设为 protected,看看 RatedPlayer 如何访问它们。

tabtenn_prot.h (使用 protected 的版本)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#ifndef TABTENN_PROT_H_
#define TABTENN_PROT_H_
#include <string>
#include <iostream>

// 基类 - 使用 protected 成员
class TableTennisPlayerProt {
// protected 成员:
protected: // 改为 protected
std::string firstname;
std::string lastname;
private: // hasTable 仍然是 private
bool hasTable;
public:
TableTennisPlayerProt (const std::string & fn = "none",
const std::string & ln = "none",
bool ht = false);
// Name() 不再需要,因为派生类可以直接访问 protected 成员来构建自己的 Name()
// virtual void Name() const; // 可以移除或保留
bool HasTable() const { return hasTable; };
void ResetTable(bool v) { hasTable = v; };
virtual ~TableTennisPlayerProt() { }
};

// 派生类
class RatedPlayerProt : public TableTennisPlayerProt {
private:
unsigned int rating;
public:
RatedPlayerProt (unsigned int r = 0, const std::string & fn = "none",
const std::string & ln = "none", bool ht = false);
RatedPlayerProt(unsigned int r, const TableTennisPlayerProt & tp);
unsigned int Rating() const { return rating; }
void ResetRating (unsigned int r) { rating = r; }
// 派生类可以直接访问基类的 protected 成员
void ShowInfo() const; // 新增一个方法来演示访问
};

#endif // TABTENN_PROT_H_

tabtenn_prot.cpp (实现)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include "tabtenn_prot.h"
#include <iostream>

// TableTennisPlayerProt 方法
TableTennisPlayerProt::TableTennisPlayerProt (const std::string & fn,
const std::string & ln, bool ht)
: firstname(fn), lastname(ln), hasTable(ht) {}

// RatedPlayerProt 方法
RatedPlayerProt::RatedPlayerProt(unsigned int r, const std::string & fn,
const std::string & ln, bool ht)
: TableTennisPlayerProt(fn, ln, ht), rating(r) {
}

// 注意:这个构造函数仍然需要调用基类构造函数,
// 因为即使 firstname/lastname 是 protected,初始化也应由基类负责。
// 但如果基类没有提供合适的构造函数,派生类可以在其构造函数体中
// 直接给 protected 成员赋值 (虽然不推荐)。
RatedPlayerProt::RatedPlayerProt(unsigned int r, const TableTennisPlayerProt & tp)
: TableTennisPlayerProt(tp), rating(r) {
}

// 派生类方法可以直接访问基类的 protected 成员
void RatedPlayerProt::ShowInfo() const {
// 直接访问继承来的 protected 成员 firstname 和 lastname
std::cout << "Name: " << lastname << ", " << firstname;
std::cout << "; Rating: " << rating;
// 不能直接访问基类的 private 成员 hasTable
// std::cout << HasTable(); // 需要通过基类的 public 方法访问
if (HasTable())
std::cout << "; Has Table: Yes\n";
else
std::cout << "; Has Table: No\n";
}

使用示例 (usett_prot.cpp)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>
#include "tabtenn_prot.h"

int main ( void ) {
using std::cout;
using std::endl;

RatedPlayerProt rplayer1(1140, "Mallory", "Duck", true);
TableTennisPlayerProt player1("Tara", "Boomdea", false); // 基类对象

cout << "Rated Player Info:\n";
rplayer1.ShowInfo(); // 调用派生类方法,该方法访问了基类的 protected 成员

// 外部代码不能直接访问 protected 成员
// cout << rplayer1.firstname; // 错误!firstname 是 protected
// cout << player1.lastname; // 错误!lastname 是 protected

// 外部代码可以访问 public 成员
cout << "\nPlayer 1 Has Table? " << player1.HasTable() << endl; // OK

return 0;
}

编译和运行:

1
2
g++ usett_prot.cpp tabtenn_prot.cpp -o usett_prot
./usett_prot

输出:

1
2
3
4
Rated Player Info:
Name: Duck, Mallory; Rating: 1140; Has Table: Yes

Player 1 Has Table? 0

使用 protected 的利弊:

  • 优点:
    • 为派生类提供比 private 更大的访问权限,允许派生类更直接地与基类实现交互。
    • 相比 public,仍然对外部世界隐藏了实现细节。
  • 缺点:
    • 破坏封装: 将成员设为 protected 而不是 private,意味着基类的实现细节暴露给了所有派生类。如果将来修改基类的 protected 成员,可能会影响所有派生类,增加了类之间的耦合度。
    • 维护困难: 随着继承层次的加深,追踪哪些类依赖于特定的 protected 成员会变得困难。

设计建议:

  • 优先使用 private 成员,并通过 publicprotected 成员函数提供对数据的访问(如果需要)。这提供了更好的封装和灵活性。
  • 只有当你确定派生类确实需要直接访问基类的某个实现细节,并且提供 public/protected 接口不方便或效率低下时,才考虑使用 protected 数据成员。
  • protected 成员函数通常比 protected 数据成员更受欢迎,因为它们提供了受控的访问接口,而不是直接暴露数据。

总之,protected 提供了一种介于 privatepublic 之间的访问控制级别,主要用于管理基类和派生类之间的访问权限。

13.6 抽象基类

在设计类层次结构时,我们有时会遇到这样一种情况:基类本身代表的是一个非常抽象的概念,以至于创建该基类的对象本身没有意义。它存在的目的主要是为了定义一个共同的接口,供所有具体的派生类来实现。这种只作为接口规范、不能被实例化的基类称为**抽象基类 (Abstract Base Class, ABC)**。

例如,考虑一个图形库,可能有一个 Shape 基类。但什么是“形状”本身?它太抽象了。我们实际操作的是具体的形状,如 Circle(圆)、Rectangle(矩形)、Triangle(三角形)等。创建一个通用的 Shape 对象可能没有意义,但我们希望所有具体的形状类都提供某些共同的操作,比如计算面积 (Area()) 或绘制 (Draw())。

C++ 通过纯虚函数 (Pure Virtual Function) 来实现抽象基类。

纯虚函数 (Pure Virtual Function):

  • 纯虚函数是一种特殊的虚函数,它在基类中没有提供实现(或者说,实现被延迟到派生类)。

  • 它用于声明一个接口,强制所有非抽象的派生类必须提供自己的实现。

  • 语法: 在虚函数声明的末尾加上 = 0

    1
    virtual ReturnType FunctionName(parameters) const = 0; // 纯虚函数声明

抽象基类 (ABC) 的定义:

  • 包含至少一个纯虚函数的类就是抽象基类 (ABC)。

ABC 的特性:

  1. 不能实例化:不能创建抽象基类的对象。

    1
    2
    // 假设 Shape 是一个 ABC
    // Shape myShape; // 错误!不能创建 ABC 的对象

    编译器会阻止你这样做,因为 ABC 中的纯虚函数没有实现,对象是不完整的。

  2. 可以作为接口: ABC 主要用作定义接口。它规定了所有派生类必须实现哪些方法。

  3. 可以有指针和引用: 虽然不能创建 ABC 的对象,但你可以声明指向 ABC 的指针引用。这些指针或引用可以指向或引用其具体的派生类对象。这对于实现多态至关重要。

    1
    2
    3
    // 假设 Circle 是从 Shape 派生的具体类
    Shape * shapePtr = new Circle(5.0); // OK: 基类指针指向派生类对象
    Shape & shapeRef = *shapePtr; // OK: 基类引用引用派生类对象
  4. 派生类必须实现纯虚函数: 任何从 ABC 派生的类,如果没有为继承来的所有纯虚函数提供实现,那么这个派生类将成为一个抽象基类,同样不能被实例化。只有当派生类实现了所有继承的纯虚函数时,它才成为**具体类 (Concrete Class)**,可以被实例化。

13.6.1 应用 ABC 概念

让我们定义一个简单的银行账户 ABC AcctABC。一个通用的“账户”可能无法直接操作,但所有具体的账户类型(如支票账户、储蓄账户)都应该有查询余额、存款、取款等操作。

acctabc.h (ABC 定义)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
#ifndef ACCTABC_H_
#define ACCTABC_H_
#include <iostream>
#include <string>

// 抽象基类 AcctABC
class AcctABC {
private:
std::string fullName;
long acctNum;
double balance;
protected: // 改为 protected 以便派生类访问
// 辅助格式化函数 (protected,派生类可用)
struct Formatting {
std::ios_base::fmtflags flag;
std::streamsize pr;
};
const std::string & FullName() const { return fullName; }
long AcctNum() const { return acctNum; }
Formatting SetFormat() const;
void Restore(Formatting & f) const;
public:
// 构造函数 (ABC 也可以有构造函数)
AcctABC(const std::string & s = "Nullbody", long an = -1,
double bal = 0.0);
// 存款
void Deposit(double amt);
// 取款 - 设为纯虚函数,具体实现在派生类
virtual void Withdraw(double amt) = 0;
// 查询余额
double Balance() const { return balance; }
// 查看账户信息 - 设为纯虚函数
virtual void ViewAcct() const = 0;
// 虚析构函数 (基类有虚函数,析构函数也应是虚的)
virtual ~AcctABC() {}
};

// 具体的派生类: Brass Account (支票账户)
class Brass : public AcctABC {
public:
Brass(const std::string & s = "Nullbody", long an = -1,
double bal = 0.0) : AcctABC(s, an, bal) {}
// 实现基类的纯虚函数 Withdraw
virtual void Withdraw(double amt) override;
// 实现基类的纯虚函数 ViewAcct
virtual void ViewAcct() const override;
// Brass 类没有新的纯虚函数,所以是具体类
virtual ~Brass() {}
};

// 具体的派生类: BrassPlus Account (带透支保护的支票账户)
class BrassPlus : public AcctABC {
private:
double maxLoan; // 最大透支额
double rate; // 透支利率
double owesBank; // 当前欠款
public:
BrassPlus(const std::string & s = "Nullbody", long an = -1,
double bal = 0.0, double ml = 500,
double r = 0.11125);
BrassPlus(const Brass & ba, double ml = 500, double r = 0.11125);
// 实现基类的纯虚函数 Withdraw
virtual void Withdraw(double amt) override;
// 实现基类的纯虚函数 ViewAcct
virtual void ViewAcct() const override;
// 派生类自己的方法
void ResetMax(double m) { maxLoan = m; }
void ResetRate(double r) { rate = r; };
void ResetOwes() { owesBank = 0; }
virtual ~BrassPlus() {}
};

#endif // ACCTABC_H_

acctabc.cpp (实现)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
#include <iostream>
#include "acctabc.h"
using std::cout;
using std::ios_base;
using std::endl;
using std::string;

// --- AcctABC 方法实现 ---
// 构造函数
AcctABC::AcctABC(const string & s, long an, double bal) {
fullName = s;
acctNum = an;
balance = bal;
}

// 存款 (非虚函数,所有账户类型通用)
void AcctABC::Deposit(double amt) {
if (amt < 0)
cout << "Negative deposit not allowed; "
<< "deposit is cancelled.\n";
else
balance += amt;
}

// 注意:纯虚函数 Withdraw() 和 ViewAcct() 在基类中没有实现体

// 辅助格式化函数
AcctABC::Formatting AcctABC::SetFormat() const {
Formatting f;
f.flag = cout.setf(ios_base::fixed, ios_base::floatfield); // use fixed-point
f.pr = cout.precision(2); // for $$$.cc format
return f;
}

void AcctABC::Restore(Formatting & f) const {
cout.setf(f.flag, ios_base::floatfield);
cout.precision(f.pr);
}

// --- Brass 方法实现 ---
// 实现纯虚函数 Withdraw
void Brass::Withdraw(double amt) {
if (amt < 0) {
cout << "Withdrawal amount must be positive; "
<< "withdrawal canceled.\n";
} else if (amt <= Balance()) { // 使用基类的 Balance() 方法
AcctABC::Withdraw(amt); // 调用基类版本来更新余额 (如果基类有实现的话,这里假设基类没实现,直接修改)
balance -= amt;

} else {
cout << "Withdrawal amount of $" << amt
<< " exceeds your balance.\n"
<< "Withdrawal canceled.\n";
}
}

// 实现纯虚函数 ViewAcct
void Brass::ViewAcct() const {
Formatting f = SetFormat(); // 使用基类的格式化辅助函数
cout << "Brass Client: " << FullName() << endl; // 使用基类的 protected 方法
cout << "Account Number: " << AcctNum() << endl; // 使用基类的 protected 方法
cout << "Balance: $" << Balance() << endl; // 使用基类的 public 方法
Restore(f); // 恢复格式
}

// --- BrassPlus 方法实现 ---
BrassPlus::BrassPlus(const string & s, long an, double bal,
double ml, double r) : AcctABC(s, an, bal) {
maxLoan = ml;
owesBank = 0.0;
rate = r;
}

BrassPlus::BrassPlus(const Brass & ba, double ml, double r)
: AcctABC(ba) { // 使用 AcctABC 的隐式复制构造函数 (如果存在且可用)
// 或者显式调用 AcctABC(ba.FullName(), ba.AcctNum(), ba.Balance())
// (需要 ba 提供访问器或 AcctABC 成员是 protected)
maxLoan = ml;
owesBank = 0.0;
rate = r;
}

// 实现纯虚函数 ViewAcct
void BrassPlus::ViewAcct() const {
Formatting f = SetFormat();
cout << "BrassPlus Client: " << FullName() << endl;
cout << "Account Number: " << AcctNum() << endl;
cout << "Balance: $" << Balance() << endl;
cout << "Maximum loan: $" << maxLoan << endl;
cout << "Owed to bank: $" << owesBank << endl;
cout.precision(3);
cout << "Loan Rate: " << 100 * rate << "%\n";
Restore(f);
}

// 实现纯虚函数 Withdraw
void BrassPlus::Withdraw(double amt) {
if (amt < 0) {
cout << "Withdrawal amount must be positive; "
<< "withdrawal canceled.\n";
return;
}

Formatting f = SetFormat();
double bal = Balance(); // 获取当前余额

if (amt <= bal) { // 如果余额足够
// 调用基类的 Withdraw (如果它修改余额) 或直接修改
// 假设 balance 是 protected:
balance -= amt;
} else if (amt <= bal + maxLoan - owesBank) { // 如果余额+剩余可透支额度足够
double advance = amt - bal; // 需要透支的金额
owesBank += advance * (1.0 + rate); // 计算欠款 (加上利息)
cout << "Bank advance: $" << advance << endl;
cout << "Finance charge: $" << advance * rate << endl;
Deposit(advance); // 先存入透支额度 (增加余额)
// 再取款 (修改余额)
// 假设 balance 是 protected:
balance -= amt;
} else {
cout << "Credit limit exceeded. Transaction cancelled.\n";
}
Restore(f);
}

使用示例 (usebrass1.cpp)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
#include <iostream>
#include "acctabc.h" // 包含 ABC 和派生类

const int CLIENTS = 4;

int main() {
using std::cin;
using std::cout;
using std::endl;

// AcctABC anAccount; // 错误!不能创建抽象基类对象

AcctABC * p_clients[CLIENTS]; // 可以创建指向 ABC 的指针数组
std::string temp;
long tempnum;
double tempbal;
char kind;

for (int i = 0; i < CLIENTS; i++) {
cout << "Enter client's name: ";
getline(cin, temp);
cout << "Enter client's account number: ";
cin >> tempnum;
cout << "Enter opening balance: $";
cin >> tempbal;
cout << "Enter 1 for Brass Account or "
<< "2 for BrassPlus Account: ";
while (cin >> kind && (kind != '1' && kind != '2'))
cout << "Enter either 1 or 2: ";

if (kind == '1')
p_clients[i] = new Brass(temp, tempnum, tempbal);
else {
double tmax, trate;
cout << "Enter the overdraft limit: $";
cin >> tmax;
cout << "Enter the interest rate "
<< "as a decimal fraction: ";
cin >> trate;
p_clients[i] = new BrassPlus(temp, tempnum, tempbal, tmax, trate);
}
while (cin.get() != '\n') // 清除行尾换行符
continue;
}
cout << endl;

// 使用多态性处理不同类型的账户
for (int i = 0; i < CLIENTS; i++) {
p_clients[i]->ViewAcct(); // 调用虚函数 ViewAcct()
cout << endl;
}

// 释放内存
for (int i = 0; i < CLIENTS; i++) {
delete p_clients[i]; // 调用虚析构函数,确保正确的析构函数被调用
}
cout << "Done.\n";

return 0;
}

编译和运行:

1
2
3
## 假设 acctabc.h 中 balance 已移至 protected
g++ usebrass1.cpp acctabc.cpp -o usebrass1
./usebrass1

程序会提示输入客户信息,创建不同类型的账户对象,然后通过基类指针数组 p_clients 多态地调用 ViewAcct() 方法,显示每个账户的信息。最后,通过 delete p_clients[i] 安全地销毁对象,因为 ~AcctABC() 是虚析构函数。

13.6.2 ABC 理念

抽象基类的核心理念是接口与实现分离

  • 接口: ABC 定义了一个通用的接口(通过纯虚函数和可能的非虚函数),规定了派生类应该具有哪些功能。
  • 实现: 具体的派生类负责提供这些功能的具体实现。

使用 ABC 的好处:

  1. 强制接口统一: 确保所有相关的派生类都遵循一个共同的接口规范。
  2. 实现多态: 允许通过基类指针或引用来统一处理不同派生类的对象,调用它们各自实现的虚函数版本。
  3. 代码可扩展性: 当需要添加新的账户类型(例如 SavingsAccount)时,只需从 AcctABC 派生并实现其纯虚函数即可,现有使用 AcctABC* 的代码(如 main 函数中的循环)通常无需修改就能处理新的账户类型。
  4. 清晰的设计: 更好地模拟现实世界中的抽象概念和层次关系。

ABC 是 C++ 中实现抽象和多态的关键工具,对于设计灵活、可维护的面向对象系统非常重要。

13.7 继承和动态内存分配

当继承与动态内存分配(使用 newdelete)结合时,我们需要特别注意析构函数、复制构造函数和赋值运算符的行为,以确保资源的正确管理。主要有两种情况:

13.7.1 第一种情况:派生类不使用 new

如果基类使用了动态内存分配(因此需要自定义析构函数、复制构造函数、赋值运算符),而派生类没有使用 new 来分配自己的动态内存,情况相对简单。

  • 析构函数:

    • 派生类不需要显式定义析构函数来释放内存(因为它没有分配)。
    • 当派生类对象被销毁时,会先执行派生类的析构函数(即使是编译器生成的默认版本),然后自动调用基类的析构函数。
    • 关键: 为了能通过基类指针 delete 派生类对象,基类的析构函数必须声明为 virtual。如果基类析构函数是虚函数,那么 delete basePtr; (其中 basePtr 指向派生类对象) 会先调用派生类的析构函数,再调用基类的析构函数,确保所有资源被正确释放。
  • 复制构造函数:

    • 派生类的默认复制构造函数会执行成员逐一复制。对于继承自基类的部分,它会自动调用基类的复制构造函数
    • 因此,如果基类的复制构造函数正确实现了深复制,那么派生类对象的基类部分也会被正确地深复制。派生类自身的成员(非动态分配)会被正常复制。
  • 赋值运算符:

    • 派生类的默认赋值运算符会执行成员逐一赋值。对于继承自基类的部分,它会自动调用基类的赋值运算符
    • 因此,如果基类的赋值运算符正确实现了深复制(包括处理自我赋值和释放旧内存),那么派生类对象的基类部分也会被正确地赋值。

结论: 如果基类正确地管理了它的动态内存(遵循三/五法则,特别是使用虚析构函数),并且派生类没有引入新的动态内存管理需求,那么派生类通常不需要显式定义这些特殊的成员函数。

示例 (baseDMA 和 lacksDMA):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
// dma.h -- 继承和动态内存分配
#ifndef DMA_H_
#define DMA_H_
#include <iostream>

// 使用动态内存分配的基类
class baseDMA {
private:
char * label;
int rating;
public:
baseDMA(const char * l = "null", int r = 0);
baseDMA(const baseDMA & rs); // 复制构造函数
virtual ~baseDMA(); // 虚析构函数
baseDMA & operator=(const baseDMA & rs); // 赋值运算符
friend std::ostream & operator<<(std::ostream & os, const baseDMA & rs);
};

// 派生类 - 不使用 new
class lacksDMA : public baseDMA {
private:
enum { COL_LEN = 40 };
char color[COL_LEN];
public:
lacksDMA(const char * c = "blank", const char * l = "null", int r = 0);
lacksDMA(const char * c, const baseDMA & rs);
// 不需要自定义析构函数、复制构造函数、赋值运算符
friend std::ostream & operator<<(std::ostream & os, const lacksDMA & ls);
};
#endif // DMA_H_

// dma.cpp -- 实现
#include "dma.h"
#include <cstring>

// --- baseDMA 实现 ---
baseDMA::baseDMA(const char * l, int r) {
label = new char[std::strlen(l) + 1];
std::strcpy(label, l);
rating = r;
}

baseDMA::baseDMA(const baseDMA & rs) { // 深复制
label = new char[std::strlen(rs.label) + 1];
std::strcpy(label, rs.label);
rating = rs.rating;
}

baseDMA::~baseDMA() {
delete [] label;
}

baseDMA & baseDMA::operator=(const baseDMA & rs) { // 深复制赋值
if (this == &rs)
return *this;
delete [] label; // 释放旧内存
label = new char[std::strlen(rs.label) + 1];
std::strcpy(label, rs.label);
rating = rs.rating;
return *this;
}

std::ostream & operator<<(std::ostream & os, const baseDMA & rs) {
os << "Label: " << rs.label << std::endl;
os << "Rating: " << rs.rating << std::endl;
return os;
}

// --- lacksDMA 实现 ---
lacksDMA::lacksDMA(const char * c, const char * l, int r)
: baseDMA(l, r) { // 调用基类构造函数
std::strncpy(color, c, COL_LEN - 1);
color[COL_LEN - 1] = '\0';
}

lacksDMA::lacksDMA(const char * c, const baseDMA & rs)
: baseDMA(rs) { // 调用基类复制构造函数
std::strncpy(color, c, COL_LEN - 1);
color[COL_LEN - 1] = '\0';
}

std::ostream & operator<<(std::ostream & os, const lacksDMA & ls) {
// 调用基类的 operator<< (需要友元或类型转换,或者直接访问)
// 这里假设通过类型转换或直接调用基类方法
os << (const baseDMA &) ls; // 将派生类引用转换为基类引用来调用基类 operator<<
os << "Color: " << ls.color << std::endl;
return os;
}

// usedma.cpp -- 使用示例
#include <iostream>
#include "dma.h"
int main() {
using std::cout;
using std::endl;
baseDMA shirt("Portabelly", 8);
lacksDMA balloon("red", "Blimpo", 4); // 派生类对象
lacksDMA balloon2(balloon); // 使用默认复制构造函数
cout << "Displaying baseDMA object:\n";
cout << shirt;
cout << "Displaying lacksDMA object:\n";
cout << balloon;
cout << "Result of lacksDMA copy:\n";
cout << balloon2;

baseDMA * p_dma = &balloon; // 基类指针指向派生类
cout << "Deleting derived object via base pointer:\n";
delete p_dma; // 正确调用 ~lacksDMA() (默认) 然后 ~baseDMA() (虚函数)

return 0;
}

13.7.2 第二种情况:派生类使用 new

如果派生类使用了 new 来分配自己的动态内存,那么派生类必须提供自己的析构函数、复制构造函数和赋值运算符。

  • 析构函数 (~Derived()):

    • 必须显式定义。
    • 负责 deletedelete[] 派生类自己分配的内存。
    • 不需要(也不能)显式调用基类的析构函数;基类析构函数会在派生类析构函数执行完毕后自动被调用。
    • 基类析构函数仍应是 virtual
  • 复制构造函数 (Derived(const Derived &)):

    • 必须显式定义。
    • 成员初始化列表中,必须显式调用基类的复制构造函数 Base(other) 来完成基类部分的深复制。
    • 然后,在构造函数体中,为派生类自己管理的动态内存执行深复制(分配新内存,复制数据)。
  • 赋值运算符 (Derived & operator=(const Derived &)):

    • 必须显式定义。
    • 检查自我赋值
    • 显式调用基类的赋值运算符 Base::operator=(other) 来完成基类部分的深复制赋值。
    • 释放派生类当前对象的旧动态内存。
    • 为派生类当前对象的动态成员分配新内存,并从源对象复制数据。
    • 返回 *this

示例 (添加 hasDMA 类):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
// dma.h (续)
// 派生类 - 使用 new
class hasDMA : public baseDMA {
private:
char * style; // 派生类管理的动态内存
public:
hasDMA(const char * s = "none", const char * l = "null", int r = 0);
hasDMA(const char * s, const baseDMA & rs);
hasDMA(const hasDMA & hs); // 复制构造函数
~hasDMA(); // 析构函数
hasDMA & operator=(const hasDMA & hs); // 赋值运算符
friend std::ostream & operator<<(std::ostream & os, const hasDMA & hs);
};

// dma.cpp (续)
// --- hasDMA 实现 ---
hasDMA::hasDMA(const char * s, const char * l, int r)
: baseDMA(l, r) { // 调用基类构造函数
style = new char[std::strlen(s) + 1];
std::strcpy(style, s);
}

hasDMA::hasDMA(const char * s, const baseDMA & rs)
: baseDMA(rs) { // 调用基类复制构造函数
style = new char[std::strlen(s) + 1];
std::strcpy(style, s);
}

hasDMA::hasDMA(const hasDMA & hs)
: baseDMA(hs) { // *** 调用基类复制构造函数 ***
style = new char[std::strlen(hs.style) + 1]; // 深复制派生类成员
std::strcpy(style, hs.style);
}

hasDMA::~hasDMA() {
delete [] style; // 释放派生类分配的内存 (基类析构函数会自动调用)
}

hasDMA & hasDMA::operator=(const hasDMA & hs) {
if (this == &hs)
return *this;
baseDMA::operator=(hs); // *** 调用基类赋值运算符 ***
delete [] style; // 释放派生类旧内存
style = new char[std::strlen(hs.style) + 1]; // 深复制派生类成员
std::strcpy(style, hs.style);
return *this;
}

std::ostream & operator<<(std::ostream & os, const hasDMA & hs) {
os << (const baseDMA &) hs; // 显示基类部分
os << "Style: " << hs.style << std::endl; // 显示派生类部分
return os;
}

// usedma.cpp (续)
int main() {
// ... (之前的代码) ...

cout << "\nTesting hasDMA:\n";
hasDMA map("Mercator", "Buffalo Keys", 5);
hasDMA map2 = map; // 调用 hasDMA 复制构造函数
hasDMA map3;
map3 = map; // 调用 hasDMA 赋值运算符

cout << "Displaying hasDMA object:\n";
cout << map;
cout << "Result of hasDMA copy:\n";
cout << map2;
cout << "Result of hasDMA assignment:\n";
cout << map3;

baseDMA * p_dma2 = &map; // 基类指针指向派生类
cout << "Deleting derived object via base pointer:\n";
delete p_dma2; // 正确调用 ~hasDMA() 然后 ~baseDMA()

return 0;
}

13.7.3 使用动态内存分配和友元的继承示例

在上面的 lacksDMAhasDMA 示例中,我们重载了 operator<< 作为友元函数。当在派生类的友元函数中需要显示基类信息时,有几种方法:

  1. 使用基类的公有/保护接口: 如果基类提供了访问所需信息的 publicprotected 方法,友元函数可以通过派生类对象调用这些方法。
  2. 使用类型转换: 将派生类对象的引用强制转换为基类对象的引用 (const baseDMA &) hs,然后调用基类版本的 operator<<。这要求基类 operator<< 也是友元或能够通过公有接口访问所需信息。
  3. 让派生类的友元也是基类的友元: 这种方式比较少见,且增加了耦合度。

在示例中,我们使用了类型转换 (const baseDMA &) hs 来调用基类的 operator<<,这是一种常见的做法。

总结:

  • 当派生类不使用 new 时,通常依赖基类正确实现的虚析构函数、复制构造函数和赋值运算符即可。
  • 当派生类也使用 new 时,派生类必须提供自己的析构函数、复制构造函数和赋值运算符。
  • 派生类的析构函数负责清理派生类资源,基类析构函数自动调用。
  • 派生类的复制构造函数必须在初始化列表中调用基类复制构造函数。
  • 派生类的赋值运算符必须显式调用基类赋值运算符。
  • 基类析构函数应始终为虚函数,以确保通过基类指针 delete 派生类对象时行为正确。

13.8 类设计回顾

经过前面几章的学习,我们已经接触了 C++ 类设计的许多方面,从基础的封装到复杂的继承和动态内存管理。本节将回顾一些关键的设计决策和最佳实践。

13.8.1 编译器生成的成员函数

C++ 编译器可以为我们自动生成一些特殊的成员函数,但这并不总是足够的,尤其是在处理资源(如动态内存)或设计继承层次结构时。

  1. 默认构造函数 (Default Constructor):

    • 生成时机: 如果你没有声明任何构造函数。
    • 行为: 对成员执行默认初始化(内置类型不初始化,类类型调用其默认构造函数)。
    • 注意: 如果你定义了任何构造函数,编译器就不会生成默认构造函数。如果此时你还需要一个无参构造函数,必须自己定义。
  2. 析构函数 (Destructor):

    • 生成时机: 如果你没有声明析构函数。
    • 行为: 对类类型的成员调用它们的析构函数。
    • 注意: 如果类管理需要显式释放的资源(如 new 分配的内存),必须提供自定义析构函数。如果类打算作为基类,析构函数应该virtual 的。
  3. 复制构造函数 (Copy Constructor):

    • 生成时机: 如果你没有声明复制构造函数(且没有声明移动操作)。
    • 行为: 执行**成员逐一复制 (浅复制)**。对于类类型成员,调用其复制构造函数。
    • 注意: 如果类管理动态内存或包含不能简单复制的资源(如文件句柄),必须提供自定义复制构造函数以实现深复制
  4. 复制赋值运算符 (Copy Assignment Operator):

    • 生成时机: 如果你没有声明复制赋值运算符(且没有声明移动操作)。
    • 行为: 执行**成员逐一赋值 (浅复制)**。对于类类型成员,调用其复制赋值运算符。
    • 注意: 如果类管理动态内存或需要特殊赋值逻辑,必须提供自定义复制赋值运算符,确保深复制、处理自我赋值并释放旧资源。
  5. 移动构造函数 (Move Constructor) (C++11):

    • 生成时机: 如果你没有声明任何复制操作(复制构造、复制赋值)、移动操作(移动构造、移动赋值)或析构函数。
    • 行为: 执行成员逐一移动。对于类类型成员,调用其移动构造函数。目的是高效地转移资源所有权,而不是复制。
    • 注意: 如果需要自定义资源转移逻辑,或者默认的成员移动不合适,可以自定义。如果定义了任何复制操作或析构函数,默认的移动构造函数通常不会生成,需要时需显式 = default 或自定义。
  6. 移动赋值运算符 (Move Assignment Operator) (C++11):

    • 生成时机: 与移动构造函数类似。
    • 行为: 执行成员逐一移动赋值
    • 注意: 与移动构造函数类似,如果需要自定义或默认版本未生成,需显式处理。

三/五/零法则 (Rule of Three/Five/Zero):

  • 三法则 (C++11 前): 如果你需要自定义析构函数、复制构造函数或复制赋值运算符中的任何一个,你几乎肯定需要全部三个
  • 五法则 (C++11): 如果你需要自定义上述三个或移动构造函数/移动赋值运算符中的任何一个,你可能需要考虑全部五个
  • 零法则 (推荐): 优先使用 RAII(资源获取即初始化)原则,利用标准库容器(std::string, std::vector)和智能指针(std::unique_ptr, std::shared_ptr)来管理资源。如果类只包含这些能自我管理的成员,通常不需要自定义任何特殊成员函数,编译器生成的默认版本就能很好地工作。

13.8.2 其他的类方法

除了特殊成员函数,类还包含其他用于实现其功能的方法:

  1. 构造函数 (Constructors): 除了默认构造函数,还可以定义多个构造函数来提供不同的对象初始化方式(例如,接受不同参数)。使用成员初始化列表来初始化成员变量。考虑使用 explicit 关键字阻止不期望的单参数隐式转换。
  2. 访问器 (Accessors): 通常是 public const 成员函数,用于获取对象的状态(私有数据成员的值),但不修改对象。例如 Balance() const
  3. 修改器 (Mutators) / 设置器 (Setters): 用于修改对象状态(私有数据成员的值)的 public 成员函数。例如 ResetTable(bool v)
  4. 功能函数 (Utility Functions): 实现类核心逻辑的其他成员函数。可以是 public, protected, 或 private
  5. const 成员函数: 在函数声明和定义后加 const,表示该函数不会修改调用它的对象的状态(数据成员)。const 对象只能调用 const 成员函数。
  6. 静态成员函数 (static): 与类本身关联,而不是特定对象。没有 this 指针,只能访问静态成员。通过类名调用 (ClassName::staticFunc())。
  7. 虚函数 (virtual): 用于在继承层次结构中实现多态。允许通过基类指针/引用调用派生类的特定实现。

13.8.3 公有继承的考虑因素

公有继承是实现 “is-a” 关系和多态的关键,但需要仔细考虑:

  1. “is-a” 关系: 确保派生类确实是基类的一种特殊类型,并且符合基类的行为契约(Liskov 替换原则)。
  2. 虚析构函数: 如果类可能被用作基类(特别是如果可能通过基类指针 delete 派生类对象),必须将析构函数声明为 virtual
  3. 继承接口 vs. 实现:
    • 纯虚函数: 只继承接口,强制派生类提供实现(用于抽象基类)。
    • 虚函数 (有实现): 继承接口和默认实现,允许派生类覆盖默认实现。
    • 非虚函数: 继承接口和强制实现,派生类不应重新定义(覆盖非虚函数通常是坏打算)。
  4. 访问控制:
    • public 成员构成类的公有接口。
    • private 成员是实现细节,对派生类隐藏。
    • protected 成员对派生类可见,但对外部隐藏。谨慎使用 protected 数据,它会增加基类和派生类之间的耦合。优先使用 protected 函数。
  5. 构造函数和初始化: 派生类构造函数必须调用基类构造函数(通常在成员初始化列表中)来初始化基类部分。
  6. 赋值运算符: 派生类的赋值运算符需要显式调用基类的赋值运算符来处理基类部分。
  7. 对象切片 (Slicing): 如果将派生类对象直接按值赋给基类对象(Base b = derived;),派生类特有的部分会被“切掉”,只保留基类部分。这是需要避免的,通常应使用指针或引用来处理多态对象。

13.8.4 类函数小结

函数类别 目的与说明 关键特性/关键字
构造函数 初始化新创建的对象 类名相同, 无返回类型, 可重载, explicit
析构函数 对象销毁前执行清理工作(释放资源) ~ClassName(), 无参数, 无返回类型, virtual
复制构造函数 用同类对象初始化新对象 ClassName(const ClassName &), 深复制
复制赋值运算符 将一个已存在的同类对象赋给另一个 operator=(const ClassName &), 深复制, 返回 *this
移动构造函数 用同类右值对象初始化新对象(转移资源) ClassName(ClassName &&), C++11, 移动语义
移动赋值运算符 将一个同类右值对象赋给另一个(转移资源) operator=(ClassName &&), C++11, 移动语义
普通成员函数 实现类的行为和功能 隐式 this 指针
const 成员函数 访问对象状态,但不修改对象 函数声明/定义后加 const
static 成员函数 与类本身关联,而非特定对象 static, 无 this 指针
virtual 函数 允许在派生类中覆盖,实现多态 virtual, 动态联编
纯虚函数 定义接口,强制派生类实现(用于 ABC) virtual ... = 0;
运算符重载函数 定义标准运算符用于类对象的行为 operator+, operator<<, etc.
转换函数 定义从类类型到其他类型的转换 operator typeName(), explicit
友元函数/类 允许非成员函数或类访问私有/保护成员 friend

设计良好的类需要仔细考虑这些不同类型的函数,确保封装性、资源管理的正确性、接口的清晰性以及在继承体系中的恰当行为。

13.9 总结

本章介绍了 C++ 的一个核心特性——继承,它允许我们基于现有类创建新类,实现代码重用和建立类之间的层次关系。

主要内容回顾:

  1. 基本继承:

    • 一个类(派生类)可以从另一个类(基类)继承成员(数据和方法)。
    • 公有继承 (public) 是最常用的方式,建立 “is-a” 关系,意味着派生类对象也是一个基类对象。基类的公有成员在派生类中仍然是公有,保护成员仍然是保护。
    • 派生类构造函数必须通过成员初始化列表调用基类构造函数来初始化继承的基类部分。
  2. 多态公有继承:

    • 多态允许我们通过基类接口(指针或引用)统一处理不同类型的派生类对象。
    • 通过在基类中将成员函数声明为虚函数 (virtual) 来启用多态行为。
    • 当通过基类指针或引用调用虚函数时,程序在运行时根据对象的实际类型选择调用哪个版本的方法(动态联编或晚绑定)。
    • 如果函数不是虚函数,或者通过对象直接调用,则在编译时根据指针/引用的声明类型或对象类型决定调用版本(静态联编或早绑定)。
  3. 虚函数注意事项:

    • override (C++11): 推荐在派生类覆盖虚函数时使用,以进行编译器检查。
    • final (C++11): 可用于阻止虚函数在更深层派生类中被覆盖,或阻止类被继承。
    • 构造函数不能是虚函数。
    • 虚析构函数: 如果类可能被用作基类(特别是涉及动态内存分配或可能通过基类指针删除派生类对象),其析构函数必须声明为 virtual,以确保正确的析构顺序和资源释放。
  4. 访问控制 (protected):

    • protected 成员对类内部和派生类成员函数可见,但对外部代码不可见。
    • 它提供了介于 privatepublic 之间的访问级别。
    • 虽然 protected 允许派生类直接访问基类实现细节,但可能破坏封装,应谨慎使用。优先使用 private 数据和 public/protected 接口函数。
  5. 抽象基类 (ABC):

    • 包含至少一个纯虚函数 (virtual ... = 0;) 的类是抽象基类。
    • ABC 不能被实例化(不能创建对象)。
    • 主要用于定义一个接口规范,强制派生类实现纯虚函数。
    • 可以声明指向 ABC 的指针或引用,用于实现多态。
    • 派生类只有实现了所有继承的纯虚函数后,才能成为具体类。
  6. 继承与动态内存分配:

    • 基类使用 new,派生类不用: 派生类通常不需要自定义特殊成员函数,但基类必须有虚析构函数。
    • 基类和派生类都使用 new: 派生类必须提供自己的析构函数、复制构造函数和赋值运算符。派生类的复制构造函数和赋值运算符必须显式调用基类的对应版本来处理基类部分。基类析构函数仍需是虚函数。
  7. 类设计回顾:

    • 理解编译器生成的特殊成员函数(构造、析构、复制、移动)及其局限性。
    • 遵循三/五/零法则来管理资源,优先使用 RAII(如智能指针、标准容器)。
    • 合理使用 const 成员函数。
    • 谨慎设计继承关系,确保符合 “is-a” 原则。
    • 正确使用虚函数和虚析构函数实现多态和安全的资源管理。

继承是 C++ 中实现代码重用、建立类型层次结构和实现多态的关键机制。理解其工作原理、不同类型的继承(本章主要关注公有继承)以及相关的设计原则对于编写强大的、可维护的面向对象程序至关重要。

评论

Powered By Valine
v1.4.14