目录
一、认识智能指针
1.为什么要有智能指针
2.智能指针的概念
二、四类智能指针
1.auto_ptr
2.unique_ptr
(1)特性介绍
(2)指针的线程安全
(3)赋值运算符重载
4.weak_ptr
(1)循环引用
(2)weak_ptr特性
三、定制删除器
四、智能指针的发展和使用
在某些场景下,如果代码向堆区申请了资源并且出现了抛异常等等执行流跳跃的操作,控制不好就可能会出现诸如内存泄漏这样的问题。
例如下面的代码:
#include
using namespace std;
int division(int a, int b)
{
if (b == 0)
throw "Divisor can not be zero.";
else
return a / b;
}
void func()
{
char* p = new char[10];//申请空间
int a = 10;
int b = 0;
cout << division(10, 0);
delete[] p;//释放空间
}
int main()
{
try
{
func();
}
catch (const char* message)
{
cout << message << endl;
}
catch (...)
{
cout << "unknown error";
}
return 0;
}
看似可以申请并释放空间,实则在division函数中抛异常时,因为func函数没有接收异常的语句,所以异常会直接跳过func的语句执行mian函数内的catch语句,此时这块申请的空间就得不到释放,从而产生了内存泄漏的严重bug。
我们当然可以修改代码使其达到适时释放空间的目的,但是这还是需要程序开发者自己去设计逻辑。那么我们可不可以通过一个特殊的数据结构管理这块内存,使其自主在生命周期结束时释放空间呢?答案是当然可以,这个数据结构就是智能指针。
我们之前在C++异常中提到过RAII,RAII是英语Resource Acquisition Is Initialization(请求即初始化)的首字母,是一种利用对象生命周期来控制程序资源的简单技术,资源可以是内存,文件句柄,网络连接,互斥量等等。
智能指针便是基于这一思想开发的数据结构。它主要有两个特征:
我们试着建立一个简易的智能指针类管理上面的这块空间。
template
class Smart_ptr
{
public:
//构造函数
Smart_ptr(T* ptr)
:_ptr(ptr)
{}
//析构函数
~Smart_ptr()
{
delete _ptr;
_ptr = nullptr;
}
T* operator*()
{
return _ptr;
}
T* operator->()
{
return _ptr;
}
T& operator[](size_t pos)
{
return _ptr[pos];
}
private:
T* _ptr = nullptr;
};
我们使用这个类变量去接收该空间。
在VS2019中使用F11进行调试,你会发现当division函数抛出异常时,Smart_ptr就会调用析构函数。这是因为func函数没有catch能够接收异常,所以func的栈帧应该被销毁,此时该变量的生命周期结束,自然就被销毁。
这四类智能指针都需要包含头文件memory
auto_ptr属于早期的智能指针,在C++98就已经出现。
auto_ptr的大致实现(功能不全)和测试代码:
template
class auto_ptr
{
public:
//构造函数
auto_ptr(T* ptr)
:_ptr(ptr)
{}
//拷贝构造函数
auto_ptr(const auto_ptr& p)
:_ptr(p._ptr)
{
p._ptr = nullptr;
}
//析构函数
~auto_ptr()
{
delete _ptr;
_ptr = nullptr;
}
T* operator*()
{
return _ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr = nullptr;
};
int main()
{
auto_ptr ap1(new int);
auto_ptr ap2(ap1);
++(*ap1);
++(*ap2);
return 0;
}
注意观察拷贝构造函数,被拷贝的智能指针直接将指针赋值给了新的智能指针,原对象的指针被置空。
如果代码使用std中的auto_ptr,通过调试也可以验证,执行完拷贝后,ap2指向了原本ap1管理的内存空间,而ap1被置为空指针了。
auto_ptr会造成资源管理权的转移,在拷贝构造后,原本的智能指针就失效了。对于不清楚它这个特性的开发者而言,这将会是巨大的问题。正是由于auto_ptr这个失败的设计,auto_ptr也被列为了坚决不能使用的智能指针。正因为大家的嫌弃,C++17也彻底移除了auto_ptr。
你可能会想,既然auto_ptr因为拷贝构造被大家嫌弃,那么直接不让拷贝不就得了。巧了,C++委员会的人也是这么想的,所以unique_ptr出现了。
unique_ptr最早由C++委员会的成员开发并上传到C++的非标准boost库中,在C++11中它被正式加入标准库。
自如其名,独特的指针,unique_ptr不存在拷贝构造和可拷贝对象的赋值运算符重载函数。下面是它的简单实现。
template
class unique_ptr
{
public:
//构造函数
unique_ptr(T* ptr)
:_ptr(ptr)
{}
//拷贝构造函数
unique_ptr(const unique_ptr&) = delete;
//赋值运算符重载
unique_ptr& operator= (const unique_ptr&) = delete;
//析构函数
~auto_ptr()
{
delete _ptr;
_ptr = nullptr;
}
T* operator*()
{
return _ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr = nullptr;
};
在代码中只要拷贝和赋值就会报错,错误是尝试使用已删除函数。
那我如果就想拷贝怎么办呢?C++11也加入了shared_ptr,它就可以做到拷贝且二者都能管理同一份资源。
在测试代码中我们也能验证这一特点
#include
#include
int main()
{
std::shared_ptr sp1(new int);
std::shared_ptr sp2(sp1);
*sp1 = 1;
std::cout << *sp2;
*sp2 = 2;
std::cout << *sp1;
return 0;
}
那么shared_ptr到底是如何实现拷贝的目的的呢?
答案是引用计数,在shared_ptr内部维护了用于计数的变量。只有一个shared_ptr指向这份内存空间时,引用计数值就是1。当我们每拷贝构造一个智能指针时,引用计数就会加一,直到维护这块资源的所有智能指针全部被销毁,这块空间也才会被释放。
shared_ptr的初步实现
template
class shared_ptr
{
public:
shared_ptr(T* ptr = nullptr)
:_ptr(ptr)
, _pcount(new int(1))
{}
~shared_ptr()
{
if (--(*_pcount) == 0)
{
delete _ptr;
delete _pcount;
}
}
shared_ptr(shared_ptr& p)
:_ptr(p._ptr)
, _pcount(p._pcount)
{
++(*_pcount);
}
int use_count()
{
return *_pcount;
}
T* get() const
{
return _ptr;
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr;
int* _pcount;
};
首先,大致讲一下线程和锁的概念。线程是进程内的一个执行流,你可以简单理解为我们平常所写的代码在运行时会成为进程。而一个进程内可以有很多个线程,各个线程可以同时干不同的事情。
由于线程必须使用同一个进程的资源,所以当多个线程同时对一个变量进行操作时就有可能出现问题。所以,我们通过加锁的方式使加锁到解锁的这一段的代码在同一时间内只允许一个线程处理信息,只有在当前线程完成处理后才能允许其他线程工作,这样就杜绝了线程同步出错的问题。
C++11加入了线程相关的库,例如thread可以进行创建线程等操作。
mutex类可以进行加锁解锁的操作。
我们使用std中的shared_ptr进行演示,sp1是一个智能指针,thread t1和t2是两个线程,两个线程同时拷贝构造10000个智能指针,每拷贝一个就在指针维护的变量处加一,同时智能指针的引用计数也要加一。但在t1和t2两个线程加入等待队列后,线程就应该被销毁了,当然它内部拷贝的20000个对象也会被销毁,所以最后引用计数应为1,只有sp1一个。而指针维护的数据内容不会变,所以最终结果应为20000.
*(sp1.get())获取的是当前智能指针维护的变量,如果最后打印出来的结果是20000且不改变,则智能指针维护的资源是线程安全的,否则就是不安全的。
sp1.use_count()获取的是智能指针的引用计数,如果最后打印出来的结果是1且不改变,则智能指针本身是线程安全的,否则就是不安全的。
#include
#include
#include
int main()
{
int n = 10000;
std::shared_ptr sp1(new int(0));
std::thread t1([&]()
{
for (int i = 0; i < n; ++i)
{
std::shared_ptr sp2(sp1);
(*sp2)++;
}
});
std::thread t2([&]()
{
for (int i = 0; i < n; ++i)
{
std::shared_ptr sp3(sp1);
(*sp3)++;
}
});
t1.join();
t2.join();
std::cout << *(sp1.get()) << std::endl;
std::cout << sp1.use_count() << std::endl;
return 0;
}
多次运行结果:
运行三次,维护的资源每次结果都不一样,而引用计数是一样的。这也证明了,我们必须在线程对智能指针维护的数据进行修改的位置进行加锁,这样才能保证在同一时刻只有一个线程在操作变量。
这次我们在代码中进行加锁
#include
#include
#include
int main()
{
std::mutex m;//创建一把锁
int n = 10000;
std::shared_ptr sp1(new int(0));
std::thread t1([&]()
{
for (int i = 0; i < n; ++i)
{
std::shared_ptr sp2(sp1);
m.lock();//加锁
(*sp2)++;
m.unlock();//解锁
}
});
std::thread t2([&]()
{
for (int i = 0; i < n; ++i)
{
std::shared_ptr sp3(sp1);
m.lock();//加锁
(*sp3)++;
m.unlock();//解锁
}
});
t1.join();
t2.join();
std::cout << *(sp1.get()) << std::endl;
std::cout << sp1.use_count() << std::endl;
return 0;
}
运行结果不管测试几次,结果都只会是20000和1
总结:
智能指针本身是线程安全的,因为对引用计数的访问是互斥访问。
智能指针管理的资源不是线程安全的,必须加锁才能保证线程安全。
正因如此,我们需要对原先的shared_ptr进行线程安全改造,因为线程中只有引用技术_pcount会有线程安全的问题,所以我们需要在只要有对_pcount进行处理的位置都要加上锁,同时管理同一块资源的智能指针都用一把锁。
template
class shared_ptr
{
public:
shared_ptr(T* ptr = nullptr)
:_ptr(ptr)
, _pcount(new int(1))
,_pmtx(new std::mutex)
{}
void Release()
{
bool flag = false;
_pmtx->lock();
if (--(*_pcount) == 0)
{
delete _ptr;
delete _pcount;
flag = true;
}
_pmtx->unlock();
if (flag)
delete _pmtx;
}
~shared_ptr()
{
Release();
}
shared_ptr(shared_ptr& p)
:_ptr(p._ptr)
, _pcount(p._pcount)
, _pmtx(p._pmtx)
{
_pmtx->lock();
++(*_pcount);
_pmtx->unlock();
}
int use_count()
{
return *_pcount;
}
T* get() const
{
return _ptr;
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr;
int* _pcount;
std::mutex* _pmtx;
};
既然shared_ptr支持拷贝构造,那它也一定支持智能指针的赋值。
赋值的逻辑如下:
最后把赋值运算符重载加上,也完成了shared_ptr的大致实现:
template
class shared_ptr
{
public:
shared_ptr(T* ptr = nullptr)
:_ptr(ptr)
, _pcount(new int(1))
,_pmtx(new std::mutex)
{}
void Release()
{
bool flag = false;//flag判断是否需要释放锁
_pmtx->lock();
if (--(*_pcount) == 0)
{
//引用计数为0,资源被释放
delete _ptr;
delete _pcount;
flag = true;
//此时需要释放锁,但此时还需要使用锁不能直接释放
}
_pmtx->unlock();
if (flag)//在这里释放锁
delete _pmtx;
}
~shared_ptr()
{
Release();
}
shared_ptr(shared_ptr& p)
:_ptr(p._ptr)
, _pcount(p._pcount)
, _pmtx(p._pmtx)
{
_pmtx->lock();//_pcount有线程安全问题,需要加锁
++(*_pcount);
_pmtx->unlock();
}
int use_count()
{
return *_pcount;
}
shared_ptr operator=(shared_ptr& p)
{
if (p._ptr != _ptr)//自己给自己赋值不用处理
{
Release();
//释放当前智能指针
_ptr = p._ptr;
_pcount = p._pcount;
_pmtx = p._pmtx;
//拷贝为新内容
_pmtx->lock();
++(*_pcount);
_pmtx->unlock();
//引用计数加一
}
return *this;
}
T* get() const
{
return _ptr;
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr;
int* _pcount;
std::mutex* _pmtx;
};
我们之前学习过链表,如果将双向链表指向前节点的prev指针和指向后节点的next指针都改为shared_ptr,并用shared_ptr维护两个互相指向对方的链表节点。
struct list_node
{
shared_ptr _next;
shared_ptr _prev;
int _data = 0;
};
int main()
{
shared_ptr node1(new list_node);
shared_ptr node2(new list_node);
node1->_next = node2;
node2->_prev = node1;
return 0;
}
此时各有两个shared_ptr维护两个节点,prev和node1维护node1的资源,next和node2维护node2的资源,所以它们的引用计数都为2
但当main函数结束运行,应当对资源进行回收时却出现了问题。
如果shared_ptr的node1被释放,那么还有node2中的prev指向node1,所以虽然node1指针被释放了,但是它管理的资源不会被释放。
如果shared_ptr的node2被释放,那么还有node1中的next指向node2,所以虽然node2指针被释放了,但是它管理的资源也不会被释放。
到最后只是各自的引用计数减一,它们管理的资源无法被回收,就发生了内存泄漏。
上面的问题最根本在于我们默许prev和next指针参与资源的管理,因此为了解决上面的问题,weak_ptr就出现了。
weak_ptr是一个有资源指向能力但没有管理权的指针,所以你无论创建多少个weak_ptr,对应shared_ptr的引用计数也不会增加。
weak_ptr有以下特点:
如果将prev和next改为weak_ptr就可以正常运行。下面是weak_ptr的实现:
template
class weak_ptr
{
public:
//构造函数
weak_ptr(T* ptr)
:_ptr(ptr)
{}
//拷贝构造函数,参数为shared_ptr
weak_ptr(const shared_ptr& p)
:_ptr(p.get())
{}
//赋值运算符重载,参数为shared_ptr
weak_ptr& operator=(const shared_ptr& p)
{
_ptr = p.get();
return *this;
}
//析构函数
~weak_ptr()
{
delete _ptr;
_ptr = nullptr;
}
T* operator*()
{
return _ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr = nullptr;
};
智能指针可以管理非常多的资源,不光是一个变量,也可以是一个数组。
对于一个变量而言,可以使用delete _ptr,但对于一个数组而言,回收资源就需要用delete[] _ptr的方式。由于代码就已经在析构函数这里写死了,所以我们就需要一个定制删除器来实现对不同资源的不同释放方式。
我们将两种释放方式写为仿函数。
template
class delete_default
{
public:
void operator()(const T* ptr)
{
delete ptr;
std::cout << "delete ptr" << std::endl;
}
};
template
class delete_arry
{
public:
void operator()(const T* ptr)
{
delete[] ptr;
std::cout << "delete[] ptr" << std::endl;
}
};
我们在shared_ptr中加入定制删除器的缺省模板参数class D和成员变量_del,析构函数改为用_del处理对应指针。默认处理方式为delete_defaul,要注意这里传递的只能是仿函数而不能是lambda表达式,因为传递的必须是类型而不能是对象。
template>
class shared_ptr
{
public:
shared_ptr(T* ptr = nullptr)
:_ptr(_ptr)
, _pcount(new int(1))
, _pmtx(new std::mutex)
,_del(D())
{}
void Release()
{
bool flag = false;//flag判断是否需要释放锁
_pmtx->lock();
if (--(*_pcount) == 0)
{
//引用计数为0,资源被释放
_del(_ptr);
delete _pcount;
flag = true;
//此时需要释放锁,但此时还需要使用锁不能直接释放
}
_pmtx->unlock();
if (flag)//在这里释放锁
delete _pmtx;
}
~shared_ptr()
{
Release();
}
shared_ptr(shared_ptr& p)
:_ptr(p._ptr)
, _pcount(p._pcount)
, _pmtx(p._pmtx)
{
_pmtx->lock();//_pcount有线程安全问题,需要加锁
++(*_pcount);
_pmtx->unlock();
}
int use_count()
{
return *_pcount;
}
shared_ptr operator=(shared_ptr& p)
{
if (p._ptr != _ptr)//自己给自己赋值不用处理
{
Release();
//释放当前智能指针
_ptr = p._ptr;
_pcount = p._pcount;
_pmtx = p._pmtx;
//拷贝为新内容
_pmtx->lock();
++(*_pcount);
_pmtx->unlock();
//引用计数加一
}
return *this;
}
T* get() const
{
return _ptr;
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr;
int* _pcount;
std::mutex* _pmtx;
D _del;//定制删除器
};
int main()
{
shared_ptr> sp1(new int[10]);
shared_ptr sp2(new int(10));
return 0;
}
你以为这就只是多一个[]和少一个[]的问题吗?
那你就错了,智能指针管理的资源谁说只能是内存空间了。我们还可以用智能指针维护文件指针,从而实现文件的自主打开和关闭。
class file_close
{
public:
void operator()(FILE* ptr)
{
fclose(ptr);
std::cout << "fclose(ptr)" << std::endl;
}
};
int main()
{
shared_ptr> sp1(new int[10]);
shared_ptr sp2(new int(10));
std::shared_ptr sp3(fopen("test.txt", "w"), file_close());
return 0;
}
对于文件的shared_ptr改造我就直接使用std的shared_ptr,就不改造我们实现的shared_ptr了。
总而言之,定制删除器有些类似与我们多态的思想。根据不同的变量对象进行不同的资源释放,资源的类型很多,这也只是其中三种,还有很多资源的释放方式都可以集成在这里。
智能指针的概念在很早就被提出了,我们的祖师爷就在C++98中加入了auto_ptr。
auto_ptr这种管理权转移的设计存在巨大的缺陷,大部分人进行程序开发时都会避开auto_ptr,而且很多公司的项目开发也明令禁止auto_ptr的使用。
C++委员会有一个非标准库——boost库,你可以理解为这个库是C++标准的来源之一。boost库中的优秀语法及结构会被C++标准库直接收录或部分修改后收录。
我们学习的unique_ptr、shared_ptr和weak_ptr都是参照boost库中的智能指针修改加入到C++11中的。
其中,unique_ptr,禁止了拷贝和赋值。
shared_ptr通过引用计数解决了不能拷贝和赋值的缺陷,并且通过互斥锁保证了智能指针本身的线程安全,但是循环引用是它唯一的问题。
weak_ptr专门用于解决循环引用问题,它是一个仅有资源指向作用而不管理资源的智能指针。
在C++17中auto_ptr被删除,不过大家用的基本都是之前的标准,它被嫌弃的历史依旧没有过去。