异常
为什么使用异常
- 使用异常处理错误使得代码更简单、更干净,并且更不可能错过错误。使用
errno
和if
语句使得错误处理和普通代码紧密缠绕,因此代码更加凌乱,也更难确保已经处理了所有的错误。 - 构造函数的工作是创建类的不变性(创建成员函数运行的环境),这经常需要获取如内存、锁、文件、套接字等资源,即 RAII(Resource Acquisition Is Initialization)。
- 报告一个构造函数检查到的错误需要抛异常实现。
C++ 使用异常
- C++ 中,异常用于指示内部不能处理的错误,比如构造函数内部获取资源失败。
- 不要使用异常作为函数的返回值。
- C++ 使用异常来支持错误处理:
- 使用
throw
指示错误(函数不能处理错误,或者暴露错误的后置条件)。 - 在知道可以处理错误的时候使用
catch
指定错误处理行为(可以翻译成另一种类型并且重新抛出)。 - 不要使用
throw
指示调用函数的代码错误。而是使用assert
或其他机制,或者发送进程给调试器,或者使得进程崩溃并收集崩溃日志以便程序员调试。 - 当发现对组件不变式的意外违反时,不要使用
throw
,使用throw
或其他机制来终止程序。抛出异常不能解决内存崩溃甚至会导致后续使用数据的错误。
- 使用
使用异常的反对观点
- 异常是昂贵的:和没有错误处理相比,现代 C++ 实现已经将异常的负载降到 3% 左右。正常情况不抛异常,比使用返回值和检查代码运行更快。只有出现错误才会有负载。
- JSF++ 禁止异常:JSF++ 是硬实时和严格安全性的应用(飞机控制系统)。我们必须保证响应时间,所以我们不能使用异常,甚至禁止使用释放分配的存储。
使用 new 调用构造函数抛异常会导致内存泄漏:这是旧编译器的 bug,现在早已经解决了。
T *p= new T;//将被编译器转换给类似下面的代码 void allocate_and_construct() { // 第一步,分配原始内存,若失败则抛出bad_alloc异常 try { // 第二步,调用构造函数构造对象 new (p)T; // placement new: 只调用T的构造函数 } catch(...) { delete p; // 释放第一步分配的内存 throw; // 重抛异常,通知应用程序 } }
替代方案:通过判断或函数返回值检查错误
ofstream os("myfile");//需要打开一个文件
if(os.bad()) { /*打开失败需要处理错误*/ }
- 可以通过函数返回一个错误码或设置一个局部变量(如 errno)。
- 不使用全局变量:全局变量需要立即检查,因为其他函数可能会重置它;多线程也会有问题。
- 这就需要测试每个对象。当类由许多对象组成,尤其是这些子对象互相依赖时,会导致代码一团糟。
但是检查返回值要求智慧甚至不可能达到目的。比如下面的代码
对于 my_negate 函数,每一个 int 返回值都是正确的,但是当使用二进制补码表示的时候,是没有最大负数的,可参考C语言中INT_MIN的一些问题。这种情况下,就需要返回值对,分别表示错误码和运算结果。
double d = my_sqrt(-1);//错误返回 -1 if(d == -1) { /*处理错误*/ } int x = my_negate(INT_MIN);//额。。。
使用 try/catch/throw 而不是条件判断和返回错误码来改善软件质量
- 条件语句更易犯错
- 延迟发布时间:白盒测试需要覆盖所有条件分支
- 增加开发花费:非必须的条件控制增加了发现 bug、解决 bug 和测试的复杂度
- 检测到错误的代码通常需要传递错误信息,这可能是多层函数调用,这种情况下每一层调用函数都需要添加判断代码和返回值;而异常可以更简洁、干净地传递错误信息到可以处理错误的调用者
异常便于传递错误信息
使用异常
void f1() { try { // ... f2(); // ... } catch (some_exception& e) { // ...code that handles the error... } } void f2() { ...; f3(); ...; } // f3 到 f9 逐层调用,f9 调用 f10 void f10() { // ... if ( /*...some error condition...*/ ) throw some_exception(); // ... }
不使用异常
int f1() { // ... int rc = f2(); if (rc == 0) { // ... } else { // ...code that handles the error... } } int f2() { // ... int rc = f3(); if (rc != 0) return rc; // ... return 0; } // f3 到 f9 都需要增加判断代码 int f10() { // ... if (...some error condition...) return some_nonzero_error_code; // ... return 0; }
异常使得代码更简洁
Number 类支持加减乘除 4 种基本运算,但是加会溢出,除会导致除 0 错误或向下溢出等等
使用异常
void f(Number x, Number y) { try { // ... Number sum = x + y; Number diff = x - y; Number prod = x * y; Number quot = x / y; // ... } catch (Number::Overflow& exception) { // ...code that handles overflow... } catch (Number::Underflow& exception) { // ...code that handles underflow... } catch (Number::DivideByZero& exception) { // ...code that handles divide-by-zero... } }
不使用异常
int f(Number x, Number y) { // ... Number::ReturnCode rc; Number sum = x.add(y, rc); if (rc == Number::Overflow) { // ...code that handles overflow... return -1; } else if (rc == Number::Underflow) { // ...code that handles underflow... return -1; } else if (rc == Number::DivideByZero) { // ...code that handles divide-by-zero... return -1; } Number diff = x.sub(y, rc); if (rc == Number::Overflow) { // ...code that handles overflow... return -1; } else if (rc == Number::Underflow) { // ...code that handles underflow... return -1; } else if (rc == Number::DivideByZero) { // ...code that handles divide-by-zero... return -1; } Number prod = x.mul(y, rc); if (rc == Number::Overflow) { // ...code that handles overflow... return -1; } else if (rc == Number::Underflow) { // ...code that handles underflow... return -1; } else if (rc == Number::DivideByZero) { // ...code that handles divide-by-zero... return -1; } Number quot = x.div(y, rc); if (rc == Number::Overflow) { // ...code that handles overflow... return -1; } else if (rc == Number::Underflow) { // ...code that handles underflow... return -1; } else if (rc == Number::DivideByZero) { // ...code that handles divide-by-zero... return -1; } // ... }
异常更易区分正常执行的代码
使用异常
void f() // Using exceptions { try { GResult gg = g(); HResult hh = h(); IResult ii = i(); JResult jj = j(); // ... } catch (FooError& e) { // ...code that handles "foo" errors... } catch (BarError& e) { // ...code that handles "bar" errors... } }
不使用异常
int f() // Using return-codes { int rc; // "rc" stands for "return code" GResult gg = g(rc); if (rc == FooError) { // ...code that handles "foo" errors... } else if (rc == BarError) { // ...code that handles "bar" errors... } else if (rc != Success) { return rc; } HResult hh = h(rc); if (rc == FooError) { // ...code that handles "foo" errors... } else if (rc == BarError) { // ...code that handles "bar" errors... } else if (rc != Success) { return rc; } IResult ii = i(rc); if (rc == FooError) { // ...code that handles "foo" errors... } else if (rc == BarError) { // ...code that handles "bar" errors... } else if (rc != Success) { return rc; } JResult jj = j(rc); if (rc == FooError) { // ...code that handles "foo" errors... } else if (rc == BarError) { // ...code that handles "bar" errors... } else if (rc != Success) { return rc; } // ... return Success; }
使用异常处理错误是值得的
- 使用异常处理错误需要付出
- 异常处理要求原则和严谨:需要学习;
- 异常处理不是万能药:如果团队是草率没有纪律的,那么使用异常和返回值都会有问题
- 异常处理不是通用的:应当知道什么条件应该使用返回值,什么条件使用异常
- 异常处理会鞭策学习新技术
构造函数可以抛异常
- 当不能正确初始化或构造一个对象时,应该在构造函数内部抛出异常
- 构造函数没有返回值,所以不能使用返回错误码的方式
- 最差的方式是使用一个内部状态码来判断是否构造成功,但是需要在每次调用构造函数的时候使用
if
检查状态码,或者在成员函数内部增加if
检查
构造函数抛异常也不会有内存泄漏
- 构造函数抛异常时,对象的析构函数不会运行。因为对象的生命周期是构造函数成功完成或返回,抛异常表示构造失败,生命周期没有开始。因此需要将 undone 的东西保存在对象的数据成员
比如使用智能指针保存分配的成员对象,而不是保存到原始的 Fred* 数据成员
// Fred.h #include <memory> class Fred { public: //typedef 简化了使用 Fred 对象的语法,可以使用Fred::Ptr 取代 std::unique_ptr<Fred> typedef std::unique_ptr<Fred> Ptr; // ... }; //调用者 cpp #include "Fred.h" void f(std::unique_ptr<Fred> p); // explicit but verbose void f(Fred::Ptr p); // simpler void g() { std::unique_ptr<Fred> p1( new Fred() ); // explicit but verbose Fred::Ptr p2( new Fred() ); // simpler // ... }
析构函数不抛异常
- 析构函数抛异常会导致异常点之后的代码不能指向,可能造成内存泄漏问题
- 可以在析构函数抛异常,但是该异常不能出析构函数,即需要在析构函数内部使用
catch
捕获异常。否则会破坏标准库和语言的规则。 - 处理方式是:
- 可以写信息到日志文件,终止进程。
- 提供一个普通函数执行可能抛异常的操作,给客户处理错误。
- C++ 规则是异常的 “栈展开(stack unwinding)” 进程中调用的析构函数不能抛异常:
- “stack unwinding”:当抛出一个异常时,栈是 “unwound” 的,因此在
throw
和catch
之间的栈帧会被弹出。 - 在 “stack unwinding” 过程中,这些栈帧中的所有局部变量会被析构。如果其中一个析构函数抛出异常,C++ 运行时系统将进入 “no-win” 状态:两个异常只能处理一个,忽视任何一个都会丢失信息。
- 此时 C++ 会调用
terminate()
终止进程。即在发生异常的情况下调用析构函数抛出异常会导致程序崩溃。因此避免的方法就是永远不要在析构函数抛异常。
- “stack unwinding”:当抛出一个异常时,栈是 “unwound” 的,因此在
抛出什么异常
- 抛出对象。如果可以,写子类继承自
std::exception
类,可以提供更多关于异常的信息
捕获什么异常
- 可以的话,捕获异常的引用:拷贝可能会有不同的行为;指针则不确定是否需要删除指向异常的指针
throw 再次抛异常
可用于实现简单的 “stack-trace”,即堆栈跟踪,在程序重要函数内部增加
catch
语句class MyException { public: // ... void addInfo(const std::string& info); // ... }; void f() { try { // ... } catch (MyException& e) { e.addInfo("f() failed"); throw;//再次抛出当前异常 } }
也可用于 “exception dispatcher”,即异常分发
void handleException() { try { throw; } catch (MyException& e) { // ...code to handle MyException... } catch (YourException& e) { // ...code to handle YourException... } } void f() { try { // ...something that might throw... } catch (...) { handleException(); } }
注解
- 不是所有编译器支持异常捕获(exception-try-block),只有 GCC 和大多数新版本的 MSVC 支持。
初始化的异常不能被隐藏:构造函数内的异常处理部分必须抛出一个异常,或重新抛出捕获的异常。下面两个版本的代码是等价的
// Version 1 struct A { Buf b_; A(int n) try : b_(n) { cout << "A initialized" << endl; } catch(BufError& ) { cout << "BufError caught" << endl; } }; // Version 2 struct A { Buf b_; A(int n) try : b_(n) { cout << "A initialized" << endl; } catch(BufError& be) { cout << "BufError caught" << endl; throw; } };