自定义类型:结构体(结构体的位段),枚举,联合
结构体是一些值的集合,这些值是成员变量。每个成员可以是不同类型的变量
struct stu
{
char name[20];
int age;
char sex[10];
char tele[20];
};
int main()
{
struct stu s1;
}
要记住结构体是一种类型,它的地位是和int这些类型是一样的,我们能用int做的事情,也可以用结构体做。唯一不同的是,结构体是通过我们自己去定义的,而int这些类型是我们c语言内置的类型
struct
{
int a;
char b;
float c;
}x;//注意在结构体的尾部我们是可以创建一个全局变量X的
struct
{
int a;
char b;
float c;
}a[20], *p;
//也可以创建一个元素均为结构体的数组,还创建了一个结构体类型的指针,
//通过这个指针我们可以访问指针所维护的成员变量abc等
p = &x;
//在上面代码的基础上,下面的代码合法吗?
这是完全不合法的,因为你创建结构体类型的时候,没有声明结构体的名字,编译器是不会认为这两个类型是一个类型的,它会把它认为成两个不同的类型
你用一个类型的指针去维护另外一个类型的变量的地址,这绝对会出问题
所以我们在使用匿名结构体时,一般所能应用到的场景就是,你后面确定不会在用这个结构体重新创建变量了,那就一次性把结构体类型的声明和变量的创建这两件事情都给做了
struct Node
{
int data;
struct Node next;
};
//这样的书写形式的代码正确嘛?
//如果正确的话,那sizeof(struct Node)是多少?
我们细微的思考一下这样的结构体的大小,就会发现问题,如果我们想要计算结构体里面的结构体大小,会发生死循环,结构体里面有个结构体,里面的结构体的里面又有个结构体,这就陷入了死循环,由此可见这样的书写形式一定是有问题的。
那么正确的书写形式又是什么样的呢?
struct Node//其实应该像下面这样的形式去写,这样的形式是正确的自应用方式
{
int data;
struct Node* next;
};
我们在结构体里面去创建一个结构体类型的指针,这样我们就可以通过这个指针去维护这个结构体,这也就是结构体的自引用,
这里我们在介绍一下,结构体的自引用的概念,官方的解答一下: 结构体的自引用就是,在结构体内部包含一个指向自身结构体类型的指针,我们就能用指针维护结构体的成员。
typedef struct//错误的代码书写方式
{
int data;
Node* next;
}Node;
typedef struct Node//正确的代码书写方式
{
int data;
struct Node* next;
}Node;
这种错误方式非常好理解,在我们重定义类型名之后,应该才能使用这个重定义的类型名。但你在定义的同时,同时又使用这个重新定义的类型名,这样的代码方式,编译器怎么能看懂啊! 编译器从上向下看代码,它首先看到的是没重定义之前的类型名,所以你要想在结构体内部定义的话,那你只能用没重定义之前的类型名。在这个结构体后面的代码,才可以使用重定义之后的名字
struct Point
{
int x;
int y;
}p1; //声明类型的同时定义变量p1
struct Point p2; //利用结构体类型定义了一个变量p2
struct Point p3 = {x, y};//利用结构体类型定义了一个变量p3,并且p3进行初始化
struct Node
{
int data;
struct Point p;
struct Node* next;
}n1 = {10, {4,5}, NULL}; //结构体嵌套初始化
struct Node n2 = {20, {5, 6}, NULL};//利用结构体裂隙定义变量n2,结构体嵌套初始化
练习1、
struct S1
{
char c1;
int i;
char c2;
};
printf("%d\n", sizeof(struct S1));//所以答案为12
结构体的对齐规则: 1.第一个成员在与结构体变量偏移量为0的地址处 2.接下来的每一个成员变量都需要对齐到自身对齐数的整数倍的地址处 3.对齐数就是本类型的字节数与编译器默认对齐数之间的较小值 例如gcc编译器是没有默认对齐数的,则对齐数就是变量类型的字节数 而vs2022编译器的默认对齐数是8 4.结构体的大小是最大对齐数的整数倍 5.如果有嵌套结构体的情况,那么所走的分析流程其实也是一样的,嵌套的结构体也是要对齐到自己的每个成员的对齐数的最大对齐数的整数倍处,最终结构体的大小就是整个所有结构体最大对齐数的整数倍处
练习2、
struct S2
{
char c1;
char c2;
int i;
};
printf("%d\n", sizeof(struct S2));
上面两段代码结果正好为12 8
练习3、
struct S3
{
double d;
char c;
int i;
};
printf("%d\n", sizeof(struct S3));
练习4、结构体嵌套问题
struct S3
{
double d;
char c;
int i;
};
struct S4
{
char c1;
struct S3 s3;
double d;
};
printf("%d\n", sizeof(struct S4));
结果正好为32字节
平台原因(移植原因):不是所有的硬件平台都能访问任意地址的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。例如某些平台默认一次访问2个字节或4个字节或8个字节。
性能原因:栈区中我们应该尽可能的在自然边界上对齐。原因在于,未对齐的内存数据在读取时,效率要往往低于对齐的内存数据 首先,栈区是我们最常访问的一个内存区域,这个区域的特点就是存储容量低,读取效率高,为了更快提升我们的cpu访问速率,我们采取了结构体内存对齐这样的内存存储方式
比如现在某个int型的数据存在地址为1234的地址(未对齐的存储方式)处,而我们读取从0地址开始读,这时我们先访问0123,发现无法完整拿到4个字节的int型数据,cpu就会继续向后读取4567这4个字节,这时我们就拿到完整的int型数据了,但我们明显的观察到这样的访问效率相比于对齐的访问效率,时明显要低一截的
这时又浮现出一个问题,就是虽然我们的访问效率高起来了,但我们浪费了很多的字节空间,所以结构体内存对齐就是用空间来换取时间的一种做法,我们想要在这两种之间达到一个平衡,可以这样做 在我们定义结构体成员变量的时候,尽量将占用内存较小的成员变量放在一块儿,这样也能节省一部分空间,而且也采用了内存对齐的方式
#pragma pack(4)//将编译器的默认对齐数修改为4
struct S
{
char c;//占1个字节
//浪费3个字节即可开始储存b
double b;//总共占12个字节
};
#pragma pack()//取消设置的默认对齐数
int main()
{
struct S s;
printf("%d\n", sizeof(s));
return 0;
}
当结构在对齐方式不合适的时候,我们可以通过#pragma pack()来修改默认对齐数或恢复默认对齐数这里的#pragma就是一个预处理指令,在预编译的时候操作系统会识别它
问题:写一个宏,计算结构体中某变量相对于首地址的偏移量,并给出说明
下面我们使用C语言库里面的宏offsetof来实现一下偏移量的计算
#include <stdio.h>
#include <stddef.h>
struct S
{
char c1;
int a;
char c2;
};
int main()
{
printf("%d\n", offsetof(struct S, c1));
printf("%d\n", offsetof(struct S, a));
printf("%d\n", offsetof(struct S, c2));
return 0;
}
看完上面的运行结果可以知道offsetof给我们返回了一个正确值
下面我们来使用一下我们自己编写的宏OFFSETOF来实现这个功能吧
#define OFFSETOF(struct_name,member_name) (int)&(((struct_name*)0)->member_name)
struct S
{
char c1;
int a;
char c2;
};
int main()
{
printf("%d\n", OFFSETOF(struct S, c1));
printf("%d\n", OFFSETOF(struct S, a));
printf("%d\n", OFFSETOF(struct S, c2));
return 0;
}
友情提醒:成员选择(指针)->的运算优先级要高于()强制类型转换
代码解释,我们知道一个结构体的成员大多数情况下会放在栈区中,每个成员的地址之间都是相差几个字节,如果我们能把这些地址强制类型转换成int型的数据,那他们每个成员与收成员的地址之间的差其实就是每个成员的偏移量。 这样就好解决这个问题了,假设首成员的地址为0的话,那么下面的地址其实就是他们每个成员的偏移量(现在还是地址形式,只要强制转换成int型就是偏移量了) 所以我们先将0强制转换成结构体类型的指针(也就是地址,因为指针就是地址,地址就是指针),然后我们在通过这个指针去选择我们的成员,也就是维护结构体内部成员,然后我们在取出内部成员的地址,将其转换成int型的数据,那这样我们就很轻松拿到不同成员的偏移量了
这里可能对于新手来说,理解起来稍微有一点困难,其实是因为这里有一个难点,是什么呢?
就是每个成员他们都有自己的地址,而他们的地址之间是互相联系的就是在内存对齐下相差一定个数的字节,所以我们调整首成员地址时,相应的后面的成员地址也会随之而变化,这里的首成员地址其实就是结构体指针struct S*,因为这个结构体指针首先指向的是首成员嘛,所以他也就是首成员地址
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;
}
这里解释一下,一般情况下如果你不想对变量进行改变的话,那么你传值(也就是传其本身,形参就是实参的一份临时拷贝)就可以了。如果你想对变量进行改变的话,要进行传址调用(也就是传变量的地址,用指针接收这个地址,再对指针解引用从内存中找到这个变量,对其进行修改)
但是,我们大多数都用的是传址调用,因为传过去的是地址,而地址只有4/8个字节的大小,对栈区的使用率是比较低的。而传值的时候,实参如果占用空间过大,形参压栈的时候,对栈区空间的消耗是比较大的,造成不必要的内存浪费,所以我们更提倡使用传址调用
解释完结构体,我们就不可避免的要讲讲结构体实现位段的能力
位段,C语言允许在一个结构体中以位为单位来指定其成员所占的内存长度,这种以位为单位的成员变量称为 “位段” 或 ”位域“
1.位段的成员必须是int,unsigned int,signed int 或char类型(只要是整型家族就行) 2.位段的成员名后边有一个冒号和一个数字,数字代表的是这个变量所占的比特位大小
例如:
struct A
{//位段就是为了节省空间
int a : 2;//占2个比特位
int b : 5;//占5个比特位
int c : 10;//占10个比特位
int d : 32;//占32个比特位
};//A就是一个位段类型 int字节数是4,所以冒号后面的比特位大小不可以超过32
int main()
{
struct A s;
printf("%d\n", sizeof(s));
return 0;
}
上面的代码结果为什么是8呢?
其实它的大概逻辑是这样的,操作系统一次性开辟4个字节的空间,如果这4个字节的空间不够,那么操作系统将以4字节为单位逐次开辟。我们先开辟第一个4个字节的空间,a,b,c等变量占去了17个字节的空间,剩余15个字节的空间,剩余空间不够存放变量d,那么就会开辟下一个4字节的空间,用来存放变量d,所以位段结构体A的大小就是8字节
1.一般情况下,位段的成员是同一类型的,不会夹杂不同类型的成员。 本身位段就是一个非常不稳定的东西,而且是不跨平台的,如果夹杂不同的类型成员之后,会使得位段变得非常复杂且增加其不确定性 2.位段的空间增长方式是,每次按照对应类型的大小开辟对应相应大小的字节空间。 比如位段成员是int,则每次开辟4字节的空间,若是char,则每次开辟1字节的空间 3.位段涉及很多不确定性因素,位段是不跨平台的,如果注重可移植性程序的话,那应该尽量避免使用位段
1.在vs环境中,char类型存储时,内存使用方式是先用掉字节的低位,再用掉字节的高位,当字节中的剩余比特位不够时,操作系统会开辟新的字节用于存储位段成员 2.在vs环境中,int类型存储时,内存使用方式是先用掉字节的低位,再用掉字节的高位,它不会浪费剩余的比特位,而是紧紧利用每一个比特位去存储我们的变量,先用低位再用高位
题目1:请通过调试,观察内存窗口中,变量s的存储形式
struct S
{
char a : 3;
char b : 4;
char c : 5;
char d : 4;
};
int main()
{
struct S s = { 0 };
s.a = 10;// 1010 发生截断存储010
s.b = 20;//10100 发生截断存储0100
s.c = 3;//011 不足5个比特位,则用0补齐为00010
s.d = 4;//100 不足4个比特位,则用0补齐为0100
//使用给位段所开辟的空间时,和结构体相同,从低位使用到高位,并且,比特位不够就重新开辟空间
return 0;
}
我们可以用计算机算出这3个字节的十进制大小分别是34,3,4,转换成十六进制就是0x22,0x03,0x04
题目2:请通过调试,观察内存窗口中,变量s的存储形式
struct A
{
int a : 3;
int b : 4;
int c : 5;
} ;
int main()
{
struct A a = { 0 };
a.a = 10;//1010 存储3个比特位发生截断,存010
a.b = 15;//1111 存储4个比特位,存1111
a.c = 20;//10100 存储5个比特位,存10100
return 0;
}
我们可以看到结构体变量a的16进制表示形式是0x7a和0x0a ,转换为10进制就是16*7+10=122和10,如果我们将二进制表示形式转换位10进制的话,结果也正好为122和10
由此可见我们的分析结果是正确的
1.int位段被当成有符号数还是无符号数是不确定的,即为开辟空间的最高位是否是符号位也是不确定的 2.位段中最大位的数目是不确定的,(16位机器最大是16,32位机器是32,64位机器是64),所以跨平台时容易出现问题 3.位段中的成员在内存中从左向右分配,还是从右向左分配标准尚未定义。(vs下是从左向右,不同编译器结果不同)所以我们在分析题目时的配图(就windows的画图工具),每个(1或4字节内存)的画的方式就是从左向右的 4.当一个位段结构包含两个位段成员,第二个位段成员比较大,无法容纳进第一个位段剩余的位时,是舍弃剩余的位还是利用,这是不确定的。(char是舍弃,int是利用)
enum Color//颜色
{
RED,
GREEN,
BLUE
};
上面代码的enum Color是枚举类型,其中的变量叫做枚举类型的可能取值,也叫枚举常量
其中每个枚举常量都是有他们自己的值的,如果你不给他们赋初值的话,默认从0开始,一次递增1。
当然如果你想要对他们定义的话,可以这样做
enum Color//颜色
{
RED=1,
GREEN=2,
BLUE=4
};
观察下面代码及运行结果,可以看到这些枚举常量分别对应的值
这里要注意一点: 我们不可以向下面这样写代码
enum color c = 2;//因为枚举类型和整型类型是不兼容的,代码有错误
我们明明可以使用#define定义,但为什么要搞一个枚举类型捏?下面来阐述一下枚举的优点
1.增加代码的可读性和可维护性 2.和#define定义的标识符相比,枚举有类型检查(因为枚举本事就是一种类型),更见严谨,而#define定义仅仅只是在预处理做一个纯粹的替换,是没有类型检查的 3.防止了命名污染(命名冲突)。 (枚举的本质是常量,常量命名是不会发生冲突的。如果用#define很有可能在多个项目合并时,发生命名冲突) 而且枚举一般定义在头文件里面,而#define在源文件里面,小组完成一个大工程的时候,可能会只有一个头文件库
命名污染就是来自不同模块儿或源文件的全局变量或外部函数的名称重复,从而导致链接失败,或是链接后产生错误的执行结果,链接器在静态函数库查找符号时,将按顺序查找静态函数,找到某个匹配的符号后,就不会查找其它函数库中是否含有相同的符号名。我也看不懂,好家伙,我学的还是太浅了
例如:以后公司里会有多个人完成同一个项目,分不同文件完成的时候,比如一个人取了一个函数名叫add,另一个人也取了这个名字,最后项目合并的时候,这两个相同的函数名就会冲突,导致链接失败
4.便于调试(调试时已经进入可执行程序阶段,会把RED这样的特殊符号替换为相应的数字,但你调试时看到的 是RED),而枚举不同,它不会进行值的替换这些步骤,你调试的就是你看到的,你看到的就是你调试的, 如果使用#define,那么在你调试的时候,会很容易出现问题,因为你看到的,不是程序真正执行的代码 5。使用起来更加方便,不会重复多次定义#define,产生代码冗余
enum Color//颜色
{
RED=1,
GREEN=2,
BLUE=4
};
enum Color clr = GREEN;//只能拿枚举常量给枚举变量赋值,才不会出现类型的差异。
clr = 5;//这个我们前面提到过,会发生类型冲突
创建变量的那段代码很好理解,你用enum color这种类型创建3个枚举常量,那你在用你自己定义的枚举类型去创建变量时,只能用你这个类型里面所包含的数据去赋值。
就像你用int的类型去创建变量,并且给变量赋值的时候,那你也只能用int类型下的数据区赋值吧,总不能用double的数据去给int的变量赋值吧,这必定发生错误啊!
联合也是一种特殊的自定义类型 这种类型定义的变量也包含一系列的成员,特征是这些成员共用一块儿空间(所以,顾名思义,联合体也叫共用体) 例如:
//联合类型的声明
union Un
{
char c;
int i;
};
//联合变量的定义
union Un un;
例如:
union un
{
char c;
int i;
};
int main()
{
union un u;
printf("%d\n", sizeof(u));
printf("%p\n", &u);
printf("%p\n", &(u.c));
printf("%p\n", &(u.i));//三个地址是相同的
return 0;
}
由运行结果可以看出,联合体类型创建的变量,变量所包含的成员的地址都是相同的,而且联合体类型的大小,是变量所包含的成员中类型所占字节数最大的数,种种迹象表明,联合体变量所包含的这些成员是共用一块空间的
一道面试题: 编程,用联合体判断当前机器的大小端字节序 小端:数据的低位在内存的低地址,高位在内存的高地址 大端:数据的低位在内存的高地址,高位在内存的低地址 知识准备: 内存条从左向右地址由低变高,数据的位从左向右由高位变为低位
从我笔记本的内存条可以看出,地址确实就是从左向右依次变高的
int check_system()
{
union //匿名联合体类型,用一次以后也就不用了
{
char a;
int i;
}u;
u.i = 1;
return u.a;//巧妙利用联合体
}
int main()
{
int ret = check_system();
if (ret = 1)
printf("小端");
else
printf("大端");
return 0;
}
代码解释:我们返回char型数据a的值,即可观察出机器的大小端存储方式,操作系统会先读取低地址的数据(也就是从左向右读),如果返回值是1,则就是小端存储模式反之就是大端模式
联合的大小至少是最大成员的大小。(因为你要和小成员一起公用一块儿空间嘛,所以大成员和小成员都要能放的下)
当最大成员大小不是最大对齐数的整数倍的时候,就要对齐到最大对齐数的整数倍。
union Un1
{
char c[5];//对齐数是1,空间大小是5
int i;//对齐数是4,空间大小是4
};//所以大小是8(4的整数倍)
union Un2
{
short c[7];//对齐数是2,空间大小是14
int i;//对齐数是4,空间大小是4
};//所以大小是16(4的整数倍)
//下面输出的结果是什么?
printf("%d\n", sizeof(union Un1));
printf("%d\n", sizeof(union Un2));
下面我们来看一下,运行的结果到底和我们分析的结果是否相同
没有问题,我们的结果完全正确
本文重点介绍了结构体,结构体中的位段,枚举,联合等自定义类型的相关知识,其中结构体和位段介绍时间较长,这两个部分也是重要的内容请大家耐心观看
剩下的枚举和联合体大家可做了解,丰富自身的知识库,以后会深入讲解这部分的内容的