动态内存

引入

关于动态内存,第一个疑问是:为什么我们要使用动态内存?它有什么优点?

要了解动态内存,得先了解一个进程在内存中的基本空间结构。进程是被执行起来的程序,程序是未执行的二进制文件。

大概来说,程序包含了三大部分内容:一是程序代码,称为text段,二是程序中定义的初始化了的全局和静态变量(非0初始值),称为data段,三是未初始化的全局和静态变量(或者初始值为0),称为bss段。

进程是执行起来的程序,自然包含上面的三部分内容,另外还会额外增加进两部分内容。一个是堆,另一个是栈。栈用来存储我们在程序中用到的局部变量,而堆则是用于本章所学习的动态内存存储。

栈的效率高,速度快,局部访问,自动管理,空间大小有限。

堆是一个内存池,它也被称为自由存储,堆的空间可以很大,也能全局访问共享,手动管理。

有了这些基本知识,再回到开头的第一个问题:为什么使用动态内存及其优点。因为动态内存是存在堆中的,所以其优点是空间大小可以很大。除了这个优点之外,更有几个不使用动态内存则无法解决的问题,这也是为什么要使用动态内存的原因:

  • 我们不知道程序到底会需要多少空间或者说多少个对象。比如说,程序经常需要处理一批数据,有多少个数据不确定,为了存储这些数据,我们只能根据当时的情况来申请内存空间来进行存储和处理。e.g. container
  • 为了实现面向对象的动态绑定功能。
  • 不知道所使用对象的确切类型。
  • 程序的多个对象之间进行数据的共享,或者说全局访问共享。典型的例子是接下来即将学习的智能指针。

简而言之,动态内存的关键点在于“动”字上。

动态内存

对于手动通过堆分配的内存来说,我们必须手动管理内存的释放,这是相当有难度的,尤其在C++中那些可能出现异常的地方。

手动分配内存会存在四个问题:

  • 内存泄露:指向该内存的指针因为超出生存期被回收了,但是该内存还没有被回收(程序员忘记使用delete释放掉,而且判断什么时候使用delete释放内存是非常困难的);
  • 使用new申请的内存通过delete释放掉了,但是程序中依旧有指针指向该内存,导致run-time error;
  • 同一个内存空间被释放多次。这样的行为是未定义的;
  • delete动态分配的内存之后,对应的指针会变成悬空指针(dangling pointer),重置指针(nullptr)是一个好的解决方案,但是找到所有指向该内存空间的指针却是一个麻烦的事情。

由于正确的管理内存很棘手,因此标准库提供了两个“智能指针”来帮助我们动态使用内存。一种是“共享指针”,它允许多个指针指向一个对象,而在没有指针指向对象时自动释放内存;另一种是“独占指针”,某一时刻,它只允许一个指针指向对象。标准库还有一个tiny工具weak_ptr,是一个辅助工具,用于指向“共享指针”指向的对象,但不会引起“共享指针”计数增加。

 

头文件

#include <memory>

提供工具

shared_ptr;
unique_ptr;
weak_ptr;
make_shared;

shared_ptrvector一样,是一个模板,在提供了足够信息后,用于生成特定类型的智能指针。用法如下:

shared_ptr<int>         *pi;    //int类型指针
shared_ptr<string>      *ps;    //string类型指针
shared_ptr<vector<int>> *pv;    //vector<int>容器指针

以上3个指针都是默认初始化的,在智能指针中,默认初始化的指针将会被初始化为nullptr

智能指针的使用方式与普通指针相同,解引用智能指针返回所指对象,在条件判断中使用智能指针就是检测其是否为空。例如:

if (pv && pv->empty())
{
    pv.push_back(5);
}

shared_ptr

shared_ptr的介绍与初始化

shared_ptr增加引用计数的情况

  • 初始化另外一个shared_ptrshared_ptr sp1(sp);
  • 作为赋值语句的右操作数
  • 以值传递给一个函数或者以值在一个函数中返回

shared_ptr递减引用计数的情况

  • 作为赋值语句的左操作数
  • 智能指针被回收,例如超出作用域

初始化方式为

shared_ptr<T> sp; NULL智能指针

unique_ptr<T> up; NULL智能指针

shared_ptr<T> sp(sp1); 值初始化智能指针

unique_ptr<T> up(up1); 值初始化NULL智能指针

使用make_shared初始化shared_ptr

最安全的分配和使用动态内存的方法是使用make_shared标准库函数,该函数能够代替我们去动态分配内存,并且将对象进行初始化,之后返回一个智能指针给我们使用。同样的,make_shared也是一个模板,虽然它是一个函数模板,但是却不能根据我们传入的参数进行类型推断。原因是该函数的模板参数存在于返回类型之中,导致无法直接推断,因此我们必须手动指定参数类型。其使用方式如下:

shared_ptr<string> ps = make_shared<string>();
auto ps2 = make_shared<string>(10, 'c');

对于智能指针对象psps2的初始化,我们传给make_shared的参数将会用于初始化psps2所指内存中对象的初始化。如果我们没有传递参数给make_shared,那么psps2依然会被初始化,但是是值初始化(上一节将初始化时,默认是nullptr,另外值初始化是对指针而言的),也即psps2指向了各自的内存(内存中的对象没有初始化,这是对所指对象而言),而不是默认初始化的nullptr;总而言之,make_shared一定会返回一个指向一块内存的智能指针,而不会返回nullptr

另外,由于make_shared不会返回nullptr,所以我们总是可以用auto关键词来让编译器进行推断,进而得出我们想要的特定类型的智能指针。

我们可以认为每个shared_ptr对象都有一个关联的计数器,通常称为引用计数。当进行一些操作时,计数会根据实际的情况进行增加或减少。例如,拷贝一个智能指针,此时会增加指向对象的指针个数;一个智能指针超出作用域,此时,会减少指向对象的指针个数。当一个计数变为0时,该智能指针会自动释放所指的动态内存。减少计数和销毁动态内存是通过智能指针的析构函数来完成的。

使用shared_ptr管理内存非常方便与安全

动态对象的生存周期同引用类型对象相同,当引用对象超出作用域时,引用对象本身会销毁,但引用对象所引用(或者说所指向)的对象不会销毁。同样的,当指向动态对象的对象(或者说指针)超出作用域之后,指向动态对象的对象销毁和动态对象之间并无关系。实例如下:

int i = 5;
{ //新作用域
    int &ri = i; 
} //引用对象ri超出作用域

{
   int *p = new int(5);
}

上面代码中引用对象ri超出作用域之后,引用对象ri所引用的对象i并不会销毁。同样的,无名动态对象被指针p所指,当p超出作用域被销毁后,无名对象并不会被销毁。这样就产生了内存泄露。

但是如果使用智能指针shared_ptr管理内存的话,就避免了内存泄露问题。将上面的代码改为:

int i = 5;
{ //新作用域
    int &ri = i; 
} //引用对象ri超出作用域

{
   auto p = shared_ptr<int>(5); // p5 points to an int that is value initialized to 5
}

当超出作用域的时候,智能指针p会递减引用值,递减之后的引用值为0,然后销毁对象,确保不会产生内存泄露。

慎用shared_ptr的get成员函数

shared_ptr类有一个get成员函数,其作用是返回一个指向该shared_ptr所指内存的built-in指针。使用get非常容易出错。

  • 使用get成员函数时必须保证不会使用delete释放返回指针所指的内存;
  • 永远不是使用get成员函数返回的bulit-in指针去初始化另外一个或者赋值给另外一个智能指针。

reset()与unique()的联合使用

reset成员是重置一个智能指针,必要时会释放掉所指内存。在需要修改共享内存但又不影响其他智能指针所指内存中的数据的时候,通常将reset()unique()联合起来使用。e.g.

if(!p.unique())
    p.reset(new string(*p)); // we aren't alone; allocate a new copy
*p += newVal; // now that we hnow we're the only pointer, okay to change this object

智能指针陷阱

为了避免智能指针陷阱,必须遵守一些规则,这些规则请参见此处

unique_ptr

一个unique_ptr拥有它所指向的对象,和shared_ptr不同,不同的unique_ptr之间不能“共享”指针指向的内存,当一个unique_ptr被销毁时,unique_ptr所指向的对象也会一并销毁。unique_ptr对象只能通过实例化unique_ptr类得到,没有类似make_shared的方法。

unique_ptr可以默认初始化指向一个空指针,或者使用new返回的指针直接初始化,除此之外,没有其他的初始化方式了。unique_ptr没有拷贝构造和赋值运算的功能。e.g.

unique_ptr<string> p0;     // default initialization, nullptr
unique_ptr<string> p1(new string("abcedfg")); // direct initialization
unique_ptr<string> p2(p1); // error: no copy for unique_ptr
unique_ptr<string> p3;
p3 = p1;                   // error: no assign fo unique_ptr

虽然不能拷贝或者赋值unique_ptr,但是可以通过unique_ptrrelease或者reset方法来转移unique_ptr中指针的所有权。例如:

unique_ptr<string> p2(p1.release());    // p1所指对象所有权转移到p2,p1置空
p4.reset(p3.release());              // p3所指对象所有权转移到p4,当p4位unique_ptr时,p4原对象释放 (p4的类型为unique_ptr)
                                        // 当p4为shared_ptr时,检查p4所指内存指针的个数,如果为1,则释放p4所指内存)

虽然不能拷贝unique_ptr,但是有一个例外,就是编译器知道拷贝之后马上就要被销毁。例如返回一个unique_ptr对象。

unique_ptr<int> clone(int p)
{
    // ok: explicitly create a unique_ptr<int> from int*
    return unique_ptr<int>(new int(p));
}

unique_ptr<int> clone(int p)
{
    unique_ptr<int> ret(new int(p));
    // ok: return a copy of a local object
    return ret;
}

weak_ptr

weak_ptr是一种不控制所指对象生存周期的智能指针,也即当weak_ptr析构时,它不会销毁所指对象及释放所指对象的内存。

weak_ptr支持的操作

weak_ptr<T> w     // Null weak_ptr that can point at 
                  // objects of type T.
weak_ptr<T> w(sp) // weak_ptr that points to the same 
                  // object as the shared_ptr sp. 
                  // T must be convertible to the type 
                  // to which sp points
w = p             // p can be a shared_ptr or a weak_ptr. 
                  // After the assignment w shares the 
                  // ownership with p.
w.reset()         // Make w null.
w.use_count()     // The number of shared_ptrs that share
                  // ownership with w.
w.expired()       // Returns true if w.use_count() is zero,
                  // false otherwise.
w.lock()          // If expired is true, returns a null 
                  // shared_ptr; otherwise return a 
                  // shared_ptr to the object to which w points.

由于weak_ptr所指的内存可能会被释放,因此,必须使用lock成员确保使用wp时有效。e.g.

if(shared_ptr<int> np = wp.lock()) // true if np is not null
{
    /// inside the if, np shares its object with p
}

 

直接管理内存

new

在C++中也支持直接手动分配和释放内存,使用new来分配内存,使用delete来释放内存,其中newdelete都是类似于”+”、”/”一样的运算符。

使用new运算符得到是相应类型对象的指针,并且该对象是匿名的,只能使用该对象的指针进行间接访问。当然,我们也可以使用一个引用来绑定到这个动态对象上,从而通过引用来访问对象。

使用new运算符分配的对象,默认情况下是默认初始化的。对于内置类型,这意味着对象的值是不确定的;对于自定义类型,意味着使用默认构造函数。如果我们想要对动态获得的对象进行初始化,可以使用直接初始化或者列表初始化的方式,当然,也可以使用值初始化的方式。

对于自定义类类型,使用默认初始化,还是值初始化结果都是调用默认构造函数,通常情况下是没有区别的,除非是非常简单的没有定义任何构造函数的类类型(build-in type, e.g. int)会有差异。

string *ps1 = new string;  // default initialized to the empty string
string *ps = new string(); // value initialized to the empty string
int *pi1 = new int;        // default initialized; *pi1 is undefined
int *pi2 = new int();      // value initialized to 0; *pi2 is 0

对于内置类型,使用默认初始化和值初始化则不相同,默认初始化的值是未定义的,值初始化的值则为0。

我们还可以使用auto配合new来进行类型推断并初始化,语法如下:

auto p1 = new auto(obj);
auto p2 = new auto{obj};    //错误,不允许使用花括号

该语句的作用是使用obj来推断动态对象的类型,并使用obj来初始化该动态对象,new返回的指针存储于p1中。

const动态对象

动态对象也可以是const类型的,和其他const对象一样,const对象必须初始化。例如:

const int *p1 = new const int(5); // value initialized to 5, const
const int *p2 = new int(5);       // value initialized to 5, nonconst

对于p1来说,p1是一个指向const对象的指针。

对于p2来说,p2也是一个指向const对象的指针,然后实际对象并不是const对象。

new分配内存失败

new分配内存也存在失败的情况。需要知道的是,new即使分配内存失败,也不会导致内存泄露。new内部自身会进行一些处理以防止内存泄露。默认情况下,new在妥善处理后,会抛出一个异常来告知内存分配出现问题。当然,如果我们的程序不接受异常或者没有异常处理,则可以在new后使用”(nothrow)”来告知new不要抛出异常,此时,new返回nullptr指针。如果要使用这些特性,需要包含头文件“new”。

delete

动态分配内存使得我们能够手动的申请内存,相应的,我们需要手动的释放申请的内存。方法是使用delete运算符,例如

delete p;

该运算符和一个运算对象组成一个表达式,此表达式的结果是:销毁p指向的对象,并且释放该对象所占用的内存。

该运算符只能对动态申请的内存进行释放,并且只能释放一次对非动态内存进行释放或者对某一内存多次释放,都是未定义的行为

另外,由于动态对象的生存周期是我们手动管理的,因此可以在动态对象的生存期间在整个程序中进行共享。

new与shared_ptr

如前所述,使用智能指针shared_ptr进行内存管理时,我们如果没有初始化shared_ptr对象,那么它指向一个空指针,除非我们使用make_shared来生成智能指针。

除了使用make_shared来生成有效的智能指针,我们也可以使用new生成的指针来初始化shared_ptr对象从而得到一个有效的智能指针。方法是在定义智能指针对象时,使用new返回的指针来直接初始化。如下:

shared_ptr<int> pi1(new int);
shared_ptr<int> pi1(new int(3));
shared_ptr<int> pi2 = new int;    //错误,shared_ptr的构造函数是explicit的

由于shared_ptr的构造函数是explicit的,因此我们不能使用拷贝构造函数来进行初始化,必须使用直接初始化,即直接匹配构造函数的方式来进行初始化

还有一个问题是:当我们默认初始化了一个智能指针,该智能指针是指向nullptr的,随后我们自己使用new手动分配了动态内存,如何让默认初始化的智能指针指向我们分配的动态内存?我们可以使用智能指针的reset方法。如下:

p.reset();
p.reset(q);
p.reset(q, d);

如果p是唯一一个指向动态对象的智能指针,则reset会释放该动态对象;如果向reset传递了普通的非智能指针q,那么p会新指向q所指的内存;如果还向reset传递了d,那么会使用d来释放q,而不是默认的delete

动态数组

申请动态数组

在C++中,newdelete支持一次性分配或释放多个对象的动态内存。对比C语言,可以发现C语言是没有动态分配数组这一说的,C语言是典型的面向过程的编程语言。在动态分配时,C语言解决问题的思路过程是:我们需要申请多少个字节的内存;而C++则是我们需要申请构造一个动态对象还是一组动态对象,并没有C语言中直接意义上的多少字节内存。

C++中动态数组的分配方式是:在欲申请的对象类型后”[ ]“中来指明需要对象的个数;在欲释放的动态数组的指针前面使用”[ ]“来告知delete释放整个数组。例如:

int *p = new int[10];    //申请动态数组
delete [] p;           //释放动态数组

在动态分配数组时,和静态数组不同,动态数组的元素数量可以是个变量,但是该变量的类型必须是整形

除了直接使用”[ ]“来分配数组,我们也可以类型别名来用于new表达式分配动态数组,例如:

using int_Arr1 = int [10];
typedef int int_Arr2[15];

auto p1 = new int_Arr1;    // p1指向含有10个元素的动态数组
auto p2 = new int_Arr2;    // p2指向含有15个元素的动态数组

对于动态分配得到的数组,new返回的是一个单纯的指针类型,而不是数组类型。我们知道数组也是一种类型,是一种复合的复杂类型,其类型包括其中元素类型元素个数。由于对于数组类型来说其类型由元素的个数和类型决定,因此我们可以使用sizeof对数组进行大小计算,也可以使用新的range-for形式for循环来遍历数组,当然也可以使用标准库的beginend函数来取得其迭代器。但是new [ ]分配得到的数组却不是一个数组类型,而是一个指针类型,因此该动态数组不能像静态数组那样进行迭代器获取行为

动态数组中的元素默认也是默认初始化的,我们可以进行值初始化,如下:

int *p1 = new int[10]();
int *p2 = new int[10] {};    // C++11

我们也可以使用C++11的列表初始化,如下:

int *p = new int[10] {1, 2, 3, 4, 5};    // 前5个元素使用给定值初始化,其余元素值初始化

如果我们初始化动态数组时,提供的元素个数多于数组最大容纳个数,则new分配失败,并抛出一个异常。

关于动态数组分配的最后一个问题是:如果动态分配的数组元素个数是0,那么new的行为是什么?答案是new正常返回一个非空指针,但不能对该指针解引用,该指针是一个尾后指针。

释放动态数组

释放动态数组的语法前面已经做出说明,现在考虑一下动态数组元素销毁的次序。对于动态数组释放,元素销毁的次序是逆序的,即先销毁最后一个元素,然后是倒数第二个,以此类推。

我们在释放动态数组时,必须在指针名前使用”[ ]“以告知编译器销毁的是一个动态数组,而不是单一对象,如果忘记使用这个方括号,那么行为是未定义的,可能造成内存泄露,或者程序崩溃。

令我们惊讶的是,标准库还提供了一个用于管理动态数组的智能指针,该智能指针是unique_ptr。为了使用unique_ptr,我们需要提供一个类型参数供模板实例化一个具体类。我们提供的用于管理动态数组的类型参数是int [ ],如下:

unique_ptr<int []> up(new int[10]);
up.release()    //自动使用delete []来释放数组

和普通的unique_ptr不同,指向动态数组版本的unique_ptr不支持点和箭头运算符,因为这些解引用操作无意义。另一方面,我们的unique_ptr是指向动态数组的,所以我们可以使用下标索引(“[ ]“)来访问数组中的元素。

因为unique_ptr同一时刻只能有一个对象拥有动态数组,如果我们需要在多个对象间共享需要怎么做?我们可以使用sharded_ptr,但是我们必须提供一个删除工具,来替代默认delete操作。如下:

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

上面我们提供了一个lambda工具给shared_ptr,使得shared_ptr能够在没有对象使用动态数组时,使用该lambda正确释放动态数组。

需要注意的是,在声明的时候,unique_ptrshared_ptr不同,unique_ptr的类型为T[],而shared_ptr的类型是T,没有[]。具体参考上面两个示例程序中的红黑体。

由于shared_ptr不支持动态数组管理,因此也就没有提供下标运算符,所以如果我们需要访问动态数组,只能使用shared_ptr中提供的get方法,通过get得到指针之后再继续我们的访问。e.g.

// shared_ptrs don't have subscript operator and don't support pointer arithmetic
for (size_t i = 0; i != 10; ++i)
    *(sp.get() + i) = i; // use get to get a bulit-in pointer

allocator类

对程序效率要求极高时,new的行为可能会造成一些局限。new运算符在分配动态内存时,无论如何都会对动态对象进行初始化,也即内存的申请和对象的初始化是组合在一起的。如果某些时候需要申请很大空间的动态数组,那么数组中元素的初始化可能就不那么有意义或者说有些开销浪费,因为我们需要在使用内存的时候自己去构造对象。而且对于没有默认构造函数的类来说,我们还必须列表初始化所有的元素。

标准库allocator类提供了一些方法,能使得动态内存的分配和对象初始化分离开来,它存在于头文件中,如下:

#include <memory>

allocator类分配的内存是原始未构造的。

类似vectorallocator也是一个模板,需要我们提供类型参数以便allocator类来分配相应类型的内存。allocator类会根据类型参数自动的确定内存的对齐方式

allocator类的使用示例如下:

allocator<int> ai;        // 实例化allocator类对象
int *p = ai.allocate(5);  // 使用类对象的方法来申请动态内存
int *q = p;

ai.construct(p, 6);       // 对申请内存中第一个元素进行构造
cout << *p << endl;       // 输出被构造的对象的值
++q;                      // 移动到第二个未构造的元素
cout << *q++ << endl;     // 未定义的行为,该内存处无对象

allocator算法

下面的函数将在目的地址中构造元素,而不是复制元素。

uninitialized_copy(b, e, b2)    // 从迭代器b和e指出的输入范围中拷贝元素到迭代器b2指定的未构造的原始内存中。b2指向的内存必须足够大,能荣达输入序列中元素的拷贝
uninitialized_copy_n(b, n, b2)  // 从迭代器b指向的元素开始,拷贝n个元素到b2开始的内存中
uninitialized_fill(b, e, t)     // 在迭代器b和e指定的原始内存范围中创建对象,对象的值均为t的拷贝
uninitailized_fill_n(b, n, t)   // 从迭代器b指向的内存地址开始创建n个对象。b必须指向足够大的为构造的原始内存,能够容纳给定数量的对象

 

发表评论

电子邮件地址不会被公开。 必填项已用*标注

5 × 5 =

− 2 = 7