在C++异常学习的部分,我们也发现异常也有很多问题,例如我们先分析一下下面这段程序的问题:
int div()
{
int a, b;
cin >> a >> b;
if (b == 0)
{
// 标准库
throw invalid_argument("Division by zero condition!");
}
return a / b;
}
如果 p1 这里 new 抛异常会如何?如果 p2 这里 new 抛异常会如何?如果 div 调用这里又会抛异常会如何?如下代码:
void Func()
{
int* p1 = new int;
int* p2 = new int;
cout << div() << endl;
cout << "delete[] " << p1 << endl;
cout << "delete[] " << p2 << endl;
delete p1;
delete p2;
}
我们分别尝试以上各种场景:
int main()
{
try
{
Func();
}
catch (const exception& e)
{
cout << e.what() << endl;
}
return 0;
}
正常的场景,p1 和 p2 都能得到释放:
当发生除0错误,就会发生内存泄漏,p1 和 p2没有得到释放:
什么是内存泄漏? 内存泄漏指因为疏忽或错误造成程序未能释放已经不再使用的内存的情况。内存泄漏并不是指内存在物理上的消失,而是应用程序分配某段内存后,因为设计错误,失去了对该段内存的控制,因而造成了内存的浪费。
内存泄漏的危害:长期运行的程序出现内存泄漏,影响很大,如操作系统、后台服务等等,出现内存泄漏会导致响应越来越慢,最终卡死。
内存泄漏的场景如下:
void MemoryLeaks()
{
// 1.内存申请了忘记释放
int* p1 = (int*)malloc(sizeof(int));
int* p2 = new int;
// 2.异常安全问题
int* p3 = new int[10];
Func(); // 这里Func函数抛异常导致 delete[] p3未执行,p3没被释放.
delete[] p3;
}
C/C++程序中一般我们关心两种方面的内存泄漏:
总结一下:
内存泄漏非常常见,解决方案分为两种:
1、事前预防型。如智能指针等;
2、事后查错型。如泄漏检测工具。
RAII(Resource Acquisition Is Initialization)是一种利用对象生命周期来控制程序资源(如内存、文件句柄、网络连接、互斥量等等)的简单技术。
在对象构造时获取资源,接着控制对资源的访问使之在对象的生命周期内始终保持有效,最后在对象析构的时候释放资源。借此,我们实际上把管理一份资源的责任托管给了一个对象。这种做法有两大好处:
下面是使用 RAII 思想设计的 SmartPtr 类,即智能指针:
template <class T>
class SmartPtr
{
public:
SmartPtr(T* ptr = nullptr)
:_ptr(ptr)
{}
~SmartPtr()
{
if(_ptr)
delete _ptr;
cout << "delete _ptr " << _ptr << endl;
}
private:
T* _ptr;
};
有了 SmartPtr,我们只需要将指针交给 SmartPtr 对象,利用这个对象的生命周期来控制资源即可,它会帮我们管理资源:
int div()
{
int a, b;
cin >> a >> b;
if (b == 0)
throw invalid_argument("Division by zero condition!");
return a / b;
}
void Func()
{
SmartPtr<int> sp1(new int[10]);
SmartPtr<double> sp2(new double[10]);
cout << div() << endl;
}
int main()
{
try
{
Func();
}
catch (const exception& e)
{
cout << e.what() << endl;
}
return 0;
}
上述的 SmartPtr 还不能将其称为智能指针,因为它还不具有指针的行为。指针可以解引用,也可以通过 ->
去访问所指空间中的内容,因此:SmartPtr 模板类中还得需要将 *
、->
重载下,才可让其像指针一样去使用。例如:
template <class T>
class SmartPtr
{
public:
// RAII 思想
SmartPtr(T* ptr = nullptr)
:_ptr(ptr)
{}
~SmartPtr()
{
cout << "~SmartPtr()->" << _ptr << endl;
if (_ptr)
delete _ptr;
}
// 让 SmartPtr 具有指针的特性
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr;
};
我们可以尝试使用一下:
void TestSmartPtr1()
{
SmartPtr<int> sp1(new int);
*sp1 = 10;
SmartPtr<pair<string, int>> sp2(new pair<string, int>("abcde", 1));
// sp2.operator->()->first
cout << sp2->first << endl;
// sp2.operator->()->second
cout << sp2->second << endl;
}
总结一下智能指针的原理:
operator*
和 opertaor->
,具有像指针一样的行为。智能指针和 RAII 的关系:智能指针就是使用 RAII 这种思想的一种实现!符合 RAII 思想的不止有智能指针,还有其它的场景!
但是我们使用只能指针避免不了以下的场景,当遇到以下场景我们应该如何解决呢?
void TestSmartPtr2()
{
SmartPtr<int> sp1(new int);
SmartPtr<int> sp2 = sp1;
}
如上场景,智能指针的对象之间赋值,应该如何理解呢?首先,sp1 对象是正在管理一个资源的,现在需要赋值给 sp2,我们是不是应该写一个深拷贝重新开一个空间呢?不是的!那么又是否采用编译器默认生成的拷贝构造进行浅拷贝呢?也不是的!因为会出现析构两次的现象!
我们这样做是为了两个对象都能管理到同一份资源,并不是像以前的拷贝一样重新开一个空间重新分配!有关这些问题,我们就需要回顾历史理解不同时期的智能指针发展史了,看看历史是如何解决这一块问题的。
在学习 auto_ptr 之前,我们可以参考一下文档:std::auto_ptr 文档介绍
C++98 版本的库中就提供了 auto_ptr 的智能指针,但是 auto_ptr 是存在许多问题的。
auto_ptr 的实现原理:在拷贝时使用管理权转移的思想,下面简化模拟实现了一份 auto_ptr 来了解它的原理:
template <class T>
class auto_ptr
{
public:
auto_ptr(T* ptr = nullptr)
:_ptr(ptr)
{}
~auto_ptr()
{
cout << "delete->" << _ptr << endl;
if (_ptr)
delete _ptr;
}
// ap2(ap1)
auto_ptr(auto_ptr<T>& ap)
:_ptr(ap._ptr)
{
ap._ptr = nullptr;
}
auto_ptr<T>& operator=(auto_ptr<T>& ap)
{
// 检测是否为自己给自己赋值
if (this != &ap)
{
// 释放当前对象中资源
if (_ptr)
delete _ptr;
// 转移 ap 中资源到当前对象中
_ptr = ap._ptr;
ap._ptr = NULL;
}
return *this;
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr;
};
auto_ptr 其实是一个失败设计,管理权转移,就是最后一个拷贝对象管理资源,被拷贝对象都被置空,这会导致对象悬空,当出现以下场景的时候,就会对空指针解引用,所以很多公司明确要求不能使用 auto_ptr:
void Test_auto_ptr()
{
Young::auto_ptr<int> ap1(new int);
Young::auto_ptr<int> ap2 = ap1;
(*ap1)++;
(*ap2)++;
}
C++11 中开始提供更靠谱的 unique_ptr.
unique_ptr 的实现原理:简单粗暴的防拷贝,即不让我们拷贝,下面简化模拟实现了一份 unique_ptr 来了解它的原理。
如何才能实现防拷贝呢?第一种方法就是显示写拷贝构造,只声明不实现,不让编译器自动生成,如下:
template <class T>
class unique_ptr
{
public:
unique_ptr(T* ptr = nullptr)
:_ptr(ptr)
{}
~unique_ptr()
{
cout << "delete->" << _ptr << endl;
if (_ptr)
delete _ptr;
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
// 只声明不实现
unique_ptr(const unique_ptr<T>& up);
private:
T* _ptr;
};
但是只声明不实现也会导致一些问题,比如我们可以在类外进行实现,万一又实现成浅拷贝的方法,也会出现析构两次的现象。
所以我们采用另外一种方法,C++98 采用将拷贝构造私有化!这样就防止有人可以在类外实现了。除此之外,我们还需要将赋值重载私有化,否则也会面临同样的问题。如下代码:
template <class T>
class unique_ptr
{
public:
unique_ptr(T* ptr = nullptr)
:_ptr(ptr)
{}
~unique_ptr()
{
cout << "delete->" << _ptr << endl;
if (_ptr)
delete _ptr;
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
// 1、只声明不实现 2、限定为私有
unique_ptr(const unique_ptr<T>& up);
unique_ptr<T>& operator=(const unique_ptr<T>& up);
private:
T* _ptr;
};
但是 C++11 并不是这样实现的,C++11 给我们提供了新的方式防拷贝,给我们提供了一个关键字:delete
,我们在该函数后面加上 = delete
表明该函数不能被调用和默认生成,例如:
template <class T>
class unique_ptr
{
public:
unique_ptr(T* ptr = nullptr)
:_ptr(ptr)
{}
~unique_ptr()
{
cout << "delete->" << _ptr << endl;
if (_ptr)
delete _ptr;
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
// C++11
unique_ptr(const unique_ptr<T>& up) = delete;
unique_ptr<T>& operator=(const unique_ptr<T>& up) = delete;
private:
T* _ptr;
};
但是防止拷贝也不是一个方法,总有一些场景是需要拷贝的,所以有了下面的智能指针的出现。
C++11 中开始提供更靠谱的并且支持拷贝的 shared_ptr.
shared_ptr 的原理:是通过引用计数的方式来实现多个 shared_ptr 对象之间共享资源,而最后管理资源的对象需要释放资源。例如:老师晚上在下班之前都会通知,让最后走的学生记得把门锁下。
那么我们该如何实现引用计数支持多个拷贝管理同一个资源,最后一个析构对象释放资源呢?首先我们需要考虑到的是,这个引用计数必须是跟着资源走,每多一个对象管理该资源,引用计数就需要加一,所以我们可以在类内部定义一个 _pcount 引用计数,每当拷贝一次资源,_pcount 就加一;反之,每当析构一次,_pcount 就减一,当减到 0 的时候就释放资源。代码如下:
// 引用计数减一
~shared_ptr()
{
if (--(*_pcount) == 0)
{
cout << "delete->" << _ptr << endl;
delete _ptr;
delete _pcount;
}
}
// sp2(sp1),引用计数加一
shared_ptr(const shared_ptr<T>& sp)
:_ptr(sp._ptr)
, _pcount(sp._pcount)
{
++(*_pcount);
}
我们再继续完善一下 shared_ptr 的赋值运算符重载,注意,这里需要防止自己给自己赋值的情况,而自己给自己赋值的情况又分为两种,第一种是直接给自己赋值,即 sp1 = sp1
;而第二种则是如下:
Young::auto_ptr<int> sp1(new int);
Young::auto_ptr<int> sp2 = sp1;
sp1 = sp2;
如果我们使用传统的方式,即下面的方式是不可行的,因为防不住第二种情况:
shared_ptr<T>& operator=(const shared_ptr<T>& sp)
{
if(this != &sp)
{
// ...
}
return *this;
}
所以应该采用以下的方式实现,相对完整的 shared_ptr 代码如下:
template <class T>
class shared_ptr
{
public:
shared_ptr(T* ptr = nullptr)
:_ptr(ptr)
,_pcount(new int(1))
{}
void release()
{
if (--(*_pcount) == 0)
{
cout << "delete->" << _ptr << endl;
delete _ptr;
delete _pcount;
}
}
~shared_ptr()
{
release();
}
// sp2(sp1)
shared_ptr(const shared_ptr<T>& sp)
:_ptr(sp._ptr)
, _pcount(sp._pcount)
{
++(*_pcount);
}
// sp2 = sp1;
shared_ptr<T>& operator=(const shared_ptr<T>& sp)
{
// 防止自己给自己赋值
if (_ptr != sp._ptr)
{
release();
_ptr = sp._ptr;
_pcount = sp._pcount;
++(*_pcount);
}
return *this;
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr;
int* _pcount;
};
尽管 shared_ptr 已经相对来说比较完美了,但是也是存在一个问题:循环引用。
在以下场景中,shared_ptr 会存在循环引用的问题,那么什么是循环引用呢?我们根据下面的代码和画图来理解:
struct ListNode
{
Young::shared_ptr<ListNode> next;
Young::shared_ptr<ListNode> prev;
int _val;
~ListNode()
{
cout << "~ListNode()" << endl;
}
};
void Test_shared_ptr2()
{
Young::shared_ptr<ListNode> n1 = new ListNode;
Young::shared_ptr<ListNode> n2 = new ListNode;
// 循环引用
n1->next = n2;
n2->prev = n1;
}
我们先画个图理解一下上述代码,假设我们现在没有 n2->prev = n1;
这句代码,如果没有这句代码,上述代码是正常的,所以我们先画一下正常的图理解一下:
此时,n2 智能指针对象中的 _ptr 和 n1 智能指针对象中 _ptr 指向的节点中的 next 智能指针对象中的 _ptr 都指向了同一个节点资源,所以该节点的资源的 _pcount 是 2;当 n2 出了作用域调用析构函数,_pcount 减一,此时 _pcount 为 1 ,n2 释放;n1 出了作用域也被释放,它的 _pcount 减为 0,所以它的 _ptr 也需要被释放,即指向的节点被释放,而其中的成员变量,即 next 和 prev 智能指针对象也需要被释放,所以 next 对象中 _pcount 需要减一,此时变为0,所以它的 _ptr 指向的节点资源需要被释放,所以两个节点都被释放了,此时没有内存泄漏,是正常的。
但是如果我们加上 n2->prev = n1;
这句代码,图就变成如下所示:
根据上图,我们分析一下,当 n1 出了作用域,它的 _pcount 减一,变为 1;当 n2 出了作用域,它的 _pcount 也减一,也变为 1;那么就会引发以下的闭环问题:
如上,上面的问题成功的形成了闭环,这就是循环引用问题,它们之间谁也释放不了,就会导致内存泄漏问题!
那么解决方案是什么呢?答案是 weak_ptr,weak_ptr 就是专门解决循环引用问题的,但是它不支持 RAII,将代码改成如下即可解决:
struct ListNode
{
std::weak_ptr<ListNode> next;
std::weak_ptr<ListNode> prev;
int _val;
~ListNode()
{
cout << "~ListNode()" << endl;
}
};
void Test_shared_ptr2()
{
std::shared_ptr<ListNode> n1(new ListNode);
std::shared_ptr<ListNode> n2(new ListNode);
// 循环引用
n1->next = n2;
n2->prev = n1;
}
为什么 weak_ptr 可以解决这里的问题呢?因为 weak_ptr 不参与引用计数,它不会增加引用计数,我们可以调用接口 use_count()
查看当前引用计数的数量,当不适用 weak_ptr 解决循环引用问题:
当使用 weak_ptr 解决循环引用问题:
如上图所示,weak_ptr 是不参与 shared_ptr 引用计数的计数的。
接下来我们自己简单实现一个 weak_ptr,我们需要实现一个可以用 shared_ptr 初始化的构造函数,因为 weak_ptr 不参与 shared_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;
};
上述代码在 shared_ptr 中增加了两个成员函数,其中 get() 是可以直接获得 shared_ptr 的原生指针的:
int use_count() const
{
return *_pcount;
}
T* get() const
{
return _ptr;
}
经过我们自己简单实现的 weak_ptr 后,使用我们自己的 weak_ptr 也可以解决循环引用问题。
我们上面实现的智能指针还是有一些问题的,比如我们 new 一个数组的时候,析构函数不能正确的释放资源,如下:
void Test_shared_ptr3()
{
Young::shared_ptr<ListNode> sp1(new ListNode[10]);
}
这时候运行就会有内存泄漏。那这个时候应该怎么解决呢?库里面给我们提供了一个具有定制删除器的构造函数,如下:
那么这个定制删除器如何使用呢?其实这个 D del
就是一个可调用对象,我们前面也学过,可以是一个函数指针,仿函数,lambda 表达式;我们可以尝试使用一下:
template<class T>
struct DelArray
{
void operator()(T* ptr)
{
delete[] ptr;
}
};
void Test_shared_ptr3()
{
// 仿函数
std::shared_ptr<ListNode> sp1(new ListNode[10], DelArray<ListNode>());
// lambda
std::shared_ptr<ListNode> sp2(new ListNode[10], [](ListNode* ptr) {delete[] ptr; });
}
结果如下:
如果我们想要自己实现一个定制删除器应该如何实现呢?看起来很简单,只需要在 shared_ptr 中增加一个具有定制删除器的构造函数即可,如下:
template<class D>
shared_ptr(T* ptr, D del)
:_ptr(ptr)
,_pcount(new int(1))
,_del(del)
{}
void release()
{
if (--(*_pcount) == 0)
{
//cout << "delete->" << _ptr << endl;
//delete _ptr;
_del(_ptr);
delete _pcount;
}
}
除此之外,我们还需要在成员变量中增加一个变量 _del,那么这个变量的类型是什么呢?是 D 吗?那么我们应该如何拿到 D 这个类型呢?D 这个类型的作用域是在该构造函数内,并不是整个类,所以我们也拿不到 D 类型。如果直接将 D 类型添加在整个类模板,那么我们传参不就要多传一个类型了吗,这也不符合库中的使用。
所以,我们可以使用前面学过的包装器解决这个问题,我们需要在成员变量中加一个包装器,如下:
private:
T* _ptr;
int* _pcount;
function<void(T*)> _del;
这样如果使用我们自己实现的定制删除器也没有问题了,如下:
但是还存在一个问题,当我们 new 一个数据的时候,如下:
Young::shared_ptr<ListNode> sp3(new ListNode);
上面这种情况也会出现问题,因为这个时候我们没有传可调用对象,它就默认调了第一个个构造函数,即没有定制删除器的构造函数,但是我们在析构函数的时候是使用包装器进行释放资源的,这个时候我们的包装器是空的,什么都没有。所以我们需要在包装器这里给上默认的可调用对象,如下:
private:
T* _ptr;
int* _pcount;
function<void(T*)> _del = [](T* ptr) {delete ptr; };
这个时候就算我们只是申请一个数据,也会有默认的可调用对象被包装器包装了,从而析构函数就没有问题了。