struct 之二:struct 的更多乐趣事实证明,用 struct 能做的事比我们讲过的多得多,只不过它们是一大堆杂七杂八的东西。所以我们把它们一股脑塞进这一章。
如果你已经掌握了 struct 的基础,可以在这里把知识补齐。
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 ");
}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 更舒服。
不过,这只是我个人的看法,老兄。
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想当年那美好的旧时光,人们还用木头雕刻 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;
}当心,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 个字节的填充,以便获得更好的性能。也许你的编译器给出的输出不同,但除非你强制要求,否则你无法确知没有填充。
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 上运行得更快。
用 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 对象的存储值只能通过具有下列类型之一的左值表达式来访问:
- 与该对象的有效类型兼容的类型
- […]
以及我的一个假设:“适当转换”指的是“强制转换为初始成员的有效类型”。
依我的经验,位域很少被用到,但你偶尔会在外面见到它们,尤其是在那些把位打包进更大空间的底层应用中。
我们来看一段代码,演示一个用例:
#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 到包含它的类型的大小)。
做法是在字段名后面加一个冒号,再写上以位为单位的字段宽度。
现在我问 C 我的 struct foo 有多大,它告诉我是 4!本来是 16 个字节,现在只有 4 个。它把那 4 个值“打包”进了 4 个字节,内存占用降到了原来的四分之一。
当然,代价是 5 位的字段只能存放 0 到 31 的值,3 位的字段只能存放 0 到 7 的值。不过生活本来就是妥协嘛。
一个陷阱: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 个字节。
把所有位域放在一起,编译器才会合并它们。
int如果你只把一个位域声明为 int,不同的编译器会把它当作 signed 或 unsigned 来处理。就像 char 的情况一样。
使用位域时,要明确写出符号性。
在某些特定场合,你可能出于硬件原因需要保留一些位,但在代码里又用不到它们。
例如,假设你有一个字节,其中高 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;
};这里再来点更冷门的东西……假设你正在把位打包进一个 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 字节)。
它们基本上和 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!
代价是,从可移植性的角度说,你一次只能使用其中一个字段。不过……
你可以以不可移植的方式往一个 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 的对象表示相同。在我的系统上如此。你的结果可能不同。
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 所在的位置。
如果你有一个由 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;
};这条规则告诉我们:共同初始序列中的成员在代码中可以互换,这一点是有保证的。也就是说:
f.sa.x 与 f.sb.x 相同。以及
f.sa.y 与 f.sb.y 相同。因为字段 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:
另外,请容忍我为这个演示加上的这两个 #define:
到目前为止,这里还没发生什么特别的事情。看起来 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 节省内存的效果,就可以按这种方式来做。
你知道可以有匿名的 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;没问题!
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);
}有趣的小知识:如果这样做,你可以直接在函数调用之后使用 . 运算符:
(当然,那个示例把函数调用了两次,效率不高。)
返回指向 struct 和 union 的指针时也是如此——只是那种情况下要确保使用 -> 箭头运算符。