【C++】C++ 的护身符:解锁 try-catch 异常处理
C++语法 | 相关知识点 | 可以通过点击 | 以下链接进行学习 | 一起加油! |
---|---|---|---|---|
命名空间 | 缺省参数与函数重载 | C++相关特性 | 类和对象-上篇 | 类和对象-中篇 |
类和对象-下篇 | 日期类 | C/C++内存管理 | 模板初阶 | String使用 |
String模拟实现 | Vector使用及其模拟实现 | List使用及其模拟实现 | 容器适配器Stack与Queue | Priority Queue与仿函数 |
模板进阶-模板特化 | 面向对象三大特性-继承机制 | 面向对象三大特性-多态机制 | STL 树形结构容器 | 二叉搜索树 |
AVL树 | 红黑树 | 红黑树封装map/set | 哈希-开篇 | 闭散列-模拟实现哈希 |
哈希桶-模拟实现哈希 | 哈希表封装 unordered_map 和 unordered_set | C++11 新特性:序章 | 右值引用、移动语义、万能引用实现完美转发 | 可变参数模板与emplace系列 |
Lambda表达式、包装器与绑定的应用 |
这篇文章深入探讨了 C++ 中的异常处理机制,尤其是通过 try-catch 结构来管理运行时错误。文章首先回顾了 C 语言中常见的错误处理方式,然后通过形象的外卖点餐场景,帮助读者理解不同类型错误的合理应对方式。接着,文章详细介绍了 C++ 异常的基本概念和使用方法,解析了 throw、try 和 catch 关键字的作用,并探讨了如何在函数调用链中优雅地处理异常。最后,还提出了自定义异常体系和 C++ 标准库的异常处理方式。
文章目录
- 一、C 语言中传统的错误处理方式
- 二、用“外卖点餐”来理解错误处理
- 2.1 情况一 | 客户端错误
- 2.2 情况二 | 程序自身的问题
- 2.3 情况三 | 环境问题
- 三、C++异常处理简介
- 3.1 三个关键字:try / throw / catch
- 四、异常的使用
- 4.1 异常的抛出与匹配规则
- 4.1.1 throw可以抛出任意类型的对象
- 4.1.2 异常处理的“就近原则”
- 4.1.3 找不到匹配的 catch
- 4.1.4 异常对象的拷贝机制
- 4.1.5 catch(...) 与未捕获异常的处理
- 4.2 在函数调用链中异常栈展开匹配原则
- 4.3 异常的重新抛出
- 4.4 异常安全
- 4.4.1 抛异常出现内存泄漏
- 4.4.2 异常安全的基本原则
- 4.5 异常规范(Exception Specification)
- 五、自定义异常体系
- 六、C++标准库的异常体系
- 6.1 std::exception异常继承实操
- 七、 C++ 异常的优缺点
- 7.1 异常优点
- 7.2 异常缺点
一、C 语言中传统的错误处理方式
- 终止程序(如
assert
)
- 优点:适用于开发阶段快速发现严重错误。
- 缺点:用户体验差,程序在运行时一旦遇到严重错误(如内存访问违规、除以零),会立即终止,难以接受。
- 返回错误码(如通过
errno
)
- 优点:灵活,允许程序继续运行,适合错误可恢复的场景。如系统的很多库的接口函数都是通过把错误码放到errno中,表示错误。
- 缺点:程序员需手动检查返回值并查找错误码含义,增加了开发复杂度。
在实际开发中,C 语言主要采用返回错误码的方式进行错误处理。对于一些致命错误(如数组越界),虽然属于运行时行为,但如果被编译器静态检查到,往往会强制终止程序以避免更严重的问题。
二、用“外卖点餐”来理解错误处理
想象你正在用手机点外卖,点的是一份奶茶。整个点餐的过程就像一个运行中的程序,每一个操作(比如选择口味、下单、付款)都可能出错。如果出错了就直接关闭整个APP,那你肯定会觉得这个软件太差劲了,对吧?
所以我们来看看,具体会遇到哪些“错误”,程序应该怎么合理地处理它们。
2.1 情况一 | 客户端错误
【场景】:余额不足,付款失败
你下单准备付款,但微信钱包里没钱了。如果这时程序直接崩溃或退出,那你可能连点别的奶茶的机会都没有了,非常不合理。
【 正确做法】:提示“余额不足”,引导你去充值或者换付款方式。 这个错误是可以预料并处理的客户端错误
2.2 情况二 | 程序自身的问题
【场景】:点击“支付”按钮没有反应
你点了“支付”,但是页面没动。这时候可能是程序写得有问题,按钮绑定错了,或者后端接口挂了。你作为用户也许看不懂程序错误日志,但开发者需要知道这里出问题了。
【正确做法】:程序不崩溃,但把这个错误悄悄记录到日志里,方便程序员以后排查。
2.3 情况三 | 环境问题
【场景】::网络不好,订单没发出去
你在地铁里网不好,付款的时候一直转圈圈。程序应该不会立刻提示“失败”,而是先尝试重新连接几次,实在不行了,再告诉你“网络连接失败,请稍后重试”。
【正确做法】:设置重试机制,允许等待→重连→最终提示失败
这样的流程。属于环境导致的问题,可以尝试恢复处理
一个健壮的程序,应该像一个优秀的服务员,面对突发状况不会慌张,而是冷静地根据情况选择恰当的应对策略。
三、C++异常处理简介
异常(Exception)是一种用于处理程序运行中出现错误的机制。当一个函数在执行过程中发现自己无法处理的问题时,它可以通过抛出异常来将错误交由其调用者(直接或间接)处理。
3.1 三个关键字:try / throw / catch
【throw | 抛出异常】
当程序检测到某个问题无法继续执行时,可以使用 throw
语句将异常抛出。
可以抛出任何类型的异常对象(如整数、字符串、自定义类等)。
【try | 捕获尝试】
将可能抛出异常的代码块包裹在 try
块中。如果在该块中抛出了异常,程序会跳转到匹配的 catch
块执行。
【catch | 捕获处理】
用于处理 try
块中抛出的异常。可以定义多个 catch
块,分别捕获不同类型的异常。
如果没有匹配的
catch
块,异常将继续向上传递,直到被捕获或导致程序终止。
try
{// 受保护的代码:这里放可能抛出异常的语句
}
catch (const ExceptionType1& e) // ← 捕获第 1 类异常
{// TODO: 针对 ExceptionType1 的处理
}
catch (const ExceptionType2& e) // ← 捕获第 2 类异常
{// TODO: 针对 ExceptionType2 的处理
}
// ...
catch (const ExceptionTypeN& e) // ← 捕获第 N 类异常
{// TODO: 针对 ExceptionTypeN 的处理
}
/* 可选:兜底捕获,防止漏网之鱼
catch (...)
{// TODO: 处理所有未被前面 catch 捕获的异常
}
*/
四、异常的使用
4.1 异常的抛出与匹配规则
4.1.1 throw可以抛出任意类型的对象
C++ 编译器在运行时会根据你 throw
的对象的类型,去调用链中寻找第一个匹配的 catch 块。匹配规则和函数参数传递类似,是基于类型兼容的匹配。
【关键理解】:
throw
后面的对象类型决定了哪个catch
能处理它。catch
是类型敏感的,不支持自动类型转换,例如throw 3.14
无法被catch(int)
捕获。- 类型可以是引用,也可以是对象,但推荐使用
const 引用
以避免拷贝。
4.1.2 异常处理的“就近原则”
当程序中通过 throw
抛出一个异常时,C++ 会在调用栈中自下而上寻找一个类型匹配的catch块来处理这个异常。第一个匹配成功的 catch 块将会被激活,其他的将被忽略。
【就近原则】 :异常总是由“离抛出位置最近、类型匹配”的 catch
块处理。
【场景】:多个函数嵌套,异常向上传播直到就近匹配
void inner()
{throw std::string("Error: file not found");
}
void middle()
{inner(); // 没有 try-catch,异常会继续向上传播
}
void outer()
{try{middle();}catch (const std::string& e){std::cout << "Caught string in outer(): " << e << '\n';}
}
int main()
{outer();
}
main → outer() → middle() → inner()
↑ ↑
try-catch? throw
4.1.3 找不到匹配的 catch
- 异常会沿着调用栈一路向上传播
- 如果直到
main()
都没人处理,程序会调用std::terminate()
立即崩溃- 因此,建议在最外层程序入口处设置兜底的 catch (…)来防止程序异常退出
4.1.4 异常对象的拷贝机制
在 C++ 中,使用 throw
抛出异常对象时,通常会发生一次对象的拷贝或移动。这是因为异常对象的生命周期需要延长:从 throw
抛出开始,直到被 catch
块捕获并处理完毕。
这种处理方式类似于函数的按值传参和返回过程。所幸在现代 C++ 中,如果异常类型支持右值引用和移动构造(例如 std::string
),那么这一步通常会通过移动构造完成,几乎不会带来额外的深拷贝开销。
4.1.5 catch(…) 与未捕获异常的处理
在 C++ 中,catch (...)
是一个特殊的捕获形式,它可以捕获任何类型的异常,无论是基本类型、标准库对象,还是用户自定义类型。
结果:虽然不知道是
double
类型,但异常不会导致程序崩溃。
【局限性】:catch(...)
无法提供异常的具体信息,也无法访问抛出的对象,因此你无法得知异常的类型或内容,只能作通用处理或记录。
【使用建议】:catch(...)
可以作为异常处理的兜底机制,用于捕获所有类型的异常,防止程序异常崩溃。但它无法获取异常的具体信息,不能做有针对性的处理,因此不应过度依赖。
建议仅在程序的最外层(如 main
函数或线程入口)使用 catch(...)
做统一的日志记录或友好退出,而在正常逻辑中,应优先使用类型明确的 catch
来处理已知异常类型。
4.2 在函数调用链中异常栈展开匹配原则
当异常被抛出时,程序会先检查 throw
是否在 try
块内部,若是,则沿调用链向上查找匹配的 catch
。如果当前函数没有匹配的 catch
,则退出当前函数,继续在上层函数中查找,这个过程称为栈展开。
如果一直到 main
函数都找不到匹配的处理代码,程序将被终止。因此,实际中建议在程序入口处添加 catch(...)
来兜底异常。异常一旦被捕获,程序将从对应的 catch
块后继续执行。
4.3 异常的重新抛出
在实际开发中,一个
catch
块可能无法完全处理某个异常,此时可以先进行局部处理(如日志记录、资源清理等),然后将异常重新抛出,交由更高层的调用者继续处理。
这称为异常的重新抛出,通常用于确保异常信息不会被吞掉,且能得到更合适的处理。
【示例演示】:
void inner()
{throw std::string("inner error");
}
void middle()
{try {inner();}catch (const std::string& e){std::cout << "main() caught: " << e << '\n';throw;}
}
int main()
{try {middle();}catch (const std::string& e){std::cout << "main() caught again: " << e << '\n';}
}
【使用要点】:
- 使用
throw;
(不带对象)可以将当前捕获的异常原样抛出;- 重新抛出前可以做一些局部处理(如资源释放、防止内存泄漏);
- 不建议用
throw e;
(抛出变量),那样会复制异常对象,可能丢失原始类型信息。
4.4 异常安全
4.4.1 抛异常出现内存泄漏
【示例演示】:
double Division(int len, int time)
{if (time == 0){throw "除0错误";}return (double)len / (double)time;
}void Func()
{int* array1 = new int[10]; // 动态分配资源try{int len, time;cin >> len >> time;cout << Division(len, time) << endl;}catch (const char* errmsg){cout << "delete [] " << array1 << endl;delete[] array1; // 清理资源throw errmsg; // 重新抛出异常}cout << "delete [] " << array1 << endl;delete[] array1; // 正常释放资源
}int main()
{try{Func();}catch (const char* errmsg){cout << errmsg << endl;}return 0;
}
【分析说明】
如果
Division
抛出异常,程序会跳转到catch
块;在
catch
中先释放了动态数组,再用throw
将异常重新抛出;如果没有
catch
块手动释放,程序跳过后面的delete[]
,就会导致内存泄漏;
这说明:在异常发生前分配的资源,如果不能在异常路径上正确释放,就会造成资源泄露,这就是典型的异常安全问题。这个处理方法相对来说并不能解决本质问题,如果有多个这种这种情况,就得做多次处理。
4.4.2 异常安全的基本原则
- 构造函数中尽量避免抛出异常,否则对象可能未完全构造,使用时容易出错。
- 析构函数中不要抛出异常,否则在对象销毁过程中可能导致资源无法正确释放。
- C++ 中异常容易导致资源泄漏,例如
new
后异常未能delete
,或lock
后异常未能unlock
,严重时会造成内存泄漏或死锁。 - 推荐使用 RAII(资源获取即初始化)思想,用对象生命周期管理资源,如使用智能指针和锁管理类,自动释放资源,避免人为遗漏。
4.5 异常规范(Exception Specification)
异常规范用于声明一个函数可能抛出哪些异常类型,目的是让函数调用者有所预期。但需要注意:C++ 的异常规范不是强制机制,而是一种“道德规范”。
【常见形式】:
// 这里表示这个函数会抛出A/B/C/D中的某种类型的异常
void fun() throw(A,B,C,D);
// 这里表示这个函数只会抛出bad_alloc的异常
void* operator new (std::size_t size) throw (std::bad_alloc);
// 这里表示这个函数不会抛出异常
void* operator delete (std::size_t size, void* ptr) throw();
// C++11 中新增的noexcept,表示不会抛异常
thread() noexcept;
thread (thread&& x) noexcept;
【潜在问题】:
尽管 C++ 提供了异常规范(如 throw()
和 noexcept
),但在实际中它们更像是一种“道德约定”,而非强制规则:
- 即使你声明函数不抛异常,编译器通常也不会严格检查,因为完整分析调用链的成本很高,尤其在大型工程中几乎不可行。
- 所谓“道德规范”,是指语言设计者假设开发者会自觉遵守规范,但并不会强制执行。然而,现实中总有人违反规则。
【解决措施】:
在复杂项目中,异常规范往往难以写全、写对。为简化这类问题,C++11 引入了 noexcept
,统一异常声明风格:
- 使用
noexcept
表示函数不会抛异常; - 如果不写,默认该函数可能抛异常。
五、自定义异常体系
在实际工程开发中,异常处理非常常见,但也容易出现以下问题:
- 项目庞大、多人协作,异常风格难以统一;
- C++ 中
throw
可以抛出任意类型,如果缺乏规范,容易出现异常未被正确捕获,导致程序崩溃;- 随意抛出各种类型的异常,调用者难以处理、问题难以定位,调试成本高。
为了解决这些问题,很多公司或大型项目会选择自定义异常体系,统一管理异常行为。
【常见做法】:
- 定义一个统一的异常基类,如
BaseException
;- *大家抛出的都是继承的派生类对象,捕获一个基类即可
- 同时可通过多态机制,获取具体的错误信息或类型。
【示例演示】:
#include <iostream>
#include <string>
#include <sstream>
#include <cstdlib>
#include <ctime>
#include <windows.h>using namespace std;// 通用异常基类
class Exception {
public:Exception(const string& errmsg, int id): _errmsg(errmsg), _id(id) {}virtual string what() const {return _errmsg;}protected:string _errmsg;int _id;
};// SQL 异常
class SqlException : public Exception {
public:SqlException(const string& errmsg, int id, const string& sql): Exception(errmsg, id), _sql(sql) {}string what() const override {ostringstream oss;oss << "SqlException: " << _errmsg << " -> " << _sql;return oss.str();}private:string _sql;
};// 缓存异常
class CacheException : public Exception {
public:CacheException(const string& errmsg, int id): Exception(errmsg, id) {}string what() const override {return "CacheException: " + _errmsg;}
};// HTTP 异常
class HttpServerException : public Exception {
public:HttpServerException(const string& errmsg, int id, const string& type): Exception(errmsg, id), _type(type) {}string what() const override {ostringstream oss;oss << "HttpServerException: [" << _type << "] " << _errmsg;return oss.str();}private:string _type;
};// 模拟 SQL 服务
void SQLMgr() {if (rand() % 7 == 0) {throw SqlException("权限不足", 100, "SELECT * FROM users WHERE name = '张三'");}
}// 模拟缓存服务
void CacheMgr() {if (rand() % 5 == 0) {throw CacheException("缓存权限不足", 200);} else if (rand() % 6 == 0) {throw CacheException("缓存中找不到数据", 201);}SQLMgr();
}// 模拟 HTTP 服务
void HttpServer() {if (rand() % 3 == 0) {throw HttpServerException("请求资源不存在", 300, "GET");} else if (rand() % 4 == 0) {throw HttpServerException("访问权限不足", 301, "POST");}CacheMgr();
}// 主函数:统一捕获异常
int main() {srand((unsigned int)time(0));while (true) {Sleep(500); // 模拟服务器循环处理请求try {HttpServer();}catch (const Exception& e) {cout << "捕获异常: " << e.what() << endl;}catch (...) {cout << "未知异常发生" << endl;}}return 0;
}
六、C++标准库的异常体系
C++ 标准库提供了一套定义在 <exception>
头文件中的标准异常类,这些异常按照继承关系组织成一个层次结构。我们可以在程序中直接使用这些标准异常类型来处理常见错误。
下面是 C++ 标准异常类的继承体系结构图:
6.1 std::exception异常继承实操
实际上,我们也可以通过继承
std::exception
来实现自己的异常类。但在实际工程中,很多公司更倾向于像前面那样自定义一套异常继承体系,这是因为 C++ 标准库提供的异常类在功能上相对简单,难以满足复杂系统的需求。
因此,这里我们不再展开对标准异常的介绍,下面给出一个简单的测试代码作为对比。
【示例演示】:
#include <iostream>
#include <exception>void LoadFile(const std::string& filename) {// 抛出一个标准异常throw std::runtime_error("File not found: " + filename);
}int main() {try {LoadFile("config.json");} catch (const std::exception& e) {// 只能获取字符串描述,无法区分错误类型、来源模块等std::cout << "Error: " << e.what() << std::endl;}return 0;
}
【分析代码】:
- [std::runtime_error] :这就创建了一个异常对象,内部保存了这个字符串,程序就中断并进入
try-catch
流程。 - [e.what()] :
e.what()
是std::exception
类中的一个 虚成员函数,返回一个const char*
类型的字符串,表示异常的描述信息。
【统一接口捕获,利用多态的特性】:
catch (const std::exception& e)
你是用引用捕获异常对象,无论你抛的是
std::runtime_error
、std::logic_error
还是其他std::exception
的子类,都能用这个统一的接口来获取错误信息。
七、 C++ 异常的优缺点
7.1 异常优点
- 【信息表达清晰】
异常对象可以封装丰富的错误信息,相比传统错误码方式更具表达力,甚至可包含堆栈信息,便于定位 Bug。
- 【避免层层传递错误码】
传统错误处理需要在函数调用链中逐层返回错误码,写法冗余且易出错;而异常机制能自动中断执行流,直接跳转到最外层
catch
块,简化了错误处理逻辑。int ConnectSql() {if (...) return 1; // 用户名错误if (...) return 2; // 权限不足 }int ServerStart() {if (int ret = ConnectSql() < 0)return ret;int fd = socket();if (fd < 0)return errno; }int main() {if (ServerStart() < 0)... // 错误处理 }
若使用异常,出错可直接跳转至
main()
中的catch
,无需逐级传递。
- 【与第三方库兼容性好】
很多主流 C++ 库(如 Boost、gtest、gmock 等)都广泛使用异常机制,使用这些库时也必须具备异常处理能力。
- 【适用于无法返回错误码的场景】
某些函数(如构造函数、重载
operator[]
等)无法通过返回值传递错误,使用异常能更自然地处理错误情况。
7.2 异常缺点
【执行流乱跳,调试困难】
异常会在程序运行时打断正常的控制流,使得执行流程变得混乱。这使得调试和分析程序变得困难,尤其是在异常传播较深时,定位问题较为复杂。【性能开销】
异常机制会带来一定的性能开销。虽然在现代硬件上,这种开销已经微乎其微,但在高性能要求的场景(如游戏开发或实时系统)中,仍然需要谨慎使用。【资源管理复杂,易导致内存泄漏】
C++ 没有垃圾回收机制,资源的管理完全依赖开发者。在异常机制下,如果资源管理不当,可能导致内存泄漏、死锁等问题。因此,必须使用 RAII(资源获取即初始化)来保证资源的正确管理,这增加了学习成本。【C++ 标准库的异常体系设计不够完善】
C++ 标准库的异常体系较为简化,无法提供更详细的错误信息(如错误码、模块来源等),导致开发者往往需要自定义异常体系,这样的做法使得异常处理在不同项目间变得更加混乱。【异常使用规范不明确,可能导致维护困难】
异常机制需要严格规范使用,否则会增加维护的难度。随意抛出异常或不合理的异常设计,可能使得外层捕获异常的用户遭遇极大的困扰。为了避免这种问题,应遵循以下两点规范:
- 抛出的所有异常类型应统一继承自一个基类;
- 函数是否抛出异常、抛出什么异常,应使用
noexcept
明确标注。