C++ 第八章 结构、联合与枚举 - 8.3 联合

第八章 结构、联合与枚举

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施加了一些限制:

  1. union不能含有虚函数
  2. union不能含有引用类型的成员
  3. union不能含有基类
  4. 如果union的成员含有用户自定义的构造函数、拷贝操作、移动操作或者析构函数,则此类函数对于union来说被delete掉了(见3.3.4节和17.6.4节)。换句话说,union类型的对象不能含有这些函数
  5. 在union的所有成员中,最多只能有一个成员含类内初始化器(见17.4.4节)
  6. 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节)
}
上一篇:MySql子查询有多个结果的查询方法


下一篇:深入理解C语言之union(共用体)和结构体struct