What & How & Why

动态内存

C++ Primer 笔记 第十二章


在 C++ 中,非常多的对象都是由编译器代理管理的;这些对象都具有完整定义的生命周期;比如全局对象Global Object)、本地静态对象(Local static obejct )和自动对象Automatic object)等等。

除了这些编译器管理的对象以外,C++ 还允许用户定义对象的生命周期。这种对象的生命周期与其在哪里创建并没有关系;只有当其被显式的释放的时候,其生命周期才结束。我们称这种对象为动态对象Dynamic Object);而为该对象划分内存的操作称为动态分配Dynamic Allocate)。

遗憾的是,在 C++ 中,手动分配与释放内存是一项很容易造成 Bug 的操作。为了减少该类操作带来的影响,C++ 标准库提供了两种类型的智能指针Smart Pointer)来管理动态分配的对象。智能指针可以确保其指向的对象在需要的时候被自动释放。

Static, Stack and Heap

在 C++ 中,内存的类型主要分为三种:

  • 静态内存(Static Memory):用于存储全局对象、局部的静态对象和类静态成员
  • 内存(Stack Memory):用于存储函数内的非静态对象(自动对象)
  • 内存(Heap Memory): 又称为 free store,用于存储动态分配的对象(也就是在 run-time 时分配的对象)。

在这三种内存中,对象的生命周期都不一样:

  • 全局 / 局部静态变量在其使用第一次使用前被分配,其生存期会一直持续到程序结束。
  • 自动对象生命周期从其定义开始,到其所在的 block 执行完毕。
  • 动态对象的生命周期由程序自身控制,需要显式的释放才能结束其周期。

动态内存与智能指针

传统的 C++ 中,动态内存通过关键字 newdelete 来分配与释放:

  • new 分配给对象动态内存(可能附带初始化操作),并返回一个指向该对象的指针
  • delete 通过该指针摧毁该对象,并释放其所占的内存。

这样做带来的问题是如何在恰当的时间释放内存。忘记释放内存可能导致内存泄露;而在指针仍然有效的时候释放内存会导致野指针Wild pointer)。

C++ 11 中提供了智能指针类型来对动态对象进行管理。智能指针与普通指针的功能相同;唯一的不同点在于智能制造能会自动删除对象并释放内存。标准库定义了如下三种智能指针类型:

  • shared_ptr:允许多个智能指针指向同一个对象的类型
  • unique_ptr:与对象一对一绑定的智能指针类型
  • weak_ptr:弱引用,指向 shared_ptr 管理的对象

以上三种智能指针类型定义于 <memory> 头文件中。

shared_ptr 类

智能指针类型属于模板类型,因此在创建指针的时候也需要指定额外的类型。该类型用于表示智能指针指向对象的类型

shared_ptr<string> p1; // shared_ptr that can point at a string
shared_ptr<list<int>> p2; // shared_ptr that can point at a list of ints
智能指针的默认初始化得到的是一个空指针,使用智能指针作为判断条件时,实际上是在测试该智能指针是否是空指针:
//if p1 is not null, check whether it's the empty string
if(p1 && p1->empty())
{
    *p1 = "hi"; // if so, dereference p1 to assign a new value to that string
}
智能指针的一些操作如下:




make_shared 函数

如果进行分配或使用动态内存,最安全的方法是使用 make_shared() 函数。该函数会初始化一个动态对象,并返回指向该对象的 share_ptr。该函数也定义于<memory> 头文件中。

当调用 make_shared() 函数时,我们需要指定被创建对象的类型:

//shared_ptr that points to an int with value 42
shared_ptr<int> p3 = make_shared<int>(42);
//p4 points to a string with value "99999"
shared_ptr<string> p4 = make_shared<string>(5, "9");
//p5 points to an int that is value initialized
shared_ptr<int> p5 = make_shared<int>();
可以看出来的是, make_shared() 调用对象本身的构造函数进行对象的创建。因此, make_shared() 参数的传递必须符合其构造函数要求。如果没有传递任何参数,则该对象会进行值初始化

make_shared() 的返回值类型可以用 auto 来推断:
// p6 points to a dynamically allocated, empty vector<string>
auto p6 = make_shared<vector<string>>();

shared_ptrs 的拷贝与赋值

在对 shared_ptr 进行拷贝和赋值的过程中,shared_ptr 会持续的跟踪当前有多少其他的 shared_ptr 指向了同一个对象:

auto p = make_shared<int>(42); // object to which p points has 1 user
auto q(p); //p and q point to the same object, the object has 2 users now
我们可以将该跟踪的过程理解为 shared_ptr 自身携带一个计数器(往往被称为引用计数 reference count)。该计数会随着 shared_ptr 的操作而变化:

  • 当 shared_ptr 被拷贝时,计数增加,比如以下场景:
    • shared_ptr 作为拷贝初始化的 initializer 的时候
    • shared_ptr 作为右值进行赋值的时候
    • shared_ptr 以值传递的方式传递给函数的时候
    • shared_ptr 以的方式被返回的时候
  • 计数减少的情况:
    • shared_ptr 作为左值被赋值的时候
    • shared_ptr 自我销毁的时候(比如局部 shared_ptr 所在作用域销毁时)

当 shared_ptr 的计数降低到 0 时,shared_ptr 会自动释放其指向的对象:

auto r = make_shared<int>(42); // shared_ptr to int 42: 1 user
// assign to r, making it point to a different address
r = q; 
// +1 use count for the object to which q points
// -1 use count of the object to which r had pointed
// the object r had pointed to has no users; that object is automatically freed
上例中,在经过一轮赋值后,依靠着 r 的指向转变,shared_ptr q 指向的对象计数加 1。而 shared_ptr r 由于改变指向,导致指向的原对象计数减 1 为 0;因此该原对象也就被 r 直接释放掉了。

如何跟踪记录某个对象的共享 shared_ptr 数量跟标准库的实现有关。我们只需要记住 shared_ptr 可以在恰当的时机自动释放对象即可。

shared_ptr 会自动摧毁其管理的对象

在摧毁所管理对象时,shared_ptr 实际上是通过调用本身的析构函数Destructor)来实现的。shared_ptr 自身的析构函数会减少当前 shared_ptr 指向该对象的计数值。当该数值为 0 的时候,shared_ptr 的析构函数就会将其指向的对象销毁掉,并释放占用的内存。

shared_ptr 会自动释放相关的内存

由于 shared_ptr 自动释放动态对象内存的特性,使得我们可以更好的利用动态内存。比如我们可以利用该特性设计一个函数:

// factory returns a shared_ptr pointing to a dynamically allocated object
shared_ptr<Foo> factory(T arg)
{
	/* process arg */
    // shared_ptr will take care of deleting this memory
	return make_shared<Foo>(arg);
}
factory 函数返回了 shared_ptr,因此接下来的过程中我们可以通过 shared_ptr 管理其申请的资源,比如将返回的 shared_ptr 作为临时对象使用,使其自动释放资源:
void use_factory(T arg)
{
	shared_ptr<Foo> p = factory(arg);
}
我们的总体目标是,调用 factory() 处理 arg,并在处理完毕之后自动释放 factory() 申请的内存。来看看该调用是如何利用 shared_ptr 来自动释放 factory() 申请的内存的:

  • 首先,factory() 以值方式返回的 shared_ptr,引用计数 + 1,factory() 申请的内存依然存在
  • 其次,使用接口函数 use_factory() 访问 factory(),在 use_factory() 中,使用局部变量 p 作为 factory() 返回的智能指针载体
  • use_factory() 执行完毕之后,临时对象 p 也会自动销毁
  • p 在销毁的同时,其对应的 shard_ptr 引用计数 - 1,由于 p 是当前与 shard_ptr 唯一有关联的对象,因此引用计数归零,factory() 申请的内存被智能指针释放。

可以看出来的是,如果希望正确的释放 shared_ptr 指向的对象,需要保证其计数量的清零。下例中,由于 use_factory() 额外返回了 shard_ptr 的拷贝,导致 shard_ptr 无法正确释放空间:

shared_ptr<Foo> use_factory(T arg)
{
	shared_ptr<Foo> p = factory(arg); +1
// use p
	return p; // reference count is incremented when we return p ; +1
} // p goes out of scope; -1; the memory to which p points is not freed
该版本中:

  • 由于 use_factory 返回了 p 的一份拷贝,导致 pfactory 生成对象的计数再加 1
  • p 的自我销毁无法清零引用计数,因为调用 user_factory 的调用者作为另外一个 user 保留了剩余的一点计数
  • 因此,该对象并没有满足被释放的条件

我们需要确保需要被释放的对象不会与任何 shard_ptr 绑定。当然,如果忘记销毁不需要的 shared_ptr,程序也会正常的运行;只是被 shared_ptr 分配的那一部分内存就会一直浪费下去。

一个常见的忘记销毁 shared_ptr 的场景:将 shared_ptr 存入容器中,然后改变了容器元素的顺序。这种情况下一定记得使用 erase 删除不需要的 shared_ptr 元素。

案例:使用动态资源的类

通常在如下三种情况下,程序会倾向于使用动态内存:

  1. 程序不清楚自己需要多少对象
  2. 程序不清楚自己需要什么类型的对象
  3. 程序希望在不同的对象之间共享数据

我们在容器中见到过头两种情况。来看看第三类的情况:

正常情况下,对象之间并不会共享数据。比如拷贝一个 vector。当拷贝完成之后,源 vector 与 目标 vector 中的元素,实际上就是互相独立的了:

vector<string> v1; // empty vector
{ // new scope
vector<string> v2 = {"a", "an", "the"};
v1 = v2; // copies the elements from v2 into v1
}
这种情况下如果销毁 v2,会导致其包含的元素一并被销毁;然而 v1 中的元素并不会被销毁,因为这些元素是作为 v2 中元素的拷贝存在的。

但在某些情况下,我们希望建立某种具有独立周期的对象,该对象与其关联的资源不共享生命周期。这种对象有两个重要的特征:

  • 不同类对象共享一份底层资源
  • 单方面摧毁某个对象不会影响共享资源

比如下面的 Blob 类:

Blob<string> b1;    // empty Blob
{ // new scope
    Blob<string> b2 = {"a", "an", "the"};
    b1 = b2; // b1 and b2 share the same elements
} // b2 is destroyed, but the elements in b2 must not be destroyed
  // b1 points to the elements originally created in b2
即便是 b2 因为离开作用域而摧毁了,作为共享资源的底层 string 依然存在。

定义 StrBlob 类

来看看上述的这种 Blob 类(此处改名为 StrBlob)如何实现。当然,最简单的办法就是从标准库的容器开始,利用容器来为我们管理内存。这里我们使用 vector。

但根据上例,有两个问题需要处理:

  • 默认情况下,vector 在栈上的拷贝会连关联的资源(vector 中的元素)一起复制
  • b2 作为局部变量,在离开其作用域后,其关联的资源会被一并释放掉

可以看出来的是,达成共享的第一步就是需要保证资源的存在。我们的解决方案是将 vector 存储到动态内存中,并使用 shared_ptr 对其进行管理。shared_ptr 会跟踪 StrBlob 对象的数量,确保在所有类对象都销毁的前提下才会释放共享的 vector。

除此之外,还有几个需要实现的部分:

  • 定义一些对 vector 的访问:比如 frontback 成员
  • 定义一些异常:比如访问不存在的 vector 元素会抛出的异常
  • 定义构造函数:至少有一个默认构造函数,和另外一个可以通过初始化列表 (initializer_list<string>) 初始化类对象的构造函数。

class StrBlob {
public:
	using size_type = std::vector<std::string>::size_type;

	//constructor
	StrBlob();
	StrBlob(std::initializer_list<std::string> il);

	//member functions

	//info
	size_type size() const { return data->size(); }
	bool empty() const { return data->empty(); }

	//adding && remove
	void push_back(const std::string &t) { data->push_back(&t); }
	void pop_back();

	//access
	std::string& front();
	std::string& back(); 

private:
	std::shared_ptr<std::vector<std::string>> data;
	//exception check
	//throw msg if data[i] isn't valid
	void check(size_type i, const std::string &msg) const;

};

StrBlob 的构造函数

本类中的两个构造函数都将通过初始化列表来对管理 vector 的 shared_ptr data 进行初始化:

StrBlob::StrBlob():data(std::make_shared<std::vector<std::string>>()) {}
StrBlob::StrBlob(std::initializer_list<string> il):
				 data(make_shared<std::vector<std::string>>(il)) {}

访问元素的成员函数

访问元素的函数需要实现 3 个:

  • front():访问 vector 中第一个元素
  • back():访问 vector 中最后一个元素
  • pop_back():移除 vector 中左后一个元素

由于这三个操作的前提都是元素必须存在,我们使用私有成员函数 check() 来对元素进行校验:

void StrBlob::check(size_type i, const std::string& msg) const {
	if (i >= data->size())
		throw out_of_range(msg);
}

std::string& StrBlob::front() {
	check(0, "front on empty StrBlob");
	return data->front();
}

std::string& StrBlob::back() {
	check(0, "back on empty StrBlob");
	return data->back();
}

void StrBlob::pop_back() {
	check(0, "pop_back on empty StrBlob");
	return data->pop_back();
}

拷贝、赋值和销毁 strBlob 类对象

所有基于对 strBlob 的拷贝、赋值和销毁操作,都是基于对 shared_ptr data 的操作。因此,被构造函数分配的底层 vector 也将在最后一个 strBlob 销毁的时候被释放。

直接管理内存

C++ 提供了两个运算符供用户进行手动分配与释放内存:

  • new 运算符,负责分配内存
  • delete 运算符,负责释放内存

需要注意的是,使用这一对运算符需要:

  • 对内存的释放时机进行人为的判断
  • 手动定义拷贝、赋值和类对象的析构等一系列操作

很显然,使用智能指针分配资源是更可靠的一种方式。

使用 new 分配和初始化对象

使用 new 会在堆上定义一个没有名字的对象,并返回指向该对象的指针:

int *pi = new int; //pi points to a dynamically allocatedd, unnamed, unintialized int.
需要注意的是,默认情况下,动态分配的对象会进行默认初始化;也就是说:

  • 访问通过 new 定义的,未初始化的 built-in type 及 复合类型的对象,是 undefined 的
  • 通过 new 定义的类会使用其自身的默认构造函数进行初始化

string *ps = new string; //empty string on the heap
int *pi = new int; //pi points to an uninitialized int
由此可见,对 new 分配的对象进行初始化是很有必要的。比如对对象进行直接初始化:C++ 11 中可以使用括号列表初始化(curly braces)两种方式:
int *pi = new int(1024);
string *ps = new string(5, '9');
vector<int> *pv = new vector<int>{0,1,2,3,4};
如果括号中没有内容,则进行的是值初始化
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
默认初始化和值初始化的区别主要体现在 Built-in type 上:

  • 值初始化可以保证 built-in type 拥有完整定义,默认初始化不行
  • 假设某个类使用合成构造函数初始化,对于类中没有给定初始值的 Build-int type 的访问行为依然是未定义的。
使用 auto 对 new 的对象进行推断

当括号中包含了 initilizer, auto 可以用于对对象类型的推断:

auto p1 = new auto(obj);   // p points to an object of the type of obj
                           // that object is initialized from obj
auto 会根据 obj 的类型推断出 p1 是什么类型的指针。但需要注意的是,编译器需要 Initializer 的类型来推断分配对象的类型,因此 initializer 只能有一个,而且需要被括号括起来
auto p2 = new auto{a,b,c}; //error, must use parentheses for the initializer

动态分配 const 对象

我们也可以在堆上分配 const 的对象:

//allocate and initalize a const int
const int *pci = new cosnt int(1024);
// allocate a default-initialized const empty strin
const string *pcs = new const string;
与其他 const 对象相同,堆上的 const 对象也必须进行显式的初始化(由默认构造函数负责初始化的类对象除外)。同时,由于 new 返回的指针是指向 const 对象的,因此返回指针的类型是 pointer to const

内存被耗尽的情况

堆内存存在着被耗尽的可能性。当堆内存耗尽时,new 的分配操作会失败,并抛出名为 bad_alloc 的异常。如果不想该异常被抛出,我们可以使用如下的形式:

// if allocation fails, new returns a null pointer
int *p1 = new int; // if allocation fails, new throws std::bad_alloc
int *p2 = new (nothrow) int; // if allocation fails, new returns a null pointer
这种形式的 new 被称为 Placement new。它允许我们赋予 new 额外的 argument notrhownothrow 代表不抛出异常,它与 bad_alloc 异常一起被定义于头文件 <new> 中。

释放动态内存

为了避免内存耗尽,内存需要在使用完之后释放。C++ 中通过 delete 表达式来释放内存。该表达式接收一个指针,并释放指针对应的对象所占用的内存:

delete p; //destroyed the object to which p points and freed the memory it took

指针的值与 delete

delete 所接的指针必须指向已经分配的动态内存,或是一个空指针。如下两种使用 delete 删除指针的行为是未定义的:

  • 删除任何指向非 new 分配内存的指针
  • 释放同一个动态内存空间两次(即 delete 两次指向该空间的指针)

int i, *pi1 = &i, *pi2 =nullptr;
double *pd = new double(33), *pd2 = pd;
delete i; // error, i is not a pointer
delete pi1; //undefined, pi1 refers to a local
delete pd; //ok
delete pd2; // error, the memory pointed by pd2 has been freed
delete pi2; // ok, delete a nullptr

编译器往往只能检测出 i 这样的非指针对象。对于某个指针指向的空间的类型(是堆或栈内存)或是有效性,编译器都无法得知。绝大部分编译器会在这种情况下执行 delete。

此外,const 对象占用的空间也可以使用 delete 释放:

const int  *pci = new const int(1024);
delete pci;
const 只保证了对象的内容不会被修改,但该对象依然可以被销毁。

动态分配对象在释放之前会一直存在

与智能指针不同,除非显式的释放手动分配的对象,否则该对象会一直存在。比如下面的例子:

//factory returns a pointer to a dynamically allocated object
Foo* factory(T arg)
{
    //process
    return new (arg);
}
该函数返回一个指向堆上的指针,因此在调用完毕之后必须显式的释放该函数所占的内存。如果不这么做:
void use_factory(T arg)
{
    Foo *p = factory(arg);
} //p goes out of scope, but the memory to which p points is not freed.
局部变量指针 p 会根据函数的销毁而销毁,但 p 指向的堆空间并不会被销毁。也就是说,built-in 类型的指针并不会自动的释放其指向的堆内存。上述例子中,当在use_factory 返回后,p 指向的内存就再也无法释放了。为此,我们需要使用完 p 之后手动的 delete 掉它:
void use_factory(T arg)
{
    Foo *p = factory(arg);
    delete p;
}
如果这些资源需要在在其他地方继续使用,我们可以返回 p
Foo* use_factory(T arg)
{
    Foo *p = factory(arg);
    return p;
}
但也需要记住在使用完之后需要 delete p

常见的动态内存管理问题
  • 忘记释放内存。该行为会导致内存泄漏Memory leak),因为被申请的内存永远不会释放。检测该问题也特别难,因为需要等足够长的时间才能等到内存耗尽。
  • 使用被 delete 掉的对象。该问题可以通过给释放掉的对象赋予空指针检测到。
  • 释放两次相同的堆内存。该问题通常出现在两个指针指向同一块堆内存。删除其中一个就会释放对应的堆内存;而接着删除第二个会破坏对应堆内存里的新内容。

使用智能指针可以避免这些问题。

在 delete 之后重置指针的值

指针会在被 delete 之后失效,其指向的动态内存也会被释放。但在很多机器中,失效的指针中会继续保存被释放内存的地址。这种指向失效动态内存的指针被称为悬挂指针dangling pointer,也被称为野指针)。访问该类指针等同于访问一个未初始化的指针。

重置此类型的指针的值可以解决一部分问题。需要注意的是,任何针对指针的操作只能在指针离开作用域之前进行。我们可以通过如下两步重置此类不再使用的指针:

  • delete 该指针
  • 在指针离开作用域之前将 nullptr 赋值给该指针

不过遗憾的是,重置了该指针只能保证该指针不会再出现问题。由于堆上分配的内存可以同时被好几个指针指向着;只重置 被 delete 的指针并不能保证其他的指针不出问题,比如:

int *p(new int(42));
auto q = p;
delete p;
p = nullptr;
此处删除并重置 pq 并没有任何影响。而且,当我们通过删除 p 释放内存后, q 也变成了悬挂指针。

shared_ptr 与 new 的配合使用

shared_ptr 可以通过 new 进行初始化:

shared_ptr<int> p2(new int(42)); //p2 points to an int with value 42
需要注意的是,shared_ptr 的构造函数是 explicit 的,因此不接受任何形式的隐式转换。这种情况下需要使用直接初始化(也就是直接括号里填初始值的)形式,来通过 new 初始化 shard_ptr:
shared_ptr<int> p1 = new int(1024);  //error, unable to convert a pointer to a smart pointer
shared_ptr<int> p2(new int(1024)); //ok, direct initialization
基于同样的原因,返回值类型为 shared_ptr 的函数也不接受隐式转换:
shared_ptr<int> clone(int p) {
    return new int(p); // error: implicit conversion to shared_ptr<int>
    return shared_ptr<int>(new int(p)); //ok, explicitly create a shared_ptr from int*
}
另外一点需要强调的是,智能指针通过 delete 释放内存。如果使用普通指针初始化智能指针,则该普通指针必须指向动态分配的内存(由 new 创建)。否则,我们必须通过自定义的操作来取代 delete 进行内存的释放操作。

不要混用智能指针与普通指针

shard_ptr 只能对为自身拷贝的对象(其他的 shared_ptr)进行析构。根据这个特性,如果在 shard_ptr 创建的时候就为其绑定动态内存,那么这片内存将无法以任何形式再次分配给其他独立的 shared_ptr。从这个观点上来看,使用 make_shared() 对 shared_ptr 进行初始化会比 new 更加安全。

来看看下面的例子:

// ptr is created and initialized when process is called
void process(shared_ptr<int> ptr) // ref_count +1, now 2
{
    // use ptr
} // ptr goes out of scope and is destroyed, ref_count -1, now 1
由于值传递复制了 ptr,那么在 process() 中,ptr 对应对象的计数至少是 2,即便在 process() 完成后,ptr 也只会因为离开作用域而计数减 1,此时其对应对象并不会被释放。

如果希望正确的使用函数 process(),那么我们需要保证传递进去的 shared_ptr 的引用计数不会被改变。我们可以通过将该 shared_ptr 作为右值赋值给某个普通指针,来达到维持引用计数平衡的效果:
shared_ptr<int> p(new int(42)); // count = 1
process(p); //count 1->2->1
int i = *p; //count = 1
但这样做导致了普通指针与 shared_ptr 的混用,而往往会带来问题。比如下面的例子中,将 shared_ptr 作为临时对象进行传递,会导致严重的问题:
int *x(new int(1024)); // dangerous: x is a plain pointer, not a smart pointer
process(x);  // error: cannot convert int* to shared_ptr<int>
process(shared_ptr<int>(x)); // legal, but the memory will be deleted!
int j = *x;  // undefined: x is a dangling pointer!
上例中:

  1. shared_ptr 使用 x 作为初始值,因此 shared_ptr 代替 x 对其绑定对象进行管理。
  2. 由于 shared_ptr 作为临时对象传递进了 process,因此在离开的时候,shared_ptr 不再有效。
  3. 此时 shared_ptr 将管理的对象直接释放掉,而 x 则成为了悬挂指针。

将普通指针绑定(作为右值赋值 / 初始化)到智能指针将导致智能指针接管对应资源的管理。当接管发生时,任何对普通指针的访问都应该避免。这种情况下,普通指针无法得知智能指针对资源的管理情况。

不要使用 get 成员初始化另外的智能指针

上面的例子引申出了一个另外的问题。智能指针拥有一个名为 get() 的成员函数。该函数返回一个 built-in 的指针,指向当前智能指针正在管理的对象。这个函数主要考虑的是解决智能指针的兼容性问题:在某些不支持智能指针的情况下,可以将智能指针的内容通过普通指针传递进去。因此,我们必须保证任意的 delete 操作都不能通过 get() 返回的普通指针进行。否则:

shared_ptr<int> p(new int(42)); // reference count is 1
int *q = p.get();  // ok: but don't use q in any way that might delete its pointer
{ // new block
    // undefined: two independent shared_ptrs point to the same memory
    auto local = shared_ptr<int>(q);
} // block ends, local is destroyed, and the memory to which p and  q points is freed
int foo = *p; // undefined; the memory to which p points was freed
上面例子中,我们通过传递的普通指针,将两个完全不同的 shared_ptr 绑定到了同一个动态对象上。由于第二个 shared_ptr 是局部变量,其自身的消亡释放了共享的动态对象。因此之后再去访问原来的 shared_ptr p,就是为定义行为。

必须确保 get() 成员返回的指针所在的代码不会有任何通过该指针(直接 / 间接)进行的 delete 操作。

get() 成员的意义是传递信息,应该被视为只读。将 get() 传达的信息通过 其他的 shared_ptr 进行释放,是一种逻辑上的错误。

【RECAP】本节讨论的两种混用指针的情况:

  • 普通指针申请的资源被作为临时对象的 shared_ptr 代管;shared_ptr 的自我销毁导致资源被释放,从而导致普通指针悬挂
  • get() 返回的 raw pointer 进行其他途径的 delete 操作,导致原有的 shared_ptr 管理的资源失效
shared_ptr 的其他操作

shared_ptr 的操作可以参考下表:



比较重要的是 reset() 成员,有如下三种功能:

  • 释放智能指针管理的内存,要求智能指针是资源的唯一管理者
  • 转交普通指针的资源给智能指针,当普通指针无资源的时候空置智能指针
  • 转交普通指针的资源给智能指针,并使用指定的方式释放普通指针

p = new int(1024); //error, cannot assign pointer to shared_ptr
p.reset(new int(1024));//ok, p now points to a new object
由于 reset() 会更新引用计数,并可以用于释放智能指针绑定的资源,利用这一点与 unique() 成员连用,在确认当前资源关联的 shared_ptr 拷贝不止一份的情况下,将当前的拷贝绑定至新的资源:
if (!p.unique()) //if p is not the only shared_ptr to the object
    p.reset(new string(*p); //it is ok to change to where p points
*p += newVal; //now p is the only shared_ptr on newVal, ok to make some changes.
可以看出来,通过 unique() 的校验,我们可以确认改变当前的 shared_ptr 不会使其原来指向的对象自动释放。

智能指针与异常

在异常抛出以后,下一步处理通常是确保异常程序使用的资源被正确的释放。使用智能指针可以容易的解决这个需求:

void f()
{
    shared_ptr<int> sp(new int(42)); // allocate a new object
    // code that throws an exception that is not caught inside f
} // shared_ptr freed automatically when the function ends
从上述例子中可以看出:

  • 如果将智能指针作为局部变量,该指针会随着函数的结束而销毁,同时释放掉指向的内存。
  • 而当异常抛出以后,函数本身会退出执行并销毁其所有局部变量。

根据以上两点,如果我们将需要的资源使用局部的智能指针进行管理,就能达到自动释放内存的效果。

为什么不推荐使用手动管理?

来看下面的程序:

void f()
{
	int *ip = new int(42); // dynamically allocate a new object
	// code that throws an exception that is not caught inside f
	delete ip; // free the memory before exiting
}
上述代码使用 delete 来释放内存。假设我们的异常发生在 newdelete 之间,我们将没有机会执行 delete. 同时,因为函数的退出, ip 对应的内存将没有机会被释放。由此可知,在异常出现的时候,手动管理内存的方式可能会导致资源无法正确释放。

实例:使用智能指针管理类的资源

类与 built-in 类型稍微有些不同;如果类本身定义了析构函数,那么析构函数会负责将类占用的内存进行释放。对于某些没有定义(或者无法拥有),可以正确处理资源释放的析构函数的类,如果异常发生在资源的分配和释放的过程中,那么很可能造成该资源的泄露。

一个解决办法是利用之前提到的,利用临时变量的自我销毁机制,以及 shared_ptr 的销毁会释放资源的机制,来确保资源最终会被妥善释放。下面的例子中,假设我们的网络库同时涉及到 C 与 C++:

struct destination; // represents what we are connecting to
struct connection; // information needed to use the connection
connection connect(destination*); // open the connection
void disconnect(connection); // close the given connection
本例中,我们主要关注 connect() 函数与 disconnect() 函数。这两个函数可以被看做是 newdelete。当程序需要建立连接时,我们调用 connect() 函数。当程序需要关闭连接时,我们调用 disconnect() 函数。那么正常的流程就应该是“建立-关闭” 连接:
void f(destination &d /* other parameters */)
{
	// get a connection; must remember to close it when done
	connection c = connect(&d);
    //close the connection.
	disconnect(c);
}
但注意,如果 connection 类对象自身不具备可以正确释放资源的析构函数,那么当我们忘记在 f() 调用之前结束链接,或是链接对象在释放之前就抛出了异常;根据局部变量的概念, c 对应的资源将永远无法释放。

我们可以通过 shared_ptr 来管理 connection 类对象来避免这种问题。由于 shared_ptr 默认指向动态内存,因此在其销毁的时候,会通过自身存储的指针调用 delete。但由于 delete 本身并不能正确释放 connection 类对象的资源,因此需要通过自定义的释放操作来完成管理。shared_ptr 允许用户提供自定义的删除操作:
shared_ptr<T>p (q,d);
其中 q 是需要绑定对象的地址d 是一个可调用对象, 也被称为 deleter。上例中,我们的 deleter 必须接受单一的,类型为 connection* 的参数:
void end_connection(connection *p) { disconnect(*p); }
这里我们创建了一个 end_connection 的接口函数,用于传递我们自定义的 deleter d。而 d 则通过自定义 disconnect() 来实现。而最终的 f() 可以改写成:
void f(destination &d)
void f(destination &d /* other parameters */)
{
    connection c = connect(&d);
    shared_ptr<connection> p(&c, end_connection);
    // use the connection
    // when f exits, even if by an exception, the connection will be properly closed
}
通过将 shared_ptr 与 connection 对象 c 绑定,并提供可以正确释放该对象资源的 deleter end_connection,shared_ptr p 会在离开作用域之后负责调用该 deleterc 关联的资源进行清理,确保即便在异常出现的情况下,也能通过 p 的自我销毁来关闭链接。

智能指针的隐患与应对

智能指针只有在正确使用的前提下才能管理好动态分配的内存。有几个准则是需要注意的:

  • 不要使用同一个普通指针初始化不同的智能指针
  • 不要 delete get() 返回的指针
  • 不要使用 get() 的返回值初始化 另外的智能指针,或者作为 reset() 的初始值
  • get() 返回的指针指向的内容受调用 get() 的智能指针影响。该内容会在对应智能指针销毁后被释放
  • 如果使用智能指针管理来源于 new 以外的动态内存,请记住传递一个 deleter 负责对该内存的释放工作。

unique_ptr

unique_ptr 是一种被视作“拥有”对象的智能指针。与 shared_ptr 不同,unique_ptr 一次只能指向一个对象。当 unique_ptr 销毁的时候,其指向的对象也会同时销毁。下表是 unique_ptr 的一些操作:

unique_ptr的初始化

定义 unique_ptr 也与 shared_ptr 稍有不同。不像 shared_ptr, unique_ptr 并没有类似 make_shared() 的函数可以使用。unique_ptr 的定义通常通过 new 返回的指针进行直接初始化

unique_ptr<double> p1; //null unique ptr, can point at a double
unique_ptr<int> p2 (new int(42)); // p2 points to int with value  42
由于 unique_ptr “拥有”其指向的对象,因此不支持普通的赋值和拷贝
unique_ptr<string> p1(new string("Stegosaurus"));
unique_ptr<string> p2(p1); // error: no copy for unique_ptr
unique_ptr<string> p3;
p3 = p2; // error: no assign for unique_ptr
不过,unique_ptr 可以通过两个成员 release()reset() 来进行拥有权的更替:
// transfers ownership from p1 (which points to the string Stegosaurus) to p2
unique_ptr<string> p2(p1.release()); // release makes p1 null
unique_ptr<string> p3(new string("Trex"));
// transfers ownership from p3 to p2
p2.reset(p3.release()); // reset deletes the memory to which p2 had pointed
release() 的作用有三个:

  • 卸除当前 unique_ptr 的拥有权
  • 将当前 unique_ptr 设置为空指针
  • 返回一个指向当前对象的指针

reset() 的带参数形式会释放当前 unique_ptr 指向的对象,并将 unique_ptr 指向参数指针指向的对象。

因此上面的过程可以解释为:

  1. 卸除了 p1 对 string Stegosaurus 的拥有权并转交给 p2,同时将 p1 空置
  2. 使 p3 与 string Trex 绑定
  3. p3realase() 空置,返回的指针通过 reset(p) 的方式转交给 p2,完成了资源的重新绑定。

很显然,这两个成员函数:

  • release() 主要用于转交当前对象的拥有权
  • reset() 主要用于重置当前 unique_ptr,使其可以接收新的绑定对象(可以是新的对象,也可以是通过 release() 传递来的拥有权)

需要注意的是, 为了正确释放被解绑的资源,release() 的返回指针通常需要一个新的智能指针来装载。不对其返回的指针进行处理则会导致资源无法被释放;如果返回指针的载体不是另外的智能指针,那么也必须通过手动 delete 该载体指针来完成资源的释放。

p2.release(); // WRONG: p2 won't free the memory and we've lost the pointer
auto p = p2.release(); // ok, but we must remember to delete(p)

传递和返回 unique_ptr

之前提到的 unique_ptr 无法被拷贝的规则有一个例外:我们可以拷贝一个即将被销毁的 unique_ptr。这种情况往往发生在我们将 unique_ptr 作为返回值的时候:

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

/* return by local object */
unique_ptr<int> clone(int p) {
    unique_ptr<int> ret(new int (p));
    // . . .
    return ret;
}
编译器在这里为 unique_ptr 定义了特殊的拷贝方法,允许返回与即将被销毁的对象绑定的 unique_ptr。

在旧的 C++ 版本中,标准库提供了 auto_ptr 类。该类拥有某些 unique_ptr 的属性;但由于 auto_ptr 无法在容器中存储,也无法返回函数,现在已经逐渐被 unique_ptr 代替。

传递 deleter 给 unique_ptr

unique_ptr 默认使用 detele 释放内存;不过也能像 shared_ptr 一样使用自定义的 deleter。不同的是,unique_ptr 使用了另外的方法来管理 deleter。

实际上,重载 unique_ptr 的 deleter 会同时影响到 unique_ptr 的类型和其构造对象(或 reset 对象)的方式。与关系容器的自定义排序规则类似,deleter 的类型需要与 unique_ptr 支持的对象类型一起写入到 unique_ptr 的三角括号中:

// object to which points by p,  has type objT;
// the deleter has type delT
// it will call an object named fcn of type delT
unique_ptr<objT, delT> p (new objT, fcn);
这里的 fcn 对应的类型是 delT,也就是我们要使用的 deleter 对象。举个例子来说,之前使用智能指针自动释放 connection 的实例,也可以使用 unique_ptr 来实现:
{
	connection c = connect(&d); // open the connection
	// when p is destroyed, the connection will be closed
	unique_ptr<connection, decltype(end_connection)*> p(&c, end_connection);
	// use the connection
	// when f exits, even if by an exception, the connection will be properly closed
}
这里使用了 decltype 来获取函数类型。由于传递函数只能通过指针,因此需要加上 *

weak_ptr

weak_ptr 通常与 shard_ptr 搭配使用。weak_ptr 指向 shard_ptr 管理的对象,但即不会影响该对象的生存周期,也不会影响该对象的计数。weak_ptr 通过一系列的工具函数来辅助 shared_ptr 的使用:



weak_ptr 的初始化与访问

weak_ptr 需要使用 shared_ptr 进行初始化:

auto p = make_shared<int>(42);
weak_ptr<int> wp(p);
由于 weak_ptr 指向的对象很可能会被 shared_ptr 释放掉,因此该对象是无法直接使用 weak_ptr 访问的。一种常见的访问方式是使用 lock() 成员来验证 weak_ptr 指向的内容是否还存在。当 lock() 返回的 shared_ptr 是 nullptr 时,该 shared_ptr 的计数值为 0,也就是其绑定对象已经被释放:
if(shared_ptr<int> np = wp.lock()) {...}; //if shared_ptr is not a nullptr, then do sth

实例:使用 weak_ptr 访问 StrBlob 类

使用 weak_ptr 最主要的目的是防止用户访问已经不存在的资源。在本例中,我们通过定义一个 StrBlobPtr 类来对 StrBlob 对象的内容进行访问。这样做不会影响 shared_ptr 对内存的管理,同时也会防止对不存在资源的意外访问。 StrBlobPtr 的数据成员主要由两个部分组成:

  • wptr:weak_ptr 指针,将与 StrBlob 的 shared_ptr 绑定,指向 StrBlob 中的 vector
  • currsize_t 类型,用于表示 vector 的下标。

除此之外,还需要几个成员:

  • check():检查两件事:访问的 vector 元素是否存在,下标是否越界。根据不同的非法访问抛出不同的异常
  • deref():通过 wptr 读取当前下标中的 vector 元素
  • incr(): StrBlobPtr 类的自增操作,目的是移动 weak_ptr,使其可以访问 vector 中下一个元素

除此之外,我们还将提供两个 StrBlob 的成员 begin()end(),利用 StrBlobPtr 对象来表示 StrBlob 的首元素指针与 off-the-end 指针。

StrBlobPtr 类的实现

构造函数的实现

由于 weak_ptr 使用 shared_ptr 进行初始化,因此我们需要指定 shared_ptr 所在的类,并将 vector 的下标也列入构造函数的初始化列表(注:练习中使用了默认构造函数+私有成员的默认值):

StrBlobPtr():curr(0) {};
StrBlobPtr(StrBlob &a, size_t sz): wptr(a.data), curr(sz) {};
Check() 成员的实现

由于 deref()incr() 成员在调用前都需要保证被访问元素的有效性,check() 函数需要优先实现。check() 会针对两种情况抛出异常:

  • 当访问的 vector 不存在时,抛出 runtime_error 异常说明解引用的目标不存在。
  • StrBlobPtr 的自增超出 vector 的长度时,抛出 out_of_range 异常说明自增越界(该检测与 StrBlob 的下标检测相同)。

同时,为了方便 deref() 使用,check() 将会返回一个 shared_ptr。该 shared_ptr 由对应的 weak_ptr 的 lock() 成员返回。

std::shared_ptr<std::vector<std::string>>
StrBlobPtr::check(std::size_t i, const std::string &msg) const
{
	auto ret = wptr.lock(); 
	if(!ret) //if the vector is invalid
		throw std::runtime_error("unbound StrBlobPtr.");
	if(i >= a.data->size()) //if the index is out of range
		throw std::out_of_range(msg);
	return ret;
}

deref() 成员的实现

deref() 成员的功能是在当前 vector 元素存在的情况下对其进行访问。由于 vector 的元素类型是 string,因此 deref() 的返回类型是 string&。同时,通过调用 check() 即可保证当前访问数据的有效性:

std::string&
StrBlobPtr::deref() const
{
	auto vsp = check(curr, "dereference past the end.");
	return (*vsp)[curr]; //using bound shared_ptr to access the current element
}
incr() 成员的实现

由于 StrBlobPtr 实际上代表了 weak_ptr,为了便于访问,提供一个自增运算用于移动 StrBlobPtr 是很有必要的。由于这里实现的是 prefix 的自增(++p),因此需要返回 StrBlobPtr 的引用类型 StrBlobPtr&。 此外,与 deref() 类似,使用 check() 成员可以保证移动操作不会越界:
StrBlobPtr&
StrBlobPtr::incr()
{
	check(curr, "increment past the end.");
	++curr;
	return *this;
}
begin() & end() 的实现

该两个成员函数是 StrBlob 的成员函数,功能类似于容器的首尾迭代器。该两个函数都是基于 StrBlobPtr 类实现的:
StrBlobPtr
StrBlob::begin()
{
	return StrBlobPtr(*this, 0);
}

StrBlobPtr
StrBlob::end()
{
	return StrBlobPtr(*this, data->size());
}
其他有两点需要注意:

  • check()deref() 都是 inspector 类型的函数,因此都应该是 const member function。并且, deref() 的返回类型是引用,在其 const 版本中需要添加 const。
  • 为了访问 StrBlob 中的私有成员,需要在 StrBlob 中添加 StrBlobPtr 为友元类:

StrBlob 
{
    friend class StrBlobPtr;
    //....
};

动态数组

当我们需要一次分配多个对象(比如当 vector reallocated 的时候)时,可以将 newdelete 与数组组合使用来实现。C++ 提供了两种方式供我们在上分配数组:

  • 使用 allocator 模板(之后介绍)
  • 使用 new 表达式分配和初始化由元素组成的数组

更多情况下,尤其是在 C++11 的标准下,我们应该使用容器分配多个堆上的对象。容器会为我们提供更好的内存管理,以及更好的性能。同时,基于容器实现的类还可以使用默认版本的拷贝、赋值与析构操作。

new 和数组

使用 new 在堆上分配数组的时候,需要指定对象的个数(数组的维度)。这里的数组维度只需要是 intergral type 即可,不是常量也没有问题。new 将会返回指向该数组头元素的指针

int *pia = new int[getsize()];
该定义也可以使用 type alias 来完成:
typedef int arrT[42]; //arrT names the type array of 42 ints
int *p = new arrT; //allocates an array of 42 ints; p points to the first one

分配数组会得到指向元素的指针

通常我们把通过 new T[] 得到的内存被称为“数组”。但实际上,这样的称呼是带有误导性的。 当分配一个堆上的数组时,我们并没有得到带有数组类型的对象;取而代之的是一个指向元素类型的指针。在这个过程中,我们并没有得到数组的维度

由于数组的维度也是数组类型的一部分,通过 new 得到的动态数组类型是不完全的。因此,基于数组的长度实现的 begin()end()range for 等操作不能用于动态数组上。

通过 new 分配的动态数组没有数组类型

初始化动态数组

与单个的动态对象的初始化一致,默认的情况下 new 为数组元素提供默认初始化。如果希望进行值初始化,需要在数组之后添加一对括号(parentheses):

int *pia = new int[10]; // block of ten uninitialized ints
int *pia2 = new int[10](); // block of ten ints value initialized to 0
string *psa = new string[10]; // block of ten empty strings
string *psa2 = new string[10](); // block of ten empty strings
C++ 11中也允许我们通过初始化列表初始化动态数组:
// block of ten ints each initialized from the corresponding initializer
int *pia3 = new int[10]{0,1,2,3,4,5,6,7,8,9};
// block of ten strings; the first four are initialized from the given initializers
// remaining elements are value initialized
string *psa3 = new string[10]{"a", "an", "the", string(3,'x')};
这种形式的初始化会有两种特殊的情况:

  • 如果初始化列表的元素数量超出分配数组的大小,那么 new 会失败,并返回 bad_array_new_length 的异常
  • 如果初始化列表的元素数量小于分配数组的大小,那么剩余的所有元素将值初始化

还需要注意的是,使用 parentheses 可以对动态数组进行值初始化,但初始值并不能添加到括号中间。这也意味着无法使用 auto 来分配动态数组。

分配空的动态数组是合法的

动态数组的维度可以使用任意的表达式来决定,当然 0 也是可以的。当数组的维度为 0 的时候,new 也会返回一个非空的有效指针:

char arr[0];            // error: cannot define a zero-length array
char *cp = new char[0]; // ok: but cp can't be dereferenced
可以保证的是,该指针与任意其他被 new 返回的指针都不一样。实际上,这个指针类似于零元素数组的 off-the-end 指针。像 end() 得到的指针一样,该指针无法被解引用,但可以用于循环判断的条件:
size_t n = get_size(); // get_size returns the number of elements needed
int* p = new int[n]; // allocate an array to hold the elements
for (int* q = p; q != p + n; ++q)
/* process the array */ ;
如果当前 n 值为 0, 上述的循环将不会执行。

释放动态数组

动态数组的释放通过一种特殊的 delete 形式来进行:

delete p; // p must point to a dynamically allocated object or be null
delete [] pa; // pa must point to a dynamically allocated array or be null
delete 后面加一对方括号的形式是专门用于释放动态的数组的。释放的顺序是反过来的,也就是最后一个元素将首先被删除。该对方括号是必要的,因为编译器需要这对括号来确定释放的指针指向的是动态数组的首元素。如果该对括号被省略,则释放动态数组的行为是 undefined 的。

需要注意的是,即便是使用 type alias 分配了动态数组,也不能省略这对方括号:
typedef int arrT[42]; // arrT names the type array of 42 ints
int *p = new arrT; // allocates an array of 42 ints; p points to the first one
delete [] p; // brackets are necessary because we allocated an array

多数情况下编译器不会对忘记添加方括号的行为提出警告。整个程序通常会以错误的方式继续运行下去。

智能指针与动态数组

标准库提供了使用 unique_ptr 管理动态数组的方案。使用 unique_ptr 的时候,其类型声明后面需要加一对方括号:

// up points to an array of ten uninitialized ints
unique_ptr<int[]> up(new int[10]);
up.release(); // automatically uses delete[] to destroy its pointer
<int[]> 表示了指针 up 指向的是一个 int 数组。因此,当 up 被销毁的时候,会调用 delete[]

需要注意的是,unique_ptr 在动态数组上的操作与在单个对象上有一些不同:

  • 指向动态数组的 unique_ptr 无法使用 . 来访问成员
  • 可以使用下标运算符 [] 来访问指定元素

因此,unique_ptr 遍历访问动态数组需要以维度 + 下标的形式实现:

for(size_t i = 0; i != 10; ++i)
    up[i] = i;


Shared_ptr 没有直接的动态数组管理

如果希望对动态数组使用 shared_ptr, 我们需要提供 deleter:
// to use a shared_ptr we must supply a deleter
shared_ptr<int> sp(new int[10], [](int *p) { delete[] p; });
sp.reset(); // uses the lambda we supplied that uses delete[] to free the array
这里使用了 lambda 作为 deleter,定义了使用 delete [] 释放 shared_ptr 对应的数组。由于 shared_ptr 默认使用 delete 释放指向的对象,当直接绑定动态数组与 shared_ptr 时,释放动态数组的行为将会是 undefined 的。

shared_ptr 不支持直接管理动态数组的特性还会影响其访问数组的方式:
// 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 built-in pointer

Allocator 类

在分配动态内存的过程中, newdelete 使用的是捆绑的操作,即分配和创建对象、释放与析构析构是同时进行的。这样的方式有时候会带来不必要的开销,比如某些按需创建对象的场景。在这种应用要求下,我们需要将动态内存的分配与对象的创建 / 析构分离开,从而达到按需付出开销的效果。下面的例子就是一个很浪费的实例:

string *const p = new string[n]; // construct n empty strings
string s;
string *q = p; // q points to the first string
while (cin >> s && q != p + n)
	*q++ = s; // assign a new value to *q
// use the array
delete[] p; // p points to an array; must remember to use delete[]
为什么推荐分离分配空间与创建对象?

按上例来说:

  • 创建而未使用的对象是一种浪费。上例中,new 分配了 n 个 string 需要的空间,但实际上我们可能用不了那么多。因此,被 new 创建的很多对象可能根本就用不到。
  • 双重写入会造成浪费。上例中,我们使用 new 分配了空间并创建了对象,但紧跟着就用新的值覆盖了这些对象。这个过程中,每个对象都被写了两遍:本例中是默认初始化和赋值。
  • 没有默认构造函数的类无法使用动态分配的数组
Allocator 类简介

Allocator 类由标准库提供,定义与 <memory>中。该类允许我们将内存分配与创建但对象分离。通过 allocator,我们可以分配没有创建过对象的的内存(raw memory)。该类的相关操作如下:



C++ 17 中,直接使用 allocator 类的成员已经不被推荐了:

While we cannot remove these members without breaking backwards compatibility with code that explicitly used this allocator type, we should not be recommending their continued use. If a type wants to support generic allocators, it should access the allocator's functionality through allocator_traits rather than directly accessing the allocator's members.

取而代之的是 allocator_traits。该模板类具有 allocator 类的绝大部分功能。

详情:Deprecate the redundant members of std::allocator

像所有模板类一样,定义 allocator 需要指定对象类型。之后,我们使用 allocate() 成员为指定的对象分配大小合适并对齐的内存块:

allocator<string> alloc; // object that can allocate strings
auto const p = alloc.allocate(n); // allocate n unconstructed strings
allocate() 返回的 p 指向申请的 raw 空间中,第一个还没有被创建对象的位置,也就是已经被创建对象的 off-the-end 指针。

allocator 的整个流程

allocator 分配的是 unconstructed(未创建对象的)的内存。为了在指定的位置创建对象,我们需要调用成员 construct()。该成员接收一个指针作为创建对象的位置(通常基于 allocate 的返回指针),并接收一系列的 arguments 用于调用该对象的构造函数。本例使用了 construct() 来创建 string 对象:

auto q = p; // q will point to one past the last constructed element
alloc.construct(q++); // *q is the empty string
alloc.construct(q++, 10, 'c'); // *q is cccccccccc
alloc.construct(q++, "hi"); // *q is hi!

之前版本的 consturct 只能接收两个参数,因此只能以拷贝的形式创建对象。

对于已经创建的对象,使用指针的一般操作形式访问即可。需要注意的是,访问 raw member 的行为是 undefined 的。

当创建的对象使用完毕之后,我们需要使用 destroy() 成员销毁这些对象。dectroy() 成员接收一个指针,并调用对象的析构函数完成销毁:

while (q != p)
    alloc.destroy(--q); //free the string we actually allocated
本例中, q 之于 p 已经向右移动了三个对象的位置。而这三个位置正好是我们之前创建的对象。通过上述的循环,我们可以将之前创建的对象都销毁掉。

destory() 只能应用于已经创建的对象。

当对象的销毁操作完毕,我们可以选择继续使用该片内存,或者是释放该片内存。如果选择释放,我们需要调用成员 decallocate()。该成员接收由 allocate() 返回的指针和定义 allcocator 时使用的大小作为参数,将之前申请的整片 raw memory 全部释放:

alloc.deallocate(p, n);
整个的流程如下图所示:



allocator 与算法

标准库提供了两种算法 uninitialized_copy()uninitialized_fill()(以及其变种)用于在 raw memory 中批量创建对象。用法如下:



比如我们想要拷贝整个 int vector 到一片内的内存中,并使得 vector 占其一半的空间,剩下全部填充为 42
// allocate twice as many elements as vi holds
auto p = alloc.allocate(vi.size() * 2);
// construct elements starting at p as copies of elements in vi
auto q = uninitialized_copy(vi.begin(), vi.end(), p);
// initialize the remaining elements to 42
uninitialized_fill_n(q, vi.size(), 42);
上例中,uninitialized_copy() 返回的是被拷贝对象的 off-the-end 指针,因此可以使用其作为 uninitialized_fill_n() 的源范围起始点。

实例:Text-Query程序

本章要求实现一个程序。该程序允许用户对指定的文件内容进行关键字查找,并将关键字出现的行,与其所在行的内容全部打印出来。格式如下:

element occurs 112 times
   (line 36) A set element contains only a key;
   (line 158) operator creates a new element
   (line 160) Regardless of whether the element
   (line 168) When we fetch an element from a map, we
   (line 214) If the element is not found, find returns

程序的设计

该程序有几个核心的要求:

  1. 程序需要在读取数据的同时,记录下所有词对应的行。
  2. 对应的行需要按升序排列,并没有重复。
  3. 程序需要打印出关键词所在行,与其对应的内容。

对应的解决方案:

  1. 使用 istringstream 拆分输入。
  2. 使用 vector 存储读取所有的文本数据,并使用 set 存储行号。当需要访问对应行号的内容时,使用行号作为下标访问 vector 即可。
  3. 行号使用 set 存储即可解决排序与重复的问题。
  4. 使用 map 将关键词与对应的行号集合 set 关联起来。
使用类数据结构

本例中使用了两个类作为数据的载体:

  • TextQuery 类,存储关键词 map 与文本数据 vector,并负责搜寻关键词对应行号,保存到 QueryResult 的 set 值中。
  • QueryResult 类,存储关键词对应的行号 set,并打印当前查询结果。

由于 QueryResult 需要打印存储在 TextQuery 中的内容,这两个类之间需要进行数据共享。有几种方式:

  • 拷贝 vector 和 map 中的 set 部分。不推荐这么做,因为拷贝开销太大。
  • 使用指针传递上述数据。不推荐这么做,因为普通指针无法保证共享数据的有效性,很可能会产生悬挂指针。
  • 使用 shared_ptr 传递数据。推荐。

因此,将本程序中需要共享的部分以 shared_ptr 的方式传递,我们可以得到如下图所示的简单关系图:



TextQuery 类实现

除了基本的数据成员之外,TextQuery 还需要构建关键词 map 与 输出关键词对应 set 的功能。构建关键词 map 的功能可以整合到构造函数中,而输出对应 set 的功能由 query() 函数实现:

class QueryResult; // declaration needed for return type in the query function
class TextQuery {
public:
    using line_no = std::vector<std::string>::size_type;
    TextQuery(std::ifstream&);
    QueryResult query(const std::string&) const;
private:
    std::shared_ptr<std::vector<std::string>> file;  // input file
    // map of each word to the set of the lines in which that word appears
    std::map<std::string,
             std::shared_ptr<std::set<line_no>>> wm;
};

TextQuery 构造函数的实现

TextQuery 的构造函数需要生成 map,并将关键词 map 与对应的文本储存到指定的数据成员中。整个构造过程可以描述为如下:

  1. 构造函数接受一个 ifstream 对象,用于读取文本
  2. 使用 getline 对每一行文本单独进行读取
  3. 将该行文本存储到 vector 成员中
  4. 使用 istringstream 对该行文本按为单位继续进行拆分
  5. 使用 shared_ptr 判断该词是否存在于 map 中。如果是第一次见到,则为该词对应行号 set 申请堆上的空间
  6. 使用 set 的 insert() 成员将对应的行号插入到已经申请的 set 中。由于 set 的特性, insert() 会自动忽略重复的行号:

实现代码如下:

// read the input file and build the map of lines to line numbers
TextQuery::TextQuery(ifstream &is): file(new vector<string>)
{
    string text;
    while (getline(is, text)) {       // for each line in the file
        file->push_back(text);        // remember this line of text
        int n = file->size() - 1;     // the current line number
        istringstream line(text);     // separate the line into words
        string word;
        while (line >> word) {        // for each word in that line
            // if word isn't already in wm, subscripting adds a new entry
            auto &lines = wm[word]; // lines is a shared_ptr
            if (!lines) // that pointer is null the first time we see word
                lines.reset(new set<line_no>); // allocate a new set
            lines->insert(n);      // insert this line number
        }
    }
}
几个需要注意的小细节:

  • 修改 set 直接使用了 shared_ptr 的引用
  • shared_ptr 可以使用 reset() 成员申请堆上的空间
  • 行号使用了 n = file→size() - 1 来生成。由于每一次循环都意味着换了一行,这里实际上可以使用初始值为 1 的临时变量来代替书上的写法:

lineNo lineNum {1}; //started at the line 1
for (string text; getline(is, text); ++lineNum)
{
    //....
}//move to the next line

query 成员函数的实现

query() 成员的目标是根据指定的关键词,输出对应关键词的行号 set 与对应的文本内容。可见该函数是一个只读函数,因此应该定义为 const member。同时,根据之前的设想,query 输出的内容会存储到 QueryResult 类中;因此,query 的返回类型应该是 QueryResult type。 query() 的处理逻辑如下:

  1. 在关键词 map 中寻找指定关键词
  2. 如果找到,返回包含对应行号 set 的 QueryResult 对象。
  3. 否则,返回包含 set 的 QueryResult 对象。

实现代码如下:

QueryResult
TextQuery::query(const string &sought) const
{
    // we'll return a pointer to this set if we don't find sought
    static shared_ptr<set<line_no>> nodata(new set<line_no>);
    // use find and not a subscript to avoid adding words to wm!
    auto loc = wm.find(sought);
    if (loc == wm.end())
        return QueryResult(sought, nodata, file); // not found
    else
        return QueryResult(sought, loc->second, file);
}
几个注意点:

  • 空 set 是通过一个静态的 shared_ptr 指针成员来实现的。这样写可以使所有的 QueryResult 类对象都可以使用该值来返回空 set。
  • 这里的 file 是用于共享的文本数据,无论是否找到关键词,都需要作为 QueryResult 的一部分。

QueryResult 类的实现

QueryResult 的类用于装载 query() 得到结果并将数据交于 print() 打印,因此只需要使用接收到的数据构造 QueryResult 对象即可。QueryResult 的内容有:

  • 关键词
  • 关键词对应的行号 set
  • 数据文本 vector

因此,QueryResult 的构造需要上述三个参数。实现代码如下:

class QueryResult {
friend std::ostream& print(std::ostream&, const QueryResult&);
public:
    QueryResult(std::string s,
                std::shared_ptr<std::set<line_no>> p,
                std::shared_ptr<std::vector<std::string>> f):
        sought(s), lines(p), file(f) { }
private:
    std::string sought;  // word this query represents
    std::shared_ptr<std::set<line_no>> lines; // lines it's on
    std::shared_ptr<std::vector<std::string>> file; // input file
};

Print() 函数接收 QueryResult() 的数据并打印结果,因此参数需要有 QueryResult 类对象。更具体的来说,该 QueryResult() 来源于 query() 的结果。由于 query() 返回的是 const QueryResult,这里使用 const QueryResult& 作为接收方法。同时,按照一贯的手法,输出函数都会接收并返回一个 ostream 对象:

ostream &print(ostream & os, const QueryResult &qr)
{
    // if the word was found, print the count and all occurrences
    os << qr.sought << " occurs " << qr.lines->size() << " "
       << make_plural(qr.lines->size(), "time", "s") << endl;
    // print each line in which the word appeared
    for (auto num : *qr.lines) // for every element in the set
        // don't confound the user with text lines starting at 0
        os << "\t(line " << num + 1 << ") "
           << *(qr.file->begin() + num) << endl;
    return os;
}
qr.file→begin() + num 这一行可以通过直接取对应 set 行数的下标来实现。由于行号计数从 1 开始,因此下标需要减掉 1:
(*qr.file)[num - 1];
如果行号从 0 开始计数,则不需要 -1