掌握 C++ 异常艺术:构建健壮程序的秘诀与实战策略「一」

以下内容为本人的烂笔头,如需要转载,请全文无改动地复制粘贴,原文链接 微信公众号「ENG八戒」mp.weixin.qq.com/s/WC8CThJ77...

在过去几十年的编程历史中,异常处理的演变仿佛一场文明的进化史,它不仅仅是技术的革新,更是编程思想与哲学的深刻体现。

从古早的错误码时代,程序员们在代码的荒野中艰难跋涉,每一个错误都需要手动检查,仿佛在茫茫大海中寻找失落的宝藏。

那时,程序的健壮性如同脆弱的瓷器,一触即碎。

直到 C++ 及其它现代编程语言的兴起,异常处理机制如同一场技术革命,为虚拟世界带来了前所未有的韧性与优雅。它借鉴了古老的战争哲学------"让情报流动",如同古代战争中传递烽火信号,迅速将问题通知给能处理它的机构。

这不仅是对错误处理方式的革新,更是编程思维的一次飞跃,从被动防御转向了主动管理复杂性。

想象一下中世纪的城堡,坚固的城墙(try 块)围护着核心区域,当外来侵扰(异常)发生时,哨兵(catch 块)立即响应,或直接抵御,或发出警报,甚至调用援军(再次抛出异常)。

这便是 C++ 异常处理机制的形象比照,它不仅是一种技术手段,也是编程智慧与人类文明的结晶。

就如同罗马帝国修建的庞大道路网,让信息与物资得以高效流通,C++ 的异常处理机制确保程序在遇到不可预知的挑战时,能灵活应对,排除错误,确保核心逻辑的顺畅执行。

每一次异常的抛出与捕获,都是对程序健壮性的一次检验。正如历史长河中,文明在一次次危机中涅槃重生,变得更加坚强。

现在,让八戒带领大家一同踏入这场编程史上的伟大征程,从如何优雅地抛出第一个异常,到如何智慧地布局 try 与 catch,再到如何巧妙地减少 try 块的冗余,每一步都是对过往智慧的致敬,也是对未来编程艺术的探索。

这个系列的主题会分为多篇文章推送,感兴趣的读者朋友记得点赞收藏,如能多多转发让更多朋友受益,笔者不胜感激!

如何抛出异常?

抛出异常信号时,对信号的要求不多,比较灵活,一般建议抛出对象,无论是内置的数据类型,还是自定义类,最佳建议是基于 std::exception 的实例(包括派生类的实例)。

std::exception 是 C++ 标准库中提供的一个异常信号基类,需要包含 头文件。它是所有标准异常类的基类。这个类的目标是提供一个统一的异常处理接口,因此捕获标准库各种异常都可以基于这个类。

抛出信号前,直接创建一个对象吗?比较好的实践是,抛出一个临时对象。下面举个例:

kotlin 复制代码
class MyException
    : public std::runtime_error {
public:
    MyException() : std::runtime_error("MyException") { }
};

void fun() {
    // ...
    throw MyException();
}

如何捕获异常?

C++ 捕捉异常信号有好几种方式:

  1. 值捕捉(catch-by-value)
  2. 引用捕捉(catch-by-reference)
  3. 指针捕捉(catch-by-pointer)

这几种捕捉异常信号的方式和函数调用时的参数传递极为相似。

最佳实践是,推荐使用引用捕捉,除非有充足的理由不这么做,比如特殊情况下可采用指针来捕捉异常信号。但是要尽量避免采用值捕捉,因为值捕捉会导致异常对象的复制,并且复制品可能会展现出与原抛出对象不同的行为。

值捕捉(catch-by-value)

捕捉异常时,值捕捉是不推荐的使用方式,看看下面的例子:

c 复制代码
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;
    }
}

通过值捕捉异常有几个方面的表现糟糕:

  1. 效率低

异常对象被抛出后,如果通过值捕捉,编译器需要创建被抛出对象的副本,也就是执行拷贝构造函数。拷贝构造函数可能是浅拷贝也可能是深拷贝,如果异常对象内部包含大量数据(比如容器、内存缓冲等)或者复杂数据结构,一旦启动深拷贝,将可能带来极大的性能开销。

  1. 行为不一致

当异常对象中包含指针或引用成员变量时,一旦拷贝执行的是浅拷贝,比如只复制指针而非指针所指的缓冲,延伸出资源竞争的问题,复制品与原抛出对象在行为上就会变得不一致。

  1. 资源管理混乱

如果异常类包含有某些资源,如文件句柄、数据库连接、锁等,复制可能会导致资源被重复管理。比如,复制品和原抛出对象都可能尝试释放同一个资源,资源将被重复释放(double-free),触发程序崩溃。还有,如果修改复制品中的这些资源,可能会影响到原异常对象的状态,进而促使对象进入不决定的状态,引发漏洞。

引用捕捉(catch-by-reference)

引用捕捉不会导致对象的拷贝,和原抛出对象行为一致,这是最推荐的捕捉方式,能满足绝大部分的使用场景,形式如下:

c 复制代码
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)

通过指针来捕捉异常对象在特殊场景下是有意义的,比如在捕捉异常对象之后还需要重新抛出去,方便其它处理块再次捕捉,又或者,为了需要处理多态异常类型时,特别适合使用这种方式捕获异常。

下面以处理多态异常类型为例:

arduino 复制代码
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 语句块负责指针指向对象的释放?下面来看几个例子:

csharp 复制代码
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 或者库,跟着人家的调调走也是可以的。用了人家的框架,不妨接受人家的约束,照着框架的思维处理问题。

本系列文章还没结束,这是第一篇,欢迎关注我!

相关推荐
QQ同步助手7 分钟前
C++ 指针进阶:动态内存与复杂应用
开发语言·c++
qq_4335545418 分钟前
C++ 面向对象编程:递增重载
开发语言·c++·算法
易码智能26 分钟前
【EtherCATBasics】- KRTS C++示例精讲(2)
开发语言·c++·kithara·windows 实时套件·krts
ཌ斌赋ད32 分钟前
FFTW基本概念与安装使用
c++
薄荷故人_1 小时前
从零开始的C++之旅——红黑树封装map_set
c++
悲伤小伞2 小时前
C++_数据结构_详解二叉搜索树
c语言·数据结构·c++·笔记·算法
m0_675988233 小时前
Leetcode3218. 切蛋糕的最小总开销 I
c++·算法·leetcode·职场和发展
code04号6 小时前
C++练习:图论的两种遍历方式
开发语言·c++·图论
煤泥做不到的!7 小时前
挑战一个月基本掌握C++(第十一天)进阶文件,异常处理,动态内存
开发语言·c++
F-2H7 小时前
C语言:指针4(常量指针和指针常量及动态内存分配)
java·linux·c语言·开发语言·前端·c++