C/C++ noexcept NRVO

为什么需要noexcept

为了说明为什么需要noexcept,我们还是从一个例子出发,我们定义MyClass类,并且我们先不对MyClass类的移动构造函数使用noexcept

class MyClass
{
public:
    MyClass()
    {}

    MyClass(const MyClass& lValue)
    {
        std::cout << "拷贝构造函数" << std::endl;
    }

    MyClass(MyClass&& rValue)  // 注意这里,我们没有对移动构造函数使用noexcept
    {
        std::cout << "移动构造函数" << std::endl;
    }

private:
    std::string str{ "hello" };
};

接着,我们创建一个MyClass的对象A,并且将其往classes容器中添加2次

MyClass A{};
std::vector<MyClass> classes;
classes.push_back(A);
classes.push_back(A);

现在,我们来梳理一下流程。classes容器在定义时默认会申请1个元素的内存空间。当第1次执行classes.push_back(A);时,对象A会被拷贝到容器第1个元素的位置:

当第2次执行classes.push_back(A);时,由于classes容器已没有多余的内存空间,因此它需要分配一块新的内存空间。在分配新的内存空间之后,classes容器会做2个操作:将对象A拷贝到容器第2个元素的位置,以及将之前的元素放到新的内存空间中容器第1个元素的位置:

细心的小伙伴一定发现了,如上图所示那般,老的元素是被拷贝到新的内存空间中的。是的,classes容器确实使用的是拷贝构造函数。那么此时我们会想到,既然classes容器已经不需要之前的内存中的数据了,那么将老数据放到新的内存空间中应该使用移动语义,而非拷贝操作。

那么为什么classes容器没有使用移动语义呢?

此时,我们需要提及一个概念,即“强异常保证(strong exception guarantee)”。所谓强异常保证,即当我们调用一个函数时,如果发生了异常,那么应用程序的状态能够回滚到函数调用之前:

那么强异常保证和决定使用移动语义或拷贝操作又有什么关系呢?

这是因为容器的push_back函数是具备强异常保证的,也就是说,当push_back函数在执行操作的过程中(由于内存不足需要申请新的内存、将老的元素放到新内存中等),如果发生了异常(内存空间不足无法申请等),push_back函数需要确保应用程序的状态能够回滚到调用它之前。以上面的例子来说,当第2次执行classes.push_back(A);时,如果发生了异常,应用程序的状态会回滚到第1次执行classes.push_back(A);之后,即classes容器中只有一个元素。

由于我们的移动构造函数没有使用noexcept说明符,也就是我们没有保证移动构造函数不会抛出异常。因此,为了确保强异常保证,就只能使用拷贝构造函数了。那么拷贝构造函数同样没有保证不会抛出异常,为什么就能用呢?这是因为拷贝构造函数执行之后,被拷贝对象的原始数据是不会丢失的。因此,即使发生异常需要回滚,那些已经被拷贝的对象仍然完整且有效。但移动语义就不同了,被移动对象的原始数据是会被清除的,因此如果发生异常,那些已经被移动的对象的数据就没有了,找不回来了,也就无法完成状态回滚了。

为移动语义使用noexcept说明符

在了解了以上的规则后,我们就清楚了,要想使用移动构造函数来将老的元素放到新的内存中,我们就需要告知编译器,我们的移动构造函数不会抛出异常,可以放心使用,这就是通过noexcept说明符完成的。

我们来修改下MyClass类的移动构造函数,为其加上noexcept说明符:

class MyClass
{
public:
    MyClass()
    {}

    MyClass(const MyClass& lValue)
    {
        std::cout << "拷贝构造函数" << std::endl;
    }

    MyClass(MyClass&& rValue) noexcept  // 注意这里,为移动构造函数使用noexcept
    {
        std::cout << "移动构造函数" << std::endl;
    }

private:
    std::string str{ "hello" };
};

现在,我们再次执行上文的例子,会发现使用的是移动构造函数来创建新的内存中的元素了:

关于noexcept说明符,是个庞大的话题,这里我们只是粗略的提及和移动语义有关的部分。值得注意的是,noexcept说明符是我们对于不会抛出异常的保证,如果在执行的过程中有异常被抛出了,应用程序将会直接终止执行。

NRVO

在C++中,存在称为“NRVO(named return value optimization,命名返回值优化)”的技术,即如果函数返回一个临时对象,则该对象会直接给函数调用方使用,而不会再创建一个新对象。听起来有点晦涩,我们来看一个例子:

class MyClass
{};

MyClass GetTemporary()
{
    MyClass A{};
    return A;
}

MyClass myClass = GetTemporary();  // 注意这里

在上面的例子中,GetTemporary函数会创建一个临时的MyClass对象A,接着在函数结束时返回。在没有NRVO的情况下,当执行语句MyClass myClass=GetTemporary();时,会调用MyClass类的拷贝构造函数,通过对象A来拷贝创建myClass对象。

我们可以发现,在创建完myClass对象之后,对象A就被销毁了,这无疑是一种浪费。因此,编译器会启用NRVO,直接让myClass对象使用对象A。这样一来,在整个过程中,我们只有一次创建对象A时构造函数的调用开销,省去了拷贝构造函数以及析构函数的调用开销。

为NRVO点赞!

此时,可能有细心的小伙伴已经发现了,这种返回临时对象的情况不就是移动语义发挥的场景嘛。没错,机智的你是不是会想到如下的修改:

MyClass GetTemporary()
{
    MyClass A{};
    return std::move(A);  // 使用移动语义
}

这样一来,通过移动语义,即使没有NRVO,也可以避免拷贝操作。乍看上去没啥毛病,但我们忽略了一种情况,那就是返回的对象类型并没有实现移动语义。

让我们来分析一下这种情况,我们改写一下MyClass类:

class MyClass
{
public:
    ~MyClass()  // 注意这里,通过声明析构函数,我们禁止了编译器去实现默认移动构造函数
    {}
};

现在,MyClass类型没有实现移动语义,当我们执行语句MyClass myClass=GetTemporary();时,编译器没有办法调用移动构造函数来创建myClass对象。同时,遗憾的是,由于std::move(A)返回的类型是MyClass&&,与函数的返回类型MyClass不一致,因此编译器也不会使用NRVO。最终,编译器只能调用拷贝构造函数来创建myClass对象。

因此,当返回局部对象时,我们不用画蛇添足,直接返回对象即可,编译器会优先使用最佳的NRVO,在没有NRVO的情况下,会尝试执行移动构造函数,最后才是开销最大的拷贝构造函数。

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

水火汪

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值