在C语言中,结构体(struct)是一种自定义的数据类型,它允许你将多个不同类型的变量组合在一起。
这些变量被称为“成员”或“字段”。
结构体类型一般定义在函数外部。
比如我需要在程序中使用一个学生student对象,同时处理一个学生的所有数据信息,就可以定义一个student结构体类型:
// 学生的属性有: 学号,姓名,性别,语文成绩,数学成绩,英语成绩... struct student { int stu_id; // 整型值学号,用于唯一标识一个学生 char name[25]; // 表示学生姓名的字符串数组,长度为25 char gender; //f: female, m: male int chinese; // 整型语文成绩 int math; // 整型数学成绩 int english; // 整型英语成绩 }; // 注意不要忘记末尾的分号
解释:
- 结构体定义使用关键字
struct
,在这里,struct student 表示定义了一个新的结构体类型。- student是一个标识符,是此结构体类型的名字,此结构体类型包含六个成员。
- 结构体类型的名字建议采用"单词小写,下划线分隔"的方式。某些C程序在给结构体命名时,会添加后缀"_s"以表明它是一个结构体类型,这是一个值得学习的命名风格。
声明结构体变量(对象)
在一般情况下,声明结构体变量需要使用关键字struct:
struct student s1; // 声明一个student结构体类型的变量s1
声明好一个结构体类型变量后,就需要初始化这个变量。
结构体对象的初始化和数组的初始化非常类似,也就是逐一对应的给对应成员赋值:
注意:
- 声明中的struct关键字不能省略。
- 和数组初始化一样,若一个成员未被手动初始化,它会被默认初始化为0值。
如果你觉得每次声明一个结构体变量都需要使用关键字struct很烦,那么你可以考虑给结构体类型起别名,仍然要使用关键字typedef,但语法稍特别,如下:
typedef struct student{ // student是此结构体的名字,起了别名的结构体可以省略名字。后续代码只使用别名操作结构体 int stu_id; char name[25]; char gender; int chinese; int math; int english; } Student; // 注意末尾的Student是该结构体类型的别名
这样起别名后,你就可以在声明结构体变量时省略struct关键字:
Student s1,s2;
Student s1 = { 1, "Faker",'m' ,100,100,100 };
我们可以计算出要连续存储这些数据项,至少需要的内存空间:
int + 25char + char + int * 3
也就是
4 + 25 + 1 + 12 = 42
但实际上,我们可以在VS中使用监视窗口计算'sizeof(s1)',发现结构体变量s1占用44个字节的数据。
多出来的两个字节,被称之为"对齐填充(padding)"。可以连带复习下计算机基础边界对齐。
对齐填充
在32位平台环境下,处理器可以一次性高效的处理最多4个字节数据。(因为地址总线的宽度和寄存器的大小是32位)
所以内存中的某个数据项(尤其是4个字节和大于4个字节的),内存空间的起始地址最好总是4的倍数。这样可以有效减少内存访问次数,提高效率。此时,该数据项就实现了"内存对齐"。
以往我们分析过数组的内存布局,由于数组连续且存储单元大小一致,所以数组中的元素总是天然"内存对齐"的。
但结构体变量就不同了,一个结构体变量可能包含不同大小的数据项,此时连续存储就要考虑对齐问题了。
不同编译器,不同平台都会对数据项实现"内存对齐",但可能会有一些差异,只需要明确对齐的概念就可以了。
声明初始化一个结构体变量后,就可以使用该变量进行一系列操作了。结构体变量主要可以进行的操作有:
- 访问/修改结构体成员
- 结构体变量给结构体变量赋值
- 结构体变量作为参数传递
- 结构体变量作为函数返回值
下面我们以上面定义的结构体Student,来演示一下结构体对象的这些基本操作。
访问/修改结构体成员是结构体变量最基础的操作。
我们可以直接用"结构体变量名.成员名"的形式来访问/修改结构体成员。其中的"."是结构体成员运算符,它是一个一元运算符,它的优先级比大多运算符都要高。
Student s1 = { 1, "王小明",'m' ,100,100,100 }; // 数学成绩改为80 s1.math = 80; // 此时打印数学的成绩就是80,而不是100 printf("math score: %d", s1.math); // 可以结合自增自减符号使用 s1.chinese--;
注:
--
后缀运算符和.
运算符优先级是一样的,但它们的结合性是左结合性的,所以要从左往右计算,s1.chinese--
是等价于(s1.chinese)--
的。这一点在使用
->
运算符时,也是一样的。
在C语言中,允许结构体变量直接给另一个结构体变量赋值,如下:
Student s1 = { 1, "王小明",'m' ,100,100,100 }; Student s2 = { 2, "李华",'f' ,90,90,90 }; // 这样进行赋值后,s1中的成员数据会完全覆盖掉s2本身的成员数据 // 也就是将s1内存空间中的数据完全拷贝覆盖到s2中 s2 = s1;
注意:
- 不要将结构体对象的名字看成和数组名一样的指针,结构体名字就是一个普通的变量名,和
int a = 10;
中的a
没有什么区别。s2 = s1;
不是让s2
指向s1
的内存区域,因为它们都不是指针。在这里,=
的目的是将s1
中各数据项取值,全部复制拷贝、覆盖原本s2
的各数据项。此语句执行后,s1
保持不变,s2
各数据项取值变成和s1
一致。- 你可以把结构体对象之间用"="的赋值,当成两个
int
变量用=
赋值,本质是一样的。
你可以选择直接将一个结构体变量作为参数传递:
// 定义一个函数直接传递操作结构体类型 void operate_struct(Student s) { // 打印结构体成员 printf("Student { stu_id = %d, name = %s, gender = %c, chinese = %d, math = %d, english = %d }\n", s.stu_id, s.name, s.gender, s.chinese, s.math, s.english); // 尝试修改结构体成员,能成功吗? s.chinese = 200; } // main函数: Student s1 = { 1, "Faker",'m' ,100,100,100 }; operate_struct(s1);
最终输出的结果是:
Student { stu_id = 1, name = Faker, gender = m, chinese = 100, math = 100, english = 100 }
但在函数体内部修改成员取值显然是无法影响实参本身的,因为函数得到的只是该结构体对象的拷贝。
注意:
- Student是结构体的别名,如果结构体没有起别名,仍然需要使用"struct 结构体类型名 变量名"来声明结构体类型形参。
由于C语言的值传递,一个结构体变量直接作为参数传递时,会复制一个它的副本传递给函数。所以上述代码中,main函数中的结构体变量s和operate_struct函数中的s,不是同一个结构体。
也就是说,结构体变量直接作为参数传递时,完全不可能通过函数修改这个结构体变量。
- 结构体变量直接作为参数传递时,由于存在复制整个结构体的操作,往往会存在效率问题。一般情况下,还是更建议将结构体变量的指针作为参数进行传递。
将结构体变量的指针作为参数进行传递,演示代码如下:
// 定义一个函数传递结构体指针操作结构体类型 void ptr_operate_struct(Student* p) { // 若函数体内部不需要修改结构体对象,应显式声明const printf("Student { stu_id = %d, name = %s, gender = %c, chinese = %d, math = %d, english = %d }\n", (*p).stu_id, (*p).name, (*p).gender, (*p).chinese, (*p).math, (*p).english); // 尝试修改结构体成员,能成功吗? (*p).chinese = 200; } // main函数: Student s1 = { 1, "Faker",'m' ,100,100,100 }; ptr_operate_struct(&s1); // 取地址
调用函数,第一行打印的结果是完全一样的,但修改成员是可以成功的。
注意事项:
- Student是结构体的别名,如果结构体没有起别名,仍然需要使用"struct 结构体类型名 *变量名 "来声明结构体指针类型形参。
- "(*s)"中的小括号是不能省略的,因为成员运算符"."的优先级高于解引用运算符"*"的。为了保证解引用运算符先计算,所以需要加括号改变优先级。
- ptr_operate_struct函数得到的是结构体指针变量的拷贝副本,但副本指针仍指向原本的结构体变量。所以,将结构体变量的指针作为参数进行传递时,函数是可以修改原本结构体变量的。若此函数没有修改结构体的需求,应明确将形参声明为const,这是一个良好的编程习惯。
- 在C语言开发中,推荐使用指针传递结构体变量。这种方法提高了程序的灵活性和效率,尤其适用于大型结构体,因为它避免了不必要的数据复制。
箭头运算符
->
(由一个减号和大于号组成)是C语言当中一个比较特殊的运算符,它实际上是结构体指针变量解引用和成员访问运算的结合,和索引运算符[]
一样是属于编译器优化的语法糖。它使得程序员不用再写丑陋的
(*p).
解引用成员访问语法,提供了一种更直观简洁的语法形式。比如上面的ptr_operate_struct函数可以写成如下代码:
void ptr_operate_struct(Student* p) { printf("Student { stu_id = %d, name = %s, gender = %c, chinese = %d, math = %d, english = %d }\n", p->stu_id, // 完全等价于(*p).std_id p->name, p->gender, p->chinese, p->math, p->english); }
利用这个运算符可以实现对结构体成员的访问,也包括赋值:
// 宏函数定义 #define SIZE_ARR(arr) (sizeof(arr) / sizeof(arr[0])) Student s1 = { 1, "Faker",'m' ,100,100,100 }; Student* p = &s1; // 将math修改为90,math是int类型可以直接用=赋值 p->math = 90; printf("%d\n", p->math); // name是一个字符数组名, 不能直接用=赋值 // p->name = "Showmaker"; // 若想要改变name字符串的内容,需要使用字符串复制函数,n取字符数组长度-1 strncpy(p->name, "Showmaker", SIZE_ARR(p->name) - 1); // 不要忘记将数组尾元素设置为空字符,保证数组表示一个字符串, 这是使用strncpy函数的惯用法 p->name[SIZE_ARR(p->name) - 1] = 0; // 此时name已设置为Showmaker printf("%s\n", p->name);
结构体变量/指针作为函数返回值,和作为参数传递时没有本质上的区别,但我们现在创建的结构体对象,都是局部结构体对象,所以:
- 返回一个指向当前栈区结构体对象的指针就是返回一个悬空指针,使用这样的指针会产生未定义行为。
- 而如果直接返回当前结构体对象本身,那函数调用得到的是这个结构体对象的副本/拷贝(这一点和数组不同,注意区分)。
关键字 enum
在编程中,我们可能会碰到一个场景,需要对一系列的状态/离散的数值进行逻辑处理。
比如在一个电商购物应用程序中,需要根据订单的不同状态执行相应的逻辑。这些状态可能包括:
新建订单(NEW)、已支付(PAID)、已发货(SHIPPED)、已送达(DELIVERED)、已完成(COMPLETED)、已取消(CANCELLED)等。
一个直观的方法是使用整数或宏定义来表示这些状态,并在函数调用中传递相应的状态码:
// 定义订单状态的宏 #define NEW 0 #define PAID 1 #define SHIPPED 2 #define DELIVERED 3 #define COMPLETED 4 #define CANCELLED 5 void handle_order(int order_status) { switch (order_status) { case NEW: // 新建订单处理逻辑 break; // 其他状态处理逻辑... } } int main() { int order_status = NEW; // 设置初始订单状态为新建 handle_order(order_status); // 根据订单状态进行处理 // ... return 0; }
这种方法确实可以实现功能,但它存在几个明显的问题:
- 没有明确地表明 NEW、PAID、SHIPPED 等状态码是属于同一种类型,这会降低代码的可读性和可维护性。
- 宏定义本质上是文本替换,这意味着在调试程序时,状态信息的名称(如 NEW 或 PAID)将不会保留,仅保留数值(如 0、1)。
- 当状态数量较多时,为每个状态创建宏定义赋整数值会变得很繁琐。此外,我们更关心的是状态名称而非其数值。
- handle_order函数实际上可以传入任何整数值作为参数,这使得代码具有安全隐患。
鉴于这些问题,C语言提供了一种特殊的数据类型——枚举(enum)。
在C语言中,枚举类型是一种自定义的复合数据类型,它由一组可命名的整数常量组成。每个枚举成员都对应一个整数值,你可以通过成员的名称来直接引用这些整数值。
使用枚举可以让代码更加清晰、类型安全,并且在调试时能够看到状态的实际名称,而不仅仅是一个数字。
// 定义一个名为 order_status 的枚举类型,表示订单的不同状态 enum order_status { NEW, // 新订单 PAID, // 已支付 SHIPPED, // 已发货 DELIVERED, // 已送达 COMPLETED, // 已完成 CANCELLED // 已取消 }; int main(void) { // 声明一个枚举类型变量 enum order_status status; return 0; }
当然,和定义结构体类型一样,你也可以给枚举类型起别名,这样就可以在声明时去掉enum关键字:
// 定义一个别名为 OrderStatus 的枚举类型,表示订单的不同状态 typedef enum { NEW, // 新订单 PAID, // 已支付 SHIPPED, // 已发货 DELIVERED, // 已送达 COMPLETED, // 已完成 CANCELLED // 已取消 } OrderStatus; int main(void) { // 声明一个枚举类型变量,此时可以省略enum关键字 OrderStatus status; return 0; }
定义枚举类型后,你可以声明该枚举类型的变量,并将预定义的枚举值赋给它:
OrderStatus status = NEW;
枚举类型的成员本质上就是一个整数,所以它们在内存中都是对应整数值。
在定义枚举类型时,若不主动给枚举成员赋值,那么这些成员的取值将从0开始,向后逐一累加。比如上面的枚举类型OrderStatus:
NEW = 0
PAID = 1
SHIPPED = 2
...
当然你也可以在定义枚举类型时,给成员手动赋值(但没必要这么做):
typedef enum { NEW = 888, PAID, // 自动累加 889 SHIPPED, // 890 DELIVERED, COMPLETED, CANCELLED } OrderStatus;
使用枚举类型的好处是显而易见的:
- 增强代码可读性。枚举允许开发者为一组整数值赋予有意义的名字,使得代码更易于理解。
- 增强代码的可维护性。一旦需要做出增删修改,你只需要在枚举定义中更改它们,而不是在代码的多个地方进行搜索和替换。
但C语言的枚举类型也有很多限制和不足,主要是:
- 枚举类型的成员会被编译器当成整型(一般是int)处理,这意味着C语言的枚举类型不是类型安全的。
- 你可以将任何整数赋值给枚举类型变量,甚至不同枚举类型变量之间都可以相互赋值。
- 再比如,将枚举类型作为函数的形参,实际上还是可以传参整数值。
比如:
typedef enum { RED = 666, BLACK, } Color; int main(void) { // 枚举类型变量可以用任何整数赋值 OrderStatus status = 100; Color c = RED; // 枚举类型之间可以互相赋值 OrderStatus status2 = c; return 0; }
为了避免这些潜在的问题,使用枚举类型枚举类型变量的赋值,应该使用枚举类型中定义的成员,不要使用整数进行赋值,更不应该用其它枚举类型进行赋值。