第8章 C++异常总结
开发程序是一项“烧脑”的工作,
- 程序员不但要经过长期的知识学习,
- 思维训练,
- 还要做到一丝不苟,注意每一个细节和边界。
即使这样,也不能防止程序出错。
程序的错误大致可以分为三种,分别是语法错误、逻辑错误和运行时错误:
- 语法错误在编译和链接阶段就能发现,只有 100% 符合语法规则的代码才能生成可执行程序。语法错误是最容易发现、最容易定位、最容易排除的错误,程序员最不需要担心的就是这种错误。
- 逻辑错误是说我们编写的代码思路有问题,不能够达到最终的目标,这种错误可以通过调试来解决。
- 运行时错误是指程序在运行期间发生的错误,例如除数为 0、内存分配失败、数组越界、文件不存在等。C++ 异常(Exception)机制就是为解决运行时错误而引入的。
at() 函数检测到下标越界会抛出一个异常,这个异常可以由程序员处理,但是我们在代码中并没有处理,所以系统只能执行默认的操作,也即终止程序执行。我们可以借助 C++ 异常机制来捕获上面的异常,避免程序崩溃。捕获异常的语法为:
try{ // 可能抛出异常的语句 }catch(exceptionType variable){ // 处理异常的语句 }
演示一下 try-catch 的用法,先让读者有一个整体上的认识。
#include <iostream> #include <string> #include <exception> using namespace std; int main(){ string str = "http://www.csdn.xyz"; try { char ch1 = str[100]; cout<<ch1<<endl; } catch(exception e) { cout<<"[1]out of bound!"<<endl; } try { char ch2 = str.at(100); cout<<ch2<<endl; } catch(exception &e) { //exception类位于<exception>头文件中 cout<<"[2]out of bound!"<<endl; } return 0; }
异常的处理流程:
抛出(Throw)--> 检测(Try) --> 捕获(Catch)
异常可以发生在当前的 try 块中,也可以发生在 try 块所调用的某个函数中,或者是所调用的函数又调用了另外的一个函数,这个另外的函数中发生了异常。这些异常,都可以被 try 检测到。
- 下面的例子演示了 try 块中直接发生的异常:
#include <iostream> #include <string> #include <exception> using namespace std; int main(){ try{ throw "Unknown Exception"; //抛出异常 cout<<"This statement will not be executed."<<endl; }catch(const char* &e){ cout<<e<<endl; } return 0; }
- 下面的例子演示了 try 块中调用的某个函数中发生了异常:
#include <iostream> #include <string> #include <exception> using namespace std; void func(){ throw "Unknown Exception"; //抛出异常 cout<<"[1]This statement will not be executed."<<endl; } int main(){ try{ func(); cout<<"[2]This statement will not be executed."<<endl; }catch(const char* &e){ cout<<e<<endl; } return 0; }
- try 块中调用了某个函数,该函数又调用了另外的一个函数,这个另外的函数抛出了异常:
#include <iostream> #include <string> #include <exception> using namespace std; void func_inner(){ throw "Unknown Exception"; //抛出异常 cout<<"[1]This statement will not be executed."<<endl; } void func_outer(){ func_inner(); cout<<"[2]This statement will not be executed."<<endl; } int main(){ try{ func_outer(); cout<<"[3]This statement will not be executed."<<endl; }catch(const char* &e){ cout<<e<<endl; } return 0; }
exceptionType是异常类型,它指明了当前的 catch 可以处理什么类型的异常;variable是一个变量,用来接收异常信息。当程序抛出异常时,会创建一份数据,这份数据包含了错误信息,程序员可以根据这些信息来判断到底出了什么问题,接下来怎么处理。 异常既然是一份数据,那么就应该有数据类型。C++ 规定,
- 异常类型可以是 int、char、float、bool 等基本类型,
- 也可以是指针、数组、字符串、结构体、类等聚合类型。
- C++ 语言本身以及标准库中的函数抛出的异常,都是 exception 类或其子类的异常。也就是说,抛出异常时,会创建一个 exception 类或其子类的对象。
exceptionType variable和函数的形参非常类似,当异常发生后,会将异常数据传递给 variable 这个变量,这和函数传参的过程类似。当然,只有跟exceptionType类型匹配的异常数据才会被传递给 variable,否则 catch 不会接收这份异常数据,也不会执行 catch 块中的语句。换句话说,catch 不会处理当前的异常。 我们可以将 catch 看做一个没有返回值的函数,当异常发生后 catch 会被调用,并且会接收实参(异常数据)。 但是 catch 和真正的函数调用又有区别:
- 真正的函数调用,形参和实参的类型必须要匹配,或者可以自动转换,否则在编译阶段就报错了。
- 而对于 catch,异常是在运行阶段产生的,它可以是任何类型,没法提前预测,所以不能在编译阶段判断类型是否正确,只能等到程序运行后,真的抛出异常了,再将异常类型和 catch 能处理的类型进行匹配,匹配成功的话就“调用”当前的 catch,否则就忽略当前的 catch。
总起来说,catch 和真正的函数调用相比,多了一个「在运行阶段将实参和形参匹配」的过程。
另外需要注意的是,如果不希望 catch 处理异常数据,也可以将 variable 省略掉,也即写作:
try{ // 可能抛出异常的语句 }catch(exceptionType){ // 处理异常的语句 }
这样只会将异常类型和 catch 所能处理的类型进行匹配,不会传递异常数据了。
一个 try 后面可以跟多个 catch:
try{ //可能抛出异常的语句 }catch (exception_type_1 e){ //处理异常的语句 }catch (exception_type_2 e){ //处理异常的语句 } //其他的catch catch (exception_type_n e){ //处理异常的语句 }
当异常发生时,程序会按照从上到下的顺序,将异常类型和 catch 所能接收的类型逐个匹配。一旦找到类型匹配的 catch 就停止检索,并将异常交给当前的 catch 处理(其他的 catch 不会被执行)。如果最终也没有找到匹配的 catch,就只能交给系统处理,终止程序的运行。
#include <iostream> #include <string> using namespace std; class Base{ }; class Derived: public Base{ }; int main(){ try{ throw Derived(); //抛出自己的异常类型,实际上是创建一个Derived类型的匿名对象 cout<<"This statement will not be executed."<<endl; }catch(int){ cout<<"Exception type: int"<<endl; }catch(char *){ cout<<"Exception type: cahr *"<<endl; }catch(Base){ //匹配成功(向上转型) cout<<"Exception type: Base"<<endl; }catch(Derived){ cout<<"Exception type: Derived"<<endl; } return 0; }
在 catch 中,我们只给出了异常类型,没有给出接收异常信息的变量。
- 算数转换:例如 int 转换为 float,char 转换为 int,double 转换为 int 等。
- 向上转型:也就是派生类向基类的转换,
- const 转换:也即将非 const 类型转换为 const 类型,例如将 char * 转换为 const char *。
- 数组或函数指针转换:如果函数形参不是引用类型,那么数组名会转换为数组指针,函数名也会转换为函数指针。
- 用户自定的类型转换。
catch 在匹配异常类型的过程中,也会进行类型转换,但是这种转换受到了更多的限制,仅能进行「向上转型」、「const 转换」和「数组或函数指针转换」,其他的都不能应用于 catch。
#include <iostream> using namespace std; int main(){ int nums[] = {1, 2, 3}; try{ throw nums; cout<<"This statement will not be executed."<<endl; }catch(const int *){ cout<<"Exception type: const int *"<<endl; } return 0; }
nums 本来的类型是int [3],但是 catch 中没有严格匹配的类型,所以先转换为int *,再转换为const int *。
异常必须显式地抛出,才能被检测和捕获到;如果没有显式的抛出,即使有异常也检测不到。在 C++ 中,我们使用 throw 关键字来显式地抛出异常,它的用法为:
throw exceptionData;
exceptionData 是“异常数据”的意思,它可以包含任意的信息,完全有程序员决定。exceptionData 可以是 int、float、bool 等基本类型,也可以是指针、数组、字符串、结构体、类等聚合类型,请看下面的例子:
char str[] = "http://www.baidu.com"; char *pstr = str; class Base{}; Base obj; throw 100; //int 类型 throw str; //数组类型 throw pstr; //指针类型 throw obj; //对象类型
一个动态数组的例子,
#include <iostream> #include <cstdlib> using namespace std; //自定义的异常类型 class OutOfRange{ public: OutOfRange(): m_flag(1){ }; OutOfRange(int len, int index): m_len(len), m_index(index), m_flag(2){ } public: void what() const; //获取具体的错误信息 private: int m_flag; //不同的flag表示不同的错误 int m_len; //当前数组的长度 int m_index; //当前使用的数组下标 }; void OutOfRange::what() const { if(m_flag == 1){ cout<<"Error: empty array, no elements to pop."<<endl; }else if(m_flag == 2){ cout<<"Error: out of range( array length "<<m_len<<", access index "<<m_index<<" )"<<endl; }else{ cout<<"Unknown exception."<<endl; } } //实现动态数组 class Array{ public: Array(); ~Array(){ free(m_p); }; public: int operator[](int i) const; //获取数组元素 int push(int ele); //在末尾插入数组元素 int pop(); //在末尾删除数组元素 int length() const{ return m_len; }; //获取数组长度 private: int m_len; //数组长度 int m_capacity; //当前的内存能容纳多少个元素 int *m_p; //内存指针 private: static const int m_stepSize = 50; //每次扩容的步长 }; Array::Array(){ m_p = (int*)malloc( sizeof(int) * m_stepSize ); m_capacity = m_stepSize; m_len = 0; } int Array::operator[](int index) const { if( index<0 || index>=m_len ){ //判断是否越界 throw OutOfRange(m_len, index); //抛出异常(创建一个匿名对象) } return *(m_p + index); } int Array::push(int ele){ if(m_len >= m_capacity){ //如果容量不足就扩容 m_capacity += m_stepSize; m_p = (int*)realloc( m_p, sizeof(int) * m_capacity ); //扩容 } *(m_p + m_len) = ele; m_len++; return m_len-1; } int Array::pop(){ if(m_len == 0){ throw OutOfRange(); //抛出异常(创建一个匿名对象) } m_len--; return *(m_p + m_len); } //打印数组元素 void printArray(Array &arr){ int len = arr.length(); //判断数组是否为空 if(len == 0){ cout<<"Empty array! No elements to print."<<endl; return; } for(int i=0; i<len; i++){ if(i == len-1){ cout<<arr[i]<<endl; }else{ cout<<arr[i]<<", "; } } } int main(){ Array nums; //向数组中添加十个元素 for(int i=0; i<10; i++){ nums.push(i); } printArray(nums); //尝试访问第20个元素 try{ cout<<nums[20]<<endl; }catch(OutOfRange &e){ e.what(); } //尝试弹出20个元素 try{ for(int i=0; i<20; i++){ nums.pop(); } }catch(OutOfRange &e){ e.what(); } printArray(nums); return 0; }
throw 关键字除了可以用在函数体中抛出异常,还可以用在函数头和函数体之间,指明当前函数能够抛出的异常类型,这称为异常规范(Exception specification),有些教程也称为异常指示符或异常列表。请看下面的例子:
double func (char param) throw (int);
这条语句声明了一个名为 func 的函数,它的返回值类型为 double,有一个 char 类型的参数,并且只能抛出 int 类型的异常。如果抛出其他类型的异常,try 将无法捕获,只能终止程序。
如果函数会抛出多种类型的异常,那么可以用逗号隔开:
double func (char param) throw (int, char, exception);
如果函数不会抛出任何异常,那么( )中什么也不写:
double func (char param) throw ();
如此,func() 函数就不能抛出任何类型的异常了,即使抛出了,try 也检测不到。
虚函数中的异常规范:
C++ 规定,派生类虚函数的异常规范必须与基类虚函数的异常规范一样严格,或者更严格。只有这样,当通过基类指针(或者引用)调用派生类虚函数时,才能保证不违背基类成员函数的异常规范。请看下面的例子:
class Base{ public: virtual int fun1(int) throw(); virtual int fun2(int) throw(int); virtual string fun3() throw(int, string); }; class Derived:public Base{ public: int fun1(int) throw(int); //错!异常规范不如 throw() 严格 int fun2(int) throw(int); //对!有相同的异常规范 string fun3() throw(string); //对!异常规范比 throw(int,string) 更严格 }
异常规范与函数定义和函数声明:
C++ 规定,异常规范在函数声明和函数定义中必须同时指明,并且要严格保持一致,不能更加严格或者更加宽松。 请看下面的几组函数:
//错!定义中有异常规范,声明中没有 void func1(); void func1() throw(int) { } //错!定义和声明中的异常规范不一致 void func2() throw(int); void func2() throw(int, bool) { } //对!定义和声明中的异常规范严格一致 void func3() throw(float, char*); void func3() throw(float, char*) { }
请抛弃异常规范,不要再使用它:
异常规范的初衷是好的,它希望让程序员看到函数的定义或声明后,立马就知道该函数会抛出什么类型的异常,这样程序员就可以使用 try-catch 来捕获了。如果没有异常规范,程序员必须阅读函数源码才能知道函数会抛出什么异常。 不过这有时候也不容易做到。例如,func_outer() 函数可能不会引发异常,但它调用了另外一个函数 func_inner(),这个函数可能会引发异常。再如,您编写的函数调用了老式的库函数,此时不会引发异常,但是库更新以后这个函数却引发了异常。总之,异常规范的初衷实现起来有点困难,所以大家达成的一致意见是,最好不要使用异常规范。 异常规范是 C++98 新增的一项功能,但是后来的 C++11 已经将它抛弃了,不再建议使用。
另外,各个编译器对异常规范的支持也不一样,请看下面的代码:
#include <iostream> #include <string> #include <exception> using namespace std; void func()throw(char*, exception){ throw 100; cout<<"[1]This statement will not be executed."<<endl; } int main(){ try{ func(); }catch(int){ cout<<"Exception type: int"<<endl; } return 0; }
- 在 GCC 下,这段代码运行到第 7 行时程序会崩溃。虽然 func() 函数中发生了异常,但是由于 throw 限制了函数只能抛出 char*、exception 类型的异常,所以 try-catch 将捕获不到异常,只能交给系统处理,终止程序。
- 在 Visual C++ 下,输出结果为Exception type: int,这说明异常被成功捕获了。在 Visual C++ 中使用异常规范虽然没有语法错误,但是也没有任何效果,Visual C++ 会直接忽略异常规范的限制,函数可以抛出任何类型的异常。
C++语言本身或者标准库抛出的异常都是 exception 的子类,称为标准异常(Standard Exception)。你可以通过下面的语句来捕获所有的标准异常:
try{ //可能抛出异常的语句 }catch(exception &e){ //处理异常的语句 }
exception 类位于 <exception> 头文件中,它被声明为:
class exception{ public: exception () throw(); //构造函数 exception (const exception&) throw(); //拷贝构造函数 exception& operator= (const exception&) throw(); //运算符重载 virtual ~exception() throw(); //虚析构函数 virtual const char* what() const throw(); //虚函数 }
这里需要说明的是 what() 函数。what() 函数返回一个能识别异常的字符串,正如它的名字“what”一样,可以粗略地告诉你这是什么异常。不过C++标准并没有规定这个字符串的格式,各个编译器的实现也不同,所以 what() 的返回值仅供参考。
下图展示了 exception 类的继承层次:
exception 类的直接派生类:
异常名称 | 说 明 |
---|---|
logic_error | 逻辑错误。 |
runtime_error | 运行时错误。 |
bad_alloc | 使用 new 或 new[ ] 分配内存失败时抛出的异常。 |
bad_typeid | 使用 typeid 操作一个 NULL 指针,而且该指针是带有虚函数的类,这时抛出 bad_typeid 异常。 |
bad_cast | 使用 dynamic_cast 转换失败时抛出的异常。 |
ios_base::failure | io 过程中出现的异常。 |
bad_exception | 这是个特殊的异常,如果函数的异常列表里声明了 bad_exception 异常,当函数内部抛出了异常列表中没有的异常时,如果调用的 unexpected() 函数中抛出了异常,不论什么类型,都会被替换为 bad_exception 类型。 |
logic_error 的派生类:
异常名称 | 说 明 |
---|---|
length_error | 试图生成一个超出该类型最大长度的对象时抛出该异常,例如 vector 的 resize 操作。 |
domain_error | 参数的值域错误,主要用在数学函数中,例如使用一个负值调用只能操作非负数的函数。 |
out_of_range | 超出有效范围。 |
invalid_argument | 参数不合适。在标准库中,当利用string对象构造 bitset 时,而 string 中的字符不是 0 或1 的时候,抛出该异常。 |
runtime_error 的派生类:
异常名称 | 说 明 |
---|---|
range_error | 计算结果超出了有意义的值域范围。 |
overflow_error | 算术计算上溢。 |
underflow_error | 算术计算下溢。 |