搜索

C++ 教程

C++基础

第1章 搭建开发环境与第一个程序 第2章 基本数据类型与变量 第3章 运算符与表达式 第4章 流程控制语句 第5章 数组与字符串 第6章 函数基础 第7章 指针基础 第8章 引用与const限定 第9章 动态内存管理 第10章 结构体与联合体 第11章 函数进阶

面向对象

第12章 面向对象编程入门 第13章 封装与访问控制 第14章 运算符重载 第15章 继承与派生 第16章 多态与虚函数

STL与进阶

第17章 模板编程基础 第18章 标准模板库(STL)容器 第19章 STL算法与迭代器 第20章 异常处理机制 第21章 文件输入输出 第22章 字符串类string 第23章 命名空间与作用域 第24章 类型转换操作符

现代C++

第25章 智能指针 第26章 移动语义与右值引用 第27章 Lambda表达式 第28章 多线程编程基础 第29章 模板元编程入门 第30章 设计模式基础 第31章 C++11/14新特性 第32章 C++17/20核心特性

实战进阶

第33章 性能优化技巧 第34章 调试与测试 第35章 项目结构与构建 第36章 网络编程入门 第37章 数据库连接操作 第38章 图形界面开发基础 第39章 综合项目实战 第40章 进阶学习路线

第20章 异常处理机制

第20章 异常处理机制

进阶 👤 已掌握基础语法和面向对象编程的开发者 🔧 构建健壮、可维护的应用程序,处理运行时错误
本章你将学到
  • 使用 try-catch 块捕获和处理异常
  • 自定义异常类并利用继承体系
  • 理解栈展开与资源管理(RAII)的配合
核心概念异常安全栈展开

想象一下,你正在操作一台自动咖啡机。正常情况下,你放入咖啡豆,按下按钮,得到咖啡。但如果咖啡豆卡住了,机器不会直接爆炸,而是会亮起红灯并发出“卡豆”的警报,让你有机会清理它,而不是让整个系统宕机。在C++中,异常处理就是这套警报和恢复机制,它允许程序在遇到无法在局部处理的错误(如文件不存在、内存不足、除零错误)时,跳出当前执行流,将错误信息“抛”给上层调用者处理,而不是让程序崩溃。

20.1 异常处理的基本语法:try, throw, catch

异常处理的核心是三个关键字:try、throw 和 catch。它们构成了一个“尝试执行-抛出问题-捕获处理”的流程。

异常处理流程

进入 try 块
→
执行代码
→
是否发生异常?
否
→
正常离开 try 块
是
→
执行 throw
→
栈展开,寻找匹配的 catch 块
→
执行 catch 块代码

try 块用于包裹可能抛出异常的代码。throw 用于在检测到错误时抛出一个异常对象。catch 块紧随 try 块之后,用于捕获并处理特定类型的异常。一个基本的例子:

#include <iostream>
#include <stdexcept>

int divide(int a, int b) {
    if (b == 0) {
        throw std::runtime_error("Division by zero!"); // 抛出异常
    }
    return a / b;
}

int main() {
    try { // 尝试执行
        int result = divide(10, 0);
        std::cout << "Result: " << result << std::endl;
    } catch (const std::runtime_error& e) { // 捕获异常
        std::cerr << "Error caught: " << e.what() << std::endl; // 输出 "Error caught: Division by zero!"
    }
    std::cout << "Program continues..." << std::endl;
    return 0;
}

关键点: throw 可以抛出任何类型的对象(基本类型、字符串、类对象),但最佳实践是抛出派生自 std::exception 的类对象,因为它们有统一的 what() 成员函数来获取错误描述。

常见误区

  • 滥用异常处理流程控制: 异常应用于处理“异常”情况(错误),而非正常的业务逻辑分支。用异常来实现类似 if-else 的逻辑会严重降低性能,我在一个高频交易系统的原型中踩过这个坑,性能直接下降了30%。
  • 捕获所有异常但不处理: 使用 catch (...) 捕获所有异常后,如果不做任何有意义的处理(如记录日志、清理资源)就直接忽略,会掩盖严重的程序缺陷,导致调试极其困难。
  • 在析构函数中抛出异常: 如果栈正在展开(即已经有异常被抛出),此时析构函数再抛出异常,程序会直接调用 std::terminate() 终止。这是C++异常机制的一个硬性规定。

20.2 标准异常类与自定义异常

C++标准库在 <stdexcept>、<new>、<typeinfo> 等头文件中定义了一套异常类体系,它们都继承自 std::exception。

异常类头文件典型抛出场景
std::logic_error<stdexcept>程序逻辑错误,如无效参数
std::runtime_error<stdexcept>运行时才能检测的错误,如文件打开失败
std::bad_alloc<new>new 操作符内存分配失败
std::bad_cast<typeinfo>dynamic_cast 对引用类型转换失败

对于特定领域的错误,你需要定义自己的异常类。一个好的自定义异常类应公开继承自标准异常类(通常是 std::runtime_error 或 std::logic_error),并提供一个能接受错误信息的构造函数。

#include <stdexcept>
#include <string>

class NetworkConnectionError : public std::runtime_error {
public:
    NetworkConnectionError(const std::string& ip, int port)
        : std::runtime_error("Failed to connect to " + ip + ":" + std::to_string(port)),
          ipAddress(ip), portNumber(port) {}

    const std::string& getIpAddress() const { return ipAddress; }
    int getPortNumber() const { return portNumber; }

private:
    std::string ipAddress;
    int portNumber;
};

// 使用
void connectToServer() {
    // ... 连接逻辑
    if (connection_failed) {
        throw NetworkConnectionError("192.168.1.100", 8080);
    }
}

编者提示: 自定义异常类充分利用了面向对象的继承特性(详见第15章)。通过建立异常类的继承体系,你可以用基类引用捕获一大类异常,也可以用派生类引用捕获特定异常,实现错误处理的精细化。

20.3 栈展开、异常安全与RAII

当 throw 语句执行时,程序控制流会立即离开当前函数,并沿着调用链向上回溯,这个过程称为栈展开。在回溯过程中,局部对象(在栈上分配)会按照构造相反的顺序被析构。

main()
↓ 调用
funcA()
↓ 调用
funcB()
↑ 栈展开
throw 异常
←
析构 funcB 局部对象
←
析构 funcA 局部对象

栈展开引出了异常安全的概念。一个函数是异常安全的,意味着即使有异常抛出,它也不会泄露资源(如内存、文件句柄)或破坏数据一致性。实现异常安全的核心是RAII。

RAII类比: 就像你进入一个房间会自动开灯(获取资源),离开时无论你是正常走出去还是被紧急叫走(发生异常),自动感应系统都会确保灯被关上(释放资源)。在C++中,RAII(Resource Acquisition Is Initialization,资源获取即初始化)是指将资源(内存、文件、锁)的生命周期绑定到一个局部对象的生命周期上,由对象的构造函数获取资源,析构函数释放资源。

由于栈展开时会自动调用局部对象的析构函数,因此RAII能保证资源在任何离开函数的方式(正常返回或异常抛出)下都能被正确释放。这是C++管理资源的根本大法。

#include <memory>
void processFile(const char* filename) {
    // 传统危险写法:如果中间抛出异常,文件句柄泄露,内存泄露
    // FILE* f = fopen(filename, "r");
    // int* buffer = new int[1024];
    // ... // 可能抛出异常的操作
    // fclose(f);
    // delete[] buffer;

    // RAII安全写法(C++11后)
    auto filePtr = std::unique_ptr<FILE, decltype(&fclose)>(fopen(filename, "r"), &fclose);
    auto bufferPtr = std::make_unique<int[]>(1024);

    if (!filePtr) {
        throw std::runtime_error("Failed to open file");
    }
    // ... 即使这里抛出异常,filePtr和bufferPtr的析构函数也会被调用,自动释放资源
}

关键点: 实际开发中,绝大部分资源管理都应通过RAII对象来完成,例如使用 std::unique_ptr(第25章)、std::shared_ptr、std::lock_guard 等。这能从根本上避免资源泄露,是实现强异常安全保证的基础。

20.4 异常规格与noexcept

在C++11之前,可以使用异常规格声明函数可能抛出的异常类型,如 void func() throw(std::runtime_error);。但这种动态检查机制问题很多,在C++11中已被弃用。

C++11引入了 noexcept 关键字,它表示函数承诺不会抛出任何异常。这有两层重要意义:

  1. 编译器优化: 编译器知道 noexcept 函数不会抛出,可以生成更高效的代码,并省略为栈展开准备的额外簿记信息。
  2. 标准库要求: 许多标准库操作(如 std::vector 的 resize、移动构造函数/赋值运算符)在特定条件下要求操作是 noexcept 的,以提供强异常安全保证或启用优化路径。
class MyMovableType {
public:
    // 移动构造函数标记为noexcept,使std::vector在扩容时能使用更高效的移动而非复制
    MyMovableType(MyMovableType&& other) noexcept
        : data(other.data), size(other.size) {
        other.data = nullptr;
        other.size = 0;
    }
    // 析构函数通常也应是noexcept的
    ~MyMovableType() noexcept {
        delete[] data;
    }
private:
    int* data;
    size_t size;
};

void simpleFunction() noexcept { // 承诺不抛异常
    // 只能调用其他noexcept函数或进行不会抛出的操作
}

建议: 对于移动操作、交换操作、析构函数,如果确实能保证不抛出异常,就加上 noexcept。对于其他函数,除非你非常确定其内部和所有调用都不会抛出,否则不要轻易使用 noexcept,因为违反 noexcept 承诺会导致程序调用 std::terminate() 直接终止。

何时使用异常?

  • 处理外部、不可预测的错误:文件I/O失败、网络断开、内存不足。
  • 错误需要跨多层函数调用向上传递到合适的处理层。
  • 构造函数失败(构造函数没有返回值,报告错误的唯一标准方式就是抛出异常)。

何时避免异常?

  • 用于常规控制流(如遍历结束)。
  • 在实时系统或性能极端敏感的代码中(异常处理有开销)。
  • 在C语言接口或需要与不支持异常的语言交互的模块边界。

错误处理替代方案

  • 返回错误码(简单,但需要每层检查,易遗漏)。
  • 返回 std::optional 或 std::expected(C++17/23,用于可能失败且有返回值的函数)。
  • 使用断言 assert(仅用于调试阶段捕捉程序逻辑错误,发布版本通常被禁用)。

动手试一试

1. 编写一个函数 readIntegerFromFile,接收一个文件名作为参数。尝试打开文件并读取一个整数。如果文件无法打开,抛出 std::runtime_error;如果文件内容不是有效整数,抛出 std::invalid_argument。在 main 函数中使用 try-catch 块捕获这两种异常并打印不同的错误信息。

2. 创建一个自定义异常类 InvalidConfigError,继承自 std::logic_error。为其添加一个成员变量存储错误的配置项名称。编写一个函数模拟读取配置,当遇到未知配置项时抛出此异常。

3. (挑战)修改第2题中的函数,使用RAII原则管理一个模拟的“配置解析器句柄”(可以用一个简单的 int 或 std::unique_ptr 模拟),确保在函数因异常退出时,该“句柄”能被正确“释放”(打印一条释放消息)。

检验你的理解

  1. 如果在一个函数内,try 块中抛出了异常,但该函数内没有匹配的 catch 块,会发生什么?
  2. 为什么说在析构函数中抛出异常是危险的?在什么情况下程序会直接终止?
  3. noexcept 关键字的主要作用是什么?将一个函数标记为 noexcept 后,它内部还能调用可能抛出异常的函数吗?
  4. 请简述RAII如何帮助实现异常安全。它与智能指针(第25章内容)有什么关系?

本章小结

  • 异常处理通过 try(尝试)、throw(抛出)、catch(捕获)三个关键字,提供了将错误信息跨函数边界传递的机制。
  • 应优先使用标准库异常类(如 std::runtime_error),并通过公开继承它们来创建具有领域意义的自定义异常类。
  • 异常抛出会触发栈展开,自动析构栈上的局部对象。结合RAII(资源获取即初始化)技术,将资源管理绑定到对象生命周期,是确保异常安全、避免资源泄露的黄金法则。
  • noexcept 说明符用于承诺函数不抛出异常,有助于编译器优化并满足某些标准库操作的强异常安全要求。
  • 异常适用于处理罕见的、跨层的错误,不应替代正常的流程控制,并需注意在性能关键或与外部接口交互的场景下的使用限制。

意见反馈

微信二维码 微信扫码关注 抖音二维码 抖音扫码关注