C++语法|智能指针的实现及智能指针的浅拷贝问题、auto_ptr、scoped_ptr、unique_ptr、shared_ptr和weak_ptr详细解读

1.自己实现智能指针

我们在C++变成中使用指针最麻烦的问题就是关于资源的及时释放,尽管我们已经很小心得检查了内存泄漏问题,但还是难免有一些突发情况导致我们定义的指针成为野指针。所以我们对智能指针最简单的要求:

  • 保证做到资源的自动释放
  • 利用栈上的对象出作用域自动析构的特征,来做到资源的自动释放(所以智能指针一定不能放到堆上)
template<typename T>
class SmartPointer {
private:
	T *mptr;
public:
	SmartPointer(T *p = nullptr): mptr(p) { }
	~SmartPointer(): { delete mptr; }
	T& operator*() { return *mptr;}
	T* operator->() { return mptr;}
}

由此我们实现了构造、析构函数和重载解引用和重载成员访问运算符,接下来我们来测试其功能。

int main() {
    SmartPointer<int> ptr1(new int(10));
    *ptr1 = 20;
    class Test {
    public:
        void test() { cout << "test()" << endl; }
    };
    SmartPointer<Test> ptr2(new Test());
    //(ptr2.operator->())->test();
    ptr2->test(); //(*ptr).test();

    return 0;
}

智能指针引起的浅拷贝问题

如果我们沿用上面实现的智能指针,

template<typename T>
class SmartPointer {
private:
	T *mptr;
public:
	SmartPointer(T *p = nullptr): mptr(p) { }
	~SmartPointer(): { delete mptr; }
	T& operator*() { return *mptr;}
	T* operator->() { return mptr;}
}
int main() {
	SmartPointer<int> p1(new int);
	SmartPointer<int> p2(p1);
	return 0;
}

我们通过智能指针p1管理一块整型资源,然后拷贝构造p2,发现程序运行崩溃,这是为什么呢?

因为我们这里在做拷贝构造的时候是做的浅拷贝,在程序结束后,把同一块资源释放了两次,造成了内存泄漏。

尝试定义自己的拷贝构造函数解决浅拷贝

template<typename T>
class SmartPointer {
private:
    T *mptr;
public:
    SmartPointer(T *p = nullptr): mptr(p) { }
    SmartPointer(const SmartPointer<T> &src) {
        mptr = new T(*src.mptr);
    }
    ~SmartPointer() { delete mptr; }
    T& operator*() { return *mptr; } //注意这里返回值是一个引用
    T* operator->() { return mptr; }
};

现在我们在进行拷贝构造的时候,又new了一个空间,所以在析构的时候,各自析构自己的空间。此时代码就不会崩溃了,但是这样又引发了一个新问题:那就是不符合用户区域。

当我们使用p2拷贝构造p1的时候,希望p1和p2管理同一块资源。
也就是说,当用户操作p1和p2的时候,希望操作的是同一个指针,但其实并不是这样的,因为我们进行了深拷贝,所以p1和p2所管理的资源完全就是两块不同的资源。

所以不满足要求!

如何解决呢?

1. 不带引用计数的智能指针

2. 带引用计数的智能指针

2.不带引用计数的智能指针

auto_ptr:在C++17之后不再支持
scoped_ptr unique_ptr:C++11新标准

auto_ptr

auto_ptr<int> ptr1(new int); //C++17标准中删除
auto_ptr<int> ptr2(ptr1);
*ptr2 = 20;
cout << *ptr1 << endl;

现在我们想看看ptr1的值是否被ptr2覆盖,但是我们发现程序崩溃。

这是因为auto_ptr的拷贝构造函数,它先调用了一个release方法,然后返回release的调用结果,其中release的源码如下:

_LIBCPP_INLINE_VISIBILITY _Tp* release() _NOEXCEPT
{
    _Tp* __t = __ptr_;
    __ptr_ = nullptr;
    return __t;
}

他先把原指针拷贝给一个临时变量,然后把原指针置为nullptr,最后返回临时变量。这就表示我们的ptr1被置为空指针了,后来的cout << *ptr1 << endl;只在操作一个空指针了!这是不被允许的。

总结:auto_ptr的解决浅拷贝逻辑就是让后来的指针来管理资源,放弃之前的指针!所以我们不推荐auto_ptr,特别是在容器中不推荐使用,比如说vector<auto_ptr<int>> vec1; vec2(vec1)。我们容器的使用过程中往往会使用容器的拷贝和赋值操作,会导致容器中每个元素的拷贝和赋值,造成容器所有元素失效。

scoped_ptr

scoped_ptr解决浅拷贝问题更加直接,他直接删除了拷贝和赋值操作:

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

unique_ptr(推荐)

这种智能指针也是只让一个指针来管理资源。首先unique_ptr也是做了一个这样的操作:

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

但是,我们可以使用:

unique_ptr<int> p1(new int);
unique_ptr<int> p2(std::move(p1));

其中std::move可以得到当前变量的右值类型,也就是右值强转操作,那是因为unique_ptr提供了移动构造和移动赋值构造:

unique_ptr(const scoped_ptr<T> &&src)
unique_ptr<T>& operator=(const scoped_ptr<T> &&src)

所以我们的p1的资源全部移动给了p2,所以我们还是不能去访问p1。但是unique_ptr的好吃就是,我们在拷贝构造的过程当中,用户的用意是非常明显的,用户既然都调用move了,所以就是明确要把p1的资源移动给p2。而不像我们在使用auto_ptr在用户没有感知的情况下去操作一个空指针,也不像scoped_ptr那么死板。

3.带引用计数的智能指针

带引用计数的好处就是多个智能指针可以管理同一个资源;

什么叫引用计数呢?给每一个对象资源匹配一个引用计数,当一个智能指针管理这个资源的时候,引用计数+1;当一个智能指针不再使用资源的时候,引用计数-1;并且只要引用计数不为0,就不允许析构;当引用计数为0时,说明该指针已经是管理资源的最后一个指针了,所以它在析构的时候必须释放资源。

这样就完美解决了浅拷贝导致的多次析构同一资源的问题。接下来我们会首先根据之前写的简单的智能指针代码为它添加引用计数的功能。

template<typename T>
class SmartPointer {
private:
	T *mptr;
public:
	SmartPointer(T *p = nullptr): mptr(p) { }
	~SmartPointer(): { delete mptr; }
	T& operator*() { return *mptr;}
	T* operator->() { return mptr;}
}
int main() {
	SmartPointer<int> p1(new int);
	SmartPointer<int> p2(p1);
	return 0;
}

模拟实现引用计数

  • 首先我们完成一个对资源进行引用计数的类,这个类非常简单,主要就是能够记录有多少个智能指针指向了这个资源。
    其中成员方法addRef表示引用计数+1操作
    delRef表示引用计数-1操作并且返回当前指向资源的指针个数。
template <typename T>
class RefCnt {
public:
    RefCnt(T *ptr = nullptr): mptr(ptr), mcount(1) {
        if (mptr != nullptr)
            mcount = 1;
    }
    void addRef() { mcount++; } //添加资源的引用计数
    int delRef() { return --mcount;}  
private:
    T *mptr;
    int mcount;
};
  • 然后是重写我们的构造函数和析构函数
template<typename T>
class SmartPointer { //shared_ptr
private:
    T *mptr;    // 指向资源的指针
    RefCnt<T> *mpRefCnt; //指向该资源引用计数对象的指针
public:
    SmartPointer(T *p = nullptr): mptr(p) {
        mpRefCnt = new RefCnt<T>(mptr);
    }

    ~SmartPointer() { 
        if (0 == mpRefCnt->delRef())
        {
            delete mptr; 
            mptr = nullptr;
        }
    }

构造函数需要初始化一个RefCnt,析构函数判断只有当引用计数为0的时候,才对资源进行释放。

  • 重构拷贝构造函数
    SmartPointer(const SmartPointer<T> &src)
        :mptr(src.mptr), mpRefCnt(src.mpRefCnt) {
        if (mptr != nullptr)
            mpRefCnt->addRef();       
    }

拷贝构造需要首先把当前资源给过去,然后把当前的引用计数对象也给拷贝变量,并且我们需要把计数+1,这里调用的是引用计数类的addRef方法

  • 重构重载赋值运算符
    SmartPointer<T>& operator=(const SmartPointer<T> &src) {
        if (this == &src)
            return *this;
        if(0==mpRefCnt->delRef()){ //给原来使用的资源减少一个引用计数
            delete mptr;
        }
        mptr = src.mptr;
        mpRefCnt = src.mpRefCnt;
        mpRefCnt->addRef();
        return *this;
    }

在这里我们基本实现了一个shared_ptr的核心代码,不过我们这里还存在一个问题,再多线程操作中,该类涉及到了对共享资源count的频繁操作,所以标准库中的智能指针模板都是原子操作的,也就是说他们都是线程安全的。

shared_ptr和weak_ptr

shared_ptr为强智能指针,可以改变资源的引用计数。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; }
	_ptra->testA();
    weak_ptr<A> _ptra;
};
int main () {
    shared_ptr<A> pa(new A());
    shared_ptr<B> pb(new B());
    cout <<  pa.use_count() << endl;
    cout <<  pb.use_count() << endl;
    return 0;
}

输出时:

A()
B()
1
1
~A()
~B()

符合资源分配的预期。那如果我们在main函数中做这样一个事情。

int main () {
    shared_ptr<A> pa(new A());
    shared_ptr<B> pb(new B());

    pa->_ptrb = pb;
    pb->_ptra = pa;

    cout <<  pa.use_count() << endl;
    cout <<  pb.use_count() << endl;

    return 0;
}

循环引用导致了什么结果

如果执行该程序,我们会发现我们的A、B类竟然没有析构,并且引用计数都是2.

引用计数造成了new出来的资源无法释放!! 这是严重的资源泄漏问题。

我们首先一段一段讲解代码。

首先我们new了两个堆内存 A类 和 B类,A类中有一个指向B类的智能指针_ptrb;B类中有一个指向A的智能指针_ptra。

栈上初始化了两个智能指针 shared_ptr<A> pa(new A()); shared_ptr<B> pa(new B());分别指向堆内存上的A类和B类:

此时我们的智能指针计数分别是 1 1

现在,我们堆上的_ptrb也指向了B类,所以堆上放B类的内存资源计数为2;
同理,_ptra指向了A类,A类的资源计数也为2;

所以我们打印资源计数的时候也是两个2,所以出作用域的时候,pb先析构,然后析构pa,但是他们并不能释放资源,因为2-1 = 1,所以堆上的内存还不能析构。

这就是典型的循环引用问题。

如何解决

定义对象的时候用强智能指针,引用对象的地方使用弱智能指针

class B;
class A {
public:
    A() { cout << "A()" << endl; }
    ~A() { cout << "~A()" << endl; }
    void testA() { cout << "非常好的方法!" << endl; }
    weak_ptr<B> _ptrb;
};

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

此时就能完成正常的打印。A、B对象都析构了,这是为什么呢?
看下图:

堆上的资源都是弱智能指针,由于弱智能指针不会改变资源的引用计数,那也就是说我们_ptrb和_ptra都分别指向了A类和B类,但是由于他们是弱智能指针,他只起一个观察的作用,也就是说观察这个资源还活着没。所以说我们两个资源的引用计数都是 1 和 1,那么等智能指针作用域结束,资源也就能正常释放了。

弱智能指针提升为强智能指针,让其拥有裸指针类似的行为

假如说我们的A类有一个“非常好用的方法”,我们希望能够在B类中利用那个指向A类的成员属性来调用那个“非常好用的方法”。
如下:

class B;
class A {
public:
    A() { cout << "A()" << endl; }
    ~A() { cout << "~A()" << endl; }
    void testA() { cout << "非常好的方法!" << endl; }
    weak_ptr<B> _ptrb;
};
class B {
public:
    B() { cout << "B()" << endl; }
    ~B() { cout << "~B()" << endl; }
    void func () {
        _ptra->testA();
    }
    weak_ptr<A> _ptra;
};

但是很可惜,_ptra->testA()错误,不能这样调用,因为我们的weak_ptr只能观察资源,他不能使用资源,也就是说弱智能指针根本就没有提供operator*和operator->。我们不能把它当一个裸指针来操作。

那应该如何使用呢?

class B {
public:
    B() { cout << "B()" << endl; }
    ~B() { cout << "~B()" << endl; }
    void func () {
        shared_ptr<A> ps = _ptra.lock(); //弱智能指针的提升方法,提升为强智能指针
        if (ps != nullptr) {
            ps->testA();
        }
    }
    weak_ptr<A> _ptra;
};

调用弱智能指针的lock()方法,把它提升为一个强智能指针。再一个,我们需要注意,在多线程编程中,由于weak_ptr只作为一个观察着,所以我们在使用提升方法的过程中,又可能提升失败,因为资源有可能已经释放了,所以我们必须检查调用lock()方法后生成的指针不为空,才说明提升强智能指针成功。这样我们才能在B类中调用A类的那个非常好用的方法。
并且,我们在主函数中使用它。

int main () {
    shared_ptr<A> pa(new A());
    shared_ptr<B> pb(new B());

    pa->_ptrb = pb;
    pb->_ptra = pa;

    cout <<  pa.use_count() << endl;
    cout <<  pa.use_count() << endl;

    pb->func();

    return 0;
}

5.多线程访问共享对象问题

加入有一个类A

class A {
public:
    A() { cout << "A()" << endl; }
    ~A() { cout << "~A()" << endl; }
    void testA() { cout << "非常好的方法!" << endl; }
};

我们在main函数里new了一个对象A,然后启动一个线程,传入线程函数,随后是释放资源A。最后等待线程结束。在子线程中我们调用A类那个非常好用的方法。

void handler01(A *q) {
	q->testA();
}
int main () {
	A *p = new A();
	thread t1(handler01, p);
	std::this_thread::sleep_for(std::chrono::seconds(2));
	delete p;
	t1.join();
	return 0;
}

这个过程是没有任何问题的。


我们在handler01中模拟这样一个问题:我们让子线程睡两秒,不让主线程睡了,也就是说,我们想先delete掉A类这块资源,然后再让子线程来访问这块资源,按道理来说这是不被允许的,然而,子线程还是完成了调用,不符合我们的预期。

void handler01 (weak_ptr<A> pw) {
    std::this_thread::sleep_for(std::chrono::seconds(2));
    sp->testA();
}
int main () {
	A *p = new A();
	thread t1(handler01, p);
	delete p;
	t1.join();
	return 0;
}

这非常不合理!因为析构也就意味着我们已经把外部资源释放了,原来资源已经啥都没有了,但是子线程仍然在进行访问。


所以我们希望q在访问A对象的时候,需要侦测一下A对象是否存活,如果存活,我们可以访问,如果已经被析构了,我们就不应该调用该方法。
这就是我们的多线程访问共享对象的安全问题。

给对象添加引用计数使用强弱智能指针监控共享对象

在主函数的初始化中,我们定义一个强智能指针,并且给线程仍一个弱智能指针,然后去掉delete,因为有智能指针帮我们做资源释放,并且我们加一个作用域来模拟资源被析构时,观察子线程还能够访问A对象:

int main () {
    {
        shared_ptr<A> p(new A());

        thread t1(handler01, weak_ptr<A>(p));
        t1.detach();
        std::this_thread::sleep_for(std::chrono::seconds(1));
    }
    std:this_thread::sleep_for(std::chrono::seconds(5));
    return 0;
}

引用的时候用一个弱智能指针,并且在访问A对象的时候,侦测A是否存活

//子线程
void handler01 (weak_ptr<A> pw) {
    std::this_thread::sleep_for(std::chrono::seconds(2));
    //q访问A对象的时候,需要侦测一下A对象是否存活
    shared_ptr<A> sp = pw.lock();
    if (sp != nullptr)
        sp->testA();
    else 
        cout << "A对象已经析构,不能再访问!" << endl;
}

最后我们能观察到理想中的结果:

A()
~A()
A对象已经析构,不能再访问!

然后将handler01睡觉的时间改成1,main函数子作用域的睡觉时间改成2,也就是说子线程在调用A对象的时候A还活着。
打印结果如下:

A()
非常好的方法!
~A()

符合预期!

6.自定义删除器

我们都知道智能指针能够保证资源的绝对释放,在之前,释放资源都是用的delete ptr.
那比如说,如果我们用智能指针来管理数组的资源,那么得在中间加一个中括号,又比如说用智能指针来管理一块文件资源或者是其他资源,那么释放这些资源也不是用的delete。

那么就需要思考一个问题了,如何给智能指针来自定义一个删除器来指导智能指针正确得删除资源呢?

库中的unique_ptr和shared_ptr都提供了自定义的删除器,如果看一下他们的源码的话会发现他们的析构函数调用了一个函数对象,通过对函数对象的调用来deletor(ptr)

~unique_ptr() { 函数对象的调用 	deletor(ptr) }

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

自定义数组删除器

如果我们想自定义删除对象的方式,我们直接给他提供一个这样的模板即可,比如说

int main () {
	unique_ptr<int> ptr1(new int[100]); //delete []ptr
	return 0;
}

我们自己写一个删除资源的方法

template<typename T>
class MyDeletor {
public:
	void operator() (T *ptr)const{
		cout << "call MyDeletor.operator()" << endl;
		delete []ptr;
	}
};

//调用
int main () {
	unique_ptr<int, MyDeletor<int>> ptr1(new int[100]); //delete []ptr
	return 0;
}

我们可以很顺利的看到终端打印出 call MyDeletor.operator()。说明用到了我们自己定义的删除器。

自定义文件资源删除器

template<typename T>
class MyFileDeletor {
public:
	void operator() (T *ptr)const{
		cout << "call MyDeletor.operator()" << endl;
		fclose(ptr);
	}
};

//调用
int main () {
	unique_ptr<FILE, MyFileDeletor<FILE>> ptr2(fopen("data.txt", "w"));
    return 0;
}

我们可以很顺利的看到终端打印出 call MyFlieDeletor.operator()。说明用到了我们自己定义的删除器。


但是这样自定义删除器不是特别好,因为我们往往需要定义一个模板类型,然后只使用在智能指针定义的语句当中,其他地方都再也用不到了,这个东西就像我们的临时量一样,它的使用只出现在某一个语句当中,那么有没有什么方法可以让我们直接在语句当中去指定我们自定义的删除器,而不用啰哩啰嗦的去自定义上面的两个模板类出来呢?

没错!!答案就是使用lambda表达式!
然而,定义智能指针的时候需要指定删除器的类型,但是我们只有lambda表达式的对象,那么lambda表达式对象的类型如何确定呢?
没错!!function函数对象!他可以留下我们lambda表达式的类型

使用lambda+function

我们在第一个传入模版类型的时候传入function,初始化列表中的第二个参数中写上lambda表达式

int main () {
	unique_ptr<int, funciton<void (int*)>> ptr1(new int[100], [](int *p)->void{ 
		cout << "call lambda release new int[100]" << endl;
		delete[]p;
	});
	unique_ptr<FILE, funciton<void (FILE*)>> ptr2(fopen("data.txt", "w"), [](FILE *p)->void{ 
		cout << "call lambda release new fopen" << endl;
		fclose(p);
	});

}
  • 13
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值