【导读】:我们在对 vector 做 push 操作的时候,或者对某个指针做 new 操作的时候,如果没有做异常处理,一旦系统内存不够用了,程序是会被 terminate 掉的。这就要求我们熟悉 C++ 异常,保证日常开发中能正确处理它。本文主要介绍C++ 异常机制的底层原理与实际应用,通俗易懂,快来读一读吧。 以下是正文 C++异常机制概述异常处理是C++的一项语言机制,用于在程序中处理异常事件。异常事件在 C++ 中表示为异常对象。 异常事件发生时,程序使用throw关键字抛出异常表达式,抛出点称为异常出现点,由操作系统为程序设置当前异常对象,然后执行程序的当前异常处理代码块,在包含了异常出现点的最内层的 try 块,依次匹配catch语句中的异常对象(只进行类型匹配,catch参数有时在 catch 语句中并不会使用到)。若匹配成功,则执行 catch 块内的异常处理语句,然后接着执行 try...catch... 块之后的代码。如果在当前的 try...catch... 块内找不到匹配该异常对象的catch语句,则由更外层的 try...catch... 块来处理该异常;如果当前函数内所有的 try...catch... 块都不能匹配该异常,则递归回退到调用栈的上一层去处理该异常。如果一直退到主函数 main() 都不能处理该异常,则调用系统函数 terminate() 终止程序。 一个最简单的 try...catch... 的例子如下所示。我们有个程序用来记班级学生考试成绩,考试成绩分数的范围在 0-100 之间,不在此范围内视为数据异常:
throw 关键字 在上面这个示例中,throw 是个关键字,与抛出表达式构成了 throw 语句。其语法为:
throw 语句必须包含在 try 块中,也可以是被包含在调用栈的外层函数的 try 块中,如:
执行 throw 语句时,throw 表达式将作为对象被复制构造为一个新的对象,称为异常对象。异常对象放在内存的特殊位置,该位置既不是栈也不是堆,在 window 上是放在线程信息块 TIB 中。这个构造出来的新对象与本级的 try 所对应的 catch 语句进行类型匹配,类型匹配的原则在下面介绍。 当执行一个 throw 语句时,跟在 throw 语句之后的语句将不再被执行,throw 语句的语法有点类似于 return,因此导致在调用栈上的函数可能提早退出。 异常对象 异常对象是一种特殊的对象,编译器依据异常抛出表达式复制构造异常对象,这要求抛出异常表达式不能是一个不完全类型(一个类型在声明之后定义之前为一个不完全类型。不完全类型意味着该类型没有完整的数据与操作描述),而且可以进行复制构造,这就要求异常抛出表达式的复制构造函数(或移动构造函数)、析构函数不能是私有的。 异常对象不同于函数的局部对象,局部对象在函数调用结束后就被自动销毁,而异常对象将驻留在所有可能被激活的 catch 语句都能访问到的内存空间中,也即上文所说的 TIB。当异常对象与 catch 语句成功匹配上后,在该 catch 语句的结束处被自动析构。在函数中返回局部变量的引用或指针几乎肯定会造成错误,同样的道理,在 throw 语句中抛出局部变量的指针或引用也几乎是错误的行为。如果指针所指向的变量在执行 catch 语句时已经被销毁,对指针进行解引用将发生意想不到的后果。throw 出一个表达式时,该表达式的静态编译类型将决定异常对象的类型。所以当 throw 出的是基类指针的解引用,而该指针所指向的实际对象是派生类对象,此时将发生派生类对象切割。除了抛出用户自定义的类型外,C++ 标准库定义了一组类,用户报告标准库函数遇到的问题。这些标准库异常类只定义了几种运算,包括创建或拷贝异常类型对象,以及为异常类型的对象赋值。[td]
catch语句匹配被抛出的异常对象。如果 catch 语句的参数是引用类型,则该参数可直接作用于异常对象,即参数的改变也会改变异常对象,而且在 catch 中重新抛出异常时会继续传递这种改变。如果 catch 参数是传值的,则复制构函数将依据异常对象来构造catch 参数对象。在该 catch 语句结束的时候,先析构 catch 参数对象,然后再析构异常对象。 在进行异常对象的匹配时,编译器不会做任何的隐式类型转换或类型提升。除了以下几种情况外,异常对象的类型必须与 catch 语句的声明类型完全匹配:
使用 catch(...){} 可以捕获所有类型的异常,根据最先匹配原则,catch(...){} 应该放在所有 catch 语句的最后面,否则无法让其他可以精确匹配的 catch 语句得到匹配。通常在catch(...){} 语句中执行当前可以做的处理,然后再重新抛出异常。注意,catch 中重新抛出的异常只能被外层的 catch 语句捕获。 栈展开、RAII 其实栈展开已经在前面说过,就是从异常抛出点一路向外层函数寻找匹配的 catch 语句的过程,寻找结束于某个匹配的 catch 语句或标准库函数 terminate。这里重点要说的是栈展开过程中对局部变量的销毁问题。我们知道,在函数调用结束时,函数的局部变量会被系统自动销毁,类似的,throw 可能会导致调用链上的语句块提前退出,此时,语句块中的局部变量将按照构成生成顺序的逆序,依次调用析构函数进行对象的销毁。例如下面这个例子:
程序将输出:
程序运行结果: RAII机制有助于解决这个问题,RAII(Resource acquisition is initialization,资源获取即初始化)。它的思想是以对象管理资源。为了更为方便、鲁棒地释放已获取的资源,避免资源死锁,一个办法是把资源数据用对象封装起来。程序发生异常,执行栈展开时,封装了资源的对象会被自动调用其析构函数以释放资源。C++ 中的智能指针便符合RAII。关于这个问题详细可以看《Effective C++》条款13.异常机制与构造函数 异常机制的一个合理的使用是在构造函数中。构造函数没有返回值,所以应该使用异常机制来报告发生的问题。更重要的是,构造函数抛出异常表明构造函数还没有执行完,其对应的析构函数不会自动被调用,因此析构函数应该先析构所有所有已初始化的基对象,成员对象,再抛出异常。 C++ 类构造函数初始化列表的异常机制,称为 function-try block。一般形式为:
C++ 不禁止析构函数向外界抛出异常,但析构函数被期望不向外界函数抛出异常。析构函数中向函数外抛出异常,将直接调用 terminator() 系统函数终止程序。如果一个析构函数内部抛出了异常,就应该在析构函数的内部捕获并处理该异常,不能让异常被抛出析构函数之外。可以如此处理:
noexcept 修饰符是 C++11 新提供的异常说明符,用于声明一个函数不会抛出异常。编译器能够针对不抛出异常的函数进行优化,另一个显而易见的好处是你明确了某个函数不会抛出异常,别人调用你的函数时就知道不用针对这个函数进行异常捕获。在 C++98中关于异常处理的程序中你可能会看到这样的代码:
这是 throw 作为函数异常说明,前者表示 func()这个函数可能会抛出 int 或 double 类型的异常,后者表示 func() 函数不会抛出异常。事实上前者很少被使用,在 C++11 这种做法已经被摒弃,而后者则被 C++11 的 noexcept 异常声明所代替:
上面一点提到了,我们不能让异常逃出析构函数,因为那将导致程序的不明确行为或直接终止程序。实际上出于安全的考虑,C++11 标准中让类的析构函数默认也是 noexcept 的。同样是为了安全性的考虑,经常被析构函数用于释放资源的 delete 函数,C++11 也默认将其设置为 noexcept。 noexcept也可以接受一个常量表达式作为参数,例如:
常量表达式的结果会被转换成 bool 类型,noexcept(bool) 表示函数不会抛出异常,noexcept(false) 则表示函数有可能会抛出异常。故若你想更改析构函数默认的 noexcept声明,可以显式地加上 noexcept(false) 声明,但这并不会带给你什么好处。 异常处理的性能分析 异常处理机制的主要环节是运行期类型检查。当抛出一个异常时,必须确定异常是不是从 try 块中抛出。异常处理机制为了完善异常和它的处理器之间的匹配,需要存储每个异常对象的类型信息以及 catch 语句的额外信息。由于异常对象可以是任何类型(如用户自定义类型),并且也可以是多态的,获取其动态类型必须要使用运行时类型检查(RTTI),此外还需要运行期代码信息和关于每个函数的结构。 当异常抛出点所在函数无法解决异常时,异常对象沿着调用链被传递出去,程序的控制权也发生了转移。转移的过程中为了将异常对象的信息携带到程序执行处(如对异常对象的复制构造或者 catch 参数的析构),在时间和空间上都要付出一定的代价,本身也有不安全性,特别是异常对象是个复杂的类的时候。 异常处理技术在不同平台以及编译器下的实现方式都不同,但都会给程序增加额外的负担,当异常处理被关闭时,额外的数据结构、查找表、一些附加的代码都不会被生成,正是因为如此,对于明确不抛出异常的函数,我们需要使用 noexcept 进行声明。 关于C++异常机制,欢迎在评论中和我探讨。 |
【银杏科技ARM+FPGA双核心应用】STM32H7系列35——USB_VCP_FS
【银杏科技ARM+FPGA双核心应用】STM32H7系列28——USB_HID
粉丝分享 | 图说CRC原理应用及STM32硬件CRC外设
【银杏科技ARM+FPGA双核心应用】STM32H7系列56——CAN
【银杏科技ARM+FPGA双核心应用】STM32H7系列25——IWDG
如何在 Vitis 中使用 UIO 驱动框架创建简单的 Linux 用户应用
STM32 IAP应用中的几个常见问题
TFT LCD 控制接口FSMC/LTDC/DSI 应用文档
介绍FreeRTOS基础及其应用
【银杏科技ARM+FPGA双核心应用】STM32H750福利