C++异常详解:从 throw、try/catch 到栈展开、RAII 与 noexcept

前言:程序出错以后,我们到底应该怎么办?
1. 错误处理是程序设计绕不开的问题
程序在运行过程中可能遇到各种问题:
除数为 0
文件打开失败
内存申请失败
数组访问越界
数据库连接失败
网络请求失败
用户参数非法
最简单的处理方式可能是:
if (error)
{
return -1;
}
然后调用者继续判断:
int ret = Func();
if (ret == -1)
{
// 处理错误
}
这种方式就是:
错误码
Error Code
C 语言中大量接口都采用这种设计。
但是随着程序调用层次越来越深:
main
↓
HttpServer
↓
CacheManager
↓
SqlManager
↓
Database
如果最底层发生错误,每一层都要:
检查返回值
↓
判断错误
↓
继续返回错误码
大量正常业务代码会被错误处理代码打断。
C++ 因此提供了另一套错误传播机制:
异常
Exception
一、异常到底解决了什么问题?
1. 将“发现问题”和“处理问题”分开
假设:
数据库模块
发现:
SQL 执行失败
数据库模块通常最清楚:
发生了什么错误
却不一定知道:
应该怎么处理错误
例如是否:
重新请求
记录日志
通知用户
断开连接
终止服务
可能由更高层决定。
所以异常机制可以形成:
底层
↓
负责发现问题
↓
throw
中间层
↓
可以不处理
上层
↓
catch
↓
统一处理
这就是异常机制非常重要的设计思想:
错误检测与错误处理解耦。
二、C++异常的三个核心关键字
1. throw:抛出异常
例如除法:
double Divide(int a, int b)
{
if (b == 0)
{
throw "division by zero";
}
return static_cast<double>(a) / b;
}
当:
b == 0
时执行:
throw "division by zero";
意味着:
产生异常
↓
当前正常执行流程停止
↓
开始寻找能够处理该异常的 catch
2. try:监控可能发生异常的代码
try
{
cout << Divide(10, 0)
<< endl;
}
try 自己并不处理异常。
它只是告诉程序:
这一块代码可能抛出异常
3. catch:捕获异常
try
{
cout << Divide(10, 0)
<< endl;
}
catch (const char* msg)
{
cout << msg << endl;
}
完整结构:
try
{
// 可能抛异常
}
catch (Type1 e)
{
// Type1 异常
}
catch (Type2 e)
{
// Type2 异常
}
可以把它理解成:
try
↓
执行代码
↓
发生异常?
├── 否 → 正常继续
│
└── 是
↓
查找匹配 catch
↓
执行处理
三、throw 执行以后发生了什么?
1. throw 后面的正常代码不会继续执行
例如:
void Func()
{
cout << "before" << endl;
throw 10;
cout << "after" << endl;
}
执行:
Func();
如果异常被外层捕获:
before
会输出。
但是:
after
不会执行。
因为:
throw 10;
已经改变了正常控制流。
2. throw 的不是“错误编号”,而是一个对象
C++ 可以:
throw 10;
也可以:
throw string("error");
甚至:
throw MyException(...);
所以异常对象能够携带:
错误类型
错误编号
错误描述
模块信息
SQL语句
HTTP请求类型
文件路径
等更多信息。
这比:
return -1
return -2
return -3
通常具有更强的表达能力。
四、异常对象的生命周期
1. 为什么不能简单理解成“throw 局部变量的引用”?
例如:
void Func()
{
string msg = "error";
throw msg;
}
函数退出以后:
msg
这个局部对象会销毁。
但异常还必须继续向上传递。
所以运行时会建立一个:
异常对象
Exception Object
用于异常传播。
可以从语义上理解为:
局部对象 msg
↓
构造异常对象
↓
原来的局部变量可以销毁
↓
异常对象继续沿调用链传播
因此正常写法是:
按值抛出
例如:
throw MyException(...);
五、catch 到底怎么匹配异常?
1. 通常按照异常类型进行匹配
例如:
try
{
throw 10;
}
catch (int e)
{
cout << "int exception"
<< endl;
}
catch (double e)
{
cout << "double exception"
<< endl;
}
因为:
throw 10;
抛出的类型是:
int
所以匹配:
catch (int e)
2. 多个 catch 时按顺序检查
例如:
try
{
...
}
catch (const SqlException& e)
{
}
catch (const Exception& e)
{
}
如果:
SqlException
既能够匹配:
SqlException&
又因为继承关系能够匹配:
Exception&
那么前面的:
catch (const SqlException& e)
会先被选择。
所以:
派生类异常通常放前面,基类异常放后面。
六、为什么异常体系特别适合使用继承?
1. 假设大型项目有多个模块
例如:
HTTP 模块
Cache 模块
SQL 模块
它们都可能产生异常。
可以建立:
Exception
/ | \
/ | \
↓ ↓ ↓
HttpException CacheException SqlException
于是不同模块:
能够携带不同的信息
而最上层又可以:
统一捕获 Exception
七、自定义异常类的基本设计
更贴近标准库的方式是继承:
std::exception
例如:
#include <exception>
#include <string>
class AppException
: public std::exception
{
public:
AppException(
std::string message,
int id)
: _message(
std::move(message))
, _id(id)
{}
const char* what()
const noexcept override
{
return _message.c_str();
}
int id() const noexcept
{
return _id;
}
private:
std::string _message;
int _id;
};
其中:
what()
负责返回:
异常描述信息
八、派生不同模块的异常
1. SQL异常
class SqlException
: public AppException
{
public:
SqlException(
const std::string& message,
int id,
const std::string& sql)
: AppException(
message,
id)
, _sql(sql)
, _detail(
"SqlException: "
+ message
+ " -> "
+ sql)
{}
const char* what()
const noexcept override
{
return _detail.c_str();
}
private:
std::string _sql;
std::string _detail;
};
2. HTTP异常
class HttpException
: public AppException
{
public:
HttpException(
const std::string& message,
int id,
const std::string& method)
: AppException(
message,
id)
, _method(method)
, _detail(
"HttpException["
+ method
+ "]: "
+ message)
{}
const char* what()
const noexcept override
{
return _detail.c_str();
}
private:
std::string _method;
std::string _detail;
};
九、为什么推荐 catch(const Exception& e)?
这是异常处理中一个非常重要的习惯。
推荐:
catch (const AppException& e)
{
cout << e.what() << endl;
}
而不是:
catch (AppException e)
{
}
原因主要有两个。
1. 避免额外复制
引用:
不需要再次复制异常对象
2. 避免对象切片
假设抛出:
throw SqlException(...);
如果:
catch (AppException e)
按值接收基类对象:
SqlException
↓
复制成 AppException
↓
派生类部分丢失
这就是:
对象切片
Object Slicing
而:
catch (const AppException& e)
保留实际动态类型。
于是:
e.what()
仍然可以通过虚函数调用:
SqlException::what()
十、完整的异常继承体系案例
#include <iostream>
#include <exception>
#include <string>
using namespace std;
class AppException
: public exception
{
public:
AppException(
string message,
int id)
: _message(
std::move(message))
, _id(id)
{}
const char* what()
const noexcept override
{
return _message.c_str();
}
int id() const noexcept
{
return _id;
}
protected:
string _message;
int _id;
};
class SqlException
: public AppException
{
public:
SqlException(
const string& message,
int id,
const string& sql)
: AppException(message, id)
, _detail(
"SQL: "
+ message
+ " -> "
+ sql)
{}
const char* what()
const noexcept override
{
return _detail.c_str();
}
private:
string _detail;
};
class HttpException
: public AppException
{
public:
HttpException(
const string& message,
int id,
const string& method)
: AppException(message, id)
, _detail(
"HTTP["
+ method
+ "]: "
+ message)
{}
const char* what()
const noexcept override
{
return _detail.c_str();
}
private:
string _detail;
};
业务代码:
void SQLManager()
{
throw SqlException(
"permission denied",
1001,
"select * from user"
);
}
void CacheManager()
{
SQLManager();
}
void HttpServer()
{
CacheManager();
}
最外层:
int main()
{
try
{
HttpServer();
}
catch (
const AppException& e)
{
cout
<< "id: "
<< e.id()
<< endl;
cout
<< e.what()
<< endl;
}
return 0;
}
虽然实际抛出:
SqlException
但是:
catch (
const AppException& e)
可以统一捕获整个异常继承体系。
十一、什么是栈展开?
这是 C++ 异常机制最核心的原理之一。
假设调用关系:
main()
↓
Func1()
↓
Func2()
↓
Func3()
然后:
Func3()
{
throw Exception();
}
异常开始沿调用链向上寻找处理器:
Func3
↓
没有匹配 catch
↓
退出 Func3
Func2
↓
没有匹配 catch
↓
退出 Func2
Func1
↓
找到匹配 catch
↓
处理异常
这个过程称为:
栈展开
Stack Unwinding
十二、栈展开不仅是在“找 catch”
更加重要的是:
离开函数作用域时,已经构造完成的自动对象会执行析构函数。
例如:
class Test
{
public:
Test(const char* name)
: _name(name)
{
cout
<< "construct "
<< _name
<< endl;
}
~Test()
{
cout
<< "destroy "
<< _name
<< endl;
}
private:
string _name;
};
void Func()
{
Test t1("t1");
Test t2("t2");
throw runtime_error(
"error");
}
即使:
Func 没有正常执行到函数末尾
栈展开时:
t2 析构
↓
t1 析构
仍然会发生。
这正是:
RAII
能够与异常完美配合的基础。
十三、如果一直找不到 catch 呢?
假设:
int main()
{
throw runtime_error(
"error");
}
没有任何地方捕获。
最终异常无法被处理:
没有匹配 handler
↓
std::terminate()
↓
程序终止
因此对于应用程序顶层,有时会使用:
try
{
RunApplication();
}
catch (
const std::exception& e)
{
// 日志
}
catch (...)
{
// 最后的未知异常边界
}
但是:
catch(...)应作为必要时的最后保护层,而不是把所有函数都套上一层catch(...)。
十四、catch(…) 是什么?
catch (...)
{
}
表示:
可以匹配任意异常类型
例如:
try
{
throw 10;
}
catch (...)
{
cout
<< "unknown exception"
<< endl;
}
它的缺点也很明显:
不知道异常的具体类型
不知道异常对象本身的信息
所以通常把它放在:
所有具体 catch 后面
例如:
catch (
const AppException& e)
{
}
catch (
const std::exception& e)
{
}
catch (...)
{
}
十五、异常重新抛出
有时候某一层:
可以处理异常的一部分
但不能完全解决问题。
这时可以:
throw;
重新抛出当前异常。
1. 典型场景:重试
例如网络请求失败:
void SendMessage()
{
try
{
Send();
}
catch (
const HttpException& e)
{
if (e.id() == 1001)
{
// 可以重试
}
else
{
throw;
}
}
}
这里:
throw;
表示:
把当前正在处理的异常继续向外传播
十六、throw; 和 throw e; 有什么区别?
这是非常值得补充的一点。
假设:
catch (
const AppException& e)
{
throw;
}
throw; 会:
重新抛出原来的异常对象
保留:
真实异常类型
1. 不推荐这样写
catch (
const AppException& e)
{
throw e;
}
这里重新执行:
throw 一个 AppException 表达式
可能导致:
派生异常信息丢失
对象切片
因此重新抛出当前异常时:
throw;
才是最自然的写法。
十七、异常最大的风险之一:资源泄漏
看下面代码:
void Func()
{
int* ptr =
new int[100];
DoSomething();
delete[] ptr;
}
如果:
DoSomething();
抛出异常:
new[]
↓
DoSomething
↓
throw
↓
直接离开 Func
于是:
delete[] ptr;
根本没有执行。
最终:
内存泄漏
十八、传统解决方案:catch 后清理再重新抛出
可以写:
void Func()
{
int* ptr =
new int[100];
try
{
DoSomething();
}
catch (...)
{
delete[] ptr;
throw;
}
delete[] ptr;
}
确实能够解决。
但问题是:
代码复杂
容易漏掉
资源越多越难维护
因此现代 C++ 有更好的方案:
RAII
十九、RAII 为什么能解决异常安全?
RAII:
Resource Acquisition
Is Initialization
核心思想:
资源生命周期
绑定到对象生命周期
例如不要:
int* ptr =
new int[100];
而使用:
vector<int> data(100);
或者:
auto ptr =
make_unique<int[]>(100);
即使:
DoSomething();
抛异常:
异常产生
↓
栈展开
↓
vector / unique_ptr 析构
↓
自动释放资源
我们完全不需要手动:
try
catch
delete
throw
这就是现代 C++ 中异常安全的核心原则之一:
资源管理优先交给 RAII 对象,而不是手工 new/delete + try/catch。
二十、RAII 不只管理内存
除了:
动态内存
RAII 还可以管理:
文件
锁
socket
数据库连接
线程资源
系统句柄
例如:
lock_guard<mutex> lock(mtx);
如果后面抛异常:
栈展开
↓
lock 析构
↓
自动解锁
否则手工:
mtx.lock();
Func(); // throw
mtx.unlock();
就可能导致:
锁永远没有释放
二十一、异常安全到底是什么意思?
异常安全并不是:
程序不能抛异常
而是:
即使操作过程中发生异常,程序仍然保持我们承诺的状态。
通常可以分为几个等级。
1. 无异常保证
No-throw Guarantee
操作保证不会抛出异常。
例如很多:
析构函数
swap
移动操作
会尽量提供这种保证。
2. 强异常安全保证
Strong Guarantee
如果操作失败:
对象状态恢复到操作之前
可以理解成:
成功
↓
提交新状态
失败
↓
什么都没发生
类似事务:
commit / rollback
3. 基本异常安全保证
Basic Guarantee
异常发生以后:
不会资源泄漏
对象仍然有效
类不变量仍然成立
但对象的具体值:
可能发生改变
4. 无异常安全保证
如果异常发生:
资源可能泄漏
对象状态可能损坏
程序状态不可预测
显然是我们应该避免的。
二十二、Copy-and-Swap 为什么异常安全?
假设自己实现:
class String
{
public:
String&
operator=(
const String& other)
{
String tmp(other);
swap(tmp);
return *this;
}
};
这里先:
构造 tmp
如果深拷贝失败:
异常发生
↓
原来的 this 完全没有被修改
只有复制成功以后:
swap
才提交新状态。
因此这种设计天然具有很好的:
强异常安全
思想。
二十三、为什么析构函数不要让异常逃出去?
这是异常机制中另一个重要规则。
假设:
异常 A 正在栈展开
此时某个对象开始析构。
如果析构函数又抛出:
异常 B
系统同时面对:
两个无法继续正常传播的异常
可能直接:
std::terminate()
因此通常:
class Resource
{
public:
~Resource() noexcept
{
try
{
Close();
}
catch (...)
{
// 内部记录/处理
// 不让异常继续逃逸
}
}
};
核心原则:
析构函数应尽量不抛出异常。
二十四、什么是 noexcept?
C++11 引入:
noexcept
例如:
void Func() noexcept
{
}
表示程序员声明:
Func 不允许异常逃出
注意:
noexcept
并不是:
编译器保证函数内部绝对没有 throw
例如:
void Func() noexcept
{
throw runtime_error(
"error");
}
有些编译器仍然可以编译。
但是异常真的逃出:
noexcept 函数
时会调用:
std::terminate()
二十五、noexcept 不等于 try/catch
void Func() noexcept
并不是:
自动帮你捕获异常
而是做出一个契约:
异常不能从这个函数传播出去
如果内部调用可能抛异常的函数:
void Func() noexcept
{
RiskyFunction();
}
也完全可以编译。
如果:
RiskyFunction 真抛异常
并且没有在 Func() 内处理:
terminate
二十六、noexcept(expression)
noexcept 还可以作为一个运算符:
noexcept(expression)
例如:
cout
<< noexcept(1 + 2)
<< endl;
如果表达式在语言层面被认为不会抛出:
true
否则:
false
注意:
它不会真正执行这个表达式。
例如:
int i = 0;
bool ret =
noexcept(++i);
之后:
i
仍然是:
0
二十七、为什么移动构造经常写 noexcept?
例如:
String(
String&& other
) noexcept;
这是一个非常重要的现代 C++ 习惯。
假设:
vector<T>
扩容:
旧空间
↓
把已有元素搬到新空间
如果移动构造可能抛异常:
移动到一半失败
而已经被移动的旧对象状态又发生变化:
vector 很难维持强异常安全保证
因此标准容器在一些场景中会更愿意:
使用不会抛异常的移动构造
所以资源管理类的移动操作如果确实不会抛异常,通常应该:
T(T&&) noexcept;
二十八、C++98 的动态异常规范
以前可能看到:
void Func()
throw(int, double);
表示:
声称可能抛出 int 和 double
还有:
void Func()
throw();
表示:
不抛异常
这种动态异常规范后来逐渐被废弃。
现代 C++ 更主要使用:
noexcept
表示非抛出保证。
二十九、标准库异常体系
C++ 标准库定义了一套异常类型。
共同基类:
std::exception
基本结构可以简化为:
std::exception
|
┌──────────────┴──────────────┐
↓ ↓
std::logic_error std::runtime_error
| |
┌──────┼──────┐ ┌─────┼─────┐
↓ ↓ ↓ ↓ ↓ ↓
invalid length out_of_range range overflow underflow
argument error error error error
除此之外还有:
std::bad_alloc
std::bad_cast
std::bad_typeid
std::bad_function_call
std::system_error
...
三十、logic_error 与 runtime_error 怎么理解?
1. logic_error
更接近:
程序逻辑、参数、前置条件存在问题
例如:
invalid_argument
length_error
out_of_range
2. runtime_error
更偏向:
运行过程中才可能发现的问题
例如:
range_error
overflow_error
underflow_error
实际项目中也经常直接:
throw runtime_error(
"database connection failed");
三十一、std::out_of_range 示例
例如:
vector<int> v
{
1, 2, 3
};
cout << v.at(10);
at() 会进行范围检查。
可以:
try
{
cout << v.at(10);
}
catch (
const out_of_range& e)
{
cout
<< e.what()
<< endl;
}
而:
v[10];
并不会提供同样的越界异常检查保证。
三十二、std::bad_alloc
例如:
try
{
int* ptr =
new int[
static_cast<size_t>(
-1)
];
}
catch (
const bad_alloc& e)
{
cout
<< e.what()
<< endl;
}
普通:
new
分配失败时通常会抛出:
std::bad_alloc
三十三、为什么顶层经常捕获 std::exception?
因为大量标准异常:
最终都继承 std::exception
因此:
try
{
Run();
}
catch (
const std::exception& e)
{
cerr
<< e.what()
<< endl;
}
可以覆盖很多标准异常。
对于大型应用,可以进一步采用:
try
{
Run();
}
catch (
const AppException& e)
{
// 项目自己的异常
}
catch (
const std::exception& e)
{
// 标准异常
}
catch (...)
{
// 未知异常
}
三十四、为什么不推荐 throw int / string / const char*?
语法层面当然可以:
throw 10;
throw "error";
throw string("error");
教学阶段用来理解:
异常按照类型匹配
非常方便。
但是工程中最好定义:
有语义的异常类型
例如:
throw invalid_argument(
"age cannot be negative");
或者:
throw SqlException(
...);
这样:
异常类型本身
就在表达:
发生了什么错误
三十五、异常不应该用来代替正常控制流
不要写:
try
{
throw i;
}
catch (int index)
{
return index;
}
来表达:
正常查找成功
异常适合:
异常情况 / 错误情况
而普通逻辑应该继续:
if
return
循环
状态值
否则会:
降低可读性
让错误处理和正常逻辑混乱
产生不必要的异常开销
三十六、错误码和异常谁更好?
没有绝对答案。
1. 错误码适合
例如:
失败是高频、正常且可预期的结果
例如:
查找不存在
尝试解析失败
非异常状态分支
2. 异常适合
例如函数:
已经无法完成自己的职责
需要跨越多层调用链,把错误交给真正有能力处理的位置。
尤其是:
构造函数失败
无法简单:
return -1;
异常往往更加自然。
三十七、一个推荐的异常使用策略
可以总结成:
底层发现错误
↓
抛出具有语义的异常对象
↓
中间层如果无法处理
↓
不要乱 catch
↓
让异常继续传播
↓
能恢复的边界处理
↓
最顶层记录未处理异常
而不是:
每一个函数
↓
try
↓
catch(...)
↓
什么都不做
后者反而会让真正的错误消失。
三十八、异常处理的几个工程习惯
1. 按值抛出
throw MyException(...);
2. 按 const 引用捕获
catch (
const MyException& e)
3. 重新抛出使用 throw;
catch (...)
{
// 局部处理
throw;
}
4. 使用 RAII 管理资源
优先:
vector<int>
unique_ptr<T>
lock_guard<mutex>
fstream
而不是:
手工申请
+
try/catch
+
手工释放
5. 不要随意让析构函数抛异常
尤其不要:
在栈展开过程中再让异常逃出析构函数
6. 对真正不会抛出的函数使用 noexcept
例如:
MyClass(
MyClass&&
) noexcept;
但不要:
不分析代码就给所有函数加 noexcept
因为一旦违反:
直接 terminate
三十九、异常执行流程总图
整个异常机制可以压缩成:
正常执行
|
v
发生错误
|
v
throw Exception
|
v
构造异常对象
|
v
检查当前 try
|
├── 找到匹配 catch
| |
| v
| 执行处理
|
└── 没找到
|
v
栈展开
|
析构局部对象
|
v
回到上一层函数
|
v
继续查找 catch
|
┌───┴────┐
↓ ↓
找到 没找到
| |
↓ ↓
处理 std::terminate

260

被折叠的 条评论
为什么被折叠?



