动态内存与智能指针


c/c++语言的一大特色是在于可以动态的进行内存管理,而这也是它的难点所在。程序出现问题,原因经常在动态内存管理这块,比如分配内存后没有及时释放,或者当前线程提前释放了其他线程也会使用的内存。而c++11中新增的智能指针能在一定程度上解决这些问题

动态内存与智能指针

在c++中动态内存的管理是通过一对运算符来完成的: new和delete ,new为对象分配空间并返回一个指向该对象的指针。delete 接受一个动态对象的指针,销毁对象并释放相关内存

动态内存的管理十分困难,有时候会忘记释放内存,这种情况下会产生内存泄漏。有时在尚有指针引用内存的情况下我们就释放了它,在这种情况下就会产生引用非法内存的指针。

为了更容易也更安全的使用动态内存,新的标准提供了两种智能指针类型来管理动态对象。

shared_ptr 类

类似于vector 智能指针也是模板。创建智能指针时,必须提供额外的信息——指针可以指向的类型。

智能指针的用法与普通指针类似。解引用一个智能指针返回它指向的对象,箭头运算符可以返回对象中的成员

shared_ptr p = new string;
if(nullptr != p && p->empty())
{
    *p = "hello world"; //字符串为空的时候,将一个新值赋予string
}

最安全的分配和使用动态内存的方法是调用一个名为 make_shared 的标准库函数。此函数在动态内存中分配一个对象并初始化它,返回此对象的 shared_ptr。

shared_ptr p3 = make_shared(42); //初始化p3 指向一个值为42的int类型
shared_ptr p4 = make_shared(10, '9'); //p4指向一个值为 "9999999999" 的string
shared_ptr p5 = make_shared(); //指向一个值初始化的int,即,值为0

当shared_ptr 进行拷贝和赋值操作时,每个shared_ptr 都会记录有多少个其他的shared_ptr 指向相同的对象

auto p = make_shared(42); //此时p指向的对象只有一个引用值
auto q = p; //此时p指向的对象有两个引用者

我们可以认为每一个shared_ptr 都有一个关联的计数器,通常称其为引用计数。无论何时我们拷贝一个shared_ptr,计数器都会递增。当我们给shared_ptr 赋一个新值或者shared_ptr 被销毁时,他所关联的计数器就会递减。当指向一个对象的最后一个
shared_ptr 被销毁时,shared_ptr 类就会自动销毁此对象。

shared_ptr 并不是万能的,也会出现内存泄漏的问题。这种情况一般出现在容器中。如果在容器中存放了shared_ptr ,然后重新排列了容器,从而不需要某些元素。在这种情况下应该确保使用earse删除某些不再需要的shared_ptr 元素

直接管理内存

相对与智能指针直接使用new 和 delete很容器出错。

当内存耗尽时,new会失败,会抛出一个bad_alloc 异常。我们可以改变使用new的方式来阻止它抛出异常

int *p1 = new int; //如果分配失败则会抛出异常
int *p2 = new (nothrow) int; //如果分配失败,new返回一个空指针

我们称这种形式的new为定位new。定位new允许我们传递额外的参数给到new,在此例子中我们传递一个标准库中的nothrow 对象,告知它在内存不足的时候不要抛出异常。如果这种形式的new不能分配所需内存,它会返回一个空指针

动态对象的生命周期一直到它被手动释放为止。这样就给使用者造成了一个额外的负担,调用者必须记得释放内存。

使用new和delete 管理动态内存存在三个常见问题:

  1. 忘记delete内存。造成内存泄漏问题
  2. 使用已经释放掉的对象。通过在释放内存后将指针置为空,有时可以检出这种错误
  3. 同一块内存多次释放

坚持只使用智能指针就可以避免所有这些问题。对于一块内存只有在没有任何智能指针指向它的情况下,智能指针才会自动释放它

shared_ptr 和 new 结合使用

接受指针参数的智能指针构造函数是 explicit 的。因此,我们不能将一个内置指针隐式转化为智能指针,必须使用直接初始化的方式

shared_ptr p1 = new int(1024); //错误,这里需要将int* 隐式转化为shared_ptr 类型
shared_ptr p2(new int(1024)); //正确

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

不要混合使用普通指针和智能指针。

void process(shared_ptr ptr)
{
    // 进入到函数中时,ptr 所在的引用计数加1
}
 //函数结束时, ptr 所在对象的引用计数减 1

shared_ptr p(new int(42)); //引用计数为1
process(p); //在函数内部,引用计数加1,变为2
//执行完成后,引用计数减1,变为1,此时对象不会被销毁

*p = 100; //可以进行赋值,此时对象还未被销毁

int* p1 = new int(42);
process(shared_ptr(p1)); //进入函数后,由于p1 自身不是智能指针,所以在函数结束之后,智能指针的计数为0,会销毁对应的对象
*p1 = 100; //错误,此时对象已被销毁

智能指针定义了一个get函数用来返回一个普通的指针,此函数是为了这样一种情况而设计的:我们需要像不能使用智能指针的代码传递一个内置指针,但这段代码中不能使用delete来销毁这个指针所指向的对象

我们不能将get返回的指针再绑定到另一个智能指针上。

智能指针和异常

当发生异常时,普通的指针如果在异常发生之后进行delete操作,那么资源回收操作可能会被中断,而智能指针不会

void f()
{
    shared_ptr sp(new int(24));
    //即使后面发生异常,sp指针在函数结束时计数变为0,对象被释放
}

void f()
{
    int* p = new int(24);
    //这里发生异常的话,后面的delete 不会被执行,可能发生内存泄漏
    delete p;
}

有些资源由于提供的是c函数级别的接口,因此需要手动进行释放,就会存在与动态内存一样的问题,忘记释放资源。这种情况我们也可以使用智能指针的技术来自动管理资源。

例如我们的socket程序,在最后需要调用shutdown 和 closesocket来关闭

void clear_socket(socket* sk)
{
    shutdown(*sk);
    closesocket(*sk);
}

socket s = socket(AF_INET, SOCK_STREAM, IPPROTO_IP);

shared_ptr ps(&s, clear_socket);

//链接服务器
//程序推出后会自动调用clear_socket 来释放socket资源

智能指针可以提供对动态分配的内存安全而有方便的管理,但是这建立在正确使用的前提下。为了方便的使用智能指针,我们必须坚持一些基本原则:

  1. 不使用相同的内置指针初始化多个智能指针
  2. 不delete get函数返回的指针
  3. 不使用get初始化或者reset另一个指针指针
  4. 如果使用get返回的指针,记住当最后一个对应的智能指针被销毁后,你的指针就变为无效了
  5. 如果使用智能指针管理的资源不是new分配的,记住传递给它一个删除器

unique_ptr

unique_ptr 拥有它所指向的对象。某一个时刻只能有一个 unique_ptr 指向一个给定的对象。当unique_ptr 被销毁时,它所指向的对象也会被销毁

unique_ptr 不支持拷贝操作,没有类似 make_shared 的操作。

unique_ptr p1(new string("hello world"));
unique_ptr p2(p1); //错误:unique_ptr 不支持拷贝
unique_ptr p3;
p3 = p1; //错误:unique_ptr 不支持赋值

虽然不能拷贝和赋值unique_ptr ,但是可以调用release或者reset将指针的所有权从一个(非const)unique_ptr 转移给另一个unique_ptr

reset 成员接受一个可选的指针参数,令unique_ptr 重新指向给定的指针。如果unique_ptr 不为空,它原来指向的对象被释放。release会切断unique_ptr 和它原来管理的对象间的联系。release返回的指针通常被用来初始化另一个智能指针或者给另一个智能指针赋值,如果我们不用另一个智能指针保存release返回的指针,则需要手工释放指针指向的资源

p2.release(); //错误,p2指向的资源不会正常释放
auto p = p2.release();
delete p;

不能拷贝unique_ptr 的规则又一个例外: 我们可以拷贝或者赋值一个将要被销毁的unique_ptr。最常见的例子是从函数返回一个unique_ptr

unique_ptr clone(int p){
    return unique_ptr(new int(p));
}

还可以返回一个局部对象的拷贝:

unique_ptr clone(int p)
{
    unique_ptr ret(unique_ptr(p));
    return ret;
}

类似于shared_ptr, unique_ptr 默认情况下使用delete 释放它指向的对象。我们也可以重载一个unique_ptr 中默认的删除器。但是unique_ptr 管理删除器的方式与shared_ptr 不同。重载一个unique_ptr 中删除器会影响到unique_ptr 类型以及如何构造该类型的对象。与

与重载关联容器的比较运算相似,我们必须在尖括号中unique_ptr 指向类型之后提供删除容器类型。在创建或者reset 一个这种unique_ptr 类型的对象时,必须提供一个指定类型的可调用对象

weak_ptr

weak_ptr 是一种不控制所指向对象生存期的智能指针,它指向由一个shared_ptr 管理的对象。将一个shared_ptr绑定到一个 weak_ptr。不会改变shared_ptr 的引用计数。一旦最后一个指向对象的shared_ptr 被销毁,对象就会被释放。即使有weak_ptr 指向对象,对象还是会被销毁

由于对象可能不存在,所以不能直接使用weak_ptr 来访问对象,需要先调用lock函数,此函数检查weak_ptr 指向的对象是否仍然存在。如果存在,lock返回一个指向共享对象的shared_ptr。只要此shared_ptr 存在,它所指向的对象也会一直存在.

if(shared_ptr np = wp.lock())
{
    //在if中np 和wp 共享对象
}

动态数组

new 和数组

标准库提供了一个可以管理new 分配的数组的unique_ptr 版本,为了用一个unique_ptr 管理动态数组,我们必须在对象类型后面跟一对方括号:

unique_ptr up(new int[10]);
up.release(); //自动用delete[] 销毁其指针

shared_ptr 不直接支持管理动态数组。如果希望使用shared_ptr 管理动态数组,必须提供自己定义的删除器:

shared_ptr sp(new int[10], [](int* p){delete[] p;});
sp.reset();

shared_ptr 未定义下标运算符,因此我们通过shared_ptr 访问动态数组时需要使用get获取到内置指针,然后用它来访问数组元素

**** allocator 类
当分配一块大内存时,我们通常计划在这块内存上按需求构造对象,这种情况下使用new分配时会立即执行对象的构造操作,会造成一定的开销

string *const p = new string[n]; //构造n个空白的string
delete[] p;

上述代码在new 的同时已经调用了n次string 的构造函数。但是我们可能不需要n个string对象,少量的即可满足。 这样我们就可能创建一些永远也用不到的对象。而且对于那些要使用的对象,我们也在初始化之后立即赋予了它们新值,每个被使用的元素被赋值了两次,第一次是在默认初始化的时候,第二次是在赋值时。

标准库中定义了allocator 类可以帮助我们将内存分配和对象构造分离开来。

allocator alloc;//可以分配string的allocator对象
auto const p = alloc.allocate(n); //分配n个未初始化的string

allocator 分配的内存是未构造的。我们按照需要在此内存中构造对象。
成员函数construct接受一个指向将要被构造的内存的指针,同时可以接受额外参数作为构造对象时的参数。

auto q = p; //q 指向下一个将要被构造的位置
alloc.construct(q++); //构造一个空字符串
alloc.construct(q++, 10, 'c'); //构造一个'cccccccccc'的字符串
alloc.construct(q++, "hi"); //构造一个 "hi" 字符串

当我们使用完对象后必须调用destroy 来销毁它们

while(q != p)
{
    alloc.destroy(--q);
}

这里要注意我们只能对真正构造了的元素进行destroy操作

destroy之后这些内存并没有完全交换给系统,还需要调用deallocate 来完成

alloc.deallocate();