C---面向对象编程揭秘-全-

C++ 面向对象编程揭秘(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

公司需要利用 C的速度。然而,面向对象的软件设计会导致更容易修改和维护的代码。了解如何将 C作为面向对象的语言使用是至关重要的。在 C中编程并不能保证面向对象编程-必须理解面向对象的概念以及它们如何映射到 C语言特性以及面向对象编程技术。此外,程序员还希望掌握超出面向对象编程的额外技能,以使代码更通用、更健壮,并采用经过充分测试的创造性解决方案,这些解决方案可以在流行的设计模式中找到。

学习如何将 C作为面向对象语言使用的程序员将成为有价值的 C开发人员。一个没有面向对象理解和技能的 C程序员,其代码将难以被其他人维护、修改或理解。成为 C中的面向对象程序员是公司需要利用这种语言的宝贵技能。

本书详细解释了基本的面向对象概念,并配有实际的代码示例,通常还附有图表,以便您真正理解事物的工作原理和原因。自我评估问题可用于测试您的技能。

本书首先提供了必要的技能构建模块(可能不是面向对象的),这些模块为面向对象的基本知识打下了基础。接下来,将描述面向对象的概念,并配以语言特性和编码技巧,以便您能够成功地将 C++作为面向对象的语言使用。此外,还添加了更高级的技能,包括友元函数/类、运算符重载、模板(用于构建更通用的代码)、异常处理(用于构建健壮的代码)、STL 基础,以及设计模式和习语。

通过本书,您将了解基本和高级的面向对象概念,以及如何在 C中实现这些概念。您将学会不仅如何使用 C,还要如何将其作为面向对象的语言使用。此外,您还将了解如何使代码更健壮、更易于维护,以及如何在编程中使用经过充分测试的设计模式。

这本书适合谁

本书的目标读者是专业程序员以及熟练的大学生,他们希望了解如何利用 C作为面向对象编程语言来编写健壮、易于维护的代码。本书假设读者是程序员,但不一定熟悉 C。早期章节简要回顾了核心语言特性,并作为主要面向对象编程章节、高级特性和设计模式的基石。

本书涵盖的内容

[第一章],理解基本的 C++假设,提供了本书中假定的基本语言特性的简要回顾,现有程序员可以快速掌握。

[第二章],添加语言必需品,回顾了关键的非面向对象特性,这些特性是 C++的基本构建模块:const 修饰符、函数原型(默认值)和函数重载。

[第三章],间接寻址-指针,回顾了 C++中的指针,包括内存分配/释放、指针使用/解引用、在函数参数中的使用和 void *。

[第四章],间接寻址-引用,介绍了引用作为指针的替代方法,包括初始化、函数参数/返回值和 const 修饰。

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

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

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

第八章《掌握抽象类》解释了抽象类的面向对象概念,它们使用纯虚拟函数进行实现,接口的面向对象概念以及如何实现它,以及在公共继承层次结构中进行向上和向下转换。

第九章《探索多重继承》详细介绍了如何使用多重继承以及在面向对象设计中的争议。它涵盖了虚基类、菱形继承结构,以及通过检查鉴别器的面向对象概念来考虑替代设计的时机。

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

第十一章《处理异常》解释了如何通过考虑许多异常情况来trythrowcatch异常。它还展示了如何扩展异常处理层次结构。

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

第十三章《使用模板》详细介绍了模板函数和类,以使某些类型的代码通用化以适用于任何数据类型。它还展示了如何通过运算符重载使选定的代码更通用,以进一步支持模板的使用。

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

第十五章《测试类和组件》说明了使用经典类形式进行面向对象测试方法,用于测试类的驱动程序,并展示了如何通过继承、关联和聚合来测试相关类,并使用异常处理来测试类。

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

第十七章应用工厂模式,介绍了工厂方法模式,并展示了其在有或没有对象工厂的情况下的实现。还比较了对象工厂和抽象工厂。

第十八章应用适配器模式,探讨了适配器模式,提供了使用继承与关联来实现该模式的策略和示例。此外,它演示了一个包装类作为简单的适配器。

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

第二十章使用 pImpl 模式去除实现细节,描述了 pImpl 模式,以减少代码中的编译时间依赖关系。使用了独特指针来探讨了详细的实现。还探讨了与该模式相关的性能问题。

充分利用本书

假设您有一个当前的 C编译器可用。您可以尝试许多在线代码示例!您可以使用任何 C编译器;但建议使用 17 版或更高版本。所呈现的代码将符合 C20 标准,但在 17 版中同样有效。请至少从gcc.gnu.org下载 g

请记住,虽然 C++有一个 ISO 标准,但一些编译器会有所不同,并以微小的差异解释标准。

如果您使用的是本书的数字版本,我们建议您自己输入代码或通过 GitHub 存储库(链接在下一节中提供)访问代码。这样做将有助于避免与复制和粘贴代码相关的任何潜在错误。

强烈建议您在阅读本书时尝试编码示例。完成评估将进一步加强您对每个新概念的理解。

下载示例代码文件

您可以从www.packt.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support并注册,以便直接通过电子邮件接收文件。

该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP。如果代码有更新,将在现有的 GitHub 存储库上进行更新。

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

下载彩色图片

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图片。您可以在此处下载:

static.packt-cdn.com/downloads/9781839218835_ColorImages.pdf

实战代码

请访问以下链接查看 CiA 视频:bit.ly/2P1UXlI

使用的约定

本书中使用了许多文本约定。

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。以下是书中的一个例子:"回顾我们前面的main()函数,我们首先创建一个STLlist,其中包含list<Humanoid *> allies;。"

代码块或程序段设置如下:

char name[10] = "Dorothy"; 
float grades[20];  
grades[0] = 4.0;

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

cout << "Hello " << name << flush;
cout << ". GPA is: " << setprecision(3) << gpa << endl;

任何命令行输入或输出都将按如下方式编写:

Ms. Giselle R. LeBrun
Dr. Zack R. Moon
Dr. Gabby A. Doone

粗体:表示一个新术语,一个重要的词,或者你在屏幕上看到的词。例如,菜单或对话框中的单词会以这种方式出现在文本中。这里有一个例子:“pImpl 模式(pointer to Implementation idiom)是一种结构设计模式,它将类的实现与其公共接口分离开来。”

提示或重要说明

会以这种方式出现。

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

本节的目标是确保您在构建即将到来的 C面向对象编程技能之前具有扎实的非面向对象 C技能背景。这是本书最短的部分,旨在快速让您适应面向对象编程和更高级的书籍章节。

第一章快速回顾了本书中所假设的先前技能:基本语言语法,循环结构,运算符,函数使用,用户定义类型基础(结构体,typedef 和类基础,枚举),以及命名空间基础。接下来的章节讨论了 const 限定变量,函数原型,带有默认值的原型,以及函数重载。

接下来的章节涵盖了使用指针进行间接寻址,介绍了 new()和 delete()来分配基本类型的数据,动态分配 1、2 和 N 维数组,使用 delete 管理内存,将参数作为函数参数传递,以及使用 void 指针。本节以一章结束,介绍了使用引用进行间接寻址,将带您回顾引用基础,引用现有对象,以及作为函数参数。

本节包括以下章节:

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

  • 第二章*,添加语言必需品*

  • 第三章*,间接寻址 - 指针*

  • 第四章*,间接寻址 - 引用*

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

本章将简要介绍 C的基本语言语法、结构和特性,这些您应该已经熟悉了,无论是来自 C、C、Java 或类似语言的基本语法。这些核心语言特性将被简要回顾。如果在完成本章后这些基本语法技能对您来说不熟悉,请先花时间探索更基本的基于语法的 C++文本,然后再继续阅读本书。本章的目标不是详细教授每个假定的技能,而是简要提供每个基本语言特性的概要,以便您能够快速回忆起应该已经掌握的技能。

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

  • 基本语言语法

  • 基本输入/输出

  • 控制结构、语句和循环

  • 运算符

  • 函数基础

  • 用户定义类型基础

  • 命名空间基础

通过本章结束时,您将对您应该熟练掌握的非常基本的 C语言技能进行简要回顾。这些技能将是成功进入下一章所必需的。因为大多数这些特性不使用 C的面向对象特性,我将尽量避免使用面向对象的术语,并在我们进入本书的面向对象部分时引入适当的面向对象术语。

技术要求

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

完整程序示例的在线代码可以在以下 GitHub URL 找到:github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter01。每个完整程序示例都可以在 GitHub 存储库中找到,位于相应章节标题(子目录)下的文件中,文件名由章节号和当前章节中的示例号组成。例如,本章的第一个完整程序可以在子目录Chapter01中的名为Chp1-Ex1.cpp的文件中找到,位于上述 GitHub 目录中。

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

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

在本节中,我们将简要回顾基本的 C语法。我们假设您要么是具有非面向对象编程技能的 C程序员,要么是在 C、Java 或类似的强类型检查语言中编程过,并且熟悉相关语法。您也可能是一个长期从事专业编程的程序员,能够快速掌握另一种语言的基础知识。让我们开始我们的简要回顾。

变量声明和标准数据类型

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

  • int:用于存储整数

  • float:用于存储浮点值

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

  • char:用于存储单个字符

  • bool:用于布尔值 true 或 false

以下是使用上述标准数据类型的一些简单示例:

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"; 
float grades[20];  
grades[0] = 4.0;

上面注意到,第一个数组name包含 10 个char元素,它们被初始化为字符串字面值"Dorothy"中的七个字符,后面跟着空字符('\0')。数组目前有两个未使用的元素。可以使用name[0]name[9]来单独访问数组中的元素,因为 C++中的数组是从零开始的。同样,上面的数组,由变量grades标识,有 20 个元素,没有一个被初始化。在初始化或赋值之前访问任何数组值都可以包含任何值;对于任何未初始化的变量都是如此。注意,在声明数组grades后,它的零元素被赋值为4.0

字符数组经常被概念化为字符串。许多标准字符串函数存在于诸如<cstring>的库中。如果要将字符数组作为字符串处理,应该以空字符结尾。当用字符数组的字符串初始化时,空字符会被自动添加。然而,如果通过赋值逐个添加字符到数组中,那么程序员就需要在数组中添加空字符('\0')作为最后一个元素。让我们看一些基本的例子:

char book1[20] = "C++ Programming":
char book2[25];
strcpy(book2, "OO Programming with C++");
strcmp(book1, book2);
length = strlen(book2);

上面,第一个变量book1被声明为长度为 20 个字符,并初始化为字符串字面值"C++ Programming"。接下来,变量book2被声明为长度为 25 个字符的数组,但没有用值初始化。然后,使用<cstring>中的strcpy()函数将字符串字面值"OO Programming with C++"复制到变量book2中。注意,strcpy()将自动添加空字符到目标字符串。在下一行,也来自<cstring>strcmp()函数用于按字典顺序比较变量book1book2的内容。该函数返回一个整数值,可以存储在另一个变量中或用于比较。最后,使用strlen()函数来计算book2中的字符数(不包括空字符)。

注释风格

C++中有两种注释风格:

  • /* */风格提供了跨越多行代码的注释。这种风格不能与同一风格的其他注释嵌套。

  • //风格的注释提供了一个简单的注释,直到当前行的末尾。

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

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

基本 I/O 回顾

在这一部分,我们将简要回顾使用键盘和显示器进行简单基于字符的输入和输出。还将简要介绍简单的操作符,以解释 I/O 缓冲区的基本机制,并提供基本的增强和格式化。

iostream 库

在 C++中,最简单的输入和输出机制之一是使用<iostream>库。头文件<iostream>包含了cincoutcerr的数据类型定义,通过包含std命名空间来使用。<iostream>库简化了简单的 I/O:

  • cin可以与提取运算符>>一起用于输入

  • cout可以与插入运算符<<一起用于输出

  • cerr也可以与插入运算符一起使用,但用于错误

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

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter01/Chp1-Ex1.cpp

#include <iostream>
using namespace std;
int main()
{
    char name[20];
    int age;
    cout << "Please enter a name and an age: ";
    cin >> name >> age;
    cout << "Hello " << name;
    cout << ". You are " << age << " years old." << endl;
    return 0;
}

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

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

接下来,我们向用户打印出一个问候语"Hello",然后是输入的姓名,再然后是他们的年龄,从第二个用户输入中获取。这一行末尾的endl既将换行符'\n'放入输出缓冲区,又确保输出缓冲区被刷新 - 更多内容请看下文。return 0;声明只是将程序退出状态返回给编程外壳,这里是值0。请注意,main()函数指示了一个int类型的返回值,以确保这是可能的。

基本 iostream 操纵器

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

  • endl: 将换行符放入与cout相关的缓冲区,然后刷新缓冲区

  • flush: 清除输出流的内容

  • setprecision(int): 设置浮点数精度

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

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

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

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter01/Chp1-Ex2.cpp

#include <iostream>
#include <iomanip>
using namespace std;
int main()
{
    char name[20];
    float gpa;   // grade point average
    cout << "Please enter a name and a gpa: "; 
    cin >> setw(20) >> name >> gpa;
    cout << "Hello " << name << flush;
    cout << ". GPA is: " << setprecision(3) << gpa << endl;
    return 0;
}

在这个例子中,首先注意到包含了<iomanip>头文件。还要注意到,setw(20)用于确保我们不会溢出名字变量,它只有 20 个字符长;setw()会自动减去一个提供的大小,以确保有空间放置空字符。注意第二个输出行上使用了flush - 这里不需要刷新输出缓冲区;这个操纵器只是演示了如何应用flush。在最后一个cout输出行上,注意使用了setprecision(3)来打印浮点数gpa。三位精度包括小数点和小数点右边的两位。

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

重新审视控制结构、语句和循环

C++有各种控制结构和循环结构,允许非顺序程序流。每个都可以与简单或复合语句配对。简单语句以分号结束;更复杂的语句则用一对大括号{}括起来。在本节中,我们将重新讨论各种类型的控制结构(ifelse ifelse)和循环结构(whiledo whilefor),以回顾代码中非顺序程序流的简单方法。

控制结构:if,else if 和 else

使用ifelse ifelse进行条件语句可以与简单语句或一组语句一起使用。请注意,if子句可以在没有后续else ifelse子句的情况下使用。实际上,else if实际上是else子句的一种简化版本,其中包含一个嵌套的if子句。实际上,开发人员将嵌套使用展平为else if格式,以提高可读性并节省多余的缩进。让我们看一个例子:

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter01/Chp1-Ex3.cpp

#include <iostream>
using namespace std;
int main()
{
    int x;
    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++中,任何非零值都被视为 true。因此,例如,测试if (x)会暗示x不等于零 - 无需写if (x !=0),除非可能是为了可读性。

循环结构:while,do while 和 for 循环

C++有几种循环结构。让我们花点时间来回顾每种样式的简短示例,从whiledo while循环结构开始。

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter01/Chp1-Ex4.cpp

#include <iostream>
using namespace std;
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循环时,进入循环的条件必须在每次进入循环体之前求值为 true。然而,使用do while循环时,保证第一次进入循环体 - 然后在再次迭代循环体之前求值条件。在上面的示例中,whiledo while循环都执行 10 次,每次打印变量i的值为 0-9。

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

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter01/Chp1-Ex5.cpp

#include <iostream>
using namespace std;
int main()
{
    int i;
    for (i = 0; i < 10; i++) 
        cout << i << endl;
    for (int j = 0; j < 10; j++)
        cout << j << endl;
    return 0;
}

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

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

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter01/Chp1-Ex6.cpp

#include <iostream>
using namespace std;
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的值从 0 到 9 执行 10 次。对于每个i的值,内部循环将执行 10 次,j的值从 0 到 9。请记住,使用for循环时,循环控制变量会在循环结构内部自动递增i++j++。如果使用了while循环,程序员需要记住在每个这样的循环体的最后一行递增循环控制变量。

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

回顾 C++运算符

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

表 1.1 - 二元运算符

表 1.1 - 二元运算符

在上述二元运算符列表中,注意到许多运算符在与赋值运算符=配对时具有“快捷”版本。例如,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。然后我们看到等号运算符==用于将分数与 100 进行比较。最后,我们看到了三元运算符?:的示例,用于替换简单的if-else语句。值得注意的是,一些程序员不喜欢使用?:,但总是有趣的回顾其使用示例。

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

重新审视函数基础知识

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

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

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

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter01/Chp1-Ex7.cpp

#include <iostream>
using namespace std;
int minimum(int a, int b)
{
    if (a < b)
        return a;
    else
        return b;
}
int main()
{
    int x, y;
    cout << "Enter two integers: ";
    cin >> x >> y;
    cout << "The minimum is: " << minimum(x, y) << endl;
    return 0;
}

在上面的简单示例中,首先定义了一个minimum()函数。它的返回类型是int,它接受两个整数参数:形式参数ab。在main()函数中,使用实际参数xy调用了minimum()。在cout语句中允许调用minimum(),因为minimum()返回一个整数值;这个值随后传递给提取运算符(<<),与打印一起使用。实际上,字符串"The minimum is: "首先被放入与cout关联的缓冲区中,然后是调用函数minimum()的返回值。然后输出缓冲区被endl刷新(它首先在刷新之前将换行符放入缓冲区)。

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

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

这些问题在历史上已经通过在将调用函数的文件顶部包含函数的前向声明来解决。前向声明由函数返回类型、函数名称和类型以及参数数量组成。在 C++中,前向声明已经得到改进,而被称为函数原型。由于围绕函数原型存在许多有趣的细节,这个主题将在下一章中得到合理详细的介绍。

当我们在本书的面向对象部分(第五章详细探讨类,以及更多)中学习时,我们将了解到有关函数的许多更多细节和相当有趣的特性。尽管如此,我们已经充分回顾了前进所需的基础知识。接下来,让我们继续我们的 C++语言回顾,学习用户定义类型。

回顾用户定义类型的基础

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

struct

C++结构在其最简单的形式中可以用来将共同的数据元素收集在一个单一的单元中。然后可以声明复合数据类型的变量。点运算符用于访问每个结构变量的特定成员。这是以最简单方式使用的结构:

https://github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter01/Chp1-Ex8.cpp

#include <iostream>
#include <cstring>
using namespace std;
struct student
{
    char name[20];
    float semesterGrades[5];
    float gpa;
};
int main()
{
    student s1;
    strcpy(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声明了用户定义类型student。类型student有三个字段或数据成员:namesemesterGradesgpa。在main()函数中,声明了一个类型为 student 的变量s1;点运算符用于访问变量的每个数据成员。由于在 C中,结构体通常不用于面向对象编程,因此我们还不会介绍与其使用相关的重要面向对象术语。值得注意的是,在 C中,标签student也成为类型名称(与 C 中需要在变量声明之前使用struct一词不同)。

typedef

typedef可以用于为数据类型提供更易记的表示。在 C中,使用struct时相对不需要typedef。在 C 中,typedef允许将关键字struct和结构标签捆绑在一起,创建用户定义的类型。然而,在 C中,由于结构标签自动成为类型,因此对于struct来说,typedef变得完全不必要。Typedefs 仍然可以与标准类型一起使用,以增强代码的可读性,但在这种情况下,typedef 并不像struct那样用于捆绑数据元素。让我们看一个简单的 typedef:

typedef float dollars; 

在上面的声明中,新类型dollars可以与类型float互换使用。展示结构体的古老用法并不具有生产力,因此让我们继续前进,看看 C++中最常用的用户定义类型,即class

class

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

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter01/Chp1-Ex9.cpp

#include <iostream>
#include <cstring>
using namespace std;
class Student
{
public:
    char name[20];
    float semesterGrades[5];
    float gpa;
};
int main()
{
    Student s1;
    strcpy(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这样的数据类型的首字母大写是典型的。我们将看到类具有丰富的特性,是面向对象编程的基本组成部分。我们将介绍新的术语,例如实例,而不是变量。然而,本节只是对假定技能的复习,因此我们需要等待才能了解语言的令人兴奋的面向对象特性。剧透警告:所有类将能够做的美妙事情也适用于结构体;然而,我们将看到,从风格上讲,结构体不会被用来举例说明面向对象编程。

enum

枚举类型可以用来记忆地表示整数列表。除非另有初始化,枚举中的整数值从零开始,并在整个列表中递增一。两个枚举类型不能使用相同的枚举器名称。现在让我们看一个例子:

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter01/Chp1-Ex10.cpp

#include <iostream>
using namespace std;
enum day {Sunday,  Monday, Tuesday, Wednesday, Thursday,
          Friday, Saturday};
enum workDay {Mon = 1, Tues, Wed, Thurs, Fri};
int main()
{
    day birthday = Monday;
    workDay payday = Fri;
    cout << "Birthday is " << birthday << endl;
    cout << "Payday is " << payday << endl;
    return 0;
}

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

现在我们已经重新访问了 C++中的简单用户定义类型,包括structtypedefclassenum,我们准备继续审查我们下一个语言必需品,即namespace

命名空间基础回顾

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

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter01/Chp1-Ex11.cpp

#include <iostream>
using namespace std;
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 member, total
    cout << "total " << total << "\n";
    cout << "DataTypes::total " << DataTypes::total << endl;
    return 0;
}

在上面的第二行代码中,我们使用关键字using表示我们想要使用或激活std命名空间。我们可以利用using来打开包含有用类的现有库;关键字using激活给定库可能属于的命名空间。接下来在代码中,使用namespace关键字创建了一个名为DataTypes的用户创建的命名空间。在这个命名空间中存在一个变量total和两个类定义:LinkListStack。在这个命名空间之后,创建了第二个命名空间AbstractDataTypes,其中包括两个类定义:StackQueue。此外,命名空间AbstractDataTypes通过第二次namespace定义的出现增加了一个变量total和一个Tree的类定义。

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

接下来,声明了一个类型为LinkList的变量,其定义来自DataTypes命名空间。接下来声明了一个类型为Stack的变量;虽然两个命名空间都有Stack类的定义,但由于只激活了一个Stack,所以没有歧义。接下来,我们使用cin读取到来自AbstractDataTypes命名空间的total。最后,我们使用作用域解析运算符显式地读取到DataTypes::total,否则该名称将被隐藏。需要注意的一点是:如果两个或更多的命名空间包含相同的“名称”,则最后打开的命名空间将主导,隐藏所有先前的出现。

总结

在本章中,我们回顾了核心 C++语法和非面向对象语言特性,以刷新您现有的技能。这些特性包括基本语言语法,使用<iostream>进行基本 I/O,控制结构/语句/循环,运算符基础,函数基础,简单的用户定义类型以及命名空间。最重要的是,您现在已经准备好进入下一章,在这一章中,我们将扩展一些这些想法,包括const限定变量,理解和使用原型(包括默认值),以及函数重载等额外的语言必需品。

下一章中的想法开始让我们更接近面向对象编程的目标,因为许多这些聚合技能经常被使用,并且随着我们深入语言,它们变得理所当然。重要的是要记住,在 C中,你可以做任何事情,无论你是否有意这样做。语言中有巨大的力量,对其许多微妙和特性有一个坚实的基础是至关重要的。在接下来的几章中,将奠定坚实的基础,以掌握一系列非面向对象的 C技能,这样我们就可以以高水平的理解和成功实现在 C++中进行面向对象编程。

问题

  1. 描述一种情况,在这种情况下,flush而不是endl可能对清除与cout关联的缓冲区的内容有用。

  2. 一元运算符++可以用作前置或后置递增运算符,例如i++++i。你能描述一种情况,在这种情况下,选择前置递增还是后置递增对代码会产生不同的后果吗?

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

第二章:添加语言必需性

本章将介绍 C的非面向对象特性,这些特性是 C面向对象特性的重要基石。本章介绍的特性代表了从本章开始在本书中将被毫不犹豫地使用的主题。C是一门笼罩在灰色地带的语言;从本章开始,您将不仅熟悉语言特性,还将熟悉语言的微妙之处。本章的目标将是从一个普通的 C程序员的技能开始,使其能够成功地在创建可维护的代码的同时在语言的微妙之处中操作。

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

  • const限定符

  • 函数原型

  • 函数重载

通过本章结束时,您将了解非面向对象的特性,如const限定符,函数原型(包括使用默认值),函数重载(包括标准类型转换如何影响重载函数选择并可能创建潜在的歧义)。许多这些看似简单的主题包括各种有趣的细节和微妙之处。这些技能将是成功地继续阅读本书后续章节所必需的。

技术要求

完整程序示例的在线代码可以在以下 GitHub URL 找到:github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter02。每个完整的程序示例都可以在 GitHub 存储库中的适当章节标题(子目录)下找到,文件名与所在章节号相对应,后跟破折号,再跟随所在章节中的示例编号。例如,第二章添加语言必需性中的第一个完整程序可以在名为Chp2-Ex1.cpp的文件中的Chapter02子目录中找到上述 GitHub 目录下。

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

使用 const 限定符

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

常量变量

一个const限定的变量是一个必须被初始化的变量,永远不能被赋予新值。将const和变量一起使用似乎是一个悖论-const意味着不改变,然而变量的概念本质上是持有不同的值。尽管如此,拥有一个在运行时可以确定其唯一值的强类型检查变量是有用的。关键字const被添加到变量声明中。

让我们在以下程序中考虑一些例子。我们将把这个程序分成两个部分,以便更有针对性地解释,但是完整的程序示例可以在以下链接中找到:

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter02/Chp2-Ex1.cpp

#include <iostream>
#include <iomanip>
#include <cstring>
using namespace std;
// simple const variable declaration and initialization
const int MAX = 50; 
int minimum(int a, int b)  // function definition with
{                          // formal parameters
    return (a < b)? a : b;   // conditional operator ?: 
}

在前面的程序段中,请注意我们在数据类型之前使用const限定符声明变量。在这里,const int MAX = 50;简单地将MAX初始化为50MAX不能通过赋值在代码中后期修改。按照惯例,简单的const限定变量通常大写。接下来,我们有函数minimum()的定义;请注意在这个函数体中使用了三元条件运算符?:。接下来,让我们继续查看main()函数的主体,继续进行本程序的其余部分:

int main()
{
    int x, y;
    cout << "Enter two values: ";
    cin >> x >> y;
    const int MIN = minimum(x, y);  // const var initialized 
                             // with a function's return value
    cout << "Minimum is: " << MIN << endl;
    char bigName[MAX];      // const var used to size an array
    cout << "Enter a name: ";
    cin >> setw(MAX) >> bigName;
    const int NAMELEN = strlen(bigName); // another const
    cout << "Length of name: " << NAMELEN << endl;
    return 0;
}

main()中,让我们考虑代码的顺序,提示用户将“输入两个值:”分别存入变量xy中。在这里,我们调用函数minimum(x,y),并将我们刚刚使用cin和提取运算符>>读取的两个值xy作为实际参数传递。请注意,除了MINconst变量声明之外,我们还使用函数调用minimum()的返回值初始化了MIN。重要的是要注意,设置MIN被捆绑为单个声明和初始化。如果这被分成两行代码--变量声明后跟一个赋值--编译器将会标记一个错误。const变量只能在声明后用一个值初始化,不能在声明后赋值。

在上面的最后一段代码中,请注意我们使用MAX(在这个完整程序示例的早期部分定义)来定义固定大小数组bigName的大小:char bigName[MAX];。然后,我们在setw(MAX)中进一步使用MAX来确保我们在使用cin和提取运算符>>读取键盘输入时不会溢出bigName。最后,我们使用函数strlen(bigname)的返回值初始化变量const int NAMELEN,并使用cout打印出这个值。

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

Enter two values: 39 17
Minimum is: 17
Enter a name: Gabby
Length of name: 5

现在我们已经看到了如何对变量进行const限定,让我们考虑对函数进行const限定。

函数的 const 限定

关键字const也可以与函数一起使用。const限定符可以用于参数中,表示参数本身不会被修改。这是一个有用的特性--函数的调用者将了解到以这种方式限定的输入参数不会被修改。然而,因为非指针(和非引用)变量被作为“按值”传递给函数,作为实际参数在堆栈上的副本,对这些固有参数的const限定并没有任何意义。因此,对标准数据类型的参数进行const限定是不必要的。

相同的原则也适用于函数的返回值。函数的返回值可以被const限定,然而,除非返回一个指针(或引用),作为返回值传回堆栈的项目是一个副本。因此,当返回类型是指向常量对象的指针时,const限定返回值更有意义(我们将在第三章中介绍,间接寻址:指针及以后内容)。作为const的最后一个用途,我们可以在类的 OO 细节中使用这个关键字,以指定特定成员函数不会修改该类的任何数据成员。我们将在第五章中探讨这种情况,详细探讨类

现在我们了解了const限定符用于变量,并看到了与函数一起使用const的潜在用途,让我们继续前进到本章的下一个语言特性:函数原型。

使用函数原型

在本节中,我们将研究函数原型的机制,比如在文件中的必要放置和跨多个文件以实现更大的程序灵活性。我们还将为原型参数添加可选名称,并了解我们为什么可以选择向 C原型添加默认值。函数原型确保了 C代码的强类型检查。

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

定义函数原型

函数原型是对函数的前向声明,描述了函数应该如何被正确调用。原型确保了函数调用和定义之间的强类型检查。函数原型包括:

  • 函数的返回类型

  • 函数的名称

  • 函数的类型和参数数量

函数原型允许函数调用在函数的定义之前,或者允许调用存在于不同的文件中的函数。让我们来看一个简单的例子:

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter02/Chp2-Ex2.cpp

#include <iostream>
using namespace std;
int minimum(int, int);     // function prototype

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

注意,我们在上面的例子中在开头原型了int minimum(int, int);。这个原型让编译器知道对minimum()的任何调用都应该带有两个整数参数,并且应该返回一个整数值(我们将在本节后面讨论类型转换)。

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

原型的存在允许对给定函数的调用在编译器看到函数定义之前进行完全的类型检查。上面的例子当然是为了演示这一点而捏造的;我们也可以改变minimum()main()在文件中出现的顺序。然而,想象一下minimum()的定义包含在一个单独的文件中(更典型的情况)。在这种情况下,原型将出现在调用这个函数的文件的顶部(以及头文件的包含),以便函数调用可以完全根据原型进行类型检查。

在上述的多文件情况下,包含函数定义的文件将被单独编译。然后链接器的工作是确保当这两个文件链接在一起时,函数定义和原型匹配,以便链接器可以解析对这样的函数调用的任何引用。如果原型和定义不匹配,链接器将无法将代码的这两部分链接成一个编译单元。

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

5

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

在函数原型中命名参数

函数原型可以选择包含名称,这些名称可能与形式参数或实际参数列表中的名称不同。参数名称会被编译器忽略,但通常可以增强可读性。让我们重新看一下我们之前的示例,在函数原型中添加可选参数名称。

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter02/Chp2-Ex3.cpp

#include <iostream>
using namespace std;
int minimum(int arg1, int arg2);    // function prototype with
                                    // optional argument names
int main()
{
    int x = 5, y = 89;
    cout << minimum(x, y) << endl;   // function call
    return 0;
}
int minimum(int a, int b)            // function definition
{
    return (a < b)? a : b;  
}

这个示例几乎与前面的示例相同。但是,请注意函数原型包含了命名参数arg1arg2。这些标识符会被编译器立即忽略。因此,这些命名参数不需要与函数的形式参数或实际参数匹配,仅仅是为了增强可读性而可选地存在。

与上一个示例相同,此示例的输出如下:

5

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

向函数原型添加默认值

默认值可以在函数原型中指定。这些值将在函数调用中没有实际参数时使用,并将作为实际参数本身。默认值必须符合以下标准:

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

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

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

让我们看一个使用默认值的示例:

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter02/Chp2-Ex4.cpp

#include <iostream>
using namespace std;
int minimum(int arg1, int arg2 = 100000);  // fn. prototype
                                    // with one default value
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 values used
    return 0;
}
int minimum(int a, int b)            // function 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/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter02/Chp2-Ex5.cpp

#include <iostream>
using namespace std;
int minimum(int, int);   // standard function prototype
void function1(int x)
{   
    int minimum(int arg1, int arg2 = 500); // local prototype
                                           // with default value
    cout << minimum(x) << endl; 
}
void function2(int x)
{
    int minimum(int arg1, int arg2 = 90);  // local prototype
                                           // with default value
    cout << minimum(x) << endl; 
}

int minimum(int a, int b)            // function 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/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter02/Chp2-Ex6.cpp

#include <iostream>
#include <cmath>
using namespace std;
const float PI = 3.14159;
class Circle        // user defined type declarations
{
public:
   float radius;
   float area;
};
class Rectangle
{
public:
   float length;
   float width;
   float area;
};
void display(Circle);     // 'overloaded' function prototypes
void display(Rectangle);  // since they differ in signature

在这个例子的开头,注意我们用 #include <cmath> 包含了 math 库,以便访问基本的数学函数,比如 pow()。接下来,注意 CircleRectangle 的类定义,每个类都有相关的数据成员(CircleradiusareaRectanglelengthwidtharea)。一旦这些类型被定义,就会显示两个重载的显示函数的原型。由于这两个显示函数的原型使用了用户定义的类型 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),将调用重载版本的 display(),因为这两个调用中传递的实际参数本身就是 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/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter02/Chp2-Ex7.cpp

#include <iostream>
using namespace std; 
int maximum(double, double);      // function prototype

int main()
{
    int result;
    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)的返回值是一个整数 - 而不是双精度。这意味着从这个函数返回的值(形式参数ab)将首先被截断为整数,然后作为返回值使用。这个返回值被整洁地赋给了result,它在main()中被声明为int。这些都是标准类型转换的例子。

接下来,maximum()被调用,实际参数为mn。与前一个函数调用类似,整数mn被提升为双精度,并且函数被按预期调用。返回值也将被截断为int,并且该值将作为整数传递给cout进行打印。

这个示例的输出是:

Result is: 9
The maximum is: 10

现在我们了解了函数重载和标准类型转换的工作原理,让我们来看一个情况,其中两者结合可能会产生一个模棱两可的函数调用。

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

当调用函数时,形式和实际参数在类型上完全匹配时,不会出现关于应该调用哪个重载函数的歧义 - 具有完全匹配的函数是显而易见的选择。然而,当调用函数时,形式和实际参数在类型上不同时,可能需要对实际参数进行标准类型转换。然而,在形式和实际参数类型不匹配且存在重载函数的情况下,编译器可能难以选择哪个函数应该被选为最佳匹配。在这些情况下,编译器会生成一个错误,指示可用的选择与函数调用本身是模棱两可的。显式类型转换或在更局部的范围内重新原型化所需的选择可以帮助纠正这些否则模棱两可的情况。

让我们回顾一个简单的函数,说明函数重载、标准类型转换和潜在的歧义。

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter02/Chp2-Ex8.cpp

#include <iostream>
using namespace std;
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 since 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((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 function
{                                    
    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 限定符,理解函数原型,使用原型中的默认值,函数重载,标准类型转换如何影响重载函数的选择,以及可能出现的歧义如何解决。

非常重要的是,您现在已经准备好进入下一章,我们将在其中详细探讨使用指针进行间接寻址。您在本章积累的事实技能将帮助您更轻松地导航每一个逐渐更详细的章节,以确保您准备好轻松应对从第五章 开始的面向对象概念,详细探索类

请记住,C++ 是一种充满了比大多数其他语言更多灰色地带的语言。您积累的微妙细微之处将增强您作为 C++ 开发人员的价值——一个不仅可以导航和理解现有微妙代码的人,还可以创建易于维护的代码。

问题

  1. 函数的签名是什么,函数的签名如何与 C++ 中的名称修饰相关联?您认为这如何促进编译器内部处理重载函数?

  2. 编写一个小的 C++ 程序,提示用户输入有关 学生 的信息,并打印出数据。

  1. 学生 信息应至少包括名字、姓氏、GPA 和 学生 注册的当前课程。这些信息可以存储在一个简单的类中。您可以利用数组来表示字符串字段,因为我们还没有涉及指针。此外,您可以在主函数中读取这些信息,而不是创建一个单独的函数来读取数据(因为后者需要指针或引用的知识)。请不要使用全局(即 extern 变量)。

  2. 创建一个函数来打印 学生 的所有数据。记得对这个函数进行原型声明。在这个函数的原型中,使用默认值 4.0 作为 GPA。以两种方式调用这个函数:一次显式传入每个参数,一次使用默认的 GPA。

  3. 现在,重载 Print 函数,其中一个打印出选定的数据(即姓氏和 GPA),或者使用接受 Student 作为参数的版本的函数(但不是 Student 的指针或引用——我们稍后会做)。记得对这个函数进行原型声明。

  4. 使用 iostream 进行 I/O。

第三章:间接寻址:指针

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

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

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

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

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

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

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

  • 使用 void 指针 - 指向未指定类型的对象的指针

通过本章结束时,您将了解如何使用new()从堆中分配内存,用于简单和复杂的数据类型,以及如何使用delete()标记内存以返回给堆管理设施。您将能够动态分配任何数据类型和任意维数的数组,并且了解释放内存的基本内存管理,以避免在应用程序中不再需要时发生内存泄漏。您将能够将指针作为参数传递给具有任何间接级别的函数 - 即,指向数据的指针,指向指向数据的指针,依此类推。您将了解如何以及为什么将const限定符与指针结合使用 - 对数据、对指针本身,或对两者都是。最后,您将了解如何声明和使用没有类型的通用指针 - void 指针 - 并了解它们可能证明有用的情况。这些技能将是成功地继续阅读本书后续章节所必需的。

技术要求

完整程序示例的在线代码可以在以下 GitHub 网址找到:github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter03。每个完整程序示例都可以在 GitHub 存储库中找到,位于相应章节标题(子目录)下的文件中,文件名与所在章节编号相对应,后跟该章节中的示例编号。例如,本章的第一个完整程序可以在子目录Chapter03中的名为Chp3-Ex1.cpp的文件中找到,位于上述 GitHub 目录下。

本章的 CiA 视频可在以下网址观看:bit.ly/2OY41sn

理解指针基础知识和内存分配

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

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

重新审视指针的基础知识

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

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

使用取地址和解引用运算符

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

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

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

请注意,在前面的代码片段中,我们首先声明并初始化变量x10。接下来,我们声明int *pointerToX;来说明变量pointerToX可能有一天会指向一个整数。在这个声明时,这个指针变量是未初始化的,因此不包含有效的内存地址。

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

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

x is 10 and *pointerToX is 10

重要提示

为了效率,C++ 在应用程序启动时不会将所有内存清零初始化,也不会确保内存与变量配对时方便地为空,没有值。内存中只是存储了先前存储在那里的内容;C++ 内存不被认为是 干净 的。因为在 C++ 中内存不是 干净 的,所以除非正确初始化或分配一个值,否则新声明的指针变量的内容不应被解释为包含有效地址。

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

使用 new()delete() 操作符

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

让我们来看一个简单的数据类型的内存分配和释放:

int *y;    // y is a pointer which may someday point to an int
y = new int;  // y points to memory allocated on the 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

在前面的程序段中,我们首先声明指针变量 yint *y;。在这里,y 可能会包含一个整数的地址。在下一行,我们从堆中分配了足够容纳一个整数的内存,使用 y = new int; 将该地址存储在指针变量 y 中。接下来,使用 *y = 17; 我们对 y 进行解引用,并将值 17 存储在 y 指向的内存位置。在打印出 *y 的值后,我们决定我们已经完成了 y 指向的内存,并通过使用 delete() 操作符将其返回给堆管理设施。重要的是要注意,变量 y 仍然包含它通过调用 new() 获得的内存地址,但是,y 不应再使用这个放弃的内存。

重要提示

程序员有责任记住,一旦内存被释放,就不应再次对该指针变量进行解引用;请理解该地址可能已经通过程序中的另一个 new() 调用重新分配给另一个变量。

现在我们了解了简单数据类型的指针基础知识,让我们继续通过分配更复杂的数据类型,并理解必要的符号来使用和访问用户定义的数据类型的成员。

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

接下来,让我们来看看如何声明指向用户定义类型的指针,以及如何在堆上分配它们的关联内存。要动态分配用户定义类型,指针首先必须声明为该类型。然后,指针必须初始化或分配一个有效的内存地址 - 内存可以是现有变量的内存,也可以是新分配的堆内存。一旦适当内存的地址被放入指针变量中,-> 操作符可以用来访问结构体或类的成员。另外,(*ptr).member 符号也可以用来访问结构体或类的成员。

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

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter03/Chp3-Ex1.cpp

include <iostream>
using namespace std;
struct collection
{
    int x;
    float y;
};

int main()  
{
    collection *item;      // 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。接下来,我们用collection *item;声明item作为指向该类型的指针。然后,我们为item分配堆内存,使用new()运算符指向。现在,我们分别为itemxy成员赋值,使用->运算符或(*).member访问表示法。在任一情况下,表示法意味着首先取消引用指针,然后选择适当的数据成员。使用(*).表示法非常简单-括号告诉我们指针解除引用首先发生,然后使用.(成员选择运算符)选择成员。->简写表示指针解除引用后选择成员。在我们使用cout和插入运算符<<打印适当的值后,我们决定不再需要与item相关的内存,并发出delete item;来标记此段堆内存以返回到空闲列表。

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

9 120.77

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

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

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

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

在运行时分配和释放数组

数组可以动态分配,以便在运行时确定其大小。动态分配的数组可以是任何类型,包括用户定义的类型。在运行时确定数组大小可以节省空间,并为我们提供编程灵活性。您可以根据运行时的各种因素分配所需的大小,而不是分配可能浪费空间的最大可能数量的固定大小数组。您还可以在需要更改数组大小时删除和重新分配数组。可以动态分配任意维数的数组。

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

动态分配单维数组

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

让我们来看一个简单的例子。我们将把它分成两个部分,但是完整的程序示例可以在下面的链接中找到:

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter03/Chp3-Ex2.cpp

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

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

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

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

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

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

接下来,当我们继续使用for循环来进行这个示例时,请注意,我们使用了典型的[]数组表示法来访问两个数组的每个元素,即使这些数组已经被动态分配。因为collectionArray是一个动态分配的用户定义类型数组,我们必须使用.符号来访问每个数组元素内的单个数据成员。虽然使用标准数组表示法使得访问动态数组非常简单,但您也可以使用指针表示法来访问内存。

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

最后,在这个示例中,我们演示了如何使用delete()释放我们不再需要的内存。对于动态分配的标准类型数组,简单的delete intArray;语句就足够了,而对于用户定义类型的数组,需要更复杂的delete [] collectionArray;语句才能正确删除。在两种情况下,与每个动态分配的数组相关联的内存将返回到空闲列表中,并且可以在后续调用new()运算符分配堆内存时重新使用。在指针变量的内存被标记为删除后,记住不要对指针变量进行解引用是至关重要的。尽管该地址将保留在指针变量中,直到您为指针分配新地址(或空指针),但一旦内存被标记为删除,该内存可能已经被程序中其他地方对new()的后续调用重用。这是在 C++中使用指针时必须要谨慎的许多方式之一。

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

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

让我们进一步看一下这个示例的内存布局。使用的内存地址(8500 和 9500)是任意的 - 它们是堆上可能由new()生成的示例地址。

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

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

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

动态分配 2-D 数组:指针数组

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

首先考虑一种情况,即我们有固定数量的行,以及每行中可变数量的条目(即列维度)。为简单起见,我们假设每行中的条目数量从一行到另一行是相同的,但实际上并非如此。我们可以使用指针数组来模拟具有固定行数和运行时确定的每行中的条目数量(列维度)的二维数组。

让我们考虑一个例子来说明动态分配列维度的二维数组。

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter03/Chp3-Ex3.cpp

#include <iostream>
using namespace std;
const int NUMROWS = 5;
int main()
{
    float *TwoDimArray[NUMROWS];  // array of pointers
    int numColumns;
    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];  // delete column for each row
    return 0;
}

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

接下来,我们提示用户输入列条目的数量。在这里,我们假设每行将有相同数量的条目(以形成列维度),但是可能每行的总条目数量是不同的。通过假设每行将有统一数量的条目,我们可以使用i来简单地循环分配每行的列数量,使用TwoDimArray[i] = new float [numColumns];

在使用j作为索引的嵌套循环中,我们简单地为外部循环指定的行的每个列条目加载值。任意赋值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 的内存模型

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

动态分配 2-D 数组:指向指针的指针

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

让我们考虑一个例子来说明一个动态分配了行和列维度的 2-D 数组。

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter03/Chp3-Ex4.cpp

#include <iostream>
using namespace std;
int main()
{
    int numRows, numColumns;
    float **TwoDimArray;    // pointer to a pointer
    cout << "Enter number of rows: " << flush;
    cin >> numRows;
    TwoDimArray = new float * [numRows];  // allocate 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];  // delete columns for each row
    delete TwoDimArray;  // delete allocated rows
    return 0;
}

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

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

就像在上一个示例中一样,我们提示用户希望每行有多少列。就像以前一样,在以i为索引的外部循环中,我们为每行分配列条目。在以j为索引的嵌套循环中,我们再次为数组条目赋值并打印它们,就像以前一样。

最后,程序继续进行内存释放。与之前一样,每行的列条目在循环内被释放。然而,此外,我们需要释放动态分配的行条目数量。我们使用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 的内存模型

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

动态分配 N-D 数组:指向指针的指针

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

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

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter03/Chp3-Ex5.cpp

int main()
{
    int dim1, dim1, dim3;
    int ***ThreeDimArray;   // 3-D dynamically allocated 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]; // allocate 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 newline between dimensions
        }
        cout << end;  // print newline 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;
}

在这个例子中,请注意我们使用三级间接来指定表示 3-D 数组的变量int ***ThreeDimArray;。然后我们为每个间接分配所需的内存。第一个分配是ThreeDimArray = new int ** [dim1];,它分配了维度 1 的指针到指针。接下来,在一个循环中迭代i,对于维度 1 中的每个元素,我们分配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/Demystified-Object-Oriented-Programming-with-CPP/raw/master/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 namespace std;
void TryToAddOne(int); // function prototypes
void AddOne(int *);
int main()
{
   int x = 10, *y;
   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;
   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/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter03/Chp3-Ex7.cpp

#include <iostream>
#include <iomanip>
using namespace std; 
const int MAX = 20;
char *createName();  // function prototype
int main()    
{
   char *name;   // pointer declaration
   name = createName();  // function will allocate memory
   cout << "Name: " << name << endl;
   delete name;  // delete allocated memory
   return 0;
}
char *createName()
{
   char *temp = new char[MAX];
   cout << "Enter name: " << flush;
   cin >> setw(MAX) >> temp; 
   return temp;
}

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

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

在调用createName()时,注意到一个局部变量char *temp = new char[MAX];被定义并分配到堆上的固定内存量,使用new()操作符。然后提示用户输入一个名称,并将该名称存储在temp中。然后从createName()返回局部变量temp

createName()中,很重要的是temp的内存由堆内存组成,以便它在函数的范围之外存在。在这里,存储在temp中的地址的副本将被复制到堆栈中为函数的返回值保留的区域。幸运的是,该地址指向堆内存。在main()中的赋值name = createName();将捕获这个地址,并将其复制存储到name变量中,该变量是main()中的局部变量。由于在createName()中分配的内存位于堆上,所以一旦函数完成,这个内存将存在。

同样重要的是,如果在createName()中定义char temp[MAX];,那么组成temp的内存将存在于堆栈上,并且将局限于createName()。一旦createName()返回到main,这个变量的内存将从堆栈中弹出,并且将无法正确使用 - 即使该地址已经在main()中的指针变量中被捕获。这是 C++中另一个潜在的指针陷阱。当从函数返回指针时,始终确保指针指向的内存存在于函数的范围之外。

这个例子的输出是:

Enter name: Gabrielle
Name: Gabrielle

现在我们了解了指针如何在函数的参数中使用以及作为函数的返回值,让我们继续通过进一步研究指针的微妙之处。

使用指针的 const 限定符

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

使用指向常量对象的指针

可以指定指向常量对象的指针,以便不能直接修改指向的对象。对这个对象进行解引用后,不能将其用作任何赋值中的 l 值。l 值表示可以修改的值,并且出现在赋值的左侧。

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

// const qualified strings; the data pointed to will be const
const char *constData = "constant"; 
const char *moreConstData;  
// regular strings, defined. One is loaded using strcpy()  
char *regularString;
char *anotherRegularString = new char[8];
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 it to
// a const char *. It will be const from that viewpoint only
moreConstData = anotherRegularString; // Yes, I can do this!

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

此外,试图通过将constData存储在相同类型(但不是const)的指针中来规避这种情况,也会生成编译器错误,比如代码行regularString = constData;。当然,在 C++中,如果你足够努力,你可以做任何事情,所以这里的显式类型转换会起作用,但故意没有显示。显式类型转换仍会生成编译器警告,以便你质疑这是否真的是你打算做的事情。当我们继续使用 OO 概念时,我们将介绍进一步保护数据的方法,以消除这种规避。

在前面代码的最后一行,请注意我们将常规字符串的地址存储在const char *moreConstData中。这是允许的-你总是可以对待某物比它定义的更尊重(只是不能更少)。这意味着使用标识符moreConstData,这个字符串可能不会被修改。然而,使用它自己的标识符,定义为char *anotherRegularString;,这个字符串可能会被更改。这似乎是不一致的,但实际上并不是。const char *变量选择指向char *-提升了它对特定情况的保护。如果const char *真的想指向一个不可变对象,它本应选择指向另一个const char *变量。

接下来,让我们看一个与此主题相关的变化。

使用常量指针指向对象

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

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

// Define, allocate, load two regular strings using strcpy()
char *regularString = new char[36];
strcpy(regularString, "I am a string which can be modified");
char *anotherRegularString = new char[21];
strcpy(anotherRegularString, "I am also modifiable"); 
// Define a const pointer to a string. It 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];
strcpy(regularString, "I am a string which can be modified");
char *anotherRegularString = new char[21];
strcpy(anotherRegularString, "I am also modifiable"); 
// Define a 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 mod addr
strcpy(constStringandPtr, "Nope"); // No! Can't mod data

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

提示-解读指针声明

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

最后,让我们继续了解作为函数参数或函数返回值的指针的 const 限定的含义。

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

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

让我们看一个例子来检验这些想法:

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter03/Chp3-Ex8.cpp

#include <iostream>
#include <cstring>
using namespace std;
char suffix = 'A';
const char *genId(const char *);  // function prototype
int main()    
{
    const char *newId1, *newId2;   // pointer declarations
    newId1 = genId("Group");  // function will allocate memory
    newId2 = genId("Group");  
    cout << "New ids: " << newId1 << " " << newId2 << endl;
    delete newId1;  // delete allocated memory  
    delete newId2;
    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 up-cast 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" 包含 5 个字符,分别位于数组 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 值来捕获 genId() 的返回值。

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

这是我们示例的输出的附加部分:

New ids: GroupA GroupB

现在我们已经了解了如何以及为什么要对指针进行 const 限定,让我们通过考虑 void 指针来看看如何以及为什么选择通用指针类型。

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

有时程序员会问为什么他们不能简单地拥有一个通用指针。也就是说,为什么我们总是要声明指针最终将指向的数据类型,比如 int *ptr;?C确实允许我们创建没有关联类型的指针,但是 C要求程序员自己来跟踪通常由编译器代劳的事情。尽管如此,在本节中我们将看到为什么 void 指针很有用,以及程序员在使用更通用的 void 指针时必须承担的任务。

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

然而,仍然存在需要更通用指针的需求。例如,你可能希望一个指针在一种情况下指向一个整数,而在另一种情况下指向一组用户定义的类型。使用void指针可以实现这一点。但是类型呢?当你对void指针进行取消引用时会发生什么?如果 C++不知道如何从一个集合中的一个元素走到另一个元素,它如何索引到动态分配的void指针数组中?一旦到达地址,它将如何解释字节?类型是什么?

答案是,你,程序员,必须随时记住你指向的是什么。没有与指针相关联的类型,编译器无法为你做到这一点。当需要对void指针进行取消引用时,你将负责正确记住所涉及的最终类型,并对该指针执行适当的类型转换。

让我们来看看所涉及的机制和逻辑。

创建 void 指针

使用void *可以指定未指定类型的对象的指针。然后,void指针可以指向任何类型的对象。在 C中,必须使用显式转换来对void *指向的实际内存进行取消引用。在 C中,还必须使用显式转换将void *指向的内存分配给已知类型的指针变量。程序员有责任确保取消引用的数据类型在进行赋值之前是相同的。如果程序员错误,那么在代码的其他地方将会有一个难以找到的指针错误。

这里有一个例子:

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter03/Chp3-Ex9.cpp

#include <iostream>
using namespace std;
int main()
{
    void *unspecified;  // void * may point to any data type
    int *x;
    unspecified = new int; // the void * now points to an int
    // void * must be cast to int * before it is dereferenced
    *((int *) unspecified) = 89;
    // let x point to the memory which unspecified points to
    x = (int *) unspecified;
    cout << *x << " " << *((int *) unspecified) << endl;
    return 0;
}

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

赋值*((int *) unspecified = 89;首先使用显式类型转换将unspecified转换为(int *),然后取消引用int *将值89放入内存。重要的是要注意,在对unspecified进行取消引用之前必须进行此类型转换-否则 C++无法理解如何解释unspecified指向的内存。还要注意,如果你意外地将unspecified转换为错误的类型,编译器将允许你继续进行,因为类型转换被视为对编译器的"just do it"命令。作为程序员,你的工作是记住你的void *指向的数据类型。

最后,我们希望x指向unspecified指向的位置。变量x是一个整数,需要指向一个或多个整数。变量unspecified确实指向一个整数,但由于 unspecified 的数据类型是void *,我们必须使用显式类型转换使以下赋值生效:x = (int *) unspecified;。此外,从程序上看,我们希望我们正确地记住了unspecified确实指向一个int;知道正确的内存布局对于int *如果被取消引用是很重要的。否则,我们只是强制了不同类型指针之间的赋值,在我们的程序中留下了潜在的错误。

以下是与我们的程序配套的输出:

89 89

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

摘要

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

本章中使用指针的所有技能将在接下来的章节中自由使用。C++希望程序员能够很好地使用指针。指针使语言具有很大的自由度和效率,可以利用大量的数据结构并采用创造性的编程解决方案。然而,指针可能会为程序引入大量错误,如内存泄漏,返回指向不再存在的内存的指针,取消引用已被删除的指针等。不用担心,我们将在接下来的示例中使用许多指针,以便您能够轻松地操纵指针。

最重要的是,您现在已经准备好继续前进到第四章间接寻址-引用,在这一章中,我们将使用引用来探索间接寻址。一旦您了解了间接寻址的两种类型-指针和引用-并且可以轻松地操纵其中任何一种,我们将在本书中探讨核心面向对象的概念,从第五章详细探讨类开始。

问题

  1. 修改并增强您的 C++程序,从第二章添加语言必需性练习 2如下所示:
  1. 创建一个名为ReadData()的函数,该函数接受一个指向Student的指针作为参数,以允许在函数内从键盘输入firstNamelastNamegpacurrentCourseEnrolled,并将其存储为输入参数的数据。

  2. 修改firstNamelastNamecurrentCourseEnrolled,在您的Student类中将它们建模为char *,而不是使用固定大小的数组(就像在第二章中可能已经建模的那样,添加语言必需性)。您可以利用一个固定大小的temp变量,最初捕获这些值的用户输入,然后为这些数据成员分配适当的大小。

  3. 如果需要,重新编写您在第二章解决方案中的Print()函数,以便为Print()接受Student作为参数。

  4. 重载Print()函数,使用一个以const Student *为参数的函数。哪一个更有效?为什么?

  5. main()中,创建一个指向Student的指针数组,以容纳 5 个学生。为每个Student分配内存,为每个Student调用ReadData(),然后使用上述函数中的选择Print()每个Student。完成后,请记得为每个分配的学生delete()内存。

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

  1. 写下以下指针声明,其中包括const修饰:
  1. 为指向常量对象的指针编写声明。假设对象的类型为Student。提示:从右向左阅读您的声明以验证其正确性。

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

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

  1. 为什么在上面的程序中将类型为const Student *的参数传递给Print()是有意义的,为什么传递类型为Student * const的参数是没有意义的?

  2. 您能想到可能需要动态分配的 3D 数组的编程情况吗?动态分配具有更多维度的数组呢?

第四章:间接寻址:引用

本章将探讨如何在 C中利用引用。引用通常可以用作间接寻址的替代方案,但并非总是如此。尽管您在上一章中使用指针有间接寻址的经验,我们将从头开始理解 C引用。

引用和指针一样,是您必须能够轻松使用的语言特性。许多其他语言使用引用进行间接寻址,而不需要像 C++那样深入理解才能正确使用指针和引用。与指针一样,您会经常在其他程序员的代码中看到引用的使用。与指针相比,使用引用在编写应用程序时提供了更简洁的表示方式,这可能会让您感到满意。

遗憾的是,在所有需要间接寻址的情况下,引用不能替代指针。因此,在 C++中,深入理解使用指针和引用进行间接寻址是成功创建可维护代码的必要条件。

本章的目标是通过了解如何使用 C引用作为替代方案来补充您对使用指针进行间接寻址的理解。了解两种间接寻址技术将使您成为一名更优秀的程序员,轻松理解和修改他人的代码,并自己编写原始、成熟和有竞争力的 C代码。

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

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

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

  • 在引用中使用 const 限定符

  • 理解底层实现,以及引用不能使用的情况

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

您还将了解 const 限定符如何适用于引用作为变量,并且如何与函数的参数和返回类型一起使用。您将能够区分引用何时可以替代指针,以及它们不能替代指针的情况。这些技能将是成功阅读本书后续章节的必要条件。

技术要求

完整程序示例的在线代码可以在以下 GitHub URL 找到:github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter04。每个完整程序示例都可以在 GitHub 存储库中的适当章节标题(子目录)下找到,文件名与所在章节编号相对应,后跟破折号,再跟随所在章节中的示例编号。例如,本章的第一个完整程序可以在名为Chp4-Ex1.cpp的文件中的Chapter04子目录中找到。

本章的 CiA 视频可在以下链接观看:bit.ly/2OM7GJP

理解引用基础

在本节中,我们将重新讨论引用基础,并介绍适用于引用的运算符,如引用运算符&。我们将使用引用运算符&来建立对现有变量的引用。与指针变量一样,引用变量指向在其他地方定义的内存。

使用引用变量允许我们使用比指针间接访问内存时更简单的符号。许多程序员欣赏引用与指针变量的符号的清晰度。但是,在幕后,内存必须始终被正确分配和释放;被引用的一部分内存可能来自堆。程序员无疑需要处理指针来处理其整体代码的一部分。

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

声明、初始化和访问引用

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

引用变量,在幕后,可以与指针变量相比较——因为它保存了它引用的变量的地址。与指针变量不同,引用变量的任何使用都会自动取消引用变量以转到它包含的地址;取消引用运算符*在引用中是不需要的。取消引用是自动的,并且隐含在每次使用引用变量时。

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

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter04/Chp4-Ex1.cpp

#include <iostream>
using namespace std;
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;
    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/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter04/Chp4-Ex2.cpp

#include <iostream>
#include <cstring>
using namespace std;
class Student
{
public:
    char name[20];
    float gpa;
};
int main()
{
    Student s1;
    Student &sRef = s1;  // establish a reference to s1
    strcpy(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; 
    strcpy(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 &sRef = s1;声明并初始化了一个Student的引用。在这里,我们声明sRef引用特定的Student,即s1。注意,s1Student类型,而sRef的引用类型也是Student类型。

现在,我们使用strcpy()加载一些初始数据到s1中,然后进行简单赋值。因此,这改变了sRef的值,因为s1sRef引用相同的内存。也就是说,sRefS1的别名。

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

现在,我们加载新的值到sRef中,也使用strcpy()和简单赋值。同样,我们打印出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/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter04/Chp4-Ex3.cpp

void AddOne(int &arg)   // These two functions are overloaded
{
    arg++;
}
void AddOne(int *arg)   // Overloaded function definition
{
    (*arg)++;
}
void Display(int &arg)  // This fn passes a reference to arg
{                       
    cout << arg << " " << flush;
}

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

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

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

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

    AddOne(x);    // calls reference version (with an object) 
    AddOne(*y);   // also calls reference version 
    Display(x);   // Based on prototype, we see we are passing
    Display(*y);  // by reference. Without prototype, we might
                  // have guessed it was by value.
    AddOne(&x);   // calls pointer version
    AddOne(y);    // also calls pointer version
    Display(x);
    Display(*y);
    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/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter04/Chp4-Ex4.cpp

#include <iostream>
using namespace std;
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;
}
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()函数。在这里,我们使用第一次调用CreateId()的返回值初始化了一个引用变量id1,如下所示的函数调用和初始化:int &id1 = CreateId();。请注意,引用id1在声明时必须被初始化,我们已经通过上述代码行满足了这个要求。

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

接下来,我们必须记得释放CreateId()分配的内存。我们必须使用delete()运算符。等等,delete()运算符需要一个指向将被删除的内存的指针。变量id1id2都是引用,而不是指针。是的,它们各自包含一个地址,因为每个都是作为指针实现的,但是它们各自的标识符的任何使用总是处于解引用状态。为了规避这个困境,我们只需在调用delete()之前取引用变量id1id2的地址,比如delete &id1;很少情况下,您可能需要通过引用变量删除内存,但现在您知道在需要时如何做。

这个例子的输出是:

Id1: 100 Id2: 101

现在我们了解了引用如何在函数参数中使用以及作为函数的返回值,让我们继续通过进一步研究引用的微妙之处。

使用 const 限定符与引用

const限定符可以用来限定引用初始化或引用的数据。我们还可以将const限定的引用用作函数的参数和函数的返回值。

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

让我们看看在引用中使用const的各种情况。

使用对常量对象的引用

const限定符可以用来指示引用初始化的数据是不可修改的。这样,别名总是引用一个固定的内存块,该变量的值不能使用别名本身来改变。一旦指定为常量,引用意味着既不会改变引用本身,也不会改变其值。同样,由于其底层实现为常量限定指针,const限定的引用不能在任何赋值中用作l 值

注意

回想一下,左值意味着可以修改的值,并且出现在赋值的左侧。

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

https://github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter04/Chp4-Ex5.cpp

#include <iostream>
using namespace std;
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的引用在所讨论的函数范围内可能不是一个左值。

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

让我们看一个例子:

https://github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter04/Chp4-Ex6.cpp

#include <iostream>      
using namespace std;
class Collection
{
public:
    int x;
    float y;
};
void Update(Collection &);   // function prototypes
void Print(const Collection &);
int main()
{
    Collection collect1, *collect2;
    collect2 = new Collection;  // allocate memory from heap
    Update(collect1);   // a ref to the object will be 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 x and y members: ";
    cin >> c.x >> c.y;
}

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

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

注意,在程序的末尾,我们看到了Update()Print()的定义。两者都采用引用作为参数,但是Print()的参数是常量限定的:void Print(const Collection &);。请注意,两个函数在每个函数体内使用.(成员选择)符号来访问相关的数据成员。

main()中,我们声明了两个变量,collect1类型为Collectioncollect2是指向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

现在我们已经了解了const限定引用何时有用,让我们看看何时可以使用引用代替指针,以及何时不可以。

实现底层实现和限制

引用可以简化间接引用所需的符号。但是,在某些情况下,引用根本无法取代指针。要了解这些情况,有必要回顾一下 C++中引用的底层实现。

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

为了理解实现,让我们考虑一个样本引用声明:int &intVar = x;。从实现的角度来看,前一个变量声明实际上被声明为int *const intVar = &x;。请注意,初始化左侧显示的&符号具有引用的含义,而初始化或赋值右侧显示的&符号意味着取地址。这两个声明说明了引用的定义与其底层实现。

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

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

根据引用的底层实现(作为const指针),大多数引用使用的限制都是有道理的。例如,不允许引用引用;每个间接级别都需要提前初始化,这通常需要多个步骤,例如使用指针。也不允许引用数组(每个元素都需要立即初始化);尽管如此,指针数组始终是一个选择。还不允许指向引用的指针;但是,允许引用指针(以及指向指针的指针)。

让我们来看看一个有趣的允许引用的机制,这是我们尚未探讨的。

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter04/Chp4-Ex7.cpp

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

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

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

以下是我们程序的输出:

20 20

通过这个例子,我们看到了另一个有趣的引用用法。我们也了解了使用引用所施加的限制,所有这些限制都是由它们的基础实现驱动的。

总结

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

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

与指针一样,本章中使用引用的所有技能将在接下来的章节中自由使用。C++允许程序员使用引用来更方便地进行间接寻址的表示;然而,程序员预计可以相对轻松地利用指针进行间接寻址。

最后,您现在可以继续前往第五章详细探讨类,在这一章中,我们将开始 C++的面向对象特性。这就是我们一直在等待的;让我们开始吧!

问题

  1. 修改并增强您的 C++程序,从第三章间接寻址-指针练习 1,如下所示:
  1. 重载您的ReadData()函数,使用接受Student &参数的版本,以允许从键盘在函数内输入firstNamelastNamecurrentCourseEnrolledgpa

  2. 替换您先前解决方案中的Print()函数,该函数取一个Student,而是取一个const``Student &作为Print()的参数。

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

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

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

本节的第一章详细探讨了类,首先描述了封装和信息隐藏的面向对象概念。语言特性,如成员函数、this 指针、详细访问区域、详细构造函数(包括复制构造函数)、析构函数、成员函数的限定符(const、static、inline)以及数据成员的限定符(const、static)都得到了深入审查。

本节的下一章介绍了单一继承的基础知识,涉及泛化和特化的 OO 概念,详细介绍了通过成员初始化列表继承的构造函数、构造和析构的顺序,以及理解继承的访问区域。本章通过探讨公共与受保护和私有基类以及这些语言特性如何改变继承的 OO 含义,深入挖掘了这一主题。

接下来的章节深入探讨了关于多态性的 OO 概念,以及在 C中使用虚函数实现该概念。方法的动态绑定被审查。虚函数表被探讨以解释运行时绑定。下一章详细解释了抽象类,将 OO 概念与使用纯虚函数的实现配对。介绍了接口的 OO 概念(在 C中没有明确表示),并审查了一种实现方法。向上和向下继承层次的转换完成了本章。

接下来的一章探讨了多重继承以及可能出现的问题。虚基类以及判别器的 OO 概念也被详细介绍,以帮助确定多重继承是否是给定场景的最佳设计,或者是否存在其他设计。

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

本节包括以下章节:

  • [第五章],详细探讨类

  • [第六章],使用单一继承实现层次结构

  • [第七章],通过多态性利用动态绑定

  • [第八章],掌握抽象类

  • [第九章],探索多重继承

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

第五章:深入探讨类

本章将开始我们对 C面向对象编程(OOP)的追求。我们将首先介绍面向对象(OO)的概念,然后逐渐理解这些概念如何在 C中实现。许多时候,实现 OOP 思想将通过直接语言支持来实现,比如本章中的特性。然而,有时我们将利用各种编程技术来实现面向对象的概念。这些技术将在后面的章节中看到。在所有情况下,重要的是理解面向对象的概念以及这些概念如何与深思熟虑的设计相关联,然后清楚地理解如何用健壮的代码实现这些设计。

本章将详细介绍 C++类的使用。微妙的特性和细微差别将超越基础知识进行详细说明。本章的目标是让您了解 OO 概念,并开始以面向对象编程的方式思考。拥抱核心的 OO 理念,如封装和信息隐藏,将使您能够编写更易于维护的代码,并使您更容易修改他人的代码。

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

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

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

  • 检查成员函数的内部;“this”指针

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

  • 理解构造函数 - 默认、重载、复制和转换构造函数

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

  • 对数据成员和成员函数应用限定符 - 内联、常量和静态

在本章结束时,您将了解适用于类的核心面向对象术语,并了解关键的 OO 思想,如封装和信息隐藏,将导致更易于维护的软件。

您还将了解 C++如何提供内置语言特性来支持面向对象编程。您将熟练掌握成员函数的使用,并理解它们通过this指针的基本实现。您将了解如何正确使用访问标签和访问区域来促进封装和信息隐藏。

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

您还将了解如何将限定符,如 const、static 和 inline,应用于成员函数,以支持面向对象的概念或效率。同样,您将了解如何将限定符,如 const 和 static,应用于数据成员,以进一步支持 OO 理念。

C可以用作面向对象的编程语言,但这并不是自动的。为此,您必须理解 OO 的概念、意识形态和语言特性,这将使您能够支持这一努力。让我们开始追求编写更易于修改和维护的代码,通过理解在面向对象 C OO 程序中找到的核心和基本构建块和语言特性,C++类。

技术要求

完整程序示例的在线代码可以在以下 GitHub URL 找到:github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter05。每个完整程序示例都可以在 GitHub 存储库中的适当章节标题(子目录)下找到,文件名为该章节号,后跟破折号,再跟该章节中的示例编号。例如,本章的第一个完整程序可以在名为Chp5-Ex1.cpp的文件中的Chapter05子目录中找到。

本章的 CiA 视频可在以下链接观看:bit.ly/2OQgiz9

介绍面向对象的术语和概念

在本节中,我们将介绍核心面向对象的概念以及适用的术语,这些术语将伴随着这些关键思想。虽然本章中会出现新术语,但我们将从必须理解的术语开始,以便在本节开始我们的旅程。

面向对象的系统因为封装和信息隐藏,因此更容易维护。用户定义类型的升级和修改可以快速进行,而不会对整个系统产生影响。

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

理解面向对象的术语

我们将从基本的面向对象术语开始,然后在介绍新概念时,我们将扩展术语以包括 C++特定的术语。

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

术语对象有时可能用来描述类似项的组合的蓝图。术语可能与对象的这种用法互换使用。术语对象也可能(更常见)用来描述这种组合中的特定项。术语实例可能与对象的这种含义互换使用。使用上下文通常会清楚地表明术语对象的哪种含义被应用。为避免潜在的混淆,最好使用术语实例

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

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

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

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

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

理解面向对象的概念

与本章相关的关键面向对象概念是封装信息隐藏。将这些相关的想法纳入到你的设计中,将为编写更易于修改和可维护的程序提供基础。

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

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

面向对象的系统因为类允许快速升级和修改而本质上更易于维护,这是由于封装和信息隐藏而不会对整个系统产生影响。

理解类和成员函数的基础

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

创建类类型的变量称为实例化。在 C中,类中的属性称为数据成员。在 C中,类中的操作称为成员函数,用于模拟行为。在 OO 术语中,操作意味着函数的签名,或者它的原型(声明),方法意味着其底层实现或函数的主体(定义)。在一些 OO 语言中,术语方法更松散地用于暗示操作或其方法,根据使用上下文而定。在 C++中,最常使用的术语是数据成员成员函数

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

C结构也可以用于封装数据及其相关行为。Cstruct可以做任何 Cclass可以做的事情;实际上,在 C中,class是以struct的方式实现的。尽管结构和类可能行为相同(除了默认可见性),类更常用于模拟对象,模拟对象类型之间的关系,并实现面向对象的系统。

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

https://github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter05/Chp5-Ex1.cpp

#include <iostream>
#include <cstring>
using namespace std;
struct student
{
    char name[20];
    float gpa;
    void Initialize(const char *, float);  // fn. prototype
    void Print();
};
class University
{
public:
    char name[30];
    int numStudents;
    void Initialize(const char *, int);   // fn. prototype
    void Print();
};

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

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

让我们来看看各种成员函数的定义:

void student::Initialize(const char *n, float avg)
{ 
    strcpy(name, n);
    gpa = avg;
}
void student::Print()
{ 
    cout << name << " GPA: " << gpa << endl;
}
void University::Initialize(const char *n, int num)
{ 
    strcpy(name, n);
    numStudents = num;
} 
void University::Print()
{ 
    cout << name << " Enrollment: " << numStudents << endl;
}

现在,让我们回顾一下每个用户定义类型的各种成员函数定义。在上面的片段中,void student::Initialize(const char *, float)void student::Print()void University::Initialize(const char *, int)void University::Print()的定义是连续的。注意作用域解析运算符::如何允许我们将相关的函数定义与其所属的classstruct联系起来。

另外,请注意,在每个Initialize()成员函数中,输入参数被用作值来加载特定类类型的特定实例的相关数据成员。例如,在void University::Initialize(const char *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的一个实例。实例化发生在为对象分配内存时。因此,使用University *u2;声明指针变量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(),因为s1的类型是student,我们在这个函数的主体中初始化了s1的数据成员。同样,当我们通过u1*u2调用Print()时,我们调用University::Print(),因为u1*u2的类型是University,我们随后打印出特定大学的数据成员。

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

伴随这个程序的输出如下:

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

现在,我们正在创建具有其关联的成员函数定义的类定义,重要的是要知道开发人员通常如何在文件中组织他们的代码。大多数情况下,一个类将被分成一个头(.h)文件,其中包含类定义,和一个源代码(.cpp)文件,它将#include头文件,然后跟随成员函数定义本身。例如,名为University的类将有一个University.h头文件和一个University.cpp源代码文件。

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

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

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

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

虽然在每个成员函数的定义中可能会显式引用this指针,但通常不会这样做。即使没有显式使用,函数范围内使用的数据成员属于this,即调用函数的对象的指针。

让我们来看一个完整的程序示例。虽然示例被分成了段落,但完整的程序可以在以下 GitHub 位置找到:

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter05/Chp5-Ex2.cpp

#include <iostream>
#include <cstring>
using namespace std;
class Student
{
public:  // for now, let's put everything public access region
    char *firstName;  // data members
    char *lastName;
    char middleInitial;
    float gpa;
    char *currentCourse;
    // member function prototypes
    void Initialize(const char *, const char *, char, 
                    float, const char *);
    void Print();
};

在程序的第一部分中,我们定义了类Student,其中包含各种数据成员和两个成员函数原型。现在,我们将把所有内容放在public访问区域。

现在,让我们来看一下void Student::Initialize()void Student::Print()的成员函数定义。我们还将内部查看每个函数的样子,对于 C++来说:

// Member function definition
void Student::Initialize(const char *fn, const char *ln, 
                       char mi, float gpa, const char *course)
{
    firstName = new char [strlen(fn) + 1];
    strcpy(firstName, fn);
    lastName = new char [strlen(ln) + 1];
    strcpy(lastName, ln);
    this->middleInitial = mi;  // optional use of 'this'
    this->gpa = gpa;  // required, explicit use of 'this'
    currentCourse = new char [strlen(course) + 1];
    strcpy(currentCourse, course);
}
// It is as if Student::Initialize() is written as:
// void 
// Student_Initialize_constchar*_constchar*_float_constchar*
//     (Student *const this, const char *fn, const char *ln,
//      char mi, float avg, char *course) 
// {
//    this->firstName = new char [strlen(fn) + 1];
//    strcpy(this->firstName, fn);
//    this->lastName = new char [strlen(ln) + 1];
//    strcpy(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()的成员函数定义,它接受各种参数。请注意,在这个函数的主体中,我们为数据成员firstName分配了足够的字符来容纳输入参数fn所需的内容(再加上一个终止的空字符)。然后,我们使用strcpy()将输入参数fn的字符串复制到数据成员firstName中。我们使用输入参数ln对数据成员lastName做同样的操作。然后,我们类似地使用各种输入参数来初始化将调用此函数的特定对象的各种数据成员。

另外,在void Student::Initialize()中注意赋值this->middleInitial = mi;。在这里,我们可以选择性地显式使用this指针。在这种情况下,没有必要或习惯性地用this限定middleInitial,但我们可以选择这样做。然而,在赋值this->gpa = gpa;中,使用this是必需的。为什么?注意输入参数的名称是gpa,数据成员也是gpa。简单地赋值gpa = gpa;会将最局部版本的gpa(输入参数)设置为自身,并不会影响数据成员。在这里,通过在赋值的左侧用this来消除gpa,表示设置数据成员gpa,该数据成员由this指向,为输入参数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来实现更中级的面向对象概念的情况。

尽管如此,让我们通过检查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.firstName;  // delete dynamically allocated
    delete s1.lastName;   // data members
    delete s1.currentCourse;
    delete s2->firstName;
    delete s2->lastName;
    delete s2->currentCourse;
    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++

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

使用访问标签和访问区域

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

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

  • private:此访问区域中的数据成员和成员函数只能在类的范围内访问。类的范围包括该类的成员函数。

  • private直到我们引入继承。当引入继承时,protected将提供一种机制,允许在派生类范围内访问。

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

提醒

几乎总是通过实例访问数据成员和成员函数。你会问,我的实例在什么范围内?以及我可以从这个特定的范围访问特定的成员吗?

程序员可以将尽可能多的成员分组到给定的标签或private下。如果在结构定义中省略了访问标签,则默认成员访问是public。当明确引入访问标签时,而不是依赖于默认可见性,classstruct是相同的。尽管如此,在面向对象编程中,我们倾向于使用类来定义用户定义的类型。

让我们通过一个例子来说明访问区域。尽管这个例子将被分成几个部分,但完整的例子将被展示,并且也可以在 GitHub 存储库中找到。

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter05/Chp5-Ex3.cpp

#include <iostream>
#include <cstring>
using namespace std;
class Student
{
// private members are accessible only within the scope of
// the class (e.g. within member functions or friends) 
private: 
    char *firstName;   // data members
    char *lastName;
    char middleInitial;
    float gpa;
    char *currentCourse;
    char *studentId;  
public:   // public members are accessible from any scope
    // member function prototypes
    void Initialize();  
    void Initialize(const char *, const char *, char, float, 
                    const char *, const char *);
    void CleanUp();
    void Print();
};

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

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

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

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

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

void Student::Initialize()
{
    firstName = lastName = 0;  // NULL pointer
    middleInitial = '\0';      // null character
    gpa = 0.0;
    currentCourse = studentId = 0;
}
// Overloaded member function definition
void Student::Initialize(const char *fn, const char *ln, 
      char mi, float avg, const char *course, const char *id) 
{
    firstName = new char [strlen(fn) + 1];
    strcpy(firstName, fn);
    lastName = new char [strlen(ln) + 1];
    strcpy(lastName, ln);
    middleInitial = mi; 
    gpa = avg;   
    currentCourse = new char [strlen(course) + 1];
    strcpy(currentCourse, course);
    studentId = new char [strlen(id) + 1];
    strcpy (studentId, id); 
}
// Member function definition
void Student::CleanUp()
{
    delete firstName;
    delete lastName;
    delete currentCourse;
    delete studentId;
}
// Member function definition
void Student::Print()
{
    cout << firstName << " " << middleInitial << ". ";
    cout << lastName << " with id: " << studentId;
    cout << " has gpa: " << gpa << " and enrolled in: ";
    cout << 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();  // Print() is public, accessible from main() 
    // Error! firstName is private; 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 with id: 178GW has gpa: 3.9 and is enrolled in: C++

既然我们了解了访问区域是如何工作的,让我们继续通过检查一个称为构造函数的概念,以及 C++中可用的各种类型的构造函数。

理解构造函数

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

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

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

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

一个class(或struct)用于定义初始化对象的多种方法。构造函数的返回类型可能不会被指定。

如果您的classstruct不包含构造函数,系统将为您创建一个公共访问区域中没有参数的构造函数。这被称为默认构造函数。在幕后,每当实例化一个对象时,编译器都会插入一个构造函数调用。当一个没有构造函数的类被实例化时,默认构造函数会被插入为一个函数调用,紧随实例化之后。这个系统提供的成员函数将有一个空的主体(方法),并且它将被链接到您的程序中,以便在实例化时可以发生任何编译器添加的隐式调用,而不会出现链接器错误。通常,程序员会编写自己的默认(无参数)构造函数;也就是说,用于默认实例化的构造函数。

大多数程序员至少会提供一个构造函数,除了他们自己的无参数默认构造函数。请记住,构造函数可以被重载。重要的是要注意,如果您自己提供了任何构造函数,那么您将不会收到系统提供的无参数默认构造函数,因此在实例化时使用这样的接口将导致编译器错误。

提醒

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

让我们介绍一个简单的例子来理解构造函数的基础知识:

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter05/Chp5-Ex4.cpp

#include <iostream>
#include <cstring>
using namespace std;
class University
{
private:
    char name[30];
    int numStudents;
public: 
    // constructor prototypes
    University(); // default constructor
    University(const char *, int);
    void Print();
};
University::University()
{
    name[0] = '\0';
    numStudents = 0;
}
University::University(const char * n, int num)
{
    strcpy(name, n);
    numStudents = num;
}
void University::Print()
{
    cout << "University: " << name;
    cout << " Enrollment: " << numStudents << endl;
}
int main()
{
    University u1; // Implicit call to default constructor
    University u2("University of Delaware", 23800);
    u1.Print();
    u2.Print();
    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 char *, int),来初始化该实例。

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

这个示例的输出是:

University: Enrollment: 0
University: University of Delaware Enrollment: 23800

接下来,让我们通过检查复制构造函数来增加对构造函数的了解。

创建复制构造函数

复制构造函数是一种专门的构造函数,每当可能需要复制对象时就会被调用。复制构造函数可能在构造另一个对象时被调用。它们也可能在通过输入参数以值传递给函数,或者从函数中以值返回对象时被调用。

通常,复制一个对象并稍微修改副本比从头开始构造一个新对象更容易。如果程序员需要一个经历了应用程序生命周期中的许多变化的对象的副本,这一点尤为真实。可能无法回忆起可能已应用于问题对象的各种转换的顺序,以创建一个副本。相反,拥有复制对象的手段是可取的,可能是至关重要的。

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

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

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

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

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

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

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter05/Chp5-Ex5.cpp

#include <iostream>  
#include <cstring>    
using namespace std;
class Student
{
private: 
    // data members
    char *firstName;
    char *lastName;
    char middleInitial;
    float gpa;
    char *currentCourse;  
public:
    // member function prototypes
    Student();  // default constructor
    Student(const char *, const char *, char, float, 
            const char *); 
    Student(const Student &);  // copy constructor prototype
    void CleanUp();
    void Print();
};

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

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

// default constructor
Student::Student()
{
    firstName = lastName = 0;  // NULL pointer
    middleInitial = '\0';
    gpa = 0.0;
    currentCourse = 0;
}
// Alternate constructor member function definition
Student::Student(const char *fn, const char *ln, char mi, 
                 float avg, const char *course)
{
    firstName = new char [strlen(fn) + 1];
    strcpy(firstName, fn);
    lastName = new char [strlen(ln) + 1];
    strcpy(lastName, ln);
    middleInitial = mi;
    gpa = avg;
    currentCourse = new char [strlen(course) + 1];
    strcpy(currentCourse, course);
}
// Copy constructor definition – implement a deep copy
Student::Student(const Student &s)
{
    // allocate necessary memory for destination string
    firstName = new char [strlen(s.firstName) + 1];
    // then copy source to destination string
    strcpy(firstName, s.firstName);
    lastName = new char [strlen(s.lastName) + 1];
    // data members which are not pointers do not need their
    // space allocated for deep copy, such as is done above
    strcpy(lastName, s.lastName);
    middleInitial = s.middleInitial;
    gpa = s.gpa;
    // allocate destination string space, then copy contents
    currentCourse = new char [strlen(s.currentCourse) + 1];
    strcpy(currentCourse, s.currentCourse);
}
// Member function definition
void Student::CleanUp()
{
    delete firstName;
    delete lastName;
    delete currentCourse;
}

// Member function definition
void Student::Print()
{
    cout << firstName << " " << middleInitial << ". ";
    cout << lastName << " has a gpa of: " << gpa;
    cout << " and is enrolled in: " << currentCourse << endl;
}

在上述代码片段中,我们有各种成员函数的定义。特别要注意的是复制构造函数的定义,它是具有Student::Student(const Student &s)签名的成员函数。

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

当我们仔细浏览复制构造函数时,请注意我们逐步为属于this指向的对象的任何指针数据成员分配空间。分配的空间与s引用的数据成员所需的大小相同。然后我们小心地从源数据成员复制到目标数据成员。我们确保在目标对象中对源对象进行精确复制。

请注意,我们在目标对象中进行了深复制。也就是说,我们不是简单地将s.firstName中包含的指针复制到this->firstName,而是为this->firstName分配空间,然后复制源数据。浅复制的结果将是每个对象中的指针数据成员共享相同的解引用内存(即,每个指针指向的内存)。这很可能不是您在复制时想要的。还要记住,系统提供的复制构造函数的默认行为是从源对象到目标对象提供浅复制。

现在,让我们来看一下我们的main()函数,看看复制构造函数可能被调用的各种方式:

int main()
{ 
    // instantiate two Students
    Student s1("Zachary", "Moon", 'R', 3.7, "C++");
    Student s2("Gabrielle", "Doone", 'A', 3.7, "C++");
   // These initializations implicitly invoke copy constructor
    Student s3(s1);  
    Student s4 = s2;
    strcpy(s3.firstName, "Zack");// alter each object slightly
    strcpy(s4.firstName, "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 an 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 char *, const char *, char, float, const char *);签名匹配的构造函数进行初始化。请注意,实例化中使用的签名是我们选择隐式调用哪个构造函数的方式。

接下来,我们实例化s3,并将对象s1作为参数传递给它的构造函数,Student s3(s1);。在这里,s1Student类型,因此这个实例化将匹配接受Student引用的构造函数,即复制构造函数。一旦进入复制构造函数,我们知道我们将对this指针在复制构造函数方法的范围内指向的新实例化对象s3进行deep copy

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

接下来,在代码的注释部分,我们实例化了两个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/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter05/Chp5-Ex6.cpp

#include <iostream>   
#include <cstring>   
using namespace std;
class Student;  // forward declaration of Student class
class Employee
{
private:
    char firstName[20];
    char lastName[20];
    float salary;
public:
    Employee();
    Employee(const char *, const char *, float);
    Employee(Student &);  // conversion constructor
    void Print();
};
class Student
{
private: // data members
    char *firstName;
    char *lastName;
    char middleInitial;
    float gpa;
    char *currentCourse;
public:
    // constructor prototypes
    Student();  // default constructor
    Student(const char *, const char *, char, float, 
            const char *);
    Student(const Student &);  // copy constructor
    void Print();
    void CleanUp();
    float GetGpa(); // access function for private data member
    const char *GetFirstName();
    const char *GetLastName();
};

在前面的程序片段中,我们首先包含了对class Student;的前向声明——这个声明允许我们在定义之前引用Student类型。然后我们定义class Employee。请注意,这个类包括几个public数据成员和三个构造函数原型——默认、替代和转换构造函数。值得一提的是,没有程序员指定的复制构造函数。这意味着编译器将提供默认(浅)复制构造函数。在这种情况下,由于没有指针数据成员,浅复制是可以接受的。

尽管如此,让我们继续通过检查转换构造函数的原型来进行。请注意,在原型中,这个构造函数接受一个参数。这个参数是Student &,这就是为什么我们需要对Student进行前向声明。最好的情况下,我们可能会使用const Student &作为参数类型,但为了这样做,我们需要了解 const 成员函数(本章后面会介绍)。将发生的类型转换将是将Student转换为新构造的Employee。我们的工作是在转换构造函数的定义中提供一个有意义的转换来实现这一点,我们很快就会看到。

接下来,我们定义我们的Student类,它与我们在以前的示例中看到的大致相同。

现在,让我们继续以示例来看EmployeeStudent的成员函数定义,以及我们的main()函数,在以下代码段中。为了节省空间,选择性地省略了一些成员函数定义;然而,在在线代码中将显示完整的程序。

继续前进,我们的EmployeeStudent的成员函数如下:

Employee::Employee()  // default constructor
{
    firstName[0] = lastName[0] = '\0';  // null character
    salary = 0.0;
}
// alternate constructor
Employee::Employee(const char *fn, const char *ln, 
                   float money)
{
    strcpy(firstName, fn);
    strcpy(lastName, ln);
    salary = money;
}
// conversion constructor – argument is a Student not Employee
Employee::Employee(Student &s)
{
    strcpy(firstName, s.GetFirstName());
    strcpy(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 char *Student::GetFirstName()
{
    return firstName;
}
const char *Student::GetLastName()
{
    return lastName;
}

在之前的代码段中,我们注意到了Employee的几个构造函数定义。我们有默认、替代和转换构造函数。

检查Employee转换构造函数的定义,注意源对象的形式参数是s,类型为Student。目标对象将是正在构造的Employee,它将由this指针指向。在这个函数的主体中,我们仔细地从Student &s复制firstNamelastName到新实例化的Employee。请注意,我们使用了访问函数const char *Student::GetFirstName()const char *Student::GetLastName()来做到这一点(通过Student的一个实例),因为这些数据成员是private的。

让我们继续使用转换构造函数。我们的工作是提供一种有意义的从一种类型到另一种类型的转换。在这个努力中,我们试图根据源Student对象的gpa来为Employee建立一个初始工资。因为gpaprivate的,所以使用访问函数Student::GetGpa()来检索这个值(通过源Student)。请注意,因为Employee没有任何动态分配的数据成员,所以我们不需要在这个函数的主体中分配内存来辅助深度复制。

为了节省空间,已省略了Student默认、替代和复制构造函数的成员函数定义,以及void Student::Print()void Student::CleanUp()成员函数的定义。然而,它们与之前展示Student类的完整程序示例中的相同。

注意Studentprivate数据成员的访问函数,比如float Student::GetGpa(),已经被添加以提供对这些数据成员的安全访问。请注意,从堆栈返回的float Student::GetGpa()的值是gpa数据成员的副本。原始的gpa不会因为使用这个函数而受到侵犯。对于成员函数const char *Student::GetFirstName()const char *Student::GetLastName()也是一样,它们每个都返回一个const char *,确保将返回的数据不会被侵犯。

让我们通过检查我们的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 dynamically
    return 0;      // allocated data members
}

在我们的main()函数中,我们实例化了一个Student,即s1,它隐式地使用匹配的构造函数进行初始化。然后我们使用转换构造函数实例化了一个Employeee1,在调用Employee e1(s1);时。乍一看,似乎我们正在使用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 进行释放(对于对象指针)

析构函数应该(通常)清理构造函数可能分配的任何内存。析构函数的名称是~字符后跟class名称。析构函数不带参数;因此,它不能被重载。最后,析构函数的返回类型可能不被指定。类和结构都可以有析构函数。

除了释放构造函数可能分配的内存之外,析构函数还可以用于执行实例的其他生命周期任务,例如将值记录到数据库中。更复杂的任务可能包括通知类数据成员指向的对象(其内存未被释放)即将结束的对象。如果链接的对象包含指向终止对象的指针,则这可能很重要。我们将在本书的后面看到这方面的例子,在第十章实现关联、聚合和组合

如果您没有提供析构函数,编译器将创建并链接一个带空体的public析构函数。这是必要的,因为析构函数调用会在本地实例被弹出堆栈之前自动打补丁,并且在应用delete()到动态分配的实例之前自动打补丁。对于编译器来说,总是打补丁比不断查看您的类是否有析构函数更容易。一个好的经验法则是始终自己提供类析构函数。

还有一些潜在的陷阱。例如,如果您忘记删除动态分配的实例,那么析构函数调用将不会为您打补丁。C++是一种给予您灵活性和权力来做(或不做)任何事情的语言。如果您不使用给定标识符删除内存(也许两个指针引用相同的内存),请记住以后通过其他标识符删除它。

还有一件值得一提的事情。虽然您可以显式调用析构函数,但您很少需要这样做。析构函数调用会在编译器自动为您打补丁在上述情况下。只有在非常少数的高级编程情况下,您才需要自己显式调用析构函数。

让我们看一个简单的例子,说明一个类析构函数,它将被分为三个部分。完整的示例可以在此处列出的 GitHub 存储库中看到:

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter05/Chp5-Ex7.cpp

#include <iostream>  
#include <cstring> 
using namespace std;
class University
{
private:
    char *name;
    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访问区域中填充了数据成员,以及public接口,其中包括默认、替代和复制构造函数的原型,以及析构函数和Print()方法。

接下来,让我们看一下各种成员函数的定义:

University::University()  // default constructor
{
    name = 0;  // NULL pointer
    numStudents = 0;
}
University::University(const char * n, int num) 
{
    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;
    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()
                 // destructor for u1 will be called before
    return 0;    // program completes 
}

在这里,我们实例化了两个University实例;u1是一个实例,u2指向一个实例。我们知道u2在其内存可用时被实例化,并且一旦内存可用,就会调用适用的构造函数。接下来,我们为两个实例调用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访问,这是你不愿意做的。内联函数可以解决这个困境,它允许你拥有一个成员函数来访问和操作你的私有数据的安全性,同时又能够执行几行代码而不需要函数调用的开销。

inline函数是一个其调用被替换为函数本身的函数。内联函数可以帮助消除调用非常小的函数所带来的开销。

为什么调用函数会有开销?当调用函数时,输入参数(包括this)被推送到栈上,为函数的返回值保留空间(尽管有时会使用寄存器),转移到代码的另一个部分需要在寄存器中存储信息以跳转到代码的那一部分,等等。用内联函数替换非常小的函数体可以提高程序的效率。

内联函数可以通过以下方式之一指定:

  • 将函数定义放在类定义内部

  • 在(典型的)函数定义中,在类定义之外找到关键字inline之前的返回类型。

在上述两种方式中将函数指定为inline只是一个请求,要求编译器考虑将函数体替换为其函数调用。这种替换不能保证。编译器何时可能不实际内联给定的函数?如果一个函数是递归的,它就不能被内联。同样,如果一个函数很长,编译器就不会内联这个函数。此外,如果函数调用是动态绑定的,具体实现在运行时确定(虚函数),它就不能被内联。

inline函数定义应该在头文件中与相应的类定义一起声明。这将允许对函数的任何修订在需要时重新扩展正确。

让我们看一个使用inline函数的例子。程序将被分成两个部分,其中一些众所周知的函数被移除。然而,完整的程序可以在 GitHub 存储库中看到:

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter05/Chp5-Ex8.cpp

#include <iostream>  
#include <cstring> 
using namespace std;
class Student
{
private: 
    // data members
    char *firstName;
    char *lastName;
    char middleInitial;
    float gpa;
    char *currentCourse;
public:
    // member function prototypes
    Student();  // default constructor
    Student(const char *, const char *, char, float, 
            const char *); 
    Student(const Student &);  // copy constructor
    ~Student();  // destructor
    void Print();
    // inline function definitions
    const char *GetFirstName() { return firstName; }  
    const char *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)
{
    delete currentCourse;  
    currentCourse = new char [strlen(c) + 1];
    strcpy(currentCourse, c); 
}

在前面的程序片段中,让我们从类定义开始。注意,在类定义中已经添加了几个访问函数定义,即GetFirstName()GetLastName()等函数。仔细看;这些函数实际上是在类定义内部定义的。例如,float GetGpa() { return gpa; }不仅仅是原型,而是完整的函数定义。由于函数放置在类定义内部,这样的函数被认为是inline

这些小函数提供了对私有数据成员的安全访问。例如,注意const char *GetFirstName()。这个函数返回一个指向firstName的指针,它在类中存储为char *。但是因为这个函数的返回值是const char *,这意味着调用这个函数的任何人都必须将返回值视为const char *,这意味着将其视为不可修改。如果这个函数的返回值被存储在一个变量中,那么这个变量也必须被定义为const char *。通过将这个指针向上转换为不可修改版本的自身,我们添加了一个规定,即没有人可以得到一个private数据成员(指针),然后改变它的值。

现在注意一下类定义的末尾,我们有一个void SetCurrentCourse(const char *);的原型。然后,在类定义之外,我们将看到这个成员函数的定义。注意在这个函数定义的void返回类型之前有关键字inline。由于这个函数是在类定义之外定义的,必须明确使用关键字。请记住,无论使用哪种inline方法,inline规范只是一个请求,要求编译器将函数体替换为函数调用。

让我们继续通过检查我们程序的其余部分来继续这个例子:

// 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()
{
    delete firstName;
    delete lastName;
    delete currentCourse;
}
int main()
{
    Student s1("Jo", "Muritz", 'Z', 4.0, "C++"); 
    cout << s1.GetFirstName() << " " << s1.GetLastName();
    cout << " Enrolled in: " << s1.GetCurrentCourse() << endl;
    s1.SetCurrentCourse("Advanced C++ Programming"); 
    cout << s1.GetFirstName() << " " << s1.GetLastName();
    cout << " New course: " << s1.GetCurrentCourse() << endl;
    return 0;
}

请注意,在我们的程序示例的其余部分中,省略了几个成员函数定义。这些函数的主体与前面的示例中完整展示了Student类的函数体相同,也可以在线查看。

让我们转而关注我们的main()函数。在这里,我们实例化了一个Student,名为s1。然后通过s1调用了几个inline函数,比如s1.GetFirstName();。因为Student::GetFirstName()是内联的,所以就好像我们直接访问数据成员firstName一样,因为这个函数的主体只有一个return firstName;语句。我们既可以使用函数来访问private数据成员(意味着在类的范围之外没有人可以修改这个数据成员),又可以使用inline函数的代码扩展来消除函数调用的开销。

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数据成员的可修改访问。

成员初始化列表必须在构造函数中用于初始化任何常量数据成员或引用。成员初始化列表提供了一种机制,用于初始化可能永远不会成为赋值的 l-values 的数据成员。成员初始化列表也可以用于初始化非 const 数据成员。出于性能原因,成员初始化列表通常是初始化任何数据成员(const 或非 const)的首选方式。

成员初始化列表可以出现在任何构造函数中,只需在形式参数列表后面放置一个:,然后是一个逗号分隔的数据成员列表,每个数据成员都与括号中的初始值配对。例如,在这里我们使用成员初始化列表来设置两个数据成员,studentIdgpa

Student::Student(): studentId(0), gpa(0.0)
{
   firstName = lastName = 0;  // NULL pointer
   middleInitial = '\0';
   currentCourse = 0;
}

有趣的是,引用必须使用成员初始化列表,因为引用被实现为常量指针。也就是说,指针本身指向特定的其他对象,不得指向其他地方。该对象的值可能会改变,但引用始终引用特定的对象,即初始化时的对象。

使用指针与const限定符可能会让人难以确定哪些情况需要使用初始化列表,哪些情况不需要。例如,指向常量对象的指针不需要使用成员初始化列表进行初始化。指针可以指向任何对象,但一旦指向对象后,就不能改变所引用的值。然而,常量指针必须使用成员初始化列表进行初始化,因为指针本身被固定在特定的地址上。

让我们看一个const数据成员的例子,以及如何使用成员初始化列表在完整的程序示例中初始化它的值。我们还将看到如何使用这个列表来初始化非 const 数据成员。虽然这个例子被分割并没有完整显示,但完整的程序可以在 GitHub 存储库中找到:

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter05/Chp5-Ex9.cpp

#include <iostream>  
#include <cstring> 
using namespace std;
class Student
{
private: 
    // data members
    char *firstName;
    char *lastName;
    char middleInitial;
    float gpa;
    char *currentCourse;
    const int studentId;   // constant data member
public:
    // member function prototypes
    Student();  // default constructor
    Student(const char *, const char *, char, float, 
            const char *, int); 
    Student(const Student &);  // copy constructor
    ~Student();  // destructor
    void Print();
    const char *GetFirstName() { return firstName; }  
    const char *GetLastName() { return lastName; }    
    char GetMiddleInitial() { return middleInitial; }
    float GetGpa() { return gpa; }
    const char *GetCurrentCourse() { return currentCourse; }
    void SetCurrentCourse(const char *);  // prototype only
};

在上述的Student类中,注意我们已经在类定义中添加了一个数据成员const int studentId;。这个数据成员将需要使用成员初始化列表来初始化每个构造函数中的这个常量数据成员。

让我们看看成员初始化列表如何在构造函数中工作:

// Usual definitions for the destructor, Print(), and 
// SetCurrentCourse() have been omitted to save space.
Student::Student(): studentId(0), gpa(0.0) // mbr. Init. list
{
   firstName = lastName = 0;  // NULL pointer
   middleInitial = '\0';
   currentCourse = 0;
}
Student::Student(const char *fn, const char *ln, char mi,
         float avg, const char *course, int id): 
         studentId (id), gpa (avg), middleInitial(mi)
{
   firstName = new char [strlen(fn) + 1];
   strcpy(firstName, fn);
   lastName = new char [strlen(ln) + 1];
   strcpy(lastName, ln);
   currentCourse = new char [strlen(course) + 1];
   strcpy(currentCourse, course);
}
Student::Student(const Student &s): studentId (s.studentId)
{
   firstName = new char [strlen(s.firstName) + 1];
   strcpy(firstName, s.firstName);
   lastName = new char [strlen(s.lastName) + 1];
   strcpy(lastName, s.lastName);
   middleInitial = s.middleInitial;
   gpa = s.gpa;
   currentCourse = new char [strlen(s.currentCourse) + 1];
   strcpy(currentCourse, s.currentCourse);
}
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构造函数。注意每个构造函数的形式参数列表后面都有一个:指定的各种成员初始化列表。

每个构造函数将使用成员初始化列表来设置const数据成员的值,比如studentId。此外,成员初始化列表可以作为一种简单的方式来初始化任何其他数据成员。我们可以通过查看默认或替代构造函数中的成员初始化列表来看到成员初始化列表被用来简单地设置非 const 数据成员的例子,例如Student::Student() : studentId(0), gpa(0.0)。在这个例子中,gpa不是const,所以在成员初始化列表中使用它是可选的。

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

Renee Alexander has gpa of: 3.7

接下来,让我们通过向成员函数添加const限定符来继续前进。

使用 const 成员函数

我们现在已经相当详尽地看到了常量限定符与数据一起使用。它也可以与成员函数一起使用。C++提供了一种语言机制来确保选定的函数不会修改数据;这种机制就是作用于成员函数的const限定符。

const 成员函数是指定(并强制执行)该方法只能对调用该函数的对象执行只读操作的成员函数。

常量成员函数意味着this的任何部分都不能被修改。然而,因为 C++允许类型转换,可以将this转换为它的非 const 对应部分,然后修改数据成员。然而,如果类设计者真的希望能够修改数据成员,他们简单地不会将成员函数标记为const

程序中声明的常量实例只能调用const成员函数。否则这些对象可能会被直接修改。

要将成员函数标记为const,关键字const应该在函数原型和函数定义的参数列表之后指定。

让我们看一个例子。它将被分成两个部分,有些部分被省略了;然而,完整的例子可以在 GitHub 存储库中看到:

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter05/Chp5-Ex10.cpp

#include <iostream>  
#include <cstring> 
using namespace std;
class Student
{
private: 
    // data members
    char *firstName;
    char *lastName;
    char middleInitial;
    float gpa;
    char *currentCourse;
    const int studentId;   // constant data member
public:
    // member function prototypes
    Student();  // default constructor
    Student(char *, char *, char, float, char *, int); 
    Student(const Student &);  // copy constructor
    ~Student();  // destructor
    void Print() const;
    const char *GetFirstName() const { return firstName; }  
    const char *GetLastName() const { return lastName; }    
    char GetMiddleInitial() const { return middleInitial; }
    float GetGpa() const { return gpa; }
    const char *GetCurrentCourse() const
        { return currentCourse; }
    int GetStudentId() const { return studentId; }
    void SetCurrentCourse(const char *);  // prototype 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() << endl;
    s1.SetCurrentCourse("Advanced C++ Programming");  
    cout << s1.GetFirstName() << " " << s1.GetLastName();
    cout << " New course: " << s1.GetCurrentCourse() << 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 s1使用Student::SetCurrentCourse()改变了他们的当前课程,然后打印了这门课的新值。

接下来,我们实例化了另一个Students2,它被限定为const。请注意,一旦这个学生被实例化,只有那些被标记为const的成员函数才能应用于s2。否则,实例可能会被修改。然后,我们使用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/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter05/Chp5-Ex11.cpp

#include <iostream>  
#include <cstring> 
using namespace std;
class Student
{
private: 
    // data members
    char *firstName;
    char *lastName;
    char middleInitial;
    float gpa;
    char *currentCourse;
    const char *studentId;  // pointer to constant string
    static int numStudents; // static data member
public:
    // member function prototypes
    Student();  // default constructor
    Student(const char *, const char *, char, float, 
            const char *, const char *); 
    Student(const Student &);  // copy constructor
    ~Student();  // destructor
    void Print() const;
    const char *GetFirstName() const { return firstName; }  
    const char *GetLastName() const { return lastName; } 
    char GetMiddleInitial() const { return middleInitial; }
    float GetGpa() const { return gpa; }
    const char *GetCurrentCourse() const 
        { return currentCourse; }
    const char *GetStudentId() const { return studentId; }
    void SetCurrentCourse(const char *);
    static int GetNumberStudents(); // static member function 
};
// definition for static data member 
// (which is implemented as an external variable)
int Student::numStudents = 0;  // notice initial value of 0
// Definition for static member function
inline int Student::GetNumberStudents()
{
    return numStudents;
}
inline void Student::SetCurrentCourse(const char *c) 
{
    delete currentCourse;  
    currentCourse = new char [strlen(c) + 1];
    strcpy(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,所以这个数据成员不需要使用成员初始化列表进行初始化,但它将需要一些特殊处理。

让我们继续审查构成这个类的其他成员函数:

Student::Student(): studentId (0) // default constructor
{
    firstName = lastName = 0;  // NULL pointer
    middleInitial = '\0';
    gpa = 0.0;
    currentCourse = 0;
    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 = new char [strlen(fn) + 1];
    strcpy(firstName, fn);
    lastName = new char [strlen(ln) + 1];
    strcpy(lastName, ln);
    middleInitial = mi;
    gpa = avg;
    currentCourse = new char [strlen(course) + 1];
    strcpy(currentCourse, course);
    char *temp = new char [strlen(id) + 1];
    strcpy (temp, id);  // studentId can't be an lvaue,  
    studentId = temp;   // but temp can!
    numStudents++;      // increment static counter
}
Student::Student(const Student &s)   // copy constructor 
{
    firstName = new char [strlen(s.firstName) + 1];
    strcpy(firstName, s.firstName);
    lastName = new char [strlen(s.lastName) + 1];
    strcpy(lastName, s.lastName);
    middleInitial = s.middleInitial;
    gpa = s.gpa;
    currentCourse = new char [strlen(s.currentCourse) + 1];
    strcpy(currentCourse, s.currentCourse);
    char *temp = new char [strlen(s.studentId) + 1];
    strcpy (temp, s.studentId); //studentId can't be an lvaue, 
    studentId = temp;           // but temp can!
    numStudents++;    // increment static counter
}

Student::~Student()    // destructor definition
{
    delete firstName;
    delete lastName;
    delete currentCourse;
    delete (char *) studentId; // cast is necessary for delete
    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初始化为0,意味着一个空指针。回想一下,我们可以使用成员初始化列表来初始化任何数据成员,但我们必须使用它来初始化const数据成员。也就是说,如果const部分等同于为实例分配的内存。由于在数据成员studentId的实例中分配的内存是一个指针,并且该数据成员的指针部分不是const(只是指向的数据),我们不需要为这个数据成员使用成员初始化列表。我们只是选择这样做。

然而,因为studentId是一个const char *,这意味着标识符studentId可能不作为 l 值,或者在赋值的左侧。在替代和复制构造函数中,我们希望初始化studentId,并且需要能够使用studentId作为 l 值。但我们不能。我们通过声明一个辅助变量char *temp;来规避这个困境,并分配它来包含我们需要加载所需数据的内存量。然后我们将所需的数据加载到temp中,最后我们让studentId指向temp来为studentId建立一个值。当我们离开每个构造函数时,局部指针temp被弹出堆栈;然而,内存现在被studentId捕获并被视为const

最后,在析构函数中,请注意,为了删除与const char *studentid相关联的内存,我们需要将studentId强制转换为非常量char *,因为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() << endl;
    cout << s2.GetFirstName() << " " << s2.GetLastName();
    cout << " Enrolled in " << s2.GetCurrentCourse() << 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()函数中,我们首先实例化两个Studentss1s2。当每个实例被构造初始化时,共享数据成员值numStudents被递增以反映我们应用程序中的学生数量。请注意,外部变量Student::numStudents,它保存了这个共享数据成员的内存,在程序开始时被初始化为0,在我们的代码中之前的语句:int Student::numStudents = 0;

在为每个Student打印一些细节之后,我们使用static访问函数Student::GetNumStudents()打印出static数据成员numStudents。调用这个函数的首选方式是Student::GetNumStudents();。因为numStudentsprivate的,只有Student类的方法才能访问这个数据成员。我们现在使用static成员函数提供了对static数据成员的安全、封装访问。

有趣的是,要记住static成员函数不会接收到this指针,因此它们可能操作的唯一数据将是类中的static数据(或其他外部变量)。同样,它们可能调用的唯一其他函数将是同一类中的其他static成员函数,或者外部非成员函数。

有趣的是,我们似乎可以通过任何实例调用Student::GetNumStudents(),比如s1.GetNumStudents();,就像我们在代码的注释部分中看到的那样。尽管看起来我们是通过一个实例调用成员函数,但函数不会接收到this指针。相反,编译器会重新解释调用,似乎是通过一个实例,然后用对内部的name-mangled函数的调用替换这个调用。从编程的角度来看,使用第一种调用方法来调用static成员函数更清晰,而不是似乎是通过一个永远不会传递给函数本身的实例来调用。

最后,这是我们完整程序示例的输出:

Nick Cole Enrolled in C++
Alex Tost Enrolled in C++
There are 2 students

现在我们已经回顾了本章的最后一个例子,是时候总结我们所学到的一切了。

总结

在本章中,我们已经开始了面向对象编程的旅程。我们学习了许多面向对象的概念和术语,并看到了 C如何直接支持实现这些概念。我们看到了 C类如何支持封装和信息隐藏,并且实现支持这些理想的设计如何导致更容易修改和维护的代码。

我们已经详细介绍了类的基础知识,包括成员函数。我们通过深入研究成员函数的内部,包括理解this指针是什么,以及它的工作原理 - 包括隐式接收this指针的成员函数的底层实现。

我们已经探讨了访问标签和访问区域。通过将数据成员分组在private访问区域,并提供一套public成员函数来操作这些数据成员,我们发现我们可以提供一种安全、受控和经过充分测试的手段来从每个类的范围内操作数据。我们已经看到,对类进行更改可以限制在成员函数本身。类的用户不需要知道数据成员的底层表示 - 这些细节是隐藏的,并且可以根据需要进行更改,而不会在应用程序的其他地方引起一系列更改。

我们已经深入探讨了构造函数的许多方面,通过检查默认、典型(重载)构造函数,复制构造函数,甚至转换构造函数。我们已经介绍了析构函数,并了解了它的正确用法。

我们通过对数据成员和成员函数使用各种限定符,如inline以提高效率,const以保护数据并确保函数也是如此,static数据成员以模拟类属性的 OO 概念,以及static方法来提供对这些static数据成员的安全接口,为我们的类增添了额外的特色。

通过沉浸在面向对象编程中,我们获得了与 C++中类相关的一套全面的技能。拥有一套全面的技能和使用类的经验,以及对面向对象编程的欣赏,我们现在可以继续前进,学习如何通过第六章使用单继承实现层次结构,来构建一系列相关类的层次结构。让我们继续前进!

问题

  1. 创建一个 C++程序来封装一个Student。您可以使用之前练习的部分。尝试自己做这个,而不是依赖任何在线代码。您将需要这个类作为未来示例的基础;现在是一个很好的时机来尝试每个功能。具体来说:
  1. 创建或修改你之前的Student类,完全封装一个学生。确保包含几个动态分配的数据成员。提供多个重载的构造函数来初始化你的类。确保包含一个拷贝构造函数。还要包含一个析构函数来释放任何动态分配的数据成员。

  2. 为你的类添加一系列访问函数,以提供对类内数据成员的安全访问。决定为哪些数据成员提供GetDataMember()接口,以及这些数据成员中是否有任何可以在构造后重置的能力,使用SetDataMember()接口。根据需要对这些方法应用constinline限定符。

  3. 确保使用适当的访问区域 - 对于数据成员使用private,可能对一些辅助成员函数使用private来分解一个较大的任务。根据需要添加public成员函数,超出上面的访问函数。

  4. 在你的类中至少包含一个const数据成员,并利用成员初始化列表来设置这个成员。添加至少一个static数据成员和一个static成员函数。

  5. 使用每个构造函数签名实例化一个Student,包括拷贝构造函数。使用new()动态分配多个实例。确保在使用完毕后delete()每个实例(这样它们的析构函数将被调用)。

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

本章将扩展我们在 C中面向对象编程的追求。我们将首先介绍额外的面向对象概念,如泛化特化,然后理解这些概念如何通过直接语言支持在 C中实现。我们将开始构建相关类的层次结构,并理解每个类如何成为我们应用程序中更易于维护、可重复使用的构建模块。我们将理解本章介绍的新的面向对象概念将支持精心规划的设计,并清楚地了解如何在 C++中使用健壮的代码来实现这些设计。

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

  • 面向对象的泛化和特化概念,以及 Is-A 关系

  • 单继承基础-定义派生类,访问继承成员,理解继承访问标签和区域

  • 单继承层次结构中的构造和销毁顺序;使用成员初始化列表选择基类构造函数

  • 修改基类列表中的访问标签-公共与私有和受保护的基类-以改变继承的 OO 目的为实现继承

通过本章结束时,您将了解泛化和特化的面向对象概念,并将知道如何在 C++中使用继承作为实现这些理想的机制。您将了解基类和派生类等术语,以及构建层次结构的面向对象动机,例如支持 Is-A 关系或支持实现继承。

具体来说,您将了解如何使用单继承来扩展继承层次结构,以及如何访问继承的数据成员和成员函数。您还将了解根据其定义的访问区域,您可以直接访问哪些继承成员。

您将了解当实例化和销毁派生类类型的实例时,构造函数和析构函数的调用顺序。您将知道如何利用成员初始化列表来选择派生类对象可能需要利用作为其自身构造的一部分的潜在组中的继承构造函数。

您还将了解如何更改基类列表中的访问标签会改变您正在扩展的继承层次结构的 OO 含义。通过检查公共与私有和受保护的基类,您将了解不同类型的层次结构,例如那些用于支持 Is-A 关系的层次结构,与那些用于支持实现继承的层次结构。

通过理解 C中单继承的直接语言支持,您将能够实现泛化和特化的面向对象概念。您的层次结构中的每个类将成为更易于维护的组件,并且可以作为创建新的、更专业化组件的潜在构建模块。让我们通过详细介绍单继承来进一步了解 C作为面向对象编程语言。

技术要求

完整程序示例的在线代码可在以下 GitHub URL 找到:github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter06。每个完整程序示例都可以在 GitHub 存储库中的适当章节标题(子目录)下找到,文件名与所在章节编号相对应,后跟破折号,再跟上该章节中的示例编号。例如,本章的第一个完整程序可以在名为Chp6-Ex1.cpp的文件中的子目录Chapter06中找到。

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

扩展面向对象的概念和术语

在本节中,我们将介绍基本的面向对象概念,以及将伴随这些关键思想的适用术语。

第五章 详细探讨类,你现在了解了封装和信息隐藏的关键面向对象思想,以及 C如何通过 C类支持这些理念。现在,我们将看看如何通过使用一个非常通用的类作为基础模块来构建一系列相关类的层次结构,然后通过创建更具体的类来扩展该类。通过以这种重复的方式构建一系列相关类的层次结构,面向对象系统提供了潜在的重复使用的基础模块。层次结构中的每个类都是封装的,因此可以更轻松地对特定类进行维护和升级,而不会影响整个系统。通过逐步将每个类与更具体和更详细的类相结合,构建一系列相关类的层次结构,可以在一个专注的维护和更改领域内进行每个组件的具体维护。

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

理解泛化和特化

本章延伸的主要面向对象概念是泛化特化。将这些原则纳入设计中将为编写更易于修改和可维护的代码提供基础,并且可能在相关应用中重复使用代码。

泛化描述了从一组类中抽象出共同点并创建一个更通用的类来容纳这些共同的属性和行为的过程。更通用的类可以被称为基类(或父类)。泛化也可以用于将单个类的更一般的属性和行为收集到一个基类中,期望新的通用类以后可以作为附加的、更具体(派生)的类的基础或基础。

特化描述了从现有的通用基类派生出一个新类的过程,目的是添加特定的、可区分的属性和行为,以充分代表新类。特化类也可以称为派生(或子类)类。通过特化,类的层次结构可以逐步完善其各自的属性和行为。

尽管重复使用很难实现,但面向对象的概念,如泛化和特化,使得重复使用更容易实现。重复使用可以在性质相似的应用程序中实现,或者在同一项目领域中,或者在现有项目的延续中,或者在相关领域中实现,至少可以最大程度地重复使用最通用的类和相关组件。

构建层次结构是 C++的基本语言特性。让我们通过探索单继承来将这个想法付诸实践。

理解单继承的基本知识

继承是 C语言机制,允许实现泛化和特化的概念。单继承是指给定类只有一个直接的基类。C支持单继承和多继承,但在本章中我们将专注于单继承,并将在后面的章节中介绍多继承。

在 C++中可以使用类和结构来构建继承层次结构。然而,通常使用类而不是结构来支持继承和面向对象编程。

为了支持泛化和特化的目的而扩展继承层次结构,我们可以说一个学生是一个人。也就是说,StudentPerson的特化,添加了基类Person提供的额外数据成员和成员函数。通过泛化和特化指定 Is-A 关系是使用继承创建基类和派生类的最典型原因。在本章的后面,我们将看到另一个使用继承的原因。

让我们开始看一下 C++中指定基类和派生类以及定义继承层次结构的语言机制。

定义基类和派生类,并访问继承的成员

在单一继承中,派生类指定了它的直接祖先或基类是谁。基类不指定它有任何派生类。

派生类只需通过在其类名后添加:,然后是关键字public(暂时),然后是特定的基类名,来创建一个基类列表。每当你在基类列表中看到一个public关键字,这意味着我们正在使用继承来指定 Is-A 关系。

这里有一个简单的例子来说明基本语法:

  • StudentPerson的派生类:
class Person  // base class
{
private:
    char *name;
    char *title;
public:
    // constructors, destructor, 
    // public access functions, public interface etc …
    const char *GetTitle() const { return title; }
};
class StudentPerson, and the derived class is Student. The derived class need only define additional data members and member functions that augment those specified in the base class. Instances of a derived class may generally access `public` members specified by the derived class or by any ancestor of the derived class. Inherited members are accessed in the same fashion as those specified by the derived class. Recall, `.` dot notation is used to access members of objects, and `->` arrow notation is used to access members of pointers to objects. Of course, to make this example complete, we will need to add the applicable constructors, which we currently assume exist. Naturally, there will be nuances with constructors relating to inheritance, which we will soon cover in this chapter. 
  • 可以使用上述类来简单访问继承的成员,如下所示:
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;
    return 0;
}

在前面的代码片段中,由s1指向的派生类实例Student可以访问基类和派生类成员,比如Person::GetTitle()Student::GetGpa()。基类实例Personp1,只能访问自己的成员,比如Person::GetTitle()

查看上面示例的内存模型,我们有:

图 6.1 - 当前示例的内存模型

图 6.1 - 当前示例的内存模型

在前面的内存模型中,Student实例由Person子对象组成。也就是说,在指示*s1的内存地址上,一个Student,我们首先看到它的Person数据成员的内存布局。然后,我们看到它额外的Student数据成员的内存布局。当然,p1,它是一个Person,只包含Person数据成员。

基类和派生类成员的访问将受到每个类指定的访问区域的限制。让我们看看继承的访问区域是如何工作的。

检查继承的访问区域

访问区域,包括继承的访问区域,定义了从哪个范围直接访问成员,包括继承的成员。

派生类继承了其基类中指定的所有成员。然而,对这些成员的直接访问受到基类指定的访问区域的限制。

基类继承的成员(包括数据和函数)按照基类强加的访问区域对派生类是可访问的。继承的访问区域及其与派生类访问的关系如下:

  • 在基类中定义的private成员在基类的范围之外是不可访问的。类的范围包括该类的成员函数。

  • 在基类中定义的protected成员在基类的范围内和派生类或其后代的范围内是可访问的。这意味着这些类的成员函数。

  • 在基类中定义的public成员可以从任何范围访问,包括派生类的范围。

在前面的简单示例中,我们注意到PersonStudent实例都从main()的范围内访问了public成员函数Person::GetTitle()。此外,我们注意到Student实例从main()访问了它的public成员Student::GetGpa()。通常,在给定类的范围之外,只有公共接口中的成员是可访问的,就像在这个例子中一样。

本章我们将很快看到一个更大的完整程序示例,展示protected访问区域。但首先,让我们回顾一下继承的构造函数和析构函数,以便我们的完整程序示例可以提供更大的整体效用。

理解继承的构造函数和析构函数

通过单一继承,我们可以构建一组相关的类。我们已经看到,当我们实例化派生类对象时,其基类数据成员的内存将被额外需要的派生类数据成员的内存所跟随。每个子对象都需要被构造。幸运的是,每个类都将为此目的定义一套构造函数。然后我们需要理解语言如何被利用来允许我们在实例化和构造派生类对象时指定适当的基类构造函数。

同样,当不再需要派生类类型的对象并且将被销毁时,重要的是要注意,将为组成派生类实例的每个子对象隐式调用析构函数。

让我们来看一下单一继承层次结构中的构造函数和析构函数顺序,以及当一个基类子对象在派生类实例中找到多个构造函数可用时,我们如何做出选择。

隐式构造函数和析构函数调用

构造函数和析构函数是两种不被派生类显式继承的成员函数。这意味着基类构造函数的签名不能用来实例化派生类对象。然而,我们将看到,当实例化派生类对象时,整体对象的基类和派生类部分的内存将分别使用各自的构造函数进行初始化。

当实例化派生类类型的对象时,不仅将调用其构造函数,还将调用其每个前面基类的构造函数。最一般的基类构造函数将首先被执行,然后一直沿着层次结构调用构造函数,直到达到与手头实例相同类型的派生类构造函数。

同样,当派生类实例超出范围(或对实例的指针进行显式释放)时,所有相关的析构函数将被调用,但顺序与构造相反。首先,派生类析构函数将被执行,然后将依次调用和执行每个前面基类的析构函数,直到达到最一般的基类。

现在你可能会问,当我实例化一个派生类时,我如何从一组潜在的基类构造函数中选择适合我的基类子对象?让我们更详细地看一下成员初始化列表,找到解决方案。

使用成员初始化列表来选择基类构造函数

成员初始化列表可以用来指定在实例化派生类对象时应调用哪个基类构造函数。每个派生类构造函数可以指定使用不同的基类构造函数来初始化派生类对象的给定基类部分。

如果派生类构造函数的成员初始化列表没有指定应使用哪个基类构造函数,则将调用默认的基类构造函数。

成员初始化列表在派生类构造函数的参数列表后使用:来指定。为了指定应该使用哪个基类构造函数,可以指定基类构造函数的名称,后跟括号,包括要传递给该基类构造函数的任何值。根据在基类名称后的基类列表中参数的签名,将选择适当的基类构造函数来初始化派生类对象的基类部分。

这是一个简单的示例,用来说明基类构造函数选择的基本语法:

  • 让我们从基本的类定义开始(请注意,许多成员函数被省略):
class Person
{
private:
    char *name;
    char *title;
public:
    Person();  // various constructors
    Person(const char *, const char *); 
    Person(const Person &);
    ~Person();  // destructor
// Assume the public interface, access functions exist
};
class Student: public Person
{
private:
    float gpa;
public:
    Student();
    Student(const char *, const char *, float);
    ~Student();
// Assume the public interface, access functions exist
};
  • 之前的类定义的构造函数如下(请注意,两个派生类构造函数使用了成员初始化列表):
// Base class constructors
Person::Person()
{
    name = title = 0;  // null pointer
}
Person::Person(const char *n, const char *t)
{    // implementation as expected
}
Person::Person(const Person &p)
{   // implementation as expected
}
// Derived class constructors
Student::Student()   // default constructor
{
    gpa = 0.0;
}
Student::Student(const char *n, const char *t, 
                 float g)Student::Student(), does not utilize the member initialization list to specify which Person constructor should be used. Because none has been selected, the default Person constructor (with no arguments) is called. Next, notice in the alternate derived class constructor, `Student::Student(const char *, const char *, float)`, the use of the member initialization list. Here, the `Person` constructor matching the signature of `Person::Person(const char *, const char *)` is selected to initialize the `Person` sub-object at hand. Also, notice that parameters from the `Student` constructor, `n` and `t`, are passed up to the aforementioned `Person` constructor to help complete the `Person` sub-object initialization.Now, notice in the copy constructor for the derived class, `Student::Student(const Student &)`, the member initialization list is used to select the `Person` copy constructor, passing `s` as a parameter to the `Person` copy constructor. Here, the object referenced by `s` is a `Student`, however, the top part of `Student` memory contains `Person` data members. Hence, it is acceptable to up-cast the `Student` to a `Person` to allow the `Person` copy constructor to initialize the `Person` sub-object. In the body of the `Student` copy constructor, the additional data members added by the `Student` class definition are initialized in the body of this function. Namely, by setting `gpa = s.gpa;`.

现在我们知道如何利用成员初始化列表来指定基类构造函数,让我们继续进行一个完整的程序示例。

将所有部分组合在一起

到目前为止,在本章中,我们已经看到了许多部分构成了一个完整的程序示例。重要的是要看到我们的代码在运行中,以及它的各个组件。我们需要看到继承的基本机制,成员初始化列表是如何用来指定应该隐式调用哪个基类构造函数的,以及protected访问区域的重要性。

让我们来看一个更复杂的完整程序示例,以充分说明单一继承。这个示例将被分成几个部分;完整的程序可以在以下 GitHub 位置找到:

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter06/Chp6-Ex1.cpp

#include <iostream>
#include <iomanip>
#include <cstring>
using namespace std;
class Person
{
private: 
   // data members
   char *firstName;
   char *lastName;
   char middleInitial;
   char *title;  // Mr., Ms., Mrs., Miss, Dr., etc.
protected: // make available to derived classes in their scope
   void ModifyTitle(const char *); 
public:
   Person();   // default constructor
   Person(const char *, const char *, char, const char *);  
   Person(const Person &);  // copy constructor
   ~Person();  // destructor
   // inline function definitions
   const char *GetFirstName() const { return firstName; }  
   const char *GetLastName() const { return lastName; }    
   const char *GetTitle() const { return title; } 
   char GetMiddleInitial() const { return middleInitial; }
};

在之前的类定义中,我们现在有了一个完整的Person类定义,比我们在本节中以前使用的简单语法示例要详细得多。请注意,我们引入了一个protected访问区域,并将成员函数void ModifyTitle(const char *);放在这个访问区域中。

继续前进,让我们来看一下Person的非内联成员函数定义:

Person::Person()
{
   firstName = lastName = 0;  // NULL pointer
   middleInitial = '\0';
   title = 0;
}
Person::Person(const char *fn, const char *ln, char mi, 
               const char *t)
{
   firstName = new char [strlen(fn) + 1];
   strcpy(firstName, fn);
   lastName = new char [strlen(ln) + 1];
   strcpy(lastName, ln);
   middleInitial = mi;
   title = new char [strlen(t) + 1];
   strcpy(title, t);
}
Person::Person(const Person &pers)
{
   firstName = new char [strlen(pers.firstName) + 1];
   strcpy(firstName, pers.firstName);
   lastName = new char [strlen(pers.lastName) + 1];
   strcpy(lastName, pers.lastName);
   middleInitial = pers.middleInitial;
   title = new char [strlen(pers.title) + 1];
   strcpy(title, pers.title);
}
Person::~Person()
{
   delete firstName;
   delete lastName;
   delete title;
}
void Person::ModifyTitle(const char *newTitle)
{
   delete title;  // delete old title
   title = new char [strlen(newTitle) + 1];
   strcpy(title, newTitle);
}

上述Person成员函数的实现如预期的那样。现在,让我们添加派生类Student的类定义,以及它的内联函数定义:

class Student: public Person
{
private: 
   // data members
   float gpa;
   char *currentCourse;
   const char *studentId;  
public:
   // member function prototypes
   Student();  // default constructor
   Student(const char *, const char *, char, const char *,
           float, const char *, const char *); 
   Student(const Student &);  // copy constructor
   ~Student();  // destructor
   void Print() const;
   void EarnPhD();  // public interface to inherited 
                    // protected member
   // inline function definitions
   float GetGpa() const { return gpa; }
   const char *GetCurrentCourse() const 
       { return currentCourse; }
   const char *GetStudentId() const { return studentId; }
   // prototype only, see inline function definition below
   void SetCurrentCourse(const char *);
};
inline void Student::SetCurrentCourse(const char *c)
{
   delete currentCourse;   // delete existing course
   currentCourse = new char [strlen(c) + 1];
   strcpy(currentCourse, c); 
}

在之前的Student定义中,class Student是通过public继承(即公共基类)从Person派生的,支持 Is-A 关系。请注意,在派生类定义中的基类列表后面的:之后有一个public访问标签(即class Student: public Person)。请注意,我们的Student类添加了数据成员和成员函数,超出了它从Person自动继承的那些。

接下来,添加非内联的Student成员函数,我们继续完善我们的代码:

Student::Student() : studentId (0)   // default constructor
{
   gpa = 0.0;
   currentCourse = 0;
}
// alternate constructor
Student::Student(const char *fn, const char *ln, char mi, 
                 const char *t, float avg, const char *course,
                 const char *id): Person(fn, ln, mi, t)
{
   gpa = avg;
   currentCourse = new char [strlen(course) + 1];
   strcpy(currentCourse, course);
   char *temp = new char [strlen(id) + 1];
   strcpy (temp, id); 
   studentId = temp;
}
// copy constructor
Student::Student(const Student &ps): Person(ps)
{
   gpa = ps.gpa;
   currentCourse = new char [strlen(ps.currentCourse) + 1];
   strcpy(currentCourse, ps.currentCourse);
   char *temp = new char [strlen(ps.studentId) + 1];
   strcpy (temp, ps.studentId); 
   studentId = temp;
}

// destructor definition
Student::~Student()
{
   delete currentCourse;
   delete (char *) studentId;
}
void Student::Print() const
{
   // Private members of Person are not directly accessible
   // within the scope of Student, so we use access functions 
   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(char *, char *, char, char *),并且将从Student构造函数中选择的输入参数(即fnlnmit)作为参数传递给Person的替代构造函数。

Student的复制构造函数中,使用成员初始化列表指定应调用Person的复制构造函数来初始化正在构造的Student实例中包含的Person子对象。Student &将被隐式向上转型为Person &,因为调用了Person的复制构造函数。请记住,Student对象的顶部部分Is-APerson,所以这是可以的。接下来,在Student的复制构造函数的主体中,我们初始化了Student类定义的任何剩余数据成员。

继续向前,我们看到了Student的析构函数。隐式地,作为这个方法中的最后一行代码,编译器为我们补充了对Person析构函数的调用。这就是析构函数序列是如何自动化的。因此,对象的最专业化部分,即Student部分,将首先被销毁,然后隐式调用Person析构函数来销毁基类子对象。

接下来,在StudentPrint()方法中,请注意我们想要打印出从Person继承的各种数据成员。遗憾的是,这些数据成员是private的。我们不能在Person类的范围之外访问它们。然而,Person类留下了一个公共接口,比如Person::GetTitle()Person::GetFirstName(),这样我们就可以从我们应用程序的任何范围访问这些数据成员,包括从Student::Print()中。

最后,我们来到Student::EarnPhD()方法。请注意,这个方法所做的就是调用protected成员函数Person::ModifyTitle("Dr.");。请记住,基类定义的protected成员在派生类的范围内是可访问的。Student::EarnPhD()是派生类的成员函数。EarnPhD()提供了一个公共接口来修改Person的头衔,也许在检查学生是否达到毕业要求之后。因为Person::ModifyTitle()不是publicPersonStudent的实例必须通过受控的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();
    return 0;
}

在程序的最后一部分,在main()中,我们只是实例化了一个Student,即s1Student利用Student::Print()来打印其当前数据。然后,Student将她当前的课程设置为“博士论文”,然后调用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

现在我们已经掌握了单继承的基本机制,并且已经使用单继承来模拟 Is-A 关系,让我们继续看看继承如何用于模拟不同的概念,通过探索受保护和私有基类。

实现继承-改变继承的目的

到目前为止,我们已经演示了使用公共基类,也称为公共继承。公共基类用于建模 Is-A 关系,并为构建继承层次结构提供了主要动机。这种用法支持泛化和特化的概念。

偶尔,继承可能被用作一种工具,以另一个类的术语来实现一个类,也就是说,一个类使用另一个类作为其基础实现。这被称为实现继承,它不支持概括和特化的理想。然而,实现继承可以提供一种快速和易于重用的实现一个类的方式。它快速且相对无误。许多类库在不知晓其类用户的情况下使用这个工具。重要的是要区分实现继承和传统层次结构构建的动机,以指定 Is-A 关系。

在 C中支持使用私有和受保护的基类来实现实现继承,这是 C独有的。其他面向对象的语言选择只支持用于建模 Is-A 关系的继承,而 C通过公共基类支持这一点。面向对象的纯粹主义者会努力只使用继承来支持概括和特化(Is-A)。然而,使用 C,我们将理解实现继承的适当用法,以便明智地使用这种语言特性。

让我们继续了解我们可能如何以及为什么使用这种类型的继承。

通过使用受保护或私有基类修改基类列表中的访问标签

重申一下,通常的继承类型是public继承。在给定派生类的基类列表中使用public标签。然而,在基类列表中,关键字protectedprivate也是可选项。

也就是说,除了在类或结构定义中标记访问区域之外,访问标签还可以在派生类定义的基类列表中使用,以指定基类中定义的成员如何被派生类继承。

继承成员只能比在基类中指定的更加严格。当派生类指定继承成员应以更加严格的方式对待时,该派生类的任何后代也将受到这些规定的约束。

让我们看一个基类列表的快速示例:

  • 请记住,基类列表中通常会指定public访问标签。

  • 在这个例子中,使用public访问标签来指定PersonStudentpublic基类。也就是说,Student Is-A Person

class Student: public Person
{
    // usual class definition
};

基类列表中指定的访问标签会修改继承的访问区域,如下所示:

  • public:基类中的公共成员可以从任何范围访问;基类中的受保护成员可以从基类和派生类的范围访问。我们熟悉使用公共基类。

  • protected:基类中的公共和受保护成员在派生类中的作用就像它们被派生类定义为受保护的一样(即可以从基类和派生类的范围以及派生类的任何后代中访问)。

  • private:基类中的公共和受保护成员在派生类中的作用就像它们被定义为私有的一样,允许这些成员在派生类的范围内访问,但不允许在任何派生类的后代范围内访问。

注意

在所有情况下,在类定义中标记为私有的类成员只能在定义类的范围内访问。修改基类列表中的访问标签只能更加严格地处理继承成员,而不能更加宽松地处理。

在与基类一起指定的访问标签缺失时,如果用户定义的类型是class,则假定为private,如果用户定义的类型是struct,则默认为public。一个好的经验法则是在派生类(或结构)定义的基类列表中始终包括访问标签。

创建一个基类来说明实现继承

为了理解实现继承,让我们回顾一个可能作为实现其他类基础的基类。我们将检查一对典型的类,以实现封装的LinkList。尽管这个例子将被分成几个部分,但完整的例子将被展示,并且也可以在 GitHub 上找到:

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter06/Chp6-Ex2.cpp

#include <iostream>
using namespace std;
typedef int Item;  
class LinkListElement  // one 'node' or element in a LinkList
{
private:
    void *data;
    LinkListElement *next;
public:
    LinkListElement() { data = 0; next = 0; }
    LinkListElement(Item *i) { data = i; next = 0; }
    ~LinkListElement() { delete (Item *) data; next = 0; }
    void *GetData() { return data; }
    LinkListElement *GetNext() { return next; }
    void SetNext(LinkListElement *e) { next = e; }
};
class LinkList   // an encapsulated LinkList
{
private:
    LinkListElement *head;
    LinkListElement *tail;
    LinkListElement *current;
public:
    LinkList();
    LinkList(LinkListElement *);
   ~LinkList();
    void InsertAtFront(Item *);
    LinkListElement *RemoveAtFront();
    void DeleteAtFront();
    int IsEmpty() { return head == 0; } 
    void Print();  
};

我们从上一段代码开始,定义了LinkListElementLinkList的类定义。LinkList类将包含指向LinkListheadtailcurrent元素的指针的数据成员。这些指针都是LinkListElement类型。包括各种典型的LinkList处理方法,如InsertAtFront()RemoveAtFront()DeleteAtFront()IsEmpty()Print()。让我们快速查看这些方法的实现,下一段代码中会有。

LinkList::LinkList()
{
    head = tail = current = 0;
}
LinkList::LinkList(LinkListElement *element)
{
    head = tail = current = element;
}
void LinkList::InsertAtFront(Item *theItem)
{
    LinkListElement *temp = new LinkListElement(theItem);
    temp->SetNext(head);  // temp->next = head;
    head = temp;
}
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 NULL

void LinkList::Print()
{
    Item output;
    if (!head)
       cout << "<EMPTY>";
    current = head;
    while (current)
    {
        output = *((Item *) current->GetData());
        cout << output << " ";
        current = current->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 a 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 and 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()逐个删除每个元素,打印每个步骤后的修订列表。

这个例子的重点不是详尽地审查这段代码的内部工作原理。毫无疑问,您对LinkList的概念非常熟悉。更重要的是,要将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>

接下来,让我们看看LinkList如何作为私有基类使用。

使用私有基类来实现一个类以另一个类为基础

我们刚刚创建了一个LinkList类,以支持封装的链表数据结构的基本处理。现在,让我们想象一下,我们想要实现Push()Pop()IsEmpty(),也许还有Print()

你可能会问栈是如何实现的。答案是实现并不重要,只要它支持被建模的 ADT 的预期接口。也许栈是使用数组实现的,或者它是在文件中实现的。也许它是使用LinkedList实现的。每种实现都有优缺点。事实上,ADT 的底层实现可能会改变,但 ADT 的用户不应受到这种变化的影响。这就是实现继承的基础。派生类是基类的实现,但派生类的底层细节是有效隐藏的。这些细节不能直接被派生类的实例(在这种情况下是 ADT)使用。尽管如此,基类默默地为派生类提供实现。

我们将使用这种方法来使用LinkedList作为其底层实现来实现一个Stack。为此,我们将让class Stack扩展LinkedList,使用一个private基类。Stack将为其用户定义一个公共接口,以建立这个 ADT 的接口,比如Push()Pop()IsEmpty()Print()。这些成员函数的实现将使用选定的LinkedList成员函数,但Stack的用户将看不到这一点,Stack的实例也不能直接使用任何LinkList成员。

在这里,我们并不是说Stack是一个LinkList,而是说,一个Stack是目前以LinkedList为基础实现的——而这个底层实现可能会改变!

实现Stack的代码很简单。假设我们使用了前面例子中的LinkListLinkListElement类。让我们在这里添加Stack类。完整的程序示例可以在我们的 GitHub 上找到:

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter06/Chp6-Ex3.cpp

class Stack: private LinkList
{
private:
    // no new data members are necessary
public:
    Stack() : LinkList() { }
    ~Stack() { }
    // the public interface for Stack 
    void Push(Item *i) { InsertAtFront(i); }
    Item *Pop(); 
    // It is necessary to redefine these operation because
    // LinkList is a private base class of Stack
    int IsEmpty() { return LinkList::IsEmpty(); }  
    void Print() { LinkList::Print(); }
};
Item *Stack::Pop()
{
    LinkListElement *temp;
    temp = RemoveAtFront();
    // copy temp's data
    Item *item = new Item(*((Item *) temp->GetData()));  
    delete temp;
    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接口。

我们很容易定义Stack::Push()来简单调用LinkList::InsertAtFront(),就像Stack::Pop()做的不仅仅是调用LinkList::RemoveAtFront()。尽管Stack很想简单地使用LinkList::IsEmpty()LinkList::Print()的继承实现,但由于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 Is-A LinkedList。这是实现继承的最佳例子;这个例子有利地说明了实现继承。

接下来,让我们继续看看如何使用protected基类,以及这与使用实现继承的private基类有何不同。

使用 protected 基类来实现一个类以另一个类为基础

我们刚刚使用private基类以LinkList的方式实现了Stack。现在,让我们实现一个Queue和一个PriorityQueue。我们将使用LinkList作为protected基类来实现Queue,并使用Queue作为public基类来实现PriorityQueue

再次强调,QueuePriorityQueue都是 ADT。Queue的实现方式(相对)不重要。底层实现可能会改变。实现继承允许我们使用LinkedList来实现我们的Queue,而不会向Queue类的用户透露底层实现。

现在,我们的Queue类将使用LinkedList作为protected基类。Queue将为其用户定义一个公共接口,以建立该 ADT 的预期接口,如Enqueue()Dequeue()IsEmpty()Print()。这些成员函数的实现将使用选定的LinkedList成员函数,但Queue用户将看不到这一点,Queue实例也无法直接使用任何LinkList成员。

此外,我们的PriorityQueue类将使用public继承来扩展Queue。没错,我们又回到了 Is-A。我们在说PriorityQueue Is-A Queue,而Queue是使用LinkedList实现的。

我们将在我们的PriorityQueue类中添加一个优先级入队方法;这个类将很乐意从Queue继承public接口(但显然不会从LinkList继承,幸运的是,它被隐藏在其父级的protected基类后面)。

实现QueuePriorityQueue的代码再次很简单。需要扩展LinkList基类以使其更加完整功能才能继续。LinkListElement类可以保持不变。我们将展示经过修订的LinkList类的基本定义。QueuePriorityQueue的完整代码将在单独的段落中显示。完整的程序示例可以在我们的 GitHub 上找到:

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter06/Chp6-Ex4.cpp

// class LinkListElement is as shown previously
// The enhanced class definition of LinkList is:
class LinkList
{
private:
    LinkListElement *head;
    LinkListElement *tail;
    LinkListElement *current;
public:
    LinkList();
    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() { return head == 0; } 
    void Print();  
};
// Assume we have the implementation for the methods here…

请注意,LinkList已经扩展为具有更完整的功能集,例如能够在LinkList内的各个位置添加、删除和删除元素。为了节省示例空间,我们将不展示这些方法的实现。

现在,让我们在下一个代码段中添加QueuePriorityQueue的类定义:

class Queue: protected LinkList
{
private:
    // no new data members are necessary
public:
    Queue() : LinkList() { }
    virtual ~Queue() { }  // we'll discuss virtual in Chp. 7
    // public interface of Queue
    void Enqueue(Item *i) { InsertAtEnd(i); }
    Item *Dequeue(); 
    // redefine these methods --LinkList is a prot. base class
    int IsEmpty() { return LinkList::IsEmpty(); }
    void Print() { LinkList::Print(); }
};
Item *Queue::Dequeue()
{
    LinkListElement *temp;
    temp = RemoveAtFront();
    // make copy of temp's data
    Item *item = new Item(*((Item *) temp->GetData())); 
    delete temp; 
    return item;
}
class PriorityQueue: public Queue
{
private:
    // no new data members are necessary
public:
    PriorityQueue() : Queue() { }
    virtual ~PriorityQueue() { } // we'll see virtual in Chp 7
    void PriorityEnqueue(Item *i1, Item *i2) 
    {  InsertBeforeItem(i1, i2); } // accessible in this scope
};

在之前的代码段中,我们定义了QueuePriorityQueue类。请注意,Queue具有LinkListprotected基类。使用protected基类时,从LinkList继承的protectedpublic成员就好像是由Queue定义为protected一样,这意味着这些继承的成员不仅可以在Queue的范围内访问,还可以在Queue的任何潜在后代内访问。与之前一样,这些限制仅适用于Queue类、它的后代和它们的实例;LinkList类及其实例不受影响。

Queue类中,不需要新的数据成员。内部实现由LinkList处理。通过protected基类,我们表明Queue是使用LinkList实现的。尽管如此,我们必须为Queue提供public接口,我们通过添加诸如Queue::Enqueue()Queue::Dequeue()Queue::IsEmpty()Queue::Print()等方法来实现。请注意,在它们的实现中,这些方法仅调用LinkList方法来执行必要的操作。Queue的用户必须使用Queue的公共接口;曾经公共的LinkList接口对于Queue实例是隐藏的。

接下来,我们定义了PriorityQueue,另一个 ADT。请注意,PriorityQueueQueue定义为public基类。我们又回到了继承,以支持 Is-A 关系。PriorityQueue Is-A Queue,可以做任何Queue能做的事情,而且还多一点。因此,PriorityQueue通常从Queue继承,包括Queue的公共接口。PriorityQueue只需要添加一个额外的方法来进行优先级入队,即PriorityQueue::PriorityEnqueue()

由于Queue有一个LinkList的受保护基类,因此LinkListpublic接口被视为对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;   // PiorityQueue 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); // fourth item
    q2.Print();
    while (!(q2.IsEmpty()))
    {
       q2.Dequeue();
       q2.Print();
    }
    return 0;
}

现在,在main()中,我们实例化了一个Queue,即q1,它使用了Queue的公共接口。请注意,q1可能不使用LinkList曾经公共接口。Queue只能像Queue一样行为,而不是像LinkList一样行为。Queue的 ADT 被保留了。

最后,我们实例化了一个PriorityQueue,即q2,它使用了QueuePriorityQueue的公共接口,比如Queue::Enqueue()PriorityQueue::PriorityEnqueue()。因为Queue Is-A PriorityQueueQueuepublic基类),继承的典型机制已经就位,允许PriorityQueue利用其祖先的公共接口。

这个例子的输出是:

50 67 80
67 80
80
<EMPTY>
67 100 167 180
100 167 180
167 180
180
<EMPTY>

最后,我们看到了使用实现继承的两个例子;这并不是 C++经常使用的特性。然而,现在你了解了protectedprivate基类,如果在库代码、你正在维护的应用程序代码中遇到它们,或者在你可能遇到的编程任务中,这种技术可能会有用。

我们现在已经介绍了 C++中单一继承的基本特性。在转到下一章之前,让我们快速回顾一下我们之前讨论过的内容。

总结

在本章中,我们已经进一步深入了解面向对象编程。我们添加了额外的 OO 概念和术语,并看到 C对这些概念有直接的语言支持。我们已经看到 C中的继承支持泛化和特化。我们已经看到如何逐步构建一组相关类的层次结构。

我们已经看到了如何使用单一继承来扩展继承层次结构,以及如何访问继承的数据成员和成员函数。我们已经回顾了访问区域,以了解基类中定义的成员可以直接访问的继承成员,基于这些成员在基类中定义的访问区域。我们知道拥有一个public基类等同于定义一个 Is-A 关系,支持泛化和特化的理想,这是继承最常用的原因。

我们已经详细说明了当派生类类型的实例被实例化和销毁时构造函数和析构函数的调用顺序。我们已经看到了成员初始化列表,以选择派生类对象可能选择利用作为其自身构造的一部分的继承构造函数。

我们已经看到,在基类列表中更改访问标签会改变所使用的继承类型的面向对象意义。通过比较publicprivateprotected基类,我们现在了解了不同类型的层次结构,例如那些用于支持 Is-A 关系的层次结构,与那些用于支持实现继承的层次结构。

我们已经看到了我们层次结构中的基类可能作为更专业组件的潜在构建块,从而导致潜在的重用。任何现有代码的潜在重用都可以节省开发时间,并减少重复代码的维护。

通过扩展我们的面向对象编程知识,我们获得了一组与 C++中的继承和层次结构构建相关的初步技能。通过掌握单一继承的基本机制,我们现在可以继续学习更多有趣的面向对象的概念和与继承相关的细节。继续到第七章通过多态性利用动态绑定,我们将学习如何将方法动态绑定到相关类的层次结构中的操作。

问题

  1. 使用你的第五章解决方案,创建一个 C++程序来构建一个继承层次结构,将Person泛化为Student的派生类。
  1. 决定Student类的哪些数据成员和成员函数更通用,应该更好地放置在Person类中。使用这些成员构建你的Person类,包括适当的构造函数(默认,替代和复制),析构函数,访问成员函数和合适的公共接口。确保将数据成员放在私有访问区域。

  2. 使用一个public基类,从Person派生Student。从Student中删除现在在Person中表示的成员。相应地调整构造函数和析构函数。使用成员初始化列表根据需要指定基类构造函数。

  3. 实例化StudentPerson多次,并利用每个适当的public接口。确保动态分配多个实例。

  4. 在每个构造函数的第一行和析构函数的第一行使用cout添加一条消息,以便您可以看到每个实例的构造和销毁顺序。

  1. (可选)完成包括LinkListQueuePriorityQueue的类层次结构,使用在线代码作为基础。完成LinkList类中的其余操作,并根据需要在QueuePriorityQueue的公共接口中调用它们。
  1. 确保为每个类添加复制构造函数(或在私有访问区域原型它们,或使用= delete在原型中抑制复制)。

  2. 使用任一构造函数实例化LinkList,然后演示每个操作的工作方式。确保在添加或删除元素后调用Print()

  3. 实例化QueuePriorityQueue,并演示它们的public接口中的每个操作是否正常工作。记住要演示Queuepublic接口中继承的操作,适用于PriorityQueue的实例。

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

本章将进一步扩展我们对 C中面向对象编程的知识。我们将首先介绍一个强大的面向对象概念,多态,然后理解这一概念是如何通过直接语言支持在 C中实现的。我们将使用虚函数在相关类的层次结构中实现多态,并理解如何将特定派生类方法的运行时绑定到更通用的基类操作。我们将理解本章中呈现的多态的面向对象概念将支持多样化和健壮的设计,并在 C++中轻松实现可扩展的代码。

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

  • 理解多态的面向对象概念以及它对面向对象编程的重要性。

  • 定义虚函数,理解虚函数如何覆盖基类方法,泛化派生类对象,虚析构函数的必要性以及函数隐藏

  • 理解方法对操作的动态(运行时)绑定

  • 虚函数表v-table)的详细理解

通过本章结束时,您将理解多态的面向对象概念,以及如何通过虚函数在 C中实现这一概念。您将理解虚函数如何使得 C中方法对操作的运行时绑定成为可能。您将看到如何在基类中指定一个操作,并在派生类中用首选实现进行覆盖。您将理解何时以及为何重要利用虚析构函数。

您将看到派生类的实例通常使用基类指针存储的原因,以及这一点的重要性。我们将发现,无论实例是如何存储的(作为其自身类型还是作为基类的类型),虚函数的正确版本始终会通过动态绑定应用。具体来说,当我们检查 C++中的虚函数指针和虚函数表时,您将看到运行时绑定是如何在幕后工作的。

通过理解 C中虚函数对多态的直接语言支持,您将能够创建一组相关类的可扩展层次结构,实现方法对操作的动态绑定。让我们通过详细介绍这些理想来增进对 C作为面向对象编程语言的理解。

技术要求

完整程序示例的在线代码可在以下 GitHub 网址找到:github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter07。每个完整程序示例都可以在 GitHub 存储库中找到,位于相应章节标题(子目录)下的文件中,文件名由章节号后跟当前章节中的示例号组成。例如,本章的第一个完整程序可以在名为Chp7-Ex1.cpp的文件中的Chapter07子目录下找到。

本章的 CiA 视频可在以下网址观看:bit.ly/317dxf3

理解多态的面向对象概念

在本节中,我们将介绍一个重要的面向对象概念,多态。

第五章 详细探索类,和第六章 使用单继承实现层次结构,您现在理解了封装、信息隐藏、泛化和特化的关键面向对象的思想。您知道如何封装一个类,使用单继承构建继承层次结构,以及构建层次结构的各种原因(例如支持 Is-A 关系或支持实现继承的较少使用的原因)。让我们通过探索多态来扩展我们的基本面向对象术语。

当基类指定一个操作,使得派生类可以用更合适的方法重新定义该操作时,该操作被称为多态的。让我们重新审视我们对操作和方法的定义,以及它们的含义,以了解这些概念如何为多态性奠定基础:

  • 在 C++中,操作映射到成员函数的完整签名(名称加上参数的类型和数量 - 没有返回类型)。

  • 此外,在 C++中,方法映射到操作的定义或主体(即成员函数的实现或主体)。

  • 回顾一下,在面向对象的术语中,操作实现了类的行为。基类操作的实现可以通过几个不同的派生类方法来实现。

StudentPerson。然而,多态操作将允许在Student对象上显示Student行为,即使它们已经采用了Person的形式。

在本章中,我们将看到派生类对象采用其公共基类的形式,即采用多种形式多态性)。我们将看到如何在基类中指定多态操作,并在派生类中用首选实现进行重写。

让我们从 C++语言特性开始,这些特性允许我们实现多态性,即虚函数。

使用虚函数实现多态性

多态性允许将方法动态绑定到操作。将方法动态绑定到操作是重要的,因为派生类实例可能被基类对象指向(即,通过基类类型的指针)。在这些情况下,指针类型无法提供关于应该应用于引用实例的正确方法的足够信息。我们需要另一种方式 - 在运行时完成 - 来确定哪种方法适用于每个实例。

通常情况下,指向派生类实例的指针会被泛化为指向基类类型的指针。当对指针应用操作时,应该应用对象真正的方法,而不是对泛化指针类型似乎合适的方法。

让我们从定义虚函数所需的相关关键字和逻辑开始,以便我们可以实现多态性。

定义虚函数并重写基类方法

C++中的虚函数直接支持多态性。虚函数是:

  • 一个成员函数,允许为给定操作的方法在层次结构中被连续重写以提供更合适的定义。

  • 允许动态绑定方法而不是通常的静态绑定的成员函数。

使用关键字virtual指定虚函数。更具体地说:

  • 关键字virtual应该在函数原型中的返回类型之前。

  • 在派生类中具有与任何祖先类中虚函数相同名称和签名的函数会重新定义这些基类中的虚函数。在这里,关键字virtual是可选的,但在派生类原型中是推荐的。

  • 在派生类中具有相同名称但不同签名的函数不会重新定义其基类中的虚函数;而是隐藏其基类中的方法。

  • 在派生类原型中,可以选择性地添加关键字override作为扩展签名的一部分。这种推荐做法将允许编译器在预期重写的方法的签名与基类中指定的签名不匹配时标记错误。override关键字可以消除意外的函数隐藏。

派生类如果继承的方法适用,就不需要重新定义基类中指定的虚函数。然而,如果派生类用新方法重新定义一个操作,必须使用与被覆盖方法相同的签名(由基类指定)。此外,派生类应该只重新定义虚函数。

这里有一个简单的例子来说明基本语法:

  • Print()是在基类Person中定义的虚函数。它将被Student类中更合适的实现所覆盖:
class Person  // base class
{
private:
    char *name;
    char *title;
public:
    // constructors, destructor, 
    // public access functions, public interface etc. …
    Person introduces a virtual function, Print(). By labeling this function as virtual, the Person class is inviting any future descendants to redefine this function with a more suitable implementation or method, should they be so motivated.
  • 在基类Person中定义的虚函数实际上是在Student类中用更合适的实现进行了覆盖:
class Student: public Person  // derived class
{
private:
    float gpa;
public:
    // constructors, destructor specific to Student,
    // public access functions, public interface, etc. …
    Student introduces a new implementation of Print() that will override (that is, replace), the definition in Person. Note that if the implementation of Person::Print() were acceptable to Student, Student would not be obligated to override this function, even if it is marked as virtual in the base class. The mechanics of public inheritance would simply allow the derived class to inherit this method.But because this function is `virtual` in `Person`, `Student` may opt to redefine this operation with a more suitable method. Here, it does. In the `Student::Print()` implementation, `Student` first calls `Person::Print()` to take advantage of the aforementioned base class function, then prints additional information itself. `Student::Print()` is choosing to call a base class function for help; it is not required to do so if the desired functionality can be implemented fully within its own class scope. Notice that when `Student::Print()` is defined to override `Person::Print()`, the same signature as specified by the base class is used. This is important. Should a new signature have been used, we would get into a potential function hiding scenario, which we will soon discuss in our sub-section, *Considering function hiding*, within this chapter.Note that though the virtual functions in `Person` and `Student` are written inline, a virtual function will never be expanded as inline code by the compiler since the specific method for the operation must be determined at runtime.

记住,多态函数的目的是具有覆盖或替换给定函数的基类版本的能力。函数重写与函数重载不同。

重要区别

函数重写是通过在相关类的层次结构中引入相同的函数名称和签名(通过虚函数)来定义的,而派生类版本旨在替换基类版本。相比之下,函数重载是在程序的同一作用域中存在两个或更多具有相同名称但不同签名的函数时定义的(比如在同一个类中)。

此外,在基类定义中最初未指定为虚拟的操作也不是多态的,因此不应该在任何派生类中被覆盖。这意味着,如果基类在定义操作时没有使用关键字virtual,那么基类并不打算让派生类用更合适的派生类方法重新定义这个操作。相反,基类坚持认为它提供的实现适用于任何它的后代。如果派生类尝试重新定义一个非虚拟的基类操作,将会在应用程序中引入一个微妙的错误。错误将是,使用派生类指针存储的派生类实例将使用派生类方法,而使用基类指针存储的派生类实例将使用基类定义。实例应该始终使用自己的行为,而不管它们是如何存储的 - 这就是多态的意义。永远不要重新定义非虚函数。

重要说明

在 C++中,未在基类中指定为虚拟的操作不是多态的,也不应该被派生类覆盖。

让我们继续前进,发现我们可能希望通过基类类型收集派生类对象的情况,以及我们可能需要将我们的析构函数标记为虚拟的情况。

泛化派生类对象

当我们查看继承层次结构时,通常是使用公共基类的层次结构;也就是说,这是一个使用公共继承来表达 Is-A 关系的层次结构。以这种方式使用继承时,我们可能会被激励将相关实例的组合在一起。例如,Student专业化的层次结构可能包括GraduateStudentUnderGraduateStudentNonDegreeStudent。假设这些派生类中的每一个都有一个名为Student的公共基类,那么说GraduateStudent 是一个 Student,等等,就是合适的。

我们可能会在我们的应用程序中找到一个理由,将这些类似的实例组合到一个共同的集合中。例如,想象一下,我们正在为一所大学实现一个计费系统。大学可能希望我们将所有学生,无论其派生类类型如何,收集到一个集合中以便统一处理,比如计算他们的学期账单。

Student类可能有一个多态操作CalculateSemesterBill(),它在Student中作为一个虚拟函数实现了一个默认方法。然而,选择的派生类,比如GraduateStudent,可能有他们希望通过在自己的类中覆盖操作来提供的首选实现。例如,GraduateStudent可能有一个不同的方法来计算他们的总账单与NonDegreeStudent。因此,每个派生类可以覆盖其类中CalculateSemesterBill()的默认实现。

尽管如此,在我们的财务应用程序中,我们可以创建一个Student类型的指针集合,尽管每个指针最终都会指向派生类类型的实例,比如GraduateStudentUnderGraduateStudentNonDegreeStudent。当以这种方式泛化派生类类型的实例时,适用于集合指针类型的基类级别中定义的函数(通常是虚拟函数)是合适的。虚拟函数允许这些泛化的实例调用多态操作,以产生它们各自的派生类方法或这些函数的实现。这正是我们想要的。但是,还有更多细节需要理解。

这个推广派生类实例的基本前提将使我们理解为什么我们可能需要在许多类定义中使用虚拟析构函数。让我们来看一下。

利用虚拟析构函数

现在我们可以概念化一下,将派生类实例按其共同的基类类型分组,并通过虚拟函数允许它们的不同行为显现出来可能是有用的情况。通过它们的基类类型收集同类派生类实例,并利用虚拟函数允许它们独特的行为显现出来,实际上是非常强大的。

但是,当存储在基类指针中的派生类实例的内存消失时会发生什么呢?我们知道它的析构函数被调用了,但是哪一个?实际上,我们知道一系列的析构函数被调用,从问题对象类型的析构函数开始。但是,如果实例通过存储使用基类指针而被泛型化,我们如何知道实际的派生类对象类型呢?一个虚拟析构函数解决了这个问题。

通过将析构函数标记为virtual,我们允许它被覆盖为类及其后代的销毁序列的起点。选择使用哪个析构函数作为销毁的入口点将推迟到运行时,使用动态绑定,基于对象的实际类型,而不是引用它的指针类型。我们很快将看到,这个过程是如何通过检查 C++的底层虚拟函数表自动化的。

与所有其他虚拟函数不同,虚拟析构函数实际上指定了要执行的一系列函数的起点。回想一下,作为析构函数的最后一行代码,编译器会自动修补一个调用来调用直接基类的析构函数,依此类推,直到我们达到层次结构中的初始基类。销毁链的存在是为了提供一个释放给定实例的所有子对象中动态分配的数据成员的论坛。将这种行为与其他虚拟函数进行对比,其他虚拟函数仅允许执行函数的单一正确版本(除非程序员选择在派生方法实现期间调用相同函数的基类版本作为辅助函数)。

你可能会问为什么在正确的级别开始销毁序列很重要?也就是说,在与对象的实际类型匹配的级别(而不是通用指针类型,可能指向对象)。请记住,每个类可能有动态分配的数据成员。析构函数将释放这些数据成员。从正确级别的析构函数开始将确保您不会通过放弃适当的析构函数及其相应的内存释放而引入任何内存泄漏到您的应用程序中。

虚析构函数总是必要的吗?这是一个很好的问题!当使用公共基类层次结构时,即使用公共继承时,虚析构函数总是必要的。请记住,公共基类支持 Is-A 关系,这很容易导致允许使用其基类类型的指针存储派生类实例。例如,研究生 学生,因此我们有时可以将研究生存储为学生,以便在需要更通用的处理时与其兄弟类型一起处理。我们可以始终以这种方式在公共继承边界上进行向上转型。然而,当我们使用实现继承(即私有或受保护的基类)时,不允许向上转型。因此,在使用私有或受保护继承的层次结构中,虚析构函数是不必要的,因为向上转型是被简单地禁止的;因此,对于私有和受保护基类层次结构中的类,哪个析构函数应该是入口点永远不会是模糊的。作为第二个例子,在第六章中,我们的LinkedList类中没有包含虚析构函数;因此,LinkedList应该只作为受保护或私有基类扩展。然而,我们在QueuePriorityQueue类中包含了虚析构函数,因为PriorityQueue使用Queue作为公共基类。PriorityQueue可以向上转型为Queue(但不能向上转型为LinkedList),因此在层次结构中的Queue及其后代级别引入虚析构函数是必要的。

在重写虚析构函数时,是否建议使用可选关键字virtualoverride?这也是一个很好的问题。我们知道,重写的析构函数只是销毁顺序的起点。我们也知道,与其他虚函数不同,派生类的析构函数将与基类的析构函数有一个唯一的名称。尽管派生类的析构函数会自动重写已声明为virtual的基类析构函数,但在派生类析构函数原型中使用可选关键字virtual是为了文档化而推荐的。然而,在派生类析构函数中通常不使用可选关键字override。原因是override关键字旨在提供一个安全网,以捕捉原始定义和重写函数之间的拼写错误。对于析构函数,函数名称并不相同,因此这个安全网并不是一个错误检查的优势。

让我们继续把所有必要的部分放在一起,这样我们就可以看到各种类型的虚函数,包括析构函数,如何发挥作用。

把所有的部分放在一起

到目前为止,在本章中,我们已经了解了虚函数的微妙之处,包括虚析构函数。重要的是要看到我们的代码在实际操作中,以及它的各种组件和细节。我们需要在一个连贯的程序中看到基本语法来指定虚函数,包括如何通过基类类型收集派生类实例,以及虚析构函数如何发挥作用。

让我们看一个更复杂的、完整的程序示例,以完全说明多态性,使用 C++中的虚函数实现。这个例子将被分成许多段;完整的程序可以在以下 GitHub 位置找到:

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter07/Chp7-Ex1.cpp

#include <iostream>
#include <iomanip>
#include <cstring>
using namespace std;
const int MAX = 5;
class Person
{
private: // data members
    char *firstName;
    char *lastName;
    char middleInitial;
    char *title;  // Mr., Ms., Mrs., Miss, Dr., etc.
protected:
    void ModifyTitle(const char *); 
public:
    Person();   // default constructor
    Person(const char *, const char *, char, const char *);  
    Person(const Person &);  // copy constructor
    virtual ~Person();  // virtual destructor
    const char *GetFirstName() const { return firstName; }  
    const char *GetLastName() const { return lastName; }    
    const char *GetTitle() const { return title; } 
    char GetMiddleInitial() const { return middleInitial; }
    virtual void Print() const;
    virtual void IsA();  
    virtual void Greeting(const char *);
};

在上述的类定义中,我们对Person这个熟悉的类进行了扩充,添加了四个虚函数,即析构函数(~Person()),Print()IsA()Greeting(const char *)。请注意,我们只是在每个成员函数的返回类型(如果有的话)前面加上了关键字virtual。类定义的其余部分就像我们在上一章中深入探讨过的那样。

现在,让我们来看一下Person的非内联成员函数定义:

Person::Person()
{
    firstName = lastName = 0;  // NULL pointer
    middleInitial = '\0';
    title = 0;
}
Person::Person(const char *fn, const char *ln, char mi, 
               const char *t)
{
    firstName = new char [strlen(fn) + 1];
    strcpy(firstName, fn);
    lastName = new char [strlen(ln) + 1];
    strcpy(lastName, ln);
    middleInitial = mi;
    title = new char [strlen(t) + 1];
    strcpy(title, t);
}
Person::Person(const Person &pers)
{
    firstName = new char [strlen(pers.firstName) + 1];
    strcpy(firstName, pers.firstName);
    lastName = new char [strlen(pers.lastName) + 1];
    strcpy(lastName, pers.lastName);
    middleInitial = pers.middleInitial;
    title = new char [strlen(pers.title) + 1];
    strcpy(title, pers.title);
}
Person::~Person()
{
    delete firstName;
    delete lastName;
    delete title;
}
void Person::ModifyTitle(const char *newTitle)
{
    delete title;  // delete old title
    title = new char [strlen(newTitle) + 1];
    strcpy(title, newTitle);
}
void Person::Print() const
{
    cout << title << " " << firstName << " ";
    cout << middleInitial << ". " << lastName << endl;
}
void Person::IsA()
{
    cout << "Person" << endl;
}
void Person::Greeting(const char *msg)
{
    cout << msg << endl;
}

在之前的代码段中,我们指定了Person的所有非内联成员函数。请注意,这四个虚函数——析构函数,Print()IsA()Greeting()——在方法(即成员函数定义)本身中不包括virtual关键字。

接下来,让我们来看一下Student类的定义:

class Student: public Person
{
private: 
    // data members
    float gpa;
    char *currentCourse;
    const char *studentId;  
public:
    // member function prototypes
    Student();  // default constructor
    Student(const char *, const char *, char, const char *,
            float, const char *, const char *); 
    Student(const Student &);  // copy constructor
    virtual ~Student();  // destructor
    void EarnPhD();  
    // inline function definitions
    float GetGpa() const { return gpa; }
    const char *GetCurrentCourse() const
        { return currentCourse; }
    const char *GetStudentId() const { return studentId; }
    void SetCurrentCourse(const char *); // prototype only

    // In the derived class, the keyword virtual is optional, 
    // but recommended for clarity. Same for override.
    virtual void Print() const override;
    virtual void IsA() override;
    // note: we choose not to redefine 
    // Person::Greeting(const char *)
};
inline void Student::SetCurrentCourse(const char *c)
{
    delete currentCourse;   // delete existing course
    currentCourse = new char [strlen(c) + 1];
    strcpy(currentCourse, c); 
}

在之前的Student类定义中,我们再次看到了构成这个类的所有各种组件。另外,请注意,我们定义了三个虚函数——析构函数,Print()IsA()。这些首选定义基本上取代了这些操作在基类中指定的默认方法。然而,请注意,我们选择不重新定义void Person::Greeting(const char *),这个方法在Person类中被引入为虚函数。如果我们发现继承的定义对Student类的实例是可以接受的,那么简单地继承这个方法就可以了。

请记住,当虚函数与析构函数配对时,它的含义是独特的,它并不意味着派生类的析构函数取代了基类的析构函数。相反,它意味着当由派生类实例发起销毁链序列时,派生类析构函数是正确的起始点(无论它们是如何存储的)。

还要记住,Student的派生类不需要覆盖在Person中定义的虚函数。如果Student类发现基类方法是可以接受的,它会自动继承。虚函数只是允许派生类在需要时用更合适的方法重新定义操作。

接下来,让我们来看一下Student类的非内联成员函数:

Student::Student(): studentId (0) 
{
    gpa = 0.0;
    currentCourse = 0;
}
// Alternate constructor member function definition
Student::Student(const char *fn, const char *ln, char mi, 
                 const char *t, float avg, const char *course,
                 const char *id): Person(fn, ln, mi, t)
{
    gpa = avg;
    currentCourse = new char [strlen(course) + 1];
    strcpy(currentCourse, course);
    char *temp = new char [strlen(id) + 1];
    strcpy (temp, id); 
    studentId = temp;
}
// Copy constructor definition
Student::Student(const Student &ps): Person(ps)
{
    gpa = ps.gpa;
    currentCourse = new char [strlen(ps.currentCourse) + 1];
    strcpy(currentCourse, ps.currentCourse);
    char *temp = new char [strlen(ps.studentId) + 1];
    strcpy (temp, ps.studentId); 
    studentId = temp;
}

// destructor definition
Student::~Student()
{
    delete currentCourse;
    delete (char *) studentId;
}
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()
{
    cout << "Student" << endl;
}

在之前列出的代码段中,我们列出了Student的非内联成员函数定义。同样,请注意,关键字virtual不会出现在任何非内联成员函数定义本身中,只会出现在它们各自的原型中。

最后,让我们来看一下main()函数:

int main()
{
    Person *people[MAX];
    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.");
    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

现在我们已经掌握了多态的概念和虚拟函数的机制,让我们来看一下与虚拟函数相关的不太常见的情况,即函数隐藏。

考虑函数隐藏

函数隐藏并不是 C++中经常使用的特性。事实上,它经常是意外使用的!让我们回顾一下我们对继承成员函数的了解。当一个操作由基类指定时,它旨在为所有派生类方法提供使用和重新定义的协议(对于虚函数的情况)。

有时,派生类会改变一个方法的签名,这个方法是用来重新定义基类指定的操作(比如虚函数)。在这种情况下,新的函数与祖先类中指定的操作在签名上不同,将不被视为继承操作的虚拟重新定义。事实上,它会隐藏祖先类中具有相同名称的虚拟函数的继承方法。

当程序编译时,会将每个函数的签名与类定义进行比较,以确保正确使用。通常情况下,当在类中找不到与实例类型看似匹配的成员函数时,会向上遍历继承链,直到找到匹配项或者继承链耗尽为止。让我们更仔细地看一下编译器考虑的内容:

  • 当找到一个与所寻找的函数同名的函数时,将检查其签名,看它是否与函数调用完全匹配,或者是否可以应用类型转换。当找到函数时,但无法应用类型转换时,正常的遍历顺序就结束了。

  • 通常情况下,隐藏虚拟函数的函数会中止这种向上搜索序列,从而隐藏了本来可能被调用的虚拟函数。请记住,在编译时,我们只是检查语法(而不是决定调用哪个版本的虚拟函数)。但如果我们找不到匹配项,就会报错。

  • 函数隐藏实际上被认为是有帮助的,并且是语言所期望的。如果类设计者提供了一个具有特定签名和接口的特定函数,那么该函数应该用于该类型的实例。在这种特定情况下,不应该使用在继承链中之前隐藏或未知的函数。

考虑对我们之前的完整程序示例进行以下修改,首先说明函数隐藏,然后提供一个更灵活的解决方案来管理函数隐藏:

  • 请记住,Person类引入了没有参数的virtual void Print()。想象一下,Student不是用相同的签名覆盖Print(),而是将签名更改为virtual void Print(const char *)
class Person  // base class
{
    // data members
public:  // member functions, etc. 
    Print() has changed from a base to a derived class. The derived class function does not redefine the virtual void Print(); of its base class. It is a new function that will in fact hide the existence of Person::Print(). This is actually what was intended, since you may not recall that the base class offers such an operation and tracking upward might cause surprising results in your application if you intended Print(const char *) to be called and if Print() is called instead. By adding this new function, the derived class designer is dictating this interface is the appropriate Print() for instances of Student.However, nothing is straightforward in C++. For situations where a `Student` is up-cast to a `Person`, the `Person::Print()` with no arguments will be called. The `Student::Print(const char *)` is not a virtual redefinition because it does not have the same signature. Hence, the `Person::Print()` will be called for generalized `Student` instances. And yet `Student::Print(const char *)` will be called for `Student` instances stored in `Student` variables. Unfortunately, this is inconsistent in how an instance will behave if it is stored in its own type versus a generalized type. Though function hiding was meant to work in this fashion, it may inevitably not be what you would like to happen. Programmers beware!

让我们来看一些可能出现的冗长代码:

  • 可能需要显式向下转型或使用作用域解析运算符来揭示一个被隐藏的函数:
int main()
{ 
    Person *people[2];
    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
    ((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)。我们通过调用((Student *) people[1])->Print("I have to study");来进行向下转型。在这里,我们承担了一定的风险 - 如果people[1]实际上是Person而不是Student,这将生成运行时错误。

接下来,我们实例化Student s1;。如果我们尝试调用s1.Print(),我们将会得到一个编译器错误 - Student::Print(const char *)隐藏了Person::Print()的存在。请记住,s1存储在其自身类型Student中,因此找到Student::Print(const char *)后,向上遍历以揭示Person::Print()被阻止了。

尽管如此,我们对s1.Print("I got an A!");的调用是成功的,因为Print(const char *)Student类级别找到了。最后,请注意,对s1.Person::Print();的调用是有效的,但需要了解被隐藏的函数。通过使用作用域解析运算符(::),我们可以找到Print()的基类版本。即使Print()在基类中是虚拟的(意味着动态绑定),使用作用域解析操作将此调用恢复为静态绑定的函数调用。

假设我们想要向派生类添加一个新的接口,其中的函数会隐藏基类函数。了解函数隐藏后,我们应该怎么做?我们可以简单地在派生类中重写基类中找到的虚函数,并且可以重载该函数以添加额外的接口。是的,我们现在既重写又重载。也就是说,我们重写了基类函数,并在派生类中重载了被重写的函数。

让我们看看我们现在会得到什么:

  • 以下是添加新成员函数的更灵活接口,同时保留原本可能被隐藏的现有接口:
class Person  // base class
{
    // data members
public:  // member functions, etc.
    Student class both overrides Person::Print() with Student::Print() and overloads Student::Print() with Student::Print(const char *) to envelop the additional desired interface. Now, for Student objects stored in Student variables, both interfaces are available – the base class interface is no longer hidden. Of course, Student objects referenced by Person pointers only have the Person::Print() interface, which is to be expected. 

总的来说,函数隐藏并不经常出现。但当出现时,通常会给人带来不必要的惊喜。现在你了解了可能发生的情况以及原因,这会让你成为一个更好的程序员。

现在我们已经看过了所有关于虚函数的用法,让我们来看看为什么虚函数能够支持将特定方法动态绑定到操作上。为了彻底理解运行时绑定,我们需要看一下虚函数表。让我们继续前进!

理解动态绑定

现在我们已经看到了多态是如何通过虚函数实现的,以允许将操作动态绑定到特定的实现或方法,让我们了解为什么虚函数允许运行时绑定。

非虚函数在编译时静态绑定。也就是说,所涉及函数的地址是在编译时确定的,基于手头对象的假定类型。例如,如果实例化了类型为Student的对象,函数调用将从Student类开始验证其原型,并且如果找不到,将向上遍历每个基类,如Person,以寻找匹配的原型。找到后,正确的函数调用将被修补。这就是静态绑定的工作原理。

然而,虚函数是 C++中一种在运行时使用动态绑定的函数类型。在编译时,任何虚函数调用都仅仅被替换为一个查找机制,以延迟绑定直到运行时。当然,每个编译器供应商在自动化虚函数方面的实现可能有所不同。然而,有一种广泛使用的实现涉及虚函数指针、虚函数表和包含虚函数的每种对象类型的虚函数表条目。

让我们继续调查 C++中动态绑定是如何常见实现的。

理解方法与操作的运行时绑定

我们知道虚函数允许将操作(在基类中指定)动态绑定到特定的实现或方法(通常在派生类中指定)。这是如何工作的?

当基类指定一个或多个新的虚函数(不仅仅是祖先虚函数的重新定义)时,在给定类型的实例的内存下方将创建一个虚函数指针(vptr)。这发生在运行时,当为实例创建内存时(在堆栈、堆或静态/外部区域)。当涉及的实例被构造时,不仅将调用适当的构造函数来初始化实例,而且这个 VPTR 将被初始化为指向该类类型的虚函数指针表(v-table)条目。

给定类类型的虚函数表(v-table)条目将由一组函数指针组成。这些函数指针通常组织成一个函数指针数组。函数指针是指向实际函数的指针。通过解引用这个指针,您实际上会调用指针所指向的函数。有机会向函数传递参数,但是为了通过函数指针进行通用调用,参数必须对该指针可能指向的任何版本的函数都是统一的。函数指针的前提条件使我们能够指向特定函数的不同版本。也就是说,我们可以指向给定操作的不同方法。这是我们可以在 C++中为虚函数自动绑定动态的基础。

让我们考虑特定对象类型的虚函数表条目。我们知道这个表条目将由一组函数指针组成,例如函数指针数组。这些函数指针排列的顺序将与给定类引入的虚函数的顺序一致。重写现有虚函数的函数将简单地用要调用的函数的首选版本替换表条目,但不会导致在函数指针数组中分配额外的条目。

因此,当程序开始运行时,首先在全局内存中(作为隐藏的外部变量),将设置一个虚函数表。该表将包含包含虚函数的每种对象类型的条目。给定对象类型的条目将包含一组函数指针(例如函数指针数组),它组织和初始化该类的动态绑定函数。函数指针的特定顺序将与引入虚函数的顺序相对应(可能是由它们的祖先类引入的),并且特定的函数指针将被初始化为该类类型的特定函数的首选版本。也就是说,函数指针可能指向其自己类级别指定的重写方法。

然后,当实例化给定类型的对象时,该对象内部的 vptr(每个新引入的子对象级别的虚函数,而不是重新定义的虚函数,将有一个)将被设置为指向该实例的相应 v-table 条目。

通过代码和内存图,看到这些细节将是有用的。让我们深入了解代码的运行情况!

详细解释虚函数表(v-table)

为了详细说明内存模型并查看运行时设置的底层 C++机制,让我们考虑来自本节的详细完整程序示例,其中包括基类Person和派生类Student的关键元素。作为提醒,我们将展示程序的关键元素:

  • PersonStudent类的缩写定义(我们将省略数据成员和大多数成员函数定义以节省空间):
class Person
{
private:   // data members will be as before
protected: // assume all member function are as before,
public:  // but we will show only virtual functions here
    Person and Student class definitions are as expected. Assume that the data members and member functions are as shown in the full program example. For brevity, we have just included the virtual functions introduced or redefined at each level. 
  • 重新审视我们main()函数的关键元素,以缩写形式:
int main()
{
    Person *people[3];
    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"); 
    for (int i = 0; i < 3; i++)
    {                 // at compile time, modified to:
        people[i]->IsA();  // *(people[i]->vptr[2])()
        people[i]->Print();
        people[i]->Greeting();
        delete people[i];
    }
    return 0;
}

在我们的main()函数中,注意我们实例化了一个Person实例和两个Student实例。所有这些都存储在基类类型Person的指针的通用数组中。然后,我们通过集合进行迭代,对每个实例调用虚函数,即IsA()Print()Greeting()和析构函数(在我们删除每个实例时隐式调用)。

考虑到先前示例的内存模型,我们有以下图表:

图 7.1 - 当前示例的内存模型

图 7.1 - 当前示例的内存模型

在上述的内存图中(遵循前面的程序),请注意我们有一个指向Person的通用化实例的指针数组。第一个实例实际上是一个Person,而另外两个实例是Student类型。但是,由于Student Person,因此将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条目。

假设PersonStudentv-table条目包含一个指向该类型适当虚函数的函数指针数组。每种类型的v-table条目实际上嵌入了更多信息,例如该类型的实例大小等。为简化起见,我们将只查看自动执行每个类类型的动态绑定的v-table条目的部分。

请注意,Personv-table条目是一个包含四个函数指针的数组。每个函数指针将指向Person的最合适版本的析构函数,Print()IsA()Greeting()。这些函数指针的排列顺序与这些虚函数由该类引入的顺序相对应。也就是说,vptr[0]将指向Person的析构函数,vptr[1]将指向Person::Print(),依此类推。

现在,让我们看一下Studentv-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();,最后是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 表条目(包含成员函数指针集)。

我们已经看到,虚函数允许我们利用操作的动态绑定到最合适的方法,使我们能够将 C++作为一个 OOP 语言来实现具有多态性的健壮设计,从而促进易于扩展的代码。

通过扩展我们对 OOP 知识的理解,利用虚函数,我们现在可以继续包括与继承和多态性相关的其他面向对象的概念和细节。继续到第八章掌握抽象类,我们将学习如何应用抽象类的 OO 理想,以及围绕这一下一个面向对象概念的各种 OOP 考虑。让我们继续!

问题

  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 网址找到:github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter08。每个完整程序示例都可以在 GitHub 存储库中的适当章节标题(子目录)下找到,文件名与所在章节编号相对应,后跟破折号,再跟所在章节中的示例编号。例如,本章的第一个完整程序可以在Chapter08子目录中的名为Chp8-Ex1.cpp的文件中找到。

本章的 CiA 视频可在以下网址观看:bit.ly/2Pa6XBT

理解抽象类的面向对象概念

在本节中,我们将介绍一个重要的面向对象概念,即抽象类。考虑到您对关键面向对象思想的知识基础不断增长,包括封装、信息隐藏、泛化、特化和多态性,您知道如何封装一个类。您还知道如何使用单继承构建继承层次结构(以及构建层次结构的各种原因,例如支持是一个关系或支持实现继承的较少使用的原因)。此外,您知道如何使用虚函数实现方法到操作的运行时绑定,从而实现多态性的概念。让我们通过探索抽象类来扩展我们不断增长的面向对象术语。

抽象类是一个旨在收集派生类中可能存在的共同点,以便在派生类上断言一个公共接口(即一组操作)的基类。抽象类不代表一个用于实例化的类。只有派生类类型的对象可以被实例化。

让我们首先看一下 C++语言特性,允许我们实现抽象类,即纯虚拟函数。

使用纯虚拟函数实现抽象类

通过在类定义中引入至少一个抽象方法(即纯虚拟函数原型)来指定抽象类。抽象方法的面向对象概念是指定一个仅具有其使用协议(即成员函数的名称签名)的操作,但没有函数定义。抽象方法将是多态的,因为没有定义,它预计会被派生类重新定义。

函数参数后面的=0。此外,重要的是要理解关于纯虚拟函数的以下微妙之处:

  • 通常不提供纯虚拟函数的定义。这相当于在基类级别指定操作(仅原型),并在派生类级别提供所有方法(成员函数定义)。

  • 未为其基类引入的所有纯虚拟函数提供方法的派生类也被视为抽象类,因此不能被实例化。

  • 原型中的=0只是向链接器指示,在创建可执行程序时,不需要链接(或解析)此函数的定义。

注意

通过在类定义中包含一个或多个纯虚拟函数原型来指定抽象类。通常不提供这些方法的可选定义。

纯虚拟函数通常不提供定义的原因是它们旨在为多态操作提供使用协议,以在派生类中实现。纯虚拟函数指定一个类为抽象;抽象类不能被实例化。因此,纯虚拟函数中提供的定义永远不会被选择为多态操作的适当方法,因为抽象类型的实例永远不会存在。也就是说,纯虚拟函数仍然可以提供一个定义,可以通过作用域解析运算符(::)和基类名称显式调用。也许,这种默认行为可能作为派生类实现中使用的辅助函数具有意义。

让我们首先简要概述指定抽象类所需的语法。请记住,abstract可能是一个用于指定抽象类的关键字。相反,仅仅通过引入一个或多个纯虚拟函数,我们已经指示该类是一个抽象类:

class LifeForm    // Abstract class definition
{
private:
    int lifeExpectancy; // all LifeForms have a lifeExpectancy
public:
    LifeForm() { lifeExpectancy = 0; }
    LifeForm(int life) { lifeExpectancy = life; }
    LifeForm(const LifeForm &form) 
       { lifeExpectancy = form.lifeExpectancy; }
    virtual ~LifeForm() { }   // virtual destructor
    int GetLifeExpectancy() const { return lifeExpectancy; }
    virtual void Print() const = 0; // pure virtual functions 
    virtual const char *IsA() = 0;   
    virtual const char *Speak() = 0;
};

请注意,在抽象类定义中,我们引入了四个虚拟函数,其中三个是纯虚拟函数。虚拟析构函数没有要释放的内存,但被指定为virtual,以便它是多态的,并且可以应用正确的销毁顺序到存储为基类类型指针的派生类实例。

三个纯虚拟函数Print()IsA()Speak()在它们的原型中被指定为=0。这些操作没有定义(尽管可以选择性地提供)。纯虚拟函数可以有默认实现,但不能作为内联函数。派生类的责任是使用基类定义指定的接口(即签名)为这些操作提供方法。在这里,纯虚拟函数为多态操作提供了接口,这些操作将在派生类定义中定义。

注意

抽象类肯定会有派生类(因为我们不能实例化抽象类本身)。为了确保虚析构函数机制在最终层次结构中能够正常工作,请确保在抽象类定义中包含虚析构函数。这将确保所有派生类的析构函数都是virtual,并且可以被重写以提供对象销毁序列中的正确入口点。

现在,让我们更深入地了解从面向对象的角度来拥有接口意味着什么。

创建接口。

接口类是面向对象概念中的一个类,它是抽象类的进一步细化。抽象类可以包含通用属性和默认行为(通过包含数据成员和纯虚函数的默认定义,或者通过提供非虚拟成员函数),而接口类只包含抽象方法。在 C++中,一个只包含抽象方法的抽象类(即没有可选定义的纯虚函数)可以被视为接口类。

在考虑 C++中实现的接口类时,有几点需要记住:

  • 抽象类不可实例化;它们通过继承提供了派生类必须提供的接口(即操作)。

  • 虽然在抽象类中纯虚函数可能包含可选实现(即方法体),但如果类希望在纯面向对象的术语中被视为接口类,则不应提供此实现。

  • 虽然抽象类可能有数据成员,但如果类希望被视为接口类,则不应该有数据成员。

  • 在面向对象的术语中,抽象方法是没有方法的操作;它只是接口,并且在 C++中实现为纯虚函数。

  • 作为提醒,请确保在接口类定义中包含虚析构函数原型;这将确保派生类的析构函数是虚拟的。析构函数定义应为空。

让我们考虑在面向对象编程实现技术中拥有接口类的各种动机。一些面向对象编程语言遵循非常严格的面向对象概念,只允许实现非常纯粹的面向对象设计。其他面向对象编程语言,如 C++,通过直接允许实现更激进的面向对象思想,提供了更多的灵活性。

例如,在纯面向对象的术语中,继承应该保留给 Is-A 关系。我们已经看到了 C++支持的实现继承,通过私有和受保护的基类。我们已经看到了一些可接受的实现继承的用法,即以另一个类的术语实现一个新类(通过使用受保护和公共基类来隐藏底层实现)。

另一个面向对象编程特性的例子是多重继承。我们将在接下来的章节中看到,C++允许一个类从多个基类派生。在某些情况下,我们确实在说派生类与许多基类可能存在 Is-A 关系,但并非总是如此。

一些面向对象编程语言不允许多重继承,而那些不允许的语言更多地依赖于接口类来混合(否则)多个基类的功能。在这些情况下,面向对象编程语言可以允许派生类实现多个接口类中指定的功能,而不实际使用多重继承。理想情况下,接口用于混合多个类的功能。这些类,不出所料,有时被称为混入类。在这些情况下,我们并不一定说派生类和基类之间存在 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'
    virtual ~Charitable() { } // remember virtual destructor
};
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 
    }
    virtual ~Person();  // prototype
};               
class Student: public Person 
{   // Student Is-A Person which mixes-in Charitable interface
    // 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
    }
    virtual ~Student();  // prototype
};

在上述的类定义中,我们首先注意到一个简单的接口类Charitable,使用受限的抽象类实现。我们不包括数据成员,一个纯虚函数来定义virtual void Give(float) = 0;接口,以及一个虚析构函数。

接下来,PersonCharitable派生,使用公共继承来实现Charitable接口。我们简单地重写virtual void Give(float);来为给予提供一个默认定义。然后我们从Person派生Student;请注意学生是一个实现了 Charitable 接口的人。在我们的Student类中,我们选择重新定义virtual void Give(float);来为Student实例提供更合适的Give()定义。也许Student实例财务有限,选择捐赠一个等同于预定货币金额的时间量。

在这里,我们在 C++中使用抽象类来模拟面向对象概念中的接口类。

让我们继续讨论关于抽象类的整体问题,通过检查派生类对象如何被抽象类类型收集。

将派生类对象泛化为抽象类型

我们在第七章中已经看到,通过多态性利用动态绑定,有时将相关的派生类实例分组存储在使用基类指针的集合中是合理的。这样做允许使用基类指定的多态操作对相关的派生类类型进行统一处理。我们也知道,当调用多态基类操作时,由于 C++中实现多态性的虚函数和内部虚表,将在运行时调用正确的派生类方法。

然而,你可能会思考,是否可能通过抽象类类型来收集一组相关的派生类类型?请记住,抽象类是不可实例化的,那么我们如何将一个派生类对象存储为一个不能被实例化的对象呢?解决方案是使用指针。虽然我们不能将派生类实例收集在一组抽象基类实例中(这些类型不能被实例化),但我们可以将派生类实例收集在抽象类类型的指针集合中。自从我们学习了多态性以来,我们一直在做这种类型的分组(使用基类指针)。

广义的专门对象组使用隐式向上转型。撤消这样的向上转型必须使用显式向下转型,并且程序员需要正确地确定先前泛化的派生类型。对错误的向下转型将导致运行时错误。

何时需要按基类类型收集派生类对象,包括抽象基类类型?答案是,当在应用程序中以更通用的方式处理相关的派生类类型时,即当基类类型中指定的操作涵盖了您想要利用的所有操作时。毫无疑问,您可能会发现同样多的情况,即保留派生类实例在其自己的类型中(以利用在派生类级别引入的专门操作)是合理的。现在您明白了可能发生的情况。

让我们继续通过检查一个全面的示例来展示抽象类的实际应用。

将所有部分放在一起

到目前为止,在本章中,我们已经了解了抽象类的微妙之处,包括纯虚函数,以及如何使用抽象类和纯虚函数创建接口类。始终重要的是看到我们的代码在各种组件及其各种细微差别中的运行情况。

让我们看一个更复杂的、完整的程序示例,以充分说明在 C++中使用纯虚函数实现抽象类。在这个例子中,我们不会进一步将抽象类指定为接口类,但我们将利用机会使用一组指向其抽象基类类型的指针来收集相关的派生类类型。这个例子将被分解成许多段落;完整的程序可以在以下 GitHub 位置找到:

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter08/Chp8-Ex1.cpp

#include <iostream>
#include <iomanip>
#include <cstring>
using namespace std;
const int MAX = 5;
class LifeForm   // abstract class definition
{
private:
   int lifeExpectancy;
public:
   LifeForm() { lifeExpectancy = 0; }
   LifeForm(int life) { lifeExpectancy = life; }
   LifeForm(const LifeForm &form) 
       { lifeExpectancy = form.lifeExpectancy; }
   virtual ~LifeForm() { }     // virtual destructor
   int GetLifeExpectancy() const { return lifeExpectancy; }
   virtual void Print() const = 0;   // pure virtual functions 
   virtual const char *IsA() = 0;   
   virtual const char *Speak() = 0;
};

在上述的类定义中,我们注意到LifeForm是一个抽象类。它是一个抽象类,因为它包含至少一个纯虚函数定义。事实上,它包含了三个纯虚函数定义,即Print()IsA()Speak()

现在,让我们用一个具体的派生类Cat来扩展LifeForm

class Cat: public LifeForm
{
private:
   int numberLivesLeft;
   char *name;
public:
   Cat() : LifeForm(15) { numberLivesLeft = 9; name = 0; }
   Cat(int lives) : LifeForm(15) { numberLivesLeft = lives; }
   Cat(const char *n);
   virtual ~Cat() { delete name; }   // virtual destructor
   const char *GetName() const { return name; }
   int GetNumberLivesLeft() const { return numberLivesLeft; }
   virtual void Print() const override; // redef pure virt fns
   virtual const char *IsA() override { return "Cat"; }
   virtual const char *Speak() override { return "Meow!"; }
};
Cat::Cat(const char *n) : LifeForm(15)
{
   name = new char [strlen(n) + 1];
   strcpy(name, n);
   numberLivesLeft = 9;
}
void Cat::Print() const
{
   cout << "\t" << name << " has " << GetNumberLivesLeft();
   cout << " lives left" << endl;
}

在前面的代码段中,我们看到了Cat的类定义。请注意,Cat已经重新定义了LifeForm的纯虚函数Print()IsA()Speak(),并为Cat类中的每个方法提供了定义。有了这些函数的现有方法,Cat的任何派生类都可以选择重新定义这些方法,使用更合适的版本(但它们不再有义务这样做)。

请注意,如果Cat未能重新定义LifeForm的任何一个纯虚函数,那么Cat也将被视为抽象类,因此无法实例化。

作为提醒,虚函数IsA()Speak()虽然是内联写的以缩短代码,但编译器永远不会将虚函数内联,因为它们的正确方法必须在运行时确定。

请注意,在Cat构造函数中,成员初始化列表用于选择接受整数参数的LifeForm构造函数(即:LifeForm(15))。将值15传递给LifeForm构造函数,以初始化LifeForm中定义的lifeExpectancy15

现在,让我们继续前进到Person的类定义,以及它的内联函数:

class Person: public LifeForm
{
private: 
   // data members
   char *firstName;
   char *lastName;
   char middleInitial;
   char *title;  // Mr., Ms., Mrs., Miss, Dr., etc.
protected:
   void ModifyTitle(const char *);  
public:
   Person();   // default constructor
   Person(const char *, const char *, char, const char *);  
   Person(const Person &);  // copy constructor
   virtual ~Person();  // destructor
   const char *GetFirstName() const { return firstName; }  
   const char *GetLastName() const { return lastName; }    
   const char *GetTitle() const { return title; } 
   char GetMiddleInitial() const { return middleInitial; }
   virtual void Print() const override; // redef pure virt fns
   virtual const char *IsA() override;   
   virtual const char *Speak() override;
};

请注意,Person现在使用公共继承扩展了LifeForm。在之前的章节中,Person是继承层次结构顶部的基类。Person重新定义了来自LifeForm的纯虚函数,即Print()IsA()Speak()。因此,Person现在是一个具体类,可以被实例化。

现在,让我们回顾一下Person的成员函数定义:

Person::Person(): LifeForm(80)
{
   firstName = lastName = 0;  // NULL pointer
   middleInitial = '\0';
   title = 0;
}
Person::Person(const char *fn, const char *ln, char mi, 
               const char *t): LifeForm(80)
{
   firstName = new char [strlen(fn) + 1];
   strcpy(firstName, fn);
   lastName = new char [strlen(ln) + 1];
   strcpy(lastName, ln);
   middleInitial = mi;
   title = new char [strlen(t) + 1];
   strcpy(title, t);
}
Person::Person(const Person &pers): LifeForm(pers)
{
   firstName = new char [strlen(pers.firstName) + 1];
   strcpy(firstName, pers.firstName);
   lastName = new char [strlen(pers.lastName) + 1];
   strcpy(lastName, pers.lastName);
   middleInitial = pers.middleInitial;
   title = new char [strlen(pers.title) + 1];
   strcpy(title, pers.title);
}
Person::~Person()
{
   delete firstName;
   delete lastName;
   delete title;
}
void Person::ModifyTitle(const char *newTitle)
{
   delete title;  // delete old title
   title = new char [strlen(newTitle) + 1];
   strcpy(title, newTitle);
}
void Person::Print() const
{
   cout << "\t" << title << " " << firstName << " ";
   cout << middleInitial << ". " << lastName << endl;
}
const char *Person::IsA() {  return "Person";  }
const char *Person::Speak() {  return "Hello!";  }   

Person成员函数中,请注意我们为Print()IsA()Speak()实现了功能。另外,请注意在两个Person构造函数中,我们在它们的成员初始化列表中选择了:LifeForm(80)来调用LifeForm(int)构造函数。这个调用将在给定Person实例的LifeForm子对象中将私有继承的数据成员LifeExpectancy设置为80

接下来,让我们回顾Student类的定义,以及它的内联函数定义:

class Student: public Person
{
private: 
   // data members
   float gpa;
   char *currentCourse;
   const char *studentId;  
public:
   Student();  // default constructor
   Student(const char *, const char *, char, const char *,
           float, const char *, const char *); 
   Student(const Student &);  // copy constructor
   virtual ~Student();  // virtual destructor
   void EarnPhD();  
   float GetGpa() const { return gpa; }
   const char *GetCurrentCourse() const 
       { return currentCourse; }
   const char *GetStudentId() const { return studentId; }
   void SetCurrentCourse(const char *);
   virtual void Print() const override; // redefine not all 
   virtual const char *IsA() override;  // virtual functions
};
inline void Student::SetCurrentCourse(const char *c)
{
   delete currentCourse;   // delete existing course
   currentCourse = new char [strlen(c) + 1];
   strcpy(currentCourse, c); 
}

前面提到的Student类定义看起来很像我们以前见过的。Student使用公共继承扩展了Person,因为Student 是一个 Person

接下来,我们将回顾非内联的Student类成员函数:

Student::Student(): studentId (0)  // default constructor
{
   gpa = 0.0;
   currentCourse = 0;
}
// Alternate constructor member function definition
Student::Student(const char *fn, const char *ln, char mi, 
                 const char *t, float avg, const char *course,
                 const char *id): Person(fn, ln, mi, t)
{
   gpa = avg;
   currentCourse = new char [strlen(course) + 1];
   strcpy(currentCourse, course);
   char *temp = new char [strlen(id) + 1];
   strcpy (temp, id); 
   studentId = temp;
}
// Copy constructor definition
Student::Student(const Student &ps): Person(ps)
{
   gpa = ps.gpa;
   currentCourse = new char [strlen(ps.currentCourse) + 1];
   strcpy(currentCourse, ps.currentCourse);
   char *temp = new char [strlen(ps.studentId) + 1];
   strcpy (temp, ps.studentId); 
   studentId = temp;
}

// destructor definition
Student::~Student()
{
   delete currentCourse;
   delete (char *) studentId;
}
void Student::EarnPhD()  {   ModifyTitle("Dr.");  }
void Student::Print() const
{
   cout << "\t" << GetTitle() << " " << GetFirstName() << " ";
   cout << GetMiddleInitial() << ". " << GetLastName();
   cout << " with id: " << studentId << " has a gpa of: ";
   cout << setprecision(2) <<  " " << gpa << " enrolled in: ";
   cout << currentCourse << endl;
}
const char *Student::IsA() {  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];
   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");
   for (int i = 0; i < MAX; i++)
   {
      cout << entity[i]->Speak();
      cout << " I am a " << entity[i]->IsA() << endl;
      entity[i]->Print();
      cout << "Has a life expectancy of: ";
      cout << entity[i]->GetLifeExpectancy();
      cout << "\n";
   } 
   for (int i = 0; i < MAX; i++)
      delete entity[i];
   return 0;
}

main()中,我们声明了一个指向LifeForm的指针数组。回想一下,LifeForm是一个抽象类。我们无法创建LifeForm对象的数组,因为那将要求我们能够实例化一个LifeForm;我们不能这样做——LifeForm是一个抽象类。

然而,我们可以创建一个指向抽象类型的指针集合,这使我们能够收集相关类型——在这个集合中的PersonStudentCat实例。当然,我们可以对以这种泛化方式存储的实例应用的唯一操作是在抽象基类LifeForm中找到的那些操作。

接下来,我们分配了各种PersonStudentCat实例,将每个实例存储在类型为LifeForm的泛化指针集合的元素中。当以这种方式存储任何这些派生类实例时,将执行隐式向上转型到抽象基类类型(但实例不会以任何方式被改变——我们只是指向整个内存布局组成部分的最基类子对象)。

现在,我们通过循环来对这个泛化集合中的所有实例应用在抽象类LifeForm中找到的操作,比如Speak()Print()IsA()。这些操作恰好是多态的,允许通过动态绑定使用每个实例的最适当实现。我们还在每个实例上调用GetLifeExpectancy(),这是在LifeForm级别找到的非虚拟函数。这个函数只是返回了相关LifeForm的寿命预期。

最后,我们通过循环使用泛化的LifeForm指针再次删除动态分配的PersonStudentCat实例。我们知道delete()将会调用析构函数,并且因为析构函数是虚拟的,适当的析构顺序将会开始。

在这个例子中,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 with id: 21-MIT has a gpa of:  4 enrolled in: C++
        Has a life expectancy of: 80
Hello! I am a Student
        Ms. Gabby A. Doone with id: 18-GWU has a gpa of: 4 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

我们已经彻底研究了抽象类的面向对象概念以及在 C++中如何使用纯虚函数实现,以及这些概念如何扩展到创建面向对象接口。在继续前进到下一章之前,让我们简要回顾一下本章涵盖的语言特性和面向对象概念。

总结

在本章中,我们继续了解面向对象编程,首先是通过理解 C中纯虚函数如何直接支持抽象类的面向对象概念。我们探讨了没有数据成员且不包含非虚函数的抽象类如何支持接口类的面向对象理想。我们谈到了其他面向对象编程语言如何利用接口类,以及 C如何选择支持这种范式,通过使用这种受限制的抽象类。我们将相关的派生类类型向上转换为抽象基类类型的指针存储,这是一种典型且非常有用的编程技术。

我们已经看到抽象类如何通过提供一个类来指定派生类共享的共同属性和行为,以及最重要的是为相关类提供多态行为的接口,因为抽象类本身是不可实例化的。

通过在 C++中添加抽象类和可能的面向对象接口类的概念,我们能够实现促进易于扩展的代码设计。

我们现在准备继续第九章探索多重继承,通过学习如何以及何时适当地利用多重继承的概念,同时理解权衡和潜在的设计替代方案,来增强我们的面向对象编程技能。让我们继续前进吧!

问题

  1. 使用以下指南创建形状的层次结构:
  1. 创建一个名为Shape的抽象基类,它定义了计算Shape面积的操作。不要包括Area()操作的方法。提示:使用纯虚函数。

  2. 使用公共继承从Shape派生RectangleCircleTriangle类。可选择从Rectangle派生Square类。在每个派生类中重新定义Shape引入的Area()操作。确保在每个派生类中提供支持该操作的方法,以便稍后实例化每种Shape类型。

  3. 根据需要添加数据成员和其他成员函数来完成新引入的类定义。记住,只有共同的属性和操作应该在Shape中指定 - 所有其他属性和操作都属于它们各自的派生类。不要忘记在每个类定义中实现复制构造函数和访问函数。

  4. 创建一个抽象类类型Shape的指针数组。将该数组中的元素指向RectangleSquareCircleTriangle类型的实例。由于现在你正在将派生类对象视为通用的Shape对象,所以循环遍历指针数组,并为每个调用Area()函数。确保delete()任何动态分配的内存。

  5. 在概念上,你的抽象Shape类也是一个接口类吗?为什么或为什么不是?

第九章:探索多重继承

本章将继续扩展我们对 C++中面向对象编程的知识。我们将从检查一个有争议的面向对象概念,多重继承MI)开始,了解为什么它有争议,如何可以合理地用于支持面向对象设计,以及何时替代设计可能更合适。

多重继承可以在 C++中通过直接语言支持来实现。这样做,我们将面临几个面向对象设计问题。我们将被要求对继承层次结构进行批判性评估,问自己是否使用最佳设计来表示潜在的对象关系集。多重继承可以是一个强大的面向对象编程工具;明智地使用它是至关重要的。我们将学习何时使用多重继承来合理地扩展我们的层次结构。

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

  • 理解多重继承的机制

  • 检查多重继承的合理用途

  • 创建菱形层次结构并探讨由其使用引起的问题

  • 使用虚基类解决菱形层次结构的重复

  • 应用判别器来评估菱形层次结构和设计中多重继承的价值,以及考虑设计替代方案

在本章结束时,您将了解多重继承的面向对象概念,以及如何在 C++中实现这个想法。您将不仅了解多重继承的简单机制,还将了解其使用的原因(混入,Is-A,或有争议的 Has-A)。

您将看到为什么多重继承在面向对象编程中是有争议的。拥有多个基类可能会导致形状奇怪的层次结构,比如菱形层次结构;这些类型的层次结构带来潜在的实现问题。我们将看到 C++如何整合一种语言特性(虚基类)来解决这些难题,但解决方案并不总是理想的。

一旦我们了解了多重继承带来的复杂性,我们将使用面向对象设计度量标准,如判别器,来评估使用多重继承的设计是否是表示一组对象关系的最佳解决方案。我们将研究替代设计,然后您将更好地理解多重继承不仅是什么,还有何时最好地利用它。让我们通过多重继承继续扩展我们对 C++作为“你可以做任何事情”面向对象编程语言的理解。

技术要求

完整程序示例的在线代码可以在以下 GitHub URL 找到:github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter09。每个完整程序示例都可以在 GitHub 存储库中的适当章节标题(子目录)下找到,文件名与所在章节编号相对应,后跟破折号,再跟着所在章节中的示例编号。例如,本章的第一个完整程序可以在名为Chp9-Ex1.cpp的文件中的子目录Chapter09中找到。

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

理解多重继承的机制

在 C++中,一个类可以有多个直接基类。这被称为多重继承,在面向对象设计和面向对象编程中是一个非常有争议的话题。让我们从简单的机制开始;然后我们将在本章的进展过程中讨论多重继承的设计问题和编程逻辑。

使用多重继承,派生类在其类定义中使用基类列表指定其每个直接祖先或基类是什么。

与单一继承类似,构造函数和析构函数在整个继承结构中被调用,因为派生类类型的对象被实例化和销毁。回顾并扩展多重继承的构造和析构的微妙之处,我们想起了以下的逻辑:

  • 构造函数的调用顺序从派生类开始,但立即将控制权传递给基类构造函数,依此类推,直到达到继承结构的顶部。一旦调用顺序传递控制到继承结构的顶部,执行顺序就开始了。所有最高级别的基类构造函数首先被执行,以此类推,直到我们到达派生类构造函数,在构造链中最后执行。

  • 派生类的析构函数首先被调用和执行,然后是所有直接基类的析构函数,依此类推,随着我们向上继承层次结构的进展。

派生类构造函数中的成员初始化列表可以用来指定应该调用每个直接基类的构造函数。如果没有这个规定,那么将使用该基类的默认构造函数。

让我们来看一个典型的多重继承示例,以实现面向对象设计中多重继承的典型应用,并理解 C++中基本的多重继承语法。这个例子将被分成许多部分;完整的程序可以在以下 GitHub 位置找到:

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter09/Chp9-Ex1.cpp

#include <iostream>
#include <cstring>
using namespace std;
class Person
{
private: 
    char *firstName;
    char *lastName;
    char middleInitial;
    char *title;  // Mr., Ms., Mrs., Miss, Dr., etc.
    Person(const Person &);  // prohibit copies 
protected:
    void ModifyTitle(const char *);  
public:
    Person();   // default constructor
    Person(const char *, const char *, char, const char *);  
    virtual ~Person();  // destructor
    const char *GetFirstName() const { return firstName; }  
    const char *GetLastName() const { return lastName; }    
    const char *GetTitle() const { return title; } 
    char GetMiddleInitial() const { return middleInitial; }
};

在前面的代码段中,我们有一个Person的预期类定义,其中包含我们习惯于定义的类元素。

接下来,让我们看看这个类的相关成员函数:

Person::Person()
{
    firstName = lastName = 0;  // NULL pointer
    middleInitial = '\0';
    title = 0;
}
Person::Person(const char *fn, const char *ln, char mi, 
               const char *t)
{
    firstName = new char [strlen(fn) + 1];
    strcpy(firstName, fn);
    lastName = new char [strlen(ln) + 1];
    strcpy(lastName, ln);
    middleInitial = mi;
    title = new char [strlen(t) + 1];
    strcpy(title, t);
}
Person::~Person()
{
    delete firstName;
    delete lastName;
    delete title;
}
void Person::ModifyTitle(const char *newTitle)
{
    delete title;  // delete old title
    title = new char [strlen(newTitle) + 1];
    strcpy(title, newTitle);
}

在之前的代码段中,Person的成员函数定义如预期的那样。然而,看到Person类的定义是有用的,因为这个类将作为一个构建块,并且它的部分将直接在接下来的代码段中被访问。

现在,让我们定义一个新的类BillableEntity

class BillableEntity
{
private:
    float invoiceAmt;
    BillableEntity(const BillableEntity &); // prohibit copies
public:
    BillableEntity() { invoiceAmt = 0.0; }
    BillableEntity(float amt) { invoiceAmt = amt; } 
    virtual ~BillableEntity() { }
    void Pay(float amt) { invoiceAmt -= amt; }
    float GetBalance() const { return invoiceAmt; }
    void Balance();
};
void BillableEntity::Balance()
{
    if (invoiceAmt)
       cout << "Owed amount: $ " << invoiceAmt << endl;
    else
       cout << "Credit: $ " << 0.0 - invoiceAmt << endl;
}

在之前的BillableEntity类中,我们定义了一个包含简单功能的类来封装一个计费结构。也就是说,我们有一个发票金额和Pay()GetBalance()等方法。请注意,复制构造函数是私有的;这将禁止复制,考虑到这个类的性质,这似乎是合适的。

接下来,让我们将前面提到的两个基类PersonBillableEntity组合起来,作为Student类的基类:

class Student: public Person, public BillableEntity
{
private: 
    float gpa;
    char *currentCourse;
    const char *studentId;  
    Student(const Student &);  // prohibit copies 
public:
    Student();  // default constructor
    Student(const char *, const char *, char, const char *,
           float, const char *, const char *, float); 
    virtual ~Student(); 
    void Print() const;
    void EarnPhD();  
    float GetGpa() const { return gpa; }
    const char *GetCurrentCourse() const
        { return currentCourse; }
    const char *GetStudentId() const { return studentId; }
    void SetCurrentCourse(const char *);
};
inline void Student::SetCurrentCourse(const char *c)
{
   delete currentCourse;   // delete existing course
   currentCourse = new char [strlen(c) + 1];
   strcpy(currentCourse, c); 
}

Student的前面的类定义中,在Student的基类列表中指定了两个公共基类PersonBillableEntity。这两个基类只是在Student的基类列表中用逗号分隔。

让我们进一步看看在Student类的其余部分中必须做出哪些调整,通过检查其成员函数:

Student::Student(): studentId (0) // call default base  
{                                  // class constructors
   gpa = 0.0;
   currentCourse = 0;
}
// The member initialization list specifies which versions
// of each base class constructor should be utilized.
Student::Student(const char *fn, const char *ln, char mi, 
       const char *t, float avg, const char *course, 
       const char *id, float amt):
       Person(fn, ln, mi, t), BillableEntity(amt)                   
{
   gpa = avg;
   currentCourse = new char [strlen(course) + 1];
   strcpy(currentCourse, course);
   char *temp = new char [strlen(id) + 1];
   strcpy (temp, id); 
   studentId = temp;
}
Student::~Student()
{
   delete currentCourse;
   delete (char *) studentId;
}
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 char *, const char *, char, const char *)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 members 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

我们现在已经看到了通常实现的面向对象设计中多重继承的语言机制。现在,让我们继续通过查看在面向对象设计中使用多重继承的典型原因,其中一些原因比其他原因更被广泛接受。

审视 MI 的合理用法

多重继承是在创建面向对象设计时出现的一个有争议的概念。许多面向对象设计避免多重继承;其他设计则严格使用它。一些面向对象编程语言,比如 Java,不明确提供直接支持多重继承的语言支持。相反,它们提供接口,就像我们在 C++ 中通过创建只包含纯虚函数的抽象类(限制为只包含纯虚函数)来建模的那样,在第八章中,掌握抽象类

当然,在 C++ 中,从两个接口类继承仍然是多重继承的一种用法。虽然 C++ 不在语言中包括接口类,但这个概念可以通过更严格地使用多重继承来模拟。例如,我们可以通过编程方式简化抽象类,只包括纯虚函数(没有数据成员,也没有带有定义的成员函数),以模仿面向对象设计中接口类的概念。

典型的多重继承困境构成了为什么多重继承在面向对象编程中具有争议的基础。经典的多重继承困境将在本章详细介绍,并可以通过将多重继承限制为仅使用接口类,或通过重新设计来避免。这就是为什么一些面向对象编程语言只支持接口类而不支持无限制的多重继承。在 C++ 中,你可以仔细考虑每个面向对象设计,并选择何时使用多重继承,何时使用一种受限制的多重继承形式(接口类),或何时使用重新设计来消除多重继承。

C++ 是一个“你可以做任何事情”的编程语言。因此,C++ 允许无限制或保留地进行多重继承。作为一个面向对象的程序员,我们将更仔细地看待接受多重继承的典型原因。随着我们在本章的深入,我们将评估使用多重继承时出现的问题,以及 C++ 如何通过额外的语言特性解决这些问题。这些多重继承的问题将使我们能够应用度量标准,更合理地了解何时应该使用多重继承,何时应该进行重新设计。

让我们开始追求合理使用 MI 的过程,首先考虑 Is-A 和混合关系,然后再来审视使用 MI 实现 Has-A 关系的有争议的用法。

支持 Is-A 和混合关系

就像我们在单一继承中学到的那样,Is-A 关系最常用于描述两个继承类之间的关系。例如,Student Is-A Person。相同的理想继续在多重继承中,Is-A 关系是指定继承的主要动机。在纯粹的面向对象设计和编程中,继承应该只用于支持 Is-A 关系。

尽管如此,正如我们在查看接口类时所学到的(这是在 C++ 中使用抽象类模拟的概念,限制为只包含纯虚函数),混合关系通常适用于当我们从一个接口继承时。请记住,混合关系是当我们使用继承来混合另一个类的功能时,仅仅是因为这个功能对于派生类来说是有用或有意义的。基类不一定是抽象或接口类,但在理想的面向对象设计中,它应该是这样的。

混合基类代表一个不适用 Is-A 关系的类。混合存在于 MI 中更多,至少作为支持(许多)基类之一的必要性的原因。由于 C++直接支持多重继承,MI 可用于支持实现混合(而像 Java 这样的语言可能只使用接口类)。在实践中,MI 经常用于继承自一个类以支持 Is-A 关系,并且还继承自另一个类以支持混合关系。在我们的最后一个例子中,我们看到Student Is-A Person,并且Student选择混合 BillableEntity的功能。

在 C++中合理使用 MI 的包括支持 Is-A 和混合关系;然而,我们的讨论将不完整,如果不考虑下一个不寻常的 MI 使用——实现 Has-A 关系。

支持 Has-A 关系

较少见,也更有争议的是,MI 可以用于实现 Has-A 关系。也就是说,模拟包含或整体与部分的关系。在第十章中,实现关联、聚合和组合,我们将看到 Has-A 关系的更广泛接受的实现;然而,MI 提供了一个非常简单的实现。在这里,部分作为基类。整体继承自部分,自动包含部分在其内存布局中,还自动继承部分的成员和功能。

例如,Student Is-A PersonStudent Has-A(n) Id;第二个基类(Id)的使用是为了包含。Id将作为一个基类,Student将从Id派生,以考虑Id提供的一切。Id的公共接口对Student是立即可用的。实际上,任何从Id继承的类在使用其Id部分时都将继承一个统一的接口。这种简单性是继承有时被用来模拟包含的驱动原因。

然而,使用继承来实现 Has-A 关系可能会导致不必要的 MI 使用,从而使继承层次结构复杂化。不必要使用 MI 是使用继承来模拟 Has-A 关系非常有争议的主要原因,而且在纯 OO 设计中相当受到反对。尽管如此,我们还是提到它,因为你会看到一些 C++应用程序使用 MI 来实现 Has-A。

让我们继续探讨其他有争议的 MI 设计,即菱形层次结构。

创建菱形层次结构

在使用多重继承时,有时会诱人地利用兄弟(或表亲)类作为新派生类的基类。当这种情况发生时,层次结构不再是树形的,而是一个包含菱形的图形。

每当在这种情况下实例化派生类类型的对象时,派生类的实例中将存在两个公共基类的副本。这种重复显然浪费空间。还会通过调用重复的构造函数和析构函数以及维护两个平行的子对象的副本(很可能是不必要的)来浪费额外的时间。当尝试访问来自这个公共基类的成员时,也会产生歧义。

让我们看一个详细说明这个问题的例子,从LifeFormHorsePerson的缩写类定义开始。虽然只显示了完整程序示例的部分,但整个程序可以在我们的 GitHub 上找到:

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter09/Chp9-Ex2.cpp

class Lifeform
{   // abbreviated class definition
private:
    int lifeExpectancy;
public:
    LifeForm(int life) {lifeExpectancy = life; }
    int GetLifeExpectancy() const { return lifeExpectancy; }
    // additional constructors, destructor, etc …
    virtual void Print() const = 0; // pure virtual functions
    virtual const char *IsA() = 0;
    virtual const char *Speak() = 0;
};
class Horse: public LifeForm
{   // abbreviated class definition
private:
    char *name;
public:
    Horse(): LifeForm(35) { name = 0; }
    // additional constructors, destructor, etc …
    virtual void Print() const override 
        { cout << name << endl; }
    virtual const char *IsA() override { return "Horse"; }
    virtual const char *Speak() override { return "Neigh!"; }
};
class Person: public LifeForm
{   // abbreviated class definition
private: 
    char *firstName;
    char *lastName;
    // additional data members …
public:
    Person(): LifeForm(80) { firstName = lastName = 0; }
    // additional constructors, destructor, etc …
    const char *GetFirstName() const { return firstName; }
    virtual void Print() const override
        { cout << firstName << " " << lastName << endl; }
    virtual const char *IsA() override { return "Person"; }
    virtual const char *Speak() override { return "Hello!"; }
};

代码片段显示了LifeFormPersonHorse的骨架类定义。每个类都显示了一个默认构造函数,仅仅是为了演示如何为每个类设置lifeExpectancy。在PersonHorse的默认构造函数中,成员初始化列表用于将值3580传递给LifeForm构造函数以设置这个值。

尽管前面的类定义是缩写的(即故意不完整)以节省空间,让我们假设每个类都有适当的额外构造函数定义,适当的析构函数和其他必要的成员函数。

我们注意到LifeForm是一个抽象类,因为它提供了纯虚函数Print()IsA()Speak()HorsePerson都是具体类,并且可以实例化,因为它们用虚函数重写了这些纯虚函数。这些虚函数是内联显示的,只是为了使代码紧凑以便查看。

接下来,让我们看一个新的派生类,它将在我们的层次结构中引入图形或菱形:

class Centaur: public Person, public Horse
{   // abbreviated class definition
public:
    // constructors, destructor, etc …
    virtual void Print() const override
       { cout << GetFirstName() << endl; }
    virtual const char *IsA() override { return "Centaur"; }
    virtual const char *Speak() override
       { return "Neigh! and Hello!"; }
};

在前面的片段中,我们使用多重继承定义了一个新的类Centaur。乍一看,我们确实是要断言CentaurPerson之间的 Is-A 关系,以及CentaurHorse之间的 Is-A 关系。然而,我们很快会挑战我们的断言,以测试它是否更像是一种组合而不是真正的 Is-A 关系。

我们将假设所有必要的构造函数、析构函数和成员函数都存在,使Centaur成为一个定义良好的类。

现在,让我们继续看一下我们可能会利用的潜在main()函数:

int main()
{
    Centaur beast("Wild", "Man");
    cout << beast.Speak() << " I'm a " << beast.IsA() << 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()。然后我们尝试调用公共的继承GetLifeExpectancy(),它在LifeForm中定义。它的实现包含在Lifeform中,因此PersonHorseCentaur不需要提供定义(也不应该这样做——它不是一个虚函数,意味着要重新定义)。

不幸的是,通过Centaur实例调用GetLifeExpectancy()是模棱两可的。这是因为beast实例中有两个LifeForm子对象。记住,Centaur是从Horse派生的,Horse是从LifeForm派生的,为所有前述的基类数据成员(HorseLifeForm)提供了内存布局。Centaur也是从Person派生的,Person是从LifeForm派生的,它也为Centaur提供了PersonLifeForm的内存布局。LifeForm部分是重复的。

继承的数据成员lifeExpectancy有两个副本。在Centaur实例中有两个LifeForm的子对象。因此,当我们尝试通过Centaur实例调用GetLifeExpectancy()时,方法调用是模棱两可的。我们试图初始化哪个lifeExpectancy?在调用GetLifeExpectancy()时,哪个LifeForm子对象将作为this指针?这是不清楚的,所以编译器不会为我们选择。

为了消除对GetLifeExpectancy()函数调用的歧义,我们必须使用作用域解析运算符。我们在::运算符之前加上我们希望从中获取LifeForm子对象的中间基类。请注意,我们调用,例如beast.Horse::GetLifeExpectancy()来选择lifeExpectancy,从Horse子对象的路径中包括LifeForm。这很尴尬,因为HorsePerson都不包括这个模棱两可的成员;lifeExpectancy是在LifeForm中找到的。

让我们考虑上述程序的输出:

Neigh! and Hello! I'm a Centaur.
It is unclear how many years I will live: 80 or 35.

我们可以看到,设计一个包含菱形形状的层次结构有缺点。这些难题包括需要以尴尬的方式解决的编程歧义,重复子对象的内存重复,以及构造和销毁这些重复子对象所需的时间。

幸运的是,C有一种语言特性来减轻这些菱形层次结构的困难。毕竟,C是一种允许我们做任何事情的语言。知道何时以及是否应该利用这些特性是另一个问题。让我们首先看一下 C++语言解决菱形层次结构及其固有问题的解决方案,通过查看虚基类。

利用虚基类来消除重复

我们刚刚看到了 MI 实现中出现的问题,当一个菱形形状包含在 OO 设计中时会迅速出现内存重复的子对象,访问该子对象的歧义(即使通过继承的成员函数),以及构造和销毁的重复。因此,纯 OO 设计不会在层次结构中包括图形(即没有菱形形状)。然而,我们知道 C是一种强大的语言,一切皆有可能。因此,C将为我们提供解决这些问题的方法。

virtual被放置在访问标签和兄弟或堂兄类的基类名称之间,这些类可能稍后被用作同一个派生类的基类。需要注意的是,知道两个兄弟类可能稍后被合并为新派生类的共同基类可能是困难的。重要的是要注意,没有指定虚基类的兄弟类将要求它们自己的副本(否则共享的)基类。

在实现中应该谨慎使用虚基类,因为这会对具有这样一个祖先类的实例施加限制和开销。需要注意的限制包括:

  • 具有虚基类的实例可能会使用比其非虚拟对应物更多的内存。

  • 当虚基类在祖先层次结构中时,禁止从基类类型的对象向派生类类型进行转换。

  • 最派生类的成员初始化列表必须用于指定应该用于初始化的共享对象类型的构造函数。如果忽略了这个规定,将使用默认构造函数来初始化这个子对象。

现在让我们看一个使用虚基类的完整程序示例。通常情况下,完整程序可以在我们的 GitHub 上找到,链接如下:

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter09/Chp9-Ex3.cpp

#include <iostream>
#include <cstring>
using namespace std;
class LifeForm
{
private:
    int lifeExpectancy;
public:
    LifeForm() { lifeExpectancy = 0; }
    LifeForm(int life) { lifeExpectancy = life; }
    LifeForm(const LifeForm &form) 
       { lifeExpectancy = form.lifeExpectancy; }
    virtual ~LifeForm() { }
    int GetLifeExpectancy() const { return lifeExpectancy; }
    virtual void Print() const = 0; 
    virtual const char *IsA() = 0;   
    virtual const char *Speak() = 0;
};

在前面的代码段中,我们看到了LifeForm的完整类定义。请注意,具有函数体的成员函数在类定义中被内联。当然,编译器实际上不会为构造函数或析构函数进行内联替换;知道这一点,将方法写成内联以使类紧凑以便审查是方便的。

接下来,让我们看一下Horse的类定义:

class Horse: public virtual LifeForm
{
private:
    char *name;
public:
    Horse() : LifeForm(35) { name = 0; }
    Horse(const char *n);
    Horse(const Horse &); 
    virtual ~Horse() { delete name; }
    const char *GetName() const { return name; }
    virtual void Print() const override 
        { cout << name << endl; }
    virtual const char *IsA() override { return "Horse"; }
    virtual const char *Speak() override { return "Neigh!"; }
};
Horse::Horse(const char *n): LifeForm(35)
{
   name = new char [strlen(n) + 1];
   strcpy(name, n);
}
Horse::Horse(const Horse &h): LifeForm (h)
{
   name = new char [strlen(h.name) + 1];
   strcpy(name, h.name); 
}

在前面的代码段中,我们有Horse的完整类定义。请记住,尽管某些方法被写成内联以节省空间,但编译器实际上永远不会内联构造函数或析构函数。虚函数也不能被内联,因为它的整个目的是在运行时确定适当的方法。

在这里,LifeFormHorse的虚基类。这意味着如果Horse有一个同级(或堂兄)也使用虚基类从LifeForm继承的兄弟,那些兄弟将共享它们的LifeForm副本。虚基类将减少存储和额外的构造函数和析构函数调用,并消除歧义。

请注意Horse构造函数,在其成员初始化列表中指定了LifeForm(35)的构造函数规范。如果LifeForm实际上是一个共享的虚基类,那么这个基类初始化将被忽略,尽管这些构造函数规范对于Horse的实例或者Horse的后代的实例是有效的,其中菱形层次结构不适用。在Horse与一个兄弟类真正作为虚基类组合的层次结构中,LifeForm(35)规范将被忽略,而是将调用默认的LifeForm构造函数或者在层次结构中的较低(不寻常的)级别选择另一个构造函数。

接下来,让我们通过查看其他类定义来看更多关于这个程序的内容,从Person开始:

class Person: public virtual LifeForm
{
private: 
    // data members
    char *firstName;
    char *lastName;
    char middleInitial;
    char *title;  // Mr., Ms., Mrs., Miss, Dr., etc.
protected:
    void ModifyTitle(const char *);  
public:
    Person();   // default constructor
    Person(const char *, const char *, char, const char *);  
    Person(const Person &);  // copy constructor
    virtual ~Person();  // destructor
    const char *GetFirstName() const { return firstName; }  
    const char *GetLastName() const { return lastName; }    
    const char *GetTitle() const { return title; } 
    char GetMiddleInitial() const { return middleInitial; }
    virtual void Print() const override;
    virtual const char *IsA() override;   
    virtual const char *Speak() override;
};

在之前的代码片段中,我们看到Person有一个公共虚基类LifeForm。如果PersonPerson的兄弟类通过多重继承组合成一个新的派生类,那些指定LifeForm为虚基类的兄弟类将同意共享一个LifeForm的子对象。

继续前进,让我们回顾一下Person的成员函数:

Person::Person(): LifeForm(80)
{
    firstName = lastName = 0;  // NULL pointer
    middleInitial = '\0';
    title = 0;
}
Person::Person(const char *fn, const char *ln, char mi, 
               const char *t): LifeForm(80)
{
    firstName = new char [strlen(fn) + 1];
    strcpy(firstName, fn);
    lastName = new char [strlen(ln) + 1];
    strcpy(lastName, ln);
    middleInitial = mi;
    title = new char [strlen(t) + 1];
    strcpy(title, t);
}
Person::Person(const Person &pers): LifeForm(pers)
{
    firstName = new char [strlen(pers.firstName) + 1];
    strcpy(firstName, pers.firstName);
    lastName = new char [strlen(pers.lastName) + 1];
    strcpy(lastName, pers.lastName);
    middleInitial = pers.middleInitial;
    title = new char [strlen(pers.title) + 1];
    strcpy(title, pers.title);
}
Person::~Person()
{
    delete firstName;
    delete lastName;
    delete title;
}
void Person::ModifyTitle(const char *newTitle)
{
    delete title;  // delete old title
    title = new char [strlen(newTitle) + 1];
    strcpy(title, newTitle);
}
void Person::Print() const
{
    cout << title << " " << firstName << " ";
    cout << middleInitial << ". " << lastName << endl;
}
const char *Person::IsA() {  return "Person"; }
const char *Person::Speak() {  return "Hello!"; }

在上述Person的方法中,我们看到一些让我们惊讶的细节;这些方法大部分都是预期的。然而,请注意,如果Person在一个菱形层次结构中与LifeForm子对象变为共享而不是重复,那么Person构造函数中成员初始化列表中的LifeForm(80)规范将被忽略。

接下来,让我们看看多重继承是如何发挥作用的,以Centaur类的定义为例:

class Centaur: public Person, public Horse
{
private:
    // no additional data members required 
public:
    Centaur(): LifeForm(1000) { }
    Centaur(const char *, const char *, char = ' ', 
            const char * = "Mythological Creature"); 
    Centaur(const Centaur &c): 
            Person(c), Horse(c),LifeForm(1000) { }
    virtual void Print() const override;
    virtual const char *IsA() override;   
    virtual const char *Speak() override;
};
// Constructors for Centaur need to specify how the shared
// base class LifeForm will be initialized
Centaur::Centaur(const char *fn, const char *ln, char mi, 
                 const char *title): 
                 Person(fn, ln, mi, title), Horse(fn), 
                 LifeForm(1000)
{
   // All initialization has been taken care of in init. list
}
void Centaur::Print() const
{
    cout << "My name is " << GetFirstName();
    cout << ".  I am a " << GetTitle() << endl;
}
const char *Centaur::IsA() { return "Centaur"; }
const char *Centaur::Speak() 
{
    return "Neigh! and Hello! I'm a master of two languages.";
} 

在上述的Centaur类定义中,我们可以看到CentaurHorsePerson的公共基类。我们暗示Centaur 是一个 HorseCentaur 是一个 Person

然而,请注意,在Centaur类定义的基类列表中没有使用关键字virtual。然而,Centaur是引入菱形形状的层次结构的级别。这意味着我们在设计阶段必须提前计划,知道在HorsePerson类定义的基类列表中利用virtual关键字。这是一个合适的设计会议至关重要的例子,而不是仅仅跳入实现。

同样非常不寻常的是,注意Centaur的替代构造函数中的Person(fn, ln, mi, title), Horse(fn), LifeForm(1000)的基类列表。在这里,我们不仅指定了我们的直接基类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已经初始化为1000

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

Neigh!!! and 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。考虑到前面提到的例子,当专门化LifeForm时,Person类将具有Humanoid鉴别器。如果我们从LifeForm派生类,如MartianRomulan,这些类也将具有Humanoid作为鉴别器。

HorsePerson作为Centaur的基类组合在一起,将两个具有不同鉴别器的基类EquineHumanoid组合在一起。因此,每个基类都考虑了完全不同类型的特征和行为。虽然备用设计可能是可能的,但这种设计是可以接受的(除了面向对象设计纯粹主义者),并且可以在 C中使用虚基类来消除否则会复制的LifeForm部分。将两个共享共同基类并使用不同鉴别器专门化基类的类组合在一起是 C中 MI 和虚基类是合理的一个例子。

然而,将两个类,比如HorseDonkey(都是从LifeForm派生的),放在一个派生类,比如Mule中,也会创建一个菱形层次结构。检查HorseDonkey的鉴别器会发现它们都有一个Equine的鉴别器。在这种情况下,使用菱形设计将这两个类放在一起并不是最佳的设计选择。还有其他的设计选择是可能的,也更可取。在这种情况下,一个更可取的解决方案是将鉴别器Equine作为自己的类,然后从Equine派生HorseDonkeyMule。这将避免多重继承和菱形层次结构。让我们来看看这两种设计选项:

图 9.2 - 重新设计的菱形多重继承,没有多重继承

图 9.2 - 重新设计的菱形多重继承,没有多重继承

提醒

在菱形层次结构中,如果组合类的鉴别器相同,可以有更好的设计(通过使鉴别器成为自己的类)。然而,如果鉴别器不同,考虑保持菱形多重继承层次结构,并使用虚基类来避免共同基类子对象的重复。

我们现在已经彻底研究了鉴别器的面向对象概念,并看到了鉴别器如何帮助评估设计的合理性。在许多情况下,使用菱形层次结构的设计可以重新设计,不仅消除菱形形状,还可以完全消除多重继承。在继续前进到下一章之前,让我们简要回顾一下本章涵盖的多重继承问题和面向对象概念。

总结

在本章中,我们继续探索了一个有争议的面向对象编程主题,即多重继承,以加深对面向对象编程的理解。首先,在本章中,我们了解了多重继承的简单机制。同样重要的是,我们回顾了构建继承层次结构的原因以及使用多重继承的可能原因(即指定 Is-A、mix-in 和 Has-A 关系)。我们被提醒使用继承来指定 Is-A 关系支持纯粹的面向对象设计。我们还看到使用多重继承来实现 mix-in 关系。我们还看了有争议的使用多重继承来快速实现 Has-A 关系;我们将在第十章实现关联、聚合和组合中看到 Has-A 的首选实现。

我们已经看到,在我们的面向对象设计工具包中具有多重继承可能会导致菱形层次结构。我们已经看到了菱形层次结构引起的不可避免的问题,比如内存中的重复,构造/析构中的重复,以及访问复制的子对象时的歧义。我们也知道 C++提供了一种语言支持的机制来解决这些问题,使用虚基类。我们知道虚基类解决了一个繁琐的问题,但它们本身并不是完美的解决方案。

为了批评菱形层次结构,我们已经研究了鉴别器的面向对象概念,以帮助我们权衡使用菱形多重继承的面向对象设计的合理性。这也使我们了解到备选设计可以应用于一组对象;有时重新设计是一种更优雅的方法,解决方案将更容易、更长期地使用。

C++是一种“你可以做任何事情”的面向对象编程语言,多重继承是一个有争议的面向对象概念。了解何时可能需要某些多重继承设计,并理解语言特性来帮助解决这些多重继承问题将使您成为一个更好的程序员。知道何时需要重新设计也是至关重要的。

我们现在准备继续[第十章](B15702_10_Final_NM_ePub.xhtml#_idTextAnchor386),实现关联、聚合和组合,通过学习如何用编程技术表示关联、聚合和组合,进一步提高我们的面向对象编程技能。这些即将出现的概念将直接得到语言支持,但这些概念对我们的面向对象编程技能至关重要。让我们继续前进!

问题

  1. 在本章中使用虚基类的菱形继承示例中输入(或使用在线代码)。按原样运行它。
  1. 对于Centaur的实例,有多少个LifeForm子对象存在?

  2. LifeForm构造函数(和析构函数)被调用了多少次?提示:你可能想在每个构造函数和析构函数中使用cout放置跟踪语句。

  3. 如果在Centaur构造函数的成员初始化列表中省略了LifeForm的构造函数选择,哪个LifeForm构造函数会被调用?

  1. 现在,从PersonHorse的基类列表中删除关键字virtual(也就是说,LifeForm将不再是PersonHorse的虚基类。LifeForm将只是PersonHorse的典型基类)。同时,从Centaur构造函数的成员初始化列表中删除LifeForm构造函数的选择。现在,实例化Centaur
  1. 对于Centaur的实例,有多少个LifeForm子对象存在?

  2. 现在,LifeForm构造函数(和析构函数)被调用了多少次?

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

本章将继续推进我们对 C面向对象编程的了解。我们将通过探索关联、聚合和组合的面向对象概念来增进我们对对象关系的理解。这些 OO 概念在 C中没有直接的语言支持;相反,我们将学习多种编程技术来实现这些想法。我们还将了解对于各种概念,哪些实现技术是首选的,以及各种实践的优势和缺陷。

关联、聚合和组合在面向对象设计中经常出现。了解如何实现这些重要的对象关系是至关重要的。

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

  • 理解聚合和组合的 OO 概念,以及各种实现

  • 理解关联的 OO 概念及其实现,包括反向链接维护的重要性和引用计数的实用性

通过本章的学习,您将了解关联、聚合和组合的 OO 概念,以及如何在 C++中实现这些关系。您还将了解许多必要的维护方法,如引用计数和反向链接维护,以保持这些关系的最新状态。尽管这些概念相对简单,但您将看到为了保持这些类型的对象关系的准确性,需要大量的簿记工作。

通过探索这些核心对象关系,让我们扩展对 C++作为面向对象编程语言的理解。

技术要求

完整程序示例的在线代码可在以下 GitHub 链接找到:github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter10。每个完整程序示例都可以在 GitHub 存储库中找到,位于相应章节标题(子目录)下的文件中,文件名与所在章节编号相对应,后跟破折号,再跟随所在章节中的示例编号。例如,本章的第一个完整程序可以在名为Chp10-Ex1.cpp的文件中的子目录Chapter10中找到,位于上述 GitHub 目录下。

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

理解聚合和组合

面向对象的聚合概念在许多面向对象设计中出现。它与继承一样频繁,用于指定对象关系。聚合用于指定具有-一个、整体-部分以及在某些情况下的包含关系。一个类可以包含其他对象的聚合。聚合可以分为两类——组合以及一种不太严格和泛化的聚合形式。

泛化聚合组合都意味着具有-一个或整体-部分关系。然而,两者在两个相关对象之间的存在要求上有所不同。对于泛化聚合,对象可以独立存在;但对于组合,对象不能没有彼此存在。

让我们来看看每种聚合的变体,从组合开始。

定义和实现组合

组合是聚合的最专业形式,通常是大多数 OO 设计师和程序员在考虑聚合时所想到的。组合意味着包含,并且通常与整体-部分关系同义——即整体由一个或多个部分组成。整体包含部分。具有-一个关系也适用于组合。

外部对象,或整体,可以由部分组成。通过组合,部分不存在于整体之外。实现通常是一个嵌入对象 - 也就是说,一个包含对象类型的数据成员。在极少数情况下,外部对象将包含对包含对象类型的指针或引用;然而,当发生这种情况时,外部对象将负责创建和销毁内部对象。包含的对象没有其外层没有目的。同样,外层也不是理想的完整,没有内部的,包含的部分。

让我们看一个通常实现的组合示例。该示例将说明包含 - Student 有一个 Id。更重要的是,我们将暗示IdStudent的一个必要部分,并且没有Student就不会存在。Id对象本身没有任何目的。如果它们不是给予它们目的的主要对象的一部分,Id对象根本不需要存在。同样,您可能会认为Student没有Id是不完整的,尽管这有点主观!我们将使用嵌入对象在整体中实现部分

组合示例将被分成许多部分。虽然只显示了示例的部分,完整的程序可以在以下 GitHub 位置找到:

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter10/Chp10-Ex1.cpp

#include <iostream>
#include <iomanip>
#include <cstring>
using namespace std;
class Id  // the contained 'part'
{
private:
    char *idNumber;
public:
    Id() { idNumber = 0; }
    Id(const char *); 
    Id(const Id &);  
    ~Id() { delete idNumber; }
    const char *GetId() const { return idNumber; }
};
Id::Id(const char *id)
{
    idNumber = new char [strlen(id) + 1];
    strcpy(idNumber, id);
} 
Id::Id(const Id &id)
{
   idNumber = new char [strlen(id.idNumber) + 1];
   strcpy(idNumber, id.idNumber);
}

在前面的代码片段中,我们已经定义了一个Id类。Id将是一个可以被其他需要完全功能的Id的类包含的类。Id将成为可能选择包含它的整体对象的部分

让我们继续构建一组最终将包含这个Id的类。我们将从一个我们熟悉的类Person开始:

class Person
{
private:
    // data members
    char *firstName;
    char *lastName;
    char middleInitial;
    char *title;  // Mr., Ms., Mrs., Miss, Dr., etc.
protected:
    void ModifyTitle(const char *);
public:
    Person();   // default constructor
    Person(const char *, const char *, char, const char *);
    Person(const Person &);  // copy constructor
    virtual ~Person();  // virtual destructor
    const char *GetFirstName() const { return firstName; }
    const char *GetLastName() const { return lastName; }
    const char *GetTitle() const { return title; }
    char GetMiddleInitial() const { return middleInitial; }
    // virtual functions
    virtual void Print() const;   
    virtual void IsA();
    virtual void Greeting(const char *);
};
//  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:
    // data members
    float gpa;
    char *currentCourse;
    static int numStudents;  
    Id studentId;  // is composed of a 'part'
public:
    // member function prototypes
    Student();  // default constructor
    Student(const char *, const char *, char, const char *,
            float, const char *, const char *);
    Student(const Student &);  // copy constructor
    virtual ~Student();  // destructor
    void EarnPhD() { ModifyTitle("Dr."); } // various inline
    float GetGpa() const { return gpa; }         // functions
    const char *GetCurrentCourse() const
        { return currentCourse; }
    void SetCurrentCourse(const char *); // prototype only
    virtual void Print() const override;
    virtual void IsA() override { cout << "Student" << endl; }
    static int GetNumberStudents() { return numStudents; }
    // Access function for embedded Id object
    const char *GetStudentId() const;   // prototype only
};
int Student::numStudents = 0;  // static data member
inline void Student::SetCurrentCourse(const char *c)
{
    delete currentCourse;   // delete existing course
    currentCourse = new char [strlen(c) + 1];
    strcpy(currentCourse, c);
}

在前面的Student类中,我们经常注意到Student是从Person派生的。正如我们已经知道的那样,这意味着Student实例将包括Person的内存布局,作为Person子对象。

但是,请注意Student类定义中的数据成员Id studentId;。在这里,studentIdId类型。它不是指针,也不是对Id的引用。数据成员studentId是一个嵌入对象。这意味着当实例化Student类时,不仅将包括从继承类中继承的内存,还将包括任何嵌入对象的内存。我们需要提供一种初始化嵌入对象studentId的方法。

让我们继续Student成员函数,以了解如何初始化,操作和访问嵌入对象:

// constructor definitions
Student::Student(): studentId ("None") 
{
    gpa = 0.0;
    currentCourse = 0;
    numStudents++;
}
Student::Student(const char *fn, const char *ln, char mi,
                 const char *t, float avg, const char *course,
                 const char *id): Person(fn, ln, mi, t),
                 studentId(id)
{
    gpa = avg;
    currentCourse = new char [strlen(course) + 1];
    strcpy(currentCourse, course);
    numStudents++;
}
Student::Student(const Student &ps): Person(ps),
                 studentId(ps.studentId)
{
    gpa = ps.gpa;
    currentCourse = new char [strlen(ps.currentCourse) + 1];
    strcpy(currentCourse, ps.currentCourse);
    numStudents++;
}
Student::~Student()   // destructor definition
{
    delete currentCourse;
    numStudents--;
    // the 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 char *GetStudentId() const 
{   
    return studentId.GetId();   
} 

Student的先前列出的成员函数中,让我们从我们的构造函数开始。请注意,在默认构造函数中,我们利用成员初始化列表(:)来指定studentId("None")。因为studentId是一个成员对象,我们有机会选择(通过成员初始化列表)应该用于其初始化的构造函数。在这里,我们仅仅选择具有Id(const char *)签名的构造函数。

类似地,在Student的替代构造函数中,我们使用成员初始化列表来指定studentId(id),这也将选择Id(const char *)构造函数,将参数id传递给此构造函数。

Student的复制构造函数还指定了如何使用成员初始化列表中的studentId(ps.studentId)来初始化studentId成员对象。在这里,我们只是调用了Id的复制构造函数。

在我们的Student析构函数中,我们不需要释放studentId。因为这个数据成员是一个嵌入对象,当外部对象的内存消失时,它的内存也会消失。当然,因为studentId本身也是一个对象,它的析构函数会在释放内存之前首先被调用。在幕后,编译器会(隐秘地)在Student析构函数的最后一行代码中补充一个对studentIdId析构函数的调用。

最后,在前面的代码段中,让我们注意一下studentId.GetId()Student::Print()Student::GetStudentId()中的调用。在这里,嵌入对象studentId调用它自己的公共函数Id::GetId()来检索它在Student类作用域内的私有数据成员。因为studentIdStudent中是私有的,所以这个嵌入对象只能在Student的作用域内被访问(也就是Student的成员函数)。然而,Student::GetStudentId()的添加为Student实例提供了一个公共的包装器,使得其他作用域中的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 

这个例子详细介绍了组合及其典型实现,即嵌入对象。现在让我们来看一个使用较少的、替代的实现方式——继承。

考虑组合的另一种实现方式

值得理解的是,组合也可以用继承来实现;然而,这是极具争议的。记住,继承通常用于实现是一个关系,而不是有一个关系。我们在第九章中简要描述了使用继承来实现有一个关系,即探索多重继承

简而言之,你只需从部分继承,而不是将部分作为数据成员嵌入。这样做时,你就不再需要为部分提供包装器函数,就像我们在前面的程序中看到的那样,Student::GetStudentId()方法调用studentId.GetId()来提供对其嵌入部分的访问。在嵌入对象的例子中,包装器函数是必要的,因为部分(Id)在整体(Student)中是私有的。程序员无法在Student的作用域之外访问Student的私有studentId数据成员。当然,Student的成员函数(如GetStudentId())可以访问它们自己类的私有数据成员,并通过这样做来实现Student::GetStudentId()包装器函数,以提供这种(安全的)访问。

如果使用了继承,Id::GetId()的公共接口将会被简单地继承为 Student 的公共接口,无需通过嵌入对象显式地进行访问。

尽管在某些方面继承部分很简单,但它大大增加了多重继承的复杂性。我们知道多重继承可能会带来许多潜在的复杂性。此外,使用继承,整体只能包含一个部分的实例,而不是多个部分的实例。

此外,使用继承实现整体-部分关系可能会在将实现与 OO 设计进行比较时产生混淆。请记住,继承通常意味着 Is-A 而不是 Has-A。因此,最典型和受欢迎的聚合实现是通过嵌入对象。

接下来,让我们继续看一下更一般形式的聚合。

定义和实现泛化聚合

我们已经看过 OO 设计中最常用的聚合形式,即组合。特别是,通过组合,我们已经看到部分没有理由在没有整体的情况下存在。尽管如此,还存在一种更一般的(但不太常见)聚合形式,并且有时会在 OO 设计中进行指定。我们现在将考虑这种不太常见的聚合形式。

泛化聚合中,部分可以存在而不需要整体。部分将被单独创建,然后在以后的某个时间点附加到整体上。当整体消失时,部分可能会留下来以供与另一个外部或整体对象一起使用。

在泛化聚合中,Has-A 关系当然适用,整体-部分的指定也适用。不同之处在于整体对象不会创建也不会销毁部分子对象。考虑一个简单的例子,汽车Has-A(n)发动机。汽车对象还Has-A一组 4 个轮胎对象。发动机或轮胎对象可以单独制造,然后传递给汽车的构造函数,以提供这些部分给整体。然而,如果发动机被销毁,可以轻松地替换为新的发动机(使用成员函数),而无需销毁整个汽车然后重新构建。

泛化聚合等同于 Has-A 关系,但我们认为这种关系比组合更灵活,个体部分的持久性更强。我们将这种关系视为聚合,只是因为我们希望赋予对象 Has-A 的含义。在“汽车”、“发动机”、“轮胎”的例子中,Has-A 关系很强;发动机和轮胎是必要的部分,需要组成整个汽车。

在这里,实现通常是整体包含指向部分(们)的指针。重要的是要注意,部分将被传递到外部对象的构造函数(或其他成员函数)中以建立关系。关键的标志是整体不会创建(也不会销毁)部分。部分也永远不会销毁整体。

顺便说一句,泛化聚合的个体部分的持久性(和基本实现)将类似于我们下一个主题 - 关联。让我们继续前进到我们的下一节,以了解泛化聚合和关联之间的相似之处以及 OO 概念上的差异(有时是微妙的)。

理解关联

关联模拟了存在于否则无关的类类型之间的关系。关联可以提供对象相互作用以实现这些关系的方式。关联不用于 Has-A 关系;然而,在某些情况下,我们描述的是真正的Has-A 关系,或者我们只是因为在语言上听起来合适而使用 Has-A 短语。

关联的多重性存在:一对一,一对多,多对一,或多对多。例如,一个学生可能与一个大学相关联,而那个大学可能与许多学生实例相关联;这是一对多的关联。

相关的对象具有独立的存在。也就是说,两个或更多的对象可以在应用程序的某个部分被实例化并独立存在。在应用程序的某个时刻,一个对象可能希望断言与另一个对象的依赖或关系。在应用程序的后续部分,相关的对象可能分道扬镳,继续各自独立的路径。

例如,考虑课程教师之间的关系。一个课程与一个教师相关联。一个课程需要一个教师;一个教师课程是必不可少的。一个教师可能与许多课程相关联。然而,每个部分都是独立存在的 - 一个不会创造也不会摧毁另一个。教师也可以独立存在而没有课程;也许一个教师正在花时间写书,或者正在休假,或者是一位进行研究的教授。

在这个例子中,关联非常类似于广义聚合。在这两种情况下,相关的对象也是独立存在的。在这种情况下,无论是说课程拥有教师还是课程教师有依赖都可以是灰色的。你可能会问自己 - 是不是只是口头语言让我选择了“拥有”的措辞?我是不是指两者之间存在必要的联系?也许这种关系是一种关联,它的描述性修饰(进一步描述关联的性质)是。你可能有支持任何选择的论点。因此,广义聚合可以被认为是关联的专门类型;我们将看到它们的实现是相同的,使用独立存在的对象。尽管如此,我们将区分典型关联作为对象之间明确不支持真正拥有关系的关系。

例如,考虑大学教师之间的关系。我们可以考虑这种关系不是拥有关系,而是关联关系;我们可以认为描述这种关系的修饰是雇用。同样,大学与许多学生对象有关系。这里的关联可以用教育来描述。可以区分出大学对象,对象和这类组件组成,以支持其通过包含的拥有关系,然而它与教师对象,学生对象等的关系是使用关联来建立的。

既然我们已经区分了典型关联和广义聚合,让我们看看如何实现关联以及涉及的一些复杂性。

实现关联

通常,两个或更多对象之间的关联是使用指针或指针集来实现的。方使用指向相关对象的指针来实现,而关系的方则以指向相关对象的指针集合的形式实现。指针集合可以是指针数组,指针链表,或者真正的任何指针集合。每种类型的集合都有其自己的优点和缺点。例如,指针数组易于使用,可以直接访问特定成员,但项目数量是固定的。指针链表可以容纳任意数量的项目,但访问特定元素需要遍历其他元素以找到所需的项目。

偶尔,引用可能被用来实现关联的one一侧。请记住,引用必须被初始化,并且不能在以后被重置为引用另一个对象。使用引用来建模关联意味着一个实例将与另一个特定实例相关联,而主对象存在期间不能更改。这是非常限制性的;因此,引用很少用于实现关联。

无论实现方式如何,当主对象消失时,它都不会影响(即删除)关联的对象。

让我们看一个典型的例子,说明了首选的一对多关联实现,利用one一侧的指针和many一侧的指针集合。在这个例子中,一个University将与许多Student实例相关联。而且,为了简单起见,一个Student将与一个University相关联。

为了节省空间,本程序中与上一个示例相同的部分将不会显示;但是,整个程序可以在我们的 GitHub 上找到:

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter10/Chp10-Ex2.cpp

#include <iostream>
#include <iomanip>
#include <cstring>
using namespace std;
const int MAX = 25;
// class Id and class 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:
    char *name;
    Student *studentBody[MAX]; // Association to many students
    int currentNumStudents;
    University(const University &);  // prohibit copies
public:
    University();
    University(const char *);
    ~University();
    void EnrollStudent(Student *);
    const char *GetName() const { return name; }
    void PrintStudents() const;
};

在前面的段落中,让我们首先注意class Student;的前向声明。这个声明允许我们的代码在Student类定义之前引用Student类型。在University类定义中,我们看到有一个指向Student的指针数组。我们还看到EnrollStudent()方法以Student *作为参数。前向声明使得在定义之前可以使用Student

我们还注意到University具有一个简单的接口,包括构造函数、析构函数和一些成员函数。

接下来,让我们来看一下University成员函数的定义:

University::University()
{
    name = 0;
    for (int i = 0; i < MAX; i++)  // the student body
       studentBody[i] = 0;         // will start out empty 
    currentNumStudents = 0;
}
University::University(const char *n)
{
    name = new char [strlen(n) + 1];
    strcpy(name, n);
    for (int i = 0; i < MAX; i++) // the student body will
       studentBody[i] = 0;        // start out empty
    currentNumStudents = 0;
}
University::~University()
{
    delete name;
    // The students will delete themselves
    for (int i = 0; i < MAX; i++)
       studentBody[i] = 0;  // only NULL out 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;
    for (int i = 0; i < currentNumStudents; i++)
    {
       cout << "\t" << studentBody[i]->GetFirstName() << " ";
       cout << studentBody[i]->GetLastName() << endl;
    }
}

仔细观察前面的University方法,我们可以看到在University的两个构造函数中,我们只是将组成studentBody的指针NULL。同样,在析构函数中,我们也将与关联的Students的链接NULL。不久,在本节中,我们将看到还需要一些额外的反向链接维护,但现在的重点是我们不会删除关联的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;
    char *currentCourse;
    static int numStudents;
    Id studentId;  // part, Student Has-A studentId
    University *univ;  // Association to University object
public:
    // member function prototypes
    Student();  // default constructor
    Student(const char *, const char *, char, const char *,
            float, const char *, const char *, University *);
    Student(const Student &);  // copy constructor
    virtual ~Student();  // destructor
    void EarnPhD() { ModifyTitle("Dr."); }
    float GetGpa() const { return gpa; }
    const char *GetCurrentCourse() const 
        { return currentCourse; }
    void SetCurrentCourse(const char *); // prototype only
    virtual void Print() const override;
    virtual void IsA() override { cout << "Student" << endl; }
    static int GetNumberStudents() { return numStudents; }
    // Access functions for aggregate/associated objects
    const char *GetStudentId() const 
        { return studentId.GetId(); }
    const char *GetUniversity() const 
        { return univ->GetName(); }
};
int Student::numStudents = 0;  // def. of static data member
inline void Student::SetCurrentCourse(const char *c)
{
    delete currentCourse;   // delete existing course
    currentCourse = new char [strlen(c) + 1];
    strcpy(currentCourse, c);
}

在前面的代码段中,我们看到了Student类的定义。请注意,我们使用指针数据成员University *univ;University关联。

Student的类定义中,我们还可以看到有一个包装函数来封装对学生所在大学名称的访问,即Student::GetUniversity()。在这里,我们允许关联对象univ调用其公共方法University::GetName(),并将该值作为Student::GetUniversity()的结果返回。

现在,让我们来看看Student的非内联成员函数:

Student::Student(): studentId ("None")
{
    gpa = 0.0;
    currentCourse = 0;  
    univ = 0;    // no current University association
    numStudents++;
}
Student::Student(const char *fn, const char *ln, char mi,
                 const char *t, float avg, const char *course,
                 const char *id, University *univ):
                 Person(fn, ln, mi, t), studentId(id)
{
    gpa = avg;
    currentCourse = new char [strlen(course) + 1];
    strcpy(currentCourse, course);
    // establish link to University, then back link
    this->univ = univ;  // required use of 'this'
    univ->EnrollStudent(this);  // another required 'this'
    numStudents++;
}
Student::Student(const Student &ps): 
                 Person(ps), studentId(ps.studentId)
{
    gpa = ps.gpa;
    currentCourse = new char [strlen(ps.currentCourse) + 1];
    strcpy(currentCourse, ps.currentCourse);
    this->univ = ps.univ;    
    univ->EnrollStudent(this);
    numStudents++;
}
Student::~Student()  // destructor
{
    delete currentCourse;
    numStudents--;
    univ = 0;  // the University will delete itself
    // the 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对象的链接NULL。默认构造函数无法将此链接设置为现有对象,并且肯定不应该创建University实例来这样做。同样,Student析构函数不应该仅仅因为Student对象的寿命已经结束就删除University

前面代码中最有趣的部分发生在Student的备用构造函数和复制构造函数中。让我们来看看备用构造函数。在这里,我们建立了与关联的University的链接,以及从University返回到Student的反向链接。

在代码行this->univ = univ;中,我们通过将数据成员univ(由this指针指向)设置为指向输入参数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类中的一个公共成员函数。没有问题,univ的类型是UniversityUniversity::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 " << s2.GetUniversity() << endl;
    u1.PrintStudents();
    return 0;
}

最后,在我们的main()函数中的前面代码片段中,我们可以创建几个独立存在的对象,创建它们之间的关联,然后查看这种关系的实际情况。

首先,我们实例化一个University,即u1。接下来,我们实例化三个Studentss1s2s3,并将每个关联到University u1。请注意,当我们实例化一个Student时,可以设置这种关联,或者稍后进行设置,例如,如果Student类支持SelectUniversity(University *)接口来这样做。

然后,我们打印出每个Student,以及每个Student所就读的University的名称。然后我们打印出我们的University u1的学生人数。我们注意到,关联对象之间建立的链接在两个方向上都是完整的。

让我们来看看上述程序的输出:

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

我们已经看到了如何在相关对象之间轻松建立和利用关联。然而,从实现关联中会产生大量的维护工作。让我们继续了解引用计数和反向链接维护的必要和相关问题,这将有助于这些维护工作。

利用反向链接维护和引用计数

在前面的小节中,我们已经看到了如何使用指针来实现关联。我们已经看到了如何使用指向关联实例中的对象的指针来建立对象之间的关系。我们也看到了如何通过建立反向链接来完成循环的双向关系。

然而,与关联对象一样,关系是流动的,随着时间的推移会发生变化。例如,给定“大学”的“学生”群体会经常发生变化,或者“教师”将在每个学期教授的各种“课程”也会发生变化。因此,通常会删除特定对象与另一个对象的关联,并可能改为与该类的另一个实例关联。但这也意味着关联的对象必须知道如何删除与第一个提到的对象的链接。这变得复杂起来。

举例来说,考虑“学生”和“课程”的关系。一个“学生”可以注册多个“课程”实例。一个“课程”包含对多个“学生”实例的关联。这是一种多对多的关联。假设“学生”希望退出一门“课程”。仅仅让特定的“学生”实例移除指向特定“课程”实例的指针是不够的。此外,“学生”必须让特定的“课程”实例知道,应该将相关的“学生”从该“课程”的名单中移除。这被称为反向链接维护。

考虑一下,在上述情况下,如果一个“学生”简单地将其与要退出的“课程”的链接设置为NULL,然后不再进行任何操作,会发生什么。受影响的“学生”实例将不会有问题。然而,以前关联的“课程”实例仍将包含指向该“学生”的指针。也许这会导致“学生”在“教师”仍然认为该“学生”已注册但没有交作业的情况下获得不及格分数。最终,这位“学生”还是受到了影响,得到了不及格分数。

记住,对于关联的对象,一个对象在完成与另一个对象的交互后不会删除另一个对象。例如,当一个“学生”退出一门“课程”时,他们不会删除那门“课程” - 只是移除他们对相关“课程”的指针(并且肯定也要处理所需的反向链接维护)。

一个帮助我们进行整体链接维护的想法是考虑引用计数。引用计数的目的是跟踪有多少指针可能指向给定的实例。例如,如果其他对象指向给定的实例,那么该实例就不应该被删除。否则,其他对象中的指针将指向已释放的内存,这将导致大量的运行时错误。

让我们考虑一个具有多重性的关联。比如“学生”和“课程”之间的关系。一个“学生”应该跟踪有多少“课程”指针指向该“学生”,也就是说,该“学生”正在上多少门“课程”。只要有多个“课程”指向该“学生”,就不应该删除该“学生”。否则,“课程”将指向已删除的内存。处理这种情况的一种方法是在“学生”析构函数中检查对象(this)是否包含指向“课程”的非NULL指针。如果对象包含这样的指针,那么它需要通过每个活跃的“课程”调用一个方法,请求从每个这样的“课程”中移除对“学生”的链接。在移除每个链接之后,与“课程”实例集对应的引用计数可以递减。

同样,链接维护应该发生在Course类中,而不是Student实例中。在通知所有在该Course中注册的Student实例之前,不应删除Course实例。通过引用计数来跟踪有多少Student实例指向Course的特定实例是有帮助的。在这个例子中,只需维护一个变量来反映当前注册在Course中的Student实例的数量就可以了。

我们可以自己精心进行链接维护,或者选择使用智能指针来管理关联对象的生命周期。智能指针可以在 C++标准库中找到。它们封装了一个指针(即在类中包装一个指针)以添加智能特性,包括引用计数和内存管理。由于智能指针使用了模板,而我们直到第十三章使用模板,我们才会涵盖,所以我们在这里只是提到了它们的潜在实用性。

我们现在已经看到了后向链接维护的重要性,以及引用计数的实用性,以充分支持关联及其成功的实现。在继续前进到下一章之前,让我们简要回顾一下本章涵盖的面向对象的概念——关联、聚合和组合。

总结

在本章中,我们通过探索各种对象关系——关联、聚合和组合,继续推进我们对面向对象编程的追求。我们已经理解了代表这些关系的各种面向对象设计概念,并且已经看到 C++并没有通过关键字或特定的语言特性直接提供语言支持来实现这些概念。

尽管如此,我们已经学会了几种实现这些核心面向对象关系的技术,比如使用嵌入对象来实现组合和广义聚合,或者使用指针来实现关联。我们已经研究了这些关系中对象存在的典型寿命,例如通过创建和销毁其内部部分(通过嵌入对象,或者更少见地通过分配和释放指针成员),或者通过相关对象的独立存在,它们既不创建也不销毁彼此。我们还深入研究了实现关联所需的内部工作,特别是那些具有多重性的关联,通过检查后向链接维护和引用计数。

通过理解如何实现关联、聚合和组合,我们已经为我们的面向对象编程技能增添了关键特性。我们已经看到了这些关系在面向对象设计中甚至可能比继承更为常见的例子。通过掌握这些技能,我们已经完成了在 C++中实现基本面向对象概念的核心技能组合。

我们现在准备继续到第十一章处理异常,这将开始我们扩展 C++编程技能的探索。让我们继续前进!

问题

  1. 在本章的University-Student示例中添加一个额外的Student构造函数,以接受引用而不是指针的University构造参数。例如,除了带有签名Student::Student(const char *fn, const char *ln, char mi, const char *t, float avg, const char *course, const char *id, University *univ);的构造函数外,重载此函数,但最后一个参数为University &univ。这如何改变对此构造函数的隐式调用?

提示:在您重载的构造函数中,您现在需要取University引用参数的地址(即&)来设置关联(存储为指针)。您可能需要切换到对象表示法(.)来设置后向链接(如果您使用参数univ,而不是数据成员this->univ)。

  1. 编写一个 C++程序来实现“课程”类型对象和“学生”类型对象之间的多对多关联。您可以选择在之前封装“学生”的程序基础上构建。多对多关系应该按以下方式工作:
  1. 给定的“学生”可以选修零到多门“课程”,而给定的“课程”将与多个“学生”实例关联。封装“课程”类,至少包含课程名称、指向关联“学生”实例的指针集,以及一个引用计数,用于跟踪在“课程”中的“学生”实例数量(这将等同于多少“学生”实例指向给定的“课程”实例)。添加适当的接口来合理封装这个类。

  2. 在您的“学生”类中添加指向该“学生”注册的“课程”实例的指针集。此外,跟踪给定“学生”注册的“课程”实例数量。添加适当的成员函数来支持这种新功能。

  3. 使用指针的链表(即,数据部分是指向关联对象的指针)或作为关联对象的指针数组来对多边关联进行建模。请注意,数组将对您可以拥有的关联对象数量施加限制;但是,这可能是合理的,因为给定的“课程”只能容纳最大数量的“学生”,而“学生”每学期只能注册最大数量的“课程”。如果您选择指针数组的方法,请确保您的实现包括错误检查,以适应每个数组中关联对象数量超过最大限制的情况。

  4. 一定要检查简单的错误,比如尝试在已满的“课程”中添加“学生”,或者向“学生”的课程表中添加过多的“课程”(假设每学期最多有 5 门课程)。

  5. 确保您的析构函数不会删除关联的实例。

  6. 引入至少三个“学生”对象,每个对象都选修两门或更多门“课程”。此外,请确保每门“课程”都有多个“学生”注册。打印每个“学生”,包括他们注册的每门“课程”。同样,打印每门“课程”,显示注册在该“课程”中的每个“学生”。

  1. (可选)增强您在练习 2中的程序,以获得以下反向链接维护和引用计数的经验:
  1. 为“学生”实现一个DropCourse()接口。也就是,在“学生”中创建一个“Student::DropCourse(Course *)”方法。在这里,找到“学生”希望在他们的课程列表中删除的“课程”,但在删除“课程”之前,调用该“课程”的一个方法,从该“课程”中删除前述的“学生”(即,this)。提示:您可以创建一个Course::RemoveStudent(Student *)方法来帮助删除反向链接。

  2. 现在,完全实现适当的析构函数。当一个“课程”被销毁时,让“课程”析构函数首先告诉每个剩余的关联“学生”删除他们与该“课程”的链接。同样,当一个“学生”被销毁时,循环遍历“学生”的课程列表,要求那些“课程”从他们的学生列表中删除前述的“学生”(即,this)。您可能会发现每个类中的引用计数(即,通过检查numStudentsnumCourses)有助于确定是否必须执行这些任务。

第三部分:扩展您的 C++编程技能

本节的目标是扩展您的 C编程技能,超越面向对象编程技能,涵盖 C的其他关键特性。

本节的初始章节通过理解 try、throw 和 catch 的机制,并通过检查许多示例来探索异常机制,深入研究各种异常处理场景来探索 C++中的异常处理。此外,本章通过引入新的异常类来扩展异常类层次结构。

下一章深入探讨了友元函数和友元类的正确使用方式,以及运算符重载(有时可能需要友元)以使内置类型和用户定义类型之间的操作多态化。

下一章探讨了使用 C++模板来帮助使代码通用化,并对各种数据类型使用模板函数和模板类。此外,本章解释了运算符重载如何帮助使模板代码对几乎任何数据类型都可扩展。

在下一章中,将介绍 C++中的标准模板库,并检查核心 STL 容器,如列表、迭代器、双端队列、栈、队列、优先队列和映射。此外,还将介绍 STL 算法和函数对象。

本节的最后一章通过探索规范类形式、为组件测试创建驱动程序、测试通过继承、关联和聚合相关的类以及测试异常处理机制,对测试 OO 程序和组件进行了调查。

本节包括以下章节:

  • [第十一章],处理异常

  • [第十二章],友元和运算符重载

  • [第十三章],使用模板

  • [第十四章],理解 STL 基础

  • [第十五章],测试类和组件

第十一章:处理异常

本章将开始我们的探索,扩展你的 C编程技能,超越面向对象编程的概念,目标是让你能够编写更健壮、更可扩展的代码。我们将通过探索 C中的异常处理来开始这个努力。在我们的代码中添加语言规定的方法来处理错误,将使我们能够实现更少的错误和更可靠的程序。通过使用语言内置的正式异常处理机制,我们可以实现对错误的统一处理,从而实现更易于维护的代码。

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

  • 理解异常处理的基础知识——trythrowcatch

  • 探索异常处理机制——尝试可能引发异常的代码,引发(抛出)、捕获和处理异常,使用多种变体

  • 利用标准异常对象或创建自定义异常类的异常层次结构

通过本章结束时,你将了解如何在 C++中利用异常处理。你将看到如何识别错误以引发异常,通过抛出异常将程序控制转移到指定区域,然后通过捕获异常来处理错误,并希望修复手头的问题。

你还将学习如何利用 C++标准库中的标准异常,以及如何创建自定义异常对象。可以设计一组异常类的层次结构,以增加健壮的错误检测和处理能力。

通过探索内置的语言异常处理机制,扩展我们对 C++的理解。

技术要求

完整程序示例的在线代码可以在以下 GitHub URL 找到:github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter11。每个完整程序示例都可以在 GitHub 存储库中找到,位于相应章节标题(子目录)下的文件中,文件名与所在章节编号相对应,后跟该章节中的示例编号。例如,本章的第一个完整程序可以在名为Chp11-Ex1.cpp的文件中的子目录Chapter11中找到,位于上述 GitHub 目录下。

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

理解异常处理

应用程序中可能会出现错误条件,这些错误条件可能会阻止程序正确地继续运行。这些错误条件可能包括超出应用程序限制的数据值、必要的输入文件或数据库不可用、堆内存耗尽,或者任何其他可能的问题。C++异常提供了一种统一的、语言支持的方式来处理程序异常。

在引入语言支持的异常处理机制之前,每个程序员都会以自己的方式处理错误,有时甚至根本不处理。程序错误和未处理的异常意味着在应用程序的其他地方,将会发生意外的结果,应用程序往往会异常终止。这些潜在的结果肯定是不可取的!

C++异常处理提供了一种语言支持的机制,用于检测和纠正程序异常,使应用程序能够继续运行,而不是突然结束。

让我们从语言支持的关键字trythrowcatch开始,来看一下这些机制,它们构成了 C++中的异常处理。

利用 try、throw 和 catch 进行异常处理

异常处理检测到程序异常,由程序员或类库定义,并将控制传递到应用程序的另一个部分,该部分可能处理特定的问题。只有作为最后的手段,才需要退出应用程序。

让我们首先看一下支持异常处理的关键字。这些关键字是:

  • try:允许程序员尝试可能引发异常的代码部分。

  • throw:一旦发现错误,throw会引发异常。这将导致跳转到与关联 try 块下面的 catch 块。Throw 将允许将参数返回到关联的 catch 块。抛出的参数可以是任何标准或用户定义的类型。

  • catch:指定一个代码块,旨在寻找已抛出的异常,以尝试纠正情况。同一作用域中的每个 catch 块将处理不同类型的异常。

在使用异常处理时,回溯的概念是有用的。当调用一系列函数时,我们在堆栈上建立起与每个连续函数调用相关的状态信息(参数、局部变量和返回值空间),以及每个函数的返回地址。当抛出异常时,我们可能需要解开堆栈,直到这个函数调用序列(或 try 块)开始的原点,同时重置堆栈指针。这个过程被称为回溯,它允许程序返回到代码中的较早序列。回溯不仅适用于函数调用,还适用于包括嵌套 try 块在内的嵌套块。

这里有一个简单的例子,用来说明基本的异常处理语法和用法。尽管代码的部分没有显示出来以节省空间,但完整的示例可以在我们的 GitHub 上找到,如下所示:

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter11/Chp11-Ex1.cpp

// Assume Student class is as we've seen before, but with one
// additional virtual member function. Assume usual headers.
void Student::Validate()  // defined as virtual in class def
{                         // so derived classes may override
    // check constructed Student to see if standards are met
    // if not, throw an exception
    throw "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 char *err)
    {
        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." << endl;
    return 0;
}

在上面的代码片段中,我们可以看到关键字trythrowcatch的作用。首先,让我们注意Student::Validate()成员函数。想象一下,在这个虚方法中,我们验证一个Student是否符合入学标准。如果是,函数会正常结束。如果不是,就会抛出异常。在这个例子中,抛出一个简单的const char *,其中包含消息"Does not meet prerequisites"。

在我们的main()函数中,我们首先实例化一个Student,即s1。然后,我们将对s1.Validate()的调用嵌套在一个 try 块中。我们实际上是在说,我们想尝试这个代码块。如果Student::Validate()按预期工作,没有错误,我们的程序将完成 try 块,跳过 try 块下面的 catch 块,并继续执行 catch 块下面的代码。

然而,如果Student::Validate()抛出异常,我们将跳过 try 块中的任何剩余代码,并在随后定义的 catch 块中寻找与const char *类型匹配的异常。在匹配的 catch 块中,我们的目标是尽可能地纠正错误。如果成功,我们的程序将继续执行 catch 块下面的代码。如果不成功,我们的工作就是优雅地结束程序。

让我们看一下上述程序的输出:

Student does not meet prerequisites 

接下来,让我们总结一下异常处理的整体流程,具体如下:

  • 当程序完成 try 块而没有遇到任何抛出的异常时,代码序列将继续执行 catch 块后面的语句。多个 catch 块(带有不同的参数类型)可以跟在 try 块后面。

  • 当抛出异常时,程序必须回溯并返回到包含原始函数调用的 try 块。程序可能需要回溯多个函数。当回溯发生时,遇到的对象将从堆栈中弹出,因此被销毁。

  • 一旦程序(引发异常)回溯到执行 try 块的函数,程序将继续执行与抛出的异常类型匹配的 catch 块(在 try 块之后)。

  • 类型转换(除了通过公共继承相关的向上转型对象)不会被执行以匹配潜在的 catch 块。然而,带有省略号()的 catch 块可以作为最一般类型的 catch 块使用,并且可以捕获任何类型的异常。

  • 如果不存在匹配的catch块,程序将调用 C++标准库中的terminate()。请注意,terminate()将调用abort(),但程序员可以通过set_terminate()函数注册另一个函数供terminate()调用。

现在,让我们看看如何使用set_terminate()注册一个函数。虽然我们这里只展示了代码的关键部分,完整的程序可以在我们的 GitHub 上找到:

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter11/Chp11-Ex2.cpp

void AppSpecificTerminate()
{   // first, do what is necessary to end program gracefully
    cout << "Uncaught exception. Program terminating" << endl;
    exit(1);
}
int main()
{   
    set_terminate(AppSpecificTerminate);  // register fn.
    return 0;
}

在前面的代码片段中,我们定义了自己的AppSpecificTerminate()函数。这是我们希望terminate()函数调用的函数,而不是调用abort()的默认行为。也许我们使用AppSpecificTerminate()来更优雅地结束我们的应用程序,保存关键数据结构或数据库值。当然,我们也会自己exit()(或abort())。

main()中,我们只需调用set_terminate(AppSpecificTerminate)来注册我们的terminate函数到set_terminate()。现在,当否则会调用abort()时,我们的函数将被调用。

有趣的是,set_terminate()返回一个指向先前安装的terminate_handler的函数指针(在第一次调用时将是指向abort()的指针)。如果我们选择保存这个值,我们可以使用它来恢复先前注册的终止处理程序。请注意,在这个示例中,我们选择不保存这个函数指针。

以下是使用上述代码未捕获异常的输出:

Uncaught exception. Program terminating

请记住,诸如terminate()abort()set_terminate()之类的函数来自标准库。虽然我们可以使用作用域解析运算符在它们的名称前加上库名称,比如std::terminate(),但这并非必需。

注意

异常处理并不意味着取代简单的程序员错误检查;异常处理的开销更大。异常处理应该保留用于以统一方式和在一个公共位置处理更严重的程序错误。

现在我们已经了解了异常处理的基本机制,让我们来看一些稍微复杂的异常处理示例。

探索异常处理机制及典型变化

异常处理可以比之前所示的基本机制更加复杂和灵活。让我们来看看异常处理基础的各种组合和变化,因为每种情况可能适用于不同的编程情况。

将异常传递给外部处理程序

捕获的异常可以传递给外部处理程序进行处理。或者,异常可以部分处理,然后抛出到外部范围进行进一步处理。

让我们在之前的示例基础上演示这个原则。完整的程序可以在以下 GitHub 位置看到:

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter11/Chp11-Ex3.cpp

// Assume Student class is as we've seen it before, but with
// two additional member functions. Assume usual header files.
void Student::Validate()  // defined as virtual in class def
{                         // so derived classes may override
    // check constructed student to see if standards are met
    // if not, throw an exception
    throw "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 char *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 char *err) // outer scope catcher (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. " << endl;
    return 0;
}

在上述代码中,假设我们已经包含了我们通常的头文件,并且已经定义了Student的通常类定义。现在我们将通过添加Student::Validate()方法(虚拟的,以便可以被覆盖)和Student::TakePrerequisites()方法(非虚拟的,后代应该按原样使用)来增强Student类。

请注意,我们的Student::Validate()方法抛出一个异常,这只是一个包含指示问题的消息的字符串字面量。我们可以想象Student::TakePrerequisites()方法的完整实现验证了Student是否满足适当的先决条件,并根据情况返回truefalse的布尔值。

在我们的main()函数中,我们现在注意到一组嵌套的 try 块。这里的目的是说明一个内部 try 块可能调用一个方法,比如s1.Validate(),这可能会引发异常。注意到与内部 try 块相同级别的处理程序捕获了这个异常。理想情况下,异常应该在与其来源的 try 块相等的级别上处理,所以让我们假设这个范围内的捕获器试图这样做。例如,我们最内层的 catch 块可能试图纠正错误,并通过调用s1.TakePrerequisites()来测试是否已经进行了纠正。

但也许这个捕获器只能部分处理异常。也许有一个外层处理程序知道如何进行剩余的修正。在这种情况下,将这个异常重新抛出到外层(嵌套)级别是可以接受的。我们在最内层的 catch 块中的简单的throw;语句就是这样做的。注意外层有一个捕获器。如果抛出的异常与外层的类型匹配,现在外层就有机会进一步处理异常,并希望纠正问题,以便应用程序可以继续。只有当这个外部 catch 块无法纠正错误时,应用程序才应该退出。在我们的例子中,每个捕获器都打印表示错误消息的字符串;因此这条消息在输出中出现了两次。

让我们看看上述程序的输出:

Student does not meet prerequisites
Student does not meet prerequisites

现在我们已经看到了如何使用嵌套的 try 和 catch 块,让我们继续看看如何一起使用各种抛出类型和各种 catch 块。

添加各种处理程序

有时,内部范围可能会引发各种异常,从而需要为各种数据类型制定处理程序。异常处理程序(即 catch 块)可以接收任何数据类型的异常。我们可以通过使用基类类型的 catch 块来最小化引入的捕获器数量;我们知道派生类对象(通过公共继承相关)总是可以向上转换为它们的基类类型。我们还可以在 catch 块中使用省略号()来允许我们捕获以前未指定的任何东西。

让我们在我们的初始示例上建立,以说明各种处理程序的操作。虽然缩写,但我们完整的程序示例可以在我们的 GitHub 上找到:

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter11/Chp11-Ex4.cpp

// Assume Student class is as we've 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 how many are missing
        throw numCreditsMissing;  // assume this is an int
    // or if Student is missing a Course, construct and
    // then throw the missing Course as a referenceable object
    // Assume appropriate Course constructor exists
        throw *(new Course("Intro. To Programming", 1234)); 
    // or if another issue, throw a diagnostic message
        throw ("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 << "Needs to take: " << err.GetTitle() << endl;
        cout << "Course #: " << err.GetCourseNum() << endl;
        // If you correct the error, and continue the program, 
        // be sure to deallocate heap mem using: delete &err;
        exit(3);  // Otherwise, heap memory for err will be 
    }             // reclaimed upon exit()
    catch (const char *err)
    {
        cout << err << endl;
        exit(4); 
    }
    catch (...)
    {
        cout << "Exiting" << endl;
        exit(5);
    }
    cout << "Moving onward with remainder of code." << endl;
    return 0;
}

在上述代码段中,我们首先检查了Student::Graduate()成员函数。在这里,我们可以想象这个方法通过许多毕业要求,并且因此可能引发各种不同类型的异常。例如,如果Student实例的gpa太低,就会抛出一个浮点数作为异常,指示学生的gpa太低。如果Student的学分太少,就会抛出一个整数,指示学生还需要多少学分才能获得学位。

也许Student::Graduate()可能引发的最有趣的潜在错误是,如果学生的毕业要求中缺少了一个必需的Course。在这种情况下,Student::Graduate()将分配一个新的Course对象,通过构造函数填充Course的名称和编号。接下来,Course的指针被解引用,并且对象被引用抛出。处理程序随后可以通过引用捕获这个对象。

main()函数中,我们只是在 try 块中包装了对Student::Graduate()的调用,因为这个语句可能会引发异常。接着 try 块后面是一系列的 catch 块 - 每种可能被抛出的对象类型对应一个catch语句。在这个序列中的最后一个 catch 块使用省略号(),表示这个 catch 块将处理Student::Graduate()抛出的任何其他类型的异常,这些异常没有被其他 catch 块捕获到。

实际上被激活的 catch 块是使用const Course &err捕获Course的那个。有了const限定符,我们不能在处理程序中修改Course,所以我们只能对这个对象应用const成员函数。

请注意,尽管上面显示的每个 catch 块只是简单地打印出错误然后退出,但理想情况下,catch 块应该尝试纠正错误,这样应用程序就不需要终止,允许在 catch 块下面的代码继续执行。

让我们看看上述程序的输出:

Needs to take: Intro. to Programming
Course #: 1234

现在我们已经看到了各种抛出的类型和各种 catch 块,让我们继续向前了解在单个 try 块中应该将什么内容分组在一起。

在 try 块中分组相关的项目

重要的是要记住,当 try 块中的一行代码遇到异常时,try 块的其余部分将被忽略。相反,程序将继续执行匹配的 catch 块(或者如果没有合适的 catch 块存在,则调用terminate())。然后,如果错误被修复,catch 块之后的代码将开始执行。请注意,我们永远不会返回来完成初始 try 块的其余部分。这种行为的含义是,你应该只在 try 块中将一起的元素分组在一起。也就是说,如果一个项目引发异常,完成该分组中的其他项目就不再重要了。

请记住,catch 块的目标是尽可能纠正错误。这意味着在适用的 catch 块之后,程序可能会继续向前。你可能会问:现在跳过了与 try 块相关的项目是否可以接受?如果答案是否定的,那么请重写你的代码。例如,你可能想在try-catch分组周围添加一个循环,这样如果 catch 块纠正了错误,整个企业就会重新开始,从初始的 try 块开始重试。

或者,将较小的、连续的try-catch分组。也就是说,try只在自己的 try 块中尝试一个重要的任务(后面跟着适用的 catch 块)。然后在自己的 try 块中尝试下一个任务,后面跟着适用的 catch 块,依此类推。

接下来,让我们看一种在函数原型中包含它可能抛出的异常类型的方法。

检查函数原型中的异常规范

我们可以通过扩展函数的签名来可选地指定 C++函数可能抛出的异常类型,包括可能被抛出的对象类型。然而,因为一个函数可能抛出多种类型的异常(或者根本不抛出异常),所以必须在运行时检查实际抛出的类型。因此,函数原型中的这些增强规范也被称为动态异常规范

让我们看一个在函数的扩展签名中使用异常类型的例子:

void Student::Graduate() throw(float, int, Course &, char *)
{
   // this method might throw any of the above mentioned types
}
void Student::Enroll() throw()
{
   // this method might throw any type of exception
}

在上述代码片段中,我们看到了Student的两个成员函数。Student::Graduate()在其参数列表后包含throw关键字,然后作为该方法的扩展签名的一部分,包含了可能从该函数中抛出的对象类型。请注意,Student::Enroll()方法在其扩展签名中仅在throw()后面有一个空列表。这意味着Student::Enroll()可能抛出任何类型的异常。

在这两种情况下,通过在签名中添加throw()关键字和可选的数据类型,我们提供了一种向该函数的用户宣布可能被抛出的对象类型的方法。然后我们要求程序员在 try 块中包含对该方法的任何调用,然后跟上适当的 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()将被调用。您可以像我们在本章前面注册set_terminate()时那样,注册自己的函数到unexpected()

您可以允许您的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 throw()。这个签名表明派生类应该重新定义what(),返回一个描述手头错误的const char *what()后面的const关键字表示这是一个const成员函数;它不会改变派生类的任何成员。扩展签名中的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/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter11/Chp11-Ex5.cpp

#include <iostream>
#include <exception>
using namespace std;
class StudentException: public exception
{
private:
    int errCode;  
    char *details;
public:           
    StudentException(const char *det, int num): errCode(num)
    {
        details = new char[strlen(det) + 1];
        strcpy(details, det);
    }   
    virtual ~StudentException() { delete details; }
    virtual const char *what() const throw()
    {   // overridden function from exception class
        return "Student Exception";
    } 
    int GetCode() const { return errCode; }
    const char *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, instantiate a StudentException,
   // pack it with relevant data during construction, and then
   // throw the dereferenced pointer as a referenceable object
   throw *(new 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() << endl;
        // Grab useful info from e and try to fix the problem
        // so that the program can continue.
        // If we fix the problem, deallocate heap memory for
        // thrown exception (take addr. of a ref): delete &e; 
        // Otherwise, memory will be reclaimed upon exit()
        exit(1);  // only exit if necessary!
    }
    return 0;
}

让我们花几分钟来检查前面的代码段。首先,注意我们定义了自己的异常类,StudentException。它是从 C++标准库exception类派生的类。

StudentException类包含数据成员来保存错误代码以及使用数据成员errCodedetails描述错误条件的字母数字细节。我们有两个简单的访问函数,StudentException::GetCode()StudentException::GetDetails(),可以轻松地检索这些值。由于这些方法不修改对象,它们是const成员函数。

我们注意到StudentException构造函数通过成员初始化列表初始化了两个数据成员,一个在构造函数的主体中初始化。我们还重写了exception类引入的virtual const char *what() const throw()方法。请注意,exception::what()方法在其扩展签名中使用了不推荐的throw()规范,这也是你必须在你的重写方法中做的事情。

接下来,让我们检查一下我们的Student::Graduate()方法。这个方法可能会抛出一个StudentException。如果必须抛出异常,我们使用new()分配一个异常,用诊断数据构造它,然后从这个函数中throw解引用指针(这样我们抛出的是一个可引用的对象,而不是一个对象的指针)。请注意,在这个方法中抛出的对象没有本地标识符 - 没有必要,因为任何这样的本地变量名很快就会在throw发生后从堆栈中弹出。

在我们的main()函数中,我们将对s1.Graduate()的调用包装在一个 try 块中,后面是一个接受StudentException的引用(&)的 catch 块,我们将其视为const。在这里,我们首先调用我们重写的what()方法,然后从异常e中打印出诊断细节。理想情况下,我们将使用这些信息来尝试纠正手头的错误,只有在真正必要时才退出应用程序。

让我们看一下上述程序的输出:

Student Exception
4 Missing Credits

尽管创建自定义异常类的最常见方式是从标准的exception类派生一个类,但也可以利用不同的技术,即嵌套异常类。

创建嵌套异常类

作为另一种实现,异常处理可以通过在特定外部类的公共访问区域添加嵌套类定义来嵌入到一个类中。内部类将代表自定义异常类。

嵌套的、用户定义的类型的对象可以被创建并抛出给预期这种类型的 catcher。这些嵌套类内置在外部类的公共访问区域,使它们很容易为派生类的使用和特化而使用。一般来说,内置到外部类中的异常类必须是公共的,以便可以在外部类的范围之外(即在主要的外部实例存在的范围内)捕获和处理抛出的嵌套类型的实例。

让我们通过检查代码的关键部分来看一下异常类的另一种实现,完整的程序可以在我们的 GitHub 上找到:

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/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;
    public:
        StudentException(int num): number(num) { }
        ~StudentException() { }
        int GetNum() const { return number; }
    };
};
void Student::Graduate()
{   // assume we determine an error and wish to throw
    // the nested exception type
    throw *(new StudentException(5));
}
int main()
{
    Student s1("Ling", "Mau", 'I', "Ms.", 3.1, "C++", "55UD");
    try
    {
        s1.Graduate();
    }
    catch (const Student::StudentException &err)
    {
        cout << "Error: " << err.GetNum() << endl;
        // If you correct err and continue with program, be
        // sure to delete heap mem for err: delete &err;
        exit(1);  // Otherwise, heap memory for err will be 
    }             // reclaimed upon exit()
    cout << "Moving onward with remainder of code." << 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)是有用的。我们可能还希望创建一个特定于应用程序的异常的层次结构。让我们继续看看如何做到这一点。

创建用户定义异常类型的层次结构

一个应用程序可能希望定义一系列支持异常处理的类,以引发特定错误,并希望提供一种收集错误诊断信息的方法,以便在代码的适当部分处理错误。

您可能希望创建一个从标准库exception派生的子层次结构,属于您自己的异常类。确保使用公共继承。在使用这些类时,您将实例化所需异常类型的对象(填充有有价值的诊断信息),然后抛出该对象。请记住,您希望新分配的对象存在于堆上,以便在函数返回时不会从堆栈中弹出(因此使用new进行分配)。在抛出之前简单地对这个对象进行解引用,以便它可以被捕获为对该对象的引用,这是标准做法。

此外,如果您创建异常类型的层次结构,您的 catcher 可以捕获特定的派生类类型或更一般的基类类型。选择权在您手中,取决于您计划如何处理异常。但请记住,如果您对基类和派生类类型都有 catcher,请将派生类类型放在前面 - 否则,您抛出的对象将首先匹配到基类类型的 catcher,而不会意识到更合适的派生类匹配是可用的。

我们现在已经看到了 C++标准库异常类的层次结构,以及如何创建和利用自己的异常类。让我们在继续前进到下一章之前,简要回顾一下本章中我们学到的异常特性。

总结

在本章中,我们已经开始将我们的 C++编程技能扩展到 OOP 语言特性之外,以包括能够编写更健壮程序的特性。用户代码不可避免地具有错误倾向;使用语言支持的异常处理可以帮助我们实现更少错误和更可靠的代码。

我们已经看到如何使用trythrowcatch来利用核心异常处理特性。我们已经看到了这些关键字的各种用法 - 将异常抛出到外部处理程序,使用各种类型的处理程序,以及在单个 try 块内有选择地将程序元素分组在一起,例如。我们已经看到如何使用set_terminate()set_unexpected()注册我们自己的函数。我们已经看到了如何利用现有的 C++标准库exception层次结构。我们还探讨了定义我们自己的异常类以扩展此层次结构。

通过探索异常处理机制,我们已经为我们的 C技能增加了关键特性。现在我们准备继续前进到第十二章友元和运算符重载,以便我们可以继续扩展我们的 C编程技能,使用有用的语言特性,使我们成为更好的程序员。让我们继续前进!

问题

  1. 将异常处理添加到您之前的Student / University练习中第十章实现关联、聚合和组合
  1. 如果一个学生尝试注册超过每个学生允许的最大定义课程数量,抛出TooFullSchedule异常。这个类可以从标准库exception类派生。

  2. 如果一个学生尝试注册一个已经满员的课程,让Course::AddStudent(Student *)方法抛出一个CourseFull异常。这个类可以从标准库exception类派生。

  3. 学生/大学申请中还有许多其他领域可以利用异常处理。决定哪些领域应该采用简单的错误检查,哪些值得异常处理。

第十二章:友元和运算符重载

本章将继续扩展你的 C编程技能,超越 OOP 概念,目标是编写更具可扩展性的代码。接下来,我们将探索友元函数友元类运算符重载在 C中的应用。我们将了解运算符重载如何将运算符扩展到与用户定义类型一致的行为,以及为什么这是一个强大的 OOP 工具。我们将学习如何安全地使用友元函数和类来实现这一目标。

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

  • 理解友元函数和友元类,适当使用它们的原因,以及增加安全性的措施

  • 学习运算符重载的基本要点——如何以及为何重载运算符,并确保运算符在标准类型和用户定义类型之间是多态的

  • 实现运算符函数;了解何时需要友元

在本章结束时,您将掌握友元的正确使用,并了解它们在利用 C++重载运算符的能力方面的实用性。尽管可以利用友元函数和类的使用,但您将只学习它们在两个紧密耦合的类中的受限使用。您将了解如何正确使用友元可以增强运算符重载,使运算符能够扩展以支持用户定义类型,以便它们可以与其操作数关联工作。

让我们通过探索友元函数、友元类和运算符重载来扩展你的 C编程技能,增进对 C的理解。

技术要求

完整程序示例的在线代码可以在以下 GitHub 网址找到:github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter12。每个完整程序示例都可以在 GitHub 存储库中的适当章节标题(子目录)下找到,文件名与所在章节编号相对应,后跟破折号,再跟所在章节中的示例编号。例如,本章的第一个完整程序可以在名为Chp12-Ex1.cpp的文件中的Chapter12子目录下找到。

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

理解友元类和友元函数

封装是 C++通过类和访问区域的正确使用提供的宝贵的 OOP 特性。封装提供了数据和行为被操作的统一方式。总的来说,放弃类提供的封装保护是不明智的。

然而,在某些编程情况下,略微破坏封装性被认为比提供一个过度公开的类接口更可接受,也就是说,当一个类需要为两个类提供合作的方法时,但总的来说,这些方法不适合公开访问时。

让我们考虑一个可能导致我们稍微放弃(即破坏)封装的情景:

  • 可能存在两个紧密耦合的类,它们在其他方面没有关联。一个类可能与另一个类有一个或多个关联,并且需要操作另一个类的成员。然而,为了允许访问这些成员的公共接口会使这些内部过度公开,并且容易受到远远超出这对紧密耦合类的需求的操纵。

  • 在这种情况下,允许紧密耦合的一对类中的一个类访问另一个类的成员比在另一个类中提供一个公共接口更好,这个公共接口允许对这些成员进行更多操作,而这通常是不安全的。我们将看到,如何最小化这种潜在的封装损失。

  • 我们很快将看到,选定的运算符重载情况可能需要一个实例在其类作用域之外的函数中访问其成员。再次强调,一个完全可访问的公共接口可能被认为是危险的。

友元函数友元类允许这种有选择性地打破封装。打破封装是严肃的,不应该简单地用来覆盖访问区域。相反,当在两个紧密耦合的类之间轻微打破封装或提供一个过度公开的接口时,可以使用友元函数和友元类,同时加入安全措施,这样做可能会从应用程序的各个作用域中获得更大且可能不受欢迎的对另一个类成员的访问。

让我们看一下如何使用每个,然后我们将添加我们应该坚持使用的相关安全措施。让我们从友元函数和友元类开始。

使用友元函数和友元类

友元函数是被单独授予扩展作用域的函数,以包括它们所关联的类。让我们来看一下其含义和具体情况:

  • 在友元函数的作用域中,关联类型的实例可以访问自己的成员,就好像它在自己的类作用域中一样。

  • 友元函数需要在放弃访问权限的类的类定义中作为友元进行原型声明(即扩展其作用域)。

  • 关键字friend用于提供访问权限的原型前面。

  • 重载友元函数的函数不被视为友元。

友元类是指该类的每个成员函数都是关联类的友元函数。让我们来看一下具体情况:

  • 友元类应该在提供访问权限的类的类定义中进行前向声明(即作用域)。

  • 关键字friend应该在获得访问权限的类的前向声明之前。

注意

友元类和友元函数应该谨慎使用,只有在有选择地和轻微地打破封装比提供一个过度公开的接口更好的选择时才使用(即一个普遍提供对应用程序中任何作用域中的选定成员的不受欢迎访问的公共接口)。

让我们首先来看一下友元类和友元函数声明的语法。以下类并不代表完整的类定义;然而,完整的程序可以在我们的在线 GitHub 存储库中找到,链接如下:

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter12/Chp12-Ex1.cpp

class Student;  // forward declaration of Student class
class Id   
{
private:
    char *idNumber;
    Student *student;
public:  // Assume constructors, destructor, etc. exist
    void SetStudent(Student *);
    // all member functions of Student are friend fns to/of Id
    friend class Student;
};
class Student
{
private:
    char *name;
    float gpa;
    Id *studentId;
public:   // Assume constructors, destructor, etc. exist
    // only the following mbr function 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 *);的含义是,如果一个Student发现自己在Id::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中的友元函数语句表示:如果Student实例(通过输入参数)在Id的特定方法中被找到,它可以完全访问其元素,就好像它在自己类的成员函数中一样。以友谊作为扩大范围的手段来思考。

现在我们已经了解了友元函数和友元类的基本机制,让我们使用一个简单的约定来使其更具吸引力,以有选择地打破封装。

在使用友元时使访问更安全

我们已经看到,通过关联相关的两个紧密耦合的类可能需要通过使用友元函数友元类来有选择地扩展它们的范围。另一种选择是为每个类提供公共接口。然而,请考虑您可能不希望这些元素的公共接口在应用程序的任何范围内都是统一可访问的。您确实面临着一个艰难的选择:使用友元或提供一个过度公共的接口。

虽然最初使用友元可能会让您感到不安,但这可能比提供不需要的公共接口给类元素更安全。

为了减少对友元允许的选择性打破封装的恐慌,考虑在使用友元时添加以下约定:

  • 在使用友元时,为了减少封装的损失,一个类可以为另一个类的数据成员提供私有访问方法。尽可能将这些方法设置为内联,以提高效率。

  • 问题实例应同意只使用创建的私有访问方法来适当地访问其所需的成员,而在友元函数的范围内(即使它实际上可以在友元函数的范围内无限制地访问自己类型的任何数据或方法)。

这里有一个简单的例子来说明两个紧密耦合的类如何适当地使用main()函数,为了节省空间,省略了几个方法,完整的例子可以在我们的 GitHub 存储库中找到:

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter12/Chp12-Ex2.cpp

typedef int Item;  
class LinkList;  // forward declaration
class LinkListElement
{
private:
   void *data;
   LinkListElement *next;
   // private access methods to be used in scope of friend 
   void *GetData() { return data; } 
   LinkListElement *GetNext() { return next; }
   void SetNext(LinkListElement *e) { next = e; }
public:
// All mbr fns of LinkList are friend fns of LinkListElement 
   friend class LinkList;   
   LinkListElement() { data = 0; next = 0; }
   LinkListElement(Item *i) { data = i; next = 0; }
   ~LinkListElement(){ delete (Item *)data; next = 0;}
};
// LinkList should only be extended as a protected or 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, *tail, *current;
public:
   LinkList() { head = tail = current = 0; }
   LinkList(LinkListElement *e) { head = tail = current = e; }
   void InsertAtFront(Item *);
   LinkListElement *RemoveAtFront();  
   void DeleteAtFront()  { delete RemoveAtFront(); }
   int IsEmpty() { return head == 0; } 
   void Print();    // see online definition
   ~LinkList() { while (!IsEmpty()) DeleteAtFront(); }
};

让我们来看看LinkListElementLinkList的前面的类定义。请注意,在LinkListElement类中,我们有三个私有成员函数,即void *GetData();LinkListElement *GetNext();void SetNext(LinkListElement *);。这三个成员函数不应该是公共类接口的一部分。这些方法只适合在LinkList的范围内使用,这是与LinkListElement紧密耦合的类。

接下来,请注意LinkListElement类中的friend class LinkList;前向声明。这个声明意味着LinkList的所有成员函数都是LinkListElement的友元函数。因此,任何发现自己在LinkList方法中的LinkListElement实例都可以访问自己前面提到的私有GetData()GetNext()SetNext()方法,因为它们将在友元类的范围内。

接下来,让我们看看前面代码中的LinkList类。类定义本身没有与友好相关的唯一声明。毕竟,是LinkListElement类扩大了其范围以包括LinkedList类的方法,而不是相反。

现在,让我们来看一下LinkList类的两个选定的成员函数。这些方法的完整组合可以在网上找到,就像之前提到的 URL 中一样。

void LinkList::InsertAtFront(Item *theItem)
{
   LinkListElement *temp = 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.
   temp->SetNext(head);  // same as: temp->next = head;
   head = temp;
}
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,它也应该意味着打印到标准输出。这样,运算符<<在输出缓冲区的上下文中是多态的;也就是说,对于所有类型,它具有相同的含义,但不同的实现。

重载 C++中的运算符时,重要的是要注意,我们不能改变语言中运算符的预定义优先级。这是有道理的 - 我们不是在重写编译器以解析和解释表达式。我们只是将运算符的含义从其与标准类型的使用扩展到包括与用户定义类型的使用。运算符优先级将保持不变。

运算符,后跟表示您希望重载的运算符的符号。

让我们来看看运算符函数原型的简单语法:

Student &operator+(float gpa, const Student &s);

在这里,我们打算提供一种方法,使用 C++加法运算符(+)来添加一个浮点数和一个Student实例。这种加法的含义可能是将新的浮点数与学生现有的平均成绩进行平均。在这里,运算符函数的名称是operator+()

在上述原型中,运算符函数不是任何类的成员函数。左操作数将是float,右操作数将是Student。函数的返回类型(Student&)允许我们将+与多个操作数级联使用,或者与多个运算符配对使用,例如s1 = 3.45 + s2;。总体概念是我们可以定义如何使用+与多种类型,只要至少有一个操作数是用户定义的类型。

实际上,比上面显示的简单语法涉及的内容要多得多。在我们完全检查详细示例之前,让我们首先看一下与实现运算符函数相关的更多后勤事项。

实现运算符函数并知道何时可能需要友元

运算符函数,重载运算符的机制,可以作为成员函数或常规外部函数实现。让我们总结实现运算符函数的机制,以下是关键点:

  • 作为成员函数实现的运算符函数将接收一个隐式参数(this指针),最多一个显式参数。如果重载操作中的左操作数是可以轻松修改类的用户定义类型,则将运算符函数实现为成员函数是合理且首选的。

  • 作为外部函数实现的运算符函数将接收一个或两个显式参数。如果重载操作中的左操作数是不可修改的标准类型或类类型,则必须使用外部(非成员)函数来重载此运算符。这个外部函数可能需要是用作右操作数的任何对象类型的“友元”。

  • 运算符函数通常应该被互相实现。也就是说,当重载二元运算符时,确保它已经被定义为可以工作,无论数据类型(如果它们不同)以何种顺序出现在运算符中。

让我们看一个完整的程序示例,以说明运算符重载的机制,包括成员和非成员函数,以及需要使用友元的情况。尽管为了节省空间,程序的一些众所周知的部分已被排除在外,但完整的程序示例可以在我们的 GitHub 存储库中找到:

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter12/Chp12-Ex3.cpp

// Assume usual header files and std namespace
class Person
{
private: 
    char *firstName, *lastname, *title;
    char middleInitial;
protected:
    void ModifyTitle(const char *);  
public:
    Person();   // default constructor
    Person(const char *, const char *, char, const char *);  
    Person(const Person &);  // copy constructor
    virtual ~Person();  // destructor
    const char *GetFirstName() const { return firstName; }  
    const char *GetLastName() const { return lastName; }    
    const char *GetTitle() const { return title; } 
    char GetMiddleInitial() const { return middleInitial; }
    virtual void Print() const;
    // overloaded operator functions
    Person &operator=(const Person &);  // overloaded assign
    bool operator==(const Person &); // overloaded comparison
    Person &operator+(const char *); // overloaded plus
    // non-mbr friend fn. for operator+ (to make associative)
    friend Person &operator+(const char *, 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 definition exist.
// overloaded assignment operator
Person &Person::operator=(const Person &p)
{
    if (this != &p)  // make sure we're not assigning an 
    {                // object to itself
        delete firstName;  // or call ~Person() to release
        delete lastName;   // this memory (unconventional)
        delete title; 
        firstName = new char [strlen(p.firstName) + 1];
        strcpy(firstName, p.firstName);
        lastName = new char [strlen(p.lastName) + 1];
        strcpy(lastName, p.lastName);
        middleInitial = p.middleInitial;
        title = new char [strlen(p.title) + 1];
        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,不需要内存分配;我们仅使用赋值。在这段代码中,我们确保已执行了深度赋值。浅赋值,其中源对象和目标对象否则会共享数据成员的内存部分的指针,将是一场等待发生的灾难。

最后,在我们对operator=()的实现结束时,我们返回*this。请注意,此函数的返回类型是Person的引用。由于this是一个指针,我们只需对其进行解引用,以便返回一个可引用的对象。这样做是为了使Person实例之间的赋值可以级联;也就是说,p1 = p2 = p3;其中p1p2p3分别是Person的实例。

注意

重载的赋值运算符不会被派生类继承,因此必须由层次结构中的每个类定义。如果忽略为类重载operator=,编译器将为该类提供默认的浅赋值运算符;这对于包含指针数据成员的任何类都是危险的。

如果程序员希望禁止两个对象之间的赋值,可以在重载的赋值操作符的原型中使用关键字delete

    // disallow assignment
    Person &operator=(const Person &) = delete;

有必要记住,重载的赋值操作符与复制构造函数有许多相似之处;对这两种语言特性都需要同样的小心和谨慎。然而,赋值操作符将在两个已存在对象之间进行赋值时被调用,而复制构造函数在创建新实例后隐式被调用进行初始化。对于复制构造函数,新实例使用现有实例作为其初始化的基础;同样,赋值操作符的左操作数使用右操作数作为其赋值的基础。

重载比较操作符

接下来,让我们看看我们对重载比较操作符的实现:

// 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 1;
    else if ( (!strcmp(firstName, p.firstName)) &&
              (!strcmp(lastName, p.lastName)) &&
              (!strcmp(title, p.title)) &&
              (middleInitial == p.middleInitial) )
        return 1;
    else
        return 0;
}

继续我们之前程序的一部分,我们重载比较操作符。它由成员函数int Person::operator==(const Person &p);指定。在这里,我们将比较右操作数上的Person对象,它将由输入参数p引用,与左操作数上的Person对象进行比较,它将由this指向。

同样,我们的首要任务是测试if (this != &p),看看两个地址是否指向同一个对象。如果两个地址指向同一个对象,我们返回true的布尔值。

接下来,我们检查两个Person对象是否包含相同的值。它们可能是内存中的不同对象,但如果它们包含相同的值,我们同样可以选择返回truebool值。如果没有匹配,我们返回falsebool值。

作为成员函数重载加法操作符

现在,让我们看看如何为Personconst char *重载operator+

// overloaded operator + (member function)
Person &Person::operator+(const char *t)
{
    ModifyTitle(t);
    return *this;
}

继续前面的程序,我们重载加法操作符(+),用于Personconst char *。操作符函数由成员函数原型Person& Person::operator+(const char *t);指定。参数t代表operator+的右操作数,即一个字符串。左操作数将由this指向。一个例子是p1 + "Miss",我们希望使用operator+Person p1添加一个称号。

在这个成员函数的主体中,我们仅仅将输入参数t作为ModifyTitle()的参数使用,即ModifyTitle(t);。然后我们返回*this,以便我们可以级联使用这个操作符(注意返回类型是Person &)。

作为非成员函数重载加法操作符(使用友元)

现在,让我们颠倒operator+的操作数顺序,允许const char *Person

// overloaded + operator (not a mbr function) 
Person &operator+(const char *t, Person &p)
{
    p.ModifyTitle(t);
    return p;
}

继续前面的程序,我们理想地希望operator+不仅适用于Personconst char *,还适用于操作数的顺序颠倒;也就是说,const char *Person。没有理由这个操作符只能单向工作。

为了完全实现operator+,接下来我们将重载operator+(),用于const char *Person。操作符函数由非成员函数Person& operator+(const char *t, Person &p);指定,有两个显式输入参数。第一个参数t代表operator+的左操作数,即一个字符串。第二个参数p是用于operator+的右操作数的引用。一个例子是"Miss" + p1,我们希望使用operator+Person p1添加一个称号。

在这个非成员函数的主体中,我们只是取输入参数p,并使用参数t指定的字符串应用受保护的方法ModifyTitle()。也就是说,p.ModifyTitle(t)。然而,因为Person::ModifyTitle()是受保护的,Person &p不能在Person的成员函数之外调用这个方法。我们在一个外部函数中;我们不在Person的范围内。因此,除非这个成员函数是Personfriend,否则p不能调用ModifyTitle()。幸运的是,在Person类中已经将Person &operator+(const char *, Person &);原型化为friend函数,为p提供了必要的范围,使其能够调用它的受保护方法。就好像pPerson的范围内一样;它在Personfriend函数的范围内!

最后,让我们继续前进到我们的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);。从这里,我们可以清楚地看到,我们正在调用之前定义的Personoperator=()方法,它从源对象p2深度复制到目标对象p1。我们应用p1.Print();来查看我们的复制结果。

接下来,我们使用重载的operator+来处理"Ms." + p2。这行代码的一部分转换成以下的操作函数调用:operator+("Ms.", p2);。在这里,我们简单地调用了之前描述的operator+()函数,这是一个Person类的非成员函数和friend。因为这个函数返回一个Person &,我们可以将这个函数调用级联,看起来更像是通常的加法上下文,并且额外地写成p2 = "Ms." + p2;。在这行完整的代码中,首先对"Ms." + p2调用了operator+()。这个调用的返回值是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. 在[第八章](B15702_08_Final_NM_ePub.xhtml#_idTextAnchor335)的Shape练习中重载operator=掌握抽象类,或者在你正在进行的LifeForm/Person/Student类中重载operator=如下:
  1. Shape(或LifeForm)中定义operator=,并在其所有派生类中重写这个方法。提示:operator=()的派生实现将比其祖先做更多的工作,但可以调用其祖先的实现来执行基类部分的工作。
  1. 在你的Shape类(或LifeForm类)中重载operator<<,以打印关于每个Shape(或LifeForm)的信息。这个函数的参数应该是ostream &Shape &(或LifeForm &)。注意,ostream来自 C++标准库(using namespace std;)。
  1. 你可以提供一个函数ostream &operator<<(ostream &, Shape &);,并从中调用多态的Print(),它在Shape中定义,并在每个派生类中重新定义),或者提供多个operator<<方法来实现这个功能(每个派生类一个)。如果使用Lifeform层次结构,将Shape替换为LifeForm
  1. 创建一个ArrayInt类,提供带边界检查的安全整数数组。重载operator[],如果数组中存在元素,则返回该元素,否则抛出异常OutOfBounds。在你的ArrayInt中添加其他方法,比如Resize()RemoveElement()。使用动态分配数组(即使用int *contents)来模拟数组的数据,这样你就可以轻松处理调整大小。代码将以以下方式开始:
class ArrayInt
{
private:
    int numElements;
    int *contents;   // dynamically allocated array
public:
    ArrayInt(int size);// set numElements, alloc contents
    int &operator[](int index) // returns a referenceable
    {                          // memory location 
        if (index < numElements) return contents[index];
        else cout << "error"; // or throw OutOfBounds
    }                         // exception
};
int main()
{
    ArrayInt a1(5); // Create an ArrayInt of 5 elements
    A1[4] = 7;      // a1.operator[](4) = 7;
}

第十三章:使用模板

本章将继续追求扩展您的 C编程技能,超越面向对象编程概念,继续编写更具可扩展性的代码。我们将探索使用 C模板创建通用代码 - 包括模板函数模板类。我们将学习如何编写正确的模板代码,以实现代码重用的最高境界。我们将探讨如何创建模板函数和模板类,以及理解适当使用运算符重载如何使模板函数可重用于几乎任何类型的数据。

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

  • 探索模板基础知识以通用化代码

  • 理解如何创建和使用模板函数和模板类

  • 理解运算符重载如何使模板更具可扩展性

通过本章结束时,您将能够通过构建模板函数和模板类来设计更通用的代码。您将了解运算符重载如何确保模板函数对任何数据类型都具有高度可扩展性。通过将精心设计的模板成员函数与运算符重载配对使用,您将能够在 C++中创建高度可重用和可扩展的模板类。

让我们通过探索模板来扩展您的编程技能,从而增进对 C++的理解。

技术要求

完整程序示例的在线代码可在以下 GitHub URL 找到:github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter13。每个完整程序示例都可以在 GitHub 存储库中找到,位于相应章节标题(子目录)下,文件名与所在章节编号对应,后跟破折号,再跟上所在章节中示例编号。例如,本章的第一个完整程序可以在Chapter13子目录中的名为Chp13-Ex1.cpp的文件中找到,位于上述 GitHub 目录下。

本章的 CiA 视频可在以下链接观看:bit.ly/2OUaLrb

探索模板基础知识以通用化代码

模板允许以一种抽象的方式对代码进行通用指定,这种方式与主要用于相关函数或类中的数据类型无关。创建模板的动机是为了通用指定我们反复想要使用的函数和类的定义,但使用不同的数据类型。这些组件的个性化版本在核心数据类型上会有所不同;这些关键数据类型可以被提取并以通用方式编写。

当我们选择使用特定类型的类或函数时,而不是复制和粘贴现有代码(带有预设数据类型)并稍作修改,预处理器会取代模板代码并为我们请求的类型进行扩展。这种模板扩展能力使程序员只需编写和维护通用化代码的一个版本,而不是需要编写许多特定类型版本的代码。另一个好处是,预处理器将更准确地将模板代码扩展为请求的类型,而不是我们可能使用复制、粘贴和轻微修改方法所做的扩展。

让我们花点时间进一步探讨在我们的代码中使用模板的动机。

审视使用模板的动机

假设我们希望创建一个类来安全地处理动态分配的int数据类型的数组,就像我们在第十二章问题 3解决方案中创建的那样,运算符重载和友元。我们的动机可能是要有一个数组类型,可以增长或缩小到任何大小(不像本地的固定大小数组),但对于安全使用有边界检查(不像使用int *实现的动态数组的原始操作,它会肆意地允许我们访问远远超出我们动态数组分配长度的元素)。

我们可能决定创建一个以下开始框架的ArrayInt类:

class ArrayInt
{
private:
    int numElements;
    int *contents;   // 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 
        if (index < numElements) return contents[index];
        else cout << "Out of Bounds"; // or better – throw an
    }                                 // OutOfBounds exception
};
int main()
{
    ArrayInt a1(5); // Create an ArrayInt of 5 elements
    a1[4] = 7;      // a1.operator[](4) = 7;
}   

在前面的代码段中,请注意我们的ArrayInt类使用int *contents;来模拟数组的数据,它在构造函数中动态分配到所需的大小。我们已经重载了operator[],以安全地返回数组中范围内的索引值。我们可以添加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/Demystified-Object-Oriented-Programming-with-CPP/raw/master/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 (Type2) x;
    else return y; 
}   
int main()
{
    int value1 = 4, value2 = 7;
    float value3 = 5.67f;
    cout << "First: " << ChooseFirst(value1, value3) << endl;
    cout << "First: " << ChooseFirst(value2, value1) << endl;
}

看一下前面的函数示例,我们首先看到一个模板函数原型。前言template <class Type1, class Type 2>表示原型将是一个模板原型,并且占位符Type1Type2将被用来代替实际数据类型。占位符Type1Type2可以是(几乎)任何名称,遵循创建标识符的规则。

然后,为了完成原型,我们看到Type2 ChooseFirst(Type1, Type2);,这表明这个函数的返回类型将是Type2ChooseFirst()函数的参数将是Type1Type2(它们肯定可以扩展为相同的类型)。

接下来,我们看到函数定义。它也以template <class Type1, class Type 2>开头。与原型类似,函数头Type2 ChooseFirst(Type1 x, Type2 y)表示形式参数xy分别是类型Type1Type2。这个函数的主体非常简单。我们只需使用<运算符进行简单比较,确定这两个参数中哪一个应该在这两个值的排序中排在第一位。

现在,在main()中,当编译器的预处理部分看到对ChooseFirst()的调用,实际参数为int value1float value3时,预处理器注意到ChooseFirst()是一个模板函数。如果还没有这样的ChooseFirst()版本来处理intfloat,预处理器将复制这个模板函数,并用int替换Type1,用float替换Type2 - 为我们创建适合我们需求的函数的适当版本。请注意,当调用ChooseFirst(value2, value1)并且类型都是整数时,当预处理器再次扩展(在代码底层)模板函数时,占位符类型Type1Type2将都被int替换。

虽然ChooseFirst()是一个简单的函数,但通过它,我们可以看到创建通用关键数据类型的模板函数的简单机制。我们还可以看到预处理器注意到模板函数的使用方式,并代表我们扩展这个函数,根据我们特定的类型使用需求。

让我们来看一下这个程序的输出:

First: 4
First: 4

现在我们已经看到了模板函数的基本机制,让我们继续了解如何将这个过程扩展到包括模板类。

创建和使用模板类

模板类参数化类定义的最终类型,并且还需要模板成员函数来处理需要知道被操作的核心数据类型的任何方法。

关键字templateclass,以及尖括号<``>type名称的占位符,用于指定模板类定义。

让我们来看一个模板类定义及其支持的模板成员函数。这个例子可以在我们的 GitHub 存储库中找到,作为一个完整的程序。

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter13/Chp13-Ex2.cpp

template <class Type>   // template class preamble
class Array
{
private:
    int numElements;
    Type *contents;   // dynamically allocated array
public:
    Array(int size) : numElements(size)
    { 
        contents = new Type [size];
    }
    ~Array() { delete contents; }  
    void Print() const;     
    Type &operator[](int index) // returns a referenceable
    {                          // memory location 
        if (index < numElements) return contents[index];
        else cout << "Out of Bounds"; // or better – throw an
    }                                 // OutOfBounds exception
    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
    Array<int> a1(3); // Create an int Array of 3 int elements
    a1[2] = 12;      
    a1[1] = 70;       // a1.operator[](1) = 70;
    a1[0] = 2;
    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>。在幕后,当我们创建特定数组类型的实例时,预处理器会注意到我们是否先前为该type扩展了这个类。如果没有,类定义和适用的模板成员函数将被复制,占位符类型将被替换为我们需要的类型。这并不比我们自己复制、粘贴和稍微修改代码少一行;然而,重点是我们只需要指定和维护一个版本。这样做更不容易出错,更容易进行长期维护。

让我们来看一下这个程序的输出:

2 70 12

接下来让我们看一个不同的完整程序例子,来整合模板函数和模板类。

检查一个完整的程序例子

有必要看一个额外的例子,说明模板函数和模板类。让我们扩展我们最近在第十二章中审查的LinkList程序,运算符重载和友元;我们将升级这个程序以利用模板。

这个完整的程序可以在我们的 GitHub 存储库中找到。

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter13/Chp13-Ex3.cpp

#include <iostream>
using namespace std;
template <class Type> class LinkList;  // forward declaration
                                     // with template preamble
template <class Type>   // template preamble for class def
class LinkListElement
{
private:
    Type *data;
    LinkListElement *next;
    // private access methods to be used in scope of friend
    Type *GetData() { return data; } 
    LinkListElement *GetNext() { return next; }
    void SetNext(LinkListElement *e) { next = e; }
public:
    friend class LinkList<Type>;   
    LinkListElement() { data = 0; next = 0; }
    LinkListElement(Type *i) { data = i; next = 0; }
    ~LinkListElement(){ delete data; next = 0;}
};
// LinkList should only be extended as a protected or 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, *tail, *current;
public:
    LinkList() { head = tail = current = 0; }
    LinkList(LinkListElement<Type> *e) 
        { head = tail = current = e; }
    void InsertAtFront(Type *);
    LinkListElement<Type> *RemoveAtFront();  
    void DeleteAtFront()  { delete RemoveAtFront(); }
    int IsEmpty() { return head == 0; } 
    void Print();    
    ~LinkList(){ while (!IsEmpty()) DeleteAtFront(); }
};

让我们来检查LinkListElementLinkList的前面的模板类定义。最初,我们注意到LinkList类的前向声明包含了必要的template class <Type>的模板前言。我们还应该注意到每个类定义本身都包含相同的模板前言,以双重指定该类将是一个模板类,并且数据类型的占位符将是标识符Type

LinkListElement类中,注意到数据类型将是Type(占位符类型)。另外,注意到类型的占位符在LinkList的友元类规范中是必要的,即friend class LinkList<Type>;

LinkList类中,注意到任何与LinkListElement的关联类的引用都将包括<Type>的类型占位符。例如,在LinkListElement<Type> *head;的数据成员声明中或者RemoveAtFront()的返回类型中,都使用了占位符。此外,注意到内联函数定义不需要在每个方法之前加上模板前言;我们仍然受到类定义本身之前的前言的覆盖。

现在,让我们继续来看看LinkList类的三个非内联成员函数:

template <class Type>     // template preamble
void LinkList<Type>::InsertAtFront(Type *theItem)
{
    LinkListElement<Type> *temp;
    temp = new LinkListElement<Type>(theItem);
    temp->SetNext(head);  // temp->next = head;
    head = temp;
}
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()
{
    Type output;
    if (!head)
        cout << "<EMPTY>" << endl;
    current = head;
    while (current)
    {
        output = *(current->GetData());
        cout << output << " ";
        current = current->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;  // now make 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声明为LinkList<int> list1;和浮点数的LinkList声明为LinkList<float> list2;

在每种情况下,我们实例化各种链表,然后添加元素并打印相应的列表。在第一个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 the proper location
}

在前面的模板成员函数中,我们依赖于operator<能够与我们想要使用这个模板类的任何数据类型一起工作。也就是说,当预处理器为特定的用户定义类型扩展这段代码时,<运算符必须适用于此方法特定扩展的任何数据类型。

如果我们希望创建一个LinkListStudent实例,并对一个Student与另一个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;
}
// 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;
}

在前面的代码中,我们可以识别operator<被实现为Student的成员函数,或者作为非成员函数。如果你可以访问Student类的定义,首选的方法是利用成员函数定义来实现这个运算符函数。然而,有时我们无法访问修改一个类。在这种情况下,我们必须使用非成员函数的方法。无论如何,在任何一种实现中,我们只是比较两个Student实例的gpa,如果第一个实例的gpa低于第二个Student实例,则返回true,否则返回false

现在operator<已经为两个Student实例定义了,我们可以回到我们之前的LinkList<Type>::OrderedInsert(Type *)模板函数,它利用LinkList中类型为Type的两个对象进行比较。当我们的代码中某处创建了LinkList<Student>时,LinkListLinkListElement的模板代码将被预处理器为Student进行扩展;Type将被替换为Student。然后编译扩展后的代码时,扩展的LinkList<Student>::OrderedInsert()中的代码将会无错误地编译,因为operator<已经为两个Student对象定义了。

然而,如果我们忽略为给定类型重载operator<会发生什么,然而,OrderedInsert()(或者另一个依赖于operator<的方法)在我们的代码中对该扩展模板类型的对象从未被调用?信不信由你,代码将会编译并且正常工作。在这种情况下,我们实际上并没有调用一个需要为该类型实现operator<的函数(即OrderedInsert())。因为这个函数从未被调用,该成员函数的模板扩展被跳过。编译器没有理由去发现operator<应该为该类型重载(为了使方法成功编译)。未被调用的方法只是没有被扩展,以供编译器验证。

通过运算符重载来补充模板类和函数,我们可以通过确保在方法体中使用的典型运算符可以应用于模板扩展中我们想要使用的任何类型,使模板代码变得更具可扩展性。我们的代码变得更加普适。

我们现在已经看到了如何使用模板函数和类,以及如何运算符重载可以增强模板,创建更具可扩展性的代码。在继续前进到下一章之前,让我们简要回顾一下这些概念。

总结

在这一章中,我们进一步加强了我们的 C++编程知识,超越了面向对象编程语言特性,包括了额外的语言特性,使我们能够编写更具可扩展性的代码。我们学会了如何利用模板函数和模板类,以及运算符重载如何很好地支持这些努力。

我们已经看到,模板可以让我们以泛型方式指定一个类或函数,与该类或函数中主要使用的数据类型相关。我们已经看到,模板类不可避免地利用模板函数,因为这些方法通常需要泛型地使用构建类的数据。我们已经看到,通过利用用户定义类型的运算符重载,我们可以利用使用简单运算符编写的方法体来适应更复杂的数据类型的使用,使模板代码变得更加有用和可扩展。

我们现在明白,使用模板可以让我们更抽象地指定一个类或函数,让预处理器为我们生成许多该类或函数的版本,基于应用程序中可能需要的特定数据类型。

通过允许预处理器根据应用程序中需要的类型来扩展模板类或一组模板函数的许多版本,创建许多类似的类或函数(并维护这些版本)的工作被传递给了 C++,而不是程序员。除了减少用户需要维护的代码外,模板类或函数中所做的更改只需要在一个地方进行 – 预处理器在需要时将重新扩展代码而不会出错。

我们通过研究模板为我们的 C技能库增加了额外的有用功能,结合运算符重载,这将确保我们可以为几乎任何数据类型编写高度可扩展和可重用的代码。我们现在准备继续进行第十四章理解 STL 基础,以便我们可以继续扩展我们的 C编程技能,使用有用的 C++库功能,这将使我们成为更好的程序员。让我们继续前进!

问题

  1. 将您的ArrayInt类从第十二章运算符重载和友元,转换为一个模板Array类,以支持可以轻松调整大小并具有内置边界检查的任何数据类型的动态分配数组。
  1. 考虑一下,如果需要的话,您将需要重载哪些运算符,以支持模板的Array类型中存储的任何用户定义类型的通用代码。

  2. 使用您的模板的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_queuemap和使用函数器的map

  • 自定义 STL 容器

到本章结束时,您将能够利用核心 STL 类来增强您的编程技能。因为您已经了解了基本的 C语言和面向对象编程特性,您将会发现您现在有能力浏览和理解几乎任何 C类库,包括 STL。通过熟悉 STL,您将能够显著增强您的编程技能,并成为一个更精明和有价值的程序员。

让我们通过研究一个非常广泛使用的类库 STL 来增加我们的 C++工具包。

技术要求

完整程序示例的在线代码可以在以下 GitHub URL 找到:github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter14。每个完整程序示例都可以在 GitHub 存储库中的适当章节标题(子目录)下找到,文件名与当前章节编号相对应,后跟当前章节中的示例编号。例如,本章的第一个完整程序可以在子目录Chapter14中的名为Chp14-Ex1.cpp的文件中找到,位于上述 GitHub 目录下。

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

调查 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 map中的 STL 容器类配对。

在本章中,我们将专注于 STL 的容器类部分。虽然我们不会检查 STL 中的每个容器类,但我们将回顾一系列这些类。我们会注意到,一些这些容器类与我们在本书的前几章中一起构建的类相似。顺便说一句,在本书的渐进章节进展中,我们也建立了我们的 C语言和面向对象编程技能,这些技能对于解码 STL 这样的 C类库是必要的。

让我们继续前进,看看选择性的 STL 类,并在解释每个类时测试我们的 C++知识。

理解如何使用基本的 STL 容器

在本节中,我们将运用我们的 C技能,解码各种 STL 容器类。我们将看到,从核心 C语法到面向对象编程技能,我们掌握的语言特性使我们能够轻松解释我们现在将要检查的 STL 的各个组件。特别是,我们将运用我们对模板的了解!例如,我们对封装和继承的了解将指导我们理解如何使用 STL 类中的各种方法。然而,我们会注意到虚函数和抽象类在 STL 中非常罕见。熟练掌握 STL 中的新类的最佳方法是接受详细说明每个类的文档。有了 C++的知识,我们可以轻松地浏览给定类,解码如何成功使用它。

C++ STL 中的容器类实现了各种listiteratorvectordequestackqueuepriority_queuemap

让我们开始检查如何利用一个非常基本的 STL 容器,list

使用 STL list

STL list 类封装了实现链表所需的数据结构。我们可以说 list 实现了链表的抽象数据类型。回想一下,在第六章中,我们通过创建 LinkedListElementLinkedList 类来制作自己的链表。STL list 允许轻松插入、删除和排序元素。不支持直接访问单个元素(称为随机访问)。相反,必须迭代地遍历链表中的先前项,直到达到所需的项。list 是顺序容器的一个很好的例子。

STL list 类有各种成员函数;我们将从这个例子中开始看一些流行的方法,以熟悉基本的 STL 容器类的用法。

现在,让我们看看如何使用 STL list 类。这个例子可以在我们的 GitHub 上找到,作为一个完整的工作程序,其中包括必要的类定义:

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter14/Chp14-Ex1.cpp

#include <list>
int main()
{   
    list<Student> studentBody;   // create a list
    Student s1("Jul", "Li", 'M', "Ms.", 3.8, "C++", "117PSU");
    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 头文件。现在,在 main() 中,我们可以使用 list<Student> studentBody; 实例化一个列表。我们的列表将包含 Student 实例。然后我们在堆栈上创建 Student s1 和使用 new() 进行分配在堆上创建 Student *s2

接下来,我们使用 list::push_back()s1*s2 添加到列表中。请注意,我们正在向 push_back() 传递对象。当我们向 studentBody 列表添加 Student 实例时,列表将在内部制作对象的副本,并在这些对象不再是列表成员时正确清理这些对象。我们需要记住,如果我们的实例中有任何分配在堆上的实例,比如 *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 循环中,我们反复检查列表是否为空,如果不是,则检查 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来维护。迭代器通过将状态信息从容器中抽象出来,而是放入迭代器类,为交错访问提供了安全的手段。

我们可以想象一个iterator,您可能会在不知情的情况下修改容器。

让我们看看如何使用 STLiterator。这个例子可以在我们的 GitHub 上找到,作为一个完整的程序:

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter14/Chp14-Ex2.cpp

#include <list>
#include <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< 
    // Create a list iterator; set to first item in the list
    list <Student>::iterator listIter = studentBody.begin();
    while (listIter != studentBody.end())
    {
        Student &temp = *listIter;
        temp.Print();
        listIter++;
    }
    return 0;
}

让我们看一下我们之前定义的代码段。在这里,我们从 STL 中包括了<list><iterator>头文件。与之前的main()函数一样,我们实例化了一个list,它可以包含Student实例,使用list<Student> studentbody;。然后,我们实例化了几个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();来实现这一点。有了iterator,我们可以使用它来安全地循环遍历list,从开始(初始化时)到end()。我们将一个本地引用变量temp赋给列表中当前第一个元素的循环迭代,使用Student &temp = *listIter;。然后我们使用temp.Print();打印这个实例,然后我们通过listIter++;增加一个元素来增加我们的迭代器。

让我们看一下此程序的排序输出(按gpa排序):

MS. Giselle R. LeBrun with id: 299TU GPA:  3.4 Course: C++
Ms. 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开始。

使用 STLvector

STLvector类实现了动态数组的抽象数据类型。回想一下,我们通过在第十三章中创建一个Array类来创建了自己的动态数组,使用模板工作。然而,STL 版本将更加广泛。

vector(动态或可调整大小的数组)将根据需要扩展以容纳超出其初始大小的额外元素。vector类允许通过重载operator[]直接(即随机访问)访问元素。vector允许通过直接访问在常量时间内访问元素。不需要遍历所有先前的元素来访问特定索引处的元素。

然而,在vector中间添加元素是耗时的。也就是说,在除vector末尾之外的任何位置添加元素都需要内部重新排列所有插入点后的元素;它还可能需要内部调整vector的大小。

显然,通过比较,listvector具有不同的优势和劣势。每个都适用于数据集的不同要求。我们可以选择最适合我们需求的那个。

让我们看一下一些常见的vector成员函数。这远非完整列表:

STL vector还有一个重载的operator=(用源向目标vector进行赋值替换),operator==(逐个元素比较向量),和operator[](返回所请求位置的引用,即可写内存)。

让我们来看看如何使用 STL vector类及其基本操作。这个例子可以在我们的 GitHub 上找到,作为一个完整的工作程序,如下所示:

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter14/Chp14-Ex3.cpp

#include <vector>
int main()
{
    vector<Student> studentBody1, studentBody2; // two vectors
    // add 3 Students, which are anonymous objects, to vect 1 
    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"));
    for (int i = 0; i < studentBody1.size(); i++)   
        studentBody1[i].Print();   // print vector1's contents
    studentBody2 = studentBody1;   // assign one to another
    if (studentBody1 == studentBody2)
        cout << "Vectors are the same" << endl;
    for (auto iter = studentBody2.begin(); // print vector2
              iter != studentBody2.end(); iter++)
        (*iter).Print();
    if (!studentBody1.empty())   // clear first vector 
        studentBody1.clear();
    return 0;
}

在前面列出的代码段中,我们#include <vector>来包含适当的 STL 头文件。现在,在main()中,我们可以使用vector<Student> studentBody1, studentBody2;来实例化两个向量。然后,我们可以使用vector::push_back()方法将几个Student实例连续添加到我们的第一个vector中。再次注意,在main()中,Student实例是匿名对象。也就是说,没有本地标识符引用它们 - 它们只是被创建用于放入我们的vector中,每次插入时都会创建每个实例的本地副本。一旦我们的vector中有元素,我们就可以遍历我们的第一个vector,使用studentBody1[i].Print();打印每个Student

接下来,我们通过studentBody1 = studentBody2;来演示vector的重载赋值运算符。在这里,我们在赋值中从右到左进行深度复制。然后,我们可以使用重载的比较运算符在条件语句中测试这两个向量是否相等。也就是说,if (studentBody1 == studentBody2)。然后,我们使用指定为auto iter = studentBody2.begin();的迭代器在for循环中打印出第二个向量的内容。auto关键字允许迭代器的类型由其初始使用确定。最后,我们遍历我们的第一个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
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 deque类,以进一步了解 STL 容器。

使用 STL deque

STL deque类(发音为deck)实现了双端队列的抽象数据类型。这个 ADT 扩展了队列先进先出的概念。相反,deque允许更大的灵活性。在deque的两端快速添加元素。在deque的中间添加元素是耗时的。deque是一个顺序容器,尽管比我们的list更灵活。

你可能会想象dequequeue的一个特例;它不是。相反,灵活的deque类将作为实现其他容器类的基础,我们很快就会看到。在这些情况下,私有继承将允许我们将deque隐藏为更严格的专门类的底层实现(具有广泛的功能)。

让我们来看看一些常见的deque成员函数。这远非完整列表:

STL deque还有一个重载的operator=(将源分配给目标 deque)和operator[](返回所请求位置的引用 - 可写内存)。

让我们来看看如何使用 STL deque类。这个例子可以在我们的 GitHub 上找到,作为一个完整的工作程序,如下所示:

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter14/Chp14-Ex4.cpp

include <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(studentBody.begin() + 1, Student
       ("Anne", "Brennan", 'B', "Ms.", 3.9, "C++", "299CU"));
    studentBody[0] = s1;  // replace  element; 
                          // no bounds checking!
    while (studentBody.empty() == false)
    {
        studentBody.front().Print();
        studentBody.pop_front();
    }
    return 0;
}

在前面列出的代码段中,我们#include <deque>来包含适当的 STL 头文件。现在,在main()中,我们可以实例化一个deque来包含Student实例,使用deque<Student> studentBody;。然后,我们调用deque::push_back()deque::push_front()来向我们的deque中添加几个Student实例(一些匿名对象)。我们已经掌握了这个!现在,我们使用studentBody.insert(studentBody.begin() + 1, Student("Anne", "Brennan", 'B', "Ms.", 3.9, "C++", "299CU"));在我们的甲板前面插入一个Student

接下来,我们利用重载的operator[]将一个Student插入我们的deque,使用studentBody[0] = s1;。请注意,operator[]不会对我们的deque进行任何边界检查!在这个语句中,我们将Student s1插入到deque位置,而不是曾经占据该位置的Student。更安全的方法是使用deque::at()方法,它将包含边界检查。关于前述的赋值,我们还要确保operator=已经被重载为PersonStudent,因为每个类都有动态分配的数据成员。

现在,我们循环直到我们的deque为空,使用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 stack

STL stack类实现了堆栈的抽象数据类型。堆栈 ADT 支持stack包括一个不公开其底层实现的公共接口。毕竟,stack可能会改变其实现;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/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter14/Chp14-Ex5.cpp

include <stack>   // template class preamble
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 头文件。现在,在main()中,我们可以实例化一个stack来包含Student实例,使用stack<Student> studentBody;。然后,我们调用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 queue

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/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter14/Chp14-Ex6.cpp

#include <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 头文件。现在,在main()中,我们可以实例化一个queue来包含Student实例,使用queue<Student> studentBody;。然后我们调用queue::push()来向我们的queue中添加几个Student实例。回想一下,使用队列 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/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter14/Chp14-Ex7.cpp

#include <map>
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 map and map iterator, of Students w char * keys
    map<const char *, Student> studentBody;
    map<const char *, Student>::iterator mapIter;
    // create three pairings of ids to Students
    pair<const char *, Student> studentPair1
                                (s1.GetStudentId(), s1);
    pair<const char *, Student> studentPair2
                                (s2.GetStudentId(), s2);
    pair<const char *, Student> studentPair3
                                (s3.GetStudentId(), s3);
    studentBody.insert(studentPair1);  // insert 3 pairs
    studentBody.insert(studentPair2);
    studentBody.insert(studentPair3);
    // insert using virtual indices per map
    studentBody[s4.GetStudentId()] = s4; 

    mapIter = studentBody.begin();
    while (mapIter != studentBody.end())
    {   
        // set temp to current item in map iterator
        pair<const char *, Student> temp = *mapIter;
        Student &tempS = temp.second;  // get 2nd item in pair 
        // access using mapIter
        cout << temp.first << " "<<temp.second.GetFirstName();  
        // or access using temporary Student, tempS  
        cout << " " << tempS.GetLastName() << endl;
        mapIter++;
    }
    return 0;
}

让我们检查前面的代码段。同样,我们使用#include <map>包含适用的头文件。接下来,我们实例化四个Student实例。我们将制作一个哈希表(map),其中Student实例将由键(即它们的studentId)索引。接下来,我们声明一个map来保存Student实例的集合,使用map<const char*,Student> studentBody;。在这里,我们指示键和元素之间的关联将在const char*Student之间进行。然后,我们使用map<const char*,Student>::iterator mapIter;声明映射迭代器,使用相同的数据类型。

现在,我们创建三个pair实例,将每个Student与其键(即其相应的studentId)关联起来,使用声明pair<const char*,Student> studentPair1(s1.GetStudentId(), s1);。这可能看起来令人困惑,但让我们将这个声明分解成其组成部分。在这里,实例的数据类型是pair<const char*,Student>,变量名是studentPair1(s1.GetStudentId(), s1)是传递给特定pair实例构造函数的参数。

现在,我们只需将三个pair实例插入map中。一个示例是studentBody.insert(studentPair1);。然后,我们使用以下语句将第四个Students4,插入map中:studentBody[s4.GetStudentId()] = s4;。请注意,在operator[]中使用studentId作为索引值;这个值将成为哈希表中Student的键值。

最后,我们将映射迭代器建立到map的开头,然后在end()之前处理map。在循环中,我们将一个变量temp设置为映射迭代器指示的pair的前端。我们还将tempS设置为map中的Student的临时引用,由temp.second(映射迭代器管理的当前pair中的第二个值)指示。现在,我们可以使用temp.first(当前pair中的第一个项目)打印出每个Student实例的studentId(键)。在同一语句中,我们可以使用temp.second.GetFirstName()打印出每个Student实例的firstName(因为与键对应的Student是当前pair中的第二个项目)。类似地,我们还可以使用tempS.GetLastName()打印出学生的lastName,因为tempS在每次循环迭代开始时被初始化为当前pair中的第二个元素。

让我们来看看这个程序的输出:

299TU Rui Qi
178UD Hana Lo
272UD Ali Li
887TU Jiang Wu

接下来,让我们看看使用 STL map的另一种方法,这将向我们介绍 STL functor的概念。

使用函数对象检查 STL 映射

STL map类具有很大的灵活性,就像许多 STL 类一样。在我们过去的map示例中,我们假设我们的Student类中存在比较的方法。毕竟,我们为两个Student实例重载了operator<。然而,如果我们无法修改未提供此重载运算符的类,并且我们选择不重载operator<作为外部函数,会发生什么呢?

幸运的是,当实例化map或映射迭代器时,我们可以为模板类型扩展指定第三种数据类型。这个额外的数据类型将是一种特定类型的类,称为函数对象。一个operator()。在重载的operator()中,我们将为问题中的对象提供比较的方法。函数对象本质上是通过重载operator()来模拟封装函数指针。

让我们看看如何修改我们的map示例以利用一个简单的函数对象。这个例子可以在我们的 GitHub 上找到,作为一个完整的工作程序:

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter14/Chp14-Ex8.cpp

#include <map>
struct comparison   // This struct represents a 'functor'
{                   // that is, a 'function object'
    bool operator() (const char *key1, const char *key2) const
    {   
        int ans = strcmp(key1, key2);
        if (ans >= 0) return true;  // return a boolean
        else return false;  
    }
    comparison() {}  // empty constructor and destructor
    ~comparison() {}
};
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 order per 'comparison'
    // functor using operator()
    map<const char *, Student, comparison> studentBody;
    map<const char *, Student, comparison>::iterator mapIter;
    // The remainder of the program is similar to prior
}   // map program. See online code for complete example.

在前面提到的代码片段中,我们首先介绍了一个名为comparison的用户定义类型。这可以是一个class或一个struct。在这个结构的定义中,我们重载了函数调用运算符(operator()),并提供了两个const char *键的Student实例之间的比较方法。这个比较将允许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 类所涉及的注意事项。请记住,在增强类时必须始终谨慎小心。我们现在看到了需要为我们创建的任何类使用适当的 OO 组件测试的必要性。

我们现在考虑如何在我们的程序中可能定制 STL 容器类和算法。我们也看到了一些 STL 容器类的实际示例。在继续下一章之前,让我们简要回顾一下这些概念。

总结

在本章中,我们进一步扩展了我们的 C知识,超越了面向对象的语言特性,以熟悉 C标准模板库。由于这个库在 C++中被如此普遍地使用,我们必须理解它包含的类的范围和广度。我们现在准备在我们的代码中利用这些有用的、经过充分测试的类。

通过检查选择的 STL 类,我们已经看了很多 STL 的例子,应该有能力自己理解 STL 的其余部分(或任何 C++库)。

我们已经看到了如何使用常见和基本的 STL 类,比如listiteratorvectordequestackqueuepriority_queuemap。我们还看到了如何将一个函数对象与容器类结合使用。我们被提醒,我们现在有可能定制任何类的工具,甚至是来自类库如 STL 的类(通过私有或受保护的继承)或者包含或关联。

通过检查选定的 STL 类,我们还看到了我们有能力理解 STL 剩余的深度和广度,以及解码许多可用于我们的额外类库。当我们浏览每个成员函数的原型时,我们注意到关键的语言概念,比如const的使用,或者一个方法返回一个表示可写内存的对象的引用。每个原型都揭示了新类的使用机制。能够在编程努力中走到这一步真是令人兴奋!

通过在 C中浏览 STL,我们现在已经为我们的 C技能库增加了额外的有用特性。使用 STL(封装传统的数据结构)将确保我们的代码可以轻松地被其他程序员理解,他们无疑也在使用 STL。依靠经过充分测试的 STL 来使用这些常见的容器和实用程序,可以确保我们的代码更少出现错误。

我们现在准备继续进行[第十五章],测试类和组件。我们希望用有用的 OO 组件测试技能来补充我们的 C++编程技能。测试技能将帮助我们了解我们是否以稳健的方式创建、扩展或增强了类。这些技能将使我们成为更好的程序员。让我们继续前进!

问题

  1. 用 STLvector替换您在[第十三章](B15702_13_Final_NM_ePub.xhtml#_idTextAnchor486)的练习中的模板Array类,使用模板。创建Student实例的vector。使用vector操作来插入、检索、打印、比较和从向量中删除对象。或者,利用 STLlist。利用这个机会利用 STL 文档来浏览这些类的全部操作。
  1. 考虑您是否需要重载哪些运算符。考虑是否需要一个iterator来提供对集合的安全交错访问。

  2. 创建第二个vectorStudents。将一个分配给另一个。打印两个vectors

  1. 修改本章的map,以根据它们的lastName而不是studentId来索引Student实例的哈希表(map)。

  2. 修改本章的queue示例,以改用priority_queue。确保利用优先级入队机制priority_queue::emplace()将元素添加到priority_queue中。您还需要利用top()而不是front()。请注意,priority_queue可以在<queue>头文件中找到。

  3. 尝试使用sort()的 STL 算法。确保#include <algorithm>。对整数数组进行排序。请记住,许多容器都内置了排序机制,但本地集合类型,如语言提供的数组,没有(这就是为什么您应该使用基本整数数组)。

第十五章:测试类和组件

本章将继续探索如何通过探索测试组成我们面向对象程序的类和组件的方法,来增加您的 C++编程技能库。我们将探索各种策略,以确保我们编写的代码经过充分测试并且健壮。

本章将展示如何通过测试单个类以及测试一起工作的各种组件来测试您的面向对象程序。

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

  • 理解规范类形式;创建健壮的类

  • 创建驱动程序来测试类

  • 测试通过继承、关联或聚合相关的类

  • 测试异常处理机制

通过本章结束时,您将掌握各种技术,确保您的代码在投入生产之前经过充分测试。具备持续产生健壮代码的技能将帮助您成为更有益的程序员。

让我们通过研究各种面向对象测试技术来增强我们的 C++技能。

技术要求

完整程序示例的在线代码可以在以下 GitHub URL 找到:github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter15。每个完整程序示例都可以在 GitHub 存储库中的适当章节标题(子目录)下找到,文件名对应于章节号,后跟破折号,再跟随该章节中的示例编号。例如,本章的第一个完整程序可以在名为Chp15-Ex1.cpp的文件中的Chapter15子目录中找到上述 GitHub 目录下。

本章的 CiA 视频可在以下链接观看:bit.ly/314TI8h

思考面向对象测试

在部署任何代码之前,软件测试非常重要。测试面向对象的软件将需要不同于其他类型软件的技术。因为面向对象的软件包含类之间的关系,我们必须了解如何测试可能存在的类之间的依赖关系和关系。此外,每个对象可能会根据对每个实例应用操作的顺序以及与相关对象的特定交互而进入不同的状态(例如,通过关联)。与过程性应用程序相比,面向对象应用程序的整体控制流程要复杂得多,因为应用于给定对象的操作的组合和顺序以及相关对象的影响是多种多样的。

然而,我们可以应用指标和流程来测试面向对象的软件。这些范围从理解我们可以应用于类规范的习语和模式,到创建驱动程序来独立测试类以及它们与其他类的关系。这些流程还可以包括创建场景,以提供对象可能经历的事件或状态的可能序列。对象之间的关系,如继承、关联和聚合,在测试中变得非常重要;相关对象可以影响现有对象的状态。

让我们从理解我们经常可以应用于开发的类的简单模式开始,来开始我们在测试面向对象软件中的探索。这种习语将确保一个类可能是完整的,没有意外的行为。我们将从规范类形式开始。

理解规范类形式

对于 C++中的许多类来说,遵循类规范的模式是合理的,以确保新类包含所需的全部组件。规范类形式是一个强大的类规范,使得类实例能够在初始化、赋值、参数传递和从函数返回值的使用等方面提供统一的行为(类似于标准数据类型)。规范类形式将适用于大多数既用于实例化的类,又用于作为新派生类的公共基类的类。打算作为私有或受保护基类的类(即使它们可能被实例化)可能不遵循这种习惯的所有部分。

遵循正统规范形式的类将包括:

  • 一个默认构造函数

  • 一个复制构造函数

  • 一个过载的赋值运算符

  • 虚析构函数

遵循扩展规范形式的类还将包括:

  • 一个“移动”复制构造函数

  • 一个“移动”赋值运算符

让我们在下面的子节中看看规范类形式的每个组件。

默认构造函数

简单实例化需要一个默认构造函数。虽然如果一个类不包含构造函数,将会提供一个默认(空)构造函数,但重要的是要记住,如果一个类包含其他签名的构造函数,将不会提供默认构造函数。最好提供一个合理的基本初始化的默认构造函数。

此外,在成员初始化列表中没有指定替代基类构造函数的情况下,将调用给定类的基类的默认构造函数。如果基类没有这样的默认构造函数(并且没有提供另一个签名的构造函数),则对基类构造函数的隐式调用将被标记为错误。

让我们还考虑多重继承情况,其中出现了菱形继承结构,并且使用虚基类来消除最派生类实例中大多数基类子对象的重复。在这种情况下,除非在负责创建菱形形状的派生类的成员初始化列表中另有规定,否则现在共享基类子对象的默认构造函数将被调用。即使在中间级别指定了非默认构造函数,当中间级别指定了一个可能共享的虚基类时,这些规定也会被忽略。

复制构造函数

对于包含指针数据成员的所有对象来说,复制构造函数是至关重要的。除非程序员提供了复制构造函数,否则系统将在应用程序中必要时链接系统提供的复制构造函数。系统提供的复制构造函数执行所有数据成员的成员逐一(浅层)复制。这意味着一个类的多个实例可能包含指向共享内存块的指针,这些内存块代表应该是个体化的数据。此外,记得在派生类的复制构造函数中使用成员初始化列表来指定基类的复制构造函数以复制基类的数据成员。当然,在深度方式中复制基类子对象是至关重要的;此外,基类数据成员不可避免地是私有的,因此在派生类的成员初始化列表中选择基类复制构造函数非常重要。

通过指定一个复制构造函数,我们还帮助提供了一个对象通过值从函数传递(或返回)的预期方式。在这些情况下确保深层复制是至关重要的。用户可能认为这些复制是“通过值”,但如果它们的指针数据成员实际上与源实例共享,那么它实际上并不是通过值传递(或返回)对象。

过载的赋值运算符

一个重载的赋值运算符,就像复制构造函数一样,对于所有包含指针数据成员的对象也是至关重要的。系统提供的赋值运算符的默认行为是从源对象到目标对象的浅赋值。同样,当数据成员是指针时,强烈建议重载赋值运算符以为任何这样的指针数据成员分配空间。

另外,请记住,重载的赋值运算符不会继承;每个类都负责编写自己的版本。这是有道理的,因为派生类不可避免地有更多的数据成员需要复制,而其基类中的赋值运算符则可能是私有的或无法访问的。然而,在派生类中重载赋值运算符时,请记住调用基类的赋值运算符来执行继承的基类成员的深度赋值(这些成员可能是私有的或无法访问的)。

虚析构函数

虚析构函数在使用公共继承时是必需的。通常,派生类实例被收集在一组中,并由一组基类指针进行泛化。请记住,以这种方式进行向上转型只可能对公共基类进行(而不是对受保护或私有基类)。当以这种方式对对象的指针进行泛化时,虚析构函数对于通过动态(即运行时)绑定确定正确的析构函数起始点至关重要,而不是静态绑定。请记住,静态绑定会根据指针的类型选择起始析构函数,而不是对象实际的类型。一个很好的经验法则是,如果一个类有一个或多个虚函数,请确保你也有一个虚析构函数。

移动复制构造函数

一个this。然后我们必须将源对象的指针置空,以便这两个实例不共享动态分配的数据成员。实质上,我们已经移动了(内存中的)指针数据成员。

那么非指针数据成员呢?这些数据成员的内存将像往常一样被复制。非指针数据成员的内存和指针本身的内存(而不是指针指向的内存)仍然驻留在源实例中。因此,我们能做的最好的事情就是为源对象的指针指定一个空值,并在非指针数据成员中放置一个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()。这将指定基类移动复制构造函数来帮助初始化子对象。

移动赋值运算符

移动赋值运算符与重载的赋值运算符非常相似,但其目标是再次通过移动源对象的动态分配数据来节省内存(而不是执行深度赋值)。与重载的赋值运算符一样,我们将测试自我赋值,然后从(已存在的)目标对象中删除任何先前动态分配的数据成员。然后,我们将简单地将源对象中的指针数据成员复制到目标对象中的指针数据成员。我们还将将源对象中的指针置空,以便这两个实例不共享这些动态分配的数据成员。

此外,就像移动复制构造函数一样,非指针数据成员将简单地从源对象复制到目标对象,并在源对象中用0值替换以指示不使用。

我们将再次使用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/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter15/Chp15-Ex1.cpp

class Person
{
private:    // Assume all usual data members exist
protected:  // Assume usual protected member functions exist 
public:
    Person();                // default constructor
    // Assume other usual constructors exist  
    Person(const Person &);  // copy constructor
    Person(Person &&);       // move copy constructor
    virtual ~Person();       // virtual destructor
    // Assume usual access functions and virtual fns. exist 
    Person &operator=(const Person &);  // assignment operator
    Person &operator=(Person &&);  // move assignment operator
};
// copy constructor
Person::Person(const Person &pers)     
{  
    // Assume deep copy is implemented here  
}
// overloaded assignment operator
Person &Person::operator=(const Person &p)
{
    if (this != &p)  // check for self-assignment
    {
       // Delete existing Person ptr data members for 'this',
       // then re-allocate correct size and copy from source
    }
    return *this;  // allow for cascaded assignments
}

在先前的类定义中,我们注意到Person包含默认构造函数、复制构造函数、重载赋值运算符和虚析构函数。在这里,我们已经采用了正统的规范类形式作为一个模式,适用于可能有一天作为公共基类的类。还要注意,我们已经添加了移动复制构造函数和移动赋值运算符的原型,以进一步采用扩展的规范类形式。

移动复制构造函数Person(Person &&);和移动赋值运算符Person &operator=(Person &&);的原型包含类型为Person &&的参数。这些是Person &的例子,将绑定到原始复制构造函数和重载赋值运算符,而 r 值引用参数将绑定到适用的移动方法。

现在让我们看一下有助于Person扩展规范类形式的方法定义 - 移动复制构造函数和移动赋值运算符:

// move copy constructor
Person::Person(const Person &&pers)   
{   // overtake source object's dynamically allocated memory
    // and null-out source object's pointers to that memory
    firstName = pers.firstName;
    pers.firstName = 0;
    lastName = pers.lastName;
    pers.lastName = 0;
    middleInitial = pers.middleInitial;
    pers.middleInitial = '\0'; // null char indicates non-use
    title = pers.title;
    pers.title = 0;
}
// move overloaded assignment operator
Person &Person::operator=(const Person &p)
{ 
    if (this != &p)       // check for self-assignment
    {
        delete firstName;  // or call ~Person(); (unusual)
        delete lastName;   // Delete existing object's
        delete title;      // allocated data members
        // overtake source object's dynamically alloc memory
        // and null source object's pointers to that memory
        firstName = p.firstName;
        p.firstName = 0;
        lastName = p.lastName;
        p.lastName = 0;
        middleInitial = p.middleInitial;
        p.middleInitial = '\0'; // null char indicates non-use
        title = p.title;
        p.title = 0;   
    }
    return *this;  // allow for cascaded assignments  
}

请注意,在前面的移动复制构造函数中,我们通过简单的指针赋值(而不是内存分配,如我们在深复制构造函数中所使用的)接管源对象的动态分配内存。然后我们在源对象的指针数据成员中放置一个空值。对于非指针数据成员,我们只是将值从源对象复制到目标对象,并在源对象中放置一个零值(例如p.middleInitial'\0')以表示其进一步的非使用。

在移动赋值运算符中,我们检查自我赋值,然后采用相同的方案,仅仅通过简单的指针赋值将动态分配的内存从源对象移动到目标对象。我们也复制简单的数据成员,并且当然用空指针或零值替换源对象数据值,以表示进一步的非使用。*this的返回值允许级联赋值。

现在,让我们看看派生类Student如何在利用其基类组件来辅助实现选定的成语方法时,同时使用正统和扩展的规范类形式:

class Student: public Person
{
private:  // Assume usual data members exist
public:
    Student();                 // default constructor
    // Assume other usual constructors exist  
    Student(const Student &);  // copy constructor
    Student(Student &&);       // move copy constructor
    virtual ~Student();        // 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.
};
// copy constructor
Student::Student(const Student &s): Person(s)
{   // Use member init. list to specify base copy constructor
    // to initialize base sub-object
    // Assume deep copy for Student is implemented here  
}
// Overloaded assignment operator
Student &Student::operator=(const Student &s)
{
   if (this != &s)   // check for self-assignment
   {
       Person::operator=(s);  // call base class assignment op
       // delete existing Student ptr data members for 'this'
       // then reallocate correct size and copy from source
   }
}

在先前的类定义中,我们再次看到Student包含默认构造函数、复制构造函数、重载赋值运算符和虚析构函数,以完成正统的规范类形式。

然而,请注意,在Student复制构造函数中,我们通过成员初始化列表指定了Person复制构造函数的使用。同样,在Student重载赋值运算符中,一旦我们检查自我赋值,我们调用Person中的重载赋值运算符来帮助我们使用Person::operator=(s);完成任务。

现在让我们看一下有助于Student扩展规范类形式的方法定义 - 移动复制构造函数和移动赋值运算符:

// move copy constructor
Student::Student(Student &&ps): Person(move(ps))   
{   // Use member init. list to specify base move copy 
    // constructor to initialize base sub-object
    gpa = ps.gpa;
    ps.gpa = 0.0;
    currentCourse = ps.currentCourse;
    ps.currentCourse = 0;
    studentId = ps.studentId;  
    ps.studentId = 0;
}
// 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 currentCourse;  // delete existing data members
      delete studentId;
      gpa = s.gpa;  
      s.gpa = 0.0;
      currentCourse = s.currentCourse;
      s.currentCourse = 0;
      studentId = s.studentId;
      s.studentId = 0;
   }
   return *this;  // allow for cascaded assignments
}

请注意,在先前列出的Student移动复制构造函数中,我们在成员初始化列表中指定了基类的移动复制构造函数的使用。Student移动复制构造函数的其余部分与Person基类中的类似。

同样,让我们注意,在Student移动赋值运算符中,调用基类的移动operator=Person::operator=(move(s);。这个方法的其余部分与基类中的类似。

一个很好的经验法则是,大多数非平凡的类应该至少使用正统的规范类形式。当然,也有例外。例如,一个只用作受保护或私有基类的类不需要具有虚析构函数,因为派生类实例不能通过非公共继承边界向上转型。同样,如果我们有充分的理由不希望复制或禁止赋值,我们可以在这些方法的扩展签名中使用= delete规范来禁止复制或赋值。

尽管如此,规范类形式将为采用这种习惯的类增加健壮性。采用这种习惯的类在初始化、赋值和参数传递方面的统一性将受到程序员的重视。

让我们继续来看看与规范类形式相辅相成的一个概念,即健壮性。

确保类是健壮的

C++的一个重要特性是能够构建用于广泛重用的类库。无论我们希望实现这个目标,还是只是希望为我们自己组织的使用提供可靠的代码,重要的是我们的代码是健壮的。一个健壮的类将经过充分测试,应该遵循规范的类形式(除了在受保护和私有基类中需要虚析构函数),并且是可移植的(或包含在特定平台的库中)。任何候选重用的类,或者将在任何专业环境中使用的类,绝对必须是健壮的。

健壮的类必须确保给定类的所有实例都完全构造。完全构造的对象是指所有数据成员都得到适当初始化的对象。必须验证给定类的所有构造函数(包括复制构造函数)以初始化所有数据成员。应检查加载数据成员的值是否适合范围。记住,未初始化的数据成员是潜在的灾难!应该在给定构造函数未能正确完成或数据成员的初始值不合适的情况下采取预防措施。

可以使用各种技术来验证完全构造的对象。一种基本的技术是在每个类中嵌入一个状态数据成员(或派生或嵌入一个状态祖先/成员)。在成员初始化列表中将状态成员设置为0,并在构造函数的最后一行将其设置为1。在实例化后探测这个值。这种方法的巨大缺陷是用户肯定会忘记探测完全构造的成功标志。

一个更好的技术是利用异常处理。在每个构造函数内嵌异常处理是理想的。如果数据成员未在合适范围内初始化,首先尝试重新输入它们的值,或者例如打开备用数据库进行输入。作为最后手段,您可以抛出异常来报告未完全构造的对象。我们将在本章后面更仔细地研究关于测试的异常处理。

与此同时,让我们继续使用一种技术来严格测试我们的类和组件——创建驱动程序来测试类。

创建驱动程序来测试类

第五章中,详细探讨类,我们简要讨论了将代码分解为源文件和头文件的方法。让我们简要回顾一下。通常,头文件将以类的名称命名(如Student.h),并包含类定义,以及任何内联成员函数定义。通过将内联函数放在头文件中,它们将在其实现更改时被正确地重新扩展(因为头文件随后包含在每个源文件中,与该头文件创建了依赖关系)。

每个类的方法实现将被放置在相应的源代码文件中(比如Student.cpp),它将包括它所基于的头文件(即#include "Student.h")。请注意,双引号意味着这个头文件在我们当前的工作目录中;我们也可以指定一个路径来找到头文件。相比之下,C++库使用的尖括号告诉预处理器在编译器预先指定的目录中查找。另外,请注意,每个派生类的头文件将包括其基类的头文件(以便它可以看到成员函数的原型)。

考虑到这种头文件和源代码文件结构,我们现在可以创建一个驱动程序来测试每个单独的类或每组紧密相关的类(例如通过关联或聚合相关的类)。通过继承相关的类可以在它们自己的单独的驱动程序文件中进行测试。每个驱动程序文件可以被命名为反映正在测试的类的名称,比如StudentDriver.cpp。驱动程序文件将包括正在测试的类的相关头文件。当然,所涉及类的源文件将作为编译过程的一部分被编译和链接到驱动程序文件中。

驱动程序文件可以简单地包含一个main()函数,作为一个测试平台来实例化相关的类,并作为测试每个成员函数的范围。驱动程序将测试默认实例化、典型实例化、复制构造、对象之间的赋值,以及类中的每个附加方法。如果存在虚析构函数或其他虚函数,我们应该实例化派生类实例(在派生类的驱动程序中),将这些实例向上转型为基类指针进行存储,然后调用虚函数以验证发生了正确的行为。在虚析构函数的情况下,我们可以通过删除动态分配的实例(或等待栈实例超出范围)并通过调试器逐步验证一切是否符合预期来跟踪销毁顺序的入口点。

我们还可以测试对象是否完全构造;我们很快将在这个主题上看到更多。

假设我们有我们通常的PersonStudent类层次结构,这里有一个简单的驱动程序来测试Student类。这个驱动程序可以在我们的 GitHub 存储库中找到。为了创建一个完整的程序,您还需要编译和链接在同一目录中找到的Student.cppPerson.cpp文件。这是驱动程序的 GitHub URL:

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter15/Chp15-Ex2.cpp

#include "Person.h"    // include relevant class header files
#include "Student.h"
using namespace std;
const int MAX = 3;
int main()   // Driver to test Student class. Stored in above
{            // filename for chapter example consistency 
    // Test all means for instantiation, including copy const.
    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];
    // 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 (int i = 0; i < MAX; i++)
    {
       people[i]->IsA();
       cout << "  ";
       people[i]->Print();
    }
    // Test destruction sequence (dynam. allocated instances)
    for (int i = 0; i < MAX; i++)
       delete people[i];   // engage virtual dest. sequence
    return 0;
}

简要回顾前面的程序片段,我们可以看到我们已经测试了每种实例化方式,包括复制构造函数。我们还测试了赋值运算符,验证了每个成员函数的工作(示例方法显示了),并验证了虚函数(包括虚析构函数)按预期工作。

既然我们已经看到了一个基本的驱动程序测试我们的类,让我们考虑一些额外的指标,当测试通过继承、关联或聚合相关的类时可以使用。

测试相关类

对于面向对象的程序,仅仅测试单个类的完整性和健壮性是不够的,尽管这些是很好的起点。完整性不仅包括遵循规范的类形式,还包括确保数据成员具有安全的访问方式,使用适当的访问方法(在不修改实例时标记为const)。完整性还验证了按照面向对象设计规范实现了所需的接口。

健壮性要求我们验证所有上述方法是否在适当的驱动程序中进行了测试,评估其平台独立性,并验证每种实例化方式是否导致完全构造的对象。我们可以通过对实例的数据成员进行阈值测试来增强这种类型的测试,注意当抛出异常时。完整性和健壮性,尽管看似全面,实际上是 OO 组件测试最直接的手段。

测试相关类之间交互的一种更具挑战性的手段是测试聚合和关联之间的交互。

通过继承、关联或聚合相关的类进行测试

通过各种对象关系相关的类需要各种额外的组件测试手段。具有各种关系的对象之间的相互影响可能会影响应用程序中给定实例的生命周期内的状态变化。这种类型的测试将需要最详细的努力。我们会发现场景对于帮助我们捕捉相关对象之间的常规交互是有用的,从而导致更全面的测试相互交互的类的方式。

让我们首先考虑如何测试通过继承相关的类。

添加测试继承的策略

通过公共继承相关的类需要验证虚函数。例如,所有预期的派生类方法是否已被覆盖?记住,如果基类行为在派生类级别仍然被认为是适当的,那么派生类不需要覆盖其基类中指定的所有虚函数。将需要将实现与设计进行比较,以确保我们已经用适当的方法覆盖了所有必需的多态操作。

当然,虚函数的绑定是在运行时完成的(即动态绑定)。重要的是创建派生类实例并使用基类指针存储它们,以便可以应用多态操作。然后我们需要验证派生类的行为是否突出。如果没有,也许我们会发现自己处于一个意外的函数隐藏情况,或者基类操作没有像预期的那样标记为虚拟(请记住,虚拟和覆盖关键字在派生类级别,虽然很好并且推荐,但是是可选的,不会影响动态行为)。

尽管通过继承相关的类具有独特的测试策略,但要记住实例化将创建一个单一对象,即基类或派生类类型的对象。当我们实例化这样的类型时,我们有一个实例,而不是一对共同工作的实例。派生类仅具有基类子对象,该子对象是其自身的一部分。让我们考虑一下这与关联对象或聚合物的比较,它们可以是单独的对象(关联),可能与其伴侣进行交互。

添加测试聚合和关联的策略

通过关联或聚合相关的类可能是多个实例之间的通信,并且彼此引起状态变化。这显然比继承的对象关系更复杂。

通过聚合相关的类通常比通过关联相关的类更容易测试。考虑到最常见的聚合形式(组合),内嵌(内部)对象是外部(整体)对象的一部分。当实例化外部对象时,我们得到内部对象嵌入在“整体”中的内存。与包含基类子对象的派生类实例的内存布局相比,内存布局并没有非常不同(除了可能的排序)。在每种情况下,我们仍然处理单个实例(即使它有嵌入的“部分”)。然而,与测试进行比较的重点是,应用于“整体”的操作通常被委托给“部分”或组件。我们将严格需要测试整体上的操作,以确保它们将必要的信息委托给每个部分。

通过一般聚合的较少使用的形式相关的类(其中整体包含指向部分的指针,而不是典型的组合的嵌入对象实现)与关联有类似的问题,因为实现是相似的。考虑到这一点,让我们来看看与相关对象有关的测试问题。

通过关联相关的类通常是独立存在的对象,在应用程序的某个时刻彼此创建了链接。在应用于一个对象上的操作可能会导致关联对象的变化。例如,让我们考虑一个“学生”和一个“课程”。两者可能独立存在,然后在应用程序的某个时刻,“学生”可能通过Student::AddCourse()添加一个“课程”。通过这样做,不仅特定的“学生”实例现在包含到特定的“课程”实例的链接中,而且Student::AddCourse()操作已经导致了“课程”类的变化。特定的“学生”实例现在是特定“课程”实例名单的一部分。在任何时候,“课程”可能被取消,从而影响到所有已经在该“课程”中注册的“学生”实例。这些变化反映了每个关联对象可能存在的状态。例如,“学生”可能处于“当前注册”或“退出”“课程”的状态。有很多可能性。我们如何测试它们?

添加场景以帮助测试对象关系

在面向对象分析中,场景的概念被提出作为创建 OO 设计和测试的手段。场景是对应用程序中可能发生的一系列事件的描述性步行。场景将展示类以及它们如何在特定情况下相互作用。许多相关场景可以被收集到 OO 概念的用例中。在 OO 分析和设计阶段,场景有助于确定应用程序中可能存在的类,以及每个类可能具有的操作和关系。在测试中,场景可以被重复使用,形成测试各种对象关系的驱动程序创建的基础。考虑到这一点,可以开发一系列驱动程序来测试多种场景(即用例)。这种建模方式将更彻底地为相关对象提供一个测试基础,而不仅仅是最初的简单测试完整性和健壮性的手段。

与任何类型的相关类之间的另一个关注领域是版本控制。例如,如果基类定义或默认行为发生了变化会发生什么?这将如何影响派生类?这将如何影响相关对象?随着每次变化,我们不可避免地需要重新审视所有相关类的组件测试。

接下来,让我们考虑异常处理机制如何影响 OO 组件测试。

测试异常处理机制

现在我们可以创建驱动程序来测试每个类(或一组相关类),我们将想要了解我们代码中哪些方法可能会抛出异常。对于这些情况,我们将希望在驱动程序中添加 try 块,以确保我们知道如何处理每个可能抛出的异常。在这样做之前,我们应该问自己,在开发过程中我们的代码是否包含了足够的异常处理?例如,考虑实例化,我们的构造函数是否检查对象是否完全构造?如果没有,它们会抛出异常吗?如果答案是否定的,我们的类可能不像我们预期的那样健壮。

让我们考虑将异常处理嵌入到构造函数中,以及我们如何构建一个驱动程序来测试所有可能的实例化方式。

将异常处理嵌入到构造函数中以创建健壮的类

我们可能还记得我们最近的第十一章处理异常,我们可以创建自己的异常类,从 C++标准库exception类派生而来。假设我们已经创建了这样一个类,即ConstructionException。如果在构造函数的任何时候我们无法正确初始化给定实例以提供一个完全构造的对象,我们可以从任何构造函数中抛出ConstructionException。潜在抛出ConstructionException的含义是我们现在应该在 try 块中封闭实例化,并添加匹配的 catch 块来预期可能抛出的ConstructionException。然而,请记住,在 try 块范围内声明的实例只在 try-catch 配对内部有效。

好消息是,如果一个对象没有完成构造(也就是说,在构造函数完成之前抛出异常),那么这个对象在技术上就不存在。如果一个对象在技术上不存在,就不需要清理部分实例化的对象。然而,我们需要考虑如果我们预期的实例没有完全构造,这对我们的应用意味着什么。这将如何改变我们代码中的进展?测试的一部分是确保我们已经考虑了我们的代码可能被使用的所有方式,并相应地进行防护!

重要的是要注意,引入trycatch块可能会改变我们的程序流程,包括这种类型的测试对我们的驱动程序是至关重要的。我们可能会寻找考虑trycatch块的场景,当我们进行测试时。

我们现在已经看到了如何增强我们的测试驱动程序以适应可能抛出异常的类。在本章中,我们还讨论了在我们的驱动程序中添加场景,以帮助跟踪具有关系的对象之间的状态,当然,我们还讨论了可以遵循的简单类习惯,以便为成功做好准备。在继续下一章之前,让我们简要回顾一下这些概念。

总结

在本章中,我们通过检查各种 OO 类和组件测试实践和策略,增强了成为更好的 C++程序员的能力。我们的主要目标是确保我们的代码是健壮的,经过充分测试,并且可以无错误地部署到我们的各个组织中。

我们已经考虑了编程习惯,比如遵循规范的类形式,以确保我们的类是完整的,并且在构造/销毁、赋值以及在参数传递和作为函数返回值中的使用方面具有预期的行为。我们已经讨论了创建健壮类的含义 - 一个遵循规范的类形式,也经过充分测试,独立于平台,并且针对完全构造的对象进行了测试。

我们还探讨了如何创建驱动程序来测试单个类或一组相关类。我们已经建立了一个测试单个类的项目清单。我们更深入地研究了对象关系,以了解彼此交互的对象需要更复杂的测试。也就是说,当对象从一种状态转移到另一种状态时,它们可能会受到相关对象的影响,这可能会进一步改变它们的进展方向。我们已经添加了使用场景作为我们的驱动程序的测试用例,以更好地捕捉实例可能在应用程序中移动的动态状态。

最后,我们已经看了一下异常处理机制如何影响我们测试代码,增强我们的驱动程序以考虑 try 和 catch 块在我们的应用程序中可能操纵的控制流。

我们现在准备继续我们书的下一部分,C++中的设计模式和习惯用法。我们将从第十六章开始,使用观察者模式。在剩下的章节中,我们将了解如何应用流行的设计模式,在我们的编码中使用它们。这些技能将使我们成为更好的程序员。让我们继续前进!

问题

  1. 考虑一对包含对象关系的类,来自你以前的练习(提示:公共继承比关联更容易考虑)。
  1. 你的类遵循规范的类形式吗?是正统的还是扩展的?为什么?如果不是,而应该是,修改类以遵循这种习惯用法。

  2. 你认为你的类健壮吗?为什么?为什么不?

  1. 创建一个(或两个)驱动程序来测试你的一对类。
  1. 确保测试通常的项目清单(构造、赋值、销毁、公共接口、向上转型(如果适用)和使用虚函数)。

b.(可选)如果您选择了两个与关联相关的类,请创建一个单独的驱动程序,以详细描述这两个类的交互的典型场景。

  1. 确保在您的一个测试驱动程序中包括异常处理的测试。
  1. 创建一个ConstructionException类(从 C++标准库exception派生)。在样本类的构造函数中嵌入检查,以在必要时抛出ConstructionException。确保将此类的所有实例化形式都包含在适当的trycatch块配对中。

第四部分:C++中的设计模式和习惯用法

本节的目标是扩展您的 C++技能,超越面向对象编程和其他必要的技能,包括核心设计模式的知识。设计模式提供了解决面向对象问题的经过验证的技术和策略。本节介绍了常见的设计模式,并深入演示了如何通过在书中以创造性的方式构建在先前示例的基础上应用这些模式。每一章都包含详细的代码示例来说明每个模式。

本节的初始章节介绍了设计模式的概念,并讨论了在编码解决方案中利用这些模式的优势。初始章节还介绍了观察者模式,并提供了一个深入的程序来欣赏这种模式的各个组成部分。

下一章解释了工厂方法模式,并提供了详细的程序,展示了如何使用对象工厂来实现工厂方法模式。此外,本章还将对象工厂与抽象工厂进行了比较。

下一章介绍了适配器模式,并提供了使用继承与关联来实现适配器类的实现策略和程序示例。此外,还说明了适配器作为一个简单的包装类。

下一章将讨论单例模式。在介绍一个简单的例子之后,将演示一个配对类的实现,并提供详细的示例。还介绍了用于容纳单例的注册表。

本节和本书的最后一章介绍了 pImpl 模式,以减少代码中的编译时间依赖关系。提供了一个基本的实现,然后使用唯一指针进行了扩展。还进一步探讨了与这种模式相关的性能问题。

本节包括以下章节:

  • [第十六章](B15702_16_Final_NM_ePub.xhtml#_idTextAnchor622),使用观察者模式

  • [第十七章](B15702_17_Final_NM_ePub.xhtml#_idTextAnchor649),应用工厂模式

  • [第十八章](B15702_18_Final_NM_ePub.xhtml#_idTextAnchor682),应用适配器模式

  • [第十九章](B15702_19_Final_NM_ePub.xhtml#_idTextAnchor718),使用单例模式

  • [第二十章](B15702_20_Final_NM_ePub.xhtml#_idTextAnchor756),使用 pImpl 模式去除实现细节

第十六章:使用观察者模式

本章将开始我们的探索,将您的 C编程技能库扩展到 OOP 概念之外,目标是使您能够通过利用常见的设计模式来解决重复出现的编码问题。设计模式还将增强代码维护,并为潜在的代码重用提供途径。本书的第四部分,从本章开始,旨在演示和解释流行的设计模式和习语,并学习如何在 C中有效实现它们。

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

  • 理解利用设计模式的优势

  • 理解观察者模式及其对面向对象编程的贡献

  • 理解如何在 C++中实现观察者模式

通过本章结束,您将了解在您的代码中使用设计模式的效用,以及了解流行的观察者模式。我们将在 C++中看到这种模式的示例实现。利用常见的设计模式将帮助您成为一个更有益和有价值的程序员,使您能够接纳更复杂的编程技术。

让我们通过研究各种设计模式来增强我们的编程技能,从本章开始使用观察者模式。

技术要求

完整程序示例的在线代码可以在以下 GitHub URL 找到:github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter16。每个完整程序示例都可以在 GitHub 存储库中找到,位于相应章节标题(子目录)下的文件中,该文件与所在章节编号对应,后跟破折号,再跟所在章节中示例编号。例如,本章的第一个完整程序可以在子目录Chapter16中的名为Chp16-Ex1.cpp的文件中找到,该文件位于上述 GitHub 目录下。

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

利用设计模式

设计模式代表了针对重复出现的编程难题的一组经过充分测试的编程解决方案。设计模式代表了设计问题的高级概念,以及类之间的通用协作如何提供解决方案,可以以多种方式实现。

在过去 25 年多的软件开发中,已经识别和描述了许多设计模式。我们将在本书的剩余章节中查看一些流行的模式,以便让您了解如何将流行的软件设计解决方案纳入我们的编码技术库中。

为什么我们选择使用设计模式?首先,一旦我们确定了一种编程问题类型,我们可以利用其他程序员充分测试过的经过验证的解决方案。此外,一旦我们使用了设计模式,其他程序员在沉浸于我们的代码(用于维护或未来增强)时,将对我们选择的技术有基本的了解,因为核心设计模式已成为行业标准。

一些最早的设计模式大约 50 年前出现,随着模型-视图-控制器范式的出现,后来有时简化为主题-视图。例如,主题-视图是一个基本的模式,其中一个感兴趣的对象(主题)将与其显示方法(视图)松散耦合。主题及其视图之间有一对一的关联。有时主题可以有多个视图,这种情况下,主题与许多视图对象相关联。如果一个视图发生变化,状态更新可以发送到主题,然后主题可以向其他视图发送必要的消息,以便它们也可以更新以反映新状态可能如何修改它们的特定视图。

最初的模型-视图-控制器MVC)模式,源自早期的面向对象编程语言,如 Smalltalk,具有类似的前提,只是控制器对象在模型(即主题)和其视图(或视图)之间委托事件。这些初步范例影响了早期的设计模式;主题-视图或 MVC 的元素在概念上可以被视为今天核心设计模式的基础。

我们将在本书的其余部分中审查的许多设计模式都是由四人组(Erich Gamma,Richard Helm,Ralph Johnson 和 John Vlissides)在设计模式,可重用面向对象软件的元素中最初描述的模式的改编。我们将应用和调整这些模式来解决我们在本书早期章节中介绍的应用程序所引发的问题。

让我们开始我们对理解和利用流行设计模式的追求,通过调查一个正在实施的模式。我们将从一个被称为观察者模式的行为模式开始。

理解观察者模式

观察者模式中,一个感兴趣的对象将维护一个对主要对象状态更新感兴趣的观察者列表。观察者将维护与他们感兴趣的对象的链接。我们将主要感兴趣的对象称为主题。感兴趣的对象列表统称为观察者。主题将通知任何观察者相关状态的变化。一旦观察者被通知主题的任何状态变化,它们将自行采取任何适当的下一步行动(通常通过主题在每个观察者上调用的虚函数来执行)。

我们已经可以想象如何使用关联来实现观察者模式。事实上,观察者代表了一对多的关联。例如,主题可以使用 STL 的list(或vector)来收集一组观察者。每个观察者将包含与主题的关联。我们可以想象主题上的一个重要操作,对应于主题中的状态改变,发出对其观察者列表的更新,以通知它们状态的改变。Notify()方法实际上是在主题的状态改变时被调用,并统一地应用于主题的观察者列表上的多态观察者Update()方法。在我们陷入实现之前,让我们考虑构成观察者模式的关键组件。

观察者模式将包括:

  • 主题,或感兴趣的对象。主题将维护一个观察者对象的列表(多边关联)。

  • 主题将提供一个接口来Register()Remove()一个观察者。

  • 主题将包括一个Notify()接口,当主题的状态发生变化时,将更新其观察者。主题将通过在其集合中的每个观察者上调用多态的Update()方法来Notify()观察者。

  • 观察者类将被建模为一个抽象类(或接口)。

  • 观察者接口将提供一个抽象的、多态的Update()方法,当其关联的主题改变其状态时将被调用。

  • 从每个 Observer 到其 Subject 的关联将在一个具体类中维护,该类派生自 Observer。这样做将减轻尴尬的转换(与在抽象 Observer 类中维护 Subject 链接相比)。

  • 两个类将能够维护它们的当前状态。

上述的 Subject 和 Observer 类是通用指定的,以便它们可以与各种具体类(主要通过继承)结合使用观察者模式。通用的 Subject 和 Observer 提供了很好的重用机会。通过设计模式,模式的许多核心元素通常可以更通用地设置,以允许代码本身更大程度的重用,不仅是解决方案概念的重用。

让我们继续看观察者模式的一个示例实现。

实现观察者模式

为了实现观察者模式,我们首先需要定义我们的SubjectObserver类。然后,我们需要从这些类派生具体类,以合并我们的应用程序特定内容并启动我们的模式。让我们开始吧!

创建 Observer、Subject 和特定领域的派生类

在我们的示例中,我们将创建SubjectObserver类来建立注册ObserverSubject以及Subject通知其一组观察者可能存在的状态更改的框架。然后,我们将从这些基类派生出我们习惯看到的派生类 - CourseStudent,其中Course将是我们的具体Subject,而Student将成为我们的具体Observer

我们将建模的应用程序涉及课程注册系统和等待列表的概念。正如我们之前在第十章问题 2中所看到的,实现关联、聚合和组合,我们将对Student进行建模,将其与许多Course实例关联,并且Course与许多Student实例关联。当我们建模我们的等待列表时,观察者模式将发挥作用。

我们的Course类将派生自Subject。我们的Course将继承的观察者列表将代表这个Course等待列表上的Student实例。 Course还将有一个Student实例列表,代表已成功注册该课程的学生。

我们的Student类将派生自PersonObserverStudent将包括Student当前注册的Course实例列表。 Student还将有一个成员,waitList,它将对应于Student正在等待的Course的关联。这个等待列表Course代表我们将收到通知的Subject。通知将对应于状态更改,指示Course现在有空间让Student添加Course

正是从Observer那里,Student将继承多态操作Update(),这将对应于Student被通知现在Course中有一个空位。在这里,在Student::Update()中,我们将添加机制,将Student从等待列表(有一个waitList数据成员)移动到Course中的实际当前学生列表(以及该学生的当前课程列表)。

指定 Observer 和 Subject

让我们将我们的示例分解成组件,从指定我们的ObserverSubject类开始。完整的程序可以在我们的 GitHub 上找到:

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter16/Chp16-Ex1.cpp

#include <list>   // partial list of #includes
#include <iterator>
using namespace std;
const int MAXCOURSES = 5, MAXSTUDENTS = 5;
class Subject;  // forward declarations
class Student;
class Observer  // Observer is an abstract class
{
private:
    int observerState;
protected:
    Observer() { observerState = 0; }
    Observer(int s) { observerState = s; }
    void SetState(int s) { observerState = s; }
public:
    int GetState() const { return observerState; }
    virtual ~Observer() {}
    virtual void Update() = 0;
};

在前面的类定义中,我们介绍了我们的抽象Observer类。在这里,我们包括一个observerState和受保护的构造函数来初始化这个状态。我们包括一个受保护的SetState()方法,以便从派生类的范围更新这个状态。我们还包括一个公共的GetState()方法。GetState()的添加将通过允许我们轻松检查Observer的状态是否已更改,有助于在我们的SubjectNotify()方法中实现。尽管状态信息历来是添加到ObserverSubject的派生类中,但我们将在这些基类中通用化状态信息。这将使我们的派生类保持更加独立于模式,并集中于应用程序的本质。

请注意,我们的析构函数是虚拟的,并且我们引入了一个抽象方法virtual void Update() = 0;来指定我们的Subject将在其观察者列表上调用的接口,以将更新委托给这些Observer实例。

现在,让我们来看看我们的Subject基类:

class Subject   // Treated as an abstract class, due to
{               // protected constructors. However, there's no 
private:        // pure virtual function
    list<class Observer *> observers;
    int numObservers;
    int subjectState;
    list<Observer *>::iterator newIter;
protected:
    Subject() { subjectState = 0; numObservers = 0; }
    Subject(int s) { subjectState = s; numObservers = 0; }
    void SetState(int s) { subjectState = s; }
public:
    int GetState() const { return subjectState; }
    int GetNumObservers() const { return numObservers; }
    virtual ~Subject() {}
    virtual void Register(Observer *);
    virtual void Release(Observer *);
    virtual void Notify();
};

在上述的Subject类定义中,我们看到我们的Subject包括一个 STLlist来收集它的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;
    // loop until we find the desired Observer
    for (list<Observer *>::iterator iter = observers.begin();
         iter != observers.end() && !found; iter++)
    {
        Observer *temp = *iter;
        if (temp == ob)  // if we found observer which 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
    for (list<Observer *>::iterator iter = observers.begin(); 
         iter != observers.end(); iter++)
    {
        Observer *temp = *iter;
        temp->Update(); // AddCourse, then Release Observer   
        // State 1 means we added course, got off waitlist 
        // (waitlist had a Release), so update the iterator
        if (temp->GetState() == 1)
            iter = newIter;  // update the iterator since
    }                        // erase() invalidated this one
    if (observers.size() != 0)
    {   // Update last item on waitlist
        Observer *last = *newIter; 
        last->Update();
    }
}

在上述的Subject成员函数中,让我们从检查void Subject::Register(Observer *)方法开始。在这里,我们只是将指定的Observer *添加到我们的 STL 观察者列表中(并增加观察者数量的计数)。

接下来,让我们通过审查void Subject::Release(Observer *)来考虑Register()的反向操作。在这里,我们遍历观察者列表,直到找到我们正在寻找的观察者。然后我们在当前项目上调用list::erase(),将我们的found标志设置为true(以退出循环),并减少观察者的数量。还要注意,我们保存了list::erase()的返回值,这是更新的(有效的)观察者列表的迭代器。循环中的迭代器iter在我们调用list::erase()时已经失效。我们将这个修改后的迭代器保存在一个数据成员newIter中,以便稍后访问它。

最后,让我们来看看Subject中的Notify()方法。一旦Subject中有状态变化,就会调用这个方法。目标是Update()所有Subject观察者列表上的观察者。为了做到这一点,我们逐个查看我们的列表。我们使用Observer *temp = *iter;使用列表迭代器逐个获取Observer。我们使用temp->Update();在当前Observer上调用Update()。我们可以通过检查观察者的状态if (temp->GetState() == 1)来判断给定Observer的更新是否成功。状态为1时,我们知道观察者的操作将导致我们刚刚审查的Release()函数被调用。因为Release()中使用的list::erase()已经使迭代器无效,所以我们现在使用iter = newIter;获取正确和修订后的迭代器。最后,在循环外,我们在观察者列表中的最后一项上调用Update()

从 Subject 和 Observer 派生具体类

让我们继续向前推进这个例子,看看我们从SubjectObserver派生的具体类。让我们从Course开始:

class Course: public Subject  
{ // inherits Observer list; represents Students on wait-list
private:
    char *title;
    int number, totalStudents; // course num; total students
    Student *students[MAXSTUDENTS];  // students cur. enrolled
public:
    Course(const char *title, int num): number(num)
    {
        this->title = new char[strlen(title) + 1];
        strcpy(this->title, title);
        totalStudents = 0;
        for (int i = 0; i < MAXSTUDENTS; i++)
            students[i] = 0; 
    }
    virtual ~Course() { delete title; } // There's more work!
    int GetCourseNum() const { return number; }
    const char *GetTitle() const { return title; }
    void Open() { SetState(1); Notify(); } 
    void PrintStudents();
};
bool Course::AddStudent(Student *s)
{  // Should also check Student isn't already added to Course.
    if (totalStudents < MAXSTUDENTS)  // course not full
    {
        students[totalStudents++] = s;
        return true;
    }
    else return false;
}
void Course::PrintStudents()
{
    cout << "Course: (" << GetTitle() << ") has the following
             students: " << endl;
    for (int i = 0; i < MAXSTUDENTS && students[i] != 0; i++)
    {
        cout << "\t" << students[i]->GetFirstName() << " ";
        cout << students[i]->GetLastName() << endl;
    }
}

在上述的Course类中,我们包括了课程标题和编号的数据成员,以及当前已注册学生的总数。我们还有我们当前已注册学生的列表,用Student *students[MAXNUMBERSTUDENTS];表示。此外,请记住我们从基类Subject继承了Observer的 STLlist。这个Observer实例列表将代表Course的等待列表中的Student实例。

Course类另外包括一个构造函数,一个虚析构函数和简单的访问函数。请注意,虚析构函数的工作比所示的更多 - 如果一个Course被销毁,我们必须首先记住从Course中删除(但不删除)Student实例。我们的bool Course::AddStudent(Student *)接口将允许我们向Course添加一个Student。当然,我们应该确保在这个方法的主体中Student尚未添加到Course中。

我们的void Course::Open();方法将在Course上调用,表示该课程现在可以添加学生。在这里,我们首先将状态设置为1(表示开放招生),然后调用Notify()。我们基类Subject中的Notify()方法循环遍历每个Observer,对每个观察者调用多态的Update()。每个观察者都是一个StudentStudent::Update()将允许等待列表上的每个Student尝试添加现在可以接收学生的Course。成功添加到课程的当前学生列表后,Student将请求在等待列表上释放其位置(作为Observer)。

接下来,让我们来看看我们从PersonObserver派生的具体类Student的类定义:

class Person { };  // Assume this is our typical Person class
class Student: public Person, public Observer
{
private:
    float gpa;
    const char *studentId;
    int currentNumCourses;
    Course *courses[MAXCOURSES]; // currently enrolled courses
    // Course we'd like to take - we're on the waitlist. 
    Course *waitList;// This is our Subject (specialized form)
public:
    Student();  // default constructor
    Student(const char *, const char *, char, const char *, 
            float, const char *, Course *);
    Student(const char *, const char *, char, const char *,
            float, const char *);
    Student(const Student &) = delete;  // Copies disallowed
    virtual ~Student();  
    void EarnPhD();
    float GetGpa() const { return gpa; }
    const char *GetStudentId() const { return studentId; }
    virtual void Print() const override;
    virtual void IsA() override;
    virtual void Update() override;
    virtual void Graduate();   // newly introduced virtual fn.
    bool AddCourse(Course *);
    void PrintCourses();
};

简要回顾上述Student类的类定义,我们可以看到这个类是通过多重继承从PersonObserver派生的。让我们假设我们的Person类就像我们过去多次使用的那样。

除了我们Student类的通常组件之外,我们还添加了数据成员Course *waitList;,它将模拟与我们的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. Let's look at those which may differ:
Student::Student(const char *fn, const char *ln, char mi,
                const char *t, float avg, const char *id,
                Course *c) : Person(fn, ln, mi, t), Observer()
{
    // Most data members are set as usual - see online code 
    waitList = c;      // Set waitlist to Course (Subject)
    c->Register(this); // Add the Student (Observer) to 
}                      // the Subject's list of Observers
bool Student::AddCourse(Course *c)
{ 
    // Should also check that Student isn't already in Course
    if (currentNumCourses < MAXCOURSES)
    {
        courses[currentNumCourses++] = c;  // set association
        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
        waitList = c;// set Student (Observer) link to Subject
        return false;
    }
}

让我们回顾之前列出的成员函数。由于我们已经习惯了Student类中大部分必要的组件和机制,我们将专注于新添加的Student方法,从一个替代构造函数开始。在这里,让我们假设我们像往常一样设置了大部分数据成员。这里的关键额外代码行是waitList = c;将我们的等待列表条目设置为所需的CourseSubject),以及c->Register(this);,其中我们将StudentObserver)添加到Subject的列表(课程的正式等待列表)。

接下来,在我们的bool Student::AddCourse(Course *)方法中,我们首先检查是否已超过最大允许的课程数。如果没有,我们将通过机制来添加关联,以在两个方向上链接StudentCourse。也就是说,courses[currentNumCourses++] = c;将学生当前的课程列表包含到新的Course的关联中,以及c->AddStudent(this);要求当前的CourseStudentthis)添加到其已注册学生列表中。

让我们继续审查Student的其余新成员函数:

void Student::Update()
{   // Course state changed to 'Open' so we can now add it.
    if (waitList->GetState() == 1)  
    {
        if (AddCourse(waitList))  // if success in Adding 
        {
            cout << GetFirstName() << " " << GetLastName();
            cout << " removed from waitlist and added to ";
            cout << waitList->GetTitle() << endl;
            SetState(1); // set Obser's state to "Add Success"
            // Remove Student from Course's waitlist
            waitList->Release(this); // Remove Obs from Subj
            waitList = 0;  // Set our link to Subject to Null
        }
    }
}
void Student::PrintCourses()
{
    cout << "Student: (" << GetFirstName() << " ";
    cout << GetLastName() << ") enrolled in: " << endl;
    for (int i = 0; i < MAXCOURSES && courses[i] != 0; i++)
        cout << "\t" << courses[i]->GetTitle() << endl;
}

继续我们之前提到的Student成员函数的其余部分,接下来,在我们的多态void Student::Update()方法中,我们进行了所需的等待列表课程添加。回想一下,当我们的SubjectCourse)上有状态变化时,Notify()将被调用。这样的状态变化可能是当一个Course开放注册,或者可能是在Student退出Course后现在存在新的空位可用的状态。Notify()然后在每个Observer上调用Update()。我们在Student中重写了Update()来获取CourseSubject)的状态。如果状态表明Course现在开放注册,我们尝试AddCourse(waitList);。如果成功,我们将StudentObserver)的状态设置为1添加成功),以表明我们在我们的Update()中取得了成功,这意味着我们已经添加了Course。接下来,因为我们已经将所需的课程添加到了我们当前的课程列表中,我们现在可以从Course的等待列表中移除自己。也就是说,我们将使用waitList->Release(this);将自己(Student)从SubjectCourse的等待列表)中移除。现在我们已经添加了我们想要的等待列表课程,我们还可以使用waitList = 0;来移除我们与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("Design Patterns in C++", 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, "555CU", 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, "55UD", c1);
    Student s5("Jiang", "Wu", 'Q', "Dr.", 3.8, "883TU", c1);
    cout << "Registration is Open. Waitlist Students to be
             added to Courses" << endl;
    // Sends a message to Students that Course is Open. 
    c1->Open();   // Students on wait-list 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 wait-list
    s4.AddCourse(c2);  
    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();
    delete c1;
    delete c2;
    delete c3;
    return 0;
}

回顾我们之前提到的main()函数,我们首先实例化了三个Course实例。接下来,我们实例化了五个Student实例,利用一个构造函数,允许我们在课程注册开始时提供每个Student想要添加的初始Course。请注意,这些StudentsObservers)将被添加到他们所需课程的等待列表(Subject)。在这里,一个SubjectCourse)将有一个希望在注册开放时添加课程的ObserversStudents)列表。

接下来,我们看到许多Student实例都希望的Course变为开放注册,使用c1->Open();进行注册。 Course::Open()Subject的状态设置为1,表示课程开放注册,然后调用Notify()。正如我们所知,Subject::Notify()将在Subject的观察者列表上调用Update()。在这里,初始等待列表的Course实例将被添加到学生的日程表中,并随后从Subject的等待列表中作为Observer被移除。

现在注册已经开放,每个Student将尝试以通常的方式添加更多课程,比如使用bool Student::AddCourse(Course *),比如s1.AddCourse(c2);。如果一个Course已满,该Student将被添加到Course的等待列表(作为继承的Subject的观察者列表)。记住,Course继承自Subject,它保留了对特定课程感兴趣的学生的列表(观察者的等待列表)。当Course状态变为新空间可用时,等待列表上的学生(观察者)将收到通知,并且每个StudentUpdate()方法随后将为该Student调用AddCourse()

一旦我们添加了各种课程,我们将看到每个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: (Design Patterns in C++) has the following students:
        Anne Chu
        Goeff Curt
        Jiang Wu
Student: (Anne Chu) enrolled in:
        C++
        Advanced C++
        Design Patterns in C++
Student: (Joley Putt) enrolled in:
        C++
        Advanced C++
Student: (Goeff Curt) enrolled in:
        C++
        Design Patterns in C++
Student: (Ling Mau) enrolled in:
        C++
        Advanced C++
Student: (Jiang Wu) enrolled in:
        C++
        Advanced C++
        Design Patterns in C++

我们现在已经看到了观察者模式的实现。我们已经将更通用的SubjectObserver类折叠到了我们习惯看到的类的框架中,即CoursePersonStudent。让我们现在简要回顾一下我们在模式方面学到的东西,然后继续下一章。

总结

在本章中,我们已经开始通过将我们的技能范围扩展到包括设计模式的利用,来使自己成为更好的 C++程序员。我们的主要目标是通过应用常见的设计模式来解决重复类型的编程问题,从而使您能够使用经过验证的解决方案。

我们首先理解了设计模式的目的,以及在我们的代码中使用它们的优势。然后,我们具体理解了观察者模式的前提以及它对面向对象编程的贡献。最后,我们看了一下如何在 C++中实现观察者模式。

利用常见的设计模式,比如观察者模式,将帮助您更轻松地解决其他程序员理解的重复类型的编程问题。面向对象编程的一个关键原则是尽可能地重用组件。通过利用设计模式,您将为更复杂的编程技术做出可重用的解决方案。

我们现在准备继续前进,进入我们下一个设计模式[第十七章](B15702_17_Final_NM_ePub.xhtml#_idTextAnchor649),实现工厂模式。向我们的技能集合中添加更多的模式将使我们成为更多才多艺和受人重视的程序员。让我们继续前进!

问题

  1. 使用本章示例中的在线代码作为起点,并使用之前练习的解决方案(问题 3,[第十章](B15702_10_Final_NM_ePub.xhtml#_idTextAnchor386),实现关联、聚合和组合):
  1. 实现(或修改之前的)Student::DropCourse()。当一个Student退课时,这个事件将导致Course的状态变为状态2新空间可用。状态改变后,CourseSubject)上的Notify()将被调用,然后Update()将更新观察者列表(等待列表上的学生)。间接地,Update()将允许等待列表上的Student实例,如果有的话,现在添加这门Course

  2. 最后,在DropCourse()中,记得从学生当前的课程列表中移除已经退课的课程。

  1. 你能想象其他容易融入观察者模式的例子吗?

第十七章:应用工厂模式

本章将继续扩展您的 C++编程技能,超越核心面向对象编程概念,目标是使您能够利用常见的设计模式解决重复出现的编码问题。我们知道,应用设计模式可以增强代码维护性,并为潜在的代码重用提供途径。

继续演示和解释流行的设计模式和习语,并学习如何在 C++中有效实现它们,我们将继续我们的探索,工厂模式,更准确地说是工厂方法模式

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

  • 理解工厂方法模式及其对面向对象编程的贡献

  • 理解如何使用对象工厂和不使用对象工厂来实现工厂方法模式;比较对象工厂和抽象工厂

在本章结束时,您将理解流行的工厂方法模式。我们将在 C++中看到这种模式的两个示例实现。将额外的核心设计模式添加到您的编程技能中,将使您成为一个更复杂和有价值的程序员。

让我们通过研究这种常见的设计模式,工厂方法模式,来增加我们的编程技能。

技术要求

本章示例程序的完整代码可在以下 GitHub 链接找到:github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter17。每个完整的示例程序都可以在 GitHub 存储库中的适当章节标题(子目录)下找到,文件名与所在章节的章节号对应,后跟破折号,再跟上所在章节中的示例编号。例如,本章的第一个完整程序可以在子目录Chapter17中的名为Chp17-Ex1.cpp的文件中找到,位于上述 GitHub 目录下。

本章的 CiA 视频可在以下链接观看:bit.ly/2PdlSLB

理解工厂方法模式

工厂模式工厂方法模式是一种创建型设计模式,允许创建对象而无需指定将实例化的确切(派生)类。工厂方法模式提供了一个创建对象的接口,但允许创建方法内的细节决定实例化哪个(派生)类。

工厂方法模式也被称为虚拟构造函数。就像虚拟析构函数具有特定的析构函数(这是销毁序列的入口点)在运行时通过动态绑定确定一样,虚拟构造函数的概念是所需的对象在运行时统一确定。

使用工厂方法模式,我们将指定一个抽象类(或接口)来收集和指定我们希望创建的派生类的一般行为。在这种模式中,抽象类或接口被称为产品。然后我们创建我们可能想要实例化的派生类,覆盖任何必要的抽象方法。各种具体的派生类被称为具体产品

然后我们指定一个工厂方法,其目的是为了统一创建具体产品的实例。工厂方法可以放在抽象产品类中,也可以放在单独的对象工厂类中;对象工厂代表一个负责创建具体产品的类。如果将工厂方法放在抽象产品类中,那么这个工厂(创建)方法将是静态的,如果放在对象工厂类中,那么它可以选择是静态的。工厂方法将根据一致的输入参数列表决定要制造哪个具体产品,然后返回一个通用的产品指针给具体产品。多态方法可以应用于新创建的对象,以引出其特定的行为。

工厂方法模式将包括以下内容:

  • 一个抽象产品类(或接口)。

  • 多个具体产品派生类。

  • 在抽象产品类或单独的对象工厂类中的工厂方法。工厂方法将具有一个统一的接口来创建任何具体产品类型的实例。

  • 具体产品将由工厂方法作为通用产品实例返回。

请记住,工厂方法(无论是在对象工厂中)都会生产产品。工厂方法提供了一种统一的方式来生产许多相关的产品类型。

让我们继续看两个工厂方法模式的示例实现。

实现工厂方法模式

我们将探讨工厂方法模式的两种常见实现。每种实现都有设计权衡,值得讨论!

让我们从将工厂方法放在抽象产品类中的技术开始。

包括工厂方法在产品类中

要实现工厂方法模式,我们首先需要创建我们的抽象产品类以及我们的具体产品类。这些类定义将为我们构建模式奠定基础。

在我们的例子中,我们将使用一个我们习惯看到的类Student来创建我们的产品。然后我们将创建具体的产品类,即GradStudentUnderGradStudentNonDegreeStudent。我们将在我们的产品(Student)类中包含一个工厂方法,以创建任何派生产品类型的统一接口。

我们将通过添加类来区分学生的教育学位目标,为我们现有的Student应用程序补充我们的框架。新的组件为大学入学(新生入学)系统提供了基础。

假设我们的应用程序不是实例化一个Student,而是实例化各种类型的Student - GradStudentUnderGradStudentNonDegreeStudent - 基于他们的学习目标。Student类将包括一个抽象的多态Graduate()操作;每个派生类将使用不同的实现重写这个方法。例如,寻求博士学位的GradStudent可能在GradStudent::Graduate()方法中有更多与学位相关的标准要满足,而其他Student的专业化可能不需要。他们可能需要验证学分小时数,验证通过的平均成绩,以及验证他们的论文是否被接受。相比之下,UnderGradStudent可能只需要验证他们的学分小时数和总体平均成绩。

抽象产品类将包括一个静态方法MatriculateStudent(),作为创建各种类型学生(具体产品类型)的工厂方法。

定义抽象产品类

让我们首先看一下我们的工厂方法实现的机制,从检查我们的抽象产品类Student的定义开始。这个例子可以在我们的 GitHub 存储库中找到一个完整的程序:

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/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;
    char *currentCourse;
    const char *studentId;
public:
    Student();  // default constructor
    Student(const char *, const char *, char, const char *,
            float, const char *, const char *);
    Student(const Student &);  // copy constructor
    virtual ~Student();  // destructor
    float GetGpa() const { return gpa; }
    const char *GetCurrentCourse() const 
       { return currentCourse; }
    const char *GetStudentId() const { return studentId; }
    void SetCurrentCourse(const char *); // prototype only
    virtual void Print() const override;
    virtual const char *IsA() override { return "Student"; }
    virtual void Graduate() = 0;  // Now Student is abstract
    // Creates a derived Student type based on degree sought
    static Student *MatriculateStudent(const char *,
       const char *, const char *, char, const char *,
       float, const char *, const char *);
};
// 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:
    char *degree;  // PhD, MS, MA, etc.
public:
    GradStudent() { degree = 0; }  // default constructor
    GradStudent(const char *, const char *, const char *,
       char, const char *, float, const char *, const char *);
    GradStudent(const GradStudent &);  // copy constructor
    virtual ~GradStudent() { delete degree; } // destructor
    void EarnPhD();
    virtual const char *IsA() override 
       { return "GradStudent"; }
    virtual void Graduate() override;
};
// Assume alternate and copy constructors are implemented
// as expected. See online code for full implementation.
void GradStudent::EarnPhD()
{
    if (!strcmp(degree, "PhD")) // only PhD candidates can 
        ModifyTitle("Dr.");     // EarnPhd(), not MA and MS 
}                               // candidates
void GradStudent::Graduate()
{   // Here, we can check that the required number 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:
    char *degree;  // BS, BA, etc
public:
    UnderGradStudent() { degree = 0; }  // default constructor
    UnderGradStudent(const char *, const char *, const char *,
       char, const char *, float, const char *, const char *);
    UnderGradStudent(const UnderGradStudent &);  
    virtual ~UnderGradStudent() { delete degree; } 
    virtual const char *IsA() override 
        { return "UnderGradStudent"; }
    virtual void Graduate() override;
};
// Assume alternate and copy constructors are implemented
// as expected. See online code for full implementation.
void UnderGradStudent::Graduate()
{   // Verify that number 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中定义它来概括这个属性。虽然我们可以引入一个共享的基类DegreeSeekingStudent,用于收集UnderGradStudentGradStudent的共同点,但这种细粒度的层次几乎是不必要的。这里的重复是一个设计权衡。

这两个兄弟类之间的关键区别是重写的Graduate()方法。我们可以想象,本科生毕业的清单可能与研究生不同。因此,我们可以合理地区分这两个类。否则,它们基本上是一样的。

现在,让我们来看看我们的下一个具体产品类,NonDegreeStudent

class NonDegreeStudent: public Student
{
public:
    NonDegreeStudent() { }  // default constructor
    NonDegreeStudent(const char *, const char *, char, 
       const char *, float, const char *, const char *);
    NonDegreeStudent(const NonDegreeStudent &s): Student(s){ }  
    virtual ~NonDegreeStudent() { } // destructor
    virtual const char *IsA() override 
       { return "NonDegreeStudent"; }
    virtual void Graduate() override;
};
// Assume alternate constructor is implemented as expected.
// Notice copy constructor is inline above (as is default)
// 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类,我们注意到这个具体产品与它的兄弟类相似。然而,在这个类中没有学位数据成员。此外,重写的Graduate()方法需要进行的验证比GradStudentUnderGradStudent类中的重写版本少。

检查工厂方法定义

接下来,让我们来看看我们的工厂方法,即我们产品(Student)类中的静态方法:

// Creates a Student based on the degree they seek
// This is a static method of Student (keyword in prototype)
Student *Student::MatriculateStudent(const char *degree, 
    const char *fn, const char *ln, char mi, const char *t,
    float avg, const char *course, const char *id)
{
    if (!strcmp(degree, "PhD") || !strcmp(degree, "MS") 
        || !strcmp(degree, "MA"))
        return new GradStudent(degree, fn, ln, mi, t, avg,
                               course, id);
    else if (!strcmp(degree, "BS") || !strcmp(degree, "BA"))
        return new UnderGradStudent(degree, fn, ln, mi, t,
                                    avg, course, id);
    else if (!strcmp(degree, "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];
    // Student is now abstract....cannot instantiate directly
    // Use the 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 (int i = 0; i < MAX; i++)
    {
       scholars[i]->Graduate();
       scholars[i]->Print();
    }
    for (int i = 0; i < MAX; i++)
       delete scholars[i];   // engage virtual dest. 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++

前面实现的一个优点是它非常直接。然而,我们可以看到抽象产品类包含工厂方法(用于构造派生类类型)和派生具体产品之间存在着紧密的耦合。然而,在面向对象编程中,基类通常不会了解任何派生类型。

这种紧密耦合实现的一个缺点是,抽象产品类必须在其静态创建方法MatriculateStudent()中包含每个后代的实例化手段。添加新的派生类现在会影响抽象基类的定义 - 需要重新编译。如果我们没有访问这个基类的源代码怎么办?有没有一种方法来解耦工厂方法和工厂方法将创建的产品之间存在的依赖关系?是的,有一种替代实现。

让我们现在来看一下工厂方法模式的另一种实现。我们将使用一个对象工厂类来封装我们的MatriculateStudent()工厂方法,而不是将这个方法包含在抽象产品类中。

创建一个对象工厂类来封装工厂方法

对于工厂方法模式的另一种实现,我们将对抽象产品类进行轻微偏离其先前的定义。然而,我们将像以前一样创建我们的具体产品类。这些类定义将再次开始构建我们模式的框架。

在我们修改后的示例中,我们将再次将我们的产品定义为Student类。我们还将再次派生具体的产品类GradStudentUnderGradStudentNonDegreeStudent。然而,这一次,我们不会在我们的产品(Student)类中包含工厂方法。相反,我们将创建一个单独的对象工厂类,其中将包括工厂方法。与之前一样,工厂方法将具有统一的接口来创建任何派生产品类型。工厂方法不需要是静态的,就像在我们上一次的实现中一样。

我们的对象工厂类将包括MatriculateStudent()作为工厂方法来创建各种Student实例(具体产品类型)。

定义不包含工厂方法的抽象产品类

让我们来看看我们对工厂方法模式的替代实现的机制,首先检查我们的抽象产品类Student的定义。这个例子可以在我们的 GitHub 存储库中找到一个完整的程序:

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/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;
    char *currentCourse;
    const char *studentId;
public:
    Student();  // default constructor
    Student(const char *, const char *, char, const char *,
            float, const char *, const char *);
    Student(const Student &);  // copy constructor
    virtual ~Student();  // destructor
    float GetGpa() const { return gpa; }
    const char *GetCurrentCourse() const 
       { return currentCourse; }
    const char *GetStudentId() const { return studentId; }
    void SetCurrentCourse(const char *); // prototype only
    virtual void Print() const override;
    virtual const char *IsA() override { return "Student"; }
    virtual void Graduate() = 0;  // Student is abstract
};

在我们上述的Student类定义中,与我们之前的实现的关键区别是,这个类不再包含一个静态的MatriculateStudent()方法作为工厂方法。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 char *degree, 
       const char *fn, const char *ln, char mi, const char *t,
       float avg, const char *course, const char *id)
    {
        if (!strcmp(degree, "PhD") || !strcmp(degree, "MS") 
            || !strcmp(degree, "MA"))
            return new GradStudent(degree, fn, ln, mi, t, 
                                   avg, course, id);
        else if (!strcmp(degree, "BS") || 
                 !strcmp(degree, "BA"))
            return new UnderGradStudent(degree, fn, ln, mi, t,
                                        avg, course, id);
        else if (!strcmp(degree, "None"))
            return new NonDegreeStudent(fn, ln, mi, t, avg,
                                        course, id);
    }
};

在上述的对象工厂类定义(StudentFactory类)中,我们最少包括工厂方法规范,即MatriculateStudent()。该方法与我们之前的示例中的方法非常相似。然而,通过在对象工厂中捕获具体产品的创建,我们已经解耦了抽象产品和工厂方法之间的关系。

将模式组件结合在一起

接下来,让我们将我们的main()函数与我们原始示例的函数进行比较,以可视化我们修改后的组件如何实现工厂方法模式:

int main()
{
    Student *scholars[MAX];
    // 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 (int i = 0; i < MAX; i++)
    {
       scholars[i]->Graduate();
       scholars[i]->Print();
    }
    for (int i = 0; i < MAX; i++)
       delete scholars[i];   // engage virtual dest. sequence
    return 0;
}

考虑到我们之前列出的main()函数,我们可以看到我们再次创建了指向抽象产品类型(Student)的指针数组。然后,我们实例化了一个可以创建各种具体产品类型的Student实例的对象工厂,即StudentFactory *UofD = new StudentFactory();。与之前的示例一样,对象工厂根据每个学生所寻求的学位类型创建了每个派生类型的GradStudentUnderGradStudentNonDegreeStudent的一个实例。main()中的其余代码与我们之前的示例中一样。

我们的输出将与我们上一个示例相同。

对象工厂类的优势在于,我们已经从抽象产品类(在工厂方法中)中移除了对象创建的依赖,并知道派生类类型是什么。也就是说,如果我们扩展层次结构以包括新的具体产品类型,我们不必修改抽象产品类。当然,我们需要访问修改我们的对象工厂类StudentFactory,以增强我们的MatriculateStudent()工厂方法。

与这种实现相关的一种模式,抽象工厂,是另一种模式,它允许具有类似目的的单个工厂被分组在一起。抽象工厂可以被指定为提供统一类似对象工厂的方法;它是一个将创建工厂的工厂,为我们原始模式添加了另一层抽象。

我们现在已经看到了工厂方法模式的两种实现。我们已经将产品和工厂方法的概念融入了我们习惯看到的类框架中,即StudentStudent的派生类。在继续前往下一章之前,让我们简要地回顾一下我们在模式方面学到的东西。

总结

在本章中,我们继续努力成为更好的 C++程序员,扩展我们对设计模式的知识。特别是,我们从概念上和通过两种常见的实现探讨了工厂方法模式。我们的第一个实现包括将工厂方法放在我们的抽象产品类中。我们的第二个实现通过添加一个对象工厂类来包含我们的工厂方法,消除了我们的抽象产品和工厂方法之间的依赖关系。我们还非常简要地讨论了抽象工厂的概念。

利用常见的设计模式,比如工厂方法模式,将帮助您更轻松地解决其他程序员理解的重复类型的编程问题。通过利用核心设计模式,您将为使用更复杂的编程技术提供了被理解和可重用的解决方案。

我们现在准备继续前进到我们的下一个设计模式第十八章实现适配器模式。向我们的技能集合中添加更多的模式使我们成为更多才多艺和有价值的程序员。让我们继续前进吧!

问题

  1. 使用问题 1中的解决方案,第八章掌握抽象类
  1. 实现工厂方法模式来创建各种形状。您已经创建了一个名为 Shape 的抽象基类,以及派生类,比如 Rectangle、Circle、Triangle,可能还有 Square。

  2. 选择在Shape中将工厂方法实现为静态方法,或者作为ShapeFactory类中的方法(如果需要的话引入后者类)。

  1. 您能想象其他哪些例子可能很容易地融入工厂方法模式?

第十八章:应用适配器模式

本章将扩展我们的探索,超越核心面向对象编程概念,旨在使您能够利用常见的设计模式解决重复出现的编码问题。在编码解决方案中应用设计模式不仅可以提供优雅的解决方案,还可以增强代码的维护性,并为代码重用提供潜在机会。

我们将学习如何在 C++中有效实现适配器模式

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

  • 理解适配器模式及其对面向对象编程的贡献

  • 理解如何在 C++中实现适配器模式

本章结束时,您将了解基本的适配器模式以及如何使用它来允许两个不兼容的类进行通信,或者将不合适的代码升级为设计良好的面向对象代码。向您的知识库中添加另一个关键设计模式将使您的编程技能得到提升,帮助您成为更有价值的程序员。

让我们通过研究另一个常见的设计模式,即适配器模式,来增加我们的编程技能。

技术要求

完整程序示例的在线代码可以在以下 GitHub URL 找到:github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter18。每个完整程序示例都可以在 GitHub 存储库中的适当章节标题(子目录)下找到,文件名与所在章节编号相对应,后跟该章节中的示例编号。例如,本章的第一个完整程序可以在上述 GitHub 目录中的Chapter18子目录中的名为Chp18-Ex1.cpp的文件中找到。

本章的 CiA 视频可在以下链接观看:bit.ly/2Pfg9VA

理解适配器模式

适配器模式是一种结构设计模式,提供了一种将现有类的不良接口转换为另一个类所期望的接口的方法。适配器类将成为两个现有组件之间通信的链接,调整接口以便两者可以共享和交换信息。适配器允许两个或更多类一起工作,否则它们无法这样做。

理想情况下,适配器不会添加功能,而是会添加所需的接口以便允许一个类以预期的方式使用,或者使两个不兼容的类相互通信。在其最简单的形式中,适配器只是将现有的类转换为支持 OO 设计中可能指定的预期接口。

适配器可以与其提供自适应接口的类相关联或派生自该类。如果使用继承,适合使用私有或受保护的基类来隐藏底层实现。如果适配器类与具有不良接口的类相关联,适配器类中的方法(具有新接口)将仅将工作委托给其关联类。

适配器模式还可以用于为一系列函数或其他类添加 OO 接口(即在一系列函数或其他类周围包装 OO 接口),从而使各种现有组件在 OO 系统中更自然地被利用。这种特定类型的适配器称为extern C,以允许链接器解析两种语言之间的链接约定。

利用适配器模式有好处。适配器允许通过提供共享接口来重用现有代码,以便否则无关的类进行通信。面向对象的程序员现在可以直接使用适配器类,从而更容易地维护应用程序。也就是说,大多数程序员的交互将是与设计良好的适配器类,而不是与两个或更多奇怪的组件。使用适配器的一个小缺点是由于增加了代码层,性能略有下降。然而,通常情况下,通过提供清晰的接口来支持它们的交互来重用现有组件是一个成功的选择,尽管会有(希望是小的)性能折衷。

适配器模式将包括以下内容:

  • 一个Adaptee类,代表具有可取用功能的类,但以不合适或不符合预期的形式存在。

  • 一个适配器类,它将适配 Adaptee 类的接口以满足所需接口的需求。

  • 一个目标类,代表应用程序所需接口的具体接口。一个类可以既是目标又是适配器。

  • 可选的客户端类,它们将与目标类交互,以完全定义正在进行的应用程序。

适配器模式允许重用合格的现有组件,这些组件不符合当前应用程序设计的接口需求。

让我们继续看适配器模式的两个常见应用;其中一个将有两种潜在的实现方式。

实现适配器模式

让我们探讨适配器模式的两种常见用法。即,创建一个适配器来弥合两个不兼容的类接口之间的差距,或者创建一个适配器来简单地用 OO 接口包装一组现有函数。

我们将从使用适配器提供连接器来连接两个(或更多)不兼容的类开始。Adaptee将是一个经过充分测试的类,我们希望重用它(但它具有不理想的接口),Target类将是我们在进行中的应用程序的 OO 设计中指定的类。现在让我们指定一个适配器,以使我们的 Adaptee 能够与我们的 Target 类一起工作。

使用适配器为现有类提供必要的接口

要实现适配器模式,我们首先需要确定我们的 Adaptee 类。然后我们将创建一个适配器类来修改 Adaptee 的接口。我们还将确定我们的 Target 类,代表我们需要根据我们的 OO 设计来建模的类。有时,我们的适配器和目标可能会合并成一个单一的类。在实际应用中,我们还将有客户端类,代表着最终应用程序中的所有类。让我们从 Adaptee 和 Adapter 类开始,因为这些类定义将为我们构建模式奠定基础。

在我们的例子中,我们将指定我们习惯看到的 Adaptee 类为Person。我们将想象我们的星球最近意识到许多其他能够支持生命的系外行星,并且我们已经与每个文明友好地结盟。进一步想象,地球上的各种软件系统希望欢迎和包容我们的新朋友,包括RomulansOrkans,我们希望调整一些现有软件以轻松适应我们系外行星邻居的新人口统计。考虑到这一点,我们将通过创建一个适配器类Humanoid来将我们的Person类转换为包含更多系外行星术语。

在我们即将实现的代码中,我们将使用私有继承来从Person(被适配者)继承Humanoid(适配器),从而隐藏被适配者的底层实现。我们也可以将Humanoid关联到Person(这也是我们将在本节中审查的一种实现)。然后,我们可以在我们的层次结构中完善一些Humanoid的派生类,比如OrkanRomulanEarthling,以适应手头的星际应用。OrkanRomulanEarthling类可以被视为我们的目标类,或者我们的应用将实例化的类。我们选择将我们的适配器类Humanoid设为抽象,以便它不能直接实例化。因为我们的具体派生类(目标类)可以在我们的应用程序(客户端)中由它们的抽象基类类型(Humanoid)进行泛化,所以我们也可以将Humanoid视为目标类。也就是说,Humanoid可以被视为主要是一个适配器,但次要是一个泛化的目标类。

我们的各种客户端类可以利用Humanoid的派生类,创建每个具体后代的实例。这些实例可以存储在它们自己的专门类型中,或者使用Humanoid指针进行泛型化。我们的实现是对广泛使用的适配器设计模式的现代化改进。

指定被适配者和适配器(私有继承技术)

让我们来看看我们的适配器模式的第一个用法的机制,首先回顾我们的被适配者类Person的定义。这个例子可以在我们的 GitHub 存储库中找到一个完整的程序。

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter18/Chp18-Ex1.cpp

// Person is the Adaptee class; the class requiring adaptation
class Person
{
private:
    char *firstName, *lastName, *title, *greeting;
    char middleInitial;
protected:
    void ModifyTitle(const char *);  
public:
    Person();   // default constructor
    Person(const char *, const char *, char, const char *);  
    Person(const Person &);  // copy constructor
    Person &operator=(const Person &); // assignment operator
    virtual ~Person();  // destructor
    const char *GetFirstName() const { return firstName; }  
    const char *GetLastName() const { return lastName; }    
    const char *GetTitle() const { return title; }
    char GetMiddleInitial() const { return middleInitial; }
    void SetGreeting(const char *);
    virtual const char *Speak() { return greeting; }
    virtual void Print();
};
// 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 char *t) { ModifyTitle(t); }
public:
    Humanoid();   
    Humanoid(const char *, const char *, const char *,
             const char *);
    Humanoid(const Humanoid &h) : Person(h) { }  
    Humanoid &operator=(const Humanoid &h) 
        { return (Humanoid &) Person::operator=(h); }
    virtual ~Humanoid() { }  
    const char *GetSecondaryName() const 
        { return GetFirstName(); }  
    const char *GetPrimaryName() const 
        { return GetLastName(); } 
    // scope resolution needed in method to avoid recursion 
    const char *GetTitle() const { return Person::GetTitle();}
    void SetSalutation(const char *m) { SetGreeting(m); }
    virtual void GetInfo() { Print(); }
    virtual const char *Converse() = 0;  // abstract class
};
Humanoid::Humanoid(const char *n2, const char *n1, 
    const char *planetNation, const char *greeting):
    Person(n2, n1, ' ', planetNation)
{
    SetGreeting(greeting);
}
const char *Humanoid::Converse()  // default definition for  
{                           // pure virtual function - unusual                           
    return Speak();
}

在上述的Humanoid类中,我们的目标是提供一个适配器,以满足我们星际应用所需的接口。我们只需使用私有继承,将HumanoidPerson派生,将Person中的公共接口隐藏在Humanoid的范围之外。我们知道目标应用(客户端)不希望Person中的公共接口被Humanoid的各种子类型实例使用。请注意,我们并没有添加功能,只是在适配接口。

然后,我们注意到Humanoid中引入的公共方法,为目标类提供了所需的接口。这些接口的实现通常很简单。我们只需调用Person中定义的继承方法,就可以轻松完成手头的任务(但使用了不可接受的接口)。例如,我们的Humanoid::GetPrimaryName()方法只是调用Person::GetLastName();来完成任务。然而,GetPrimaryName()可能更多地代表适当的星际术语,而不是Person::GetLastName()。我们可以看到Humanoid是作为Person的适配器。

请注意,在Humanoid方法中调用Person基类方法时,不需要在调用前加上Person::(除非Humanoid方法调用Person中同名的方法,比如GetTitle())。Person::的作用域解析用法避免了这些情况中的潜在递归。

我们还注意到Humanoid引入了一个抽象的多态方法(即纯虚函数),其规范为virtual const char *Converse() = 0;。我们已经做出了设计决策,即只有Humanoid的派生类才能被实例化。尽管如此,我们理解公共的派生类仍然可以被其基类类型Humanoid收集。在这里,Humanoid主要作为适配器类,其次作为一个目标类,提供一套可接受的接口。

请注意,我们的纯虚函数virtual const char *Converse() = 0;包括一个默认实现。这是罕见的,但只要实现不是内联写的,就是允许的。在这里,我们利用机会通过简单调用Person::Speak()来为Humanoid::Converse()指定默认行为。

从适配器派生具体类

接下来,让我们扩展我们的适配器(Humanoid)并看看我们的一个具体的、派生的目标类Orkan

class Orkan: public Humanoid
{
public:
    Orkan();   // default constructor
    Orkan(const char *n2, const char *n1, const char *t): 
       Humanoid(n2, n1, t, "Nanu nanu") { }
    Orkan(const Orkan &h) : Humanoid(h) { }  
    Orkan &operator=(const Orkan &h) 
        { return (Orkan &) Humanoid::operator=(h); }
    virtual ~Orkan() { }  
    virtual const char *Converse() override;  
};
const char *Orkan::Converse()  // Must override to make
{                              // Orkan a concrete class
    return Humanoid::Converse(); // use scope resolution to
}                                // avoid recursion

在我们前面提到的Orkan类中,我们使用公共继承来从Humanoid派生OrkanOrkan 是一个 Humanoid。因此,Humanoid中的所有公共接口都对Orkan实例可用。请注意,我们的替代构造函数将默认问候消息设置为"Nanu nanu",符合Orkan方言。

因为我们希望Orkan是一个具体的、可实例化的类,所以我们必须重写Humanoid::Converse()并在Orkan类中提供一个实现。然而,请注意,Orkan::Converse()只是调用了Humanoid::Converse();。也许Orkan认为其基类中的默认实现是可以接受的。请注意,我们在Orkan::Converse()方法中使用Humanoid::作用域解析来限定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 constructor
    Romulan(const char *n2, const char *n1, const char *t): 
        Humanoid(n2, n1, t, "jolan'tru") { }
    Romulan(const Romulan &h) : Humanoid(h) { } 
    Romulan &operator=(const Romulan &h) 
        { return (Romulan &) Humanoid::operator=(h); }
    virtual ~Romulan() { }  
    virtual const char *Converse() override;  
};
const char *Romulan::Converse()   // Must override to make
{                                 // Romulan a concrete class
    return Humanoid::Converse();   // use scope resolution to
}                                  // avoid recursion                  

快速看一下前面提到的Romulan类,我们注意到这个具体的目标与其兄弟类Orkan相似。我们注意到传递给我们基类构造函数的默认问候消息是"jolan'tru",以反映Romulan方言。虽然我们可以使Romulan::Converse()的实现更加复杂,但我们选择不这样做。我们可以快速理解这个类的全部范围。

接下来,让我们看一下我们的第三个目标类Earthling

class Earthling: public Humanoid
{
public:
    Earthling();   // default constructor
    Earthling(const char *n2, const char *n1, const char *t):
        Humanoid(n2, n1, t, "Hello") { }
    Earthling(const Romulan &h) : Humanoid(h) { }  
    Earthling &operator=(const Earthling &h) 
        { return (Earthling &) Humanoid::operator=(h); }
    virtual ~Earthling() { }  
    virtual const char *Converse() override;  
};
const char *Earthling::Converse()   // Must override to make
{                                // Earthling a concrete class  
    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 the generic list
    allies.push_back(o1);
    allies.push_back(r1);
    allies.push_back(e1);
    // Create a list iterator; set to first item in the list
    list <Humanoid *>::iterator listIter = allies.begin();
    while (listIter != allies.end())
    {
        (*listIter)->GetInfo();
        cout << (*listIter)->Converse() << endl;
        listIter++;
    }
    // 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()函数,我们首先创建一个STL list of Humanoid指针,使用list<Humanoid *> allies;。然后,我们实例化一个OrkanRomulan和一个Earthling,并使用allies.push_back()将它们添加到列表中。再次使用STL,我们接下来创建一个列表迭代器,以遍历指向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 之间的关系。具体的派生类(Targets)仍将像以前一样从 Adapter 派生。

这是我们 Adapter 类Humanoid的另一种实现,使用 Adapter 和 Adaptee 之间的关联。虽然我们只会审查与我们最初的方法不同的代码部分,但完整的实现可以在我们的 GitHub 上找到作为一个完整的程序:

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/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;  // delegate all requests to assoc. object
protected:
    void SetTitle(const char *t) { life->ModifyTitle(t); }
public:
    Humanoid() { life = 0; }
    Humanoid(const char *, const char *, const char *, 
             const char *);
    Humanoid(const Humanoid &h);
    Humanoid &operator=(const Humanoid &);
    virtual ~Humanoid() { delete life; }  
    const char *GetSecondaryName() const 
        { return life->GetFirstName(); }
    const char *GetPrimaryName() const 
        { return life->GetLastName(); }    
    const char *GetTitle() const { return life->GetTitle(); }
    void SetSalutation(const char *m) { life->SetGreeting(m);}
    virtual void GetInfo() { life->Print(); }
    virtual const char *Converse() = 0;  // abstract class
};
Humanoid::Humanoid(const char *n2, const char *n1, 
          const char *planetNation, const char *greeting)
{
    life = new Person(n2, n1, ' ', planetNation);
    life->SetGreeting(greeting);
}
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());  
}
Humanoid &Humanoid::operator=(const Humanoid &h)
{
    if (this != &h)
        life->Person::operator=((Person &) h);
    return *this;
}
const char *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 方法)。

与我们最初的实现一样,我们从HumanoidOrkanRomulanEarthling)派生的类以相同的方式指定,我们的客户端也在main()函数中。

选择被适配者和适配器之间的关系

在选择适配器和被适配者之间的关系时,一个有趣的点是选择私有继承还是关联的关系,这取决于被适配者是否包含任何受保护的成员。回想一下,Person的原始代码包括一个受保护的ModifyTitle()方法。如果被适配者类中存在受保护的成员,私有基类实现允许在适配器类的范围内继续访问这些继承的受保护成员(也就是适配器的方法)。然而,使用基于关联的实现,被适配者(Person)中的受保护方法在适配器的范围内是无法使用的。为了使这个例子工作,我们需要将Person::ModifyTitle()移到公共访问区域。然而,修改被适配者类并不总是可能的,也不一定推荐。考虑到受保护成员的问题,我们最初使用私有基类的实现是更强大的实现,因为它不依赖于我们修改被适配者(Person)的类定义。

现在让我们简要地看一下适配器模式的另一种用法。我们将简单地使用一个适配器类作为包装类。我们将为一个本来松散排列的一组函数添加一个面向对象的接口,这些函数工作得很好,但缺乏我们的应用程序(客户端)所需的接口。

使用适配器作为包装器

作为适配器模式的另一种用法,我们将在一组相关的外部函数周围包装一个面向对象的接口。也就是说,我们将创建一个包装类来封装这些函数。

在我们的示例中,外部函数将代表一套现有的数据库访问函数。我们将假设核心数据库功能对于我们的数据类型(Person)已经经过了充分测试,并且已经被无问题地使用。然而,这些外部函数本身提供了一个不可取和意外的功能接口。

相反,我们将通过创建一个适配器类来封装这些外部函数的集体功能。我们的适配器类将是CitizenDataBase,代表了一个封装的方式,用于从数据库中读取和写入Person实例。我们现有的外部函数将为我们的CitizenDataBase成员函数提供实现。让我们假设在我们的适配器类中定义的面向对象的接口满足我们的面向对象设计的要求。

让我们来看看我们简单包装的适配器模式的机制,首先要检查提供数据库访问功能的外部函数。这个例子可以在我们的 GitHub 仓库中找到一个完整的程序:

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter18/Chp18-Ex3.cpp

// Assume Person class exists with its usual implementation
Person objectRead;  // holds the object from the current read
                    // to support a simulation of a DB read
void db_open(const char *dbName)
{   // Assume implementation exists
    cout << "Opening database: " << dbName << endl;
}
void db_close(const char *dbName)
{   // Assume implementation exists
    cout << "Closing database: " << dbName << endl;
}
Person &db_read(const char *dbName, const char *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;  // a non-stack instance for simulation
}
const char *db_write(const char *dbName, Person &data)
{   // Assume implementation exists
    const char *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 the undesired interface)
{
private:
    char *name;
public:
    // No default constructor (unusual)
    CitizenDataBase(const char *);
    CitizenDataBase(const CitizenDataBase &) = delete;
    CitizenDataBase &operator=(const CitizenDataBase &) 
                               = delete;  
    virtual ~CitizenDataBase();  
    Person &Read(const char *);
    const char *Write(Person &);
};
CitizenDataBase::CitizenDataBase(const char *n)
{
    name = new char [strlen(n) + 1];
    strcpy(name, n);
    db_open(name);   // call existing external function
}
CitizenDataBase::~CitizenDataBase()
{
    db_close(name);  // close database with external function
    delete name;
}
Person &CitizenDataBase::Read(const char *key)
{
    return db_read(name, key);   // call external function
}
const char *CitizenDataBase::Write(Person &data)
{
    return db_write(name, data);  // call external function
}

在我们上述的适配器类定义中,我们只是在CitizenDataBase类中封装了外部数据库功能。在这里,CitizenDataBase不仅是我们的适配器类,也是我们的目标类,因为它包含了我们手头应用程序(客户端)期望的接口。

现在,让我们来看看我们的main()函数,这是一个客户端的简化版本:

int main()
{
    const char *key;
    char name[] = "PersonData"; // name of database
    Person p1("Curt", "Jeffreys", 'M', "Mr.");
    Person p2("Frank", "Burns", 'W', "Mr.");
    Person p3;
    CitizenDataBase People(name);   // open requested 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++程序员的追求。我们已经在概念和多种实现中探讨了适配器模式。我们的第一个实现使用私有继承从被适配者类派生适配器。我们将适配器指定为抽象类,然后使用公共继承根据适配器类提供的接口引入了几个基于接口的目标类。我们的第二个实现则使用关联来建模适配器和被适配者之间的关系。然后我们看了一个适配器作为包装器的示例用法,简单地为现有基于函数的应用组件添加了面向对象的接口。

利用常见的设计模式,比如适配器模式,将帮助你更容易地重用现有的经过充分测试的代码部分,以一种其他程序员能理解的方式。通过利用核心设计模式,你将为更复杂的编程技术做出贡献,提供了被理解和可重用的解决方案。

我们现在准备继续前进,进入我们的下一个设计模式[第十九章],使用单例模式。增加更多的模式到我们的编程技能库中,使我们成为更多才多艺和有价值的程序员。让我们继续前进!

问题

  1. 使用本章中找到的适配器示例:
  1. 实现一个CitizenDataBase,用于存储各种类型的Humanoid实例(OrkanRomulanEarthling,也许还有Martian)。决定你是使用私有基类适配器-被适配者关系,还是适配器和被适配者之间的关联关系(提示:私有继承版本会更容易)。

  2. 注意CitizenDataBase处理Person实例,这个类是否可以原样用来存储各种类型的Humanoid实例,还是必须以某种方式进行适配?请记住,PersonHumanoid的基类(如果你选择了这种实现方式),但也要记住我们永远不能向上转型超出非公共继承边界。

  1. 你能想象哪些其他例子可能很容易地应用适配器模式?

第十九章:使用单例模式

本章将继续扩展您的 C++编程技能,超越核心面向对象编程概念,旨在让您能够利用核心设计模式解决重复出现的编码难题。在编码解决方案中使用设计模式不仅可以提供精炼的解决方案,还有助于更轻松地维护代码,并为代码重用提供潜在机会。

我们将学习如何在 C++中有效实现单例模式,这是下一个核心设计模式。

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

  • 理解单例模式及其对面向对象编程的贡献

  • 在 C++中实现单例模式(使用简单的对类方法和配对类方法的方法);使用注册表允许多个类利用单例模式

通过本章结束时,您将了解单例模式以及如何使用它来确保给定类型只能存在一个实例。将另一个核心设计模式添加到您的知识体系中,将进一步增强您的编程技能,帮助您成为更有价值的程序员。

通过研究另一个常见的设计模式,单例模式,来增强我们的编程技能。

技术要求

本章中完整程序示例的代码可以在以下 GitHub URL 找到:github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter19。每个完整程序示例都可以在 GitHub 存储库中的适当章节标题(子目录)下找到,文件名与当前章节号对应,后跟当前章节中的示例编号。例如,本章中的第一个完整程序可以在名为Chp19-Ex1.cpp的文件中的Chapter19子目录中找到上述 GitHub 存储库中。

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

理解单例模式

单例模式是一种创建型设计模式,它保证了一个类只会存在一个实例;该类型的两个或更多实例根本不可能同时存在。采用这种模式的类被称为单例

单例模式可以使用静态数据成员和静态方法来实现。这意味着单例将在全局范围内访问当前实例。这一影响起初似乎很危险;将全局状态信息引入代码是对单例模式的一种批评,有时会被认为是一种反模式。然而,通过对定义单例的静态数据成员使用访问区域的适当使用,我们可以坚持只使用当前类的适当静态方法访问单例(除了初始化),从而减轻这种潜在的模式问题。

该模式的另一个批评是它不是线程安全的。可能存在竞争条件,以进入创建单例实例的代码段。如果不保证对该关键代码区域的互斥性,单例模式将会破坏,允许多个这样的实例存在。因此,如果将使用多线程编程,必须使用适当的锁定机制来保护创建单例的关键代码区域。使用静态内存实现的单例存储在同一进程中的线程之间的共享内存中;有时会因为垄断资源而批评单例。

Singleton 模式可以利用多种实现技术。每种实现方式都必然会有利弊。我们将使用一对相关的类SingletonSingletonDestroyer来强大地实现该模式。虽然还有更简单、直接的实现方式(我们将简要回顾其中一种),但最简单的技术留下了 Singleton 可能不会被充分销毁的可能性。请记住,析构函数可能包括重要和必要的活动。

Singleton 通常具有长寿命;因此,在应用程序终止之前销毁 Singleton 是合适的。许多客户端可能有指向 Singleton 的指针,因此没有一个客户端应该删除 Singleton。我们将看到Singleton将是自行创建的,因此它应该理想地自行销毁(即通过其SingletonDestroyer的帮助)。因此,配对类方法虽然不那么简单,但将确保正确的Singleton销毁。请注意,我们的实现也将允许直接删除 Singleton;这是罕见的,但我们的代码也将处理这种情况。

带有配对类实现的 Singleton 模式将包括以下内容:

  • 一个代表实现 Singleton 概念所需的核心机制的Singleton类。

  • 一个SingletonDestroyer类,它将作为 Singleton 的辅助类,确保给定的 Singleton 被正确销毁。

  • 从 Singleton 派生的类,代表我们希望确保在特定时间只能创建一个其类型实例的类。这将是我们的目标类。

  • 可选地,目标类可以既从 Singleton 派生,又从另一个类派生,这个类可能代表我们想要专门化或简单包含的现有功能(即混入)。在这种情况下,我们将从一个特定于应用程序的类和 Singleton 类中继承。

  • 可选的客户端类,它们将与目标类交互,以完全定义手头的应用程序。

  • 或者,Singleton 也可以在目标类内部实现,将类的功能捆绑在一个单一类中。

  • 真正的 Singleton 模式可以扩展到允许创建多个(离散的)实例,但不是一个确定数量的实例。这是罕见的。

我们将专注于传统的 Singleton 模式,以确保在任何给定时间只存在一个类的实例。

让我们继续前进,首先检查一个简单的实现,然后是我们首选的配对类实现,Singleton 模式。

实现 Singleton 模式

Singleton 模式将用于确保给定类只能实例化该类的单个实例。然而,真正的 Singleton 模式还将具有扩展功能,以允许多个(但数量明确定义的)实例被创建。这种 Singleton 模式的罕见且不太为人所知的特殊情况。

我们将从一个简单的 Singleton 实现开始,以了解其局限性。然后我们将进一步实现 Singleton 的更强大的配对类实现,最常见的模式目标是只允许在任何给定时间内实例化一个目标类。

使用简单实现

为了实现一个非常简单的 Singleton,我们将使用一个简单的单类规范来定义 Singleton 本身。我们将定义一个名为Singleton的类来封装该模式。我们将确保我们的构造函数是私有的,这样它们就不能被应用超过一次。我们还将添加一个静态的instance()方法来提供Singleton对象的实例化接口。这个方法将确保私有构造只发生一次。

让我们先来看一下这个简单的实现,可以在我们的 GitHub 存储库中找到:

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter19/Chp19-Ex1.cpp

class Singleton
{
private:
    static Singleton *theInstance;
    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 = NULL; // external variable
                                         // to hold static mbr
Singleton::Singleton()
{
    cout << "Constructor" << endl;
    theInstance = NULL;
}
Singleton::~Singleton()  // the destructor is not called in
{                        // the typical pattern usage
    cout << "Destructor" << endl;
    if (theInstance != NULL)  
    {  
       Singleton *temp = theInstance;
       theInstance = NULL;       // removes ptr to Singleton
       temp->theInstance = NULL; // prevents recursion
       delete temp;              // delete the Singleton
    }                 
}
Singleton *Singleton::instance()
{
    if (theInstance == NULL)
        theInstance = new Singleton();  // allocate Singleton
    return theInstance;
}
int main()
{
    Singleton *s1 = Singleton::instance(); // create Singleton
    Singleton *s2 = Singleton::instance(); // returns existing
    cout << s1 << " " << s2 << endl; // addresses are the same
}                                         

在上述的类定义中,我们注意到包括数据成员static Singleton *theInstance;来表示Singleton实例本身。我们的构造函数是私有的,这样就不能多次使用它来创建多个Singleton实例。相反,我们添加了一个static Singleton *instance()方法来创建Singleton。在这个方法中,我们检查数据成员theInstance是否为NULL,如果是,我们就实例化唯一的Singleton实例。

在类定义之外,我们看到了外部变量(及其初始化)来支持静态数据成员的内存需求,定义为Singleton *Singleton::theInstance = NULL;。我们还看到在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数据成员是否不是NULL,然后安排temp指向theInstance来管理递归,以保存我们需要删除的实例的句柄。然后我们进行temp->theInstance = NULL;的赋值,以防止在delete temp;时递归。为什么?因为delete temp;也会调用Singleton的析构函数。在这个析构函数调用时,temp将绑定到this,并且在第一次递归函数调用时不满足条件测试if (theInstance != NULL),使我们退出持续的递归。请注意,我们即将使用成对类方法的实现不会有这个潜在问题。

重要的是要注意,在实际应用中,我们不会创建一个领域不明确的Singleton实例。相反,我们将应用程序分解到设计中以使用该模式。毕竟,我们希望有一个有意义的类类型的Singleton实例。要使用我们简单的Singleton类作为基础来做到这一点,我们只需将我们的目标(特定于应用程序)类从Singleton继承。目标类也将有私有构造函数 - 接受足以充分实例化目标类的参数。然后,我们将静态的instance()方法从Singleton移到目标类,并确保instance()的参数列表接受传递给私有目标构造函数的必要参数。

总之,我们简单的实现存在固有的设计缺陷,即Singleton本身没有保证的适当销毁。让操作系统在应用程序终止时收集内存不会调用析构函数。选择一个可以删除内存的Singleton句柄虽然可能,但需要协调,也破坏了模式的通常应用,即允许Singleton在应用程序的持续时间内存在。

现在,因为我们理解了简单的Singleton实现的局限性,我们将转而前进到首选的成对类实现 Singleton 模式。成对类方法将确保我们的Singleton在应用程序允许Singleton在应用程序终止之前被销毁(最常见的情况)或者在应用程序中罕见地提前销毁Singleton时,能够进行适当的销毁。

使用更健壮的成对类实现

为了以一种良好封装的方式实现成对类方法的 Singleton 模式,我们将定义一个 Singleton 类,纯粹添加创建单个实例的核心机制。我们将把这个类命名为Singleton。然后,我们将添加一个辅助类到Singleton,称为SingletonDestroyer,以确保我们的Singleton实例在应用程序终止之前始终进行适当的销毁。这一对类将通过聚合和关联进行关联。更具体地说,Singleton类将在概念上包含一个SingletonDestroyer(聚合),而SingletonDestroyer类将持有一个关联到(外部)Singleton的关联。因为SingletonSingletonDestroyer的实现是通过静态数据成员,聚合是概念性的 - 静态成员被存储为外部变量。

一旦定义了这些核心类,我们将考虑如何将 Singleton 模式纳入我们熟悉的类层次结构中。假设我们想要实现一个类来封装“总统”的概念。无论是一个国家的总统还是大学的校长,都很重要的是在特定时间只有一个总统。 “总统”将是我们的目标类;因此,“总统”是一个很好的候选者来利用我们的 Singleton 模式。

有趣的是,尽管在特定时间只会有一位总统,但是可以替换总统。例如,美国总统的任期一次只有四年,可以连任一届。大学校长可能也有类似的条件。总统可能因辞职、弹劾或死亡而提前离任,或者在任期到期后简单地离任。一旦现任总统的存在被移除,那么实例化一个新的 Singleton President就是可以接受的。因此,我们的 Singleton 模式在特定时间只允许一个 Target 类的 Singleton。

反思我们如何最好地实现President类,我们意识到President Person,并且还需要混入 Singleton的功能。有了这个想法,我们现在有了我们的设计。President将使用多重继承来扩展Person的概念,并混入Singleton的功能。

当然,我们可以从头开始构建一个President类,但是当President类的Person组件在一个经过充分测试和可用的类中表示时,为什么要这样做呢?同样,当然,我们可以将Singleton类的信息嵌入到我们的President类中,而不是继承一个单独的Singleton类。绝对,这也是一个选择。然而,我们的应用程序将封装解决方案的每个部分。这将使未来的重用更容易。尽管如此,设计选择很多。

指定 Singleton 和 SingletonDestroyer 类

让我们来看看我们的 Singleton 模式的机制,首先检查SingletonSingletonDestroyer类的定义。这些类合作实现 Singleton 模式。这个例子可以在我们的 GitHub 存储库中找到完整的程序。

https://github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter19/Chp19-Ex2.cpp

class Singleton;    // Necessary forward class declarations
class SingletonDestroyer;
class Person;
class President;
class SingletonDestroyer   
{
private:
    Singleton *theSingleton;
public:
    SingletonDestroyer(Singleton *s = 0) { theSingleton = s; }
    SingletonDestroyer(const SingletonDestroyer &) = delete; 
    SingletonDestroyer &operator=(const SingletonDestroyer &)                                  = delete; 
    ~SingletonDestroyer(); // destructor shown further below
    void setSingleton(Singleton *s) { theSingleton = s; }
    Singleton *getSingleton() { return theSingleton; }
};

在上述代码段中,我们从几个前向类声明开始,比如class Singleton;。这些声明允许在编译器看到它们的完整类定义之前就可以引用这些数据类型。

接下来,让我们来看看我们的SingletonDestroyer类定义。这个简单的类包含一个私有数据成员Singleton *theSingleton;,表示SingletonDestroyer将来将负责释放的Singleton的关联(我们将很快检查SingletonDestroyer的析构函数定义)。请注意,我们的析构函数不是虚拟的,因为这个类不打算被专门化。

请注意,我们的构造函数为Singleton *指定了默认值0NULL)。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, and add a Static *Singleton instance();
// method to the public access region.
class Singleton
{
protected:
    static Singleton *theInstance;
    static SingletonDestroyer destroyer;
protected:
    Singleton() {}
    Singleton(const Singleton &) = delete; // disallow copies
    Singleton &operator=(const Singleton &) = delete; // and =
    friend class SingletonDestroyer;
    virtual ~Singleton() 
        { cout << "Singleton destructor" << endl; }
};

上述的Singleton类包含受保护的数据成员static Singleton *theInstance;,它将表示为采用 Singleton 习惯用法分配给类的唯一实例的指针。

受保护的数据成员static SingletonDestroyer destroyer代表一个概念上的聚合或包含成员。这种包含实际上只是概念性的,因为静态数据成员不存储在任何实例的内存布局中;它们实际上存储在外部内存中,并且name-mangled以显示为类的一部分。这个(概念上的)聚合子对象destroyer将负责正确销毁Singleton。请记住,SingletonDestroyer与唯一的Singleton有关,代表了SingletonDestroyer概念上包含的外部对象。这种关联是SingletonDestroyer将如何访问 Singleton 的方式。

当实现静态数据成员static SingletonDestroyer destroyer;的外部变量的内存在应用程序结束时消失时,将调用SingletonDestroyer(静态的概念性子对象)的析构函数。这个析构函数将运行delete theSingleton;,确保外部动态分配的Singleton对象将有适当的析构顺序运行。因为Singleton中的析构函数是受保护的,所以需要将SingletonDestructor指定为Singleton的友元类。

请注意,Singleton中复制构造函数和重载赋值运算符的使用都已经在它们的原型中使用=delete禁止了。

在我们的实现中,我们假设Singleton将通过继承混入到派生的目标类中。在派生类(打算使用 Singleton 习惯用法的类)中,我们提供了所需的静态instance()方法来创建Singleton实例。请注意,如果Singleton被用作独立类来创建单例,我们将在Singleton的公共访问区域中添加static Singleton* instance()。然后我们将数据成员从受保护的访问区域移动到私有访问区域。然而,拥有一个与应用程序无关的 Singleton 只能用来演示概念。相反,我们将把 Singleton 习惯用法应用到需要使用这种习惯用法的实际类型上。

有了我们的SingletonSingletonDestroyer类定义,让我们接下来检查这些类的其余必要实现需求:

// External (name mangled) variables to hold static data mbrs
Singleton *Singleton::theInstance = 0;
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 == NULL)
        cout << "SingletonDestroyer destructor: Singleton                  has already been destructed" << endl;
    else
    {
        cout << "SingletonDestroyer destructor" << endl;
        delete theSingleton;   
    }                          
}

在上述代码片段中,首先注意两个外部变量定义,提供内存以支持Singleton类中的两个静态数据成员——即Singleton *Singleton::theInstance = 0;SingletonDestroyer Singleton::destroyer;。请记住,静态数据成员不存储在其指定类的任何实例中。相反,它们存储在外部变量中;这两个定义指定了内存。请注意,数据成员都标记为受保护。这意味着虽然我们可以直接定义它们的外部存储,但我们不能通过Singleton的静态成员函数以外的方式访问这些数据成员。这将给我们一些安心。虽然静态数据成员有潜在的全局访问点,但它们的受保护访问区域要求使用Singleton类的适当静态方法来正确操作这些重要成员。

接下来,注意SingletonDestroyer的析构函数。这个巧妙的析构函数首先检查它是否与它负责的Singleton的关联是否为NULL。这将很少发生,并且只会在非常不寻常的情况下发生,即客户端直接使用显式的delete释放Singleton对象。

SingletonDestroyer析构函数中的通常销毁场景将是执行else子句,其中SingletonDestructor作为静态对象将负责删除其配对的Singleton,从而销毁它。请记住,Singleton中将包含一个SingletonDestroyer对象。这个静态(概念上的)子对象的内存不会在应用程序结束之前消失。请记住,静态内存实际上并不是任何实例的一部分。因此,当SingletonDestroyer被销毁时,它通常的情况将是delete theSingleton;,这将释放其配对的 Singleton 的内存,使得Singleton能够被正确销毁。

单例模式背后的驱动设计决策是,单例是一个长期存在的对象,它的销毁通常应该在应用程序的最后发生。单例负责创建自己的内部目标对象,因此单例不应该被客户端删除(因此也不会被销毁)。相反,首选的机制是,当作为静态对象移除时,SingletonDestroyer会删除其配对的Singleton

尽管如此,偶尔也会有合理的情况需要在应用程序中间删除一个Singleton。如果一个替代的Singleton从未被创建,我们的SingletonDestroyer析构函数仍将正确工作,识别到其配对的Singleton已经被释放。然而,更有可能的情况是我们的Singleton将在应用程序的某个地方被另一个Singleton实例替换。回想一下我们的应用程序示例,总统可能会被弹劾、辞职或去世,但会被另一位总统取代。在这些情况下,直接删除Singleton是可以接受的,然后创建一个新的Singleton。在这种情况下,SingletonDestroyer现在将引用替代的Singleton

从 Singleton 派生目标类

接下来,让我们看看如何从Singleton创建我们的目标类President

// Assume our Person class definition is as we are accustomed
// A President Is-A Person and also mixes-in Singleton 
class President: public Person, public Singleton
{
private:
    President(const char *, const char *, char, const char *);
public:
    virtual ~President();
    President(const President &) = delete;  // disallow copies
    President &operator=(const President &) = delete; // and =
    static President *instance(const char *, const char *,
                               char, const char *);
};
President::President(const char *fn, const char *ln, char mi,
    const char *t) : Person(fn, ln, mi, t), Singleton()
{
}
President::~President()
{
    destroyer.setSingleton(NULL);  
    cout << "President destructor" << endl;
}
President *President::instance(const char *fn, const char *ln,
                               char mi, const char *t)
{
    if (theInstance == NULL)
    {
        theInstance = new President(fn, ln, mi, t);
        destroyer.setSingleton(theInstance);
        cout << "Creating the Singleton" << endl;
    }
    else
        cout << "Singleton previously created.                  Returning existing singleton" << endl;
    return (President *) theInstance; // cast necessary since
}                              // theInstance is a Singleton * 

在我们上述的目标类President中,我们仅仅使用公共继承从Person继承President,然后通过多重继承从Singleton继承President混入Singleton机制。

我们将构造函数放在私有访问区域。静态方法instance()将在内部使用这个构造函数来创建唯一允许的Singleton实例,以符合模式。没有默认构造函数(不寻常),因为我们不希望允许创建没有相关细节的President实例。请记住,如果我们提供了替代的构造函数接口,C++将不会链接默认构造函数。由于我们不希望复制President或将President分配给另一个潜在的President,我们已经在这些方法的原型中使用=delete规范来禁止复制和分配。

我们的President析构函数很简单,但至关重要。在我们明确删除Singleton对象的情况下,我们通过设置destroyer.setSingleton(NULL);来做好准备。请记住,President继承了受保护的static SingletonDestroyer destroyer;数据成员。在这里,我们将销毁者的关联Singleton设置为NULL。然后,我们的President析构函数中的这行代码使得SingletonDestroyer的析构函数能够准确地依赖于检查其关联的Singleton是否已经在开始其Singleton对应部分的通常删除之前被删除。

最后,我们定义了一个静态方法,为我们的President提供Singleton的创建接口,使用static President *instance(const char *, const char *, char, const char *);。在instance()的定义中,我们首先检查继承的受保护数据成员Singleton *theInstance是否为NULL。如果我们还没有分配Singleton,我们使用上述的私有构造函数分配President并将这个新分配的President实例分配给theInstance。这是从President *Singleton *的向上转型,在公共继承边界上没有问题。然而,如果在instance()方法中,我们发现theInstance不是NULL,我们只需返回指向先前分配的Singleton对象的指针。由于用户无疑会想要将此对象用作President来享受继承的Person功能,我们将theInstance向下转型为President *,作为此方法的返回值。

最后,让我们考虑一下我们整个应用程序中一个示例客户端的后勤。在其最简单的形式中,我们的客户端将包含一个main()函数来驱动应用程序并展示我们的 Singleton 模式。

将模式组件在客户端中组合在一起

现在让我们来看看我们的main()函数是如何组织我们的模式的:

int main()
{ 
    // Create a Singleton President
    President *p1 = President::instance("John", "Adams", 
                                        'Q', "President");
    // This second request will fail, returning orig. instance
    President *p2 = President::instance("William", "Harrison",
                                        'H', "President");
    if (p1 == p2)   // Verification there's only one object
        cout << "Same instance (only one Singleton)" << endl;
    p1->Print();
    // SingletonDestroyer will release Singleton at end
    return 0;
}

回顾我们在前面的代码中的main()函数,我们首先使用President *p1 = President::instance("John", "Adams", 'Q', "President");分配一个 Singleton President。然后我们尝试在下一行代码中分配另一个President,使用*p2。因为我们只能有一个SingletonPresident 混入了一个Singleton),一个指针被返回到我们现有的President并存储在p2中。我们通过比较p1 == p2来验证只有一个Singleton;指针确实指向同一个实例。

接下来,我们利用我们的President实例以其预期的方式使用,比如使用从Person继承的一些成员函数。例如,我们调用p1->Print();。当然,我们的President类可以添加适合在我们的客户端中使用的专门功能。

现在,在main()的末尾,我们的静态对象SingletonDestroyer Singleton::destroyer;将在其内存被回收之前被适当地销毁。正如我们所看到的,SingletonDestroyer的析构函数(通常)会使用delete theSingleton;向其关联的Singleton(实际上是President)发出delete。这将触发我们的President析构函数、Singleton析构函数和Person析构函数分别被调用和执行(从最专门的到最一般的子对象)。由于我们的Singleton析构函数是虚拟的,我们保证从正确的级别开始销毁并包括所有析构函数。

让我们看看这个程序的输出:

Creating the Singleton
Singleton previously created. Returning existing singleton
Same instance (only one Singleton)
President John Q Adams
SingletonDestroyer destructor
President destructor
Singleton destructor
Person destructor

在前面的输出中,我们可以看到 Singleton President的创建,以及第二个instance()请求一个President只是返回现有的President。然后我们看到打印出的President的细节。

最有趣的是,我们可以看到Singleton的销毁顺序,这是由SingletonDestroyer的静态对象回收驱动的。通过在SingletonDestroyer析构函数中正确删除Singleton,我们看到PresidentSingletonPerson的析构函数都被调用,因为它们共同构成了完整的President对象。

检查显式单例删除及其对 SingletonDestroyer 析构函数的影响

让我们看看客户端的另一个版本,其中有一个替代的main()函数。在这里,我们强制删除我们的Singleton;这是罕见的。在这种情况下,我们的SingletonDestroyer不会删除其配对的Singleton。这个例子可以在我们的 GitHub 存储库中找到作为一个完整的程序。

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/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 one 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;来释放我们的单例President,而不是让实例在程序结束时通过静态对象删除来回收。幸运的是,我们在我们的SingletonDestroyer析构函数中包含了一个测试,让我们知道SingletonDestroyer是否必须删除其关联的Singleton,或者这个删除已经发生。

让我们来看一下修改后的输出,注意与我们原来的main()函数的区别:

Creating the Singleton
Singleton previously created. Returning existing singleton
Same instance (only one Singleton)
President John Q Adams
President destructor
Singleton destructor
Person destructor
SingletonDestroyer destructor: Singleton has already been destructed

在我们修改后的客户端的输出中,我们可以再次看到单例President的创建,第二个President失败创建请求,等等。

让我们注意一下销毁顺序以及它与我们第一个客户端的不同之处。在这里,单例President被明确地释放。我们可以看到President的正确删除,通过在PresidentSingletonPerson中的析构函数的调用和执行。现在,当应用程序即将结束并且静态SingletonDestroyer即将回收其内存时,我们可以看到SingletonDestroyer上的析构函数被调用。然而,这个析构函数不再删除其关联的Singleton

理解设计的优势和劣势

前面(成对类)实现的单例模式的一个优点(无论使用哪个main())是,我们保证了Singleton的正确销毁。这不管Singleton是长寿命的,并且通过其关联的SingletonDestroyer以通常方式被删除,还是在应用程序中较早地直接删除(一个罕见的情况)。

这种实现的一个缺点是继承自Singleton的概念。也就是说,只能有一个派生类Singleton包含Singleton类的特定机制。因为我们从Singleton继承了President,我们正在使用PresidentPresident独自使用的单例逻辑(即静态数据成员,存储在外部变量中)。如果另一个类希望从Singleton派生以采用这种习惯用法,Singleton的内部实现已经被用于President。哎呀!这看起来不公平。

不用担心!我们的设计可以很容易地扩展,以适应希望使用我们的Singleton基类的多个类。我们将扩展我们的设计以容纳多个Singleton对象。然而,我们仍然假设每个类类型只有一个Singleton实例。

现在让我们简要地看一下如何扩展单例模式来解决这个问题。

使用注册表允许多个类使用单例

让我们更仔细地检查一下我们当前单例模式实现的一个缺点。目前,只能有一个派生类Singleton能有效地利用Singleton类。为什么呢?Singleton是一个带有外部变量定义的类,用于支持类内的静态数据成员。代表theInstance的静态数据成员(使用外部变量Singleton *Singleton::theInstance实现)只能设置为一个Singleton实例。不是每个类一个 - 只有一组外部变量创建了关键的Singleton数据成员theInstancedestroyer的内存。问题就在这里。

相反,我们可以指定一个Registry类来跟踪应用单例模式的类。有许多Registry的实现,我们将审查其中一种实现。

在我们的实现中,Registry将是一个类,它将类名(对于使用 Singleton 模式的类)与每个注册类的单个允许实例的Singleton指针配对。我们仍然将每个 Target 类从Singleton派生(以及根据我们的设计认为合适的任何其他类)。

我们从Singleton派生的每个类中的instance()方法将被修改如下:

  • 我们在instance()中的第一个检查将是调用Registry方法(使用派生类的名称),询问该类是否以前创建过Singleton。如果Registry方法确定已经为请求的派生类型实例化了Singleton,则instance()将返回对现有实例的指针。

  • 相反,如果Registry允许分配Singletoninstance()将分配Singleton,就像以前一样,将theInstance的继承受保护数据成员设置为分配的派生Singleton。静态instance()方法还将通过使用setSingleton()设置继承受保护的销毁者数据成员的反向链接。然后,我们将新实例化的派生类实例(即Singleton)传递给Registry方法,以在RegistryStore()新分配的Singleton

我们注意到存在四个指向相同Singleton的指针。一个是从我们的派生类instance()方法返回的派生类类型的专用指针。这个指针将被传递给我们的客户端进行应用使用。第二个Singleton指针将是存储在我们继承的受保护数据成员theInstance中的指针。第三个Singleton指针将是存储在SingletonDestroyer中的指针。第四个指向Singleton的指针将存储在Registry中。没有问题,我们可以有多个指向Singleton的指针。这是SingletonDestroyer在其传统销毁功能中使用的一个原因-它将在应用程序结束时销毁每种类型的唯一Singleton

我们的Registry将维护每个使用Singleton模式的类的一对,包括类名和相应类的(最终)指针到特定Singleton。每个特定Singleton实例的指针将是一个静态数据成员,并且还需要一个外部变量来获取其底层内存。结果是每个拥抱 Singleton 模式的类的一个额外的外部变量。

Registry的想法如果我们选择另外容纳 Singleton 模式的罕见使用,可以进一步扩展。如果我们选择另外容纳 Singleton 模式的罕见使用,Registry的想法可以进一步扩展。在这种扩展模式中的一个例子可能是,我们选择对一个只有一个校长但有多个副校长的高中进行建模。Principal将是Singleton的一个预期派生类,而多个副校长将代表Vice-Principal类的固定数量的实例(派生自Singleton)。我们的注册表可以扩展到允许Vice-Principal类型的N个注册的Singleton对象。

我们现在已经看到了使用成对类方法实现 Singleton 模式。我们已经将SingletonSingetonDestroyer、Target 和 Client 的概念折叠到我们习惯看到的类框架中,即Person,以及我们的SingletonPerson的后代类(President)。让我们现在简要回顾一下我们在模式方面学到的东西,然后继续下一章。

总结

在本章中,我们通过接受另一个设计模式来扩展我们的编程技能,从而实现了成为更好的 C++程序员的目标。我们首先采用了一种简单的方法来探讨 Singleton 模式,然后使用SingletonSingletonDestroyer进行了成对类的实现。我们的方法使用继承将 Singleton 的实现合并到我们的 Target 类中。可选地,我们使用多重继承将一个有用的现有基类合并到我们的 Target 类中。

利用核心设计模式,如 Singleton 模式,将帮助您更轻松地重用现有的经过充分测试的代码部分,以一种其他程序员理解的方式。通过使用熟悉的设计模式,您将为众所周知和可重用的解决方案做出贡献,采用前卫的编程技术。

现在,我们准备继续前往我们的最终设计模式,在第二十章中,使用 pImpl 模式去除实现细节。将更多的模式添加到我们的编程技能库中,使我们成为更多才多艺和有价值的程序员。让我们继续前进!

问题

  1. 使用本章中找到的 Singleton 模式示例:
  1. 实现一个President辞职()的接口,或者实现一个接口来弹劾()一个President。您的方法应删除当前的 SingletonPresident(并从SingletonDestroyer中删除该链接)。SingletonDestroyer有一个setSingleton(),可能有助于帮助您删除反向链接。

  2. 注意到前任的 SingletonPresident已被移除,使用President::instance()创建一个新的President。验证新的President已经安装。

c.(可选)创建一个Registry,允许在多个类中有效地使用Singleton(不是互斥的,而是当前的实现)。

  1. 为什么不能将Singleton中的static instance()方法标记为虚拟,并在President中重写它?

  2. 您能想象哪些其他例子可能很容易地融入 Singleton 模式?

第二十章:使用 pImpl 模式去除实现细节

本章将结束我们扩展您的 C++编程技能的探索,超越核心面向对象编程概念,旨在进一步赋予您解决重复类型的编码问题的能力,利用常见的设计模式。在编码中应用设计模式不仅可以提供精炼的解决方案,还可以有助于更轻松地维护代码,并提供代码重用的潜力。

我们将学习如何在 C++中有效实现pImpl 模式的下一个设计模式。

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

  • 理解 pImpl 模式以及它如何减少编译时的依赖关系

  • 了解如何在 C++中使用关联和唯一指针实现 pImpl 模式

  • 识别与 pImpl 相关的性能问题和必要的权衡

本章结束时,您将了解 pImpl 模式以及如何使用它来将实现细节与类接口分离,以减少编译器依赖性。将额外的设计模式添加到您的技能集中将帮助您成为更有价值的程序员。

让我们通过研究另一个常见的设计模式,pImpl 模式,来增强我们的编程技能。

技术要求

完整程序示例的在线代码可以在以下 GitHub URL 找到:github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter20。每个完整程序示例都可以在 GitHub 存储库中找到,位于相应章节标题(子目录)下的文件中,文件名与所在章节编号相对应,后跟破折号,再跟随该章节中的示例编号。例如,本章的第一个完整程序可以在名为Chp20-Ex1.cpp的文件中的Chapter20子目录中找到上述 GitHub 目录。一些程序位于适用的子目录中,如示例中所示。

本章的 CiA 视频可在以下链接观看:bit.ly/2OT5K1W

理解 pImpl 模式

pImpl 模式pointer to Implementation idiom)是一种结构设计模式,它将类的实现与其公共接口分离。这种模式最初被四人组GofF)称为桥接模式,也被称为切尔西猫编译器防火墙习惯d 指针不透明指针句柄模式

该模式的主要目的是最小化编译时的依赖关系。减少编译时的依赖关系的结果是,类定义(尤其是私有访问区域)的更改不会在开发或部署的应用程序中引发及时的重新编译。相反,必要的重新编译代码可以被隔离到类本身的实现中。依赖于类定义的应用程序的其他部分将不再受重新编译的影响。

类定义中的私有成员可能会影响类的重新编译。这是因为更改数据成员可能会改变该类型的实例的大小。此外,私有成员函数必须与函数调用的签名匹配,以进行重载解析以及潜在的类型转换。

传统的头文件(.h)和源代码文件(.cpp)指定依赖关系的方式会触发重新编译。通过将类内部实现细节从类头文件中移除(并将这些细节放在源文件中),我们可以消除许多依赖关系。我们可以更改其他头文件在其他头文件和源代码文件中的包含方式,简化依赖关系,从而减轻重新编译的负担。

pImpl 模式将迫使对类定义进行以下调整:

  • 私有(非虚拟)成员将被替换为指向嵌套类类型的指针,该类型包括以前的私有数据成员和方法。嵌套类的前向声明也是必要的。

  • 指向实现的指针(pImpl)将是一个关联,类实现的方法调用将被委托给它。

  • 修订后的类定义将存在于一个采用这种习惯用法的类的头文件中。以前包含在这个头文件中的任何头文件现在将被移动到该类的源代码文件中。

  • 现在,其他类包括 pImpl 类的头文件将不会面临重新编译,如果类的实现在其私有访问区域内被修改。

  • 为了有效地管理代表实现的关联对象的动态内存资源,我们将使用一个唯一指针(智能指针)。

修订后的类定义中的编译自由度利用了指针只需要类类型的前向声明才能编译的事实。

让我们继续检查 pImpl 模式的基本实现,然后是精炼的实现。

实现 pImpl 模式

为了实现 pImpl 模式,我们需要重新审视典型的头文件和源文件组成。然后,我们将用指向实现的指针替换典型类定义中的私有成员,利用关联。实现将被封装在我们目标类的嵌套类中。我们的 pImpl 指针将把所有请求委托给我们的关联对象,该对象提供内部类的详细信息或实现。

内部(嵌套)类将被称为实现类。原始的、现在是外部的类将被称为目标接口类

我们将首先回顾典型(非 pImpl 模式)文件组成,其中包含类定义和成员函数定义。

组织文件和类内容以应用模式基础知识

首先让我们回顾一下典型的 C++类的组织策略,关于类定义和成员函数定义的文件放置。接下来,我们将考虑使用 pImpl 模式的类的修订组织策略。

回顾典型的文件和类布局

让我们看一下典型的类定义,以及我们以前如何组织类与源文件和头文件相关的内容,比如我们在[第五章](B15702_05_Final_NM_ePub.xhtml#_idTextAnchor199)中的讨论,详细探讨类,以及[第十五章](B15702_15_Final_NM_ePub.xhtml#_idTextAnchor572),测试 OO 程序和组件

回想一下,我们将每个类组织成一个头(.h)文件,其中包含类定义和内联函数定义,以及一个相应的源代码(.cpp)文件,其中包含非内联成员函数定义。让我们回顾一个熟悉的样本类定义,Person

#ifndef _PERSON_H  // preprocessor directives to avoid 
#define _PERSON_H  // multiple inclusion of header
class Person
{
private:
    char *firstName, *lastName, *title;
    char middleInitial;
protected:
    void ModifyTitle(const char *);
public:
    Person();   // default constructor
    Person(const char *, const char *, char, const char *);
    Person(const Person &);  // copy constructor
    virtual ~Person();  // virtual destructor
    const char *GetFirstName() const { return firstName; }
    const char *GetLastName() const { return lastName; }
    const char *GetTitle() const { return title; }
    char GetMiddleInitial() const { return middleInitial; }
    virtual void Print() const;
    virtual void IsA();
    virtual void Greeting(const char *);
    Person &operator=(const Person &);  // overloaded op =
};
#endif

在上述的头文件(Person.h)中,我们已经包含了我们的Person类的类定义,以及类的内联函数定义。任何不在类定义中出现的较大的内联函数定义(在原型中使用关键字inline表示)也会出现在这个文件中,在类定义本身之后。请注意使用预处理指令来确保每个编译单元只包含一次类定义。

接下来让我们回顾相应的源代码文件Person.cpp的内容:

#include <iostream>  // also include other relevant libraries
#include "Person.h"  // include the header file
using namespace std;
// Include all the non-inline Person member functions
// The default constructor is one example of many in the file
Person::Person()
{
    firstName = lastName = title = 0;  // NULL pointer
    middleInitial = '\0';   // null character
}

在先前定义的源代码文件中,我们为类Person定义了所有非内联成员函数。虽然并非所有方法都显示出来,但所有方法都可以在我们的 GitHub 代码中找到。此外,如果类定义包含任何静态数据成员,应该在源代码文件中包含外部变量的定义,指定这个成员的内存。

现在让我们考虑如何通过应用 pImpl 模式,从Person类定义及其对应的头文件中删除实现细节。

应用修订后的类和文件布局的 pImpl 模式

为了使用 pImpl 模式,我们将重新组织我们的类定义及其相应的实现。我们将在现有类定义中添加一个嵌套类,以表示原始类的私有成员和其实现的核心。我们的外部类将包括一个指向内部类类型的指针,作为与我们实现的关联。我们的外部类将把所有实现请求委托给内部关联对象。我们将重新构造头文件和源代码文件中类和源代码的放置方式。

让我们仔细看一下我们修订后的类的实现,以了解实现 pImpl 模式所需的每个新细节。这个例子由一个源文件PersonImpl.cpp和一个头文件Person.h组成,可以在与我们的 GitHub 存储库中测试该模式的简单驱动程序相同的目录中找到。为了创建一个完整的可执行文件,您需要编译和链接PersonImp.cppChp20-Ex1.cpp(驱动程序),这两个文件都在同一个目录中。以下是驱动程序的 GitHub 存储库 URL:

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter20/Chp20-Ex1.cpp

#ifndef _PERSON_H    // Person.h header file definition
#define _PERSON_H
class Person
{
private:
    class PersonImpl;  // forward declaration of nested class
    PersonImpl *pImpl; // pointer to implementation of class
protected:
    void ModifyTitle(const char *);
public:
    Person();   // default constructor
    Person(const char *, const char *, char, const char *);
    Person(const Person &);  // copy constructor
    virtual ~Person();  // virtual destructor
    const char *GetFirstName() const; // no longer inline
    const char *GetLastName() const; 
    const char *GetTitle() const; 
    char GetMiddleInitial() const; 
    virtual void Print() const;
    virtual void IsA();
    virtual void Greeting(const char *);
    Person &operator=(const Person &);  // overloaded =
};
#endif

在我们前面提到的Person的修订类定义中,请注意我们已经删除了私有访问区域中的数据成员。任何非虚拟的私有方法,如果存在的话,也将被删除。相反,我们使用class PersonImpl;对我们的嵌套类进行了前向声明。我们还声明了一个指向实现的指针,使用PersonImpl *pImpl;,它代表了封装实现的嵌套类成员的关联。在我们的初始实现中,我们将使用一个本地(原始的)C++指针来指定与嵌套类的关联。随后我们将修改我们的实现以利用unique pointer

请注意,我们的Person的公共接口与以前大致相同。所有现有的公共和受保护的方法都存在于预期的接口中。然而,我们注意到依赖于数据成员实现的内联函数已被替换为非内联成员函数原型。

让我们继续看一下我们嵌套类PersonImpl的类定义,以及PersonImplPerson的成员函数在一个共同的源代码文件PersonImpl.cpp中的放置。我们将从嵌套PersonImpl类定义开始:

// PersonImpl.cpp source code file includes the nested class
// Nested class definition supports implementation
class Person::PersonImpl
{
private:
    char *firstName, *lastName, *title;
    char middleInitial;
public:
    PersonImpl();   // default constructor
    PersonImpl(const char *, const char *, char, 
               const char *);
    PersonImpl(const PersonImpl &);  
    virtual ~PersonImpl();  
    const char *GetFirstName() const { return firstName; }
    const char *GetLastName() const { return lastName; }
    const char *GetTitle() const { return title; }
    char GetMiddleInitial() const { return middleInitial; }
    void ModifyTitle(const char *);
    virtual void Print() const;
    virtual void IsA() { cout << "Person" << endl; }
    virtual void Greeting(const char *msg) 
        { 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
Person::PersonImpl::PersonImpl()
{
    firstName = lastName = title = 0;  // NULL pointer
    middleInitial = '\0';
}
Person::PersonImpl::PersonImpl(const char *fn, const char *ln,
                               char mi, const char *t)
{
    firstName = new char [strlen(fn) + 1];
    strcpy(firstName, fn);
    // Continue memory allocation, init. for data members
}
Person::PersonImpl::PersonImpl(const Person::PersonImpl &pers)
{
    firstName = new char [strlen(pers.firstName) + 1];
    strcpy(firstName, pers.firstName);
    // Continue memory allocation and deep copy for all
}   // pointer data members and copy for non-ptr data members
Person::PersonImpl::~PersonImpl()
{   // Delete all dynamically allocated data members
}
void Person::PersonImpl::ModifyTitle(const char *newTitle)
{   // Delete old title, reallocate space for and copy new one
}
void Person::PersonImpl::Print() const
{   // Print each data member as usual
}
Person::PersonImpl &Person::PersonImpl::operator=
                             (const PersonImpl &p)
{  // check for self-assignment, then delete destination
   // object data members. Then reallocate and copy from 
   // source object. 
   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())
{  // This is the complete member function definition
}
Person::Person(const char *fn, const char *ln, char mi,
               const char *t): 
               pImpl(new PersonImpl(fn, ln, mi, t))
{  // This is the complete member function definition
}  
Person::Person(const Person &pers): 
           pImpl(new PersonImpl(*(pers.pImpl)))
{  // This is the complete member function definition
}  // No Person data members to copy from pers except deep
   // copy of *(pers.pImpl) to data member pImpl
Person::~Person()
{
    delete pImpl;   // delete associated implementation
}
void Person::ModifyTitle(const char *newTitle)
{   // delegate request to the implementation 
    pImpl->ModifyTitle(newTitle);  
}
const char *Person::GetFirstName() const
{   // no longer inline in Person;further hides implementation
    return pImpl->GetFirstName();
}
// Note: methods GetLastName(), GetTitle(), 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复制构造函数进行对象的创建和初始化,将*(pers.pImpl)传递给嵌套对象的复制构造函数。也就是说,pers.pImpl是一个指针,所以我们使用*对指针进行解引用,以获得可引用的对象,用于PersonImpl的复制构造函数。

我们在Person的重载赋值运算符中使用了类似的策略。也就是说,除了pImpl之外,没有其他数据成员来执行深度赋值,因此我们只是在关联对象pImpl上调用PersonImpl的赋值运算符,再次将*(p.pImpl)作为右值传入。

最后,让我们考虑一个示例驱动程序,以演示我们模式的运行情况。有趣的是,我们的驱动程序将使用我们最初指定的非模式类(源文件和头文件)或我们修改后的 pImpl 模式特定源文件和头文件!

将模式组件组合在一起

最后,让我们来看看我们驱动程序源文件Chp20-Ex1.cpp中的main()函数:

#include <iostream>
#include "Person.h"
using namespace std;
const int MAX = 3;
int main()
{
    Person *people[MAX];
    people[0] = new Person("Giselle", "LeBrun", 'R', "Ms.");
    people[1] = new Person("Zack", "Moon", 'R', "Dr.");
    people[2] = new Person("Gabby", "Doone", 'A', "Dr.");
    for (int i = 0; i < MAX; i++)
       people[i]->Print();
    for (int i = 0; i < MAX; i++)
       delete people[i];
    return 0;
}

审查我们上述的main()函数,我们只是动态分配了几个Person实例,调用了实例的Person方法,然后删除了每个实例。我们已经包含了Person.h头文件,如预期的那样,以便能够使用这个类。从客户端的角度来看,一切看起来像往常一样,并且与模式无关。

请注意,我们分别编译PersonImp.cppChp20-Ex1.cpp,将对象文件链接在一起成为可执行文件。然而,由于 pImpl 模式,如果我们改变了Person的实现,这种改变将被封装在PersonImp嵌套类的实现中。只有PersonImp.cpp需要重新编译。客户端不需要在驱动程序Chp20-Ex1.cpp中重新编译,因为更改不会发生在驱动程序依赖的Person.h头文件中。

让我们来看看这个程序的输出。

Ms. Giselle R. LeBrun
Dr. Zack R. Moon
Dr. Gabby A. Doone

在上述输出中,我们看到了我们简单驱动程序的预期结果。

让我们继续考虑如何通过使用独特指针来改进我们的 pImpl 模式的实现。

使用独特指针改进模式

我们使用与本机 C++指针关联的关联来实现的初始实现减轻了许多编译器依赖。这是因为编译器只需要看到 pImpl 指针类型的前向类声明,才能成功编译。到目前为止,我们已经实现了使用 pImpl 模式的核心目标-减少重新编译。

然而,总是有人批评使用本机或原始指针。我们需要自己管理内存,包括记住在外部类析构函数中删除分配的嵌套类类型。内存泄漏、内存滥用和内存错误是使用原始指针自己管理内存资源的潜在缺点。因此,习惯上使用智能指针来实现 pImpl 模式。

我们将继续实现 pImpl 的任务,通过检查通常与 pImpl 模式一起使用的关键组件——智能指针,更具体地说是unique_ptr

让我们从理解智能指针的基础知识开始。

理解智能指针

为了习惯性地实现 pImpl 模式,我们必须首先了解智能指针。智能指针是一个小的包装类,封装了一个原始指针,确保它包含的指针在包装对象超出范围时自动删除。实现智能指针的类可以使用模板来为任何数据类型创建智能指针。

这是一个非常简单的智能指针示例。这个示例可以在我们的 GitHub 上找到:

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/Chapter20/Chp20-Ex2.cpp

#include <iostream>
#include "Person.h"
using namespace std;
template <class Type>
class SmartPointer
{
private:
    Type *pointer;
public:
    SmartPointer(Type *ptr = NULL) { 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_pointer的所有者将独占该指针的使用权。唯一指针的所有者可以选择将这些指针移动到其他资源,但后果是原始资源将不再包含unique_pointer。我们必须#include <memory>来包含unique_ptr的定义。

修改我们的智能指针程序,改用unique_pointer,现在我们有:

#include <iostream>
#include <memory>
#include "Person.h"
using namespace std;
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 namespace std;,所以在唯一指针声明中我们不需要用std::来限定unique_ptr

有了这个知识,让我们将唯一指针添加到我们的 pImpl 模式中。

将唯一指针添加到模式中

为了使用unique_ptr实现 pImpl 模式,我们将对先前的实现进行最小的更改,从我们的Person.h头文件开始。我们的 pImpl 模式利用unique_ptr的完整程序示例可以在我们的 GitHub 存储库中找到,并且还将包括一个修订后的PersonImpl.cpp文件。这是驱动程序Chp20-Ex3.cpp的 URL;请注意我们的 GitHub 存储库中的子目录,用于这个完整示例:

github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/raw/master/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 to nested class
    std::unique_ptr<PersonImpl> pImpl; // unique ptr to impl
protected:
    void ModifyTitle(const char *);
public:
    Person();   // default constructor
    Person(const char *, const char *, char, const char *);
    Person(const Person &);  // copy constructor
    virtual ~Person();  // virtual destructor
    const char *GetFirstName() const; // no longer inline
    const char *GetLastName() const; 
    const char *GetTitle() const; 
    char GetMiddleInitial() const; 
    virtual void Print() const;
    virtual void IsA();
    virtual void Greeting(const char *);
    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
Person::~Person()
{  // unique_pointer pImpl will delete its own resources
}

看一下前面提到需要修改的成员函数,我们发现只有Person的析构函数!因为我们使用了一个独占指针来实现对嵌套类实现的关联,我们不再需要自己管理这个资源的内存。这非常好!通过这些小的改变,我们的 pImpl 模式现在使用unique_ptr来指定类的实现。

接下来,让我们来检查一些与使用 pImpl 模式相关的性能问题。

理解 pImpl 模式的权衡取舍。

将 pImpl 模式纳入生产代码中既有好处又有缺点。让我们分别审查一下,以便更好地理解可能需要部署这种模式的情况。

可忽略的性能问题包括大部分的缺点。也就是说,几乎每个对目标(接口)类的请求都需要委托给其嵌套实现类。唯一可以由外部类处理的请求是那些不涉及任何数据成员的请求;这种情况将非常罕见!另一个缺点包括实例需要更高的内存需求来容纳作为模式实现的一部分添加的指针。这些问题在嵌入式软件系统和需要最佳性能的系统中将是至关重要的,但在其他情况下相对较小。

对于使用 pImpl 模式的类来说,维护将会更加困难,这是一个不幸的缺点。每个目标类现在都与一个额外的(实现)类配对,包括一组转发方法来将请求委托给实现。

也可能会出现一些实现困难。例如,如果任何私有成员(现在在嵌套实现类中)需要访问外部接口类的受保护或公共方法中的任何一个,我们将需要在嵌套类中包含一个反向链接,以便访问该成员。为什么?内部类中的this指针将是嵌套对象类型的。然而,外部对象中的受保护和公共方法将期望一个this指针指向外部对象 - 即使这些公共方法将重新委托请求调用私有的嵌套类方法来帮助。还需要这个反向链接来从内部类(实现)的范围内调用接口的公共虚函数。然而,请记住,我们通过每个对象添加的另一个指针和委托来影响性能,这将影响性能。

利用 pImpl 模式的优势有很多,提供了重要的考虑因素。其中最重要的是,在开发和维护代码期间重新编译的时间显著减少。此外,类的编译二进制接口变得独立于类的底层实现。更改类的实现只需要重新编译和链接嵌套实现类。外部类的用户不受影响。作为一个额外的好处,pImpl 模式提供了一种隐藏类的底层私有细节的方法,这在分发类库或其他专有代码时可能会有用。

在我们的 pImpl 实现中包含unique_pointer的一个优势是,我们保证了关联实现类的正确销毁。我们还有可能避免程序员引入的指针和内存错误!

使用 pImpl 模式是一种权衡。对每个类和所涉及的应用进行仔细分析将有助于确定 pImpl 模式是否适合您的设计。

我们现在已经看到了最初使用原始指针的 pImpl 模式的实现,然后应用了unique_pointer。让我们现在简要回顾一下我们在结束本书的最后一章之前所学到的与模式相关的内容。

总结

在本章中,我们通过进一步提高我们的编程技能,探索了另一个核心设计模式,进一步推进了成为更不可或缺的 C程序员的目标。我们通过使用本地 C指针和关联来探索了 pImpl 模式的初始实现,然后通过使用 unique 指针来改进我们的实现。通过检查实现,我们很容易理解 pImpl 模式如何减少编译时的依赖,并且可以使我们的代码更依赖于实现。

利用核心设计模式,比如 pImpl 模式,将帮助您更轻松地为其他熟悉常见设计模式的程序员理解的可重用、可维护的代码做出贡献。您的软件解决方案将基于创造性和经过良好测试的设计解决方案。

我们现在一起完成了我们的最后一个设计模式,结束了对 C++面向对象编程的长期探索。您现在拥有了许多技能,包括对面向对象编程的深入理解、扩展的语言特性和核心设计模式,这些都使您成为了一名更有价值的程序员。

尽管 C++是一种复杂的语言,具有额外的特性、补充技术和额外的设计模式需要探索,但您已经具备了坚实的基础和专业水平,可以轻松地掌握和获取任何额外的语言特性、库和模式。您已经走了很长的路;这是一次充满冒险的旅程!我享受我们的探索过程的每一分钟,希望您也一样。

我们从审查基本语言语法和理解 C基础知识开始,这些知识对我们即将开始的面向对象编程之旅至关重要。然后,我们一起将 C作为面向对象的语言,不仅学习了基本的面向对象概念,还学会了如何使用 C++语言特性、编码技巧或两者都使用来实现它们。然后,我们通过添加异常处理、友元、运算符重载、模板、STL 基础知识以及测试面向对象类和组件来扩展您的技能。然后,我们通过应用感兴趣的每个模式深入代码,进入了复杂的编程技术。

这些所获得的技能段代表了 C知识和掌握的新层次。每一个都将帮助您更轻松地创建可维护和健壮的代码。您作为一名精通的、熟练的 C面向对象程序员的未来正在等待。现在,让我们开始编程吧!

问题

  1. 修改本章中使用 unique 指针的 pImpl 模式示例,另外在嵌套类的实现中引入 unique 指针。

  2. 将您以前章节解决方案中的Student类简单地继承自本章中采用 pImpl 模式的Person类。您遇到了什么困难吗?现在,修改您的Student类,另外利用独特指针实现 pImpl 模式。现在,您遇到了什么困难吗?

  3. 您能想象其他什么例子可能合理地将 pImpl 模式纳入相对独立的实现中?

第二十一章:评估

每章的编程解决方案可以在我们的 GitHub 存储库的以下 URL 找到:github.com/PacktPublishing/Demystified-Object-Oriented-Programming-with-CPP/tree/master。每个完整的程序解决方案可以在 GitHub 的适当章节标题下(子目录,如Chapter01)的Assessments子目录中找到,文件名对应于章节编号,后跟着该章节中的解决方案编号的破折号。例如,第一章问题 3 的解决方案可以在 GitHub 目录中的Chapter01/Assessments子目录中的名为Chp1-Q3.cpp的文件中找到。

非编程问题的书面答复可以在以下部分找到。如果一个练习有编程部分和后续问题,后续问题的答案可以在下一部分和 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 存储库中的Chapter01/Assessments/Chp1-Q3.cpp

第二章 - 添加语言必需品

  1. 函数的签名是函数的名称加上其类型和参数数量(没有返回类型)。这与名称修饰有关,因为签名帮助编译器为每个函数提供一个唯一的内部名称。例如,void Print(int, float);可能有一个名称修饰为Print_int_float();。这通过为每个函数提供一个唯一的名称来促进重载函数,因此当调用被执行时,可以根据内部函数名称明确调用哪个函数。

  2. 在 GitHub 存储库中的Chapter02/Assessments/Chp2-Q2.cpp

第三章 - 间接寻址:指针

  1. 在 GitHub 存储库中的Chapter03/Assessments/Chp3-Q1.cpp

Print(Student)Print(const Student *)效率低,因为这个函数的初始版本在堆栈上传递整个对象,而重载版本只在堆栈上传递一个指针。

  1. 假设我们有一个指向Student类型对象的现有指针,比如:

Student *s0 = new Student;(这个Student还没有用数据初始化)

const Student *s1;(不需要初始化)

Student *const s2 = s0;(需要初始化)

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 张图片的一月份的图片集。这组一月份的图片是一个 3-D 数组(2-D 用于图像,第三维用于包含一月份的 31 张图片的集合)。你可能希望对每个月都做同样的事情。我们可以创建一个第四维来将一年的数据收集到一个集合中,而不是为每个月的图像集创建单独的 3-D 数组变量。第四维将为一年的 12 个月中的每个月都有一个元素。那么 5-D 数组呢?你可以通过将第五维作为收集各年数据的方式来扩展这个图像的想法,比如收集一个世纪的图像(第五维)。现在我们有了按世纪组织的图像,然后按年份组织,然后按月份组织,最后按图像组织(图像需要前两个维度)。

第四章 - 间接寻址:引用

  1. 在 GitHub 存储库中的Chapter04/Assessments/Chp4-Q1.cpp

ReadData(Student *)接受一个指向Student的指针和引用变量不仅需要调用接受Student引用的ReadData(Student &)版本。例如,指针变量可以使用*取消引用,然后调用接受引用的版本。同样,引用变量可以使用&取其地址,然后调用接受指针的版本(尽管这种情况较少见)。你只需要确保传递的数据类型与函数期望的匹配。

第五章 - 详细探讨类

  1. 在 GitHub 存储库中的Chapter05/Assessments/Chp5-Q1.cpp

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

  1. 在 GitHub 存储库中的Chapter06/Assessments/Chp6-Q1.cpp

  2. 在 GitHub 存储库中的Chapter06/Assessments/Chp6-Q2.cpp

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

  1. 在 GitHub 存储库中的Chapter07/Assessments/Chp7-Q1.cpp

第八章 - 掌握抽象类

  1. 在 GitHub 存储库中的Chapter08/Assessments/Chp8-Q1.cpp

Shape类可能被视为接口类,也可能不是。如果你的实现是一个不包含数据成员,只包含抽象方法(纯虚函数)的抽象类,那么你的Shape实现被认为是一个接口类。然而,如果你的Shape类在派生类中的重写Area()方法计算出area后将其存储为数据成员,那么它只是一个抽象基类。

第九章 - 探索多重继承

  1. 请参阅 GitHub 存储库中的Chapter09/Assessments/Chp9-Q1.cpp

LifeForm子对象。

LifeForm构造函数和析构函数各被调用一次。

如果Centaur构造函数的成员初始化列表中删除了LifeForm(1000)的替代构造函数的规范,则将调用LifeForm

  1. 请在 GitHub 存储库中查看Chapter09/Assessments/Chp9-Q2.cpp

LifeForm子对象。

LifeForm构造函数和析构函数各被调用两次。

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

  1. 请在 GitHub 存储库中查看Chapter10/Assessments/Chp10-Q1.cpp

(后续问题)一旦您重载了一个接受University &作为参数的构造函数,可以通过首先取消引用构造函数调用中的University指针来调用这个版本(使其成为可引用的对象)。

  1. 在 GitHub 存储库中的Chapter10/Assessments/Chp10-Q2.cpp

  2. 在 GitHub 存储库中的Chapter10/Assessments/Chp10-Q3.cpp

第十一章-处理异常

  1. 在 GitHub 存储库中的Chapter11/Assessments/Chp11-Q1.cpp

第十二章-友元和运算符重载

  1. 请在 GitHub 存储库中查看Chapter12/Assessments/Chp12-Q1.cpp

  2. 请在 GitHub 存储库中查看Chapter12/Assessments/Chp12-Q2.cpp

  3. 请在 GitHub 存储库中查看Chapter12/Assessments/Chp12-Q3.cpp

第十三章-使用模板

  1. 在 GitHub 存储库中的Chapter13/Assessments/Chp13-Q1.cpp

  2. 请在 GitHub 存储库中查看Chapter13/Assessments/Chp13-Q2.cpp

第十四章-理解 STL 基础

  1. 在 GitHub 存储库中的Chapter14/Assessments/Chp14-Q1.cpp

  2. 请在 GitHub 存储库中查看Chapter14/Assessments/Chp14-Q2.cpp

  3. 请在 GitHub 存储库中查看Chapter14/Assessments/Chp14-Q3.cpp

  4. 请在 GitHub 存储库中查看Chapter14/Assessments/Chp14-Q4.cpp

第十五章-测试类和组件

  1. a:如果每个类都包括(用户指定的)默认构造函数、复制构造函数、重载的赋值运算符和虚析构函数,则您的类遵循正统的规范类形式。如果它们还包括移动复制构造函数和重载的移动赋值运算符,则您的类还遵循扩展的规范类形式。

b:如果您的类遵循规范类形式,并确保类的所有实例都具有完全构造的手段,则您的类将被视为健壮的。测试类可以确保健壮性。

  1. 在 GitHub 存储库中的Chapter15/Assessments/Chp15-Q2.cpp

  2. 请在 GitHub 存储库中查看Chapter15/Assessments/Chp15-Q3.cpp

第十六章-使用观察者模式

  1. 在 GitHub 存储库中的Chapter16/Assessments/Chp16-Q1.cpp

  2. 其他很容易包含观察者模式的例子包括任何需要顾客接收所需产品缺货通知的应用程序。例如,许多人可能希望接种 Covid-19 疫苗,并希望在疫苗分发站的等候名单上。在这里,VaccineDistributionSite(感兴趣的主题)可以从Subject继承,并包含一个Person对象列表,其中Person继承自ObserverPerson对象将包含一个指向VaccineDistributionSite的指针。一旦在给定的VaccineDistributionSite上存在足够的疫苗供应(即,分发事件已发生),就可以调用Notify()来更新Observer实例(等候名单上的人)。每个Observer将收到一个Update(),这将是允许该人安排约会的手段。如果Update()返回成功并且该人已经安排了约会,Observer可以通过Subject从等候名单中释放自己。

第十七章-应用工厂模式

  1. 在 GitHub 存储库中的Chapter17/Assessments/Chp17-Q1.cpp

  2. 其他可能很容易融入工厂方法模式的例子包括许多类型的应用程序,其中根据提供的特定值实例化各种派生类可能是必要的。例如,工资单应用程序可能需要各种类型的Employee实例,如ManagerEngineerVice-President等。工厂方法可以根据雇佣Employee时提供的信息来实例化各种类型的Employee。工厂方法模式是一种可以应用于许多类型的应用程序的模式。

第十八章 - 应用适配器模式

  1. 在 GitHub 存储库中的Chapter18/Assessments/Chp18-Q1.cpp

  2. 其他可能很容易融入适配器模式的例子包括许多重用现有、经过充分测试的非面向对象代码以提供面向对象接口(即适配器的包装类型)的例子。其他例子包括创建一个适配器,将以前使用的类转换为当前需要的类(再次使用先前创建和经过充分测试的组件的想法)。一个例子是将以前用于表示汽油发动机汽车的Car类改编为模拟ElectricCar的类。

第十九章 - 使用单例模式

  1. Chapter19/Assessments/Chp19-Q1.cpp

  2. 我们不能将Singleton中的static instance()方法标记为虚拟的,并在President中重写它,因为静态方法永远不可能是虚拟的。它们是静态绑定的,也永远不会接收到this指针。此外,签名可能需要不同(没有人喜欢无意的函数隐藏情况)。

  3. 其他例子可能很容易地融入单例模式,包括创建一个公司的单例CEO,或者一个国家的单例TreasuryDepartment,或者一个国家的单例Queen。这些单例实例都提供了建立注册表以跟踪多个单例对象的机会。也就是说,许多国家可能只有一个Queen。在这种情况下,注册表不仅允许每种对象类型有一个单例,而且还允许每个其他限定符(如国家)有一个单例。这是一个罕见的例子,其中同一类型的单例对象可能会出现多个(但始终是受控数量的对象)。

第二十章 - 使用 pImpl 模式去除实现细节

  1. 请参阅 GitHub 存储库中的Chapter20/Assessments/Chp20-Q1.cpp

  2. 请参阅 GitHub 存储库中的Chapter20/Assessments/Chp20-Q2.cpp

(后续问题)在本章中,从Person类中简单地继承Student,这个类采用了 pImpl 模式,不会出现后勤上的困难。此外,修改Student类以使用 pImpl 模式并利用独特指针更具挑战性。各种方法可能会遇到各种困难,包括处理内联函数、向下转型、避免显式调用底层实现,或需要反向指针来帮助调用虚拟函数。有关详细信息,请参阅在线解决方案。

  1. 其他可能很容易融入 pImpl 模式以实现相对独立的实现的例子包括创建通用的 GUI 组件,比如WindowScrollbarTextbox等,用于各种平台(派生类)。实现细节可以很容易地隐藏起来。其他例子包括希望隐藏在头文件中可能看到的实现细节的专有商业类。
posted @ 2024-05-15 15:26  绝不原创的飞龙  阅读(1)  评论(0编辑  收藏  举报