到本章为止,我们还没有接触过动态内存,接下来我们会学习动态内存的知识。
关于动态内存,第一个疑问是:为什么我们要使用动态内存?它有什么优点?
要了解动态内存,得先了解一个进程在内存中的基本空间结构。进程是被执行起来的程序,程序是未执行的二进制文件。
大概来说,程序包含了三大部分内容:一是程序代码,称为text段,二是程序中定义的初始化了的全局和静态变量(非0初始值),称为data段,三是未初始化的全局和静态变量(或者初始值为0),称为bss段。
进程是执行起来的程序,自然包含上面的三部分内容,另外还会额外增加进两部分内容。一个是堆,另一个是栈。栈用来存储我们在程序中用到的局部变量,而堆则是用于本章所学习的动态内存存储。
栈的效率高,速度快,局部访问,自动管理,空间大小有限。
堆是一个内存池,它也被称为自由存储,堆的空间可以很大,也能全局访问共享,手动管理。
有了这些基本知识,再回到开头的第一个问题:为什么使用动态内存及其优点。因为动态内存是存在堆中的,所以其优点是空间大小可以很大。除了优点之外,更有几个不使用动态内存则无法解决的问题,这也是为什么要使用动态内存的原因:
1.我们不知道程序到底会需要多少空间或者说多少个对象。比如说,程序经常需要处理一批数据,有多少个数据不确定,为了存储这些数据,我们只能根据当时的情况来申请内存空间来进行存储和处理。
2.为了实现面向对象的动态绑定功能。这点将在后面的章节进行学习。
3.程序的多个对象之间进行数据的共享,或者说全局访问共享。典型的例子是接下来即将学习的智能指针。
简而言之,动态内存的关键点在于“动”字上。
加上动态内存,在C++总共就有了3种类型的内存:静态内存、动态内存、栈内存。静态内存存储全局变量、静态局部变量、类静态数据成员;栈内存用于存储局部变量、动态内存用于存储手动分配的对象。
对于手动通过堆分配的内存来说,我们必须手动管理内存的释放,这是相当有难度的,尤其在C++中那些可能出现异常的地方。由于正确的管理内存很棘手,因此标准库提供了两个“智能指针”来帮助我们动态使用内存。一种是“共享指针”,它允许多个指针指向一个对象,而在没有指针指向对象时自动释放内存;另一种是“独占指针”,某一时刻,它只允许一个指针指向对象。标准库还有一个tiny工具weak_ptr,是一个辅助工具,用于指向“共享指针”指向的对象,但不会引起“共享指针”计数增加。
头文件
#include <memory>
提供工具
shared_ptr;
unique_ptr;
weak_ptr;
make_shared;
shared_ptr和vector一样,是一个模板,在提供了足够信息后,用于生成特定类型的智能指针。用法如下:
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); }
最安全的分配和使用动态内存的方法是使用make_shared标准库函数,该函数能够代替我们去动态分配内存,并且将对象进行初始化,之后返回一个智能指针给我们使用。同样的,make_shared也是一个模板,虽然他是一个函数模板,但是却不能根据我们传入的参数进行类型推断。原因是该函数的模板参数存在于返回类型之中,导致无法直接推断,因此我们必须手动指定参数类型。其使用方式如下:
shared_ptr<string> ps = make_shared<string>(); auto ps2 = make_shared<string>(10, 'c');
对于智能指针对象ps和ps2的初始化,我们传给make_shared的参数将会用于初始化ps和ps2所指内存中对象的初始化。如果我们没有传递参数给make_shared,那么ps和ps2依然会被初始化,但是是值初始化,也即ps和ps2指向了各自的内存(内存中的对象没有初始化),而不是默认初始化的nullptr;总而言之,make_shared一定会返回一个指向一块内存的智能指针,而不会返回nullptr;
另外,由于make_shared不会返回nullptr,所以我们总是可以用auto关键词来让编译器进行推断,进而得出我们想要的特定类型的智能指针。
我们可以认为每个shared_ptr对象都有一个关联的计数器,通常称为引用计数。当进行一些操作时,计数会根据实际的情况进行增加或减少。例如,拷贝一个智能指针,此时会增加指向对象的指针个数;一个智能指针超出作用域,此时,会减少指向对象的指针个数。当一个计数变为0时,该智能指针会自动释放所指的动态内存。减少计数和销毁动态内存是通过智能指针的析构函数来完成的。
动态对象的生存周期同引用类型对象相同,当引用对象超出作用域时,引用对象本身会销毁,但引用对象所引用(或者说所指向)的对象不会销毁。同样的,当指向动态对象的对象(或者说指针)超出作用域之后,指向动态对象的对象销毁和动态对象之间并无关系。实例如下:
int i = 5; { //新作用域 int &ri = i; } //引用对象ri超出作用域
{ int *p = new int{};
}
上面代码中引用对象ri超出作用域之后,引用对象ri所引用的对象i并不会销毁。同样的,无名动态对象被指针p所指,当p超出作用域被销毁后,无名对象并不会被销毁。
直接管理内存
在C++中也支持直接手动分配和释放内存,使用new来分配内存,使用delete来释放内存,其中new和delete都是类似于"+"、"/"一样的运算符。
使用new运算符得到是相应类型对象的指针,并且该对象是匿名的,只能使用该对象的指针进行间接访问。当然,我们也可以使用一个引用来绑定到这个动态对象上,从而通过引用来访问对象。
使用new运算符分配的对象,默认情况下是默认初始化的。对于内置类型,这意味着对象的值是不确定的;对于自定义类型,意味着使用默认构造函数。如果我们想要对动态获得的对象进行初始化,可以使用直接初始化或者列表初始化的方式,当然,也可以使用值初始化的方式。
对于自定义类类型,使用默认初始化,还是值初始化结果都是调用默认构造函数,通常情况下是没有区别的,除非是非常简单的没有定义任何构造函数的类类型会有差异。
对于内置类型,使用默认初始化和值初始化则不相同,默认初始化的值是未定义的,值初始化的值则为0。
我们还可以使用auto配合new来进行类型推断并初始化,语法如下:
auto p1 = new auto(obj); auto p2 = new auto{obj}; //错误,不允许使用花括号
该语句的作用是使用obj来推断动态对象的类型,并使用obj来初始化该动态对象,new返回的指针存储于p1中。
动态对象也可以是const类型的,和其他const对象一样,const对象必须初始化。例如:
const int *p1 = new const int(5); const int *p2 = new int(5);
对于p1来说,p1是一个指向const对象的指针。
对于p2来说,p2也是一个指向const对象的指针,然后实际对象并不是const对象。
new分配内存也存在失败的情况。需要知道的是,new即使分配内存失败,也不会导致内存泄露。new内部自身会进行一些处理以防止内存泄露。默认情况下,new在妥善处理后,会抛出一个异常来告知内存分配出现问题。当然,如果我们的程序不接受异常或者没有异常处理,则可以在new后使用"(nothrow)"来告知new不要抛出异常,此时,new返回nullptr指针。如果要使用这些特性,需要包含头文件“new”。
动态分配内存使得我们能够手动的申请内存,相应的,我们需要手动的释放申请的内存。方法是使用delete运算符,例如
delete p;
该运算符和一个运算对象组成一个表达式,此表达式的结果是:销毁p指向的对象,并且释放该对象所占用的内存。
该运算符只能对动态申请的内存进行释放,并且只能释放一次。对非动态内存进行释放或者对某一内存多次释放,都是未定义的行为。
另外,由于动态对象的生存周期是我们手动管理的,因此可以在动态对象的生存期间在整个程序中进行共享。
如前所述,使用智能指针shared_ptr进行内存管理时,我们如果没有初始化shared_ptr对象,那么它指向一个空指针,除非我们使用make_shared来生成智能指针。
除了使用make_shared来生成有效的智能指针,我们也可以使用new生成的指针来初始化shared_ptr对象从而得到一个有效的智能指针。方法是在定义智能指针对象时,使用new返回的指针来直接初始化。如下:
shared_ptr<int> pi1(new int); 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。
unique_ptr
一个unique_ptr拥有它所指向的对象,和shared_ptr不同,不同的unique_ptr之间不能“共享”指针指向的内存,当一个unique_ptr被销毁时,unique_ptr所指向的对象也会一并销毁。unique_ptr对象只能通过实例化unique_ptr类得到,没有类似make_shared的方法。
unique_ptr可以默认初始化指向一个空指针,或者使用new返回的指针直接初始化,除此之外,没有其他的初始化方式了。unique_ptr没有拷贝构造和赋值运算的功能。
虽然不能拷贝或者赋值unique_ptr,但是可以通过unique_ptr的release或者reset方法来转移unique_ptr中指针的所有权。例如:
unique_ptr<string> p2(p1.release()); //p1所指对象所有权转移到p2,p1置空 p4.reset(p3.release()); //p3所指对象所有权转移到p4,p4原对象释放
weak_ptr
weak_ptr是一种不控制所指对象生存周期的智能指针,也即当weak_ptr析构时,它不会销毁所指对象及释放所指对象的内存。
动态数组
在C++中,new和delete支持一次性分配或释放多个对象的动态内存。对比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循环来遍历数组,当然也可以使用标准库的begin和end函数来取得其迭代器。但是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正确释放动态数组。
由于shared_ptr不支持动态数组管理,因此也就没有提供下标运算符,所以如果我们需要访问动态数组,只能使用shared_ptr中提供的get方法,通过get得到指针之后再继续我们的访问。
allocator类
对程序效率要求极高时,new的行为可能会造成一些局限。new运算符在分配动态内存时,无论如何都会对动态对象进行初始化,也即内存的申请和对象的初始化是组合在一起的。如果某些时候需要申请很大空间的动态数组,那么数组中元素的初始化可能就不那么有意义或者说有些开销浪费,因为我们需要在使用内存的时候自己去构造对象。而且对于没有默认构造函数的类来说,我们还必须列表初始化所有的元素。
标准库allocator类提供了一些方法,能使得动态内存的分配和对象初始化分离开来,它存在于头文件中,如下:
#include <memory>
allocator类分配的内存是原始未构造的。
类似vector,allocator也是一个模板,需要我们提供类型参数以便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; //未定义的行为,该内存处无对象
练习12.1:在此代码的结尾,b1 和 b2 各包含多少个元素?
StrBlob b1; { StrBlob b2 = {"a", "an", "the"}; b1 = b2; b2.push_back("about"); }
b2被销毁,b1包含4个元素。
练习12.2:编写你自己的StrBlob 类,包含const 版本的 front 和 back。
const string& StrBlob::front() const { check(0, "front on empty StrBlob"); return data->front(); } const string& StrBlob::back() const { check(0, "back on empty StrBlob"); return data->front(); }
练习12.3:StrBlob 需要const 版本的push_back 和 pop_back吗?如果需要,添加进去。否则,解释为什么不需要。
不需要,因为push_back和poo_back是写操作,不能使用const。
练习12.4:在我们的 check 函数中,没有检查 i 是否大于0。为什么可以忽略这个检查?
因为size_type是无符号类型,一定大于等于0。
练习12.5:我们未编写接受一个 initializer_list explicit 参数的构造函数。讨论这个设计策略的优点和缺点。
练习12.6:编写函数,返回一个动态分配的 int 的vector。将此vector 传递给另一个函数,这个函数读取标准输入,将读入的值保存在 vector 元素中。再将vector传递给另一个函数,打印读入的值。记得在恰当的时刻delete vector。
#include <iostream> #include <vector> std::vector<int>* get_vector() { auto p = new std::vector<int>; return p; } void use_vector(std::istream &in, std::vector<int> *p) { int i; while (in >> i) { p->push_back(i); } } void print_vector(std::vector<int> *p) { for (auto b = p->begin(), e = p->end(); b != e; ++b) { std::cout << *b << ' '; } } int main() { auto p = get_vector(); use_vector(std::cin, p); print_vector(p); delete p; std::cout << std::endl; return 0; }
练习12.7:重做上一题,这次使用 shared_ptr 而不是内置指针。
#include <iostream> #include <memory> #include <vector> std::shared_ptr<std::vector<int>> get_vector() { return std::make_shared<std::vector<int>>(); } void use_vector(std::istream &in, std::shared_ptr<std::vector<int>> p) { int i; while (in >> i) { p->push_back(i); } } void print_vector(std::shared_ptr<std::vector<int>> p) { for (auto b = p->begin(), e = p->end(); b != e; ++b) { std::cout << *b << ' '; } } int main() { auto p = get_vector(); use_vector(std::cin, p); print_vector(p); std::cout << std::endl; return 0; }
练习12.8:下面的函数是否有错误?如果有,解释错误原因。
bool b() { int* p = new int; // ... return p; }
有错误,函数返回值是bool,指针无法转换到bool
练习12.9:解释下面代码执行的结果。
int *q = new int(42), *r = new int(100); r = q; auto q2 = make_shared<int>(42), r2 = make_shared<int>(100); r2 = q2;
q赋值给r,r所指的内存没有释放导致内存泄露。q2赋值给r2,r2的引用计数减1后为0,自动销毁。
练习12.10:下面的代码调用了第413页中定义的process 函数,解释此调用是否正确。如果不正确,应如何修改?
shared_ptr<int> p(new int(42)); process(shared_ptr<int>(p));
正确。用p初始化了一个临时的智能指针,该操作递增了引用计数,当临时对象被销毁时,智能指针的计数恢复但不为0。
练习12.11:如果我们像下面这样调用 process,会发生什么?
process(shared_ptr<int>(p.get()));
将会导致智能指针所指的内存被释放2次。
练习12.12:p 和 q 的定义如下,对于接下来的对 process 的每个调用,如果合法,解释它做了什么,如果不合法,解释错误原因:
auto p = new int();
auto sp = make_shared<int>();
(a) process(sp);
(b) process(new int());
(c) process(p);
(d) process(shared_ptr<int>(p));
(a)正确
(b)错误,不允许隐式转换
(c)错误,类型不匹配
(d)错误,可能会导致p所指内存被释放2次
练习12.13:如果执行下面的代码,会发生什么?
auto sp = make_shared<int>();
auto p = sp.get();
delete p;
sp所指内存被释放2次
练习12.14:编写你自己版本的用 shared_ptr 管理 connection 的函数。
void end_connection(connection *p) { disconnect(*p); } void f(destination &d /* other parameters */) { connection c = connect(&d); shared_ptr<connection> p(&c, end_connection); }
练习12.15:重写第一题的程序,用 lambda (参见10.3.2节,第346页)代替end_connection 函数。
void f(destination &d /* other parameters */) { connection c = connect(&d); shared_ptr<connection> p(&c, [](connection * p) { disconnect (*p); }); }
练习12.16:如果你试图拷贝或赋值 unique_ptr,编译器并不总是能给出易于理解的错误信息。编写包含这种错误的程序,观察编译器如何诊断这种错误。
note: 'unique_ptr' has been explicitly marked deleted here
unique_ptr(const unique_ptr&) = delete;
练习12.17:下面的 unique_ptr 声明中,哪些是合法的,哪些可能导致后续的程序错误?解释每个错误的问题在哪里。
int ix = 1024, *pi = &ix, *pi2 = new int(2048);
typedef unique_ptr<int> IntP;
(a) IntP p0(ix);
(b) IntP p1(pi);
(c) IntP p2(pi2);
(d) IntP p3(&ix);
(e) IntP p4(new int(2048));
(f) IntP p5(p2.get());
(a)错误,不能用int来初始化unique_ptr
(b)错误,unique_ptr必须使用动态分配的内存来初始化
(c)正确。
(d)错误,unique_ptr必须使用动态分配的内存来初始化
(e)正确
(f)错误,多个unique_ptr指向同一块内存。
练习12.18:shared_ptr 为什么没有 release 成员?
release 成员的作用是放弃对指针控制权,一个对象只能被一个unique_ptr所拥有,而shared_ptr则是多个指向同一个对象,因此不需要 release 成员。
练习12.19:定义你自己版本的 StrBlobPtr,更新 StrBlob 类,加入恰当的 friend 声明以及 begin 和 end 成员。
#include <iostream> #include <vector> #include <memory> using namespace std; class StrBlob; class StrBlobPtr { public: StrBlobPtr(); StrBlobPtr(StrBlob &a, size_t sz); std::string& deref() const; StrBlobPtr& incr(); private: shared_ptr<vector<string>> check(std::size_t, const std::string&) const; std::weak_ptr<std::vector<std::string>> wptr; std::size_t curr; }; StrBlobPtr::StrBlobPtr(): curr(0) { } shared_ptr<vector<string>> StrBlobPtr::check(std::size_t i, const std::string &msg) const { auto ret = wptr.lock(); if (!ret) { throw std::runtime_error("unbound StrBlobPtr"); } if (i >= ret->size()) { throw std::out_of_range(msg); } return ret; } std::string& StrBlobPtr::deref() const { auto p = check(curr, "dereference past end"); return (*p)[curr]; } StrBlobPtr& StrBlobPtr::incr() { check(curr, "increment past end of StrBlobPtr"); ++curr; return *this; } class StrBlob { friend class StrBlobPtr; public: typedef std::vector<std::string>::size_type size_type; StrBlob(); StrBlob(std::initializer_list<std::string> il); StrBlobPtr begin(); StrBlobPtr end(); size_type size() const { return data->size(); } bool empty() const { return data->empty(); } void push_back(const std::string &t) { data->push_back(t); } void pop_back(); std::string& front(); std::string& back(); private: std::shared_ptr<std::vector<std::string>> data; void check(size_type i, const std::string &msg) const; }; StrBlobPtr::StrBlobPtr(StrBlob &a, size_t sz = 0): wptr(a.data), curr(sz) { } StrBlobPtr StrBlob::begin() { return StrBlobPtr(*this); } StrBlobPtr StrBlob::end() { auto ret = StrBlobPtr(*this, data->size()); return ret; } int main() { std::cout << std::endl; return 0; }
练习12.20:编写程序,逐行读入一个输入文件,将内容存入一个 StrBlob 中,用一个 StrBlobPtr 打印出 StrBlob 中的每个元素。
略
练习12.21:也可以这样编写 StrBlobPtr 的 deref 成员:
std::string& deref() const {
return (*check(curr, "dereference past end"))[curr];
}
你认为哪个版本更好?为什么?
原版更易读。
练习12.22:为了能让 StrBlobPtr 使用 const StrBlob,你觉得应该如何修改?定义一个名为ConstStrBlobPtr 的类,使其能够指向 const StrBlob。
重载构造函数接受const Strblob &形参, 然后给 Strblob 类添加两个 const 成员函数 cbegin 和 cend,返回 ConstStrBlobPtr。
练习12.23:编写一个程序,连接两个字符串字面常量,将结果保存在一个动态分配的char数组中。重写这个程序,连接两个标准库string对象。
#include <iostream> #include <cstring> using namespace std; int main() { char *p = new char[strlen("hello world") + 1]; strcat(p, "hello "); strcat(p, "world"); cout << p << endl; delete [] p; string s1("hello "), s2("world"); cout << s1 + s2 << endl; return 0; }
练习12.24:编写一个程序,从标准输入读取一个字符串,存入一个动态分配的字符数组中。描述你的程序如何处理变长输入。测试你的程序,输入一个超出你分配的数组长度的字符串。
#include <iostream> int main() { char *p = new char[64]; std::cout << "input: "; std::string s; std::cin >> s; if (s.size() < 64) { for (int i = 0; i < 64; ++i) { p[i] = s[i]; } } delete [] p; return 0; }
练习12.25:给定下面的new表达式,你应该如何释放pa?
int *pa = new int[10];
delete [] pa;