C++ 移动构造函数详解

移动构造函数是什么?

  移动构造是C++11标准中提供的一种新的构造方法。

  先举个生活例子,你有一本书,你已经不想看了,但我非常想看,那么我有哪些方法可以让我能看这本书?有两种做法,一种是你直接把书交给我,另一种是我去买一些稿纸来,然后照着你这本书一字一句抄到稿纸上。很显然第二种方法很浪费时间,但这正是有些深拷贝构造函数的做法,而移动构造函数便能像第一种做法一样省时,第一种做法在 C++ 中又叫做完美转发

  在C++11之前,如果要将源对象的状态转移到目标对象只能通过复制。而现在在某些情况下,我们没有必要复制对象,只需要移动它们。

  C++11引入移动语义:源对象资源的控制权全部交给目标对象。

复制构造和移动构造对比

  复制构造:在对象被复制后临时对象和被复制构造的对象各自占有不同的同样大小的堆内存,临时就是一个副本。从下图中可以看到,临时对象和新建对象a申请的堆内存同时存在。

image

  移动构造:将临时对象它原本控制的内存的空间转移给构造出来的 a 对象,这样就相当于把它移动过去了。从下图中可以看到,原本由临时对象申请的堆内存,由新建对象 a 接管,临时对象不再指向该堆内存。

image

改进的拷贝构造

  设想一种情况,用对象 a 初始化对象 b ,后对象 a 后续就不在使用,但是对象 a 的空间在析构发生之前还在,既然是拷贝构造函数,实际上就是把 a 对象的内容复制一份到 b 中,那么可以对指针进行浅复制,这样就避免了新的空间的分配,大大降低了构造的成本。

  但是指针的浅层复制非常危险,之所以危险是因为会出现两个指针共同指向一片内存空间的情况,这时候第一个指针将这片内存区域释放后,就会导致另一个指针的指向不合法(不明白可参考深拷贝和浅拷贝问题详解)。所以就要避免第一个指针释放空间。避免的方法就是将第一个指针(比如 a->value = NULL)置为NULL,这样在调用对象 a 析构函数时由于有判断是否为NULL的语句析构的时候就不会回收 a->value 指向的内存空间(这片内存空间同时也是 b->value 指向的内存空间)注意即使没有判断NULL的语句,直接 delete NULL (int *a = nullptr;delete a;)也不会发生任何事。

#include <iostream>
#include <string>

class Integer {
public:
    Integer(int value) : m_ptr(new int(value)) {
        std::cout << "有参构造" << std::endl;
    }

    Integer(const Integer &source) : m_ptr(new int(*source.m_ptr)) {
        std::cout << "常量左值引用深拷贝构造" << std::endl;
    }

    Integer(Integer &source) : m_ptr(source.m_ptr) {
        source.m_ptr = nullptr;
        std::cout << "左值引用浅拷贝构造" << std::endl;
    }

    Integer(Integer &&source) : m_ptr(source.m_ptr) {
        source.m_ptr = nullptr;
        std::cout << "移动构造" << std::endl;
    }

    ~Integer() { delete m_ptr; }

    void printInfo(char *msg) {
        std::cout << "输出位置:" << msg << "  地址:" << m_ptr << "  值:" << *m_ptr << std::endl;
    }

private:
    int *m_ptr;
};

Integer getNum() {
    Integer a(100);
    return a;
}

    // 关闭 ROV 返回优化,添加编译选项"-fno-elide-constructors"
    // 参考:https://blog.csdn.net/Snow__Sunny/article/details/127373650
int main(int argc, char const *argv[]) {
    // 1、这里会调用三种构造(只有在关闭ROV优化时才能看出来具体细节)
    // 1.1 getNum函数内部调用有参构造
    // 1.2 此处赋值给引用 a 时会调用移动构造,getNum函数返回的是右值引用
    // 1.3 初始化 b 时会调用对应的复制拷贝构造
    const Integer &a = getNum();
    Integer b(a);
    
	// 2、这里会调用两种构造函数
    // 2.1 getNum函数内部调用有参构造
    // 2.2 初始化 c 时会调用对应的复制拷贝构造
    Integer c(getNum());
    c.printInfo("c");

	// 3、这里会调用三种构造函数
    // 3.1 getNum函数内部调用有参构造
    // 3.2 初始化 d 时会调用移动构造,getNum函数返回的是右值引用
    // 3.3 初始化 e 时会调用对应的复制拷贝构造
    Integer d = getNum();
    Integer e(d);
    
    // 4、初始化 g 时,存在 引用构造 先用 引用构造,若不存在则使用 常量引用构造
    Integer f(10000);
    Integer g(f);
    return 0;
}

  在程序中,参数为 左值引用浅拷贝构造函数 的做法相当于前面说的的移动构造。

  当同时存在参数类型为常量左值引用 Integer(const Integer& source)左值引用 Integer(Integer& source)的拷贝构造函数时,getNum()返回的临时对象(右值)只能选择前一种,非匿名对象 f(左值)系统会优先选择后者,后者不存在时也可以选择前者,优先选择后者是因为该情况后者比前者好。

  为什么 getNum 返回的临时对象(右值)只能选择前者?这是因为 常量左值引用 可以接受 左值、右值、常量左值、常量右值,而 左值引用 只能接受 左值 。所以对于右值来说,在使用 左值引用类型浅拷贝构造函数Integer(Integer& source)时并不能实现完美转发。还有一种办法——右值引用。

移动构造实现

  移动构造函数 的参数和 复制拷贝构造函数 不同,复制拷贝构造函数 的参数是一个左值引用,但是移动构造函数的参数是一个右值引用。这意味着 移动构造函数 的参数是一个 右值 或者 即将消亡的值的引用(例如函数返回的临时变量,匿名变量等等)。也就是说只有当用一个 右值 或者 将亡值 初始化另一个对象的时候才会调用移动构造函数。移动构造函数的例子如下:

Integer(Integer &&source) : m_ptr(source.m_ptr) {
    source.m_ptr = nullptr;
    std::cout << "移动构造" << std::endl;
}

int main(int argc, char *argv[], char *env[]) {
    Integer a(getNum()); 
    Integer temp(10000);
    Integer b(temp);
    return 0;
}

  解释: getNum 函数中返回了一个栈变量,所以它此时就是一个临时变量,因为在函数结束后它就消亡了,对应的其动态内存也会被析构掉,所以系统在执行 return 函数之前,需要再生成一个临时对象将 a 中的数据内容返回到被调的主函数中,此处自然就有两种解决方法:1、调用复制构造函数进行备份;2、使用移动构造函数把即将消亡的且仍需要用到的这部分内存的所有权进行转移,手动延长它的生命周期。

  很明显前者需要深拷贝操作依次复制全部数据,而后者只需要“变更所有权”即可。

  上面的运行结果中第一次析构就是 getNum 函数最后一句return a; 这个临时对象在转移完内存所用权之后就析构了。

  此处更需要说明的是:遇到这种情况时,编译器会很智能帮你选择类内合适的构造函数去执行,如果没有移动构造函数,它只能默认的选择复制构造函数,而同时存在移动构造函数和复制构造函数则自然会优先选择移动构造函数。
  比如上述程序如果只注释掉移动构造函数而其他不变,运行后结果如下:原来调用了移动构造函数的地方变成了调用拷贝构造函数。

注:移动构造的 && 是右值引用,而 getNum 函数返回的临时变量就是右值

【思考】
  1、移动构造函数的第一个参数必须是自身类型的右值引用(不需要const,为啥?右值使用const没有意义),若存在额外的参数,任何额外的参数都必须有默认实参

  2、看移动构造函数体里面,我们发现参数指针所指向的对象转给了当前正在被构造的指针后,接着就把参数里面的指针置为空指针(source.m_ptr= nullptr;),对象里面的指针置为空指针后,将来析构函数析构该指针(delete m_ptr;)时,是delete一个空指针,不发生任何事情,这就是一个移动构造函数。

  3、有个疑问希望有识之士解答:匿名变量也是右值,为什么上面的程序换成 Integer a(Integer(100)); 后运行却不会调用移动构造函数?

移动构造优点

  移动构造函数是c++11的新特性,移动构造函数传入的参数是一个右值 用&&标出。

  首先讲讲拷贝构造函数:拷贝构造函数是先将传入的参数对象进行一次深拷贝,再传给新对象。这就会有一次拷贝对象的开销,拷贝的内存越大越耗费时间,并且进行了深拷贝,就需要给对象分配地址空间。而移动构造函数就是为了解决这个拷贝开销而产生的。

  移动构造函数首先将传递参数的内存地址空间接管,然后将内部所有指针设置为nullptr,并且在原地址上进行新对象的构造,最后调用原对象的的析构函数,这样做既不会产生额外的拷贝开销,也不会给新对象分配内存空间。即提高程序的执行效率,节省内存消耗。

左值、右值、左值引用、右值引用

  何为左值?能用取址符号 & 取出地址的皆为左值,剩下的都是右值。

  同时匿名变量一律属于右值

int i = 1; // i 是左值,1 是右值

int GetZero {
    int zero = 0;
    return zero;
}
// j 是左值,GetZero() 是右值,因为返回值存在于寄存器中
int j = GetZero();

// s 是左值,string("no name") 是匿名变量,是右值
string s = string("no name");

std::move

  std::move() 能把左值强制转换为右值。

  移动构造实现一节的例程把语句 Integer b(temp); 改为 Integer b(std::move(temp)); 后运行。

  对比移动构造实现一节的例程运行结果发现,非匿名对象 temp (左值)在加了std::move之后强制转为右值也能做 只接收右值的移动拷贝函数 的参数了,因此编译器在这里调用了移动拷贝函数。

参考

原文地址:https://blog.csdn.net/weixin_44788542/article/details/126284429

posted @ 2023-11-02 22:20  黄河大道东  阅读(259)  评论(0编辑  收藏  举报