更新时间: 2026-08-27
C 语言处理错误靠返回码:if (ret != 0) return -1;,一路检查、一路传。C++ 提供了另一套机制——异常(exception):出错时"抛出"一个东西,沿途的代码不用一个个检查,错误直接飞到能处理它的地方。
本文要回答:try/catch/throw 三个关键字怎么配合?异常的传播路径是什么?以及——什么时候该用异常,什么时候用返回码更合适?
一、最简示例
#include <iostream>
#include <stdexcept>
double divide(double a, double b) {
if (b == 0) {
throw std::runtime_error("除以零"); // 抛出异常
}
return a / b;
}
int main() {
try {
double r = divide(10, 0); // 这里会抛异常
std::cout << r << "\n"; // 不会执行到这
} catch (const std::runtime_error& e) {
std::cout << "捕获: " << e.what() << "\n";
}
}三个关键字的角色:
| 关键字 | 作用 |
|---|---|
throw | 抛出异常(可以抛任何类型,惯例抛异常对象) |
try | 标记"可能抛异常"的代码块 |
catch | 捕获并处理特定类型的异常 |
执行流程:divide 里 throw 一执行,函数立刻停止,控制权直接跳到最近的、能匹配这个异常类型的 catch 块。中间的代码(包括 divide 里 throw 之后的语句)全部跳过。
二、捕获规则
2.1 按类型匹配,从上到下
catch 可以有多个,按声明顺序从上往下匹配:
try {
// 可能抛出不同类型的异常
} catch (const std::invalid_argument& e) {
// 处理 invalid_argument
} catch (const std::runtime_error& e) {
// 处理 runtime_error
} catch (const std::exception& e) {
// 兜底:任何标准异常
} catch (...) {
// 兜底:任何异常(包括非标准类型的)
}先具体后一般。std::invalid_argument 是 std::exception 的子类,如果你先写 catch (const std::exception&),后面的 catch (const std::invalid_argument&) 永远不会被执行(被前面的截胡了)。所以顺序必须是"子类在前,基类在后"。
2.2 类型匹配的细节
catch 的类型匹配比函数重载宽容——引用、const、值,都可以匹配:
catch (const std::exception& e) // 推荐:按引用捕获,不复制、不切片
catch (std::exception e) // 会切片!丢失派生类型信息
catch (...) // 捕获一切惯例是按 const 引用捕获:const SomeException&。按值捕获会把派生类"切"成基类(31 篇讲过切片),丢失 .what() 的具体信息。
2.3 没被捕获会怎样
异常抛出后,如果调用链上没有任何 catch 匹配它,程序调用 std::terminate 直接终止——进程崩溃。所以"抛了异常没人接"的结果很严重。工程上要么保证顶层有兜底 catch (...),要么让异常在合适的层被处理。
三、异常传播与栈展开
throw 之后,控制权往回跳:一层层退出函数,直到找到匹配的 catch。这个过程叫栈展开(stack unwinding)——每退出一层函数,该层作用域内的局部对象都会执行析构(这是 RAII 能兜底资源释放的原因,56 篇详讲)。
@startuml
left to right direction
skinparam nodeFontSize 13
skinparam backgroundColor #FFFFFF
rectangle "main" as main #E8F1FF {
rectangle "try { f(); }" as tryb #DCE9FF
rectangle "catch (...) 处理" as catchb #D9FFE2
}
rectangle "f()" as f #FFF3D6 {
node "调 g()" as f1 #FFF3D6
}
rectangle "g()" as g #FFD9D9 {
node "throw X" as g1 #FFD9D9
}
main -down-> f
f -down-> g
g -right-> catchb : "异常沿调用链向上传播"
note bottom of tryb
传播途中各层局部对象
依次析构(栈展开)
end note
@enduml理解这个图,就理解了异常的"飞行路径":不是跳回"调我的那一行",而是沿着调用栈一路向上找能处理它的 catch。
四、异常 vs 返回码
C 的方式是返回码,C++ 是异常,两者各有主场:
| 维度 | 返回码 | 异常 |
|---|---|---|
| 错误处理位置 | 每个调用点都要检查 | 集中到 catch |
| 漏检查后果 | 错误悄悄传播/忽略 | 没 catch 就终止(显眼) |
| 性能 | 无成本 | 抛出时有成本(仅异常路径) |
| 可读性 | 嵌套检查,层层 if | 主流程干净 |
| 适合 | 高频、可恢复、就近处理 | 低频、深层错误、集中处理 |
核心权衡:
- 返回码适合"错误是常态、且调用者要逐一处理"的场景(文件读写失败、网络重试)
- 异常适合"错误是例外、调用者不关心细节"的场景(深层库的错误,顶层统一处理)
- 异常只在抛出时才有性能开销(正常路径零成本),所以"从不抛出"的代码用异常是免费的
一条工程经验:别用异常做控制流——throw 来跳出循环、throw 代替 return,都是滥用。异常是给"真正的错误"用的。
五、C 对照
C 的错误处理家族:返回码 + errno + goto cleanup:
int process(void) {
FILE* f = fopen("a.txt", "r");
if (!f) { return -1; } // 错误码 -1
if (parse(f) < 0) {
fclose(f); // 记得清理!
return -2;
}
fclose(f);
return 0;
}| 环节 | C(返回码 + goto cleanup) | C++(异常) |
|---|---|---|
| 报告错误 | return -1 | throw 异常对象 |
| 传递错误 | 逐层返回、逐层检查 | 自动沿栈传播 |
| 清理资源 | goto cleanup 手动 | 栈展开自动析构(RAII) |
| 漏处理 | 静默忽略 | 未捕获则终止 |
| 错误信息 | errno / 手动编码 | e.what() 携带描述 |
C 的 goto cleanup 模式是"手动模拟栈展开"——把需要清理的点都跳到一个标签去执行释放。C++ 里 RAII + 异常把这个过程自动化了:析构自动执行,不用 goto、不用记"哪条路径忘了清理"。
六、与本站主线衔接
异常处理和本站很多主题相关:
- 42 篇(RAII):异常路径下析构必然执行,RAII 是异常安全的基石
- 56 篇(栈展开):异常传播时析构顺序的完整机制
- 57 篇(noexcept):哪些函数不该抛异常(析构、移动构造……)
- 55 篇(异常类型):标准异常类的层级
- 19 篇(析构函数):析构不抛异常的约定
crash/:未捕获异常导致std::terminate→ 进程崩溃,对应 core dump 排查线
七、一句话总结
异常用 throw 抛出、try/catch 捕获,错误沿调用栈传播、途中自动析构局部对象(栈展开);捕获要"子类在前基类在后"、按 const 引用接、顶层兜底 catch (...);异常适合"低频、深层、集中处理"的错误,高频可恢复的错误还是返回码更合适。