12. C生万物之详解结构体、枚举与联合体

一、结构体的声明

1、结构的基础知识

结构是一些值的集合,这些值称为成员变量。结构的每个成员可以是不同类型的变量

2、结构的声明

  • 下面是结构体声明的形式
struct tag
{
    member-list;
}variable-list;
  • 例如现在要使用结构体来描述一个学生:里面就存放了一个学生所具有的基本信息,如姓名、年龄、性别、身高
struct Stu {
    char name[20];	//姓名
    int age;		//年龄
    char sex[2];	//性别
    float height;	//身高
};	
  • 或者用结构体来描述一本书,里面存放了:书名、作者、定价、书号
struct Book {
    char name[20];
    char author[20];
    double price;
    int id;
};
  • 不仅如此,结构的成员还可以是标量、数组、指针,甚至是其他结构体

3、特殊的声明

接下去我来介绍一种特殊的结构体声明形式,也就是【匿名结构体】,也就是没有结构体名称

//匿名结构体类型
struct
{
    int a;
    char b;
    float c;
}x;
struct
{
    int a;
    char b;
    float c;
}a[20], * p;
  • 可以看到,我故意将两个结构体的内容设置成一样,然后直接在声明的时候就定义出了它们各自的结构体成员,第二个结构体定义了一个结构体数组和结构体指针
  • 此处我使用这个结构体指针去接收第一个结构体定义出来的变量x,此时你应该会觉得合情合理,但是编译之后却看到【它们的类型不兼容】,这是为什么呢?
p = &x;

原因其实就在于这个【匿名结构体】

  • 对于匿名结构体来说呢只能在声明的时候用一次,后面就不能再用了。所以这个东西其实也没什么用,了解一下就行
  • 不过这个语法形式和C++中的【匿名对象】很类似,声明周期只在当前行,进入下一行就会调用析构函数

4、结构的自引用

有关结构体自引用这块,就要说到数据结构里面的相关知识了,

  • 说起自引用这一块最典型的其实就是数据结构中的《链表》,可能有的C语言教科书上也会提及。但如果要说链表的话那就得先说说顺序表
    • 对于顺序表而言:就是存放在内存中一块连续的存储空间
    • 对于链表而言:是由存放在内存中一些不连续结点所链接而成的

  • 那要如何去声明一个链表的结构体,下面这样可行吗?
struct Node
{
    int data;
    struct Node next;
};
  • 可以看到,程序报出了错误,说是【struct Node未定义】,这是为什么呢?

  • 其实对于链表的单个结点来说,是由两个域组成的,一个叫做【数据域】,是存放结点数据的,一个叫做【指针域】,是存放下一个结点在堆内存中地址,图示如下:

  • 那指针域要存放一个结点,也就是结构体的地址,就必须要一个结构体指针,此时我们就可以将代码写成下面这样,每个结构体指针都指向下一个结点的地址
struct Node
{
    int data;
    struct Node* next;
};
  • 此时就可以通过这个结构体定义出有关链表的结点,然后去初始化链接每个结点即可
struct Node list;

但你是否有觉得上面这种形式太麻烦了,每次在定义一个结构体变量的时候都要在前面加上一个struct,如果可以不加该多好

  • 这个其实很好办,只需要在结构体声明的时候在前面加上要一个typedef关键字即可,然后再定义变量的位置为其重命名一下,那在定义结构体变量的时候就不需要再加上struct关键字了
typedef struct Node
{
    int data;
    struct Node* next;
}Node;
  • 【友情提示】:可不能把结构体定义成下面这样,Node* next;这种写法是错误的,因为到这行为止,结构体还不认识Node,所以是不可以使用它的
typedef struct Node
{
    int data;
    Node* next;
}Node;

二、结构体变量的定义和初始化

有了结构体类型,那如何定义变量,其实很简单。

  • 第一种方法就是直接在声明出结构体的时候就可以定义结构体变量,例如这里的s1、s2、s3指的的都是一个学生,而且它们属于全局变量
struct Stu {
    char name[20];	//姓名
    int age;		//年龄
    char sex[2];	//性别
    float height;	//身高
}s1, s2, s3;	//全局变量
  • 第二种方法就是脱离结构体进行定义,不过这和我们在定义普通变量的格式是一样的,也要在前面加上数据类型,比如int a前面的int
  • struct Stu就是这个结构体的类型,不要忘记加上前面的struct这个修饰符了。下面的【ss】定义在外面,那就是全局变量;【su】定义在函数内部,那就是局部变量
struct Stu ss;		//全局变量
int main(void)
{
    struct Stu su;	//局部变量
    return 0;
}
  • 如果你觉得每次写 struct Stu太麻烦了,也是有办法了,那就是为其进行一个重命名,一般我们直接在结构体最前面加上一个typedef关键字,然后在定义处为其做一个重命名
typedef struct Stu {
    char name[20];	//姓名
    int age;		//年龄
    char sex[2];	//性别
    float height;	//身高
}S;
  • 因此可以说 S == struct Stu,定义方式就简洁了许多
struct Stu su;
S su2;

结构体如何初始化

  • 我们将初始化的内容使用花括号{ }括起来,里面就可以对结构体的成员进行一个初始化,分别意义对照进行初始化即可
struct Stu su = { "zhangsan", 20, "男", 180 };
  • 这里我又定义了一个【点】的结构体,成员变量即为坐标x和坐标y
struct Point 
{
    int x;
    int y;
};
  • 对其初始化也很简单,如下
struct Point p = { 10, 20 };

但是现在我又有了一个结构体,这个结构体内部呢又有一个结构体,就是上面这个【点】,这该如何初始化呢?

struct MyStruct
{
    char c;
    struct Point p;
    double d;
    char str[20];
};
  • 很简单,其他普通的成员变量就正常初始化,结构体成员变量的话也按照结构体的方式使用花括号括起来即可
struct MyStruct ms = { 'c', {40, 80}, 3.14f, "haha" };

来看看初始化后的结果

不想按照顺序来进行初始化也是可以的

  • 只需要用[.]操作符然后选择对应的成员变量进行初始化即可
struct MyStruct ms2 = {.d = 6.28, .str = "abcdef", .c = 'cc'};		//乱序初始化
1

来看看这样初始化后的结果为多少。可以观察到没有被初始化到的变量就取为默认值,也就是这个【点】

三、结构体成员的访问

结构变量的成员是通过点操作符[.]访问的。点操作符接受两个操作数

  • 这一点我们上面已经使用过了,就不多说,主要再介绍一种使用[->]操作符的形式进行访问,这一点我们在指针章节就有说到过,所以我们可以可以称其为结构体指针
  • 有时候我们得到的不是一个结构体变量,而是指向一个结构体的指针。例如下面将一个结构体变量的地址给到一个结构体指针的,那就可以通过这个结构体指针去访问到这个结构体变量的成员了
struct Stu
{
    char name[20];
    int age;
};

int main()
{
    struct Stu s1 = { "zhangsan", 20 };
    printf("普通形式访问:%s %d\n\n", s1.name, s1.age);

    struct Stu* ss = &s1;
    printf("指针形式:%s %d\n", ss->name, ss->age);

    return 0;
}

  • 那可以访问了,可不可以去进行一个修改呢?当然是可以的
  • 例如下面又定义了一个结构体,然后通过乱序的方式去进行了一个初始化
struct S 
{
    char name[20];
    int age;
};

int main()
{
    struct S s = { .age = 22, .name = "zhangsan" };
}
  • 下面是对成员变量进行修改的方式,你觉得正确吗?对于s.age = 30;来说是没有问题的,但是呢对于姓名的修改来说其实是存在问题的,在数组章节我们有说到过对于【数组名】来说指的就是首元素地址
s.age = 30;
s.name = "zhangsanfeng";
  • 所以单单拿到一个数组的首元素地址,是无法对整个字符数组进行一个修改的,要么通过指针的形式遍历这个字符串做一一修改,不过这里我想要从整体的修改那么就得使用到一个有关字符串的库函数strcpy,不了解的可以去学习一下
strcpy(s.name, "zhangsanfeng");

四、结构体内存对齐

1、前言

在结构体章节,我们掌握了结构体的基本使用,但是现在我要你去计算一个结构体的大小,你会怎么做呢?

  • 现在我定义了两个结构体,通过观察可以发现它们内部的成员变量都是一样的,均有c1c2i三个成员变量,那此时分别去计算它们两个结构体的大小, 最后的结果会是多少呢?会是一样的吗
struct S1 
{
    char c1;
    int i;
    char c2;
};

struct S2 
{
    char c1;
    char c2;
    int i;
};

int main()
{
    printf("%d\n", sizeof(struct S1));
    printf("%d\n", sizeof(struct S2));
    return 0;
}
  • 通过运行可以发现两者是不一样的,这是为什么呢?如果你没有结构体内存对齐的相关知识,那相信你一定会这么去计算:
    • 在结构体S1中,c1的类型为【char】,是1个字节;
    • i的类型是【int】,是4个字节
    • c2的类型为【char】,是1个字节;
  • 那么最后的结果就是1 + 4 + 1 = 6B,可事实呢,原不止这些。。。

结构体偏移量计算:

offsetof

  • 就上面这么来看还是看不出什么细节的内容,给读者介绍一个宏叫做offsetof,它可以用来计算结构体成员相对于起始位置的偏移量,在使用这个函数的时候就需要引入头文件stddef.h

它的第一个参数是结构体类型,第二个参数是结构体成员

printf("%d\n", offsetof(struct S1, c1));
printf("%d\n", offsetof(struct S1, i));
printf("%d\n", offsetof(struct S1, c2));
  • 最后,计算出来的结果分别是【0】【4】【8】,那我们可以通过画内存图来看看结构体中的三个成员变量在内存中究竟是如何分布的
  • 可以看出,因为总的结构体大小为12B,可是在放完这3个成员后中间空出了三个位置,并且对于最后在c放完之后还没有到达12B,所以还得再浪费3个空间的废位置

规则介绍:

2、规则介绍

  1. 第一个成员在与结构体变量偏移量为0的地址处
  2. 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处
    • 对齐数 = 编译器默认的一个对齐数 与 该成员大小的较小值
    • VS中默认的值为8Linux环境默认不设对齐数(对齐数是结构体成员自身的大小)】
  3. 结构体总大小为最大对齐数(每个成员变量都有一个对齐数)的整数倍
  4. 如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍
例题的分解与细说

我们再来回顾一下上面这个结构体的大小该如何计算

  • 假设我这里创建一个结构体变量叫做ss,它的起始地址就从0开始,所以根据第一条规则,第一个成员变量在与结构体变量偏移量为0的地址处,而且它的类型还是char,所以只占1个内存单元

  • 接下去看第二个成员变量i,其为整型所以在内存中就需要存储4个字节的大小,此时便要拿其和VS下默认对齐数8去进行比较,取较小的值4
  • 算出来【4】之后便要对齐到4整数倍的地址处,那就是4这块空间,往下一直占用4个字节,这就是成员变量i在这个结构体中的内存占用分布
  • 那既然这个i是从4的位置开始放的,中间空出来的位置就不会再放置其他成员变量了,那么这个3个空间也就浪费了

  • 接下去放置第三个成员变量c2,char类型的变量为1个字节,和8比较取小就是1,那就要将其放到1整数倍的地址处,那其实任何空间都是可以的,直接放到这个【8】的位置就行
  • 那截止目前为止这个结构体中的所有成员变量都放置完了,此时去计算一个所占的内存空间就可以发现只有9个字节。但是在一开始我们计算的这个结构体的大小为12个字节,可是现在还差3个字节,所以最后就要去进行一个填充。但是,为什么呢?

  • 这就要用到第三条规则了:结构体总大小为最大对齐数的整数倍
  • 那在这么计算下来之后,就可以知道结构体中的最大对齐数为4,那么【9】、【10】、【11】都不是它的整数倍,只有【12】是它的整数倍的地址处(注意这里是地址处!),因此我们需要填充3个字节,此时从0 ~ 11就有12个字节了,便为4的整数倍 这就是【12】如何被计算出来的全过程

首先还是一样,c1放在这个与结构体变量偏移量为0的地址处,而且它的类型还是char,所以只占1个内存单元

  • 接下去还是一样,在放置第二个成员变量开始就要考虑【对齐数】了,char所占的字节为1B,与8去进行比较一下就可以知道1来得小,那我们直接放在偏移处为1的地方就可以了,此时在内存中也只占了2个字节

  • 接下去放置第三个成员变量【i】,大小为4个字节小于8因此选择在4的整数倍的地址处开始放置这个变量,整型占4个字节,所以一直占用到偏移量为7的地方
  • 接下去就是计算整个结构体的大小,最大对齐数为4,所以要为4的整数倍,此时去计算一下得知从0 ~ 7偏移了7个字节,占用了8个空间,刚好为4的整数倍,所以结构体S2的大小为【8】是这么算出来的

3、习题演练

练习①

你可以先试着自己做一做,然后对一下是否正确

struct S3
{
    double d;
    char c;
    int i;
};
printf("%d\n", sizeof(struct S3));

【分析】:

  • 首先看到第一个成员变量,从偏移量为0的地址处开始放起,因为double类型的数据在内存中占8个字节,所以一直占用偏移处为7的地方

对于第二个成员变量【c】,类型为char,所以在内存中占用1个字节,那直接放在偏移量为8的地址处即可

  • 接下去来安排第三个成员变量【i】,整型占用4个字节,比VS下默认对齐数8来得小所以【对齐数为4】,去寻找4整数倍的地址处,【9】、【10】、【11】都不是,【12】是4的整数所偏移的地址处,从此处开始往下数4个字节的空间,刚好放满15
  • 最后我们便去计算整个结构体的大小,为最大对齐数的整数倍,最大对齐数是8,计算一下放置三个成员变量占了16个空间,刚好是8的整数倍,因此16即为结构体的大小

运行结果如下:

也可以通过【offsetof】来验证一下

练习②

接下去再来做一道练习,涉及结构体嵌套的问题,对应的需要使用到规则4

struct S3
{
    double d;
    char c;
    int i;
};

struct S4
{
    char c1;
    struct S3 s3;	//成员变量为另一个结构体
    double d;
};

因为本题的结构体比较大,所以就标出4的整数倍所在的地址

  • 首先还是一样,来看到第一个成员变量【c1】,放到与结构体变量偏移量为0的地址处,又因为类型为char,所以只占一个字节的空间

  • 接下去,就是嵌套的结构体s3,此时我们要对齐到s3这个结构体中最大对齐数的整数倍处,那么最大对齐数就是【8】,所以要从8的地址处开始往下放置,那要占用多少空间呢?这就是s3这个结构体的大小【16】,所以一直往下数16个空间即可,一直到23这个地址处
  • 那么中间的这7个位置就算是浪费了

  • 最后就是这个【d】,与VS中的默认对齐数一致,所以为【8】,下一个24刚好为8整数倍的地址处,所以从这开始放,double类型的数据在内存中占8个字节,所以一直到31的地址处
  • 然后来算整个结构体s4的大小,为所有最大对齐数(含嵌套结构体的对齐数)的整数倍,也就是取s3和s4中的最大对齐数,那也就是【8】,计算一下结构体s4所占的内存空间为32,刚好为8的整数倍,所以整个结构体的大小即为32

运行结果如下:

可以通过【offsetof】再来验证一下

4、为什么存在内存对齐?

经过了两道例题和两道练习题的训练,相信你对如何计算结构体的大小一定是心中有数了,但在阅读的过程中你是否有疑惑为什么会存在这个【结构体内存对齐】呢?有什么实际意义吗?

① 平台原因(移植原因)

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

② 性能原因

  • 假设下面有一个结构体,内部有两个成员变量ci,然后要在内存中存储它们,我分为了两种,一个是【无内存对齐】,呈现的是紧密存放;一个是【内存对齐】,需要考虑到最大对齐数
  • 然后在32位平台下去分别访问结构体中的成员,假设现在读取数据的时候一次性读四个字节。
    • 首先看到的是【无内存对齐】的结构体内存分布,读一次就能读到c,但是若要全部读取完i,就还需要再读取一次,那访问到所有的成员变量就需要两次;
    • 接下去看到的是【内存对齐】的结构体内存分布,因为内存对齐的缘故,所有两个成员变量ci互不干扰,此时再看到成员变量i,从它的初始地址处开始读取,一次读4个字节,那么读1次就刚刚好可以读完这个变量了,而不是像上面那样还需要再读一次

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

总体来说:

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

了解了为什么会存在内存对齐之后,我们再回到一开始的这两个结构体,你是否有想过为什么两个结构体的成员变量都一模一样但是大小却是一个【12】,一个【8】呢?

  • 没错,就是你想到的它们所存放的位置不一样罢了。因为要存在内存对齐,所以若两个对齐数大的成员变量定义在一起的话为了满足规则就可能会浪费很多空间的内存。
  • 但若是两个对齐数较小甚至相同规定的变量定义在一块的话,可能它们就是挨着放的,占用的空间少了↓,那最后结构体的大小就变小了
struct S1 {
    char c1;
    int i;
    char c2;
};

struct S2 {
    char c1;
    char c2;
    int i;
};
  • 所以,那在设计结构体的时候,我们既要满足对齐,又要节省空间,就要让占用空间小的成员尽量集中在一起

5、如何修改默认对齐数

之前我们见过了 #pragma 这个预处理指令#pragma comment,用来链接函数的静态库。这里我们再次使用,可以改变我们的默认对齐数

  • 用法很简单#pragma pack(1)就可以设置默认对齐数为1,#pragma pack()就可以取消设置的默认对齐数,还原为默认。到它为止的默认对齐数还是被修改后的对齐数
  • 接下去就来看下面这个修改完默认对齐数后的结构体,它的大小会是多少呢?
#pragma pack(1)//设置默认对齐数为1
struct S1
{
    char c1;
    int i;
    char c2;
};
#pragma pack()//取消设置的默认对齐数,还原为默认

int main()
{
    //输出的结果是什么?
    printf("%d\n", sizeof(struct S1));
    
    return 0;
}
  • 可以看到,若是默认的对齐数设置为1的话,那其实可以看出每个成员变量的对齐数就都是1了,那么也就不存在浪费的现象,因为任何数都是1的整数倍,所以3个成员变量的内存分布如下,大小即为【6】

运行结果如下:

可以通过【offsetof】再来验证一下

结论:

结构在对齐方式不合适的时候,我么可以自己更改默认对齐数

6、实战演练

offsetof 宏的实现

在上面的每一个结构体计算后,我都使用到了offsetof这个宏,和我画出来的内存分布图完全就是一致的,那它的原理到底是怎样的呢?马上来探究一下:

曾经有一年的百度笔试题就考到了有关offsetof 的实现原理

【原题】:写一个宏,计算结构体中某变量相对于首地址的偏移,并给出说明

  • 我们通过上面的结构体S1进行讲解。列出3个成员变量放置的初始地址,其实【offsetof】计算的也就是每个变量在内存中的起始地址相较于首地址偏移了多少,那将它们进行一个相减就可以得出048这三个结果

  • 但是上面的这些地址太复杂了,都是十六进制的,接下去我们来将 c1这块地址设置为0,那么
    • 【c1】相对于自己的偏移量就是&c1 - 0
    • 【i】相对于自己的偏移量就是&i - 0
    • 【c2】相对于自己的偏移量就是&c2 - 0
  • 但其实这可以看出,虽然每个成员变量各自的偏移量为他们的地址减去首地址,但是可以看出这减了和没减有什么区别呢?所以可以得出它们三者的偏移量其实就是他们各自的初始地址

知道了上面这些我们就可以使用【宏】来实现每个成员变量偏移量的计算了

#define OFFSETOF(m_type, m_name)	(int)&(((m_type *)0)->m_name)
  • 不过相信你一定看不懂上面这个宏,所以我会来一步步讲解一下
    • m_type是结构体变量;m_name是结构体成员
  • 首先是地址为0的这个地方要放置结构体成员,但是0是一个整型,所以我们使用强制类型转换将0转换成一个结构体的指针,那么在外部传入结构体成员变量的时候就符合类型了
#define OFFSETOF(m_type, m_name)	(m_type *)0

printf("%d\n", OFFSETOF(struct S1, c1));
  • 那既然这是一个结构体指针的话,就可以访问到其内部的结构体成员变量,也就是这个m_name
#define OFFSETOF(m_type, m_name)	((m_type *)0)->m_name
  • 那么在上面说到过,每个结构体成员变量的地址就是它相对于起始位置的偏移量
#define OFFSETOF(m_type, m_name)	&(((m_type *)0)->m_name)
  • 但是呢,在打印的时候可以看出对于偏移量而言都是第一个整数,所以还要对取到的地址偏移转换为整型,便是最后的结果
#define OFFSETOF(m_type, m_name)	(int)&(((m_type *)0)->m_name)

下面是流程图:

两道高频面试题

结构体怎么对齐? 为什么要进行内存对齐?

  1. 第一个成员在与结构体变量偏移量为0的地址处
  2. 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处
    • 对齐数 = 编译器默认的一个对齐数 与 该成员大小的较小值
    • VS中默认的值为8Linux环境默认不设对齐数(对齐数是结构体成员自身的大小)】
  3. 结构体总大小为最大对齐数(每个成员变量都有一个对齐数)的整数倍
  4. 如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍
  • 为什么要进行内存对齐呢?原因有两个,一个是平台本身的原因,任意地址上的任意数据是不能随意访问的,如果不正确访问可能会造成硬件异常。第二个就是性能原因,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问

如何让结构体按照指定的对齐参数进行对齐?能否按照3、4、5即任意字节对齐?

  • 可以的,只需要使用一个预处理指令#pragma pack(3)便可以将默认对齐数修改为3,其他的也是同理,因为结构体默认对齐数发生了变化,此时就会导致结构体大小发生变化

五、结构体传参

最后我们再来说说有关结构体的传参

直接上代码:

struct S
{
    int data[1000];
    int num;
};
struct S s = { {1,2,3,4}, 1000 };

//结构体传参
void print1(struct S s)
{
    printf("%d\n", s.num);
}

//结构体地址传参
void print2(struct S* ps)
{
    printf("%d\n", ps->num);
}

int main()
{
    print1(s);		//传整个结构体
    print2(&s);		//传地址
    return 0;
}
  • 可以看到,这里我对于结构体的传参使用了两种方式,一种是直接将整个结构体传过去,一个则是将这个结构体的地址传过去,然后在形参中用指针进行接收

上面的 print1print2 函数哪个好些?

  • 答案是:首选print2函数
  • 原因:函数传参的时候,参数是需要压栈的。如果传递一个结构体对象的时候,因为形参是实参的一份临时拷贝,实参传递过来的结构体有多大,形参也要开辟一块同样大小的空间来存放这个结构体,此时若是结构体过大,参数压栈的的系统开销比较大,所以会导致性能的下降。
  • 但若是我们只传递这个结构体的地址过去的话,函数内部便可以使用结构体指针访问到所有的内容,以便节省开销

所以在结构体传参的时候,要传结构体的地址

六、位段

  • 结构体讲完就得讲讲结构体实现位段的能力。

1. 什么是位段

  • 位段的声明和结构是类似的,有两个不同:

1.位段的成员必须是intunsigned intsigned int
2.位段的成员名后边有一个冒号和一个数字。

比如:

struct A
{
    int _a : 2;
    int _b : 5;
    int _c : 10;
    int _d : 30;
};
  • A就是一个位段类型。
  • 那位段A的大小是多少?
printf("%d\n", sizeof(struct A));

2. 位段的内存分配

  1. 位段的成员可以是 int unsigned int signed int 或者是 char (属于整形家族)类型
  2. 位段的空间上是按照需要以4个字节( int )或者1个字节( char )的方式来开辟的。
  3. 位段涉及很多不确定因素,位段是不跨平台的,注重可移植的程序应该避免使用位段。

列如:

struct S
{
 char a:3;
 char b:4;
 char c:5;
 char d:4;
};
struct S s = {0};
s.a = 10;
s.b = 12;
s.c = 3;
s.d = 4;

空间是如何开辟的?

3. 位段的跨平台问题

  1. int 位段被当成有符号数还是无符号数是不确定的。
  2. 位段中最大位的数目不能确定。(16位机器最大16,32位机器最大32,写成27,在16位机
    器会出问题。
  3. 位段中的成员在内存中从左向右分配,还是从右向左分配标准尚未定义。
  4. 当一个结构包含两个位段,第二个位段成员比较大,无法容纳于第一个位段剩余的位时,是
    舍弃剩余的位还是利用,这是不确定的。

4. 总结:

跟结构相比,位段可以达到同样的效果,但是可以很好的节省空间,但是有跨平台的问题存在。

5. 位段在网络传输中的应用

  • 如果要定义一个版本那就至少需要一个char类型的,一个char类型就会占用8个比特位,那么使用位段就会很大程度上就节省了空间

七、枚举

1、枚举类型的定义

对于枚举,顾名思义就是一一列举,把一个事物可能的取值一 一地列举出来

  • 例如在我们现实生活中一周的星期一到星期日是有限的7天,可以一一列举
//星期
enum Day{
    Mon,	
    Tues,
    Wed,
    Thur,
    Fri,
    Sat,
    Sun
};
  • 性别有:男、女、保密,也可以一一列举
enum Sex{
    MALE,
    FEMALE,
    SECRET
};
  • 有很多的颜色,也可以一一列举
enum Color{
    RED = 3,	
    GREEN,		
    BLUE
};

以上定义的 enum Dayenum Sexenum Color 都是枚举类型。{}中的内容是枚举类型的可能取值,也叫 【枚举常量】

  • 要怎么证明它是一个常量呢?我们可以试着去修改以下里面的这个值,编译一下就可以发现这个GREEN是不可以修改的

  • 虽然说这个常量是不可改变的,但是对于一个常量在一开始也是需要有一个值,即我们所说的【初始值】,对于定义在枚举内部的常量,是存在初始值的,默认从0开始,依次递增1
  • 我们可以去打印来观察一下是否存在上面这样的规律💻

  • 当然,我们也可以自己去做赋值

  • 除此之外,枚举内部的这个值是会自动增长的,假如我们给RED设了一个初始值为3,那么GREEN和BLUE的初始值便会去进行一个自动的增长的,如下所示↓

2、枚举的使用

那这个枚举定义出来后,要怎么去使用它呢?

  • 其实这和我们在使用结构体的时候是类似的,不过这边记得要去做一个初始化,否则会爆出一个Warning
  • 但是在初始化的时候,我们不可以像下面这样去初始化,虽然在VS上进行编译是没问题的,但是在其他平台上的话就不一定了。因为这里的3是一个整型,但左侧的c2却是枚举类型,两个类型是不一样的,所以不可以这样去做一个初始化,只能拿枚举常量给枚举变量赋值,才不会出现类型的差异

3、枚举的优点

那为什么要使用枚举呢?它有什么优势所在吗?

1、增加代码的可读性和可维护性
2、和#define定义的标识符比较枚举有类型检查,更加严谨。

  • 例如说我在这里使用#define去定义了一个MALE2,它的值是6,之前我们在讲这个预处理的时候,有说到过宏是没有类型的,所以编译器在编译的过程中就不会对这个MALE2做类型检查,而是在预处理阶段就直接替换了
  • 但是呢,对于枚举变量来说,它是有类型的,即为enum,所以若是你在定义的过程中出现什么语法错误的话就会直接报错,显得就比较严谨
#define MALE2 6

enum Sex{
    MALE = 1,	// 枚举变量可以赋值
    FEMALE = 2,
    SECRET = 4
};

3、防止了命名污染(封装)

  • 对枚举来说,它将类型相同的常量放在了一起,就不会造成命名冲突了

4、便于调试

  • 对于枚举来说,还有一个很大的特点就是便于调试,在程序环境和预处理中我们有讲到对于【宏】来说是无法进行调试的,因为在预处理阶段就直接进行替换了,是无法进行调试的
  • 可以观察到,当我去进行调试的时候,在【监视窗口】中是可以看到它们的值的,但是呢在按F11的时候无法进入,而是直接将MAX的值进行了一个替换,所以宏是无法进行调试的

5、使用方便,一次可以定义多个常量

  • 还记得我们上面定义的一个枚举Day吗,里面存放了从周一到周日七个枚举常量,但若是我们不使用枚举的话,而是用#define去进行定义的话,就需要写7行,虽然看上去很整齐美观,但是在写的时候却没有枚举来得方便
#define MON 1
#define TUE 2
#define WED 3
#define TUS 4
#define FRI 5
#define SAT 6
#define SUN 7

八、联合体

1、联合体类型的定义

联合也是一种特殊的自定义类型。这种类型定义的变量也包含一系列的成员,特征是这些成员公用同一块空间(所以联合也叫共用体)。

  • 以下就是一个联合体的声明,通过观察它的形式可以发现其与结构体和枚举非常类似,尤其是和【结构体】,里面可以存放不同的数值类型
// 联合类型的声明
union Un
{
    char c;
    int i;
};
  • 在声明完后去进行定义也是类似的样子
//联合变量的定义
union Un un;

2、联合体的特点

既然它被称作是联合体(公用体),那一定是可以存在公用一些什么东西的,我们可以去打印其中的变量、地址来观察一下

  • 于是就有了新的发现,无论是对于这个联合体本身还是其内部成员,它们都使用同一块内存地址
  • 我们可以通过画图的方式来观察一下,通过sizeof(un)可以看到这个联合体在内存中所占的字节数为4,当然为什么为4,后面在谈到【联合体的计算】时我们再去细细地讲一讲这块

  • 因为在联合体内部,有char类型的变量c和int类型的变量i,前者占1个字节,后者占4个字节,此时变量【c】就是从[0122F928]这块地址开始放置,总共的话就占一个字节。那既然编译器为联合体un就分配了4个字节的空间,而且变量【i】也是从[0122F928]这块地址开始放置,总共也就占这4个字节
  • 那其实就很清晰可以看出:对于联合体内部的成员都是共用一块地址空间的,这样一个联合变量的大小,至少是最大成员的大小(因为联合至少得有能力保存最大的那个成员)

所以这就可以解释得通为何这个联合体的大小是4了,因为在联合体中成员i是int类型的,其所占的字节数是里面最多的为4个字节

3、联合体大小的计算

规则:

  • 联合的大小至少是最大成员的大小。
  • 当最大成员大小不是最大对齐数的整数倍的时候,就要对齐到最大对齐数的整数倍。

下面这个联合体u1的大小是什么

union Un1
{
    char c[5];
    int i;
};

int main()
{
    printf("%zu\n", sizeof(union Un1));
    return 0;
}
  • 通过观察可以发现,联合体U1中有一个大小为5的字符数组,那么其在内存中就需要占5个字节的大小,那对于变量i来说就需要占4个字节的大小,但为什么最后打印出来的结果是【8】呢?

  • 这就要去考虑内存对齐
  • 很简单,字符数组c中的每一个元素在内存中所占大小为1个字节,VS默认对齐数为8,即为1;成员【i】在内存中所占大小为4个字节,VS默认对齐数为8,即为4;因为现在已经存放了5个字节的大小,我们要对齐到最大对齐数的整数倍,即为【8】,因此最后计算出来的联合体的大小就为8

例如:

union Un2
{
    short c[7];		// 14
                // 2/8 = 2
    int i;		// 4/8 = 4
};

来分析一下:

  • 联合体内部有一个大小为7的short类型数组,在内存中所占大小为14,整型变量i即为4,那它的大小是多少呢?c数组中的元素个数所占大小为2B,和8一比即为2,i类似,因为现在已经存放了14个字节的大小,我们要对齐到最大对齐数的整数倍,即为【16】,因此最后计算出来的联合体的大小就为16

4、联合体例子

  • 使⽤联合体是可以节省空间的
    举例: ⽐如,我们要搞⼀个活动,要上线⼀个礼品兑换单,礼品兑换单中有三种商品:图书、杯⼦、衬衫。 每⼀种商品都有:库存量、价格、商品类型和商品类型相关的其他信息

图书:书名、作者、⻚数
杯⼦:设计
衬衫:设计、可选颜⾊、可选尺⼨

  • 那我们不耐⼼思考,直接写出⼀下结构
struct gift_list
{
    //公共属性
    int stock_number;//库存量
    double price; //定价
    int item_type;//商品类型

    //特殊属性
    char title[20];//书名
    char author[20];//作者
    int num_pages;//⻚数

    char design[30];//设计
    int colors;//颜⾊
    int sizes;//尺⼨
};

  • 上述的结构其实设计的很简单,用起来也方便,但是结构的设计中包含了所有礼品的各种属性,这样使得结构体的大小就会偏大,比较浪费内存。因为对于礼品兑换单中的商品来说,只有部分属性信息是常用的。
  • 比如:商品是图书,就不需要design、colors、sizes。
  • 所以我们就可以把公共属性单独写出来,剩余属于各种商品本身的属性使用联合体起来,这样就可以介绍所需的内存空间,一定程度上节省了内存。
struct gift_list
{
    int stock_number;//库存量
    double price; //定价
    int item_type;//商品类型

    union {
        struct
        {
            char title[20];//书名
            char author[20];//作者
            int num_pages;//⻚数
        }book;
        struct
        {
            char design[30];//设计
        }mug;
        struct
        {
            char design[30];//设计
            int colors;//颜⾊
            int sizes;//尺⼨
        }shirt;
    }item;
};

九、笔试题

接下去给大家分享一下在校招的过程中可能会遇到的题目

9.1 一道字节校招笔试题

在X86下,小端字节序存储,有下列程序:

#include <stdio.h>
int main()
{
    union
    {
        short k;	// 2/4 = 2
        char i[2];	// 1/4 = 1
        // 大小:2
    }*s, a;
    s = &a;
    s->i[0] = 0x39;
    s->i[1] = 0x38;
    printf("%x\n", a.k);
    return 0;
}

输出结果是( A

A.3839
B.3938
C.380039
D.不确定

【解析】:

  • 首先看到这里有一个联合体,里面有两个成员,那此时我们可以通过上面所学习的【联合体】的知识,首先去算出这个联合体的大小为2字节,对于2字节来说有16个二进制位,即可以表示4个十六进制位,那么此时我们就可以先排除选项CD
  • 接下去就要分析结果是多少,首先我们看到为这个联合体成员数组i进行了赋值,那么此时在内存中这个数组的存放便是3938,但是不要忘了题目中明确给出这是在【小端字节序】中进行存储,所以内存中的低位要放到数值位中的低位,内存中的高位要放到数值位中的高位,此时我们在显示器上看到的数值位便是3839

  • 不过呢这里打印的是a.k,我们上面所操作的是数组,但是不要忘了这个数组是在联合体内部的,对于联合体来说最大的一个特征就是【所有成员共享同一块空间】,那么此时k的值也发生了变化,因此我们去打印k的值就相当于是在打印数组的值,最后的结果即为[3839]

9.2 判断当前计算机的大小端存储

  • 这到题其实我们在讲大小端存储的时候已经有讲到过了,还记得解题思路吗?根据大小端存储的特性:
    • 【大端存储模式】:是指数据的低位保存在内存的高地址中,而数据的高位,保存在内存的低地址中;
    • 【小端存储模式】:是指数据的低位保存在内存的低地址中,而数据的高位,,保存在内存的高地址中;
  • 所以我们在判断当前机器是大/小端字节的时候,只需要去判断这个数的地址的第一位是1还是0即可,这里就不细讲了
int check_sys(int num)
{
    char* p = (char*)&num;
    if (*p == 1) {
        return 1;
    }
    else {
        return 0;
    }
}

不过在学习了【联合体】之后,你是否可以使用它来进行实现呢?

  • 在此我封装了一个函数,里面呢声明了一个匿名联合体(和匿名结构体一样没有名称),然后在声明的同时直接定义了一个联合体un,将里面的成员【i】赋值为1,然后再返回成员【c】
int check_sys() 
{
    union 
    {
        char c;
        int i;
    }un;
    un.i = 1;
    return un.c;
}
  • 此时就需要我们前面所学习的联合体的特点了,因为联合体中的成员是共用一块内存空间的,所以给成员【i】进行赋值后,其使用二进制进行表示即为00 00 00 01,那么根据VS小端存放,当前内存地址中所存放的即为01 00 00 00,我们可以打开内存来观察一下:

  • 那么此时联合体中的成员c便为0x00EFF99C这块地址上的第一个字节即01,这个时候我们去return un.c的时候就是把这个01给返回回去了,使用int整型来接受即为1,所以最后打印的结果就是【小端】

posted @ 2024-07-03 15:57  shilinnull  阅读(22)  评论(0)    收藏  举报  来源