跳到主要内容
C++ 异常机制详解与实践指南 | 极客日志
C++
C++ 异常机制详解与实践指南 C++ 异常机制利用 try-catch-throw 实现错误传播,解决传统错误码层层返回的繁琐问题。通过栈展开机制自动查找匹配处理器,结合 RAII 思想可有效管理资源防止泄漏。工程实践中建议构建统一异常基类体系,利用多态简化捕获逻辑,并配合 noexcept 规范函数接口。顶层设置兜底捕获以保障服务稳定性,是现代 C++ 编写健壮代码的关键实践。
C++ 异常机制概述
在 C++ 学习过程中,异常(Exception) 是一个既重要又常被忽视的知识点。很多初学者更习惯使用 错误码 或者 断言 来处理问题,却很少系统地去理解和使用异常机制。事实上,异常的设计初衷,就是为了解决 C 语言时代'错误处理困难、代码可读性差'的痛点。
在现代软件开发中,系统往往具有更高的复杂性,函数调用链很长,模块之间紧密协作 。如果依赖传统的错误返回值,就需要'层层上传',一旦遗漏检查就可能导致严重的 bug;而直接使用 assert 终止程序,也会让用户难以接受。C++ 提供的 异常机制 恰好为我们提供了一个优雅的解决方案:
错误信息能够在调用链上自动传递 ;
外层可以通过 catch 块集中处理错误;
结合 RAII 思想,还能有效避免资源泄漏问题。
本文将带你系统学习 C++ 异常:从传统 C 风格错误处理 的不足说起,再到异常的语法规则 、抛出与捕获的过程 、异常安全与 RAII 、统一异常体系的设计 ,以及标准库提供的异常层次 。通过循序渐进的讲解与丰富的代码示例,你将能够从根本上理解 C++ 异常机制的工作原理,并在工程实践中做到'用得明白,用得规范'。
1. 传统 C 风格的错误处理:为何需要异常?
C 语言中错误处理 常见两种做法:
终止程序 (如 assert 为 false 时直接终止程序):缺陷 :并不能明确知道是什么错误,用户难以接收错误信息
内存错误(数组越界,未初始化/空指针/无效地址的访问,野指针,内存泄露,同一块空间释放多次等等)
除 0 错误,会直接终止程序
返回错误码 (需配合 errno),缺陷 :需要调用者层层检查和传递对应的错误,既繁琐又易出错
如系统的很多库的接口函数都是通过把错误码放到 errno 中,表示错误
实际工程里,多数选择返回错误码,极少数'致命错误'直接终止。
实际工程中,C 语言基本都是使用返回错误码的方式处理错误,部分情况下使用终止程序 处理非常严重的错误
小结: 当函数调用链很深时,错误码方案要求'层层返回',既污染业务逻辑,又容易遗漏。
异常机制正是为解决此类痛点而生,异常不会终止程序,并且会将错误信息详细介绍。
先感受一下异常:
double Division (int a, int b) {
if (b == 0 ) throw "Division by zero condition!" ;
else return ((double )a / (double )b);
}
{
len, time;
cin >> len >> time;
cout << (len, time) << endl;
}
{
{
();
} ( * errmsg) {
cout << << errmsg << endl;
} (...) {
cout << << endl;
}
;
}
void Func ()
int
Division
int main ()
try
Func
catch
const
char
"异常已捕获:"
catch
"unkown exception"
return
0
不使用异常,传统 C 语言直接终止程序会导致流程中断且无提示。
使用异常捕获除 0 错误,显示出异常的信息,程序可以继续运行或优雅退出。
2. C++ 异常的概念 异常是面向对象语言处理错误的一种方式 ,当一个函数发现自己无法处理的错误时,可以抛出异常,让函数的直接或间接的调用者 处理这个错误。异常的抛出和捕获由以下三个关键字 配合完成:
throw :当问题出现时,程序会抛出一个异常 (本质是抛出一个对象 )。抛异常使用 throw 关键字完成
try : try 块中包含可能出现异常的代码或者函数,
try 块中的代码被称为保护代码,放置可能抛出异常的代码,
catch(异常的类型) :跟在 try 块之后,用于捕捉异常。catch 关键字用于捕获异常,可以设置多个 catch 捕获 throw 抛出的不同类型的异常 (对象) 。
catch(...) 可以捕获任意类型的异常,用来捕获没有显示捕获类型的异常,相当于条件判断最后的 else
只有 try{ } 块中抛出了异常,才会执行 catch 中的代码
在想要处理问题的地方,通过异常处理程序捕获异常。
注意:不论 try 和 catch 块中有多少行代码,都必须加上 {}
try {
}
catch (ExceptionName e1) {
} catch (ExceptionName e2) {
} catch (...) {
}
throw抛出的是对象,对象的类型决定匹配到哪个 catch ;
可以有多分支;
catch(...) 可兜底但无法区分具体错误。
3. 异常的使用
3.1 异常的抛出和捕获
匹配原则
异常是通过抛出 对象而引发的,该对象的静态类型决定了应该激活哪个 catch 的处理代码 。
异常抛出后,匹配的 catch 处理代码是调用链中与该异常对象类型匹配且离抛出异常位置最近 的那一个。
抛出异常对象后,会生成一个异常对象的拷贝 。因为抛出的异常对象可能是一个临时对象 (匿名对象),所以会生成一个拷贝对象抛出。
catch 结束后该拷贝对象销毁(类比函数的按值返回)。
catch(. . .) 可以捕获任意类型的异常 ,但无法得知 异常的具体信息。抛出了异常但没有被捕获时,程序会被终止。因此需要 catch(...) 兜底
实际中抛出和捕获的类型并不是完全匹配,可以抛出派生类对象,使用基类捕获 ,便于统一处理与多态扩展(工程中非常实用)。
可用基类捕获派生类 原因是:派生类可以赋值给基类,基类的指针或引用可以指向派生类
异常调用链上的'栈展开'
首先检查抛出点 throw 本身是否在某个 try 块 内,如果是,尝试就近查找匹配 catch。
如果有匹配的,则跳转到 catch 的地方进行处理。
没有匹配的 catch ,退出当前函数栈帧 ,到调用者的函数栈帧中查找 匹配的 catch 语句。
一直查找至 main 函数的栈帧,如果依旧没有 catch 块匹配,则终止程序 ;
找到 catch 匹配并处理后,从当前栈帧的 catch 子句之后继续往下执行。
上述这个沿着调用链查找匹配的 catch 子句的过程称为栈展开 。
实践建议 :
顶层栈帧(通常是main 函数的栈帧 )加上一个 catch(...) 兜底,用于捕获任意类型的异常 ,避免异常漏出导致进程崩溃(程序直接终止) 。
按照函数调用链,一层一层往外找,直到找到匹配的 catch 块,直接跳到匹配的 catch 块执行,执行完 catch,会继续往 catch 块后面的语句执行。相当于没有找到匹配的函数栈帧被释放了。
3.2 异常的重新抛出 有时 catch 到一个异常对象后,需要对错误进行分类,其中的某种异常错误需要进行特殊的处理,其他错误则重新抛出异常给外层调用链处理。捕获异常后需要重新抛出时,直接 throw,就可以直接抛出。
下面程序模拟展示了聊天时发送消息,发送失败捕获异常,但是可能在电梯地下室等场景手机信号不好,则需要多次尝试。
如果多次尝试都发送不出去,则就需要捕获异常再重新抛出,其次如果不是网络差导致的错误,捕获后也要重新抛出。
void _SeedMsg(const string& s) {
if (rand () % 2 == 0 ) throw HttpException ("网络不稳定,发送失败" , 102 , "put" );
else if (rand () % 7 == 0 ) throw HttpException ("你已经不是对象的好友,发送失败" , 103 , "put" );
else cout << "发送成功" << endl;
}
void SendMsg (const string& s) {
for (size_t i = 0 ; i < 4 ; i++) {
try {
_SeedMsg(s);
break ;
} catch (const Exception& e) {
if (e.getid () == 102 ) {
if (i == 3 ) throw ;
cout << "开始第" << i + 1 << "重试" << endl;
} else
throw ;
}
}
}
int main () {
srand (time (0 ));
string str;
while (cin >> str) {
try {
SendMsg (str);
} catch (const Exception& e) {
cout << e.what () << endl << endl;
} catch (...) {
cout << "Unkown Exception" << endl;
}
}
return 0 ;
}
在下面的代码场景里面,当 b==0 时,抛出异常,被 main 函数里面的 catch 捕获,代码直接跳到 main 函数里面执行,但是 Func 函数里面的 ptr1 没有被释放,怎么办?
double Divide (int a, int b) {
try {
if (b == 0 ) {
string s ("Divide by zero condition!" ) ;
throw s;
} else
return ((double )a / (double )b);
} catch (int errid) cout << errid << endl;
return 0 ;
}
void Func () {
int * ptr1 = new int [10 ];
int len, time;
cin >> len >> time;
cout << Divide (len, time) << endl;
delete [] ptr1;
cout << "delete:" << ptr1 << endl;
}
int main () {
while (1 ) {
try {
Func ();
} catch (const string& errmsg) {
cout << errmsg << endl;
} catch (...) {
cout << "未知异常" << endl;
}
}
return 0 ;
}
在 Func 函数中添加一个 try{}...catch{} 语句,并对异常进行重新抛出来解决
void Func () {
int * ptr1 = new int [10 ];
int len, time;
cin >> len >> time;
try {
cout << Divide (len, time) << endl;
} catch (...) {
delete [] ptr1;
cout << "delete:" << ptr1 << endl;
throw ;
}
delete [] ptr1;
cout << "delete:" << ptr1 << endl;
}
无异常时:catch 块中的代码不执行,下面的代码 delete[] ptr1 正确释放了 ptr1 指针
出现异常时:执行 catch 块中的代码,释放 ptr1,单条 throw 语句将异常重新抛出,让外层栈帧对该异常进行处理
单个 catch 不能完全处理时,可在完成必要清理后'重新抛出',将异常交给更外层处理 。在 C++ 中用于'原样抛出'的语法是 throw;。
3.3 异常安全与 RAII 思想
构造函数 :构造函数完成对象的构造和初始化 ,尽量不要抛异常,避免出现异常后执行流跳转 ,使对象处于不完整 状态。
析构函数 :尽量不要抛异常,执行流跳转后资源未被清理 ,可能导致资源泄漏 甚至 terminate。
典型风险:new/delete 之间抛异常,delete 未被执行导致内存泄漏;lock/unlock 之间抛异常,锁没有被释放导致死锁 。
解决思路:RAII (资源获取即初始化)——把资源放入对象,靠对象生命周期自动管理。
void Func () {
int * array = new int [10 ];
try {
int len, time;
std::cin >> len >> time;
std::cout << Division (len, time) << std::endl;
}
catch (...) {
std::cout << "出现异常时:delete []" << array << std::endl;
delete [] array;
throw ;
}
std::cout << "正常情况:delete []" << array << std::endl;
delete [] array;
}
'异常经常导致资源泄漏,RAII 是通用解法 (如智能指针管理内存,互斥量用守卫对象管理加解锁)'。
3.4 异常规范说明
为了让函数使用者 明确该函数'可能抛什么'异常,可在函数尾部给出异常规格 :
函数的后面加 throw() ,表示函数不抛异常。
void fun() throw(A, B, C, D); 函数仅可能抛出列出的异常类型;
void f() throw(); 表示不抛异常 ;
若无 异常接口声明,表示此函数可能抛出任意 类型的异常;
C++98 中的 std::exception 类的成员函数,后面跟 throw() 的函数,表示该函数不会抛出异常
标准库中曾使用过:operator new 可能抛 std::bad_alloc;operator delete 声明不抛异常;
但是上述 C++98 的异常接口的声明的方式存在问题:如果这个函数的可能会抛出的异常类型很多,那么就会造成使用者书写繁琐的问题。所以这种方式的使用几乎很少
C++11 引入 noexcept 表示不抛异常,如 thread() noexcept;,thread(thread&&) noexcept;。
void fun () throw (A, B, C, D) ;
void * operator new (std::size_t size) throw (std::bad_alloc) ;
void * operator new (std::size_t size, void * ptr) throw () ;
void * operator new (std::size_t size, void * ptr) noexcept ;
现代 C++ 已弃用 旧式 throw(TypeList) 规格而统一到 noexcept,但理解其设计意图有助于阅读旧代码。
4. 自定义异常体系——统一管理
1. 为什么要自定义统一体系 为什么要自定义? 如果多个不同模块的 团队'随心所欲'地抛各种类型(int、const char*、自定义结构体…),外层就很难一网打尽。最佳实践是统一继承自共同基类 ,外层只需捕获基类 对象,通过派生类虚函数的重写 ,实现异常的多态处理 。
2. 常用自定义体系
异常类继承层次 class Exception {
public :
Exception (const std::string& errmsg, int id) : _errmsg(errmsg), _id(id) {}
virtual std::string what () const { return _errmsg; }
protected :
std::string _errmsg;
int _id;
};
class SqlException : public Exception {
public :
SqlException (const std::string& errmsg, int id, const std::string& sql) : Exception (errmsg, id), _sql(sql) {}
std::string what () const override { return "SqlException:" + _errmsg + "->" + _sql; }
private :
const std::string _sql;
};
class CacheException : public Exception {
public :
using Exception::Exception;
std::string what () const override { return "CacheException:" + _errmsg; }
};
class HttpServerException : public Exception {
public :
HttpServerException (const std::string& errmsg, int id, const std::string& type) : Exception (errmsg, id), _type(type) {}
std::string what () const override { return "HttpServerException:" + _type + ":" + _errmsg; }
private :
const std::string _type;
};
处理函数 void SQLMgr () {
srand (time (0 ));
if (rand () % 7 == 0 ) {
throw SqlException ("权限不足" , 100 , "select * from name = '张三'" );
}
}
void CacheMgr () {
srand (time (0 ));
if (rand () % 5 == 0 ) throw CacheException ("权限不足" , 100 );
else if (rand () % 6 == 0 ) throw CacheException ("数据不存在" , 101 );
SQLMgr ();
}
void HttpServer () {
srand (time (0 ));
if (rand () % 3 == 0 ) throw HttpServerException ("请求资源不存在" , 100 , "get" );
else if (rand () % 4 == 0 ) throw HttpServerException ("权限不足" , 101 , "post" );
CacheMgr ();
}
捕获模块 int main () {
while (1 ) {
std::this_thread::sleep_for (std::chrono::seconds (1 ));
try {
HttpServer ();
} catch (const Exception& e) {
std::cout << e.what () << std::endl;
} catch (...) {
std::cout << "Unkown Exception" << std::endl;
}
}
return 0 ;
}
利用继承 和多态 ,我们最终只需写两个 catch 模块:
catch (const Exception& e):只捕获基类异常,基类的引用可以指向多个派生类的异常
基类的引用指向派生类对象时,通过虚函数的重写 ,就实现了指向哪个模块,就调用哪个异常模块
catch (. . .):捕获任意类型的异常作为兜底 ,防止异常抛出后未被捕获导致程序终止
这个设计的好处:统一入口、统一上下文信息(错误码、信息、上下文如 SQL 语句或 HTTP 方法),且易于扩展 。
5. C++ 标准库中的异常体系 标准库提供了以 std::exception 为基类的异常层次(如 std::bad_alloc、std::out_of_range 等)。
我们可以在工程里继承 exception 实现自己的自定义异常类
我们想要捕获标准库中的异常 ,只需 catch (const std::exception& e){ } 即可
因为标准库中的各种异常类,都是 std::exception 的派生类,基类的指针或引用指向派生类,可以实现多态调用
但很多公司更愿意自建一套 统一、好用的异常体系(见上节),在顶层捕获基类即可。因为 C++ 标准库设计的不够好用
try {
std::vector<int > v (10 , 5 ) ;
v.reserve (1000000000 );
v.at (10 ) = 100 ;
} catch (const std::exception& e) {
std::cout << e.what () << std::endl;
} catch (...) {
std::cout << "Unkown Exception" << std::endl;
}
要点 :对 STL/第三方库,我们应当了解其抛出的异常类型 并据此捕获,或在外层用统一基类处理。
6. 异常的优缺点
相比错误码的方式,设计好的异常对象可清晰携带各种错误信息 ,甚至包含堆栈调用信息 ,定位程序的 bug 更高效
传统返回错误码的方式有个很大的问题:在函数调用链中,深层的函数返回了错误,那么我们需要层层返回错误,最外层才能拿到错误。异常有效地避免了错误码的'层层返回',外层直接在 catch 统一处理;
大量三方库(如 Boost、gtest/gmock 等)都使用异常,那么我们使用这些库也需要学习使用异常
某些函数签名不便返回错误 (如构造函数、引用返回的 operator 等),异常更合适。比如:
构造函数没有返回值,不方便使用错误码方式处理。
T& operator[](size_t pos) 这样的函数,如果 pos 越界了只能使用异常或者终止程序处理,无法通过返回值表示错误
层层返回错误码的缺陷 :
给出对照:
错误码方案 :底层 ConnectSql 出错 → 返回给 ServerStart → 再返回 main → main 再处理;
异常方案 :在调用链任意层 抛异常,直接跳到顶层 catch ,由 main 集中处理即可。
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 ) ...
return 0 ;
}
异常会导致程序的执行流乱跳转 ,并且是运行时出错抛异常就会乱跳转 。这会给我们跟踪调试以及分析程序时带来困难。
存在一定性能开销 (一般可忽略),性能开销来源于要返回对象的拷贝 。现代硬件速度很快,这个影响基本忽略不计。
C++ 没有垃圾回收机制,资源需要自己管理。异常非常容易导致内存泄漏、死锁等异常安全问题 。这个需要使用 RAII 来处理资源的管理问题,学习难度较高。
C++ 标准库的异常体系定义得不好 ,导致大家各自定义各自的异常体系,生态不统一。
C++ 并没有规定异常的使用规范 。随意抛异常,外层不好捕获,所以尽量按照异常规范使用 。规范有两点:
抛出异常类型为派生类,都继承自一个基类 。
函数是否抛异常、抛什么异常 ,都在函数后面加上 throw() 的方式规范化。
结论 :异常总体'利大于弊 ',工程上鼓励使用异常 ,前提是严格规范 与RAII 落地。
7. 结语
异常并不是一个'锦上添花'的语法,而是 C++ 提供的一套完整的错误处理机制 。掌握它,意味着你能够在项目中写出更安全、更健壮、更具可维护性的代码。
C 风格错误处理的缺陷,以及异常存在的意义;
try、catch、throw 的基本语法与调用链上的栈展开;
异常重新抛出、异常安全与 RAII 的重要性;
如何设计一套统一的异常体系,让错误处理更加可控;
C++ 标准库中异常体系的应用,以及它与工程实践的结合。
统一异常体系 :项目内统一从共同基类 继承(如 Exception),最外层捕获基类即可;不要 随意抛基本类型或字符串。
保留兜底 :在顶层(线程入口/main)准备 catch(...) 兜底,保证服务稳定性。
只在能修复的地方捕获 :不能彻底处理时,清理后 throw; 重新抛出 ,不要'吞掉'异常。
异常安全与 RAII :任何'获取—释放'对,都优先用 RAII 封装;构造/析构尽量不抛异常。
对外接口的契约 :明确'是否抛异常/可能抛何种异常',现代 C++ 统一用 noexcept 传达'不抛'语义。
与标准库/三方库协作 :了解其异常类型;外层以 std::exception 或自家基类做统一拦截与日志。
总结一句话:异常是把双刃剑,若能合理设计并配合 RAII 使用,它会让你的程序更健壮;若滥用或忽视规范,它反而会成为隐藏 bug 的温床。
以上就是本文的所有内容了,如果觉得文章对你有帮助,欢迎 点赞⭐收藏 支持!如有疑问或建议,请在评论区留言交流,我们一起进步
分享到此结束啦 一键三连,好运连连!你的每一次互动,都是对作者最大的鼓励! 征程尚未结束,让我们在广阔的世界里继续前行! 🚀
相关免费在线工具 Base64 字符串编码/解码 将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
Base64 文件转换器 将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
Markdown转HTML 将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
HTML转Markdown 将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
JSON 压缩 通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
JSON美化和格式化 将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online