异常处理
异常处理(exception handling
)机制允许程序独立开发的部分能够在运行时就出现问题进行通信并作出相应的处理。异常是的我们能够将问题的检测和解决过程分离开来。程序的一部分负责检测问题的出现,然后解决该问题的任务传递给程序的另一部分。检测环节无需知道问题处理模块的所有细节,反之亦然。
1. 抛出异常
在C++语言中,我们通过抛出(throwing
)一条表达式来引发(raised
)一个异常。被抛出的表达式的类型以及当前的调用链共同决定了哪段处理代码(handler
)将被用来处理该异常。被选中的处理代码实在调用链中与抛出对象类型匹配的最近的处理代码。其中,根据抛出对象的类型和内容,程序的异常抛出部分会告知异常处理部分到底发生了什么错误。
当执行一个throw
时,跟在throw
后面的语句将不再被执行。相反,程序的控制权从throw
转移到与之匹配的catch
模块。该catch
可能是同一函数中的局部catch
,也可能位于直接或间接调用了发生异常的函数的另一个函数中。控制权从一处转移到另一处,这有两个重要的含义:
- 沿着调用链的函数可能会提早退出。
- 一旦程序开始执行异常处理代码,则沿着调用链创建的对象将被销毁。
因为跟在throw
后面的语句将不再被执行,所以throw
语句的有类似于return
语句:它通常作为条件语句的一部分或者作为某个函数的最后(或者唯一)一条语句。
1.1 栈展开
当抛出一个异常后,程序暂停当前函数的执行过程并立即开始寻找与异常匹配的catch
子句。
当
throw
出现在一个try语句块(try block
)内时,检查与该try
块关联的catch
子句。如果找到了匹配的
catch
,就使用该catch
处理异常。如果这一步没找到匹配的
catch
且该try
语句嵌套在其他try
块中,则继续检查与外层try
匹配的catch
子句。如果还是找不到匹配的
catch
,则退出当前函数,在调用当前函数的外层函数中继续寻找。如果对抛出异常的函数的调用语句位于一个
try
语句块内,则检查与该try
块关联的catch
子句。如果找到了匹配的
catch
,就使用该catch
处理异常。否则,如果该
try
语句嵌套在其他try
块中,则继续检查与外层try
匹配的catch
子句。如果仍然没找到匹配的
catch
,则退出当前这个主调函数,继续在调用刚刚退出的这个函数的其他函数中寻找,以此类推。
上述过程被称为栈展开(stack unwinding
)过程。栈展开过程沿着嵌套函数的调用链不断查找,直到找到了与异常匹配的catch
子句为止;或者也可能一致没找到匹配的catch
,则退出主函数后过程中止。
假设找到了一个匹配的catch
子句,则程序进入该子句并执行其中代码。当执行完这个catch
子句后,找到与try
块关联的最后一个catch
子句后的点,并从这里继续执行。
如果没有找到匹配的catch
子句,程序将退出。因为异常通常被认为是妨碍程序正常执行的事件,所以一旦引发了某个异常,就不能对它置之不理。当找不到匹配的catch
时,程序将调用标准库函数terminate
,顾名思义,terminate
负责中止程序的执行过程。
1.2 栈展开过程中对象被自动销毁
在栈展开过程中,位于调用链上的语句块可能会提前退出。如果在栈展开过程中退出了某个块,编译器将负责确保在这个块中创建的对象都能被正确的销毁。如果某个局部对象的类型是类类型,则该对象的析构函数将被自动调用。与往常一样,编译器在销毁内置类型的对象时不需要做任何事情。
如果异常发生在构造函数中,则当前的对象可能只构造了一部分。有的成员已经开始初始化了,而另外一些成员在异常发生前也许还没有开始初始化。即使某个对象只构造了一部分,我们也要确保构造的成员能被正确的销毁(否则会发生内存泄露)。
类似的,异常也可能发生在数组或标准库容器的元素初始化过程中。与之前类似,如果在异常发生前已经构造了一部分元素,则我们应该确保这部分元素被正确的销毁。
1.3 析构函数与异常
析构函数总是会被执行的,但是函数中负责释放资源的代码却可能会被跳过。如果一个块分配了资源,并且在负责释放这些资源的代码前面发生了异常,则释放资源的代码将不会被执行。另一方面,类对象分配的资源将由类的析构函数负责释放。因此,如果我们使用类来控制资源的分配,就能确保无论函数正常结束还是遭遇异常,资源都能被正确地释放。(RAII
的思想,在构造函数中获取资源(i.e new
),在析构函数中释放资源(i.e delete
)。)
所以出于栈展开可能使用析构函数的考虑,析构函数不应该抛出不能被它自身处理的异常。换句话说,如果析构函数需要执行某个可能抛出异常的操作,则该操作应该被放置在一个try语句块当中,并且在析构函数内部得到处理(如果不这样做的话,程序会马上被终止)。
注:所有标准库类型都能保证它们的析构函数不会引发异常。
1.4 异常对象
异常对象(exception object
)是一种特殊的对象,编译器使用异常抛出表达式来对异常对象进行拷贝初始化。因此throw
语句中的表达式必须拥有完整类型。而且如果该表达式是类类型的话,则相应的类必须含有一个可访问的析构函数和一个可访问的拷贝或移动构造函数。如果该表达式是数组类型或函数类型,则表达式将被转换成与之对应的指针类型。
异常对象位于有编译器管理的空间中,编译器确保无论调用哪个catch
子句都能访问该空间。异常处理完毕后,异常对象被销毁。
当一个异常被抛出是,沿着调用链的块将依次退出直至找到与异常匹配的处理代码。如果退出某个块,则同时释放块中局部对象使用的内存。因此,抛出一个指向局部对象的指针几乎肯定是一种错误行为。如果指针所指的对象位于某个块中,而该块在catch
语句之前就已经退出了,则意味着在执行catch
语句之前局部对象已经被销毁了。
当我们抛出一条表达式时,该表达式的静态编译时类型决定了异常对象的类型。很多情况下程序抛出的表达式类型来自于某个继承体系。如果一条throw
表达式解引用一个基类指针,而该指针实际指向的是派生类对象,则抛出的对象将被切掉一部分,只有基类部分被抛出。
注: 抛出指针要求在任何对应处理代码存在的地方,指针所指的对象都必须存在。
2. 捕获异常
catch
子句(catch clause
)中的一场声明(exception declaration
)看起来像是只包含一个形参的函数形参列表。像在形参列表中一样,如果catch
无须访问抛出的表达式的话,则我们可以忽略捕获形参的名字。
声明的类型决定了处理代码所能捕获的异常类型。这个类型必须是完全类型,它可以是左值引用,不能是右值引用。当进入一个catch
语句后,入参通过异常对象初始化异常声明中的参数。和函数的参数类似,如果catch
的参数类型是非引用类型,则该参数是异常对象的一个副本,如果参数是引用类型,则和其他引用参数一样,该参数是异常对象的一个别名。
如果catch
的参数是基类类型,则我们可以使用其派生类类型的异常对象对其进行初始化。此时,如果catch
的参数是非引用类型,则异常对象将被切掉一部分,如果catch
的参数是基类的引用,则该参数将以常规方式绑定到异常对象上。
最后一点需要注意的是,异常声明的静态类型将决定catch
语句所能执行的操作。如果catch
的参数是基类类型,则catch
无法使用派生类特有的任何成员。
Tips: 通常情况下,如果
catch
接收的异常与某个继承体系有关,则最好将该catch
的参数定义成引用类型。
2.1 查找匹配的处理代码
在搜寻catch
语句的过程中,我们最终找到的catch
未必是异常的最佳匹配。相反,挑选出来的应该是第一个与异常匹配的catch
语句。因此,越是专门的catch
越应该置于整个catch
列表的前端。
因为catch
语句是按照其出现的顺序逐一匹配的,所以当程序员使用具有继承关系的多个异常时必须对catch
语句的顺序进行组织管理,是的派生类异常的处理代码出现在基类异常的处理代码异常之前。
与实参和形参的匹配规则相比,异常和catch
异常声明的匹配规则受到更多限制。此时,绝大多数类型转换都不被允许,除了一些极细小的差别之外,要求异常的类型和catch
声明的类型时精确匹配的:
- 允许从非常量的类型转换,也就是说一条非常量对象的
throw
语句可以匹配一个接受常量引用的catch
语句 - 允许从派生类向基类的类型转换。
- 数组被转换成指向数组(元素)类型的指针,函数被转化成指向该函数类型的指针。
除此之外,包括标准算术类型转换和类类型转换在内,其他所有转换规则都不能在匹配catch的过程中使用。
如果在多个catch语句的类型之间存在着继承关系,则我们应该把继承链最低端的类(
most derived type
)放在前面,而将继承链最顶端的类(least derived type
)放在后面。
2.2 重新抛出
一个单独的catch
语句不能完整的处理某个异常。在执行了某些校正操作之后,当前的catch
可能会决定由调用链更上一层的函数接着处理异常。一条catch语句通过重新抛出的操作将异常传递给另外一个catch
语句。这里的重新抛出仍然是一条throw
语句,只不过不包含任何表达式: throw;
空的throw
语句只能出现在catch
语句或catch
语句直接或间接调用的函数之内。如果在处理代码之外的区域遇到了空throw
语句,编译器将调用terminate
。
一个重新抛出语句并不指定新的表达式,而是将当前的异常对象沿着调用链向上传递。
很多时候,catch语句会改变其参数内容。如果在改变了参数的内容后catch语句重新抛出异常,则只有当catch异常声明是引用类型时我们对参数所作的改变才会被保留并继续传播。
2.3 捕获所有异常的处理代码
为了一次性捕获所有异常,我们使用省略号作为异常声明,这样的处理代码称为捕获所有异常的处理代码,形如catch(...)
.
catch(...)
通常与重新抛出语句一起使用,其中catch
执行当前局部能完成的工作,随后重新抛出异常。
Tips: 如果
catch(...)
与其他几个catch
语句一起出现,则catch(...)
必须在最后的位置。出现在捕获所有一场语句后面的catch
语句将永远不会被匹配。
3. 函数try语句块与构造函数
通常情况下,程序执行的任何时刻都可能发生异常,特别是一场可能发生在处理构造函数初始值的过程中。构造函数在进入其函数体之前首先执行初始值列表。因为在初始值列表抛出异常时构造函数体内的try
语句块还未生效,所以构造函数体内的catch
语句无法处理构造函数初始值列表抛出的异常。
要想处理构造函数初始值抛出的异常,我们必须将构造函数写成函数try语句块(function try block
)的形式。函数try
语句使得一组catch
语句既能处理构造函数体(或析构函数体),也能处理构造函数的初始化过程(或析构函数的析构过程)。
1 | template <typename T> |
4. noexcept 异常说明
1 | void recoup() noexcept; /** 不会抛出异常 */ |
对于一个函数来说,noexcept说明要么出现在该函数的所有声明语句和定义语句中,要么一次也不出现。该说明应该在函数应该在函数的尾置返回类型之前。
我们也可以在函数指针的声明和定义中指定
noexcept
。在typedef或类型别名中则不能出现
noexcept
。在成员函数中,
noexcept
说明符需要跟在const
及引用限定符之后,而在final
、override
或虚函数=0
之前。
4.1 违反异常说明
编译器并不会在编译时检查noexcept
说明。实际上,如果一个函数说明了noexcept
的同时又含有throw
语句或者调用了可能抛出异常的其他函数,编译器将顺利通过,并不会因为这种违反异常说明的情况而报错。
因此可能会出现一种情况:尽管函数说明了它不会抛出异常,但实际上还是抛出了。一旦一个noexcept
函数抛出异常,程序就会调用terminate
以确保遵守不在运行时抛出异常的承诺。
上述过程是执行栈展开未作约定,因此noexcept
可以用在两种情况下:一是我们确认函数不会抛出异常,二是我们根本不知道该如何处理异常。
4.2 noexcept运算符
noexcept
说明符接受一个可选实参,该实参必须能转换为bool
类型:如果实参是true
,则函数不会抛出异常;如果实参是false
,则函数可能抛出异常:
1 | void recoup() noexcept(true); /** 不会抛出异常 */ |
noexcept
说明符的实参常常与noexcept
运算符混合使用。noexcept
运算符是一个一元运算符,它的返回值是一个bool类型的右值常量表达式,用于表示给定的表达式是否会抛出异常。和sizeof
类似,noexcept
也不会求其运算对象的值。
1 | noexcept(recoup()) /** 如果recoup不跑出异常则结果为true;否则结果为false */ |
我们可以使用noexcept运算符得到如下的异常说明:
1 | void f() noexcept(noexcept(g())); // f 和 g的异常说明一致 |
如果函数g()
承诺了不会抛出异常,则f也不会抛出异常;如果g()
没有异常说明符,或者g虽然有异常说明符但是允许抛出异常,则f()
也可能抛出异常。
noexcept
有两层含义:当跟在函数参数列表后面时它是异常说明符;而当作为noexcept
异常说明的bool
实参出现时,它是一个运算符。
4.3 异常说明与指针、虚函数和拷贝控制
函数指针及该指针所指的函数必须具有一致的异常说明。也就是说我们为某个指针做了不抛出异常的声明,则该指针将只能指向不抛出异常的函数。相反,如果我们显式或隐式地说明了指针可能抛出异常,则该指针可以指向任何函数,即使是承诺了不抛出异常的函数也可以。
如果虚函数承诺了它不会抛出异常,则后续派生出来的虚函数也必须做出同样的承诺;与之相反如果基类的虚函数允许抛出异常,则派生类的对应函数既可以允许抛出异常,也可以不允许抛出异常。
当编译器合成拷贝控制成员时,同时也生成一个异常说明。如果对所有成员和基类的所有操作都承诺了不会抛出异常,则合成的成员是noexcept
的。如果合成成员调用的任意一个函数可能抛出异常,则合成的成员是noexcept(false)
。而且如果我们定义了一个析构函数但是没有为它提供异常说明,则编译器将合成一个。合成的异常说明将于假设有编译器为类合成析构函数时所得的异常说明一致。