C Programming Language

Expertise #06: More about the Structure Type

# Basic Tutorial,  # Programming Language,  # C Programming Language

Last Updated: 2020-07-01


前置知识

本文后半部分的内容相对较为深入,在阅读本文档前,建议你先阅读我的其它所有非进阶文档,详见 C 语言栏目。

请注意:本文是面向进阶读者的,在阅读本文档前,请确保你已经具备了较为完整的 C 语言基础。


前言

读者可能会感到奇怪,为什么我会专门用一篇文章来介绍结构体这种基本语法,还将其标记为进阶文章。事实上看文章的标题就知道了——我们讨论的是 More about 的部分。因此本文假设读者已经掌握了结构体相关的基础知识。

本文将分别介绍一些与结构体密切相关的、相对冷门的语法知识,包括内部内存布局、内存对齐与填充、位域等,读者可根据自身需要来阅读。


新的基本语法

C99 引入的一些新特性为数组、结构体和联合体类型增添了新的基本语法,本节对此部分内容作出简要总结。

复合字面量

C99 引入的新特性 复合字面量 (compound literal) 是一种用于初始化和赋值复合类型的语法规则,它提供了一个未命名的对象,其值由初始化列表提供。初始化列表的所有语法规则均应用于复合字面量。

下面几组示例代码展示了复合字面量的用法。

在下面的代码中,程序声明并初始化了一个指针 pp ,它指向一个匿名的、长度为 33 的数组的首元素:

int * p = (int []){1, 2, 3};

在下面的代码中,程序声明了一个指针 pp ,并在后续代码中令它指向一个匿名的、长度为 44 的数组的第二个元素,随后使其指向首元素:

int * p;
// ...
p = (int [4]){1, 5} + 1;
p --;

匿名数组并没有什么特别的,他仍然储存于其被定义的位置,例如上面的代码和下面的代码 几乎 等价,只是数组 aa 是匿名的,无法通过数组名直接访问:

int aa[4] = {1, 5};
int * p = &a[0];

另外,即便是复合字面量也不能用于非初始化地赋值数组:

int a[3];
a = (int [3]){1, 2, 3}; // compile error

对于下面的代码,在 C89 中,只有第 66 行能通过编译,而第 77 行将报告错误,因为 C89 只允许在初始化时使用 {} 语法,从 C99 开始才提供了对复合字面量的支持:

struct Block {
    int x;
    char str[10];
};
int main(void) {
    struct Block block = {1, "aaa"};
    block = (struct Block){2, "xyz"};
    return 0;
}

在下面的代码中,程序在函数调用时传入了一个匿名对象和一个指向匿名对象的指针:

struct point {
    int x;
    int y;
};
void f(struct point a, struct point * p) {
    // ...
}
int main(void) {
    f((struct point){1, 1}, &(struct point){3, 2});
    return 0;
}

注意区分匿名结构体实例(匿名对象)和匿名结构体类型,关于匿名结构体类型详见下文。

指定初始值

C99 引入的新特性 指定初始值 (designated initializer) 是一种用于初始化复合类型的语法规则,在与复合字面量的共同作用下亦可用于赋值结构体和联合体类型。

在 C89 中,如果你想初始化一个稀疏数组,可能只能这么做:

int a[10] = {0, 0, 3, 0, 0, 0, 0, 0, 5, 0};

或者这么做:

int a[10] = {0};
a[2] = 3;
a[8] = 5;

而指定初始值的特性允许你以这样的语法对数组进行初始化,并且依然保证会将其中未指定的数组元素初始化为零值:

int a[10] = {[2] = 3, [8] = 5};

类似的特性也存在于结构体的初始化和赋值中,其语法如下所示:

#include <stdio.h>
 
struct Block {
    int a;
    double b;
    char c;
};
int main(void) {
    struct Block block = (struct Block){.a = 1, .c = 'x'};
    printf("%d, %.1f, %c\n", block.a, block.b, block.c);
    block = (struct Block){.b = 0.5, .c = 'z'};
    printf("%d, %.1f, %c\n", block.a, block.b, block.c);
    return 0;
}

上述代码的输出结果如下:

1, 0.0, x
0, 0.5, z

未命名与匿名结构体

事实上,尽管未命名结构体和匿名结构体是两个完全不同的东西,但是它们的名称经常被混用(就连 gcc 编译器也会在编译信息中混用,甚至不是交换名称而是每一个都拥有这两个名称,简直毫无章法),因此读者在查阅相关资料时还请注意细心确认究竟是在谈论哪一种。本文为它们分别指定予的名称也不代表正确的名称,只是我所见到的最常见的用法。

未命名结构体

有时,你会见到类似如下代码的写法,初次见到时你可能会感到非常困惑:

struct {
    int a;
    char b;
} block;

事实上,上述代码不是定义了一个名为 struct block 的结构体类型,而是定义了一个未命名的结构体类型,该类型有唯一的实例 block 。这种语法被称为 未命名结构体 (unnamed structure) ,类似地有未命名联合体。

除定义类型时以外,在程序中的任何位置无法在语法层次上获取未命名结构体类型的标识符(在 C++ 语言中可以),但可以正常获取未命名结构体类型的实例的所有成员的标识符。因此,在定义未命名类型的同时至少要定义一个实例(称为 tagged ),否则该未命名类型将失去意义:

struct {
    int a;
    char b;
}; 

上述代码会报如下编译错误:

|4| error: unnamed struct/union that defines no instances

与一般的结构体类型类似,即便成员相同,未命名结构体类型与其它结构体类型也是互不兼容的:

struct {
    int a;
    char b;
} k1;
struct {
    int a;
    char b;
} k2;
struct named {
    int a;
    char b;
} k3;
k1 = k2; // compile error
k1 = k3; // compile error

尽管我们无法在其它地方创建未命名结构体类型的实例(因为在其它地方无法表示其类型标识符),但是我们可以正常地、随意地读写它们的内容:

struct {
    int a;
    char b;
} block;
block.a = 1;
block.b = 'c';
int aa = block.a;
char bb = block.b;

未命名结构体通常用于在程序中需要限制实例创建的类型,使用未命名结构体通常是为了针对开发团队的代码规范作出限制,没什么别的作用。

匿名结构体

在结构体(或联合体)类型中,如果定义了某个未命名结构体(或联合体)类型且没有定义任何实例,则该类型被称为 匿名结构体 (anonymous structure) 或匿名联合体。匿名结构体是 C11 引入的新特性,但在 C99 的 GNU 扩展中也有支持。

匿名结构体(或联合体)的所有成员将被视为其所属结构体(或联合体)的成员,例如:

struct circle {
    struct {
        int centerX;
        int centerY;
    };
    int radius;
};
struct circle c;
c.centerX = 1;
c.centerY = -1;
c.radius = 2;

匿名结构体(或联合体)允许多层嵌套。

C11 标准中的相关原文如下:

6.7.2.1 Structure and union specifiers 13 An unnamed member whose type specifier is a structure specifier with no tag is called an anonymous structure; an unnamed member whose type specifier is a union specifier with no tag is called an anonymous union. The members of an anonymous structure or union are considered to be members of the containing structure or union. This applies recursively if the containing structure or union is also anonymous.

匿名结构体的实际应用可参考下面的链接,通常只在混用结构体和联合体的情况下有用,例如结构体内含匿名联合体成员,或是联合体内含匿名结构体成员。

https://stackoverflow.com/questions/13376494/what-are-the-benefits-of-unnamed-structs-unions-in-c


内部内存布局

基本规则

根据 C 语言标准,结构体内的成员变量之间的相对位置总是与程序中声明这些成员的顺序相同(位域除外,关于位域的知识详见后文),且结构体变量本身的地址值与其首个成员变量的地址值相同。

例如对于下面的代码,编译器总是会保证 &t == &t.a << &t.b << &t.c ,无论 a, b, c 的类型是什么。

struct block {
    T1 a;
    T2 b;
    T3 c;
};
struct block t;

我们可以直接将成员变量的地址与结构体本身的地址相减,来获取成员在 struct 或 union 类型内部的相对地址,也可以通过宏 offsetof 来获取。

头文件 stddef.h 定义了宏 offsetof(type, member) ,返回成员在结构体内部的相对地址,返回结果为 size_t 类型。其用法示例代码及输出结果如下所示。

#include <stdio.h>
#include <stddef.h>
 
struct block {
    int a;
    short b;
    char c;
};
 
int main(void) {
    int oa = offsetof(struct block, a);
    int ob = offsetof(struct block, b);
    int oc = offsetof(struct block, c);
    printf("%d %d %d\n", oa, ob, oc);
    return 0;
}
0 4 6

需要注意的是,标准并不保证成员在结构体内部的内存位置是相邻的。成员变量之间以及结构体的末尾可能有空余的内存区域,这是由內存对齐机制导致的(详见下文)。标准仅保证结构体的首部一定不存在空余。对应于前文所述的例子,即保证 &t == &t.a ,而不保证 &t.a + sizeof(t.a) == &t.b&t.c + sizeof(t.c) == &t + sizeof(t)

內存对齐与填充

我把这部分内容放在内存模型与布局的文章中具体介绍,详见 C 语言的 05 - Memory Layout and Rules 文档。

本文的后续内容依赖于此部分知识,请确保将其掌握后再继续阅读。


Flexible Array Member

基本概念

我们知道,结构体中不能含有未完成类型的成员:

struct block {
    int a1[];          // compile error
    int a2[5];         // ok
    struct block b;    // compile error
    struct block * pb; // ok
};

但是当结构体中含有至少一个完成类型的成员时,结构体的最后一个成员可以是一个大小未知的数组类型,该成员被称为 flexible array member, FAM 。 FAM 是 C99 标准引入的新特性。

struct block {
    int a;
    int b[]; // ok in C99
};

注:由于我没有找到一个看起来比较像是公认且靠谱的翻译(“柔性数组成员”听起来也太奇怪了),可能“弹性数组成员”这个翻译听起来还可以,不过鉴于我不能百分百地确定,本文中我还是暂且仅以英文缩写 FAM 来表示此概念。

含有 FAM 的结构体类型不能成为其它结构体类型的成员,也不能构成数组。

结构体的大小不会将 FAM 计算在内,但是结构体的内存对齐会被 FAM 的类型所影响,例如:

struct block1 {
    char c;
    char b[];
};
struct block2 {
    char c;
    int b[];
};
printf("%d %d\n", sizeof(struct block1), sizeof(struct block2));

上述代码的输出结果如下:

1 4

含有 FAM 的结构体通常以指针和动态内存分配的形式来使用,例如:

struct block {
    char a;
    int b[];
};
int n;
scanf("%d", &n);
struct block * p = malloc(sizeof(struct block) + n * sizeof(int));
for (int i = 0; i < n; i ++) {
    p->b[i] = i * i;
}

其中分配内存的代码也可以写成下面的形式:

struct block * p = malloc(sizeof(*p) + n * sizeof(p->b[0]));

但是注意不能写成 sizeof(char) + n * sizeof(int) ,因为结构体的内存对齐会被 FAM 的类型所影响。

静态地使用结构体的 FAM 很容易产生越界错误,除非你清楚地知道你在做什么,否则请不要这么做:

struct block {
    int a;
    int b[];
};
struct block t;
t.b[0] = 5; // might be undefined behaviour

注意,直接赋值时不会拷贝 FAM 中的任何内容,只会拷贝那些完全类型的成员。下面的代码将会输出随机乱数,因为是已分配而未初始化的内存:

struct block {
    char a;
    int b[];
};
int n;
scanf("%d", &n);
size_t sz = sizeof(struct block) + n * sizeof(int);
struct block * p1 = malloc(sz);
struct block * p2 = malloc(sz);
for (int i = 0; i < n; i ++) {
    p1->b[i] = i * i;
}
*p2 = *p1;
for (int i = 0; i < n; i ++) {
    printf("%d ", *(p2->b + i));
}

正确的方法是使用 memcpy 拷贝指定大小的内存区域:

memcpy(p2, p1, sz);

最后,一些编译器支持如下的等价语法,但这不符合 C 标准,程序可能会有可移植性的问题:

struct block {
    int a;
    int b[0];
};

实用价值

事实上,可以认为 FAM 就是基于人们对动态结构体的需求而产生的。我们知道结构体中不能含有变长数组成员,例如下面的代码是不能编译通过的:

int n = 5;
struct block {
    int a[n];
};

所以在 C99 以前,如果希望结构体中有运行时决定大小的数据,那我们只能利用指针来实现,例如:

struct block {
    int a;
    int * p;
};
struct block t;
int n;
scanf("%d", &n);
t.p = malloc(n * sizeof(int));

而 FAM 的出现则完美地解决了这一问题。当然,用指针确实也能解决问题,但是与 FAM 相比可以说是只有缺点没有优点。

  • FAM 降低了内存消耗,指针成员会额外占用空间(通常是 4488 字节)。

  • FAM 提高了执行效率,因为 FAM 的数据就放在结构体的末尾,可直接访问,而通过指针访问动态区域则需要一次额外的解引用。

  • FAM 不需要深拷贝和深释放,通过直接分配和释放结构体即可分配和释放 FAM ,且可通过 memcpy 直接复制 FAM ,而使用指针则需要额外考虑这些问题。

  • FAM 降低了代码复杂度,用指针时的深层 malloc 可能需要更多的分配失败检查,以及在并发情形下需要更谨慎地编写代码。

当然,一些小的代码技巧可以使二者之间的差距降到最小,但是仍然有所区别,且仍然不如 FAM 更好(仍然额外占用空间且访问需要额外解引用):

struct block {
    int a;
    int * b;
};
int n;
scanf("%d", &n);
struct block * p = malloc(sizeof(struct block) + n * sizeof(int));
p->b = (int *)(p + 1);
for (int i = 0; i < n; i ++) {
    p->b[i] = i * i;
}

不过,在需要多个动态数组成员时(这种情况极其罕见),这种技巧可以解决问题,而 FAM 则不行。


位域

本节内容需要二进制数和位运算的基础知识,请读者注意。相关知识的博客我目前还没有写,目前还请读者通过其它途径自行学会。

基本概念

位域 (bit-field) 是一种特殊的结构体成员类型,是可自定义长度(单位为比特位数)的整数类型,通常用于节约空间消耗和优化代码可读性。

位域的使用要求程序员对二进制数的表示非常熟悉,且因为位域的许多规则与体系结构息息相关,通常用于更靠近底层的代码中。如果读者对底层的 C 不感兴趣,那么位域基本上没什么用,读者可以直接跳过位域这一整节。

声明一个位域成员的语法格式如下所示:

struct ... {
    ...
    type [name] : width;
    ...
} ... ;

类型 type 必须是整数类型,而具体支持哪些整数类型则是实现定义的,标准仅保证支持 _Boolsigned intunsigned int 三种类型。 注:根据 gcc 官方文档, gcc 编译器允许将位域定义为任意的整数类型(即便是在要求严格符合 C 标准的编译选项下)。

类型为 int 的位域会被解读为 signed int 还是 unsigned int 也是实现定义的,因此在实际工程中最好避免使用 int ,而是使用 signed intunsigned int ,除非你没有过多的移植需求。

类型可以用 const 修饰。

长度 width 必须是非负整数的字面常量,且不可超过类型 type 的长度。

特殊情况下名称 name 是可省略的,且宽度 width 可以为 00

如果声明中没有名称,则称其为 匿名位域 (unnamed bit-field) 。当且仅当位域成员为匿名位域时,其长度 width 可以为 00 。匿名位域仅用于调整结构体内部的内存布局,其具体作用详见下文。

下面的代码中包含一个简单的实例,结构体 Date 中包含一个长为 55 比特的位域 day 和一个长为 44 比特的位域 month

struct Date {
    int day : 5;
    int month : 4;
    int year;
};
struct Date date = {31, 12, 2020};
printf("%d.%02d.%02d\n", date.year, date.month, date.day);

因为一年最多有 1212 月,一月最多有 3131 天,所以 44 位和 55 位二进制数事实上足以存储日期中的月份和天数,因为 xx 位二进制数最大可以表示 2x12^x - 1

然而不幸的是,上述代码会发生溢出,其输出结果如下:

2020.-4.-1

原因是在当前环境中 int 被解析为有符号整型,最高位将被用作符号位。粗暴的解决方案是将其长度加到 66 位和 55 位,但是由于月份和天数不可能为负,我们只需将其声明为 unsigned 类型即可:

struct Date {
    unsigned int day : 5;
    unsigned int month : 4;
    int year;
};
struct Date date = {31, 12, 2020};
printf("%d.%02d.%02d\n", date.year, date.month, date.day);

由于几比特长度的无符号整型的表示范围尚在有符号 int 类型的表示范围之内,我们也无需在 printf 时使用 %u 来识别,仍然用 %d 并让其隐式转换为 int 类型即可(虽然用了更严谨,但是在这里确实没有意义)。此时的输出结果如下:

2020.12.31

注意,不能获取位域成员的地址,不能使用指针指向位域成员,也不能用宏 offsetof 来获取位域成员的相对地址。

unsigned int * p = &date.day;            // compile error
int off = offsetof(struct Date, day);    // undefined behaviour

另外,将超出范围的值分配给位字段成员,其结果是实现定义的。

内存分配规则

使用位域的一大原因就是节约内存,所以了解位域的内存分配规则至关重要。

位域的内存分配规则中有很大一部分都是实现定义的,因此在试图利用位域之前,通常都需要查询运行环境和编译器相关的文档,或是通过一些宏定义来判断当前环境中的一些实现定义的确切情况。

在前文的例子中,位域成员 daymonth 会被分配到一块等同于 int 类型所占大小的内存中。我们将这样的一块内存称为 可寻址存储单元 (addressable storage unit) 。一般的基本类型的变量都是一个变量占据一块存储单元,但是位域不受此限制,经常会有多个位域共享同一块存储单元的情况发生。

为若干个位域分配的存储单元的大小的实现定义的。对于前文的例子,编译器完全有可能为 daymonth 分配一块 short 大小的内存,因为已经足够装下。

在依次将各个位域成员分配到内存中时,若当前存储单元的剩余内存足以存下这个位域,那么这个位域一定会被分配到这个存储单元中。若剩余内存不足,那么该位域的分配是实现定义的,可能是横跨两个存储单元,也可能是跳过当前存储单元中剩余的内存并分配到下一个新的存储单元中。

在同一个存储单元中的多个位域成员,其在内存中排布的相对顺序是实现定义的。不过标准规定,具体的顺序要么是从低位到高位,要么是从高位到低位,而不可能乱序。

在同一个存储单元中的多个位域成员,其间的内存对齐与填充规则是未指定的。例如对于下面的代码,注释中给出了其中一种可能的情况,但是编译器也有可能不做任何对齐与填充,而直接把 a , b , c 按顺序紧贴在一起排布,亦或是其它此处未提及的排布方式。

struct block {
    int a : 1; /* 1 bit */
    int b : 4; /* 4 bits padded up to boundary of 8 bits. Thus 3 extra bits are padded */
    int c : 7; /* 7 Bits septet, padded up to boundary of 32 bits. */
};

最后,在前文我们提到过:

根据 C 语言标准,结构体内的成员变量之间的相对位置总是与程序中声明这些成员的顺序相同(位域除外,关于位域的知识详见后文),且结构体变量本身的地址值与其首个成员变量的地址值相同。

事实上,若干个位域会以其所处的存储单元为单位被视为一个整体,此整体将符合前文所述的按声明顺序排序。例如对于下面的代码,编译器总是会保证 &t == &t.a << &t.b << &t.bf1, &t.bf2, &t.bf3 << &t.c ,但是对于 bf1, bf2, bf3 ,其间的相对顺序、是否有由内存对齐产生的匿名内存填充、被分配到了几个多大的存储单元中,都是随环境而变的。注意这里 &t.bfi 的写法只是一个意会,对位域成员取地址是不合法的语法。

struct block {
    int a;
    float b;
    int bf1 : 3;
    int bf2 : 4;
    int bf3 : 6;
    short c;
};
struct block t;

类似地,如果结构体的首个成员是位域成员,那么只能保证结构体本身的地址值等于该位域所处的存储单元的地址值,而不能保证通过该地址等于此位域成员的地址。

C99 标准中的相关原文如下:

6.7.2.1 Structure and union specifiers 10 An implementation may allocate any addressable storage unit large enough to hold a bitfield. If enough space remains, a bit-field that immediately follows another bit-field in a structure shall be packed into adjacent bits of the same unit. If insufficient space remains, whether a bit-field that does not fit is put into the next unit or overlaps adjacent units is implementation-defined. The order of allocation of bit-fields within a unit (high-order to low-order or low-order to high-order) is implementation-defined. The alignment of the addressable storage unit is unspecified. ... 13 Within a structure object, the non-bit-field members and the units in which bit-fields reside have addresses that increase in the order in which they are declared. ...

根据 gcc 官方文档,在 gcc 编译器中,上述的实现定义的规则均取决于 ABI ,即取决于计算机底层的体系结构。

gcc 官方文档中的相关原文如下:

4.9 Structures, Unions, Enumerations, and Bit-Fields

  • Allowable bit-field types other than _Bool, signed int, and unsigned int (C99 and C11 6.7.2.1). Other integer types, such as long int, and enumerated types are permitted even in strictly conforming mode. ...
  • Whether a bit-field can straddle a storage-unit boundary (C90 6.5.2.1, C99 and C11 6.7.2.1). Determined by ABI.
  • The order of allocation of bit-fields within a unit (C90 6.5.2.1, C99 and C11 6.7.2.1). Determined by ABI.
  • The alignment of non-bit-field members of structures (C90 6.5.2.1, C99 and C11 6.7.2.1). Determined by ABI.

应用场合

位域主要有两个作用,其一是有节约空间消耗,其二是优化代码可读性。第一点已经在前文中写得很明白了,根据体系结构的规则合理排布位域成员,在许多情况下都可以节约相当的空间。而在此基础上,位域还能优化代码的可读性。

例如,在需要节约空间的代码中,我们经常会看到一种处理标志位字段的方法,就是将其每一个比特位拆开来用。因为哪怕是 _Bool 类型也有一个字节长,而用一个整数类型拆分成每个比特当作布尔类型来用,轻轻松松就节约了 88 倍的内存。例如要维护一块内存的读写权限,可能会有类似如下代码的实现:

// definition
#define IS_READABLE   (1 << 0)
#define IS_WRITABLE   (1 << 1)
#define IS_EXECUTABLE (1 << 2)
unsigned char memflags;
// code to set
memflags |= IS_READABLE;
// code to check
if (memflags & IS_WRITABLE) {
    // ...
}

这显然比下面的代码节约了两个字节的内存,而我们通常需要为大量的内存块维护这样的标志位,因此这一点点的改进可能就节约了至少几百万字节的内存。但是从软件系统的角度来看,下面的代码显然更优美、更安全、且更不容易在编程时出错。不过嘛,如果你已经习惯了上面那种常见的写法,可能会觉得二者没什么区别。

// definition
struct {
    _Bool is_readable;
    _Bool is_writable;
    _Bool is_executable;
} memflags;
// code to set
memflags.is_readable = true;
// code to check
if (memflags.is_writable) {
    // ...
}

而现在,我们可以在位域的帮助下优雅地解决这个问题:

// definition
struct {
    unsigned int is_readable   : 1;
    unsigned int is_writable   : 1;
    unsigned int is_executable : 1;
} memflags;
// code to set
memflags.is_readable = true;
// code to check
if (memflags.is_writable) {
    // ...
}

标志位只是其中一个例子,还有其它情况可以优化代码的可读性,这里就不再赘述了。