跳转至

Exception⚓︎

2331 个字 69 行代码 预计阅读时间 13 分钟

C++ 中,运行时错误(run-time error) 指的是程序在执行过程中发生的意外情况。虽然 C++ 的基本哲学强调“格式错误的代码将无法运行”,但我们仍需考虑并处理程序未来运行中可能出现的各种异常情况。

所谓异常(exception),是指程序在运行时遇到的不正常的或意外的情况,这些情况可能导致程序无法按照正常流程继续执行。异常机制提供了一种结构化的方式来处理这些运行时的错误,通过将描述程序“要做什么”的核心逻辑代码与处理错误情况的代码分离开来,使得程序结构更加清晰。

当一个函数可能抛出异常时,调用者可以选择不同的处理策略:

  • 十分关心:使用 try-catch 块捕获并处理特定类型的异常,阻止异常继续传播。
  • 适中的关心:捕获异常进行部分处理(如记录日志,然后使用 throw; 重新抛出异常,让上层调用者继续处理。
  • 不关心某些特殊情况:使用多个 catch 块分别处理特定的异常类型,而使用 catch (...) 处理所有其他类型的异常。
  • 毫不关心:调用者代码可能甚至没有意识到问题,异常会继续向上层传播,直到找到匹配的 catch 块或导致程序终止。

异常适用于处理程序无法在当前上下文继续执行的错误情况。当无法在发生问题时知道如何处理它,但知道不能若无其事地继续时,就应该考虑抛出异常。异常将问题“抛给”调用者(或其上层的调用者)来负责处理。

异常处理的代码框架如下:

try {
    // 可能抛出异常的代码
} catch (ExceptionType1 param1) {
    // 处理 ExceptionType1 类型的异常
} catch (ExceptionType2 param2) {
    // 处理 ExceptionType2 类型的异常
} catch (...) {
    // 处理所有其他类型的异常
}
  • try 语句块:用于包含可能抛出异常的代码

    • 一个 try 块可以跟随任意数量的 catch 块,用于设置想要处理的特定异常情况
    • 没有 try 块的代码区域发生的异常将直接向上层传播
    • 异常处理(查找和匹配 catch 块)是需要成本的,会影响到程序性能
  • catch 语句块(异常处理函数:用于捕获和处理特定类型的异常

    • 根据异常的类型来选择处理哪个 catch
    • 可以重新抛出捕获到的异常(throw;
    • 有以下两种基本形式(都只接受一个参数,表示捕获到的异常对象

      catch (SomeType v) {
          // 处理 SomeType 类型的异常
      }
      
      catch (...) {
          // 处理所有类型的异常 (通配符)
      }
      
    • catch 块会根据代码顺序从上到下检查所有的处理函数:

      • 首先查找完全匹配的异常类型
      • 然后应用基本的类转换,这仅适用于捕获引用&)或指针*)类型的异常,允许捕获基类引用 / 指针来处理派生类异常(多态)
      • 最后,如果都没有匹配,省略号(...)会匹配所有类型的异常

Throwing Execption⚓︎

C++ 中,我们使用关键字 throw 语句抛出异常。我们可以抛出任何类型的对象作为异常,并且一般会定义专门的异常类来携带错误信息。throw 语句有两种主要形式:

  • throw expression;:这是最常见的形式,用于抛出一个新的异常对象。expression 可以是任何类型、任何值的表达式。这个 expression 的值会被用来初始化一个临时的异常对象,该对象被传递给异常处理机制。

    例子
    template <class T>
    T& Vector<T>::operator[](int indx) {
        if (indx < 0 || indx >= m_size) {
            // 抛出一个表示索引越界的异常对象
            // 这里抛出一个 VectorIndexError 类型的对象
            throw VectorIndexError(indx);
        }
        return m_elements[indx];
    }
    

    我们几乎可以抛出任何类型的值,但通常推荐抛出类类型的对象,特别是继承自 std::exception标准异常类或自定义异常类,以便能够携带更多关于错误的信息。

    • 抛出对象(推荐throw MyErrorType("Detail message");
    • 抛出基本类型(不推荐throw 42;(缺乏错误信息)
    • 抛出指针(非常不推荐throw new MyErrorType();(需要在 catch 块中手动 delete,容易内存泄漏)

    throw expression; 被执行时:

    • 当前函数的执行立即停止
    • 程序开始栈展开(stack unwinding) 过程,从当前作用域开始,依次调用局部对象的析构函数
    • 在栈展开过程中,程序向上层调用栈查找匹配的 catch 块;查找过程会考虑异常对象的类型以及 catch 块声明的参数类型,包括基类到派生类的引用 / 指针转换
    • 找到匹配的 catch 块后,栈展开停止,控制权转移到该 catch 块的开头
    • 如果一直追溯到 main 函数仍然没有找到匹配的 catch 块,则调用 std::terminate(),默认情况下会终止程序
  • throw;:这种形式称为重新抛出异常(re-throwing),它只能用在 catch 块内部。它的作用是将当前正在处理的异常(也就是刚刚被这个 catch 块捕获的那个异常)再次向上层抛出。

    例子
    void outer2() {
        String err("exception caught");
        try {
            func(); // func() 可能会抛出 VectorIndexError
        } catch (VectorIndexError& e) {
            // 捕获到 VectorIndexError 异常
            std::cout << err << ": " << e.diagnostic() << std::endl; // 进行局部处理 (例如日志记录)
    
            // 重新抛出同一个异常,让上层调用者处理
            throw; // 传播异常
        }
        // 如果没有重新抛出,并且没有其他异常,控制权会回到这里
        // 但因为上面 throw 了,控制权会继续向上层传播
    }
    

Exception and Inheritance⚓︎

继承机制可以用于构建异常类的层次结构。我们可通过定义一个基类异常和多个派生类异常,更灵活地组织和处理不同类型的错误。

例子
class MathErr {
    ...
    virtual void diagnostic();
};
class OverflowErr : public MathErr { ... }
class UnderflowErr : public MathErr { ... }
class ZeroDivideErr : public MathErr { ... }

在使用处理函数时,可以先捕获派生类异常进行特定处理,再捕获基类异常处理更通用的错误:

try {
    // code to exercise math options
    throw UnderFlowErr(); // 抛出派生类异常
} catch (ZeroDivideErr& e) {
    // 处理 ZeroDivideErr (完全匹配)
    // handle zero divide case
} catch (MathErr& e) {
    // 处理 MathErr 及其派生类 (如 UnderflowErr)
    // handle other math errors
} catch (...) {
    // 处理任何其他异常
}

Exception in Standard Library⚓︎

C++ 标准库提供了一套标准的异常类,它们通过继承构建了层次结构,通常用于报告标准库函数可能发生的错误。常见的标准库异常包括:

  • std::exception:所有标准库异常的基类
  • std::bad_alloc:当 new 操作失败无法分配内存时抛出
    • new 操作在失败时不会返回 nullptr(除非指定了 std::nothrow,而是默认抛出一个 std::bad_alloc 异常
  • std::runtime_error:运行时错误的基础类,如 std::overflow_errorstd::range_error
  • std::logic_error:程序逻辑错误的基础类,如 std::domain_errorstd::invalid_argumentstd::out_of_rangestd::length_error

Exception Specifications⚓︎

异常规范(exception specification)(throw(...))用于声明函数可能抛出的异常类型。

void abc(int a) throw(MathErr) {
    ... // 可能抛出 MathErr 类型的异常
}

void goodguy() throw() {
    // 声明不抛出任何异常
}

void average() { } // 没有异常规范,可能抛出任何异常

void lala() noexcept; // C++11 引入,明确表示函数不会抛出异常

需要注意的是,C++11 引入了 noexcept 关键字来取代传统的 throw(...) 异常规范,并且强烈建议使用 noexcept。传统的异常规范在 C++11 后已被弃用并在 C++20 中移除,因为它存在一些问题:

  • 它们不在编译时检查
  • 在运行时,如果函数抛出了一个不在其 throw 列表中声明的异常,则会引发 std::unexpected 异常,默认情况下会调用 std::terminate() 终止程序

Exception in Ctors and Dtors⚓︎

构造函数的一个特殊之处在于它没有返回值,因此无法通过返回值来指示失败。

  • 传统的处理构造函数失败的方法包括使用“未初始化的标志”或将初始化工作放在一个单独的 Init() 函数中
  • 但更好的做法是在构造函数中抛出异常来指示初始化失败
    • 当构造函数抛出异常时,该对象的析构函数不会被调用
    • 因此,在构造函数中抛出异常前,必须确保已经分配的资源被正确清理,这通常通过 RAII (Resource Acquisition Is Initialization) 技术来实现

析构函数在以下情况下会被调用:

  • 正常调用:对象离开其作用域时
  • 发生异常时:在栈展开过程中,当异常向上层传播,经过一个对象的生命周期范围时,该对象的析构函数会被调用以进行资源清理

注意

在析构函数中抛出异常是非常危险的。如果一个析构函数本身是因为另一个异常正在传播而进行栈展开时被调用,并且这个析构函数又抛出了新的异常,程序将会调用 std::terminate() 函数,默认情况下会直接终止程序。因此,应避免在析构函数中抛出异常。

Summary⚓︎

在设计异常时,应遵循以下原则:

  • 异常需要反映错误:异常应该用于指示程序发生了无法正常处理的错误情况,而不是用于正常的控制流程。
  • 不要用异常替换良好的设计:异常是处理错误的机制,而不是编写不良代码的借口。在可以利用其他语言特性(如 RAII)来简化资源管理时,应优先考虑。

最后我们来总结一下 C++ 异常机制的用途:

  • 实现了动态传播错误信息
  • 在异常传播过程中,栈上的对象会被正确销毁
  • 可以用来终止那些无法继续执行的函数
  • 特别适用于处理构造函数无法完成任务的情况

合理地使用异常可以提高代码的可读性、可维护性和健壮性,将错误处理与核心业务逻辑分离开来。但在设计和使用异常时,也需要仔细权衡,避免滥用导致代码复杂或引入新的问题。

评论区

如果大家有什么问题或想法,欢迎在下方留言~