现在的位置: 首页 > 综合 > 正文

C++ Primer学习笔记——$17 异常

2018年03月30日 ⁄ 综合 ⁄ 共 6328字 ⁄ 字号 评论关闭

题记:本系列学习笔记(C++ Primer学习笔记)主要目的是讨论一些容易被大家忽略或者容易形成错误认识的内容。只适合于有了一定的C++基础的读者(至少学完一本C++教程)。

 
作者: tyc611, 2007-02-28


   本文主要讨论C++的异常机制及相关技术。
   如果文中有错误或遗漏之处,敬请指出,谢谢!


抛出异常
 
   因为在处理异常的时候会释放局部存储,所以被抛出的对象就不能再局部存储,而是用throw表达式初始化一个称为异常对象(exception object)的特殊对象。异常对象由编译器管理,而且保证驻留在可能被激活的catch都可访问的空间里。异常对象由throw创建,并被初始化为被抛出的表达式的副本。异常对象将传给对应的catch,并且在完全处理了异常之后销毁。
 
   异常以类似于将实参传递给函数的方式抛出(throw)捕获(catch)。异常对象是通过复制抛出表达式的结果而创建,该结果必须是可以复制的类型。
 
   我们知道,在传递数组或函数类型实参的时候,该实参自动转换为一个指针。被抛出的对象将发生同样的自动转换,因此,不存在数组或函数类型的异常。相反,如果抛出一个数组,被抛出的对象转换为指向数组首元素的指针;类似地,如果抛出一个函数,函数被转换为指向函数的指针。
 
   当抛出一个表达式的时候,被抛出对象的静态类型将决定异常对象的类型。需要特别注意的是,在抛出中对指针解引用时,解引用的结果是一个对象,其类型与指针的静态类型匹配。如果指针指向继承层次中的一种类型,指针所指对象的类型就有可能与指针的静态类型不同。无论对象的类型类型是什么,异常对象的类型都与指针的静态类型相匹配。如果该指针是一个指向派生类对象的基类类型指针,则那个对象将被切片,只抛出基类部分。
 
栈展开
 
   抛出异常时,将暂停当前函数的执行,开始查找匹配的catch子句。首先检查throw本身是否在try块内部,如果是,检查与该try相关的catch子句,看是否其中之一与被抛出对象相匹配。如果找到匹配的catch,就处理异常;如果找不到,就退出当前函数(释放当前函数内存并撤消局部对象),并且继续在调用函数中查找。如果对抛出异常的函数的调用是在try块中,则检查与该try块相关的catch子句。如果找到匹配的catch子句,就处理异常;否则,退出调用函数,并且继续在调用这个函数的函数中查找。这个过程,称之为栈展开(stack unwinding),沿嵌套链继续向上,直至为异常找到一个catch子句。如果最终找不到匹配的catch,程序就调用库函数terminate。
 
   析构函数应该从不抛出异常。栈展开期间会经常执行析构函数。此时,在执行析构函数时,已经引发了异常但还没有处理它。如果在这个时候析构函数本身抛出新的异常,则此时,应当是新的异常取代仍未处理的先前的异常?还是忽略析构函数中的异常?答案是:在为某个异常进行栈展开时,析构函数如果又抛出自己的未经处理的另一个异常,将会导致调用标准库terminate函数。一般而言,terminate函数将调用abort函数,强制从整个程序非正常退出。因此,标准库类型都保证它们的析构函数不会引发异常。
 
   与析构函数不同,构造函数内部所做的事情经常会抛出异常。如果在构造函数对象的时候发生异常,则该对象可能只是部分被构造,它的一些成员可能已经初始化,而另一些成员在异常发生之前还没有初始化。即使对象只是部分地构造了,也要保证将会适当地撤销已经构造的成员。类似地,在初始化数组或其他容器类型的元素的时候,也可能发生异常,同样,也要保证将会适当地撤销已构造的元素。
 
捕获异常
 
   catch子句中的异常说明符(exception specifier)的类型决定了处理代码能够捕获的异常种类。类型必须是完全类型,即必须是内置类型或者已经定义的自定义类型;类型的前向声明不行。如果不需要使用异常对象的话,异常说明符中的形参名可以省略。
 
   在查找匹配的catch期间,找到的catch不必是与异常最匹配的那个catch,相反,将选中第一个找到的可以处理该异常的catch。因此,在catch子句列表中,最特殊的catch必须最先出现。
 
   异常与catch异常说明符匹配的规则比匹配实参和形参类型的规则更严格,大多数转换都不允许——除下面几种可能的区别之外,异常的类型与catch说明符的类型必须完全匹配:
   1)允许从非const到const的转换。也就是说,非const对象的throw可以与指定接受const引用的catch匹配;
   2)允许从派生类型到基类类型的转换(引用和非引用);
   3)将数组转换为指向数组类型的指针,将函数转换为指向函数类型的适当指针。
在查找匹配catch的时候,不允许其他转换。具体而言,既不允许标准算术转换,也不允许为类类型定义的转换。
 
   异常说明符类型可以是引用。异常对象本身是被抛出对象的副本,是否再次将异常对象复制到catch位置取决于异常说明符类型。
 
重新抛出
 
   有可能单个catch不能完全处理一个异常,此时catch在做完自己的处理后,可以将该异常重新抛出(rethrow),以传递给外层的try块。重新抛出是后面不跟类型或表达式的一个空throw语句:
   throw;
该空throw语句将重新抛出异常对象,它只能出现在catch或者从catch调用的函数中。如果在处理代码不活动时碰到空throw,就调用terminate函数。
 
   虽然重新抛出不指定自己的异常,但仍然将一个异常对象沿链向上传递,被抛出的异常是原来的异常对象,而不是catch形参。因此,当catch形参是基类类型的时候,我们不知道重新抛出的异常的实际类型,该类型取决于异常对象的动态类型,而不是catch形参的静态类型。并且,当catch形参是引用时,若catch改变了它的形参对象(即原来的异常对象),则重新抛出时将传播那些改变(因为仍是原来的异常对象在传递)。
 
捕获所有异常
 
   捕获所有异常(catch-all)的catch子句形式为:catch (...)。catch (...)通常与重新抛出表达式结合使用,catch完成可做的所有局部工作,然后重新抛出异常。例如:
   void func() {
      try {
         // exception may be thrown
      }
      catch (...) {
         // partially handle the exception
         throw;
      }
   }
 
函数测试块
 
   对于一般的函数中的异常,我们可以直接在函数体内编写代码处理异常。但对于构造函数初始化列表中抛出的异常却无法这样实现异常处理。此时,必须将构造函数编写为函数测试块(function try block)。函数测试块将catch子句与函数联成一个整体。例如:
template <typename T>
void func(const T &t)
try: _t(t)
{
   // function body
} catch (const exception& ) { /* do something */ }
  
此时,catch子句既可以处理从初始化列表中抛出的异常,也可以处理从构造函数体中抛出的异常。
 
标准库异常类
   C++标准库异常类定义在四个头文件中:
   1) <exception>头文件中定义了异常类exception;
   2) <stdexcept>头文件中定义了几种常见的异常类。
   3) <new>头文件中定义了bad-alloc异常类。当new无法分配内存时将抛出该异常类对象。
   4) <type_info>头文件中定义了bad_cast异常类。当dynamic_cast失败时将抛出该异常类对象。
   标准异常类之间的关系:exception派生出runtime_error类、logic_error类、bad_cast类和bad_alloc类。由runtime_error派生出range_error、overflow_error、underflow_error;由logic_error派生出domain_error、invalid_argument、length_error、out_of_range。
 
标准异常类的详细列表
 exception  最常见的问题
 runtime_error  运行时错误:仅在运行时才能检测到的问题
 range_error  运行时错误:生成的结果超出了有意义的值域范围
 overflow_error  运行时错误:计算上溢
 underflow_error  运行时错误:计算下溢
 logic_error  逻辑错误:可在运行前检测到的问题
 domain_error  逻辑错误:域错误
 invalid_argument  逻辑错误:无效参数
 length_error  逻辑错误:试图生成一个超出该类型最大长度的对象
 out_of_range  逻辑错误:使用一个超出有效范围的值
注:运行时错误是指在某语句计算过程中产生的错误,逻辑错误是指在某语句执行前检查到的错误。
 
   exception、bad_alloc、bad_cast类型只定义了默认构造函数,而其它类型则只定义了一个使用string作为参数的构造函数。基类exception提供了一个what()成员函数,其返回const char*类型的C风格字符串。对于以string初始化的异常类,what()将返回该string对应的C风格字符串;否则返回的值是未定义的。
 
异常安全
 
   如果在动态分配的资源释放前,异常被抛出,导致程序跳过了释放资源的代码的执行,就不能保证正确地释放资源,从而这段代码不是异常安全的。要做到代码是异常安全的(exception safe),可以定义一个类来封装资源的分配和释放,可以保证:即使发生异常,也能正确释放资源。这一技术常称为“资源分配即初始化”,简称RAII。
 
   这种资源管理类的原型如下:
   class Resource {
   public:
      Resource (parms p): r(allocate(p) {}
      ~Resource() { release(r);}
      // also need to define copy and assignment
   private:
      resource_type *r;
      resource_type* allocate(parms p);
      void release(resource_type*);
   };
 
auto_ptr类
 
   标准库的auto_ptr类是前面异常安全介绍中的资源管理类的一个实现。auto_ptr类是接受一个类型形参的模板,它为动态分配的对象提供异常安全,并且提供了更多的操作,比如解引用操作符(*)和箭头操作符(->)的重载版本。auto_ptr类在头文件memory中定义。另外,auto_ptr类提供的操作(除了析构)都保证不会抛出异常。
 
                                Talbe: auto_ptr类
 auto_ptr<T> ap;  创建名为ap的未绑定的auto_ptr对象(实质是auto_ptr<T> ap(0))
 auto_ptr<T> ap(p);  创建名为ap的auto_ptr对象,ap拥有指针p指向的对象。该构造函数为explicit
 auto_ptr<T> ap1(ap2);  创建名为ap1的auto_ptr对象,ap1保存原来存储在ap2中的指针。将所有权转给ap1,ap2成为未绑定的auto_ptr对象
 ap1 = ap2;  将所有权从ap2转给ap1。删除ap1指向的对象并且使ap1指向ap2指向的对象,使ap2成为未绑定的
 ~ap  析构函数。删除ap所指向的对象
 *ap  返回对ap所绑定的对象的引用
 ap->  返回ap保存的指针
 ap.reset(p)  如果p与ap的值不同,则删除ap指向的对象并且将ap绑定到p
 ap.release()  返回ap所保存的指针并且使ap成为未绑定的
 ap.get()  返回ap保存的指针
 
   auto_ptr只能用于管理从new返回的一个对象,它不能管理动态分配的数组。正如我们所见,当auto_ptr被复制或赋值时,有不寻常的行为(所有权转移,而不是分享),因此,不能将auto_ptr存储在标准库容器类型中。
 
   有时,需要知道auto_ptr对象是否绑定,此时应使用get成员返回基础指针:若为0则未绑定;否则绑定。注意:不能用get作为创建其它auto_ptr对象的实参;否则违反auto_tpr的设计原则(任意时刻只有一个auto_ptr对象保存对象指针)。
 
   根据auto_ptr的设计原则,不要使用两个auto_ptr对象指向同一对象,导致这个错误的三种明显方式是:使用同一指针来初始化或者reset两个不同的auto_ptr对象,或者使用安然一个auto_ptr对象的get函数的结果来初始化或者reset另一个auto_ptr对象。
 
异常说明
 
   异常说明(exception specification)指定,如果函数抛出异常,被抛出的异常将是包含在该说明中的一种,或者是从列出的异常中派生的类型。异常说明跟在函数形参表之后,一个异常说明在关键字throw之后跟着一个(可能为空的)由圆括号括住的异常类型列表。例如:
   void func() throw (std::logic_error, std::runtime_error);
 
   异常说明是函数接口的一部分,函数定义及声明必须具有相同的异常说明。如果一个函数声明没有指定异常声明,则该函数可以抛出任意类型的异常。
 
   由于不可能在编译时知道程序是否抛出异常及会抛出哪些异常,只有在运行时才能检测是否违反函数异常说明。如果函数抛出了没有在其异常说明中列出的异常,就调用标准库函数unexpected。默认情况下,unexpected函数调用terminate函数。
 
   在编译时,编译器也不会试图验证异常说明。即使程序中可能抛出异常说明中没有的异常,编译器也不会给出提示。例如:
   void func() throw() {
      throw exception();
   }
对于上面的代码,编译器不会给出提示。
 
   基类中虚函数的异常说明,可以与派生类中对应虚函数的异常说明不同。但是,派生类虚函数的异常说明必须与对应基类虚函数的异常说明同样严格,或者比后者更加受限。这个限制保证,当使用指向基类类型的指针调用派生类虚函数的时候,派生类的异常说明不会增加新的可抛出异常。这样,在编写代码时,可以知道必须处理哪些可能的异常。
 
   异常说明是函数类型的一部分。因此,可以在函数指针的定义中提供异常说明。例如:
   void (*pf) (int) throw (runtime_error);
在用另一个指针初始化带异常说明的函数的指针,或者将后者赋值给函数地址的时候,两个指针的异常说明不必相同,但是,源指针的异常说明必须至少与目标指针的一样严格,或者更加受限。
 

   如果文中有错误或遗漏之处,敬请指出,谢谢!

参考文献:
[1] C++ Primer(Edition 4)
[2] Thinking in C++(Volume Two, Edition 2)
[3] International Standard:ISO/IEC 14882:1998

抱歉!评论已关闭.