【C++】异常与智能指针
目录
一、异常
(一)传统错误处理机制
(二)概念
二、异常的使用
(一)基础语法
(二)异常的抛出和捕获
1、异常的抛出与匹配原则
2、在调用链中异常栈展开匹配原则
(三)异常的重新抛出
(四)异常安全与规范
三、标准库异常与自定义异常
四、异常的优缺点
(一)优点
(二)缺点
五、智能指针
(一)内存泄漏
(二)概念
1、RAII
2、像指针一样
六、C++98的auto_ptr
(一)unique_ptr
1、概念
2、线程安全
3、循环引用
(三)weak_ptr
八、定制删除器
一、异常
(一)传统错误处理机制
在未引入异常以前,C语言主要靠两种方式处理错误:
1、终止程序:如 assert ,这种方式会直接终止掉运行程序,如发生内存错误或者除0错误;
2、返回错误码:通过返回错误码的形式。例如很多系统调用函数都是通过将错误码放入 errno 中,但用户需要根据该错误码去查询对应的错误。
(二)概念
异常是一种处理错误的方式,当一个函数发现无法处理的错误时即可以抛出异常,让该函数的直接或间接调用者处理该异常。
throw:当程序出现异常,可以使用 throw 关键字抛出异常。其中将任意对象使用 throw 抛出后,由对象的类型决定激活哪段 catch 代码。
catch: 捕获异常,可以使用多组catch进行捕获,可以通过该关键字捕获异常并进行程序处理。
同一栈帧的 catch 捕获异常的类型必须不相同。如果异常抛出后并没有匹配的 catch 类型进行捕获,程序会异常退出,。进行捕获的 catch 类型必须与抛出类型一样并且离 throw 抛出异常的栈帧最近。
try: try块中的代码标识将被激活的特定异常,它后面通常跟着一个或多个catch块,try 块中的代码被称为保护代码。
二、异常的使用
(一)基础语法
try
{
// 保护的标识代码
}catch( ExceptionName e1 )
{
// catch 块
}catch( ExceptionName e2 )
{
// catch 块
}catch( ExceptionName eN )
{
// catch 块
}catch( ... )
{
// 捕获任意类型
}
(二)异常的抛出和捕获
1、异常的抛出与匹配原则
(1)异常是通过抛出对象触发的,该对象的类型决定了激活哪个 catch 的处理代码;
(2)被激活 catch 的处理代码是调用链中与抛出对象类型匹配且与抛出异常栈帧最近的那一个;
(3)抛出异常对象后,会生成一个异常对象的临时拷贝,因为抛出对象有可能是一个临时对象,所以会生成一个临时拷贝。该拷贝对象会被 catch 捕获以后销毁;
(4)catch( ... ) 会捕捉任意类型的异常对象,但无法得知该异常对象的类型。
2、在调用链中异常栈展开匹配原则
(1)首先检查 throw 本身是否在 try 保护代码中(同一个栈帧),如是则去匹配相应的 catch 异常代码并进行处理;
(2)如不是则退出当前栈帧,在调用函数栈帧的栈帧中寻找匹配的 catch 异常处理代码;
(3)若一直寻找至 main 函数的栈帧,如没有匹配的 catch 异常处理,则程序会直接终止。因此一般在 main 栈帧加一个 catch(...) 捕获任意类型的异常以防程序直接终止。
需要注意的是,抛出异常进行 catch 代码处理后代码执行流并不会返回到抛出异常代码处,而是在 catch 代码处理后继续执行。
double div(double x, double y)
{
if (y == 0)
{
throw "除零异常";
}
std::cout << "div(double, double)" << std::endl;
return x / y;
}
void func()
{
try
{
double x = 0, y = 0;
std::cin >> x >> y;
std::cout << div(x, y) << std::endl;
}
catch (const char* errmsg)
{
std::cout << errmsg << std::endl;
}
catch (...)
{
std::cout << "未知异常" << std::endl;
}
std::cout << "func()" << std::endl;
}
int main()
{
try
{
func();
}
catch (const char* errmsg)
{
std::cout << errmsg << std::endl;
}
catch (...)
{
std::cout << "未知异常" << std::endl;
}
return 0;
}
运行结果:
由上可见,当抛出异常后并不会返回抛出异常处继续执行。
(三)异常的重新抛出
在一些场景中可能需要将捕获的异常重新抛出。
double div(double x, double y)
{
if (y == 0)
{
throw "除零异常";
}
std::cout << "div(double, double)" << std::endl;
return x / y;
}
void func()
{
int* ptr = new int[10];
try
{
double x = 0, y = 0;
std::cin >> x >> y;
std::cout << div(x, y) << std::endl;
}
catch (...)
{
delete[] ptr; //释放申请的资源以防内存泄露
std::cout << "delete[]" << std::endl;
throw; //向外层抛出
}
delete[] ptr;
}
int main()
{
try
{
func();
}
catch (const char* errmsg)
{
std::cout << errmsg << std::endl;
}
catch (...)
{
std::cout << "未知异常" << std::endl;
}
return 0;
}
以上例子中我们将异常统一在主函数中进行处理。如果在 func() 函数中不进行 catch 异常捕获并重新抛出,那么当发生异常后执行流会直接跳转至 main 函数中的异常处理,而在 func() 函数中申请的资源并不会释放而导致内存泄露,因此需要现在 func() 函数中进行异常捕获,捕获后将资源释放并将异常重新抛出,由此可以避免内存泄漏。除此之外,其实还有更好的解决方案:智能指针,这些我们在下文进行介绍。
(四)异常安全与规范
由上文我们得知,异常的不当使用可能会导致内存泄漏。因此我们在使用异常时需要注意:
1、在构造函数中尽量不要抛出异常。构造函数是完成对象的初始化,因此如果在构造函数中抛出异常可能会导致对象初始化不完整或没有完全初始化;
2、同样的,在析构函数中也不要抛出异常。析构函数主要完成资源的清理,如果在析构函数中抛出异常可能会导致资源释放不完整而导致内存泄漏;
3、C++异常很容易就导致内存泄漏的问题,C++是使用RAII来解决的,有关RAII的内容将会在指针指针处进行介绍。
为了方便用户编写程序,C++还引入了异常规格说明,表明该函数在调用时可能会抛出异常对学校的类型,增强代码的可读性。
// 表明在该函数中可能会抛出 异常类型分别为A, B, C, D的异常对象
void fun() throw(A,B,C,D);
但是这个在实际项目中并不常用,例如在 func() 函数中可能还会调用其他的函数,而在其他函数中可能会抛出其他类型的异常,如果要完全按照以上规则,那么用户必须得了解在该函数中调用的其他函数中可能会抛出异常对象的类型。
由于以上的缺陷,C++11中引入新的关键字 noexcept ,表明函数不会抛出异常,而不带有该关键字的则表明可能会抛出异常(并不是强制要求必须使用 noexcept 关键字)。
//使用 noexcept 表明该函数不会抛出异常
void func() noexcept;
三、标准库异常与自定义异常
C++标准库的提供了exception类的异常体系。可以继承exception类实现自己的异常体系。
以上只是C++标准库 exception类的部分内容,这里不进行展开叙述了。
int main()
{
try {
vector<int> v(10, 5);
// 这里如果系统内存不够也会抛异常
v.reserve(1000000000);
}
catch (const exception& e) // 这里捕获父类对象就可以
{
// 打印输出异常内容
cout << e.what() << endl;
}
catch (...)
{
cout << "未知异常" << endl;
}
return 0;
}
除了使用标准库以外,实际中我们可以可以去继承exception类实现自己的异常类。
四、异常的优缺点
(一)优点
1、在使用错误码处理错误时,可能会因此调用链太深导致难以定位bug出现的具体位置,而充分正确使用异常,相比较于传统的错误处理机制可以更加精确地展示出错误的各种信息,可以更好的定位以及处理bug;
2、很多的第三方库都包含异常,比如boost、gtest、gmock等等常用的库,那么我们使用它们 也需要使用异常;
3、部分函数使用异常更好处理,比如构造函数没有返回值,不方便使用错误码方式处理。比如T& operator这样的函数,如果pos越界了只能使用异常或者终止程序处理,没办法通过返回值表示错误。
(二)缺点
1、异常会导致程序的执行流乱跳,并且非常的混乱,并且是运行时出错抛异常就会乱跳。这会增加调试程序时的复杂程度;
2、 异常会有一些性能的开销。当然在现代硬件速度很快的情况下,这个影响基本忽略不计;
3、 C++没有垃圾回收机制,资源需要自己管理。有了异常非常容易导致内存泄漏、死锁等异常安全问题。这个需要使用RAII来处理资源的管理问题,增加学习成本;
4、因此在各个项目中可能都定义了各自的异常体系,增加管理项目的成本;
5、异常尽量规范使用,以防调用链过深导致项目编写调试困难。在使用异常时需注意:
(1)抛出异常类型都继承自一个基类;
(2)函数是否抛异常、抛什么异常,都使用 func()throw(A,B,C)和noexcept的方式规范化。
五、智能指针
(一)内存泄漏
内存泄漏指因为疏忽或错误造成程序未能释放已经不再使用的内存的情况。内存泄漏并不是指内存在物理上的消失,而是应用程序分配某段内存后,因为设计错误,失去了对该段内存的控制,因而造成了内存的浪费。
对于长期运行的程序而言,如果出现内存泄漏会导致程序运行越来越慢最终崩溃卡死。
void func()
{
int* pa = new int;
int* pb = new int;
try
{
//调用函数
function();
}
catch (...)
{
//发生异常释放申请资源
delete pa;
delete pb;
throw;
}
//未发生异常正常释放资源
delete pa;
delete pb;
}
int main()
{
try
{
func();
}
catch (...)
{
//处理异常...
}
return 0;
}
例如在以上场景,看似无论发不发生对申请的资源进行了处理,但实际上内存资源不足的情况下,new 申请资源也会抛出异常。
如果 pa,pb 在申请资源时抛出异常会直接由主函数捕获到异常而导致内存泄漏。无论是将 pa,pb 单独设置异常处理还是将其并入到下面的保护代码块中都无法解决问题,因为 pa,pb 申请资源失败时抛出的异常类型是相同的,当pa申请失败抛出异常则不需要进行操作,当pb申请失败则需要对 pa 进行资源释放,但是我们接收到异常时无法确定是 pa 抛出的还是 pb 抛出的。
如果将每次一次资源申请都单独加以异常处理虽然理论可行,但是在实际项目中那样会造成大量的代码冗余,因此引入了智能指针。
(二)概念
智能指针实际就是里用类与对象的特性来管理资源,对象出作用域以后会自动调用析构函数来释放资源。智能指针只要有以下两个特性:
1、RAII
RAII是一种利用对象生命周期来控制程序资源(如内存、文件句柄、网络连接、互斥量等等)的简单技术。对象构造时获取资源,对象析构时自动释放资源。(可以利用局部对象出作用域自动调用析构函数完成对象资源的清理)
2、像指针一样
通过重载运算符使得智能指针可以像指针一样使用,智能指针的类型很多,但是其主要区别都是对拷贝构造以及赋值重载的处理不同。
智能指针实际就是通过类与对象的特性完成的,下面是示例代码:
template<class T>
class smartPtr
{
smartPtr(cosnt T* ptr = nullptr)
:_ptr(ptr)
{}
~smartPtr()
{
if(_ptr)
delete _ptr;
_ptr = nullptr;
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
T& operator[](size_t idx)
{
return _ptr[idx];
}
private:
T* _ptr;
};
六、C++98的auto_ptr
C++98引入的 auto_ptr 的主要特性是发生拷贝构造以及赋值重载时会移交管理资源,也就是会把原智能指针的对象悬空。
template<class T>
class auto_ptr
{
public:
auto_ptr(auto_ptr<T>& ptr)
{
_ptr = ptr._ptr;
ptr._ptr = nullptr
}
auto_ptr<T> operator=(auto_ptr<T>& ptr)
{
if (_ptr != ptr._ptr)
{
_ptr = ptr._ptr;
ptr._ptr = nullptr;
}
}
private:
T* _ptr
};
七、C++11的 unique_ptr 和 shared_ptr
(一)unique_ptr
unique_ptr 的主要特性是不运行进行拷贝构造以及赋值重载,也就是一个资源只允许被一个智能指针进行管理。
template<class T>
class unique_ptr
{
public:
//禁用拷贝构造与赋值重载
unique_ptr(unique_ptr<T>& ptr) = delete;
unique_ptr<T> operator(unique_ptr<T>& ptr) = delete;
private:
T* _ptr
};
(二)shared_ptr
1、概念
与 unique_ptr 不同,shared_ptr 是允许多个智能指针共同管理一个资源的,其采用引用计数的方法统计同一块资源由多少个智能指针进行管理。只有引用计数为 0 时,才会去调用析构释放资源。
template<class T>
class unique_ptr
{
unique_ptr(cosnt T* ptr = nullptr)
:_ptr(ptr)
._pcount(new int(1))
{}
~unique_ptr()
{
if (--(*_pcount) == 0)
{
delete _ptr;
delete _pcount;
}
}
unique_ptr(unique_ptr<T>& ptr)
:_ptr(ptr._ptr)
,_pcount(ptr._pcount)
{
++(*_pcount);
}
unique_ptr<T> operator=(unique_ptr<T>& ptr)
{
if (_ptr != ptr._ptr)
{
if (--(*_pcount) == 0)
{
delete _ptr;
delete _pcount;
}
_ptr = ptr._ptr;
_pcount = ptr._pcount;
++(*_pcount);
}
return *this;
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
T& operator[](size_t idx)
{
return _ptr[idx];
}
private:
T* _ptr;
int* _pcount;
};
以上代码只是对 shared_ptr 的简单实现,实际上的 shared_ptr 十分复杂,对于引用计数是有一个专门的类来管理的。
2、线程安全
这里需要注意的是,智能指针并不是完全线程安全的,实际上 shared_ptr 是只对计数器而言是线程安全,而进行管理的数据并不是线程安全的,这需要用户自行进行保护。
int main()
{
int* p = new int(0);
std::shared_ptr<int> ap(p);
std::thread t1([&]() {
for(int i = 0; i < 50000; ++i)
{
++(*ap);
}
});
std::thread t2([&]() {
for (int i = 0; i < 50000; ++i)
{
++(*ap);
}
});
t1.join();
t2.join();
std::cout << *ap << std::endl;
return 0;
}
以上代码证明的打印输出结果并不是每次都是100000,因此证明智能指针进行管理的资源并不是线程安全的,由此当我们对自定义类型进行管理时需要注意线程安全。
3、循环引用
shared_ptr 存在一个缺陷,也就是循环引用。
从上图我们可得知,在一些场景下智能指针并不能完美解决资源管理。
在以上场景,当出作用域时sp1 与 sp2本应该释放所管理的资源,但因为节点内包含着智能指针相互指向,导致出作用域后智能指针管理的节点资源的引用计数并不为 0,因此导致出作用域后智能指针 sp1 和 sp2被销毁,但其管理的节点资源并没有释放。
由于以上问题,因此引入了 weak_ptr。
(三)weak_ptr
与普通的智能指针不同,weak_ptr 专用于协同 shared_ptr 工作。weak_ptr 支持无参的构造,支持拷贝构造以及 shared_ptr 拷贝构造,但是不支持使用指针进行构造,无法单独使用。
weak_ptr 解决了 shared_ptr 循环引用的问题,但本质是 weak_ptr 参与资源的管理,但不会增加 shared_ptr 内的计数器。其实 weak_ptr 内也有计数器,其主要是用来判断管理的资源是否有 shared_ptr 指向,方便资源的管理。
八、定制删除器
观察 shared_ptr 的构造函数我们可以得知,有些时候可以传入删除器来实现析构。在一些自定义类型或者特殊类型的场景,默认的 delete 删除方式可能并不能满足我们的需要,例如当使用智能指针管理文件指针时,delete 方式并不能释放资源,因此我们需要传入删除器。
删除器参数的传入可以使用仿函数的传入实现,如不传入则使用默认删除器进行资源释放,例如:
template <class T>
struct Delete
{
void operator()(const T* ptr)
{
delete[] ptr;
}
};
int mian()
{
std::shared_ptr<int> sp1(new int[10], Delete<int>());
std::shared_ptr<std::string> sp2(new std::string[10], Delete<std::string>());
std::shared_ptr<std::string> sp3(new std::string[10], [](std::string* ptr) {delete[] ptr; });
std::shared_ptr<FILE> sp4(fopen("test.txt", "r"), [](FILE* ptr) {fclose(ptr); });
return 0;
}