2
用户定义类型
别慌!
—— 道格拉斯·亚当斯
2.1 导言
仅使用基本类型(§1.4)、const
修饰符(§1.6)以及声明运算符(§1.7)构建出的类型,
被称为内置类型(built-in type)。
C++的内置类型和运算都很丰富,但有意地低级化了。
它们直接、高效地反映出了传统计算机硬件的能力。
但是在开发高级应用程序的便利性方面,它的高层设施可就捉襟见肘了。
相反,C++用一套精细的抽象机制(abstraction mechanism)强化了内置类型及运算,
借助这套机制,程序员可以构建出这些高层设施。
C++的抽象机制的主要设计意图是让程序员设计并实现自己的类型, 这些类型具备适当的表现和运算,同时,还能让程序员用起来简单而优雅。 遵循C++的抽象机制,借助其它类型构建出的类型被称为 用户定义类型(user-defined type)。 也被称为类(class)和枚举(enumeration)。 构造用户定义类型时,即可以动用内置类型,也可以动用其它用户定义类型。 用户定义类型通常优于内置类型,因为更易使用,出错少, 而效率则通常与内置类型相差无几,有时候甚至更快。
本章后面的内容,介绍最简单也是最基础的用于创建和使用类型的工具。 对这种抽象机制及其支持的编程风格,在第4-7章给出了更完整的描述。 第5-8章给出了标准库的概览,同时标准库主要由用户定义类型构成, 因此对第1-7章所介绍的语言构造及编程技术而言,标准库提供了用例。
2.2 结构体(structure)
构建新类型的第一步,通常是把它所需的要素组织到一个数据类型
——结构体(struct
)里:
struct Vector
{
int sz; // 元素数量
double* elem; // 指向元素的指针
};
这Vector
的第一个版本包含了一个int
和一个double*
。
Vector
类型的变量可以这样定义:
Vector v;
但是,光有它自己用处不大,因为v
的指针elem
没有指向任何东西。
想让它有用,必须让v
指向某些元素。比方说,可以这样构造Vector
:
void vector_init(Vector& v, int s)
{
v.elem = new double[s]; // 分配一个数组,里面有s个double
v.sz = s;
}
这样,v
的成员elem
就得到一个用new
生成的指针,
而v
的成员sz
也得到了元素数量。
Vector&
里的&
意思是:v
通过非const
引用(§1.7)方式传递;
这样一来,vector_init()
就可以修改传入的Vector了。
new
运算符从一块叫自由存储(free store)
(也叫动态内存(dynamic memory)和堆(heap))的区域里分配内存。
分配在自由存储上的对象,与其被创建的作用域无关,
而是会一直“存活”下去,直到用delete
运算符(§4.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
,作为语言特性及技术的示例。
第11章介绍标准库的vector
,它包含很多精致的改进。
我拿vector
和其它标准库组件做例子是为了:
- 展示语言特性和设计技巧,并
- 帮助你理解和运用标准库组件
对于vector
和string
这样的标准库组件,别造轮子;直接用。
通过变量名(及引用)访问struct
成员用.
(点),
而通过指针访问struct
成员用->
。例如:
void f(Vector v, Vector& rv, Vector* pv)
{
int i1 = v.sz; // 通过变量名访问
int i2 = rv.sz; // 通过引用访问
int i3 = pv->sz; // 通过指针访问
}
2.3 类(class)
把具体数据和运算分离有它的优势,比方说,能够随心所欲地使用数据。
但是,想让用户定义类型具有“真正的类型”那些属性,就需要让数据和运算结合得更紧密些。
具体而言,我们通常希望数据表示对用户不可访问,从而避免被使用,
确保该类型数据的使用一致性,这还让我们后续能够改进数据表示。
要达成这个目的,必须区分类型的(供任何人使用的)接口和(可对数据排他性访问的)实现。
这个语言机制叫做类(class)。
类拥有一组成员(member),成员可以是数据、函数或者类型成员。
接口由类的public
成员定义,而private
成员仅允许通过接口访问。例如:
class Vector{
public:
Vector(int s) :elem{new double[s]}, sz{s} { }
double& operator[](int i) { return elem[i]; }
int size() { return sz; }
private:
double* elem; // 指向元素的指针
int sz; // 元素的数量
};
有了这些,就可以定义新的Vector
类型的变量了:
Vector v(6); // 具有6个元素的Vector
Vector
对象可图示如下:
大体上,Vector
对象就是个“把手”,
其中装载着指向元素的指针(elem
)和元素数量(sz
)。
元素数量(例中是6)对不同的Vector
对象是可变的,而同一个Vector
对象,
在不同时刻,其元素数量也可以不同。但是Vector
对象自身的大小始终不变。
在C++中,这是处理可变数量信息的基本技巧:以固定大小的把手操控数量可变的数据,
这些数据被放在“别处”(比如用new
分配在自由存储上;§4.2.2)。
设计与使用这些对象的方法,是第4章的主要内容。
在这里,Vector
的数据(成员elem
及sz
)只能通过接口访问,
这些接口都是public
成员: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;
}
和类具有相同名称的成员“函数(function)”叫做构造函数(constructor),
就是说,这个函数的用途是构建此类对象。
因此,构造函数Vector()
取代了§2.2里的vector_init()
。
与一般函数不同,在构造其所属的类对象时,构造函数保证会被调用。
由此,定义构造函数,就类消除了类的“变量未初始化”问题。
Vector(int)
定义了怎样构造Vector
对象。
具体来说,它明确指出需要一个整数。
该整数作为元素的数量使用。
这个构造函数通过成员初始化列表来初始化Vector
的成员:
:elem{new double[s]}, sz{s}
意思是说,先用一个指针初始化elem
,该指针指向s
个double
类型的元素,
这些元素的空间取自自由存储区。
然后用s
的值初始化sz
。
对元素的访问由取下标函数提供,该函数叫做operator[]
。
它返回相应元素的引用(即可读又可写的double&
)。
函数size()
把元素的数量交给用户。
一望而知,错误处理被彻底忽略了,但是我们会在§3.5讲到它。
与此类似,对于通过new
获取的double
数组,
我们也并未提供一个机制把它“送回去(give back)”;
§4.2.2展示了用析构函数优雅地做到这一点的方式。
struct
和class
没有本质上的区别,struct
就是个class
,
只不过其成员默认是public
的。
比方说,你可以为struct
定义构造函数和其它成员函数。
2.4 联合(union)
联合(union
)就是结构体(struct
),只不过联合的所有成员都分配在相同的地址上,
因此联合所占据的空间,仅跟其容量最大的那个成员相同。
自然而然,任何时候联合都只能持有其某一个成员的值。
举例来说,有个符号表条目,它包含一个名称和一个值。其值可以是Node*
或int
:
enum Type { ptr, num }; // 一个 Type 可以是ptr和num(§2.5)
struct Entry {
string name; // string是个标准库里的类型
Type t;
Node* p; // 如果t==ptr,用p
int i; // 如果t==num,用i
};
void f(Entry* pe)
{
if (pe->t == num)
cout << pe->i;
// ...
}
成员p
和i
永远不会同时使用,但这样空间就被浪费了。
可以指定它们都是某个union
的成员,这样空间就轻而易举地节省下来了,像这样:
union Value {
Node* p;
int i;
};
语言并不会追踪union
保持了哪种类型的值,所以程序员要亲力亲为:
struct Entry {
string name;
Type t;
Value v; // 如果t==ptr,用v.p;如果t==num,用v.i
};
void f(Entry* pe)
{
if (pe->t == num)
cout << pe->v.i;
// ...
}
类型信息(type field)和union
所持的类型之间的一致性很难维护。
想要避免错误,可以强化这种一致性——把联合与类型信息封装成一个类,
仅允许通过成员函数访问它们,再用成员函数确保准确无误地使用联合。
在应用层面,依赖于这种附有标签的联合(tagged union)的抽象常见且有用。
尽量少用“裸”的union
。
标准库有个类型叫variant
,使用它就可以避免绝大多数针对 联合 的直接应用。
variant
存储一个值,该值的类型可以从一组类型中任选一个(§13.5.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类型吗?(参见§13.5.1)
cout << get<int>(pe->v); // 取(get)int值
// ...
}
很多情况下,使用variant
都比union
更简单也更安全。
2.5 枚举(enum)
除了类,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
)位于其enum class
的作用域里,
因此可以在不同的enum class
里重复出现,而且不会相互混淆。
例如:Color::red
是Color
里面的red
,跟Traffic_light::red
毫无关系。
枚举用于表示一小撮整数值。 使用它们,可以让代码更具有可读性,也更不易出错。
enum
后的class
指明了这是个强类型的枚举,并且限定了这些枚举值的作用域。
作为独立的类型,enum class
有助于防止常量的误用。
比方说,Traffic_light
和Color
的值无法混用:
Color x = red; // 错误:哪个颜色?
Color y = Traffic_light::red; // 错误:此red并非Color类型
Color z = Color::red; // OK
同样,Color
的值也不能和整数值混用:
int i = Color::red; // 错误:Color::red不是int类型
Color c = 2; // 初始化错误:2不是Color类型
捕捉这种向 enum 的类型转换有助于防止出错,
但我们通常需要用底层类型(默认情况下是int
)的值初始化一个 enum,
因此,这种初始化就像显式从底层类型转换而来一样合法:
Color x = Color{5}; // 可行,但略有些啰嗦
Color y {6}; // 同样可行
默认情况下,enum class
仅定义了赋值、初始化和比较(也就是==
和<
; §1.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;
}
}
Traffic_light next = ++light; // next 将是 Traffic_light::green
如果你的枚举值不需要独立的作用域,并希望把它们作为int
使用(无需显式类型转换),
可以省掉enum class
中的class
,以获得一个“普通”enum
。
“普通”enum
中的枚举值的作用域跟这个enum
相同,还能隐式转换成整数值,例如:
enum Color { red, green, blue };
int col = green;
此处的col
值为1
。
默认情况下,枚举值的整数值从0
开始,每增加一个新枚举值就递增一。
“普通”enum
是C++(和C)与生俱来的,因此,尽管它问题多多却仍然很常见。
2.6 忠告
- [1] 如果某个内置类型过于底层,请使用定义良好的用户定义类型代替它;§2.1。
- [2] 把有关联的数据组织到结构里(成
struct
或class
);§2.2; [CG: C.1]。 - [3] 借助
class
区分接口和实现;§2.3; [CG: C.3]。 - [4]
struct
就是其成员默认为public
的class
;§2.3。 - [5] 为
class
定义构造函数,以确保执行初始化操作并简化它;§2.3; [CG: C.2]。 - [6] 别用“裸”
union
,把它们和类型字段凑一起放进类里面;§2.4; [CG: C.181]。 - [7] 使用枚举表示具名常量的集合;§2.5; [CG: Enum.2]。
- [8] 用
enum class
替代“普通”enum
以避免事故;§2.5; [CG: Enum.3]。 - [9] 给枚举定义运算,可以获得安全性和便利性;§2.5; [CG: Enum.4]。