C++异常的使用

引入

在很多场景下, 异常是一种非常好的用来报告和处理代码中的逻辑错误和运行错误的方法。特别是当堆栈中含有多个函数调用且其中的某个函数发生了一些错误时,使用异常处理的话那么它就一些上下文来处理相应的错误。异常提供了一种正规的,定义完好的方案来处理错误并将相关信息传递给调用堆栈。


异常的使用

程序错误主要分为两大类:

  1. 逻辑错误。 比如数组越界之类错误。这类错误的发生完全是编程导致的, 程序员完全有能力控制这类错误的发生。
  2. 运行时错误。比如网络连接失败,文件不存在导致读取失败。这一类错误的发生超出了程序员的可控范围,是不可预知的。

在C风格编程中,错误报告通常是在特定的函数中返回一个值,这个值可以表示错误码或者状态码; 或者是设置一个全局变量,调用者可以在每次执行完其他函数之后取出这个值以查看是否有错误发生。例如Win32 API 的GetLastError函数和 CUDA API 的cudaGetLastError函数, 实现的都是此类功能。针对出现了错误的情况,调用者就会识别这些编码,并给出相应的回应。如果调用者不对错误码进行处理,那么程序就可能在不报警告的情况下直接崩溃, 或者是在错误的结果下继续执行。

在现在的C++编程中推荐使用异常主要基于以下几个原因:

  1. 异常会强制去判断错误的条件并处理它。未处理的异常通常会终止程序执行。
  2. 异常会跳转到调用堆栈中可以处理错误的地方。中间函数会逐渐传递异常。
  3. 异常的栈展开机制会在抛出异常前销毁作用范围内的所有按照规则定义好的对象。
  4. 异常可以隔离开错误的检测代码和处理代码。

以下是C++中异常使用的简单示例。

#include <stdexcept>
#include <limits>
#include <iostream>

using namespace std;

void fun(int c)
{
    if (c > numeric_limits< char> ::max())
        throw invalid_argument("argument too large.");
}

int main()
{
    try {
        fun(256); //cause an exception to throw
    } catch (invalid_argument& e) {
        cerr << e.what() << endl;
        return -1;
    }
    return 0;
}

示例代码中的try代码块如果抛出(throw)一个异常,那么这个异常就会被后面的与抛出异常类型相同的catch代码块进行捕获。换句话说,就是代码的执行过程直接从抛异常的位置跳转到捕获代码的位置, 中间的其他语句并不会执行。如果没有可用的catch块对异常进行处理,那么std::terminate将会被调用然后退出程序。在C++中, 任何类型的异常都可以被抛出,因此推荐所抛出异常的类型直接或者间接的继承于标准异常std::exception。C++中并没有提供finally来保证所有资源在异常抛出时进行释放。因此,通常使用RAII方案,类似智能指针之类的方法来进行资源的清理。

使用准则
尽管异常提供了很多特性来支持错误处理, 但它并不能处理所有情况。因此我们很有必要理解好异常的好处并在coding的时候时刻记住异常。

  1. 使用断言(assert) 来校验永远不应该出现的错误; 用异常来处理可能出现的错误。异常与断言是两种不同的用来检测运行时错误的机制。断言表达式在开发阶段通常用来测试一些在代码正确的前提下永远为不为真的条件。这种情况下是没有必要使用异常的,因为如果断言测试成功那就表明代码中存在必须修复的BUG, 而且它也并不表示这是程序需要进行恢复条件中的一种。异常会在合适的捕获句柄处继续执行,并不会直接中断程序, 断言会中断程序的执行。及时函数并不存在错误之处,但也需要记住通过异常来校验函数的输入参数, 因为我们不能控制用户对这个函数传递什么样的参数值。
  2. 当处理错误的代码和检测错误的代码分开始时使用异常。在严格要求性能的循环中错误检测与错误处理耦合关系很紧密时,考虑使用错误码。在没有异常抛出的情况下,异常机制通常仅有很小的性能损耗。如果抛出异常,遍历与展开栈的开销大致相当于一个函数的调用开销。当进入try代码块时, 需要一个额外的数据结构来追踪调用栈; 且但异常抛出时,也需要额外的指令来展开调用栈。但是,在大多数情况下,这种调用开销和内存开销并不值得关注。只有在有内存要求的系统或者性能严格要求的循环中,会产生较为严重的负面影响。
  3. 因为每个函数都可能抛出或者传递异常,最好提供三种异常保证:强保证,基本保证以及无异常保证。
  4. 抛出异常值,通过引用捕获。不要捕获不能处理的异常。
  5. 不要使用异常说明(exception specification),这是在C++11不推荐使用的。
  6. 尽量使用标准库的异常,可以通过通过派生来定制异常类型。
  7. 不要让异常逃离析构函数和内存释放函数。

异常安全

异常机制的一个优势就是程序会执行会带着异常数据直接从抛出异常的代码位置跳转到捕获代码的位置。 在try语句和异常抛出语句之间的函数并不需要知道抛出了异常。但是, 它们必须设计得可以在任意时刻因为下层应用抛出异常使得其离开所在的作用域时不留下未构造完的对象、泄露的内存以及不可用的数据结构。

基本方案
一个鲁棒的异常处理机制需要仔细的思考且应该是设计过程的一部分。通常情况下, 大多异常都发生在软件的底层模块, 但却没有更多的上下文来处理这些错误或者暴露信息给最终用户。在中间层,一些函数可以捕获这些异常并重新抛出,或者他们提供更多的其他信息给上层可以捕获该错误的函数。一个可以完全处理好这个引发异常的错误并使得整个程序可以继续正常运行的函数应该捕获这样异常并“吞掉”它。在很多情况下, 中间层应该让异常传递至上一层。甚至在最高层,如果一个异常发生后不能保证程序可以正确的执行,也可以适当地考虑让这种未处理的异常终止程序的执行。无论一个函数如何设计来保证异常安全,它都应该遵循以下几个原则。

  1. 保证资源类简单。当需要在类中手动管理资源时,可以使用一个更加简单的类来管理单个资源。通过这种简化的类,可以减少资源泄露的风险。在任何可能的时候使用智能指针。
// old-style new/delete version
class NDResourceClass 
{
  private:
    int*   m_p;
    float* m_q;
  public:
    NDResourceClass() : m_p(0), m_q(0) 
    {
        m_p = new int;
        m_q = new float;
    }
    ~NDResourceClass() 
    {
        delete m_p;
        delete m_q;
    }
    // Potential leak! When a constructor emits an exception,
    // the destructor will not be invoked.
};

// shared_ptr version
#include <memory>

using namespace std;

class SPResourceClass {
private:
    shared_ptr<int> m_p;
    shared_ptr<float> m_q;
public:
    SPResourceClass() : m_p(new int), m_q(new float) { }
    // Implicitly defined dtor is OK for these members,
    // shared_ptr will clean up and avoid leaks regardless.
};

// A more powerful case for shared_ptr

class Shape {
    // ...
};

class Circle : public Shape {
    // ...
};

class Triangle : public Shape {
    // ...
};

class SPShapeResourceClass {
private:
    shared_ptr<Shape> m_p;
    shared_ptr<Shape> m_q;
public:
    SPShapeResourceClass() : m_p(new Circle), m_q(new Triangle) { }
};
  1. 使用 RAII 来管理资源。为了保证异常安全, 函数中即使发生了异常,其中动态分配的内存必须释放、所有文件句柄必须关闭。RAII(Acquisition Is Initialization)指的就是对这样一些资源在其变量生命周期中的管理。但一个函数无论是因为正常返回或者是因为异常而退出其作用范围, 其内部自动变量的析构函数会自动调用。一个RAII封装对象会在销毁的时候调用相应的函数。在异常安全代码中,将资源的拥有权传递给RAII对象是非常重要的。

  2. 三种异常保证。典型地, 异常安全一般是就函数所提供的三种异常保证而言:no-fail guaranteestrong guaranteebasic guarantee。第一种保证是一个函数所能提供的最强异常保证,因为它意味着这个函数不会抛出异常。但是通常很难保证,因为要做到这一点需要保证:1)内部调用的函数也不会抛出异常;2)保证异常在到达这个函数前被捕获;3)在函数内部捕获所有异常并进行相应的处理。 第二种和第三种保证都是依赖于析构函数不会抛出异常。第二种保证表示但一个函数因为异常离开作用域时,它不会发生内存泄露且程序的状态不会发生改变。一个函数有强保证意味着这个函数只能执行完全成功或者它没有任何影响。第三种异常保证是最弱的保证。但是强保证在性能方面的影响导致这种保证有时候又可能是最好的选择。基本保证指的是当异常发生时,没有内存泄露且对象可以继续使用即使相关数据可能被修改。
    4.异常安全类。 类即使在不安全的函数中使用也是异常安全的。如果一个类的构造函数在中途退出,那么为其内未构造对象并不会调用其相应的析构函数。但是,动态分配的内存和资源如果没有通过智能指针进行管理, 则会发生泄漏。内建类型的构造是no-fail guarantee的, 且标准库支持的类型最少都是basic guarantee的。使用以下建议构造的自定义类型也是异常安全的:a) 使用智能指针或者其他RAII方法来进行管理所有的资源;b)注意在基类的构造函数中抛出异常并不能被其派生类“吞掉”;c) 考虑将类的所有状态封装在一个用智能指针管理的数据成员之中;d) 不要让异常逃离析构函数。


异常代码与无异常代码之间的接口

有时候C++模块必须和不使用异常的代码通过接口进行交互。这样的接口称之为异常边界。例如一些其他模块的代码不抛出异常,但会返回或者设置全局的错误码(Error Code)变量。如果你对自己C++工程比较注重,希望使用从头到尾的使用基于异常的错误处理策略。那么此时就不会在自己的代码工程中放弃使用基于异常的错误处理策略, 也不希望混合使用异常与无异常的错误处理策略。

在异常代码模块中调用无异常代码
但在C++中调用了一个无异常函数时, 可以将其封装在一个C++函数中并在内部对产生的错误码进行检测同时抛出相关的异常。当在设计一个这样的函数时:首先,要决定使用哪种异常可以保证有三种异常保证(no-throw, strong, basic);其次, 要保证所有的资源在抛出异常时可以得到正确地释放,也就是说需要使用智能指针或者相似的资源管理对象来管理所拥有的资源。

在无异常代码模块中调用异常代码
当C++函数使用"extern C"修饰符的时候就可以被C程序调用。C++ COM服务可以被任意语言编写的代码所调用。 在无异常的代码中调用会抛出异常的C++函数,那么C++函数就不能让任何异常逃离这个函数。换句话说,就是这个函数必须"吞掉"所有它可以处理的异常, 并且返回适当的error code。如果有未知的异常抛出, 这必须使用catch(…)块作为最后的handle,与此同时返回一个fatal error给调用者。

BOOL DiffFiles2(const string& file1, const string& file2)
{
    try
    {
        File f1(file1);
        File f2(file2);
        if (IsTextFileDiff(f1, f2))
        {
            SetLastError(MY_APPLICATION_ERROR_FILE_MISMATCH);
            return FALSE;
        }
        return TRUE;
    }
    catch(Win32Exception& e)
    {
        SetLastError(e.GetErrorCode());
    }

    catch(std::exception& e)
    {
        SetLastError(MY_APPLICATION_GENERAL_ERROR);
    }
    return FALSE;
}

将异常转换为错误码之后,一个潜在的问题就是错误码并没有异常所自带的丰富信息。为了解决这个问题,可以在提供的各个catch模块中增加日志来记录异常相关信息。如果工程中有很多这样的catch块,这种方案会导致存在很多的重复性代码。一个简单的方法就是对这些相似的代码块进行重构,将try…catch块封装进入一个接受函数对象的函数中,并在其内的try块内调用该函数。在每个公共函数中,将各自的代码以lambda表达式传入该函数。

template<typename Func>
bool Win32ExceptionBoundary(Func&& f)
{
    try
    {
        return f();
    }
    catch(Win32Exception& e)
    {
        SetLastError(e.GetErrorCode());
    }
    catch(const std::exception& e)
    {
        SetLastError(MY_APPLICATION_GENERAL_ERROR);
    }
    return false;
}

bool DiffFiles3(const string& file1, const string& file2)
{
    return Win32ExceptionBoundary([&]() -> bool
    {
        File f1(file1);
        File f2(file2);
        if (IsTextFileDiff(f1, f2))
        {
            SetLastError(MY_APPLICATION_ERROR_FILE_MISMATCH);
            return false;
        }
        return true;
    });
}

catch块异常类型的匹配

当一个异常被抛出时,它可以被下列的catch handler捕获:

  1. 一个可以接受任何类型的handler, 即catch(…)。
  2. 一个可以接收和异常相同类型的handler, 因为是拷贝,const 和 volatile修饰符会被忽略。
  3. 一个可以接收和异常相同类型引用的handler。其中类型可以被const 和 volatile修饰。
  4. 一个可以接收异常类型基类的handler。
  5. 一个可以接受异常类型基类引用的handler。其中类型可以被const 或者 volatile修饰。
  6. 一个可以接受能够使用标准指针转换规则的指向抛出的指针对象的指针类型的handler。

因为同一个异常类型对象可以被多个handler捕获,因此在代码中各个handler的顺序也是非常重要的。不恰当的handler排序有时候起不到应有的效果。

C++ 异常时的栈展开

在C++的异常机制中, 程序会直接从抛出异常的地方跳转到能够处理该异常的handler中。当进入相应的catch块时, 进程中的throw语句与catch语句之间的自动变量都会被销毁, 这称之为栈展开(stack unwinding)。在栈展开的过程中, 执行过程如下:

  1. 控制流按照正常的顺序执行至try语句时, try块中的代码继续执行。
  2. 当try块中的代码无异常抛出时,try块之后的catch块不会再继续执行。catch块之后的代码继续执行。
  3. 如果try块中因为某种原因抛出了异常,此时编译器会寻找恰当的catch handler来处理。如果当前代码块中没有找到合适的handler, 则会继续往上进行寻找。一层一层的重复此过程。
  4. 如果最终没有找到合适的handler, 那么就调用预定义的运行时函数terminate函数。如果在一个异常抛出之在抛出一个异常,而栈的展开又没有开始, 此时也会调用terminate
  5. 如果找到了合适的异常处理handler,会初始化一些格式化的参数并开始栈的展开。这里涉及到销毁在try块开始和异常抛出代码之间的未销毁但已经完全构建好的变量。当该handler执行完毕之后, 程序会恢复执行。控制流只能在抛出异常后进入一个catch handler中, 所以不要通过goto语句或者switch…case语句进入catch handler。

Reference

[1] Exception handling in MSVC

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值