【C语言】自定义类型详细讲解(结构体、位段的简单到深入)

目录

1.结构体的声明

1.1基础知识

1.2结构体的声明

1.3结构体的特殊声明

 1.4结构体的自引用

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

1.6结构体内存对齐

那对齐这么浪费空间,为什么要对齐

 1.7修改默认对齐数

1.8结构体传参

2.位段

2.1什么是位段

2.2位段的内存分配

深入研究VS环境下的位段内存分配

2.4位段的跨平台问题


首先理解什么是自定义类型,我们平时所接触的char、short、int、float、double等等这写都是内置类型,这些类型都是c语言所规定好的,不是我们所创造出来的,生来就能为我们所用的。

其实C语言还允许我们创造一些类型,这就是自定义类型

那自定义类型允许我们创建哪些类型呢?

结构体类型、结构体类型、枚举类型,那本章就是对自定义类型的讲解。

首先我们先了解结构体

1.结构体的声明

1.1基础知识

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

说到集合我们会想到相同元素的集合是数组,而本篇要讲的是不同种类元素的集合,结构体。

1.2结构体的声明

 结构体类型的关键字是struct,后面的tag是结构体的标签名,就是结构体的名字。

大括号里的mem-list是成员列表,最后的variable-list是变量列表,那如下就是结构体命名的方式

struct  tag
{
	mem - list;
}variable-list;

接下来举个例子简单使用一下结构体类型

在这里我用一个学生信息来讲解 

//定义一个学生类型
struct student
{
	char name[20];
	int age;
	float weight;
};

一一对照着上面的结构体命名方式,struct是结构体关键字,tag对应着student结构体名字,在这里我们将其命名为student,mem-list对应着结构体的成员变量(可以有多个),最后的变量列表如下和主函数讨论,代码如下

struct student
{
	char name[20];
	int age;
	float weight;
}s4,s5,s6;//全局变量
int main()
{
    int num=0;
	struct student s1;//局部变量
	struct student s2;
	struct student s3;
	return 0;
}

主函数中定义的struct student s1类型其实就和int num相同,结构体类型可以在主函数中定义(s1,s2,s3),也可以在变量列表中定义(s4,s5,s6),区别就是s1,s2,s3是局部变量,

而s4,s5,s5是全局变量,所以变量列表可以有也可以没有。

1.3结构体的特殊声明

当我们声明结构体类型的时候将结构体名省略掉时,我们将其称为匿名结构体类型

那想要用这个匿名结构体在主函数中定义局部变量可以吗?

答案是不行,因为没有名字

9447d20dce014299a8eeb299c13a5624.png

 我们可以看到可以在匿名结构体类型可以在变量列表中定义全局变量,但不能定义局部变量,也就只能用在结构体类型的变量列表中,定义全局变量。

那下面整点儿花活儿;

struct 
{
	char name[20];
	int age;
	float weight;
}s1;
struct
{
	char name[20];
	int age;
	float weigt;
}* pa;

int main()
{
	pa = &s1;
	return 0;
}

我们在定义一个结构体类型的指针pa,将其指向匿名结构体变量s1;在主函数中将s1的地址赋给指针变量pa,看到这样的代码可能就会觉得两个自定义结构体类型的成员变量都一样,那把s1放到pa中去,这两个匿名变量的成员类型, 这就是两个同一种类型的变量。

但是当我们实现的时候编译器会报错

ac71132211b642109b09404f46778093.png

 意思就是说呢虽然两个结构体类型的成员变量一摸一样,但是在编译器看来就是两个完全不同类型的变量,那这些都是匿名结构体的错误的使用方法

如下才是匿名结构体正确用法

匿名结构体只能在创建结构体的时候定义好变量,不能再定义局部变量,因为没有结构体名

7cd330283fdc45829d75821805559d83.png

 1.4结构体的自引用

结构体中包含一个类型为该结构本身成员是否可以呢?

讲解结构体自引用前先带大家简单了解一下数据结构的一些内容

a838e98da1ea4daeba091a111417eaca.png

 我们要储存1 2 3 4 5这几个数字的时候就是找几块连续的空间将他们储存进去,就像数组一样。

那我们把这样连续存储的方式就叫做顺序表。

那数据的储存也有可能是乱序的

e57ba601f4ee4ece8983469993d4af1d.png

 像这样乱序的我们可以让1找到2,让2找到3,以此类推找到5就不需要再往后找了,所以我们只需要找到1的位置就可以找到后面数字的位置,这样的方式像个链条一样把1 2 3 4 5穿起来,那么这样的数据储存方式就叫做链表;顺序表和链表都像一条线一样将他们穿起来储存,我们就称他们为线性数据结构。

我们将像这样存放1 2 3 4 5 数据的就叫做一个结点,那我们每次访问节点时,只需要访问一个节点,就可以将他们的全部内容都访问到;

那我如何访问到下一个节点呢?

下一个节点和这一个节点的类型都是一样的,那一个节点既要储存自己的数据又要和下一个节点建立关系

我们不妨使用结构体类型

struct  Node
{
	int data;//大小为四个字节
	struct Node* next;//大小为四个字节或八个字节
};

 我们先定义一个结构体类型Node;在成员变量中定义一个int类型的data来存放自己的数据,定义一个struct Node*类型的next;这样就和下一个数据建立了

在这里要非常注意的是访问下一个数据的时候一定要使用指针的类型;因为我们不知道链接到的下一个数据的大小,不知道他内存的多少,而使用指针时访问它的地址同样也可以做到访问数据的效果,所以在成员变量内要使用指针结构体类型,可以同时确定下一个数据的大小和位置;

那我们接下来的操作就是将两个数据连接起来

struct  Node
{
	int data;
	struct Node* next;
};
int main()
{
	struct Node n1;
	struct Node n2;
	n1.next=&n2;
	return 0;
}

我们定义两个结构体变量n1和n2;将n2的地址赋给n1中的next中去,这样n1就有能力找到n2了,就相当于一个链条将两个数据串起来了

所以当一个结构体要找到与另一个跟自己相同的结构体时就可以使用这样的方法。

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

非常简单,直接上代码举例子

struct student
{
	char name[20];
	int age;
	float weight;
}s4, s5, s6;
int main()
{
	struct student s1;
	struct student s2;
	struct student s3;
	return 0;
}

其实无非就是两种方式

一种是直接定义在结构体后面的全局变量,一种是定义在main函数中的局部变量;

我们创建完变量后就要初始化,就像我们定义别的类型,如int类型时要对其进行初始化,要给他赋予一个初始值

那结构体的初始化与我们所学的数组是相同

struct S
{
	int a;
	char c;
}s1;
int main()
{
	int arr[10] = { 1,2,3};
	struct S s2 = {100,'u'};
	return 0;
}

首先定义一个结构体成员变量有int类型的a和char类型的c,再观察main函数中数组的初始化的方法,需要大括号括起来,那结构体初始化也要大括号括起来,再往大括号中按顺序输入结构体的成员变量的初始化内容即可;

那这里初始化的内容就是将100赋给a,将dudu付给了char;

那结构体的自引用如何初始化呢?qishi


struct S
{
	int a;
	char c;
}s1;
struct B
{
	float f;
	struct S s;
};
int main()
{
	
	struct B sb = { 3.14,{100,'u'} };
	return 0;
}

 其实也很简单,我们只需要在大括号中再带上一个大括号就好了,我们调试起来看一下这些值到底有没有初始化给我们的变量呢

0a335f1c3ba644d9a18786e6f8be2cd4.png

 我们可以看到确实都是我们想要初始化的值。

当然也可以不用按顺序来初始化,不按顺序的初始化如下

struct S
{
	int a;
	char c;
}s1;
int main()
{
	struct S s3 = { .c = 'w',.a = 100 };
	return 0;
}

我们只需要在大括号内写成  .  加上成员变量名,再加上想被赋予的值就可以了,调试验证

0b776677d22040daba6b39ac13637163.png

 结果在意料之中;

那我们如何将这些保存好的数据拿出来用呢?

也非常的简单

int main()
{
	struct B sb = { 3.14,{100,'w'} };
	printf("%f %d %c",sb.f,sb.s.a,sb.s.c);
	return 0;
}

 结构体的操作符时一个‘ .’,所以在输出时要用结构体变量名+成员变量名

dd3db51f9d0844fab1a585cbb34538ed.png

 输出的也是我们想要的。

1.6结构体内存对齐

接下来要分享的是结构体的一个重点:如何计算结构体的大小;

struct S1
{
	int a;
	char c1;
};
struct S2
{
	char c1;
	int a;
	char c2;
};
struct S3
{
	char c1;
	int a;
	char c2;
	char c3;
};
int main()
{
	printf("%d\n", sizeof(struct S1));
	printf("%d\n", sizeof(struct S2));
	printf("%d\n", sizeof(struct S3));
	return 0;
}

给出这样一串代码预测一下输出值

ac5f845ef3e04d3e8e32e368f86a7376.png

 你预测对了吗

实际上结构体在内存中存放时是要完成对其这样一个操作,要把这些结构体放在一个对齐的边界上进行操作,而不是乱存放;我们利用表格来进行解释

 d8e1a93854234333b535740a1925efa3.png

这个图意思就是说结构体的第一个变量永远都放在0偏移处,就是箭头所指向的地方,那整形变量a占4个字节,如下图

74199903b535496fa3be7a430a288bad.png

 从第二个成员开始,以后的每个成员都要对齐到某个对齐数的整数倍处;

对齐数是:成员自身大小和默认对齐数的较小值;

这个例子中c1的大小是1;

那在vs的环境下,对齐数默认值是8,那再别的没有默认对齐数时,对齐数就是成员自身的大小。

那按照这么分析,char c1自身大小是1,默认对齐数是8,那取较小值,那他的对齐数就是1。

那c1只要对齐的1的倍数处就可以了,所以我们按偏移量顺序存放,并且这些偏移量都是1的倍数,所以可以存放在4的位置;现在的储存状况如下图

0abbccf32f8f49bb89cc31f3c308825f.png

那刚刚输出的结果不是8吗,为什么只占了五个位置?

接下来就要说重要的第三点:当成员全部存放进去后,结构体总体的大小必须是,所有成员对齐数中最大对齐数的整数倍,如果不够,则浪费空间。

int类型的对齐数是4,char类型的对齐数是1,这两个变量中最大的对齐数是4,那结构体的大小必须是4的倍数,那刚刚占了五个空间会继续浪费掉三个空间

d8990c6e500449138ed62fe83ee26470.png

那这样就会得出8个空间。实际上a和c只占了5个空间,剩下三个字节的空间因为要对其所以浪费掉了。

那我们接下来将两个变量的位置反过来,将c1定义在上面,将a写在下面会是什么样呢?

struct S1
{
	char c1;
	int a;
};

 当我们写成这个样子的时候

偏移量的占用如下

9a18630d02ae40cc9223289af52a7458.png

 因为0被占用后,1、2、3 都不是4的倍数,所以a要从第四位开始占位;

所以这种写法的情况下1、2、3号的相对偏移量就会被浪费掉

那么我们调试起来验证一下

3ed10de0fbe54399b5afd08189fec34f.png

 我们在监视中取地址可以看到确实c1和a之间隔了三个地址,也就是浪费了三个字节;

那还有一种方法是offsetof函数,他的作用就是测量偏移量

fa9b73f7f39e4d7bb86295ba6517ca24.png

 我们可以看到它其实是一个宏,返回值是一个整形的值,那使用时不要忘记包含头文件<stdef.h>,使用代码如下

#include<stddef.h>
struct S
{
	char c;
	int a;
};

int main()
{
	struct S s;
	printf("%d\n", offsetof(struct S, c));
	printf("%d\n", offsetof(struct S, a));
	return 0;
}

5bee2ccee1674424b51bcc1f84db0009.png

 我们可以看到c的偏移量是0,a的偏移量是4,上面的图示给大家演示过,过程复杂,希望大家能够理解。

那对齐这么浪费空间,为什么要对齐

大部分平台都有两个原因

1.平台原因:

不是所有的硬件平台都能访问任意地址上的任意数据,某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常,也就是说某些机器上是不能让操作者去操作一些数据的,所以我们只能将一些地址对应在特定的位置边界上,在边界上取数据就可以了,所以要对齐;

2.性能原因:

数据结构(尤其是栈)应该尽可能在自然边界上对齐。原因在于为了访问未对齐的内存,处理器需要做两次内存访问;而对齐的内存访问只需要一次就可以了,也就是说对齐可以提升效率。

用图例给大家简单解释一下

420a3c16909b49a19c5f3bdb36fec73a.png

先说明如果是在32位的机器上,每次读取数据是四个字节,所以当我们未对齐时,每次读取四个字节要两次才能读取完这个数据(如未对齐的紫色读取方式),但是当我们数据对齐的时候,访问c的地址时只需跳过浪费掉的字节数,访问a的地址就可以了。

所以总的来说,结构体内存对齐就是用空间换取时间的做法。

那我们在平时设计代码的时候就要注意一些创建变量时位置的问题

尽量将占用空间较小的类型集中在一起,这时原本可能浪费掉的空间就会被利用上,这样也就一定程度上的节省了我们的空间。

eaa671de911d47ef84951b9a97cbcbd0.png

 1.7修改默认对齐数

如果我们觉得我们使用的环境设置的默认对齐数不太合适的话,我们当然也是可以手动修改的

修改需要使用#pragma这个预处理命令,使用如下

#pragma pack(8)

 后面括号的内容 就是默认对齐数,我们可以对其进行修改

#pragma pack()

那我们如果又想取消设置的默认对齐数,秩序将括号里的数字去掉,就可以恢复为原本的默认对齐数。

我们不妨对其简单实用试一下,代码如下;

#pragma pack(1)
struct S
{
	char c1;//1
	int i;//4
	char c2;//1
};
#pragma pack()


int main()
{
	printf("%d", sizeof(struct S));
	return 0;
}

fe8cf99586df4615b24b87b815be206f.png

 我们可以看到原本在默认对齐数是8的情况下,我们将默认对齐数改成1,此时输出为6

我们的环境提供了这样一个方式来适应自己的开发,简单了解一下。

1.8结构体传参

直接上代码讲解

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;
}

我们可以看到和函数的传参基本上没什么两样,要注意的就是函数的形参是结构体变量名;

上面的 print1 和 print2 函数哪个好些?
答案是print2函数。
因为函数传参的时候,参数是需要压栈,会有时间和空间上的系统开销。
如果传递一个结构体对象的时候,结构体过大,参数压栈的的系统开销比较大,所以会导致性能的
下降。

那我们可能还会有疑问,print1函数传的是值,我们如果不小心修改掉struct S s中的s的内容,也不会影响到printf1(s)中的s,那这种形式是否更加安全一些呢?而如果我不小心改掉print2中的指针,则会修改掉print2打印函数中打印函数的内容,这样不就不安全吗?

其实完全可以使用const修饰指针

void print2(const struct S* ps)
{
	printf("%d\n", ps->num);
}

这样子加上const,指针就没有能力修改指针所指向的内容。

所以在以后的代码中尽量保证结构体传参,这样保证我们的效率能高一些。

2.位段

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

2.1什么是位段

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

1.位段的成员通常是int、unsigned int或signed int

2.位段的成员后必须有一个冒号和一个数字

举个例子

struct A
{
	int a : 2;
	int b : 5;
	int c : 10;
	int d : 30;
};

这就是位段的一个简单的定义,可以看到和结构体有区别也有相似的地方。

那冒号后面的数字和冒号到底是什么意思呢?

我们先从他所占的内存来入手了解,直接上代码;

struct A
{
	int a : 2;
	int b : 5;
	int c : 10;
	int d : 30;
};
int main()
{
	struct A sa = { 0 };
	printf("%d\n", sizeof(sa));
	return 0;
}

可以预测一下输处结果

0c8e45f4807f46bfaab3d1537c330d8f.png

 其实位段中的‘位’是指二进制位

也就是说冒号后面的数字表示几个二进制位,

struct A
{
	int a : 2;//表示两个二进制位
	int b : 5;//表示五个二进制位
	int c : 10;//表示十个二进制位
	int d : 30;//表示三十个二进制位
};

如上述代码后的注释所示,那这样的注释不是和我们之前所学的int不是占32个比特位吗?

需要注意是,我们在设计结构体的时候,我们在成员变量a中存放的数据是非常有限的,我只要存放0 1 2 3四种状态, 其对应的二进制分别是00 01 10 11 ,我们就会发现只有两个比特位就可以表示0或1或2 或3,但是如果我按正常的32位比特位给int类型开辟空间,但是我只用到两个比特位,所以剩下的30个比特位都浪费掉了,那这时候为了节省空间,让他的成员变量所占的空间小一点,就有了位段的概念。

也就是说如果如果我给他两个比特位就足以表达这个成员变量想要表达的意思,那就完全足够了。这就是位段做到了更加节省空间的一种方式。

2.2位段的内存分配

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

那我们继续用上述代码举例

struct A
{
	int a : 2;
	int b : 5;
	int c : 10;
	int d : 30;
};

当这个位段放到我们们面前时我们发现它的成员是int,那我们就一次开辟四个字节,也就是32个比特位,a先用去2个,还有30个;b用去5个,还剩25个;c用去10个,还剩15个;最后d要用30个,那15个比特位不够给d分配怎么办呢?我们重新申请int类型的空间,4个字节,也就是32个比特位,d用去30个,还剩两个;问题来了,那前面15剩下的空间还要不要呢?

这样的事情完全取决与不同的编译器;

实际上C语言标准并没有规定这样的空间要不要被利用掉。

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

不过都不影响我们最后的结果,不管上面的d有没有用去15个剩下的空间,我们都开辟了第二个四个字节的空间大小,所以这个位段占用了8个字节的内存,输出结果也能对的上上图所示了

深入研究VS环境下的位段内存分配

 接下来继续研究在VS平台上数据分配的方向和空间如何使用等等问题

依然使用代码举个例子

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

这段代码的意思就是先开辟char类型的空间,因为char只占一个字节,所以a占三个字节,b占四个字节,c占五个字节,d占四个字节;但是在进入主函数中struct S s又将他们的空间全部初始化成0,所以之前不管他们的空间是多少,被初始化为0后,他们所占的空间就又是0了,再往下就是给a,b,c,d赋值了。

我们先观察并讨论位段中的内容

看到成员都是char类型就一次开辟一个字节的空间,也就是八个比特位,图示如下

a3db90b2ccd4435eb6ce54524e285409.png

 我们先假设,位段中的数据是从低地址到高地址的,所以我们先给a开辟三个比特位的空间,a用去3个比特位,还剩5个比特位;b用去4个比特位,还剩1个,那我们继续先假设不会占用多于出来的空间,所以使用给c开辟空间时,我们必须在申请一个字节的空间,也就是八个比特位来存放c的内容,c用去5个比特位,还剩3个比特位;这时不够存放d的数据,继续申请一个字节的空间来存放b,这时b就占用4个比特位;

到现在我们一共开辟了三个字节的空间,消耗了三个字节的空间,

那事实是否和我们假设的一样呢?

我们在观察并研究主函数中的内容

主函数中的内容就是将这写空间都初始化位0,并将内容都放在这些内存空间中来验证存放的顺序。

int main()
{
	struct S s = { 0 };
	s.a = 10;
	s.b = 12;
	s.c = 3;
	s.d = 4;
	return 0;
}

第一要将10存到a中,10的二进制表达是1010,但是a只能存放3个比特位;所以就存放010;

第二要将12存到b中,12的二进制表达是1100,b可以存放4个比特位,所以存放1100;

第三要将3放进c中,3的二进制表达是11,但是要存放五个比特位,前面补三个0,所以存放的是00011

第四个要将4存到d中,4的二进制表达位100,d可以存放4个比特位;所以前面补一个0,所以存放的是0100;

这些存放的前提是

1.分配到的内存中的比特位是从右向左使用的

2.一个字节内分配的内存剩余的比特位不够分给下一位时,浪费掉,重新申请空间

为了验证以上说法,我们调试起来,并且为了便于观察,我们将二进制数转化为十六进制数来观察计算机中的储存方式

所以转化为十六进制分别是6 2 0 3 0 4

我们调试起来

b92e8c941c2445ada348928927821960.png

 可以看到与我们假设的方式一摸一样

那么就说明VS这样的编译器就是按如上所说的方式去做的

但是

但是

但是不能说明别的编译器按这种方式储存结构体成员内容

2.4位段的跨平台问题

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

这样的问题在不同平台的问题是不同的
2. 位段中最大位的数目不能确定。(16位机器最大16,32位机器最大32,写成27,在16位机
器会出问题。

也就是说我们给int 定义很大的空间时,比如说int b :30;

这个30的合理性有待商榷,在十六位的机器上可能就会出现问题
3. 位段中的成员在内存中从左向右分配,还是从右向左分配标准尚未定义。


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

基于以上问题就可以得知,位段是不跨平台的,所以注重可移植的程序,应该避免使用位段
总结:
跟结构相比,位段可以达到同样的效果,但是可以很好的节省空间,但是有跨平台的问题存在。

以上就是此次要分享的全部内容,希望对你有所帮助,最后求个三连,感谢观看

猜你喜欢

转载自blog.csdn.net/wangduduniubi/article/details/129657607