第9章 类和对象

    至此,您探索了简单程序的结构。这种程序从 main()开始执行,让您能够声明局部和全局变量和常量、将执行逻辑划分为可接受参数和返回值的函数。这与C语言等过程型语言很像,不涉及面向对象。换句话说,您需要学习如何管理数据并将其与方法关联起来。

    在本章中,您将学习:

    • 什么是类;

    • 类如何帮助您整合数据和处理数据的方法(类似于函数);

    • 构造函数、复制构造函数和析构函数;

    • C++11如何通过移动构造函数改进性能;

    • 封装和抽象等面向对象的概念;

    • this指针;

    • 结构是什么,它与类有何不同。

    9.1 类和对象

    假设您要编写一个模拟人(如您自己)的程序。人有其特征:姓名、出生日期、出生地和性别,还能做某些事情,如交谈、自我介绍等。前述特征是有关人的数据,而能做的事情是方法,如图 9.1所示。

    第9章 类和对象 - 图1

    图9.1 人的简单表示

    要模拟人,需要一个结构,将定义人的属性(数据)以及人可使用这些属性执行的操作(类似于函数的方法)整合在一起。这种结构就是类。

    9.1.1 声明类

    要声明类,可使用关键字class,并在它后面依次包含类名、一组放在{}内的成员属性和方法以及结尾的分号。

    类声明类似于函数声明,将类本身及其属性告诉编译器。类声明本身并不能改变程序的行为,而必须使用它,就像需要调用函数一样。

    模拟人类的类类似于下面这样(请暂时不要考虑其中的语法):

    第9章 类和对象 - 图2

    不用说,IntroduceSelf()将使用Talk()以及整个在类Human中的一些数据。通过关键字class,C++提供了一种功能强大的方式,让您能够创建自己的数据类型,并在其中封装属性和使用它们的函数。类的所有属性(这里是 Name、DateOfBirth、PlaceOfBirth 和 Gender)以及在其中声明的函数(Talk()和IntroduceSelf())都是类(Human)的成员。

    封装指的是将数据以及使用它们的方法进行逻辑编组,这是面向对象编程的重要特征。

    第9章 类和对象 - 图3方法就是属于类成员的函数。

    9.1.2 实例化对象

    类相当于蓝图,仅声明类并不会对程序的执行产生影响。在运行阶段,对象是类的化身。要使用类的功能,通常需要根据类实例化一个对象,并通过对象访问成员方法和属性。

    实例化Human对象与创建其他类型(如double)的实例类似:

    第9章 类和对象 - 图4

    就像为int动态分配内存一样,也可使用new为Human对象动态地分配内存:

    第9章 类和对象 - 图5

    9.1.3 使用句点运算符访问成员

    一个人的例子是Tom,男性,1970年出生于阿拉巴马州。Tom是Human类的对象,是这个类存在于现实世界(运行阶段)的化身:

    第9章 类和对象 - 图6

    类声明表明,Tom有DateOfBirth等属性,可使用句点运算符(.)来访问:

    第9章 类和对象 - 图7

    这是因为从类声明表示的蓝图可知,属性DateOfBirth是类Human的一部分。仅当实例化了一个对象后,这个属性在现实世界(运行阶段)才存在。句点运算符(.)用于访问对象的属性。

    这也适用于IntroduceSelf()等方法:

    第9章 类和对象 - 图8

    如果有一个指针pTom,它指向Human类的一个实例,则可使用指针运算符(->)来访问成员(这将在下一小节介绍),也可使用间接运算符(*)来获取对象,再使用句点运算符来访问成员:

    第9章 类和对象 - 图9

    9.1.4 使用指针运算符(->)访问成员

    如果对象是使用 new在自由存储区中实例化的,或者有指向对象的指针,则可使用指针运算符(->)来访问成员属性和方法:

    第9章 类和对象 - 图10

    程序清单9.1是一个值得编译的Human类,其中使用了关键字private和public等。

    程序清单9.1 一个值得编译的Human类

    第9章 类和对象 - 图11

    输出:

    第9章 类和对象 - 图12

    分析:

    第4~6行说明了C++类的基本构造。请从实用的角度浏览这些代码,忽略还不明白的术语和概念,它们将在本章后面详细介绍。请重点关注Human类的结构以及如何在main()中使用这个类。

    这个类包含两个私有(private)变量,其中一个名为Name,类型为string(第7行),另一个名为Age,类型为int(第8行)。还有几个使用这些私有变量的公有函数(也叫方法):SetName()、SetAge()和IntroduceSelf(),如第11、16和21行所示。在main()中,第31和36行创建了两个Human对象。接下来的几行代码使用方法SetName()和SetAge()设置对象FirstMan和FirstWoman的成员变量,这些方法被称为存取器方法。注意到第40和41行对这两个对象调用了方法IntroduceSelf(),该方法使用前面设置的成员变量现实两行输出。

    您注意到了程序清单9.1中的关键字private和public吗?下面介绍C++提供的这样的功能,即保护属性,对外部隐藏它们。

    9.2 关键字public和private

    每个人都有很多个人信息,其中的有些向周围的人公开,如姓名。这样的信息被称为公有的。然而,有些个人信息您可能不想让外人知道,如收入。这种信息是私有的,通常保密。

    C++让您能够将类属性和方法声明为公有的,这意味着有了对象后就可获取它们;也可将其声明为私有的,这意味着只能在类的内部(或其友元)中访问。作为类的设计者,您可使用 C++关键字public和private来指定哪些部分可从外部(如main())访问,哪些部分不能。

    对程序员来说,能够将属性或方法声明为私有的(private)有何优点呢?请看下述Human类的声明(忽略除成员属性Age外的其他代码):

    第9章 类和对象 - 图13

    假设有一个名为Eve的Human实例:

    第9章 类和对象 - 图14

    如果试图使用下述代码访问Eve的年龄:

    第9章 类和对象 - 图15

    将出现类似于这样的编译错误:“错误:Human::Age——不能访问Human类声明的私有成员”。要访问Age,唯一的途径就是通过Human类的公有方法GetAge(),这个方法以编写Human类的程序员认为的合适方式暴露Age:

    第9章 类和对象 - 图16

    如果编写Human类的程序员愿意,可以让GetAge()显示的年龄比Eve的实际年龄小!换句话说,这意味着C++允许类决定要暴露哪些属性以及如何暴露。如果Human类没有公有成员方法GetAge(),就可确保用户根本无从查询Age。在本章后面将介绍的情形下,这种功能很有用。

    同样,也不能直接给Human::Age赋值:

    第9章 类和对象 - 图17

    要设置年龄,唯一的途径是通过SetAge():

    第9章 类和对象 - 图18

    这有很多优点。当前,SetAge()的实现只是直接设置成员变量 Human::Age,但也可在 SetAge()中验证外部输入,避免Age被设置为零或负数:

    第9章 类和对象 - 图19

    总之,C++让类的设计者能够控制类属性的访问和操纵方式。

    9.2.1 使用关键字private实现数据抽象

    除让您能够将类设计容器,以封装数据以及操作数据的方法外,C++还让您能够使用关键字 private指定哪些信息不能从外部访问(即在类外不可用)。另外,可将方法声明为公有的(public),以便从外部通过这些方法访问私有信息。因此,类的实现可对外(其他类和 main()等函数)隐藏您认为它们无需知道的东西。

    回到Human类,其中的Age是一个私有成员。您知道,在现实世界中,很多人不想公开自己的真实年龄。要 Human 类向外指出的年龄比实际年龄小两岁很容易,只需在公有方法 GetAge()中将 Age减2,再返回结果,如程序清单9.2所示。

    程序清单9.2 一个对外隐藏真实年龄并将自己说得更年轻的Human类

    第9章 类和对象 - 图20

    输出:

    第9章 类和对象 - 图21

    分析:

    请注意第16行的公有方法Human::GetAge()。由于实际年龄存储在私有成员Human::Age中,而该成员不能从外部直接访问,因此外部用户要获悉 Human 对象的 Age 属性,唯一的途径是通过方法GetAge()。也就是说,对外隐藏了存储在Human::Age中的实际年龄。

    在面向对象编程语言中,抽象是一个非常重要的概念,让程序员能够决定哪些属性只能让类及其成员知道,类外的任何人都不能访问(友元除外)。

    9.3 构造函数

    构造函数是一种特殊的函数(方法),在创建对象时被调用。与函数一样,构造函数也可以重载。

    9.3.1 声明和实现构造函数

    构造函数是一种特殊的函数,它与类同名且不返回任何值。因此,Human类的构造函数的声明类似于下面这样:

    第9章 类和对象 - 图22

    这个构造函数可在类声明中实现,也可在类声明外实现。在类声明中实现(定义)构造函数的代码类似于下面这样:

    第9章 类和对象 - 图23

    在类声明外定义构造函数的代码类似于下面这样:

    第9章 类和对象 - 图24

    第9章 类和对象 - 图25::被称为作用域解析运算符。例如,Human::DateOfBirth指的是在Human类中声明的变量DateOfBirth,而::DateOfBirth表示全局作用域中的变量DateOfBirth。

    9.3.2 何时及如何使用构造函数

    构造函数总是在创建对象时被调用,这让构造函数是将类成员变量(int、指针等)初始化为已知值的理想场所。再看一下程序清单9.2。如果忘记调用SetAge(),int变量Human::Age将包含未知的垃圾值,因为该变量未初始化(请尝试将第28和30行注释掉)。程序清单9.3是一个更好的Human类版本,它使用构造函数初始化Age。

    程序清单9.3 使用构造函数初始化类成员变量

    第9章 类和对象 - 图26

    输出:

    第9章 类和对象 - 图27

    分析:

    除与程序清单 9.1 相同的输出外,开头还新增了两行。来看看第 36~48 行的 main(),从中可知,新增的两行输出是第 38 和 42 行创建两个对象(FirstMan 和 FirstWoman)间接生成的。由于它们是 Human类对象,创建它们将自动执行第 13~17行定义的 Human类构造函数。这个构造函数包含的 cout 语句生成了这些输出。注意到该构造函数还将 Age 初始化为零。如果您忘记给新创建的对象设置 Age 也没有关系,该构造函数将确保 Age 不是随机值而是零,这表明没有设置属性Age。

    第9章 类和对象 - 图28可在不提供参数的情况下调用的构造函数被称为默认构造函数。默认构造函数是可选的。如果您像程序清单 9.1 那样没有提供默认构造函数,编译器将为您创建一个,这种构造函数会创建成员属性,但不会初始化POD类型(如int)的属性。

    9.3.3 重载构造函数

    与函数一样,构造函数也可重载,因此可创建一个将姓名作为参数的构造函数,如下所示:

    第9章 类和对象 - 图29

    程序清单9.4演示了重载构造函数的用途,它在创建Human对象时提供了姓名。

    程序清单9.4 包含多个构造函数的Human类

    第9章 类和对象 - 图30

    输出:

    第9章 类和对象 - 图31

    分析:

    Adam 是使用默认构造函数创建的;创建 Eve 时使用的是一个重载的构造函数,该构造函数接受一个string参数,并将其赋给Human::Name;Rose是使用第三个重载的构造函数创建的,该构造函数接受一个string参数和一个int参数,并将int参数赋给Human::Age。这个程序表明,重载构造函数很有用,可帮助您初始化变量。

    第9章 类和对象 - 图32您可选择不实现默认构造函数,从而要求实例化对象时必须提供某些参数。

    9.3.4 没有默认构造函数的类

    在程序清单9.5中,Human类没有默认构造函数,要求创建Human对象时必须提供姓名和年龄。

    程序清单9.5 一个有重载的构造函数,但没有默认构造函数的类

    第9章 类和对象 - 图33

    输出:

    第9章 类和对象 - 图34

    分析:

    请注意main()中的第32行,它与程序清单9.3中创建FirstMan的代码很像,但如果您取消对它的注释,程序将不能通过编译,出现的错误消息为:error:‘Human’:no appropriate default constructor available。这是因为这个版本的Human 类只有一个构造函数,该构造函数接受一个string参数和一个int参数,如第14行所示。没有默认构造函数,而在您提供了重载的构造函数时,C++编译器不会为您生成默认构造函数。

    这个示例表明,设计类时,可要求创建其对象时必须提供某些参数,就这里而言,是创建Human对象时必须提供Name和Age。这个示例还表明,可在创建Human对象时提供Name,且以后不能修改它。这是因为Human类的Name属性存储在私有string变量Name中,main()或其他不属于Human类成员的实体不能访问或修改它。换句话说,Human类的用户创建每个对象时,都必须指定姓名(和年龄),且不能修改姓名,这与现实情况相当吻合,您说呢?您的姓名是父母在您出生时取的,别人可以知道,但任何人都无权修改(您自己除外)。

    9.3.5 带默认值的构造函数参数

    就像函数可以有带默认值的参数一样,构造函数也可以。在下面的代码中,对程序清单 9.5 中第14行的构造函数稍做了修改,给参数Age指定了默认值25:

    第9章 类和对象 - 图35

    实例化这个类的对象时,可使用下面的语法:

    第9章 类和对象 - 图36

    第9章 类和对象 - 图37默认构造函数是调用时可不提供参数的构造函数,而并不一定是不接受任何参数的构造函数。因此,下面的构造函数虽然有两个参数,但它们都有默认值,因此也是默认构造函数:

    第9章 类和对象 - 图38

    因为实例化Human对象时仍可不提供任何参数:

    第9章 类和对象 - 图39

    9.3.6 包含初始化列表的构造函数

    您知道,构造函数对初始化成员很有用。另一种初始化成员的方式是使用初始化列表。对于程序清单9.5中接受两个参数的构造函数,其包含初始化列表的变种类似于下面这样:

    第9章 类和对象 - 图40

    初始化列表由包含在括号中的参数声明后面的冒号标识,冒号后面列出了各个成员变量及其初始值。初始值可以是参数(如InputName),也可以是固定的值。使用特定参数调用基类的构造函数时,初始化列表也很有用,这将在第10章讨论。

    在程序清单 9.6中,Human 类包含一个带初始化列表的默认构造函数,该默认构造函数的参数都有默认值。

    程序清单9.6 接受带默认值的参数的默认构造函数,并使用初始化列表来设置成员

    第9章 类和对象 - 图41

    输出:

    第9章 类和对象 - 图42

    分析:

    第11~16行的构造函数包含初始化列表,且用于设置Name和Age的参数分别包含默认值“Adam”和25。因此,第21行创建Human类的实例FirstMan时,自动将默认值赋给了其成员。另一方面,第22行创建FirstWoman时,给Name和Age显式地提供了值。

    9.4 析构函数

    与构造函数一样,析构函数也是一种特殊的函数。与构造函数不同的是,析构函数在对象销毁时自动被调用。

    9.4.1 声明和实现析构函数

    与构造函数一样,析构函数也看起来像一个与类同名的函数,但前面有一个波浪号(~)。因此, Human类的析构函数的声明类似于下面这样:

    第9章 类和对象 - 图43

    这个析构函数可在类声明中实现,也可在类声明外实现。在类声明中实现(定义)析构函数的代码类似于下面这样:

    第9章 类和对象 - 图44

    在类声明外定义析构函数的代码类似于下面这样:

    第9章 类和对象 - 图45

    正如您看到的,析构函数的声明与构造函数稍有不同,那就是包含波浪号(~)。然而,析构函数的作用与构造函数完全相反。

    9.4.2 何时及如何使用析构函数

    每当对象不再在作用域内或通过 delete 被删除,进而被销毁时,都将调用析构函数。这使得析构函数是重置变量以及释放动态分配的内存和其他资源的理想场所。

    使用C风格char缓冲区时,您必须自己管理内存分配等,因此本书始终建议不要使用它们,而使用std::string。std::string等工具都是类,它们充分利用了构造函数和析构函数,还有将在第12章介绍的运算符。程序清单9.7所示的类MyString在构造函数中为一个字符串分配内存,并在析构函数中释放它。

    程序清单9.7 一个简单的类,它封装了一个C风格字符串并通过析构函数释放它

    第9章 类和对象 - 图46

    输出:

    第9章 类和对象 - 图47

    分析:

    这个类封装了一个C风格字符串(MyString::Buffer),让您使用字符串时无需分配和释放内存。我们最感兴趣的是第10~19行的构造函数MyString()以及第21~26行的析构函数~MyString()。这个构造函数构造MyString对象。它通过输入参数获取一个输入字符串;然后使用strlen确定输入字符串的长度并为C风格字符串Buffer分配内存(第14行);再使用strcpy将输入字符串复制到新分配的内存中(第15行)。如果传递给参数InitialInput的值为NULL,MyString::Buffer也被初始化为NULL (以防该指针包含随机值,否则使用它来访问内存单元将非常危险)。析构函数的代码确保构造函数分配的内存自动被归还给系统。它检查MyString::Buffer是否为NULL,如果不是,则对其执行delete[],这对应于构造函数中的new[…]。注意到在main()中,程序员无需调用new和delete。这个类不仅对程序员隐藏了实现,还正确地释放了分配的内存。main()执行完毕时,将自动调用析构函数~MyString(),输出证明了这一点——其中包含析构函数中cout语句的输出。

    类更好地处理了字符串,这是析构函数的众多用途之一。在更智能地使用指针方面,析构函数也扮演了重要角色,第26章将演示这一点。

    第9章 类和对象 - 图48析构函数不能重载,每个类都只能有一个析构函数。如果您忘记了实现析构函数,编译器将创建一个伪(dummy)析构函数并调用它。伪析构函数为空,即不释放动态分配的内存。

    9.5 复制构造函数

    第7章介绍过,对于程序清单7.1中的函数Area(),传递的实参被复制:

    第9章 类和对象 - 图49

    因此,调用Area()时,实参被复制给形参InputRadius。这种规则也适用于对象(类的实例)。

    9.5.1 浅复制及其存在的问题

    程序清单9.7所示的MyString类包含一个指针成员,它指向动态分配的内存(这些内存是在构造函数中使用new分配的,并在析构函数中使用delete[]进行释放)。复制这个类的对象时,将复制其指针成员,但不复制指针指向的缓冲区,其结果是,两个对象指向同一块动态分配的内存。这被称为浅复制,会威胁程序的稳定性,如程序清单9.8所示。

    程序清单9.8 按值传递类(如MyString)的对象带来的问题

    第9章 类和对象 - 图50

    输出:

    第9章 类和对象 - 图51

    第9章 类和对象 - 图52

    图9.2 在MS Visual Studio调试模式下执行程序清单9.8时出现的崩溃屏幕

    分析:

    在程序清单9.7中,这个类运行正常,为何会导致程序清单9.8崩溃呢?相比于程序清单9.7,程序清单 9.8 唯一不同的地方在于,在 main()中,将使用 MyString 对象 SayHello 的工作交给了函数UseMyString,如第54行所示。在main()中将工作交给这个函数的结果是,对象SayHello被复制到形参Input,并在UseMyString()中使用它。编译器之所以进行复制,是因为函数SayHello的参数Input被声明为按值(而不是按引用)传递。对于整型、字符和原始指针等POD数据,编译器执行二进制复制,因此SayHello.Buffer包含的指针值被复制到Input中,即SayHello.Buffer和Input.Buffer指向同一个内存单元,如图9.3所示。

    第9章 类和对象 - 图53

    图9.3 调用UseMyString()时,SayHelo被浅复制到 Input中

    二进制复制并不深复制指向的内存单元,这导致两个 MyString 对象指向同一个内存单元。函数UseMyString()返回时,变量Input不再在作用域内,因此被销毁。为此,将调用MyString类的析构函数,而该析构函数使用delete释放分配给Buffer的内存(如程序清单9.8的第26行所示)。这将导致main()中的对象 SayHello 指向的内存无效,而等 main()执行完毕时,SayHello 将不再在作用域内,进而被销毁。然而,第26行对不再有效的内存地址调用 delete(销毁Input时释放了该内存,导致它无效)。图9.2的调试断言消息指出错误出在第52行(本书的行号从零开始,因此是第51行),因为未能成功地销毁这里创建的对象SayHello。

    第9章 类和对象 - 图54在这里,编译器没有进行深复制,因为编译时它不确定指针成员MyString::Buffer指向的是多少字节的内存。

    9.5.2 使用复制构造函数确保深复制

    复制构造函数是一个特殊的重载构造函数,编写类的程序员必须提供它。每当对象被复制(包括将对象按值传递给函数)时,编译器都将调用复制构造函数。

    为MyString类声明复制构造函数的语法如下:

    第9章 类和对象 - 图55

    复制构造函数接受一个以引用方式传入的当前类的对象作为参数。这个参数是源对象的别名,您使用它来编写自定义的复制代码,确保对所有缓冲区进行深复制,如程序清单9.9所示。

    程序清单9.9 定义一个复制构造函数,确保对动态分配的缓冲区进行深复制

    第9章 类和对象 - 图56

    输出:

    第9章 类和对象 - 图57

    分析:

    大多数代码都与程序清单9.8类似,只是构造函数新增了多行cout语句,还新增了一个复制构造函数(第27~45行)。首先,将重点放在main()上。与前一个示例一样,它创建了对象SayHello,如第77行所示。创建SayHello导致了第1行输出,这是由MyString的构造函数的第12行生成的。出于方便考虑,这个构造函数还显示了Buffer指向的内存地址。接下来,main()将SayHello按值传递个函数 UseMyString(),如第 80 行所示,这将自动调用复制构造函数,输出指出了这一点。复制构造函数的代码与构造函数很像,基本思想也相同:检查CopySource.Buffer包含的C风格字符串的长度(第34行),分配相应数量的内存并将返回的指针赋给Buffer,再使用strcpy将CopySource.Buffer的内容复制到Buffer(第37行)。这里并非浅复制(复制指针的值),而是深复制,即将指向的内容复制到给当前对象新分配的缓冲区中,如图9.4所示。

    第9章 类和对象 - 图58

    图9.4 调用函数UseMyString()时,将实参SayHelo深复制到形参 Input中

    程序清单9.9 的输出表明,拷贝中的 Buffer 指向内存地址不同,即两个对象并未指向同一个动态分配的内存地址。因此,函数 UseMyString()返回、形参 Input 被销毁时,析构函数对复制构造函数分配的内存地址调用delete[],而没有影响main()中SayHello指向的内存。因此,这两个函数都执行完毕时,成功地销毁了各自的对象,没有导致应用程序崩溃。

    第9章 类和对象 - 图59复制构造函数确保下面的函数调用进行深复制:

    第9章 类和对象 - 图60

    然而,如果您通过赋值进行复制时,结果如何呢?

    第9章 类和对象 - 图61

    由于您没有指定任何赋值运算符,编译器提供的默认赋值运算符将导致浅复制。为避免赋值时进行浅复制,您需要实现复制赋值运算符(=)。

    复制赋值运算符将在第12章深入讨论。程序清单12.9是改进后的MyString,它实现了复制赋值运算符:

    第9章 类和对象 - 图62

    第9章 类和对象 - 图63通过在复制构造函数声明中使用const,可确保复制构造函数不会修改指向的源对象。另外,复制构造函数的参数必须按引用传递,否则调用它时将复制实参的值,导致对源数据进行浅复制——这正是您要极力避免的。

    第9章 类和对象 - 图64

    第9章 类和对象 - 图65 MyString类包含原始指针成员 char Buffer,这里使用它旨在阐述为何需要复制构造函数。如果您编写类时需要包含字符串成员,用于存储姓名等,应使用 std::string 而不是char。在没有使用原始指针的情况下,您都不需要编写复制构造函数。这是因为编译器添加的默认复制构造函数将调用成员对象(如std::string)的复制构造函数。

    C++11

    9.5.3 有助于改善性能的移动构造函数

    由于C++的特征和需求,有些情况下对象会自动被复制。请看下面的代码:

    第9章 类和对象 - 图66

    正如注释指出的,实例化sayHelloAgain时,由于调用了函数Copy(sayHello),而它按值返回一个MyString,因此调用了复制构造函数两次。然而,这个返回的值存在时间很短,且在该表达式外不可用。因此,C++编译器严格地调用复制构造函数反而降低了性能,如果复制的是很大的动态对象数组,对性能的影响将很大。

    为避免这种性能瓶颈,除编写复制构造函数,还应编写一个移动构造函数。移动构造函数的语法如下:

    第9章 类和对象 - 图67

    有移动复制构造函数时,C++11 编译器将自动使用它来“移动”临时资源,从而避免深复制。实现移动构造函数后,应将前面的注释改成下面这样:

    第9章 类和对象 - 图68

    移动构造函数通常是利用移动赋值运算符实现的,这将在第12章更详细地讨论。程序清单12.12是一个更好的MyString版本,实现了移动构造函数和移动赋值运算符。

    9.6 构造函数和析构函数的其他用途

    本章介绍了几个重要的基本概念,如构造函数、析构函数以及使用关键字public和private等抽象数据和方法。等您设计类时,这些概念让您能够控制其对象的创建、复制和销毁方式,还可控制其数据的暴露方式。

    下面介绍几种有趣的模式,它们可帮助您解决众多重要的设计问题。

    9.6.1 不允许复制的类

    假设您需要模拟国家的政体。一个国家只能有一位总统,而President类面临如下风险:

    第9章 类和对象 - 图69

    显然,需要避免这样的情况发生。编写操作系统时,您需要模拟一个局域网、一个处理器等,为此需要避免这样的资源被复制。如果您不声明复制构造函数,C++将为您添加一个公有的默认复制构造函数,这破坏了您的设计,威胁着您的实现。然而,C++提供了实现这种设计范式的解决方案。

    要禁止类对象被复制,可声明一个私有的复制构造函数。这确保函数调用DoSomething(OurPresident)无法通过编译。为禁止赋值,可声明一个私有的赋值运算符。

    因此,解决方案如下:

    第9章 类和对象 - 图70

    无需给私有复制构造函数和私有赋值运算符提供实现,只需将它们声明为私有的就足以实现您的目标:确保President的对象是不可复制的。

    9.6.2 只能有一个实例的单例类

    前面讨论的President类很不错,但存在一个缺陷:无法禁止通过实例化多个对象来创建多名总统:

    第9章 类和对象 - 图71

    由于复制构造函数是私有的,其中每个对象都是不可复制的,但您的目标是确保President类有且只有一个化身,即有了一个President对象后,就禁止创建其他的President对象。要实现这种功能强大的模式,可使用单例的概念,它使用私有构造函数、私有赋值运算符和静态实例成员。

    第9章 类和对象 - 图72将关键字static用于类的数据成员时,该数据成员将在所有实例之间共享。

    将static用于函数中声明的局部变量时,该变量的值将在两次调用之间保持不变。

    将static用于成员函数(方法)时,该方法将在所有成员之间共享。

    要创建单例类,关键字static必不可少,如程序清单9.10所示。

    程序清单9.10 单例类President,它禁止复制、赋值以及创建多个实例

    第9章 类和对象 - 图73

    输出:

    第9章 类和对象 - 图74

    分析:

    main()包含几行代码和大量注释,演示了各种创建 President 实例和拷贝的方式,这些方式都行不通。下面逐一进行分析。

    第9章 类和对象 - 图75

    第47和48行分别试图使用默认构造函数在堆和栈上创建对象,但默认构造函数不可用,因为它是私有的,如第8行所示。

    第9章 类和对象 - 图76

    第49行试图使用复制构造函数创建现有对象的拷贝(在创建对象的同时为其赋值,将调用复制构造函数),但在main()中不能使用复制构造函数,因为第11行将其声明成了私有的。

    第9章 类和对象 - 图77

    第50行试图通过赋值创建对象的拷贝,但行不通,因为第14行将赋值运算符声明成了私有的。因此,在main()中,不能创建President类的实例,唯一的方法是使用静态函数GetInstance()来获取 President 的实例,如第 43 行所示。GetInstance()是静态成员,类似于全局函数,无需通过对象来调用它。GetInstance()是在第21~27行实现的,它使用静态变量OnlyInstance确保有且只有一个 President 实例。为更好地理解这一点,可以认为第 24 行只执行一次(静态初始化),因此 GetInstance()返回唯一一个 President 实例,而不管您如何使用该实例,如 main()中的第 43和53行所示。

    第9章 类和对象 - 图78为方便扩充应用程序的功能,仅在绝对必要时才使用单例模式。单例模式禁止创建多个实例,在需要多个实例时,这将变成架构瓶颈。

    例如,如果应用程序从模拟一个国家升级到模拟联合国(当前,联合国有192位成员,因此有192位总统),单例模式将是个架构问题。

    9.6.3 禁止在栈中实例化的类

    栈空间通常有限。如果您要编写一个数据库类,其内部结构包含1GB数据,可能应该禁止在栈上实例化它,而只允许在堆上创建其实例。为此,关键在于将析构函数声明为私有的:

    第9章 类和对象 - 图79

    这样,便不能像下面这样创建MonsterDB类的实例:

    第9章 类和对象 - 图80

    上述代码试图在栈上创建实例。编译器知道,当myDatabase不再在作用域内时,需要将其销毁,因此编译器自动在 main()末尾调用析构函数,但该析构函数是私有的,即不可用,因此上述语句将导致编译错误。

    然而,将析构函数声明为私有的并不能禁止在堆中实例化:

    第9章 类和对象 - 图81

    上述代码将导致内存泄露。由于在main中不能调用析构函数,因此也不能调用delete。为解决这种问题,需要在MonsterDB类中提供一个销毁实例的静态公有函数(作为类成员,它能够调用析构函数),如程序清单9.11所示。

    程序清单9.11 数据库类MonsterDB,只能使用new在自由存储区中创建其对象

    第9章 类和对象 - 图82

    分析:

    这些代码旨在演示如何创建禁止在栈中实例化的类。为此,将构造函数声明成了私有的,如第 6行所示。另外,它还包含静态函数DestroyInstance(),如第9~13行所示;在main()中,第28行调用了该静态函数。

    9.7 this指针

    在C++中,一个重要的概念是保留的关键字this。在类中,关键字this包含当前对象的地址,换句话说,其值为&object。当您在类成员方法中调用其他成员方法时,编译器将隐式地传递this指针——函数调用中不可见的参数:

    第9章 类和对象 - 图83

    在这里,方法 IntroduceSelf()使用私有成员 Talk()在屏幕上显示一句话。实际上,编译器将在调用Talk时嵌入 this指针,即Talk(this, “Blab la”)。

    从编程的角度看,this的用途不多,且大多数情况下都是可选的。例如,在程序清单9.1中,可将SetAge()中访问Age的代码修改成下面这样:

    第9章 类和对象 - 图84

    第9章 类和对象 - 图85调用静态方法时,不会隐式地传递this指针,因为静态函数不与类实例相关联,而由所有实例共享。

    如果要在静态函数中使用实例变量,应显式地声明一个形参,让调用者将实参设置为this指针。

    9.8 将sizeof()用于类

    您知道,通过使用关键字class声明自定义类型,可封装数据属性和使用数据的方法。第3章介绍过,运算符 sizeof()用于确定指定类型需要多少内存(单位为字节)。这个运算符也可用于类,在这种情况下,它将指出类声明中所有数据属性占用的总内存量(单位为字节)。sizeof()可能对某些属性进行填充,使其与字边界对齐,也可能不这样做,这取决于您使用的编译器。用于类时,sizeof()不考虑成员函数及其定义的局部变量,如程序清单9.12所示。

    程序清单9.12 将sizeof用于类及其实例的结果

    第9章 类和对象 - 图86

    输出:

    第9章 类和对象 - 图87

    分析:

    这个示例很长,它包含程序清单9.9所示的MyString类(省略了大部分显示文本的语句)。还包含Human类,这个类使用MyString对象来存储姓名(Name),并新增了bool数据成员Gender。

    首先来分析输出。从中可知,将 sizeof()用于类及其对象时,结果相同。sizeof(MyString)和sizeof(FirstMan)的值相同,因为类占用的字节数在编译阶段就已确定,且在设计时就知道。虽然FirstMan包含Adam,而FirstWoman包含Eve,但它们占用的字节数相同,这没什么可奇怪的,因为存储姓名的MyString::Buffer是一个 char *,这是一个大小固定的指针(在我使用的 32位系统中,为 4字节),而与指向的数据量无关。

    将sizeof()用于Human时,结果为12。第53~55行表明,Human 包含一个int 成员、一个 bool成员和一个MyString成员。要获悉内置类型占用的字节数,请参阅程序清单3.4。从该程序清单可知, int占用4字节,bool占用1字节,而MyString占用4字节。它们的总和与输出中的12字节不符,这是因为 sizeof()的结果受字填充(word padding)和其他因素的影响。

    9.9 结构不同于类的地方

    关键字struct来自C语言,在C++编译器看来,它与类及其相似,差别在于程序员未指定时,默认的访问限定符(public 和 private)不同。因此,除非指定了,否则结构中的成员默认为公有的(而类成员默认为私有的);另外,除非指定了,否则结构以公有方式继承基结构(而类为私有继承)。继承将在第10章详细讨论。

    对于程序清单9.12所示的Human类,对应的结构如下:

    第9章 类和对象 - 图88

    正如您看到的,结构Human与类Human很像;结构的实例化与类的实例化也很像:

    第9章 类和对象 - 图89

    9.10 声明友元

    不能从外部访问类的私有数据成员和方法,但这条规则不适用于友元类和友元函数。要声明友元类或友元函数,可使用关键字friend,如程序清单9.13所示。

    程序清单9.13 使用关键字friend让外部函数DisplayAge()能够访问私有数据成员

    第9章 类和对象 - 图90

    输出:

    第9章 类和对象 - 图91

    分析:

    第10行的声明告诉编译器,函数DisplayAge()是全局函数,可访问Human类的私有数据成员。如果将第10行注释掉,第22行将导致编译错误。

    与函数一样,也可将外部类指定为可信任的朋友,如程序清单9.14所示。

    程序清单9.14 使用关键字friend让外部类Utility能够访问私有数据成员

    第9章 类和对象 - 图92

    输出:

    第9章 类和对象 - 图93

    分析:

    第10行指出Utility类是Human类的友元,这让Utility类的所有方法都能访问Human类的私有数据成员和方法。

    9.11 总结

    本章介绍了最重要的C++关键字和概念——class(类)。您了解到,类封装了成员数据以及使用这些数据的成员函数;您知道,诸如public和private等访问限定符有助于对外部实体隐藏类的数据和功能。

    您学习了复制构造函数的概念,知道 C++让您能够使用移动构造函数消除不必要的复制步骤。您还了解到,通过结合使用这些元素,可实现单例等设计模式。

    9.12 问与答

    问:类实例和类对象有何不同?

    答:基本上是一回事。实例化类时,将获得一个实例,它也被称为对象。

    问:要访问成员,可使用句点运算符(.),也可使用指针运算符(->)。请问哪种方式更好?

    答:如果有一个指向对象的指针,则使用指针运算符最合适;如果栈中实例化了一个对象,并将其存储到了一个局部变量中,则使用句点运算符最合适。

    问:总是应该编写一个复制构造函数吗?

    答:如果类的数据成员是设计良好的智能指针、字符串类或STL容器(如std::vector),则编译器生成的默认复制构造函数将调用成员的复制构造函数。然而,如果类包含原始指针成员(如使用 int*而不是std::vector<int>表示的动态数组),则需要提供设计良好的复制构造函数,确保将类对象按值传递给函数时进行深复制,创建该数组的拷贝。

    问:我的类只有一个构造函数,它接受一个有默认值的参数。请问这个构造函数是默认构造函数吗?

    答:是的。只要在不提供参数的情况下创建实例,就可认为这个类有默认构造函数。每个类都只能有一个默认构造函数。

    问:在本章的有些示例中,使用了函数SetAge()来设置成员Human::Age的值。为何不将其声明为公有的,这样就能在需要时给它赋值了?

    答:从技术角度说,将成员Human::Age声明为公有的也可行,但从设计的角度看,将数据成员声明为私有的是个不错的主意。通过使用 GetAge()和 SetAge()等存取函数,提供了一种更优雅、可扩展性更强的私有数据访问方式,让您在设置或重置Human::Age的值之前,能够执行错误检查。

    问:在复制构造函数中,为何将指向源对象的引用作为参数?

    答:这是编译器对复制构造函数的要求。其原因是,如果按值接受源对象,复制构造函数将调用自己,导致没完没了的复制循环。

    9.13 作业

    作业包括测验和练习,前者帮助读者加深对所学知识的理解,后者提供了使用新学知识的机会。请尽量先完成测验和练习题,然后再对照附录 D 的答案。在继续学习下一章前,请务必弄懂这些答案。

    9.13.1 测验

    1.使用new创建类实例时,将在什么地方创建它?

    2.我的类包含一个原始指针int*,它指向一个动态分配的int数组。请问将 sizeof 用于这个类的对象时,结果是否取决于该动态数组包含的元素数?

    3.假设有一个类,其所有成员都是私有的,且没有友元类和友元函数。请问谁能访问这些成员?

    4.可以在一个类成员方法中调用另一个成员方法吗?

    5.构造函数适合做什么?

    6.析构函数适合做什么?

    9.13.2 练习

    1.查错:下面的类声明有什么错误?

    第9章 类和对象 - 图94

    2.练习1所示类的用户如何访问成员Human::Age?

    3.对练习1中的类进行修改,在构造函数中使用初始化列表对所有参数进行初始化。

    4.编写一个 Circle 类,它根据实例化时提供的半径计算面积和周长。将 Pi 包含在一个私有成员常量中,该常量不能在类外访问。