首先看下面这个程序:
int div()
{
int a, b;
cin >> a >> b;
if (b == 0) throw invalid_argument("除以0错误");
return a / b;
}
void func()
{
int* p1 = new int[10];
int* p2 = new int[10];
div();
delete[] p1;
delete[] p2;
}
int main()
{
try
{
func();
}
catch (const exception& e)
{
cout << e.what() << endl;
}
return 0;
}
这个程序最尴尬的地方就在于 func 函数,这里有 3 个地方可能会抛异常,如果 p1 的 new 抛异常,那么直接跳到 main 函数去处理,没问题;如果 p2 的 new 抛异常,跳出去后就会导致 p1 没有释放,内存泄漏;如果 div() 抛异常,那么就会导致 p1 和 p2 都没有释放。
如何解决呢?
如果在 func 函数内进行捕获再抛出呢?
void func()
{
int* p1 = new int[10];
int* p2 = new int[10];
try
{
div();
}
catch (...)
{
delete[] p1;
delete[] p2;
throw;
}
delete[] p1;
delete[] p2;
}
如果 div() 抛出异常就在 func 函数内部捕获然后释放已经开辟的 p1 和 p2 后抛出。但是这里还有一个问题,如果 p2 抛异常呢?p2 抛异常就会直接跳出到 main 函数,导致 p1 未释放。如果把 p2 放到 try 块里面,又会导致最后一行正常释放的 delete[] p2;
报未定义的错误。
所以这里用 try/catch 解决比较困难也比较麻烦。更好的解决方式就是使用我们下面要学的智能指针。
RAII
RAII (Resource Acquisition Is Initialization) “资源获取即初始化”,是C++管理资源,避免泄漏的一种方法。它利用对象的生命周期来控制资源(如内存、文件句柄、网络链接、互斥量等)。
在对象构造时获取资源,接着控制对资源的访问使之在对象的生命周期内始终保持有效,最后在对象析构的时候释放资源,这实际上是把管理一份资源的责任委托到一个对象上。这样做我们就不需要显式释放资源
下面我们简单地包装一个智能指针类,然后把 new 出来的指针交给这个类的对象管理:
template<class T>
class SmartPtr
{
public:
SmartPtr(T* ptr)
: _ptr(ptr)
{}
~SmartPtr()
{
delete _ptr;
}
private:
T* _ptr;
};
int div()
{
int a, b;
cin >> a >> b;
if (b == 0) throw invalid_argument("除以0错误");
return a / b;
}
void func()
{
SmartPtr<int> sp1(new int[10]);
SmartPtr<int> sp2(new int[10]);
SmartPtr<int> sp3(new int[10]);
SmartPtr<int> sp4(new int[10]);
div();
}
int main()
{
try
{
func();
}
catch (const exception& e)
{
cout << e.what() << endl;
}
return 0;
}
这样只要 sp1、sp2、sp3、sp4 这些对象调用析构函数就能释放空间,对象的生命周期跟着函数跑,我们不用担心它不会被销毁。
我们上面实现的智能指针还不完整,还需要实现指针的行为,不过这已经体现了 RAII 的核心思想。
// 重载*和->,使之可以像指针一样使用
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
智能指针的基本思想有了,但是它的实现却面临着两大难题
我们下面来看C++是如何解决这两个问题的
回想普通指针之间的拷贝,就是让两个指针指向同一块空间,不会开辟新的空间,但是两个智能指针如果指向同一块空间,那么它的两个对象一共析构两次,导致同一块空间被释放两次,程序崩溃。
我们先看 C++98 是如何解决这个问题的
C++98 的智能指针:auto_ptr
它的核心思想是管理权转移,即直接将指针的管理权移交给要拷贝的对象
auto_ptr<int> sp1(new int(10));
auto_ptr<int> sp2 = sp1;
cout << *sp1; // 运行此处时程序崩溃
cout << *sp2;
这显然不合理啊,原来的智能指针不能用了,不符合拷贝的含义。
我们可以轻松实现它的拷贝构造:
auto_ptr(auto_ptr<T>& sp)
: _ptr(sp.ptr)
{
sp._ptr = nullptr;
}
目前 auto_ptr
已被废弃,很多公司命令禁止使用 auto_ptr
C++11 智能指针发展得较为成熟,引入了 3 类智能指针
该智能指针非常简单粗暴,就是明确表示不允许拷贝。
unique_ptr<int> sp1(new int(10));
unique_ptr<int> sp2 = sp1; //错误 C2280 尝试引用已删除的函数
C++11 直接使用 delete 禁止了其默认拷贝构造和赋值重载的生成
unique_ptr(const unique_ptr<T>& sp) = delete;
unique_ptr<T>& operator=(const unique_ptr<T>& sp) = delete;
防拷贝还有一种方式:将拷贝构造和赋值重载显式声明出来,但是不去实现,并把它设为 private,防止其他人在类外实现。
该智能指针真正解决了拷贝问题,可以当成普通的指针来使用。
它的核心原理是,采用引用计数共有多少个对象管理同一块资源,最后一个析构的对象负责释放资源。
要让多个对象共用一个成员变量进行计数,静态成员变量行不行?
显然不行,因为静态成员变量是属于所有一个类的所有对象的,不只是指向同一块空间的所有对象。
解决方式:
int* _pCount;
它指向的是一个 int,是用来计数的空间,我们让它的计数空间和 _ptr 的资源同时分配(在构造函数中给 _pCount new 一个 int 并初始化为 1),保证一块资源配一个计数空间(计数器)*_pCount
+1。保证两个对象指向的是同一块资源和同一个计数器。*_pCount
-1,如果减到0,则释放资源和计数器。template<class T>
class shared_ptr
{
public:
shared_ptr(T* ptr)
: _ptr(ptr)
, _pCount(new int(1))
{}
~shared_ptr()
{
if (--(*_pCount) == 0 && _ptr)
{
delete _ptr;
delete _pCount;
_ptr = nullptr;
_pCount = nullptr;
}
}
shared_ptr(const shared_ptr<T>& sp)
: _ptr(sp.ptr)
, _pCount(sp._pCount)
{
++(*_pCount);
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
T* get()
{
return _ptr;
}
private:
T* _ptr;
int* _pCount;
};
赋值重载的实现稍微麻烦一点
this
和 &sp
,而是直接比较_ptr
和 sp._ptr
。shared_ptr<T>& operator=(const shared_ptr<T>& sp)
{
if (_ptr == sp._ptr) return *this;
if (--(*_pCount) == 0)
{
delete _ptr;
delete _pCount;
_ptr = nullptr;
_pCount = nullptr;
}
_ptr = sp._ptr;
_pCount = sp._pCount;
++(*_pCount);
return *this;
}
解决了拷贝问题,智能指针还面临一个问题——循环引用
用智能指针让两个双链表的结点互连,代码如下:
struct ListNode
{
std::shared_ptr<ListNode> _next = nullptr;
std::shared_ptr<ListNode> _prev = nullptr;
int _val = 0;
~ListNode()
{
cout << "~ListNode()" << endl;
}
};
void test1()
{
std::shared_ptr<ListNode> p1(new ListNode);
std::shared_ptr<ListNode> p2(new ListNode);
p1->_next = p2;
p2->_prev = p1;
}
程序运行结果发现 ListNode
的析构函数没有被调用,这是怎么回事?
在两个结点互连之后,两个结点资源对应的计数器都为2(一个资源有2个只能指针维护,如 p1
指向的结点还有 p2->_prev
指向),函数结束后智能指针 p1
p2
被销毁,两个结点的计数器都变为1,没有变为0,无法释放。此时如果要让一个结点释放->那么必须先把它的计数器变为0->要让另一个结点的成员指针释放->要让另一个结点释放,这个逻辑死循环了,所以两个都不会释放。
怎么解决呢?
一个方案就是,让结点内部指针 _prev
和 _next
指向的时候不使对应的资源的计数器+1,那么它们俩用原生指针行不行?不行,因为原生指针无法接受智能指针类型 p1->_next = p2;
p2->_prev = p1;
这两句会报错。
为了解决这个问题,C++引入了智能指针 weak_ptr
weak_ptr - C++ Reference (cplusplus.com)
该指针不参与资源的创建与释放,它的特点是 share_ptr
拷贝给它的时候不会让计数器+1,它的释放也不会让计数器-1
其构造函数原型:
//default (1)
constexpr weak_ptr() noexcept;
//copy (2)
weak_ptr (const weak_ptr& x) noexcept;template <class U> weak_ptr (const weak_ptr<U>& x) noexcept;
//from shared_ptr (3)
template <class U> weak_ptr (const shared_ptr<U>& x) noexcept;
作为 share_ptr
的辅助,它只支持默认构造,拷贝构造,传入 share_ptr
,不支持传入原生指针。
要解决上面的问题,我们只要把 ListNode
定义的 _next
和 _prev
改成 weak_ptr
就可以了:
struct ListNode
{
std::weak_ptr<ListNode> _next;
std::weak_ptr<ListNode> _prev;
int _val = 0;
~ListNode()
{
cout << "~ListNode()" << endl;
}
};
下面是 weak_ptr
的简易实现(库里的实现还要复杂很多,此处不细讲):
template<class T>
class weak_ptr
{
public:
weak_ptr()
: _ptr(nullptr)
{}
weak_ptr(const shared_ptr<T>& sp)
: _ptr(sp.get())
{}
weak_ptr<T>& operator=(const shared_ptr<T>& sp)
{
_ptr = sp.get();
return *this;
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr;
};
总结:
使用智能指针能避免大部分的内存泄漏问题,但是还有少部分场景不注意会出现内存泄漏,比如循环引用问题,如果程序员没识别出这个场景并改用 weak_ptr
还是会内存泄漏。相比之下,unique_ptr
更安全一些,也更节省空间,在不需要拷贝的场景,推荐使用 unique_ptr
。
又一个问题,智能指针的析构函数释放资源用的是 delete
,是写死的,如果 new 的时候用了 [] 就会出现不匹配的问题。不仅如此,如果指向的资源是 malloc
出来的呢,又或者智能指针是一个文件指针呢?
std::unique_ptr<Date> up1(new Date[10]);
std::unique_ptr<FILE> up2((FILE*)fopen("test.cpp", "r"));
std::unique_ptr<Date> up3((Date*)malloc(sizeof(Date)));
我们来看 C++ 是如何解决的
unique_ptr
类模板原型:
//non-specialized
template <class T, class D = default_delete<T>> class unique_ptr;
//array specialization
template <class T, class D> class unique_ptr<T[],D>;
可以看到,这里提供了一个模板参数 class D = default_delete
,这就是删除器,它支持传入仿函数类型,可以由我们自己定制。
要 delete 多个对象,我们就可以传入这样一个仿函数:
template<class T>
struct DeleteArray
{
void operator()(T* ptr)
{
delete[] ptr;
}
};
std::unique_ptr<Date, DeleteArray<Date>> up1(new Date[10]);
释放 malloc 出来的内存:
template<class T>
struct Free
{
void operator()(T* ptr)
{
free(ptr);
}
};
std::unique_ptr<Date, Free<Date>> up3((Date*)malloc(sizeof(Date)));
关文件:
struct Fclose
{
void operator()(FILE* ptr)
{
fclose(ptr);
}
};
std::unique_ptr<FILE, Fclose> up2((FILE*)fopen("test.cpp", "w"));
对 unique_ptr
的改造,使其支持传入定制删除器:
template<class T>
struct default_delete
{
void operator()(T* ptr)
{
delete ptr;
}
};
template<class T, class D = default_delete<T>>
class unique_ptr
{
public:
~unique_ptr()
{
//delete _ptr;
D()(_ptr);
}
//...
shared_ptr
支持定制删除器的方式有点不一样,它是在构造函数部分传函数对象支持的
其构造函数原型一部分:
//with deleter (4)
template <class U, class D> shared_ptr (U* p, D del);
template <class D> shared_ptr (nullptr_t p, D del);
使用方式:
std::shared_ptr<Date> sp1(new Date[10], DeleteArray<Date>()); // 传函数对象
std::shared_ptr<Date> sp2(new Date[10], [](Date* ptr) {delete[] ptr; }); // 传lambda表达式
传对象显然更轻松一些,但是 unique_ptr
不支持,因为析构函数必须是无参的,拿不到函数对象,shared_ptr
在库里面实现比较复杂,支持传函数对象。