一、结构体

在C语言中有int,char,float等等类型,可以用来形容某些数据,但是有些数据仅靠一种类型无法描述出来,比如说一个人,我们不仅要描述他的名字,还要描述他的身高、体重、性别等特征。此时我们我发一种C语言自带的内置类型定义一个人。此时我们就需要学会使用结构体。

1、什么是结构体

结构体是一些值的集合,这些值被称为结构体的成员变量,这些成员变量类型可以各不相同。可以将结构体理解为我们自己定义的一种类型,和int,char这些类型类似,但是结构体可以用来描述更加抽象的数据。

2、结构体的声明

自定义类型详解_位段


struct是结构体关键字,在声明结构体是是不可缺少的。tag指的是结构体标签,也就是结构体的名字。member-list是标准的变量定义,在这里指的就是成员变量。variable-list是结构体变量。

如果我们要用结构体描述一个学生。

自定义类型详解_位段_02

3、特殊的结构体声明

自定义类型详解_位段_03

上面这个结构体在声明时舍弃了结构体标签,此时我们想要使用这个结构体类型,就只能在声明时定义,如上,我们在声明结构体时,定义了结构体变量

People1、People2。

4、结构体的定义与初始化

结构体变量的定义无非就是两种方式:

自定义类型详解_位段_04

结构体变量的初始化:

自定义类型详解_偏移量_05

5、结构体内存对齐

接下来讨论结构体的大小,看下面一题,求打印出的结果。

自定义类型详解_成员变量_06

S1的第一个成员变量是int型,占4个字节,第二个和第三个成员变量char型,各占1个字节,加起来一共是6个字节。S2的成员变量只是顺序不同,应该也是6个字节。打印结果应该是6和6。

答案如下:

自定义类型详解_位段_07

答案并不是我们想象的那样。

而且S1和S2的大小是不一样的,这就意味着结构体成员变量的顺序会影响结构体的大小。

先要求结构体的大小,就必须先了解结构体对齐。

结构体对齐规则如下:

①第一个成员在与结构体变量偏移量为0的地址处。

②其他成员变量要对齐某个数字(对齐数)的整数倍的地址处。

对齐数=编译器默认的一个对齐数与该成员变量大小的较小值。

VS编译器中默认的对齐数是8.

③结构体总大小为最大对齐数(每个成员变量对齐数的最大值)的整数倍。

④如果嵌套了结构体的情况下,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍。

多说无益,我们用,图来解释。

首先所谓的偏移量就是值离结构体地址起始处距离。

自定义类型详解_成员变量_08

如图所示,1个格子代表1个字节。假设这个结构体的起始地址指向红色区域,那么这个红色区域的偏移量为0,黄色区域的偏移量为1,绿色区域的偏移量为2,以此类推。

我们先来看S1:

S1的第一个成员变量a是整形,根据规则①,应该从0偏移出开始,共占4个字节。用红色区域表示。

自定义类型详解_偏移量_09

第二个成员变量b是字符型,根据规则②,b的大小是1个字节,VS默认对齐数是8,取较小值,所以b的默认对齐数是1。并且要存放在偏移量为1的倍数的地址处,4就是1倍数,我们绿色区域表示b。

自定义类型详解_成员变量_10

第三个成员变量c与b一样,用黄色区域表示。

自定义类型详解_位段_11

最后,根据规则3,三个成员变量中最大的对齐数是4,所以结构体的整体大小得是4的倍数,现在已经占了6个字节了,我们要浪费2个字节,使结构体的大小为8个字节。蓝色区域表示浪费掉的空间。

自定义类型详解_位段_12

我们再来分析一下S2

S2的第一个成员变量b是字符型,根据规则①,应该从0偏移出开始,共占1个字节。用绿色区域表示。

自定义类型详解_位段_13

第二个成员变量a是整型,根据规则②,a的大小是4个字节,VS默认对齐数是8,取较小值,所以b的默认对齐数是4。并且要存放在偏移量为4的倍数的地址处,4就是4倍数,我们跳过浪费掉偏移量为1、2、3的空间,我们红色区域表示a,蓝色区域表示浪费掉的空间

自定义类型详解_成员变量_14

第三个成员变量c是字符型,根据规则②,c的大小是1个字节,VS默认对齐数是8,取较小值,所以c的默认对齐数是1。并且要存放在偏移量为1的倍数的地址处,8就是1倍数,这次就不用浪费空间。我们黄色区域表示c。

自定义类型详解_成员变量_15

最后,根据规则3,三个成员变量中最大的对齐数是4,所以结构体的整体大小得是4的倍数,现在已经占了9个字节了,我们要浪费3个字节,使结构体的大小为12个字节。

自定义类型详解_位段_16

6、内存对齐的意义

①平台原因(移植原因):

不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。

②性能原因:

数据结构(尤其是栈)应该尽可能地在自然边界上对齐。

原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问。

总体来说:

结构体的内存对齐是拿空间来换取时间的做法。

7、修改默认对齐数

可以用pragma这个预处理指令来修改默认对齐数

//修改对齐数
#pragma pack(1)
struct S1
{
  char b;
  int a;
  char c;
};
int main()
{
  printf("%d\n", sizeof(struct S1));
  return 0;
}

我们把默认对齐数修改位1,根据我们上面所讲的知识,此时printf打印出来的结果应该是6。

自定义类型详解_位段_17

S1的内存分布如下:

自定义类型详解_偏移量_18

//修改对齐数
#pragma pack(1)
//重置默认对齐数
#pragma pack()
struct S1
{
  char b;
  int a;
  char c;
};
int main()
{
  printf("%d\n", sizeof(struct S1));
  return 0;
}

将默认对齐数重置,此时默认对齐数为8,所以打印的结果应该是12

自定义类型详解_成员变量_19

二、位段

1、什么是位段

位段的声明和结构体的声明类似,有两个地方不同。

①位段的成员必须是整形家族(如int、char等等)

②位段成员名后面有一个冒号和数字。

自定义类型详解_位段_20

冒号后面的数字代表的是自己规定的这个数据所占的空间大小,单位是比特位。例如b这个成员变量只能占用2个比特位的空间。

2、位段的内存分配

①位段的成员可以是 int unsigned int signed int 或者是 char (属于整形家族)类型

②位段的空间上是按照需要以4个字节( int )或者1个字节( char )的方式来开辟的。

③位段涉及很多不确定因素,位段是不跨平台的,注重可移植的程序应该避免使用位段。

位段具有跨平台缺陷,在不同的平台上实现方式不同,我们现在就在VS2022的环境(小端)下探究一下位段的内存分配

自定义类型详解_成员变量_21

这个位段里的成员是int型,系统会先开辟4个字节的空间,如下:

自定义类型详解_位段_22

我们给成员a规定的大小是2个比特位,a的值为2,转换为二进制为10,放入第一个字节的空间,接下来我们要讨论一个问题,是要从左往右分配还是从右往左分配?也就是a应该放在哪?

下图红色的空间表示存放a的位置。

自定义类型详解_成员变量_23

有两种可能,我们先假设是从右往左分配,最后验证是否正确。

我们给成员b规定的大小是30个比特位,此时内存的分配如下

自定义类型详解_成员变量_24

我们给成员c规定的大小是3个比特位,系统第一次开辟的4个字节的空间已经用完了,此时系统会再次开辟4个字节的空间,存放c。

自定义类型详解_位段_25

然后我们放入他们的值,a的值转化为二进制位是01(2位),b的值转化为二进制位是

00 0000 0000 0000 0000 0110 0001 1010(30位),c的值转化为二进制位是111(3位)。 注意,我们是在小端存储进行的,所以低位字节的数据要放在低地址处。

最后s的内存分配如下:

自定义类型详解_成员变量_26

我们把每4个比特位的二进制位转化为16进制位

自定义类型详解_偏移量_27

接下来我们通过调试来查看位段变量s内存中的储存情况,如果是

6A 18 00 00 00 07 00 00 00,那就代表我们的猜测是对的。

自定义类型详解_成员变量_28

通过调试,我们证实了我们的猜想是正确的,在VS2022的环境下,空间是从右往左分配,并且我们也知道了,位段变量b所占的内存大小为8个字节。

自定义类型详解_偏移量_29

我们再来看这题,当分配好成员a的空间时,成员变量a只占了3个比特位,还剩余了5个比特位的空间,那我们在给成员b分配空间时,我们需要7个比特位的空间,前面的5个比特位的空间显然不够,系统此时再给1个字节的空间,那么问题来了,我们是否要使用前面5个比特位的空间,还是说浪费5个比特位的空间,直接使用后面的空间。

图解如下:

自定义类型详解_成员变量_30

和上一题一样,我们先假设,随后在通过调试来验证。

我们就先假设会浪费空间。

那么r的内存分布就应该如下:

自定义类型详解_位段_31

我们在通过调试来查看位段变量r在内存中的储存情况:

自定义类型详解_位段_32

分布情况和我们猜想的一样,这也就代表这,在VS2022的环境下,当第二个位段成员较大,无法容纳于第一个位段成员剩余的空间时,浪费掉剩余的空间。

并且我们得知了位段变量r所占空间大小为2个字节。

3、位段的跨平台问题

①int 位段被当成有符号数还是无符号数是不确定的。

②位段中最大位的数目不能确定。(16位机器最大16,32位机器最大32,写成27,在16位机 器会出问题。

③位段中的成员在内存中从左向右分配,还是从右向左分配标准尚未定义。

④当一个结构包含两个位段,第二个位段成员比较大,无法容纳于第一个位段剩余的位时,是舍弃剩余的位还是利用,这是不确定的。

4、位段的应用

位段相对于结构体最大的优点就是能够节省空间,例如,一个变量只需要存储0或或2或3。如果使用整形变量的话,就会多浪费掉30个比特位的空间。此时我们使用位段就能够很好地节省空间。

位段的缺点就是存在跨平台问题,所以我们在使用位段时一定要斟酌。

三、枚举

枚举顾名思义就是一一列举,例如一周中的7天从星期一到星期日可以一一列举。再比如人的性别可以列举出男或女。

1、枚举类型的声明

//枚举
//枚举关键字enum不能舍弃
enum  Sex
{
  male,
  famale
};
//枚举也可以相结构体一样改名
typedef enum Country
{
  China,
  Americ,
  Italy,
  England,
  Korea,
  Japan
}Country;

以上定义的enum Sex、Country都是枚举类型。{}内的是枚举类型的所有可能取值,我们称之为枚举常量。每个枚举常量都有对应的值,默认从0开始,依次加1。例如,枚举常量China默认的值是0,America的默认值就是1,以此类推。

我们也可以在定义时就赋值。

enum Color
{
  Red = 3,
  Blue = 5,
  purple,//值为前一个值+1,也就是6
  Yellow = 11,
  green,//值为前一个值+1,也就是12
};

2、枚举的优点

枚举和用#define定义常量所达成的效果十分类似,我们为什么要用枚举呢?

枚举的优点:

①增加代码的可读性和可维护性。

②和#define定义的标识符相比枚举有类型检查,更加严谨。

③防止了命名污染。

④便于调试。

⑤使用方便,一次可以定义多个常量。

关于第①点,举个例子

int main()
{
  printf("1.add      2.sub\n");
  printf("3.mul      4.div\n");
  int option;
  printf("请输入选项:");
  scanf("%d", &option);
  switch (option)
  {
    case 1:
      printf("加法");
      break;
    case 2:
      printf("减法");
      break;
    case 3:
      printf("乘法");
      break;
    case 4:
      printf("除法");
      break;
    defalut:
      printf("错误");
      break;
  }
  return 0;
}

这段代码没有任何问题,但如果但是当我们调试这段代码时忘记了1,2,3,4代表的是执行什么命令时,还得回去查看,在这段代码可能无法体现出这种问题,但如果是一段非常复杂的代码,这种问题就会非常的麻烦,此时我们就可以使用枚举。

enum Option
{
  add=1,
  sub=2,
  mul=3,
  div=4
};
int main()
{
  printf("1.add      2.sub\n");
  printf("3.mul      4.div\n");
  enum Option option;
  printf("请输入选项:");
  scanf("%d", &option);
  switch (option)
  {
    case add:
      printf("加法");
      break;
    case sub:
      printf("减法");
      break;
    case mul:
      printf("乘法");
      break;
    case div:
      printf("除法");
      break;
    defalut:
      printf("错误");
      break;
  }
  return 0;
}


此时代码就非常地直观了,增强了代码的可读性和可维护性。

关于第②点

#define只是简单地进行替换,并非定义一个类型,所以#define定义的标识符是没有类型可言的。但是枚举是有类型的,这也就意味着如果把非枚举类型的数据赋值给枚举变量时,是非法的。

关于第③点

当#define定义了一个标识符,那么其他地方就无法使用同名的标识符,这就被称为命名污染例如

自定义类型详解_偏移量_33

编译器会报警告。

自定义类型详解_成员变量_34

而使用枚举则不会有任何问题。

关于第④点

枚举类型的数据是可以在调试中观察的

自定义类型详解_位段_35

能够更加方便我们进行调试。

四、联合体(共用体)

1、联合体的定义

联合体也是自定义类型的一种,联合体类型的变量也会由一个个成员构成,特点就是这些成员公用同一块内存。

//联合体
//联合类型的声明
union S1
{
  char a;
  int b;
};
int main()
{
  //联合变量的定义
  union S1 s;
  //计算联合变量的大小
  printf("%d", sizeof(s));
  return 0;
}

2、联合体的特点

联合体成员是共用同一块内存空间,一个联合体变量的大小,至少得是最大成员的大小(至少得方的下最大的成员。)

自定义类型详解_偏移量_36

我们用一段代码来验证。当我们分别求联合体变量s,以及s的两个成员变量的地址时,它们的地址都是一样的,这也就印证了联合体成员变量公用同一块内存空间。

3、联合体的内存分配

看下面这题

自定义类型详解_成员变量_37

因为联合体的成员公用同一块内存空间,所以这个联合体只需要4个字节的空间就可以存放两个成员。

结果如下:

自定义类型详解_成员变量_38

再看下面这一题

自定义类型详解_成员变量_39

成员a是一个字符数组,共占6个字节,如果联合体变量s有6个字节的空间,那么放4个字节的成员b也不是问题,那么答案是6吗?

自定义类型详解_偏移量_40

然而正确答案案是8。

为什么呢,因为联合体也是有对齐的,上面这题,成员a的对齐数是1,成员b的对齐数是4,那么这个联合体变量的大小就应该是成员中最大对齐数的整数倍,也就是8个字节。也就是说浪费了2个字节的空间。

下图,假设一个代表1个字节的空间

自定义类型详解_位段_41


本文结束。