第八章 结构、联合与枚举
8.3 联合
union是一种特殊的struct,它的所有成员都分配在同一个地址空间上。因此,一个union实际占用的空间大小与其最大的成员一样。自然地,在同一时刻union只能保存一个成员的值。例如,考虑一个符号表项存放名字和值对的情况:
enum Type{str, num};
struct Entry{
char* name;
Type t;
char* s; //如果t == str,使用s
int i; //如果t == num,使用i
};
void f(Entry* p)
{
if(p->t == str)
cout << p->s;
//...
}
在这个例子中,成员s和i永远不会被同时使用,因此空间都浪费了。我们可以把它们指定成union的成员,以解决上述问题:
union Value{
char* s;
int i;
};
语言本身并不负责追踪和管理union到底存的是哪些值,这是程序员的责任:
struct Entry{
char* name;
Type t;
Value v; //如果 t==str,使用v.s;如果t==num,使用v.i
};
void f(Emtry* p)
{
if(p->t == str)
cout << p->v.s;
//...
}
为了避免可能出现的错误,程序员最好把union封装起来,从而确保访问union成员的方式与该成员的类型永远保持一致(见8.3.2节)。
联合有时候会被误用于“类型转换”的目的。这种误用的情况常常发生在一些特定的程序员身上,他们曾经使用的编程语言缺少显式类型转换的功能,因此不得不采用这种方式。例如,下面所示的 int 向 int* 的“转换”以这两种类型逐位等价为前提:
union Fudge{
int i;
int* p;
};
int* cheat(int i)
{
Fudge a;
a.i = i;
return a.p; //错误的用法
}
这根本算不上是一种类型转换。在一些机器环境中,int和int*占用的空间大小并不一样;而在另外一些机器中,整数的地址不能是奇数。因此,像这样使用union不但危险,而且无法移植。如果你确实需要类似的转换,最好使用显式类型转换符(见11.5.2节),这样读者就能清楚地知道到底发生了什么。例如:
int* cheat2(int i)
{
return reinterpret_cast<int*>(i); //显然这个转换本身既不美观,也很容易出错
}
无论如何,在上面的代码中,如果转换前后对象的尺寸不一致,那么编译器至少有机会给出报错信息;它比使用union的版本强多了。
使用union的目的无非是让数据更紧密,从而提高程序的性能。然而,大多数程序即使用了union也不会提高太多;同时,使用union的代码更容易出错。因此,我认为union是一种被过度使用的语言特性,最好不要出现在你的程序中。
8.3.1 联合和类
在很多非平凡的union中,存在一个不太常用的成员,它的尺寸比其他常用成员的尺寸都大得多。因为union的尺寸与它最大的成员一样大,所以避免地存在空间浪费的情况。我们可以使用一组派生类(见3.2.2节和第20章)代替union,从而避免空间的浪费。
从技术上来说,union是一种特殊的struct(见8.2节),而struct是一种特殊的class(第16章)。然而,很多提供给类的功能与联合无关,因此对union施加了一些限制:
- union不能含有虚函数
- union不能含有引用类型的成员
- union不能含有基类
- 如果union的成员含有用户自定义的构造函数、拷贝操作、移动操作或者析构函数,则此类函数对于union来说被delete掉了(见3.3.4节和17.6.4节)。换句话说,union类型的对象不能含有这些函数
- 在union的所有成员中,最多只能有一个成员含类内初始化器(见17.4.4节)
- union不能被用作其他类的基类
这些约束规则有效地阻止了很多错误的发生,同时简化了union的实现过程。后面一点非常重要,因为union的主要作用是优化代码的性能,所以我们肯定不希望在使用union的过程中引入“隐形的代价”。
如果union的成员含有构造函数(及其他),则必须delete掉这些函数。这条规则使得简单的union使用起来确实简单。如果需要用到更复杂的操作,那么由程序员来实现。例如,因为Entry的成员不含构造函数、析构函数及赋值操作,所以我们能*地创建Entry的副本:
void f(Entry a)
{
Entry b = a;
};
如果对一个复杂的union执行同样的操作,则不仅实现起来很难,而且容易出错:
union U{
int m1;
complex<double> m2; //复数含有构造函数
string m3; //string含有构造函数(维护一个重要的不变量)
};
要想拷贝U,程序员必须决定使用哪个拷贝操作。例如:
void f2(U x)
{
U u; //错误:哪个默认构造函数?
U u2 = x; //错误:哪个拷贝构造函数?
u.m1 = 1; //给int成员赋值
string s = u.m3; //程序灾难:从string成员中读取内容
return; //错误:x、u和u2使用的是哪个析构函数?
}
一般来说,先把值写入某个成员然后读取另一个成员的值通常是非法的,但是人们常常忽略这一点(从而产生了错误)。在此例中,程序以无效实参调用了string的拷贝构造函数。程序员应该庆幸这段代码无法通过编译,否则后果不堪设想。如果确实需要,用户可以定义一个包含union的类,该类可以正确地处理union中含有构造函数、析构函数和赋值操作(见8.3.2节)的成员。同时,该类还能防止先把值写入某个成员然后读取另一个成员的错误。
C++允许为联合的最多一个成员指定类内初始化器。此时,该初始化器被用于默认初始化。例如:
union U2{
int a;
const char* p{“”};
};
U2 x1; //执行默认初始化,使得x1.p == “”
U2 x2{7}; //x2.a == 7
8.3.2 匿名union
下面的程序建立了Entry(见8.3节)的一个变形,从中可以看出如何编写一个类来解决误用union带来的问题:
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&); //因为存在string变量,所以是必需的
Entry2(const Entry2&);
//...
int number() const;
string text() const;
void set_number(int n);
void set_text(const string&);
//...
};
我不是get/set函数的拥趸,但是在这个例子中,我们确实需要为每种访问操作自定义非平凡的形式。我用Tag的取值为“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;
};
这两个访问函数首先检查type标签,如果是我们想执行的访问,则返回对应值的引用;否则,抛出异常。这样的union称为标签联合(tagged union)或者可判别联合(discriminated union)。
执行写操作的函数检查type标签的方式与执行读操作的函数基本相同,但是在设置新值的时候必须考虑之前的值的情况:
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中声明的union没有命名,它是一个匿名联合(anonymous union)。匿名联合是一个对象而非一种类型,我们无须对象名就能直接访问它的成员。因此,我们使用匿名联合的成员的方式与使用类成员的方式完全一样,只要谨记同一时刻只能使用union的一个成员就可以了。
Entry2含有一个string类型的成员,而在string类型中有用户自定义的赋值运算符,因此Entry2的赋值运算符被delete掉了(见3.3.4节和17.6.4节)。要想为Entry2的对象赋值,就必须先定义Entry2::operator=()。赋值运算兼具读/写两种操作的复杂性,但是它在逻辑上与访问函数很相似:
Entry2& Entry2::operator=(const Entry2& e) //因为存在string变量,所以是必需的
{
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的作用是显式地构造string(见11.2.4节)
type = e.type;
}
return *this;
}
构造函数与移动赋值操作的定义方式可以很类似。我们至少需要一到两个构造函数来建立type标签和值之间的对应关系。析构函数必须能处理string类型:
Entry2::~Entry2()
{
if(type == Tag::text) s.~string(); //显式地销毁(见11.2.4节)
}