异常是什么
概念上,异常是一种处理错误的方式。当一个函数遇到自己无法处理的错误时,会抛出异常,交由调用者处理。
在 C 语言中,我们主要通过错误码来处理错误。错误码本质是对错误信息进行编号,拿到码后还得去查表获取具体信息,比较麻烦。而 C++ 的异常机制则更直接,通过对象传递错误信息。
使用异常主要涉及三个关键字:
throw: 当函数出现问题时抛出异常 try: 包含可能出错的代码块,通常跟随 catch 模块 catch: 用来捕获并处理异常
栈展开
抛出异常后,程序会暂停当前函数的执行,开始寻找匹配的 catch 子句。首先检查 throw 是否在 try 块内部,如果在则查找匹配的 catch 语句;如果有匹配,则跳转到 catch 处处理。
如果当前函数中没有 try/catch 子句,或者类型不匹配,则退出当前函数,继续在外层调用链中查找。这种逐层向上查找的过程叫做栈展开(Stack Unwinding)。
如果一直找到 main 函数仍未找到匹配的 catch 子句,程序会调用标准库的 terminate 函数终止程序;如果找到了,处理完 catch 代码后,程序会继续执行后续逻辑。
异常的抛出和捕获
当程序出现问题时,我们会抛出一个对象来引发异常。该对象的类型以及当前的调用链决定了由哪个 catch 处理代码来接管。
被选中的处理代码是调用链中与该对象类型匹配且离抛出位置最近的那一个。根据抛出对象的类型和内容,异常处理部分能获知具体的错误情况。

类型匹配规则
- 类型不匹配: 如果类型不匹配,会继续向上传递。

- 执行流跳转: 当
throw执行时,其后的语句将不再被执行。程序直接从 throw 位置跳到匹配的 catch 模块。 - 通配符捕获: 使用
catch(...)可以捕获任意类型的异常,但缺点是不知道具体是什么错误。 - 拷贝机制: 抛出异常对象后,会生成一个异常对象的拷贝。因为抛出的可能是临时对象,所以必须生成拷贝,这个拷贝对象会在 catch 子句结束后销毁。
异常的重新抛出
有时我们需要对捕获到的异常进行分类处理。比如某种特定错误需要特殊处理,而其他错误则应重新抛出给外层调用链。
下面模拟一个聊天发消息的场景:网络不好时需要多次重试,其他错误则直接上报。
class Exception {
public:
Exception(const string& errmsg, int id) : _errmsg(errmsg), _id(id) {}
virtual string what() const { return _errmsg; }
int getid() const { return _id; }
protected:
string _errmsg;
int _id;
};
class HttpException : public Exception {
public:
HttpException(const string& errmsg, int id, const string type)
: Exception(errmsg, id), _type(type) {}
virtual string what() const {
string str = "HttpException: ";
str += _type;
str += ":";
str += _errmsg;
return str;
}
private:
const string _type;
};
void _SeedMsg(const string& s) {
if (rand() % 2 == 0) {
throw HttpException("网络不稳定,发送失败", 102, "put");
} else if (rand() % 7 == 0) {
throw HttpException("你不是好友,发送失败", 103, "put");
} else {
cout << "发送成功" << endl;
}
}
void SendMsg(const string& s) {
for (size_t i = 0; i < 4; i++) {
try {
_SeedMsg(s);
break;
} catch (const Exception& e) {
// 102 号错误是网络问题,尝试重试
if (e.getid() == 102) {
if (i == 3) throw; // 重试三次失败,重新抛出
cout << "开始第" << i + 1 << "重试" << endl;
} else {
throw; // 其他错误直接重新抛出
}
}
}
}
int main() {
srand(time(0));
string str;
while (cin >> str) {
try {
SendMsg(str);
} catch (const Exception& e) {
cout << e.what() << endl << endl;
} catch (...) {
cout << "Unkown Exception" << endl;
}
}
return 0;
}
异常的匹配原则
- 就近原则: 一般情况下抛出对象和 catch 类型完全匹配。如果有多个类型匹配,选择离抛出位置更近的那个。
- 继承关系: 实际中,抛出的类型和捕获的类型不一定完全相同。可以抛出派生类对象用基类捕获,这在多态场景下非常常用。
- 兜底处理: 如果到 main 函数异常仍未被匹配,程序会终止。为了避免非严重错误导致程序崩溃,一般 main 函数最后都会使用
catch(...)作为兜底。
异常的安全问题
异常抛出后,后续代码不再执行。如果我们前面申请了资源(如内存、锁),后面才释放,中间抛出异常会导致资源泄漏。
因此,在中间也需要捕获异常,释放资源后再重新抛出。当然,后续学习智能指针章节时,会发现 RAII 方式是解决这类问题的更好手段。
同时要注意析构函数。例如析构函数要释放 5 个资源,若释放到第 2 个时抛出异常,后面的资源就没法释放,同样会造成泄漏。
double Divide(int a, int b) {
if (b == 0) throw "Division by zero condition!";
return (double)a / (double)b;
}
void Func() {
int* array = new int[10];
try {
int len, time;
cin >> len >> time;
cout << Divide(len, time) << endl;
} catch (...) {
cout << "delete []" << array << endl;
delete[] array;
throw; // 重新抛出,保留原始异常信息
}
cout << "delete []" << array << endl;
delete[] array;
}
异常规范
在 C++98 中,函数参数列表后面接 throw() 表示不抛异常,接 throw(类型 1, 类型 2...) 表示可能抛出多种类型。
由于 C++98 方式过于复杂,实践中并不好用,C++11 进行了简化:
- noexcept: 在函数参数列表后面加
noexcept表示不会抛出异常。 - 默认行为: 啥都不加表示可能会抛出异常。
注意:编译器并不会在编译时严格检查 noexcept。如果一个函数声明了 noexcept 但包含了 throw 语句或调用了可能抛异常的函数,编译器仍可能通过编译(有些会报警告)。但是,如果一个声明了 noexcept 的函数抛出了异常,程序会调用 terminate 终止程序。

此外,noexcept(expression) 还可以作为一个运算符检测表达式是否会抛出异常,可能会则返回 false,不会就返回 true。

总结
优点: 可以清晰准确地展示错误的各种信息,便于集中处理。 缺点: 会导致程序执行流乱跳,分析困难,且容易导致内存泄漏问题。
合理使用异常机制,配合 RAII 和资源管理,能让 C++ 程序更加健壮。


