【重学C++】【引用】深入理解:右值引用(将亡值) 与 移动语义std::move

大家好,我是 同学小张,持续学习C++进阶知识AI大模型应用实战案例,持续分享,欢迎大家点赞+关注,共同学习和进步。

重学C++系列文章,在会用的基础上深入探讨底层原理和实现,适合有一定C++基础,想在C++方向上持续学习和进阶的同学。争取让你每天用5-10分钟,了解一些以前没有注意到的细节。


上次我们了解了右值、右值引用存在的意义:减少不必要的拷贝,提高程序性能。

对于一般的变量,编译器会自动识别是左值还是右值,从而自动选择使用拷贝还是移动操作,减少拷贝次数,提高程序性能。

而对于很明确的左值,有时候我们知道这个左值的生命周期,知道什么时候应该消亡,所以我们知道它在某些场合下的赋值操作是可以不用深拷贝,而直接使用swap操作的。对于这样的左值,编译器就无法识别成右值从而减少拷贝次数了。所以,C++11提供了移动语义,来将左值转换成右值,告诉编译器,对于该变量可以使用移动操作,而非拷贝操作。

本文就来看一看C++11提供的移动语义的原理与使用细节。

0. 右值引用回顾

0.1 值的分类

上次(【重学C++】【引用】一文看懂引用的本质与右值引用存在的意义)我们介绍了右值引用,本文,我们再将C++中值的类型细分一下。如下图:

在这里插入图片描述
在C++中,我们可以将值分为lvalue(左值)、prvalue(纯右值)和 xvalue(有人称为将亡值)。

左值和右值我们上次已经详细介绍过了。下面解释下将亡值:将亡值从字面上理解,是生命周期将要结束的值。下面是一个可以视作将亡值的例子:

#include <iostream>
#include <utility> // 包含 std::move

class MyObject {
public:
    MyObject() {
        std::cout << "Constructor called" << std::endl;
    }

    // 移动构造函数
    MyObject(MyObject&& other) noexcept {
        std::cout << "Move constructor called" << std::endl;
    }

    // 复制构造函数
    MyObject(const MyObject& other) {
        std::cout << "Copy constructor called" << std::endl;
    }

    // 赋值构造函数
    MyObject& operator=(const MyObject& other) {
        std::cout << "Copy assignment operator called" << std::endl;
        if (this != &other) {
            // 这里进行复制操作,具体根据类的成员变量来实现
        }
        return *this;
    }

    // 移动赋值构造函数
    MyObject& operator=(MyObject&& other) noexcept {
        std::cout << "Move assignment operator called" << std::endl;
        if (this != &other) {
            // 这里进行移动操作,具体根据类的成员变量来实现
        }
        return *this;
    }

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

MyObject createObject() {
    // 返回一个临时的 MyObject 对象,这是一个将亡值
    return MyObject();
}

int main() {
    // 调用 createObject() 返回一个将亡值,并将其移动到一个新的 MyObject 对象中
    MyObject obj = createObject();

    // 调用复制构造函数
    MyObject copyObj = obj;

    return 0;
}

主要看 main 函数,我们createObject创建了一个 obj 实例,然后将 obj 实例复制给了 copyObj。假设下文直到函数结束,obj实例已经不再使用了,那我们可以认为obj是个将亡值。

正如本文开头说的,有时候我们知道这个左值的生命周期,知道什么时候应该消亡,所以我们知道它在某些场合下的赋值操作是可以不用深拷贝,而直接使用swap操作的。对于这样的将亡值,我们需要手动告诉编译器,它可以作为右值使用。手动告诉编译器的方法,是使用 std::move 函数,将左值转化为右值。

0.2 右值引用一定是右值吗?

例子参考:https://mp.weixin.qq.com/s/35Jbt-vroWhxTk0SSyhgSQ

对于以下代码,foo函数中x1 = x调用的是复制还是移动版本的函数?

class X {
public:
    // 复制版本的赋值函数
    X& operator=(const X& rhs);

    // 移动版本的赋值函数
    X& operator=(X&& rhs) noexcept;
};

void foo(X&& x) {
    X x1;
    x1 = x;
}

答案是调用的 复制版本的函数。

如果将 foo 函数变成下面这样,就变成了调用 移动版本的函数。

X bar();
// 调用X& operator=(X&& rhs),因为bar()返回的X对象没有关联到一个变量名上
X x = bar();
  • 下面解释原因:

只要一个右值引用有名称,那对应的变量就是一个左值,否则,就是右值。

带着这句话,看第一个实现:函数foo的入参X&& x虽然是右值引用,但有变量名x,所以x是一个左值,所以operator=(const X& rhs)最终会被调用。而第二个实现,bar()的返回值是没有名称的,所以是一个右值。

  • C++这样设计的原因:以上面foo函数为例,如最后一行,右值引用有名称,就能被程序员截获,程序员就能任意使用,所以前面为了保险起见就不能被移动。
void foo(X&& x) {
    X x1;
    x1 = x;

	....
	x = xxx // 右值引用有名称,就能被程序员截获,程序员就能任意使用,所以前面为了保险起见就不能被移动。
}

1. 移动语义 - std::move

1.1 基本使用和意义

还是以上面的代码为例,我们来看下使用 std::move 与不使用 std::move 的区别:

int main() {
    // 调用 createObject() 返回一个将亡值,并将其移动到一个新的 MyObject 对象中
    MyObject obj = createObject();
    
    // 调用赋值构造函数
    MyObject assignedObj;
    assignedObj = obj;

    // 调用移动赋值构造函数
    MyObject movedObj;
    movedObj = std::move(obj);

    return 0;
}

assignedObj 没有使用std::move函数,它将调用的是 赋值构造函数,会进行深拷贝操作,拷贝之后内存中存储了两份相同的数据。

movedObj 使用了 std::move 函数,它将调用的是 移动赋值构造函数,不会进行深拷贝操作,而是进行移动(swap)操作,只是将两者的内存空间进行交换,内存中还是只有一份数据。

如果 MyObject 是一个非常大的类,拷贝一次会非常耗费性能,那使用 std::move 就可以节省很多的拷贝性能消耗。

当然,使用std::move的前提是,你必须非常确定的知道movedObj = std::move(obj);之后,obj不会再使用。移动之后obj就为空了,再使用会出大问题。

1.2 配套的移动语义函数

在使用std::move时,还要注意的是,你所move的对象支不支持移动语义。对于C++内置的标准类型或STL中的标准容器,肯定是都是支持的,所以对于这些对象来说,直接std::move即可。

而对于自定义的类型来说,需要手动实现相应的移动语义函数,这样才能实现真正的移动操作。

还是以上的示例代码,MyObject类中,必须实现如下类似的移动构造函数,这样构造函数才能接收右值,从而调用到此函数中实现移动操作而非复制操作:

// 移动构造函数
MyObject(MyObject&& other) noexcept {
    std::cout << "Move constructor called" << std::endl;
}

// 移动赋值构造函数
MyObject& operator=(MyObject&& other) noexcept {
    std::cout << "Move assignment operator called" << std::endl;
    if (this != &other) {
        // 这里进行移动操作,具体根据类的成员变量来实现
    }
    return *this;
}

1.3 移动需要保证异常安全

移动构造/赋值函数都要在函数声明处加关键字 noexcept,这是向调用者表明,我们的移动函数不会抛出异常。只有这样,调用方才会放心地使用我们定义的移动构造/赋值函数。

比较经典的场景是std::vector 扩缩容。当vector由于push_back、insert、reserve、resize 等函数导致内存重分配时,如果我们自定义的元素提供了一个noexcept的移动构造函数,vector会调用该移动构造函数将元素移动到新的内存区域;否则,则会调用拷贝构造函数,将元素复制过去。

1.4 不需要std::move的场景 - 返回值优化

对于以下代码,你觉得哪个效率高?

MyObject createObject1() {
    return std::move(MyObject());
}

MyObject createObject() {
    return MyObject();
}

int main() {
    // 调用 createObject() 返回一个将亡值,并将其移动到一个新的 MyObject 对象中
    MyObject obj = createObject();
}

答案可能与你想的不一样。createObject 的效率更高,而加了 std::movecreateObject1 反而效率低一点。运行结果如下:

在这里插入图片描述

这个原因是:现代C++编译器会有返回值优化。编译器将直接在createObject返回值的位置构造对象,而不是在本地构造然后将其复制出去。很明显,这比在本地构造后移动效率更快。

2. 总结

2.1 移动语义的概念

看了这么多,应该或多或少对移动语义有点感性的认识了,下面总结一下移动语义是什么:

  • C++中的移动语义是一种优化技术,它允许将资源(如内存、文件句柄等)从一个对象转移到另一个对象,而不是复制整个资源。通过移动语义,可以避免不必要的资源拷贝,提高程序的性能和效率。

  • 移动语义的关键在于右值引用和移动构造函数、移动赋值运算符。

  • 移动构造函数和移动赋值运算符允许将一个对象的资源“窃取”或“移动”到另一个对象,而不是进行深层次的资源复制。这是通过使用 std::move() 来将右值引用传递给目标对象实现的。

2.2 std::move的使用注意事项

(1)std::move自定义类型时,自定义类型必须要有移动构造函数、移动赋值构造函数等用来支持移动语义的相应函数实现。

(2)std::move使用时需要确保move的值后面不会再用

(3)移动构造函数、移动赋值构造函数等实现时必须加 noexcept 关键字

(4)注意编译器的返回值优化的场景,这种场景下不需要移动语义

如果觉得本文对你有帮助,麻烦点个赞和关注呗 ~~~


  • 大家好,我是 同学小张,持续学习C++进阶知识AI大模型应用实战案例
  • 欢迎 点赞 + 关注 👏,持续学习持续干货输出
  • +v: jasper_8017 一起交流💬,一起进步💪。
  • 微信公众号也可搜同学小张 🙏

本站文章一览:

在这里插入图片描述

  • 40
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

同学小张

如果觉得有帮助,欢迎给我鼓励!

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

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

打赏作者

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

抵扣说明:

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

余额充值