带你一起探索 c++11 中右值引用、移动构造、&&、move、forward

本文将介绍带你一步步的了解 c++11 中:

  • 右值、右值引用
  • 移动构造函数
  • && 解密
  • move 移动语义
  • forward 完美转发

产生原由

class Object
 {
 public:    
     //无参构造函数   
     Object() : m_num(new int(10))    
     {        
         std::cout << "contr function..." << std::endl;        
         printf("m_num 地址:%p\n", m_num);    
     }        
     //拷贝构造函数    
     Object(const Object& o) : m_num(new int(*o.m_num))    
     {       
         std::cout << "copy contr function..." << std::endl;   
     }       
 private:    
     int* m_num;
 };

我们知道,某个类中如果含有指向堆内存的(一般含有指针)成员变量,如果不编写拷贝构造函数,那么编译器将调用默认的拷贝构造函数,即只进行浅拷贝。即下图中只拷贝了 a 指针,将会出现 a、b指向同一块内存,为了防止堆区地址双重释放,那么应该编写 拷贝构造函数 防止浅拷贝发生问题。即在拷贝构造函数中重新分配一块内存进行初始化。

1616898818316.png

问题:在 c++11 中引入了右值引用和移动构造函数又是为了什么呢?

下面的代码中调用 getObj 函数初始化一个对象 oo1, 分析其执行过程:

1、getObj 函数中初始化一个临时对象 temp, 调用构造函数;

2、将临时对象赋值给 oo1, 调用拷贝构造函数;

class Object
{
    ...
}

Object getObj() {
    //1、初始化一个临时对象 temp, 调用构造函数;
    Object temp;
    return temp;
}

int main() {
    //2、将临时对象赋值给 `oo1`, 调用拷贝构造函数;
    Object oo1 = getObj();
    return 0;
};

执行结果:

contr function...          
m_num 地址:00E7F6D8
copy contr function...

根据执行结果,与预期一致。

问题: 如果在第一步中,在调用 getObj时,创建的临时对象 temp 在构造过程中如果要进行大量的初始化工作(特别耗时),并且其用完后将被释放; 第二步中,将临时对象拷贝给 oo1 时,也需要进行大量的拷贝工作。oo1 在生命周期结束后也将释放。

思考: 中间产生了临时对象 temp 只起到赋值作用,极大的耗费性能。有什么方式可以避免产生这个中间的临时变量呢?怎么去优化它呢?

答案: c++ 11 的右值引用。

右 值

c++11 中引入了右值的概念,使用 && 标记。

不必要去记其概念,只需要知道怎么去判别即可,可以被取地址的即为左值,反之为右值

int x = 1000;
int y = 2000;
x = y;

其中, 等号左边的 x、y 可以取地址,即为左值; 等号右边 1000、 2000 为右值; 处于等号左右的 x = y 中,因为其都可以进行取地址,所以都为左值。

右值引用

右值引用也即是一个引用,和左值引用一样,只不过左值引用是左值的别名。右值不具备名字,所以只能使用右值引用标记它。因为左值引用和右值引用都是别名,不拥有所绑定对象的内存,所以必须进行初始化操作。右值被右值引用接收后重新有了名字,只要该引用变量存活,,右值也将存活。即右值引用可以延长某块内存的存活时间。

int&& data = 1000;  //必须进行初始化

class Object
{
public:
    Object()
    {
        std::cout << "contr function..." << std::endl;
    }
    Object(const Test& a)
    {
        std::cout << "copy contr function..." << std::endl;
    }
};

Object getObj()
{
    return Object();
}

int main()
{
    int a1;
    int &&a2 = a1;        // error
    Object& t = getObj();   // error
    Object && t = getObj();
    const Object& t = getObj();
    return 0;
}

  • int &&a2 = a1; a1 具有名字,其为左值,左值赋值给右值引用 错误
  • Object& t = getObj(); getObj 函数返回一个没有名字的右值,将一个右值赋值给左值引用 错误
  • Object && t = getObj(); 右值赋值给右值引用 正确
  • const Object& t = getObj(); 常量左值引用被成为万能引用,既可以引用左值也可以引用右值 正确

性能优化

介绍完右值和右值引用, 在回到上面的问题:中间产生了临时对象temp只起到赋值作用,极大的耗费性能。有什么方式可以避免产生这个中间的临时变量呢?怎么去优化它呢?

class Object
{
    ...
}

Object getObj() {
    //1、初始化一个临时对象 temp, 调用构造函数;
    Object temp;
    return temp;
}

int main() {
    //2、将临时对象赋值给 `oo1`, 调用拷贝构造函数;
    Object oo1 = getObj();
    return 0;
};

getObj 函数中创建的临时对象(堆上)构建完成后,还没有使用,就释放掉了,那么如果可以复用这个临时对象,将会对性能有很大帮助。

那么如何复用呢? 给该类编写移动构造函数即可。

class Object
{
    ...
    //移动构造函数
    Object(Object&& o) {
        m_num = o.m_num;
        o.m_num = nullptr;
        std::cout << "move contr function..." << std::endl;
    }

private:
    int* m_num;
}

Object getObj() {
    //1、初始化一个临时对象 temp, 调用构造函数;
    Object temp;
    return temp;
}

int main() {
    //2、 调用移动构造函数
    Object oo1 = getObj();
    return 0;
};

执行结果:

contr function...
m_num 地址:00CCF5B0
move contr function...

执行结果调用了移动构造函数。那么我们分析移动构造函数中发生了什么?

 //移动构造函数
Object(Object&& o) {
    m_num = o.m_num;
    o.m_num = nullptr;
    std::cout << "move contr function..." << std::endl;
}
...

Object oo1 = getObj();

因为临时对象用完就释放,白白构造那么长时间。在执行 Object oo1 = getObj(); 这条语句是,调用移动构造函数将临时对象 temp 所指的内存直接赋值给 oo1 对象的指针(m_num = o.m_num;); 然后避免 temp 出了作用域销毁内存,则将 temp 指向的内存置空(o.m_num = nullptr;)。 oo1 对象直接拥有了 构造 temp 时分配的内存。

1616903425781.png

上图充分的展示了拷贝构造函数和移动构造函数的关系,可以看出,移动构造函数整体上少分配了一块内存,相当于浅拷贝,只不过最后将原始指针置空,因此极大的节省了空间和时间。

问题1: 什么时候会调用移动构造函数?

答案: 要求右侧的对象是一个临时对象,才会调用移动构造函数,如果没有移动构造函数,则将调用拷贝构造函数。因此可以看出,移动构造函数不是必须存在的,只是为了性能优化而存在的。

可以将上述代码中移动构造函数注释掉,编译器将会调用拷贝构造函数。

问题2: 怎么编写移动构造函数呢?

从上面可以看出,移动构造函数实质是为了复用其他对象的资源而产生的,这种资源往往是堆内存的资源。那么在编写移动构造函数过程中,只需要转移该类中关于堆上的资源即可。

右值符号 && 解密

在很多代码模板函数中经常会出现诸如以下的代码:

1、 static_cast::type&&>(_Arg)

2、 typename void function(T&& t)

代码中的 && 会不会让你晕头转向? 如果是,那么和我一起解密吧!

c++ 中有一种叫做未定义的引用类型,通常有以下两种方式:

自动类型推导的  auto&&
模板类型推导的  T&&

有一种特列 const T&&, 表示右值引用,不属于未定义类型引用。

那么接下来记住两个规则即可,不对,是一个规则(引用折叠):

使用右值推导 T&& 和 auto&& 得到的是一个右值引用类型;其他的都是左值引用类型。

int a = 10;
int b = 250;
auto&& x = a;  //a 是一个左值, auto&& 表示左值引用
auto&& y = 100;  //100 是右值, auto&& 表示右值引用

int&& a1 = 5; 
auto&& b1 = a1; //a1是右值引用,不是右值,所以b1 是左值引用

int a2 = 10;  
int& a3 = a2;   //a2是左值,a3为左值引用
auto&& c1 = a3; // a3是左值引用, c1 即是左值引用类型
auto&& c2 = a2; // a2是左值, c2 即是左值引用类型

const int& d1 =3;  
const int&& d2 = 4;
auto&& e1 = d1;  //d1是常量左值引用, e1 即为常量左值引用
auto&& e2 = d2;  //d2是常量右值引用, e2 即为常量左值引用

通过以上既可以理解,使用右值推导 T&& 和 auto&& 得到的都是右值引用类型,其他的都是左值引用类型。

void printX(int &x)
{
    std::cout << "l-value: " << x << std::endl;
}

void printX(int &&x)
{
    cout << "r-value: " << x << endl;
}

void forward(int &&x)
{
    printX(x);
}

int main()
{
    int a = 100;
    printX(a);
    printX(20);
    forward(500);

    return 0;
    system("pause");
};

上面定义了两个重载函数 printX, 先对上面的输出结果进行推导:

1、 printX(a); 其中 a 是一个左值,那么调用第一个 printX函数,输出应该是左值;

2、 printX(20); 其中 20 是右值, 那么调用第二个printX函数, 输出应该是右值;

3、 forward(500), 其中 500 是右值,forward 形参x 是右值引用类型,继续调用printX,由于此时的右值具备的名字,所以将退化成一个左值,所以将调用第一个 printX 函数, 输出应该是左值;

对于最后以重情况可能稍微难以理解,只需要记住,右值引用在传递的过程中将会退化成左值引用。这也是 std::forward 为了防止退化成左值引用,所以才出现的,被誉为完美转发 ,即不做任何变动的转发。将上述 forward 函数中的 printX(x)改成 printX(std::forward(x)) 将会调用第二个函数,输出应该是右值。

输出结果:

l-value: 100
r-value: 20
l-value: 500

结果完全正确。

std::move

在上述的例子中,有一种情况是不能进行赋值的,即用一个左值初始化一个右值引用;

int a = 10;
int&& b = a;  //error 无法将右值引用绑定到左值

所以 std::move 函数应运而生, move 通常被理解为“移动”, 但在本文中被译为“转移”更加合适,即转移所有权,将你的房产名字转给你的老婆,房子本身不变,只是所有者发生了变化。

再来看 std::move 的源代码:

template inline
    _CONST_FUN typename remove_reference<_Ty>::type&&
        move(_Ty&& _Arg) _NOEXCEPT
    {   // forward _Arg as movable
    return (static_cast::type&&>(_Arg));
    }

使用 std::move 后,上述代码可以更改为:

int a = 10;
int&& b = std::move(a);  //ok 

对于这种将左值转化为右值的方法有什么用处呢?

vector vec;
vec.push_back("wang");
vec.push_back("zhuo");
.....
//插入一百万条数据
.....
vector vec1 = vec;
vector vec2 = std::move(vec);

如果用 vec 这个左值直接初始化 vec1,将会发生大量的内存拷贝。y

如果用 vec2 = std::move(vec), 直接将 vec 的所有权转移给 vec2即可

用处? 在对于拥有大量的堆内存或者动态数组时候,使用 std::move 可以有效的节省时间效率。

如果将 std::move 和 移动构造函数结合起来,尽可能重复利用资源, 移动构造函数接收的是一个右值引用类型。

std::forward

上文也提及到了完美转发 forward,即在右值引用传递的过程中,为了防止被编译器当作左值处理,使其以原由类型进行转发,引入了 forward。

原型如下:

std::forward(x);

当forward 的模板类型参数 T 为左值引用类型时,x将被转换成 T类型的左值,否则将被转换成右值。

template
void printX(T& t)
{
    std::cout << "left value"<< std::endl;
}

template
void printX(T&& t)
{
    std::cout << "rifht value " <
void test(T && v)
{
    printX(v);
    printX(move(v));
    printX(forward(v));
}

int main()
{
    test(100);
    int num = 10;
    test(num);
    test(forward(num));
    test(forward(num));
    test(forward(num));

    return 0;
}

1、test(100), 100 是右值,test形参是未定义引用类型,即根据上文提到的 ”使用右值推导 T&&auto&& 得到的是一个右值引用类型“ 形参 v 是一个具有名字的右值引用,但编译器将其视为左值。

    * 传递给第一个函数 `printX `变为左值,调用第一个,输出左值。
    * 使用`move(v)` 之后,左值 v 被 move 成右值,输出右值。
    * `printX(forward(v))`, T为右值引用,因此最终将会成为右值, 输出右值

2、test(forward(num)); 模板参数为int&, 根据 ”当forward 的模板类型参数 T 为左值引用类型时,x将被转换成 T类型的左值“ , 将会得到一个左值,test 形参为未定义类型 T&&, 根据 ”使用右值推导 T&&auto&& 得到的是一个右值引用类型,反之为左值引用“,即test 形参为左值引用类型。

* `printX(v);`  v 是左值, 输出左值
* `printX(move(v));` 左值经过 `move` 后成为右值,输出右值
* `printX(forward(v));` T类型为 `int&` ,v将被转换成左值, 输出左值。

你学会了吗? 其他的几种留给你自己分析。

你可能感兴趣的:(带你一起探索 c++11 中右值引用、移动构造、&&、move、forward)