新博客地址: https://voltwu.github.io/blog/

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

目录结构:

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

 



posted @ 2019-06-17 22:24  HDWK  阅读(352)  评论(0编辑  收藏