4755 字
24 分钟
智能指针(二):使用总结、侵入式智能指针与设计
2022-07-20

常用部分总结#

比较shared_ptrunique_ptr备注
初始化①shared_ptr<T> sp;
② sp.reset(new T());
②shared_ptr<T> sp(new T());
③shared_ptr<T> sp1 = sp; //拷贝构造
④auto sp = make_shared<int>(10);
①unique_ptr<T> up;
up.reset(new T());
②unique_ptr<T> up(new T());
③unique_ptr<T> up1 = std::move(up);//移动构造
④auto up = make_unique<int>(10);
两者的构造函数将声明为**explicit,即不允许隐式类型转换,**如shared_ptr<int> sp = new int(10);
条件判断如,if(sp){…}如,if(up){…}两都均重载operator bool()
解引用(间接寻址运算符 *)*sp*up解引用,获得它所指向的对象
类成员访问运算符(->)sp->memup->mem重载->运算符
get()sp.get()up.get()返回智能指针中保存的裸指针,要小心使用。
p.swap(q)sp.swap(q);up.swap(q);交换p和q指针
独有操作①shared_ptr<T> p(q);//拷贝构造
②p = q;//赋值
③p.unique();若p.use_count()为1,返回true,否则返回false。④p.use_count()//返回强引用计数
①up=nullptr;释放up指向的对象,并将up置空。
②up.release();//up放弃对指针的控制权,返回裸指针,并将up置空
③up.reset();释放up指向的对象。 up.reset(q);其中q为裸指针。令up指向q所指对象。
up.reset(nullptr);置空
注意:
①unique_ptr不可拷贝和赋值,但可以被移动。
②release会切断unique_ptr和它原来管理的对象间的联系。通常用来初始化另一个智能指针。

强烈建议以对待普通指针的方式对待智能指针。#

  • 如按值传递,按值返回。
  • 按值传递unique_ptr显示了所有权的转移。

关于侵入式和非侵入式智能指针。#

什么是非侵入式智能指针#

STL的shared_ptr属于非侵入式智能指针。从原理上来说就是它的实现完全放在智能指针模板里。也就是控制块是和指针在一个类里。

模板类有一个用于保存资源类对象的指针变量,和一个用于记录资源对象使用计数(控制块)的指针变量。

什么是侵入式智能指针#

ITK, VTK和boost里的智能指针是侵入式的。从原理来说就是**它的实现分散在智能指针模板和使用智能指针模板的类中:模板类只有一个用于保存对象的指针变量,对象的计数放在了资源类中。**举例来说就是把控制块比如引用计数和对象绑定到一起。

QQ截图20221118211750

总结#

  • 非侵入式智能指针,它的引用计数变量为了保证所有对象共享,需要用堆里的内存,所以需要用new,这个都一样,不一样的是使用new的次数。

  • 侵入式智能指针的引用计数变量保存在对象里,因为对象是唯一的,所以引用计数也是唯一的。

  • 非侵入式智能指针的缺点:

    • 在不使用make_shared的时候,会为控制块单独分配一次内存。这导致性能问题。而如果使用make_shared,则在某些时候会导致整块内存都无法释放,比如弱引用计数仍不为0的情况下。
    • 引用计数的内存区域和数据区域不一致,缓存失效导致性能问题。
    • 编写代码不善,将导致同一个数据,绑定到了两个引用计数,从而导致双重删除问题。比如
    obj *resource = new obj(100);
    shared_ptr<obj> ptr1(resource); //ptr1有控制块
    shared_ptr<obj> ptr2(resource); //ptr2还有一个控制块。double free
  • 相比非侵入式智能指针,侵入式智能指针的好处是:

    • 一个资源对象无论被多少个侵入式智能指针包含,从始至终只有一个引用计数变量,不需要在每一个使用智能指针对象的地方都new一个计数对象,这样子效率比较高,使用内存也比较少,也比较安全;
      • 这句话理解就是如果在使用非侵入的时候我们有一个原始指针指向资源,我们使用这个原始指针初始化共享指针的时候会创建多个控制块。
    • 因为引用计数存储在对象本身,所以在函数调用的时候可以直接传递资源对象地址,而不用担心引用计数值丢失(非侵入式智能指针对象的拷贝,必须带着智能指针模板,否则就会出现对象引用计数丢失)。
  • 缺点是:

    • 资源类必须有引用计数变量,并且该变量的增减可以被侵入式智能指针模板基类操作,这显得麻烦。也就是资源类必须继承自某个引用计数变量的基类或者是编写的时候将其编写进去。b
      • 比如在boost库中,需要继承boost::intrusive_ptr_base基类
    • 如果该类并不想使用智能指针,它还是会带着引用计数变量。

可能的实现方法:

https://zhuanlan.zhihu.com/p/460983966

https://zhiqiang.org/coding/boost-intrusive-ptr.html

可能有用的参考资料#

https://developer.aliyun.com/article/582674

https://www.cnblogs.com/5iedu/p/11622401.html

https://kingsamchen.github.io/2018/03/16/demystify-shared-ptr-and-weak-ptr-in-msvc-stl/ sharedptr源码分析 包含enable_shared_from_this

https://blog.csdn.net/lgp88/article/details/7529254 不完整类型

如果自己设计的话,如何让原始指针为形参的函数接受智能指针为实参?#

可以使用转换构造函数。如

operator T*() const{
//return static_cast<T*>(this->object); 这行是VTK源码但不知道为何要这么做。
return this->object;
}

参考vtkNew和vtkSmartpointer。VTK8以前的vtkNew有意禁用了自动转换。但是8以后新增了转换构造所以可以进行转换。转换构造参见杂记2。

  • 注意,这样做非常危险。因为你现在可以delete一个智能指针了。delete会调用这个类型转换函数。所以这样设计的危险性就在于这里。-----Modern C++ design 7.7 和 more effective 条款28

为何智能指针支持 和继承有关的类型转换? more effective c++ -条款28(部分)#

回顾我们曾经实现过的uptr,代码不在这里贴了。

  • 首先我们知道,原始指针中,指向完整派生类类型的(可有 cv 限定的)空指针可转换成指向它的(有相同 cv 限定的)基类的指针。如果基类不可访问或有歧义,那么转换非良构(不能编译)。转换结果是指向原被指向对象内的基类子对象的指针。空指针值转换成目标类型的空指针值。也就是子类类型指针可以天然自然地转换为父类类型指针。(这是隐式类型转换中的第五级的指针转换。)

假设我们有如下场景

struct b{
};
struct d:b{
};
void func(area2::unique_ptr<b> ptr){
cout <<"called" << endl;
}
void formalfunc(unique_ptr<b> ptr){
cout <<"formal" << endl;
}
int main(){
area2::unique_ptr<b> ptr(new b());
func(move(ptr)); //可以。因为此时类型是b
area2::unique_ptr<b> ptr2(new d());
func(move(ptr2)); //可以。因为此时类型是b
area2::unique_ptr<d> ptr2(new d());
func(move(ptr2)); //不可以。因为此时类型是d
unique_ptr<d> uptr(new d()); //针对标准库的智能指针,这样可以,因为有隐式类型转换。
formalfunc(move(uptr)); //可以。有隐式类型转换。
}
  • 我们发现我们实现的简易版和标准库的相比,我们无法做到和继承有关的类型转换。原因非常明显。就算b和d有继承关系,但是unique_ptr<b>和unique_ptr<d>毫无关联。但是为什么标准库的智能指针可以让我们把一个子类类型的智能指针传入(赋值)给接受基类类型的智能指针的函数中呢?

标准库的做法我们从文档中可以窥见一斑:

template< class U, class E >
unique_ptr( unique_ptr<U, E>&& u ) noexcept; //(6)

通过从 u 转移所有权给 *this 构造 unique_ptr ,其中 u 以指定的删除器( E )构造。它依赖于 E 是否是引用类型,如下:

a) 若 E 是引用类型,则从 u 的删除器复制构造此删除器(要求此构造不抛出)

b) 若 E 不是引用类型,则从 u 的删除器移动构造此删除器(要求此构造不抛出)

此构造函数仅若下列皆为真才参与重载决议:

a) unique_ptr<U, E>::pointer 可隐式转换为 pointer

b) U 不是数组类型

c) Deleter 是引用类型且 E 与 D 为同一类型,或 Deleter 不是引用类型且 E 可隐式转换为 D

std::unique_ptr<Derived> 通过重载 (6) 可隐式转换为 std::unique_ptr<Base> (因为被管理指针和 std::default_delete都可隐式转换)。

根据 more effective c++ 条款28,在简单层面,选择是添加一个简单的用户定义转换函数模板。但是如果照着标准库的方法模拟,则是这样。

template<typename _Tp>
struct default_delete
{
/// Default constructor
constexpr default_delete() noexcept = default; //默认删除器的默认构造
/** @brief Converting constructor.
*
* Allows conversion from a deleter for objects of another type, `_Up`,
* only if `_Up*` is convertible to `_Tp*`.
*/
template<typename _Up,
typename = _Require<is_convertible<_Up*, _Tp*>>>
_GLIBCXX23_CONSTEXPR
default_delete(const default_delete<_Up>&) noexcept { } //一个构造函数模板用来在查看默认删除器是否可以被类型转换后进行类型转换。这里很重要
/// Calls `delete __ptr`
_GLIBCXX23_CONSTEXPR
void
operator()(_Tp* __ptr) const
{
static_assert(!is_void<_Tp>::value,
"can't delete pointer to incomplete type");
static_assert(sizeof(_Tp)>0,
"can't delete pointer to incomplete type");
delete __ptr;
}
};
//unique_ptr的构造函数6
template<typename _Up, typename _Ep, typename = _Require<
__safe_conversion_up<_Up, _Ep>,
__conditional_t<is_reference<_Dp>::value,
is_same<_Ep, _Dp>,
is_convertible<_Ep, _Dp>>>>
_GLIBCXX23_CONSTEXPR
unique_ptr(unique_ptr<_Up, _Ep>&& __u) noexcept
: _M_t(__u.release(), std::forward<_Ep>(__u.get_deleter())) //非常单纯的赋值
{ }

源代码

上面的代码看起来复杂,其实用自己写的版本可以化简为这样:

template<typename T>
void pivot(T p){
std::puts(__PRETTY_FUNCTION__); //查看函数签名
}
class myobj{
public:
int* ptrs;
myobj(int val):ptrs(new int(val)){}
~myobj(){
if(ptrs != nullptr){
delete ptrs;
ptrs = nullptr;
}
}
};
namespace area2{
template<typename T>
class default_deleter{ //默认形式的删除器
public:
default_deleter() = default;
//模拟默认删除器的构造函数模板
template<typename _Up, typename = typename enable_if<is_convertible<_Up*, T*>::value>::type> //注意必须是指针类型的可转换性
default_deleter(const default_deleter<_Up>&){
std::puts(__PRETTY_FUNCTION__); //2
}
default_deleter& operator=(const default_deleter& rhs){
cout <<"operator= called" << endl; //4
return *this;
}
void operator()(T *p) const {
std::puts(__PRETTY_FUNCTION__); //6
if(p != nullptr){
delete p;
p = nullptr;
}
}
};
template<typename T, typename Deleter = default_deleter<T>> //模板表示接受T类型一个接受T类型的删除器
class unique_ptr{
public:
T* p_ = nullptr; //T类型指针
Deleter d_; //删除器
//.....
//模拟构造函数6
template<typename U, typename E>
unique_ptr(unique_ptr<U,E>&& u){
this->p_ = u.p_; //指针赋值。自然转换
//----------------
pivot(u.d_); //1
default_deleter<T> temp(u.d_);
pivot(temp); //3
this->d_ = temp;
//----上面的区域可以换成下面一行------------
//this->d = u.d_
u.p_ = nullptr;
}
//......
}
void pivot(T) [with T = area2::default_deleter<d>] //1
area2::default_deleter<T>::default_deleter(const area2::default_deleter<_Up>&) [with _Up = d; <template-parameter-2-2> = void; T = b] //2
void pivot(T) [with T = area2::default_deleter<b>] //3
operator= called //4
called
void area2::default_deleter<T>::operator()(T*) const [with T = b] //6

核心原理就是把指针直接赋值过来。

当我们有

area2::unique_ptr<d> ptr3(new d());
area2::unique_ptr<b> ptr(move(ptr3));

的时候,首先我们发现这时候传入的指针的删除器类型是area2::default_deleter<d>。我们发现子类删除器和父类删除器area2::default_deleter<d>不一样,这就是为什么默认删除器要添加一个构造函数模板。

然后我们拆开写了,可以直接写this->d_ = u.d_。

第一步是查找合适的赋值方式。先查找d_有没有合适的operator=,明显没有,那么查看u.d_能不能转换为本类类型的默认拷贝赋值的参数类型,也就是area2::default_deleter<b>。我们发现default_deleter拥有一个构造函数模板:

template<typename _Up, typename = typename enable_if<is_convertible<_Up*, T*>::value>::type>
default_deleter(const default_deleter<_Up>&){
std::puts(__PRETTY_FUNCTION__); //2
}

它可以帮助我们转换。这个其实非常简单。先查看_Up*(子类指针,也就是本类中的d)能不能转换为T*(父类指针,也就是本类中的b)。然后就是构造了一个对象。

  • 这里必须要用is_convertible<_Up*, T*>而不是is_convertible<_Up, T>
    • 因为智能指针只有父类指针对子类指针进行delete的时候才有意义。但是如果直接两个对象之间判断是否可以转换,语义不对。比如两个没有继承关系的但是有转换函数的对象是convertible的,int和long之间也是convertible的。但是int指针可以用long指针析构吗?显然不可以。所以必须使用指针类型进行判断是否convertible。
    • 所以这里只有在有继承关系的子转父的时候,或者是**转换CV限定(non-const to const)**的时候才是true。语义才正确。

第二步就是我们得到了一个父类类型的默认删除器的临时对象。

第三部就是把这个删除器赋值即可。

因为默认删除器这个类没有任何数据。所以这样直接转换是符合我们直观意图的。

现在子类指针已经被转为父类。那么子类的删除器也应该变为父类。这样才正确。

测试

struct b{
};
struct d:b{
};
void func(area2::unique_ptr<b> ptr){
cout <<"called" << endl;
}
int main(){
area2::unique_ptr<d> ptr3(new d()); //现在不会报错了
func(move(ptr3));
}

智能指针的大师问答#

来自这里

1. 如何决定是否使用和使用哪一种智能指针?— 默认使用unique_ptr#

优先使用标准智能指针,默认情况下使用unique_ptr,如果需要共享,则使用shared_ptr。它们是所有 C++ 库都可以理解的通用类型。仅在需要与其他库进行互操作时,或者在需要使用标准指针上的删除器和分配器无法实现的自定义行为时,才使用其他智能指针类型。

2. shared_ptr应该如何构造?make_shared还是直接构造?#

除了我们上面提到的那些关于make_shared的优缺点和区别以外,想在此补充一些。

第一个,C++20开始其实有了allocate_shared。 顾名思义就是自定义内存分配器,但是我们其实可以视make_shared是一种狭义的allocate_shared(因为实际上没有自定义自己的内存分配器)

第二个,除了上面说到的make方法的使用以外,有一个额外的工程层面问题可能会需要直接构造,也就是如果你采用指向从其他(通常是遗留的)代码传递给你的对象的原始指针,则可以直接从该原始指针构造一个shared_ptr。 其余情况下,应当尽可能的使用make_shared。因为不仅使代码更清晰,而且从内存角度来讲,一般情况下,局部性,碎片和分配次数比实际的大小对系统的影响更大

其他文章#

  1. 智能指针和Pinpl
  2. 过度使用shared_ptr的危害及如何避免

设计智能指针 — more effective C++ 第七章#

因为这本书非常老,所以我们可以从最原始的角度理解智能指针到底需要满足什么需求,有哪些设计方式,为什么最后成为了现在的这个样子。

7.2 语义#

7.2讲的是语义。智能指针到底应该如何保有资源。这部分我想就是后面拆分的unique_ptr和shared_ptr

7.3 智能指针的存储#

7.3讲的是智能指针的存储。应该保留一个什么类型的原始指针?同时提到了重载operator->的技巧。

7.4 成员函数#

7.4讲的是成员函数。提到了智能指针不应该拥有如release()这样的成员函数。但是目前,它确实有。书中给到的不应该有的理由是:必须区分ptr->release()和ptr.release()的区别。因为可能智能指针保有的指针类型恰好也含有一个对应名字的函数。

7.5 所有权#

7.5讲的是所有权。 相当于7.2的延伸。这部分涉及到了侵入式和非侵入式指针的设计。侵入式如VTK的智能指针,好处是智能指针本身不需要操心引用计数器。因为引用计数是和对象绑定在一起的。缺点是对象必须要继承引用计数类。同时,提到了几个引用计数的设计。这部分在我们上面提到的make方法中有所体现。同时,额外提到了一种利用双向链表来做为引用计数的方法(感觉没啥用)。最后提到了关于循环引用的问题。

7.6 取址操作符#

7.6讲的是取址操作符。这一部分非常经典?理论上从不应该重载取址操作符,当然了,在某些代理类的时候除外。书中提到了重载了取址操作符的类不太好用于STL容器,因为所有&T都应该返回一个T*。当然了我觉得这是因为当时还没有std::address_of这个东西。不过也确实,STL的智能指针都没有重载operator&

7.7 隐式转换#

这部分就是上面提到的:如果自己设计的话,如何让原始指针为形参的函数接受智能指针为实参?

答案是看情况。需要权衡利弊。

7.8 7.9 比较操作#

STL目前是拥有智能指针的比较函数的。也就是重载了对应的函数。并且不是成员函数。

7.10 错误检测#

比如是否可以把空值传入智能指针做为初始值?目前是可以的。但是书上说不太应该。但是也是case by case。

另一个就是关于是否要进行解引用前检测是否为空?我个人认为应该把这个留给用户。应该让智能指针在智能的同时,尽可能简约。

7.11 常量性#

确实有一个很有意思的地方。无论指针的常量性是顶层还是底层还是皆有,delete都可正常使用。从某个角度来说是废话,但是从所有权的语义上理解可能就有了微妙的地方。为啥要在智能指针上面提到这种呢?因为智能指针也应该支持顶层和底层的const的语义区别。确实支持。毕竟原理就是type trait

7.12 数组类型#

其实不太应该使用堆上的数组。当然了,标准库也对智能指针提供了[]数组特化的版本。

7.13 多线程#

对于智能指针的多线程访问一直是一个比较令人困扰的问题。

一般来说,对于智能指针的多线程访问我们需要分成两个方向去讨论。第一个方向是指针保有的对象的多线程访问。另一个是指针的引用计数的多线程访问。

针对第一个,我们能做的东西不大。语义上我们希望智能指针帮我们做的事情越少越好,越少也就越透明,越自由。所以说在这一方面我们不应替用户操心太多。书中提到了自动锁定的应用,但我觉得不怎么样。用户自己会去针对并发访问进行处理。

针对第二个,我们可以肯定的是目前的shared_ptr的引用计数块的并发访问是不安全的。所以需要进行控制。原理还是一样,我们不应该在这个情况上替用户做过多决定。

智能指针(二):使用总结、侵入式智能指针与设计
https://sterben-01.github.io/posts/智能指针-2/
作者
Sterben_01
发布于
2022-07-20
许可协议
CC BY-NC-SA 4.0