现代C++编程:从入门到实践
上QQ阅读APP看本书,新人免费读10天
设备和账号都新为新人

2.4 全功能的C++类

POD类只包含数据成员,有时这就是你对类的全部要求。然而,只用POD类设计程序会很复杂。我们可以用封装来处理这种复杂性,封装是一种设计模式,它可以将数据与操作它的函数结合起来。将相关的函数和数据放在一起,至少在两个方面有助于简化代码。首先,可以把相关的代码放在一个地方,这有助于对程序进行推理。它有助于对代码工作原理的理解,因为它在一个地方同时描述了程序状态以及代码如何修改该状态。其次,可以通过一种叫作信息隐藏的做法将类的一些代码和数据相对程序的其他部分隐藏起来。

在C++中,向类定义添加方法和访问控制即可实现封装。

2.4.1 方法

方法就是成员函数。它们在类、其数据成员和一些代码之间建立了明确的联系。定义方法就像在类的定义中加入函数一样简单。方法可以访问类的所有成员。

考虑一个记录年份的示例类ClockOfTheLongNow。以下代码定义了一个int类型的year成员和一个递增它的add_year方法:

add_year方法的声明❶看起来像其他不需要参数也不返回值的函数声明一样。在这个方法中,我们增加了成员year的值❷。代码清单2-19显示了如何使用这个类来跟踪年份。

代码清单2-19 一个使用ClockOfTheLongNow的程序

我们声明了一个ClockOfTheLongNow实例clock❶,然后将clockyear设置为2017❷。接着,调用clockadd_year方法❸,然后打印clock.year的值❹。递增年份❺并再次打印年份❻,这样就完成了这个程序。

2.4.2 访问控制

访问控制可以限制类成员的访问。公有和私有是两个主要的访问控制。任何人都可以访问公有成员,但只有类自身可以访问其私有成员。所有的struct成员默认都是公有的。

私有成员在封装中发挥着重要作用。再次考虑ClockOfTheLongNow类。目前,year成员可以从任何地方访问,包括读访问和写访问。假设我们想防止year的值小于2019,那么可以通过两个步骤来实现:将year设为私有,并要求使用该类的任何人(消费者)只能通过该结构的方法与year进行交互。代码清单2-20说明了这种方法。

代码清单2-20 从代码清单2-19更新的ClockOfTheLongNow,它封装了year

我们向ClockOfTheLongNow添加了两个方法:setter❶和getter❸,用于设置或获取year。我们没有让ClockOfTheLongNow的用户直接修改year,而是用set_year设置year。这个新增的输入验证可以确保new_year永远不会小于2019❷。如果小于2019,代码就会返回false,保持year不被修改。否则,year会被更新并返回true。为了获得year的值,用户需调用get_year

我们已经使用了访问控制标签private❹来禁止消费者访问year。现在,用户只能从ClockOfTheLongNow内部访问year

1.关键字class

我们可以用class关键字代替struct关键字,class关键字默认成员声明为private。除了默认的访问控制外,用structclass关键字声明的类是一样的。例如,可以用下列方式声明ClockOfTheLongNow

以何种方式声明类只是个人风格问题。除了默认的访问控制外,structclass之间完全没有区别。我更喜欢使用struct关键字,因为我喜欢把公有成员列在前面。但每个程序员都有自己的风格,培养一种风格并坚持下去。

2.初始化成员

在封装了year之后,我们现在必须使用方法来与ClockOfTheLongNow交互。代码清单2-21显示了如何将这些方法拼接到一个试图将年份设置为2018的程序中。程序没有设置成功,然后程序将year设置为2019,递增年份,并打印其最终值。

代码清单2-21 使用ClockOfTheLongNow说明方法用法的程序

首先,我们声明了clock❶,并试图把它的年份设置为2018❷。没有设置成功,因为2018小于2019,然后程序将年份设置为2019❸。把年份递增一次❹,然后打印它的值。

ClockOfTheLongNow有一个问题:当clock被声明❶时,year是未初始化的。而我们想保证year在任何情况下都不会小于2019。这样的要求被称为类不变量:一个总是真的类特性(也就是说,它从不改变)。

在这个程序中,clock最终会进入一个良好的状态❸,但通过构造函数可以做得更好。构造函数会初始化对象,并在对象的生命周期之初就强制执行类不变量。

2.4.3 构造函数

构造函数是具有特殊声明的特殊方法。构造函数声明不包含返回类型,其名称与类的名称一致。例如,代码清单2-22中的构造函数不需要任何参数,并将year设置为2019,这将导致year默认为2019。

代码清单2-22 使用无参数构造函数对代码清单2-21进行改进

该构造函数不需要参数❶并将year设置为2019❷。当声明新的ClockOfThe-LongNow时❸,year默认为2019。我们可以使用get_year访问year,并把它打印到控制台❹。

如果想用其他年份来初始化ClockOfTheLongNow,该怎么办?构造函数也可以接受任何数量的参数。我们可以实现任意多的构造函数,只要它们的参数类型不同。

考虑代码清单2-23中的例子,它添加了一个接受int类型参数的构造函数。该构造函数可将year初始化为参数的值。

代码清单2-23 用另一个构造函数对代码清单2-22进行扩展

新的构造函数❶接受一个int类型的year_in参数。我们可以用year_in❷调用set_year。如果set_year返回false,说明调用者提供了错误的输入,程序会用默认值2019覆写year_in❸。在main中,我们用新的构造函数❹声明一个clock,然后打印结果❺。ClockOfTheLongNow clock{2020};被称为初始化语句。

注意 你可能不喜欢将无效的year_in实例默默纠正为2019❸的做法。我也不喜欢这样。异常机制(详见4.3节)可以解决这个问题。

2.4.4 初始化

对象初始化(简称初始化)是使对象“活起来”的过程。不幸的是,对象初始化的语法很复杂。幸运的是,初始化过程是直截了当的。本节将对C++对象初始化进行简单提炼。

1.将基本类型初始化为零

我们先把基本类型的对象初始化为零。有四种方法可以做到这一点:

其中三种是可靠的,分别是使用字面量❶明确地设置对象的值,使用大括号{}❷,以及使用等号加大括号(={})的方法❸。声明对象时没有额外的符号❹是不可靠的,它只在某些情况下有效。即使你了解这些情况,也应该避免依赖这种行为,因为它会造成混乱。

不出所料,使用大括号{}初始化变量的方法被称为大括号初始化。C++初始化语法如此混乱的部分原因是,该语言从C语言(对象的生命周期是原始的)发展到具有健壮和丰富特性的对象生命周期的语言。语言设计者在现代C++中加入了大括号初始化,以帮助在初始化语法中的各种尖锐冲突中平滑过渡。简而言之,无论对象的作用域或类型如何,大括号初始化总是适用的,而其他方法则不总适用。本章后面会介绍鼓励广泛使用大括号初始化的一般规则。

2.将基本类型初始化为任意值

初始化为任意值的过程类似于将基本类型初始化为零的过程:

它也有四种方法,分别是使用等号的初始化❶,使用大括号初始化❷,使用等号加大括号的初始化❸,以及使用小括号的初始化❹。所有这些都产生相同的代码。

3.初始化POD

初始化POD的语法大多遵循基本类型的初始化语法。代码清单2-24通过声明一个包含三个成员的POD类型并用不同的值初始化它的实例来说明这种相似性。

代码清单2-24 一个说明初始化POD的各种方法的程序

将POD对象初始化为零与将基本类型的对象初始化为零类似。大括号初始化❶和等号加大括号的初始化❷产生相同的代码:字段初始化为零。

警告 不能对POD使用“等于0”的初始化方法。以下代码不会被编译,因为它在语言规则中被明确禁止了:

4.将POD初始化为任意值

我们可以使用括号内的初始化列表将字段初始化为任意值。大括号初始化列表中的参数必须与POD成员的类型相匹配。从左到右的参数顺序与从上到下的成员顺序一致。任何省略的成员都被设置为零。成员abinitialized_pod3❸中被初始化为42Helloc被清零(设置为false),因为我们在括号内的初始化中省略了它。initialized_pod4❹的初始化包括了c的参数(true),所以它的值在初始化后被设置为true

等号加大括号的初始化工作方式与此相同。例如,我们可以用以下代码来代替❹:

因为只能从右到左省略字段,所以下面的代码不会被编译:

警告 不能使用小括号来初始化POD。以下代码将不会被编译:

5.初始化数组

我们可以像初始化POD一样初始化数组。数组声明和POD声明的主要区别是,数组指定了长度,这个长度参数在方括号[]中。

当使用大括号初始化列表来初始化数组时,长度参数变得可有可无,因为编译器可以从初始化列表参数的数量推断出长度参数。

代码清单2-25演示了一些初始化数组的方法。

代码清单2-25 一个列出各种初始化数组方法的程序

数组array_1的长度为3,其元素为1、2和3❶。array_2的长度是5,因为它指定了长度参数❷。括号内的初始化列表是空的,所以五个元素都初始化为零。array_3的长度也是5,但括号内的初始化列表不是空的。它包含三个元素,所以剩下的两个元素初始化为零❸。array_4没有大括号初始化列表,所以它包含未初始化的对象❹。

警告 array_5是否被初始化实际上取决于与初始化基本类型相同的规则。对象的存储期(见4.1节)决定了这些规则。如果你对初始化有明确的要求,你就不必记住这些规则。

6.全功能类的初始化

与基本类型和POD不同,全功能类总是被初始化。换句话说,一个全功能类的构造函数总是在初始化时被调用。具体是哪个构造函数被调用取决于初始化时给出的参数。

代码清单2-26中的类有助于阐明全功能类的用法。

代码清单2-26 一个宣布调用了哪个构造函数的类

Taxonomist类有四个构造函数。如果初始化时没有提供参数,无参数的构造函数被调用❶。如果初始化时提供charintfloat,相应的构造函数❷❸❹会分别被调用。在每一种情况下,构造函数都会用printf语句给出提示。

代码清单2-27使用不同的语法和参数初始化了几个Taxonomist

代码清单2-27 一个通过各种初始化语法初始化Taxonomist类的程序

没有任何大括号或小括号时,无参数构造函数被调用❶。与POD和基本类型不同,无论在哪里声明对象,都可以依赖这种初始化。使用大括号初始化列表,char❷、int❸和float❹构造函数会如预期那样被调用。我们也可以使用小括号❺和等号加大括号的语法❻,这些都会调用预期的构造函数。

虽然全功能类总是被初始化,但有些程序员喜欢对所有对象使用相同的初始化语法。这对于大括号初始化来说是没有问题的,因为默认构造函数会按照预期被调用❼。

不幸的是,使用小括号的初始化❽会导致一些令人惊讶的行为。它不会给出任何输出。

乍看起来,最后一个初始化语句❽像函数声明,这是因为它就是。由于一些神秘的语言解析规则,我们向编译器声明的是一个尚未定义的函数t8,它没有任何参数,返回一个类型为Taxonomist的对象。

注意 9.1节详细地介绍了函数声明。但是现在,你只需要知道你可以提供一个函数声明,在声明中定义函数的修饰符、名称、参数和返回类型,然后再在函数定义中提供函数体。

这个广为人知的问题被称为“最令人头疼的解析”(most vexing parse),这也是C++社区在语言中引入大括号初始化语法的一个主要原因。缩小转换(narrowing conversion)是另一个问题。

7.缩小转换

每当遇到隐式缩小转换时,大括号初始化将产生警告。这是一个很棒的功能,可以让我们避免一些讨厌的bug。考虑下面的例子:

两个float字面量的除法结果仍是一个浮点数。当初始化narrowed_result❶时,编译器默默地将a/b(0.5)的结果缩小到0,因为我们使用小括号( )来初始化。当使用大括号初始化时,编译器会产生一个警告❷。

8.初始化类成员

我们可以使用大括号初始化来初始化类的成员,正如这里所演示的:

gold成员使用等号初始化方法初始化❶,year_of_smelting_accident使用大括号初始化方法初始化❷,key_location使用等号加大括号的方法初始化❸。不能使用小括号来初始化成员变量。

9.打起精神来

初始化对象的各种方法甚至让有经验的C++程序员都感到困惑。这里有一条使初始化变得简单的一般规则:在任何地方都使用大括号初始化方法。大括号初始化方法几乎在任何地方都能正常工作,而且它们引起的意外也最少。由于这个原因,大括号初始化也被称为“统一初始化”。本书的其余部分都遵循这一指导。

警告 对于C++标准库中的某些类,你可能需要打破在任何地方都使用大括号初始化方法的规则。第二部分会把这些例外情况讲解清楚。

2.4.5 析构函数

对象的析构函数是其清理函数。析构函数在销毁对象之前被调用。析构函数几乎不会被明确地调用:编译器将确保每个对象的析构函数在适当的时机被调用。我们可以在类的名称前加上“~”来声明该类的析构函数。

下面的Earth类有一个析构函数,该析构函数会打印Making way for hyperspace bypass

析构函数的定义是可选的。如果决定实现一个析构函数,它不能接受任何参数。我们想在析构函数中执行的操作包括释放文件句柄、刷新网络套接字(socket)和释放动态对象。

如果没有定义析构函数,则会自动生成默认的析构函数。默认析构函数的行为是不执行任何操作。

更多关于析构函数的信息请见4.2节。