从C语言到C++你必须学会的---动态内存和智能指针

Posted KookNut39

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了从C语言到C++你必须学会的---动态内存和智能指针相关的知识,希望对你有一定的参考价值。

不管你是C++初学者,还是想从C语言转变为C++,你都应该了解C++的动态内存和智能指针,今天我们就来看一下有关这两个方面的内容。

本文章内容篇幅较长,且干货满满,感兴趣的大家可以收藏+点赞,以后慢慢看!

本文适合C语言转C++或者学习C++的同学,码字不易,如果喜欢,希望您能来个三连支持一下博主🤞

一、C语言动态内存

对于C语言来说,动态内存的申请是通过关键字malloc来实现的,使用malloc进行动态内存申请,是在堆区为当前程序分配一块内存,为了方便我们对于程序中某些片段的内存使用未知大小的时候,给程序的使用者更大的灵活性,可以从外部来决定对于内存的使用多少。该函数返回void*的指针,我们首先来看一下malloc的声明是如何的:

void* __cdecl malloc(
    _In_ _CRT_GUARDOVERFLOW size_t _Size
    );

由以上代码可以看出,malloc使用了__cdecl的调用约定,返回值是void*,然后形式参数只有一个,把它命名为_Size,是size_t类型的变量。
在使用malloc申请内存的时候,需要注意以下几个方面:

1.参数传递:

参数传递可以直接传递一个变量、一个sizeof计算出来的值,或者一个直接的数值,都是可以的,如下图所示:

2.返回值:

我们在一开始已经说过了malloc的返回值是void类型的指针,并且指向的是这一片内存的首地址,如果我们使用它进行内存申请,如果指针类型不是void,那肯定需要对返回值进行强制类型转换,一般转换的类型和我们的变量类型是息息相关的,如下图所示:

3.内存申请有效性判断:

我们上面举得例子,肯定是有问题的,对于申请内存来说,申请两个字就至关重要了,既然作为程序,你是去申请,那么系统给不给你内存,那这是不一定的事情,所以申请就有可能成功,有可能失败,那我们肯定得进行有效性判断啊,但是怎么判断呢?这就和返回值有关了,如果申请成功,那就返回指向内存首地址的指针,如果失败,那么返回NULL。所以判断如下:

4.内存初始化:

我们使用malloc申请出来的内存里面到底是什么东西呢?它本身有没有对申请到的内存进行初始化呢,我们可以在调试时查看一下:

使用vs2017来进行调试,申请出来的4字节内存,没有进行初始化,其中各个字节都是’cd’,假如我们暂时不用,或者说用不了全部的申请出来的内存,那我们最好还是将其初始化一下,不管后面使用的时候我们会为它赋什么值,我一般会把它初始化为0。

	int *ptr1 = (int*)malloc(4);
	if (ptr1 == NULL)
	{
		//进行对应处理
		printf("malloc false\\n");
		return -1;
	}
	//初始化申请到的4个字节为0
	memset(ptr1,0,4);

5.内存越界:

我们在使用动态申请到的内存一定要注意内存越界的问题,这是一个隐蔽的错误,因为编译器不会在编译阶段对内存越界的操作报错,而你对越界之后的内存进行读写操作,可能发生不可预估的错误!下面我们故意写一个内存越界的小代码,看一下会发生什么:

int main()
{
	int *ptr1 = (int*)malloc(4);
	if (ptr1 == NULL)
	{
		//进行对应处理
		printf("malloc false\\n");
		return -1;
	}
	//初始化申请到的4个字节为0
	memset(ptr1,0,4);
	for (int i = 0; i < 3; i++, ptr1++)
	{
		printf("%d\\n", *ptr1);
	}
	return 0;
}


结果分析,本来我们的int*指针解引用之后,是我们初始化的数字0,但是我们for循环使指针越界之后,就打印出了如上图所示的其他数字。并且这样的数组越界访问不会引起编译器报错,还是相当危险的。

6.内存释放与野指针

内存释放最大的问题是程序员忘记释放,所以一定要养成良好的编程习惯,释放内存使用的是free函数,需要传入参数就是申请的那块内存的首地址,内存释放的原因是用malloc申请的内存,在当前程序的堆区,如果使用完这块内存,而程序员不手动释放的话,那么这块内存在程序生存期间就会一直无法再次利用,这样对于内存利用上来说,肯定是不好的,所以养成良好的习惯,使用完毕之后,对申请到的内存进行释放:

野指针,顾名思义就是狂野的,“没有家”的指针,也就是指向的那块地址可能是无效的,尤其常见发生在一种情况,就是指针在释放之后,我们还去访问它指向的那块地址,就可能会发生意想不到的结果,并且这种野指针可能会对我们的程序造成伤害,且无法在最初编译阶段发现报错,甚至运行过程中也没有异常发生,只是我们得不到期望的结果:

	if (ptr1 != NULL)
	{
		//如果指针变量不为NULL
		free(ptr1);
	}
	//我们故意去解这个指针的引用
	printf("%d\\n",*ptr1);


打印出来了这样一个负数,显然不是我们最初给它初始化的0。所以我们该如何避免这样的操作呢?等到把指针释放之后,给它置为NULL,这样的话,如果对指针进行解引用访问,就会发生异常报错:

	//初始化申请到的4个字节为0
	memset(ptr1,0,4);
	if (ptr1 != NULL)
	{
		//如果指针变量不为NULL
		free(ptr1); 
		ptr1 = NULL;//让指针指向NULL地址
	}
	//我们故意去解这个指针的引用  这时候就会发生异常报错,因为对空指针解引用了
	printf("%d\\n",*ptr1);

有关C语言中动态内存申请,我们一般需要注意以上的这些点,而C++是与C语言的内存申请有什么不同,或者说,做了哪些改进呢?接下来让我们一起看一看。

二、C++动态内存

对于C++来说,我们使用new关键字来为程序申请一块堆区的内存,至于动态申请内存的原因在之前C语言malloc那里就有提到,是为了让内存的使用更加的合理和灵活。这个申请到的对象所占用的内存空间的生存期是从开始申请到,一直到调用delete显示释放之前,不会被自动释放。

1.参数传递

我们可以在使用new动态申请内存的时候只申请指向单个对象的指针,也可以申请指向多个对象的指针,只需要用不同的写法就可以了:

	int n = 10;
	int *ptr1 = new int;//申请指向一个int类型的指针
	int *ptr2 = new int[10];//申请指向10个int类型的指针
	int *ptr3 = new int[n];//申请指向n个int类型的指针

2.返回值

对于new来说,如果我们给对象申请内存成功之后,那么也会返回指向对象的指针,如果说申请的是多个对象,那么就会返回第一个对象的首地址,当前指针变量自然就指向那个对应的地址。还有就是new在申请内存的时候,就已经指定了固定的类型,如上面的代码所示,我们申请到的都是int型的指针。

3.内存有效性判断

如果我们在C++中用new去申请一块动态内存,那也是有可能成功,有可能失败,malloc失败之后返回的是NULL,那new失败之后返回的是什么呢?我们又该如何去判断呢?我们有两种办法可以用:
第一种就是利用try catch来捕获异常的发生,因为在new申请内存失败之后,会返回一个bad_alloc的异常,如果catch捕获到这个异常,则进行处理

	try
	{
		int *ptr1 = new int;//申请指向一个int类型的指针
	}
	catch (bad_alloc)
	{
		//当new分配内存失败之后,会抛出bad_alloc的异常代码
		cout << "bad alloc" << endl;
		//接下来做其他处理
	}

第二种是使用定位new的表达式new (place_address) type这种方式来进行处理:


	int *ptr1 = new (nothrow) int;//申请指向一个int类型的指针,如果失败,返回nullptr指针
	if (ptr1 == nullptr)
	{
		//申请失败进行处理
		cout << "new false" << endl;
	}

我们使用这种方式来进行内存申请,如果失败,则不抛出异常,会返回一个nullptr的指针,nothrow是标准库定义过的对象。

4.内存初始化

那我们分配到内存之后,初始化操作又是怎么初始化的呢?对于malloc来说,必须先申请出来内存,然后进行初始化,但是new可不是这样,当然,对于new来说,如果先申请内存,再进行初始化,那也是可行的,并且未初始化之前,对于int型指针来说,里面内容也都是‘cd’,这个我们之前在malloc那一块有过介绍。现在来说点不一样的,那就是在给对象申请内存的同时进行对象初始化。

	int *ptr1 = new int(39);//ptr1指向的对象的值为39
	int *ptr2 = new int();//ptr2指向的int对象被初始化为0
	int *ptr3 = new int[5]();//ptr3指向5个int对象初始化为0

在C++11的新标准中,给出了一个花括号列表初始化元素的方法,我们可以用花括号来初始化对象的全部或者部分元素,如果只初始化部分元素,那么剩下的就是按照0来赋值,如果初始化的个数超出了我们本身申请内存的元素个数,编译器会报出E0146的错误,提醒初始值设定项太多,所以使用这种初始化操作,也可以有效防止初始化元素,指针越界的问题:

	int *ptr4 = new int[5]{ 1,2,3,4,5 };//初始化5个int值为1,2,3,4,5
	int *ptr5 = new int[5]{ 1,2 };//初始化前两个int值为1,2  其余全是0
	
	//编译器会报错 错误(活动)	E0146	初始值设定项值太多
	//int *ptr6 = new int[5]{ 1,2,3,4,5,6,7 };

5.内存越界

内存越界是指针的一个非常值得注意的问题,我们以上说的申请内存时给对象初始化可以一定程度上防止初始化指针越界,但是并不能防止访问时候指针越界的操作,对于指针越界,在malloc那里已经举了例子,我们要做的就是一定要主要防止指针越界这种情况的发生,因为一旦发生,可能会有一些意想不到的代码错误出现。

6.内存释放与野指针

对于C++的内存释放,使用的是delete关键字,有两种形式,一种是对单个的对象进行释放,一种是对数组进行释放:

	int *ptr1 = new int(39);//ptr1指向的对象的值为39
	
	int *ptr3 = new int[5]();//ptr3指向5个int对象初始化为0

	delete ptr1;//释放单个对象的内存
	delete[] ptr3;//释放数组的内存空间

delete[] ptr3 []表示指针指向一个对象数组的第一个元素。当我们写这么一点测试代码的时候,也许我们不会在这个问题上出错,但是当项目变大,代码量变大,我们可能就会出现:忘记使用delete释放内存,这样会造成内存空间一直被占用,自然也就造成了内存泄露;使用已经释放过的内存,比如我们上面在malloc中的举例,此时的指针就是野指针,用官方点的话来说叫做空悬指针;还有就是对一个已经申请的内存空间进行重复释放,已经用过delete了,但是忘记了,在程序中又使用了一次,那么可能就会造成堆的破坏:

	int *ptr1 = new int(39);//ptr1指向的对象的值为39
	int *ptr3 = new int[5]();//ptr3指向5个int对象初始化为0
	
	delete[] ptr3;//释放数组的内存空间
	delete ptr1;//释放单个对象的内存
	cout << *ptr1 << endl;//野指针
	delete ptr1;//重复释放  引发异常

所以我们一定要注意内存释放之后,将指针置为nullptr,防止野指针的发生。

7.对类对象使用new申请内存

当我们使用new来对一个类对象动态内存申请时候,会执行这个类的构造函数,使用delete释放内存时候,会执行析构函数。

class MyClass
{
public:
	MyClass();
	~MyClass();
private:
};

MyClass::MyClass()
{
	cout << "Here is constructor" << endl;
}
MyClass::~MyClass()
{
	cout << "Here is destructor" << endl;
}
int main()
{
	MyClass *ptr1 = new MyClass;//申请内存 执行无参构造函数
	
	delete ptr1;//释放内存,执行MyClass的析构
	return 0;
}


那对于C/C++的动态内存申请的介绍到这里就告一段落了,有优点那就是可以使内存使用更灵活,并且可以在堆区申请内存来使用等等,但是也有缺点,那就是可能造成一些指针和内存上的问题。那我们可以优化这些问题吗?

三、C++智能指针

对于动态内存的使用,对我们来说会带来便捷和程序开发的灵活性,但是防止内存泄露,防止二次释放造成堆破坏,防止野指针四处游荡,这些令我们头大的问题,依旧困扰着太多的开发者。C++11新特性智能指针应运而生!!!智能指针的出现,主要是为了让开发者更安全也更容易地使用动态内存,作为新学习C++的选手来说,智能指针一定要好好学习掌握,在以后的开发中多多使用智能指针,而不是平时普通意义的指针。接下来就来依次介绍一下这些指针和简单的使用举例吧!智能指针我觉得最多的还是使用吧,其实用起来和普通的指针没有什么不同,只不过它们使模板类,然后有一些封装好的内部接口可以调用罢了,对普通指针的使用操作,在智能指针身上照样都可以使用。有一些使用时候的注意事项也是我们将要提到的。

1. shared_ptr

shared_ptr需要记住一句话,它可以允许多个指针指向同一个对象。

1.1 使用举例

我们可以使用如下的方式来声明一个智能指针,这个指针指向int类型的指针,我把它命名为ptr1,目前这个指针是默认初始化的形式,保存的是一个空指针。

shared_ptr<int> ptr1;

当然我们要为这个指针指向一块动态分配的内存,该用什么样的方式来分配内存呢?调用一个名为make_shared的标准库函数。接下来我们来介绍对于指针的内存申请和初始化。

	//动态分配一个新对象,初始化值为39
	shared_ptr<int> ptr1 = make_shared<int>(39);
	//	//动态分配一个新对象,初始化值为0
	shared_ptr<int> ptr2 = make_shared<int>();
	//动态分配一个新对象,初始化值为39
	shared_ptr<int> ptr3(new int(39));

在以上的代码中我们看到了三个申请内存和初始化方式,也是我们最常用的三种,可以使用make_shared申请内存并初始化对象,也可以使用new来直接初始化,注意new直接初始化时候的写法,如果写成下面这样,是错误的:

	//不允许隐式转换  从int*到shared_ptr
	shared_ptr<int> ptr3 = new int(39);

有关shared_ptr最重要的可以说是引用计数的功能了,这也是为什么命名为shared,因为它可以实现多个指针指向同一个对象。当同类型的shared_ptr发生拷贝或者赋值操作的时候,会将原指针中的引用计数增加,当一个shared_ptr中引用计数变为0的时候,该对象会被释放。

	//动态分配一个新对象,初始化值为39  引用计数为1,因为只有当前使用
	shared_ptr<int> ptr1 = make_shared<int>(39);
	long usecount = ptr1.use_count();//计算当前有多少指针在共享对象
	cout << usecount << endl;

	//动态分配一个新对象,初始化值为1  引用计数为1
	shared_ptr<int> ptr2 = make_shared<int>(1);

	//ptr2指向ptr1指向的对象,ptr1中的对象引用计数+1
	//ptr2原本指向的对象引用计数-1,变为0,所以被销毁,内存释放
	ptr2 = ptr1;
	usecount = ptr1.use_count();//计算当前有多少指针在共享对象
	cout << usecount << endl;

	//ptr3指向ptr1指向的对象,引用计数+1
	shared_ptr<int> ptr3(ptr1);
	usecount = ptr1.use_count();//计算当前有多少指针在共享对象
	cout << usecount << endl;


上图是申请内存并且初始化ptr1之后,引用计数为1。

ptr2被修改为指向ptr1的指针之后,引用计数增加为2。关于ptr3由于文章篇幅限制,就不截图了,拷贝复制之后,就会将引用计数增加为3。

说一下引用计数吧,引用计数就是用来控制是否该释放内存而设计的,如果我们的普通指针,发生赋值操作之后,如果把源指针进行delete之后,现在指针依然指向那个地址空间,只不过是已经释放的地址空间,这时候如果进行操作,就会发生野指针现象,执行结果随机性很大,所以有了引用计数,就可以知道这个对象什么时候需要被销毁,销毁的时候,那一定是没有任何的指针在引用它了。这个引用计数的概念,在windows内核对象中也被广泛使用,原理是相通的。

1.2 使用注意事项

普通指针不能直接隐式转化为shared_ptr:

	//不允许隐式转换  从int*到shared_ptr
	shared_ptr<int> ptr3 = new int(39);

如果我们将一个普通指针绑定到shared_ptr身上,那就最好不要再使用普通指针了,而是使用智能指针,因为它的内存在智能指针销毁时候被释放,就使用起来很危险。我们举例:


	//注意事项  内存泄露
	int *ptr5 = new int(22);
	cout << *ptr5 << endl;
	//绑定普通指针,内存管理交给shared_ptr,不能再使用普通指针了
	shared_ptr<int> ptr6(ptr5);
	
	//此时ptr6指向了ptr1的对象,那么原本ptr6指向的对象没有shared_ptr在引用了
	//所以原本的内存就会被销毁,如果我们再使用,可能就很危险
	ptr6 = ptr1;
	
	//我们试着去打印下这块被shared_ptr销毁的内存ptr5中看看是什么
	cout << *ptr5 << endl;

来看一下两次打印ptr5里面的内容有何区别:

和我们预期一样,ptr5中的内存被释放,所以不能直接访问了,因为它的生存已经交给了shared_ptr,ptr5将无法知道自己指向的内存何时被销毁。
不要使用相同的普通指针来初始化多个智能指针,因为可能会造成多次释放内存,导致堆破坏,内存奔溃:


	//注意事项  内存奔溃
	int *ptr5 = new int(22);

	//多个智能指针绑定同一个普通指针
	shared_ptr<int> ptr6(ptr5);
	shared_ptr<int> ptr7(ptr5);
	//此时ptr6指向了ptr1的对象,那么原本ptr6指向的对象没有shared_ptr在引用了
	//所以原本的内存就会被销毁,如果我们再使用,可能就很危险
	ptr6 = ptr1;

	//此时ptr7指向了ptr1的对象,那么原本ptr7指向的对象没有shared_ptr在引用了
	//所以原本的内存 再次被销毁  导致内存奔溃
	ptr7 = ptr1;
	

2. weak_ptr

weak_ptr是一种弱引用的智能指针,它指向shared_ptr管理的对象,并且不会改变这个对象的引用计数,所以不管weak_ptr存在与否,只要shared_ptr引用变为0,对象内存还是会被释放。

2.1 使用举例

在下面的这段代码中,我们创建了两个weak_ptr指针,当我们创建这样的指针时候,需要使用shared_ptr类型的指针来初始化它,并且这个过程不会增加shared_ptr的引用计数。但是,会增加Weaks变量的计数,我们在下面的运行图中可以清楚地看到:

	//动态分配一个新对象,初始化值为39  引用计数为1,因为只有当前使用
	shared_ptr<int> ptr1 = make_shared<int>(39);
	//long usecount = ptr1.use_count();//计算当前有多少指针在共享对象
	
	//不会改变ptr1的引用计数
	weak_ptr<int> wptr = ptr1;//不改变引用计数
	weak_ptr<int> wptr2(ptr1);//不改变引用计数


对于weak_ptr来说,它不会增加引用计数,那么它也就不能控制shared_ptr的销毁,所以我们不可以直接对这个指针进行解引用操作:

	//不可以对weak_ptr解引用
	cout << *wptr << endl;//错误
	cout << *wptr2 << endl;//错误

那我们该怎么来访问一个weak_ptr指针中的对象呢??用一个接口函数来返回一个shared_ptr类型的指针:

	//动态分配一个新对象,初始化值为39  引用计数为1,因为只有当前使用
	shared_ptr<int> ptr1 = make_shared<int>(39);
	//long usecount = ptr1.use_count();//计算当前有多少指针在共享对象
	
	//不会改变ptr1的引用计数
	weak_ptr<int> wptr = ptr1;//不改变引用计数
	weak_ptr<int> wptr2(ptr1);//不改变引用计数

	shared_ptr<int> ptr2 = wptr.lock();
	if (ptr2)
	{
		//如果不为空,则访问weak_ptr指向的对象
		//否则,说明对象已经被销毁,不存在了
		cout << *ptr2 << endl;
	}

此时,shared_ptr指向的对象并没有被销毁,所以可以打印出我们想要的结果:

接下来我们将shared_ptr原本指向的对象引用计数减少为0:

	//动态分配一个新对象,初始化值为39  引用计数为1,因为只有当前使用
	shared_ptr<int> ptr1 = make_shared<int>(39);
	//long usecount = ptr1.use_count();//计算当前有多少指针在共享对象
	
	//不会改变ptr1的引用计数
	weak_ptr<int> wptr = ptr1;//不改变引用计数
	weak_ptr<int> wptr2(ptr1);//不改变引用计数

	//我让ptr1重新指向一个对象  也就是让weak_ptr中指向的对象引用计数变为0 被销毁
	ptr1 = make_shared<int>(8);

	shared_ptr<int> ptr2 = wptr.lock();
	if (ptr2)
	{
		//如果不为空,则访问weak_ptr指向的对象
		//否则,说明对象已经被销毁,不存在了
		cout << *ptr2 << endl;
	}

如果我们将一个weak_ptr指向的对象内存释放之后,那么使用lock函数就会返回empty,也就无法执行 if 语句中的内容,看一下运行结果:

什么也没有打印出来,因为没有进入 if 语句中去。说明此时原对象已经被销毁。

2.2 使用注意事项

接下来要说的这个,是我们在面试时候会经常被问到的,有关shared_ptr和weak_ptr的问题,那就是shared_ptr一定是安全的吗?回答:不是的,因为shared_ptr循环引用,会造成类的析构无法执行,造成内存泄露!那么如何避免这种问题呢?就是用weak_ptr和shared_ptr搭配使用,看一下错误示范:

class MyClass;//类的前置声明  为了在YourClass中识别

class YourClass
{
public:
	YourClass();
	~YourClass();
	shared_ptr<MyClass> ptr;//用来指向MyClass的对象
private:

};

YourClass::YourClass()
{
	cout << "Here is yourClass constructor" << endl;
}

YourClass::~YourClass()
{
	cout << "Here is yourClass destructor" << endl;
}


class MyClass
{
public:
	MyClass();
	~MyClass();
	shared_ptr<YourClass> ptr;//用来指向YourClass的对象
private:

};

MyClass::MyClass()
{
	cout << "Here is MyClass constructor" << endl;
}

MyClass::~MyClass()
{
	cout << "Here is MyClass destructor" << endl;
}
void Sub_1()
{
	//pa和pb在这里是两个局部变量,应该在sub_1函数中new申请内存执行构造
	shared_ptr<MyClass> pa(new MyClass());
	shared_ptr<YourClass> pb(new YourClass());
	pa->ptr = pb;//shared_ptr赋值
	pb->ptr = pa;//shared_ptr赋值
	//函数退出,执行析构  是我们希望的结果
}

int main()
{
	Sub_1();
	将动态分配的内存从 C++ 返回到 C

C++ 一篇文章让你知道智能指针的魅力

C++从入门到入土第六篇:C/C++内存管理

《c++从0到99》五 C&C++内存管理

《c++从0到99》五 C&C++内存管理

《c++从0到99》五 C&C++内存管理