C++常用异常处理以及原理解析

C++常用异常处理以及原理解析

C++中异常机制的实现机制详解

函数的调用和返回

要理解C++异常机制实现之前,首先要了解一个函数的调用和返回机制,这里面就要涉及到ESP和EBP寄存器。我们先看一下函数调用和返回的流程:

下面是按调用约定__stdcall 调用函数test(int p1,int p2)的汇编代码  
假设执行函数前堆栈指针ESP为NN  
push p2 ;参数2入栈, ESP -= 4h , ESP = NN - 4h  
push p1 ;参数1入栈, ESP -= 4h , ESP = NN - 8h  
call test ;压入返回地址 ESP -= 4h, ESP = NN - 0Ch  
{  
push ebp ;保护先前EBP指针, EBP入栈, ESP-=4h, ESP = NN - 10h  
mov ebp, esp ;设置EBP指针指向栈顶 NN-10h  
mov eax, dword ptr [ebp+0ch] ;ebp+0ch为NN-4h,即参数2的位置  
mov ebx, dword ptr [ebp+08h] ;ebp+08h为NN-8h,即参数1的位置  
sub esp, 8 ;局部变量所占空间ESP-=8, ESP = NN-18h  
...  
add esp, 8 ;释放局部变量, ESP+=8, ESP = NN-10h  
pop ebp ;出栈,恢复EBP, ESP+=4, ESP = NN-0Ch  
ret 8 ;ret返回,弹出返回地址,ESP+=4, ESP=NN-08h, 后面加操作数8为平衡堆栈,ESP+=8,ESP=NN, 恢复进入函数前的堆栈.  
}  

 

函数栈架构主要承载着以下几个部分:

 

 

 

① 传递参数:通常,函数的调用参数总是在这个函数栈框架的最顶端;

② 传递返回地址:告诉被调用者的 return 语句应该 return 到哪里去,通常指向该函数调用的下一条语句(代码段中的偏移);

③ 存放调用者的当前栈指针:便于清理被调用者的所有局部变量、并恢复调用者的现场:

④ 存放当前函数内的所有局部变量:记得吗?刚才说过所有局部和临时变量都是存储在栈上的。

下面我们来看个具体例子:

假设有 FuncA、FuncB 和 FuncC 三个函数,每个函数均接收两个整形值作为其参数。在某线程上的某一时间段内,FuncA 调用了 FuncB,而 FuncB 又调用了 FuncC。则,它们的栈框架看起来应该像这样:

 

正如上图所示的那样,随着函数被逐级调用,编译器会为每一个函数建立自己的栈框架,栈空间逐渐消耗。随着函数的逐级返回,该函数的栈框架也将被逐级销毁,栈空间得以逐步释放。顺便说一句,递归函数的嵌套调用深度通常也是取决于运行时栈空间的剩余尺寸。

C++ 函数的调用和返回

首先澄清一点,这里说的 “C++ 函数”是指:

① 该函数可能会直接或间接地抛出一个异常:即该函数的定义存放在一个 C++ 编译(而不是传统 C)单元内,并且该函数没有使用“throw()”异常过滤器;

② 或者该函数的定义内使用了 try 块。

以上两者满足其一即可。为了能够成功地捕获异常和正确地完成栈回退(stack unwind),编译器必须要引入一些额外的数据结构和相应的处理机制。我们首先来看看引入了异常处理机制的栈框架大概是什么样子:

 

由图2可见,在每个 C++ 函数的栈框架中都多了一些东西。仔细观察的话,你会发现,多出来的东西正好是一个 EXP 类型的结构体。进一步分析就会发现,这是一个典型的单向链表式结构:

① piPrev 成员指向链表的上一个节点,它主要用于在函数调用栈中逐级向上寻找匹配的 catch 块,并完成栈回退工作;

② piHandler 成员指向完成异常捕获和栈回退所必须的数据结构(主要是两张记载着关键数据的表:“try”块表:tblTryBlocks 及“栈回退表”:tblUnwind);

③ nStep 成员用来定位 try 块,以及在栈回退表中寻找正确的入口。

我们可能有几个疑问:

① piPrev 成员是什么意思?

#include <iostream>  
using namespace std;  
  
void FuncA()  
{  
    int i = 1;  
    if (i == 1)  
    {  
        throw - 1;  
    }  
}  
  
void FuncB()  
{  
    try  
    {  
        FuncA();  
    }  
    catch (int var)  
    {  
        cout << "捕获了FuncA抛出的异常" << endl;  
    }  
}  
  
int main()  
{  
    FuncB();  
}  

 

看上述程序,我们可以知道,FuncA(被FuncB函数调用)抛出异常后,由于没有对应的catch块去捕获或者说在FuncA函数中根本没有catch块去捕获相应的异常,因此编译器需要去上一级函数(也就是调用该函数的父类函数)去寻找到底有没有相应的catch块去处理相应的异常,但是到最后结束的时候有两种结果:我们可以通过FuncA函数本身的catch块去捕获处理相应的异常/可以逐级向上查询(在调用自己的函数中查询)处理相应异常的catch块;该有一种是“尝试过了所有的查找路线,但是最终没有找到一个处理该异常的catch块”。最后一种方法对于我们来说是危险的,如下所示:

#include <iostream>  
using namespace std;  
  
void FuncA()  
{  
    int i = 1;  
    if (i == 1)  
    {  
        throw - 1;  
    }  
}  
  
void FuncB()  
{  
    try  
    {  
        FuncA();  
    }  
    catch (double var)  
    {  
        cout << "捕获了FuncA抛出的异常" << endl;  
    }  
}  
  
int main()  
{  
    FuncB();  
}  

 

注意:我把上述捕获异常的catch的捕获目标改成了double类型的,我们抛出的int型的异常显然没有任何一个catch块可以捕获处理。运行结果如下所示:

 

这样的话,如果有多个可能引发异常的位置我们根本不知道那里引发的异常,进而无从下手去解决异常。

② piHandler 成员和nStep 成员如何来定位异常捕获异常?

这个问题就请看我们的“栈回退(Stack Unwind)机制”。

栈回退(Stack Unwind)机制(针对于不带异常的函数)

“栈回退”是伴随异常处理机制引入 C++ 中的一个新概念,主要用来确保在异常被抛出、捕获并处理后,所有生命期已结束的对象都会被正确地析构,它们所占用的空间会被正确地回收。

受益于栈回退机制的引入,以及 C++ 类所支持的“资源申请即初始化”语意,使得我们终于能够彻底告别既不优雅也不安全的 setjmp/longjmp 调用,简便又安全地实现远程跳转了。我想这也是 C++ 异常处理机制在错误处理以外唯一一种合理的应用方式了。

下面我们就来具体看看编译器是如何实现栈回退机制的:

 

图3中的“FuncUnWind”函数内,所有真实代码均以黑色和蓝色字体标示,编译器生成的代码则由灰色和橙色字体标明。此时,在图2里给出的 nStep 变量和 tblUnwind 成员作用就十分明显了。

nStep 变量用于跟踪函数内局部对象的构造、析构阶段。再配合编译器为每个函数生成的 tblUnwind 表,就可以完成退栈机制。表中的 pfnDestroyer 字段记录了对应阶段应当执行的析构操作(析构函数指针);pObj 字段则记录了与之相对应的对象 this 指针偏移。将 pObj 所指的偏移值加上当前栈框架基址(EBP),就是要代入 pfnDestroyer 所指析构函数的 this 指针,这样即可完成对该对象的析构工作。而 nNextIdx 字段则指向下一个需要析构对象所在的行(下标)。

在发生异常时,异常处理器首先检查当前函数栈框架内的 nStep 值,并通过 piHandler 取得 tblUnwind[] 表。然后将 nStep 作为下标带入表中,执行该行定义的析构操作,然后转向由 nNextIdx 指向的下一行,直到 nNextIdx 为 -1 为止。在当前函数的栈回退工作结束后,异常处理器可沿当前函数栈框架内 piPrev 的值回溯到异常处理链中的上一节点重复上述操作,直到所有回退工作完成为止。

值得一提的是,nStep 的值完全在编译时决定,运行时仅需执行若干次简单的整形立即数赋值(通常是直接赋值给CPU里的某个寄存器)。此外,对于所有内部类型以及使用了默认构造、析构方法(并且它的所有成员和基类也使用了默认方法)的类型,其创建和销毁均不影响 nStep 的值。

注意:如果在栈回退的过程中,由于析构函数的调用而再次引发了异常(异常中的异常),则被认为是一次异常处理机制的严重失败。此时进程将被强行禁止。为防止出现这种情况,应在所有可能抛出异常的析构函数中使用“std::uncaught_exception()”方法判断当前是否正在进行栈回退(即:存在一个未捕获或未完全处理完毕的异常)。如是,则应抑制异常的再次抛出。

异常捕获机制(针对于带有异常处理的函数)

一个异常被抛出时,就会立即引发 C++ 的异常捕获机制:

 

注意:虽然在图4示例中的 tblTryBlocks[] 只有一个条目,这个条目中的 tblCatchBlocks[] 也只有一行。但是在实际情况中,这两个表中都允许有多条记录。意即:一个函数中可以有多个 try 块,每个 try 块后均可跟随多个与之配套的 catch 块。

在上一小节中,我们已经看到了 nStep 变量在跟踪对象构造、析构方面的作用。实际上 nStep 除了能够跟踪对象创建、销毁阶段以外,还能够标识当前执行点是否在 try 块中,以及(如果当前函数有多个 try 块的话)究竟在哪个 try 块中。这是通过在每一个 try 块的入口和出口各为 nStep 赋予一个唯一 ID 值,并确保 nStep 在对应 try 块内的变化恰在此范围之内来实现的。

在具体实现异常捕获时,首先,C++ 异常处理器检查发生异常的位置是否在当前函数的某个 try 块之内。这项工作可以通过将当前函数的 nStep 值依次在 piHandler 指向 tblTryBlocks[] 表的条目中进行范围为 [nBeginStep, nEndStep) 的比对来完成。

例如:若图4 中的 FuncB 在 nStep == 2 时发生了异常,则通过比对 FuncB 的 tblTryBlocks[] 表发现 2∈[1, 3),故该异常发生在 FuncB 内的第一个 try 块中。

其次,如果异常发生的位置在当前函数中的某个 try 块内,则尝试匹配该 tblTryBlocks[] 相应条目中的 tblCatchBlocks[] 表。tblCatchBlocks[] 表中记录了与指定 try 块配套出现的所有 catch 块相关信息,包括这个 catch 块所能捕获的异常类型及其起始地址等信息。

若找到了一个匹配的 catch 块,则复制当前异常对象到此 catch 块,然后跳转到其入口地址执行块内代码。

否则,则说明异常发生位置不在当前函数的 try 块内,或者这个 try 块中没有与当前异常相匹配的 catch 块,此时则沿着函数栈框架中 piPrev 所指地址(即:异常处理链中的上一个节点)逐级重复以上过程,直至找到一个匹配的 catch 块或到达异常处理链的首节点。对于后者,我们称为发生了未捕获的异常,对于 C++ 异常处理器而言,未捕获的异常是一个严重错误,将导致当前进程被强制结束(前面我们已举例说明)。

异常的抛出

接下来讨论整个 C++ 异常处理机制中的最后一个环节,异常的抛出:

 

在编译一段 C++ 代码时,编译器会将所有 throw 语句替换为其 C++ 运行时库中的某一指定函数,这里我们叫它 __CxxRTThrowExp(与本文提到的所有其它数据结构和属性名一样,在实际应用中它可以是任意名称)。该函数接收一个编译器认可的内部结构(我们叫它 EXCEPTION 结构)。这个结构中包含了待抛出异常对象的起始地址、用于销毁它的析构函数,以及它的 type_info 信息。

在图中的深灰色框图内,我们使用 C++ 伪代码展示了函数 FuncA 中的 “throw myExp(1);” 语句将被编译器最终翻译成的样子。实际上在多数情况下,__CxxRTThrowExp 函数即我们前面曾多次提到的“异常处理器”,异常捕获和栈回退等各项重要工作都由它来完成。

__CxxRTThrowExp 首先接收(并保存)EXCEPTION 对象;然后从 TLS:Current ExpHdl 处找到与当前函数对应的 piHandler、nStep 等异常处理相关数据;并按照前文所述的机制完成异常捕获和栈回退。由此完成了包括“抛出”->“捕获”->“回退”等步骤的整套异常处理机制。

C++ 异常的详细介绍

虽然函数也可以通过if判断结合“返回值或者传引用的参数“通知调用者发生了异常,但采用这种方式的话,每次调用函数时都要判断是否发生了异常,这在函数被多处调用时比较麻烦。有了异常处理机制,可以将多处函数调用都写在一个 try 块中,任何一处调用发生异常都会被匹配的 catch 块捕获并处理,也就不需要每次调用后都判断是否发生了异常。

程序有时会遇到运行阶段错误,导致程序无法正常执行下去。c++异常为处理这种情况提供了一种功能强大的而灵活的工具。异常是相对比较新的C++功能,有些老编译器可能没有实现。另外,有些编译器默认关闭这种特性,我们可能需要使用编译器选项来启用它。

注意:C++异常处理并不是阻止程序运行,而是异常发生了,C++编译器告知你异常发生在哪里并且转入相应的异常处理中去。

异常机制的使用

异常提供了将控制程序的一个部分传递到另一部分的途径。对异常的处理有3个组成部分:

① 在函数中使用throw抛出相应异常;

② 确保异常抛出位置在try块内;

③ 只有在try块内抛出的异常才可以使用try块相应的catch块捕获异常;

举例说明:

① 代码要求:求解double类型a和b的实数域中的几何平均数(Geometric mean)和调和平均数(Harmonic mean);

② 公式展示:

调和平均数:

 

注:禁止分母为0;

几何平均数:

 

注意:禁止被开方数小于0。

② 代码示例:

#include <iostream>  
#include <math.h>  
using namespace std;  
  
double hmean(double a, double b)  
{  
    if (a == -b)  
    {     
        throw "bad hmean() arguments a= -b not allowed";  
    }  
    return 2.0*a*b / (a + b);  
}  
  
double gmean(double a, double b)  
{  
    if (a*b < 0)  
    {  
        throw "bad gmean() arguments ab < 0 not allowed";  
    }  
    return sqrt(a*b);  
}  
  
int main()  
{  
    double a = 1.0, b = -1.0;  
    double h_result = 0, g_result = 0;  
    try  
    {  
        h_result = hmean(a, b);  
        g_result = gmean(a, b);  
    }  
    catch (const char* exp)  
    {  
        cout << exp << endl;  
    }  
    cout << a << " and " << b << "'s 几何平均数(Geometric mean) is " << g_result << endl;  
    cout << a << " and " << b << "'s 调和平均数(Harmonic mean) is " << h_result << endl;  
} 

 

运行结果如下:

 

由于我用的是VS2017,这个编译器中会在运行阶段对程序进行检查(RTTI: Run Time Type Identification),因此编译器会在运行阶段识别到异常并且触发异常处理(异常处理说白了就是提醒我们到底哪里出错了并且终止程序,并不是给我们修复异常)。

异常触发三大件:

① 可以抛出异常的函数:

double hmean(double a, double b)  
{  
    if (a == -b)  
    {     
        throw "bad hmean() arguments a = -b not allowed";  // 其实throw抛出的是"bad hmean() arguments a = -b not allowed"字符串的首地址,相当于抛出的是字符串常量
    }  
    return 2.0*a*b / (a + b);  
}  
  
double gmean(double a, double b)  
{  
    if (a*b < 0)  
    {  
        throw "bad gmean() arguments ab < 0 not allowed";  
    }  
    return sqrt(a*b);  
}  

 

执行throw语句类似于执行返回语句,因为他也将终止函数的执行;但throw不是讲控制权返回给调用程序,而是导致程序沿函数调用序列后退,直到找到包含try块的函数。

② 这些可以抛出异常的函数必须在try块内进行执行并且抛出异常:

try  
{  
   h_result = hmean(a, b);  
   g_result = gmean(a, b);  
}  

 

注意:我们不可以写成如下形式:

try  
{  
   double h_result = hmean(a, b);  
   double g_result = gmean(a, b);  
}  

 

在try块内定义我们下一步要使用的两个变量是不行的,也就是说如果函数运行正常函数的返回值接收变量的作用域不可以被限制在try块内,因为我们下一步还要用这两个参数输出相应的结果:

cout << a << " and " << b << "'s 几何平均数(Geometric mean) is " << g_result << endl;  
cout << a << " and " << b << "'s 调和平均数(Harmonic mean) is " << h_result << endl;  

 

如果我们将上面两个接收函数返回值的变量定义在try块内,由于{}(语句块)的特性,这两个变量在try块外是无法使用的,这就导致了我们的程序只能判断一场无法进行无异常时的下一步操作。

③ 有相应的捕获被抛出异常的catch块:

catch (const char* exp)  // 捕获字符串的首地址
{  
   cout << exp << endl;  
}  

 

总结异常触发的次序:

① 函数抛出异常;

② 编译器从函数抛出异常处向栈的顶部查找直到栈顶(函数所在作用域的开始位置)看看这个函数在没在try块内;

③ 如果在try块内根据我们第一部分介绍的C++异常机制的实现原理我们可以知道,编译器下一步就要去寻找该try块所属的所有catch块,并且匹配异常入口,进而引发异常处理程序。

能够捕获任何异常的 catch 语句

如果希望不论拋出哪种类型的异常都能捕获,可以编写如下 catch 块:

catch(...) {  
    异常处理程序  
}  

 

这样的 catch 块能够捕获任何还没有被捕获的异常。例如下面的程序:

#include <iostream>  
using namespace std;  
int main()  
{  
    double m, n;  
    cin >> m >> n;  
    try {  
        cout << "before dividing." << endl;  
        if (n == 0)  
            throw - 1;  //抛出整型异常  
        else if (m == 0)  
            throw - 1.0;  //拋出 double 型异常  
        else  
            cout << m / n << endl;  
        cout << "after dividing." << endl;  
    }  
    catch (double d) {  
        cout << "catch (double)" << d << endl;  
    }  
    catch (...) {  
        cout << "catch (...)" << endl;  
    }  
    cout << "finished" << endl;  
    return 0;  
}  

 

运行结果:

 

当 m 为 0 时,拋出一个 double 类型的异常。虽然catch (double)和catch(...)都能匹配该异常,但是catch(double)是第一个能匹配的 catch 块,因此会执行它,而不会执行catch(...)块。

注意:由于catch(...)能匹配任何类型的异常,它后面的 catch 块实际上就不起作用,因此不要将它写在其他 catch 块前面。

异常的再拋出

如果一个函数在执行过程中拋出的异常在本函数内就被 catch 块捕获并处理,那么该异常就不会拋给这个函数的调用者(也称为“上一层的函数”):

#include <iostream>  
#include <string>  
using namespace std;  
  
double Divide(double a, double b)  
{  
    try  
    {  
        if (b == 0)  
        {  
            throw string("Divisor b is zero!");  
        }  
        return a / b;  
    }  
    catch (const string exp)  
    {  
        cout << exp << endl;  
    }  
}  
  
int main()  
{  
    double Dividend = 9.0, Divisor = 0.0, Result = 0;  
    Result = Divide(Dividend, Divisor);  
    cout << "Division Result = " << Result << endl;  
}  

 

输出结果:

 

我们看到:

Divide 函数拋出异常后自行处理,这个异常就不会继续被拋给调用者( main 函数)。因此在 main 函数的 try 块中,调用Divide函数之后的语句还能正常执行,即会执行Result= Devide(9.0, 0.0)。

如果异常在本函数中没有被处理,则它就会被拋给上一层的函数。有时,虽然在函数中对异常进行了处理,但是还是希望能够通知调用者并且在main函数中阻止异常程序继续运行,以便让调用者知道发生了异常,从而可以作进一步的处理。在 catch 块中拋出异常可以满足这种需要:

#include <iostream>  
#include <string>  
using namespace std;  
  
double Divide(double a, double b)  
{  
    try  
    {  
        if (b == 0)  
        {  
            throw string("Divisor b is zero!");  
        }  
        return a / b;  
    }  
    catch (const string exp)  
    {  
        cout << "Division error: ";  
        throw; // 重抛出异常  
    }  
}  
  
int main()  
{  
    double Dividend = 9.0, Divisor = 0.0, Result = 0;  
    try  
    {  
        Result = Divide(Dividend, Divisor); // Result一定要在try块外部定义,以便不会随着try块的结束而被销毁  
    }  
    catch (const string& exp)  
    {  
        cout << exp << endl;  
    }  
    cout << "Division Result = " << Result << endl; // Result被定义在try块之外的目的就在于可以无异常时(try块顺利执行完成后)正常使用  
}  

 

运行结果:

 

将对象用作异常类型

通常,引发异常的函数将传递一个对象。这样做的重要优点之一是,可以使用不同的异常类型来区分不同的函数在不同情况下引发的异常。另外,对象可以携带信息,程序员可以根据这些信息来确定引发异常的原因。同时,catch块可以根据这些信息来决定采取什么样的措施。

举例说明:

① 代码要求:求解double类型a和b的实数域中的几何平均数(Geometric mean)和调和平均数(Harmonic mean);

② 公式展示:

调和平均数:

 

注:禁止分母为0;

几何平均数:

 

注意:禁止被开方数小于0。

② 代码示例:

Bad_gmean.hpp:

#include <iostream>  
using namespace std;  
  
class bad_gmean  
{  
private:  
    double a;  
    double b;  
public:  
    bad_gmean(double var1 = 0, double var2 = 0) :a(var1), b(var2) {};  
    void mesg() const;  
};  
  
inline void bad_gmean::mesg() const  
{  
    cout << "Since " << this->a*this->b << " < 0 , " << this->a << " and " << this->b << " are invalid arguments !" << endl;  
}  

 

Bad_hmean.hpp:

#include <iostream>  
using namespace std;  
  
class bad_hmean  
{  
private:  
    double a;  
    double b;  
public:  
    bad_hmean(double var1 = 0, double var2 = 0) :a(var1), b(var2) {};  
    void mesg() const;  
};  
  
inline void bad_hmean::mesg() const  
{  
    cout << "Since " << this->a << " == " << -this->b << " , " << this->a << " and " << this->b << " are invalid arguments !" << endl;  
} 

​​​​​​​ 

Main.cpp:

#include "bad_gmean.hpp"  
#include "bad_hmean.hpp"  
#include <iostream>  
using namespace std;  
  
double hmean(double a, double b)  
{  
    if (a == -b)  
    {  
        throw bad_hmean(a, b);  
    }  
    return 2.0*a*b / (a + b);  
}  
  
double gmean(double a, double b)  
{  
    if (a*b < 0)  
    {  
        throw bad_gmean(a, b);  
    }  
    return sqrt(a*b);  
}  
  
int main()  
{  
    double a = 1.0, b = -2.0;  
    double h_result = 0, g_result = 0;  
    try  
    {  
        h_result = hmean(a, b);  
        g_result = gmean(a, b);  
    }  
    catch (bad_gmean const& exp)  
    {  
        exp.mesg();  
    }  
    catch (bad_hmean const& exp)  
    {  
        exp.mesg();  
    }  
    cout << a << " and " << b << "'s 几何平均数(Geometric mean) is " << g_result << endl;  
    cout << a << " and " << b << "'s 调和平均数(Harmonic mean) is " << h_result << endl;  
}  

 

输出结果:

 

C++中的内联函数与普通函数的区别(针对于类的成员函数)

普通函数运行的过程如下:

 

内联函数的运行过程:

 

对应较短的类类型的成员函数还是将其声明为inline内联函数为好,但是若成员函数较长,即使你将成员函数声明为inline,编译器也不会去执行,因为编译器(编译->优化->执行)在优化过程中会直接pass掉这种将较长程序声明为inline的傻瓜操作。

异常规范

① C++03 异常处理(throw)

C++98中,在函数声明时,我们使用throw指定一个函数可以抛出异常的类型。例如:

class Ex {  
public:  
  double getVal();  
  void display() throw();  
  void setVal(int i) throw (char*, double);  
 private:  
   int m_val;  
};  

 

上述函数的声明指定了该函数可以抛出异常的类型:

  1. getVal() 可以抛出任何异常(默认);
  2. display() 不可以抛出任何异常;
  3. setVal() 只可以抛出char* 和 double类型异常。

② C++11异常处理(noexcept)

编译器在编译时能过做的检测非常有限,因此在C++11中异常声明被简化为以下两种情况:
1)函数可以抛出任何异常(和之前的默认情况相同)
2)函数不可以抛出任何异常。

在C++11中,声明一个函数不可以抛出任何异常使用关键字noexcept.

void mightThrow(); // 可以抛出任何一个异常  
void doesNotThrow() noexcept; // 不能抛出任何一个异常

下面两个函数声明的异常规格在语义上是相同的,都表示函数不抛出任何异常:

void old_stytle() throw();  
void new_style() noexcept;  

 

它们的区别在于程序运行时的行为和编译器优化的结果:

使用throw(), 如果函数抛出异常,异常处理机制会进行栈回退,寻找(一个或多个)catch语句。此时,检测catch可以捕捉的类型,如果没有匹配的类型,std::unexpected()会被调用。

但是std::unexpected()本身也可能抛出异常。如果std::unexpected()抛出的异常对于当前的异常规格是有效的,异常传递和栈回退会像以前那样继续进行。

这意味着,如果使用throw, 编译器几乎没有机会做优化。

触发异常后,编译器的操作流程:

(1)栈必须被保存在回退表中;

(2)所有对象的析构函数必须被正确的调用(按照对象构建相反的顺序析构对象)。

事实上,编译器甚至会让代码变得更臃肿、庞大(可能触发的不正常情况):

(1)编译器可能引入新的传播栅栏(propagation barriers)、引入新的异常表入口,使得异常处理的代码变得更庞大;

(2)内联函数的异常规格(exception specification)可能无效的。

当使用noexcept时,std::teminate()函数会被立即调用,而不是调用std::unexpected();因此,在异常处理的过程中,编译器不会回退栈,这为编译器的优化提供了更大的空间。简而言之,如果你知道你的函数绝对不会抛出任何异常,应该使用noexcept, 而不是throw()。

③ 有条件的noexcecpt

Void Func() noexcept(常量表达式):

如果常量表达式的结果为true,表示该函数不会抛出异常,反之则有可能抛出异常。不带常量表达式的noexcept相当于noexcept(true),相当于告诉编译器“你按照这个函数不会产生异常对其进行优化即可“。

单独使用noexcept,表示其所限定的swap函数绝对不发生异常。然而,使用方式可以更加灵活,表明在一定条件下不发生异常:

void swap(Type& x, Type& y) noexcept(noexcept(x.swap(y)))    //C++11  
{  
    x.swap(y);  
}  

 

它表示,如果操作x.swap(y)不发生异常,那么函数swap(Type& x, Type& y)一定不发生异常。

一个更好的示例是std::pair中的移动分配函数(move assignment),它表明,如果类型T1和T2的移动分配(move assign)过程中不发生异常,那么该移动构造函数就不会发生异常:

pair& operator=(pair&& __p)  
noexcept(__and_<is_nothrow_move_assignable<_T1>,  
                is_nothrow_move_assignable<_T2>>::value)  
{  
    first = std::forward<first_type>(__p.first);  
    second = std::forward<second_type>(__p.second);  
    return *this;  
}  

 

⑤ 什么时候该使用noexcept?

使用noexcept表明函数或操作不会发生异常,会给编译器更大的优化空间。然而,并不是加上noexcept就能提高效率,步子迈大了也容易倒行逆施。

以下情形鼓励使用noexcept:

  1. 移动构造函数(move constructor);
  2. 移动分配函数(move assignment);
  3. 析构函数(destructor)。这里提一句,在新版本的编译器中,析构函数是默认加上关键字noexcept的。

注意:noexcept(……)和throw(……)不能起到实际的约束效果,但是他们可以起到注释的效果,指定可以触发的异常类型,如果我们触发的异常不在异常列表中,编译器就将该异常作为“意外异常“进行处理。

C++异常处理注意事项

异常(exception)是C++语言引入的错误处理机制。它 采用了统一的方式对程序的运行时错误进行处理,具有标准化、安全和高效的特点。C++为了实现异常处理,引入了三个关键字:try、throw、catch。异常由throw抛出,格式为throw[expression],由catch捕捉。Try语句块是可能抛出异常的语句块,它通常和一个或多个catch语句块连续出现。

try语句块和catch语句块必须相互配合,以下三种情况都会导致编译错误:

 (1)只有try语句块而没有catch语句块,或者只有catch语句块而没有try语句块;

 (2)在try语句块和catch语句块之间夹杂有其他语句;

 (3)当try语句块后跟有多个catch语句块时,catch语句块之间夹杂有其他语句;

 (4)同一种数据类型的传值catch分支与传引用catch分支不能同时出现。

C++标准异常类

C++ 标准库中有一些类代表异常,这些类都是从 exception 类派生而来的。Exception基类结构如下:

 

自定义类的基类:exception类的结构如下:

class exception {  
public:  
  exception() throw();  
  exception(const exception& rhs) throw();  
  exception& operator=(const exception& rhs) throw();  
  virtual ~exception() throw();  
  virtual const char *what() const throw();  
};  

 

exception的直接派生类:

异常名称

说  明

logic_error

逻辑错误。

runtime_error

运行时错误。

bad_alloc

使用new或new[ ]分配内存失败时抛出的异常。

bad_typeid

使用typeid操作一个 NULL 指针,而且该指针是带有虚函数的类,这时抛出 bad_typeid 异常。

bad_cast

使用 dynamic_cast 转换失败时抛出的异常。

ios_base::failure

io 过程中出现的异常。

bad_exception

这是个特殊的异常,如果函数的异常列表里声明了 bad_exception 异常,当函数内部抛出了异常列表中没有的异常时,如果调用的 unexpected() 函数中抛出了异常,不论什么类型,都会被替换为 bad_exception 类型。

logic_erro派生的异常类:

异常名称

说  明

length_error

试图生成一个超出该类型最大长度的对象时抛出该异常,例如 vector 的 resize 操作。

domain_error

参数的值域错误,主要用在数学函数中,例如使用一个负值调用只能操作非负数的函数。

out_of_range

超出有效范围。

invalid_argument

参数不合适。在标准库中,当利用string对象构造 bitset 时,而 string 中的字符不是 0 或1 的时候,抛出该异常。

runtime_error派生的异常:

异常名称

说  明

range_error

计算结果超出了有意义的值域范围。

overflow_error

算术计算上溢。

underflow_error

算术计算下溢。

 

bad_typeid

使用 typeid 运算符时,如果其操作数是一个多态类的指针,而该指针的值为 NULL,则会拋出此异常。

代码示例:

#include <iostream>  
using namespace std;  
#include <exception>  
#include <string>  
  
class Person  
{  
private:  
    string name;  
    int age;  
public:  
    Person(string namev = "无", int agev = 0) :name(namev), age(agev) {};  
    virtual void ShowInf() {  
        cout << this->name << "的年龄为" << this->age << endl;  
    }  
};  
  
int main()  
{  
    Person* Person_Obj1 = NULL;  
    try  
    {  
        cout << typeid(*Person_Obj1).name() << endl;  
    }  
    catch (bad_typeid& exp)  
    {  
        cout << "typeid操作指针为空" << endl;  
    }  
}  

 

运行结果:

 

由于Person类中含有virtual关键字,即Person类类型中含有虚函数表,因此Person类指针是一个多态类的指针。

bad_cast

在用 dynamic_cast 进行从多态基类对象(或引用)到派生类的引用的强制类型转换时,如果转换是不安全的,则会拋出此异常。程序示例如下:

#include <iostream>  
#include <exception>  
#include <string>  
using namespace std;  
  
class Person  
{  
private:  
    string name;  
    int age;  
public:  
    Person(string namev = "无", int agev = 0) :name(namev), age(agev) {};  
    virtual void ShowInf() {  
        cout << this->name << "的年龄为" << this->age << endl;  
    }  
};  
  
class Student : public Person  
{  
private:  
    int studnumber;  
public:  
    Student(int studnumber, string name, int age) :Person(name, age)  
    {  
        this->studnumber = studnumber;  
    }  
    Student(Student& obj) {  
        this->studnumber = obj.studnumber;  
    }  
    void ShowInf() {  
        cout << "学生学号:" << this->studnumber << endl;  
    }  
};  
  
int main()  
{  
    Person Person_Obj1("张三", 19);  
    try  
    {  
        Student Student_Obj1 = dynamic_cast<Student&>(Person_Obj1);  
    }  
    catch (bad_cast& exp)  
    {  
        cout << "基类强制转换为派生类不可行!" << endl;  
    }  
} 

​​​​​​​ 

bad_alloc

在用 new 运算符进行动态内存分配时,如果没有足够的内存,则会引发此异常。程序示例如下:

#include <iostream>  
#include <stdexcept>  
using namespace std;  
int main()  
{  
    try {  
        char * p = new char[0x7fffffff];  //无法分配这么多空间,会抛出异常  
    }  
    catch (bad_alloc & e)  {  
        cerr << e.what() << endl;  
    }  
    return 0;  
}  

 

程序的输出结果如下:

bad allocation

ios_base::failure

out_of_range

用 vector 或 string 的 at 成员函数根据下标访问元素时,如果下标越界,则会拋出此异常。例如:

#include <iostream>  
#include <stdexcept>  
#include <vector>  
#include <string>  
using namespace std;  
int main()  
{  
    vector<int> v(10);  
    try {  
        v.at(100) = 100;  //拋出 out_of_range 异常  
    }  
    catch (out_of_range & e) {  
        cerr << e.what() << endl;  
    }  
    string s = "hello";  
    try {  
        char c = s.at(100);  //拋出 out_of_range 异常  
    }  
    catch (out_of_range & e) {  
        cerr << e.what() << endl;  
    }  
    return 0;  
}  

 

运行结果:

 

Length_error

这个异常的含义不是访问的数组序号超出数组的长度,而是vector等数组申请的总长度大于系统可以提供的内存长度。

代码示例:

// length_error example  
#include <iostream>       // std::cerr  
#include <stdexcept>      // std::length_error  
#include <vector>         // std::vector  
  
int main(void) {  
    try {  
        // vector throws a length_error if resized above max_size  
        std::vector<int> myvector;  
        myvector.resize(myvector.max_size() + 1);  
    }  
    catch (const std::length_error& le) {  
        std::cerr << "Length error: " << le.what() << '\n';  
    }  
    return 0;  
}  

 

运行结果:

 

invalid_argument

invalid_argument顾名思义指无效参数,这个应该应用在检查参数是否是无效的,一般检查参数用于特定的函数以及类,那么就应该是给类的成员变量赋值或者函数参数赋值时,检查其赋给它们的值是否有效,例如有一个类(people,有三个成员变量name,age,height)那么我们知道人的年龄在0~150岁之间。身高的话0~300cm,名字的长度不会超过20。如果都超过这些范围,就可以认定是无效数据。那么这个类可以如下定义:

#include <exception>  
#include <iostream>  
#include <string>  
using namespace std;  
  
class Person  
{  
private:  
    int age;  
    string name;  
    double height;  
public:  
    Person(int age, string name, double height)  
    {  
        this->age = age;  
        this->name = name;  
        this->height = height;  
        valid(); // 数据有效性判断  
    }  
    void valid()  
    {  
        if (!((age > 0 && age < 150) && (name.size() < 20) && (height > 0 && height < 30)))  
        {  
            throw invalid_argument("输入参数无效!");  // 抛出无效参数异常
        }  
    }  
};  
  
int main()  
{  
    try  
    {  
        Person Person_Obj1(900, "张三", 301);  
    }  
    catch (const invalid_argument& exp)  // 捕获无效参数异常
    {  
        cout << exp.what() << endl;  
    }  
}  

 

输出结果:

 

将exception类作为基类的自定义异常类类型

例1:自定义一个继承自excepton的异常类myException

代码示例:

#include <iostream>  
using namespace std;  
#include <exception>  
#include <string>  
  
class MyException : public exception  
{  
private:  
    string content;  
public:  
    MyException(string exp) :content(exp) {};  
    // 注意:const char* what() const是what的正确重载形式,千万不要忘了用const限定this指针  
    virtual const char* what() const   
    {  
        return (this->content).c_str();  
    }  
};  
  
double Divide(double dividend, double divisor)  
{  
    if (divisor == 0)  
    {  
        throw MyException("Divisor equals 0!");  
    }  
    return dividend / divisor;  
}  
  
int main()  
{  
    double a = 1, b = 0, Result = 0;  
    try  
    {  
        Result = Divide(a, b);  
    }  
    catch (const exception& exp)  
    {  
        cout << exp.what() << endl;  
    }  
}  

 

运行结果:

 

例2:当抛出异常不在异常列表之中,则抛出terminate异常

#include <iostream>  
#include <exception>  
using namespace std;  
  
class MyException : public exception  
{  
private:  
    string content;  
public:  
    MyException(string exp) :content(exp) {};  
    // 注意:const char* what() const是what的正确重载形式,千万不要忘了用const限定this指针  
    virtual const char* what() const  
    {  
        return (this->content).c_str();  
    }  
};  
  
void MyTerminateException()  
{  
    cout << "Terminate exception caught!\n";  
    system("pause");  
    exit(-1);  
};  
  
double Divide(double dividend, double divisor) noexcept // 不可以抛出异常  
{  
    if (divisor == 0)  
    {  
        throw MyException("Divisor equals 0!");  
    }  
    return dividend / divisor;  
}  
  
int main()  
{  
    double a = 1, b = 0, Result = 0;  
    terminate_handler Terminate_Handler = set_terminate(MyTerminateException);  
    try  
    {  
        Result = Divide(a, b);  
    }  
    catch (const exception& exp)  
    {  
        cout << exp.what() << endl;  
    }  
}  

 

运行结果:

 

注意:terminate异常触发时,catch(…)是不能捕获的,因此我们要定义terminate异常句柄。

如果对于函数体throw列表合法的异常被抛出,但是却没有被程序捕捉处理,系统将调用terminate handler进行处理。缺省情况下,只是简单调用abort()函数终止程序。

Abort()函数简介:

void abort (void)  // abort函数原型

 

作用:中止当前进程,导致程序异常终止。

代码示例:

/* abort example */  
#include <stdio.h>      /* fopen, fputs, fclose, stderr */  
#include <stdlib.h>     /* abort, NULL */  
  
int main ()  
{  
  FILE * pFile;  
  pFile= fopen ("myfile.txt","r");  
  if (pFile == NULL)  
  {  
    fputs ("error opening file\n",stderr);  
    abort();  
  }  
  
  /* regular process here */  
  
  fclose (pFile);  
  return 0;  
}  

 

被抛出异常类对象的生命周期

代码示例:

#include <iostream>  
#include <string>  
using namespace std;  
  
class Exception  
{  
private:  
    string content;  
public:  
    Exception(string exp = "无") :content(exp) {  
        cout << "构造类对象" << endl;  
    };  
    void error_message() {  
        cout << this->content << endl;  
    }  
    ~Exception() {  
        cout << "析构类对象" << endl;  
    }  
};  
  
void ShowInf()  
{  
    if (1)  
    {  
        throw Exception("出现异常");  
    }  
}  
  
int main()  
{  
    try  
    {  
        ShowInf();  
    }  
    catch (const Exception& exp)  
    {  
        exp.error_message();  
    }  
}  

 

运行结果:

 

我们看到被抛出的异常类对象Exception("出现异常")的传递流程如下:

抛出Exception("出现异常")匿名类对象->被catch捕获->运行至catch块结束时被析构。

注意:当你使用如下代码时你会发现,必须添加异常类的移动构造函数和拷贝构造函数:

#include <iostream>  
#include <string>  
using namespace std;  
  
class Exception  
{  
private:  
    string content;  
public:  
    Exception(string exp = "无") :content(exp) {  
        cout << "构造类对象" << endl;  
    };  
    Exception(Exception& obj) {  
        cout << "调用拷贝构造函数" << endl;  
        this->content = obj.content;  
    }  
    Exception(Exception&& obj) {  
        cout << "调用移动构造函数" << endl;  
        this->content = obj.content;  
    }  
    void error_message() {  
        cout << this->content << endl;  
    }  
    ~Exception() {  
        cout << "析构类对象" << endl;  
    }  
};  
  
void ShowInf()  
{  
    if (1)  
    {  
        throw Exception("出现异常");  
    }  
}  
  
int main()  
{  
    try  
    {  
        ShowInf();  
    }  
    catch ( Exception exp)  
    {  
        exp.error_message();  
    }  
}  

 

与前一个代码的不同在于:

catch捕获的不是Exception自定义异常类的引用,因此被throw抛出的异常类的匿名对象会经过一下流程传递至catch中:

抛出匿名对象->匿名对象通过移动构造函数赋值给临时类对象temp->temp类对象通过拷贝构造函数赋值给catch块的exp类对象。

运行结果:

 

因此,当我们采用catch块捕获非引用异常类对象时,我们要在异常类的实现中加入“拷贝构造函数“和”移动构造函数“。

在大多数情况下,我们采用第一种catch捕获异常的形式:

#include <iostream>  
#include <string>  
using namespace std;  
  
class Exception  
{  
private:  
    string content;  
public:  
    Exception(string exp = "无") :content(exp) {  
        cout << "构造类对象" << endl;  
    };  
    Exception(Exception& obj) {  
        cout << "调用拷贝构造函数" << endl;  
        this->content = obj.content;  
    }  
    Exception(Exception&& obj) {  
        cout << "调用移动构造函数" << endl;  
        this->content = obj.content;  
    }  
    void error_message() const {  
        cout << this->content << endl;  
    }  
    ~Exception() {  
        cout << "析构类对象" << endl;  
    }  
};  
  
void ShowInf()  
{  
    if (1)  
    {  
        throw Exception("出现异常");  
    }  
}  
  
int main()  
{  
    try  
    {  
        ShowInf();  
    }  
    catch (const Exception& exp)  
    {  
        exp.error_message();  
    }  
} 

​​​​​​​ 

采用const Exception& exp作为catch块的入口,这样被抛出的Exception类的匿名对象会通过以下方式传递:

抛出的Exception类的匿名对象->作为右值引用存在,即const Exception& exp 中的exp类对象。(这是const引用的特性)

总结

为什么不建议使用异常?

除非已有的项目或底层库中使用了异常,要不然尽量不要使用异常,虽然提供了方便,但是开销也大。

使用异常的缺点

如果使用异常,光凭查看代码是很难评估程序的控制流:函数返回点可能在你意料之外,这就导致了代码管理和调试的困难。启动异常使得生成的二进制文件体积变大,延长了编译时间,还可能会增加地址空间的压力。

C++没有垃圾回收机制,资源需要自己管理。有了异常非常容易导致内存泄漏、死锁等异常安全问题。 这个需要使用RAII来处理资源的管理问题。学习成本较高 (RAII技术被认为是C++中管理资源的最佳方法,进一步引申,使用RAII技术也可以实现安全、简洁的状态管理,编写出优雅的异常安全的代码) 。

RAII介绍:RAII介绍​​​​​​​

C++标准库的异常体系定义得不好,导致大家各自定义各自的异常体系,非常的混乱。

使用异常处理的优点

传统错误处理技术,检查到一个错误,只会返回退出码或者终止程序等等,我们只知道有错误,但不能更清楚知道是哪种错误。使用异常,把错误和处理分开来,由库函数抛出异常,由调用者捕获这个异常,调用者就可以知道程序函数库调用出现的错误是什么错误,并去处理,而是否终止程序就把握在调用者手里了。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

肥肥胖胖是太阳

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

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

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

打赏作者

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

抵扣说明:

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

余额充值