C++ Primer 笔记 第十二章
在 C++ 中,非常多的对象都是由编译器代理管理的;这些对象都具有完整定义的生命周期;比如全局对象(Global Object)、本地静态对象(Local static obejct )和自动对象(Automatic object)等等。
除了这些编译器管理的对象以外,C++ 还允许用户定义对象的生命周期。这种对象的生命周期与其在哪里创建并没有关系;只有当其被显式的释放的时候,其生命周期才结束。我们称这种对象为动态对象(Dynamic Object);而为该对象划分内存的操作称为动态分配(Dynamic Allocate)。
遗憾的是,在 C++ 中,手动分配与释放内存是一项很容易造成 Bug 的操作。为了减少该类操作带来的影响,C++ 标准库提供了两种类型的智能指针(Smart Pointer)来管理动态分配的对象。智能指针可以确保其指向的对象在需要的时候被自动释放。
在 C++ 中,内存的类型主要分为三种:
在这三种内存中,对象的生命周期都不一样:
传统的 C++ 中,动态内存通过关键字 new
和 delete
来分配与释放:
new
分配给对象动态内存(可能附带初始化操作),并返回一个指向该对象的指针delete
通过该指针摧毁该对象,并释放其所占的内存。
这样做带来的问题是如何在恰当的时间释放内存。忘记释放内存可能导致内存泄露;而在指针仍然有效的时候释放内存会导致野指针(Wild pointer)。
C++ 11 中提供了智能指针类型来对动态对象进行管理。智能指针与普通指针的功能相同;唯一的不同点在于智能制造能会自动删除对象并释放内存。标准库定义了如下三种智能指针类型:
以上三种智能指针类型定义于 <memory>
头文件中。
智能指针类型属于模板类型,因此在创建指针的时候也需要指定额外的类型。该类型用于表示智能指针指向对象的类型:
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
}
智能指针的一些操作如下:
<img src=“/_media/programming/cpp/cpp_primer/share_unique_ptr_common_op.svg” width=“650”>
</html>
<html>
<img src=“/_media/programming/cpp/cpp_primer/sptr_op.svg” width=“650”>
</html>
如果进行分配或使用动态内存,最安全的方法是使用 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() 参数的传递必须符合其构造函数要求。如果没有传递任何参数,则该对象会进行值初始化。
auto
来推断:
// p6 points to a dynamically allocated, empty vector<string>
auto p6 = make_shared<vector<string>>();
在对 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 的计数降低到 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 实际上是通过调用本身的析构函数(Destructor)来实现的。shared_ptr 自身的析构函数会减少当前 shared_ptr 指向该对象的计数值。当该数值为 0
的时候,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
该版本中:
p
的一份拷贝,导致 p
对 factory 生成对象的计数再加 1
。p
的自我销毁无法清零引用计数,因为调用 user_factory 的调用者作为另外一个 user 保留了剩余的一点计数我们需要确保需要被释放的对象不会与任何 shard_ptr 绑定。当然,如果忘记销毁不需要的 shared_ptr,程序也会正常的运行;只是被 shared_ptr 分配的那一部分内存就会一直浪费下去。
一个常见的忘记销毁 shared_ptr 的场景:将 shared_ptr 存入容器中,然后改变了容器元素的顺序。这种情况下一定记得使用 erase 删除不需要的 shared_ptr 元素。
通常在如下三种情况下,程序会倾向于使用动态内存:
我们在容器中见到过头两种情况。来看看第三类的情况:
正常情况下,对象之间并不会共享数据。比如拷贝一个 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 依然存在。
来看看上述的这种 Blob 类(此处改名为 StrBlob)如何实现。当然,最简单的办法就是从标准库的容器开始,利用容器来为我们管理内存。这里我们使用 vector。
但根据上例,有两个问题需要处理:
b2
作为局部变量,在离开其作用域后,其关联的资源会被一并释放掉
可以看出来的是,达成共享的第一步就是需要保证资源的存在。我们的解决方案是将 vector 存储到动态内存中,并使用 shared_ptr 对其进行管理。shared_ptr 会跟踪 StrBlob 对象的数量,确保在所有类对象都销毁的前提下才会释放共享的 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;
};
本类中的两个构造函数都将通过初始化列表来对管理 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 个:
由于这三个操作的前提都是元素必须存在,我们使用私有成员函数 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 的拷贝、赋值和销毁操作,都是基于对 shared_ptr data
的操作。因此,被构造函数分配的底层 vector 也将在最后一个 strBlob 销毁的时候被释放。
C++ 提供了两个运算符供用户进行手动分配与释放内存:
new
运算符,负责分配内存delete
运算符,负责释放内存需要注意的是,使用这一对运算符需要:
很显然,使用智能指针分配资源是更可靠的一种方式。
使用 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 上:
当括号中包含了 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 的对象:
//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 notrhow
。 nothrow
代表不抛出异常,它与 bad_alloc
异常一起被定义于头文件 <new>
中。
为了避免内存耗尽,内存需要在使用完之后释放。C++ 中通过 delete
表达式来释放内存。该表达式接收一个指针,并释放指针对应的对象所占用的内存:
delete p; //destroyed the object to which p points and freed the memory it took
delete
所接的指针必须指向已经分配的动态内存,或是一个空指针。如下两种使用 delete
删除指针的行为是未定义的:
new
分配内存的指针
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
。
使用智能指针可以避免这些问题。
指针会在被 delete 之后失效,其指向的动态内存也会被释放。但在很多机器中,失效的指针中会继续保存被释放内存的地址。这种指向失效动态内存的指针被称为悬挂指针(dangling pointer,也被称为野指针)。访问该类指针等同于访问一个未初始化的指针。
重置此类型的指针的值可以解决一部分问题。需要注意的是,任何针对指针的操作只能在指针离开作用域之前进行。我们可以通过如下两步重置此类不再使用的指针:
nullptr
赋值给该指针不过遗憾的是,重置了该指针只能保证该指针不会再出现问题。由于堆上分配的内存可以同时被好几个指针指向着;只重置 被 delete 的指针并不能保证其他的指针不出问题,比如:
int *p(new int(42));
auto q = p;
delete p;
p = nullptr;
此处删除并重置 p
对 q
并没有任何影响。而且,当我们通过删除 p
释放内存后, q
也变成了悬挂指针。
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!
上例中:
x
作为初始值,因此 shared_ptr 代替 x
对其绑定对象进行管理。x
则成为了悬挂指针。将普通指针绑定(作为右值赋值 / 初始化)到智能指针将导致智能指针接管对应资源的管理。当接管发生时,任何对普通指针的访问都应该避免。这种情况下,普通指针无法得知智能指针对资源的管理情况。
上面的例子引申出了一个另外的问题。智能指针拥有一个名为 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 的操作可以参考下表:
<html>
<img src=“/_media/programming/cpp/cpp_primer/shared_ptr_addi_op.svg” width=“650”>
</html>
比较重要的是 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
来释放内存。假设我们的异常发生在 new
与 delete
之间,我们将没有机会执行 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() 函数。这两个函数可以被看做是 new
与 delete
。当程序需要建立连接时,我们调用 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
对应的资源将永远无法释放。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
会在离开作用域之后负责调用该 deleter 对 c
关联的资源进行清理,确保即便在异常出现的情况下,也能通过 p
的自我销毁来关闭链接。
智能指针只有在正确使用的前提下才能管理好动态分配的内存。有几个准则是需要注意的:
delete
get() 返回的指针new
以外的动态内存,请记住传递一个 deleter
负责对该内存的释放工作。
unique_ptr 是一种被视作“拥有”对象的智能指针。与 shared_ptr 不同,unique_ptr 一次只能指向一个对象。当 unique_ptr 销毁的时候,其指向的对象也会同时销毁。下表是 unique_ptr 的一些操作:
<html>
<img src=“/_media/programming/cpp/cpp_primer/unique_ptr_op.svg” width=“650”>
</html>
定义 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() 的作用有三个:
reset() 的带参数形式会释放当前 unique_ptr 指向的对象,并将 unique_ptr 指向参数指针指向的对象。
因此上面的过程可以解释为:
p1
对 string Stegosaurus
的拥有权并转交给 p2
,同时将 p1
空置p3
与 string Trex
绑定p3
被 realase()
空置,返回的指针通过 reset(p)
的方式转交给 p2
,完成了资源的重新绑定。很显然,这两个成员函数:
需要注意的是, 为了正确释放被解绑的资源,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 作为返回值的时候:
/* 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 代替。
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 通常与 shard_ptr 搭配使用。weak_ptr 指向 shard_ptr 管理的对象,但即不会影响该对象的生存周期,也不会影响该对象的计数。weak_ptr 通过一系列的工具函数来辅助 shared_ptr 的使用:
<html>
<img src=“/_media/programming/cpp/cpp_primer/wptr_op.svg” width=“550”>
</html>
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 最主要的目的是防止用户访问已经不存在的资源。在本例中,我们通过定义一个 StrBlobPtr 类来对 StrBlob 对象的内容进行访问。这样做不会影响 shared_ptr 对内存的管理,同时也会防止对不存在资源的意外访问。 StrBlobPtr 的数据成员主要由两个部分组成:
size_t
类型,用于表示 vector 的下标。除此之外,还需要几个成员:
wptr
读取当前下标中的 vector 元素除此之外,我们还将提供两个 StrBlob 的成员 begin() 和 end(),利用 StrBlobPtr 对象来表示 StrBlob 的首元素指针与 off-the-end 指针。
构造函数的实现
由于 weak_ptr 使用 shared_ptr 进行初始化,因此我们需要指定 shared_ptr 所在的类,并将 vector 的下标也列入构造函数的初始化列表(注:练习中使用了默认构造函数+私有成员的默认值):
StrBlobPtr():curr(0) {};
StrBlobPtr(StrBlob &a, size_t sz): wptr(a.data), curr(sz) {};
Check() 成员的实现 同时,为了方便 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&
。 此外,与 deref() 类似,使用 check() 成员可以保证移动操作不会越界:
StrBlobPtr&
StrBlobPtr::incr()
{
check(curr, "increment past the end.");
++curr;
return *this;
}
begin() & end() 的实现 StrBlobPtr
StrBlob::begin()
{
return StrBlobPtr(*this, 0);
}
StrBlobPtr
StrBlob::end()
{
return StrBlobPtr(*this, data->size());
}
其他有两点需要注意:
StrBlob
{
friend class StrBlobPtr;
//....
};
当我们需要一次分配多个对象(比如当 vector reallocated 的时候)时,可以将 new
、delete
与数组组合使用来实现。C++ 提供了两种方式供我们在堆上分配数组:
new
表达式分配和初始化由元素组成的数组更多情况下,尤其是在 C++11 的标准下,我们应该使用容器分配多个堆上的对象。容器会为我们提供更好的内存管理,以及更好的性能。同时,基于容器实现的类还可以使用默认版本的拷贝、赋值与析构操作。
使用 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 的。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 遍历访问动态数组需要以维度 + 下标的形式实现:
for(size_t i = 0; i != 10; ++i)
up[i] = i;
<html>
<img src=“/_media/programming/cpp/cpp_primer/unique_ptr_array.svg” width=“600”>
</html>
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_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
在分配动态内存的过程中, new
和 delete
使用的是捆绑的操作,即分配和创建对象、释放与析构析构是同时进行的。这样的方式有时候会带来不必要的开销,比如某些按需创建对象的场景。在这种应用要求下,我们需要将动态内存的分配与对象的创建 / 析构分离开,从而达到按需付出开销的效果。下面的例子就是一个很浪费的实例:
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 类由标准库提供,定义与 <memory>
中。该类允许我们将内存分配与创建但对象分离。通过 allocator,我们可以分配没有创建过对象的的内存(raw memory)。该类的相关操作如下:
<html>
<img src=“/_media/programming/cpp/cpp_primer/alloc_op.svg ” width=“650”>
</html>
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 分配的是 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);
整个的流程如下图所示:
<img src=“/_media/programming/cpp/cpp_primer/alloc_workflow.svg” width=“300”>
</html>
标准库提供了两种算法 uninitialized_copy() 与 uninitialized_fill()(以及其变种)用于在 raw memory 中批量创建对象。用法如下:
<html>
<img src=“/_media/programming/cpp/cpp_primer/alloc_algorithm.svg ” width=“600”>
</html>
比如我们想要拷贝整个 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() 的源范围起始点。
本章要求实现一个程序。该程序允许用户对指定的文件内容进行关键字查找,并将关键字出现的行,与其所在行的内容全部打印出来。格式如下:
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
该程序有几个核心的要求:
对应的解决方案:
本例中使用了两个类作为数据的载体:
由于 QueryResult 需要打印存储在 TextQuery 中的内容,这两个类之间需要进行数据共享。有几种方式:
因此,将本程序中需要共享的部分以 shared_ptr 的方式传递,我们可以得到如下图所示的简单关系图:
<html>
<img src=“/_media/programming/cpp/cpp_primer/relationship_query_1.svg” width=“300”>
</html>
除了基本的数据成员之外,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 的构造函数需要生成 map,并将关键词 map 与对应的文本储存到指定的数据成员中。整个构造过程可以描述为如下:
ifstream
对象,用于读取文本getline
对每一行文本单独进行读取istringstream
对该行文本按词为单位继续进行拆分实现代码如下:
// 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
}
}
}
几个需要注意的小细节:
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() 成员的目标是根据指定的关键词,输出对应关键词的行号 set 与对应的文本内容。可见该函数是一个只读函数,因此应该定义为 const member。同时,根据之前的设想,query 输出的内容会存储到 QueryResult 类中;因此,query 的返回类型应该是 QueryResult type。 query() 的处理逻辑如下:
实现代码如下:
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);
}
几个注意点:
file
是用于共享的文本数据,无论是否找到关键词,都需要作为 QueryResult 的一部分。QueryResult 的类用于装载 query() 得到结果并将数据交于 print() 打印,因此只需要使用接收到的数据构造 QueryResult 对象即可。QueryResult 的内容有:
因此,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
。