C语言学会自定义类型这一篇足够了(万字呕心沥血总结)【致敬:我们仍然相信光】
目录
零.前言
话说C语言的江湖中对数据类型这一神奇的组织有着多种分类方法,大体上分为四个派别,分别是基本类型,自定义类型,指针类型,空类型。其中基本类型的江湖地位一家独大,无法撼动。其次就是为基本类型安家定位的指针类型也在江湖中四处奔走,出场频繁。所谓空类型,则提倡万物皆可空,即万物都可以以空定义,以空指向,经常归隐山林,只有当人们想到它们时才会现身幽幽一面,需要强行转换才愿意重出江湖助人一臂之力。
而我们今天要讲的自定义类型,集其他三派精华于一身,三派类型均可定义,且各种自定义类型阵法整齐有秩,既方便应用又方便欣赏,尤其是结构体在数据结构与算法界广受好评。只有熟练使用它们才能发现它们发出的光。
1.什么是自定义类型
在C语言中元素的类型分为内置类型和自定义类型,内置类型就是我们经常使用的int,char,long,double之类的类型,而自定义类型就是我们自己为自己的变量创造的类型,如果说变量是一栋房子,那么类型就是建造它的图纸,除了C语言给定的图纸之外,我们还要自己创造一个属于一栋独一无二房子的图纸。
自定义类型主要分为三种:结构体,枚举和联合体。
2.结构体
1.结构体的含义
C语言结构体(Struct)从本质上讲是一种自定义的数据类型,只不过这种数据类型比较复杂,是由 int、char、float 等基本类型组成的。你可以认为结构体是一种聚合类型。
结构体主要应用于复杂对象的声明,在我们生活中有很多这样的复杂对象,比如说一个人就是一个复杂对象,这个人的信息包括他的年龄,身高,体重等等。再比如我们想了解一本书,需要了解它的书名,作者,出版社,定价等等,这些都是我们要描述的对象。那么我们就可以将书定义一个类型,以后不管遇到哪一本书,都可以以这种方式来描述它。
2.结构体的声明以及变量的创建
1.结构体类型的声明
struct Book//struct为关键字,Book为结构体标签名
{
char name[20];//定义书名
char price;//定义价格
char author[20];//定义作者名
};
除了这种创建方式,我们还可以利用typedef来进行创建,即:
typedef struct Book
{
char name[20];
char price;
char author[20];
}B;//将这一结构体命名为B
此外还有一种特殊的声明方式:匿名定义结构体
struct
{
int a;
int b;
}a1,a2;
即不添加标签名创建结构体,这种情况下变量的创建只能在定义结构体后来创建,否则以后就没有创建的机会了。
我们主要以第一种形式举例。
这样一个结构体类型就创建好了,然后我们需要根据这个结构体创建变量。
2.结构体变量的创建
我们可以直接在结构体的后面创建变量:
struct Book
{
char name[20];
char price;
char author[20];
}b1,b2;//创建了两个变量b1,b2
注意用typedef创建类型时,后面的B是结构体名,而不是变量,注意区分。
也可以在结构体外创建变量
struct Book
{
char name[20];
char price;
char author[20];
};
struct Book b1,b2;
这样也可以创建两个叫b1和b2的结构体变量。
一般结构体是定义在主函数之外的,所以b1,b2是全局变量,在程序运行开始就已经创建了。
如果在主函数内创建的,就是局部变量,执行代码时才会创建。
3.填充变量内容
创建变量之后,以Book为例,每个变量都包含三个内容,对变量内容的填充就很灵活了。
struct Book
{
char name[20];
char price;
char author[20];
}b1={"代码传奇",25,"zhangsan"},b2={"代码传奇2",25,"lisi"};
如果变量定义在类型的后面我们可以直接填充变量内容。
struct Book b1={"代码传奇",25,"zhangsan"};
如果变量创建在之定义的类型外,我们可以这样填充变量内容。
4.结构体变量的引用
以打印结构体变量为例,引用结构体变量需要用'.'这个操作符。
#include<stdio.h>
struct Book
{
char name[20];
char price;
char author[20];
}b1 = { "代码传奇",25,"zhangsan" }, b2 = { "代码传奇2",25,"lisi" };
int main()
{
printf("%s\n", b1.name);
printf("%d\n", b2.price);
return 0;
}

3.结构体的自引用
结构体的自引用通常在数据结构部分链表处实现。
这里我简答介绍一下:
在存储数据的时候我们可以在内存中一个接一个进行存储(顺序表),也可以将数据存储在内存的各个位置,然后通过前一个数据来寻找下一个数据的方式进行存储(链表)。

在存储链表的元素时,我们既需要存储每个节点中的数据,也需要存储下一个节点的地址以用来寻找下一个元素位置。此时就需要结构体来实现了,每一个结构体都是一个节点的类型。
typedef struct Node
{
int data;
Node* next;//在结构体中定义一个指针,指向下一个节点
}Node;//将这一结构体类型换一个名Node
其中next所指向节点的类型与该结构体类型是相同的。进而可以找到下一个节点的下一个节点。
3.结构体内存对齐
1.讨论原因
根据结构体内存对齐的规则我们可以计算一个结构体在内存中所占的大小。
2.结构体内存对齐的现象
我们定义两个这样的结构体:
struct S1
{
char c1;
int a;
char c2;
};
struct S2
{
char c1;
char c2;
int a;
};
观察可以发现,这两个结构体的内容是完全相同的,只是定义的顺序不同。那他们的内存是否也相同呢?答案是否定的。我们可以打印一下他们的大小。

我们会发现打印的结果分别是12和8。
这就是因为结构体的内存对齐规则所导致的。
3.结构体的内存对齐规则
1.结构体的第一个成员,永远放在结构体起始位置偏移量为0的位置
2.从结构体第二个成员开始,总是放在一个对齐数的整数倍处。
(对齐数:编译器默认的对齐数与变量自身大小的较小值,VS下的对齐数为8)。
3.结构体的总大小必须是各个成员中的最大对齐数的整数倍。
4.如果出现了嵌套结构体的情况,嵌套结构体对齐到自己最大的对齐数的整数倍,结构体的大小就是所有最大对齐数(含嵌套结构体)的对齐数的整数倍。
4.分析
了解了内存对齐的规则,我们来分析一下S1,和S2结构体在内存中的存储。

下面逐条给大家分析创建过程:
1.结构体的第一个成员,永远放在结构体起始位置偏移量为0的位置
即将第一个·成员也就是char类型放在了0处。
2.从结构体第二个成员开始,总是放在一个对齐数的整数倍处。
第二个成员是int类型,占四个字节,小于8个字节,所以应该放在4的整数倍数处,即编号为4,8,16……位置处,这里发现最近的是4,所以放在4的位置处。
第三个成员是char类型,占一个字节,小于8个字节,所以应该放在1的倍数处,所有数字都是1的倍数,所以放在int的下一个位置即编号为8的位置处。
3.结构体的总大小必须是各个成员中的最大对齐数的整数倍。
将最后的元素放在内存中后,我们发现内存现在一共占用了9个字节,但不是最大对齐数即4的倍数,所以内存要开辟到4的倍数处,下一个4的倍数是12,所以一共开辟的空间大小为12。
再来分析一下S2在内存中的存储:

1.结构体的第一个成员,永远放在结构体起始位置偏移量为0的位置
第一个char放在了0的位置。
2.从结构体第二个成员开始,总是放在一个对齐数的整数倍处。
第二个成员也是char类型,小于8个字节,放在1的倍数处,即下一个空间标号为1的地址处。
第三个成员是int型,占4个字节,小于8个字节,放在4的倍数处,下一个倍数是4,所以放在标号为4的位置。
3.结构体的总大小必须是各个成员中的最大对齐数的整数倍。
此时结构体的成员已经都放入了内存中,此时结构体所占内存为8个字节,是最大对齐数4的倍数,所以结构体多占内存的大小就是8。
再举一个嵌套结构体的例子:
struct S2
{
char c1;
struct S1 s;
int a;
};
如果将第二个结构体这样修改的话,它的内存是多少呢?我们测试一下:

测试的结果是20,还是按步骤进行分析:

1.结构体的第一个成员,永远放在结构体起始位置偏移量为0的位置
即将char类型放在标号为0处。
2.从结构体第二个成员开始,总是放在一个对齐数的整数倍处。
第二个元素为struct S1结构体类型,对齐处应为这个结构体中最大元素对齐数,S1结构体中的最大元素为int型,int型应该对齐4的倍数(这里就不用与8进行比较了),所以struct S1也应该对齐4的倍数,所以对齐了4。
第三个元素为int型,占四个字节,小于八个字节,所以对齐4的倍数16。
3.结构体的总大小必须是各个成员中的最大对齐数的整数倍。
当所有成员在内存中成功存储之后,发现共占20个字节,最大对齐数为4,是4的整数倍,所以结构体共占20个字节。
5.产生这种情况的原因
1.硬件原因:不是所有硬件平台都能访问任意地址上的任意数据,某些硬件平台只能在某些地址处取某些特定类型的数据,否则会出现硬件异常。
比如某些硬件只能读取内存上4的倍数或者8的倍数的数据,其他的数据无法被硬件处理造成数据丢失,或者硬件异常。
2.性能原因:数据结构(尤其是栈)应该尽可能的在自然边界上对齐,原因在于为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次。

如果处理器可以一次处理四个字节的内容,如图要获取绿色区域的数据的话,未对齐的要处理两次,而对齐的只需要一次就够了。
6.设计结构体的原则
在设计结构体时,我们要既满足对齐规则,也要节省空间,采用的方式是将空间小的元素放在一起,比如S2结构体就是把两个char类型的数据放在一起,最终占八个字节,而S1结构体就是两个char类型是分开的,所以占了12个字节。
7.修改编译器默认对齐数的方式
即在结构体之前加上#pragma pack(要修改的值),比如想要将对齐数改成4
#pragma pack(4)//将默认的对齐数改成4
struct S3
{
int a;
char b;
char c;
}
#pragma pack()//将默认的对齐数改成默认的8
8.计算偏移量
即找到结构体元素存入内存的编号,默认结构体存入内存的第一个字节的编号是1。这里我们需要用到一个宏offsetof,注意不是一个函数,因为它的操作数可以是一个类型。
#include<stdio.h>
#include<stddef.h>
struct S1
{
char c1;
int num;
char c2;
};
int main()
{
printf("%u\n", offsetof(struct S1, c1));
printf("%u\n", offsetof(struct S1, num));
printf("%u\n", offsetof(struct S1, c2));
return 0;
}
注意要使用offsetof宏需要引用头文件stddef.h,这段代码打印的结果是:

打印的0,4,8分别是三者的偏移量。
5.结构体传参
1.直接传参
结构体传参和其他变量的传参是一样的。
#include<stdio.h>
struct S1
{
int data[100];
int num;
};
void print(struct S1 s)
{
int i = 0;
for (i = 0; i < 10; i++)
{
printf("%d ", s.data[i]);
}
printf("\n%d", s.num);
}
int main()
{
struct S1 s = { {1,2,3,4,5,6,7,8,9,10},100 };
print(s);
return 0;
}
看这样一段代码就利用到了结构体传参,这段代码打印的结果是:

2.指针传参
在传参的过程中,内存的变化也是一个需要探讨的重要问题。
我们先看直接传参的代码,首先需要在内存中建立一个结构体,在程序运行到打印函数的时候,也需要开辟一段相同大小的空间来接收这个结构体。
总的来说,函数传参的时候,参数是需要压栈的,会有时间和空间上的系统开销。如果传递的是一个结构体对象时,结构体过大,参数压栈的系统开销比较大,所以会导致性能下降。
因此,结构体传参时尽量要传递结构体的地址。
#include<stdio.h>
struct S1
{
int data[100];
int num;
};
void print(struct S1* s)
{
int i = 0;
for (i = 0; i < 10; i++)
{
printf("%d ", s->data[i]);
}
printf("\n%d", s->num);
}
int main()
{
struct S1 s = { {1,2,3,4,5,6,7,8,9,10},100 };
print(&s);
return 0;
}
我们传递的是结构体的地址,所以需要用指针来接收,当s是一个结构体指针的时候,要找到结构体变量就需要用到'->'操作符。
打印的结果与直接传参的结果是相同的:

3.位段
1.位段与结构体的不同
1.位段的成员只能是int,unsigned int,char等属于整型家族的类型。
2.位段的成员名后面都有一个冒号和一个数字。
2.位段的作用
位段经常应用在网络的数据封装部分,在一定程度上起到节省空间的作用,但还是会有一定的浪费。
3.位段的定义及含义
struct A
{
int a : 2;//a占2个二进制位
int b : 5;//b占5个二进制位
int c : 10;//c占10个二进制位
int d : 30;//d占30个二进制位
};
这样就定义成功了一个位段,注意变量与数字之间是用:隔开的。
即定义了一个位段struct A中有四个变量,变量a占2个比特位的空间,b占5个比特位的空间,c占10个比特位的空间,d占30个比特位的空间。在主函数中我们就可以向位段中赋予相应大小的值。
4.位段在内存中的存储
1.内存开辟规则
位段在空间上的开辟是以4个字节(元素是int型),或者1个字节(元素是char型)来开辟的。
我们看到的位段大部分都是位段中的元素的类型是相同的,在位段中基本不使用类型不同的元素,我们这里也用位段中元素是相同类型来讨论位段中的内存开辟规则。
当位段是不同类型的时候,博主也研究过,感兴趣的话可以看看我的文章啊!
2.举例
struct S
{
int a : 2;
int b : 5;
int c : 10;
int d : 30;
};
我们定义一个简单的位段,这里的类型采用int型。
注意冒号后面的是所占字节的大小。首先我们会为它开辟空间:
根据规则首先开辟一个4个字节的空间,占32个比特位,将a,b,c存进去之后共占了17个字节(存放的时候是从低位向高位存放的),剩余的15个比特位不够存放下一个元素d,所以需要重新开辟一段4个字节空间,来存放d,而且存放的时候前面空余的15个比特位的空间被浪费了。
我们可以通过另一个例子来证明这一点:
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;
}
我们可以看一下这段代码的内存分布:

首先开辟一个字节的空间,由于a传入的是10,但是a只能占3个比特位,所以a中放的是010,占了该字节三个比特位,然后以4个比特位的形式向其中存放12即为1100,现在该字节已经被占用了7个比特位,还剩下一个比特位无法存5个字节的c,所以再开辟一段一个字节内存空间。
在下一个字节中存放5个比特位的数字3,即为00011,此时不够存储下4个字节的4,所以再开辟一段一个字节的空间。
存放4个比特位的4,即为0100,所以一共开辟了三个字节,每个字节中的内容为:
0110 0010
0000 0011
0000 0100
分别对应十六进制的6,2,0,3,0,4。
5.位段跨平台性(位段在不同平台下有些性质是不同的)
1.int位段被当成有符号数还是无符号数是未知的。
2.位段中最大位的数目是不确定的。
即16位机器最大是16,32位机器最大是32,64位机器最大是64。
3.位段中的成员在内存中是从左向右分配还是从右向左分配尚未定义。
4.当一个结构体包含两个位段,第二个位段成员比较大,无法容纳第一个位段剩余的位时,是舍弃剩余的位还是利用是不确定的。
4.枚举
1.含义
枚举,即一一列举,比如性别可以列举:男,女。一年的12个月可以一一列举:1月,2月,3月……12月。C语言中通过枚举来列举一些项。
我们将变量分为4种:字面常量,const修饰的常变量,#define定义的常量,枚举常量。
所谓常量即为程序中无法改变值的量,修改常量的值会出现报错,我们接下来说的就是枚举常量。
2.举例
#include<stdio.h>
enum Color
{
red,
green,
blue
};
int main()
{
printf("%d\n", red);
printf("%d\n", green);
printf("%d\n", blue);
return 0;
}
其中enum是枚举的关键字,枚举的意思是将red赋值为0,将green赋值为1,将blue赋值为2,即从上到下依次增加。
这段代码打印的结果是:

也可以人为地规定这些颜色的数值,没被规定的颜色的值是它的上一个颜色的值加一。
#include<stdio.h>
enum Color
{
red,
green=8,
blue
};
int main()
{
printf("%d\n", red);
printf("%d\n", green);
printf("%d\n", blue);
return 0;
}
打印的结果是:

3.使用
#include<stdio.h>
enum Color
{
red,
green=8,
blue
};
int main()
{
int d = green;
printf("%d\n", d);
enum Color c = green;
printf("%d\n", c);
if (c == green)
{
printf("绿色");
}
return 0;
}
打印的结果是:

我们发现既可以用int型接收green,也可以用enum Color型接收,以这两种类型接收得到的结果是一样的, 在VS下c和d的值都可以修改,但在dev的测试下enum Color定义的变量(数字)是不能修改的。
4.枚举的优点
枚举和#define的作用是差不多的,所以我们主要拿它和#define比较。
1.增加了代码的可读性和可维护性
#define定义不容易理解,比如我们定义颜色的时候是enum Color{},其中Color就指明了我们要定义的是颜色,而#define无法实现这一点。
2.和#define定义的标识符比较枚举有类型检查,更加严谨。
我们用#define green 1定义时,这里的green是没有类型的,但是如果用枚举定义,green的类型就是enum Color。
3.防止了命名污染,枚举定义的是局部变量,而#define定义的是全局变量。
4.便于调试,枚举中可以调试,#define green 1在调试的时候感觉不到green的存在。
5.使用方便,一次可以定义多个变量。
5.联合体(共用体)
1.含义
联合体即共用体,顾名思义,就是联合体内部的元素公用同一段内存。在使用联合体时,由于不同元素利用同一段内存,所以更改某一个元素的大小也会更改其他元素的大小,所以一般使用联合体时只会使用其中的一个元素,举一个最简单的例子。比如一个成绩管理系统,可以使用学生身份登入,也可以使用老师身份登入,如果是学生登入的话就不能以老师的身份登入,如果以老师的身份登入就没必要以学生的身份登入。
2.联合体的内存存储
union U
{
char c;
int i;
};
int main()
{
union U u = { 0 };//结构体初始化为0
printf("%d\n", sizeof(u));//计算结构体的总大小
printf("%p\n", &u);//打印结构体的地址
printf("%p\n", &u.c);//打印u.c的地址
printf("%p", &u.i);//打印u.i的地址
return 0;
}
来看这样一段代码,打印的结果为:

我们发现联合体总大小为4,u,u.c,u.i的地址时相同的。
这也就说明其实u.i与u.c占据的其实是同一块空间,画成图是这样的:

所以说改变u.c或者u.i的一个可以影响另一个。
3.联合体的简单应用---检验大小端
首先解释一下大小端:
我们知道数据在内存中是以二进制来进行存储的,比如存放一个整型1,存储的形式就是
00000000 00000000 00000000 00000001,而我们观察到的是以16进制存储的即为
00 00 00 01,每一个部分占据一个字节。而每一个地址又对应一个字节。假设从左向右是从低地址到高地址,
大端存储:高位放在低地址,低位放在高地址,即存储的形式就是00 00 00 01
小端存储:高位放在高地址,低位放在低地址,即存储的形式就是01 00 00 00
下面我们用联合体来检验一下一台存储方式:
#include<stdio.h>
union U {
char a;
int i;
};
int main()
{
union U s = { 0 };
s.i = 1;
if (s.a == 1)
{
printf("小端");
}
else
{
printf("大端");
}
return 0;
}
我们发现打印的结果是:
说明是小端存储。
4.联合体大小的计算
1.两个原则
1.联合体大小至少是最大元素的大小
2.当最大成员的大小不是最大对齐数的整数倍时,就对齐到最大对齐数的整数倍
2.举例
union U1
{
char c[5];
int i;
};
union U2
{
short c[7];
int i;
};
int main()
{
printf("%d\n", sizeof(union U1));
printf("%d", sizeof(union U2));
return 0;
}
计算U1和U2所占的字节数,打印的结果是:

先来解释U1:第一个元素char的对齐数是1,第二个元素的对齐数是4,四个字节无法存放5个字节,所以字节数为4的倍数,即为8。
U2:第二个联合体中最大的对齐数是4,但是第一个元素是7*2=14个字节,4个字节无法存放,就寻找4的倍数,即16个字节可以存放,所以联合体U2的大小就是16。
5.总结
看完这篇文章不知道你有没有发现自定义类型发出的光呀,反正迪迦奥特曼的光已经被夺走了,但这不妨碍我们相信光,学习学成光之巨人然后变成光呀。其实自定义类型就算是不去定义的话,代码也是可以写的,它主要的作用在于简化那些相同或者相似变量出现频繁的情况,熟练使用它,然后帮不会使用它而造成代码冗余的同学优化一下代码,在帮他之前也可以问他一句:“你相信光吗?”。
ps:吐槽一下吧,博主本身就是一个奥特曼迷,虽然那些古老的初代,雷欧,赛文啥的没怎么看过,但确实是看着迪迦长大的,希望迪迦早点重回我们的视野吧。。。


浙公网安备 33010602011771号