【C语言】c语言中的自定义类型&动态内存管理部分知识

本文深入解析C语言中自定义类型的关键概念,包括结构体的声明与内存对齐、位段的定义与应用、枚举的优势与联合(共用体)的特性。同时探讨动态内存管理的必要性与malloc、free、calloc和realloc的使用技巧。
摘要由CSDN通过智能技术生成

C语言自定义类型

c语言中自定义类型主要包括以下四种,即结构体、位端、枚举和联合(共用体),其中最常用的就是结构体类型,它是后续数据结构的学习的基石。

结构体

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

结构体的声明

声明方法如下:

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

其中struct tag为此结构体类型的类型名称,大括号中的内容为结构体的成员变量,它们可以是不同类型的变量,最后variable-list是初始化创建的结构体对象,在此处既可以创建此类型的变量,看也可以创建指向这种类型的结构体指针。
例如一个学生信息:

struct Stu
{
 char name[20];//名字
 int age;//年龄
 char sex[5];//性别
 char id[20];//学号
}s1,s2,*ps; 

此类型的名称为struct Stu,其中包括了学生的姓名、年龄、性别和学号,s1、s2是随类型定义创建的两个未初始化的结构体变量,而ps则为此结构体类型的一个指针。

结构体在声明的时候可以省去其tag部分,此时结构体为匿名结构体,此时这种类型只能通过伴随类型创建的变量进行使用,后续无法创建同类型的其他变量。此外,如果有两个匿名结构体变量的内容完全相同,他们仍然会被当作是两种不同的类型。

结构体的自引用

在结构体中既可以包括其他的结构体,同样也可以包含自己的结构体。但如果只是简单的包含自己这种类型的结构体本身,就会形成无限套娃,一个结构体变量的大小将不可知,如下代码:

struct Node
{
 int data;
 struct Node next;//可行?
};

因此,通常情况下结构体的自引用都会借助结构体指针指向下一个同类型的结构体,这也引出了数据结构链表中的一个概念——结点:

typedef struct Node
{
 int data;
 struct Node* next;
}Node;

这里我们借助typedef对struct Node重命名为Node,每个Node中包含两个变量,第一个是数据,第二个指向下一个同类型的结构体。这样所有的Node就像链子一样既保存了数据,也能通过指针找到下一个数据,最终形成链表。

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

话不多说,贴几段代码:

struct Point
{
 int x;
 int y;
}p1; //声明类型的同时定义变量p1
struct Point p2; //定义结构体变量p2
struct Point p3 = {1, 2};//定义结构体变量p3并赋值

p1是伴随类型定义而创建的变量,p2是利用创建好的结构体类型创建的变量,p3在定义的同时还进行了赋初值。

struct Stu        //类型声明
{
 char name[15];//名字
 int age;      //年龄
};
struct Stu s = {"zhangsan", 20};//初始化

包含多种类型的结构体初始化。

struct Node
{
 int data;
 struct Point p;
 struct Node* next; 
}n1 = {10, {4,5}, NULL}; //结构体嵌套初始化
struct Node n2 = {20, {5, 6}, NULL};//结构体嵌套初始化

有结构体嵌套的结构体初始化

结构体内存对齐

这个知识作为此板块重难点,主要和一个结构体类型所占字节大小相关联,一个结构体类型的大小是其包含的变量类型大小之和还是通过其他方式计算获得?这就需要引入结构体内存对齐这个概念。
观察以下代码:

//1
struct S1
{
 char c1;
 int i;
 char c2;
};
printf("%d\n", sizeof(struct S1));
//2
struct S2
{
 char c1;
 char c2;
 int i;
};
printf("%d\n", sizeof(struct S2));
//3
struct S3
{
 double d;
 char c;
 int i;
};
printf("%d\n", sizeof(struct S3));
//4-结构体嵌套
struct S4
{
 char c1;
 struct S3 s3;
 double d;
};
printf("%d\n", sizeof(struct S4));

先看前两个结构体,二者都包含两个字符以及一个整型数,但是他们所占的字节数却不相同——S1占12个字节,而S2占8个字节。这里我们要先引入结构体内存对齐的几个规则:
①第一个成员在与结构体变量偏移量为0的地址处。
② 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。
③结构体总大小为最大对齐数(每个成员变量都有一个对齐数)的整数倍。

在这里插入图片描述
s1:首先c1变量存放在偏移量为0的地方毋庸置疑,然后s1要存放整型变量i,但是看到内存下一个开始的地方是1,并不是默认对齐数(vs中默认为8)和自身所占内存中的较小值4的整数倍,因此要向后找到地址为4的地方开始存放并占用四个字节,然后c2为字符,占用一个字节,8是1的整数倍且1<8,因此可以紧接着存放,到此为止结构体s1共占用九个字节,又因为结构体总大小要为最大对齐数的整数倍,在这个结构体中最大对齐数为4,因此整个结构体的size应为4的倍数,所以要多占用三字节的内存来遵守规则,因此使占用字节数增加到12个。
在这里插入图片描述
s2:首先c1和c2两个均为字符变量,占用一个字节,因此从偏移量为0处连续存储两个(1<8),然后存储整型变量i,与上面相同,整型变量i要从内存中4的整数倍处开始存储,因此要跳过两个字节从4处开始存储,占用四个字节。在存储完毕后,此时结构体共占用了八个字节,为最大对齐数4的整数倍,无需在进行空字节补充,因此s2占用内存最后计算出为8个字节。

在这里插入图片描述
s3:首先是双精度浮点数d在偏移量为零处开始占用八个字节,然后字符c可以紧接着存储,然后整数i因为要进行内存对齐,需要从4的整数倍开始存储,因此要跳过三个字节从12处开始存储,占用四个字节。最后判断是否需要补齐内存:目前为止s3共用掉16个字节,是最大对齐数8(来自d)的整数倍,因此无需额外补充,结构体s3占用16个字节。

在讲解s4前还要交代最后一个规则:
④如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整
体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍。

在这里插入图片描述
s4:字符c1在偏移量0处占用一个字节,然后存放结构体s3,因为s3的最大对齐数来自其内部的double变量,因此要跳过七个字节,从8处开始存储,并占用16个字节(上面求出来的)。此时内存已占用24个字节,双精度浮点数d刚好可以接续存储(24为8的整数倍),占用八个字节,总共32字节。最后整个结构体占用字节数32是最大对齐数8的整数倍,符合规则,无需增加空字节。

讲解清楚结构体内存对齐的规则,我们不禁思考,为什么要进行结构体内存对齐呢?主流解释包含以下两条:
①平台原因(移植原因):
不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特
定类型的数据,否则抛出硬件异常。

②性能原因:
数据结构(尤其是栈)应该尽可能地在自然边界上对齐。
原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访
问。

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

因此在创建结构体时,为了能让其占用的内存尽可能小,我们应尽量让占用内存小的变量集中在一起,如将s1的排序方式转换为s2,这样能够一定程度上节省空间。

结构体传参

先看代码:

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

对比两种调用方式,你觉得哪种更好?
答案一定是传址调用。
因为当函数传参的时候,参数是需要压栈的,会有时间和空间上的系统开销。如果传递一个结构体对象的时候,结构体过大,参数压栈的的系统开销比较大,会导致性能的下降,因此结构体作为函数参数时,我们进行传址调用。

位段

位段定义及性质

位段是由结构体实现的,我第一次看到它也不知道在何处应用,这个我们按下不表,最后再说,我们先讲讲他的一些性质:
位段和结构体有两个不同之处,分别是:
①位段的成员必须是 int、unsigned int 或signed int 。
②位段的成员名后边有一个冒号和一个数字。例如:

struct A
{
 int _a:2;
 int _b:5;
 int _c:10;
 int _d:30;
};

其中冒号后的数组为该变量所需要的比特位,而不是字节

那么这个位段占几个字节呢?
位段中的变量在申请空间的时候是根据其类型进行申请的,如果是整型,则一次申请四个字节,如果是字符型则一次申请一个字节。此位段中的变量均为整型,因此我们先申请好四个字节,然后判断是否够用:整型变量_a需要两个比特位,小于四个字节;随后整型变量_b需要五个比特位,加上前面两个仍然小于四个字节;整型变量_c申请了10个比特位,加上前面七个共17个比特位,仍小于四个字节(32个比特位);最后整型变量_d申请了30个比特,前面申请好的四个字节中的剩余空间已经不足以存下第四个占30比特的变量,因此我们为_d再申请四个字节。所以最后加在一起,结构体A共占用了八个字节。

位段能够使同类型的变量在存储过程中节省很多空间,但同时位段也涉及很多不确定因素,位段是不跨平台的,注重可移植的程序应该避免使用位段。

位段的应用

位段这种能够将数据紧凑存储的性质让他在数据传输界大放异彩,它最主要的应用就是ip数据报,如图:在这里插入图片描述
在数据链路层传输数据时,数据前面要绑定很多协同的信息,如长度、标志、片偏移、协议等等,而绑定的信息如果过长就会喧宾夺主成为累赘,降低传输效率。因此我们采用位段来进行存储这些信息,尽可能缩短这些”协同信息的大小“,降低信息传输的成本。

枚举

枚举类型定义

枚举顾名思义就是一一列举,把可能的取值列举出来。比如我们现实生活中:一个星期的七天可以列举出来;性别有男女也可以列举出来;月份有十二个月也可以列举出来。代码如下:

enum Day//星期
{
 Mon,
 Tues,
 Wed,
 Thur,
 Fri,
 Sat,
 Sun
};
enum Sex//性别
{
 MALE,
 FEMALE,
 SECRET
}enum Color//颜色
{
 RED,
 GREEN,
 BLUE
};

以上定义的 enum Day , enum Sex , enum Color 都是枚举类型。{}中的内容是枚举类型的可能取值,也叫枚举常量 。这些可能取值都是有值的,默认从0开始,一次递增1,当然在定义的时候也可以赋初值。例如:

enum Color//颜色
{
 RED=1,
 GREEN=2,
 BLUE=4
};

枚举的优点

我们可以使用 #define 定义常量,为什么非要使用枚举?
枚举的优点包括:

  1. 增加代码的可读性和可维护性
  2. 和#define定义的标识符比较枚举有类型检查,更加严谨。
  3. 防止了命名污染(封装)
  4. 便于调试
  5. 使用方便,一次可以定义多个常量

此外,枚举类型在使用时只能用枚举常量给枚举变量进行赋值,而不能用枚举常量之外的如整型变量等进行赋值,否则会出现错误。

联合(共用体)

联合的定义及性质

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

//联合类型的声明
union Un
{
 char c;
 int i;
};
//联合变量的定义
union Un un;
//计算联合变量的大小
printf("%d\n", sizeof(un));

由于联合的成员是共用同一块内存空间的,这样一个联合变量的大小,至少是最大成员的大小。
此外,为了验证联合共用同一块内存,我们用以下代码验证:

union Un
{
 int i;
 char c;
};
int main()
{
	union Un un;
	printf("%d\n", &(un.i));
	printf("%d\n", &(un.c));
	un.i = 0x11223344;
	un.c = 0x55;
	printf("%x\n", un.i);
	return 0;
}

运行结果:
在这里插入图片描述
由于vs为小端存储,所以在修改c时修改了i的最低位,并且二者的地址是完全相同的,这就验证了联合是使用同一块内存的性质。

联合大小的计算

我们知道,联合的大小至少是最大成员的大小。但是当最大成员大小不是最大对齐数的整数倍的时候,就要对齐到最大对齐数的整数倍。(比如联合中存在数组)代码如下:

union Un1
{
 char c[5];
 int i;
};
union Un2
{
 short c[7];
 int i;
};
int main()
{
	printf("%d\n", sizeof(union Un1));
	printf("%d\n", sizeof(union Un2));
	return 0;
}

第一个联合中字符数组c占用五字节内存,有符号整数i占四字节内存,因此联合大小最小是5,但5又不是该联合最大对齐数4的整数倍(数组元素按单个元素的大小计算,而不是数组整体大小),类似于结构体,联合也需要将内存进行补足,使其整体占用内存为最大对齐数的整数倍,因此该联合最终占用的内存应为8字节。

第二个联合中数组占用2*7也就是十四个字节,有符号整型i占用四个字节,因此联合大小最小是十四,但十四又不是该联合最大对齐数4的整数倍,与上面的联合相同,需要将占用内存补足至16,使其称为最大对齐数的整数倍即可。
在这里插入图片描述

动态内存管理

动态内存管理的意义

我们已经掌握的内存开辟方式有:

int val = 20;
char arr[10] = {0};

但是上述的开辟空间的方式有两个特点:

  1. 空间开辟大小是固定的。
  2. 数组在声明的时候,必须指定数组的长度,它所需要的内存在编译时分配。
    但是对于空间的需求,不仅仅是上述的情况。有时候我们需要的空间大小在程序运行的时候才能知道,那数组的编译时开辟空间的方式就不能满足了。
    这时候就需要进行动态内存开辟了。

malloc函数和free函数

malloc

C语言提供了一个动态内存开辟的函数:

void* malloc (size_t size);

作用:
①这个函数向内存申请一块连续可用的空间,并返回指向这块空间的指针。
②如果开辟成功,则返回一个指向开辟好空间的指针。
③如果开辟失败,则返回一个NULL指针,因此malloc的返回值一定要做检查。
④返回值的类型是 void* ,所以malloc函数并不知道开辟空间的类型,具体在使用的时候使用者自己
来决定。
⑤如果参数 size 为0,malloc的行为是标准是未定义的,取决于编译器。(蜜汁操作)

free

C语言提供了另外一个函数free,专门是用来做动态内存的释放和回收的,函数原型如下:

void free (void* ptr);

作用:
①free函数用来释放动态开辟的内存。
②如果参数 ptr 指向的空间不是动态开辟的,那free函数的行为是未定义的。
③如果参数 ptr 是NULL指针,则函数什么事都不做。

malloc和free两个函数都声明在 stdlib.h 头文件中。下面举一个两者应用的例子:

#include <stdio.h>
#include <stdlib.h>
int main()
{

 int num = 0;
 scanf("%d", &num);
 int* ptr = NULL;
 ptr = (int*)malloc(num*sizeof(int));
 if(NULL != ptr)//判断ptr指针是否为空
 {
 int i = 0;
 for(i=0; i<num; i++)
 {
 *(ptr+i) = 0}
 }
 free(ptr);//释放ptr所指向的动态内存
 ptr = NULL;//是否有必要?
 return 0;
}

在释放掉指针对应的内存空间后,一定要将指针设置为空指针。因为这个指针所指向的地址已经还给操作系统了,如果我们再次利用这个指针访问内存,就是越界访问,是很危险的!为了避免这种情况的发生,我们不仅要在程序结束前释放掉malloc申请的空间(calloc、realloc同理),还要将当时接受空间的地址置为空指针,以保证安全。

calloc函数

C语言还提供了一个函数叫 calloc , calloc 函数也用来动态内存分配。原型如下:

void* calloc (size_t num, size_t size);

作用:
①calloc函数的功能是为 num 个大小为 size 的元素开辟一块空间,并且把空间的每个字节初始化为0。
②calloc函数与malloc函数的区别只在于calloc会在返回地址之前把申请的空间的每个字节初始化为全0。
用例:

#include <stdio.h>
#include <stdlib.h>
int main()
{
 int *p = (int*)calloc(10, sizeof(int));
 if(NULL != p)
 {
 //使用空间
 }
 free(p);
 p = NULL;
 return 0;
}

运行结果:
在这里插入图片描述

realloc函数

realloc函数的出现让动态内存管理更加灵活。
有时会我们发现过去申请的空间太小了,有时候我们又会觉得申请的空间过大了,那为了合理的时
候内存,我们一定会对内存的大小做灵活的调整。那 realloc 函数就可以做到对动态开辟内存大小
的调整。
函数原型如下:

void realloc(void* ptr, size_t size);

其中ptr是要调整的内存地址,size是调整之后新大小,返回值为调整之后的内存起始位置。
这个函数调整原内存空间大小的基础上,还会将原来内存中的数据移动到新的空间。
realloc在调整内存空间的是存在两种情况:
情况1:原有空间之后有足够大的空间
情况2:原有空间之后没有足够大的空间
针对两种不同的情况,有两种不同的解决方法:
①当是情况1的时候,要扩展内存就直接原有内存之后直接追加空间,原来空间的数据不发生变化。
②当是情况2的时候,原有空间之后没有足够多的空间时,扩展的方法是:在堆空间上另找一个合适大小的连续空间来使用。这样函数返回的是一个新的内存地址。
由于上述的两种情况,realloc函数的使用就要注意一些。
应用举例:

#include <stdio.h>
int main()
{
 int *ptr = (int*)malloc(100);
 if(ptr != NULL)
 {
     //空间使用
 }
 else
 {
     exit(EXIT_FAILURE);    
 }
 //扩展容量
 //代码1
 ptr = (int*)realloc(ptr, 1000);//这样可以吗?(如果申请失败会如何?)
 
 //代码2
 int*p = NULL;
 p = realloc(ptr, 1000);
 if(p != NULL)
 {
 ptr = p;
 }
 //业务处理
 free(ptr);
 ptr = NULL;
 return 0;
}

对比代码一和代码二,明显可以看出代码二的处理方式更加妥当,realloc函数在申请空间时也不是百分百成功的,也可能会返回空指针,如果将空指针赋值给原有指针,程序就会出错。因此realloc返回的指针我们也需要进行检验是否为空,若不为空再进行使用。

结束语

本篇博客讲解了c语言中的自定义类型&动态内存管理部分知识,如有不足或遗漏之处还请大家指正,笔者感激不尽;同时也欢迎大家在评论区进行讨论,一起学习,共同进步!

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值