13.1 一个简单的基类
面向对象编程 (OOP) 的一个核心优势是代码重用。除了通过包含对象成员(组合)之外,C++ 还提供了另一种强大的代码重用机制:**继承 (Inheritance)**。
继承允许我们基于一个已有的类(称为基类 (Base Class) 或父类 (Parent Class)**)来创建一个新的类(称为派生类 (Derived Class)** 或**子类 (Child Class)**)。派生类会自动获得基类的所有成员(数据和函数),并且可以添加自己的新成员或修改继承来的成员的行为。
这种机制非常适合用来表示现实世界中的“is-a”(是一种)关系。例如,一个“经理”是一个“员工”,一个“圆”是一个“形状”。
本节我们将从定义一个简单的基类开始,然后演示如何从中派生出新的类。
场景: 假设我们要为一个乒乓球俱乐部编写程序,需要表示球员信息。
基类 TableTennisPlayer
:
我们首先创建一个基础的 TableTennisPlayer
类,包含球员的姓名和是否有球桌。
tabtenn0.h (基类定义)
1 |
|
tabtenn0.cpp (基类实现)
1 |
|
这个基类很简单,包含了球员的基本信息和操作。
13.1.1 派生一个类
现在,假设我们想为一部分球员添加“积分”信息。我们可以从 TableTennisPlayer
派生出一个新的类 RatedPlayer
,它将继承 TableTennisPlayer
的所有成员,并添加自己的积分成员和相关方法。
继承语法:
1 | class DerivedClassName : accessSpecifier BaseClassName { |
-
DerivedClassName
: 派生类的名称。 -
:
: 表示继承关系。 -
accessSpecifier
: 访问说明符(通常是public
),指定继承类型。 -
BaseClassName
: 基类的名称。
RatedPlayer
类的定义 (添加到 tabtenn0.h 或新的头文件)
我们将 RatedPlayer
的定义也放在 tabtenn0.h
中(虽然分成不同文件更好)。
1 | // ... (TableTennisPlayer 定义之后) ... |
-
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 | // ... (TableTennisPlayer 实现之后) ... |
-
: TableTennisPlayer(fn, ln, ht)
: 这部分是成员初始化列表的关键。它显式地调用了基类TableTennisPlayer
的构造函数,并将必要的参数 (fn
,ln
,ht
) 传递给它。基类构造函数负责初始化firstname
,lastname
,hasTable
。 -
, rating(r)
: 初始化列表也用于初始化派生类自己的成员rating
。 - 规则: 在创建派生类对象时,程序会首先调用基类的构造函数来创建和初始化对象的基类部分,然后再执行派生类构造函数体(并初始化派生类成员)。如果你不在成员初始化列表中显式调用基类构造函数,编译器会尝试调用基类的默认构造函数。如果基类没有默认构造函数,或者你需要调用带参数的基类构造函数,就必须在初始化列表中显式调用。
13.1.3 使用派生类
派生类的对象拥有基类和派生类的所有公有成员。
usett0.cpp (使用示例)
1 | // filepath: d:\ProgramData\files_Cpp\250424\usett0.cpp |
编译和运行:
你需要将 tabtenn0.cpp
和 usett0.cpp
一起编译链接。
1 | g++ usett0.cpp tabtenn0.cpp -o usett0 |
输出:
1 | Duck, Mallory: has a table. |
13.1.4 派生类和基类之间的特殊关系
公有继承建立了一种非常重要的关系:
派生类对象也是一个基类对象: 一个
RatedPlayer
对象是一个TableTennisPlayer
对象。这意味着需要TableTennisPlayer
对象的地方,通常可以使用RatedPlayer
对象来替代。基类指针/引用可以指向/引用派生类对象:
1
2
3
4
5
6
7
8
9
10RatedPlayer 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 | RatedPlayer rp("May", "Lee", true, 1500); |
与其他关系的对比:
继承(特别是公有继承)与其他类之间的关系不同:
“has-a” 关系 (包含/组合 - Composition): 一个类包含另一个类的对象作为其成员。例如,一个
Car
有一个Engine
。这通常通过将Engine
对象作为Car
类的数据成员来实现。Car
不是Engine
,Engine
也不是Car
。1
2
3
4
5
6class Engine { /* ... */ };
class Car {
private:
Engine engine_member; // Car has-a Engine
// ...
};“uses-a” 关系 (使用 - Association/Dependency): 一个类在它的方法中使用了另一个类的对象(例如,作为参数、返回值或局部变量),但并不拥有它。例如,一个
Programmer
使用一个Computer
。1
2
3
4
5
6
7class 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
,因为正方形“是”矩形。但这里存在问题:如果 Rectangle
有 setWidth()
和 setHeight()
方法,并且它们可以独立设置宽高,那么当你在一个被视为 Rectangle
的 Square
对象上调用 setWidth()
时,为了保持正方形的性质,你可能需要同时修改高度,这违反了 Rectangle
的行为预期(宽度和高度可以独立设置)。在这种情况下,公有继承可能不是最佳选择,可能需要重新考虑设计或使用其他继承方式。
总结:
- 公有继承 (
public
) 建立了一种 “is-a” 关系,表示派生类是基类的一种特殊类型。 - “is-a” 关系的核心是派生类继承了基类的公有接口,因此可以像基类对象一样被使用。
- 这体现在基类指针或引用可以指向或引用派生类对象。
- 应将公有继承与 “has-a”(组合)和 “uses-a”(关联)关系区分开。
- 只有在派生类确实符合基类的行为契约时,才应使用公有继承。
13.3 多态公有继承
我们已经知道,公有继承建立了 “is-a” 关系,允许我们使用基类指针或引用来指向或引用派生类对象。例如:
1 | RatedPlayer rp("May", "Lee", true, 1500); |
然而,当我们通过基类指针或引用调用一个同时存在于基类和派生类中的方法时,默认情况下会发生什么呢?
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 |
|
修改实现 (tabtenn1.cpp)
我们需要提供 RatedPlayer::Name()
的实现,并更新构造函数以匹配新的类名。
1 |
|
动态联编 (Dynamic Binding):
当通过基类指针或引用调用一个虚函数时,程序会根据指针或引用实际指向的对象类型来决定调用哪个版本的方法。这个决定是在运行时做出的,因此称为动态联编或晚绑定。
使用示例 (usett1.cpp)
1 |
|
编译和运行:
1 | g++ usett1.cpp tabtenn1.cpp -o usett1 |
输出:
1 | Direct call: |
可以看到,当通过基类指针 pt2
或基类引用 rt2
调用虚函数 Name()
时,程序在运行时检查到它们实际指向/引用的是 RatedPlayer
对象,因此调用了 RatedPlayer::Name()
版本,实现了多态行为。
为什么需要多态?
多态允许我们编写更通用、更灵活的代码。例如,我们可以创建一个函数,接受一个 TableTennisPlayer
的指针或引用数组,然后遍历这个数组,对每个元素调用 Name()
方法。即使数组中包含不同类型的球员(TableTennisPlayer
, RatedPlayer
或其他派生类),只要 Name()
是虚函数,每个对象都会以自己正确的方式显示其名称和信息。
1 | void ShowPlayerInfo(const TableTennisPlayer & player) { |
总结:
- 多态允许以统一的方式处理不同类型的对象。
- C++ 通过公有继承、虚函数 (
virtual
) 和动态联编来实现多态。 - 在基类中将希望在派生类中重新定义并希望通过基类指针/引用调用的方法声明为
virtual
。 - 当通过基类指针或引用调用虚函数时,程序在运行时根据对象的实际类型确定要调用的方法版本(动态联编)。
- 如果未使用
virtual
,则根据指针/引用的声明类型在编译时确定调用版本(静态联编)。 - 多态是实现代码灵活性和可扩展性的关键 OOP 技术。
13.4 静态联编和动态联编
联编 (Binding) 指的是将源代码中的函数调用(或方法调用)与其在可执行代码中的具体实现(函数体)关联起来的过程。C++ 支持两种类型的联编:静态联编和动态联编。理解它们的区别对于掌握多态至关重要。
13.4.1 指针和引用类型的兼容性
我们已经知道,在公有继承下,基类指针或引用可以指向或引用派生类对象。这是实现多态的前提。
1 | // 假设 RatedPlayer 公有继承自 TableTennisPlayer |
这种向上转换(将派生类指针/引用转换为基类指针/引用)是自动且安全的,因为派生类对象保证包含了基类的所有成员和接口。
反过来,将基类指针或引用转换为派生类指针或引用(向下转换)通常是不安全的,需要显式类型转换(如 dynamic_cast
,将在后面章节讨论),并且只有在指针/引用确实指向一个派生类对象时才有效。
13.4.2 虚成员函数和动态联编
现在考虑通过基类指针或引用调用成员函数:
1 | pt->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 有关虚函数注意事项
为了正确使用虚函数和动态联编,需要注意以下几点:
在基类中声明
virtual
: 必须在基类中将希望表现出多态行为的函数声明为virtual
。派生类覆盖: 如果派生类提供了同名、同参数列表(包括
const
属性)的方法,它将自动覆盖(override)基类的虚函数。这个派生类方法也自动成为虚函数,无论是否显式使用了virtual
关键字。override
关键字 (C++11): 强烈建议在派生类覆盖虚函数时使用override
关键字。这会让编译器检查该方法是否确实覆盖了基类中的某个虚函数。如果签名不匹配(例如,参数类型不同或const
属性不同),编译器会报错,帮助捕获潜在错误。1
2
3
4
5
6
7
8
9
10class Base {
public:
virtual void func(int) const;
};
class Derived : public Base {
public:
// virtual void func(int) override; // 错误!const 属性不匹配
virtual void func(int) const override; // 正确!明确覆盖
};final
关键字 (C++11): 如果不希望某个虚函数在更深层次的派生类中被进一步覆盖,可以在其声明后加上final
。也可以将整个类声明为final
,阻止任何类从它派生。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18class 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 {}; // 错误!构造函数不能是虚函数: 对象的类型在构造完成之前是未确定的,因此构造函数无法实现动态联编。
析构函数应该是虚函数: 如果一个类打算作为基类(特别是如果可能通过基类指针
delete
派生类对象),那么它的析构函数应该声明为虚函数 (virtual ~Base();
)。- 原因: 考虑
Base *p = new Derived; delete p;
。如果~Base()
不是虚函数,delete p;
只会调用~Base()
,派生类Derived
的析构函数~Derived()
将不会被调用,可能导致派生类分配的资源(如动态内存)泄漏。如果~Base()
是虚函数,delete p;
会通过动态联编正确地调用~Derived()
,然后再调用~Base()
,确保所有资源都被释放。 - 规则: 如果类中有任何虚函数,通常就应该提供一个虚析构函数。即使基类析构函数什么也不做,也应将其声明为
virtual
。
- 原因: 考虑
友元函数不能是虚函数: 友元函数不是类的成员函数,继承机制不适用于它们。
静态成员函数不能是虚函数: 静态成员函数与类本身关联,而不是与特定对象关联(没有
this
指针),因此动态联编对其没有意义。
理解静态联编和动态联编的区别,以及何时、如何使用虚函数,是掌握 C++ 多态特性和编写健壮、可扩展的面向对象代码的关键。
13.5 访问控制:protected
到目前为止,我们使用了 public
和 private
访问说明符来控制对类成员的访问。
-
public
成员可以被任何地方的代码访问。 -
private
成员只能被定义它们的类的成员函数(以及友元)访问。
在继承的背景下,private
成员有一个重要的限制:基类的 private
成员不能被派生类的成员函数直接访问。派生类需要通过基类的 public
或 protected
接口(方法)来间接与基类的私有数据交互。
C++ 提供了第三个访问说明符:**protected
**。
protected
成员的访问规则:
- 对于类的外部(非成员函数,非派生类),
protected
成员的行为与private
成员相同,即不可访问。 - 对于类的成员函数(以及友元),
protected
成员的行为与public
成员相同,即可直接访问。 - 对于派生类的成员函数,
protected
成员的行为也与public
成员相同,即派生类可以直接访问基类的protected
成员。
总结访问权限:
访问来源 | public 成员 |
protected 成员 |
private 成员 |
---|---|---|---|
类内部 (成员函数) | 可访问 | 可访问 | 可访问 |
派生类 (成员函数) | 可访问 | 可访问 | 不可访问 |
类外部 (普通代码) | 可访问 | 不可访问 | 不可访问 |
示例:修改 TableTennisPlayer
使用 protected
让我们修改 TableTennisPlayer
类,将 firstname
和 lastname
设为 protected
,看看 RatedPlayer
如何访问它们。
tabtenn_prot.h (使用 protected 的版本)
1 |
|
tabtenn_prot.cpp (实现)
1 |
|
使用示例 (usett_prot.cpp)
1 |
|
编译和运行:
1 | g++ usett_prot.cpp tabtenn_prot.cpp -o usett_prot |
输出:
1 | Rated Player Info: |
使用 protected
的利弊:
- 优点:
- 为派生类提供比
private
更大的访问权限,允许派生类更直接地与基类实现交互。 - 相比
public
,仍然对外部世界隐藏了实现细节。
- 为派生类提供比
- 缺点:
- 破坏封装: 将成员设为
protected
而不是private
,意味着基类的实现细节暴露给了所有派生类。如果将来修改基类的protected
成员,可能会影响所有派生类,增加了类之间的耦合度。 - 维护困难: 随着继承层次的加深,追踪哪些类依赖于特定的
protected
成员会变得困难。
- 破坏封装: 将成员设为
设计建议:
- 优先使用
private
成员,并通过public
或protected
成员函数提供对数据的访问(如果需要)。这提供了更好的封装和灵活性。 - 只有当你确定派生类确实需要直接访问基类的某个实现细节,并且提供
public/protected
接口不方便或效率低下时,才考虑使用protected
数据成员。 -
protected
成员函数通常比protected
数据成员更受欢迎,因为它们提供了受控的访问接口,而不是直接暴露数据。
总之,protected
提供了一种介于 private
和 public
之间的访问控制级别,主要用于管理基类和派生类之间的访问权限。
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
2// 假设 Shape 是一个 ABC
// Shape myShape; // 错误!不能创建 ABC 的对象编译器会阻止你这样做,因为 ABC 中的纯虚函数没有实现,对象是不完整的。
可以作为接口: ABC 主要用作定义接口。它规定了所有派生类必须实现哪些方法。
可以有指针和引用: 虽然不能创建 ABC 的对象,但你可以声明指向 ABC 的指针或引用。这些指针或引用可以指向或引用其具体的派生类对象。这对于实现多态至关重要。
1
2
3// 假设 Circle 是从 Shape 派生的具体类
Shape * shapePtr = new Circle(5.0); // OK: 基类指针指向派生类对象
Shape & shapeRef = *shapePtr; // OK: 基类引用引用派生类对象派生类必须实现纯虚函数: 任何从 ABC 派生的类,如果没有为继承来的所有纯虚函数提供实现,那么这个派生类也将成为一个抽象基类,同样不能被实例化。只有当派生类实现了所有继承的纯虚函数时,它才成为**具体类 (Concrete Class)**,可以被实例化。
13.6.1 应用 ABC 概念
让我们定义一个简单的银行账户 ABC AcctABC
。一个通用的“账户”可能无法直接操作,但所有具体的账户类型(如支票账户、储蓄账户)都应该有查询余额、存款、取款等操作。
acctabc.h (ABC 定义)
1 |
|
acctabc.cpp (实现)
1 |
|
使用示例 (usebrass1.cpp)
1 |
|
编译和运行:
1 | ## 假设 acctabc.h 中 balance 已移至 protected |
程序会提示输入客户信息,创建不同类型的账户对象,然后通过基类指针数组 p_clients
多态地调用 ViewAcct()
方法,显示每个账户的信息。最后,通过 delete p_clients[i]
安全地销毁对象,因为 ~AcctABC()
是虚析构函数。
13.6.2 ABC 理念
抽象基类的核心理念是接口与实现分离。
- 接口: ABC 定义了一个通用的接口(通过纯虚函数和可能的非虚函数),规定了派生类应该具有哪些功能。
- 实现: 具体的派生类负责提供这些功能的具体实现。
使用 ABC 的好处:
- 强制接口统一: 确保所有相关的派生类都遵循一个共同的接口规范。
- 实现多态: 允许通过基类指针或引用来统一处理不同派生类的对象,调用它们各自实现的虚函数版本。
- 代码可扩展性: 当需要添加新的账户类型(例如
SavingsAccount
)时,只需从AcctABC
派生并实现其纯虚函数即可,现有使用AcctABC*
的代码(如main
函数中的循环)通常无需修改就能处理新的账户类型。 - 清晰的设计: 更好地模拟现实世界中的抽象概念和层次关系。
ABC 是 C++ 中实现抽象和多态的关键工具,对于设计灵活、可维护的面向对象系统非常重要。
13.7 继承和动态内存分配
当继承与动态内存分配(使用 new
和 delete
)结合时,我们需要特别注意析构函数、复制构造函数和赋值运算符的行为,以确保资源的正确管理。主要有两种情况:
13.7.1 第一种情况:派生类不使用 new
如果基类使用了动态内存分配(因此需要自定义析构函数、复制构造函数、赋值运算符),而派生类没有使用 new
来分配自己的动态内存,情况相对简单。
析构函数:
- 派生类不需要显式定义析构函数来释放内存(因为它没有分配)。
- 当派生类对象被销毁时,会先执行派生类的析构函数(即使是编译器生成的默认版本),然后自动调用基类的析构函数。
- 关键: 为了能通过基类指针
delete
派生类对象,基类的析构函数必须声明为virtual
。如果基类析构函数是虚函数,那么delete basePtr;
(其中basePtr
指向派生类对象) 会先调用派生类的析构函数,再调用基类的析构函数,确保所有资源被正确释放。
复制构造函数:
- 派生类的默认复制构造函数会执行成员逐一复制。对于继承自基类的部分,它会自动调用基类的复制构造函数。
- 因此,如果基类的复制构造函数正确实现了深复制,那么派生类对象的基类部分也会被正确地深复制。派生类自身的成员(非动态分配)会被正常复制。
赋值运算符:
- 派生类的默认赋值运算符会执行成员逐一赋值。对于继承自基类的部分,它会自动调用基类的赋值运算符。
- 因此,如果基类的赋值运算符正确实现了深复制(包括处理自我赋值和释放旧内存),那么派生类对象的基类部分也会被正确地赋值。
结论: 如果基类正确地管理了它的动态内存(遵循三/五法则,特别是使用虚析构函数),并且派生类没有引入新的动态内存管理需求,那么派生类通常不需要显式定义这些特殊的成员函数。
示例 (baseDMA 和 lacksDMA):
1 | // dma.h -- 继承和动态内存分配 |
13.7.2 第二种情况:派生类使用 new
如果派生类也使用了 new
来分配自己的动态内存,那么派生类必须提供自己的析构函数、复制构造函数和赋值运算符。
析构函数 (
~Derived()
):- 必须显式定义。
- 负责
delete
或delete[]
派生类自己分配的内存。 - 不需要(也不能)显式调用基类的析构函数;基类析构函数会在派生类析构函数执行完毕后自动被调用。
- 基类析构函数仍应是
virtual
。
复制构造函数 (
Derived(const Derived &)
):- 必须显式定义。
- 在成员初始化列表中,必须显式调用基类的复制构造函数
Base(other)
来完成基类部分的深复制。 - 然后,在构造函数体中,为派生类自己管理的动态内存执行深复制(分配新内存,复制数据)。
赋值运算符 (
Derived & operator=(const Derived &)
):- 必须显式定义。
- 检查自我赋值。
- 显式调用基类的赋值运算符
Base::operator=(other)
来完成基类部分的深复制赋值。 - 释放派生类当前对象的旧动态内存。
- 为派生类当前对象的动态成员分配新内存,并从源对象复制数据。
- 返回
*this
。
示例 (添加 hasDMA 类):
1 | // dma.h (续) |
13.7.3 使用动态内存分配和友元的继承示例
在上面的 lacksDMA
和 hasDMA
示例中,我们重载了 operator<<
作为友元函数。当在派生类的友元函数中需要显示基类信息时,有几种方法:
- 使用基类的公有/保护接口: 如果基类提供了访问所需信息的
public
或protected
方法,友元函数可以通过派生类对象调用这些方法。 - 使用类型转换: 将派生类对象的引用强制转换为基类对象的引用
(const baseDMA &) hs
,然后调用基类版本的operator<<
。这要求基类operator<<
也是友元或能够通过公有接口访问所需信息。 - 让派生类的友元也是基类的友元: 这种方式比较少见,且增加了耦合度。
在示例中,我们使用了类型转换 (const baseDMA &) hs
来调用基类的 operator<<
,这是一种常见的做法。
总结:
- 当派生类不使用
new
时,通常依赖基类正确实现的虚析构函数、复制构造函数和赋值运算符即可。 - 当派生类也使用
new
时,派生类必须提供自己的析构函数、复制构造函数和赋值运算符。 - 派生类的析构函数负责清理派生类资源,基类析构函数自动调用。
- 派生类的复制构造函数必须在初始化列表中调用基类复制构造函数。
- 派生类的赋值运算符必须显式调用基类赋值运算符。
- 基类析构函数应始终为虚函数,以确保通过基类指针
delete
派生类对象时行为正确。
13.8 类设计回顾
经过前面几章的学习,我们已经接触了 C++ 类设计的许多方面,从基础的封装到复杂的继承和动态内存管理。本节将回顾一些关键的设计决策和最佳实践。
13.8.1 编译器生成的成员函数
C++ 编译器可以为我们自动生成一些特殊的成员函数,但这并不总是足够的,尤其是在处理资源(如动态内存)或设计继承层次结构时。
默认构造函数 (Default Constructor):
- 生成时机: 如果你没有声明任何构造函数。
- 行为: 对成员执行默认初始化(内置类型不初始化,类类型调用其默认构造函数)。
- 注意: 如果你定义了任何构造函数,编译器就不会生成默认构造函数。如果此时你还需要一个无参构造函数,必须自己定义。
析构函数 (Destructor):
- 生成时机: 如果你没有声明析构函数。
- 行为: 对类类型的成员调用它们的析构函数。
- 注意: 如果类管理需要显式释放的资源(如
new
分配的内存),必须提供自定义析构函数。如果类打算作为基类,析构函数应该是virtual
的。
复制构造函数 (Copy Constructor):
- 生成时机: 如果你没有声明复制构造函数(且没有声明移动操作)。
- 行为: 执行**成员逐一复制 (浅复制)**。对于类类型成员,调用其复制构造函数。
- 注意: 如果类管理动态内存或包含不能简单复制的资源(如文件句柄),必须提供自定义复制构造函数以实现深复制。
复制赋值运算符 (Copy Assignment Operator):
- 生成时机: 如果你没有声明复制赋值运算符(且没有声明移动操作)。
- 行为: 执行**成员逐一赋值 (浅复制)**。对于类类型成员,调用其复制赋值运算符。
- 注意: 如果类管理动态内存或需要特殊赋值逻辑,必须提供自定义复制赋值运算符,确保深复制、处理自我赋值并释放旧资源。
移动构造函数 (Move Constructor) (C++11):
- 生成时机: 如果你没有声明任何复制操作(复制构造、复制赋值)、移动操作(移动构造、移动赋值)或析构函数。
- 行为: 执行成员逐一移动。对于类类型成员,调用其移动构造函数。目的是高效地转移资源所有权,而不是复制。
- 注意: 如果需要自定义资源转移逻辑,或者默认的成员移动不合适,可以自定义。如果定义了任何复制操作或析构函数,默认的移动构造函数通常不会生成,需要时需显式
= default
或自定义。
移动赋值运算符 (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 其他的类方法
除了特殊成员函数,类还包含其他用于实现其功能的方法:
- 构造函数 (Constructors): 除了默认构造函数,还可以定义多个构造函数来提供不同的对象初始化方式(例如,接受不同参数)。使用成员初始化列表来初始化成员变量。考虑使用
explicit
关键字阻止不期望的单参数隐式转换。 - 访问器 (Accessors): 通常是
public const
成员函数,用于获取对象的状态(私有数据成员的值),但不修改对象。例如Balance() const
。 - 修改器 (Mutators) / 设置器 (Setters): 用于修改对象状态(私有数据成员的值)的
public
成员函数。例如ResetTable(bool v)
。 - 功能函数 (Utility Functions): 实现类核心逻辑的其他成员函数。可以是
public
,protected
, 或private
。 -
const
成员函数: 在函数声明和定义后加const
,表示该函数不会修改调用它的对象的状态(数据成员)。const
对象只能调用const
成员函数。 - 静态成员函数 (
static
): 与类本身关联,而不是特定对象。没有this
指针,只能访问静态成员。通过类名调用 (ClassName::staticFunc()
)。 - 虚函数 (
virtual
): 用于在继承层次结构中实现多态。允许通过基类指针/引用调用派生类的特定实现。
13.8.3 公有继承的考虑因素
公有继承是实现 “is-a” 关系和多态的关键,但需要仔细考虑:
- “is-a” 关系: 确保派生类确实是基类的一种特殊类型,并且符合基类的行为契约(Liskov 替换原则)。
- 虚析构函数: 如果类可能被用作基类(特别是如果可能通过基类指针
delete
派生类对象),必须将析构函数声明为virtual
。 - 继承接口 vs. 实现:
- 纯虚函数: 只继承接口,强制派生类提供实现(用于抽象基类)。
- 虚函数 (有实现): 继承接口和默认实现,允许派生类覆盖默认实现。
- 非虚函数: 继承接口和强制实现,派生类不应重新定义(覆盖非虚函数通常是坏打算)。
- 访问控制:
-
public
成员构成类的公有接口。 -
private
成员是实现细节,对派生类隐藏。 -
protected
成员对派生类可见,但对外部隐藏。谨慎使用protected
数据,它会增加基类和派生类之间的耦合。优先使用protected
函数。
-
- 构造函数和初始化: 派生类构造函数必须调用基类构造函数(通常在成员初始化列表中)来初始化基类部分。
- 赋值运算符: 派生类的赋值运算符需要显式调用基类的赋值运算符来处理基类部分。
- 对象切片 (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++ 的一个核心特性——继承,它允许我们基于现有类创建新类,实现代码重用和建立类之间的层次关系。
主要内容回顾:
基本继承:
- 一个类(派生类)可以从另一个类(基类)继承成员(数据和方法)。
- 公有继承 (
public
) 是最常用的方式,建立 “is-a” 关系,意味着派生类对象也是一个基类对象。基类的公有成员在派生类中仍然是公有,保护成员仍然是保护。 - 派生类构造函数必须通过成员初始化列表调用基类构造函数来初始化继承的基类部分。
多态公有继承:
- 多态允许我们通过基类接口(指针或引用)统一处理不同类型的派生类对象。
- 通过在基类中将成员函数声明为虚函数 (
virtual
) 来启用多态行为。 - 当通过基类指针或引用调用虚函数时,程序在运行时根据对象的实际类型选择调用哪个版本的方法(动态联编或晚绑定)。
- 如果函数不是虚函数,或者通过对象直接调用,则在编译时根据指针/引用的声明类型或对象类型决定调用版本(静态联编或早绑定)。
虚函数注意事项:
-
override
(C++11): 推荐在派生类覆盖虚函数时使用,以进行编译器检查。 -
final
(C++11): 可用于阻止虚函数在更深层派生类中被覆盖,或阻止类被继承。 - 构造函数不能是虚函数。
- 虚析构函数: 如果类可能被用作基类(特别是涉及动态内存分配或可能通过基类指针删除派生类对象),其析构函数必须声明为
virtual
,以确保正确的析构顺序和资源释放。
-
访问控制 (
protected
):-
protected
成员对类内部和派生类成员函数可见,但对外部代码不可见。 - 它提供了介于
private
和public
之间的访问级别。 - 虽然
protected
允许派生类直接访问基类实现细节,但可能破坏封装,应谨慎使用。优先使用private
数据和public/protected
接口函数。
-
抽象基类 (ABC):
- 包含至少一个纯虚函数 (
virtual ... = 0;
) 的类是抽象基类。 - ABC 不能被实例化(不能创建对象)。
- 主要用于定义一个接口规范,强制派生类实现纯虚函数。
- 可以声明指向 ABC 的指针或引用,用于实现多态。
- 派生类只有实现了所有继承的纯虚函数后,才能成为具体类。
- 包含至少一个纯虚函数 (
继承与动态内存分配:
- 基类使用
new
,派生类不用: 派生类通常不需要自定义特殊成员函数,但基类必须有虚析构函数。 - 基类和派生类都使用
new
: 派生类必须提供自己的析构函数、复制构造函数和赋值运算符。派生类的复制构造函数和赋值运算符必须显式调用基类的对应版本来处理基类部分。基类析构函数仍需是虚函数。
- 基类使用
类设计回顾:
- 理解编译器生成的特殊成员函数(构造、析构、复制、移动)及其局限性。
- 遵循三/五/零法则来管理资源,优先使用 RAII(如智能指针、标准容器)。
- 合理使用
const
成员函数。 - 谨慎设计继承关系,确保符合 “is-a” 原则。
- 正确使用虚函数和虚析构函数实现多态和安全的资源管理。
继承是 C++ 中实现代码重用、建立类型层次结构和实现多态的关键机制。理解其工作原理、不同类型的继承(本章主要关注公有继承)以及相关的设计原则对于编写强大的、可维护的面向对象程序至关重要。