在程序开发的世界里,“错误” 是绕不开的话题。你可能写过一个简单的计算器,却因为用户输入 “5÷0” 而崩溃;也可能在操作数据库时,因为权限不足导致数据读取失败;甚至在申请内存时,因为系统资源耗尽而无法继续执行。这些运行时出现的意外情况,若不妥善处理,轻则导致程序异常退出,重则引发数据丢失、资源泄漏等严重问题。
C 语言中,我们通常用 “错误码” 来应对这类问题 —— 比如用-1
表示函数执行失败,用全局变量errno
存储错误类型。但这种方式的缺陷显而易见:错误码只能传递简单的整数信息,想要知道具体错误原因(比如 “为什么打开文件失败?是文件不存在还是权限不够?”),还得手动查询错误码对照表;更麻烦的是,错误码需要函数调用者主动检查,一旦遗漏,错误就会 “沉默地传播”,直到程序崩溃时才暴露,排查起来举步维艰。
为了解决这些痛点,C++ 引入了 “异常处理机制”。它将 “错误检测” 和 “错误处理” 彻底分离:程序的某个模块只需专注于 “发现错误时抛出异常”,而另一个模块则负责 “捕获异常并解决问题”,两者无需知道对方的具体实现细节。这种解耦的设计,让代码更具可读性、可维护性,也让错误处理变得更灵活、更全面。
接下来,我们将从基础概念出发,一步步拆解 C++ 异常的核心机制 —— 从异常的抛出与捕获,到栈展开的过程,再到异常安全、异常规范等实战要点,最后结合标准库异常体系和自定义异常案例,帮你彻底掌握 C++ 异常的使用。
一、C++ 异常的基础:什么是异常?
在 C++ 中,“异常” 是程序运行时发生的意外情况(比如除零、内存分配失败、数组越界等)的抽象表示 —— 它以 “对象” 的形式存在,能够携带丰富的错误信息(如错误描述、错误编号、发生位置等),并通过特定的语法(throw
、try
、catch
)在函数调用链中传递,最终被对应的 “异常处理器” 捕获并处理。
异常处理的核心思想可以概括为三句话:
- 检测错误:在可能出错的地方,用
throw
抛出一个异常对象,告知 “这里出问题了”; - 传递错误:异常会沿着函数调用链 “向上传播”(即 “栈展开”),直到找到能处理它的代码;
- 处理错误:用
catch
捕获异常,执行针对性的处理逻辑(比如打印错误信息、重试操作、释放资源等)。
1.1 为什么需要异常?对比 C 语言的错误码
为了更直观地理解异常的优势,我们先看一个 C 语言用错误码处理 “除零错误” 的例子:
#include <stdio.h>
#include <errno.h> // 用于存储错误码
// 用返回值表示结果,错误码存到errno中
double Divide(int a, int b) {
if (b == 0) {
errno = 1; // 用1表示除零错误(需要手动定义错误码含义)
return -1.0; // 返回一个无效值
}
return (double)a / (double)b;
}
int main() {
int a = 5, b = 0;
double result = Divide(a, b);
// 必须主动检查错误码,否则不知道出错
if (errno != 0) {
// 不知道具体错误原因,只能靠注释或查表
printf("错误:除零错误\n");
return 1;
}
printf("结果:%lf\n", result);
return 0;
}
这个例子的问题很明显:
- 错误信息有限:
errno
只能存整数,想要知道 “具体是什么错”,必须手动维护错误码对照表(比如1=除零,2=内存不足
); - 依赖主动检查:如果调用者忘记检查
errno
,错误会被忽略,程序可能继续执行错误逻辑; - 返回值歧义:返回
-1.0
可能是 “错误”,也可能是 “合法结果”(比如Divide(-1,1)
),需要额外判断。
而用 C++ 异常处理同样的问题,代码会清晰很多:
#include <iostream>
#include <string>
using namespace std;
double Divide(int a, int b) {
if (b == 0) {
// 抛出一个string对象,直接携带错误描述
throw string("除零错误:除数b不能为0");
}
return (double)a / (double)b;
}
int main() {
int a = 5, b = 0;
try {
// 尝试执行可能出错的代码
double result = Divide(a, b);
cout << "结果:" << result << endl;
} catch (const string& errMsg) {
// 捕获并处理异常,直接获取错误描述
cout << "捕获到异常:" << errMsg << endl;
}
return 0;
}
运行结果:
对比可见,异常的优势:
- 错误信息丰富:可以抛出任意类型的对象(如
string
、自定义类),携带错误描述、编号、位置等信息; - 强制处理(或传播):如果不捕获异常,它会自动向上传播,直到程序终止(不会 “沉默”);
- 不占用返回值:返回值可以专注于 “正常结果”,无需用特殊值表示错误。
二、异常的核心操作:抛出与捕获
异常的使用依赖三个关键字:throw
(抛出异常)、try
(包裹可能抛出异常的代码)、catch
(捕获并处理异常)。这三者的配合,是异常处理的基础。
2.1 抛出异常:throw
关键字
throw
的作用是 “触发异常”—— 当程序检测到错误时,用throw
抛出一个 “异常对象”,告知系统 “这里发生了意外”。
语法与规则
- 语法:
throw 表达式;
(表达式的结果就是异常对象,类型可以是内置类型、标准库类型或自定义类型); - 抛出后,
throw
后面的代码不会执行:程序会立即终止当前函数的执行,转而寻找能处理该异常的catch
; - 异常对象会被拷贝:如果抛出的是局部对象(比如函数内定义的
string
),系统会创建一个该对象的 “拷贝”,并将拷贝传递给catch
(因为原局部对象会随着函数栈帧销毁而消失)—— 这个过程类似 “函数传值返回”。
常见的抛出场景
- 抛出内置类型:比如
throw 1;
(用整数表示错误编号),但信息有限,不推荐; - 抛出标准库类型:比如
throw string("错误描述");
,简单直接,适合快速开发; - 抛出自定义类型:比如
throw SqlException("权限不足", 100, "select * from user");
,能携带多维度信息,适合复杂项目。
示例:抛出自定义异常对象
#include <iostream>
#include <string>
using namespace std;
// 自定义异常类,携带错误描述和编号
class MyException {
public:
MyException(string msg, int id) : _msg(msg), _id(id) {}
// 提供方法获取错误信息
string getMsg() const { return _msg; }
int getId() const { return _id; }
private:
string _msg; // 错误描述
int _id; // 错误编号
};
// 模拟数据库操作,可能抛出异常
void queryDatabase(const string& sql) {
// 模拟“权限不足”错误
if (sql.find("select * from user") != string::npos) {
throw MyException("权限不足:无法查询用户表", 1001);
}
cout << "SQL执行成功:" << sql << endl;
}
int main() {
try {
queryDatabase("select * from user"); // 会抛出异常
} catch (const MyException& e) {
// 捕获自定义异常,获取详细信息
cout << "错误编号:" << e.getId() << endl;
cout << "错误描述:" << e.getMsg() << endl;
}
return 0;
}
运行结果:
2.2 捕获异常:try-catch
块
try-catch
块的作用是 “捕获并处理异常”——try
包裹 “可能抛出异常的代码”,catch
则定义 “如何处理特定类型的异常”。
语法结构
cpp
try {
// 可能抛出异常的代码(如函数调用、危险操作)
riskyOperation();
} catch (类型1& e) {
// 处理“类型1”的异常
} catch (类型2& e) {
// 处理“类型2”的异常
} catch (...) {
// 处理所有其他类型的异常(兜底,避免程序终止)
}
关键规则
try
不能单独存在:必须跟至少一个catch
(包括catch(...)
);- 匹配规则:类型优先:
catch
只会捕获 “类型完全匹配”(或符合特殊转换规则,后面会讲)的异常; - 顺序重要:如果有多个
catch
,子类异常的catch
必须放在父类异常的catch
前面(否则父类catch
会先匹配,子类catch
永远不会执行); catch(...)
是兜底:它能捕获任意类型的异常,但无法获取异常对象的具体信息,通常用于 “防止程序崩溃”,并记录 “未知异常” 日志。
示例:多catch
的匹配顺序
#include <iostream>
#include <string>
using namespace std;
// 基类异常
class BaseException {
public:
virtual string what() const { return "BaseException"; }
};
// 子类异常:除零错误
class DivideZeroException : public BaseException {
public:
string what() const override { return "DivideZeroException:除零错误"; }
};
// 子类异常:空指针错误
class NullPtrException : public BaseException {
public:
string what() const override { return "NullPtrException:空指针错误"; }
};
// 可能抛出不同异常的函数
void doSomething(int type) {
if (type == 1) {
throw DivideZeroException();
} else if (type == 2) {
throw NullPtrException();
} else {
throw BaseException();
}
}
int main() {
int type;
cin >> type;
try {
doSomething(type);
} catch (const DivideZeroException& e) { // 子类1:先捕获
cout << "处理除零错误:" << e.what() << endl;
} catch (const NullPtrException& e) { // 子类2:再捕获
cout << "处理空指针错误:" << e.what() << endl;
} catch (const BaseException& e) { // 父类:后捕获
cout << "处理基类异常:" << e.what() << endl;
} catch (...) { // 兜底:最后捕获
cout << "处理未知异常" << endl;
}
return 0;
}
测试结果:
- 输入
1
:处理除零错误:DivideZeroException:除零错误
- 输入
2
:处理空指针错误:NullPtrException:空指针错误
- 输入
3
:处理基类异常:BaseException
如果把BaseException
的catch
放在最前面,输入1
或2
时,都会被BaseException
的catch
捕获(因为子类对象可以隐式转换为父类对象),导致子类catch
失效 —— 这就是 “顺序重要” 的原因。
三、异常的传播:栈展开(Stack Unwinding)
当throw
抛出异常后,如果当前函数的try-catch
块无法匹配该异常,程序会怎么做?答案是 “栈展开”—— 沿着函数调用链 “向上回溯”,逐个销毁函数栈帧,直到找到能匹配的catch
,或者到达main
函数后终止程序。
3.1 什么是 “函数栈帧”?
在理解栈展开前,我们需要先明确 “函数栈帧” 的概念:程序运行时,函数的调用会在 “调用栈”(栈内存)中创建一个 “栈帧”,用于存储函数的参数、局部变量、返回地址等信息。当函数执行完毕,栈帧会被销毁,内存释放。
比如,main
调用func3
,func3
调用func2
,func2
调用func1
,此时的调用栈和栈帧如下:
调用栈(从上到下,栈增长方向向下):
[func1的栈帧:局部变量、参数等]
[func2的栈帧:局部变量、参数等]
[func3的栈帧:局部变量、参数等]
[main的栈帧:局部变量、参数等]
3.2 栈展开的完整过程
栈展开的核心是 “逐层回溯、销毁栈帧、寻找匹配的catch
”,具体步骤如下:
- 抛出异常:在函数
A
中throw
一个异常对象; - 检查当前函数:查看
throw
是否在A
的try
块内:- 如果在,遍历
A
的catch
,寻找类型匹配的处理器; - 如果找到,执行
catch
代码,之后继续执行catch
块后面的代码; - 如果没找到,进入下一步;
- 如果在,遍历
- 销毁当前栈帧:销毁
A
的栈帧(局部变量、参数等被释放),退回到调用A
的函数B
; - 重复检查:在
B
中重复步骤 2-3,直到找到匹配的catch
; - 终止程序:如果回溯到
main
函数,仍未找到匹配的catch
,系统会调用terminate()
函数,强制终止程序。
3.3 栈展开的实例分析
我们用一个完整的代码例子,一步一步拆解栈展开的过程:
#include <iostream>
#include <string>
using namespace std;
// 函数调用链:main -> func3 -> func2 -> func1
void func1() {
cout << "进入func1,准备抛出异常" << endl;
// 抛出string类型的异常
throw string("func1中发生的错误");
cout << "func1中throw后面的代码(不会执行)" << endl;
}
void func2() {
cout << "进入func2,调用func1" << endl;
func1(); // 调用func1,会抛出异常
cout << "func2中func1后面的代码(不会执行)" << endl;
}
void func3() {
cout << "进入func3,调用func2" << endl;
// func3的try-catch:只捕获int类型,无法匹配string
try {
func2();
} catch (int errId) {
cout << "func3捕获到int异常:" << errId << endl;
}
cout << "func3中try-catch后面的代码(异常未被捕获,不会执行)" << endl;
}
int main() {
cout << "进入main,调用func3" << endl;
try {
func3(); // 调用func3,最终会传播异常到这里
} catch (const string& errMsg) {
// main的catch:匹配string类型,捕获异常
cout << "main捕获到string异常:" << errMsg << endl;
} catch (...) {
cout << "main捕获到未知异常" << endl;
}
cout << "main中try-catch后面的代码(会执行)" << endl;
return 0;
}
步骤 1:函数调用与异常抛出
main
调用func3
,func3
调用func2
,func2
调用func1
;func1
中执行throw string("func1中发生的错误")
,throw
后面的代码不会执行。
步骤 2:func1 中无 try-catch,销毁栈帧
func1
没有try-catch
块,直接销毁func1
的栈帧,退回到func2
;func2
中func1()
后面的代码不会执行(因为异常未处理)。
步骤 3:func2 中无 try-catch,销毁栈帧
func2
也没有try-catch
块,销毁func2
的栈帧,退回到func3
。
步骤 4:func3 的 try-catch 不匹配,销毁栈帧
func3
有try-catch
,但catch
的是int
类型,无法匹配string
异常;- 销毁
func3
的栈帧,退回到main
。
步骤 5:main 的 try-catch 匹配,处理异常
main
的try
块包裹func3()
,catch (const string& errMsg)
匹配异常;- 执行
catch
中的代码,打印 “main 捕获到 string 异常:func1 中发生的错误”; - 执行
catch
块后面的代码,打印 “main 中 try-catch 后面的代码(会执行)”。
最终运行结果
如果我们删除main
中的catch (const string& errMsg)
,会发生什么?
- 异常会传播到
main
的catch (...)
,打印 “main 捕获到未知异常”; - 如果连
catch (...)
也删除,main
中没有匹配的catch
,程序会调用terminate()
,直接终止,不会执行main
后面的代码。
四、异常的匹配规则:不止 “完全匹配”
前面我们提到,catch
的匹配规则是 “类型优先”,但这并不意味着只有 “完全相同的类型” 才能匹配。C++ 允许三种特殊的类型转换,让catch
能匹配 “兼容类型” 的异常。
4.1 允许的三种类型转换
1. 非常量(non-const)到常量(const)的转换(权限缩小)
如果抛出的是 “非常量对象”,catch
可以用 “常量引用” 捕获 —— 这是最常见的转换,因为捕获引用可以避免对象拷贝,而const
可以防止误修改异常对象。
示例:
#include <iostream>
#include <string>
using namespace std;
void throwNonConst() {
string err = "非常量string异常";
throw err; // 抛出非常量string对象
}
int main() {
try {
throwNonConst();
} catch (const string& e) { // 用const引用捕获,允许转换
cout << "捕获成功:" << e << endl;
}
return 0;
}
运行结果:捕获成功:非常量string异常
2. 数组到指针、函数到函数指针的转换
如果抛出的是 “数组”,catch
可以用 “指向数组元素类型的指针” 捕获;如果抛出的是 “函数”,catch
可以用 “函数指针” 捕获。
示例:数组到指针的转换
#include <iostream>
using namespace std;
void throwArray() {
const char err[] = "数组类型的异常";
throw err; // 抛出数组,会被转换为const char*
}
int main() {
try {
throwArray();
} catch (const char* e) { // 用指针捕获数组异常
cout << "捕获成功:" << e << endl;
}
return 0;
}
运行结果:捕获成功:数组类型的异常
3. 派生类到基类的转换(最实用的转换)
如果抛出的是 “派生类异常对象”,catch
可以用 “基类引用或指针” 捕获 —— 这是 C++ 异常处理中最核心的特性之一,也是 “自定义异常体系” 的设计基础。
通过这种转换,我们可以用一个catch (BaseException& e)
捕获所有 “继承自 BaseException 的子类异常”,大大简化代码。
示例:派生类到基类的转换
#include <iostream>
#include <string>
using namespace std;
// 基类异常
class Exception {
public:
Exception(string msg) : _msg(msg) {}
virtual string what() const { return _msg; } // 虚函数,支持多态
private:
string _msg;
};
// 子类异常:SQL错误
class SqlException : public Exception {
public:
SqlException(string msg, string sql)
: Exception(msg), _sql(sql) {}
// 重写what(),返回更详细的信息
string what() const override {
return "SqlException:" + Exception::what() + ",SQL:" + _sql;
}
private:
string _sql;
};
// 子类异常:HTTP错误
class HttpException : public Exception {
public:
HttpException(string msg, string method)
: Exception(msg), _method(method) {}
string what() const override {
return "HttpException:" + Exception::what() + ",方法:" + _method;
}
private:
string _method;
};
// 模拟SQL操作,抛出SqlException
void sqlQuery(const string& sql) {
if (sql.empty()) {
throw SqlException("SQL语句为空", sql);
}
}
// 模拟HTTP请求,抛出HttpException
void httpRequest(const string& method) {
if (method != "GET" && method != "POST") {
throw HttpException("不支持的请求方法", method);
}
}
int main() {
try {
// 测试1:抛出SqlException
// sqlQuery("");
// 测试2:抛出HttpException
httpRequest("PUT");
} catch (const Exception& e) { // 用基类引用捕获所有子类异常
// 多态调用:根据实际异常类型,调用对应的what()
cout << "捕获到异常:" << e.what() << endl;
}
return 0;
}
测试结果(开启httpRequest("PUT")
):
测试结果(开启sqlQuery("")
):
这个例子完美体现了 “派生类到基类转换” 的优势:无论抛出的是SqlException
还是HttpException
,只需一个catch (const Exception& e)
就能处理,且通过虚函数what()
实现了 “多态打印”,获取不同异常的详细信息。
4.2 匹配规则的优先级
当多个catch
都能匹配异常时,优先级如下:
- 完全匹配:比任何转换都优先;
- 派生类到基类的转换:如果没有完全匹配,再看是否能转换为基类;
catch(...)
:最后匹配,兜底用。
示例:优先级验证
#include <iostream>
using namespace std;
class Base {};
class Derived : public Base {};
int main() {
try {
throw Derived(); // 抛出派生类对象
} catch (const Derived& e) { // 完全匹配:优先级最高
cout << "捕获到Derived异常" << endl;
} catch (const Base& e) { // 派生类到基类:优先级次之
cout << "捕获到Base异常" << endl;
} catch (...) { // 兜底:优先级最低
cout << "捕获到未知异常" << endl;
}
return 0;
}
运行结果:捕获到Derived异常
如果删除catch (const Derived& e)
,结果会变成捕获到Base异常
—— 这就是优先级的体现。
五、异常的二次处理:异常重新抛出
在实际开发中,我们可能需要 “部分处理异常”:比如捕获异常后,先记录日志,再将异常传递给上层函数处理;或者只处理特定类型的异常,其他异常继续向上传播。这时,就需要 “异常重新抛出”。
5.1 重新抛出的语法:throw;
重新抛出异常的语法非常简单:在catch
块中直接写throw;
,它会 “原封不动” 地将当前捕获的异常对象再次抛出。
注意:不要用throw e;
(e 是捕获的异常对象),因为这会创建一个新的异常对象(拷贝 e),可能导致 “切片问题”(如果 e 是基类引用,实际指向子类对象,throw e;
会抛出基类对象,丢失子类特有的信息)。
示例:throw;
vs throw e;
的区别
#include <iostream>
#include <string>
using namespace std;
class BaseException {
public:
virtual string what() const { return "BaseException"; }
};
class DerivedException : public BaseException {
public:
string what() const override { return "DerivedException"; }
};
void rethrowTest() {
try {
throw DerivedException(); // 抛出子类对象
} catch (const BaseException& e) {
cout << "rethrowTest中捕获到:" << e.what() << endl;
// 错误方式:throw e; 会抛出BaseException对象(切片)
// throw e;
// 正确方式:throw; 会抛出原DerivedException对象
throw;
}
}
int main() {
try {
rethrowTest();
} catch (const DerivedException& e) {
cout << "main中捕获到DerivedException:" << e.what() << endl;
} catch (const BaseException& e) {
cout << "main中捕获到BaseException:" << e.what() << endl;
}
return 0;
}
情况 1:用throw e;
(错误)
运行结果:
原因:throw e;
拷贝了e
(基类引用,指向子类对象),但抛出的是BaseException
对象,子类信息丢失(切片)。
情况 2:用throw;
(正确)
运行结果:
原因:throw;
直接抛出原异常对象(DerivedException),没有拷贝,信息完整。
5.2 重新抛出的典型场景:重试机制
重新抛出最常见的场景之一是 “重试”—— 比如网络请求失败时,重试几次后再抛异常;如果重试成功,则不抛异常。
示例:网络请求重试
#include <iostream>
#include <string>
#include <ctime>
using namespace std;
// 自定义HTTP异常
class HttpException {
public:
HttpException(string msg, int errId) : _msg(msg), _errId(errId) {}
string getMsg() const { return _msg; }
int getErrId() const { return _errId; }
private:
string _msg;
int _errId;
};
// 模拟发送网络请求,50%概率失败(errId=102:网络不稳定)
void sendRequest(const string& data) {
srand(time(0)); // 初始化随机数种子
if (rand() % 2 == 0) {
throw HttpException("网络不稳定,请求失败", 102);
}
cout << "请求发送成功,数据:" << data << endl;
}
// 封装重试逻辑:最多重试3次
void sendWithRetry(const string& data) {
const int maxRetry = 3; // 最大重试次数
for (int i = 0; i < maxRetry; ++i) {
try {
sendRequest(data);
return; // 发送成功,直接返回
} catch (const HttpException& e) {
// 只处理errId=102(网络不稳定),其他错误直接抛出
if (e.getErrId() != 102) {
throw; // 非网络错误,重新抛出
}
// 网络错误,重试
cout << "第" << (i+1) << "次请求失败:" << e.getMsg() << ",准备重试..." << endl;
}
}
// 重试3次都失败,抛出最终异常
throw HttpException("重试3次后仍失败:网络持续不稳定", 102);
}
int main() {
try {
sendWithRetry("Hello, Server!");
} catch (const HttpException& e) {
cout << "最终失败:" << e.getMsg() << "(错误编号:" << e.getErrId() << ")" << endl;
}
return 0;
}
可能的运行结果:
这个例子中,sendWithRetry
捕获HttpException
后,只对 “网络不稳定”(errId=102)进行重试,其他错误(比如 “权限不足”)直接重新抛出,交给main
处理 —— 这就是 “部分处理 + 重新抛出” 的典型用法。
六、异常的隐藏陷阱:异常安全问题
异常虽然强大,但也会带来 “异常安全” 问题 —— 如果异常抛出时,程序已经申请了资源(比如内存、文件句柄、锁),但还没来得及释放,这些资源会因为栈展开时局部变量被销毁而永久泄漏,导致程序运行不稳定。
6.1 常见的异常安全问题:资源泄漏
最典型的资源泄漏场景是 “手动申请内存后,异常抛出导致未释放”。
示例:内存泄漏问题
#include <iostream>
#include <string>
using namespace std;
double Divide(int a, int b) {
if (b == 0) {
throw string("除零错误");
}
return (double)a / (double)b;
}
void func() {
// 申请内存(资源)
int* arr = new int[10];
cout << "内存申请成功:" << arr << endl;
// 执行可能抛出异常的操作
int a = 5, b = 0;
double result = Divide(a, b); // 抛出异常
cout << "计算结果:" << result << endl;
// 释放内存(异常抛出后,这行代码不会执行!)
delete[] arr;
cout << "内存释放成功" << endl;
}
int main() {
try {
func();
} catch (const string& e) {
cout << "捕获到异常:" << e << endl;
}
// 程序结束后,arr指向的内存仍未释放(内存泄漏)
return 0;
}
运行结果:
问题分析:func
中new int[10]
申请了内存,但Divide(a,b)
抛出异常后,delete[] arr
不会执行,导致arr
指向的内存永远无法释放 —— 这就是内存泄漏。
6.2 临时解决方案:在catch
中释放资源
要解决这个问题,我们可以在func
中添加try-catch
块,捕获异常后先释放资源,再重新抛出异常。
示例:修复内存泄漏
#include <iostream>
#include <string>
using namespace std;
double Divide(int a, int b) {
if (b == 0) {
throw string("除零错误");
}
return (double)a / (double)b;
}
void func() {
int* arr = new int[10];
cout << "内存申请成功:" << arr << endl;
try {
// 把可能抛出异常的代码放在try块中
int a = 5, b = 0;
double result = Divide(a, b);
cout << "计算结果:" << result << endl;
} catch (...) {
// 捕获所有异常,先释放资源
delete[] arr;
cout << "异常发生,释放内存:" << arr << endl;
throw; // 重新抛出异常,交给上层处理
}
// 正常执行时,释放资源
delete[] arr;
cout << "正常执行,释放内存:" << arr << endl;
}
int main() {
try {
func();
} catch (const string& e) {
cout << "捕获到异常:" << e << endl;
}
return 0;
}
运行结果:
这样一来,无论是否发生异常,内存都会被释放 —— 异常安全问题得到解决。但这种方式的缺点很明显:如果有多个资源(比如内存、文件、锁),需要写大量的try-catch
,代码臃肿且容易出错。
6.3 终极解决方案:RAII(资源获取即初始化)
RAII(Resource Acquisition Is Initialization)是 C++ 中解决异常安全问题的 “银弹”—— 它的核心思想是 “将资源的生命周期与对象的生命周期绑定”:
- 资源获取(比如
new
、open
)在对象的构造函数中完成; - 资源释放(比如
delete
、close
)在对象的析构函数中完成; - 当对象离开作用域(比如栈展开时局部对象被销毁),析构函数会自动调用,资源被释放。
C++ 标准库中的 “智能指针”(unique_ptr
、shared_ptr
)就是 RAII 的典型实现。用智能指针管理内存,即使发生异常,也能自动释放资源。
示例:用智能指针解决内存泄漏
#include <iostream>
#include <string>
#include <memory> // 包含智能指针头文件
using namespace std;
double Divide(int a, int b) {
if (b == 0) {
throw string("除零错误");
}
return (double)a / (double)b;
}
void func() {
// 用unique_ptr管理内存(RAII)
unique_ptr<int[]> arr(new int[10]);
cout << "内存申请成功(智能指针管理)" << endl;
// 执行可能抛出异常的操作,无需手动释放内存
int a = 5, b = 0;
double result = Divide(a, b);
cout << "计算结果:" << result << endl;
// 无需手动delete,arr离开作用域时,析构函数自动释放内存
}
int main() {
try {
func();
} catch (const string& e) {
cout << "捕获到异常:" << e << endl;
}
return 0;
}
内存申请成功(智能指针管理)
捕获到异常:除零错误
问题分析:unique_ptr<int[]> arr
创建后,new int[10]
的内存由arr
管理。当func
中抛出异常,arr
作为局部对象会被销毁,其析构函数会自动调用delete[]
释放内存 —— 无需手动写try-catch
,代码简洁且安全。
6.4 另一个陷阱:析构函数不能抛出异常
除了资源泄漏,“析构函数抛出异常” 也是一个严重的异常安全问题。因为析构函数的作用是 “释放资源”,如果析构函数抛出异常,可能导致:
- 后续的资源释放操作无法执行(比如析构函数要释放 10 个资源,第 5 个时抛出异常,后面 5 个资源泄漏);
- 程序调用
terminate()
终止(如果析构函数的异常在栈展开过程中抛出)。
《Effective C++》第 8 条明确指出:别让异常逃离析构函数。
示例:析构函数抛出异常的风险
#include <iostream>
using namespace std;
class BadResource {
public:
BadResource(int id) : _id(id) {
cout << "构造函数:创建资源" << _id << endl;
}
~BadResource() {
cout << "析构函数:释放资源" << _id << endl;
// 模拟析构函数抛出异常
throw "析构函数抛出异常";
}
private:
int _id;
};
int main() {
try {
BadResource res1(1);
BadResource res2(2);
// 抛出异常,触发栈展开,销毁res2和res1
throw "main中抛出异常";
} catch (const char* e) {
cout << "捕获到异常:" << e << endl;
}
return 0;
}
运行结果:
问题分析:
main
中抛出异常,触发栈展开,先销毁res2
;res2
的析构函数抛出异常,而此时正处于 “处理 main 异常” 的栈展开过程中;- C++ 不允许 “异常嵌套”,直接调用
terminate()
终止程序,res1
的析构函数没有执行,资源 1 泄漏。
解决方案:在析构函数中捕获异常
如果析构函数中确实可能发生异常(比如关闭文件时失败),必须在析构函数内部捕获并处理,不让异常逃离。
示例:安全的析构函数
#include <iostream>
using namespace std;
class SafeResource {
public:
SafeResource(int id) : _id(id) {
cout << "构造函数:创建资源" << _id << endl;
}
~SafeResource() {
try {
cout << "析构函数:释放资源" << _id << endl;
// 模拟可能抛出异常的操作
if (_id == 2) {
throw "关闭文件失败";
}
} catch (const char* e) {
// 内部捕获异常,记录日志,不让异常逃离
cout << "析构函数内部处理异常:" << e << endl;
}
}
private:
int _id;
};
int main() {
try {
SafeResource res1(1);
SafeResource res2(2);
throw "main中抛出异常";
} catch (const char* e) {
cout << "main捕获到异常:" << e << endl;
}
return 0;
}
运行结果:
这样一来,析构函数的异常被内部处理,不会影响栈展开,所有资源都能正常释放,程序也不会终止。
七、异常的 “说明书”:异常规范
在团队开发中,我们需要告诉其他开发者:“我的函数会不会抛出异常?会抛出哪些类型的异常?”—— 这就是 “异常规范” 的作用。它相当于函数的 “说明书”,帮助调用者提前做好异常处理准备。
C++ 历史上有两种异常规范:C++98 的throw()
语法和 C++11 的noexcept
语法。目前,noexcept
已基本取代throw()
,成为主流。
7.1 C++98 的异常规范:throw()
C++98 的异常规范用throw(类型列表)
来声明函数可能抛出的异常类型:
throw(Type1, Type2)
:函数可能抛出Type1
或Type2
类型的异常;throw()
:函数不会抛出任何异常(空列表);- 不写:函数可能抛出任意类型的异常。
示例:C++98 异常规范
#include <iostream>
#include <string>
using namespace std;
// 声明:可能抛出string或int类型的异常
void func1() throw(string, int) {
if (rand() % 2 == 0) {
throw string("string类型异常");
} else {
throw 100;
}
}
// 声明:不会抛出任何异常
void func2() throw() {
cout << "func2:不会抛出异常" << endl;
}
int main() {
srand(time(0));
try {
func1();
} catch (const string& e) {
cout << "捕获到string异常:" << e << endl;
} catch (int e) {
cout << "捕获到int异常:" << e << endl;
}
func2();
return 0;
}
C++98 异常规范的缺陷
- 语法繁琐:如果函数可能抛出多种类型,
throw()
列表会很长; - 编译器检查宽松:即使声明
throw(string)
,函数中抛出int
异常,编译器也不会报错(只会在运行时调用unexpected()
终止程序); - 兼容性差:不同编译器对
throw()
的处理不一致,导致跨平台问题。
正因如此,C++11 引入了更简洁、更实用的noexcept
。
7.2 C++11 的异常规范:noexcept
C++11 用noexcept
关键字简化了异常规范,规则更清晰:
noexcept
:声明函数不会抛出任何异常;noexcept(表达式)
:根据 “表达式是否可能抛出异常” 来决定函数是否 noexcept(表达式为true
则不抛,false
则可能抛);- 不写
noexcept
:函数可能抛出任何异常(默认行为)。
1. noexcept
的基本用法
#include <iostream>
#include <string>
using namespace std;
// 声明:不会抛出异常
void func1() noexcept {
cout << "func1:不会抛出异常" << endl;
}
// 声明:可能抛出异常(默认,可省略)
void func2() {
if (rand() % 2 == 0) {
throw string("func2抛出异常");
}
cout << "func2:未抛出异常" << endl;
}
int main() {
srand(time(0));
func1(); // 安全调用,无需try-catch
try {
func2();
} catch (const string& e) {
cout << "捕获到func2的异常:" << e << endl;
}
return 0;
}
2. noexcept
的编译器行为
- 不强制检查:即使函数声明了
noexcept
,如果函数内部抛出异常,编译器也不会报错(但会在运行时调用terminate()
终止程序); - 优化提示:编译器会根据
noexcept
进行优化 —— 比如noexcept
函数不需要生成 “异常处理相关的代码”,执行效率更高。
示例:noexcept
函数抛出异常的后果
#include <iostream>
#include <string>
using namespace std;
// 声明noexcept,但内部抛出异常
void func() noexcept {
throw string("noexcept函数抛出异常");
}
int main() {
try {
func();
} catch (const string& e) {
// 永远不会执行到这里,因为func是noexcept
cout << "捕获到异常:" << e << endl;
}
return 0;
}
运行结果:
因此,必须确保noexcept
函数内部确实不会抛出异常(包括直接抛出和间接调用可能抛出异常的函数)。
3. noexcept
作为运算符
noexcept
还可以作为 “运算符” 使用,用于检测一个 “表达式是否可能抛出异常”,返回bool
类型(true
表示不会抛,false
表示可能抛)。
示例:noexcept
运算符
#include <iostream>
#include <string>
#include <vector>
using namespace std;
void func1() noexcept { cout << "func1" << endl; }
void func2() { throw string("func2"); }
int main() {
// 检测表达式是否可能抛出异常
cout << "noexcept(func1()):" << noexcept(func1()) << endl; // true(func1是noexcept)
cout << "noexcept(func2()):" << noexcept(func2()) << endl; // false(func2可能抛)
cout << "noexcept(1 + 2):" << noexcept(1 + 2) << endl; // true(内置运算不会抛)
cout << "noexcept(vector<int>().at(0)):" << noexcept(vector<int>().at(0)) << endl; // false(at()可能抛out_of_range)
return 0;
}
运行结果:
noexcept(func1()):1
noexcept(func2()):0
noexcept(1 + 2):1
noexcept(vector<int>().at(0)):0
noexcept
运算符常用于模板编程中,根据模板参数的特性决定函数是否 noexcept。
八、C++ 标准库的异常体系
为了方便开发者使用,C++ 标准库提供了一套预定义的异常体系,所有异常类都继承自基类std::exception
。这个体系覆盖了大多数常见的错误场景,比如内存分配失败、类型转换错误、逻辑错误等。
8.1 标准库异常的继承结构
std::exception
是所有标准库异常的基类,定义在<exception>
头文件中,它只有一个虚函数what()
,返回const char*
类型的错误描述,子类可以重写该函数。
标准库异常的继承结构如下(关键类):
std::exception(基类)
├─ std::bad_alloc(内存分配失败,如new失败)
├─ std::bad_cast(动态类型转换失败,如dynamic_cast<void*>失败)
├─ std::bad_typeid(typeid操作空指针失败)
├─ std::bad_exception(异常处理过程中发生的意外异常)
├─ std::logic_error(逻辑错误:编译时可检测,如参数无效)
│ ├─ std::invalid_argument(无效参数,如传递空指针给需要非空的函数)
│ ├─ std::domain_error(参数不在合法范围内,如数学函数的定义域错误)
│ ├─ std::length_error(长度超出范围,如string::reserve(过大值))
│ └─ std::out_of_range(索引超出范围,如vector::at(超出大小的索引))
└─ std::runtime_error(运行时错误:编译时不可检测,如文件不存在)
├─ std::range_error(运行时范围错误,如计算结果超出表示范围)
├─ std::overflow_error(算术上溢,如int最大值+1)
└─ std::underflow_error(算术下溢,如int最小值-1)
8.2 常用标准库异常的使用示例
我们通过几个例子,学习如何使用标准库异常。
1. std::bad_alloc
:内存分配失败
当new
无法分配足够的内存时,会抛出std::bad_alloc
异常(注意:new(nothrow)
不会抛出异常,而是返回nullptr
)。
示例:
#include <iostream>
#include <new> // 包含bad_alloc
using namespace std;
int main() {
try {
// 尝试分配一个极大的内存(超过系统可用内存)
int* p = new int[1000000000000];
delete[] p;
} catch (const bad_alloc& e) {
// 捕获内存分配失败异常
cout << "捕获到bad_alloc:" << e.what() << endl;
}
return 0;
}
运行结果:
2. std::out_of_range
:索引超出范围
vector::at()
、string::at()
等成员函数会检查索引是否超出范围,超出时抛出std::out_of_range
异常(而operator[]
不检查,会导致未定义行为)。
示例:
#include <iostream>
#include <vector>
#include <stdexcept> // 包含out_of_range
using namespace std;
int main() {
vector<int> vec = {1, 2, 3};
try {
// vec的大小是3,索引0-2,at(3)超出范围
cout << vec.at(3) << endl;
} catch (const out_of_range& e) {
cout << "捕获到out_of_range:" << e.what() << endl;
}
// 对比:operator[]不检查,行为未定义(可能崩溃或输出垃圾值)
// cout << vec[3] << endl;
return 0;
}
运行结果:
捕获到out_of_range:vector::_M_range_check: __n (which is 3) >= this->size() (which is 3)
3. std::invalid_argument
:无效参数
当传递给函数的参数不符合要求时,会抛出std::invalid_argument
异常(常见于字符串转换、算法函数等)。
示例:
#include <iostream>
#include <string>
#include <sstream>
#include <stdexcept> // 包含invalid_argument
using namespace std;
// 把字符串转换为整数,空字符串视为无效参数
int stringToInt(const string& s) {
if (s.empty()) {
throw invalid_argument("无效参数:字符串为空");
}
stringstream ss(s);
int num;
ss >> num;
if (ss.fail()) {
throw invalid_argument("无效参数:无法转换为整数");
}
return num;
}
int main() {
try {
cout << stringToInt("123") << endl; // 正常转换
cout << stringToInt("") << endl; // 抛出无效参数异常
} catch (const invalid_argument& e) {
cout << "捕获到invalid_argument:" << e.what() << endl;
}
return 0;
}
运行结果:
123
捕获到invalid_argument:无效参数:字符串为空
8.3 自定义异常继承标准库异常
在实际开发中,我们可以继承标准库的异常类(比如std::exception
或std::runtime_error
),实现自定义异常 —— 这样做的好处是,自定义异常可以和标准库异常一起被catch (const std::exception& e)
捕获,统一处理。
示例:自定义 HTTP 异常(继承std::runtime_error
)
#include <iostream>
#include <string>
#include <stdexcept> // 包含runtime_error
using namespace std;
// 自定义HTTP异常,继承自std::runtime_error
class HttpException : public runtime_error {
public:
// 构造函数:调用父类runtime_error的构造函数(传递错误描述)
HttpException(int statusCode, const string& msg)
: runtime_error(msg), _statusCode(statusCode) {}
// 获取HTTP状态码(自定义属性)
int getStatusCode() const { return _statusCode; }
private:
int _statusCode; // HTTP状态码(如404、500)
};
// 模拟HTTP请求,根据路径抛出不同异常
void httpGet(const string& path) {
if (path == "/404") {
throw HttpException(404, "页面未找到:" + path);
} else if (path == "/500") {
throw HttpException(500, "服务器内部错误:" + path);
}
cout << "HTTP GET成功:" << path << endl;
}
int main() {
try {
httpGet("/home"); // 正常
httpGet("/404"); // 抛出404异常
} catch (const HttpException& e) {
// 捕获自定义异常,获取状态码和描述
cout << "HTTP错误 " << e.getStatusCode() << ":" << e.what() << endl;
} catch (const exception& e) {
// 捕获其他标准库异常
cout << "标准库异常:" << e.what() << endl;
}
return 0;
}
运行结果:
HTTP GET成功:/home
HTTP错误 404:页面未找到:/404
这种方式的优势在于:自定义异常融入了标准库异常体系,既可以单独捕获,也可以和其他标准库异常一起用catch (const exception& e)
统一处理,灵活性更高。
九、实战:构建一个完整的异常处理体系
前面我们学习了异常的基础概念、核心机制和实战要点,现在我们将这些知识结合起来,构建一个 “服务端程序的异常处理体系”—— 包含 SQL、缓存、HTTP 三个模块,每个模块抛出自定义异常,通过基类统一捕获,同时处理异常安全问题。
9.1 需求分析
- 模块划分:HTTP 模块(处理请求)、缓存模块(处理缓存读写)、SQL 模块(处理数据库操作),模块间依赖关系为:HTTP 模块调用缓存模块,缓存模块调用 SQL 模块;
- 异常体系:设计基类
Exception
,子类HttpException
、CacheException
、SqlException
分别对应三个模块的异常,子类重写what()
方法以提供模块专属的错误信息; - 异常安全:使用智能指针(
shared_ptr
)管理模块对象生命周期,避免手动释放资源导致的泄漏;析构函数声明为noexcept
,确保不抛出异常; - 运行逻辑:程序以 1 秒为周期循环模拟 HTTP 请求,随机触发各模块异常,在
main
函数中统一捕获并打印异常信息,确保程序不终止,持续提供服务。
9.2 完整代码实现(基于异常体系规范)
#include <iostream>
#include <string>
#include <memory> // 智能指针,解决资源泄漏(RAII)
#include <ctime> // 随机数种子初始化
#include <thread> // 线程睡眠,模拟服务周期
#include <chrono> // 时间单位(秒)
#include <cstring> // 时间格式化
using namespace std;
// -------------------------- 1. 自定义异常体系(基类+子类) --------------------------
// 异常基类:所有模块异常的父类
class Exception {
public:
// 构造函数:初始化错误描述、错误编号、错误发生时间
Exception(const string& errmsg, int id)
: _errmsg(errmsg)
, _id(id)
, _timestamp(time(nullptr)) // 获取当前时间戳
{}
// 析构函数:声明为noexcept,确保不抛出异常(避免栈展开时程序终止)
virtual ~Exception() noexcept {}
// 虚函数:返回错误信息(多态调用,子类可重写)
virtual string what() const {
// 格式化时间戳(转换为"年-月-日 时:分:秒")
char time_buf[64] = {0};
strftime(time_buf, sizeof(time_buf), "%Y-%m-%d %H:%M:%S", localtime(&_timestamp));
// 拼接基础错误信息
return "[" + string(time_buf) + "] 错误ID:" + to_string(_id) + ",描述:" + _errmsg;
}
// 获取错误ID(用于异常分类处理,如重试逻辑)
int getid() const { return _id; }
protected:
string _errmsg; // 错误描述
int _id; // 错误编号(区分不同错误类型)
time_t _timestamp; // 错误发生时间戳
};
// SQL模块异常:继承自Exception,增加SQL语句信息
class SqlException : public Exception {
public:
// 构造函数:调用基类构造,初始化SQL语句
SqlException(const string& errmsg, int id, const string& sql)
: Exception(errmsg, id)
, _sql(sql)
{}
// 重写what():返回包含SQL语句的错误信息
virtual string what() const override {
string base_info = Exception::what();
return "SqlException:" + base_info + ",SQL语句:" + _sql;
}
private:
const string _sql; // 触发错误的SQL语句(便于定位问题)
};
// 缓存模块异常:继承自Exception,增加缓存键信息
class CacheException : public Exception {
public:
// 构造函数:调用基类构造,初始化缓存键
CacheException(const string& errmsg, int id, const string& key)
: Exception(errmsg, id)
, _key(key)
{}
// 重写what():返回包含缓存键的错误信息
virtual string what() const override {
string base_info = Exception::what();
return "CacheException:" + base_info + ",缓存键:" + _key;
}
private:
const string _key; // 触发错误的缓存键(便于定位问题)
};
// HTTP模块异常:继承自Exception,增加请求方法和路径信息
class HttpException : public Exception {
public:
// 构造函数:调用基类构造,初始化请求方法和路径
HttpException(const string& errmsg, int id, const string& method, const string& path)
: Exception(errmsg, id)
, _method(method)
, _path(path)
{}
// 重写what():返回包含HTTP请求信息的错误信息
virtual string what() const override {
string base_info = Exception::what();
return "HttpException:" + base_info + ",请求方法:" + _method + ",路径:" + _path;
}
private:
const string _method; // HTTP请求方法(GET/POST等)
const string _path; // HTTP请求路径(如/query、/login等)
};
// -------------------------- 2. 业务模块实现(依赖注入+智能指针) --------------------------
// SQL模块:模拟数据库查询操作,随机触发异常
class SqlMgr {
public:
// 模拟SQL查询:1/7概率触发"权限不足",1/7概率触发"语法错误"
void query(const string& sql) {
int rand_val = rand() % 7; // 生成0-6的随机数
if (rand_val == 0) {
// 抛出"权限不足"异常(错误ID:1001)
throw SqlException("权限不足:无法访问目标表", 1001, sql);
} else if (rand_val == 1) {
// 抛出"SQL语法错误"异常(错误ID:1002)
throw SqlException("语法错误:关键字拼写错误", 1002, sql);
}
// 无异常时,打印执行成功信息
cout << "[SQL模块] 执行成功,SQL:" << sql << endl;
}
};
// 缓存模块:依赖SQL模块,模拟缓存读写,随机触发异常
class CacheMgr {
public:
// 构造函数:通过智能指针注入SQL模块(依赖注入,降低耦合)
CacheMgr(shared_ptr<SqlMgr> sql_mgr)
: _sql_mgr(sql_mgr)
{}
// 模拟缓存获取:1/5概率触发"缓存不存在",1/6概率触发"缓存过期"
void get(const string& key) {
int rand_val1 = rand() % 5; // 生成0-4的随机数(判断缓存是否存在)
if (rand_val1 == 0) {
// 抛出"缓存不存在"异常(错误ID:2001),并调用SQL模块加载数据
cout << "[缓存模块] 缓存键" << key << "不存在,尝试从SQL加载..." << endl;
_sql_mgr->query("SELECT * FROM cache_data WHERE key = '" + key + "'");
throw CacheException("缓存不存在:已触发SQL加载", 2001, key);
}
int rand_val2 = rand() % 6; // 生成0-5的随机数(判断缓存是否过期)
if (rand_val2 == 0) {
// 抛出"缓存过期"异常(错误ID:2002),并调用SQL模块更新缓存
cout << "[缓存模块] 缓存键" << key << "已过期,尝试从SQL更新..." << endl;
_sql_mgr->query("UPDATE cache_data SET value = new_val WHERE key = '" + key + "'");
throw CacheException("缓存过期:已触发SQL更新", 2002, key);
}
// 无异常时,打印缓存获取成功信息
cout << "[缓存模块] 获取成功,缓存键:" << key << endl;
}
private:
shared_ptr<SqlMgr> _sql_mgr; // 智能指针管理SQL模块,自动释放资源
};
// HTTP模块:依赖缓存模块,模拟HTTP请求处理,随机触发异常
class HttpServer {
public:
// 构造函数:通过智能指针注入缓存模块(依赖注入,降低耦合)
HttpServer(shared_ptr<CacheMgr> cache_mgr)
: _cache_mgr(cache_mgr)
{}
// 模拟HTTP请求处理:1/3概率触发"资源不存在",1/4概率触发"权限不足"
void handle_request(const string& method, const string& path) {
// 模拟请求校验:先检查请求路径是否合法
if (path != "/user/info" && path != "/order/list" && path != "/goods/detail") {
// 抛出"请求资源不存在"异常(错误ID:3001)
throw HttpException("请求资源不存在:路径未注册", 3001, method, path);
}
int rand_val = rand() % 4; // 生成0-3的随机数(判断权限)
if (rand_val == 0 && method == "POST") {
// 抛出"POST请求权限不足"异常(错误ID:3002)
throw HttpException("权限不足:需要登录才能执行POST请求", 3002, method, path);
}
// 无异常时,调用缓存模块获取数据
cout << "[HTTP模块] 接收请求:" << method << " " << path << ",尝试获取缓存..." << endl;
_cache_mgr->get(path); // 调用缓存模块,可能触发缓存/SQL异常
}
private:
shared_ptr<CacheMgr> _cache_mgr; // 智能指针管理缓存模块,自动释放资源
};
// -------------------------- 3. 主函数(统一异常捕获+服务循环) --------------------------
int main() {
// 1. 初始化随机数种子(确保每次运行异常触发概率随机)
srand(time(0));
// 2. 初始化模块(智能指针管理,确保异常安全,避免资源泄漏)
shared_ptr<SqlMgr> sql_mgr = make_shared<SqlMgr>();
shared_ptr<CacheMgr> cache_mgr = make_shared<CacheMgr>(sql_mgr);
shared_ptr<HttpServer> http_server = make_shared<HttpServer>(cache_mgr);
// 3. 模拟服务循环:每秒处理一次请求,持续运行
cout << "=== 服务启动成功,开始处理请求(每秒1次)===" << endl;
while (1) {
// 模拟随机HTTP请求(方法:GET/POST,路径:3个合法路径中随机选)
string method = (rand() % 2 == 0) ? "GET" : "POST";
string paths[] = {"/user/info", "/order/list", "/goods/detail", "/invalid/path"};
string path = paths[rand() % 4]; // 1/4概率选择非法路径
try {
// 处理HTTP请求:可能触发HTTP、缓存、SQL模块的异常
http_server->handle_request(method, path);
}
// 4. 捕获所有自定义异常(基类引用,多态匹配所有子类异常)
catch (const Exception& e) {
cout << "=== 捕获到异常 ===" << endl;
cout << e.what() << endl; // 多态调用子类的what()方法
}
// 5. 捕获未知异常(兜底处理,避免程序终止)
catch (...) {
cout << "=== 捕获到未知异常 ===" << endl;
cout << "错误描述:未知异常类型,可能是系统级错误" << endl;
}
// 6. 服务周期:每秒处理一次请求
cout << "=== 本次请求处理结束,等待1秒..." << endl << endl;
this_thread::sleep_for(chrono::seconds(1));
}
return 0;
}
9.3 代码解析与运行效果
9.3.1 核心设计亮点(贴合异常处理规范)
- 异常体系解耦:通过基类
Exception
统一异常类型,子类仅需补充模块专属信息(如 SQL 语句、缓存键),符合 “派生类到基类转换” 的异常匹配规则,实现 “一 catch 多处理”; - 异常安全保障:使用
shared_ptr
管理模块对象,无需手动new/delete
,避免异常抛出时资源泄漏;所有析构函数声明为noexcept
,符合 “别让异常逃离析构函数” 的规范; - 错误信息完整:异常包含时间戳、错误 ID、模块类型、业务上下文(如 SQL 语句、请求路径),便于问题定位,解决了 C 语言错误码 “信息模糊” 的痛点;
- 服务稳定性:
main
函数中while(1)
循环 +catch(...)
兜底,确保即使触发未知异常,程序也不会终止,符合服务端 “持续运行” 的需求。
9.3.2 典型运行效果(随机触发异常)
从运行效果可见:
- 无异常时,各模块正常调用(HTTP→缓存→SQL);
- 触发异常时,异常沿调用链向上传播(如 SQL 异常→缓存异常→HTTP 异常),最终被
main
函数的catch(const Exception& e)
统一捕获; - 异常信息包含时间、模块、业务上下文,定位问题清晰;
- 程序持续运行,即使触发异常也不会终止,符合服务端稳定性要求。
9.4 实战扩展:异常重试机制
基于上述代码,我们可以增加 “异常重试” 功能 —— 针对 “临时错误”(如缓存过期、网络波动),重试 3 次后再抛出异常;针对 “永久错误”(如权限不足、资源不存在),直接抛出异常。
扩展后的 HTTP 模块handle_request
方法
void handle_request(const string& method, const string& path) {
const int max_retry = 3; // 最大重试次数
int retry_count = 0; // 当前重试次数
while (retry_count < max_retry) {
try {
// 1. 校验请求路径(永久错误,不重试)
if (path != "/user/info" && path != "/order/list" && path != "/goods/detail") {
throw HttpException("请求资源不存在:路径未注册", 3001, method, path);
}
// 2. 校验POST请求权限(永久错误,不重试)
if (method == "POST" && rand() % 4 == 0) {
throw HttpException("权限不足:需要登录才能执行POST请求", 3002, method, path);
}
// 3. 调用缓存模块(临时错误,可重试)
cout << "[HTTP模块] 接收请求:" << method << " " << path << ",尝试获取缓存(第" << retry_count + 1 << "次)..." << endl;
_cache_mgr->get(path);
return; // 无异常,直接返回
}
// 4. 捕获临时错误(缓存过期/不存在,重试)
catch (const CacheException& e) {
if (e.getid() == 2001 || e.getid() == 2002) { // 2001=缓存不存在,2002=缓存过期
retry_count++;
if (retry_count >= max_retry) {
// 重试次数用尽,抛出最终异常
throw HttpException("请求失败:缓存重试" + to_string(max_retry) + "次后仍失败", 3003, method, path);
}
cout << "[HTTP模块] 临时错误," << max_retry - retry_count << "次重试机会,等待0.5秒..." << endl;
this_thread::sleep_for(chrono::milliseconds(500)); // 重试间隔0.5秒
} else {
// 其他缓存错误(非临时),直接抛出
throw;
}
}
// 5. 捕获永久错误(直接抛出,不重试)
catch (const Exception& e) {
throw;
}
}
}
扩展后运行效果(重试机制生效)
=== 本次请求处理开始 ===
[HTTP模块] 接收请求:GET /order/list,尝试获取缓存(第1次)...
[缓存模块] 缓存键/order/list已过期,尝试从SQL更新...
[SQL模块] 执行成功,SQL:UPDATE cache_data SET value = new_val WHERE key = '/order/list'
[HTTP模块] 临时错误,2次重试机会,等待0.5秒...
[HTTP模块] 接收请求:GET /order/list,尝试获取缓存(第2次)...
[缓存模块] 缓存键/order/list已过期,尝试从SQL更新...
[SQL模块] 执行成功,SQL:UPDATE cache_data SET value = new_val WHERE key = '/order/list'
[HTTP模块] 临时错误,1次重试机会,等待0.5秒...
[HTTP模块] 接收请求:GET /order/list,尝试获取缓存(第3次)...
[缓存模块] 获取成功,缓存键:/order/list
=== 本次请求处理结束,等待1秒...
=== 本次请求处理开始 ===
[HTTP模块] 接收请求:GET /goods/detail,尝试获取缓存(第1次)...
[缓存模块] 缓存键/goods/detail不存在,尝试从SQL加载...
[SQL模块] 执行成功,SQL:SELECT * FROM cache_data WHERE key = '/goods/detail'
[HTTP模块] 临时错误,2次重试机会,等待0.5秒...
[HTTP模块] 接收请求:GET /goods/detail,尝试获取缓存(第2次)...
[缓存模块] 缓存键/goods/detail不存在,尝试从SQL加载...
[SQL模块] 执行成功,SQL:SELECT * FROM cache_data WHERE key = '/goods/detail'
[HTTP模块] 临时错误,1次重试机会,等待0.5秒...
[HTTP模块] 接收请求:GET /goods/detail,尝试获取缓存(第3次)...
[缓存模块] 缓存键/goods/detail不存在,尝试从SQL加载...
[SQL模块] 执行成功,SQL:SELECT * FROM cache_data WHERE key = '/goods/detail'
=== 捕获到异常 ===
HttpException:[2024-05-20 15:35:10] 错误ID:3003,描述:请求失败:缓存重试3次后仍失败,请求方法:GET,路径:/goods/detail
=== 本次请求处理结束,等待1秒...
重试机制的核心是 “基于错误 ID 分类处理”—— 通过e.getid()
判断异常是否为 “临时错误”,仅对临时错误重试,避免对永久错误做无效重试,既提升了服务成功率,又保证了处理效率。
十、C++ 异常总结
通过前面的学习,我们从基础概念到实战落地,全面掌握了 C++ 异常处理的核心知识。这里我们对关键内容进行梳理,形成完整的知识体系。
10.1 异常的核心价值
- 解耦错误检测与处理:异常允许 “检测错误的模块” 和 “处理错误的模块” 独立开发,检测模块只需抛出异常,无需关心处理逻辑;
- 错误信息丰富:异常以对象形式存在,可携带时间戳、错误 ID、业务上下文(如 SQL 语句、请求路径),远超 C 语言错误码的信息承载能力;
- 强制错误传播:未捕获的异常会沿调用链自动传播,避免 “错误被忽略” 导致的隐性 bug;
- 保障服务稳定性:通过
catch(...)
兜底和重试机制,可确保程序即使触发异常也不终止,符合服务端持续运行的需求。
10.2 异常使用的关键规则
抛出规则:
- 抛出的异常对象建议是 “拷贝语义安全” 的(避免抛出局部对象的引用);
- 优先抛出自定义异常(继承自
Exception
或标准库std::exception
),避免抛出内置类型(如int
、const char*
),便于统一处理; - 重新抛出时使用
throw;
,而非throw e;
,避免 “切片问题” 导致子类信息丢失。
捕获规则:
catch
的顺序是 “子类在前,父类在后”,避免父类catch
屏蔽子类catch
;- 优先使用 “引用捕获”(
const Exception& e
),避免异常对象拷贝,同时支持多态; - 必须在
main
函数中添加catch(...)
兜底,避免未知异常导致程序终止。
异常安全规则:
- 用 RAII 机制(如智能指针、自定义资源管理类)管理资源,避免异常抛出时内存 / 文件句柄泄漏;
- 析构函数必须声明为
noexcept
,禁止异常逃离析构函数; - 避免在构造函数中做复杂操作,若必须做,需在
try-catch
中处理异常,避免构造出 “半初始化对象”。
异常规范规则:
- C++11 及以后优先使用
noexcept
声明 “不会抛出异常的函数”,帮助编译器优化,同时明确函数行为; - 不滥用
noexcept
:若函数内部可能抛出异常(或调用可能抛出异常的函数),禁止声明noexcept
,否则运行时会调用terminate()
终止程序; - 团队开发中,需明确函数的异常规范(是否抛出、抛出哪些类型),避免调用者因信息不足导致未处理异常。
- C++11 及以后优先使用
10.3 异常的适用场景与局限性
适用场景:
- 服务端程序:需持续运行,异常可确保程序不终止,配合重试机制提升成功率;
- 复杂业务系统:模块多、依赖关系复杂,异常可解耦错误处理逻辑,便于维护;
- 标准库 / 框架开发:如智能指针、容器(
vector
、string
),异常可传递 “内存不足”“索引越界” 等错误,便于上层处理。
局限性:
- 性能开销:异常处理会生成额外的栈展开代码,若在 “高频调用的函数”(如循环内的计算函数)中频繁抛出异常,会影响性能;
- 调试难度:异常沿调用链传播,若未正确记录上下文,定位异常根源会比错误码更复杂;
- 兼容性问题:C 语言不支持异常,若 C++ 代码需调用 C 语言库,需在接口层将 C 语言错误码转换为 C++ 异常,增加适配成本。
10.4 最佳实践建议
设计清晰的异常体系:
- 定义一个顶层基类
Exception
,包含通用错误信息(时间戳、错误 ID); - 按模块 / 业务场景设计子类(如
SqlException
、HttpException
),补充专属信息; - 子类重写
what()
方法,统一错误信息格式,便于日志输出和问题定位。
- 定义一个顶层基类
合理使用异常与错误码:
- 对 “运行时意外错误”(如内存不足、网络波动)使用异常;
- 对 “编译时可检测的逻辑错误”(如参数为空、索引越界)使用断言(
assert
)或返回错误码; - 对 “高频调用的简单函数”(如工具类函数),优先使用错误码,避免异常的性能开销。
完善异常日志:
- 在
catch
块中记录异常的完整信息(e.what()
),包括时间戳、调用栈、业务参数; - 对关键业务(如支付、订单),需将异常日志同步到监控系统,实时告警,快速响应。
- 在
持续测试异常场景:
- 编写单元测试时,需覆盖 “异常触发场景”(如模拟除零、内存不足、权限不足);
- 进行压力测试时,需模拟 “高异常率场景”,验证程序的稳定性和资源泄漏情况。
C++ 异常是一把 “双刃剑”—— 用得好可以大幅提升代码的可维护性和服务稳定性,用得不好则会引入性能问题和隐性 bug。只有深入理解其核心机制,遵循最佳实践,才能让异常成为开发中的 “助力”,而非 “负担”。