智能指针的使用及其实现原理

  • 目录
    • 裸指针和智能指针
    • 自己实现智能指针
    • 不带引用计数的智能指针
    • 带引用计数的智能指针
    • 智能指针的循环引用(交叉引用)问题
    • 多线程共享对象的线程安全问题
    • 自定义删除器
    • make_shared和make_unique
    • enable_shared_from_this类和shared_from_this()方法

裸指针和智能指针

传统的指针,例如int* p = new int(1),我们称为“裸指针”。其会向系统申请堆资源,但是由于使用不当,所以往往会出现“内存泄漏”的问题。情况如下:

  • 忘记写delete去释放堆资源
  • 代码提前退出,导致没有执行delete
  • 代码抛出异常,导致没有执行delete
  • . . .

此时就需要使用智能指针去托管裸指针的资源。
智能指针的作用:保证对象申请的堆资源能够被自动释放。

template<typename T>
class SmartPtr
{
public:
    SmartPtr(T *_ptr = nullptr)
        :ptr(_ptr)
    {}
    ~SmartPtr() { delete ptr; }
private:
    T *ptr;
};

这就是一个最简单的智能指针,利用的就是栈上的对象出作用域的时候自动析构的特征来做到资源的自动释放。

作为指针,它也要能够解引用(*ptr)和使用->。所以可以改进一下

template<typename T>
class SmartPtr
{
public:
    SmartPtr(T *_ptr = nullptr)
        :ptr(_ptr)
    {}
    ~SmartPtr() { delete ptr; }
    // 返回*ptr的引用
    T& operator*() { return *ptr; }
    // 返回ptr本身
    T* operator->() { return ptr; }
private:
    T *ptr;
};

int main()
{
    SmartPtr<int> ptr(new int(666));
    // 相当于 ptr.operator*();
    cout << *ptr << endl; // 666

    struct Test
    {
        void print() { cout << "hello world" << endl; }
    };
    SmartPtr<Test> ptr2(new Test());
    // 相当于 ptr2.operator->().print();
    ptr2->print(); // "hello world"
    // 相当于 ptr2.operator*().print();
    (*ptr2).print(); // "hello world"

    return 0;
}

不带引用计数的智能指针:auto_ptr,scoped_ptr,unique_ptr

我们来想一下下面这种智能指针的使用场景。

template<typename T>
class SmartPtr
{
public:
    SmartPtr(T *_ptr = nullptr)
        :ptr(_ptr)
    {}
    ~SmartPtr() { delete ptr; }
private:
    T *ptr;
};

int main()
{
    SmartPtr<int> ptr1(new int(0));
    SmartPtr<int> ptr2(ptr1);
    return 0;
}

如果两个智能指针都要托管同一块资源,但是由于资源只能被释放一次,而两个智能指针在析构的时候,都会调用delete。因此在第二次资源被delete的时候,就会系统异常。

这种智能指针的“浅拷贝”带来的问题让人棘手,因为并不能将智能指针改成“深拷贝“。

template<typename T>
class SmartPtr
{
public:
    SmartPtr(T *_ptr = nullptr)
        :ptr(_ptr)
    {}
    // 当进行智能指针拷贝构造的时候,进行“深拷贝”
    SmartPtr(const SmartPtr<T>& src)
    {
        ptr = new T(*src.ptr);
    }
    T& operator*() { return *ptr; }
    ~SmartPtr() { delete ptr; }
private:
    T *ptr;
};

int main()
{
    SmartPtr<int> ptr1(new int(666));
    SmartPtr<int> ptr2(ptr1);
    *ptr2 = 20;
    cout << *ptr1 << endl; // 输出666
    return 0;
}

用户本来想让两个指针管理同一块资源,但是由于深拷贝,两个指针分别管理内容相同的内存。因此这达不到裸指针原本想要的效果。

在C++库中有三种不实现引用计数的智能指针,分别是

  • auto_ptr
  • scoped_ptr
  • unique_ptr

auto_ptr

int main()
{
    auto_ptr<int> ptr1(new int(10));
    auto_ptr<int> ptr2(ptr1);
    cout << *ptr1 << endl; // 空指针异常
    return 0;
}

上面这段导致系统崩溃了。原因其实就是auto_ptr的实现原理有关。当auto_ptr进行赋值构造的时候,当前智能指针就会管理资源的权力全部交给被赋值的对象,而自己就指向空了。

T* release()
{
	T* tmp = ptr;
	ptr = nullptr;
	return tmp;
}

所以导致当使用*ptr1的时候,就会报出空指针异常的错误。
由于auto_ptr这种特性,所以官方是不推荐使用这种指针的。因为可能由于忘记了哪一个智能指针有管理资源的权力,导致错误地使用了智能指针,最终导致了空指针异常。

scoped_ptr

scoped_ptr内部实现更加暴力,它不能被赋值或者拷贝。

scoped_ptr(const scoped_ptr<T>&) = delete;
scoped_ptr<T>& operator=(const scoped_ptr<T>&) = delete;

unique_ptr

unique_ptr和scoped_ptr有一点是相同的,unique_ptr也是不能被拷贝或者赋值的。但是不同的时候,unique_ptr中支持移动构造和移动赋值。

unique_ptr(const unique_ptr<T>&) = delete;
unique_ptr<T>& operator=(const unique_ptr<T>&) = delete;

// 支持移动构造和移动赋值
unique_ptr(const unique_ptr<T>&& src);
unique_ptr<T>& operator=(const unique_ptr<T>&& src);

如果配合move将变量转成右值引用或者传递临时对象的时候,就可以拷贝构造或者赋值出unique_ptr。

template<typename T>
unique_ptr<T> getSmartPtr()
{
    unique_ptr<T> ptr(new T());
    return ptr;
}

int main()
{
    unique_ptr<int> ptr1(new int);
    //  unique_ptr ptr2(ptr1) // 错误
    unique_ptr<int> ptr2(move(ptr1)); // move移动语义将变量强转成右值

    unique_ptr<int> ptr3 = getSmartPtr<int>(); // getSmartPtr返回的是临时智能指针对象
    return 0;
}

综合来说还是比较推荐使用unique_ptr的,因为在智能指针之间的拷贝或者赋值的时候,使用者还是会感知到资源是被转移的。而不像auto_ptr会经常忘记资源管理权在谁手上。

带引用计数的智能指针:shared_ptr和weak_ptr

不带引用计数的智能指针同时只能存在一个智能指针在管理资源。而带引用计数的智能指针就可以实现多个智能指针管理同一块资源。
其原理就是:每一个智能指针对象都有一个引用计数,记录就是该智能指针指向的资源有多少个对象在管理。每当多出一个对象管理该资源,引用计数++。反之,若对象不管理该资源,引用计数–。并且只有当引用计数为0的时候,资源才会被释放。

/**
 * 多个对象共用同一个引用计数空间
 */
template<typename T>
class RefCnt
{
public:
    RefCnt(T *ptr = nullptr)
        : ptr_(ptr)
        , count_(0)
    {
        if (ptr_ != nullptr)
           count_ = 1;
    }
	// 线程不安全
    void addRef() { count_ ++; }
    int delRef() { return -- count_; }
private:
    T *ptr_;
    int count_;
};

/**
 * 实现是一个带引用计数的智能指针(不保证线程安全)
 */
template<typename T>
class SmartPtr
{
public:
    SmartPtr(T *ptr = nullptr)
            : ptr_(ptr)
    {
        refCnt_ = new RefCnt<T>(ptr_);
    }
    // 拷贝构造
    SmartPtr(const SmartPtr<T>& src)
        : ptr_(src.ptr_)
        , refCnt_(src.refCnt_)
    {
        if (ptr_ != nullptr)
            refCnt_->addRef();
    }
    // 赋值
    SmartPtr<T>& operator=(const SmartPtr<T>& src)
    {
        if (this == &src)
            return *this;

        // 如果当前对象的引用计数为0的时候,就释放该对象
        if (0 == refCnt_->delRef())
        {
            delete ptr_;
        }
        ptr_ = src.ptr_;
        refCnt_ = src.refCnt_;
        refCnt_->addRef();
        return *this;
    }

    T& operator*() { return *ptr_; }
    T* operator->() { return ptr_; }
    ~SmartPtr()
    {
		// 只有当引用计数为0的时候,才会delete回收资源
        if (0 == refCnt_->delRef())
        {
            delete ptr_;
            ptr_ = nullptr;
        }
    }
private:
    T *ptr_;
    RefCnt<T> *refCnt_; // 指向该资源的引用计数的指针
};

int main()
{
    SmartPtr<int> ptr1(new int(0));
    SmartPtr<int> ptr2(ptr1);
    SmartPtr<int> ptr3;
    ptr3 = ptr2;
    *ptr1 = 666;
    cout << *ptr2 << ' ' << *ptr3 << endl;
    return 0;
}

上面实现的智能指针中RefCount的count使用的是int,而在addRef和delRef中都是直接使用++和–的,所以在多线程环境下会造成数据不一致的情况。
在share_ptr和weak_ptr的实现原理和上述智能指针类似,但是其引用计数采用的是atomic_int,其通过CAS锁机制保证了++和–是线程安全的。

shared_ptr带来的循环引用问题

shared_ptr被称为强智能指针,weak_ptr被称为弱智能指针。其中的区别就是shared_ptr会改变资源的引用计数,而weak_ptr不会改变资源的引用计数。
shared_ptr这种强智能指针会循环引用的问题,如下代码:

class B;
class A
{
public:
    A() { cout << "A()" << endl; }
    ~A() { cout << "~A()" << endl; }
    shared_ptr<B> ptrb_;
};

class B
{
public:
    B() { cout << "B()" << endl; }
    ~B() { cout << "~B()" << endl; }
    shared_ptr<A> ptra_;
};

int main()
{
    shared_ptr<A> ptra(new A());
    shared_ptr<B> ptrb(new B());

    ptra->ptrb_ = ptrb;
    ptrb->ptra_ = ptra;

	// use_count()可以查看指针指向的资源的引用计数
	cout << ptra.use_count() << endl; // 2
	cout << ptrb.use_count() << endl; // 2

    return 0;
}

当ptra和ptrb不再指向申请的内存的时候,A和B内部的ptrb_和ptra_还会指向申请的资源,导致引用计数没有减到0,因此资源没有释放。
智能指针的使用及其实现原理_第1张图片

此时就需要使用到weak_ptr。 一般在定义对象的时候,用强智能指针;引用对象的时候,使用弱智能指针。 由于弱智能指针不会影响到引用计数的增减,所以不会影响到资源的释放。

class B;
class A
{
public:
    A() { cout << "A()" << endl; }
    ~A() { cout << "~A()" << endl; }
    weak_ptr<B> ptrb_; // 弱智能指针
};

class B
{
public:
    B() { cout << "B()" << endl; }
    ~B() { cout << "~B()" << endl; }
    weak_ptr<A> ptra_; // 弱智能指针
};

int main()
{
    shared_ptr<A> ptra(new A());
    shared_ptr<B> ptrb(new B());

    ptra->ptrb_ = ptrb;
    ptrb->ptra_ = ptra;

    cout << ptra.use_count() << endl; // 1
    cout << ptrb.use_count() << endl; // 1

    return 0;
}

智能指针的使用及其实现原理_第2张图片

注意:weak_ptr可以解决循环引用的问题,是因为weak_ptr在申请资源的过程中只充当了一个观察者的身份。即weak_ptr不能像裸指针那样使用*ptr或者ptr->。如果一定想要使用weak_ptr调用其指向对象中的函数的话,就需要将weak_ptr提升成为shared_ptr。

class B;
class A
{
public:
    A() { cout << "A()" << endl; }
    ~A() { cout << "~A()" << endl; }
    void print() { cout << "hello world" << endl; }
    weak_ptr<B> ptrb_; // 弱智能指针
};

class B
{
public:
    B() { cout << "B()" << endl; }
    ~B() { cout << "~B()" << endl; }
    void print()
    {
		// 通过lock(),可以将弱智能指针提升为强智能指针
        shared_ptr<A> p = ptra_.lock();
        // 如果ptra_指向的资源还存在的话,就可以提升成功。否则返回nullptr
        // 判断ptra_提升成功为强智能指针。
        if (p != nullptr)
            p->print();

    }
    weak_ptr<A> ptra_; // 弱智能指针
};

int main()
{
    shared_ptr<A> ptra(new A());
    shared_ptr<B> ptrb(new B());
	
    ptrb->print(); // B: print() -> A:print() -> "hello world"
    return 0;
}

多线程下共享资源的线程安全问题

我们来看这样一个场景

class A
{
public:
    A() { cout << "A()" << endl; }
    ~A() { cout << "~A()" << endl; }
    void print() { cout << "hello world" << endl; }
};

void handler(A* ptr)
{
    ptr->print();
}

int main()
{
    A *ptr = new A();
	// 起一个线程去调用ptr指向的A()的print()
    thread t(handler, ptr);

    delete ptr;
    t.join();
    return 0;
}

结果:
智能指针的使用及其实现原理_第3张图片

如果我们将A析构,资源释放之后。子线程中是否还能够调用A()中的print()呢?

class A
{
public:
    A() { cout << "A()" << endl; }
    ~A() { cout << "~A()" << endl; }
    void print() { cout << "hello world" << endl; }
};

void handler(A* ptr)
{
    // 让子线程睡眠2s,让主线程中先调用delete将A析构掉
    this_thread::sleep_for(chrono::seconds(2));
    ptr->print();
}

int main()
{
    A *ptr = new A();
    thread t(handler, ptr);

    delete ptr;
    t.join();
    return 0;
}

结果::
在这里插入图片描述

我们惊奇地发现,虽然已经调用~A()释放了A对象的资源。但是线程任然可以调用A()的print()。这就导致了线程不安全。

想要解决这个问题就需要使用到weak_ptr和shared_ptr配合。在子线程中weak_ptr来观察资源是否存在,如果存在就提升为强智能指针然后调用A()的print()。如果不存在就不调用。

class A
{
public:
    A() { cout << "A()" << endl; }
    ~A() { cout << "~A()" << endl; }
    void print() { cout << "hello world" << endl; }
};

// 这里使用weak_ptr接收传递过来的“弱化”的ps
void handler(weak_ptr<A> ptrw)
{
    // 子线程睡眠2s,等待主线程析构掉ptrw指向的对象
    std::this_thread::sleep_for(std::chrono::seconds(2));
    shared_ptr<A> ptrs = ptrw.lock();
    if (ptrs != nullptr)
    {
        ptrs->print();
    }
    else
    {
        cout << "~A(), A was destroyed" << endl;
    }
}

int main()
{
    thread t;
    {
        shared_ptr<A> ps(new A());
        // 传递过去ps的弱智能指针
        t = thread(handler, weak_ptr<A>(ps));
    } // 出了作用域 ps 调用析构函数,销毁ps指向的A()对象
    
    t.join();
    return 0;
}

结果:智能指针的使用及其实现原理_第4张图片

因为A()资源被销毁了,所以弱智能智能没能成功升级,所以没有调用print();

如果想要让子线程中的弱智能指针升级为强智能指针的话,就可以让主线程不要那么快就将A()资源释放。

class A
{
public:
    A() { cout << "A()" << endl; }
    ~A() { cout << "~A()" << endl; }
    void print() { cout << "hello world" << endl; }
};

void handler(weak_ptr<A> ptrw)
{
    shared_ptr<A> ptrs = ptrw.lock();
    if (ptrs != nullptr)
    {
        ptrs->print();
    }
    else
    {
        cout << "~A(), A was destroyed" << endl;
    }
}

int main()
{
    thread t;
    {
        shared_ptr<A> ps(new A());
        // 传递过去ps的弱智能指针
        t = thread(handler, weak_ptr<A>(ps));
        
        // 让主线程睡眠2s,这样子线程中的智能指针就可以升级为强智能指针了
        std::this_thread::sleep_for(std::chrono::seconds(2));
    } // 出了作用域 ps 调用析构函数,销毁ps指向的A()对象

    t.join();
    return 0;
}

小总结:

  • weak_ptr帮助shared_ptr解决了循环引用的问题。
  • weak_ptr作为弱智能指针通常充当了一个观察者的身份,来判断其指向的资源是否存在。如果存在就可以提升成为强指针指针进行资源的使用;否则就不能使用资源。

自定义删除器

删除器就是一个函数对象。智能指针在调用析构函数需要以删除器这个函数对象重载的operator()来“释放”资源。在unique_ptr和shared_ptr中都是存在默认的删除器。

template<typename T>
class default_delete
{
public:
    void operator()(T* ptr)
    {
		// 默认直接使用delete释放资源
        delete ptr;
    }
};

源码:在这里插入图片描述

默认的释放资源的方式就是直接使用delete,但是很多资源不能使用delete。例如:如果申请了100个int元素的空间数组,就需要使用delete[]来释放资源。如果是打开的文件,要就需要使用fclose来关闭文件。
因此就需要我们自定义删除器,来做到定制化的“释放资源”服务。

template<typename T>
class arr_delete
{
public:
    void operator()(T* ptr)
    {
        delete[] ptr;
    }
};

template<typename T>
class file_delete
{
public:
    void operator()(T* ptr)
    {
        fclose(ptr);
    }
};

int main()
{
    unique_ptr<int, arr_delete<int>> ptr1(new int[100]);
    unique_ptr<FILE, file_delete<FILE>> ptr2(fopen("test.txt", "w"));
    return 0;
}

使用lambda表达式优化删除器

每一个定制的删除器只会被用在智能指针析构的时候,所以其实不用专门定义一个类。而可以使用lambda表达式来构造一个函数对象。

int main()
{
	// lambda表达式要放在unique_ptr的第二个参数中
	// 同时<>泛型的第二个参数要指明lambda表达式的类型,可以使用function<>
    unique_ptr<int, function<void(int*)>> ptr1(
			  new int[100]
			, [](int* ptr) { delete[] ptr; });
    unique_ptr<FILE, function<void(FILE*)>> ptr2(
             fopen("test.txt", "w")
			,[](FILE* ptr) { fclose(ptr);});
    return 0;
}

用make_shared,make_unique代替shared_ptr,unique_ptr

最后来说一下前面shared_ptr存在的问题。shared_ptr在使用的时候可能会出现循环引用的问题,这个可以通过使用weak_ptr做解决。但是shared_ptr还可能会出现内存泄漏的问题。

template<typename T>
class shared_ptr
{
private:
	int *ptr_;   // 指向资源的指针
	RefCnt *ref; // 指向资源的引用计数
};

在使用shared_ptr的时候,其中有两个指针,一个指向的是申请的资源,一个指向的是该资源的引用计数。这两块空间都是需要new出来的。如果申请的资源new成功了,但是引用计数的空间new失败了,那么这块空间就没有办法进行释放了,这就造成了内存泄漏。
智能指针的使用及其实现原理_第5张图片

注:uses是强引用指针的引用个数,weaks是弱引用指针的引用个数。

所以C++11中提供了make_shared来配合使用。make_shared的返回值就是shared_ptr,但是在make_shared的构造函数中不用手动进行new空间。而是直接将值写入make_shared的构造函数中自动会分配空间。最重要的是make_shared会将外界需要创建的空间和引用计数所需的空间一起开辟,从而使得两块空间都能开辟成功或者都失败
智能指针的使用及其实现原理_第6张图片

这样带来的好处就是两块空间一起开辟,分配资源的效率变高了。同时防止了资源泄漏的风险。

int main()
{
    shared_ptr<int> ps = make_shared<int>(10);
    auto ps2 = make_shared<int>(10);
    cout << *ps << ' ' << *ps2 << endl; // 10 10
    return 0;
}

但是这样也带来了一些缺陷。原本两块空间是分别存储的,所以当引用计数中强引用计数的个数为0的时候,申请的资源就可以被回收了。等待弱引用指针的指向也为0的时候,引用计数的空间才会被释放。现在申请的空间和引用计数的空间放在一起了,当强智能指针的指向为0,而弱智能指针的引用不为0的时候,不能释放申请的空间。因为此时引用计数不能释放,而两块空间是在一起的,所以申请的空间也不能被释放。这就导致了申请的内存被延迟释放的问题。
另外make_shared不能定制删除器,所以想要打开一个文件的时候,就不能将FILE托管给make_shared而只能给unique_ptr或者shared_ptr了。

总结:

  • make_shared优点
    1. 分配资源效率变高
    2. 防止资源泄漏
  • make_shared缺点
    1. 托管的资源延迟释放
    2. 不能定制删除器

类似的C++14中针对unique_ptr也做了一个make_unique,作用和make_shared类似,也是将unique_ptr指向的内存和引用计数的空间一起开辟防止资源泄漏。

enable_shared_from_this和shared_from_this()

如果想要在一个类中以智能指针的方式返回一个对象的资源,该怎么办呢?是这样吗?

class A
{
public:
    A()
    {
        ptr_ = new int(0);
    }

    ~A()
    {
        delete ptr_;
        ptr_ = nullptr;
    }

    shared_ptr<A> getSmartPtr()
    {
        return shared_ptr<A>(this);
    }

private:
    int* ptr_;
};


int main()
{
    shared_ptr<A> ptr1(new A());
    shared_ptr<A> ptr2 = ptr1->getSmartPtr(); // 获得A()中的智能指针
    
    cout << ptr1.use_count() << endl; // 1
    cout << ptr2.use_count() << endl; // 2
	// 系统崩溃
    return 0;
}

有上文可知,shared_ptr有两种构造函数,一种是针对裸指针的,一种是针对智能指针的。对于裸指针的构造函数,shared_ptr会创建出该指针指向的资源的引用计数。对于智能指针的构造函数,shared_ptr会增加指向资源的引用计数。

如果getSmartPtr()通过shared_ptr(this)这样实现的话,getSmartPtr返回的临时对象会创建出一个指向该资源的引用计数。当该临时对象销毁的时候,因为它指向的引用计数只有这个临时对象使用,所以随着临时对象的消失,该引用计数也要被销毁,从而资源也要被销毁。这个不是我们想要的。因为ptr1还在管理该资源,在ptr1析构的时候,又会析构一次该资源,那么就会导致nullptr exception。

shared_ptr(this)肯定是不行的了,所以C++中提供了enable_shared_from_this这个基类,该类中有一个成员函数shared_from_this()可以以智能指针的形式返回当前类的对象(等同于shared_ptr(this)),同时不会增加资源的引用计数的个数。从而不会造成资源被析构多次的情况。

class A : public enable_shared_from_this<A>
{
public:
    A()
    {
        ptr_ = new int(0);
    }

    ~A()
    {
        delete ptr_;
        ptr_ = nullptr;
    }

    shared_ptr<A> getSmartPtr()
    {
        return shared_from_this();
    }

private:
    int* ptr_;
};


int main()
{
    shared_ptr<A> ptr1(new A());
    shared_ptr<A> ptr2 = ptr1->getSmartPtr(); // 获得A()中的智能指针

    cout << ptr1.use_count() << endl; // 2
    cout << ptr2.use_count() << endl; // 2
    return 0;
}

原理实现

  • enable_shared_from_this类中有一个weak_ptr,当调用shared_ptr的裸指针的构造函数,就会初始化该weak_ptr,并作为观察者观察A对象的资源。(关键)
  • 当调用shared_from_this的时候,就会去尝试将weak_ptr升级为shared_ptr。如果weak_ptr观察的资源还存在的话,weak_ptr就可以升级为shared_ptr。否则不行。

问题集

SmartPtr *p = new p(SmartPtr());像这样使用智能指针管理一块堆资源可以吗?

不可以,因为申请的堆资源还是用裸指针来管理的。

auto_ptr可以用在容器当中吗?

不可以,因为容器中的拷贝和赋值涉及的元素很多。由于auto_ptr的赋值,会导致赋值之前的容器中的元素全部都不能再使用了。

注意事项

shared_ptr虽然能够帮助我们实现多个指针同时管理一块资源。但是也有很多问题:

  • 循环引用:
    • 要配合weak_ptr使用,让weak_ptr去观察资源,但是不增加引用计数的引用数量。
    • 编程建议:一般在定义对象的时候,用强智能指针;引用对象的时候,使用弱智能指针。
  • 资源泄漏:创建资源引用计数失败,导致资源无法被回收
    • 要配合make_shared使用。make_shard将资源空间和指向它的引用计数空间合并成一块。
    • 编程建议:多使用make_shared去管理资源。
  • 空指针异常:多次使用裸指针去构造shared_ptr,导致资源的引用计数有多个,在对象析构的时候,资源会被释放多次
    • 要配合enable_shared_from_this类和其成员函数shared_from_this()使用。
    • 编程建议:多个shared_ptr托管相同资源时,第一个shared_ptr使用裸指针构造,其他的shared_ptr都是用智能指针构造。

你可能感兴趣的:(C++,面试题,数据结构,算法,c++,数据结构)