写文件失败、内存不够、用户输入非法——程序里的错误无处不在。最原始的做法是让每个函数返回错误码,于是代码变成 if (ret != 0) return -1; 的海洋。C++ 的异常机制让你可以在出错的地方"原地抛出",让调用链上任意一层"接住",配合 RAII 还能自动清理资源。本文把这套机制讲透,并给出一个文件读取的完整实战。

1. 从错误码到异常

先看错误码的痛点。假设读取配置文件的函数返回 int,0 表示成功:

#include <iostream>

// 错误码方案:每层都要检查、都要传递
int read_config(const char* path) {
    FILE* f = fopen(path, "r");
    if (!f) return -1;          // 文件不存在
    fclose(f);
    return 0;
}

int load_app() {
    int ret = read_config("app.conf");
    if (ret != 0) return ret;   // 层层上抛,忘了检查就悄悄失败
    return 0;
}

int main() {
    if (load_app() != 0) {
        std::cout << "加载失败\n";
        return 1;
    }
    std::cout << "启动成功\n";
    return 0;
}

错误码方案有两个致命问题:一是中间层必须"记得"把错误传上去,漏掉一个检查,错误就石沉大海;二是错误码本身没有信息量,-1 到底是文件不存在还是权限不足?异常方案把"错误"变成"对象",自带类型和消息,还能跨任意多层直接传递——中间层即使什么都不写,异常也会自动穿过它。

2. try / throw / catch 基础

异常的三件套:throw 抛出、try 圈定监控范围、catch 接住处理:

#include <iostream>
#include <stdexcept>

double divide(double a, double b) {
    if (b == 0) {
        throw std::runtime_error("除数不能为 0");
    }
    return a / b;
}

int main() {
    try {
        std::cout << divide(10, 2) << "\n";   // 5
        std::cout << divide(10, 0) << "\n";   // 这里抛出
        std::cout << "这行不会执行\n";
    } catch (const std::runtime_error& e) {
        std::cout << "捕获: " << e.what() << "\n";
    }
    std::cout << "程序继续运行\n";
    return 0;
}

throw 后面的语句不再执行,控制流直接跳到最近的匹配 catch。注意 catch 的参数写成 const std::runtime_error&——按引用捕获,既避免拷贝,又能利用多态调用正确的 what()。另一个细节:throw 抛出去的是对象的"副本",不是原对象,catch 里拿到的和 throw 语句里的变量是两份,所以抛临时对象 throw std::runtime_error("...") 是最标准的写法,不背额外状态。标准异常都在 <stdexcept> 里,runtime_error 的 what() 返回错误描述字符串。如果不 catch,异常会一路冲出 main,程序直接调用 std::terminate 崩溃——所以顶层函数(比如 main)最好兜底 catch 一次。

3. 传播与栈展开

异常可以不马上处理,它沿着调用栈向上找 catch。这个过程叫栈展开:沿途所有已构造的局部对象都会被析构:

#include <iostream>
#include <stdexcept>
#include <string>
#include <utility>

struct Tracker {
    std::string name;
    explicit Tracker(std::string n) : name(std::move(n)) {
        std::cout << "构造 " << name << "\n";
    }
    ~Tracker() { std::cout << "析构 " << name << "\n"; }
};

void inner() {
    Tracker t("inner 局部对象");
    throw std::runtime_error("inner 出错");
}

void outer() {
    Tracker t("outer 局部对象");
    inner();   // 异常穿过 outer
}

int main() {
    try {
        outer();
    } catch (const std::exception& e) {
        std::cout << "捕获: " << e.what() << "\n";
    }
    return 0;
}

输出顺序:构造 inner → 构造 outer → 异常抛出 → 析构 inner → 析构 outer → 捕获。两个局部对象都被正确析构了,这就是栈展开的价值——即使你没写任何清理代码,局部对象自己会收拾干净。所以异常安全的第一原则是:资源都放进对象的构造函数,让析构函数去释放。析构顺序和构造相反(后构造的先析构),这是 C++ 保证的。

4. 异常类型与自定义异常

catch 的匹配规则是按类型匹配,先匹配先得。想"接住所有异常"用 catch (...),但它拿不到异常对象,只能兜底:

#include <iostream>
#include <stdexcept>

int main() {
    try {
        throw std::out_of_range("下标越界");
    } catch (const std::out_of_range& e) {
        std::cout << "具体处理: " << e.what() << "\n";
    } catch (const std::exception& e) {
        std::cout << "通用处理: " << e.what() << "\n";
    } catch (...) {
        std::cout << "未知异常\n";
    }
    return 0;
}

所有标准异常都继承自 std::exception,所以 catch (const std::exception&) 能接住 out_of_range、runtime_error、bad_alloc 等。但 catch 顺序有讲究:子类在前、父类在后,否则子类永远轮不到。业务代码里通常自定义异常,带上更多上下文:

#include <iostream>
#include <stdexcept>
#include <string>

class ConfigError : public std::runtime_error {
public:
    explicit ConfigError(const std::string& msg)
        : std::runtime_error(msg) {}
};

void load_config(const std::string& path) {
    if (path.empty()) {
        throw ConfigError("配置文件路径为空");
    }
}

int main() {
    try {
        load_config("");
    } catch (const ConfigError& e) {
        std::cout << "配置错误: " << e.what() << "\n";
    }
    return 0;
}

自定义异常继承 std::runtime_error 即可,把消息传给基类构造,what() 自动可用。这样调用方可以按业务类型精确捕获,而不是靠解析字符串猜。你甚至可以给异常类加成员变量,比如存下出错的文件名和行号,调试时信息量十足。

5. RAII:异常安全的基石

看一个反面教材:手动 new 出来的内存,如果中间抛出异常,delete 就执行不到,内存泄漏:

#include <cstdlib>
#include <iostream>
#include <memory>
#include <stdexcept>

void risky() {
    // 不要这样写!异常时 delete 不会执行
    int* p = new int(42);
    if (std::rand() % 2 == 0) throw std::runtime_error("随机失败");
    delete p;
}

void safe() {
    // RAII:unique_ptr 析构时自动释放,异常也拦不住它
    std::unique_ptr<int> p = std::make_unique<int>(42);
    if (std::rand() % 2 == 0) throw std::runtime_error("随机失败");
    std::cout << *p << "\n";
}

int main() {
    try {
        risky();
    } catch (const std::exception&) {
        std::cout << "risky 可能泄漏了内存\n";
    }
    try {
        safe();
    } catch (const std::exception&) {
        std::cout << "safe 的 p 已被自动释放\n";
    }
    return 0;
}

unique_ptr 的析构函数负责 delete,而栈展开一定会调用析构函数,所以无论哪一行抛异常,内存都不会泄漏。这就是 RAII(资源获取即初始化):把资源生命周期绑定到对象生命周期。文件流、互斥锁、智能指针都是 RAII 的典范。RAII 的威力不止于内存:std::lock_guard 在构造时加锁、析构时解锁,哪怕锁区间中间抛异常,锁也会被正确释放,不会死锁;ofstream 在析构时关文件,异常路径也不会留下半开的文件句柄。可以说,凡是"成对出现"的操作(加锁/解锁、开/关、分配/释放),都适合用 RAII 封装。反过来,析构函数里绝不能抛异常——析构期间再抛异常,程序会直接调用 std::terminate 终止。

6. 实战:文件读取的异常与 RAII

把前面所有知识串起来:写一个"把整个文件读成字符串"的函数。注意 ifstream 打开失败时不会自己抛异常,所以我们要手动检查失败态再抛出:

#include <fstream>
#include <iostream>
#include <sstream>
#include <stdexcept>
#include <string>

std::string read_file(const std::string& path) {
    std::ifstream in(path);
    if (!in) {
        throw std::runtime_error("无法打开文件: " + path);
    }
    std::ostringstream buf;
    buf << in.rdbuf();   // 把整个文件内容倒进字符串流
    return buf.str();
}

int main() {
    try {
        std::string content = read_file("no_such_file.txt");
        std::cout << "内容长度: " << content.size() << "\n";
    } catch (const std::exception& e) {
        std::cout << "出错: " << e.what() << "\n";
    }
    return 0;
}

这个函数有三个妙处:一是错误信息里带上了文件路径,调用方一眼看出问题在哪;二是 ifstream 是 RAII 对象,即使读取中途抛异常,文件也会被自动关闭;三是调用方只需要 catch std::exception 一族,不用关心具体错误码。你可以试着传入一个真实存在的文件,验证正常路径也能工作。

7. 什么时候不该用异常

异常不是万能的,用错地方反而添乱:

💡 一个实用的分工:异常用于"真正异常的情况"(资源失败、逻辑错误、环境问题),而"预期内的分支"(没找到、为空、输入不合法但可提示重试)优先用返回值表达。

8. 总结与练习

本文讲了异常的完整链路:throw 抛出对象 → 栈展开沿途析构局部对象 → 按类型匹配 catch → 接住后恢复执行。核心心法是两条:按引用捕获、用 RAII 管理资源;红线是:析构函数不抛异常、不用异常做控制流。掌握这些,你写的函数就能在出错时"说人话",而不是静默返回一个没人检查的错误码。

练习题: