目录
异常
C语言传统的处理错误的方式
传统的错误处理机制:
- 终止程序,如assert。缺陷:用户难以接受。如发生内存错误,除0错误时就会终止程序。
- 返回错误码。缺陷:需要程序员自己去查找对应的错误。如系统的很多库的接口函数都是通过把错误码放到errno中,表示错误。
- C标准库中setjmp和longjmp组合。(不常用)
实际中C语言基本都是使用返回错误码的方式处理错误,部分情况下使用终止程序处理非常严重的错误。
C++异常概念
异常是面向对象语言常用的一种处理错误的方式,当一个函数发现自己无法处理的错误时就可以抛出异常,让函数直接或间接的调用者处理这个错误。
- throw:当程序出现问题时,可以通过throw关键字抛出一个异常。
- try:try块中放置的是可能抛出异常的代码,该代码块在执行时将进行异常错误检测,try块后面通常跟着一个或多个catch块。
- catch:如果try块中发生错误,则可以在catch块中定义对应要执行的代码块。
使用try/catch语句的语法如下所示:
try
{
//被保护的代码
}
catch (ExceptionName e1)
{
//catch块
}
catch (ExceptionName e2)
{
//catch块
}
catch (ExceptionName eN)
{
//catch块
}
异常的用法
异常的抛出和捕获
异常的抛出和捕获的匹配原则:
- 异常是通过抛出对象而引发的,该对象的类型决定了应该激活哪个catch的处理代码,如果抛出的异常对象没有捕获,或是没有匹配类型的捕获,那么程序会终止报错。
- 被选中的处理代码(catch块)是调用链中与该对象类型匹配且离抛出异常位置最近的那一个。
- 抛出异常对象后,会生成一个异常对象的拷贝,因为抛出的异常对象可能是一个临时对象,所以会生成一个拷贝对象,这个拷贝的临时对象会在被catch以后销毁。(类似于函数的传值返回)
- catch(...)可以捕获任意类型的异常,但捕获后无法知道异常错误是什么。
- 实际异常的抛出和捕获的匹配原则有个例外,捕获和抛出的异常类型并不一定要完全匹配,可以抛出派生类对象,使用基类进行捕获,这个在实际中非常有用。
在函数调用链中异常栈展开的匹配原则:
- 当异常被抛出后,首先检查throw本身是否在try块内部,如果在则查找匹配的catch语句,如果有匹配的,则跳到catch的地方进行处理。
- 如果当前函数栈没有匹配的catch则退出当前函数栈,继续在上一个调用函数栈中进行查找匹配的catch。找到匹配的catch子句并处理以后,会沿着catch子句后面继续执行,而不会跳回到原来抛异常的地方。
- 如果到达main函数的栈,依旧没有找到匹配的catch,则终止程序。
下面代码中main函数中调用了func3,func3中调用了func2,func2中调用了func1,在func1中抛出了一个string类型的异常对象。
void func1()
{
throw string("这是一个异常");
}
void func2()
{
func1();
}
void func3()
{
func2();
}
int main()
{
try
{
func3();
}
catch (const string& s)
{
cout << "错误描述:" << s << endl;
}
catch (...)
{
cout << "未知异常" << endl;
}
return 0;
}
当func1中的异常被抛出后:
- 首先会检查throw本身是否在try块内部,这里由于throw不在try块内部,因此会退出func1所在的函数栈,继续在上一个调用函数栈中进行查找,即func2所在的函数栈。
- 由于func2中也没有匹配的catch,因此会继续在上一个调用函数栈中进行查找,即func3所在的函数栈。
- func3中也没有匹配的catch,于是就会在main所在的函数栈中进行查找,最终在main函数栈中找到了匹配的catch。
- 这时就会跳到main函数中对应的catch块中执行对应的代码块,执行完后继续执行该代码块后续的代码。
上述这个沿着调用链查找匹配的catch子句的过程称为栈展开。在实际中我们最后都要加一个catch(...)
捕获任意类型的异常,否则当有异常没捕获时,程序就会直接终止。
异常的重新抛出
有时候单个的catch可能不能完全处理一个异常,在进行一些校正处理以后,希望再交给更外层的调用链函数来处理,比如最外层可能需要拿到异常进行日志信息的记录,这时就需要通过重新抛出将异常传递给更上层的函数进行处理。
但如果直接让最外层捕获异常进行处理可能会引发一些问题。
void func1()
{
throw string("这是一个异常");
}
void func2()
{
int* array = new int[10];
func1();
//do something...
delete[] array;
}
int main()
{
try
{
func2();
}
catch (const string& s)
{
cout << s << endl;
}
catch (...)
{
cout << "未知异常" << endl;
}
return 0;
}
其中func2中通过new操作符申请了一块内存空间,并且在func2最后通过delete对该空间进行了释放,但由于func2中途调用的func1内部抛出了一个异常,这时会直接跳转到main函数中的catch块执行对应的异常处理程序,并且在处理完后继续沿着catch块往后执行。
这时就导致func2中申请的内存块没有得到释放,造成了内存泄露。这时可以在func2中先对func1抛出的异常进行捕获,捕获后先将申请到的内存释放再将异常重新抛出,这时就避免了内存泄露。
void func2()
{
int* array = new int[10];
try
{
func1();
//do something...
}
catch (...)
{
delete[] array;
throw; //将捕获到的异常再次重新抛出
}
delete[] array;
}
- func2中的new和delete之间可能还会抛出其他类型的异常,因此在fun2中最好以catch(...)的方式进行捕获,将申请到的内存delete后再通过throw重新抛出。
- 重新抛出异常对象时,throw后面可以不用指明要抛出的异常对象(正好也不知道以catch(...)的方式捕获到的具体是什么异常对象)
异常安全
将抛异常导致的安全问题叫做异常安全问题,对于异常安全问题下面给出几点建议:
- 构造函数完成对象的构造和初始化,最好不要在构造函数中抛出异常,否则可能导致对象不完整或没有完全初始化。
- 析构函数主要完成对象资源的清理,最好不要在析构函数中抛出异常,否则可能导致资源泄露(内存泄露、句柄未关闭等)。
- C++中异常经常会导致资源泄露的问题,比如在new和delete中抛出异常,导致内存泄露,在lock和unlock之间抛出异常导致死锁,C++经常使用RAII的方式来解决以上问题。
异常规范
为了让函数使用者知道某个函数可能抛出哪些类型的异常,C++标准规定:
- 在函数的后面接throw(type1, type2, ...),列出这个函数可能抛掷的所有异常类型。
- 在函数的后面接throw()或noexcept(C++11),表示该函数不抛异常。
- 若无异常接口声明,则此函数可以抛掷任何类型的异常。(异常接口声明不是强制的)
//表示func函数可能会抛出A/B/C/D类型的异常
void func() throw(A, B, C, D);
//表示这个函数只会抛出bad_alloc的异常
void* operator new(std::size_t size) throw(std::bad_alloc);
//表示这个函数不会抛出异常
void* operator new(std::size_t size, void* ptr) throw();
自定义异常体系
实际中很多公司都会自定义自己的异常体系进行规范的异常管理。
- 公司中的项目一般会进行模块划分,让不同的程序员或小组完成不同的模块,如果不对抛异常这件事进行规范,那么负责最外层捕获异常的程序员就非常难受了,因为他需要捕获大家抛出的各种类型的异常对象。
- 因此实际中都会定义一套继承的规范体系,先定义一个最基础的异常类,所有人抛出的异常对象都必须是继承于该异常类的派生类对象,因为异常语法规定可以用基类捕获抛出的派生类对象,因此最外层就只需捕获基类就行了。
最基础的异常类至少需要包含错误编号和错误描述两个成员变量,甚至还可以包含当前函数栈帧的调用链等信息。该异常类中一般还会提供两个成员函数,分别用来获取错误编号和错误描述。
class Exception
{
public:
Exception(int errid, const char* errmsg)
:_errid(errid)
, _errmsg(errmsg)
{}
int GetErrid() const
{
return _errid;
}
virtual string what() const
{
return _errmsg;
}
protected:
int _errid; //错误编号
string _errmsg; //错误描述
//...
};
其他模块如果要对这个异常类进行扩展,必须继承这个基础的异常类,可以在继承后的异常类中按需添加某些成员变量,或是对继承下来的虚函数what进行重写,使其能告知程序员更多的异常信息。
class CacheException : public Exception
{
public:
CacheException(int errid, const char* errmsg)
:Exception(errid, errmsg)
{}
virtual string what() const
{
string msg = "CacheException: ";
msg += _errmsg;
return msg;
}
protected:
//...
};
class SqlException : public Exception
{
public:
SqlException(int errid, const char* errmsg, const char* sql)
:Exception(errid, errmsg)
, _sql(sql)
{}
virtual string what() const
{
string msg = "CacheException: ";
msg += _errmsg;
msg += "sql语句: ";
msg += _sql;
return msg;
}
protected:
string _sql; //导致异常的SQL语句
//...
};
- 异常类的成员变量不能设置为私有,因为私有成员在子类中是不可见的。
- 基类Exception中的what成员函数最好定义为虚函数,方便子类对其进行重写,从而达到多态的效果。
标准库异常体系
C++标准库当中的异常也是一个基础体系,其中exception就是各个异常类的基类,我们可以在程序中使用这些标准的异常,它们之间的继承关系如下:
异常 | 描述 |
std::exception | 该异常是所有标准C++异常的父类。 |
std::bad_alloc | 该异常可以通过new抛出。 |
std::bad_cast | 该异常可以通过dynamic_cast抛出。 |
std::bad_exception | 这在处理C++程序中无法预期的异常时非常有用。 |
std::bad_typeid | 该异常可以通过typeid抛出。 |
std::logic_error | 理论上可以通过读取代码来检测到的异常。 |
std::domain_error | 当使用了一个无效的数学域时,会抛出该异常。 |
std::invalid_argument | 当使用了无效的参数时,会抛出该异常。 |
std::length_error | 当创建了太长的std::string时,会抛出该异常。 |
std::out_of_range | 该异常可以通过方法抛出,例如std::vector和std::bitset<>::operator。 |
std::runtime_error | 理论上不可以通过读取代码来检测到的异常。 |
std::overflow_error | 当发生数学上溢时,会抛出该异常。 |
std::range_error | 当尝试存储超出范围的值时,会抛出该异常。 |
std::underflow_error | 当发生数学下溢时,会抛出该异常。 |
- exception类的what成员函数和析构函数都定义成了虚函数,方便子类对其进行重写,从而达到多态的效果。
- 实际中我们也可以去继承exception类来实现自己的异常类,但实际中很多公司都会自己定义一套异常继承体系。
异常的优缺点
异常的优点:
- 异常对象定义好了,相比错误码的方式可以清晰准确的展示出错误的各种信息,甚至可以包含堆栈调用等信息,这样可以帮助更好的定位程序的bug。
- 返回错误码的传统方式有个很大的问题就是,在函数调用链中,深层的函数返回了错误,那么我们得层层返回错误码,最终最外层才能拿到错误。
- 很多的第三方库都会使用异常,比如boost、gtest、gmock等等常用的库,如果我们不用异常就不能很好的发挥这些库的作用。
- 很多测试框架也都使用异常,因此使用异常能更好的使用单元测试等进行白盒的测试。
- 部分函数使用异常更好处理,比如T& operator这样的函数,如果pos越界了只能使用异常或者终止程序处理,没办法通过返回值表示错误。
异常的缺点:
- 异常会导致程序的执行流乱跳,并且非常的混乱,这会导致我们跟踪调试以及分析程序时比较困难。
- 异常会有一些性能的开销,当然在现代硬件速度很快的情况下,这个影响基本忽略不计。
- C++没有垃圾回收机制,资源需要自己管理。有了异常非常容易导致内存泄露、死锁等异常安全问题。这个需要使用RAII来处理资源的管理问题,学习成本比较高。
- C++标准库的异常体系定义得不够好,导致大家各自定义自己的异常体系,非常的混乱。
- 异常尽量规范使用,否则后果不堪设想,随意抛异常,也会让外层捕获的用户苦不堪言。
- 异常接口声明不是强制的,对于没有声明异常类型的函数,无法预知该函数是否会抛出异常。
但总体而言,异常的利大于弊,所以工程中我们还是鼓励使用异常的,并且OO的语言基本都使用异常处理错误,这也可以看出这是大势所趋。
智能指针的使用及原理
智能指针的使用
内存泄露问题
内存泄露是指因为疏忽或错误,造成程序未能释放已经不再使用的内存的情况。
int div()
{
int a, b;
cin >> a >> b;
if (b == 0)
throw invalid_argument("除0错误");
return a / b;
}
void func()
{
int* ptr = new int;
//...
cout << div() << endl;
//...
delete ptr;
}
int main()
{
try
{
func();
}
catch (exception& e)
{
cout << e.what() << endl;
}
return 0;
}
如果用户输入的除数为0,那么div函数中就会抛出异常,这时程序的执行流会直接跳转到主函数中的catch块中执行,最终导致func函数中申请的内存资源没有得到释放。
利用异常的重新捕获解决
对于这种情况,可以在func函数中先对div函数中抛出的异常进行捕获,捕获后先将之前申请的内存资源释放,然后再将异常重新抛出。
int div()
{
int a, b;
cin >> a >> b;
if (b == 0)
throw invalid_argument("除0错误");
return a / b;
}
void func()
{
int* ptr = new int;
try
{
cout << div() << endl;
}
catch (...)
{
delete ptr;
throw;
}
delete ptr;
}
int main()
{
try
{
func();
}
catch (exception& e)
{
cout << e.what() << endl;
}
return 0;
}
利用智能指针解决
上述问题也可以使用智能指针进行解决。
template<class T>
class SmartPtr
{
public:
SmartPtr(T* ptr)
:_ptr(ptr)
{}
~SmartPtr()
{
cout << "delete: " << _ptr << endl;
delete _ptr;
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _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> sp(new int);
//...
cout << div() << endl;
//...
}
int main()
{
try
{
func();
}
catch (exception& e)
{
cout << e.what() << endl;
}
return 0;
}
代码中将申请到的内存空间交给了一个SmartPtr对象进行管理。
- 在构造SmartPtr对象时,SmartPtr将传入的需要被管理的内存空间保存起来。
- 在SmartPtr对象析构时,SmartPtr的析构函数中会自动将管理的内存空间进行释放。
- 此外,为了让SmartPtr对象能够像原生指针一样使用,还需要对
*
和->
运算符进行重载。
这样一来,无论程序是正常执行完毕返回了,还是因为某些原因中途返回了,或是因为抛异常返回了,只要SmartPtr对象的生命周期结束就会调用其对应的析构函数,进而完成内存资源的释放。
智能指针的原理
实现智能指针时需要考虑以下三个方面的问题:
- 在对象构造时获取资源,在对象析构的时候释放资源,利用对象的生命周期来控制程序资源,即RAII特性。
- 对
*
和->
运算符进行重载,使得该对象具有像指针一样的行为。 - 智能指针对象的拷贝问题。
概念说明: RAII(Resource Acquisition Is Initialization)是一种利用对象生命周期来控制程序资源(如内存、文件句柄、互斥量等等)的简单技术。
为什么要解决智能指针对象的拷贝问题
对于当前实现的SmartPtr类,如果用一个SmartPtr对象来拷贝构造另一个SmartPtr对象,或是将一个SmartPtr对象赋值给另一个SmartPtr对象,都会导致程序崩溃。
int main()
{
SmartPtr<int> sp1(new int);
SmartPtr<int> sp2(sp1); //拷贝构造
SmartPtr<int> sp3(new int);
SmartPtr<int> sp4(new int);
sp3 = sp4; //拷贝赋值
return 0;
}
原因如下:
- 编译器默认生成的拷贝构造函数对内置类型完成值拷贝(浅拷贝),因此用sp1拷贝构造sp2后,相当于这sp1和sp2管理了同一块内存空间,当sp1和sp2析构时就会导致这块空间被释放两次。
- 编译器默认生成的拷贝赋值函数对内置类型也是完成值拷贝(浅拷贝),因此将sp4赋值给sp3后,相当于sp3和sp4管理的都是原来sp3管理的空间,当sp3和sp4析构时就会导致这块空间被释放两次,并且还会导致sp4原来管理的空间没有得到释放。
智能指针就是要模拟原生指针的行为,当一个指针赋值给另一个指针时,目的就是让这两个指针指向同一块内存空间,所以这里本就应该进行浅拷贝,但单纯的浅拷贝又会导致空间被多次释放,因此根据解决智能指针拷贝问题方式的不同,从而衍生出了不同版本的智能指针。
C++中的智能指针
std::auto_ptr
管理权转移
auto_ptr是C++98中引入的智能指针,auto_ptr通过管理权转移的方式解决智能指针的拷贝问题,保证一个资源在任何时刻都只有一个对象在对其进行管理,这时同一个资源就不会被多次释放了。
int main()
{
std::auto_ptr<int> ap1(new int(1));
std::auto_ptr<int> ap2(ap1);
*ap2 = 10;
//*ap1 = 20; //error
std::auto_ptr<int> ap3(new int(1));
std::auto_ptr<int> ap4(new int(2));
ap3 = ap4;
return 0;
}
但一个对象的管理权转移后也就意味着,该对象不能再用对原来管理的资源进行访问了,否则程序就会崩溃,因此使用auto_ptr之前必须先了解它的机制,否则程序很容易出问题,很多公司也都明确规定了禁止使用auto_ptr。
auto_ptr的模拟实现
- 在构造函数中获取资源,在析构函数中释放资源,利用对象的生命周期来控制资源。
- 对*和->运算符进行重载,使auto_ptr对象具有指针一样的行为。
- 在拷贝构造函数中,用传入对象管理的资源来构造当前对象,并将传入对象管理资源的指针置空。
- 在拷贝赋值函数中,先将当前对象管理的资源释放,然后再接管传入对象管理的资源,最后将传入对象管理资源的指针置空。
namespace cl
{
template<class T>
class auto_ptr
{
public:
//RAII
auto_ptr(T* ptr = nullptr)
:_ptr(ptr)
{}
~auto_ptr()
{
if (_ptr != nullptr)
{
cout << "delete: " << _ptr << endl;
delete _ptr;
_ptr = nullptr;
}
}
auto_ptr(auto_ptr<T>& ap)
:_ptr(ap._ptr)
{
ap._ptr = nullptr; //管理权转移后ap被置空
}
auto_ptr& operator=(auto_ptr<T>& ap)
{
if (this != &ap)
{
delete _ptr; //释放自己管理的资源
_ptr = ap._ptr; //接管ap对象的资源
ap._ptr = nullptr; //管理权转移后ap被置空
}
return *this;
}
//可以像指针一样使用
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr; //管理的资源
};
}
std::unique_ptr
防拷贝
unique_ptr是C++11中引入的智能指针,unique_ptr通过防拷贝的方式解决智能指针的拷贝问题,也就是简单粗暴的防止对智能指针对象进行拷贝,这样也能保证资源不会被多次释放。
int main()
{
std::unique_ptr<int> up1(new int(0));
//std::unique_ptr<int> up2(up1); //error
return 0;
}
但防拷贝其实也不是一个很好的办法,因为总有一些场景需要进行拷贝。
unique_ptr的模拟实现
- 在构造函数中获取资源,在析构函数中释放资源,利用对象的生命周期来控制资源。
- 对
*
和->
运算符进行重载,使unique_ptr对象具有指针一样的行为。 - 用C++98的方式将拷贝构造函数和拷贝赋值函数声明为私有,或者用C++11的方式在这两个函数后面加上
=delete
,防止外部调用。
namespace cl
{
template<class T>
class unique_ptr
{
public:
//RAII
unique_ptr(T* ptr = nullptr)
:_ptr(ptr)
{}
~unique_ptr()
{
if (_ptr != nullptr)
{
cout << "delete: " << _ptr << endl;
delete _ptr;
_ptr = nullptr;
}
}
//可以像指针一样使用
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
//防拷贝
unique_ptr(unique_ptr<T>& up) = delete;
unique_ptr& operator=(unique_ptr<T>& up) = delete;
private:
T* _ptr; //管理的资源
};
}
std::shared_ptr
std::shared_ptr的基本设计
引用计数
shared_ptr是C++11中引入的智能指针,shared_ptr通过引用计数的方式解决智能指针的拷贝问题。
- 每一个被管理的资源都有一个对应的引用计数,通过这个引用计数记录着当前有多少个对象在管理着这块资源。
- 当新增一个对象管理这块资源时则将该资源对应的引用计数进行++,当一个对象不再管理这块资源或该对象被析构时则将该资源对应的引用计数进行--。
- 当一个资源的引用计数减为0时说明已经没有对象在管理这块资源了,这时就可以将该资源进行释放了。
通过这种引用计数的方式就能支持多个对象一起管理某一个资源,也就是支持了智能指针的拷贝,并且只有当一个资源对应的引用计数减为0时才会释放资源,因此保证了同一个资源不会被释放多次。
int main()
{
cl::shared_ptr<int> sp1(new int(1));
cl::shared_ptr<int> sp2(sp1);
*sp1 = 10;
*sp2 = 20;
cout << sp1.use_count() << endl; //2
cl::shared_ptr<int> sp3(new int(1));
cl::shared_ptr<int> sp4(new int(2));
sp3 = sp4;
cout << sp3.use_count() << endl; //2
return 0;
}
shared_ptr的模拟实现
- 在shared_ptr类中增加一个成员变量count,表示智能指针对象管理的资源对应的引用计数。
- 在构造函数中获取资源,并将该资源对应的引用计数设置为1,表示当前只有一个对象在管理这个资源。
- 在拷贝构造函数中,与传入对象一起管理它管理的资源,同时将该资源对应的引用计数++。
- 在拷贝赋值函数中,先将当前对象管理的资源对应的引用计数--(如果减为0则需要释放),然后再与传入对象一起管理它管理的资源,同时需要将该资源对应的引用计数++。
- 在析构函数中,将管理资源对应的引用计数--,如果减为0则需要将该资源释放。
- 对*和->运算符进行重载,使shared_ptr对象具有指针一样的行为。
namespace cl
{
template<class T>
class shared_ptr
{
public:
//RAII
shared_ptr(T* ptr = nullptr)
:_ptr(ptr)
, _pcount(new int(1))
{}
~shared_ptr()
{
if (--(*_pcount) == 0)
{
if (_ptr != nullptr)
{
cout << "delete: " << _ptr << endl;
delete _ptr;
_ptr = nullptr;
}
delete _pcount;
_pcount = nullptr;
}
}
shared_ptr(shared_ptr<T>& sp)
:_ptr(sp._ptr)
, _pcount(sp._pcount)
{
(*_pcount)++;
}
shared_ptr& operator=(shared_ptr<T>& sp)
{
if (_ptr != sp._ptr) //管理同一块空间的对象之间无需进行赋值操作
{
if (--(*_pcount) == 0) //将管理的资源对应的引用计数--
{
cout << "delete: " << _ptr << endl;
delete _ptr;
delete _pcount;
}
_ptr = sp._ptr; //与sp对象一同管理它的资源
_pcount = sp._pcount; //获取sp对象管理的资源对应的引用计数
(*_pcount)++; //新增一个对象来管理该资源,引用计数++
}
return *this;
}
//获取引用计数
int use_count()
{
return *_pcount;
}
//可以像指针一样使用
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr; //管理的资源
int* _pcount; //管理的资源对应的引用计数
};
}
为什么引用计数需要存放在堆区?
首先,shared_ptr中的引用计数count不能单纯的定义成一个int类型的成员变量,因为这就意味着每个shared_ptr对象都有一个自己的count成员变量,而当多个对象要管理同一个资源时,这几个对象应该用到的是同一个引用计数。
其次,shared_ptr中的引用计数count也不能定义成一个静态的成员变量,因为静态成员变量是所有类型对象共享的,这会导致管理相同资源的对象和管理不同资源的对象用到的都是同一个引用计数。
而如果将shared_ptr中的引用计数count定义成一个指针,当一个资源第一次被管理时就在堆区开辟一块空间用于存储其对应的引用计数,如果有其他对象也想要管理这个资源,那么除了将这个资源给它之外,还需要把这个引用计数也给它。
这时管理同一个资源的多个对象访问到的就是同一个引用计数,而管理不同资源的对象访问到的就是不同的引用计数了,相当于将各个资源与其对应的引用计数进行了绑定。
但同时需要注意,由于引用计数的内存空间也是在堆上开辟的,因此当一个资源对应的引用计数减为0时,除了需要将该资源释放,还需要将该资源对应的引用计数的内存空间进行释放。
std::shared_ptr的线程安全问题
shared_ptr的线程安全问题
当前模拟实现的shared_ptr还存在线程安全的问题,由于管理同一个资源的多个对象的引用计数是共享的,因此多个线程可能会同时对同一个引用计数进行自增或自减操作,而自增和自减操作都不是原子操作,因此需要通过加锁来对引用计数进行保护,否则就会导致线程安全问题。
比如下面代码中用一个shared_ptr管理一个整型变量,然后用两个线程分别对这个shared_ptr对象进行1000次拷贝操作,这些对象被拷贝出来后又会立即被销毁。
void func(cl::shared_ptr<int>& sp, size_t n)
{
for (size_t i = 0; i < n; i++)
{
cl::shared_ptr<int> copy(sp);
}
}
int main()
{
cl::shared_ptr<int> p(new int(0));
const size_t n = 1000;
thread t1(func, p, n);
thread t2(func, p, n);
t1.join();
t2.join();
cout << p.use_count() << endl; //预期:1
return 0;
}
在这个过程中两个线程会不断对引用计数进行自增和自减操作,理论上最终两个线程执行完毕后引用计数的值应该是1,因为拷贝出来的对象都被销毁了,只剩下最初的shared_ptr对象还在管理这个整型变量,但每次运行程序得到引用计数的值可能都是不一样的,根本原因就是因为对引用计数的自增和自减不是原子操作。
加锁解决线程安全问题
要解决引用计数的线程安全问题,本质就是要让对引用计数的自增和自减操作变成一个原子操作,因此可以对引用计数的操作进行加锁保护,也可以用原子类atomic对引用计数进行封装,这里以加锁为例。
- 在shared_ptr类中新增互斥锁成员变量,为了让管理同一个资源的多个线程访问到的是同一个互斥锁,管理不同资源的线程访问到的是不同的互斥锁,因此互斥锁也需要在堆区创建。
- 在调用拷贝构造函数和拷贝赋值函数时,除了需要将对应的资源和引用计数交给当前对象管理之外,还需要将对应的互斥锁也交给当前对象。
- 当一个资源对应的引用计数减为0时,除了需要将对应的资源和引用计数进行释放,由于互斥锁也是在堆区创建的,因此还需要将对应的互斥锁进行释放。
- 为了简化代码逻辑,可以将拷贝构造函数和拷贝赋值函数中引用计数的自增操作提取出来,封装成AddRef函数,将拷贝赋值函数和析构函数中引用计数的自减操作提取出来,封装成ReleaseRef函数,这样就只需要对AddRef和ReleaseRef函数进行加锁保护即可。
namespace cl
{
template<class T>
class shared_ptr
{
private:
//++引用计数
void AddRef()
{
_pmutex->lock();
(*_pcount)++;
_pmutex->unlock();
}
//--引用计数
void ReleaseRef()
{
_pmutex->lock();
bool flag = false;
if (--(*_pcount) == 0) //将管理的资源对应的引用计数--
{
if (_ptr != nullptr)
{
cout << "delete: " << _ptr << endl;
delete _ptr;
_ptr = nullptr;
}
delete _pcount;
_pcount = nullptr;
flag = true;
}
_pmutex->unlock();
if (flag == true)
{
delete _pmutex;
}
}
public:
//RAII
shared_ptr(T* ptr = nullptr)
:_ptr(ptr)
, _pcount(new int(1))
, _pmutex(new mutex)
{}
~shared_ptr()
{
ReleaseRef();
}
shared_ptr(shared_ptr<T>& sp)
:_ptr(sp._ptr)
, _pcount(sp._pcount)
, _pmutex(sp._pmutex)
{
AddRef();
}
shared_ptr& operator=(shared_ptr<T>& sp)
{
if (_ptr != sp._ptr) //管理同一块空间的对象之间无需进行赋值操作
{
ReleaseRef(); //将管理的资源对应的引用计数--
_ptr = sp._ptr; //与sp对象一同管理它的资源
_pcount = sp._pcount; //获取sp对象管理的资源对应的引用计数
_pmutex = sp._pmutex; //获取sp对象管理的资源对应的互斥锁
AddRef(); //新增一个对象来管理该资源,引用计数++
}
return *this;
}
//获取引用计数
int use_count()
{
return *_pcount;
}
//可以像指针一样使用
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr; //管理的资源
int* _pcount; //管理的资源对应的引用计数
mutex* _pmutex; //管理的资源对应的互斥锁
};
}
- 在ReleaseRef函数中,当引用计数被减为0时需要释放互斥锁资源,但不能在临界区中释放互斥锁,因为后面还需要进行解锁操作,因此代码中借助了一个flag变量,通过flag变量来判断解锁后释放需要释放互斥锁资源。
- shared_ptr只需要保证引用计数的线程安全问题,而不需要保证管理的资源的线程安全问题,就像原生指针管理一块内存空间一样,原生指针只需要指向这块空间,而这块空间的线程安全问题应该由这块空间的操作者来保证。
std::shared_ptr的定制删除器
定制删除器的用法
当智能指针对象的生命周期结束时,所有的智能指针默认都是以delete
的方式将资源释放,这是不太合适的,因为智能指针并不是只管理以new
方式申请到的内存空间,智能指针管理的也可能是以new[]
的方式申请到的空间,或管理的是一个文件指针。
struct ListNode
{
ListNode* _next;
ListNode* _prev;
int _val;
~ListNode()
{
cout << "~ListNode()" << endl;
}
};
int main()
{
std::shared_ptr<ListNode> sp1(new ListNode[10]); //error
std::shared_ptr<FILE> sp2(fopen("test.cpp", "r")); //error
return 0;
}
这时当智能指针对象的生命周期结束时,再以delete
的方式释放管理的资源就会导致程序崩溃,因为以new[]
的方式申请到的内存空间必须以delete[]
的方式进行释放,而文件指针必须通过调用fclose
函数进行释放。
这时就需要用到定制删除器来控制释放资源的方式,C++标准库中的shared_ptr提供了构造函数:
template <class U, class D>
shared_ptr (U* p, D del);
- p:需要让智能指针管理的资源。
- del:删除器,这个删除器是一个可调用对象,比如函数指针、仿函数、lambda表达式以及被包装器包装后的可调用对象。
当shared_ptr对象的生命周期结束时就会调用传入的删除器完成资源的释放,调用该删除器时会将shared_ptr管理的资源作为参数进行传入。
因此当智能指针管理的资源不是以new
的方式申请到的内存空间时,就需要在构造智能指针对象时传入定制的删除器。
template<class T>
struct DelArr
{
void operator()(const T* ptr)
{
cout << "delete[]: " << ptr << endl;
delete[] ptr;
}
};
int main()
{
std::shared_ptr<ListNode> sp1(new ListNode[10], DelArr<ListNode>());
std::shared_ptr<FILE> sp2(fopen("test.cpp", "r"), [](FILE* ptr){
cout << "fclose: " << ptr << endl;
fclose(ptr);
});
return 0;
}
定制删除器的模拟实现
定制删除器的实现问题:
- C++标准库中实现shared_ptr时是分成了很多个类的,因此C++标准库中可以将删除器的类型设置为构造函数的模板参数,然后将删除器的类型在各个类之间进行传递。
- 但我们是直接用一个类来模拟实现shared_ptr的,因此不能将删除器的类型设置为构造函数的模板参数。因为删除器不是在构造函数中调用的,而是需要在ReleaseRef函数中进行调用,因此势必需要用一个成员变量将删除器保存下来,而在定义这个成员变量时就需要指定删除器的类型,因此这里模拟实现的时候不能将删除器的类型设置为构造函数的模板参数。
- 要在当前模拟实现的shared_ptr的基础上支持定制删除器,就只能给shared_ptr类再增加一个模板参数,在构造shared_ptr对象时就需要指定删除器的类型。然后增加一个支持传入删除器的构造函数,在构造对象时将删除器保存下来,在需要释放资源的时候调用该删除器进行释放即可。最好在设置一个默认的删除器,如果用户定义shared_ptr对象时不传入删除器,则默认以delete的方式释放资源。
namespace cl
{
//默认的删除器
template<class T>
struct Delete
{
void operator()(const T* ptr)
{
delete ptr;
}
};
template<class T, class D = Delete<T>>
class shared_ptr
{
private:
void ReleaseRef()
{
_pmutex->lock();
bool flag = false;
if (--(*_pcount) == 0) //将管理的资源对应的引用计数--
{
if (_ptr != nullptr)
{
cout << "delete: " << _ptr << endl;
_del(_ptr); //使用定制删除器释放资源
_ptr = nullptr;
}
delete _pcount;
_pcount = nullptr;
flag = true;
}
_pmutex->unlock();
if (flag == true)
{
delete _pmutex;
}
}
//...
public:
shared_ptr(T* ptr, D del)
: _ptr(ptr)
, _pcount(new int(1))
, _pmutex(new mutex)
, _del(del)
{}
//...
private:
T* _ptr; //管理的资源
int* _pcount; //管理的资源对应的引用计数
mutex* _pmutex; //管理的资源对应的互斥锁
D _del; //管理的资源对应的删除器
};
}
- 如果传入的删除器是一个仿函数,那么需要在构造shared_ptr对象时指明仿函数的类型。
- 如果传入的删除器是一个lambda表达式就更麻烦了,因为lambda表达式的类型不太容易获取。这里可以将lambda表达式的类型指明为一个包装器类型,让编译器传参时自行进行推演,也可以先用auto接收lambda表达式,然后再用decltype来声明删除器的类型。
template<class T>
struct DelArr
{
void operator()(const T* ptr)
{
cout << "delete[]: " << ptr << endl;
delete[] ptr;
}
};
int main()
{
//仿函数示例
cl::shared_ptr<ListNode, DelArr<ListNode>> sp1(new ListNode[10], DelArr<ListNode>());
//lambda示例1
cl::shared_ptr<FILE, function<void(FILE*)>> sp2(fopen("test.cpp", "r"), [](FILE* ptr){
cout << "fclose: " << ptr << endl;
fclose(ptr);
});
//lambda示例2
auto f = [](FILE* ptr){
cout << "fclose: " << ptr << endl;
fclose(ptr);
};
cl::shared_ptr<FILE, decltype(f)> sp3(fopen("test.cpp", "r"), f);
return 0;
}
std::weak_ptr
std::shared_ptr的循环引用问题
循环引用问题
shared_ptr的循环引用问题在一些特定的场景下才会产生。比如定义如下的结点类,并在结点类的析构函数中打印一句提示语句,便于判断结点是否正确释放。
struct ListNode
{
ListNode* _next;
ListNode* _prev;
int _val;
~ListNode()
{
cout << "~ListNode()" << endl;
}
};
现在以new
的方式在堆上构建两个结点,并将这两个结点连接起来,在程序的最后以delete
的方式释放这两个结点。
int main()
{
ListNode* node1 = new ListNode;
ListNode* node2 = new ListNode;
node1->_next = node2;
node2->_prev = node1;
//...
delete node1;
delete node2;
return 0;
}
上述程序是没有问题的,两个结点都能够正确释放。为了防止程序中途返回或抛异常等原因导致结点未被释放,我们将这两个结点分别交给两个shared_ptr对象进行管理,这时为了让连接节点时的赋值操作能够执行,就需要把ListNode类中的next和prev成员变量的类型也改为shared_ptr类型。
struct ListNode
{
std::shared_ptr<ListNode> _next;
std::shared_ptr<ListNode> _prev;
int _val;
~ListNode()
{
cout << "~ListNode()" << endl;
}
};
int main()
{
std::shared_ptr<ListNode> node1(new ListNode);
std::shared_ptr<ListNode> node2(new ListNode);
node1->_next = node2;
node2->_prev = node1;
//...
return 0;
}
这时程序运行结束后两个结点都没有被释放,但如果去掉连接结点时的两句代码中的任意一句,那么这两个结点就都能够正确释放,根本原因就是因为这两句连接结点的代码导致了循环引用。
当以new的方式申请到两个ListNode结点并交给两个智能指针管理后,这两个资源对应的引用计数都被加到了1。
将这两个结点连接起来后,资源1当中的next成员与node2一同管理资源2,资源2中的prev成员与node1一同管理资源1,此时这两个资源对应的引用计数都被加到了2。
当出了main函数的作用域后,node1和node2的生命周期也就结束了,因此这两个资源对应的引用计数最终都减到了1。
循环引用导致资源未被释放的原因:
- 当资源对应的引用计数减为0时对应的资源才会被释放,因此资源1的释放取决于资源2当中的prev成员,而资源2的释放取决于资源1当中的next成员。
- 而资源1当中的next成员的释放又取决于资源1,资源2当中的prev成员的释放又取决于资源2,于是这就变成了一个死循环,最终导致资源无法释放。
而如果连接结点时只进行一个连接操作,那么当node1和node2的生命周期结束时,就会有一个资源对应的引用计数被减为0,此时这个资源就会被释放,这个释放后另一个资源的引用计数也会被减为0,最终两个资源就都被释放了,这就是为什么只进行一个连接操作时这两个结点就都能够正确释放的原因。
std::weak_ptr解决循环引用问题
解决循环引用问题
weak_ptr是C++11中引入的智能指针,weak_ptr不是用来管理资源的释放的,它主要是用来解决shared_ptr的循环引用问题的。
- weak_ptr支持用shared_ptr对象来构造weak_ptr对象,构造出来的weak_ptr对象与shared_ptr对象管理同一个资源,但不会增加这块资源对应的引用计数。
将ListNode中的next和prev成员的类型换成weak_ptr就不会导致循环引用问题了,此时当node1和node2生命周期结束时两个资源对应的引用计数就都会被减为0,进而释放这两个结点的资源。
struct ListNode
{
std::weak_ptr<ListNode> _next;
std::weak_ptr<ListNode> _prev;
int _val;
~ListNode()
{
cout << "~ListNode()" << endl;
}
};
int main()
{
std::shared_ptr<ListNode> node1(new ListNode);
std::shared_ptr<ListNode> node2(new ListNode);
cout << node1.use_count() << endl;
cout << node2.use_count() << endl;
node1->_next = node2;
node2->_prev = node1;
//...
cout << node1.use_count() << endl;
cout << node2.use_count() << endl;
return 0;
}
通过use_count获取这两个资源对应的引用计数就会发现,在结点连接前后这两个资源对应的引用计数就是1,根本原因就是weak_ptr不会增加管理的资源对应的引用计数。
weak_ptr的模拟实现
- 提供一个无参的构造函数,比如刚才new ListNode时就会调用weak_ptr的无参的构造函数。
- 支持用shared_ptr对象拷贝构造weak_ptr对象,构造时获取shared_ptr对象管理的资源。
- 支持用shared_ptr对象拷贝赋值给weak_ptr对象,赋值时获取shared_ptr对象管理的资源。
- 对*和->运算符进行重载,使weak_ptr对象具有指针一样的行为。
namespace cl
{
template<class T>
class weak_ptr
{
public:
weak_ptr()
:_ptr(nullptr)
{}
weak_ptr(const shared_ptr<T>& sp)
:_ptr(sp.get())
{}
weak_ptr& 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函数,用于获取其管理的资源。
C++11和boost中智能指针的关系
- C++98中产生了第一个智能指针auto_ptr。
- C++boost给出了更实用的scoped_ptr、shared_ptr和weak_ptr。
- C++TR1,引入了boost中的shared_ptr等。不过注意的是TR1并不是标准版。
- C++11,引入了boost中的unique_ptr、shared_ptr和weak_ptr。需要注意的是,unique_ptr对应的就是boost中的scoped_ptr,并且这些智能指针的实现原理是参考boost中实现的。