2 用户自定义类型

2.1 引言

由基本类型(int、double、bool等)、CV限定修饰符(const和volatile)和声明操作符([]、*、&、()等)构造出来的类型,称为内置类型。这些内置类型被有意设计得更偏向底层,以直接且高效地发挥传统计算机硬件的能力,但它们缺乏编写高级应用程序所需的高级特性。为此,C++在内置类型的基础上,增加了一套精致的抽象机制,作为支持高级应用程序开发的高层设施。

C++的类型抽象机制,旨在帮助程序员,设计并实现一套自定义的数据类型。这些用户自定义类型具有恰当的表示和操作,程序员可以简单而优雅地使用它们。典型的用户自定义类型,如结构、类、枚举和联合,可以基于内置类型构造,也可以基于其它自定义类型构造。用户自定义类型通常优于内置类型,它们更容易使用且不易出错,同时具有与内置类型相当的性能,甚至更优。

用户自定义类型涉及到:

2.2 结构

构建新类型的第一步是将所需的元素组织成一个数据结构。例如:

这时第一个版本的Vector,包含一个double*和一个int。Vector类型的变量可以如下方式定义:

然而,它本身并没有什么用处,因为其中的elem指针并未指向任何有效的数据内容。为此,可先为其分配足量的内存空间。例如:

vec中的elem成员被赋予来自new操作符的指针,其size成员则保存了元素的数量。“Vector&”中的“&”表示vec是一个不带常限定的引用,其引用的目标可在initVector函数中修改。

new操作符从所谓的自由存储(亦称动态内存或堆)中分配内存,并返回指向该内存的指针。自由存储区对象的生命周期,与其创建语句所处的作用域无关,它会一直“存活”,直到被delete操作符销毁,或程序终止。

用户自定义类型Vector的一个简单应用如下所示:

显然,在优雅性和灵活性方面,目前的Vector与标准库的vector还有很大差距。尤其是作为Vector的使用者,他必须完全清楚Vector的所有实现细节。借助C++的类型抽象机制,Vector将经历一系列的改善,以期达到堪与vector相媲美的程度。

定义Vector仅仅是为了展示语言特性和程序设计技术。永远不要试图重新发明vector和string这样的标准库组件。直接使用它们才是明智之举。

访问struct成员有两种方式,通过struct型变量或引用访问其成员,使用点(.)操作符,通过struct型指针访问其成员,则使用箭头(->)操作符。例如:

2.3 类

上面这种将数据与对数据的操作分离的做法有其优势,用户可以非常自由地使用数据。不过对于用户自定义类型而言,为了将其所有属性捏合在一起,形成一个“真正的类型”,在其表示形式和操作之间建立紧密的联系还是很有必要的。特别是作为自定义的数据类型,易于使用和修改,并保持高度的一致和自洽,同时将数据的表示对使用者隐藏,显得尤为重要。理想的做法是将类型的接口(可被外部访问的部分)与实现(不可被外部访问的部分)分离开来。在C++中,实现上述目标的语言机制被称为类。类含有一系列成员,可以是数据,也可以是函数或类型。

类的public成员定义了该类的接口,可被外部直接访问,而private成员则定义了该类的实现,只能通过接口被外部间接访问。

接口
实现
外部

public成员和private成员可以任意顺序出现在类的声明中,但按照惯例,通常将public成员放在前面,而将private成员放在后面,除非需要特别强调private成员的实现。例如:

定义一个拥有5个元素的Vector类型的变量:

下图解释了Vector类型的变量,或曰Vector对象的含义:

Vector对象更象是一个句柄,它包含了指向元素序列的指针(m_elem)和元素的数量(m_size)。在不同的Vector对象中,元素的序列和数量可能不同。即使是同一个Vector对象,在不同的时刻,也可能包含不同的元素序列和数量。不过Vector对象本身的大小(即字节数)始终保持不变。这是C++语言处理可变数量信息的一项基本技术,即用一个固定大小的句柄,指向一组位于别处(比如通过new分配的自由存储中)数量可变的数据。

位于Vector类外部的代码,不能直接访问其private成员m_elem和m_size,而只能通过其public接口,如Vector()、operator[]()和size(),间接地访问它们。之前的readAndSum函数因此得到简化。例如:

与所属类同名的成员函数称为构造函数,负责对象的初始化。因此这里不再需要initVector函数。与普通函数不同,构造函数没有返回类型,具有可选的成员初始化表,并在对象创建时自动被调用。定义构造函数可以避免一切因对象未经初始化而引发的问题。

Vector(int)规定了Vector对象的构造方式,即必须通过一个整数构造该对象,这个整数用于指定向量中元素的数量。该构造函数使用如下形式的成员初始化表,初始化类中的各成员变量:

其含义为,首先通过new操作符,从自由存储,分配可容纳size个double型数据的内存空间,并用指向该内存空间的指针初始化m_elem成员变量,然后将m_size成员变量的初值指定为size。

访问元素的功能是由名为“operator[]”的下标操作符函数提供的。它的返回值是一个针对特定元素的引用,其类型为“double&”,可读可写。size成员函数的作用是向用户返回元素的数量。

截至目前的Vector类显然还不够完善,其中没有包含对可能发生的错误的处理,也没有归还new操作符分配的内存。

在C++中,两个关键字struct和class已没有本质性的区别。唯一的不同在于struct中的成员默认为public,而class中的成员默认为private。同样可以为struct定义构造函数和其它成员函数,与class无异。

2.4 枚举

除了类,C++还支持一种简单的用户自定义类型——枚举,用于表示一组位于给定取值范围内的,符号化的整数常量。例如:

枚举值的作用域被限制在包含它的“enum class”内部,因此“Color::red”和“TrafficLight::red”可以取完全不同的值,且不会发生名字冲突。

枚举类型用于表示少量整数常量的集合。通过使用符号(或称助记符)代替整数字面值,而获得更好的可读性,降低发生潜在错误的机会。

enum关键字后面的class关键字,表示这是一个拥有独立作用域的强类型枚举。不同的“enum class”是不同的类型。这有助于防止对常量的误用。比如,不能混用Color和TrafficLight中的枚举值:

类似地,强类型枚举也无法和整型混用:

除非使用显式类型转换:

或等价地写成:

默认情况下,一个“enum class”定义仅包含初始化函数、赋值操作符和小于操作符。用户可以根据需要为其增加其它操作符的定义,例如前缀自增操作符:

反复出现的“TrafficLight::”使代码略显冗长,可以借助“using enum”简化代码:

通过前缀自增操作符变换交通信号灯:

去掉“enum class”中的“class”,则将强类型枚举降级为普通枚举。普通枚举没有严格的作用域限制,并可以和整型混合使用。例如:

无论哪种枚举,其底层类型都是整型(int),一个枚举值从本质上讲就是一个整数,默认从0开始,并依次加一。源自C语言的普通枚举,自C++诞生之初就已经存在,虽然其某些行为不尽人意,但仍被广泛应用于现代代码中。

C++11:通过enum class定义带作用域的强类型枚举

C++17:用底层类型的值初始化enum类型的变量

C++20:对带作用域的枚举使用using

2.5 联合

union是一种特殊的struct,它的所有成员都被分配在同一块内存区域中,因此一个union型变量所占内存空间的大小取决于其最大的成员。显然,在任意时刻,一个union型变量中,只有一个成员是可用的。例如:

从逻辑上看,Entry中的两个成员p和n永远不会同时使用,因此上述代码的空间效率不高。借助union,可获得明显改善:

Value中的两个成员p和n占用同一块内存,拥有相同的地址,节省空间且不妨碍使用。对于那些需要使用大量内存的应用而言, 这种空间效率的提升就显得尤其重要。

C++语言本身并不负责跟踪union成员的使用情况。究竟哪个成员可用,完全由程序员编写代码自行判断。就象上面的代码,时刻维护Entry中的t和v中成员使用情况的对应关系,并不容易。为了避免潜在的错误,可以将enum类型的t和union类型的v封装成一个类,通过接口,以正确的方式访问v中的成员。例如:

类似上面这样,将enum和union组合使用,比单纯使用union,在应用程序的开发实践中,更具普遍意义。

标准库的variant类模板,可以更一般地解决类型选择问题。例如:

在涉及到类型选择的应用场景中,variant比union更简单,也更安全。

2.6 建议