目录
一、什么是异常
异常是程序运行过程中出现的意外情况,它可能会导致程序无法正常执行。
- 数组越界
- 分配内存失败
- ......
无论是因为程序员的失误、不可预测的输入,还是其他外部因素,错误总是难以避免。必须在程序中添加特殊代码,来捕获异常并采取适当的措施确保程序的稳定运行。
异常处理机制将问题检测和问题处理相分离,让一个函数在发现了自己无法处理的错误时抛出(throw)一个异常,然后它的(直接或者间接)调用者能够处理这个问题。
- 提高程序的健壮性:异常处理可以帮助识别程序中的错误,并在发生异常时采取适当的措施,从而避免程序崩溃或数据丢失。
- 提高代码的可读性:将错误处理代码与正常逻辑代码分离,可以使程序结构更加清晰,便于阅读和维护。
- 便于调试和定位问题:当异常发生时,异常处理机制可以提供详细的错误信息,帮助快速定位和解决问题。
二、C语言中处理错误的方式
在C语言中,处理错误有两种方法:
- 使用整型的返回值标识错误。
- 使用errno宏(一个全局整型变量)去记录错误。
当然,C++中仍然可以用这两种方法。然而,这两种方法有一定缺陷:
- 定义不一致。例如有些函数返回1表示成功,返回0表示出错;而有些函数返回0表示成功,返回非0表示出错。
- 函数的返回值只有一个,如果通过函数的返回值表示错误代码,函数就不能返回其他的值。当然,也可以通过指针或者C++引用来返回另外的值,但是这样可能会令程序略微晦涩难懂。
- 使用全局的errno则很容易被人遗忘,从而没有及时处理问题。
三、异常处理机制的基本语法
1. try和catch
try {
// 可能发生异常的代码
}
catch (异常类型1 参数1) {
// 处理异常类型1的代码
}
catch (异常类型2 参数2) {
// 处理异常类型2的代码
}
// 更多的catch块...
try块用于定义可能发生异常的代码段。
- 当程序执行try块中的代码时,如果发生异常,程序将跳出try块,并查找与之匹配的catch块。
- 如果没有异常发生,程序将正常执行try块中的代码,并跳过与之相关的catch块。
catch块用于捕获并处理异常。
- catch匹配的原则是异常对象的类型必须与catch块中声明的异常类型相同,或者是其派生类。
- 当找到匹配的catch块时,程序将执行该catch块中的代码,以处理异常。
- 如果没有找到匹配的catch块,程序将终止并调用std::terminate()函数。
- 程序会按照catch块的顺序进行匹配。建议先捕获较具体的异常类型,再捕获较一般的异常。
2. thow
throw语句用于抛出异常。当程序执行到throw语句时,程序将立即终止当前函数的执行,并跳转到最近的try块。然后,程序开始查找与抛出的异常类型匹配的catch块。如果没有找到匹配的catch块,程序将继续在调用栈中向上查找,直到找到匹配的catch块或程序终止。
throw exceptionData; //throw 异常类型
exceptionData 是“异常数据”的意思,它可以包含任意的信息,完全有程序员决定。exceptionData 可以是 int、float、bool 等基本类型,也可以是指针、数组、字符串、结构体、类等聚合类型。
- 抛出的异常对象可以是任何类型,但通常应该使用C++标准库中定义的异常类型(如std::runtime_error)或自定义的异常类(继承自std::runtime_error的派生类)。
- 当抛出异常时,最好使用值传递而不是引用传递,以避免引用无效对象的问题。
- 如果在函数中抛出异常,应确保函数的资源已正确释放,以避免内存泄漏等问题。
3. noexcept
从C++11开始,用关键字noexcept表示函数中不会发生异常,便于编译器对程序做更多的优化。
如果在运行时,noexecpt函数向外抛出了异常(如果函数内部捕捉了异常并完成处理,这种情况不算抛出异常),程序会直接终止,调用std::terminate()函数,该函数内部会调用std::abort()终止程序。通常情况,编译器会帮助程序员排除一些显示的异常抛出问题。
在实践中,一般两种异常抛出方式是常用的:
- 一个操作或者函数可能会抛出一个异常;
- 一个操作或者函数不可能抛出任何异常。
void swap(Type& x, Type& y) throw() //C++11之前, c++17弃用
{
x.swap(y);
}
void swap(Type& x, Type& y) noexcept //C++11
{
x.swap(y);
}
void swap(Type& x, Type& y) noexcept(noexcept(x.swap(y))) // 表明在一定条件下不发生异常。
{
x.swap(y);
}
以下情形鼓励使用noexcept:
- 移动构造函数(move constructor)
- 移动分配函数(move assignment)
- 析构函数(destructor)。在新版本的编译器中,析构函数是默认加上关键字noexcept的。
pair& operator=(pair&& __p)
noexcept(__and_<is_nothrow_move_assignable<_T1>,
is_nothrow_move_assignable<_T2>>::value)
{
first = std::forward<first_type>(__p.first);
second = std::forward<second_type>(__p.second);
return *this;
}
没把握的情况下,不要轻易使用noexception。
使用条件异常noexception的规范如下:
- 编译器现在将检测如果你使用一个函数抛出异常,而一个函数不抛出任何异常的情况。当然,在允许抛出函数的地方使用不抛出的函数仍然是有效的。
void f1();
void f2() noexcept; // different type since c++17
void (*fp)() noexcept; // pointer to function that doesn’t throw
fp = f2; // OK
fp = f1; // ERROR since C++17
void (*fp2)(); // pointer to function that might throw
fp2 = f2; // OK
fp2 = f1; // OK
- 异常规范在函数声明和定义中必须同时指明并严格保持一致,不能更严格或者更宽松。不允许重载具有不同异常规范的同一签名的函数名。
void f();
void f() noexcept; // ERROR
- 派生类虚函数的异常规范必须与基类虚函数的异常规范一样严格,不能松散地重载(它不会覆盖基类虚函数,也无法编译)。
class Base {
public:
virtual void foo() noexcept;
...
};
class Derived : public Base {
public:
void foo() override; // ERROR: does not override
// 它不会覆盖基类的foo()
// 也不能编译
...
};
- 当使用条件noexcept规范时,函数的类型取决于条件是真还是假
void f1();
void f2() noexcept;
void f3() noexcept(sizeof(int)<4); // same type as either f1() or f2()
void f4() noexcept(sizeof(int)>=4); // different type than f3()
4. 标准库中的异常类
- 在上述继承体系中,每个类都有提供了构造函数、复制构造函数、和赋值操作符重载。
- logic_error类及其子类、runtime_error类及其子类,它们的构造函数是接受一个string类型的形式参数,用于异常信息的描述;
- 所有的异常类都有一个what()方法,返回const char* 类型(C风格字符串)的值,描述异常信息。
标准异常类:
异常名称 | 描述 |
exception | 所有标准异常类的父类。 |
bad_alloc | 当operator new and operator new[],请求分配内存失败时。 |
bad_exception | 如果函数的异常抛出列表里声明了bad_exception异常,当函数内部抛出了异常抛出列表中没有的异常,这是调用的unexpected函数中若抛出异常,不论什么类型,都会被替换为bad_exception类型。 |
bad_typeid | 使用typeid操作符,操作一个NULL指针,而该指针是带有虚函数的类,这时抛出bad_typeid异常。 |
bad_cast | 使用dynamic_cast转换引用失败的时候。 |
ios_base::failure | io操作过程出现错误。 |
logic_error | 逻辑错误,可以在运行前检测的错误。 |
runtime_error | 运行时错误,仅在运行时才可以检测的错误。 |
logic_error的子类:
异常名称 | 描述 |
length_error | 试图生成一个超出该类型最大长度的对象时,例如vector的resize操作。 |
overflow_error | 参数的值域错误,主要用在数学函数中。例如使用一个负值调用只能操作非负数的函数。 |
underflow_error | 超出有效范围。 |
invalid_argument | 参数不合适。在标准库中,当利用string对象构造bitset时,而string中的字符不是’0’或’1’的时候,抛出该异常。 |
runtime_error的子类:
异常名称 | 描述 |
range_error | 计算结果超出了有意义的值域范围 |
overflow_error | 算术计算上溢 |
underflow_error | 算术计算下溢 |
5. 自定义异常
- 建议自定义异常类继承标准异常类std::exception。尽管C++中可以抛出任何类型的异常,但自定义异常类不继承自标准异常可能会导致程序混乱,尤其是多人协同开发时。
- 当继承标准异常类时,应该重载父类的what函数和虚析构函数。
- 因为栈展开的过程中,要复制异常类型,要根据自定义类中添加的成员考虑是否提供自己的复制构造函数。
方法一:构造函数中透传
#include <stdexcept>
#include <string>
namespace exception {
class ExampleException final : public std::runtime_error {
public:
explicit ExampleException(std::string const& what)
: std::runtime_error{what} {}
explicit ExampleException(const char* what)
: std::runtime_error{what} {}
explicit ExampleException(const char* tag, const char* what)
: std::runtime_error{std::string{tag} + what} {}
}; // class ExampleException
} // namespace exception
// use
throw exception::ExampleException{"Invalid parameter!"};
throw exception::ExampleException{"UserName", "Invalid parameter!"};
方法二:重写what函数
可以为自定义异常类提供一个默认构造函数,以便在不提供详细信息的情况下创建异常对象。
自定义异常类的析构函数通常应该声明为虚函数,并使用noexcept关键字标记,以确保在异常处理过程中不会抛出新的异常。这是因为在C++中,析构函数中抛出异常可能导致未定义行为。
#include <exception>
#include <string>
class MyException : public std::exception {
public:
MyException(const std::string& message)
: mMessage(message) {}
virtual ~MyException() noexcept {}
const char* what() const noexcept override {
return mMessage.c_str();
}
private:
std::string mMessage; // string内部用一个智能指针来管理内存,因此会被适时自动清理。
};
// =========== use =============================
void foo() {
throw MyException("Something went wrong in foo()");
}
int main() {
try {
foo();
} catch (const MyException& e) {
std::cerr << "Caught MyException: " << e.what() << std::endl;
} catch (...) {
std::cerr << "Caught an unknown exception" << std::endl;
}
return 0;
}
四、如何编写可维护的代码
1. 使用RAII确保资源安全
资源分配即初始化(RAII,Resource Acquisition Is Initialization)的基本思想是将资源的生命周期与对象的生命周期绑定,通过对象的构造和析构函数来分配和释放资源。当异常发生时,已经构造的对象会自动调用其析构函数,确保资源被正确释放。
- 尽量使用智能指针(如std::unique_ptr和std::shared_ptr)来管理动态分配的内存。
- 使用C++标准库中的容器和类(如std::vector和std::fstream),它们已经实现了RAII。
- 在自定义类中实现RAII,以确保资源在构造函数和析构函数中被正确分配和释放。
2. 避免在构造函数和析构函数中抛出异常
在构造函数中抛出异常可能导致对象处于无效状态。如果在构造函数中抛出异常,应确保已分配的资源被正确释放,以避免内存泄漏等问题。
避免在析构函数中抛出异常,因为在异常处理过程中,析构函数抛出的异常可能导致未定义行为。
3. 利用异常规范说明来提高代码可读性
异常规格泛化(Exception Specification)是一种旧的异常处理机制,用于指定函数可能抛出的异常类型。这种机制通过在函数声明后添加throw关键字来实现。然而,异常规格泛化在实践中往往导致问题,如代码膨胀和运行时开销,因此不推荐使用。
在C++11及更高版本中,可以使用noexcept关键字来指定函数不会抛出异常,帮助编译器生成更高效的代码,并提高代码的可读性。当确定函数不会抛出异常时,可以考虑使用noexcept关键字。
4. 使用异常安全的设计模式
异常安全的设计模式是指在异常发生时能保持程序的正确性和稳定性,异常安全性包含三个级别:
- 基本异常安全(Basic Exception Safety):确保异常发生时,程序的资源不会泄漏,但并不能保证异常会被妥善处理。
- 强异常安全(Strong Exception Safety):确保异常发生时,“资源不变”(Resource Aversion),保持数据的一致性和完整性。要求在方法开始时获取的所有资源在方法结束时都必须返回到初始状态,即使该方法内部发生了未预期的错误。这通常通过原子操作、检查点恢复或数据库事务等机制来实现。
- 不抛异常安全(Nothrow Exception Safety):确保函数不会抛出异常。
五、来自C++之父Bjarne Stroustrup的建议
节选自《The C++ Programming Language》
1. Don’t use exceptions where more local control structures will suffice; 当局部的控制能够处理时,不要使用异常;
2. Use the "resource allocation is initialization" technique to manage resources; 使用“资源分配即初始化”技术去管理资源;
3. Minimize the use of try-blocks. Use "resource acquisition is initialization" instead of explicit handler code; 尽量少用try-catch语句块,而是使用“资源分配即初始化”技术。
4. Throw an exception to indicate failure in a constructor; 如果构造函数内发生错误,通过抛出异常来指明。
5. Avoid throwing exceptions from destructors; 避免在析构函数中抛出异常。
6. Keep ordinary code and error-handling code separate; 保持普通程序代码和异常处理代码分开。
7. Beware of memory leaks caused by memory allocated by new not being released in case of an exception; 小心通过new分配的内存在发生异常时,可能造成内存泄露。
8. Assume that every exception that can be thrown by a function will be thrown; 如果一个函数可能抛出某种异常,那么我们调用它时,就要假定它一定会抛出该异常,即要进行处理。
9. Don't assume that every exception is derived from class exception; 要记住,不是所有的异常都继承自exception类。
10. A library shouldn't unilaterally terminate a program. Instead, throw an exception and let a caller decide; 编写的供别人调用的程序库,不应该结束程序,而应该通过抛出异常,让调用者决定如何处理(因为调用者必须要处理抛出的异常)。
11. Develop an error-handling strategy early in a design; 若开发一个项目,那么在设计阶段就要确定“错误处理的策略”。
参考文献
[1] c++ 11 noexcept_c++ noexcept-CSDN博客