以下内容为本人的烂笔头,如需要转载,请全文无改动地复制粘贴,原文链接 微信公众号「ENG八戒」https://mp.weixin.qq.com/s/WC8CThJ77oHMsCSH0CBzsQ
在过去几十年的编程历史中,异常处理的演变仿佛一场文明的进化史,它不仅仅是技术的革新,更是编程思想与哲学的深刻体现。
从古早的错误码时代,程序员们在代码的荒野中艰难跋涉,每一个错误都需要手动检查,仿佛在茫茫大海中寻找失落的宝藏。
那时,程序的健壮性如同脆弱的瓷器,一触即碎。
直到 C++ 及其它现代编程语言的兴起,异常处理机制如同一场技术革命,为虚拟世界带来了前所未有的韧性与优雅。它借鉴了古老的战争哲学——“让情报流动”,如同古代战争中传递烽火信号,迅速将问题通知给能处理它的机构。
这不仅是对错误处理方式的革新,更是编程思维的一次飞跃,从被动防御转向了主动管理复杂性。
想象一下中世纪的城堡,坚固的城墙(try 块)围护着核心区域,当外来侵扰(异常)发生时,哨兵(catch 块)立即响应,或直接抵御,或发出警报,甚至调用援军(再次抛出异常)。
这便是 C++ 异常处理机制的形象比照,它不仅是一种技术手段,也是编程智慧与人类文明的结晶。
就如同罗马帝国修建的庞大道路网,让信息与物资得以高效流通,C++ 的异常处理机制确保程序在遇到不可预知的挑战时,能灵活应对,排除错误,确保核心逻辑的顺畅执行。
每一次异常的抛出与捕获,都是对程序健壮性的一次检验。正如历史长河中,文明在一次次危机中涅槃重生,变得更加坚强。
现在,让八戒带领大家一同踏入这场编程史上的伟大征程,从如何优雅地抛出第一个异常,到如何智慧地布局 try 与 catch,再到如何巧妙地减少 try 块的冗余,每一步都是对过往智慧的致敬,也是对未来编程艺术的探索。
这个系列的主题会分为多篇文章推送,感兴趣的读者朋友记得点赞收藏,如能多多转发让更多朋友受益,笔者不胜感激!
如何抛出异常?
抛出异常信号时,对信号的要求不多,比较灵活,一般建议抛出对象,无论是内置的数据类型,还是自定义类,最佳建议是基于 std::exception 的实例(包括派生类的实例)。
std::exception 是 C++ 标准库中提供的一个异常信号基类,需要包含 头文件。它是所有标准异常类的基类。这个类的目标是提供一个统一的异常处理接口,因此捕获标准库各种异常都可以基于这个类。
抛出信号前,直接创建一个对象吗?比较好的实践是,抛出一个临时对象。下面举个例:
class MyException
: public std::runtime_error {
public:
MyException() : std::runtime_error("MyException") { }
};
void fun() {
// ...
throw MyException();
}
如何捕获异常?
C++ 捕捉异常信号有好几种方式:
- 值捕捉(catch-by-value)
- 引用捕捉(catch-by-reference)
- 指针捕捉(catch-by-pointer)
这几种捕捉异常信号的方式和函数调用时的参数传递极为相似。
最佳实践是,推荐使用引用捕捉,除非有充足的理由不这么做,比如特殊情况下可采用指针来捕捉异常信号。但是要尽量避免采用值捕捉,因为值捕捉会导致异常对象的复制,并且复制品可能会展现出与原抛出对象不同的行为。
值捕捉(catch-by-value)
捕捉异常时,值捕捉是不推荐的使用方式,看看下面的例子:
class MyException
: public std::runtime_error
{
public:
MyException()
: std::runtime_error("MyException") { }
};
void fun() {
try {
throw MyException("exception message");
} catch (MyException e) {
std::cout << "Caught exception by value: "
<< e.what() << std::endl;
}
}
通过值捕捉异常有几个方面的表现糟糕:
- 效率低
异常对象被抛出后,如果通过值捕捉,编译器需要创建被抛出对象的副本,也就是执行拷贝构造函数。拷贝构造函数可能是浅拷贝也可能是深拷贝,如果异常对象内部包含大量数据(比如容器、内存缓冲等)或者复杂数据结构,一旦启动深拷贝,将可能带来极大的性能开销。
- 行为不一致
当异常对象中包含指针或引用成员变量时,一旦拷贝执行的是浅拷贝,比如只复制指针而非指针所指的缓冲,延伸出资源竞争的问题,复制品与原抛出对象在行为上就会变得不一致。
- 资源管理混乱
如果异常类包含有某些资源,如文件句柄、数据库连接、锁等,复制可能会导致资源被重复管理。比如,复制品和原抛出对象都可能尝试释放同一个资源,资源将被重复释放(double-free),触发程序崩溃。还有,如果修改复制品中的这些资源,可能会影响到原异常对象的状态,进而促使对象进入不决定的状态,引发漏洞。
引用捕捉(catch-by-reference)
引用捕捉不会导致对象的拷贝,和原抛出对象行为一致,这是最推荐的捕捉方式,能满足绝大部分的使用场景,形式如下:
try {
throw MyException("An exception message");
} catch (const MyException& e) {
std::cout << "Caught exception by reference: "
<< e.what() << std::endl;
} catch (...) {
std::cerr << "Caught an unknown exception"
<< std::endl;
}
catch (const MyException& e)
语句块参数声明中添加了修饰符 const 用于表示不会修改原抛出异常对象,&
表示以引用方式捕捉异常。
catch (...)
语句块是为了捕捉剩余所有未被匹配到的异常信号,加强程序的健壮性。
指针捕捉(catch-by-pointer)
通过指针来捕捉异常对象在特殊场景下是有意义的,比如在捕捉异常对象之后还需要重新抛出去,方便其它处理块再次捕捉,又或者,为了需要处理多态异常类型时,特别适合使用这种方式捕获异常。
下面以处理多态异常类型为例:
class BaseException
: public std::exception {
public:
virtual const char* what()
const noexcept override {
return "BaseException occurred";
}
};
class DerivedException
: public BaseException {
public:
virtual const char* what()
const noexcept override {
return "DerivedException occurred";
}
};
int main() {
try {
throw new DerivedException();
} catch (BaseException* e) {
std::cerr << "Caught exception: "
<< e->what() << std::endl;
delete e;
} catch (...) {
std::cerr << "Caught an unknown exception"
<< std::endl;
}
return 0;
}
从上面的代码来看,想要通过指针来捕捉异常信号对象,那么在抛出的时候也需要抛出对应的指针,然后捕捉的时候可声明为基类的对象指针,实现多态捕捉。
但是,相应的,通过指针捕获异常后,还需要手动管理异常对象的生命周期,所以这种方式可能带来内存资源的泄漏,又或者悬空指针的问题。
除此之外,通过指针来捕捉异常的方式还有个令人疑惑的场景,到底应不应该由 catch 语句块负责指针指向对象的释放?下面来看几个例子:
MyException x;
void f()
{
MyException y;
try {
int num = rand() % 3;
switch () {
case 0: throw new MyException;
case 1: throw &x;
case 2: throw &y;
}
}
catch (MyException* p) {
// delete p; ?
}
}
上面这个例子中,如果随机数计算结果返回 0,那么在 catch 语句块中释放异常对象是合适的,因为异常对象是通过 new 在 try 语句块中动态实例化的。
相反,如果随机数计算结果返回 1 或者 2 时,都不适合在 catch 语句块中再释放异常对象,因为这时被抛出的异常对象的生命周期并不完全在 try-catch 语句块内,内存管理不一致,擅自释放对象可能导致程序崩溃的。
再说,如果异常对象是通过 new 在 try 语句块中动态实例化的,那么在 catch 语句块中释放异常对象就一定没有问题了吗?
抛出异常对象时,需要依赖内存分配顺利,也就是说当内存不足时,继续使用这种方式抛出异常有可能会无法正常抛出。
可见,通过指针来捕捉异常的真实处理过程可以非常复杂,并不是所有场合都应该推荐使用。
看到这里,有的读者朋友可能会突然想起,微软曾经称霸一时的骨灰级 GUI 框架 MFC 里边就充斥着这种指针捕捉异常的方式,你看微软不也照用不误嘛,也不耽误人家软件产品风靡全世界。
诚然,这种方式放在现在的确很让人疑惑,但是,MFC 推出的时候,C++ 的异常处理标准还没发布呢!总不能要求别人发布的时候就可以预测未来吧?再说,这妨碍人家发布 Windows 了吗?
所以,如果你用了什么比较古老的 SDK 或者库,跟着人家的调调走也是可以的。用了人家的框架,不妨接受人家的约束,照着框架的思维处理问题。