【C++】C++中的动态内存解析
目录结构:
静态内存用来保存局部static对象、类static数据成员以及定义在任何函数之外的变量。栈内存用来保存定义在函数内的非static对象。分配在静态和栈内存中的对象由编译器自动创建和销毁。对于栈对象,仅在其定义的程序块运行时才存在;static对象在使用之前分配。在程序结束时销毁。
除了静态内存和栈内存,每个程序还拥有一个内存池。这部分内存被称为自由空间(free store)或堆(heap)。程序用堆来存储动态分配(dynamically allocate)的对象,也就是那些在程序运行时分配的对象。动态内存的生存期由程序来控制,也就是说,当动态内存不再使用时,我们的代码必须显式地销毁它们。
1 动态内存和智能指针
在C++中,动态内存的管理是通过一对运算符来完成的:new和delete。
new:在动态内存中为对象分配空间并返回一个指向该对象的指针,我们可以选择对对象进行初始化。
delete:接受一个动态对象的指针,销毁该对象,并释放与之有关的内存。
动态内存的使用非常容器出现问题,因为确保在正确的时间释放内存是极其困难的。为了更好的管理动态内存,C++标准库在<memory>模块中提供了大量的智能指针类型,这里笔者就介绍几种较常见的:shared_ptr允许多个指针指向同一个对象,unqiue_ptr则“独占”所指向的对象。标准库还定义一个名为weak_ptr的伴随类,它是一种弱引用,指向shared_ptr所管理的对象。
1.1 使用shared_ptr管理内存
shared_ptr是一个智能指针类,它可以和其他的shared_ptr共享同一个动态内存的所有权。出现如下两种情况的话,动态内存会被自动释放:
a.最后一个保留动态内存的shared_ptr对象被销毁。
b.最后一个保留动态内存的shared_ptr对象重新保存另一个动态内存(通过=或reset())
我们可以这样认为,每个shared_ptr都有一个关联的计数器,通常称为引用计数(reference count)。无论何时我们拷贝一个shared_ptr,计数器都会增加。例如,当用一个shared_ptr初始化另一个shared_ptr,或将它作为参数传递给一个函数以及作为函数的返回值时,它所关联的计数器就会递增。我我们给shared_ptr赋予一个新值或是shared_ptr被销毁(一个局部的shared_ptr离开作用域)时,计数器就会递减。
一旦一个shared_ptr的计数器变为0,它就会自动释放自己所管理的对象。
在使用shared_ptr时,我们无需关心内存的释放问题。程序会自动帮助我们在合适的时机释放内存。因此推荐在程序中使用shared_ptr来管理动态内存。
创建shared_ptr对象既可以通过它的构造方法,也可以通过make_shared方法。
#include <iostream> /*cout*/ #include <memory> /*shared_ptr,make_shared*/ #include <string> /*string*/ using namespace std; int main(int argc,char* argv[]){ shared_ptr<string> sp1 = make_shared<string>("hello");//通过make_shared创建 // {} 块代码 { shared_ptr<string> sp2; //shared_ptr的默认构造 sp2 = sp1;//将sp1复制给sp2,sp1和sp2指向相同的动态内存 }//退出块,sp2对象被销毁。sp2指向的动态内存不会被销毁(因为指向该内存的还有sp1,所以程序不会自动该动态内存) //现在只有sp1对象指向该动态内存了 cout << *sp1 << endl;//打印sp1中的动态管理的值 return 0;//退出方法,离开sp1对象作用域,sp1对象被销毁。由于sp1对象是最后一个指向动态内存的shared_ptr对象,所以该动态内存被释放。 }
shared_ptr(以及其他的智能指针)除了可以管理new分配的资源,也可以管理不是new分配的资源,这时候记得传递给它一个删除器(因为默认的删除器,是针对new分配资源的删除器,也就是调用delete),例如下面一个网络库代码:
struct destination; //表示连接的目标信息 struct connection; //使用连接所需信息 connection connect(destination*); //打开连接 void disconnect(connection); //关闭给定连接 void end_connection(connection *p){ diconnect(*p); } void f(destination &d){ //未使用shared_ptr /* //获得一个连接;记住使用完后要关闭它 connection c = connect(&d); //使用连接 //如果我们在f退出之前,忘记调用disconnect,就无法关闭c了。 */ //使用shared_ptr connection c = connect(&d); shared_ptr<connection> p(&c,end_connection);//一定要传入自定义的删除器,也可以用lambda表达式 //使用连接 //当f退出时(即使是由于异常而退出),connection会被正确关闭 }
1.2 使用new直接管理内存
C++语言定义两个运算符来分配和释放动态内存。运算符new分配内存,delete释放new分配的内存。
在堆中分配的内存是无名的,因此new无法为其分配的对象命令,而是返回一个指向该对象的指针:
int *pi = new int;//pi指向一个动态的、未初始化的无名对象
我们可以采用直接构造的方式来初始化一个动态分配对象,我们可以使用传统的的构造方式(使用圆括号),我们还可以使用列表初始化的方法(花括号):
int *p = new int; //p指向一个未初始化的int int *p2 = new int(); //p2指向的对象的值为0 int *pi = new int(1024); //pi指向的对象的值为1024 string *ps = new string(10,"9"); //*ps为999999999 //vector有10个元素,依次从0到9 vector<int> *pv = new vector<int>{0,1,2,3,4,5,6,7,8,9}
如果我们提供了一个括号包围的初始化器,就可以使用auto从此初始化器来推断我们想要分配的对象的类型。但是,由于编译器要使用初始化器的类型来推断我们想要的创建的类型,只有当括号中有单一初始化器时才可用auto
auto p1 = new auto(obj); //p指向一个与obj相同类型的对象 auto p2 = new auto{a,b,c}; //错误,括号中只能有单一初始化器
p1的类型是指针,指向从obj推断出来的类型。若obj是int,那么p1就是int*类型,若obj是string,那么p1就是string*类型。
动态分配的const对象
用new分配const对象是合法的:
//分配并初始化一个const int const int* pci = new const int(1024); //分配并默认初始化一个const的空string const string* pcs = new const string();
和其它const一样,一个动态的const对象必需要初始化。对于一个定义了默认构造函数的类类型,其const对象可以隐式初始化,而其他的类型必须显示初始化。由于分配的对象是const的,new返回的指针是一个指向const的指针。
内存耗尽
虽然现代计算机通常都具备大容量内存,但是自由空间被耗尽的情况还是有可能发生。一旦一个程序用光了它所有的内存,new表达式就会失败。默认情况下,如果new不能分配所要求的空间,它就会抛出一个类型为bad_alloc的异常。我们可以改变new的使用方式来阻止它:
//如果分配失败,new返回一个空指针 int *p1 = new int; //如果分配失败,抛出std::bad_alloc的异常。 int *p2 = new (nothrow) int; //如果分配失败,new返回一个空指针
释放动态内存
为了防止内存耗尽,在动态内存使用完毕后,必须将其归还给系统。我们通过delete表达式(delete expression)来将动态内存归还给系统。delete表达式接受一个指针,指向我们想要释放的对象:
delete p;//p必须指向一个动态内存分配的对象或是一个空指针
与new类型类似,delete表达式也执行两个动作:销毁给定的指针指向的对象;释放对应的内存
悬空指针
当我们delete一个指针后,指针值就变为无效了。虽然地址以及无效,但在很多机器上仍然保存着(以及释放的)动态内存的地址。在delete之后,指针就变成人们所说的悬空指针(dangling pointer),即,指向一块曾经保存数据对象但现在已经无效的内存指针。
我们可以在指针即将离开其作用域之前释放掉它所关联的内存。这样关联指针的内存被释放后,就没机会继续使用指针了。我们可以将nullptr赋予指针,就清楚的指出指针不再指向任何对象。
下面使用一个new和delete的完整案例:
using namespace std; int main(int argc,char *argv[]){ int *p(new int(42)); //指向动态内存 auto q = p; //q和p指向相同的内存 //在程序退出之前,一定要delete delete p; //p和q均无效 p = nullptr; //指出p不再绑定到任何对象 return 0; }
1.3 shared_ptr和new结合使用
我们可以用new返回的指针来初始化智能指针
shared_ptr<double> p1; //shared_ptr可以指向一个double shared_ptr<double> p2(new int(42)); //p2指向一个值为42的int
默认情况下,一个用来初始化智能指针的普通指针必须指向动态内存,因为智能指针默认使用delete释放它所关联的对象。我们可以将智能指针绑定到一个指向其他类型的资源的指针上,但是为了这么做,必须提供自己的操作来代替delete。
shared_ptr类和new之间提供很多的相互转化操作,比如shared_ptr的构造函数接受一个new的动态指针。shared_ptr的get()方法返回它所管理的动态指针。虽然shared_ptr提供了丰富的相互转化操作,但是笔者建议不要混合使用普通指针和智能指针,混合使用将会使动态内存的释放问题更加复杂。
例如下面这个程序,在不经意间就会造成指向已经释放内存的错误:
shared_ptr<int> p(new int(42)); //引用计数为1 int *q = p.get(); //正确:但使用q要注意,不要让它管理的指针被释放 { //新的块 shared_ptr<int>(q); //两个独立的shared_ptr指向相同的内存 }//程序块结束,q被销毁,它指向的内存被释放 int foo = *p; //未定义:p指向的内存已经被释放了
p和q指向相同的内存。由于它们是相互独立创建的,因此各自的引用计数都是1。当q所在的程序块结束时,q被销毁,这会导致q指向的内存被释放。从而p变成一个悬空指针,意味着当我们试图使用p时,将发生未定义的行为。而且,当p被销毁时,这块内存会被第二次delete。
get用来将指针的访问权限传递给代码,你只有在确定代码不会delete指针的情况下,才能使用get。特别是,永远不要使用get初始化另一个智能指针或者为另一个智能指针赋值。
#include <iostream> #include <string> #include <memory> using namespace std; int main(int argc,char* argv[]){ shared_ptr<int> p(new int(42)); shared_ptr<int> q(p); if(!p.unique()) p.reset(new string(*p)); //如果我们不是唯一的用户,分配新的拷贝 *p += "0";//我们知道自己是唯一的用户了,可以改变对象的值 return 0; }
1.4 unique_ptr
一个unique_ptr“拥有”它所指向的对象。与shared_ptr不同,某个时刻只能有一个unique_ptr指向一个给定对象。当unique_ptr被销毁时,它所指向的对象也被销毁。
与shared_ptr不同,没有类似的make_shared的标准库函数返回一个unique_ptr。当我们定义一个unique_ptr时,需要将其绑定一个new返回的指针上。类似shared_ptr,初始化unique_ptr必须采用直接初始化。
接下来是一个使用案例:
#include <string> #include <memory> using namespace std; int main(int argc,char *argv[]){ unique_ptr<string> p1(new string("hello")); //将所有权从p1(指向string hello)转移给p2 unique_ptr<string> p2(p1.release());//release将p1置为空 unique_ptr<string> p3(new string("world")); //将所有权从p3转义给p2 p2.reset(p3.release());//reset释放了p2原来指向的内存 return 0; }
1.5 weak_ptr
weak_ptr是一种不控制所指向对象生存期的智能指针,它指向由一个shared_ptr管理的对象。将一个weak_ptr绑定到一个shared_ptr不会改变shared_ptr的引用计数。一旦最后一个指向对象的shared_ptr被销毁,对象就会被释放。即使有weak_ptr指向对象,对象也还是会被释放,因此,weak_ptr的名字抓住这种智能指针"弱"共享对象的特点。
当我们创建一个weak_ptr时,要用一个shared_ptr来初始化它:
shared_ptr<int> p = make_shared<int>(42); weak_ptr<int> wp(p); //wp弱共享p;p的引用计数未改变
wp和p指向相同的对象。由于是弱共享,创建wp不会改变p的引用计数;wp指向的对象可能会被释放掉。
由于对象可能不存在,我们不能在weak_ptr上直接访问对象,而是必需要调用lock。
例如:
if(shared_ptr<int> np = wp.lock()){//如果np不为空,则条件成立 //np与p共享 }
标准库还提供了weak_ptr大量的相关操作函数,读者可以自行翻阅。
1.6 程序异常情况下的资源释放处理
在我们的程序中,当一个程序发生异常时要令发生异常后的程序流继续。我们注意到,这种程序需要确保在异常发生后资源能够被正确的释放。一个简单的确保资源被释放的方法是使用智能指针。
如果使用智能指针,即使程序块过早结束,智能指针类也能确保在不存不再需要时将其释放掉:
void f(){ shared_ptr<int> sp(new int(42)); //分配一个新对象 //这段代码抛出一个异常,且在f中未被捕获 }//在函数结束时,shared_ptr自动释放内存
函数的退出有两种情况,正常处理结束或发生了异常,无论哪种情况,局部对象都会被销毁。在上面的程序中,sp是一个shared_ptr,因此sp销毁时检查引用计数。在此例中,sp是指向这块内存的唯一指针,因此内存会被释放掉。
void f(){ int *ip = new int(42); //动态分配一个新对象 //这段代码抛出一个异常,且在f中未捕获 delete ip; //在退出之前释放内存 }
如果在new和delete之间发生异常,且未在f中捕获异常,则内存就永远不会被释放了。
1.7 使用智能指针的陷阱
在使用智能指针时,我们必需坚持一些基本规范:
1.不使用相同的内置指针初始化多个智能指针。
2.不delete get()返回的指针。
3.不使用get()初始化或reset另一个智能指针。
4.如果你使用get返回的指针,记住当最后一个对于的智能指针销毁后,你的指针就变成无效了。
5.如果你使用智能指针管理的资源不是new分配的内存,记住传递给它一个删除器。
2 动态数组
new和delete运算符一次分配/释放一个对象,但某些应用需要一次为很多对象分配内存的功能。例如,vector和string都是连续在内存中保存它们的元素。
c++语句定义了另外一种new表达式语法,可以分配并初始化一个对象数组。标准库中包含一个名为allocator类,允许我们将分配和初始化分离。
2.1 new管理动态数组内存
为了让new分配一个对象数组,我们要在类型名之后跟一对方括号,在其中指明要分配对象的数目。例如:
//调用get_size确定分配多少个int int *pia = new int[getsize()]; //pia指向第一个int
方括号的大小必须是整数,不必是常量。
也可以使用数组的类型别名:
typedef int arrT[42]; //arrT表示42个int的数组类型 int *p = new arrT; //分配一个42个int的数组;p指向第一个int
我们通常称new T[]分配的内存为“动态数组”,但这种叫法某种程度上有些误导。当用new分配一个数组时,我们并未得到一个数组类型的对象,而是得到一个数组类型的指针。
释放动态数组
为了释放动态数组,我们使用一种特殊的形式的delete-在指针前加上一个空括号对:
delete p; //p必须指向一个动态分配的对象或为空 delete []pa; //pa必须指向一个动态分配的数组或为空
当我们释放一个指向数组的指针时,空方括号是必须的:它指示编译器此指针指向一个对象数组的第一个元素。如果我们在delete一个指向数组的指针时忽略了方括号(或者在delete一个指向单一对象的指针时使用了方括号),其行为都是未定义的。
typedef int arrT[42]; //arrT是42个int的数组的类型别名 int *p = new arrT; //分配一个42个int的数组;p指向第一个元素 delete[] p; //方括号是必须的,因为我们分配是的是一个数组
在最后说一个shared_ptr对动态数组的操作,shared_ptr不支持直接管理动态数组,要使用动态数组,必需自定义删除器:
#include <memory> using namespace std; int main(int argc,char* argv[]){ //提供一个删除器,默认的删除器是delete T,我们这里是数组,也就应该是delete[] T,所以应该提供delete[]格式的删除器 shared_ptr<int> sp(new int[10],[](int *p){delete []p}): //shared_ptr未定义下标运算符,并且不支持指针的算术运算 for(size_t i = 0; i != 10; i++) { *(sp.get() + i) = i; //get()获取一个内置指针 } sp.reset(); //使用我们的lambda释放数组,它使用delete[] return 0; }
2.2 allocator管理动态数组内存
new有一些灵活性上的局限,其中一方面表现在它将内存分配和对象构造组合在了一起。类似的,delete将对象析构和内存释放组合在一起。
标准库的allocator类帮助我们将内存分配和对象构造分离开来。它提供一个类型感知的内存分配方法,他分配的内存是原始的、未构造的。
#include <memory> #include <iostream> #include <string> using namespace std; int main() { int n = 3; allocator<string> alloc; string* const p = alloc.allocate(n); //为了使用allocate分配的内存,必须使用construct来构造对象。 string* q = p; alloc.construct(q++); //*q为空字符串 alloc.construct(q++,5,'c'); //*q为ccccc alloc.construct(q++,"hi"); //*q为hi cout << *p << endl; //正确 cout << *q << endl; //灾难:指向未构造的内存 while(q != p){ alloc.destroy(--q); //释放我们构造的string } //一旦元素被释放后,我们就可以使用这部分内存来保存其它string, //也可以将其归还给给系统 //归还内存给系统 alloc.deallocate(p,n); }