清风日常

Stay curious. Stay gentle.

听一会儿

4 首收藏
LOVE 2000 封面

LOVE 2000

遠野ひかる

0:004:23
首页/C Primer Plus/正文

C Primer Plus(第6版)· 第14章:结构和其他数据形式

数组把相同类型的数据排列起来,结构则把同一事物的不同属性组合成一个对象。联合用于表达几种表示方式中的一种,枚举给状态命名,函数指针则让程序把处理行为作为参数传递。

文章目录 42 个章节

第14章:结构和其他数据形式

数组把相同类型的数据排列起来,结构则把同一事物的不同属性组合成一个对象。联合用于表达几种表示方式中的一种,枚举给状态命名,函数指针则让程序把处理行为作为参数传递。

本章可以围绕两个问题展开:数据怎样组织,操作怎样围绕数据定义。 设计类型时,还要明确哪些内容直接存放在对象内,哪些只是指向其他对象的地址。

1. 用结构把相关信息放在一起

c
struct book {
    char title[40];
    char author[32];
    double price;
};

这段代码定义一个结构类型,描述三个成员。它本身没有创建一本实际的书。

c
struct book first;
struct book second;

这两条定义才创建实际对象。每个对象都有自己的书名数组、作者数组与价格。

名称 含义
struct 声明结构的关键字
book 结构标记
struct book 完整的类型名称
first 该类型的对象
title、author、price 成员名称

右花括号后的分号不能省略。成员也要分别写成完整声明,结构成员不能直接写成带默认初始化值的普通 C 成员定义。

可以在类型定义后面同时定义对象:

c
struct point {
    double x;
    double y;
} start, finish;

也可以省略标记,只在当前声明中直接定义对象;如果要在多个位置反复引用同一类型,保留标记或提供合适的 typedef 更方便。

2. 初始化结构与访问成员

2.1 按成员声明顺序初始化

c
struct book first = {"Small Programs", "Lin", 28.5};

花括号中的值依次初始化 title、author、price。字符数组成员可以在这里直接用字符串字面量初始化。

c
struct book empty = {0};

部分初始化时,其余成员按规则零初始化。没有初始化的普通自动结构对象,不会自动得到全零成员。

静态存储期结构的初始化仍须符合静态初始化规则,不能随意在初始化列表中调用运行时函数。

2.2 指定初始化器直接说明成员

c
struct book first = {
    .price = 28.5,
    .title = "Small Programs",
    .author = "Lin"
};

C99 支持 .成员名 = 值,可以改变初始化项的书写顺序,减少记错位置的风险。

若指定初始化后继续写普通初始化项,它从紧随指定成员之后的位置继续。重复指定同一成员时,后一次初始化会取代前一次;实际代码通常应避免这种容易混淆的写法。

2.3 对象用点,指针用箭头

c
struct book first = {"Small Programs", "Lin", 28.5};
struct book *p = &first;

first.price = 30.0;
p->price = 32.0;

以下表示相同成员:

c
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 一条记录一个结构,多条记录一个数组

c
struct book library[3] = {
    {"Loops", "Lin", 20.0},
    {"Pointers", "Chen", 35.0},
    {"Files", "Zhao", 25.0}
};
text
library[0] → { title, author, price }
library[1] → { title, author, price }
library[2] → { title, author, price }

先选数组元素,再选成员:

c
library[1].price
library[1].title[0]

library.price[1] 不符合这里的布局,因为 library 是数组,不是具有 price 成员的结构。

处理结构数组的函数通常接收首元素指针与实际记录数:

c
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 一个结构可以包含另一个结构

c
struct name {
    char first[20];
    char last[20];
};

struct person {
    struct name name;
    unsigned age;
};

struct person user = {{"Ming", "Lin"}, 24};

访问时逐层选择:

c
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 结构可以作为返回值

c
#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;
}

输出:

text
original: 1.0 2.0
copy: 4.0 6.0
changed: 0.0 4.0

返回结构值是合法的:调用方获得结果值,不是在继续使用已经消失的局部对象地址。

小型结构按值传递往往清楚自然;较大型结构可以考虑传指针。编译器也会进行优化,不能仅凭源码写了一个结构参数,就断言实际实现必然付出某个固定复制成本。

5. 结构赋值复制成员,不会自动复制指针目标

5.1 数组成员可以随结构一起复制

c
struct book first = {"Loops", "Lin", 20.0};
struct book second = first;

second.title[0] = 'B';

second 拥有自己的字符数组,修改它不会修改 first.title。同类型结构可以整体初始化和赋值,即使其中包含数组成员;单独的数组仍不能用普通 = 完成整体赋值。

5.2 指针成员只复制地址值

c
struct label {
    char *text;
};

char buffer[] = "green";
struct label first = {buffer};
struct label second = first;

second.text[0] = 'G';

两份结构仍指向同一个 buffer,所以通过任一指针修改字符,另一个也能观察到。

text
first.text  ─┐
             ├──→ 同一字符数组
second.text ─┘

这就是常说的浅复制:复制结构成员值,没有递归复制所有指针指向的数据。

如果指针代表拥有的动态内存,普通结构赋值还可能造成双重释放或丢失原目标。应提供明确的复制、转交与销毁操作,而不是假定 = 自动处理所有权。

5.3 const struct * 的保护有层次

通过 const struct label *p 不能改变 p->text 这个指针成员,但其声明为 char * 时,所指字符并不会因此自动变成只读。

如果接口只允许读取字符,应按需要把成员或相应访问接口设计为 const char *。结构外层的 const 不会自动沿所有指针递归传播。

6. 动态字符串成员:初始化、复制与清理配套设计

用字符数组成员,空间直接位于结构内;用指针成员,结构只保存地址,实际字符存储需要另外提供。

c
struct fixed_name {
    char text[32];
};

struct dynamic_name {
    char *text;
};

后者并没有为输入自动预留 32 字节,也不能把尚未初始化的 text 交给输入函数。

下面的完整示例提供显式深复制,得到两份可以独立修改的字符数据:

c
#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;
}

输出:

text
first: Ming
second: Jing

接口约定很重要:

  • 初始化或克隆的目标没有需要先释放的旧资源,且不能与源对象相同。
  • name 和克隆源中的字符串必须有效。
  • 成功后目标拥有新分配内存;失败时目标保持原状。
  • 销毁只适用于按此约定管理的对象;不能拿指向字面量的名字交给它释放。

若要支持替换已有对象,应先准备新资源,成功后再释放旧资源并提交结果。不要在分配尚未成功时就丢掉仍然有效的旧值。

7. 复合字面量让临时结构值写得更直接

c
struct point value;
value = (struct point){.x = 3.0, .y = 4.0};

结构赋值右侧必须是一个结构表达式,不能只写孤立的 {3.0, 4.0}。复合字面量提供了这样的匿名结构对象。

也可以直接作为实参,或取得其地址供本次处理使用:

c
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 最后一个成员可以是不完整数组

c
struct samples {
    size_t count;
    double values[];
};

C99 的伸缩型数组成员满足以下条件:

  • 位于结构的最后。
  • 结构还有至少一个其他具名成员。
  • 数组方括号留空,不写成零长度数组。

普通 struct samples 对象并没有自动获得任意数量的 values 元素。需要为固定结构部分和尾部元素一起分配空间。

8.2 完整分配示例

c
#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;
}

输出:

text
count: 4, average: 2.5

固定头部与元素数据属于同一块分配,最后只需一次 free(data)。数组长度由程序另外记录在 count 中,不是结构类型自动保存的隐藏信息。

8.3 复制规则与普通数组成员不同

sizeof(struct samples) 不包含为具体记录额外分配的那批元素。普通结构赋值、按值传参,也不会因此完整复制整个可变尾部。

需要复制时,应先分配足够目标空间,再明确复制头部与实际元素。不要把 *target = *source 当成一条完整的伸缩数据复制操作。

这种结构不能作为数组元素或另一个结构的普通成员使用。通过指针处理它,明确分配大小、元素数量和生存期,更符合这种布局的使用方式。

9. 内存布局与文件记录

9.1 结构可能有内部填充和尾部填充

c
struct item {
    char code;
    int count;
    double weight;
};

各普通成员按声明次序安排,但为了满足对齐要求,成员之间以及结构末尾可以存在填充。结构开头没有为了跳过第一个成员而添加的前置填充。

因此,不能假定:

text
sizeof(struct item) = 各成员 sizeof 之和

需要成员偏移时,可使用 <stddef.h> 的 offsetof:

c
size_t position = offsetof(struct item, weight);

结构数组按 sizeof(struct item) 跨步。不要自行把成员大小相加,作为访问下一个结构的距离。

9.2 结构不能直接用 == 比较内容

同类型结构可以赋值,但不能普遍使用 ==、< 等运算符比较整个结构。

比较时应按业务含义逐个成员处理:字符串使用字符串比较,数值使用适当比较规则。

也不能用 memcmp 比较整块结构字节来普遍替代成员比较,因为填充字节等对象表示细节不一定反映成员值是否相等。

9.3 直接写结构只适合相容布局

c
fwrite(&record, sizeof record, 1, file);

这种方式按结构大小写出整个对象表示,便于在相容环境中处理固定长度记录,但会把填充和固定字符数组的未用部分也纳入表示。

需要跨系统或长期保存时,应明确编码每个字段。尤其是指针成员:把地址值写入文件,不会把所指字符串或链式结构一起保存,也不能在下一次进程中直接恢复那些地址。

从文件读入的字符字段还必须验证结束标记与容量。成功读满一个结构大小的数据,不代表其中的字符串一定合法。

10. 链式结构:用指针连接同类型节点

c
struct node {
    int value;
    struct node *next;
};

结构可以包含指向自身类型的指针,但不能直接包含一个完整的同类型成员,否则对象大小会无穷嵌套。

text
head → {value, next} → {value, next} → {value, NULL}

这种形式是链表的基础;两个子节点指针则可以组织成树。结构负责保存节点局部数据,指针负责描述节点之间的关系。

性能取决于具体组织方式、平衡情况与操作需求,不能简单认为“树永远比数组查找更快”。有序数组也可以二分查找,退化的搜索树则可能需要线性查找。

动态节点还需要明确分配、连接与释放责任。只释放头指针所指节点,不会自动递归释放整条链。

11. 联合:在同一空间中选择一种表示

11.1 结构同时保存成员,联合共享成员存储

c
union value {
    int integer;
    double real;
    char letter;
};

联合提供足以容纳其各成员的空间,还需要满足相应对齐要求。大小可能包含填充,不能简单规定为某个固定字节数。

c
union value data = {.real = 2.5};
data.integer = 7;

后一次写入改变了共享存储。不要再把 data.real 当作仍保存原来的 2.5。

联合赋值给同类型联合是允许的;初始化未使用指定成员时,从第一个成员开始。访问语法与结构相同,对象用点,指针用箭头。

11.2 用另一个成员读取,不是数值类型转换

把 integer 写入后读取 real,不是把整数值转换成浮点值,而是在按另一种类型解释对象表示。

这可能涉及依赖实现的表示和陷阱表示等问题,不适合作为普通、可移植的数值转换方法。需要数值转换时,直接使用相应赋值或强制类型转换;需要理解对象字节时,明确处理表示层面的规则。

11.3 配合枚举保存当前类别

联合本身不会自动记录当前写入的是哪个成员。常见方式是把“类别标签 + 联合载荷”放在一个结构里。

c
#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;
    }
}

输出:

text
integer: 7
real: 2.50
text: hello

程序必须保持标签与载荷一致。例如标签为 VALUE_TEXT 时,文本指针必须有效;标签值不能替代真正的初始化与生存期管理。

12. 枚举:给整数状态命名

c
enum state {
    STATE_IDLE,
    STATE_READING,
    STATE_DONE,
    STATE_ERROR = 10,
    STATE_RETRY
};

按本章 C99/C11 规则,枚举常量为 int 类型。默认从 0 开始,未显式指定的后续常量等于前一项加 1,所以这里的值依次为 0、1、2、10、11。

枚举类型本身的兼容整数类型由实现选择,不能假定每个枚举对象都恰好占 4 字节。

c
enum state current = STATE_IDLE;

枚举能让 switch、返回状态与接口更容易理解,但不会自动完成以下事情:

  • 把输入字符串 "STATE_IDLE" 转换成枚举值。
  • 验证输入整数恰好属于列出的状态集合。
  • 为非连续的枚举值生成安全数组索引。

若状态要索引字符串表,必须确保索引范围有效;若从外部数据读取状态,先验证再使用。

13. typedef:为已有类型提供别名

13.1 别名不创建另一种独立类型

c
typedef unsigned char Byte;
typedef struct point Point;

Byte data[16];
Point location = {1.0, 2.0};

Point 与 struct point 指的是同一类型。typedef 由编译器处理,并遵循相应作用域规则,不是简单的预处理文本替换。

c
typedef struct {
    double x;
    double y;
} Position;

Position first = {1.0, 2.0};
Position second = first;

两个变量使用同一个已定义类型,所以可以赋值。

但如果分别写两个没有标记的独立结构定义,即使成员名称和类型完全一样,也不能仅凭外观相同就认为它们是同一结构类型。

13.2 指针别名与宏的区别

c
typedef char *Text;
Text first, second;  // 两个都是 char *。

如果改用 #define Text char *,Text first, second; 文本展开后是 char *first, second;,只有第一个是指针。

限定符也要按完整别名理解:

c
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 的数组。

可以用别名拆分层次:

c
typedef int Row[4];
typedef Row *RowPointer;

RowPointer rows[3];

别名应该帮助表达领域含义或减少重复,不必为了炫示语法而层层嵌套。

15. 函数指针:把操作本身作为参数

15.1 先写函数类型,再在名字处放指针

c
double square(double value);
double (*operation)(double);

operation 是指针,指向“接收一个 double,返回一个 double”的函数。

c
operation = square;
operation = &square;

这两种赋地址形式都可以使用。调用也有两种等价写法:

c
double first = operation(3.0);
double second = (*operation)(3.0);

operation = square(3.0) 则是在调用函数并取得结果,不是在保存函数地址。

15.2 类型必须相容

函数指针不仅要匹配返回类型,也要匹配参数类型和数量等要求。通过不相容类型的函数指针调用函数可能导致未定义行为,强制转换不会自动修复接口。

空函数指针不能调用,也不能对函数指针进行数组式递增来“找到下一个函数”。标准 C 不把函数指针与对象指针的表示和转换普遍等同起来。

15.3 完整示例:同一遍历过程选择不同操作

c
#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]);
    }
}

输出:

text
operation 0: 4.0 16.0 36.0
operation 1: 1.0 2.0 3.0

这里约定输入输出范围有效、互不重叠,函数指针有效,数值运算在允许范围内。遍历逻辑只写一份,操作由调用者决定。

函数指针数组也适合表达菜单处理表。菜单输入必须先转换为合法下标,不能直接用未经验证的输入访问函数表。

16. C11 匿名结构与匿名联合成员

c
struct person {
    unsigned id;
    struct {
        char first[20];
        char last[20];
    };
};

内层没有成员名。若定义 struct person user;,就可以直接使用 user.first,不需要额外的 user.name.first 层级。

匿名联合可以简化带标签载荷的访问:

c
struct number {
    enum { NUMBER_INT, NUMBER_REAL } kind;
    union {
        int integer;
        double real;
    };
};

对象可以直接使用 .integer 或 .real,但存储仍然共享,类别标签仍需由程序维护。

这与“省略结构标记、但定义了一个有名字的对象”不是同一件事。匿名成员减少的是成员访问层次,不会取消类型、布局和生存期规则。

17. 按表达意图选择数据形式

需求 适合的方式
一组同类型元素 数组
一个实体的多种属性同时存在 结构
几种载荷表示中选择一种 联合,通常配合明确的类别标签
给整数状态提供名称 枚举
给已有类型提供方便名称 typedef
固定头部后跟运行时数量的数据 伸缩型数组成员与适当分配
节点之间存在可变连接 包含指针的结构
同一处理流程使用不同操作 函数指针或函数指针表

建立一个结构后,配套定义读取、验证、修改、复制与销毁函数,往往比在各处直接拼接成员操作更容易维护。类型说明数据如何组合,接口则说明这些组合怎样保持有效。

Aron 的清风日常

作者 Aron · 发布于 2026.08.15

愿这份记录能为你提供一点参考。发现问题,欢迎通过 GitHub 或 邮件交流。

如果这篇记录对你有用,留下一份喜欢。

← 所有文章继续阅读C Primer Plus(第6版)· 附录整理:语法与标准库速查 →
SEARCH MY NOTES

在记录里,找一点答案

搜索标题、标签与全文 · 按 Esc 关闭