| 目录 |

20 struct 之二:struct 的更多乐趣

事实证明,用 struct 能做的事比我们讲过的多得多,只不过它们是一大堆杂七杂八的东西。所以我们把它们一股脑塞进这一章。

如果你已经掌握了 struct 的基础,可以在这里把知识补齐。

20.1 嵌套 struct 和数组的初始化器

还记得你可以这样初始化结构体成员吗?

struct foo x = {.a=12, .b=3.14};

事实证明,这些初始化器的本事比我们最初透露的更大。真让人兴奋!

首先,如果你有下面这样的嵌套子结构体,就可以沿着变量名一路向下,去初始化那个子结构体的成员:

struct foo x = {.a.b.c=12};

来看一个例子:

#include <stdio.h>

struct cabin_information {
    int window_count;
    int o2level;
};

struct spaceship {
    char *manufacturer;
    struct cabin_information ci;
};

int main(void)
{
    struct spaceship s = {
        .manufacturer="General Products",
        .ci.window_count = 8,   // <-- 嵌套初始化器!
        .ci.o2level = 21
    };

    printf("%s: %d seats, %d%% oxygen\n",
        s.manufacturer, s.ci.window_count, s.ci.o2level);
}

看第 16 至 17 行!我们在那里初始化 struct cabin_information 的成员,它位于 s(也就是我们的 struct spaceship)的定义中。

同一个初始化器还有另一种写法——这次我们写得看起来更常规一些,但两种方式都可行:

    struct spaceship s = {
        .manufacturer="General Products",
        .ci={
            .window_count = 8,
            .o2level = 21
        }
    };

好了,上面这些信息似乎还不够精彩,我们还能把数组初始化器也掺进去。

我们改一改,在里面放一个乘客信息数组,顺便看看那里的初始化器是怎么工作的。

#include <stdio.h>

struct passenger {
    char *name;
    int covid_vaccinated; // 布尔值
};

#define MAX_PASSENGERS 8

struct spaceship {
    char *manufacturer;
    struct passenger passenger[MAX_PASSENGERS];
};

int main(void)
{
    struct spaceship s = {
        .manufacturer="General Products",
        .passenger = {
            // 一次初始化一个字段
            [0].name = "Gridley, Lewis",
            [0].covid_vaccinated = 0,

            // 或者一次全部初始化
            [7] = {.name="Brown, Teela", .covid_vaccinated=1},
        }
    };

    printf("Passengers for %s ship:\n", s.manufacturer);

    for (int i = 0; i < MAX_PASSENGERS; i++)
        if (s.passenger[i].name != NULL)
            printf("    %s (%svaccinated)\n",
                s.passenger[i].name,
                s.passenger[i].covid_vaccinated? "": "not ");
}

20.2 匿名 struct

这就是“没有名字的 struct”。我们在typedef一节里也提到过它们,不过这里再复习一遍。

这是一个普通的 struct:

struct animal {
    char *name;
    int leg_count, speed;
};

而这是与之等价的匿名版本:

struct {              // <-- 没有名字!
    char *name;
    int leg_count, speed;
};

好——吧。我们有了一个 struct,但它没有名字,所以以后就没法用它了?看起来毫无意义。

诚然,在那个例子里确实如此。但我们还是有几种办法用上它。

一种办法比较少见:既然匿名 struct 表示一种类型,我们就可以直接在它后面跟几个变量名,然后使用这些变量。

struct {                   // <-- 没有名字!
    char *name;
    int leg_count, speed;
} a, b, c;                 // 这种结构体类型的 3 个变量

a.name = "antelope";
c.leg_count = 4;           // 例如

但这样仍然没多大用。

更常见得多的是把匿名 struct 和 typedef 一起用,这样以后就能使用这个类型(比如把变量传给函数)。

typedef struct {                   // <-- 没有名字!
    char *name;
    int leg_count, speed;
} animal;                          // 新类型:animal

animal a, b, c;

a.name = "antelope";
c.leg_count = 4;           // 例如

就我个人而言,我不怎么用匿名 struct。我觉得在声明里看到变量名前面完整的 struct animal 更舒服。

不过,这只是我个人的看法,老兄。

20.3 自引用的 struct

对任何类似图的数据结构来说,能持有指向相连节点/顶点的指针是很有用的。但这意味着在一个节点的定义里,你得有一个指向节点的指针。这简直是先有鸡还是先有蛋!

但事实证明,在 C 里这样做毫无问题。

例如,下面是一个链表节点:

struct node {
    int data;
    struct node *next;
};

要注意 next 是一个指针。正是这一点让整个定义能够成立。尽管编译器还不知道整个 struct node 长什么样,但所有指针的大小都是一样的。

这是一个用来测试它的老套链表程序:

#include <stdio.h>
#include <stdlib.h>

struct node {
    int data;
    struct node *next;
};

int main(void)
{
    struct node *head;

    // 取巧地建一个链表 (11)->(22)->(33)
    head = malloc(sizeof(struct node));
    head->data = 11;
    head->next = malloc(sizeof(struct node));
    head->next->data = 22;
    head->next->next = malloc(sizeof(struct node));
    head->next->next->data = 33;
    head->next->next->next = NULL;

    // 遍历它
    for (struct node *cur = head; cur != NULL; cur = cur->next) {
        printf("%d\n", cur->data);
    }
}

运行它会打印:

11
22
33

20.4 柔性数组成员

想当年那美好的旧时光,人们还用木头雕刻 C 代码,有些人觉得,要是能分配末尾带变长数组的 struct,那就太妙了。

我要说明白:本节前半部分是老一套的做法,之后我们会用新的做法。

例如,你可能会定义一个 struct 来存放字符串及其长度。它会有长度字段和一个存放数据的数组。大概像这样:

struct len_string {
    int length;
    char data[8];
};

但这样一来字符串的最大长度就被写死成 8 了,这实在不够用。要是我们做点聪明的事,在结构体后面用 malloc() 多分配一些空间,然后让数据“溢出”到那块空间里呢?

我们就这么干,然后再在它之上多分配 40 个字节:

struct len_string *s = malloc(sizeof *s + 40);

因为 data 是 struct 的最后一个字段,所以一旦我们让该字段溢出,数据就会跑进我们已经分配好的空间里!正因如此,这个技巧只有在短数组是 struct 中最后一个字段时才管用。

// 复制超过 8 字节!

strcpy(s->data, "Hello, world!");  // 不会崩溃。大概吧。

事实上,曾有一种常见的编译器变通做法:在末尾分配一个长度为零的数组:

struct len_string {
    int length;
    char data[0];
};

这样你多分配的每一个字节都可以直接用在那个字符串上。

因为 data 是 struct 的最后一个字段,所以一旦我们让该字段溢出,数据就会跑进我们已经分配好的空间里!

// 复制超过 8 字节!

strcpy(s->data, "Hello, world!");  // 不会崩溃。大概吧。

不过,真正去访问该数组末尾之后的数据当然是未定义行为!如今这个年代,我们已经不屑于诉诸这种野蛮手段了。

幸运的是,从 C99 及以后的标准开始,我们依然能获得同样的效果,只是现在它是合法的了。

我们只要把上面的定义改成不给数组指定大小134:

struct len_string {
    int length;
    char data[];
};

同样,这只有在柔性数组成员是 struct 中最后一个字段时才管用。

然后,只要我们 malloc() 的大小超过 struct len_string,就能为这些字符串分配任意多的空间,下面这个例子就是这么做的——它从一个 C 字符串创建一个新的 struct len_string:

struct len_string *len_string_from_c_string(char *s)
{
    int len = strlen(s);

    // 比通常所需多分配 "len" 个字节
    struct len_string *ls = malloc(sizeof *ls + len);

    ls->length = len;

    // 把字符串复制到那些多出来的字节里
    memcpy(ls->data, s, len);

    return ls;
}

20.5 填充字节

当心,C 允许在 struct 内部或之后随意添加填充字节。你不能指望它们在内存中彼此紧挨着135。

来看看这个程序。它会输出两个数字。一个是各个字段类型 sizeof 之和。另一个是整个 struct 的 sizeof。

人们会以为这两个数相同。整体的尺寸就是各部分尺寸之和,对吧?

#include <stdio.h>

struct foo {
    int a;
    char b;
    int c;
    char d;
};

int main(void)
{
    printf("%zu\n", sizeof(int) + sizeof(char) + sizeof(int) + sizeof(char));
    printf("%zu\n", sizeof(struct foo));
}

但在我的系统上,它输出:

10
16

它们不一样!编译器加了 6 个字节的填充,以便获得更好的性能。也许你的编译器给出的输出不同,但除非你强制要求,否则你无法确知没有填充。

20.6 offsetof

上一节我们看到,编译器可以随意在结构体内部插入填充字节。

如果我们需要知道它们在哪里呢?可以用 offsetof 来测量,它定义在 <stddef.h> 中。

我们修改上面的代码,打印 struct 中各个字段的偏移量:

#include <stdio.h>
#include <stddef.h>

struct foo {
    int a;
    char b;
    int c;
    char d;
};

int main(void)
{
    printf("%zu\n", offsetof(struct foo, a));
    printf("%zu\n", offsetof(struct foo, b));
    printf("%zu\n", offsetof(struct foo, c));
    printf("%zu\n", offsetof(struct foo, d));
}

在我这里,它输出:

0
4
8
12

这说明每个字段都占用了 4 个字节。这有点奇怪,因为 char 只有 1 个字节,对吧?编译器在每个 char 后面放了 3 个填充字节,好让所有字段都是 4 个字节长。据推测,这在我的 CPU 上运行得更快。

20.7 假的 OOP

用 struct 可以玩一种有点滥用的把戏,勉强算是类似 OOP 的做法。

由于指向 struct 的指针和指向该 struct 第一个元素的指针相同,你可以自由地把指向 struct 的指针强制转换为指向第一个元素的指针。

这意味着我们可以构造出这样一种情形:

struct parent {
    int a, b;
};

struct child {
    struct parent super;  // 必须是第一个
    int c, d;
};

然后我们就能把一个指向 struct child 的指针,传给一个期望它或者指向 struct parent 的指针的函数!

因为 struct parent super 是 struct child 的第一项,所以指向任何 struct child 的指针都等同于指向那个 super 字段的指针136。

我们在这里做个例子。就像上面那样定义 struct,然后把一个指向 struct child 的指针传给一个需要指向 struct parent 的指针的函数……结果它照样能工作。

#include <stdio.h>

struct parent {
    int a, b;
};

struct child {
    struct parent super;  // 必须是第一个
    int c, d;
};

// 把参数设为 `void*`,这样就能把任何类型传进去
// (也就是 struct parent 或 struct child)
void print_parent(void *p)
{
    // 期望一个 struct parent——但 struct child 也能用
    // 因为这个指针指向位于第一个位置的 struct parent
    // 字段:
    struct parent *self = p;

    printf("Parent: %d, %d\n", self->a, self->b);
}

void print_child(struct child *self)
{
    printf("Child: %d, %d\n", self->c, self->d);
}

int main(void)
{
    struct child c = {.super.a=1, .super.b=2, .c=3, .d=4};

    print_child(&c);
    print_parent(&c);  // 尽管它是 struct child,也一样能用!
}

看到我们在 main() 最后一行干的了吗?我们调用了 print_parent(),传进去的实参却是 struct child*!尽管 print_parent() 要求实参指向 struct parent,我们却蒙混过关了,因为 struct child 的第一个字段就是一个 struct parent。

再说一遍,这能成立是因为指向 struct 的指针与指向该 struct 第一个字段的指针具有相同的值。

这一切都取决于规范中的这段话:

§6.7.2.1¶15 […] 指向结构体对象的指针,经适当转换后,指向该结构体的初始成员 […],反之亦然。

以及

§6.5¶7 对象的存储值只能通过具有下列类型之一的左值表达式来访问:

以及我的一个假设:“适当转换”指的是“强制转换为初始成员的有效类型”。

20.8 位域

依我的经验,位域很少被用到,但你偶尔会在外面见到它们,尤其是在那些把位打包进更大空间的底层应用中。

我们来看一段代码,演示一个用例:

#include <stdio.h>

struct foo {
    unsigned int a;
    unsigned int b;
    unsigned int c;
    unsigned int d;
};

int main(void)
{
    printf("%zu\n", sizeof(struct foo));
}

在我这里,它打印 16。这很合理,因为在我的系统上 unsigned 是 4 个字节。

但如果我们知道要存进 a 和 b 的所有值用 5 个位就能存下,而 c 和 d 里的值用 3 个位就能存下呢?加起来总共才 16 位。既然只用 16 位,为什么要给它们预留 128 位呢?

好吧,我们可以客客气气地请 C 尽量把这些值打包进去。我们可以指定值最多能占多少个位(从 1 到包含它的类型的大小)。

做法是在字段名后面加一个冒号,再写上以位为单位的字段宽度。

struct foo {
    unsigned int a:5;
    unsigned int b:5;
    unsigned int c:3;
    unsigned int d:3;
};

现在我问 C 我的 struct foo 有多大,它告诉我是 4!本来是 16 个字节,现在只有 4 个。它把那 4 个值“打包”进了 4 个字节,内存占用降到了原来的四分之一。

当然,代价是 5 位的字段只能存放 0 到 31 的值,3 位的字段只能存放 0 到 7 的值。不过生活本来就是妥协嘛。

20.8.1 不相邻的位域

一个陷阱:C 只会合并相邻的位域。如果它们被非位域字段隔开,就一点也省不下来:

struct foo {            // sizeof(struct foo) == 16(在我这儿)
    unsigned int a:1;   // 因为 a 与 c 不相邻。
    unsigned int b;
    unsigned int c:1;
    unsigned int d;
};

在那个例子里,由于 a 和 c 不相邻,它们各自“打包”进自己的 int 里。

于是 a、b、c、d 各自占一个 int。由于我的 int 是 4 个字节,总共就是 16 个字节。

稍微调换一下顺序,就能省下一些空间,从 16 个字节降到 12 个字节(在我的系统上):

struct foo {            // sizeof(struct foo) == 12(在我这儿)
    unsigned int a:1;
    unsigned int c:1;
    unsigned int b;
    unsigned int d;
};

现在,由于 a 紧挨着 c,编译器把它们合进一个 int 里。

这样,合并后的 a 和 c 占一个 int,b 和 d 各占一个 int。总共 3 个 int,也就是 12 个字节。

把所有位域放在一起,编译器才会合并它们。

20.8.2 有符号还是无符号的 int

如果你只把一个位域声明为 int,不同的编译器会把它当作 signed 或 unsigned 来处理。就像 char 的情况一样。

使用位域时,要明确写出符号性。

20.8.3 无名的位域

在某些特定场合,你可能出于硬件原因需要保留一些位,但在代码里又用不到它们。

例如,假设你有一个字节,其中高 2 位有意义,低 1 位有意义,而中间的 5 位你用不到137。

我们可以这么做:

struct foo {
    unsigned char a:2;
    unsigned char dummy:5;
    unsigned char b:1;
};

这确实管用——在代码里我们使用 a 和 b,但从不用 dummy。它只是为了占掉 5 位,确保 a 和 b 落在这个字节里(按照这个刻意构造的例子)所“要求的”位置上。

C 提供了一种把它清理干净的办法:无名的位域。这种情况下你大可以把名字(dummy)省掉,C 也完全乐意接受,效果一样:

struct foo {
    unsigned char a:2;
    unsigned char :5;   // <-- 无名位域!
    unsigned char b:1;
};

20.8.4 零宽度无名位域

这里再来点更冷门的东西……假设你正在把位打包进一个 unsigned int,而你需要让某些相邻的位域打包进下一个 unsigned int。

也就是说,如果你这样写:

struct foo {
    unsigned int a:1;
    unsigned int b:2;
    unsigned int c:3;
    unsigned int d:4;
};

编译器会把它们全都打包进一个 unsigned int。但如果你需要让 a 和 b 在一个 int 里,而 c 和 d 在另一个里呢?

有办法解决:在你想让编译器重新开始、把位打包进另一个 int 的地方,放一个宽度为 0 的无名位域:

struct foo {
    unsigned int a:1;
    unsigned int b:2;
    unsigned int :0;   // <--零宽度无名位域
    unsigned int c:3;
    unsigned int d:4;
};

这类似于文字处理软件里的显式分页符。你是在告诉编译器:“别在这个 unsigned 里打包位了,从下一个开始打包。”

在那个位置加上零宽度的无名位域之后,编译器就会把 a 和 b 放进一个 unsigned int,把 c 和 d 放进另一个 unsigned int。总共两个,在我的系统上大小是 8 字节(每个 unsigned int 占 4 字节)。

20.9 联合体

它们基本上和 struct 一样,只不过各字段在内存中相互重叠。union 的大小只够容纳最大的那个字段,而且一次只能使用一个字段。

这是一种把同一块内存空间复用于不同类型数据的办法。

声明它们的方式和 struct 一样,只是要写 union。看看这个:

union foo {
    int a, b, c, d, e, f;
    float g, h;
    char i, j, k, l;
};

这可真是一堆字段。如果这是一个 struct,我的系统会告诉我它总共要占 36 字节。

但它是 union,所以所有这些字段都重叠在同一段内存里。最大的是 int(或者 float),在我的系统上占 4 字节。而且,如果我求一下 union foo 的 sizeof,它确实告诉我 4!

代价是,从可移植性的角度说,你一次只能使用其中一个字段。不过……

20.9.1 联合体与类型双关

你可以以不可移植的方式往一个 union 字段写入,再从另一个字段读出!

这种做法叫作类型双关138,只有在你非常清楚自己在干什么时才会用它,通常出现在某种底层编程中。

由于联合体的各个成员共享同一块内存,写入其中一个成员必然会影响到其他成员。而如果你从某个成员读出原本写入另一个成员的内容,就会得到一些奇怪的效果。

#include <stdio.h>

union foo {
    float b;
    short a;
};

int main(void)
{
    union foo x;

    x.b = 3.14159;

    printf("%f\n", x.b);  // 3.14159,还算合理

    printf("%d\n", x.a);  // 但这个呢?
}

在我的系统上,这会打印出:

3.141590
4048

因为在底层,float 3.14159 的对象表示与 short 4048 的对象表示相同。在我的系统上如此。你的结果可能不同。

20.9.2 指向 union 的指针

如果你有一个指向 union 的指针,就可以把它强制转换为该 union 中任意字段类型的指针,再以这种方式取出值。

在这个示例中,我们看到这个 union 里有 int 和 float。我们取得指向该 union 的指针,但把它们强制转换为 int* 和 float* 类型(强制转换可以消除编译器警告)。然后解引用它们,就会看到里面正是我们直接存进 union 的那些值。

#include <stdio.h>

union foo {
    int a, b, c, d, e, f;
    float g, h;
    char i, j, k, l;
};

int main(void)
{
    union foo x;

    int *foo_int_p = (int *)&x;
    float *foo_float_p = (float *)&x;

    x.a = 12;
    printf("%d\n", x.a);           // 12
    printf("%d\n", *foo_int_p);    // 又是 12

    x.g = 3.141592;
    printf("%f\n", x.g);           // 3.141592
    printf("%f\n", *foo_float_p);  // 又是 3.141592
}

反过来也成立。如果我们有一个指向 union 内部某个类型的指针,就可以把它强制转换为指向该 union 的指针,并访问它的成员。

union foo x;
int *foo_int_p = (int *)&x;             // 指向 int 字段的指针
union foo *p = (union foo *)foo_int_p;  // 转回指向 union 的指针

p->a = 12;  // 这一行等同于……
x.a = 12;   // 这一行。

这一切只是让你知道:在底层,union 里的所有值都从内存中的同一个位置开始,而这个位置也正是整个 union 所在的位置。

20.9.3 联合体中的共同初始序列

如果你有一个由 struct 组成的 union,并且所有这些 struct 都以同一个共同初始序列开头,那么通过该 union 的任意成员来访问这个序列中的成员都是合法的。

什么?

下面这两个 struct 具有一个共同初始序列:

struct a {
    int x;     //
    float y;   // 共同初始序列

    char *p;
};

struct b {
    int x;     //
    float y;   // 共同初始序列

    double *p;
    short z;
};

看出来了吗?它们都以 int 开头、后面跟着 float——这就是共同初始序列。这些 struct 序列中的成员必须是兼容的类型。而 x 和 y 正是如此,它们分别是 int 和 float。

现在把它们组成一个联合体:

union foo {
    struct a sa;
    struct b sb;
};

这条规则告诉我们:共同初始序列中的成员在代码中可以互换,这一点是有保证的。也就是说:

以及

因为字段 x 和 y 都位于共同初始序列中。

另外,共同初始序列中成员的名字并不重要——重要的是类型相同。

综合起来,这让我们有办法在 union 中的各个 struct 之间安全地添加一些共享信息。最好的例子大概就是用某个字段来确定,union 的所有 struct 中当前“正在使用”的是哪种 struct。

也就是说,如果不允许这么做,而我们把 union 传给某个函数,那个函数又怎么知道该看 union 的哪个成员呢?

看看这些 struct。注意那个共同初始序列:

#include <stdio.h>

struct common {
    int type;   // 共同初始序列
};

struct antelope {
    int type;   // 共同初始序列

    int loudness;
};

struct octopus {
    int type;   // 共同初始序列

    int sea_creature;
    float intelligence;
};

现在把它们丢进一个 union:

union animal {
    struct common common;
    struct antelope antelope;
    struct octopus octopus;
};

另外,请容忍我为这个演示加上的这两个 #define:

#define ANTELOPE 1
#define OCTOPUS  2

到目前为止,这里还没发生什么特别的事情。看起来 type 字段完全没用。

但现在我们来写一个打印 union animal 的通用函数。它得能分辨出自己看的是 struct antelope 还是 struct octopus。

借助共同初始序列的魔力,对于某个特定的 union animal x,它可以从下面这些地方中的任意一处查到动物类型:

int type = x.common.type;    // 或者……
int type = x.antelope.type;  // 或者……
int type = x.octopus.type;

所有这些都指向内存中的同一个值。

而且,正如你可能猜到的,struct common 的存在是为了让代码可以不分具体动物地查看类型。

来看看打印 union animal 的代码:

void print_animal(union animal *x)
{
    switch (x->common.type) {
        case ANTELOPE:
            printf("Antelope: loudness=%d\n", x->antelope.loudness);
            break;

        case OCTOPUS:
            printf("Octopus : sea_creature=%d\n", x->octopus.sea_creature);
            printf("          intelligence=%f\n", x->octopus.intelligence);
            break;
        
        default:
            printf("Unknown animal type\n");
    }

}

int main(void)
{
    union animal a = {.antelope.type=ANTELOPE, .antelope.loudness=12};
    union animal b = {.octopus.type=OCTOPUS, .octopus.sea_creature=1,
                                       .octopus.intelligence=12.8};

    print_animal(&a);
    print_animal(&b);
}

看到第 29 行了吗——我们只是把 union 传进去,完全不知道它内部正在使用的是哪种动物的 struct。

但没关系!因为第 31 行我们检查了类型,看它是羚羊还是章鱼。然后就能查看对应的 struct 来取得成员。

只用 struct 当然也能做到同样的效果,但如果你想要 union 节省内存的效果,就可以按这种方式来做。

20.10 联合体与匿名结构体

你知道可以有匿名的 struct,就像这样:

struct {
    int x, y;
} s;

这定义了一个类型为匿名 struct 的变量 s(因为这个 struct 没有名字标签),其成员为 x 和 y。

所以像这样的写法是合法的:

s.x = 34;
s.y = 90;

printf("%d %d\n", s.x, s.y);

事实证明,你可以像预期的那样把这些匿名 struct 放进 union 里:

union foo {
    struct {       // 没有名字!
        int x, y;
    } a;

    struct {       // 没有名字!
        int z, w;
    } b;
};

然后照常访问它们:

union foo f;

f.a.x = 1;
f.a.y = 2;
f.b.z = 3;
f.b.w = 4;

没问题!

20.11 传递和返回 struct 与 union

你可以按值把 struct 或 union 传给函数(而不是传指向它的指针)——和通常一样,会像赋值那样把该对象复制一份给形参。

你也可以从函数返回 struct 或 union,它同样是按值传回来的。

#include <stdio.h>

struct foo {
    int x, y;
};

struct foo f(void)
{
    return (struct foo){.x=34, .y=90};
}

int main(void)
{
    struct foo a = f();  // 进行复制

    printf("%d %d\n", a.x, a.y);
}

有趣的小知识:如果这样做,你可以直接在函数调用之后使用 . 运算符:

    printf("%d %d\n", f().x, f().y);

(当然,那个示例把函数调用了两次,效率不高。)

返回指向 struct 和 union 的指针时也是如此——只是那种情况下要确保使用 -> 箭头运算符。


| 目录 |