一、为什么需要异常处理?
在讲异常处理之前,我们先思考一个问题:传统的错误处理方式有什么问题?
最常见的错误处理方式是返回错误码 :函数执行失败时返回一个特定的整数(比如 - 1、NULL),调用者检查这个返回值来判断是否出错。
cpp
// 传统错误码方式
int divide(int a, int b, int* result) {
if (b == 0) {
return -1; // 返回错误码表示失败
}
*result = a / b;
return 0; // 返回0表示成功
}
int main() {
int result;
int err = divide(10, 0, &result);
if (err != 0) {
std::cout << "Error: division by zero" << std::endl;
return 1;
}
std::cout << "Result: " << result << std::endl;
return 0;
}
这种方式看起来简单,但在复杂项目中会暴露出致命的缺陷:
- 错误处理和正常逻辑混杂:代码中充满了 if (err != 0) 的检查,可读性极差
- 错误容易被忽略:如果调用者忘记检查返回值,错误会被静默忽略,导致程序在后续某个地方崩溃,排查难度极大
- 无法跨多层调用传播:如果错误需要向上传递多层,每一层都要检查并转发错误码,代码会变得极其臃肿
- 构造函数无法返回错误码:构造函数没有返回值,无法通过错误码报告对象构造失败
- 没有类型安全:错误码只是整数,无法携带更多的错误信息,也无法进行类型检查
C++ 的异常处理机制就是为了解决这些问题而设计的。它提供了一种结构化、类型安全、自动传播的错误处理方式,将正常逻辑和错误处理代码分离,让代码更加清晰、健壮。
二、异常处理的核心:try-throw-catch
C++ 异常处理基于三个关键字:try、throw、catch,它们构成了异常处理的基本流程:
- throw:抛出一个异常对象,终止当前函数的执行
- try:定义一个监控范围,其中的代码可能会抛出异常
- catch:捕获并处理特定类型的异常
2.1 基本语法示例
我们用一个简单的除法函数来演示基本的异常处理流程:
cpp
#include <iostream>
#include <stdexcept> // 标准异常头文件
// 抛出异常的函数
double divide(double a, double b) {
if (b == 0) {
// 抛出一个标准异常对象
throw std::invalid_argument("Division by zero is not allowed");
}
return a / b;
}
int main() {
try {
// 可能抛出异常的代码放在try块中
double result1 = divide(10, 2);
std::cout << "10 / 2 = " << result1 << std::endl;
double result2 = divide(10, 0); // 这里会抛出异常
std::cout << "10 / 0 = " << result2 << std::endl; // 这行永远不会执行
}
catch (const std::invalid_argument& e) {
// 捕获并处理std::invalid_argument类型的异常
std::cout << "Caught exception: " << e.what() << std::endl;
}
std::cout << "Program continues after exception handling" << std::endl;
return 0;
}
运行结果:
10 / 2 = 5
Caught exception: Division by zero is not allowed
Program continues after exception handling
2.2 异常抛出(throw)
throw关键字用于抛出一个异常对象,它可以抛出任意类型的对象:
- 内置类型(int、double、const char * 等)
- 标准库异常类
- 自定义异常类
最佳实践 :永远抛出一个对象,而不是基本类型。最好继承自std::exception,这样可以统一处理所有标准异常。
cpp
// ❌ 不好的做法:抛出基本类型
throw -1;
throw "Error message";
// ✅ 好的做法:抛出标准异常对象
throw std::runtime_error("Something went wrong");
当throw语句执行时,会发生以下事情:
- 立即终止当前函数的执行
- 创建异常对象的副本(因为原对象会在函数退出时销毁)
- 开始栈展开过程(后面会详细讲)
2.3 异常捕获(catch)
catch块用于捕获并处理特定类型的异常,它必须紧跟在try块后面。一个try块可以对应多个catch块,分别处理不同类型的异常。
异常匹配规则
catch块按声明顺序 进行匹配,第一个匹配的catch块会被执行- 支持多态匹配:可以用基类引用捕获派生类异常
catch(...)可以捕获所有类型的异常,通常作为最后一个catch块
cpp
运行
#include <iostream>
#include <stdexcept>
void process_data(int value) {
if (value < 0) {
throw std::invalid_argument("Negative value not allowed");
}
if (value > 100) {
throw std::out_of_range("Value exceeds maximum limit");
}
std::cout << "Processing value: " << value << std::endl;
}
int main() {
try {
process_data(150);
}
catch (const std::invalid_argument& e) {
std::cout << "Invalid argument: " << e.what() << std::endl;
}
catch (const std::out_of_range& e) {
std::cout << "Out of range: " << e.what() << std::endl;
}
catch (const std::exception& e) {
// 捕获所有继承自std::exception的异常
std::cout << "Standard exception: " << e.what() << std::endl;
}
catch (...) {
// 捕获所有其他类型的异常
std::cout << "Unknown exception caught" << std::endl;
}
return 0;
}
重要注意事项 :catch块的顺序非常重要!一定要把最具体的异常放在前面,最通用的异常放在后面 。如果把catch(const std::exception& e)放在最前面,它会捕获所有派生类异常,后面的catch块永远不会被执行。
按引用捕获异常
永远按引用捕获异常,这是 C++ 异常处理的黄金法则。原因有三个:
- 避免对象切片:如果按值捕获,派生类异常会被切片成基类对象,丢失多态性
- 避免不必要的拷贝:异常对象可能很大,按引用捕获可以提高性能
- 可以重新抛出原始异常:按引用捕获可以保留原始异常对象的所有信息
cpp
运行
// ❌ 不好的做法:按值捕获
catch (std::exception e) { ... }
// ✅ 好的做法:按const引用捕获
catch (const std::exception& e) { ... }
2.4 自定义异常类
当标准异常无法满足需求时,我们可以自定义异常类。最佳实践是继承自 std::exception ,并重写what()方法。
cpp
#include <iostream>
#include <stdexcept>
class Resource {
public:
Resource(const std::string& name) : name(name) {
std::cout << "Resource " << name << " acquired" << std::endl;
}
~Resource() {
std::cout << "Resource " << name << " released" << std::endl;
}
private:
std::string name;
};
void func_c() {
Resource r3("r3");
std::cout << "Entering func_c" << std::endl;
throw std::runtime_error("Exception thrown in func_c");
std::cout << "Leaving func_c" << std::endl; // 不会执行
}
void func_b() {
Resource r2("r2");
std::cout << "Entering func_b" << std::endl;
func_c();
std::cout << "Leaving func_b" << std::endl; // 不会执行
}
void func_a() {
Resource r1("r1");
std::cout << "Entering func_a" << std::endl;
try {
func_b();
}
catch (const std::runtime_error& e) {
std::cout << "Caught exception: " << e.what() << std::endl;
}
std::cout << "Leaving func_a" << std::endl;
}
int main() {
std::cout << "Entering main" << std::endl;
func_a();
std::cout << "Leaving main" << std::endl;
return 0;
}
三、异常处理的核心机制:栈展开(Stack Unwinding)
栈展开是 C++ 异常处理最核心、最容易被误解的机制,也是面试必问的知识点。
3.1 什么是栈展开?
当一个异常被抛出后,程序会从异常抛出点开始,沿着函数调用链向上查找匹配的 catch 块 。在这个过程中,会自动调用所有已经构造完成的局部对象的析构函数 ,释放它们占用的资源。
这个过程就像把调用栈一层一层地 "展开",直到找到匹配的 catch 块为止 。如果直到 main 函数都没有找到匹配的 catch 块,程序会调用**std::terminate()**终止执行。
3.2 栈展开的执行过程
我们用一个例子来演示栈展开的完整过程:
cpp
#include <iostream>
#include <stdexcept>
class Resource {
public:
Resource(const std::string& name) : name(name) {
std::cout << "Resource " << name << " acquired" << std::endl;
}
~Resource() {
std::cout << "Resource " << name << " released" << std::endl;
}
private:
std::string name;
};
void func_c() {
Resource r3("r3");
std::cout << "Entering func_c" << std::endl;
throw std::runtime_error("Exception thrown in func_c");
std::cout << "Leaving func_c" << std::endl; // 不会执行
}
void func_b() {
Resource r2("r2");
std::cout << "Entering func_b" << std::endl;
func_c();
std::cout << "Leaving func_b" << std::endl; // 不会执行
}
void func_a() {
Resource r1("r1");
std::cout << "Entering func_a" << std::endl;
try {
func_b();
}
catch (const std::runtime_error& e) {
std::cout << "Caught exception: " << e.what() << std::endl;
}
std::cout << "Leaving func_a" << std::endl;
}
int main() {
std::cout << "Entering main" << std::endl;
func_a();
std::cout << "Leaving main" << std::endl;
return 0;
}
运行结果:
Entering main
Resource r1 acquired
Entering func_a
Resource r2 acquired
Entering func_b
Resource r3 acquired
Entering func_c
Resource r3 released
Resource r2 released
Caught exception: Exception thrown in func_c
Leaving func_a
Resource r1 released
Leaving main
从运行结果可以清楚地看到栈展开的过程:
- 异常在
func_c中抛出,func_c中的局部对象r3被析构 func_c没有匹配的 catch 块,继续向上到func_bfunc_b中的局部对象r2被析构,func_b也没有匹配的 catch 块- 继续向上到
func_a,找到匹配的 catch 块,栈展开停止 - 执行 catch 块中的代码,然后继续执行
func_a中 catch 块后面的代码 func_a执行完毕,局部对象r1被析构- 回到 main 函数,程序正常结束
3.3 栈展开的重要意义
栈展开最重要的意义在于保证资源自动释放 。即使发生异常,程序也能正确地清理所有局部对象,不会发生资源泄漏。
这也是 C++ 中 RAII(资源获取即初始化) 技术的基础。RAII 的核心思想是:将资源的生命周期与对象的生命周期绑定,对象构造时获取资源,对象析构时释放资源。这样,无论函数是正常返回还是因异常退出,资源都会被正确释放。
四、异常规格与 noexcept 说明符
异常规格是用来声明一个函数是否会抛出异常,以及会抛出哪些类型异常的语法。C++ 的异常规格经历了两个阶段:
4.1 旧的动态异常规格(C++98,已废弃)
C++98 引入了动态异常规格,用throw()来声明函数会抛出的异常类型:
cpp
// 可能抛出std::runtime_error或std::invalid_argument
void func1() throw(std::runtime_error, std::invalid_argument);
// 不会抛出任何异常
void func2() throw();
// 可能抛出任何类型的异常
void func3();
这种动态异常规格存在很多问题:
- 运行时检查,性能开销大
- 如果函数抛出了声明之外的异常,会调用
std::unexpected(),默认行为是终止程序 - 无法与模板很好地配合
- 实际工程中几乎没人使用
因此,C++11 正式废弃了动态异常规格,引入了新的noexcept说明符。
4.2 noexcept 说明符(C++11 及以后)
noexcept是 C++11 引入的新语法,用来声明一个函数不会抛出异常。它有两种形式:
-
无条件 noexcept :声明函数永远不会抛出异常
cppvoid func() noexcept; // 这个函数不会抛出任何异常 -
条件 noexcept :根据表达式的值决定函数是否会抛出异常
cpp// 如果T的移动构造函数是noexcept的,那么这个函数也是noexcept的 template <typename T> void swap(T& a, T& b) noexcept(noexcept(T(std::move(a))) && noexcept(a.operator=(std::move(b)))) { T temp = std::move(a); a = std::move(b); b = std::move(temp); }
4.3 noexcept 运算符
noexcept运算符用于检查一个表达式是否会抛出异常,它返回一个 bool 值:
cpp
#include <iostream>
#include <vector>
void func1() noexcept {}
void func2() {}
int main() {
std::cout << std::boolalpha;
std::cout << "func1 is noexcept: " << noexcept(func1()) << std::endl; // true
std::cout << "func2 is noexcept: " << noexcept(func2()) << std::endl; // false
std::cout << "int addition is noexcept: " << noexcept(1 + 2) << std::endl; // true
return 0;
}
4.4 noexcept 的重要作用
- 性能优化:编译器可以为 noexcept 函数生成更高效的代码,因为不需要生成异常处理的栈展开代码
- 移动语义优化 :标准库容器(如 std::vector)在重新分配内存时,会优先使用 noexcept 的移动构造函数。如果移动构造函数不是 noexcept 的,容器会使用拷贝构造函数,性能会大大降低
- 异常安全保证:noexcept 明确声明了函数不会抛出异常,让调用者可以放心地使用
4.5 什么时候应该使用 noexcept?
- 析构函数:析构函数默认是 noexcept 的,永远不要让析构函数抛出异常
- 移动构造函数和移动赋值运算符:这是 noexcept 最常见的使用场景,可以显著提高标准库容器的性能
- 简单的、不会抛出异常的函数:比如 getter、setter、简单的计算函数
- swap 函数:swap 函数通常应该是 noexcept 的
注意 :不要随便给函数加上 noexcept,除非你 100% 确定它不会抛出任何异常。如果一个声明为 noexcept 的函数实际上抛出了异常,会直接调用std::terminate()终止程序。
五、异常安全
异常安全是指当异常发生时,程序不会发生资源泄漏,也不会处于不一致的状态。这是 C++ 编程中非常重要的概念,也是面试官喜欢追问的难点。
5.1 异常安全的三个级别
C++ 标准定义了三个级别的异常安全保证,从低到高依次是:
- 基本保证(Basic Guarantee):如果发生异常,程序不会发生资源泄漏,所有对象仍然处于有效状态,但可能处于未知的状态
- 强保证(Strong Guarantee):如果发生异常,程序状态会回滚到操作执行前的状态,就像操作从未发生过一样
- 不抛出保证(No-throw Guarantee):操作永远不会抛出异常,总是会成功完成
5.2 如何实现异常安全
实现异常安全的核心是使用 RAII 技术。RAII 可以保证即使发生异常,资源也会被正确释放。
我们用一个反例来看看不使用 RAII 会发生什么:
cpp
// ❌ 异常不安全的代码
void func() {
int* p = new int(42);
// 这里如果抛出异常,p指向的内存会泄漏
some_operation_that_might_throw();
delete p; // 如果前面抛出异常,这行永远不会执行
}
使用 RAII(智能指针)修正后:
cpp
// ✅ 异常安全的代码
void func() {
std::unique_ptr<int> p = std::make_unique<int>(42);
some_operation_that_might_throw();
// 无论函数是正常返回还是因异常退出,unique_ptr的析构函数都会自动释放内存
}
5.3 实现强保证的常用方法:copy-and-swap
copy-and-swap 是实现强异常安全保证的经典方法,它的步骤是:
- 创建一个临时对象,对临时对象进行修改
- 如果修改过程中发生异常,临时对象会被销毁,原对象保持不变
- 如果修改成功,将临时对象与原对象交换
cpp
#include <iostream>
#include <vector>
#include <algorithm>
class MyClass {
private:
std::vector<int> data;
public:
// 拷贝构造函数
MyClass(const MyClass& other) : data(other.data) {}
// swap函数
void swap(MyClass& other) noexcept {
std::swap(data, other.data);
}
// 赋值运算符,使用copy-and-swap实现强异常安全
MyClass& operator=(MyClass other) { // 按值传递参数,自动创建拷贝
swap(other);
return *this;
}
// 添加元素的函数,实现强异常安全
void add_element(int value) {
// 先创建一个临时vector
std::vector<int> temp = data;
temp.push_back(value); // 如果push_back抛出异常,temp会被销毁,原data不变
// 交换临时vector和原data
data.swap(temp);
}
};
六、C++ 标准异常库
C++ 标准库提供了一套完整的异常类,它们都继承自std::exception,定义在<stdexcept>头文件中。
6.1 标准异常的继承层次
cpp
std::exception
├── std::logic_error // 逻辑错误,理论上可以在程序运行前避免
│ ├── std::invalid_argument // 无效参数
│ ├── std::domain_error // 域错误
│ ├── std::length_error // 长度错误
│ └── std::out_of_range // 超出范围
└── std::runtime_error // 运行时错误,无法在程序运行前预测
├── std::range_error // 范围错误
├── std::overflow_error // 溢出错误
├── std::underflow_error // 下溢错误
└── std::system_error // 系统错误(C++11)
6.2 常用标准异常说明
| 异常类 | 用途 |
|---|---|
std::invalid_argument |
函数接收到无效的参数 |
std::out_of_range |
访问超出范围的元素,比如 vector 的 at () 方法 |
std::length_error |
尝试创建超过最大长度的对象,比如 string 的 reserve () |
std::runtime_error |
通用运行时错误,其他运行时异常的基类 |
std::system_error |
系统调用失败,比如文件打开失败、网络连接失败 |
最佳实践:尽量使用标准异常,只有当标准异常无法满足需求时才自定义异常类。
七、异常处理的最佳实践与常见陷阱
7.1 最佳实践
- 永远按引用捕获异常:避免对象切片和不必要的拷贝
- 抛出标准异常或继承自标准异常的自定义异常:保持一致性,方便统一处理
- 使用 RAII 管理资源:保证异常发生时资源自动释放
- 给移动构造函数、移动赋值运算符和析构函数加上 noexcept:提高性能,保证异常安全
- 异常粒度要适中:不要为每一行代码都写 try-catch,也不要把整个程序都包在一个 try 块里
- 在 catch 块中只处理你能处理的异常:不能处理的异常应该重新抛出
- 不要在析构函数中抛出异常:会导致程序直接终止
7.2 常见陷阱
-
忽略异常:空的 catch 块是最糟糕的做法,会隐藏错误
cpp// ❌ 绝对不要这么做 try { risky_operation(); } catch (...) { // 什么都不做,错误被静默忽略 } -
按值捕获异常:导致对象切片,丢失多态性
-
catch 块顺序错误:把通用异常放在具体异常前面
-
在构造函数和析构函数中做太多可能抛出异常的操作
-
过度使用异常:异常应该只用于处理异常情况,不要用它来控制正常的程序流程
八、面试高频问题汇总
Q1: 异常处理相比返回错误码有什么优势?
答:
- 分离错误处理和正常逻辑:代码更加清晰易读
- 自动传播:异常会自动向上传播,不需要每层都检查错误码
- 类型安全:异常是类型安全的,编译器可以检查类型匹配
- 无法忽略:未处理的异常会导致程序终止,错误不会被静默忽略
- 构造函数支持:构造函数无法返回错误码,只能使用异常报告失败
Q2: 什么是栈展开?在异常处理中起什么作用?
答 :栈展开是从异常抛出点开始,沿着函数调用链向上查找匹配 catch 块的过程。在这个过程中:
- 自动调用所有已经构造完成的局部对象的析构函数
- 释放自动存储期的资源
- 维护调用栈的完整性
栈展开保证了即使发生异常,程序也能正确地清理资源,不会发生资源泄漏,是 RAII 技术的基础。
Q3: 为什么应该按引用捕获异常?
答:按引用捕获异常有三个主要优势:
- 避免对象切片:捕获派生类异常时保持多态性
- 避免拷贝:不需要复制异常对象,提高性能
- 保持原始异常:能够重新抛出原始异常对象
Q4: 在析构函数中抛出异常会有什么问题?
答:在析构函数中抛出异常是极其危险的,因为:
- 双重异常 :如果栈展开过程中析构函数抛出异常,会立即调用
std::terminate()终止程序 - 资源泄漏:可能中断其他资源的清理过程
- 未定义行为:C++ 标准规定这种情况会导致程序终止
最佳实践是:析构函数应该用noexcept声明,并吞掉所有可能的异常。
Q5: noexcept 的作用是什么?什么时候应该使用 noexcept?
答:noexcept 的作用是声明一个函数不会抛出异常。它的主要作用有:
- 性能优化:编译器可以生成更高效的代码
- 移动语义优化:标准库容器会优先使用 noexcept 的移动构造函数
- 异常安全保证:明确声明函数不会抛出异常
应该使用 noexcept 的场景:
- 析构函数(默认就是 noexcept)
- 移动构造函数和移动赋值运算符
- swap 函数
- 简单的、不会抛出异常的函数
九、总结
C++ 异常处理是一个非常强大但也容易被误用的特性。它的核心是 try-throw-catch 机制和栈展开,通过 RAII 技术实现异常安全。
在面试中,面试官不仅会问基础的语法,更会深入考察栈展开、noexcept、异常安全等底层原理和工程实践。