异常
异常概念
我们传统处理错误的机制为:
- 终止程序,如assert
缺陷:用户难以接受。如发生内存错误,除0错误时就会终止程序。 - 返回错误码
缺陷:需要程序员自己去查找对应的错误。如系统的很多库的接口函数都是通过把错误码放到errno中,表示错误
在实际中C语言基本都是使用返回错误码的方式处理错误,部分情况下使用终止程序处理非常严重的错误,很明显这些处理错误的方式无论是用户还是开发者都是难以接受的,因此c++提出了异常的概念。
异常是一种处理错误的方式,当一个函数发现自己无法处理的错误时就可以抛出异常,让函数的直接或间接的调用者处理这个错误。与异常相关的关键字主要有3个:
- throw:当问题出现时,开发者通过使用 throw 关键字让程序抛出一个异常
- catch:用于捕获异常,匹配参数类型与抛出的异常的对象类型相同的catch快,可以有多个catch进行捕获
- try:尝试运行可能出现错误的代码
其使用格式一般如下:
throw xxx;//xxx可以是任意类型的对象
try
{
// 可能出错的代码
}
catch( ExceptionName e1 )
{
// catch 块
}
catch( ExceptionName e2 )
{
// catch 块
}
catch(...)//表示捕获任意类型的异常
{
// catch 块
}
异常的使用
异常的抛出匹配原则如下:
- 异常是通过抛出对象而引发的,该对象的类型决定了应该激活哪个catch的处理代码。
- 编译器会检查throw本身是否在try块内部,如果是再查找匹配的catch语句。如果有匹配的,则跳到catch的地方进行处理,如果没有匹配的catch则退出当前函数栈,继续在调用函数的栈中进行查找匹配的catch,直至main函数的栈帧,如果到达main函数的栈,依旧没有匹配的,则终止程序。
- 被选中的处理代码是调用链中与该对象类型匹配且离抛出异常位置最近的那一个。
- 实际中抛出和捕获的匹配原则有个例外,并不都是类型完全匹配,可以抛出的派生类对象,使用基类进行捕获。
在使用过程中有以下几点需要注意:
-
catch(…)可以捕获任意类型的异常,但无法知道是什么异常错误。
-
如果main函数即将退出,但还有throw抛出的异常没有被捕获,则程序出错,因此在捕获模块中最后应加上catch(…)避免程序异常终止。
-
抛出异常对象后,会生成一个异常对象的拷贝,因为抛出的异常对象可能是一个临时对象,所以会生成一个拷贝对象,这个拷贝的临时对象会在被catch以后销毁。(这里的处理类似于函数的传值返回)
-
找到匹配的catch子句并处理以后,会继续沿着catch子句后面继续执行,这意味着异常捕获后代码可以跨越多层栈帧接着执行,也意味着程序的执行流被打乱了。
-
异常可以重新抛出,即有可能单个的catch不能完全处理一个异常,在进行一些校正处理以后,可以在catch中通过重新抛出将异常传递给更上层的函数进行处理。
由于捕获异常后程序是继续沿着catch子句后面继续执行,这就引发了一些异常安全问题:
-
构造函数完成对象的构造和初始化,最好不要在构造函数中抛出异常,否则可能导致对象不完整或没有完全初始化
-
析构函数主要完成资源的清理,最好不要在析构函数内抛出异常,否则可能导致资源泄漏(内存泄漏、句柄未关闭等)
-
C++中异常经常会导致资源泄漏的问题,比如在new和delete中抛出了异常导致内存泄漏,在lock和unlock之间抛出了异常导致死锁。
new ...
fun();//调用fun函数抛异常,导致后面的delete没有被调用,从而导致内存泄漏
delete...
针对以上问题,开发者可以在捕获异常后对资源进行释放,但这会导致代码的可读性和美观性极大下降 ,明显不实用,为此c++11提出了智能指针的概念以解决异常安全的问题。
为了让函数使用者知道该函数可能抛出的异常有哪些,c++提出了一些异常规范:
- 可以在函数的后面接throw(类型),列出这个函数可能抛掷的所有异常类型, 函数的后面接throw(),表示函数不抛异常。
// 这里表示这个函数会抛出A/B/C/D中的某种类型的异常
void fun() throw(A,B,C,D);
// 这里表示这个函数只会抛出bad_alloc的异常
void* operator new (std::size_t size) throw (std::bad_alloc);
// 这里表示这个函数不会抛出异常
void* operator delete (std::size_t size, void* ptr) throw();
- 若函数后面无异常接口声明,则此函数可以抛掷任何类型的异常。
- C++11 中新增关键字noexcept,放在函数后面表示该函数不会抛异常
//表示不会抛异常
thread() noexcept;
这些异常规范不是强制要求的,一般程序员都不会遵守。
异常体系
1.c++标准库的异常体系:
C++ 提供了一系列标准的异常,我们可以在程序中使用这些标准的异常,需要包含头文件<exception>,它们是以父子类层次结构组织起来的:
对上图各个异常的说明:
但C++标准库设计的不够好用,实际中很多公司会自己定义一套异常继承体系。
2.自定义异常体系
实际中我们可以可以去继承exception类实现自己的异常类,定义自己的异常体系。
这样抛出的都是继承的派生类对象,因此只需要捕获一个基类就可以了。
异常优缺点
C++异常的优点:
-
异常对象相比错误码的方式可以清晰准确的展示出错误的各种信息,甚至可以包含堆栈调用的信息,这样可以帮助程序员更好的定位程序的bug
-
返回错误码的传统方式有个很大的问题就是,在函数调用链中,深层的函数返回了错误,那么我们得层层返回错误,最外层才能拿到错误
-
很多的第三方库都包含异常,比如boost、gtest、gmock等等常用的库,在使用它们也需要使用异常。
-
部分函数使用异常更好处理,比如构造函数没有返回值,不方便使用错误码方式处理。比如T& operator这样的函数,如果pos越界了只能使用异常或者终止程序处理,没办法通过返回值表示错误。
C++异常的缺点:
- 异常会导致程序的执行流乱跳,并且非常的混乱,这增加在踪调试时以及分析程序时的困难。
- 异常会有一些性能的开销。当然在现代硬件速度很快的情况下,这个影响基本忽略不计。
- C++没有垃圾回收机制,资源需要自己管理。有了异常非常容易导致内存泄漏、死锁等异常安全问题。这个需要使用RAII来处理资源的管理问题。
- C++标准库的异常体系定义得不好,导致开发者各自定义各自的异常体系,非常的混乱。
- 一些程序员可能会随意抛异常,导致外层捕获的用户苦不堪言。所以异常规范有两点:一是抛出异常类型都继承自一个基类。二是函数是否抛异常、抛什么异常,都使用 func() throw();的方式规范化。
异常总体而言,利大于弊,所以工程中我们还是建议使用异常。
智能指针
我们知道内存泄漏时非常严重的危害,尽管程序员在申请空间时匹配着去释放空间,由于异常扰乱了执行流,依旧会存在内存泄漏的风险,因此需要使用智能指针来解决这个问题。
原理
RAII(Resource Acquisition Is Initialization)是一种利用对象生命周期来控制程序资源(如内存、文件句柄、网络连接、互斥量等等)的简单技术。在对象构造时获取资源,接着控制对资源的访问使之在对象的生命周期内始终保持有效,最后在对象析构的时候释放资源。借此,我们实际上把管理一份资源的责任托管给了一个对象,当遇到异常扰乱执行流时,由于栈帧时正常销毁的,那么该对象也是正常销毁的,对象在销毁时会调用其析构函数,这样我们就可以将对象的资源呢正常释放了,这种做法有两大好处:
1.不需要显式地释放资源。
2.采用这种方式,对象所需的资源在其生命期内始终保持有效
我们只需要在类中对* 、->进行重载,就可以让其实例化对象可以像指针一样去使用了,但这依旧存在一个问题:当我们进行指针拷贝时,实际上是将一份资源交给了多个对象进行管理,当其中某个对象进行销毁时,提前把其他对象还需要的资源释放了,同时还会导致了一份资源被多次释放的错误,对此问题,c++提供了多个版本的智能指针,使用时需要包含头文件<memory>。
auto_ptr
auto_ptr是c++98版本开始提供的智能指针,其采取管理权转移的思想:只允许一个对象拥有资源,在拷贝时将资源转移给拷贝对象,同时将被拷贝对象指针置空。
//简单模拟
template<class T>
class auto_ptr
{
public:
auto_ptr(T* ptr = nullptr)
:_ptr(ptr)
{}
auto_ptr(auto_ptr<T>& ap)
:_ptr(ap._ptr)
{
ap._ptr = nullptr;
}
auto_ptr<T>& operator=(auto_ptr<T>& ap)
{
if (_ptr != ap._ptr)
{
if (_ptr)
{
delete _ptr;
std::cout << "auto_ptr::delete" << std::endl;
}
_ptr = ap._ptr;
ap._ptr = nullptr;
}
return *this;
}
T& operator* ()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
~auto_ptr()
{
if (_ptr)
{
delete _ptr;
std::cout << "auto_ptr::delete" << std::endl;
}
}
private:
T* _ptr;
};
这个版本的智能指针是极其不靠谱的,基本不被人们所接受,很多公司也是严禁使用这个版本的智能指针。
unique_ptr
c++11提供了相对靠谱一点的unique_ptr版本智能指针,其原理非常简单粗暴,就是禁止进行指针的拷贝。
//简单模拟
template<class T>
class unique_ptr
{
public:
unique_ptr(unique_ptr<T>& sp) = delete;
unique_ptr<T>& operator=(unique_ptr<T>& sp) = delete;
unique_ptr(T* ptr=nullptr)
:_ptr(ptr)
{}
T& operator* ()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
~unique_ptr()
{
if (_ptr)
{
delete _ptr;
std::cout << "unique_ptr::delete" << std::endl;
}
}
private:
T* _ptr;
};
shared_ptr
c++同时也提供了广大用户可以接受的shared_ptr版本的智能指针,其支持指针的拷贝,原理也较为简单,就是每一份资源都用一个引用计数记录资源的拥有者的个数,只有当引用计数为0时才释放资源。
//简单模拟
template<class T>
class share_ptr
{
public:
share_ptr(T* ptr = nullptr)
:_ptr(ptr)
{
_count_ptr = new size_t(1);
if (nullptr == _count_ptr)
{
perror("share_ptr");
exit(-1);
}
if (nullptr == _ptr)
{
*_count_ptr = 0;
}
}
share_ptr(share_ptr<T>& sp)
:_ptr(sp._ptr)
,_count_ptr(sp._count_ptr)
{
++(*_count_ptr);
}
share_ptr<T>& operator=(share_ptr<T>& sp)
{
if (_ptr != sp._ptr)
{
this->~share_ptr();
++(*(sp._count_ptr));
_ptr = sp._ptr;
_count_ptr = sp._count_ptr;
}
return *this;
}
T* GetPtr()
{
return _ptr;
}
~share_ptr()
{
if (_ptr)
{
--(*_count_ptr);
if (0 == (*_count_ptr))
{
delete _ptr;
delete _count_ptr;
std::cout << "~share_ptr::delete" << std::endl;
}
}
}
T& operator* ()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr;
size_t* _count_ptr;
};
但还存在一个问题,当出现循环引用(例如循环链表)时,资源依旧得不到释放,导致内存泄漏。
struct Node
{
share_ptr<Node> _pre;
share_ptr<Node> _next;
int _data;
};
share_ptr<Node> node1(new Node);
share_ptr<Node> node2(new Node);
node1->_next = node2;
node2->_next = node1;
//内存泄漏
为此c++提供了虚指针weak_ptr来解决这个问题,其不参与资源的管理。我们的想法是:既然是由于A节点里的next或pre指针指向另一个节点B时,调用了share_ptr的赋值拷贝函数后,使得B节点的计数增加了,最后在循环中导致内存无法释放,那我们只需要控制若是next或者pre指针指向节点B时,节点B的引用计数不增加,最后资源不就正常释放了吗,因此我们对pre或者next进行包装另一个类,使其可以正常指向节点,同时在复制拷贝中不增加计数。
//简单模拟
template<class T>
class weak_ptr
{
public:
weak_ptr(T* ptr = nullptr)
:_ptr(ptr)
{}
weak_ptr(weak_ptr<T>& wp)
:_ptr(wp._ptr)
{}
weak_ptr(share_ptr<T>& sp)
:_ptr(sp.GetPtr())
{}
weak_ptr<T>& operator=(weak_ptr<T>& wp)
{
_ptr = wp._ptr;
return *this;
}
weak_ptr<T>& operator=(share_ptr<T>& sp)
{
_ptr = sp.GetPtr();
return *this;
}
private:
T* _ptr;
};
这样使用智能指针就不会导致内存泄漏:
struct Node
{
weak_ptr<Node> _pre;
weak_ptr<Node> _next;
int _data;
};
share_ptr<Node> node1(new Node);
share_ptr<Node> node2(new Node);
node1->_next = node2;//不会计数
node2->_next = node1;//不会计数
因此只有正确的使用智能指针才能防止资源泄露。