目录

1. 异常处理

1.1 核心概念

1.2 异常捕获规则

1.3 异常执行流程

1.4 总结

2. 栈展开(Stack Unwinding)

2.1 核心概念

2.2 清理范围

2.3 异常安全问题

2.4 栈展开的重要意义:保障异常安全

2.5 总结

3. noexcept 关键字

3.1 noexcept 作为说明符

3.2 合规的两种写法

3.3 noexcept 作为运算符

3.4 noexcept 的核心作用

3.5 noexcept 在STL 容器中的经典应用场景

3.6 强烈建议使用 noexcept 的三大场景

4. 重新抛出异常

4.1 语法形式

4.2 常见使用场景

5. 异常多态与标准库异常

5.1 标准异常体系结构

5.2 自定义异常类


1. 异常处理

1.1 核心概念

C++ 异常处理是专门用来处理程序运行时错误的机制,让程序在出错时不会直接崩溃,还能优雅地释放资源、提示错误。

核心三个关键字

  1. try:尝试执行可能出错的代码块
  2. throw:抛出异常(发现错误时触发)
  3. catch:捕获并处理异常(接住抛出的错误)

基本语法结构

int main() 
{
	// 1. try 包裹可能出错的代码
	try 
	{
		int a = 10, b = 0;
		if (b == 0) 
		{
			// 2. 主动抛出异常(const char*类型)
			throw "除数不能为 0!";
		}
		cout << a / b << endl;
	}
	// 3. catch 捕获并处理异常
	catch (const char* err) 
	{
		cout << "捕获到异常:" << err << endl;
	}

	return 0;
}

1.2 异常捕获规则

  1. try 必须搭配 catch,不能单独使用。
  2. 抛出什么类型,就捕获什么类型。
    • 抛 int → catch(int)
    • 抛 string → catch(string)
    • 抛自定义类 → catch(类名)
  3. 一个 try 可以对应多个 catch,捕获不同类型异常。
  4. 可以用 catch(...) 捕获任意类型异常(兜底),必须写在所有catch的最后,不能写在前面。
  5. 如果 throw 抛出的异常,全程没有任何 catch 接住 → 程序会直接终止(崩溃)!

代码示例:

int main()
{
	try
	{
		int num;
		cin >> num; 
		if (num == 0) throw "零错误";   // 抛 const char*
	}
	catch (int err)
	{
		cout << "整数异常:" << err << endl;
	}
	catch (const char* err)
	{
		cout << "字符串异常:" << err << endl;
	}	
	catch (...) // 兜底:捕获所有没被匹配的异常
	{
		cout << "未知异常!" << endl;
	}

	return 0;
}

1.3 异常执行流程

当执行 throw 时:

  1. throw 后面的所有代码立刻停止执行,程序直接跳转
  2. 沿着函数调用链往上回溯,逐层退出函数(触发栈展开,自动销毁局部对象)
  3. 直到找到第一个类型匹配的 catch 块,控制权完全转移到 catch 块执行
  4. 如果全程找不到匹配的 catch,程序直接调用 std::terminate() 终止崩溃
void funcB() 
{
	cout << "进入funcB函数" << endl;
	throw 10;       // 抛异常,立刻跳转!
	cout << "嘻嘻嘻" << endl; // 这句话永远不会执行!
}

void funcA() 
{
	cout << "进入funcA函数" << endl;
	funcB();        // 调用funcB
	cout << "哈哈哈" << endl; // 这句话也不会执行!
}

int main() 
{
	try 
	{
		funcA();
	}
	catch (int x)  // 最终跳到这里!
	{  
		cout << "捕获异常:" << x << endl;
	}
	cout << "程序继续运行\n";
	return 0;
}

跳转逻辑:funcB → funcA → main(找到匹配的 catch 并进去执行)

运行结果:

1.4 总结

  • 抛出异常后,throw 之后代码不再执行
  • 沿调用链向上回溯
  • 选择第一个类型匹配的 catch
  • 找不到 catch → 调用 terminate,程序直接退出

2. 栈展开(Stack Unwinding)

栈展开是 C++ 异常处理的核心机制,它保证了程序在抛出异常时能安全清理资源、避免资源泄漏,是 C++ 异常安全的基石。

2.1 核心概念

当程序抛出一个异常,且当前函数没有立即捕获它时,程序不再往下执行,开始沿着函数调用链一层层退出函数,在退出每一层函数时,自动销毁该函数内已构造完成的所有局部对象(调用析构函数),直到找到匹配的catch,这个“回溯并清理”的过程就叫 栈展开。

代码实例:

struct A 
{
	A() { cout << "A 构造\n"; }
	~A() { cout << "A 析构\n"; }
};
struct B 
{
	B() { cout << "B 构造\n"; }
	~B() { cout << "B 析构\n"; }
};

void funcB() 
{
	A a;                  // 局部对象
	cout << "funcB 抛异常\n";
	throw 1;              // 抛异常 → 触发栈展开
	cout << "funcB 结束\n";// 永远不执行
}

void funcA() 
{
	B b;                  // 局部对象
	funcB();
	cout << "funcA 结束\n";// 永远不执行
}

int main() 
{
	try 
	{
		funcA();
	}
	catch (int) 
	{
		cout << "捕获异常\n";
	}
	return 0;
}

运行结果:

2.2 清理范围

  • 只处理栈上局部对象。
  • 不处理堆上 new 出来的对象(所以裸指针会泄漏,这也是为什么必须使用智能指针(RAII)来管理堆内存,因为智能指针本身是栈对象,其析构函数会自动 delete)。
  • 不处理全局 / 静态变量。

2.3 异常安全问题

析构函数绝对不能向外抛异常(别让异常逃离析构函数)

栈展开一定会执行析构函数,当程序因异常进行栈展开时,如果析构函数又抛出新异常,运行时会面临“两个异常同时存在”的困境,导致直接调用 std::terminate() 立即崩溃。

为了从根源杜绝这种风险,C++11 规定析构函数默认隐式为 noexcept ,这意味着:

  • 编译器会默认认为析构函数不会抛出异常,无需手动标注;
  • 若析构函数实际抛出了未被内部捕获的异常,会直接触发std::terminate(),程序强制终止;
  • 该规则强制要求开发者在析构函数内部处理所有可能的错误,彻底阻断异常逃逸的可能。

2.4 栈展开的重要意义:保障异常安全

栈展开是 C++ 异常安全的核心保障:

  • 无栈展开:异常直接跳转,资源无法释放,造成泄漏、死锁。
  • 有栈展开:自动析构局部对象,安全释放内存、文件、锁等资源,避免程序异常崩溃或卡死。

栈展开是 RAII(资源获取即初始化) 的核心基础,通过将资源封装为栈上对象,利用栈展开自动调用析构函数的特性,实现异常安全的资源管理。

2.5 总结

  • 抛出异常后,throw 后面代码立刻停止执行,程序沿函数调用链向上回溯找 catch。
  • 回溯过程中会自动销毁每一层函数的局部对象,调用析构函数,这个过程就是栈展开。
  • 找到匹配的 catch 后,栈展开停止,程序执行 catch 内代码,之后继续正常运行。

3. noexcept 关键字

noexcept 是 C++11 引入的一个关键字,用于替代旧式的throw(),它既是一个承诺(说明符),也是一个查询工具(运算符)。

3.1 noexcept 作为说明符

当 noexcept 修饰函数时,它向编译器和调用者承诺:“本函数绝不会向外抛出异常”。

语法形式:

void f() noexcept; // 等同于 void f() noexcept(true); 
void f() noexcept(表达式); // 根据表达式结果决定是否为 noexcept 

核心规则

  • 函数承诺不向外抛异常。
  • 如果一个noexcept函数实际抛出了未被内部捕获的异常,C++会立即调用std::terminate(),终止程序的执行,这个过程不会进行正常的栈展开,因此可能会导致资源泄漏。
  • 在C++11及以后,析构函数默认就是noexcept的,不需要手动写。

3.2 合规的两种写法

要正确地使用 noexcept,函数实现必须确保异常不逃逸。主要有两种方式:

1. 内部完全不抛出异常(推荐)

这是最常见和推荐的做法。函数内部只调用其他noexcept函数,或执行绝对不会失败的操作。

// 一个简单的数学计算函数,肯定不会抛异常
int add(int a, int b) noexcept 
{
	return a + b;
}

2. 抛出异常,但必须在函数内部完全捕获和处理

如果在noexcept函数内部调用了可能抛出异常的代码,必须使用try-catch 块将所有异常捕获并消化掉,绝不能让异常“逃逸”出去。

// 一个可能会抛出异常的函数
void mightThrow()
{
	throw "来自 mightThrow 的异常";
}

void safeWrapper() noexcept 
{
	try 
	{
		mightThrow(); // 调用可能抛出异常的函数
	}
	catch (const char* err) // 捕获所有异常,并进行处理(如记录日志)
	{		
		// 异常在这里被成功捕获和处理
		cout << "捕获到异常并已处理: " << err << endl;
	}
	// 函数正常结束,履行了 noexcept 承诺
}

3.3 noexcept 作为运算符

noexcept 还可以作为一个运算符去检测一个表达式是否会抛异常。

noexcept (表达式) 返回值:

  • true:表达式保证不抛出异常(函数声明为 noexcept 或 throw())。
  • false:表达式可能抛出异常(未声明异常规格,或声明了 throw(...))。

代码示例:

void f1() 
{
	// 可能抛异常
}

void f2() noexcept 
{
	// 保证不抛异常
}

int main() 
{
	// 检测表达式会不会抛异常
	cout << boolalpha; //开启文字布尔值

	cout << noexcept(f1()) << endl;   // false
	cout << noexcept(f2()) << endl;   // true
	cout << noexcept(1 + 2) << endl;  // true(基础运算保证不抛异常)
	return 0;
}

3.4 noexcept 的核心作用

1、性能优化

当编译器知道一个函数绝对不会抛出异常时,它可以生成更高效、更紧凑的机器码。

  • 减少开销:编译器不需要为该函数生成异常处理表(Exception Tables)和栈展开(Stack Unwinding)的代码,这能减小二进制文件的体积并提高运行速度。
  • 启用移动语义(关键场景):这是 noexcept 最经典的应用。标准库容器(如 std::vector)在扩容时,为了保证强异常安全(即操作要么完全成功,要么回滚到原状态)会检查元素的移动构造函数是否标记为 noexcept。
    • 如果是 noexcept:容器会放心地使用移动操作,效率极高。
    • 如果不是 noexcept:容器会认为移动操作可能失败,为了防止移动过程中抛出异常导致数据丢失,容器会退而求其次使用拷贝操作,导致性能大幅下降。

 2. 异常安全与程序稳定性

  • 保护析构函数:C++ 规定,如果在栈展开(即处理一个异常)的过程中,析构函数又抛出了新异常,程序会直接崩溃。因此,析构函数默认都是 noexcept 的。
  • 防止异常逃逸:对于某些关键操作(如 swap 交换函数),如果抛出异常可能会导致数据状态不一致。标记为 noexcept 可以强制要求在函数内部处理所有潜在错误,保证操作的原子性。

3.5 noexcept 在STL 容器中的经典应用场景

std::vector 等容器在扩容时,会通过编译期类型检测,在性能和异常安全之间做最优权衡。

核心逻辑原理

容器扩容的本质是:分配新的更大内存块,将旧元素迁移到新内存,再释放旧内存。

  • 如果直接用移动构造:效率极高,但如果移动操作抛异常,会导致旧元素已被破坏、新元素未完全构造,数据状态损坏。
  • 如果全程用拷贝构造:绝对安全,如果中途抛异常,原数据还在旧内存里完好无损,但性能开销大,尤其是大对象、大容器场景。

vector 扩容时的元素迁移逻辑(伪代码):

// vector扩容时的元素迁移大致逻辑
template <typename T, typename Alloc>
void vector_uninitialized_copy(T* new_begin, T* new_end, T* old_begin, Alloc& alloc) 
{
	T* dst = new_begin;
	try 
	{
		// 编译期判断:如果T的移动构造是noexcept的或者T根本不能拷贝(只能移动)
		if constexpr (std::is_nothrow_move_constructible_v<T> || !std::is_copy_constructible_v<T>) 
		{
			// 移动构造(高效)
			for (auto it = old_begin; it != new_end; ++it, ++dst) 
			{
				alloc.construct(dst, std::move(*it));
			}
		}
		else 
		{
			// 拷贝构造(移动可能抛异常,为了安全降级为拷贝)
			for (auto it = old_begin; it != new_end; ++it, ++dst) 
			{
				alloc.construct(dst, *it);
			}
		}
	}
	catch (...) 
	{
		// 异常处理:强异常安全保证(回滚机制)
		// 发现异常,撤销所有已完成的操作
		for (auto it = new_begin; it != dst; ++it) 
		{
			alloc.destroy(it); //销毁已构造对象
		}
		alloc.deallocate(new_begin, new_end - new_begin);//释放分配的内存
		throw; //重新抛出异常,让上层处理
	}
}

3.6 强烈建议使用 noexcept 的三大场景

  • 1. 移动构造函数和移动赋值运算符(为了性能,让容器敢于移动)。
  • 2. 析构函数(在 C++11 及以后,析构函数默认就是noexcept的)。
  • 3. 交换函数 (swap) (为了强异常安全保证和原子性,避免数据状态不一致)。

4. 重新抛出异常

在异常传播的过程中,我们经常需要做一些日志记录、资源清理或异常类型转换的处理,然后把异常继续 “甩” 给上层调用者处理,这就是异常的重新抛出。

4.1 语法形式

在catch块内部,再次调用 throw 即可。

方式一:原样重新抛出(推荐)

try 
{
	// 可能抛异常的代码
}
catch (const char* msg) 
{
	// 第一步:本地处理(如打印日志)
	cout << "记录日志:" << msg << endl;

	// 第二步:重新抛出
	throw; //(空 throw)它会将当前正在处理的同一个异常对象再次抛出,保留原始类型和信息。
}

方式二:抛出一个新的异常(异常包装/转换)

如果想把捕获到的异常替换成一个新的异常抛给上层,直接写:

try 
{
	// ...
}
catch (int err) 
{
	// 把 int 类型的异常,转换成一个标准的 runtime_error 抛出去
	throw runtime_error("发生了整数异常:" + to_string(err));
}

4.2 常见使用场景

场景1:异常日志记录

在框架或中间层代码中,我们可能无法处理具体的业务错误,但需要记录错误的“踪迹”,以便排查问题。

void logAndRethrow() {
    try {
        // 调用可能抛异常的函数
        riskyOperation();
    } catch (...) { // 捕获所有异常
        std::cerr << "[致命错误] 捕获到未知异常,正在记录..." << std::endl;
        // 记录完日志后,原样抛出,让上层处理
        throw; 
    }
}

场景2:包装异常(异常类型转换)

底层抛出的是简单的错误码(如 int),但上层业务逻辑需要统一的异常类(如 MyException)。此时可以在中间层 “包装” 一下。

try {
   ……  // 可能抛 int 错误码
} catch (int errCode) {
    // 把简单的错误码,包装成业务含义明确的异常对象再抛
    throw DatabaseException("查询失败", errCode);
}

场景3:局部资源清理

虽然现代C++推荐使用RAII(智能指针)来自动管理资源,但在某些遗留代码或极端性能场景下,可能需要手动管理资源。如果发生异常,必须在抛出前释放资源,否则会造成资源泄漏。

void manualResourceManage() {
	int* data = new int[100]; // 手动申请堆内存
	std::mutex mtx;
	mtx.lock(); // 手动加锁

	try {
		riskyOperation(); // 可能抛异常的操作
	}
	catch (...) {
		// 异常发生:手动回滚、释放资源
		mtx.unlock();
		delete[] data;
		// 清理完成后,重新抛出异常通知调用者
		throw;
	}

	// 正常流程:操作成功后手动释放资源
	mtx.unlock();
	delete[] data;
}

5. 异常多态与标准库异常

C++语法上允许抛出任意类型的异常,比如(throw 1; 或 throw “error”;),这在语言上属于过度灵活,实际工程中几乎不会这样用,甚至可以认为是一种设计上的宽松缺陷。

在真实的项目开发中,统一且规范的做法是:

只抛异常类对象,且该类通常继承自标准库的std::exception基类。

多态优势

通过继承,我们可以利用C++的多态特性,只用一个 catch(const std::exception& e) 就能捕获所有派生类的异常,无需为每种异常类型编写单独的catch块,大幅简化异常处理逻辑。

5.1 标准异常体系结构

C++ 标准库在<exception>、<stdexcept>等头文件中定义了一套成熟的异常继承体系。工程实践中通常直接复用或继承这些类,或者在此基础上进行扩展。

关键函数

所有标准异常的根类std::exception提供了统一的虚函数接口:

virtual const char* what() const noexcept;

该函数用于返回异常描述信息的C风格字符串,所有派生类都会重写该方法,从而在捕获基类引用时,能够正确地调用派生类的实现,实现多态调用。

标准异常继承层次图

利用多态性来捕获标准库中的异常

代码示例:多态捕获

void func()
{
	std::vector<int> vec = { 1, 2, 3 };

	try 
	{
		// 场景1:故意制造一个越界访问 (触发点:at() 函数会检查边界,这里会立即抛出 std::out_of_range)
		// vec.at(10) = 4;

		// 场景2:故意制造一个无效参数错误 (抛出 std::invalid_argument)
		throw invalid_argument("参数值无效");

	}
	catch (const std::exception& e) 
	{
		// 多态优势:无论是 out_of_range 还是 invalid_argument,它们都继承自std::exception,因此都能被这里捕获
		cout << "捕获到标准异常: " << e.what() << endl;
	}
}

int main() 
{
	func();
	return 0;
}

5.2 自定义异常类

真实项目中,标准库的异常往往不够具体,我们需要继承std::exception(或其子类)来定义自己的错误类型。

1. 直接继承std::exception(不推荐):

虽然理论上可以继承std::exception,但这通常不是最佳选择。因为std::exception是一个非常基础的抽象类,它没有成员变量,不存储任何错误信息,它的what()函数返回的通常是固定的字符串。如果直接继承它,我们需要手动实现字符串的存储和what()函数的重写。

代码示例:

class MyException : public exception
{
private:
	string msg; // 需要自己维护错误信息字符串

public:
	MyException(const string& s) 
		: msg(s) 
		{}

	// 重写 what()
	const char* what() const noexcept override 
	{
		return msg.c_str();
	}
};

void test() 
{
	throw MyException("自定义异常抛出!");
}

int main() 
{
	try 
	{
		test();
	}
	catch (const exception& e) //基类捕获
	{
		cout << e.what() << endl;
	}
    catch (...) // 兜底,捕获非标准异常
	{
		cout << "未知异常" << endl;
	}

	return 0;
}

缺点分析:

这种做法不仅繁琐,而且容易出错。我们需要自己管理 std::string 的生命周期,确保 c_str() 返回的指针在 what() 被调用时依然有效。

2. 继承std::runtime_error(推荐)

std::runtime_error 内部已经完整实现了 what() 方法,并且拥有一个 std::string 成员变量来存储错误信息。只需在构造函数的初始化列表中调用父类的构造函数传入错误信息即可,无需手动重写 what()。此外,我们还可以在自定义异常中添加额外的成员变量,例如错误码,以携带更多的上下文信息。

// 1.定义基类业务异常,继承自 std::runtime_error
class BusinessException : public std::runtime_error 
{
private:
	int errCode_; // 扩展功能 自定义成员:错误码

public:
	// 构造函数:初始化父类(错误信息)和子类(错误码)
	BusinessException(const string& msg, int code)
		: runtime_error(msg)
		, errCode_(code) 
		{}

	// 获取错误码的接口
	int GetCode() const noexcept 
	{
		return errCode_;
	}
};

// 2.定义具体的派生异常
// 数据库相关异常
class DatabaseException : public BusinessException 
{
public:
	DatabaseException(const string& sqlState)
		: BusinessException("数据库错误 [" + sqlState + "]", 1001) 
		{}
};

// 网络相关异常
class NetworkException : public BusinessException 
{
public:
	NetworkException(const std::string& reason)
		: BusinessException("网络错误: " + reason, 2002) 
		{}
};

// 3.业务函数:模拟抛出异常
void queryDatabase() 
{
	throw DatabaseException("连接超时");
}

// 4.主函数:异常捕获与处理
int main() 
{
	try 
	{
		queryDatabase();
	}
	// 捕获顺序:先捕获子类,再捕获基类
	// 捕获具体的自定义业务异常	
	catch (const BusinessException& e) 
	{
		cout << "捕获业务异常" << endl;
		cout << "错误码: " << e.GetCode() << endl;
		cout << "信息: " << e.what() << endl;
	}
	// 捕获其他标准异常(非业务异常)
	catch (const exception& e) 
	{
		cout << "标准异常: " << e.what() << endl;
	}
	// 捕获所有非标准异常(兜底)
	catch (...) 
	{
		cout << "未知异常" << endl;
	}

	return 0;
}
Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐