本文为《C++ Primer》的读书笔记
目录
try
语句块
throw
表达式
- 程序的异常检测部分使用
throw
表达式引发一个异常 throw
表达式包含关键字throw
和紧随其后的一个表达式, 其中表达式的类型就是抛出的异常类型;抛出的异常类型可以是任意类型,不一定非得是标准异常类
// 初始化`runtime_error`的对象
throw runtime_error("Data must refer to same ISBN");
try
语句块
try {
program-statements
} catch (exception-declaration) {
handler-statements
} catch (exception-declaration) {
handler-statements
} // ...
catch
子句包括三部分:- 关键字
catch
- 括号内一个(可能未命名的)对象的声明(异常声明)
- 一个块
- 关键字
- 当选中了某个
catch
子句处理异常之后,执行与之对应的块。catch
一旦完成, 程序跳转到try
语句块最后一个catch
子句之后的那条语句继续执行 - 注意:
try
语句块内声明的变量在块外部无法访问,特别是在catch
子句内也无法访问
while (cin >> iteml >> item2) {
try {
// 如果失败, 代码抛出一个 runtime_error 异常
} catch (runtime_error err) {
cout << err.what()
<< "\nTry Again? Enter y or n" << endl;
char c;
cin >> c;
if(!cin || c == 'n')
break; // 跳出 while 循环
}
}
抛出异常
栈展开
- 当异常被抛出时,程序暂停当前函数的执行过程并立即开始寻找与异常匹配的
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
子句, 程序将调用 标准库函数terminate
terminate
的行为与系统有关, 一般情况下, 执行该函数将导致程序非正常退出,从而终止程序的执行过程
- 假设找到了一个匹配的
栈展开过程中对象被自动销毁
- 在栈展开过程中, 位于调用链上的语句块可能会提前退出。通常情况下,程序在这些块中创建了一些局部对象。如果在栈展开过程中退出了某个块, 编译器将负责确保在这个块中创建的对象能被正确地销毁
- 如果异常发生在构造函数中, 即使某个对象只构造了一部分,我们也要确保已构造的成员能被正确地销毁
- 类似的, 异常也可能发生在数组或标准库容器的元素初始化过程中。与之前类似,如果在异常发生前已经构造了一部分元素,则我们应该确保这部分元素被正确地销毁
析构函数与异常
使用类控制资源的分配
- 析构函数总是会被执行的, 但是函数中负责释放资源的代码却可能被跳过。如果一个块分配了资源, 并且在负责释放这些资源的代码前发生了异常,则释放资源的代码将不会被执行 → \rightarrow → 资源泄漏
- 另一方面, 类对象分配的资源将由类的析构函数负责释放。因此, 如果我们使用类来控制资源的分配, 就能确保无论函数正常结束还是遗遇异常, 资源都能被正确地释放
析构函数不应该抛出不能被它自身处理的异常
- 在栈展开的过程中,运行类类型的局部对象的析构函数。因为这些析构函数是自动执行的, 所以它们不应该抛出异常。一旦在栈展开的过程中析构函数抛出了异常, 并且析构函数自身没能捕获到该异常, 则程序将被终止
- 因此,出于栈展开可能使用析构函数的考虑, 析构函数不应该抛出不能被它自身处理的异常。换句话说, 如果析构函数要执行某个可能抛出异常的操作, 则该操作应该被放置在一个
try
语句块当中, 并在析构函数内部得到处理
- 因此,出于栈展开可能使用析构函数的考虑, 析构函数不应该抛出不能被它自身处理的异常。换句话说, 如果析构函数要执行某个可能抛出异常的操作, 则该操作应该被放置在一个
异常对象
- 异常对象 (exception object) 是一种特殊的对象, 编译器使用异常抛出表达式来对异常对象进行拷贝初始化。因此,
throw
语句中的表达式必须拥有完全类型- 如果该表达式是类类型的话, 则相应的类必须含有一个可访问的析构函数和一个可访问的拷贝或移动构造函数
- 如果该表达式是数组类型或函数类型, 则表达式将被转换成与之对应的指针类型
- 异常对象位于由编译器管理的空间中, 编译器确保无论最终调用的是哪个
catch
子句都能访问该空间。当异常处理完毕后, 异常对象被销毁 - 注意:请勿抛出一个指向局部对象的指针:如我们所知, 当一个异常被抛出时, 沿着调用链的块将依次退出直至找到与异常匹配的处理代码。如果退出了某个块, 则同时释放块中局部对象使用的内存。因此, 抛出一个指向局部对象的指针几乎肯定是一种错误的行为。如果指针所指的对象位于某个块中,而该块在
catch
语句之前就已经退出了, 则意味着在执行catch
语句之前局部对象已经被销毁了- 抛出指针要求在任何对应的处理代码存在的地方, 指针所指的对象都必须存在
- 当我们抛出一条表达式时, 该表达式的静态编译时类型决定了异常对象的类型。如果一条
throw
表达式解引用一个基类指针, 而该指针实际指向的是派生类对象, 则抛出的对象将被切掉一部分, 只有基类部分被抛出
捕获异常
catch
子句 (catch clause) 中的异常声明看起来像是只包含一个形参的函数形参列表。如果catch
无须访问抛出的表达式的话, 则我们可以忽略捕获形参的名字- 声明的类型决定了处理代码所能捕获的异常类型。这个类型必须是完全类型,它可以是左值引用,但不能是右值引用
- 当进入一个
catch
语句后, 通过异常对象初始化异常声明中的参数。如果catch
的参数类型是非引用类型,则该参数是异常对象的一个副本; 如果参数是引用类型, 则和其他引用参数一样, 该参数是异常对象的一个别名 - 如果
catch
的参数是基类类型, 则我们可以使用其派生类类型的异常对象对其进行初始化。此时, 如果catch
的参数是非引用类型, 则异常对象将被切掉一部分, 这与将派生类对象以值传递的方式传给一个普通函数差不多 - 另一方面, 如果
catch
的参数是基类的引用, 则该参数将以常规方式绑定到异常对象上。但异常声明的静态类型将决定catch
语句所能执行的操作。如果catch
的参数是基类类型, 则catch
无法使用派生类特有的成员
通常情况下,如果
catch
接受的异常与某个继承体系有关,则最好将该catch
的参数定义成引用类型
查找匹配的处理代码
- 在搜寻
catch
语句的过程中,我们最终找到的catch
未必是异常的最佳匹配。和反,挑选出来的应该是第一个与异常匹配的catch
语句。因此, 越是专门的catch
越应该置于整个catch
列表的前端
当程序使用具有继承关系的多个异常时必须对
catch
语句的顺序进行组织和管理, 使得派生类异常的处理代码出现在基类异常的处理代码之前
- 与实参和形参的匹配规则相比, 异常和
catch
异常声明的匹配规则受到更多限制。此时, 绝大多数类型转换都不被允许, 除了一些极细小的差别之外, 要求异常的类型和catch
声明的类型是精确匹配的:- 允许从非常量向常量的类型转换, 也就是说, 一条非常量对象的
throw
语句可以匹配一个接受常量引用的catch
语句 - 允许从派生类向基类的类型转换
- 数组被转换成指向数组(元素)类型的指针,函数被转换成指向该函数类型的指针
- 除此之外, 包括标准算术类型转换和类类型转换在内, 其他所有转换规则都不能在匹配
catch
的过程中使用
- 允许从非常量向常量的类型转换, 也就是说, 一条非常量对象的
重新抛出
- 有时, 一个单独的
catch
语句不能完整地处理某个异常。在执行了某些校正操作之后, 当前的catch
可能会决定由调用链更上一层的函数接着处理异常。一条catch
语句通过重新抛出(rethrowing)的操作将异常传递给另外一个catch
语句:
throw;
- 空的
throw
语句只能出现在catch
语句或catch
语句直接或间接调用的函数之内。如果在处理代码之外的区域遇到了空throw
语句, 编译器将调用terminate
- 一个重新抛出语句将当前的异常对象沿着调用链向上传递。很多时候,
catch
语句会改变其参数的内容。如果在改变了参数的内容后catch
语句重新抛出异常, 则只有当catch
异常声明是引用类型时我们对参数所做的改变才会被保留并继续传播:
catch (my_error &eObj) { // 引用类型
eObj.status = errCodes::severeErr; // 修改了异常对象
throw; //异常对象的status成员是severeErr
} catch (other_error eObj) { // 非引用类型
eObj.status = errCodes::badErr; // 只修改了异常对象的局部副本
throw; // 异常对象的status成员没有改变
}
捕获所有异常 (catch-alI) 的处理代码
- 为了一次性捕获所有异常, 我们使用省略号作为异常声明
catch(...)
,使其可以与任意类型的异常匹配catch(...)
通常与重新抛出语句一起使用, 其中catch
执行当前局部能完成的工作, 随后重新抛出异常:- 如果
catch(...)
与其他几个catch
语句一起出现,则**catch(...)
必须在最后的位置**。出现在捕获所有异常语句后面的catch
语句将永远不会被匹配
void manip() {
try {
// 抛出一个异常
catch (...) {
// 处理异常的某些特殊操作
throw;
}
}
函数 try
语句块 与 构造函数
- 通常情况下, 程序执行的任何时刻都可能发生异常,特别是异常可能发生在处理构造函数初始值的过程中
- 构造函数在进入其函数体之前首先执行初始值列表。因为在初始值列表抛出异常时构造函数体内的
try
语句块还未生效, 所以构造函数体内的catch
语句无法处理构造函数初始值列表抛出的异常 - 要想处理构造函数初始值抛出的异常,我们必须将构造函数写成函数
try
语句块(也称为函数测试块, function try block)的形式 - 函数
try
语句块使得一组catch
语句既能处理构造函数体(或析构函数体), 也能处理构造函数的初始化过程(或析构函数的析构过程)
- 构造函数在进入其函数体之前首先执行初始值列表。因为在初始值列表抛出异常时构造函数体内的
例如:
template <typename T>
Blob<T>::Blob(std::initializer_list<T> il) try :
data(std::make_shared<std::vector<T>>(il)) {
/* 空函数体 */
} catch(const std::bad_alloc &e) { handle_out_of_memory(e); }
- 关键字
try
出现在表示构造函数初始值列表的冒号以及表示构造函数体的花括号之前。与这个try
关联的catch
既能处理构造函数体抛出的异常,也能处理成员初始化列表抛出的异常 - 还有一种情况值得注意,在初始化构造函数的参数时也可能发生异常,这样的异常不属于函数
try
语句块的一部分。函数try
语句块只能处理构造函数开始执行后发生的异常。和其他函数调用一样,如果在参数初始化的过程中发生了异常,则该异常属于调用表达式的一部分,并将在调用者所在的上下文中处理
noexcept
异常说明
对用户及编译器来说,预先知道某个函数不会抛出异常显然大有裨益
- 有助于用户简化调用该函数的代码
- 如果编译器确认函数不会抛出异常,它就能执行某些特殊的优化操作,而这些优化操作并不适用于可能出错的代码
在C++11新标准中,我们可以通过提供 noexcept
说明 指定某个函数不会抛出异常
- 其形式是关键字
noexcept
紧跟在函数的参数列表之后,尾置返回类型之前。在成员函数中,noexcept
说明符需要跟在const
及引用限定符之后,而在final
、override
或虚函数的=0
之前 - 对于一个函数来说,
noexcept
说明要么出现在该函数的所有声明语句和定义语句中,要么一次也不出现 - 我们也可以在函数指针的声明和定义中指定
noexcept
,在typedef
或类型别名中则不能出现noexcept
void recoup(int) noexcept; // 不会抛出异常
违反异常说明
- 编译器并不会在编译时检查
noexcept
说明。实际上,如果一个函数在说明了noexcept
的同时又含有throw
语句或者调用了可能抛出异常的其他函数,编译器将顺利编译通过 - 一旦一个
noexcept
函数抛出了异常,程序就会调用terminate
以确保遵守不在运行时抛出异常的承诺。上述过程对是否执行栈展开未作约定, 因此noexcept
可以用在两种情况下:- 一是我们确认函数不会抛出异常
- 二是我们根本不知道该如何处理异常
异常说明的实参
noexcept
说明符接受一个可选的实参, 该实参必须能转换为bool
类型:
- 如果实参是
true
, 则函数不会抛出异常 - 如果实参是
false
, 则函数可能抛出异常:
void recoup(int) noexcept(true); // recoup不会抛出异常
void alloc(int) noexcept(false); // alloc可能抛出异常
noexcept
运算符
noexcept
说明符的实参常常与 noexcept
运算符混合使用
noexcept
运算符是一个一元运算符, 它的返回值是一个bool
类型的右值常量表达式,用于表示给定的表达式是否会抛出异常noexcept
也不会求其运算对象的值
noexcept(recoup(i)) //如果recoup不抛出异常则结果为true; 否则结果为false
更普通的形式是:
noexcept(e)
- 当
e
调用的所有函数都做了不抛出说明且e
本身不含有throw
语句时, 上述表达式为true
我们可以使用noexcept
运算符得到如下的异常说明:
void f() noexcept(noexcept(g())); // f和g 的异常说明一致
异常说明与指针、虚函数和拷贝控制
尽管 noexcept
说明符不属于函数类型的一部分, 但是函数的异常说明仍然会影响函数的使用
- 函数指针及该指针所指的函数必须具有一致的异常说明。也就是说, 如果我们为某个指针做了不抛出异常的声明, 则该指针将只能指向不抛出异常的函数。相反, 如果我们显式或隐式地说明了指针可能抛出异常,则该指针可以指向任何函数,即使是承诺了不抛出异常的函数也可以:
// recoup 和 pf1 都承诺不会抛出异常
void (*pf1)(int) noexcept = recoup;
// 正确: recoup 不会抛出异常, pf2 可能抛出异常, 二者之间互不干扰
void (*pf2)(int) = recoup;
pf1 = alloc; // 错误: alloc 可能抛出异常, 但是pf1 已经说明了它不会抛出异常
pf2 = alloc; // 正确: pf2 和 alloc 都可能抛出异常
- 如果一个虚函数承诺了它不会抛出异常,则后续派生出来的虚函数也必须做出同样的承诺;与之相反, 如果基类的虚函数允许抛出异常, 则派生类的对应函数既可以允许抛出异常, 也可以不允许抛出异常:
class Base {
public:
virtual double f1(double) noexcept; //不会抛出异常
virtual int f2() noexcept(false); // 可能抛出异常
virtual void f3(); // 可能抛出异常
};
class Derived : public Base {
public:
double f1(double); // 错误: Base::f1 承诺不会抛出异帘
int f2() noexcept(false); // 正确: 与Base::f2 的异常说明一致
void f3() noexcept; // 正确: Derived 的f3 做了更严格的限定
}
- 当编译器合成拷贝控制成员时, 同时也生成一个异常说明
- 如果对所有成员和基类的所有操作都承诺了不会抛出异常, 则合成的成员是
noexcept
的 - 如果我们定义了一个析构函数但是没有为它提供异常说明, 则编译器将合成一个。合成的异常说明将与假设由编译器为类合成析构函数时所得的异常说明一致
- 如果对所有成员和基类的所有操作都承诺了不会抛出异常, 则合成的成员是
异常类
标准异常类
- C++标准库定义了一组标准异常类, 用于报告标准库函数遇到的问题,它们分别定义在4个头文件中:
exception
头文件定义了最通用的异常类exception
stdexcept
头文件定义了几种常用的异常类
new
头文件定义了bad_alloc
异常类型type_info
头文件定义了bad_cast
异常类型
标准库异常类的继承体系如下图所示:
exception
只报告异常的发生,不提供任何额外信息- 它仅仅定义了拷贝构造函数、拷贝赋值运算符、一个虚析构函数和一个名为
what
的虚成员 对于只有默认初始化函数的异常类型来说,what
返回的内容由编译器决定 - 其中
what
函数返回一个const char*
, 该指针指向一个字符串数组, 并且确保不会抛出任何异常
- 它仅仅定义了拷贝构造函数、拷贝赋值运算符、一个虚析构函数和一个名为
what
是在catch
异常后用于提取异常基本信息的虚函数。如果what
抛出异常,则会在新产生的异常中由于what
继续产生异常,将会产生抛出异常的死循环。因此,what
必须确保不抛出异常
- 类
exception
、bad_cast
和bad_alloc
定义了默认构造函数。因此,只能以默认初始化的方式初始化exception
、bad_alloc
和bad_cast
对象, 不允许为这些对象提供初始值 - 类
runtime_error
和logic_error
没有默认构造函数, 但是有一个可以接受 C 风格字符串或者标准库string
类型实参的构造函数, 这些实参负责提供关于错误的更多信息runtime_error
表示的是只有在程序运行时才能检测到的错误logic_error
一般指的是我们可以在程序代码中发现的错误- 在这些类中,
what
负责返回用于初始化异常对象的信息
自定义异常类
- 实际的应用程序通常会自定义
exception
(或者exception
的标准库派生类)的派生类以扩展其继承体系。这些面向应用的异常类表示了与应用相关的异常条件
- 如果我们构建的是一个真实的书店应用程序, 则其中的类将比本书之前所示的复杂得多。复杂性的一个方面就是如何处理异常。实际上, 我们很可能需要建立一个自己的异常类体系, 用它来表示与应用相关的各种问题。我们设计的异常类可能如下所示:
// 为某个书店应用程序设定的异常类
// out_of_stock 类表示在运行时可能发生的错误, 比如某些顺序无法满足
class out_of_stock : public std::runtime_error
{
public:
explicit out_of_stock(const std::string &s):
std::runtime_error(s) {}
};
// isbn_mismatch 类是logic_error的一个特例,
// 程序可以通过比较对象的 isbn() 结果来阻止或处理这一错误
class isbn_mismatch : public std::logic_error
{
public:
explicit isbn_mismatch(const std::string &s):
std::logic_error(s) {}
isbn_mismatch(const std::string &s,
const std::string &lhs, const std::string &rhs):
std::logic_error(s), left(lhs), right(rhs) { }
const std::string left, right;
};
// 如果参与加法的两个对象并非同一书籍, 则抛出一个异帘
Sales_data&
Sales_data::operator+=(const Sales_data& rhs)
{
if(isbn() != rhs.isbn())
throw isbn_mismatch("wrong isbns", isbn(), rhs.isbn());
units_sold += rhs.units_sold;
revenue += rhs.revenue;
return *this;
}
Sales_data item1, item2, sum;
while (cin >> item1 >> item2) {
try {
sum = item1 + item2;
} catch (const isbn_mismatch &e) {
cerr << e.what() << ": left isbn(" << e.left << ") right isbn(" << e.right << ")" << endl;
}
}