深入理解C++中的异常处理机制

jopen 9年前

异常处理

增强错误恢复能力是提高代码健壮性的最有力的途径之一,C语言中采用的错误处理方法被认为是紧耦合的,函数的使用者必须在非常靠近函数调用的地方编 写错误处理代码,这样会使得其变得笨拙和难以使用。C++中引入了异常处理机制,这是C++的主要特征之一,是考虑问题和处理错误的一种更好的方式。使用 错误处理可以带来一些优点,如下:

  • 错误处理代码的编写不再冗长乏味,并且不再和正常的代码混合在一起,程序员只需要编写希望产生的代码,然后在后面某个单独的区段里编写处理错误的嗲吗。多次调用同一个函数,则只需要某个地方编写一次错误处理代码。
  • 错误不能被忽略,如果一个函数必须向调用者发送一次错误信息。它将抛出一个描述这个错误的对象。

传统的错误处理和异常处理

在讨论异常处理之前,我们先谈谈C语言中的传统错误处理方法,这里列举了如下三种:

  • 在函数中返回错误,函数会设置一个全局的错误状态标志。
  • 使用信号来做信号处理系统,在函数中raise信号,通过signal来设置信号处理函数,这种方式耦合度非常高,而且不同的库产生的信号值可能会发生冲突
  • 使用标准C库中的非局部跳转函数 setjmp和longjmp ,这里使用setjmp和longjmp来演示下如何进行错误处理:
#include <iostream>  #include <setjmp.h>  jmp_buf static_buf; //用来存放处理器上下文,用于跳转    void do_jmp()  {      //do something,simetime occurs a little error      //调用longjmp后,会载入static_buf的处理器信息,然后第二个参数作为返回点的setjmp这个函数的返回值      longjmp(static_buf,10);//10是错误码,根据这个错误码来进行相应的处理  }    int main()  {      int ret = 0;      //将处理器信息保存到static_buf中,并返回0,相当于在这里做了一个标记,后面可以跳转过来      if((ret = setjmp(static_buf)) == 0) {          //要执行的代码          do_jmp();      } else {    //出现了错误          if (ret == 10)              std::cout << "a little error" << std::endl;      }  }

错误处理方式看起来耦合度不是很高,正常代码和错误处理的代码分离了,处理处理的代码都汇聚在一起了。但是基于这种局部跳转的方式来处理代码,在 C++中却存在很严重的问题,那就是对象不能被析构,局部跳转后不会主动去调用已经实例化对象的析构函数。这将导致内存泄露的问题。下面这个例子充分显示 了这点

#include <iostream>  #include <csetjmp>    using namespace std;    class base {      public:          base() {              cout << "base construct func call" << endl;          }          ~base() {              cout << "~base destruct func call" << endl;          }  };    jmp_buf static_buf;    void test_base() {      base b;      //do something      longjmp(static_buf,47);//进行了跳转,跳转后会发现b无法析构了  }    int main() {      if(setjmp(static_buf) == 0) {          cout << "deal with some thing" << endl;          test_base();      } else {          cout << "catch a error" << endl;      }  }

在上面这段代码中,只有base类的构造函数会被调用,当longjmp发生了跳转后,b这个实例将不会被析构掉,但是执行流已经无法回到这里,b 这个实例将不会被析构。这就是局部跳转用在C++中来处理错误的时候带来的一些问题,在C++中异常则不会有这些问题的存在。那么接下来看看如何定义一个 异常,以及如何抛出一个异常和捕获异常吧.

异常的抛出

class MyError {      const char* const data;  public:      MyError(const char* const msg = 0):data(msg)      {          //idle      }  };    void do_error() {      throw MyError("something bad happend");  }    int main()  {      do_error();  }

上面的例子中,通过throw抛出了一个异常类的实例,这个异常类,可以是任何一个自定义的类,通过实例化传入的参数可以表明发生的错误信息。其实 异常就是一个带有异常信息的类而已。异常被抛出后,需要被捕获,从而可以从错误中进行恢复,那么接下来看看如何去捕获一个异常吧。在上面这个例子中使用抛 出异常的方式来进行错误处理相比与之前使用局部跳转的实现来说,最大的不同之处就是异常抛出的代码块中,对象会被析构,称之为堆栈反解.

异常的捕获

C++中通过catch关键字来捕获异常,捕获异常后可以对异常进行处理,这个处理的语句块称为异常处理器。下面是一个简单的捕获异常的例子:

    try{          //do something          throw string("this is exception");      } catch(const string& e) {          cout << "catch a exception " << e << endl;      }

catch有点像函数,可以有一个参数,throw抛出的异常对象,将会作为参数传递给匹配到到catch,然后进入异常处理器,上面的代码仅仅是 展示了抛出一种异常的情况,加入try语句块中有可能会抛出多种异常的,那么该如何处理呢,这里是可以接多个catch语句块的,这将导致引入另外一个问 题,那就是如何进行匹配。

异常的匹配

异常的匹配我认为是符合函数参数匹配的原则的,但是又有些不同,函数匹配的时候存在类型转换,但是异常则不然,在匹配过程中不会做类型的转换,下面的例子说明了这个事实:

#include <iostream>    using namespace std;  int main()  {      try{            throw 'a';      }catch(int a) {          cout << "int" << endl;      }catch(char c) {          cout << "char" << endl;      }  }

上面的代码的输出结果是char,因为抛出的异常类型就是char,所以就匹配到了第二个异常处理器。可以发现在匹配过程中没有发生类型的转换。将 char转换为int。尽管异常处理器不做类型转换,但是基类可以匹配到派生类这个在函数和异常匹配中都是有效的,但是需要注意catch的形参需要是引 用类型或者是指针类型,否则会 导致切割派生类这个问题。

//基类  class Base{      public:          Base(string msg):m_msg(msg)          {          }          virtual void what(){              cout << m_msg << endl;          }      void test()      {          cout << "I am a CBase" << endl;      }      protected:          string m_msg;  };  //派生类,重新实现了虚函数  class CBase : public Base  {      public:          CBase(string msg):Base(msg)          {            }          void what()          {             cout << "CBase:" << m_msg << endl;          }  };    int main()  {      try {          //do some thing      //抛出派生类对象          throw CBase("I am a CBase exception");        }catch(Base& e) {  //使用基类可以接收          e.what();      }  }

上面的这段代码可以正常的工作,实际上我们日常编写自己的异常处理函数的时候也是通过继承标准异常来实现字节的自定义异常的,但是如果将 Base&换成Base的话,将会导致对象被切割,例如下面这段代码将会编译出错,因为CBase被切割了,导致CBase中的test函数无法 被调用。

    try {          //do some thing          throw CBase("I am a CBase exception");        }catch(Base e) {          e.test();      }

到此为此,异常的匹配算是说清楚了,总结一下,异常匹配的时候基本上遵循下面几条规则:

异常匹配除了必须要是严格的类型匹配外,还支持下面几个类型转换.

  • 允许非常量到常量的类型转换,也就是说可以抛出一个非常量类型,然后使用catch捕捉对应的常量类型版本
  • 允许从派生类到基类的类型转换
  • 允许数组被转换为数组指针,允许函数被转换为函数指针

假想一种情况,当我要实现一代代码的时候,希望无论抛出什么类型的异常我都可以捕捉到,目前来说我们只能写上一大堆的catch语句捕获所有可能在 代码中出现的异常来解决这个问题,很显然这样处理起来太过繁琐,幸好C++提供了一种可以捕捉任何异常的机制,可以使用下列代码中的语法。

   catch(...) {      //异常处理器,这里可以捕捉任何异常,带来的问题就是无法或者异常信息     }

如果你要实现一个函数库,你捕捉了你的函数库中的一些异常,但是你只是记录日志,并不去处理这些异常,处理异常的事情会交给上层调用的代码来处理.对于这样的一个场景C++也提供了支持.

    try{          throw Exception("I am a exception");          }catch(...) {          //log the exception          throw;      }

通过在catch语句块中加入一个throw,就可以把当前捕获到的异常重新抛出.在异常抛出的那一节中,我在代码中抛出了一个异常,但是我没有使用任何catch语句来捕获我抛出的这个异常,执行上面的程序会出现下面的结果.

terminate called after throwing an instance of 'MyError'  Aborted (core dumped)

为什么会出现这样的结果呢?,当我们抛出一个异常的时候,异常会随着函数调用关系,一级一级向上抛出,直到被捕获才会停止,如果最终没有被捕获将会 导致调用terminate函数,上面的输出就是自动调用terminate函数导致的,为了保证更大的灵活性,C++提供了set_terminate 函数可以用来设置自己的terminate函数.设置完成后,抛出的异常如果没有被捕获就会被自定义的terminate函数进行处理.下面是一个使用的 例子:

#include <exception>  #include <iostream>  #include <cstdlib>  using namespace std;    class MyError {      const char* const data;  public:      MyError(const char* const msg = 0):data(msg)      {          //idle      }  };    void do_error() {      throw MyError("something bad happend");  }  //自定义的terminate函数,函数原型需要一致  void terminator()  {      cout << "I'll be back" << endl;      exit(0);  }    int main()  {      //设置自定义的terminate,返回的是原有的terminate函数指针      void (*old_terminate)() = set_terminate(terminator);      do_error();  }  上面的代码会输出I'll be back

到此为此关于异常匹配的我所知道的知识点都已经介绍完毕了,那么接着可以看看下一个话题,异常中的资源清理.

异常中的资源清理

在谈到局部跳转的时候,说到局部调转不会调用对象的析构函数,会导致内存泄露的问题,C++中的异常则不会有这个问题,C++中通过堆栈反解将已经 定义的对象进行析构,但是有一个例外就是构造函数中如果出现了异常,那么这会导致已经分配的资源无法回收,下面是一个构造函数抛出异常的例子:

#include <iostream>  #include <string>  using namespace std;    class base  {      public:          base()          {              cout << "I start to construct" << endl;              if (count == 3) //构造第四个的时候抛出异常                  throw string("I am a error");              count++;          }            ~base()          {              cout << "I will destruct " << endl;          }      private:          static int count;  };    int base::count = 0;    int main()  {          try{                base test[5];            } catch(...){                cout << "catch some error" << endl;            }  }  上面的代码输出结果是:  I start to construct  I start to construct  I start to construct  I start to construct  I will destruct   I will destruct   I will destruct   catch some error

在上面的代码中构造函数发生了异常,导致对应的析构函数没有执行,因此实际编程过程中应该避免在构造函数中抛出异常,如果没有办法避免,那么一定要 在构造函数中对其进行捕获进行处理.最后介绍一个知识点就是函数try语句块,如果main函数可能会抛出异常该怎么捕获?,如果构造函数中的初始化列表 可能会抛出异常该怎么捕获?下面的两个例子说明了函数try语句块的用法:

#include <iostream>    using namespace std;    int main() try {      throw "main";  } catch(const char* msg) {      cout << msg << endl;      return 1;  }  main函数语句块,可以捕获main函数中抛出的异常.  class Base  {      public:          Base(int data,string str)try:m_int(data),m_string(str)//对初始化列表中可能会出现的异常也会进行捕捉         {              // some initialize opt         }catch(const char* msg) {                cout << "catch a exception" << msg << endl;         }        private:          int m_int;          string m_string;  };    int main()  {      Base base(1,"zhangyifei");  }

上面说了很多都是关于异常的使用,如何定义自己的异常,编写异常是否应该遵循一定的标准,在哪里使用异常,异常是否安全等等一系列的问题,下面会一一讨论的.

标准异常

C++标准库给我们提供了一系列的标准异常,这些标准异常都是从exception类派生而来,主要分为两大派生类,一类是 logic_error,另一类则是runtime_error这两个类在stdexcept头文件中,前者主要是描述程序中出现的逻辑错误,例如传递了 无效的参数,后者指的是那些无法预料的事件所造成的错误,例如硬件故障或内存耗尽等,这两者都提供了一个参数类型为std::string的构造函数,这 样就可以将异常信息保存起来,然后通过what成员函数得到异常信息.

#include <stdexcept>  #include <iostream>  #include <string>  using namespace std;    class MyError:public runtime_error {  public:      MyError(const string& msg = "") : runtime_error(msg) {}    };    //runtime_error logic_error 两个都是继承自标准异常,带有string构造函数  //  int main()  {      try {          throw MyError("my message");         }   catch(MyError& x) {          cout << x.what() << endl;          }  }

异常规格说明

假设一个项目中使用了一些第三方的库,那么第三方库中的一些函数可能会抛出异常,但是我们不清楚,那么C++提供了一个语法,将一个函数可能会抛出 的异常列出来,这样我们在编写代码的时候参考函数的异常说明即可,但是C++11中这中异常规格说明的方案已经被取消了,所以我不打算过多介绍,通过一个 例子看看其基本用法即可,重点看看C++11中提供的异常说明方案:

#include <exception>  #include <iostream>  #include <cstdio>  #include <cstdlib>  using namespace std;    class Up{};  class Fit{};  void g();  //异常规格说明,f函数只能抛出Up 和Fit类型的异常  void f(int i)throw(Up,Fit) {      switch(i) {          case 1: throw Up();          case 2: throw Fit();          }      g();  }    void g() {throw 47;}    void my_ternminate() {      cout << "I am a ternminate" << endl;      exit(0);  }    void my_unexpected() {      cout << "unexpected exception thrown" << endl;   //   throw Up();      throw 8;      //如果在unexpected中继续抛出异常,抛出的是规格说明中的    则会被捕捉程序继续执行      //如果抛出的异常不在异常规格说明中分两种情况      //1.异常规格说明中有bad_exception ,那么会导致抛出一个bad_exception      //2.异常规格说明中没有bad_exception 那么会导致程序调用ternminate函数     // exit(0);  }    int main() {   set_terminate(my_ternminate);   set_unexpected(my_unexpected);   for(int i = 1;i <=3;i++)   {       //当抛出的异常,并不是异常规格说明中的异常时       //会导致最终调用系统的unexpected函数,通过set_unexpected可以       //用来设置自己的unexpected汗函数      try {          f(i);          }catch(Up) {          cout << "Up caught" << endl;          }catch(Fit) {          cout << "Fit caught" << endl;          }catch(bad_exception) {          cout << "bad exception" << endl;          }   }  }

上面的代码说明了异常规格说明的基本语法,以及unexpected函数的作用,以及如何自定义自己的unexpected函数,还讨论了在 unexpected函数中继续抛出异常的情况下,该如何处理抛出的异常.C++11中取消了这种异常规格说明.引入了一个noexcept函数,用于表 明这个函数是否会抛出异常

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

此外还提供了noexecpt用来检测一个函数是否不抛出异常.

异常安全

异常安全我觉得是一个挺复杂的点,不光光需要实现函数的功能,还要保存函数不会在抛出异常的情况下,出现不一致的状态.这里举一个例子,大家在实现 堆栈的时候经常看到书中的例子都是定义了一个top函数用来获得栈顶元素,还有一个返回值是void的pop函数仅仅只是把栈顶元素弹出,那么为什么没有 一个pop函数可以 即弹出栈顶元素,并且还可以获得栈顶元素呢?

template<typename T> T stack<T>::pop()  {      if(count == 0)          throw logic_error("stack underflow");      else          return data[--count];  }

如果函数在最后一行抛出了一个异常,那么这导致了函数没有将退栈的元素返回,但是Count已经减1了,所以函数希望得到的栈顶元素丢失了.本质原 因是因为这个函数试图一次做两件事,1.返回值,2.改变堆栈的状态.最好将这两个独立的动作放到两个独立的函数中,遵守内聚设计的原则,每一个函数只做 一件事.我们 再来讨论另外一个异常安全的问题,就是很常见的赋值操作符的写法,如何保证赋值操作是异常安全的.

class Bitmap {...};  class Widget {      ...  private:      Bitmap *pb;    };  Widget& Widget::operator=(const Widget& rhs)  {      delete pb;      pb = new Bitmap(*rhs.pb);      return *this;  }

上面的代码不具备自我赋值安全性,倘若rhs就是对象本身,那么将会导致*rhs.pb指向一个被删除了的对象.那么就绪改进下.加入证同性测试.

Widget& Widget::operator=(const Widget& rhs)  {      If(this == rhs) return *this; //证同性测试      delete pb;      pb = new Bitmap(*rhs.pb);      return *this;  }

但是现在上面的代码依旧不符合异常安全性,因为如果delete pb执行完成后在执行new Bitmap的时候出现了异常,则会导致最终指向一块被删除的内存.现在只要稍微改变一下,就可以让上面的代码具备异常安全性.

Widget& Widget::operator=(const Widget& rhs)  {      If(this == rhs) return *this; //证同性测试      Bitmap *pOrig = pb;      pb = new Bitmap(*rhs.pb); //现在这里即使发生了异常,也不会影响this指向的对象      delete pOrig;      return *this;     }

这个例子看起来还是比较简单的,但是用处还是很大的,对于赋值操作符来说,很多情况都是需要重载的.