C++学习-入门到精通【15】异常处理深入剖析
C++学习-入门到精通【15】异常处理深入剖析
目录
- C++学习-入门到精通【15】异常处理深入剖析
- 一、实例:处理除数为0的异常处理
- 定义一个异常类,描述可能出现的问题类型
- try语句块中封装的代码
- 定义一个catch处理器处理异常DivideByZeroException
- 异常处理的终止模式
- 异常抛出
- 二、重新抛出异常
- 三、堆栈展开
- 四、何时使用异常处理
- C++11:声明不会抛出异常的函数
- 五、构造函数、析构函数和异常处理
- 初始化局部对象获取资源
- 六、异常与继承
- 七、处理new失败
- new失败时抛出bad_alloc异常
- 使用函数set_new_handler处理new失败
- 八、类unique_ptr和动态分配内存
- 使用unique_ptr的注意事项
- 指向内置数组的unique_ptr
- 九、标准库的异常层次结构
一、实例:处理除数为0的异常处理
通常对于浮点数除法,如果除数为0,在不少C++的实现版本中都是被允许的,计算结果为正或负的无穷大,相应输出为inf
或-inf
。
下面的程序中我们定义了一个名为quotient
的函数,该函数接收用户输入两个整数,然后用第一个int类型的参数除以第二个int类型的参数。在执行除法之前,该函数会将第一个int类型参数的值强制类型转换为double类型,如此在计算过程中,第二个参数也会被强制类型转换为double类型,所以该函数实际上执行的是两个double类型值的除法,并返回一个double类型的结果。
虽然除数为0的浮点数除法是被允许的,但是现在规定将任何以0为除数的行为都视为错误。因此quotient
函数在执行除法之前,需要检查它的第二个参数,以确保它不为0。如果为0,则抛出异常,来向调用它的函数表明执行出现的问题。接着,调用函数将处理这个异常,同时允许用户重新输入两个值。
定义一个异常类,描述可能出现的问题类型
下面代码中我们定义类DivideByZeroException
,该类是标准库runtime_error
(在头文件<stdexcept>
中定义)的一个派生类。类runtime_error
是标准库exception
类(在头文件<exception>
)的派生类,它是C++用于描述运行时错误所创建的标准基类。而类exception是C++标准库中描述所有异常而建立的标准基类。
一个从runtime_error
类派生出来的典型异常类只定义了一个构造函数,这个构造函数将带有错误信息的字符串传递给基类runtime_error的构造函数。所有的异常类都直接或间接地从含有虚函数what
的exception类派生而来,而what函数返回一个异常对象的错误信息。
DivideByZeroException.h
#pragma once
#include <stdexcept>class DivideByZeroException : public std::runtime_error
{
public:DivideByZeroException(): std::runtime_error("attempted to divide by zero") {}
};
异常处理演示
#include <iostream>
#include "DivideByZeroException.h"
using namespace std;double quotient(int numerator, int denominator)
{if(denominator == 0)throw DivideByZeroException();return static_cast<double>(numerator) / denominator;
}int main()
{int number1;int number2;cout << "Enter two integers (end-of-file to end): ";while (cin >> number1 >> number2){try{double result = quotient(number1, number2);cout << "The quotient is: " << result << endl;}catch (DivideByZeroException& ex){cerr << "Exception occured: "<< ex.what() << endl;}cout << "\nEnter two integers (end-of-file to end): ";}cout << endl;
}
运行结果:
try语句块中封装的代码
try语句块可以进行异常处理,try语句块中包含着可能引起异常的语句和在异常发生时应该跳过的语句。
异常可能发生在try语句块中明确提及的代码中,也可能出现在try语句块中的代码对其他函数的调用和深层嵌套的函数调用中。
定义一个catch处理器处理异常DivideByZeroException
在之前的学习中,我们是使用catch
处理器来进行处理的。在每个try语句块后面至少应该立即跟着一个catch处理器。
每个catch处理器都是由关键字catch
开始,并且在圆括号里面的异常参数应该被声明为一个异常类型的引用,而这个异常类型正是该catch处理器所能处理的异常类型
。使用一个引用的好处在于当此异常被捕获时,避免了对异常对象的复制,同时还使相应的catch处理器能够正确地捕获派生类的异常。
当一个try语句块中的异常发生时,那么将要执行就是第一个能够匹配这个异常类型的catch处理器(也就是catch块中的类型恰好匹配了抛出的异常类型,或者是它的直接或间接基类)。如果一个异常参数含有一个可选的参数名,那么catch处理器就能使用该参数名与catch处理器体中捕获到的异常对象进行交互。catch处理器的体要用一对花括号{}
括起来。
提示
在try语句块和相应的catch处理器之间,或者几个catch处理器之间放入代码是一个语法错误;
一个catch处理器只有一个参数——指定用逗号隔开的异常参数列表是一个语法错误;
在一个try语句块后的多个不同catch处理器中捕获相同的异常类型是一个编译错误;
异常处理的终止模式
如果一个try语句块中的一条语句发生了异常,那么这个try语句块就会终止。然后,程序搜索能够处理已发生的异常的第一个catch处理器。程序通过将抛出的异常的类型与每一个catch处理器的异常参数类型进行比较来找到匹配的catch,并定位那里。如果抛出的异常类型和异常参数的类型完全相同,或者异常参数类型是抛出的异常类型的直接或间接基类类型,那么就匹配成功。
当匹配成功时,该catch处理器内部的代码将会被执行,当运行到该catch处理器的右花括号}
时,catch处理器执行结束,异常被认为已经处理,同时在catch处理器体内定义的局部变量(包括catch的参数)就出了作用域。程序将会从try语句块后的最后一个catch处理器之后的第一条语句开始继续执行。
注意,一些程序设计语言使用异常处理的恢复模式
,在这种模式下,当一个异常处理完毕,将会在异常抛出点后继续执行。
如果假设在一个异常处理结束后,控制将回到抛出点后的第一条语句,那么这将是一个逻辑错误
如果try语句块成功地执行了,那么程序将忽略catch处理器,并且程序控制将从try语句块的最后一个catch处理器体之后的第一条语句开始。
如果异常发生在try语句块中,且没有与之相匹配的catch处理器,或者发生异常的语句并不属于try语句,则含有这条语句的函数将立即终止,并且程序将试图定位调用函数中封装的try语句块。这个过程被称为堆栈展开
。
异常抛出
在quotient函数中,当输入的分母是0时,此时函数会使用关键字throw
来抛出一个异常。该关键字后面跟着一个代表抛出的异常类型的操作数。一般情况下,throw语句指定一个操作数(也可以使用没有指定操作数的throw语句)。一个throw语句的操作数可以是任何类型(但是必须是可以被构造复制的)。如果操作数是一个对象,就称之为异常对象;操作数也可以是其他值,比如是一个并不产生类对象的表达式的值(例如,throw x > 5)或者是一个int值(例如,throw 5)。
一般情况下,应该只抛出异常类类型的对象
作为抛出异常的一部分,throw的操作数将会被创建并用来初始化catch处理器中的参数。在上面的代码中,throw语句创建了一个DivideByZeroException
类的对象。当抛出该异常时,函数quotient
立即结束,因为如果继续执行就会产生错误。这是异常处理的一个重要特征:程序必须在错误有可能发生之前显式地抛出异常。
将每种运行时错误与一个有相应名称的异常类型联系在一起,可以提高程序的清晰度
二、重新抛出异常
异常处理器可以在接收到异常时,通过语句throw;
重新抛出异常。无论处理器能否处理异常,处理器都可以为了在处理器外更进一步进行处理而重新抛出异常。下一个封装的try语句块将检测这个重新抛出的异常(异常类型与该catch处理器处理的异常类型相同),而列在该封装的try语句之后的一个catch处理器将试图处理该异常。
下面给出了一个重新抛出异常的示例代码:
#include <iostream>
#include <exception>
using namespace std;void throwException()
{try{cout << " Function throwException throws an exception\n";throw exception(); // 抛出一个exception类的对象}catch (exception&) // 不显式指定异常的参数名,所以在内部无法使用该异常对象获取信息{cout << " Exception handled in function throwException\n"<< " Function throwException rethrows exception";throw; // 重新抛出该异常对象}cout << "This also should not print\n";
}int main()
{try{cout << "main invokes function throwException\n";throwException(); // 该函数中会抛出异常,后续的代码都不会被执行cout << "This should not print\n";}catch (exception&){cout << "\n\nException handled in main\n";}cout << "Program control continues after catch in main\n";
}
运行结果:
三、堆栈展开
当异常被抛出但没有在一个特定的作用域内被捕获时,函数调用堆栈就会展开,并试图在下一个外部的try...catch
语句内捕获这个异常。
展开函数调用堆栈意味着如果一个函数中出现的异常没有被捕获,那么这个函数将会结束,此函数中已经完成初始化的所有局部变量都将被销毁,并且控制将返回到最初调用该函数的语句。如果该语句被一个try语句块封装,那么该try语句块对应的catch处理器就会试图捕获该异常。如果该语句没有被一个try语句封装堆栈展开将再次发生,就算有try语句进行了封装,但是没有相应的catch处理器能够捕获该异常,那么堆栈展开也会发生。如果没有任何一个catch处理器能够捕获该异常,那么程序将会结束。
下面的程序就给出了一个堆栈展开的示例:
#include <iostream>
#include <stdexcept>
using namespace std;void function3()
{cout << "In function 3" << endl;// 不在try语句块或catch语句块中,直接结束函数throw runtime_error("runtime_error in function3");
}void function2()
{cout << "function3 is called inside function2" << endl;function3();
}void function1()
{cout << "function2 is called inside function1" << endl;function2();
}int main()
{try{cout << "function1 is called inside main" << endl;function1();}catch (runtime_error& error){cout << "Exception occured: " << error.what() << endl;cout << "Exception handled in main" << endl;}
}
运行结果:
四、何时使用异常处理
异常处理是用来处理同步错误的,这些错误发生在一个语句正在执行的时候。常见例子有数组下标越界、运算溢出、除数为0、无效的函数参数和失败的内存分配。异常处理并不处理相关的异步事件(例如,磁盘I/O操作的完成、网络消息的到达、鼠标的点击和键盘的击键等),这些事件与程序的控制流并行且互相独立。
异常处理提供一个单独的、统一的处理问题的技术。这有助于一个大型项目中的程序员互相了解各自的错误处理代码。
异常处理机制对于处理程序与软件元素交互时发生的问题也十分有用。软件元素包括成员函数、构造函数、析构函数和类,等等。这些软件元素通常在问题发生时使用异常来通知程序。这样使程序员能够为每个应用程序定制相应的错误处理操作。
一个复杂的应用程序常常由两类组件构成,这两类组件是预定义的软件组件和使用预定义的软件组件的应用程序特定的组件。当预定义组件发生问题时,该组件就需要一种机制,来与应用程序特定的组件进行通信,因为预定义的组件无法知道每个应用程序将如何处理所发生的问题(同样的问题不同程序中的处理方式不同,而一个预定义的组件中可能发生的错误相同)。
C++11:声明不会抛出异常的函数
对于C++11,如果一个函数不会抛出任何异常,也不会调用任何会抛出异常的函数,那么程序员应该显式地说明它不会抛出异常。
只需要在该函数的原型和定义中的参数列表的右侧添加noexcept
关键字。对于声明为const的成员函数,应该将noexcept
放在const之后。
如果一个声明为noexcept的函数调用另一个抛出一个异常的函数,或者调用另一个执行一条throw语句的函数,那么程序将终止。
五、构造函数、析构函数和异常处理
大家有没有考虑过如果一个构造函数中检测到一个错误时会发生什么呢?例如,当一个对象的构造函数没有接收到有效的数据时,构造函数应该如何响应?大家都知道,构造函数是没有返回值,所以它也不能通过返回一个值来指示错误,所以必须要有一种可行的方式来指出这个对象没有被正确的创建。
一种方案是返回这个未正确构造的对象,并希望使用它的人会对它进行一些测试,从而判定它处于错误状态。
另一种方案是在构造函数之外设置一些变量。
而还有一种更好的办法,就是要求构造函数抛出包含错误信息的异常,这样做可以为程序提供一个处理失败的机会。
在构造函数抛出异常之前,作为所构造对象一部分的成员对象的析构函数将会被调用。在异常被捕获之前,try语句块中构造的每一个自动对象,它们的析构函数都将被调用。在异常处理器开始执行的时候,必须保证堆栈展开已经完成(有一个catch语句捕获到该异常)。如果是由于堆栈展开而调用的析构函数抛出一个异常,那么程序将结束。这可能会导致各种各样的安全攻击。
析构函数应该捕获异常,以防止程序意外终止
(因为当析构函数被调用时通常时是在堆栈展开的过程中,此时已经存在了一个异常未被捕获,如果此时析构函数再次抛出一个未被捕获的异常,那么程序就会直接结束。)
不要由具有静态存储期的对象的构造函数抛出异常,因为这样的异常无法捕获。
(无法捕获的原因是,这种具有静态存储期的对象可能在main函数调用之前就已经创建,如果在这种情况下,创建失败,那么此时程序中还没有合适的catch处理器来处理该异常,所以说这样的异常无法捕获。)
如果一个对象拥有成员对象,并且如果在这种外部对象被完全构造前抛出了异常,那么对于在异常抛出前已经构造好了的成员对象,其析构函数将被调用。如果在异常发生时一个对象数组只是部分被构造,那么只有数组中已经完成构造的对象的析构函数才会被调用。
对于在构造函数和析构函数中的异常处理
,要点在于构造函数过程中发生异常,此构造函数要构造的对象并没构造完成,所以它的析构函数不会被调用,只有该对象内部的已经被构造完成的成员对象需要调用对应的析构函数。
如果在调用析构函数时出现异常,应该就在本析构函数中进行处理,不要将异常留到当前函数栈外。
初始化局部对象获取资源
异常可能阻止正常释放资源(例如内存资源或文件资源)的代码的执行,这将导致资源泄漏,妨碍其他程序获取资源。解决这个问题的一个方法是初始化一个局部变量来获取资源。当异常发生时,将调用该对象的析构函数并可以释放资源。
例如,现在有一个程序要打开一个文件进行操作,在操作的过程中可能会抛出异常,如果此处没有对该异常进行捕获,那么就会进行堆栈展开,当前函数中后续的用来释放资源的语句并没有被执行。就算在异常抛出之后,马上进行了处理,那么也会导致代码冗余,且处理复杂,容易出错。
如果我们使用一个对象来获取资源,我们在创建一个对象时,系统会为它的数据成员分配对应资源,当将该对象为一个自动存储期的变量时,当它离开当前作用域时会自动调用析构函数进行销毁,此时对象中获取的资源就会自动释放,且我们只需要在该对象的析构函数中释放资源即可,减少了代码的冗余,也使得逻辑更加清晰,不易出错。
六、异常与继承
各种各样的异常类可以从公共的基类派生出来,就像之前使用的DivideByZeroException这个例子一样。根据我们之前对多态的学习,我们知道一个基类的引用或指针也可以用来指向一个派生类的对象,那么在catch处理器中,我们可以使其捕获一个基类类型的异常对象的一个引用,使得它同样可以捕获该基类的public派生类的所有对象的一个引用,如此就可以使用多态性来处理相关的异常。
七、处理new失败
当new
运算符操作失败时,它会抛出bad_alloc
异常(在头文件<new>
中定义)。下面将给出两个例子,一个是new失败时抛出bad_alloc
的版本,另一个是使用set_new_handler
函数来处理new失败。
new失败时抛出bad_alloc异常
#include <iostream>
#include <new>
using namespace std;int main()
{double *ptr[50]; // 创建一个内置的指针数组try{for (size_t i = 0; i < 50; ++i){ptr[i] = new double[500000000]; // 使用new运算符分配大量空间,该行为可能会导致异常发生cout << "ptr[" << i << "] points to 500,000,000 new doubles\n";}}catch (exception& ex){cerr << "Exception occured: "<< ex.what() << endl; // 使用多态性,利用基类指针调用派生类的虚函数}
}
运行结果:
这一过程会非常卡顿,因为该例子中分配了大量内存。
new在失败后返回nullptr
在旧版本的C++中,当运算符new在分配内存失败时,将返回一个nullptr。在头文件<new>
中定义了对象nothrow
(其类型为nothrow_t
),用法如下:double *ptr = new (nothrow) double[500000000];
建议使用抛出异常的new版本。
使用函数set_new_handler处理new失败
处理new失败的另一种方法是使用函数set_new_handler
(原型在标准头文件<new>
中),这个函数的参数是一个函数指针,指向的函数没有参数且返回值类型为void。该指针指向的函数在new失败时被调用。这给程序员提供了一个统一处理所有new失败的方法,不管这种失败发生在程序的什么位置,一旦set_new_handler在程序中注册了new处理器,那么在失败时new运算符不会抛出bad_alloc异常,它会将错误推给new处理器函数(set_new_haneler的参数指定的函数)来处理。
如果new成功地分配了内存,它将返回一个指向该内存的指针;如果new分配内存失败并且set_new_handler函数没有注册new处理器函数,那么new将抛出一个bad_alloc异常;如果在new分配内存失败时,已经注册了new处理器函数,那么将调用该处理器函数。new处理器函数应该完成下列列出的一个任务:
- 通过释放其他动态分配的内存来增加更多的可用内存(或者告诉用户关掉其他应用程序),并且返回到运算符new来尝试再次分配内存;
- 抛出一个bad_alloc类型的异常;(异常从new中被抛出转移到在处理器函数中抛出)
- 调用函数
abort
(异常/强制的结束程序)或exit
(正常/有序的结束程序)(在头文件<cstdlib>
中定义)来结束程序;
示例代码:
#include <iostream>
#include <new>
#include <cstdlib>
using namespace std;// 该处理器函数选择终止程序
void customNewHandler()
{cerr << "customNewHandler was called";abort();
}int main()
{double *ptr[50]; // 创建一个内置的指针数组set_new_handler(customNewHandler);for (size_t i = 0; i < 50; ++i){ptr[i] = new double[500000000]; // 使用new运算符分配大量空间,该行为可能会导致异常发生cout << "ptr[" << i << "] points to 500,000,000 new doubles\n";}
}
运行结果:
八、类unique_ptr和动态分配内存
之前我们提到过了使用这初始化局部对象的方法来获取资源以防止当异常发生在释放资源之前,而导致的资源泄漏问题。这里的unique_ptr就是用来处理动态分配的内存资源在异常发生时可能出现的泄漏问题。C++在头文件<memory>
中提供了类模板unique_ptr
来处理这种情况。
一个unique_ptr
类对象维护了一个指向动态分配内存的指针。当一个unique_ptr对象的析构函数被调用时,它将对其指针数据成员执行delete
操作。由于unique_ptr类模板中提供了重载的运算符*
和->
,所以unique_ptr的对象可以像一般的指针变量一样使用。
示例代码:
Integer.h
#pragma once
class Integer
{
public:Integer(int i = 0); // 默认构造函数~Integer(); // 析构函数void setInteger(int i);int getInteger() const;
private:int value;
};
Integer.cpp
#include <iostream>
#include "Integer.h"
using namespace std;Integer::Integer(int i): value(i)
{cout << "Constructor for Integer " << value << endl;
}Integer::~Integer()
{cout << "Destructor for Integer " << value << endl;
}void Integer::setInteger(int i)
{value = i;
}int Integer::getInteger() const
{return value;
}
test.cpp
#include <iostream>
#include <memory>
using namespace std;#include "Integer.h"int main()
{cout << "Creating a unique_ptr object that points to an Integer\n";// 创建一个unique_ptr的对象,该对象的类型为unique_ptr类模板的Integer类型特化版unique_ptr<Integer> ptrToInteger(new Integer(7)); // 该对象管理一个动态分配的Integer对象cout << "\nUsing the unique_ptr to manipulate the Integer\n";ptrToInteger->setInteger(99); // 就像使用一个Integer类型的指针一样,使用unique_ptr对象来操作管理的对象cout << "Integer after setInteger: " << (*ptrToInteger).getInteger()<< "\n\nTerminating program" << endl;
}
运行结果:
使用unique_ptr的注意事项
unique_ptr类之所以这么命名是因为,对于一个动态分配的对象在同一时间只能有一个unique_ptr对象可以指向它。通过unique_ptr类的重载赋值运算符或拷贝构造函数,可以使一个unique_ptr类对象转让它管理的动态内存的所有权。最后一个维护指向动态内存的指针的unique_ptr类对象将负责回收内存。当一个unique_ptr类对象在客户代码中退出作用域时,unique_ptr的析构函数将销毁动态分配的对象并回收它的内存。
指向内置数组的unique_ptr
程序员也可以使用一个unique_ptr对象来管理动态分配的内置数组,例如:
unique_ptr<string[]> ptr(new string[10]);
这条语句动态分配了一个由unique_ptr对象ptr管理的具有10个string元素的数组。类型string[]
表明被管理的内存是一个包含string元素的内置数组,当管理一个数组的unique_ptr离开它的作用域时,它通过delete[]
来回收内存。
同时,unique_ptr类中提供重载的运算符[]
,所以该对象可以通过下标运算符来访问数组元素。
九、标准库的异常层次结构
C++标准库中包含一个异常类的层次结构,其中的一部分如下图所示:
该层次结构的最上层是基类exception(在头文件<exception>
中定义),它包含了虚函数what
,exception的派生类可以通过重载what来发布合适错误信息。
基类exception的直接派生类包括runtime_error
和logic_error
(在头文件<stdexcept>
中定义),其中每一个都有若干个派生类。此外,由C++运算符抛出的异常也直接由exception派生。例如,bad_alloc
异常是由new抛出的,bad_cast
上由dynamic_cast抛出的,以及bad_typeid
异常。
注意,将捕获基类对象的catch处理器放在捕获该基类的派生类对象的catch处理器前面是一个逻辑错误。基类的catch处理器捕获所有由基类派生的类对象,所以派生类catch处理器永远不会被执行。
logic_error类是一些用来表明程序逻辑错误的标准异常类的基类。例如,invalid_argument类表明一个函数接收了一个非法参数。length_error类表明对对象使用的长度超过了该对象所允许的最大长度。out_of_range类表明了一个值越过所允许的取值范围。
而runtime_error的派生类,overflow_error描述了算术上溢错误,underflow_error描述了算术下溢错误。
为了捕获由一个try语句抛出的所有异常,可以使用catch(...),使用这种方式捕获异常有两个缺点,一个是捕获到的异常的类型是未知的,另外一个缺点是没有命名的参数,在catch处理器中无法引用该异常对象。
对于catch(...)
可以用来执行不依赖于异常类型的恢复操作(例如,释放公共资源)。异常可以被重新抛出,由其他更特定的catch处理器来处理。