第 8 章 结构,合并和枚举
(Structures, Unions, and Enumerations)
目录
8.2.3 结构体和类(Structures and Classes)
8.2.4 结构体和数组(Structures and Arrays)
8.3.1 合并体和类(Unions and Classes)
8.1 引言
有效使用 C++ 的关键是定义和使用用户定义类型。本章介绍用户定义类型概念的三种最原始的变体:
struct(结构体)是任意类型的元素(称为成员)序列。
union(合并体)是一种在任何时候都只保存其中一个元素值的结构(译注:由于其特征更像是合并,因为我译为合并,其大小是最大的那个类型的大小,这个特点跟集合论中的并集有相似之处)。
enum(枚举)是一种具有一组命名常量(称为枚举器)的类型。
enum 类(enum class)(作用域枚举)是一种枚举,其中枚举器在枚举作用域内,并且不提供到其他类型的隐式转换(译注:和Java语言中的枚举类相似,语言之间的优点相互借签)。
这些简单类型的变体自 C++ 诞生之初就已存在(译注:作用域枚举,即enum 类是C++ 11新增)。它们主要侧重于数据的表示,是大多数 C 风格编程的支柱。此处描述的结构体概念是类的一种简单形式(§3.2,第 16 章)。
8.2 结构体
数组是同类元素的聚合。结构体以其最简单的形式聚合任意类型的元素。例如:
struct Address {
const char∗ name; // "Jim Dandy"
int number; // 61
const char∗ street; // "South St"
const char∗ town; //"New Providence"
char state[2]; // 'N' 'J'
const char∗ zip; //"07974"
};
这定义了一个名为 Address 的类型,其中包含向美国境内某人发送邮件所需的项目。请注意结尾的分号(“;”)。
Address 类型的变量可以像其他变量一样声明,并且可以使用 • (点)运算符访问各个成员。例如:
void f()
{
Address jd;
jd.name = "Jim Dandy";
jd.number = 61;
}
结构体类型的变量可以使用 {} 符号初始化(§6.3.5)。例如:
Address jd = {
"Jim Dandy",
61, "South St",
"New Providence",
{'N','J'}, "07974"
};
请注意,jd.state 无法通过字符串“NJ”进行初始化。字符串以零字符“\0”结尾,因此“NJ”有三个字符——比 jd.state 中能容纳的字符多一个。我故意使用相当底层的类型作为成员来说明如何做到这一点以及它可能导致哪些类型的问题。
通常使用 ⟶ (结构指针解引用)运算符通过指针访问结构。例如:
void print_addr(Address∗ p)
{
cout << p−>name << '\n'
<< p−>number << ' ' << p−>street << '\n'
<< p−>town << '\n'
<< p−>state[0] << p−>state[1] << ' ' << p−>zip << '\n';
}
当 p 为指针时,p−>m 等同于 (∗p).m。
或者,可以通过引用传递struct并使用 • (结构成员访问)运算符进行访问:
void print_addr2(const Address& r)
{
cout << r.name << '\n'
<< r.number << ' ' << r.street << '\n'
<< r.town << '\n'
<< r.state[0] << r.state[1] << ' ' << r.zip << '\n';
}
参数传递在第 12.2 节中讨论。
结构类型的对象可以赋值,可作为函数参数传递以及作为函数结果返回。例如:
Address current;
Address set_current(Address next)
{
address prev = current;
current = next;
return prev;
}
其他可行的操作,如比较(== 和 !=),默认情况下不可用。但是,用户可以定义此类运算符(第 18 章第 3.2.1.1 节)。
8.2.1 结构体的内存布局(struct Layout)
结构体对象按照声明的顺序保存其成员。例如,我们可以将从设备读出的数存储在如下结构体中:
struct Readout {
char hour; // [0:23]
int value;
char seq; // sequence mark ['a':'z']
};
你可以想象 Readout 对象的成员在内存中的布局如下:
成员按声明顺序分配在内存中,因此 hour 的地址一定小于 value 的地址。另请参阅 §8.2.6。
但是,结构体对象的大小不一定是其成员大小的总和。这是因为许多机器要求将某些类型的对象分配在与架构相关的边界上,或者如果这样做,处理此类对象会更加高效。例如,整数通常分配在字边界上。在这样的机器上,对象必须正确对齐(§6.2.9)。这会导致结构中出现“空洞”(译注:也就是说系统分配的内存的地址一定是对齐要求的整数倍,如果结构体不足这么,空出的部分就称为“空洞”,空洞的内容是随机的)。在具有 4 字节 int 对齐的机器上,Readout 的更现实布局会是:
在这种情况下,与许多机器一样,sizeeof(Readout) 为 12,而不是 6,因为人们天真地认为只需将各个成员的大小简单地相加就可以得到 6。
您可以通过简单地按大小对成员进行排序(最大的成员排在最前面)来最大限度地减少浪费的空间。例如:
struct Readout {
int value;
char hour; // [0:23]
char seq; // sequence mark ['a':'z']
};
这会给出:
请注意,这仍然会在 Readout 中留下一个 2 字节的“空洞”(未使用的空间),并且 sizeof(Readout)==8。原因是当我们将两个对象放在一起时,例如在 Readout 数组中,我们需要保持对齐。10 个 Readout 对象的数组的大小为 10∗sizeof(Readout)。
通常,最好对成员进行排序以便于阅读,并且只有当确实需要优化时才按大小排序。
使用多个访问说明符(即公共、私有或受保护)可能会影响布局(§20.5)。
8.2.2 struct 命名(struct Names)
类型的名称所见即可用,而无需完整声明后才可用。例如:
struct Link {
Link∗ previous;
Link∗ successor;
};
但是,在结构的完整声明之前,无法声明结构的新对象。例如:
struct No_good {
No_good member; // 错: 递归定义
};
这是一个错误,因为编译器无法确定 No_good 的大小。为了允许两个(或更多)结构相互引用,我们可以将一个名称声明为结构的名称(译注:即先申请一个结构名,在后面再定义)。例如:
struct List; // struct名声明: List 稍后定义
struct Link {
Link∗ pre;
Link∗ suc;
List∗ member_of;
int data;
};
struct List {
Link∗ head;
};
如果没有 List 的第一个声明,则在 Link 的声明中使用指针类型 List∗ 将会出现语法错误。
struct的名称可以在定义类型之前使用,只要这种使用不需要知道成员的名称或结构的大小即可。但是,在完成结构声明之前,该结构都是不完整的类型。例如:
struct S; // ‘‘S’’是某种类型名
extern S a;
S f();
void g(S);
S∗ h(S∗);
但是,除非定义了类型 S,否则许多此类声明都不能使用:
void k(S∗ p)
{
S a; // 错 : S 未定义; 分配需知大小
f(); //错 : S 未定义; 反回值需知大小
g(a); //错 : S 未定义; 传参需知大小
p−>m = 7; //错 : S 未定义; 未知成员名
S∗ q = h(p); // ok: 可分配和传递指针
q−>m = 7; //错 : S 未定义; 成员名未知
}
由于可追溯的 C 语言史前原因,可以在同一作用域内声明具有相同名称的结构和非结构。例如:
struct stat { /* ... */ };
int stat(char∗ name, struct stat∗ buf);
在这种情况下,普通名称 (stat) 是非结构的名称,并且必须使用前缀 struct 来引用结构。同样,关键字 class、union (§8.3) 和 enum (§8.4) 可用作前缀以消除歧义。但是,最好不要重载名称,以使这种显式歧义成为必要。
8.2.3 结构体和类(Structures and Classes)
结构体只是一个类,其成员默认为公共。因此,结构体可以有成员函数(§2.3.2,第 16 章)。特别是,结构体可以有构造函数。例如:
struct Points {
vector<Point> elem;// 必须至少包含一个Point
Points(Point p0) { elem.push_back(p0);}
Points(Point p0, Point p1) { elem.push_back(p0); elem.push_back(p1); }
// ...
};
Points x0; // 错: 无默认构造函数
Points x1{ {100,200} }; // 1个Point
Points x1{ {100,200}, {300,400} }; // 2个Points
您不需要定义构造函数来按顺序初始化成员。例如:
struct Point {
int x, y;
};
Point p0; // 危险 : 若在局域作用域内未初始化(§6.3.5.1)
Point p1 {}; // 默认构造函数: {{},{}};即 {0.0}
Point p2 {1}; // 第二个成员默认构造: {1,{}}; 即{1,0}
Point p3 {1,2}; // {1,2}
如果需要重新排序参数、验证参数、修改参数、建立不变量(§2.4.3.2、§13.4)等,则需要构造函数。例如:
struct Address {
string name; // "Jim Dandy"
int number; // 61
string street; // "South St"
string town; // "New Providence"
char state[2]; // ’N’ ’J’
char zip[5]; // 07974
Address(const string n, int nu, const string& s, const string& t, const string& st, int z);
};
在这里,我添加了一个构造函数,以确保每个成员都已初始化,并允许我使用字符串和 int 作为邮政编码,而不是摆弄单个字符。例如:
Address jd = {
"Jim Dandy",
61, "South St",
"New Providence",
"NJ", 7974 // (07974 would be octal; §6.2.4.1)
};
Address 构造函数可能定义如下:
Address::Address(const string& n, int nu, const string& s, const string& t, const string& st, int z)
// validate postal code
:name{n},
number{nu},
street{s},
town{t}
{
if (st.size()!=2)
error("State abbreviation should be two characters")
state = {st[0],st[1]}; //将邮政编码存储为字符
ostringstream ost; //输出字符串流;参见 §38.4.2
ost << z; //从 int 中提取字符
string zi {ost.str()};
switch (zi.siz e()) {
case 5:
zip = {zi[0], zi[1], zi[2], zi[3], zi[4]};
break;
case 4: // star ts with ’0’
zip = {'0', zi[0], zi[1], zi[2], zi[3]};
break;
default:
error("unexpected ZIP code format");
}
// ... 检验编码使其有意义 ...
}
8.2.4 结构体和数组(Structures and Arrays)
当然,我们可以有结构体的数组和包含数组的结构体。例如:
struct Point {
int x,y
};
Point points[3] {{1,2},{3,4},{5,6}};
int x2 = points[2].x;
struct Array {
Point elem[3];
};
Array points2 {{1,2},{3,4},{5,6}};
int y2 = points2.elem[2].y;
将内置数组放入结构体中允许我们将该数组视为对象:我们可以在初始化(包括参数传递和函数返回)和赋值时复制包含它的结构体。例如:
Array shift(Array a, Point p)
{
for (int i=0; i!=3; ++i) {
a.elem[i].x += p.x;
a.elem[i].y += p.y;
}
return a;
}
Array ax = shift(points2,{10,20});
Array 的符号有点原始:为什么 i!=3?为什么一直重复 .elem[i]?为什么只是 Point 类型的元素?标准库提供了std::array (§34.2.1),作为固定大小数组作为结构体思想的更完整、更优雅的开发:
template<typename T, siz e_t N >
struct array { // simplified (see §34.2.1)
T elem[N];
T∗ begin() noexcept { return elem; }
const T∗ begin() const noexcept {return elem; }
T∗ end() noexcept { return elem+N; }
const T∗ end() const noexcept { return elem+N; }
constexpr size_t size() noexcept;
T& operator[](size_t n) { return elem[n]; }
const T& operator[](size_type n) const { return elem[n]; }
T ∗ data() noexcept { return elem; }
const T ∗ data() const noexcept { return elem; }
// ...
};
此数组是一个模板,允许任意数量的任意类型的元素。它还直接处理异常(§13.5.1.1)和 const 对象(§16.2.9.1)的可能性。使用array,我们现在可以编写:
struct Point {
int x,y
};
using Array = array<Point,3>; // 3 个 Points 的数组
Array points {{1,2},{3,4},{5,6}};
int x2 = points[2].x;
int y2 = points[2].y;
Array shift(Array a, Point p)
{
for (int i=0; i!=a.size(); ++i) {
a[i].x += p.x;
a[i].y += p.y;
}
return a;
}
Array ax = shift(points,{10,20});
std::array 相对于内置数组的主要优势在于它是一种合适的对象类型(具有赋值等),并且不会隐式转换为指向单个元素的指针:
ostream& operator<<(ostream& os, Point p)
{
cout << '{' << p[i].x << ',' << p[i].y << '}';
}
void print(Point a[],int s) // must specify number of elements
{
for (int i=0; i!=s; ++i)
cout << a[i] << '\n';
}
template<typename T, int N>
void print(array<T,N>& a)
{
for (int i=0; i!=a.size(); ++i)
cout << a[i] << '\n';
}
Point point1[] = {{1,2},{3,4},{5,6}}; // 3 elements
array<Point,3> point2 = {{1,2},{3,4},{5,6}}; // 3 elements
void f()
{
print(point1,4); // 4 is a bad error
print(point2);
}
与内置数组相比,std::array 的缺点是我们无法从初始化器的长度推断出元素的数量:
Point point1[] = {{1,2},{3,4},{5,6}}; // 3 elements
array<Point,3> point2 = {{1,2},{3,4},{5,6}}; // 3 elements
array<Point> point3 = {{1,2},{3,4},{5,6}}; // 错 :未给出元素数
8.2.5 类型等价(Type Equivalence)
即使两个结构体具有相同的成员(译注:等价类型),它们也是不同的类型。例如:
struct S1 { int a; };
struct S2 { int a; };
S1 和 S2 是两种不同的类型,因此:
S1 x;
S2 y = x; // 错 : 类型不匹配
结构体也是与用作成员的类型不同的类型。例如:
S1 x;
int i = x; //错 : 类型不匹配
每个结构在程序中都必须具有唯一的定义(§15.2.3)。
8.2.6 平坦型旧数据(Plain Old Data)
有时,我们只想将对象视为“平坦旧数据”(内存中连续的字节序列),而不必担心更高级的语义概念,例如运行时多态性(§3.2.3、§20.3.2)、用户定义的复制语义(§3.3、§17.5)等。通常,这样做的原因是为了能够以硬件能够实现的最高效方式移动对象。例如,使用 100 次复制构造函数调用复制 100 个元素的数组不太可能像调用 std::memcpy()那样快,后者通常只使用块移动机器指令。即使构造函数是内联的,优化器也很难发现这种优化。这种“技巧”并不罕见,而且在容器(如vector)的实现和底层 I/O 例程中很重要。它们是不必要的,应该在高级代码中避免使用。
因此,POD(“平坦旧数据”)是一种可以作为“纯数据”进行操作的对象,无需担心类布局的复杂性或构造、复制和移动的用户定义语义。例如:
struct S0 { }; // a POD
struct S1 { int a; }; // a POD
struct S2 { int a; S2(int aa) : a(aa) { } }; // not a POD (no default constructor)
struct S3 { int a; S3(int aa) : a(aa) { } S3() {} }; // a POD (user-defined default constructor)
struct S4 { int a; S4(int aa) : a(aa) { } S4() = default; }; // a POD
struct S5 { virtual void f(); /* ... */ }; // not a POD (has a virtual function)
struct S6 : S1 { }; // a POD
struct S7 : S0 { int b; }; // a POD
struct S8 : S1 { int b; }; // not a POD (data in both S1 and S8)
struct S9 : S0, S1 {}; // a POD
为了让我们能够将对象作为“仅仅是数据”(作为 POD)来操作,该对象必须
• 没有复杂的内存布局(例如,使用 vptr;(§3.2.3、§20.3.2),
• 没有非标准(用户定义)复制语义,并且
• 有一个简单的默认构造函数。
显然,我们需要精确定义 POD,以便我们只在不破坏任何语言保证的情况下使用此类优化。正式地(§iso.3.9、§iso.9),POD 对象必须是
• 标准内存布局类型,以及
• 可简单复制的类型,
• 具有简单默认构造函数的类型。
一个相关的概念是平凡类型(trivial type),它是一种具有
• 一个简单的默认构造函数和
• 简单的复制和移动操作
的类型。
非正式地,如果默认构造函数不需要做任何工作,那么它就是简单的(如果需要定义一个,请使用 = default §17.6.1)。
类型有标准布局,除非
• 具有非标准布局的非静态成员或基类,
• 具有虚拟函数(§3.2.3、§20.3.2),
• 具有虚拟基类(§21.3.5),
• 具有引用成员(§7.7),
• 具有非静态数据成员的多个访问说明符(§20.5),或
• 阻止重要的布局优化:
(1) 在多个基类中或在派生类和基类中都拥有非静态数据成员,或者
(2) 拥有与第一个非静态数据成员相同类型的基类。
基本上,标准布局类型是一种具有与 C 中明显等价的布局并且属于常见 C++ 应用程序二进制接口 (ABI) 可以处理的合并体的类型。
除非类型具有非平凡的复制操作、移动操作或析构函数(§3.2.1.2、§17.6),否则该类型是可平凡复制的。非正式地说,如果复制操作可以实现为按位复制,则它是平凡的。那么,是什么让复制、移动或析构函数变得非平凡?
• 它是用户定义的。
• 它的类有一个虚拟函数。
• 它的类有一个虚拟基。
• 它的类有一个不平凡的基或成员。
内置类型的对象是可简单复制的,并且具有标准布局。此外,可简单复制的对象数组也是可简单复制的,而标准布局对象的数组具有标准布局。考虑一个例子:
template<typename T>
void mycopy(T∗ to, const T∗ from, int count);
我想优化 T 是 POD 的简单情况。我可以通过只为 POD 调用 mycopy() 来做到这一点,但这很容易出错:如果我使用 mycopy(),我能否依靠代码维护者记住永远不要为非 POD 调用 mycopy()?实际上,我不能。或者,我可以调用 std::copy(),这很可能是通过必要的优化实现的。无论如何,这是通用的和优化的代码:
template<typename T>
void mycopy(T∗ to, const T∗ from, int count)
{
if (is_pod<T>::value)
memcpy(to,from,count∗sizeof(T));
else
for (int i=0; i!=count; ++i)
to[i]=from[i];
}
is_pod 是 <type_traits> 中定义的标准库类型属性谓词 (§35.4.1),允许我们在代码中提出问题“T 是 POD 吗?”。is_pod<T> 的最大优点是它让我们无需记住 POD 的确切规则。
请注意,添加或减少非默认构造函数不会影响布局或性能(在 C++98 中并非如此)。
标准(§iso.3.9、§iso.9)并尝试思考它们对程序员和编译器编写者的影响。这样做可能会在它占用你太多时间之前治愈你的冲动。
8.2.7 位域(Fields)
使用整个字节(char 或 bool)来表示二进制变量(例如,一个on/off开关)似乎有些奢侈,但 char 是 C++ 中可以独立分配和寻址的最小对象(§7.2)。但是,可以将几个这样的小变量捆绑在一起作为结构中的字段。字段通常称为位域。通过指定成员要占用的位数,将其定义为位域。允许使用未命名的位域。它们不会影响命名位域的含义,但可以以某种与机器相关的方式使用它们来改善布局:
struct PPN { // R6000 物理页号
unsigned int PFN : 22; // 页帧号
int : 3; // 未用
unsigned int CCA : 3; // 缓存一致性算法
bool nonreachable : 1;
bool dirty : 1;
bool valid : 1;
bool global : 1;
};
此示例还说明了字段的其他主要用途:命名外部强加的布局的各个部分。位域必须是整数或枚举类型(§6.2.1)。无法获取位域的地址。除此之外,它可以像其他变量一样使用。请注意,bool 字段实际上可以用单个位表示。在操作系统内核或调试器中,PPN 类型可能按如下方式使用:
void part_of_VM_system(PPN∗ p)
{
// ... if (p−>dirty) { // contents changed
// copy to disk
p−>dirty = 0;
}
}
令人惊讶的是,使用位域将多个变量打包成一个字节并不一定能节省空间。虽然它节省了数据空间,但在大多数机器上,操作这些变量所需的代码大小会增加。众所周知,当二进制变量从位域转换为字符时,程序会大大缩小!此外,访问 char 或 int 通常比访问位域快得多。位域只是使用按位逻辑运算符(§11.1.1)从字的一部分中提取信息和将信息插入其中某位的的一种方便的简写。
8.3 合并体(Unions)
合并体是一种其中所有成员都分配在同一地址的结构,因此合并仅占用其最大成员的空间。当然,合并体一次只能保存一个成员的值。例如,考虑一个包含名称和值的符号表条目:
enum Type { str, num };
struct Entry {
char∗ name;
Type t;
char∗ s; // use s if t==str
int i; // use i if t==num
};
void f(Entry∗ p)
{
if (p−>t == str)
cout << p−>s;
// ...
}
成员 s 和 i 永远不能同时使用,因此会浪费空间。可以通过指定两者都应为 union 的成员来轻松回收空间,如下所示:
union Value {
char∗ s;
int i;
};
该语言不跟踪联合体保存哪种值,因此程序员必须这样做:
struct Entry {
char∗ name;
Type t;
Value v; // use v.s if t==str; use v.i if t==num
};
void f(Entry∗ p)
{
if (p−>t == str)
cout << p−>v.s;
// ...
}
为了避免错误,可以封装一个union,这样就可以保证类型字段和union成员访问之间的对应关系(§8.3.2)。
union有时会被误用为“类型转换”。这种误用主要由接受过没有明确类型转换功能的语言培训的程序员实施,因此作弊是必要的。例如,以下代码只需假设按位等价即可将 int 转换为 int∗:
union Fudge {
int i;
int∗ p;
};
int∗ cheat(int i)
{
Fudge a;
a.i = i;
return a.p; // 严重误用
}
这实际上根本不是转换。在某些机器上,int 和 int∗ 占用的空间大小不同,而在其他机器上,没有整数可以有奇数地址。这种合并的使用是危险的,并且不可移植。如果您需要这种本质上丑陋的转换,请使用显式类型转换运算符(§11.5.2),以便读者可以看到发生了什么。例如:
int∗ cheat2(int i)
{
return reinterpret_cast<int∗>(i); // 明显是丑陋且危险的
}
在这里,如果对象的大小不同,编译器至少有机会警告你,并且这样的代码会显得格格不入。
使用合并体对于数据紧凑性以及性能至关重要。但是,大多数程序使用联合后性能并没有得到太大的改进,而且合并体很容易出错。因此,我认为合并体是一种过度使用的功能;尽可能避免使用它们。
8.3.1 合并体和类(Unions and Classes)
许多非平凡合并体的成员比最常用的成员大得多。由于合并体的大小至少与其最大成员一样大,因此会浪费空间。通常可以通过使用一组派生类(§3.2.2,第 20 章)代替合并体来消除这种浪费。
从技术上讲,合并体是一种结构体(struct)(§8.2),而结构体又是一种类(第 16 章)。
但是,为类提供的许多功能与合并体无关,因此对合并体施加了一些限制:
[1] 合并体不能具有虚函数。
[2] 合并体不能具有引用类型的成员。
[3] 合并体不能具有基类。
[4] 如果合并体的成员具有用户定义的构造函数、复制操作、移动操作或析构函数,则该特殊函数将被删除(§3.3.4、§17.6.4);
也就是说,它不能用于合并体类型的对象。
[5] 合并体最多只能有一个成员具有类内初始化程序(§17.4.4)。
[6] 合并体不能用作基类。
这些限制可以防止许多细微的错误,并简化 union 的实现。后者很重要,因为 union 的使用通常是一种优化,我们不希望强加“隐性成本”来损害这一点。
从具有构造函数(等)的成员的合并体中删除构造函数(等)的规则使简单合并体保持简单,并迫使程序员在需要时提供复杂的操作。例如,由于 Entry 没有具有构造函数、析构函数或赋值的成员,因此我们可以自由地创建和复制 Entry。例如:
void f(Entry a)
{
Entry b = a;
};
使用更复杂的合并体来执行此操作会导致实施困难或错误:
union U {
int m1;
complex<double> m2; // complex 有一个构造函数
string m3; // string有一个构造函数(保持严格不变)
};
如果要复制 U,我们必须决定使用哪种复制操作。例如:
void f2(U x)
{
U u; // 错 : 调用哪一个默认构造函数?
U u2 = x; //错: 调用哪一个复制构造函数?
u.m1 = 1; // 赋值给int
string s = u.m3; // 灾难 : 从字符串成员读取
return; //错:对于 x, u, 和 u2 调用哪一个析构函数?
}
写入一个成员然后读取另一个成员是违背语法的,但人们还是会这样做(通常是错误的)。在这种情况下,string复制构造函数将被调用并带有无效参数。幸运的是,U 不会编译。在需要时,用户可以定义一个包含合并体的类,该类使用构造函数、析构函数和赋值正确处理合并成员(§8.3.2)。如果需要,这样的类还可以防止写入一个成员然后读取另一个成员的错误。
最多可以为一个成员指定类内初始化器。如果这样,则此初始化器将用于默认初始化。例如:
union U2 {
int a;
const char∗ p {""};
};
U2 x1; //默认初始化器为 x1.p == ""
8.3.2 匿名合并体(Anonymous Unions)
要了解如何编写一个能够克服滥用 union 的问题的类,请考虑 Entry 的一个变体(§8.3):
class Entry2 { //两个替代表示形式表示为一个合并体
private:
enum class Tag { number, text };
Tag type; //判别式
union { // 表示
int i;
string s; // string 有默认构造函数、复制操作和析构函数
};
public:
struct Bad_entry { }; // 用作异常
string name;
˜Entry2();
Entry2& operator=(const Entry2&); //由于字符串变体而必需
Entry2(const Entr y2&);
// ...
int number() const;
string text() const;
void set_number(int n);
void set_text(const string&);
// ...
};
我不喜欢 get/set 函数,但在这种情况下,我们确实需要在每次访问时执行一个非平凡的用户指定操作。我选择以值命名“get”函数,并使用 set_ 前缀作为“set”函数。这恰好是我最喜欢的命名约定。
读访问函数可以定义如下:
int Entry2::number() const
{
if (type!=Tag::number) throw Bad_entry{};
return i;
};
string Entry2::text() const
{
if (type!=Tag::text) throw Bad_entry{};
return s;
};
这些访问函数会检查类型标记,如果标记与我们需要的访问正确对应,则返回对该值的引用;否则,将引发异常。这样的联合通常称为带标记合并体(tagged union)或可区分合并体(discriminated union)。
写访问函数基本上对类型标签进行相同的检查,但请注意,设置新值时必须考虑先前的值:
void Entry2::set_number(int n)
{
if (type==Tag::text) {
s.˜string(); // 显式销毁 string (§11.2.4)
type = Tag::number;
}
i = n;
}
void Entry2::set_text(const string& ss)
{
if (type==Tag::text)
s = ss;
else {
new(&s) string{ss}; // 定位放置 new: 显式构造 string (§11.2.4)
type = Tag::text;
}
}
使用 union 迫使我们使用其他晦涩难懂的底层语言工具(显式构造和析构)来管理 union 元素的生命周期。这是谨慎使用 union 的另一个原因。
请注意,Entry2 声明中的合并体未命名。这使它成为一个匿名合并体。匿名合并体是一个对象,而不是类型,并且无需提及对象名称即可访问其成员。这意味着我们可以像使用类的其他成员一样使用匿名合并体的成员——只要我们记住合并体成员实际上一次只能使用一个。
Entry2 有一个带有用户定义赋值运算符的类型成员,即字符串,因此 Entry2 的赋值运算符被删除(§3.3.4、§17.6.4)。如果我们想赋值Entry2,我们必须定义 Entry2::operator=()。赋值结合了读取和写入的复杂性,但在逻辑上与访问函数类似:
Entry2& Entr y2::operator=(const Entr y2& e) //由于字符串变体而必需
{
if (type==Tag::text && e.type==Tag::text) {
s = e.s; // 常规string 赋值
return ∗this;
}
if (type==Tag::text) s.˜string(); // 显式销毁 (§11.2.4)
switch (e.type) {
case Tag::number:
i = e.i;
break;
case Tag::text:
new(&s)(e .s); // 定位放置new: 显式构造 (§11.2.4)
type = e.type;
}
return ∗this;
}
构造函数和移动赋值可以根据需要以类似的方式定义。我们至少需要一个或两个构造函数来建立类型标记和值之间的对应关系。析构函数必须处理字符串大小写:
Entry2::˜Entry2()
{
if (type==Tag::text) s.˜string(); // 显式销毁 (§11.2.4)
}
8.4 枚举(Enumerations)
枚举是一种可以保存用户指定的一组整数值的类型(§iso.7.2)。枚举的一些可能值被命名并称为枚举项(enumerators)。例如:
enum class Color { red, green, blue };
这定义了一个名为 Color 的枚举,枚举项为red,green和blue。“enumeration”通俗地缩写为“enum”。
有两种枚举:
[1] 枚举类(enum class),其枚举项名称(例如 red)是枚举的局部名称,并且它们的值不会隐式转换为其他类型。
[2] “普通枚举(Plain enums)”,其枚举项名称与枚举在同一作用域内,并且它们的值隐式转换为整数。
一般来说,更喜欢枚举类,因为它们引起的意外更少。
8.4.1 枚举类(Enum Classes)
枚举类是具有作用域和强类型的枚举。例如:
enum class Traffic_light { red, yellow, green };
enum class Warning { green, yellow, orange, red }; // fire alert levels
Warning a1 = 7; // 错: 无int到Waning 的转换
int a2 = green; //错: green 不在作用域
int a3 = Warning::green; // //错:无Waning 到int的转换
Warning a4 = Warning::green; // OK
void f(Traffic_light x)
{
if (x == 9) { /* ... */ } // 错: 9不是一个交通信号灯
if (x == red) { /* ... */ } // 错:作用域内没有red
if (x == Warning::red) { /* ... */ } // 错: x 不是Warning
if (x == Traffic_light::red) { /* ... */ } // OK
}
请注意,两个枚举中存在的枚举项不会发生冲突,因为每个枚举项都在其自己的枚举类的作用域内。
枚举由某个整数类型表示,每个枚举项由某个整数值表示。我们将用于表示枚举的类型称为其基础类型。基础类型必须是有符号或无符号整数类型之一(§6.2.4);默认值为 int。我们可以明确说明:
enum class Warning : int { green, yellow, orange, red }; // sizeof(War ning)==sizeof(int)
如果我们认为这太浪费空间,我们可以使用 char:
enum class Warning : char { green, yellow, orange, red }; // sizeof(War ning)==1
默认情况下,枚举值从 0 开始递增分配。这里,我们得到:
static_cast<int>(Warning::green)==0
static_cast<int>(Warning::yellow)==1
static_cast<int>(Warning::orang e)==2
static_cast<int>(Warning::red)==3
将变量声明为 Warning 而不是普通的 int 可以为用户和编译器提供有关预期用途的提示。例如:
void f(Warning key)
{
switch (key) {
case Warning::green:
// do something
break;
case Warning::orang e:
// do something
break;
case Warning::red:
// do something
break;
}
}
人们可能会注意到缺少了黄色,而编译器可能会发出警告,因为只有四个警告值中的三个被处理。
枚举项可以通过整数类型(§6.2.1)的常量表达式(§10.4)初始化。例如:
enum class Printer_flags {
acknowledg e=1,
paper_empty=2,
busy=4,
out_of_black=8,
out_of_color=16,
//
};
Printer_flags 枚举项的值经过选择,以便可以通过按位运算进行组合。枚举是用户定义的类型,因此我们可以为其定义 | 和 & 运算符(§3.2.1.1,第 18 章)。例如:
constexpr Printer_flags operator|(Printer_flags a, Printer_flags b)
{
return static_cast<Printer_flags>(static_cast<int>(a))|static_cast<int>(b));
}
constexpr Printer_flags operator&(Printer_flags a, Printer_flags b)
{
return static_cast<Printer_flags>(static_cast<int>(a))&static_cast<int>(b));
}
显式转换是必要的,因为类枚举不支持隐式转换。鉴于 Printer_flags 的 | 和 & 的定义,我们可以这样写:
void try_to_print(Printer_flags x)
{
if (x&Printer_flags::acknowledg e) {
// ...
}
else if (x&Printer_flags::busy) {
// ...
}
else if (x&(Printer_flags::out_of_black|Printer_flags::out_of_color)) {
//要么我们失去了black,要么我们没有彩色
// ...
}
// ...
}
我将 operator|() 和 operator&() 定义为 constexpr 函数(§10.4、§12.1.6),因为有人可能想在常量表达式中使用这些运算符。例如:
void g(Printer_flags x)
{
switch (x) {
case Printer_flags::acknowledge:
// ...
break;
case Printer_flags::busy:
// ...
break;
case Printer_flags::out_of_black:
// ...
break;
case Printer_flags::out_of_color:
// ...
break;
case Printer_flags::out_of_black&Printer_flags::out_of_color:
//我们没有black且没有彩色
// ...
break;
}
// ...
}
可以先声明一个枚举类,而后再定义它(§6.3)。例如:
enum class Color_code : char; // declaration
void foobar(Color_code∗ p); // use of declaration
// ...
enum class Color_code : char { // definition
red, yellow, green, blue
};
整数类型的值可以显式转换为枚举类型。除非该值在枚举的基本类型的范围内,否则这种转换的结果是未定义的。例如:
enum class Flag : char{ x=1, y=2, z=4, e=8 };
Flag f0{}; //f0 gets the default value 0
Flag f1 = 5; // 类型钷: 5不是类型Flag
Flag f2 = Flag{5}; //错: 无到枚举类的窄化㔹换
Flag f3 = static_cast<Flag>(5); //蛮力
Flag f4 = static_cast<Flag>(999); // 错: 999 不是一个char 值(可能不会捕捉到)
最后的任务说明了为什么没有从整数到枚举的隐式转换;大多数整数值在具体的枚举中没有表示。
每个枚举项都有一个整数值。我们可以显式地提取该值。例如:
int i = static_cast<int>(Flag::y); // i becomes 2
char c = static_cast<char>(Flag::e); // c becomes 8
枚举值范围的概念与 Pascal 语言系列中的枚举概念不同。但是,在 C 和 C++ 中,需要枚举项之外的值进行良好定义的位操作示例(例如 Printer_flags 示例)由来已久。
枚举类的大小是其基础类型的大小。具体来说,如果没有明确指定基础类型,则大小为 sizeof(int)。
8.4.2 普通枚举(Plain enums)
“普通枚举”大致就是 C++ 在引入枚举类之前提供的功能,因此您会在许多 C 和 C++98 风格的代码中找到它们。普通枚举的枚举器被导进到枚举的作用域中,并隐式转换为某种整数类型的值。请考虑 §8.4.1 中移除“class”的示例:
enum Traffic_light { red, yellow, green };
enum Warning { green, yellow, orange, red }; // fire alert levels
// 错: yellow 的两个定义(指向定一个值)(译注:两个枚举中的项有重复)
//错: red 的两个定义(指向不同值) (译注:两个枚举中的项有重复)
Warning a1 = 7; //错: 无 int->Warning 转换
int a2 = green; // OK: green is in scope and converts to int
int a3 = Warning::green; // OK: War ning->int conversion
Warning a4 = Warning::green; // OK
void f(Traffic_light x)
{
if (x == 9) { /* ... */ } // OK (but Traffic_light doesn’t have a 9)
if (x == red) { /* ... */ } // 错: 作用域内两个reds
if (x == Warning::red) { /* ... */ } // OK (Ouch!)
if (x == Traffic_light::red) { /* ... */ } // OK
}
我们很幸运,在单个作用域中用两个普通枚举定义红色可以避免难以发现的错误。考虑通过消除枚举器的歧义来“清理”普通枚举(在小程序中很容易做到,但在大程序中很难做到):
enum Traffic_light { tl_red, tl_yellow, tl_green };
enum Warning { green, yellow, orange, red }; // fire alert levels
void f(Traffic_light x)
{
if (x == red) { /* ... */ } // OK (ouch!)
if (x == Warning::red) { /* ... */ } // OK (ouch!)
if (x == Traffic_light::red) { /* ... */ } // 错: red 不是一个Traffic_light 值
}
编译器接受 x==red,这几乎肯定是一个错误。将名称注入封闭作用域(如枚举,但不是枚举类或类)是命名空间污染,并且可能成为大型程序中的一个主要问题(第 14 章)。
您可以指定普通枚举的底层类型,就像枚举类一样。如果您这样做,则可以声明枚举,而不必稍后再定义它们。例如:
enum Traffic_light : char { tl_red, tl_yellow, tl_green }; // 底层类型是char
enum Color_code : char; // declaration
void foobar(Color_code∗ p); // use of declaration
// ...
enum Color_code : char { red, yellow, green, blue }; // definition
如果不指定底层类型,则无法在不定义枚举的情况下声明枚举,其底层类型由一个相对复杂的算法确定:当所有枚举器都为非负数时,枚举的范围为 [0:2k-1],其中 2k 是所有枚举器都在范围内的 2 的最小幂。如果有负枚举器,则范围为 [-2k:2k-1]。这定义了能够使用常规的二进制补码表示法保存枚举器值的最小位字段。例如:
enum E1 { dark, light }; // range 0:1
enum E2 { a = 3, b = 9 }; // range 0:15
enum E3 { min = −10, max = 1000000 }; // range -1048576:1048575
将整数显式转换为普通枚举的规则与类枚举相同,只是当没有显式基础类型时,这种转换的结果是未定义的,除非值在枚举的范围内。例如:
enum Flag { x=1, y=2, z=4, e=8 }; // range 0:15
Flag f0{}; //f0 取默认值 0
Flag f1 = 5; //类型错: 5 不是类型Flag类型
Flag f2 = Flag{5}; //错: 无从int 到 Flag的隐式转换
Flag f2 = static_cast<Flag>(5); // OK: 5 在Flag的范围内
Flag f3 = static_cast<Flag>(z|e); // OK: 12在Flag的范围内
Flag f4 = static_cast<Flag>(99); // 未定义: 99不在Flag的范围内
因为存在从普通枚举到其基础类型的隐式转换,所以我们不需要定义 | 来使此示例工作:z 和 e 转换为 int,以便可以评估 z|e。枚举的大小是其基础类型的大小。如果没有明确指定基础类型,则它是某种可以容纳其范围且不大于 sizeof(int) 的整数类型,除非枚举器不能表示为 int 或无符号 int。例如,sizeof(e1) 可以是 1 或可能是 4,但在 sizeof(int)==4 的机器上不是 8。
8.4.3 无名枚举(Unnamed enums)
普通枚举可以不命名。例如:
enum { arrow_up=1, arrow_down, arrow_sideways };
当我们需要的是一组整数常量而不是用于变量的类型时,我们就会使用它。
8.5 建议
[1] 当数据的紧凑性很重要时,将结构数据成员的布局方式设置为:较大的成员在较小的成员之前;§8.2.1。
[2] 使用位域表示硬件强加的数据布局(译注:位操作);§8.2.7。
[3] 不要天真地尝试通过将多个值打包成一个字节来优化内存消耗;§8.2.7。
[4] 使用合并体节省空间(表示替代方案),而不要用于类型转换;§8.3。
[5] 使用枚举来表示命名常量的集合;§8.4。
[6] 优先使用类枚举而不是“普通”枚举,以尽量减少意外;§8.4。
[7] 定义枚举上的操作以确保安全和简单的使用;§8.4.1
内容来源:
<<The C++ Programming Language >> 第4版,作者 Bjarne Stroustrup