你的浏览器版本过低,可能导致网站不能正常访问!
为了你能正常使用网站功能,请使用这些浏览器。

介绍C++ 异常机制的底层原理与实际应用

[复制链接]
gaosmile 发布时间:2020-11-19 20:47
【导读】:我们在对 vector 做 push 操作的时候,或者对某个指针做 new 操作的时候,如果没有做异常处理,一旦系统内存不够用了,程序是会被 terminate 掉的。这就要求我们熟悉 C++ 异常,保证日常开发中能正确处理它。本文主要介绍C++ 异常机制的底层原理与实际应用,通俗易懂,快来读一读吧。
* H" v: p9 B5 |' G. t  Z: g/ p" |/ A
以下是正文
C++异常机制概述
- f; I* a; m: H1 `8 r6 U7 M: {
. x! b5 R# `2 C: t
异常处理是C++的一项语言机制,用于在程序中处理异常事件。异常事件在 C++ 中表示为异常对象; B% R+ H. |2 G& r" R
异常事件发生时,程序使用throw关键字抛出异常表达式,抛出点称为异常出现点,由操作系统为程序设置当前异常对象,然后执行程序的当前异常处理代码块,在包含了异常出现点的最内层的 try 块,依次匹配catch语句中的异常对象(只进行类型匹配,catch参数有时在 catch 语句中并不会使用到)。若匹配成功,则执行 catch 块内的异常处理语句,然后接着执行 try...catch... 块之后的代码。如果在当前的 try...catch... 块内找不到匹配该异常对象的catch语句,则由更外层的 try...catch... 块来处理该异常;如果当前函数内所有的 try...catch... 块都不能匹配该异常,则递归回退到调用栈的上一层去处理该异常。如果一直退到主函数 main() 都不能处理该异常,则调用系统函数 terminate() 终止程序。
( E7 Z9 Q: s$ f

0 @$ p5 V" g0 R2 Q$ S+ h. S! e$ B+ l一个最简单的 try...catch... 的例子如下所示。我们有个程序用来记班级学生考试成绩,考试成绩分数的范围在 0-100 之间,不在此范围内视为数据异常:
6 R  E$ T# [0 C: n3 i

  1. ( m9 i- t- B  R/ f0 s, m( j3 X
  2. int main()
    * Z+ r% E* z8 ~( z7 L) K
  3. {
    ) M5 v+ n! H7 }1 i2 S
  4.     int score=0;
    1 j5 I, j  a3 d; b! n3 u! G5 ?
  5.     while (cin >> score)
    # G! N% i* p" N6 ~6 J, t# @
  6.     {4 ]+ K1 ~8 H  q, \/ f4 D
  7.         try
    3 k2 f' b4 k7 M5 G
  8.         {+ E' V2 r' Y7 s& b6 K6 F
  9.             if (score > 100 || score < 0)( @  L& F- e8 a
  10.             {
    0 c9 e) \& L+ |
  11.                 throw score;
    3 y! |3 r# q  ~, Z  x
  12.             }
    2 \' A7 A4 i' _2 i
  13.             //将分数写入文件或进行其他操作
    + F& H. d$ u8 m
  14.         }
    7 A, x; k5 K/ r- n8 [
  15.         catch (int score)
    - c2 J# Z' J- ?* c
  16.         {
    : i, S( i8 u: I& B5 I& z
  17.             cerr << "你输入的分数数值有问题,请重新输入!";( z  I  q# Y; Z$ U
  18.             continue;$ d3 j% W0 ]" E0 U7 A, V, v& s- N, E
  19.         }" V2 a  p9 B$ ~; P, s4 ^
  20.     }8 K; g1 f! T- b+ R' y7 }
  21. }
复制代码

. K( X% U5 @5 E8 @) n
% e) r) W# Z$ p, B2 Xthrow 关键字
8 `$ M& P9 @7 b
在上面这个示例中,throw 是个关键字,与抛出表达式构成了 throw 语句。其语法为:
# V% Z; L# g7 C3 M& a8 g
  • throw 表达式;8 n. l+ e& h" J4 ?2 D7 G! j+ p

* @# R9 Y! A; T" `& j$ jthrow 语句必须包含在 try 块中,也可以是被包含在调用栈的外层函数的 try 块中,如:
9 b+ ]2 L( Q0 l! Z: m+ G. W

  1. 1 b5 X4 P, g4 b7 Y" e1 J$ G# b
  2. //示例代码:throw包含在外层函数的try块中2 T3 i& }! o$ ]  w+ o- o, @- G
  3. void registerScore(int score)
    ! x: z6 u4 ~3 w
  4. {4 i0 G: R  ~) D  `' C: v; `0 r
  5.     if (score > 100 || score < 0)
    ! R. O# T3 ]( X. Z1 Z" z% V
  6.         throw score; //throw语句被包含在外层main的try语句块中$ Y+ {/ l* z# ~, o
  7.     //将分数写入文件或进行其他操作
    ' E) L" ?9 [6 ~" u' c
  8. }
    % h( c1 K7 Z3 |( q$ _$ u6 S- D
  9. int main()9 h+ Q! Q! o/ m" u4 _  @
  10. {" m2 |& D% k. W  x0 g, I6 `; U
  11.     int score=0;
    * n" D5 F7 t7 [& }3 C  c
  12.     while (cin >> score)6 }0 k: n0 T& x5 v$ j7 [, |
  13.     {
    $ H% V( U8 v- V
  14.         try, o$ X7 P6 \1 L2 B' a
  15.         {' ?+ q5 n( L& D
  16.             registerScore(score);/ {! X7 ^3 R& h8 A+ A% R9 x8 _) w
  17.         }
    ' W5 D1 Q! [7 z& l' l
  18.         catch (int score)
    ! N  ^& C$ |9 u' s, Q2 ?
  19.         {
    - V1 X1 W+ k! [5 }% Y& C
  20.             cerr << "你输入的分数数值有问题,请重新输入!";
    0 L. M+ Q4 ?6 R  Z+ f8 G# n
  21.             continue;6 g( t9 k1 N9 y. a
  22.         }
    8 @. u- y2 e8 P' N
  23.     }
    ; h+ E5 @4 E) J4 N9 @6 F  B
  24. }
复制代码

* u4 [  ^9 ?  {4 j$ I2 \7 S! k% I, F0 \7 M1 M$ l' x2 T1 Q
执行 throw 语句时,throw 表达式将作为对象被复制构造为一个新的对象,称为异常对象。异常对象放在内存的特殊位置,该位置既不是栈也不是堆,在 window 上是放在线程信息块 TIB 中。这个构造出来的新对象与本级的 try 所对应的 catch 语句进行类型匹配,类型匹配的原则在下面介绍。
微信图片_20201119204009.png
在本例中,依据 score 构造出来的对象类型为 int,与 catch(int score) 匹配上,程序控制权转交到 catch 的语句块,进行异常处理代码的执行。如果在本函数内与 catch 语句的类型匹配不成功,则在调用栈的外层函数继续匹配,如此递归执行直到匹配上 catch 语句,或者直到 main 函数都没匹配上而调用系统函数 terminate() 终止程序。1 H1 H" S- r, M% p
当执行一个 throw 语句时,跟在 throw 语句之后的语句将不再被执行,throw 语句的语法有点类似于 return,因此导致在调用栈上的函数可能提早退出。* O9 c/ i, t1 ~: I* E7 ^
: @, z3 g/ _1 g) z- j4 W4 Q

0 W! V8 I/ l4 _异常对象
6 B" h/ O5 t9 U& ^7 ?异常对象是一种特殊的对象,编译器依据异常抛出表达式复制构造异常对象,这要求抛出异常表达式不能是一个不完全类型(一个类型在声明之后定义之前为一个不完全类型。不完全类型意味着该类型没有完整的数据与操作描述),而且可以进行复制构造,这就要求异常抛出表达式的复制构造函数(或移动构造函数)、析构函数不能是私有的。- r9 y8 v0 t/ h6 F* [
异常对象不同于函数的局部对象,局部对象在函数调用结束后就被自动销毁,而异常对象将驻留在所有可能被激活的 catch 语句都能访问到的内存空间中,也即上文所说的 TIB。当异常对象与 catch 语句成功匹配上后,在该 catch 语句的结束处被自动析构。在函数中返回局部变量的引用或指针几乎肯定会造成错误,同样的道理,在 throw 语句中抛出局部变量的指针或引用也几乎是错误的行为。如果指针所指向的变量在执行 catch 语句时已经被销毁,对指针进行解引用将发生意想不到的后果。throw 出一个表达式时,该表达式的静态编译类型将决定异常对象的类型。所以当 throw 出的是基类指针的解引用,而该指针所指向的实际对象是派生类对象,此时将发生派生类对象切割。除了抛出用户自定义的类型外,C++ 标准库定义了一组类,用户报告标准库函数遇到的问题。这些标准库异常类只定义了几种运算,包括创建或拷贝异常类型对象,以及为异常类型的对象赋值。[td]
标准异常类
描述
头文件
exception
最通用的异常类,只报告异常的发生而不提供任何额外的信息
exception
runtime_error
只有在运行时才能检测出的错误
stdexcept
rang_error
运行时错误:产生了超出有意义值域范围的结果
stdexcept
overflow_error
运行时错误:计算上溢
stdexcept
underflow_error
运行时错误:计算下溢
stdexcept
logic_error
程序逻辑错误
stdexcept
domain_error
逻辑错误:参数对应的结果值不存在
stdexcept
invalid_argument
逻辑错误:无效参数
stdexcept
length_error
逻辑错误:试图创建一个超出该类型最大长度的对象
stdexcept
out_of_range
逻辑错误:使用一个超出有效范围的值
stdexcept
bad_alloc
内存动态分配错误
new
bad_cast
dynamic_cast类型转换出错
type_info
catch 关键字/ _" w; H9 a$ i
catch语句匹配被抛出的异常对象。如果 catch 语句的参数是引用类型,则该参数可直接作用于异常对象,即参数的改变也会改变异常对象,而且在 catch 中重新抛出异常时会继续传递这种改变。如果 catch 参数是传值的,则复制构函数将依据异常对象来构造catch 参数对象。在该 catch 语句结束的时候,先析构 catch 参数对象,然后再析构异常对象。
/ G- K# D5 {" U. H
在进行异常对象的匹配时,编译器不会做任何的隐式类型转换或类型提升。除了以下几种情况外,异常对象的类型必须与 catch 语句的声明类型完全匹配:
  • 允许从非常量到常量的类型转换。
  • 允许派生类到基类的类型转换。
  • 数组被转换成指向数组(元素)类型的指针。
  • 函数被转换成指向函数类型的指针。

    9 X) A+ g* v' s. k6 U
寻找 catch 语句的过程中,匹配上的未必是类型完全匹配那项,而在是最靠前的第一个匹配上的 catch 语句(我称它为最先匹配原则)。所以,派生类的处理代码 catch 语句应该放在基类的处理 catch 语句之前,否则先匹配上的总是参数类型为基类的 catch 语句,而能够精确匹配的 catch 语句却不能够被匹配上。在 catch 块中,如果在当前函数内无法解决异常,可以继续向外层抛出异常,让外层catch 异常处理块接着处理。此时可以使用不带表达式的 throw 语句将捕获的异常重新抛出:. X- u7 l( _  g
  1. 9 S6 E* S7 }2 y/ e- x% V: M
  2. catch(type x)
    0 e8 |, R7 z  M
  3. {+ z- A2 [6 w7 {, `
  4.     //做了一部分处理2 |# j, G+ u% C1 \" T
  5.     throw;6 _$ Z$ d% d2 Q+ q2 g
  6. }
复制代码
被重新抛出的异常对象为保存在 TIB 中的那个异常对象,与 catch 的参数对象没有关系,若 catch 参数对象是引用类型,可能在 catch 语句内已经对异常对象进行了修改,那么重新抛出的是修改后的异常对象;若catch参数对象是非引用类型,则重新抛出的异常对象并没有受到修改。+ A! ?. d' a( X
使用 catch(...){} 可以捕获所有类型的异常,根据最先匹配原则,catch(...){} 应该放在所有 catch 语句的最后面,否则无法让其他可以精确匹配的 catch 语句得到匹配。通常在catch(...){} 语句中执行当前可以做的处理,然后再重新抛出异常。注意,catch 中重新抛出的异常只能被外层的 catch 语句捕获。# O. f3 Y1 g: [

/ G1 a0 _4 P. @: Y栈展开、RAII
  ~2 }) k8 s" R1 e
其实栈展开已经在前面说过,就是从异常抛出点一路向外层函数寻找匹配的 catch 语句的过程,寻找结束于某个匹配的 catch 语句或标准库函数 terminate。这里重点要说的是栈展开过程中对局部变量的销毁问题。我们知道,在函数调用结束时,函数的局部变量会被系统自动销毁,类似的,throw 可能会导致调用链上的语句块提前退出,此时,语句块中的局部变量将按照构成生成顺序的逆序,依次调用析构函数进行对象的销毁。例如下面这个例子:* M  R8 b7 V. ^9 _& b2 l6 ?7 e

  1. 6 m/ ]* {  b( ^5 [! m3 m
  2. //一个没有任何意义的类
    : H: N; y$ {/ I( F( H# o
  3. class A2 Q, }- I  \/ w1 t& Z5 D
  4. {
    ( \' ^, c7 \& Z
  5. public:0 _9 F' t- j5 A+ r: e2 S" z- X
  6.     A() :a(0){ cout << "A默认构造函数" << endl; }
    ' n0 Z9 D0 n# w" d
  7.     A(const  A& rsh){ cout << "A复制构造函数" << endl; }6 h1 E% y% F; A+ i5 o
  8.     ~A(){ cout << "A析构函数" << endl; }: o$ p# h% }2 s8 o1 _
  9. private:1 d( d6 A# D/ x; o; ]
  10.     int  a;
      Q8 f/ S. Z$ c0 {* c
  11. };
      g7 J& @* K2 s# B2 F" @
  12. int main()
    7 A5 `: X/ y! T! @- ~
  13. {
    ! h0 g, e3 z6 T, A+ l
  14.         try
    ! t( K. w4 ~, ~9 L8 f. y: ?  t
  15.         {
    8 r1 Q# w4 \9 w5 n
  16.             A a ;
    - M7 H& |2 G. U2 Q, C! Q& T
  17.             throw a;9 y* Y/ ]6 T- U$ _) |1 O% y) G0 a2 [
  18.         }
    . h. s( m2 M$ C/ C" _7 m, u
  19.         catch (A a)+ ?/ k# E) K6 \) _: ?1 Y
  20.         {
    ) i9 J9 y. I6 J! R& I% l% E
  21.             ;/ o0 y( i/ b0 ~) C1 N
  22.         }
    ; e( R  `5 m; h7 I- U5 Y" s, D
  23.     return 0;
    ) [6 S1 X7 \/ x
  24. }
复制代码

. V0 z* [# A" {1 u
程序将输出:
微信图片_20201119204012.png
定义变量 a 时调用了默认构造函数,使用 a 初始化异常变量时调用了复制构造函数,使用异常变量复制构造 catch 参数对象时同样调用了复制构造函数。三个构造对应三个析构,也即 try 语句块中局部变量 a 自动被析构了。然而,如果 a 是在自由存储区上分配的内存时:
! e' {) e' J' j6 N0 A
  1. 0 K/ c+ Z/ `6 `8 Y
  2. int main()% {$ a9 Q$ f$ P4 y. T, K; m0 E
  3. {4 x6 m: g! q4 m. Y8 Z0 P4 P; s
  4.     try
    $ @0 {# g: s! n  Y  U
  5.     {; P. n- Z: Y* e5 o! s/ i7 q- F
  6.         A * a= new A;
    5 M1 F3 |: `. A: _& `' E$ ]6 V
  7.         throw *a;
    3 r7 L4 y& v, g3 ^% L
  8.     }
    + c) H( L( Q" U+ Q/ w
  9.     catch (A a)
    & X0 _/ f) K  K5 j/ P  G- ]
  10.     {2 `8 I3 Z  v$ p" `+ W
  11.         ;
    1 A' N6 R" N* ^
  12.     }! k* S+ C. c9 P1 v6 @
  13.     getchar();. m$ c3 ]' Z, J, }( `2 L4 {0 u
  14.     return 0;
    . b3 ^! h* J8 o+ i" O5 u
  15. }
复制代码

' D* a+ Y; B% l
程序运行结果:
微信图片_20201119204015.png
同样的三次构造,却只调用了两次的析构函数!说明 a 的内存在发生异常时并没有被释放掉,发生了内存泄漏。
/ \3 p* V' P# O" b' kRAII机制有助于解决这个问题,RAII(Resource acquisition is initialization,资源获取即初始化)。它的思想是以对象管理资源。为了更为方便、鲁棒地释放已获取的资源,避免资源死锁,一个办法是把资源数据用对象封装起来。程序发生异常,执行栈展开时,封装了资源的对象会被自动调用其析构函数以释放资源。C++ 中的智能指针便符合RAII。关于这个问题详细可以看《Effective C++》条款13.
异常机制与构造函数
2 _8 y) |" k' C* h; H3 s0 \! \1 J. {
异常机制的一个合理的使用是在构造函数中。构造函数没有返回值,所以应该使用异常机制来报告发生的问题。更重要的是,构造函数抛出异常表明构造函数还没有执行完,其对应的析构函数不会自动被调用,因此析构函数应该先析构所有所有已初始化的基对象,成员对象,再抛出异常。+ p( x# b: X0 g$ c7 a& L
C++ 类构造函数初始化列表的异常机制,称为 function-try block。一般形式为:

2 U& c& ~; I" ~7 q6 [) g
  1. / R2 Z0 U2 P0 m3 U
  2. myClass::myClass(type1 pa1)
    3 K1 V% h. p1 P" x- c+ L6 g
  3.     try:  _myClass_val (初始化值)
    0 H( b0 B3 i) \
  4. {
      ]6 L0 v- R% E4 J0 W# h# b
  5.   /*构造函数的函数体 */
    * L( N- [) O" Y- \6 B
  6. }9 Y$ R- l2 B6 H/ B3 O" ^
  7.   catch ( exception& err )+ @! Z0 Q2 _) V
  8. {2 n% n1 R3 V+ f* W2 X
  9.   /* 构造函数的异常处理部分 */
    / l: b# |  _5 w7 v
  10. };
复制代码
' @8 s- E9 p- I6 _
异常机制与析构函数4 `& R" b6 N% e8 f- b
C++ 不禁止析构函数向外界抛出异常,但析构函数被期望不向外界函数抛出异常。析构函数中向函数外抛出异常,将直接调用 terminator() 系统函数终止程序。如果一个析构函数内部抛出了异常,就应该在析构函数的内部捕获并处理该异常,不能让异常被抛出析构函数之外。可以如此处理:
  • 若析构函数抛出异常,调用 std::abort() 来终止程序。
  • 在析构函数中 catch 捕获异常并作处理。

    1 p7 K) k; C0 s2 \. {" A6 p
关于具体细节,有兴趣可以看《Effective C++》条款08:别让异常逃离析构函数noexcept修饰符与noexcept操作符
) g- M8 o7 k  Y; N
noexcept 修饰符是 C++11 新提供的异常说明符,用于声明一个函数不会抛出异常。编译器能够针对不抛出异常的函数进行优化,另一个显而易见的好处是你明确了某个函数不会抛出异常,别人调用你的函数时就知道不用针对这个函数进行异常捕获。在 C++98中关于异常处理的程序中你可能会看到这样的代码:& `  P' A0 U+ R1 i$ p; U# N

  1. % a; O" ?9 W: v/ P5 I
  2. void func() throw(int ,double ) {...}
    # r, \' l' m7 G4 E
  3. void func() throw(){...}
复制代码
. _& R2 F6 w( W. J1 b
这是 throw 作为函数异常说明,前者表示 func()这个函数可能会抛出 int 或 double 类型的异常,后者表示 func() 函数不会抛出异常。事实上前者很少被使用,在 C++11 这种做法已经被摒弃,而后者则被 C++11 的 noexcept 异常声明所代替:

  1. ) D8 H6 D! l* N- ^% q
  2. void func() noexcept {...}( U8 ?8 O( C: n1 e" S/ N
  3. //等价于void func() throw(){...}
复制代码
在 C++11 中,编译器并不会在编译期检查函数的 noexcept 声明,因此,被声明为noexcept 的函数若携带异常抛出语句还是可以通过编译的。在函数运行时若抛出了异常,编译器可以选择直接调用 terminate() 函数来终结程序的运行,因此,noexcept 的一个作用是阻止异常的传播,提高安全性., H, N$ z6 \# T' l5 |/ S
上面一点提到了,我们不能让异常逃出析构函数,因为那将导致程序的不明确行为或直接终止程序。实际上出于安全的考虑,C++11 标准中让类的析构函数默认也是 noexcept 的。同样是为了安全性的考虑,经常被析构函数用于释放资源的 delete 函数,C++11 也默认将其设置为 noexcept。" L9 |8 l* T: h6 b* V
noexcept也可以接受一个常量表达式作为参数,例如:
  • void func() noexcept(常量表达式);
    & Q+ E! t- l1 Y+ _/ m

1 D1 `, y4 E' m' P' R常量表达式的结果会被转换成 bool 类型,noexcept(bool) 表示函数不会抛出异常,noexcept(false) 则表示函数有可能会抛出异常。故若你想更改析构函数默认的 noexcept声明,可以显式地加上 noexcept(false) 声明,但这并不会带给你什么好处。
% A  O; N$ l1 n# b) h8 H1 G( k' s
异常处理的性能分析
5 @/ R9 I7 D8 @. a+ @( X
异常处理机制的主要环节是运行期类型检查。当抛出一个异常时,必须确定异常是不是从 try 块中抛出。异常处理机制为了完善异常和它的处理器之间的匹配,需要存储每个异常对象的类型信息以及 catch 语句的额外信息。由于异常对象可以是任何类型(如用户自定义类型),并且也可以是多态的,获取其动态类型必须要使用运行时类型检查(RTTI),此外还需要运行期代码信息和关于每个函数的结构。1 q: F5 U, F8 H5 @5 B
当异常抛出点所在函数无法解决异常时,异常对象沿着调用链被传递出去,程序的控制权也发生了转移。转移的过程中为了将异常对象的信息携带到程序执行处(如对异常对象的复制构造或者 catch 参数的析构),在时间和空间上都要付出一定的代价,本身也有不安全性,特别是异常对象是个复杂的类的时候。
% z7 w& G, I) r% {7 c+ g异常处理技术在不同平台以及编译器下的实现方式都不同,但都会给程序增加额外的负担,当异常处理被关闭时,额外的数据结构、查找表、一些附加的代码都不会被生成,正是因为如此,对于明确不抛出异常的函数,我们需要使用 noexcept 进行声明。
关于C++异常机制,欢迎在评论中和我探讨。

. \  J" h  o5 c+ R+ V( A1 Q! r, q
收藏 1 评论0 发布时间:2020-11-19 20:47

举报

0个回答
关于
我们是谁
投资者关系
意法半导体可持续发展举措
创新与技术
意法半导体官网
联系我们
联系ST分支机构
寻找销售人员和分销渠道
社区
媒体中心
活动与培训
隐私策略
隐私策略
Cookies管理
行使您的权利
官方最新发布
STM32N6 AI生态系统
STM32MCU,MPU高性能GUI
ST ACEPACK电源模块
意法半导体生物传感器
STM32Cube扩展软件包
关注我们
st-img 微信公众号
st-img 手机版