【C++】C++中的动态内存解析

时间:2023-12-20 18:52:02

目录结构:

contents structure [-]

静态内存用来保存局部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 ;//退出方法,离开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(); //pi指向的对象的值为1024
string *ps = new string(,""); //*ps为999999999
//vector有10个元素,依次从0到9
vector<int> *pv = new vector<int>{,,,,,,,,,}

如果我们提供了一个括号包围的初始化器,就可以使用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();
//分配并默认初始化一个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()); //指向动态内存
auto q = p; //q和p指向相同的内存
//在程序退出之前,一定要delete
delete p; //p和q均无效
p = nullptr; //指出p不再绑定到任何对象
return ;
}

1.3 shared_ptr和new结合使用

我们可以用new返回的指针来初始化智能指针

shared_ptr<double> p1; //shared_ptr可以指向一个double
shared_ptr<double> p2(new int()); //p2指向一个值为42的int

默认情况下,一个用来初始化智能指针的普通指针必须指向动态内存,因为智能指针默认使用delete释放它所关联的对象。我们可以将智能指针绑定到一个指向其他类型的资源的指针上,但是为了这么做,必须提供自己的操作来代替delete。

shared_ptr类和new之间提供很多的相互转化操作,比如shared_ptr的构造函数接受一个new的动态指针。shared_ptr的get()方法返回它所管理的动态指针。虽然shared_ptr提供了丰富的相互转化操作,但是笔者建议不要混合使用普通指针和智能指针,混合使用将会使动态内存的释放问题更加复杂。

例如下面这个程序,在不经意间就会造成指向已经释放内存的错误:

shared_ptr<int> p(new int());   //引用计数为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());
shared_ptr<int> q(p);
if(!p.unique())
p.reset(new string(*p)); //如果我们不是唯一的用户,分配新的拷贝
*p += "";//我们知道自己是唯一的用户了,可以改变对象的值
return ;
}

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

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>();
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()); //分配一个新对象
//这段代码抛出一个异常,且在f中未被捕获
}//在函数结束时,shared_ptr自动释放内存

函数的退出有两种情况,正常处理结束或发生了异常,无论哪种情况,局部对象都会被销毁。在上面的程序中,sp是一个shared_ptr,因此sp销毁时检查引用计数。在此例中,sp是指向这块内存的唯一指针,因此内存会被释放掉。

void f(){
int *ip = new int(); //动态分配一个新对象
//这段代码抛出一个异常,且在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[];  //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[]; //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[],[](int *p){delete []p}):
//shared_ptr未定义下标运算符,并且不支持指针的算术运算
for(size_t i = ; i != ; i++) {
*(sp.get() + i) = i; //get()获取一个内置指针
}
sp.reset(); //使用我们的lambda释放数组,它使用delete[]
return ;
}

2.2 allocator管理动态数组内存

new有一些灵活性上的局限,其中一方面表现在它将内存分配和对象构造组合在了一起。类似的,delete将对象析构和内存释放组合在一起。

标准库的allocator类帮助我们将内存分配和对象构造分离开来。它提供一个类型感知的内存分配方法,他分配的内存是原始的、未构造的。

#include <memory>
#include <iostream>
#include <string> using namespace std; int main()
{
int n = ;
allocator<string> alloc;
string* const p = alloc.allocate(n); //为了使用allocate分配的内存,必须使用construct来构造对象。 string* q = p;
alloc.construct(q++); //*q为空字符串
alloc.construct(q++,,'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);
}