C++基础(十):异常处理

本文为《C++ Primer》的读书笔记

目录

  • `try`语句块
    • `throw` 表达式
    • `try` 语句块
  • 抛出异常
    • 栈展开
    • 栈展开过程中对象被自动销毁
    • 析构函数与异常
    • 异常对象
  • 捕获异常
    • 查找匹配的处理代码
    • 重新抛出
    • 捕获所有异常 (catch-alI) 的处理代码
  • 函数 `try` 语句块 与 构造函数
  • `noexcept` 异常说明
    • 违反异常说明
    • 异常说明的实参
    • `noexcept` 运算符
    • 异常说明与指针、虚函数和拷贝控制
  • 异常类
    • 标准异常类
    • 自定义异常类

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 循环
	}
}

抛出异常

栈展开

  1. 异常被抛出时,程序暂停当前函数的执行过程并立即开始寻找与异常匹配的 catch 子句
  2. throw 出现在一个 try 语句块 (try block) 内时, 检查与该 try 块关联的catch 子句。如果找到了匹配的 catch, 就使用该 catch 处理异常
  3. 如果没找到匹配的 catch 且该 try 语句嵌套在其他try 块中, 则继续检查与外层 try 匹配的catch 子句。如果还是找不到匹配的 catch, 则退出当前的函数, 在调用当前函数的外层函数中继续寻找
  4. 如果对抛出异常的函数的调用语句位于一个 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及引用限定符之后,而在finaloverride或虚函数的=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头文件定义了几种常用的异常类
      C++基础(十):异常处理_第1张图片
    • new头文件定义了bad_alloc异常类型
    • type_info 头文件定义了bad_cast异常类型

标准库异常类的继承体系如下图所示:

C++基础(十):异常处理_第2张图片

  • exception 只报告异常的发生,不提供任何额外信息
    • 它仅仅定义了拷贝构造函数、拷贝赋值运算符、一个虚析构函数和一个名为what 的虚成员 对于只有默认初始化函数的异常类型来说, what返回的内容由编译器决定
    • 其中what 函数返回一个 const char*, 该指针指向一个字符串数组, 并且确保不会抛出任何异常

what 是在 catch 异常后用于提取异常基本信息的虚函数。如果 what 抛出异常,则会在新产生的异常中由于 what 继续产生异常,将会产生抛出异常的死循环。因此,what 必须确保不抛出异常

  • exceptionbad_castbad_alloc 定义了默认构造函数。因此,只能以默认初始化的方式初始化exceptionbad_allocbad_cast对象, 不允许为这些对象提供初始值
  • runtime_errorlogic_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;
	}
}

你可能感兴趣的:(C++)