王道C++班级参考资料
——
C语言部分卷4指针
节4结构体和枚举

最新版本V2.0
王道C++团队
COPYRIGHT ⓒ 2021-2024. 王道版权所有

概述

Gn!

在学习了指针、指针和数组、字符串等一系列指针相关的复杂概念后,也许你已经有点麻了。那刚好我们就先放松一下,学习一下本章节相对简单轻松的语法点。

C语言中,除了基本数据类型,还提供了一些复合数据类型。除了我们前面学习过的数组,枚举和结构体也是常见的复合类型,用以支持更复杂的数据表示。

其中:

  1. 结构体是一系列值(成员)的集合,这一系列值可以有不同的数据类型。

  2. 枚举本质上是一组整数类型的集合,只不过给这些整数值起了名称罢了。

结构体

Gn!

结构体是一种自定义的聚合数据类型,它能够将多种不同类型的数据项聚合到一个统一的结构中。

C语言中的结构体,非常类似于C++等面向对象语言中的类(class),不同的是结构体只能存放数据变量,而不能拥有函数。(只有属性,没有行为)

结构体类型和结构体对象之间的关系,就像是设计好的空白成绩单和填写了具体分数的小明同学的成绩单之间的关系。

结构体类型定义了哪些数据项将被记录(例如数学、语文、英语成绩),而具体的结构体对象(小明的成绩单)则包含了这些数据项的实际值(具体的分数)。

结构体对象和C++中的对象

在C语言中,术语“对象”简单地指代内存中一块可以识别的数据结构实例。从这个角度出发,任何C语言的变量都可以称之为对象。

结构体对象无非就是一个占用一片内存空间的"结构体变量"罢了。

结构体对象可以看成是一个封装了数据集合的实体,但C++中的对象,是面向对象编程范畴下的一个更为复杂的概念。在C++中,对象不仅封装了数据,还封装了可以操作那些数据的函数,也就是行为。

C++中的对象具有属性和行为,一个对象是可以作为程序世界中的独立个体看待的,功能更加完善、复杂和强大。总之不要搞混淆C和C++中对象的概念。

定义结构体类型

Gn!

在C语言中,结构体(struct)是一种自定义的数据类型,它允许你将多个不同类型的变量组合在一起。

这些变量被称为“成员”或“字段”。结构体类型的定义为这些成员赋予了一个集合的名字,使得你可以同时处理这些相关的数据项。这对于将数据组织成更高层次的形式非常有用,尤其是当这些数据项自然地组合在一起时。

结构体类型一般定义在函数外部。

比如我需要在程序中使用一个学生student对象,同时处理一个学生的所有数据信息,就可以定义一个student结构体类型:

定义student结构体类型-语法演示

解释:

  1. 结构体定义使用关键字struct,在这里,struct student 表示定义了一个新的结构体类型。

  2. student是一个标识符,是此结构体类型的名字,此结构体类型包含六个成员。

  3. 结构体类型的名字建议采用"单词小写,下划线分隔"的方式。某些C程序在给结构体命名时,会添加后缀"_s"以表明它是一个结构体类型,这是一个值得学习的命名风格。

声明结构体变量(对象)

Gn!

定义一个结构体类型后,就可以像对待int这种基本数据类型一样,正常的去使用声明和初始化语法创建一个结构体变量。

在一般情况下,声明结构体变量需要使用关键字struct:

注:结构体变量名就是一个普通的标识符,一个普遍的变量名。

初始化结构体变量(对象)

Gn!

声明好一个结构体类型变量后,就需要初始化这个变量。

结构体对象的初始化和数组的初始化非常类似,也就是逐一对应的给对应成员赋值:

初始化结构体对象(变量)-语法

注意:

  1. 声明中的struct关键字不能省略。

  2. 和数组初始化一样,若一个成员未被手动初始化,它会被默认初始化为0值。

如果你觉得每次声明一个结构体变量都需要使用关键字struct很烦,那么你可以考虑给结构体类型起别名,仍然要使用关键字typedef,但语法稍特别,如下:

给结构体类型起别名

这样起别名后,你就可以在声明结构体变量时省略struct关键字:

注意:

  1. 为结构体类型起别名后可以简化操作,是推荐的操作,实际开发中建议以起别名的形式来定义结构体类型。

  2. 结构体类型的别名要放在结构体大括号末尾,这个别名建议"大驼峰形式"命名。总之建议和结构体名保持不一致的命名风格以示区分就可以了。

结构体变量的内存布局(重要)

Gn!

结构体变量的内存布局和数组非常类似,都是用一片连续的内存空间来存储单一数据项,但又稍有区别。下面举个例子来说明。

对于一个结构体变量:

我们可以计算出要连续存储这些数据项,至少需要的内存空间:

int + 25char + char + int * 3

也就是

4 + 25 + 1 + 12 = 42

但实际上,我们可以在VS中使用监视窗口计算'sizeof(s1)',发现结构体变量s1占用44个字节的数据。

多出来的两个字节,被称之为"对齐填充(padding)"

对齐填充

Gn!

对齐填充是什么呢?我们简单讲解一下。

在32位平台环境下,处理器可以一次性高效的处理最多4个字节数据。(因为地址总线的宽度和寄存器的大小是32位)

所以内存中的某个数据项(尤其是4个字节和大于4个字节的),内存空间的起始地址最好总是4的倍数。这样可以有效减少内存访问次数,提高效率。此时,该数据项就实现了"内存对齐"。如下图所示:

内存对齐-示意图

以往我们分析过数组的内存布局,由于数组连续且存储单元大小一致,所以数组中的元素总是天然"内存对齐"的。

但结构体变量就不同了,一个结构体变量可能包含不同大小的数据项,此时连续存储就要考虑对齐问题了。

比如一个结构体变量中存储了一个char又接着存储一个int,如何保证"内存对齐"呢?

可以让这两个数据量直接连续存储吗?如下图所示:

内存对齐-示意图2

显然不行,这样int类型变量就不是"内存对齐"了。那怎么办呢?

只需要在char数据项和int数据项之间,用无意义的字节数据,填充三个字节空间就可以了。这些无意义的字节数据,就是"对齐填充"。

如下图所示:

内存对齐-示意图3

在64位平台环境下,处理器可以一次性高效的处理8个字节数据。这时,数据项内存空间的起始地址就要保证为8的倍数了,而且也会有类似的对齐填充概念,不再赘述。

注:

不同编译器,不同平台都会对数据项实现"内存对齐",但可能会有一些差异,所以不要盲目记忆上述对齐的方式,只需要明确对齐的概念就可以了。

分析Student对象的内存布局

Gn!

明白上述"对齐填充"概念后,那我们来看一下上面的Student对象的内存布局。

先存储一个4字节的int,随后连续存储25个字节的char数组。接下来为了让char数据项能够内存对齐,需要填充3个字节。

存储char后,为了让后续的int数据项都能够对齐,又需要填充3个字节。总共需要48个字节来存储该对象。

如下图所示:

Student对象内存布局图

但实际上,不同平台编译器会采用不同的对齐方式,VS的32位平台下,该结构体变量的内存布局如下:

Student对象内存布局图-2

总共需要44个字节,即可存储该结构体变量。

也就是说:

  1. VS的32位平台下采用更紧凑的内存布局来存储结构体变量,这样更节省内存空间。

  2. 第二种方式中,char数据项没有实现内存对齐,但由于它小于4个字节,即便不对齐对使用效率的影响也微乎其微。

结构体变量(对象)的基本操作

Gn!

声明初始化一个结构体变量后,就可以使用该变量进行一系列操作了。结构体变量主要可以进行的操作有:

  1. 访问/修改结构体成员

  2. 结构体变量给结构体变量赋值

  3. 结构体变量作为参数传递

  4. 结构体变量作为函数返回值

下面我们以上面定义的结构体Student,来演示一下结构体对象的这些基本操作。

访问/修改结构体成员

Gn!

访问/修改结构体成员是结构体变量最基础的操作。

我们可以直接用"结构体变量名.成员名"的形式来访问/修改结构体成员。其中的"."是结构体成员运算符,它是一个一元运算符,它的优先级比大多运算符都要高。

参考代码如下:

访问/修改结构体成员-演示代码

注:--后缀运算符和.运算符优先级是一样的,但它们的结合性是左结合性的,所以要从左往右计算,s1.chinese--是等价于(s1.chinese)--的。

这一点在使用->运算符时,也是一样的。

结构体变量给结构体变量赋值

Gn!

在C语言中,允许结构体变量直接给另一个结构体变量赋值,如下:

结构体变量给结构体变量赋值-演示代码

注意:

  1. 不要将结构体对象的名字看成和数组名一样的指针,结构体名字就是一个普通的变量名,和int a = 10;中的a没有什么区别。

  2. s2 = s1;不是让s2指向s1的内存区域,因为它们都不是指针。在这里,=的目的是将s1中各数据项取值,全部复制拷贝、覆盖原本s2的各数据项。此语句执行后,s1保持不变,s2各数据项取值变成和s1一致。

  3. 你可以把结构体对象之间用"="的赋值,当成两个int变量用=赋值,本质是一样的。

结构体对象和数组变量的区别

作为C语言当中两个最常用的聚合数据类型,数组显然和结构体大不相同。

作为C语言初学者,乍一看结构体=赋值的语法可能会有些吃惊,因为数组变量完全不可以用"="运算符进行赋值。

结构体变量的这种语法,给结构体操作带来了很大的便利性。程序员可以非常简便、直观的利用"="运算符将一个结构体变量的值复制到另一个结构体变量。

但这也会带来一些看困扰和麻烦,主要体现在函数调用上:

数组作为形参时直接退化成指针,灵活的同时避免了大量数据复制损耗性能,而且C语言不允许数组类型直接做返回值类型,而是只能返回指针类型间接返回一个数组。

但结构体完全不同:

  1. 当结构体变量作为函数的参数传递时,会将整个结构体复制拷贝一份,然后传递到函数体内部。

  2. 当结构体变量作为函数的返回值时,函数调用者接收返回值意味着,要将整个结构体对象复制一份。

这样会增加程序的开销,特别是当结构体很大的时候。为了避免这些不必要开销,我们可以传递或返回一个指向结构体的指针。

在日常的学习和工作中,我们普遍会定义一个指向结构体变量的结构体指针,用于操作、传递、返回结构体类型,这也是在指针的后面讲结构体的原因。

结构体变量直接作为参数传递

Gn!

你可以选择直接将一个结构体变量作为参数传递:

结构体变量直接作为参数传递-演示代码

最终输出的结果是:

Student { stu_id = 1, name = Faker, gender = m, chinese = 100, math = 100, english = 100 }

但在函数体内部修改成员取值显然是无法影响实参本身的,因为函数得到的只是该结构体对象的拷贝。

注意:

  1. Student是结构体的别名,如果结构体没有起别名,仍然需要使用"struct 结构体类型名 变量名"来声明结构体类型形参。

  2. 由于C语言的值传递,一个结构体变量直接作为参数传递时,会复制一个它的副本传递给函数。所以上述代码中,main函数中的结构体变量s和operate_struct函数中的s,不是同一个结构体。

    也就是说,结构体变量直接作为参数传递时,完全不可能通过函数修改这个结构体变量。

  3. 结构体变量直接作为参数传递时,由于存在复制整个结构体的操作,往往会存在效率问题。一般情况下,还是更建议将结构体变量的指针作为参数进行传递。

结构体指针作为参数传递(重要)

Gn!

将结构体变量的指针作为参数进行传递,演示代码如下:

将结构体变量的指针作为参数进行传递-演示代码

调用函数,第一行打印的结果是完全一样的,但修改成员是可以成功的。

注意事项:

  1. Student是结构体的别名,如果结构体没有起别名,仍然需要使用"struct 结构体类型名 *变量名 "来声明结构体指针类型形参。

  2. "(*s)"中的小括号是不能省略的,因为成员运算符"."的优先级高于解引用运算符"*"的。为了保证解引用运算符先计算,所以需要加括号改变优先级。

  3. ptr_operate_struct函数得到的是结构体指针变量的拷贝副本,但副本指针仍指向原本的结构体变量。所以,将结构体变量的指针作为参数进行传递时,函数是可以修改原本结构体变量的。若此函数没有修改结构体的需求,应明确将形参声明为const,这是一个良好的编程习惯。

  4. 在C语言开发中,推荐使用指针传递结构体变量。这种方法提高了程序的灵活性和效率,尤其适用于大型结构体,因为它避免了不必要的数据复制。

箭头运算符->

Gn!

箭头运算符->(由一个减号和大于号组成)是C语言当中一个比较特殊的运算符,它实际上是结构体指针变量解引用和成员访问运算的结合,和索引运算符[]一样是属于编译器优化的语法糖。

它使得程序员不用再写丑陋的(*p).解引用成员访问语法,提供了一种更直观简洁的语法形式。

比如上面的ptr_operate_struct函数可以写成如下代码:

箭头运算符->使用-演示代码

利用这个运算符可以实现对结构体成员的访问,也包括赋值:

箭头运算符->使用-演示代码2

在C语言编程中,结合使用结构体指针和箭头运算符 -> 是一种高效且清晰的方式,来访问和操作结构体的成员。

结构体变量/指针作为函数返回值

Gn!

结构体变量/指针作为函数返回值,和作为参数传递时没有本质上的区别,但我们现在创建的结构体对象,都是局部结构体对象,所以:

  1. 返回一个指向当前栈区结构体对象的指针就是返回一个悬空指针,使用这样的指针会产生未定义行为。

  2. 而如果直接返回当前结构体对象本身,那函数调用得到的是这个结构体对象的副本/拷贝(这一点和数组不同,注意区分)。

所以,为了更好的给大家讲解这个语法点,我们先挖个坑,等到《指针的高级应用》章节时再来讨论。

结构体类型定义的变种语法

Gn!

基于结构体类型,衍生出了很多变种的语法,我们通过几段代码及注释说明。

首先我们这里总结一下定义结构体的语法:

定义结构体的语法

除开第一种,下面三种定义结构体的语法都是可行的,实际开发中可根据情况自行选择。除了结构体的定义语法外,结构体在早期C语言代码中会出现一些比较奇怪的代码。

比如可以在定义结构体时,直接给结构体指针类型起别名:

结构体定义时给结构体指针起别名

这种语法大家理解,认识即可,请不要在自己的代码中使用这样的语法。一般而言,不建议给指针类型起别名,这严重影响代码可读性。比如:

左边类型的声明由于缺少了"*"运算符,会变得难以辨认它是一个指针类型。

最奇葩的,你可能还会看到以下特别坑爹的结构体定义语法:

匿名结构体一次性声明变量-演示代码

这段代码定义了一个匿名结构体,但由于没有关键字typedef,所以后面的三个名字不可能是别名。那么只能是:

  1. 声明了此匿名结构体变量s1

  2. 声明了一个长度为10的,此匿名结构体数组

  3. 声明了此匿名结构体的指针变量p

这种做法在语法上都是没有问题的,但强烈不推荐这么做,因为它严重牺牲了代码的可维护性和可读性。

补充知识点:复合字面量语法(了解)

Gn!

假如存在这样的一个结构体类型:

在初始化一个Num结构体对象时,要特别注意这个初始化操作是和声明一起的,还是不紧跟声明,它们是有区别的:

若在声明结构体对象时初始化:

在声明语句的下面再进行初始化(即赋值):

在C语言中类似(Num){ 1 }这样的语法,被称之为"复合字面量"语法,在C99以后可用。该语法允许程序员在代码的任何位置初始化创建一个结构体、联合体或数组的匿名对象。

对于结构体类型对象而言,可以使用=赋值号直接进行结构体成员值的拷贝,所以在代码中我们可以使用该语法为一个结构体对象重新赋值:

除此之外,这种语法也可以在代码中创建一个匿名的数组对象,如下所示:

在上述代码中,我们利用该语法使用=完成了一个结构体对象重新赋值,思考一下:利用匿名数组,可以实现重新给数组赋值吗?

当然是不可以的。

因为数组名在视为首元素指针时,是一个不可改变指向的指针,并且数组名和结构体名不同,数组名无法使用=连接。

复合字面量的语法一般都写在函数体内部,这些匿名结构都会在栈上申请空间,可以认为就是一个特殊的没有名字的局部变量。

那么这种复合字面量的语法,有什么作用呢?

除了上面说的,给结构体对象成员完全重新赋值外,复合字面量语法还常用于:

第一,作为函数的实参传递给函数:

这个程序的执行结果是:[1, 2, 3]

第二,作为函数的返回值返回:

思考以下代码:

这个函数的实现合法吗?

显然是不合法的,这样的返回值将导致返回一个悬空指针,代码不能这么写。

但可以按照下面的方式去写:

因为返回结构体对象,并不是返回指针,不是返回内存块,函数调用语句如下:

此时ret这个Num结构体对象将在main函数的栈帧中分配空间,函数返回值只是起到一个拷贝复制值的作用。

除此之外,复合字面量的语法还可以用于给结构体的数组赋值,比如下列代码:

总得来说,复合字面量语法不是一个特别常用的语法,大家了解即可。

枚举

Gn!

在编程中,我们可能会碰到一个场景,需要对一系列的状态/离散的数值进行逻辑处理。

比如在一个电商购物应用程序中,需要根据订单的不同状态执行相应的逻辑。这些状态可能包括:

新建订单(NEW)、已支付(PAID)、已发货(SHIPPED)、已送达(DELIVERED)、已完成(COMPLETED)、已取消(CANCELLED)等。

一个直观的方法是使用整数或宏定义来表示这些状态,并在函数调用中传递相应的状态码:

利用宏定义来进行状态处理-演示代码

这种方法确实可以实现功能,但它存在几个明显的问题:

  1. 没有明确地表明 NEW、PAID、SHIPPED 等状态码是属于同一种类型,这会降低代码的可读性和可维护性。

  2. 宏定义本质上是文本替换,这意味着在调试程序时,状态信息的名称(如 NEW 或 PAID)将不会保留,仅保留数值(如 0、1)。

  3. 当状态数量较多时,为每个状态创建宏定义赋整数值会变得很繁琐。此外,我们更关心的是状态名称而非其数值。

  4. handle_order函数实际上可以传入任何整数值作为参数,这使得代码具有安全隐患。

鉴于这些问题,C语言提供了一种特殊的数据类型——枚举(enum)。

在C语言中,枚举类型是一种自定义的复合数据类型,它由一组可命名的整数常量组成。每个枚举成员都对应一个整数值,你可以通过成员的名称来直接引用这些整数值。

使用枚举可以让代码更加清晰、类型安全,并且在调试时能够看到状态的实际名称,而不仅仅是一个数字。

定义枚举类型

Gn!

基于上面的例子,你就可以定义下列枚举类型:

枚举类型-定义语法

当然,和定义结构体类型一样,你也可以给枚举类型起别名,这样就可以在声明时去掉enum关键字:

枚举类型-定义别名语法

可以看到枚举类型的定义语法几乎和结构体类型一样,只不过换了个关键字。

初始化枚举类型变量

Gn!

定义枚举类型后,你可以声明该枚举类型的变量,并将预定义的枚举值赋给它:

枚举类型的成员本质上就是一个整数,所以它们在内存中都是对应整数值。

在定义枚举类型时,若不主动给枚举成员赋值,那么这些成员的取值将从0开始,向后逐一累加。比如上面的枚举类型OrderStatus:

NEW = 0

PAID = 1

SHIPPED = 2

...

当然你也可以在定义枚举类型时,给成员手动赋值(但没必要这么做)

枚举类型成员的手动赋值-演示代码

枚举类型的优缺点

Gn!

使用枚举类型的好处是显而易见的:

  1. 增强代码可读性。枚举允许开发者为一组整数值赋予有意义的名字,使得代码更易于理解。

  2. 增强代码的可维护性。一旦需要做出增删修改,你只需要在枚举定义中更改它们,而不是在代码的多个地方进行搜索和替换。

但C语言的枚举类型也有很多限制和不足,主要是:

  1. 枚举类型的成员会被编译器当成整型(一般是int)处理,这意味着C语言的枚举类型不是类型安全的。

  2. 你可以将任何整数赋值给枚举类型变量,甚至不同枚举类型变量之间都可以相互赋值。

  3. 再比如,将枚举类型作为函数的形参,实际上还是可以传参整数值。

比如:

C语言的枚举类型是类型不安全的

为了避免这些潜在的问题,使用枚举类型枚举类型变量的赋值,应该使用枚举类型中定义的成员,不要使用整数进行赋值,更不应该用其它枚举类型进行赋值。

总结

Gn!

C语言的枚举类型设计是十分简单和功能弱小的,但在特定的场景中,它也是足够用的。待到后面用到枚举时,我们还会再次复习它。

THE END