深入C++异常:栈展开、异常安全与工程规范

1.异常的概念以及运用

1.1 什么是异常

核心概念:异常 (Exception)

异常是程序在运行时发生的、偏离正常执行流程的意外事件或错误状态。它可以是硬件问题(如除零)、资源问题(如内存不足、文件不存在)、逻辑错误(如下标越界)等。

异常处理机制的优势 (与C语言错误码对比)

C语言错误码方式(如返回 -1NULL)存在一些固有缺陷,而异常处理正是为了解决这些问题而设计的。

1.2 异常的抛出与捕获

  1. 核心机制
  • 抛出与匹配 :程序通过 throw 一个对象来引发异常。系统会根据这个对象的类型 和当前的调用链 ,自动寻找与该对象类型匹配且离抛出异常位置最近的 catch 处理器。

  • 类型与内容 :抛出对象的类型决定了匹配哪个 catch,而对象携带的内容(如错误信息)则用于向处理器描述发生了什么错误。

  • 控制权转移 :一旦 throw 执行,其后的语句不再运行 ,控制权会从 throw 位置直接跳转到匹配的 catch 模块。这个 catch 可以在当前函数,也可以在调用链中的其他函数。

  1. 关键特性
  • 栈展开与对象销毁 :当控制权转移时,沿着调用链的函数会提前退出。同时,在 throw 位置到 catch 位置之间,所有在调用链上创建的局部对象都会被自动销毁。

  • 异常对象的拷贝throw 时会生成一个异常对象的拷贝 。这是因为原始异常对象可能是局部变量,在栈展开时就会被销毁。这个拷贝会存活到 catch 子句执行结束后才被销毁,其处理方式类似于函数传值返回。

1.3 栈展开

栈展开是异常处理机制中,从异常抛出点向上查找匹配 catch 子句的过程。

  • 查找起点 :抛出异常后,程序暂停当前函数的执行,首先检查 throw 语句本身是否位于 try 块内部。

    • 若在 try 块内,则查找该 try 块关联的 catch 子句。

    • 若找到类型匹配的 catch,则跳转到该 catch 处执行处理。

  • 逐层退出 :若当前函数中没有 try/catch 结构,或者虽然有 try/catch 但没有匹配的 catch 类型,则退出当前函数,销毁其局部对象,并继续在外层调用函数中重复上述查找过程。这个过程就称为栈展开

  • 未找到匹配 :如果栈展开一直进行到 main 函数,仍然没有找到匹配的 catch 子句,程序将调用标准库的 terminate 函数终止运行。

  • 处理完成 :一旦找到匹配的 catch 子句并执行完毕后,程序会从该 catch 块之后继续执行(不会回到 throw 的位置)。

cpp 复制代码
double Divide(int a, int b)
{
	try
	{
		// 当b == 0时抛出异常
		if (b == 0)
		{
			string s("Divide by zero condition!");
			throw s;
			//throw语句后面不执行
		}
		else
		{
			return ((double)a / (double)b);
		}
	}
	catch (int errid)
	{
		cout << errid << endl;
	}
	return 0;
}
void Func()
{
	int len, time;
	cin >> len >> time;
	try
	{
		cout << Divide(len, time) << endl;
	}
	catch (const char* errmsg)
	{
		cout << errmsg << endl;
	}
	cout << __FUNCTION__ << ":" << __LINE__ << "执行" << endl;
}
int main()
{
	while (1)
	{
		try
		{
			Func();
		}
		catch (const string& errmsg)
		{
			cout << errmsg << endl;
		}
		catch (...)//任意类型异常
		{
			cout << "unknow error" << endl;
		}
	}
	return 0;
}

1.4查找匹配的处理代码

异常匹配的核心原则是:抛出对象的类型 决定了由哪个 catch 处理。一般情况下要求类型完全匹配 ,且多个匹配时选择离抛出位置最近的那个。

但在以下几种特殊情况下,允许进行合法的类型转换:

允许的类型转换(非严格匹配)

重点:派生类向基类的转换

实际工程中,异常类通常设计为继承体系(如 std::exception 作为基类),通过捕获基类类型即可统一处理所有派生异常,非常方便。

未找到匹配的处理

  • 如果栈展开到 main 函数仍然没有找到匹配的 catch,程序会调用 std::terminate() 终止。

  • 程序终止通常不是我们期望的结果(除非发生严重错误)。

兜底捕获:catch(...)

catch(...) 的特点:

  • 可以捕获任意类型的异常

  • 缺点是无法获取异常对象的内容(不知道错误是什么)

  • 通常放在 catch 链的最后作为兜底,防止程序意外终止

1.5 异常的重新抛出

cpp 复制代码
try 
{
    // 可能抛出异常的代码
}
catch (const Exception& e) 
{
    // 部分错误需要特殊处理
    if (某种条件)
    {
        // 特殊处理逻辑
    }
    else
    {
        throw;   // 重新抛出当前捕获的异常
    }
}

注意:throw;throw e; 的区别

cpp 复制代码
catch (const std::exception& e) {
    throw;   //  正确:重新抛出原始异常,保留动态类型
}

catch (const std::exception& e) {
    throw e; // 错误:抛出的是 e 的静态类型(std::exception)
}

总结:throw; 就是把当前抓到的异常原封不动地继续往上抛,让外层调用链来处理。

cpp 复制代码
// 下⾯程序模拟展⽰了聊天时发送消息,发送失败捕获异常,但是可能在
// 电梯地下室等场景⼿机信号不好,则需要多次尝试,如果多次尝试都发
// 送不出去,则就需要捕获异常再重新抛出,其次如果不是⽹络差导致的
// 错误,捕获后也要重新抛出。
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;
	}
}
void SendMsg(const string & s)
{// 发送消息失败,则再重试3次
	for (size_t i = 0; i < 4; i++)
	{
		try
		{
			_SendMsg(s);
			break;
		}
		catch (const Exception& e)
		{
			// 捕获异常,if中是102号错误,⽹络不稳定,则重新发送
			// 捕获异常,else中不是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;
}

1.6 异常安全问题

一、异常导致的资源泄漏问题

问题描述:

当程序抛出异常时,throw 后面的代码将不再执行。如果在申请资源(如内存、锁、文件句柄等)和释放资源之间发生了异常,就会导致资源无法被释放,从而引发资源泄漏和安全问题。

示例场景:

cpp 复制代码
void func() 
{
    int* ptr = new int[1000];  // 申请资源
    lock.acquire();             // 获取锁
    // 这里可能抛出异常
    doSomething();              // 如果抛异常,后面释放资源的代码不会执行
    delete[] ptr;               // 可能被跳过 → 内存泄漏
    lock.release();             // 可能被跳过 → 死锁
}

二、传统解决方案:捕获 → 释放 → 重新抛出

cpp 复制代码
void func() 
{
    int* ptr = new int[1000];
    lock.acquire();
    
    try 
    {
        doSomething();  // 可能抛异常
    }
    catch (...)
    {
        delete[] ptr;   // 释放资源
        lock.release();
        throw;          // 重新抛出,让上层继续处理
    }
    
    delete[] ptr;
    lock.release();
}

缺点: 代码繁琐、容易遗漏、可维护性差。

三、更好的解决方案:RAII

RAII(Resource Acquisition Is Initialization,资源获取即初始化)是 C++ 中推荐的资源管理方式。

核心思想:

  • 构造函数中获取资源

  • 析构函数中释放资源

  • 当栈展开时,局部对象的析构函数会被自动调用 ,资源自动释放

这里的内容我会单独出一篇博客来详细讲解智能指针


四、析构函数中的异常处理

4.1 问题描述

析构函数在释放多个资源时,如果释放到一半抛出异常,后续资源将无法释放,同样会导致资源泄漏。

cpp 复制代码
~ResourceManager() 
{
    for (int i = 0; i < 10; ++i) 
    {
        delete res[i];  // 假设第5个 delete 抛出异常
        // 第6-10个资源无法释放!
    }
}

4.2 核心原则(《Effective C++》条款08)不要让异常逃离析构函数

4.3 为什么析构函数不应抛出异常?

4.4 解决方案:捕获并处理

cpp 复制代码
~ResourceManager() 
{
    for (int i = 0; i < 10; ++i)
    {
        try 
        {
            delete res[i];
        }
        catch (const std::exception& e) 
        {
            // 记录日志,但不向外抛出
            Logger::Log(e.what());
        }
        catch (...)
        {
            Logger::Log("未知错误");
        }
    }
}

4.5 更好的设计:提供独立的 close() 方法

cpp 复制代码
class Connection 
{
public:
    void close() 
    {
        // 可能抛异常的操作
        if (disconnect() == FAILED)
        {
            throw Exception("断开失败");
        }
        closed = true;
    }
    
    ~Connection()
    {
        if (!closed)
        {
            try { close(); }
            catch (...)
            { 
                // 析构中只能吞掉异常或调用 terminate
            }
        }
    }
private:
    bool closed = false;
};

总结

1.7 异常规范

异常规格说明:从 throw()noexcept

一、为什么需要异常规格说明?

对于用户和编译器而言,预先知道一个函数是否会抛出异常大有裨益:

  • 用户 :可以决定是否需要为该函数编写 try-catch 代码

  • 编译器:可以进行优化(如避免生成栈展开相关的代码)

二、C++98 的 throw() 方式

在 C++98 中,通过在函数参数列表后面添加 throw() 来声明函数的异常抛出行为:

缺点:

  • 语法复杂,尤其是需要列出多种异常类型时

  • 实践中不好用,容易出现不一致

  • 运行时检查开销较大

三、C++11 的 noexcept 方式

C++11 对其进行了简化,引入了 noexcept 关键字:

四、noexcept 的编译器行为

关键点:编译器不会在编译时强制检查 noexcept

cpp 复制代码
void mayThrow()
{
    throw 42;
}

void test() noexcept 
{
    mayThrow();  // 调用了可能抛异常的函数
    throw 42;    // 直接 throw
    // 编译器可能只给一个警告,但会编译通过
    // warning "XXX": 假定函数不引发异常,但确实发生了
}

运行时行为:

  • 如果一个声明为 noexcept 的函数实际抛出了异常

  • 程序会调用 std::terminate() 立即终止(而不是进行栈展开)

cpp 复制代码
void dangerous() noexcept 
{
    throw std::runtime_error("error");  // 抛出异常
}//warning "dangerous": 假定函数不引发异常,但确实发生了

int main() 
{
    dangerous();  // 程序直接终止,不会传播到 main
    return 0;
}

五、noexcept 作为运算符

noexcept(expression) 可以作为一个运算符 ,在编译时检测一个表达式是否会抛出异常:意思就是这个表达式是noexcept(无异常)的吗

  • 返回 true:表达式不会抛出异常

  • 返回 false:表达式可能抛出异常

示例:

cpp 复制代码
void nonThrow() noexcept {}
void mayThrow() {}

int main()
{
    std::cout << noexcept(1 + 2) << std::endl;           // 1 (true),整型运算不抛异常
    std::cout << noexcept(nonThrow()) << std::endl;      // 1 (true)
    std::cout << noexcept(mayThrow()) << std::endl;      // 0 (false)
    
    // 常用于模板元编程或条件编译
    if constexpr (noexcept(nonThrow()))
    {
        // 编译时就知道不抛异常,可以做一些优化
    }
    return 0;
}

六、实际应用建议


总结

  • noexcept 是 C++11 引入的更简洁、更实用的异常规格说明

  • 它告诉编译器"这个函数不会抛异常",如果违反了承诺,程序会直接终止

  • 移动语义和性能优化中应优先使用 noexcept

cpp 复制代码
// C++98
// 这⾥表⽰这个函数只会抛出bad_alloc的异常
void* operator new (std::size_t size) throw (std::bad_alloc);
// 这⾥表⽰这个函数不会抛出异常
void* operator delete (std::size_t size, void* ptr) throw();

// C++11
size_type size() const noexcept;
iterator begin() noexcept;
const_iterator begin() const noexcept;

double Divide(int a, int b) noexcept
{
	// 当b == 0时抛出异常
	if (b == 0)
	{
		throw "Division by zero condition!";
	}
	return (double)a / (double)b;
}
int main()
{
	try
	{
		int len, time;
		cin >> len >> time;
		cout << Divide(len, time) << endl;
	}
	catch (const char* errmsg)
	{
		cout << errmsg << endl;
	}
	catch (...)
	{
		cout << "Unkown Exception" << endl;
	}
	int i = 0;
	cout << noexcept(Divide(1, 2)) << endl;
	cout << noexcept(Divide(1, 0)) << endl;
	cout << noexcept(++i) << endl;
	return 0;
}

2.标准库的异常

  • 参考文档:cplusplus.com/reference/exception/exception/
  • C++标准库也定义了⼀套⾃⼰的⼀套异常继承体系库,基类是exception,所以我们⽇常写程序,需要在主函数捕获exception即可,要获取异常信息,调⽤what函数,what是⼀个虚函数,派⽣类可以重写。
相关推荐
码农爱学习2 小时前
用简单的例子,来理解C指针
c语言·开发语言
敲敲千反田2 小时前
CMS和G1
java·开发语言·jvm
sycmancia2 小时前
Qt——Qt中的文件操作、文本流和数据流
开发语言·qt
ACP广源盛139246256732 小时前
长距传输全能芯 @ACP#GSV5800 Type‑C/DP1.4/HDMI2.0 高速延长芯片
c语言·开发语言·网络·人工智能·嵌入式硬件·计算机外设·电脑
tankeven2 小时前
C++ 学习杂记00:标准模板库(STL)
c++
存在的五月雨2 小时前
Python操作 调用yolov8n-pose
开发语言·python·yolo
cmc10282 小时前
230.C语言循环的相关延时计算
c语言·开发语言
Fate_I_C2 小时前
Kotlin 基础语法快速回顾
android·开发语言·kotlin
一只大袋鼠2 小时前
MyBatis 进阶实战(四): 连接池、动态 SQL、多表关联(一对多 / 多对一 / 多对多)
java·开发语言·数据库·sql·mysql·mybatis