写文件失败、内存不够、用户输入非法——程序里的错误无处不在。最原始的做法是让每个函数返回错误码,于是代码变成 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. 什么时候不该用异常
异常不是万能的,用错地方反而添乱:
- 别用异常做正常控制流:比如"用异常退出循环",异常机制的开销远大于普通分支,而且会让代码难读。
- 实时/嵌入式系统慎用:有些平台直接禁用异常(编译加
-fno-exceptions),展开开销也不可控。 - 析构函数和
noexcept函数里别抛:前者直接 terminate,后者也是。 - 小而频繁的错误用错误码或 optional:比如查找元素没找到,返回
std::optional或迭代器,比抛异常更自然、更快。
💡 一个实用的分工:异常用于"真正异常的情况"(资源失败、逻辑错误、环境问题),而"预期内的分支"(没找到、为空、输入不合法但可提示重试)优先用返回值表达。
8. 总结与练习
本文讲了异常的完整链路:throw 抛出对象 → 栈展开沿途析构局部对象 → 按类型匹配 catch → 接住后恢复执行。核心心法是两条:按引用捕获、用 RAII 管理资源;红线是:析构函数不抛异常、不用异常做控制流。掌握这些,你写的函数就能在出错时"说人话",而不是静默返回一个没人检查的错误码。
练习题:
- 写一个
read_int函数,用std::stoi解析字符串,分别捕获std::invalid_argument和std::out_of_range,返回解析结果或抛出自定义异常。 - 把文件流文章里的 CSV 程序改造:文件打不开时抛出带路径信息的异常,并在 main 里捕获打印。
- 思考题:一个函数里同时用
std::lock_guard和std::unique_ptr,抛异常时它们的析构顺序是怎样的?写个小程序验证。