C++ 抛出的异常对象会被复制多少次?

619 阅读3分钟

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

_4f37f7c8-b9e9-4174-bebd-eb74dbf35d08.jpeg

这是《掌握 C++ 异常艺术:构建健壮程序的秘诀与实战策略》系列文章的第二篇,文末有链接可以查看系列里其它文章。

C++ 抛出的异常对象会被复制多少次?

这个问题问得好,提出好的问题往往揭露真相。

如果看过上文《掌握 C++ 异常艺术:构建健壮程序的秘诀与实战策略「一」》,可能你会记得笔者曾提到过,说「比较好的实践是,抛出一个临时对象」,为什么呢?

反问一下,假设在抛出信号前,先创建一个对象呢?看下面的实例代码:

class MyException {
public:
    MyException() {
        std::cout << "Default constructor"
            << std::endl;
    }

    MyException(const MyException& other) {
        std::cout << "Copy constructor"
            << std::endl;
    }

    ~MyException() {
        std::cout << "Destructor"
            << std::endl;
    }
};

int main() {
    try {
        MyException e;
        throw e;
    } catch (const MyException& e) {
        std::cout << "Exception caught"
            << std::endl;
    }
    return 0;
}

代码中,声明一个异常类 MyException,手动定义了默认构造函数、拷贝构造函数和析构函数,添加日志输出,方便观察异常对象实例化和释放的过程。

在 try 语句块中,创建一个局部对象,然后再抛出该对象。执行程序运行结果:

Default constructor
Copy constructor
Destructor
Exception caught
Destructor

可见在当前编译执行环境下,抛出异常后,异常信号会被拷贝一次,并且拷贝前后的信号都会被释放。

看过笔者之前文章的你应该知道,拷贝对于系统运行过程来说是一种很普遍的负担,尽量避免这样的负荷,即使没有再做其它的优化也已经对系统非常友好了。

那么这个莫名其妙的拷贝是从何而来?

因为在退出当前调用栈后,栈内的局部异常信号量会被自动释放,为了继续上传到异常捕获的代码执行处,所以必须将抛出的信号量拷贝为可移动的量,也就是我们移动语义中提到的「右值」。

关于「移动语义」的介绍,可查看文末的阅读连接。

中途再转化为右值着实有些折腾,还不如一开始创建信号量就声明为可移动的量嘛,比如创建临时量。

下面改为直接抛出一个临时对象:

int main() {
    try {
        throw MyException();
    } catch (const MyException& e) {
        std::cout << "Exception caught"
            << std::endl;
    }
    return 0;
}

执行程序运行结果:

Default constructor
Exception caught
Destructor

从修改后的输出结果来看,拷贝动作没有了,析构动作也少了一次。

对于某些编译器来说,有时候可以通过优化来避免实际的拷贝操作,例如使用返回值优化(RVO)或移动语义,所以上面的手动优化并不是必须的。如果你的编译器支持这类的优化,即使编译器没有进行实际对象的拷贝,它仍然会在编译期检查该对象的拷贝构造函数是否可用而且是可访问的。

那么,这里可以说,抛出异常的过程,异常对象在优化的情况下可以做到被拷贝 0 次,除非你真的想让它执行多余拷贝。