第14章:结构和其他数据形式
数组把相同类型的数据排列起来,结构则把同一事物的不同属性组合成一个对象。联合用于表达几种表示方式中的一种,枚举给状态命名,函数指针则让程序把处理行为作为参数传递。
本章可以围绕两个问题展开:数据怎样组织,操作怎样围绕数据定义。 设计类型时,还要明确哪些内容直接存放在对象内,哪些只是指向其他对象的地址。
1. 用结构把相关信息放在一起
struct book {
char title[40];
char author[32];
double price;
};
这段代码定义一个结构类型,描述三个成员。它本身没有创建一本实际的书。
struct book first;
struct book second;
这两条定义才创建实际对象。每个对象都有自己的书名数组、作者数组与价格。
| 名称 | 含义 |
|---|---|
struct |
声明结构的关键字 |
book |
结构标记 |
struct book |
完整的类型名称 |
first |
该类型的对象 |
title、author、price |
成员名称 |
右花括号后的分号不能省略。成员也要分别写成完整声明,结构成员不能直接写成带默认初始化值的普通 C 成员定义。
可以在类型定义后面同时定义对象:
struct point {
double x;
double y;
} start, finish;
也可以省略标记,只在当前声明中直接定义对象;如果要在多个位置反复引用同一类型,保留标记或提供合适的 typedef 更方便。
2. 初始化结构与访问成员
2.1 按成员声明顺序初始化
struct book first = {"Small Programs", "Lin", 28.5};
花括号中的值依次初始化 title、author、price。字符数组成员可以在这里直接用字符串字面量初始化。
struct book empty = {0};
部分初始化时,其余成员按规则零初始化。没有初始化的普通自动结构对象,不会自动得到全零成员。
静态存储期结构的初始化仍须符合静态初始化规则,不能随意在初始化列表中调用运行时函数。
2.2 指定初始化器直接说明成员
struct book first = {
.price = 28.5,
.title = "Small Programs",
.author = "Lin"
};
C99 支持 .成员名 = 值,可以改变初始化项的书写顺序,减少记错位置的风险。
若指定初始化后继续写普通初始化项,它从紧随指定成员之后的位置继续。重复指定同一成员时,后一次初始化会取代前一次;实际代码通常应避免这种容易混淆的写法。
2.3 对象用点,指针用箭头
struct book first = {"Small Programs", "Lin", 28.5};
struct book *p = &first;
first.price = 30.0;
p->price = 32.0;
以下表示相同成员:
first.price
(*p).price
p->price
(*p).price 的括号不能省略,因为 . 比一元 * 结合得更紧。
| 表达式 | 含义 |
|---|---|
first.title |
结构内部的字符数组 |
first.title[2] |
书名数组的第 3 个字符 |
&first.price |
价格成员的地址 |
p->title |
指针所指结构中的书名数组 |
结构表达式不会像数组表达式那样通常转换为首元素指针。取得结构对象的地址,需要使用 &first。
修改已经存在的字符数组成员时,仍需使用符合容量约束的字符串操作,不能写 first.title = "Other"。
3. 结构数组与嵌套结构
3.1 一条记录一个结构,多条记录一个数组
struct book library[3] = {
{"Loops", "Lin", 20.0},
{"Pointers", "Chen", 35.0},
{"Files", "Zhao", 25.0}
};
library[0] → { title, author, price }
library[1] → { title, author, price }
library[2] → { title, author, price }
先选数组元素,再选成员:
library[1].price
library[1].title[0]
library.price[1] 不符合这里的布局,因为 library 是数组,不是具有 price 成员的结构。
处理结构数组的函数通常接收首元素指针与实际记录数:
double total_price(const struct book books[], size_t count)
{
double total = 0.0;
for (size_t i = 0; i < count; ++i) {
total += books[i].price;
}
return total;
}
数组容量和已经录入的记录数量不同。遍历时使用有效记录数量,不能把剩下尚未赋值的元素当作已存在记录。
3.2 一个结构可以包含另一个结构
struct name {
char first[20];
char last[20];
};
struct person {
struct name name;
unsigned age;
};
struct person user = {{"Ming", "Lin"}, 24};
访问时逐层选择:
user.name.first
user.name.last
如果 p 指向 user,则使用 p->name.first。只有最外层对象通过指针访问,内层 name 是结构成员,所以接下来使用点。
4. 结构与函数:成员、值和地址
4.1 三种接口分别提供不同信息
| 接口形式 | 调用示意 | 特点 |
|---|---|---|
| 只传需要的成员 | sum(a.x, a.y) |
接口只依赖这些值 |
| 按值传结构 | length(a) |
形参是结构值的副本 |
| 传结构地址 | move(&a) |
可通过指针修改原对象 |
只读取原结构时,可以声明 const struct point *。这限制通过该指针对结构本身的修改。
4.2 结构可以作为返回值
#include <stdio.h>
struct point {
double x;
double y;
};
struct point translated(struct point value, double dx, double dy);
void translate(struct point *value, double dx, double dy);
int main(void)
{
struct point original = {1.0, 2.0};
struct point copy = translated(original, 3.0, 4.0);
printf("original: %.1f %.1f\n", original.x, original.y);
printf("copy: %.1f %.1f\n", copy.x, copy.y);
translate(&original, -1.0, 2.0);
printf("changed: %.1f %.1f\n", original.x, original.y);
return 0;
}
struct point translated(struct point value, double dx, double dy)
{
value.x += dx;
value.y += dy;
return value;
}
void translate(struct point *value, double dx, double dy)
{
value->x += dx;
value->y += dy;
}
输出:
original: 1.0 2.0
copy: 4.0 6.0
changed: 0.0 4.0
返回结构值是合法的:调用方获得结果值,不是在继续使用已经消失的局部对象地址。
小型结构按值传递往往清楚自然;较大型结构可以考虑传指针。编译器也会进行优化,不能仅凭源码写了一个结构参数,就断言实际实现必然付出某个固定复制成本。
5. 结构赋值复制成员,不会自动复制指针目标
5.1 数组成员可以随结构一起复制
struct book first = {"Loops", "Lin", 20.0};
struct book second = first;
second.title[0] = 'B';
second 拥有自己的字符数组,修改它不会修改 first.title。同类型结构可以整体初始化和赋值,即使其中包含数组成员;单独的数组仍不能用普通 = 完成整体赋值。
5.2 指针成员只复制地址值
struct label {
char *text;
};
char buffer[] = "green";
struct label first = {buffer};
struct label second = first;
second.text[0] = 'G';
两份结构仍指向同一个 buffer,所以通过任一指针修改字符,另一个也能观察到。
first.text ─┐
├──→ 同一字符数组
second.text ─┘
这就是常说的浅复制:复制结构成员值,没有递归复制所有指针指向的数据。
如果指针代表拥有的动态内存,普通结构赋值还可能造成双重释放或丢失原目标。应提供明确的复制、转交与销毁操作,而不是假定 = 自动处理所有权。
5.3 const struct * 的保护有层次
通过 const struct label *p 不能改变 p->text 这个指针成员,但其声明为 char * 时,所指字符并不会因此自动变成只读。
如果接口只允许读取字符,应按需要把成员或相应访问接口设计为 const char *。结构外层的 const 不会自动沿所有指针递归传播。
6. 动态字符串成员:初始化、复制与清理配套设计
用字符数组成员,空间直接位于结构内;用指针成员,结构只保存地址,实际字符存储需要另外提供。
struct fixed_name {
char text[32];
};
struct dynamic_name {
char *text;
};
后者并没有为输入自动预留 32 字节,也不能把尚未初始化的 text 交给输入函数。
下面的完整示例提供显式深复制,得到两份可以独立修改的字符数据:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
struct person {
char *name;
unsigned age;
};
int person_init(struct person *out, const char *name, unsigned age);
int person_clone(struct person *out, const struct person *source);
void person_destroy(struct person *person);
int main(void)
{
struct person first = {0};
struct person second = {0};
if (!person_init(&first, "Ming", 24)) {
return EXIT_FAILURE;
}
if (!person_clone(&second, &first)) {
person_destroy(&first);
return EXIT_FAILURE;
}
second.name[0] = 'J';
printf("first: %s\n", first.name);
printf("second: %s\n", second.name);
person_destroy(&second);
person_destroy(&first);
return EXIT_SUCCESS;
}
int person_init(struct person *out, const char *name, unsigned age)
{
char *copy = malloc(strlen(name) + 1);
if (copy == NULL) {
return 0;
}
strcpy(copy, name);
out->name = copy;
out->age = age;
return 1;
}
int person_clone(struct person *out, const struct person *source)
{
return person_init(out, source->name, source->age);
}
void person_destroy(struct person *person)
{
free(person->name);
person->name = NULL;
person->age = 0;
}
输出:
first: Ming
second: Jing
接口约定很重要:
- 初始化或克隆的目标没有需要先释放的旧资源,且不能与源对象相同。
name和克隆源中的字符串必须有效。- 成功后目标拥有新分配内存;失败时目标保持原状。
- 销毁只适用于按此约定管理的对象;不能拿指向字面量的名字交给它释放。
若要支持替换已有对象,应先准备新资源,成功后再释放旧资源并提交结果。不要在分配尚未成功时就丢掉仍然有效的旧值。
7. 复合字面量让临时结构值写得更直接
struct point value;
value = (struct point){.x = 3.0, .y = 4.0};
结构赋值右侧必须是一个结构表达式,不能只写孤立的 {3.0, 4.0}。复合字面量提供了这样的匿名结构对象。
也可以直接作为实参,或取得其地址供本次处理使用:
translated((struct point){1.0, 2.0}, 3.0, 4.0);
translate(&(struct point){1.0, 2.0}, 3.0, 4.0);
在本章的 C99/C11 规则下,块内复合字面量与相应块的执行具有相关生存期;函数外的复合字面量具有静态存储期。不能把局部复合字面量地址交出去后,越过其生存期继续使用。
8. 伸缩型数组成员:一块分配中的固定头部与尾部数据
8.1 最后一个成员可以是不完整数组
struct samples {
size_t count;
double values[];
};
C99 的伸缩型数组成员满足以下条件:
- 位于结构的最后。
- 结构还有至少一个其他具名成员。
- 数组方括号留空,不写成零长度数组。
普通 struct samples 对象并没有自动获得任意数量的 values 元素。需要为固定结构部分和尾部元素一起分配空间。
8.2 完整分配示例
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
struct samples {
size_t count;
double values[];
};
struct samples *make_samples(size_t count);
int main(void)
{
struct samples *data = make_samples(4);
if (data == NULL) {
return EXIT_FAILURE;
}
double total = 0.0;
for (size_t i = 0; i < data->count; ++i) {
total += data->values[i];
}
printf("count: %zu, average: %.1f\n",
data->count, total / data->count);
free(data);
return EXIT_SUCCESS;
}
struct samples *make_samples(size_t count)
{
struct samples *data;
if (count == 0 ||
count > (SIZE_MAX - sizeof *data) / sizeof data->values[0]) {
return NULL;
}
data = malloc(sizeof *data + count * sizeof data->values[0]);
if (data == NULL) {
return NULL;
}
data->count = count;
for (size_t i = 0; i < count; ++i) {
data->values[i] = (double)i + 1.0;
}
return data;
}
输出:
count: 4, average: 2.5
固定头部与元素数据属于同一块分配,最后只需一次 free(data)。数组长度由程序另外记录在 count 中,不是结构类型自动保存的隐藏信息。
8.3 复制规则与普通数组成员不同
sizeof(struct samples) 不包含为具体记录额外分配的那批元素。普通结构赋值、按值传参,也不会因此完整复制整个可变尾部。
需要复制时,应先分配足够目标空间,再明确复制头部与实际元素。不要把 *target = *source 当成一条完整的伸缩数据复制操作。
这种结构不能作为数组元素或另一个结构的普通成员使用。通过指针处理它,明确分配大小、元素数量和生存期,更符合这种布局的使用方式。
9. 内存布局与文件记录
9.1 结构可能有内部填充和尾部填充
struct item {
char code;
int count;
double weight;
};
各普通成员按声明次序安排,但为了满足对齐要求,成员之间以及结构末尾可以存在填充。结构开头没有为了跳过第一个成员而添加的前置填充。
因此,不能假定:
sizeof(struct item) = 各成员 sizeof 之和
需要成员偏移时,可使用 <stddef.h> 的 offsetof:
size_t position = offsetof(struct item, weight);
结构数组按 sizeof(struct item) 跨步。不要自行把成员大小相加,作为访问下一个结构的距离。
9.2 结构不能直接用 == 比较内容
同类型结构可以赋值,但不能普遍使用 ==、< 等运算符比较整个结构。
比较时应按业务含义逐个成员处理:字符串使用字符串比较,数值使用适当比较规则。
也不能用 memcmp 比较整块结构字节来普遍替代成员比较,因为填充字节等对象表示细节不一定反映成员值是否相等。
9.3 直接写结构只适合相容布局
fwrite(&record, sizeof record, 1, file);
这种方式按结构大小写出整个对象表示,便于在相容环境中处理固定长度记录,但会把填充和固定字符数组的未用部分也纳入表示。
需要跨系统或长期保存时,应明确编码每个字段。尤其是指针成员:把地址值写入文件,不会把所指字符串或链式结构一起保存,也不能在下一次进程中直接恢复那些地址。
从文件读入的字符字段还必须验证结束标记与容量。成功读满一个结构大小的数据,不代表其中的字符串一定合法。
10. 链式结构:用指针连接同类型节点
struct node {
int value;
struct node *next;
};
结构可以包含指向自身类型的指针,但不能直接包含一个完整的同类型成员,否则对象大小会无穷嵌套。
head → {value, next} → {value, next} → {value, NULL}
这种形式是链表的基础;两个子节点指针则可以组织成树。结构负责保存节点局部数据,指针负责描述节点之间的关系。
性能取决于具体组织方式、平衡情况与操作需求,不能简单认为“树永远比数组查找更快”。有序数组也可以二分查找,退化的搜索树则可能需要线性查找。
动态节点还需要明确分配、连接与释放责任。只释放头指针所指节点,不会自动递归释放整条链。
11. 联合:在同一空间中选择一种表示
11.1 结构同时保存成员,联合共享成员存储
union value {
int integer;
double real;
char letter;
};
联合提供足以容纳其各成员的空间,还需要满足相应对齐要求。大小可能包含填充,不能简单规定为某个固定字节数。
union value data = {.real = 2.5};
data.integer = 7;
后一次写入改变了共享存储。不要再把 data.real 当作仍保存原来的 2.5。
联合赋值给同类型联合是允许的;初始化未使用指定成员时,从第一个成员开始。访问语法与结构相同,对象用点,指针用箭头。
11.2 用另一个成员读取,不是数值类型转换
把 integer 写入后读取 real,不是把整数值转换成浮点值,而是在按另一种类型解释对象表示。
这可能涉及依赖实现的表示和陷阱表示等问题,不适合作为普通、可移植的数值转换方法。需要数值转换时,直接使用相应赋值或强制类型转换;需要理解对象字节时,明确处理表示层面的规则。
11.3 配合枚举保存当前类别
联合本身不会自动记录当前写入的是哪个成员。常见方式是把“类别标签 + 联合载荷”放在一个结构里。
#include <stddef.h>
#include <stdio.h>
enum value_kind { VALUE_INTEGER, VALUE_REAL, VALUE_TEXT };
struct value {
enum value_kind kind;
union {
int integer;
double real;
const char *text;
} data;
};
void show_value(const struct value *value);
int main(void)
{
struct value values[] = {
{.kind = VALUE_INTEGER, .data = {.integer = 7}},
{.kind = VALUE_REAL, .data = {.real = 2.5}},
{.kind = VALUE_TEXT, .data = {.text = "hello"}}
};
size_t count = sizeof values / sizeof values[0];
for (size_t i = 0; i < count; ++i) {
show_value(&values[i]);
}
return 0;
}
void show_value(const struct value *value)
{
switch (value->kind) {
case VALUE_INTEGER:
printf("integer: %d\n", value->data.integer);
break;
case VALUE_REAL:
printf("real: %.2f\n", value->data.real);
break;
case VALUE_TEXT:
printf("text: %s\n", value->data.text);
break;
default:
puts("invalid kind");
break;
}
}
输出:
integer: 7
real: 2.50
text: hello
程序必须保持标签与载荷一致。例如标签为 VALUE_TEXT 时,文本指针必须有效;标签值不能替代真正的初始化与生存期管理。
12. 枚举:给整数状态命名
enum state {
STATE_IDLE,
STATE_READING,
STATE_DONE,
STATE_ERROR = 10,
STATE_RETRY
};
按本章 C99/C11 规则,枚举常量为 int 类型。默认从 0 开始,未显式指定的后续常量等于前一项加 1,所以这里的值依次为 0、1、2、10、11。
枚举类型本身的兼容整数类型由实现选择,不能假定每个枚举对象都恰好占 4 字节。
enum state current = STATE_IDLE;
枚举能让 switch、返回状态与接口更容易理解,但不会自动完成以下事情:
- 把输入字符串
"STATE_IDLE"转换成枚举值。 - 验证输入整数恰好属于列出的状态集合。
- 为非连续的枚举值生成安全数组索引。
若状态要索引字符串表,必须确保索引范围有效;若从外部数据读取状态,先验证再使用。
13. typedef:为已有类型提供别名
13.1 别名不创建另一种独立类型
typedef unsigned char Byte;
typedef struct point Point;
Byte data[16];
Point location = {1.0, 2.0};
Point 与 struct point 指的是同一类型。typedef 由编译器处理,并遵循相应作用域规则,不是简单的预处理文本替换。
typedef struct {
double x;
double y;
} Position;
Position first = {1.0, 2.0};
Position second = first;
两个变量使用同一个已定义类型,所以可以赋值。
但如果分别写两个没有标记的独立结构定义,即使成员名称和类型完全一样,也不能仅凭外观相同就认为它们是同一结构类型。
13.2 指针别名与宏的区别
typedef char *Text;
Text first, second; // 两个都是 char *。
如果改用 #define Text char *,Text first, second; 文本展开后是 char *first, second;,只有第一个是指针。
限定符也要按完整别名理解:
typedef char *Text;
char buffer[] = "hello";
const Text fixed = buffer; // char *const fixed。
这里 const 限定指针本身,不会把被指向的字符改成只读。为了让接口的只读意图清楚,常常直接写 const char * 更合适。
13.3 名称空间与作用域不同
结构、联合和枚举的标记共享一类名称空间;普通变量、函数、枚举常量和 typedef 名称则属于普通标识符名称空间。不同结构的成员各有相应名称空间,标签也有自己的规则。
所以 struct point 的标记与一个普通变量名可以在语法上同名,但不代表这样写有利于阅读。
作用域回答名字在哪里生效,名称空间回答名字属于哪一类命名体系。这是两个维度。
14. 复杂声明:从名字向外逐层读
声明中的 [] 和函数参数列表 () 比 * 结合得更紧,括号可以改变组合方式。
| 声明 | 含义 |
|---|---|
int *items[4]; |
含 4 个整数指针的数组 |
int (*row)[4]; |
指向含 4 个整数的数组的指针 |
int (*rows[3])[4]; |
含 3 个数组指针的数组,每个指向一行 4 个整数 |
int (*matrix)[3][4]; |
指向一个 3 × 4 整数数组的指针 |
char *make_text(int); |
接收 int 并返回字符指针的函数 |
char (*operation)(int); |
指向接收 int、返回 char 的函数的指针 |
char (*operations[3])(int); |
含 3 个上述函数指针的数组 |
例如 int (*rows[3])[4]:先看出 rows 是 3 个元素的数组;每个元素是指针;指向含 4 个 int 的数组。
可以用别名拆分层次:
typedef int Row[4];
typedef Row *RowPointer;
RowPointer rows[3];
别名应该帮助表达领域含义或减少重复,不必为了炫示语法而层层嵌套。
15. 函数指针:把操作本身作为参数
15.1 先写函数类型,再在名字处放指针
double square(double value);
double (*operation)(double);
operation 是指针,指向“接收一个 double,返回一个 double”的函数。
operation = square;
operation = □
这两种赋地址形式都可以使用。调用也有两种等价写法:
double first = operation(3.0);
double second = (*operation)(3.0);
operation = square(3.0) 则是在调用函数并取得结果,不是在保存函数地址。
15.2 类型必须相容
函数指针不仅要匹配返回类型,也要匹配参数类型和数量等要求。通过不相容类型的函数指针调用函数可能导致未定义行为,强制转换不会自动修复接口。
空函数指针不能调用,也不能对函数指针进行数组式递增来“找到下一个函数”。标准 C 不把函数指针与对象指针的表示和转换普遍等同起来。
15.3 完整示例:同一遍历过程选择不同操作
#include <stddef.h>
#include <stdio.h>
typedef double (*Transform)(double);
double square(double value);
double half(double value);
void transform(double out[], const double in[],
size_t count, Transform operation);
int main(void)
{
const double source[] = {2.0, 4.0, 6.0};
double target[3];
Transform operations[] = {square, half};
for (size_t choice = 0; choice < 2; ++choice) {
transform(target, source, 3, operations[choice]);
printf("operation %zu:", choice);
for (size_t i = 0; i < 3; ++i) {
printf(" %.1f", target[i]);
}
putchar('\n');
}
return 0;
}
double square(double value)
{
return value * value;
}
double half(double value)
{
return value / 2.0;
}
void transform(double out[], const double in[],
size_t count, Transform operation)
{
for (size_t i = 0; i < count; ++i) {
out[i] = operation(in[i]);
}
}
输出:
operation 0: 4.0 16.0 36.0
operation 1: 1.0 2.0 3.0
这里约定输入输出范围有效、互不重叠,函数指针有效,数值运算在允许范围内。遍历逻辑只写一份,操作由调用者决定。
函数指针数组也适合表达菜单处理表。菜单输入必须先转换为合法下标,不能直接用未经验证的输入访问函数表。
16. C11 匿名结构与匿名联合成员
struct person {
unsigned id;
struct {
char first[20];
char last[20];
};
};
内层没有成员名。若定义 struct person user;,就可以直接使用 user.first,不需要额外的 user.name.first 层级。
匿名联合可以简化带标签载荷的访问:
struct number {
enum { NUMBER_INT, NUMBER_REAL } kind;
union {
int integer;
double real;
};
};
对象可以直接使用 .integer 或 .real,但存储仍然共享,类别标签仍需由程序维护。
这与“省略结构标记、但定义了一个有名字的对象”不是同一件事。匿名成员减少的是成员访问层次,不会取消类型、布局和生存期规则。
17. 按表达意图选择数据形式
| 需求 | 适合的方式 |
|---|---|
| 一组同类型元素 | 数组 |
| 一个实体的多种属性同时存在 | 结构 |
| 几种载荷表示中选择一种 | 联合,通常配合明确的类别标签 |
| 给整数状态提供名称 | 枚举 |
| 给已有类型提供方便名称 | typedef |
| 固定头部后跟运行时数量的数据 | 伸缩型数组成员与适当分配 |
| 节点之间存在可变连接 | 包含指针的结构 |
| 同一处理流程使用不同操作 | 函数指针或函数指针表 |
建立一个结构后,配套定义读取、验证、修改、复制与销毁函数,往往比在各处直接拼接成员操作更容易维护。类型说明数据如何组合,接口则说明这些组合怎样保持有效。

