c++异常


# C++ 异常处理深入理解:从基础到异常安全

初学异常时,我们通常只知道 `try-catch` 能捕获错误。但“什么是异常?什么时候用?如何不泄漏资源?怎样写出健壮代码?”这些才是深究的意义。这篇文章从**异常概念**出发,覆盖**异常抛出与捕获、重新抛出、异常规范(noexcept)**,并重点剖析**资源泄漏陷阱**、**RAII 资源管理**和**异常安全级别**,帮你把这块硬骨头啃下来。

## 1. 异常的概念

**异常**是程序在运行过程中出现的意外情况,它中断了正常的控制流。C++ 提供了一套异常处理机制,使得错误的发现(throw)和处理(catch)可以分离在不同代码层次,从而写出更清晰的错误处理逻辑。

**异常不是返回码**:它强制调用方必须处理或继续传递错误,否则程序会终止。异常通常用于:
- 罕见的、意外的错误(如除零、文件不存在、网络中断)。
- 构造函数中遇到的错误(因为构造函数没有返回值)。
- 深层调用链中发生的错误,且需要向上跨越多层直接处理。

## 2. 异常的基本用法:throw 与 try-catch

通过 `throw` 抛出异常,`try-catch` 捕获处理。

‍```cpp
#include <iostream>
#include <stdexcept>

double divide(double a, double b) {
    if (b == 0) {
        throw std::runtime_error("Division by zero!");
    }
    return a / b;
}

int main() {
    try {
        std::cout << divide(10, 0) << std::endl;
    } catch (const std::exception& e) {
        std::cerr << "Error: " << e.what() << std::endl;
    } catch (...) {
        std::cerr << "Unknown exception" << std::endl;
    }
}

推荐:抛出标准异常类(std::runtime_error​、std::logic_error​ 等),它们都继承自 std::exception​,可以用多态方式统一捕获,并通过 .what()​ 获取错误信息。尽量不要抛 std::string或原生类型,否则容易在 catch 链中丢失信息。

3. 栈展开与析构函数

异常抛出后,程序会沿着调用链向上“栈展开”,依次销毁各层局部对象,析构函数会被自动调用。这是 C++ 异常机制的核心优势。

struct Trace {
    Trace(const std::string& name) : name_(name) {
        std::cout << "Enter " << name_ << std::endl;
    }
    ~Trace() {
        std::cout << "Exit " << name_ << std::endl;
    }
    std::string name_;
};

void f2() {
    Trace t("f2");
    throw std::runtime_error("error in f2");
}

void f1() {
    Trace t("f1");
    f2();
}

int main() {
    try {
        f1();
    } catch (...) {}
}
// 输出:
// Enter f1
// Enter f2
// Exit f2
// Exit f1

可见局部对象 t 在栈展开时被安全销毁。这一特性为 RAII 打下了基础。

4. 异常的重新抛出

有时在某一层捕获了异常,进行部分处理(比如日志记录、资源释放),但该异常还需要由更高层调用者决定如何处理,这时候就需要重新抛出

4.1 直接 throw;

catch​ 块中写一个单独的 throw;​,它会将当前捕获的异常原样重新抛出,保留异常对象的类型和多态信息

void inner() {
    throw std::runtime_error("inner error");
}

void outer() {
    try {
        inner();
    } catch (...) {
        std::cerr << "Caught in outer, rethrowing..." << std::endl;
        throw;   // 重新抛出原来的 std::runtime_error
    }
}

4.2 捕获后抛出新的异常

也可以在 catch 中抛出一个全新的异常,这时原异常信息可能会丢失(除非嵌套)。

try {
    // ...
} catch (const std::exception& e) {
    throw std::runtime_error(std::string("Wrapped: ") + e.what()); // 新异常
}

注意:如果使用 throw e;​ 而不是 throw;​,当你通过基类引用捕获 e​ 时,会触发切片,仅抛出基类部分,丢失派生类信息。所以若要重新抛出原异常,一定要用无参数的 throw;

5. 异常规范:noexcept 与动态异常规范

5.1 动态异常规范(已废弃)

C++98/03 允许在函数声明中列出可能抛出的异常类型,称为“动态异常规范”,如:

void func() throw(std::runtime_error, std::logic_error); // 只允许抛出这两种异常
void func() throw();                                       // 不抛出任何异常

但该特性在实践中问题很多,比如编译期检查不严格、运行时意外(unexpected)会导致 std::unexpected​ 调用等,C++11 已将动态异常规范标记为废弃,C++17 完全移除。仅保留 throw()​ 作为 noexcept 的等价形式,但也不再推荐。

5.2 noexcept 规范(C++11)

C++11 引入了 noexcept 关键字,它更简洁,并支持编译时推导。

  • 声明函数不抛出异常

    void noThrowFunc() noexcept;          // 承诺不抛出异常
    void mayThrowFunc();                  // 可能抛出异常(默认)
    
  • noexcept的用途

    • 提供更优的优化机会(例如移动构造若为 noexcept,容器扩容时会更倾向使用移动而非拷贝)。
    • 表达清晰的语义:该函数是“不会失败”的操作。
    • 析构函数默认 noexcept(C++11 起),强烈建议析构函数永远不要抛出异常。
  • 条件 noexcept:可以用编译期表达式决定是否 noexcept:

    template<typename T>
    void swap(T& a, T& b) noexcept(noexcept(a.swap(b))) {
        a.swap(b);
    }
    
  • noexcept运算符:可在编译期检测一个表达式是否 noexcept:

    static_assert(noexcept(noThrowFunc()), "Should be noexcept");
    

最佳实践

  • 移动构造函数和移动赋值运算符尽可能标记 noexcept
  • 简单的 getter/setter、数学运算等不会失败的操作标记 noexcept
  • 析构函数绝对不要抛出异常(默认 noexcept 已保证)。

6. 资源泄漏的坑

注意:只有局部对象会被自动析构,原生指针、文件句柄、互斥锁等“裸资源”不会

void dangerous() {
    int* p = new int(42);       // 分配内存
    // … 操作中抛出异常
    throw std::runtime_error("oops");
    delete p;                   // 这行永远不会执行!
}

throw​ 后的代码被跳过,p​ 没有释放,造成内存泄漏。类似地:

  • 打开的文件(FILE*)未关闭;
  • 互斥锁 lock() 后未解锁;
  • 网络连接未断开等。

所有需要手动释放的资源,在异常抛出时都可能泄漏。

7. RAII:资源获取即初始化

解决之道就是 RAII(Resource Acquisition Is Initialization) :将资源封装到类中,构造函数获取资源,析构函数释放资源。当栈展开时,局部对象的析构函数会自动调用,资源被安全释放。

7.1 智能指针

std::unique_ptr​ 和 std::shared_ptr 是最常用的 RAII 工具。

#include <memory>

void safe() {
    auto p = std::make_unique<int>(42);  // 智能指针接管内存
    throw std::runtime_error("oops");
    // unique_ptr 的析构函数自动释放内存,绝无泄漏
}

7.2 文件流

std::fstream 本身就是 RAII 的:打开文件在构造时,关闭在析构时。

#include <fstream>

void writeFile() {
    std::ofstream f("data.txt");
    // 写操作,可能抛异常
    throw std::runtime_error("write failed");
    // f 的析构函数自动关闭文件
}

7.3 互斥锁

std::lock_guard​ 或 std::unique_lock 帮你管理锁。

#include <mutex>

std::mutex mtx;

void safeWithLock() {
    std::lock_guard<std::mutex> lock(mtx); // 构造加锁
    // 临界区,可能抛异常
    // lock 析构时自动解锁,不会死锁
}

原则:绝不使用裸资源,用 RAII 包裹一切需要手动管理的资源。

8. 构造/析构函数中的异常

构造函数中抛出异常:已经构造完成的成员变量和基类子对象会被自动销毁,但构造函数本身尚未完成的对象不会执行析构函数。因此,构造函数中若持有“裸资源”,必须在异常抛出前自行清理。

class Bad {
    int* p;
public:
    Bad() : p(new int(10)) {
        throw std::runtime_error("fail"); // 异常离开构造函数
        // p 泄漏!因为 Bad 的析构函数不会被调用
    }
    ~Bad() { delete p; }
};

正解:用 RAII 成员,例如将 int* p​ 改为 std::unique_ptr<int> p,即使构造失败,已构造的 RAII 成员也会自动析构。

析构函数中:绝不要抛出异常。如果析构函数抛出异常且正在处理另一个异常,程序会直接 std::terminate​。C++11 开始析构函数默认 noexcept,如需抛出请显式标记,但强烈不建议。

9. 异常安全的三个级别

函数在抛出异常时,应提供以下保障之一:

  1. 基本保证(Basic Guarantee)
    抛出异常后,程序状态仍然有效,没有资源泄漏,所有对象可析构。
  2. 强保证(Strong Guarantee)
    操作要么完全成功,要么完全回滚到操作前的状态(类似事务)。
  3. 不抛出保证(No-throw Guarantee)
    操作绝不可能抛出异常,通常用 noexcept 声明。

std::vector::push_back​ 为例,它通常提供强保证(如果扩容时元素移动构造可能抛出异常,则 vector 会保证原容器不变,但若移动构造为 noexcept,则能保证高效)。编写异常安全代码的核心技巧:

  • 先用 RAII 管理所有资源,获得基本保证。
  • 执行可能失败的操作时,先做副本修改,最后用不抛出操作交换(copy-and-swap 惯用法),实现强保证。

10. 常见误区与最佳实践

  • 误区1:捕获所有异常却不处理
    catch(...) { /* 空 */ } 会吞掉错误,导致程序带着非法状态继续运行。

  • 误区2:在 throw的位置还想继续执行
    异常就是用来中断当前流的,不要试图在 catch​ 里修复后跳回原处(没有 resume 语义)。

  • 误区3:用异常处理正常流程
    异常开销较大,应只用于错误处理,不要用它替代条件判断。

  • 误区4:使用已废弃的动态异常规范
    现代 C++ 一律使用 noexcept​,忘掉 throw(type_list) 写法。

  • 最佳实践总结

    • 抛标准异常类的子类,用引用捕获 const std::exception&
    • 用 RAII 包装所有资源,杜绝裸 new/delete
    • 析构函数绝不抛出异常。
    • 移动构造/赋值标记 noexcept
    • 重新抛出用 throw;,避免切片。
    • 明确每个函数的异常安全级别,并文档化。

11. 结语

从表面的 try-catch​ 到深层的资源安全,C++ 异常机制是一整套设计哲学。记住一句话:用 RAII 让资源与对象共生,用栈展开让异常处理变得自然。当你的代码充满 unique_ptr​、lock_guard​ 和 fstream​,并合理使用 noexcept 时,异常安全就是水到渠成的事了。

希望这篇文章帮你填平“资源泄漏的坑”,并建立起完整的异常知识体系,写出更健壮的现代 C++ 代码。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐