【C++基础】第一百一十一课:[特殊工具与技术]union:一种节省空间的类

union

Posted by x-jeff on September 12, 2024

【C++基础】系列博客为参考《C++ Primer中文版(第5版)》C++11标准)一书,自己所做的读书笔记。
本文为原创文章,未经本人允许,禁止转载。转载请注明出处。

1.union:一种节省空间的类

联合(union)是一种特殊的类。一个union可以有多个数据成员,但是在任意时刻只有一个数据成员可以有值。当我们给union的某个成员赋值之后,该union的其他成员就变成未定义的状态了。分配给一个union对象的存储空间至少要能容纳它的最大的数据成员。和其他类一样,一个union定义了一种新类型。

类的某些特性对union同样适用,但并非所有特性都如此。union不能含有引用类型的成员,除此之外,它的成员可以是绝大多数类型。在C++11新标准中,含有构造函数或析构函数的类类型也可以作为union的成员类型。union可以为其成员指定public、protected和private等保护标记。默认情况下,union的成员都是公有的,这一点与struct相同。

union可以定义包括构造函数和析构函数在内的成员函数。但是由于union既不能继承自其他类,也不能作为基类使用,所以在union中不能含有虚函数。

1.1.定义union

union提供了一种有效的途径使得我们可以方便地表示一组类型不同的互斥值。

1
2
3
4
5
6
7
//Token类型的对象只有一个成员,该成员的类型可能是下列类型中的任意一种
union Token {
    //默认情况下成员是公有的
    char cval;
    int ival;
    double dval;
};

1.2.使用union类型

union的名字是一个类型名。和其他内置类型一样,默认情况下union是未初始化的。我们可以像显式地初始化聚合类一样使用一对花括号内的初始值显式地初始化一个union:

1
2
3
Token first_token = {'a'}; //初始化cval成员
Token last_token; //未初始化的Token对象
Token *pt = new Token; //指向一个未初始化的Token对象的指针

我们使用通用的成员访问运算符访问一个union对象的成员:

1
2
last_token.cval = 'z';
pt->ival = 42;

为union的一个数据成员赋值会令其他数据成员变成未定义的状态。因此,当我们使用union时,必须清楚地知道当前存储在union中的值到底是什么类型。如果我们使用错误的数据成员或者为错误的数据成员赋值,则程序可能崩溃或出现异常行为,具体的情况根据成员的类型而有所不同。

1.3.匿名union

匿名union(anonymous union)是一个未命名的union,并且在右花括号和分号之间没有任何声明。一旦我们定义了一个匿名union,编译器就自动地为该union创建一个未命名的对象:

1
2
3
4
5
6
7
union { //匿名union
    char cval;
    int ival;
    double dval;
}; //定义一个未命名的对象,我们可以直接访问它的成员
cval = 'c'; //为刚刚定义的未命名的匿名union对象赋一个新值
ival = 42; //该对象当前保存的值是42

在匿名union的定义所在的作用域内该union的成员都是可以直接访问的。

匿名union不能包含受保护的成员或私有成员,也不能定义成员函数。

1.4.含有类类型成员的union

C++的早期版本规定,在union中不能含有定义了构造函数或拷贝控制成员的类类型成员。C++11新标准取消了这一限制。不过,如果union的成员类型定义了自己的构造函数和/或拷贝控制成员,则该union的用法要比只含有内置类型成员的union复杂得多。

当union包含的是内置类型的成员时,我们可以使用普通的赋值语句改变union保存的值。但是对于含有特殊类类型成员的union就没这么简单了。如果我们想将union的值改为类类型成员对应的值,或者将类类型成员的值改为一个其他值,则必须分别构造或析构该类类型的成员:当我们将union的值改为类类型成员对应的值时,必须运行该类型的构造函数;反之,当我们将类类型成员的值改为一个其他值时,必须运行该类型的析构函数。

当union包含的是内置类型的成员时,编译器将按照成员的次序依次合成默认构造函数或拷贝控制成员。但是如果union含有类类型的成员,并且该类型自定义了默认构造函数或拷贝控制成员,则编译器将为union合成对应的版本并将其声明为删除的(参见:阻止拷贝)。

1.5.使用类管理union成员

对于union来说,要想构造或销毁类类型的成员必须执行非常复杂的操作,因此我们通常把含有类类型成员的union内嵌在另一个类当中。这个类可以管理并控制与union的类类型成员有关的状态转换。举个例子,我们为union添加一个string成员,并将我们的union定义成匿名union,最后将它作为Token类的一个成员。此时,Token类将可以管理union的成员。

为了追踪union中到底存储了什么类型的值,我们通常会定义一个独立的对象,该对象称为union的判别式(discriminant)。我们可以使用判别式辨认union存储的值。为了保持union与其判别式同步,我们将判别式也作为Token的成员。我们的类将定义一个枚举类型的成员来追踪其union成员的状态。

在我们的类中定义的函数包括默认构造函数、拷贝控制成员以及一组赋值运算符,这些赋值运算符可以将union的某种类型的值赋给union成员:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Token {
public:
    //因为union含有一个string成员,所以Token必须定义拷贝控制成员
    Token() : tok(INT), ival{0} { }
    Token(const Token &t): tok(t.tok) { copyUnion(t); }
    Token &operator=(const Token&);
    //如果union含有一个string成员,则我们必须销毁它
    ~Token() { if(tok == STR) sval.~string(); }
    //下面的赋值运算符负责设置union的不同成员
    Token &operator=(const std::string&);
    Token &operator=(char);
    Token &operator=(int);
    Token &operator=(double);
private:
    enum {INT, CHAR, DBL, STR} tok; //判别式
    union { //匿名union
        char cval;
        int ival;
        double dval;
        std::string sval;
    }; //每个Token对象含有一个该未命名union类型的未命名成员
    //检查判别式,然后酌情拷贝union成员
    void copyUnion(const Token&);
};

1.6.管理判别式并销毁string

类的赋值运算符将负责设置tok并为union的相应成员赋值。和析构函数一样,这些运算符在为union赋新值前必须首先销毁string:

1
2
3
4
5
6
7
Token &Token::operator=(int i)
{
    if (tok == STR) sval.~string(); //如果当前存储的是string,释放它
    ival = i; //为成员赋值
    tok = INT; //更新判别式
    return *this;
}

string版本:

1
2
3
4
5
6
7
8
9
Token &Token::operator=(const std::string &s)
{
    if (tok == STR) //如果当前存储的是string,可以直接赋值
        sval = s;
    else
        new(&sval) string(s); //否则需要先构造一个string
    tok = STR; //更新判别式
    return *this;
}

在此例中,如果union当前存储的是string,则我们可以使用普通的string赋值运算符直接为其赋值。如果union当前存储的不是string,则我们找不到一个已存在的string对象供我们调用赋值运算符。此时,我们必须先利用定位new表达式在内存中为sval构造一个string,然后将该string初始化为string形参的副本,最后更新判别式并返回结果。

1.7.管理需要拷贝控制的联合成员

和依赖于类型的赋值运算符一样,拷贝构造函数和赋值运算符也需要先检验判别式以明确拷贝所采用的方式。为了完成这一任务,我们定义一个名为copyUnion的成员。

1
2
3
4
5
6
7
8
9
10
void Token::copyUnion(const Token &t)
{
    switch (t.tok) {
        case Token::INT: ival = t.ival; break;
        case Token::CHAR: cval = t.cval; break;
        case Token::DBL: dval = t.dval; break;
        //要想拷贝一个string可以使用定位new表达式构造它
        case Token::STR: new(&sval) string(t.sval); break;
    }
}

赋值运算符:

1
2
3
4
5
6
7
8
9
10
11
Token &Token::operator=(const Token &t)
{
    //如果此对象的值是string而t的值不是,则我们必须释放原来的string
    if (tok == STR && t.tok != STR) sval.~string();
    if (tok == STR && t.tok == STR)
        sval = t.sval; //无须构造一个新string
    else
        copyUnion(t); //如果t.tok是STR,则需要构造一个string
    tok = t.tok;
    return *this;
}