对于大多数C++使用者而言,我们并不是总能接触到异常,或者更准确地说,我们可能不一定总是能编写抛出异常的函数,但我们一定经常接收着来自各种库的异常。
当我们对异常一无所知的时候,我们会发现在使用第三方库的时候,自己的程序往往会因为各种各样的运行错误而退出,要么是输入格式不规范,要么是内存不足。但这并不是我们所期望的,我们所期望的是,程序能够做一些操作,然后继续执行,而不是强制退出。
(1) 异常的使用者
这时候,我们就有必要了解异常处理了,以OpenCV为例,我们做一个GUI程序,以灰度方式读入一张图片,如果读取失败,则需要给用户一定的提示,再返回。
一般情况下,我们可能会这样写:
Mat* readImg() {
string path = getOpenFileName();
if (path.isEmpty()) {
MessageBox("路径错误");
return nullptr;
}
Mat* mat = new Mat();
*mat = imread(path, CV_LOAD_IMAGE_GRAYSCALE);
return mat;
}
假设用户输入的图片不符合规范,OpenCV库会抛出异常,在imread这一句之后,程序中止,应用程序因为用户的输入而退出了,这是一个相当糟糕的设计。
Mat* readImg() {
string path = getOpenFileName();
if (path.isEmpty()) {
MessageBox("路径错误");
return nullptr;
}
try {
Mat* mat = new Mat();
*mat = imread(path, CV_LOAD_IMAGE_GRAYSCALE);
return mat;
}
catch (cv::Exception& e) {
const char* err_msg = e.what();
MessageBox(string(err_msg));
return nullptr;
}
}
我们把可能出问题的语句包裹在try块中,也就是说,我们先试一试这个操作,看看能不能成功,如果失败了,就在catch语句中捕获,在这里,我们获取opencv库抛出的一个异常类Exception,并且调用what函数得到了错误字符串信息,我们把这个字符串用消息框的形式提示给用户,然后应用程序继续进行,用户可以选择重新读入一张符合要求的图片。
try也可以是函数级的,它的写法如下
void fun() try {
throw Exception("error");
} catch(Exception& e){
// ...
}
实际上等价于:
void fun() {
try {
throw Exception("error");
} catch(Exception& e){
// ...
}
}
(2) 异常的编写者
现在我们已经可以处理一些基本的异常了,那么对于类库的设计而言,我们可以使用throw来抛出一个异常,而这个异常可以是任何类型的,在上例中,抛出异常的类型为cv::Exception, 我们也可以简单的抛出一个int, double之类的变量,虽然这意义并不大。
我们可以自己编写一个Exception类,然后提供一些必要的信息,正如上例中的what函数,可以给出错误字符串,让异常接收者明白发生了什么。
class Exception {
const char* error_msg;
public:
Exception(const char* const msg = 0):error_msg(msg) { }
const char* what() { return error_msg; }
};
int* array(int size) {
int* a = (int*)malloc(size * sizeof(int));
if (a == nullptr) {
throw(Exception("空间不足!"));
return nullptr;
}
return a;
}
更为方便的,我们可以直接使用C++标准异常,在标准异常中已经定义好的,如越界、算术溢出、失败内存分配这些常见错误,推荐直接使用标准异常。
C++标准异常都继承自std::exception类,定义在头文件<exception>中,主要的派生类为logic_error以及runtime_error,顾名思义,它们处理的分别是逻辑错误和运行时错误,从这两个异常又派生出了不少别的异常,具体有哪些异常我们可以在使用的时候再去查一查,在这里就不一一列举了。
如果标准异常不能满足我们的需求,我们也可以继承标准异常来编写自己的异常类:
class MyException : public runtime_error {
public:
MyException(const string& msg = "") : runtime_error(msg) { }
};
(3) 栈反解(stack unwinding)
可能已经有人注意到了这么一个问题,我们在try语句中为一个Mat指针申请了空间,但是因为读入失败,这个指针没有作为返回值交给调用者来控制,也就是说,我们似乎面临着内存泄漏的风险。
而异常的最重要的特性,也正在于此,它可以回收try块中,异常发生之前的局部对象。也就是说,在异常发生后,mat的析构会被调用。
#include <iostream>
using namespace std;
class A {
private:
int num;
public:
A(int i) :num(i) { cout << "A(" << num << ")" << endl; };
~A() { cout << "~A(" << num << ")" << endl; }
void fun() { throw(1); }
};
int main() {
A a1(1);
try {
A a2(2);
a2.fun();
}
catch (int err) {
cout << "catch error" << endl;
}
cout << "main end" << endl;
return 0;
}
以上代码的执行结果为:
A(1)
A(2)
~A(2)
catch error
main end
~A(1)
可以看出,try块内定义的A的实例a2,在抛出异常之后立即调用了析构函数,随后在catch语句中被捕获。而在try块之外的a1则没有。
由此可见,我们在try块中的定义的那些局部变量都会被安全地回收。
(4) 异常匹配
考虑到try块中可能会抛出各种各样类型的异常,我们可以加入多个catch块来捕获对应的异常,这就涉及到了一个匹配的过程,也就是说,我们抛出的异常将会一个个比较类型,直到找到合适的类型。这一匹配遵循首次匹配原则,它会在找到第一个合适的类型,然后就不再继续,哪怕下面有更匹配的类型。这也就意味着我们在编写catch的时候要特别注意它们的先后顺序。
class BaseException { };
class DerivedException : public BaseException { };
void fun() {
throw DerivedException();
}
int main() {
try {
fun();
}
catch (BaseException& e) {
cout << "BaseException" << endl;
}
catch (DerivedException& e) {
cout << "DerivedException" << endl;
}
}
执行以上代码,由于DerivedException是一个BaseException,所以最终会被BaseException捕获,而不是DerivedException。
我们注意到异常的参数通常都使用引用,这是有原因的。假设上述异常类型用的是值传递,当DerivedException被BaseException捕获后,会发生切割现象——派生类多于父类的那一部分都将被切割,但我们更希望它能够保留多态性,所以使用引用是一个更好的选择。
另外一个原则是,匹配时不会发生类型转换。例如,对于如下代码而言,我们调用fun,但是传入一个int,这时候就会发生隐式转换,把int转换为Exception,运行程序,也能打印出2。但是在匹配抛出的int时,Exception的异常类型无法接收这个异常,而只能由int的异常类型来接收,这正是因为类型转换不会发生。
class Exception {
private:
int num;
public:
Exception(int i) :num(i) { }
void print() { cout << num << endl; }
};
void fun(Exception e)
{
e.print();
}
int main() {
fun(2);
try {
throw 1;
}
catch (Exception& e) {
cout << "Exception" << endl;
}
catch (int e) {
cout << "int" << endl;
}
}
我们还有一种捕获所有异常的方法,它通常放在异常处理器列表的最后,但是它无法接收任何参数。我们往往用它来清理资源,然后重新抛出异常。
重新抛出异常会被更高层语境的catch来接收。也就是说,它不会被并列的后面的catch所接收,而是嵌套在外层的catch接收。有这样一个适用场景,类的设计者先捕获异常做一些统一的处理,然后再把这个异常抛出,供库的使用者再一次catch。
catch (...) {
}
(5) 中止退出
正如我们一开始读入图片的代码一样,在我们没有捕获异常的时候,程序会异常退出。那么同样地,当所有的catch都不能匹配上的时候,也会经历同样的过程。
异常中止的发生是因为当无法捕获异常的时候,程序会调用一个terminate()的函数,默认情况下,它会调用标准库abort()使得程序中止。
我们也能通过set_terminate来编写自己的terminate函数,terminate的返回值为void,无参数。
void (*oterminate)() = set_terminate(terminator);
set_terminate传入自己的函数,返回的是默认的terminate函数。
(6) 在构造和析构函数中抛出异常
我们可能会因为某些业务需求无意中写出在析构函数抛出异常的代码,但需要注意的是这是不可取的!因为在栈反解的过程中会调用析构函数,这时现存的异常可能还没有到达catch子句,就调用terminate()导致程序中止,这是我们所不期望的。
而在构造函数中抛出异常也是很常见的编程行为,因为在一个对象创建的时候难免会出一些意外,导致这个对象无法正确创建,但有一个问题需要注意的是,如果在构造函数中抛出异常,也就意味着构造并没有完成,对象没有被创建,所以相应地,栈反解的时候,析构函数也不会被调用。所以我们在抛出异常之前申请的那些内存,都无法得到释放。我们必须采取一定的措施来预防这一情况的发生。
我们要么直接在构造函数中捕获异常并做处理,要么保证资源分配的原子性,如将每个指针都用一个模板类来封装,每个模板类单独处理一个指针的异常,而不是把所有资源一起分配一起抛出异常,导致一旦抛出异常资源都无法正常回收。
(7) 异常声明
一般情况下,对一个函数,当我们什么也不写的时候,这意味着我们可以抛出任何类型的异常。
如果我们想要限定抛出异常的种类,我们可以写成:
void fun() throw ( Except1,Except2,Except3);
当我们指定不抛出任何异常的时候,可以写成:
void fun() noexcept;
如果我们声明了异常种类,却抛出了不在这一声明中的异常,就会调用unexpected()函数,它默认会调用terminate()使得程序中止,和terminate一样,我们可以使用set_unexpected来编写自己的unexcepted函数。
特别需要注意的是,对于派生类而言,它会继承基类抛出的异常。所以对于标准c++库而言,它一般不会去限定异常类型,因为它对派生类的异常是不可预见的。