shared_ptr类(C++ Primer 12.1.1)
类似vector,智能指针也是模板(参见3.3节)。因此,当我们创建一个智能指针时,必须提供额外的信息——指针可以指向的类型。与vector一样,我们在尖括号内给出类型,之后是所定义的这种智能指针的名字:
- shared_ptr<string> p1; // shared_ptr,可以指向string
- shared_ptr<list<int>> p2; // shared_ptr,可以指向int的list
默认初始化的智能指针中保存着一个空指针(参见2.3.2)。在12.1.3节中,我们将介绍初始化智能指针的其他方法。
智能指针的使用方式与普通指针类似。解引用一个智能指针返回它指向的对象。如果在一个条件判断中使用智能指针,效果就是检查它是否为空:
- // 如果p1不为空,检查它是否指向一个空string
- if (p1 && p1->empty())
- *p1 = "h1"; // 如果p1指向一个空string,解引用p1,将一个新值赋予string
表12.1列出了shared_ptr和unique_ptr都支持的操作。只适用于shared_ptr的操作列于表12.2中。
shared_ptr<T> sp | 空智能指针,可以指向类型为T的对象 |
unique_ptr<T> sp | |
p | 将p用作一个条件判断,若p指向一个对象,则为true |
*p | 解引用p,获得它指向的对象 |
p->mem | 等价于(*p).mem |
p.get | 返回p中保存的指针。要小心使用,若智能指针释放了其对象,返回的指针所指向的对象也就消失了 |
swap(p,q) | 交换p和q中的指针 |
p.swap(q) |
shared_ptr<T>(args) | 返回一个shared_ptr,指向一个动态分配的类型T的对象。使用args初始化此对象 |
unique_ptr<T> p(q) | p是shared_ptr q的拷贝;此操作会递增q中的计数器。q中的指针必须能转换为T* |
p=q | p和q都是shared_ptr,所保存的指针能相关转化。此操作会递减p的引用计数,递增q的引用计数;若p的引用计数变为0,则将其管理的原内存释放 |
p.unique() | 若p.use_count()为1,返回true;否则返回false |
p.use_count() | 返回与p共享的智能指针数量;可能很慢,主要用于调试 |
make_shared函数
最安全的分配和使用动态内存的方法是调用一个名为make_shared的标准库函数。此函数在动态内存中分配一个对象并初始化它,返回指向此对象的shared_ptr。与智能指针一样,make_shared也定义在头文件memory中。
当要用make_shared时,必须指定想要创建的对象的类型。定义方式与模板类相同,在函数名之后跟一个尖括号,在其中给出类型:
- // 指向一个值为42的int的shared_ptr
- shared_ptr<int> p3 = make_shared<int>(42);
- // p4指向一个值为"9999999999"的string
- shared_ptr<string> p4 = make_shared<string>(10, '9');
- // p5指向一个值初始化的(参见3.3.1节)int,即,值为0
- shared_ptr<int> p5 = make_shared<int>();
类似顺序容器的emplace成员(参见9.3.1节),make_shared用其参数来构造给定类型的对象。例如,调用make_shared<string>时传递的参数必须与string的某个构造函数相匹配,调用make_shared<int>时传递的参数必须能用来初始化一个int,以此类推。如果我们不传递任何参数,对象就会进行值初始化。
当然,我们通常用auto(参见2.5.2)定义一个对象来保存make_shared的结果,这种方式较为简单:
- // p6指向一个动态分配的空vector<string>
- auto p6 = make_shared<vector<string>>();
shared_ptr的拷贝和赋值
当进行拷贝或赋值操作时,每个shared_ptr都会记录有多少个其他shared_ptr指向相同的对象:
- auto p = make_shared<int>(42); // p指向的对象只有p一个引用者
- auto q(p); // p和q指向相同对象,此对象有两个引用者
我们可以认为每个shared_ptr都有一个关联的计数器,通常称其为引用计数(reference count)。无论何时我们拷贝一个shared_ptr,计数器都会增加。例如,当用一个shared_ptr初始化另一个shared_ptr,或将它作为参数传递给一个函数(参加6.2.1节)以及作为函数的返回值时,它所关联的计数器就会增加。当我们给shared_ptr赋予一个新值或是shared_ptr被销毁(例如一个局部的shared_ptr离开其作用域)时,计数器就会递减。
一旦一个shared_ptr的计数器变为0,它就会自动释放自己管理的对象:
- auto r = make_shared<int>(42); // r指向的int只有一个引用者
- r = q; // 给r赋值,令它指向另一个地址
- // 递增q指向的对象的引用计数
- // 递减r原来指向的对象的引用计数
- // r原来指向的对象已没有引用者,会自动释放
此例中我们分配了一个int,将其指针保存在r中。接下来,我们将一个新值赋予r。在此情况下,r时唯一指向此int的shared_ptr,在把q赋给r的过程中,此int被自动释放。
到底是用一个计数器还是其他数据结构来记录有多少指针共享对象,完全由标准库的具体实现来决定。关键是智能指针类能记录有多少个shared_ptr指向相同的对象,并能在恰当的时候自动释放对象。
shared_ptr自动销毁所管理的对象……
当指向一个对象的最后一个shared_ptr被销毁时,shared_ptr类会自动销毁此对象。它是通过另一个特殊的成员函数——析构函数(destructor)完成销毁工作的。类似于构造函数,每个类都有一个析构函数。就像构造函数控制初始化一样,析构函数控制此类型的对象销毁时做什么操作。
析构函数一般用来释放对象所分配的资源。例如,string的构造函数(以及其他string成员)会分配内存来保存构成string的字符。string的析构函数就负责释放这些内存。类似的,vector的若干操作都会分配内存来保存其元素。vector的析构函数就负责销毁这些元素,并释放它们所占用的内存。
shared_ptr的析构函数会递减它所指向的对象的引用计数。如果引用计数变为0,shared_ptr的析构函数就会销毁对象,并释放它占用的内存。
……shared_ptr还会自动释放相关联的内存
当动态对象不再被使用时,shared_ptr类会自动释放动态对象,这一特性使得动态内存的使用变得非常容易。例如,我们可能有一个函数,它返回一个shared_ptr,指向一个Foo类型的动态分配的对象,对象是通过一个类型为T的参数进行初始化的:
- // factory返回一个shared_ptr,指向一个动态分配的对象
- shared_ptr<Foo> factory(T arg)
- {
- // 恰当处理arg
- // shared_ptr负责释放内存
- return make_shared<Foo>(arg);
- }
由于factory返回一个shared_ptr,所以我们可以确保它分配的对象会在恰当的时刻被释放。例如,下面的函数将factory返回的shared_ptr保存在局部变量中:
- void use_factory(T arg)
- {
- shared_ptr<Foo> p = factory(arg);
- // 使用p
- } // p离开了作用域,它指向的内存会被自动释放掉
由于p是use_factory的局部变量,在use_factory结束时它将被销毁。当p被销毁时,将递减其引用计数并检查它是否为0。在此例中,p是唯一引用factory返回的内存的对象。由于p将要销毁,p指向的这个对象也会被销毁,所占用的内存会被释放。
但如果有其他shared_ptr也指向这块内存,它就不会被释放掉:
- shared_ptr<Foo> use_factory(T arg)
- {
- shared_ptr<Foo> p = factory(arg);
- // 使用p
- return p; // 当我们返回p时,引用计数进行了递增操作
- } // p离开了作用域,但它指向的内存不会被自动释放掉
在此版本中,use_factory中的return语句向此函数的调用者返回一个p的拷贝。拷贝一个shared_ptr会增加所管理对象的引用计数值。现在当p被销毁时,它所指向的内存还有其他使用者。对于一块内存,shared_ptr类保证只要有任何shared_ptr对象引用它,它就不会被释放掉。
由于在最后一个shared_ptr销毁前内存不会释放,保证shared_ptr在无用之后不再保留就非常重要了。如果你忘记了销毁程序不再需要的shared_ptr,程序仍会正常执行,但会浪费内存。shared_ptr在无用之后仍然保留的一种可能情况是,你将shared_ptr存放在一个容器中,随后重排了容器,从而不再需要某些元素。在这种情况下,你应该确保用erase删除那些不再需要的shared_ptr元素。
如果你将shared_ptr存放于一个容器中,而后不再需要全部元素,而只使用其中一部分,要记得用erase删除不再需要的那些元素。
使用了动态生存期的资源的类
程序使用动态内存出于以下三种原因之一:
1. 程序不知道自己需要使用多少对象
2. 程序不知道所需对象的准确类型
3. 程序需要在多个对象间共享数据
容器类是出于第一种原因而使用动态内存的典型例子,我们将在第15章看到出于第二种原因而使用动态内存的例子。在本节中,我们将定义一个类,它使用动态内存是为了让多个对象共享相同的底层数据。
到目前为止,我们使用过的类中,分配的资源都与对应对象生存期一致。例如,每个vector“拥有”其自己的元素。当我们拷贝一个vector时,原vector和副本vector中的元素是相互分离的:
- vector<string> v1; // 空vector
- { // 新作用域
- vector<string> v2 = {"a", "an", "the"};
- v1 = v2; // 从v2拷贝元素到v1中
- } // v2被销毁,其中的元素也被销毁
- // v1有三个元素,是原来v2中元素的拷贝
由一个vector分配的元素只有当这个vector存在时才存在。当一个vector被销毁时,这个vector中的元素也都被销毁。
但某些类分配的资源具有与原对象相独立的生存期。例如,假定我们希望定义一个名为Blob的类,保存一组元素。与容器不同,我们希望Blob对象的不同拷贝之间共享相同的元素。即,当我们拷贝一个Blob时,原Blob对象及其拷贝应该引用相同的底层元素。
一般而言,如果两个对象共享底层的数据,当某个对象被销毁时,我们不能单方面地销毁底层数据:
- Blob<string> b1; // 空Blob
- { // 新作用域
- Blob<string> b2 = { "a", "an", "the"};
- b1 = b2; // b1和b2共享相同的元素
- } // b2被销毁了,但b2中的元素不能销毁
- // b1指向最初由b2创建的元素
在此例中,b1和b2共享相同的元素。当b2离开作用域时,这些元素必须保留,因为b1仍然在使用它们。
使用动态内存的一个常见原因是允许多个对象共享相同的状态。
定义StrBlob类
最终,我们会将Blob类实现为一个模板,但我们直到16.1.2节才会学习模板的相关知识。因此,现在我们先定义一个管理string的类,此版本命名为StrBlob。
实现一个新的集合类型最简单方法是使用某个标准库容器来管理元素。采用这种方法,我们可以借助标准库类型来管理元素所使用的内存空间。在本里中,我们将使用vector来保存元素。
但是,我们不能在一个Blob对象内直接保存vector,因为一个对象的成员在对象销毁时也会被销毁。例如,假定b1和b2是两个Blob对象,共享相同的vector。如果此vector保存在其中一个Blob中——例如b2中,那么当b2离开作用域时,此vector也将被销毁,也就是说其中的元素都将不复存在。为了保证vector中的元素继续存在,我们将vector保存在动态内存中。
为了实现我们所希望的数据共享,我们为每个StrBlob设置一个shared_ptr来管理动态分配的vector。此shared_ptr的成员将记录有多少个StrBlob共享相同的vector,并在vector的最后一个使用者被销毁时释放vector。
我们还需要确定这个类应该提供什么操作。当前,我们实现一个vector操作的小的子集。我们会修改访问元素的操作(如front和back):在我们的类中,如果用户试图访问不存在的元素,这些操作会抛出一个异常。
我们的类有一个默认构造函数和一个构造函数,接受单一的initializer_list<string>类型参数(参见6.2.6节)。此构造函数可以接受一个初始化器的花括号列表。
- class StrBlob
- {
- public:
- typedef std::vector<std::string>::size_type size_type;
- StrBlob();
- StrBlob(std::initializer_list<std::string> il);
- 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;
- // 如果data[i]不合法,抛出一个异常
- void check(size_type i, const std::string& msg) const;
- };
在此类中,我们实现了size、empty和push_back成员。这些成员通过指向底层vector的data成员来完成它们的工作。例如,对一个StrBlob对象调用size()会调用data->size(),依此类推。
StrBlob构造函数
两个构造函数都使用初始化列表(参见7.1.4节)来初始化其data成员,令它指向一个动态分配的vector。默认构造函数分配一个空vector:
- StrBlob::StrBlob() : data(make_shared<vector<string>>()) {}
- StrBlob::StrBlob(std::initializer_list<std::string> il) : data(make_shared<vector<string>>(il)) {}
接受一个initializer_list的构造函数将其参数传递给对应的vector构造函数。此构造函数通过拷贝列表中的值来初始化vector的元素。
元素访问成员函数
pop_back、front和back操作访问vector中的元素。这些操作在试图访问元素之前必须检查元素是否存在。由于这些成员函数需要做相同的检查操作,我们为StrBlob定义了一个名为check的private工具函数,它检查一个给定索引是否在合法范围内。除了索引,check还接受一个string参数,它会将参数传递给异常处理程序,这个string描述了错误内容:
- void StrBlob::check(size_type i, const std::string& msg) const
- {
- if (i >= data->size())
- throw out_of_range(msg);
- }
pop_back和元素访问成员函数首先调用check。如果check成功,这些成员函数继续利用底层vector的操作来完成自己的工作:
- string& StrBlob::front()
- {
- check(0, "front on empty StrBlob");
- return data->front();
- }
- 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();
- }
front和back应该对const进行重载(参见7.3.2),这些版本的定义留作练习。
StrBlob的拷贝、赋值和销毁
类似Sales_data类,StrBlob使用默认版本的拷贝、赋值和销毁成员函数来对此类型的对象进行这些操作。默认情况下,这些操作拷贝、赋值和销毁类的数据成员。我们的StrBlob类只有一个数据成员,它是shared_ptr类型。因此,当我们拷贝、赋值或销毁一个StrBlob对象时,它的shared_ptr成员会被拷贝、赋值或销毁。
如前所见,拷贝一个shared_ptr会递增其引用计数;将一个shared_ptr赋予另一个shared_ptr会递增赋值号右侧shared_ptr的引用计数,而递减左侧shared_ptr的引用计数。如果一个shared_ptr的引用计数变为0,它所指向的对象会被自动销毁。因此,对于由StrBlob构造函数分配的vector,当最后一个指向它的StrBlob对象被销毁时,它会随之被自动销毁。
思考和总结
在学习了shared_ptr之后,最疑惑的是shared_ptr是怎么实现统计指向相同内存的shared_ptr的个数的。在牛客上查了智能指针的实现,发现引用计数是通过同一个地址保存维护的。这样拷贝、赋值和销毁操作都作用在同一地址上,shared_ptr的机制就说的通了。
由于shared_ptr的源码看不懂,以下用实验的手段验证一下上述的想法,使用cl -d1 reportSingleClassLayout查看类的分布。首先以shared_ptr<int>为例查看shared_ptr的结构:
可以看到shared_ptr的数据成员并不复杂,_Ptr指向原始地址,_Rep指向引用计数相关数据结构,_Rep的结构如下,_Uses成员和shared_ptr的引用计数相关:
写了以下代码进行验证,两个关联的shared_ptr对象的_Ptr和_Rep的地址的确是相同的:
- int main()
- {
- shared_ptr<int> p = make_shared<int>(42);
- shared_ptr<int> q = p;
- int* Ptr_p = *reinterpret_cast<int**>(&p);
- int* Ptr_q = *reinterpret_cast<int**>(&q);
- _Ref_count_base* Rep_p = *(reinterpret_cast<_Ref_count_base**>(&p) + 1);
- _Ref_count_base* Rep_q = *(reinterpret_cast<_Ref_count_base**>(&q) + 1);
- int cnt_p = p.use_count();
- int cnt_q = Rep_q->_Use_count();
- }
最后用上述得知的数据结构,分析一下之前看到的shared_ptr可能造成无法释放原本内存的例子:
- class Node
- {
- public:
- shared_ptr<Node> ptr;
- };
- int main()
- {
- _Ref_count_base* Rep_p, * Rep_q;
- {
- shared_ptr<Node> p = make_shared<Node>();;
- shared_ptr<Node> q = make_shared<Node>();;
- Rep_p = *(reinterpret_cast<_Ref_count_base**>(&p) + 1);
- Rep_q = *(reinterpret_cast<_Ref_count_base**>(&q) + 1);
- // 1. "1 1"
- printf("%d %d\n", Rep_p->_Use_count(), Rep_q->_Use_count());
- p->ptr = q;
- q->ptr = p;
- // 2. "2 2"
- printf("%d %d\n", Rep_p->_Use_count(), Rep_q->_Use_count());
- }
- // 3. "1 1"
- printf("%d %d\n", Rep_p->_Use_count(), Rep_q->_Use_count());
- }
在打印第一条内容时,p和q分别指向新分配的两块内存,此时这两块内存的引用计数都为1:
在ptr的赋值操作后,即打印第二条内容时,p和q结构中的ptr分别指向了彼此(这种情况并不是没有,比如双向链表就有类似的情形),这时引用计数都为2:
在p和q脱离作用域后,即打印第三条内容时。q析构时,内存2的引用计数会减一,但是内存1中的ptr还指着它,析构后引用计数为1,所以内存2不会被释放。内存2不释放,说明内存2中指向内存1的ptr也不会析构,当p析构时,内存1也不会被释放。