C/C++编程:移动构造函数(移动语义)

我们知道:右值引用主要用于实现移动(move)语义和完美转发。那么,什么移动语义?它是怎么实现的呢?

引入

在C++11之前,如果想用其他对象初始化一个同类的新对象,只能借助类中的复制(拷贝)构造函数。

拷贝构造函数的实现原理很简单,就是为新对象复制一份和其他对象一模一样的数据(注意,当类中有指针类型的成员变量,拷贝构造函数需要以深拷贝的方式复制该指针成员,另外,也要注意处理静态成员变量)

举个例子:

#include 
using namespace std;
class demo{
public:
   demo():num(new int(0)){
      cout<<"construct!"<<endl;
   }
   //拷贝构造函数
   demo(const demo &d):num(new int(*d.num)){
      cout<<"copy construct!"<<endl;
   }
   ~demo(){
      cout<<"class destruct!"<<endl;
   }
private:
   int *num;
};
demo get_demo(){
    return demo();
}
int main(){
    demo a = get_demo();
    return 0;
}

如上所示,我们为 demo 类自定义了一个拷贝构造函数。该函数在拷贝 d.num 指针成员时,必须采用深拷贝的方式,即拷贝该指针成员本身的同时,还要拷贝指针指向的内存资源。否则一旦多个对象中的指针成员指向同一块堆空间,这些对象析构时就会对该空间释放多次,这是不允许的。

可以看到,程序中定义了一个可返回 demo 对象的 get_demo() 函数,用于在 main() 主函数中初始化 a 对象,其整个初始化的流程包含以下几个阶段:

  • 执行 get_demo() 函数内部的 demo() 语句,即调用 demo 类的默认构造函数生成一个匿名对象;
  • 执行 return demo() 语句,会调用拷贝构造函数复制一份之前生成的匿名对象,并将其作为 - --get_demo() 函数的返回值(函数体执行完毕之前,匿名对象会被析构销毁);
  • 执行 a = get_demo() 语句,再调用一次拷贝构造函数,将之前拷贝得到的临时对象复制给 a(此行代码执行完毕,get_demo() 函数返回的对象会被析构);
  • 程序执行结束前,会自行调用 demo 类的析构函数销毁 a。

注意,目前多数编译器都会对程序中发生的拷贝操作进行优化,因此如果我们使用 VS 2017、codeblocks 等这些编译器运行此程序时,看到的往往是优化后的输出结果:

construct!
class destruct!

而同样的程序,如果在 Linux 上使用g++ demo.cpp -fno-elide-constructors命令运行(其中 demo.cpp 是程序文件的名称),就可以看到完整的输出结果:

construct!                <-- 执行 demo()
copy construct!       <-- 执行 return demo()
class destruct!         <-- 销毁 demo() 产生的匿名对象
copy construct!       <-- 执行 a = get_demo()
class destruct!         <-- 销毁 get_demo() 返回的临时对象
class destruct!         <-- 销毁 a

如上所示,利用拷贝构造函数实现对a对象的初始化,底层实际上进行了2次拷贝(而且是深拷贝)操作。当然,对于仅申请少量堆空间的临时对象来说,深拷贝的执行效率依旧可以接受,但是如果临时对象的指针成员申请了大量的堆空间,那么2次深拷贝操作势必会影响a对象的初始化执行效率

事实上,此问题一直存留在C++98/03标准编写的C++程序中。由于临时变量的产生、销毁以及发生的拷贝操作本身就是很隐晦的(编译器对这些过程做了专门的优化),而且不会影响程序的正确性,因此很少进入程序员的视野

那么当类中包含指针变量的成员函数,使用其他对象来初始化同类对象时,怎样才能避免深拷贝导致的效率问题呢?C++标准引入了解决方案,该标准中引入了右值引用的语法,借助它可以实现移动语义

实现

所谓移动语义,指的就是以移动而非深拷贝的方式初始化含有指针成员的类对象简单的理解,移动语义就是将其其他对象(通常是临时对象)拥有的资源“移为己用”

以前面程序中的 demo 类为例,该类的成员都包含一个整形的指针成员,其默认指向的是容纳一个整形变量的堆空间。当使用 get_demo() 函数返回的临时对象初始化 a 时,我们只需要将临时对象的 num 指针直接浅拷贝给 a.num,然后修改该临时对象中 num 指针的指向(通常另其指向 NULL),这样就完成了 a.num 的初始化。

事实上,对于程序执行过程中产生的临时对象,往往只用于传递数据(没有其它的用处),并且会很快会被销毁。因此在使用临时对象初始化新对象时,我们可以将其包含的指针成员指向的内存资源直接移给新对象所有,无需再新拷贝一份,这大大提高了初始化的执行效率。

例如,下面程序对 demo 类进行了修改:

#include 
using namespace std;
class demo{
public:
    demo():num(new int(0)){
        cout<<"construct!"<<endl;
    }
    demo(const demo &d):num(new int(*d.num)){
        cout<<"copy construct!"<<endl;
    }
    //添加移动构造函数
    demo(demo &&d):num(d.num){
        d.num = NULL;
        cout<<"move construct!"<<endl;
    }
    ~demo(){
        cout<<"class destruct!"<<endl;
    }
private:
    int *num;
};
demo get_demo(){
    return demo();
}
int main(){
    demo a = get_demo();
    return 0;
}

可以看到,在之前 demo 类的基础上,我们又手动为其添加了一个构造函数。和其它构造函数不同,此构造函数使用右值引用形式的参数,又称为移动构造函数。并且在此构造函数中,num 指针变量采用的是浅拷贝的复制方式,同时在函数内部重置了 d.num,有效避免了“同一块对空间被释放多次”情况的发生。

在 Linux 系统中使用g++ demo.cpp -o demo.exe -std=c++0x -fno-elide-constructors命令执行此程序,输出结果为:

construct!
move construct!
class destruct!
move construct!
class destruct!
class destruct!

通过执行结果我们不难得知,当为 demo 类添加移动构造函数之后,使用临时对象初始化 a 对象过程中产生的 2 次拷贝操作,都转由移动构造函数完成。

我们知道,非 const 右值引用只能操作右值,程序执行结果中产生的临时对象(例如函数返回值、lambda 表达式等)既无名称也无法获取其存储地址,所以属于右值。当类中同时包含拷贝构造函数和移动构造函数时,如果使用临时对象初始化当前类的对象,编译器会优先调用移动构造函数来完成此操作。只有当类中没有合适的移动构造函数时,编译器才会退而求其次,调用拷贝构造函数。

在实际开发中,通常在类中自定义移动构造函数的同时,会再为其自定义一个适当的拷贝构造函数,由此当用户利用右值初始化类对象时,会调用移动构造函数;当使用左值(非右值)初始化类对象时,会调用拷贝构造函数

问:如果使用左值初始化同类对象,但是也想调用移动构造函数完成,有没有办法实现呢?

默认情况下,左值初始化同类对象只能通过拷贝构造函数来完成,如果要调用移动构造函数,则必须使用右值进行初始化。C++11标准中为了满足用户使用左值初始化同类对象时也通过移动构造函数完成的需求,引入了std::move()函数,它可以将左值强制转换为对应的右值,由此就可以使用移动构造函数

定义

类T的移动构造函数非模板构造函数,其首个形参是T &&const T &&volatile T&&或者const volatile T&&,且无其他形参,或者剩余形参均有默认值

语法

类名 ( 类名 && ) (1) (C++11 起)
类名 ( 类名 && ) = default; (2) (C++11 起)
类名 ( 类名 && ) = delete; (3) (C++11 起)

其中 类名 必须指名当前类(或类模板的当前实例化),或在命名空间作用域或友元声明中声明时,必须是有限定的类名。

  1. 移动构造函数的典型声明。
  2. 强制编译器生成移动构造函数。
  3. 避免隐式移动构造函数。

从上面可以看出,移动构造函数只接受右值引用的参数:

  • 右值就意味着参数用完就会死掉

从上面可以看出,移动构造函数的参数都不带const

  • 这是因为类似Moaeable(const Moaeable&&)或者const Moveble Return()都会使得临时变量常量化,成为一个常量右值,那么临时变量的引用也就无法修改,从而导致无法实现移动语义。因此,程序员在实现移动语义时一定要注意排除不必要的const关键字。

调用时机

当(以直接初始化或者复制初始化)从同类型的右值(亡值或纯右值) (C++17 前)亡值 (C++17 起)初始化对象时,调用移动构造函数,情况包括

  • 初始化:T a = std::move(b); 或 T a(std::move(b));,其中 b 类型为 T ;
  • 函数实参传递:f(std::move(a));,其中 a 类型为 T 而 f 为 Ret f(T t) ;
  • 函数返回:在如 T f() 的函数中的 return a;,其中 a 类型为 T,它有移动构造函数。
  • 在C++17前,当初始化器为为纯右值时,通常会优化掉对移动构造函数的调用
  • 在C++17后,当初始化器为为纯右值时,始终不会进行 对移动构造函数的调用

具体见复制消除

典型的移动构造函数“窃取”实参曾保有的资源(例如指向动态分配对象的指针,文件描述符,TCP socket,I/O 流,运行的线程,等等),而非复制它们,并使其实参遗留于某个合法但不确定的状态。例如,从 std::string 或从 std::vector 移动可以导致实参被置为空。但是不应依赖此行为。对于某些类型,例如 std::unique_ptr,移动后的状态是完全指定的。

判断一个类型是否可以移动

通过标准头文件中的辅助模板类来判断一个类型是否可以移动,比如is_move_constructible、is_trivially_move_constructible、is_nothrow_move_constructible、使用方法是使用其成员value。比如:std::cout << is_move_constructible::value

移动与异常

对于移动构造函数来说,抛出异常是危险的。因为可能移动语义还没有完成,一个异常却抛出来了,这就会导致一些指针称为悬挂指针。因此,我们应该尽量编写不抛出异常的移动构造函数,通过为其添加一个noexcept关键字,可以保证移动构造函数中抛出来的异常会直接调用terminate程序终止运行,而不是造成指针悬挂的状态

在标准库中,可以用一个std::move_if_noexcept的模板函数替代move函数。该函数在类的移动构造函数没有noexcept关键字修饰时返回一个左值引用从而使变量可以使用拷贝语义,而在类的移动构造函数有noexcept关键字时,返回一个右值引用,从而使变量可以使用移动语义

#include
#include 


using namespace std;

struct MayThrow{
    MayThrow(){}
    MayThrow(const MayThrow&){
        printf("MayThrow copy constructor.\n");
    }
    MayThrow(MayThrow&&) {
        printf("MayThrow move constructor.\n");
    }
};

struct NoThrow{
    NoThrow(){}
    NoThrow(NoThrow&&) noexcept {
        printf("NoThrow move constructor.\n");
    }
    NoThrow(const NoThrow&)  {
        printf("NoThrow copy constructor.\n");
    }
};
int main(){
    MayThrow m;
    NoThrow n;

    MayThrow mt = move_if_noexcept(m);
    NoThrow nt = move_if_noexcept(n);
    return 0;
}

在这里插入图片描述
实际上,move_if_noexcept是牺牲性能保证安全的一种做法,而且要求类的开发者对移动构造函数使用noexcept进行描述,否则就会损失更多的性能。

应用

一个典型应用是可以实现高性能的置换(swap)函数:

template<class T>
void swap(T &a, T &b){
	T tmp(move(a));
	a = move(b);
	b = move(tmp);
}

如果T是可以移动的,那么移动构造和移动赋值将被用于这个置换:a先将自己的资源交给tmp,然后b再将资源交给a,tmp又将从a得到的资源交给b。整个过程中,代码都只会按照移动语义来进行指针交换,不会有资源的是否和申请。如果T是不可移动可拷贝的,那么拷贝语义将被用来置换,这与普通的置换语句是相同的

如何编写移动构造函数与移动赋值运算符

移动构造函数允许将右值对象拥有的资源移到左值,而无需复制

下面是一个用于管理内存缓冲区的C++类MemoryBlock

// MemoryBlock.h
#pragma once
#include 
#include 

class MemoryBlock
{
public:

   // Simple constructor that initializes the resource.
   explicit MemoryBlock(size_t length)
      : _length(length)
      , _data(new int[length])
   {
      std::cout << "In MemoryBlock(size_t). length = "
                << _length << "." << std::endl;
   }

   // Destructor.
   ~MemoryBlock()
   {
      std::cout << "In ~MemoryBlock(). length = "
                << _length << ".";

      if (_data != nullptr)
      {
         std::cout << " Deleting resource.";
         // Delete the resource.
         delete[] _data;
      }

      std::cout << std::endl;
   }

   // Copy constructor.
   MemoryBlock(const MemoryBlock& other)
      : _length(other._length)
      , _data(new int[other._length])
   {
      std::cout << "In MemoryBlock(const MemoryBlock&). length = "
                << other._length << ". Copying resource." << std::endl;

      std::copy(other._data, other._data + _length, _data);
   }

   // Copy assignment operator.
   MemoryBlock& operator=(const MemoryBlock& other)
   {
      std::cout << "In operator=(const MemoryBlock&). length = "
                << other._length << ". Copying resource." << std::endl;

      if (this != &other)
      {
         // Free the existing resource.
         delete[] _data;

         _length = other._length;
         _data = new int[_length];
         std::copy(other._data, other._data + _length, _data);
      }
      return *this;
   }

   // Retrieves the length of the data resource.
   size_t Length() const
   {
      return _length;
   }

private:
   size_t _length; // The length of the resource.
   int* _data; // The resource.
};

为 C++ 创建移动构造函数

  1. 定义一个空的构造函数方法,该方法采用一个对类类型的右值引用作为参数,如以下示例所示:
MemoryBlock(MemoryBlock&& other)
   : _data(nullptr)
   , _length(0)
{
}
  1. 在移动构造函数中,将源对象中的类数据成员添加到要构造的对象:
_data = other._data;
_length = other._length;
  1. 将源对象的数据成员分配给默认值。 这可以防止析构函数多次释放资源(如内存)
other._data = nullptr;
other._length = 0;

为 C++ 类创建移动赋值运算符

  1. 定义一个空的赋值运算符,该运算符采用一个对类类型的右值引用作为参数并返回一个对类类型的引用,如以下示例所示:
MemoryBlock& operator=(MemoryBlock&& other)
{
}
  1. 在移动赋值运算符中,如果尝试将对象赋给自身,则添加不执行运算的条件语句。
if (this != &other)
{
}
  1. 在条件语句中,从要将其赋值的对象中释放所有资源(如内存)。
  • 从要将其赋值的对象中释放 _data 成员:
// Free the existing resource.
delete[] _data;
  • 将数据成员从源对象转移到要构造的对象:
// Copy the data pointer and its length from the
// source object.
_data = other._data;
_length = other._length;

// Release the data pointer from the source object so that
// the destructor does not free the memory multiple times.
other._data = nullptr;
other._length = 0;
  1. 返回对当前对象的引用
return *this;

完成

// Move constructor.
MemoryBlock(MemoryBlock&& other) noexcept
   : _data(nullptr)
   , _length(0)
{
   std::cout << "In MemoryBlock(MemoryBlock&&). length = "
             << other._length << ". Moving resource." << std::endl;

   // Copy the data pointer and its length from the
   // source object.
   _data = other._data;
   _length = other._length;

   // Release the data pointer from the source object so that
   // the destructor does not free the memory multiple times.
   other._data = nullptr;
   other._length = 0;
}

// Move assignment operator.
MemoryBlock& operator=(MemoryBlock&& other) noexcept
{
   std::cout << "In operator=(MemoryBlock&&). length = "
             << other._length << "." << std::endl;

   if (this != &other)
   {
      // Free the existing resource.
      delete[] _data;

      // Copy the data pointer and its length from the
      // source object.
      _data = other._data;
      _length = other._length;

      // Release the data pointer from the source object so that
      // the destructor does not free the memory multiple times.
      other._data = nullptr;
      other._length = 0;
   }
   return *this;
}

可靠编程

要防止资源效率,请始终释放移动赋值运算符中的资源(如内存、文件句柄和套接字)。

若要防止不可恢复的资源损坏,请正确处理移动赋值运算符中的自我赋值。
如果为你的类同时提供了移动构造函数和移动赋值运算符,则可以编写移动构造函数来调用移动赋值运算符,从而消除冗余代码。 以下示例显示了调用移动赋值运算符的移动构造函数的修改后的版本:

// Move constructor.
MemoryBlock(MemoryBlock&& other) noexcept
   : _data(nullptr)
   , _length(0)
{
   *this = std::move(other);
}

例子

#include 
#include 
#include 
#include 
 
struct A
{
    std::string s;
    int k;
    A() : s("test"), k(-1) { }
    A(const A& o) : s(o.s), k(o.k) { std::cout << "move failed!\n"; }
    A(A&& o) noexcept :
           s(std::move(o.s)),       // 类类型成员的显式移动
           k(std::exchange(o.k, 0)) // 非类类型成员的显式移动
    { }
};
 
A f(A a)
{
    return a;
}
 
struct B : A
{
    std::string s2;
    int n;
    // 隐式移动构造函数 B::(B&&)
    // 调用 A 的移动构造函数
    // 调用 s2 的移动构造函数
    // 并进行 n 的逐位复制
};
 
struct C : B
{
    ~C() { } // 析构函数阻止隐式移动构造函数 C::(C&&)
};
 
struct D : B
{
    D() { }
    ~D() { }          // 析构函数阻止隐式移动构造函数 D::(D&&)
    D(D&&) = default; // 强制生成移动构造函数
};
 
int main()
{
    std::cout << "Trying to move A\n";
    A a1 = f(A()); // 按值返回时,从函数形参移动构造其目标
    std::cout << "Before move, a1.s = " << std::quoted(a1.s) << " a1.k = " << a1.k << '\n';
    A a2 = std::move(a1); // 从亡值移动构造
    std::cout << "After move, a1.s = " << std::quoted(a1.s) << " a1.k = " << a1.k << '\n';
 
    std::cout << "Trying to move B\n";
    B b1;
    std::cout << "Before move, b1.s = " << std::quoted(b1.s) << "\n";
    B b2 = std::move(b1); // 调用隐式移动构造函数
    std::cout << "After move, b1.s = " << std::quoted(b1.s) << "\n";
 
    std::cout << "Trying to move C\n";
    C c1;
    C c2 = std::move(c1); // 调用复制构造函数
 
    std::cout << "Trying to move D\n";
    D d1;
    D d2 = std::move(d1);
}

C/C++编程:移动构造函数(移动语义)_第1张图片

官方文档

面试题

移动构造函数是为了解决什么问题?

移动构造函数常和拷贝构造函数对比。

  • 拷贝构造函数的实现原理是为新对象复制一份和旧对象一模一样的数据,如果有指针成员,那么需要深拷贝,这样效率会比较低效
  • 为此,C++引入了右值引用,以实现移动语义。所谓移动语义就是将一个对象用用的资源抢过来给自己。如果是指针成员的话,那么新对象成员指向老对象成员,老对象成员置于NULL

你可能感兴趣的:(C++,c++,c语言,算法)