第2章 用户定义类型
第2章 用户定义类型
- 介绍
- 结构体
- 类
- 枚举
- 联合体
- 建议
2.1 介绍
我们称那些可以从基本类型(§1.4)、 const 修饰符(§1.6)和声明符运算符(§1.7)构建出来的类型为 内置类型 。C++的内置类型和操作集丰富,但刻意保持低级。它们直接且高效地反映了常规计算机硬件的能力。然而,它们并没有为程序员提供方便编写高级应用的高级设施。相反,C++通过一套精巧的 抽象机制 增强了内置类型和操作,程序员可以利用这些机制构建出这样的高级设施。
C++的抽象机制主要设计用来让程序员设计和实现他们自己的类型,具有适当的表示和操作,并让程序员简单且优雅地使用这些类型。使用C++的抽象机制由其他类型构建出来的类型被称为 用户定义类型 。它们被称为 类 和 枚举 。用户定义类型可以由内置类型和其他构建出来。本书的大部分内容都致力于用户定义类型的设计、实现和使用。用户定义类型通常比内置类型更受欢迎,因为它们更易于使用,更不容易出错,并且在它们所做的事情上通常与直接使用内置类型一样高效,甚至更高效。
这一章的其余部分介绍了定义和使用类型的最简单和最基本的设施。第4-8章是对抽象机制和它们支持的编程风格的更完整的描述。用户定义类型提供了标准库的骨架,所以标准库的章节,9-17章,提供了使用第1-8章中介绍的语言设施和编程技术可以构建什么的例子。
2.2 结构体
构建新类型的第一步通常是将其需要的元素组织成一个数据结构,即 结构体 :
struct Vector {
double∗ elem; // 指向元素的指针
int sz;// 元素的数量
};
Vector 的第一个版本由一个 int 和一个 double∗ 组成。 可以这样定义一个 Vector 类型的变量:
Vector v;
然而,仅仅这样并没有什么用,因为 v 的 elem 指针并没有指向任何东西。为了使其有用,我们必须给 v 一些元素来指向。例如:
void vector_init(Vector& v, int s) // 初始化一个Vector
{
v.elem = new double[s]; // 分配一个包含s个double的数组
v.sz = s;
}
也就是说, v 的 elem 成员获取了 new 运算符产生的指针, v 的 sz 成员获取了元素的数量。 Vector& 中的 & 表示我们通过非 const 引用传递 v (§1.7);这样, vector_init() 可以修改传递给它的向量。
new 运算符从一个称为自由存储区(也称为动态内存和堆)的区域分配内存。在自由存储区分配的对象独立于它们创建的范围,并且“活”到使用 delete 运算符(§5.2.2)销毁它们为止。
Vector 的简单使用如下:
double read_and_sum(int s)
// 从cin读取s个整数并返回它们的和;假定s为正数
{
Vector v;
vector_init(v,s);// 为v分配s个元素
for (int i=0; i!=s; ++i)
cin>>v.elem[i];// 读入元素
double sum = 0;
for (int i=0; i!=s; ++i)
sum+=v.elem[i];
return sum;
// 计算元素的和
}
在我们的 Vector 变得像标准库 vector 那样优雅和灵活之前,还有很长的路要走。特别是, Vector 的用户必须了解 Vector 表示的每一个细节。本章剩余部分和接下来的两章将逐步改进 Vector ,作为语言特性和技术的例子。第12章介绍了标准库 vector ,它包含了许多很好的改进。
我使用 vector 和其他标准库组件作为例子
- 来说明语言特性和设计技术,以及
- 帮助你学习和使用标准库组件。
不要重新发明像 vector 和 string 这样的标准库组件。标准库类型的名称都是小写的,所以为了区分用来说明设计和实现技术的类型的名称(例如, Vector 和 String ),我将它们大写。
我们使用 . (点)通过名称(和引用)访问 结构体 成员,使用 -> 通过指针访问 结构体 成员。例如:
void f(Vector v, Vector& rv, Vector∗ pv)
{
int i1 = v.sz;// 通过名称访问
int i2 = rv.sz;// 通过引用访问
int i3 = pv->sz;// 通过指针访问
}
2.3 类
将数据与其上的操作分开指定有其优点,比如能够以任意方式使用数据。然而,用户定义类型要具有所有期望的“真实类型”的属性,就需要在表示和操作之间有更紧密的联系。特别是,我们通常希望保持表示对用户不可访问,以简化使用,保证数据的一致性使用,并允许我们以后改进表示。为此,我们必须区分类型的接口(供所有人使用)和其实现(可以访问到其他无法访问的数据)。这种语言机制被称为 类 。一个类有一组 成员 ,可以是数据、函数或类型成员。
类的接口由其 公共 成员定义,其 私有 成员只能通过该接口访问。类声明的 公共 部分和 私有 部分可以以任何顺序出现,但我们通常先放置 公共 声明,然后放置 私有 声明,除非我们想强调表示。
例如:
class Vector {
public:
Vector(int s) :elem{new double[s]}, sz{s} { }// 构造一个Vector
double& operator[](int i) { return elem[i]; }// 元素访问:下标
int size() { return sz; }
private:
double∗ elem; // 指向元素的指针
int sz;// 元素的数量
};
有了这个,我们可以定义一个新类型 Vector 的变量:
Vector v(6);// 一个有6个元素的Vector
我们可以用下面的图片来说明一个 Vector 对象:
总的来说, Vector 对象是一个 ‘‘handle’’ ,包含一个指向元素( elem )的指针和元素的数量( sz )。元素的数量(例如,在示例中为6)在不同的 Vector 对象上可以不同,而且一个 Vector 对象在不同的时间可以有不同的元素数量(§5.2.3)。然而, Vector 对象本身的大小总是相同的。这是在C++中处理不同量信息的基本技术:一个固定大小的 handle 引用到“其他地方”(例如,在由 new 分配的自由存储区)的可变量数据(§5.2.2)。如何设计和使用这样的对象是第5章的主题。
在这里, Vector 的成员 elem 和 sz 只能通过公共成员提供的接口访问: Vector() , operator[]() ,和 size() 。从§2.2的 read_and_sum() 示例简化为:
double read_and_sum(int s)
{
Vector v(s);// 创建一个包含s个元素的vector
for (int i=0; i!=v.size(); ++i)
cin>>v[i];// 读入元素
double sum = 0;
for (int i=0; i!=v.size(); ++i)
sum+=v[i];// 计算元素的和
return sum;
}
一个与其类名相同的成员函数被称为 构造函数 ,也就是用来构造类的对象的函数。所以,构造函数 Vector() 替换了§2.2中的 vector_init() 。与普通函数不同,构造函数保证用于初始化其类的对象。因此,定义一个构造函数消除了类的未初始化变量的问题。
Vector(int) 定义了如何构造 Vector 类型的对象。特别地,它声明需要一个整数来完成这个操作。这个整数被用作元素的数量。构造函数使用成员初始化列表初始化 Vector 成员:
:elem{new double[s]}, sz{s}
也就是说,我们首先用从自由存储区获取的 s 个 double 类型元素的指针初始化 elem 。然后,我们将 sz 初始化为 s 。
元素的访问是通过一个下标函数提供的,称为 operator[] 。它返回对应元素的引用(一个 double& ,允许读写)。
size() 函数被提供给用户以获取元素的数量。
显然,错误处理完全缺失,但我们将在第4章回到这个问题。同样,我们没有提供一种“归还”由 new 获取的 double 数组的机制;§5.2.2展示了如何定义一个析构函数来优雅地做到这一点。
struct 和 class 之间没有本质的区别; struct 只是默认成员为公有的 class 。例如,你可以为 struct 定义构造函数和其他成员函数。
2.4 枚举
除了类,C++还支持一种简单的用户定义类型,我们可以枚举这些值:
enum class Color { red, blue, green };
enum class Traffic_light { green, yellow, red };
Color col = Color::red;
Traffic_light light = Traffic_light::red;
注意,枚举器(例如,red)在其枚举类的范围内,因此它们可以在不同的枚举类中反复使用,而不会引起混淆。例如, Color::red 是 Color 的 red ,它与 Traffic_light::red 是不同的。
枚举被用来表示小集合的整数值。它们被用来使代码更易读,比没有使用符号(和助记)枚举器名称的代码更不容易出错。
枚举 后面的 类 指定了枚举是强类型的,且其枚举器是有范围的。作为独立的类型, 枚举类 有助于防止常量的意外误用。特别地,我们不能混合 Traffic_light 和 Color 的值:
Color x1 = red; // 错误:哪个red?
Color y2 = Traffic_light::red; // 错误:那个red不是Color
Color z3 = Color::red; // 正确
auto x4 = Color::red; // 正确:Color::red是Color
同样,我们不能隐式地混合 Color 和整数值:
int i = Color::red;// 错误:Color::red不是一个int
Color c = 2;// 初始化错误:2不是一个Color
捕获尝试转换为 枚举 的操作是防止错误的一种好方法,但我们经常希望用其底层类型(默认为 int )的值初始化一个 枚举 ,所以这是允许的,就像从底层类型显式转换一样:
Color x = Color{5}; // OK,但冗长
Color y {6}; // 也OK
同样,我们可以显式地将 枚举 值转换为其底层类型:
int x = int(Color::red);
默认情况下, 枚举类 只定义了赋值、初始化和比较(例如, == 和 < ;§1.4)。然而,枚举是用户定义类型,所以我们可以为它定义操作符(§6.4):
Traffic_light& operator++(Traffic_light& t)// 前缀递增:++
{
switch (t) {
case Traffic_light::green:
return t=Traffic_light::yellow;
case Traffic_light::yellow:
return t=Traffic_light::red;
case Traffic_light::red:
return t=Traffic_light::green;
}
}
auto signal = Traffic_light::red;
Traffic_light next = ++signal;// next变为Traffic_light::green
如果觉得重复的枚举名称 Traffic_light 繁琐,我们可以在一个范围内缩写它:
Traffic_light& operator++(Traffic_light& t)// 前缀递增:++
{
using enum Traffic_light;// 在这里,我们使用Traffic_light
switch (t) {
case green:
return t=yellow;
case yellow:
return t=red;
case red:
return t=green;
}
}
如果你不想显式地限定枚举器名称,并希望枚举器值是 int (无需显式转换),你可以从 enum class 中移除 class ,得到一个“普通”的 enum 。来自“普通” enum 的枚举器被输入到与它们的 enum 名称相同的范围中,并隐式地转换为它们的整数值。例如:
enum Color { red, green, blue };
int col = green;
在这里, col 得到的值是 1 。默认情况下,枚举器的整数值从0开始,每增加一个枚举器,就增加一。这种“普通”的枚举从C++(和C)早期时就有,所以即使它们的行为不太规范,它们在当前的代码中还是很常见的。
2.5 联合体
Union 是一种特殊 结构体 ,其所有成员共享同一内存地址,因此 联合体 所占空间仅为其最大成员所需空间。自然而然地, 联合体 在同一时间只能存储一个成员的值。例如,考虑一个符号表条目,它需要保存一个名称和一个值。这个值可以是一个 Node* 或者一个 int :
enum class Type { ptr, num }; // 类型可以持有 ptr 和 num 两个值(§2.4)
struct Entry {
string name; // string 是标准库类型
Type t;
Node* p; // 如果 t==Type::ptr,则使用 p
int i; // 如果 t==Type::num,则使用 i
};
void f(Entry* pe) {
if (pe->t == Type::num)
cout << pe->i;
// ...
}
成员 p 和 i 不会同时被使用,这导致了空间的浪费。我们可以通过声明它们同为一个 联合体 的成员来轻易地回收这部分空间:
union Value {
Node* p;
int i;
};
现在, Value::p 和 Value::i 在每个 Value 对象的同一内存地址上。
这种空间优化对于需要管理大量内存、要求紧凑表示的应用程序尤为重要。
语言本身并不跟踪 联合体 中实际存储的是哪种类型的值,因此程序员必须自己处理这个问题:
struct Entry {
string name;
Type t;
Value v; // 如果 t==Type::ptr,则使用 v.p;如果 t==Type::num,则使用 v.i
};
void f(Entry* pe) {
if (pe->t == Type::num)
cout << pe->v.i;
// ...
}
维护 类型字段 (有时称为区分符或标签,此处为 t )与 联合体 中实际类型之间的一致性是容易出错的。为了避免错误,我们可以通过将联合体和类型字段封装在一个类中,并仅通过正确使用联合体的成员函数来提供访问,以此来强制执行这种一致性。在应用层面上,依赖于这种标记联合体的抽象概念是常见且有用的。最好尽量避免直接使用“裸” 联合体 。
标准库中的类型 variant 可用于消除大多数直接使用联合体的情况。 variant 可以存储一组可选类型之一的值(§15.4.1)。例如,一个 variant<Node, int>* 可以保存一个 Node* 或一个 int 。使用 variant ,上述 Entry 示例可以写为:
struct Entry {
string name;
variant<Node*, int> v;
};
void f(Entry* pe) {
if (holds_alternative<int>(pe->v))// pe 是否持有一个 int?(见 §15.4.1)
cout << get<int>(pe->v);// 获取 int 值
// ...
}
对于许多用途而言, variant 比联合体更简单且安全。
2.6 建议
- 当内置类型过于底层时,优先选择定义清晰的用户自定义类型;参见 §2.1。
- 将相关数据组织成结构( struct 或 class );参见 §2.2;[CG: C.1]。
- 利用 类 来体现接口和实现的区别;参见 §2.3;[CG: C.3]。
- struct 实质上是一种默认成员 公开 的 类 ;参见 §2.3。
- 定义构造函数以确保并简化 类 的初始化过程;参见 §2.3;[CG: C.2]。
- 使用枚举来表示命名常量的集合;参见 §2.4;[CG: Enum.2]。
- 为了减少意外情况,优先选用 class enum 而非“普通” enum ;参见 §2.4;[CG: Enum.3]。
- 为枚举定义操作,以确保其使用安全且简便;参见 §2.4;[CG: Enum.4]。
- 避免使用“裸” union ;应将它们与类型字段一起封装在类中;参见 §2.5;[CG: C.181]。
- 相较于“裸” union ,优先采用 std::variant ;参见 §2.5。