程序运行中会出现各种「特殊情况」,导致后续逻辑无法正常执行:文件打不开、内存不足、网络超时、除数为零……这类情况统称为异常。本文介绍 C++ 异常处理的三个关键字、catch 的匹配顺序,以及重新抛出的用法。

一、三个关键字

关键字 作用
try 包裹一段「可能出问题」的代码,检查其中产生的异常
catch 捕获并处理 try 中产生的异常
throw 在 try 块或它调用的函数里,抛出异常

配合使用的基本形态:

1
2
3
4
5
try {
// 可能抛异常的代码
} catch (异常类型& e) {
// 处理这个异常
}

try 块里只要有一处 throw 被执行,后面的语句就不再执行,控制流立刻跳到匹配的 catch。

二、第一个例子:除零异常

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>
#include <stdexcept> // runtime_error 所需头文件
using namespace std;

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

int main() {
try {
cout << divide(2, 0); // 这里会抛异常,不会执行 cout
} catch (runtime_error& e) {
cout << "异常:" << e.what() << endl; // e.what() 返回异常的描述信息
}
return 0;
}

输出:

1
异常:Divided by zero!

几点值得注意:

  • catch (runtime_error& e) 里的 & 是引用,这是推荐写法。用引用可以避免一次对象拷贝,也能正确捕获派生类异常(值传递会发生「对象切片」,丢失派生类信息)。
  • e.what() 是 std::exception 提供的虚函数,返回构造异常时传入的描述字符串。
  • 除零在 C++ 里并不会自动抛异常(整数除零是未定义行为),所以这里必须手动判断、手动 throw。

三、可以抛出任意类型

C++ 允许把各种类型的变量、对象、指针作为异常抛出,catch 里写对应的类型即可:

1
2
3
4
5
6
7
8
9
10
11
#include <iostream>
using namespace std;

int main() {
try {
throw "这是一个异常"; // 抛出字符串字面量(类型是 const char*)
} catch (const char* e) {
cout << "异常:" << e;
}
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
#include <iostream>
using namespace std;

int main() {
try {
throw 114514; // 抛出 int
} catch (int& e) {
cout << "异常:" << e;
}
return 0;
}

不过实践中不建议抛基本类型或裸字符串,原因有两个:

  1. 无法携带更多信息(错误码、上下文等);
  2. 容易和普通类型混淆,catch 的语义不清晰。

更好的做法是抛出继承自 std::exception 的类对象,或自定义一个异常类,这样能统一用 e.what() 取描述、统一被 catch (exception&) 兜底。

四、多个 catch 与匹配顺序

一个 try 后面可以跟多个 catch,它们自上而下依次尝试匹配,第一个匹配成功的会被执行,后面的全部跳过。

因此顺序至关重要:派生层次越深的(越专用的)越放前面,越通用的越放后面。 如果把通用异常放在前面,它会把后面的专用异常全都拦住,专用分支永远得不到执行。

先看一下标准库异常的继承关系,这解释了为什么顺序会出错:

logic_error 和 runtime_error 是兄弟关系(都直接继承 exception),彼此不构成父子。所以正确的顺序是:

1
2
3
4
5
6
7
8
9
try {
// 可能抛异常的代码
} catch (runtime_error& e) { // 较专用
cout << "runtime_error: " << e.what() << endl;
} catch (logic_error& e) { // 另一支较专用
cout << "logic_error: " << e.what() << endl;
} catch (exception& e) { // 最通用的兜底,必须放最后
cout << "exception: " << e.what() << endl;
}

反例(错误写法):

1
2
3
// 错误:logic_error 永远匹配不到,是死代码
catch (exception& e) { /* ... */ } // 它会把 logic_error 也一并捕获
catch (logic_error& e) { /* ... */ } // 永远不会执行

编译器通常不会对这种情况报错,属于典型的「能编译、逻辑错」的 bug,写的时候要特别留意。

五、catch(...):捕获所有异常

对于不知道具体类型的异常,可以用 catch (...) 兜底:

1
2
3
catch (...) {
cout << "捕获到未知异常" << endl;
}

它可以匹配任何类型的异常且不需要写出类型,一般放在所有 catch 的最后。

⚠️ 但要注意:既然不知道异常类型,也就拿不到 e.what(),无法给出有意义的错误信息。所以不要无脑用它吃掉所有异常——如果一段代码根本没能力处理这个异常,正确做法是让它继续向上传播,交给能处理的调用方,或者干脆让程序终止并报错。悄悄吞掉异常会让问题被掩盖,后面排查起来非常痛苦。

catch (...) 的合理用途通常只有两个:在最外层做兜底日志(记录后重新抛出),以及资源清理。

六、重新抛出:throw;

如果 catch 里做了必要的清理,但自己处理不了这个异常,可以用不带操作数的 throw; 把它原样抛给上层:

1
2
3
4
5
6
7
8
9
10
void func() {
try {
// 可能抛异常的代码
} catch (...) {
// 做点清理工作,例如释放资源、记录日志
cout << "func 中记录日志" << endl;

throw; // 原样重新抛出,交给上层处理
}
}

要注意 throw; 和 throw e; 的区别:

1
2
3
4
catch (exception& e) {
// throw; // 正确:原样抛出,异常的动态类型保持不变
// throw e; // 错误:会按 exception 重新拷贝一份,派生类信息被切掉
}

throw e; 会以静态类型 exception 复制出一个新异常,原本的派生类型信息(以及附加数据)全部丢失。重新抛出永远用裸 throw;。

七、小结

  • 异常用于处理「导致后续逻辑无法继续」的意外情况,三个关键字是 try / catch / throw;
  • try 中抛出异常后,其后的语句不再执行,控制流直接跳到匹配的 catch;
  • catch 的参数推荐用引用(异常类型& e),避免拷贝和对象切片;
  • 可以抛任意类型,但推荐抛继承自 std::exception 的类对象,便于统一用 e.what() 取信息;
  • 多个 catch 自上而下匹配,专用异常在前、通用异常在后,否则专用分支成为死代码;
  • catch (...) 能捕获所有类型,但不要用它默默吞掉异常——处理不了就让它向上传播;
  • 重新抛出用裸 throw;,用 throw e; 会丢失派生类信息。