自定义类型
目录
1.6.1 对齐核心规则(VS 环境默认对齐数 = 8,gcc 无默认对齐数)
✨引言:
在 C 语言中,内置类型(char、int、float等)只能描述简单数据,但现实中的数据往往是复杂的(比如 “一本书”“一个人”“一份礼品单”)。这时就需要自定义类型来组合不同类型的数据,实现复杂对象的描述。本文将从 “基础用法→底层原理→实战应用” 三层,详细拆解结构体、联合体、枚举三大自定义类型。
1. 结构体(struct):复杂对象的 “组合框架”
结构体就像一个 “收纳盒”,可以把不同类型的数据(成员变量)装在一起,描述一个复杂对象。比如用结构体描述 “一本书”,需要包含书名、作者、价格、书号等不同类型的信息。
1.1 结构体声明与变量定义
核心语法:struct 结构体名 { 成员变量列表; } 变量名;变量定义分三种:全局变量、局部变量、结构体数组。
#define _CRT_SECURE_NO_WARNINGS 1
#include <stdio.h>
// 声明一个结构体:描述“书”的框架
struct Book {
char name[20]; // 书名(字符串)
char author[20]; // 作者(字符串)
float price; // 价格(浮点型)
char id[13]; // 书号(字符串,如ISBN)
} b4, b5, b6; // 声明时直接定义变量(全局变量,整个文件可用)
struct Book b2; // 全局变量(声明后单独定义)
int main() {
struct Book b1; // 局部变量(仅main函数内可用)
struct Book book_arr[5]; // 结构体数组(存储5本书的信息)
return 0;
}
💡 注意:结构体名是 “类型名”,就像int一样,必须结合struct使用(除非用typedef重命名)。
1.2 结构体初始化(顺序 / 指定成员)
初始化分两种方式:顺序初始化(按成员顺序赋值)和指定成员初始化(不按顺序,用.成员名指定),后者更灵活。
int main() {
// 1. 顺序初始化:必须按结构体成员声明顺序赋值
struct Book b1 = { "鹏哥C语言", "鹏哥", 18.8f, "PG10001" };
// 2. 指定成员初始化:可跳过成员、打乱顺序(C99支持)
struct Book b2 = {
.id = "PG10002", // 直接指定成员名赋值
.author = "鹏哥",
.name = "C语言程序设计",
.price = 8.8f
};
// 打印验证
printf("书名:%s,作者:%s,价格:%.1f\n", b2.name, b2.author, b2.price);
return 0;
}
✅ 输出:书名:C语言程序设计,作者:鹏哥,价格:8.8
1.3 特殊声明:匿名结构体(仅用一次)
如果结构体只需要使用一次,可以省略结构体名(匿名),但无法重复定义该类型的变量。
// 匿名结构体:无类型名,仅定义变量s(全局)
struct {
char c; // 字符
int i; // 整数
double d; // 双精度浮点
} s = { 'x', 100, 3.14 };
int main() {
printf("%c %d %.2lf\n", s.c, s.i, s.d); // 输出:x 100 3.14
return 0;
}
// ❌ 易错点:两个匿名结构体即使成员完全相同,也是不同类型!
struct { char c; int i; double d; } s1;
struct { char c; int i; double d; } *ps;
int main() {
ps = &s1; // 编译报错:类型不兼容(编译器认为是两个不同结构体)
return 0;
}
1.4 结构体自引用(链表节点核心)
结构体自引用是指结构体成员中包含 “指向自身类型的指针”,这是实现链表、树等数据结构的核心。
❌ 错误写法:直接包含自身类型成员(结构体大小无限,无法计算)
struct Node {
int data; // 数据域
struct Node n; // 错误:结构体包含自身,大小无穷大
};
✅ 正确写法:包含自身类型的指针(指针大小固定,如 4/8 字节)
struct Node {
int data; // 数据域(存储实际数据)
struct Node* next;// 指针域(指向同类型下一个节点)
};
int main() {
struct Node n1; // 节点1
struct Node n2; // 节点2
n1.next = &n2; // 链表串联:n1指向n2
return 0;
}
💡 比喻:每个节点就像 “一节火车车厢”,data是车厢里的货物,next是连接下一节车厢的挂钩。
1.5 结构体重命名(typedef 用法与易错点)
用typedef可以给结构体重命名,简化书写(无需每次写struct),但要注意重命名的顺序。
❌ 错误写法:重命名前使用新类型名
typedef struct Node {
int data;
Node* next; // 错误:此时Node还未完成重命名,编译器不识别
} Node; // 重命名struct Node为Node
✅ 正确写法:重命名前用原始类型名
// 方式1:先声明结构体,再重命名
struct Node {
int data;
struct Node* next; // 正确:用原始类型名struct Node
};
typedef struct Node Node; // 重命名后,可直接用Node定义变量
// 方式2:声明+重命名合并(推荐)
typedef struct Node {
int data;
struct Node* next; // 仍需用struct Node,因为Node还在定义中
} Node;
int main() {
Node n; // 简化书写,等价于struct Node n
return 0;
}
1.6 结构体内存对齐(底层规则 + 计算实战)
结构体的成员在内存中不是连续排列的,而是遵循内存对齐规则—— 这是为了平衡 “平台兼容性” 和 “访问效率”(牺牲少量空间,换取更快的读取速度)。
1.6.1 对齐核心规则(VS 环境默认对齐数 = 8,gcc 无默认对齐数)
- 第一个成员对齐到结构体起始地址(偏移量 = 0);
- 后续成员对齐到「自身大小」和「默认对齐数」的较小值(称为 “实际对齐数”)的整数倍地址;
- 结构体总大小是「所有成员实际对齐数的最大值」的整数倍;
- 嵌套结构体:嵌套的结构体成员对齐到其内部最大对齐数的整数倍,总大小仍遵循规则 3。
1.6.2 对齐数定义
实际对齐数 = min(成员变量大小, 编译器默认对齐数)例:int大小 = 4,VS 默认对齐数 = 8 → 实际对齐数 = 4。
1.6.3 4 个计算实战(逐行拆解)
练习 1:成员顺序影响大小
struct S1 {
char c1; // 大小=1,实际对齐数=1 → 偏移量0
int i; // 大小=4,实际对齐数=4 → 偏移量4(0+1不够4,补3字节)
char c2; // 大小=1,实际对齐数=1 → 偏移量8(4+4=8)
}; // 最大对齐数=4,总大小需是4的整数倍 → 8+1=9→补3→12
int main() {
printf("%zd\n", sizeof(struct S1)); // 输出:12
}
练习 2:优化成员顺序(节省空间)
struct S2 {
char c1; // 偏移量0
char c2; // 偏移量1(1是1的整数倍)
int i; // 偏移量4(1+1=2不够4,补2字节)
}; // 最大对齐数=4,总大小=4+4=8(无需补位)
int main() {
printf("%zd\n", sizeof(struct S2)); // 输出:8
}
💡 优化技巧:将占用空间小的成员集中在一起,减少补位浪费。
练习 3:包含 double 类型
struct S3 {
double d; // 大小=8,实际对齐数=8 → 偏移量0
char c; // 大小=1,实际对齐数=1 → 偏移量8
int i; // 大小=4,实际对齐数=4 → 偏移量12(8+1=9不够12,补3字节)
}; // 最大对齐数=8,总大小=12+4=16(16是8的整数倍)
int main() {
printf("%zd\n", sizeof(struct S3)); // 输出:16
}
练习 4:嵌套结构体
struct S3 { double d; char c; int i; }; // 大小=16,内部最大对齐数=8
struct S4 {
char c1; // 偏移量0
struct S3 s3; // 对齐到8的整数倍 → 偏移量8(0+1不够8,补7字节)
double d; // 大小=8,实际对齐数=8 → 偏移量8+16=24
}; // 最大对齐数=8,总大小=24+8=32(32是8的整数倍)
int main() {
printf("%zd\n", sizeof(struct S4)); // 输出:32
}
1.6.4 为什么需要内存对齐?
- 平台兼容性:某些硬件只能访问特定地址的数据(如只能访问 4 的整数倍地址),不对齐会导致程序崩溃;
- 访问效率:CPU 读取内存时按 “块” 读取(如每次读 8 字节),对齐后数据只需读一次,未对齐可能需要读两次。
1.6.5 修改默认对齐数(#pragma pack)
用#pragma pack(n)可以修改默认对齐数(n 必须是 2 的幂,如 1、2、4、8),#pragma pack()恢复默认。
#pragma pack(1) // 设置默认对齐数=1(取消对齐,紧凑排列)
struct S {
char c1; // 偏移0
int i; // 偏移1(1是1的整数倍)
char c2; // 偏移5
}; // 总大小=1+4+1=6(无需补位)
#pragma pack() // 恢复默认对齐数
int main() {
printf("%zd\n", sizeof(struct S)); // 输出:6
}
1.6.6 计算成员偏移量(offsetof 宏)
offsetof(type, member) 是 C 语言内置宏,用于计算结构体成员相对于起始地址的偏移量(头文件<stddef.h>)。
#include <stddef.h>
struct S1 { char c1; int i; char c2; };
int main() {
printf("c1偏移量:%zd\n", offsetof(struct S1, c1)); // 0
printf("i偏移量:%zd\n", offsetof(struct S1, i)); // 4
printf("c2偏移量:%zd\n", offsetof(struct S1, c2)); // 8
return 0;
}
1.7 结构体传参(传值 vs 传地址)
结构体传参有两种方式:传值(拷贝整个结构体)和传地址(传递指针),推荐用传地址。
struct S {
int arr[1000]; // 大数组,结构体体积大
int n;
double d;
};
// 方式1:传值(拷贝整个结构体,浪费空间和时间)
void print1(struct S tmp) {
for (int i = 0; i < 5; i++) {
printf("%d ", tmp.arr[i]); // 访问拷贝后的成员
}
}
// 方式2:传地址(仅传递4/8字节指针,高效)
// const修饰:防止意外修改原结构体
void print2(const struct S* ps) {
for (int i = 0; i < 5; i++) {
printf("%d ", ps->arr[i]); // 指针访问成员用->
}
printf("%d %.2lf\n", ps->n, ps->d);
}
int main() {
struct S s = { {1,2,3,4,5}, 100, 3.14 };
print1(s); // 传值:拷贝s的所有数据(1000+4+8字节)
print2(&s); // 传地址:仅拷贝s的地址(4/8字节)
return 0;
}
💡 原理:函数传参时参数会压栈,结构体过大会导致压栈开销大、性能下降,传地址可避免此问题。
1.8 结构体位段(内存优化神器)
位段是结构体的 “精简版”,通过指定成员占用的二进制位数(bit) 来节省内存,适用于不需要完整字节的场景(如网络协议、硬件配置)。
1.8.1 位段的声明规则
- 成员必须是
int、unsigned int、signed int(C99 支持char等整数类型); - 成员名后加
:和数字(表示占用的 bit 数)。
// 位段声明:每个成员指定占用的bit数
struct A {
int _a : 2; // 占用2bit,取值范围:0~3(00、01、10、11)
int _b : 5; // 占用5bit,取值范围:0~31
int _c : 10; // 占用10bit,取值范围:0~1023
int _d : 30; // 占用30bit,取值范围:0~2^30-1
};
struct B { // 普通结构体(对比用)
int _a;
int _b;
int _c;
int _d;
};
int main() {
printf("位段大小:%zd\n", sizeof(struct A)); // 8字节(2+5+10+30=47bit≈6字节→对齐到8字节)
printf("普通结构体大小:%zd\n", sizeof(struct B)); // 16字节(4×4)
return 0;
}
✅ 输出:位段大小:8,普通结构体大小:16 → 节省 50% 内存!
1.8.2 位段的内存分配细节(VS 环境)
- 空间按 “4 字节(int)” 或 “1 字节(char)” 开辟,优先满足当前成员;
- 同一开辟的空间内,成员从右向左使用(VS 规则,其他编译器可能从左向右);
- 剩余空间不足时,直接浪费,开辟新空间。
struct S {
char a : 3; // 占用1字节的3bit
char b : 4; // 占用同一字节的4bit(剩余1bit)
char c : 5; // 剩余1bit不够,浪费,开辟新字节(占用5bit)
char d : 4; // 开辟新字节(占用4bit)
};
int main() {
printf("%zd\n", sizeof(struct S)); // 3字节(1+1+1)
return 0;
}
💡 内存布局(VS 环境):
- 第 1 字节:
b(4bit) + a(3bit)→ 剩余 1bit 浪费; - 第 2 字节:
c(5bit)→ 剩余 3bit 浪费; - 第 3 字节:
d(4bit)→ 剩余 4bit 浪费。
1.8.3 位段的跨平台问题(⚠️ 重点)
位段的内存分配规则未完全标准化,导致跨平台兼容性差:
int位段的符号性不确定(有的编译器视为有符号,有的视为无符号);- 成员存储方向(左→右 / 右→左)不确定;
- 剩余空间是否复用不确定;
- 最大 bit 数限制(16 位编译器最大 16bit,32 位最大 32bit)。
✅ 结论:位段适合单平台场景(如嵌入式硬件配置),跨平台需谨慎使用。
1.8.4 位段的实际应用(IP 协议头)
位段最经典的应用是网络协议头(如 IP 数据报),通过精准分配 bit 数,节省网络传输带宽:
// IP协议头简化模型(用位段描述)
struct IPHeader {
unsigned int version : 4; // 版本号(4bit)
unsigned int header_len : 4; // 首部长度(4bit)
unsigned int service_type : 8; // 服务类型(8bit)
unsigned int total_len : 16; // 总长度(16bit)
unsigned int id : 16; // 标识符(16bit)
// ... 其他字段
};
1.8.5 位段的注意事项(不能用 & 操作符)
位段成员可能不占用完整字节,因此没有独立地址,不能用&取地址,也不能直接用scanf输入:
struct A {
int _a : 2;
int _b : 5;
};
int main() {
struct A sa;
// scanf("%d", &sa._b); // ❌ 错误:不能取位段成员地址
int b = 0;
scanf("%d", &b);
sa._b = b; // ✅ 正确:先存到普通变量,再赋值
return 0;
}
2. 联合体(union):数据的 “共享空间”
联合体(又称 “共用体”)的核心特点是所有成员共用同一块内存空间,就像 “多人共享一个房间”,同一时间只能有一个成员使用空间,节省内存。
2.1 联合体声明与核心特点
关键字:union,声明语法与结构体类似,但成员共用空间。
// 联合体声明
union Un {
char c; // 1字节
int i; // 4字节
};
int main() {
union Un u;
// 特点1:所有成员地址相同(共用空间)
printf("u的地址:%p\n", &u);
printf("u.c的地址:%p\n", &u.c);
printf("u.i的地址:%p\n", &u.i);
// 特点2:大小至少是最大成员的大小(4字节)
printf("联合体大小:%zd\n", sizeof(u)); // 输出:4
return 0;
}
✅ 输出:三个地址完全相同,大小为 4 字节 → 验证 “共用空间”。
关键特性:修改一个成员,会覆盖其他成员
int main() {
union Un u;
u.i = 0x11223344; // 小端环境下内存:44 33 22 11
printf("u.i = 0x%x\n", u.i); // 0x11223344
u.c = 0x55; // 修改char成员,覆盖低地址1字节 → 内存:55 33 22 11
printf("u.i = 0x%x\n", u.i); // 0x11223355(被覆盖)
return 0;
}
💡 原理:u.c占用低地址 1 字节,修改后直接覆盖u.i的低字节数据。
2.2 联合体大小计算(对齐规则)
联合体大小遵循两个规则:
- 至少是最大成员的大小(保证能容纳最大成员);
- 必须是最大对齐数的整数倍(对齐规则与结构体一致)。
练习 1:成员为数组 + int
union Un1 {
char c[5]; // 大小5字节,实际对齐数1
int i; // 大小4字节,实际对齐数4
}; // 最大成员大小=5,最大对齐数=4 → 5不是4的整数倍→补3→8
int main() {
printf("%zd\n", sizeof(union Un1)); // 输出:8
}
练习 2:成员为短数组 + int
union Un2 {
short c[7]; // 大小14字节,实际对齐数2
int i; // 大小4字节,实际对齐数4
}; // 最大成员大小=14,最大对齐数=4 → 14不是4的整数倍→补2→16
int main() {
printf("%zd\n", sizeof(union Un2)); // 输出:16
}
2.3 联合体实战应用
应用 1:判断机器大小端(比结构体更简洁)
利用 “成员共用空间”,通过低地址字节的值判断大小端:
int check_sys() {
union Un {
char c;
int i;
} u;
u.i = 1; // 内存:小端→01 00 00 00,大端→00 00 00 01
return u.c; // 小端返回1,大端返回0
}
int main() {
printf("%s\n", check_sys() ? "小端" : "大端"); // 输出:小端(x86环境)
return 0;
}
应用 2:存储互斥数据(节省内存)
当多个数据 “不会同时使用” 时,用联合体存储可大幅节省空间。例如 “礼品兑换单”:
// 礼品兑换单:图书、杯子、衬衫(互斥,一次只能兑换一种)
struct GiftList {
int stock; // 库存量(公共属性)
double price; // 价格(公共属性)
int type; // 类型:1=图书,2=杯子,3=衬衫(公共属性)
// 互斥属性用联合体存储
union {
struct {
char title[20]; // 书名
char author[20]; // 作者
int pages; // 页数
} book;
struct {
char design[30]; // 设计方案
} mug;
struct {
char design[30]; // 设计方案
int colors; // 可选颜色数
int sizes; // 可选尺寸数
} shirt;
} item; // 联合体成员
};
int main() {
struct GiftList gl;
gl.type = 1; // 兑换图书
strcpy(gl.item.book.title, "C语言编程");
printf("书名:%s\n", gl.item.book.title); // 输出:C语言编程
return 0;
}
💡 优势:如果用结构体存储所有属性,大小会更大;用联合体仅存储当前需要的属性,节省内存。
3. 枚举(enum):有限值的 “一一列举”
枚举用于描述 “有限个可选值” 的场景(如星期、月份、菜单选项),关键字enum,本质是 “有名字的整数常量”。
3.1 枚举声明与赋值规则
枚举成员默认从0开始递增,也可手动指定值(未指定的成员继承前一个值 + 1)。
// 枚举1:默认赋值(0,1,2)
enum Color {
RED, // 0
GREEN, // 1
BLUE // 2
};
// 枚举2:手动指定起始值(5,6,7)
enum Color2 {
RED2 = 5,
GREEN2, // 6
BLUE2 // 7
};
// 枚举3:跳跃赋值(0,5,6)
enum Color3 {
RED3, // 0
GREEN3 = 5,
BLUE3 // 6
};
int main() {
printf("RED=%d, GREEN=%d, BLUE=%d\n", RED, GREEN, BLUE); // 0 1 2
printf("RED2=%d, GREEN2=%d, BLUE2=%d\n", RED2, GREEN2, BLUE2); //5 6 7
return 0;
}
💡 注意:枚举成员是常量,不能修改(如RED = 10编译报错)。
3.2 枚举的优势(对比 #define)
虽然#define也能定义常量,但枚举有明显优势:
| 特性 | 枚举(enum) | #define 宏 |
|---|---|---|
| 类型安全 | 有明确类型(如 enum Color) | 无类型,仅文本替换 |
| 调试支持 | 调试时可见成员名 | 预处理阶段被替换,不可见 |
| 批量定义 | 一次定义多个相关常量 | 需逐个定义,繁琐 |
| 作用域 | 遵循作用域规则(如局部枚举仅局部可用) | 全局有效,易冲突 |
// 用枚举定义菜单选项(推荐)
enum Option {
EXIT = 0, // 0:退出
ADD, // 1:加法
SUB, // 2:减法
MUL, // 3:乘法
DIV // 4:除法
};
3.3 枚举实战:菜单程序
枚举让菜单选项更具可读性和维护性,避免硬编码数字:
#include <stdio.h>
// 枚举菜单选项
enum Option {
EXIT = 0,
ADD,
SUB,
MUL,
DIV
};
// 加法函数
int Add(int a, int b) { return a + b; }
// 减法函数
int Sub(int a, int b) { return a - b; }
// 乘法函数
int Mul(int a, int b) { return a * b; }
// 除法函数(处理除数为0)
int Div(int a, int b) {
if (b == 0) {
printf("除数不能为0!\n");
return 0;
}
return a / b;
}
// 菜单打印
void menu() {
printf("********************************\n");
printf("******** 1.ADD 2.SUB ********\n");
printf("******** 3.MUL 4.DIV ********\n");
printf("******** 0.EXIT ********\n");
printf("********************************\n");
}
int main() {
int input = 0;
int a = 0, b = 0, ret = 0;
do {
menu();
printf("请选择:");
scanf("%d", &input);
switch (input) {
case ADD:
printf("请输入两个数:");
scanf("%d%d", &a, &b);
ret = Add(a, b);
printf("结果:%d\n", ret);
break;
case SUB:
printf("请输入两个数:");
scanf("%d%d", &a, &b);
ret = Sub(a, b);
printf("结果:%d\n", ret);
break;
case MUL:
printf("请输入两个数:");
scanf("%d%d", &a, &b);
ret = Mul(a, b);
printf("结果:%d\n", ret);
break;
case DIV:
printf("请输入两个数:");
scanf("%d%d", &a, &b);
ret = Div(a, b);
printf("结果:%d\n", ret);
break;
case EXIT:
printf("退出程序!\n");
break;
default:
printf("选择错误,请重新输入!\n");
break;
}
} while (input != EXIT);
return 0;
}
✅ 运行效果:菜单选项与枚举成员一一对应,代码可读性强,后续修改选项顺序无需改逻辑。
4. 三大自定义类型对比(适用场景 + 区别)
| 类型 | 核心特点 | 内存布局 | 适用场景 |
|---|---|---|---|
| 结构体 | 成员独立,类型可不同 | 成员按对齐规则排列 | 描述复杂对象(如书、人、礼品单) |
| 联合体 | 成员共用空间,类型可不同 | 所有成员起始地址相同 | 存储互斥数据、判断大小端 |
| 枚举 | 成员是有名字的整数常量 | 仅存储常量值(无实例) | 描述有限可选值(如菜单、状态码) |
核心区别总结
- 内存占用:结构体(成员大小之和 + 补位)> 联合体(最大成员大小 + 补位),枚举不占实例内存;
- 数据关系:结构体成员 “同时存在”,联合体成员 “互斥存在”,枚举成员 “相互独立”;
- 用途侧重:结构体→组合复杂数据,联合体→节省内存,枚举→规范常量。
🎯 最后总结
自定义类型是 C 语言实现复杂逻辑的核心工具:
- 结构体是 “组合器”,适合描述有多个属性的复杂对象,需掌握内存对齐规则和传参技巧;
- 联合体是 “共享器”,适合存储互斥数据,利用共用空间节省内存,可用于大小端判断;
- 枚举是 “规范器”,适合描述有限可选值,比
#define更安全、更易维护。
掌握这三大自定义类型,能让你的代码更简洁、更高效、更具可读性,无论是日常开发还是面试(如内存对齐计算、大小端判断、链表实现),都是高频考点!
如果这篇博客帮你打通了自定义类型的 “任督二脉”,欢迎点赞收藏🌟~
1615

被折叠的 条评论
为什么被折叠?



