C++异常处理

异常处理概念

异常是指存在运行时的反常行为,这些行为超出了函数正常功能的范围。典型的异常包括:数组越界、除零、失去数据库连接、遇到意外的输入等。处理反常行为可能是设计所有系统最难的一部分。
异常处理允许程序中独立开发的部分能够在运行就出现的问题进行通信并作出相应的处理。能够将问题的检测与解决过程分离开来。程序的一部分检测问题的出现,然后将解决该问题的任务传递给程序的另一部分,检测环节不需要知道问题处理模块的细节。
想要有效的使用异常,必须先要了解抛出异常时发生了什么,捕获异常时发生了什么,用来传递错误的对象有什么意义。

抛出异常

通过抛出一条表达式来引发一个异常,被抛出的表达式的类型和当前的调用链共同决定了哪段处理代码被用来处理异常。被选中的处理代码时在调用链中与抛出对象类型匹配最近的处理代码。根据抛出对象的类型和异常内容,程序的异常抛出部分将会告知异常处理部分到底发生了什么错误。
当执行一个 throw 时,跟在 throw 下面的语句将不再被执行。程序的控制权从 throw 转移到与之匹配的 catch 模块。该 catch 模块可能是同一个函数中的局部 catch,也可能位于直接或间接调用了发生异常的函数的另一个函数中,控制权从一处转移到另一处。
有两个重要的含义:

  1. 沿着调用链的函数可能会提前退出。
  2. 一旦程序开始执行异常处理代码,沿着调用链创建的对象将被销毁。

由于 throw 后面的语句不再执行,所以 throw 语句的用法有点类似于 return 语句。
我们看下面的例子,我们有一个函数用来计算两个数相除的结果,当分母为 0 时,抛出异常:

#include <iostream>
using namespace std;

int func2(int a, int b)
{
    if (0 == b)
        throw runtime_error("The denominator cannot be 0!");
    cout << "fun2:" << __LINE__ << endl;
    return a / b;
}

int func1(int a, int b)
{
    try
        {
            return func2(a, b);
        }
    catch(const std::runtime_error& e)
        {
            std::cerr << "std::runtime_error:" << e.what() << '\n';
        }
    catch(const std::exception& e)
        {
            std::cerr << "std::exception:" << e.what() << '\n';
        }
    cout << "fun1:" << __LINE__ << endl;
    return 0;
}

int main()
{
    int a = 0;
    int b = 0;
    std::cout << "place input two number with a space:";
    std::cin >> a >> b;
    std::cout << func1(1, 0) << endl;
    cout << "main exit!" << endl;
    return 0;
}

运行结果:

$ ./a.out 
place input two number with a space:2 0
std::runtime_error:The denominator cannot be 0!
fun1:26
0
main exit!

throw 处理过程(栈展开)

当使用 throw 抛出一个异常后,如果 throw 出现在一个 try 语句块内时,开始检查与该 try 块关联的 catch 子句,如果找到了匹配的 catch,就使用该 catch 处理异常,如果这一步没找到与之匹配的 catch 块,且该 try 语句嵌套再其他 try 块中,则继续检查与外层 try 匹配的 catch 子句。如果还是找不到匹配的 catch,则退出当前函数,继续向调用当前函数的外层函数中继续寻找与之匹配的 catch 子句。
这个过程成为栈展开,其操作会沿着嵌套函数的调用链不断查找,直到找到了与异常匹配的 catch 子句为止。如果一直没有找到与之匹配的 catch,则退出 main 主函数后查找过程终止。没有找到与之匹配的 catch,程序将调用标准库函数 terminate,terminate 负责终止程序的执行过程。
假设找到了一个匹配的 catch 子句,程序进入该子句并执行其中的代码。当执行完这个 catch 后,找到与该 try 块关联的最后一个 catch 子句后面的语句,从这里继续执行。

注意:一个异常如果没有被捕获,则它将终止当前的程序。

从上面的示例程序中可以看出,func2 函数中使用 throw 抛出了异常,但是在 func2 中没有对应的 catch 处理该异常,因为 func1 调用了 func2,于是程序退出 func2,检查 func1 中是否有与该异常匹配的 catch,因为 func2 是嵌套在 func1 中的 try 块中的,所以会直接在 func1 的 try 块对应的 catch 中搜索,执行完这个 catch 后,继续执行到与该 try 块关联的最后一个 catch 子句后面的语句。
修改一下程序,假如一直没找到对应的 catch 块:

int func1(int a, int b)
{
    try
        {
            if (0 == b)
            {
                throw runtime_error("The denominator cannot be 0!");
            }
        }
    catch(const std::logic_error& e)
        {
            std::cerr << "std::logic_error:" << e.what() << '\n';
        }
    cout << "fun1:" << __LINE__ << endl;
    return 0;
}

int main()
{
    int a = 0;
    int b = 0;
    std::cout << "place input two number with a space:";
    std::cin >> a >> b;
    std::cout << func1(1, 0) << endl;
    cout << "main exit!" << endl;
    return 0;
}

运行结果:

$ ./a.out 
place input two number with a space:3 0
terminate called after throwing an instance of 'std::runtime_error'
what():  The denominator cannot be 0!
Aborted (core dumped)

可以看见,程序在抛出 runtime_error 后,最终调用了 terminate 函数,打印出了异常提示,并终结了程序的执行。

析构函数与异常

栈展开过程中,位于调用链上的语句块可能会提前退出,通常情况下,程序在这些块中创建了一些局部对象也将随之销毁,编译器将确保块中创建的对象能被正确的销毁,如果是类类型,将会调用该类的析构函数,而内置类型的变量不需要做任何事情。
如果异常发生在构造函数中,可能只构造了一部分,只初始化了一部分变量,这时候发生了异常,也要确保构造的成员能被正确的销毁。
当异常发生或者使用 throw 时,后面的语句将不会执行,在析构函数中,如果在释放资源的代码之前就出现了异常,析构函数中负责释放资源的代码不会被执行,为了资源都能被正常的释放,析构函数不应该抛出不能被它自身处理的异常。析构函数必须将可能抛出异常的操作放在一个 try 语句块中,并且在析构函数的内部 catch 块中得到处理。
在栈展开时,局部对象的析构函数一旦抛出了异常,但自身不能捕获该异常,程序将被终止。

异常对象

异常对象是位于编译器管理的空间中,编译确保无论最终调用的是哪个 catch 子句都能访问该空间。
当异常发生时,编译器会使用 throw 语句抛出表达式的值来对“异常对象”进行拷贝初始化。当异常处理完毕后,异常对象被销毁。
异常对象的限制如下:

  1. 如果表达式是类类型的话,相必须含有一个可访问的析构函数和一个可访问的拷贝或移动构造函数。(拷贝初始化和析构用到)
  2. 如果该表达式是数组类型或函数类型,表达式将被转换为与之对应的指针类型。
  3. 抛出一个指向局部对象的指针是错误的行为。因为 throw 退出作用域后,局部对象被析构,抛出的指针到外层无法访问。
  4. 抛出的表达式必须是静态类型,如果抛出的指针是该对象的基类指针,则抛出的对象将被切掉一部分,只有基类的部分被抛出。

注意:抛出指针要求在任何对应的处理代码存在的地方,指针所指的对象都必须存在。

捕获异常

捕获异常使用 catch 子句,catch 子句的异常声明看起来像是只有一个形参的函数形参列表。声明的类型决定了可捕捉的异常类型。可以是左值引用,不能是右值引用。

  1. catch 的参数是非引用类型时,参数为异常对象的一个副本,在 catch 块内修改异常对象只是对副本进行修改,并非异常对象本身。
  2. catch 参数是引用类型时,参数为异常对象的别名,可以适用于继承体系下的对象传递,也就是基类类型引用绑定到派生类对象上。

catch 的匹配规则:
按照出现 catch 顺序逐一匹配,直到第一个与异常相匹配的 catch 块,未必是最佳匹配。
异常对象的类型和 catch 参数类型的允许的匹配如下:

  1. 允许从非常量向常量的转换
  2. 允许从派生类向基类的类型转换
  3. 数组和函数被转换成对应类型的指针

其他的转换都不允许,所以越是专门的 catch 就应该放到前面。比如,派生类异常处理代码需要放在基类异常处理代码之前。

重新抛出

如果一个 catch 语句不能完整的处理该异常,可能需要调用链上层函数接着处理异常,可以通过 catch 语句重新抛出将异常传递给上一层,语句格式为一个空的 throw; 他只能出现在 catch 语句或者 catch 语句直接或间接调用的函数内,否则会直接调用 terminate。
如果我们修改了 catch 参数的内容,并希望对参数的改变传递给上一层,catch 参数就需要用引用类型传递。
如下代码:

#include <iostream>
using namespace std;


int func2(int a, int b)
{
    try
    {
        if (b == 0)
            throw logic_error("func2:The denominator cannot be 0!");
    }
    catch(std::exception& e)
    {
        std::cerr << e.what() << '\n';
        throw;
    }
    
    cout << "fun2:" << __LINE__ << endl;
    return a / b;
}

int func1(int a, int b)
{
    try
    {
        func2(a, b);
    }
    catch(const std::logic_error& e)
    {
        std::cerr << "func1:std::logic_error:" << e.what() << '\n';
    }
    cout << "fun1:" << __LINE__ << endl;
    return 0;
}

int main()
{
    int a = 0;
    int b = 0;
    std::cout << "place input two number with a space:";
    std::cin >> a >> b;
    std::cout << func1(1, 0) << endl;
    cout << "main exit!" << endl;
    return 0;
}

捕获所有异常对象代码

有时候需要无论抛出什么类型的异常,程序都能统一捕获。而不需要直到异常的类型是什么,可以使用省略号作为异常声明 catch(...),这样的处理代码成为捕获所有异常,它与任意类型异常匹配。
catch(...) 通常与重新抛出一起使用,既能单独出现(必须在最后的位置),也能和其他几个 catch 一起出现。

void func()
{
	try
    {
        //引发抛出异常
    }
    catch (...)
    {
        //处理某些异常操作
        throw;
    }
}

函数 try 语句块与构造函数

异常可能发生在构造函数中,构造函数首先会执行初始化列表,但执行初始化列表时抛出的异常,try 块还未生效,所以构造函数体内的 catch 语句无法处理初始化列表发生的异常。
如果要处理初始化列表抛出的异常,必须将构造函数写成函数 try 语句块的形式。如下 Blob 的构造函数:

template <typename T>
Blob<T>::Blob(std::initializer_list<T> il) try :
	data(std::make_shared<std::vector<T>>(il))
{
    //空函数体
}
catch (const std::bad_alloc& e)
{
	handle_out_of_memory(e);
}

注意:try 出现在表示构造函数初始值列表的冒号以及表示构造函数体的花括号之前。
如上的 try 语句使得一组 catch 语句能够处理构造函数体(或析构函数体),也能处理构造函数的初始化过程(或析构函数的析构过程)。catch 语句技能处理构造函数抛出的异常,也能处理成员初始化列表抛出的异常。
如果是构造函数的参数初始化时发生了异常,这样的异常不属于 try 语句块的。try 语句块只处理构造函数开始执行后发生的异常,这种异常需要在调用者所在的上下文中处理。

noexcept 异常说明

如果我们知道某个函数不会抛出异常,有助于简化调用该函数的代码,编译器也可以执行某些特殊的优化操作。
C++11 中通过 noexcept 说明执行某个函数不会抛出异常。如下:

void function1(int arg) noexcept;    //不会抛出异常
void function2(int arg);				//可能会抛异常

从函数的角度来说,noexcept 说明应该遵从如下规则:

  1. 要么出现在该函数的所有声明语句和定义中,要么一次也不出现。
  2. 应该在函数的尾置返回类型之前。
  3. 也可以在函数指针的声明和定义中指定 noexcept。
  4. typedef 或类型别名中则不能出现 noexcept。
  5. 成员函数中,noexcept 说明符需要跟在 const 及引用限定符之后,而在 final、override 或虚函数的 =0 之前。

违反异常说明

若一个函数已经说明了 noexcept 同时又含有 throw 语句或者调用了可能抛出异常的其他函数,编译器可以正常通过,因为编译器不能也不必在编译期验证异常说明,比如:

void f() noexcept	//承若不会抛出异常
{
	throw exception();	//违反异常说明
}

一旦一个noexcept 函数抛出了异常,程序就会调用 terminate 以确保遵守不在运行时抛出异常的承诺。
因此 noexcept 异常说明可以用在两种情况:

  1. 确认函数不会抛出异常
  2. 根本不知道如何处理异常

异常说明的实参

noexcept 说明符接受一个可选的实参,该实参必须能转换为 bool 类型:如果实参为 true,则函数不会抛出异常;如果为 false,则函数可能抛出异常。比如:

void recoup(int) noexcept(true);
void alloc(int) noexcept(false);

noexcept 运算符

noexcept 运算符是一个一元运算符,它的返回值是一个 bool 类型的右值常量表达式,用于表示给定的表达式是否会抛出异常。noexcept 也不会求其运算对象的值。
比如声明 recoup 时使用了 noexcept 说明符,则下面的表达式返回为 true :

noexcept(recoup(i))

更普通的形式:

noexcept(e)

当 e 调用的所有函数都做了不抛出说明且 e 本身不含有 throw 语句时,上述表达式为 true,否则 noexcept(e) 返回 false。
可以的到如下的异常说明:

void f() noexcept(noexcept(g()));	//f和g的异常说明一致

如果函数 g 承诺不跑出异常,则 f 也不抛出异常。如果 g 没有异常说明符,或者有异常说明符但允许抛出异常,则 f 也可能抛出异常。

异常说明与指针、虚函数、拷贝控制

虽然 noexcept 部署与函数类型的一部分,但是还是会影响函数的使用。

函数指针

如果为某个函数指针做出了不抛出异常的声明,则该指针只能指向不抛出异常的函数。
相反,如果我们显式地说明了指针可能抛出的异常,则该指针可以指向任何函数,即使承诺了不跑出异常的函数亦可以:

void (*pf1) (int) noexcept = recoup;	//recoup 和 pf1 都承若不跑出异常
void (*pf2) (int) = recoup;		//正确 recoup 不会抛出异常,pf2可能抛出异常,二者互不干扰
pf1 = alloc;		//错误:alloc可能抛出异常
pf2 = alloc;		//正确:pf2和alloc都可能抛出异常
虚函数

如果一个虚函数承诺不会抛出异常,则后续派生出来的虚函数也必须做出同样的承诺。
反之,如果基类的虚函数允许抛出异常,则派生类的对应函数既可以允许抛出异常,也可以不允许抛出异常。

class Base
{
public:
	virtual double f1(double) noexcept;	//不会抛出异常
	virtual int f2() noexcept(false);	//可能抛出异常
	virtual voie f3();					//可能会抛出异常
};

class Derived : public Base
{
public:
	double f1(double);			//错误
	int f2() noexcept(false);	//正确
	void f3() noexcept;			//正确
}
拷贝控制

编译器在合成拷贝控制成员时,也生成一个异常说明。如果对所有成员和基类的所有操作都不会抛出异常,则合成的成员是 noexcept 的。如果合成成员调用的任意一个函数可能抛出异常,则合成的成员是 noexcept(false)。而且,如果我们定义了一个析构函数但是没有为它提供异常说明,则编译器将合成一个。合成的异常说明将与假设由编译器为类合成析构函数时所得的异常说明一致。

异常类层次

C++ 标准库定义了一组类,用于报告标准库函数遇到的问题。分别定义在 4 个头文件中:

  • exception 头文件定义了最通用的异常类 exception。它只报告异常的发生,不提供任何信息。
  • stdexcept 头文件定义了最通用的异常类。
  • new 头文件定义了 bad_alloc 异常类型。
  • type_info 头文件定义了 bad_cast 异常类型。

标准库异常构成了如下的继承体系:
image.png
我们只能用默认初始化的方式初始化 exception、bad_alloc 和 bad_cast 对象,不允许为这些对象提供初始值。
而其他异常类型的行为则恰好相反,应该使用 string 对象或者 C 风格字符串初始化这些类型的对象。
类型 exception 仅仅定义了拷贝构造函数、拷贝赋值、虚析构函数、what 虚成员函数。其中 what 函数返回一个 const char*,该指针指向一个 null 结尾的字符数组,并确保不会抛出任何异常。
继承体系的第二层将 exception 划分为两个大的类别:运行时错误和逻辑错误。它们都是继承于 exception,同时我们可以自定义类型继承于运行时类或逻辑错误类,层次越低表示异常情况就越特殊。我们完全可以使用自己定义的异常类型,其使用与标准异常类的方式完全一样。
例如:

SaleData& SaleDate::operator+=(const SaleData& rhs)
{
	if (isbn() != rhs.isbn())
    	throw isbn_mismatch("wrong isbns", isbn(), rns.isbn());
    units_sold += rhs.units_sold;
    revenue += rhs.revenue;
    return *this;
}
//...
SaleData item1, item2, sum;
while (cin >> item1 >> item2)
{
	try {
    	sum = item1 + item2;
        //此处使用sum
    } catch {
        cerr << e.what() << ": left isbn (" << e.left << ") right isbn (" << e.right << ")" << endl;
    }
}
  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

code_peak

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值