- Boost程序库完全开发指南:深入C++”准”标准库(第5版)
- 罗剑锋
- 5076字
- 2020-08-27 16:45:18
3.3 shared_ptr
shared_ptr是一个最像指针的“智能指针”,它是boost.smart_ptr库中最有价值、最重要的组成部分,Boost库的许多组件(甚至还包括一些其他领域的智能指针)都使用了shared_ptr,所以它被毫无悬念地收入C++11标准。
shared_ptr与scoped_ptr一样包装了new操作符在堆上分配的动态对象,但它实现的是引用计数型的智能指针,可以被自由地拷贝和赋值,可以在任意的地方共享它,当没有代码使用它时(引用计数为0),才删除被包装的动态分配的对象。
shared_ptr也可以安全地放到标准容器中,这是在STL容器中存储指针的最标准的解法。
3.3.1 类摘要
shared_ptr要比同为智能指针的scoped_ptr复杂许多,它的类摘要如下:
3.3.2 操作函数
shared_ptr与scoped_ptr同样是用于管理new动态分配对象的智能指针,因此其功能有很多相似之处:它们都重载了“*”“->”操作符以模仿原始指针的行为,提供显式bool值转换以判断指针的有效性,get()函数可以得到原始指针,并且没有提供指针算术操作,也不能管理new[]产生的动态数组指针。例如:
但shared_ptr的名字表明了它与scoped_ptr的主要不同之处:它是可以被安全共享的。shared_ptr是一个“全功能”的类,有着正常的拷贝、赋值语义,也可以进行shared_ptr间的比较,是“最智能”的智能指针。
shared_ptr有多种形式的构造函数,可以应用于各种可能的情形,示例如下。
■ 无参的shared_ptr():创建一个持有空指针的shared_ptr。
■ shared_ptr(Y*p):获得指向类型T的指针p的管理权,同时将引用计数置为1,这个构造函数要求Y类型必须能够转换为T类型。
■ shared_ptr(shared_ptr const&r):从另外一个shared_ptr获得指针的管理权,同时引用计数加1,结果是两个shared_ptr共享一个指针的管理权。
■ operator=:赋值操作符,可以从另外一个shared_ptr获得指针的管理权,其行为同拷贝构造函数。
■ shared_ptr(Y*p,D d):其行为类似shared_ptr(Y*p),但它使用参数d指定了析构时的定制删除器,而不是简单的delete,这部分将在3.3.8节详述。
■ aliasing:别名构造函数是不增加引用计数的特殊用法,这部分会在3.3.9节详述。
shared_ptr的reset()函数的行为与scoped_ptr不尽相同,它的作用是将引用计数减1,停止对指针的共享,除非引用计数为0,否则不会发生删除操作。带参数的reset()函数类似相同形式的构造函数,在原指针引用计数减1的同时改为管理另一个指针。
shared_ptr有两个专门的函数来检查引用计数:unique()在shared_ptr是指针的唯一所有者时返回true(这时shared_ptr的行为类似scoped_ptr或unique_ptr);use_count()返回当前指针的引用计数。要小心,use_count()应该仅仅用于测试或调试,它不提供高效率的操作,而且有的时候它是不可用的(极少数情形)。而unique()是可靠的,任何时候它都可用,而且它比use_count()==1的速度更快。
shared_ptr还支持比较运算,可以测试两个shared_ptr相等与否,比较基于内部保存的指针,相当于a.get()==b.get()。shared_ptr还可以使用operator<比较大小,但不提供除operator<以外的比较操作符,这使得shared_ptr可以被用于标准关联容器(set和map):
此外,shared_ptr还支持流输出操作符operator<<,输出内部的指针值,方便调试。
3.3.3 用法
shared_ptr的“高智能”使其行为最接近原始指针,因此它比scoped_ptr的应用范围更广。shared_ptr几乎可以在任何new出现的地方接收new的动态分配结果,然后被任意使用,从而完全消灭delete的使用和内存泄漏,而它的用法与scoped_ptr一样简单。
shared_ptr也提供基本的线程安全保证,一个shared_ptr可以被多个线程安全读取,但其他的访问形式的结果是未定义的。
第一个例子示范shared_ptr基本用法:
第二个例子示范了shared_ptr较复杂的用法:
这段代码定义了一个类和一个函数,这两者都接收shared_ptr对象作为参数,需要特别注意的是我们没有使用引用的方式传递参数,而是直接拷贝,就像是在使用一个原始指针——shared_ptr支持这样的用法。
在声明了shared_ptr和两个shared类实例后,指针被它们共享,因此引用计数为3。print_func()函数内部拷贝了一个shared_ptr对象,因此引用计数再加1,但当退出函数时,拷贝自动析构,引用计数又恢复为3。
程序的运行结果如下:
3.3.4 工厂函数
shared_ptr很好地消除了显式的delete调用,如果读者掌握了它的用法,即可确保delete在你的编程字典中彻底消失。
但这还不够,因为shared_ptr的构造还需要new调用,这会导致代码的某种不对称性。虽然shared_ptr很好地包装了new表达式,但过多的显式new操作符也是个问题,显式new调用应该使用工厂模式来解决。
因此,smart_ptr库提供了一个工厂函数make_shared()来消除显式的new调用:
make_shared()函数可以接收若干个参数,然后把它们传递给类型T的构造函数,创建一个shared_ptr<T>的对象并返回。通常使用make_shared()函数要比直接创建shared_ptr对象的方式快且高效,因为它内部仅分配一次内存,消除了shared_ptr构造时的“开销”。
下面的代码示范了make_shared()函数的用法:
如果C++编译器支持可变参数模板特性,那么make_shared()的参数数量没有限制,能够以任意多数量的参数构造对象;否则它最多只能接收10个参数。
除了make_shared(),smart_ptr库还提供了一个allocate_shared(),它比make_shared()多接收一个定制的内存分配器类型的参数,其他方面二者相同。
3.3.5 应用于标准容器
有两种方式可以将shared_ptr应用于标准容器(或者容器适配器等其他容器)。
一种方式是将容器作为shared_ptr的管理对象,如shared_ptr<list<T>>,使容器可以被安全地共享,其用法与普通的shared_ptr没有区别,我们不再讨论。
另一种方式是将shared_ptr作为容器的元素,如vector<shared_ptr<T>>,因为shared_ptr支持拷贝语义和比较操作,符合标准容器对元素的要求,所以可以在容器中安全地容纳元素的指针,而不是拷贝。
标准容器不能容纳scoped_ptr,因为scoped_ptr不能拷贝和赋值。标准容器可以容纳原始指针,但这就丧失了容器的许多好处,因为标准容器无法自动管理指针类型的元素,所以必须编写大量额外的代码来保证指针最终被正确删除,这通常很麻烦而且容易出错。
存储shared_ptr的容器与存储原始指针的容器的功能几乎一样,但shared_ptr为程序员做了指针的管理工作,可以任意使用shared_ptr而不用担心资源泄漏。
下面的代码示范了将shared_ptr应用于标准容器的方式:
在上述代码中,需要注意迭代器和operator[]的用法,因为容器内存储的是shared_ptr,我们必须对迭代器pos使用一次解引用操作符“*”以获得shared_ptr,再对shared_ptr使用解引用操作符“*”,才能操作真正的值。*(*pos)也可以直接写成**pos,但前者更清晰,而后者很容易让人迷惑。
vector的operator[]用法与迭代器类似,也需要使用解引用操作符“*”获取真正的值。
使用boost.foreach库(见8.1节)或C++里的新式for循环可以避免迭代器到shared_ptr的两次解引用,直接取出容器里的shared_ptr。例如:
另外Boost还在boost.iterators库里提供了迭代器适配器indirect_iterator来简化容纳shared_ptr容器的使用,具体可参考附录A中的推荐书目[3]。
3.3.6 应用于桥接模式
桥接模式是一种结构型设计模式,它把类的具体实现细节对用户隐藏起来,以达到类之间的最小耦合关系。在具体编程实践中,桥接模式也被称为pimpl或handle/body惯用法,它可以将头文件的依赖关系降到最小,减少编译时间,而且桥接模式不使用虚函数即可实现多态。
scoped_ptr和shared_ptr都可以用来实现桥接模式,但通常shared_ptr更合适,因为它支持拷贝和赋值,这在很多情况下都是有用的,比如可以配合容器工作。
本节通过一个小例子来说明shared_ptr如何用于pimpl。
首先我们声明一个类sample,它仅向外界暴露了最小的细节,其真正的实现在内部类impl,sample用一个shared_ptr来保存它的指针:
在sample的cpp中完整定义impl和其他功能:
最后是桥接模式的使用,具体如下:
桥接模式非常有用,它可以任意改变具体的实现并使外界对此一无所知,同时它可以减少源文件之间的编译依赖,使程序获得更多的灵活性。而shared_ptr是实现桥接模式的最佳工具,它解决了指针的共享问题和引用计数问题(关于桥接模式更详细的讨论请见附录A的推荐书目[1])。
3.3.7 应用于工厂模式
工厂模式是一种创建型设计模式,这个模式包装了new操作符的使用,使对象的创建工作集中在工厂类或工厂函数中,从而更容易适应变化,make_shared()就是工厂模式的一个很好的例子。
在程序中编写自己的工厂类或工厂函数时通常需要在堆上使用new动态分配一个对象,然后返回对象的指针。这种做法很不安全,因为用户很容易忘记对指针调用delete,存在资源泄漏的隐患。
使用shared_ptr可以解决这个问题,只需要修改工厂方法的接口,不再返回原始指针,而是返回一个被shared_ptr包装的智能指针,这样可以很好地保护系统资源,而且可以更好地控制对接口的使用。
接下来我们使用代码来解释将shared_ptr应用于工厂模式的用法,首先实现一个纯抽象基类,即接口类:
注意abstract的析构函数,如果它被定义为保护的,则意味着除了它自己和它的子类,任何其他对象都无权调用delete来删除它。
然后我们定义abstract的实现子类:
随后的工厂函数返回基类的shared_ptr:
这样我们就完成了全部工厂模式的实现,现在可以把这些组合起来:
由于基类abstract的析构函数是保护的,所以用户无法对指针做出任何破坏行为,即使用get()获得了原始指针也不行:
这段代码不能通过编译,其原因是无法访问abstract的保护析构函数。
但这不是绝对的,使用“粗鲁”的方法也可以在shared_ptr外删除对象,因为impl的析构函数是公开的,所以:
这样就可以任意操作原本处于shared_ptr控制下的原始指针了,但永远也不要这样做,因为这会使shared_ptr在析构时删除可能已经不存在的指针,引发未定义行为。
3.3.8 定制删除器
在3.3.2节我们特意没有讨论shared_ptr的一种形式的构造函数shared_ptr(Y*p,D d),它涉及shared_ptr的另一个重要概念——删除器。
shared_ptr(Y*p,D d)的第一个参数是要被管理的指针,它的含义与其他构造函数的参数相同。而第二个参数则告诉shared_ptr在析构时不要使用delete来操作指针p,而要用d来操作,即把delete p换成d(p)。
在这里删除器d可以是一个函数对象,也可以是一个函数指针,只要它能够像函数那样被调用,使得d(p)成立即可。对删除器的要求是它必须可拷贝,其行为也必须像delete那样,不能抛出异常。
为了配合删除器的工作,shared_ptr提供一个自由函数get_deleter(),它能够返回内部的删除器指针。
有了删除器的概念,我们就可以用shared_ptr实现管理任意资源。只要这种资源提供了它自己的释放操作,shared_ptr就能够保证它自动释放。
假设我们有一组操作socket的函数,使用socket_t:
那么,socket资源对应的释放操作就是函数close_socket(),它符合shared_ptr对删除器的定义,可以像用shared_ptr这样管理socket资源:
在这里,删除器close_socket()是一个自由函数,因此只需要把函数名传递给shared_ptr即可。也可以在函数名前加上取地址操作符“&”,其效果是等价的:
这样我们就使用shared_ptr配合定制的删除器管理了socket资源。当离开作用域时,shared_ptr会自动调用close_socket()函数关闭socket,再也不用担心会有资源遗失。
再如,对于传统的使用struct FILE的C文件操作,也可以使用shared_ptr配合定制删除器自动管理。例如:
shared_ptr<FILE>fp(fopen("./1.txt","r"),fclose);
离开作用域时,shared_ptr会自动调用fclose()函数关闭文件。
shared_ptr的删除器特性在处理某些特殊资源时非常有用,它使得用户可以定制、扩展shared_ptr的行为,使shared_ptr不仅仅能够管理内存资源,而是成为一个“万能”的资源管理工具。
3.3.9 高级议题
本节讨论关于shared_ptr的一些高级议题。
1.对比std::shared_ptr
C++标准(C++11.20.7.2)中定义了std::shared_ptr,功能与boost::shared_ptr基本相同,完全可以等价互换。
2.显式bool转型
早期版本的shared_ptr的bool转型函数是隐式转换,但后来为了与标准一致,添加了explicit修饰,变成了显式转换。
出于对兼容性的考虑,C++标准规定在if、assert、for等逻辑判断语境下,shared_ptr还是可以隐式转换的(如之前的代码),但其他情形(如函数参数或返回值)则必须显式转换,可以使用static_cast<bool>(p)、p!=nullptr或!!p等形式。例如:
3.指针转型函数
在编写基于虚函数的多态代码时,指针的类型转换很有用,如把一个基类指针转型为一个派生类指针,或者反过来。但shared_ptr不能使用诸如static_cast<T*>(p.get())的形式,这将导致转型后的指针无法再被shared_ptr正确管理。
为了支持这样的用法,shared_ptr提供了类似的转型函数static_pointer_cast<T>()、const_pointer_cast<T>()和dynamic_pointer_cast<T>(),它们与标准的转型操作符static_cast<T>等类似,但它们返回的是转型后的shared_ptr。
例如,下面的代码使用dynamic_pointer_cast把一个shared_ptr<std::exception>向下转型为一个shared_ptr<bad_exception>,然后又用static_pointer_cast将其重新转型为shared_ptr<std::exception>:
4.shared_ptr<void>
shared_ptr<void>能够存储void*型指针,而void*型指针可以指向任意类型,因此shared_ptr<void>就像是一个泛型的指针容器,拥有容纳任意类型的能力。
但将指针存储为void*型的同时会丧失原来的类型信息,为了在需要的时候正确使用指针,可以用static_pointer_cast<T>等转型函数将指针重新转为原来的指针。但这涉及运行时进行动态类型转换,会导致代码不够安全,建议最好不要这样使用。
5.删除器的高级用法
基于shared_ptr<void>和定制删除器,shared_ptr可以有更“惊人”的用法。由于空指针可以是任何指针类型,因此利用shared_ptr<void>还可以实现退出作用域时调用任意函数。例如:
shared_ptr<void>存储了一个空指针,并指定了删除器是操作void*的一个函数,因此当它析构时会自动调用函数any_func(),从而执行任意我们想做的工作。
6.别名构造函数(aliasing)
除之前介绍的构造函数之外,shared_ptr还有一种比较特殊的构造函数,其形式如下:
上述函数的作用是共享r的引用计数,但它实际持有的却是另外一个可能与r毫无关系的指针p,而且它并不负责p的自动销毁。
初看,这种形式的构造函数非常怪异,但它是有实际应用价值的,一个具体的使用场景是指向已经被shared_ptr管理的对象的内部成员变量:
7.owner_less
因为存在别名构造函数,所以在某些情况下,单纯地基于p.get()的指针值比较就不再适用,为此smart_ptr库提供了基于所有权的比较函数对象owner_less,定义了严格的弱序关系,可以用于关联容器。
下面的代码简单地示范了将owner_less用于标准容器set: