第9章 异常

学习目标

★ 熟悉C++中异常的处理方式

★ 理解栈解旋机制

★ 了解C++标准异常

★ 了解静态断言的作用

读者在编写程序的过程中,难免会出现一些错误,例如,除零错误、指针访问受保护空间、数组越界、访问的文件不存在等,这些错误会导致程序运行失败,像这些导致程序运行失败的错误,通常称为异常。为了确保程序的高容错性,开发者在编写程序过程中,需要对这些异常进行处理,防止系统崩溃。大多数常见的编程语言都提供了异常处理机制,C++也不例外,本章将针对C++的异常处理机制进行详细讲解。

9.1 异常处理方式

在C++中,如果函数在调用时发生异常,异常通常会被传递给函数的调用者进行处理,而不在发生异常的函数内部处理。如果函数调用者也不能处理异常,则异常会继续向上一层调用者传递,直到异常被处理为止。如果最终异常没有被处理,则C++运行系统就会捕捉异常,终止程序运行。

C++的异常处理机制使得异常的引发和处理不必在同一函数中完成,函数的调用者可以在适当的位置对函数抛出的异常进行处理。这样,底层的函数可以着重解决具体的业务问题,而不必考虑对异常的处理。

C++的异常处理通过throw关键字和try…catch语句结构实现。通常情况下,被调用的函数如果发生异常,就通过throw关键字抛出异常,而函数的上层调用者通过try…catch语句检测、捕获异常,并对异常进行处理。

throw关键字抛出异常的语法格式如下所示:

throw 表达式; 

在上述格式中,throw后面的表达式可以是常量、变量或对象。如果函数调用中出现异常,就可以通过throw将表示异常的表达式抛给它的调用者。

函数调用者通过try…catch语句捕获、处理异常,try…catch语句的语法格式如下所示:

try 
{ 
 …       //可能会出现异常的代码 
} 
catch (异常类型1) 
{ 
 …        //异常处理代码 
} 
 catch (异常类型2)  
{ 
 …        //异常处理代码 
} 
… 
catch (异常类型n)  
{ 
 …        //异常处理代码 
} 
catch (...) 
{ 
 …        //异常处理代码 
} 

在上述语法格式中,try语句块用于检测可能发生异常的代码(函数调用),如果这段代码抛出了异常,则catch语句会依次对抛出的异常进行类型匹配,如果某个catch语句中的异常类型与抛出的异常类型相同,则该catch语句就捕获异常并对异常进行处理。

在使用try…catch语句时,有以下几点需要注意。

(1)一个try…catch语句中只能有一个try语句块,但可以有多个catch语句块,以便与不同的异常类型匹配。catch语句必须有参数,如果try语句块中的代码抛出了异常,无论抛出的异常的值是什么,只要异常的类型与catch语句的参数类型匹配,异常就会被catch语句捕获。最后一个catch语句参数为“…”符号,表示可以捕获任意类型的异常。

(2)一旦某个catch语句捕获到了异常,后面的catch语句将不再被执行,其用法类似switch…case语句。

(3)try和catch语句块中的代码必须使用大括号“{}”括起来,即使语句块中只有一行代码。

(4)try语句和catch语句不能单独使用,必须连起来一起使用。

在使用try…catch语句处理异常时,如果try语句块中的某一行代码抛出了异常,则无论异常是否被处理,抛出异常的语句后面的代码都不再被执行。例如,有如下代码:

try 
{ 
 func(); 
 add(); 
 cout << 3/0<< endl; 
} 
catch (int) 
{ 
 cout << "异常处理" << endl; 
} 
cout << "异常处理完毕,程序从此处开始向下执行" << endl; 

在上述代码中,如果try语句块中的func()函数调用抛出了异常,并且catch语句成功捕获到了异常,则异常处理结束之后,程序会执行try…catch语句后面的代码,而不会执行try语句块中的add()函数。

下面通过案例演示C++的异常处理,如例9-1所示。

例9-1 tryCatch.cpp

 1  #include<iostream> 
 2  #include<fstream> 
 3  using namespace std; 
 4 class AbstractException    //定义抽象异常类AbstractException 
 5  { 
 6  public: 
 7       virtual void printErr() = 0;   //纯虚函数printErr() 
 8  }; 
 9  //定义文件异常类FileException公有继承AbstractException 
 10 class FileException : public AbstractException 
 11 { 
 12 public:
 13      virtual void printErr()    //实现printErr()函数 
 14      { 
 15           cout << "错误:文件不存在" << endl; 
 16      } 
 17 }; 
 18 //定义整除异常类DivideException公有继承AbstractException 
 19 class DivideException:public AbstractException 
 20 { 
 21 public: 
 22      virtual void printErr()    //实现printErr()函数 
 23      { 
 24           cout << "错误:除零异常" << endl; 
 25      } 
 26 }; 
 27 void readFile()     //定义readFile()函数 
 28 { 
 29      ifstream ifs("log.txt");   //创建文件输入流对象ifs并打开log.txt文件 
 30      if(!ifs)       //如果文件打开失败 
 31      { 
 32           throw FileException();  //抛出异常 
 33      } 
 34      ifs.close();      //关闭文件 
 35 } 
 36 void divide()      //定义divide()函数 
 37 { 
 38      int num1 = 100; 
 39      int num2 = 2; 
 40      if(num2 == 0)      //如果除数num2为0 
 41      { 
 42           throw DivideException();   //抛出异常 
 43      } 
 44      int ret = num1/num2; 
 45      cout << "两个数相除结果:" << ret << endl; 
 46 } 
 47 int main() 
 48 { 
 49      try   
 50      { 
 51           readFile();     //检测readFile()函数调用 
 52           divide();      //检测divide()函数调用 
 53      } 
 54      catch(FileException& fex)  //捕获FileException&类型异常 
 55      { 
 56           fex.printErr();    //调用相应函数输出异常信息 
 57      } 
 58      catch(DivideException& dex)   //捕获DivideException&类型异常 
 59      { 
 60           dex.printErr(); 
 61      } 
 62      catch(...)      //捕获任意类型异常  
 63      { 
 64           cout << "处理其他异常" << endl; 
 65      } 
 66      cout << "程序执行结束" << endl; 
 67      return 0; 
 68 }

例9-1运行结果如图9-1所示。

图9-1 例9-1运行结果

在例9-1中,第4~8行代码定义了异常类AbstractException,AbstractException类是一个抽象类,该类声明了纯虚函数printErr()。第10~17行代码定义了文件异常类FileException,该类公有继承AbstractException类,并实现了printErr()函数,用于输出“文件不存在”的错误提示信息。第19~26行代码定义异常类D ivideException,该类公有继承AbstractException类,并实现了printErr()函数,用于输出除数为0的错误提示信息。

第27~35行代码定义了readFile()函数,在该函数中,创建了文件输入流对象ifs用于读取文件,如果文件不存在,就抛出FileException类型的异常。第36~46行代码定义了divide()函数,在该函数中,定义两个整数相除,如果除数为0,就抛出D ivideException类型的异常。

第49~65行代码在m ain()函数中使用try…catch语句检测并捕获异常。在try语句块中,检测readFile()函数和divide()函数调用,如果有异常抛出,则通过catch语句捕获异常。第一个catch语句捕获FileException&类型的异常,第二个catch语句捕获D ivideException&类型的异常,第三个catch语句捕获任意类型的异常。

由图9-1可知,程序输出了“文件不存在”的错误提示信息,这表明在调用readFile()函数时,由于文件log.txt不存在,readFile()抛出了异常,通过第54行代码的catch语句捕获了该异常,在catch语句块中通过对象fex调用printErr()函数输出了异常信息。同时,由图9-1还可知,catch语句捕获异常之后,程序直接执行了第66行代码,并没有返回执行第52行代码的divide()函数。

9.2 栈解旋

C++不仅能够处理各种不同类型的异常,还可以在异常处理前释放所有局部对象。从进入try语句块开始到异常被抛出之前,在栈上创建的所有对象都会被析构,析构的顺序与构造的顺序相反,这一过程称为栈解旋或栈自旋。

下面通过案例演示栈的解旋过程,如例9-2所示。

例9-2 stackUnwinding.cpp

 1  #include<iostream> 
 2  using namespace std; 
 3 class Shape     //定义形状类Shape 
 4  { 
 5  public: 
 6       Shape();      //构造函数 
 7       ~Shape();      //析构函数 
 8       static int count;    //静态成员变量count 
 9  }; 
 10 int Shape::count = 0;    //count初始值为0 
 11 Shape::Shape()     //实现构造函数 
 12 { 
 13      count++; 
 14      if(Shape::count == 3) 
 15           throw "纸张画不下啦!!"; 
 16      cout << "Shape构造函数" << endl; 
 17 } 
 18 Shape::~Shape()    //实现析构函数 
 19 { 
 20      cout << "Shape析构函数" << endl; 
 21 } 
 22 int main() 
 23 { 
 24      Shape circle;     //画圆形 
 25      try       //try语句块检测可能抛出异常的代码 
 26      { 
 27           int num = 2;   //定义int类型变量num,表示纸张可画两个图形 
 28           cout << "纸张可画图形个数:" << num << endl; 
 29           Shape rectangle;  //画长方形 
 30           Shape triangle;   //画三角形 
 31      } 
 32      catch(const char* e)   //捕获异常 
 33      { 
 34           cout << e << endl; 
 35      } 
 36      return 0; 
 37 }

例9-2运行结果如图9-2所示。

在例9-2中,第3~9行代码定义了形状类Shape,该类声明了一个静态成员变量count,用于记录Shape类对象的个数。此外,Shape类还声明了构造函数和析构函数。第10~21行代码,在类外初始化count的值为0,并实现类的构造函数与析构函数。在实现构造函数时,如果count值为3,就抛出一个异常,提示纸张画不下的异常信息。

图9-2 例9-2运行结果

第24行代码创建Shape类对象circle,表示画了一个圆形。第25~31行代码,在try语句块中检测可能抛出异常的代码。第27行代码定义int类型变量num为2,表示纸张可画两个图形;第29~30行代码,创建Shape类对象rectangle和triangle。第32~35行代码通过catch语句捕获char*类型异常并输出异常信息。

由图9-2可知,程序抛出了异常,catch语句捕获并输出了异常提示信息:“纸张画不下啦!!”程序在运行时,首先调用Shape类的构造函数创建了对象circle;然后进入try语句块,定义num变量并输出;最后创建Shape类对象rectangle,对象rectangle创建成功之后,内存中有两个Shape类对象,当程序再创建对象triangle时,count值为3,就抛出了异常,对象triangle并没有创建成功。

在抛出异常之前,程序会将try语句块中创建的对象(num和rectangle)都释放。因此,在图9-2中,异常信息输出之前调用了一次Shape析构函数,用来析构对象rectangle。在try语句块之外创建的对象circle,待异常处理完成之后才析构。因此,在图9-2中,异常信息输出之后又调用了一次Shape析构函数。

需要注意的是,栈解旋只能析构栈上的对象,不会析构动态对象。

9.3 标准异常

C++提供了一组标准异常类,这些类以exception为根基类,程序中抛出的所有标准异常都继承自exception类。exception类定义在exception头文件中,C++11标准对exception类的定义如下所示:

class exception{ 
public: 
  exception () noexcept; 
  exception (const exception&) noexcept; 
  exception& operator=(const exception&) noexcept; 
  virtual ~exception(); 
  virtual const char* what() const noexcept; 
}; 

由上述定义可知,exception类提供了多个函数,其中,what()函数用于描述异常相关信息。what()函数是一个虚函数,exception类的派生类可以对what()函数重新定义,以便更好地描述派生类的异常信息。

exception类派生了多个类,这些派生类又作为基类派生出了新的类,因此,以exception为根基类的标准异常类是一个庞大的异常类库。标准异常类的继承关系如图9-3所示。

在图9-3中,logic_error类与runtim e_error类是exception类主要的两个派生类,它们都定义在stdexcept头文件中。logic_error类表示那些可以在程序中被预先检测到的异常,即通过检查或编译可以发现的错误。runtim e_error则表示运行时的异常,这类错误只有在程序运行时才能被检测到。logic_error类和runtim e_error类都有一个带有const string&类型参数的构造函数,构造异常对象时,可以将错误信息传递给该参数,通过调用异常对象的what()函数,可以得到构造时提供的异常信息。

图9-3 标准异常类的继承关系

标准异常类被定义在不同的头文件中,读者可通过查阅附录Ⅱ查看标准异常类所属的头文件及含义。下面通过案例演示标准异常类的使用,如例9-3所示。

例9-3 stdexception.cpp

 1  #include<iostream> 
 2  using namespace std; 
 3 class Animal     //定义动物类Animal 
 4  { 
 5  public: 
 6       virtual void speak();  //声明虚函数speak() 
 7  }; 
 8 void Animal::speak()   //类外实现speak()函数 
 9  { 
 10      cout << "动物叫声" << endl; 
 11 } 
 12 class Cat:public Animal   //猫类Cat公有继承Animal类 
 13 { 
 14 public: 
 15      virtual void speak();  //声明虚函数speak() 
 16 }; 
 17 void Cat::speak()    //类外实现speak()函数 
 18 { 
 19      cout << "小猫喵喵叫" << endl; 
 20 } 
 21 int main() 
 22 { 
 23      Animal animal;    //创建Animal类对象animal 
 24      Animal& ref = animal;  //定义Animal类引用ref 
 25      ref.speak();     //通过Animal的引用ref调用speak()函数 
 26      try 
 27      { 
 28           //将引用ref强制转换为Cat&类型 
 29           Cat& cat = dynamic_cast<Cat&>(ref); 
 30           cat.speak(); 
 31      } 
 32      catch(bad_cast& ex)   //捕获异常,bad_cast标准异常 
 33      { 
 34           cout << ex.what() << endl; 
 35      } 
 36      return 0; 
 37 }

例9-3运行结果如图9-4所示。

图9-4 例9-3运行结果

在例9-3中,第3~7行代码定义了动物类Anim al,该类声明了一个虚函数speak()。第12~16行代码定义了猫类Cat公有继承Anim al类,Cat类声明了虚函数speak()。第23~25行代码创建了Anim al类对象anim al;定义了Anim al类引用ref,使用对象anim al为ref初始化,并通过引用ref调用speak()函数。第26~31行代码检测第29~30行代码是否抛出异常。第29行代码通过dynam ic_cast转换运算符将Anim al类型的引用ref强制转换为Cat类型的引用,并赋值给Cat类型的引用cat。由于引用ref指向的是基类对象anim al,因此在将其转换为派生类引用时会发生bad_cast异常。第32~35行代码通过catch语句捕获bad_cast类型的标准异常,如果捕获到异常,就调用what()函数输出异常信息。由图9-4可知,程序在运行时抛出了bad_cast标准异常,提示异常信息:“Bad dynam ic_cast!”

小提示:noexcept关键字

在exception类定义中,noexcept关键字表示函数不抛出异常。noexcept关键字是C++11新增的关键字,在C++11之前,如果一个函数不抛出异常,则在函数后面添加throw()函数,示例代码如下所示:

void func() throw();

相比于throw(),noexcept关键字有利于编译器优化代码,但它并不适用于任何地方。在指定函数是否抛出异常时,如果读者对noexcept关键字不是很熟悉,尽量使用throw(),而不要轻易使用noexcept关键字。

9.4 静态断言

断言是编程中常用的一种调试手段。在C++11之前,C++使用assert()宏进行断言。但是,assert()宏只能在程序运行时期执行,这意味着不运行程序将无法检测到断言错误。如果每次断言都要执行一遍程序,则检测效率就会降低。另外,对于C++中使用较多的模板来说,模板实例化是在编译阶段完成的,assert()断言不能在编译阶段完成对模板实例化的检测。

为此,C++11引入了静态断言static_assert,用于实现编译时断言。静态断言的语法格式如下所示:

static_assert(常量表达式, 提示字符串); 

在上述语法格式中,static_assert有两个参数:第一个参数是一个常量表达式,即断言表达式;第二个参数是一个字符串。在执行断言时,编译器首先检测“常量表达式”的值,若常量表达式的值为真,则static_assert()不做任何操作,程序继续完成编译;若常量表达式的值为假,则static_assert()产生一条编译错误提示,错误提示的内容就是第二个参数。

下面通过案例演示静态断言的用法,如例9-4所示。

例9-4 staticAssert.cpp

 1  #include<iostream> 
 2  using namespace std; 
 3  template<typename T,typename U>   
 4 void func(T& t, U& u)       //定义函数模板func() 
 5  { 
 6       static_assert(sizeof(t) == sizeof(u),    //静态断言 
 7            "the parameters must be the same width."); 
 8       cout << t << "与 << u << "字节大小相同" << endl; 
 9  } 
 10 int main() 
 11 { 
 12      int x = 100;        //定义变量 
 13    int y = 20;        //定义变量 
 14    char ch = 'a';        //定义变量 
 15    func(x, y);        //调用func()函数 
 16    func(x, ch);        //调用func()函数 
 17      return 0; 
 18 }

编译例9-4,结果如图9-5所示。

图9-5 例9-4编译结果

在例9-4中,第3~9行代码定义了函数模板func(),在函数内部,使用static_assert()判断两个参数的字节大小是否相同,如果不相同,即静态断言失败,则输出提示信息“the param eters must be the sam e width”。第12~14行代码,定义了两个int类型的变量x、y和一个char类型的变量ch。第15行代码调用func()函数,传入x与y作为参数,由于传入的参数都为int类型,因此程序编译不会出错。第16行代码调用func()函数,传入x与ch作为参数,由于x为int类型,ch为char类型,因此,程序在编译时静态断言失败,编译器报错。由图9-5可知,编译错误就是由于第16行代码断言失败。如果注释掉第16行代码,则程序编译通过。

需要注意的是,static_assert()断言表达式的结果必须在编译阶段就可以计算出来,即必须是常量表达式。如果使用了变量,则会导致编译错误,示例代码如下:

void set_age(const int n)  
{   
    static_assert(n > 0, "The age should be greater than zero!");   
} 

上述代码中,在static_assert()中使用了参数变量n,编译时报错“表达式的计算结果不是常数”。

9.5 本章小结

本章主要讲解了C++中的异常处理机制。异常处理机制通过抛出异常与处理异常的分离,使程序每个部分只完成自己的本职工作而互不干扰,保证了程序的高容错性。静态断言是C++程序常用的程序调试手段,使用静态断言可以在编译阶段查找出程序中存在的逻辑错误,从而提高程序的编译效率。学习完本章,读者可以了解C++中异常处理的方式和规则。

9.6 本章习题

一、填空题

1.C++中,抛出异常的关键字是___。

2.C++标准异常库以___类为根基类。

3.C++标准异常类中,___类表示运行时异常。

4. 在C++中,宏定义___表示静态断言。

二、判断题

1.try…catch语句中可以有多个try语句。( )

2.try…catch语句可以分开,单独使用。( )

3.try语句块中代码抛出异常后,如果异常被正确处理,抛出异常代码后面的程序会继续执行。( )

4. 栈解旋会把try语句块中的所有对象都释放,包括堆内存上的对象。( )

5.C++标准异常类都定义了what()函数,用于描述异常信息。( )

6. 使用静态断言,程序可以在编译时检测错误。( )

三、选择题

1. 关于函数声明“float func(int a,int b)throw;”,下列描述中正确的是( )。

A.表明函数抛出float类型异常

B.表明函数可抛出任何类型异常

C.表明函数不抛出任何类型异常

D.表明函数可能抛出异常

2. 关于C++异常处理的流程,下列说法中错误的是( )。

A.对某段可能产生异常的代码或函数使用try结构进行检测

B.如果在执行try结构期间没有引起异常,则跟在try后面的catch结构不会执行

C.如果在执行try结构期间发生异常,在异常发生的位置使用throw抛出异常,一个异常对象将被创建

D.本层try语句抛出了异常,只能由本层的catch语句处理

3. 关于栈解旋,下列说法中正确的是( )。

A.栈解旋时,对象的析构顺序与构造顺序相同

B.栈解旋只能释放栈上的对象

C.栈解旋可以释放堆上的对象

D.try语句块之外的对象也可以通过栈解旋释放

4. 关于标准库异常,下列说法中错误的是( )。

A.logic_error类表示那些可以在程序中被预先检测到的异常

B.异常基类exception定义在头文件exception中

C.exception类接口中的函数都有一个noexcept关键字,这表示exception类成员函数不会抛出任何异常

D.runtim e_error类不能被继承

5. 关于断言,下列说法中错误的是( )。

A.断言是调试程序的一种手段

B.static_assert是静态断言,即在程序编译时期检测错误

C.宏assert()用来在运行阶段实现断言

D.static_assert可以使用变量作为参数

四、简答题

1. 简述C++中的异常处理过程。

2. 简述static_assert与assert的区别。

五、编程题

请按照下列要求编写程序。

(1)定义一个异常类Cexception,有成员函数reason(),用来显示异常的类型。

(2)定义一个函数fun()触发异常,在主函数try语句块中调用fun(),在catch语句块中捕获异常,观察程序执行流程。