C++ 移动语义

从拷贝说起

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

我们来看一个例子,该类中有一个string类型的成员函数,定义如下:

class MyClass
{
 public:
       MyClass(const std::string& s) :str(s)
       {
       };
 private:
     std::string str;
};

MyClass A{"hello"};

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

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

MyClass B = A;

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

需要移动语义的情况

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

class MyClass
{
  public:
      MyClass(const std::string& s):str(s)
      {
         
      };
  private:
      std:string str;
}
std:vector<MyClass> myclasses;
MyClass tmp{"hello"};
myclasses.push_pack(tmp);
myclasses.push_pack(tmp);

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

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

移动语义

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

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

class Myclass
{
   public:
          MyClass(const std::string& s):str(s)
          {
             
          };
    //假设已经实现了移动语义
   private:
       std::string str;
}
std:vector<MyClass> myclasses;
MyClass tmp{"hello"};
myclasses.push_pack(tmp);
myclasses.push_pack(std::move(tmp));

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

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

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

左值引用与右值引用

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

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

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

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

移动语义的实现需要用到右值引用。以下2中情况会让编译器将对象匹配右值引用:

1:一个语句执行完毕后会被自动销毁的临时对象。

2:由std::move标记的非const对象

区分拷贝操作与移动操作

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


class MyClass
{
public:
    MyClass(const std::string& s)
        : str{ s }
    {};

    // 假设已经实现了移动语义

private:
    std::string str;
};

std::vector<MyClass> myClasses;
MyClass tmp{ "hello" };
myClasses.push_back(tmp);  // 这里执行拷贝操作,将tmp中的数据拷贝给容器中的元素
myClasses.push_back(std::move(tmp));  // 这里执行移动操作,容器中的元素直接将tmp的数据转移给自己

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

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


class vector
{
public:
    void push_back(const MyClass& value)  // const MyClass& 左值引用
    {
        // 执行拷贝操作
    }

    void push_back(MyClass&& value)  // MyClass&& 右值引用
    {
        // 执行移动操作
    }
};

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

移动构造函数

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

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

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

class MyClass
{
public:
    // 移动构造函数
    MyClass(MyClass&& rValue) noexcept  // 关于noexcept我们稍后会介绍
        : str{ std::move(rValue.str) }  // 看这里,调用std::string类型的移动构造函数
    {}

    MyClass(const std::string& s)
        : str{ s }
    {}

private:
    std::string str;
};

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

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


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

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

自己手动实现移动语义

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

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


class MyClass
{
public:
    MyClass()
        : val{ 998 }
    {
        name = new char[] { "Peter" };
    }

    ~MyClass()
  {
    if (nullptr != name)
    {
      delete[] name;
      name = nullptr;
    }
  }

private:
    int val;
    char* name;
};

MyClass A{};

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

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

class MyClass
{
public:
  MyClass()
    : val{ 998 }
  {
    name = new char[] { "Peter" };
  }

  // 实现移动构造函数
  MyClass(MyClass&& rValue) noexcept
    : val{ std::move(rValue.val) }  // 转移数据
  {
    rValue.val = 0;  // 清除被转移对象的数据

    name = rValue.name;  // 转移数据
    rValue.name = nullptr;  // 清除被转移对象的数据
  }

  ~MyClass()
  {
    if (nullptr != name)
    {
      delete[] name;
      name = nullptr;
    }
  }

private:
  int val;
  char* name;
};

MyClass A{};
MyClass B{ std::move(A) };  // 通过移动构造函数创建新对象B

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

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

移动赋值运算符

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


class MyClass
{
public:
  MyClass()
    : val{ 998 }
  {
    name = new char[] { "Peter" };
  }

  MyClass(MyClass&& rValue) noexcept
    : val{ std::move(rValue.val) }
  {
    rValue.val = 0;

    name = rValue.name;
    rValue.name = nullptr;
  }

  // 移动赋值运算符
  MyClass& operator=(MyClass&& myClass) noexcept
  {
    val = myClass.val;
    myClass.val = 0;

    name = myClass.name;
    myClass.name = nullptr;

    return *this;
  }

  ~MyClass()
  {
    if (nullptr != name)
    {
      delete[] name;
      name = nullptr;
    }
  }

private:
  int val;
  char* name;
};

MyClass A{};
MyClass B{};
B = std::move(A);  // 使用移动赋值运算符将对象A赋值给对象B

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
### 概述 在C++语言中,拷贝语义移动语义是与对象拷贝相关的两个核心概念,它们分别决定了如何高效地复制对象。 #### 拷贝语义 (Copy Semantics) 拷贝语义是传统的复制机制,用于复制(`copy`)、赋值(`assignment`)以及通过值传递的对象。它包括三种形式: 1. **拷贝构造函数** (`Copy Constructor`) - 当使用新的对象初始化另一个已存在的对象或创建新对象时调用。 ```cpp MyClass(const MyClass& other); ``` 2. **赋值运算符** (`Assignment Operator`) - 将一个对象的值赋予另一个对象。 ```cpp MyClass& operator=(const MyClass& other); ``` 3. **拷贝赋值运算符** (`Copy Assignment Operator`) - 类似于`operator=`,但在某些情况下,为了性能优化,可以提供专用版本。 ```cpp MyClass& operator=(MyClass&& other); // 使用移动语义时提供此版本 ``` 拷贝语义通常涉及资源的浅复制或深复制,这取决于所处理对象的数据结构特性。 #### 移动语义 (Move Semantics) 移动语义是为了提高性能而引入的一种更先进的复制机制。相比拷贝语义移动操作会将对象的一部分或全部状态从源对象转移到目标对象,同时尽量减少内存分配和拷贝操作。 移动语义涉及到以下关键概念: 1. **移出构造函数** (`Move Constructor`) - 当需要从其他非临时对象移动资源而不是拷贝时使用。 ```cpp MyClass(MyClass&& other) noexcept; ``` 2. **移动赋值运算符** (`Move Assignment Operator`) - 同样是在某些情况下为了提高效率,可以提供专门的移动赋值运算符。 ```cpp MyClass& operator=(MyClass&& other); // 代替拷贝赋值运算符 ``` 移动语义的核心优势在于减少了不必要的拷贝和内存分配,特别是在大对象或多线程环境下能显著提升程序性能。 ### 应用场景及优缺点 - **优点**:移动语义可以大幅提高代码的运行效率,在处理大型数据结构或在高并发环境中特别有效。 - **缺点**:引入移动语义后,代码需要额外关注哪些地方应该使用移动构造和赋值,这可能会增加编写和维护代码的复杂度,并可能导致错误如未声明的移动操作或不当的拷贝操作。 ### 相关问题: 1. **移动语义何时优先于拷贝语义?** 这种情况通常出现在资源密集型的应用中,特别是当对象很大或需要频繁复制时,移动语义能够显著提高性能。 2. **如何在实际项目中应用移动语义?** 需要识别潜在的移动候选者,通常是那些包含大量资源的类或容器,评估是否可以在构造函数、赋值运算符或析构函数中应用移动语义。 3. **移动语义与拷贝语义之间的交互是如何管理的?** 确保正确的使用移动语义需要理解两者之间的区别和相互作用,避免在不应使用移动的地方使用,同时合理利用拷贝语义保证程序的兼容性和健壮性。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

水火汪

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

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

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

打赏作者

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

抵扣说明:

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

余额充值