C++ try-catch 异常处理完全指南
一、为什么需要异常处理
程序运行时可能遇到各种错误:文件打不开、内存分配失败、网络断开、输入的除数为零等。C++ 提供异常机制,让你把正常逻辑 和错误处理 分离开,避免到处写 if (error) return -1;。注意,整数除零本身不会自动变成可捕获的 C++ 异常,必须在执行除法前主动检查并抛出异常。
二、基本语法:try / throw / catch
三个关键字
| 关键字 | 作用 |
|---|---|
try |
标记一段"可能出错"的代码 |
throw |
当错误发生时,抛出一个异常对象 |
catch |
捕获并处理被抛出的异常 |
最小可运行示例
cpp
#include <iostream>
#include <stdexcept>
int main() {
try {
int age = -1;
if (age < 0) {
throw std::runtime_error("age cannot be negative");
// throw 之后的代码不会执行
std::cout << "这行永远不会打印" << std::endl;
}
std::cout << "age = " << age << std::endl;
} catch (const std::runtime_error& e) {
// e 就是 throw 抛出的那个对象
// e.what() 返回构造时传入的字符串
std::cerr << "Error: " << e.what() << std::endl;
}
std::cout << "program continues" << std::endl;
return 0;
}
执行流程:
- 进入
try块 age < 0为真,执行throw- 立刻跳出
try块(后面的cout不执行) - 从上到下查找匹配的
catch - 找到后执行
catch块内的代码 catch结束后,从catch块之后继续执行
输出:
Error: age cannot be negative
program continues
关键点
throw可以抛出任何类型 :int、std::string、自定义类......但实践中几乎只抛std::exception的派生类。- 捕获类类型异常时,参数通常写成
const X&,以避免复制和对象切片(object slicing);int等简单类型可以按值捕获。 - 如果抛出的异常最终没有任何
catch能匹配,程序会调用std::terminate();其默认处理通常会终止进程。
三、多个 catch 块
一个 try 后面可以跟多个 catch,按从上到下 的顺序匹配,只执行第一个匹配的那个。
cpp
#include <iostream>
#include <stdexcept>
#include <string>
void do_something(int code) {
if (code == 1) throw std::runtime_error("runtime problem");
if (code == 2) throw std::logic_error("logic problem");
if (code == 3) throw 42; // 抛一个 int
if (code == 4) throw std::string("oops");
}
int main() {
for (int code : {1, 2, 3, 4, 0}) {
try {
do_something(code);
std::cout << "code=" << code << " OK\n";
} catch (const std::runtime_error& e) {
std::cerr << "runtime_error: " << e.what() << "\n";
} catch (const std::logic_error& e) {
std::cerr << "logic_error: " << e.what() << "\n";
} catch (int n) {
std::cerr << "caught int: " << n << "\n";
} catch (const std::string& s) {
std::cerr << "caught string: " << s << "\n";
}
}
return 0;
}
输出:
runtime_error: runtime problem
logic_error: logic problem
caught int: 42
caught string: oops
code=0 OK
⚠️ 顺序很重要 :如果先写
catch (const std::exception& e),它会匹配所有标准异常,后面的catch (const std::runtime_error&)永远走不到。所以派生类放前面,基类放后面。
四、catch(...):捕获一切
... 是通配符,匹配任何类型 的异常。通常放在最后一个 catch,作为兜底。
cpp
#include <iostream>
int main() {
try {
throw 3.14; // 抛一个 double,没有任何具体 catch 能接
} catch (int n) {
std::cout << "int: " << n << "\n";
} catch (...) {
// 走到这里
std::cout << "caught something unknown\n";
}
return 0;
}
实际用途:
- 在析构函数中兜底(析构函数绝不能让异常逃出去)
- 在 C 接口边界(如
extern "C"回调)防止异常穿越 C 栈帧 - 记录日志后重新抛出
五、重新抛出(rethrow)
在 catch 块里,用 不带参数的 throw; 可以把当前异常原封不动地再抛出去。
cpp
#include <iostream>
#include <stdexcept>
void low_level() {
throw std::runtime_error("disk full");
}
void mid_level() {
try {
low_level();
} catch (const std::exception& e) {
std::cerr << "[LOG] mid_level saw: " << e.what() << "\n";
throw; // ← 重新抛出,不是 throw e;
}
}
int main() {
try {
mid_level();
} catch (const std::runtime_error& e) {
// 这里仍然能捕获到 std::runtime_error
// 因为 throw; 保留了原始类型
std::cerr << "[MAIN] " << e.what() << "\n";
}
return 0;
}
⚠️
throw;vsthrow e;的区别:
throw;→ 重新抛出原始异常对象,保留完整类型(多态安全)throw e;→ 把e拷贝 一份再抛,如果e是基类引用,会切片成基类,丢失派生类信息需要重新抛出当前异常时,应使用
throw;。如果要转换异常语义,则显式抛出一个新的异常对象。
六、常见的标准异常体系
标准库已经提供了一棵常用的异常继承树。它们分布在 <stdexcept>、<new>、<typeinfo>、<system_error> 和 <filesystem> 等头文件中:
std::exception ← 所有标准异常的基类
├── std::logic_error ← 程序逻辑错误(理论上可在运行前避免)
│ ├── std::invalid_argument ← 非法参数
│ ├── std::out_of_range ← 越界
│ ├── std::length_error ← 长度超限
│ └── std::domain_error ← 数学域错误
├── std::runtime_error ← 运行时才能发现的错误
│ ├── std::overflow_error
│ ├── std::underflow_error
│ └── std::system_error ← 包装 errno
│ └── std::filesystem::filesystem_error
├── std::bad_alloc ← new 失败
├── std::bad_cast ← 引用形式的 dynamic_cast 失败
└── std::bad_exception
实际项目中用得最多的:
| 异常类 | 什么时候抛 |
|---|---|
std::runtime_error |
IO 失败、网络断开、文件不存在等 |
std::invalid_argument |
调用者传了非法参数 |
std::out_of_range |
索引越界 |
std::logic_error |
违反函数前置条件或其他程序逻辑错误 |
std::bad_alloc |
内存分配失败(通常只在有明确恢复策略或顶层兜底时捕获) |
使用示例
cpp
#include <iostream>
#include <stdexcept>
#include <string>
int divide(int a, int b) {
if (b == 0) {
throw std::invalid_argument("divisor must not be zero");
}
return a / b;
}
int main() {
try {
int r = divide(10, 0);
std::cout << r << "\n";
} catch (const std::invalid_argument& e) {
std::cerr << "Bad input: " << e.what() << "\n";
return 1;
}
return 0;
}
七、自定义异常类
当标准异常不足以表达你的业务语义时,继承 std::runtime_error 或 std::exception:
cpp
#include <iostream>
#include <stdexcept>
#include <string>
// 自定义异常:继承 std::runtime_error
class NetworkError : public std::runtime_error {
int error_code_;
public:
NetworkError(int code, const std::string& msg)
: std::runtime_error(msg) // 基类负责存储 what() 字符串
, error_code_(code)
{}
int code() const noexcept { return error_code_; }
};
// 更细粒度的子类
class TimeoutError : public NetworkError {
public:
explicit TimeoutError(const std::string& msg)
: NetworkError(110, msg) // 示例业务错误码;不要假设系统 ETIMEDOUT 在所有平台都等于 110
{}
};
void connect(const std::string& host) {
if (host.empty()) {
throw std::invalid_argument("host is empty");
}
// 模拟超时
throw TimeoutError("connect to " + host + " timed out");
}
int main() {
try {
connect("192.168.1.1");
} catch (const TimeoutError& e) {
// 最具体的先捕获
std::cerr << "Timeout (code=" << e.code() << "): " << e.what() << "\n";
} catch (const NetworkError& e) {
std::cerr << "Network (code=" << e.code() << "): " << e.what() << "\n";
} catch (const std::exception& e) {
// 兜底:捕获 invalid_argument 等
std::cerr << "Error: " << e.what() << "\n";
}
return 0;
}
输出:
Timeout (code=110): connect to 192.168.1.1 timed out
要点:
- 继承
std::runtime_error而不是std::exception,因为前者已经帮你实现了what() - 构造函数参数传给基类的
const std::string& - 额外字段(如
error_code_)用noexcept的 getter 暴露
八、函数 try-catch 块(Function-try-block)
就是你在问题代码里看到的写法:
cpp
int main(int argc, char** argv) try {
// 整个函数体
} catch (const std::exception& e) {
std::cerr << e.what() << '\n';
return 1;
}
它和普通 try-catch 的区别
对于 main 或普通函数,可以改写为相同处理器包住整个函数体的形式:
cpp
int main(int argc, char** argv) {
try {
// 整个函数体
} catch (const std::exception& e) {
std::cerr << e.what() << '\n';
return 1;
}
}
只是少了一层缩进,写法更紧凑。
真正有用的场景:构造函数
构造函数体内写的 try-catch 无法 捕获初始化列表中抛出的异常。函数 try-catch 块可以:
cpp
#include <iostream>
#include <stdexcept>
#include <string>
class Config {
public:
Config(const std::string& path) {
if (path.empty()) throw std::runtime_error("empty path");
std::cout << "Config loaded from: " << path << "\n";
}
};
class App {
Config config_;
int port_;
public:
// 函数 try-catch 块:能捕获 config_ 初始化时的异常
App(const std::string& path, int port) try
: config_(path) // ← 这里抛异常,下面的 catch 能接住
, port_(port)
{
std::cout << "App constructed\n";
} catch (const std::exception& e) {
std::cerr << "App init failed: " << e.what() << "\n";
// ⚠️ 这里不能"阻止"构造失败
// 函数结束时异常会自动重新抛出
// 通常只记录不依赖对象成员的日志;此时已构造的成员和基类已被销毁
}
};
int main() {
try {
App app("", 8080); // 空路径 → Config 构造抛异常
} catch (const std::exception& e) {
std::cerr << "main caught: " << e.what() << "\n";
}
return 0;
}
输出:
App init failed: empty path
main caught: empty path
⚠️ 构造函数的 function-try-block 中,
catch块结束时必定重新抛出 (隐式throw;)。你无法"吞掉"异常让对象假装构造成功------因为成员没有正确初始化,对象不完整。
九、noexcept:承诺不抛异常
语法
cpp
void safe_swap(int& a, int& b) noexcept {
int tmp = a;
a = b;
b = tmp;
}
noexcept 等价于 noexcept(true),表示"我保证不抛异常"。
为什么要标 noexcept
- 优化与容器行为 :编译器可以利用不抛异常的契约;
std::vector扩容时也会根据移动构造是否为noexcept等条件决定优先移动还是复制。 - 契约 :当成员和基类的析构都不抛异常时,析构函数通常会隐式成为
noexcept;异常一旦逃出noexcept函数,就会调用std::terminate()。 - 接口文档:告诉调用者异常不会从该函数逃出。
noexcept 运算符(编译期检查)
cpp
#include <iostream>
#include <vector>
int main() {
std::cout << std::boolalpha;
std::cout << noexcept(1 + 1) << "\n"; // true
std::cout << noexcept(std::vector<int>{}) << "\n"; // true:使用默认分配器的空 vector 默认构造不分配元素存储
return 0;
}
实际规则
| 场景 | 是否标 noexcept |
|---|---|
| 析构函数 | 通常隐式为 noexcept,不要让异常逃出 |
| swap | 确实不会抛出时标 |
| 移动构造 / 移动赋值 | 确实不会抛出时应标,有助于容器选择移动 |
| 简单 getter | 确实不会抛出时标 |
| 可能抛异常的函数(IO、分配内存) | 不标 |
十、异常与 RAII:资源安全的核心
C++ 异常安全依赖 RAII(Resource Acquisition Is Initialization) :资源的生命周期绑定到对象的生命周期。当异常抛出、栈展开(stack unwinding)时,局部对象的析构函数自动调用,资源自动释放。
反面教材(C 风格,异常不安全)
cpp
void bad() {
FILE* f = fopen("data.txt", "r");
if (!f) throw std::runtime_error("open failed");
char* buf = new char[1024];
// new 自身若抛异常,f 会泄漏;process 若抛异常,f 和 buf 都会泄漏
process(buf);
delete[] buf;
fclose(f);
}
正确写法(RAII)
cpp
#include <fstream>
#include <memory>
#include <stdexcept>
void good() {
std::ifstream f("data.txt");
if (!f) throw std::runtime_error("open failed");
auto buf = std::make_unique<char[]>(1024);
// 即使 process() 抛异常:
// 1. buf 的析构 → delete[]
// 2. f 的析构 → 关闭底层文件
// 栈展开自动完成,无需手写清理
process(buf.get());
}
核心原则: 优先用智能指针、std::fstream、std::lock_guard 等 RAII 包装资源,让析构函数完成清理,而不是依赖 catch 中的手动释放。
十一、异常安全级别
写代码时要清楚你的函数提供哪个级别的保证:
| 级别 | 含义 | 例子 |
|---|---|---|
| nothrow | 不抛异常;仍可能通过返回值等方式报告失败 | 简单 getter、满足条件的析构函数 |
| 强保证(strong) | 要么成功,要么状态不变(事务语义) | std::vector::push_back 通常提供此保证,但对某些会抛出的移动类型存在例外 |
| 基本保证(basic) | 不泄漏资源,对象处于合法但不确定的状态 | 大多数标准库操作 |
| 无保证 | 抛异常后状态可能混乱 | 你自己写的没考虑异常的代码 |
实际项目目标:至少基本保证,关键操作争取强保证。
十二、异常在多层调用中的传播
异常会沿调用栈向上传播 ,直到遇到匹配的 catch。中间每一层的局部对象都会被析构(栈展开)。
cpp
#include <iostream>
#include <stdexcept>
struct Guard {
const char* name;
Guard(const char* n) : name(n) { std::cout << " [construct] " << name << "\n"; }
~Guard() { std::cout << " [destruct] " << name << "\n"; }
};
void level3() {
Guard g3("g3");
throw std::runtime_error("boom in level3");
}
void level2() {
Guard g2("g2");
level3();
}
void level1() {
Guard g1("g1");
level2();
}
int main() {
try {
level1();
} catch (const std::exception& e) {
std::cout << "caught: " << e.what() << "\n";
}
return 0;
}
输出:
[construct] g1
[construct] g2
[construct] g3
[destruct] g3 ← 栈展开,逆序析构
[destruct] g2
[destruct] g1
caught: boom in level3
这证明了:你不需要在每一层都写 try-catch。让异常自然传播到能处理它的层级即可。
十三、实际项目中的常见模式
模式 1:顶层兜底(main 或线程入口)
cpp
int main(int argc, char** argv) try {
// 业务逻辑...
} catch (const std::exception& e) {
std::cerr << "Fatal: " << e.what() << "\n";
return 1;
} catch (...) {
std::cerr << "Fatal: unknown exception\n";
return 1;
}
模式 2:在边界层转换异常类型
cpp
// 底层抛 std::system_error,API 层转成业务异常
void api_read_config(const std::string& path) {
try {
auto data = read_file(path); // 可能抛 std::system_error
parse(data);
} catch (const std::system_error& e) {
throw ConfigError("failed to read config: " + std::string(e.what()));
}
}
模式 3:析构函数中绝不抛
cpp
#include <unistd.h> // POSIX close
class Connection {
public:
~Connection() noexcept {
if (fd_ >= 0 && ::close(fd_) == -1) {
// close 通过返回值报告失败,不会抛 C++ 异常;这里可按需记录日志
}
}
private:
int fd_{-1};
};
模式 4:用 std::exception_ptr 跨线程传递异常
cpp
#include <iostream>
#include <exception>
#include <stdexcept>
#include <thread>
int main() {
std::exception_ptr eptr;
std::thread t([&eptr] {
try {
throw std::runtime_error("error in worker thread");
} catch (...) {
eptr = std::current_exception(); // 捕获并保存
}
});
t.join();
if (eptr) {
try {
std::rethrow_exception(eptr); // 在主线程重新抛出
} catch (const std::exception& e) {
std::cerr << "Main thread caught: " << e.what() << "\n";
}
}
return 0;
}
模式 5:用错误码而非异常(高频路径)
对于预期内的、高频的 错误(如"键不存在"),用返回值/std::optional/std::expected(C++23) 比异常更高效:
cpp
#include <optional>
#include <string>
#include <unordered_map>
std::optional<int> find(const std::unordered_map<std::string, int>& m,
const std::string& key) {
auto it = m.find(key);
if (it == m.end()) return std::nullopt; // 不抛异常,正常返回
return it->second;
}
经验法则:
- 异常情况(不该发生、需要调用者处理)→ 抛异常
- 正常分支(经常发生、属于流程控制)→ 返回值 / optional
十四、不要做的事
| ❌ 反模式 | 为什么 |
|---|---|
throw e;(在 catch 里) |
会创建新的异常对象;按基类捕获时还会发生切片,重新抛出当前异常应使用 throw; |
让异常逃出通常为 noexcept 的析构函数 |
调用 std::terminate();栈展开期间再次抛出尤其危险 |
| 用异常做正常流程控制 | 性能差,语义混乱 |
catch (...) 后吞掉不处理 |
隐藏 bug,至少记日志 |
在 noexcept 函数里抛异常 |
直接 std::terminate |
抛裸 int / const char* |
无法携带上下文,无法多态捕获 |
| 在构造函数体内 catch 初始化列表的异常 | catch 不到,用 function-try-block |
十五、编译与运行
完整示例在补齐其使用的头文件后可用以下命令编译;展示设计模式的片段还需要项目中的占位类型和函数定义:
bash
g++ -std=c++17 -Wall -Wextra -o demo demo.cpp
# 或
clang++ -std=c++17 -Wall -Wextra -o demo demo.cpp
具体链接选项取决于示例和平台,例如某些平台上的线程程序需要 -pthread;<stdexcept> 本身是头文件。
速查表
text
try {
// 可能出错的代码
throw std::runtime_error("msg");
} catch (const DerivedException& e) { // 派生类在前
// 处理
throw; // 重新抛出(保留原始类型)
} catch (const std::exception& e) { // 基类在后
std::cerr << e.what() << '\n';
} catch (...) { // 兜底
// 记日志 / 转换 / 吞掉
}