结构体(Struct)
Java 中有 class 类,在 C 语言中则有 struct 结构体。
结构体的定义与操作
我们先来看看结构体的基本使用:
c
#include <stdio.h>
#include <string.h>
// 定义一个结构体
struct Student {
char name[16];
int age;
float score;
};
void printStudent(struct Student stu) {
printf("name: %s\nage: %d\nscore: %.2f\n\n", stu.name, stu.age, stu.score);
}
int main() {
// 定义结构体变量
struct Student stu1 = { "Martin", 21, 94.0f };
printStudent(stu1);
printf("====================\n");
// 修改成员属性
stu1.age = 22;
stu1.score = 89.0f;
const char* p = "Jack";
// 清空原字符串并拷贝新字符串
memset(stu1.name, 0, sizeof(stu1.name));
strcpy_s(stu1.name, sizeof(stu1.name), p);
printStudent(stu1);
return 0;
}
注意:在标准的 C 语言中(.c),定义变量时的 struct 关键字不能省略 ,我们必须这样 struct Student stu1;。但如果是在 .cpp 文件中就不会报错, 因为 C++ 允许省略。
我们也可以在定义结构体时,顺便定义出变量,方便使用。
c
struct Student {
char name[16];
int age;
float score;
} stu1 = { "Brian", 23, 85.0f }, stu2;
int main() {
printStudent(stu1);
printf("======================\n");
stu2.age = 25;
stu2.score = 77;
char name[] = "Mark";
strcpy_s(stu2.name, sizeof(stu2.name), name);
printStudent(stu2);
return 0;
}
与直接定义一个结构体变量(struct Student stu1;)不同的是,顺便定义出的 stu2 会有默认值,换个说法:每个属性都会是其默认值。以 name 属性为例,值是 "" 空字符串。
结构体的嵌套
结构体之间还可能会进行嵌套:
c
// 地址信息
struct Address {
char city[20]; // 城市
char district[20]; // 区
};
struct Student {
char name[16];
int age;
float score;
struct Address addr;
};
void printStudent(struct Student stu) {
printf("student info:\n");
printf("name: %s\nage: %d\nscore: %.2f\n", stu.name, stu.age, stu.score);
printf("city: %s\ndistrict: %s\n\n", stu.addr.city, stu.addr.district);
}
int main() {
struct Student stu = {
"Brian",
23,
85.0f,
{"Shanghai", "Pudong"}
};
printStudent(stu);
return 0;
}
嵌套的结构体初始化时,只需使用 {} 包裹数据即可,这点和普通结构体的初始化相同。
结构体的定义也可以放到结构体内部:
c
struct Student {
char name[16];
int age;
float score;
struct Address {
char city[20];
char district[20];
};
struct Address addr;
};
也能顺手定义出变量:
c
// 也能顺手定义出变量:
struct Student {
char name[16];
int age;
float score;
struct Address {
char city[20];
char district[20];
} addr; // 顺手定义出变量 addr
};
这样嵌套定义后,如果我们有一个学生变量 struct Student stu;,我们还是可以通过 stu.addr.city 来正常访问嵌套结构体里的数据。
结构体指针
结构体指针(变量)的定义方式是这样的:
c
struct Student stu = {
"Alan",
26,
96.0f,
};
struct Student* stu_p = &stu; // 注意:这里的 struct 同样不能省略
它与结构体变量的区别在于,访问成员时,结构体变量用的是 .,结构体指针用的是 -> 箭头运算符,例如:
c
void printStudent(struct Student* stu_p) {
struct Student* p = stu_p;
printf("student info:\n");
// 使用 -> 或 (*p). 访问
printf("name: %s\nage: %d\nscore: %.2f\n\n", p->name, p->age, (*p).score);
}
结构体指针除了取一个结构体变量的地址外,我们可以直接为指针开辟空间(之前学指针的时候就开始用了):
c
struct Student* stu_p = (struct Student*)malloc(sizeof(struct Student)); // 需要导入:#include <malloc.h>
// 省略分配内存失败及释放内存的代码
结构体数组
我们再来看看结构体数组,它的定义与使用和普通数组类似:
c
int main() {
// 静态开辟
struct Student stu_arr[3] = {
{"Jack", 21, 88},
{"Mark", 24, 92},
{"Leon", 19, 76}
};
for (size_t i = 0; i < 3; i++)
{
printStudent(stu_arr[i]);
}
// 动态开辟,stu_arr_2指向首元素的地址
struct Student* stu_arr_2 = (struct Student*)malloc(sizeof(struct Student) * 3);
if (stu_arr_2 == NULL) {
free(stu_arr_2);
stu_arr_2 = NULL;
return -1;
}
strcpy_s(stu_arr_2[0].name, sizeof(stu_arr_2[0].name), "Brain");
stu_arr_2->age = 25;
stu_arr_2->score = 78.0;
printStudent(*stu_arr_2);
return 0;
}
字节对齐(内存对齐)
int 类型的指针步长是 4 个字节,那结构体指针的步长呢?
当然就是结构体的大小,以之前的 Student 为例,它的指针步长是 32。这...Student 的大小不是 28 吗?别急,这涉及到了字节对齐。
对齐有几个要求:
- 每个成员的起始偏移,必须是自身类型大小的整数倍。
- 对于嵌套的结构体,必须按其内部最大基础类型的大小进行对齐。(注意对齐基准不是它自身的总大小)
- 数组必须按其元素类型的大小进行对齐。(因为数组在内存中是连续分配的,所以只要首元素对齐了,后面的元素自然也就对齐了)
- 整个结构体的大小,必须是内部最大对齐数的整数倍。(最大对齐数是所有成员的对齐要求的最大值,包括嵌套结构体中的基础成员)
编译器默认有最大对齐上限(通常是 8 字节,相当于使用
#pragma pack(8)),如果成员对齐数不超过 8,就按成员自身的类型大小对齐,否则会截断为上限大小。
现在应该就明白了:正常来说,Student 的大小是 16+4+8=28。但根据规则三得知 name 的对齐要求是 1 字节;根据规则一得知:age 的对齐要求是 4 字节、score 的对齐要求是 8 字节;根据规则四,得知整体的对齐基准是 8 字节(最大的基础类型是 double)。
然后我们开始算大小,首先是成员进行对齐:name 的起始偏移是 0,正是自身对齐要求 1 的倍数。接着摆放 age,因为 name 占 16 个字节,所以 age 的起始偏移能放到 16,也符合 4 的倍数。接着放 score,我们要填充 4 个字节,这样 score 的起始偏移才能为 8 的倍数(24)。
成员都对齐后,再来看整体的对齐,结构体此时的大小是 32,是 8 的倍数,所以尾部无需额外填充。因此 Student 的大小是 32 字节。
c
struct Student {
char name[16]; // 1
int age; // 4
double score; // 8
};
再来看一个嵌套的例子:
c
struct BirthDate {
int year;
int month;
int day;
};
struct Student {
char name[10];
int age;
int score;
char sex;
struct BirthDate birth;
};
首先看 BirthDate 的大小:
year 放在 0~3,month 放在 4~7,day 放在 8~11,每个成员的偏移都是其类型大小的倍数(4),接着看整体,整体的大小(12)是内部最大基础成员(4)的倍数,无需进行尾部填充。
然后看 Student 的大小:
name 放 0~9,age 放 12~15,name 和 age 的中间填充两个字节(10、11),score 放 16~19,sex 放 20,最后 sex 和 birth 之间填充 3 个字节,birth 放 24~35,这样偏移才是 4 的倍数。
最终 Student 的大小就是 36,你学会了吗?
那为什么需要字节对齐呢?
答:为了提高存储的访问效率。硬件访问数据是一次性访问一块数据,字节对齐可以让数据尽可能排布到较少的数据块上,从而减少硬件访问存储的次数。
typedef 取别名
前面我们多次提到定义结构体变量时不能省略 struct 关键字,为了解决这个问题,我们可以使用 typedef 关键字给结构体取别名:
c
typedef struct Student {
char name[16];
int age;
double score;
} Student; // 这里定义的是类型别名,不是变量名
除了在定义结构体时直接取别名,我们也可以给已经存在的结构体或指针单独取别名:
c
// 语法:typedef 原类型 别名;
typedef struct SchoolStudent Student; // 给 struct SchoolStudent 取别名为 Student
typedef struct Student* StudentPtr; // 给结构体指针取别名为 StudentPtr
这么做的目的是什么?
- 省略定义结构体变量时的
struct关键字,让代码更加简洁(Student student;)。 - 方便使用,将一大串的结构体名称进行缩短。
联合体(Union)
再来看看联合体,它和结构体很像,定义方式如下:
c
union Teacher {
char name[16];
int age;
char professional_title[26]; // 职称
};
联合体和结构体的使用几乎一致,只是它的成员同一时间只能存在一个,例如:
c
int main() {
// union Teacher t1 = { "Color", 59 }; // 报错:初始值设定项值太多
union Teacher t2 = { .age = 59 }; // 或者 { "Color" };
strcpy_s(t2.name, sizeof(t2.name), "Color");
printf("%s-%d\n", t2.name, t2.age); // 结果可能是 Color-1869377347
return 0;
}
这里 age 打印出来的是未初始化的随机值,不是因为内存被释放了,而是内存覆盖 。在联合体中所有成员会共享同一块内存地址 ,所以我们在给 t2.name 赋值为 "Color" 时,这几个字符的 ASCII 值覆盖了 age 之前所在的内存空间,所以此时打印 age,实际上是 "Color" 的前 4 个字符的内存二进制数据,直接当做整型解析出来的一个数值,看起来有点像乱码而已。
联合体的大小计算:
-
成员对齐后的大小是最大成员的大小,在我们的例子中是 26,因为同一时刻,只能存在一个成员。
-
然后是联合体整体的对齐,联合体大小必须是内部最大对齐数的整数倍,这里是 4,所以联合体会填充 2 个字节,最终大小是 28。
枚举(Enum)
最后是枚举。枚举在 C 语言中其实是 int 类型,第一个枚举变量的值默认是 0,后续的枚举变量会依次递增(+1)。
c
enum Result {
Success, // 0
Fail // 1
};
我们也可以手动改值:
c
enum Week {
MONDAY = 1,
TUESDAY = 2,
WEDNESDAY = 3,
THURSDAY = 4,
FRIDAY = 5,
SATURDAY = 6,
SUNDAY = 7
};
要么全都不赋值(从 0 开始自动递增),要么全都手动赋值,或者只给第一个枚举成员赋值(比如 MONDAY = 1)。这样不容易出错,因为不同枚举变量的值是可以相同的。