云计算百科
云计算领域专业知识百科平台

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

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

头像

🔥 星恒随风:
个人主页

❄️ 个人专栏:
《指针合集》

《C语言基础》

《数据结构》

《机器学习导论》

《前端基础》

《python基础》

《C++从入门到入土》

《Linux的学习之旅》

✨ 数据即知识,压缩即智能

前言:程序出错以后,我们到底应该怎么办?

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


赞(0)
未经允许不得转载:网硕互联帮助中心 » C++异常详解:从 throw、try/catch 到栈展开、RAII 与 noexcept
分享到: 更多 (0)

评论 抢沙发

评论前必须登录!