右值引用

121 阅读2分钟

指针成员与拷贝构造

编写C++程序有一条必须注意的规则,就是在类中包含了一个指针成员的话,要特别小心拷贝构造函数的编写,因为一不小心,就会出现内存泄漏。

class HasPtrMem {
public:
    HasPtrMem() : d(new int(0)) {}

    HasPtrMem(const HasPtrMem &h) : d(new int(*h.d)) {}

    ~HasPtrMem() {
        delete d;
    }

    int *d;
};

#include <iostream>
#include "HasPtrMem.h"

using namespace std;

int main() {
    HasPtrMem a;
    HasPtrMem b(a);

    cout << *a.d << endl;
    cout << *b.d << endl;

}

我们为 HasPtrMem 添加了一个拷贝构造函数,拷贝构造函数从堆中重新分配内存,将该分配来的内存的指针交还给 d,又使用 *(h.d) 对 *d 进行了初始化,避免了悬挂指针的困扰。

移动语义

有些时候,我们不需要拷贝构造语义。

#include <iostream>

using namespace std;

class HasPtrMem {
public:
    HasPtrMem() : d(new int(0)) {
        cout << "Construct: " << ++n_cstr << endl;
    }

    HasPtrMem(const HasPtrMem &h) : d(new int(*h.d)) {
        cout << "Copy Construct: " << ++n_cptr << endl;

    }

    ~HasPtrMem() {
        cout << "Destruct: " << ++n_dstr << endl;
        delete d;
    }

    int *d;

    static int n_cstr;
    static int n_dstr;
    static int n_cptr;
};


int HasPtrMem::n_cstr = 0;
int HasPtrMem::n_dstr = 0;
int HasPtrMem::n_cptr = 0;


HasPtrMem GetTemp() { return HasPtrMem(); }

int main() {
    HasPtrMem a = GetTemp();
}

注意关闭编译优化:add_compile_options(-fno-elide-constructors)

输出:

Construct: 1
Copy Construct: 1
Destruct: 1
Copy Construct: 2
Destruct: 2
Destruct: 3

image.png

  • 构造函数调用了一次,GetTemp 函数中 HasPtrMem() 表达式显示地调用了构造函数。
  • 拷贝构造函数调用了两次,一次是从 GetTemp 函数中 HasPtrMem() 生成的变量上拷贝构造出一个临时值,以用作 GetTemp 的返回值;另一次则是由临时值构造出 main 中变量 a 调用的。
  • 相应的析构函数调用了 3 次。

如果 HasPtrMem 的指针指向非常大的堆内存数据的话,那么拷贝构造的过程就会非常昂贵。

让我们把目光再次聚集在临时对象上,按照 C++ 的语义,临时对象将在语句结束后被析构,会释放它所包含的堆内存资源。而 a 在拷贝构造的时候,又会被分配堆内存,这样一去一来似乎没有太大的意义。

移动构造函数

在构造时使得 a.d 指向临时对象的堆内存资源,同时保证临时对象不释放所指向的堆内存,那么在构造完成后,临时对象被析构,a 就从中 “偷” 到了临时对象所拥有的堆内存资源。

image.png

在C++11中,这样的“偷走”临时变量中资源的构造函数,就被称为“移动构造函数”,而这样的“偷”的行为,则称之为“移动语义”(move semantices)。

#include <iostream>

using namespace std;

class HasPtrMem {
public:
    HasPtrMem() : d(new int(0)) {
        cout << "Construct: " << ++n_cstr << endl;
    }

    HasPtrMem(const HasPtrMem &h) : d(new int(*h.d)) {
        cout << "Copy Construct: " << ++n_cptr << endl;

    }

    HasPtrMem(HasPtrMem &&h):d(h.d) {
        h.d = nullptr;
        cout << "Move construct: " << ++n_mvtr << endl;
    }

    ~HasPtrMem() {
        cout << "Destruct: " << ++n_dstr << endl;
        delete d;
    }

    int *d;

    static int n_cstr;
    static int n_dstr;
    static int n_cptr;
    static int n_mvtr;
};

HasPtrMem GetTemp() {
    HasPtrMem h;
    cout << "Resource from " << __func__ << ": " << hex << h.d << endl;
    return h;
}

int main() {
    HasPtrMem a = GetTemp();
    cout << "Resource from " << __func__ << ": " << hex << a.d << endl;

}

输出:

Construct: 1
Resource from GetTemp: 0x600001e88030
Move construct: 1
Destruct: 1
Move construct: 2
Destruct: 2
Resource from main: 0x600001e88030
Destruct: 3

移动构造函数使用了参数 h 的成员 d 初始化了本对象的成员d(而不是像拷贝构造函数一样需要分配内存,然后将内存依次拷贝到新分配的内存中),而 h 的成员 d 随后被置为指针控制 nullptr。

可以看到,这里没有调用拷贝构造函数,而是调用了两次移动构造函数,移动构造的结果是 GetTemp 中的 h 的指针成员 h.d 和 main 函数中的 a 的指针成员 a.d 的值是相同的,即 h.d 和 a.d 都指向了相同的堆地址内存。

那移动构造函数何时会被触发?

左值、右值与右值引用

可以取地址的、有名字的就是左值,反之,不能取地址的、没有名字的就是右值。

在C++1中,右值是由两个概念构成的,一个是将亡值(xvalue, eXpiring Value),另一个是纯右值(prvalue, Pure Rvalue)。

纯右值:

  • 非引用返回的函数返回的临时变量值
  • 一些运算表达式,如 1 + 3 产生的临时变量
  • 不跟对象关联的字面量值,如 2、'c'、true

将亡值:

  • 返回右值引用 T&& 的函数返回值
  • std::move 的返回值
  • 转换为 T&& 的类型转换函数的返回值

在C++11中,右值引用就是对一个右值进行引用的类型。

T &&a = ReturnRvalue();

ReturnRvalue 函数返回的右值在表达式语句结束后,其生命也就终结了,而通过右值引用的声明,该右值又”重获新生“,其生命周期将与右值引用类型变量 a 的生命周期一样。

所以相比于一下语句的声明方式

T b = ReturnRvalue();

右值引用变量声明,就会少一次对象的析构以及一次对象的构造。

参考

一文读懂C++右值引用和std::move
C++ moves for people who don’t know or care what rvalues are