由基本类型(int、double、bool等)、CV限定修饰符(const和volatile)和声明操作符([]、*、&、()等)构造出来的类型,称为内置类型。这些内置类型被有意设计得更偏向底层,以直接且高效地发挥传统计算机硬件的能力,但它们缺乏编写高级应用程序所需的高级特性。为此,C++在内置类型的基础上,增加了一套精致的抽象机制,作为支持高级应用程序开发的高层设施。
C++的类型抽象机制,旨在帮助程序员,设计并实现一套自定义的数据类型。这些用户自定义类型具有恰当的表示和操作,程序员可以简单而优雅地使用它们。典型的用户自定义类型,如结构、类、枚举和联合,可以基于内置类型构造,也可以基于其它自定义类型构造。用户自定义类型通常优于内置类型,它们更容易使用且不易出错,同时具有与内置类型相当的性能,甚至更优。
用户自定义类型涉及到:
定义和使用用户自定义类型的基本语法
基于用户自定义类型的抽象机制,及其所支持的编程风格
提供大量现成用户自定义类型的标准库
构建新类型的第一步是将所需的元素组织成一个数据结构。例如:
xxxxxxxxxx
41struct Vector {
2 double* elem; // 指向元素序列的指针
3 int size; // 元素的数量
4};
这时第一个版本的Vector,包含一个double*和一个int。Vector类型的变量可以如下方式定义:
xxxxxxxxxx
11Vector vec;
然而,它本身并没有什么用处,因为其中的elem指针并未指向任何有效的数据内容。为此,可先为其分配足量的内存空间。例如:
xxxxxxxxxx
61// 初始化Vector类型的变量
2
3void initVector(Vector& vec, int size) {
4 vec.elem = new double[size]; // 分配内存空间,包含size个double类型的元素
5 vec.size = size;
6}
vec中的elem成员被赋予来自new操作符的指针,其size成员则保存了元素的数量。“Vector&”中的“&”表示vec是一个不带常限定的引用,其引用的目标可在initVector函数中修改。
new操作符从所谓的自由存储(亦称动态内存或堆)中分配内存,并返回指向该内存的指针。自由存储区对象的生命周期,与其创建语句所处的作用域无关,它会一直“存活”,直到被delete操作符销毁,或程序终止。
用户自定义类型Vector的一个简单应用如下所示:
xxxxxxxxxx
351import std;
2
3using namespace std;
4
5struct Vector {
6 double* elem; // 指向元素序列的指针
7 int size; // 元素的数量
8};
9
10// 初始化Vector类型的变量
11
12void initVector(Vector& vec, int size) {
13 vec.elem = new double[size]; // 分配内存空间,包含size个double类型的元素
14 vec.size = size;
15}
16
17// 从cin读入size个数据,返回它们的和
18
19double readAndSum(int size) {
20 Vector vec;
21 initVector(vec, size); // 分配内存
22
23 for (int i = 0; i < size; ++i)
24 cin >> vec.elem[i]; // 读取数据
25
26 double sum = 0;
27 for (int i = 0; i < size; ++i)
28 sum += vec.elem[i]; // 累加求和
29
30 return sum; // 返回结果
31}
32
33int main() {
34 cout << readAndSum(5) << endl;
35}
显然,在优雅性和灵活性方面,目前的Vector与标准库的vector还有很大差距。尤其是作为Vector的使用者,他必须完全清楚Vector的所有实现细节。借助C++的类型抽象机制,Vector将经历一系列的改善,以期达到堪与vector相媲美的程度。
定义Vector仅仅是为了展示语言特性和程序设计技术。永远不要试图重新发明vector和string这样的标准库组件。直接使用它们才是明智之举。
访问struct成员有两种方式,通过struct型变量或引用访问其成员,使用点(.)操作符,通过struct型指针访问其成员,则使用箭头(->)操作符。例如:
xxxxxxxxxx
51void f(Vector v, Vector& r, Vector* p) {
2 cout << v.size << endl; // 通过struct型变量访问其成员
3 cout << r.size << endl; // 通过struct型引用访问其成员
4 cout << p->size << endl; // 通过struct型指针访问其成员
5}
上面这种将数据与对数据的操作分离的做法有其优势,用户可以非常自由地使用数据。不过对于用户自定义类型而言,为了将其所有属性捏合在一起,形成一个“真正的类型”,在其表示形式和操作之间建立紧密的联系还是很有必要的。特别是作为自定义的数据类型,易于使用和修改,并保持高度的一致和自洽,同时将数据的表示对使用者隐藏,显得尤为重要。理想的做法是将类型的接口(可被外部访问的部分)与实现(不可被外部访问的部分)分离开来。在C++中,实现上述目标的语言机制被称为类。类含有一系列成员,可以是数据,也可以是函数或类型。
类的public成员定义了该类的接口,可被外部直接访问,而private成员则定义了该类的实现,只能通过接口被外部间接访问。
public成员和private成员可以任意顺序出现在类的声明中,但按照惯例,通常将public成员放在前面,而将private成员放在后面,除非需要特别强调private成员的实现。例如:
xxxxxxxxxx
241class Vector {
2public:
3 // 初始化
4 Vector(int size) : m_elem{ new double[size] }, m_size{ size } {}
5
6 // 通过下标访问特定的元素
7 double& operator[](int i) {
8 return m_elem[i];
9 }
10
11 // 通过下标访问特定的元素
12 const double& operator[](int i) const {
13 return const_cast<Vector&>(*this)[i];
14 }
15
16 // 获取元素的数量
17 int size() const {
18 return m_size;
19 }
20
21private:
22 double* m_elem; // 指向元素序列的指针
23 int m_size; // 元素的数量
24};
定义一个拥有5个元素的Vector类型的变量:
xxxxxxxxxx
11Vector vec(5);
下图解释了Vector类型的变量,或曰Vector对象的含义:
xxxxxxxxxx
61vec:Vector
2_____ _____________________________
3m_elem:double* | * | -> |_____|_____|_____|_____|_____|
4|-----| 0 1 2 3 4
5m_size:int | 5 |
6|_____|
Vector对象更象是一个句柄,它包含了指向元素序列的指针(m_elem)和元素的数量(m_size)。在不同的Vector对象中,元素的序列和数量可能不同。即使是同一个Vector对象,在不同的时刻,也可能包含不同的元素序列和数量。不过Vector对象本身的大小(即字节数)始终保持不变。这是C++语言处理可变数量信息的一项基本技术,即用一个固定大小的句柄,指向一组位于别处(比如通过new分配的自由存储中)数量可变的数据。
位于Vector类外部的代码,不能直接访问其private成员m_elem和m_size,而只能通过其public接口,如Vector()、operator[]()和size(),间接地访问它们。之前的readAndSum函数因此得到简化。例如:
xxxxxxxxxx
141// 从cin读入size个数据,返回它们的和
2
3double readAndSum(int size) {
4 Vector vec(size); // 创建一个可容纳size个元素的Vector对象
5
6 for (int i = 0; i < vec.size(); ++i)
7 cin >> vec[i]; // 读取数据
8
9 double sum = 0;
10 for (int i = 0; i < vec.size(); ++i)
11 sum += vec[i]; // 累加求和
12
13 return sum; // 返回结果
14}
与所属类同名的成员函数称为构造函数,负责对象的初始化。因此这里不再需要initVector函数。与普通函数不同,构造函数没有返回类型,具有可选的成员初始化表,并在对象创建时自动被调用。定义构造函数可以避免一切因对象未经初始化而引发的问题。
Vector(int)规定了Vector对象的构造方式,即必须通过一个整数构造该对象,这个整数用于指定向量中元素的数量。该构造函数使用如下形式的成员初始化表,初始化类中的各成员变量:
xxxxxxxxxx
11: m_elem{ new double[size] }, m_size{ size }
其含义为,首先通过new操作符,从自由存储,分配可容纳size个double型数据的内存空间,并用指向该内存空间的指针初始化m_elem成员变量,然后将m_size成员变量的初值指定为size。
访问元素的功能是由名为“operator[]”的下标操作符函数提供的。它的返回值是一个针对特定元素的引用,其类型为“double&”,可读可写。size成员函数的作用是向用户返回元素的数量。
截至目前的Vector类显然还不够完善,其中没有包含对可能发生的错误的处理,也没有归还new操作符分配的内存。
在C++中,两个关键字struct和class已没有本质性的区别。唯一的不同在于struct中的成员默认为public,而class中的成员默认为private。同样可以为struct定义构造函数和其它成员函数,与class无异。
除了类,C++还支持一种简单的用户自定义类型——枚举,用于表示一组位于给定取值范围内的,符号化的整数常量。例如:
xxxxxxxxxx
51enum class Color { red, green, blue };
2enum class TrafficLight { green, yellow, red };
3...
4Color c = Color::red;
5TrafficLight t = TrafficLight::red;
枚举值的作用域被限制在包含它的“enum class”内部,因此“Color::red”和“TrafficLight::red”可以取完全不同的值,且不会发生名字冲突。
枚举类型用于表示少量整数常量的集合。通过使用符号(或称助记符)代替整数字面值,而获得更好的可读性,降低发生潜在错误的机会。
enum关键字后面的class关键字,表示这是一个拥有独立作用域的强类型枚举。不同的“enum class”是不同的类型。这有助于防止对常量的误用。比如,不能混用Color和TrafficLight中的枚举值:
xxxxxxxxxx
41Color c1 = red; // 错误,缺少作用域限定“Color::”
2Color c2 = TrafficLight::red; // 错误,类型不一致,Color和TrafficLight是两个完全不同的类型
3Color c3 = Color::red; // 正确
4auto c4 = Color::red; // 正确,c4将被编译器隐式推断为Color类型的变量
类似地,强类型枚举也无法和整型混用:
xxxxxxxxxx
21int c1 = Color::red; // 错误,Color::red的类型是Color而不是int
2Color c2 = 0; // 错误,0的类型是int而不是Color
除非使用显式类型转换:
xxxxxxxxxx
21int c1 = int(Color::red);
2Color c2 = Color{ 0 };
或等价地写成:
xxxxxxxxxx
11Color c2{ 0 };
默认情况下,一个“enum class”定义仅包含初始化函数、赋值操作符和小于操作符。用户可以根据需要为其增加其它操作符的定义,例如前缀自增操作符:
xxxxxxxxxx
121TrafficLight& operator++(TrafficLight& t) {
2 switch (t) {
3 case TrafficLight::green:
4 return t = TrafficLight::yellow;
5
6 case TrafficLight::yellow:
7 return t = TrafficLight::red;
8
9 case TrafficLight::red:
10 return t = ThrafficLight::green;
11 }
12}
反复出现的“TrafficLight::”使代码略显冗长,可以借助“using enum”简化代码:
xxxxxxxxxx
141TrafficLight& operator++(TrafficLight& t) {
2 using enum TrafficLight;
3
4 switch (t) {
5 case green:
6 return t = yellow;
7
8 case yellow:
9 return t =red;
10
11 case red:
12 return t = green;
13 }
14}
通过前缀自增操作符变换交通信号灯:
xxxxxxxxxx
81auto t = TrafficLight::red;
2...
3++t; // TrafficLight::green
4...
5++t; // TrafficLight::yellow
6...
7++t; // TrafficLight::red
8...
去掉“enum class”中的“class”,则将强类型枚举降级为普通枚举。普通枚举没有严格的作用域限制,并可以和整型混合使用。例如:
xxxxxxxxxx
51enum Color { red, green, blue };
2...
3Color c1 = red;
4Color c2 = 0;
5int c3 = red;
无论哪种枚举,其底层类型都是整型(int),一个枚举值从本质上讲就是一个整数,默认从0开始,并依次加一。源自C语言的普通枚举,自C++诞生之初就已经存在,虽然其某些行为不尽人意,但仍被广泛应用于现代代码中。
C++11:通过enum class定义带作用域的强类型枚举
C++17:用底层类型的值初始化enum类型的变量
C++20:对带作用域的枚举使用using
union是一种特殊的struct,它的所有成员都被分配在同一块内存区域中,因此一个union型变量所占内存空间的大小取决于其最大的成员。显然,在任意时刻,一个union型变量中,只有一个成员是可用的。例如:
xxxxxxxxxx
161enum class Type { ptr, num }; // 指针或数值
2
3struct Entry {
4 string k;
5 Type t;
6 Node* p; // 如果t为Type::ptr,则使用p
7 int n; // 如果t为Type::num,则使用n
8}
9
10void foo(Entry* e) {
11 if (e->t == Type::ptr)
12 ... e->p ...
13 else
14 if (e->t == Type::num)
15 ... e->n ...
16}
从逻辑上看,Entry中的两个成员p和n永远不会同时使用,因此上述代码的空间效率不高。借助union,可获得明显改善:
xxxxxxxxxx
201enum class Type { ptr, num }; // 指针或数值
2
3union Value {
4 Node* p;
5 int n;
6};
7
8struct Entry {
9 string k;
10 Type t;
11 Value v; // 如果t为Type::ptr,则使用v.p,如果t为Type::num,则使用v.n
12}
13
14void foo(Entry* e) {
15 if (e->t == Type::ptr)
16 ... e->v.p ...
17 else
18 if (e->t == Type::num)
19 ... e->v.n ...
20}
Value中的两个成员p和n占用同一块内存,拥有相同的地址,节省空间且不妨碍使用。对于那些需要使用大量内存的应用而言, 这种空间效率的提升就显得尤其重要。
C++语言本身并不负责跟踪union成员的使用情况。究竟哪个成员可用,完全由程序员编写代码自行判断。就象上面的代码,时刻维护Entry中的t和v中成员使用情况的对应关系,并不容易。为了避免潜在的错误,可以将enum类型的t和union类型的v封装成一个类,通过接口,以正确的方式访问v中的成员。例如:
xxxxxxxxxx
291enum class Type { ptr, num }; // 指针或数值
2
3union Value {
4 Node* p; // 如果t为Type::ptr,则使用p
5 int n; // 如果t为Type::num,则使用n
6};
7
8class Variant {
9public:
10 ... get() {
11 switch (t) {
12 case Type::ptr: return v.p;
13 case Type::num: return v.n;
14 }
15 }
16
17private:
18 Type t;
19 Value v; // 如果t为Type::ptr,则使用v.p,如果t为Type::num,则使用v.n
20};
21
22struct Entry {
23 string k;
24 Variant v;
25}
26
27void foo(Entry* e) {
28 ... e->v.get() ...
29}
类似上面这样,将enum和union组合使用,比单纯使用union,在应用程序的开发实践中,更具普遍意义。
标准库的variant类模板,可以更一般地解决类型选择问题。例如:
xxxxxxxxxx
121struct Entry {
2 string k;
3 variant<Node*, int> v;
4}
5
6void foo(Entry* e) {
7 if (holds_alternative<Node*>(e->v))
8 ... get<Node*>(e->v) ...
9 else
10 if (holds_alternative<int>(e->v))
11 ... get<int>(e->v) ...
12}
在涉及到类型选择的应用场景中,variant比union更简单,也更安全。
当内置类型过于底层时,优先使用定义良好的用户自定义类型
将有关联的数据组织为结构(struct或class)
用class表达接口与实现的分离
一个struct就是一个成员默认为public的class
定义构造函数可以保证并简化对象的初始化
用枚举表示一组具名常量
优先使用强类型枚举而非普通枚举,以避免许多麻烦
为枚举定义操作符,以简化使用并保证安全
避免使用裸union,将其与表示可用成员的信息一起封装到类中
优先使用std::variant而非裸union,以支持不同类型的数据