跳转到内容

C++ 编程/异常处理

来自维基教科书,开放的书籍,开放的世界

异常处理

[编辑 | 编辑源代码]

异常处理 是一种旨在处理异常发生的构造,异常是指改变程序执行正常流程的特殊情况。在设计编程任务(类甚至函数)时,不能总是假设应用程序/任务会正常运行或完成(退出并获得预期结果)。它可能是在给定任务中,报告错误消息(返回错误代码)或仅仅退出是不合适的。为了处理这些类型的情况,C++ 支持使用语言构造来将错误处理和报告代码与普通代码分离,也就是说,可以处理这些异常(错误和异常)的构造,因此我们称这种为程序设计添加统一性的全局方法为异常处理

在某个地方检测到错误或异常情况时,就会说抛出异常。抛出将导致正常的程序流程中止,进入异常状态。抛出异常是程序性的,程序员指定了抛出的条件。

已处理异常中,程序的执行将在一个指定的代码块(称为catch 块)处恢复,该块在程序执行方面包含抛出点。catch 块可以(通常是)位于与抛出点不同的函数/方法中。通过这种方式,C++ 支持非局部错误处理。除了改变程序流程之外,抛出异常还会将一个对象传递给 catch 块。这个对象可以提供处理代码所需的数据,以便处理代码能够决定如何对异常做出反应。

请考虑以下代码示例,该示例是trycatch 块组合以供说明。

void AFunction()
{
    // This function does not return normally, 
    // instead execution will resume at a catch block.
    // The thrown object is in this case of the type char const*,
    // i.e. it is a C-style string. More usually, exception
    // objects are of class type.
    throw "This is an exception!"; 
}

void AnotherFunction()
{
    // To catch exceptions, you first have to introduce
    // a try block via " try { ... } ". Then multiple catch
    // blocks can follow the try block.
    // " try { ... } catch(type 1) { ... } catch(type 2) { ... }"
    try 
    {
        AFunction();
       // Because the function throws an exception,
       // the rest of the code in this block will not
       // be executed
    }
    catch(char const* pch)  // This catch block 
                            // will react on exceptions
                            // of type char const*
    {
        // Execution will resume here.
        // You can handle the exception here.
    }
               // As can be seen
    catch(...) // The ellipsis indicates that this
               // block will catch exceptions of any type. 
    {
       // In this example, this block will not be executed,
       // because the preceding catch block is chosen to 
       // handle the exception.
    }
}

另一方面,未处理异常将导致函数终止,并且堆栈将展开(堆栈分配的对象将调用析构函数),因为它正在寻找异常处理程序。如果找不到,最终会导致程序终止。

从程序员的角度来看,引发异常是通知例程无法正常执行的一种有用方法。例如,当输入参数无效时(例如,除法中的零分母),或者当它依赖的资源不可用时(例如,缺少文件或硬盘错误)。在没有异常的系统中,例程需要返回一些特殊的错误代码。然而,这有时会因半谓词问题而变得复杂,在这种问题中,例程的用户需要编写额外的代码来区分正常的返回值和错误的返回值。

由于很难编写异常安全的代码,因此只有在必须使用时才应该使用异常,即当发生无法处理的错误时。不要将异常用于程序的正常流程。

此示例是错误的,它演示了要避免的内容。

void sum(int iA, int iB)
{
    throw iA + iB;
}

int main()
{
    int iResult;

    try 
    {
        sum(2, 3);
    }
    catch(int iTmpResult)  
    {
        // Here the exception is used instead of a return value!
        // This is  wrong!
        iResult = iTmpResult;
    }

    return 0;
}

栈展开

[编辑 | 编辑源代码]

考虑以下代码。

void g()
{ 
    throw std::exception();
}
 
void f()
{
    std::string str = "Hello"; // This string is newly allocated
    g();
}
 
int main()
{
    try
    {
        f();
    }
    catch(...) 
    { }
}

程序流程

  • main() 调用 f()
  • f() 创建一个名为 str 的局部变量。
  • str 构造函数分配一个内存块来保存字符串 "Hello"
  • f() 调用 g()
  • g() 抛出异常
  • f() 没有捕获异常。
由于未捕获到异常,因此我们需要以干净的方式退出 f()
此时,将调用抛出之前的所有局部变量的析构函数。
这称为“堆栈展开”。
  • 将调用 str 的析构函数,它将释放其占用的内存。
如您所见,“堆栈展开”机制对于防止资源泄漏至关重要,如果没有它,str 永远不会被销毁,它使用的内存将一直保留到程序结束(甚至直到下次断电或冷启动,具体取决于操作系统内存管理)。
  • main() 捕获异常
  • 程序继续执行。

“堆栈展开”保证了在离开其范围时将调用局部变量(堆栈变量)的析构函数。

抛出对象

[编辑 | 编辑源代码]

有几种方法可以抛出异常对象。

抛出指向对象的指针

void foo()
{
    throw new MyApplicationException();
}

void bar()
{
    try 
    {
        foo();
    }
    catch(MyApplicationException* e)
    {
        // Handle exception
    }
}

但现在,谁负责删除异常?处理程序?这使得代码更难看。必须有更好的方法!

怎么样?

void foo()
{
    throw MyApplicationException();
}

void bar()
{
    try 
    {
        foo();
    }
    catch(MyApplicationException e)
    {
        // Handle exception
    }
}

看起来好多了!但是现在,捕获异常的 catch 处理程序,它是按值捕获的,这意味着将调用复制构造函数。如果捕获的异常是由于内存不足而导致的 bad_alloc,这会导致程序崩溃。在这种情况下,似乎安全的代码被假定为处理内存分配问题,但会导致程序因异常处理程序的失败而崩溃。此外,按值捕获可能会导致复制行为不同,因为对象被切片了。

正确的方法是

void foo()
{
    throw MyApplicationException();
}

void bar()
{
    try 
    {
        foo();
    }
    catch(MyApplicationException const& e)
    {
        // Handle exception
    }
}

这种方法具有所有优点,编译器负责销毁对象,并且在捕获时不会进行任何复制!

结论是,异常应该按值抛出,并按(通常是 const)引用捕获。

finally 关键字

[编辑 | 编辑源代码]

考虑以下代码片段。

try
{
void x()
{
throw m();
}
}
catch n();
{
std:cout << "Exception caught\n";
}

执行此代码时,程序将查找异常的 catch 块,但不存在。因此它会崩溃,不会继续执行。
finally 关键字允许在崩溃之前执行一些最终代码。

finally
{
// residual code that will execute in any case
}

请注意,如果捕获了异常,则 try-catch 块后的行将照常执行。因此,finally 块只在没有匹配的 catch 块时才会生效。

构造函数和析构函数

[编辑 | 编辑源代码]

当从构造函数中抛出异常时,该对象不视为已实例化,因此其析构函数不会被调用。但是,相同主对象的已成功构造的基类和成员对象的所有析构函数将被调用。相同主对象的尚未构造的基类或成员对象的析构函数将不会执行。例如

class A : public B, public C
{
public:
    D sD;
    E sE;
    A(void)
    :B(), C(), sD(), sE()
    {
    }
};

假设基类 C 的构造函数抛出异常。然后执行顺序为

  • B
  • C(抛出异常)
  • ~B

假设成员对象 sE 的构造函数抛出异常。然后执行顺序为

  • B
  • C
  • sD
  • sE(抛出异常)
  • ~sD
  • ~C
  • ~B

因此,如果执行了某个构造函数,那么可以依赖于在该构造函数之前执行的相同主对象的另外所有构造函数都已成功完成。这使得能够使用已构造的成员或基类对象作为相同主对象的后续成员或基类对象构造函数的参数。

如果使用new分配此对象,会发生什么?

  • 为对象分配内存
  • 对象的构造函数抛出异常
    • 由于异常,对象未实例化
  • 删除对象占用的内存
  • 异常被传播,直到被捕获

从构造函数中抛出异常的主要目的是通知程序/用户对象创建和初始化未正确完成。这是一种提供此重要信息的非常干净的方式,因为构造函数不返回包含错误代码的单独值(如初始化函数可能那样)。

相反,强烈建议不要在析构函数内部抛出异常。重要的是要注意析构函数何时被调用

  • 作为正常释放(退出作用域,删除)的一部分。
  • 作为处理先前抛出的异常的堆栈展开的一部分。

在第一种情况下,在析构函数内部抛出异常可能会导致内存泄漏,因为对象被错误地释放了。在第二种情况下,代码必须更聪明。如果在另一个异常引起的堆栈展开过程中抛出了异常,则无法选择首先处理哪个异常。这被解释为异常处理机制的失败,并导致程序调用 terminate 函数。

为了解决这个问题,可以测试析构函数是否作为异常处理过程的一部分被调用。为此,应该使用标准库函数 uncaught_exception,如果抛出了异常但尚未捕获,它将返回 true。在这种情况下执行的所有代码都不得抛出其他异常。

需要这种小心编码的情况非常罕见。如果代码以析构函数根本不抛出异常的方式编写,则更安全、更容易调试。

编写异常安全代码

[edit | edit source]
异常安全性

如果代码中的运行时故障不会产生不良影响(例如内存泄漏、存储数据的混乱或无效的输出),则称该代码是异常安全的。即使出现异常,异常安全代码也必须满足代码上的不变量。异常安全有几个级别。

  1. 故障透明,也称为不抛出保证:即使在出现异常的情况下,操作也保证能够成功并满足所有要求。如果发生异常,它不会将异常进一步抛出。(最佳的异常安全性级别。)
  2. 提交或回滚语义,也称为强异常安全性无变化保证:操作可能会失败,但失败的操作保证不会产生副作用,因此所有数据保留原始值。
  3. 基本异常安全性:失败操作的部分执行可能会导致副作用,但状态上的不变量将被保留。即使数据现在的值与异常之前不同,任何存储的数据都将包含有效值。
  4. 最小异常安全性也称为无泄漏保证:失败操作的部分执行可能会存储无效数据,但不会导致崩溃,并且不会泄漏任何资源。
  5. 无异常安全性:不作任何保证。(最差的异常安全性级别)

部分处理

[edit | edit source]

考虑以下情况

void g()
{
    throw "Exception";
}
  
void f()
{
    int* pI = new int(0);
    g();
    delete pI;
}

int main()
{
    f();
    return 0;
}

你能看到这段代码中的问题吗?如果 g() 抛出异常,变量 pI 永远不会被删除,我们会发生内存泄漏。

为了防止内存泄漏,f() 必须捕获异常并删除 pI。但 f() 无法处理异常,它不知道如何处理!

那么解决方案是什么呢?f() 应该捕获异常,然后重新抛出它。

void g()
{
    throw "Exception";
}
  
void f()
{
    int* pI = new int(0);

    try
    {
        g();
    }
    catch (...)
    {
        delete pI;
        throw; // This empty throw re-throws the exception we caught
               // An empty throw can only exist in a catch block
    }

    delete pI;
}

int main()
{
    f();
    return 0;
}

不过,有一个更好的方法;使用 RAII 类来避免使用异常处理的需要。

保护
[edit | edit source]

如果你打算在代码中使用异常,你必须始终尝试以异常安全的方式编写代码。让我们看看可能会出现的一些问题。

考虑以下代码。

void g()
{ 
    throw std::exception();
}
 
void f()
{
    int* pI = new int(2);

    *pI = 3;
    g();
    // Oops, if an exception is thrown, pI is never deleted
    // and we have a memory leak
    delete pI;
}
 
int main()
{
    try
    {
        f();
    } 
    catch(...) 
    { }

    return 0;
}

你能看到这段代码中的问题吗?当抛出异常时,我们永远不会执行删除 pI 的那行代码!

对此的解决方案是什么?前面我们看到了基于 f() 捕获和重新抛出异常的能力的解决方案。但有一个更简洁的解决方案,它使用“堆栈展开”机制。但“堆栈展开”只适用于对象的析构函数,那么我们如何使用它呢?

我们可以编写一个简单的包装类

 // Note: This type of class is best implemented using templates, discussed in the next chapter.
 class IntDeleter {
 public:
    IntDeleter(int* piValue)
    {
        m_piValue = piValue;
    }
    
    ~IntDeleter() 
    {
        delete m_piValue;
    }
  
    // operator *, enables us to dereference the object and use it
    // like a regular pointer.
    int&  operator *() 
    {
        return *m_piValue;
    }

 private:
     int* m_piValue;
 };

f() 的新版本

 void f()
 {
   IntDeleter pI(new int(2));

   *pI = 3;
   g();
   // No need to delete pI, this will be done in destruction.
   // This code is also exception safe.
 }

这里展示的模式被称为保护。保护在其他情况下非常有用,它还可以帮助我们使代码更加异常安全。保护模式类似于其他语言中的finally块。

请注意,C++ 标准库提供了名为 unique_ptr 的模板保护。

异常层次结构

[edit | edit source]

你可以将对象(如类或字符串)、指针(如 char*)或基本类型(如 int)作为异常抛出。那么,你应该选择哪一个?你应该抛出对象,因为它们使程序员更容易处理异常。创建一个异常类层次结构很常见。

  • class MyApplicationException {};
    • class MathematicalException : public MyApplicationException {};
      • class DivisionByZeroException : public MathematicalException {};
    • class InvalidArgumentException : public MyApplicationException {};

一个例子

float divide(float fNumerator, float fDenominator)
{
    if (fDenominator == 0.0)
    {
        throw DivisionByZeroException();
    }

    return fNumerator/fDenominator;
}

enum MathOperators {DIVISION, PRODUCT};

float operate(int iAction, float fArgLeft, float fArgRight)
{ 
    if (iAction == DIVISION)
    {
        return divide(fArgLeft, fArgRight);
    }
    else if (iAction == PRODUCT))
    {
        // call the product function
        // ... 
    }

    // No match for the action! iAction is an invalid agument
    throw InvalidArgumentException(); 
}
 
int main(int iArgc, char* a_pchArgv[])
{
    try
    {
        operate(atoi(a_pchArgv[0]), atof(a_pchArgv[1]), atof(a_pchArgv[2]));
    } 
    catch(MathematicalException& )
    {
        // Handle Error
    }
    catch(MyApplicationException& )
    {
        // This will catch in InvalidArgumentException too.
        // Display help to the user, and explain about the arguments.
    }

    return 0;
}

注意
catch 块的顺序很重要。抛出的对象(例如,InvalidArgumentException)可以在其超类之一的 catch 块中被捕获。(例如,catch (MyApplicationException& ) 也将捕获它)。这就是将派生类的 catch 块放在其超类的 catch 块之前很重要的原因。

异常说明

[edit | edit source]

注意
在新标准 C++11 中,异常说明的使用已被弃用。建议不要使用它们。它们在这里是为了历史原因(并非所有人都在使用 C++11)。

函数可以抛出的异常范围是该函数公共接口的重要组成部分。没有这些信息,你将不得不假设任何函数调用时都可能发生任何异常,因此编写极具防御性的代码。了解可以抛出的异常列表,你可以简化代码,因为它不需要处理所有情况。

此异常信息是公共接口的特定部分。类的用户不需要了解它的实现方式,但他们需要了解可以抛出的异常,就像他们需要了解成员函数的参数数量和类型一样。向库的客户端提供此信息的其中一种方法是通过代码文档,但这需要非常小心地手动更新。不正确的异常信息比完全没有信息更糟糕,因为你最终可能会编写比你想要编写的代码更不安全的代码。

C++ 通过异常说明提供另一种记录异常接口的方法。异常说明由编译器解析,它提供一定程度的自动化检查。异常说明可以应用于任何函数,并且看起来像这样。

double divide(double dNumerator, double dDenominator) throw (DivideByZeroException);

你可以使用空异常说明指定函数不能抛出任何异常。

void safeFunction(int iFoo) throw();

异常说明的缺点

[edit | edit source]

C++ 在编译时不会以编程方式强制执行异常说明。例如,以下代码是合法的。

void DubiousFunction(int iFoo) throw()
{
    if (iFoo < 0)
    {
        throw RangeException();
    }
}

C++ 不是在编译时检查异常说明,而是在运行时检查异常说明,这意味着你可能直到测试时才意识到你的异常说明不准确,或者,如果你运气不好,代码已经在生产环境中运行时才意识到。

如果在运行时抛出了异常,该异常从不允许其异常说明中出现该异常的函数传播出去,则该异常不会进一步传播,而是函数 RangeException() 将被调用。RangeException() 函数不会返回,但可以抛出可能(也可能不)满足异常说明并允许异常处理正常进行的不同类型的异常。如果这仍然无法恢复情况,则程序将被终止。

许多人认为在运行时尝试转换异常的行为比简单地允许异常向上传播到可能能够处理它的调用者更糟糕。违反异常说明的事实并不意味着调用者不能处理这种情况,只是代码作者没有预料到这种情况。通常,堆栈上会存在一个 catch (...) 块,它可以处理任何异常。

注意
一些编码标准要求不使用异常说明。在 C++ 语言标准 C++11(C++0x) 中,当前版本标准 (C++03) 中指定的异常说明的使用已被弃用。异常说明的使用已被完全弃用,这意味着强烈建议不要使用它们。

Noexcept 说明符

[edit | edit source]
Clipboard

待办事项
添加 C++11 noexcept 说明符,包括 noexcept 运算符。

华夏公益教科书