🍒异常处理
2022-6-14
| 2023-8-2
0  |  阅读时长 0 分钟
type
status
date
slug
summary
tags
category
icon
password
Property
 

 
异常(exception)是指程序运行时的反常行为,这些行为超出了函数正常功能的范围。当程序的某一部分检测到一个它无法处理的问题时,需要使用 异常处理
异常处理机制为程序中异常检测异常处理这两部分的协作提供支持,包括throw表达式try语句块异常类
  • 异常检测部分使用 throw 表达式表示它遇到了无法处理的问题(throw引发了异常)
  • 异常处理部分使用 try语句块处理异常。try语句块以关键字 try开始,并以一个或多个catch子句结束。try语句块中代码抛出的异常通常会被某个 catch子句处理,catch子句也被称作异常处理代码
  • try语句块中代码抛出的异常通常会被某个catch子句处理,catch子句也被称作异常处理代码
 

throw表达式

throw表达式包含关键字 throw和紧随其后的一个表达式,表达式的类型就是抛出的异常类型。throw表达式后面通常紧跟一个分号,从而构成一条表达式语句:
throw被执行时,throw之后的语句是不会执行的。相反,控制(control)将从throw转移到对应的catch处。catch子句可能在同一个函数中,也可能在直接或间接调用了发生异常的函数的函数中。控制从一个地方转到另一个地方的事实有两个重要的暗示:
  • 调用链上的所有函数调用将永久退出
  • 当进入一个处理器时,调用链上创建的对象将被销毁
如果一条throw表达式解引用一个基类指针,而这个指针指向于派生类对象,则抛出的对象被切掉的一部分是基类部分中的
 
throw指定异常
函数可以在函数体的参数列表圆括号后加上throw限制,用来说明函数可以抛出什么异常。
建议函数的声明、定义都写上,可以在函数指针的声明和定义中指定throwthrow异常说明应该出现在函数的尾指返回类型之前。
在类成员函数中,应该出现在const以及引用限定符之后,而在finaloverride、虚函数=0之前
即使函数指定了throw异常说明,但是函数体内如果还是抛出异常,或是抛出与throw异常说明中不对应的异常,程序不会报错。编译器在编译时不会检查throw异常说明,尽管说明了,但抛出了还是不会出错
 

try、catch语句块

try语句块的通用形式:
try语句块中的 program-statements组成程序的正常逻辑,其内部声明的变量在块外无法访问,即使在 catch子句中也不行。语句块之后是 catch子句,catch子句包含:关键字 catch、括号内一个对象的声明(异常声明)和一个块。当选中了某个 catch子句处理异常后,执行与之对应的块。catch一旦完成,程序会跳过剩余的所有 catch子句,继续执行后面的语句。
寻找处理代码的过程与函数调用链刚好相反。当异常被抛出时, 首先搜索抛出该异常的函数。如果没找到匹配的 catch子句, 终止该函数, 并在调用该函数的函数中继续寻找。如果还是没有找到匹配的 catch子句,这个新的函数也被终止, 继续搜索调用它的函数。以此类推,沿着程序的执行路径逐层回退,直至找到适当类型的 catch子句为止。如果最终没能找到与异常相匹配的 catch子句,程序会执行名为 terminate的标准库函数。该函数的行为与系统有关,一般情况下,执行该函数将导致程序非正常退出。类似地,如果一段程序没有 try语句块且发生了异常,系统也会调用 terminate函数并终止当前程序的执行。
  • trycatch都不可以省去花括号,尽管后面只有一条语句也不能省去
  • trycatch组合中,try最多只有一个,catch可以有多个
  • 嵌套:trycatch语句块中都可以再嵌套trycatch语句块组合
  • try中使用throw抛出一个异常时,跳转到参数类型与throw后面表达式类型相对应的catch语句块中,throw后面的语句将不再执行

catch子句

catch的参数

catch子句中的异常声明非常类似于只有一个参数的函数参数列表。当catch不需要访问抛出的异常对象时,异常声明中的名字可以省略。
异常声明中的类型决定了可以处理的异常,这个类型必须是完全类型,可以是左值引用但不能是右值引用。catch子句非常类似于函数体。当进入catch子句时,异常声明中的参数将被初始化为异常对象,与函数参数一样,如果catch参数是非引用类型,那么 catch 参数是异常对象的拷贝;在catch中对参数做的任何改变都是针对本地拷贝而与异常对象本身没有任何关系。如果参数是引用类型,那么与任何别的引用参数是一样的,catch参数是异常对象的另一个名字。对参数做的任何改变都会反映到异常对象上。
与函数参数一样,如果catch参数是基类类型,其可以被初始化为子类类型对象。如果catch参数是非引用类型,那么异常对象将被裁剪,如果参数是基类类型的引用的话,那么参数将被绑定到异常对象上。
同样,异常声明的静态类型决定了catch可以执行的操作,如果catch参数是基类类型,那么catch就不能执行派生类类型的任何操作。
最佳实践:一个catch如果是处理通过继承关联起来的类型的异常对象时,应该将其声明为引用。
 

catch的匹配

在查找匹配的catch时,找到的catch不需要是最匹配异常的那个,而是第一个匹配异常的那个。因而,在一串catch子句中,最具体的catch子句应该第一个出现。
若多个catch与句之间存在着继承关系,则:继承链最低端的类放在前面,继承链最顶端的类放在后面
 
异常匹配的规则比之函数参数匹配更加严格,绝大多数时候catch声明的异常类型必须与异常对象的类型完全一致,只有在极少数的情况下两者之间可以有差异:
  • 从非constconst的转换是允许的,意味着抛出一个非const对象可以匹配一个声明为捕获const引用的catch子句
  • 从派生类到基类的转换是允许的
  • 数组可以转为其元素类型的指针;函数可以转为函数类型的指针
其它的任何转型都是不允许的,特别指出的是不允许标准算术转型和类类型定义的转型。
 

重新抛出(Rethrow)

有时,一条单独的catch语句不能完整地处理某个异常,会将传递的异常继续传递给外层trycatch组合或者上一层的函数处理。
重新抛出形如:throw; 就是throw后不跟随任何对象。空的throw只能出现在catch子句中,或者由catch调用的函数。如果一个空throw出现在非catch中,terminate将被调用。
如果catch参数是引用类型,在catch语句中改变参数值,下一条catch接受的是改变后的参数:
 
 

捕获所有异常

可以通过catch(...)的方式来捕获所有的异常,这个称为catch-all处理器,这种处理器通常与重新抛出结合在一起,其做完任何可以做的本地工作然后重新抛出异常。如果将 catch(...) 与其它catch子句使用时应该放在最后的位置,其后的任何catch子句都不会被匹配到。
捕获所有异常通常与重新抛出配合使用,但不是必须
 
 
 
 
 

标准异常

C++标准库定义了一组类,用于标准库函数遇到的问题。这些异常类可以被使用者调用。
异常类分别定义在4个头文件中:
  • 头文件 exception定义了最通用的异常类 exception。它只报告异常的发生,不提供任何额外信息
  • 头文件 stdexcept 定义了几种常用的异常类
    • notion image
  • 头文件new定义了bad_alloc异常类(当动态分配内存,内存不足时,抛出这个异常)
  • 头文件type_info定义了ban_cast异常类、bad_typeid异常类(当遇到NULL对象时,会抛出这个异常)
 
上面的所有异常类,都有一个共同的成员函数what(); (无参数,返回值为类初始化时传入的const char*类型的字符串,代表错误的信息,该函数一定不会抛出异常)
 

各个类之间的继承体系

  • exception仅仅定义了拷贝构造函数、拷贝赋值运算符、一个虚析构函数、一个虚函数what()
  • exception第2层又将异常类分为:运行时错误和逻辑错误
exceptionbad_allocbad_cast对象只能使用默认初始化,不能提供初始化值;其他异常类型创建时必须提供初始化值。值的类型为const char*类型或者string类型。
 
  • 当一个一个catch的参数为exception类型时,这个catch语句块捕获的异常类型是基类型exception以及所有从exception派生的类型(后者是因为派生类可以向基类转换)
  • 使用runtime_error异常类,抛出一个异常类对象
 

继承标准异常实现自己的异常类型

通过继承某一异常类,并实现基类的相关函数,也可以自己新增函数,自己定义的异常类使用方式和标准异常类的使用方式完全一样
 
 
 

noexcept 异常说明符(C++11

对于用户以及编译器来说,预先知道函数不会抛出异常有助于简化调用该函数的代码。如果编译器确认该函数不会抛出异常,就能执行某些特殊的优化操作,而这些优化操作不适用于可能出错的代码。
在新标准中,函数可以通过noexcept说明,在函数参数列表后放置的noexcept关键字表示函数不会抛出异常:
  • 函数的声明和定义都加上关键字noexcept
  • 可以在函数指针的声明和定义中指定noexcept
  • throw异常说明应该出现在函数的尾指返回类型之前
  • typedef或类型别名中不能出现noexcept
  • 在类成员函数中,应该出现在const以及引用限定符之后,而在finaloverride虚函数=0之前
 

违反异常说明

编译器在编译时并不会检查函数是否有noexcept说明。如果一个函数定义了关键字noexcept,但是该函数在运行时仍然可以抛出异常或者调用可能抛出异常的其它函数:
noexcept只是用来说明函数不会抛出异常,但是函数是否会抛出异常与noexcept无关;如果函数抛出了异常,但是程序没有对异常进行处理,则程序就会调用terminate中断程序
 
 

向后兼容

早期版本的C++的异常说明更加复杂,允许指定一个函数可能抛出的异常,但是现在几乎是没有什么人使用这种方式了,并且被废弃了。但是有一个方式是经常使用的就是:throw() 来表明函数不抛出任何异常:
 

noexcept说明的实参

noexcept可以有一个可选的实参,必须是可以转换为布尔值的,如果为false的话就表示可能会抛出异常,true则不会抛出:
 

noexcept运算符

noexcept说明的实参经常是由noexcept操作符求值所得,noexcept是一元操作符,返回的bool型的右值常量表达式,其求值发生在编译期所以不会对表达式求值,而是进行编译推导。这与sizeof是一样的:
fun函数不会抛出异常,所以返回true
 
noexcept一个小功能:可以将两个函数的异常说明规定为相同的格式
 

noexcept异常说明与指针

尽管noexcept不属于函数类型的一部分,但是仍影响函数的使用。规则如下:
  • 如果为某个函数指针做了不抛出异常的说明,则该指针只能指向不抛出异常的函数
  • 相反,如果显示或隐式说明了指针可能会抛出异常,则该指针可以指向任何函数,即使承诺不会抛出异常的函数也可以
 

noexcept异常说明与虚函数

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

noexcept异常说明与拷贝控制

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

构造函数的异常处理

在进入构造函数的函数体之前,要先执行初始化列表。但是如果trycatch语句块放在构造函数体内,初始化列表如果出现异常,函数体内的try语句块还未生效,所以无法捕获异常。为了解决这种情况,必须将构造函数写成函数try语句块,也称为函数测试体。
函数try语句块既能处理初始化列表,也能处理构造函数体。
 
try跟在构造函数的值初始化列表的冒号之前,catch跟在构造函数后
值得注意的是出现在构造函数参数本身时发生的异常不会被函数级try语句块捕获,只有开始构造函数的初始化列表后的异常才能被捕获。捕获这种异常的职责是调用表达式的,需要有调用者来处理。
注:书写函数级try块是处理构造函数初始化列表中抛出异常的唯一方法。
 
 

栈展开

当异常抛出时,当前函数的执行被中止并开始搜索匹配的catch子句。如果 throw 出现在一个 try 块中,那么与之相对应的 catch 子句将首先被检查,如果找到了匹配的 catch 子句,异常就被此 catch 所处理。否则,如果 try 被嵌套在另外一个 try 中,那么将继续搜索外层的 catch 子句。如果没有任何 catch 匹配此异常,那么当前函数将退出,并且继续搜索发起调用的函数。这样一直向上,称为栈展开,直到找到一个匹配异常的 catch 子句,或者在没有找到任何匹配的 catch 子句时 main 函数自己退出。
假如找到了一个匹配的 catch 子句,将执行 catch 中的代码,当其完成后,将执行其后的第一条非 catch 子句代码。如果没有找到任何匹配的 catch 子句,程序将退出。异常是必须处理的,因为异常的目的就是阻止程序继续按常规执行,如果不处理异常则程序会隐式调用 terminate 库函数来终止程序的执行。
没有被捕获的异常将终止程序的执行。
 

栈展开时对象将自动被销毁

语句块在结束之后,块内的局部对象会自动销毁。栈展开中也是如此,如果栈展开中退出了某个块,代表该块生命周期已经结束,语句块中的局部对象也会被销毁(自动调用析构函数)
 
在栈展开时,调用链中的语句块将会永久退出,通常语句块中将创建本地对象,而本地对象则在语句块退出时销毁。栈展开执行相同的逻辑:当一个语句块在栈展开时退出,编译器保证其中创建的对象被适当的销毁。如果本地对象是类类型,对象的析构函数将执行,如果是内置类型,那么将不执行任何操作直接销毁。
异常可能发生在构造函数中,那么对象可能处于部分构建状态。其中一些成员已经被初始化了,但是另外一些成员在异常发生时还没有初始化。即便处于部分构建状态,编译器将保证已经构建的成员将被销毁。
同样,异常可能发生在数组或容器元素的初始化过程中,编译器将保证在异常发生前构建的元素将被销毁。
 

析构函数和异常

栈展开过程中对象会自动调用析构函数销毁,析构函数中不可以再放置try语句块,很危险。原因:若析构函数中放置try语句块,其后面释放资源等操作可能就不会执行,后果很危险。
析构函数总是执行,而函数中释放资源的代码可能会被跳过影响着我们如何组织程序。如果一个代码块分配了资源,但是异常发生在释放资源的代码之前,那么释放资源的代码将不会执行。另一方面,由类类型对象分配的资源肯定会被析构函数释放。通过使用类来控制资源的分配,我们可以保证资源总是被合理的释放,而不管函数是正常结束还是由异常导致结束。
栈展开时将执行析构函数影响着我们如何写析构函数。在栈展开时,异常已经引发但是还没有被处理。如果栈展开过程中又抛出一个新的异常,而没有在抛出的函数中捕获的话就会调用 terminate 函数。由于析构函数会在栈展开中调用,析构函数不应该抛出任何它自己不处理的异常。也意味着如果析构函数调用了可能抛出异常的函数,需要将其放在 try 块中,并将异常处理掉。
在现实中,由于析构函数只释放资源,它不太可能会抛出异常。所有的标准库中的类型都保证其析构函数不会抛出异常。
 

不可抛出局部对象的指针

退出了某个块,则同时释放该块中局部对象使用的内存。如果抛出了一个局部对象的指针,则在执行相对应的catch语句块之前,该对象已经被销毁了。因此,抛出一个指向局部对象的指针是错误的。(原理类似于函数不能返回一个局部对象的指针)
 

异常对象

编译器使用抛出表达式来拷贝复制一个特殊对象称为异常对象。所以抛出的对象必须是完全类型,如果对象是类类型,那么其必须具有可访问的析构函数和可访问的拷贝或移动构造函数。如果对象是数组或者函数类型,那么其将被转型为对应的指针类型。
异常对象驻留于编译器管理内存空间中,当任何catch子句被调用时,这个异常对象就会被访问,这个异常对象将在异常被处理之后被销毁。抛出本地对象的指针是错误的用法,因为在栈展开时本地对象会被销毁。
 

栈展开过程中的内存泄漏

若一个指针对象在释放之前抛出异常,则会造成内存泄漏:
解决办法:在异常发生的时候,自动释放其内存。可以使用智能指针,并传入删除的lambda表达式
 
  • C++
  • 特殊类设计函数模板
    目录