从零吃透 C++ 异常:抛出捕获、栈展开、异常重抛与编码规范详解

目录

  • 前言
  • 一、异常的概念及使用
    • [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,无异常抛出)

  1. Divide 正常计算除法并返回数值,无 throw;
  2. Func 的 try 内正常打印除法结果,跳过 catch 代码,执行cout << "Func行号";
  3. Func 函数顺利执行完毕回到 main 的 try,main 内无异常触发,跳过 main 的两个 catch;
  4. 执行cout << "main行号",程序正常结束。

场景 2:异常输入(time=0,除数为 0,触发 throw exception)

  1. Divide 中b==0成立,执行throw exception(...),Divide 剩余代码不再执行,触发栈展开;
  2. Divide 被上层 Func 的 try 块包裹 ,向上匹配 Func 的catch(const exception& e):类型完全匹配;
  3. 进入 Func 的 catch,调用e.what()打印异常字符串Divide by zero condition!
  4. catch 执行结束,顺序执行cout << "Func行号"
  5. Func 整体执行完成,无异常向外抛出,回到 main 的 try,跳过 main 所有 catch;
  6. 执行cout << "main行号",程序结束。

拓展场景 1:注释掉 Func 内部的 try-catch

  1. Divide 抛 exception 异常,Func 没有任何捕获逻辑 → 触发栈展开,销毁 Func 内局部变量len/time,回溯到 main 函数;
  2. main 的 try 匹配第二个catch(const exception& e),打印异常信息;
  3. 执行 main 行号输出。

拓展场景 2:修改异常为 throw string(启用注释代码throw string("xxx");)

  1. Divide 抛 string 对象,Func 的 catch 只接收 exception,类型不匹配、捕获失败
  2. 栈展开退出 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,官方也意识到这个问题太麻烦,于是进行了简化。现在的规则非常简单粗暴,只分两种情况:

  1. 加 noexcept: 明确表示这个函数绝对不会抛出异常。
  2. 啥都不加: 默认表示可能会抛出异常。

这种二元对立的分类方式,极大地降低了心智负担。我们看几个标准库的例子:

  • 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(); // 获取异常信息
};

这里有两个细节值得大家注意:

  1. what() 函数:这是一个虚函数。它的作用是返回一个 C 风格字符串(const char*),用来描述异常的具体信息。因为它是虚函数,所以当我们用基类指针或引用捕获异常时,调用 e.what() 能够利用多态特性,精准地打印出到底是哪个子类(比如是内存分配失败还是越界)报的错。
  2. 虚析构函数:大家看源码里 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 并打印日志往往是最优解。


结语

相关推荐
灯澜忆梦1 小时前
GO---可见性规则
开发语言·golang
REDcker1 小时前
libdatachannel 快速入门
c++·webrtc·datachannel
逝水无殇1 小时前
C# 文件的输入与输出详解
开发语言·数据库·后端·c#
这是个栗子2 小时前
前端开发中的常用工具函数(八)
开发语言·前端·javascript
凤凰院凶涛QAQ2 小时前
《Java版数据结构 & 集合类剖析》栈与队列:“push/pop 是栈的灵魂,offer/poll 是队列的骨架——四组 API,两种人生”
java·开发语言·数据结构
研☆香3 小时前
为什么变量声明在赋值前也能使用?—— 深入理解 JavaScript 变量提升与作用域
开发语言·前端·javascript
右耳朵猫AI3 小时前
PHP周刊2026W28 | 安全更新、Laravel 13.18、Twig 3.28
开发语言·php·laravel
second603 小时前
第一部分:快速上手 —— 建立 C++ 基本语法与编程范式
开发语言·c++
郝学胜-神的一滴3 小时前
算法实战:最小k个数——大顶堆的优雅解法
开发语言·数据结构·c++·python·程序人生·算法·排序算法