首页 > 技术文章 > C++ Primer学习笔记 - 第18章 用于大型程序的工具

fortunely 2022-02-14 16:42 原文

18.1 异常处理

异常处理(exception handling)机制,允许程序中独立开发的部分能够在运行时就出现的问题进行通信,并做出相应的处理。
异常使得我们能将问题的检测和解决过程分离开,传统C语言异常处理必须对每个函数返回的异常作处理,也就是检测和解决过程捆绑在一起。

这样做的优势在于: 检测环节无需知道问题处理模块的所有细节,反过来也成立。

18.1.1 抛出异常

C++中,通过抛出(throw)一条表达式来引发(raised)一个异常。被抛出的表达式的类型以及当前的调用链,共同决定了哪段处理代码(handler)将被用来处理该异常。

示例:
try语句块中调用的代码段,throw抛出runtime_error异常,之后的程序不再执行。在多个catch模块中,程序控制权被转移到与异常匹配的catch模块,调用catch处理异常模块代码。

// 某处抛出异常
throw std::runtime_error("runtime error");

// 另外一处处理异常
try
{
    ...
}
catch (const std::runtime_error& err)
{
    ...
}
catch (const std::logic_error& err)
{
    ...
}
catch(const std::exception& ex)
{
    ...
}

这也就是说:

  • 沿着调用链的函数可能会提早退出;
  • 一旦程序开始执行异常处理代码,则沿着调用链创建的对象将被销毁(退出了try语句块及内部语句块)。

栈展开

当抛出一个异常后,程序暂停当前函数的执行过程,并立即开始寻找与异常匹配的catch子句。当throw出现在一个try语句块(try block)内时,检查与该try块关联的catch子句。如果找到匹配catch,就调用该catch处理异常。如果没匹配到catch,就继续检查与外层try匹配的catch子句。
如果一直没有找到匹配的catch子句,程序将调用terminate()退出。
上面这个过程称为栈展开(stack unwinding)过程。栈展开过程沿着嵌套函数的调用链不断查找,知道找到与异常匹配的catch子句为止;或者,一直没找到匹配的catch,则退出主函数后查找过程终止。

展开过程中对象被自动销毁

在栈展开过程中,位于调用链上的语句块可能会提前退出。通常,程序在这些语句块中创建了一些局部变量。而块退出后,它的局部对象也会随之销毁。

如果异常发生在构造函数(ctor)中,当前对象可能只构造了一半:有些成员已经构造,有些则没有。即使如此,程序员有责任确保异常发生时,已构造的成员能程序销毁。

类似,如果异常发生在数组,或者标准库容器的元素初始化过程中,我们也应该确保这部分元素被正确地销毁。

析构函数与异常

析构函数总是会被执行,但(普通)函数中负责释放资源的代码却可能被跳过。因为异常发生时,会跳过异常发生点后续的代码执行,转到匹配的catch语句继续执行,但资源可能并没有释放。而临时对象的析构函数,在退出(函数)语句块时,总是会被执行。
这样,可以利用临时对象的构造函数初始化资源,析构函数来释放资源,可以确保即使发生异常,资源也能正确释放。我们把这种资源管理方式称为RAII(Resource Acquisition Is Initialization,“资源获取就是初始化”)。

因此,析构函数不应该抛出异常,即使抛出异常,也应该内部处理。

异常对象

异常对象是什么?
异常对象(exception object)一种特殊的对象,编译器使用异常抛出表达式来对异常对象进行拷贝初始化

例如,抛出一个异常对象

throw std::runtime_error("runtime"); // 抛出runtime_error异常对象

如果throw抛出的表达式是类类型,则相应的类必须含有一个可访问的析构函数和一个可访问的copy或move构造函数。
如果throw数组或函数类型,则表达式将被转换为与之对应的指针类型。

异常对象位于哪里?
异常对象位于由编译器管理的空间中,编译器确保无论最终调用的是哪个catch子句都能访问该空间。当异常处理完毕后,异常对象将被销毁。

抛出一个指向局部对象的指针是一种错误的行为,因为异常抛出后,会沿着调用链找到匹配的catch子句,当退出某个块,该块内局部对象对应内存也就释放了。

抛出的异常对象是什么类型?
当我们抛出一条表达式时,该表达式的静态编译时类型决定了异常对象的类型。
如果一条throw表达式解引用一个基类指针,而该基类指针实际指向的是派生类对象,则抛出的对象将被切割,只保留基类那部分(被拷贝)。

例如,下面示例实际抛出基类exception那部分

// exception 是runtime_error基类
auto ex = std::runtime_error("runtime");
std::exception pex = &ex;
throw *pex; // 实际抛出基类exception那部分

18.2.2 捕获异常

catch子句(catch clause)中的异常声明(exception declaration)看起来像是只包含一个形参的函数形参列表。如果catch无需访问抛出的表达式,则可以忽略捕获形参的名字。

try
{
  ...
}
catch(const std::exception&) // 如果不想访问抛出的表达式,就可以忽略捕获形参的名字
{
  ...
}

catch参数特性
1)声明的类型决定了处理代码所能捕获的异常类型,如上面的代码就能捕获std::exception异常类型。该类型必须是完全类型(不能是声明类型),可以是左值引用,但不能是右值引用。

2)当进入catch语句后,通过异常对象初始化异常声明中的参数。和函数参数类似,如果catch的参数是non-reference类型,则该参数是异常对象的一个副本。
如果catch的参数是reference类型,则该参数是异常对象本身(别名)。

3)如果catch的参数是基类类型,可以用派生类异常对象对其进行初始化。如果参数是non-reference类型,则异常对象会被切割掉一部分,只保留基类那部分。如果参数是reference类型,则参数将以常规方式绑定到异常对象上。

TIPS:通常,如果catch接受的异常与某个继承体系有关,则最好将该catch参数定义成引用类型。

查找匹配的处理代码

在查找catch语句的过程中,最终找到的catch未必是异常的最佳匹配,而是第一个与异常匹配的catch语句。因此,越是专门的catch越应该置于整个catch列表的前端。

也就是说,处理多个catch语句时,派生类异常的处理应当出现在基类异常之前。

绝大多数的异常类型转换是不被允许的,除了一些极细小的差别外,要求异常的类型和catch声明的类型是精确匹配的:

  • 允许从非常量向常量的类型转换,i.e. 一条非常量对象的throw语句可以匹配一个接受常量引用的catch语句。non-const => const
  • 允许从派生类向基类类型转换。derived object => base object
  • 数组被转换成指向数组(元素)类型的指针,函数被转换成指向函数类型的指针。T array[] => T*,function => function pointer

重新抛出

有时,一个单独的catch不能完整处理某个异常。在执行一些操作后,当前catch可能会决定由调用链更上一层的函数接着处理异常。
重新抛出(rethrow)将原来捕获的异常继续传递给另一个catch语句:

throw;

空throw语句只能在catch语句,或catch语句直接或间接调用的函数内。如果在处理代码之外的区域遇到了空throw语句,编译器将调用terminate。

很多时候,catch语句会改变参数内容,此时参数需要用引用类型。

catch (my_error &eObj) { // 引用类型
  eObj.status = errCodes::servereErr; // 修改了异常对象
  throw; // 异常对象的status成员是servereErr
}
catch (other_error eObj) { // 非引用类型
  eObj.status = errCodes::badErr; // 只修改了异常对象的局部副本
  throw; // 异常对象的status成员没有改变
}

捕获所有异常的处理代码

用省略号...做异常声明,这样的catch语句捕获所有异常(catch-all)。
catch(...)通常与重新抛出语句一起使用,其中catch执行当前局部能完成的工作,随后重新抛出异常。

void manip() {
  try {
    // 这里的操作将引发并抛出一个异常
  }
  catch(const std::exception &ex) {
    // 捕获exception异常并处理
  }
  catch(...) {
    // 处理异常的某些特殊操作
    throw;
  }
}

18.1.3 函数try语句块与构造函数

构造函数也可能发生异常:
1)异常发生在构造函数体内,可以使用try-catch语句捕获并处理异常。

2)异常发生在构造函数的初始值列表中,无法通过在函数体内嵌入try-catch语句捕获异常。而应该使用函数try语句块(也称为函数测试块,function try block)。
例如,把class template Blob的构造函数写出如下形式:

template<typename T>
Blob<T>::Blob(std::initializer_list<T> i1) try : data(std::make_shared<std::vector<T>*i1)) {
  // 空函数体
} catch(const std::bad_alloc &e) { handle_out_of_memory(e); }

注:析构函数不允许抛出异常,即使要抛出异常,也应该内部处理。

18.1.4 noexcept异常说明

对于用户及编译器来说,预先知道某个函数不会抛出异常大有裨益。表现在2点:
1)知道函数不会抛出异常,有助于简化调用该函数的代码;

2)如果编译器确认函数不会抛出异常,就能执行某些特殊的优化操作,而这些优化操作并不适用于可能出错的代码。

C++11标准中,可以通过noexcept说明(noexcept specification)指定某个函数不会抛出异常。

形式是关键字noexcept 紧跟函数的参数列表后面,用来标识该函数不会抛出异常:

void recoup(int) noexcept; // 不会抛出异常
void alloc(int);           // 可能抛出的异常

第一条声明语句指出recoup将不会抛出任何异常,因此做了不抛出说明(nonthrowing specification);
而alloc可能抛出异常,则不做该说明。

对于一个函数来说,noexcept说明要么出现在该函数的所有声明语句和定义语句中,要么一次也不出现。
noexcept说明应该在尾置返回类型之前。

可以在函数指针的声明和定义中指定noexcept,在typedef或类型别名中不能出现noexcept。

成员函数中,noexcept说明符需要跟在const及引用限定符之后,而在final、override或virtual函数=0之前。

违反异常说明

万一某个函数使用了noexcept说明,但又throw了异常,怎么办?
首先,编译器并不会在编译时检查noexcept说明,也就是说,能通过编译。要是在运行时noexcept函数抛出了异常,程序就会调用terminate()终止程序,以确保不在运行时抛出异常的承诺。对释放执行栈展开未做约定。

因此,noexcept可以在2种情况下使用:

  1. 我们确认函数不会抛出异常;
  2. 我们根本不知道如何处理异常。

使用noexcept,通常意味着承诺函数不会抛出异常。

TIPS:通常,编译器不能也不必在编译时验证异常说明。

向后兼容:异常说明
早期C++版本,函数可以指定关键字throw,在后面括号跟上异常类型列表,来说明函数可能抛出的异常类型。throw的位置跟noexcept相同。

// 下面2个声明等价,都表示函数recoup不会抛出异常
void recoup(int) noexcept;
void recoup(int) throw();

函数说明的实参

noexcept 说明符接受一个可选实参,该实参必须能转换为bool类型:如果实参是true,则不会抛出异常;如果是false,则函数可能抛出异常。

void recoup(int) noexcept(true);  // recoup不会抛出异常
void recoup(int) noexcept(false); // recoup可能抛出异常

noexcept运算符

noexcept说明符的实参经常与noexcept运算符(noexcept operator)混合使用。noexcept运算符是一元运算符,其返回值是一个bool类型的右值常量表达式,用于表示给定的表达式是否会抛出异常。和sizeof类似,noexcept也不会求其运算符对象的值。

例如,我们已声明recoup() 为noexcept,因此下面表达式的返回值为true:

noexcept(recoup(i)); // 如果recoup不抛出异常,则结果为true;否则结果为false

更普通形式:

noexcept(e);

当e调用的所有函数都做了不抛出异常说明,且e本身不含有throw语句时,上面表达式为true;否则,为false。

例如,可以让函数f()是否抛出异常说明,跟g()保持一致:

void f() noexcept(noexcept(g())); // f和g的异常说明一致

noexcept两层含义:
1)作为noexcept说明符,承诺修饰的函数不抛出异常;
2)作为noexcept运算符,返回值取决于参数是否(承诺)抛出异常。

异常说明与指针、virtual函数、copy控制

noexcept说明符并非函数类型的一部分,不过函数的异常说明会影响函数的使用。

函数指针及该指针所指函数,必须具有一致的异常说明。
例如,

// recoup和pf1都承诺不会抛出异常
void (*pf1)(int) noexcept = recoup;
// 正确:recoup不会抛出异常,pf2可能抛出异常,二者之间互不干扰
void (*pf2)(int) = recoup; 

pf1 = alloc; // 错误:pf1已经做了不抛出异常说明,但alloc可能抛出异常
pf2 = alloc; // 正确:pf2和alloc都可能抛出异常

如果一个virtual函数承诺不会抛出异常,那么派生出来的virtual函数也必须做同样的承诺。相反,如果基类virtual函数允许抛出异常,那么派生出来的virtual函数既可以抛出异常,也可以不抛出异常。

异常类层次

标准库异常类(std::exception)构成如下图所示的继承体系:

  • 类型exception仅定义了copy ctor(copy构造函数)、copy assignment、virtual dtor(虚析构函数)、一个名为what的virtual函数成员。
    其中,what函数返回一个const char*,该指针指向一个以unll结尾的字符数组,并且确保不会抛出任何异常。

  • 类exception、bad_cast和bad_alloc定义了default ctor。

  • 类runtime_error和logic_error没有default ctor,但有一个可以接受C风格字符串或标准库string类型实参的ctor,这些实参由用户提供,包含关于错误的更多信息。

在这些类中,virtual函数what()负责返回用于初始化异常对象的信息。

推荐阅读