3.9.2 联合

3.9.2 联合

联合提供了一种方式,能够规避 C 语言的类型系统,允许以多种类型来引用一个对象。联合声明的语法与结构的语法一样,只不过语义相差比较大。它们是用不同的字段来引用相同的内存块。

考虑下面的声明:

struct S3 {
    char c;
    int i[2];
    double v;
};

union U3 {
    char c;
    int i[2];
    double v;
};

在一台 x86-64 Linux 机器上编译时,字段的偏移量、数据类型 S3 和 U3 的完整大小如下:

类型 c i v 大小
S3 0 4 16 24
U3 0 0 0 8

(稍后会解释 S3 中 i 的偏移量为什么是 4 而不是 1,以及为什么 v 的偏移量是 16 而不是 9 或 12。)对于类型 union U3 * 的指针 p,p->cp->i[0]p->v 引用的都是数据结构的起始位置。还可以观察到,一个联合的总的大小等于它最大字段的大小。

在一些上下文中,联合十分有用。但是,它也能引起一些讨厌的错误,因为它们绕过了 C 语言类型系统提供的安全措施。一种应用情况是,我们事先知道对一个数据结构中的两个不同字段的使用是互斥的,那么将这两个字段声明为联合的一部分,而不是结构的一部分,会减小分配空间的总量。

例如,假设我们想实现一个二叉树的数据结构,每个叶子节点都有两个 double 类型的数据值,而每个内部节点都有指向两个孩子节点的指针,但是没有数据。如果声明如下:

struct node_s {
    struct node_s *left;
    struct node_s *right;
    double data[2];
};

那么每个节点需要 32 个字节,每种类型的节点都要浪费一半的字节。相反,如果我们如下声明一个节点:

union node_u {
    struct {
        union node_u *left;
        union node_u *right;
    } internal;
    double data[2];
};

那么,每个节点就只需要 16 个字节。如果 n 是一个指针,指向 union node_u * 类型的节点,我们用 n->data[0]n->data[1] 来引用叶子节点的数据,而用 n->internal.leftn->internal.right 来引用内部节点的孩子。

不过,如果这样编码,就没有办法来确定一个给定的节点到底是叶子节点,还是内部节点。通常的方法是引入一个枚举类型,定义这个联合中可能的不同选择,然后再创建一个结构,包含一个标签字段和这个联合:

typedef enum { N_LEAF, N_INTERNAL } nodetype_t;

struct node_t {
    nodetype_t type;
    union {
        struct {
            struct node_t *left;
            struct node_t *right;
        } internal;
        double data[2];
    } info;
};

这个结构总共需要 24 个字节:type 是 4 个字节,info.internal.leftinfo.internal.right 各要 8 个字节,或者是 info.data 要 16 个字节。我们后面很快会谈到,在字段 type 和联合的元素之间需要 4 个字节的填充,所以整个结构大小为 4 + 4 + 16 = 24。在这种情况中,相对于给代码造成的麻烦,使用联合带来的节省是很小的。对于有较多字段的数据结构,这样的节省会更加吸引人。

联合还可以用来访问不同数据类型的位模式。例如,假设我们使用简单的强制类型转换将一个 double 类型的值 d 转换为 unsigned long 类型的值 u:

unsigned long u = (unsigned long) d;

值 u 会是 d 的整数表示。除了 d 的值为 0.0 的情况以外,u 的位表示会与 d 的很不一样。再看下面这段代码,从一个 double 产生一个 unsigned long 类型的值:

unsigned long double2bits(double d) {
    union {
        double d;
        unsigned long u;
    } temp;
    temp.d = d;
    return temp.u;
}

在这段代码中,我们以一种数据类型来存储联合中的参数,又以另一种数据类型来访问它。结果会是 u 具有和 d 一样的位表示,包括符号位字段、指数和尾数,如 3.11 节中描述的那样。u 的数值与 d 的数值没有任何关系,除了 d 等于 0.0 的情况。

当用联合来将各种不同大小的数据类型结合到一起时,字节顺序问题就变得很重要了。例如,假设我们写了一个过程,它以两个 4 字节的 unsigned 的位模式,创建一个 8 字节的 double

double uu2double(unsigned word0, unsigned word1)
{
    union {
        double d;
        unsigned u[2];
    } temp;

    temp.u[0] = word0;
    temp.u[1] = word1;
    return temp.d;
}

在 x86-64 这样的小端法机器上,参数 word0 是 d 的低位 4 个字节,而 word1 是高位 4 个字节。在大端法机器上,这两个参数的角色刚好相反。

练习题 3.43 假设给你一个任务,检查一下 C 编译器为结构和联合的访问产生正确的代码。你写了下面的结构声明:

typedef union {
    struct {
        long u;
        short v;
        char w;
    } t1;
    struct {
        int a[2];
        char *p;
    } t2;
} u_type;

你写了一组具有下面这种形式的函数:

void get(u_type *up, type *dest) {
    *dest = expr;
}

这组函数有不一样的访问表达式 expr,而且根据 expr 的类型来设置目的数据类型 type。然后再检查编译这些函数时产生的代码,看看它们是否与你预期的一样。

假设在这些函数中,updest 分别被加载到寄存器 %rdi%rsi 中。填写下表中的数据类型 type,并用 1~3 条指令序列来计算表达式,并将结果存储到 dest 中。

expr type 代码
up->t1.u long movq (%rdi), %raxmovq %rax, (%rsi)
up->t1.v
&up->t1.w
up->t2.a
up->t2.a[up->t1.u]
*up->t2.p