这一章在讲什么?

一本书有书名、作者、价格,用 7 个零散数组存藏书信息?改一次排序就要同步改 7 个数组,必乱无疑。C 的结构(structure)把一组相关数据打包成一个整体——这正是”记录”思想在 C 里的化身。本章还引出联合(union)、枚举(enum)、typedef 与函数指针,构成 C 数据表示的进阶武器库。

14.1 问题示例:创建书籍清单

Gwen 想给藏书做清单,每本书要记书名、作者、价值。与其维护三个平行数组,不如定义一个”书的模板”,让一个变量同时装下三样东西。清单 14.1 book.c 是最小示例:

/* book.c -- 一本书的清单 */
#include <stdio.h>
#define MAXTITL 41    /* 书名最大长度 + 1 */
#define MAXAUTL 31    /* 作者名最大长度 + 1 */
struct book {
    char title[MAXTITL];
    char author[MAXAUTL];
    float value;
};    /* 结构模板结束 */
int main(void)
{
    struct book library; /* 声明 library 为一个 book 变量 */
    printf("Please enter the book title.\n");
    gets(library.title);          /* 访问 title 成员 */
    printf("Now enter the author.\n");
    gets(library.author);
    printf("Now enter the value.\n");
    scanf("%f", &library.value);
    printf("%s by %s: $%.2f\n", library.title, library.author, library.value);
    printf("%s: \"%s\" ($%.2f)\n", library.author, library.title, library.value);
    printf("Done.\n");
    return 0;
}

你需要掌握三个基本功:建立结构布局按布局声明变量访问变量的各个成员

建立结构声明

模板声明(即清单 14.1 开头的 struct book {...};)中:struct 关键字表明后面是一个结构;book标记(tag),可用来日后引用这个设计;大括号里是成员列表(member list),每个成员有自己的声明和分号,成员可以是任意 C 类型——甚至另一个结构;末尾大括号后的分号不能漏,它结束整个模板定义。模板只是图纸,不分配内存:声明放在函数外,其后所有函数都能用;放在函数内,只对该函数可见。

定义结构变量与初始化

struct book library; 这行声明让编译器按 book 图纸分配空间:两个字符数组加一个 float,统一挂在 library 名下:

/图:一个结构变量在内存中的布局

声明与定义可以合二为一(此时可省略标记);但模板要复用就该留标记。初始化语法和数组一样,用大括号包住逗号分隔的初始值,按成员顺序一一对应:

struct book library = {
    "The Pirate and the Devious Damsel",
    "Renee Vivotte",
    1.95
};

注意:具有静态存储期(static storage duration)的结构,初始化值必须是常量表达式。

访问成员:点运算符

结构成员运算符 .(点号):library.value 就像普通 float 一样用,library.title 就像普通 char 数组一样用。scanf("%f", &library.value) 能成立,是因为 . 优先级高于 &,表达式相当于 &(library.value)

C99 还给结构引入了指定初始化器(designated initializer),用点号加成员名,顺序随意:

struct book surprise = { .value = 10.99 };
struct book gift = { .value = 25.99,
    .author = "James Broadfool",
    .title = "Rue for the Toad" };

14.2 结构数组

一本书一个结构变量显然不够,把结构装进数组即可——清单 14.2 manybook.c:

/* manybook.c -- 多本书的清单(book 模板与 14.1 相同,此处省略) */
#include <stdio.h>
#define MAXTITL 40
#define MAXAUTL 40
#define MAXBKS 100    /* 藏书上限 */
int main(void)
{
    struct book library[MAXBKS]; /* book 结构的数组 */
    int count = 0;
    int index;
 
    printf("Please enter the book title.\n");
    printf("Press [enter] at the start of a line to stop.\n");
    while (count < MAXBKS && gets(library[count].title) != NULL && library[count].title[0] != '\0')
    {
        printf("Now enter the author.\n");
        gets(library[count].author);
        printf("Now enter the value.\n");
        scanf("%f", &library[count++].value);
        while (getchar() != '\n') continue;    /* 清空输入行 */
        if (count < MAXBKS)
            printf("Enter the next title.\n");
    }
 
    if (count > 0)
    {
        printf("Here is the list of your books:\n");
        for (index = 0; index < count; index++)
            printf("%s by %s: $%.2f\n", library[index].title,
                library[index].author, library[index].value);
    }
    else
        printf("No books? Too bad.\n");
 
    return 0;
}

library 是数组名,library[2] 才是一个结构变量,所以下标要挂在 library 身上:library[2].value 对,library.value[2] 错。下标还能叠加——library[2].title[4] 是第三本书书名里的第 5 个字符。规律:点号左边的下标作用于结构数组,右边的下标作用于成员内部

/图:结构数组 library[MAXBKS] 的内存布局

程序里 while (getchar() != '\n') continue; 是为了清掉 scanf() 留在输入队列里的换行符——否则下一次 gets() 会读到残留的 \n,误判为空行而提前终止。

100 个结构可能撑爆栈

这个数组是自动存储类对象,通常放在(stack)上。100 本 × 80 多字节要占好几千字节,某些编译器默认栈很小,会报 stack overflow。解决办法:声明成 static 或外部(不占栈)、调大栈选项,或缩小数组。

14.3 嵌套结构

结构成员本身可以是另一个结构。比如”朋友”结构里嵌一个”姓名”结构:

struct names {          // 第一个结构
    char first[LEN];
    char last[LEN];
};
struct guy {            // 第二个结构
    struct names handle; // 嵌套结构
    char favfood[LEN];
    char job[LEN];
    float income;
};
struct guy fellow = {
    { "Ewen", "Villard" },   // 嵌套结构的初始化
    "grilled salmon",
    "personality coach",
    58112.00
};

初始化时嵌套部分用内层大括号;访问时连用两个点号:fellow.handle.first(fellow.handle).first——先找 fellow,再找其中的 handle,再找 handle 里的 first。

14.4 指向结构的指针

用指针操作结构至少有三大好处:指针比整个结构好挪动(比如排序)、老式编译器只支持传结构指针、链式数据结构全靠结构里的指针互相勾连。

/* friends.c -- 使用指向结构的指针(names、guy 模板与 14.3 相同) */
#define LEN 20
int main(void)
{
    struct guy fellow[2] = {
        { { "Ewen", "Villard" }, "grilled salmon",
          "personality coach", 58112.00 },
        { { "Rodney", "Swillbelly" }, "tripe",
          "tabloid editor", 232400.00 }
    };
    struct guy * him;   /* 指向结构的指针 */
    printf("address #1: %p #2: %p\n", &fellow[0], &fellow[1]);
    him = &fellow[0];   /* 告诉指针指向哪里 */
    printf("pointer #1: %p #2: %p\n", him, him + 1);
    printf("him->income is $%.2f: (*him).income is $%.2f\n", him->income, (*him).income);
    him++;              /* 指向下一个结构 */
    printf("him->favfood is %s: him->handle.last is %s\n", him->favfood, him->handle.last);
    return 0;
}

要点:

  • 声明 struct guy * him; 与其他指针声明如出一辙。结构名不是结构的地址(这点与数组不同),取地址必须用 &
  • him + 1 地址增加了 84(十六进制 54)——一个 guy 结构占 84 字节。有时结构会大于各成员之和,因为系统按对齐要求插入”空洞”。
  • 用指针访问成员有两种写法:间接成员运算符 ->him->income),或解引用加点((*him).income,括号不能省,因为 . 优先级高于 *)。三者等价:barney.income == (*him).income == him->income(假设 him == &barney)。

14.5 把结构告诉函数

三种传法

以”算两个账户总额”为例,ANSI C 有三种传法:

1. 传成员——只传函数需要的成员(清单 14.5 funds1.c 的思路):sum(stan.bankfund, stan.savefund),sum() 根本不知道也不关心参数来自结构。

2. 传结构地址(清单 14.6 funds2.c):原型 double sum(const struct funds * money);,函数体为 return (money->bankfund + money->savefund);,调用 sum(&stan)。函数直接操作原结构,加 const 防止误改。

3. 传结构本身(清单 14.7 funds3.c):形参换成 struct funds moolah,调用 sum(stan)。调用时自动创建副本结构、成员逐个拷贝,函数操作副本,用点号访问(moolah.bankfund)。

两种风格对比(清单 14.8 / 14.9)

同一任务”统计姓名字母总数”,指针版(names1.c)全程只维护一个 person 结构,三个函数都拿 &person 干活;传值版(names2.c)里每个函数各造一份副本,靠返回值传回:person = getinfo();person = makeinfo(person);(在副本上计算,再 return info; 传回)、showinfo(person);

传结构是"整包复制",小结构才划算

按值传结构要把所有成员整体拷贝一遍,结构一大,时间空间双重浪费——尤其函数只用其中一两个成员时。惯例是:默认传指针(配 const 保护数据),小型结构才按值传。传值的好处是操作副本更安全,代码也更自然。

成员用字符数组还是字符指针?

对比 struct names { char first[LEN]; char last[LEN]; };(数组,安全)与 struct pnames { char * first; char * last; };(指针,有陷阱):

初始化 struct pnames treas = {"Brad", "Fallingjaw"} 没问题——字符串常量本身有存储处,结构里只存两个地址。但 scanf("%s", attorney.last) 就危险了:attorney.last 是未初始化的指针,指向哪里天知道,程序可能崩溃,更糟的是偶尔”碰巧能跑”——那意味着埋了一颗雷。想让结构自己存字符串,就用数组。

结构、指针与 malloc()

指针成员的正确打开方式:先 malloc() 分配空间再存地址。清单 14.10 names3.c 的 getinfo() 先读进临时数组再按需分配:

void getinfo (struct namect * pst)
{
    char temp[81];
    printf("Please enter your first name.\n");
    gets(temp);
    pst->fname = (char *) malloc(strlen(temp) + 1); // 分配内存
    strcpy(pst->fname, temp);                       // 拷贝过去
    printf("Please enter your last name.\n");
    gets(temp);
    pst->lname = (char *) malloc(strlen(temp) + 1);
    strcpy(pst->lname, temp);
}

字符串本体活在 malloc() 的内存块里,结构只存地址;程序末尾配 cleanup() 函数 free 掉两块内存,malloc/free 收支平衡。

C99 新玩意:复合字面量与柔性数组

复合字面量(compound literal)能凭空造一个结构当实参:rect_area((struct rect) {10.5, 20.0}),也能取其地址传给要指针的函数。

柔性数组成员(flexible array member)让结构的最后一个成员是”可伸缩”的数组:

struct flex {
    int count;
    double average;
    double scores[];   // 柔性数组:先不占空间
};

规则:必须是最后一个成员、至少还有一个其他成员、方括号为空。使用时不声明结构变量,而是声明指针并 malloc 出”结构 + 数组”的整块空间:

pf = malloc(sizeof(struct flex) + 5 * sizeof(double));
pf->count = 5;
pf->scores[2] = 18.5;   // 像普通数组一样用

传结构数组

数组名即首元素地址,直接传即可(清单 14.13 funds4.c):sum(jones, N),形参 const struct funds money[],函数内用 money[i].bankfund 访问第 i 个结构,const 保护原数据。

14.6 把结构内容保存到文件

结构是天然的数据库构件:一个结构是一条记录(record),一个成员是一个字段(field)。用 fprintf() 逐字段存既啰嗦又难解析,正确姿势是 fread()/fwrite() 整块搬运——清单 14.14 booksave.c:

/* booksave.c -- 把结构内容保存到文件(book 模板与 14.1 相同,此处省略) */
#include <stdio.h>
#include <stdlib.h>
#define MAXTITL 40
#define MAXAUTL 40
#define MAXBKS 10    /* 藏书上限 */
int main(void)
{
    struct book library[MAXBKS]; /* 结构数组 */
    int count = 0;
    int index, filecount;
    FILE * pbooks;
    int size = sizeof (struct book);
 
    if ((pbooks = fopen("book.dat", "a+b")) == NULL)
    {
        fputs("Can't open book.dat file\n", stderr);
        exit(1);
    }
 
    rewind(pbooks);    /* 定位到文件开头 */
    while (count < MAXBKS && fread(&library[count], size, 1, pbooks) == 1)
    {
        if (count == 0)
            puts("Current contents of book.dat:");
        printf("%s by %s: $%.2f\n", library[count].title,
            library[count].author, library[count].value);
        count++;
    }
    filecount = count;
    if (count == MAXBKS)
    {
        fputs("The book.dat file is full.", stderr);
        exit(2);
    }
 
    puts("Please add new book titles.");
    puts("Press [enter] at the start of a line to stop.");
    while (count < MAXBKS && gets(library[count].title) != NULL && library[count].title[0] != '\0')
    {
        puts("Now enter the author.");
        gets(library[count].author);
        puts("Now enter the value.");
        scanf("%f", &library[count++].value);
        while (getchar() != '\n') continue;    /* 清空输入行 */
        if (count < MAXBKS)
            puts("Enter the next title.");
    }
    if (count > 0)
    {
        puts("Here is the list of your books:");
        for (index = 0; index < count; index++)
            printf("%s by %s: $%.2f\n", library[index].title,
                library[index].author, library[index].value);
        fwrite(&library[filecount], size, count - filecount, pbooks);
    }
    else
        puts("No books? Too bad.\n");
    puts("Bye.\n");
    fclose(pbooks);
    return 0;
}

程序脉络:

  • “a+b” 模式:能读全文件 + 追加写 + 二进制格式。文本部分用编辑器看得懂,value 的二进制部分看不懂——这正是要的效果。
  • 第一次 while 循环:用 fread() 一块一块(每块一个结构)读进数组并显示旧记录,filecount 记住旧记录条数;第二次 while 循环接收新书记录,追加到数组末尾。
  • 一次写完fwrite(&library[filecount], size, count - filecount, pbooks) 把新增的”结构个数 = count - filecount”块一口气写进文件,不必逐条循环。

fread/fwrite 让"记录"真正落地

一条 fwrite 存下整个结构,一条 fread 整条取回——不必管结构里有多少字段、字符串多长。每条记录大小相同,随机访问也极简单:第 n 条记录的位置就是 n × sizeof(struct book)。这是最朴素也最实用的”数据库”。

代价是浪费:书名只有 10 个字符也得占 40 字节,未用空间一并存盘。变长记录要靠链式结构与动态分配,那是后续章节的话题。

14.7 结构之后:链式数据形式

结构最激动人心的用途是发明新数据形式:队列、二叉树、哈希表、图……它们的共同套路是——每个结构(节点)除了数据,还携带指向同类型结构的指针,把节点串成链。下图是一棵二叉树,10 层可存 1023 个词,若按规律排列,最多 9 步就能找到任意一个词;而数组最坏要扫全部 1023 个元素。

/图:用结构搭出的二叉树

14.8 联合(union)

联合与结构语法几乎一样,区别只有一字:结构能同时存 int、double、char;联合同一时刻只能存其中之一,所有成员共享同一块内存(按最大成员分配):

union hold {
    int digit;
    double bigfl;
    char letter;
};
union hold fit;      // 8 字节(按最大的 double 分配)
fit.digit = 23;      // 存 23,用 2 字节
fit.bigfl = 2.0;     // 23 被清掉,存 2.0,用 8 字节
fit.letter = 'h';    // 2.0 被清掉,存 h,用 1 字节

点号决定当前用哪个成员,记住里面装的是什么类型是你的责任。典型应用:结构里一个 status 成员标记车型是自有还是租赁,联合成员据此决定存车主信息还是租赁公司信息——空间不浪费。

联合里"躺着"的是上次写入的那个成员

写入 digit 后去读 bigfl,得到的不是”另一种解释”,而是无效数据。联合不是类型转换工具,正确用法永远是”用什么成员写入,就用什么成员读出”。

14.9 枚举(enum)

enum 用符号名代表整型常量,纯粹为了可读性——color = bluecolor = 4 友好:

enum spectrum {red, orange, yellow, green, blue, violet};
enum spectrum color;
  • 默认从 0 开始编号:red=0, orange=1, …… 也可手动指定,未指定的接着顺延:enum feline {cat, lynx = 10, puma, tiger}; 则 puma=11, tiger=12。
  • 枚举常量本质是 int,可用在数组大小、switch 标签等任何整型常量的位置;但输入输出时要自己转换(用户输入 “orange” 字符串,程序查表换成枚举值)。清单 14.15 enum.c 的骨架:字符串数组 colors[] 的下标恰好用枚举值,strcmp 找到匹配后用 switch(color) 输出对应诗句——枚举把”下标”和”标签”优雅地串了起来。

顺带一提命名空间(namespace):结构/联合/枚举标记共享一个命名空间,与普通变量互不冲突,所以同一作用域里 struct rect {...};int rect; 合法——但容易困惑,不建议这么起名。

14.10 typedef 快览

typedef 给类型起别名(#define 是给预处理器的文本替换,typedef 由编译器处理且只作用于类型):

typedef unsigned char BYTE;
BYTE x, y[10], * z;      // 等价于 unsigned char ...
typedef char * STRING;   // STRING 即 char*
STRING name, sign;       // 两个都是指针

对比 #define STRING char *:那会让 STRING name, sign; 变成 char * name, sign;——只有 name 是指针,sign 只是 char。typedef 优势尽显。typedef 还常配合结构免写 struct 前缀(typedef struct {double x; double y;} rect;),也是 size_t、time_t 这类”平台自适应类型”的实现机制。注意:typedef 不创造新类型,只是贴标签。

14.11 函数与指针

函数指针

函数也有地址——机器码加载进内存的起始位置。声明函数指针的诀窍:先写出该类型的函数原型,把函数名换成 (*pf)。对比 void (*pf)(char *);(指向函数的指针)与 void *pf(char *);(返回指针的函数,完全不同!)。赋值时函数名即地址(不要加括号):pf = ToUpper; 合法,pf = round; 非法(类型不匹配),pf = ToLower(); 非法(那是调用)。调用有两种等价语法:(*pf)(mis);pf(mis);——ANSI C 都接受。

函数指针最常见的去处是当参数,让通用函数按需选择具体操作。C 库的 qsort() 就是这样实现对任意类型数组排序的。清单 14.16 func_ptr.c 用菜单选择大小写变换,核心结构:

void (*pfun)(char *);      // 声明函数指针
switch (choice)            // 菜单选择决定指针指向
{
    case 'u': pfun = ToUpper;    break;
    case 'l': pfun = ToLower;    break;
    case 't': pfun = Transpose;  break;
    case 'o': pfun = Dummy;      break;
}
show(pfun, copy);          // 把选中的函数传给 show()
 
void show(void (* fp)(char *), char * str)
{
    (*fp)(str);   // 用传入的函数处理 str
    puts(str);    // 显示结果
}

还可以把函数指针排成数组(不能有”函数数组”,但可以有”函数指针数组”):V_FP_CHARP arpf[4] = {ToUpper, ToLower, Transpose, Dummy};,菜单序号直接当下标,switch 都省了。

花式声明速读

读复杂声明的两条规则:[]() 优先级高于 *;它们彼此左结合。于是 int * risks[10]; 是 10 个指针组成的数组,int (* rusks)[10]; 是指向”10 个 int 的数组”的指针,char (* flump[3])(); 是 3 个函数指针组成的数组。

一套心智模型贯穿全章

结构 = 打包数据;结构数组 = 批量记录;结构套结构 = 层次化信息;结构指针 = 高效传参与链式结构;fread/fwrite = 记录落盘;union/enum/typedef = 换着花样描述数据。组合起来就能从零搭出队列、树、哈希表——第 17 章将正式动手。

关键概念与小结

  • 结构把多个(通常不同类型的)数据项装进一个对象;标记声明模板,.-> 访问成员。
  • 结构名不是地址;向函数传结构可选传指针(高效,配 const)、传值(安全,开销大)、传成员。
  • 现代还允许结构赋值、结构做返回值;C99 补充了指定初始化器、复合字面量与柔性数组。
  • 联合成员共享内存,一次只活一个;枚举造符号整型常量;typedef 给类型起别名。
  • 函数名即函数地址,函数指针让”把函数当参数传”成为现实。