c语言修炼秘籍 - - 禁(进)忌(阶)秘(技)术(巧)【第五式】动态内存管理

发布于:2025-04-07 ⋅ 阅读:(23) ⋅ 点赞:(0)

c语言修炼秘籍 - - 禁(进)忌(阶)秘(技)术(巧)【第五式】动态内存管理

【心法】
【第零章】c语言概述
【第一章】分支与循环语句
【第二章】函数
【第三章】数组
【第四章】操作符
【第五章】指针
【第六章】结构体
【第七章】const与c语言中一些错误代码
【禁忌秘术】
【第一式】数据的存储
【第二式】指针
【第三式】字符函数和字符串函数
【第四式】自定义类型详解(结构体、枚举、联合)
【第五式】动态内存管理



前言

大家在使用数组时肯定都会有过这样的疑问,如果一个数组的长度不足了,可以延长这个数组吗?使其能够满足存储的需求。又或者是现在不确定数组应该设为多长,先把它放着,等之后再设置它的长度。
上面的这些需求,数组显然是无法实现的,那么c语言中有什么东西能够做到这一点呢?
动态内存分配!你现在不知道需要多大的空间,没关系,之后你明确了的时候,使用动态内存分配,将你要的空间分配给即可。

本章重点:

  • 为什么存在动态内存分配
  • 动态内存函数的介绍
    • malloc
    • free
    • calloc
    • realloc
  • 常见的动态内存错误

一、为什么存在动态内存分配

此前我们掌握的内存开辟方式有:

int val = 20; // 在栈空间开辟4个字节的空间
char arr[10] = { 0 }; // 在栈空间上开辟10个字节的连续空间

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

  • 空间开辟大小的固定的;
  • 数组在申明的时候,必须指定数组的长度,它所需要的内存在编译时分配。

但正如前言中说的,在处理实际问题时,我们对于空间的需求,不仅仅是上述的情况。有时候我们需要的空间大小只有在程序运行的时候才知道,这时候就只能试试动态内存开辟了。

二、动态内存函数的介绍

1. malloc和free

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

void* malloc(size_t size);

在这里插入图片描述

  • 这个函数的功能是分配一块内存空间,并返回这片空间的起始地址;
  • 这片空间的大小由参数size指定,单位为字节;
  • 这片新分配的空间并未初始化,保存的数据未知;
  • 如果参数size为0时,这种行为未被定义,取决于编译器;
  • 如果该函数分配空间成功,则会返回这块空间的地址;这个指针的类型为void *是因为它可以强制转换为任何所需的类型以满足解引用的需求;如果分配空间失败则会返回一个NULL指针;

c语言还提供了另一个函数free

void free(void*)

在这里插入图片描述

  • 它的功能是回收一片动态分配的内存空间;
  • 它可以回收malloccalloc或者是realloc函数分配的空间,使得这片空间又能被再次分配;
  • 如果`ptr``指向的空间不是动态分配的内存空间,这种行为是未定义的;
  • 如果ptr是一个NULL指针,这个函数什么都不会做(不是错误);
  • 注意,这个函数并不会改变ptr这个指针本身的内容,也就是说指针ptr仍指向这片空间(变成了野指针);

举个例子:

#include <stdio.h>
#include <stdlib.h> // 动态分配函数必须包含库

int main()
{
	// 代码1
	int num = 0;
	scanf("%d", &num);
	// 要在程序执行过程中,从用户处获得输入,设置一个用户指定大小的数组
	int arr[num] = { 0 }; // error,数组的定义[]中的值应该是一个常量

	// 代码2
	int* ptr = NULL;
	ptr = (int*)malloc(num * sizeof(int)); // malloc函数的单位为字节
	if (ptr == NULL) // 内存分配失败
	{
		perror("malloc"); // 输出错误信息,方便调试
		return;
	}
	// 内存分配成功后执行逻辑
	int i = 0;
	for (i = 0; i < num; i++)
	{
		*(ptr + i) = 0;
	}

	// 内存使用完毕
	// 回收空间
	free(ptr);
	ptr = NULL; // free函数并不会改变ptr本身的值,需要程序员手动将指针置空

	return 0;
}

在这里插入图片描述

注意
在最后的ptr = NULL这条语句是非常有必要的,因为没有这条语句,ptr此时已经变成了野指针,这是十分危险的;

2. calloc

除了能使用malloc函数进行内存分配之外,c语言还提供了calloc函数来进行内存分配;

void* calloc(size_t num, size_t size);

大家可能会疑惑,我们已经有malloc函数,为什么还要来一个calloc呢?
请接着往下看:
在这里插入图片描述

  • 这个函数的功能是分配一片空间,并对这片空间进行初始化(初始化为0);
  • calloc将会为num个元素分配一片内存空间,每个元素占size个字节,并在分配空间之后,以字节为单位将这片空间的值初始化为0;
  • 这个函数的有效结果就是分配一片初值为0的,有num * size这么多字节的空间;
  • 如果size为0,它的返回值依赖于特定的库实现(不一定是NULL指针),但这个指针一定不能被解引用;
  • 两个参数,num表示元素的个数,size表示一个元素占的字节数;
  • 如果分配成功,函数会返回一个指向这片空间的指针,指针的类型为void *,它可以根据需求,由使用者自由的强制转换成需要的类型,并进行解引用操作;
  • 如果分配失败,将会返回一个NULL指针;

从上面的解读中,我们就能知道,malloc函数仅仅只是分配了这么一片空间,但其中的值是没有被初始化的,calloc函数能够在分配空间的同时,将这片空间的每个字节都初始化为0;

#include <stdio.h>
#include <stdlib.h>

int main()
{
	int *ptr1 = (int*)malloc(10 * sizeof(int));
	if (ptr1 == NULL)
	{
		perror("malloc");
		return;
	}

	int *ptr2 = (int*)calloc(10, sizeof(int));
	if (ptr2 == NULL)
	{
		perror("calloc");
	}

	free(ptr1);
	ptr1 == NULL;
	free(ptr2);
	ptr2 = NULL;

	return 0;
}

在这里插入图片描述
所以当我们对申请的空间的内容有初始化的要求时,就可以使用calloc函数很方便的完成了;

3. realloc

有了上面的两个内存分配函数,我们能够在程序执行中根据需求为变量分配内存空间;
但是仔细想想,当我们在分配完空间之后,这片空间仍不足以满足需求呢?这时应该怎么办?
是再使用malloccalloc函数重新分配一片空间,并将原来的空间回收吗?
这样是否有点麻烦,有没有其他方法?
有的,兄弟有的。
使用realloc函数;

void* realloc(void* ptr, size_t size);

在这里插入图片描述

  • realloc函数可以重新分配动态分配的空间,改变ptr指向空间的大小;
  • 这个函数可能会分配一片新的内存空间(返回值指向的空间);
  • 这片空间的内容会保留到新大小和旧大小中较小的那个,即使这片空间是一片新分配的空间;如果新大小更大,新分配空间中的内容是未定义的;
  • 如果参数ptrNULL指针,这个函数的行为和malloc相同,分配一片大小为size字节的空间,并返回一个指向它的指针;
  • 在C90标准中,如果参数size为0,调用该函数的行为就像调用了free一样,它会释放掉ptr指向的空间,并返回一个NULL指针;在C99/C11标准中,则取决于特定的库实现,该行为未定义;
  • 返回值有两种情况:
    1. 返回的指针和ptr相同;
    2. 返回的指针和ptr不同,是一个新的地址;
  • 返回指针的类型为void*,原因和前面的相同;
  • C90标准中,有两种情况会返回NULL指针:参数size为0;空间分配失败;
  • C99/C11标准中,只有空间分配失败会返回NULL指针。参数size为0的这种行为未定义;

在这里插入图片描述

看下面代码:

#include <stdio.h>
#include <stdlib.h>

int main()
{
	int* ptr = (int*)malloc(100);
	if (ptr == NULL)
	{
		perror("malloc");
		return;
	}
	// 业务处理

	// 扩充容量
	// 代码1
	ptr = (int*)realloc(ptr, 1000); 

	// 代码2
	int* p = (int*)realloc(ptr, 1000);
	if (p == NULL)
	{
		perror("realloc");
		return;
	}
	ptr = p;
	
	// 业务处理

	free(ptr);
	ptr = NULL;

	return 0;
}

上面代码中代码1和代码2正确吗,应该用哪个?

根据上面的函数解读,我们知道realloc函数是可能出现分配失败的情况的。
在代码1中,如果realloc失败,它会返回NULL指针,此时直接将ptr设为了NULL指针,这就导致了内存泄漏,之后再也找不到ptr指向的空间了;
所以代码1是不可行的,应该使用代码2,在调用realloc之后,先判断它的返回值,看看空间是否分配成功,成功之后,才将分配的地址赋值给ptr;

三、常见的动态内存错误

1. 对NULL指针的解引用操作

#include <limits.h>

void test()
{
	int* p = (int *)malloc(INT_MAX / 4);
	*p = 20;
	free(p);
	p = NULL;
}

这段代码正确吗?
错误,因为malloc函数可能返回NULL指针,当空间分配失败时,ptr指针为NULL指针,对NULL指针解引用是错误的;

2. 对动态开辟空间的越界访问

#include <stdio.h>

void test()
{
	int i = 0;
	int *p = (int *)malloc(10 * sizeof(int));
	if (p == NULL)
	{
		perror("malloc");
		return;
	}

	for (i = 0; i < 11; i++)
	{
		*(p + i) = 1;
	}

	free(p);
	p = NULL;
}

这段代码也是有问题的,
malloc函数只为p分配了10个int类型的空间,循环会访问到第十一个int类型的元素,此时产生了越界;

3. 对非动态开辟内存使用free释放

#include <stdlib.h>

void test()
{
	int arr[10] = { 0 };
	int *p = arr;
	free(p);
}

这段代码就是使用了free函数释放不是动态分配的内存空间,此时也会出错;

4. 使用free释放一块动态开辟内存的一部分

#include <stdio.h>
#include <stdlib.h>

void test()
{
	int *p = (int *)malloc(10 * sizeof(int));
	if (p == NULL)
	{	
		perror("malloc");
		return ;
	}

	int i = 0;
	for (i = 0; i < 5; i++)
	{
		*(p++) = i;
	}

	free(p);
	p = NULL;
}

这段代码同样是有问题的,在循环中,指针p指向的位置已经发生了变化,此时指针p并没有指向动态分配的内存空间的起始位置,此时使用free函数来释放动态分配的空间,仍有一部分没有释放,所以这个代码是错误的;

5. 对同一块动态内存多次释放

#include <stdio.h>
#include <stdlib.h>

void test()
{
	int *p = (int *)malloc(10 * sizeof(int));
	if (p == NULL)
	{
		perror("malloc");
		return ;
	}

	// 业务实现
	free(p);

	// 业务实现
	free(p);
}

这种代码也是错误的,它对同一片动态内存空间释放了两次;
虽然,上面的代码不太可能会出现(毕竟没有谁会在同一个函数中对一片动态分配的内存空间释放多次),但这样的错误的可能会出现的;
动态分配的空间,只有两种情况会释放,程序结束、手动free;有可能出现,你在这个函数中将这个空间释放了一次,在另一个函数中又对它释放了一次,这时就出现了多次释放的错误;

有方法可以规避这种错误:

free(p);
p = NULL;

上面的这两行代码就能规避多次释放的问题,还记得free函数的特点吗?
当free函数的参数为NULL指针时,调用这个函数什么也不会发生。
在每次释放指针指向的动态内存空间之后,紧接着将这个指针置为NULL指针,能够有效的规避这种错误的出现;

6. 动态开辟内存忘记释放(内存泄漏)

#include <stdio.h>
#include <stdlib.h>

void test()
{
	int *p = (int *)malloc(10 * sizeof(int));
	if (p == NULL)
	{
		perror("malloc");
		return ;
	}

	// 业务处理

	// 忘记释放动态内存空间
	// 出现内存泄漏
}

int main()
{
	test();
	// 业务处理

	return 0;
}

上述代码中,主函数调用了test函数,动态分配了一个内存空间给指针p,在test函数退出时,并没有释放这片空间,这片空间就一直留存在堆区中,且指针p是test函数的局部变量,在test弹出之后,这个变量就销毁了,这片动态分配的空间也就没有人能够找到了,所以这片空间在之后的程序中是无法再被使用的,只有在整个程序结束时,这片空间才会还给OS,这就产生了内存泄漏问题。
可能有人会这没什么,反正程序结束,这片空间会归还给OS的,我们不用管;
但是试想一下,这个程序如果是一个服务器程序呢?它需要长期的执行,每次调用test函数都会泄漏一部分的内存空间,硬件资源总是有限的,那么这个程序在运行一段时间之后就一定会因为内存资源不足而挂掉,如果2天这个程序就把内存消耗光了,就得2天重启一次程序,这会带来很大的损失;

忘记释放不再使用的动态开辟的空间会造成内存泄漏
切记
动态开辟的空间一定要释放,并且要正确的释放

四、使用动态内存修改通讯录

在之前的通讯录结构体类型中,不管什么情况,通讯录都能保存1000个人的信息,如果这个人他只有100个朋友的信息需要保存,他使用这个通讯录会浪费剩下的900个空间,这会造成内存资源很大的浪费;如果他需要保存的信息超过1000个,那么这个通讯录就无法满足他的需求;
这时我们就可以使用动态内存来替换掉之前使用的静态分配的空间,在程序执行过程中,根据用户的需要增加通讯录的空间,即满足了用户要保存人的信息的需求,又减少了系统资源的开销;

// 静态版本
// 定义结构体、标识符常量
#define MAXNUM 1000 // 通讯录中能保存的人数

typedef struct
{
	int age; // 年龄
	char name[20]; // 名字
	char sex[7]; // 性别
	char phone_number[20]; // 电话号码
	char address[30]; // 地址
} Peoinfo; // 保存人的信息

typedef struct
{
	Peoinfo data[MAXNUM]; // 通讯录可以保存1000个人的信息
	int count; // 当前通讯录中的人数
} Contact;

修改后的代码,有改动的代码

// 动态内存版本
#define INIT_COUNT 3 // 通讯录初始能保存3个人的信息
#define INCREASE_COUNT 2 // 每次扩容增加数量

typedef struct
{
	int age; // 年龄
	char name[20]; // 名字
	char sex[7]; // 性别
	char phone_number[20]; // 电话号码
	char address[30]; // 地址
} Peoinfo; // 保存人的信息

// 使用指针指向保存人的信息的空间
typedef struct
{
	Peoinfo* data; // 指向保存人信息的空间
	int count; // 当前通讯录中的人数
	int num; // 当前通讯录中能保存的最大人数
} Contact;
void initContact(Contact* contact)
{
	assert(contact);

	// 动态分配 INIT_COUNT * sizeof(Peoinfo) 个字节的空间
	contact->data = (Contact*)calloc(INIT_COUNT, sizeof(Peoinfo));
	contact->count = 0;
	contact->num = INIT_COUNT;
}

static void increase_Capacity(Contact* contact)
{
	// 为通讯录的保存人数扩容
	Contact* ptr = (Contact*)realloc(contact->data, ((contact->num) + INCREASE_COUNT) * sizeof(Peoinfo));
	// 空间分配失败
	if (ptr == NULL)
	{
		perror("checkCapacity, realloc");
		return;
	}
	contact->data = ptr; // 将重新分配的空间的地址赋值给contact
	contact->num += INCREASE_COUNT;
}

void Add(Contact* contact)
{
	assert(contact);
	if (isFull(contact))
	{
		increase_Capacity(contact);
		printf("已扩容\n");
	}

	getchar();
	// 没有做溢出检查
	printf("输入名字:>");
	gets((contact->data[contact->count]).name);

	printf("输入年龄:>");
	scanf("%d", &((contact->data[contact->count]).age));

	getchar();
	printf("输入性别:>");
	gets((contact->data[contact->count]).sex);

	//getchar();
	printf("输入电话:>");
	gets((contact->data[contact->count]).phone_number);

	//getchar();
	printf("输入地址:>");
	gets((contact->data[contact->count]).address);

	(contact->count)++;
}

别忘了要手动释放动态开辟的内存空间

case exit:
	free(contact.data);
	contact.data = NULL;
	printf("退出通讯录\n");
	break;

五、几个经典笔试题

1. 题目1

#include <stdio.h>
#include <string.h>

void GetMemory(char* p)
{
	p = (char*)malloc(100);
}
void Test()
{
	char *str = NULL;
	GetMemory(str);
	strcpy(str, "hello world");
	printf(str);
}

int main()
{
	// 调用Test函数会有什么样的结果?
	Test();

	return 0;
}

分析:
根据代码意思,这段代码的目的是在Test函数中定义了一个char *的变量,初始化为NULL指针,调用GetMemorystr动态分配一片空间,之后将“hello world”这个字符串复制到这片动态开辟的空间中;
但事实真的如此吗?
来看看运行结果:
在这里插入图片描述
为什么会这样呢?
我们不是将一个指针作为参数吗?这不是传址调用吗?
真的吗?真的是传址调用吗?
注意,虽然GetMemory的参数是指针,且传入的实参也是指针,但是GetMemory是对这个指针本身进行修改,并不是修改这个指针指向的内容;形参pstr的一份临时拷贝,这个函数是一个传值调用,函数内部的操作是不会对实参本身产生任何影响的;
注意不要看到函数的参数是指针就把它当成传址调用,应该结合这个指针在函数中执行什么样的操作来判断

2. 题目2

#include <stdio.h>

char* GetMemory()
{
	char p[] = "hello world";
	return p;
}

void Test()
{
	char *str = NULL;
	str = GetMemory();
	printf(str);
}

int main()
{
	Test();

	return 0;
}

分析:
上面代码的目的应该是,Test函数通过调用GetMemory函数来获得"hello world"这个字符串的地址,并在Test中输出它;
但结果真的如此吗?
并不是对吧,注意GetMemory函数中的字符数组p是一个局部变量,且没有static修饰,并不是静态变量,所以这个变量的生命周期只在GetMemory函数中,离开函数这个变量就销毁了,所以当Test函数使用这个地址时,其实它已经变成了一个野指针,这片空间指向的内容是什么完全确认;

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

可以看到结果和分析的相同,这个地址指向内容变成了随机值;

3. 题目3

#include <stdio.h>

void GetMemory(char** p, int num)
{
	*p = (char*)malloc(num);
}

void Test()
{
	char *str = NULL;
	GetMemory(&str, 100);
	strcpy(str, "hello");
	printf(str);
}

int main()
{
	Test();

	return 0;
}

分析:
GetMemory函数中动态开辟了一片空间,并通过指针操作,将Test函数传给GetMemory的参数str,此时是一个传址调用,此时的代码是符合设计的;
运行结果:
在这里插入图片描述

4. 题目4

#include <stdio.h>
#include <stdlib.h>

void Test()
{
	char *str = (char*)malloc(100);
	strcpy(str, "hello");
	free(str);
	if (str != NULL)
	{
		strcpy(str, "world");
		printf(str);
	}
}

int main()
{
	Test();

	return 0;
}

分析:
这就是一个典型的野指针问题,str接收了动态开辟的空间,在调用了free函数之后,str指向的空间已经被系统回收,此时这片空间已经不属于str,但是并没有手动将str指向NULL,所以if语句块中的语句会被执行,会输出"world"(虽然这片空间已经不属于str,但strcpy这个函数不会管你这些,你给出指令,它就会执行对应的操作);

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

六、C/C++程序的内存开辟

我们曾经简单的介绍了C\C++中程序的内存区域划分,分为栈区、堆区、静态区三个区域,现在我们再对这部分进行详细说明;
在这里插入图片描述
C/C++程序内存分配的几个区域:

  1. 栈区(stack):在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时,这些存储单元自动释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。栈区主要存放运行函数而分配的局部变量、函数参数、返回数据、返回地址等;
  2. 堆区:一般由程序员分配释放,若程序员不释放,程序结束可能由OS回收。分配方式类似于链表;
  3. 数据段(静态区)(static):存放全局变量、静态数据。程序结束后由系统释放;
  4. 代码段:存放函数体(类成员函数和全局函数)的二进制代码;

从这幅图中更能理解为什么用static修饰的局部变量生命周期会变长,因为静态变量存放在数据段,数据段的特点是在上面创建的变量会一直存在,直到程序结束才销毁,所以生命周期变长。

七、柔性数组

在C99中,结构体的最后一个元素允许是未知大小的数组,这就叫做【柔性数组】成员。
例如:

typedef struct st_type
{
	int i;
	int a[0]; // 柔性数组成员
} type_a;

有时上面代码编译器会报错,可以改成:

typedef struct st_type
{
	int i;
	int a[]; // 柔性数组成员
} type_a;

1. 柔性数组的特点

  • 结构中的柔性数组成员前面必须至少有一个其他成员;
  • sizeof返回这种结构大小不包括柔性数组的内存;
  • 包含柔性数组成员的结构用malloc()函数进行内存的动态分配,并且分配的内存应该大于结构的大小,以适应柔性数组的预期大小;

例如:

#include <stdio.h>

typedef struct st_type
{
	int i;
	int a[]; // 柔性数组成员
} type_a;

int main()
{
	printf("%d\n", sizeof(type_a));

	return 0;
}

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

2. 柔性数组的使用

// 代码1
#include <stdio.h>
#include <stdlib.h>

typedef struct st_type
{
	int i;
	int a[]; // 柔性数组成员
} type_a;

int main()
{
	int i = 0;
	type_a *p = (type_a*)malloc(sizeof(type_a) + sizeof(int) * 100);
	if (p == NULL)
	{
		perror("malloc");
		return;
	}
	p->i = 100;
	for (i = 0; i < p->i; i++)
	{
		p->a[i] = i;
	}
	free(p);
	p = NULL;

	return 0;
}

上面的代码在堆区开辟了一片空间用于保存这个结构体的信息,这个结构体中有一个长度为100的int数组;
柔性数组a有100个int元素的连续空间;

3. 柔性数组的优势

上面的代码还有另一种写法:

// 代码2
#include <stdio.h>
#include <stdlib.h>

typedef struct
{
	int i;
	int *p_a;
} type_a;

int main()
{
	type_a *p = (type_a*)malloc(sizeof(type_a));
	if(p == NULL)
	{
		perror("malloc p");
		return ;
	}
	p->i = 100;
	p->p_a = (int*)malloc(p->i * sizeof(int));
	if(p->p_a == NULL)
	{
		perror("malloc P_a");
		return ;
	}

	int i = 0;
	for (i = 0; i < p->i; i++)
	{
		*(p->p_a + i) = i;
	}

	// 一定要按照顺序,否则会出错
	free(p->p_a);
	p->p_a = NULL;
	free(p);
	p = NULL;

	return 0;
}

代码1和代码2才能实现同样的功能,但是代码1有两个好处:

  1. 方便内存释放:

    可以看到,在代码2中进行了两次内存释放操作,因为结构体内部的成员也进行了动态内存开辟;
    当这个函数是我们封装好提供给他人使用的函数,别人是不知道,我们也不希望别人知道这个函数是怎样实现的,所以用户是不知道这个结构体内部的成员在使用结束后也需要释放内存,这就会产生问题;
    使用代码1的写法,直接释放结构体指针指向的内存空间,就能够完成动态开辟的内存空间的释放;

  2. 有利于访问速度

    代码1中整个结构体是在一片连续的堆区空间中,代码2则是数组和结构体的内存空间并不连续;连续的内存有益于提高访问速度,也有益于减少内存碎片;

所以上面修改后的通讯录还可以使用柔性数组来实现;

typedef struct
{
	int age; // 年龄
	char name[20]; // 名字
	char sex[7]; // 性别
	char phone_number[20]; // 电话号码
	char address[30]; // 地址
} Peoinfo; // 保存人的信息

typedef struct
{
	int count; // 当前通讯录中的人数
	int num; // 当前通讯录中能保存的最大人数
	Peoinfo data[0]; // 指向保存人信息的空间
} Contact;

在空间不足时使用realloc函数来扩容即可;


总结

本文介绍了动态内存的开辟方法,并结合这个技术修改了之前的通讯录程序,这个技术使得程序员可以在程序执行过程中动态的调整变量占据的内存空间大小,极大的扩展了程序的功能;并在之后详细解读了动态内存开辟的6种错误;在最后介绍了柔性数组;


网站公告

今日签到

点亮在社区的每一天
去签到