目录
- 前言
- 一、异常的概念及使用
-
- [1.1 异常的概念](#1.1 异常的概念)
- [1.2 异常的抛出和捕获](#1.2 异常的抛出和捕获)
- [1.3 栈展开](#1.3 栈展开)
- [1.4 查找匹配的处理代码](#1.4 查找匹配的处理代码)
- [1.5 异常重新抛出](#1.5 异常重新抛出)
- [1.6 异常安全问题](#1.6 异常安全问题)
- [1.7 异常规范](#1.7 异常规范)
-
- [1.7.1 什么是异常规范?](#1.7.1 什么是异常规范?)
- [1.7.2 C++98的异常规范](#1.7.2 C++98的异常规范)
- [1.7.3 C++11 的极简主义:noexcept](#1.7.3 C++11 的极简主义:noexcept)
- [1.7.4 noexcept 运算符的额外用法](#1.7.4 noexcept 运算符的额外用法)
- 二、标准库的异常
-
- [2.1 抓住 std::exception](#2.1 抓住 std::exception)
- [2.2 异常家族图谱与常见成员](#2.2 异常家族图谱与常见成员)
- 结语


🎬 云泽Q :个人主页
🔥 专栏传送入口 : 《C语言》《数据结构》《C++》《Linux》《蓝桥杯系列》《笔试算法》
⛺️遇见安然遇见你,不负代码不负卿~
前言
大家好啊,我是云泽Q,欢迎阅读我的文章,一名热爱计算机技术的在校大学生,喜欢在课余时间做一些计算机技术的总结性文章,希望我的文章能为你解答困惑~
一、异常的概念及使用
1.1 异常的概念
一、核心理论
异常机制核心作用:分离「错误检测」和「错误处理」,异常把程序拆成两部分:一段代码只负责检测有没有出错,发现错误后把错误信息打包抛出;另一段代码专门接收异常、处理错误;检测代码不需要关心错误最终怎么处理,实现解耦。
对比 C 语言错误码的优缺点
-
C 语言:依靠返回值(错误码)标记错误,错误码只是数字编号,拿到编码后还要额外查表查错误详情,使用繁琐;

如图:f4出错后,错误码必须经由f3、f2、f1逐层返回,中间所有函数都要编写错误接收、转发逻辑,代码冗余,且函数返回值被错误码占用,不能用来返回正常运算结果。
-
C++ 异常:出错时直接抛出异常对象,对象内部可以存储完整错误描述(字符串、自定义错误信息等),信息直观完整。对于上图来说f4 throw后,跳过f3/f2/f1层层转发,异常自动沿调用链向上,直达main的catch,且函数返回值专门用于返回业务结果,错误和返回数据解耦。。
二、结合示例代码解析
cpp
// 【错误检测函数】:只负责检查除数是否为0,发现错误就抛异常,不处理报错输出
double Divide(int a, int b)
{
if (b == 0)
{
string s("Divide by zero condition!"); // 用字符串对象保存完整错误信息
throw s; // 抛出异常,把错误打包传递出去
}
else
return ((double)a / (double)b);
}
// 中间调用函数:只管调用除法,既不检测错误、也不处理错误
void Func()
{
int len, time;
cin >> len >> time;
cout << Divide(len, time) << endl;
}
// 【错误处理模块】:专门捕获异常、打印报错
int main()
{
try
{
Func();
}
catch (const string& s)
{
cout << s << endl;
}
return 0;
}
检测和处理分离 :
Divide是错误检测方:只判断b==0是否出错,出错后仅抛出异常,不用管错误怎么打印;
main中的catch是错误处理方 :专门接收异常、输出错误提示,Divide完全不用关心处理细节,契合异常 "检测、处理拆分" 的设计思想。
对比 C 错误码优势
代码里用string对象存"Divide by zero condition!"完整错误文本,不像 C 错误码:-1代表除零错误,拿到 - 1 还要手动查注释 / 文档才知道错误含义;异常对象自带全部错误描述。
1.2 异常的抛出和捕获
1. throw 执行逻辑
- 执行throw 异常对象;后,throw 后续同函数代码不再执行,程序立即跳出当前执行流,跳转去寻找匹配的catch处理分支;
- 匹配的catch可以在当前函数的 try 块内 ,也可以在上层调用链的任意函数中,控制权直接从 throw 位置跳转到 catch 位置;
- 跳转过程中:调用链上已经创建的局部对象会自动调用析构函数销毁(栈展开前置逻辑)。
2. 异常对象拷贝规则
throw抛出局部对象时,会生成异常对象的临时拷贝 :因为局部对象在函数结束后会被销毁,无法被外部 catch 访问,因此编译器拷贝一份异常对象,拷贝对象会在catch子句执行结束后自动销毁,逻辑类似函数传值返回。
3. try-catch 语法匹配规则
- try{}:包裹可能抛出异常的代码段;
- catch(类型& 引用名){}:按异常对象的类型精准匹配,只有抛出对象类型和 catch 参数类型一致时,才会进入当前 catch;
- 一个 try 可以挂载多个不同类型的 catch,从上到下依次匹配。
1.3 栈展开
抛出异常后,从当前函数逐层退出函数栈、销毁栈内局部变量 ,沿着函数调用链向上回溯查找匹配 catch 的过程,叫做栈展开 。

第一步:当前函数自查 :throw 所在代码如果被 try 包裹,优先在当前 try 的 catch 列表匹配类型;匹配成功直接进入对应 catch 处理;
第二步:无匹配则出栈回溯 :当前函数无 try/catch、或所有 catch 类型都不匹配 → 销毁当前函数所有局部对象、退出当前函数,回到上层调用函数,重复第一步查找逻辑;
第三步:查找到 main 仍无匹配 :逐层回溯到main函数后,所有 catch 依旧匹配失败 → 系统调用标准库terminate()函数,直接终止整个程序;
第四步:catch 处理完毕:找到匹配 catch 并执行完代码后,不再回到 throw 抛出位置,从catch代码段之后继续顺序执行剩余代码。
图例逻辑:func1 内部 throw 异常 → func2 调用 func1 无捕获→func3 调用 func2 无捕获→main 调用 func3 并配置 catch;异常逐层栈展开退出 func1→func2→func3,最终在 main 的 catch 被捕获。
示例代码
cpp
#include<iostream>
#include<exception>
using namespace std;
double Divide(int a, int b)
{
//当b == 0时抛出异常
if (b == 0)
{
//string s("Divide by zero condition!");
//throw s;
throw exception("Divide by zero condition!");
}
else
{
return ((double)a / (double)b);
}
}
void Func()
{
try
{
int len, time;
cin >> len >> time;
cout << Divide(len, time) << endl;
}
catch (const exception& e)
{
cout << e.what() << endl;
}
cout << "Func():" << __LINE__ << endl;
}
int main()
{
try
{
Func();
}
catch (const string& s)
{
cout << s << endl;
}
catch (const exception& e)
{
cout << e.what() << endl;
}
cout << "main():" << __LINE__ << endl;
return 0;
}
场景 1:正常输入(time≠0,除数不为 0,无异常抛出)
- Divide 正常计算除法并返回数值,无 throw;
- Func 的 try 内正常打印除法结果,跳过 catch 代码,执行cout << "Func行号";
- Func 函数顺利执行完毕回到 main 的 try,main 内无异常触发,跳过 main 的两个 catch;
- 执行cout << "main行号",程序正常结束。
场景 2:异常输入(time=0,除数为 0,触发 throw exception)
- Divide 中
b==0成立,执行throw exception(...),Divide 剩余代码不再执行,触发栈展开; - Divide 被上层 Func 的 try 块包裹 ,向上匹配 Func 的
catch(const exception& e):类型完全匹配; - 进入 Func 的 catch,调用e.what()打印异常字符串
Divide by zero condition!; - catch 执行结束,顺序执行
cout << "Func行号"; - Func 整体执行完成,无异常向外抛出,回到 main 的 try,跳过 main 所有 catch;
- 执行cout << "main行号",程序结束。
拓展场景 1:注释掉 Func 内部的 try-catch
- Divide 抛 exception 异常,Func 没有任何捕获逻辑 → 触发栈展开,销毁 Func 内局部变量len/time,回溯到 main 函数;
- main 的 try 匹配第二个
catch(const exception& e),打印异常信息; - 执行 main 行号输出。
拓展场景 2:修改异常为 throw string(启用注释代码throw string("xxx");)
- Divide 抛 string 对象,Func 的 catch 只接收 exception,类型不匹配、捕获失败;
- 栈展开退出 Func,回到 main,匹配 main 第一个
catch(const string& s),在 main 中打印错误字符串。
1.4 查找匹配的处理代码
1. 基本匹配原则:类型必须严格匹配
我们在前面的文章中讲过,抛出异常对象和 catch 捕获的地方,并不是随便就能匹配的。比如你抛出一个 string 类型的异常,却试图用 exception 类型去捕获,这并不能匹配。
一般原则是 : 抛出的异常对象类型与 catch 声明的类型必须完全匹配。如果存在多个匹配的 catch 块,编译器会选择离抛出位置最近的那一个进行处理。
2. 允许的例外情况(隐式类型转换)
虽然要求严格匹配,但在 C++ 的异常处理机制中,也允许一些特定的类型转换,这主要是为了方便编程:
- 非常量到常量的转换(权限缩小): 比如抛出非 const 对象,可以用 const 引用捕获。
- 数组到指针的转换: 允许数组转换成指向数组元素类型的指针(但要注意避免野指针问题)。
- 函数到函数指针的转换。
- 【最重要】派生类到基类的转换: 允许从派生类向基类类型的转换。这个点非常实用,实际工程中复杂的继承体系基本都是利用这个特性来设计的。
3. 大型项目中的痛点:为什么要用基类捕获?
大家想一想,如果我们是在做一个大型项目,下面有好多人各自负责不同的模块,大家都会抛异常。
- 你会抛你的异常,我会抛我的异常,他会抛他的异常。
- 通常我们在外层(比如主函数或接近主函数的层级)统一处理错误。
如果我不使用继承体系,而是每个人抛什么我就 catch 什么,那写外层捕获代码的人不累死吗?我要捕获几十个、上百个不同的异常类型
更严重的是,如果有个实习生新加了一个异常类型,而我没有捕获到,程序就会直接终止。在生产环境中,程序终止是很大的问题,属于"事故"。
举个例子:就像美团 App 突然崩了,骑手接不了单,商家做不了饭,用户下不了单,这都是巨大的损失。如果仅仅因为一个未被捕获的小异常导致服务挂掉,那就太可笑了。
4. 解决方案:多态与继承体系
为了避免上述问题,我们在实践中通过以下两个核心点来解决:
第一点:利用多态进行统一捕获
我们定义一个统一的异常基类(例如下方示例代码中的 Exception),所有模块的异常都继承自它。在外层捕获时,我们只需要捕获基类的引用或指针(catch (const Exception& e))。
根据 C++ 的规则,基类的引用可以绑定到派生类对象上。这样,无论底层抛出的是 SqlException 还是 CacheException,外层都能一把抓。
第二点:调用重写的虚函数 (what())
捕获到基类引用后,如何知道具体是哪个模块报错了呢?这就用到了多态。
- 基类中定义虚函数
virtual string what() const。 - 派生类重写这个
what()函数,组装自己特有的错误信息。
当我们通过基类引用调用e.what()时,会发生多态调用: - 如果你抛的是 CacheException,就调 CacheException 的 what。
- 如果你抛的是 CircleException(假设有的话),就调 CircleException 的 what。
这样,输出的字符串就是各个模块根据自己的需求定制组装出来的(比如加上前缀 "SqlException:" 或者具体的 SQL 语句),既实现了统一处理,又保留了详细信息。
5. 代码实战演示
结合我给出的代码示例,大家可以清晰地看到这套机制是如何运作的:
cpp
#include<iostream>
#include<thread>
using namespace std;
// 一般大型项目程序才会使用异常,下面我们模拟设计一个服务的几个模块
// 每个模块的继承都是Exception的派生类,每个模块可以添加自己的数据
// 最后捕获时,我们捕获基类就可以
// 1. 定义统一的异常基类
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;
};
// 2. 派生类:数据库模块异常,增加了 sql 成员
class SqlException : public Exception
{
public:
SqlException(const string& errmsg, int id, const string& sql)
:Exception(errmsg, id)
, _sql(sql)
{
}
// 重写 what(),组装包含 SQL 信息的错误串
virtual string what() const
{
string str = "SqlException:";
str += _errmsg;
str += "->";
str += _sql;
return str;
}
private:
const string _sql;
};
// ... 其他派生类 CacheException, HttpException 同理 ...
class CacheException : public Exception
{
public:
CacheException(const string& errmsg, int id)
:Exception(errmsg, id)
{
}
virtual string what() const
{
string str = "CacheException:";
str += _errmsg;
return str;
}
};
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 SQLMgr()
{
if (rand() % 7 == 0)
{
throw SqlException("权限不足", 100, "select * from name = '张三'");
}
else
{
cout << "SQLMgr 调用成功" << endl;
}
}
void CacheMgr()
{
if (rand() % 5 == 0)
{
throw CacheException("权限不足", 100);
}
else if (rand() % 6 == 0)
{
throw CacheException("数据不存在", 101);
}
else
{
cout << "CacheMgr 调用成功" << endl;
}
SQLMgr();
}
void HttpServer()
{
if (rand() % 3 == 0)
{
throw HttpException("请求资源不存在", 100, "get");
}
else if (rand() % 4 == 0)
{
throw HttpException("权限不足", 101, "post");
}
else
{
cout << "HttpServer调用成功" << endl;
}
CacheMgr();
}
int main()
{
srand(time(0));
while (1)
{
// 睡眠一秒,使得我们能看清异常抛出
this_thread::sleep_for(chrono::seconds(1));
try
{
// 模拟调用链,内部可能抛出各种异常
HttpServer();
}
// 3. 统一捕获基类引用
catch (const Exception& e) // 这里捕获基类,基类对象和派生类对象都可以被捕获
{
// 4. 多态调用:根据实际抛出的对象类型,自动调用对应的 what()
cout << e.what() << endl;
}
// 5. 兜底保护
catch (...)
{
cout << "Unkown Exception" << endl;
}
}
return 0;
}
6. 最后的防线:catch(...)
管我们设计了完善的继承体系,但为了绝对的安全,一般在 main 函数的最后都会加上 catch(...)。
作用: 它可以捕获任意类型的异常。
目的: 防止因为某些未知的、未匹配到的异常导致程序直接崩溃终止。
局限: 使用它虽然能保住程序不挂,但我们不知道具体的异常错误是什么,所以它通常只作为最后的"兜底"手段,日志里可能只能打印一句 "Unknown Exception"。
1.5 异常重新抛出
什么是异常的重新抛出?简单来说,就是我们在 catch 块中捕获到了一个异常对象后,并不是为了彻底"消化"或处理掉这个错误,而是为了在中间层做一些特定的逻辑(比如资源释放、日志记录、或者像我们接下来要讲的重试机制),处理完之后,再将这个异常原封不动地抛给上层调用者去处理。
这就好比快递分拣中心,包裹(异常)到了这里,分拣员(中间层函数)看了一眼单子,发现有些包裹需要重新打包或者贴个标(中间处理),处理完还得把它扔回传送带(重新抛出),让最终的收件人(上层调用者)去签收或拒收。
为了让大家更直观地感受,我们来看一个非常经典的场景------开发一个类似微信的聊天软件。
大家想想,发送消息是不是有可能失败?
比如你坐电梯进地库了,信号不好;或者对方把你拉黑、删除了,你没权限发了。这时候我们的程序就得给用户反馈,不能傻愣着。
但在实际工程中,我们不能一遇到发送失败就直接弹窗报错说"发送失败"。
因为现在的手机网络是无线信号,跟基站连接,信号波动是很正常的。如果用户在电梯里,信号时断时续,你发一条报一次错,用户体验极差。
合理的做法是建立"重试机制":
识别错误类型:我们需要区分是因为"网络抖动"导致的失败,还是因为"权限不足"导致的失败。
分类处理:
- 如果是网络不稳定(比如错误码 102),我们应该在后台悄悄重试几次(比如重试 3 次)。如果重试成功了,用户根本感知不到网络卡顿;如果重试 3 次都失败了,再报错。
- 如果是非好友关系(比如错误码 103),这种重试没用,应该立即停止重试,直接把异常抛出去,提示用户"发送失败,请检查好友关系"。
代码实现与核心语法
结合上面的思路,我们设计了如下的异常类体系和业务逻辑(参考提供的示例代码):
cpp
#include<iostream>
using namespace std;
// 定义统一的异常基类,包含错误信息和ID
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;
};
// 具体的HTTP异常,继承自Exception
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 _SendMsg(const string& s)
{
if (rand() % 2 == 0)
{
throw HttpException("网络不稳定,发送失败", 102, "put");
}
else if (rand() % 7 == 0)
{
throw HttpException("你已经不是对象的好友,发送失败", 103, "put");
}
else
{
cout << "发送成功" << endl;
}
}
// 网络不稳定,要求重试3次,均失败
void SendMsg(const string& s)
{
// 循环4次:1次正常发送 + 3次重试
for (size_t i = 0; i < 4; i++)
{
try
{
_SendMsg(s);
// 走到这里代表成功了,跳出循环
break;
}
catch (const Exception& e)
{
// 核心逻辑:根据错误ID判断
if (e.getid() == 102)
{
// 102代表网络错误,允许重试
if (i == 3)
// 重试次数耗尽,重新抛出,通知上层彻底失败
throw;
cout << "开始第" << i + 1 << "重试" << endl;
}
else
{
// 重新抛出异常,两种写法都可
//throw e;
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;
}
注意看代码中的 throw; 语句。这里有一个非常重要的细节:重新抛出必须使用裸的 throw;,而不要写 throw e;。
-
throw;(推荐):表示"捕获到什么,就抛出什么"。它会保持原始异常对象的类型不变。即使你是用基类引用
const Exception& e捕获的,throw; 依然能抛出原始的 HttpException 对象,保证多态性不丢失。 -
throw e;(不推荐):这相当于构造了一个新的异常对象。因为 e 是基类引用,throw e; 会触发对象切片(Object Slicing),导致抛出的变成了一个纯粹的 Exception 基类对象,原本的 HttpException 特有信息(如 _type)就丢失了,上层捕获时就无法识别具体类型了。
1.6 异常安全问题
讲完了重新抛出,我们必须引申出一个更严肃的问题------异常安全。
C++ 中,一旦抛出异常,程序的执行流就会发生跳转,try 块中抛出点之后的代码将不再执行。这就带来了一个巨大的隐患:资源泄漏。
举个例子,如果你在函数里 new 了一块内存,或者打开了一个文件句柄、获取了一把锁,紧接着下一行代码抛出了异常,那么原本写在后面的 delete 或 fclose 就永远没机会执行了。这就导致了内存泄漏或资源死锁。
看下面这段代码,虽然写了 new 也写了 delete,看似没问题:
cpp
void Func() {
// 申请资源
int* array = new int[10];
// 中间调用了一系列函数
// 如果这里面的某个函数(比如 Divide)抛出了异常...
DoSomething(array);
// ...这里的 delete 就不会被执行!导致内存泄漏
delete[] array;
}
在正常的顺序执行中,除非你显式加了 return,否则代码都会跑完。那种 return 我们一眼就能看出来。但现在的问题是,中间调用的函数你看不到它内部会不会抛异常。如果它抛了,程序直接跳走,资源就泄露了。
不仅是内存,像锁(Lock)、文件描述符等资源,如果在异常发生前已获取,也必须确保在异常路径下被正确释放。
解决方案:捕获并清理(及其缺陷)
为了保证异常安全,我们需要遵循一个原则:如果在局部作用域内申请了资源,必须确保在异常发生时也能释放它。
最朴素的做法是利用 catch(...) 或具体的 catch 块来拦截异常,手动释放资源,然后再重新抛出。
cpp
#include<iostream>
using namespace std;
double Divide(int a, int b)
{
// 当b == 0时抛出异常
if (b == 0)
{
throw "Division by zero condition!";
}
return (double)a / (double)b;
}
void Func()
{
// 这⾥可以看到如果发⽣除0错误抛出异常,另外下⾯的array没有得到释放。
// 所以这⾥捕获异常后并不处理异常,异常还是交给外层处理,这⾥捕获了再
// 重新抛出去。
int* array = new int[10];
try
{
int len, time;
cin >> len >> time;
cout << Divide(len, time) << endl;
}
catch (...)
{
// 1. 拦截所有异常
cout << "delete []" << array << endl;
// 2. 手动释放资源,防止泄漏
delete[] array;
// 3. 重新抛出异常,把错误交给上层处理
throw;
}
// 正常流程也会走到这里释放
// 但要注意,如果catch里已经释放了,这里可能会重复释放
// 所以通常会将指针置空或使用更高级的RAII机制
cout << "delete []" << array << endl;
delete[] array;
}
int main()
{
try
{
Func();
}
catch (const char* errmsg)
{
cout << errmsg << endl;
}
catch (const exception& e)
{
cout << e.what() << endl;
}
catch (...)
{
cout << "Unkown Exception" << endl;
}
return 0;
}
在上述代码中,catch(...) 的作用不是为了处理业务逻辑,而是作为一个"资源清理站"。无论发生什么类型的异常,都会先进入这里把 array 释放掉,然后通过 throw; 继续向上传播错误。
虽然用 catch 块手动释放能解决问题,但代码写起来很繁琐,而且容易出错,甚至会把人"坑死"。
想象一下这种场景:你需要连续 new 多个对象。
- 第一个 new 成功了。
- 第二个 new 抛异常了 -> 你需要释放第一个。
- 第三个 new 抛异常了 -> 你需要释放前两个。
为了安全,你不得不写出层层嵌套的 try-catch 结构。而且别忘了,new 本身底层调用 operator new 时,如果内存不足也会抛出 bad_alloc 异常。这种写法不仅代码非常丑陋,而且维护成本极高,逻辑稍微复杂一点就容易漏掉某种情况。
终极方案:析构函数与 RAII
这就引出了 C++ 中最重要的异常安全机制------RAII(资源获取即初始化)。这也是我们下一篇文章要重点讲的智能指针的核心原理。
利用栈对象的析构函数会自动调用的特性,我们可以把资源封装在类里。不管函数是正常结束还是因为异常退出,栈对象都会被销毁,析构函数里的释放代码一定会执行。这才是解决异常安全问题的终极方案。
特别注意:别让异常逃离析构函数
此外,还要注意析构函数本身绝对不能抛出异常 。
《Effective C++》第8条款专门讲了这一点。为什么呢?
因为析构函数本身就是用来释放资源的。如果一个对象要释放 10 个资源,释放到第 5 个时析构函数抛出了异常,程序流程中断,剩下 5 个资源照样泄漏,而且会导致程序直接崩溃(terminate)。
所以,C++ 搞出智能指针,本质上就是为了解决这个问题:让它去自动释放资源,让我们从繁琐且危险的 catch 和手动 delete 中解脱出来。
1.7 异常规范
1.7.1 什么是异常规范?
异常规范其实说白了,这就是给"用户"看的一个说明书。这里说的"用户"不是指用软件的人,而是指写程序的程序员 。
可以想一下,在公司里开发,本质是协作。大家分不同的组,你做底层库,我做上层业务。那我在上层肯定要调用你下层提供的接口,这个接口通常就是一个函数或者一个类。
作为调用者,我最关心的是什么?除了这个函数能干嘛,我还特别想知道:这哥们儿写的函数,到底会不会抛异常?
如果我明确知道它会不会抛异常,对我写代码太有价值了。比如我知道它绝对安全,我就不需要写 try-catch 去捕获它;如果我知道它可能会炸,我就得提前防备。所以,语言设计者就搞出了这么一套机制,用来声明函数的异常抛出情况。
1.7.2 C++98的异常规范
在早期的 C++98 标准中,是在函数参数列表的后面加东西。
- 如果不抛异常: 就在括号后面写个
throw(),里面啥也不填。这就告诉编译器:"放心,我这函数很稳,绝不抛异常。" - 如果会抛异常: 就在 throw 的括号里写上具体的类型,比如
throw(int, char*)。这就表示:"我这函数可能会抛出int型或者char*型的异常,别的我不抛。"
大家看下面这段代码,这是库里的源码。
cpp
// C++98 风格的 operator new 声明
void *__CRTDECL operator new(size_t size) _THROW1(_STD bad_alloc)
{
// ...内部实现...
}
它后面有个 _THROW1(宏定义展开其实就是 throw(bad_alloc))。这就明确告诉你,内存分配失败时,它会抛出一个 bad_alloc 类型的异常。bad_alloc 是标准库里 exception 的派生类。
C++98 这种方式有个巨大的坑,导致它后来被弃用了。
原因是它太复杂且难以维护了
如果你写了一个函数 Func(),你在里面调用了 Divide(),而 Divide() 又调用了 operator new......这一层套一层,你得把所有底层可能抛出的异常类型都罗列出来,太难了!而且,如果你漏写了一个,编译器往往也不报错,或者报一堆让人头大的警告。所以在实际工程中,大家发现这东西根本没法完美执行,最后基本都被大家抛弃了
1.7.3 C++11 的极简主义:noexcept
到了 C++11,官方也意识到这个问题太麻烦,于是进行了简化。现在的规则非常简单粗暴,只分两种情况:
- 加 noexcept: 明确表示这个函数绝对不会抛出异常。
- 啥都不加: 默认表示可能会抛出异常。
这种二元对立的分类方式,极大地降低了心智负担。我们看几个标准库的例子:

std::vector::size():后面加了 noexcept。因为获取大小只是返回一个整数,不涉及内存分配,绝对安全。std::vector::begin():后面也加了 noexcept。获取迭代器也很安全。std::vector::push_back():注意看,因为 push_back 可能会导致扩容,扩容就要申请内存(调用 new),一旦内存不够就会抛 bad_alloc。就没有标 noexcept。
这里还有一个点,noexcept是有一定副作用的
如果你给一个函数加了 noexcept,相当于你跟编译器立了个军令状:"我保证不抛异常"。
如果你违背了誓言,标了 noexcept,结果函数内部真的抛出了异常(比如你手滑写了个 throw,或者调用的底层函数抛了异常),会发生什么?
- 编译期: 编译器不会报错,它甚至可能连警告都不给(有些编译器会给 warning,但标准不强制)。代码照样能跑。
- 运行期: 一旦异常真的抛出来了,程序不会像往常一样进入 catch 块,而是直接调用
std::terminate()终止程序,也就是直接崩掉
所以,使用 noexcept 要非常谨慎,必须是你百分之百确定内部逻辑(包括所有间接调用的函数)都不会出问题才行。
1.7.4 noexcept 运算符的额外用法
虽然手动标注有风险,但 C++11 提供了一个非常有用的工具------noexcept 运算符 。它可以作为一个检测器,帮我们在编译期判断一个表达式或函数是否会抛出异常。
它的用法是:noexcept(表达式)。
- 如果表达式不会抛异常,它返回 true (即 1)。
- 如果表达式可能会抛异常,它返回 false (即 0)。
cpp
double Divide(int a, int b)
{
if (b == 0) throw "Division by zero!"; // 这里明显会抛异常
return (double)a / b;
}
void Func()
{
int* array = new int[10]; // new 可能会抛 bad_alloc
// ...
}
int main()
{
// 测试 1:Divide 函数内部有 throw,所以结果是 0 (false)
cout << noexcept(Divide(1, 2)) << endl;
// 测试 2:哪怕参数传的是安全的,只要函数定义里有 throw,结果还是 0
cout << noexcept(Divide(1, 0)) << endl;
// 测试 3:Func 内部调用了 new,new 是不安全的,所以 Func 也是 0
cout << noexcept(Func()) << endl;
// 测试 4:i++ 这种简单操作,绝对安全,结果是 1 (true)
int i = 0;
cout << noexcept(++i) << endl;
return 0;
}
这个功能有什么用呢?
这就回到了我们开头说的"协作"问题。假设你的同事给了你一个接口(或者你用了一个第三方库),但他很懒,或者代码太老,既没有标 C++98 的 throw(),也没有标 C++11 的 noexcept。你心里没底啊,不知道这函数安不安全。
这时候,你就可以用 noexcept(他的函数()) 测一下。
- 如果返回 true,说明他要么标了 noexcept,要么函数体极其简单没调危险函数,你可以放心用。
- 如果返回 false,说明这函数"有毒",你在调用的时候最好包一层 try-catch 来兜底。
二、标准库的异常
https://legacy.cplusplus.com/reference/exception/exception/
2.1 抓住 std::exception
首先我们要建立一个概念:C++ 标准库定义了一套非常严谨的异常继承体系。这套体系的"老祖宗"(基类)就是 std::exception。
标准库抛出的所有异常,本质上都是 std::exception 的派生类。这意味着在日常写程序时,你不需要去死记硬背每一个具体的异常名字,只需要在主函数或者关键的逻辑块中捕获 std::exception,就能兜住绝大多数标准库抛出的问题。
我们来看一下 std::exception 的源码定义(参考 cplusplus.com):
cpp
class exception {
public:
exception () throw();
exception (const exception&) throw();
exception& operator= (const exception&) throw();
virtual ~exception() throw(); // 虚析构函数
virtual const char* what() const throw(); // 获取异常信息
};
这里有两个细节值得大家注意:
- what() 函数:这是一个虚函数。它的作用是返回一个 C 风格字符串(
const char*),用来描述异常的具体信息。因为它是虚函数,所以当我们用基类指针或引用捕获异常时,调用e.what()能够利用多态特性,精准地打印出到底是哪个子类(比如是内存分配失败还是越界)报的错。 - 虚析构函数:大家看源码里
virtual ~exception(),为什么析构函数要设成虚函数?
这是为了防止内存泄漏。虽然我们在捕获异常时通常是用引用catch(std::exception& e),不涉及对象的销毁,但作为基类,设计成虚析构是 C++ 多态类的标准规范,确保通过基类指针删除派生类对象时能正确释放资源。
2.2 异常家族图谱与常见成员
标准库的异常体系非常庞大,我们可以把它看作一个大家族。除了基类 std::exception,还有很多常见的派生类。虽然平时我们不需要一个个去 catch,但了解一下它们有助于我们在调试时快速定位问题。
以下是几个最高频出现的:
std::bad_alloc:这个大家应该很眼熟。当你使用 new 进行动态内存分配,如果内存不足分配失败时,就会抛出这个异常。std::out_of_range:这个也非常常见。比如你在使用 vector、string、deque 等容器时,调用了 .at() 接口(注意不是 \[\] 操作符),如果下标越界了,它就会跳出来报错。std::invalid_argument:非法参数异常。通常是因为传入了不符合预期的参数导致的。
虽然异常种类很多,但在实际工程实践中,我们通常不会针对每一种异常都写一个 catch 块。除非你有特殊的恢复逻辑(比如内存不够了尝试释放缓存再重试),否则直接捕获 std::exception 并打印日志往往是最优解。
结语
