为什么需要结构体:把相关数据绑在一起
写稍微大一点的程序时,你很快会遇到这种场景:一个“学生”有姓名、学号、三门课成绩。如果各自用一个独立数组来存,name[i]、id[i]、score[i] 全靠“下标 i 是同一个人”这种口头约定维持,一旦漏对齐就全错。结构体(struct)的本质,就是把逻辑上属于同一个实体的若干字段打包成一个新的自定义类型。
有了结构体,“一个学生”就是一个完整的值,可以整体传参、整体放进数组、整体写进文件。它不增加任何运行时魔法——编译器只是替你算出每个字段在这块连续内存里的偏移量。
/* 先约定一个新类型:一名学生长什么样 */
struct Student {
char name[24]; /* 姓名 */
int id; /* 学号 */
float score[3]; /* 三门课成绩 */
};
/* 于是数组里每一项都是“完整的一个学生” */
struct Student class[40];
struct 不改变内存布局的朴素性:字段按声明顺序相邻摆放,中间可能插入少量填充字节以满足对齐。这也是 sizeof 常常比你把字段肉眼相加要大的原因。
- struct把相关字段打包成一个自定义复合类型,字段相邻存储并可能对齐填充。
- ->结构体指针访问成员用箭头,普通结构体用点号。
- union多个成员共用同一块内存,同一时刻只应理解其中一个。
- 模块化头文件放声明、.c 放定义,用 include 守卫防止重复包含。
定义、声明与初始化
定义结构体类型时,可以在花括号后直接列出变量名;也可以用 typedef 给它起个短名字,省得每次都写 struct。初始化用花括号按顺序给值,C99 之后还能按字段名指定初始化,不必记顺序——这在字段多、又只填其中几个时特别好用。
#include <stdio.h>
typedef struct {
char *name;
int id;
float score[3];
} Student; /* 以后直接写 Student,不用再写 struct */
int main(void) {
/* 按顺序初始化 */
Student a = {"张伟", 2024001, {88.5, 91.0, 79.0}};
/* 按字段名初始化,未列出的字段自动清零 */
Student b = {.id = 2024002, .name = "李娜"};
printf("%s %d\n", a.name, a.id); /* 张伟 2024001 */
printf("%s %d\n", b.name, b.id); /* 李娜 2024002 */
return 0;
}
两条经验:一是未初始化的局部结构体字段是垃圾值,用 = {0} 或指定初始化把它整体清零最省心;二是结构体可以直接相互赋值,a = b; 会按字节复制所有字段,对纯数据结构既方便又安全。
结构体指针与 -> 运算符
结构体作为函数参数时,如果整个传值,会发生一次完整拷贝——字段一多,开销不小。更常见的做法是传指针,函数只拿到地址,既能改原对象,又很快。访问指针指向的成员,用的是箭头 -> 而不是点号。
#include <stdio.h>
typedef struct {
char *name;
int id;
float total;
} Student;
/* 传指针:函数内部能直接修改原结构体 */
void add_bonus(Student *s, float bonus) {
s->total += bonus; /* (*s).total 的简写 */
}
int main(void) {
Student z = {"张伟", 2024001, 258.5f};
add_bonus(&z, 5.0f);
printf("%.1f\n", z.total); /* 263.5 */
return 0;
}
记住这个对照:s.name 是“结构体本身”取成员,s->name 是“指向结构体的指针”取成员。s->x 完全等价于 (*s).x,箭头只是让代码少写一层括号。当你发现自己在写 (*p).field,就该换成 p->field 了。
位域:把标志位压进一个字节
有些字段只占一两个二进制位,比如开关状态、权限标志。如果每个都用一个 int,内存浪费明显。位域(bit-field)允许你声明“这个成员占几位”,把多个布尔标志打包进同一个字节或字里。
typedef struct {
unsigned int read : 1; /* 1 位:可读 */
unsigned int write : 1; /* 1 位:可写 */
unsigned int execute : 1; /* 1 位:可执行 */
unsigned int level : 4; /* 4 位:权限等级 0~15 */
} Perm;
int main(void) {
Perm p = {.read = 1, .write = 0, .execute = 1, .level = 7};
/* p 实际只占几个位,而不是四个 int */
return 0;
}
要清醒地认识到:位域虽然写法清爽,但位在内存里的具体排布顺序由编译器决定,跨平台、跨编译器未必一致。因此位域适合“省内存的内部标志”,不适合用来对齐硬件寄存器或网络协议字节序——后者应当用位运算(& | ~ <<)手动、显式地控制每一位。
联合体 union:一块内存多种解读
联合体(union)和结构体长得几乎一样,但它的所有成员共用同一块起始地址的内存,联合体的大小等于其最大成员的大小。它适合“同一数据,在不同时刻按不同类型解释”的场景。
| 对比项 | struct 结构体 | union 联合体 |
|---|---|---|
| 内存关系 | 各成员各占一块,顺序排列 | 所有成员共用同一块内存 |
| 大小 | 各成员大小之和(含填充) | 等于最大成员的大小 |
| 同时有效性 | 所有字段同时有效 | 同一时刻只应读最近写入的那个成员 |
| 典型用途 | 把相关数据打包 | 节省内存、做“带类型标签”的变体 |
typedef struct {
int type; /* 标签:1=整数, 2=浮点 */
union {
int i;
float f;
} data;
} Value;
Value v = {.type = 1, .data.i = 42};
/* 读哪个成员,必须先看 type 标签:v.type==1 才读 v.data.i */
这种“标签 + 联合体”的组合,是 C 语言里实现类似“可变类型”的经典手法,也是现代 C++ std::variant 的思想源头。关键纪律是:永远先看标签,再决定读哪个成员,否则就是把同一段内存按错误类型解读,得到莫名其妙的值。
枚举 enum 与 typedef:给类型起名字
当一个变量只应该取有限几个离散值时(比如状态机的状态、菜单的选项),用裸数字 0、1、2 会让代码满是“魔数”。枚举(enum)给这些常量起可读的名字,可读性立刻上来。
typedef enum {
STATE_IDLE, /* 0 */
STATE_RUNNING, /* 1 */
STATE_PAUSED, /* 2 */
STATE_STOPPED /* 3 */
} State;
void handle(State s) {
if (s == STATE_RUNNING) {
/* ... */
}
}
int main(void) {
State cur = STATE_RUNNING;
handle(cur);
return 0;
}
枚举本质上就是一组整型常量,默认从 0 依次递增,也可以显式赋值。配合 typedef,自定义类型用起来才顺手:typedef struct {...} Student; 之后,Student 就能当普通类型名用。把 typedef 想成“给已有类型起别名”,它不创造新类型,只是让名字更好写、语义更清楚。
模块化:头文件、守卫与多文件编译
当代码超过一个屏幕,全塞在一个 main.c 里会很难维护。C 的模块化约定很朴素:头文件(.h)放“对外承诺”——类型定义、函数声明;源文件(.c)放“具体实现”。别的文件用 #include 拿到声明,就能调用你的函数,而不必关心内部怎么写。
/* student.h —— 对外声明 */
#ifndef STUDENT_H_ /* include 守卫:防止被重复包含 */
#define STUDENT_H_
typedef struct {
char *name;
int id;
float total;
} Student;
void student_init(Student *s, const char *name, int id);
float student_average(const Student *s);
#endif /* STUDENT_H_ */
/* student.c —— 具体实现 */
#include "student.h"
#include <string.h>
void student_init(Student *s, const char *name, int id) {
s->name = (char *)name;
s->id = id;
s->total = 0.0f;
}
float student_average(const Student *s) {
return s->total / 3.0f;
}
三个要点:第一,每个头文件都要有 include 守卫(#ifndef / #define / #endif),否则一个头被两个头层层包含时就会出现“类型重定义”错误;第二,对外暴露的函数用声明进 .h,只在内部用的辅助函数加 static 限制在本文件内可见;第三,编译时把多个 .c 一起交给编译器:gcc main.c student.c -o app。
实战:一个模块化的学生成绩管理
把前面的知识点串起来:用结构体存学生,用 enum 表示菜单选项,把学生相关操作收进 student.c,主程序只负责交互。下面是主逻辑的骨架。
#include <stdio.h>
#include "student.h"
typedef enum { OP_ADD = 1, OP_SHOW, OP_EXIT } Op;
int main(void) {
Student cls[3];
student_init(&cls[0], "张伟", 2024001);
student_init(&cls[1], "李娜", 2024002);
student_init(&cls[2], "王强", 2024003);
for (int i = 0; i < 3; ++i) {
cls[i].total = 240.0f + i * 10.0f;
printf("%d %s 均分=%.1f\n",
cls[i].id, cls[i].name,
student_average(&cls[i]));
}
return 0;
}
这个小程序刻意把“数据结构”和“操作数据的函数”分到了两个文件:改成绩算法只动 student.c,改交互菜单只动 main.c,两边通过 student.h 里的声明对接。这就是 C 语言里最朴素也最耐用的工程组织方式。
提交代码前,用这份清单快速自查:
- 结构体是否用来“打包相关数据”,而不是又散落成一堆平行数组?
- 传参给函数时,大结构体是否传了指针而不是整份拷贝?
- 读 union 前,是否先看了它的类型标签?
- 每个头文件是否都有 include 守卫?
- 内部辅助函数是否加了
static,没有暴露成全局符号?
struct、union、enum 看起来是语法小事,但它们决定了你怎么组织“一个实体”。一旦习惯用类型系统去描述数据,代码就从一堆变量长成了一座结构清楚的小房子。
下一篇我们会进入 Rust:它把“一块数据归谁所有、能不能同时被几处借用”直接写进了编译器规则,连手动内存管理都省了。