异常处理 C++ Exception Handling

一种优雅地处理运行时错误的机制,让您的代码更健壮、更可靠。

exception_demo.cpp
#include <iostream>
#include <stdexcept>

class ResourceManager {
private:
    int* resource;
public:
    ResourceManager() {
        try {
            resource = new int[1000];
            std::cout << "资源获取成功" << std::endl;
            
            // 模拟初始化失败
            throw std::runtime_error("初始化资源失败");
        } 
        catch (...) {
            delete[] resource;  // 清理已分配的资源
            throw;  // 重新抛出异常
        }
    }
    
    ~ResourceManager() {
        delete[] resource;
        std::cout << "资源已释放" << std::endl;
    }
};

int main() {
    try {
        std::cout << "创建资源管理器..." << std::endl;
        ResourceManager rm;
        std::cout << "使用资源..." << std::endl;
    } 
    catch (const std::exception& e) {
        std::cerr << "捕获到异常: " << e.what() << std::endl;
    }
    
    std::cout << "程序正常结束" << std::endl;
    return 0;
}
1

错误检测与处理分离

异常处理机制将检测错误的代码与处理错误的代码分离,使程序结构更清晰。

2

自动栈展开

异常发生时自动调用栈上对象的析构函数,确保资源正确释放,防止内存泄漏。

3

类型安全

C++异常处理是类型安全的,可以精确匹配异常类型,有针对性地处理不同错误情况。

第一章

什么是异常处理机制

理解C++中处理错误和异常情况的优雅方式

异常处理的定义

异常处理是C++提供的一种处理程序运行时错误的机制。它允许程序在遇到异常情况时能够优雅地处理错误,而不是直接崩溃。

异常处理机制将错误检测代码错误处理代码分离,使得程序结构更加清晰。这种分离使得开发者可以专注于正常逻辑的实现,而不必在每个操作后都检查错误状态。

关键点

异常处理不应用于处理正常的控制流程或可预期的程序状态,而应专注于处理那些真正的"异常"情况。

异常 vs 错误码

异常处理相比传统错误码方法,能更好地处理复杂调用链中的错误传播。

异常处理的优势

  • 将正常代码路径与错误处理代码分离,提高可读性
  • 错误不能被忽略,未处理的异常会终止程序
  • 能够在调用栈中传播错误信息,无需每个函数都处理错误
  • 适合处理构造函数中的错误(构造函数无法返回错误码)
  • 支持将异常对象的完整信息传递给处理程序

异常处理的局限性

  • 性能开销较大,特别是当异常被抛出时
  • 可能导致程序流程难以追踪,特别是在大型项目中
  • 不适合用于资源受限的嵌入式系统
  • 需要仔细设计以避免资源泄漏
  • 可能与某些编程范式(如实时系统)不兼容
第二章

C++异常处理的基本语法

掌握try, throw和catch的用法,建立异常处理的基础

基本组成部分

C++异常处理机制由三个关键部分组成,它们协同工作以检测和处理程序中的异常情况。

try 块

包含可能引发异常的代码

throw 语句

当检测到异常情况时,用于抛出异常

catch 块

用于捕获并处理异常

基本语法示例

basic_exception.cpp
#include <iostream>
using namespace std;

int main() {
    try {
        // 可能引发异常的代码
        int divisor = 0;
        if (divisor == 0) {
            throw "除数不能为零!";  // 抛出异常
        }
        int result = 10 / divisor;
    }
    catch (const char* msg) {
        // 处理异常
        cerr << "捕获到异常: " << msg << endl;
    }
    
    cout << "程序继续执行..." << endl;
    return 0;
}

执行流程分析

  1. 程序检测到除数为0的异常情况
  2. 通过throw语句抛出一个字符串异常
  3. 程序立即跳转到匹配的catch
  4. 执行catch块中的错误处理代码
  5. 继续执行try-catch块后的代码

自定义异常类

custom_exception.cpp
#include <iostream>
#include <exception>
using namespace std;

class MyException : public exception {
public:
    const char* what() const throw() {
        return "发生了自定义异常";
    }
};

int main() {
    try {
        throw MyException();
    }
    catch (const MyException& e) {
        cout << "捕获到异常: " << e.what() << endl;
    }
    return 0;
}

自定义异常类通常继承自std::exception基类,并重写what()方法来提供异常的描述信息。

标准异常类

C++ 标准库提供了一系列的异常类,都派生自std::exception基类:

std::logic_error

  • std::invalid_argument
  • std::domain_error
  • std::length_error
  • std::out_of_range

std::runtime_error

  • std::overflow_error
  • std::underflow_error
  • std::range_error
  • std::system_error

使用标准异常类可以让代码更加规范,并且与STL库的异常处理机制保持一致。建议自定义异常时继承这些标准异常类。

抛出与捕获的类型匹配

C++异常处理机制在捕获异常时,会按照catch块的顺序寻找第一个匹配的异常类型。匹配规则遵循C++的类型转换规则:

  • 精确匹配:抛出的异常类型与catch参数类型完全相同
  • 派生类匹配:抛出的异常是catch参数类型的派生类
  • 指针匹配:如果catch参数是指针类型,则允许从派生类指针到基类指针的转换
  • 引用匹配:如果catch参数是引用类型,则允许从派生类引用到基类引用的转换

最佳实践提示

按照从最具体到最一般的顺序排列多个catch块,以确保异常能被最合适的处理程序捕获。始终将基类异常的catch块放在后面。

第三章

异常处理的高级特性

探索C++异常处理的高级用法与机制

捕获所有异常

catch_all.cpp
try {
    // 可能抛出任何类型的异常
}
catch (const std::exception& e) {
    // 处理std::exception及其派生类异常
    std::cerr << "标准异常: " << e.what() << std::endl;
}
catch (...) {
    // 处理其它所有异常
    std::cerr << "捕获到未知类型的异常" << std::endl;
}

使用catch(...)作为最后一个catch块可以捕获任何类型的异常,但代价是无法获取异常的具体信息。这通常用作最后的安全网。

异常重新抛出

rethrow.cpp
try {
    // 外部try块
    try {
        // 内部try块
        throw std::runtime_error("原始错误");
    }
    catch (const std::exception& e) {
        std::cout << "内部捕获: " << e.what() << std::endl;
        // 执行一些清理或日志记录
        
        throw;  // 重新抛出当前异常
    }
}
catch (const std::exception& e) {
    std::cout << "外部捕获: " << e.what() << std::endl;
}

使用throw;语句(不带参数)可以重新抛出当前捕获的异常,保留原始异常的类型和所有信息。这对于需要在多个层次处理同一异常的情况非常有用。

函数异常规范

C++11之前的语法(已弃用)

deprecated_spec.cpp
// 指定可能抛出的异常类型(已弃用)
void func() throw(std::runtime_error, std::logic_error);

// 保证不会抛出任何异常(已弃用)
void safeFunc() throw();

这种语法在C++11标准中被弃用,在C++17中被完全移除,因为它在运行时检查异常,性能开销大且难以维护。

C++11引入的noexcept规范

noexcept_spec.cpp
// 保证不会抛出异常
void safeFunc() noexcept;

// 等同于 noexcept(true)
void alsoSafeFunc() noexcept(true);

// 可能抛出异常
void maybeThrow() noexcept(false);  

// noexcept可以与条件表达式结合使用
template <class T>
void processValue(T t) noexcept(noexcept(t.process()));

noexcept规范在编译时检查,如果一个声明为noexcept的函数抛出了异常且未被捕获,程序将调用std::terminate()终止。

noexcept的重要性

noexcept不仅是一种声明,也是优化的提示。标记为noexcept的函数可以被编译器更有效地优化,因为它不需要为异常处理设置额外的机制。

现代C++中,很多标准库函数和移动构造函数/赋值运算符都被标记为noexcept,以提供更好的性能保证。

异常处理的底层机制

堆栈展开(Stack Unwinding)

当异常被抛出时,C++运行时系统会沿着调用栈向上搜索匹配的catch处理程序。在这个过程中,它会:

  • 按照与创建顺序相反的顺序调用作用域内的局部对象析构函数
  • 释放临时对象
  • 从当前函数返回
  • 在调用者的上下文中继续上述过程

异常表(Exception Table)

编译器会生成特殊的表格,记录了try块的范围、相应的catch处理程序以及需要调用析构函数的对象信息。这些表格指导运行时系统如何执行堆栈展开。

运行时开销

异常处理机制的设计遵循"零开销原则":如果不使用它,就不会付出代价。只有在实际抛出异常时,才会产生明显的性能开销。

  • 设置try块的开销很小
  • 抛出异常的开销较大,包括对象复制和堆栈展开
  • 异常发生的频率通常较低,因此平均性能影响可控

堆栈展开示意图

堆栈展开确保了即使在异常发生时,所有已构造的对象都能被正确销毁,从而防止资源泄漏。

如果在堆栈展开过程中析构函数抛出异常,程序将调用std::terminate()终止。

第四章

异常处理的最佳实践

如何有效地在现代C++中使用异常处理机制

何时使用异常处理

异常情况

用于处理真正的异常情况,而非常规错误检查。例如,文件无法打开、网络连接中断等不寻常的情况。

构造函数错误

构造函数无法返回错误码,因此异常是处理构造函数中错误的最佳机制。

资源获取失败

当无法获取必要的资源(如内存、文件句柄、网络连接)时,抛出异常是合适的。

跨多层传播错误

当错误需要跨越多个函数调用层次传播时,异常比层层返回错误码更加优雅。

RAII原则

raii_example.cpp
#include <fstream>
#include <stdexcept>
#include <memory>

class FileResource {
private:
    std::ifstream file;
    
public:
    FileResource(const std::string& filename) : file(filename) {
        if (!file) {
            throw std::runtime_error("无法打开文件: " + filename);
        }
    }
    
    // 析构函数自动关闭文件
    ~FileResource() {
        if (file.is_open()) {
            file.close();
        }
    }
    
    // 禁止复制
    FileResource(const FileResource&) = delete;
    FileResource& operator=(const FileResource&) = delete;
    
    // 读取文件内容
    std::string readContent() {
        std::string content;
        std::string line;
        while (std::getline(file, line)) {
            content += line + '\n';
        }
        return content;
    }
};

void processFile(const std::string& filename) {
    FileResource resource(filename);  // RAII:资源获取即初始化
    std::string content = resource.readContent();
    
    // 处理文件内容...
    
    // 文件会在函数结束或异常发生时自动关闭
}

RAII(Resource Acquisition Is Initialization)原则确保资源在获取时就与对象的生命周期绑定,当对象被销毁时(无论是正常退出作用域还是因异常而退出),资源会自动释放。

异常处理的设计原则

异常中立代码

函数应该传递它不处理的异常,但不应该改变异常的类型,除非有充分的理由。这使调用者能够处理原始异常。

// 异常中立函数
template <class T>
void processData(T& data) {
    // 不捕获不需要处理的异常
    data.prepare();
    data.process();
    data.finalize();
}

避免析构函数抛异常

析构函数在堆栈展开过程中可能被调用。如果此时再抛出异常,会导致程序调用std::terminate()终止。

class Resource {
public:
    ~Resource() noexcept {  // 标记为noexcept
        try {
            // 可能抛出异常的清理代码
            closeConnection();
        }
        catch (...) {
            // 记录错误但不再抛出
            logError("清理资源时发生错误");
        }
    }
};

异常类层次结构

设计良好的异常层次结构能帮助程序更精确地捕获和处理不同类型的错误。保持层次结构简单但有意义。

class DatabaseError : public std::runtime_error {
    using std::runtime_error::runtime_error;
};

class ConnectionError : public DatabaseError {
    using DatabaseError::DatabaseError;
};

class QueryError : public DatabaseError {
    using DatabaseError::DatabaseError;
};

现代C++中处理异常的推荐做法

1

使用标准库异常类或自定义的派生异常类

利用标准库提供的异常类可以提高代码的可读性和可维护性。如需特殊的错误信息,可以派生自标准异常类。

2

按引用捕获异常,避免对象切片

不推荐

catch (std::exception e) { ... }

推荐

catch (const std::exception& e) { ... }
3

尽量使用noexcept标记不抛异常的函数

为不会抛出异常的函数添加noexcept声明,可以帮助编译器进行优化,并在代码审查时明确函数的行为。

4

使用智能指针管理动态分配的资源

利用std::unique_ptrstd::shared_ptr自动管理动态资源,防止在异常发生时内存泄漏。

5

不要过度使用异常

对于可预见的错误,如用户输入验证,使用错误码或返回std::optional/std::expected可能更合适。

异常处理的对比

选择合适的错误处理机制

  • 异常处理:用于不可预期的错误和异常情况
  • 错误码:用于频繁发生的错误和性能敏感的场景
  • 断言:用于检测开发过程中的程序逻辑错误
第五章

异常处理机制的比较

探讨C++异常处理与其他错误处理机制的异同

异常处理 vs 错误码 vs 断言

特性 异常处理 错误码 断言
用途 处理运行时异常情况 处理预期的错误情况 捕获程序逻辑错误
代码分离 错误检测与处理代码分离 错误检测与处理代码混合 仅检测错误,不处理
错误传播 自动沿调用栈传播 需要手动检查和传递 不传播(终止程序)
忽略风险 未捕获的异常终止程序 可能被忽略 不可忽略(触发即终止)
性能开销 未抛出时低,抛出时高 低且一致 仅在调试版本有开销
适用场景 异常情况,构造函数错误 频繁错误,性能敏感场景 开发调试阶段检查逻辑错误
错误信息 可传递详细的错误对象 通常只能传递简单的数值 可包含描述性消息

C++异常处理与Windows SEH异常的区别

Windows操作系统提供了结构化异常处理(SEH)机制,与C++标准的异常处理机制有所不同:

C++异常处理

  • 使用try、catch和throw关键字
  • 基于类型的异常匹配
  • 跨平台,符合C++标准
  • 通常处理程序逻辑错误
  • 可以捕获用户定义的异常类型

Windows SEH异常

  • 使用__try、__except和__finally关键字
  • 基于异常代码的过滤器
  • 仅限于Windows平台
  • 可处理硬件异常(如访问冲突)
  • 可以混合使用__finally块确保清理
seh_example.cpp
#include <windows.h>
#include <iostream>

// Windows SEH异常处理示例
int main() {
    __try {
        // 可能导致异常的代码
        int* p = nullptr;
        *p = 42;  // 将触发访问冲突异常
    }
    __except(EXCEPTION_EXECUTE_HANDLER) {
        // 处理异常
        std::cerr << "捕获到SEH异常!" << std::endl;
    }
    
    std::cout << "程序继续执行..." << std::endl;
    return 0;
}

编译器选项控制异常处理行为

Microsoft Visual C++编译器提供了多种选项来控制异常处理的行为,特别是C++异常与SEH异常的交互:

/EHs

标准C++异常

C++异常处理程序不捕获SEH异常。这是最常用的选项,提供标准C++行为。

/EHsc

同步C++异常

与/EHs类似,但通知编译器假设外部C函数不会抛出异常,允许更多优化。

/EHa

异步异常

允许C++异常处理程序捕获SEH异常。这使C++代码能够处理像访问冲突这样的硬件异常。

最佳实践

在大多数C++项目中,推荐使用/EHsc选项。只有在需要在C++代码中处理系统级异常时,才考虑使用/EHa选项。

总结

C++异常处理机制总结

掌握异常处理,构建更稳健的C++程序

关键要点回顾

1 异常处理基础

C++异常处理机制提供了一种强大的方式来分离错误检测与处理代码,使程序更清晰、更健壮。通过try、catch和throw关键字,可以实现错误的检测、抛出和捕获。

2 异常类层次结构

标准库提供了一套异常类,都派生自std::exception基类。创建自定义异常类时,最佳实践是从这些标准异常类派生,并重写what()方法提供有意义的错误信息。

3 堆栈展开机制

当异常被抛出时,C++运行时系统会自动执行堆栈展开,调用所有局部对象的析构函数,这确保了即使在错误情况下资源也能被正确释放。

4 RAII设计模式

结合RAII(资源获取即初始化)原则使用异常处理,可以创建异常安全的代码。将资源封装在对象中,利用对象的生命周期自动管理资源的获取和释放。

5 noexcept规范

现代C++中,应使用noexcept关键字明确标记不抛出异常的函数,这不仅是一种声明,也是一种优化提示,让编译器生成更高效的代码。

6 选择合适的机制

在实际项目中,需要根据具体情况选择异常处理、错误码或断言等不同机制。异常适合处理异常情况,错误码适合处理常见错误,断言适合捕获开发阶段的逻辑错误。

持续学习和实践

异常处理是C++中一个重要但复杂的主题。要真正掌握它,需要在实际项目中不断实践和总结经验。随着C++标准的发展,也要关注新的错误处理机制,如C++23中的std::expected

进一步阅读

书籍

  • 《Exceptional C++》 - Herb Sutter
  • 《C++ Coding Standards》 - Herb Sutter, Andrei Alexandrescu
  • 《Effective Modern C++》 - Scott Meyers

在线资源

  • C++ Reference - Exception Handling
  • CPPCon 讲座视频
  • ISO C++ 官方指南

相关话题

  • 智能指针与资源管理
  • 错误处理策略
  • Contract Programming
  • C++23 std::expected