一文入魂:再也不用担心我不懂C++移动语义了!

举报
C语言与CPP编程 发表于 2022/09/24 22:07:21 2022/09/24
【摘要】 导语 | 移动语义是从C++11开始引入的一项全新功能。本文将为您拨开云雾,让您对移动语义有个全面而深入的理解,希望本文对你理解移动语义提供一点经验和指导。 一、为什么要有移动语义 (一)从拷贝说起 我们知道,C++中有拷贝构造函数和拷贝赋值运算符。那既然是拷贝,听上去就是开销很大的操作。没错,所谓...

c92e2e9b5e212291e4dacf146fde4a27.jpeg

导语 | 移动语义是从C++11开始引入的一项全新功能。本文将为您拨开云雾,让您对移动语义有个全面而深入的理解,希望本文对你理解移动语义提供一点经验和指导。

一、为什么要有移动语义

(一)从拷贝说起

我们知道,C++中有拷贝构造函数和拷贝赋值运算符。那既然是拷贝,听上去就是开销很大的操作。没错,所谓拷贝,就是申请一块新的内存空间,然后将数据复制到新的内存空间中。如果一个对象中都是一些基本类型的数据的话,由于数据量很小,那执行拷贝操作没啥毛病。但如果对象中涉及其他对象或指针数据的话,那么执行拷贝操作就可能会是一个很耗时的过程。

我们来看一个例子。假设我们有个类,该类中有一个string类型的成员变量,定义如下:


   
  1. class MyClass
  2. {
  3. public:
  4. MyClass(const std::string& s)
  5. : str{ s }
  6. {};
  7. private:
  8. std::string str;
  9. };
  10. MyClass A{ "hello" };

当我们新建一个该类的对象A,并传递参数“hello”时,对象A的成员变量str中会存储字符串“hello”。而为了存储字符串,string类型会为其分配内存空间。因此,当前内存中的数据如图所示:

063044d416707d6447d89ff78114e3d9.png

现在,当我们定义了一个该类的新对象B,且把对象A赋值给对象B时,会发生什么?即,我们执行如下语句:

MyClass B = A;
  

当拷贝发生时,为了让B对象中的成员变量str也能够存储字符串“hello”,string类型会为其分配内存空间,并将对象A的str中存储的数据复制过来。因此,经过拷贝操作后,此时内存中的数据如图所示:

e20ed29a80afb1feb20d9f038905633d.png

这个拷贝操作无可厚非,毕竟我们希望A对象和B对象是完全独立无关的对象,对B对象的修改不会影响A对象,反之亦然。

(二)需要移动语义的情况

既然拷贝操作没毛病,那为什么要新增移动语义呢。因为在一些情况下,我们可能确实不需要拷贝操作。考虑下面一个例子:


   
  1. class MyClass
  2. {
  3. public:
  4. MyClass(const std::string& s)
  5. : str{ s }
  6. {};
  7. private:
  8. std::string str;
  9. };
  10. std::vector<MyClass> myClasses;
  11. MyClass tmp{ "hello" };
  12. myClasses.push_back(tmp);
  13. myClasses.push_back(tmp);

在这个例子中,我们创建了一个容器以及一个MyClass对象tmp,我们将tmp对象添加到容器中2次。每次添加时,都会发生一次拷贝操作。最终内存中的数据如图所示:

747b207f5fe617bab791dcc233a1ea4d.png

现在问题来了,tmp对象在被添加到容器中2次之后,就不需要了,也就是说,它的生命期即将结束。那么聪明的你一定想到了,既然tmp对象不再需要了,那么第2次将其添加到容器中的操作是不是就可以不执行拷贝操作了,而是让容器直接取tmp对象的数据继续用。没错,这时,就需要移动语义帅气登场了!

(三)移动语义帅气登场

所谓移动语义,就像其字面意思一样,即把数据从一个对象中转移到另一个对象中,从而避免拷贝操作所带来的性能损耗

那么在上面的例子中,我们如何触发移动语义呢?很简单,我们只需要使用std::move函数即可。有关std::move函数,就是另一个话题了,这里我们不深入探讨。我们只需要知道,通过std::move函数,我们可以告知编译器,某个对象不再需要了,可以把它的数据转移给其他需要的对象用。

我们来改造下之前的例子:


   
  1. class MyClass
  2. {
  3. public:
  4. MyClass(const std::string& s)
  5. : str{ s }
  6. {};
  7. // 假设已经实现了移动语义
  8. private:
  9. std::string str;
  10. };
  11. std::vector<MyClass> myClasses;
  12. MyClass tmp{ "hello" };
  13. myClasses.push_back(tmp);
  14. myClasses.push_back(std::move(tmp)); // 看这里

由于我们还没讲到移动语义的实现,因此这里先假设MyClass类已经实现了移动语义。我们改动的是最后一行代码,由于我们不再需要tmp对象,因此通过使用std::move函数,我们让myClasses容器直接转移tmp对象的数据为已用,而不再需要执行拷贝操作了。

通过数据转移,我们避免了一次拷贝操作,最终内存中的数据如图所示:

97b63ecb85643982cbe62813f53f0bd4.png

至此,我们可以了解到,C++11引入移动语义可以在不需要拷贝操作的场合执行数据转移,从而极大的提升程序的运行性能。

二、移动语义的实现

在了解了为什么要有移动语义之后,接着我们就该来看看它该如何实现。

(一)左值引用与右值引用

在学习如何实现移动语义之前,我们需要先了解2个概念,即“左值引用”与“右值引用”。

为了支持移动语义,C++11引入了一种新的引用类型,称为“右值引用”,使用“&&”来声明。而我们最常使用的,使用“&”声明的引用,现在则称为“左值引用”。

右值引用能够引用没有名称的临时对象以及使用std::move标记的对象:


   
  1. int val{ 0 };
  2. int&& rRef0{ getTempValue() }; // OK,引用临时对象
  3. int&& rRef1{ val }; // Error,不能引用左值
  4. int&& rRef2{ std::move(val) }; // OK,引用使用std::move标记的对象

移动语义的实现需要用到右值引用,我们在后文会详细的说。现在我们需要知道,以下2种情况会让编译器将对象匹配为右值引用:

  • 一个在语句执行完毕后就会被自动销毁的临时对象;

  • 由std::move标记的非const对象。

让编译器将对象匹配为右值引用,是一切的基础

(二)区分拷贝操作与移动操作

我们回到上文的例子,对于myClasses容器的第一次push_back,我们期望执行的是拷贝操作,而对于myClasses容器的第二次push_back,由于之后我们不再需要tmp对象了,因此我们期望执行的是移动操作:


   
  1. class MyClass
  2. {
  3. public:
  4. MyClass(const std::string& s)
  5. : str{ s }
  6. {};
  7. // 假设已经实现了移动语义
  8. private:
  9. std::string str;
  10. };
  11. std::vector<MyClass> myClasses;
  12. MyClass tmp{ "hello" };
  13. myClasses.push_back(tmp); // 这里执行拷贝操作,将tmp中的数据拷贝给容器中的元素
  14. myClasses.push_back(std::move(tmp)); // 这里执行移动操作,容器中的元素直接将tmp的数据转移给自己

现在我们已经知道,移动操作执行的是对象数据的转移,那么它一定是与拷贝操作不一样的。因此,为了能够将拷贝操作与移动操作区分执行,就需要用到我们上一节的主题:左值引用与右值引用。

因此,对于容器的push_back函数来说,它一定针对拷贝操作和移动操作有不同的重载实现,而重载用到的即是左值引用与右值引用。伪代码如下:


   
  1. class vector
  2. {
  3. public:
  4. void push_back(const MyClass& value) // const MyClass& 左值引用
  5. {
  6. // 执行拷贝操作
  7. }
  8. void push_back(MyClass&& value) // MyClass&& 右值引用
  9. {
  10. // 执行移动操作
  11. }
  12. };

通过传递左值引用或右值引用,我们就能够根据需要调用不同的push_back重载函数了。那么下一个问题来了,我们知道std::vector是模板类,可以用于任意类型。所以,std::vector不可能自己去实现拷贝操作或移动操作,因为它不知道自己会用在哪些类型上。因此,std::vector真正做的,是委托具体类型自己去执行拷贝操作与移动操作。

(三)移动构造函数

当通过push_back向容器中添加一个新的元素时,如果是通过拷贝的方式,那么对应执行的会是容器元素类型的拷贝构造函数。关于拷贝构造函数,它是C++一直以来都包含的功能,相信大家已经很熟悉了,因此在这里就不展开了。

当通过push_back向容器中添加一个新的元素时,如果是通过移动的方式,那么对应执行的会是容器元素类型的“移动构造函数”(敲黑板,划重点)。

移动构造函数是C++11引入的一种新的构造函数,它接收右值引用。以我们前文的MyClass例子来说,为其定义移动构造函数:


   
  1. class MyClass
  2. {
  3. public:
  4. // 移动构造函数
  5. MyClass(MyClass&& rValue) noexcept // 关于noexcept我们稍后会介绍
  6. : str{ std::move(rValue.str) } // 看这里,调用std::string类型的移动构造函数
  7. {}
  8. MyClass(const std::string& s)
  9. : str{ s }
  10. {}
  11. private:
  12. std::string str;
  13. };

在移动构造函数中,我们要做的就是转移成员数据。我们的MyClass有一个std::string类型的成员,该类型自身实现了移动语义,因此我们可以继续调用std::string类型的移动构造函数。

在有了移动构造函数之后,我们就可以在需要时通过它来创建新的对象,从而避免拷贝操作的开销。以如下代码为例:


   
  1. MyClass tmp{ "hello" };
  2. MyClass A{ std::move(tmp) }; // 调用移动构造函数

首先我们创建了一个tmp对象,接着我们通过tmp对象来创建A对象,此时传递给构造函数的参数为std::move(tmp)。还记得我们前文提及的编译器匹配右值引用的情况之一嘛,即由std::move标记的非const对象,因此编译器会调用执行移动构造函数,我们就完成了将tmp对象的数据转移到对象A上的操作:

b61f4cf8f8d63486c05fb003ab518fb7.png

(四)自己动手实现移动语义

在前文的MyClass例子中,我们将移动操作交由std::string类型去完成。那如果我们的类有成员数据需要我们自己去实现数据转移的话,通常该怎么做呢?

我们来举个例子,假设我们定义的类型中包含了一个int类型的数据以及一个char*类型的指针:


   
  1. class MyClass
  2. {
  3. public:
  4. MyClass()
  5. : val{ 998 }
  6. {
  7. name = new char[] { "Peter" };
  8. }
  9. ~MyClass()
  10. {
  11. if (nullptr != name)
  12. {
  13. delete[] name;
  14. name = nullptr;
  15. }
  16. }
  17. private:
  18. int val;
  19. char* name;
  20. };
  21. MyClass A{};

当我们创建一个MyClass的对象A时,它在内存中的布局如图所示:

880316f36be55368b7854e226c7f7d7b.png

现在我们来为MyClass类型实现移动构造函数,代码如下所示:


   
  1. class MyClass
  2. {
  3. public:
  4. MyClass()
  5. : val{ 998 }
  6. {
  7. name = new char[] { "Peter" };
  8. }
  9. // 实现移动构造函数
  10. MyClass(MyClass&& rValue) noexcept
  11. : val{ std::move(rValue.val) } // 转移数据
  12. {
  13. rValue.val = 0; // 清除被转移对象的数据
  14. name = rValue.name; // 转移数据
  15. rValue.name = nullptr; // 清除被转移对象的数据
  16. }
  17. ~MyClass()
  18. {
  19. if (nullptr != name)
  20. {
  21. delete[] name;
  22. name = nullptr;
  23. }
  24. }
  25. private:
  26. int val;
  27. char* name;
  28. };
  29. MyClass A{};
  30. MyClass B{ std::move(A) }; // 通过移动构造函数创建新对象B

还记得移动语义的精髓嘛?数据拿过来用就完事儿了。因此,在移动构造函数中,我们将传入对象A的数据转移给新创建的对象B。同时,还需要关注的重点在于,我们需要把传入对象A的数据清除,不然就会产生多个对象共享同一份数据的问题。被转移数据的对象会处于“有效但未定义(valid but unspecified)”的状态(后文会介绍)。

通过移动构造函数创建对象B之后,内存中的布局如图所示:

dc87ac84cf56aab6e0ff6f0783dc6dc0.png

(五)移动赋值运算符

与拷贝构造函数和拷贝赋值运算符一样,除了移动构造函数之外,C++11还引入了移动赋值运算符。移动赋值运算符也是接收右值引用,它的实现和移动构造函数基本一致。在移动赋值运算符中,我们也是从传入的对象中转移数据,并将该对象的数据清除:


   
  1. class MyClass
  2. {
  3. public:
  4. MyClass()
  5. : val{ 998 }
  6. {
  7. name = new char[] { "Peter" };
  8. }
  9. MyClass(MyClass&& rValue) noexcept
  10. : val{ std::move(rValue.val) }
  11. {
  12. rValue.val = 0;
  13. name = rValue.name;
  14. rValue.name = nullptr;
  15. }
  16. // 移动赋值运算符
  17. MyClass& operator=(MyClass&& myClass) noexcept
  18. {
  19. val = myClass.val;
  20. myClass.val = 0;
  21. name = myClass.name;
  22. myClass.name = nullptr;
  23. return *this;
  24. }
  25. ~MyClass()
  26. {
  27. if (nullptr != name)
  28. {
  29. delete[] name;
  30. name = nullptr;
  31. }
  32. }
  33. private:
  34. int val;
  35. char* name;
  36. };
  37. MyClass A{};
  38. MyClass B{};
  39. B = std::move(A); // 使用移动赋值运算符将对象A赋值给对象B

三、移动构造函数和移动赋值运算符

                   的生成规则

在C++11之前,我们拥有4个特殊成员函数,即构造函数、析构函数、拷贝构造函数以及拷贝赋值运算符。从C++11开始,我们多了2个特殊成员函数,即移动构造函数和移动赋值运算符。

本节将介绍移动构造函数和移动赋值运算符的生成规则。

(一)deleted functions

在细说移动构造函数和移动赋值运算符的生成规则之前,我们先要说一说“已删除的函数(deleted functions)”。

在C++11中,可以使用语法=delete;来将函数定义为“已删除”。任何使用“已删除”函数的代码都会产生编译错误:


   
  1. class MyClass
  2. {
  3. public:
  4. void Test() = delete;
  5. };
  6. MyClass value;
  7. value.Test(); // 编译错误:attempting to reference a deleted function

在之后的介绍中,我们需要关注到的点是在特定情况下,编译器会将移动构造函数和移动赋值运算符定义为deleted。

现在让我们进入主题,正式开始吧。

(二)默认情况下,我们拥有一切

我们知道,在C++11之前,如果我们定义一个空类,编译器会自动为我们生成构造函数、析构函数、拷贝构造函数以及拷贝赋值运算符。该特性在移动语义上得以延伸。在C++11之后,如果我们定义一个空类,除了之前的4个特殊成员函数,编译器还会为我们生成移动构造函数和移动赋值运算符:


   
  1. class MyClass
  2. {};
  3. MyClass A{}; // OK,执行编译器默认生成的构造函数
  4. MyClass B{ A }; // OK,执行编译器默认生成的拷贝构造函数
  5. MyClass C{ std::move(A) }; // OK,执行编译器默认生成的移动构造函数

(三)当我们定义了拷贝操作之后

如果我们在类中定义了拷贝构造函数或者拷贝赋值运算符,那么编译器就不会自动生成移动构造函数和移动赋值运算符。此时,如果调用移动语义的话,由于编译器没有自动生成,因此会转而执行拷贝操作


   
  1. class MyClass
  2. {
  3. public:
  4. MyClass()
  5. {}
  6. // 我们定义了拷贝构造函数,这会禁止编译器自动生成移动构造函数和移动赋值运算符
  7. MyClass(const MyClass& value)
  8. {}
  9. };
  10. MyClass A{};
  11. MyClass B{ std::move(A) }; // 执行的是拷贝构造函数来创建对象B

(四)析构函数登场

析构函数的情况和定义拷贝操作一致,如果我们在类中定义了析构函数,那么编译器也不会自动生成移动构造函数和移动赋值运算符。此时,如果调用移动语义的话,同样会转而执行拷贝操作


   
  1. class MyClass
  2. {
  3. public:
  4. // 我们定义了析构函数,这会禁止编译器自动生成移动构造函数和移动赋值运算符
  5. ~MyClass()
  6. {}
  7. };
  8. MyClass A{};
  9. MyClass B{ std::move(A) }; // 执行的是拷贝构造函数来创建对象B

析构函数有一点值得注意,许多情况下,当一个类需要作为基类时,都需要声明一个virtual析构函数,此时需要特别留意是不是应该手动的为该类定义移动构造函数以及移动赋值运算符。此外,当子类派生时,如果子类没有实现自己的析构函数,那么将不会影响移动构造函数以及移动赋值运算符的自动生成:


   
  1. class MyBaseClass
  2. {
  3. public:
  4. virtual ~MyBaseClass()
  5. {}
  6. };
  7. class MyClass : MyBaseClass // 子类没有实现自己的析构函数
  8. {};
  9. MyClass A{};
  10. MyClass B{ std::move(A) }; // 这里将执行编译器自动生成的移动构造函数

(五)移动构造函数和移动赋值运算符的相互影响

如果我们在类中定义了移动构造函数,那么编译器就不会为我们自动生成移动赋值运算符。反之,如果我们在类中定义了移动赋值运算符,那么编译器也不会为我们自动生成移动构造函数。

之前我们提到,如果我们在类中定义了拷贝构造函数、拷贝赋值运算符或者析构函数,那么编译器不会为我们生成移动构造函数与移动赋值运算符。此时如果执行移动语义,会转而执行拷贝操作。但这里不同,以移动构造函数为例,如果我们定义了移动构造函数,那么编译器不会为我们自动生成移动赋值运算符,此时,移动赋值运算符的调用并不会转而执行拷贝赋值运算符,而是会产生编译错误:


   
  1. class MyClass
  2. {
  3. public:
  4. MyClass()
  5. {}
  6. // 我们定义了移动构造函数,这会禁止编译器自动生成移动赋值运算符,并且对移动赋值运算符的调用会产生编译错误
  7. MyClass(MyClass&& rValue) noexcept
  8. {}
  9. };
  10. MyClass A{};
  11. MyClass B{};
  12. B = std::move(A); // 对移动赋值运算符的调用产生编译错误:attempting to reference a deleted function

通过编译器的报错信息我们可以推断,如果我们定义了移动构造函数,那么移动赋值运算符会被编译器定义为“已删除的函数”,反之,如果我们定义了移动赋值运算符,那么移动构造函数也会被编译器定义为“已删除的函数”。

(六)小结

通过以上的介绍说明,我们对移动构造函数以及移动赋值运算符的自动生成以及可用性有了理解和掌握。我们现在将其整理为表格,从而能够更加清晰而全面的一览无遗:

73417d9939d811b9a191ced4ca48e823.png

四、noexcept

在前文我们实现移动构造函数以及移动赋值运算符时,我们使用了noexcept说明符。本节我们就来聊聊何为noexcept。

(一)为什么需要noexcept

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


   
  1. class MyClass
  2. {
  3. public:
  4. MyClass()
  5. {}
  6. MyClass(const MyClass& lValue)
  7. {
  8. std::cout << "拷贝构造函数" << std::endl;
  9. }
  10. MyClass(MyClass&& rValue) // 注意这里,我们没有对移动构造函数使用noexcept
  11. {
  12. std::cout << "移动构造函数" << std::endl;
  13. }
  14. private:
  15. std::string str{ "hello" };
  16. };

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


   
  1. MyClass A{};
  2. std::vector<MyClass> classes;
  3. classes.push_back(A);
  4. classes.push_back(A);

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

9901a0a35613ea25b317a3ce2109ea6f.png

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

ca5c8085c86f14fa07d0700d75e0854f.png

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

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

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

bb15f5ae9f5e0f7c8eb3a0ed7d2ae729.jpeg

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

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

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

(二)为移动语义使用noexcept说明符

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

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


   
  1. class MyClass
  2. {
  3. public:
  4. MyClass()
  5. {}
  6. MyClass(const MyClass& lValue)
  7. {
  8. std::cout << "拷贝构造函数" << std::endl;
  9. }
  10. MyClass(MyClass&& rValue) noexcept // 注意这里,为移动构造函数使用noexcept
  11. {
  12. std::cout << "移动构造函数" << std::endl;
  13. }
  14. private:
  15. std::string str{ "hello" };
  16. };

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

a0360a02760a67cf37ac065ab835ccdf.png

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

五、使用移动语义时需要注意的其他内容

在最后一节,我们聊聊与移动语义相关的一些额外内容。

(一)编译器生成的移动构造函数和移动赋值运算符

前文我们提及,在特定情况下,编译器会为我们自动生成移动构造函数和移动赋值运算符。在自动生成的函数中,编译器执行的是逐成员的移动语义。

假设我们的类包含一个int类型和一个std::string类型的成员:


   
  1. class MyClass
  2. {
  3. private:
  4. int val;
  5. std::string str;
  6. };

那么编译器为我们自动生成的移动构造函数和移动赋值运算符类似于如下所示:


   
  1. class MyClass
  2. {
  3. public:
  4. // 编译器自动生成的移动构造函数类似这样,执行逐成员的移动语义
  5. MyClass(MyClass&& rValue) noexcept
  6. : val{ std::move(rValue.val) }
  7. , str{ std::move(rValue.str) }
  8. {}
  9. // 编译器自动生成的移动赋值运算符类似这样,执行逐成员的移动语义
  10. MyClass& operator=(MyClass&& rValue) noexcept
  11. {
  12. val = std::move(rValue.val);
  13. str = std::move(rValue.str);
  14. return *this;
  15. }
  16. private:
  17. int val;
  18. std::string str;
  19. };

了解编译器自动生成的移动构造函数以及移动赋值运算符之后,我们就会对何时应该自己去实现有个很清晰的认识。

(二)被移动对象的状态

当一个对象被移动之后,该对象仍然是有效的,你可以继续使用它,最终它会被销毁,执行析构函数。

C++在其文档中表明,所有标准库中的对象,当被移动之后,会处于一个“有效但未定义的状态(valid but unspecified state)”。

C++并没有强制的规定限制被移动对象必须处于什么状态,并且当类型需要满足不同用途时它的要求也不一致(例如用于key的类型要求被移动对象仍然能够进行排序),因此我们在实现自己的类型时需要根据具体情况来分析。但通常来说,我们应该尽可能的贴近C++标准库中的类型规范。但不管如何,以下这一点是我们必须考虑的:

保证被移动对象能够被正确的析构。

为什么必须保证这一点呢?这是因为被移动对象只是处于一个特殊的状态,对于运行时来说,仍然是有效的,最终也会执行析构函数进行销毁。

例如在之前我们的MyClass类型定义中,当我们执行移动语义后,被移动对象的name指针会被置空。当执行析构函数时,我们就可以简单的通过判空来避免指针的无效释放,这就确保了被移动对象能够正确的析构:


   
  1. class MyClass
  2. {
  3. public:
  4. MyClass()
  5. : val{ 998 }
  6. {
  7. name = new char[] { "Peter" };
  8. }
  9. MyClass(MyClass&& rValue) noexcept
  10. : val{ std::move(rValue.val) }
  11. {
  12. rValue.val = 0;
  13. name = rValue.name;
  14. rValue.name = nullptr; // 置空被移动对象的指针
  15. }
  16. MyClass& operator=(MyClass&& myClass) noexcept
  17. {
  18. val = myClass.val;
  19. myClass.val = 0;
  20. name = myClass.name;
  21. myClass.name = nullptr; // 置空被移动对象的指针
  22. return *this;
  23. }
  24. ~MyClass()
  25. {
  26. if (nullptr != name) // 通过判空来避免指针的无效释放
  27. {
  28. delete[] name;
  29. name = nullptr;
  30. }
  31. }
  32. private:
  33. int val;
  34. char* name;
  35. };

(三)避免非必要的std::move调用

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


   
  1. class MyClass
  2. {};
  3. MyClass GetTemporary()
  4. {
  5. MyClass A{};
  6. return A;
  7. }
  8. MyClass myClass = GetTemporary(); // 注意这里

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

ccf9e03049e8a3c1ccab6b6cf867d607.png

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

ab54fb08fd6d9c939304e98d9aa68386.png

为NRVO点赞!

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


   
  1. MyClass GetTemporary()
  2. {
  3. MyClass A{};
  4. return std::move(A); // 使用移动语义
  5. }

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

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


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

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

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

23836676dd28d3cfb9346d1976b3daf9.png


六、总结

本文向您阐述了C++中的移动语义,从缘由、定义到实现,以及其他的一些相关细节内容。相信您在看完本文后对C++的移动语义会有更加全面而深刻的认识,可以向妈妈汇报了!

参考资料:

1.C++ reference

2.Effective Modern C++

3.CppCon 2019:Klaus Iglberger "Back to Basics: Move Semantics"

文章来源: blog.csdn.net,作者:程序员编程指南,版权归原作者所有,如需转载,请联系作者。

原文链接:blog.csdn.net/weixin_41055260/article/details/126672722

【版权声明】本文为华为云社区用户转载文章,如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。