C++ 移动语义

  • 阿里云国际版折扣https://www.yundadi.com

  • 阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6

    从拷贝说起

    我们知道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

  • 阿里云国际版折扣https://www.yundadi.com

  • 阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6
    标签: c++