结构体和枚举
结构体
结构体是一种自定义的聚合数据类型,它能够将多种不同类型的数据项聚合到一个统一的结构中。
C语言中的结构体,非常类似于C++等面向对象语言中的类(class),不同的是结构体只能存放数据变量,而不能拥有函数。(只有属性,没有行为)
结构体类型和结构体对象之间的关系,就像是设计好的空白成绩单和填写了具体分数的小明同学的成绩单之间的关系。
结构体类型定义了哪些数据项将被记录(例如数学、语文、英语成绩),而具体的结构体对象(小明的成绩单)则包含了这些数据项的实际值(具体的分数)。
结构体类型的定义
在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 student s1 = { 1, "王小明",'m' ,100,100,100};
struct student s1 = { 2, "张美丽",'f'}; // 三个成绩会被初始化为0
注意:
- 声明中的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的倍数。这样可以有效减少内存访问次数,提高效率。此时,该数据项就实现了"内存对齐"。如下图所示:
以往我们分析过数组的内存布局,由于数组连续且存储单元大小一致,所以数组中的元素总是天然"内存对齐"的。
但结构体变量就不同了,一个结构体变量可能包含不同大小的数据项,此时连续存储就要考虑对齐问题了。
比如一个结构体变量中存储了一个char又接着存储一个int,如何保证"内存对齐"呢?
可以让这两个数据量直接连续存储吗?如下图所示:
显然不行,这样int类型变量就不是"内存对齐"了。那怎么办呢?
只需要在char数据项和int数据项之间,用无意义的字节数据,填充三个字节空间就可以了。这些无意义的字节数据,就是"对齐填充"。
如下图所示:
在64位平台环境下,处理器可以一次性高效的处理8个字节数据。这时,数据项内存空间的起始地址就要保证为8的倍数了,而且也会有类似的对齐填充概念,不再赘述。
注:
不同编译器,不同平台都会对数据项实现"内存对齐",但可能会有一些差异,所以不要盲目记忆上述对齐的方式,只需要明确对齐的概念就可以了。
分析Student对象的内存布局
明白上述"对齐填充"概念后,那我们来看一下上面的Student对象的内存布局。
先存储一个4字节的int,随后连续存储25个字节的char数组。接下来为了让char数据项能够内存对齐,需要填充3个字节。
存储char后,为了让后续的int数据项都能够对齐,又需要填充3个字节。总共需要48个字节来存储该对象。
如下图所示:
但实际上,不同平台编译器会采用不同的对齐方式,VS的32位平台下,该结构体变量的内存布局如下:
总共需要44个字节,即可存储该结构体变量。
也就是说:
- VS的32位平台下采用更紧凑的内存布局来存储结构体变量,这样更节省内存空间。
- 第二种方式中,char数据项没有实现内存对齐,但由于它小于4个字节,即便不对齐对使用效率的影响也微乎其微。
结构体变量(对象)的基本操作
声明初始化一个结构体变量后,就可以使用该变量进行一系列操作了。结构体变量主要可以进行的操作有:
- 访问/修改结构体成员
- 结构体变量给结构体变量赋值
- 结构体变量作为参数传递
- 结构体变量作为函数返回值
下面我们以上面定义的结构体Student,来演示一下结构体对象的这些基本操作。
访问/修改结构体成员
访问/修改结构体成员是结构体变量最基础的操作。
我们可以直接用**“结构体变量名.成员名”**的形式来访问/修改结构体成员。其中的"."是结构体成员运算符,它是一个一元运算符,它的优先级比大多运算符都要高。
注:--
后缀运算符和.
运算符优先级是一样的,但它们的结合性是左结合性的,所以要从左往右计算,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
变量用=
赋值,本质是一样的。
结构体对象和数组变量的区别
作为C语言当中两个最常用的聚合数据类型,数组显然和结构体大不相同。
作为C语言初学者,乍一看结构体
=
赋值的语法可能会有些吃惊,因为数组变量完全不可以用"="运算符进行赋值。结构体变量的这种语法,给结构体操作带来了很大的便利性。程序员可以非常简便、直观的利用"="运算符将一个结构体变量的值复制到另一个结构体变量。
但这也会带来一些看困扰和麻烦,主要体现在函数调用上:
数组作为形参时直接退化成指针,灵活的同时避免了大量数据复制损耗性能,而且C语言不允许数组类型直接做返回值类型,而是只能返回指针类型间接返回一个数组。
但结构体完全不同:
- 当结构体变量作为函数的参数传递时,会将整个结构体复制拷贝一份,然后传递到函数体内部。
- 当结构体变量作为函数的返回值时,函数调用者接收返回值意味着,要将整个结构体对象复制一份。
这样会增加程序的开销,特别是当结构体很大的时候。为了避免这些不必要开销,我们可以传递或返回一个指向结构体的指针。
在日常的学习和工作中,我们普遍会定义一个指向结构体变量的结构体指针,用于操作、传递、返回结构体类型,这也是在指针的后面讲结构体的原因。
结构体变量直接作为参数传递
你可以选择直接将一个结构体变量作为参数传递:
// 定义一个函数直接传递操作结构体类型
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->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);
在C语言编程中,结合使用结构体指针和箭头运算符 -> 是一种高效且清晰的方式,来访问和操作结构体的成员。
结构体变量/指针作为函数返回值
结构体变量/指针作为函数返回值,和作为参数传递时没有本质上的区别,但我们现在创建的结构体对象,都是局部结构体对象,返回一个指向当前栈区的结构体对象的指针就是返回一个悬空指针,会产生未定义行为。
而如果直接返回当前结构体对象本身,那函数调用得到的是这个结构体对象的副本/拷贝。
所以,为了更好的给大家讲解这个语法点,我们先挖个坑,等到《指针的高级应用》章节时再来讨论。
结构体类型定义的变种语法
基于结构体类型,衍生出了很多变种的语法,我们通过几段代码及注释说明。
首先我们这里总结一下定义结构体的语法:
// 定义一个完全匿名的结构体类型,这种做法C语言允许,但没有意义
struct {
// 成员定义
};
// 定义一个名为student_s的结构体类型
struct student_s {
// 成员定义
};
// 定义一个匿名结构体并为其定义别名Student
typedef struct {
// 成员定义
} Student;
// 定义一个名为student_s的结构体类型,并为其定义别名Student
typedef struct student_s {
// 成员定义
} Student;
除开第一种,下面三种定义结构体的语法都是可行的,实际开发中可根据情况自行选择。
除了结构体的定义语法外,结构体在早期C语言代码中会出现一些比较奇怪的代码。
比如可以在定义结构体时,直接给结构体指针类型起别名:
// 定义一个匿名结构体并为其定义别名Student,同时为它的指针类型定义别名PStudent
typedef struct {
// 成员定义
} Student, *PStudent;
这种语法大家理解,认识即可,请不要在自己的代码中使用这样的语法。**一般而言,不建议给指针类型起别名,这严重影响代码可读性。**比如:
PStudent p = &s;
左边类型的声明由于缺少了"*"运算符,会变得难以辨认它是一个指针类型。
最奇葩的,你可能还会看到以下特别坑爹的结构体定义语法:
struct{
// 成员定义
} s1, s2[10], *p;
这段代码定义了一个匿名结构体,但由于没有关键字typedef
,所以后面的三个名字不可能是别名。那么只能是:
- 声明了此匿名结构体变量s1
- 声明了一个长度为10的,此匿名结构体数组
- 声明了此匿名结构体的指针变量p
这种做法在语法上都是没有问题的,但强烈不推荐这么做,因为它太坑爹了,严重牺牲了代码的可维护性和可读性。
枚举
在编程中,我们可能会碰到一个场景,需要对一系列的状态/离散的数值进行逻辑处理。
比如在一个电商购物应用程序中,需要根据订单的不同状态执行相应的逻辑。这些状态可能包括:
新建订单(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;
}
为了避免这些潜在的问题,使用枚举类型枚举类型变量的赋值,应该使用枚举类型中定义的成员,不要使用整数进行赋值,更不应该用其它枚举类型进行赋值。
C语言的枚举类型设计是十分简单和功能弱小的,但在特定的场景中,它也是足够用的。待到后面用到枚举时,我们还会再次复习它。