C++中的移动语义

这篇具有很好参考价值的文章主要介绍了C++中的移动语义。希望对大家有所帮助。如果存在错误或未考虑完全的地方,请大家不吝赐教,您也可以点击"举报违法"按钮提交疑问。

C++引以为傲的特点是零开销抽象和高性能,尤其是C++11发布的移动语义,更是让它的高性能更上一层楼。

C++是一门支持多种编程范式的语言,其中之一就是面向对象编程。面向对象编程的一个重要特征就是封装,即将数据和操作数据的函数组织在一起,形成一个类。类可以定义自己的构造函数、拷贝构造函数、赋值运算符和析构函数,来控制对象的创建、复制、赋值和销毁过程。

拷贝语义

在C++11之前,类的拷贝构造函数和赋值运算符都是基于拷贝语义(copy semantics)的,即在复制或赋值一个对象时,会创建一个新的对象,并将原对象的所有成员变量逐一复制到新对象中,它可以保证对象之间互不影响,符合直觉和逻辑。这样做有时候是必要和合理的,比如当我们想要保留原对象的状态,或者当原对象和新对象有不同的生命周期时。但是,在某些情况下,我们并不需要保留原对象的状态,或者原对象本身就是一个临时的、即将被销毁的对象。比如:

1std::string s1 = "Hello";
2std::string s2 = s1 + " World"; // s1 + " World" 是一个临时字符串

在这个例子中,我们把两个字符串相加得到一个新的字符串,并赋值给s2。按照传统的拷贝语义,这个过程会涉及三次拷贝:

  • 首先,在运算符+中,会创建一个临时字符串,并把s1和" World"复制到其中;

  • 然后,在赋值运算符=中,会创建s2,并把临时字符串复制到其中;

  • 最后,在表达式结束后,会销毁临时字符串。

可以看到,在这个过程中,有两次拷贝其实是没有必要的:第一次是把s1和" World"复制到临时字符串中;第二次是把临时字符串复制到s2中。因为在这两次拷贝之后,原来的数据就没有用处了:s1和" World"并没有改变;临时字符串马上就要被销毁。如果我们能够直接把原来的数据转移到目标对象中,而不需要创建新的副本,那么就可以节省内存空间和时间开销。这就是移动语义要做的事情。

移动语义

问题是找到了,在拷贝语义下,虽然解决了对象共存的问题,却引入了内存浪费的问题,所以C++11引入了移动语义(move semantics),它的主要目标就是实现最少的内存浪费。即在复制或赋值一个对象时,可以选择将原对象的资源“移动”到新对象中,而不是进行逐一复制。这样做有两个优点:

  • 提高效率,因为移动操作通常只涉及简单地交换指针或句柄等轻量级的操作;

  • 符合直觉,因为在某些情况下(例如临时变量、返回值等),我们确实只关心新对象的状态,而不在乎原对象是否被修改或销毁。

当然要实现移动语义,就不得不引入新的工具。首先需要一个新的类型来表示直接量和临时值,即右值引用(rvalue reference),而直接量和临时值统称为右值。另一个工具是针对自定义类型的,为了使自定义类也能实现移动语义,C++新增了移动构造函数(move constructor)和移动赋值运算符(move assignment operator)。它们和拷贝构造函数和拷贝复制运算符的差别就在于前者使用右值引用(rvalue reference)作为参数类型。
为了说明白移动语义的这些工具,我们需要从C++11以前开始说起。

右值引用的由来

为了说明白移动语义的必要性,我们可以先从一个函数开始说起。

 1struct Config {
 2    std::string api;
 3    std::string token;
 4};
 5
 6void config(Config config) {
 7    std::cout << "Use config( api = " << config.api <<" , token = "<<config.token<<")" << std::endl;
 8}
 9
10int main() {
11    Config c{ "api","token" };
12    config(c); //复制了一个Config
13    return 0;
14}

假设我们需要一个config函数,它只有一个Config的参数,用来表示配置信息,看起来很简单吧。但是我们很容易就能发现传递给config函数会复制一个对象,这个对象会一直占用内存,直到出config的调用作用域。这种内存占用显然不合理,我们于是开始着手重构。

第一次重构

为了避免生成多余的对象,我们首先想到的肯定是指针,于是我们改成以下版本

1void config(Config* config)
2

这个版本很好地解决了效率的问题,但是也引入了新问题。指针虽好,但是对于函数调用者无法直观地看出该参数是否可为空,这对于api来说是不友好的。另一方面,对于函数开发者来说,也需要检测空指针的情况。所以指针的方案也不完美。

第二次重构

结合不生成多余对象和不能为空的需求,我们很快想到使用引用。于是有了以下版本

1void config(Config& config)
2

这个版本解决了所有问题吗?并没有。由于是左值引用,调用者每次调用函数的时候,都需要有一个变量来作为参数,不能用临时对象,甚至不能用直接量。作为一个配置api,这种限制显然也不合理。

第三次重构

走到这里,相信大家都觉得没所谓了,兜兜转转还是得回到最开始的那个版本,这点内存消耗,反正也不大。但是假如配置项很多,或者有些配置项占用内存很高呢,显然我们早晚还是要解决这个问题,但是似乎已经没有合适的方案了。不过当你试一试在第二版参数前面加个const呢,你就会发现无论你传变量,还是临时对象,甚至直接量都可以了。发生了什么魔法呢,我们暂时按下不表,我们来看一看api还有没有问题。目前,对于调用者来说,已经很明确很方便了,但是对于api的实现者呢。实现者假如需要对Config参数进行修改,实现者就不得不重新生成变量,在调用者处省略的内存,又被占回来了。那有没有一种方式可以同时解决修改,临时对象的问题呢,这就是右值引用。它的形式如下

1void config(Config&& config)
2

上面的例子就是从拷贝语义逐步改造成移动语义的过程,可以看到,在拷贝语义时,内存占用分成两份,我们只使用了拷贝出来的新对象,老对象虽然外部也没用了,但是还是需要等到它出作用域后才能销毁,这就是拷贝语义下的对象行为。而在移动语义下呢,函数参数可以从外部移动到函数内部,并且内部修还可以当作普通左值一样修改,非常方便。

左值和右值

虽然我们解释了右值引用的由来,但是还没有解释右值。为了解释右值,我们先来看看什么是值。
我们知道程序是由代码和数据组成的,它们在程序运行的时候都需要存储在内存中。为了能让开发者读取或者修改数据,需要知道数据保存在哪里,同时,为了能解释数据,我们还需要知道数据占用的大小和可取值范围,它们共同组成了类型。一旦类型和地址确定了,我们就能从内存的字符串中还原出期望的内容,可能是数字,也可能是字符串,而我们称还原出来的内容为值。更确切的,某某类型的值。
从上面的情景我们可以看出,任何值都有个地址和类型,有的值只是暂时的,很快就会被丢弃,而有的值存活得更久,可以被反复读取和修改。暂时的这种值很可能是用来初始化某个变量的,也可能是某个计算的中间值,它们的生命周期很短,要么等着复制到其他位置,要么等着被回收,我们统称这种值为右值。另一种开发者用一种叫变量的东西记住了值的地址和类型,通过变量就可以定位到那一块固定内存,从而对它进行修改或者读取,这种类型的值,我们称为左值。如开发者在源代码里面写了字符串abc,当程序运行起来后,abc肯定也被加载进了内存里,但是我们不知道abc被存在哪里,所以为了能后面能继续使用abc,我们需要用变量来记住它。加载进来的那个abc就是一种右值,当它被复制到变量指定的位置后,就再也没法找到它了,而开发者可以通过变量找到被初始化为abc的值,即左值。由此,我们可以看出右值是即将消完的值,而左值生存周期被绑定在变量上,直到变量销毁前,都能通过变量找到它,它都存在。左值和右值的明显差别是左值可直接寻址,右值是某个中间值或者直接量,等待被复制到新的变量位置或者被系统回收。

右值引用

在上面的例子中,有仔细的读者可能已经发现了,既然左值和右值都存在了内存里,而且都是一样的值,那为啥不直接使用右值呢。因为右值不能直接寻址,所以我们需要一种工具来操作右值,这个工具就是右值引用。右值引用延长了右值的生命周期,它把右值偷来做自己的值,所以它既有左值的特点,还避免了一次内存可以操作。
右值引用相比左值引用多了一个&,但是不仅仅是语法上的不同,右值引用可以接受临时值和直接量,并且在函数内部还可以修改,是提高内存效率的强力工具。
我们先来看看左值和右值的语法

1int x = 10; // x 是一个左值
2int& lref = x; // lref 是一个左值引用
3int&& rref1 = 20; // rref1 是一个右值引用
4int&& rref2 = x + y; // rref2 是一个右值引用
5int&& rref3 = x; // 错误!x 是一个左值

可以看到右值引用是一种新的类型,直接量,临时值都可以是右值引用的值。
对于上面的例子,下面这些都是有效的写法

1Config readConfig(){
2    Config c;
3    return c;
4}
5config({ "api","token" });
6config(readConfig());

既然左值右值都在内存里,那么有办法让左值转化成右值呢?答案是可以,但是有副作用。当左值转换成右值引用后,原来的变量依然有效,但是状态是不确定的。也就是还是可以调用它的api,但是对调用结果不做保证。如把std::string对象转换为右值引用后,用这个左值对象调用size()方法后,可能得到的是0,也可能还是原来的大小。
这就给我们怎样使用右值引用提供了指导思路。既然右值引用可以接收匿名对象,那么我们就可以直接使用匿名对象做参数,在不得不用左值存储中间值并确定变量不需要再时候后,直接用std::move来将左值转换为右值。

自定义类添加右值引用支持

上面的实例是针对函数的,对于自定义类,C++同样提供了新工具来支持移动语义,即添加了新的移动构造函数,移动赋值函数,其实现即是在拷贝语义的基础上,将左值引用替换为右值引用。

 1class MyString {
 2public:
 3    MyString() {
 4        std::cout << "Default Constructor" << std::endl;
 5    }
 6
 7    MyString(const MyString& origin) :base_{ origin.base_ } {
 8        std::cout << "Copy Constructor" << std::endl;
 9    }
10
11    MyString(MyString&& origin) :base_{ std::move(origin.base_) } {
12        std::cout << "Move Constructor" << std::endl;
13    }
14private:
15    std::string base_;
16};
17
18int main() {   
19    MyString str1;                      // 输出 Default Constructor
20    MyString str2{ str1 };              // 输出 Copy Constructor
21    MyString str3{ std::move(str2) };   // 输出 Move Constructor
22    return 0;
23}

上例是一个实现了移动构造函数的示例,像传递函数参数一样,我们把右值引用的数据直接偷来初始化自己的成员变量。

 1// 新增
 2// 拷贝赋值运算符
 3MyString& operator=(const MyString& origin) {
 4    base_ = origin.base_;
 5    std::cout << "Assign operator" << std::endl;
 6    return *this;
 7}
 8
 9// 移动赋值运算符
10MyString& operator=(MyString&& origin) {
11    base_={ std::move(origin.base_) };
12    std::cout << "Move assign operator" << std::endl;
13    return *this;
14}
15
16int main() {   
17    MyString str1;                      // 输出 Default Constructor
18    MyString str2{ str1 };              // 输出 Copy Constructor
19    MyString str3{ std::move(str2) };   // 输出 Move Constructor
20    str2 = str3;                        // 输出 Assign operator
21    str1 = std::move(str3);             // 输出 Move assign operator
22    return 0;
23}

可以看到,移动语义并不是取代拷贝语义的工具,而是对拷贝语义的完善,开发者可以根据实际业务自行取舍。

总结

移动语义能提高内存使用效率,极大地增加了开发者对内存的控制能力,在C++ 的发展中越来越重要。在C++14及后面的版本中,编译器更是将返回值默认优化成移动语义的形式,足以见移动语义的成功。
右值引用给开发者提供了操作右值的能力,拷贝构造函数和拷贝移动函数给开发者提供了移动资源的能力,开发者只需要在合适的时候选择移动或者拷贝就行,而对于现有代码,也可以通过std::move来完成右值转换。文章来源地址https://www.toymoban.com/news/detail-433627.html

到了这里,关于C++中的移动语义的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请点击违法举报进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用

相关文章

  • 【C++】—— C++11新特性之 “右值引用和移动语义”

    前言: 本期,我们将要的介绍有关 C++右值引用 的相关知识。对于本期知识内容,大家是必须要能够掌握的,在面试中是属于重点考察对象。 目录 (一)左值引用和右值引用 1、什么是左值?什么是左值引用? 2、什么是右值?什么是右值引用? (二)左值引用与右值引用比

    2024年02月11日
    浏览(40)
  • 【重学C++】05 | 说透右值引用、移动语义、完美转发(下)

    【重学C++】05 | 说透右值引用、移动语义、完美转发(下) 大家好,我是只讲技术干货的会玩code,今天是【重学C++】的第五讲,在第四讲《【重学C++】04 | 说透右值引用、移动语义、完美转发(上)》中,我们解释了右值和右值引用的相关概念,并介绍了C++的移动语义以及如

    2024年02月06日
    浏览(39)
  • 【C++】 C++11(右值引用,移动语义,bind,包装器,lambda,线程库)

    C++11是C++语言的一次重大更新版本,于2011年发布,它包含了一些非常有用的新特性,为开发人员提供了更好的编程工具和更好的编程体验,使得编写高效、可维护、安全的代码更加容易。 一些C++11的新增特性包括: 强制类型枚举,使得枚举类型的通常行为更加可靠和容易控制

    2024年02月10日
    浏览(42)
  • C++右值引用(左值表达式、右值表达式)(移动语义、完美转发(右值引用+std::forward))(有问题悬而未决)

    在 C++ 中,表达式可以分为左值表达式和右值表达式。左值表达式指的是可以出现在赋值语句左边的表达式,例如变量、数组元素、结构体成员等;右值表达式指的是不能出现在赋值语句左边的表达式,例如常量、临时对象、函数返回值等。 右值是指将要被销毁的临时对象或

    2024年02月04日
    浏览(42)
  • 我以为发现了Android 14系统中的一个bug,然而...

    本文同步发表于我的微信公众号,扫一扫文章底部的二维码或在微信搜索 郭霖 即可关注,每个工作日都有文章更新。 今天来跟大家探讨一个Android 14很细节的知识点。 事情的起因是这样的,某天工作群里,我看到我们部门的同事guting发了这样一条消息。 我看到这条消息之后

    2024年02月02日
    浏览(49)
  • 【论文阅读】WATSON:通过聚合上下文语义从审计日志中抽象出行为(NDSS-2021)

    Zeng J, Chua Z L, Chen Y, et al. WATSON: Abstracting Behaviors from Audit Logs via Aggregation of Contextual Semantics[C]//NDSS. 2021. TC_e3 trace、攻击调查、TransE、 以信息流为边界提取子图,为子图提取行为表示,进一步聚类,分析师只需分析一个簇的代表事件 1. 摘要引言 WATSON,一种通过推断和汇总审计

    2024年02月09日
    浏览(39)
  • 【C++庖丁解牛】面向对象的三大特性之一多态 | 抽象类 | 多态的原理 | 单继承和多继承关系中的虚函数表

    🍁你好,我是 RO-BERRY 📗 致力于C、C++、数据结构、TCP/IP、数据库等等一系列知识 🎄感谢你的陪伴与支持 ,故事既有了开头,就要画上一个完美的句号,让我们一起加油 需要声明的,本节课件中的代码及解释都是在vs2013下的x86程序中,涉及的指针都是4bytes。如果要其他平台

    2024年04月10日
    浏览(56)
  • 移动端高性能Unity播放器实现方案

    前情提要: 视听体验再进化——如何在24小时内全面升级你的视频应用 如何打造新时代的终端播放产品? 随着VR、AR、元宇宙等新玩法的出现,Unity平台的视频播放需求逐渐增加,比如下面两个动图就是在百度真实的案例。前者是演唱会场景,后者则是一个演讲会场。 通过这

    2024年02月08日
    浏览(42)
  • Rust-所有权和移动语义

    拿C语言的代码来打个比方。我们可能会在堆上创建一个对象,然后使用一个指针来管理这个对象: 接下来,我们可能需要使用这个对象: 然而,这段代码之后,谁能猜得到,指针p指向的对象究竟发生了什么?它是否被修改过了?它还存在吗,是否已经被释放?是否有另外一个指

    2024年01月18日
    浏览(38)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包