第四章 智能指针

裸指针问题如下:

  1. 裸指针在声明中并未指出,裸指针指涉到的是单个对象还是一个数组。
  2. 裸指针在声明中也没有提示是不是要对其进行虚构。换言之,无法得知指针是否拥有其指涉的对象。或者是否空悬
  3. 指针的析构是不是拥有重载的delete操作符。
  4. 要防止多于一次的释放和析构。

C++存在的4种智能指针:

stdboost功能说明
auto_ptr(C++98)-独占指针对象
unique_ptr(C++11)scoped_ptr独占指针对象,并保证指针所指对象生命周期与其一致
shared_ptr(C++11)shared_ptr可共享指针对象,可以赋值给shared_ptr或weak_ptr。指针所指对象在所有的相关联的shared_ptr生命周期结束时结束,是强引用。
weak_ptr(C++11)weak_ptr它不能决定所指对象的生命周期,引用所指对象时,需要lock()成shared_ptr才能使用。

auto_ptr的问题

auto_ptr对指针进行独占,但是问题在于,独占需要使用移动操作,而C++98种没有移动语意,所以auto_ptr使用的是复制,所以要求独占却又可以复制,并且由于此问题,auto_ptr也不能存储于容器之中。

auto_ptr::get();    //返回原始指针
auto_ptr::release();    //将当前保存的普通指针删除并返回这个指针的值
auto_ptr::reset();  //将当前保存的指针所指向的地方释放并存储新的指针

十八 使用std::unique_ptr管理具备专属所有权的资源

默认情况下,基本可以认为unique_ptr和裸指针有着相同的尺寸,并且对于大多数操作(包括提领),它们都是执行了相同的指令。

unique_ptr是专属所有权语意,一个非空的unique_ptr总是拥有其指涉到的资源。移动一个unique_ptr会将所有权从源指针移动到默认指针,源指针被置空。unique_ptr不允许复制操作,为的就是防止破坏专属所有权。所以unique_ptr是一个只移型别。

默认的,资源的析构是调用默认的delete运算符。

例如:

template <typename... Ts>   //工厂函数
std::unique_ptr<classType> makeClass(Ts&&... params);
auto pClass = makeClass(arguments); //使用返回的unique_ptr

当使用自定义析构器的时候,只能使用构造函数(不能使用make_unique)来构造unique_ptr。

//自定义析构器
auto delClass = [](classType *pc){ makelog(pc); delete pc; };
template <typename... Ts>
std::unique_ptr<classType, decltype(delClass)> makeClass(Ts&&... params)
{
    std::unique_ptr<classType, decltype(delClass)> pc(nullptr, delClass);   //构造函数
    if(/*构建Stock类*/)
        pc.reset(new stock(std::forward<Ts>(params)...));
    else if(/*构建Bond类*/)
        pc.reset(new Bond(std::forward<Ts>(params)...));
    else if(/*创建Real类*/)
        pc.reset(new Real(std::forward<Ts>(params)...));
    return pc;
}

首先在这段代码种属于自定义析构器(delCLass),所有的自定义删除函数都是接受一个指涉到欲析构对象的裸指针,返回类型是void。其次是当使用自定义析构器,类型必须被指定为unique_ptr的第二个实参型别,所以这里工厂函数的返回类型是std::unique_ptr<classType, decltype(delClass)>。这里decltype(delClass)的类型是std::function<void(classType *)>。makeClass的基本策略是创建一个空的unique_ptr,使其指涉到适当型别的对象,然后将其返回。

将裸指针直接赋值给unique_ptr无法通过编译。因为这会形成裸指针到智能指针的隐式型别转换(发生裸指针到智能指针的转换后,当前指针将成为空指针,并且随着智能指针超出作用域而释放,当前内存将不再存在,将出现空悬指针)。

对每一次new运算符的调用结果,我们都使用forward将实参完美转发给makeClass。

自定义析构器接受基类类型,不管真实的类型是基类还是派生类,都会在lambda表达式种作为一个基类对象被删除。所以基类种必须声明虚析构函数。

C++14种,增加了函数返回型别推导,就使得makeClass可以通过以下更简单的、封装性更好的方法。

template <typename... Ts>
auto makeClass(Ts&&... params)
{
    //内存同上
}

若析构器是函数指针,那么unique_ptr长度尺寸一般会增加一到两个字长,而若析构器是函数对象,则带来的尺寸变化取决于该函数对象种存储了多少状态。无状态的函数指针不会浪费。

unique_ptr提供两种形式,一种是单个对象,另一种是数组(std::unique_ptr<T[]>),这样区分的结果是,指涉到的对象种类不会产生二义性。对于单个对象,不提供索引运算符(operator[]),数组形式不提供提领运算符(operator*operator->)。

unique_ptr可以高效的转换成shared_ptr。这是因为shared_ptr种有以unique_ptr类型为形参的右值构造函数。

shared_ptr<classType> sp = makeClass(arguments);

十九 使用shared_ptr管理具备共享所有权的资源

shared_ptr智能指针访问的对象采用共享所有权来管理对象的生存期,只有当最后一个指涉到对象的shared_ptr不再指涉到的时候,会析构其对象。

shared_ptr通过访问某资源的引用计数来确定是否自己是最后一个指涉到该资源的。也就是说,当引用计数归零的时候,会析构所指向的对象。否则只是引用对象进行自减。

shared_ptr的性能影响:

  • shared_ptr的尺寸是裸指针的两倍,因为内部包含一个指涉到该资源的裸指针,也包含一个直射到该资源的引用计数的裸指针。
  • 引用计数的内存必须动态分配,从技术上来说,引用计数于被指着到的对象相关联,然而被指涉到的对象却对此一无所知。这样所有的型别都可以被shared_ptr所管理。
  • 引用计数的递增和递减都必须是原子操作。因为在不同的线程种可能存在并发的读写器。因为不同线程可能会指涉到同一个对象,并做不同的操作。

当进行移动构造的时候是不需要进行计数递增的,因此移动操作比复制操作更快。复制操作要求递增引用计数,而移动不需要。这是因为移动构造后,原有的shared_ptr将不再指涉到此资源。

与unique_ptr类似,shared_ptr也使用delete来作为默认资源析构器,这种支持的设计却与unique_ptr不同,对于unique_ptr,析构器是智能指针类型型别的一部分,对于shared_ptr却不一样。

auto delandLog = [](classType *pc){ makelog(pc); delete pc; };
unique_ptr<Widget, decltype(delandLog)> upw(new Widget, delandLog); //析构器是智能指针的一部分
shared_ptr<Widget> upw(new Widget, delandLog);  //析构器型别不是智能指针的一部分

也就是说,传入了不同型别的析构器的unique_ptr将不再是同一个类型,尽管指涉到的对象类型是一致的。但是对于shared_ptr来说,传入不同的析构器并不会影响shared_ptr的型别,shared_ptr的型别只决定于所指对象的型别。

shared_ptr的析构器可能是lambda表达式,函数指针或函数对象,这些函数可能是有内存大小的,不存在于shared_ptr的类种,那存在哪里呢?

答案就是存储在shared_ptr的控制块中,这个控制块包括了引用计数,弱计数(weak_ptr所用)和其他数据(例如自定义的分配器或删除器等)。

如图:
这里写图片描述

一个对象的控制块应当由首个指涉到该对象的shared_ptr函数确定。毕竟正在创建指向某对象的控制块是不知道是否有其他对象指涉到该控制块。控制块的创建遵循以下规则:

  • make_shared()总是创建一个控制块。此函数会生产出一个用以指涉到的新对象,因此在调用make_shared的时刻,显然不会有针对该对象的控制块存在。
  • 从具备专属所有权的指针(unique_ptr或auto_ptr)出发构造一个shared_ptr时,会创建一个控制块。专属所有权指针不使用控制块,因此不应该存在所指涉的对象的控制块,并且当shared_ptr被指定了其所指涉到的对象的所有权,原有的专属所有权的指针被置空。
  • 当shared_ptr用裸指针创建的时候会创建控制块。如果想从一个已经拥有控制块的对象出发来创建一个shared_ptr,一般会使用shared_ptr或者weak_ptr,而非裸指针作为构造函数的实参。所以如果传入的实参是shared_ptr或者weak_ptr,则不会创造控制块,可以使用传入的智能指针的控制块。

这里有一个需要注意的地方,就是不能对同一个裸指针构造多个shared_ptr,这样会造成一个对象附有多个控制块,多重的控制块意味着该对象会被析构多次。

所以应尽量避免将裸指针传递给一个shared_ptr的构造函数。常用的替代方法是直接使用make_shared,但是对于自定义析构器的情况下,是不能使用make_shared的。其次如果必须将裸指针传递给shared_ptr,就直接传递new运算符的结果,而非传递一个裸指针变量。

上述的问题(多重控制块)很可能会隐式发生于this指针中,也就是说当类内部处理this指针的,并且又有外部的shared_ptr指向这个对象的时候,就会出现多控制块的问题。(实测在vs2017中无法通过编译,因为无法将裸指针隐式转换为shared_ptr,改函数被声明为explicit)

class A;
vector<shared_ptr<A>> classv;
class A
{
public:
    void process(){
        a = 100;
        classv.push_back(this);
    }
private:
    int a{0};
};

note: 原因如下: 无法从“A *”转换为“const std::shared_ptr”
note: class“std::shared_ptr”的构造函数声明为“explicit”

C++中提供了一个模板类std::enable_shared_from_this,该函数可以将内部的this指针包装为shared_ptr形式。

所以上述代码可以改为:

class A;
vector<shared_ptr<A>> classv;
class A : public enable_shared_from_this<A> //继承此模板类
{
public:
    void process(){
        a = 100;
        classv.push_back(shared_from_this());   //返回一个shared_ptr
    }
private:
    int a{0};
};

enable_shared_from_this是一个基类模板,其型别形参总是其派生类的类名。

enable_shared_from_this定义了一个成员函数,它会创建一个shared_ptr指涉到当前对象,但同时不会重复创建控制块。这个成员函数的名字是shared_from_this,每当需要一个和this指针指涉到相同对象的shared_ptr的时候,都可以在成员函数中使用它。正如上述代码。

从内部实现的角度,shared_from_this查询当前对象的控制块,并创建一个指向该控制块的新shared_ptr,这样的设计依赖于当前对象有一个新的shared_ptr。如果shared_ptr未定义,会产生错误。

为了避免这种情况的发生(即在shared_ptr指涉到对象之前就调用了引发shared_from_this的成员函数),继承自enable_shared_from_this的类,通常将其构造函数声明为private访问级别,并只允许用户通过调用返回shared_ptr的工厂函数来创建对象(通过静态函数调用)。

shared_ptr不可以被用来处理数组,只能用来处理指涉到单个对象的指针,shared_ptr还支持派生类到基类的指针型别转换,这也仅仅只对单个对象有意义。并且shared_ptr未提供operator[]。

shared_ptr实现多态

多态是虚函数+类对象指针/引用来实现的。但是对于shared_ptr来说,首先是该指针类并不了解具体的派生情况,其次是传入基类和派生类的shared_ptr其实是不同的类型的,那么如何在类内部实现多态呢?

方法就是通过成员函数来实现:

template<class _Ty2, enable_if_t<_SP_pointer_compatible<_Ty2, _Ty>::value, int> = 0>
        shared_ptr(const shared_ptr<_Ty2>& _Other) _NOEXCEPT
        {   // construct shared_ptr object that owns same resource as _Other
            this->_Copy_construct_from(_Other);
        }
template<class _Ty2>
        void _Copy_construct_from(const shared_ptr<_Ty2>& _Other)
        {   // implement shared_ptr's (converting) copy ctor
        if (_Other._Rep)
            {
            _Other._Rep->_Incref();
            }

        _Ptr = _Other._Ptr;
        _Rep = _Other._Rep;
        }

从这个函数我们可以知道,当父类的shared_ptr指向派生类的对象的时候,类内部是直接将裸指针形式进行派生类到基类的转换,也就是说,在成员函数内部,是基类的裸指针指向了派生类的裸指针,这就在shared_ptr中实现了多态。

二十 对于类似shared_ptr但有可能空悬的指针使用weak_ptr

对于两个互相指向对方来指针来说,如果使用shared_pr,有可能出现内存泄露。代码如下:

#include <iostream>
#include <memory>
using namespace std;
class A;
class B;
class A
{
public:
    A() { cout << "create A" << endl; }
    ~A() { cout << "destroy A" << endl; }
    shared_ptr<B> sp{nullptr};
};
class B
{
public:
    B() { cout << "create B" << endl; }
    ~B() { cout << "destroy B" << endl; }
    shared_ptr<A> sp{nullptr};
};
void test()
{
    shared_ptr<A> pa = make_shared<A>();
    shared_ptr<B> pb = make_shared<B>();
    pa->sp = pb;
    pb->sp = pa;
}
int main()
{
    test();
    return 0;
}

输出为:

create A
create B

很明显发现当超出了test的作用范围后,两个shared_ptr都没有被释放。

首先对这两个对象使用shared_ptr,这样每一个对象的控制块计数都是1。之后分别指向对方,会导致每一个的控制块计数都变成2。当超出作用域的时候,pa,pb检测到对象的控制块计数都是2,这样pa和pb的释放仅仅是把控制块计数减去1,而不会进行具体对象的析构。而由于这里退出test,我们已经无法再知道指向这两个对象的指针。就产生了内存泄露。

消除的方法就是把其中一个shared_ptr改为weak_ptr,这样不会改变其中之一对象的计数,这样退出作用域就会析构被shared_ptr和weak_ptr(计数是1),之后析构另一个对象。

class A;
class B;
class A
{
public:
    A() { cout << "create A" << endl; }
    ~A() { cout << "destroy A" << endl; }
    shared_ptr<B> sp{nullptr};
};
class B
{
public:
    B() { cout << "create B" << endl; }
    ~B() { cout << "destroy B" << endl; }
    weak_ptr<A> sp; //使用weak_ptr以防止循环引用
};
void test()
{
    shared_ptr<A> pa = make_shared<A>();
    shared_ptr<B> pb = make_shared<B>();
    pa->sp = pb;
    pb->sp = pa;
}

weak_ptr像shared_ptr那样运作,但是不影响其指涉对象的引用计数。这样这个指针就必须处理当前所指对象是否被析构的问题。

weak_ptr不能提领也不能检查是否未空,这是因为weak_ptr并不是一种独立的指针,而是shared_ptr的一种扩充。

weak一般是通过shared_ptr来创建的,当使用shared_ptr完成初始化weak_ptr的时刻,两者就指涉到相同的位置,但是weak_ptr不影响其指涉对象的引用计数。

class W
{
public:
    W(int _x, int _y) : x(_x), y(_y) { cout << "create W" << endl; }
    ~W() { cout << "destroy W" << endl; }
private:
    int x;
    int y;
};
int main()
{
    shared_ptr<W> spw = make_shared<W>(1, 2);
    weak_ptr<W> wpw(spw);
    cout << wpw.use_count() << endl;    //查看有多少shared_ptr指向该对象
    cout << wpw.expired() << endl;  //是否成为空悬指针
    auto pw = wpw.lock();   //从weak_ptr得到一个shared_ptr
    cout << typeid(pw).name() << endl;  //class std::shared_ptr<class W>
    cout << wpw.use_count() << endl;    //2

    return 0;
}

但是再多线程的情况下,如果先检测weak所指的对象是不是被析构了,如果未被析构再用shared_ptr去访问的话,可能会出现问题,因为再检测和访问之间,对象可能会被别的线程析构,所以需要一个原子操作来决定,这就是刚才用的lock()函数,用次函数,如果对象已经被析构,那么返回为空。

再工厂函数返回指针的时候,之前返回的是unique_ptr,但是这是独占的指针,如果想把指针放入到缓存中,则可以使用shared_ptr,但是如果这样,shared_ptr指向的对象将永远不会释放,换句话说,该释放的对象将永远因为缓存中的shared_ptr而不会释放。

所以可以在缓存中存储weak_ptr,这样缓存中的智能指针不会影响对象的生存期,并且也可以检测到对象的生存状态(expired())。

shared_ptr和weak_ptr配合使用最常见的就是在观察者模式中

在观察者模式中,主要是由两个类组成,其中之一是观察者(observer),另外一个就是主题(subject),主题中保存着一个链表或数组(通常用STL中的list或vector实现),主题中持有指向观察者的指针(weak_ptr实现),这样主题中发生变化的时候就会通过指针调用观察者的函数发起相对应的操作。并且当观察者被析构了之后,weak_ptr可以检测空悬。

从效率上看,weak_ptr和shared_ptr本质上一致,weak_ptr的对象和shared_ptr对象的尺寸相同,它们使用相同的控制块。事实上,控制块中也记录中weak_ptr的引用计数。

二十一 优先选用std::make_unique和std::make_shared,而非直接使用new

make_shared处于C++11中,而make_unique处于C++14中。

make_unqiue和make_shared仅仅是做了一次完美转发,这个完美转发接受一组可变参数模板,并将这些参数转发给类的构造函数执行动态内存分配,并将这个构造函数的结果返回给智能指针的构造函数。

通过make_XXX不能自定义析构器。

源代码如下:

//make_shared()源代码
template<class _Ty,
    class... _Types> inline
    shared_ptr<_Ty> make_shared(_Types&&... _Args)
    {   // make a shared_ptr
    const auto _Rx = new _Ref_count_obj<_Ty>(_STD forward<_Types>(_Args)...);

    shared_ptr<_Ty> _Ret;
    _Ret._Set_ptr_rep_and_enable_shared(_Rx->_Getptr(), _Rx);
    return (_Ret);
    }
//make_unique源代码
template<class _Ty,
    class... _Types,
    enable_if_t<!is_array_v<_Ty>, int> = 0> inline
    unique_ptr<_Ty> make_unique(_Types&&... _Args)
    {   // make a unique_ptr
    return (unique_ptr<_Ty>(new _Ty(_STD forward<_Types>(_Args)...)));
    }

make_XXX函数较好的一点是,如果使用构造函数的形式,动态创建类的对象和依据类的对象构建智能指针是两步操作,这两个操作之间可能会被插入其他的操作,而一旦这个其他的操作抛出了异常,就会导致内存泄露,因为此时这个内存将不会被智能指针所托管。但是使用make_XXX就不会出这种问题,因为此时创建动态内存对象和智能指针托管之间没有其他操作。但是如果使用了自定义析构器将无法使用make_XXX,这个时候就需要把创建的代码单独提取出来,而不是写在形参中,但是这样会造成shared_ptr的拷贝,所以我们还需要用move将其强转为右值。

void test();
void Mydel(T *);
f(shared_ptr<T>(new T), test);  //可能存在内存泄露,如果test()抛出异常
f(make_shared<T>(), test);  //安全
f(shared_ptr<T>(new T, Mydel), test);   //无法使用make_XXX,可能有内存泄露
shared_ptr<T> sp(new T, Mydel);
f(sp, test);    //安全,但是效率不够高
f(move(sp), test);  //安全,并且右值传输,无需复制

并且由于make_shared结构的紧凑,产生的代码的运行速度会更快。首先对于构造函数来说,需要申请两次内存,一次是new类的对象,另一次是申请控制块。而对于make_shared来说,仅仅需要申请一次内存,因为make_shared会分配一块内存,同时保存控制块和类的对象

除了不能自定义析构器之外,对于make_XXX来说还有一个限制,就是对于initializer_list型别的形参,如果创建对象语句中使用大括号会优先匹配initializer_list类型的构造函数,而使用圆括号则不会匹配此类型构造函数。所以在make_XXX中是无法使用大括号进行初始化的。不过可以有折中的办法,那就是使用auto自动推导。

auto initl = {1,2,3,4,5};
auto spv = make_shared<vector<int>>(initl);

有些类会定义自身版本的operator new和operator delete,这些函数的存在意味着全局版本的内存分配和释放函数不适合这种对象。通常,类自定义的这两种函数被设计为仅仅释放该类精确尺寸的这种对象,例如A类,自定义new和delete恰好删除和构造sizeof(A)大小的内存,但是make_shared创建的内存是类的内存加上控制块的内存。所以对于拥有自定义new的类,make_shared并不是一个好办法。

前面说过控制块中还会有weak_ptr的引用计数,这个计数被称为弱计数。当shared_ptr引用计数归零的时候,控制块实际上不会删除,因为还要保留着weak_ptr的引用计数。所以其实控制块要等到所有的weak_ptr都超过作用域,才能被析构。

如果使用make_shared函数创建shared_ptr,那么类的对象的内存和控制块内存属于同一块内存,那么这一块内存将不会被释放。如果这个类对象比较大的话,则会产生很多不必要的内存占用,而对构造函数创建的shared_ptr则不会,类对象会在最后一个shared_ptr析构时被析构。

二十二 使用Pimpl习惯用法时,将特殊成员函数的定义放到实现文件中

Pimpl:pointer to implementation,即指涉到实现的指针。这种方法的技巧就是把某一类的数据成员用一个指涉到某实现类(或结构体)的指针代替,而后把原来主类中的数据成员放到实现类中,并通过指针间接访问这些数据成员。

class Widget
{
public:
    Widget();
    ~Widget();
private:
    /*原私有数据成员
    string name;
    vector<double> data;
    classType a, b, c;  //自定义类型
    */
    struct Impl;    //一个仅声明未定义的非完整类型
    Impl *pImpl;    //可声明指向这个非完整类型的指针
};

这里使用了Pimpl方法,可以减少头文件的引用,这样可以加快编译速度。

Pimpl用法的第一步就是申明一个指针类型的数据成员,指向一个非完整类型。第二步就是动态分配和回收持有从前在原始类里的那些数据成员的对象,而分配和回收代码则是放在实现文件中(包括Impl的实现)。这样也把原来在头文件中包含的头文件(如string、vector)也被转移到实现的.cpp文件中。

但是裸指针的使用不如智能指针,裸指针还会导致自己书写new,delete运算符,并时刻防止内存泄露,所以在C++11中,使用Pimpl方法的时候,最好是使用智能指针。并且由于这里是专属所有权,由外部类独占对象,所以可以使用unique_ptr。

裸指针风格的Pimpl方法中,析构函数要自己写delete,而现在使用了智能指针后,析构函数可以直接声明为= default(声明虚函数的理由是将其实现写在.cpp文件中,头文件中对非完整型别的Impl无法进行析构)。

对于移动操作也是一样,需要将其定义于.cpp文件中,因为在头文件中,Impl结构体还是非完整型别,而move函数在出现异常的时候,会生成析构代码,于是就会析构Impl,而这里的Impl无法被析构(原因同上述析构函数的原因)。

对于复制操作来说,这里要执行深复制,也就是说复制的代码需要重新撰写(如果没使用Pimpl就可以看数据成员支不支持复制操作,如果支持就无需写额外的代码)。

P.s.这里如果使用shared_ptr就不用考虑非完整类型,因为shared_ptr的析构器是不是智能指针类型的一部分,所以需要更大的运行时期数据结构和更慢的目标代码。而unique_ptr尺寸更小,速度更快,所以编译器要求其指涉到的对象必须是完整类型。

一个简单的示例代码如下:

//EMCPP_pimpl.h
#pragma once
#include <memory>
class Widget
{
public:
    Widget();
    ~Widget();
    Widget(Widget&& rhs);
    Widget& operator=(Widget&& rhs);
    Widget(const Widget& rhs);
    Widget& operator=(const Widget& rhs);
private:
    struct Impl;
    std::unique_ptr<Impl> pImpl;
};
#if 1
#include <iostream>
#include <string>
#include <vector>
#include "EMCPP_pimpl.h"
using namespace std;

struct Widget::Impl //实现
{
    string name;
    vector<int> data;
};
Widget::Widget() : pImpl(make_unique<Impl>())
{}
Widget::~Widget() = default;
Widget::Widget(Widget&& rhs) = default;
Widget& Widget::operator=(Widget&& rhs) = default;
Widget::Widget(const Widget& rhs)
    :pImpl(make_unique<Impl>(*rhs.pImpl))
{}
Widget& Widget::operator=(const Widget& rhs)
{
    *pImpl = *rhs.pImpl;
    return *this;
}

#endif
  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值