解密-C---面向对象编程-全-

解密 C++ 面向对象编程(全)

原文:zh.annas-archive.org/md5/f192f6cf32aca5d2d8a0f553db0f3c99

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

公司选择使用 C++是因为其速度;然而,面向对象(OO)的软件设计导致代码更健壮,更容易修改和维护。因此,了解如何将 C++用作面向对象(OOP)语言是至关重要的。在 C++中编程并不能保证面向对象(OOP)编程(OOP)——您必须理解面向对象(OO)概念以及它们如何映射到 C++语言特性和编程技术。此外,程序员还希望掌握超出面向对象(OOP)的额外技能,以使代码更通用和健壮,并采用在流行设计模式中可以找到的经过良好测试的创造性解决方案。对于程序员来说,理解可以使 C++成为更安全语言使用的语言特性和约定也是至关重要的。

一个学会如何按照安全编程约定使用 C++作为面向对象(OOP)语言的程序员将成为宝贵的 C++开发者——一个代码易于维护、修改且易于他人理解的开发者。

这本书对必要的面向对象(OO)概念进行了逐步解释,并配以代码中的实际示例,通常还配有图表,以便您真正理解事物是如何以及为什么工作的。书中还提供了自我评估问题,以测试您的技能。

本书首先提供了技能的必要构建块(可能不是面向对象的),这些构建块为构建面向对象(OOP)技能提供了基本的基础。接下来,将描述面向对象(OO)概念,并将其与语言特性以及编码技术相结合,以便您了解如何成功地将 C++用作面向对象(OOP)语言。此外,还增加了更多高级技能,以丰富程序员的技能库,包括友元函数/类、运算符重载、模板(构建更通用的代码)、异常处理(构建健壮的代码)、标准模板库(STL)基础,以及设计模式和惯用法。本书通过重新审视书中呈现的编程结构,并配以导致 C++更安全编程的约定,来结束全书。最终目标是使您能够生成健壮、易于维护且易于他人理解的代码。

在本书结束时,您将理解必要的和高级的面向对象(OO)概念,以及如何在 C++中实现这些概念。您将拥有一个多功能的 C++编程技能工具包。您还将了解如何使代码更安全、更健壮和易于维护,以及如何将经过良好测试的设计模式作为您编程技能的一部分。

这本书适合谁?

想要利用 C++进行面向对象编程的程序员会发现这本书对于理解如何在 C++中通过语言特性和精炼的编程技术实现面向对象设计至关重要,同时创建健壮且易于维护的代码。这本书假设读者有先前的编程经验;然而,如果你有有限的或没有先前的 C++经验,早期章节将帮助你学习必要的 C++技能,为许多面向对象部分、高级特性、设计模式和约定打下基础,以促进 C++中的安全编程。

本书涵盖的内容

第一章, 理解基本的 C++假设,对书中假定知识的语言基本特性进行了简要回顾。现有程序员可以快速掌握本第一章中回顾的语言基础。

第二章, 添加语言需求,回顾了关键的 C++构建块的非面向对象特性:const限定符、函数原型(默认值)和函数重载。

第三章, 间接寻址 – 指针,回顾了 C++中的指针,包括内存分配/释放、指针使用/解引用、在函数参数中的使用、空指针,并介绍了智能指针的概念。

第四章, 间接寻址 – 引用,介绍了引用作为指针的替代方案,包括初始化、函数参数/返回值和const限定符。

第五章, 详细探索类,首先通过探索面向对象和封装、信息隐藏的概念来介绍面向对象编程,然后详细介绍了类特性:成员函数、this指针、访问标签和区域、构造函数、析构函数以及数据成员和成员函数的限定符(conststaticinline)。

第六章, 使用单一继承实现层次结构,详细介绍了使用单一继承进行泛化和特殊化。本章涵盖了继承成员、使用基类构造函数、继承访问区域、构造/析构顺序、final类,以及公共与私有和受保护的基类,以及这些如何改变继承的含义。

第七章, 通过多态利用动态绑定,描述了面向对象的多态概念,然后区分了操作和方法,详细介绍了虚拟函数和方法的运行时绑定到操作(包括 v-table 的工作原理),并区分了virtualoverridefinal的使用。

第八章, 掌握抽象类,解释了面向对象(OO)中的抽象类概念,以及使用纯虚函数实现抽象类的方法,还介绍了接口的 OO 概念及其实现方式,以及在一个公有继承层次结构中的向上和向下转换。

第九章, 探索多重继承,详细说明了如何使用多重继承以及它在 OO 设计中的争议。本章涵盖了虚基类、菱形层次结构,以及通过考察判别器的 OO 概念来确定考虑替代设计的情况。

第十章, 实现关联、聚合和组合,描述了面向对象的关联、聚合和组合概念,以及如何使用指针、指针集合、包含和有时引用来实现每个概念。

第十一章, 处理异常,通过考虑许多异常场景来解释如何trythrowcatch异常。本章展示了如何扩展异常处理层次结构。

第十二章, 朋友和运算符重载,解释了正确使用朋友函数和类的方法,并检查了运算符重载(可能使用朋友)以允许运算符以与标准类型相同的方式与用户定义的类型一起工作。

第十三章, 使用模板,详细说明了模板函数和类,以泛化某些类型的代码以与任何数据类型一起工作。本章还展示了运算符重载如何使选定的代码对任何类型更通用,从而进一步支持模板的使用。

第十四章, 理解 STL 基础,介绍了 C++中的标准模板库(STL),并演示了如何使用常见的容器,如listiteratordequestackqueuepriority_queuemap。此外,还介绍了 STL 算法和函数对象。

第十五章, 测试类和组件,展示了使用规范类形式和驱动程序进行 OO 测试的方法,并展示了如何测试通过继承、关联和聚合相关联的类。本章还展示了如何测试利用异常处理的类。

第十六章, 使用观察者模式,介绍了设计模式概述,然后解释了观察者模式,并通过一个深入示例说明了模式的组成部分。

第十七章, 应用工厂模式,介绍了工厂方法模式,并展示了带和不带对象工厂的实现方式。它还比较了对象工厂与抽象工厂。

评估 包含了每一章的所有问题的答案。

第十九章使用单例模式,详细探讨了单例模式,并使用复杂的配对类实现。还介绍了单例注册表。

第二十章使用 pImpl 模式移除实现细节,描述了 pImpl 模式,该模式用于减少代码中的编译时依赖。使用唯一指针探讨了详细的实现,并探讨了与该模式相关的性能问题。

第二十一章使 C++更安全,回顾了本书中涵盖的主题,目的是确定可以用来使 C++成为开发稳健软件更安全语言的核心理编程指南。

评估 包含了每一章的所有问题的答案。

为了充分利用本书

假设您有一个当前的 C++编译器。您将想要尝试许多在线代码示例!您可以使用任何 C++编译器;然而,建议使用 C++17 或更高版本。展示的代码将符合 C++20 规范。至少,请从gcc.gnu.org下载 g++。

请记住,尽管 C++有一个 ISO 标准,但一些编译器有所不同,并且对标准的解释有细微的差异。

如果您正在使用这本书的数字版,我们建议您亲自输入代码或从书的 GitHub 仓库(下一节中有一个链接)获取代码。这样做将帮助您避免与代码复制粘贴相关的任何潜在错误。

在阅读本书时尝试编码示例是高度推荐的。完成评估将进一步增强您对每个新概念的理解。

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件:github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP。如果代码有更新,它将在 GitHub 仓库中更新。

我们还有其他来自我们丰富的图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!

在行动中的代码

本书在行动中的代码视频可在bit.ly/3pylFkV查看。

下载彩色图像

我们还提供了一个包含本书中使用的截图和图表彩色图像的 PDF 文件。您可以从这里下载:packt.link/ZvNhC

使用的约定

本书使用了多种文本约定。

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“考虑到这一点,让我们看看我们的适配器类,Humanoid。”

代码块应如下设置:

class Humanoid: private Person   // Humanoid is abstract
{                           
protected:
    void SetTitle(const string &t) { ModifyTitle(t); }
public:
    Humanoid() = default;   
    Humanoid(const string &, const string &, 
             const string &, const string &);
    // class definition continues 

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

    const string &GetSecondaryName() const
       { return GetFirstName(); }  
    const string &GetPrimaryName() const 
       { return GetLastName(); } 

任何命令行输入或输出应如下编写:

Orkan Mork McConnell
Nanu nanu
Romulan Donatra Jarok
jolan'tru
Earthling Eve Xu
Hello
Earthling Eve Xu
Bonjour

小贴士或重要提示

看起来像这样。

联系我们

我们始终欢迎读者的反馈。

一般反馈:如果您对本书的任何方面有疑问,请通过电子邮件发送给我们 customercare@packtpub.com,并在邮件主题中提及书名。

错误清单:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告这一点。请访问 www.packtpub.com/support/errata 并填写表格。

侵权:如果您在互联网上以任何形式发现我们作品的非法副本,我们将不胜感激,如果您能提供位置地址或网站名称。请通过电子邮件发送给我们 copyright@packt.com,并提供材料的链接。

如果您有兴趣成为作者:如果您在某个主题上具有专业知识,并且您有兴趣撰写或为本书做出贡献,请访问 authors.packtpub.com

分享您的想法

一旦您阅读了《用 C++解码面向对象编程》,我们很乐意听听您的想法!请点击此处直接访问此书的亚马逊评论页面并分享您的反馈。

您的评论对我们和科技社区都很重要,并将帮助我们确保我们提供高质量的内容。

第一部分:C++构建块基础

本部分的目标是确保您在非 OO C++技能方面有坚实的基础,以便在 C++中构建即将到来的 OOP 技能。这是本书最短的部分,旨在快速让您熟悉 OOP 和更高级的章节。

第一章快速回顾了您在阅读本书时假定具备的基本技能:基本语言语法、循环结构、运算符、函数使用、用户定义类型基础(structtypedefclass基础、using语句、enum、强类型enum)和namespace基础。下一章讨论了const限定变量、函数原型、具有默认值的原型和函数重载。

下一章通过引入new()delete()来分配基本数据类型,动态分配 1 维、2 维和 N 维数组,使用delete管理内存,将参数作为函数的参数传递,使用空指针,以及智能指针的概述,来介绍指针的间接寻址。本节以一个章节结束,该章节通过引用的间接寻址,将带您回顾引用的基础知识、对现有对象的引用,以及作为函数的参数。

虽然这本书会逐渐过渡到优先使用智能指针(并且推荐使用智能指针以确保安全),但熟练掌握原生 C++指针将是一项重要的技能。这项技能对于修改和解析使用原生指针的现有代码,以及清晰地理解原生指针的潜在误用和陷阱至关重要。

本部分包括以下章节:

  • 第一章, 理解基本的 C++假设

  • 第二章, 添加语言需求

  • 第三章, 间接寻址 – 指针

  • 第四章, 间接寻址 – 引用

第一部分:C++构建块基础

第一章:理解基本的 C++ 假设

本章将简要介绍 C++ 的基本语言语法、结构和功能,这些您可能通过熟悉 C++、C、Java 或类似语言的基本语法而获得。这些核心语言特性将简要回顾。如果在完成本章后,这些基本语法技能对您来说仍然不熟悉,请在继续阅读本书之前,先花时间探索更基础的语法驱动的 C++ 文本。本章的目标不是详细教授每个假设的技能,而是简要概述每个基本语言特性,以便您能够快速回忆起您编程库中应该已经掌握的技能。

在本章中,我们将涵盖以下主要主题:

  • 基本语言语法

  • 基本输入/输出

  • 控制结构、语句和循环

  • 运算符

  • 函数基础

  • 用户定义类型基础

  • 命名空间基础

到本章结束时,您将对您假设熟练掌握的非常基本的 C++ 语言技能有一个简洁的回顾。这些技能对于成功进入下一章是必要的。因为大多数这些特性不使用 C++ 的面向对象特性,所以我会尽量避免使用面向对象的术语(尽可能),当我们进入本书的面向对象部分时,我会引入适当的面向对象术语。

技术要求

请确保您有可用的当前 C++ 编译器;您将想要尝试许多在线代码示例。至少,请从 gcc.gnu.org 下载 g++。

完整程序示例的在线代码可以在以下 GitHub 网址找到:github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/tree/main/Chapter01。每个完整程序示例都可以在 GitHub 的相应章节标题(子目录)下的文件中找到,该文件以章节编号开头,后面跟着一个连字符,然后是本章中的示例编号。例如,第一完整的程序在 第一章理解基本的 C++ 假设,可以在上述 GitHub 目录下的 Chapter01 子目录中找到,文件名为 Chp1-Ex1.cpp

本章的 Code in Action (CiA) 视频可以在以下网址查看:bit.ly/3PtOYjf

回顾基本的 C++ 语言语法

在本节中,我们将简要回顾基本的 C++ 语法。我们假设你是一个具有非面向对象编程技能的 C++ 程序员,或者你已经使用过 C、Java 或类似的有相关语法的强类型检查语言进行编程。你也可能是一位经验丰富的专业程序员,能够快速掌握另一种语言的基础。让我们开始我们的简要回顾。

注释风格

C++ 中有可用的两种注释风格:

  • /*   */风格的注释允许跨越多行代码的注释。这种风格不能与相同风格的其它注释嵌套。

  • //风格的注释允许对当前行的末尾进行简单注释。

使用两种注释风格一起可以允许嵌套注释,这在调试代码时可能很有用。

变量声明和标准数据类型

变量的长度可以是任意的,并且可以由字母、数字和下划线组成。变量是区分大小写的,并且必须以字母或下划线开头。C++中的标准数据类型包括以下内容:

  • int:用于存储整数

  • float:用于存储浮点值

  • double:用于存储双精度浮点值

  • char:用于存储单个字符

  • bool:用于存储布尔值truefalse

这里有一些使用上述标准数据类型的简单示例:

int x = 5;
int a = x;
float y = 9.87; 
float y2 = 10.76f;  // optional 'f' suffix on float literal
float b = y;
double yy = 123456.78;
double c = yy;
char z = 'Z';
char d = z;
bool test = true;
bool e = test;
bool f = !test;

回顾之前的代码片段,注意一个变量可以被赋予一个字面量值,例如int x = 5;,或者一个变量可以被赋予另一个变量的值或内容,例如int a = x;。这些示例用各种标准数据类型说明了这种能力。注意,对于bool类型,值可以设置为truefalse,或者使用!(非)来设置这些值的相反。

变量和数组基础

数组可以声明为任何数据类型。数组名称代表与数组内容关联的连续内存的起始地址。在 C++中,数组是零基的,这意味着它们从数组element[0]开始索引,而不是从element[1]开始。最重要的是,C++中的数组不执行范围检查;如果你访问数组之外的元素,你将访问属于另一个变量的内存,并且你的代码可能会很快出现故障。

让我们回顾一些简单的数组声明(一些带有初始化),以及一个赋值操作:

char name[10] = "Dorothy"; // size is larger than needed
float grades[20];  // array is not initialized; caution!
grades[0] = 4.0;  // assign a value to one element of array
float scores[] = {3.3, 4.3, 4.0, 3.7}; // initialized array

注意,第一个数组 name 包含 10 个 char 元素,这些元素被初始化为字符串字面量 "Dorothy" 中的七个字符,后面跟着空字符 ('\0')。数组目前有两个未使用的元素在末尾。可以使用 name[0]name[9] 访问数组中的元素,因为在 C++ 中数组是从零开始的。同样,上面由变量 grades 标识的数组有 20 个元素,其中没有任何一个被初始化。在初始化或赋值之前访问的任何数组值都可能包含任何值;这是任何未初始化变量的特性。注意,在声明数组 grades 之后,它的第 0 个元素被赋值为 4.0。最后,注意 float 类型的数组 scores 被声明并初始化了值。尽管我们可以在 [] 对中指定数组大小,但我们没有这样做——编译器能够根据我们初始化中的元素数量来计算大小。尽可能初始化数组(即使使用零),始终是利用最安全的风格。

字符数组通常被概念化为字符串。在 <cstring> 等库中存在许多标准的字符串函数。如果字符数组要被当作字符串处理,它们应该是以空字符终止的。当字符数组被初始化为一个字符串时,空字符会自动添加。然而,如果字符逐个添加到数组中通过赋值,那么添加空字符 ('\0') 作为数组的最后一个元素就是程序员的职责了。

除了使用字符数组(或字符指针)实现的字符串之外,C++ 标准库中还有一个更安全的数据类型,即 std::string。一旦我们掌握了类的内容,我们就会了解这个类型的细节;在 第五章 详细探索类;然而,现在让我们先介绍 string,作为一种更容易且更不容易出错的方式来创建字符字符串。你需要理解这两种表示;char 数组(和 char 指针)的实现不可避免地会出现在 C++ 库和其他现有代码中。然而,你可能会在新代码中更喜欢 string,因为它更简单且更安全。

让我们看看一些基本的例子:

// size of array can be calculated by initializer
char book1[] = "C++ Programming"; 
char book2[25];  // this string is uninitialized; caution!
// use caution as to not overflow destination (book2)
strcpy(book2, "OO Programming with C++"); 
strcmp(book1, book2);
length = strlen(book2);
string book3 = "Advanced C++ Programming";  // safer usage
string book4("OOP with C++"); // alt. way to init. string
string book5(book4); // create book5 using book4 as a basis

在这里,第一个变量book1被声明并初始化为一个字符串字面量"C++ Programming";数组的大小将由引号字符串值的长度加上一个空字符('\0')来计算。接下来,变量book2被声明为一个长度为25字符的数组,但没有初始化任何值。接下来,使用来自<cstring>的函数strcpy()将字符串字面量"OO Programming with C++"复制到变量book2中。注意,strcpy()会自动将空终止字符添加到目标字符串中。在下一行,使用来自<cstring>strcmp()函数来对变量book1book2的内容进行字典序比较。这个函数返回一个整数值,可以被捕获到另一个变量中或用于比较。最后,使用strlen()函数来计算book2中的字符数(不包括空字符)。

最后,请注意book3book4都是string类型,展示了两种不同的初始化字符串的方法。同时请注意book5是使用book4作为基础进行初始化的。正如我们很快就会发现的,string类中内置了许多安全特性,以促进安全的字符串使用。尽管我们已经回顾了几个表示字符串的方法的示例(一个本地的字符数组与字符串类),但我们通常将使用std::string,因为它更安全。尽管如此,我们现在已经看到了各种函数,如strcpy()strlen(),它们在原生 C++字符串上操作(我们不可避免地会在现有代码中遇到它们)。重要的是要注意,C++社区正在远离原生 C++字符串——即使用字符数组(或指针)实现的字符串。

现在我们已经成功回顾了基本的 C++语言特性,如注释风格、变量声明、标准数据类型和数组基础,让我们继续回顾 C++的另一个基本语言特性:使用<iostream>库的基本键盘输入和输出。

回顾基本 I/O

在本节中,我们将简要回顾基于键盘和监视器的简单字符输入和输出。还将回顾简单的操纵器,以解释 I/O 缓冲区的底层机制,并提供基本的增强和格式化。

iostream 库

在 C++中,最容易的输入和输出机制之一是使用<iostream>库。头文件<iostream>包含了数据类型cincoutcerrclog的定义,通过包含std命名空间来整合。<iostream>库简化了 I/O 操作,可以使用如下方式:

  • cin可以与提取操作符>>一起用于缓冲输入

  • cout可以与插入操作符<<一起用于缓冲输出

  • cerr(无缓冲)和clog(缓冲)也可以与插入操作符一起使用,但用于错误

让我们回顾一个展示简单 I/O 的示例:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter01/Chp1-Ex1.cpp

#include <iostream>
using namespace std;  // we'll limit the namespace shortly
int main()
{
    char name[20];  // caution, uninitialized array of char
    int age = 0;
    cout << "Please enter a name and an age: ";
    cin >> name >> age; // caution, may overflow name var.
    cout << "Hello " << name;
    cout << ". You are " << age << " years old." << endl;
    return 0;
}

首先,我们包含 <iostream> 库并指示我们正在使用 std 命名空间以使用 cincout(关于命名空间将在本章后面进行更多介绍)。接下来,我们介绍 main() 函数,它是我们应用程序的入口点。在这里,我们声明了两个变量,nameage,它们都没有初始化。接下来,我们通过将字符串 "Please enter a name and an age: " 放入与 cout 相关的缓冲区来提示用户输入。当与 cout 相关的缓冲区被刷新时,用户将在屏幕上看到这个提示。

然后,使用提取操作符 << 将键盘输入字符串放入与 cout 相关的缓冲区。方便的是,一个自动刷新与 cout 相关的缓冲区的机制是使用 cin 将键盘输入读取到变量中,如下一行所示,我们将用户输入读取到变量 nameage 中,分别。

接下来,我们向用户打印出 "Hello" 的问候语,然后是输入的名字,然后是用户年龄的指示,这些是从用户输入的第二部分收集的。此行末尾的 endl 不仅可以向输出缓冲区中放置一个换行符 '\n',而且确保输出缓冲区被刷新——关于这一点将在下一节中详细介绍。return 0; 声明只是将程序退出状态返回给编程外壳,在这种情况下,值为 0。请注意,main() 函数表明返回值是 int 类型,以确保这是可能的。

基本 iostream 操作符

通常,我们希望能够操作与 cincoutcerr 相关的缓冲区的内容。操作符允许修改这些对象的内部状态,从而影响它们相关缓冲区的格式化和操作。操作符在 <iomanip> 头文件中定义。常见的操作符示例包括以下内容:

  • endl: 在与 cout 相关的缓冲区中放置一个换行符 ('\n') 然后刷新缓冲区

  • flush: 清除输出流的全部内容

  • setprecision(int): 定义用于输出浮点数的精度(数字位数)

  • setw(int): 设置输入和输出的宽度

  • ws: 从缓冲区中移除空白字符

让我们看看一个简单的示例:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter01/Chp1-Ex2.cpp

#include <iostream>
#include <iomanip>
using namespace std;   // we'll limit the namespace shortly
int main()
{
    char name[20];     // caution; uninitialized array
    float gpa = 0.0;   // grade point average
    cout << "Please enter a name and a gpa: "; 
    cin >> setw(20) >> name >> gpa;  // won't overflow name
    cout << "Hello " << name << flush;
    cout << ". GPA is: " << setprecision(3) << gpa << endl;
    return 0;
}

在这个例子中,首先,注意 <iomanip> 头文件的包含。另外,注意 setw(20) 的使用是为了确保我们不会溢出只有 20 个字符长的名称变量;setw() 会自动从提供的大小中减去一个,以确保有空间放置空字符。注意在第二行输出中使用 flush – 在这里不需要刷新输出缓冲区;这个转换器仅仅演示了如何应用 flush。在最后一行输出 cout 中,注意使用了 setprecision(3) 来打印浮点数 gpa。三个小数点精度包括小数点及其右边的两个位置。最后,注意我们向与 cout 关联的缓冲区添加了 endl 转换器。endl 转换器首先在缓冲区中插入一个换行符 ('\n'),然后刷新缓冲区。为了性能,如果你不需要立即看到输出,只使用换行符会更高效。

现在我们已经回顾了使用 <iostream> 库的简单输入和输出,让我们通过简要回顾控制结构、语句和循环结构来继续前进。

回顾控制结构、语句和循环

C++ 具有多种控制结构和循环结构,允许程序流程非顺序执行。每个结构都可以与简单或复合语句结合使用。简单语句以分号结束;更复杂的复合语句则用一对花括号 {} 包围。在本节中,我们将回顾各种控制结构(ifelse ifelse)以及循环结构(whiledo whilefor),以总结代码中非顺序程序流程的简单方法。

控制结构 – if、else if 和 else

使用 ifelse ifelse 的条件语句可以与简单语句或语句块一起使用。注意,if 子句可以不跟 else ifelse 子句。实际上,else if 是一个嵌套了 if 子句的 else 子句的压缩版本。实际上,开发者将嵌套使用简化为 else if 格式以提高可读性和节省多余的缩进。让我们看一个例子:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter01/Chp1-Ex3.cpp

#include <iostream>
using namespace std;   // we'll limit the namespace shortly
int main()
{
    int x = 0;
    cout << "Enter an integer: ";
    cin >> x;
    if (x == 0) 
        cout << "x is 0" << endl;
    else if (x < 0)
        cout << "x is negative" << endl;
    else
    {
        cout << "x is positive";
        cout << "and ten times x is: " << x * 10 << endl;
    }  
    return 0;
}

注意,在前面的 else 子句中,多个语句被组合成一个代码块,而在 ifelse if 条件中,每个条件后只跟一个语句。作为旁注,在 C++ 中,任何非零值都被视为真。例如,测试 if (x) 就意味着 x 不等于零 – 不必写 if (x !=0),除非为了可读性。

值得注意的是,在 C++中,采用一套一致的编码约定和实践是明智的(正如许多团队和组织所做的那样)。作为一个简单的例子,括号的放置可能被指定在编码标准中(例如,将{与关键字else放在同一行,或者放在else关键字下面的行上,并指定缩进的空格数)。另一个约定可能是,即使是一个跟随在else关键字之后的单条语句,也应该用括号包含在一个块中。遵循一套一致的编码约定将使你的代码更容易被他人阅读和维护。

循环结构——while、do while 和 for 循环

C++有几种循环结构。让我们花点时间简要回顾每种风格的示例,从whiledo while循环结构开始:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter01/Chp1-Ex4.cpp

#include <iostream>
using namespace std;   // we'll limit the namespace shortly
int main()
{
    int i = 0;
    while (i < 10)
    {
        cout << i << endl;
        i++;
    }
    i = 0;
    do 
    {
        cout << i << endl;
        i++;
    } while (i < 10);
    return 0;
}

使用while循环时,循环体进入之前,条件必须评估为真。然而,使用do while循环时,循环体的第一次进入是有保证的——条件是在循环体再次迭代之前评估的。在先前的例子中,whiledo while循环都执行了 10 次,每次为变量i打印值09

接下来,让我们回顾一下典型的for循环。for循环在括号内有三个部分。首先,有一个语句只执行一次,通常用于初始化循环控制变量。接下来,在括号中间由分号分隔的表达式。这个表达式在每次进入循环体之前都会被评估。只有当这个表达式评估为true时,才会进入循环体。最后,括号内的第三部分是第二个语句。这个语句在执行循环体之后立即执行,通常用于修改循环控制变量。在第二个语句之后,中心表达式会被重新评估。以下是一个例子:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter01/Chp1-Ex5.cpp

#include <iostream>
using namespace std;   // we'll limit the namespace shortly
int main()
{
    // though we'll prefer to declare i within the loop
    // construct, let's understand scope in both scenarios
    int i; 
    for (i = 0; i < 10; i++) 
        cout << i << endl;
    for (int j = 0; j < 10; j++)   // preferred declaration
        cout << j << endl;      // of loop control variable
    return 0;
}

在这里,我们有两个 for 循环。在第一个循环之前,变量 i 被声明。然后,变量 i 在循环括号 () 中的语句 1 中初始化为 0 的值。测试循环条件,如果为 true,则进入并执行循环体,然后执行语句 2,在重新测试循环条件之前。这个循环对于 i 的值 09 执行 10 次。第二个 for 循环类似,唯一的区别是变量 j 在循环结构的语句 1 中被声明和初始化。请注意,变量 j 只在 for 循环本身的作用域内,而变量 i 的作用域是从其声明点开始到整个声明它的代码块。

让我们快速看看一个使用嵌套循环的示例。循环结构可以是任何类型,但在以下内容中,我们将回顾嵌套 for 循环:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter01/Chp1-Ex6.cpp

#include <iostream>
using namespace std;   // we'll limit the namespace shortly
int main()
{
    for (int i = 0; i < 10; i++) 
    {
        cout << i << endl;
        for (int j = 0; j < 10; j++)
            cout << j << endl;
        cout << "\n";
    }
    return 0;
}

在这里,外层循环将执行十次,i 的值为 09。对于每个 i 的值,内层循环将执行十次,j 的值为 09。记住,在使用 for 循环时,循环控制变量会自动在循环结构内部通过 i++j++ 进行递增。如果使用 while 循环,程序员需要在每个此类循环体的最后一行记住递增循环控制变量。

现在我们已经回顾了 C++ 中的控制结构、语句和循环结构,我们可以通过简要回顾 C++ 的运算符继续前进。

回顾 C++ 运算符

C++ 中存在一元、二元和三元运算符。C++ 允许运算符根据使用上下文具有不同的含义。C++ 还允许程序员在至少一个用户定义类型的上下文中重新定义所选运算符的含义。运算符列在以下简洁列表中。我们将在本节剩余部分和整个课程中看到这些运算符的示例。以下是 C++ 中二元、一元和三元运算符的概述:

图 1.1 – C++ 运算符

图 1.1 – C++ 运算符

在上述二元运算符列表中,请注意有多少运算符在与赋值运算符 = 配对时具有“快捷”版本。例如,a = a * b 可以使用快捷运算符 a *= b 等价地编写。让我们看看一个包含各种运算符的示例,包括快捷运算符的使用:

score += 5;
score++;
if (score == 100)
    cout << "You have a perfect score!" << endl;
else
    cout << "Your score is: " << score << endl;
// equivalent to if - else above, but using ?: operator
(score == 100)? cout << "You have a perfect score" << endl:
                cout << "Your score is: " << score << endl; 

在前面的代码片段中,注意到了快捷操作符 += 的使用。这里,语句 score += 5; 等同于 score = score + 5;。接下来,使用一元增量操作符 ++score 增加 1。然后我们看到等号操作符 == 用于比较 score 与值 100。最后,我们看到三元操作符 ?: 的一个示例,用于替换简单的 if - else 语句。值得注意的是,?: 并不是所有程序员的首选,但回顾其使用示例总是很有趣。

现在我们已经简要回顾了 C++ 中的运算符,让我们重新审视函数基础。

回顾函数基础

函数标识符必须以字母或下划线开头,并且还可以包含数字。函数的返回类型、参数列表和返回值是可选的。C++ 函数的基本形式如下:

<return type> FunctionName (<argumentType argument1, …>)
{
    expression 1…N;
    <return value/expression;>
}

让我们回顾一个简单的函数:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter01/Chp1-Ex7.cpp

#include <iostream>
using namespace std;   // we'll limit the namespace shortly
int Minimum(int a, int b)
{
    if (a < b)
        return a;
    else
        return b;
}
int main()
{
    int x = 0, y = 0;
    cout << "Enter two integers: ";
    cin >> x >> y;
    cout << "The minimum is: " << Minimum(x, y) << endl;
    return 0;
}

在前面的简单示例中,首先定义了一个函数 Minimum()。它返回 int 类型,并接受两个整数参数:形式参数 ab。在 main() 函数中,使用实际参数 xy 调用了 Minimum() 函数。由于 Minimum() 返回一个整数值,因此可以在 cout 语句中调用它;这个值与打印操作一起传递给提取操作符 (<<)。实际上,字符串 "The minimum is: " 首先被放置在 cout 相关的缓冲区中,然后是调用函数 Minimum() 返回的值。然后通过 endl(在刷新之前先在缓冲区中放置一个换行符)刷新输出缓冲区。

注意,函数首先在文件中定义,然后在文件中的 main() 函数中稍后调用。在调用函数时,会执行强类型检查,比较参数类型及其在函数定义中的使用。然而,当函数调用在其定义之前发生时会发生什么?或者如果函数的调用与其定义在不同的文件中?

在这种情况下,编译器默认假设函数具有某种 签名,例如整数返回类型,并且形式参数将与函数调用中的参数类型匹配。通常,默认假设是不正确的;当编译器随后在文件中遇到函数定义(或在链接另一个文件时),将引发错误,指示函数调用和定义不匹配。

这些问题在历史上通常通过在文件顶部包含一个函数的前向声明来解决,该声明位于将要调用该函数的位置。前向声明包括函数的返回类型、函数名和类型以及参数数量。在 C++中,前向声明得到了改进,现在被称为函数原型。由于函数原型周围有许多有趣的细节,这个主题将在下一章中详细讨论。

重要提示

可以选择性地将[[nodiscard]]指定符添加到函数返回类型之前。此指定符用于指示函数的返回值不得被忽略——也就是说,它必须被捕获在变量中或在表达式中使用。如果函数的返回值因此被忽略,编译器将发出警告。请注意,nodiscard限定符可以添加到函数原型中,也可以选择性地添加到定义中(如果没有原型,则定义中必须要求)。理想情况下,nodiscard应出现在两个位置。

随着我们转向本书中的面向对象部分(第五章详细探索类,以及更多),我们将了解到与函数相关的更多细节和非常有趣的功能。尽管如此,我们已经充分回忆了前进所需的基本知识。接下来,让我们继续用用户定义类型来回顾 C++语言。

回顾用户定义类型的基本知识

C++提供了几种机制来创建用户定义的类型。将类似特性捆绑成一个数据类型(稍后我们还将添加相关行为)将形成面向对象概念“封装”的基础,该概念将在本文的后续部分探讨。现在,让我们回顾一下将数据捆绑在一起的基本机制,这些机制包括structclasstypedef(在某种程度上)。我们还将回顾枚举类型,以更有意义地表示整数列表。

struct

C++结构在其最简单形式下可以用来将常见的数据元素收集到一个单元中。然后可以声明复合数据类型的变量。点操作符用于访问每个结构变量的特定成员。以下是一个以最简单方式使用的结构示例:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter01/Chp1-Ex8.cpp

#include <iostream>
using namespace std;   // we'll limit the namespace shortly
struct student
{
    string name;
    float semesterGrades[5];
    float gpa;
};
int main()
{
    student s1;
    s1.name = "George Katz";
    s1.semesterGrades[0] = 3.0;
    s1.semesterGrades[1] = 4.0;
    s1.gpa = 3.5;
    cout << s1.name << " has GPA: " << s1.gpa << endl;
    return 0;        
}

在风格上,当使用 struct 时,类型名称通常是小写的。在先前的例子中,我们使用 struct 声明用户定义类型 student。类型 student 有三个字段或数据成员:namesemesterGradesgpa。在 main() 函数中,声明了一个类型为 student 的变量 s1;使用点操作符来访问变量的每个数据成员。由于在 C++ 中通常不使用 struct 进行面向对象编程,所以我们现在不会介绍与它们使用相关的重大面向对象术语。值得注意的是,在 C++ 中,标签 student 也成为类型名称(与 C 中的不同,在变量声明中需要用 struct 来先于类型)。

typedef 和 “using” 别名声明

typedef 声明可以用来为数据类型提供更易记的表示。在 C++ 中,与 struct 一起使用时,typedef 的相对需求已经被消除。历史上,C 中的 typedef 允许将关键字 struct 和结构标签捆绑在一起,以创建用户定义的类型。然而,在 C++ 中,由于结构标签自动成为类型,因此 typedef 对于 struct 来说就完全不必要的了。typedef 仍然可以与标准类型一起使用,以增强代码的可读性,但以这种方式,typedef 并不是用来捆绑数据元素,例如与 struct 一起使用。作为一个相关的历史注释,#define(一个预处理器指令和宏替换)曾经被用来创建更易记的类型,但 typedef(和 using)无疑是首选。在查看旧代码时,这一点值得注意。

using 语句可以用作简单 typedef 的替代,以创建类型的别名,称为 using 语句也可以用来简化更复杂类型(例如,在使用标准模板库或声明函数指针时提供复杂声明的别名)。当前的趋势是更倾向于使用 using 别名声明而不是 typedef

让我们比较一下简单的 typedef 与简单的 using 别名声明:

typedef float dollars; 
using money = float;

在前面的声明中,新类型 dollars 可以与类型 float 互换使用。同样,新别名 money 也可以与类型 float 互换使用。使用 typedef 与结构体进行陈旧的使用并不具有生产力,所以让我们继续到 C++ 中最常用的用户定义类型 class

class

在其最简单的形式中,class 可以几乎像 struct 一样用来将相关数据捆绑成一个单一的数据类型。在 第五章详细探索类,我们将看到 class 通常也用来将相关函数与新数据类型捆绑在一起。将相关数据和与该数据相关的行为组合在一起是封装的基础。现在,让我们看看 class 的最简单形式,就像 struct 一样:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter01/Chp1-Ex9.cpp

#include <iostream>
using namespace std;   // we'll limit the namespace shortly
class Student
{
public:
    string name;
    float semesterGrades[5];
    float gpa;
};
int main()
{
    Student s1;
    s1.name = "George Katz";
    s1.semesterGrades[0] = 3.0;
    s1.semesterGrades[1] = 4.0;
    s1.gpa = 3.5;
    cout << s1.name << " has GPA: " << s1.gpa << endl;
    return 0;      
}

注意,前面的代码与 struct 示例中使用的代码非常相似。主要区别在于使用了关键字 class 而不是 struct,以及在类定义开头添加了访问标签 public:(更多内容请参阅第五章**,详细探索类)。从风格上讲,数据类型名称首字母大写,如 Student,是类的典型特征。我们将看到类具有更多功能,是面向对象编程的构建块。我们将介绍新的术语,如 实例,而不是 变量 来使用。然而,本节仅是对假设技能的回顾,因此我们需要等待了解语言中令人兴奋的面向对象特性。剧透一下:类能够做到的所有奇妙事情也适用于结构体;然而,我们将看到在风格上结构体不会用来举例说明面向对象编程。

枚举和强类型枚举

传统枚举类型可以用来记忆性地表示整数列表。除非另有初始化,枚举中的整数值从零开始,并在整个列表中递增。两个枚举类型不能使用相同的枚举值名称。

强类型枚举类型在传统枚举类型的基础上进行了改进。强类型枚举默认表示整数列表,但也可以用来表示任何整型,例如 intshort intlong intcharbool。枚举值不会被导出到周围作用域,因此枚举值可以在不同类型之间重用。强类型枚举允许对其类型的提前声明(允许在枚举声明之前将这些类型用作函数的参数)。

现在让我们看看传统枚举和强类型枚举的例子:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter01/Chp1-Ex10.cpp

#include <iostream>
using namespace std;   // we'll limit the namespace shortly
// traditional enumerated types
enum day {Sunday, Monday, Tuesday, Wednesday, Thursday,
          Friday, Saturday};
enum workDay {Mon = 1, Tues, Wed, Thurs, Fri};
// strongly-typed enumerated types can be a struct or class
enum struct WinterHoliday {Diwali, Hanukkah, ThreeKings,
  WinterSolstice, StLucia, StNicholas, Christmas, Kwanzaa};
enum class Holiday : short int {NewYear = 1, MLK, Memorial,
  Independence, Labor, Thanksgiving};
int main()
{
    day birthday = Monday;
    workDay payday = Fri;
    WinterHoliday myTradition = WinterHoliday::StNicholas;
    Holiday favorite = Holiday::NewYear;
    cout << "Birthday is " << birthday << endl;
    cout << "Payday is " << payday << endl;
    cout << "Traditional Winter holiday is " << 
             static_cast<int> (myTradition) << endl;
    cout << "Favorite holiday is " << 
             static_cast<short int> (favorite) << endl;
    return 0;      
}

在前一个示例中,传统的枚举类型 day 的值从 06,以 Sunday 开始。传统的枚举类型 workDay 的值从 15,以 Mon 开始。请注意,枚举类型中显式使用 Mon = 1 作为第一个项目已用于覆盖默认的起始值 0。有趣的是,我们可以在两个枚举类型之间不重复枚举器。因此,您会注意到 MonworkDay 中用作枚举器,因为 Monday 已经在枚举类型 day 中使用。现在,当我们创建如 birthdaypayday 这样的变量时,我们可以使用有意义的枚举类型来初始化或分配值,如 MondayFri。尽管枚举器在代码中可能很有意义,但请注意,当操作或打印时,它们的值将是相应的整数值。

在考虑前一个示例中的强类型枚举类型之后,WinterHolidayenum 是使用 struct 定义的。枚举器的默认值是整数,从值 0 开始(就像传统枚举一样)。然而,请注意,Holidayenum 指定枚举器为 short int 类型。此外,我们选择从值 1 开始枚举类型中的第一个项目,而不是 0。请注意,当我们打印强类型枚举器时,我们必须使用 static_cast 将类型转换为枚举器的类型。这是因为插入操作符知道如何处理选定的类型,但这些类型不包括强类型枚举;因此,我们将枚举类型转换为插入操作符可以理解的类型。

现在我们已经回顾了 C++ 中的简单用户定义类型,包括 structtypedef(以及使用别名using)、classenum,我们准备继续前进,复习我们下一个语言必需品,即 namespace

回顾命名空间基础知识

namespace 实用工具被添加到 C++ 中,以在全局作用域之外为应用程序添加一个作用域级别。此功能可用于允许使用两个或多个库,而无需担心它们可能包含重复的数据类型、函数或标识符。程序员需要在应用程序的相关部分中使用关键字 using 激活所需的命名空间。程序员还可以创建自己的命名空间(通常用于创建可重用的库代码)并按适用情况激活每个命名空间。在前面的示例中,我们已经看到了 std 命名空间的简单使用,以包含 cincout,它们是 istreamostream 的实例(其定义在 <iostream> 中)。让我们回顾一下我们如何自己创建命名空间:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter01/Chp1-Ex11.cpp

#include <iostream>
// using namespace std; // Do not open entire std namespace
using std::cout;   // Instead, activate individual elements
using std::endl;   // within the namespace as needed
namespace DataTypes
{
    int total;
    class LinkList
    {  // full class definition … 
    };
    class Stack
    {  // full class definition …
    };
};
namespace AbstractDataTypes
{
    class Stack
    {  // full class definition …
    };
    class Queue
    {  // full class description …
    };
};
// Add entries to the AbstractDataTypes namespace
namespace AbstractDataTypes   
{
    int total;
    class Tree
    {  // full class definition …
    };
};
int main()
{
    using namespace AbstractDataTypes; //activate namespace
    using DataTypes::LinkList;    // activate only LinkList 
    LinkList list1;     // LinkList is found in DataTypes
    Stack stack1;    // Stack is found in AbstractDataTypes
    total = 5;       // total from active AbstractDataTypes
    DataTypes::total = 85;// specify non-active mbr., total
    cout << "total " << total << "\n";
    cout << "DataTypes::total " << DataTypes::total;
    cout << endl;
    return 0;        
}

在上一段代码的第二行(该行已被注释),我们注意到使用了关键字using来表示我们希望使用或激活整个std命名空间。最好是在接下来的两行代码中,我们只激活我们将需要的标准命名空间中的元素,例如std::coutstd::endl。我们可以使用using来打开可能包含有用类的现有库(或这些库中的单个元素);关键字using激活了可能属于给定库的命名空间。接下来在代码中,创建了一个名为DataTypes的用户指定命名空间,使用namespace关键字。在这个命名空间中存在一个变量total和两个类定义:LinkListStack。在此命名空间之后,创建了一个名为AbstractDataTypes的第二个命名空间,并包含两个类定义:StackQueue。此外,通过第二个namespace定义的出现,AbstractDataTypes命名空间被扩展,添加了一个变量total和一个Tree类的定义。

main()函数中,首先,使用using关键字打开AbstractDataTypes命名空间。这激活了该命名空间中的所有名称。接下来,将using关键字与作用域解析运算符(::)结合使用,仅激活来自DataTypes命名空间的LinkList类定义。如果AbstractDataType命名空间中也有一个LinkList类,那么最初可见的LinkList现在将被激活DataTypes::LinkList所隐藏。

接下来,声明了一个类型为LinkList的变量,其定义来自DataTypes命名空间。接下来声明了一个类型为Stack的变量;尽管两个命名空间都有一个Stack类的定义,但由于只有一个Stack被激活,因此没有歧义。接下来,我们使用cin将数据读入total,该变量来自AbstractDataTypes命名空间。最后,我们使用作用域解析运算符显式地将数据读入DataTypes::total,否则这个变量将被隐藏。需要注意的是:如果有两个或多个命名空间包含相同的标识符,则最后打开的命名空间将占主导地位,隐藏所有之前的出现。

被认为是一种良好的实践,只激活我们希望使用的命名空间元素。从上述示例中,我们可以看到可能出现的潜在歧义。

摘要

在本章中,我们回顾了核心 C++语法和非面向对象语言特性,以更新你的现有技能集。这些特性包括基本语言语法、基本 I/O 操作与<iostream>、控制结构/语句/循环、运算符基础、函数基础、简单的用户定义类型和命名空间。最重要的是,你现在可以进入下一章,我们将通过添加一些额外的语言必要性来扩展这些想法,例如const限定变量、理解和使用原型(包括默认值)和函数重载。

下一章中的思想开始让我们更接近面向对象编程的目标,因为随着我们深入语言,许多这些聚合技能经常被使用,并且变得自然而然。重要的是要记住,在 C++中,无论你是否有意为之,你都可以做任何事情。这个语言拥有巨大的力量,对其众多细微差别和特性的坚实基础至关重要。在接下来的几章中,我们将通过一系列非面向对象的 C++技能打下坚实的基础,以便我们能够以高水平和成功率在 C++中现实地参与面向对象编程。

问题

  1. 描述一个场景,其中flush而不是endl可能对清除与cout关联的缓冲区内容更有用。

  2. 一元运算符++可以用作前增量或后增量运算符,例如i++++i。你能描述一个选择前增量或后增量++会有不同后果的代码场景吗?

  3. 使用structclass创建一个简单的程序,定义一个用户自定义类型Book。为标题、作者和页数添加数据成员。创建两个Book类型的变量,并使用点操作符.为每个实例填充数据成员。使用iostreams来提示用户输入值,并在完成后打印每个Book实例。只使用本章中介绍的功能。

第二章:添加语言必要性

本章将介绍 C++中必要的非面向对象特性,这些特性是 C++面向对象特性的关键构建块。本章中介绍的特性代表了从这一点开始在书中将直接使用的主题。C++是一种充满灰色地带的语言;从本章开始,你将不仅熟悉语言特性,还将熟悉语言的细微差别。本章的目标将是开始提升你的技能,从一名普通 C++程序员转变为能够在创建可维护代码的同时成功操作语言细微差别的人。

在本章中,我们将涵盖以下主要主题:

  • const限定符

  • 函数原型

  • 函数重载

到本章结束时,你将理解非面向对象特性,如const限定符、函数原型(包括使用默认值)和函数重载(包括标准类型转换如何影响重载函数的选择以及可能产生的潜在歧义)。许多这些看似简单的话题都包含了一系列有趣的细节和细微差别。这些技能对于成功进行本书的下一章至关重要。

技术要求

完整程序示例的在线代码可以在以下 GitHub URL 中找到:github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/tree/main/Chapter02。每个完整程序示例都可以在 GitHub 的相应章节标题(子目录)下找到,对应章节的文件名,后面跟着一个连字符,然后是当前章节中的示例编号。例如,第二章中的第一个完整程序,添加语言必要性,可以在上述 GitHub 目录下的Chapter02子目录中的Chp2-Ex1.cpp文件中找到。

本章的 CiA 视频可以在以下链接查看:bit.ly/3CM65dF

使用constconstexpr限定符

在本节中,我们将向变量添加constconstexpr限定符,并讨论它们如何添加到函数的输入参数和返回值中。随着我们在 C++语言中的前进,这些限定符将被广泛使用。使用constconstexpr可以使值被初始化,但之后不再修改。函数可以通过使用constconstexpr来声明它们不会修改其输入参数,或者它们的返回值可能只能被捕获(但不能修改)。这些限定符有助于使 C++成为一种更安全的语言。让我们看看constconstexpr的实际应用。

constconstexpr变量

一个有资格的 const 变量是一个必须初始化且永远不会被赋予新值的变量。将 const 和变量的使用放在一起似乎是一个悖论——const 意味着不要改变,而变量的概念是天生可以持有不同的值。尽管如此,拥有一个强类型检查的变量,其唯一值可以在运行时确定,是非常有用的。关键字 const 被添加到变量声明中。

类似地,使用 constexpr 声明的变量是一个有资格的常量变量——它可以被初始化,但永远不会被赋予新的值。只要可能,constexpr 的使用正在变得越来越受欢迎。

在某些情况下,常量的值在编译时是未知的。一个例子可能是如果使用用户输入或函数的返回值来初始化一个常量。一个 const 变量可以在运行时轻松初始化。constexpr 变量通常可以在运行时初始化,但并不总是如此。在我们的例子中,我们将考虑各种情况。

让我们在下面的程序中考虑几个例子。我们将把这个程序分成两个部分进行更具体的解释,然而,完整的程序示例可以在以下链接中找到:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter02/Chp2-Ex1.cpp

#include <iostream>
#include <iomanip>
#include <cstring> // though, we'll prefer std:: string,
// char [ ] demos the const qualifier easily in cases below
using std::cout;     // preferable to: using namespace std;
using std::cin;
using std::endl;
using std::setw;
// simple const variable declaration and initialization
// Convention will capitalize those known at compile time
// (those taking the place of a former macro #define)
const int MAX = 50; 
// simple constexpr var. declaration and init. (preferred)
constexpr int LARGEST = 50;
constexpr int Minimum(int a, int b)  
// function definition w formal parameters
{
    return (a < b)? a : b;   // conditional operator ?: 
}

在前面的程序段中,注意我们如何使用 const 修饰符在数据类型之前声明一个变量。这里,const int MAX = 50; 简单地将 MAX 初始化为 50。MAX 在代码的后续部分不能通过赋值来修改。出于惯例,简单的 constconstexpr 资格变量(取代了曾经使用的 #define 宏)通常使用大写字母,而计算(或可能计算)的值则使用典型的命名约定。接下来,我们使用 constexpr int LARGEST = 50; 引入一个常量变量,同样不能被修改。这个选项正在成为首选用法,但并不总是可以使用的。

接下来,我们有函数 Minimum() 的定义;注意在这个函数体中使用了三元条件运算符 ?:。同时注意,这个函数的返回值被 constexpr 赋予了资格(我们很快就会检查这一点)。接下来,让我们在继续这个程序的其余部分时检查 main() 函数的主体:

int main()
{
    int x = 0, y = 0;
    // Since 'a', 'b' could be calculated at runtime
    // (such as from values read in), we will use lowercase
    constexpr int a = 10, b = 15;// both 'a', 'b' are const
    cout << "Enter two <int> values: ";
    cin >> x >> y;
    // const variable initialized w return val. of a fn.
    const int min = Minimum(x, y);  
    cout << "Minimum is: " << min << endl;
    // constexpr initialized with return value of function
    constexpr int smallest = Minimum(a, b);           
    cout << "Smallest of " << a << " " << b << " is: " 
         << smallest << endl;
    char bigName[MAX] = {""};  // const used to size array
    char largeName[LARGEST] = {""}; // same for constexpr 
    cout << "Enter two names: ";
    cin >> setw(MAX) >> bigName >> setw(LARGEST) >>
           largeName;
    const int namelen = strlen(bigName);   
    cout << "Length of name 1: " << namelen << endl;
    cout << "Length of name 2: " << strlen(largeName) <<
             endl;
    return 0;
}

main() 函数中,让我们考虑以下代码序列:我们提示用户输入 "Enter two values: " 并分别将它们存储在变量 xy 中。在这里,我们调用函数 Minimum(x,y) 并将刚刚使用 cin 和提取操作符 >> 读取的两个值 xy 作为实际参数传递。请注意,在 minconst 变量声明旁边,我们使用函数调用 Minimum() 的返回值初始化 min。重要的是要注意,设置 min 是作为一个单独的声明和初始化捆绑在一起的。如果将此拆分为两行代码——变量声明后跟赋值——编译器将会报错。标记为 const 的变量只能初始化为一个值,并且在声明后不能赋值。

接下来,我们将函数 Minimum(a, b) 的返回值初始化给 smallest。请注意,参数 ab 是可以在编译时确定的字面量值。同时请注意,Minimum() 函数的返回值已经被 constexpr 标记。这种标记是必要的,以便 constexpr smallest 能够使用函数的返回值进行初始化。注意,如果我们尝试将 xy 传递给 Minimum() 来设置 smallest,将会得到一个错误,因为 xy 的值不是字面量值。

在上一个示例的最后一段代码中,请注意我们使用 MAX(在程序示例的早期部分定义)来在声明 char bigName[MAX]; 中为固定大小数组 bigName 定义一个大小。我们同样使用 LARGEST 来为固定大小数组 largeName 定义一个大小。在这里,我们看到可以使用 constconstexpr 来以这种方式定义数组的大小。然后我们进一步在 setw(MAX) 中使用 MAX,在 setw(LARGEST) 中使用 LARGEST,以确保在读取键盘输入时使用 cin 和提取操作符 >> 不溢出 bigNamelargeName。最后,我们使用函数 strlen(bigname) 的返回值初始化变量 const int namelen 并使用 cout 打印这个值。请注意,因为 strlen() 不是一个其值被 constexpr 标记的函数,所以我们不能使用这个返回值来初始化一个 constexpr

伴随上述完整程序示例的输出如下:

Enter two <int> values: 39 17
Minimum is: 17
Smallest of 10 15 is: 10
Enter two names: Gabby Dorothy
Length of name 1: 5
Length of name 2: 7

现在我们已经看到了如何使用 constconstexpr 标记变量,让我们考虑函数的常量标记。

函数的 const 标记

关键字constconstexpr也可以与函数一起使用。这些修饰符可以在参数之间使用,以指示参数本身不会被修改。这是一个有用的特性——函数的调用者将理解该函数不会修改以这种方式修饰的输入参数。然而,由于非指针(和非引用)变量作为栈上实际参数的副本以值传递的方式传递给函数,因此constconstexpr修饰这些参数的内在副本并不起作用。因此,不需要对标准数据类型的参数进行constconstexpr修饰。

同样的原则也适用于函数的返回值。函数的返回值可以是constconstexpr修饰的;然而,除非返回的是一个指针(或引用),否则作为返回值传递回栈上的项是一个副本。因此,当返回类型是指向常量对象的指针(我们将在第三章间接寻址:指针,以及更后面讨论)时,const修饰的返回值更有意义。注意,如果一个函数的返回值将被用来初始化一个constexpr变量,那么需要这个函数有一个constexpr修饰的返回值,正如我们在之前的例子中所看到的。作为const的最后一次使用,当我们转向类的 OO 细节时,我们可以使用这个关键字来指定特定的成员函数将不会修改该类的任何数据成员。我们将在第五章详细探索类中探讨这种情况。

现在我们已经了解了constconstexpr修饰符在变量中的用法,并看到了constconstexpr与函数结合使用的潜在用途,让我们继续本章的下一个语言特性:函数原型。

与函数原型一起工作

在本节中,我们将检查函数原型的机制,例如在文件中以及跨多个文件放置的必要性,以增加程序灵活性。我们还将为原型参数添加可选名称,并理解我们为什么可能选择在 C++原型中添加默认值。函数原型确保 C++代码进行强类型检查。

在进入函数原型之前,让我们花一点时间回顾一些必要的编程术语。函数定义指的是一个函数的代码主体,而函数的声明(也称为前向声明)只是引入一个函数名及其返回类型和参数类型。前向声明允许编译器通过比较调用与前向声明来执行函数调用和定义之间的强类型检查。前向声明是有用的,因为函数定义并不总是出现在函数调用之前的文件中;有时,函数定义出现在与它们的调用不同的文件中。

定义函数原型

函数原型是函数的前向声明,它描述了函数应该如何正确调用。原型确保了函数调用和其定义之间的强类型检查。一个简单的函数原型包括以下内容:

  • 函数的返回类型

  • 函数的名称

  • 函数的类型和参数数量

函数原型允许函数调用先于函数的定义,或者允许调用存在于单独文件中的函数。随着我们学习更多 C++语言特性,例如异常,我们将看到更多元素有助于函数的扩展原型(和扩展签名)。现在,让我们看看一个简单的例子:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter02/Chp2-Ex2.cpp

#include <iostream>
using std::cout;     // preferred to: using namespace std;
using std:: endl;
[[nodiscard]] int Minimum(int, int);   // fn. prototype

int main()
{
    int x = 5, y = 89;
    // function call with actual parameters
    cout << Minimum(x, y) << endl;     
    return 0;                          
}
[[nodiscard]] int Minimum(int a, int b) // fn. definition
                                 // with formal parameters 
{
    return (a < b)? a : b;  
}

注意,我们在上述示例的早期原型化了int Minimum(int, int);。这个原型让编译器知道任何对Minimum()的调用都应该接受两个整数参数,并返回一个整数值(我们将在本节稍后讨论类型转换)。

还要注意在函数返回类型之前使用[[nodiscard]]。这表示程序员应该存储返回值或以其他方式使用返回值(例如在表达式中)。如果忽略此函数的返回值,编译器将发出警告。

接下来,在main()函数中,我们调用函数Minimum(x, y)。此时,编译器检查函数调用是否与上述原型在类型和参数数量以及返回类型上匹配。也就是说,两个参数都是整数(或者可以轻松转换为整数),返回类型是整数(或者可以轻松转换为整数)。返回值将被用作cout打印的值。最后,在文件中定义了Minimum()函数。如果函数定义与原型不匹配,编译器将引发错误。

原型的存在使得在编译器看到函数定义之前,可以完全对给定函数的调用进行类型检查。当前的例子当然是人为设计的,以说明这一点;我们本可以交换文件中Minimum()main()出现的顺序。然而,想象一下Minimum()的定义包含在一个单独的文件中(这是更典型的情况)。在这种情况下,原型将出现在调用此函数的文件顶部(以及头文件包含),以便可以对原型进行完全的类型检查。

在上述多文件场景中,包含函数定义的文件将单独编译。然后,链接器的任务将确保当多个文件链接在一起时,函数定义和所有原型匹配,以便链接器可以解决对这种函数调用的任何引用。如果原型与函数定义不匹配,链接器将无法将代码的不同部分链接成一个编译单元。

让我们看看这个例子的输出:

5

现在我们已经了解了函数原型的基础知识,让我们看看我们如何可以向函数原型添加可选的参数名称。

在函数原型中命名参数

函数原型可以可选地包含与形式参数列表或实际参数列表中不同的名称。参数名称被编译器忽略,但通常可以增强可读性。让我们回顾一下我们之前的例子,在函数原型中添加可选的参数名称:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter02/Chp2-Ex3.cpp

#include <iostream>
using std::cout;    // preferred to: using namespace std;
using std::endl;
// function prototype with optional argument names
[[nodiscard]] int Minimum(int arg1, int arg2);

int main()
{
    int x = 5, y = 89;
    cout << Minimum(x, y) << endl;      // function call
    return 0;
}
[[nodiscard]] int Minimum(int a, int b) // fn. definition
{
    return (a < b)? a : b;  
}

这个例子几乎与前面的例子相同。然而,请注意,函数原型包含命名参数arg1arg2。这些标识符立即被编译器忽略。因此,这些命名参数不需要与函数的形式参数或实际参数匹配,并且只是可选的,仅为了提高可读性。

伴随这个例子的输出与前面的例子相同:

5

接下来,让我们通过向函数原型添加一个有用的功能来继续我们的讨论:默认值。

在函数原型中添加默认值

默认值可以在函数原型中指定。这些值将在函数调用中缺少实际参数时使用,并作为实际参数本身。默认值遵循以下标准:

  • 默认值必须在函数原型中从右到左指定,不能省略任何值。

  • 实际参数在函数调用中从左到右进行替换;因此,在原型中指定默认值的顺序是重要的。

函数原型可以全部、部分或没有任何值被默认值填充,只要默认值符合上述规范。

让我们通过一个使用默认值的示例来了解一下:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter02/Chp2-Ex4.cpp

#include <iostream>
using std::cout;    // preferred to: using namespaces std;
using std::endl;
// fn. prototype with one default value
[[nodiscard]] int Minimum(int arg1, int arg2 = 100000);  
int main()
{
    int x = 5, y = 89;
    cout << Minimum(x) << endl; // function call with only
                             // one argument (uses default)
    cout << Minimum(x, y) << endl; // no default vals used
    return 0;
}
[[nodiscard]] int Minimum(int a, int b) // fn. definition
{
    return (a < b)? a : b;  
}

在这个例子中,请注意,在 int Minimum(int arg1, int arg2 = 100000); 的函数原型中添加了一个默认值到最右边的参数。这意味着当从 main() 调用 Minimum() 时,它可以带有一个参数调用 Minimum(x),或者带有两个参数调用 Minimum(x, y)。当 Minimum() 带有一个参数被调用时,单个参数绑定到函数形式参数列表中的最左边参数,默认值绑定到下一个顺序参数。然而,当 Minimum() 带有两个参数被调用时,两个实际参数都绑定到函数的形式参数;默认值不会被使用。

下面是这个示例的输出:

5
5

现在我们已经掌握了函数原型中的默认值,让我们通过在程序的不同作用域中使用不同的默认值来扩展这个想法。

在不同作用域中使用不同的默认值进行原型设计

函数可以在不同的作用域中使用不同的默认值进行原型设计。这允许函数以通用方式构建,并通过原型在多个应用程序或代码的多个部分中进行定制。

下面是一个示例,展示了同一函数(在不同作用域)使用不同默认值的多重原型:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter02/Chp2-Ex5.cpp

#include <iostream>
using std::cout;    // preferred to: using namespace std;
using std::endl;
// standard function prototype
[[nodiscard]] int Minimum(int, int);   
void Function1(int x)
{   
    // local prototype with default value
    [[nodiscard]] int Minimum(int arg1, int arg2 = 500); 
    cout << Minimum(x) << endl; 
}
void Function2(int x)
{
    // local prototype with default value
    [[nodiscard]] int Minimum(int arg1, int arg2 = 90);  
    cout << Minimum(x) << endl; 
}

[[nodiscard]] int Minimum(int a, int b) // fn. definition
{ 
    return (a < b)? a : b;   
}
int main()
{
    Function1(30);    
    Function2(450);
    return 0;
}

在这个例子中,请注意,int Minimum(int, int); 的原型在文件顶部附近被定义。然后请注意,在 Function1() 的更局部作用域中,函数 Minimum() 被重新原型化为 int Minimum(int arg1, int arg2 = 500);,为最右边的参数指定了默认值 500。同样,在 Function2() 的作用域中,函数 Minimum() 被重新原型化为 int Minimum(int arg1, int arg2 = 90);,在右边的参数中指定了默认值 90。当在 Function1()Function2() 内部调用 Minimum() 时,每个函数作用域中的局部原型分别会被使用——每个都有自己的默认值。

以这种方式,程序的具体区域可以很容易地通过具有特定应用部分中具有意义的默认值进行定制。然而,请确保在调用函数的作用域内使用具有个性化默认值的函数重原型,以确保这种定制可以轻松地包含在非常有限的范围内。永远不要在全局作用域中用不同的默认值重原型化函数——这可能导致意外和错误的结果。

以下是对该例子的输出:

30
90

现在我们已经探讨了与单文件和多文件中的默认使用、原型中的默认值以及在不同作用域中用个性化默认值重原型化函数相关的函数原型,我们现在可以继续本章的最后一个重要主题:函数重载。

理解函数重载

C++ 允许存在两个或多个具有相似目的但参数类型或数量不同的函数,它们可以与相同的函数名称共存,这被称为函数重载。这允许进行更通用的函数调用,让编译器根据使用函数的变量(对象)的类型选择正确的函数版本。在本节中,我们将向函数重载的基本知识中添加默认值,以提供灵活性和定制。我们还将了解标准类型转换可能如何影响函数重载,以及可能出现的潜在歧义(以及如何解决这些类型的疑问)。

学习函数重载的基本知识

当存在两个或多个具有相同名称的函数时,这些类似函数之间的区别因素将是它们的签名。通过改变函数的签名,可以在同一个命名空间中存在两个或多个具有其他名称相同的函数。函数重载依赖于函数的签名如下:

  • 函数的签名指的是函数的名称,加上它的类型和参数数量。

  • 函数的返回类型不包括在其签名部分。

  • 具有相同目的的两个或多个函数可以共享相同的名称,前提是它们的签名不同。

函数的签名有助于为每个函数提供一个内部,“混淆”的名称。这种编码方案保证了每个函数在编译器内部都有唯一的表示。

让我们花几分钟时间理解一个稍微大一点的例子,这个例子将包含函数重载。为了简化解释,这个例子被分为三个部分;尽管如此,完整的程序可以在以下链接中找到:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter02/Chp2-Ex6.cpp

#include <iostream>
#include <cmath>
using std::cout;    // preferred to: using namespace std;
using std::endl;
constexpr float PI = 3.14159;
class Circle     // simple user defined type declarations
{
public:
   float radius;
   float area;
};
class Rectangle
{
public:
   float length;
   float width;
   float area;
};
void Display(Circle);     // 'overloaded' fn. prototypes
void Display(Rectangle);  // since they differ in signature

在这个示例的开始,注意我们使用#include <cmath>包含数学库,以提供对基本数学函数,如pow()的访问。接下来,注意CircleRectangle的类定义,每个类都有相关的数据成员(CircleradiusareaRectanglelengthwidtharea)。一旦定义了这些类型,就显示了两个重载的Display()函数的原型。由于两个显示函数的原型使用了用户定义的类型CircleRectangle,因此重要的是CircleRectangle之前已经被定义。现在,让我们在继续程序的下一个部分时检查main()函数的主体:

int main()
{
    Circle myCircle;
    Rectangle myRect;
    Rectangle mySquare;
    myCircle.radius = 5.0;
    myCircle.area = PI * pow(myCircle.radius, 2.0);
    myRect.length = 2.0;
    myRect.width = 4.0;
    myRect.area = myRect.length * myRect.width;
    mySquare.length = 4.0;
    mySquare.width = 4.0;
    mySquare.area = mySquare.length * mySquare.width;
    Display(myCircle);   // invoke: void display(Circle)
    Display(myRect);     // invoke: void display(Rectangle)
    Display(mySquare);
    return 0;
}

现在,在main()函数中,我们声明了一个Circle类型的变量和两个Rectangle类型的变量。然后我们使用点操作符(.)和适当的值,在main()函数中为这些变量的数据成员加载。接下来在main()函数中,有三次对Display()的调用。第一次函数调用Display(myCircle)将调用接受一个Circle作为形式参数的Display()版本,因为传递给这个函数的实际参数实际上是用户定义的类型Circle。接下来的两个函数调用Display(myRect)Display(mySquare)将调用接受一个Rectangle作为形式参数的重载版本,因为在这两个调用中传递的实际参数本身就是Rectangle类型。让我们通过检查Display()的两个函数定义来完成这个程序:

void Display (Circle c)
{
   cout << "Circle with radius " << c.radius;
   cout << " has an area of " << c.area << endl; 
}

void Display (Rectangle r)
{
   cout << "Rectangle with length " << r.length;
   cout << " and width " << r.width;
   cout << " has an area of " << r.area << endl; 
}

注意在这个示例的最后部分,定义了Display()的两个版本。其中一个函数接受一个Circle作为形式参数,而重载版本接受一个Rectangle作为其形式参数。每个函数体访问特定于其形式参数类型的成员数据,然而每个函数的整体功能相似,因为在每种情况下,都显示了一个特定的形状(CircleRectangle)。

让我们看看这个完整程序示例的输出:

Circle with radius 5 has an area of 78.5397
Rectangle with length 2 and width 4 has an area of 8
Rectangle with length 4 and width 4 has an area of 16

接下来,让我们通过了解标准类型转换如何允许一个函数被多个数据类型使用来扩展我们对函数重载的讨论。这可以使函数重载的使用更加选择性地进行。

使用标准类型转换消除过度重载

基本语言类型可以由编译器自动从一种类型转换为另一种类型。这允许语言提供比其他情况下所需更少的操作符来操作标准类型。标准类型转换还可以在保持函数参数的精确数据类型不是至关重要的情况下消除函数重载的需要。在包括赋值和操作的表达式中,标准类型之间的提升和降级通常被透明地处理,无需显式类型转换。

这里有一个示例,说明了简单的标准类型转换。此示例不包括函数重载:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter02/Chp2-Ex7.cpp

#include <iostream>
using std::cout;    // preferred to: using namespace std;
using std::endl;
int Maximum(double, double);      // function prototype

int main()
{
    int result = 0;
    int m = 6, n = 10;
    float x = 5.7, y = 9.89;

    result =  Maximum(x, y); 
    cout << "Result is: " << result << endl;
    cout << "The maximum is: " << Maximum(m, n) << endl;
    return 0;
}
int Maximum(double a, double b)  // function definition
{
    return (a > b)? a : b;
}

在此示例中,Maximum()函数接受两个双精度浮点数作为参数,并将结果作为int类型返回。首先请注意,int Maximum(double, double);在程序顶部附近进行了原型声明,并在同一文件的底部进行了定义。

现在,在main()函数中,请注意我们定义了三个int类型的变量:resultax。后两个变量分别初始化为610。我们还定义了两个浮点数并初始化:float x = 5.7, y = 9.89;。在第一次调用Maximum()函数时,我们使用xy作为实际参数。这两个浮点数被提升为双精度浮点数,并且函数按预期被调用。

这是一个标准类型转换的示例。让我们注意到int Maximum(double, double)的返回值是一个整数——不是一个双精度数。这意味着从这个函数返回的值(无论是形式参数a还是b)将是一个ab的副本,首先将其截断为整数,然后用作返回值。这个返回值被整洁地分配给在main()中声明的int类型的result。这些都是标准类型转换的例子。

接下来,使用实际参数mn调用Maximum()函数。与之前的函数调用类似,整数mn被提升为双精度,函数按预期被调用。返回值也将被截断回int类型,并将此值传递给cout以打印为整数。

此示例的输出如下:

Result is: 9
The maximum is: 10

现在我们已经了解了函数重载和标准类型转换的工作原理,让我们考察一个两种情况结合可能会产生模糊函数调用的场景。

函数重载和类型转换引起的歧义

当一个函数被调用,其形式参数和实际参数在类型上完全匹配时,关于应该调用选择中的哪个重载函数不会产生歧义——与完全匹配的函数是明显的选择。然而,当一个函数被调用且其形式参数和实际参数在类型上不同时,可能需要对实际参数执行标准类型转换。然而,存在形式参数和实际参数类型不匹配的情况,并且存在重载函数。在这些情况下,编译器可能难以选择哪个函数应该被选为最佳匹配。在这些情况下,编译器会生成一个错误,表明与函数调用本身配对的可用选择是不确定的。显式类型转换或在更局部的作用域中重新原型化所需的选择可以帮助纠正这些其他情况下可能的不确定性。

让我们回顾一个简单的函数,它展示了函数重载、标准类型转换以及潜在的不确定性:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter02/Chp2-Ex8.cpp

#include <iostream>
using std::cout;    // preferred to: using namespace std;
using std::endl;
int Maximum (int, int);   // overloaded function prototypes
float Maximum (float, float); 
int main()
{
    char a = 'A', b = 'B';
    float x = 5.7, y = 9.89;
    int m = 6, n = 10;
    cout << "The max is: " << Maximum(a, b) << endl;
    cout << "The max is: " << Maximum(x, y) << endl;
    cout << "The max is: " << Maximum(m, n) << endl;
    // The following (ambiguous) line generates a compiler 
// error - there are two equally good fn. candidates 
    // cout << "The maximum is: " << Maximum(a, y) << endl;
    // We can force a choice by using an explicit typecast
    cout << "The max is: " << 
             Maximum(static_cast<float>(a), y) << endl;
    return 0;
}
int Maximum (int arg1, int arg2)    // function definition
{
    return (arg1 > arg2)? arg1 : arg2;
}
float Maximum (float arg1, float arg2)  // overloaded fn.
{                                    
    return (arg1 > arg2)? arg1 : arg2;
}

在这个先前的简单示例中,Maximum() 的两个版本都被原型化和定义了。这些函数是重载的;注意,它们的名称相同,但它们使用的参数类型不同。还要注意,它们的返回类型不同;然而,由于返回类型不是函数签名的一部分,因此返回类型不需要匹配。

接下来,在 main() 中,声明并初始化了两个类型为 charintfloat 的变量。接下来,调用 Maximum(a, b) 并将两个 char 实际参数转换为整数(使用它们的 ASCII 等价物)以匹配此函数的 Maximum(int, int) 版本。这是与 abchar 参数类型最接近的匹配:Maximum(int, int)Maximum(float, float)。然后,调用 Maximum(x, y) 并使用两个浮点数,这个调用将正好匹配此函数的 Maximum(float, float) 版本。同样,Maximum(m, n) 将被调用,并将完美匹配此函数的 Maximum(int, int) 版本。

现在,注意下一个函数调用(这并非巧合,它是被注释掉的):Maximum(a, y)。在这里,第一个实际参数完美匹配 Maximum(int, int) 中的第一个参数,而第二个实际参数完美匹配 Maximum(float, float) 中的第二个参数。对于不匹配的参数,可以应用类型转换——但并没有!相反,这个函数调用被编译器标记为歧义函数调用,因为任一重载函数都可能是一个合适的匹配。

在代码行Maximum((float) a, y)中,注意函数调用Maximum((float) a, y)强制对第一个实际参数a进行显式类型转换,解决了调用哪个重载函数的潜在歧义。现在参数a被转换成float类型,这个函数调用很容易匹配Maximum(float, float),不再被认为是歧义的。类型转换可以是一种解决这类疯狂情况的工具。

这里是伴随我们示例的输出:

The maximum is: 66
The maximum is: 9.89
The maximum is: 10
The maximum is: 65

摘要

在本章中,我们学习了 C++中一些额外的非面向对象特性,这些特性是构建 C++面向对象特性的必要基石。这些语言需求包括使用const限定符、理解函数原型、在原型中使用默认值、函数重载、标准类型转换如何影响重载函数的选择,以及可能出现的歧义(以及如何解决)。

非常重要的是,你现在可以向前推进到下一章,我们将详细探讨使用指针进行间接寻址。你在本章积累的事实技能将帮助你更容易地导航每一章,确保你准备好轻松应对从第五章开始的 OO 概念,即探索类的细节

记住,C++是一种比大多数其他语言都有更多灰色区域的语言。你通过技能集积累的微妙差异将提高你作为 C++开发者的价值——不仅能导航和理解现有的微妙代码,而且能创建易于维护的代码。

问题

  1. 函数的签名是什么,函数的签名在 C++中与名称修饰有何关系?你认为这如何帮助编译器内部处理重载函数?

  2. 编写一个小型的 C++程序,提示用户输入有关Student的信息,并打印出这些数据。使用以下步骤编写你的代码:

    1. 使用classstruct创建一个Student数据类型。Student信息至少应包括firstNamelastNamegpa以及Student注册的currentCourse。这些信息可以存储在一个简单的类中。你可以使用char数组来表示字符串字段,因为我们还没有介绍指针,或者你可以(更推荐)使用string类型。此外,你可以在main()函数中读取这些信息,而不是创建一个单独的函数来读取数据(因为后者将需要了解指针或引用)。请勿使用全局变量(即外部变量)。

    2. 创建一个函数来打印出Student的所有数据。记住要为这个函数原型。在函数原型中使用gpa的默认值4.0。以两种方式调用这个函数:一次显式传递每个参数,一次使用默认的gpa

    3. 现在,重载 print 函数,使其能够打印出选定的数据(例如,lastNamegpa),或者使用这个函数的版本,它接受一个Student作为参数(但不能是Student的指针或引用——我们稍后会这样做)。记得要为这个函数编写原型。

    4. 使用 iostreams 进行输入输出。

第三章:间接寻址 – 指针

本章将全面介绍如何在 C++中利用指针。虽然假设您对间接寻址有一些先前的经验,但我们将从基础开始。指针是语言的一个基础且普遍的特性 – 您必须彻底理解并能够轻松地利用它。许多其他语言仅通过引用来实现间接寻址;然而,在 C++中,您必须亲自动手,理解如何正确和有效地使用指针来访问和返回堆内存。您将在其他程序员的代码中看到指针被大量使用;忽视它们的使用是没有道理的。误用指针可能会在程序中产生最难以找到的错误。在 C++中,对使用指针进行间接寻址的彻底理解是创建成功且可维护代码的必要条件。

在本章中,您还将预览智能指针的概念,这可以帮助减轻与原生指针相关的难度和潜在陷阱。尽管如此,您仍需要熟练掌握所有类型的指针,以便成功使用现有的类库或与现有代码集成或维护。

本章的目标将是构建或增强您对指针间接寻址的理解,以便您能够轻松理解和修改他人的代码,以及自己编写原创、复杂、无错误的 C++代码。

在本章中,我们将涵盖以下主要主题:

  • 指针基础,包括访问、内存分配和释放 – 对于标准类型和用户定义类型

  • 动态分配一维、二维和 N 维数组,并管理它们的内存释放

  • 指针作为函数的参数和作为函数的返回值

  • 向指针变量添加const限定符

  • 使用空指针 – 指向未指定类型的对象的指针

  • 预览智能指针以减轻典型指针使用错误

到本章结束时,你将了解如何使用new()为简单和复杂的数据类型在堆上分配内存,以及如何使用delete()标记内存以将其返回到堆管理设施。你将能够动态分配任何数据类型和任何维度的数组,并理解在应用程序中不再需要内存时释放内存的基本内存管理,以避免内存泄漏。你将能够将指针作为任何级别的间接函数的参数传递——即指向数据的指针、指向数据指针的指针,依此类推。你将了解如何以及为什么将 const 限定符与指针结合——指向数据、指向指针本身或两者。你还将了解如何声明和使用无类型的泛型指针——void 指针——以及了解它们可能有用的情况。最后,你将预览智能指针的概念,以减轻潜在的指针难题和使用错误。这些技能将有助于你在本书的下一章中成功前进。

技术要求

完整程序示例的在线代码可以在以下 GitHub URL 中找到:github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/tree/main/Chapter03。每个完整程序示例都可以在 GitHub 的相应章节标题(子目录)下找到,对应章节的文件名,后面跟着一个连字符,然后是当前章节中的示例编号。例如,本章的第一个完整程序可以在上述 GitHub 目录下的Chapter03子目录中的Chp3-Ex1.cpp文件中找到。

本章的 CiA 视频可以在以下链接查看:bit.ly/3AtBPlV

理解指针基础和内存分配

在本节中,我们将回顾指针基础,并介绍适用于指针的运算符,例如取地址运算符、解引用运算符以及new()delete()运算符。我们将使用取地址运算符&来计算现有变量的地址,相反,我们将应用解引用运算符*到指针变量上以访问变量中包含的地址。我们将看到堆上内存分配的示例,以及如何在完成使用后通过将其返回到空闲列表来标记相同内存以供潜在重用。

使用指针变量可以使我们的应用程序具有更大的灵活性。在运行时,我们可以确定我们可能需要的数据类型数量(例如在动态分配的数组中),在数据结构中组织数据以方便排序(例如在链表中),或者通过传递大型数据块的地址给函数来提高速度(而不是传递整个数据块的副本)。指针有很多用途,我们将在本章和整个课程中看到许多例子。让我们从指针基础知识开始。

回顾指针基础知识

首先,让我们回顾一下指针变量的含义。指针变量是可能包含地址的变量,该地址的内存可能包含相关数据。通常说,指针变量指向包含相关数据的地址。指针变量的值是一个地址,而不是我们想要的数据。当我们到达那个地址时,我们找到感兴趣的数据。这被称为间接寻址。总结一下,指针变量的内容是一个地址;如果你然后去那个地址,你就能找到数据。这是单级间接寻址。

指针变量可能指向非指针变量的现有内存,或者它可能指向在堆上动态分配的内存。后一种情况是最常见的情况。除非指针变量被正确初始化或分配了值,否则指针变量的内容是无意义的,并不代表一个可用的地址。一个很大的错误是假设指针变量已经被正确初始化,而实际上可能没有。让我们看看一些对指针有用的基本运算符。我们将从地址-of & 和解引用运算符 * 开始。

使用地址-of 和解引用运算符

地址-of 运算符 & 可以应用于变量以确定其在内存中的位置。解引用运算符 * 可以应用于指针变量以获取指针变量中包含的有效地址的数据值。

让我们看一个简单的例子:

int x = 10;
int *pointerToX = nullptr; // pointer variable which may 
                           // someday point to an integer
pointerToX = &x;  // assign memory loc. of x to pointerToX
cout << "x: " << x << " and *pointerToX: " << *pointerToX;

注意在之前的代码段中,我们首先声明并初始化变量 x10。接下来,我们声明 int *pointerToX = nullptr; 来表示变量 pointerToX 可能将来会指向一个整数,但它用 nullptr 初始化以确保安全。如果我们没有用 nullptr 初始化这个变量,它将是未初始化的,因此不会包含一个有效的内存地址。

在代码中向前移动到行pointerToX = &x;,我们使用取地址运算符(&)将x的内存位置赋值给pointerToX,它等待被填充为某个整数的有效地址。在这段代码的最后一行,我们打印出x*pointerToX。在这里,我们使用了解引用运算符*与变量pointerToX。解引用运算符告诉我们去变量pointerToX中包含的地址。在那个地址,我们找到整数10的数据值。

这里是这个片段作为完整程序生成的输出:

X: 10 and *pointerToX: 10

重要提示

为了提高效率,C++在应用程序启动时并不会整齐地将所有内存初始化为零,也不会确保与变量配对时内存是方便地空的,没有值。内存中简单地包含之前存储的内容;C++的内存不被认为是干净的。因为 C++中内存没有给程序员干净,所以新声明的指针变量的内容,除非正确初始化或赋值,否则不应被视为包含有效地址。

在前面的例子中,我们使用取地址运算符&来计算内存中现有整数的地址,并将我们的指针变量设置为指向那个内存。相反,让我们引入new()delete()运算符,以便我们可以利用动态分配的堆内存与指针变量一起使用。

使用 new()和 delete()运算符

运算符new()可以用来从堆中获取动态分配的内存。指针变量可以选择指向在运行时动态分配的内存,而不是指向另一个变量的现有内存。这给了我们灵活性,关于我们想要何时分配内存,以及我们可能选择有多少这样的内存块。然后,可以使用运算符delete()对一个指针变量应用,以标记我们不再需要的内存,将内存返回给堆管理设施以供应用程序稍后重用。重要的是要理解,一旦我们delete()了一个指针变量,我们就不再应该使用该变量中包含的地址作为有效地址。

让我们看看使用基本数据类型进行简单内存分配和释放的例子:

int *y = nullptr; // ptr y may someday point to an int
y = new int;   // y pts to uninit. memory allocated on heap
*y = 17;   // dereference y to load the newly allocated
           // memory with a value of 17
cout << "*y is: " << *y << endl;
delete y;  // relinquish the allocated memory
// alternative ptr declaration, mem alloc., initialization
int *z = new int(22); 
cout <<  "*z is: " << *z << endl;
delete z;  // relinquish heap memory

在上一个程序段中,我们首先使用int *y = nullptr;声明了指针变量y。在这里,y将来可能包含一个整数的地址,但与此同时,它被安全地初始化为nullptr。在下一行,我们使用y = new int;从堆中分配足够的内存来容纳一个整数,并将该地址存储在指针变量y中。接下来,使用*y = 17;解引用y并将值17存储在y指向的内存位置。在打印出*y的值后,我们决定我们完成了对y指向的内存的处理,并通过使用运算符delete()将其返回给堆管理设施。重要的是要注意,变量y仍然包含它通过调用new()获得的内存地址;然而,y不应再使用这个释放的内存。

在上一个程序段快结束时,我们交替地声明了指针变量z,为其分配了堆内存,并使用int *z = new int(22);初始化了该内存。请注意,我们同样使用delete z;来释放堆内存。

重要提示

记住,一旦内存被释放,就不应该再次解引用该指针变量;请理解,该地址可能已经被重新分配给程序其他地方的另一个变量。一种保护措施是在使用delete()释放内存后,将指针重置为nullptr

现在我们已经了解了简单数据类型的指针基础知识,让我们继续前进,分配更复杂的数据类型,并了解利用和访问用户定义数据类型成员所需的表示法。

创建和使用用户定义类型的指针

接下来,让我们来探讨如何声明用户定义类型的指针,以及如何在堆上为其分配关联的内存。为了动态分配一个用户定义类型,首先需要声明该类型的指针。然后,该指针必须被初始化或分配一个有效的内存地址——内存可以是现有变量的内存或新分配的堆内存。一旦适当的内存地址被放置在指针变量中,就可以使用->运算符来访问结构体或类的成员。或者,可以使用(*ptr).member的表示法来访问结构体或类的成员。

让我们看看一个基本示例:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter03/Chp3-Ex1.cpp

#include <iostream>
using std::cout;
using std::endl;
struct collection
{
    int x;
    float y;
};

int main()  
{
    collection *item = nullptr;   // pointer declaration 
    item = new collection;   // memory allocation 
    item->x = 9;        // use -> to access data member x
    (*item).y = 120.77; // alt. notation to access member y
    cout << (*item).x << " " << item->y << endl;
    delete item;           // relinquish memory
    return 0;
}

首先,在上述程序中,我们声明了一个名为collection的用户定义类型,具有数据成员xy。接下来,我们声明item为该类型的指针,使用collection *item = nullptr;初始化指针,以确保安全。然后,我们使用操作符new()item分配堆内存。现在,我们分别使用->操作符或(*).成员访问符号为itemxy成员赋值。在两种情况下,这种表示法意味着首先取消引用指针,然后选择适当的数据成员。使用(*).表示法非常直接——括号表明指针取消引用先发生,然后使用.(成员选择)操作符选择成员。->简写表示法表示指针取消引用后跟成员选择。在使用cout和插入操作符<<打印适当的值后,我们决定不再需要与item关联的内存,并发出delete item;来标记这段堆内存以返回到空闲列表。

让我们看看这个示例的输出:

9 120.77

让我们也看看这个示例的内存布局。使用的内存地址(9000)是任意的——只是一个可能由new()生成的示例地址。

图 3.1 – Chp3-Ex1.cpp 的内存模型

图 3.1 – Chp3-Ex1.cpp 的内存模型

现在我们已经知道了如何为用户定义类型分配和释放内存,让我们继续前进,动态分配任何数据类型的数组。

在运行时分配和释放数组

数组可以动态分配,以便在运行时确定其大小。动态分配的数组可以是任何类型,包括用户定义的类型。在运行时确定数组的大小可以节省空间,并给我们带来编程灵活性。而不是分配一个固定大小的数组,该数组包含所需的最大数量(可能浪费空间),您可以根据运行时确定的各种因素分配所需的大小。如果您需要更改数组的大小,您还有额外的灵活性来删除和重新分配数组。任何数量的维度的数组都可以动态分配。

在本节中,我们将探讨如何动态分配基本和用户定义数据类型的数组和单维或多维数组。让我们开始吧。

动态分配单维数组

单维数组可以动态分配,以便在运行时确定其大小。我们将使用指针来表示每个数组,并使用操作符new()分配所需的内存。一旦数组被分配,就可以使用标准数组表示法来访问每个数组元素。

让我们来看一个简单的例子。我们将将其分为两个部分,但是完整的程序示例可以通过以下链接找到:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter03/Chp3-Ex2.cpp

#include <iostream>
using std::cout;
using std::cin;
using std:::endl;
using std::flush;
struct collection
{
    int x;
    float y;
};

int main()
{
    int numElements = 0;
    int *intArray = nullptr;    // pointer declarations to
    collection *collectionArray = nullptr; // future arrays
    cout << "How many elements would you like? " << flush;
    cin >> numElements;
    intArray = new int[numElements]; // alloc. array bodies
    collectionArray = new collection[numElements];
    // continued …

在这个程序的第一个部分,我们首先使用 struct 声明一个用户定义的类型 collection。接下来,我们声明一个整数变量来保存我们希望提示用户输入以选择两个数组大小的元素数量。我们还使用 int *intArray; 声明一个指向整数的指针,并使用 collection *collectionArray; 声明一个指向 collection 的指针。这些声明表明,这些指针将来可能分别指向一个或多个整数,或者一个或多个 collection 类型的对象。一旦分配,这些变量将组成我们的两个数组。

在使用 cin 和提取运算符 >> 提示用户输入所需元素数量之后,我们动态分配了一个整数数组和一个相同大小的 collection 类型的数组。在这两种情况下,我们都使用了运算符 new()intArray = new int[numElements];collectionArray = new collection[numElements];。括号中的 numElements 表示为每种数据类型请求的内存块将足够大,以容纳相应数据类型的那么多连续元素。也就是说,intArray 将分配足够的内存来容纳 numElements 乘以整数所需的大小。请注意,对象的数据类型是已知的,因为指针声明中包含了将要指向的数据类型。collectionArray 将通过其相应的 new() 运算符调用提供适当的内存量。

让我们继续检查这个示例程序中剩余的代码:

    // load each array with values
    for (int i = 0; i < numElements; i++)
    {
        intArray[i] = i; // load each array w values using
        collectionArray[i].x = i;  // array notation []
        collectionArray[i].y = i + .5;
        // alternatively use ptr notation to print values
        cout << *(intArray + i) << " ";
        cout << (*(collectionArray + i)).y << endl;
    }
    delete [] intArray;     // mark memory for deletion
    delete [] collectionArray;
    return 0;
}

接下来,当我们继续使用 for 循环这个例子时,请注意,我们正在使用典型的数组表示法 [] 来访问两个数组的每个元素,尽管这些数组是动态分配的。因为 collectionArray 是用户定义类型的动态分配数组,我们必须使用 . 表示法来访问每个数组元素中的单个数据成员。虽然使用标准数组表示法使访问动态数组变得相当简单,但您也可以使用指针表示法来访问内存。

在循环内部,请注意我们使用指针表示法逐步打印intArray数组的元素和collectionArray集合的y成员。在表达式*(intArray +i)中,标识符intArray代表数组的起始地址。通过向这个地址添加i个偏移量,你现在就到达了该数组第i个元素的地址。通过使用*解引用这个复合地址,你现在将到达正确的地址以检索相关的整数数据,然后使用cout和插入操作符<<打印出来。同样,对于(*(collectionArray + i)).y,我们首先将i加到collectionArray的起始地址上,然后使用()解引用这个地址,由于这是一个用户定义的类型,我们必须使用.来选择适当的数据成员y

最后,在这个例子中,我们展示了如何使用delete()来释放我们不再需要的内存。虽然一个简单的delete intArray;语句就足以用于标准类型的动态分配数组,但我们选择使用delete [] intArray;来与用户定义类型动态分配数组删除所需的方式保持一致。也就是说,对于用户定义类型的数组,需要使用更复杂的delete [] collectionArray;语句来正确删除。在所有情况下,与每个动态分配数组相关的内存都将返回到空闲列表,然后可以在后续调用操作符new()再次分配堆内存时被重用。然而,正如我们稍后将看到的,与delete()一起使用的[]将允许在释放内存之前对用户定义类型的每个数组元素应用特殊的清理函数。此外,一致性很重要:如果你使用new()分配,则使用delete()释放内存;如果你使用new []分配,则使用delete []释放。这种一致的配对也将确保在将来程序员对上述任何操作符进行重载(即重新定义)时,程序按预期工作。

记住不要在内存被标记为删除后解引用指针变量非常重要。尽管那个地址将保留在指针变量中,直到你将指针分配新的地址(或空指针),但一旦内存被标记为删除,相关的内存可能已经被程序其他地方的后续new()调用重用。这是在使用 C++中的指针时你必须勤勉注意的许多方式之一。

伴随完整程序示例的输出如下:

How many elements would you like? 3
0 0.5
1 1.5
2 2.5

让我们再看看这个例子的内存布局。使用的内存地址(85009500)是任意的——它们是堆上可能由new()生成的示例地址。

图 3.2 – Chp3-Ex2.cpp 的内存模型

图 3.2 – Chp3-Ex2.cpp 的内存模型

接下来,让我们通过分配多维数组来继续我们关于动态分配数组的讨论。

动态分配二维数组 - 指针数组

也可以动态分配两个或更多维度的数组。对于二维数组,列维度可以动态分配,而行维度可能保持固定,或者两个维度都可以动态分配。动态分配一个或多个维度允许程序员对关于数组大小的运行时决策进行考虑。

让我们先考虑这种情况,即我们有一个固定数量的行,以及每行中不同数量的条目(这将成为列维度)。为了简单起见,我们将假设每行的条目数从行到行是相同的,但不必如此。我们可以使用指针数组来模拟一个具有固定行数和每行运行时确定的条目数(列维度)的二维数组。

让我们考虑一个示例来阐述一个列维度动态分配的二维数组:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter03/Chp3-Ex3.cpp

#include <iostream>
using std::cout;
using std::cin;
using std::endl;
using std::flush;
constexpr int NUMROWS = 5; // convention to use uppercase
                    // since value is known at compile time
int main()
{
    float *TwoDimArray[NUMROWS] = { }; // init. to nullptrs
    int numColumns = 0;
    cout << "Enter number of columns: ";
    cin >> numColumns;
    for (int i = 0; i < NUMROWS; i++)
    {
        // allocate column quantity for each row
        TwoDimArray[i] = new float [numColumns];
        // load each column entry with data
        for (int j = 0; j < numColumns; j++)
        {
            TwoDimArray[i][j] = i + j + .05;
            cout << TwoDimArray[i][j] << " ";
        }
        cout << endl;  // print newline between rows
    }
    for (int i = 0; i < NUMROWS; i++)
        delete [] TwoDimArray[i];  // del col. for each row
    return 0;
}

在这个例子中,请注意,我们最初使用float *TwoDimArray[NUMROWS];声明了一个指向浮点数的指针数组。为了安全起见,我们将每个指针初始化为nullptr。有时,从右到左阅读指针声明是有帮助的;也就是说,我们有一个大小为NUMROWS的数组,它包含指向浮点数的指针。更具体地说,我们有一个固定大小的指针数组,其中每个指针条目可以指向一个或多个连续的浮点数。每行中指向的条目数构成了列维度。

接下来,我们提示用户输入列条目的数量。在这里,我们假设每行将具有相同数量的条目(以使列维度);然而,每行可能具有不同的总条目数。通过假设每行将具有均匀的条目数,我们可以使用i来为每行分配列数量,即TwoDimArray[i] = new float [numColumns];

在使用j作为索引的嵌套循环中,我们简单地加载由外循环中指定的i行中每一列的值。任意赋值TwoDimArray[i][j] = i + j + .05;将一个有趣的价值加载到每个元素中。在以j为索引的嵌套循环中,我们也打印出i行的每一列条目。

最后,程序说明了如何释放动态分配的内存。由于内存是在一个固定数量的行循环中分配的——一次内存分配来收集构成每一行列条目的内存——释放操作将类似地进行。对于每一行,我们使用以下语句:delete [] TwoDimArray[i];

示例的输出如下:

Enter number of columns: 3
0.05 1.05 2.05
1.05 2.05 3.05
2.05 3.05 4.05
3.05 4.05 5.05
4.05 5.05 6.05

接下来,让我们看看这个例子的内存布局。就像之前的内存图一样,使用的内存地址是任意的——它们是new()可能生成的堆上的示例地址。

图 3.3 – Chp3-Ex3.cpp 的内存模型

图 3.3 – Chp3-Ex3.cpp 的内存模型

现在我们已经看到了如何利用指针数组来模拟二维数组,让我们继续看看如何使用指针的指针来模拟二维数组,这样我们就可以在运行时选择两个维度。

动态分配二维数组 – 指针的指针

为数组动态分配行和列的维度可以为程序添加必要的运行时灵活性。为了实现这种终极灵活性,可以使用指向所需数据类型的指针的指针来模拟二维数组。最初,将分配表示行数的维度。接下来,对于每一行,将分配每一行的元素数量。与使用指针数组的最后一个例子一样,每一行的元素数量(列条目)不需要在行之间大小一致。然而,为了准确模拟二维数组的概念,假设列大小将在行与行之间均匀分配。

让我们通过一个例子来说明一个二维数组,其中行和列的维度都是动态分配的:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter03/Chp3-Ex4.cpp

#include <iostream>
using std::cout;
using std::cin;
using std::endl;
using std::flush;
int main()
{
    int numRows = 0, numColumns = 0;
    float **TwoDimArray = nullptr;  // pointer to a pointer
    cout << "Enter number of rows: " << flush;
    cin >> numRows;
    TwoDimArray = new float * [numRows]; // alloc. row ptrs
    cout << "Enter number of Columns: ";
    cin >> numColumns;
    for (int i = 0; i < numRows; i++)
    {
        // allocate column quantity for each row
        TwoDimArray[i] = new float [numColumns];
        // load each column entry with data
        for (int j = 0; j < numColumns; j++)
        {
            TwoDimArray[i][j] = i + j + .05;
            cout << TwoDimArray[i][j] << " ";
        }
        cout << end;  // print newline between rows
    }
    for (i = 0; i < numRows; i++)
        delete [] TwoDimArray[i];  // del col. for each row
    delete [] TwoDimArray;  // delete allocated rows
    return 0;
}

在这个例子中,请注意我们最初使用以下方式声明了一个指向float类型的指针的指针:float **TwoDimArray;。从右到左阅读这个声明,我们看到TwoDimArray是一个指向float指针的指针。更具体地说,我们理解TwoDimArray将包含一个或多个连续指针的地址,每个指针可能指向一个或多个连续的浮点数。

现在,我们提示用户输入行条目的数量。我们随后进行分配到一组浮点指针,TwoDimArray = new float * [numRows];。这个分配创建了一个numRows数量的连续float指针。

就像上一个例子一样,我们提示用户输入每行希望有多少列。就像之前一样,在外层循环中,我们根据索引i为每一行分配列条目。在内层循环中,我们再次为我们的数组条目赋值并像之前一样打印它们。

最后,程序继续进行内存释放。就像之前一样,在循环中释放了每一行的列条目。然而,我们还需要释放动态分配的行条目数量。我们通过 delete [] TwoDimArray; 来完成这个操作。

这个程序的输出稍微灵活一些,因为我们可以在运行时输入所需的行数和列数:

Enter number of rows: 3
Enter number of columns: 4
0.05 1.05 2.05 3.05
1.05 2.05 3.05 4.05
2.05 3.05 4.05 5.05

让我们再次看看这个程序的内存模型。提醒一下,就像之前的内存图一样,使用的内存地址是任意的——它们是 new() 可能生成的堆上的示例地址。

图 3.4 – Chp3-Ex4.cpp 的内存模型

图 3.4 – Chp3-Ex4.cpp 的内存模型

现在我们已经看到了如何利用指针到指针来模拟二维数组,让我们继续看看如何使用指针到指针到指针等来模拟任意维度的数组。在 C++ 中,你可以模拟任何维度的动态分配数组,只要你能够想象出来!

动态分配 N 维数组——指针到指针到指针

在 C++ 中,你可以模拟任何维度的动态分配数组。你需要做的只是能够想象出来,声明适当的指针级别,并执行所需的内存分配(以及最终的释放)。

让我们看看你需要遵循的模式:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter03/Chp3-Ex5.cpp

#include <iostream>
using std::cout;
using std::cin;
using std::endl;
using std::flush;
int main()
{
    int dim1 = 0, dim2 = 0, dim3 = 0;
    int ***ThreeDimArray = nullptr; // 3D dyn. alloc. array
    cout << "Enter dim 1, dim 2, dim 3: ";
    cin >> dim1 >> dim2 >> dim3;
    ThreeDimArray = new int ** [dim1]; // allocate dim 1
    for (int i = 0; i < dim1; i++)
    {
        ThreeDimArray[i] = new int * [dim2]; // alloc dim 2
        for (int j = 0; j < dim2; j++)
        {
            // allocate dim 3
            ThreeDimArray[i][j] = new int [dim3];
            for (int k = 0; k < dim3; k++)
            {
               ThreeDimArray[i][j][k] = i + j + k; 
               cout << ThreeDimArray[i][j][k] << " ";
            }
            cout << endl;  // print '\n' between dimensions
        }
        cout << end;  // print '\n' between dimensions
    }
    for (int i = 0; i < dim1; i++)
    {
        for (int j = 0; j < dim2; j++)
           delete [] ThreeDimArray[i][j]; // release dim 3
        delete [] ThreeDimArray[i];  // release dim 2
    }
    delete [] ThreeDimArray;   // release dim 1
    return 0;
}

在这个例子中,请注意我们使用了三个级别的间接引用来指定变量以表示三维数组 int ***ThreeDimArray;。随后,我们为每个级别的间接引用分配所需的内存。第一次分配是 ThreeDimArray = new int ** [dim1];,它分配了维度 1 的指针集。接下来,在一个遍历 i 的循环中,并为数组第一维的每个元素,我们分配 ThreeDimArray[i] = new int * [dim2]; 以分配第二维数组中整数的指针。然后在嵌套循环中遍历 j,并为第二维的每个元素,我们分配 ThreeDimArray[i][j] = new int [dim3]; 以分配由 dim3 指定的整数本身。

与前两个例子一样,我们在内循环中初始化数组元素并打印它们的值。此时,你无疑会注意到这个程序与其前辈之间的相似之处。一个分配模式正在出现。

最后,我们将以类似但相反的方式释放三个级别的内存,这与分配级别的方式相似。我们使用嵌套循环遍历j来释放最内层级别的内存,然后是遍历i的外层循环释放内存。最后,我们通过简单的调用delete [] ThreeDimArray;来释放初始维度的内存。

此示例的输出如下:

Enter dim1, dim2, dim3: 2 4 3
0 1 2
1 2 3
2 3 4
3 4 5
1 2 3
2 3 4
3 4 5
4 5 6

现在我们已经看到了如何使用指针到指针到指针来模拟 3-D 数组,一个模式已经出现,展示了如何声明所需的级别和指针数量来模拟 N-D 数组。我们还可以看到必要的分配模式。多维数组可以变得相当大,尤其是如果你被迫使用可能需要的最大固定大小数组来模拟它们。使用指针到指针(到指针,等等)来模拟每个必要的多维数组的美丽之处在于,你可以分配一个在运行时可能确定的精确大小。为了使使用简单,可以使用[]数组符号作为指针符号的替代,以访问动态分配数组中的元素。C++有很多来自指针的灵活性。动态分配的数组展示了这种灵活性之一。

让我们现在继续我们的指针理解,并考虑它们在函数中的使用。

使用指针与函数

C++中的函数无疑会接受参数。我们在前面的章节中看到了许多示例,说明了函数原型和函数定义。现在,让我们通过将指针作为函数参数传递以及将指针作为函数的返回值来增强我们对函数的理解。

将指针作为函数参数传递

在函数调用中,从实际参数传递到形式参数默认是在栈上复制的。为了将变量的内容作为函数的参数修改,必须使用该参数的指针作为函数参数。

在 C++中,每次将实际参数传递给函数时,都会在栈上创建一个副本并将其传递给该函数。例如,如果将整数作为实际参数传递给函数,就会创建该整数的副本,然后将其传递到栈上,作为函数接收的形式参数。在函数的作用域内更改形式参数只会更改传递给函数的数据的副本。

如果我们要求能够修改函数的参数,那么就必须将所需数据的指针作为参数传递给函数。在 C++中,将指针作为实际参数时,会在栈上复制这个地址,并且地址的副本作为形式参数在函数中接收。然而,使用地址的副本,我们仍然可以到达那个地址(通过取消引用该指针)来访问所需的数据并对所需数据进行更改。

再次强调,在 C++中传递参数时,总会在栈上复制某个东西。如果你传递一个非指针变量,你将得到该数据的副本,并将其作为栈上的数据传递给函数。在该函数的作用域内对该数据进行更改仅是局部更改,并且在函数返回时不会持续存在。局部副本在函数结束时简单地从栈上弹出。然而,如果你传递一个指向函数的指针,尽管存储在指针变量中的地址仍然被复制到栈上并传递给函数,你仍然可以取消引用指针的副本来访问所需地址的实际数据。

你总是需要退一步来修改你想要修改的东西。如果你想更改标准数据类型,请传递该类型的指针。如果你想更改指针本身的价值(地址),你必须将指向该指针的指针作为参数传递给函数。记住,某物的副本被传递到函数的栈上。你无法在函数的作用域之外更改该副本。传递你想要更改的东西的地址——你仍然在传递该地址的副本,但使用它将带你到实际数据。

让我们花几分钟时间理解一个示例,说明将指针作为函数参数传递。在这里,我们将首先检查两个函数,这两个函数有助于以下完整程序示例:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter03/Chp3-Ex6.cpp

void TryToAddOne(int arg)
{
   arg++;
}
void AddOne(int *arg)
{
   (*arg)++;
}

检查前面的函数,注意TryToAddOne()函数接受一个int作为形式参数,而AddOne()函数接受一个int *作为形式参数。

TryToAddOne()中,传递给函数的整数仅仅是实际参数的副本。在形式参数列表中,该参数被称为arg。在函数体中将arg的值增加一仅是TryToAddOne()内部的局部更改。一旦函数完成,形式参数arg将从栈上弹出,调用此函数的实际参数将不会被修改。

然而,请注意AddOne()接受一个int *作为形式参数。实际整数参数的地址将被复制到栈上,并作为形式参数arg接收。使用该地址的副本,我们使用*解引用指针arg,然后在代码行中使用++递增该地址处的整数值:(*arg)++;。当这个函数完成时,实际参数将被修改,因为我们传递的是该整数的指针副本,而不是整数的副本本身。

让我们检查这个程序的其余部分:

#include <iostream>
using std::cout;
using std::endl;
void TryToAddOne(int); // function prototypes
void AddOne(int *);
int main()
{
   int x = 10, *y = nullptr;
   y = new int;    // allocate y's memory
   *y = 15;        // dereference y to assign a value
   cout << "x: " << x << " and *y: " << *y << endl;
   TryToAddOne(x);   // unsuccessful, call by value
   TryToAddOne(*y);  // still unsuccessful
   cout << "x: " << x << " and *y: " << *y << endl;
   AddOne(&x);   // successful, passing an address 
   AddOne(y);    // also successful
   cout << "x: " << x << " and *y: " << *y << endl;
   delete y;     // relinquish heap memory
   return 0;
}

注意这个程序段顶部的函数原型。它们将与之前代码段中的函数定义相匹配。现在,在main()函数中,我们声明并初始化int x = 10;并声明一个指针:int *y;。我们使用new()y分配内存,然后通过解引用指针赋值*y = 15;。我们打印出x*y的相应值作为基准。

接下来,我们调用TryToAddOne(x);然后是TryToAddOne(*y);。在这两种情况下,我们都是将整数作为实际参数传递给函数。变量x被声明为整数,而*y指的是y所指向的整数。这两个函数调用都不会导致实际参数被改变,这在我们使用cout和插入操作符<<打印各自的值时可以验证。

最后,我们调用AddOne(&x);然后是AddOne(y);。在这两种情况下,我们都是将地址的副本作为实际参数传递给函数。当然,&x是变量x的地址,所以这可行。同样,y本身也是一个地址——它被声明为一个指针变量。回想一下,在AddOne()函数内部,形式参数首先在函数体中被解引用然后递增:(*arg)++;。我们可以使用指针副本来访问实际数据。

这是完整程序示例的输出:

x: 10 and *y: 15
x: 10 and *y: 15
x: 11 and *y: 16

接下来,让我们通过使用函数作为返回值来指针使用进行讨论。

使用函数返回值作为指针的返回值

函数可以通过它们的返回语句返回数据指针。当通过函数的返回语句返回指针时,请确保所指向的内存将在函数调用完成后持续存在。不要返回指向函数中局部栈上定义的局部变量的指针。也就是说,不要返回指向函数中栈上定义的局部变量的指针。然而,返回函数内部使用new()分配的内存的指针是可以接受的。因为分配的内存将在堆上,它将在函数调用之后存在。

让我们通过一个例子来说明这些概念:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter03/Chp3-Ex7.cpp

#include <iostream>
#include <iomanip>
using std::cin;
using std::cout;
using std::endl;
using std::flush;
using std::setw;
constexpr int MAX = 20;
[[nodiscard]] char *createName();  // function prototype
int main()    
{
   char *name = nullptr;   // pointer declaration and init.
   name = createName();    // function will allocate memory
   cout << "Name: " << name << endl;
   delete [] name;  // del alloc. memory (in a diff. scope
   return 0;   // than allocated); this can be error prone!
}
[[nodiscard]] char *createName()
{
   char *temp = new char[MAX];
   cout << "Enter name: " << flush;
   cin >> setw(MAX) >> temp; // ensure no overflow of temp
   return temp;
}

在这个例子中,定义了constexpr int MAX = 20;,然后原型化了char *createName();,这表明这个函数不接受任何参数,但返回一个指向一个或多个字符的指针。

main()函数中,定义了一个局部变量:char *name;,但没有初始化。接下来,调用createName()并使用其返回值来给name赋值。请注意,name和函数的返回类型都是char *类型。

在调用createName()时,请注意,局部变量char *temp = new char[MAX];既被定义也被分配,用于在堆上使用操作符new()指向固定数量的内存。然后提示用户输入一个名字,该名字被存储在temp中。随后,局部变量tempcreateName()返回。

createName()中,重要的是temp的内存应该由堆内存组成,这样它将超出这个函数的作用域。在这里,temp中存储的地址的副本将被复制到函数返回值预留的栈区。幸运的是,这个地址指向堆内存。在main()中的name = createName();赋值将捕获这个地址并将其复制到存储在name变量中,该变量是main()的局部变量。由于在createName()中分配的内存是在堆上,所以这个内存将在函数完成后存在。

同样重要的是要注意,如果tempcreateName()中被定义为char temp[MAX];,那么temp所包含的内存将存在于栈上,并且是createName()的局部内存。一旦createName()返回到main(),这个变量的内存就会被从栈上弹出,变得不可用——即使这个地址被捕获在main()中的一个指针变量中。这是 C++中另一个潜在的指针陷阱。在从函数返回指针时,始终确保指针指向的内存存在于函数的作用域之外。

这个例子的输出如下:

Enter name: Gabrielle
Name: Gabrielle

现在我们已经了解了如何在函数参数中使用指针以及如何从函数返回值中获取指针,让我们继续前进,通过检查更多的指针细微差别来深入探讨。

使用指针的 const 限定符

const限定符可以用来以几种不同的方式限定指针。关键字const可以应用于所指向的数据,应用于指针本身,或者两者都应用。通过以这种方式使用const限定符,C++提供了在程序中保护值的手段,这些值可能被初始化但不再修改。让我们检查这些不同的场景。我们还将结合const限定指针与函数的返回值来了解这些不同场景中哪些是合理的实现。

使用指向常量对象的指针

可以指定指向常量对象的指针,以便指向的对象不能被直接修改。指向此对象的解引用指针不能在任何赋值中用作左值。左值意味着可以修改的值,并且出现在赋值的左侧。

让我们通过一个简单的例子来理解这种情况:

// const qualified str; the data pointed to will be const
const char *constData = "constant"; 
const char *moreConstData = nullptr;  
// regular strings, defined. One is loaded using strcpy()  
char *regularString = nullptr;
char *anotherRegularString = new char[8];  // sized to fit 
                                           // this string 
strcpy(anotherRegularString, "regular");
// Trying to modify data marked as const will not work
// strcpy(constData, "Can I do this? ");  // NO! 
// Trying to circumvent by having a char * point to
// a const char * also will not work
// regularString = constData; // NO! 
// But we can treat a char * more strictly by assigning to 
// const char *. It will be const from that viewpoint only
moreConstData = anotherRegularString; // Yes - can do this!

在这里,我们引入了const char *constData = "constant";。这个指针指向初始化的数据,并且可能永远不会通过这个标识符再次修改。例如,如果我们尝试使用strcpy来改变这个值,其中constData是目标字符串,编译器将发出错误。

此外,尝试通过将constData存储到相同(但不是const)类型的指针中来规避这种情况,将会生成编译器错误,例如在代码行regularString = constData;中。当然,在 C++中,如果你足够努力,你可以做任何事情,所以这里的显式类型转换将有效,但这里故意没有展示。显式类型转换仍然会生成编译器警告,以便你可以质疑这真的是你想要做的事情。当我们向前推进到面向对象的概念时,我们将介绍进一步保护数据的方法,以便消除这种规避。

在上一段代码的最后一行,注意我们将普通字符串的地址存储到const char *moreConstData中。这是允许的——你总是可以比定义时赋予它更多的尊重(只是不能更少)。这意味着当使用标识符moreConstData时,这个字符串不能被修改。然而,使用其自己的标识符,定义为char *anotherRegularString;,这个字符串可以被改变。这似乎不一致,但事实并非如此。const char *变量选择指向一个char *——在特定情况下提高了其保护。如果const char *真正想要指向一个不可变对象,它会选择指向另一个const char *变量。

接下来,让我们看看这个主题的一个变体。

使用指向对象的常量指针

对象的常量指针是一个初始化为指向特定对象的指针。这个指针永远不能被分配以指向另一个对象。这个指针本身不能在赋值中用作左值。

让我们回顾一个简单的例子:

// Define, allocate, load simple strings using strcpy()
char *regularString = new char[36]; // sized for str below
strcpy(regularString, "I am a modifiable string");
char *anotherRegularString = new char[21]; // sized for
                                           // string below
strcpy(anotherRegularString, "I am also modifiable");
// Define a const pointer to a string; must be initialized
char *const constPtrString = regularString; // Ok
// You may not modify a const pointer to point elsewhere
// constPtrString = anotherRegularString;  // No! 
// But you may change the data which you point to
strcpy(constPtrString, "I can change the value"); // Yes

在这个例子中,定义了两个常规的char *变量(regularStringanotherRegularString),并加载了字符串字面量。接下来,定义并初始化char *const constPtrString = regularString;,使其指向一个可修改的字符串。因为const修饰的是指针本身而不是指向的数据,所以指针本身必须在声明时初始化。请注意,代码行constPtrString = anotherRegularString;将生成编译器错误,因为const指针不能在赋值的左侧。然而,因为const修饰不适用于指向的数据,可以使用strcpy来修改数据的值,如strcpy(constPtrString, "I can change the value");所示。

接下来,让我们将const修饰符应用于指针及其指向的数据。

使用常量指针指向常量对象

指向常量对象的常量指针是一个指向特定对象且指向不可修改数据的指针。指针本身必须初始化为给定的对象,该对象(希望)已用适当的值初始化。对象和指针都不能被修改或用作赋值中的左值。

这里有一个例子:

// Define two regular strings and load using strcpy()
char *regularString = new char[36]; // sized for str below
strcpy(regularString, "I am a modifiable string");
char *anotherRegularString = new char[21]; // sized for
                                           // string below
strcpy(anotherRegularString, "I am also modifiable");
// Define const ptr to a const object; must be initialized
const char *const constStringandPtr = regularString; // Ok 
// Trying to change the pointer or the data is illegal
constStringandPtr = anotherRegularString; // No! Can't 
                                          // modify address
strcpy(constStringandPtr, "Nope"); // No! Can't modify data

在这个例子中,声明了两个常规的char *变量,分别是regularStringanotherRegularString。每个变量都初始化为一个字符串字面量。接下来,我们引入const char *const constStringandPtr = regularString;,这是一个const修饰的指针,它指向的数据也被视为const。请注意,这个变量必须被初始化,因为指针本身不能在后续的赋值中作为左值。你还想确保这个指针被初始化为一个有意义的值,因为指向的数据也不能被更改(如strcpy语句所示,这将生成编译器错误)。在指针及其指向的数据上结合const是一种严格的数据保护方式。

小贴士 - 解析指针声明

为了读取复杂的指针声明,从右到左读取声明通常有帮助。例如,指针声明const char *p1 = "hi!";可以解释为p1是一个指向一个或多个常量字符的指针。声明const char *const p2 = p1;可以解释为p2是一个指向一个或多个常量字符的常量指针。

最后,让我们前进,了解使用const修饰指针的内涵,这些指针作为函数参数或作为函数的返回值。

使用指向常量对象的指针作为函数参数和作为函数的返回类型

在栈上复制用户定义类型作为参数可能会消耗时间。将指针作为函数参数传递更快,同时允许在函数的作用域内修改解引用的对象。将指向常量对象的指针作为函数参数提供了针对该参数的速度和安全性。解引用的指针在函数的作用域内可能不是一个左值。同样的原则也适用于函数的返回值。对指向的数据使用常量限定符要求函数的调用者也必须将返回值存储在指向常量对象的指针中,确保对象的长期不可变性。

让我们通过一个例子来考察这些想法:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter03/Chp3-Ex8.cpp

#include <iostream>
#include <iomanip>
#include <cstring>  // we'll generally prefer std::string, 
        // however, let's understand ptr concept shown here
using std::cout;
using std::endl;
char suffix = 'A';
const char *GenId(const char *);  // function prototype
int main()    
{
    const char *newId1, *newId2;   // pointer declarations
    newId1 = GenId("Group");  // func. will allocate memory
    newId2 = GenId("Group");  
    cout << "New ids: " << newId1 << " " << newId2 << endl;
    delete [] newId1;  // delete allocated memory  
    delete [] newId2;  // caution: deleting in different 
                       // scope than allocation can 
                       // lead to potential errors
    return 0;
}
const char *GenId(const char *base)
{
    char *temp = new char[strlen(base) + 2]; 
    strcpy(temp, base);  // use base to initialize string
    temp[strlen(base)] = suffix++; // Append suffix to base
    temp[strlen(base) + 1] = '\0'; // Add null character
    return temp; // temp will be upcast to a const char *
                 // to be treated more restrictively than 
                 // it was defined
}  

在这个例子中,我们从一个全局变量开始,用于存储初始后缀,char *suffix = 'A';,以及函数的原型:const char *GenId(const char *base);。在main()函数中,我们声明了const char* newId1, *newId2;,但没有初始化,它们最终将保存由GenId()生成的 ID。

接下来,我们两次调用GenId()函数,将字符串字面量"Group"作为实际参数传递给这个函数。这个参数作为形式参数接收:const char *base。这个函数的返回值将被用来分别赋值给newId1newId2

更仔细地看,我们看到调用GenId("Group")时,将字符串字面量"Group"作为实际参数传递,它在函数定义的形式参数列表中被接收为const char *base。这意味着在使用标识符base时,这个字符串可能不会被修改。

接下来,在GenId()函数内部,我们在栈上声明了局部指针变量temp,并为temp分配了足够的堆内存,以便它指向一个字符串,以容纳由base指向的字符串加上一个额外的字符用于添加后缀,再加上一个用于终止新字符串的空字符。请注意,strlen()函数计算字符串中的字符数,不包括空字符。现在,通过使用strcpy(),将base复制到temp中。然后,使用赋值temp[strlen(base)] = suffix++;,将存储在suffix中的字母添加到由temp指向的字符串中(并且suffix递增到下一次调用此函数时的下一个字母)。请记住,在 C++中向给定字符串的末尾添加字符时,数组是从零开始的。例如,如果"Group"temp数组的 0 到 4 位置包含五个字符,那么下一个字符(来自suffix)将添加到temp的位置 5(覆盖当前的空字符)。在下一条代码行中,将空字符重新添加到由temp指向的新字符串的末尾,因为所有字符串都需要以空字符终止。请注意,虽然strcpy()会自动以空字符终止字符串,但一旦你求助于单字符替换,例如通过将后缀添加到字符串中,那么你需要自己重新添加空字符到新的整体字符串中。

最后,在这个函数中,返回temp。请注意,尽管temp被声明为char *类型,但它被返回为const char *类型。这意味着在返回到main()函数体时,字符串将以更严格的方式处理,而不是在函数体中处理。本质上,它已经被提升为const char *类型。这意味着,由于这个函数的返回值是const char *类型,因此只能捕获该函数返回值的const char *类型的指针。这是必要的,以便字符串不能以比函数GenId()的创建者所期望的更宽松的方式处理。如果newId1newId2被声明为char *类型而不是const char *类型,它们将不允许作为 l-values 来捕获GenId()函数的返回值。

main()函数的末尾,我们删除了与newId1newId2相关的内存。请注意,这些指针变量的内存是在程序的不同作用域内分配和释放的。程序员必须始终勤奋地跟踪 C++中的内存分配和释放。忘记释放内存可能导致应用程序中的内存泄漏。

下面是伴随我们示例的输出:

New ids: GroupA GroupB

现在我们已经了解了如何以及为什么需要对指针进行const限定,让我们通过考虑空指针来探讨我们可能如何以及为什么选择一个泛型指针类型。

使用未指定类型的对象指针

有时,程序员会问为什么他们不能简单地有一个泛型指针。也就是说,为什么我们必须始终声明指针最终指向的数据类型,例如 int *ptr;?C++ 当然允许我们创建没有关联类型的指针,但 C++ 那时要求程序员跟踪那些通常由他们自己完成的事情。尽管如此,我们将在本节中看到空指针为什么有用,以及程序员在使用更通用的空指针时必须承担什么。

重要的是要注意,空指针需要谨慎处理,它们的误用可能非常危险。我们将在本书的较后部分看到一种更安全的泛化类型(包括指针)的替代方案(包括指针),即在第十三章模板工作中。不过,有一些谨慎封装的技术使用 void * 的底层实现以提高效率,并配以模板的安全包装。我们将看到模板为每个需要的类型展开,有时可能导致 模板膨胀。在这些情况下,模板与底层 void * 实现的安全配对为我们提供了安全和效率。

要理解空指针,我们首先考虑为什么类型通常与指针变量相关联。通常,用指针声明类型会给 C++ 提供关于如何进行指针算术或索引到该指针类型的动态分配数组的信息。也就是说,如果我们分配了 int *ptr = new int [10];,我们就有了 10 个连续的整数。使用数组的表示法 ptr[3] = 5; 或指针算术 *(ptr + 3) = 5; 来访问这个动态分配集合中的一个元素,依赖于数据类型 int 的大小,以便 C++ 内部理解每个元素的大小以及如何从一个元素移动到下一个元素。数据类型还告诉 C++,一旦到达适当的内存地址,如何解释内存。例如,intfloat 在给定的机器上可能具有相同的存储大小,然而,int 的二进制补码内存布局与 float 的尾数、指数布局是相当不同的。C++ 如何解释给定内存的知识至关重要,而指针的数据类型正是这样做的。

然而,仍然需要有一个更通用的指针。例如,你可能希望一个指针在一种情况下指向一个整数,在另一种情况下指向一组用户定义的类型。使用 void * 允许发生这种情况。但是类型怎么办?当你取消引用空指针时会发生什么?如果 C++ 不知道从一个集合中的一个元素移动到另一个元素需要多少字节,它如何索引空指针的动态分配数组?它如何解释地址上的字节?类型是什么?

答案是,你必须始终亲自记住你指向的内容。没有与指针关联的类型,编译器无法为你做这件事。当需要解引用 void 指针时,你将负责正确地记住涉及到的最终类型,并对该指针执行适当的类型转换。

让我们来看看涉及到的机制和后勤。

创建 void 指针

可以使用 void * 来指定指向未指定类型的对象的指针。void 指针可以指向任何类型的对象。为了解引用由 void * 指向的实际内存,必须使用显式转换。在 C++ 中,也必须使用显式转换将 void * 指向的内存分配给已知类型的指针变量。确保在赋值之前解引用的数据类型相同是程序员的职责。如果程序员出错,将会有一个难以追踪的指针错误出现在代码的其他地方。

这里有一个例子:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter03/Chp3-Ex9.cpp

#include <iostream>
using std::cout;
using std::endl;
int main()
{
    void *unspecified = nullptr; // may point to any 
                                 // data type
    int *x = nullptr;
    unspecified = new int; // void ptr now points to an int
    // void * must be cast to int * before dereferencing
    *(static_cast<int *>(unspecified)) = 89;
    // let x point to the memory that unspecified points to
    x = static_cast<int *>(unspecified);
    cout << *x << " " << *(static_cast<int *>(unspecified)) 
         << endl;
    delete static_cast<int *>(unspecified);
    return 0;
}

在这个例子中,声明 void *unspecified; 创建了一个指针,将来可能指向任何数据类型的内存。声明 int *x; 声明了一个指针,将来可能指向一个或多个连续的整数。

赋值 *(static_cast<int *>(unspecified)) = 89; 首先使用显式类型转换将 unspecified 转换为 (int *),然后解引用 int * 将值 89 放入内存。重要的是要注意,在解引用 unspecified 之前必须进行此类型转换——否则,C++ 不理解如何解释 unspecified 指向的内存。还要注意,如果你不小心将 unspecified 转换为错误类型,编译器会允许你继续,因为类型转换被视为对编译器的一个 “只管做” 命令。作为程序员,记住你的 void * 指向的数据类型是你的责任。

最后,我们希望 x 指向 unspecified 指向的位置。变量 x 是一个整数,需要指向一个或多个整数。变量 unspecified 确实指向一个整数,但由于未指定类型的数据类型是 void *,我们必须使用显式类型转换来使以下赋值工作:x = static_cast<int *>(unspecified) ;。此外,从程序的角度来看,我们希望我们是正确的,并且记得 unspecified 真正指向一个 int;如果 int * 任何时候被解引用,知道正确的内存布局是很重要的。否则,我们只是强制在类型不同的指针之间进行赋值,在我们的程序中留下了一个潜伏的错误。

这里是伴随我们程序的输出:

89 89

在 C++中,void 指针有许多创造性的用法。一些技术使用void *进行通用指针操作,并将这种内部处理与顶层薄层相结合,以将数据转换为已知的数据类型。这些薄层可以通过 C++的模板功能进一步泛化。使用模板,程序员只需维护一个显式类型转换的版本,但实际上为你的需求提供了许多版本——每个实际具体数据类型需要一个。这些想法包括高级技术,但在接下来的章节中,我们将看到其中的一些,从第十三章使用模板开始。

展望智能指针的安全性

我们已经看到了许多指针的使用,它们为我们的程序增加了灵活性和效率。然而,我们也看到了指针所提供的强大功能可能带来的潜在灾难!解引用未初始化的指针可能会带我们到不存在的内存位置,这不可避免地会导致我们的程序崩溃。意外地解引用我们已标记为删除的内存同样具有破坏性——内存地址可能已经被程序其他地方的堆管理设施重新使用。当我们完成对动态分配内存的使用时,忘记删除它将导致内存泄漏。更具有挑战性的是,在一个作用域中分配内存,并期望在另一个作用域中记住删除该内存。或者,考虑当两个或更多指针指向同一块堆内存时会发生什么。哪个指针负责删除内存?这个问题在本书的多个地方都会出现,我们将看到各种解决方案。这些问题只是我们在使用指针时可能遇到的潜在地雷中的一小部分。

你可能会问,是否还有其他方法可以享有动态分配内存的好处,同时又能有一个安全网来管理其使用。幸运的是,答案是肯定的。这个概念是unique_ptrshared_ptrweak_ptr。智能指针的前提是它是一个类,用于安全地封装原始指针的使用,当外部智能指针超出作用域时,最小化处理堆内存的正确释放。

然而,为了最好地理解智能指针,我们需要了解第五章《详细探索类》,第十二章《友元和运算符重载》,以及第十三章《与模板一起工作》。在理解了这些核心的 C++ 特性之后,智能指针将是我们拥抱指针安全性的一个有意义的选项,用于我们创建的新代码。你还需要了解如何使用 C++ 中的原生指针吗?是的。在 C++ 中,你不可避免地会使用许多大量使用原生指针的类库,因此你需要了解它们的用法。此外,你可能正在集成或维护大量依赖原生指针的现有 C++ 代码。你还可以在网上查看许多 C++ 论坛或教程,那里也会不可避免地出现原生指针。

核心在于,作为 C++ 程序员,我们需要了解如何使用原生 C++ 指针,同时也要了解它们的风险、潜在的误用和陷阱。一旦我们掌握了类、运算符重载和模板,我们就可以将智能指针添加到我们的工具箱中,并明智地选择在我们的全新代码中使用它们。然而,通过理解原生 C++ 指针,我们将为任何 C++ 场景做好准备。

在这个前提下,我们将继续提高对原生 C++ 指针的熟练程度,直到我们为将这些有用的智能指针类添加到我们的工具箱中打下坚实的基础。然后,我们将详细查看每种智能指针类型。

摘要

在本章中,我们学习了围绕 C++ 中指针的许多方面。我们看到了如何使用 new() 从堆中分配内存,以及如何使用 delete() 将内存归还给堆管理设施。我们看到了使用标准类型和用户定义类型的示例。我们还理解了为什么我们可能想要动态分配数组,并看到了如何为一维、二维和 N 维进行动态分配。我们还看到了如何使用 delete[] 释放相应的内存。我们通过向函数添加指针作为参数和从函数返回值来回顾了函数。我们还学习了如何使用 const 来指定指针及其指向的数据(或两者)以及为什么你可能想要这样做。我们看到了通过引入空指针来泛化指针的一种方法。最后,我们展望了智能指针的概念。

本章中使用的所有指针技能都将自由地应用于即将到来的章节中。C++期望程序员能够熟练地使用指针。指针使语言具有极大的自由度和效率,可以充分利用大量的数据结构,并采用创造性的编程解决方案。然而,指针可以通过内存泄漏、返回不再存在的内存的指针、取消引用已删除的指针等方式,以巨大的方式向程序中引入错误。不要担心;我们将继续使用许多指针示例,以便你能够熟练地操作指针。此外,我们将在以后的编程中添加特定类型的智能指针,以便我们在从头开始构建代码时能够使用指针安全性。

最重要的是,你现在已经准备好向前迈进到第四章间接寻址 – 引用,我们将探讨使用引用的间接寻址。一旦你理解了两种间接寻址类型 – 指针和引用 – 并且可以轻松地操作它们,我们将在本书中承担核心的面向对象概念,从第五章详细探索类开始。

问题

  1. 修改并增强你的 C++程序,从第二章添加语言需求问题 2,如下:

    1. 创建一个函数ReadData(),它接受一个指向Student的指针作为参数,以便在函数内部从键盘输入firstNamelastNamegpacurrentCourseEnrolled,并将这些作为输入参数的数据存储。

    2. firstNamelastNamecurrentCourseEnrolledStudent类中修改为char *(或string),而不是使用固定大小的数组(如它们可能在第二章添加语言需求中建模的那样)。你可以使用一个固定大小的temp变量来最初捕获这些值的用户输入,然后为这些数据成员中的每一个分配适当的、相应的尺寸。请注意,使用string将是简单且最安全的方法。

    3. 如果需要,重写你在第二章添加语言需求中的解决方案中的Print()函数,使其接受Student作为Printd()的参数。

    4. 载荷Print()函数,添加一个接受const Student *作为参数的版本。哪一个更高效?为什么?

    5. main()中创建一个指向Student的指针数组,以容纳五个学生。为每个Student分配内存,为每个Student调用ReadData(),然后使用你之前的功能之一Print()每个Student。完成后,记得删除为每个Student分配的内存。

    6. 同样在 main() 中,创建一个与指向 Student 的指针数组大小相同的 void 指针数组。将 void 指针数组中的每个元素设置为指向从 Student 指针数组中对应的 Student。为 void * 数组中的每个元素调用接受 const Student * 作为参数的 Print() 版本。提示:在执行某些赋值和函数调用之前,你需要将 void * 元素转换为 Student * 类型。

  2. 编写以下包含 const 修饰符的指针声明:

    1. 编写一个指向常量对象的指针的声明。假设对象是 Student 类型。提示:从右到左阅读你的声明以验证其正确性。

    2. 编写一个指向非常量对象的常量指针的声明。再次,假设对象是 Student 类型。

    3. 编写一个指向常量对象的常量指针的声明。对象再次是 Student 类型。

  3. 为什么在先前的程序中将类型为 const Student * 的参数传递给 Print() 有意义,而传递类型为 Student * const 的参数则没有意义?

  4. 你能想到需要动态分配三维数组的编程场景吗?更多维度的动态分配数组又如何?

第四章:间接寻址 – 引用

本章将探讨如何在 C++ 中使用引用。引用通常,但不总是,可以用作间接寻址的替代品,而指针则不行。尽管您已经从上一章通过使用指针获得了间接寻址的经验,但我们仍将从基础开始,以理解 C++ 中的引用。

与指针一样,引用是您必须能够轻松利用的语言特性。许多其他语言在不需要像 C++ 那样彻底理解的情况下,使用引用进行间接寻址。正如指针一样,您将在其他程序员的代码中经常看到引用的使用。您可能会很高兴地发现,与指针相比,使用引用在编写应用程序时将提供更简洁的记法。

不幸的是,引用不能在所有需要间接寻址的情况下替代指针。因此,在 C++ 中,对使用指针和引用进行间接寻址的彻底理解是编写成功且可维护代码的必要条件。

本章的目标将是补充您对使用指针进行间接寻址的理解,并了解如何使用 C++ 引用作为替代品。理解这两种间接寻址技术将使您成为一名更好的程序员,能够轻松理解并修改他人的代码,以及自己编写原创、成熟和合格的 C++ 代码。

在本章中,我们将涵盖以下主要主题:

  • 引用基础 – 声明、初始化、访问和引用现有对象

  • 将引用用作函数的参数和返回值

  • 使用引用的 const 限定符

  • 理解底层实现,以及何时无法使用引用

到本章结束时,您将了解如何声明、初始化和访问引用;您将了解如何引用内存中的现有对象。您将能够将引用用作函数的参数,并理解它们如何作为函数的返回值使用。

您还将理解 const 限定符如何应用于引用作为变量,以及如何与函数的参数和返回类型一起使用。您将能够区分在哪些情况下可以使用引用代替指针,以及在哪些情况下它们不能作为指针的替代品。为了成功进行本书后续章节的学习,这些技能是必要的。

技术要求

完整程序示例的在线代码可以在以下 GitHub URL 中找到:github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/tree/main/Chapter04。每个完整程序示例都可以在 GitHub 的相应章节标题(子目录)下的文件中找到,该文件以章节编号开头,后面跟着一个连字符,然后是本章中的示例编号。例如,本章的第一个完整程序可以在上述 GitHub 目录下的Chapter04子目录中的Chp4-Ex1.cpp文件中找到。

本章的 CiA 视频可在以下网址查看:bit.ly/3ptaMRK

理解引用基础

在本节中,我们将回顾引用基础,并介绍适用于引用的运算符,例如引用操作符 &。我们将使用引用操作符(&)来建立对现有变量的引用。与指针变量一样,引用变量引用的是在别处定义的内存。

使用引用变量使我们能够使用比指针在间接访问内存时使用的符号更直接的符号。许多程序员都欣赏引用变量与指针变量在符号上的清晰度。然而,在幕后,内存必须始终得到适当的分配和释放;被引用的部分内存可能来自堆。程序员无疑需要处理他们整体代码中的一些指针。

我们将区分引用和指针何时可以互换,何时不能。让我们从声明和使用引用变量的基本符号开始。

声明、初始化和访问引用

让我们从引用变量的含义开始。C++中的 &。引用必须在声明时初始化,并且永远不能被分配以引用另一个对象。引用和初始化器必须是同一类型。由于引用和被引用的对象共享相同的内存,因此可以使用任一变量来修改共享内存位置的内存内容。

在幕后,引用变量可以与指针变量相提并论,因为它持有它所引用的变量的地址。与指针变量不同,引用变量的任何使用都会自动解除引用变量,以到达它所包含的地址;引用操作符 * 对于引用来说是不需要的。解除引用是自动的,并且在使用引用变量时是隐含的。

让我们看看一个说明引用基础的示例:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter04/Chp4-Ex1.cpp

#include <iostream>
using std::cout;
using std::endl;
int main()
{
    int x = 10;
    int *p = new int;   // allocate memory for ptr variable
    *p = 20;            // dereference and assign value 
    int &refInt1 = x;  // reference to an integer
    int &refInt2 = *p; // also a reference to an integer
    cout << x << " " << *p << " ";
    cout << refInt1 << " " << refInt2 << endl;
    x++;      // updates x and refInt1
    (*p)++;   // updates *p and refInt2
    cout << x << " " << *p << " ";
    cout << refInt1 << " " << refInt2 << endl;
    refInt1++;    // updates refInt1 and x
    refInt2++;    // updates refInt2 and *p
    cout << x << " " << *p << " ";
    cout << refInt1 << " " << refInt2 << endl;
    delete p;       // relinquish p's memory
    return 0;
}

在前面的例子中,我们首先声明并初始化int x = 10;,然后声明并分配int *p = new int;。然后我们将整数值20赋给*p

接下来,我们声明并初始化两个引用变量,refInt1refInt2。在第一个引用声明和初始化中,int &refInt1 = x;,我们将refInt1设置为引用变量x。从右到左阅读引用声明有助于理解。在这里,我们说的是使用x来初始化refInt1,它是一个指向整数的引用(&)。注意,初始化器x是一个整数,而refInt1被声明为指向整数的引用;它们的类型匹配。这是很重要的。如果类型不匹配,代码将无法编译。同样,声明和初始化int &refInt2 = *p;也将refInt2声明为指向整数的引用。哪一个?由p指向的那个。这就是为什么使用*解引用p以到达整数本身的原因。

现在,我们打印出x*prefInt1refInt2;我们可以验证xrefInt1具有相同的值10,而*prefInt2也具有相同的值20

接下来,使用原始变量,我们将x*p都增加一。这不仅增加了x*p的值,还增加了refInt1refInt2的值。重复打印这四个值,我们再次注意到xrefInt1的值为11,而*prefInt2的值为21

最后,我们使用引用变量来增加共享内存。我们将refInt1*refint2都增加一,这也增加了原始变量x*p的值。这是因为原始变量和对其的引用之间的内存是相同的。也就是说,引用可以被视为原始变量的别名。我们通过再次打印出这四个变量来结束程序。

下面是输出结果:

10 20 10 20
11 21 11 21
12 22 12 22

重要提示

记住,引用变量必须初始化为它将要引用的变量。引用永远不能分配给另一个变量。更准确地说,我们不能重新绑定引用到另一个实体。引用及其初始化器必须是同一类型。

现在我们已经掌握了如何声明简单的引用,让我们更全面地看看如何引用现有对象,例如用户定义的类型。

引用用户定义类型的现有对象

如果需要定义对structclass类型对象的引用,则可以通过使用.(成员选择)运算符直接访问被引用的对象。再次强调,与指针不同,在选择所需的成员之前,没有必要首先使用解引用运算符来访问被引用的对象。

让我们看看一个例子,其中我们引用一个用户定义的类型:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter04/Chp4-Ex2.cpp

#include <iostream>
using std::cout;
using std::endl;
using std::string;
class Student    // very simple class – we will add to it 
{                // in our next chapter
public:
    string name;
    float gpa;
};
int main()
{
    Student s1;
    Student &sRef = s1;  // establish a reference to s1
    s1.name = "Katje Katz";   // fill in the data
    s1.gpa = 3.75;
    cout << s1.name << " has GPA: " << s1.gpa << endl; 
    cout << sRef.name << " has GPA: " << sRef.gpa << endl; 
    sRef.name = "George Katz";  // change the data
    sRef.gpa = 3.25;
    cout << s1.name << " has GPA: " << s1.gpa << endl; 
    cout << sRef.name << " has GPA: " << sRef.gpa << endl; 
    return 0;
}

在本程序的第一个部分,我们使用 class 定义了一个用户自定义类型 Student。接下来,我们使用 Student s1; 声明了一个类型为 Student 的变量 s1。现在,我们声明并初始化了一个指向 Student 的引用 Student &sRef = s1;。在这里,我们声明 sRef 来引用一个特定的 Student,即 s1。请注意,s1Student 类型,而 sRef 的引用类型也是 Student 类型。

现在,我们通过两个简单的赋值将一些初始数据加载到 s1.names1.gpa 中。因此,这改变了 sRef 的值,因为 s1sRef 引用的是相同的内存。也就是说,sRefs1 的别名。

我们打印出 s1sRef 的各种数据成员,并注意到它们包含相同的值。

现在,我们通过赋值将新的值加载到 sRef.namesRef.gpa 中。同样,我们打印出 s1sRef 的各种数据成员,并注意到两者的值都发生了变化。再次,我们可以看到它们引用的是相同的内存。

伴随此程序的输出如下:

Katje Katz has GPA: 3.75
Katje Katz has GPA: 3.75
George Katz has GPA: 3.25
George Katz has GPA: 3.25

让我们通过考虑引用在函数中的使用来进一步理解引用的概念。

在函数中使用引用

到目前为止,我们通过使用它们为现有变量创建别名来最小化地展示了引用。相反,让我们提出一个有意义的引用用法,例如在函数调用中使用它们。我们知道大多数 C++ 函数都会接受参数,我们已经在之前的章节中看到了许多示例,说明了函数原型和函数定义。现在,让我们通过将引用作为函数参数传递,并使用引用作为函数的返回值来增强我们对函数的理解。

将引用作为函数参数传递

引用可以用作函数的参数,以实现按引用传递参数,而不是按值传递参数。引用还可以在所讨论的函数的作用域内以及在调用该函数时减少对指针符号的需求。对于引用形式的正式参数,使用对象或 .(成员选择)符号来访问 structclass 成员。

为了修改传递给函数的变量的内容,必须使用该参数的引用(或指针)作为函数参数。就像指针一样,当将引用传递给函数时,传递的是表示该引用的地址的副本。然而,在函数内部,任何使用形式参数为引用的用法都将自动和隐式地解引用,使用户可以使用对象而不是指针表示法。与传递指针变量一样,将引用变量传递给函数将允许修改该参数引用的内存。

当检查一个函数调用(除了其原型)时,不会很明显地看出传递给该函数的对象是按值传递还是按引用传递。也就是说,整个对象是否会在栈上复制,或者是否将对该对象的引用传递到栈上。这是因为当操作引用时使用了对象表示法,并且这两种情况下的函数调用将使用相同的语法。

仔细使用函数原型将解决函数定义看起来是什么样子以及其参数是对象还是对象引用的神秘。记住,函数定义可能定义在一个与该函数的任何调用都分开的文件中,并且不容易查看。注意,这种歧义不会出现在函数调用中指定的指针;根据变量的声明方式,立即可以明显看出地址是否被发送到函数。

让我们花几分钟时间理解一个示例,该示例说明了将引用作为参数传递给函数。在这里,我们将首先检查三个函数,这些函数有助于以下完整的程序示例:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter04/Chp4-Ex3.cpp

void AddOne(int &arg)   // These two fns. are overloaded
{
    arg++;
}
void AddOne(int *arg)   // Overloaded function definition
{
    (*arg)++;
}
void Display(int &arg)  // Function parameter establishes 
                       // a reference to arg
{
    cout << arg << " " << flush;
}

检查前面的函数,注意AddOne(int &arg)将一个int的引用作为形式参数,而AddOne(int *arg)将一个int的指针作为形式参数。这些函数是重载的。它们的实际参数的类型将确定调用哪个版本。

现在,让我们考虑Display(int &arg)。这个函数接受一个整数的引用。注意,在这个函数的定义中,使用的是对象(而不是指针)表示法来打印arg

现在,让我们检查这个程序的其余部分:

#include <iostream>
using std::cout;
using std::flush;
void AddOne(int &);    // function prototypes
void AddOne(int *);
void Display(int &);
int main()
{
    int x = 10, *y = nullptr;
    y = new int;    // allocate y's memory
    *y = 15;        // dereference y to assign a value
    Display(x);
    Display(*y);

    AddOne(x);    // calls ref. version (with an object) 
    AddOne(*y);   // also calls reference version 
    Display(x);   // Based on prototype, we see we are 
    Display(*y);  // passing by ref. Without prototype, 
                  // we may have guessed it was by value.
    AddOne(&x);   // calls pointer version
    AddOne(y);    // also calls pointer version
    Display(x);
    Display(*y);
    delete y;     // relinquish y's memory
    return 0;
}

注意这个程序段顶部的函数原型。它们将与代码前一段中的函数定义相匹配。现在,在 main() 函数中,我们声明并初始化 int x = 10; 并声明一个指针 int *y;。我们使用 new() 分配 y 的内存,然后通过解引用指针赋值 *y = 15;。我们使用连续的 Display() 调用来打印 x*y 的相应值作为基准。

接下来,我们调用 AddOne(x) 然后是 AddOne(*y)。变量 x 被声明为整数,*y 指向 y 所指向的整数。在这两种情况下,我们都在将整数作为实际参数传递给具有签名 void AddOne(int &); 的重载函数版本。在这两种情况下,形式参数将在函数中被更改,因为我们是通过引用传递的。我们可以在使用连续的 Display() 调用打印它们的相应值时验证这一点。注意,在函数调用 AddOne(x); 中,实际参数 x 的引用是通过函数调用时参数列表中的形式参数 arg 建立的。

相比之下,我们随后调用 AddOne(&x); 然后是 AddOne(y);。在这两种情况下,我们都在调用具有签名 void AddOne(int *); 的重载函数版本。在每种情况下,我们都在函数中将地址的副本作为实际参数传递。自然地,&x 是变量 x 的地址,所以这行得通。同样,y 本身也是一个地址——它被声明为一个指针变量。我们再次通过两次调用 Display() 验证它们的相应值是否被更改。

注意,在每次调用 Display() 时,我们传递一个 int 类型的对象。仅从函数调用本身来看,我们无法确定这个函数是否将接受一个 int 作为实际参数(这意味着值不能被更改),或者接受一个 int & 作为实际参数(这意味着值可以被修改)。这两种情况都是可能的。然而,通过查看函数原型,我们可以清楚地看到这个函数接受一个 int & 作为参数,并且由此我们可以理解参数可能被修改。这是函数原型有很多好处之一。

下面是完整程序示例的输出:

10 15 11 16 12 17

现在,让我们通过使用函数的引用作为返回值来补充我们对使用引用与函数的讨论。

使用引用作为函数的返回值

函数可以通过返回语句返回数据的引用。当我们为用户定义的类型重载运算符时,在第十二章朋友与运算符重载中,我们将看到需要通过引用返回数据的需求。在运算符重载中,使用指针从函数返回值将不会是保留运算符原始语法的选项。我们必须返回一个引用(或带有const修饰的引用);这也会使重载的运算符能够享受级联使用。此外,当我们探索 C++标准模板库时,在第十四章理解 STL 基础中,了解如何通过引用返回对象将是有用的。

当通过函数的返回语句返回引用时,请确保所引用的内存在函数调用完成后仍然存在。不要返回函数中定义在栈上的局部变量的引用;这个内存将在函数完成时从栈上弹出。

由于我们无法在函数内部返回局部变量的引用,并且返回外部变量的引用是没有意义的,您可能会问我们返回引用的数据将驻留在何处。这些数据不可避免地将在堆上。堆内存将存在于函数调用的范围之外。在大多数情况下,堆内存已经在其他地方分配;然而,在罕见的情况下,内存可能在此函数内部分配。在这种情况下,您必须记住在不再需要时释放分配的堆内存。

通过引用变量删除堆内存(与指针相比)将需要您使用地址运算符&将所需的地址传递给delete()运算符。尽管引用变量包含它们所引用的对象的地址,但引用标识符的使用始终处于解引用状态。使用引用变量删除内存的需求很少出现;我们将在第十章实现关联、聚合和组合中讨论一个有意义的(尽管很少见)示例。

重要提示

以下示例说明了如何从函数中返回引用的语法,您将在我们重载运算符以允许其级联使用时使用它,例如。然而,不建议使用引用返回新分配的堆内存(在大多数情况下,堆内存已经在其他地方分配)。这是一个常见的约定,使用引用向其他程序员发出信号,表明该变量不需要内存管理。尽管如此,在现有代码中可能会看到通过引用进行此类删除的罕见场景(如上述与关联的罕见使用),因此了解如何进行这种罕见的删除是有用的。

让我们通过一个示例来展示将引用作为函数返回值的机制:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter04/Chp4-Ex4.cpp

#include <iostream>
using std::cout;
using std::endl;
int &CreateId();  // function prototype

int main()    
{
    int &id1 = CreateId();  // reference established
    int &id2 = CreateId();
    cout << "Id1: " << id1 << " Id2: " << id2 << endl;
    delete &id1; // Here, '&' is address-of, not reference
    delete &id2; // to calculate address to pass delete()
    return 0;  // It is unusual to delete in fashion shown,
}          // using the addr. of a ref. Also, deleting in 
           // a diff. scope than alloc. can be error prone
int &CreateId()   // Function returns a reference to an int
{
    static int count = 100;  // initialize with first id 
    int *memory = new int;
    *memory = count++;  // use count as id, then increment
    return *memory;
}

在此示例中,我们看到int &CreateId();在程序顶部进行了原型声明。这告诉我们CreateId()将返回一个整数的引用。返回值必须用于初始化类型为int &的变量。

在程序底部,我们看到CreateId()函数的定义。请注意,此函数首先声明了一个static计数器,它被初始化一次为100。因为这个局部变量是static的,它将在函数调用之间保留其值。然后我们在几行之后将这个计数器增加一。静态变量count将作为生成唯一 ID 的基础。

接下来,在CreateId()中,我们在堆上为整数分配空间,并使用局部变量memory指向它。然后我们将*memory加载为count的值,然后增加count以便下次进入此函数时使用。然后我们使用*memory作为此函数的返回值。请注意,*memory是一个整数(由变量memory在堆上指向的整数)。当我们从函数返回它时,它作为对该整数的引用返回。当从函数返回引用时,始终确保所引用的内存存在于函数的作用域之外。

现在,让我们看看我们的main()函数。在这里,我们用以下函数调用和初始化的返回值初始化引用变量id1int &id1 = CreateId();。请注意,引用id1必须在声明时初始化,我们通过上述代码满足了这一要求。

我们用id2重复此过程,用CreateId()的返回值初始化这个引用。然后我们打印id1id2。通过打印id1id2,你可以看到每个 ID 变量都有自己的内存并保持自己的数据值。

接下来,我们必须记住释放CreateId()代表我们分配的内存。我们必须使用delete()运算符。等等,delete()运算符期望一个指向将被删除内存的指针。变量id1id2都是引用,不是指针。确实,它们各自包含一个地址,因为每个都是作为指针实现的,但任何使用它们各自标识符的操作总是处于解引用状态。为了解决这个问题,我们在调用delete()之前简单地取引用变量id1id2的地址,例如delete &id1;。通过引用变量删除内存的情况很少见,但现在你知道了如果需要这样做该如何操作。

此示例的输出如下:

Id1: 100 Id2: 101

现在我们已经了解了如何在函数参数中使用引用以及如何从函数返回引用,让我们进一步探讨引用的细微差别。

使用 const 关键字与引用

const 关键字可以用来限定引用初始化或引用的数据。我们还可以使用 const 限定的引用作为函数的参数和函数的返回值。

重要的是要理解在 C++ 中,引用被实现为一个常量指针。也就是说,引用变量中包含的地址是一个固定的地址。这解释了为什么引用变量必须初始化为它将要引用的对象,并且不能通过赋值来更新。这也解释了为什么对引用本身进行常量限定(而不仅仅是它引用的数据)没有意义。这种 const 限定的变体已经隐含在其底层实现中。

让我们通过使用 const 与引用的各种场景来看看这些。

使用常量对象引用

const 关键字可以用来表明引用初始化的数据是不可修改的。以这种方式,别名始终指向一个固定的内存块,该变量的值不能通过别名本身来更改。一旦引用被指定为常量,就暗示了引用及其值都不能更改。再次强调,由于引用本身作为常量限定指针的底层实现,引用本身不能更改。const 限定的引用不能用作任何赋值中的 左值

注意

回想一下,左值 是一个可以修改的值,它出现在赋值语句的左侧。

让我们通过一个简单的例子来了解这种情况:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter04/Chp4-Ex5.cpp

#include <iostream>
using std::cout;
using std::endl;
int main()
{
   int x = 5;
   const int &refInt = x;
   cout << x << " " << refInt << endl;
   // refInt = 6;  // Illegal -- refInt is const 
   x = 7;   // we can inadvertently change refInt
   cout << x << " " << refInt << endl;
   return 0;
}

在上一个示例中,请注意我们声明了 int x = 5; 然后通过以下声明建立对该整数的常量引用:const int &refInt = x;。接下来,我们打印出这两个值作为基准,并注意到它们是相同的。这是有道理的;它们引用了相同的整数内存。

接下来,在注释掉的代码片段 //refInt = 6; 中,我们尝试修改引用所引用的数据。因为 refInt 被标记为 const,这是非法的;这就是为什么我们注释掉了这一行代码。

然而,在下一行代码中,我们将 x 的值赋为 7。由于 refInt 引用了相同的内存,其值也将被修改。等等,refInt 不是常量吗?是的,通过将 refInt 标记为 const,我们表明其值不会通过 refInt 这个标识符来修改。这个内存仍然可以通过 x 来修改。

但是,这难道不是一个问题吗?不,如果refInt确实想要引用不可修改的对象,它可以用const int而不是int来初始化自己。这个微妙之处是 C++中需要记住的,这样你就可以编写出符合你意图的代码,理解每个选择的意义和后果。

本例的输出如下:

5 5
7 7

接下来,让我们看看const修饰符主题的一个变体。

使用指向常量对象的指针作为函数参数,以及作为函数的返回类型

使用const修饰符作为函数参数不仅允许通过引用传递参数的速度,还允许通过值传递参数的安全性。这是 C++中的一个有用特性。

一个以对象引用作为参数的函数通常比一个以对象副本作为参数的函数开销更小。这最明显地发生在对象类型本应复制到栈上且很大时。将引用作为形式参数传递更快,同时允许在函数的作用域内修改实际参数。将常量对象的引用作为函数参数提供了对所讨论参数的速度和安全性。在参数列表中修饰为const的引用在相关函数的作用域内可能不是*l-value*

const修饰的引用对函数的返回值也有同样的好处。对引用的数据进行常量修饰坚持要求函数的调用者必须也将返回值存储在常量对象的引用中,确保对象不会被修改。

让我们看看一个例子:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter04/Chp4-Ex6.cpp

#include <iostream>
using std::cout;
using std::cin;
using std::endl;
struct collection
{
    int x;
    float y;
};
void Update(collection &);   // function prototypes
void Print(const collection &);
int main()
{
    collection collect1, *collect2 = nullptr;
    collect2 = new collection;  // allocate mem. from heap
    Update(collect1);  // a ref to the object is passed
    Update(*collect2); // same here: *collect2 is an object
    Print(collect1);  
    Print(*collect2);
    delete collect2;   // delete heap memory
    return 0;
}
void Update(collection &c)
{
    cout << "Enter <int> and <float> members: ";
    cin >> c.x >> c.y;
}

void Print(const collection &c)
{
    cout << "x member: " << c.x;
    cout << "   y member: " << c.y << endl;
}

在这个例子中,我们首先定义了一个简单的struct collection,其中包含数据成员xy。接下来,我们原型化了Update(collection &);Print(const collection &);。请注意,Print()的常量修饰符指定了被引用的数据作为输入参数。这意味着这个函数将享受通过引用传递参数的速度,以及通过值传递参数的安全性。

注意,在程序末尾,我们看到了Update()Print()的定义。两个函数都接受引用作为参数,然而,Print()的参数是常量修饰的:void Print(const collection &);。请注意,两个函数都在函数体内使用.(成员选择)运算符来访问相关数据成员。

main() 中,我们声明了两个变量,collect1 类型为 collection,以及 collect2,它是一个指向 collection 的指针(其内存随后被分配)。我们对 collect1*collect2 都调用了 Update(),在每种情况下,都向 Update() 函数传递了适用对象的引用。在 collect2 的情况下,由于它是一个指针变量,在调用此函数之前必须首先取消引用 *collect2 以到达被引用的对象。

最后,在 main() 函数中,我们依次对 collect1*collect2 调用 Print()。在这里,Print() 将引用每个作为形式参数的对象作为常量合格引用数据,确保在 Print() 函数的作用域内不可能修改任何输入参数。

下面是伴随我们示例的输出:

Enter x and y members: 33 23.77
Enter x and y members: 10 12.11
x member: 33   y member: 23.77
x member: 10   y member: 12.11

现在我们已经了解了何时使用常量合格引用是有用的,让我们看看何时可以使用引用代替指针,以及何时不能。

理解底层实现和限制

引用可以简化间接引用所需的符号。然而,有些情况下引用根本不能取代指针。为了理解这些情况,回顾 C++ 中引用的底层实现是有用的。

引用被实现为常量指针,因此它们必须被初始化。一旦初始化,引用就不能指向不同的对象(尽管被引用的对象的值可以改变)。

为了理解实现,让我们考虑一个示例引用声明:int &intVar = x;。从实现的角度来看,这就像前面的变量声明被改为 int *const intVar = &x;。请注意,初始化左侧的 & 符号表示引用的意义,而初始化或赋值右侧的 & 符号表示取地址。这两个声明说明了引用的定义与其底层实现之间的关系。

尽管引用被实现为常量指针,但引用变量的使用就像底层常量指针已被取消引用一样。因此,你不能用 nullptr 初始化引用——不仅 nullptr 不能被取消引用,而且由于引用只能初始化而不能重置,就会失去将引用变量设置为指向有意义对象的机遇。这也适用于指针的引用。

接下来,让我们了解在哪些情况下我们不能使用引用。

理解何时必须使用指针而不是引用

基于引用的底层实现(作为const指针),大多数引用使用的限制都是有意义的。例如,引用的引用通常是不允许的;每个间接级别都需要预先初始化,这通常需要多个步骤,例如使用指针。然而,我们将在第十五章中看到&&),测试类和组件,我们将检查各种移动操作。引用数组也是不允许的(每个元素都需要立即初始化);然而,指针数组始终是一个选项。此外,不允许指向引用的指针;但是,允许引用指针(以及指针的指针)。

让我们看看一个有趣的允许引用案例的机制,我们尚未探索:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter04/Chp4-Ex7.cpp

#include <iostream>   
using std::cout;
using std::endl;
int main()
{
    int *ptr = new int;
    *ptr = 20;
    int *&refPtr = ptr;  // establish a reference to a ptr
    cout << *ptr << " " << *refPtr << endl; 
    delete ptr;
    return 0;
}

在这个例子中,我们声明int *ptr;然后为ptr分配内存(合并在一行中)。然后我们将值20赋给*p

接下来,我们声明int *&refPtr = ptr;,这是一个指向int类型指针的引用。有助于从右到左阅读声明。因此,我们使用ptr来初始化refPtr,它是指向int的指针的引用。在这种情况下,两种类型匹配;ptrint的指针,所以refPtr也必须引用一个指向int的指针。然后我们打印出*ptr*refPtr的值,可以看到它们是相同的。

这里是伴随我们程序的输出:

20 20

通过这个例子,我们看到了引用的另一种有趣用途。我们还理解了使用引用的限制,所有这些限制都是由它们的底层实现驱动的。

摘要

在本章中,我们学习了 C++引用的众多方面。我们花时间理解了引用的基础,例如将引用变量声明和初始化为现有对象,以及如何访问基本和用户定义类型的引用组件。

我们看到了如何以有意义的方式在函数中使用引用,无论是作为输入参数还是作为返回值。我们还看到了何时合理地将const限定符应用于引用,以及看到了如何将此概念与函数的参数和返回值相结合。最后,我们看到了引用的底层实现。这有助于解释引用包含的一些限制,以及理解哪些间接寻址的情况需要使用指针而不是引用。

与指针一样,本章中使用的所有关于引用的技能将在接下来的章节中自由使用。C++允许程序员使用引用来更方便地实现间接寻址;然而,程序员应预期能够相对容易地使用引用进行间接寻址。

最后,你现在可以向前推进到第五章详细探索类,其中我们将开始 C++的面向对象特性。这是我们一直在等待的;让我们开始吧!

问题

  1. 修改并增强你的 C++程序,从第三章间接寻址 – 指针问题 1,如下进行:

    1. 重载你的ReadData()函数,添加一个接受Student &参数的版本,以便在函数内部从键盘输入firstNamelastNamecurrentCourseEnrolledgpa

    2. 将你之前解决方案中的Print()函数替换为接受一个const Student &作为参数的函数。

    3. main()中创建Student类型和Student *类型的变量。现在,调用ReadData()Print()的各种版本。指针变量是否必须调用接受指针的这些函数版本,非指针变量是否必须调用接受引用的这些函数版本?为什么或为什么不?

第二部分:在 C++中实现面向对象的概念

本部分的目标是理解如何使用 C++语言特性和经过验证的编程技术来实现 OO 设计。C++可以用于许多编码范式;程序员必须努力在 C++中以 OO 方式编程(这不是自动的)。这是本书最大的章节,因为理解如何将语言特性和实现技术映射到 OO 概念是至关重要的。

本节的第一章详细探讨了类,从描述 OO 概念中的封装和信息隐藏开始。深入探讨了语言特性,如成员函数、this指针、详细访问区域、构造函数(包括拷贝构造函数、成员初始化列表和类内初始化)、析构函数、成员函数的限定符(conststaticinline),以及数据成员的限定符(conststatic)。

本节下一章探讨单继承的基本概念,使用 OO 概念中的泛化和特化,详细介绍了通过成员初始化列表继承的构造函数、构造和析构的顺序,以及理解继承的访问区域。探讨了最终类。本章通过探索公有与保护以及私有基类,以及这些语言特性如何改变继承的 OO 意义来进一步深入。

下一章深入探讨了面向对象的泛型概念,包括对这一概念的理解以及如何在 C++中使用虚函数实现。探讨了virtualoverridefinal关键字。检查了将操作动态绑定到特定方法。通过探索虚函数表来解释运行时绑定。

下一章详细解释了抽象类,将面向对象(OO)概念与其使用纯虚函数的实现相结合。介绍了接口的 OO 概念(在 C++中不是显式定义的),并回顾了其实施方法。通过继承层次结构的向上和向下转换完成本章内容。

下一章探讨了多重继承及其可能引发的问题。详细介绍了虚拟基类以及用于确定多重继承是否是特定场景的最佳设计的 OO 概念——区分器。如果存在其他可能的设计。

本节最后一章介绍了关联、聚合和组合的概念,以及如何使用指针或引用、指针集或内嵌对象来实现这些常见的对象关系。

本部分包括以下章节:

  • 第五章详细探索类

  • 第六章使用单继承实现层次结构

  • 第七章通过多态利用动态绑定

  • 第八章掌握抽象类

  • 第九章探索多重继承

  • 第十章实现关联、聚合和组合

第二部分:在 C++中实现面向对象的概念

第五章:详细探索类

本章将开始我们的 C++面向对象编程(OOP)之旅。我们将首先介绍面向对象(OO)概念,然后进一步理解这些概念如何在 C++中实现。很多时候,实现 OOP 理念将通过直接语言支持,如本章中的特性。有时,我们还将利用各种编程技术来实现面向对象的概念。这些技术将在后续章节中介绍。在所有情况下,理解面向对象的概念以及这些概念如何与精心设计的理念相关联,然后清楚地理解如何用健壮的代码实现这些设计,这一点非常重要。

本章将详细阐述 C++类的高级使用。除了基础知识之外,还将详细说明细微特性和细微差别。本章的目标是让你理解面向对象的概念,并开始从面向对象编程的角度思考。拥抱核心面向对象理念,如封装和信息隐藏,将使你编写的代码更容易维护,并使你更容易修改他人的代码。

在本章中,我们将涵盖以下主要主题:

  • 定义面向对象术语和概念 - 对象、类、实例、封装和信息隐藏

  • 应用类和成员函数的基本知识

  • 检查成员函数内部结构;this指针

  • 使用访问标签和访问区域

  • 理解构造函数 - 默认构造函数、重载构造函数、拷贝构造函数、转换构造函数和类内初始化器

  • 理解析构函数及其正确使用

  • 将限定符应用于数据成员和成员函数 - inlineconststatic

到本章结束时,你将理解适用于类的核心面向对象术语,以及关键 OO 理念如封装和信息隐藏如何导致软件更容易维护。

你还将欣赏 C++如何提供内置语言特性来支持面向对象编程。你将精通成员函数的使用,并通过this指针理解其底层实现。你将了解如何正确使用访问标签和访问区域来促进封装和信息隐藏。

你将了解构造函数如何用于初始化对象,以及从基本到典型(重载)再到拷贝构造函数,甚至转换构造函数的多种构造函数类型。同样,你将了解如何在对象存在结束时之前正确使用析构函数。

你还将了解如何将限定符,如conststaticinline应用于成员函数以支持面向对象概念或效率。同样,你将了解如何将限定符,如conststatic应用于数据成员以进一步支持 OO 理念。

C++可以用作面向对象编程语言,但这不是自动的。要做到这一点,你必须理解 OO 概念、意识形态和语言特性,这将使你能够支持这一努力。让我们通过理解面向对象 C++程序中的核心和基本构建块开始我们的追求,即 C++类。

技术要求

完整程序示例的在线代码可以在以下 GitHub URL 中找到:github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/tree/main/Chapter05。每个完整程序示例都可以在 GitHub 中找到,位于适当的章节标题(子目录)下的文件中,该文件以章节编号开头,后面跟着一个连字符,然后是本章中的示例编号。例如,本章的第一个完整程序可以在上述 GitHub 目录下的Chapter05子目录中找到,文件名为Chp5-Ex1.cpp

本章的 CiA 视频可以在以下链接查看:bit.ly/3KaiQ39

介绍面向对象术语和概念

在本节中,我们将介绍核心的面向对象概念以及伴随这些关键思想的适用术语。尽管在本章中会出现新术语,但我们将从本节开始旅程所必需的基本术语开始。

让我们从基本的面向对象术语开始。

理解面向对象术语

我们将首先介绍基本的面向对象术语,然后随着我们引入新的概念,我们将扩展术语以包括 C++特定的术语。

术语“对象”、“类”和“实例”都是重要且相关的术语,我们可以从这些定义开始。一个对象体现了一组有意义的特性和行为。对象可以被操作,可以接收行为的动作或后果。对象可能经历变换,并且可以随时间重复改变。对象可以与其他对象交互。

术语“对象”有时用来描述类似项目的分组蓝图。术语可以与这种对象的使用方式互换。术语“对象”也可能(更常见的是)用来描述这种分组中的特定项目。术语实例可以与这种对象的意义互换。使用的上下文通常会使清楚哪种“对象”的含义正在应用。为了避免潜在的混淆,建议优先使用术语“类”和“实例”。

让我们考虑一些示例,使用上述术语:

图片

对象也有组件。类的特性被称为属性。类的行为被称为操作。行为或操作的特定实现被称为其方法。换句话说,方法就是操作的实施方式,或者定义功能的代码主体,而操作是函数的原型或使用协议。

让我们考虑一些使用上述术语的高级示例:

图片

类的每个实例可能都有其属性的不同值。例如:

图片

现在我们已经掌握了基本的 OO 术语,让我们继续探讨与本章相关的其他重要面向对象概念。

理解面向对象概念

与本章相关的关键面向对象概念是封装信息隐藏。将这些相互关联的理念纳入你的设计中,将为编写易于修改和维护的程序奠定基础。

将有意义的特性(属性)和行为(操作)分组在一起,这些行为操作这些属性,并捆绑成一个单一单元,这被称为封装。在 C++中,我们通常将这些项目组合成一个类。每个类实例的接口是通过模拟与每个类相关的行为的操作来实现的。这些操作还可以通过改变其属性的值来修改对象的内部状态。在类中将属性隐藏起来,并提供操作这些细节的接口,使我们探索支持性的概念信息隐藏

信息隐藏指的是将执行操作的具体细节抽象成一个类方法的过程。也就是说,用户只需要了解要利用的操作及其整体目的;实现细节隐藏在方法(函数的主体)中。以这种方式,改变底层实现(方法)不会改变操作的接口。信息隐藏还可以指隐藏类的属性的底层实现。当我们介绍访问区域时,我们将进一步探讨这一点。信息隐藏是实现类适当封装的一种手段。一个适当封装的类将能够实现适当的类抽象,从而支持面向对象的设计。

面向对象系统天生更容易维护,因为类允许快速进行升级和修改,而不会因为封装和信息隐藏而对整个系统产生影响。

理解类和成员函数的基本知识

C++的是 C++中的基本构建块,允许程序员指定用户定义的类型,封装相关的数据和行为。C++类的定义将包含属性、操作,有时还有方法。C++类支持封装。

创建一个类类型的变量被称为实例化。在 C++中,类中的属性被称为数据成员。类中的操作被称为 C++中的成员函数,并用于建模行为。在面向对象术语中,一个操作意味着一个函数的签名,或其原型(声明),而方法意味着其底层实现或函数体(定义)。在某些面向对象的语言中,术语方法被更宽松地使用,根据使用上下文来表示操作或其方法。在 C++中,术语数据成员成员函数最常被使用。

成员函数的原型必须放在类定义中。通常,成员函数的定义放在类定义之外。然后使用作用域解析运算符::将给定的成员函数定义与它是其成员的类关联起来。点.或箭头->表示法用于访问所有类成员,包括成员函数,具体取决于我们是通过实例还是通过实例的指针来访问成员。

C++结构也可以用来封装数据和它们相关的行为。C++的struct可以做 C++的class能做的任何事情;事实上,在 C++中,class是用struct实现的。尽管结构和类可能表现相同(除了默认可见性之外),但类更常用于建模对象、对象类型之间的关系以及实现面向对象系统。

让我们来看一个简单的例子,其中我们实例化一个class和一个struct,每个都有成员函数,以便进行比较。我们将把这个例子分成几个部分。完整的程序示例可以在 GitHub 仓库中找到:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter05/Chp5-Ex1.cpp

#include <iostream>
using std::cout;    // preferred to: using namespace std;
using std::endl;
using std::string;
struct student
{ 
    string name;
    float gpa;
    void Initialize(string, float);  // fn. prototype
    void Print();
};
class University
{
public:
    string name;
    int numStudents;
    void Initialize(string, int);   // fn. prototype
    void Print();
};

在前面的例子中,我们首先使用struct定义了一个student类型,使用class定义了一个University类型。请注意,按照惯例,使用结构创建的用户定义类型不使用大写字母,而使用类创建的用户定义类型以大写字母开头。此外,请注意,class定义在其定义的开始处需要标签public:。我们将在本章后面探讨这个标签的使用;然而,目前,public标签存在是为了使这个class具有与struct相同的成员默认可见性。

classstruct定义中,注意Initialize()Print()函数的原型。我们将在下一个程序段中使用作用域解析运算符::将这些原型与成员函数定义联系起来。

让我们检查各种成员函数的定义:

void student::Initialize(string n, float avg)
{ 
    name = n;    // simple assignment
    gpa = avg;   // we'll see preferred init. shortly
}
void student::Print()
{ 
    cout << name << " GPA: " << gpa << endl;
}
void University::Initialize(string n, int num)
{ 
    name = n;           // simple assignment; we will see
    numStudents = num;  // preferred initialization shortly
} 
void University::Print()
{ 
    cout << name << " Enrollment: " << numStudents << endl;
}

现在,让我们回顾每个用户定义类型的各种成员函数定义。void student::Initialize(string, float)void student::Print()void University::Initialize(string, int)void University::Print() 的定义在前面片段中依次出现。注意解析域运算符 :: 如何使我们能够将相关的函数定义与它是成员的 classstruct 关联起来。

此外,请注意,在每一个 Initialize() 成员函数中,输入参数被用作值来加载特定类或结构类型特定实例的相关数据成员。例如,在 void University::Initialize(string n, int num) 函数定义中,输入参数 num 被用来初始化特定 University 实例的 numStudents

注意

解析域运算符 :: 将成员函数定义与它们所属的类(或结构)关联起来。

让我们通过考虑这个例子中的 main() 来看看成员函数是如何被调用的:

int main()
{ 
    student s1;  // instantiate a student (struct instance)
    s1.Initialize("Gabby Doone", 4.0);
    s1.Print();
    University u1;  // instantiate a University (class)
    u1.Initialize("GWU", 25600);
    u1.Print();
    University *u2;         // pointer declaration
    u2 = new University();  // instantiation with new()
    u2->Initialize("UMD", 40500);  
    u2->Print();  // or alternatively: (*u2).Print();
    delete u2;  
    return 0;
}

在这里,在 main() 中,我们简单地定义了一个 student 类型的变量 s1 和一个 University 类型的变量 u1。在面向对象术语中,更倾向于说 s1student 的一个实例,而 u1University 的一个实例。实例化发生在为对象分配内存时。因此,使用以下方式声明指针变量 u2University *u2; 并不会实例化一个 University;它仅仅声明了一个指向可能未来实例的指针。相反,在下一行,u2 = new University();,我们通过分配内存来实例化一个 University

对于每个实例,我们通过调用它们各自的 Initialize() 成员函数来初始化它们的数据成员,例如 s1.Initialize("Gabby Doone", 4.0);u1.Initialize("UMD", 4500);。然后我们通过每个相应的实例调用 Print(),例如 u2->Print();。回想一下,u2->Print(); 也可以写成 (*u2).Print();,这更容易让我们记住这里的实例是 *u2,而 u2 是对该实例的指针。

注意,当我们通过 s1 调用 Initialize() 时,我们调用 student::Initialize(),因为 s1student 类型,并且我们在该函数体内初始化 s1 的数据成员。同样,当我们通过 u1*u2 调用 Print() 时,我们调用 University::Print(),因为 u1*u2University 类型,并且随后我们打印出特定大学的成员数据。

由于实例 u1 是在堆上动态分配的,我们负责在 main() 函数的末尾使用 delete() 释放其内存。

伴随此程序的输出如下:

Gabby Doone GPA: 4.4
GWU Enrollment: 25600
UMD Enrollment: 40500

现在我们正在创建带有其相关成员函数定义的类定义,了解开发者通常如何在文件中组织代码是很重要的。通常,一个类会被分成一个头文件(.h),其中包含类定义,和一个源代码文件(.cpp),该文件包含头文件,然后跟随成员函数的定义。例如,名为 University 的类将有一个 University.h 头文件和一个 University.cpp 源代码文件。

现在,让我们通过检查 "this" 指针来进一步了解成员函数的工作细节。

检查成员函数内部;"this" 指针

到目前为止,我们已经注意到成员函数是通过对象调用的。我们注意到,在成员函数的作用域内,可能被使用的是调用该函数的特定对象的数据成员(以及其他成员函数)(除了任何输入参数)。唉,这是如何以及为什么这样工作的呢?

结果表明,大多数情况下,成员函数是通过对象调用的。每当以这种方式调用成员函数时,该成员函数都会接收到一个指向调用该函数的实例的指针。然后,调用该函数的对象的指针作为隐式第一个参数传递给函数。这个指针的名称是 this

虽然在各个成员函数的定义中可能会明确地引用 "this" 指针,但通常并不这样做。即使没有明确使用,函数作用域内使用的数据成员属于 this,即指向调用该函数的对象的指针。

让我们看看一个完整的程序示例。尽管示例被分成了几个部分,但完整的程序可以在以下 GitHub 位置找到:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter05/Chp5-Ex2.cpp

#include <iostream>
#include <cstring> // though we'll prefer std::string, one
                   // pointer data member will illustrate 
                   // important concepts
using std::cout;   // preferred to: using namespace std;
using std::endl;
using std::string;
class Student
{
// for now, let's put everything public access region
public:  
    string firstName;  // data members
    string lastName;
    char middleInitial;
    float gpa;
    char *currentCourse;  // ptr to illustrate key concepts 
    // member function prototypes
    void Initialize(string, string, char, float,
                    const char *);
    void Print();
};

在程序的第一个部分,我们定义了一个名为 Student 的类,它包含多种数据成员和两个成员函数原型。目前,我们将所有内容放置在 public 访问区域。

现在,让我们检查 void Student::Initialize()void Student::Print() 这两个成员函数的定义。我们还将检查这些函数在 C++ 中的内部结构:

// Member function definition
void Student::Initialize(string fn, string ln, char mi,
                         float gpa, const char *course)
{
    firstName = fn;
    lastName = ln;  
    this->middleInitial = mi;  // optional use of 'this'
    this->gpa = gpa;  // required, explicit use of 'this'
    // remember to allocate memory for ptr data members
    currentCourse = new char [strlen(course) + 1];
    strcpy(currentCourse, course);
}
// It is as if Student::Initialize() is written as:
// void Student_Initialize_str_str_char_float_constchar*
//     (Student *const this, string fn, string ln,
//      char mi, float avg, const char *course) 
// {
//    this->firstName = fn;
//    this->lastName = ln;
//    this->middleInitial = mi;
//    this->gpa = avg;
//    this->currentCourse = new char [strlen(course) + 1];
//    strcpy(this->currentCourse, course);
// }
// Member function definition
void Student::Print()
{
   cout << firstName << " ";
   cout << middleInitial << ". ";
   cout << lastName << " has a gpa of: ";
   cout << gpa << " and is enrolled in: ";
   cout << currentCourse << endl;
}
// It is as if Student::Print() is written as:
// void Student_Print(Student *const this)
// {
//    cout << this->firstName << " ";
//    cout << this->middleInitial << ". ";
//    cout << this->lastName << " has a gpa of: ";
//    cout << this->gpa << " and is enrolled in: ";
//    cout << this->currentCourse << endl;
// }

首先,我们看到void Student::Initialize()成员函数的定义,它接受各种参数。请注意,在这个函数体中,我们首先将输入参数fn赋值给数据成员firstName。我们以类似的方式,使用各种输入参数,初始化将要调用此函数的特定对象的各种数据成员。同时请注意,我们为指针数据成员currentCourse分配足够的内存,以容纳输入参数course所需的字符数(加上一个终止空字符)。然后,我们使用strcpy()函数将输入参数course中的字符串复制到数据成员currentCourse

此外,请注意在void Student::Initialize()中,赋值this->middleInitial = mi;。在这里,我们有一个可选的显式使用this指针。在这种情况下,用this来限定middleInitial不是必需的或习惯性的,但我们可以选择这样做。然而,在赋值this->gpa = gpa;中,使用this是必需的。为什么?请注意,输入参数被命名为gpa,数据成员也是gpa。简单地赋值gpa = gpa;会将最局部版本的gpa(输入参数)设置为自身,并且不会影响数据成员。在这里,通过在赋值表达式的左侧使用this来区分gpa,表示将this指向的数据成员gpa设置为输入参数gpa的值。另一个解决方案是使用与输入参数不同的数据成员名称,例如将形式参数列表中的gpa重命名为avg(我们将在代码的后续版本中这样做)。

现在,请注意void Student::Initialize()的注释版本,它位于已使用的void Student::Initialize()版本下方。在这里,我们可以看到大多数成员函数是如何在内部表示的。首先,请注意,函数的名称被名称混淆以包含其参数的数据类型。这是函数在内部表示的方式,并且因此允许函数重载(即具有看似相同名称的两个函数;在内部,每个都有唯一的名称)。接下来,请注意,在输入参数中,还有一个额外的第一个输入参数。这个额外(隐藏)输入参数的名称是this,它被定义为Student *const this

现在,在内部函数视图的void Student::Initialize()函数体中,请注意,每个数据成员的名称前都跟着this。实际上,我们是在访问由this指向的对象的数据成员。this在哪里定义的?回想一下,this是这个函数的隐式第一个输入参数,并且是一个指向调用此函数的对象的常量指针。

类似地,我们可以回顾void Student::Print()的成员函数定义。在这个函数中,每个数据成员都使用cout和插入运算符<<整洁地打印出来。然而,请注意在这个函数定义下面的注释掉的内部版本void Student::Print()。同样,this实际上是类型为Student *const的隐式输入参数。此外,每个数据成员的使用都通过this指针进行访问,例如this->gpa。再次明显地看到,在成员函数的作用域内访问了特定实例的成员;这些成员是通过this指针隐式访问的。

最后,请注意,在成员函数的体内可以使用this。我们几乎总是可以在成员函数体内访问的数据成员或成员函数之前使用显式的this。在本章的后面,我们将看到一种相反的情况(使用静态方法)。此外,在本书的后面,我们将看到需要显式使用this来实现更高级的 OO 概念的情况。

尽管如此,让我们通过检查main()来完成这个程序示例:

int main()
{
    Student s1;   // instance
    Student *s2 = new Student; // ptr to an instance
    s1.Initialize("Mary", "Jacobs", 'I', 3.9, "C++");
    s2->Initialize("Sam", "Nelson", 'B', 3.2, "C++");
    s1.Print();
    s2->Print(); // or use (*s2).Print();
    delete [] s1.currentCourse;     // delete dynamically 
    delete [] s2->currentCourse; // allocated data members
    delete s2;    // delete dynamically allocated instance
    return 0;
}

在这个程序的最后一部分,我们在main()中两次实例化StudentStudent s1是一个实例,而s2是一个指向Student的指针。接下来,我们使用.->运算符通过每个相关实例调用各种成员函数。

注意,当s1调用Initialize()时,this指针(在成员函数的作用域内)将指向s1。这就像将&s1作为第一个参数传递给这个函数一样。同样,当*s2调用Initialize()时,this指针将指向s2;这就像将s2(它已经是一个指针)作为隐式第一个参数传递给这个函数一样。

在每个实例调用Print()以显示每个Student的数据成员之后,请注意我们释放了不同级别的动态分配的内存。我们首先释放每个实例的动态分配的数据成员,使用delete()释放每个这样的成员。然后,因为s2是指向我们动态分配的实例的指针,我们必须记住也要释放包含实例本身的堆内存。我们再次使用delete s2;来这样做。

这里是完整程序示例的输出:

Mary I. Jacobs has a gpa of: 3.9 and is enrolled in: C++
Sam B. Nelson has a gpa of: 3.2 and is enrolled in: C++

现在,让我们通过检查访问标签和访问区域来加深我们对类和信息隐藏的理解。

使用访问标签和访问区域

标签可以引入到类(或结构)定义中,以控制类(或结构)成员的访问或可见性。通过控制我们应用程序中从各个范围直接访问成员,我们可以支持封装和信息隐藏。也就是说,我们可以坚持让我们的类用户使用我们选择的函数,使用我们选择的协议来操作类内部的数据和其他成员函数,这些是我们程序员认为合理和可接受的。此外,我们可以通过仅向用户宣传给定类的所需公共接口来隐藏类的实现细节。

数据成员或成员函数,统称为成员,可以单独标记,或分组到访问区域中。可能指定的三个标签或访问区域如下:

  • 私有:在此访问区域内的数据成员和成员函数仅可在类的范围内访问。类的范围包括该类的成员函数。

  • 私有直到我们介绍继承。当介绍继承时,保护将提供一个机制,允许在派生类范围内进行访问。

  • 公共:在此访问区域内的数据成员和成员函数可以从程序的任何范围访问。

提醒

数据成员和成员函数通常通过实例进行访问。你可能会问,“我的实例在什么范围内?”,以及“我能否从这个特定范围访问特定的成员?”

程序员可能需要的成员可以分组在给定的标签或私有下。如果在结构定义中省略了访问标签,则默认成员访问为公共。当显式引入访问标签时,而不是依赖于默认可见性,结构是相同的。尽管如此,在面向对象编程中,我们倾向于使用类来定义用户定义的类型。

有趣的是要注意,当数据成员在具有相同访问标签的访问区域中分组时,它们在内存中的布局顺序是保证的。然而,如果存在包含给定类中数据成员的多个访问区域,编译器可以自由地重新排序这些相应的分组以实现高效的内存布局。

让我们通过一个例子来检查访问区域。尽管这个例子将被分成几个部分,但完整的例子将展示出来,也可以在 GitHub 仓库中找到:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter05/Chp5-Ex3.cpp

#include <iostream>
#include <cstring>    // though we'll prefer std::string, 
// one ptr data member will illustrate important concepts
using std::cout;      // preferred to: using namespace std;
using std::endl;
using std::string;
class Student
{
// private members are accessible only within the scope of
// the class (that is, within member functions or friends) 
private: 
    string firstName;     // data members
    string lastName;
    char middleInitial;
    float gpa;
    char *currentCourse;  // ptr to illustrate key concepts
public:   // public members are accessible from any scope
    // member function prototypes
    void Initialize();  
    void Initialize(string, string, char, float,  
                    const char *);
    void CleanUp();
    void Print();
};

在这个例子中,我们首先定义了Student类。注意,我们在类定义的顶部附近添加了一个private访问区域,并将所有数据成员放置在这个区域中。这种放置方式将确保这些数据成员只能在类的范围内直接访问和修改,这意味着通过这个类的成员函数(以及我们稍后将要看到的友元函数)。通过限制数据成员的访问仅限于它们自己类的成员函数,确保了这些数据成员的安全处理;只有通过类设计者自己引入的预期和安全函数才能允许访问。

接下来,注意在成员函数原型之前在类定义中添加了标签public。这意味着这些函数将在我们程序的任何范围内可访问。当然,我们通常需要通过实例来访问这些函数。但是,实例可以在main()函数的作用域内,或者任何其他函数的作用域内(甚至在其他类的成员函数的作用域内),当实例访问这些公共成员函数时。这被称为类的public接口。

访问区域支持封装和信息隐藏

一个好的经验法则是将数据成员放置在private访问区域,然后指定一个安全、适当的public接口,通过public成员函数来访问它们。这样做,数据成员的唯一访问方式就是通过类设计者有意设计的、经过良好测试的成员函数。采用这种策略,类的底层实现也可以更改,而不会导致对公共接口的任何调用发生变化。这种做法支持封装和信息隐藏。

让我们继续,看看我们程序中的各种成员函数定义:

void Student::Initialize()
{   // even though string data members are initialized with
    // empty strings, we are showing how to clear these 
    // strings, should Initialize() be called more than 1x
    firstName.clear();   
    lastName.clear(); 
    middleInitial = '\0';      // null character
    gpa = 0.0;
    currentCourse = nullptr; 
}
// Overloaded member function definition
void Student::Initialize(string fn, string ln, char mi,
                         float avg, const char *course) 
{
    firstName = fn;
    lastName = ln;
    middleInitial = mi; 
    gpa = avg;   
    // dynamically allocate memory for pointer data member
    currentCourse = new char [strlen(course) + 1];
    strcpy(currentCourse, course);
}
// Member function definition
void Student::CleanUp()
{   // deallocate previously allocated memory
    delete [] currentCourse;  
}                          
// Member function definition
void Student::Print()
{
    cout << firstName << " " << middleInitial << ". ";
    cout << lastName << " has gpa: " << gpa;
    cout << " and enrolled in: " << currentCourse << endl;
}

在这里,我们已经定义了在类定义中原型化的各种成员函数。注意使用作用域解析运算符::将类名与成员函数名关联起来。内部,这两个标识符被名称混淆在一起,以提供唯一的内部函数名。注意,void Student::Initialize()函数被重载了;一个版本只是将所有数据成员初始化为某种形式的空或零,而重载版本使用输入参数来初始化各种数据成员。

现在,让我们继续,检查以下代码段中的main()函数:

int main()
{
    Student s1;
    // Initialize() is public; accessible from any scope
    s1.Initialize("Ming", "Li", 'I', 3.9, "C++", "178GW"); 
    s1.Print(); // public Print() accessible from main() 
    // Error! private firstName is not accessible in main()
    // cout << s1.firstName << endl;  
    // CleanUp() is public, accessible from any scope
    s1.CleanUp(); 
    return 0;
}

在上述main()函数中,我们首先使用声明Student s1;实例化一个Student对象。接下来,s1调用与提供的参数签名匹配的Initialize()函数。由于这个成员函数在public访问区域,它可以在我们程序的任何作用域中访问,包括main()。同样,s1调用了Print(),这也是public的。这些函数是Student类的公共接口的一部分,代表了操作任何给定Student实例的核心功能。

接下来,在注释掉的代码行中,注意 s1尝试直接使用 s1.firstName来访问firstName。因为firstNameprivate的,这个数据成员只能在它自己类的范围内访问,这意味着它的成员函数(以及后来的朋友)可以访问。main()函数不是Student类的成员函数,因此 s1不能在main()的作用域内访问firstName,也就是说,在它自己类的作用域之外。

最后,我们调用了s1.CleanUp();,这也同样有效,因为CleanUp()public的,因此可以从任何作用域(包括main())访问。

这个完整示例的输出如下:

Ming I. Li has gpa: 3.9 and enrolled in: C++

现在我们已经了解了访问区域是如何工作的,让我们继续前进,通过考察一个称为构造函数的概念,以及 C++中可用的各种类型的构造函数。

理解构造函数

你有没有注意到,在本章的程序示例中,每个classstruct都有一个Initialize()成员函数是多么方便?当然,初始化给定实例的所有数据成员是可取的。更重要的是,确保任何实例的数据成员都有真实值至关重要,因为我们知道 C++不会提供干净零初始化的内存。访问未初始化的数据成员,并像使用真实值一样使用它的值,是粗心大意的程序员可能遇到的潜在陷阱。

每次实例化类时单独初始化每个数据成员可能是一项繁琐的工作。如果我们简单地忽略设置值怎么办?如果这些值是private的,因此不能直接访问怎么办?我们已经看到,Initialize()函数是有益的,因为它一旦编写,就提供了一种为给定实例设置所有数据成员的方法。唯一的缺点是程序员现在必须记住在应用程序的每个实例上调用Initialize()。那么,如果有一种方法可以确保每次实例化类时都调用Initialize()函数怎么办?如果我们能够重载多种版本来初始化实例,并且可以根据当时可用的数据调用适当的版本怎么办?这个前提是 C++中构造函数的基础。该语言提供了一系列重载的初始化函数,一旦实例的内存变得可用,它们将自动被调用。

让我们通过检查 C++ 构造函数来查看这个初始化成员函数家族。

应用构造函数基础和重载构造函数

定义一个 class(或 struct)以提供初始化对象的多种方式。构造函数的返回类型不能指定。

如果您的 classstruct 不包含构造函数,将在 public 访问区域为您创建一个,不带参数。这被称为默认构造函数。在幕后,每次实例化对象时,编译器都会插入一个构造函数调用。当实例化没有构造函数的类时,默认构造函数会作为函数调用立即跟在实例化之后插入。这个系统提供的成员函数将有一个空体(方法),它将被链接到您的程序中,以便在实例化时,任何编译器添加的、隐式的对此函数的调用都可以发生,而不会出现链接器错误。根据设计需要,程序员经常可以编写自己的默认(无参数)构造函数;即,用于无参数默认实例化的构造函数。

大多数程序员除了自己的无参数默认构造函数外,至少还提供一个构造函数。回想一下,构造函数可以重载。重要的是要注意,如果您自己提供了任何构造函数,您将不会收到系统提供的无参数默认构造函数,并且随后使用该接口进行实例化将导致编译器错误。

提醒

构造函数与类的名称相同。您不能指定它们的返回类型。它们可以重载。如果您在类中没有提供任何构造函数(即实例化的方法),编译器只会创建一个公共的默认(无参数)构造函数。

让我们通过一个简单的例子来了解构造函数的基础:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter05/Chp5-Ex4.cpp

#include <iostream>
using std::cout;   // preferred to: using namespace std;
using std::endl;
using std::string;
class University
{
private:
    string name;
    int numStudents;
public: 
    // constructor prototypes
    University(); // default constructor
    University(const string &, int);
    void Print();
    void CleanUp();
};
University::University()
{   // Because a string is a class type, all strings are 
    // constructed with an empty value by default. 
    // For that reason, we do not need to explicitly 
    // initialize strings if an empty string is desired. 
    // We'll see a preferred manner of initialization 
    // for all data members shortly in this chapter.    
    // Hence, name is constructed by default (empty string)
    numStudents = 0;
}
University::University(const string &n, int num)
{   // any pointer data members should be allocated here
    name = n; // assignment between strings is deep assign.
    numStudents = num;
}
void University::Print()
{
    cout << "University: " << name;
    cout << " Enrollment: " << numStudents << endl;
}
void University::CleanUp()
{   // deallocate any previously allocated memory
}
int main()
{
    University u1; // Implicit call to default constructor
    // alternate constructor instantiation and invocation
    University u2("University of Delaware", 23800);
    University u3{"Temple University", 20500}; // note {}  
    u1.Print();
    u2.Print();
    u3.Print();
    u1.CleanUp();
    u2.CleanUp();
    u3.CleanUp();
    return 0;
}

在前面的程序段中,我们首先定义了 class University;数据成员是 private 的,三个成员函数是 public 的。注意,前两个成员函数是构造函数的原型。它们都与类的名称相同;都没有指定它们的返回类型。这两个构造函数是重载的,因为每个都有不同的签名。

接下来,注意定义了三个成员函数。注意在每个定义中,每个成员函数名称之前都使用了作用域解析运算符 ::。每个构造函数提供了一种初始化实例的不同方法。void University::Print() 成员函数仅为我们提供的示例提供了一种简单的输出方式。

现在,在 main() 中,让我们创建三个 University 实例。第一行代码 University u1; 实例化一个 University 对象,然后隐式调用默认构造函数来初始化数据成员。在下一行代码 University u2("University of Delaware", 23800); 中,我们实例化第二个 University。一旦在 main() 中为该实例分配了内存,与提供的参数签名匹配的构造函数(即 University::University(const string &, int))将隐式调用以初始化该实例。

最后,我们使用 University u3{"Temple University", 20500}; 实例化第三个 University 对象,这也使用了备用构造函数。注意在实例化和构造 u3{}() 的使用。两种风格都可以使用。后一种风格是为了创建一致性;两种构造方式都不会带来性能优势。

我们可以看到,根据我们如何实例化对象,我们可以指定我们希望为我们调用哪个构造函数来执行初始化。

本例的输出如下:

University: Enrollment: 0
University: University of Delaware Enrollment: 23800
University: Temple Enrollment: 20000

参数比较

你注意到备用 University 构造函数的签名是 University(const string &, int); 吗?这意味着第一个参数是一个 const string & 而不是 string,就像之前示例中我们 Initialize() 成员函数所使用的?两者都是可接受的。一个 string 参数会将形式参数的副本传递到成员函数的栈上。如果形式参数是一个引号中的字符串字面量(例如 "University of Delaware"),则会首先创建一个 string 实例来容纳这个字符序列。相比之下,如果构造函数的参数是一个 const string &,则将传递形式参数的引用到该函数,并且引用的对象将被视为 const。在构造函数体中,我们使用赋值操作将输入参数的值复制到数据成员。不用担心,string 类的赋值操作员会执行从源到目标字符串的深度复制。这意味着我们不必担心数据成员与初始化数据(即字符串)共享内存(也就是说,没有自己的副本)。因此,将 stringconst string & 作为构造函数的参数都是可接受的。

现在,让我们用类内初始化器来补充我们对构造函数的使用。

构造函数和类内初始化器

除了在构造函数中初始化数据成员外,一个类还可以选择性地包含类内初始化器。也就是说,可以在类定义中指定默认值,作为初始化数据成员的手段,在没有为这些数据成员提供特定的构造函数初始化(或赋值)的情况下。

让我们考虑我们之前示例的一个修订版:

class University
{
private:
    string name {"None"}; // in-class initializer to be
    int numStudents {0};  // used when values not set in
                          // constructor
    // Above line same as: int numStudents = 0;
public:                   
    University(); // default constructor
    // assume remainder of class def is as previously shown
};
University::University()
{   // Because there are no initializations (or
    // assignments) of data members name, numStudents 
    // in this constructor, the in-class initializer
    // values will persist.
    // This constructor, with its signature, is still 
    // required for the instantiation below, in main()
}
// assume remaining member functions exist here
int main()
{
    University u1;  // in-class initializers are used
}

在之前的代码片段中,请注意,我们的University类定义包含两个数据成员namenumStudents的类内初始化器。当University构造函数没有设置这些值时,这些值将用于初始化University实例的数据成员。更具体地说,如果University构造函数使用初始化来设置这些值,则类内初始化器将被忽略(我们将在本章稍后看到正式的构造函数初始化与成员初始化列表)。

此外,如果一个构造函数在构造函数体内部通过赋值来设置这些数据成员(正如我们在之前的构造函数示例中所见),这些赋值将覆盖任何原本为我们进行的类内初始化。然而,如果我们没有在构造函数中设置数据成员(如当前代码片段所示),则将使用类内初始化器。

类内初始化器可以用作简化默认构造函数或减轻构造函数原型中指定的默认值(这种风格变得越来越不受欢迎)。

正如我们在本例中所见,类内初始化器可能导致默认构造函数在方法体本身中不再有工作(即初始化)要做。然而,我们可以看到在某些情况下,如果我们想使用默认接口进行实例化,则默认构造函数是必要的。在这些情况下,可以将=default添加到默认构造函数的原型中,以指示系统提供的默认构造函数(具有空体)应该为我们链接,从而减轻我们提供空默认构造函数的需要(如我们之前的示例所示)。

通过这次改进,我们的类定义将变为以下内容:

class University
{
private:
    string name {"None"}; // in-class init. to be used when
    int numStudents {0};  // values not set in constructor
public: 
    // request the default constructor be linked in     
    University() = default; 
    University(const string &, int);
    void Print();
    void CleanUp();
};

在之前的类定义中,我们现在请求系统提供的默认构造函数(具有空体),在这种情况下我们本来不会自动获得(因为我们已经提供了一个具有不同签名的构造函数)。我们已经节省了指定一个空体的默认构造函数,正如我们的原始示例所示。

接下来,让我们通过检查拷贝构造函数来增加我们对构造函数的知识。

创建拷贝构造函数

拷贝构造函数是一种特殊构造函数,当可能需要创建对象的副本时会被调用。拷贝构造函数可能在另一个对象的构造过程中被调用。它们也可能在对象通过值传递给函数作为输入参数或从函数返回值时被调用。

通常,复制一个对象并稍作修改比从头开始构造一个具有其各自属性的新对象要容易。这在程序员需要应用程序生命周期中经过多次更改的对象的副本时尤其正确。可能无法回忆起对特定对象应用的各种转换的顺序,以创建一个副本。相反,拥有复制对象的方法是可取的,可能是至关重要的。

复制构造函数的签名是 ClassName::ClassName(const ClassName &);。注意,一个对象被明确地作为参数传递,并且该参数将是一个指向常量对象的引用。复制构造函数,就像大多数成员函数一样,将接收一个隐式参数到函数中,即 this 指针。复制构造函数定义的目的是为了复制显式参数以初始化 this 指向的对象。

如果类(或 struct)的设计者没有实现复制构造函数,将为你提供一个(在 public 访问区域),它执行浅拷贝。如果你在类中有指针类型的成员变量,这很可能不是你想要的。相反,最好的做法是自己编写一个复制构造函数,并编写它以执行深拷贝(根据需要分配内存)。

如果程序员希望在构造过程中禁止复制,可以在复制构造函数的原型中使用 =delete,如下所示:

    // disallow copying during construction
    Student(const Student &) = delete;   // prototype

或者,如果程序员希望禁止对象复制,可以在 private 访问区域中声明一个复制构造函数。在这种情况下,编译器将链接默认的复制构造函数(执行浅拷贝),但它将被视为私有。因此,在类的作用域之外使用复制构造函数的实例将被禁止。自从 =delete 出现以来,这种技术使用得较少,但它可能存在于现有代码中,因此了解它是有用的。

让我们检查一个复制构造函数,从类定义开始。尽管程序以几个片段的形式呈现,但完整的程序示例可以在 GitHub 仓库中找到:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter05/Chp5-Ex5.cpp

#include <iostream>  
#include <cstring>    // though we'll prefer std::string, 
// one ptr data member will illustrate important concepts
using std::cout;      // preferred to: using namespace std;
using std::endl;
using std::string;
class Student
{
private: 
    // data members
    string firstName;
    string lastName;
    char middleInitial;
    float gpa;
    char *currentCourse;  // ptr to illustrate key concepts
public:
    // member function prototypes
    Student();  // default constructor
    Student(const string &, const string &, char, float, 
            const char *); 
    Student(const Student &);  // copy constructor proto.
    void CleanUp();
    void Print();
    void SetFirstName(const string &);
};

在这个程序段中,我们首先定义 class Student。注意通常的 private 数据成员和 public 成员函数原型,包括默认构造函数和一个重载构造函数。还要注意复制构造函数的原型 Student(const Student &);

接下来,让我们看看我们程序接下来的部分中成员函数的定义:

// default constructor
Student::Student()
{
    // Because firstName and lastName are member objects of
    // type string, they are default constructed and hence
    // 'empty' by default. They HAVE been initialized.
    middleInitial = '\0';   // with a relevant value
    gpa = 0.0;
    currentCourse = 0;
}
// Alternate constructor member function definition
Student::Student(const string &fn, const string &ln, 
                 char mi, float avg, const char *course)
{
    firstName = fn;  // not to worry, assignment for string
    lastName = ln;   // is a deep copy into destination str
    middleInitial = mi;
    gpa = avg;
    // dynamically allocate memory for pointer data member
    currentCourse = new char [strlen(course) + 1];
    strcpy(currentCourse, course);
}
// Copy constructor definition – implement a deep copy
Student::Student(const Student &s)
{   // assignment between strings will do a deep 'copy'
    firstName = s.firstName;
    lastName = s.lastName;
    middleInitial = s.middleInitial;
    gpa = s.gpa;
    // for ptr data members, ensure a deep copy 
    // allocate memory for destination
    currentCourse = new char [strlen(s.currentCourse) + 1];
    // then copy contents from source to destination
    strcpy(currentCourse, s.currentCourse); 
}
// Member function definition
void Student::CleanUp()
{   // deallocate any previously allocated memory
    delete [] currentCourse;   
}

// Member function definitions
void Student::Print()
{
    cout << firstName << " " << middleInitial << ". ";
    cout << lastName << " has a gpa of: " << gpa;
    cout << " and is enrolled in: " << currentCourse;
    cout << endl;
}
void Student::SetFirstName(const string &fn)
{
    firstName = fn;
}

在上述代码片段中,我们定义了各种成员函数。最值得注意的是,让我们考虑复制构造函数的定义,它是一个具有签名 Student::Student(const Student &s) 的成员函数。

注意到输入参数 s 是一个指向 Studentconst 引用。这意味着我们将要复制的源对象可能不会被修改。我们将要复制的目标对象将是 this 指针指向的对象。

当我们仔细导航复制构造函数时,注意我们依次为属于 this 指向的对象的任何指针数据成员分配必要的空间。分配的空间与 s 所引用的数据成员所需的大小相同。然后我们仔细地从源数据成员复制到目标数据成员。我们细致地确保在目标对象中精确地复制源对象。

注意到我们在目标对象中进行了深拷贝。也就是说,我们不是简单地复制 s.currentCourse 中的指针到 this->currentCourse,例如,我们而是为 this->currentCourse 分配空间,然后复制源数据。浅拷贝的结果将是每个对象的指针数据成员共享相同的解引用内存(即每个指针指向的内存)。这很可能不是你想要的拷贝方式。此外,回想一下,系统提供的复制构造函数的默认行为将是从源对象到目标对象的浅拷贝。也值得注意,在复制构造函数中,两个字符串如 firstName = s.firstName; 之间的赋值将执行从源到目标字符串的深拷贝,因为这是字符串类定义的赋值运算符的行为。

现在,让我们看看我们的 main() 函数,看看复制构造函数可以以哪些方式被调用:

int main()
{ 
    // instantiate two Students
    Student s1("Zachary", "Moon", 'R', 3.7, "C++");
    Student s2("Gabrielle", "Doone", 'A', 3.7, "C++");
    // These inits implicitly invoke copy constructor
    Student s3(s1);  
    Student s4 = s2;
    s3.SetFirstName("Zack");// alter each object slightly
    s4.SetFirstName("Gabby"); 
    // This sequence does not invoke copy constructor 
    // This is instead an assignment.
    // Student s5("Giselle", "LeBrun", 'A', 3.1, "C++);
    // Student s6;
    // s6 = s5;   // this is assignment, not initialization
    s1.Print();   // print each instance
    s3.Print();
    s2.Print();
    s4.Print();
    s1.CleanUp(); // Since some data members are pointers,
    s2.CleanUp(); // let's call a function to delete() them
    s3.CleanUp();
    s4.CleanUp();
    return 0;
}

main() 中,我们声明了两个 Student 实例,s1s2,并且每个都是使用与 Student::Student(const string &, const string &, char, float, const char *); 签名匹配的构造函数进行初始化。注意,在实例化中使用的签名是我们选择应该隐式调用哪个构造函数的方式。

接下来,我们实例化 s3 并将其构造函数的参数传递为对象 s1,即 Student s3(s1);。在这里,s1Student 类型,因此这种实例化将匹配接受 Student 引用的构造函数,即复制构造函数。一旦进入复制构造函数,我们就知道我们将对 s1 进行深拷贝以初始化新实例化的对象 s3,该对象将由复制构造函数方法作用域内的 this 指针指向。

此外,我们使用以下代码行实例化s4Student s4 = s2;。在这里,因为这一行代码是一个初始化(即s4在同一语句中既被声明又被赋予了一个值),所以也会调用复制构造函数。复制的源对象将是s2,目标对象将是s4。请注意,我们随后稍微修改了每个副本(s3s4)的firstName数据成员。

接下来,在代码的注释部分,我们实例化了两个Student类型的对象,s5s6。然后我们尝试使用s5 = s6;将一个赋值给另一个。尽管这看起来与s4s2之间的初始化相似,但它并不是。s5 = s6;这一行是一个赋值操作。每个对象都之前已经存在。因此,这段代码没有调用复制构造函数。尽管如此,这段代码是合法的,并且具有与赋值运算符类似的含义。我们将在第十二章“朋友和运算符重载”中稍后讨论这些细节。

我们随后打印出对象s1s2s3s4。然后,我们对这四个对象中的每一个调用Cleanup()。为什么?每个对象都包含指针数据成员,因此在这些外部栈对象超出作用域之前,删除每个实例(即选定的指针数据成员)中包含的堆内存是合适的。

下面是伴随完整程序示例的输出:

Zachary R. Moon has a gpa of: 3.7 and is enrolled in: C++
Zack R. Moon has a gpa of: 3.7 and is enrolled in: C++
Gabrielle A. Doone has a gpa of: 3.7 and is enrolled in: C++
Gabby A. Doone has a gpa of: 3.7 and is enrolled in: C++

此示例的输出显示了每个原始Student实例及其副本的配对。请注意,每个副本都略微修改了原始副本(firstName不同)。

相关主题

有趣的是,赋值运算符与复制构造函数有很多相似之处,因为它可以允许数据从源复制到目标实例。然而,复制构造函数在初始化新对象时隐式调用,而赋值运算符将在两个现有对象之间执行赋值时调用。尽管如此,每种方法看起来都非常相似!我们将在第十二章“朋友和运算符重载”中检查重载赋值运算符以自定义其行为以执行深度赋值(类似于深度复制)。

现在我们已经对复制构造函数有了深入的理解,让我们来看最后一种构造函数,即转换构造函数。

创建转换构造函数

类型转换可以从一个用户定义类型转换到另一个用户定义类型,或者从标准类型转换到用户定义类型。转换构造函数是一种语言机制,允许这种转换发生。

转换构造函数是一种接受一个标准或用户定义类型的显式参数的构造函数,并应用合理的转换或变换来初始化正在实例化的对象。

让我们通过一个示例来展示这个想法。虽然这个示例将被分成几个部分,并且也会进行缩写,但完整的程序可以在 GitHub 仓库中找到:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter05/Chp5-Ex6.cpp

#include <iostream>   
#include <cstring>   // though we'll prefer std::string, 
// one ptr data member will illustrate important concepts
using std::cout;     // preferred to: using namespace std;
using std::endl;
using std::string;
class Student;      // forward declaration of Student class
class Employee
{
private:
    string firstName;
    string lastName;
    float salary;
public:
    Employee();
    Employee(const string &, const string &, float);
    Employee(Student &);  // conversion constructor
    void Print();
};
class Student
{
private: // data members
    string firstName;
    string lastName;
    char middleInitial;
    float gpa;
    char *currentCourse;  // ptr to illustrate key concepts
public:               
    // constructor prototypes
    Student();  // default constructor
    Student(const string &, const string &, char, float, 
            const char *);
    Student(const Student &);  // copy constructor
    void Print();
    void CleanUp();
    float GetGpa(); // access function for private data mbr
    const string &GetFirstName();
    const string &GetLastName();
};

在之前的程序段中,我们首先包含了一个对class Student;的前向声明——这个声明允许我们在定义之前引用Student类型。然后我们定义了class Employee。注意,这个类包含几个private数据成员和三个构造函数原型——一个默认构造函数、一个替代构造函数和一个转换构造函数。作为旁注,请注意,没有程序员指定复制构造函数。这意味着编译器将提供一个默认(浅拷贝)复制构造函数。在这种情况下,由于没有指针数据成员,浅拷贝是可以接受的。

然而,让我们继续通过检查Employee转换构造函数原型。注意,在这个原型中,这个构造函数接受一个单一参数。参数是一个Student &,这就是为什么我们需要对Student进行前向声明。最好,我们可能使用const Student &作为参数类型,但我们需要理解 const 成员函数(在本章的后面部分)才能做到这一点。将要发生的类型转换是将Student转换成一个新的Employee。这将是我们提供有意义的转换以在转换构造函数的定义中实现这一点的任务,我们将在稍后看到。

接下来,我们定义我们的Student类,它与我们在之前的示例中看到的大致相同。

现在,让我们继续这个示例,看看EmployeeStudent的成员函数定义,以及我们的main()函数,在下面的代码段中。为了节省空间,将省略选定的成员函数定义,然而,在线代码将展示整个程序。

继续前进,我们的EmployeeStudent的成员函数如下:

Employee::Employee()  // default constructor
{
    // Remember, firstName, lastName are member objects of
    // type string; they are default constructed and hence
    // 'empty' by default. They HAVE been initialized.
    salary = 0.0;
}
// alternate constructor
Employee::Employee(const string &fn, const string &ln, 
                   float money)
{
    firstName = fn;
    lastName = ln;
    salary = money;
}
// conversion constructor param. is a Student not Employee
// Eventually, we can properly const qualify parameter, but
// we'll need to learn about const member functions first…
Employee::Employee(Student &s)
{
    firstName = s.GetFirstName();
    lastName = s.GetLastName();
    if (s.GetGpa() >= 4.0)
        salary = 75000;
    else if (s.GetGpa() >= 3.0)
        salary = 60000;
    else
        salary = 50000; 
}
void Employee::Print()
{
    cout << firstName << " " << lastName << " " << salary;
    cout << endl;
}
// Definitions for Student's default, alternate, copy
// constructors, Print()and CleanUp() have been omitted 
// for space, but are same as the prior Student example.
float Student::GetGpa()
{
    return gpa;
}
const string &Student::GetFirstName()
{
    return firstName;
}
const string &Student::GetLastName()
{
    return lastName;
}

在之前的代码段中,我们注意到为Employee定义了几个构造函数。我们有一个默认构造函数、一个替代构造函数和一个转换构造函数。

检查Employee转换构造函数的定义,注意源对象的正式参数是s,其类型为Student。目标对象将是正在构造的Employee,它将由this指针指向。在这个函数的主体中,我们仔细地将firstNamelastNameStudent &s复制到新实例化的Employee。注意,我们使用访问函数const string &Student::GetFirstName()const string &Student::GetLastName()来完成此操作(通过一个Student实例),因为这些数据成员是private的。

让我们继续讨论转换构造函数。我们的任务是提供一个有意义的类型之间的转换。在这个过程中,我们尝试根据源Student对象的gpa来为Employee设置一个初始工资。因为gpa是私有的,所以使用访问函数Student::GetGpa()来检索这个值(通过源Student)。请注意,由于Employee没有动态分配的数据成员,我们不需要在这个函数体中分配内存来辅助深度复制。

为了节省空间,省略了Student类的默认、替代和复制构造函数的成员函数定义,以及void Student::Print()void Student::CleanUp()成员函数的定义。然而,它们与之前完整程序示例中说明的Student类中的定义相同。

注意,已经添加了对Studentprivate数据成员的访问函数,例如float Student::GetGpa(),以提供对这些数据成员的安全访问。请注意,从float Student::GetGpa()返回的值是gpa数据成员的副本。原始的gpa不会因为使用这个函数而受到威胁。同样的情况适用于成员函数const string &Student::GetFirstName()const string &Student::GetLastName(),它们各自返回一个const string &,确保返回的数据不会被破坏。

让我们通过检查我们的main()函数来完成我们的程序:

int main()
{
    Student s1("Giselle", "LeBrun", 'A', 3.5, "C++");
    Employee e1(s1);  // conversion constructor
    e1.Print();
    s1.CleanUp();  // CleanUp() will delete() s1's 
    return 0;      // dynamically allocated data members
}

在我们的main()函数中,我们实例化了一个Student对象,即s1,它隐式地使用匹配的构造函数进行初始化。然后我们使用转换构造函数在调用Employee e1(s1);中实例化了一个Employee对象e1。乍一看,我们可能认为我们正在使用Employee的复制构造函数。但仔细观察后,我们发现实际参数s1的类型是Student,而不是Employee。因此,我们正在使用Student s1作为基础来初始化Employee e1。请注意,在这个转换过程中,Student对象s1在没有任何方式受到损害或改变。因此,最好在形式参数列表中将源对象定义为const Student &;一旦我们理解了 const 成员函数,这在转换构造函数体中将是必需的,我们就可以这样做。

为了结束这个程序,我们使用Employee::Print()打印出Employee,这样我们就可以可视化我们对StudentEmployee的转换所应用的转换。

下面是伴随我们示例的输出:

Giselle LeBrun 60000

在我们继续前进之前,有一个关于转换构造函数的细微细节需要我们特别注意。

重要提示

任何只接受单个参数的构造函数都被认为是转换构造函数,它可以潜在地用来将参数类型转换为它所属的类的对象类型。例如,如果你在Student类中有一个只接受float的构造函数,这个构造函数不仅可以用于前面示例中所示的方式,还可以用于期望Student类型参数的地方(如函数调用),当提供的参数类型是float时。这可能不是你想要的,这就是为什么这个有趣的功能被特别指出。如果你不希望发生隐式转换,你可以在构造函数原型开始处使用explicit关键字来禁用此行为。

现在我们已经了解了 C++中的基本、替代、拷贝和转换构造函数,让我们继续前进,探索构造函数的互补成员函数,即 C++的析构函数。

理解析构函数

回想一下类构造函数是如何方便地为我们提供一个初始化新创建对象的方法?我们不需要为给定类型的每个实例记住调用一个Initialize()方法,构造函数允许自动初始化。在构造过程中使用的签名有助于指定应该使用一系列构造函数中的哪一个。

那么对象清理呢?许多类包含动态分配的数据成员,这些数据成员通常在构造函数中分配。当程序员完成一个实例后,这些数据成员所占用的内存不应该被释放吗?当然应该。我们已经在几个示例程序中编写了一个CleanUp()成员函数。并且我们已经记得调用CleanUp()。方便的是,类似于构造函数,C++有一个自动构建的功能作为清理函数。这个函数被称为析构函数。

让我们看看析构函数,以了解其正确使用方法。

应用析构函数的基本知识和正确使用

析构函数是一个成员函数,其目的是释放对象在其存在期间可能获取的资源。当一个类或结构实例发生以下任一情况时,析构函数会自动调用:

  • 超出作用域(这适用于非指针变量)

  • 显式地使用delete(用于指向对象的指针)

析构函数应该(通常)清理构造函数可能分配的任何内存。析构函数的名称是一个~字符后跟类名。析构函数将没有参数;因此,它不能被重载。最后,析构函数的返回类型不能指定。类和结构都可以有析构函数。

除了释放构造函数可能分配的内存外,析构函数还可以用于执行实例的生命周期结束任务,例如将值记录到数据库中。更复杂的任务可能包括通知由类数据成员指向的对象(其内存不会被释放),当前对象将结束。如果链接的对象包含指向终止对象的指针,这可能很重要。我们将在本书后面的章节中看到这个例子,在第十章实现关联、聚合和组合

如果你没有提供析构函数,编译器将创建并链接一个空的public析构函数。这是必要的,因为析构函数的调用会在局部实例从栈中弹出之前自动修补,以及在使用delete()之前,动态分配的实例释放内存之前。对于编译器来说,始终修补这个调用比不断检查你的类是否有析构函数要容易得多。当有资源需要清理或需要释放的动态分配的内存时,请务必自己提供一个类析构函数。如果析构函数将是空的,考虑在其原型中使用=default来承认其自动包含(并且放弃自己提供定义);然而,这种做法增加了不必要的代码,因此变得越来越不受欢迎。

存在一些潜在的陷阱。例如,如果你忘记删除一个动态分配的实例,析构函数调用将不会为你修补。C++是一种给你灵活性和强大功能去做(或不做)任何事情的语言。如果你不使用给定的标识符(可能两个指针指向相同的内存)删除内存,请记住稍后通过其他标识符删除它。

值得注意的是最后一项。尽管你可以显式调用析构函数,但你很少需要这样做。在上述情况下,编译器会代表你隐式地修补析构函数调用。只有在非常少数的高级编程情况下,你才需要自己显式调用析构函数。

让我们看看一个简单的例子,说明类析构函数,它将被分为三个部分。其完整示例可以在以下 GitHub 仓库中看到:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter05/Chp5-Ex7.cpp

#include <iostream>  
#include <cstring>    // though we'll prefer std::string, 
// one ptr data member will illustrate important concepts
using std::cout;      // preferred to: using namespace std;
using std::endl;
using std::string;
class University
{
private:
    char *name;   // ptr data member shows destructor 
                  // purpose
    int numStudents;
public: 
    // constructor prototypes
    University(); // default constructor
    University(const char *, int); // alternate constructor
    University(const University &);  // copy constructor
    ~University();  // destructor prototype
    void Print();
};

在之前的代码段中,我们首先定义了class University。注意填充数据成员的private访问区域和包含默认构造函数、替代构造函数、复制构造函数、析构函数和Print()方法原型的public接口。

接下来,让我们看看各种成员函数的定义:

University::University()  // default constructor
{
    name = nullptr;
    numStudents = 0;
}
University::University(const char *n, int num) 
{   // allocate memory for pointer data member
    name = new char [strlen(n) + 1];
    strcpy(name, n);
    numStudents = num;
}
University::University(const University &u) // copy const
{
    name = new char [strlen(u.name) + 1];  // deep copy
    strcpy(name, u.name);
    numStudents = u.numStudents;
}
University::~University()  // destructor definition
{  
    delete [] name;  // deallocate previously allocated mem
    cout << "Destructor called " << this << endl;
}
void University::Print()
{
    cout << "University: " << name;
    cout << " Enrollment: " << numStudents << endl;
}

在上述代码片段中,我们看到我们现在习惯看到的各种重载构造函数,以及void University::Print()。新增的是析构函数的定义。

注意到析构函数University::~University()不接受任何参数;它可能不能被重载。析构函数简单地释放可能在任何构造函数中分配的内存。注意我们简单地delete [] name;,这将无论name指向一个有效地址还是包含一个空指针(是的,将delete应用于空指针是允许的)。此外,我们在析构函数中打印this指针,只是为了好玩,这样我们就可以看到即将消失的实例的地址。

接下来,让我们看看main()函数,看看析构函数可能在何时被调用:

int main()
{
    University u1("Temple University", 39500);
    University *u2 = new University("Boston U", 32500);
    u1.Print();
    u2->Print();
    delete u2; // destructor will be called before delete()
               // and destructor for u1 will be called 
    return 0;  // before program completes 
}

在这里,我们创建了两个University实例;u1是一个实例,而u2指向一个实例。我们知道u2是在其内存可用时通过new()实例化的,一旦内存可用,就会调用相应的构造函数。接下来,我们调用University::Print()为两个实例生成输出。

最后,在main()函数的末尾,我们删除u2以将内存返回给堆管理设施。在内存释放之前,在调用delete()时,C++将插入对u2指向的对象的析构函数的调用。这就像在delete u2;之前插入了一个秘密的函数调用u2->~University();(注意,这是自动完成的,不需要你自己这样做)。对析构函数的隐式调用将删除类内部可能分配的任何数据成员的内存。u2的内存释放现在已完成。

那么u1实例呢?它的析构函数会被调用吗?是的;u1是一个栈实例。在main()中将它的内存从栈上弹出之前,编译器将插入对其析构函数的调用,就像代表你添加了u1.~University();这样的调用(再次强调,不需要你自己这样做)。对于u1实例,析构函数也会释放可能分配给数据成员的任何内存。同样,u1的内存释放现在已完成。

注意到在每次析构函数调用中,我们都打印了一条消息来展示析构函数被调用的时刻,并且也打印出了this的内存地址,以便你可以可视化每个特定的实例在析构时的状态。

下面是与我们的完整程序示例一起的输出:

University: Temple University Enrollment: 39500
University: Boston U Enrollment: 32500
Destructor called 0x10d1958
Destructor called 0x60fe74

通过这个例子,我们现在已经检查了析构函数,这是与一系列类构造函数相对应的。让我们继续探讨与类相关的一系列有用的主题:数据成员和成员函数的各种关键字限定符。

对数据成员和成员函数应用限定符

在本节中,我们将研究可以添加到数据成员和成员函数中的修饰符。各种修饰符——inlineconststatic——可以支持程序效率,有助于保持私有数据成员的安全,支持封装和信息隐藏,并且还可以用来实现各种面向对象的概念。

让我们开始探讨各种成员资格类型。

添加内联函数以提升潜在效率

想象一下,在你的程序中有一组短成员函数,它们被各种实例反复调用。作为一个面向对象的程序员,你欣赏使用public成员函数来提供对private数据的安全和受控访问。然而,对于非常短的函数,你担心效率问题。也就是说,反复调用小函数的开销。当然,直接粘贴函数的两三行代码会更有效率。然而,你却犹豫不决,因为这可能意味着提供对其他隐藏类信息的public访问,例如数据成员,而你对此犹豫不决。内联函数可以解决这个困境,让你既能拥有成员函数访问和操作私有数据的安全性,又能执行多行代码而不受函数调用开销的影响。

内联函数是指其调用被替换为函数本身的函数体。内联函数可以帮助消除调用非常小的函数所关联的开销。

为什么调用函数会有开销?当调用函数时,输入参数(包括this)会被推入栈中,为函数的返回值预留空间(尽管有时使用寄存器),移动到代码的另一部分需要将信息存储在寄存器中以跳转到该部分,等等。用内联函数替换非常小的函数体可以提高程序效率。

可以使用以下任一机制指定内联函数:

  • 在类定义内放置函数定义

  • 在(典型的)函数定义中,将关键字inline放置在返回类型之前,该定义位于类定义之外

将函数指定为上述两种方式之一中的inline,仅仅是向编译器提出一个请求,让其考虑用函数体替换其函数调用。这种替换并不保证。编译器在什么情况下可能不会实际内联一个给定的函数?如果一个函数是递归的,它就不能被指定为inline。同样,如果一个函数很长,编译器也不会内联该函数。此外,如果函数调用是动态绑定的(即在运行时确定具体实现,如虚函数),它也不能被指定为inline

应该在包含相应类定义的头文件中声明inline函数定义。这样,如果需要,任何对函数的修订都可以正确地重新展开。

让我们通过使用内联函数的例子来看看。程序将被分成两个部分,其中一些已知的函数被移除。然而,完整的程序可以在 GitHub 仓库中查看:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter05/Chp5-Ex8.cpp

#include <iostream>  
#include <cstring>   // though we'll prefer std::string, 
                     // one ptr data member will illustrate
                     // important concepts
using std::cout;     // preferred to: using namespace std;
using std::endl;
using std::string;
class Student
{
private: 
    // data members
    string firstName;
    string lastName;
    char middleInitial;
    float gpa;
    char *currentCourse;  // ptr to illustrate key concepts
public:
    // member function prototypes
    Student();  // default constructor
    Student(const student &, const student &, char, float, 
            const char *); 
    Student(const Student &);  // copy constructor
    ~Student();  // destructor
    void Print();
    // inline function definitions
    const string &GetFirstName() { return firstName; }  
    const string &GetLastName() { return lastName; }    
    char GetMiddleInitial() { return middleInitial; }
    float GetGpa() { return gpa; }
    const char *GetCurrentCourse() 
        { return currentCourse; }
    // prototype only, see inline function definition below
    void SetCurrentCourse(const char *);
};
inline void Student::SetCurrentCourse(const char *c)
{   // notice the detailed work to reset ptr data member;
    // it's more involved than if currentCourse was a str
    delete [] currentCourse;  
    currentCourse = new char [strlen(c) + 1];
    strcpy(currentCourse, c); 
}

在上一个程序片段中,让我们从类定义开始。注意,在类定义本身中添加了几个访问函数定义,例如GetFirstName()GetLastName()等。仔细观察;这些函数实际上是在类定义内部定义的。例如,float GetGpa() { return gpa; }不仅是一个原型,而且是完整的函数定义。由于这些函数是在类定义内部放置的,因此这些函数被认为是内联的。

这些小函数提供了对私有数据成员的安全访问。例如,注意const char *GetCurrentCourse()。这个函数返回一个指向currentCourse的指针,它在类中以char *的形式存储。但是,因为这个函数的返回值是const char *,这意味着调用这个函数的人必须将返回值视为const char *,这意味着将其视为不可修改的。如果这个函数的返回值被存储在一个变量中,那么这个变量也必须定义为const char *。通过将这个指针向上转换为具有返回值的不可修改版本,我们添加了这样一个规定:没有人可以接触到private数据成员(这是一个指针)并更改其值。

现在,注意在类定义的末尾,我们有一个void SetCurrentCourse(const char *);的原型。然后,在这个类定义之外,我们将看到这个成员函数的定义。注意这个函数定义的void返回类型之前的inline关键字。必须在这里显式使用这个关键字,因为函数是在类定义之外定义的。记住,对于任何一种inline方法的样式,inline指定仅是向编译器发出替换函数体为函数调用的请求。就像任何函数一样,如果你提供了一个原型(没有=default),务必提供函数定义(否则链接器肯定会抱怨)。

让我们通过检查我们程序的其余部分来继续这个例子:

// Definitions for default, alternate, copy constructor,
// and Print() have been omitted for space,
// but are same as last example for class Student
// the destructor is shown because we have not yet seen
// an example destructor for the Student class
Student::~Student()
{   // deallocate previously allocated memory
    delete [] currentCourse;
}
int main()
{
    Student s1("Jo", "Muritz", 'Z', 4.0, "C++"); 
    cout << s1.GetFirstName() << " " << s1.GetLastName();
    cout << " Enrolled in: " << s1.GetCurrentCourse();
    cout << endl;
    s1.SetCurrentCourse("Advanced C++ Programming"); 
    cout << s1.GetFirstName() << " " << s1.GetLastName();
    cout << " New course: " << s1.GetCurrentCourse(); 
    cout << endl;
    return 0;
}

注意,在我们的程序示例的其余部分,省略了几个成员函数的定义。这些函数体的代码与之前完整展示Student类的示例相同,也可以在线查看。

让我们专注于我们的main()函数。在这里,我们实例化了一个Student对象,命名为s1。然后,我们通过s1调用几个inline函数调用,例如s1.GetFirstName();。因为Student::GetFirstName()是内联的,所以它就像我们直接访问数据成员firstName一样,因为这个函数体的代码仅仅是return firstName;语句。我们使用函数访问private数据成员(意味着没有人可以在类的范围之外修改这个数据成员),但内联函数代码的展开速度可以消除函数调用的开销。

main()函数中,我们以这种方式调用其他几个inline函数,包括s1.SetCurrentCourse();。现在,我们有了封装访问的安全性和使用小型inline函数直接访问数据成员的速度。

这里是伴随我们完整程序示例的输出:

Jo Muritz Enrolled in: C++
Jo Muritz New course: Advanced C++ Programming

现在我们继续前进,研究我们可以添加到类成员中的另一个限定符,即const限定符。

添加const数据成员和成员初始化列表

我们已经在本书的早期部分看到了如何对变量进行常量限定以及这样做的影响。为了简要回顾,添加const限定符到变量的影响是,变量在声明时必须初始化,并且其值可能永远不再被修改。我们之前还看到了如何对指针添加const限定,这样我们就可以限定被指向的数据、指针本身,或者两者。现在,让我们看看在类中添加const限定符到数据成员意味着什么,以及必须使用特定的语言机制来初始化这些数据成员。

应该永远不会修改的数据成员应该被标记为const。一个const变量,永不修改意味着该数据成员不能使用其自己的标识符进行修改。那么,我们的任务就是确保我们不会用非const标记的对象初始化指向const对象的指针数据成员(以免我们提供了一个改变私有数据的后门)。

请记住,在 C++中,程序员总是可以从指针变量中移除 const 属性。虽然他们不应该这样做。不过,我们将采取安全措施,通过使用访问区域和适当的访问函数返回值,确保我们不会轻易提供对private数据成员的可修改访问。

成员初始化列表必须在构造函数中使用来初始化任何常量数据成员或引用数据成员。成员初始化列表提供了一种初始化可能永远不会作为赋值中的左值的成员的机制。成员初始化列表也可以用来初始化非const数据成员。出于性能考虑,成员初始化列表通常是初始化任何数据成员(常量或非常量)的首选方式。成员初始化列表还提供了一种指定任何自身为类类型的数据成员的首选构造方式(即,成员对象)。

成员初始化列表可以出现在任何构造函数中,为了表示这个列表,只需在形式参数列表之后放置一个冒号:,然后是一个以逗号分隔的数据成员列表,每个数据成员后面跟着括号中的初始值。例如,这里我们使用成员初始化列表来设置两个数据成员,gpamiddleInitial

Student::Student(): gpa(0.0), middleInitial('\0')
{   
    // Remember, firstName, lastName are member objects of
    // type string; they are default constructed and hence
    // 'empty' by default. They HAVE been initialized. 
    currentCourse = nullptr; // don't worry – we'll change
}                        // currentCourse to a string next!

尽管我们在之前的构造函数中已经使用成员初始化列表初始化了两个数据成员,但我们本可以用它来设置所有数据成员!我们很快就会看到这个命题(以及首选用法)。

成员初始化列表中的数据成员将按照它们在类定义中(即,声明)出现的顺序进行初始化(除了静态数据成员,我们很快就会看到)。接下来,执行构造函数的主体。将数据成员按与类定义中相同的顺序排列在成员初始化列表中是一个很好的约定。但请记住,实际初始化的顺序与类定义中指定的数据成员的顺序相匹配,而不管成员初始化列表的顺序如何。

有趣的是,引用必须使用成员初始化列表,因为引用被实现为常量指针。也就是说,指针本身指向一个特定的其他对象,不能指向其他地方。该对象的价值可能会改变,但引用始终引用初始化时特定的对象。

使用const修饰符与指针一起使用可能会难以确定哪些场景需要使用这个列表进行初始化,哪些则不需要。例如,指向常量对象的指针不需要使用成员初始化列表进行初始化。该指针可以指向任何对象,但一旦指向,它就不能改变解引用的值。然而,常量指针必须使用成员初始化列表进行初始化,因为指针本身被固定在特定的地址上。

让我们通过一个完整的程序示例来看看const数据成员以及如何使用成员初始化列表来初始化其值。我们还将看到如何使用此列表来初始化非const数据成员。尽管这个例子被分割并且没有全部展示,但完整的程序可以在 GitHub 仓库中找到:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter05/Chp5-Ex9.cpp

#include <iostream>  
using std::cout;    // preferred to: using namespace std;
using std::endl;
using std::string;
class Student
{
private: 
    // data members
    string firstName;
    string lastName;
    char middleInitial;
    float gpa;
    string currentCourse; // let's finally change to string
    const int studentId;  // added, constant data member
public:
    // member function prototypes
    Student();  // default constructor
    Student(const string &, const string &, char, float, 
            const string &, int); 
    Student(const Student &);  // copy constructor
    ~Student();  // destructor
    void Print();
    const string &GetFirstName() { return firstName; }  
    const string &GetLastName() { return lastName; }    
    char GetMiddleInitial() { return middleInitial; }
    float GetGpa() { return gpa; }
    const string &GetCurrentCourse() 
        { return currentCourse; }
    void SetCurrentCourse(const string &);  // proto. only
}; 

在上述Student类中,请注意我们已向类定义中添加了一个数据成员,const int studentId;。这个数据成员将需要在每个构造函数中使用成员初始化列表来初始化这个常量数据成员。

让我们看看成员初始化列表与构造函数的使用将如何工作:

// Definitions for the destructor, Print(), and 
// SetCurrentCourse() have been omitted to save space.
// They are similar to what we have seen previously.
// Constructor w/ member init. list to set data mbrs
Student::Student(): firstName(), lastName(),
middleInitial('\0'), gpa(0.0), 
                    currentCourse(), studentId(0) 
{
    // You may still set data members here, but using above
    // initialization is more efficient than assignment
    // Note: firstName, lastName are shown in member init.
    // list selecting default constructor for init.
    // However, as this is the default action for member 
    // objects (string), we don't need to explicitly incl.
    // these members in the member initialization list
    // (nor will we include them in future examples).
}
Student::Student(const string &fn, const string &ln, 
         char mi, float avg, const string &course, int id): 
         firstName(fn), lastName(ln), middleInitial(mi),
         gpa(avg), currentCourse(course), studentId (id) 
{
   // For string data members, the above init. calls    
   // the string constructor that matches the arg in ().
   // This is preferred to default constructing a string
   // and then resetting it via assignment in the
   // constructor body.
}
Student::Student(const Student &s): firstName(s.firstName),
      lastName(s.lastName), middleInitial(s.middleInitial),
gpa(s.gpa), currentCourse(s.currentCourse), 
      studentId(s.studentId)
{
   // remember to do a deep copy for any ptr data members
}
int main()
{ 
    Student s1("Renee", "Alexander", 'Z', 3.7, 
               "C++", 1290);
    cout << s1.GetFirstName() << " " << s1.GetLastName();
    cout << " has gpa of: " << s1.GetGpa() << endl;
    return 0;
}

在前面的代码片段中,我们看到有三个Student构造函数。注意三个构造函数正式参数列表后面的各种成员初始化列表。

特别值得注意的是成员初始化列表在用于类型为string(或我们稍后将看到的任何类类型)的数据成员时的用法。在这种情况下,字符串数据成员使用指定的构造函数通过成员初始化列表进行构造;也就是说,其签名与()中的参数相匹配的那个构造函数。这比默认构造每个字符串(这是之前幕后发生的事情)然后通过构造函数方法体内的赋值来重置其值要高效得多。

在这种情况下,Student默认构造函数的成员初始化列表中的默认string构造函数选择——即:firstName(), lastName(), currentCourse()——被用来强调这些数据成员是成员对象(类型为string)并且将被构造。在这种情况下,它们将各自进行默认构造,这将给它们的内容提供一个空字符串。然而,除非使用成员初始化列表进行其他指示,否则成员对象总是会被默认构造。因此,成员初始化列表中的:firstName()lastName()currentCourse()指定是可选的,并且将不会包含在未来的示例中。

每个构造函数都将使用成员初始化列表来设置const类型的数据成员的值,例如studentId。此外,成员初始化列表也可以用作简单(且更高效)的初始化任何其他数据成员的方法。我们可以通过查看默认构造函数或备用构造函数中的成员初始化列表来看到使用成员初始化列表简单设置非const数据成员的示例,例如,Student::Student() : studentId(0), gpa(0.0)。在这个例子中,gpa不是const,因此它在成员初始化列表中的使用是可选的。

下面是伴随我们完整程序示例的输出:

Renee Alexander has gpa of: 3.7

重要提示

尽管构造函数的成员初始化列表是唯一可以用来初始化const数据成员(或引用或成员对象)的机制,但它也常常是执行任何数据成员简单初始化的首选机制,出于性能考虑。在许多情况下(例如成员对象——例如,一个字符串),这可以节省数据成员首先以默认状态初始化(构造自身)然后再在构造函数体中重新赋值的步骤。

有趣的是,程序员可以选择在成员初始化列表中使用(){}来初始化数据成员。注意以下代码中使用了{}

Student::Student(const string &fn, const string &ln, 
         char mi, float avg, const string &course, int id): 
         firstName{fn}, lastName{ln}, middleInitial{mi},
         gpa{avg}, currentCourse{course}, studentId{id} 
{
}

这里使用的{}最初是为了在 C++中实例化(因此在使用成员初始化列表中完全构造数据成员时)而添加的,旨在提供一个统一的初始化语法。{}还可能控制数据类型的缩窄。然而,当与模板(我们将在第十三章与模板一起工作)一起使用std::initializer_list时,{}可能会引起语义混淆。由于这些复杂性干扰了语言统一性的目标,下一个 C++标准可能会回归到更喜欢使用(),我们也将这样做。有趣的是,从性能的角度来看,(){}都没有优势。

接下来,让我们通过添加const限定符到成员函数来继续前进。

使用 const 成员函数

我们现在已经非常详尽地看到了const限定符在数据中的应用。它也可以与成员函数结合使用。C++提供了一种语言机制来确保选定的函数不能修改数据;这种机制是应用于成员函数的const限定符。

const 成员函数是一种成员函数,它指定(并强制)该函数只能对调用该函数的对象执行只读操作。

常量成员函数意味着this的任何部分都不能被修改。然而,由于 C++允许类型转换,可以将this转换为它的非const对应类型,然后更改数据成员。但是,如果类设计者真正希望能够修改数据成员,他们就不会将成员函数标记为const

在你的程序中声明的常量实例只能调用const成员函数。否则,这些对象可能会被直接修改。

要将成员函数标记为const,应在函数原型和函数定义中的参数列表后面指定const关键字。

让我们看看一个例子。它将被分为两个部分,其中一些部分被省略;然而,完整的示例可以在 GitHub 仓库中查看:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/master/Chapter05/Chp5-Ex10.cpp

#include <iostream>  
using std::cout;   // preferred to: using namespace std;
using std::endl;
using std::string;
class Student
{
private: 
    // data members
    string firstName;
    string lastName;
    char middleInitial;
    float gpa;
    string currentCourse;
    const int studentId;   // constant data member
public:
    // member function prototypes
    Student();  // default constructor
    Student(const string &, const string &, char, float, 
            const string &, int); 
    Student(const Student &);  // copy constructor
    ~Student();  // destructor
    void Print() const;
    const string &GetFirstName() const 
        { return firstName; }  
    const string &GetLastName() const 
        { return lastName; }    
    char GetMiddleInitial() const { return middleInitial; }
    float GetGpa() const { return gpa; }
    const string &GetCurrentCourse() const
        { return currentCourse; }
    int GetStudentId() const { return studentId; }
    void SetCurrentCourse(const string &);  // proto. only
};

在前面的程序片段中,我们看到一个Student类的定义,这个定义对我们来说越来越熟悉。然而,请注意,我们已经将const限定符添加到大多数访问成员函数上,即那些只提供数据只读访问的方法。

例如,让我们考虑float GetGpa() const { return gpa; }。参数列表后面的const关键字表示这是一个常量成员函数。请注意,这个函数没有修改由this指向的任何数据成员。它不能这样做,因为它被标记为const成员函数。

现在,让我们继续这个示例的剩余部分:

// Definitions for the constructors, destructor, and 
// SetCurrentCourse() have been omitted to save space.
// Student::Print() has been revised, so it is shown below:
void Student::Print() const
{
    cout << firstName << " " << middleInitial << ". ";
    cout << lastName << " with id: " << studentId;
    cout << " and gpa: " << gpa << " is enrolled in: ";
    cout << currentCourse << endl;
}
int main()
{
    Student s1("Zack", "Moon", 'R', 3.75, "C++", 1378); 
    cout << s1.GetFirstName() << " " << s1.GetLastName();
    cout << " Enrolled in " << s1.GetCurrentCourse();
    cout << endl;
    s1.SetCurrentCourse("Advanced C++ Programming");  
    cout << s1.GetFirstName() << " " << s1.GetLastName();
    cout << " New course: " << s1.GetCurrentCourse();
    cout << endl;
    const Student s2("Gabby", "Doone", 'A', 4.0, 
                     "C++", 2239);
    s2.Print();
    // Not allowed, s2 is const
    // s2.SetCurrentCourse("Advanced C++ Programming");  
    return 0;
}

在程序的其余部分,请注意,我们再次选择不包含我们已熟悉的成员函数的定义,例如构造函数、析构函数和void Student::SetCurrentCourse()

相反,让我们关注具有以下签名的成员函数:void Student::Print() const。在这里,参数列表后面的const关键字表示在这个函数的作用域内,由this指向的任何数据成员都不能被更改。实际上并没有。同样,在void Student::Print()中调用的任何成员函数也必须是const成员函数。否则,它们可能会修改this

接下来,我们来检查我们的main()函数。我们实例化一个Student,即s1。这个Student调用几个成员函数,包括一些const成员函数。然后使用Student::SetCurrentCourse()更改s1的当前课程,并打印出这个课程的新值。

接下来,我们实例化另一个Student,即s2,它被标记为const。请注意,一旦这个学生被实例化,可以应用于s2的唯一成员函数是那些被标记为const的。否则,实例可能会被修改。然后我们使用Student::Print();打印s2的数据,这是一个const成员函数。

你注意到被注释掉的代码行:s2.SetCurrentCourse("Advanced C++ Programming");吗?这一行是非法的,并且无法编译,因为SetCurrentCourse()不是一个常量成员函数,因此不适宜通过常量实例(如s2)来调用。

让我们来看看完整程序示例的输出:

Zack Moon Enrolled in C++
Zack Moon New course: Advanced C++ Programming
Gabby A. Doone with id: 2239 and gpa: 3.9 is enrolled in: C++

现在我们已经完全探讨了const成员函数,让我们继续本章的最后部分,深入探讨static数据成员和static成员函数。

利用静态数据成员和静态成员函数

现在我们已经使用 C++类来定义和实例化对象了,让我们通过探索类属性的概念来丰富我们对面向对象概念的了解。一个旨在被特定类的所有实例共享的数据成员被称为类属性

通常,给定类的每个实例都有其数据成员的不同值。然而,有时,让给定类的所有实例共享一个包含单个值的数据成员可能是有用的。在 C++中,可以使用静态数据成员来模拟类属性的概念。

静态数据成员本身被实现为外部(全局)变量,其作用域通过名称修饰与相关的类绑定。因此,每个静态数据成员的作用域都可以限制在相关的类中。

使用关键字static在类定义中指定静态数据成员。为了完成对static数据成员的建模,必须在类定义之外,在static数据成员指定之后,添加一个外部变量定义。此类成员的存储是通过包含其底层实现的那个外部变量获得的。

类或结构中的static数据成员。一个static成员函数不接收this指针,因此它只能操作static数据成员和其他外部(全局)变量。

要表示一个static成员函数,必须在成员函数原型中函数返回类型之前指定关键字static。关键字static不得出现在成员函数定义中。如果函数定义中出现了关键字static,则该函数在 C 语言编程中也将是static的;也就是说,该函数的作用域将限制在定义它的文件中。

让我们看看static数据成员和成员函数使用的一个示例。下面的示例将被分成几个部分,但是它将完整地展示,没有任何函数被省略或缩写,因为它是本章的最后一个示例。它也可以在 GitHub 仓库中找到:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter05/Chp5-Ex11.cpp

#include <iostream>  
#include <cstring>   // though we'll prefer std::string, 
// one pointer data member will illustrate one last concept
using std::cout;     // preferred to: using namespace std;
using std::endl;
using std::string;
class Student
{
private: 
    // data members
    string firstName;
    string lastName;
    char middleInitial;
    float gpa;
    string currentCourse;
    const char *studentId;  // pointer to constant string
    static int numStudents; // static data member
public:
    // member function prototypes
    Student();  // default constructor
    Student(const string &, const string &, char, float, 
            const string &, const char *); 
    Student(const Student &);  // copy constructor
    ~Student();  // destructor
    void Print() const;
    const string &GetFirstName() const 
        { return firstName; }  
    const string &GetLastName() const { return lastName; } 
    char GetMiddleInitial() const { return middleInitial; }
    float GetGpa() const { return gpa; }
    const string &GetCurrentCourse() const 
        { return currentCourse; }
    const char *GetStudentId() const { return studentId; }
    void SetCurrentCourse(const string &);
    static int GetNumberStudents(); // static mbr function 
};
// definition for static data member 
// (which is implemented as an external variable)
int Student::numStudents = 0;  // notice initial value of 0
                    // which is default for integral values
// Definition for static member function
inline int Student::GetNumberStudents()
{
    return numStudents;
}
inline void Student::SetCurrentCourse(const char *c) 
{
    // far easier implementation to reset using a string
    currentCourse = c;
}

在包含我们完整示例的第一个代码段中,我们有 Student 类的定义。在 private 访问区域,我们添加了一个数据成员,static int numStudents;,来模拟面向对象的概念,即类属性,这是一个将被此类所有实例共享的数据成员。

接下来,注意在这个类定义的末尾,我们添加了一个 static 成员函数,static int GetNumberStudents();,以提供对 private 数据成员 numStudents 的封装访问。注意,仅在原型中添加了 static 关键字。如果我们查看类定义之外的成员函数定义 int Student::GetNumberStudents(),我们会注意到在函数本身的定义中没有使用 static 关键字。这个成员函数的主体只是简单地返回共享的 numStudents,即静态数据成员。

还要注意,在类定义下方是外部变量定义,用于支持静态数据成员的实现:int Student::numStudents = 0;。注意,在这个声明中使用 ::(作用域解析运算符)将类名与标识符 numStudents 相关联。尽管这个数据成员作为外部变量实现,并且因为数据成员被标记为 private,它只能由 Student 类内的成员函数访问。将 static 数据成员作为外部变量实现有助于我们了解这个共享数据的内存来源;它不是任何类实例的一部分,而是作为全局命名空间中的独立实体存储。还要注意,声明 int Student::numStudents = 0; 将这个共享变量初始化为零。

作为有趣的补充,注意在这个 Student 类的新版本中,数据成员 studentId 已经从 const int 改为 const char *studentId;。记住,这意味着 studentId 是一个指向常量字符串的指针,而不是一个常量指针。因为指针本身的内存不是 const,所以这个数据成员不需要使用成员初始化列表进行初始化,但它需要一些特殊处理。

让我们继续前进,回顾这个类包含的其他成员函数:

// Default constructor (note member init. list usage)
// Note: firstName, lastName, currentCourse as member 
// objects (type string), will be default constructed 
// to empty strings
Student::Student(): middleInitial('\0'), gpa(0.0), 
                    studentId(nullptr)
{
    numStudents++;       // increment static counter
}
// Alternate constructor member function definition
Student::Student(const char *fn, const char *ln, char mi, 
          float avg, const char *course, const char *id): 
          firstName(fn), lastName(ln), middleInitial(mi),
          gpa(avg), currentCourse(course)
{   
    // Because studentId is a const char *, we can't change
    // value pointed to directly! We enlist temp for help.
    char *temp = new char [strlen(id) + 1];
    strcpy (temp, id);  // studentId can't be an l-value,  
    studentId = temp;   // but temp can!
    numStudents++;      // increment static counter
}
// copy constructor
Student::Student(const Student &s): firstName(s.firstName),
       lastName(s.lastName),middleInitial(s.middleInitial),
       gpa(s.gpa), currentCourse(s.currentCourse)
{
    delete studentId;  // release prev. allocated studentId
    // Because studentId is a const char *, we can't change
    // value pointed to directly! Temp helps w deep copy.
    char *temp = new char [strlen(s.studentId) + 1];
    strcpy (temp, s.studentId); // studentId can't be an 
    studentId = temp;           // l-value, but temp can!
    numStudents++;    // increment static counter
}

Student::~Student()    // destructor definition
{   
    delete [] studentId;
    numStudents--;   // decrement static counter
}
void Student::Print() const
{
   cout << firstName << " " << middleInitial << ". ";
   cout << lastName << " with id: " << studentId;
   cout << " and gpa: " << gpa << " and is enrolled in: ";
   cout << currentCourse << endl;
}

在之前的成员函数程序段中,大多数成员函数看起来与我们习惯看到的样子相似,但也有一些细微的差别。

一个与我们的static数据成员相关的差异是,numStudents在每个构造函数中被增加,在析构函数中被减少。由于这个static数据成员被class Student的所有实例共享,每次创建一个新的Student实例时,计数器将增加,当一个Student实例不再存在并且其析构函数被隐式调用时,计数器将减少以反映这样一个实例的移除。这样,numStudents将准确地反映在我们的应用程序中存在多少个Student实例。

这段代码还有其他一些有趣的细节需要注意,与static数据成员和成员函数无关。例如,在我们的类定义中,我们将studentIdconst int更改为const char *。这意味着所指向的数据是常量,而不是指针本身,因此我们不需要使用成员初始化列表来初始化这个数据成员。

尽管如此,在默认构造函数中,我们选择使用成员初始化列表将studentId初始化为一个空指针,nullptr。回想一下,我们可以为任何数据成员使用成员初始化列表,但我们必须使用它们来初始化const数据成员。也就是说,如果const部分等于使用实例分配的内存。由于实例内部为数据成员studentId分配的内存是一个指针,而这个数据成员的指针部分不是const(只是所指向的数据),因此我们不需要为这个数据成员使用成员初始化列表。我们只是选择这样做。

然而,因为studentId是一个const char *,这意味着标识符studentId不能作为左值,或者不能在赋值表达式的左侧。在替代构造函数和复制构造函数中,我们希望初始化studentId并需要能够将studentId用作左值。但我们不能。我们通过声明一个辅助变量char *temp;并分配它以包含我们需要的内存量来规避这个困境。然后,我们将所需数据加载到temp中,最后,我们将studentId指向temp以为studentId建立一个值。当我们离开每个构造函数时,局部指针temp将从栈上弹出;然而,现在内存被studentId捕获并被视为const

最后,在析构函数中,我们使用delete [] studentId;删除与const char *studentid关联的内存。值得注意的是,在较旧的编译器中,我们反而需要将studentId强制类型转换为非常量char *;即delete const_cast<char *>(studentId);,因为操作符delete()之前并不期望一个常量限定指针。

现在我们已经完成了对成员函数中新细节的审查,让我们继续检查这个程序示例的最后部分:

int main()
{
   Student s1("Nick", "Cole", 'S', 3.65, "C++", "112HAV"); 
   Student s2("Alex", "Tost", 'A', 3.78, "C++", "674HOP"); 
   cout << s1.GetFirstName() << " " << s1.GetLastName();
   cout << " Enrolled in " << s1.GetCurrentCourse();
   cout << endl;
   cout << s2.GetFirstName() << " " << s2.GetLastName();
   cout << " Enrolled in " << s2.GetCurrentCourse();
   cout << endl;

   // call a static member function in the preferred manner
   cout << "There are " << Student::GetNumberStudents(); 
   cout << " students" << endl;
   // Though not preferable, we could also use:
   // cout << "There are " << s1.GetNumberStudents(); 
   // cout << " students" << endl;
   return 0;
}

在我们程序的 main() 函数中,我们首先创建了两个 Students 实例,s1s2。随着每个实例通过构造函数进行初始化,共享数据成员 numStudents 的值增加,以反映我们应用程序中的学生数量。注意,外部变量 Student::numStudents,它为这个共享数据成员保留内存,在程序开始时通过我们代码中的以下语句初始化为 0int Student::numStudents = 0;

在打印出每个 Student 的详细信息之后,我们然后使用 static 访问函数 Student::GetNumStudents() 打印出 static 数据成员 numStudents。调用此函数的首选方法是 Student::GetNumStudents();。因为 numStudentsprivate 的,只有 Student 类的方法可以访问这个数据成员。我们现在已经通过 static 成员函数提供了对 static 数据成员的安全、封装的访问。

有趣的是要记住,static 成员函数不会接收到 this 指针,因此,它们可能操作的唯一数据将是类中的 static 数据(或其他外部变量)。同样,它们可能调用的唯一其他函数将是同一类中的其他 static 成员函数或外部非成员函数。

同样有趣的是,我们似乎可以通过任何实例调用 Student::GetNumStudents(),例如 s1.GetNumStudents();,正如我们在注释掉的代码部分所看到的。尽管看起来我们是通过实例调用成员函数,但函数不会接收到 this 指针。相反,编译器重新解释了调用,看起来是通过实例,并将其替换为对内部、名称混淆函数的调用。从编程的角度来看,使用第一种调用方法调用 static 成员函数更清晰,而不是看似通过一个永远不会传递给函数本身的实例。

最后,这是我们的完整程序示例的输出:

Nick Cole Enrolled in C++
Alex Tost Enrolled in C++
There are 2 students

现在我们已经回顾了本章的最终示例,是时候回顾我们所学的一切了。

摘要

在本章中,我们开始了面向对象编程的旅程。我们学习了大量的面向对象的概念和术语,并看到了 C++ 如何直接提供语言支持来实现这些概念。我们看到了 C++ 类如何支持封装和信息隐藏,以及实现支持这些理念的设计如何导致代码更容易修改和维护。

我们详细介绍了类的基本知识,包括成员函数。我们通过检查成员函数的内部,包括理解 this 指针是什么以及它是如何工作的——包括隐式接收 this 指针的成员函数的底层实现——来深入了解了成员函数。

我们已经探讨了访问标签和访问区域。通过将我们的数据成员分组在private访问区域,并提供一系列public成员函数来操作这些数据成员,我们发现我们可以提供一种安全、受控和经过良好测试的方法来从每个类的限制中操作数据。我们看到,对类的更改可以限制在成员函数本身。类的用户不需要知道数据成员的底层表示——这些细节是隐藏的,可以根据需要更改而不会在应用程序的其他地方引起连锁反应。

我们通过检查默认构造函数、典型(重载)构造函数、复制构造函数甚至转换构造函数,深入研究了构造函数的许多方面。我们已经介绍了析构函数,并理解了它的正确用法。

我们通过使用各种限定符对数据成员和成员函数进行操作,为我们的类增加了额外的风味,例如使用inline以提高效率,使用const来保护数据并确保函数也会这样做,使用static数据成员来模拟面向对象的类属性概念,以及使用static方法来提供对这些static数据成员的安全接口。

通过沉浸在面向对象编程中,我们已经获得了一套关于 C++中类的全面技能。凭借一套全面的技能和经验,以及对我们各自使用的类和面向对象编程的欣赏,我们现在可以继续前进到第六章使用单一继承实现层次结构,学习如何扩展相关类的层次结构。让我们继续前进!

问题

  1. 创建一个 C++程序来封装一个Student。你可以使用你之前的练习的部分内容。尽量自己完成,而不是依赖任何在线代码。你需要这个类作为前进的基础,以学习未来的示例;现在是尝试每个功能的好时机。包括以下步骤:

    1. 创建或修改你之前的Student类,以完全封装一个学生。确保包括几个可以动态分配的数据成员。提供几个重载构造函数,以提供初始化你的类的方法。确保包括一个复制构造函数。此外,包括一个析构函数来释放任何动态分配的数据成员。

    2. 向你的类添加一系列访问函数,以提供对类内数据成员的安全访问。决定你将为哪些数据成员提供GetDataMember()接口,以及是否任何这些数据成员应该具有使用SetDataMember()接口在构造后重置的能力。根据适当的情况,将这些方法的constinline限定符应用于这些方法。

    3. 确保使用适当的访问区域 - 对于数据成员使用private,对于一些辅助成员函数可能也需要使用private以分解更大的任务。根据需要添加public成员函数,超出之前的访问函数。

    4. 在你的类中包含至少一个const数据成员,并使用成员初始化列表来设置此成员。添加至少一个static数据成员和一个static成员函数。

    5. 使用每个构造函数签名实例化一个Student,包括拷贝构造函数。使用new()动态分配几个实例。确保在完成这些实例后删除它们(以便调用它们的析构函数)。

第六章:使用单继承实现层次结构

本章将扩展我们在 C++中追求面向对象编程的旅程。我们将从介绍额外的 OO 概念开始,例如泛化特化,然后理解这些概念是如何通过直接语言支持在 C++中实现的。我们将开始构建相关类的层次结构,并理解每个类如何成为我们应用程序中更容易维护、可能可重用的构建块。我们将理解本章中提出的新 OO 概念将如何支持精心设计的计划,并且我们将清楚地理解如何使用健壮的 C++代码实现这些设计。

在本章中,我们将涵盖以下主要主题:

  • 泛化和特化的面向对象概念,以及Is-A关系

  • 单继承基础 – 定义派生类,访问继承成员,理解继承访问标签和区域,以及final类指定

  • 单继承层次结构中的构造和析构序列;使用成员初始化列表选择基类构造函数

  • 修改基类列表中的访问标签 – publicprivateprotected基类 – 以改变继承的 OO 目的为实现继承

到本章结束时,你将理解面向对象的概念,如泛化和特化,并了解如何将继承用于 C++,作为一种实现这些理想的机制。你将了解诸如基类和派生类等术语,以及构建层次结构时的 OO 动机,例如支持“是...的”关系或支持实现继承。

具体来说,你将理解如何使用单继承来扩展继承层次结构,以及如何访问继承的数据成员和成员函数。你还将理解基于它们定义的访问区域,你可以直接访问哪些继承成员。

你将理解在派生类类型的实例化和销毁时,构造函数和析构函数调用的顺序。你将知道如何利用成员初始化列表来选择派生类对象在其自身构造过程中可能需要利用的潜在继承构造函数组中的哪一个。

你还将理解如何通过改变基类列表中的访问标签来改变你正在构建的继承层次结构的 OO 含义。通过比较公共、私有和受保护的基类,你将理解不同类型的层次结构,例如那些用于支持“是...的”关系与那些用于支持实现继承的层次结构。

通过理解 C++中单继承的直接语言支持,你将能够实现泛化和特殊化的面向对象概念。你层次结构中的每个类都将是一个更容易维护的组件,并可以作为创建新的、更专业组件的潜在构建块。让我们通过详细说明单继承来进一步理解 C++作为面向对象语言。

技术要求

完整程序示例的在线代码可以在以下 GitHub URL 找到:github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/tree/main/Chapter06。每个完整程序示例都可以在 GitHub 上找到,位于相应章节标题(子目录)下的文件中,该文件以章节编号开头,后面跟着一个连字符,然后是本章中的示例编号。例如,本章的第一个完整程序可以在上述 GitHub 目录下的Chapter06子目录中找到,文件名为Chp6-Ex1.cpp

本章的 CiA 视频可以在以下链接查看:bit.ly/3R7uNci

扩展面向对象概念和术语

在本节中,我们将介绍必要的面向对象概念,以及伴随这些关键思想的适用术语。

第五章详细探索类,你现在已经理解了封装和信息隐藏的关键面向对象思想,以及 C++如何通过类支持这些理念。现在,我们将探讨如何通过使用一个非常通用的类作为构建块,然后通过创建一个更具体的类来扩展该类,从而构建一个相关类的层次结构。通过以这种方式重复构建相关类的层次结构,面向对象系统提供了潜在的复用构建块。层次结构中的每个类都是封装的,因此对特定类的维护和升级可以更容易地进行,而不会影响整个系统。通过使用更具体和更详细的类逐步细化每个类,以构建相关类的层次结构,每个组件的特定维护集中在维护和变更的焦点区域。

让我们从扩展我们的基本面向对象术语开始。

解密泛化和特殊化

本章扩展的主要面向对象概念是泛化特殊化。将这些原则纳入你的设计中,将为编写更易于修改和维护的代码,以及为可能在相关应用程序中复用的代码提供基础。

泛化描述了从一组类中抽象出共同性,并为该组创建一个更通用的类,以便容纳共同的属性和行为。这个更通用的类可以被称为基类(或父类)。泛化还可以用来将单个类的更通用属性和行为收集到一个基类中,期望这个新的、通用的类可以后来作为构建块或基础,用于创建更多、更具体的(派生)类。

特殊化描述了从现有的通用基类派生出一个新类的过程,目的是为了添加特定的、可区分的属性和行为,以便充分表示新类。这个特殊化的类也可以被称为派生类(或子类)。通过特殊化,类层次结构可以逐步细化各自的属性和行为。

虽然重用很难实现,但泛化和特殊化等面向对象的概念使得重用更容易获得。重用可以在性质相似的应用程序中、在同一项目领域中、在现有项目的延续中,或者在相关领域中实现,在这些领域中至少最通用的类和相关组件可以被重用。

构建层次结构是 C++语言的一个基本特性。让我们通过探索单继承来将这个想法付诸实践。

理解单继承基础

继承是 C++语言机制,它允许实现泛化和特殊化的概念。单继承是指一个给定的类恰好有一个直接基类。C++支持单继承和多继承;然而,在本章中,我们将专注于单继承,并在后面的章节中介绍多继承。

在 C++中,可以使用类和结构来构建继承层次结构。然而,类通常比结构更常用于支持继承和面向对象。

为了通用化和特殊化而构建的继承层次结构支持一个Person类和一个派生类Student,我们可以说一个学生是一个人。也就是说,StudentPerson的一个特殊化,它在其基类Person提供的数据成员和成员函数之上添加了额外的数据成员和成员函数。通过泛化和特殊化指定 Is-A 关系是使用继承创建基类和派生类最典型的原因。在本章的后面,我们将探讨利用继承的另一个原因。

让我们从查看 C++语言机制开始,以指定基类和派生类并定义继承层次结构。

定义基类和派生类以及访问继承成员

在单继承中,派生类指定其直接祖先或基类是谁。基类不指定它有任何派生类。

派生类只需在其类名后添加一个:,然后跟上一个关键字public(目前是这样),最后是具体的基类名称。每当你在基类列表中看到public关键字时,这意味着我们正在使用继承来指定一个“是”关系。

这里有一个简单的例子来说明基本的语法:

  • StudentPerson的派生类:

    class Person  // base class
    {
    private:
        string name;
        string title;
    public:
        // constructors, destructor, 
        // public access functions, public interface etc.
        const string &GetTitle() const { return title; }
    };
    class Student: public Person  // derived class
    {
    private:
        float gpa;
    public:
        // constructors, destructor specific to Student,
        // public access functions, public interface, etc.
        float GetGpa() const { return gpa; }
    }; 
    

这里,基类是Person,派生类是Student。派生类只需定义额外的数据成员和成员函数,以增强基类中指定的那些。

派生类的实例通常可以访问由派生类或派生类的任何祖先指定的public成员。继承成员的访问方式与派生类指定的方式相同。回想一下,点符号(.)用于访问对象的成员,箭头符号(->)用于访问指向对象的指针的成员。

当然,为了使这个例子完整,我们需要添加适用的构造函数,我们目前假设它们存在。自然地,与构造函数相关的细微差别,我们将在本章的后续内容中讨论。

  • 使用上述类,我们可以看到对继承成员的简单访问如下:

    int main()
    {   
        // Let's assume the applicable constructors exist
        Person p1("Cyrus Bond", "Mr.");
        Student *s1 = new Student("Anne Lin", "Ms.", 4.0);
        cout << p1.GetTitle() << " " << s1->GetTitle();
        cout << s1->GetGpa() << endl;
        delete s1; // remember to relinquish alloc. memory
        return 0;
    }
    

在之前的代码片段中,由s1指向的Student派生类实例可以访问基类和派生类成员,例如Person::GetTitle()Student::GetGpa()Person基类实例p1只能访问其自身的成员,例如Person::GetTitle()

查看前一个示例的记忆模型,我们有以下内容:

图 6.1 – 当前示例的内存模型

图 6.1 – 当前示例的内存模型

注意,在前面的内存模型中,Student实例由一个Person子对象组成。也就是说,在指示*s1开始的内存地址处,我们首先看到其Person数据成员的内存布局。然后,我们看到其额外的Student数据成员的内存布局。当然,p1,它是一个Person,只包含Person数据成员。

对基类和派生类成员的访问将受每个类指定的访问区域所约束。让我们看看继承的访问区域是如何工作的。

检查继承的访问区域

访问区域,包括继承的访问区域,定义了成员(包括继承成员)可以直接访问的范围。

派生类继承了其基类中指定的所有成员。然而,对这些成员的直接访问受基类中指定的访问区域所约束。

基类 继承的成员(数据和函数)可以通过基类施加的访问区域访问 派生类。继承的访问区域以及它们与派生类访问的关系如下:

  • 在基类中定义的 private 成员在基类的作用域之外不可访问。类的作用域包括该类的成员函数。

  • 在基类中定义的 protected 成员可以在基类的范围内以及派生类或其派生类的范围内访问。这意味着这些类的成员函数。

  • 在基类中定义的 public 成员可以从任何作用域访问,包括派生类的作用域。

在前面的简单示例中,我们注意到一个 Person 实例和一个 Student 实例都从 main() 的作用域中调用了 public 成员函数 Person::GetTitle()。同样,我们也注意到 Student 实例从 main() 中调用了其 public 成员 Student::GetGpa()。通常,在给定类的范围之外,可访问的成员只有那些在公共接口中的成员,例如在这个例子中。

我们将在本章中很快看到一个更大的、完整的程序示例,展示 protected 访问区域。但首先,让我们发现一个可能有助于确定我们的继承层次结构形状和可扩展性的附加指定符。

指定类为最终

在 C++ 中,我们可以指示一个类在我们的继承层次结构中不能进一步扩展。这被称为在基类列表中使用 final 来指定一个类为 final(不可扩展)类或 叶节点

这里有一个简单的示例来说明基本语法:

  • 给定我们之前的基类 PersonStudentPerson派生类。此外,GradStudentStudent最终派生类

    class GradStudent final: public Person // derived class
    {
       // class definition
    };
    

在这里,GradStudent 被指定为一个最终、不可扩展的类。因此,GradStudent 可能不会出现在新派生类的基础类列表中。

接下来,让我们回顾继承的构造函数和析构函数,以便我们即将到来的完整程序示例可以提供更大的整体效用。

理解继承的构造函数和析构函数

通过单继承,我们可以构建一个相关类的层次结构。我们已经看到,当我们实例化一个派生类对象时,其基类数据成员的内存随后是额外派生类数据成员所需的内存。每个这些子对象都需要被构造。幸运的是,每个类都将定义一套用于此目的的构造函数。然后我们需要了解语言如何被利用,以便在实例化和构造派生类对象时指定适当的基类构造函数。

同样地,当一个派生类类型的对象不再需要并被销毁时,需要注意的是,将隐式调用每个组成派生类实例的子对象的析构函数。

让我们看看单继承层次结构中的构造函数和析构函数的调用顺序,以及当在派生类实例中找到一个基类子对象时,如果存在多个构造函数可供选择,我们如何做出选择。

隐式构造函数和析构函数调用

构造函数和析构函数是两种派生类没有显式继承的成员函数。这意味着不能使用基类构造函数的签名来实例化派生类对象。然而,我们将看到,当实例化派生类对象时,将分别使用每个类的相应构造函数来单独初始化整体对象中的基类和派生类部分。

当实例化派生类类型的对象时,不仅会调用其构造函数,还会调用其所有先前基类的构造函数。最一般的基类构造函数首先执行,然后是层次结构中的所有构造函数,直到我们到达与当前实例类型相同的派生类构造函数。

同样地,当一个派生类实例超出作用域(或显式地释放了指向实例的指针)时,所有相关的析构函数都将被调用,但调用顺序与构造顺序相反。首先执行派生类析构函数,然后以向上递归的方式调用并执行每个先前基类的析构函数,直到达到最一般的基类。

你现在可能会问,在实例化派生类时,我如何从一组潜在的基类构造函数中选择我的基类子对象?让我们更详细地看看成员初始化列表,以发现解决方案。

使用成员初始化列表选择基类构造函数

成员初始化列表可以用来指定在实例化派生类对象时应该调用哪个基类构造函数。每个派生类构造函数可以指定使用不同的基类构造函数来初始化派生类对象中给定的基类部分。

如果派生类构造函数的成员初始化列表没有指定应该使用哪个基类构造函数,将调用默认的基类构造函数。

成员初始化列表是在派生类构造函数中的参数列表之后使用 : 指定的。为了指定应该使用哪个基类构造函数,可以指示基类构造函数的名称,后面跟着括号,包括要传递给该基类构造函数的任何值。根据基类名称之后基类列表中参数的签名,将选择合适的基类构造函数来初始化派生类对象中的基类部分。

这里有一个简单的例子来说明基类构造函数选择的基本语法:

  • 让我们从基本的类定义开始(注意,省略了许多成员函数和一些常用的数据成员):

    class Person
    {
    private:
        string name;
        string title;
    public:
        Person() = default;  // various constructors
        Person(const string &, const string &); 
        Person(const Person &);
        // Assume the public interface, access fns. exist
    };
    class Student: public Person
    {
    private:
        float gpa = 0.0;  // use in-class initializer
    public:
        Student() = default;
        Student(const string &, const string &, float);
        // Assume the public interface, access fns. exist
    };
    
  • 之前类定义的构造函数如下(注意两个派生类构造函数使用了成员初始化列表):

    // Base class constructors
    // Note: default constructor is included by = default
    // specification in Person constructor prototype
    Person::Person(const string &n, const string &t): 
                   name(n), title(t)
    {    
    }
    Person::Person(const Person &p): 
                   name(p.name), title(p.title)
    {   
    }
    // Derived class constructors
    // Note: default constructor is included by = default
    // specification in Student constructor prototype and
    // gpa is set with value of in-class initializer (0.0)
    
    Student::Student(const char *n, const char *t, 
                     float g): Person(n, t), gpa(g)
    {                    
    }                 
    
    Student::Student(const Student &s): Person(s),
                                        gpa(s.gpa)
    {                                  
    }
    

在之前的代码片段中,请注意,系统提供的默认派生类构造函数 Student::Student() 已经被选中,并在构造函数原型中添加了 =default。在这个类定义中有另一个构造函数,如果我们想支持这个简单的类实例化接口,这个指定(或者通过我们自己编写默认构造函数)是必要的。记住,我们只有在类定义中没有其他构造函数(即实例化方法)时,才会得到系统提供的默认构造函数。

接下来,请注意在替代派生类构造函数 Student::Student(const string &, const string &, float) 中,用于基类构造函数指定的成员初始化列表的使用。在这里,选择与 Person::Person(const string &, const string &) 签名匹配的 Person 构造函数来初始化当前的 Person 子对象。此外,请注意,从 Student 构造函数中传递的参数 nt 被传递到上述 Person 构造函数中,以帮助完成 Person 子对象的初始化。如果我们没有在成员初始化列表中指定应该使用哪个 Person 基类构造函数,将使用默认的 Person 构造函数来初始化 StudentPerson 基类子对象。此外,这个构造函数还使用了成员初始化列表来初始化在 Student 类定义中引入的数据成员(例如 gpa)。

现在,注意在派生类的复制构造函数Student::Student(const Student &)中,使用了成员初始化列表来选择Person的复制构造函数,将s作为参数传递给Person的复制构造函数。在这里,由s引用的对象是一个Student,然而,Student内存的上部包含Person的数据成员。因此,隐式地将Student提升为Person是可接受的,以便Person的复制构造函数初始化Person子对象。此外,在Student复制构造函数的成员初始化列表中,Student类定义中添加的额外数据成员被初始化,即通过初始化gpa(s.gpa)。这些额外的数据成员也可以在这个构造函数的主体中设置。

现在我们已经了解了如何利用成员初始化列表来指定基类构造函数,让我们继续一个完整的程序示例。

将所有部件组合在一起

到目前为止,在本章中,我们已经看到了许多贡献于一个完整的程序示例的片段。看到我们的代码在行动中,以及所有其各种组件,是很重要的。我们需要看到继承的基本机制,如何使用成员初始化列表来指定应该隐式调用的哪个基类构造函数,以及protected访问区域的重要性。

让我们看看一个更复杂、完整的程序示例,以充分说明单继承。这个示例将被分成几个部分;完整的程序可以在以下 GitHub 位置找到:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter06/Chp6-Ex1.cpp

#include <iostream>
#include <iomanip>
using std::cout;  // preferred to: using namespace std;
using std::endl;
using std::setprecision;
using std::string;
using std::to_string;
class Person
{
private: 
   // data members   
   string firstName; // str mbrs are default constructed,
   string lastName;  // so don't need in-class initializers
   char middleInitial = '\0';  // in-class initialization
   string title;  // Mr., Ms., Mrs., Miss, Dr., etc.
protected: // make avail. to derived classes in their scope
   void ModifyTitle(const string &); 
public:
   Person() = default;   // default constructor
   Person(const string &, const string &, char, 
          const string &);  
   // We get default copy constructor and destructor even
   // without the below protypes; hence, commented out
   // Person(const Person &) = default;  // def. copy ctor
   // ~Person() = default;  // use default destructor
   // inline function definitions
   const string &GetFirstName() const { return firstName; }  
   const string &GetLastName() const { return lastName; }    
   const string &GetTitle() const { return title; } 
   char GetMiddleInitial() const { return middleInitial; }
};

在前面的类定义中,我们现在有一个完整的Person类定义,比我们在这个部分以前使用的简单语法示例包含更多的细节。注意,我们引入了一个protected访问区域,并将成员函数void ModifyTitle(const string &);放置在这个访问区域中。

继续前进,让我们检查Person的非线成员函数定义:

// Default constructor included with = default in prototype
// With in-class initialization, it is often not necessary
// to write the default constructor yourself.
// alternate constructor
Person::Person(const string &fn, const string &ln, char mi,
               const string &t): firstName(fn),
               lastName(ln), middleInitial(mi), title(t)
{
   // dynamically allocate memory for any ptr data members
}
// We are using default copy constructor; let's see what
// it would look like if we prototyped/defined it ourselves
// (so we may better understand an upcoming discussion with
// the upcoming derived class copy constructor). Also,
// this is what the system-supplied version may look like.
// Person::Person(const Person &p): firstName(p.firstName),
//    lastName(p.lastName), middleInitial(p.middleInitial),
//    title(p.title)
// {
        // deep copy any pointer data members here
// }
// Using default destructor – no need to write it ourselves
void Person::ModifyTitle(const string &newTitle)
{
   title = newTitle;
}

对于上述Person成员函数的实现是预期的。现在,让我们添加派生类Student的类定义,以及其内联函数定义:

class Student: public Person
{
private: 
   // data members
   float gpa = 0.0;   // in-class initialization
   string currentCourse;  
   const string studentId;  // studentId is not modifiable
   static int numStudents; // static data mbr. init. occurs
public:                  // outside of the class definition
   // member function prototypes
   Student();   // we will provide default constructor
   Student(const string &, const string &, char, 
           const string &, float, const string &, 
           const string &); 
   Student(const Student &);  // copy constructor
   ~Student();  // we will provide destructor
   void Print() const;
   void EarnPhD();  // public interface to inherited 
                    // protected member
   // inline function definitions
   float GetGpa() const { return gpa; }
   const string &GetCurrentCourse() const 
       { return currentCourse; }
   const string &GetStudentId() const { return studentId; }
   // prototype only, see inline function definition below
   void SetCurrentCourse(const string &);
   static int GetNumberStudents(); // static mbr function
};
// definition for static data mbr. (implemented as extern)
int Student::numStudents = 0;  // notice initial value of 0
inline void Student::SetCurrentCourse(const string &c)
{
   currentCourse = c;
}
// Definition for static member function (it's also inline)
inline int Student::GetNumberStudents()
{
    return numStudents;
}

Student的前面定义中,class Student使用public继承(即公共基类)从Person派生,这支持一个 Is-A 关系。注意在派生类定义中冒号后面的基类列表之后的public访问标签(即class Student: public Person)。注意,我们的Student类添加了比从Person自动继承的更多的数据成员和成员函数。

接下来,添加非内联的Student成员函数,我们继续扩展我们的代码:

// Default constructor uses in-class init. for gpa, while
// currentCourse (string mbr object) is default constructed
Student::Student(): studentId(to_string(numStudents + 100) 
                              + "Id")
{
   // Since studentId is const, we need to initialize it 
   // during construction using member init list (above)
   // Also, remember to dynamically allocate memory for any 
   // pointer data mbrs. here (not needed in this example)
   numStudents++;   // increment static counter
}
// alternate constructor
Student::Student(const string &fn, const string &ln, 
                 char mi, const string &t, float avg, 
                 const string &course, const string &id):
            Person(fn, ln, mi, t),
            gpa(avg), currentCourse(course), studentId(id)
{
   // Remember to dynamically allocate memory for any 
   // pointer data members (none in this example) 
   numStudents++;   // increment static counter
}
// copy constructor 
Student::Student(const Student &s): Person(s), gpa(s.gpa),
                 currentCourse(s.currentCourse),
                 studentId(s.studentId)
{
   // deep copy any ptr data mbrs (none in this example)
   numStudents++;   // increment static counter
}

// destructor definition
Student::~Student()
{
   // Remember to release memory for any dynamically 
   // allocated data members (none in this example)
   numStudents--;  // decrement static counter
}
void Student::Print() const
{
   // Private members of Person are not directly accessible
   // within the scope of Student, so we use access fns. 
   cout << GetTitle() << " " << GetFirstName() << " ";
   cout << GetMiddleInitial() << ". " << GetLastName();
   cout << " with id: " << studentId << " gpa: ";
   cout << setprecision(2) << gpa;
   cout << " course: " << currentCourse << endl;
}
void Student::EarnPhD()
{
   // Protected members defined by the base class are
   // accessible within the scope of the derived class.
   // EarnPhd() provides a public interface to this
   // functionality for derived class instances. 
   ModifyTitle("Dr.");  
}

在上述代码段中,我们定义了Student的非内联成员函数。请注意,默认构造函数仅使用成员初始化列表来初始化一个数据成员,就像我们在上一章中所做的那样。由于在默认Student构造函数的成员初始化列表中没有指定Person构造函数,因此当使用默认构造函数实例化Student时,将使用默认的Person构造函数来初始化Person子对象。

接下来,Student类的替代构造函数使用成员初始化列表来指定应该使用Person的替代构造函数来构建给定Student实例中包含的Person子对象。请注意,所选构造函数将与签名Person::Person(const string &, const string &, char, const string &)匹配,并且从Student构造函数中选择的输入参数(即fnlnmit)将作为参数传递给Person的替代构造函数。然后,Student构造函数的成员初始化列表被用来初始化Student类引入的任何附加数据成员。

Student类的复制构造函数中,成员初始化列表被用来指定应该调用Person的复制构造函数来初始化正在构建的Student实例的Person子对象。当调用Person的复制构造函数时,Student &将隐式地向上转换为Person &。回想一下,Student对象的上半部分是Is-A Person,所以这是可以的。接下来,在复制构造函数的剩余成员初始化列表中,我们初始化Student类定义的任何剩余数据成员。任何需要深度复制的数据成员(例如指针)可以在复制构造函数的主体中处理。

继续前进,我们看到一条注释,指出了Student析构函数。隐式地,作为此方法(无论析构函数是系统提供的还是用户编写的)的最后一行代码,编译器为我们修补了一个对Person析构函数的调用。这就是析构函数序列自动化的方式。因此,对象最专业的一部分,即Student部分,将首先被析构,然后是隐式调用Person析构函数来析构基类子对象。

接下来,在Student类的Print()方法中,请注意我们希望打印出从Person类继承的各种数据成员。唉,这些数据成员是private的。我们可能无法在Person类的作用域之外访问它们。然而,Person类为我们留下了一个公共接口,例如Person::GetTitle()Person::GetFirstName(),这样我们就可以从应用程序的任何作用域中访问这些数据成员,包括从Student::Print()中访问。

最后,我们来到了 Student::EarnPhD() 方法。注意,这个方法所做的只是调用受保护的成员函数 Person::ModifyTitle("Dr.");。回想一下,由基类定义的 protected 成员在派生类的范围内是可访问的。Student::EarnPhD() 是派生类的一个成员函数。EarnPhD() 提供了一个公共接口来修改 Person 的头衔,可能是在检查学生是否满足毕业要求之后。因为 Person::ModifyTitle() 不是 public,所以 PersonStudent 的实例必须通过受控的 public 接口来更改它们各自的头衔。这些接口可能包括 Student::EarnPhD()Person::GetMarried() 等方法,等等。

尽管如此,让我们通过检查 main() 函数来完成我们的完整程序示例:

int main()
{
    Student s1("Jo", "Li", 'U', "Ms.", 3.8, 
               "C++", "178PSU"); 
    // Public members of Person and Student are accessible
    // outside the scope of their respective classes....
    s1.Print();
    s1.SetCurrentCourse("Doctoral Thesis");
    s1.EarnPhD();
    s1.Print();
    cout << "Total number of students: " << 
             Student::GetNumberStudents() << endl;
    return 0;
}

在这个程序的最后一个部分 main() 中,我们简单地实例化了一个 Student,即 s1Student 使用 Student::Print() 来打印其当前数据。然后 Student 将她的当前课程设置为 "Doctoral Thesis",然后调用 Student::EarnPhD();。请注意,StudentPerson 的任何 public 成员都可以在它们的作用域之外由 s1 使用,例如在 main() 中。为了完成这个示例,s1 使用 Student::Print() 重新打印她的详细信息。

下面是完整程序示例的输出:

Ms. Jo U. Li with id: 178PSU gpa: 3.9 course: C++
Dr. Jo U. Li with id: 178PSU gpa: 3.9 course: Doctoral Thesis
Total number of students: 1

现在我们已经掌握了单继承的基本机制,并使用单继承来建模 Is-A 关系,让我们继续前进,看看如何通过探索 protectedprivate 基类来使用继承来建模不同的概念。

实现继承 – 改变继承的目的

到目前为止,我们展示了使用公共基类,也称为 公共继承。公共基类用于建模 Is-A 关系,并提供了构建继承层次结构的主要动机。这种用法支持泛化和特殊化的概念。

有时,继承可能被用作一种工具,通过另一个类来实现一个类,也就是说,一个类使用另一个类作为其底层实现。这被称为 实现继承,它不支持泛化和特殊化的理念。然而,实现继承可以提供一种快速且易于重用的方式来实现一个基于另一个类的类。它是快速且相对无错误的。许多类库在不知道其类用户的情况下使用这个工具。区分实现继承和传统的层次结构构建对于指定 Is-A 关系是有动机的。

C++支持实现继承,使用私有和受保护的基类,这是 C++独有的。其他面向对象编程语言选择只接受继承用于建模Is-A关系,这在 C++中通过公共基类得到支持。面向对象纯主义者会努力只使用继承来支持泛化和特殊化(Is-A)。然而,使用 C++,我们将了解实现继承的适当用途,以便我们能够明智地使用这种语言特性。

让我们继续前进,了解我们可能如何以及为什么利用这种类型的继承。

通过使用受保护的或私有的基类修改基类列表中的访问标记

再次强调,通常的继承类型是public继承。对于给定的派生类,基类列表中使用public标记。然而,在基类列表中,protectedprivate关键字也是可选项。

也就是说,除了在类或结构定义内标记访问区域外,访问标记还可以在派生类定义的基类列表中使用,以指定基类中定义的成员如何被派生类继承。

继承成员只能比在基类中指定的更严格。当派生类指定继承成员应以更严格的方式处理时,该派生类的任何后代也将受到这些规定的约束。

让我们快速看一下基类列表的例子:

  • 回想一下,通常在基类列表中会指定一个public访问标记。

  • 在这个例子中,使用public访问标记来指定PersonStudentpublic基类。也就是说,StudentPerson一种

    class Student: public Person
    {
        // usual class definition
    };
    

基类列表中指定的访问标记将按以下方式修改继承的访问区域:

  • 公共的:基类中的公共成员可以从任何范围访问;基类中的受保护成员可以从基类和派生类的范围访问。我们熟悉使用公共基类。

  • 受保护的:基类中的公共和受保护成员的行为就像它们被派生类定义为受保护的(即,可以从基类和派生类的范围以及派生类后代的范围内访问)。

  • 私有的:基类中的公共和受保护成员的行为就像它们被派生类定义为私有的,允许这些成员在派生类的范围内访问,但不能在任何派生类后代的范围内访问。

注意

在所有情况下,在类定义中将成员标记为私有的,只能在其定义的范围内访问。修改基类列表中的访问标记只能使继承成员更严格,而不能更宽松。

如果没有在基类中指定访问标签,当用户定义的类型是class时,将假定其为private,而当用户定义的类型是struct时,默认为public。一个很好的经验法则是,在派生类(或结构)定义的基类列表中始终包含访问标签。

创建基类以说明实现继承

要理解实现继承,让我们回顾一个可以作为其他类实现基础的基类。我们将检查一对典型的类来实现封装的LinkList。尽管这个例子将被分成几个部分,但完整的示例将展示出来,也可以在 GitHub 上找到:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter06/Chp6-Ex2.cpp

#include <iostream>
using std::cout;    // preferred to: using namespace std;
using std::endl;
using Item = int;  
class LinkListElement  // a 'node' or element of a LinkList
{
private:
    void *data = nullptr;   // in-class initialization
    LinkListElement *next = nullptr;
public:
    LinkListElement() = default;
    LinkListElement(Item *i) : data(i), next(nullptr) { }
    ~LinkListElement()
       { delete static_cast<Item *>(data); 
         next = nullptr; }
    void *GetData() const { return data; }
    LinkListElement *GetNext() const { return next; }
    void SetNext(LinkListElement *e) { next = e; }
};
class LinkList   // an encapsulated LinkList
{
private:
    LinkListElement *head = nullptr;  // in-class init.
    LinkListElement *tail = nullptr;
    LinkListElement *current = nullptr;
public:
    LinkList() = default; // required to keep default
                          // interface
    LinkList(LinkListElement *);
   ~LinkList();
    void InsertAtFront(Item *);
    LinkListElement *RemoveAtFront();
    void DeleteAtFront();
    int IsEmpty() const { return head == nullptr; } 
    void Print() const;  
};

我们从代码的前一部分开始,定义了LinkListElementLinkList两个类的定义。LinkList类将包含指向LinkList中的headtailcurrent元素的指针。这些指针的类型都是LinkListElement。它包括各种典型的LinkList处理方法,如InsertAtFront()RemoveAtFront()DeleteAtFront()IsEmpty()Print()。让我们快速浏览一下这些方法的实现,代码的下一部分将展示:

// default constructor – not necessary to write it 
// ourselves with in-class initialization above 
LinkList::LinkList(LinkListElement *element)
{
    head = tail = current = element;
}
void LinkList::InsertAtFront(Item *theItem)
{
    LinkListElement *newHead = new
                               LinkListElement(theItem);
    newHead->SetNext(head);  // newHead->next = head;
    head = newHead;
}
LinkListElement *LinkList::RemoveAtFront()
{
    LinkListElement *remove = head;
    head = head->GetNext();  // head = head->next;
    current = head;    // reset current for usage elsewhere
    return remove;
}

void LinkList::DeleteAtFront()
{
    LinkListElement *deallocate;
    deallocate = RemoveAtFront();
    delete deallocate;  // destructor will both delete data 
}                       // and will set next to nullptr

void LinkList::Print() const
{
    if (!head)
       cout << "<EMPTY>";
    LinkListElement *traverse = head;
    while (traverse)
    {
        Item output = *(static_cast<Item *>
                        (traverse->GetData()));
        cout << output << " ";
        traverse = traverse->GetNext();
    }
    cout << endl;
}
LinkList::~LinkList()
{
    while (!IsEmpty())
        DeleteAtFront();
}

在之前提到的成员函数定义中,我们注意到LinkList可以构造为空或包含一个元素(注意有两个可用的构造函数)。LinkList::InsertAtFront()为了效率,将项目添加到列表的前面。LinkList::RemoveAtFront()移除一个项目并将其返回给用户,而LinkList::DeleteAtFront()移除并删除前面的项目。LinkList::Print()函数允许我们在必要时查看LinkList

接下来,让我们看看一个典型的main()函数,以说明如何实例化和操作LinkList

int main()
{
    // Create a few items, to be data for LinkListElements
    Item *item1 = new Item;
    *item1 = 100;
    Item *item2 = new Item(200);
    // create an element for the Linked List
    LinkListElement *element1 = new LinkListElement(item1);
    // create a linked list and initialize with one element
    LinkList list1(element1);
    // Add some new items to the list and print
    list1.InsertAtFront(item2);   
    list1.InsertAtFront(new Item(50)); // add nameless item
    cout << "List 1: ";
    list1.Print();         // print out contents of list
    // delete elements from list, one by one
    while (!(list1.IsEmpty()))
    {
        list1.DeleteAtFront();
        cout << "List 1 after removing an item: ";
        list1.Print();
    }
    // create a second linked list, add some items, print
    LinkList list2;
    list2.InsertAtFront(new Item (3000));
    list2.InsertAtFront(new Item (600));
    list2.InsertAtFront(new Item (475));
    cout << "List 2: ";
    list2.Print();
    // delete elements from list, one by one
    while (!(list2.IsEmpty()))
    {
        list2.DeleteAtFront();
        cout << "List 2 after removing an item: ";
        list2.Print();
    }
    return 0;
}

main()中,我们创建了一些类型为Item的项目,这些项目将后来成为LinkListElement的数据。然后我们实例化一个LinkListElement,即element1,并将其添加到一个新构建的LinkList中,使用LinkList list1(element1);。然后我们使用LinkList::InsertAtFront()向列表中添加几个项目,并调用LinkList::Print()来打印出list1作为基线。接下来,我们逐个从list1中删除元素,打印过程中使用LinkList::DeleteAtFront()LinkList::Print(),分别进行。

现在,我们实例化第二个LinkList,即list2,它一开始是空的。我们逐渐使用LinkList::InsertAtFront()插入几个项目,然后打印列表,然后逐个使用LinkList::DeleteAtFront()删除每个元素,并在每个步骤中打印修改后的列表。

这个示例的目的不是详尽无遗地审查这段代码的内部工作原理。你无疑已经熟悉了LinkedList的概念。更重要的是,这个示例的目的是建立这一系列类,LinkListElementLinkList,作为构建块集合,在这个集合中可以构建多个抽象数据类型

然而,前面示例的输出如下:

List 1: 50 200 100
List 1 after removing an item: 200 100
List 1 after removing an item: 100
List 1 after removing an item: <EMPTY>
List 2: 475 600 3000
List 2 after removing an item: 600 3000
List 2 after removing an item: 3000
List 2 after removing an item: <EMPTY>

接下来,让我们看看如何将LinkedList用作私有基类。

使用私有基类通过一个类实现另一个类

我们刚刚创建了一个LinkList类来支持封装的链表数据结构的基本处理。现在,让我们想象一下,我们想要实现Push()Pop()IsEmpty(),也许还有Print()

你可能会问堆栈是如何实现的。答案是,实现方式并不重要,只要它支持所建模的 ADT(抽象数据类型)预期的接口即可。也许堆栈是通过数组实现的,也许它是通过文件实现的。也许它是通过LinkedList实现的。每种实现都有其优缺点。实际上,ADT 的底层实现可能会改变,但 ADT 的用户不应受到这种变化的影响。这就是实现继承的基础。派生类是在基类的基础上实现的,但派生出新类的基础类的底层细节实际上是被隐藏的。这些细节不能直接被派生类的实例(在这种情况下,ADT 的实例)使用。尽管如此,基类默默地为派生类提供了实现。

我们将使用这种方法,通过使用LinkedList作为其底层实现来实现Stack。为此,我们将使用class Stack通过一个private基类扩展LinkedListStack将为用户定义一个公共接口,以建立这个 ADT 的接口,例如Push()Pop()IsEmpty()Print()。这些成员函数的实现将利用选定的LinkedList成员函数,但Stack的用户将看不到这一点,Stack的实例也不能直接使用任何LinkedList成员。

这里,我们并不是说Stack LinkedList,而是说,目前Stack是通过LinkedList实现的——而且这种底层实现可能会改变!

实现Stack的代码很简单。假设我们正在使用前面示例中的LinkListLinkListElement类。让我们在这里添加Stack类。完整的程序示例可以在我们的 GitHub 上找到:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter06/Chp6-Ex3.cpp

class Stack: private LinkList
{
private:
    // no new data members are necessary
public:  
    // Constructor / destructor prototypes shown below are
    // not needed; we get both without these prototypes! 
    // Commented to remind what's automatically provided
    // Stack() = default; // will call :LinkList() def ctor
    // ~Stack() = default; 
    // the public interface for Stack 
    void Push(Item *i) { InsertAtFront(i); }
    Item *Pop(); 
    // It is necessary to redefine these operations because
    // LinkList is a private base class of Stack
    int IsEmpty() const { return LinkList::IsEmpty(); }  
    void Print() { LinkList::Print(); }
};
Item *Stack::Pop()
{
    LinkListElement *top;
    top = RemoveAtFront();
    // copy top's data
    Item *item = new Item(*(static_cast<Item *>
                            (top->GetData())));
    delete top;
    return item;
}
int main()
{
    Stack stack1;    // create a Stack
    // Add some items to the stack, using public interface 
    stack1.Push(new Item (3000)); 
    stack1.Push(new Item (600));
    stack1.Push(new Item (475));
    cout << "Stack 1: ";
    stack1.Print();
    // Pop elements from stack, one by one
    while (!(stack1.IsEmpty()))
    {
        stack1.Pop();
        cout << "Stack 1 after popping an item: ";
        stack1.Print();
    }
    return 0;
} 

注意上述代码对于我们的Stack类是多么紧凑!我们首先指定Stack有一个private基类LinkList。回想一下,private基类意味着从LinkList继承的protectedpublic成员就像是由Stack定义的private一样(并且只可以在Stack的作用域内访问,即Stack的成员函数)。这意味着Stack的实例可能无法使用LinkList原有公共接口。这也意味着Stack作为LinkList的底层实现实际上是隐藏的。当然,LinkList实例不会受到影响,并且可以像往常一样使用它们的public接口。

我们注意到=default已经被添加到Stack构造函数和析构函数的原型中。这两个方法都没有工作要做,因为我们没有向这个类添加任何数据成员;因此,默认的系统提供的版本是可以接受的。注意,如果我们省略了构造函数和析构函数的原型,我们将链接系统提供的两个版本。

我们很容易地将Stack::Push()定义为简单地调用LinkList::InsertAtFront(),就像Stack::Pop()所做的只是调用LinkList::RemoveAtFront()一样。尽管Stack很想简单地使用从LinkList继承的实现,但由于LinkList是一个private基类,这些函数不是Stack的公共接口的一部分。因此,Stack添加了一个简单的IsEmpty()方法,该方法仅调用LinkList::IsEmpty();。注意使用作用域解析运算符来指定LinkList::IsEmpty()方法;如果没有基类限定符,我们将会添加一个递归函数调用!这个调用基类方法是允许的,因为Stack成员函数可以调用LinkList一次公开方法(它们现在在Stack内部被视为private)。同样,Stack::Print()仅仅调用LinkList::Print()

main()函数的作用域内,我们实例化了一个Stack,即stack1。使用Stack的公共接口,我们可以轻松地通过Stack::Push()Stack::Pop()Stack::IsEmpty()Stack::Print()来操作stack1

这个示例的输出如下:

Stack 1: 475 600 3000
Stack 1 after popping an item: 600 3000
Stack 1 after popping an item: 3000
Stack 1 after popping an item: <EMPTY>

重要的是要注意,Stack实例的指针不能向上转换为存储为指向LinkList的指针。不允许跨越private基类边界的向上转换。这将允许Stack揭示其底层实现;C++不允许这种情况发生。在这里,我们看到Stack仅仅是基于LinkList实现的;我们并不是说Stack LinkedList。这是实现继承的最佳概念;这个例子有利的说明了实现继承。

接下来,让我们继续前进,看看我们如何使用一个protected基类,以及它是如何通过实现继承与一个private基类不同的。

使用受保护的基类通过另一个类来实现一个类

我们刚刚使用private基类在LinkList的基础上实现了一个Stack。现在,让我们实现一个Queue和一个PriorityQueue。我们将使用LinkList作为protected基类来实现Queue,并使用Queue作为public基类来实现PriorityQueue

再次强调,QueuePriorityQueue都是抽象数据类型。Queue是如何实现的(相对)并不重要。底层的实现可能会改变。实现继承允许我们使用LinkedList来实现我们的Queue,而不向Queue类的用户透露底层的实现。

现在,我们的类Queue将使用LinkedList作为protected基类。Queue将为用户定义一个公共接口,以建立这个 ADT 的预期接口,例如Enqueue()Dequeue()IsEmpty()Print()。这些成员函数的实现将利用选定的LinkedList成员函数,但Queue的用户将看不到这一点,Queue实例也不能直接使用任何LinkList成员。

此外,我们的类PriorityQueue将通过public继承来扩展Queue。没错,我们又回到了“是-一个”的关系。我们说PriorityQueue“是-一个”Queue,而Queue是通过LinkedList实现的。

我们将仅仅在我们的PriorityQueue类中添加一个优先级入队方法;这个类将很高兴地继承自Queuepublic接口(但显然不是从LinkList继承,幸运的是,LinkList在其父级的级别上被隐藏在一个protected基类之后)。

实现QueuePriorityQueue的代码同样简单直接。为了继续进行,LinkList基类需要增强以实现更完整的功能。LinkListElement类可以保持不变。我们将仅通过其类定义展示修订后的LinkList类的基本内容。QueuePriorityQueue的完整代码将在单独的部分展示。完整的程序示例可以在我们的 GitHub 上找到:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter06/Chp6-Ex4.cpp

// class LinkListElement is as shown previously
// The enhanced class definition of LinkList is:
class LinkList
{
private:
    LinkListElement *head = nullptr;
    LinkListElement *tail = nullptr;
    LinkListElement *current = nullptr;
public:
    LinkList() = default;
    LinkList(LinkListElement *);
    ~LinkList();
    void InsertAtFront(Item *);  
    LinkListElement *RemoveAtFront();
    void DeleteAtFront();
    // Notice additional member functions added
    void InsertBeforeItem(Item *, Item *); 
    LinkListElement *RemoveSpecificItem(Item *);
    void DeleteSpecificItem(Item *);
    void InsertAtEnd(Item *);
    LinkListElement *RemoveAtEnd();
    void DeleteAtEnd();
    int IsEmpty() const { return head == nullptr; } 
    void Print() const;  
};
// Assume we have the implementation for the methods here…

注意到LinkList已经被扩展,具有更完整的功能集,例如能够在LinkList的各个位置添加、删除和删除元素。为了保持我们检查的代码简短,我们不会展示这些方法的实现。

现在,让我们在下一段代码中添加QueuePriorityQueue的类定义:

class Queue: protected LinkList
{
private:
    // no new data members are necessary
public: 
    // Constructor prototype shown below is not needed; 
    // we get default w/o prototype (since no other ctor)
    // Commented to remind what's automatically provided
    // Queue() = default;  // calls :LinkList() def. ctor
    // Destructor prototype is needed (per virtual keyword)
    virtual ~Queue() = default; // we'll see virtual Chp. 7
    // public interface of Queue
    void Enqueue(Item *i) { InsertAtEnd(i); }
    Item *Dequeue(); 
    // redefine these methods, LinkList is prot. base class
    int IsEmpty() const { return LinkList::IsEmpty(); }
    void Print() { LinkList::Print(); }
};
Item *Queue::Dequeue()
{
    LinkListElement *front;
    front = RemoveAtFront();
    // make copy of front's data
    Item *item = new Item(*(static_cast<Item *>
                            (front->GetData())));
    delete front; 
    return item;
}
class PriorityQueue: public Queue
{
private:
    // no new data members are necessary
public:
    // Constructor prototype shown below is not needed; 
    // we get default w/o protoype (since no other ctor)
    // Commented to remind what's automatically provided
    // PriorityQueue() = default; // calls :Queue() 
                                  // default constructor
    // destructor proto. is not needed for overriden dtor
    // ~PriorityQueue() override = default; // see Chp 7
    void PriorityEnqueue(Item *i1, Item *i2) 
    {  InsertBeforeItem(i1, i2); } // accessible in this 
};                                 // scope

在之前的代码段中,我们定义了QueuePriorityQue类。请注意,Queue有一个protected基类LinkList。使用protected基类,从LinkList继承的protectedpublic成员表现得好像是由Queue作为protected定义的,这意味着这些继承成员不仅可以在Queue的作用域内访问,还可以在任何潜在的Queue后代中访问。和以前一样,这些限制仅适用于Queue类、其后代及其实例;LinkList类及其实例不受影响。

Queue类中,不需要新的数据成员。内部实现由LinkList处理。使用protected基类,我们表示Queue是通过LinkList实现的。尽管如此,我们必须提供Queuepublic接口,我们通过添加方法如Queue::Enqueue()Queue::Dequeue()Queue::IsEmpty()Queue::Print()来实现。请注意,在这些方法的实现中,它们仅仅调用LinkList方法来执行必要的操作。Queue的使用者必须使用Queue的公共接口;LinkList曾经公开接口对Queue实例是隐藏的。

接下来,我们定义PriorityQueue,另一个 ADT。请注意,PriorityQueueQueue定义为public基类。我们回到了继承来支持 Is-A 关系。PriorityQueue一个Queue,可以做到Queue能做的所有事情,只是更多一点。因此,PriorityQueue像往常一样从Queue继承,包括Queue的公共接口。PriorityQueue只需要添加一个额外的优先级入队方法,即PriorityQueue::PriorityEnqueue()

由于Queue有一个protected基类LinkListLinkListpublic接口对Queue及其后代,包括PriorityQueue,被认为是protected的,这样LinkList曾经公开方法对QueuePriorityQueue都是protected的。请注意,PriorityQueue::PriorityEnqueue()使用了LinkList::InsertBeforeItem()。如果LinkListQueueprivate基类而不是protected基类,这将是不可能的。

在类定义和实现到位后,让我们继续我们的main()函数:

int main()
{
    Queue q1;   // Queue instance
    q1.Enqueue(new Item(50));
    q1.Enqueue(new Item(67));
    q1.Enqueue(new Item(80));
    q1.Print();
    while (!(q1.IsEmpty()))
    {
        q1.Dequeue();
        q1.Print();
    }
    PriorityQueue q2;   // PriorityQueue instance
    Item *item = new Item(167); // save a handle to item
    q2.Enqueue(new Item(67));   // first item added
    q2.Enqueue(item);           // second item
    q2.Enqueue(new Item(180));  // third item
    // add new item before an existing item
    q2.PriorityEnqueue(new Item(100), item); // 4th item
    q2.Print();
    while (!(q2.IsEmpty()))
    {
       q2.Dequeue();
       q2.Print();
    }
    return 0;
}

现在,在main()中,我们实例化一个Queue,即q1,它使用Queue的公共接口。请注意,q1可能不会使用LinkList曾经公开接口。Queue只能表现得像一个Queue,而不能像一个LinkListQueue的 ADT 得到了保留。

最后,我们实例化一个PriorityQueue,即q2,它利用了QueuePriorityQueue的公共接口,例如Queue::Enqueue()PriorityQueue::PriorityEnqueue()。因为一个Queue一个PriorityQueueQueuepublic基类),继承的典型机制就位,允许PriorityQueue利用其祖先的公共接口。

本例的输出如下:

50 67 80
67 80
80
<EMPTY>
67 100 167 180
100 167 180
167 180
180
<EMPTY>

最后,我们看到了使用实现继承的两个示例;这并不是 C++中常用的特性。然而,你现在应该理解,如果你在库代码、你正在维护的应用代码中,或者在罕见的机会中,这种技术可能对你要遇到的编程任务有用,那么你会遇到protectedprivate基类。

=default的可选用途

我们已经看到了在构造函数和析构函数原型中使用=default来减轻用户提供此类方法定义的需求。然而,让我们回顾一下当构造函数(或析构函数)自动为我们提供时的一些指南。在这种情况下,使用构造函数或析构函数原型中的=default将具有更多文档性质,而不是要求;如果没有=default原型,我们将得到相同的系统提供的方法。

如果默认构造函数是一个类中唯一的构造函数,则不需要使用=default原型;回想一下,如果一个类没有构造函数,你将得到一个系统提供的默认构造函数(以提供实例化类的接口)。然而,如果类中有其他构造函数(不包括复制构造函数),并且你想要保持默认对象创建(构造)接口,则使用默认构造函数原型中的=default是至关重要的。对于复制构造函数,如果你使用的是系统提供的默认版本,你将得到这个方法,无论你是否使用=default原型或完全省略原型。同样,对于析构函数,如果系统提供的版本足够好,你将得到这个版本,无论你是否使用=default原型或完全省略原型;后者风格越来越普遍。

我们现在已经涵盖了 C++中单继承的基本特性。在进入下一章之前,让我们快速回顾一下我们已经覆盖的内容。

摘要

在本章中,我们继续我们的面向对象编程之旅。我们添加了额外的 OO 概念和术语,并看到了 C++如何直接支持这些概念。我们已经看到了 C++中的继承如何支持泛化和特殊化。我们已经看到了如何逐步构建相关类的层次结构。

我们已经看到了如何使用单继承来扩展继承层次结构,以及如何访问继承的数据成员和成员函数。我们已经回顾了访问区域,以了解哪些继承成员可以直接访问,这取决于成员在基类中定义的访问区域。我们知道,有一个public基类等同于定义了一个 Is-A 关系,这支持了泛化和特殊化的理念,这是继承最常用的原因。

我们已经详细说明了在实例化和销毁派生类类型实例时构造函数和析构函数调用的顺序。我们看到了成员初始化列表,以选择派生类对象可能选择利用作为其自身构造(其基类子对象)的一部分的继承构造函数。

我们已经看到,在基类列表中更改访问标签如何改变所使用的继承类型的面向对象意义。通过比较 publicprivateprotected 基类,我们现在理解了不同类型的层次结构,例如那些用于支持 Is-A 关系与那些用于支持实现继承的层次结构。

我们已经看到,在我们的层次结构中的基类可以作为更专用组件的潜在构建块,从而实现潜在的复用。任何现有代码的潜在复用都可以节省开发时间,并减少对其他重复代码的维护。

通过扩展我们的面向对象(OOP)知识,我们已经获得了一组与 C++ 中的继承和层次结构构建相关的初步技能。在掌握了单继承的基本机制后,我们现在可以继续学习更多有关继承的有趣面向对象概念和细节。继续阅读第七章通过多态利用动态绑定,我们将接下来学习如何将方法动态绑定到相关类层次结构中的相应操作。

问题

  1. 使用你的 第五章详细探索类 的解决方案,创建一个 C++ 程序来构建继承层次结构,将 Person 作为基类从 Student 的派生类中泛化。

    1. 决定你的 Student 类中哪些数据成员和成员函数更通用,并且更适合放在 Person 类中。使用这些成员构建你的 Person 类,包括适当的构造函数(默认、替代和复制)、析构函数、访问成员函数和合适的公共接口。务必将数据成员放在私有访问区域。

    2. 使用 public 基类,从 Person 派生 Student。从 Student 中删除现在在 Person 中表示的成员。相应地调整构造函数和析构函数。根据需要使用成员初始化列表指定基类构造函数。

    3. 实例化多次 StudentPerson,并利用每个的适当 public 接口。务必动态分配几个实例。

    4. 在每个构造函数和析构函数的第一行使用 cout 添加一条消息,以便您可以查看每个实例的构造和销毁顺序。

  2. (可选)使用在线代码作为基础,完成包括 LinkListQueuePriorityQueue 在内的类层次结构。完成 LinkList 类中的剩余操作,并在 QueuePriorityQueue 的公共接口中适当地调用它们。

    1. 确保为每个类添加复制构造函数(或者在私有访问区域中对其进行原型化,或者如果你确实不希望允许复制,在原型中使用 =delete 来抑制复制)。

    2. 使用任一构造函数实例化 LinkList,然后演示你的每个操作是如何工作的。确保在添加或删除元素后调用 Print()

    3. 实例化 QueuePriorityQueue,并演示它们 public 接口中的每个操作都能正确工作。请记住,对于 PriorityQueue 的实例,也要演示 Queuepublic 接口中继承的操作。

第七章:通过多态利用动态绑定

本章将进一步扩展我们对 C++面向对象编程的知识。我们将首先介绍一个强大的面向对象概念,即多态,然后理解这一想法是如何通过直接语言支持在 C++中实现的。我们将使用相关类的层次结构实现多态,并理解我们如何将特定派生类方法与更通用的基类操作进行运行时绑定。我们将理解本章中提出的面向对象的多态概念将如何支持 C++中的灵活和健壮的设计以及易于扩展的代码。

在本章中,我们将涵盖以下主要主题:

  • 理解面向对象的多态概念及其在面向对象编程中的重要性

  • 定义虚函数,理解虚函数如何覆盖基类方法(或使用final指定符来停止覆盖过程),泛化派生类对象,虚析构函数的需求,以及理解函数隐藏

  • 理解方法到操作的动态(运行时)绑定

  • 详细理解虚函数表(v-table)

在本章结束时,你将理解面向对象的多态概念,以及如何通过虚函数在 C++中实现这一想法。你将了解虚函数如何使 C++中的方法与操作在运行时绑定。你将看到如何在基类中指定一个操作,并在派生类中以首选实现覆盖它。你将理解何时以及为什么利用虚析构函数很重要。

你将看到派生类的实例通常是如何使用基类指针存储的,以及为什么这很重要。我们将发现,无论实例是如何存储的(作为其自身类型或基类类型),通过动态绑定,总是应用正确的虚函数版本。具体来说,你将看到在检查 C++中的虚函数指针和虚函数表时,运行时绑定是如何工作的。

通过理解 C++中多态的直接语言支持,即使用虚函数,你将朝着创建一个具有方法到操作动态绑定的可扩展类层次结构迈进。让我们通过详细阐述这些理念来加深我们对 C++作为面向对象编程语言的理解。

技术要求

完整程序示例的在线代码可以在以下 GitHub URL 中找到:github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/tree/main/Chapter07。每个完整程序示例都可以在 GitHub 的相应章节标题(子目录)下的文件中找到,该文件以章节编号开头,后面跟着一个连字符,然后是本章中的示例编号。例如,本章的第一个完整程序可以在上述 GitHub 目录下的Chapter07子目录中的Chp7-Ex1.cpp文件中找到。

本章的 CiA 视频可在以下网址查看:bit.ly/3QQUGxg

理解面向对象的多态概念

在本节中,我们将介绍一个重要的面向对象概念,即多态性。

第五章《详细探索类》和第六章《使用单一继承实现层次结构》中,你现在已经理解了关键面向对象思想:封装、信息隐藏、泛化和特殊化。你知道如何封装一个类,如何使用单一继承构建继承层次结构,以及构建层次结构的各种原因(例如支持“是”关系或较少使用的支持实现继承的原因)。让我们通过探索多态性来扩展我们的基本面向对象术语。

当基类指定一个操作,使得派生类可以在其类中以更合适的方法重新定义该操作时,该操作被称为多态性。让我们回顾一下操作方法的定义,以及它们的含义,以了解这些概念如何为多态性奠定基础:

  • 在 C++中,一个操作映射到成员函数的完整签名(名称加上类型和参数数量——没有返回类型)。

  • 此外,在 C++中,一个方法映射到操作的定义或主体(即成员函数的实现或主体)。

  • 回想一下,在面向对象术语中,一个操作实现了类的行为。基类操作的实现可能通过几个不同的派生类方法来完成。

Student Person。然而,多态操作将允许在Student对象上揭示Student的行为,即使它们具有Person形式

随着我们进入本章,我们将看到派生类对象采取其公共基类的形式,即采取多种形式多态性)。我们将看到如何在基类中指定多态操作,并在派生类中以首选实现覆盖它。

让我们首先看看 C++语言特性,它允许我们实现多态性,即虚函数。

使用虚函数实现多态

多态允许将方法动态绑定到操作。将方法动态或运行时绑定到操作很重要,因为派生类实例可能由基类对象(即基类类型的指针)指向。在这些情况下,指针类型不提供有关应应用于引用实例的正确方法的信息。我们需要另一种方式——在运行时完成——来确定适用于每个实例的方法。

通常情况下,派生类类型的实例指针会被泛化为基类类型的指针。当对指针执行操作时,应该应用适用于对象真正身份的正确方法,而不是适用于泛化指针类型的看似合适的方法。

让我们从定义虚函数所需的相关关键字和后勤开始,这样我们就可以实现多态。

定义虚函数和重写基类方法

虚函数 在 C++ 中直接支持多态。一个 虚函数 的定义如下:

  • 一个成员函数允许在层次结构中连续重写给定操作的方法,以提供更合适的定义

  • 一个成员函数允许动态绑定,而不是通常的静态绑定,用于方法

使用关键字 virtual 指定虚函数,以下是一些细微差别:

  • 关键字 virtual 应该位于其原型中的函数返回类型之前。

  • 在派生类中具有与任何祖先类中虚函数相同名称和签名的函数重新定义了这些基类中的虚函数。在这里,派生类原型中的关键字 virtual 是可选的。

  • 可选且推荐,可以在派生类原型中作为扩展签名的一部分添加关键字 override。这种推荐做法将允许编译器在预期重写的函数签名与在基类中指定的签名不匹配时标记错误。override 关键字可以消除意外的函数隐藏。

  • 在派生类中具有相同名称但不同签名的函数不会在它们的基类中重新定义虚函数;相反,它们隐藏了在它们的基类中找到的方法。

  • 此外,如果所讨论的虚函数不打算在派生类中进一步重写,则可以将关键字 final 添加为虚函数原型的扩展签名的一部分。

如果继承的方法是合适的,派生类不需要重新定义其基类中指定的虚函数。然而,如果派生类使用新方法重新定义一个操作,则重写的函数必须使用相同的签名(由基类指定)。此外,派生类应仅重新定义虚函数。

这里有一个简单的例子来说明基本语法:

  • Print()是在基类Person中定义的虚函数。它将在Student类中被一个更合适的实现所覆盖:

    class Person  // base class
    {
    private:
        string name;
        string title;
    public:
        // constructors/destructor (will soon be virtual), 
        // public access functions, public interface etc.
        virtual void Print() const
        {
            cout << title << " " << name << endl;
        }
    }; 
    

在这里,基类Person引入了一个虚函数Print()。通过将此函数标记为virtualPerson类正邀请任何未来的后代,如果他们有此动机,可以重新定义此函数以使用更合适的实现或方法。

  • 在基类Person中定义的虚函数,实际上在Student类中被一个更合适的实现所覆盖:

    class Student: public Person  // derived class
    {
    private:
        float gpa = 0.0;  // in-class initialization
    public:
        // constructors, destructor specific to Student,
        // public access functions, public interface, etc.
        void Print() const override 
        {
            Person::Print(); // call base class fn to help
            cout << " is a student. GPA: " << gpa << endl;
        }
    }; 
    

注意这里,派生类Student引入了一个新的Print()实现,这将覆盖(即替换)Person中的定义。请注意,如果Person::Print()的实现对Student来说是可接受的,即使它在基类中被标记为virtualStudent也没有义务覆盖这个函数。公共继承的机制将简单地允许派生类继承这个方法。

但因为此函数在Person中是virtual的,Student可以选择使用更合适的方法重新定义此操作。在这里,它确实这样做了。在Student::Print()实现中,Student首先调用Person::Print()以利用上述基类函数,然后打印额外的信息。Student::Print()选择调用基类函数以获得帮助;如果可以在其自己的类作用域内完全实现所需的功能,则不需要这样做。

注意,当Student::Print()被定义为覆盖Person::Print()时,使用了与基类中指定的相同签名。这一点很重要。如果使用了新的签名,我们可能会遇到潜在的功能隐藏场景,我们将在本章的考虑功能隐藏子节中很快讨论这个问题。

注意,尽管PersonStudent中的虚函数是内联编写的,但编译器几乎永远不会将虚函数展开为内联代码,因为必须确定运行时的具体方法。存在少数编译器去虚化的情况,涉及 final 方法或知道实例的动态类型;这些罕见的情况将允许虚函数内联。

记住,多态函数旨在具有覆盖或替换给定函数的基类版本的能力。函数覆盖与函数重载不同。

重要区分

函数覆盖是通过在相关类的层次结构中引入具有相同签名的相同函数名来定义的(通过虚函数),而派生类版本旨在替换基类版本。相比之下,函数重载是在程序的同作用域内存在两个或更多具有相同名称但具有不同签名的函数时定义的(例如,在同一个类中)。

此外,在基类定义中最初未指定为虚函数的操作不是多态的,因此不应在任何派生类中覆盖。这意味着如果基类在定义操作时没有使用关键字 virtual,则基类不希望派生类用更合适的派生类方法重新定义此操作。相反,基类坚持它提供的实现适合其所有后代。如果派生类尝试重新定义一个非虚基类操作,应用程序中将会引入一个微妙的错误。错误在于,使用派生类指针存储的派生类实例将使用派生类方法,而使用基类指针存储的派生类实例将使用基类定义。实例应该始终使用它们自己的行为,而不管它们是如何存储的——这是多态的目的。永远不要重新定义非虚函数。

重要提示

在 C++ 中,未指定为虚函数的基类操作不是多态的,因此不应由派生类覆盖。

让我们继续前进,了解我们可能想要通过基类类型收集派生类对象的情况,以及我们可能需要将析构函数指定为虚函数的情况。

对派生类对象的泛化

当我们查看继承层次结构时,通常是一个使用公共基类的层次结构;也就是说,这是一个使用公共继承来表达“是”关系的层次结构。以这种方式使用继承时,我们可能会被激励将相关实例的组收集在一起。例如,Student 特殊化的层次结构可能包括 GraduateStudentUnderGraduateStudentNonDegreeStudent。假设这些派生类都有 Student 的公共基类,那么可以说一个 GraduateStudentStudent 的一个实例,依此类推。

我们可能在应用程序中找到一个理由将这些“某种程度上相似”的实例组合成一个共同的集合。例如,想象我们正在实现一个大学的计费系统。大学可能希望我们将所有学生,无论他们的派生类类型如何,都收集到一个集合中,以便统一处理,以便计算他们的学期账单。

Student 类可能有一个多态操作 CalculateSemesterBill(),该操作在 Student 类中以默认方法实现为一个虚函数。然而,选定的派生类,如 GraduateStudent,可能有自己的首选实现,他们希望通过在自己的类中用更合适的方法覆盖该操作来提供。例如,一个 GraduateStudent 可能有一个与 NonDegreeStudent 不同的方法来计算他们的总账单。因此,每个派生类都可能覆盖它们各自类中的 CalculateSemesterBill() 的默认实现。

尽管如此,在我们的出纳应用程序中,我们可以创建一组类型为 Student 的指针,尽管每个指针不可避免地会指向派生类类型的实例,例如 GraduateStudentUnderGraduateStudentNonDegreeStudent。当派生类类型的实例以这种方式泛化后,将函数(通常是虚拟函数)应用于集合中定义的基类级别对应指针类型是合适的。虚拟函数允许这些泛化实例调用多态操作,以产生它们各自的派生类方法或这些函数的实现。这正是我们想要的。但是,还有更多的细节需要理解。

将派生类实例泛化的这个基本前提将使我们理解为什么我们可能需要在许多类定义中包含虚拟析构函数。让我们看看。

利用虚拟析构函数

现在,我们可以设想将派生类实例分组到一个类似于由它们的共同基类类型存储的集合中的情况可能是有用的。实际上,通过基类类型收集同类型派生类实例并使用虚拟函数允许它们独特的表现是非常强大的。

但是,让我们考虑当存储在基类指针中的派生类实例的内存消失时会发生什么。我们知道它的析构函数被调用,但调用的是哪一个?实际上,我们知道会调用一系列析构函数,从问题中对象类型的析构函数开始。但如果实例已经被通过基类指针存储而泛化,我们如何知道实际的派生类对象类型?一个 虚拟析构函数 解决了这个问题。

通过将析构函数标记为 virtual,我们允许它成为类及其任何后代在销毁序列中的起始点。关于使用哪个析构函数作为销毁的入口点的选择将被推迟到运行时,使用动态绑定,基于对象的实际类型,而不是引用它的指针类型可能是什么。我们将很快通过检查 C++ 的底层虚拟函数表来了解这个过程是如何自动化的。

与所有其他虚拟函数不同,虚拟析构函数实际上指定了执行一系列函数的起始点。回想一下,作为析构函数中的最后一行代码,编译器会自动插入一个调用,调用直接基类的析构函数,依此类推,直到我们达到层次结构中的初始基类。销毁链的存在是为了提供一个论坛,以释放给定实例的所有子对象中的动态分配的数据成员。将这种行为与其他虚拟函数进行对比,那些虚拟函数仅仅允许执行函数的正确版本(除非程序员在派生方法实现期间选择调用基类版本的同一函数作为辅助函数)。

你可能会问为什么在适当级别开始销毁序列很重要。也就是说,从与对象实际类型匹配的级别开始(而不是可能指向对象的通用指针类型)。回想一下,每个类都可能有自己的动态分配的数据成员。析构函数将释放这些数据成员。从正确的级别开始使用析构函数将确保你不会通过省略适当的析构函数及其相应的内存释放操作,在你的应用程序中引入任何内存泄漏。

虚析构函数是否总是必要的?这是一个好问题!当使用公共基类层次结构时,即使用公共继承时,虚析构函数总是必要的。回想一下,公共基类支持“是...的”关系,这很容易导致允许派生类实例使用其基类类型的指针进行存储。例如,GraduateStudentStudent 的“是...的”,因此我们可以在需要更多通用处理及其兄弟类型的时候,将 GraduateStudent 存储为 Student。我们总是可以通过这种方式在公共继承边界上向上转换。然而,当我们使用实现继承(即私有或保护基类)时,不允许向上转换。因此,对于使用私有或保护继承的层次结构,虚析构函数不是必要的,因为向上转换是简单地被禁止的;因此,在私有和保护基类层次结构中的类,哪个析构函数应该是入口点将不会产生歧义。作为第二个例子,我们在第六章,“使用单一继承实现层次结构”中,没有包含我们的 LinkedList 类的虚析构函数;因此,LinkedList 应仅作为保护或私有基类进行扩展。然而,我们在 QueuePriorityQueue 类中包含了虚析构函数,因为 PriorityQueueQueue 作为公共基类使用。PriorityQueue 可以向上转换为 Queue(但不能转换为 LinkedList),这需要在层次结构中的 Queue 及其子级别引入虚析构函数。

在重写虚析构函数时,推荐使用可选的virtualoverride关键字吗?这也是很好的问题。我们知道重写的析构函数只是销毁序列的起点。我们还知道,与其他虚函数不同,派生类析构函数将具有与基类析构函数不同的唯一名称。尽管派生类析构函数自动重写已声明为virtual的基类析构函数,但在派生类析构函数原型中使用可选的关键字override是为了文档化。然而,在派生类析构函数中使用可选的关键字virtual通常不再使用。其理由是override关键字旨在提供一个安全网,以捕获原始定义和重写函数之间的拼写错误。对于析构函数,函数名称并不相同,因此这个安全网不是错误检查的优势,而更多的是为了文档化。

让我们继续将所有必要的部件组合起来,这样我们就可以看到所有类型的虚函数,包括析构函数,在实际中的表现。

将所有部件组合在一起

到目前为止,在本章中,我们已经理解了虚函数的细微差别,包括虚析构函数。重要的是要看到我们的代码在实际操作中展现其所有各种组件和细节。我们需要在一个统一的程序中看到指定虚函数的基本语法,包括我们如何通过基类类型收集派生类实例,以及看到虚析构函数如何发挥作用。

让我们看看一个更复杂、完整的程序示例,以充分说明 C++中使用虚函数实现的多态性。这个示例将被分成多个部分;完整的程序可以在以下 GitHub 位置找到:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter07/Chp7-Ex1.cpp

#include <iostream>
#include <iomanip>
using std::cout;    //preferred to: using namespace std;
using std::endl;
using std::setprecision;
using std::string;
using std::to_string;
constexpr int MAX = 5;
class Person
{
private: 
    string firstName;
    string lastName;
    char middleInitial = '\0';  // in-class initialization
    string title;  // Mr., Ms., Mrs., Miss, Dr., etc.
protected:
    void ModifyTitle(const string &); 
public:
    Person() = default;   // default constructor
    Person(const string &, const string &, char, 
           const string &); 
    // copy constructor =default prototype not needed; we
    // get the default version w/o the =default prototype
    // Person(const Person &) = default;  // copy const.
    virtual ~Person();  // virtual destructor
    const string &GetFirstName() const 
        { return firstName; } 
    const string &GetLastName() const { return lastName; }
    const string &GetTitle() const { return title; } 
    char GetMiddleInitial() const { return middleInitial; }
    virtual void Print() const;
    virtual void IsA() const;  
    virtual void Greeting(const string &) const;
};

在上述类定义中,我们在熟悉的Person类中增加了四个虚函数,即析构函数(~Person())、Print()IsA()Greeting(const string &)。请注意,我们只是简单地将关键字virtual放在每个成员函数的返回类型(如果有)之前。类定义的其余部分与我们之前深入探讨的内容相同。

现在,让我们来检查Person类的非内联成员函数定义:

// With in-class initialization, writing the default
// constructor is no longer necessary. 
// Also, remember that strings are member objects and will 
// be default constructed as empty.
// alternate constructor
Person::Person(const string &fn, const string &ln, char mi,
               const string &t): firstName(fn),
               lastName(ln), middleInitial(mi), title(t)
{
    // dynamically allocate memory for any ptr data members 
}
// We are choosing to utilize the default copy constructor. 
// If we wanted to prototype/define it, here's the method:
// Person::Person(const Person &p):
//          firstName(p.firstName), lastName(p.lastName),
//          middleInitial(p.middleInitial), title(p.title)
// { 
    // deep copy any pointer data members here
// }
Person::~Person()
{
    // release memory for any dynamically alloc. data mbrs.
    cout << "Person destructor <" << firstName << " " 
         << lastName << ">" << endl;
}
void Person::ModifyTitle(const string &newTitle)
{   // assignment between strings ensures a deep assignment
    title = newTitle;     
}
void Person::Print() const
{
    cout << title << " " << firstName << " ";
    cout << middleInitial << ". " << lastName << endl;
}
void Person::IsA() const
{
    cout << "Person" << endl;
}
void Person::Greeting(const string &msg) const
{
    cout << msg << endl;
}

在之前的代码段中,我们已经指定了Person类的所有非内联成员函数。请注意,四个虚函数——析构函数、Print()IsA()Greeting()——在方法(即成员函数定义)本身中不包括virtual关键字。

接下来,让我们检查Student类的定义及其内联函数:

class Student: public Person
{
private: 
    float gpa = 0.0;   // in-class initialization
    string currentCourse;
    const string studentId; 
    static int numStudents;  // static data member
public:
    Student();  // default constructor
    Student(const string &, const string &, char, 
            const string &, float, const string &, 
            const string &); 
    Student(const Student &);  // copy constructor
    ~Student() override;  // virtual destructor
    void EarnPhD();  
    // inline function definitions
    float GetGpa() const { return gpa; }
    const string &GetCurrentCourse() const
        { return currentCourse; }
    const string &GetStudentId() const 
        { return studentId; }
    void SetCurrentCourse(const string &); // proto. only

    // In the derived class, keyword virtual is optional, 
    // and not currently recommended. Use override instead.
    void Print() const final override;
    void IsA() const override;
    // note: we choose not to redefine 
    // Person::Greeting(const string &) const
    static int GetNumberStudents(); // static mbr. function
};
// definition for static data member 
int Student::numStudents = 0;  // notice initial value of 0
inline void Student::SetCurrentCourse(const string &c)
{
    currentCourse = c;
}
// Definition for static member function (it's also inline)
inline int Student::GetNumberStudents()
{
    return numStudents;
}

在之前为 Student 定义的类中,我们又看到了我们习惯看到的所有各种组件来构成这个类。此外,请注意,我们已经使用 override 关键字重写和重新定义了三个虚拟函数——析构函数、Print()IsA()——这些首选定义基本上替换或覆盖了在基类中为这些操作指定的默认方法。

然而,请注意,我们选择不重新定义 void Person::Greeting(const string &),这是在 Person 类中作为虚函数引入的。如果我们认为从基类继承的这个方法对 Student 类的实例来说是可接受的,简单地继承这个方法就足够了。此外,请注意 Print() 函数上额外的 final 标记。这个关键字表示 Print() 不能在 Student 的派生类中被重写;在 Student 层级上重写的方法将是最终的实现。

回想一下,当与析构函数配对时,override 的含义是独特的,因为它并不意味着派生类的析构函数替换基类的析构函数。相反,它意味着当由派生类实例(无论它们如何存储)启动时,派生类(虚拟)析构函数是 销毁链 序列的正确起点。虚拟派生类析构函数仅仅是完整销毁序列的入口点。

还要记住,Student 的派生类不必须重写 Person 中定义的虚函数。如果 Student 类认为基类的方法是可接受的,它将自动继承。虚函数仅仅允许派生类在需要时用更合适的方法重新定义一个操作。

接下来,让我们检查非内联的 Student 类成员函数:

Student::Student(): studentId(to_string(numStudents + 100) 
                                        + "Id")
{
   // studentId is const; we need to set at construction. 
   // We're using member init list with a unique id based
   // on numStudents + 100), concatenated with string "Id".
   // Remember, string member currentCourse will be default
   // const. with an empty string (it's a member object)
   numStudents++;     // set static data member
}
// Alternate constructor member function definition
Student::Student(const string &fn, const string &ln, 
                 char mi, const string &t, float avg, 
                 const string &course, const string &id):
                 Person(fn, ln, mi, t), gpa(avg),
                 currentCourse(course), studentId(id)
{
   // dynamically alloc memory for any pointer data members
   numStudents++;
}
// Copy constructor definition
Student::Student(const Student &s) : Person(s),
                gpa(s.gpa), currentCourse(s.currentCourse),
                studentId(s.studentId)
{
   // deep copy any pointer data mbrs of derived class here
   numStudents++;
}

// destructor definition
Student::~Student()
{
    // release memory for any dynamically alloc. data mbrs
    cout << "Student destructor <" << GetFirstName() << " "
         << GetLastName() << ">" << endl;
}
void Student::EarnPhD()
{
    ModifyTitle("Dr.");  
}
void Student::Print() const
{   // need to use access functions as these data members 
    // are defined in Person as private
    cout << GetTitle() << " " << GetFirstName() << " ";
    cout << GetMiddleInitial() << ". " << GetLastName();
    cout << " with id: " << studentId << " GPA: ";
    cout << setprecision(3) <<  " " << gpa;
    cout << " Course: " << currentCourse << endl;
}
void Student::IsA() const
{
    cout << "Student" << endl;
}

在之前列出的代码部分中,我们列出了 Student 的非内联成员函数定义。再次注意,关键字 override 不会出现在任何虚拟成员函数的定义本身中,只出现在它们各自的原型中。

最后,让我们检查 main() 函数:

int main()
{
    Person *people[MAX] = { }; // initialize with nullptrs
    people[0] = new Person("Juliet", "Martinez", 'M',
                           "Ms.");
    people[1] = new Student("Hana", "Sato", 'U', "Dr.",
                            3.8, "C++", "178PSU"); 
    people[2] = new Student("Sara", "Kato", 'B', "Dr.",
                            3.9, "C++", "272PSU"); 
    people[3] = new Person("Giselle", "LeBrun", 'R',
                           "Miss");
    people[4] = new Person("Linus", "Van Pelt", 'S',
                           "Mr.");
    // We will soon see a safer and more modern way to loop
    // using a range for loop (starting in Chp. 8). 
    // Meanwhile, let's notice mechanics for accessing 
    // each element.
    for (int i = 0; i < MAX; i++)   
    {
       people[i]->IsA();
       cout << "  ";
       people[i]->Print();
    } 
    for (int i = 0; i < MAX; i++)
       delete people[i];   // engage virtual dest. sequence
    return 0;
}

在这里,在 main() 函数中,我们声明了一个指向 Person 的指针数组。这样做,使我们能够将 PersonStudent 实例都收集在这个集合中。当然,我们可以应用于存储在这种通用方式中的实例的唯一操作是那些在基类 Person 中找到的操作。

接下来,我们分配几个Person和几个Student实例,通过指针集合的元素存储每个实例。当以这种方式存储Student时,会执行向上转换到基类类型(但实例不会被任何方式改变)。回想一下,当我们查看派生类实例的内存布局时,在第六章中,使用单一继承实现层次结构,我们注意到Student实例首先包含Person的内存布局,然后是Student数据成员所需额外的内存。这种向上转换仅仅指向这个集合内存的起始点。

现在,我们通过循环应用在Person类中找到的操作,将这些泛化集合中的所有实例都应用上。这些操作恰好是多态的。也就是说,虚函数允许通过运行时绑定调用特定方法的实现,以匹配实际的对象类型(无论对象是否存储在泛化指针中)。

最后,我们再次通过循环删除PersonStudent的动态分配的实例,再次使用泛化的Person指针。因为我们知道delete()会插入对析构函数的调用,所以我们明智地使析构函数virtual,以便动态绑定选择每个对象的适当起始析构函数(在析构链中)。

当我们查看上述程序的输出时,我们可以看到每个对象的特定方法都适当地调用了每个虚函数,包括析构序列。派生类对象既调用了派生类,也调用了基类析构函数并执行了它们。以下是完整程序示例的输出:

Person
  Ms. Juliet M. Martinez
Student
  Dr. Hana U. Sato with id: 178PSU GPA:  3.8 Course: C++
Student
  Dr. Sara B. Kato with id: 272PSU GPA:  3.9 Course: C++
Person
  Miss Giselle R. LeBrun
Person
  Mr. Linus S. Van Pelt
Person destructor <Juliet Martinez>
Student destructor <Hana Sato>
Person destructor <Hana Sato>
Student destructor <Sara Kato>
Person destructor <Sara Kato>
Person destructor <Giselle LeBrun>
Person destructor <Linus Van Pelt>

现在我们已经掌握了利用多态概念和虚函数机制的能力,让我们看看与虚函数相关的一个不太常见的情况,即函数隐藏。

考虑函数隐藏

函数隐藏不是 C++中常用的特性。实际上,它经常是意外使用的!让我们回顾一下关于继承成员函数的一个关键点,以便开始。当一个操作由基类指定时,它旨在为所有派生类方法提供使用协议和重定义(在虚函数的情况下)。

有时,派生类会改变一个旨在重定义基类指定的操作(让我们考虑虚函数)的方法的签名。在这种情况下,与祖先类中指定的操作不同的新函数将不会被考虑为对继承操作的虚拟重定义。实际上,它将隐藏具有在祖先类中指定相同名称的虚函数的继承方法。

当程序编译时,每个函数的签名都会与类定义进行比较,以确保正确使用。通常情况下,当在看似与实例类型匹配的类中找不到成员函数时,会向上遍历层次结构,直到找到匹配项或遍历完整个层次结构。让我们更仔细地看看编译器考虑的内容:

  • 当找到一个与正在寻找的函数具有相同名称的函数时,会检查签名以确定它是否与函数调用完全匹配,或者是否可以应用类型转换。当找到函数但无法应用类型转换时,正常的遍历序列结束。

  • 通常,隐藏虚拟函数的函数会停止向上搜索序列,从而隐藏了否则可能被调用的虚拟函数。记住,在编译时,我们只是在检查语法(而不是决定调用虚拟函数的哪个版本)。但如果找不到匹配项,则会标记错误。

  • 函数隐藏实际上被认为是有所帮助的,并且是语言设计者意图的。如果类设计者提供了一个具有特定签名和接口的特定函数,那么应该使用该函数来处理该类型的实例。在层次结构中隐藏或未预料到的函数不应在此特定场景中使用。

考虑以下对我们之前完整程序示例的修改,首先说明函数隐藏,然后提供一个更灵活的解决方案来管理函数隐藏:

  • 记住,Person 类引入了不带参数的 virtual void Print()。想象一下,Student 类不是使用相同的签名覆盖 Print(),而是将其更改为 virtual void Print(const char *)

    class Person  // base class
    {
        // data members
    public:  // member functions, etc. 
        virtual void Print() const;  
    };
    class Student: public Person
    {
        // data members
    public:  // member functions, etc.
        // Newly introduced virtual fn. -- 
        // Not a redefinition of Person::Print()
        virtual void Print(const string &) const;
    };
    

注意,Print() 的签名已从基类更改为派生类。派生类函数并没有重新定义其基类的 virtual void Print();。这是一个实际上会隐藏 Person::Print() 存在的新函数。这正是预期的结果,因为你可能不记得基类提供了这样的操作,如果在你的应用中意图调用 Print(const string &) 但实际上调用的是 Print(),向上跟踪可能会在你的应用中产生令人惊讶的结果。通过添加这个新函数,派生类设计者指定了这个接口是 Student 实例的适当 Print()

然而,在 C++中没有什么事情是直截了当的。对于将Student向上转换为Person的情况,将调用无参数的Person::Print()Student::Print(const string &)不是虚拟重定义,因为它没有相同的签名。因此,对于泛化的Student实例,将调用Person::Print()。而对于存储在Student变量中的Student实例,将调用Student::Print(const string &)。不幸的是,这与实例存储在其自身类型与泛化类型中的行为不一致。尽管函数隐藏旨在以这种方式工作,但它可能不可避免地不是你希望发生的事情。程序员,小心!

让我们看看可能随之而来的繁琐代码:

  • 可能需要显式回溯或使用作用域解析运算符来揭示其他隐藏的功能:

    constexpr int MAX = 2;
    int main()
    { 
        Person *people[MAX] = { }; // init. with nullptrs
        people[0] = new Person("Jim", "Black", 'M',
                               "Mr.");
        people[1] = new Student("Kim", "Lin", 'Q', "Dr.",
                                3.55, "C++", "334UD"); 
        people[1]->Print(); // ok, Person::Print() defined
        // people[1]->Print("Go Team!"); // error!
        // explicit downcast to derived type assumes you
        // correctly recall what the object is
        (dynamic_cast<Student *> (people[1]))->
                                 Print("I have to study");
        // Student stored in its own type
        Student s1("Jafari", "Kanumba", 'B', "Dr.", 3.9,
                   "C++", "845BU"); 
        // s1.Print(); // error, base class version hidden
        s1.Print("I got an A!"); // works for type Student
        s1.Person::Print(); // works using scope 
                          // resolution to base class type
        return 0;
    }
    

在上述示例中,我们有一个泛型集合的两个Person指针。一个条目指向一个Person,另一个条目指向一个Student。一旦Student被泛化,唯一适用的操作是那些在Person基类中找到的操作。因此,调用people[1]->Print();是有效的,而调用people[1]->Print("Go Team!");则不工作。后者的Print(const char *)调用在泛化基类级别是错误的,尽管对象确实是一个Student

如果,从一个泛型指针中,我们希望调用在层次结构中的Student级别的特定函数,那么我们需要将实例回溯到其自身类型(Student)。我们通过以下调用添加回溯:(dynamic_cast<Student *>(people[1]))->Print("I have to study");。在这里,我们正在承担风险——如果people[1]实际上是一个Person而不是Student,这将生成运行时错误。然而,通过在调用Print()之前首先检查动态转换到Student *的结果,我们可以确保我们进行了适当的转换。

接下来,我们实例化Student s1;。如果我们尝试调用s1.Print(),我们会得到编译器错误——Student::Print(const string &)隐藏了基类中Person::Print()的存在。记住,s1存储在其自身类型Student中,并且由于找到了Student::Print(const string &),向上遍历以揭示Person::Print()的过程被终止。

然而,我们的s1.Print("I got an A!");调用是成功的,因为Print(const string &)Student类级别被找到。最后,请注意,调用s1.Person::Print();是有效的,但需要了解其他隐藏的功能。通过使用作用域解析运算符(::),我们可以找到基类版本的Print()。尽管Print()在基类中是虚拟的(意味着动态绑定),但使用作用域解析运算符会将此调用回退为静态绑定函数调用。

让我们提出,我们想要向派生类添加一个新接口,该接口将隐藏基类函数。了解函数隐藏后,我们理想上应该怎么做?我们可以简单地使用基类中的虚拟函数,在派生类中用新的方法覆盖它,然后我们可以重载该函数以添加额外的接口。是的,我们现在既覆盖又重载。也就是说,我们覆盖了基类函数,并在派生类中重载了被覆盖的函数。

让我们看看我们现在会有什么:

  • 这里是更灵活的接口,在保持现有接口(否则会被隐藏)的同时添加新成员函数:

    class Person  // base class
    {
        // data members
    public:  // member functions, etc.
        virtual void Print() const;
    };
    class Student: public Person
    {
        // data members
    public:  // member functions, etc.
        // Override the base class method so that this
        // interface is not hidden by overloaded fn. below
        void Print() const override; 
        // add the additional interface 
        // (which is overloaded)
        // Note: this additional Print() is virtual
        // from this point forward in the hierarchy
        virtual void Print(const string &) const; 
    };
    int main()
    {
        Student s1("Zack", "Doone", 'A', "Dr.", 3.9,
                   "C++", "769UMD"); 
        s1.Print();  // this version is no longer hidden.
        s1.Print("I got an A!"); // also works
        s1.Person::Print(); // this is no longer necessary
    }
    

在前面的代码片段中,Student 类既用 Student::Print() 覆盖了 Person::Print(),又用 Student::Print(const string &) 重载了 Student::Print() 以包含额外的所需接口。现在,对于存储在 Student 变量中的 Student 对象,两个接口都是可用的——基类接口不再被隐藏。当然,由 Person 指针引用的 Student 对象只有 Person::Print() 接口,这是预期的。

总体来说,函数隐藏并不常见,但一旦发生,通常是一个不受欢迎的惊喜。现在你了解了可能发生的情况以及原因,这有助于你成为一个更好的程序员。

现在我们已经看到了围绕虚拟函数的所有用途,让我们看看内部机制,了解为什么虚拟函数能够支持将特定方法绑定到操作。为了彻底理解运行时绑定,我们需要查看 v-table。让我们继续前进!

理解动态绑定

现在我们已经看到了如何通过虚拟函数实现多态,以允许将操作动态绑定到特定的实现或方法,让我们了解为什么虚拟函数允许运行时绑定。

非虚拟函数在编译时进行静态绑定。也就是说,所涉及函数的地址是在编译时确定的,基于当前对象假设的类型。例如,如果创建了一个类型为 Student 的对象,函数调用会从 Student 类开始验证其原型,如果找不到,则会遍历层次结构向上到每个基类,如 Person,以查找匹配的原型。找到后,正确的函数调用会被修补。这就是静态绑定的工作方式。

然而,虚函数是 C++中一种在运行时使用动态绑定的函数类型。在编译时,任何虚函数调用都仅被替换为一个查找机制,以延迟绑定到运行时。当然,每个编译器供应商在自动实现虚函数方面可能会有所不同。然而,有一个广泛使用的实现涉及虚函数指针、虚函数表以及包含虚函数的每个对象类型的虚函数表条目。

让我们继续前进,研究 C++中动态绑定通常是如何实现的。

理解方法到操作的运行时绑定

我们知道虚函数允许将操作(在基类中指定)动态绑定到特定的实现或方法(通常在派生类中指定)。这是如何工作的?

当基类指定一个或多个新虚函数(不仅仅是祖先虚函数的重定义)时,会在该类型给定实例的内存下方创建一个虚函数指针vptr)。这发生在创建实例内存时(在栈上、堆上或静态/外部区域)。当构造特定实例时,不仅会调用适当的构造函数来初始化实例,而且这个 vptr 将被初始化,指向该类类型的虚函数指针表v-table)条目。

给定类类型的虚函数表条目将包含一组函数指针。这些函数指针通常组织成一个函数指针数组。函数指针是指向实际函数的指针。通过解引用这个指针,你实际上会调用该指针指向的函数。虽然有机会向函数传递参数,但是为了使通过函数指针的调用通用,参数必须对指针可能指向的该函数的任何版本都是统一的。函数指针的前提为我们提供了指向特定函数不同版本的能力。也就是说,我们可以指向给定操作的不同的方法。这是 C++中自动化虚函数动态绑定的基础。

让我们考虑特定对象类型的特定虚函数表条目。我们知道这个表条目将包含一组函数指针,例如一个函数指针数组。这些函数指针的排列顺序将与给定类新引入的虚函数的顺序一致。在层次结构中较高级别新引入的覆盖现有虚函数的函数将简单地用要调用的函数的优选版本替换表条目,但不会在函数指针数组中分配额外的条目。

因此,当程序开始运行时,首先在全局内存中(作为一个隐藏的外部变量),将设置一个 v-table。这个表将包含包含虚拟函数的每个对象类型的条目。给定对象类型的条目将包含一组函数指针(例如函数指针数组),它组织和初始化该类动态绑定的函数。函数指针的具体顺序将对应于虚拟函数被引入的顺序(可能由其祖先类引入),并且特定的函数指针将被初始化为针对特定类类型的函数的优选版本。也就是说,函数指针可能指向在它们自己的类级别指定的重写方法。

然后,当给定类型的对象被实例化时,该对象内的 vptr(对于每个新引入的子对象级别的新虚拟函数 – 不是重新定义的虚拟函数 – 将有一个)将被设置为指向该实例的相应 v-table 条目。

通过代码和内存图查看这个细节将非常有用。让我们揭开盖子,看看代码是如何运行的!

详细解释 v-table

为了详细说明内存模型并查看在运行时将设置的底层 C++ 机制,让我们考虑本节中的详细完整程序示例,其中基类为 Person,派生类为 Student。作为提醒,我们将展示程序的关键元素:

  • PersonStudent 类的简略定义(为了节省空间,我们将省略数据成员和大多数成员函数的定义):

    class Person
    {
    private:   // data members will be as before
    protected: // assume all member funcs. are as before,
    public:  // but we will show only virtual funcs. here
        virtual ~Person();       // 4 virt fns introduced 
        virtual void Print() const;  // in Person class
        virtual void IsA() const;  
        virtual void Greeting(const string &) const;
    };
    class Student: public Person
    {
    private:  // data members will be as before
    public:   // assume all member funcs. are as before, 
        // but we will show only virtual functions here
        ~Student() override;  // 3 virt fns are overridden
        void Print() const override;
        void IsA() const override;
    };
    

PersonStudent 类的定义符合预期。假设数据成员和成员函数与完整程序示例中所示相同。为了简洁,我们只包括了在每个级别引入或重新定义的虚拟函数。

  • 以简略形式回顾 main() 函数的关键元素(简化为三个实例):

    constexpr int MAX = 3;
    int main()
    {
        Person *people[MAX] = { }; // init. with nullptrs
        people[0] = new Person("Joy", "Lin", 'M', "Ms.");
        people[1] = new Student("Renee", "Alexander", 'Z',
                        "Dr.", 3.95, "C++", "21-MIT"); 
        people[2] = new Student("Gabby", "Doone", 'A', 
                        "Ms.", 3.95, "C++", "18-GWU"); 
        // In Chp. 8, we'll upgrade to a range for loop
        for (int i = 0; i < MAX; i++)
        {                 // at compile time, modified to:
            people[i]->IsA();  // *(people[i]->vptr[2])()
            people[i]->Print();
            people[i]->Greeting("Hello");
            delete people[i];
        }
        return 0;
    }
    

注意在我们的 main() 函数中,我们实例化了一个 Person 实例和两个 Student 实例。所有这些都被存储在一个基类类型 Person 的泛型指针数组中。然后我们遍历这个集合,对每个实例调用虚拟函数,即 IsA()Print()Greeting() 以及析构函数(当我们删除每个实例时,析构函数会被隐式调用)。

考虑前一个示例的内存模型,我们有以下图示:

图 7.1 – 当前示例的内存模型

图 7.1 – 当前示例的内存模型

在上述内存图中(该图紧接在先前的程序之后),请注意我们有一个指向泛化Person实例的指针数组。第一个实例实际上是一个Person,而第二个两个实例是Student类型。但是,由于StudentPerson的子类,因此将Student向上转换为Person是可接受的。内存布局的顶部实际上是每个Student实例的Person。对于实际上是Student类型的实例,Student的附加数据成员将跟随Person子对象所需的全部内存。

注意到vptr条目紧接在每个Person对象(或子对象)的三个实例的数据成员之后。vptr的位置是每个对象顶部相同的偏移量。这是因为所讨论的虚拟函数都是在层次结构中的Person级别引入的。有些可能在Student类中被重写,以提供更适合Student的定义,但每个函数的引入级别是在Person级别,因此位于Person对象(或子对象)下的vptr将反映指向在Person级别引入的操作列表的指针。

作为旁注,假设Student引入了全新的虚拟函数(而不仅仅是现有虚拟函数的重定义),就像我们在先前的函数隐藏场景中看到的那样。那么,在Student子对象下面将会有一个第二vptr条目,其中添加了那些额外的(新虚拟)操作。

当每个对象被实例化时,首先将调用每个实例的适当构造函数(沿层次结构向上)。此外,编译器将为每个实例的vptr设置一个指针赋值,使其指向与对象类型对应的v-table条目。也就是说,当实例化Person时,其vptr将指向Personv-table条目。当实例化Student时,其vptr将指向Studentv-table条目。

假设PersonStudent类型的v-table条目包含指向该类型适当虚拟函数的函数指针数组。每个类型的v-table条目实际上还包含更多信息,例如该类型实例的大小等。为了简化,我们只需查看v-table条目的那部分,它为每个类类型自动执行动态绑定。

注意到Personv-table条目是一个包含四个函数指针的数组。每个函数指针将指向为Person提供的最合适的析构函数、Print()IsA()Greeting()版本。这些函数指针出现的顺序与这些虚拟函数由该类引入的顺序相对应。也就是说,vptr[0]将指向Person的析构函数,vptr[1]将指向Person::Print(),依此类推。

现在,让我们看看 Student 的 v-table 条目。虚函数(作为函数指针)放入数组中的顺序与 Person 类中的顺序相同。这是因为基类引入了这些函数,并且在这个指针数组中的顺序是由这个级别设置的。但是请注意,实际指向的函数已经被 Student 实例重写,主要是派生类 Student 重新定义的方法。也就是说,Student 析构函数被指定(作为销毁的起点),然后是 Student::Print(),接着是 Student::IsA(),然后是 Person::Greeting()。注意 vptr[3] 指向 Person::Greeting()。这是因为 Student 在其类定义中没有重新定义这个函数;Student 发现继承的 Person 定义是可以接受的。

将这个内存图与我们的 main() 函数中的代码配对,注意在我们实例化一个 Person 和两个 Student 实例,并将每个实例存储在泛化的 Person 指针数组中之后,我们遍历一个包含多个操作的循环。我们统一调用 people[i]->Print();,然后 people[i]->IsA();,接着 people[i]->Greeting("Hello");,然后 delete people[i];(这会插入一个析构函数调用)。

因为每个函数都是虚的,所以决定调用哪个函数的决定被推迟到运行时查找。这是通过访问每个实例的隐藏 vptr 成员,根据当前操作索引适当的 v-table 条目,然后解引用在该条目中找到的函数指针来调用适当的方法来完成的。编译器知道,例如,vptr[0] 将是析构函数,vptr[1] 将是基类定义中引入的下一个虚拟函数,依此类推,因此可以通过多态操作的名称轻松确定应该激活的 v-table 中的元素位置。

想象一下,在 main() 函数中对 people[i]->Print(); 的调用被替换成了 *(people[i]->vptr[1])();,这是调用当前函数时解引用函数指针的语法。注意,我们首先是通过 people[i]->vptr[1] 来访问哪个函数,然后使用 * 来解引用函数指针。注意语句末尾的括号 (),这是传递任何参数给函数的地方。因为解引用函数指针的代码需要统一,所以任何此类函数的参数也必须是统一的。这就是为什么在派生类中重写的任何虚函数都必须使用基类中指定的相同签名。当你深入了解时,这一切都变得有意义。

我们现在已经彻底研究了面向对象的泛型概念以及它是如何通过 C++ 中的虚函数实现的。在继续到下一章之前,让我们简要回顾一下本章我们涵盖了哪些内容。

概述

在本章中,我们通过理解 C++中的虚拟函数如何为面向对象的泛化思想提供直接的语言支持,进一步深入了面向对象编程的旅程。我们看到了虚拟函数如何提供动态绑定,将特定方法与继承层次结构中的操作相关联。

我们已经看到,使用虚拟函数,一个基类指定的操作可以被派生类覆盖,提供更合适的实现。我们还看到,可以通过运行时绑定选择每个对象的正确方法,无论该对象是存储在其自身类型中还是在泛化类型中。

我们已经看到,对象通常是通过基类指针进行泛化的,以及这种方式如何允许对相关派生类类型的统一处理。我们了解到,无论一个实例是如何存储的(作为其自身类型或作为基类类型使用指针),正确的虚拟函数版本总是会通过动态绑定来应用。我们还看到,在可能常规进行向上转型的公共继承层次结构中,拥有一个虚拟析构函数是至关重要的。

我们还通过检查典型的编译器实现,了解了动态绑定是如何通过将 vptr 嵌入实例中工作的,以及这些指针如何引用与每个对象类型相关的 v-table 条目(包含成员函数指针集)。

我们已经看到,虚拟函数使我们能够利用动态绑定操作到最合适的方法,使我们能够使用 C++作为面向对象的语言来实现具有多态性的健壮设计,这促进了代码的易于扩展。

通过利用虚拟函数扩展我们的面向对象知识,我们现在可以向前迈进,包括与继承和泛化相关的更多面向对象概念和细节。继续阅读第八章,“掌握抽象类”,我们将接下来学习如何利用面向对象的抽象类理想,以及围绕这一下一个面向对象概念的所有相关的面向对象考虑。让我们继续!

问题

  1. 使用你的第六章,“使用单一继承实现层次结构”的解决方案,增强你的继承层次结构,以进一步将StudentGraduateStudentNonDegreeStudent进行特殊化。

    1. 向你的GraduateStudent类添加必要的成员变量。可以考虑的成员变量包括论文题目研究生导师。包括适当的构造函数(默认、替代和复制)、析构函数、访问成员函数和合适的公共接口。确保将你的成员变量放置在私有访问区域。对NonDegreeStudent也做同样的处理。

    2. 根据需要向PersonStudentGraduateStudentNonDegreeStudent添加多态操作。在Person级别引入虚拟函数IsA()Print()。根据需要,在派生类中重写IsA()Print()。可能的情况是在StudentGraduateStudent中重写IsA(),但选择只在Student类中重写Print()。务必在每个类中包含虚拟析构函数。

    3. 多次实例化StudentGraduateStudentNonDegreeStudentPerson,并利用每个的适当public接口。务必动态分配多个实例。

    4. 创建一个指向Person的指针数组,并为PersonStudentGraduateStudentNonDegreeStudent分配实例,使它们成为该数组的成员。一旦泛化,只需调用在Person级别(以及Person的其他公共方法)中找到的多态操作。务必删除任何动态分配的实例。

    5. 创建一个指向Student的指针数组,并只为该数组分配GraduateStudentNonDegreeStudent的实例。现在,调用在Student级别找到的操作以应用于这些泛化实例。此外,利用在Person级别找到的操作——它们是继承的,并且对于泛化的Student实例也额外可用。务必删除您数组中指向的任何动态分配的实例。

第八章:掌握抽象类

本章将继续扩展我们对 C++面向对象编程知识的理解。我们将从探索一个强大的面向对象概念——抽象类开始,然后进一步了解这个想法是如何通过直接语言支持在 C++中实现的。

我们将使用纯虚函数实现抽象类,以最终支持相关类层次结构中的改进。我们将了解抽象类如何增强和与我们的多态理解相匹配。我们还将认识到本章中提出的抽象类面向对象概念将支持强大且灵活的设计,使我们能够轻松创建可扩展的 C++代码。

在本章中,我们将涵盖以下主要内容:

  • 理解抽象类的面向对象概念

  • 使用纯虚函数实现抽象类

  • 使用抽象类和纯虚函数创建接口

  • 使用抽象类泛化派生类对象,以及向上和向下转换

到本章结束时,您将理解抽象类的面向对象概念,以及如何通过纯虚函数在 C++中实现这一想法。您将了解仅包含纯虚函数的抽象类如何定义一个面向对象的概念——接口。您将理解抽象类和接口如何有助于强大的面向对象设计。

您将看到我们如何非常容易地使用抽象类型集合泛化相关、专业的对象组。我们将进一步探索在层次结构中的向上和向下转换,以了解允许什么以及何时进行此类类型转换是合理的。

通过理解 C++中抽象类的直接语言支持以及为什么创建接口是有用的,您将拥有更多工具来创建一个可扩展的相关类层次结构。让我们通过理解这些概念在 C++中的实现来扩展我们对 C++作为面向对象语言的理解。

技术要求

完整程序示例的在线代码可以在以下 GitHub URL 中找到:github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/tree/main/Chapter08。每个完整程序示例都可以在 GitHub 的相应章节标题(子目录)下找到,该文件对应章节编号,后面跟着一个连字符,然后是当前章节中的示例编号。例如,本章的第一个完整程序可以在上述 GitHub 目录下的Chapter08子目录中的名为Chp8-Ex1.cpp的文件中找到。

本章的 CiA 视频可以在以下链接中查看:bit.ly/3SZv0jy

理解抽象类的面向对象概念

在本节中,我们将介绍一个基本面向对象的概念,即抽象类。这个概念将丰富你对关键 OO(面向对象)思想的了解,包括封装、信息隐藏、泛化、特化和多态。你知道如何封装一个类。你也知道如何使用单继承构建继承层次结构,以及构建层次结构的各种原因,例如支持关系或支持实现继承的较少使用原因。此外,你知道如何通过多态的概念使用运行时绑定方法到操作,这是通过虚函数实现的。让我们通过探索抽象类来扩展我们不断增长的 OO 术语。

抽象类是一个基类,旨在收集派生类中可能存在的共同点,目的是在派生类上断言一个公共接口(即一组操作)。抽象类不代表一个旨在实例化的类。只有派生类类型的对象可以被实例化。

让我们从 C++语言特性开始,它允许我们实现抽象类,即纯虚函数。

使用纯虚函数实现抽象类

抽象类是通过在类定义中引入至少一个抽象方法(即纯虚函数原型)来指定的。抽象方法的概念是仅指定操作的协议(即仅成员函数的名称签名),但没有函数定义。抽象方法将是多态的,因为没有定义,它预期将被派生类重新定义。

函数参数后跟一个 =0。此外,理解有关纯虚函数的以下细微差别也很重要:

  • 通常不提供纯虚函数的定义。这相当于在基类级别指定操作(仅原型),并在派生类级别提供所有方法(成员函数定义)。

  • 派生类没有为其基类引入的所有纯虚函数提供方法,也被认为是抽象的,因此不能实例化。

  • 原型中的 =0 仅是向链接器指示,在创建可执行程序时,不需要(或解决)此函数的定义。

注意

抽象类是通过在类定义中包含一个或多个纯虚函数原型来指定的。通常不提供这些方法的可选定义。

纯虚函数通常不会有定义的原因是它们旨在为在派生类中实现的多态操作提供一个使用协议。纯虚函数指定一个类为抽象类;抽象类不能被实例化。因此,在纯虚函数中提供的定义永远不会被选为多态操作的正确方法,因为抽象类型的实例永远不会存在。话虽如此,纯虚函数仍然可以提供一个定义,该定义可以使用作用域解析运算符(::)和基类名称显式调用。也许,这种默认行为可能对作为派生类实现中辅助函数的有意义。

让我们从简要概述一下指定抽象类所需的语法开始。记住,使用abstract关键字本身并不用于指定抽象类。相反,仅仅通过引入一个或多个纯虚函数,我们就已经指明了该类是一个抽象类:

class LifeForm    // Abstract class definition
{
private:
    // all LifeForms have a lifeExpectancy
    int lifeExpectancy = 0; // in-class initialization
public:
    LifeForm() = default; // def. ctor, uses in-class init 
    LifeForm(int life): lifeExpectancy(life) { }
    // Remember, we get default copy, even w/o proto below
    // LifeForm(const LifeForm &form) = default; 
    // Must include prototype to specify virtual destructor
    virtual ~LifeForm() = default;   // virtual destructor
    // Recall, [[nodiscard]] requires ret. value to be used
    [[nodiscard]] int GetLifeExpectancy() const 
        { return lifeExpectancy; }
    virtual void Print() const = 0; // pure virtual fns. 
    virtual string IsA() const = 0;   
    virtual string Speak() const = 0;
};

注意,在抽象类定义中,我们引入了四个虚函数,其中三个是纯虚函数。虚析构函数没有内存需要释放,但被标记为virtual,以便它是多态的,并且可以应用于存储为基类类型指针的派生类实例的正确销毁顺序。

三个纯虚函数,Print()IsA()Speak(),在它们的原型中用=0表示。这些操作没有定义(尽管可以有选择地定义)。纯虚函数可以有默认实现,但不能作为内联函数。提供这些操作的方法的责任将落在派生类身上,使用由基类定义指定的接口(即签名)。在这里,纯虚函数为在派生类定义中定义的多态操作提供了接口

重要提示

抽象类肯定会有派生类(因为我们不能实例化抽象类本身)。为了确保虚拟析构机制在最终层次结构中适当工作,请确保在抽象类定义中包含一个虚拟析构函数。这将确保所有派生类析构函数都是虚拟的,并且可以被重写以在对象的销毁顺序中提供正确的入口点。

现在,让我们从面向对象的角度更深入地探讨拥有接口的含义。

创建接口

接口类是一个类的面向对象概念,它是抽象类的一个进一步细化。而抽象类可以包含泛化属性和默认行为(通过包含数据成员和纯虚函数的默认定义,或者通过提供非虚成员函数),接口类将只包含抽象方法。在 C++中,只包含抽象方法(即没有可选定义的纯虚函数)的抽象类可以被视为接口类

当考虑 C++中实现的接口类时,记住以下内容是有用的:

  • 抽象类是不可实例化的;它们通过继承提供(即,接口,即操作)派生类必须提供的接口。

  • 虽然纯虚函数在抽象类中可能包含一个可选的实现(即,方法体),但如果类希望被视为在纯面向对象术语中的接口类,则不应提供此实现。

  • 尽管抽象类可能包含数据成员,但如果类希望被视为接口类,则不应包含。

  • 在面向对象术语中,抽象方法是一个没有方法的操作;它仅是接口,并在 C++中作为纯虚函数实现。

  • 作为提醒,请确保在接口类定义中包含虚拟析构函数原型;这将确保派生类的析构函数将是虚拟的。析构函数定义应该是空的。

让我们考虑在面向对象编程(OOP)实现技术中拥有接口类的各种动机。一些面向对象编程(OOP)语言遵循非常严格的面向对象概念,并且只允许实现非常纯粹的面向对象设计。其他面向对象编程(OOP)语言,如 C++,提供了更多的灵活性,允许通过语言直接实现更激进的面向对象思想。

例如,在纯面向对象术语中,继承应该保留用于“是...的”关系。我们已经看到了实现继承,这是 C++通过私有和受保护基类支持的。我们已经看到了一些可接受的实现继承的使用,即,通过另一个(使用受保护和公共基类使用的能力来隐藏底层实现)来实现一个新的类。

另一个边缘面向对象编程(OOP)特性的例子是多继承。我们将在第九章,“探索多继承”中看到,C++允许一个类从多个基类派生。在某些情况下,我们确实是在说派生类与可能许多基类之间存在“是...的”关系,但并非总是如此。

一些面向对象的语言不允许多重继承,而那些不允许多重继承的语言则更多地依赖于接口类来混合(否则)多个基类的功能。在这些情况下,面向对象的语言可以允许派生类根据多个接口类中指定的功能实现,而不实际使用多重继承。理想情况下,接口用于从多个类中混合功能。这些类,不出所料,有时被称为混合类。在这些情况下,我们并不是说派生类和基类之间必然存在 Is-A 关系。

在 C++中,当我们引入一个只包含纯虚函数的抽象类时,我们可以将其视为创建一个接口类。当一个新类从多个接口中混合功能时,我们可以从面向对象的角度将其视为使用每个接口类作为混合所需接口以实现行为的一种手段。请注意,派生类必须用自己的实现覆盖每个纯虚函数;我们只是在混合所需的 API。

C++实现面向对象的接口概念仅仅是包含纯虚函数的抽象类。在这里,我们使用从抽象类的公共继承以及多态来模拟面向对象的接口类概念。请注意,其他语言(如 Java)直接在语言中实现这个想法(但那些语言不支持多重继承)。在 C++中,我们可以做几乎所有的事情,但了解如何以合理和有意义的方式实现面向对象的理念(即使这些理念没有直接的语言支持)仍然很重要。

让我们通过一个示例来展示如何使用抽象类来实现接口类:

class Charitable    // interface class definition
{                   // implemented using an abstract class
public:
    virtual void Give(float) = 0; // interface for 'giving'
    // must include prototype to specify virtual destructor
    virtual ~Charitable() = default; // remember virt. dest
};
class Person: public Charitable   // mix-in an 'interface'
{
    // Assume typical Person class definition w/ data
    // members, constructors, member functions exist.
public:
    virtual void Give(float amt) override
    {  // implement a means for giving here 
    }
    ~Person() override;  // virtual destructor prototype
};
// Student Is-A Person which mixes-in Charitable interface
class Student: public Person 
{   
    // Assume typical Student class definition w/ data
    // members, constructors, member functions exist.
public:
    virtual void Give(float amt) override
    {  // Should a Student have little money to give,
       // perhaps they can donate their time equivalent to
       // the desired monetary amount they'd like to give
    }
    ~Student() override;  // virtual destructor prototype 
};

在上述类定义中,我们首先注意到一个简单的接口类Charitable,它使用受限的抽象类实现。我们不包含数据成员,而是一个纯虚函数virtual void Give(float) = 0;来定义接口类。我们还包含一个虚析构函数。

接下来,Person通过公共继承从Charitable派生出来,以实现Charitable接口。我们简单地覆盖virtual void Give(float);以提供默认的捐赠定义。然后我们从Person派生出Student;请注意,学生是 Person 的一个混合(或实现)Charitable 接口的类。在我们的Student类中,我们选择重新定义virtual void Give(float);以提供更适合Student实例的Give()定义。也许学生财务有限,选择捐赠相当于预定金额的时间。

在这里,我们使用 C++中的抽象类来模拟面向对象的接口类概念。

让我们继续讨论与抽象类相关的内容,通过考察派生类对象如何被抽象类类型收集来展开。

将派生类对象泛化为抽象类型

我们在第七章,“通过多态利用动态绑定”,看到有时将相关的派生类实例分组到一个使用基类指针存储的集合中是合理的。这样做允许使用基类指定的多态操作对相关的派生类类型进行统一处理。我们还知道,当调用多态基类操作时,由于 C++中实现多态的虚函数和内部 v-table,将在运行时调用正确的派生类方法。

然而,你可以思考一下,是否有可能通过一个抽象基类类型来收集一组相关的派生类类型。记住,抽象类是不可实例化的,那么我们如何将派生类对象存储为一个不能实例化的对象呢?解决方案是使用指针(甚至是一个引用)。由于我们不能在抽象基类实例的集合中收集派生类实例(这些类型不能实例化),我们可以在抽象类类型的指针集合中收集派生类实例。我们还可以让抽象类类型的引用指向派生类实例。自从我们学习了多态性以来,我们就一直在做这种类型的分组(使用基类指针)。

专门对象的泛化组使用隐式向上转型。撤销这种向上转型必须使用显式向下转型来完成,程序员需要确保之前泛化的派生类型是正确的。错误的向下转型到错误类型将导致运行时错误。

在什么情况下有必要通过基类类型(包括抽象基类类型)收集派生类对象?答案是当在你的应用程序中按更通用的方式处理相关的派生类类型是有意义的时候,也就是说,当基类类型中指定的操作涵盖了您希望利用的所有操作时。不可否认,你可能会发现同样多的情况,其中保持派生类实例在其自己的类型中(以利用在派生类级别引入的专用操作)是合理的。现在你理解了可能发生的情况。

让我们继续通过检查一个展示抽象类在行动中的综合示例来继续。

将所有部件组合在一起

到目前为止,在本章中,我们已经理解了抽象类的微妙之处,包括纯虚函数,以及如何使用抽象类和纯虚函数创建接口类。始终重要的是要看到我们的代码在行动中的表现,以及其所有各种组件及其各种细微差别。

让我们看看一个更复杂、完整的程序示例,以完全说明使用纯虚函数实现的抽象类。在这个例子中,我们不会进一步将抽象类指定为接口类,但我们将有机会使用它们抽象基类类型的一组指针收集相关的派生类类型。这个例子将被分成多个部分;完整的程序可以在以下 GitHub 位置找到:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter08/Chp8-Ex1.cpp

#include <iostream>
#include <iomanip>
using std::cout;     // preferred to:  using namespace std;
using std::endl;
using std::setprecision;
using std::string;
using std::to_string;
constexpr int MAX = 5;
class LifeForm   // abstract class definition
{
private:
   int lifeExpectancy = 0;  // in-class initialization
public:
   LifeForm() = default;
   LifeForm(int life): lifeExpectancy(life) { }
   // Remember, we get the default copy ctor included,
   // even without the prototype below:
   // LifeForm(const LifeForm &) = default; 
   // Must include prototype to specify virtual destructor
   virtual ~LifeForm() = default;     // virtual destructor
   [[nodiscard]] int GetLifeExpectancy() const 
       { return lifeExpectancy; }
   virtual void Print() const = 0;   // pure virtual fns. 
   virtual string IsA() const = 0;   
   virtual string Speak() const = 0;
};

在上述类定义中,我们注意到LifeForm是一个抽象类。它是一个抽象类,因为它至少包含一个纯虚函数的定义。实际上,它包含三个纯虚函数的定义,即Print()IsA()Speak()

现在,让我们通过一个具体的派生类Cat来扩展Lifeform

class Cat: public LifeForm
{
private:
   int numberLivesLeft = 9;  // in-class initialization
   string name;
   static constexpr int CAT_LIFE = 15;  // Life exp for cat
public:
   Cat(): LifeForm(CAT_LIFE) { } // note prior in-class init
   Cat(int lives): LifeForm(CAT_LIFE),
                   numberLivesLeft(lives) { }
   Cat(const string &);
   // Because base class destructor is virtual, ~Cat() is 
   // automatically virtual (overridden) whether or not 
   // explicitly prototyped. Below prototype not needed:
   // ~Cat() override = default;   // virtual destructor
   const string &GetName() const { return name; }
   int GetNumberLivesLeft() const 
       { return numberLivesLeft; }
   void Print() const override; // redef pure virt fns
   string IsA() const override { return "Cat"; }
   string Speak() const override { return "Meow!"; }
};
Cat::Cat(const string &n) : LifeForm(CAT_LIFE), name(n)
{  // numLivesLeft will be set with in-class initialization
}
void Cat::Print() const
{
   cout << "\t" << name << " has " << numberLivesLeft;
   cout << " lives left" << endl;
}

在之前的代码段中,我们看到了Cat类的定义。注意,Cat通过在Cat类中为这些方法提供定义,重新定义了LifeForm的纯虚函数Print()IsA()Speak()。由于这些函数已经有了现有的方法,任何Cat的派生类都可以选择性地重新定义这些方法以使用更合适的版本(但它们不再有义务这样做)。

注意,如果Cat未能重新定义LifeForm的任何一个纯虚函数,那么Cat也将被视为一个抽象类,因此不能实例化。

作为提醒,尽管IsA()Speak()虚函数被内联编写以缩短代码,但编译器几乎永远不会内联虚函数,因为它们的正确方法必须在运行时确定(除了涉及编译器去虚化、final 方法或实例的动态类型已知的一些情况)。

注意,在Cat构造函数中,成员初始化列表被用来选择接受一个整型参数的LifeForm构造函数(即:LifeForm(CAT_LIFE))。值15CAT_LIFE)被传递给LifeForm构造函数,以将LifeForm中定义的lifeExpectancy初始化为15。此外,成员初始化列表还用于初始化Cat类中定义的数据成员,在类内初始化未使用的情况下(即,值由方法的参数确定)。

现在,让我们继续前进到Person类的定义,以及它的内联函数:

class Person: public LifeForm
{
private: 
    string firstName;
    string lastName;
    char middleInitial = '\0';
    string title;  // Mr., Ms., Mrs., Miss, Dr., etc.
    static constexpr int PERSON_LIFE = 80;  // Life exp of
protected:                                  // a Person
    void ModifyTitle(const string &);  
public:
    Person();   // programmer-specified default constructor
    Person(const string &, const string &, char, 
           const string &);  
    // Default copy constructor prototype is not necessary:
    // Person(const Person &) = default;  // copy const.
    // Because base class destructor is virtual, ~Person() 
    // is automatically virtual (overridden) whether or not 
    // explicitly prototyped. Below prototype not needed:
    // ~Person() override = default;  // destructor
    const string &GetFirstName() const 
        { return firstName; }  
    const string &GetLastName() const 
        { return lastName; }    
    const string &GetTitle() const { return title; } 
    char GetMiddleInitial() const { return middleInitial; }
    void Print() const override; // redef pure virt fns
    string IsA() const override;   
    string Speak() const override;
};

注意现在Person使用公有继承扩展了LifeForm。在之前的章节中,Person是继承层次结构顶部的基类。Person重新定义了来自LifeForm的纯虚函数,即Print()IsA()Speak()。因此,Person现在是一个具体类,可以被实例化。

现在,让我们回顾Person的成员函数定义:

// select the desired base constructor using mbr. init list
Person::Person(): LifeForm(PERSON_LIFE) 
{  // Remember, middleInitial will be set w/ in-class init
   // and the strings will be default constructed to empty
}
Person::Person(const string &fn, const string &ln, char mi,
               const string &t): LifeForm(PERSON_LIFE), 
                               firstName(fn), lastName(ln),
                               middleInitial(mi), title(t)
{
}
// We're using the default copy constructor. But if we did
// choose to prototype and define it, the method would be:
// Person::Person(const Person &p): LifeForm(p),
//           firstName(p.firstName), lastName(p.lastName),
//           middleInitial(p.middleInitial), title(p.title)
// {
// }
void Person::ModifyTitle(const string &newTitle)
{
   title = newTitle;
}
void Person::Print() const
{
   cout << "\t" << title << " " << firstName << " ";
   cout << middleInitial << ". " << lastName << endl;
}
string Person::IsA() const
{  
   return "Person";  
}
string Person::Speak() const 
{  
   return "Hello!";  
}  

Person成员函数中,请注意我们为Print()IsA()Speak()提供了实现。此外,请注意在两个Person构造函数中,我们在它们的成员初始化列表中选择了:LifeForm(PERSON_LIFE)来调用LifeForm(int)构造函数。这个调用将设置私有继承数据成员LifeExpectancy80PERSON_LIFE)在给定Person实例的LifeForm子对象中。

接下来,让我们回顾Student类的定义,以及它的内联函数定义:

class Student: public Person
{
private: 
    float gpa = 0.0;  // in-class initialization
    string currentCourse;
    const string studentId;  
    static int numStudents;
public:
    Student();  // programmer-supplied default constructor
    Student(const string &, const string &, char, 
            const string &, float, const string &, 
            const string &); 
    Student(const Student &);  // copy constructor
    ~Student() override;  // virtual destructor
    void EarnPhD();  
    float GetGpa() const { return gpa; }
    const string &GetCurrentCourse() const 
       { return currentCourse; }
    const string &GetStudentId() const 
       { return studentId; }
    void SetCurrentCourse(const string &);
    // Redefine not all of the virtrtual function; don't 
    // override Person::Speak(). Also, mark Print() as 
    // the final override
    void Print() const final override; 
    string IsA() const override;
    static int GetNumberStudents();  
};
int Student::numStudents = 0; // static data mbr def/init
inline void Student::SetCurrentCourse(const string &c)
{
    currentCourse = c; 
}
inline int Student::GetNumberStudents()
{
    return numStudents;
}

上述Student类的定义看起来与我们过去看到的非常相似。Student使用公有继承扩展了Person,因为StudentPerson的一个子类。

接下来,我们将回顾非内联的Student类成员函数:

// default constructor
Student::Student(): studentId(to_string(numStudents + 100) 
                                         + "Id")
{   // Set const studentId in mbr init list with unique id 
    // (based upon numStudents counter + 100), concatenated
    // with the string "Id". Remember, string member
    // currentCourse will be default constructed with
    // an empty string - it is a member object
    numStudents++;
}
// Alternate constructor member function definition
Student::Student(const string &fn, const string &ln, 
                 char mi, const string &t, float avg, 
                 const string &course, const string &id):
                 Person(fn, ln, mi, t), gpa(avg),
                 currentCourse(course), studentId(id)
{
    numStudents++;
}
// Copy constructor definition
Student::Student(const Student &s) : Person(s), 
                 gpa(s.gpa), 
                 currentCourse(s.currentCourse),
                 studentId(s.studentId)
{
    numStudents++;
}
// destructor definition
Student::~Student()
{
    numStudents--;
}
void Student::EarnPhD()  
{   
   ModifyTitle("Dr.");  
}
void Student::Print() const
{
   cout << "\t" << GetTitle() << " " << GetFirstName();
   cout << " " << GetMiddleInitial() << ". " 
        << GetLastName();
   cout << " id: " << studentId << "\n\twith gpa: ";
   cout << setprecision(3) << " " << gpa 
        << " enrolled in: " << currentCourse << endl;
}
string Student::IsA() const
{  
   return "Student";  
}

在之前列出的代码部分中,我们看到Student的非内联成员函数定义。到这一点,完整的类定义对我们来说在很大程度上是熟悉的。

因此,让我们检查main()函数:

int main()
{
   // Notice that we are creating an array of POINTERS to
   // LifeForms. Since LifeForm cannot be instantiated, 
   // we could not create an array of LifeForm(s).
   LifeForm *entity[MAX] = { }; // init. with nullptrs
   entity[0] = new Person("Joy", "Lin", 'M', "Ms.");
   entity[1] = new Student("Renee", "Alexander", 'Z',
                           "Dr.", 3.95, "C++", "21-MIT"); 
   entity[2] = new Student("Gabby", "Doone", 'A', "Ms.", 
                            3.95, "C++", "18-GWU"); 
   entity[3] = new Cat("Katje");
   entity[4] = new Person("Giselle", "LeBrun", 'R',
                          "Miss");
   // Use range for-loop to process each element of entity
   for (LifeForm *item : entity)  // each item is a 
   {                              // LifeForm *       
      cout << item->Speak();
      cout << " I am a " << item->IsA() << endl;
      item->Print();
      cout << "\tHas a life expectancy of: ";
      cout << item->GetLifeExpectancy();
      cout << "\n";
   }
   for (LifeForm *item : entity) // process each element 
   {                             // in the entity array    
      delete item;
      item = nullptr;   // ensure deleted ptr isn't used
   }
   return 0;
}

在这里,在main()函数中,我们声明了一个指向LifeForm的指针数组。回想一下,LifeForm是一个抽象类。我们不能创建一个LifeForm对象的数组,因为这需要我们能够实例化一个LifeForm;我们不能——LifeForm是一个抽象类。

然而,我们可以创建一个抽象类型的指针集合,这允许我们收集相关类型,例如在这个集合中收集PersonStudentCat实例。当然,我们可能应用于以这种泛型方式存储的实例的操作仅限于在抽象基类LifeForm中找到的操作。

然后,我们分配了各种PersonStudentCat实例,通过类型为LifeForm的泛型指针集合的元素存储每个实例。当任何这些派生类实例以这种方式存储时,将执行隐式向上转换到抽象基类类型(但实例本身不会被任何方式改变——我们只是在指向构成整个内存布局的最基础类子对象)。

现在,我们通过循环应用在抽象类LifeForm中找到的操作,将这些操作应用于这个泛型集合中的所有实例,例如Speak()Print()IsA()。这些操作恰好是多态的,允许通过动态绑定利用每个实例最合适的实现。我们还对每个这些实例调用了GetLifeExpectancy(),这是一个在LifeForm级别找到的非虚函数。这个函数仅仅返回所讨论的LifeForm的生命预期。

最后,我们再次通过使用通用的LifeForm指针来遍历删除PersonStudentCat的动态分配实例。我们知道delete()会调用析构函数,并且由于析构函数是虚函数,因此将开始适当的析构函数起始级别和正确的销毁顺序。此外,通过设置item = nullptr;,我们确保被删除的指针不会错误地用作有效地址(我们正在用nullptr覆盖每个释放的地址)。

在这个例子中,抽象类LifeForm的效用在于,它的使用允许我们将所有LifeForm对象的共同方面和行为集中在一个基类中(例如lifeExpectancyGetLifeExpectancy())。这些共同行为还扩展到一组具有所需接口的纯虚函数,即所有LifeForm对象都应该有的Print()IsA()Speak()

重要提示

抽象类是收集派生类共同特性的类,但它本身并不代表一个有形的实体或对象,不应该被实例化。为了指定一个类为抽象类,它必须至少包含一个纯虚函数。

观察上述程序的输出,我们可以看到各种相关派生类类型的对象被实例化和统一处理。在这里,我们通过它们的抽象基类类型收集了这些对象,并在各种派生类中对基类中的纯虚函数进行了有意义的定义。

下面是完整程序示例的输出:

Hello! I am a Person
        Ms. Joy M. Lin
        Has a life expectancy of: 80
Hello! I am a Student
        Dr. Renee Z. Alexander id: 21-MIT
        with gpa:  3.95 enrolled in: C++
        Has a life expectancy of: 80
Hello! I am a Student
        Ms. Gabby A. Doone id: 18-GWU
        with gpa:  3.95 enrolled in: C++
        Has a life expectancy of: 80
Meow! I am a Cat
        Katje has 9 lives left
        Has a life expectancy of: 15
Hello! I am a Person
        Miss Giselle R. LeBrun
        Has a life expectancy of: 80     

我们现在已经彻底研究了抽象类的 OO 概念及其在 C++中使用纯虚函数的实现,以及这些想法如何扩展到创建 OO 接口。在继续下一章之前,让我们简要回顾一下本章中我们涵盖的语言特性和 OO 概念。

摘要

在本章中,我们继续通过面向对象编程来推进我们的学习,首先,通过理解 C++中的纯虚函数如何直接提供对抽象类 OO 概念的语言支持。我们探讨了没有数据成员且不包含非虚函数的抽象类如何支持 OO 理想中的接口类。我们讨论了其他 OOP 语言如何利用接口类,以及 C++可能如何通过使用这种受限的抽象类来支持这种范式。我们将相关的派生类类型向上转换为存储为抽象基类类型的指针,这是一种典型且非常有用的编程技术。

我们已经看到,抽象类如何通过提供指定派生类共享的公共属性和行为,不仅补充了多态性,而且最值得注意的是,为相关类提供了多态行为的接口,因为抽象类本身是不可实例化的。

通过将抽象类和可能的对象导向概念中的接口类添加到我们的 C++编程资源中,我们能够实现促进代码易于扩展的设计。

我们现在准备继续学习第九章《探索多重继承》,通过学习如何和何时恰当地利用多重继承的概念来增强我们的面向对象编程技能,同时理解权衡和潜在的设计替代方案。让我们继续前进!

问题

  1. 使用以下指南创建形状的层次结构:

    1. 创建一个名为Shape的抽象基类,该类定义了一个计算形状面积的操作。不要包含Area()操作的任何方法。提示:使用纯虚函数。

    2. Shape类使用公有继承派生出RectangleCircleTriangle类。可选地,从Rectangle类派生出Square类。在每个派生类中重新定义Shape类引入的Area()操作。确保为每个派生类提供支持该操作的方法,以便你以后可以实例化每种类型的Shape

    3. 根据需要添加数据成员和其他成员函数以完成新引入的类定义。记住,只有公共属性和操作应该在Shape中指定——所有其他属性都属于它们各自的派生类。不要忘记在每个类定义中实现复制构造函数和访问函数。

    4. 创建一个抽象类Shape类型的指针数组。将数组中的元素分配给RectangleSquareCircleTriangle类型的实例。由于你现在将派生类对象作为通用的Shape对象处理,因此遍历指针数组并对每个实例调用Area()函数。确保删除你分配的任何动态分配的内存。

    5. 你的抽象Shape类在概念面向对象术语中也是一个接口类吗?为什么,或者为什么不?

第九章:探索多重继承

本章将继续扩展我们对 C++面向对象编程的知识。我们将从检查一个有争议的面向对象概念——多重继承MI)开始,理解为什么它是有争议的,以及它如何合理地支持面向对象设计,以及何时替代设计可能更为合适。

在 C++中,可以通过直接的语言支持来实现多重继承。在这样做的时候,我们将面临几个面向对象设计的问题。我们将被要求批判性地评估继承层次结构,问自己我们是否正在使用可能的最优设计来表示一组潜在的对象关系。多重继承可以是一个强大的面向对象工具;明智地使用它是至关重要的。我们将学习何时使用多重继承来合理地扩展我们的层次结构。

在本章中,我们将涵盖以下主要主题:

  • 理解多重继承的机制

  • 检查多重继承的合理用途

  • 创建菱形层次结构并探讨其使用中产生的问题

  • 使用虚拟基类来解决菱形层次结构的重复问题

  • 将判别器应用于评估菱形层次结构和设计中的 MI 的价值,以及考虑设计替代方案

到本章结束时,你将理解面向对象的多个继承概念,以及如何在 C++中实现这一想法。你将不仅理解 MI 的简单机制,还将理解其使用的理由(混入、Is-A 或具有争议的 Has-A)。

你将看到为什么多重继承在面向对象编程中是有争议的。拥有多个基类可能导致形状奇特的层次结构,如菱形;这类层次结构可能带来潜在的实现问题。我们将看到 C++如何通过语言特性(虚拟基类)来解决这些难题,但解决方案并不总是理想的。

一旦我们理解了由多重继承引起的复杂性,我们将使用面向对象设计的度量标准,例如判别器,来评估使用多重继承的设计是否是表示一组对象关系的最佳解决方案。我们将探讨替代设计,这样你将更好地理解多重继承是什么,以及在何时使用它最为合适。让我们通过前进使用多重继承来扩展我们对 C++作为“你可以做任何事情”的面向对象语言的了解。

技术要求

完整程序示例的在线代码可以在以下 GitHub URL 中找到:github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/tree/main/Chapter09。每个完整程序示例都可以在 GitHub 的相应章节标题(子目录)下找到,对应于章节编号,后面跟着一个连字符,然后是当前章节中的示例编号。例如,本章的第一个完整程序可以在上述 GitHub 目录下的Chapter09子目录中找到一个名为Chp9-Ex1.cpp的文件。

本章的 CiA 视频可以在以下链接查看:bit.ly/3Cbqt7y

理解多重继承机制

在 C++中,一个类可以有一个以上的直接基类。这被称为多重继承,在面向对象设计和面向对象编程中都是一个非常有争议的话题。让我们从简单的机制开始;然后我们将在本章的进展过程中讨论 MI 的设计问题和编程逻辑。

在多重继承中,派生类通过其类定义中的基类列表指定了其每个直接祖先或基类是谁。

与单继承类似,当派生类类型的对象被实例化和销毁时,构造函数和析构函数会一直向上遍历层次结构。回顾和扩展 MI 的构造和析构的细微差别,我们会想起以下逻辑:

  • 构造函数的调用序列从派生类开始,但立即将控制权传递给基类构造函数,依此类推向上传递。一旦调用序列将控制权传递到层次结构的顶部,执行序列开始。在同一级别的所有最高级基类构造函数首先执行,依此类推向下遍历层次结构,直到我们到达派生类构造函数,其主体在构造链中最后执行。

  • 派生类的析构函数首先被调用并执行,然后是所有直接基类的析构函数,依此类推,随着我们向上遍历继承层次结构。

派生类构造函数中的成员初始化列表可以用来指定每个直接基类应该调用哪个构造函数。如果没有指定,将使用该基类的默认构造函数。

让我们来看一个典型的多重继承示例,以实现从面向对象设计角度的一个典型的 MI 应用,以及理解 C++中的基本 MI 语法。这个例子将被分成多个部分;完整的程序可以在以下 GitHub 位置找到:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter09/Chp9-Ex1.cpp

#include <iostream>
using std::cout;    // preferred to: using namespace std;
using std::endl;
using std::string;
using std::to_string;
class Person
{
private: 
    string firstName;
    string lastName;
    char middleInitial = '\0';  // in-class initialization
    string title;  // Mr., Ms., Mrs., Miss, Dr., etc.
protected:
    void ModifyTitle(const string &);  
public:
    Person() = default;   // default constructor
    Person(const string &, const string &, char, 
           const string &);
    Person(const Person &) = delete;  // prohibit copies
    virtual ~Person();  // destructor prototype
    const string &GetFirstName() const 
        { return firstName; }  
    const string &GetLastName() const 
        { return lastName; }    
    const string &GetTitle() const { return title; } 
    char GetMiddleInitial() const { return middleInitial; }
};

在之前的代码段中,我们有一个预期的 Person 类定义,其中包含我们习惯于定义的类元素。

接下来,让我们看看这个类的伴随成员函数:

// With in-class initialization, writing the default
// constructor is no longer necessary.
// Also, remember strings are member objects and will 
// be default constructed as empty.
Person::Person(const string &fn, const string &ln, char mi, 
               const string &t): firstName(fn),
               lastName(ln), middleInitial(mi), title(t)
{
}
// Simple destructor so we can trace the destruction chain
Person::~Person()  
{
    cout << "Person destructor <" << firstName << " " << 
            lastName << ">" << endl;
}
void Person::ModifyTitle(const string &newTitle)
{
    title = newTitle;
}

在之前的代码段中,Person 类的成员函数定义正如预期的那样。然而,查看 Person 类的定义是有用的,因为这个类将作为构建块,其部分内容将在接下来的代码段中直接访问。

现在,让我们定义一个新的类,BillableEntity

class BillableEntity
{
private:
    float invoiceAmt = 0.0;   // in-class initialization
public:
    BillableEntity() = default;
    BillableEntity(float amt) invoiceAmt(amt) { } 
    // prohibit copies with prototype below
    BillableEntity(const BillableEntity &) = delete; 
    virtual ~BillableEntity();
    void Pay(float amt) { invoiceAmt -= amt; }
    float GetBalance() const { return invoiceAmt; }
    void Balance() const;
};
// Simple destructor so we can trace destruction chain
BillableEntity::~BillableEntity()
{
    cout << "BillableEntity destructor" << endl;
}
void BillableEntity::Balance() const
{
    if (invoiceAmt)
       cout << "Owed amount: $ " << invoiceAmt << endl;
    else
       cout << "Credit: $ " << 0.0 - invoiceAmt << endl;
}

在之前的 BillableEntity 类中,我们定义了一个包含简单功能以封装计费结构的类。也就是说,我们有一个发票金额和 Pay()GetBalance() 等方法。注意,复制构造函数在其原型中指示 = delete;这将会禁止复制,考虑到这个类的性质,这似乎是合适的。

接下来,让我们将上述两个基类 PersonBillableEntity 结合起来,作为 Student 类的基类:

class Student: public Person, public BillableEntity
{
private: 
    float gpa = 0.0;   // in-class initialization
    string currentCourse;
    const string studentId;
    static int numStudents;
public:
    Student();  // default constructor
    Student(const string &, const string &, char, 
            const string &, float, const string &, 
            const string &, float); 
    Student(const Student &) = delete;  // prohibit copies
    ~Student() override; 
    void Print() const;
    void EarnPhD();  
    float GetGpa() const { return gpa; }
    const string &GetCurrentCourse() const
        { return currentCourse; }
    const string &GetStudentId() const 
        { return studentId; }
    void SetCurrentCourse(const string &);
    static int GetNumberStudents();
};
// definition for static data member
int Student::numStudents = 0;  // notice initial value of 0
inline void Student::SetCurrentCourse(const string &c)
{
   currentCourse = c;
}
inline int Student::GetNumberStudents()
{
    return numStudents;
}

Student 的先前类定义中,在 Student 的基类列表中指定了两个公共基类,PersonBillableEntity。这两个基类在 Student 的基类列表中仅以逗号分隔。我们还在类定义中包含了内联函数定义,因为这些通常与头文件一起打包。

让我们进一步看看通过检查其成员函数,Student 类剩余部分需要做出哪些调整:

// Due to non-specification in the member init list, this 
// constructor calls the default base class constructors
Student::Student() : studentId(to_string(numStudents + 100) 
                                         + "Id")
{
   // Note: since studentId is const, we need to set it at 
   // construction using member init list. Remember, string
   // members are default constructed w an empty string. 
   numStudents++;
}
// The member initialization list specifies which versions
// of each base class constructor should be utilized.
Student::Student(const string &fn, const string &ln, 
        char mi, const string &t, float avg, 
        const string &course, const string &id, float amt): 
        Person(fn, ln, mi, t), BillableEntity(amt),
        gpa(avg), currentCourse(course), studentId(id)
{
   numStudents++;
}
// Simple destructor so we can trace destruction sequence
Student::~Student()
{
   numStudents--;
   cout << "Student destructor <" << GetFirstName() << " "
        << GetLastName() << ">" << endl;
}
void Student::Print() const
{
    cout << GetTitle() << " " << GetFirstName() << " ";
    cout << GetMiddleInitial() << ". " << GetLastName();
    cout << " with id: " << studentId << " has a gpa of: ";
    cout << " " << gpa << " and course: " << currentCourse;
    cout << " with balance: $" << GetBalance() << endl;
}
void Student::EarnPhD() 
{  
    ModifyTitle("Dr."); 
}

让我们考虑之前的代码段。在 Student 的默认构造函数中,由于成员初始化列表中缺少基类构造函数的指定,将调用 PersonBillableEntity 基类的默认构造函数。

然而,请注意,在 Student 的替代构造函数中,我们只是在成员初始化列表中用逗号分隔我们的两个基类构造函数选择——即 Person(const string &, const string &, char, const string &)BillableEntity(float)——然后使用此列表将各种参数从 Student 构造函数传递到基类构造函数。

最后,让我们看看我们的 main() 函数:

int main()
{
    float tuition1 = 1000.00, tuition2 = 2000.00;
    Student s1("Gabby", "Doone", 'A', "Ms.", 3.9, "C++",
               "178GWU", tuition1); 
    Student s2("Zack", "Moon", 'R', "Dr.", 3.9, "C++",
               "272MIT", tuition2); 
    // public mbrs. of Person, BillableEntity, Student are
    // accessible from any scope, including main()
    s1.Print();
    s2.Print();
    cout << s1.GetFirstName() << " paid $500.00" << endl;
    s1.Pay(500.00);
    cout << s2.GetFirstName() << " paid $750.00" << endl;
    s2.Pay(750.00);
    cout << s1.GetFirstName() << ": ";
    s1.Balance();
    cout << s2.GetFirstName() << ": ";
    s2.Balance();
    return 0;
}

在之前的代码中的 main() 函数中,我们创建了几个 Student 实例。注意,Student 实例可以利用 StudentPersonBillableEntity 的公共接口中的任何方法。

让我们看看上述程序的输出:

Ms. Gabby A. Doone with id: 178GWU has a gpa of:  3.9 and course: C++ with balance: $1000
Dr. Zack R. Moon with id: 272MIT has a gpa of:  3.9 and course: C++ with balance: $2000
Gabby paid $500.00
Zack paid $750.00
Gabby: Owed amount: $ 500
Zack: Owed amount: $ 1250
Student destructor <Zack Moon>
BillableEntity destructor
Person destructor <Zack Moon>
Student destructor <Gabby Doone>
BillableEntity destructor
Person destructor <Gabby Doone>

注意上述输出中的销毁顺序。我们可以看到每个 Student 实例调用了 Student 析构函数,以及每个基类(BillableEntityPerson)的析构函数。

我们已经看到了使用典型实现的面向对象(OO)设计进行 MI 的语言机制。现在,让我们通过查看在 OO 设计中采用多重继承的典型原因来继续前进,其中一些原因比其他原因更广泛地被接受。

检查多重继承的合理用途

多重继承是一个在创建 OO 设计时出现的有争议的概念。许多 OO 设计避免使用 MI;其他设计则严格地接受它。一些面向对象编程语言,如 Java,不提供直接的语言支持来明确支持多重继承。相反,它们提供接口,例如我们在 C++中通过创建使用抽象类(仅包含纯虚拟函数)的接口类来模拟(见第八章掌握抽象类)。

当然,在 C++中,从两个接口类继承仍然是多重继承的一种用法。尽管 C++语言本身不包含接口类,但可以通过更限制性的 MI 使用来模拟这个概念。例如,我们可以通过编程简化抽象类,只包含纯虚拟函数(没有数据成员,也没有带定义的成员函数),以模仿接口类的面向对象设计理念。

典型的 MI 难题是为什么 MI 在面向对象编程(OOP)中存在争议的基础。本章将详细阐述经典的 MI 难题,并通过仅限制 MI 用于接口类或通过重新设计来避免这些问题。这就是为什么一些面向对象编程语言只支持接口类,而不是允许无限制的 MI。在 C++中,你可以仔细考虑每个 OO 设计,并选择何时利用 MI,何时利用限制性 MI(接口类),或何时采用消除 MI 的重新设计。

C++是一种“你可以做任何事情”的编程语言。因此,C++允许无限制地使用多重继承。作为一个面向对象程序员,我们将更仔细地研究接受 MI 的典型原因。随着我们进一步进入本章,我们将评估使用 MI 时出现的问题,并了解 C++如何通过额外的语言特性来解决这些问题。这些 MI 问题将使我们能够应用度量标准,以更合理地了解何时应该使用 MI,何时重新设计可能更合适。

让我们通过考虑 Is-A 和 mix-in 关系来开始我们对 MI 合理使用的追求,然后转向检查 MI 在实现 Has-A 关系方面的有争议的使用。

支持 Is-A 和 mix-in 关系

正如我们通过单继承所学习的,Is-A 关系最常用来描述两个继承类之间的关系。例如,一个StudentPerson的子类。在 MI 中,这种理想继续存在;Is-A 关系是指定继承的主要动机。在纯面向对象设计和编程中,继承应该仅用于支持 Is-A 关系。

尽管如此,正如我们在查看接口类(在 C++中使用具有仅包含纯虚函数的限制的抽象类来建模的概念)时所学到的那样,当我们从接口继承时,混入关系通常适用。回想一下,混入关系是我们使用继承来混入另一个类的功能,仅仅因为这种功能对派生类来说是有用或有意义的。基类不必是抽象类或接口类,但采用理想的面向对象设计,它应该是这样的。

混入基类代表了一个其中不适用“是”关系的类。混入在多重继承中更为常见,至少作为支持(许多)基类之一的必要性的原因。由于 C++直接支持多重继承,MI 可以用来支持实现混入(而像 Java 这样的语言可能只能使用接口类)。在实践中,MI 通常用于从一个类继承以支持“是”关系,并从另一个类继承以支持混入关系。在我们最后的例子中,我们看到一个StudentPerson,并且Student选择混入BillableEntity的能力。

C++中 MI 的合理用途包括支持“是”和混入关系;然而,如果没有接下来考虑 MI 的非常规用途——实现“拥有”关系,我们的讨论就不会完整。

支持“拥有”关系

不太常见且更具争议性的是,MI 可以用来实现“拥有”关系,即建模包含或整体与部分的关系。我们将在第十章,“实现关联、聚合和组合”中看到,对于“拥有”关系有一个更广泛接受的实现;然而,如果没有接下来考虑 MI 的非常规用途——实现“拥有”关系,我们的讨论就不会完整。

例如,一个StudentPerson的实例,并且一个Student拥有一个Id;第二个基类(Id)的使用是为了包含。Id将作为基类,Student将从Id派生出来,以利用Id提供的一切。Id的公共接口可以直接用于Student。实际上,任何从Id继承的类在利用其Id部分时都会继承一个统一接口。这种简单性是继承有时被用来建模包含的一个驱动因素。

然而,使用继承来实现 Has-A 关系可能会导致不必要的 MI(多继承)使用,这可能会使继承层次结构复杂化。使用继承来模拟 Has-A 关系之所以非常具有争议,并且坦率地说在纯面向对象设计中不受欢迎,主要是因为这种不必要的 MI 使用。尽管如此,我们还是要提到这一点,因为你会看到一些 C++应用程序使用 MI 来实现 Has-A。

让我们继续探索使用 MI 的其他具有争议的设计,即菱形层次结构。

创建菱形层次结构

当使用多继承时,有时会诱使人们使用兄弟(或堂兄弟)类作为新派生类的基类。当这种情况发生时,层次结构就不再是树形,而是一个包含菱形的图。

在这种情况下,每当派生类类型的对象被实例化时,派生类实例中都将存在两个公共基类的副本。这种类型的复制显然会浪费空间。此外,调用重复子对象的重复构造函数和析构函数,以及维护子对象的两个并行副本(很可能是不必要的),也会浪费额外的时间。尝试从该公共基类访问成员时,也会产生歧义。

让我们通过一个示例详细说明这个问题,从LifeFormHorsePerson的简化和(故意)不完整的类定义开始。尽管只显示了完整程序示例的部分,但整个程序可以在我们的 GitHub 上找到,如下所示:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter09/Chp9-Ex2.cpp

class Lifeform
{   // abbreviated class definition – see full code online
private:
    int lifeExpectancy = 0;  // in-class initialization
public:
    LifeForm(int life): lifeExpectancy(life) { }
    [[nodiscard]] int GetLifeExpectancy() const 
        { return lifeExpectancy; }
    // additional constructors, destructor, etc.
    virtual void Print() const = 0; // pure virtual funcs.
    virtual string IsA() const = 0;
    virtual string Speak() const = 0;
};
class Horse: public LifeForm
{   // abbreviated class definition
private:
    string name;
    static constexpr int HORSE_LIFE = 35; // life exp Horse
public:
    Horse(): LifeForm(HORSE_LIFE) { }
    // additional constructors, destructor, etc …
    void Print() const override { cout << name << endl; }
    string IsA() const override { return "Horse"; }
    string Speak() const override { return "Neigh!"; }
};
class Person: public LifeForm
{   // abbreviated class definition
private: 
    string firstName;
    string lastName;
    static constexpr int PERSON_LIFE = 80; // life expect.
                                           // of Person
    // additional data members (imagine them here)
public:
    Person(): LifeForm(PERSON_LIFE) { }
    // additional constructors, destructor, etc.
    const string &GetFirstName() const 
        { return firstName; }
    // additional access methods, etc. 
    void Print() const override
        { cout << firstName << " " << lastName << endl; }
    string IsA() const override { return "Person"; }
    string Speak() const override { return "Hello!"; }
};

之前的代码片段显示了LifeFormPersonHorse的骨架类定义。每个类都显示了一个默认构造函数,它仅作为示例,展示如何为每个类设置lifeExpectancy。在PersonHorse的默认构造函数中,使用成员初始化列表将35HORSE_LIFE)或80PERSON_LIFE)的值传递给LifeForm构造函数,以设置此值。

虽然之前的类定义被简略了(也就是说,故意不完整)以节省空间,但让我们假设每个类都有适当的额外构造函数定义、适当的析构函数和其他必要的成员函数。

我们注意到LifeForm是一个抽象类,因为它提供了纯虚函数:Print()IsA()Speak()HorsePerson都是具体类,并且因为它们用虚函数覆盖了这些纯虚函数,所以将是可实例化的。这些虚函数仅内联显示,目的是为了使代码在查看时更紧凑(编译器几乎永远不会内联虚函数,因为它们的函数方法几乎总是在运行时确定)。

接下来,让我们看看一个将引入图中或菱形形状的新派生类:

class Centaur: public Person, public Horse
{   // abbreviated class definition
public:
    // constructors, destructor, etc …
    void Print() const override
       { cout << GetFirstName() << endl; }
    string IsA() const override { return "Centaur"; }
    string Speak() const override
       { return "Neigh! and Hello!"; }
};

在前面的片段中,我们使用多重继承定义了一个新的类 Centaur。乍一看,我们确实意味着要断言 CentaurPerson 之间,以及 CentaurHorse 之间的 Is-A 关系。然而,我们很快就会挑战我们的断言,以测试它是否更多的是一种组合而不是真正的 Is-A 关系。

我们将假设所有必要的构造函数、析构函数和成员函数都存在,以便使 Centaur 成为一个定义良好的类。

现在,让我们继续前进,看看我们可能使用的潜在 main() 函数:

int main()
{
    Centaur beast("Wild", "Man");
    cout << beast.Speak() << " I'm a " << beast.IsA();
    cout << endl;
    // Ambiguous method call – which LifeForm sub-object?
    // cout << beast.GetLifeExpectancy();  
    cout << "It is unclear how many years I will live: ";
    cout << beast.Person::GetLifeExpectancy() << " or ";
    cout << beast.Horse::GetLifeExpectancy() << endl; 
    return 0;
}

在这里,在 main() 中,我们实例化了一个 Centaur 并将其命名为 beast。我们很容易在 beast 上调用两个多态操作,即 Speak()IsA()。然后我们尝试调用从 LifeForm 继承的公共 GetLifeExpectancy(),它在 LifeForm 中定义。其实现包含在 Lifeform 中,这样 PersonHorseCentaur 就不需要提供定义(也不应该提供定义——它不是一个虚拟函数,意味着要被重定义)。

不幸的是,通过 Centaur 实例调用 GetLifeExpectancy() 是有歧义的。这是因为 beast 实例中有两个 LifeForm 子对象。记住,Centaur 是从 Horse 继承的,而 Horse 是从 LifeForm 继承的,为所有上述基类数据成员(HorseLifeForm)提供了内存布局。而 Centaur 也从 Person 继承,而 Person 是从 Lifeform 继承的,这为 Centaur 中的 PersonLifeForm 提供了内存布局。LifeForm 部分是重复的。

有两个 int lifeExpectancy; 继承数据成员的副本。在 Centaur 实例中有两个 LifeForm 子对象。因此,当我们尝试通过 Centaur 实例调用 GetLifeExpectancy() 时,方法调用是有歧义的。我们试图初始化哪个 lifeExpectancy?当调用 GetLifeExpectancy() 时,哪个 LifeForm 子对象将作为 this 指针?这很简单,所以编译器不会为我们选择。

为了消除对 GetLifeExpectancy() 函数调用的歧义,我们必须使用作用域解析运算符。我们在 :: 运算符之前加上我们想要从中获取 LifeForm 子对象的中间基类。请注意,例如,我们调用 beast.Horse::GetLifeExpectancy() 来选择来自 Horse 子对象路径的 lifeExpectancy,这将包括 LifeForm。这是尴尬的,因为 HorsePerson 都不包括这个有歧义的成员;lifeExpectancyLifeForm 中。

让我们考虑上述程序的结果:

Neigh! and Hello! I'm a Centaur.
It is unclear how many years I will live: 80 or 35.

我们可以看到,设计包含菱形形状的层次结构有缺点。这些难题包括需要以尴尬方式解决的编程歧义,重复子对象的内存重复,以及构建和销毁这些重复子对象的时间。

幸运的是,C++有一个语言特性可以减轻这些困难,特别是对于具有菱形层次结构的情况。毕竟,C++是一种允许我们做任何事的强大语言。知道何时以及是否应该利用这些特性是另一个问题。让我们首先看看 C++语言如何通过查看虚拟基类来处理菱形层次结构和它们固有的问题。

利用虚拟基类消除重复

我们刚刚看到了在 OO 设计中包含菱形时迅速出现的 MI 实现问题——重复子对象在内存中的重复、访问该子对象的歧义(甚至通过继承的成员函数),以及构造和析构的重复。出于这些原因,纯 OO 设计不会在层次结构中包含图(即,没有菱形)。然而,我们知道 C++是一种强大的语言,任何事都是可能的。因此,C++将为我们提供解决这些问题的解决方案。

virtual被放置在基类列表中,位于可能后来用作相同派生类基类的兄弟或堂兄弟类的访问标签和基类名称之间。请注意,知道两个兄弟类可能后来被组合为新的派生类的共同基类可能很困难。重要的是要注意,没有指定虚拟基类的兄弟类将要求其自己的(否则)共享基类副本。

应该谨慎地在实现中使用虚拟基类,因为它们对具有此类作为祖先类的实例施加限制和开销。需要注意的限制包括以下内容:

  • 具有虚拟基类的实例可能比其非虚拟对应实例使用更多的内存(该实例包含一个指向可能共享的基类组件的指针)。

  • 当虚拟基类在祖先层次结构中时,从基类类型的对象到派生类类型的转换是被禁止的。

  • 最派生类的成员初始化列表必须用来指定应该使用共享对象类型的哪个构造函数进行初始化。如果忽略此指定,将使用默认构造函数来初始化此子对象。

让我们看看一个使用虚拟基类的完整程序示例。像往常一样,完整的程序可以在我们的 GitHub 上找到,如下所示:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter09/Chp9-Ex3.cpp

#include <iostream>
using std::cout;    // preferred to: using namespace std;
using std::endl;
using std::string;
using std::to_string;
class LifeForm
{
private:
    int lifeExpectancy = 0;  // in-class initialization
public:
    LifeForm() = default; 
    LifeForm(int life): lifeExpectancy(life) { }
    // We're accepting default copy constructor, but if we
    // wanted to write it, it would look like:
    // LifeForm(const LifeForm &form): 
    //         lifeExpectancy(form.lifeExpectancy) { }
    // prototype necessary to specify virtual dest. below
    virtual ~LifeForm() = default;
    [[nodiscard]] int GetLifeExpectancy() const 
        { return lifeExpectancy; }
    virtual void Print() const = 0; 
    virtual string IsA() const = 0;   
    virtual string Speak() const = 0;
};

在之前的代码段中,我们看到LifeForm类的完整类定义。请注意,具有主体的成员函数在类定义中被内联。当然,编译器实际上不会对构造函数或虚析构函数进行内联替换;了解这一点,将方法写成内联形式方便进行类定义的审查。

接下来,让我们看看Horse类的定义:

class Horse: public virtual LifeForm
{
private:
    string name;
    static constexpr int HORSE_LIFE = 35; // Horse life exp
public:
    Horse() : LifeForm(HORSE_LIFE) { }
    Horse(const string &n);
    // Remember, it isn't necessary to proto def. copy ctor
    // Horse(const Horse &) = default; 
    // Because base class destructor is virtual, ~Horse()
    // is automatically virtual (overridden) even w/o proto
    // ~Horse() override = default;
    const string &GetName() const { return name; }
    void Print() const override 
        { cout << name << endl; }
    string IsA() const override { return "Horse"; }
    string Speak() const override { return "Neigh!"; }
};
Horse::Horse(const string &n) : LifeForm(HORSE_LIFE),
                                name(n)
{
}
// We are using the default copy constructor, but if we
// wanted to write it, this is what it would look like:
// Horse::Horse(const Horse &h): LifeForm (h), name(h.name)
// {
// }

在之前的代码段中,我们有Horse类的完整类定义。请注意,尽管某些方法为了紧凑性而写成内联形式,但编译器永远不会实际内联构造函数或析构函数。同样,虚函数也不能内联,因为它的整个目的是在运行时确定适当的方法(除了涉及去虚化的罕见场景)。

在这里,LifeFormHorse的虚基类。这意味着如果Horse有一个兄弟(或堂兄弟)也使用虚基类从LifeForm继承,并且这些兄弟作为派生类的基类,那么这些兄弟将共享他们的LifeForm副本。虚基类将减少存储空间,减少额外的构造函数和析构函数调用,并消除歧义。

注意Horse构造函数在其成员初始化列表中指定了LifeForm(HORSE_LIFE)构造函数指定。如果LifeForm实际上是一个共享的虚基类,则此基类初始化将被忽略,尽管这些构造函数指定对于Horse的实例或对于在菱形继承结构不适用的情况下Horse的派生类的实例是有效的。在Horse与兄弟类结合以真正作为虚基类的情况下,LifeForm(HORSE_LIFE)指定将被忽略,取而代之的是,将调用默认的LifeForm构造函数,或者在层次结构的较低(且不寻常)级别选择另一个构造函数。

接下来,让我们通过查看更多的类定义来更深入地了解这个程序,从Person类开始:

class Person: public virtual LifeForm
{
private: 
    string firstName;
    string lastName;
    char middleInitial = '\0';  // in-class initialization
    string title;  // Mr., Ms., Mrs., Miss, Dr., etc.
    static constexpr int PERSON_LIFE = 80; // Life expect.
protected:
    void ModifyTitle(const string &);  
public:
    Person();   // default constructor
    Person(const string &, const string &, char, 
           const string &); 
    // Default copy constructor prototype is not necessary 
    // Person(const Person &) = default;  // copy ctor.
    // Because base class destructor is virtual, ~Person()
    // is automatically virtual (overridden) even w/o proto
    // ~Person() override = default;  // destructor
    const string &GetFirstName() const 
        { return firstName; }  
    const string &GetLastName() const 
        { return lastName; }    
    const string &GetTitle() const { return title; } 
    char GetMiddleInitial() const { return middleInitial; }
    void Print() const override;
    string IsA() const override;   
    string Speak() const override;
};

在之前的代码段中,我们看到Person有一个公共虚基类LifeForm。如果PersonPerson的兄弟类通过多重继承结合成一个新的派生类的基类,那些表明了LifeForm虚基类的兄弟类将同意共享一个LifeForm的子对象。

继续前进,让我们回顾Person的成员函数:

Person::Person(): LifeForm(PERSON_LIFE)
{  // Note that the base class init list specification of
   // LifeForm(PERSON_LIFE) is ignored if LifeForm is a 
   // shared, virtual base class.
}  // This is the same in all Person constructors.
Person::Person(const string &fn, const string &ln, char mi,
               const string &t): LifeForm(PERSON_LIFE),
               firstName(fn), lastName(ln),
               middleInitial(mi), title(t)
{
}
// We're using the default copy constructor, but if we 
// wrote/prototyped it, here's what the method would be:
// Person::Person(const Person &p): LifeForm(p),
//           firstName(p.firstName), lastName(p.lastName),
//           middleInitial(p.middleInitial), title(p.title)
// {
// }
void Person::ModifyTitle(const string &newTitle)
{
    title = newTitle;
}
void Person::Print() const
{
    cout << title << " " << firstName << " ";
    cout << middleInitial << ". " << lastName << endl;
}
string Person::IsA() const
{  
    return "Person"; 
}
string Person::Speak() const
{   
    return "Hello!"; 
}

在之前提到的Person方法中,我们看到很少的细节会让我们感到惊讶;这些方法基本上符合预期。然而,作为提醒,请注意,如果Person在一个菱形继承结构中被组合,其中LifeForm子对象变为共享而不是复制,那么Person构造函数成员初始化列表中的LifeForm(PERSON_LIFE)指定将被忽略。

接下来,让我们看看多重继承如何发挥作用,通过Centaur类的定义来了解:

class Centaur: public Person, public Horse
{
private:
    // no additional data members required, but the below
    // static constexpr eliminates a magic number of 1000
    static constexpr int CENTAUR_LIFE = 1000; //life expect
public:
    Centaur(): LifeForm(CENTAUR_LIFE) { }
    Centaur(const string &, const string &, char = ' ', 
            const string & = "Mythological Creature"); 
    // We don't want default copy constructor due to the
    // needed virtual base class in the mbr init list below
    Centaur(const Centaur &c): 
           Person(c), Horse(c), LifeForm(CENTAUR_LIFE) { }
    // Because base class' destructors are virt, ~Centaur()
    // is automatically virtual (overridden) w/o prototype
    // ~Centaur() override = default;
    void Print() const override;
    string IsA() const override;
    string Speak() const override;
};
// Constructors for Centaur need to specify how the shared
// base class LifeForm will be initialized
Centaur::Centaur(const string &fn, const string &ln, 
                 char mi, const string &title):
                 Person(fn, ln, mi, title), Horse(fn),
                 LifeForm(CENTAUR_LIFE)
{
   // All initialization has been taken care of in 
}  // member initialization list
void Centaur::Print() const
{
    cout << "My name is " << GetFirstName();
    cout << ".  I am a " << GetTitle() << endl;
}
string Centaur::IsA() const 
{ 
    return "Centaur"; 
}
string Centaur::Speak() const
{
    return "Neigh! Hello! I'm a master of two languages.";
} 

在上述Centaur类定义中,我们可以看到Centaur有公共基类HorsePerson。我们暗示CentaurHorsePerson的实例。

然而,请注意,在Centaur类定义的基类列表中没有使用virtual关键字。但是,Centaur是在层次结构中引入菱形形状的级别。这意味着我们必须在设计阶段提前规划,知道在HorsePerson类定义的基类列表中使用virtual关键字。这是一个为什么适当的设计会议至关重要,而不仅仅是跳入实现的例子。

此外,非常不寻常的是,注意在Centaur的替代构造函数中,基类列表Person(fn, ln, mi, title), Horse(fn), LifeForm(CENTAUR_LIFE)。在这里,我们不仅指定了PersonHorse的直接基类的首选构造函数,而且还指定了它们共同的基类LifeForm的首选构造函数。这是非常不寻常的。如果没有将LifeForm作为HorsePerson的虚基类,Centaur将无法指定如何构造共享的LifeForm部分(即通过为除其直接基类之外的其他选择构造函数)。你也会注意到,为了同样的目的,在默认构造函数以及复制构造函数的成员初始化列表中,对基类构造函数的指定。虚基类的使用使得PersonHorse类在其他应用中更难以重用,原因在本小节开头已概述。

让我们看看我们的main()函数包含什么:

int main()
{
   Centaur beast("Wild", "Man");
   cout << beast.Speak() << endl;
   cout << " I'm a " << beast.IsA() << ". ";
   beast.Print();
   cout << "I will live: ";
   cout << beast.GetLifeExpectancy();// no longer ambiguous
   cout << " years" << endl; 
   return 0;
}

与我们非虚基类示例中的main()函数类似,我们可以看到Centaur同样被实例化,并且虚拟函数如Speak()IsA()Print()可以轻松调用。然而,现在当我们通过beast实例调用GetLifeExpectancy()时,调用不再模糊。只有一个LifeForm的子对象,其lifeExpectancy(一个整数)已被初始化为1000CENTAUR_LIFE)。

下面是完整程序示例的输出:

Neigh! Hello! I'm a master of two languages.
I am a Centaur. My name is Wild. I am a Mythological Creature.
I will live: 1000 years.

虚基类解决了困难的 MI 难题。但我们也已经看到,实现这一目标所需的代码在未来的扩展和重用方面不太灵活。因此,只有在设计真正支持菱形层次结构时,才应谨慎和少量地使用虚基类。考虑到这一点,让我们考虑一个面向对象的判别器概念,并考虑何时其他设计可能更合适。

考虑判别器和替代设计

判别器是一个面向对象的概念,有助于概述为什么一个给定的类是从其基类派生出来的。判别器倾向于描述给定基类存在的特殊化分组类型。

例如,在上面的菱形层次结构的程序示例中,我们有以下判别器(括号中显示),概述了我们从给定的基类中特化新类的原因:

图 9.1 – 使用判别器展示的多重继承菱形设计

图 9.1 – 使用判别器展示的多重继承菱形设计

无论何时,诱惑导致创建菱形层次结构时,检查判别器可以帮助我们决定设计是否合理,或者是否可能有一个更好的替代设计。以下是一些值得考虑的良好设计指标:

  • 如果正在合并的兄弟类之间的判别器相同,那么最好重新设计菱形层次结构。

  • 当兄弟类没有唯一的判别器时,它们将引入的属性和行为将包括由具有类似判别器而产生的重复。考虑将判别器作为一个类来存放这些共同点。

  • 如果兄弟类的判别器是唯一的,那么菱形层次结构可能是合理的。在这种情况下,虚拟基类将非常有用,并且应该添加到层次结构的适当位置。

在前面的例子中,详细说明Horse如何特化LifeForm的判别器是Equine。也就是说,我们通过马的特征和行为(蹄子、奔跑、嘶鸣等)来特化LifeForm。如果我们从LifeForm派生出像DonkeyZebra这样的类,这些类的判别器也将是Equine。考虑到上述相同的例子,Person类在特化LifeForm时将有一个Humanoid判别器。如果我们从LifeForm派生出像MartianRomulan这样的类,这些类也将有Humanoid作为判别器。

HorsePerson作为Centaur的基类结合起来,是合并具有不同判别器(EquineHumanoid)的两个基类。因此,每个基类都考虑了完全不同类型的特点和行为。尽管可能存在其他设计,但这种设计是可以接受的(除了面向对象设计的纯粹主义者),并且可以使用 C++中的虚拟基类来消除其他情况下复制的LifeForm部分。将具有共同基类且每个类都使用不同的判别器特化基类的两个类结合起来,是 C++中 MI 和虚拟基类合理性的一个例子。

然而,将HorseDonkey(两者都从LifeForm派生)这样的两个类在派生类Mule中结合起来,也会创建一个菱形层次结构。检查HorseDonkey的判别器可以发现,两者都有Equine的判别器。在这种情况下,使用菱形设计将这些两个类结合起来并不是最佳的设计选择。另一个设计选择是可能的,也是首选的。在这种情况下,一个首选的解决方案是将判别器Equine作为其自己的类,然后从Equine派生HorseDonkeyMule。这将避免 MI 和菱形层次结构。让我们看看两种设计选项:

图 9.2 – 无 MI 重新设计的菱形多重继承

图 9.2 – 无 MI 重新设计的菱形多重继承

提醒

在菱形层次结构中,如果结合类的判别器相同,则可以进行更好的设计(通过将判别器作为其自己的类)。然而,如果判别器不同,考虑保留菱形 MI 层次结构,然后使用虚拟基类来避免重复公共基类子对象。

我们现在已经彻底研究了判别器的 OO 概念,并看到了判别器如何被用来帮助评估设计的合理性。在许多情况下,使用菱形层次结构的设计可以被重新设计,不仅消除菱形形状,而且完全消除多重继承。在我们继续下一章之前,让我们简要回顾一下本章中涵盖的 MI 问题和 OO 概念。

摘要

在本章中,我们继续我们的探索,以理解面向对象编程,我们探索了一个有争议的 OOP 主题,即多重继承。首先,在本章中,我们理解了多重继承的简单机制。同样重要的是,我们回顾了构建继承层次结构的原因以及使用 MI(即指定 Is-A、mix-in 和 Has-A 关系)的可能原因。我们被提醒,使用继承来指定 Is-A 关系支持纯 OO 设计。我们还看到了使用 MI 实现 mix-in 关系的实用性。我们还审视了 MI 的争议性使用,以快速实现 Has-A 关系;我们将在第十章中看到,实现关联、聚合和组合,一个首选的 Has-A 实现方法。

我们已经看到,在我们的面向对象设计工具包中拥有多重继承如何导致菱形层次结构。我们也看到了菱形层次结构不可避免的问题,例如内存中的重复、构造/析构中的重复以及访问复制的子对象时的歧义。我们还看到,C++提供了一种语言支持的机制来纠正这些问题,即使用虚基类。我们知道虚基类解决了繁琐的问题,但它们本身并不是完美的解决方案。

为了批评菱形层次结构,我们研究了面向对象中的一个判别器概念,以帮助我们权衡在菱形中使用多重继承的面向对象设计的有效性。这也使我们理解到,替代设计可以适用于一组对象;有时,重新设计是一种更优雅的方法,其中解决方案将产生更简单、长期的使用。

C++是一种“你可以做任何事情”的面向对象编程语言,多重继承是一个有争议的面向对象概念。了解何时某些多重继承设计可能是合理的,以及理解语言特性以帮助解决这些多重继承问题,将使你成为一个更好的程序员。了解何时需要重新设计也是至关重要的。

我们现在准备继续到第十章实现关联、聚合和组合,通过学习如何使用编程技术表示关联、聚合和组合来进一步提高我们的面向对象技能。这些即将到来的概念将不会有直接的语言支持,但这些概念在我们的面向对象技能库中是至关重要的。让我们继续前进!

问题

  1. 在本章中输入(或使用在线代码)使用虚基类的菱形层次结构示例。按原样运行它。提示:你可能想在显式析构函数中添加cout语句以跟踪销毁顺序:

    1. 对于Centaur实例,存在多少个LifeForm子对象?

    2. LifeForm构造函数(和析构函数)被调用了多少次?提示:你可能想在每个构造函数和析构函数中使用cout放置跟踪语句。

    3. 如果省略了Centaur构造函数中LifeForm的构造函数选择,将调用哪个LifeForm构造函数?

  2. 现在,从PersonHorse(即LifeForm将不再是PersonHorse的虚基类。LifeForm将只是PersonHorse的典型基类)的基类列表中移除关键字virtual。同时,从Centaur构造函数的成员初始化列表中移除LifeForm构造函数的选择。现在,实例化Centaur

    1. 对于Centaur实例,存在多少个LifeForm子对象?

    2. 现在,LifeForm构造函数(和析构函数)被调用了多少次?提示:你可能想在构造函数和析构函数中添加跟踪语句。

第十章:实现关联、聚合和组合

本章将继续深化我们对 C++ 面向对象编程知识的理解。我们将通过探索关联、聚合和组合等面向对象概念来增强我们对对象关系的理解。这些面向对象的概念在 C++ 中没有直接的语言支持;我们将学习多种编程技术来实现这些想法。我们还将了解针对各种概念首选的实现技术,以及各种实践的优势和陷阱。

关联、聚合和组合在面向对象(OO)设计中频繁出现。理解如何实现这些重要的对象关系至关重要。

在本章中,我们将涵盖以下主要内容:

  • 理解聚合和组合的面向对象概念及其各种实现

  • 理解关联的面向对象概念及其实现,包括后链维护的重要性以及引用计数的实用性

到本章结束时,你将理解关联、聚合和组合的面向对象概念,以及如何在 C++ 中实现这些关系。你还将了解许多必要的维护方法,以保持这些关系最新,例如引用计数和后链维护。尽管这些概念相对简单,但你将了解为什么需要大量的记录来保持这些类型对象关系的准确性。

通过探索这些核心对象关系,让我们扩展对 C++ 作为面向对象(OOP)语言的了解。

技术要求

完整程序示例的在线代码可以在以下 GitHub 网址找到:github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/tree/main/Chapter10。每个完整程序示例都可以在 GitHub 上找到,位于相应章节标题(子目录)下的文件中,该文件以章节编号开头,后面跟着一个连字符,然后是本章中的示例编号。例如,本章的第一个完整程序可以在上述 GitHub 目录下的 Chapter10 子目录中找到,文件名为 Chp10-Ex1.cpp

本章的 CiA 视频可在以下网址查看:bit.ly/3clgvGe

理解聚合和组合

聚合的面向对象概念在许多面向对象设计中出现。它出现的频率与继承一样,用于指定对象关系。"聚合"用于指定“拥有”(Has-A)、整体-部分关系,在某些情况下,还用于包含关系。一个类可以包含其他对象的聚合。聚合可以分为两类——组合以及一种不那么严格的泛化聚合形式。

通用聚合和组合都暗示了“拥有”或整体-部分关系。然而,这两个相关对象的存在要求之间存在差异。在通用聚合中,对象可以独立存在,而在组合中,对象不能没有对方而存在。

让我们来看看聚合的每一种类型,从组合开始。

定义和实现组合

组合是聚合最特殊的形式,并且通常是大多数面向对象的设计师和程序员在考虑聚合时首先想到的。组合意味着包含,通常与整体-部分关系同义——也就是说,整体实体由一个或多个部分组成。整体包含部分。拥有关系也将适用于组合。

外部对象,或整体,可以由部分组成。在组合中,部分的存在依赖于整体。实现通常是嵌入的对象——也就是说,是包含对象类型的成员数据。在罕见的情况下,外部对象将包含指向包含对象类型的指针或引用;然而,当这种情况发生时,外部对象将负责创建和销毁内部对象。没有外部层,包含对象就没有任何目的。同样,没有其内部包含的部分,外部层也不是理想上完整的。

让我们看看一个通常实现的组合。示例将展示包含——一个Student拥有一个Id。更进一步,我们将暗示IdStudent的一个必要部分,没有Student它将不存在。单独的Id对象没有任何作用。如果它们不是赋予它们目的的主要对象的一部分,Id对象就无需存在。同样,你可能会说,没有IdStudent就不完整,尽管这有点主观!我们将使用嵌入在整体中的对象来实现部分。

组合示例将被分成许多部分。尽管只展示了示例的一部分,但完整的程序可以在以下 GitHub 位置找到:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter10/Chp10-Ex1.cpp

#include <iostream>
#include <iomanip>
using std::cout;
using std::endl;
using std::setprecision;
using std::string;
using std::to_string;
class Id final  // the contained 'part'
{        // this class is not intended to be extended 
private:
    string idNumber;
public:
    Id() = default;
    Id(const string &id): idNumber(id) { }
    // We get default copy constructor, destructor
    // without including without including prototype
    // Id(const Id &id) = default;
    // ~Id() = default;
    const string &GetId() const { return idNumber; }
};

在之前的代码片段中,我们定义了一个Id类。Id将是一个可以被其他需要完全功能的Id能力的类所包含的类。Id将成为任何可能选择包含它的任何整体对象的一部分。

让我们继续前进,构建一组最终将包含这个Id的类。我们将从一个我们熟悉的类Person开始:

class Person
{
private:
    string firstName;
    string lastName;
    char middleInitial = '\0';   // in-class initialization
    string title;  // Mr., Ms., Mrs., Miss, Dr., etc.
protected:
    void ModifyTitle(const string &);
public:
    Person() = default;   // default constructor
    Person(const string &, const string &, char, 
           const string &);
    // We get default copy constructor w/o prototype 
    // Person(const Person &) = default;  // copy ctor.
    // But, we need prototype destructor to add 'virtual' 
    virtual ~Person() = default;  // virtual destructor
    const string &GetFirstName() const 
        { return firstName; }
    const string &GetLastName() const { return lastName; }
    const string &GetTitle() const { return title; }
    char GetMiddleInitial() const { return middleInitial; }
    // virtual functions
    virtual void Print() const;   
    virtual void IsA() const;
    virtual void Greeting(const string &) const;
};
//  Assume the member functions for Person exist here
//  (they are the same as in previous chapters)

在之前的代码段中,我们已经定义了Person类,正如我们习惯描述的那样。为了简化这个例子,让我们假设伴随的成员函数如上述类定义中原型所示存在。您可以在之前提供的 GitHub 链接中参考这些成员函数的在线代码。

现在,让我们定义我们的Student类。尽管它将包含我们习惯看到的元素,但Student还将包含一个作为嵌入对象的Id

class Student: public Person  // 'whole' object
{
private:
    float gpa = 0.0;    // in-class initialization
    string currentCourse;
    static int numStudents;  
    Id studentId;  // is composed of a 'part'
public:    
    Student();  // default constructor
    Student(const string &, const string &, char, 
            const string &, float, const string &, 
            const string &);
    Student(const Student &);  // copy constructor
    ~Student() override;  // destructor
    // various member functions (many are inline)
    void EarnPhD() { ModifyTitle("Dr."); } 
    float GetGpa() const { return gpa; }         
    const string &GetCurrentCourse() const
        { return currentCourse; }
    void SetCurrentCourse(const string &); // proto. only
    void Print() const override;
    void IsA() const override 
        { cout << "Student" << endl; }
    static int GetNumberStudents() { return numStudents; }
    // Access function for embedded Id object
    const string &GetStudentId() const;   // prototype only
};
int Student::numStudents = 0;  // static data member
inline void Student::SetCurrentCourse(const string &c)
{
    currentCourse = c;
}

在先前的Student类中,我们通常会注意到Student是从Person派生出来的。正如我们已经知道的,这意味着一个Student实例将包含一个Person的内存布局,作为一个Person子对象。

然而,请注意Student类定义中的数据成员Id studentId;。在这里,studentIdId类型。它不是一个指针,也不是Id的引用。数据成员studentId是一个嵌入对象(即聚合或成员对象)。这意味着当Student类被实例化时,不仅将包含从继承的类中继承的内存,还包括任何嵌入对象的内存。我们需要提供一种初始化嵌入对象studentId的方法。注意,我们之前已经见过成员对象,例如类型为string的数据成员;即数据成员是另一个类类型。

让我们继续前进,通过Student成员函数来了解我们如何初始化、操作和访问嵌入的对象:

Student::Student(): studentId(to_string(numStudents + 100) 
                                         + "Id") 
{
    numStudents++;   // increment static counter
}
Student::Student(const string &fn, const string &ln, 
                 char mi, const string &t, float avg, 
                 const string &course, const string &id):  
                 Person(fn, ln, mi, t), gpa(avg),
                 currentCourse(course), studentId(id)
{
    numStudents++;
}
Student::Student(const Student &s): Person(s),
                gpa(s.gpa), currentCourse(s.currentCourse),
                studentId(s.studentId)
{
    numStudents++;
}
Student::~Student()   // destructor definition
{
    numStudents--;    // decrement static counter
    // embedded object studentId will also be destructed
}
void Student::Print() const
{
    cout << GetTitle() << " " << GetFirstName() << " ";
    cout << GetMiddleInitial() << ". " << GetLastName();
    cout << " with id: " << studentId.GetId() << " GPA: ";
    cout << setprecision(3) <<  " " << gpa;
    cout << " Course: " << currentCourse << endl;
}    
const string &GetStudentId() const 
{   
    return studentId.GetId();   
} 

在之前列出的Student成员函数中,让我们从构造函数开始。注意在默认构造函数中,我们利用成员初始化列表(:)来指定studentId(to_string(numStudents + 100) + "Id")。因为studentId是一个成员对象,所以我们有机会(通过成员初始化列表)选择用于其初始化的构造函数。在这里,我们只是选择具有Id(const string &)签名的构造函数。如果没有特定的值用于初始化Id,我们将制造一个字符串值来作为所需的 ID。

同样,在Student的另一个构造函数中,我们使用成员初始化列表来指定studentId(id),这也会选择Id(const string &)构造函数,并将参数id传递给这个构造函数。

Student的拷贝构造函数还额外指定了如何使用成员初始化列表中的studentId(s.studentId)规范来初始化studentId成员对象。在这里,我们只是调用了Id的拷贝构造函数。

在我们的 Student 析构函数中,我们不需要释放 studentId 的内存。因为这个数据成员是一个嵌入的(聚合)对象,其内存将在外部对象的内存释放时消失。当然,因为 studentId 本身也是一个对象,所以它的析构函数将在其内存释放之前首先被调用。在底层,编译器将(隐式地)在 Student 析构函数的最后一条代码中插入对 Id 析构函数的调用。实际上,这将是析构函数中的倒数第二行隐式插入的代码——最后将被隐式插入的将是调用 Person 析构函数(以继续销毁序列)。

最后,在之前的代码段中,让我们注意对 studentId.GetId() 的调用,这在 Student::Print()Student::GetStudentId() 中都发生了。在这里,嵌入对象 studentId 调用其自己的公共函数 Id::GetId() 来检索其在 Student 类作用域内的私有数据成员。因为 studentIdStudent 中是私有的,这个嵌入对象只能在其作用域内(即 Student 的成员函数)访问。然而,Student::GetStudentId() 的添加为其他作用域中的 Student 实例提供了一个公共包装器来检索此信息。

最后,让我们看一下我们的 main() 函数:

int main()
{
    Student s1("Cyrus", "Bond", 'I', "Mr.", 3.65, "C++",
               "6996CU");
    Student s2("Anne", "Brennan", 'M', "Ms.", 3.95, "C++",
               "909EU");
    cout << s1.GetFirstName() << " " << s1.GetLastName();
    cout << " has id #: " << s1.GetStudentId() << endl;
    cout << s2.GetFirstName() << " " << s2.GetLastName();
    cout << " has id #: " << s2.GetStudentId() << endl;
    return 0;
}

在前面提到的 main() 函数中,我们创建了两个 Student 实例:s1s2。当为每个 Student 创建内存(在这种情况下,在栈上)时,任何继承的类的内存也将作为子对象包含在内。此外,任何嵌入的对象,如 Id,也将作为 Student 内部的子对象进行布局。包含的对象,或称为 部分,将与外部对象,或称为 整体,的分配一起分配。

接下来,让我们注意对包含部分的访问,即嵌入的 Id 对象。我们从调用 s1.GetStudentId() 开始;s1 访问一个 Student 成员函数,GetStudentId()。这个学生成员函数将利用 studentId 的成员对象来调用 Id::GetId(),这是 Id 类型的内部对象。Student::GetStudentId() 成员函数可以通过简单地返回 Id::GetId() 在嵌入对象上返回的值来实现所需的公共访问。

让我们看一下上述程序的输出:

Cyrus Bond has id #: 6996CU
Anne Brennan has id #: 909EU 

这个例子详细介绍了组合及其典型实现,即嵌入对象。现在,让我们看看一种使用较少的、替代的实现方式——继承。

考虑为组合使用另一种实现方式

有必要了解,组合也可以通过继承来实现,然而,这种方法极具争议。记住,继承最常用于实现“是”某种类型(Is-A)和“拥有”某种类型(Has-A)的关系。我们在第九章中简要描述了使用继承来实现“拥有”关系,即探索多重继承

总结一下,你只需从“部分”继承,而不是将部分作为数据成员嵌入。这样做时,你不再需要为“部分”提供“包装”函数,例如我们之前程序中看到的,Student::GetStudentId() 方法调用 studentId.GetId() 以提供对其内嵌部分的访问。在嵌入式对象示例中,包装函数是必要的,因为部分(Id)在整体(Student)中是私有的。程序员无法在 Student 的作用域之外访问私有的 studentId 数据成员。当然,Student 的成员函数(如 GetStudentId())可以访问它们自己类的私有数据成员,并在这样做时,可以实施 Student::GetStudentId() 包装函数以提供这样的(安全)访问。

如果使用了继承,Id::GetId() 的公共接口将简单地作为 Student 中的公共接口继承,提供简单的访问,无需首先显式地通过嵌入式对象。

尽管在某些方面继承“部分”很简单,但它极大地增加了多重继承的复杂性。我们知道多重继承可以带来许多潜在的问题。此外,使用继承,整体只能包含每种“部分”的一个实例——不能有多个“部分”的实例。

此外,当你将实现与面向对象设计进行比较时,使用继承实现整体-部分关系可能会令人困惑。记住,继承通常意味着“是”某种类型(Is-A)而不是“拥有”某种类型(Has-A)。因此,聚合最典型和最受欢迎的实现方式是通过内嵌对象。

接下来,让我们继续探讨更一般的聚合形式。

定义和实现泛化聚合

我们已经探讨了面向对象设计中最常用的聚合形式,即组合。最值得注意的是,通过组合,我们看到部分没有理由在没有整体的情况下存在。然而,存在一种更通用的(但不太常见)的聚合形式,有时在面向对象设计中指定。我们现在将考虑这种不太常见的聚合形式。

泛化聚合中,一个“部分”可能存在于没有“整体”的情况下。一个部分将单独创建,然后在稍后的时间点附加到整体上。当“整体”消失时,一个“部分”可能仍然可以用于与另一个外部或“整体”对象一起使用。

在广义聚合中,Has-A 关系当然适用,整个部分指定也适用。区别在于,整体对象不会创建或销毁部分子对象。考虑一个简单的例子,一个Car 具有 一个EngineCar对象也具有一套四个Tire对象。EngineTire对象可以单独制造,然后传递给Car的构造函数,为整体提供这些部分。然而,如果销毁一个Engine,可以很容易地用新的Engine替换(使用成员函数),而不需要销毁整个Car并重新构建。

广义聚合相当于 Has-A 关系,但我们将其视为具有更多灵活性和个体部分的永久性,就像我们在组合中所做的那样。我们将其视为聚合关系,仅仅因为我们希望将对象等同于具有 Has-A 意义。在CarEngineTire示例中的 Has-A 关系是强烈的;EngineTire是必要的部分,是构成整个Car所必需的。

在这里,实现通常是整体包含指向部分(或一组指针)的指针。重要的是要注意,部分将被传递到外部对象的构造函数(或另一个成员函数)中,以建立这种关系。关键标记是整体不会创建(也不会销毁)部分,部分永远不会销毁整体。

顺便提一下,广义聚合的各个部分(和基本实现)的持久性将类似于我们下一个主题——关联。让我们继续前进到下一个部分,了解广义聚合和关联之间的相似性,以及 OO 概念上的差异(有时微妙)。

理解关联

关联模型了存在于不同类类型之间的关系。关联可以提供对象交互的方式以满足这些关系。然而,关联不用于 Has-A 关系,但在某些情况下,我们描述的是一个真正的 Has-A 关系,还是仅仅因为听起来在语言上合适而使用 Has-A 这个短语,这之间可能存在灰色地带。

关联的多重性存在:一对一、一对多、多对一或多对多。例如,一个Student可以与一个单独的University相关联,而这个University可以与许多Student实例相关联;这是一个一对多关联。

关联对象具有独立的存在性。也就是说,两个或多个对象可能被实例化并独立存在于应用程序的一部分。在某个时刻,一个对象可能希望断言与其他对象的依赖关系或关系。在应用程序的后期,关联对象可能分道扬镳,继续它们各自无关的路径。

例如,考虑课程讲师之间的关系。一个课程与一个讲师相关联。一个课程需要有一个讲师讲师课程是必不可少的。一个讲师可以与多个课程(s)相关联。然而,每个部分都是独立存在的——一个不会创建或摧毁另一个。讲师也可以独立于课程存在;也许讲师正在花时间写一本书,正在休假,或者是一位正在进行研究的教授。

在这个例子中,关联与泛化聚合非常相似。在这两种情况下,相关对象也都是独立存在的。在这种情况下,无论是说课程拥有讲师,还是说课程讲师有依赖关系,都可能是一种灰色地带。你可能自己问自己——是口语使我选择了“拥有”这个词吗?我是不是意味着两者之间存在必要的联系?也许这种关系是一种关联,其描述性修饰语(进一步描述关联的性质)是教授。你可能对两种选择都有支持性的论点。因此,泛化聚合可以被认为是关联的特殊类型;我们将看到,它们使用独立存在的对象实现时是相同的。尽管如此,我们将区分典型的关联为对象之间的关系,这种关系明确不支持真正的“拥有”关系。

例如,考虑大学讲师之间的关系。我们与其将其视为“拥有”关系,不如将其视为两者之间的关联关系;我们可以将描述这种关系的修饰语视为雇佣。同样,大学与许多学生对象建立关系。这里的关联可以用修饰语教育来描述。可以区分的是,大学对象、建筑对象以及此类组件组成,以通过包含支持其“拥有”关系,然而其与讲师对象、学生对象等的关系则是通过关联来实现的。

现在我们已经区分了典型的关联和泛化聚合,让我们来看看我们如何实现关联以及其中涉及的一些复杂性。

实现关联

通常,两个或多个对象之间的关联是通过指针或指针集合实现的。一方使用指向相关对象的指针来实现,而关系的多方使用指向相关对象的指针集合来实现。指针集合可能是一个指针数组、指针链表,或者真正任何指针集合。每种类型的集合都有自己的优点和缺点。例如,指针数组易于使用,可以直接访问特定成员,但项目数量是固定的。指针链表可以容纳任何数量的项目,但访问特定元素需要遍历其他元素以找到所需的项目。

有时,可能会使用引用来实现关联的一侧。回想一下,引用必须初始化,并且以后不能重置以引用另一个对象。使用引用来建模关联意味着在主对象存在期间,一个实例将与另一个精确的实例相关联。这是非常限制性的,因此引用很少用于实现关联。

无论实现方式如何,当主对象消失时,它不会干扰(即删除)相关对象。

让我们看看一个典型的例子,它说明了如何实现一对多关联的首选方法,在一方使用指针,而在多方使用指针集合。在这个例子中,一个University将与多个Student实例相关联。为了简单起见,一个Student将与一个单一的University相关联。

为了节省空间,本程序中与上一个示例相同的部分将不会显示;然而,整个程序可以在我们的 GitHub 上找到,如下所示:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter10/Chp10-Ex2.cpp

#include <iostream>
#include <iomanip>
using std::cout;
using std::endl;
using std::setprecision;
using std::string;
using std::to_string;
// classes Id and Person are omitted here to save space.
// They will be as shown in previous example: Chp10-Ex1.cpp
class Student; // forward declaration
class University
{
private:
    string name;
    static constexpr int MAX = 25; // max students allowed
    // Notice: each studentBody element is set to a nullptr 
    // using in-class initialization 
    Student *studentBody[MAX] = { }; // Association to
                                     // many students
    int currentNumStudents = 0;  // in-class initialization
public:
    University();
    University(const string &);
    University(const University &) = delete; // no copies
    ~University();
    void EnrollStudent(Student *);
    const string &GetName() const { return name; }
    void PrintStudents() const;
};

在前面的部分中,我们首先注意到class Student;的前向声明。这个声明将允许我们的代码在Student类定义之前引用Student类型。在University类定义中,我们看到有一个指向Student的指针数组。我们还看到EnrollStudent()方法接受一个Student *作为参数。前向声明使得在定义之前可以使用Student

我们还注意到,University有一个简单的接口,包括构造函数、析构函数和一些成员函数。

接下来,让我们看看University成员函数的定义:

// Remember, currentNumStudents will be set w in-class init
// and name, as a string member object, will be init to 
// empty. And studentBody (array of ptrs) will also set w
// in-class initialization.
University::University()
{
    // in-lieu of in-class init, we could alternatively set
    // studentBody[i] to nullptr iteratively in a loop:
    // (the student body will start out empty)   
    // for (int i = 0; i < MAX; i++) 
    //    studentBody[i] = nullptr; 
}
University::University(const string &n): name(n)
{   
    // see default constructor for alt init of studentBody
}
University::~University()
{
    // The University will not delete the students
    for (int i = 0; i < MAX; i++)   // only null out 
       studentBody[i] = nullptr;    // their link
}                      
void University::EnrollStudent(Student *s)
{
    // set an open slot in the studentBody to point to the
    // Student passed in as an input parameter
    studentBody[currentNumStudents++] = s;
}
void University::PrintStudents()const
{
    cout << name << " has the following students:" << endl;
    // Simple loop to process set of students, however we
    // will soon see safer, more modern ways to iterate 
    // over partial arrays w/o writing explicit 'for' loops
    for (int i = 0; i < currentNumStudents; i++)
    {
       cout << "\t" << studentBody[i]->GetFirstName();
       cout << " " << studentBody[i]->GetLastName();
       cout << endl;
    }
}

仔细观察前面提到的 University 方法,我们可以看到,在 University 的两个构造函数中,我们可以选择使用 nullptr 来将组成我们的 studentBody 的指针置为空(而不是我们选择在类内初始化,这同样会初始化每个元素)。同样,在析构函数中,我们也将关联到相关 Student 实例的链接置为空。在本节稍后,我们将看到还需要进行一些额外的反向链接维护,但到目前为止,重点是我们将不会删除相关的 Student 对象。

由于 University 对象和 Student 对象将独立存在,因此它们都不会创建或销毁其他类型的实例。

我们还遇到了一个有趣的成员函数,EnrollStudent(Student *)。在这个方法中,将传递一个指向特定 Student 的指针作为输入参数。我们只是索引到我们的 Student 对象指针数组 studentBody,并将未使用的数组元素指向新注册的 Student。我们使用 currentNumStudents 计数器跟踪当前 Student 对象的数量,该计数器在将指针分配给数组后通过后增量增加。

我们还注意到,University 类有一个 Print() 方法,它会打印大学的名称,然后是其当前的学生阵容。它是通过简单地访问 studentBody 中的每个相关 Student 对象,并要求每个 Student 实例调用 Student::GetFirstName()Student::GetLastName() 方法来实现的。

接下来,现在让我们来看看我们的 Student 类定义,包括其内联函数。回想一下,我们假设的 Person 类与本章前面看到的相同:

class Student: public Person  
{
private:
    // data members
    float gpa = 0.0;  // in-class initialization
    string currentCourse;
    static int numStudents;
    Id studentId;  // part, Student Has-A studentId
    University *univ = nullptr;  // Assoc. to Univ object
public:                          
    // member function prototypes
    Student();  // default constructor
    Student(const string &, const string &, char, 
            const string &, float, const string &, 
            const string &, University *);
    Student(const Student &);  // copy constructor
    ~Student() override;  // destructor
    void EarnPhD() { ModifyTitle("Dr."); }
    float GetGpa() const { return gpa; }
    const string &GetCurrentCourse() const 
        { return currentCourse; }
    void SetCurrentCourse(const string &); // proto. only
    void Print() const override;
    void IsA() const override 
        { cout << "Student" << endl; }
    static int GetNumberStudents() { return numStudents; }
    // Access functions for aggregate/associated objects
    const string &GetStudentId() const 
        { return studentId.GetId(); }
    const string &GetUniversity() const 
        { return univ->GetName(); }
};
int Student::numStudents = 0;  // def. of static data mbr.
inline void Student::SetCurrentCourse(const string &c)
{
    currentCourse = c;
}

在前面的代码段中,我们看到 Student 类的定义。请注意,我们有一个与大学关联的指针数据成员 University *univ = nullptr;,并且该成员使用类内初始化设置为 nullptr

Student 类的定义中,我们还可以看到有一个包装函数来封装对学生的大学名称的访问,即 Student::GetUniversity()。在这里,我们允许关联的对象 univ 调用其公共方法 University::GetName(),并将该值作为 Student::GetUniversity() 的结果返回。

现在,让我们来看看 Student 的非内联成员函数:

Student::Student(): studentId(to_string(numStudents + 100) 
                                        + "Id")
{
    // no current University association (set to nullptr 
    // with in-class initialization)
    numStudents++;
}
Student::Student(const string &fn, const string &ln, 
          char mi, const string &t, float avg, 
          const string &course, const string &id, 
          University *univ): Person(fn, ln, mi, t), 
          gpa(avg), currentCourse(course), studentId(id)
{
    // establish link to University, then back link
    // note: forward link could also be set in the
    // member initialization list
    this->univ = univ;  // required use of ‹this›
    univ->EnrollStudent(this);  // another required 'this'
    numStudents++;
}
Student::Student(const Student &s): Person(s), 
          gpa(s.gpa), currentCourse(s.currentCourse),
          studentId(s.studentId)
{
    // Notice, these three lines of code are the same as 
    // in the alternate constructor – we could instead make
    // a private helper method with this otherwise 
    // duplicative code as a means to simplify code 
    // maintenance. 
    this->univ = s.univ;    
    univ->EnrollStudent(this);
    numStudents++;
}
Student::~Student()  // destructor
{
    numStudents--;
    univ = nullptr;  // a Student does not delete its Univ
    // embedded object studentId will also be destructed
}
void Student::Print() const
{
    cout << GetTitle() << " " << GetFirstName() << " ";
    cout << GetMiddleInitial() << ". " << GetLastName();
    cout << " with id: " << studentId.GetId() << " GPA: ";
    cout << setprecision(3) <<  " " << gpa;
    cout << " Course: " << currentCourse << endl;
}

在前面的代码段中,请注意,默认的 Student 构造函数和析构函数都只将它们与 University 对象的链接置为空(使用 nullptr)。默认构造函数无法将此链接设置为现有对象,并且绝对不应该创建一个 University 实例来这样做。同样,Student 析构函数也不应该仅仅因为 Student 对象的生命周期结束就删除 University

上述代码中最有趣的部分发生在 Student 类的替代构造函数和复制构造函数中。让我们来检查替代构造函数。在这里,我们建立了与关联的 University 的链接,以及从 University 返回到 Student 的反向链接。

在代码行 this->univ = univ; 中,我们通过将 this 指针指向的 univ 数据成员设置为指向输入参数 univ 指向的位置来赋值。仔细看看之前的类定义——University * 的标识符被命名为 univ。此外,在替代构造函数中 University * 的输入参数也被命名为 univ。我们在这个构造函数体(或成员初始化列表)中不能简单地使用 univ = univ; 来赋值。在这个最局部作用域中的 univ 标识符是输入参数 univ。将 univ = univ; 赋值将会使这个参数指向自己。相反,我们使用 this 指针来消除赋值表达式左侧的 univ 的歧义。语句 this->univ = univ; 将数据成员 univ 设置为输入参数 univ。我们能否仅仅将输入参数重命名为不同的名称,比如 u?当然可以,但重要的是要理解在需要这样做时如何消除具有相同标识符的输入参数和数据成员的歧义。

现在,让我们检查下一行代码,univ->EnrollStudent(this);。由于 univthis->univ 指向同一个对象,使用哪一个来设置反向链接并不重要。在这里,univ 调用 EnrollStudent(),这是 University 类中的一个公共成员函数。没问题,univUniversity 类型。University::EnrollStudent(Student *) 期望传入一个指向 Student 的指针以在 University 一侧完成链接。幸运的是,我们 Student 替代构造函数(调用函数的作用域)中的 this 指针是一个 Student *this 指针(在替代构造函数中)正是我们需要用来创建反向链接的 Student *。这是一个需要显式使用 this 指针来完成任务的另一个例子。

让我们继续到我们的 main() 函数:

int main()
{
    University u1("The George Washington University");
    Student s1("Gabby", "Doone", 'A', "Miss", 3.85, "C++",
               "4225GWU", &u1);
    Student s2("Giselle", "LeBrun", 'A', "Ms.", 3.45,
               "C++", "1227GWU", &u1);
    Student s3("Eve", "Kendall", 'B', "Ms.", 3.71, "C++",
               "5542GWU", &u1);
    cout << s1.GetFirstName() << " " << s1.GetLastName();
    cout << " attends " << s1.GetUniversity() << endl;
    cout << s2.GetFirstName() << " " << s2.GetLastName();
    cout << " attends " << s2.GetUniversity() << endl;
    cout << s3.GetFirstName() << " " << s3.GetLastName();
    cout << " attends " << s3.GetUniversity() << endl;
    u1.PrintStudents();
    return 0;
}

最后,在我们的 main() 函数中的前一个代码片段中,我们可以创建几个独立存在的对象,在它们之间建立关联,然后观察这种关系在实际中的应用。

首先,我们实例化一个 University,即 u1。然后,我们实例化三个 Student 对象,s1s2s3,并将它们分别关联到 University u1 上。请注意,这种关联可以在实例化 Student 时设置,或者稍后进行,例如,如果 Student 类支持一个 SelectUniversity(University *) 接口来这样做的话。

我们随后打印出每个Student,以及每个Student所就读的University的名称。然后,我们打印出我们Universityu1的学生名单。我们注意到,在相关对象之间建立的联系在两个方向上都是完整的。

让我们看看上述程序的输出:

Gabby Doone attends The George Washington University
Giselle LeBrun attends The George Washington University
Eve Kendall attends The George Washington University
The George Washington University has the following students:
        Gabby Doone
        Giselle LeBrun
        Eve Kendall

我们已经看到,在相关对象之间建立和利用关联是多么容易。然而,实现关联会产生很多维护工作。让我们继续前进,了解参考计数和反向链接维护的必要和相关问题,这将有助于这些维护工作。

利用反向链接维护和引用计数

在前面的子节中,我们看到了如何使用指针实现关联。我们看到了如何通过指针将一个对象与关联实例中的另一个对象链接起来。我们还看到了如何通过建立反向链接来完成循环的双向关系。

然而,正如关联对象通常所表现的那样,关系是流动的,并且会随时间变化。例如,对于特定的University,其学生名单会经常变化,或者Instructor将教授的各种Course集合在每个学期也会变化。因此,通常需要移除特定对象与另一个对象之间的关联,或许还会与该类别的不同实例建立关联。但这同时也意味着关联对象必须知道如何移除其与第一个提到的对象的链接。这变得复杂了。

例如,考虑StudentCourse之间的关系。一名Student注册了许多Course实例。一个Course包含了对许多Student实例的关联。这是一个多对多关联。让我们想象一下,如果Student想要退选一个Course,仅仅移除特定Student实例对特定Course实例的指针是不够的。此外,Student必须让特定的Course实例知道,相关的Student应该从该Course的名单中移除。这被称为反向链接维护。

考虑一下,如果一名Student只是简单地将其与所退选的Course之间的链接置为 null,并且不再采取任何进一步行动,上述场景会发生什么。相关的Student实例将没问题。然而,之前关联的Course实例仍然会包含一个指向该Student的指针。也许这相当于StudentCourse中得到了不及格的成绩,因为Instructor仍然认为该Student是注册的,但还没有提交作业。最终,Student还是受到了影响,得到了不及格的成绩。

记住,对于关联对象,当一个对象完成与另一个对象的工作时,它不会删除另一个对象。例如,当一个Student退选一门Course时,他们不会删除那门Course——只会移除他们对那门Course的指针(并且肯定还要处理所需的反向链接维护)。

一个帮助我们进行整体链接维护的想法是考虑引用计数。引用计数的目的是跟踪可能指向给定实例的指针数量。例如,如果其他对象指向一个给定的实例,则不应删除该实例。否则,其他对象中的指针将指向已释放的内存,这将导致许多运行时错误。

让我们考虑一个具有多重性的关联,例如StudentCourse之间的关系。Student应该跟踪有多少Course指针指向Student,即Student正在选修多少门Course。在多个Course指向该Student的情况下,不应删除Student。否则,Course将指向已删除的内存。处理这种情况的一种方法是在Student析构函数中检查对象(this)是否包含任何非空Course实例指针。如果对象包含,它需要通过每个活动的Course实例调用一个方法,请求从每个这样的Course中删除对Student的链接。在每个链接被删除后,对应于Course实例集合的引用计数可以递减。

同样,链接维护应在Course类中进行,以利于Student实例。在所有注册该CourseStudent实例被通知之前,不应删除Course实例。通过引用计数保持指向特定Course实例的Student实例数量的计数器是有帮助的。在这个例子中,这就像维护一个变量来反映当前注册该CourseStudent实例数量一样简单。

我们可以细致地自行进行链接维护,或者我们可能选择使用智能指针来管理关联对象的生存期。智能指针可以在 C++标准库中找到。它们封装了一个指针(即,在类中包装一个指针)以添加智能功能,包括引用计数和内存管理。由于智能指针使用模板,而我们将不会在第十三章“使用模板”中介绍模板,我们在这里只提及其潜在用途。

我们现在已经看到了反向链接维护的重要性以及引用计数在完全支持关联及其成功实现中的实用性。在继续下一章之前,让我们简要回顾一下本章中涵盖的面向对象概念——关联、聚合和组合。

摘要

在本章中,我们通过探索各种对象关系——关联、聚合和组合——来继续我们面向对象编程的追求。我们已经理解了代表这些关系的各种 OO 设计概念,并看到 C++不通过关键字或特定语言特性直接提供语言支持来实现这些概念。

尽管如此,我们已经学习了实现这些核心 OO 关系的技术,例如用于组合的内嵌对象和泛化聚合,或使用指针来实现关联。我们研究了这些关系下对象典型存在期限;例如,在聚合中,通过创建和销毁其内部部分(通过内嵌对象,或者更少的情况下,通过分配和释放指针成员)。或者通过关联对象的独立存在,这些对象既不创建也不销毁对方。我们还深入研究了实现关联(特别是具有多重性的关联)所需的维护工作,特别是通过检查反向链接维护和引用计数。

通过理解如何实现关联、聚合和组合,我们已经增加了我们的 OOP 技能的关键特性。我们看到了这些关系如何在 OO 设计中比继承更加普遍的例子。通过掌握这些技能,我们已经完成了在 C++中实现基本 OO 概念的技能集。

我们现在准备继续前进到第十一章处理异常,这将开始我们扩展 C++编程技能库的探索。让我们继续前进!

问题

  1. 在本章的University/Student示例中添加一个额外的Student构造函数,以便通过引用接受University构造函数参数,而不是通过指针。例如,除了具有签名Student::Student(const string &fn, const string &ln, char mi, const string &t, float avg, const string &course, const string &id, University *univ);的构造函数外,还可以重载此函数,但最后一个参数为University &univ。这种改变如何影响对这个构造函数的隐式调用?

提示:在你的重载构造函数中,你现在需要取University引用参数的地址(&)来设置关联(该关联以指针形式存储)。你可能需要切换到对象表示法(.)来设置反向链接(如果你使用参数univ,而不是数据成员this->univ)。

  1. 编写一个 C++程序以实现Course类型和Student类型对象之间的多对多关联。你可以选择基于之前封装Student的程序进行构建。多对多关系应按以下方式工作:

    1. 特定的 Student 可以选择零到多个 Course,而特定的 Course 将与多个 Student 实例相关联。将 Course 类封装起来,至少包含课程名称、一组指向相关联的 Student 实例的指针和一个引用计数,以跟踪在 Course 中的 Student 实例数量(这将等同于指向特定 Course 实例的 Student 实例数量)。添加适当的接口以合理封装此类。

    2. Student 类中添加一组指向该学生已注册的 Course 实例的指针。此外,跟踪特定学生当前注册的 Course 实例数量。添加适当的成员函数以支持这一新功能。

    3. 使用指针链表(即,数据部分是关联对象的指针)或关联对象的指针数组来模拟多方面的关联。请注意,数组将限制您可以拥有的关联对象数量,但这可能是合理的,因为特定的 Course 只能容纳一定数量的 Student,而学生每学期可能只能注册一定数量的 Course。如果您选择指针数组方法,请确保您的实现包括错误检查,以适应每个数组中关联对象数量的上限。

    4. 一定要检查简单的错误,例如尝试将 Student 添加到一个已满的 Course 中,或者将过多的 Course 添加到学生的课程表(假设每学期最多五门课程)中。

    5. 确保您的析构函数不会删除关联的实例。

    6. 引入至少三个 Student 对象,每个对象选择两个或更多的 Course。此外,确保每个 Course 有多个注册的学生。打印每个学生,包括他们注册的每个 Course。同样,打印每个 Course,显示注册在该 Course 中的每个学生。

  2. (可选)增强您的程序在 练习 2 中,以获得关于反向链接维护和引用计数的经验如下:

    1. Student 实现 DropCourse() 接口。也就是说,在 Student 中创建一个 Student::DropCourse(Course *) 方法。在这里,找到学生想要从课程列表中删除的 Course,但在删除 Course 之前,调用该 Course 上的一个方法来从 Course 中删除上述 Student(即 this)。提示:您可以创建一个 Course::RemoveStudent(Student *) 方法来帮助进行反向链接删除。

    2. 现在,完全实现适当的析构函数。当 Course 被析构时,Course 析构函数首先告诉每个剩余的关联 Student 移除它们对该 Course 的链接。同样,当 Student 被析构时,遍历 Student 的课程列表,要求那些 Courses 从他们的学生列表中移除上述 Student(即 this)。您可能会发现每个类中的引用计数(即通过检查 numStudentsnumCourses)对于确定是否需要执行这些任务很有帮助。

第三部分:扩展您的 C++ 编程曲目

本部分的目标是扩展您的 C++ 编程技能,不仅限于面向对象编程技能,还要涵盖 C++ 的其他关键特性。

本节的第一章通过理解 trythrowcatch 的机制,并通过检查许多示例来深入各种异常处理场景,来探索 C++ 中的异常处理。此外,本章还通过引入新的异常类来扩展异常类层次结构。

下一章深入探讨正确使用友元函数和友元类,以及运算符重载(有时可能需要友元),以使内置类型和用户定义类型之间的操作多态化。

下一章探讨使用 C++ 模板来帮助使代码通用并可用于各种数据类型,使用模板函数和模板类。此外,本章还解释了运算符重载如何帮助使模板代码对几乎所有数据类型都具有可扩展性。

在下一章中,将介绍 C++ 的标准模板库(Standard Template Library),并检查核心 STL 容器,如 listiteratordequestackqueuepriority_queuemap(包括一个使用函数对象的例子)。此外,还将介绍 STL 算法和函数对象。

本节的最后一章通过探索规范类形式、创建组件测试驱动程序、测试通过继承、关联、聚合相关联的类以及测试异常处理机制,来概述测试面向对象程序和组件。

本部分包括以下章节:

  • 第十一章处理异常

  • 第十二章友元和运算符重载

  • 第十三章使用模板

  • 第十四章理解 STL 基础

  • 第十五章测试类和组件

第三部分:扩展您的 C++ 编程曲目

第十一章:处理异常

本章将开始我们的探索之旅,旨在扩展你的 C++编程知识库,使其超越面向对象的概念,目标是使你能够编写更健壮和可扩展的代码。我们将从探索 C++中的异常处理开始这一努力。在我们的代码中添加语言规定的错误处理方法将使我们能够编写更少错误和更可靠的程序。通过使用语言内建的正式异常处理机制,我们可以实现错误的统一处理,这导致代码更容易维护。

在本章中,我们将涵盖以下主要主题:

  • 理解异常处理基础知识 – trythrowcatch

  • 探索异常处理机制 – 尝试可能引发异常的代码,抛出(抛出),捕获,并使用多种变体处理异常

  • 利用标准异常对象或创建自定义异常类来利用异常层次结构

到本章结束时,你将了解如何在 C++中利用异常处理。你将看到如何识别错误以引发异常,通过抛出异常将程序控制权转移到指定区域,然后通过捕获异常来处理错误,并希望修复当前的问题。

你还将学习如何利用 C++标准库中的标准异常,以及创建自定义异常对象。可以设计一个异常类层次结构,以添加强大的错误检测和处理能力。

让我们通过探索 C++内建的异常处理机制来扩展我们对 C++的理解。

技术要求

完整程序示例的在线代码可以在以下 GitHub URL 找到:github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/tree/main/Chapter11。每个完整的程序示例都可以在 GitHub 的相应章节标题(子目录)下找到,对应章节的文件名,后面跟着一个连字符,然后是当前章节的示例编号。例如,本章节的第一个完整程序可以在上述 GitHub 目录下的Chapter11子目录中找到,文件名为Chp11-Ex1.cpp

本章节的 CiA 视频可以在以下链接查看:bit.ly/3QZi638

理解异常处理

应用程序中可能会发生错误条件,这会阻止程序正确继续。这些错误条件可能包括超出应用程序限制的数据值,必要的输入文件或数据库变得不可用,堆内存耗尽,或任何其他可想象的问题。C++异常提供了一种统一、语言支持的程序异常处理方式。

在引入语言支持的异常处理机制之前,每个程序员都会以自己的方式处理错误,有时甚至不处理。未处理的程序错误和异常意味着在应用程序的某个地方,将发生意外的结果,并且应用程序通常会异常终止。这些潜在的结果当然是不希望的!

C++的异常处理提供了一种语言支持的机制来检测和纠正程序异常,以便应用程序可以继续运行,而不是突然终止。

让我们看看机制,从语言支持的trythrowcatch关键字开始,这些构成了 C++中的异常处理。

利用 try、throw 和 catch 进行异常处理

异常处理检测程序异常,由程序员或类库定义,并将控制权传递到应用程序的另一部分,在那里可以处理特定的问题。只有在最后不得已的情况下,才需要退出应用程序。

让我们从查看支持异常处理的关键字开始。关键字如下:

  • try:允许程序员尝试可能引发异常的代码部分。

  • throw:一旦发现错误,throw会引发异常。这将导致跳转到关联的 try 块下面的 catch 块;throw将允许将参数返回到关联的 catch 块。抛出的参数可以是任何标准或用户定义的类型。

  • catch:指定一个代码块,用于寻找已抛出的异常,并尝试纠正情况。同一作用域中的每个 catch 块将处理不同类型的异常。

当使用异常处理时,回顾回溯的概念是有用的。当一系列函数被调用时,我们会在栈上建立适用于每个后续函数调用的状态信息(参数、局部变量和返回值空间),以及每个函数的返回地址。当抛出异常时,我们可能需要将栈回溯到函数调用序列(或 try 块)开始的点,同时重置栈指针。这个过程被称为回溯,允许程序返回到代码中的早期序列。回溯不仅适用于函数调用,还适用于嵌套块,包括嵌套的 try 块。

这里有一个简单的例子,用于说明基本的异常处理语法和用法。尽管为了节省空间没有显示代码的部分,但完整的例子可以在我们的 GitHub 上找到,如下所示:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter11/Chp11-Ex1.cpp

// Assume Student class is as seen before, but with one
// additional virtual mbr function. Assume usual headers.
void Student::Validate() // defined as virtual in class def
{                        // so derived classes may override
    // check constructed Student; see if standards are met
    // if not, throw an exception
    throw string("Does not meet prerequisites");
}
int main()
{
    Student s1("Sara", "Lin", 'B', "Dr.", 3.9,
               "C++", "23PSU");
    try      // Let's 'try' this block of code -- 
    {        // Validate() may raise an exception
        s1.Validate(); // does s1 meet admission standards?
    }
    catch (const string &err) 
    {
        cout << err << endl;
        // try to fix problem here…
        exit(1); // only if you can't fix error, 
    }            // exit as gracefully as possible
    cout << "Moving onward with remainder of code.";
    cout << endl;
    return 0;
}

在前面的代码片段中,我们可以看到 trythrowcatch 关键字在起作用。首先,让我们注意到 Student::Validate() 成员函数。想象一下,在这个虚函数中,我们验证一个 Student 是否符合入学标准。如果是这样,函数将正常结束。如果不是,将抛出一个异常。在这个例子中,抛出了一个简单的 string,封装了消息 "Does not meet prerequisites"

在我们的 main() 函数中,我们首先实例化一个 Student,即 s1。然后,我们将对 s1.Validate() 的调用嵌套在一个 try 块中。我们实际上是在说我们想要 尝试 这段代码。如果 Student::Validate() 如预期那样工作,没有错误,我们的程序将完成 try 块,跳过 try 块下面的捕获块,并继续执行任何捕获块下面的代码。

然而,如果 Student::Validate() 抛出异常,我们将跳过 try 块中剩余的任何代码,并寻找一个随后定义的匹配的捕获块中与 const string & 类型匹配的异常。在这里,在匹配的捕获块中,我们的目标是尽可能纠正错误。如果我们成功,我们的程序将继续执行捕获器下面的代码。如果我们不成功,我们的任务是优雅地结束程序。

让我们看看上述程序的输出:

Student does not meet prerequisites 

接下来,让我们用以下逻辑总结异常处理的总体流程:

  • 当程序完成 try 块而没有遇到任何抛出的异常时,代码序列将继续执行 catch 块后面的语句。多个具有不同参数类型的 catch 块可以跟在 try 块后面。

  • 当抛出异常时,程序必须回溯并返回到包含原始函数调用的 try 块。程序可能需要回溯多个函数。当回溯发生时,堆栈上遇到的对象将被弹出,因此将被销毁。

  • 一旦程序(抛出异常)回溯到执行 try 块的函数,程序将继续执行与抛出的异常类型匹配的签名匹配的 catch 块(跟随 try 块)。

  • 类型转换(除了通过公共继承相关联的对象的上转型)不会执行以匹配潜在的捕获块。然而,可以捕获任何类型的异常的省略号()捕获块可以用作最通用的捕获块。

  • 如果不存在匹配的捕获块,程序将调用 C++ 标准库中的 terminate()。请注意,terminate() 将调用 abort();然而,程序员可以通过 set_terminate() 函数注册另一个函数来代替 terminate()

现在,让我们看看如何使用 set_terminate() 注册一个函数。尽管我们在这里只展示了代码的关键部分,但完整的程序可以在我们的 GitHub 上找到:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter11/Chp11-Ex2.cpp

void AppSpecificTerminate()
{   // first, do what's necessary to end program gracefully
    cout << "Uncaught exception. Program terminating";
    cout << endl;
    exit(1);
}
int main()
{   
    set_terminate(AppSpecificTerminate);  // register fn.
    return 0;
}

在之前的代码片段中,我们定义了自己的AppSpecificTerminate()函数。这是我们希望terminate()函数调用的函数,而不是其默认行为调用abort()。也许我们会使用AppSpecificTerminate()来更优雅地结束我们的应用程序,保存关键数据结构或数据库值。当然,我们也会自己exit()(或abort())。

main()中,我们只是调用set_terminate(AppSpecificTerminate)来将我们的终止函数注册到set_terminate()。现在,当abort()会被调用时,我们的函数将被调用。

有趣的是,set_terminate()返回一个指向之前安装的terminate_handler的函数指针(在其第一次调用时将是一个指向abort()的指针)。如果我们选择保存这个值,我们可以使用它来恢复之前注册的终止处理器。请注意,我们没有选择在这个例子中保存这个函数指针。

下面是使用上述代码未捕获异常的输出示例:

Uncaught exception. Program terminating

请记住,terminate()abort()set_terminate()等函数来自标准库。尽管我们可以使用作用域解析运算符在它们的名字前加上库名,例如std::terminate(),但这不是必要的。

注意

异常处理并不是要取代简单的程序员错误检查;异常处理有更大的开销。异常处理应该保留用于以统一的方式和在共同的位置处理更严重的程序错误。

现在我们已经看到了异常处理的基本机制,让我们看看稍微复杂一些的异常处理示例。

探索具有典型变化的异常处理机制

异常处理可以比之前展示的基本机制更复杂和灵活。让我们看看异常处理基本原理的各种组合和变化,因为每种可能适用于不同的编程场景。

将异常传递给外部处理器

捕获的异常可以被传递给外部处理器进行处理。或者,异常可以被部分处理,然后抛出到外部作用域进行进一步处理。

让我们基于之前的例子来演示这个原则。完整的程序可以在以下 GitHub 目录中查看:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter11/Chp11-Ex3.cpp

// Assume Student class is as seen before, but with
// two additional member fns. Assume usual header files.
void Student::Validate() // defined as virtual in class def
{                        // so derived classes may override
    // check constructed student; see if standards are met
    // if not, throw an exception
    throw string("Does not meet prerequisites");
}
bool Student::TakePrerequisites()  
{
    // Assume this function can correct the issue at hand
    // if not, it returns false
    return false;
}
int main()
{
    Student s1("Alex", "Ren", 'Z', "Dr.", 3.9, 
               "C++", "89CU");
    try    // illustrates a nested try block 
    {   
        // Assume another important task occurred in this
        // scope, which may have also raised an exception
        try
        {   
            s1.Validate();  // may raise an exception
        }
        catch (const string &err)
        {
            cout << err << endl;
            // try to correct (or partially handle) error.
            // If you cannot, pass exception to outer scope
            if (!s1.TakePrerequisites())
                throw;    // re-throw the exception
        }
    }
    catch (const string &err) // outer scope catcher 
    {                         // (e.g. handler)
        cout << err << endl;
        // try to fix problem here…
        exit(1); // only if you can't fix, exit gracefully
    } 
    cout << "Moving onward with remainder of code. ";
    cout << endl;
    return 0;
}

在上述代码中,让我们假设我们已经包含了我们常用的头文件,并定义了Student类的常用类定义。现在我们将通过添加Student::Validate()方法(虚拟的,以便它可以被重写)和Student::TakePrerequisites()方法(非虚拟的,后代应该直接使用它)来增强Student类。

注意到我们的Student::Validate()方法抛出了一个异常,它只是一个包含指示当前问题的消息的字符串字面量。我们可以想象Student::TakePrerequisites()方法的完整实现验证Student是否满足适当的先决条件,并相应地返回truefalse布尔值。

在我们的main()函数中,我们现在注意到一组嵌套的try块。这里的目的是说明一个可能调用方法(例如s1.Validate())的内部try块,该方法可能会抛出异常。请注意,与内部try块相同级别的处理程序捕获了这个异常。理想情况下,异常应该在与它起源的try块相同的级别上得到处理,所以让我们假设在这个作用域中的捕获器试图这样做。例如,我们的最内层捕获块可能试图纠正错误,并通过调用s1.TakePrerequisites()来测试是否已进行了纠正。

但也许这个捕获器只能部分处理异常。也许存在这样的知识,即外层处理程序知道如何进行剩余的修正。在这种情况下,将这个异常重新抛出到外层(嵌套)级别是可以接受的。我们最内层的捕获块中的简单throw;语句正是这样做的。请注意,外层确实有一个捕获器。如果抛出的异常在类型上匹配,那么现在外层级别将有机会进一步处理异常,并希望纠正问题,以便应用程序可以继续运行。只有当外层捕获块无法纠正错误时,应用程序才应该退出。在我们的例子中,每个捕获器都会打印出表示错误信息的字符串;因此,这个消息在输出中出现了两次。

让我们看看上述程序的输出:

Student does not meet prerequisites
Student does not meet prerequisites

现在我们已经看到了如何使用嵌套的trycatch块,让我们继续前进,看看如何将各种抛出类型和多种捕获块结合起来使用。

添加一系列处理器

有时,从内部作用域可能会抛出各种异常,这就需要为各种数据类型创建处理器。异常处理器(即捕获块)可以接收任何数据类型的异常。我们可以通过使用基类类型的捕获块来最小化我们引入的捕获器的数量;我们知道派生类对象(通过公共继承相关)总是可以被向上转换为它们的基类类型。我们还可以在捕获块中使用省略号()来允许我们捕获之前未指定的任何内容。

让我们基于最初的示例来构建一个示例,以展示各种处理器的实际应用。虽然程序示例被简化了,但完整的程序示例可以在我们的 GitHub 上找到,如下所示:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter11/Chp11-Ex4.cpp

// Assume Student class is as seen before, but with one
// additional virtual member function, Graduate(). Assume 
// a simple Course class exists. All headers are as usual.
void Student::Graduate()
{   // Assume the below if statements are fully implemented 
    if (gpa < 2.0) // if gpa doesn't meet requirements
        throw gpa;
    // if Student is short credits, throw number missing
        throw numCreditsMissing;  // assume this is an int
    // or if Student is missing a Course, construct, then
    // throw the missing Course as a referenceable object
    // Assume appropriate Course constructor exists
        throw Course("Intro. To Programming", 1234); 
    // or if another issue, throw a diagnostic message
        throw string("Does not meet requirements"); 
}
int main()
{
    Student s1("Ling", "Mau", 'I', "Ms.", 3.1, 
               "C++", "55UD");
    try  
    {  
        s1.Graduate();
    }
    catch (float err)
    {
        cout << "Too low gpa: " << err << endl;
        exit(1); // only if you can't fix, exit gracefully
    } 
    catch (int err)
    {
        cout << "Missing " << err << " credits" << endl;
        exit(2);
    }
    catch (const Course &err)
    {
        cout << "Need to take: " << err.GetTitle() << endl;
        cout << "Course #: " << err.GetCourseNum() << endl; 
        // Ideally, correct the error, and continue program 
        exit(3); // Otherwise, exit, gracefully if possible
    }             
    catch (const string &err)
    {
        cout << err << endl;
        exit(4); 
    }
    catch (...)
    {
        cout << "Exiting" << endl;
        exit(5);
    }
    cout << "Moving onward with remainder of code.";
    cout << endl;
    return 0;
}

在上述代码段中,我们首先检查 Student::Graduate() 成员函数。在这里,我们可以想象这个方法会运行许多毕业要求,因此可能会抛出各种不同类型的异常。例如,如果 Student 实例的 gpa 太低,会抛出一个浮点数作为异常,表示学生的 gpa 很差。如果 Student 的学分太少,会抛出一个整数,表示学生还需要获得多少学分才能获得学位。

Student::Graduate() 可能引发的最有趣的潜在错误是,如果学生的毕业要求中缺少一个必需的 Course。在这种情况下,Student::Graduate() 会实例化一个新的 Course 对象,通过构造函数填充 Course 名称和编号。这个匿名对象随后会从 Student::Graduate() 中抛出,就像在这个方法中可以交替抛出的匿名 string 对象一样。然后处理器可以通过引用捕获 Course(或 string)对象。

main() 函数中,我们仅仅将 Student::Graduate() 的调用封装在一个 try 块中,因为这个语句可能会抛出异常。在 try 块之后跟随一系列的捕获器 – 每个捕获器对应可能抛出的对象类型。在这个序列中的最后一个捕获块使用了省略号(),表示这个捕获器将处理 Student::Graduate() 抛出的任何其他类型的异常,这些异常没有被其他捕获器捕获。

实际参与捕获的捕获块是使用 const Course &err 捕获 Course 的那个。由于有 const 关键字,我们在处理程序中不能修改 Course,因此我们只能对此对象应用 const 成员函数。

注意,尽管每个早期的捕获器只是打印出错误然后退出,理想情况下,捕获器会尝试纠正错误,这样应用程序就不需要终止,允许 catch 块下面的代码继续执行。

让我们看看上述程序的输出:

Need to take: Intro. to Programming
Course #: 1234

现在我们已经看到了各种抛出类型和捕获块,让我们继续了解我们应该在单个 try 块中一起组合哪些内容。

在 try 块中将相关项分组

重要的是要记住,当try块中的一行代码遇到异常时,try块剩余的部分将被忽略。相反,程序将继续执行匹配的 catcher(如果不存在合适的 catcher,则调用terminate())。然后,如果错误被修复,catcher 之后的代码开始执行。请注意,我们永远不会返回以完成初始try块的剩余部分。这种行为的意义是,你应该只将属于try块中的相关元素组合在一起。也就是说,如果一个项引发了异常,那么完成该分组中的其他项就不再重要了。

请记住,catcher 的目标是在可能的情况下纠正错误。这意味着程序可以在适当的 catch 块之后继续执行。你可能会问:现在跳过关联的try块中的某个项是否可以接受?如果答案是否定的,那么请重写你的代码。例如,你可能会想要在try-catch分组周围添加一个循环,这样如果 catcher 纠正了错误,整个尝试将从最初的try块重新开始。

或者,创建更小的连续try-catch分组。也就是说,仅在它自己的try块中(后面跟着相应的 catcher)尝试一个重要的任务。然后,在它自己的try块中尝试下一个任务,并带有其关联的 catcher,依此类推。

接下来,让我们看看如何在函数原型中包含可能抛出的异常类型。

检查函数原型中的异常规范

我们可以通过扩展函数的签名来指定 C++函数可能抛出的异常类型,包括可能抛出的对象类型。然而,由于一个函数可能抛出多种类型的异常(或者根本不抛出),检查实际抛出的类型必须在运行时完成。因此,这些增强的指定符在函数原型中也被称为noexcept指定符,我们将在稍后看到。动态异常的使用也存在于现有的代码库和库中,所以让我们简要地考察其用法。

让我们通过一个示例来看看在函数的扩展签名中使用异常类型:

void Student::Graduate() throw(float, int, 
                               Course &, string)
{
   // this method might throw any type included in 
   // its extended signature
}
void Student::Enroll() throw()
{
   // this method might throw any type of exception
}

在上述代码片段中,我们可以看到Student类的两个成员函数。Student::Graduate()函数在其参数列表之后包含了throw关键字,并在其扩展签名中包含了可能从这个函数抛出的对象类型。请注意,Student::Enroll()方法在其扩展签名中仅在throw()之后有一个空列表。这意味着Student::Enroll()可能会抛出任何类型的异常。

在这两种情况下,通过在签名中添加带有可选数据类型的throw()关键字,我们为用户提供了宣布可能抛出的对象类型的方式。然后我们要求程序员在适当的位置包含对这种方法的方法调用,并在其后添加相应的 catcher。

我们将看到,尽管扩展签名的想法看起来非常有帮助,但在实践中却存在不利的因素。因此,动态异常规范已被弃用。由于你可能会在现有的代码中看到这些规范的使用,包括标准库原型(例如异常处理),因此编译器仍然支持这个弃用的特性,你需要了解它们的用法。

尽管动态异常(如之前所述的扩展函数签名)已被弃用,但为了达到类似的目的,语言中已添加了一个指定符,即noexcept关键字。

这个指定符可以按照以下方式添加到扩展签名之后:

void Student::Graduate() noexcept   // will not throw() 
{          // same as  noexcept(true) in extended signature
}          // same as deprecated throw() in ext. signature
void Student::Enroll() noexcept(false)  // may throw()
{                                       // an exception
}                                     

尽管如此,让我们通过查看当我们的应用程序抛出不属于函数扩展签名的异常时会发生什么来调查与动态异常相关的不利问题。

处理意外的动态异常类型

如果抛出的异常类型与扩展函数原型中指定的类型不同,C++标准库中的unexpected()将被调用。你可以使用unexpected()注册你自己的函数,就像我们在本章前面注册set_terminate()时做的那样。

你可以让你的AppSpecificUnexpected()函数重新抛出原始函数应该抛出的异常类型;然而,如果这种情况没有发生,terminate()将被调用。此外,如果不存在可能的匹配 catcher 来处理从原始函数正确抛出(或由AppSpecificUnexpected()重新抛出)的内容,那么terminate()将被调用。

让我们看看如何使用set_unexpected()与我们的函数结合使用:

void AppSpecificUnexpected()
{
    cout << "An unexpected type was thrown" << endl;
    // optionally re-throw the correct type, or
    // terminate() will be called.
}
int main()
{
   set_unexpected(AppSpecificUnexpected)
}

如前所述的代码片段所示,使用set_unexpected()将我们自己的函数注册起来非常简单。

从历史上看,在函数的扩展签名中使用异常规范的一个激励因素是为了提供文档效果。也就是说,你可以通过检查函数的签名来看到函数可能会抛出的异常。然后你可以计划在函数调用周围放置 try 块,并提供适当的 catcher 来处理任何潜在的情况。

然而,关于动态异常,值得注意的是,编译器不会检查函数体中实际抛出的异常类型是否与函数扩展签名中指定的类型匹配。确保它们同步是程序员的职责。因此,这个弃用的特性可能会引起错误,并且总体上不如其原始意图有用。

尽管初衷良好,但动态异常目前除了在大量的库代码(如标准 C++库)中之外,并未被使用。由于你不可避免地会使用这些库,因此了解这些过时的特性很重要。

重要提示

动态异常指定(即在方法扩展签名中指定异常类型的能力)在 C++中已被弃用。这是因为编译器无法验证其使用,这必须延迟到运行时。尽管它们的使用仍然得到支持(许多库有这样的指定),但现在已被弃用。

既然我们已经看到了各种异常处理检测、抛出、捕获以及(希望)纠正方案,让我们来看看如何创建一个异常类层次结构,以增强我们的错误处理能力。

利用异常层次结构

创建一个封装与程序错误相关的详细信息的类似乎是一项有用的任务。实际上,C++标准库已经创建了一个这样的通用类,即exception,为构建整个有用的异常类层次结构提供基础。

让我们来看看exception类及其标准库派生类,然后探讨如何通过我们自己的类扩展exception

使用标准异常对象

<exception>头文件。exception类包含以下签名的虚拟函数:virtual const char *what() const noexceptvirtual const char *what() const throw()。这些签名表明派生类应该重新定义what()以返回一个描述当前错误的const char *what()后面的const关键字表明这些是const成员函数;它们不会改变派生类的任何成员。第一个原型中noexcept的使用表明what()是非抛出的。第二个原型扩展签名中的throw()表明此函数可能抛出任何类型。第二个签名中throw()的使用是一个过时的做法,不应在新代码中使用。

std::exception类是多种预定义的 C++异常类的基类,包括bad_allocbad_castbad_exceptionbad_function_callbad_typeidbad_weak_ptrlogic_errorruntime_error以及嵌套类ios_base::failure。其中许多派生类本身也有派生,为预定义的异常层次结构添加了额外的标准异常。

如果一个函数抛出上述任何异常,这些异常可以通过捕获基类类型exception或捕获单个派生类类型来捕获。根据您的处理程序将采取的行动,您可以决定是否希望将其作为其泛化基类类型或其特定类型捕获。

正如标准库已经基于exception类建立了一个类层次结构一样,您也可以这样做。接下来,让我们看看我们如何做到这一点!

创建自定义异常类

作为程序员,你可能会决定建立自己的专用异常类型是有益的。每种类型都可以将有关应用程序中发生什么错误的有用信息打包到对象中。此外,你还可以将有关如何纠正当前错误的线索打包到(将被抛出的)对象中。只需从标准库 exception 类派生你的类即可。

让我们通过检查下一个示例的关键部分来看看这有多容易做到,该示例作为一个完整的程序可以在我们的 GitHub 上找到:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter11/Chp11-Ex5.cpp

#include <iostream>
#include <exception>
// See online code for many using std:: inclusions
class StudentException: public exception
{
private:
    int errCode = 0;  // in-class init, will be over-
    // written with bonified value after successful 
    // alternate constructor completion
    string details;
public:
    StudentException(const string &det, int num):
                     errCode(num), details(det) { } 
    // Base class destructor (exception class) is virtual. 
    // Override at this level if there's work to do. 
    // We can omit the default destructor prototype.
    // ~StudentException() override = default;
    const char *what() const noexcept override
    {   // overridden function from exception class
        return "Student Exception";
    } 
    int GetCode() const { return errCode; }
    const string &GetDetails() const { return details; }
};
// Assume Student class is as we've seen before, but with
// one additional virtual member function, Graduate() 
void Student::Graduate() // fn. may throw StudentException
{
   // if something goes wrong, construct a 
   // StudentException, packing it with relevant data, 
   // and then throw it as a referenceable object
   throw StudentException("Missing Credits", 4);
}
int main()
{
    Student s1("Alexandra", "Doone", 'G', "Miss", 3.95, 
               "C++", "231GWU");
    try
    {
        s1.Graduate();
    }
    catch (const StudentException &e)  // catch exc. by ref
    { 
        cout << e.what() << endl;
        cout << e.GetCode() << " " << e.GetDetails();
        cout << endl;
        // Grab useful info from e and try to fix problem
        // so that the program can continue.
        exit(1);  // only exit if we can't fix the problem!
    }
    return 0;
}

让我们花几分钟时间检查之前的代码段。首先,请注意我们定义了自己的异常类,StudentException。它是从 C++ 标准库 exception 类派生的。

StudentException 类包含数据成员来存储错误代码以及使用数据成员 errCodedetails 分别描述错误条件的字母数字细节。我们有两个简单的访问函数,StudentException::GetCode()StudentException::GetDetails(),以便轻松检索这些值。由于这些方法不修改对象,它们是 const 成员函数。

我们注意到,StudentException 构造函数初始化了两个数据成员——一个通过成员初始化列表,一个在构造函数体中。我们还重写了 StudentException 类中的 virtual const char *what() const noexcept 方法(由 exception 类引入),以返回字符串 "Student Exception"

接下来,让我们检查我们的 Student::Graduate() 方法。此方法可能会抛出 StudentException 异常。如果必须抛出异常,我们将实例化一个异常,使用诊断数据构造它,然后从该函数中 throw StudentException 异常。请注意,在此方法中抛出的对象没有局部标识符——因为没有必要,因为任何这样的局部变量名在抛出后很快就会从栈上弹出。

在我们的 main() 函数中,我们将对 s1.Graduate() 的调用包裹在一个 try 块中,后面跟着一个 catch 块,该块接受一个对 StudentException 的引用(&),我们将其视为 const。在这里,我们首先调用我们重写的 what() 方法,然后从异常 e 中打印出诊断细节。理想情况下,我们会使用这些信息来尝试纠正当前错误,只有在真正必要时才退出应用程序。

让我们看看上述程序的输出:

Student Exception
4 Missing Credits

虽然从标准 exception 类派生一个类来创建自定义异常类是最常见的方式,但你可能也希望利用不同的技术,即嵌入异常类技术。

创建嵌套异常类

作为一种替代实现,异常处理可以通过在特定外部类的公共访问区域添加嵌套类定义来嵌入到类中。内部类将代表定制的异常类。

可以创建嵌套的用户定义类型对象并将其抛出,以便捕获器可以捕获这些类型。这些嵌套类被构建在外部类的公共访问区域,使得它们对于派生类的使用和特殊化很容易访问。一般来说,嵌入到外部类中的异常类必须是公共的,这样抛出的嵌套类型实例才能在外部类的范围之外(即主要的外部实例存在的范围)被捕获和处理。

让我们通过检查代码的关键部分来查看这个异常类的替代实现,完整的程序可以在我们的 GitHub 上找到:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter11/Chp11-Ex6.cpp

// Assume Student class is as before, but with the addition 
// of a nested exception class. All headers are as usual.
class Student: public Person
{
private:  // assume usual data members
public:   // usual constructors, destructor, and methods
    virtual void Graduate();
    class StudentException   // nested exception class
    {
    private:
        int number = 0;  // will be over-written after 
        // successful alternate constructor 
        // note: there is no default constructor
    public:
        StudentException(int num): number(num) { }
        // Remember, it is unnecessary to proto. default ~
        // ~StudentException() = default;
        int GetNum() const { return number; }
    };
};
void Student::Graduate()
{   // assume we determine an error and wish to throw
    // the nested exception type
    throw StudentException(5);
}
int main()
{
    Student s1("Ling", "Mau", 'I', "Ms.", 3.1, 
               "C++", "55UD");
    try
    {
        s1.Graduate();
    }
    // following is one of many catch blocks in online code
    catch (const Student::StudentException &err)
    {
        cout << "Error: " << err.GetNum() << endl;
        // If you correct error, continue the program
        exit(5);  // Otherwise, exit application 
    }
    cout << "Moving onward with remainder of code.";
    cout << endl;
    return 0;
}

在之前的代码片段中,我们将 Student 类扩展,包括一个名为 StudentException 的私有嵌套类。尽管显示的类过于简化,但嵌套类理想情况下应该定义一种方法来记录所讨论的错误,以及收集任何有用的诊断信息。

在我们的 main() 函数中,我们实例化了一个 Student 对象,即 s1。然后在 try 块中调用 s1.Graduate();。我们的 Student::Graduate() 方法可能检查 Student 是否满足毕业要求,如果没有,则抛出嵌套类类型的异常,即 Student::StudentException(根据需要实例化)。

注意,我们的相应 catch 块使用作用域解析来指定 err(引用的对象,即 const Student::StudentException &err)的内部类类型。尽管我们理想情况下希望在处理程序中纠正程序错误,如果我们不能这样做,我们只需打印一条消息并调用 exit()

让我们看看上述程序的输出:

Error: 5

理解如何创建我们自己的异常类(无论是作为嵌套类还是从 std::exception 派生)是有用的。我们可能还希望创建一个特定应用级别的异常层次结构。让我们继续前进,看看如何做到这一点。

创建用户定义异常类型的层次结构

应用程序可能希望定义一系列支持异常处理的类,以引发特定的错误,并希望提供一种收集错误诊断信息的方法,以便在代码的适当部分处理错误。

你可能希望创建一个从 C++ 标准库 exception 派生的子层次结构,以包含你自己的异常类。务必使用公有继承。当使用这些类时,你将实例化一个你想要的异常类型的对象(填充有有价值的诊断信息),然后抛出该对象。

此外,如果你创建了一个异常类型的层次结构,你的捕获器可以捕获特定的派生类类型或更一般的基类类型。选择权在你,取决于你将如何计划处理异常。然而,请记住,如果你同时有一个基类和派生类类型的捕获器,请将派生类类型放在前面——否则,你的抛出对象将首先匹配到基类类型的捕获器,而不会意识到有一个更合适的派生类匹配可用。

我们现在已经看到了 C++ 标准库异常类的层次结构,以及如何创建和使用你自己的异常类。现在,在我们继续前进到下一章之前,让我们简要回顾一下本章学到的异常特性。

摘要

在本章中,我们开始扩展我们的 C++ 编程库,不仅包括面向对象语言特性,还包括将使我们能够编写更健壮程序的特性。用户代码不可避免地具有错误倾向;使用语言支持的异常处理可以帮助我们实现更少错误和更可靠的代码。

我们已经看到了如何使用 trythrowcatch 核心异常处理特性。我们已经看到了这些关键字的各种用法——向外部处理程序抛出异常,使用各种类型的处理程序,例如,在单个 try 块内选择性地将程序元素分组在一起。我们已经看到了如何使用 set_terminate()set_unexpected() 注册我们自己的函数。我们已经看到了如何利用现有的 C++ 标准库 exception 层次结构。我们还探讨了定义我们自己的异常类以扩展这个层次结构。

通过探索异常处理机制,我们已经增加了我们的 C++ 技能的关键特性。我们现在准备向前推进到 第十二章友元和运算符重载,这样我们就可以继续使用有用的语言特性来扩展我们的 C++ 编程库,使我们成为更好的程序员。让我们继续前进!

问题

  1. 将异常处理添加到你的上一章 第十章实现关联、聚合和组合Student / University 练习中,如下所示:

    1. 如果一个 Student 尝试注册超过 MAX 定义的数量允许的课程,则抛出 TooFullSchedule 异常。这个类可能从标准库 exception 类派生。

    2. 如果一个Student试图报名一个已经满员的CourseCourse::AddStudent(Student *)方法应该抛出一个CourseFull异常。这个类可以继承自标准库的exception类。

    3. Student / University应用程序中还有许多其他区域可以利用异常处理。决定哪些区域应该使用简单的错误检查,哪些值得使用异常处理。

第十二章:友元与操作符重载

本章将继续我们扩展你的 C++编程知识库的追求,目标是编写更可扩展的代码。我们将接下来探索 C++中的友元函数友元类操作符重载。我们将了解操作符重载如何将操作符的使用扩展到标准类型之外,以与用户定义的类型保持一致,以及为什么这是一个强大的面向对象编程工具。我们将学习如何安全地使用友元函数和类来实现这一目标。

在本章中,我们将涵盖以下主要主题:

  • 理解友元函数和友元类,适当的使用理由以及增加其使用安全性的措施

  • 了解操作符重载的基本知识——如何以及为什么重载操作符,确保操作符在标准类型和用户定义类型之间是多态的

  • 实现操作符函数以及了解何时可能需要友元

到本章结束时,你将解锁友元的正确使用方法,并了解它们在利用 C++重载操作符的能力中的用途。尽管友元函数和类的使用可能被滥用,但你将坚持只在两个紧密耦合的类中仅限内部使用。你将了解正确使用友元如何增强操作符重载,使操作符能够扩展以支持用户定义的类型,从而与操作数关联操作。

让我们通过探索友元函数、友元类和操作符重载来扩展我们对 C++的理解。

技术要求

完整程序示例的在线代码可以在以下 GitHub URL 中找到:github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/tree/main/Chapter12。每个完整程序示例都可以在 GitHub 存储库中找到,位于相应章节标题(子目录)下的文件中,该文件以章节编号开头,后面跟着一个连字符,然后是本章中的示例编号。例如,本章的第一个完整程序可以在上述 GitHub 目录下的Chapter12子目录中找到,文件名为Chp12-Ex1.cpp

本章的 CiA 视频可在以下网址查看:bit.ly/3K0f4tb

理解友元类和友元函数

封装是 C++通过正确使用类和访问区域提供的有价值的面向对象编程(OOP)特性。封装提供了在处理数据和行为方面的统一性。一般来说,放弃类提供的封装保护是不明智的。

然而,在某些编程场景中,稍微打破封装性被认为比提供一个过于公开的类接口更可接受。也就是说,当一个类需要为两个类提供协作的方法时,尽管通常这些方法不适合公开访问。

让我们考虑一个可能导致我们考虑稍微放弃(即,打破)神圣的面向对象编程概念封装性的场景:

  • 可能存在两个紧密耦合的类,它们之间没有其他关系。一个类可能与其他类有一个或多个关联,并需要操作另一个类的成员。然而,允许访问这些成员的公共接口会使这些内部成员过于公开,并容易受到超出紧密耦合类对需求之外的操纵。

  • 在这种情况下,允许紧密耦合对中的其中一个类访问另一个类的成员,比在另一个类中提供一个允许比通常更安全地操作这些成员的公共接口更好。我们将很快看到如何最小化这种潜在的封装性损失。

  • 我们将很快看到的某些选定的运算符重载场景可能需要实例在类作用域之外的函数中访问其成员。再次强调,一个完全可访问的公共接口可能被认为是危险的。

友元函数友元类允许这种选择性的封装性打破。打破封装性是严重的,不应仅仅为了覆盖访问区域而进行。相反,当选择稍微打破两个紧密耦合类之间的封装性或提供一个过于公开的接口,该接口可能导致从应用程序的各个作用域对另一个类的成员有更大的、可能是不希望有的访问时,可以使用友元(并采取额外的安全措施)。

让我们看看每个如何使用,然后我们将添加我们应该坚持采用的相关安全措施。让我们从友元函数和友元类开始。

使用友元函数和友元类

友元函数是那些被个别授予扩展作用域以包括它们所关联的类的函数。让我们考察其影响和后勤:

  • 在友元函数的作用域内,关联类型的实例可以访问其自己的成员,就像它们在自己的类作用域内一样。

  • 一个友元函数需要在放弃访问权限(即扩展其作用域)的类的类定义中作为友元进行原型化。

  • 关键字friend用于提供访问的函数原型之前。

  • 函数重载友元函数不被视为友元。

友元类是那些其每个成员函数都是关联类的友元函数的类。让我们考察其后勤:

  • 友元类应该在提供访问其成员(即作用域)的类的类定义中有一个前向声明。

  • 关键字 friend 应该位于获得访问权限的类的前向声明之前(即,其作用域已被扩展)。

重要提示

友元类和友元函数应该谨慎使用,只有在选择性地稍微打破封装时,它才比提供一个过于公开的接口(即,一个在应用程序的任何作用域中都会提供对所选成员的不希望访问的公共接口)更好。

让我们从检查友元类和友元函数声明的语法开始。以下类不代表完整的类定义;然而,完整的程序可以在我们的 GitHub 仓库中找到,如下所示:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter12/Chp12-Ex1.cpp

class Student;  // forward declaration of Student class
class Id  // Partial class – full class can be found online
{
private:
    string idNumber;
    Student *student = nullptr;  // in-class initialization
public:  // Assume constructors, destructor, etc. exist
    void SetStudent(Student *);
    // all member fns. of Student are friend fns to/of Id
    friend class Student;
};
// Note: Person class is as often defined; see online code
class Student : public Person
{
private:
    float gpa = 0.0;    // in-class initialization
    string currentCourse;
    static int numStudents;
    Id *studentId = nullptr;
public:   // Assume constructors, destructor, etc. exist
    // only the following mbr fn. of Id is a friend fn.
    friend void Id::SetStudent(Student *); // to/of Student
};

在前面的代码片段中,我们首先注意到 Id 类中的一个友元类定义。语句 friend class Student; 表示 Student 中的所有成员函数都是 Id 的友元函数。这个包含性的声明代替了将 Student 类的每个函数都命名为 Id 的友元函数。

此外,在 Student 类中,请注意 friend void Id::SetStudent(Student *); 的声明。这个友元函数声明表示,只有 Id 的这个特定成员函数是 Student 的友元函数。

友元函数原型 friend void Id::SetStudent(Student *); 的含义是,如果一个 StudentId::SetStudent() 方法的范围内,那么这个 Student 可以像在自己的作用域内一样操作自己的成员,即 Student 的作用域。你可能想知道,哪个 Student 会发现自己处于 Id::SetStudent(Student *) 的作用域中?这很简单,就是作为输入参数传递给方法的那个。结果是,Id::SetStudent() 方法中的类型为 Student * 的输入参数可以像 Student 实例在自己的类作用域内一样访问其自己的私有和受保护的成员——它处于友元函数的作用域中。

类似地,Id 类中找到的友元类前向声明 friend class Student; 的含义是,如果任何 Id 实例发现自己处于 Student 方法中,那么这个 Id 实例可以像在自己的类中一样访问其自己的私有或受保护方法。Id 实例可能在其友元类 Student 的任何成员函数中,就像那些方法被扩展到也有 Id 类的作用域一样。

注意放弃访问权的类——即扩展范围的类——是宣布友情的类。也就是说,Id中的friend class Student;语句表示:如果任何Id恰好位于Student的任何成员函数中,允许该Id完全访问其成员,就像它在自己的作用域中一样。同样,Student中的友元函数语句表示,如果在这个特定的Id方法中找到一个Student实例(通过输入参数),它可以完全访问其元素,就像它在自己的类成员函数中一样。从增加作用域的角度考虑友情。

现在我们已经看到了友元函数和友元类的基本机制,让我们使用一个简单的协议来使其更具吸引力,以便选择性地破坏封装。

使用友元时,使访问更安全

我们已经看到,两个紧密耦合的类,例如通过关联相关联的类,可能需要稍微扩展它们的范围,以便通过使用友元函数友元类来选择性地包含彼此。另一种选择是提供一个公共接口来选择每个类的元素。然而,考虑到你可能不希望这些元素的公共接口在应用程序的任何范围内都可以统一访问,你可以使用。你真正面临的是一个艰难的选择:利用友元还是提供一个过于公开的接口。

虽然一开始使用友元可能会让你感到不适,但它可能比提供对类元素的不希望公开的接口更安全。

为了减轻你对友元允许的选择性破坏封装所感到的恐慌,考虑将以下协议添加到你的友元使用中:

  • 当使用友元时,为了减少封装的损失,一个类可以提供对另一个类数据成员的私有访问方法。考虑到这些方法是简单的访问方法(通常是单行方法,不太可能通过扩展增加软件膨胀),可以考虑将这些方法内联以提高效率。

  • 被讨论的实例应同意仅使用在友元函数的作用域内适当访问其所需成员而创建的私有访问方法。这种非正式的理解当然是一种绅士协议,而不是语言强加的。

以下是一个简单的示例,说明如何使用main()函数和几个方法来适当地使用两个紧密耦合的类,为了节省空间,没有显示所有方法,完整的示例可以在我们的 GitHub 仓库中找到,如下所示:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter12/Chp12-Ex2.cpp

using Item = int;  
class LinkList;  // forward declaration
class LinkListElement
{
private:
   void *data = nullptr;   // in-class initialization
   LinkListElement *next = nullptr;
   // private access methods to be used in scope of friend 
   void *GetData() const { return data; } 
   LinkListElement *GetNext() const { return next; }
   void SetNext(LinkListElement *e) { next = e; }
public:
// All member functions of LinkList are friend 
   // functions of LinkListElement 
   friend class LinkList;   
   LinkListElement() = default;
   LinkListElement(Item *i): data(i), next(nullptr) { }
   ~LinkListElement() { delete static_cast<Item *>(data); 
                        next = nullptr; }
};
// LinkList should only be extended as a protected/private
// base class; it does not contain a virtual destructor. It
// can be used as-is, or as implementation for another ADT.
class LinkList
{
private:
   LinkListElement *head = nullptr, *tail = nullptr, 
                   *current = nullptr;  // in-class init.
public:
   LinkList() = default;
   LinkList(LinkListElement *e) 
       { head = tail = current = e; }
   void InsertAtFront(Item *);
   LinkListElement *RemoveAtFront();  
   void DeleteAtFront()  { delete RemoveAtFront(); }
   bool IsEmpty() const { return head == nullptr; } 
   void Print() const;    // see online definition
   ~LinkList() { while (!IsEmpty()) DeleteAtFront(); }
};

让我们检查前面定义的LinkListElementLinkList类。注意,在LinkListElement类中,我们有三个私有成员函数:void *GetData();LinkListElement *GetNext();void SetNext(LinkListElement *);。这三个成员函数不应成为公共类接口的一部分。只有当这些方法在LinkList的范围内使用时才是合适的,LinkList是与LinkListElement紧密耦合的类。

接下来,注意在LinkListElement类中的friend class LinkList;前向声明。这个声明意味着LinkList的所有成员函数都是LinkListElement的友元函数。因此,任何发现自己处于LinkList方法中的LinkListElement实例可以简单地访问它们之前提到的私有GetData()GetNext()SetNext()方法,因为它们将处于友元类的范围内。

接下来,让我们看看前面代码中的LinkList类。类定义本身没有关于友元的独特声明。毕竟,是LinkListElement类扩大了其作用域以包括LinkedList类的方法,而不是相反。

现在,让我们看看LinkList类的两个选定的成员函数。这些方法的完整集合可以在之前提到的 URL 上找到:

void LinkList::InsertAtFront(Item *theItem)
{
   LinkListElement *newHead = new LinkListElement(theItem);
   // Note: temp can access private SetNext() as if it were
   // in its own scope – it is in the scope of a friend fn.
   newHead->SetNext(head);// same as: newHead->next = head;
   head = newHead;
}
LinkListElement *LinkList::RemoveAtFront()
{
   LinkListElement *remove = head;
   head = head->GetNext();  // head = head->next;
   current = head;    // reset current for usage elsewhere
   return remove;
}

在检查上述代码时,我们可以看到在LinkList方法的样本中,一个LinkListElement可以在友元函数(本质上是其自己的作用域的扩展)的作用域内调用其私有的方法。例如,在LinkList::InsertAtFront()中,LinkListElement *temp使用temp->SetNext(head)将其next成员设置为head。当然,我们也可以直接使用temp->next = head;来访问私有数据成员。然而,我们通过LinkListElement提供私有访问函数(如SetNext()),并要求LinkList方法(友元函数)让temp使用私有方法SetNext(),而不是直接操作数据成员本身,从而保持了一定的封装性。

由于LinkListElement中的GetData()GetNext()SetNext()是内联函数,我们通过提供封装访问成员datanext的感觉,并没有失去性能。

我们可以同样看到LinkList的其他成员函数,例如RemoveAtFront()(以及出现在在线代码中的Print())使用LinkListElement实例利用其私有访问方法,而不是允许LinkListElement实例直接获取它们的私有datanext成员。

LinkListElementLinkList 是两个紧密耦合的类的标志性例子,在这种情况下,最好扩展一个类以包含另一个类的访问范围,而不是提供一个过于公开的接口。毕竟,我们不想让 main() 中的用户能够接触到 LinkListElement 并应用 SetNext(),例如,这可能会在没有 LinkList 类知识的情况下改变整个 LinkedList

既然我们已经了解了友元函数和类的机制以及建议的用法,让我们探索另一种可能需要使用友元的语言特性——操作符重载。

解密操作符重载的基本原理

C++ 语言中包含多种操作符。C++ 允许大多数操作符被重新定义,以便包括与用户定义类型的用法;这被称为操作符重载。通过这种方式,用户定义的类型可以利用与标准类型相同的表示法来执行这些已知的操作。我们可以将重载的操作符视为多态的,因为它的相同形式可以与各种类型(标准类型和用户定义类型)一起使用。

并非所有操作符都可以在 C++ 中重载。以下操作符不能重载:成员访问操作符 (.)、三元条件操作符 (?:)、作用域解析操作符 (::)、成员指针操作符 (.*)、sizeof() 操作符和 typeid() 操作符。所有其余的操作符都可以重载,前提是至少有一个操作数是用户定义类型。

在重载操作符时,重要的是要传达操作符对标准类型所具有的相同含义。例如,当与 cout 一起使用时,提取操作符 (<<) 被定义为打印到标准输出。此操作符可以应用于各种标准类型,例如整数、浮点数、字符字符串等。如果为用户定义的类型(如 Student)重载提取操作符 (<<),它也应该意味着打印到标准输出。以这种方式,操作符 << 在输出缓冲区(如 cout)的上下文中是多态的;也就是说,它对所有类型具有相同的意义但不同的实现。

重要的是要注意,在 C++ 中重载操作符时,我们可能不能改变操作符在语言中出现的预定义优先级。这很有意义——我们并不是在重写编译器以不同方式解析和解释表达式。我们只是在扩展操作符的含义,从它与标准类型的用法扩展到包括与用户定义类型的用法。操作符优先级将保持不变。

一个操作符,后跟表示你想要重载的操作符的符号。

让我们看看操作符函数原型的简单语法:

Student &operator+(float gpa, const Student &s);

在这里,我们旨在提供一种使用 C++加法运算符(+)将浮点数和Student实例相加的方法。这种加法的意义可能是将新的浮点数与学生的现有平均成绩点数平均。在这里,运算符函数的名称是operator+()

在上述原型中,运算符函数不是任何类的成员函数。左操作数预期为float类型,右操作数为Student类型。函数的返回类型(Student &)允许我们使用多个操作数级联使用+,或者与多个运算符配对,例如s1 = 3.45 + s2;。整体概念是,只要至少有一个操作数是用户定义类型,我们就可以定义如何使用+与多个类型一起使用。

实际上,涉及的内容远不止前面原型中显示的简单语法。在我们全面检查详细示例之前,让我们首先看看更多与实现运算符函数相关的后勤问题。

实现运算符函数以及知道何时可能需要友元

运算符函数,重载运算符的机制,可以作为一个成员函数或作为一个常规的外部函数实现。让我们以下列关键点总结实现运算符函数的机制:

  • 作为成员函数实现的运算符函数将接收一个隐式参数(this指针),最多还有一个显式参数。如果重载操作中的左操作数是用户定义类型,并且可以轻松修改该类,则将运算符函数实现为成员函数是合理且首选的。

  • 作为外部函数实现的运算符函数将接收一个或两个显式参数。如果重载操作中的左操作数是标准类型或不可修改的类类型,则必须使用外部(非成员)函数来重载此运算符。这个外部函数可能需要成为任何用作右手函数参数的对象类型的friend

  • 运算符函数通常应该相互实现。也就是说,在重载二元运算符时,确保它已经定义为无论数据类型(如果它们不同)在运算符中出现的顺序如何都能工作。

让我们看看一个完整的程序示例,以说明运算符重载的机制,包括成员函数和非成员函数,以及需要使用友元的场景。尽管为了节省空间,已经排除了程序的一些知名部分,但完整的程序示例可以在我们的 GitHub 仓库中找到:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter12/Chp12-Ex3.cpp

// Assume usual header files and std namespace inclusions
class Person
{
private: 
    string firstName, lastname;
    char middleInitial = '\0';
    char *title = nullptr; // use ptr member to demonstrate
                           // deep assignment
protected:
    void ModifyTitle(const string &); // converts to char *
public:                               
    Person() = default;   // default constructor
    Person(const string &, const string &, char, 
           const char *);  
    Person(const Person &);  // copy constructor
    virtual ~Person();  // virtual destructor
    const string &GetFirstName() const 
        { return firstName; }  
    const string &GetLastName() const { return lastName; }    
    const char *GetTitle() const { return title; } 
    char GetMiddleInitial() const { return middleInitial; }
    virtual void Print() const;
    virtual void IsA() const;
    // overloaded operator functions
    Person &operator=(const Person &); // overloaded assign
    bool operator==(const Person &);   // overloaded
                                       // comparison
    Person &operator+(const string &); // overloaded plus
    // non-mbr friend fn. for op+ (to make associative)
    friend Person &operator+(const string &, Person &);  
};

让我们从查看Person类的先前定义开始,检查我们的代码。除了我们习惯看到的类元素之外,我们还有四个原型化的运算符函数:operator=(), operator==(), 和 operator+()(它被实现两次,以便+的运算数可以反转)。

operator=(), operator==()operator+()的一个版本将被实现为这个类的成员函数,而另一个operator+(),具有const char *Person参数,将被实现为一个非成员函数,并且还需要使用友元函数。

赋值运算符重载

让我们继续前进,检查这个类的适用运算符函数定义,首先从重载赋值运算符开始:

// Assume the required constructors, destructor and basic
// member functions prototyped in the class def. exist.
// overloaded assignment operator
Person &Person::operator=(const Person &p)
{
    if (this != &p)  // make sure we're not assigning an 
    {                // object to itself
        // delete any previously dynamically allocated data 
        // from the destination object
        delete title;
        // Also, remember to reallocate memory for any 
        // data members that are pointers
        // Then, copy from source to destination object 
        // for each data member
        firstName = p.firstName;
        lastName = p.lastName;
        middleInitial = p.middleInitial;
        // Note: a pointer is used for title to demo the
        // necessary steps to implement a deep assignment -
        // otherwise, we would implement title with string
        title = new char[strlen(p.title) + 1]; // mem alloc
        strcpy(title, p.title);
    }
    return *this;  // allow for cascaded assignments
}

现在我们来回顾一下前面代码中重载的赋值运算符。它由成员函数Person &Person::operator=(const Person &p);指定。在这里,我们将从源对象分配内存,该源对象将是输入参数p,到目标对象,该对象将由this指向。

我们的首要任务是确保我们不是将对象赋值给自己。如果是这种情况,就没有工作要做!我们通过测试if (this != &p)来检查两个地址是否指向同一个对象。如果不是将对象赋值给自己,我们继续。

接下来,在条件语句(if)内部,我们首先释放由this指向的动态分配的数据成员所使用的现有内存。毕竟,赋值运算符左侧的对象是预先存在的,并且无疑为这些数据成员进行了分配。

现在,我们注意到条件语句中的核心代码看起来与复制构造函数非常相似。也就是说,我们仔细分配空间给指针数据成员,以匹配从输入参数p的相应数据成员所需的大小。然后,我们将输入参数p的适用数据成员复制到由this指向的数据成员。对于char数据成员middleInitial,不需要内存分配;我们只是使用赋值。对于string数据成员firstNamelastName也是如此。在这段代码中,我们确保我们对任何指针数据成员执行了深度赋值。浅拷贝(指针赋值),其中源对象和目标对象本应共享数据成员指针的数据部分内存,将会是一个即将发生的灾难。

最后,在我们的 operator=() 实现的末尾,我们返回 *this。请注意,此函数的返回类型是 Person 的引用。由于 this 是一个指针,我们只需取消引用它,以便返回一个可引用的对象。这样做是为了使 Person 实例之间的赋值可以级联;也就是说,p1 = p2 = p3; 其中 p1p2p3 分别是 Person 的实例。

注意

当重载 operator= 时,始终检查自赋值。也就是说,确保你不会将一个对象赋值给自己。在自赋值的情况下,实际上没有工作要做,但继续进行不必要的自赋值实际上可能会产生意外的错误!例如,如果我们有动态分配的数据成员,我们将释放目标对象的内存,并根据源对象内存的细节重新分配这些数据成员(当它们是同一个对象时,这些内存已经被释放)。这种行为可能是不可预测的,并且容易出错。

如果程序员希望禁止两个对象之间的赋值,可以在重载赋值运算符的原型中使用 delete 关键字,如下所示:

    // disallow assignment
    Person &operator=(const Person &) = delete;

记住,重载的赋值运算符与拷贝构造函数有很多相似之处;对这两种语言特性都应采取相同的谨慎和注意。然而,请注意,赋值运算符将在进行两个现有对象之间的赋值时被调用,而拷贝构造函数是在创建新实例后隐式调用的初始化。在拷贝构造函数中,新实例使用现有实例作为其初始化的基础;同样,赋值运算符的左侧对象使用右侧对象作为其赋值的基础。

重要提示

重载的赋值运算符不会被派生类继承;因此,它必须由层次结构中的每个类定义。忽略为类重载 operator= 将迫使编译器为该类提供一个默认的浅拷贝赋值运算符;这对于包含指针数据成员的任何类都是危险的。

赋值运算符重载

接下来,让我们看看我们对重载的赋值运算符的实现:

// overloaded comparison operator
bool Person::operator==(const Person &p)
{   
    // if the objects are the same object, or if the
    // contents are equal, return true. Otherwise, false.
    if (this == &p) 
        return true;
    else if ( (!firstName.compare(p.firstName)) &&
              (!lastName.compare(p.lastName)) &&
              (!strcmp(title, p.title)) &&
              (middleInitial == p.middleInitial) )
        return true;
    else
        return false;
}

继续使用我们之前的程序段,我们重载比较运算符。它由成员函数 int Person::operator==(const Person &p); 指定。在这里,我们将比较运算符右侧的 Person 对象,该对象将通过输入参数 p 进行引用,与运算符左侧的 Person 对象进行比较,该对象将由 this 指向。

同样,我们的首要任务是测试 if (this != &p) 中的对象是否指向同一个对象。如果两个地址都指向同一个对象,我们返回布尔值 (bool) 的 true

接下来,我们检查两个 Person 对象是否包含相同的值。它们可能在内存中是分离的对象,但如果它们包含相同的值,我们同样可以选择返回 bool 值为 true。如果没有匹配,我们则返回 bool 值为 false

将加法操作符作为成员函数重载

现在,让我们看看如何为 Personstring 重载 operator+

// overloaded operator + (member function)
Person &Person::operator+(const string &t)
{
    ModifyTitle(t);
    return *this;
}

继续使用前面的程序,我们将加法操作符 (+) 重载以与 Personstring 一起使用。操作符函数由成员函数原型 Person& Person::operator+(const string &t); 指定。参数 t 将代表 operator+ 的右操作数,它是一个字符字符串(它将绑定到一个字符串的引用)。左操作数将由 this 指向。一个可能的用法是 p1 + "Miss",其中我们希望使用 operator+Person p1 添加一个 title

在这个成员函数的主体中,我们仅仅将输入参数 t 作为 ModifyTitle() 的参数使用,即 ModifyTitle(t);。然后我们返回 *this 以便我们可以级联使用这个操作符(注意返回类型是 Person &)。

将加法操作符作为非成员函数重载(使用友元)

现在,让我们使用 operator+ 反转操作数的顺序,以便使用 stringPerson

// overloaded + operator (not a mbr function) 
Person &operator+(const string &t, Person &p)
{
    p.ModifyTitle(t);
    return p;
}

继续使用前面的程序,我们希望 operator+ 不仅与 Personstring 一起工作,而且与操作数顺序相反,即与 stringPerson 一起工作。没有理由这个操作符应该以一种方式工作,而不是另一种方式。

要完全实现 operator+,我们接下来重载 operator+() 以与 const string &Person 一起使用。操作符函数由非成员函数 Person& operator+(const string &t, Person &p); 指定,它有两个显式输入参数。第一个参数 t 将代表 operator+ 的左操作数,它是一个字符串(将此参数绑定到操作符函数中的第一个形式参数的字符串引用)。第二个参数 p 将是 operator+ 中使用的右操作数的引用。一个可能的用法是 "Miss" + p1,其中我们希望使用 operator+Person p1 添加一个头衔。注意,"Miss" 将使用 std::string(const char *) 构造函数构造为一个 string——字符串字面量只是字符串对象的初始值。

在这个非成员函数的函数体内,我们仅仅接受输入参数 p 并使用参数 t 指定的字符串字符调用受保护的 ModifyTitle() 方法,即 p.ModifyTitle(t)。然而,因为 Person::ModifyTitle() 是受保护的,Person &p 可能不能在 Person 的成员函数之外调用此方法。我们处于外部函数中;我们不在 Person 的作用域内。因此,除非这个成员函数是 Personfriend,否则 p 不能调用 ModifyTitle()。幸运的是,Person &operator+(const string &, Person &); 已经在 Person 类中作为友元函数原型,为 p 提供了必要的范围,允许它调用其受保护的方法。这就像 pPerson 的作用域内;它是在 Person 的友元函数的作用域内!

让我们继续前进到我们的 main() 函数,将我们之前提到的许多代码段结合起来,以便我们可以看到如何使用重载运算符调用我们的运算符函数:

int main()
{
    Person p1;      // default constructed Person
    Person p2("Gabby", "Doone", 'A', "Miss");
    Person p3("Renee", "Alexander", 'Z', "Dr.");
    p1.Print();
    p2.Print();
    p3.Print();  
    p1 = p2;       // invoke overloaded assignment operator
    p1.Print();
    p2 = "Ms." + p2;  // invoke overloaded + operator
    p2.Print();       // then invoke overloaded = operator
    p1 = p2 = p3;     // overloaded = can handle cascaded =
    p2.Print();     
    p1.Print();
    if (p2 == p2)   // overloaded comparison operator
       cout << "Same people" << endl;
    if (p1 == p3)
       cout << "Same people" << endl;
   return 0;
}

最后,让我们检查前面程序中的 main() 函数。我们首先实例化三个 Person 实例,即 p1p2p3;然后使用每个实例的成员函数 Print() 打印它们的值。

现在,我们用语句 p1 = p2; 调用我们的重载赋值运算符。在底层,这翻译成以下运算符函数调用:p1.operator=(p2);。从这我们可以清楚地看到,我们正在调用之前定义的 operator=() 方法,该方法从源对象 p2 深度复制到目标对象 p1。我们使用 p1.Print(); 来查看我们的复制结果。

接下来,我们用 "Ms." + p2 调用我们的重载 operator+。这一行代码的这一部分翻译成以下运算符函数调用:operator+("Ms.", p2);。在这里,我们简单地调用之前描述的 operator+() 函数,这是一个非成员函数,也是 Person 类的 friend。因为此函数返回一个 Person &,我们可以级联这个函数调用,使其看起来更像通常的加法上下文,并额外写出 p2 = "Ms." + p2;。在这整行代码中,首先调用 operator+()"Ms." + p2。这个调用的返回值是 p2,然后被用作级联调用 operator= 的右操作数。注意,operator= 的左操作数也恰好是 p2。幸运的是,重载的赋值运算符检查自赋值。

现在,我们看到一个级联赋值 p1 = p2 = p3;。在这里,我们两次调用了重载的赋值运算符。首先,我们用 p2p3 调用 operator=。翻译后的调用将是 p2.operator=(p3);。然后,使用第一次函数调用的返回值,我们再次调用 operator=。对于 p1 = p2 = p3; 的嵌套翻译调用看起来像这样:p1.operator=(p2.operator=(p3));

最后在这个程序中,我们调用了两次重载的比较操作符。例如,每次比较if (p2 == p2)if (p1 == p3)仅仅调用我们之前定义的operator==成员函数。回想一下,我们编写这个函数是为了报告对象在内存中相同或简单地包含相同的值时返回true,否则返回false

让我们看看这个程序的输出:

No first name No last name
Miss Gabby A. Doone
Dr. Renee Z. Alexander
Miss Gabby A. Doone
Ms. Gabby A. Doone
Dr. Renee Z. Alexander
Dr. Renee Z. Alexander
Same people
Same people

我们现在已经看到了如何指定和利用友元类和友元函数,如何重载 C++操作符,以及这些概念可以相互补充的情况。在我们继续前进到下一章之前,让我们简要回顾一下本章我们学到的特性。

摘要

在本章中,我们将 C++编程努力扩展到了面向对象语言特性之外,包括将使我们能够编写更可扩展程序的功能。我们已经学习了如何利用友元函数和友元类,我们也学习了如何在 C++中重载操作符。

我们已经看到,应该谨慎且少量地使用友元函数和类。它们不是为了提供一种明显的绕过访问区域的方法。相反,它们是为了处理编程情况,允许两个紧密耦合的类之间进行访问,而不在任一类中提供过于公开的接口,这可能在更广泛的范围内被滥用。

我们已经看到了如何使用操作符函数在 C++中重载操作符,无论是作为成员函数还是非成员函数。我们已经了解到,重载操作符将允许我们扩展 C++操作符的意义,使其包括用户定义的类型,就像它们包含标准类型一样。我们还看到,在某些情况下,友元函数或类可能很有用,可以帮助实现操作符函数,以便它们可以关联地行为。

通过探索友元和操作符重载,我们已经为我们的 C++工具箱添加了重要功能,后者将帮助我们确保我们很快将使用模板编写的代码可以用于几乎任何数据类型,从而有助于高度可扩展和可重用的代码。我们现在可以继续前进到第十三章使用模板,这样我们就可以继续使用基本语言特性来扩展我们的 C++编程技能,这些特性将使我们成为更好的程序员。让我们继续前进吧!

问题

  1. 第八章掌握抽象类Shape练习中重载operator=,或者,也可以在你的正在进行的LifeForm/Person/Student类中重载operator=,如下所示:

Shape(或LifeForm)中定义operator=,并在所有其派生类中重写此方法。提示:派生类中operator=()的实现将比其祖先做更多的工作,但仍可以调用祖先的实现来执行基类的工作部分。

  1. 在你的 Shape 类(或 LifeForm 类)中重载 operator<< 操作符以打印每个 Shape(或 LifeForm)的信息。此函数的参数应该是一个 ostream & 和一个 Shape &(或一个 LifeForm &)。注意,ostream 来自 C++ 标准库(using namespace std;)。

你可以提供一个函数,ostream &operator<<(ostream &, Shape &);,并从该函数中调用在 Shape 中定义并在每个派生类中重新定义的多态 Print(),或者提供多个 operator<< 方法来实现此功能(每个派生类一个)。如果使用 Lifeform 层次结构,在上述 operator<< 函数签名中将 LifeForm 替换为 Shape

  1. 创建一个 ArrayInt 类以提供具有边界检查的安全整数数组。重载 operator[] 以返回数组中存在的元素,或者在元素不存在时抛出 OutOfBounds 异常。向 ArrayInt 添加其他方法,例如 Resize()RemoveElement() 等。使用动态分配的数组(即使用 int *contents)来表示数组中的数据,这样你可以轻松地处理调整大小。代码可以从以下内容开始:

    class ArrayInt // starting point for the class def.
    {          // be sure to add: using std::to_string;
    private:   // and also: using std::out_of_range;
        int numElements = 0;     // in-class init.
        int *contents = nullptr; // dynam. alloc. array
    public:
        ArrayInt(int size); // set numElements and
                            // allocate contents
        // returns a referenceable memory location or
        // throws an exception
        int &operator[](int index) 
        {             
            if (index < numElements) 
                return contents[index];
            else    // index is out of bounds
                throw std::out_of_range(
                                  std::to_string(index));
        }                        
    };
    int main()
    {
        ArrayInt a1(5); // Create ArrayInt of 5 elements
        try
        {
            a1[4] = 7;      // a1.operator[](4) = 7;
        }
        catch (const std::out_of_range &e)
        {
            cout << "Out of range: " << e.what() << endl;
        }
    }
    

第十三章:与模板一起工作

本章将继续我们提高 C++编程知识库的追求,超越面向对象的概念,并继续以编写更可扩展的代码为目标。我们将接下来探索使用 C++模板创建泛型代码——包括模板函数模板类。我们将学习如何正确编写模板代码,使其成为代码重用的顶峰。除了探索如何创建模板函数和模板类之外,我们还将了解适当使用运算符重载如何使模板函数对几乎所有类型的数据都具有可重用性。

在本章中,我们将涵盖以下主要主题:

  • 探索模板基础以泛化代码

  • 理解如何创建和使用模板函数和模板类

  • 理解运算符重载如何使模板更易于扩展

许多面向对象的语言包括使用泛型的编程概念,允许类和接口的类型自身被参数化。在一些语言中,泛型仅仅是用于将对象转换为所需类型的包装器。在 C++中,泛型的概念更为全面,并且通过模板来实现。

到本章结束时,你将能够通过构建模板函数和模板类来设计更通用的代码。你将了解运算符重载如何确保模板函数可以针对任何数据类型进行高度扩展。通过将精心设计的模板成员函数与运算符重载相结合,你将能够在 C++中创建高度可重用和可扩展的模板类。

让我们通过探索模板来扩展我们的编程知识,以加深对 C++的理解。

技术要求

完整程序示例的在线代码可以在以下 GitHub URL 中找到:github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/tree/main/Chapter13。每个完整的程序示例都可以在 GitHub 仓库中找到,位于相应章节标题(子目录)下的文件中,该文件以章节编号开头,后面跟着一个连字符,然后是当前章节中的示例编号。例如,本章的第一个完整程序可以在上述 GitHub 目录下的Chapter13子目录中找到,文件名为Chp13-Ex1.cpp

本章的 CiA 视频可以在以下链接查看:bit.ly/3A7lx0U

探索模板基础以泛化代码

模板允许以抽象数据类型的方式在相关函数或类中指定代码,这些数据类型主要用于相关函数或类。创建模板的动机是为了泛型指定我们反复想要利用的函数和类的定义,但数据类型可以不同。这些组件的个性化版本在核心数据类型上可能有所不同;然后可以从这些关键数据类型中提取并泛型编写。

当我们选择使用此类或函数的特定类型时,而不是从类似类或函数(具有预置的数据类型)复制粘贴现有代码并稍作修改,预处理器会取模板代码并将其展开为我们请求的、真正的类型。这种模板展开能力允许程序员只编写和维护一个泛型化代码版本,而不是需要编写的许多特定类型版本的代码。好处是,预处理器将比我们使用复制、粘贴和轻微修改方法做得更精确地展开模板代码到真正的类型。

让我们花点时间进一步研究在代码中使用模板的动机。

检查模板的动机

假设我们希望创建一个类来安全地处理动态分配的数组,数据类型为int,就像我们在第十二章问题 3的解决方案中所创建的那样,朋友和运算符重载。我们的动机可能是有一种可以增长或缩小的数组类型(与原生的固定大小数组不同),同时具有边界检查以确保安全使用(与使用int *实现的原始动态数组操作不同,这会无耻地允许我们访问超出动态数组分配长度的元素)。

我们可能决定创建一个ArrayInt类,以下是其初始框架:

class ArrayInt
{
private: 
    int numElements = 0;     // in-class initialization
    int *contents = nullptr; // dynamically allocated array
public:
    ArrayInt(int size): numElements(size) 
    { 
        contents = new int [size];
    }
    ~ArrayInt() { delete [] contents; }       
    int &operator[](int index) // returns a referenceable
    {                // memory location or throws exception
        if (index < numElements) 
            return contents[index];
        else         // index selected is out of bounds
            throw std::out_of_range(std::to_string(index));
    }                                
};
int main()
{
    ArrayInt a1(5); // Create an ArrayInt of 5 elements
    try    // operator[] could throw an exception
    {
        a1[4] = 7;      // a1.operator[](4) = 7;
    }
    catch (const std::out_of_range &e)
    {
        cout << "Out of range: element " << e.what();
        cout << endl;
    }
}   

在前面的代码段中,请注意,我们的ArrayInt类使用int *contents实现数组的数据结构,该结构在构造函数中动态分配到所需的大小。我们已经重载了operator[],以确保只返回数组中正确的范围内的索引值,否则抛出std::out_of_range异常。我们可以添加Resize()方法到ArrayInt等。总的来说,我们喜欢这个类的安全性和灵活性。

现在,我们可能想要有一个 ArrayFloat 类(或者稍后,一个 ArrayStudent 类)。与其复制我们的基线 ArrayInt 类并稍作修改来创建一个 ArrayFloat 类,例如,我们可能会问是否有更自动化的方式来完成这种替换。毕竟,在创建一个以 ArrayInt 类为起点的 ArrayFloat 类时,我们会改变什么?我们会改变数据成员 contents类型 – 从 int *float *。我们会在构造函数中的内存分配中将 contents = new int [size]; 改为使用 float 而不是 int(在任何实际重新分配中也是如此,例如在 Resize() 方法中)。

与其复制、粘贴并稍微修改一个 ArrayInt 类来创建一个 ArrayFloat 类,我们完全可以简单地使用一个 模板类 来泛化这个类内部操作的数据的 类型。同样,任何依赖于特定数据类型的函数都将成为 模板函数。我们将在稍后考察创建和使用模板的语法。

使用模板,我们可以创建一个名为 Array 的单一模板类,其中类型被泛化。在编译时,如果预处理器检测到我们在代码中使用了类型 intfloat 的这个类,预处理器将为我们提供必要的模板 展开。也就是说,通过复制和粘贴(在幕后)每个模板类(及其方法),并用预处理器识别的我们正在使用的数据类型进行替换。

扩展后的代码,一旦在底层展开,并不比我们亲自为每个类编写代码更小。但重点是,我们不必费力地创建、修改、测试,并在以后维护每个略有不同的类。这是 C++ 为我们做的事情。这就是模板类和模板函数值得注意的目的。

模板不仅限于与原始数据类型一起使用。例如,我们可能希望创建一个 Array,其类型是用户定义的,如 Student。我们需要确保所有模板成员函数对我们实际扩展模板类以使用的所有数据类型都是有意义的。我们可能需要重载选定的运算符,以便我们的模板成员函数可以像与原始类型一样无缝地与用户定义的类型一起工作。

在本章后面,我们将看到一个示例,说明如果我们选择扩展模板类以用于用户定义的类型,我们可能需要重载哪些选定的运算符,以便类的成员函数可以流畅地与任何数据类型一起工作。幸运的是,我们知道如何重载运算符!

让我们继续前进,探索指定和使用模板函数和模板类的机制。

理解模板函数和类

模板提供了通过抽象与这些函数和类关联的数据类型来创建泛型函数和类的能力。模板函数和类都可以被精心编写,以便泛化这些函数和类所依赖的相关数据类型。

让我们先来探讨如何创建和使用模板函数。

创建和使用模板函数

模板函数除了参数化函数的参数本身之外,还参数化了函数的参数类型。模板函数要求函数体适用于几乎任何数据类型。模板函数可以是成员函数或非成员函数。运算符重载可以帮助确保模板函数的主体适用于用户定义的类型——我们很快就会看到更多关于这一点的内容。

关键字template,以及尖括号< >和类型名称的占位符,用于指定模板函数及其原型。

让我们看看一个不属于类成员的模板函数(我们很快就会看到模板成员函数的例子)。这个例子作为一个完整的可运行程序,可以在我们的 GitHub 仓库中找到,如下所示:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter13/Chp13-Ex1.cpp

// template function prototype
template <class Type1, class Type2>   // template preamble
Type2 ChooseFirst(Type1, Type2);
// template function definition
template <class Type1, class Type2>  // template preamble
Type2 ChooseFirst(Type1 x, Type2 y)
{
    if (x < y) 
        return static_cast<Type2>(x);
    else 
        return y; 
}   
int main()
{
    int value1 = 4, value2 = 7;
    float value3 = 5.67f;
    cout << "First: " << ChooseFirst(value1, value3); 
    cout << endl;
    cout << "First: " << ChooseFirst(value2, value1); 
    cout << endl;
}

看看之前的函数示例,我们首先看到一个模板函数原型。template <class Type1, class Type2>的声明表明,这个原型将是一个模板原型,并且将使用占位符Type1Type2而不是实际的数据类型。占位符Type1Type2可以是(几乎)任何遵循标识符创建规则的名称。

然后,为了完成原型,我们看到Type2 ChooseFirst(Type1, Type2);,这表明这个函数的返回类型将是Type2,并且ChooseFirst()函数的参数将是Type1Type2(当然,它们也可以是相同类型)。

接下来,我们看到函数的定义。它同样以template <class Type1, class Type2>的声明开始。与原型类似,函数头Type2 ChooseFirst(Type1 x, Type2 y)表明形式参数xy分别是Type1Type2类型。这个函数的主体相当直接。我们只需通过使用<运算符进行简单的比较,来确定两个参数中哪一个应该在两个值排序中排在前面。

现在,在 main() 函数中,当编译器的预处理部分看到对 ChooseFirst() 的调用,并带有实际参数 int value1float value3 时,预处理程序会注意到 ChooseFirst() 是一个模板函数。如果还没有这样的 ChooseFirst() 版本来处理 intfloat 类型,预处理程序会复制这个模板函数,并将 Type1 替换为 int,将 Type2 替换为 float —— 代表我们创建适合我们需求的这个函数的适当版本。注意,当调用 ChooseFirst(value2, value1) 并且类型都是整数时,模板函数在预处理程序再次在我们的代码中(在幕后)展开时,Type1Type2 的占位符类型都将被替换为 int

虽然 ChooseFirst() 是一个简单的函数,但通过它我们可以看到创建泛化关键数据类型的模板函数的直接机制。我们还可以看到预处理程序如何注意到模板函数的使用,并代表我们承担起根据我们的特定类型使用情况展开这个函数的努力。

让我们看看这个程序的输出:

First: 4
First: 4

现在我们已经了解了模板函数的基本机制,让我们继续前进,了解我们如何扩展这个过程以包括模板类。

创建和使用模板类

模板类参数化了类定义的最终类型,并且还需要为任何需要知道正在操作的核心数据类型的任何方法提供模板成员函数。

关键字 templateclass,以及尖括号 < > 和类型名称的占位符,用于指定模板类定义。

让我们看看一个模板类定义及其支持的模板成员函数。这个例子可以作为完整的程序(包含必要的 #includeusing 语句)在我们的 GitHub 仓库中找到,如下所示:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter13/Chp13-Ex2.cpp

template <class Type>   // template class preamble
class Array
{
private:
    int numElements = 0;   // in-class initialization
    Type *contents = nullptr;// dynamically allocated array
public:
    // Constructor and destructor will allocate, deallocate
    // heap memory to allow Array to be fluid in its size.
    // Later, you can use a smart pointer – or use the STL
    // vector class (we're building a similar class here!)
    Array(int size): numElements(size), 
                     contents(new Type [size])
    { // note: allocation is handled in member init. list
    }
    ~Array() { delete [] contents; }  
    void Print() const;     
    Type &operator[](int index) // returns a referenceable
    {               // memory location or throws exception
        if (index < numElements) 
            return contents[index];
        else   // index is out of bounds
            throw std::out_of_range
                             (std::to_string (index));    
    }                                
    void operator+(Type);   // prototype only
};
template <class Type>
void Array<Type>::operator+(Type item)  
{
    // resize array as necessary, add new data element and
    // increment numElements
}
template <class Type>
void Array<Type>::Print() const
{
    for (int i = 0; i < numElements; i++)
        cout << contents[i] << " ";
    cout << endl;
}
int main()
{                    
    // Creation of int Array will trigger template
    // expansion by the preprocessor.
    Array<int> a1(3); // Create an int Array of 3 elements
    try    // operator[] could throw an exception
    {
        a1[2] = 12;      
        a1[1] = 70;       // a1.operator[](1) = 70;
        a1[0] = 2;
        a1[100] = 10;// this assignment throws an exception
    }
    catch (const std::out_of_range &e)
    {
        cout << "Out of range: index " << e.what() << endl;
    } 
    a1.Print();
}   

在前面的类定义中,我们首先注意到 template <class Type> 的模板类前缀。这个前缀指定即将到来的类定义将是一个模板类,并且占位符 Type 将用于泛化在这个类中主要使用的数据类型。

我们接下来看到Array类的定义。数据成员contents将是Type类型的占位符。当然,并不是所有数据类型都需要泛型化。数据成员int numElements作为一个整数是完全可以接受的。接下来,我们看到一系列成员函数的原型定义和一些内联定义,包括重载的operator[]。对于内联定义的成员函数,在函数定义前不需要模板声明。我们只需要对内联函数进行泛型化,使用我们的占位符Type

现在,让我们看看选定的成员函数。在构造函数中,我们注意到contents = new Type [size];的内存分配仅仅使用了占位符Type代替实际的数据类型。同样,对于重载的operator[],这个方法的返回类型也是Type

然而,当我们查看一个非内联的成员函数时,我们注意到template <class Type>的模板声明必须位于成员函数定义之前。例如,让我们考虑void Array<Type>::operator+(Type item);的成员函数定义。除了声明之外,在函数定义中,类名(在成员函数名称和作用域解析运算符::之前)必须增加包括占位符类型<Type>的尖括号。此外,任何泛型函数参数都必须使用Type的占位符类型。

现在,在我们的main()函数中,我们仅仅使用Array<int>的数据类型来实例化一个安全、易于调整大小的整数数组。如果我们想实例化一个浮点数数组,我们可以使用Array<float>。在底层,当我们创建特定数组类型的实例时,预处理器会注意到我们是否已经为该类型扩展了此类。如果没有,类定义和相关的模板成员函数会为我们复制,并且占位符类型会被替换为我们需要的类型。这并不比我们自己复制、粘贴并稍作修改代码少;然而,重点是,我们只需要指定和维护一个版本。这更少出错,并且更容易进行长期维护。

让我们看看这个程序的输出:

2 70 12

一个有趣的话题——std::optional

在前面的示例中,Array<Type>::operator[]在所选索引超出范围时抛出out_of_range异常。有时,异常处理可能具有程序上的成本。在这种情况下,使用可选返回类型可能是一个有用的替代方案。记住,operator[]的有效返回值是对相关数组元素内存位置的引用。对于超出范围的索引场景,我们知道我们无法从这个方法中返回数组元素的相应内存位置(这没有意义),因此异常处理的替代方案可能是使用std::optional<Type>作为函数的返回值。

让我们接下来看看一个不同的完整程序示例,以结合模板函数和模板类。

检查一个完整程序示例

看到一个额外的示例,说明模板函数和模板类是有用的。让我们扩展我们在第十二章中最近审查的LinkList程序,友元和运算符重载;我们将升级此程序以利用模板。

这个完整的程序可以在我们的 GitHub 仓库中找到,如下所示:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter13/Chp13-Ex3.cpp

#include <iostream>
using std::cout;   // preferred to: using namespace std;
using std::endl;
// forward declaration with template preamble
template <class Type> class LinkList;  
template <class Type>   // template preamble for class def.
class LinkListElement
{
private:
    Type *data = nullptr;
    LinkListElement *next = nullptr;
    // private access methods to be used in scope of friend
    Type *GetData() const { return data; } 
    LinkListElement *GetNext() const { return next; }
    void SetNext(LinkListElement *e) { next = e; }
public:
    friend class LinkList<Type>;   
    LinkListElement() = default;
    LinkListElement(Type *i): data(i), next(nullptr) { }
    ~LinkListElement(){ delete data; next = nullptr; }
};
// LinkList should only be extended as a protected/private
// base class; it does not contain a virtual destructor. It
// can be used as-is, or as implementation for another ADT.
template <class Type>
class LinkList
{
private:
    LinkListElement<Type> *head = nullptr, *tail = nullptr,
                                 *current = nullptr;
public:
    LinkList() = default;
    LinkList(LinkListElement<Type> *e) 
        { head = tail = current = e; }
    void InsertAtFront(Type *);
    LinkListElement<Type> *RemoveAtFront();  
    void DeleteAtFront()  { delete RemoveAtFront(); }
    bool IsEmpty() const { return head == nullptr; } 
    void Print() const;    
    ~LinkList(){ while (!IsEmpty()) DeleteAtFront(); }
};

让我们检查LinkListElementLinkList的前置模板类定义。最初,我们注意到LinkList类的声明中包含了必要的模板前缀template class <Type>。我们还应该注意到,每个类定义本身也包含相同的模板前缀,以双重指定该类将是一个模板类,并且数据类型的占位符将是标识符Type

LinkListElement类中,请注意数据类型将是Type(占位符类型)。同时请注意,在LinkList的友元类指定中,类型占位符将是必要的,即friend class LinkList<Type>;

LinkList类中,请注意对LinkListElement相关类的任何引用都将包括类型占位符<Type>。例如,注意在LinkListElement<Type> *head;的数据成员声明中或RemoveAtFront()的返回类型中的占位符使用,其返回类型为LinkListElement<Type>。此外,请注意内联函数定义不需要在每个方法前使用模板前缀;我们仍然受到在类定义本身之前出现的模板前缀的保护。

现在,让我们继续前进,看看LinkList类的三个非内联成员函数:

template <class Type>     // template preamble
void LinkList<Type>::InsertAtFront(Type *theItem)
{
    LinkListElement<Type> *newHead = nullptr;
    newHead = new LinkListElement<Type>(theItem);
    newHead->SetNext(head);  // newHead->next = head;
    head = newHead;
}
template <class Type>    // template preamble
LinkListElement<Type> *LinkList<Type>::RemoveAtFront()
{
    LinkListElement<Type> *remove = head;
    head = head->GetNext();  // head = head->next;
    current = head;    // reset current for usage elsewhere
    return remove;
}

template <class Type>    // template preamble
void LinkList<Type>::Print() const
{
    if (!head)
        cout << "<EMPTY>" << endl;
    LinkListElement<Type> *traverse = head;
    while (traverse)
    {
        Type output = *(traverse->GetData());
        cout << output << ' ';
        traverse = traverse->GetNext();
    }
    cout << endl;
}

在检查前面的代码时,我们可以看到在LinkList的非内联方法中,template <class Type>模板前缀出现在每个成员函数定义之前。我们还可以看到,与作用域解析运算符结合的类名被<Type>增强,例如,void LinkList<Type>::Print()

我们注意到上述模板成员函数需要它们的方法的一部分来使用占位符类型Type。例如,InsertAtFront(Type *theItem)方法使用占位符Type作为形式参数theItem的数据类型,并在声明局部指针变量temp时指定相关的类LinkListElement<Type>RemoveAtFront()方法同样使用类型为LinkListElement<Type>的局部变量,因此需要将其用作模板函数。同样,Print()引入了一个类型为Type的局部变量来帮助输出。

现在我们来看看我们的main()函数,看看我们如何利用我们的模板类:

int main()
{
    LinkList<int> list1;  // create a LinkList of integers
    list1.InsertAtFront(new int (3000));
    list1.InsertAtFront(new int (600));
    list1.InsertAtFront(new int (475));
    cout << "List 1: ";
    list1.Print();
    // delete elements from list, one by one
    while (!(list1.IsEmpty()))
    {
       list1.DeleteAtFront();
       cout << "List 1 after removing an item: ";
       list1.Print();
    }
    LinkList<float> list2;  // create a LinkList of floats
    list2.InsertAtFront(new float(30.50));
    list2.InsertAtFront(new float (60.89));
    list2.InsertAtFront(new float (45.93));
    cout << "List 2: ";
    list2.Print();
}

在我们之前的main()函数中,我们使用我们的模板类创建两种类型的链表,即声明为LinkList<int> list1;的整数LinkList和声明为LinkList<float> list2;的浮点数LinkList

在每种情况下,我们实例化各种链表,然后添加元素并打印相应的列表。在第一个LinkList实例的情况下,我们还展示了如何从列表中连续删除元素。

让我们看看这个程序的输出:

List 1: 475 600 3000
List 1 after removing an item: 600 3000
List 1 after removing an item: 3000
List 1 after removing an item: <EMPTY>
List 2: 45.93 60.89 30.5

总体来看,我们看到创建LinkList<int>LinkList<float>非常容易。模板代码在幕后简单地扩展以适应我们希望的数据类型。然后,我们可以问自己,创建Student实例的链表有多容易?非常容易!我们可以简单地实例化LinkList<Student> list3;并调用适当的LinkList方法,例如list3.InsertAtFront(new Student("George", "Katz", 'C', "Mr.", 3.2, "C++", "123GWU"));

也许我们希望在模板LinkList类中包含一种对元素进行排序的方法,例如,通过添加一个OrderedInsert()方法(这通常依赖于operator<operator>来进行元素比较)。这对所有数据类型都适用吗?这是一个好问题。如果方法中编写的代码对所有数据类型都是通用的,那么它就可以。操作符重载能帮助这个任务吗?是的!

现在我们已经看到了模板类和函数的机制,让我们考虑如何确保我们的模板类和函数能够完全扩展以适用于任何数据类型。为此,让我们考虑操作符重载如何有价值。

使模板更加灵活和可扩展

C++中模板的引入使我们能够通过程序员一次指定某些类型的类和函数,而在幕后,预处理器为我们生成许多版本的代码。然而,为了使一个类真正可扩展以扩展到许多不同的用户定义类型,成员函数中编写的代码必须适用于任何类型的数据。为了帮助这一努力,运算符重载可以用来扩展可能容易存在于标准类型中的操作,包括为用户定义类型提供定义。

总结一下,我们知道运算符重载可以使简单的运算符不仅与标准类型一起工作,还可以与用户定义的类型一起工作。通过在我们的模板代码中重载运算符,我们可以确保我们的模板代码具有高度的复用性和可扩展性。

让我们考虑一下如何通过运算符重载来加强模板。

通过添加运算符重载进一步泛化模板代码

回想一下,当重载运算符时,重要的是要传达与标准类型相同的运算符意义。想象一下,如果我们想给我们的LinkList类添加一个OrderedInsert()方法。这个成员函数的主体可能需要比较两个元素以确定哪个应该排在另一个之前。做到这一点最简单的方法是使用operator<。这个运算符很容易定义为与标准类型一起工作,但它会与用户定义的类型一起工作吗?它可以,前提是我们重载运算符以与所需类型一起工作。

让我们看看一个例子,我们将需要重载一个运算符以使成员函数代码具有普遍适用性:

template <class Type>
void LinkList<Type>::OrderedInsert(Type *theItem)
{
    current = head;    
    if (*theItem < *(head->GetData()))  
        InsertAtFront(theItem);  // add theItem before head
    else
        // Traverse list, add theItem in proper location
}

在前面的模板成员函数中,我们依赖于operator<能够与任何我们希望利用此模板类的数据类型一起工作。也就是说,当预处理器为特定的用户定义类型展开此代码时,<运算符必须适用于此方法被特定展开的任何数据类型。

如果我们希望创建一个包含Student实例的LinkList并应用一个Student实例相对于另一个实例的OrderedInsert(),那么我们需要确保两个Student实例之间的比较operator<是定义好的。当然,默认情况下,operator<只为标准类型定义。但是,如果我们简单地为Student重载operator<,我们可以确保LinkList<Type>::OrderedInsert()方法也将适用于Student数据类型。

让我们看看如何为Student实例重载operator<,无论是作为成员函数还是作为非成员函数:

// overload operator < As a member function of Student
bool Student::operator<(const Student &s)
{   // if this->gpa < s.gpa return true, else return false
    return this->gpa < s.gpa;
}
// OR, overload operator < as a non-member function
bool operator<(const Student &s1, const Student &s2)
{   // if s1.gpa < s2.gpa return true, else return false
    return s1.gpa < s2.gpa;
}

在前面的代码中,我们可以识别出 operator< 被实现为 Student 的成员函数或非成员函数。如果你可以访问 Student 类的定义,那么首选的方法是使用该运算符函数的成员函数定义。然而,有时我们无法修改类。在这种情况下,我们必须使用非成员函数方法。无论如何,在两种实现中,我们只是比较两个 Student 实例的 gpa,如果第一个实例的 gpa 低于第二个 Student 实例,则返回 true,否则返回 false

现在已经为两个 Student 实例定义了 operator<,我们可以回到之前的模板函数 LinkList<Type>::OrderedInsert(Type *),该函数在 LinkList 中使用 operator < 来比较类型为 Type 的两个对象。当在代码的某个地方创建 LinkList<Student> 时,LinkListLinkListElement 的模板代码将由预处理程序为 Student 展开;Type 将被替换为 Student。当展开的代码被编译时,展开的 LinkList<Student>::OrderedInsert() 代码将无错误编译,因为已经为两个 Student 对象定义了 operator<

然而,如果我们忽略为给定类型重载 operator<,但在我们的代码中从未调用 OrderedInsert()(或依赖于 operator< 的其他方法)在相同展开的模板类型对象上,会发生什么?信不信由你,代码将编译并正常工作。在这种情况下,我们实际上没有调用需要为该类型实现 operator< 的函数(即 OrderedInsert())。因为该函数从未被调用,所以该成员函数的模板展开被跳过。编译器没有理由发现应该为该类型重载 operator<(以便方法能够成功编译)。未调用的方法只是没有被编译器验证展开。

通过使用运算符重载来补充模板类和函数,我们可以通过确保方法体中使用的典型运算符可以应用于模板展开中我们想要利用的任何类型,从而使模板代码更加可扩展。我们的代码变得更加通用。

我们现在已经看到了如何使用模板函数和类,以及运算符重载如何增强模板以创建更可扩展的代码。在继续下一章之前,让我们简要回顾这些概念。

摘要

在本章中,我们将 C++ 编程知识从面向对象语言特性扩展到包括其他语言特性,这些特性将使我们能够编写更可扩展的代码。我们学习了如何使用模板函数和模板类,以及运算符重载如何很好地支持这些工作。

我们已经看到,模板可以让我们根据类或函数内部主要使用的数据类型泛型地指定一个类或函数。我们已经看到,模板类不可避免地会使用模板函数,因为这些方法通常需要泛型地使用构建类所依据的数据。我们已经看到,通过利用用户定义类型的运算符重载,我们可以利用使用简单运算符编写的代码体,以适应更复杂的数据类型的用法,从而使模板代码更加有用和可扩展。

模板的力量加上运算符重载(使方法可用于几乎所有类型)使得 C++对泛型的实现比简单的类型替换要强大得多。

我们现在明白,使用模板可以让我们更抽象地指定一个类或函数一次,并允许预处理器根据应用程序中可能需要的特定数据类型为我们生成该类或函数的多个版本。

通过允许预处理器根据应用程序中需要的类型为我们扩展模板类或模板函数的多个版本,创建许多类似类或函数(以及维护这些版本)的工作就转移给了 C++,而不是程序员。除了用户需要维护的代码更少之外,对模板类或函数所做的更改只需在一个地方进行——当需要时,预处理器将重新扩展代码而不会出错。

通过研究模板,我们已经增加了额外的、有用的功能到我们的 C++工具箱中,这些功能与运算符重载相结合,将确保我们可以为几乎所有数据类型编写高度可扩展和可重用的代码。我们现在准备好继续前进到第十四章理解 STL 基础,这样我们就可以继续使用有用的 C++库功能来扩展我们的 C++编程技能,使我们成为更好的程序员。让我们继续前进!

问题

  1. 将你的ArrayInt类从第十二章友元和运算符重载,转换为模板Array类,以支持任何数据类型的动态分配数组,这些数组可以轻松调整大小并具有内置的边界检查。

a. 考虑你将需要重载哪些运算符,以便在每种方法内的泛型代码支持你可能在模板Array类型中希望存储的任何用户定义类型。

b. 使用你的模板Array类,创建一个Student实例的数组。利用各种成员函数来演示各种模板函数能正确运行。

  1. 使用模板LinkList类,完成LinkList<Type>::OrderedInsert()的实现。在main()函数中创建一个Student实例的LinkList。在列表中使用OrderedInsert()插入几个Student实例后,通过显示每个Student及其gpa来验证此方法是否正确工作。Student实例应按最低到最高的gpa顺序排列。您可能希望将在线代码作为起点。

第十四章:理解 STL 基础

本章将继续我们扩展 C++ 编程知识库的追求,通过深入研究一个核心 C++ 库,该库已经彻底融入了语言的常见用法。我们将通过检查该库的子集来探索 C++ 中的 标准模板库 (STL),这些子集代表了一些常见的工具,它们既可以简化我们的编程,也可以使熟悉 STL 的他人更容易理解我们的代码。

在本章中,我们将涵盖以下主要内容:

  • 概述 C++ 中 STL 的内容和目的

  • 理解如何使用基本 STL 容器 – listiteratorvectordequestackqueuepriority_queuemapmap 通过一个函数对象使用

  • 自定义 STL 容器

到本章结束时,你将能够利用核心 STL 类来提高你的编程技能。因为你已经理解了构建库所必需的基本 C++ 语言和面向对象编程特性,你会发现你现在有能力导航和理解几乎任何 C++ 类库,包括 STL。通过熟悉 STL,你将能够显著扩展你的编程知识库,并成为一个更加精明和有价值的程序员。

让我们通过检查一个高度使用的类库——STL,来增加我们的 C++ 工具箱。

技术要求

完整程序示例的在线代码可以在以下 GitHub 网址找到:github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/tree/main/Chapter14。每个完整程序示例都可以在 GitHub 仓库中找到,位于相应章节标题(子目录)下的文件中,该文件以章节编号开头,后面跟着一个连字符,然后是本章中的示例编号。例如,本章的第一个完整程序可以在上述 GitHub 目录下的 Chapter14 子目录中找到,文件名为 Chp14-Ex1.cpp

本章的 CiA 视频可以在以下网址观看:bit.ly/3PCL5IJ

概述 STL 的内容和目的

C++ 中的 标准模板库 是一个标准类和工具的库,它扩展了 C++ 语言。STL 的使用非常普遍,以至于它似乎成了语言本身的一部分;它是 C++ 的一个基本且不可或缺的部分。C++ 中的 STL 由四个关键组件组成,构成了库:容器迭代器函数算法

STL 还影响了 C++标准库,提供了编程标准的一套;这两个库实际上共享一些共同的特征和组件,最显著的是容器和迭代器。我们已经使用了标准库的组件,例如<iostream>用于 IOStreams,<exception>用于异常处理,以及<new>用于new()delete()运算符。在本章中,我们将探讨 STL 和 C++标准库之间的许多重叠组件。

STL 拥有一系列完整的容器类。这些类封装了传统的数据结构,以便将相似的项目收集在一起并统一处理。容器类分为几个类别——顺序、关联和无序。让我们总结这些类别,并给出每个类别的几个示例:

  • listqueuestack。值得注意的是,queuestack可以被视为对更基本容器(如list)的定制或自适应接口。尽管如此,queuestack仍然提供对其元素的顺序访问。

  • setmap

  • unordered_setunordered_map

为了使这些容器类能够用于任何数据类型(并保持强类型检查),我们使用了模板来抽象和泛化收集项的数据类型。实际上,我们在第十三章“使用模板”中使用了模板构建了自己的容器类,包括LinkListArray,因此我们已经对模板化的容器类有了基本了解!

此外,STL 还提供了一套完整的迭代器,使我们能够“遍历”或遍历容器。迭代器跟踪我们的当前位置,而不会破坏相应对象集合的内容或顺序。我们将看到迭代器如何使我们能够在 STL 中更安全地处理容器类。

STL 还包含大量有用的算法。例如,排序、计算可能满足条件的集合中元素的数量、在元素内搜索特定元素或子序列,或以各种方式复制元素。其他算法示例包括修改对象序列(替换、交换和删除值)、将集合划分为范围,或将集合合并在一起。此外,STL 还包含许多其他有用的算法和实用工具。

最后,STL 包括函数。实际上,更准确的说法是 STL 包括operator()(函数调用运算符),通过这样做,我们可以通过函数指针实现参数化的灵活性。尽管这不是 STL 的初级特性,我们将在本章中看到一个与 STL 容器类结合的小型、简单的示例,在即将到来的部分使用函数对象检查 STL map中。

在本章中,我们将关注 STL 的容器类部分。尽管我们不会检查 STL 中的每个容器类,但我们将回顾这些类中的大量内容。我们会注意到,其中一些容器类与我们在这本书的前几章中一起构建的类相似。顺便提一下,在本书的增量章节进展中,我们也构建了 C++ 语言和 OOP 技能,这些技能是解码像 STL 这样的 C++ 类库所必需的。

让我们继续前进,看看选择性的 STL 类,并在解释每个类的同时检验我们的 C++ 知识。

理解如何使用基本的 STL 容器

在本节中,我们将通过解码各种 STL 容器类来检验我们的 C++ 技能。我们将看到,从核心 C++ 语法到 OOP 技能,我们已经掌握的语言特性使我们能够轻松地解释我们现在将要检查的 STL 的各种组件。最值得注意的是,我们将运用我们的模板知识!例如,我们的封装和继承知识将指导我们理解如何在 STL 类中使用各种方法。然而,我们会注意到,在 STL 中虚拟函数和抽象类非常罕见。掌握 STL 中新类的能力的最佳方式是拥抱详细说明每个类的文档。有了 C++ 的知识,我们可以轻松地导航到给定的类,解码如何成功使用它。

C++ STL 中的容器类实现了各种 listiteratorvectordequestackqueuepriority_queuemap

让我们从检查如何利用一个非常基本的 STL 容器 list 开始。

使用 STL list

STL 的 list 类封装了实现链表所需的数据结构。我们可以这样说,list 实现了链表的抽象数据类型。回想一下,我们在 第六章使用继承实现层次结构 中通过创建 LinkedListElementLinkedList 类来构建了自己的链表。STL list 允许轻松地插入、删除和排序元素。不支持对单个元素的直接访问(称为 随机访问)。相反,你必须迭代地遍历链表中的前一个项目,直到达到所需的项目。STL list 是顺序容器的一个很好的例子。

STL 的 list 实际上支持对其元素的 bidirectional sequential access(它使用双链表实现)。STL 还提供了 forward_list,允许以比 list 更小的内存占用对元素进行 unidirectional sequential access;forward_list 使用单链表实现(就像我们的 LinkedList 类一样)。

STL 的 list 类有许多成员函数;我们将从这个例子中开始,看看一些流行的函数,以熟悉基本 STL 容器类的使用。

现在,让我们看看我们如何利用 STL list 类。这个例子可以在我们的 GitHub 上找到,作为一个完整的、带有必要类定义的工作程序,如下所示:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter14/Chp14-Ex1.cpp

#include <list>
using std::list;
int main()
{   
    list<Student> studentBody;   // create a list
    Student s1("Jul", "Li", 'M', "Ms.", 3.8, "C++",
               "117PSU");
    // Note: simple heap instance below, later you can opt
    // for a smart pointer to ease allocation/deallocation
    Student *s2 = new Student("Deb", "King", 'H', "Dr.", 
                              3.8, "C++", "544UD");
    // Add Students to the studentBody list. 
    studentBody.push_back(s1);
    studentBody.push_back(*s2);
    // The next 3 instances are anonymous objects in main()
    studentBody.push_back(Student("Hana", "Sato", 'U', 
                          "Dr.", 3.8, "C++", "178PSU"));
    studentBody.push_back(Student("Sara", "Kato", 'B',
                          "Dr.", 3.9, "C++", "272PSU"));
    studentBody.push_back(Student("Giselle", "LeBrun", 'R',
                          "Ms.", 3.4, "C++", "299TU"));
    while (!studentBody.empty())
    {
       studentBody.front().Print();
       studentBody.pop_front();
    }
    delete s2;  // delete any heap instances
    return 0;
}

让我们检查上述程序段,其中我们创建并使用了一个 STL list。首先,我们 #include <list> 来包含适当的 STL 头文件。我们还添加 using std::list; 以从标准命名空间包含 list。现在,在 main() 中,我们可以使用 list<Student> studentBody; 实例化一个列表。我们的列表将包含 Student 实例。然后,我们使用 new() 分配在栈上创建 Student s1 和在堆上创建 Student *s2

接下来,我们使用 list::push_back()s1*s2 都添加到列表中。注意,我们正在将对象传递给 push_back()。当我们将 Student 实例添加到 studentBody 列表中时,列表将内部复制这些对象,并在它们不再是列表的成员时适当地清理这些对象。我们需要记住,如果我们的任何实例已经在堆上分配,例如 *s2,那么在 main() 结束时,我们必须删除该实例的副本。展望 main() 的结尾,我们可以看到我们适当地 delete s2;

接下来,我们将另外三个学生添加到列表中。这些 Student 实例没有局部标识符。这些学生是在 push_back() 调用中实例化的,例如,studentBody.push_back(Student("Hana", "Sato", 'U', "Dr.", 3.8, "C++", "178PSU"));。在这里,我们实例化了一个 匿名(栈)对象,它将在 push_back() 调用结束时从栈中正确弹出并销毁。请注意,push_back() 还将为这些实例创建它们在 list 中的生命周期内的本地副本。

现在,在一个 while 循环中,我们反复检查列表是否 empty(),如果不是,我们检查 front() 项并调用我们的 Student::Print() 方法。然后我们使用 pop_front() 从列表中移除该项。

让我们看看这个程序的输出:

Ms. Jul M. Li with id: 117PSU GPA:  3.8 Course: C++
Dr. Deb H. King with id: 544UD GPA:  3.8 Course: C++
Dr. Hana U. Sato with id: 178PSU GPA:  3.8 Course: C++
Dr. Sara B. Kato with id: 272PSU GPA:  3.9 Course: C++
Ms. Giselle R. LeBrun with id: 299TU GPA:  3.4 Course: C++

现在我们已经解析了一个简单的 STL list 类,让我们继续了解 iterator 的概念,以补充像 list 这样的容器。

使用 STL 迭代器

很频繁地,我们需要一种非破坏性的方法来遍历一组对象。例如,在给定的容器中维护第一个、最后一个和当前位置很重要,特别是如果该集合可能被多个方法、类或线程访问。使用 迭代器,STL 提供了一种遍历任何容器类的通用方法。

迭代器的使用具有明显的优势。一个类可以创建一个指向集合中第一个成员的 iterator。然后迭代器可以被移动到集合的后续下一个成员。迭代器可以提供对由 iterator 指向的元素访问。

总体来说,一个容器的状态信息可以通过 iterator 维护。迭代器提供了一种安全的方法,通过将状态信息从容器抽象出来,而不是放入迭代器类中,来实现交错访问。

我们可以将迭代器想象成一本书中的书签,两个人或更多人正在参考。第一个人按顺序阅读书籍,将书签整洁地放在他们期望继续阅读的地方。当第一个人离开时,另一个人在书中查找一个重要项目,并将书签移动到书中的另一个位置以保存他们的位置。当第一个人回来时,他们会发现自己失去了当前的位置,并不在他们期望的地方。每个用户都应该有自己的书签或迭代器。这个类比是,迭代器(理想情况下)允许安全地交错访问可能由应用程序中的多个组件处理的一个资源。如果没有迭代器,你可能会无意中修改一个容器,而其他用户并不知道。STL 迭代器大多数情况下,但并非总是,能够达到这个理想目标。

让我们看看如何利用 STL iterator。这个例子可以在我们的 GitHub 上作为一个完整的程序找到,如下所示:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter14/Chp14-Ex2.cpp

#include <list>
#include <iterator>
using std::list;
using std::iterator;
bool operator<(const Student &s1, const Student &s2)
{   // overloaded operator< -- required to use list::sort()
    return s1.GetGpa() < s2.GetGpa();
}
int main()
{
    list<Student> studentBody;  
    Student s1("Jul", "Li", 'M', "Ms.", 3.8, "C++",
               "117PSU");
    // Add Students to the studentBody list.
    studentBody.push_back(s1);
    // The next Student instances are anonymous objects
    studentBody.push_back(Student("Hana", "Sato", 'U',
                          "Dr.", 3.8, "C++", "178PSU"));
    studentBody.push_back(Student("Sara", "Kato", 'B',
                          "Dr.", 3.9, "C++", "272PSU"));
    studentBody.push_back(Student("Giselle", "LeBrun", 'R',
                          "Ms.", 3.4, "C++", "299TU"));
    studentBody.sort();  // sort() will rely on operator< 
    // Though we'll generally prefer range-for loops, let's
    // understand and demo using an iterator for looping.
    // Create a list iterator; set to first item in list.
    // We'll next simplify iterator notation with 'auto'.
    list <Student>::iterator listIter =studentBody.begin();
    while (listIter != studentBody.end())
    {
        Student &temp = *listIter;
        temp.EarnPhD();
        ++listIter;    // prefer pre-inc (less expensive)
    } 
    // Simplify iterator declaration using 'auto'
    auto autoIter = studentBody.begin();
    while (autoIter != studentBody.end())
    {
        (*autoIter).Print();  
        ++autoIter;
    }
    return 0;
}

让我们来看看之前定义的代码段。在这里,我们包含了 STL 中的 <list><iterator> 头文件。我们还添加了 using std::list;using std::iterator; 来包含标准命名空间中的 listiterator。与之前的 main() 函数一样,我们使用 list<Student> studentbody; 实例化了一个可以包含 Student 实例的 list。然后我们实例化几个 Student 实例,并使用 push_back() 将它们添加到列表中。再次注意,几个 Student 实例是 匿名对象,在 main() 中没有局部标识符。这些实例将在 push_back() 完成时从栈中弹出。这没有问题,因为 push_back() 将为列表创建局部副本。

现在,我们可以使用 studentBody.sort(); 对列表进行排序。需要注意的是,这个 list 方法要求我们重载 operator< 以提供两个 Student 实例之间比较的方法。幸运的是,我们已经做到了!我们选择通过比较 gpa 来实现 operator<,但它也可以使用 studentId 进行比较。

现在我们有了list,我们可以创建一个iterator并将其设置为指向list的第一个项目。我们通过声明list <Student>::iterator listIter = studentBody.begin();来这样做。一旦建立了迭代器,我们就可以使用它安全地从开始(因为它被初始化)到end()循环遍历list。我们通过Student &temp = *listIter;将局部引用变量temp赋值给列表循环迭代的当前第一个元素。然后我们通过temp.EarnPhD();在这个实例上调用一个方法,然后通过++listIter;将迭代器增加一个元素。

在随后的循环中,我们使用 auto 简化了迭代器的声明。auto 关键字允许迭代器的类型由其初始使用确定。在这个循环中,我们还消除了对 temp 的使用——我们只需在括号内取消迭代器的引用,然后使用 (*autoIter).Print() 调用 Print()。使用 ++autoIter 简单地前进到列表中的下一个项目以进行处理。

让我们看看这个程序的排序输出(按 gpa 排序):

Dr. Giselle R. LeBrun with id: 299TU GPA:  3.4 Course: C++
Dr. Jul M. Li with id: 117PSU GPA:  3.8 Course: C++
Dr. Hana U. Sato with id: 178PSU GPA:  3.8 Course: C++
Dr. Sara B. Kato with id: 272PSU GPA:  3.9 Course: C++

现在我们已经看到了iterator类的实际应用,让我们来调查一些额外的 STL 容器类,从vector开始。

使用 STL vector

STL 的 vector 类实现了动态数组的抽象数据类型。回想一下,我们在 第十三章使用模板 中通过创建一个 Array 类来创建了自己的动态数组。然而,STL 版本将更加广泛。

vector(动态或可调整大小的数组)将根据需要扩展以容纳超出其初始大小的额外元素。vector类通过重载operator[]允许直接(即随机访问)访问元素。访问特定索引的元素不需要遍历所有先前元素。

然而,在vector的中间添加元素是耗时的。也就是说,除了在vector的末尾添加之外,还需要将插入点之后的所有元素在内部重新排列;这还可能需要vector的内部调整大小。

显然,与listvector相比,它们有不同的优点和缺点。每个都是针对数据集合的不同需求而设计的。我们可以选择最适合我们需求的一个。

让我们看看一些常见的vector成员函数。这远非一个完整的列表:

图片

STL 的 vector 类还包括重载的 operator=(赋值用源 vector 替换目标 vector),operator==(逐元素比较向量),和 operator[](返回对请求位置的引用,即可写内存)。

让我们看看我们如何利用 STL vector 类及其基本操作。这个例子可以作为完整的程序在 GitHub 上找到,如下所示:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter14/Chp14-Ex3.cpp

#include <vector>
using std::vector;
int main()
{   // instantiate two vectors
    vector<Student> studentBody1, studentBody2; 
    // add 3 Students, which are anonymous objects 
    studentBody1.push_back(Student("Hana", "Sato", 'U',
"Dr.", 3.8, "C++", "178PSU"));
    studentBody1.push_back(Student("Sara", "Kato", 'B',
                           "Dr.", 3.9, "C++", "272PSU"));
    studentBody1.push_back(Student("Giselle", "LeBrun",
                         'R', "Ms.", 3.4, "C++", "299TU"));
    // Compare this loop to next loop using an iterator and
    // also to the preferred range-for loop further beyond
    for (int i = 0; i < studentBody1.size(); i++)   
        studentBody1[i].Print();   // print first vector
    studentBody2 = studentBody1;   // assign one to another
    if (studentBody1 == studentBody2)
        cout << "Vectors are the same" << endl;
    // Notice: auto keyword simplifies iterator declaration
    for (auto iter = studentBody2.begin();
              iter != studentBody2.end(); iter++)
        (*iter).EarnPhD();
   // Preferred range-for loop (and auto to simplify type)
    for (const auto &student : studentBody2)
        student.Print();
    if (!studentBody1.empty())   // clear first vector 
        studentBody1.clear();
    return 0;
}

在之前列出的代码段中,我们 #include <vector> 以包含适当的 STL 头文件。我们还添加 using std::vector; 以从标准命名空间包含 vector。现在,在 main() 中,我们可以使用 vector<Student> studentBody1, studentBody2; 实例化两个向量。然后,我们可以使用 vector::push_back() 方法连续将几个 Student 实例添加到我们的第一个向量中。再次注意,Student 实例在 main() 中是 匿名对象。也就是说,没有局部标识符引用它们——它们仅被创建以放入我们的向量中,在插入时为每个实例创建局部副本。一旦我们在向量中有元素,我们就遍历第一个向量,使用 studentBody1[i].Print(); 打印每个 Student

接下来,我们通过使用 studentBody1 = studentBody2; 将一个向量赋值给另一个向量来演示 vector 的重载赋值运算符。在这里,我们在赋值过程中从右向左进行深度复制。然后,我们可以使用条件语句中的重载比较运算符来测试两个向量是否相等,即 if (studentBody1 == studentBody2);

然后,我们使用 auto iter = studentBody2.begin(); 指定的迭代器在 for 循环中对第二个向量的内容应用 EarnPhD()auto 关键字允许迭代器的类型由其初始使用确定。然后,我们使用首选的范围-for 循环(以及使用 auto 简化范围-for 循环中的变量类型)打印出第二个向量的内容。最后,我们检查第一个 vector 是否为 empty(),然后使用 studentBody1.clear(); 逐个清除元素。我们现在已经看到了 vector 方法和它们的功能的样本。

让我们看看这个程序的输出:

Dr. Hana U. Sato with id: 178PSU GPA:  3.8 Course: C++
Dr. Sara B. Kato with id: 272PSU GPA:  3.9 Course: C++
Ms. Giselle R. LeBrun with id: 299TU GPA:  3.4 Course: C++
Vectors are the same
Everyone to earn a PhD
Dr. Hana U. Sato with id: 178PSU GPA:  3.8 Course: C++
Dr. Sara B. Kato with id: 272PSU GPA:  3.9 Course: C++
Dr. Giselle R. LeBrun with id: 299TU GPA:  3.4 Course: C++

接下来,让我们调查 STL deque 类,以进一步了解 STL 容器。

使用 STL deque

STL deque 类(发音为 deck)实现了双端队列的抽象数据类型。这种 ADT 扩展了队列是先进先出的概念。相反,deque 类提供了更大的灵活性。在 deque 的两端添加元素是快速的。在 deque 的中间添加元素是耗时的。deque 是一个顺序容器,尽管比列表更灵活。

你可能会想象dequequeue的一个特殊化;它不是。相反,灵活的deque类将作为实现其他容器类的基础,我们很快就会看到。在这些情况下,私有继承将允许我们隐藏deque作为底层实现(具有广泛的功能)以供更限制性、特殊化的类使用。

让我们看看一些常见的deque成员函数。这远非一个完整的列表:

图片

STL 的deque类还包括重载的operator=(源到目标deque的赋值)和operator[](返回请求位置的引用——可写内存)。

让我们看看我们如何利用 STL 的deque类。这个例子作为一个完整的程序可以在我们的 GitHub 上找到,如下所示:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter14/Chp14-Ex4.cpp

#include <deque> 
using std::deque;
int main()
{
    deque<Student> studentBody;   // create a deque
    Student s1("Tim", "Lim", 'O', "Mr.", 3.2, "C++",
               "111UD");
    // the remainder of the Students are anonymous objects
    studentBody.push_back(Student("Hana", "Sato", 'U',
                          "Dr.",3.8, "C++", "178PSU"));
    studentBody.push_back(Student("Sara", "Kato", 'B',
                          "Dr.", 3.9, "C++", "272PSU"));
    studentBody.push_front(Student("Giselle", "LeBrun",
                          'R',"Ms.", 3.4, "C++", "299TU"));
    // insert one past the beginning 
    studentBody.insert(std::next(studentBody.begin()), 
    Student("Anne", "Brennan", 'B', "Ms.", 3.9, "C++",
            "299CU"));
    studentBody[0] = s1;  // replace 0th element; 
                          // no bounds checking!
    while (!studentBody.empty())
    {
        studentBody.front().Print();
        studentBody.pop_front();
    }
    return 0;
}

在之前列出的代码段中,我们#include <deque>来包含适当的 STL 头文件。我们还添加了using std::deque;来从标准命名空间包含deque。现在,在main()中,我们可以实例化一个deque来包含Student实例,使用deque<Student> studentBody;。然后我们调用deque::push_back()deque::push_front()来向我们的deque添加几个Student实例(一些匿名对象)。我们正在掌握这个!现在,我们使用studentBody.insert(std::next(studentBody.begin()), Student("Anne", "Brennan", 'B', "Ms.", 3.9, "C++", "299CU"));deque的前端之后插入一个Student

接下来,我们利用重载的operator[]将一个Student插入到我们的deque中,使用studentBody[0] = s1;。请务必注意,operator[]对我们的deque不进行任何边界检查!在这个语句中,我们将Student s1插入到deque的第 0 个位置,而不是曾经占据那个位置的Student。一个更安全的做法是使用deque::at()方法,它将包含边界检查。关于上述赋值,我们还想确保operator=已经为PersonStudent两个类重载,因为每个类都有动态分配的数据成员。

现在,我们循环直到我们的dequeempty(),使用studentBody.front().Print();提取并打印deque的前端元素。在每次迭代中,我们也会使用studentBody.pop_front();deque中弹出前端的项目。

让我们看看这个程序的输出:

Mr. Tim O. Lim with id: 111UD GPA:  3.2 Course: C++
Ms. Anne B. Brennan with id: 299CU GPA:  3.9 Course: C++
Dr. Hana U. Sato with id: 178PSU GPA:  3.8 Course: C++
Dr. Sara B. Kato with id: 272PSU GPA:  3.9 Course: C++

既然我们对deque有了感觉,接下来让我们研究一下 STL 的stack类。

使用 STL 栈

STL 的 stack 类实现了栈的抽象数据类型。栈 ADT 包含一个公共接口,该接口不公开其底层实现。毕竟,栈可能会更改其实现;ADT 的使用不应以任何方式依赖于其底层实现。STL 的 stack 被视为基本顺序容器的自适应接口。

回想一下,我们在 第六章使用继承实现层次结构 中创建了自己的 Stack 类,使用 LinkedList 作为私有基类。STL 版本将更加丰富;有趣的是,它使用 deque 作为其底层的私有基类。由于 deque 是 STL stack 的私有基类,deque 的更多通用底层能力被隐藏;只使用适用的方法来实现栈的公共接口。此外,由于实现方式被隐藏,stack 可以在以后使用另一个容器类实现,而不会影响其使用。

让我们看看一些常见的 stack 成员函数。这远非一个完整的列表。重要的是要注意,stack 的公共接口远小于其私有基类 deque 的接口:

STL 的 stack 类还包括重载的 operator=(源栈到目标栈的赋值)、operator==operator!=(两个栈的相等/不等)、以及 operator<operator>operator<=operator>=(栈的比较)。

让我们看看如何利用 STL 的 stack 类。这个例子可以作为完整的工作程序在我们的 GitHub 上找到,如下所示:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter14/Chp14-Ex5.cpp

#include <stack>   // template class preamble
using std::stack;
int main()
{
    stack<Student> studentBody;   // create a stack
    // add Students to the stack (anonymous objects)
    studentBody.push(Student("Hana", "Sato", 'U', "Dr.",
                             3.8, "C++", "178PSU"));
    studentBody.push(Student("Sara", "Kato", 'B', "Dr.",
                             3.9, "C++", "272PSU"));
    studentBody.push(Student("Giselle", "LeBrun", 'R',
                             "Ms.", 3.4, "C++", "299TU"));
    while (!studentBody.empty())
    {
        studentBody.top().Print();
        studentBody.pop();
    }
    return 0;
}

在上述代码段中,我们 #include <stack> 包含适当的 STL 头文件。我们还添加 using std::stack; 来包含标准命名空间中的 stack。现在,在 main() 中,我们可以使用 stack<Student> studentBody; 实例化一个 stack 来包含 Student 实例。然后,我们调用 stack::push() 向我们的 stack 添加几个 Student 实例。注意,我们正在使用传统的 push() 方法,这有助于栈的 ADT。

然后,我们在 stack 不为 empty() 时循环遍历。我们的目标是使用 studentBody.top().Print(); 访问并打印栈顶元素。然后,我们使用 studentBody.pop(); 整洁地弹出栈顶元素。

让我们看看这个程序的输出:

Ms. Giselle R. LeBrun with id: 299TU GPA:  3.4 Course: C++
Dr. Sara B. Kato with id: 272PSU GPA:  3.9 Course: C++
Dr. Hana U. Sato with id: 178PSU GPA:  3.8 Course: C++

接下来,让我们研究 STL 的 queue 类,以进一步丰富我们的 STL 容器系列。

使用 STL 队列

STL 的queue类实现了队列 ADT。作为典型的队列类,STL 的queue类支持成员插入和删除的先进先出FIFO)顺序。

回想一下,我们在第六章中自己实现了Queue类,使用继承实现层次结构;我们使用私有继承从LinkedList类派生我们的Queue。STL 版本将更加丰富;STL 的queue类使用deque作为其底层实现(也使用私有继承)。记住,由于实现方式通过私有继承被隐藏,queue可以在以后使用其他数据类型实现,而不会影响其公共接口。STL 的queue类是基本顺序容器的一个自适应接口的另一个例子。

让我们看看一些常见的queue成员函数。这远非一个完整的列表。重要的是要注意,queue的公共接口远小于其私有基类deque的接口:

图片

STL 的queue类还包括重载的operator=(源队列到目标队列的赋值),operator==operator!=(两个队列的相等/不等),以及operator<operator>operator<=operator>=(队列的比较)。

让我们看看如何利用 STL 的queue类。这个例子可以作为完整的工作程序在我们的 GitHub 上找到,如下所示:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter14/Chp14-Ex6.cpp

#include <queue>  
using std::queue;
int main()
{
    queue<Student> studentBody;  // create a queue
    // add Students to the queue (anonymous objects)
    studentBody.push(Student("Hana", "Sato", 'U', "Dr.",
                             3.8, "C++", "178PSU"));
    studentBody.push(Student("Sara", "Kato", 'B' "Dr.",
3.9, "C++", "272PSU"));
    studentBody.push(Student("Giselle", "LeBrun", 'R',
                             "Ms.", 3.4, "C++", "299TU"));
    while (!studentBody.empty())
    {
        studentBody.front().Print();
        studentBody.pop();
    }
    return 0;
}

在之前的代码段中,我们首先#include <queue>来包含适当的 STL 头文件。我们还添加了using std::queue;以从标准命名空间中包含queue。现在,在main()中,我们可以使用queue<Student> studentBody;来实例化一个queue以包含Student实例。然后我们调用queue::push()来将几个Student实例添加到我们的queue中。回想一下,在队列 ADT 中,push()意味着我们在队列的末尾添加一个元素。一些程序员更喜欢使用术语enqueue来描述这个操作;然而,STL 选择了将此操作命名为push()。在队列 ADT 中,pop()将从队列的前端移除一个项目;一个更好的术语是dequeue,但 STL 并没有选择这个术语。我们可以适应。

然后,我们在queue不为empty()时循环遍历。我们的目标是使用studentBody.front().Print();访问并打印前端的元素。然后我们使用studentBody.pop();整洁地从queue中移除前端元素。我们的工作就完成了。

让我们看看这个程序的输出:

Dr. Hana U. Sato with id: 178PSU GPA:  3.8 Course: C++
Dr. Sara B. Kato with id: 272PSU GPA:  3.9 Course: C++
Ms. Giselle R. LeBrun with id: 299TU GPA:  3.4 Course: C++

现在我们已经尝试了queue,让我们来调查 STL 的priority_queue类。

使用 STL 优先队列

STL 的priority_queue类实现了优先队列的抽象数据类型。优先队列 ADT 支持修改后的 FIFO 插入和删除成员的顺序;元素是加权的。最前面的元素是最大值(由重载的operator<确定)并且其余元素按照从大到小的顺序依次排列。STL 的priority_queue类被认为是顺序容器的自适应接口。

回想一下,我们在第六章通过继承实现层次结构中实现了自己的PriorityQueue类。我们使用公有继承来允许我们的PriorityQueue专门化我们的Queue类,添加额外的支持优先级(加权)入队方案的方法。Queue的底层实现(带有私有基类LinkedList)是隐藏的。通过使用公有继承,我们允许我们的PriorityQueue能够通过向上转型(在我们学习了第七章通过多态利用动态绑定)后作为Queue使用。我们做出了一个可接受的设计选择:PriorityQueue Is-A(专门化)Queue*,有时可以以更通用的形式处理。我们还回忆起,QueuePriorityQueue都不能向上转型到其底层实现LinkedList,因为Queue是私有地从LinkedList派生的;我们不能越过非公有继承边界进行向上转型。

相比之下,STL 版本的priority_queue使用 STL 的vector作为其底层实现。回想一下,由于实现方式是隐藏的,priority_queue可能在以后使用其他数据类型实现,而不会影响其公共接口。

STL 的priority_queue允许检查,但不允许修改,最顶端的元素。STL 的priority_queue不允许通过其元素进行插入。也就是说,只能添加元素以产生从大到小的顺序。因此,可以检查最顶端的元素,并且可以移除最顶端的元素。

让我们看看一些常见的priority_queue成员函数。这不是一个完整的列表。重要的是要注意,priority_queue的公共接口远小于其私有基类vector

图片

与之前检查的容器类不同,STL 的priority_queue没有重载运算符,包括operator=, operator==, 和 operator<

priority_queue 最有趣的方法是 void emplace(args);。这是允许优先级入队机制向此 ADT 添加项的成员函数。我们还注意到必须使用 top() 来返回顶部元素(与 queue 使用的 front() 相比)。但是,STL 的 priority_queue 并不是使用 queue 实现的。要利用 priority_queue,我们需要 #include <queue>,就像我们为 queue 做的那样。

由于 priority_queue 的使用与 queue 非常相似,因此我们将在本章末尾的问题集中进一步探讨其编程方面的应用。

现在我们已经看到了许多 STL 中的顺序容器类型示例(包括自适应接口),接下来让我们研究 STL 的 map 类,这是一个关联容器。

检查 STL map

STL 的 map 类实现了哈希表的抽象数据类型。map 类允许在哈希表或映射中快速存储和检索元素。如果需要将多个数据项与单个键关联起来,可以使用 multimap

哈希表(映射)在存储和查找数据方面非常快。性能保证为 O(log(n))。STL 的 map 被视为关联容器,因为它将键与值关联起来,以便快速检索值。

让我们看看一些常见的 map 成员函数。这不是一个完整的列表:

图片

STL 类 map 还包括重载的运算符 operator==(逐元素比较映射)作为全局函数实现。STL map 还包括重载的 operator[](返回与用作索引的键关联的映射元素引用;这是可写内存)。

让我们看看如何利用 STL 的 map 类。这个示例可以作为完整的可工作程序在我们的 GitHub 上找到,如下所示:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter14/Chp14-Ex7.cpp

#include <map>
using std::map;
using std::pair;
bool operator<(const Student &s1, const Student &s2)
{   // We need to overload operator< to compare Students
    return s1.GetGpa() < s2.GetGpa();
}
int main()
{
    Student s1("Hana", "Lo", 'U', "Dr.", 3.8, "C++",
               "178UD");
    Student s2("Ali", "Li", 'B', "Dr.", 3.9, "C++",
               "272UD");
    Student s3("Rui", "Qi", 'R', "Ms.", 3.4, "C++",
               "299TU");
    Student s4("Jiang", "Wu", 'C', "Ms.", 3.8, "C++",
               "887TU");
    // create three pairings of ids to Students
    pair<string, Student> studentPair1
                                (s1.GetStudentId(), s1);
    pair<string, Student> studentPair2
                                (s2.GetStudentId(), s2);
    pair<string, Student> studentPair3
                                (s3.GetStudentId(), s3);
    // Create map of Students w string keys
    map<string, Student> studentBody;
    studentBody.insert(studentPair1);  // insert 3 pairs
    studentBody.insert(studentPair2);
    studentBody.insert(studentPair3);
    // insert using virtual indices per map
    studentBody[s4.GetStudentId()] = s4; 
    // Iterate through set with map iterator – let's 
    // compare to range-for and auto usage just below
    map<string, Student>::iterator mapIter;
    mapIter = studentBody.begin();
    while (mapIter != studentBody.end())
    {   
        // set temp to current item in map iterator
        pair<string, Student> temp = *mapIter;
        Student &tempS = temp.second;  // get 2nd element
        // access using mapIter
        cout << temp.first << " ";
        cout << temp.second.GetFirstName();  
        // or access using temporary Student, tempS  
        cout << " " << tempS.GetLastName() << endl;
        ++mapIter;
    }
    // Now, let's iterate through our map using a range-for
    // loop and using 'auto' to simplify the declaration
    // (this decomposes the pair to 'id' and 'student')
    for (auto &[id, student] : studentBody)
        cout << id << " " << student.GetFirstName() << " " 
             << student.GetLastName() << endl;
    return 0;
}

让我们检查前面的代码段。同样,我们包含适用的头文件 #include <map>。我们还添加了 using std::map;using std::pair; 以包含标准命名空间中的 mappair。接下来,我们创建了四个 Student 实例。然后,我们创建了三个 pair 实例,用于将每个学生与其键(即他们的相应 studentId)关联起来,使用声明 pair<string, Student> studentPair1 (s1.GetStudentId(), s1);。这可能会让人感到困惑,但让我们把这个声明分解成其组成部分。在这里,实例的数据类型是 pair<string, Student>,变量名是 studentPair1(s1.GetStudentId(), s1) 是传递给特定 pair 实例构造函数的参数。

我们将创建一个以键(即他们的 studentId)为索引的 Student 实例的哈希表(map)。接下来,我们声明一个 map 来存储 Student 实例的集合,使用 map<string, Student> studentBody;。在这里,我们表明键和元素之间的关联将是一个 string 和一个 Student。然后,我们使用相同的数据类型声明一个 map 迭代器 map<string, Student>::iterator mapIter;

现在,我们只需将三个 pair 实例插入到 map 中。例如,studentBody.insert(studentPair1); 就是一个这样的插入操作。然后,我们使用 map 的重载 operator[] 将第四个 Students4,插入到 map 中,如下所示:studentBody[s4.GetStudentId()] = s4;。请注意,studentId 被用作 operator[] 中的索引值;这个值将成为哈希表中 Student 的键值。

接下来,我们声明并设置 map 迭代器到 map 的开始处,然后处理 map,直到它到达 end()。在循环中,我们将一个变量 temp 设置为地图前端的 pair,由地图迭代器指示。我们还设置 tempS 作为 map 中一个 Student 的临时引用,由 temp.second(当前由地图迭代器管理的 pair 中的第二个值)指示。现在我们可以使用 temp.first(当前 pair 中的第一个项目)打印出每个 Student 实例的 studentId(键,它是一个 string)。在同一个语句中,我们可以使用 temp.second.GetFirstName() 打印出每个 Student 实例的 firstName(因为与键对应的 Student 是当前 pair 中的第二个项目)。同样,我们也可以使用 tempS.GetLastName() 打印一个学生的 lastName,因为 tempS 在每次循环迭代的开始时被初始化为当前 pair 中的第二个元素。

最后,作为之前演示的更繁琐的通过 map 迭代的方法的替代方案,让我们检查程序中的最后一个循环。在这里,我们使用范围-for 循环来处理 map。使用 auto&[id, student] 将指定我们将迭代的类型数据。括号([])将分解 pair,将迭代元素分别绑定到 idstudent 作为标识符。注意我们现在迭代 studentBody map 的简便性。

让我们看看这个程序的输出:

178UD Hana Lo
272UD Ali Li
299TU Rui Qi
887TU Jiang Wu
178UD Hana Lo
272UD Ali Li
299TU Rui Qi
887TU Jiang Wu

接下来,让我们看看 STL map 的一个替代方案,这将介绍我们到 STL functor 概念。

使用函数对象检查 STL map

STL 的 map 类具有很大的灵活性,就像许多 STL 类一样。在我们之前的 map 示例中,我们假设 Student 类中存在一种比较方法。毕竟,我们已经为两个 Student 实例重载了 operator<。然而,如果我们不能修改一个没有提供这个重载操作符的类,并且我们也不选择将 operator< 作为外部函数重载,会发生什么呢?

幸运的是,在实例化 map 或 map 迭代器时,我们可以指定一个第三种数据类型用于模板类型扩展。这种额外的数据类型将是一种特定的类,称为函数对象。一个 operator()。正是在重载的 operator() 中,我们将提供对相关对象的比较方法。函数对象本质上通过重载 operator() 来模拟封装函数指针。

让我们看看我们如何修改我们的 map 示例以利用一个简单的函数对象。这个例子可以作为完整的程序在 GitHub 上找到,如下所示:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter14/Chp14-Ex8.cpp

#include <map>
using std::map;
using std::pair;
struct comparison   // This struct represents a 'functor'
{                   // that is, a 'function object'
    bool operator() (const string &key1, 
                     const string &key2) const
    {   
        int ans = key1.compare(key2);
        if (ans >= 0) return true;  // return a boolean
        else return false;  
    }
    // default constructor and destructor are adequate
};
int main()
{
    Student s1("Hana", "Sato", 'U', "Dr.", 3.8, "C++", 
               "178PSU");
    Student s2("Sara", "Kato", 'B', "Dr.", 3.9, "C++",
               "272PSU");
    Student s3("Jill", "Long", 'R', "Dr.", 3.7, "C++",
               "234PSU");
    // Now, map is maintained in sorted (decreasing) order
    // per ‹comparison› functor using operator()
    map<string, Student, comparison> studentBody;
    map<string, Student, comparison>::iterator mapIter;
    // The remainder of the program is similar to prior
}   // map program. See online code for complete example.

在之前提到的代码片段中,我们首先引入了一个用户定义的 comparison 类型。这可以是一个 classstruct。在这个结构定义中,我们重载了函数调用操作符 (operator()),并为 Student 实例的两个 string 键提供了一个比较方法。这种比较将允许 Student 实例按照比较函数对象的顺序插入。

现在,当我们实例化我们的 map 和 map 迭代器时,我们将模板类型扩展的第三个参数指定为我们的 comparison 类型(函数对象)。并且,在这个类型中巧妙地嵌入了一个重载的函数调用操作符 operator(),它将提供所需的比较。剩余的代码将与我们的原始 map 程序类似。

当然,函数对象可以以比我们在这里看到的容器类 map 更多的方式使用,更高级的方式。不过,你现在已经对函数对象如何应用于 STL 有了一定的了解。

现在我们已经看到了如何利用各种 STL 容器类,让我们考虑为什么我们可能想要自定义一个 STL 类,以及如何做到这一点。

自定义 STL 容器

大多数 C++类都可以以某种方式自定义,包括 STL 中的类。然而,我们必须意识到 STL 内部做出的设计决策,这些决策将限制我们如何自定义这些组件。因为 STL 容器类故意不包含虚析构函数或其他虚函数,所以我们不应该通过公有继承来对这些类进行特殊化。请注意,C++不会阻止我们这样做,但我们从第七章通过多态使用动态绑定中了解到,我们永远不应该重写非虚函数。STL 选择不包含虚析构函数和其他虚函数,以允许对这些类进行进一步的特殊化,这是很久以前在 STL 容器被设计时做出的一个稳健的设计选择。

然而,我们可以使用私有或保护继承,或者使用包含或关联的概念,将 STL 容器类作为构建块来使用。也就是说,为了隐藏新类的底层实现,其中 STL 为新类提供了一个稳健且隐藏的实现。我们只需为新类提供一个自己的公共接口,并在幕后将工作委托给我们的底层实现(无论是私有或保护基类,还是包含或关联的对象)。

在扩展任何模板类时,包括使用私有或保护基类的 STL 中的模板类,必须非常小心和谨慎。这种谨慎也适用于包含或关联到其他模板类。模板类通常只有在创建了具有特定类型的模板类实例之后才会编译(或进行语法检查)。这意味着创建的任何派生或包装类只能在创建了特定类型的实例后才能完全测试。

对于新类,需要放置适当的重载运算符,以便这些运算符可以自动与自定义类型一起工作。请记住,某些运算符函数,如operator=,并不是从基类显式继承到派生类的,并且需要为每个新类编写。这是合适的,因为派生类可能需要完成比operator=的通用版本更多的工作。记住,如果你不能修改需要选择重载运算符的类的定义,你必须将该运算符函数实现为一个外部函数。

除了自定义容器外,我们还可以选择增强 STL 中现有算法的算法。在这种情况下,我们会使用许多 STL 函数之一作为新算法底层实现的一部分。

在编程中,从现有库中定制类是常见的。例如,考虑我们如何在第十一章“处理异常”中扩展标准库 exception 类以创建自定义异常(尽管该场景使用了公有继承,这不会应用于自定义 STL 类)。记住,STL 提供了一套非常完整的容器类。你很少需要增强 STL 类——可能只有针对特定领域需求的类。然而,你现在知道定制 STL 类所涉及的风险。记住,在增强类时必须小心谨慎。我们现在可以看到,为任何我们创建的类进行适当的面向对象组件测试是必要的。

我们已经考虑了如何在我们的程序中自定义 STL 容器类和算法。我们也看到了许多 STL 容器类的实际应用示例。现在,在进入下一章之前,让我们简要回顾这些概念。

摘要

在本章中,我们将 C++ 知识扩展到面向对象语言特性之外,以熟悉 C++ 标准模板库。由于这个库在 C++ 中使用非常普遍,因此了解它包含的类的范围和广度至关重要。我们现在准备在我们的代码中使用这些有用且经过良好测试的类。

我们已经研究了相当多的 STL 示例;通过检查选定的 STL 类,我们应该能够独立理解 STL 的其余部分(或任何 C++ 库)。

我们已经看到了如何使用常见的和基本的 STL 类,如 listiteratorvectordequestackqueuepriority_queuemap。我们还看到了如何结合容器类使用函数对象。我们被提醒,我们现在有工具可以定制任何类,甚至可以通过私有或保护继承,或者通过包含或关联来定制来自类库(如 STL)的类。

通过检查选定的 STL 类,我们还看到我们有能力理解 STL 的剩余深度和广度,以及解码许多对我们可用的其他类库。在我们导航每个成员函数的原型时,我们注意到关键语言概念,例如使用 const,或者一个方法返回一个指向表示可写内存的对象的引用。每个原型都揭示了新类使用的机制。在编程努力中走到这一步是非常令人兴奋的!

通过在 C++ 中浏览 STL,我们已经通过 C++ 增加了额外的、有用的功能到我们的 C++ 资料库。使用 STL(封装传统数据结构)将确保我们的代码可以轻松被其他无疑也在使用 STL 的程序员理解。依赖经过良好测试的 STL 来确保这些常见容器和实用工具,可以确保我们的代码更加无错误。

现在我们准备继续前进到第十五章测试类和组件。我们希望用有用的 OO 组件测试技能来补充我们的 C++编程技能。测试技能将帮助我们了解我们是否以健壮的方式创建了、扩展了或增强了类。这些技能将使我们成为更好的程序员。让我们继续前进!

问题

  1. 用从第十三章的练习中替换你的模板Array类,即使用模板。创建一个Student实例的vector。使用vector操作在向量中插入、检索、打印、比较和删除对象。或者,使用 STL 的list。利用这个机会使用 STL 文档来导航这些类可用的全部操作集。

a. 考虑是否需要重载运算符。考虑是否需要一个iterator来提供对集合的安全交错访问。

b. 创建第二个Student实例的vector。将一个赋值给另一个。打印两个向量。

  1. 将本章中的map修改为根据lastName而不是studentId索引Student实例的哈希表(map)。

  2. 将本章中的queue示例修改为使用priority_queue。确保使用优先入队机制priority_queue::emplace()将元素添加到priority_queue中。你还需要使用top()而不是front()。注意,priority_queue可以在<queue>头文件中找到。

  3. 尝试使用sort()算法。确保包含#include <algorithm>。对一个整数数组进行排序。记住,许多容器都有内置的排序机制,但本地集合类型,如语言提供的数组,则没有(这就是为什么你应该使用基本整数数组)。

第十五章:测试类和组件

本章将继续我们的追求,通过探索测试构成我们面向对象程序类和组件的方法,来增加你的 C++编程知识库,而不仅仅是面向对象的概念。我们将探讨各种策略,以确保我们编写的代码经过充分测试且稳健。

本章展示了如何通过测试单个类以及测试协同工作的各种组件来测试你的面向对象程序。

在本章中,我们将涵盖以下主要主题:

  • 理解典型类形式和创建稳健类

  • 创建驱动程序以测试类

  • 测试通过继承、关联或聚合相关的类

  • 测试异常处理机制

在本章结束时,你将拥有各种技术,以确保你的代码在生产前经过充分测试。具备持续生产稳健代码的技能将帮助你成为一个更有益的程序员。

通过研究面向对象测试的各种技术,让我们增加我们的 C++技能集。

技术要求

完整程序示例的在线代码可以在以下 GitHub URL 找到:github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/tree/main/Chapter15。每个完整程序示例都可以在 GitHub 仓库中找到,位于相应章节标题(子目录)下的文件中,该文件以章节编号开头,后面跟着一个连字符,然后是本章中的示例编号。例如,本章的第一个完整程序可以在上述 GitHub 目录下的Chapter15子目录中找到,文件名为Chp15-Ex1.cpp

本章的 CiA 视频可在以下网址查看:bit.ly/3AxyLFH

思考面向对象测试

在任何代码部署之前进行软件测试至关重要。测试面向对象软件需要不同于其他类型软件的技术。因为面向对象软件包含类之间的关系,我们必须了解如何测试可能存在于类之间的依赖关系和关系。此外,每个对象可能根据应用于每个实例的操作顺序以及与相关对象的特定交互(例如,通过关联)而处于不同的状态。与过程式应用程序相比,面向对象应用程序的整体控制流程要复杂得多,因为应用于特定对象的操作组合和顺序以及来自相关对象的影响众多。

尽管如此,我们仍然可以应用一些指标和流程来测试面向对象(OO)软件。这些指标和流程包括理解我们可以应用于类指定的惯用语句和模式,以及创建驱动程序来独立测试类以及它们与其他类之间的关系。这些流程还可以包括创建场景,以提供对象可能经历的事件或状态的预期序列。对象之间的关系,如继承、关联和聚合,在测试中变得非常重要;相关对象可以影响现有对象的状态。

让我们通过理解一个简单的模式开始我们的面向对象软件测试之旅,这个模式我们经常可以应用于我们开发的类。这个惯用语句将确保一个类可能是完整的,没有意外的行为。我们将从规范类形式开始。

理解规范类形式

对于 C++中的许多类,遵循类指定的模式以确保新类包含所需的所有组件是合理的。规范类形式是对类的一个稳健的规范,它使类实例能够在初始化、赋值、参数传递以及函数返回值的使用等方面提供统一的行为(类似于标准数据类型)。规范类形式适用于大多数旨在实例化或将成为新派生类公共基类的类。旨在作为私有或保护基类的类(即使它们可能被实例化)可能不会遵循这个惯用语句的所有部分。

一个遵循正统规范形式的类将包括以下内容:

  • 一个默认构造函数(或一个=default原型,以显式允许此接口)

  • 一个复制构造函数

  • 一个重载的赋值运算符

  • 一个虚析构函数

尽管上述任何组件都可以使用=default原型来显式利用默认的系统提供的实现,但现代的偏好正在远离这种做法(因为这些原型通常是多余的)。例外是默认构造函数,如果没有使用=default,在其他构造函数存在的情况下,你将无法获得其接口。

一个遵循扩展规范形式的类将还包括以下内容:

  • 一个移动复制构造函数

  • 一个移动赋值运算符

让我们接下来在下一小节中查看规范类形式的每个组成部分。

默认构造函数

对默认构造函数原型的=default;这在利用类内初始化时特别有用。

此外,如果没有在成员初始化列表中指定其他基类构造函数,将调用给定类的基类的默认构造函数。如果一个基类没有这样的默认构造函数(并且没有提供,因为存在具有不同签名的构造函数),对基类构造函数的隐式调用将被标记为错误。

让我们也考虑多继承的情况,其中出现菱形层次结构,并且使用虚拟基类来消除最派生类实例中最基本类子对象的重复。在这种情况下,除非在负责创建菱形结构的派生类的成员初始化列表中另有指定,否则将调用现在共享的基类子对象的默认构造函数。即使在中级别的成员初始化列表中指定了非默认构造函数,这种情况也会发生;记住,当中级别指定一个可能共享的虚拟基类时,这些指定将被忽略。

拷贝构造函数

对于包含指针数据成员的所有对象,拷贝构造函数通常至关重要。除非程序员提供了拷贝构造函数,否则在应用程序需要时将链接系统提供的拷贝构造函数。系统提供的拷贝构造函数执行所有数据成员的成员级(浅)拷贝。这意味着类的多个实例可能包含指向共享内存块的指针,这些内存块代表应该个性化的数据。除非有意资源共享,否则新实例化的对象中的原始指针数据成员将想要分配自己的内存并将数据值从源对象复制到这个内存中。此外,请记住在派生类的拷贝构造函数中使用成员初始化列表来指定基类的拷贝构造函数以复制基类数据成员。当然,以深度方式复制基类子对象至关重要;此外,基类数据成员必然是私有的,因此在派生类的成员初始化列表中选择基类拷贝构造函数非常重要。

通过指定拷贝构造函数,我们也有助于提供从函数传递(或返回)值时创建对象的预期方式。在这些情况下确保深度拷贝至关重要。用户可能会认为这些拷贝是按值进行的,但如果它们的指针数据成员实际上与源实例共享,那么这并不是真正按值传递(或返回)对象。

重载赋值运算符

一个重载的赋值运算符,就像拷贝构造函数一样,对于所有包含指针数据成员的对象通常也是至关重要的。系统提供的赋值运算符的默认行为是从源对象到目标对象的浅拷贝。同样,当数据成员是原始指针时,除非两个对象想要共享堆数据成员的资源,否则强烈建议重载赋值运算符。目标对象中分配的空间应等于任何此类指针数据成员的数据成员大小。然后应该将每个指针数据成员的内容(数据)从源对象复制到目标对象。

此外,请记住,重载的赋值运算符不是继承的;每个类都负责编写自己的版本。这很有意义,因为派生类不可避免地有比其基类赋值运算符函数更多的数据成员要复制。然而,当在派生类中重载赋值运算符时,请记住调用基类的赋值运算符以执行继承基类成员的深度赋值(这些成员可能是私有的且无法访问)。

虚析构函数

A =default).

移动拷贝构造函数

一个this。然后我们必须将源对象对这些数据成员的指针设置为空,这样两个实例就不会共享动态分配的数据成员。本质上,我们已经移动了(这些指针的内存)。

那么非指针数据成员怎么办?这些数据成员的内存将按常规复制。非指针数据成员的内存以及指针本身的内存(不是那些指针指向的内存)仍然位于源实例中。因此,我们能做的最好的事情是为源对象的指针指定一个空值(nullptr),并在非指针数据成员中放置一个0(或类似值)以指示这些成员不再相关。

我们将使用 C++标准库中找到的move()函数,如下指示移动拷贝构造函数:

Person p1("Alexa", "Gutierrez", 'R', "Ms.");
Person p2(move(p1));  // move copy constructor
Person p3 = move(p2); // also the move copy constructor

此外,对于通过继承相关联的类,我们也会在派生类移动拷贝构造函数的成员初始化列表中使用move()。这将指定基类的移动拷贝构造函数以帮助初始化子对象。

移动赋值运算符

移动赋值运算符与重载的赋值运算符非常相似,对于所有包含指针数据成员的对象来说通常至关重要。然而,目标是再次通过 移动 源对象的动态分配数据到目标对象(而不是执行深拷贝)来节省内存。与重载的赋值运算符一样,我们将测试自赋值,然后从(现有的)目标对象中删除任何先前动态分配的数据成员。然而,然后我们将简单地从源对象复制指针数据成员到目标对象。我们还将使源对象中的指针为空,这样两个实例就不会共享这些动态分配的数据成员。

类似于移动复制构造函数,非指针数据成员将简单地从源对象复制到目标对象,并在源对象中用 nullptr 值替换,以指示未使用。

我们将再次使用 move() 函数,如下所示:

Person p3("Alexa", "Gutierrez", 'R', "Ms.");
Person p5("Xander", "LeBrun", 'R', "Dr.");
p5 = move(p3);  // move assignment; replaces p5

此外,对于通过继承相关联的类,我们还可以指定派生类的移动赋值运算符将调用基类的移动赋值运算符以帮助完成任务。

将规范类形式的组件组合在一起

让我们看看一对采用规范类形式的类的示例。我们将从我们的 Person 类开始。这个例子可以作为完整的程序在我们的 GitHub 仓库中找到:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter15/Chp15-Ex1.cpp

class Person
{
private:    // Note slightly modified data members
    string firstName, lastName;
    char middleInitial = '\0';   // in-class initialization
    // pointer data member to demo deep copy and operator =
    char *title = nullptr;      // in-class initialization
protected: // Assume usual protected member functions exist 
public:
    Person() = default;      // default constructor
    // Assume other usual constructors exist  
    Person(const Person &);  // copy constructor
    Person(Person &&);       // move copy constructor
    virtual ~Person() { delete [] title }; // virtual dest.
    // Assume usual access functions and virtual fns. exist 
    Person &operator=(const Person &);  // assignment op.
    Person &operator=(Person &&);  // move assignment op.
};
// copy constructor
Person::Person(const Person &p): firstName(p.firstName),
      lastName(p.lastName), middleInitial(p.middleInitial)
{ 
    // Perform a deep copy for the pointer data member 
    // That is, allocate memory, then copy contents
    title = new char [strlen(p.title) + 1];
    strcpy(title, p.title);
}
// overloaded assignment operator
Person &Person::operator=(const Person &p)
{
    if (this != &p)  // check for self-assignment
    {
       // delete existing Person ptr data mbrs. for 'this'
       delete [] title;
       // Now re-allocate correct size and copy from source
       // Non-pointer data members are simply copied from
       // source to destination object
       firstName = p.firstName; // assignment btwn. strings
       lastName = p.lastName;
       middleInitial = p.middleInitial;
       title = new char [strlen(p.title) + 1]; // mem alloc 
       strcpy(title, p.title);
    }
    return *this;  // allow for cascaded assignments
}

在前面的类定义中,我们注意到 Person 包含一个默认构造函数、复制构造函数、重载的赋值运算符和一个虚析构函数。在这里,我们采用了传统的规范类形式作为可能有一天会作为公共基类的类的模式。同时请注意,我们添加了移动复制构造函数和移动赋值运算符的原型,以进一步采用扩展规范类形式。

移动复制构造函数 Person(Person &&); 和移动赋值运算符 Person &operator=(Person &&); 的原型包含类型为 Person && 的参数。这些都是 Person & 的例子,将绑定到原始的复制构造函数和重载的赋值运算符,而右值引用参数将绑定到相应的移动方法。

现在我们来看一下对扩展规范类形式有所贡献的方法的定义——Person 的移动构造函数和移动赋值运算符:

// move copy constructor
Person::Person(Person &&p): firstName(p.firstName), 
    lastName(p.lastName), middleInitial(p.middleInitial),
    title(p.title)  // dest ptr takes over src ptr's memory
{   
    // Overtake source object's dynamically alloc. memory
    // or use simple assignments (non-ptr data members) 
    // to copy source object's members in member init. list 
    // Then null-out source object's ptrs to that memory
    // Clear source obj's string mbrs, or set w null char
    p.firstName.clear(); // set src object to empty string
    p.lastName.clear();
    p.middleInitial = '\0'; // null char indicates non-use
    p.title = nullptr; // null out src ptr; don't share mem
}
// move overloaded assignment operator
Person &Person::operator=(Person &&p)
{ 
    if (this != &p)       // check for self-assignment
    {
        // delete destination object's ptr data members
        delete [] title;      
        // for ptr mbrs: overtake src obj's dynam alloc mem
        // and null source object's pointers to that memory
        // for non-ptr mbrs, a simple assignment suffices
        // followed by clearing source data member
        firstName = p.firstName;  // string assignment
        p.firstName.clear();   // clear source data member 
        lastName = p.lastName;
        p.lastName.clear();
        middleInitial = p.middleInitial; // simple =
        p.middleInitial = '\0'; // null char shows non-use
        title = p.title; // ptr assignment to take over mem
        p.title = nullptr;   // null out src pointer
    }
    return *this;  // allow for cascaded assignments  
}

注意,在前面的移动构造函数中,对于指针类型的成员变量,我们通过在成员初始化列表中使用简单的指针赋值来接管源对象的动态分配的内存(而不是像在深度复制构造函数中那样使用内存分配)。然后我们在构造函数的主体中将源对象的指针数据成员设置为nullptr值。对于非指针数据成员,我们简单地从源对象复制值到目标对象,并在源对象中放置一个零值或空值(例如,对于p.middleInitial使用'\0'或使用clear()对于p.firstName),以指示其进一步的非使用。

在移动赋值运算符中,我们检查自赋值,然后采用相同的方案,仅通过简单的指针赋值将动态分配的内存从源对象移动到目标对象。我们也复制简单的数据成员,当然,用空指针(nullptr)或零值替换源对象的数据值,以指示其进一步的非使用。*this的返回值允许级联赋值。

现在,让我们看看派生类Student如何利用其基类组件,同时采用正统和扩展规范类形式来实现选定的惯用方法:

class Student: public Person
{
private:  
    float gpa = 0.0;        // in-class initialization
    string currentCourse;
    // one pointer data member to demo deep copy and op=
    const char *studentId = nullptr; // in-class init.
    static int numStudents; 
public:
    Student();                 // default constructor
    // Assume other usual constructors exist  
    Student(const Student &);  // copy constructor
    Student(Student &&);       // move copy constructor
    ~Student() override;       // virtual destructor
    // Assume usual access functions exist 
    // as well as virtual overrides and additional methods
    Student &operator=(const Student &);  // assignment op.
    Student &operator=(Student &&);  // move assignment op.
};
// See online code for default constructor implementation
// as well as implementation for other usual member fns.
// copy constructor
Student::Student(const Student &s): Person(s), 
                 gpa(s.gpa), currentCourse(s.currentCourse)
{   // Use member init. list to specify base copy 
    // constructor to initialize base sub-object
    // Also use mbr init list to set most derived data mbrs
    // Perform deep copy for Student ptr data members 
    // use temp - const data can't be directly modified 
    char *temp = new char [strlen(s.studentId) + 1];
    strcpy (temp, s.studentId);
    studentId = temp;
    numStudents++;
}
// Overloaded assignment operator
Student &Student::operator=(const Student &s)
{
   if (this != &s)   // check for self-assignment
   {   // call base class assignment operator
       Person::operator=(s); 
       // delete existing Student ptr data mbrs for 'this'
       delete [] studentId;
       // for ptr members, reallocate correct size and copy
       // from source; for non-ptr members, just use =
       gpa = s.gpa;  // simple assignment
       currentCourse = s.currentCourse;
       // deep copy of pointer data mbr (use a temp since
       // data is const and can't be directly modified)
       char *temp = new char [strlen(s.studentId) + 1];
       strcpy (temp, s.studentId);
       studentId = temp;
   }
   return *this;
}

在前面的类定义中,我们再次看到Student包含一个默认构造函数、一个复制构造函数、一个重载的赋值运算符和一个虚析构函数,以完成正统的规范类形式。

然而,请注意,在Student复制构造函数中,我们通过成员初始化列表指定了使用Person复制构造函数。同样,在Student重载的赋值运算符中,一旦我们检查到自赋值,我们就调用Person中的重载赋值运算符来帮助我们完成任务,使用Person::operator=(s);

现在让我们看看对Student扩展规范类形式做出贡献的方法定义——移动复制构造函数和移动赋值运算符:

// move copy constructor
Student::Student(Student &&s): Person(move(s)), gpa(s.gpa),
    currentCourse(s.currentCourse), 
    studentId(s.studentId) // take over src obj's resource 
{   
    // First, use mbr. init. list to specify base move copy 
    // constructor to initialize base sub-object. Then
    // overtake source object's dynamically allocated mem.
    // or use simple assignments (non-ptr data members) 
    // to copy source object's members in mbr. init. list.
    // Then null-out source object's ptrs to that memory or 
    // clear out source obj's string mbrs. in method body
    s.gpa = 0.0;     // then zero-out source object member
    s.currentCourse.clear();  // clear out source member
    s.studentId = nullptr; // null out src ptr data member
    numStudents++;  // it is a design choice whether or not 
    // to inc. counter; src obj is empty but still exists
}
// move assignment operator
Student &Student::operator=(Student &&s)
{
   // make sure we're not assigning an object to itself
   if (this != &s)
   {
      Person::operator=(move(s));  // call base move oper=
      delete [] studentId;  // delete existing ptr data mbr
      // for ptr data members, take over src objects memory
      // for non-ptr data members, simple assignment is ok
      gpa = s.gpa; // assignment of source to dest data mbr
      s.gpa = 0.0; // zero out source object data member
      currentCourse = s.currentCourse; // string assignment
      s.currentCourse.clear(); // set src to empty string
      studentId = s.studentId; // pointer assignment
      s.studentId = nullptr;   // null out src ptr data mbr
   }
   return *this;  // allow for cascaded assignments
}

注意,在之前列出的Student移动复制构造函数中,我们在成员初始化列表中指定了使用基类的移动复制构造函数。Student移动复制构造函数的其余部分与Person基类中的类似。

同样,让我们注意到在Student移动赋值运算符中,调用了基类的移动赋值运算符Person::operator=(move(s));。此方法的其他部分与基类中的类似。

一个好的经验法则是,大多数非平凡类应该至少利用正统的规范类形式。当然,也有一些例外。例如,一个仅作为受保护的或私有基类的类不需要有虚拟析构函数,因为派生类实例不能超出非公共继承边界向上转换。同样,如果我们有充分的理由不希望有副本或禁止赋值,我们可以在这些方法的扩展签名中使用= delete指定来禁止副本或赋值。

尽管如此,规范类形式将为采用这种语法的类增加健壮性。程序员将重视使用这种语法在初始化、赋值和参数传递方面的类之间的统一性。

让我们继续前进,看看与规范类形式互补的一个想法,那就是健壮性。

确保类是健壮的

C++的一个重要特性是能够构建用于广泛重用的类库。无论我们是否希望实现这一目标,或者只是希望为我们自己的组织提供可靠的代码,我们的代码都必须是健壮的。一个健壮的类应该经过良好的测试,应遵循规范类形式(除了在受保护的和私有的基类中需要虚拟析构函数外),并且应该是可移植的(或包含在特定平台的库中)。任何可能被重用或将在任何专业场合使用的类,绝对必须是健壮的。

一个健壮的类必须确保给定类的所有实例都是完全构建的。一个完全构建的对象是指所有数据成员都适当地初始化了。给定类的所有构造函数(包括拷贝构造函数)都必须经过验证以确保初始化所有数据成员。数据成员加载的值应该检查范围适宜性。记住,未初始化的数据成员可能是一个潜在的灾难!如果给定的构造函数没有正确完成或者数据成员的初始值不合适,应该采取预防措施。

可以使用各种技术来验证完全构建的对象。一种(不推荐)的基本技术是在每个类中嵌入一个状态数据成员(或从或嵌入一个状态祖先/成员)。在成员初始化列表中将状态成员设置为0,在构造函数的最后一条语句中将它设置为1。在实例化后检查这个值。这种方法的一个巨大缺陷是用户肯定会忘记检查完全构建的成功标志。

与上述简单方案不同的替代方案是,对于所有简单数据类型,利用课堂初始化,将这些成员在替代构造函数的成员初始化列表中重置为所需的值。实例化之后,可以再次探测这些值,以确定是否成功完成了替代构造函数。这仍然远非理想的实现。

一种更好的技术是利用异常处理。将异常处理嵌入到每个构造函数中是理想的。如果数据成员没有在合适的范围内初始化,首先尝试重新输入它们的值,或者打开一个备用的数据库进行输入,例如。作为最后的手段,你可以抛出一个异常来报告未完全构建的对象。我们将在本章稍后更详细地检查与测试相关的异常处理。

同时,让我们继续探讨一种严格测试我们的类和组件的技术——创建用于测试类的驱动程序。

创建驱动程序以测试类

第五章《详细探索类》中,我们简要地讨论了将代码拆分为源文件和头文件。让我们简要回顾一下。通常,头文件将以类的名称命名(例如Student.h),并包含类的定义以及任何内联成员函数的定义。通过将内联函数放在头文件中,如果它们的实现发生变化(因为头文件随后包含在每个源文件中,从而与该头文件建立依赖关系),它们将被正确地重新展开。

每个类的方法实现将放置在相应的源代码文件中(例如Student.cpp),该文件将包含基于它的头文件(即#include "Student.h")。注意,双引号表示该头文件位于我们的当前工作目录中;我们也可以指定一个路径来查找头文件。相比之下,与 C++库一起使用的尖括号告诉预处理器在编译器预先指定的目录中查找。此外,请注意,每个派生类的头文件将包含其基类的头文件(这样它可以看到成员函数原型)。

注意,任何静态数据成员或方法定义都将出现在它们相应的源代码文件中(这样每个应用程序将只有一个定义)。

在考虑到这个头文件和源代码文件结构的情况下,我们现在可以创建一个驱动程序来测试每个单独的类或每个紧密相关的类的分组(例如,通过关联或聚合相关的类)。通过继承相关的类可以在它们自己的单独的驱动程序文件中进行测试。每个驱动程序文件可以命名为反映正在测试的类,例如StudentDriver.cpp。驱动程序文件将包含正在测试的类(的)相关头文件。当然,在编译过程中,相关类的源文件会被编译并链接到驱动程序文件中。

驱动文件可以简单地包含一个main()函数作为测试床,以实例化相关的类,并作为测试每个成员函数的范围。驱动程序将测试默认实例化、典型实例化、拷贝构造、对象之间的赋值以及类中每个额外的方法。如果存在虚拟析构函数或其他虚拟函数,我们应该实例化派生类实例(在派生类的驱动程序中),将这些实例向上转换为使用基类指针存储,然后调用虚拟函数以验证是否发生正确的行为。在虚拟析构函数的情况下,我们可以通过删除动态分配的实例(或等待栈实例超出作用域)并使用调试器单步执行来跟踪析构序列中的入口点,以验证一切是否如预期。

我们还可以测试对象是否完全构造;我们将在稍后了解更多关于这个话题的内容。

假设我们有一个常见的PersonStudent类层次结构,以下是一个简单的驱动程序(包含main()的文件)来测试Student类。这个驱动程序可以在我们的 GitHub 仓库中找到。要制作一个完整的程序,您还需要编译并链接同一目录中找到的Student.cppPerson.cpp文件。以下是驱动程序的 GitHub 仓库 URL:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter15/Chp15-Ex2.cpp

#include "Person.h"  // include relevant class header files
#include "Student.h"
using std::cout;    // preferred to: using namespace std;
using std::endl;
constexpr int MAX = 3;
int main() // Driver to test Student class, stored in above
{          // filename for chapter example consistency 
    // Test all instantiation means, even copy constructor
    Student s0; // Default construction
    // alternate constructor
    Student s1("Jo", "Li", 'H', "Ms.", 3.7, "C++", 
               "UD1234");
    Student s2("Sam", "Lo", 'A', "Mr.", 3.5, "C++",
               "UD2245");
    // These initializations implicitly invoke copy const.
    Student s3(s1);
    Student s4 = s2;   // This is also initialization
    // Test the assignment operator
    Student s5("Ren", "Ze", 'A', "Dr.", 3.8, "C++",
               "BU5563");
    Student s6;
    s6 = s5;  // this is an assignment, not initialization
    // Test each public method. A sample is shown here
    s1.Print();  // Be sure to test each method! 

    // Generalize derived instances as base types 
    // Do the polymorphic operations work as expected?
    Person *people[MAX] = { }; // initialized with nullptrs
    // base instance for comparison
    people[0] = new Person("Juliet", "Martinez", 'M',
                           "Ms.");
    // derived instances, generalized with base class ptrs.   
    people[1] = new Student("Zack", "Moon", 'R', "Dr.",
                            3.8, "C++", "UMD1234");  
    people[2] = new Student("Gabby", "Doone", 'A', "Dr.", 
                            3.9, "C++", "GWU4321");
    for (auto *item : people)  // loop through all elements
    {
       item->IsA();
       cout << "  ";
       item->Print();
    }
    // Test destruction sequence (dynam. alloc. instances)
    for (auto *item : people)  // loop thru all elements
       delete item;   // engage virtual dest. sequence
    return 0;
}

简要回顾前面的程序片段,我们可以看到我们已经测试了每种实例化方式,包括拷贝构造函数。我们还测试了赋值运算符,验证了每个成员函数的工作情况(示例方法如下),并验证了虚拟函数(包括虚拟析构函数)按预期工作。

现在我们已经看到基本驱动程序测试了我们的类,让我们考虑一些在测试通过继承、关联或聚合相关的类时可以使用的额外指标。

测试相关类

在面向对象程序中,仅仅测试单个类以验证完整性和健壮性是不够的,尽管这些都是好的起点。完整性不仅意味着遵循规范类形式,而且还确保数据成员有使用适当访问方法(当不修改实例时标记为const)的安全访问方式。完整性还验证了面向对象设计指定的所需接口是否已实现。

健壮性使我们验证所有上述方法是否已在适当的驱动程序中测试过,评估了平台独立性,并验证了每种实例化的方式都导致一个完全构建的对象。我们可以通过数据成员的阈值测试来增强这种类型的测试,例如,注意何时抛出异常。虽然看似全面,但完整性和健壮性实际上是面向对象组件测试的最直接手段。

更具挑战性的测试方法是测试相关类之间的交互。

测试通过继承、关联或聚合相关联的类

通过各种对象关系相关联的类需要各种额外的测试手段。具有彼此之间不同关系的对象可能会影响给定实例在其应用程序生命周期内可能具有的状态进展。这种测试将需要最详细的工作。我们将发现场景对于帮助我们捕捉相关对象之间的通常交互是有用的,从而导致测试相互交互的类的更全面的方法。

让我们先考虑如何测试与继承相关的类。

添加测试继承的策略

通过公共继承相关联的类需要验证虚函数。例如,所有预期的派生类方法是否都被覆盖了?记住,如果基类行为在派生类级别仍然被认为是合适的,派生类不需要覆盖其基类中指定的所有虚函数。将实现与设计进行比较是必要的,以确保我们已用适当的方法覆盖了所有必需的多态操作。

当然,虚函数的绑定是在运行时完成的(即动态绑定)。创建派生类实例并使用基类指针存储它们,以便应用多态操作,这将是重要的。然后我们需要验证派生类行为是否显现出来。如果没有,我们可能发现自己处于一个意外的函数隐藏情况,或者可能是基类操作没有按照预期标记为virtual(记住,在派生类级别,关键字virtualoverride虽然很好且推荐,但不是必需的,并且不影响动态行为)。

虽然通过继承相关联的类有独特的测试策略,但请记住,实例化将创建一个单一的对象,即基类或派生类类型。当我们实例化此类类型时,我们有一个这样的实例,而不是一对共同工作的实例。派生类仅仅有一个基类子对象,它是它自身的一部分。让我们考虑一下这与关联对象或聚合体相比如何,这些可以是独立的对象(关联),可能与其伴侣相互作用。

添加测试聚合和关联的策略

通过关联或聚合相关联的类可能存在多个实例相互通信并相互引起状态变化。这肯定比继承的对象关系更复杂。

通过聚合相关联的类通常比通过关联相关联的类更容易测试。考虑到最常见的聚合形式(组合),嵌入式(内部)对象是外部(整体)对象的一部分。当外部对象被实例化时,我们得到嵌入在整体中的内部对象的内存。与包含基类子对象的派生类实例的内存布局相比,内存布局并没有太大不同(除了可能的顺序)。在每种情况下,我们仍然在处理一个单一实例(尽管它有嵌入式部分)。然而,在测试中的比较点是,应用于整体的操作通常被委托给部分或组件。我们将严格需要测试整体上的操作,以确保它们将必要的信息委托给每个部分。

通过较少使用的通用聚合形式(其中整体包含对部分的指针,而不是典型的组合嵌入式对象实现)相关联的类,与关联有类似的问题,因为实现是相似的。考虑到这一点,让我们看看与关联对象相关的测试问题。

通过关联相关联的类通常是独立存在的对象,它们在应用中的某个时刻创建了彼此之间的链接。在应用中,两个对象创建彼此之间的链接可能有一个预定的点,也可能没有。对一个对象应用的操作可能会引起相关对象的变化。例如,让我们考虑一个Student(学生)和一个Course(课程)。它们可能独立存在,然后在应用中的某个时刻,一个Student可能通过Student::AddCourse()添加一个Course。通过这样做,不仅特定的Student实例现在包含了对特定Course实例的链接,而且Student::AddCourse()操作已经引起了Course类的变化。那个特定的Student实例现在成为特定Course实例名单的一部分。在任何时候,Course都可能被取消,从而影响所有注册该CourseStudent实例。这些变化反映了每个相关对象可能存在的状态。例如,一个Student可能处于当前注册退课的状态。有许多可能性。我们如何测试所有这些情况?

添加场景以帮助测试对象关系

在面向对象分析中,场景的概念被提出作为一种既可创建面向对象设计又可对其进行测试的手段。场景是对应用中可能发生的一系列事件的描述性遍历。一个场景将展示类以及它们在特定情况下可能如何相互交互。许多相关的场景可以收集到面向对象的用例概念中。在面向对象分析和设计阶段,场景有助于确定应用中可能存在的类以及每个类可能具有的操作和关系。在测试中,场景可以被重用来形成驱动器创建的基础,以测试各种对象关系。考虑到这一点,可以开发一系列驱动器来测试许多场景(即用例)。这种类型的建模将能够比最初简单测试完整性和鲁棒性的方法更全面地为相关对象提供测试平台。

任何类型的相关类之间的另一个关注领域是版本控制。例如,如果基类定义或默认行为发生变化,会发生什么?这将对派生类有何影响?这将对相关对象有何影响?随着每次变化,我们不可避免地需要重新访问所有相关类的组件测试。

接下来,让我们考虑异常处理机制如何在面向对象组件测试中发挥作用。

测试异常处理机制

现在我们能够创建用于测试每个类(或相关类的组合)的驱动程序,我们将希望了解我们代码中的哪些方法可能会抛出异常。对于这些场景,我们希望在驱动程序中添加 try 块以确保我们知道如何处理每个潜在的异常。在这样做之前,我们应该问自己,在开发过程中,我们是否在我们的代码中包含了足够的异常处理?例如,考虑到实例化,我们的构造函数是否检查对象是否完全构建?如果没有,它们是否会抛出异常?如果答案是“否”,那么我们的类可能没有我们预期的那么健壮。

让我们考虑将异常处理嵌入到构造函数中,以及我们如何构建一个驱动程序来测试所有可能的实例化方法。

在构造函数中嵌入异常处理以创建健壮的类

我们可能还记得我们最近的第十一章处理异常,我们可以创建自己的异常类,这些类是从 C++ 标准库 exception 类派生出来的。让我们假设我们已经创建了一个这样的类,即 ConstructionException。如果在构造函数的任何点上我们无法正确初始化给定的实例以提供一个完全构建的对象,我们可以从任何构造函数中抛出 ConstructionException。抛出 ConstructionException 的潜在含义是,我们现在应该将实例化包含在 try 块中,并添加匹配的 catch 块来预测可能抛出的 ConstructionException。然而,请记住,在 try 块作用域内声明的实例的作用域仅限于 try-catch 对。

好消息是,如果一个对象没有完成构建(即,如果在构造函数完成之前抛出了异常),那么从技术上讲,该对象将不存在。如果一个对象在技术上不存在,那么就没有必要清理部分实例化的对象。然而,我们需要考虑,如果我们预期的实例没有完全构建,这对我们的应用程序意味着什么?这将如何改变我们代码的执行流程?测试的一部分是确保我们已经考虑了我们的代码可能被使用的所有方式,并相应地使我们的代码更加健壮!

重要的是要注意,引入 trycatch 块可能会改变我们的程序流程,并且在我们的驱动程序中包含这种类型的测试是至关重要的。在我们进行测试时,我们可能会寻找考虑 trycatch 块的场景。

我们已经看到如何增强我们的测试驱动程序以适应可能会抛出异常的类。我们也在本章中讨论了在我们的驱动程序中添加场景以帮助跟踪具有关系的对象之间的状态,以及当然,我们可以遵循的简单类习惯用法,以帮助我们取得成功。现在,在我们继续到下一章之前,让我们简要回顾这些概念。

摘要

在本章中,通过检查各种面向对象类和组件测试实践和策略,我们提高了成为更好的 C++ 程序员的能力。我们的主要目标是确保我们的代码健壮、经过良好测试,并且可以无错误地部署到我们的各个组织中。

我们考虑了编程惯用法,例如遵循经典类形式以确保我们的类完整,并且对于构造/析构、赋值以及在参数传递和函数返回值中的使用具有预期的行为。我们讨论了创建健壮类意味着什么——即遵循经典类形式且经过良好测试、平台无关且对完全构造的对象进行了测试的类。

我们还探讨了如何创建驱动程序来测试单个类或相关类的集合。我们为驱动程序中的单个类建立了一个测试清单。我们更深入地研究了对象关系,以了解相互交互的对象需要更复杂的测试。也就是说,当对象从一个状态移动到另一个状态时,它们可能会受到相关对象的影响,这可能会进一步改变它们的进程。我们为我们的驱动程序添加了利用场景作为测试用例,以更好地捕捉实例在应用程序中可能移动的动态状态。

最后,我们已经探讨了异常处理机制如何影响我们测试代码的方式。我们已经增强了我们的驱动程序,以考虑 try 和 catch 块可能会将我们的应用程序从预期的典型流程中重定向的流程控制。

我们现在可以继续前进,进入我们书籍的下一部分,即 C++ 中的设计模式和惯用法。我们将从第十六章开始,使用观察者模式。在接下来的章节中,我们将了解如何应用流行的设计模式并将它们应用于我们的编码。这些技能将使我们成为更好的程序员。让我们继续前进!

问题

  1. 考虑到你的前一个练习中的一个包含对象关系的类对(提示——与关联相比,公共继承更容易考虑)。

    1. 你的类遵循经典类形式吗?是正则的还是扩展的?为什么,或者为什么不?如果它们不遵循并且应该遵循,请修改类以遵循这个惯用法。

    2. 你认为你的类是健壮的吗?为什么,或者为什么不?

  2. 创建一个(或两个)驱动程序来测试你的类对:

    1. 确保测试以下常规清单项目(构造、赋值、析构、公共接口、向上转型(如果适用)以及虚拟函数的使用)。

    2. (可选)如果你选择了通过关联使用的关系相关的两个类,创建一个单独的驱动程序来遵循典型场景,详细说明两个类的交互。

    3. 确保在你的测试驱动程序中包含异常处理的测试。

  3. 创建一个ConstructionException类(从 C++标准库的exception派生)。在示例类中嵌入检查,在必要时抛出ConstructionException。确保将此类的所有实例化形式都包含在适当的trycatch块对中。

第四部分:C++中的设计模式和惯用法

本部分的目标是扩展你的 C++技能库,不仅限于面向对象编程和其他必要技能,还包括核心设计模式的知识。设计模式提供了经过验证的技术和策略来解决重复出现的 OO 问题。本节介绍了常见的设计模式,并通过在书中构建创意示例深入展示了如何应用这些模式。每一章都包含详细的代码示例,以说明每个模式。

本节的第一章介绍了设计模式的概念,并讨论了在编码解决方案中利用此类模式的优势。第一章还介绍了观察者模式,并提供了深入理解该模式各个组成部分的程序示例。

下一章解释了工厂方法模式,并同样提供了详细的程序,展示了如何实现带和不带对象工厂的工厂方法模式。本章还比较了对象工厂与抽象工厂。

下一章介绍了适配器模式,并提供了使用继承与关联实现适配器类的实现策略和程序示例。此外,还展示了适配器作为一个简单的包装类。

下一章将探讨单例模式。在两个简单的示例之后,展示了使用配对类实现的详细示例。还介绍了用于容纳单例的注册表。

本节和本书的最后一章介绍了 pImpl 模式,以减少代码中的编译时依赖。提供了一个基本实现,然后使用唯一指针进行扩展。此外,还探讨了与该模式相关的性能问题。

本部分包括以下章节:

  • 第十六章, 使用观察者模式

  • 第十七章, 应用工厂模式

  • 第十八章, 应用适配器模式

  • 第十九章, 使用单例模式

  • 第二十章, 使用 pImpl 模式去除实现细节

第四部分:C++中的设计模式和惯用法

第十六章:使用观察者模式

本章将开始我们的探索之旅,旨在扩展您的 C++ 编程知识库,超越面向对象的概念,目标是让您能够通过利用常见的设计模式来解决重复出现的编程问题。设计模式还将增强代码维护性,并为潜在的代码重用提供途径。

从本章开始,本书的第四部分的目标是展示和解释流行的设计模式和惯用法,并学习如何在 C++ 中有效地实现它们。

本章将涵盖以下主要主题:

  • 理解利用设计模式的优势

  • 理解观察者模式及其对面向对象编程的贡献

  • 理解如何在 C++ 中实现观察者模式

到本章结束时,您将理解在代码中采用设计模式的价值,以及理解流行的观察者模式。我们将通过 C++ 中的示例实现来展示这个模式。利用常见的设计模式将帮助您成为一个更有益和有价值的程序员,使您能够掌握更复杂的编程技术。

让我们通过研究各种设计模式来提高我们的编程技能集,从本章的观察者模式开始。

技术要求

完整程序示例的在线代码可以在以下 GitHub 网址找到:github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/tree/main/Chapter16。每个完整程序示例都可以在 GitHub 仓库中找到,位于相应章节标题(子目录)下的文件中,文件名对应章节编号,后面跟着一个连字符,然后是本章中的示例编号。例如,本章的第一个完整程序可以在上述 GitHub 目录下的 Chapter16 子目录中找到一个名为 Chp16-Ex1.cpp 的文件。

本章的 CiA 视频可以在以下网址观看:bit.ly/3A8ZWoy

利用设计模式

设计模式代表了一组经过良好测试的编程解决方案,用于解决重复出现的编程难题。设计模式代表了设计问题的概念层面,以及类之间如何进行通用协作以提供多种实现方式的解决方案。

在过去 25+ 年的软件开发中,已经识别和描述了许多公认的设计模式。我们将在本书的剩余章节中探讨一些流行的模式,以让您了解如何将流行的软件设计解决方案融入我们的技术编码库中。

我们为什么可能选择使用设计模式?首先,一旦我们确定了一种编程问题类型,我们可以使用其他程序员已经全面测试过的经过验证的解决方案。此外,一旦我们采用设计模式,其他沉浸在我们的代码中(用于维护或未来的增强)的程序员将对我们选择的技术有一个基本理解,因为核心设计模式已成为行业标准。

一些最早的设计模式几乎在 50 年前出现,随着模型-视图-控制器(Model-View-Controller,简称 MVC)范式的出现,后来有时简化为主题-视图。例如,主题-视图是一个基本的模式,其中感兴趣的物体(即主题)将与它的显示方法(即它的视图)松散耦合。主题和它的视图通过一对一的关联进行通信。有时主题可以有多个视图,在这种情况下,主题与多个视图对象相关联。如果一个视图发生变化,可以发送一个状态更新到主题,然后主题可以发送必要的消息到其他视图,以便它们也能更新以反映新的状态可能对其特定视图的修改。

来自早期面向对象语言(如 Smalltalk)的原始模型-视图-控制器(MVC)模式有一个类似的假设,只不过是一个控制器对象在模型(即主题)和它的视图(或视图)之间委派事件。这些初步范式影响了早期的设计模式;主题-视图或 MVC 的元素可以从概念上被视为今天核心设计模式的基本基础。

在本书的剩余部分,我们将回顾的许多设计模式将是四人帮(Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides)在《设计模式:可复用面向对象软件元素》中最初描述的模式的改编。我们将应用和改编这些模式来解决本书早期章节中介绍的应用程序产生的问题。

让我们通过调查一个实际应用中的模式来开始我们对理解和利用流行设计模式的追求。我们将从一个被称为观察者模式的行为模式开始。

理解观察者模式

观察者模式中,一个感兴趣的物体将维护一个观察者的列表,这些观察者对主要物体的状态更新感兴趣。观察者将维护对其感兴趣物体的链接。我们将把感兴趣的物体称为主题。感兴趣物体的列表统称为观察者。主题将通知任何观察者相关的状态变化。一旦观察者被通知主题的任何状态变化,它们将自行采取任何适当的后续行动(通常是通过主题在每个观察者上调用虚拟函数来实现)。

已经,我们可以想象如何使用关联来实现观察者模式。事实上,观察者代表了一对多关联。例如,主题可能使用 STL list(或vector)来收集一组观察者。每个观察者都将包含对主题的关联。我们可以想象一个对主题的重要操作,对应于主题的状态变化,向其观察者列表发出更新,以通知它们状态变化。实际上,当主题的状态发生变化时,Notify()方法会被调用,并在主题的每个观察者列表上统一应用多态的观察者Update()方法。在我们陷入实现之前,让我们考虑构成观察者模式的关键组件。

观察者模式将包括以下内容:

  • 主题,或感兴趣的物体。主题将维护一个观察者对象列表(多边关联)。

  • 主题将提供一个接口来Register()Remove()观察者。

  • 主题将包括一个Notify()接口,当主题的状态发生变化时,将更新其观察者。主题将通过在其集合中的每个观察者上调用多态的Update()方法来Notify()观察者。

  • 观察者类将被建模为一个抽象类(或接口)。

  • 观察者接口将提供一个抽象的多态Update()方法,当其关联的主题改变其状态时将被调用。

  • 每个观察者与其主题之间的关联将在一个从观察者派生的具体类中维护。这样做将减轻尴尬的类型转换(与在抽象观察者类中维护主题链接相比)。

  • 这两个类都将能够维护它们当前的状态。

上述SubjectObserver类被指定为通用类型,以便它们可以与各种具体的类(主要通过继承)结合使用,这些类希望使用观察者模式。通用的主题和观察者提供了很好的重用机会。在设计模式中,模式的核心元素通常可以更通用地设置,以便允许代码本身的重用,而不仅仅是解决方案(模式)概念的重用。

让我们继续前进,看看观察者模式的示例实现。

实现观察者模式

要实现观察者模式,我们首先需要定义我们的SubjectObserver类。然后,我们需要从这些类派生出具体的类,以包含我们的应用程序特定内容,并使模式生效。让我们开始吧!

创建观察者、主题和特定领域的派生类

在我们的示例中,我们将创建 SubjectObserver 类来建立将 Observer 注册到 Subject 的框架,以及 Subject 通知其观察者可能的状态变化的机制。然后,我们将从这些基类派生出我们习惯看到的派生类 - CourseStudent,其中 Course 将是我们的具体 Subject,而 Student 将成为我们的具体 Observer

我们将要模拟的应用将涉及课程注册系统和等待名单的概念。正如我们在 第十章问题 2 中所看到的,实现关联、聚合和组合,我们将模拟一个 Student 与多个 Course 实例的关联,以及一个 Course 与多个 Student 实例的关联。当我们模拟等待名单时,观察者模式将发挥作用。

我们的 Course 类将派生自 Subject。我们将继承的观察者列表将代表此 Course 的等待名单上的 Student 实例。Course 还将有一个 Student 实例列表,代表成功注册了当前课程的 Student

我们的 Student 类将派生自 PersonObserverStudent 将包括一个 Course 实例列表,其中包含该 Student 当前注册的课程。Student 还将有一个数据成员 waitListedCourse,它对应于一个 Student 正在等待添加的 Course 的关联。这个 等待名单Course 代表我们将从中接收通知的 Subject。一个通知将对应于一个状态变化,表明 Course 现在有空间让一个 Student 添加该 Course

Student 将从 Observer 继承多态操作 Update(),这对应于 Student 被通知 Course 中现在有空位。在这里,在 Student::Update() 中,我们将包括添加学生的 waitListedCourse(前提是课程开放且有可用座位)的机制。如果添加成功,我们将从课程的等待名单(CourseSubject 继承的观察者列表)中释放 Student。自然地,Student 将被添加到 Course 的当前学生名单中,并且该 Course 将出现在该学生的当前课程列表中。

指定观察者和主题

让我们将我们的示例分解成组件,从指定我们的 ObserverSubject 的类对开始。完整的程序可以在我们的 GitHub 上找到:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter16/Chp16-Ex1.cpp

#include <list>    // partial list of #includes
#include <iterator>
using std::cout;   // prefered to: using namespace std;
using std::endl;
using std::setprecision;
using std::string;
using std::to_string;
using std::list;
constexpr int MAXCOURSES = 5, MAXSTUDENTS = 5;
// Simple enums for states; we could have also made a
// hierarchy of states, but let's keep it simple
enum State { Initial = 0, Success = 1, Failure = 2 };
// More specific states for readability in subsequent code
enum StudentState { AddSuccess = State::Success, 
                    AddFailure = State::Failure };
enum CourseState { OpenForEnrollment = State::Success,
                   NewSpaceAvailable = State::Success, 
                   Full = State::Failure };
class Subject;  // forward declarations
class Student;
class Observer  // Observer is an abstract class
{
private:
    // Represent a state as an int, to eliminate type
    // conversions between specific and basic states
    int observerState = State::Initial;  // in-class init.
protected:
    Observer() = default;
    Observer(int s): observerState(s) { }
    void SetState(int s) { observerState = s; }
public:
    int GetState() const { return observerState; }
    virtual ~Observer() = default;
    virtual void Update() = 0;
};

在前面的类定义中,我们引入了我们的抽象Observer类。在这里,我们包括一个observerState和受保护的构造函数来初始化这个状态。我们包括一个受保护的SetState()方法,从派生类的范围更新这个状态。我们还包括一个公共的GetState()方法。GetState()的添加将有助于在SubjectNotify()方法中的实现,因为它允许我们轻松地检查我们的Observer的状态是否已更改。尽管状态信息传统上被添加到ObserverSubject的派生类中,但我们将在这两个基类中泛化状态信息。这将允许我们的派生类保持更独立于模式,并专注于应用程序的本质。

注意,我们的析构函数是虚的,我们引入了一个抽象方法virtual void Update() = 0;来指定Subject将在其观察者列表上调用该接口,以将这些更新委托给这些Observer实例。

现在,让我们看看我们的Subject基类:

class Subject   // Treated as an abstract class, due to
{               // protected constructors. However, there's 
private:        // no pure virtual function
    list<class Observer *> observers;
    int numObservers = 0;
    // Represent a state as an int, to eliminate
    // type conversions between specific and basic states
    int subjectState = State::Initial;
    list<Observer *>::iterator newIter;
protected:
    Subject() = default;
    Subject(int s): subjectState(s) { } // note in-class
                                        // init. above
    void SetState(int s) { subjectState = s; }
public:
    int GetState() const { return subjectState; }
    int GetNumObservers() const { return numObservers; }
    virtual ~Subject() = default;
    virtual void Register(Observer *);
    virtual void Release(Observer *);
    virtual void Notify();
};

在上述Subject类定义中,我们看到我们的Subject包括一个 STL list来收集其Observer实例。它还包括subjectState和一个计数器,以反映观察者的数量。此外,我们还包括一个数据成员来跟踪一个未损坏的迭代器。我们将看到这将在我们删除一个元素时很有用(list::erase()是一个将使当前迭代器无效的操作)。

我们的Subject类也将拥有受保护的构造函数和一个SetState()方法,该方法用于初始化或设置Subject的状态。尽管这个类在技术上不是抽象的(它不包含纯虚函数),但其构造函数是受保护的,以模拟抽象类;这个类仅打算在派生类实例内部作为子对象进行构造。

在公共接口中,我们有一些访问函数来获取当前状态或观察者的数量。我们还有一个虚析构函数,以及Register()Release()Notify()的虚函数。我们将在基类级别提供后三个方法的实现。

接下来,让我们看看Subject基类中Register()Release()Notify()的默认实现:

void Subject::Register(Observer *ob)
{
    observers.push_back(ob); // Add an Observer to the list
    numObservers++;
}
void Subject::Release(Observer *ob) // Remove an Observer 
{                                   // from the list
    bool found = false;
    // loop until we find the desired Observer
    // Note auto iter will be: list<Observer *>::iterator
    for (auto iter = observers.begin();
         iter != observers.end() && !found; ++iter)
    {
        if (*iter == ob)// if we find observer that we seek
        {
            // erase() element, iterator is now corrupt.
            // Save returned (good) iterator; 
            // we'll need it later
            newIter = observers.erase(iter);
            found = true;  // exit loop after found
            numObservers--;
        }
    }
}
void Subject::Notify()
{   // Notify all Observers
    // Note auto iter will be: list<Observer *>::iterator
    for (auto iter = observers.begin(); 
         iter != observers.end(); ++iter)
    {
        (*iter)->Update(); // AddCourse, then Release   
        // Observer. State 'Success' is represented
        // generally for Observer (at this level we have 
        // no knowledge of how Subject and Observer have
        // been specialized). In our application, this
        // means a Student (observer) added a course,
        // got off waitlist (so waitlist had a Release),
        // so we update the iterator
        if ((*iter)->GetState() == State::Success)
            iter = newIter; // update the iterator since
    }                       // erase() invalidated this one
    if (!observers.empty())
    {   // Update last item on waitlist
        Observer *last = *newIter; 
        last->Update();
    }
}

在上述Subject成员函数中,让我们首先检查void Subject::Register(Observer *)方法。在这里,我们只是将作为参数指定的Observer *添加到我们的 STL 观察者list中(并增加观察者数量的计数器)。

接下来,让我们考虑Register()的逆操作,通过审查void Subject::Release(Observer *)。在这里,我们遍历观察者列表,直到找到我们正在寻找的观察者。然后我们在当前项上调用list::erase(),将我们的found标志设置为true(以退出循环),并减少观察者的数量。注意,我们还保存了list::erase()的返回值,这是一个更新(且有效)的观察者列表迭代器。循环中的迭代器iter在我们的list::erase()调用后已失效。我们将这个修订的迭代器保存在数据成员newIter中,以便我们稍后可以访问它。

最后,让我们看看Subject中的Notify()方法。当Subject中发生状态变化时,此方法将被调用。目标是更新Subject的观察者列表中的所有观察者。为了做到这一点,我们遍历我们的列表。一个接一个地,我们使用列表迭代器iter获取一个Observer。我们通过(*iter)->Update();在当前Observer上调用Update()。我们可以通过使用if ((*iter)->GetState() == State::Success)检查观察者的状态来判断给定的Observer的更新是否成功。当状态为Success时,我们知道观察者的操作将导致我们刚刚审查的Release()函数被调用。因为Release()中使用的list::erase()已使迭代器无效,所以我们现在使用iter = newIter;获取正确和修订的迭代器。最后,在循环外部,我们在观察者列表的最后一个项目上调用Update()

从 Subject 和 Observer 派生具体类

让我们通过查看从SubjectObserver派生的具体类来继续这个例子。让我们从Subject派生的Course开始:

class Course: public Subject  
{   // inherits Observer list; 
    // Observer list represents Students on waitlist
private:
    string title;
    int number = 0;  // course num, total num students set
    int totalStudents = 0; // using in-class initialization
    Student *students[MAXSTUDENTS] = { }; // initialize to
                                          // nullptrs
public:                             
    Course(const string &title, int num): number(num)
    {
        this->title = title;  // or rename parameter
        // Note: in-class init. is in-lieu of below:
        // for (int i = 0; i < MAXSTUDENTS; i++)
            // students[i] = nullptr; 
    }
    // destructor body shown as place holder to add more
    // work that will be necessary
    ~Course() override 
    {     /* There's more work to add here! */    }
    int GetCourseNum() const { return number; }
    const string &GetTitle() const { return title; }
    const AddStudent(Student *);
    void Open() 
{    SetState(CourseState::OpenForEnrollment); 
Notify(); 
    } 
    void PrintStudents() const;
};
bool Course::AddStudent(Student *s)
{  // Should also check Student hasn't been added to Course
    if (totalStudents < MAXSTUDENTS)  // course not full
    {
        students[totalStudents++] = s;
        return true;
    }
    else return false;
}
void Course::PrintStudents() const
{
    cout << "Course: (" << GetTitle() << 
            ") has the following students: " << endl;
    for (int i = 0; i < MAXSTUDENTS && 
                        students[i] != nullptr; i++)
    {
        cout << "\t" << students[i]->GetFirstName() << " ";
        cout << students[i]->GetLastName() << endl;
    }
}

在我们之前提到的Course类中,我们包括课程标题和编号的数据成员,以及当前注册的学生总数。我们还有当前注册的学生列表,表示为Student *students[MAXNUMBERSTUDENTS];。此外,请记住,我们从Subject基类继承了 STL list观察者。这个Observer实例的列表将代表我们的Course(学生)等待列表。

Course类还包括一个构造函数、一个虚析构函数和简单的访问函数。请注意,虚析构函数要做的工作比显示的更多——如果Course被销毁,我们必须记住首先从Course中移除(但不删除)Student实例。我们的bool Course::AddStudent(Student *)接口将允许我们将Student添加到Course中。当然,我们应该确保Student没有在这个方法的主体中添加Course

我们的 void Course::Open(); 方法将在 Course 对象上被调用,以指示该课程现在可以添加学生。在这里,我们首先将状态设置为 Course::OpenForEnrollment(通过枚举类型明确表示 Open for Enrollment),然后调用 Notify()。我们的基类 Subject 中的 Notify() 方法会遍历每个 Observer,对每个观察者调用多态的 Update() 方法。每个 Observer 是一个 StudentStudent::Update() 将允许等待名单上的每个 Student 尝试添加 Course,现在该课程对学生开放。一旦成功添加到课程的当前学生名单中,Student 将请求其在等待名单上的位置 Release()(作为一个 Observer)。

接下来,让我们看一下我们的 Student 类定义,这是我们从 PersonObserver 派生出来的具体类:

class Person { }; // Assume our typical Person class here
class Student: public Person, public Observer
{
private:
    float gpa = 0.0;     // in-class initialization
    const string studentId;
    int currentNumCourses = 0;
    Course *courses[MAXCOURSES] = { }; // set to nullptrs
    // Course we'd like to take - we're on the waitlist. 
    Course *waitListedCourse = nullptr;  // Our Subject
                                // (in specialized form)
    static int numStudents;
public:
    Student();  // default constructor
    Student(const string &, const string &, char, 
            const string &, float, const string &, Course *);
    Student(const string &, const string &, char, 
            const string &, float, const string &);
    Student(const Student &) = delete; // Copies disallowed
    ~Student() override;   // virtual destructor
    void EarnPhD();
    float GetGpa() const { return gpa; }
    const string &GetStudentId() const 
       { return studentId; }
    void Print() const override;  // from Person
    void IsA() const override;  // from Person
    void Update() override;     // from Observer
    virtual void Graduate(); // newly introduced virtual fn
    bool AddCourse(Course *);
    void PrintCourses() const;
    static int GetNumberStudents() { return numStudents; } 
};

简要回顾一下之前提到的 Student 类定义,我们可以看到这个类通过多继承同时从 PersonObserver 派生出来。让我们假设我们的 Person 类与我们过去多次使用的是一样的。

除了我们 Student 类的常规组件外,我们添加了数据成员 Course *waitListedCourse;,它将模拟与我们的 Subject 的关联。这个数据成员将模拟我们非常希望添加,但目前无法添加的 Course 的概念,即一个 等待名单 的课程。在这里,我们正在实现单个等待名单课程的概念,但我们可以轻松地扩展示例以包括支持多个等待名单课程的列表。请注意,这个链接(数据成员)是以派生类型 Course 的形式声明的,而不是基类型 Subject。这在观察者模式中很典型,并且将帮助我们避免在 Student 中重写 Update() 方法时的讨厌的向下转型。正是通过这个链接,我们将与我们的 Subject 进行交互,以及我们接收来自 Subject 更新状态的方式。

我们还注意到,我们在 Student 中声明了 virtual void Update() override;。这个方法将允许我们重写由 Observer 指定的纯虚 Update() 方法。

接下来,让我们回顾一下 Student 的各种新成员函数:

// Assume most Student member functions are as we are
// accustomed to seeing. All are available online.
// Let's look at ONLY those that may differ:
// Note that the default constructor for Observer() will be
// invoked implicitly, thus it is not needed in init list
// below (it is shown in comment as a reminder it's called)
Student::Student(const string &fn, const string &ln, 
    char mi, const string &t, float avg, const string &id,
    Course *c): Person(fn, ln, mi, t), // Observer(),
    gpa(avg), studentId(id), currentNumCourses(0)
{ 
    // Below nullptr assignment is no longer needed with
    // above in-class initialization; otherwise, add here:
    // for (int i = 0; i < MAXCOURSES; i++)
        // courses[i] = nullptr;
    waitListedCourse = c;  // set initial waitlisted Course
                           // (Subject)
    c->Register(this); // Add the Student (Observer) to 
                       // the Subject's list of Observers
    numStudents++;
}
bool Student::AddCourse(Course *c)
{ 
    // Should also check Student isn't already in Course
    if (currentNumCourses < MAXCOURSES)
    {
        courses[currentNumCourses++] = c;  // set assoc.
        c->AddStudent(this);               // set back-link
        return true;
    }
    else  // if we can't add the course,
    {   // add Student (Observer) to the Course's Waitlist, 
        c->Register(this);  // stored in Subject base class
        waitListedCourse = c; // set Student (Observer) 
                              // link to Subject
        return false;
    }
}

让我们回顾一下之前列出的成员函数。由于我们已经习惯了 Student 类中的大多数必要组件和机制,我们将重点关注新添加的 Student 方法,从备用构造函数开始。在这个构造函数中,让我们假设我们像往常一样设置了大多数数据成员。这里的关键代码行是 waitListedCourse = c;,将我们的等待名单条目设置为所需的 CourseSubject),以及 c->Register(this);,其中我们将 StudentObserver)添加到 Subject 的列表(课程的正式等待名单)中。

接下来,在我们的 bool Student::AddCourse(Course *) 方法中,我们首先检查我们是否没有超过允许的最大课程数。如果没有,我们就进行添加关联的机制,以便在两个方向上链接一个 StudentCourse。也就是说,courses[currentNumCourses++] = c; 使得学生的当前课程列表包含对新 Course 的关联,以及 c->AddStudent(this); 请求当前 CourseStudent(即 this)添加到其注册学生名单中。

让我们继续回顾 Student 的新成员函数的其余部分:

void Student::Update()
{   // Course state changed to 'Open For Enrollment', etc.
    // so we can now add it.
    if ((waitListedCourse->GetState() == 
         CourseState::OpenForEnrollment) ||
        (waitListedCourse->GetState() == 
         CourseState::NewSpaceAvailable))
    {
        if (AddCourse(waitListedCourse)) // success Adding 
        {
            cout << GetFirstName() << " " << GetLastName();
            cout << " removed from waitlist and added to ";
            cout << waitListedCourse->GetTitle() << endl;
            // Set observer's state to AddSuccess
            SetState(StudentState::AddSuccess); 
            // Remove Student from Course's waitlist
            waitListedCourse->Release(this); // Remove Obs.
                                            // from Subject
            waitListedCourse = nullptr; // Set Subject link 
        }                               // to null
    }
}
void Student::PrintCourses() const
{
    cout << "Student: (" << GetFirstName() << " ";
    cout << GetLastName() << ") enrolled in: " << endl;
    for (int i = 0; i < MAXCOURSES && 
                    courses[i] != nullptr; i++)
        cout << "\t" << courses[i]->GetTitle() << endl;
}

继续我们之前提到的 Student 成员函数的其余部分,接下来,在我们的多态 void Student::Update() 方法中,我们执行所需的添加等待名单中的课程。回想一下,当我们的 Subject(课程)发生状态变化时,将调用 Notify()。一种这样的状态变化可能是当 Course 对注册开放,或者当 Student 放弃 Course 后,现在存在一个 New Space Available(新空间可用)的状态。Notify() 然后对每个 Observer 调用 Update()。我们的 Update()Student 中被重写以获取 Course(主题)的状态。如果状态表明课程现在对注册开放或有一个 New Space Available,我们尝试 AddCourse(waitListedCourse);。如果成功,我们将 Student(观察者)的状态设置为 StudentState::AddSuccess(添加成功)以指示我们在 Update() 中成功,这意味着我们已添加了课程。接下来,由于我们已经将期望的课程添加到我们的当前课程列表中,我们现在可以自己从 Course 的等待名单中移除。也就是说,我们将使用 waitListedCourse->Release(this); 将自己(学生)作为 ObserverSubject(课程的等待名单)中移除。现在我们已经添加了我们的期望等待名单课程,我们也可以使用 waitListedCourse = nullptr; 移除我们与 Subject 的链接。

最后,我们之前提到的 Student 代码包括一个方法来打印 Student 当前注册的课程,即 void Student::PrintCourses();。这个方法相当直接。

将模式组件组合在一起

让我们现在通过查看我们的 main() 函数来查看我们的观察者模式是如何编排的:

int main()
{   // Instantiate several courses
    Course *c1 = new Course("C++", 230);  
    Course *c2 = new Course("Advanced C++", 430);
    Course *c3 = new Course("C++ Design Patterns", 550);
    // Instantiate Students, select a course to be on the 
    // waitlist for -- to be added when registration starts
    Student s1("Anne", "Chu", 'M', "Ms.", 3.9, "66CU", c1);
    Student s2("Joley", "Putt", 'I', "Ms.", 3.1, 
               "585UD", c1);
    Student s3("Geoff", "Curt", 'K', "Mr.", 3.1, 
               "667UD", c1);
    Student s4("Ling", "Mau", 'I', "Ms.", 3.1, "55TU", c1);
    Student s5("Jiang", "Wu", 'Q', "Dr.", 3.8, "88TU", c1);
    cout << "Registration is Open" << "\n";
    cout << "Waitlist Students to be added to Courses"; 
    cout << endl;
    // Sends a message to Students that Course is Open. 
    c1->Open(); // Students on waitlist will automatically
    c2->Open(); // be Added (as room allows)
    c3->Open();
    // Now that registration is open, add more courses 
    cout << "During open registration, Students now adding
             additional courses" << endl;
    s1.AddCourse(c2);  // Try to add more courses
    s2.AddCourse(c2);  // If full, we'll be added to 
    s4.AddCourse(c2);  // a waitlist
    s5.AddCourse(c2);  
    s1.AddCourse(c3);  
    s3.AddCourse(c3);  
    s5.AddCourse(c3);
    cout << "Registration complete\n" << endl;
    c1->PrintStudents();   // print each Course's roster
    c2->PrintStudents();
    c3->PrintStudents();
    s1.PrintCourses();  // print each Student's course list
    s2.PrintCourses();
    s3.PrintCourses();
    s4.PrintCourses();
    s5.PrintCourses();
    return 0;
}

回顾我们之前提到的 main() 函数,我们首先创建了三个 Course 实例。接下来,我们创建了五个 Student 实例,使用一个构造函数,允许我们在课程注册开始时为每个 Student 提供一个他们想要添加的初始 Course。请注意,这些 Students(观察者)将被添加到他们期望课程的等待名单(主题)中。在这里,一个 Subject(课程)将有一个 Observer(学生)列表,这些学生希望在注册开放时添加该课程。

接下来,我们看到许多Student实例渴望的Course变为开放注册,可以通过c1->Open();进行注册。Course::Open()Subject的状态设置为CourseState::OpenForEnrollment,这很容易表明课程是开放注册的,然后调用Notify()。正如我们所知,Subject::Notify()将在Subject的观察者列表上调用Update()。正是在这里,一个初始的等待注册的Course实例将被添加到学生的日程表中,并随后作为ObserverSubject的等待列表中移除。

现在注册已经开放,每个Student将尝试使用bool Student::AddCourse(Course *)以通常的方式添加更多课程,例如s1.AddCourse(c2);。如果一个Course已满,Student将被添加到Course的等待列表中(作为继承自Subject的观察者列表,实际上是由派生自Student类型的观察者组成)。回想一下,Course继承自Subject,它保留了一个对添加特定课程感兴趣的学生列表(观察者的等待列表)。当Course状态变为新空间可用时,等待列表中的学生(通过数据成员observers)将被通知,并且每个Student上的Update()方法将随后调用该StudentAddCourse()方法。

一旦我们添加了各种课程,我们就会看到每个Course打印其学生名单,例如c2->PrintStudents()。同样,我们也会看到每个Student打印他们所注册的课程,例如使用s5.PrintCourses()

让我们看看这个程序的输出:

Registration is Open 
Waitlist Students to be added to Courses
Anne Chu removed from waitlist and added to C++
Goeff Curt removed from waitlist and added to C++
Jiang Wu removed from waitlist and added to C++
Joley Putt removed from waitlist and added to C++
Ling Mau removed from waitlist and added to C++
During open registration, Students now adding more courses
Registration complete
Course: (C++) has the following students:
        Anne Chu
        Goeff Curt
        Jiang Wu
        Joley Putt
        Ling Mau
Course: (Advanced C++) has the following students:
        Anne Chu
        Joley Putt
        Ling Mau
        Jiang Wu
Course: (C++ Design Patterns) has the following students:
        Anne Chu
        Goeff Curt
        Jiang Wu
Student: (Anne Chu) enrolled in:
        C++
        Advanced C++
        C++ Design Patterns
Student: (Joley Putt) enrolled in:
        C++
        Advanced C++
Student: (Goeff Curt) enrolled in:
        C++
        C++ Design Patterns
Student: (Ling Mau) enrolled in:
        C++
        Advanced C++
Student: (Jiang Wu) enrolled in:
        C++
        Advanced C++
        C++ Design Patterns

我们现在已经看到了观察者模式的实现。我们将更通用的SubjectObserver类折叠到我们习惯看到的类框架中,即CoursePersonStudent。现在,让我们简要回顾一下与模式相关的学习内容,然后再进入下一章。

摘要

在本章中,我们开始追求成为更好的 C++ 程序员,通过将我们的知识库从面向对象的概念扩展到包括设计模式的应用。我们的主要目标是让您能够通过应用常见的模式,使用经过验证和可靠的解决方案来解决重复出现的编程问题。

我们首先理解了设计模式的目的以及在我们代码中采用它们的优势。然后,我们具体理解了观察者模式背后的前提以及它是如何贡献于面向对象的。最后,我们查看了一下如何在 C++ 中实现观察者模式。

利用常见的模式,如观察者模式,将帮助您更轻松地以其他程序员能理解的方式解决重复出现的编程问题。面向对象的一个关键原则是尽可能追求组件的重用。通过利用设计模式,您将为具有更复杂编程技术的可重用解决方案做出贡献。

现在我们已经准备好继续前进,进入我们的下一个设计模式第十七章实现工厂模式。将更多模式添加到我们的技能集合中,使我们成为更灵活、更有价值的程序员。让我们继续前进!

问题

  1. 以本章示例的在线代码作为起点,以及之前练习的解决方案(问题 3第十章实现关联、聚合和组合):

    1. 实现(或修改你之前的)Student::DropCourse()。当Student取消选课时,这个事件将导致Course状态变为状态2新空间可用。随着状态的变化,Notify()将被调用在Course(主题)上,然后它将Update()观察者列表(等待名单上的学生)。Update()将间接允许等待名单上的Student实例(如果有),现在可以添加该课程。

    2. 最后,在DropCourse()中,记得从学生的当前课程列表中移除已取消的课程。

  2. 你能想象出哪些其他例子可以轻松地融入观察者模式?

第十七章:应用工厂模式

本章将继续我们的追求,以扩展你的 C++编程工具箱,使其超越核心 OOP 概念,目标是使你能够利用常见的设计模式解决重复出现的编码问题。我们知道,结合设计模式可以增强代码维护性,并为潜在的代码重用提供途径。

继续演示和解释流行的设计模式和惯用法,并学习如何在 C++中有效地实现它们,我们继续我们的探索之旅,这次是工厂模式,更确切地说是工厂方法模式

在本章中,我们将涵盖以下主要内容:

  • 理解工厂方法模式及其对面向对象编程(OOP)的贡献

  • 理解如何使用和没有对象工厂实现工厂方法模式,以及比较对象工厂和抽象工厂

到本章结束时,你将理解流行的工厂方法模式。我们将看到 C++中该模式的两个示例实现。将更多的核心设计模式添加到你的编程工具箱中,将使你成为一个更复杂且更有价值的程序员。

通过研究另一个常见的设计模式,即工厂方法模式,让我们提高我们的编程技能。

技术要求

完整程序示例的在线代码可以在以下 GitHub 网址找到:github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/tree/main/Chapter17。每个完整程序示例都可以在 GitHub 存储库中找到,位于相应章节标题(子目录)下的文件中,该文件以章节编号开头,后面跟着一个连字符,然后是本章中的示例编号。例如,本章的第一个完整程序可以在上述 GitHub 目录下的Chapter17子目录中找到,文件名为Chp17-Ex1.cpp

本章的 CiA 视频可以在以下网址查看:bit.ly/3QOmCC1

理解工厂方法模式

工厂模式,或称为工厂方法模式,是一种创建型设计模式,它允许在不指定将要实例化的确切(派生)类的情况下创建对象。工厂方法模式提供了一个创建对象的接口,同时允许创建方法中的细节决定要实例化哪个(派生)类。

工厂方法模式也被称为虚拟构造函数。正如虚拟析构函数具有特定的析构函数(它是销毁序列的入口点),通过动态绑定在运行时确定一样,虚拟构造函数的概念是,在运行时统一确定要实例化的所需对象。

我们无法总是预见到在应用程序中需要的特定相关派生类对象的混合。工厂方法(或虚拟构造函数)可以根据提供的输入,在请求时创建许多相关派生类类型中的一种实例。工厂方法将派生类对象作为其基类类型返回,允许对象以更通用的方式创建和存储。可以将多态操作应用于新创建的(向上转型)实例,使相关的派生类行为得以展现。工厂方法通过消除在客户端代码中绑定特定派生类类型的需要,促进了与客户端代码的松耦合。客户端只需利用工厂方法来创建和提供适当的实例。

使用工厂方法模式,我们将指定一个抽象类(或接口)来收集和指定我们希望创建的派生类的通用行为。在这个模式中的抽象类或接口被称为产品。然后我们创建可能想要实例化的派生类,覆盖任何必要的抽象方法。各种具体的派生类被称为具体产品

然后我们指定一个工厂方法,其目的是提供一个接口,以统一创建具体产品实例。工厂方法可以放在抽象产品类中,也可以放在单独的对象工厂类中;对象工厂代表一个具有创建具体产品任务的类。如果放在抽象产品类中,这个工厂(创建)方法将是静态的;如果放在对象工厂类中,则可选地是静态的。工厂方法将根据一致的输入参数列表决定制造哪个具体的产品。工厂方法将返回一个指向具体产品的通用产品指针。可以将多态方法应用于新创建的对象,以引发其特定行为。

工厂方法模式将包括以下内容:

  • 一个抽象的产品类(或接口)。

  • 多个具体产品派生类。

  • 在抽象产品类或单独的对象工厂类中的工厂方法。工厂方法将具有统一的接口来创建任何具体产品类型的实例。

  • 具体产品将由工厂方法作为通用产品实例返回。

请记住,工厂方法(无论是否在对象工厂中)产生产品。工厂方法提供了一种统一的方式产生许多相关的产品类型。可以存在多个工厂方法来生产独特的产品线;每个工厂方法可以通过一个有意义的名称来区分,即使它们的签名碰巧是相同的。

让我们继续前进,看看工厂方法模式的两个示例实现。

实现工厂方法模式

我们将探讨两种常见的工厂方法模式的实现。每种实现都会有设计权衡,当然值得讨论!

让我们从将工厂方法放置在抽象产品类中的技术开始。

在产品类中包含工厂方法

为了实现工厂方法模式,我们首先需要创建我们的抽象产品类以及我们的具体产物类。这些类定义将为我们构建模式的基础。

在我们的例子中,我们将使用我们习惯看到的类来创建我们的产品 – Student。然后我们将创建具体的产物类,即GradStudentUnderGradStudentNonDegreeStudent。我们将在我们的产品(Student)类中包含一个工厂方法,它具有一致的接口来创建任何派生产品类型。

我们将要建模的组件将通过添加基于他们教育学位目标的类来补充我们现有的Student应用程序的框架。这些新组件为大学入学(新的Student录取)系统提供了基础。

让我们假设,而不是实例化一个Student,我们的应用程序将根据他们的学习目标实例化各种类型的StudentGradStudentUnderGradStudentNonDegreeStudentStudent类将包括一个抽象的多态Graduate()操作;每个派生类将使用不同的实现覆盖此方法。例如,寻求博士学位的GradStudent可能在GradStudent::Graduate()方法中需要满足比其他Student特殊化更多的学位相关标准。他们可能需要验证学分小时数,验证通过的平均成绩点,以及验证他们的论文已被接受。相比之下,UnderGradStudent可能只需要验证他们的学分小时数和整体平均成绩点。

抽象产品类将包括一个静态方法MatriculateStudent()作为工厂方法来创建各种类型的学生(具体的产物类型)。

定义抽象产品类

让我们先看看实现我们的工厂方法的具体机制,从检查我们的抽象产品类Student的定义开始。这个例子可以作为完整的程序在我们的 GitHub 仓库中找到:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter17/Chp17-Ex1.cpp

// Assume Person class exists with its usual implementation
class Student: public Person  // Notice that Student is now
{                             // an abstract class
private:
    float gpa = 0.0;  // in-class initialization
    string currentCourse;
    const string studentId;
    static int numStudents;
public:
    Student();  // default constructor
    Student(const string &, const string &, char, 
       const string &, float, const string &, 
       const string &);
    Student(const Student &);  // copy constructor
    ~Student() override;  // virtual destructor
    float GetGpa() const { return gpa; }
    const string &GetCurrentCourse() const 
       { return currentCourse; }
    const string &GetStudentId() const 
       { return studentId; }
    void SetCurrentCourse(const string &); // proto. only
    void Print() const override;
    string IsA() const override { return "Student"; }
    virtual void Graduate() = 0;  // Student is abstract
    // Create a derived Student type based on degree sought
    static Student *MatriculateStudent(const string &,
       const string &, const string &, char, 
       const string &, float, const string &, 
       const string &);
    static int GetNumStudents() { return numStudents; }
};
// Assume all the usual Student member functions exist 

在前面的类定义中,我们引入了我们的抽象Student类,它从Person(一个具体类,因此可以实例化)派生而来。这是通过引入抽象方法virtual void Graduate() = 0;实现的。在我们的学生注册示例中,我们将遵循这样的设计决策:只有特定类型的学生的实例应该被创建,即派生类类型GradStudentUnderGradStudentNonDegreeStudent

在前面的类定义中,请注意我们的工厂方法,其原型为static Student *MatriculateStudent();。这个方法将使用统一的接口,并提供创建Student的各种派生类类型的手段。一旦我们看到了派生类的类定义,我们将详细研究这个方法。

定义具体产品类

现在,让我们来看看我们的具体产品类,从GradStudent开始:

class GradStudent: public Student
{
private:
    string degree;  // PhD, MS, MA, etc.
public:
    GradStudent() = default;// default constructor
    GradStudent(const string &, const string &, 
       const string &, char, const string &, float, 
       const string &, const string &);
    // Prototyping default copy constructor isn't necessary
    // GradStudent(const GradStudent &) = default;
    // Since the most base class has virt dtor prototyped,
    // it is not necessary to prototype default destructor
    // ~GradStudent() override = default; // virtual dtor
    void EarnPhD();
    string IsA() const override { return "GradStudent"; }
    void Graduate() override;
};
// Assume alternate constructor is implemented
// as expected. See online code for full implementation.
void GradStudent::EarnPhD()
{
    if (!degree.compare("PhD")) // only PhD candidates can 
        ModifyTitle("Dr.");     // EarnPhd(), not MA and MS 
}                               // candidates
void GradStudent::Graduate()
{   // Here, we can check that the required num of credits
    // have been met with a passing gpa, and that their 
    // doctoral or master's thesis has been completed.
    EarnPhD(); // Will change title only if a PhD candidate
    cout << "GradStudent::Graduate()" << endl;
}

在上述GradStudent类定义中,我们添加了一个degree数据成员来表示"PhD""MS""MA"学位,并根据需要调整构造函数和析构函数。我们将EarnPhD()移动到GradStudent中,因为这个方法并不适用于所有Student实例。相反,EarnPhD()适用于GradStudent实例的一个子集;我们只会授予"Dr."头衔给博士候选人。

在这个类中,我们重写了IsA()以返回"GradStudent"。我们还重写了Graduate(),以执行适用于研究生的毕业清单,如果清单项目已经满足,则调用EarnPhD()

现在,让我们来看看我们的下一个具体产品类,UnderGradStudent

class UnderGradStudent: public Student
{
private:
    string degree;  // BS, BA, etc
public:
    UnderGradStudent() = default;// default constructor
    UnderGradStudent(const string &, const string &, 
       const string &, char, const string &, float, 
       const string &, const string &);
    // Prototyping default copy constructor isn't necessary
    // UnderGradStudent(const UnderGradStudent &) =default; 
    // Since the most base class has virt dtor prototyped,
    // it is not necessary to prototype default destructor
    // ~UnderGradStudent() override = default; // virt dtor
    string IsA() const override 
       { return "UnderGradStudent"; }
    void Graduate() override;
};
// Assume alternate constructor is implemented
// as expected. See online code for full implementation.
void UnderGradStudent::Graduate()
{   // Verify that num of credits and gpa requirements have
    // been met for major and any minors or concentrations.
    // Have all applicable university fees been paid?
    cout << "UnderGradStudent::Graduate()" << endl;
}

快速看一下之前定义的UnderGradStudent类,我们会发现它非常类似于GradStudent。这个类甚至包括一个degree数据成员。记住,并不是所有的Student实例都会获得学位,所以我们不希望在Student中定义这个属性,从而将其泛化。虽然我们可以为UnderGradStudentGradStudent引入一个共享的基类DegreeSeekingStudent来收集这种共性,但这种细粒度的层次结构几乎是不必要的。这里的重复是一个设计权衡。

这两个兄弟类之间的关键区别在于重写的Graduate()方法。我们可以想象,本科生毕业的清单可能和研究生毕业的清单有很大不同。因此,我们可以合理地区分这两个类。否则,它们非常相似。

现在,让我们来看看我们的下一个具体产品类,NonDegreeStudent

class NonDegreeStudent: public Student
{
public:
    NonDegreeStudent() = default;  // default constructor
    NonDegreeStudent(const string &, const string &, char, 
       const string &, float, const string &, 
       const string &);
    // Prototyping default copy constructor isn't necessary
    // NonDegreeStudent(const NonDegreeStudent &s)
    //     =default;
    // Since the most base class has virt dtor prototyped,
    // it is not necessary to prototype default destructor
    // ~NonDegreeStudent() override = default; // virt dtor
    string IsA() const override  
       { return "NonDegreeStudent"; }
    void Graduate() override;
};
// Assume alternate constructor is implemented as expected.
// See online code for full implementation.
void NonDegreeStudent::Graduate()
{   // Check if applicable tuition has been paid. 
    // There is no credit or gpa requirement.
    cout << "NonDegreeStudent::Graduate()" << endl;
}

快速看一下前面提到的NonDegreeStudent类,我们会注意到这个具体产品与其兄弟类相似。然而,这个类中没有degree数据成员。此外,重写的Graduate()方法比GradStudentUnderGradStudent类中此方法的重写版本需要进行的验证要少。

检查工厂方法定义

接下来,让我们看看我们的工厂方法,这是我们的产品(Student)类中的一个静态方法:

// Creates a Student based on the degree they seek
// This is a static Student method (keyword in prototype)
Student *Student::MatriculateStudent(const string &degree, 
    const string &fn, const string &ln, char mi, 
    const string &t, float avg, const string &course, 
    const string &id)
{
    if (!degree.compare("PhD") || !degree.compare("MS") 
        || !degree.compare("MA"))
        return new GradStudent(degree, fn, ln, mi, t, avg,
                               course, id);
    else if (!degree.compare("BS") || 
             !degree.compare("BA"))
        return new UnderGradStudent(degree, fn, ln, mi, t,
                                    avg, course, id);
    else if (!degree.compare("None"))
        return new NonDegreeStudent(fn, ln, mi, t, avg,
                                    course, id);
}

前面提到的Student类的静态方法MatriculateStudent()代表了工厂方法来创建各种产品(具体的Student实例)。在这里,根据Student寻求的学位类型,将实例化GradStudentUnderGradStudentNonDegreeStudent之一。注意,MatriculateStudent()的签名可以处理任何派生类构造函数的参数要求。也请注意,这些专门的实例类型将作为抽象产品类型(Student)的基类指针返回。

工厂方法中的一个有趣选项是MatriculateStudent(),这个方法并不强制实例化一个新的派生类实例。相反,它可能回收一个可能仍然可用的先前实例。例如,想象一个Student因为延迟付款而暂时在大学中未注册,但仍然保留在待处理学生名单上。MatriculateStudent()方法可以选择返回这样一个现有Student的指针。回收是工厂方法中的一个替代方案!

将模式组件组合在一起

最后,现在让我们通过查看main()函数来将所有各种组件组合在一起,看看我们的工厂方法模式是如何编排的:

int main()
{
    Student *scholars[MAX] = { }; // init. to nullptrs
    // Student is now abstract; cannot instantiate directly
    // Use Factory Method to make derived types uniformly
    scholars[0] = Student::MatriculateStudent("PhD", 
       "Sara", "Kato", 'B', "Ms.", 3.9, "C++", "272PSU");
    scholars[1] = Student::MatriculateStudent("BS", 
       "Ana", "Sato", 'U', "Ms.", 3.8, "C++", "178PSU");
    scholars[2] = Student::MatriculateStudent("None", 
       "Elle", "LeBrun", 'R', "Miss", 3.5, "C++", "111BU");
    for (auto *oneStudent : scholars)
    {
       oneStudent->Graduate();
       oneStudent->Print();
    }
    for (auto *oneStudent : scholars)
       delete oneStudent;   // engage virt dtor sequence
    return 0;
}

回顾我们前面提到的main()函数,我们首先创建了一个指针数组,用于潜在的专门Student实例,以它们的泛化Student形式存在。接下来,我们在抽象产品类中调用静态工厂方法Student::MatriculateStudent(),以创建适当的具体产品(派生Student类类型)。我们创建了每种派生Student类型的一个实例——GradStudentUnderGradStudentNonDegreeStudent

我们随后遍历我们的泛化集合,对每个实例调用Graduate()方法,然后调用Print()方法。对于获得博士学位的学生(GradStudent实例),他们的头衔将通过GradStudent::Graduate()方法更改为"Dr."。最后,我们通过另一个循环来释放每个实例的内存。幸运的是,Student类包含了一个虚析构函数,这样销毁序列就会从正确的级别开始。

让我们看看这个程序的输出:

GradStudent::Graduate()
  Dr. Sara B. Kato with id: 272PSU GPA:  3.9 Course: C++
UnderGradStudent::Graduate()
  Ms. Ana U. Sato with id: 178PSU GPA:  3.8 Course: C++
NonDegreeStudent::Graduate()
  Miss Elle R. LeBrun with id: 111BU GPA:  3.5 Course: C++

之前实现的一个优点是它非常直接。然而,我们可以看到抽象 Product(包含 Factory Method,它构建派生类类型)和派生具体 Product 之间存在紧密耦合。然而,在面向对象编程中,基类理想情况下对任何子类类型一无所知。

这种紧密耦合的实现的一个缺点是,抽象的 Product 类必须在它的静态创建方法MatriculateStudent()中包含一个实例化的方法,用于其每个子类。现在添加新的派生类会影响抽象基类定义——它需要重新编译。如果我们无法访问这个基类的源代码怎么办?有没有一种方法可以解耦 Factory Method 和 Factory Method 将要创建的 Products 之间的依赖关系?是的,有一种替代实现。

让我们现在看看 Factory Method 模式的另一种实现。我们将使用一个 Object Factory 类来封装我们的MatriculateStudent()Factory Method,而不是将其包含在抽象 Product 类中。

创建一个封装 Factory Method 的对象工厂类

对于我们的 Factory Method 模式的替代实现,我们将创建我们的抽象 Product 类,与之前的定义略有不同。然而,我们仍然会像以前一样创建我们的具体 Product 类。这些类定义共同构成了我们模式的基础框架。

在我们的修改后的例子中,我们将再次将 Product 定义为Student类。我们也将再次派生GradStudentUnderGradStudentNonDegreeStudent的具体产品类。然而,这一次,我们不会在我们的 Product(Student)类中包含 Factory Method。相反,我们将创建一个单独的对象工厂类,该类将包含 Factory Method。像以前一样,Factory Method 将有一个统一的接口来创建任何派生产品类型。Factory Method 不需要是静态的,就像我们上一个实现中那样。

我们的 Object Factory 类将包括MatriculateStudent()作为 Factory Method 来创建各种类型的Student实例(具体产品类型)。

不包含 Factory Method 的抽象 Product 类定义

让我们来看看 Factory Method 模式替代实现的机制,首先从我们的抽象 Product 类Student的定义开始。这个例子可以作为完整的程序,在我们的 GitHub 仓库中找到,以下 URL:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter17/Chp17-Ex2.cpp

// Assume Person class exists with its usual implementation
class Student: public Person   // Notice Student is 
{                              // an abstract class
private:
    float gpa = 0.0;   // in-class initialization
    string currentCourse;
    const string studentId;
    static int numStudents; // Remember, static data mbrs 
                // are also shared by all derived instances
public:          
    Student();  // default constructor
    Student(const string &, const string &, char, 
       const string &, float, const string &, 
       const string &);
    Student(const Student &);  // copy constructor
    ~Student() override;  // destructor
    float GetGpa() const { return gpa; }
    const string &GetCurrentCourse() const 
       { return currentCourse; }
    const string &GetStudentId() const 
       { return studentId; }
    void SetCurrentCourse(const string &); // proto. only
    void Print() const override;
    string IsA() const override { return "Student"; }
    virtual void Graduate() = 0;  // Student is abstract
    static int GetNumStudents() { return numStudents; }
};

在我们之前提到的Student类定义中,与之前的实现相比,关键的不同之处在于这个类不再包含一个静态的MatriculateStudent()方法作为工厂方法。Student仅仅是一个抽象基类。记住,所有的研究生、本科生和非学位学生都是Student的特化形式,因此static int numStudents是所有Student类型的一个共享、集体计数。

定义具体产品类

考虑到这一点,让我们看看派生(具体产品)类:

class GradStudent: public Student
{   // Implemented as in our last example
};
class UnderGradStudent: public Student
{   // Implemented as in our last example
};
class NonDegreeStudent: public Student
{   // Implemented as in our last example
};

在我们之前列出的类定义中,我们可以看到我们的具体派生产品类与我们在第一个示例中的这些类的实现是相同的。

添加带有工厂方法的对象工厂类

接下来,让我们介绍一个包含我们的工厂方法的对象工厂类:

class StudentFactory    // Object Factory class
{
public:   
   // Factory Method creates Student based on degree sought
    Student *MatriculateStudent(const string &degree, 
       const string &fn, const string &ln, char mi, 
       const string &t, float avg, const string &course, 
       const string &id)
    {
        if (!degree.compare("PhD") || !degree.compare("MS") 
            || !degree.compare("MA"))
            return new GradStudent(degree, fn, ln, mi, t, 
                                   avg, course, id);
        else if (!degree.compare("BS") || 
                 !degree.compare("BA"))
            return new UnderGradStudent(degree, fn, ln, mi,
                                       t, avg, course, id);
        else if (!degree.compare("None"))
            return new NonDegreeStudent(fn, ln, mi, t, avg,
                                        course, id);
    }
};

在之前提到的对象工厂类定义(StudentFactory类)中,我们最小化地包含了工厂方法规范,即MatriculateStudent()。该方法与之前的示例非常相似。然而,通过在对象工厂中捕获具体产品的创建,我们将抽象产品与工厂方法之间的关系解耦了。

将模式组件组合在一起

接下来,让我们比较我们的main()函数与原始示例,以可视化我们修改后的组件如何实现工厂方法模式:

int main()
{
    Student *scholars[MAX] = { }; // init. to nullptrs
    // Create an Object Factory for Students
    StudentFactory *UofD = new StudentFactory();
    // Student is now abstract, cannot instantiate directly
    // Ask the Object Factory to create a Student
    scholars[0] = UofD->MatriculateStudent("PhD", "Sara", 
               "Kato", 'B', "Ms.", 3.9, "C++", "272PSU");
    scholars[1] = UofD->MatriculateStudent("BS", "Ana", 
               "Sato", 'U', "Dr.", 3.8, "C++", "178PSU");
    scholars[2] = UofD->MatriculateStudent("None", "Elle",
               "LeBrun", 'R', "Miss", 3.5, "C++", "111BU");
    for (auto *oneStudent : scholars)
    {
       oneStudent->Graduate();
       oneStudent->Print();
    }
    for (auto *oneStudent : scholars)
       delete oneStudent;   // engage virt dtor sequence
    delete UofD; // delete factory that created various 
    return 0;    // types of students
}

考虑我们之前列出的main()函数,我们看到我们再次创建了一个指向抽象产品类型(Student)的指针数组。然后我们实例化了一个对象工厂,它可以创建各种具体产品类型的Student实例,使用StudentFactory *UofD = new StudentFactory();。与之前的示例一样,根据每个学生的学位类型,对象工厂创建了每种派生类型GradStudentUnderGradStudentNonDegreeStudent的一个实例。main()中的其余代码与之前的示例相同。

我们的结果将与我们的上一个示例相同。

与我们之前的方法相比,对象工厂类的优势在于我们消除了从抽象产品类(在工厂方法中)创建对象的知识依赖。也就是说,如果我们扩展我们的层次结构以包括新的具体产品类型,我们不需要修改抽象产品类。当然,我们需要能够修改我们的对象工厂类StudentFactory,以增强我们的MatriculateStudent()工厂方法。

与此实现相关的模式,即抽象工厂,是一种允许具有相似目的的单独工厂被分组在一起的附加模式。抽象工厂可以指定提供一种统一类似对象工厂的方法;它是一个将创建工厂的工厂,为我们的原始模式增加了另一个抽象层次。

我们现在已经看到了工厂方法模式的两种实现。我们将产品和工厂方法的概念融合到了我们习惯看到的类框架中,即Student及其Student的派生类。现在,让我们简要回顾一下与模式相关的内容,然后再继续下一章。

摘要

在本章中,我们通过扩展我们对设计模式的知识,继续追求成为更好的 C++ 程序员。特别是,我们探讨了工厂方法模式,从概念上以及通过两种常见的实现方式进行了研究。我们的第一个实现是将工厂方法放置在我们的抽象产品类中。我们的第二个实现通过添加一个包含工厂方法的对象工厂类,而不是在抽象产品和工厂方法之间建立依赖关系,从而消除了这种依赖。我们还非常简短地讨论了抽象工厂的概念。

利用常见的模式,如工厂方法模式,将帮助您更轻松地以其他程序员能理解的方式解决重复出现的编程问题。通过利用核心设计模式,您将为具有更复杂编程技术的可理解和可重用解决方案做出贡献。

我们现在准备继续前进,学习下一个设计模式,即第十八章中的实现适配器模式。将更多模式添加到我们的技能集合中,使我们成为更灵活、更有价值的程序员。让我们继续前进!

问题

  1. 使用之前练习的解决方案(问题 1第八章掌握抽象类),按照以下方式增强您的代码:

    1. 实现工厂方法模式以创建各种形状。你将已经创建了一个抽象基类Shape以及派生类,如RectangleCircleTriangle,以及可能的Square

    2. 选择是否将您的工厂方法实现为Shape中的静态方法,或者作为ShapeFactory类中的方法(如果需要,引入此类)。

  2. 你能想象出哪些其他例子可以轻松地结合工厂方法模式?

第十八章:应用适配器模式

本章将扩展我们的探索,旨在将你的 C++编程技能扩展到核心面向对象概念之外,目标是使你能够利用常见设计模式解决重复出现的编程问题。在编码解决方案中采用设计模式不仅可以提供优雅的解决方案,还可以提高代码维护性,并提供代码重用的潜在机会。

我们接下来将学习如何在 C++中有效地实现下一个核心设计模式——适配器模式

在本章中,我们将涵盖以下主要内容:

  • 理解适配器模式及其对面向对象编程(OOP)的贡献

  • 理解如何在 C++中实现适配器模式

到本章结束时,你将理解基本的适配器模式以及如何使用它来允许两个不兼容的类进行通信,或者将不合适的代码升级为良好的面向对象代码。将另一个关键设计模式添加到你的知识库中,将提高你的编程技能,帮助你成为一个更有价值的程序员。

让我们通过研究另一个常见的设计模式——适配器模式,来增加我们的编程技能集。

技术要求

在以下 GitHub URL 中可以找到完整程序示例的在线代码:github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/tree/main/Chapter18。每个完整程序示例都可以在 GitHub 仓库中找到,位于相应章节标题(子目录)下的文件中,该文件以章节编号开头,后面跟着一个连字符,然后是当前章节中的示例编号。例如,本章的第一个完整程序可以在上述 GitHub 目录下的Chapter18子目录中的名为Chp18-Ex1.cpp的文件中找到。

本章的 CiA 视频可在以下链接查看:bit.ly/3Kaxckc

理解适配器模式

适配器模式是一种结构型设计模式,它提供了一种将现有类的不理想接口转换为另一个类期望的接口的方法。适配器类将是两个现有组件之间通信的链接,通过适配接口使得这两个组件可以共享和交换信息。适配器允许两个或更多类协同工作,否则它们无法这样做。

理想情况下,适配器不会添加功能,而是添加使用(或转换)的首选接口,以便允许一个类以预期的方式使用,或者使两个原本不兼容的类能够相互通信。在其最简单的形式中,适配器只是将现有的类转换为支持预期的接口,正如在面向对象设计中可能指定的那样。

适配器可以与它提供适配接口的类相关联或从该类派生。如果使用继承,则适当的私有或受保护的基类可以隐藏底层实现。如果相反,适配器类与具有不理想接口的类相关联,适配器类中的方法(具有新接口)将仅将工作委托给其关联的类。

适配器模式还可以用来给一系列函数或其他类添加面向对象(OO)接口(即,在周围包裹一个 OO 接口),使得各种现有组件在面向对象系统中更自然地被利用。这种特定的适配器类型被称为 extern C,以便链接器解决两种语言之间的链接约定)。

利用适配器模式有好处。适配器通过提供一个共享接口,允许原本不相关的类进行通信,从而允许重用现有代码。现在面向对象的程序员将直接使用适配器类,这有助于应用程序的维护。也就是说,大多数程序员的交互将是一个设计良好的适配器类,而不是与两个或更多奇特的组件交互。使用适配器的一个小缺点是,由于增加了代码层,性能略有下降。然而,通常情况下,通过提供一个干净的接口来支持现有组件的交互,以重用现有组件,这是一个有利可图的方案,尽管可能会有(希望是小的)性能权衡。

适配器模式将包括以下内容:

  • 一个 Adaptee 类,它代表了具有理想实用工具的类,但它的存在形式不适合或不理想。

  • 一个 Adapter 类,它将 Adaptee 类的接口适配以满足所需接口的需求。

  • 一个 Target 类,它代表了当前应用程序的具体、所需的接口。一个类可能既是 Target 也是 Adapter。

  • 可选的 Client 类,它们将与 Target 类交互,以完全定义当前的应用程序。

适配器模式允许重用符合当前应用程序设计接口需求的现有合格组件。

让我们继续前进,看看适配器模式的两种常见应用;其中一种将有两种潜在的实现方式。

实现适配器模式

让我们探索适配器模式(Adapter pattern)的两种常见用法。那就是创建一个适配器来弥合两个不兼容的类接口之间的差距,或者创建一个适配器来简单地用面向对象(OO)接口包装现有的函数集。

我们将从使用一个提供两个(或更多)不兼容类之间连接器的适配器(Adapter)的用法开始。Adaptee 将是一个经过良好测试的类,我们希望重用它(但它的接口可能不理想),而Target 类将是我们在正在制作的应用程序中的 OO 设计所指定的。现在让我们指定一个适配器,以便我们的 Adaptee 能够与我们的 Target 类一起工作。

使用适配器为现有类提供必要的接口

要实现适配器模式,我们首先需要确定我们的 Adaptee 类。然后我们将创建一个适配器类来修改 Adaptee 的接口。我们还将确定我们的 Target 类,代表我们需要根据 OO 设计进行建模的类。有时,适配器和目标可能会合并成一个类。在实际应用中,我们还将有 Client 类,代表最终应用中发现的完整类集。让我们从 Adaptee 和 Adapter 类开始,因为这些类定义将是我们构建模式的基础。

在我们的例子中,我们将指定我们的 Adaptee 类为我们习惯看到的类——Person。我们将设想我们的星球最近意识到了许多其他能够支持生命的系外行星,并且我们已经善意地与每个这样的文明结盟。进一步设想地球上的各种软件系统都希望欢迎并包括我们的新朋友,包括RomulansOrkans,我们希望调整一些现有的软件以轻松适应我们系外行星邻居的新人口结构。考虑到这一点,我们将通过创建一个适配器类Humanoid来将我们的Person类转换为包含更多星际术语。

在我们即将到来的实现中,我们将使用私有继承从Person(Adaptee)继承Humanoid(Adapter),因此隐藏了 Adaptee 的底层实现。我们还可以将一个Humanoid与一个Person关联(我们将在本节中回顾这种实现)。然后我们可以在我们的层次结构中充实一些Humanoid的派生类,例如OrkanRomulanEarthling,以适应手头的星际应用。OrkanRomulanEarthling类可以被视为我们的 Target 类,或者是我们应用将要实例化的类。我们将选择使我们的适配器类Humanoid抽象,这样它就不能直接实例化。因为我们的特定派生类(目标类)可以通过我们的应用(Client)中的抽象基类类型(Humanoid)进行泛化,我们也可以将Humanoid视为一个目标类。也就是说,Humanoid主要被视为一个适配器,但次要地被视为一个泛化的目标类。

我们的各个 Client 类可以利用Humanoid的派生类,创建其具体后代的实例。这些实例可以存储在自己的专用类型中,或者使用Humanoid指针进行泛化。我们的实现是对广泛使用的适配器设计模式的一种现代诠释。

指定 Adaptee 和 Adapter(私有继承技术)

让我们看看我们适配器模式第一次使用时的机制,首先回顾一下我们的 Adaptee 类Person的定义。这个例子可以作为完整的程序在我们的 GitHub 仓库中找到:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter18/Chp18-Ex1.cpp

// Person is the Adaptee class (class requiring adaptation)
class Person
{
private:
    string firstName, lastName, title, greeting;
    char middleInitial  = '\0';  // in-class initialization
protected:
    void ModifyTitle(const string &);  
public:
    Person() = default;   // default constructor
    Person(const string &, const string &, char, 
           const string &);
    // default copy constructor prototype is not necessary
    // Person(const Person &) = default;  // copy ctor
    // Default op= suffices, so we'll comment out proto.
    // (see online code for review of implementation)
    // Person &operator=(const Person &); // assignment op.
    virtual ~Person()= default;  // virtual destructor
    const string &GetFirstName() const 
        { return firstName; }  
    const string &GetLastName() const 
        { return lastName; }    
    const string &GetTitle() const { return title; }
    char GetMiddleInitial() const { return middleInitial; }
    void SetGreeting(const string &);
    virtual const string &Speak() { return greeting; }
    virtual void Print() const;
};
// Assume constructors, destructor, and non-inline methods
// are implemented as expected (see online code)

在前面的类定义中,我们注意到我们的 Person 类定义与我们在这本书的许多其他示例中看到的一样。这个类是可以实例化的;然而,在我们的星际应用程序中,Person 并不是一个合适的类来实例化。相反,预期的接口应该是使用 Humanoid 中的接口。

在这个前提下,让我们来看看我们的适配器类 Humanoid

class Humanoid: private Person   // Humanoid is abstract
{                           
protected:
    void SetTitle(const string &t) { ModifyTitle(t); }
public:
    Humanoid() = default;   
    Humanoid(const string &, const string &, 
             const string &, const string &);
    // default copy constructor prototype not required
    // Humanoid(const Humanoid &h) = default; 
    // default op= suffices, so commented out below, but
    // let's review how we'd write op= if needed
    // note explicit Humanoid downcast after calling base  
    // class Person::op= to match return type needed here
    // Humanoid &operator=(const Humanoid &h) 
    //     { return dynamic_cast<Humanoid &> 
    //              (Person::operator=(h)); }
    // dtor proto. not required since base dtor is virt.
    // ~Humanoid() override = default; // virt destructor
    // Added interfaces for the Adapter class 
    const string &GetSecondaryName() const 
        { return GetFirstName(); }  
    const string &GetPrimaryName() const 
        { return GetLastName(); } 
    // scope resolution needed in method to avoid recursion 
    const string &GetTitle() const 
        { return Person::GetTitle();}
    void SetSalutation(const string &m) { SetGreeting(m); }
    virtual void GetInfo() const { Print(); }
    virtual const string &Converse() = 0; // abstract class
};
Humanoid::Humanoid(const string &n2, const string &n1, 
    const string &planetNation, const string &greeting):
    Person(n2, n1, ' ', planetNation)
{
    SetGreeting(greeting);
}
const string &Humanoid::Converse()  // default definition 
{                    // for pure virtual function - unusual
    return Speak();
}

在上述 Humanoid 类中,我们的目标是提供一个适配器,以贡献我们星际应用程序所需的预期接口。我们简单地使用私有继承从 Person 派生 Humanoid,隐藏了 Person 中发现的公共接口,使其在 Humanoid 的作用域之外不被使用。我们理解目标应用程序(客户端)不希望 Person 中的公共接口被 Humanoid 实例的各种子类型所利用。请注意,我们不是添加功能,只是在适配接口。

我们接着注意到 Humanoid 中引入的公共方法,为目标类(们)提供了所需的接口。这些接口的实现通常是直接的。我们只需调用在 Person 中定义的继承方法,就可以轻松完成当前任务(但这样做使用的是不可接受的接口)。例如,我们的 Humanoid::GetPrimaryName() 方法简单地调用 Person::GetLastName(); 来完成任务。然而,GetPrimaryName() 可能更多地代表了适当的星际语言,而不是 Person::GetLastName()。我们可以看到 Humanoid 如何作为 Person 的适配器。我们还可以看到适配器类 Humanoid 的大多数成员函数如何使用内联函数简单地封装 Person 方法,以提供更合适的接口,同时不增加任何开销。

注意,在 Humanoid 方法中对 Person 基类方法的调用前不需要使用 Person::(除非 Humanoid 方法调用 Person 中相同名称的方法,例如 GetTitle())。使用 Person:: 的作用域解析避免了这些情况中的潜在递归。

我们还注意到,Humanoid 引入了一个抽象的多态方法(即纯虚函数),其指定为 virtual const string &Converse() = 0;。我们已经做出了设计决定,只有 Humanoid 的派生类才能被实例化。尽管如此,我们理解公共派生类仍然可以被其基类类型 Humanoid 收集。在这里,Humanoid 主要作为适配器类,次要作为提供一系列可接受接口的目标类。

注意,我们的纯虚函数virtual const String &Converse() = 0;包含了一个默认实现。这种情况很少见,但允许这样做,只要实现不是内联编写的。在这里,我们利用这个机会为Humanoid::Converse()指定一个默认行为,只需简单地调用Person::Speak()

从适配器派生具体类

接下来,让我们扩展我们的适配器(Humanoid)并查看我们的一个具体派生目标类Orkan

class Orkan: public Humanoid
{
public:
    Orkan() = default;   // default constructor
    Orkan(const string &n2, const string &n1, 
        const string &t): Humanoid(n2, n1, t, "Nanu nanu")
        { }
    // default copy constructor prototype not required
    // Orkan(const Orkan &h) = default;  
    // default op= suffices, so commented out below, but
    // let's review how we'd write it if needed
    // note explicit Orkan downcast after calling base  
    // class Humanoid::op= to match return type needed here
    // Orkan &operator=(const Orkan &h) 
    //    { return dynamic_cast<Orkan &>
    //             (Humanoid::operator=(h)); }
    // dtor proto. not required since base dtor is virt.
    // ~Orkan() override = default; // virtual destructor
    const string &Converse() override;  
};
// Must override Converse to make Orkan a concrete class
const string &Orkan::Converse()  
{                                
    return Humanoid::Converse(); // use scope resolution to
}                                // avoid recursion

在我们前面提到的Orkan类中,我们使用公有继承从Humanoid派生Orkan。一个Orkan 一个Humanoid。因此,Humanoid中的所有公共接口都对Orkan实例可用。注意,我们的备用构造函数将默认问候信息设置为"Nanu nanu",按照Orkan方言。

由于我们希望Orkan成为一个具体可实例化的类,我们必须在Orkan类中重写Humanoid::Converse()并提供实现。然而,请注意,Orkan::Converse()只是调用Humanoid::Converse();。也许Orkan认为其基类中的默认实现是可以接受的。注意,我们使用Humanoid::作用域解析符在Orkan::Converse()方法中限定Converse(),以避免递归。

有趣的是,如果Humanoid不是一个抽象类,Orkan就不需要重写Converse()方法——默认行为会自动继承。然而,由于Humanoid被定义为抽象类,Orkan中必须重写Converse()方法,否则Orkan也会被视为一个抽象类。不用担心!我们只需在Orkan::Converse()中调用Humanoid::Converse(),就可以利用Humanoid::Converse()的默认行为的好处。这将满足使Orkan具体化的要求,同时允许Humanoid保持抽象状态,同时仍然为Converse()提供罕见的默认行为!

现在,让我们看一下我们的下一个具体目标类Romulan

class Romulan: public Humanoid
{
public:
    Romulan() = default;   // default constructor
    Romulan(const string &n2, const string &n1, 
        const string &t): Humanoid(n2, n1, t, "jolan'tru")
        { }
    // default copy constructor prototype not required
    // Romulan(const Romulan &h) = default;
    // default op= suffices, so commented out below, but
    // let's review how we'd write it if so needed
    // note explicit Romulan downcast after calling base  
    // class Humanoid::op= to match return type needed here
    // Romulan &operator=(const Romulan &h) 
    //    { return dynamic_cast<Romulan &>
    //             (Humanoid::operator=(h)); }
    // dtor proto. not required since base dtor is virt.
    // ~Romulan() override = default;  // virt destructor
    const string &Converse() override;  
};
// Must override Converse to make Romulan a concrete class
const string &Romulan::Converse()  
{                               
    return Humanoid::Converse(); // use scope resolution to
}                                // avoid recursion        

快速看一下前面提到的Romulan类,我们会注意到这个具体的目标类与它的兄弟类Orkan相似。我们会注意到传递给基类构造函数的默认问候信息是"jolan'tru",以反映Romulan方言。尽管我们可以使我们的Romulan::Converse()实现更加复杂,但我们选择不这样做。我们可以快速理解这个类的全部范围。

接下来,让我们看一下我们的第三个目标类Earthling

class Earthling: public Humanoid
{
public:
    Earthling() = default;   // default constructor
    Earthling(const string &n2, const string &n1, 
        const string &t): Humanoid(n2, n1, t, "Hello") { }
    // default copy constructor prototype not required
    // Earthling(const Romulan &h) = default;  
    // default op= suffices, so commented out below, but
    // let's review how we'd write it if so needed
    // note explicit Earthling downcast after calling base
    // class Humanoid::op= to match return type needed here
    // Earthling &operator=(const Earthling &h) 
    //    { return dynamic_cast<Earthling &>
    //             (Humanoid::operator=(h)); }
    // dtor proto. not required since base dtor is virt.
    // ~Earthling() override = default; // virt destructor
    const string &Converse() override;  
};
// Must override to make Earthling a concrete class
const string &Earthling::Converse() 
{                                                          
    return Humanoid::Converse(); // use scope resolution to
}                                // avoid recursion

再次,快速看一下前面提到的Earthling类,我们会注意到这个具体的目标类与它的兄弟类OrkanRomulan相似。

现在我们已经定义了我们的适配器、适配器和多个目标类,让我们通过检查程序的客户端部分来将这些组件组合在一起。

将模式组件组合在一起

最后,让我们考虑一下在我们的整体应用程序中一个示例客户端可能的样子。当然,它可能由许多具有各种类的文件组成。在其最简单的形式中,如以下所示,我们的客户端将包含一个main()函数来驱动应用程序。

现在我们来看看我们的main()函数,看看我们的模式是如何编排的:

int main()
{
    list<Humanoid *> allies;
    Orkan *o1 = new Orkan("Mork", "McConnell", "Orkan");
    Romulan *r1 = new Romulan("Donatra", "Jarok", 
                              "Romulan");
    Earthling *e1 = new Earthling("Eve", "Xu",
                                  "Earthling");
    // Add each specific type of Humanoid to generic list
    allies.push_back(o1);
    allies.push_back(r1);
    allies.push_back(e1);

    // Process the list of allies (which are Humanoid *'s 
    // Actually, each is a specialization of Humanoid!)
    for (auto *entity : allies)
    {
        entity->GetInfo();
        cout << entity->Converse() << endl;
    }
    // Though each type of Humanoid has a default
    // Salutation, each may expand their skills with 
    // an alternate language
    e1->SetSalutation("Bonjour");
    e1->GetInfo();
    cout << e1->Converse() << endl; // Show the Earthling's 
                           // revised language capabilities
    delete o1;   // delete the heap instances
    delete r1;
    delete e1;
    return 0;
}

回顾我们之前提到的main()函数,我们首先使用list<Humanoid *>创建一个 STL 列表,名为allies;。然后我们实例化一个OrkanRomulan和一个Earthling,并使用allies.push_back()将它们每个都添加到列表中。再次使用标准模板库,我们接下来创建一个列表迭代器来遍历指向Humanoid实例的指针列表。当我们遍历我们的通用盟友列表时,我们在列表中的每个项目上调用GetInfo()Converse()的批准接口(即,对于每种特定的Humanoid类型)。

接下来,我们指定一个特定的Humanoid,一个Earthling,并通过调用e1->SetSalutation("Bonjour");来更改这个实例的默认问候语。通过再次在这个实例上调用Converse()(我们首先在上面的循环中泛型地这样做),我们可以要求Earthling使用"Bonjour"来问候盟友,而不是使用默认的问候语"Hello"Earthling的默认问候语)。

让我们看看这个程序的输出:

Orkan Mork McConnell
Nanu nanu
Romulan Donatra Jarok
jolan'tru
Earthling Eve Xu
Hello
Earthling Eve Xu
Bonjour

在上述输出中,请注意,每个Humanoid的行星规范都显示了出来(OrkanRomulanEarthling),然后是它们的次要和主要名称。然后,显示特定Humanoid的适当问候语。请注意,Earthling Eve Xu首先使用"Hello"进行对话,然后后来使用"Bonjour"进行对话。

之前实现(使用私有基类从 Adaptee 派生 Adapter)的一个优点是代码非常直接。使用这种方法,Adaptee 类中的任何受保护的方法都可以轻松地传递到 Adapter 方法的作用域内。我们很快就会看到,如果我们将关联用作将 Adapter 连接到 Adaptee 的手段,受保护成员将是一个问题。

之前提到的方法的一个缺点是它是一个 C++特定的实现。其他语言不支持私有基类。另外,使用公有基类来定义 Adapter 和 Adaptee 之间的关系将无法隐藏不想要的 Adaptee 接口,这将是一个非常糟糕的设计选择。

考虑 Adaptee 和 Adapter(关联)的另一种规范

让我们现在简要考虑一下之前提到的 Adapter 模式实现的略微修改版本。我们将使用关联来模拟 Adaptee 和 Adapter 之间的关系。具体的派生类(目标)仍然会像之前一样从 Adapter 派生。

这里是我们的 Adapter 类 Humanoid 的另一种实现,使用 Adapter 和 Adaptee 之间的关联。尽管我们只会审查与我们的初始方法不同的代码部分,但完整的实现可以在我们的 GitHub 仓库中找到:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter18/Chp18-Ex2.cpp

// Assume that Person exists mostly as before – however,
// Person::ModifyTitle() must be moved from protected to
// public or be unused if modifying Person is not possible.
// Let's assume we moved Person::ModifyTitle() to public.
class Humanoid    // Humanoid is abstract
{
private:
    Person *life = nullptr;  // delegate all requests to
                             // the associated object
protected:
    void SetTitle(const string &t) 
        { life->ModifyTitle(t); }
public:
    Humanoid() = default;
    Humanoid(const string &, const string &, 
             const string &, const string &);
    Humanoid(const Humanoid &h);// we have work for copies!
    Humanoid &operator=(const Humanoid &); // and for op=
    virtual ~Humanoid()  // virtual destructor
        { delete life; life = nullptr; }  
    // Added interfaces for the Adapter class
    const string &GetSecondaryName() const 
        { return life->GetFirstName(); }
    const string &GetPrimaryName() const 
        { return life->GetLastName(); }    
    const string &GetTitle() const 
        { return life->GetTitle();}
    void SetSalutation(const string &m) 
        { life->SetGreeting(m); }
    virtual void GetInfo() const { life->Print(); }
    virtual const string &Converse() = 0; // abstract class
};
Humanoid::Humanoid(const string &n2, const string &n1, 
          const string &planetNation, const string &greeting)
{
    life = new Person(n2, n1, ' ', planetNation);
    life->SetGreeting(greeting);
}
// copy constructor (we need to write it ourselves)
Humanoid::Humanoid(const Humanoid &h)  
{  // Remember life data member is of type Person
    delete life;  // delete former associated object
    life = new Person(h.GetSecondaryName(),
                     h.GetPrimaryName(),' ', h.GetTitle());
    life->SetGreeting(h.life->Speak());  
}
// overloaded operator= (we need to write it ourselves)
Humanoid &Humanoid::operator=(const Humanoid &h)
{
    if (this != &h)
        life->Person::operator=(dynamic_cast
                                <const Person &>(h));
    return *this;
}
const string &Humanoid::Converse() //default definition for
{                              // pure virtual fn - unusual
    return life->Speak();
}

在上述 Adapter 类的实现中,Humanoid 已不再从 Person 派生。相反,Humanoid 将添加一个私有数据成员 Person *life;,它将代表 Adapter (Humanoid) 和 Adaptee (Person) 之间的关联。在我们的 Humanoid 构造函数中,我们需要分配 Adaptee (Person) 的底层实现。我们还需要在我们的析构函数中删除 Adaptee (Person)。

与我们上一次的实现类似,Humanoid 在其公共接口中提供了相同的成员函数。然而,请注意,每个 Humanoid 方法都通过关联对象将调用委托给适当的 Adaptee 方法。例如,Humanoid::GetSecondaryName() 仅调用 life->GetFirstName(); 来委托请求(而不是调用继承的相应 Adaptee 方法)。

与我们最初的实现一样,我们的从 Humanoid 派生的类(OrkanRomulanEarthling)以相同的方式指定,同样,我们的 main() 函数中的客户端也是这样。

选择 Adaptee 和 Adapter 之间的关系

在选择私有继承或关联作为 Adapter 和 Adaptee 之间的关系时,一个值得考虑的有趣点是 Adaptee 是否包含任何受保护的成员。回想一下,Person 的原始代码包括一个受保护的 ModifyTitle() 方法。Adaptee 类中应该存在受保护的成员吗?私有基类实现允许那些继承的受保护成员在 Adapter 类的作用域内继续被访问(即通过 Adapter 的方法)。然而,使用基于关联的实现,Adaptee (Person) 中的受保护方法在 Adapter 的作用域内不可用。为了使这个例子工作,我们需要将 Person::ModifyTitle() 移到公共访问区域。然而,修改 Adaptee 类并不总是可能的,也不一定是推荐的。考虑到受保护成员的问题,我们最初使用私有基类的实现是更强的实现,因为它不依赖于我们修改 Adaptee (Person) 的类定义。

现在,让我们简要地看一下适配器模式的一种替代用法。我们只是将适配器类用作包装类。我们将向一个原本松散排列但工作良好的函数集添加面向对象的接口,但这些函数缺少我们应用程序(客户端)所需的目标接口。

使用适配器作为包装器

作为适配器模式的一种替代用法,我们将围绕一组相关的外部函数包装一个面向对象(OO)接口。也就是说,我们将创建一个包装类来封装这些函数。

在我们的例子中,外部函数将代表一组现有的数据库访问函数。我们假设核心数据库功能已经针对我们的数据类型(Person)进行了良好的测试,并且没有问题地使用过。然而,这些外部函数本身提供了一个不理想且意外的功能接口。

我们将通过创建一个适配器类来封装这些外部函数的功能。我们的适配器类将是CitizenDataBase,代表一种封装的从数据库读取和写入Person实例的方法。我们现有的外部函数将为我们的CitizenDataBase成员函数提供实现。让我们假设在适配器类中定义的面向对象接口符合我们的面向对象设计要求。

让我们来看看我们简单包装适配器模式的机制,首先从检查提供数据库访问功能的外部函数开始。这个例子作为完整的程序可以在我们的 GitHub 仓库中找到:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter18/Chp18-Ex3.cpp

// Assume Person class exists with its usual implementation
Person objectRead; // holds the object from current read
                   // to support a simulation of a DB read
void db_open(const string &dbName)
{   // Assume implementation exists
    cout << "Opening database: " << dbName << endl;
}
void db_close(const string &dbName)
{   // Assume implementation exists
    cout << "Closing database: " << dbName << endl;
}
Person &db_read(const string &dbName, const string &key)
{   // Assume implementation exists
    cout << "Reading from: " << dbName << " using key: ";
    cout << key << endl;
    // In a true implementation, we would read the data
    // using the key and return the object we read in
    return objectRead; // non-stack instance for simulation
}
const string &db_write(const string &dbName, Person &data)
{   // Assume implementation exists
    const string &key = data.GetLastName();
    cout << "Writing: " << key << " to: " << 
             dbName << endl;
    return key;
}

在我们之前定义的外部函数中,让我们假设所有函数都经过了良好的测试,并允许从数据库中读取或写入Person实例。为了支持这种模拟,我们创建了一个外部Person实例Person objectRead;,为刚读取的实例提供一个简短的、非堆栈位置的存储空间(由db_read()使用),直到新读取的实例被捕获为返回值。请注意,现有的外部函数并不代表一个封装的解决方案。

现在,让我们创建一个简单的包装类来封装这些外部函数。这个包装类,CitizensDataBase,将代表我们的适配器类:

// CitizenDataBase is the Adapter class 
class CitizenDataBase  // Adapter wraps undesired interface
{
private:
    string name;
public:
    // No default constructor (unusual)
    CitizenDataBase(const string &);
    CitizenDataBase(const CitizenDataBase &) = delete;
    CitizenDataBase &operator=(const CitizenDataBase &) 
                               = delete;  // disallow =
    virtual ~CitizenDataBase();  // virtual destructor
    inline Person &Read(const string &);
    inline const string &Write(Person &);
};
CitizenDataBase::CitizenDataBase(const string &n): name(n)
{
    db_open(name);   // call existing external function
}
CitizenDataBase::~CitizenDataBase()
{
    db_close(name);  // close database with external
}                    // function
Person &CitizenDataBase::Read(const string &key)
{
    return db_read(name, key);   // call external function
}
const string &CitizenDataBase::Write(Person &data)
{
    return db_write(name, data);  // call external function
}

在我们之前为适配器类定义的类中,我们只是简单地将外部数据库功能封装在 CitizenDataBase 类中。在这里,CitizenDataBase 不仅是我们的适配器类,也是我们的目标类,因为它包含了我们的应用程序(客户端)所期望的接口。请注意,CitizenDataBaseRead()Write() 方法都已经在类定义中内联了;它们的方法只是调用外部函数。这是一个示例,说明了具有内联函数的包装类可以是一个低成本适配器类,仅添加非常小的开销(构造函数、析构函数以及可能的其他非内联方法)。

现在,让我们来看看我们的 main() 函数,它是客户端的一个精简版本:

int main()
{
    string key;
    string name("PersonData"); // name of database
    Person p1("Curt", "Jeffreys", 'M', "Mr.");
    Person p2("Frank", "Burns", 'W', "Mr.");
    Person p3;
    CitizenDataBase People(name);   // open Database
    key = People.Write(p1); // write a Person object
    p3 = People.Read(key);  // using a key, retrieve Person
    return 0;
}                        // destruction will close database

在上述 main() 函数中,我们首先创建了三个 Person 实例。然后,我们创建了一个 CitizenDataBase 实例,以提供封装的访问权限来写入或读取我们的 Person 实例,到或从数据库中。我们的 CitizenDataBase 构造函数的方法调用外部函数 db_open() 来打开数据库。同样,析构函数调用 db_close()。正如预期的那样,我们的 CitizenDataBaseRead()Write() 方法将分别调用外部函数 db_read()db_write()

让我们看看这个程序的输出:

Opening database: PersonData
Writing: Jeffreys to: PersonData
Reading from: PersonData using key: Jeffreys
Closing database: PersonData

在上述输出中,我们可以注意到各种成员函数与包装的外部函数之间的关联,通过构造函数、写入和读取的调用,以及数据库的销毁。

我们的简单 CitizenDataBase 包装器是适配器模式的一个非常直接但合理的应用。有趣的是,我们的 CitizenDataBase 也与 数据访问对象模式 有相似之处,因为这个包装器提供了一个干净的接口来访问数据存储机制,隐藏了底层数据库的实现(访问)。

我们现在已经看到了适配器模式的三个实现。我们将适配器、适配者、目标和客户端的概念融合到了我们习惯看到的类框架中,即 Person 类,以及我们的适配器的后代(OrkanRomulanEarthling,如我们的前两个例子所示)。现在,让我们简要回顾一下我们在学习模式之前所学的知识,然后继续到下一章。

摘要

在本章中,我们通过扩展我们对设计模式的知识来提高我们成为更好的 C++程序员的追求。我们探讨了适配器模式的概念及其多种实现。我们的第一个实现使用私有继承从适配器类派生出适配器。我们指定适配器为一个抽象类,然后使用公共继承根据适配器类提供的接口引入几个目标类。我们的第二个实现则使用关联来模拟适配器和适配器之间的关系。然后我们查看了一个适配器作为包装器的示例用法,简单地为现有的基于函数的应用程序组件添加 OO 接口。

利用常见的设计模式,例如适配器模式,将帮助您更轻松地重用现有经过良好测试的代码部分,并且其他程序员也能理解。通过利用核心设计模式,您将为理解良好且可重用的解决方案做出贡献,并使用更复杂的编程技术。

我们现在准备继续前进,学习下一个设计模式,在第十九章中,使用单例模式。将更多模式添加到我们的编程技能库中,使我们成为更灵活且更有价值的程序员。让我们继续前进!

问题

  1. 使用本章中找到的适配器示例,创建一个如下所示的程序:

    1. 实现一个CitizenDataBase,该数据库存储各种类型的Humanoid实例(OrkanRomulanEarthling以及可能还有Martian)。决定您将使用私有基类适配器-适配器关系还是适配器和适配器之间的关联关系(提示:私有继承版本将更容易)。

    2. 注意到CitizenDataBase处理Person实例,这个类是否可以原样使用来存储各种类型的Humanoid实例,或者必须以某种方式对其进行适配?回想一下,PersonHumanoid的基类(如果您选择了这种实现),但也要记住,我们永远不能超出非公共继承边界进行向上转型。

  2. 你能想象出哪些其他例子可以轻松地结合适配器模式?

第十九章:使用单例模式

本章将继续我们的目标,即扩展你的 C++ 编程技能,使其超越核心面向对象编程(OOP)概念,目标是让你能够利用核心设计模式解决重复出现的编程难题。在编码解决方案中使用设计模式不仅可以提供更精细的解决方案,还有助于简化代码维护,并提供代码重用的潜在机会。

我们接下来将学习如何在 C++ 中有效地实现下一个核心设计模式——单例模式

在本章中,我们将涵盖以下主要主题:

  • 理解单例模式及其对面向对象编程(OOP)的贡献

  • 在 C++ 中实现单例模式(使用简单技术与配对类方法),并使用注册表允许许多类使用单例模式

到本章结束时,你将理解单例模式及其如何确保给定类型只能存在一个实例。将另一个核心设计模式添加到你的知识体系中将进一步增强你的编程技能,帮助你成为一个更有价值的程序员。

让我们通过检查另一个常见的设计模式——单例模式,来提高我们的编程技能集。

技术要求

完整程序示例的在线代码可以在以下 GitHub 网址找到:github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/tree/main/Chapter19。每个完整程序示例都可以在 GitHub 仓库中找到,位于相应章节标题(子目录)下的文件中,该文件以章节编号开头,后面跟着一个连字符,然后是当前章节中的示例编号。例如,本章的第一个完整程序可以在上述 GitHub 目录下的 Chapter19 子目录中找到,文件名为 Chp19-Ex1.cpp

本章的 CiA 视频可以在以下网址观看:bit.ly/3ThNKe0

理解单例模式

单例模式是一种创建型设计模式,它保证一个采用这种习惯用法的类只有一个实例;该类型的两个或多个实例可能根本无法同时存在。采用这种模式的类将被称为单例

Singleton 可以通过静态数据成员和静态方法来实现。这意味着 Singleton 将拥有对当前实例的全局访问点。这种影响最初看起来很危险;将全局状态信息引入代码是导致 Singleton 有时被认为是一种反模式的一个批评。然而,通过适当使用定义 Singleton 的静态数据成员的访问区域,我们可以坚持认为对 Singleton(除了初始化之外)的访问只能使用当前类适当的静态方法(并缓解这种潜在的模式关注)。

对该模式的另一个批评是它不是线程安全的。可能存在竞争条件来进入创建 Singleton 实例的代码段。如果没有保证对那个关键代码区域的互斥性,Singleton 模式将会破裂,允许多个此类实例。因此,如果使用多线程编程,那么也必须使用适当的锁定机制来保护 Singleton 实例化的关键代码区域。使用静态内存实现的 Singleton 是同一进程中的线程之间的共享内存;有时,Singleton 可能会因为垄断资源而受到批评。

Singleton 模式可以利用几种技术来实现。每种实现方式不可避免地都会有优点和缺点。我们将使用一对相关的类,SingletonSingletonDestroyer,来稳健地实现该模式。虽然存在更简单、更直接的实施方法(其中两种我们将简要回顾),但最简单的技术留下了 Singleton 可能不会被充分销毁的可能性。回想一下,析构函数可能包括重要且必要的活动。

Singleton 往往是长期存在的;因此,在应用程序终止之前销毁 Singleton 是合适的。许多客户端可能指向 Singleton,因此不应有单个客户端删除 Singleton。我们将看到 Singleton 将是 自我创建 的,因此它应该理想地是 自我销毁(即通过其 SingletonDestroyer 的帮助)。因此,配对类方法,虽然不那么简单,但将确保适当的 Singleton 销毁。请注意,我们的实现还将允许直接删除 Singleton;这很少见,但我们的代码也将处理这种情况。

配对类实现的 Singleton 模式将包括以下内容:

  • 一个代表实现 Singleton 概念所需核心机制的 Singleton 类。

  • Singleton 确保给定的 Singleton 被正确地销毁。

  • Singleton 派生出的类代表一个我们想要确保在给定时间只能创建其类型的一个实例的类。这将是我们的 目标 类。

  • 可选地,目标类可以同时从 Singleton 和另一个类派生,该类可能代表我们想要专门化或简单地包含(即 mix-in)的现有功能。在这种情况下,我们将从应用程序特定的类和 Singleton 类进行多重继承。

  • 可选的 Client 类,这些类将与目标类(们)交互,以完全定义当前的应用程序。

  • 或者,Singleton 也可以在目标类中实现,将类功能捆绑在一个类中。

  • 真正的 Singleton 模式可以扩展以允许创建多个(离散的),但不是不确定数量的实例。这种情况很少见。

我们将关注一个传统的 Singleton 模式,确保在给定时间只有一个实例的类采用此模式存在。

让我们继续前进,首先考察两种简单的实现,然后是我们的首选配对类实现 Singleton 模式。

实现 Singleton 模式

Singleton 模式将被用来确保给定的类只能实例化该类的一个实例。然而,一个真正的 Singleton 模式也将具有扩展能力,允许创建多个(但数量是明确定义的)实例。Singleton 模式这个不常见且不太为人所知的限制条件是很少见的。

我们将从两个简单的 Singleton 实现开始,以了解它们的局限性。然后我们将过渡到更健壮的配对类 Singleton 实现,其最常见的模式目标是任何给定时间只允许一个目标类实例化。

使用简单实现

为了实现一个非常简单的 Singleton,我们将为 Singleton 本身使用一个直接的单类规范。我们将定义一个名为 Singleton 的类,以封装此模式。我们将确保我们的构造函数是私有的,这样就不能被多次应用。我们还将添加一个静态的 instance() 方法,以提供 Singleton 对象实例化的接口。此方法将确保私有构造只发生一次。

让我们看看这个简单的实现,它可以在我们的 GitHub 仓库中找到:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter19/Chp19-Ex1.cpp

class Singleton
{
private:
    static Singleton *theInstance;   // initialized below
    Singleton();  // private to prevent multiple
                  // instantiation
public:
    static Singleton *instance(); // interface for creation
    virtual ~Singleton(); // never called, unless you
};                        // delete Singleton explicitly, 
                          // which is unlikely and atypical
Singleton *Singleton::theInstance = nullptr; // extern var
                                   // to hold static member
Singleton::Singleton()
{
    cout << "Constructor" << endl;
    // Below line of code is not necessary and therefore
    // commented out – see static member init. above
    // theInstance = nullptr;
}
Singleton::~Singleton()  // the destructor is not called in
{                        // the typical pattern usage
    cout << "Destructor" << endl;
    if (theInstance != nullptr)  
    {  
       Singleton *temp = theInstance;
       // Remove pointer to Singleton and prevent recursion
       // Remember, theInstance is static, so
       // temp->theInstance = nullptr; would be duplicative 
       theInstance = nullptr;    
       delete temp;              // delete the Singleton
       // Note, delete theInstance; without temp usage
       // above would be recursive 
    }                 
}
Singleton *Singleton::instance()
{
    if (theInstance == nullptr)
        theInstance = new Singleton();// allocate Singleton
    return theInstance;
}
int main()
{
    // create Singleton
    Singleton *s1 = Singleton::instance(); 
    // returns existing Singleton (not a new one)
    Singleton *s2 = Singleton::instance(); 
    // note: addresses are the same (same Singleton!)
    cout << s1 << " " << s2 << endl; 
    return 0;
}                                         

注意,在上面的类定义中,我们包括数据成员 static Singleton *theInstance; 来表示 Singleton 实例本身。我们的构造函数是私有的,因此不能被多次使用来创建多个 Singleton 实例。相反,我们添加一个 static Singleton *instance() 方法来创建 Singleton。在此方法中,我们检查数据成员 theInstance 是否等于 nullptr,如果是,则实例化唯一的 Singleton 实例。

在类定义之外,我们看到外部变量(及其初始化)通过定义 Singleton *Singleton::theInstance = nullptr; 来支持静态数据成员的内存需求。我们还可以看到在 main() 函数中,我们如何调用静态 instance() 方法来使用 Singleton::instance() 创建一个 Singleton 实例。第一次调用此方法将实例化一个 Singleton,而后续调用此方法将仅返回现有 Singleton 对象的指针。我们可以通过打印这些对象地址来验证实例是相同的。

让我们看看这个简单程序的输出:

Constructor
0xee1938 0xee1938

在之前提到的输出中,我们注意到一些可能意想不到的事情——析构函数没有被调用!如果析构函数有重要的任务要执行会怎样?

理解简单 Singleton 实现的关键缺陷

在简单实现中,我们没有调用我们的 Singleton 的析构函数,仅仅是因为我们没有通过 s1s2 标识符删除动态分配的 Singleton 实例。为什么没有呢?显然可能有多个指向 Singleton 对象的指针(句柄)。决定哪个句柄应该负责删除 Singleton 是困难的——句柄至少需要协作或采用引用计数。

此外,Singleton 往往存在于整个应用程序的运行期间。这种长寿进一步表明 Singleton 应该负责自己的销毁。但是如何做到呢?我们很快就会看到一个实现,它将允许 Singleton 使用辅助类来控制自己的销毁。然而,在简单实现中,我们可能会简单地举手表示无能为力,并建议操作系统在应用程序终止时回收内存资源——包括这个小型 Singleton 的堆内存。这是真的;然而,如果在析构函数中需要完成一个重要的任务会怎样?我们正在遇到简单模式实现中的局限性。

如果我们需要调用析构函数,我们是否应该允许一个句柄使用例如 delete s1; 来删除实例?我们之前已经审查了是否允许任何句柄执行删除的问题,但现在让我们进一步检查析构函数本身可能存在的问题。例如,如果我们的析构函数假设上只包括 delete theInstance;,我们将有一个递归函数调用。也就是说,调用 delete s1; 将调用 Singleton 析构函数,而析构函数体内的 delete theInstance; 将将 theInstance 识别为 Singleton 类型并再次调用 Singleton 析构函数——递归地

不要担心!正如所示,我们的析构函数通过首先检查theInstance数据成员是否不等于nullptr来管理递归,然后安排temp指向theInstance以保存我们需要删除的实例的句柄。然后我们执行temp->theInstance = nullptr;赋值操作,以防止在执行delete temp;时发生递归。为什么?因为delete temp;也会调用Singleton析构函数。在这个析构函数调用期间,temp将绑定到this,并在第一次递归函数调用中失败条件测试if (theInstance != nullptr),从而退出递归。请注意,我们即将实施的配对类方法实现将不会出现这个问题。

重要的是要注意,在实际应用中,我们不会创建一个域无关的Singleton实例。相反,我们会将应用程序分解到设计中以使用该模式。毕竟,我们希望有一个有意义的类类型的Singleton实例。为此,我们可以以简单的Singleton类为基础,简单地从Singleton继承我们的目标(应用程序特定)类。目标类也将有私有构造函数——接受必要的参数以充分实例化目标类。然后我们将Singleton中的静态instance()方法移动到目标类,并确保instance()的参数列表接受传递给私有目标构造函数的必要参数。

总结来说,我们的简单实现存在固有的设计缺陷,即无法保证Singleton本身的正确销毁。当应用程序终止时,让操作系统收集内存不会调用析构函数。虽然选择多个句柄之一来删除Singleton的内存是可能的,但这需要协调,并且也破坏了通常的应用模式,允许Singleton在应用程序运行期间存在。

让我们接下来考虑一个使用静态局部内存引用而不是堆内存指针的简单实现,用于我们的单例(Singleton)。

另一种简单的实现

作为实现一个非常简单的单例的替代方法,我们将修改之前的简单类定义。首先,我们将移除静态指针数据成员(它是在Singleton::instance()中动态分配的)。我们不会在类中使用静态数据成员,而是在instance()方法中使用一个(非指针)静态局部变量来表示单例。

让我们看看这个替代实现,它可以在我们的 GitHub 仓库中找到:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter19/Chp19-Ex1b.cpp

class Singleton
{ 
private:
    string data;
    Singleton(string d); // private to prevent multiple 
public:                  // instantiation
    static Singleton &instance(string); // return reference
    // destructor is called for the static local variable
    // declared in instance() before the application ends
    virtual ~Singleton();   // destructor is now called
    const string &getData() const { return data; }
};
Singleton::Singleton(string d): data(d)  // initialize data
{                                   
    cout << "Constructor" << endl;
}
Singleton::~Singleton()
{
    cout << "Destructor" << endl;
}
// Note that instance() takes a parameter to reflect how we
// can provide meaningful data to the Singleton constructor
Singleton &Singleton::instance(string d)
{   // create the Singleton with desired constructor; But,
    // we can never replace the Singleton in this approach!
    // Remember, static local vars are ONLY created and 
    // initialized once - guaranteeing one Singleton
    static Singleton theInstance(d);   
    return theInstance;
}
int main()
{   
    // First call, creates/initializes Singleton
    Singleton &s1 = Singleton::instance("Unique data"); 
    // Second call returns existing Singleton
    // (the static local declaration is ignored)
    Singleton &s2 = Singleton::instance("More data"); 
    cout << s1.getData() << " " << s2.getData() << endl;
    return 0;
}                                        

注意,在上述单例类定义中,我们不再包含一个静态数据成员(以及支持此数据成员的外部静态变量声明)来表示Singleton实例本身。相反,我们使用静态局部(非指针)变量在静态instance()方法中指定了单例的实现。我们的构造函数是私有的;它可以在类的静态成员函数中调用以初始化这个静态局部变量。作为静态(并且不是指针分配),这个局部变量只会在创建和初始化一次。它的空间将在应用程序启动时预留,并且静态变量将在第一次调用instance()时初始化。随后的instance()调用不会产生这个Singleton的替换;除了第一次调用instance()之外,静态局部变量声明将被忽略。请注意,instance()的返回值现在是对这个静态局部Singleton实例的引用。记住,静态局部变量将存在于整个应用程序中(它不会像其他局部变量一样存储在栈上)。

此外,非常重要的一点是,请注意我们通过参数列表将数据传递给初始化单例的instance()方法;这些数据随后传递给了Singleton构造函数。能够使用适当的数据构造单例是非常重要的。通过将单例实现为一个静态局部(非指针)变量在静态instance()方法中,我们有机会在这个方法内构造单例。请注意,在类中定义的静态指针数据成员也具有这种能力,因为分配(以及因此构造,如前例所示)也是在instance()方法内进行的。然而,类的非指针静态数据成员不允许提供有意义的构造函数参数,因为实例将在程序开始时创建和初始化,而这样的初始化器将在此之前可用(实际上不在instance()方法内)。在后一种情况下,单例将只从instance()返回,而不会在其中初始化。

现在,请注意,在main()中,我们调用静态instance()方法,使用Singleton::instance()创建一个Singleton实例。我们使用从Singleton::instance()返回的单例的引用创建了一个别名s1。对这个方法的第一次调用将实例化单例,而对该方法的后续调用将仅返回现有Singleton对象的引用。我们可以通过打印单例中包含的数据来验证这两个别名(s1s2)引用的是同一个对象。

让我们看看这个简单程序的输出:

Constructor
Unique data 
Unique data
Destructor

在之前提到的输出中,我们注意到在应用程序结束之前,析构函数会自动被调用以清理 Singleton。我们还注意到,尝试创建第二个 Singleton 实例只会返回现有的 Singleton。这是因为静态局部变量 theInstance 只在应用程序中创建和初始化一次,无论 instance() 被调用多少次(静态局部变量的一个简单属性)。然而,这种实现也有潜在的缺点;让我们看看。

理解替代简单 Singleton 实现的限制

instance() 中使用非指针静态局部变量来实现 Singleton 并没有给我们提供改变 Singleton 的灵活性。在函数中,任何静态局部变量在应用程序开始时都会为其分配内存;这个内存只初始化一次(在第一次调用 instance() 时)。这意味着我们总是在应用程序中恰好有一个 Singleton。即使我们从未调用 instance() 来初始化它,这个 Singleton 的空间也存在。

此外,由于静态局部变量的实现方式,这个实现中的 Singleton 不能被替换为另一个 Singleton 对象。在某些应用程序中,我们可能一次只想有一个 Singleton 对象,但同时也希望能够将一个 Singleton 的实例替换为另一个实例。例如,想象一个组织可以有一个总统;然而,希望(Singleton)总统每隔几年可以被不同的(Singleton)总统所取代。使用指针的初始简单实现允许这种可能性,但存在潜在的缺陷,即其析构函数从未被调用。每个简单实现都有潜在的缺点。

现在,因为我们理解了简单 Singleton 实现的限制,我们将转向 Singleton 模式的首选配对类实现。配对类方法将保证我们的 Singleton 能够正确销毁,无论是应用程序允许在应用程序终止前通过故意配对类(最常见的情况)销毁 Singleton,还是在应用程序中提前销毁 Singleton 的罕见情况下。这种方法还将允许我们用一个 Singleton 的另一个实例替换 Singleton。

使用更健壮的配对类实现

为了以良好的封装方式实现带有配对类方法的单例模式,我们将定义一个单例类,仅用于添加创建单个实例的核心机制。我们将这个类命名为Singleton。然后,我们将添加一个辅助类到Singleton中,称为SingletonDestroyer,以确保在应用程序终止之前,我们的Singleton实例总是经过适当的销毁。这两个类将通过聚合和关联相关联。更具体地说,Singleton在概念上包含一个SingletonDestroyer(聚合),而SingletonDestroyer将保持对其(外部)Singleton的关联,它在概念上是嵌入的。由于SingletonSingletonDestroyer的实现是通过静态数据成员,这种聚合是概念性的——静态成员作为外部变量存储。

一旦定义了这些核心类,我们将考虑如何将单例模式融入到我们熟悉的类层次结构中。让我们设想,我们想要实现一个封装“总统”概念的类。无论是国家的总统还是大学的校长,在某个特定的时间点只有一个总统是很重要的。“总统”将是我们的目标类;“总统”是利用我们的单例模式的好候选者。

有趣的是,虽然某个特定时间点只有一个总统,但总统是可以被替换的。例如,美国总统的任期只有四年,可能还会再连任一个任期。大学校长可能也有类似的情况。总统可能通过辞职、弹劾或死亡提前离开,或者简单地在其任期结束时离开。一旦现任总统的存在被移除,那么实例化一个新的单例“总统”就是可以接受的。因此,我们的单例模式允许在某个特定时间点只有一个目标类的单例。

反思如何最好地实现“总统”类,我们意识到“总统”是“人”的一种,并且还需要“混合”单例功能。考虑到这一点,我们现在有了我们的设计。“总统”将使用多重继承来扩展“人”的概念,并混合单例的功能。

当然,我们可以从头开始构建“总统”类,但为什么这样做,当“总统”类中的“人”组件已经由一个经过良好测试和可用的类表示呢?同样,当然,我们可以将单例类信息嵌入到我们的“总统”类中,而不是从单独的单例类继承它。绝对,这也是一个选择。然而,我们的应用程序将封装解决方案的每一部分。这将使未来的重用更加容易。尽管如此,设计选择是多种多样的。

指定单例和 SingletonDestroyer

让我们看一下我们的单例模式的机制,首先检查SingletonSingletonDestroyer类定义。这些类协同工作以实现单例模式。这个例子作为一个完整的程序,可以在我们的 GitHub 上找到:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter19/Chp19-Ex2.cpp

class Singleton;    // Necessary forward class declarations
class SingletonDestroyer;
class Person;
class President;
class SingletonDestroyer   
{
private:
    Singleton *theSingleton = nullptr;
public:
    SingletonDestroyer(Singleton *s = nullptr) 
        { theSingleton = s; }
    // disallow copies and assignment
    SingletonDestroyer(const SingletonDestroyer &) 
                                    = delete; 
    SingletonDestroyer &operator=
       (const SingletonDestroyer &) = delete;
    ~SingletonDestroyer(); // dtor shown further below
    void setSingleton(Singleton *s) { theSingleton = s; }
    Singleton *getSingleton() { return theSingleton; }
};

在上述代码段中,我们首先声明了几个前向类声明,例如class Singleton;。这些声明允许在编译器看到它们的完整类定义之前对这些数据类型进行引用。

接下来,让我们看一下我们的SingletonDestroyer类定义。这个简单的类包含一个私有数据成员Singleton *theSingleton;,它将关联到SingletonSingletonDestroyer将有一天负责释放它(我们很快将检查SingletonDestroyer的析构函数定义)。注意,我们的析构函数不是虚拟的,因为这个类不是用来专门化的。

注意,我们的构造函数为Singleton *指定了默认值nullptr,这是一个输入参数。SingletonDestroyer还包含两个成员函数setSingleton()getSingleton(),它们仅仅提供了设置和获取相关联的Singleton成员的手段。

还要注意,在SingletonDestroyer中使用复制构造函数和重载的赋值运算符都已被在它们的原型中使用=delete禁止。

在我们检查这个类的析构函数之前,让我们看一下Singleton的类定义:

// Singleton will be mixed-in using inheritance with a
// Target class. If Singleton is used stand-alone, the data 
// members would be private. Also be sure to add a
// Static *Singleton instance(); 
// method to the public access region.
class Singleton
{
protected:    // protected data members
    static Singleton *theInstance;
    static SingletonDestroyer destroyer;
protected:   // and protected member functions
    Singleton() = default;
    // disallow copies and assignment
    Singleton(const Singleton &) = delete; 
    Singleton &operator=(const Singleton &) = delete; 
    friend class SingletonDestroyer;
    virtual ~Singleton() 
        { cout << "Singleton destructor" << endl; }
};

上述Singleton类包含受保护的static Singleton *theInstance;数据成员,它将代表(当分配时)指向使用单例语法的类分配的唯一实例的指针。

受保护的static SingletonDestroyer destroyer;数据成员代表一个概念上的聚合或包含成员。这种包含仅仅是概念上的,因为静态数据成员不会存储在任何实例的内存布局中;相反,它们存储在外部内存中,并通过名称混淆来显示为类的一部分。这个(概念上的)聚合子对象destroyer将负责正确地销毁Singleton。回想一下,SingletonDestroyer与唯一的Singleton有关联,代表SingletonDestroyer在概念上包含的外部对象。这种关联是SingletonDestroyer访问 Singleton 的方式。

当实现静态数据成员static SingletonDestroyer destroyer;的外部变量的内存在使用结束时消失时,SingletonDestroyer的析构函数(静态的、概念上的子对象)将被调用。这个析构函数将delete theSingleton;,确保外部的Singleton对象(它是动态分配的)将运行适当的析构函数序列。因为Singleton中的析构函数是受保护的,所以必须将SingletonDestroyer指定为Singleton的友元类。

注意,Singleton中复制构造函数和重载赋值运算符的使用都已被在它们的原型中使用=delete禁止。

在我们的实现中,我们假设Singleton将通过继承混合到派生目标类中。它将位于派生类(即打算使用 Singleton 惯用语的类)中,我们将提供所需的静态instance()方法来创建Singleton实例。请注意,如果Singleton被用作独立的类来创建单例,我们将在Singleton的公共访问区域添加static Singleton* instance()。我们还将把数据成员从受保护的访问区域移动到私有访问区域。然而,具有应用程序无关的单例仅用于演示概念。相反,我们将 Singleton 惯用语应用于实际需要使用此惯用语的类型。

在我们的SingletonSingletonDestroyer类定义就绪后,接下来让我们检查这些类的剩余实现必要性:

// External (name mangled) vars to hold static data mbrs
Singleton *Singleton::theInstance = nullptr;
SingletonDestroyer Singleton::destroyer;
// SingletonDestroyer destructor definition must appear 
// after class definition for Singleton because it is 
// deleting a Singleton (so its destructor can be seen)
// This is not an issue when using header and source files.
SingletonDestroyer::~SingletonDestroyer()
{   
    if (theSingleton == nullptr)
        cout << "SingletonDestroyer destructor: Singleton 
                 has already been destructed" << endl;
    else
    {
        cout << "SingletonDestroyer destructor" << endl;
        delete theSingleton;   
    }                          
}

在上述代码片段中,我们首先注意到两个外部变量定义,它们为Singleton类中的两个静态数据成员提供内存支持——即Singleton *Singleton::theInstance = nullptr;SingletonDestroyer Singleton::destroyer;。回想一下,静态数据成员不存储在其指定的类实例中。相反,它们存储在外部变量中;这两个定义指定了内存。请注意,数据成员都被标记为protected。这意味着尽管我们可以以这种方式直接定义它们的存储,但我们不能通过Singleton的静态成员函数之外的方式访问这些数据成员。这将给我们带来一些安慰。尽管存在对静态数据成员的潜在全局访问点,但它们施加的受保护访问区域要求使用Singleton类的适当静态方法来正确操作这些重要的成员。

接下来,请关注SingletonDestroyer的析构函数。这个巧妙的析构函数首先检查其与负责的Singleton的关联是否等于nullptr。这种情况很少见,并且发生在非常罕见的情况下,即客户端直接使用显式的delete释放 Singleton 对象。

SingletonDestroyer析构函数中的通常销毁场景将是执行else子句,其中SingletonDestructor作为一个静态对象将负责其配对的Singleton的删除和销毁。记住,在Singleton中会有一个包含的SingletonDestroyer对象。这个静态(概念上)子对象的内存不会消失,直到应用程序完成。回想一下,静态内存实际上不是任何实例的一部分。然而,静态子对象将在main()完成之前被销毁。因此,当SingletonDestroyer被销毁时,其通常情况将是delete theSingleton;,这将释放其配对的 Singleton 的内存,允许Singleton被正确销毁。

单例模式背后的驱动设计决策是,单例是一个长期存在的对象,其销毁最常正确地发生在应用程序的末尾。单例负责其自身的内部目标对象创建,因此单例不应该被客户端删除(从而销毁)。相反,首选的机制是当SingletonDestroyer作为一个静态对象被移除时,删除其配对的Singleton

尽管如此,偶尔在应用程序过程中删除Singleton也有合理的场景。如果永远不会创建替换的Singleton,我们的SingletonDestroyer析构函数仍然可以正确工作,识别出其配对的Singleton已经被释放。然而,更有可能的是,我们的Singleton将在应用程序的某个地方被另一个Singleton实例替换。回想一下我们的应用程序示例,总统可能会被弹劾、辞职或去世,但将被另一位总统取代。在这些情况下,直接删除Singleton并创建一个新的Singleton是可以接受的。在这种情况下,SingletonDestroyer现在将引用替换的Singleton

从 Singleton 派生目标类

接下来,让我们看看我们如何从Singleton创建我们的目标类,President

// Assume our Person class is as we are accustomed
// A President Is-A Person and also mixes-in Singleton 
class President: public Person, public Singleton
{
private:
    President(const string &, const string &, char, 
              const string &);
public:
    ~President() override;   // virtual destructor
    // disallow copies and assignment
    President(const President &) = delete;  
    President &operator=(const President &) = delete; 
    static President *instance(const string &, 
                    const string &, char, const string &);
};
President::President(const string &fn, const string &ln, 
    char mi, const string &t): Person(fn, ln, mi, t),
                               Singleton()
{
}
President::~President()
{
    destroyer.setSingleton(nullptr);  
    cout << "President destructor" << endl;
}
President *President::instance(const string &fn, 
           const string &ln, char mi, const string &t)
{
    if (theInstance == nullptr)
    {
        theInstance = new President(fn, ln, mi, t);
        destroyer.setSingleton(theInstance);
        cout << "Creating the Singleton" << endl;
    }
    else
        cout << "Singleton previously created. 
                 Returning existing singleton" << endl;
    // below cast is necessary since theInstance is 
    // a Singleton *
    return dynamic_cast<President *>(theInstance);  
}                              

在我们之前提到的目标类President中,我们只是使用公有继承从Person继承President,然后多重继承PresidentSingleton混合Singleton机制。

我们将构造函数放在私有访问区域。静态方法 instance() 将在内部使用此构造函数创建一个且仅有一个 Singleton 实例,以符合模式。没有默认构造函数(不寻常),因为我们不希望允许创建没有相关详细信息的 President 实例。回想一下,如果我们提供了替代的构造函数接口,C++ 不会链接默认构造函数。由于我们不希望复制 President 或将 President 赋值给另一个潜在的 President,我们在这些方法的原型中使用 =delete 规范禁止复制和赋值。

我们为 President 定义的析构函数简单而关键。如果我们的 Singleton 对象将被显式删除,我们通过设置 destroyer.setSingleton(nullptr); 来做准备。回想一下,President 继承了受保护的 static SingletonDestroyer destroyer; 数据成员。在这里,我们将破坏者关联的 Singleton 设置为 nullptr。然后,在 President 的析构函数中的这一行代码使得 SingletonDestroyer 中的析构函数能够准确地依赖于检查其关联的 Singleton 是否已经被删除,然后再开始通常的 Singleton 对应析构。

最后,我们定义了一个静态方法来为我们的 President 作为 Singleton 提供创建接口,即 static President *instance(const string &, const string &, char, const string &);。在 instance() 的定义中,我们首先检查继承的受保护数据成员 Singleton *theInstance 是否等于 nullptr。如果我们还没有分配 Singleton,我们将使用上述私有构造函数分配 President 并将这个新分配的 President 实例赋值给 theInstance。这是一个从 President *Singleton * 的向上转换,这在公共继承边界上没有问题。然而,如果在 instance() 方法中我们发现 theInstance 不等于 nullptr,我们只需返回之前分配的 Singleton 对象的指针。由于用户无疑会想将此对象用作 President 以享受继承的 Person 功能,我们将 theInstance 向下转换为 President * 以从该方法返回。

最后,让我们考虑一下我们整体应用程序中一个示例客户端的物流。在其最简单的形式中,我们的客户端将包含一个 main() 函数来驱动应用程序并展示我们的单例模式。

在客户端中将模式组件组合在一起

现在我们来看看我们的 main() 函数,看看我们的模式是如何编排的:

int main()
{ 
    // Create a Singleton President
    President *p1 = President::instance("John", "Adams", 
                                        'Q', "President");
    // This second request will fail, returning 
    // the original instance
    President *p2 = President::instance("William",
                            "Harrison", 'H', "President");
    if (p1 == p2)   // Verification there's only one object
        cout << "Same instance (only 1 Singleton)" << endl;
    p1->Print();
    // SingletonDestroyer will release Singleton at end
    return 0;
}

回顾前面代码中的 main() 函数,我们首先使用 President *p1 = President::instance("John", "Adams", 'Q', "President"); 分配了一个 Singleton 总统。然后我们在下一行代码中尝试使用 *p2 分配另一个 总统。因为我们只能有一个 Singleton(一个 总统 混入 一个 Singleton),所以返回了一个指向现有 总统 的指针,并将其存储在 p2 中。我们通过比较 p1 == p2 来验证只有一个 Singleton;指针确实指向了同一个实例。

接下来,我们利用 总统 实例的预期方式使用它,例如,通过使用从 Person 继承的一些成员函数。例如,我们调用 p1->Print();。当然,我们的 总统 类可以添加一些专门的功能,这些功能也适合在 Client 中使用。

现在,在 main() 函数的末尾,我们的静态对象 SingletonDestroyer Singleton::destroyer; 将在内存回收之前适当地被析构。正如我们所见,SingletonDestroyer 的析构函数将(大多数情况下)使用 delete theSingleton; 对其关联的 Singleton(实际上是一个 总统)执行 delete 操作。这将触发 总统SingletonPerson 析构函数的调用和执行(从最专门的子对象到最一般的子对象)。由于 Singleton 中的析构函数是虚拟的,我们保证从正确的级别开始销毁,并包括所有析构函数。

让我们看看这个程序的输出:

Creating the Singleton
Singleton previously created. Returning existing singleton
Same instance (only 1 Singleton)
President John Q Adams
SingletonDestroyer destructor
President destructor
Singleton destructor
Person destructor

在前面的输出中,我们可以可视化 Singleton 总统 的创建过程,以及看到对 总统 的第二次 instance() 请求仅仅返回现有的 总统。然后我们看到打印出的 总统 的详细信息。

最有趣的是,我们可以看到 Singleton 的销毁序列,这是由 SingletonDestroyer 的静态对象回收驱动的。通过在 SingletonDestroyer 的析构函数中适当地删除 Singleton,我们看到 总统SingletonPerson 析构函数各自被调用,因为它们有助于完整的 总统 对象。

检查显式 Singleton 销毁及其对 SingletonDestroyer 析构函数的影响

让我们看看 Client 的一个替代版本,它有一个不同的 main() 函数。在这里,我们强制删除我们的 Singleton;这种情况很少见。在这种情况下,我们的 SingletonDestroyer 不会删除其配对的 Singleton。这个例子作为一个完整的程序,可以在我们的 GitHub 仓库中找到:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter19/Chp19-Ex3.cpp

int main()
{
    President *p1 = President::instance("John", "Adams", 
                                        'Q', "President");
    President *p2 = President::instance("William",
                             "Harrison", 'H', "President");
    if (p1 == p2)  // Verification there's only one object
        cout << "Same instance (only 1 Singleton)" << endl;
    p1->Print();
    delete p1;  // Delete the Singleton – unusual.
    return 0;   // Upon checking, the SingletonDestroyer 
}   // will no longer need to destroy its paired Singleton

在上述main()函数中,请注意我们明确地使用delete p1;释放了我们的 Singleton President,而不是允许实例在程序结束时通过静态对象删除来回收。幸运的是,我们在SingletonDestroyer析构函数中包含了一个测试,以让我们知道是否必须删除关联的Singleton,或者这种删除是否已经发生。

让我们看看修订后的输出,以注意与我们的原始main()之间的差异:

Creating the Singleton
Singleton previously created. Returning existing singleton
Same instance (only 1 Singleton)
President John Q Adams
President destructor
Singleton destructor
Person destructor
SingletonDestroyer destructor: Singleton has already been destructed

在我们修订后的Client的上述输出中,我们再次可以可视化Singleton President的创建,第二个President创建请求的失败,等等。

让我们注意到销毁序列以及它与我们的第一个Client的不同之处。在这里,Singleton President被明确地释放。我们可以通过调用和执行PresidentSingletonPerson中的析构函数来看到President的正确删除,因为每个都执行了。现在,当应用程序即将结束时,静态的SingletonDestroyer即将回收其内存,我们可以可视化对SingletonDestroyer调用的析构函数。然而,这个析构函数将不再删除其关联的Singleton

理解设计优势和劣势

单例模式的前一个(配对类)实现的优势(无论使用哪个main())在于我们保证了Singleton的正确销毁。这发生在无论Singleton是长期存在的并且通常由其关联的SingletonDestroyer删除,还是它较早地在应用程序中直接删除(一个罕见的情况)。

这种实现的缺点源于Singleton的概念。也就是说,只能有一个Singleton的派生类包含Singleton类的特定机制。因为我们从Singleton继承了President,所以我们正在为President和仅President使用 Singleton 物流(即静态数据成员,存储在外部变量中)。如果另一个类希望从Singleton派生以采用这种习语,Singleton的内部实现已经被President使用。哎呀!这看起来似乎不太公平。

不要担心!我们的设计可以很容易地扩展以适应希望使用我们的Singleton基类的多个类。我们将增强我们的设计以容纳多个Singleton对象。然而,我们假设我们仍然希望每个类类型只有一个Singleton实例。

另一个潜在的担忧是线程安全性。例如,如果将使用多线程编程,我们需要确保我们的static President::instance()方法表现得像是原子的,也就是说,不可中断的。我们可以通过仔细同步对静态方法本身的访问来实现这一点。

现在让我们简要地看看我们如何扩展 Singleton 模式来解决此问题。

使用注册表允许许多类使用 Singleton

让我们更详细地检查我们当前 Singleton 模式实现的一个缺点。目前,只有一个派生自 Singleton 的类可以有效地利用 Singleton 类。为什么是这样?Singleton 是一个带有外部变量定义的类,以支持类内的静态数据成员。代表 theInstance 的静态数据成员(使用外部变量 Singleton *Singleton::theInstance 实现)只能设置为单个 Singleton 实例。不是每个类一个 – 只有一组外部变量为关键的 Singleton 数据成员 theInstancedestroyer 创建内存。问题就出在这里。

我们可以指定一个 Registry 类来跟踪应用 Singleton 模式的类。有许多 Registry 的实现,我们将审查其中一种实现。

在我们的实现中,Registry 将是一个将类名(用于采用 Singleton 模式的类)与每个注册类单个允许实例的 Singleton 指针配对的类。我们仍然会将每个目标类从 Singleton 派生出来(以及从任何其他我们认为合适的设计中派生出来)。

我们将对每个从 Singleton 派生出来的类的 instance() 方法进行修订,如下所示:

  • instance() 中的第一次检查将是调用一个 Registry 方法(带有派生类的名称),询问是否为该类之前创建了一个 Singleton。如果 Registry 方法确定请求的派生类型的 Singleton 之前已经被实例化,instance() 将返回现有实例的指针。

  • 相反,如果 Registry 授予了分配 Singleton 的权限,instance() 将像以前一样分配 Singleton,将继承的受保护的 theInstance 数据成员设置为分配的派生 Singleton。静态 instance() 方法还将通过继承的受保护的销毁器数据成员使用 setSingleton() 设置回链。然后我们将新实例化的派生类实例(它是一个 Singleton)传递给 Registry 方法以 Store()Registry 中存储新分配的 Singleton

我们注意到将存在四个指向相同 Singleton 的指针。一个将是我们的派生类类型的专用指针,它从我们的派生类 instance() 方法返回。这个指针将被交给我们的客户端用于应用。第二个 Singleton 指针将是存储在我们继承的受保护数据成员 theInstance 中的指针。第三个 Singleton 指针将是存储在 SingletonDestroyer 中的指针。指向 Singleton 的第四个指针将是一个存储在 Registry 中的指针。没问题,我们可以有多个指向 Singleton 的指针。这是 SingletonDestroyer 在其传统销毁能力中如此重要的原因之一——它将在应用程序结束时销毁每个类型的唯一 Singleton

我们的 Registry 将为每个使用 Singleton 模式的类维护一对,包括一个类名和对应特定 Singleton 的(最终)指针。每个特定 Singleton 实例的指针将是一个静态数据成员,并且还需要一个外部变量来获取其底层内存。结果是每个采用 Singleton 模式的类将额外有一个外部变量。

如果我们选择进一步扩展 Registry 的概念,以允许在罕见的情况下使用 Singleton 模式,允许每个类类型有多个(但数量有限的)Singleton 对象,那么这个概念还可以进一步扩展。这种受控的多个单例的罕见存在被称为 Principal,将是 Singleton 的预期派生类,而多个副校长将代表 Vice-Principal 类(从 Singleton 派生)的固定数量的实例。我们的注册表可以扩展到允许 Vice-Principal 类型最多注册 NSingleton 对象(多例)。

我们现在已经看到了使用配对类方法实现的 Singleton 模式。我们将 SingletonSingletonDestroyer、目标类和客户端的概念融合到我们习惯看到的类框架中,即 Person,以及我们的 SingletonPerson 的派生类(President)。现在,让我们简要回顾一下与模式相关的内容,然后继续下一章。

摘要

在本章中,我们通过采用另一个设计模式来扩展我们的编程技能,进一步实现了成为更好的 C++ 程序员的目标。我们首先采用了两种简单的方法来探索 Singleton 模式,然后使用 SingletonSingletonDestroyer 的配对类实现。我们的方法使用继承将 Singleton 的实现纳入我们的目标类。可选地,我们通过多重继承将一个有用的现有基类纳入我们的目标类。

利用核心设计模式,如 Singleton 模式,将帮助您更容易地以其他程序员能理解的方式重用现有、经过良好测试的代码部分。通过使用熟悉的设计模式,您将为具有前卫编程技术的易于理解和可重用的解决方案做出贡献。

我们现在准备继续前进,进入我们的最后一个设计模式第二十章使用 pImpl 模式去除实现细节。将更多模式添加到我们的编程技能库中,使我们成为更加多才多艺且受重视的程序员。让我们继续前进!

问题

  1. 使用本章中找到的 Singleton 模式示例,创建一个程序来完成以下任务:

    1. 实现一个用于PresidentResign()接口或实现Impeach()接口。你的方法应该删除当前的Singleton President(并从SingletonDestroyer中移除那个链接)。SingletonDestroyer有一个setSingleton()方法,这可能有助于移除回链。

    2. 注意到之前的Singleton President已经被移除,使用President::instance()创建一个新的President。验证新的President已经被安装。

    3. (可选)创建一个Registry以允许Singleton在多个类中有效使用(不是相互排他地,如当前实现那样)。

  2. 为什么不能将Singleton中的static instance()方法标记为虚拟并在President中重写它?

  3. 你还能想象出哪些其他示例可以轻松地结合 Singleton 模式?

第二十章:使用 pImpl 模式移除实现细节

本章将结束我们扩展你的 C++ 编程知识库的旅程,目标是进一步赋予你解决常见编码问题的能力,利用常见的设计模式。在你的编码中融入设计模式不仅可以提供更精细的解决方案,还有助于简化代码维护并提供潜在的代码重用。

我们接下来将学习如何在 C++ 中有效地实现下一个设计模式——pImpl 模式

在本章中,我们将涵盖以下主要主题:

  • 理解 pImpl 模式及其如何减少编译时依赖

  • 理解如何在 C++ 中使用关联和唯一指针实现 pImpl 模式

  • 识别与 pImpl 相关的性能问题以及必要的权衡

到本章结束时,你将理解 pImpl 模式以及如何将其用于将实现细节从类接口中分离出来,以减少编译器依赖。将额外的设计模式添加到你的技能集中将帮助你成为一个更有价值的程序员。

让我们通过检查另一个常见的设计模式——pImpl 模式,来提高我们的编程技能。

技术要求

完整程序示例的在线代码可以在以下 GitHub 网址找到:github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/tree/main/Chapter20。每个完整程序示例都可以在 GitHub 仓库中找到,位于相应章节标题(子目录)下的文件中,文件名对应章节编号,后面跟着一个连字符,然后是当前章节中的示例编号。例如,本章的第一个完整程序可以在上述 GitHub 目录下的 Chapter20 子目录中找到,文件名为 Chp20-Ex1.cpp。一些程序位于示例中指示的可应用子目录中。

本章的 CiA 视频可以在以下网址查看:bit.ly/3CfQxhR

理解 pImpl 模式

pImpl 模式pointer to Implementation 习语)是一种结构化设计模式,它将类的实现与其公共接口分离。这个模式最初被称为 Bridge 模式,由 Gang of FourGoF)提出,也被称为 Cheshire Catcompiler-firewall 习语d-pointeropaque pointerHandle 模式

此模式的主要目的是最小化编译时依赖。减少编译时依赖的结果是,类定义中的更改(最明显的是私有访问区域)不会在开发或部署的应用程序中引发一系列及时的重新编译。相反,必要的重新编译代码可以隔离到类的实现本身。依赖于类定义的应用程序的其他部分将不再受重新编译的影响。

类定义内部的私有成员可能会影响类的重新编译。这是因为更改数据成员可能会改变该类型实例的大小。此外,私有成员函数必须与函数调用签名匹配,以便进行重载解析以及潜在的类型转换。

传统头文件(.h.hpp)和源代码文件(.cpp)中指定依赖关系的方式会触发重新编译。通过将类内部实现细节从类头文件中移除(并将这些细节放在源文件中),我们可以消除许多依赖。我们可以更改其他头文件和源代码文件中包含的头文件,简化依赖关系,从而减轻重新编译的负担。

pImpl 模式将强制对类定义进行以下调整:

  • 私有(非虚拟)成员将被替换为指向包含以前私有数据成员和方法的嵌套类类型的指针。还需要对嵌套类进行前向声明。

  • 实现的指针(pImpl)将是一个关联,类实现的函数调用将被委派到这个关联上。

  • 修订后的类定义将存在于采用此习语的类的头文件中。任何以前由该头文件依赖的已包含的头文件现在将移动到源代码文件中,而不是包含在头文件中。

  • 如果修改了类在其私有访问区域内的实现,现在包括 pImpl 类的头文件在内的其他类将不会面临重新编译。

  • 为了有效地管理表示实现的关联对象的动态内存资源,我们将使用唯一指针(智能指针)。

修订后的类定义中的编译自由利用了这样一个事实:指针只需要对指针指向的类类型进行前向声明即可编译。

让我们继续前进,首先考察一个基本的,然后是一个改进的 pImpl 模式的实现。

实现 pImpl 模式

为了实现 pImpl 模式,我们需要重新审视典型的头文件和源文件组成。然后,我们将典型类定义中的私有成员替换为指向实现的指针,利用关联的优势。实现将被封装在我们目标类的嵌套类中。我们的 pImpl 指针将委托所有请求到提供内部类细节或实现的关联对象。

内部(嵌套)类将被称为实现类。原始的,现在外部的,类将被称为目标接口类

我们将首先回顾包含类定义和成员函数定义的典型(非 pImpl 模式)文件组成。

组织文件和类内容以应用模式基础

让我们首先回顾典型 C++类在文件放置方面的组织策略,包括类定义和成员函数定义。接下来,我们将考虑使用 pImpl 模式的类的修改后的组织策略。

回顾典型文件和类布局

让我们看看一个典型的类定义以及我们之前是如何根据源文件和头文件组织类的,例如在第五章“详细探索类”和第十五章“测试类和组件”中的讨论。

回想一下,我们将每个类组织到一个包含类定义和内联函数定义的头文件(.h.hpp)中,以及一个包含非内联成员函数定义的相应源代码文件(.cpp)。让我们回顾一个熟悉的样本类定义,Person

#ifndef _PERSON_H  // preprocessor directives to avoid 
#define _PERSON_H  // multiple inclusion of header
using std::string;
class Person
{
private:
    string firstName, lastName, title;
    char middleInitial = '\0';   // in-class initialization
protected:
    void ModifyTitle(const string &);
public:
    Person() = default;   // default constructor
    Person(const string &, const string &, char, 
           const string &);  // alternate constructor
    // prototype not needed for default copy constructor
    // Person(const Person &) = default;  // copy ctor
    virtual ~Person() = default;  // virtual destructor
    const string &GetFirstName() const 
        { return firstName; }
    const string &GetLastName() const { return lastName; }
    const string &GetTitle() const { return title; }
    char GetMiddleInitial() const { return middleInitial; }
    virtual void Print() const;
    virtual void IsA() const;
    virtual void Greeting(const string &) const;
    Person &operator=(const Person &);  // overloaded op =
};
#endif

在上述头文件(Person.h)中,我们包含了Person类的类定义以及类的内联函数定义。任何未出现在类定义中(在原型中用关键字inline指示)的较大内联函数定义也将出现在此文件中,在类定义之后。注意预处理指令的使用,以确保每个编译单元只包含一次类定义。

让我们接下来回顾相应的源代码文件的内容,Person.cpp

#include <iostream>  // also incl. other relevant libraries
#include "Person.h"  // include the header file
using std::cout;     // preferred to: using namespace std;
using std::endl; 
using std::string;
// Include all the non-inline Person member functions
// The alt. constructor is one example of many in the file
Person::Person(const string &fn, const string &ln, char mi,
             const string &t): firstName(fn), lastName(ln),
                               middleInitial(mi), title(t)
{
   // dynamically alloc. memory for any ptr data mbrs here
}

在之前定义的源代码文件中,我们为类Person定义了所有非内联成员函数。尽管不是所有方法都显示出来,但所有方法都可以在我们的 GitHub 代码中找到。此外,如果类定义包含任何静态数据成员,这些成员的内存指定的外部变量定义也应包含在源代码文件中。

现在让我们考虑如何通过应用 pImpl 模式,从Person类定义及其相应的头文件中移除实现细节。

应用 pImpl 模式并修改类和文件布局

要使用 pImpl 模式,我们将重新组织我们的类定义及其相应的实现。我们将在现有的类定义内添加一个嵌套类,以表示原始类的私有成员和其实现的核心。我们的外部类将包含一个指向内部类类型的指针,作为对我们实现的关联。我们的外部类将委派所有实现请求到关联的内部对象。我们将重新结构化头文件和源代码文件中类的放置。

让我们更仔细地看看我们对类的修订版实现,以了解实现 pImpl 模式所需的所有新细节。这个例子由一个源文件 PersonImpl.cpp 和一个头文件 Person.h 组成,可以在我们的 GitHub 仓库中的同一目录下找到,作为测试该模式的简单驱动程序。要制作一个完整的可执行文件,您需要编译并链接同一目录下的 PersonImp.cppChp20-Ex1.cpp(驱动程序)。以下是驱动程序的 GitHub 仓库 URL:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter20/Chp20-Ex1.cpp

#ifndef _PERSON_H    // Person.h header file definition
#define _PERSON_H
class Person
{
private:
    class PersonImpl;  // forward declaration nested class
    PersonImpl *pImpl = nullptr; // ptr to implementation 
                                 // of class
protected:
    void ModifyTitle(const string &);
public:
    Person();   // default constructor
    Person(const string &, const string &, char, 
           const string &);
    Person(const Person &);  // copy const. will be defined
    virtual ~Person();  // virtual destructor
    const string &GetFirstName() const; // no longer inline
    const string &GetLastName() const; 
    const string &GetTitle() const; 
    char GetMiddleInitial() const; 
    virtual void Print() const;
    virtual void IsA() const;
    virtual void Greeting(const string &) const;
    Person &operator=(const Person &);  // overloaded =
};
#endif

在我们之前提到的针对 Person 的修订版类定义中,请注意我们已经移除了私有访问区域中的数据成员。任何非虚私有方法,如果存在的话,也会被移除。相反,我们通过 class PersonImpl; 对嵌套类进行了前置声明。我们还声明了一个指向实现的指针 PersonImpl *pImpl;,它代表了对封装实现的嵌套类成员的关联。在我们的初始实现中,我们将使用原生(原始)C++指针来指定对嵌套类的关联。我们将随后修订我们的实现以利用 唯一指针

注意,我们的 Person 的公共接口与之前几乎相同。我们现有的所有公共和受保护方法在接口上如预期存在。然而,我们注意到,内联函数(依赖于数据成员的实现)已被非内联成员函数原型所取代。

让我们继续前进,看看我们嵌套类 PersonImpl 的类定义,以及 PersonImplPerson 的成员函数在公共源代码文件 PersonImpl.cpp 中的放置。我们将从嵌套的 PersonImpl 类定义开始:

// PersonImpl.cpp source code file includes nested class
// Nested class definition supports implementation
class Person::PersonImpl
{
private:
    string firstName, lastName, title;
    char middleInitial = '\0';  // in-class initialization
public:
    PersonImpl() = default;   // default constructor
    PersonImpl(const string &, const string &, char, 
               const string &);
    // Default copy ctor does not need to be prototyped
    // PersonImpl(const PersonImpl &) = default;  
    virtual ~PersonImpl() = default;  // virtual destructor
    const string &GetFirstName() const 
        { return firstName; }
    const string &GetLastName() const { return lastName; }
    const string &GetTitle() const { return title; }
    char GetMiddleInitial() const { return middleInitial; }
    void ModifyTitle(const string &);
    virtual void Print() const;
    virtual void IsA() const { cout << "Person" << endl; }
    virtual void Greeting(const string &msg) const
        { cout << msg << endl; }
    PersonImpl &operator=(const PersonImpl &); 
};

在之前提到的PersonImpl嵌套类定义中,请注意,这个类看起来与原始的Person类定义惊人地相似。我们有私有数据成员和一系列完整的成员函数原型,甚至为了简洁而编写的某些内联函数(实际上它们不会内联,因为它们是虚拟的)。PersonImpl代表Person的实现,因此这个类能够访问所有数据并完全实现每个方法至关重要。请注意,在class Person::PersonImpl的定义中使用的作用域解析运算符(::)用于指定PersonImplPerson的嵌套类。

让我们继续,通过查看PersonImpl的成员函数定义来继续,这些定义将出现在与类定义相同的源文件PersonImpl.cpp中。尽管一些方法已经缩写,但它们的完整在线代码可以在我们的 GitHub 仓库中找到:

// File: PersonImpl.cpp - See online code for full methods 
// Nested class member functions. 
// Notice that the class name is Outer::Inner class
// Notice that we are using the system-supplied definitions
// for default constructor, copy constructor and destructor
// alternate constructor
Person::PersonImpl::PersonImpl(const string &fn, 
             const string &ln, char mi, const string &t): 
             firstName(fn), lastName(ln), 
             middleInitial(mi), title(t)   
{
}
void Person::PersonImpl::ModifyTitle(const string &newTitle)
{   
    title = newTitle;
}
void Person::PersonImpl::Print() const
{   // Print each data member as usual
}
// Note: same as default op=, but it is good to review what 
// is involved in implementing op= for upcoming discussion
Person::PersonImpl &Person::PersonImpl::operator=
                             (const PersonImpl &p)
{  
    if (this != &p)  // check for self-assignment
    {
        firstName = p.firstName;
        lastName = p.lastName;
        middleInitial = p.middleInitial;
        title = p.title;
   }
   return *this;  // allow for cascaded assignments
}

在上述代码中,我们看到使用嵌套类PersonImpl实现的整体Person类的实现。我们看到PersonImpl的成员函数定义,并注意到这些方法的主体与我们之前在原始Person类中(没有使用 pImpl 模式)实现的方法完全相同。再次,我们注意到使用作用域解析运算符(::)来指定每个成员函数定义的类名,例如void Person::PersonImpl::Print() const。在这里,Person::PersonImpl表示Person类内部的嵌套类PersonImpl

接下来,让我们花一点时间回顾Person类的成员函数定义,我们在这个类中使用了 pImpl 模式。这些方法还将贡献到PersonImpl.cpp源代码文件中,可以在我们的 GitHub 仓库中找到:

// Person member functions – also in PersonImpl.cpp
Person::Person(): pImpl(new PersonImpl())
{ // As shown, this is the complete member fn. definition
}
Person::Person(const string &fn, const string &ln, char mi,
               const string &t): 
               pImpl(new PersonImpl(fn, ln, mi, t))
{ // As shown, this is the complete member fn. definition
}  
Person::Person(const Person &p): 
               pImpl(new PersonImpl(*(p.pImpl)))
{  // This is the complete copy constructor definition
}  // No Person data members to copy from 'p' except deep
   // copy of *(p.pImpl) to data member pImpl
Person::~Person()
{
    delete pImpl;   // delete associated implementation
}
void Person::ModifyTitle(const string &newTitle)
{   // delegate request to the implementation 
    pImpl->ModifyTitle(newTitle);  
}
const string &Person::GetFirstName() const
{   // no longer inline in Person; 
    // non-inline method further hides implementation
    return pImpl->GetFirstName();
}
// Note: methods GetLastName(), GetTitle(), and  
// GetMiddleInitial() are implemented similar to
// GetFirstName(). See online code
void Person::Print() const
{
    pImpl->Print();   // delegate to implementation
}                     // (same named member function)
// Note: methods IsA() and Greeting() are implemented 
// similarly to Print() – using delegation. See online code
Person &Person::operator=(const Person &p)
{  // delegate op= to implementation portion
   pImpl->operator=(*(p.pImpl)); // call op= on impl. piece
   return *this;  // allow for cascaded assignments
}

在之前提到的Person成员函数定义中,我们注意到所有方法都通过关联的pImpl将所需的工作委托给嵌套类。在我们的构造函数中,我们分配关联的pImpl对象并适当地初始化它(使用每个构造函数的成员初始化列表)。我们的析构函数负责使用delete pImpl;删除关联的对象。

我们的Person拷贝构造函数将成员pImpl设置为新的分配的内存,同时调用嵌套对象的创建和初始化的PersonImpl拷贝构造函数,将*(p.pImpl)传递给嵌套对象的拷贝构造函数。也就是说,p.pImpl是一个指针,所以我们使用*解引用指针以获得对PersonImpl拷贝构造函数的可引用对象。

我们在Person的重载赋值运算符中也使用了类似的策略。也就是说,除了pImpl之外没有其他数据成员来执行深度赋值,所以我们只是调用关联对象pImpl上的PersonImpl赋值运算符,再次传入*(p.pImpl)作为右侧值。

最后,让我们考虑一个示例驱动程序来展示我们的模式在实际应用中的效果。有趣的是,我们的驱动程序可以与最初指定的非模式类(源文件和头文件)一起工作,也可以与经过修订的 pImpl 模式特定源文件和头文件一起工作!

将模式组件组合在一起

让我们最后看看我们的驱动程序源文件Chp20-Ex1.cpp中的main()函数,看看我们的模式是如何编排的:

#include <iostream>
#include "Person.h"
using std::cout;  // preferred to: using namespace std;
using std::endl;
constexpr int MAX = 3;
int main()
{
    Person *people[MAX] = { }; // initialized to nullptrs
    people[0] = new Person("Elle", "LeBrun", 'R',"Ms.");
    people[1] = new Person("Zack", "Moon", 'R', "Dr.");
    people[2] = new Person("Gabby", "Doone", 'A', "Dr.");
    for (auto *individual : people)
       individual->Print();
    for (auto *individual : people)
       delete individual;
    return 0;
}

回顾我们之前提到的main()函数,我们只是动态分配了几个Person实例,在实例上调用选定的Person方法(Print()),然后删除每个实例。我们如预期那样包含了Person.h头文件,以便能够使用这个类。从客户端的角度来看,一切看起来都很正常,看起来没有使用模式。

注意,我们分别编译PersonImp.cppChp20-Ex1.cpp,将目标文件链接在一起生成可执行文件。然而,由于使用了 pImpl 模式,如果我们更改Person的实现,这种更改将被封装在其PersonImp嵌套类中的实现中。只有PersonImp.cpp需要重新编译。客户端不需要重新编译驱动程序Chp20-Ex1.cpp,因为更改不会发生在Person.h头文件中(驱动程序依赖于该头文件)。

让我们看看这个程序的输出:

Ms. Elle R. LeBrun
Dr. Zack R. Moon
Dr. Gabby A. Doone

在上述输出中,我们看到了我们简单驱动程序的预期结果。

让我们继续前进,考虑如何使用唯一指针改进我们的 pImpl 模式实现。

使用唯一指针改进模式

我们最初使用与原生 C++指针关联的实现减少了编译器的依赖。这是因为编译器只需要看到 pImpl 指针类型的类前向声明就能成功编译。到目前为止,我们已经实现了使用 pImpl 模式的核心目标——减少重新编译。

然而,使用原生或原始指针总是存在批评。我们负责自己管理内存,包括记住在外部类析构函数中删除分配的嵌套类类型。使用原始指针自行管理内存资源可能会导致内存泄漏、内存误用和内存错误等潜在缺点。因此,通常使用智能指针来实现 pImpl 模式。

我们将继续我们的任务,通过检查与 pImpl 模式经常一起使用的关键组件——智能指针,或者更具体地说,是unique_ptr——来实现 pImpl。

让我们首先理解智能指针的基本知识。

理解智能指针

要实现 pImpl 模式,我们首先必须理解智能指针。智能指针是一个小的包装类,它封装了一个原始指针,确保当包装对象超出作用域时,它所包含的指针会自动删除。实现智能指针的类可以使用模板来实现,为任何数据类型创建智能指针。

这里是一个非常简单的智能指针示例。这个示例可以在我们的 GitHub 上找到:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter20/Chp20-Ex2.cpp

#include <iostream>
#include "Person.h"
using std::cout;   // preferred to: using namespace std;
using std::endl;
template <class Type>
class SmartPointer
{
private:
    Type *pointer = nullptr;  // in-class initialization
public:
    // Below ctor also handles default construction 
    SmartPointer(Type *ptr = nullptr): pointer(ptr) { }
    virtual ~SmartPointer();  // allow specialized SmrtPtrs
    Type *operator->() { return pointer; }
    Type &operator*() { return *pointer; }
};
SmartPointer::~SmartPointer()
{
    delete pointer;
    cout << "SmartPtr Destructor" << endl;
}
int main()
{
    SmartPointer<int> p1(new int());
    SmartPointer<Person> pers1(new Person("Renee",
                               "Alexander", 'K', "Dr."));
    *p1 = 100;
    cout << *p1 << endl;
    (*pers1).Print();   // or use: pers1->Print();
    return 0;
}

在之前定义的简单SmartPointer类中,我们只是封装了一个原始指针。关键好处是SmartPointer的析构函数将确保在包装对象从栈中弹出(对于局部实例)或程序终止之前(对于静态和外部实例)时,原始指针被销毁。当然,这个类是基础的,我们必须确定所需的复制构造函数和赋值运算符的行为。也就是说,允许浅复制/赋值,要求深复制/赋值,或者禁止所有复制/赋值。尽管如此,我们现在可以可视化智能指针的概念。

这里是我们智能指针示例的输出:

100
Dr. Renee K. Alexander
SmartPtr Destructor
SmartPtr Destructor

如上述输出所示,SmartPointer中包含的每个对象的内存都是由我们管理的。我们可以很容易地通过“SmartPtr Destructor”输出字符串看到,当main()中的局部对象超出作用域并被从栈中弹出时,会代表我们调用每个对象的析构函数。

理解唯一指针

标准 C++库中的unique_ptr是一种封装了给定堆内存资源独占所有权和访问权的智能指针类型。unique_ptr不能被复制;unique_ptr的所有者将独占使用该指针。unique_ptr的所有者可以选择将这些指针移动到其他资源,但后果是原始资源将不再包含unique_ptr。我们必须#include <memory>来包含unique_ptr的定义。

其他类型的智能指针

标准 C++库中除了unique_ptr之外,还有其他类型的智能指针可用,例如weak_ptrshared_ptr。这些额外的智能指针类型将在第二十一章 《使 C++更安全》中探讨。

将我们的智能指针程序修改为使用unique_ptr,我们现在有以下内容:

#include <iostream>
#include <memory>
#include "Person.h"
using std::cout;   // preferred to: using namespace std;
using std::endl;
using std::unique_ptr;
int main()
{
    unique_ptr<int> p1(new int());
    unique_ptr<Person> pers1(new Person("Renee", 
                             "Alexander", 'K', "Dr."));
    *p1 = 100;
    cout << *p1 << endl;
    (*pers1).Print();   // or use: pers1->Print();
    return 0;
}

我们的输出将与 SmartPointer 示例类似;区别在于不会显示 "SmartPtr Destructor" 调用消息(因为我们使用的是 unique_ptr)。注意,因为我们包含了 using std::unique_ptr;,所以我们不需要在唯一指针声明中对 unique_ptr 进行 std:: 限定。

带着这些知识,让我们将唯一指针添加到我们的 pImpl 模式中。

向模式中添加唯一指针

要使用 unique_ptr 实现 pImpl 模式,我们将对我们的先前实现进行最小程度的修改,从我们的 Person.h 头文件开始。我们使用 unique_ptr 的 pImpl 模式完整程序示例可以在我们的 GitHub 仓库中找到,并将包括对 PersonImpl.cpp 的修订文件。以下是驱动程序的 URL,Chp20-Ex3.cpp;注意我们 GitHub 仓库中此完整示例的子目录,unique

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter20/unique/Chp20-Ex3.cpp

#ifndef _PERSON_H    // Person.h header file definition
#define _PERSON_H
#include <memory>
class Person
{
private:
    class PersonImpl;  // forward declaration nested class
    std::unique_ptr<PersonImpl> pImpl; //unique ptr to impl
protected:
    void ModifyTitle(const string &);
public:
    Person();   // default constructor
    Person(const string &, const string &, char, 
           const string &);
    Person(const Person &);  // copy constructor
    virtual ~Person();  // virtual destructor
    const string &GetFirstName() const; // no longer inline
    const string &GetLastName() const; 
    const string &GetTitle() const; 
    char GetMiddleInitial() const; 
    virtual void Print() const;
    virtual void IsA() const;
    virtual void Greeting(const string &) const;
    Person &operator=(const Person &);  // overloaded =
};
#endif

注意,在修改后的 Person 类定义中,std::unique_ptr<PersonImpl> pImpl; 的唯一指针声明。在这里,我们使用 std:: 限定符,因为标准命名空间尚未在我们的头文件中显式包含。我们还 #include <memory> 以获取 unique_ptr 的定义。类的其余部分与我们的初始 pImpl 实现相同,该实现使用原始指针实现的关联。

接下来,让我们了解我们的源代码需要从初始的 pImpl 实现中修改到何种程度。现在,让我们查看源文件 PersonImpl.cpp 中必要的修改后的成员函数:

// Source file PersonImpl.cpp
// Person destructor no longer needs to delete pImpl member
// and hence can simply be the default destructor!
// Note: prototyped with virtual in header file.
Person::~Person() = default;
// unique_pointer pImpl will delete its own resources

查看需要修改的上述成员函数,我们发现只有 Person 析构函数!因为我们使用唯一指针来实现对嵌套类实现的关联,所以我们不再需要自己管理这个资源的内存。这真是太好了!通过这些小的改动,我们的 pImpl 模式现在具有一个 unique_ptr 来指定类的实现。

接下来,让我们检查一些与使用 pImpl 模式相关的性能问题。

理解 pImpl 模式的权衡

将 pImpl 模式集成到生产代码中既有优点也有缺点。让我们逐一回顾,以便我们更好地理解可能需要部署此模式的情况。

可忽略的性能问题涵盖了大多数缺点。也就是说,针对目标(接口)类的几乎所有请求都需要委派给其嵌套的实现类。唯一可以由外部类处理的请求将是不涉及任何数据成员的请求;这些情况将极其罕见!另一个缺点包括实例的内存需求略有增加,以适应模式实现中添加的指针。这些问题在嵌入式软件系统和需要峰值性能的系统中将至关重要,但在其他情况下相对较小。

对于采用 pImpl 模式的类,维护将稍微困难一些,这是一个不幸的缺点。每个目标类现在都配有一个额外的(实现)类,包括一组转发方法,用于将请求委派给实现类。

也可能出现一些实现困难。例如,如果任何私有成员(现在在嵌套实现类中)需要访问外部(接口)类的任何受保护或公共方法,我们需要从嵌套类到外部类包含一个回链以访问该成员。为什么?内部类中的this指针将是嵌套对象类型。然而,外部对象中的受保护和公共方法将期望一个指向外部对象的this指针——即使这些公共方法随后将请求重新委派以调用私有嵌套类方法以获得帮助。此回链还用于从内部类(实现)的作用域调用接口的公共虚拟函数。然而,请记住,我们通过为每个对象添加另一个指针以及委派调用相关对象中的每个方法来影响性能。

利用 pImpl 模式有几个优点,提供了重要的考虑因素。最重要的是,在代码的开发和维护期间,重新编译时间显著减少。此外,类的编译后的二进制接口与类的底层实现无关。仅需要重新编译和链接嵌套的实现类即可更改类的实现。外部类的用户不受影响。作为额外的好处,pImpl 模式提供了一种隐藏类底层私有细节的方法,这在分发类库或其他专有代码时可能很有用。

在我们的 pImpl 实现中包含unique_ptr的一个优点是,我们保证了相关实现类的正确销毁。我们还有潜力避免程序员无意中引入的指针和内存错误!

使用 pImpl 模式是一种权衡。仔细分析每个类和当前的应用程序将有助于确定 pImpl 模式是否适合您的设计。

我们现在已经看到了 pImpl 模式的实现,最初使用原始指针,然后应用了unique_ptr。现在,让我们简要回顾一下与模式相关的学习内容,然后进入我们书籍的附加章节,第二十一章使 C++更安全

摘要

在本章中,我们通过进一步掌握另一个核心设计模式来提高我们的编程技能,从而实现了成为更不可或缺的 C++程序员的宏伟目标。我们探讨了 pImpl 模式,最初使用原生 C++指针和关联进行实现,然后通过使用唯一指针来改进我们的实现。通过检查实现,我们很容易理解 pImpl 模式如何减少编译时依赖,并使我们的代码更依赖于实现。

利用核心设计模式,如 pImpl 模式,将帮助你更轻松地贡献可重用、可维护且其他熟悉常见设计模式的程序员可以理解的代码。你的软件解决方案将基于创造性和经过充分测试的设计解决方案。

我们现在一起完成了我们的最后一个设计模式,结束了在 C++中理解面向对象编程的漫长旅程。你现在拥有了许多技能,包括对面向对象有深入的理解、扩展的语言特性和核心设计模式,所有这些都使你成为一个更有价值的程序员。

尽管 C++是一种复杂的语言,具有额外的功能、补充技术和额外的设计模式需要探索,但你已经拥有了一个坚实的基础和专业知识水平,可以轻松地导航和接受你可能希望获得的任何额外的语言功能、库和模式。你已经走了很长的路;这已经是一次冒险的旅程!我享受了我们这次探索的每一分钟,我希望你也一样。

我们首先回顾了基本语言语法,并理解了 C++的必要要素,这些要素是我们当时即将开始的面向对象编程(OOP)之旅的基石。然后,我们将 C++视为一种面向对象的编程语言,不仅学习了必要的面向对象概念,还学习了如何使用 C++语言特性、编码技术或两者结合来实现这些概念。接着,我们通过添加异常处理、友元、运算符重载、模板、STL 基础以及测试面向对象类和组件的知识来扩展你的技能。然后,我们通过采用核心设计模式和深入应用感兴趣的模式来应用代码,我们冒险进入了更复杂的编程技术。

这些获得的知识技能块代表了 C++知识掌握的新层次。每个都将帮助你创建更易于维护和健壮的代码。你作为一个熟练的 C++面向对象程序员的未来正在等待。现在,让我们继续我们的附加章节,然后,让我们开始编程!

问题

  1. 修改本章中使用的唯一指针的 pImpl 模式示例,在嵌套类的实现中进一步引入唯一指针。

  2. 修改之前章节中的Student类,使其简单地从本章中采用 pImpl 模式的Person类继承。你遇到什么困难吗?现在,修改Student类,使其额外利用唯一指针的 pImpl 模式。一个建议的Student类是包含与Course关联的类。现在,你遇到什么困难吗?

  3. 你能想象出哪些其他示例可以合理地结合 pImpl 模式以实现相对的实现独立性?

第五部分:C++中更安全编程的考虑因素

本部分的目标是了解作为程序员我们可以做什么来使 C++成为一种更安全的语言,这反过来将有助于使我们的程序更健壮。到目前为止,我们已经对 C++有了很多了解,从语言基础到在 C++中实现 OO 设计。我们已经增加了额外的技能,例如使用友元和运算符重载、异常处理、模板和 STL。我们甚至深入研究了几个流行的设计模式。我们知道我们几乎可以在 C++中做任何事情,但我们也已经看到,拥有如此大的能力可能会留下粗心编程和严重错误的空间,这可能导致难以维护的代码。

在本节中,我们将以敏锐的目光回顾全书所学内容,了解我们如何努力使我们的代码更加健壮。我们将致力于制定一套核心编程指南,目标只有一个:使我们的程序更安全!

我们将重新审视并扩展我们对智能指针(唯一、共享和弱引用)的知识,以及介绍一个互补的惯用语,RAII。我们将回顾与原生 C++指针相关的安全问题,并总结我们的安全担忧,以编程指南的形式:在新的 C++代码中始终优先使用智能指针。

我们将回顾现代编程特性,例如基于范围的for循环和 for-each 风格的循环,以了解这些简单的结构如何帮助我们避免常见错误。我们将重新审视auto关键字,而不是显式类型,以增加代码的安全性。我们将重新审视使用经过良好测试的 STL 类型,以确保我们的代码在使用临时容器时不会出错。我们将重新审视const限定符以多种方式增加代码的安全性。通过回顾全书使用的具体语言特性,我们将重新审视每个特性如何增加代码的安全性。我们还将考虑线程安全性以及我们全书所见的各种主题如何与线程安全性相关。

最后,我们将讨论核心编程指南,例如优先初始化而不是赋值,或者使用virtualoverridefinal之一来指定多态操作及其方法。我们将理解采用编程指南的重要性,并了解可用于支持在 C++中安全编程的资源。

本部分包含以下章节:

  • 第二十一章使 C++更安全

第五部分:C++更安全编程的考虑因素

第二十一章:使 C++ 更加安全

本附录章节将深入探讨作为 C++ 程序员,我们可以如何使语言在日常使用中尽可能安全。我们已经从基本语言特性进步到我们的核心兴趣——使用 C++ 进行面向对象编程,再到额外的有用语言特性库(异常、运算符重载、模板和 STL),以及设计模式,以提供解决重复出现的面向对象编程问题的知识库。在旅途中,我们始终看到 C++ 需要我们额外的关注,以避免棘手和可能存在问题的编程情况。C++ 是一种允许我们做任何事情的语言,但随之而来的是需要指导方针来确保我们的编程遵循安全实践。毕竟,我们的目标是创建能够成功运行且无错误的程序,并且易于维护。C++ 能够做任何事情的能力需要与良好的实践相结合,以使 C++ 更加安全。

本章的目标是回顾我们在前几章中介绍过的主题,并从安全的角度进行审查。我们还将结合与之前内容紧密相关的话题。本章的目的不是全面覆盖全新的主题或深入探讨之前的话题,而是提供一组更安全的编程实践,并鼓励在需要时进一步了解每个主题。其中一些主题本身可以涵盖整个章节(或书籍)!

在本附录章节中,我们将介绍一些流行的编程约定,以满足我们的安全挑战:

  • 重新审视智能指针(唯一、共享和弱引用),以及补充的惯用法(RAII)

  • 使用现代for循环(基于范围的、for-each)以避免常见错误

  • 添加类型安全:使用auto代替显式类型声明

  • 优先使用 STL 类型作为简单容器(如std::vector等)

  • 适当地使用const以确保某些项不被修改

  • 理解线程安全问题

  • 考虑核心编程指导原则的基本要素,例如优先初始化而不是赋值,或者只选择virtualoverridefinal中的一个

  • 采用 C++ 核心编程指南进行安全编程(如果需要,构建和组装一个)

  • 理解 C++ 编程中的资源安全

在本章结束时,您将了解一些当前行业在 C++ 中安全编程的标准和关注点。本章的目的不是列出 C++ 中所有安全问题和实践的综合列表,而是展示作为成功的 C++ 程序员,您需要关注的问题类型。在某些情况下,您可能希望更深入地研究一个主题,以获得更全面的能力和熟练度。将安全性添加到您的 C++ 编程中会使您成为一个更有价值的程序员,因为您的代码将更加可靠,具有更长的生命周期和更高的成功率。

让我们通过考虑如何使 C++ 更安全来完善我们的编程技能集。

技术要求

完整程序示例的在线代码可以在以下 GitHub 网址找到:github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/tree/main/Chapter21。每个完整程序示例都可以在 GitHub 仓库中找到,位于相应章节标题(子目录)下的文件中,该文件以章节编号开头,后面跟着一个连字符,然后是当前章节中的示例编号。例如,本章的第一个完整程序可以在上述 GitHub 目录下的 Chapter21 子目录中找到,文件名为 Chp21-Ex1.cpp。一些程序位于示例中指示的可应用子目录中。

本章的 CiA 视频可以在以下网址观看:bit.ly/3wpOG6b

重新审视智能指针

在整本书中,我们已经对如何使用原生或本地 C++ 指针有了合理的理解,包括与堆实例相关的内存分配和释放。我们坚持使用原生 C++ 指针,因为它们在现有的 C++ 代码中无处不在。了解如何正确利用原生指针对于处理目前广泛使用的现有 C++ 代码量至关重要。但是,对于新创建的代码,有一种更安全的方式来操作堆内存。

我们已经看到,使用原生指针进行动态内存管理是一项繁重的工作!特别是当可能有多个指针指向同一块内存时。我们讨论了引用计数到共享资源(如堆内存)以及当所有实例都完成对共享内存的操作时删除内存的机制。我们还知道,内存释放很容易被忽视,从而导致内存泄漏。

我们还亲身体验到,原生指针的错误可能代价高昂。当我们解引用我们不想访问的内存,或者解引用未初始化的原生指针(解释内存包含有效的地址和该地址上的有意义数据——这两者实际上都不有效)时,我们的程序可能会突然结束。通过指针算术遍历内存可能会被一个本应熟练的程序员的错误所困扰。当出现内存错误时,指针或堆内存误用往往是罪魁祸首。

当然,使用引用可以减轻许多原生指针错误带来的负担。但引用仍然可以指向某人忘记释放的已解引用堆内存。出于这些以及其他许多原因,智能指针在 C++ 中变得流行,其主要目的是使 C++ 更安全。

我们在之前的章节中讨论了智能指针,并看到了它们在我们使用 pImpl 模式(使用unique_ptr)时的实际应用。但除了唯一指针之外,还有更多类型的智能指针需要我们回顾:共享和弱指针。让我们还设定一个编程前提(未来风格指南的补充),即在我们的新代码中优先使用智能指针而不是原生指针,以实现指针安全的目的和价值。

回想一下,智能指针是一个小的包装类,它封装了一个原始指针或原生指针,确保当包装对象超出作用域时,它所包含的指针会自动删除。标准 C++库中实现的唯一共享智能指针使用模板来为任何数据类型创建特定的智能指针类别。

虽然我们可以为每种智能指针类型深入探讨整整一章,但我们将简要回顾每种类型,作为起点,鼓励在新创建的代码中使用它们,以支持我们使 C++更安全的目标。

现在,让我们逐一回顾每种智能指针类型。

使用智能指针 - 唯一

回想一下,在标准 C++库中,unique_ptr是一种智能指针类型,它封装了对给定堆内存资源的独占所有权和访问。unique_ptr不能被复制;unique_ptr的所有者将独占使用该指针。唯一指针的所有者可以选择将这些指针移动到其他资源,但后果是原始资源将不再包含unique_ptr。回想一下,我们必须使用#include <memory>来包含unique_ptr的定义。

这里有一个非常简单的例子,说明了如何创建唯一指针。这个例子可以在我们的 GitHub 仓库中找到:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter21/Chp21-Ex1.cpp

#include <iostream>
#include <memory>
#include "Person.h"
using std::cout;   // preferred to: using namespace std;
using std::endl;
using std::unique_ptr;
// We will create unique pointers, with and without using
// the make_unique (safe wrapper) interface
int main()
{
    unique_ptr<int> p1(new int(100));
    cout << *p1 << endl;
    unique_ptr<Person> pers1(new Person("Renee",
                             "Alexander",'K', "Dr."));
    (*pers1).Print();      // or use: pers1->Print();
    unique_ptr<Person> pers2; // currently uninitialized
    pers2 = move(pers1);// take over another unique
                           // pointer's resource
    pers2->Print();        // or use: (*pers2).Print();  
    // make_unique provides a safe wrapper, eliminating
    // obvious use of heap allocation with new()
    auto pers3 = make_unique<Person>("Giselle", "LeBrun",
                                      'R', "Ms.");
    pers3->Print();        
    return 0;
}

首先,请注意,因为我们包含了using std::unique_ptr;,所以我们不需要在唯一指针声明中对unique_ptrmake_unique进行std::限定。在这个小程序中,我们创建了几个唯一指针,从指向一个整数的一个指针p1和一个指向Person实例的指针pers1开始。由于我们使用了唯一指针,因此每个变量都独占使用它所指向的堆内存。

接下来,我们介绍一个唯一指针pers2,它通过pers2 = move(pers1);接管了原本分配并链接到pers1的内存。原始变量不再能访问这块内存。请注意,尽管我们可以为pers2分配它自己的唯一堆内存,但我们选择展示如何使用move()允许一个唯一指针将其内存释放给另一个唯一指针。使用move()来改变唯一指针的所有权是典型的,因为唯一指针不能被复制(因为这会导致两个或更多指针共享相同的内存,因此它们不是唯一的!)

最后,我们创建另一个唯一指针pers3,它使用make_unique作为包装器来为pers3将表示的唯一指针分配堆内存。使用make_unique的偏好是,new()的调用将内部为我们执行。此外,在对象构造期间抛出的任何异常也将由我们处理,如果底层的new()没有成功完成并且需要调用delete(),也是如此。

堆内存将由系统自动管理;这是使用智能指针的一个好处。

下面是unique_ptr示例的输出:

100
Dr. Renee K. Alexander
Dr. Renee K. Alexander
Ms. Giselle LeBrun
Person destructor
Person destructor

在底层,当内存不再被利用时,智能指针所指向的每个对象都将自动调用析构函数。在本例中,当main()中的局部对象超出作用域并被从栈中弹出时,代表每个Person对象的析构函数将代表我们被调用。请注意,我们的Person析构函数包含一个cout语句,这样我们就可以可视化地看到只有两个Person对象被销毁。在这里,被销毁的Person对象代表通过move()语句从pers1接管实例的pers2,以及使用make_unique包装器创建的pers3对象。

接下来,让我们添加使用共享和弱智能指针的示例。

使用智能指针 – 共享

标准 C++库中的shared_ptr是一种智能指针类型,允许共享对给定资源的所有权和访问。对于该资源的最后一个共享指针将触发资源的销毁和内存释放。共享指针可用于多线程应用程序;然而,如果使用非常量成员函数来修改共享资源,则可能会发生竞争条件。由于共享指针仅提供引用计数,我们需要使用额外的库方法来解决这些问题(缓解竞争条件、同步对代码关键区域的访问等)。例如,标准 C++库提供了重载的原子方法来锁定、存储和比较共享指针所指向的底层数据。

我们已经看到了许多可以利用共享指针的示例程序。例如,我们利用了CourseStudent类之间的关联——一个学生可以关联多个课程,一个课程也可以关联多个学生。显然,多个Student实例可以指向同一个Course实例,反之亦然。

在以前,使用原始指针时,程序员有责任使用引用计数。相比之下,使用共享指针时,内部引用计数器会原子性地增加和减少,以支持指针和线程安全。

解引用共享指针几乎与解引用原始指针一样快;然而,由于共享指针在类中代表了一个包装指针,因此构造和复制共享指针的成本更高。然而,我们感兴趣的是使 C++更安全,所以我们将简单地注意这个非常小的性能开销并继续前进。

让我们看看一个非常简单的使用shared_ptr的例子。这个例子可以在我们的 GitHub 仓库中找到:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter21/Chp21-Ex2.cpp

#include <iostream>
#include <memory>
#include "Person.h"
using std::cout;   // preferred to: using namespace std;
using std::endl;
using std::shared_ptr;
int main()
{
    shared_ptr<int> p1 = std::make_shared<int>(100);
    // alternative to preferred, previous line of code:
    // shared_ptr<int> p1(new int(100));
    shared_ptr<int> p2;// currently uninitialized (caution)
    p2 = p1; // p2 now shares the same memory as p1
    cout << *p1 << " " << *p2 << endl;
    shared_ptr<Person> pers1 = std::make_shared<Person>
                          ("Gabby", "Doone", 'A', "Miss");
    // alternative to preferred, previous lines of code:
    // shared_ptr<Person> pers1(new Person("Gabby",
    //                              "Doone",'A', "Miss"));
    shared_ptr<Person> pers2 = pers1;  // initialized
    pers1->Print();   // or use: (*pers1).Print();
    pers2->Print();   
    pers1->ModifyTitle("Dr."); // changes shared instance
    pers2->Print();   
    cout << "Number of references: " << pers1.use_count();
    return 0;
}

在上述程序中,我们创建了四个共享指针——两个指向相同的整数(p1p2)和两个指向相同的Person实例(pers1pers2)。由于我们使用的是共享指针(允许这种重新赋值),这些变量中的每一个都可能改变它们所指向的特定共享内存。例如,通过pers1对共享内存的更改,如果随后我们通过指针pers2查看(共享)内存,将会反映出来;这两个变量都指向相同的内存位置。

堆内存将再次由智能指针的使用自动管理。在这个例子中,当移除对内存的最后一个引用时,内存将被销毁和删除。请注意,引用计数是由我们代为进行的,并且我们可以使用use_count()来访问此信息。

让我们注意前一个示例中的一些有趣之处。注意 shared_ptr 变量 pers1pers2->. 符号的混合使用。例如,我们使用 pers1->Print();,同时也使用 pers1.use_count()。这并非错误,而是揭示了智能指针的包装实现。考虑到这一点,我们知道 use_count()shared_ptr 的一个方法。我们的共享指针 pers1pers2 都被声明为 shared_ptr 的实例(绝对不是使用带有符号 * 的原始 C++ 指针)。因此,点符号是访问 use_count() 方法是合适的。然而,我们使用 -> 符号来访问 pers1->Print();。在这里,回忆一下这个符号等同于 (*pers1).Print();shared_ptr 类中的 operator*operator-> 都被重载,以便将智能指针中包含的包装原始指针委托出去。因此,我们可以使用标准指针符号来访问 Person 方法(通过安全包装的原始指针)。

这里是关于我们的 shared_ptr 指针示例的输出:

100 100
Miss Gabby Doone
Miss Gabby Doone
Dr. Gabby Doone
Number of references: 2
Person destructor

共享指针似乎是一种确保多个指针指向的内存资源得到适当管理的好方法。总体来说,这是真的。然而,存在循环依赖的情况,共享指针根本无法释放其内存 – 另一个指针始终指向相关的内存。这发生在内存循环被遗弃时;也就是说,当没有外部共享指针指向循环连接时。在这些独特的情况下,我们实际上(并且反直觉地)可能会用共享指针管理内存。在这些情况下,我们可以从弱指针那里寻求帮助,以帮助我们打破循环。

考虑到这一点,接下来让我们看看弱智能指针。

使用智能指针 – 弱指针

在标准 C++ 库中,weak_ptr 是一种不拥有给定资源的智能指针类型;相反,弱指针充当观察者。弱指针可以用来帮助打破共享指针之间可能存在的循环连接;也就是说,在共享资源的销毁本应永远不会发生的情况下。在这里,一个弱指针被插入到链中,以打破共享指针单独可能创建的循环依赖。

例如,想象一下我们最初的编程示例中的 StudentCourse 依赖关系,利用关联,或者从我们的更复杂的程序中,该程序展示了观察者模式。每个都包含关联对象类型的指针数据成员,从而有效地创建了一个潜在的循环依赖。现在,如果存在外部(来自圆圈外的)共享指针,例如外部课程列表或外部学生列表,那么可能不会出现排他性的循环依赖场景。在这种情况下,例如,课程的主列表(外部指针,与关联对象之间存在的任何循环依赖无关)将提供取消课程的方法,从而导致其最终被销毁。

同样,在我们的例子中,由大学学生群体组成的校外学生集合可以提供一个指向由 StudentCourse 之间的关联产生的潜在循环共享指针场景的外部指针。然而,在这两种情况下,都需要做工作来从学生的课程列表中删除已取消的课程(或从课程的学生的列表中删除已退出的学生)。在这种情况下,删除关联反映了准确管理学生的日程安排或课程的出勤名单。尽管如此,我们可以想象存在循环连接的情景,但没有外部对链接的访问(与上述具有外部链接到圆圈中的情景不同)。

在存在循环依赖(没有外部影响)的情况下,我们需要将一个共享指针降级为弱指针。弱指针不会控制其所指向的资源的生命周期。

指向资源的弱指针不能直接访问该资源。这是因为 weak_ptr 类中没有重载操作符 *->。您需要将弱指针转换为共享指针才能访问(包装的)指针类型的方法。一种方法是将 lock() 方法应用于弱指针,因为返回值是一个共享指针,其内容通过信号量锁定以确保对共享资源的互斥访问。

让我们通过一个使用 weak_ptr 的非常简单的例子来看看。这个例子可以在我们的 GitHub 上找到:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter21/Chp21-Ex3.cpp

#include <iostream>
#include <memory>
#include "Person.h"
using std::cout;   // preferred to: using namespace std;
using std::endl;
using std::weak_ptr;
using std::shared_ptr;
int main()
{
    // construct the resource using a shared pointer
    shared_ptr<Person> pers1 = std::make_shared<Person>
                           ("Gabby", "Doone", 'A', "Miss");
    pers1->Print(); // or alternatively: (*pers1).Print();
    // Downgrade resource to a weak pointer
    weak_ptr<Person> wpers1(pers1); 
    // weak pointer cannot access the resource; 
    // must convert to a shared pointer to do so
    // wpers1->Print();   // not allowed! operator-> is not
                          // overloaded in weak_ptr class
    cout << "# references: " << pers1.use_count() << endl;
    cout << "# references: " << wpers1.use_count() << endl;
    // establish a new shared pointer to the resource
    shared_ptr<Person> pers2 = wpers1.lock();  
    pers2->Print();
    pers2->ModifyTitle("Dr.");   // modify the resource
    pers2->Print();
    cout << "# references: " << pers1.use_count() << endl;
    cout << "# references: " << wpers1.use_count() << endl;
    cout << "# references: " << pers2.use_count() << endl;
    return 0;
}

在上述程序中,我们使用 pers1 中的共享指针来分配我们的资源。现在,让我们假设在我们的程序中有理由将我们的资源降级为弱指针——也许我们想要插入一个弱指针来打破共享指针的循环。使用 weak_ptr<Person> wpers1(pers1);,我们为这个资源建立了一个弱指针。请注意,我们无法使用 wpers1 来调用 Print();。这是因为 weak_ptr 类中没有重载 operator->operator*

我们为 pers1wpers1 打印出 use_count(),以注意到每个都显示了一个值为 1。这意味着只有一个非弱指针控制着相关的资源(弱指针可能暂时持有资源,但不能修改它)。

现在,假设我们想要按需将 wpers1 指向的资源转换为另一个共享指针,以便我们可以访问该资源。我们可以通过首先锁定弱指针来实现这一点;lock() 将返回一个共享指针,其内容由信号量保护。我们将这个值赋给 pers2。然后我们使用共享指针调用 pers2->ModifyTitle("Dr."); 来修改资源。

最后,我们从 pers1wpers1pers2 的角度打印出 use_count()。在每种情况下,引用计数都将为 2,因为有两个非弱指针引用了共享资源。弱指针不会对该资源的引用计数做出贡献,这正是弱指针可以帮助打破循环依赖的方式。通过在依赖循环中插入弱指针,共享资源的引用计数不会受到弱指针存在的影响。这种策略允许当只有对资源的弱指针剩余(且引用计数为 0)时删除资源。

堆内存将再次由智能指针的使用自动管理。在本例中,当移除对内存的最后一个引用时,内存将被销毁和删除。再次注意,弱指针没有对这个计数做出贡献。我们可以从 Person 析构函数中的 cout 语句中看到,只有一个实例被析构。

下面是关于我们的 weak_ptr 指针示例的输出:

Miss Gabby Doone
# references: 1
# references: 1
Miss Gabby Doone
Dr. Gabby Doone
# references: 2
# references: 2
# references: 2
Person destructor

在本节中,我们回顾并补充了有关智能指针的基本知识。然而,每个类型的智能指针都可能单独占用一章内容。尽管如此,希望您对基本知识已经足够熟悉,可以开始在代码中包含各种智能指针,并在需要时进一步研究每种类型。

探索一个互补的想法——RAII

一种与智能指针(以及其他概念)相辅相成的编程惯用方法是 move() 操作。

许多 C++ 类库遵循 RAII(资源获取即初始化)进行资源管理,例如 std::stringstd::vector。这些类遵循该惯用法,即它们的构造函数获取必要的资源(堆内存),并在析构函数中自动释放资源。使用这些类的用户不需要显式释放容器本身的任何内存。在这些类库中,即使不使用智能指针来管理堆内存,RAII 作为一种技术也被用来管理这些资源,其概念被封装并隐藏在类实现本身之中。

当我们在 第二十章 中实现自己的智能指针时,即 使用 pImpl 模式去除实现细节,我们无意中使用了 RAII 来确保在构造函数中分配堆资源,并在析构函数中释放资源。标准 C++ 库中实现的智能指针(std::unique_ptrstd::shared_ptrstd::weak_ptr)也采用了这种惯用法。通过使用采用这种惯用法的类(或者在自己无法做到时将其添加到类中),可以有助于确保代码更安全且易于维护。由于这种惯用法为代码增加了安全性和健壮性,熟练的开发者强烈建议我们将 RAII 作为 C++ 中最重要的实践和功能之一。

在我们努力使 C++ 更安全的努力中,接下来让我们考虑几个我们可以轻松采用的简单 C++ 功能,以确保我们的编码更加健壮。

采用促进安全性的额外 C++ 功能

如我们通过前 20 章编程所见,C++ 是一种广泛的语言。我们知道 C++ 有很大的能力,我们几乎可以在 C++ 中做任何事情。作为面向对象的 C++ 程序员,我们已经看到了如何采用 OO 设计,目标是使我们的代码更容易维护。

我们还积累了大量使用 C++ 中的原始(本地)指针的经验,主要是因为原始指针在现有代码中非常普遍。当需要时,你确实需要经验和熟练使用本地指针。在获得这种经验的过程中,我们亲眼目睹了可能遇到的堆内存管理陷阱——我们的程序可能崩溃,我们可能泄漏了内存,意外覆盖了内存,留下了悬垂指针,等等。在本章中,我们的首要任务是优先使用智能指针来创建新代码——以促进 C++ 的安全性。

现在,我们将探索 C++的其他领域,我们可以同样使用更安全的特性。我们在整本书中看到了这些不同的特性;建立一条指导原则,选择那些能促进 C++安全性的语言特性是很重要的。仅仅因为我们可以在 C++中做任何事情,并不意味着我们应该例行公事地将那些与高度误用相关的特性纳入我们的技能库。那些不断崩溃(或者甚至只崩溃一次)的应用程序是不可接受的。当然,我们在整本书中都提到了禁忌。在这里,让我们指出那些值得拥抱的语言特性,以进一步实现使 C++更安全的目标,使我们的应用程序更加健壮和易于维护。

让我们从回顾我们可以将其纳入日常代码中的简单项目开始。

重温范围 for 循环

C++有各种各样的循环结构,我们在整本书中都看到了。在处理一个完整的元素集合时,一个常见的错误是正确跟踪集合中有多少个项目,尤其是在这个计数器被用作遍历集合中所有项目的依据时。例如,当我们的集合以数组形式存储时,处理过多的元素可能会导致我们的代码不必要地抛出异常(或者更糟,可能导致程序崩溃)。

与其依赖于一个MAX值来遍历集合中的所有元素,不如以一种不依赖于程序员正确记住这个上限循环值的方式遍历集合中的每一个项目。相反,对于集合中的每一个项目,让我们进行某种处理。for-each 循环很好地满足了这一需求。

在处理一个不完整的元素集合时,一个常见的错误是正确跟踪当前集合中有多少个项目。例如,一门课程可能允许的最大学生人数是有限的。然而,截至今天,只有一半的潜在Student位置被填满。当我们查看课程中注册的学生名单时,我们需要确保我们只处理已填满的学生位置(即当前的学生人数)。处理所有最大学生位置显然是错误的,可能会导致我们的程序崩溃。在这种情况下,我们必须小心地只遍历Course中当前使用的Student位置,无论是通过在适当的时候退出循环的逻辑,还是通过选择一个当前大小代表要遍历的集合完整大小的容器类型(没有空白的待填充位置);后一种情况使得 for-each 循环成为理想的选择。

此外,如果我们依赖于基于 currentNumStudents 计数器的循环呢?在之前示例中提到的情况下,这可能比 MAX 值更好,但如果我们没有正确更新那个计数器呢?我们也会在这个问题上出错。再次强调,将表示当前条目数量的容器类与 foreach 类型的循环结合起来,可以确保我们以更不易出错的方式处理完整的当前分组。

既然我们已经回顾了现代和更安全的循环风格,让我们拥抱 auto 以确保类型安全。然后我们将看到一个结合这些共同特性的示例。

使用 auto 进行类型安全

在许多情况下,使用 auto 可以使变量声明(包括循环迭代器)的编码更加容易,并且使用 auto 而不是显式类型化可以确保类型安全。

选择使用 auto 是声明具有复杂类型的变量的简单方法。使用 auto 还可以确保为给定变量选择最佳类型,并且不会发生隐式转换。

我们可以在各种情况下使用 auto 作为类型的占位符,让编译器推导出特定情况下的需求。我们甚至可以在许多情况下将 auto 用作函数的返回类型。使用 auto 可以使我们的代码看起来更通用,并且可以作为泛化的替代方案来补充模板。我们还可以将 autoconst 配对,并将这些限定符与引用配对;请注意,这些限定符 结合 不能与 auto 外推,必须由程序员单独指定。此外,auto 不能与增强类型的限定符一起使用,例如 longshort,也不能与 volatile 一起使用。虽然这超出了我们书籍的范围,但 auto 可以与 lambda 表达式一起使用。

当然,使用 auto 有一些缺点。例如,如果程序员不理解正在创建的对象的类型,程序员可能会期望编译器选择某种类型,然而却推导出另一种(意外的)类型。这可能会在您的代码中产生微妙的错误。例如,如果您为 auto 将选择和编译器实际推导出的 auto 声明类型都重载了函数,您可能会调用一个与预期不同的函数!当然,这大多可能是由于程序员在插入 auto 关键字时没有完全理解当前使用上下文的结果。另一个缺点是,当程序员仅仅为了强制代码编译而使用 auto,而没有真正地处理手头的语法并思考代码应该如何编写时。

既然我们已经回顾了在我们的代码中添加 auto,那么让我们回顾一下在日常代码中拥抱 STL。然后我们将看到一个结合这些共同特性的示例。

优先使用 STL 进行简单容器

如我们在第十四章“理解 STL 基础”所见,标准模板库(STL)包含了一套非常完整且健壮的容器类,这些类在 C++代码中被广泛使用。使用这些经过良好测试的组件(而不是原生 C++机制,如指针数组)来收集类似项,可以为我们的代码增加鲁棒性和可靠性。内存管理变得更容易(消除了许多潜在的错误)。

通过使用模板实现其大量的容器类,STL 允许其容器以通用方式用于程序可能遇到的任何数据类型。相比之下,如果我们使用了原生 C++机制,我们可能会将我们的实现绑定到特定的类类型,例如指向Student的指针数组。当然,我们可以实现一个指向模板化类型的指针数组,但为什么要在有这么多经过良好测试且易于使用的容器可供我们使用时这样做呢?

STL 容器还避免了使用new()delete()进行内存管理,而是选择使用分配器来提高 STL 底层内存管理的效率。例如,一个向量、栈或队列可能会增长或缩小。而不是分配你可能预期的最大元素数量(这可能既难以估计,又可能对典型使用(通常不会达到最大值)来说既困难又低效),在幕后可能会预先分配一定大小的缓冲区或元素数量。这种初始分配允许在不需要为集合中的每个新添加项进行大小调整的情况下多次向容器中添加元素(否则可能会这样做以避免过度分配)。只有当底层容器的内部分配(或缓冲区)大小超过预先分配的量时,才需要进行内部重新分配(对容器用户来说是未知的)。内部重新分配或移动的代价是分配更大的内存块,从原始内存复制到更大的内存块,然后释放原始内存。STL 在幕后努力微调内部分配,以平衡典型使用需求与可能进行的昂贵重新分配。

既然我们已经重新审视了在代码中优先使用 STL,那么现在让我们重新审视在必要时使用const,以确保代码不会被修改,除非我们有意使其如此。我们将通过一个示例结束本节,该示例展示了本节中所有关键的安全点。

根据需要使用 const

const限定符应用于对象是一种简单的方法,可以表明不应修改的实例实际上没有被修改。我们可能会记得,const实例只能调用const成员函数。而且,const成员函数不能修改调用该方法的任何对象的部分(this)。记住利用这个简单的限定符可以确保这个检查点链对于我们不打算修改的对象发生作用。

在此基础上,请记住,const可以在参数列表中使用,以指定对象和方法。使用const不仅增加了它所指定的对象和方法的可读性,还增加了宝贵的只读对象和方法强制执行。让我们记住在需要时使用const

现在,让我们看看我们如何使用这些易于添加的 C++特性,这些特性有助于更安全的编程。这个例子重新审视了首选的循环风格,使用auto进行类型安全,使用 STL 进行简单的容器,以及适当地应用const。这个例子可以在我们的 GitHub 仓库中找到:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter21/Chp21-Ex4.cpp

#include <vector>  
using std::vector;  
// Assume additional #include/using as typically included
// Assume classes Person, Student are as typically defined
// In this const member function, no part of 'this' will
// be modified. Student::Print() can be called by const
// instances of Student, including const iterators 
void Student::Print() const
{   // need to use access functions as these data members
    // are defined in Person as private
    cout << GetTitle() << " " << GetFirstName() << " ";
    cout << GetMiddleInitial() << ". " << GetLastName();
    cout << " with id: " << studentId << " GPA: ";
    cout << setprecision(3) <<  " " << gpa;
    cout << " Course: " << currentCourse << endl;
}
int main()
{   // Utilize STL::vector instead of more native C++ data
    // structures (such as an array of pointers to Student)
    // There's less chance for us to make an error with
    // memory allocation, deallocation, deep copies, etc.
    vector<Student> studentBody;  
    studentBody.push_back(Student("Hana", "Sato", 'U', 
                           "Miss", 3.8, "C++", "178PSU"));
    studentBody.push_back(Student("Sam", "Kato", 'B', 
                           "Mr.", 3.5, "C++", "272PSU"));
    studentBody.push_back(Student("Giselle", "LeBrun", 'R',
                           "Ms.", 3.4, "C++", "299TU"));
    // Notice that our first loop uses traditional notation
    // to loop through each element of the vector.
    // Compare this loop to next loop using an iterator and
    // also to the preferred range-for loop further beyond
    // Note: had we used MAX instead of studentBody.size(),
    // we'd have a potential error – what if MAX isn't the
    // same as studentBody.size()? 
    for (int i = 0; i < studentBody.size(); i++)   
        studentBody1[i].Print();  
    // Notice auto keyword simplifies iterator declaration
    // However, an iterator is still not the most
    // preferred looping mechanism. 
    // Note, iterator type is: vector<Student>::iterator
    // the use of auto replaces this type, simplifying as: 
    for (auto iter = studentBody.begin(); 
              iter != studentBody.end(); iter++)
        (*iter).EarnPhD();
    // Preferred range-for loop 
    // Uses auto to simplify type and const to ensure no
    // modification. As a const iterator, student may only
    // call const member fns on the set it iterates thru
    for (const auto &student : studentBody)
        student.Print();
    return 0;
}

在上述程序中,我们最初注意到我们使用了 C++ STL 中的std::vector。在main()函数中,我们注意到使用vector<Student> studentBody;实例化了一个向量。利用这个经过良好测试的容器类无疑增加了我们代码的健壮性,相对于我们自行管理动态大小的数组。

接下来,注意指定了一个常量成员函数void Student::Print() const;。在这里,const指定确保调用此方法的对象(this)的任何部分都不能被修改。此外,如果存在任何const实例,它们将能够调用Student::Print(),因为const指定保证了此方法对const实例来说是安全的(即只读)。

接下来,我们注意到三种循环风格和机制,从最不安全到最安全的风格进行排序。第一个循环使用传统的for循环遍历循环中的每个元素。如果我们用MAX代替studentBody.size()作为循环条件会怎样?我们可能会尝试处理比容器中当前元素更多的元素;这种疏忽可能导致错误。

第二个循环使用迭代器和 auto 关键字来简化类型指定(从而对迭代器本身来说更容易且更安全)。虽然迭代器定义良好,但它们仍然不是首选的循环机制。for 语句中第二个语句的增量中的一个细微差别也可能导致效率低下。例如,考虑在循环条件重新测试之前执行的语句中的预增量与后增量。如果这是 iter++,则代码效率会较低。这是因为 iter 是一个对象,预增量返回对象的引用,而后增量返回一个临时对象(在每个循环迭代中创建和销毁)。后增量还使用了一个重载函数,因此编译器无法优化其使用。

最后,我们看到首选且最安全的循环机制,它结合了范围-for 循环和 auto 用于迭代器指定(以简化类型声明)。使用 auto 替换了 vector<Student>::iterator 作为 iter 的类型。任何简化符号的地方,都有更少的错误空间。此外,请注意迭代器声明中添加了 const,以确保循环将只调用每个迭代实例上的不可修改方法;这是一个我们可以在我们代码中采用的额外、适当的特性示例。

以下是上述程序的输出:

Miss Hana U. Sato with id: 178PSU GPA:  3.8 Course: C++
Mr. Sam B. Kato with id: 272PSU GPA:  3.5 Course: C++
Ms. Giselle R. LeBrun with id: 299TU GPA:  3.4 Course: C++
Everyone to earn a PhD
Dr. Hana U. Sato with id: 178PSU GPA:  3.8 Course: C++
Dr. Sam B. Kato with id: 272PSU GPA:  3.5 Course: C++
Dr. Giselle R. LeBrun with id: 299TU GPA:  3.4 Course: C++

我们现在回顾了几个简单的 C++ 语言特性,这些特性可以轻松地被采用以促进我们日常编码实践中的安全性。使用范围-for 循环提供了代码简化并消除了对循环迭代中经常错误的上限的依赖。采用 auto 简化了变量声明,包括循环迭代器内的声明,并有助于确保类型安全与显式类型。使用经过良好测试的 STL 组件可以为我们的代码增加鲁棒性、可靠性和熟悉感。最后,将 const 应用于数据和方法是确保数据不会被意外修改的一种简单方法。这些原则都很容易应用,并通过增加整体安全性为我们的代码增加价值。

接下来,让我们考虑理解线程安全性如何有助于使 C++ 更加安全。

考虑线程安全性

C++ 的多线程编程本身就是一个完整的书籍。尽管如此,我们在本书中提到了几个可能需要考虑线程安全性的情况。值得重申这些主题,以提供一个概述,说明您可能在 C++ 编程的各个细分领域中遇到的问题。

一个程序可能由多个线程组成,每个线程可能都可能会相互竞争以访问共享资源。例如,共享资源可能是一个文件、套接字、共享内存区域或输出缓冲区。每个访问共享资源的线程都需要对资源进行仔细协调(称为互斥)的访问。

例如,想象如果有两个线程想要向你的屏幕写入输出。如果每个线程都可以访问与cout关联的输出缓冲区,而不必等待另一个线程完成一个连贯的语句,输出将是一团糟的随机字母和符号。显然,对共享资源的同步访问是非常重要的!

线程安全涉及理解原子操作、互斥、锁、同步等——这些都是多线程编程的方面。

让我们从线程和多线程编程的概述开始。

多线程编程概述

线程是在一个进程内部的一个独立的控制流,从概念上讲,它就像是在给定进程内部的一个子进程(或进程的进一步细分)。线程有时被称为控制线程。拥有许多控制线程的应用程序被称为多线程应用程序

在单处理器环境中,线程给人一种多个任务同时运行的感觉。就像进程一样,线程在 CPU 之间快速切换,以便用户看起来它们正在被同时处理(尽管实际上不是)。在共享的多处理器环境中,应用程序中使用线程可以显著加快处理速度,并允许实现并行计算。即使在单处理器系统中,线程实际上(也许出人意料地)可以加快一个进程的速度,因为在等待另一个线程的 I/O 完成时,一个线程可以运行。

执行相同任务的线程可能会同时处于类的类似方法中。如果每个线程都在处理一个不同的数据集(例如,一个不同的this指针,即使是在同一个方法中工作),通常没有必要同步对这些方法的访问。例如,想象s1.EarnPhd();s2.EarnPhD();。在这里,两个独立的实例处于同一个方法中(可能是并发地)。然而,每个方法中处理的数据集是不同的——在第一种情况下,s1将绑定到this;在第二种情况下,s2将绑定到this。这两个实例之间共享的数据很可能没有重叠。然而,如果这些方法正在访问静态数据(即给定类所有实例共享的数据,例如numStudents数据成员),那么对访问共享内存区域的代码的关键部分进行同步将是必需的。传统上,在需要互斥访问代码关键区域的 数据或函数周围添加系统依赖的锁或信号量。

C++中的多线程编程可以通过各种商业或公共领域的多线程库来实现。此外,标准 C++库在多种能力上提供了线程支持,包括使用 std::condition_variable 进行线程同步,std::mutex 确保关键资源的互斥性(通过避免竞争条件),以及 std::semaphore 来模拟资源计数。通过实例化 std::thread 对象并熟练掌握上述功能,我们可以使用已建立的 C++库添加多线程编程。此外,可以将 std::atomic 模板添加到类型中,将其建立为原子类型并确保类型安全的同步。std::exception_ptr 类型允许在协调线程之间传输异常。总的来说,有许多线程库功能需要考虑;这是一个广泛的话题。

多线程编程的细节超出了本书的范围;然而,我们可以讨论本书中可能需要增加以要求使用线程知识的场景。让我们回顾一些那些情况。

多线程编程场景

有许多编程场景可以从使用多线程编程中受益。我们只提及一些扩展了本书中涵盖的思想的例子。

观察者模式当然可以在多线程编程场景中使用!在这些情况下,必须小心处理 ObserverSubjectUpdate()Notify() 方法,以添加同步和锁定机制。

智能指针,例如 shared_ptrweak_ptr,可以在多线程应用程序中使用,并且已经包含了通过引用计数(以及使用原子库方法)来锁定和同步访问共享资源的手段。

通过关联相关的对象可能会在多线程编程或通过共享内存区域中出现。任何通过多线程编程使用共享资源进行访问的时候,都应该使用互斥锁(锁)来确保对这些共享资源的互斥访问。

抛出异常的对象需要相互通信,将需要在捕获块中包含同步或将异常委派给 main() 程序线程。使用工作线程与 main() 程序线程通信是典型设计模式。利用共享内存是存储需要在抛出和捕获异常本身之间协调的线程之间共享的数据的手段。可以使用 std::exception_ptr 实例与 std::current_exception() 一起使用来存储需要共享的实例。这个共享实例(在线程之间)可以使用 std::rethrow_exception() 重新抛给参与线程。

多线程编程本身就是一个迷人的主题,并且需要在 C++ 中安全地使用它之前进行深入理解。我们已经回顾了一些可能补充本书所涵盖内容的线程安全性考虑的区域。强烈建议在向代码中添加多线程编程之前,深入探讨 C++ 中的线程安全性。

接下来,让我们进一步探讨编程指南如何为 C++ 编程增加必要的安全性级别。

利用核心编程指南

编程指南远不止是一套约定,比如指示缩进多少空格或变量的命名规范,函数、类、数据成员和成员函数的命名约定。现代编程指南是组织内部程序员之间的一种契约,旨在创建遵循特定标准的代码,其最大目标是通过对这些共同标准的遵循,提供健壮且易于扩展的代码。简而言之,编程指南中包含的大多数约定都是为了使 C++ 编程更安全。

关于构成 C++ 编程指南的内容,各组织之间可能存在共识差异,但有许多资源可用(包括来自标准委员会的资源)来提供示例和指导。

让我们继续探讨编程指南的基本要素的抽样,然后讨论采用核心指南集,以及理解广泛可用的在 C++ 中安全编程的资源。

检查指南要素

让我们从检查一个典型的 C++ 编程指南中可以遵循的有意义的约定开始。我们在整本书中已经探讨了这些编程问题中的许多,但回顾一些项目对于选择促进 C++ 安全性的约定是有用的。

优先初始化而非赋值

在可能的情况下,始终选择初始化而非赋值。这既更高效也更安全!使用类内初始化或成员初始化列表。在初始化之后使用赋值可能效率较低。例如,想象一个默认构造的成员对象,它只是快速通过构造函数体内的赋值来覆盖其值。利用成员初始化列表通过另一个构造函数初始化这个成员对象会更有效率。

此外,未能为每一块内存赋予初始值可能会在安全性方面给我们带来巨大的代价——在 C++ 中,内存不是干净的,因此将未初始化变量(或数据成员)中的任何内容解释为有效内容是完全不恰当的。访问未初始化的值是未定义的行为。我们真的不知道未初始化的内存中隐藏着什么,但我们知道它绝不是用作初始化器的正确值!

让我们通过一个小程序来回顾首选的初始化。这个例子可以在我们的 GitHub 上找到:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter21/Chp21-Ex5.cpp

class Person
{
private: 
   string firstName; // str mbrs are default constructed so
   string lastName;  // we don't need in-class initializers
   char middleInitial = '\0';  // in-class initialization
   string title;  
protected: 
   void ModifyTitle(const string &); 
public:
   Person() = default;   // default constructor
   Person(const string &, const string &, char, 
          const string &);  
   // use default copy constructor and default destructor
   // inline function definitions
   const string &GetFirstName() const { return firstName; }
   const string &GetLastName() const { return lastName; }
   const string &GetTitle() const { return title; } 
   char GetMiddleInitial() const { return middleInitial; }
};
// With in-class initialization, it often not necessary to
// write the default constructor yourself – there's often
// nothing remaining to initialize!
// alternate constructor
// Note use of member init list to initialize data members
Person::Person(const string &fn, const string &ln, char mi,
               const string &t): firstName(fn),
               lastName(ln), middleInitial(mi), title(t)
{
    // no need to assign values in body of method –
    // initialization has handled everything!
}

检查前面的代码,我们发现Person类使用类内初始化将middleInitial数据成员设置为空字符('\0')。对于Person的每个实例,middleInitial将在调用任何进一步初始化该实例的构造函数之前被设置为空字符。请注意,类中的其他数据成员都是string类型。因为string本身就是一个类,这些数据成员实际上是string类型的成员对象,并将被默认构造,适当地初始化这些字符串成员。

接下来,请注意我们没有提供默认(无参数)构造函数,允许系统提供的默认构造函数为我们链接。类内初始化,加上适当的string成员对象初始化,使得对于新的Person实例没有额外的初始化工作,因此不需要程序员指定的默认构造函数。

最后,请注意我们在Person类的替代构造函数中使用了成员初始化列表。在这里,每个数据成员都使用此方法参数列表中的适当值进行设置。请注意,每个数据成员都是通过初始化设置的,这样在替代构造函数的主体中就不需要任何赋值操作了。

我们前面的代码遵循了流行的代码规范:在可能的情况下,始终选择通过初始化而不是赋值来设置值。知道每个数据成员在构造过程中都有适当的值,这使我们能够提供更安全的代码。初始化也比赋值更高效。

现在,让我们考虑另一个与虚函数相关的核心 C++指南。

选择virtualoverridefinal中的一个

多态是一个美妙的概念,C++通过使用虚函数轻松支持。我们在第七章通过多态利用动态绑定中了解到,关键字virtual用于指示多态操作——一个可能被派生类用首选方法覆盖的操作。派生类没有义务通过提供新方法来覆盖多态操作(虚函数),但可能会发现这样做是有意义的。

当派生类选择用新方法覆盖基类引入的虚函数时,被覆盖的方法可以在方法的签名中使用virtualoverride关键字。然而,在这个被覆盖的(派生类)级别,只使用override是一个约定。

当在层次结构中引入虚函数时,在某个时候可能希望表明某个方法是此操作的最终实现。也就是说,所涉及的操作可能不再被覆盖。我们知道,在层次结构的这个级别上应用final说明符是合适的,以表明给定的方法可能不再被覆盖。尽管我们也可以在这个级别包含关键字virtual,但建议只使用final

总结一下,在指定虚函数时,每个级别只选择一个标签:virtualoverridefinal——即使关键字virtual可以添加以补充overridefinal。这样做可以使当前虚函数是新生成的(virtual)、是虚函数的覆盖方法(override),还是虚函数的最终方法(final)更加清晰。清晰性导致错误发生得少,这有助于使 C++更安全。

让我们通过一个程序段来回顾使用虚函数时的首选关键字用法。完整的示例可以在我们的 GitHub 仓库中找到:

github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/blob/main/Chapter21/Chp21-Ex6.cpp

class Person
{
private: 
    string firstName;
    string lastName;
    char middleInitial = '\0';  // in-class initialization
    string title;  // Mr., Ms., Mrs., Miss, Dr., etc.
protected:
    void ModifyTitle(const string &); 
public:
    Person() = default;   // default constructor
    Person(const string &, const string &, char, 
           const string &); 
    virtual ~Person();  // virtual destructor
    const string &GetFirstName() const 
        { return firstName; } 
    const string &GetLastName() const { return lastName; }
    const string &GetTitle() const { return title; } 
    char GetMiddleInitial() const { return middleInitial; }
    virtual void Print() const; // polymorphic operations
    virtual void IsA() const;   // introduced at this level
    virtual void Greeting(const string &) const;
};
// Assume the non-inline member functions for Person 
// follow and are as we are accustomed to seeing
class Student: public Person
{
private: 
    float gpa = 0.0;   // in-class initialization
    string currentCourse;
    const string studentId; 
    static int numStudents;  // static data member
public:
    Student();  // default constructor
    Student(const string &, const string &, char, 
            const string &, float, const string &, 
            const string &); 
    Student(const Student &);  // copy constructor
    ~Student() override;  // virtual destructor
    void EarnPhD();  
    // inline function definitions
    float GetGpa() const { return gpa; }
    const string &GetCurrentCourse() const
        { return currentCourse; }
    const string &GetStudentId() const 
        { return studentId; }
    void SetCurrentCourse(const string &); // proto. only

    // In the derived class, keyword virtual is optional, 
    // and not currently recommended. Use override instead.
    void Print() const final; // override is optional here
    void IsA() const override;
    // note, we choose not to redefine (override):
    // Person::Greeting(const string &) const
    static int GetNumberStudents(); // static mbr. function
};
// definition for static data member 
int Student::numStudents = 0;  // notice initial value of 0
// Assume the non-inline, non-static member functions for
// Students follow and are as we are accustomed to seeing

在前面的示例中,我们看到我们一直在书中使用的Person类。作为一个基类,请注意,Person指定了多态操作Print()IsA()Greeting(),以及使用virtual关键字的析构函数。这些操作旨在由派生类使用更合适的方法覆盖(不包括析构函数),但如果派生类认为基类的实现是合适的,则不需要覆盖。

在派生类Student中,我们使用更合适的方法覆盖了IsA()。请注意,我们在该函数的签名中使用了override,尽管我们也可以包含virtual。接下来,请注意,我们没有在Student级别覆盖Greeting();我们可以假设Student认为Person中的实现是可以接受的。另外,请注意,析构函数被覆盖以提供销毁链的入口点。回想一下,析构函数不仅会调用派生类的析构函数,还会调用基类的析构函数(隐式地作为派生类析构函数中的最后一行代码),从而确保对象的完整销毁序列能够正确开始。

最后,请注意,在Student类中,Print()函数已被重写为final。尽管我们也可以将override关键字添加到这个函数的签名中,但我们选择根据推荐的编码规范仅使用final

现在,让我们看看典型 C++编程指南中的另一个典型元素,与智能指针相关。

在新代码中优先使用智能指针

我们在这本书中使用了许多本地的(原始的)C++指针,因为你无疑会被要求沉浸到包含大量指针的现有代码中。拥有本地指针的经验和能力,将使你在被要求进入使用本地指针的情况时成为一个更安全的程序员。

然而,出于安全考虑,大多数编程指南都会建议在新建代码中仅使用智能指针。毕竟,它们的使用开销很小,可以帮助消除程序员管理堆内存的许多潜在陷阱。智能指针还有助于异常安全性。例如,异常处理意味着代码的预期流程可能在几乎任何时间被中断,导致使用传统指针时可能发生内存泄漏。智能指针可以减轻一些这种负担,并提供异常安全性。

在原始代码中使用智能指针非常重要,这一点值得重复:在 C++中选择智能指针而不是本地指针,将导致更安全且更容易维护的代码。代码也将更容易编写,消除了许多析构函数的需求,自动阻止不希望的复制和赋值(unique_ptr)等。考虑到这一点,在可能的情况下,始终选择智能指针在新创建的代码中。

我们在这本书中也看到了智能指针和本地指针。现在,你可以选择在你创建的新代码中使用智能指针——这强烈推荐。当然,可能有一些情况下这是不可能的;也许你正在创建与现有本地指针代码高度交互的新代码,需要利用相同的数据结构。尽管如此,在可能的情况下,你可以努力使用智能指针,同时你拥有灵活性和经验来理解大量使用本地指针的现有代码、库和在线示例。

对于安全性来说,还有什么比在你的原始代码中拥有智能指针的能力,并辅以本地指针的知识,只在必要时使用更好呢?

有许多编程指南的例子可以轻松遵循,以使你的代码更安全。上述例子只是许多例子中的一部分,用以说明你将在一组基本 C++编程指南中看到的各种实践。

现在,让我们考虑如何组装或采用核心编程指南,以帮助使我们的代码更安全。

采用编程指南

无论你是自己构建或组装一套编程指南,还是遵循你所在组织管理的一套指南,采用一组核心 C++编程指南对于确保你的代码尽可能安全、健壮至关重要,这转化为更容易维护的代码。

指南应始终随着语言的发展而保持灵活。接下来,让我们考虑寻找核心 C++ 编程指南的资源,以便直接遵循或逐步回顾,以改进你所在组织接受的指南。

在 C++ 中安全编程的资源

在线有许多关于 C++ 编程指南的资源。然而,最重要的资源是 ISO C++ 核心指南,主要由 Bjarne Stroustrup 和 Herb Sutter 组装,可以在以下 GitHub 网址找到:github.com/isocpp/CppCoreGuidelines/blob/master/CppCoreGuidelines.md。他们的共同目标是帮助程序员安全且更有效地使用现代 C++。

选取的市场领域可能需要遵循一定的指南来获得或确保行业内的认证。例如,MISRA 是一套针对 汽车工业软件可靠性协会(Motor Industry Software Reliability Association)的 C++ 编码标准;MISRA 也已被其他行业采纳为标准,例如医疗系统。另一个为嵌入式系统开发的编码标准是 CERT,由 卡内基梅隆大学(Carnegie Mellon University,简称 CMU)开发。CERT 最初是 计算机紧急响应团队(Computer Emergency Response Team)的缩写,现在已成为 CMU 的注册商标。CERT 也被许多金融行业采纳。JSF AV C++(Joint Strike Fighter Air Vehicle C++)是洛克希德·马丁公司(Lockheed Martin)开发的一种 C++ 编码标准,用于航空航天工程领域,以确保安全关键系统的代码无错误。

毫无疑问,你加入的每个组织作为贡献者都将有一套编程指南,供组内所有程序员遵循。如果没有,明智的做法是建议采用一套核心的 C++ 编程指南。毕竟,你需要帮助维护自己的代码以及同事的代码;一套统一和预期的标准将使所有相关人员都能管理这项工作。

摘要

在本附录中,我们通过理解在 C++ 中安全编程的重要性,增加了成为不可或缺的 C++ 程序员的目标。毕竟,我们的主要目标是创建健壮且易于维护的代码。采用安全的编程实践将帮助我们实现这一目标。

我们回顾了书中提到的概念,以及相关思想,最终形成了一套核心编程指南,以确保更安全的编码实践。

首先,我们回顾了智能指针,检查了来自标准 C++ 库的三种类型,即 unique_ptrshared_ptrweak_ptr。我们了解到,这些类通过提供封装来分配和释放堆内存,从而通过我们在经过充分测试的标准库类中的行为来安全地实现 RAII 习语。我们提出了一个指南:在新建代码中始终优先考虑智能指针。

接下来,我们重申了在本书中看到的多种编程实践,我们可以利用这些实践来使我们的编码更加安全。例如,优先使用 for-each 风格的循环和 auto 关键字来保证类型安全。此外,使用 STL 容器而非较脆弱的本地机制,并在需要时为数据和方法添加 const 限定符以确保只读访问。这些实践(在许多实践中)可以帮助确保我们的代码尽可能安全。

接下来,我们介绍了 C++ 的多线程编程,并回顾了之前我们看到的可能从使用线程中受益的编程场景。我们还前瞻性地查看了一下标准 C++ 库中支持多线程编程的类,包括那些提供同步、互斥锁、信号量和创建原子类型的类。

最后,我们探讨了编程指南的要点,以便更好地理解在 C++ 核心编程指南中可能有益的规则。例如,我们回顾了优先初始化而非赋值,关于 virtualoverridefinal 关键字的虚拟函数使用,以及本章之前探讨的主题。我们讨论了采用一套全面的 C++ 核心编程指南的重要性,以及查找作为行业标准使用的示例指南的资源。

在应用本书中涵盖的许多特性时,了解如何使 C++ 更加安全无疑会使你成为一个更有价值的程序员。你现在拥有了核心语言技能,并且对 C++ 中的面向对象编程(基本概念及其在 C++ 中的实现方式,无论是直接语言支持还是使用编程技术)有了非常坚实的理解。我们通过异常处理、友元、运算符重载、模板、STL 基础和测试 OO 类和组件等知识丰富了你的技能。我们还接受了核心设计模式,通过综合编程示例深入研究每个模式。最后在本章中,我们回顾了如何通过在每个可用的机会选择采用更安全的编程实践来安全地组合你所学的知识。

当我们一起结束我们的附加章节时,你现在准备好独自踏上旅程,将 C++ 应用于许多新的和现有的应用。你准备好创建安全、健壮且易于维护的代码。我真诚地希望你对 C++ 的兴趣和我一样浓厚。再次,让我们开始编程吧!

评估

每章问题的编程解决方案可以在我们的 GitHub 仓库中找到,网址如下:github.com/PacktPublishing/Deciphering-Object-Oriented-Programming-with-CPP/tree/main。每个完整的程序解决方案都可以在我们的 GitHub 仓库的“评估”子目录中找到,然后在适当的章节标题(子目录,例如Chapter01)下,在一个与章节编号相对应的文件中,后面跟着一个连字符,然后是当前章节中的解决方案编号。例如,第一章,“理解基本的 C++假设”中的问题 3的解决方案可以在上述 GitHub 目录下的Assessments/Chapter01子目录中的Chp1-Q3.cpp文件中找到。

非编程问题的书面回答可以在以下章节中找到,按章节组织,以及在上文提到的 GitHub 中相应章节的“评估”子目录中。例如,Assessments/Chapter01/Chp1-WrittenQs.pdf将包含对第一章,“理解基本的 C++假设”的非编程解决方案的答案。如果一个练习既有编程部分又有对程序的后续问题,那么后续问题的答案可以在下一节(以及上文提到的.pdf文件)中找到,也可以在 GitHub 中编程解决方案顶部的注释中找到(因为可能需要审查解决方案以完全理解后续问题的答案)。

第一章,理解基本的 C++假设

  1. 在不希望光标移动到下一行进行输出的情况下,flush可能比endl更有用,用于清除与cout关联的缓冲区的内容。回想一下,endl操作符只是一个换行符加上缓冲区刷新。

  2. 对于变量选择前置递增(++i)还是后置递增(i++),当与复合表达式一起使用时,会对代码产生影响。一个典型的例子是result = array[i++];result = array[++i];。在后置递增(i++)的情况下,array[i]的内容将被分配给result,然后i递增。在前置递增的情况下,i首先递增,然后result将具有array[i]的值(即使用i的新值作为索引)。

  3. 请参阅 GitHub 仓库中的Assessments/Chapter01/Chp1-Q3.cpp

第二章,添加语言必要性

  1. 函数的签名是函数名加上其类型和参数数量(没有返回类型)。这与名称混淆有关,因为签名帮助编译器为每个函数提供一个唯一的内部名称。例如,void Print(int, float); 可能具有混淆后的名称 Print_int_float();。这通过为每个函数提供一个唯一的名称来简化重载函数,使得在调用时,可以通过内部函数名称明显地知道正在调用哪个函数。

  2. a – d. 请参阅 GitHub 仓库中的 Assessments/Chapter02/Chp2-Q2.cpp

第三章,间接寻址:指针

  1. a – f. 请参阅 GitHub 仓库中的 Assessments/Chapter03/Chp3-Q1.cpp

d. (后续问题)Print(Student) 比重载版本 Print(const Student *) 效率低,因为该函数的初始版本在栈上传递整个对象,而重载版本只在栈上传递指针。

  1. 假设我们有一个指向类型为 Student 的对象的现有指针,例如:Student *s0 = new Student; (这个 Student 尚未用数据初始化)

a. const Student *s1; (不需要初始化)

b. Student *const s2 = s0; (需要初始化)

c. const Student *const s3 = s0; (也需要初始化)

  1. 将类型为 const Student * 的参数传递给 Print() 允许将指向 Student 的指针传递给 Print() 以提高速度,但指向的对象不能被解引用和修改。然而,将 Student * const 作为 Print() 的参数传递是没有意义的,因为指针的副本将被传递给 Print()。将这个副本标记为 const(意味着不允许改变指针指向)将没有意义,因为不允许改变指针副本对原始指针本身没有影响。原始指针在函数内部地址被改变的风险中从未处于危险之中。

  2. 在许多编程场景中可能会使用动态分配的 3-D 数组。例如,如果图像存储在 2-D 数组中,一组图像可能存储在 3-D 数组中。拥有动态分配的 3-D 数组允许从文件系统中读取任意数量的图像并将其存储在内部。当然,在分配 3-D 数组之前,你需要知道将要读取多少图像。例如,一个 3-D 数组可能包含 30 张图像,其中 30 是第三维,用于收集一组图像。为了概念化一个 4-D 数组,你可能希望组织上述 3-D 数组的集合。

例如,也许你有一组 31 张 1 月份的图片。这组 1 月份的图片是一个三维数组(二维用于图片,第三维用于组成 1 月份的 31 张图片的集合)。你可能希望对每个月都做同样的事情。而不是为每个月的图片集合分别设置单独的三维数组变量,我们可以创建一个第四维来收集一年的数据到一个集合中。第四维将包含一年的 12 个月份中的一个元素。那么五维数组呢?你可以通过将第五维作为收集不同年份数据的方式扩展这个图像概念,例如收集一个世纪的图片(第五维)。现在我们有按世纪组织的图片,然后按年、按月、按图片(需要前两个维度)组织。

第四章,间接寻址:引用

  1. a – c. 请参阅 GitHub 仓库中的Assessments/Chapter04/Chp4-Q1.cpp

c. (后续问题)指针变量不仅需要调用接受Student指针的ReadData(Student *)版本,引用变量也不需要仅调用接受Student引用的ReadData(Student &)版本。例如,指针变量可以用*解引用然后调用接受引用的版本。同样,引用变量可以用&取其地址然后调用接受指针的版本(尽管这不太常见)。你只需确保数据类型与你要传递的和函数期望的类型相匹配。

第五章,详细探索类

  1. a – e. 请参阅 GitHub 仓库中的Assessments/Chapter05/Chp5-Q1.cpp

第六章,使用单继承实现层次结构

  1. a – d. 请参阅 GitHub 仓库中的Assessments/Chapter06/Chp6-Q1.cpp

  2. a – c. (可选)请参阅 GitHub 仓库中的Chapter06/Assessments/Chp6-Q2.cpp

第七章,利用多态实现动态绑定

  1. a – e. 请参阅 GitHub 仓库中的Assessments/Chapter07/Chp7-Q1.cpp

第八章,精通抽象类

  1. a – d. 请参阅 GitHub 仓库中的Assessments/Chapter08/Chp8-Q1.cpp

e. 根据你的实现,你的Shape类可能被视为接口类,也可能不是。如果你的实现是一个不包含数据成员且只包含抽象方法(纯虚函数)的抽象类,那么你的Shape实现被视为接口类。然而,如果你的Shape类在派生类中通过重写的Area()方法计算了area后将其作为数据成员存储,那么它就只是一个抽象基类。

第九章,探索多重继承

  1. 请参阅 GitHub 仓库中的Assessments/Chapter09/Chp9-Q1.cpp

a. 有一个LifeForm子对象。

b. LifeForm构造函数和析构函数各被调用一次。

c. 如果从Centaur构造函数的成员初始化列表中移除了对LifeForm(1000)的替代构造函数的指定,则将调用LifeForm的默认构造函数。

  1. 请参阅 GitHub 仓库中的Assessments/Chapter09/Chp9-Q2.cpp

a. 有两个LifeForm子对象。

b. LifeForm构造函数和析构函数各自被调用两次。

第十章,实现关联、聚合和组合

  1. 请参阅 GitHub 仓库中的Assessments/Chapter10/Chp10-Q1.cpp

(后续问题)一旦您重载了一个接受University &作为参数的构造函数,这个版本可以通过首先在构造函数调用中对University指针进行解引用(以创建一个可引用的对象)来使用University *调用。

  1. a – f. 请参阅 GitHub 仓库中的Assessments/Chapter10/Chp10-Q2.cpp

  2. a – b. (可选)请参阅 GitHub 仓库中的Assessments/Chapter10/Chp10-Q3.cpp

第十一章,处理异常

  1. a – c. 请参阅 GitHub 仓库中的Assessments/Chapter11/Chp11-Q1.cpp

第十二章,友元和运算符重载

  1. 请参阅 GitHub 仓库中的Assessments/Chapter12/Chp12-Q1.cpp

  2. 请参阅 GitHub 仓库中的Assessments/Chapter12/Chp12-Q2.cpp

  3. 请参阅 GitHub 仓库中的Assessments/Chapter12/Chp12-Q3.cpp

第十三章,使用模板

  1. a – b. 请参阅 GitHub 仓库中的Assessments/Chapter13/Chp13-Q1.cpp

  2. 请参阅 GitHub 仓库中的Assessments/Chapter13/Chp13-Q2.cpp

第十四章,理解 STL 基础

  1. a – b. 请参阅 GitHub 仓库中的Assessments/Chapter14/Chp14-Q1.cpp

  2. 请参阅 GitHub 仓库中的Assessments/Chapter14/Chp14-Q2.cpp

  3. 请参阅 GitHub 仓库中的Assessments/Chapter14/Chp14-Q3.cpp

  4. 请参阅 GitHub 仓库中的Assessments/Chapter14/Chp14-Q4.cpp

第十五章,测试类和组件

  1. a. 如果您的类包含一个(用户指定的)默认构造函数、拷贝构造函数、重载的赋值运算符和一个虚析构函数,则您的类遵循正交规范类形式。如果它们还包括移动拷贝构造函数和重载的移动赋值运算符,则您的类还遵循扩展规范类形式。

b. 如果您的类遵循规范类形式并确保类的所有实例都有完全构造的手段,则您的类将被认为是健壮的。测试一个类可以确保其健壮性。

  1. a – c. 请参阅 GitHub 仓库中的Assessments/Chapter15/Chp15-Q2.cpp

  2. 请参阅 GitHub 仓库中的Assessments/Chapter15/Chp15-Q3.cpp

第十六章,使用观察者模式

  1. a – b. 请参阅 GitHub 仓库中的Assessments/Chapter16/Chp16-Q1.cpp

  2. 其他可能容易融入观察者模式的例子包括任何需要客户接收他们所希望的后备产品通知的应用程序。例如,许多人可能希望接种新冠疫苗,并希望在疫苗分发点的等待名单上。在这里,一个VaccineDistributionSite(感兴趣的主体)可以继承自Subject并包含一个Person对象列表,其中Person继承自ObserverPerson对象将包含一个指向VaccineDistributionSite的指针。一旦在某个VaccineDistributionSite(即,发生了分发事件)有足够的疫苗供应,就可以调用Notify()来更新Observer实例(等待名单上的人)。每个Observer都将收到一个Update(),这将允许那个人安排预约。如果Update()返回成功并且Person已经安排了预约,Observer可以使用Subject从等待名单中释放自己。

第十七章,应用工厂模式

  1. a – b. 请参阅 GitHub 仓库中的Assessments/Chapter17/Chp17-Q1.cpp

  2. 其他可能容易融入工厂方法模式的例子包括许多类型的应用程序,这些应用程序可能需要根据构造时提供的特定值实例化各种派生类。例如,一个工资单应用程序可能需要各种类型的Employee实例,如ManagerEngineerVice-President等。工厂方法可以提供一种根据在雇佣Employee时提供的信息实例化各种类型Employee的方法。工厂方法模式是一种可以应用于许多类型应用程序的模式。

第十八章,应用适配器模式

  1. a – b. 请参阅 GitHub 仓库中的Assessments/Chapter18/Chp18-Q1.cpp

  2. 其他可能容易融入适配器模式的例子包括许多将现有经过良好测试的非 OO 代码重新用于提供 OO 接口(即,适配器类型的包装器)的例子。其他例子包括创建一个适配器将以前使用的类转换为当前所需的类(再次使用重用先前创建和经过良好测试的组件的想法)。一个例子是将以前用于表示汽油发动机汽车的Car类适配为一个表示ElectricCar的类。

第十九章,使用单例模式

  1. a – c. 请参阅 GitHub 仓库中的Assessments/Chapter19/Chp19-Q1.cpp

  2. 我们不能将Singleton中的static instance()方法标记为虚拟并在President中重写它,仅仅是因为静态方法永远不能是虚拟的。它们是静态绑定的,并且永远不会接收一个this指针。此外,签名可能需要不同(没有人喜欢意外隐藏函数的情况)。

  3. 其他可能容易融入单例模式的例子包括创建一个公司的单例CEO、一个国家的单例TreasuryDepartment,或者一个国家的单例Queen。这些单例实例都提供了建立注册表以跟踪多个单例对象的机会。也就是说,许多国家可能只有一个Queen。在这种情况下,注册表不仅允许每个对象类型只有一个单例,而且允许每个其他限定符(如国家)只有一个单例。这是一个罕见的情况,其中给定类型的多个单例对象可以出现(但总是受控数量的对象)。

第二十章,使用 pImpl 模式去除实现细节

  1. 请参阅 GitHub 仓库中的Assessments/Chapter20/Chp20-Q1.cpp

  2. 请参阅 GitHub 仓库中的Assessments/Chapter20/Chp20-Q2.cpp

(后续问题)在本章中,简单地从采用 pImpl 模式的Person类继承Student类不会带来任何物流困难。此外,修改Student类以也采用 pImpl 模式并使用唯一指针更具挑战性。可能遇到各种困难,包括处理内联函数、向下转型、避免显式调用底层实现,或者需要回指针来帮助调用虚函数。请参阅在线解决方案以获取详细信息。

  1. 其他可能容易融入 pImpl 模式以实现相对独立实现的例子包括创建通用的 GUI 组件,例如WindowScrollbarTextbox等,用于各种平台(派生类)。实现细节可以轻松隐藏。另一个例子可能是开发者希望隐藏在头文件中可能看到的实现细节的专有商业类。
posted @ 2025-10-26 08:51  绝不原创的飞龙  阅读(7)  评论(0)    收藏  举报