[C++特性]对std::move和std::forward的理解

发布时间 2023-05-10 09:28:35作者: imxiangzi

左值、右值、左值引用以及右值引用

std::move和std::forward这两个API主要服务于左值引用和右值引用的转化和转发,因此再了解这两个API之前,需要先弄清楚这几个概念。

左值:一般指的是在内存中有对应的存储单元的值,最常见的就是程序中创建的变量

右值:和左值相反,一般指的是没有对应存储单元的值(寄存器中的立即数,中间结果等),例如一个常量,或者表达式计算的临时变量

int x = 10 

int y = 20

int z = x + y 

//x, y , z 是左值

//10 , 20,x + y 是右值,因为它们在完成赋值操作后即消失,没有占用任何资源

左值引用:C++中采用 &对变量进行引用,这种常规的引用就是左值引用

右值引用:这个概念实际上不是说对上述的右值进行引用(因为右值本身也没有对应的存储单元),右值引用实际上只是一个逻辑上的概念,最大的作用就是让一个左值达到类似右值的效果(下面程序举例),让变量之间的转移更符合“语义上的转移”,以减少转移之间多次拷贝的开销。右值引用符号是&&。

例如,对于以下程序,我们要将字符串放到vector中,且我们后续的代码中不再用到x:

std::vector<std::string> vec;

std::string x = "abcd";

vec.push_back(x);

std::cout<<"x: "<<x<<"\n";

std::cout<<"vector: "<< vec[0]<<"\n";

 

//-------------output------------------

// x: abcd

// vector: abcd

该程序在真正执行的过程中,实际上是复制了一份字符串x,将其放在vector中,这其中多了一个拷贝的开销和内存上的开销。但如果x以及没有作用了,我们希望做到的是 真正的转移,即x指向的字符串移动到vector中,不需要额外的内存开销和拷贝开销。因此我们希望让变量 x传入到push_back 表现的像一个右值 ,这个时候就体现右值引用的作用,只需要将x的右值引用传入就可以。

std::move

前面提到了右值引用的主要作用是减少不必要的拷贝开销和内存开销。而std::move的作用就是进行无条件转化,任何的左值/右值通过std::move都转化为右值引用。将上面的程序改写成右值引用的方式

std::vector<std::string> vec;

std::string x = "abcd";

vec.push_back(std::move(x));

std::cout<<"x: "<<x<<"\n";

std::cout<<"vector: "<< vec[0]<<"\n";

//-------------output------------------

// x: 

// vector: abcd

可以看到,完成`push_back`后x是空的。

使用场景

对于一个值(比如数组、字符串、对象等)如果在执行某个操作后不再使用,那么这个值就叫做将亡值(Expiring Value),因此对于本次操作我们就没必要对该值进行额外的拷贝操作,而是希望直接转移,尽可能减少额外的拷贝开销,操作后该值也不再占用额外的资源,此时就可以使用std::move。

举个例子

#include <iostream>

#include <vector>

#include <string>

 

class A {

  public:

    A(){}

    A(size_t size): size(size), array((int*) malloc(size)) {

        std::cout 

          << "create Array,memory at: "  

          << array << std::endl;

        

    }

    ~A() {

        free(array);

    }

    A(A &&a) : array(a.array), size(a.size) {

        a.array = nullptr;

        std::cout 

          << "Array moved, memory at: " 

          << array 

          << std::endl;

    }

    A(A &a) : size(a.size) {

        array = (int*) malloc(a.size);

        for(int i = 0;i < a.size;i++)

            array[i] = a.array[i];

        std::cout 

          << "Array copied, memory at: " 

          << array << std::endl;

    }

    size_t size;

    int *array;

};

int main() {

    std::vector<A> vec;

    A a = A(10);

    vec.push_back(a);   

    return 0;   

}

 

//----------------output--------------------

// create Array,memory at: 0x600002a28030 // A a = A(10); 调用了 构造函数A(size_t size){}

// Array copied, memory at: 0x600002a28050 //vec push的时候拷贝一份,调用构造函数A(A &a){}

从输出可以看到,每次进行push_back的时候,会重新创建一个对象,调用了左值引用A(A &a) : size(a.size)对应的构造函数,将对象中的数组重新深拷贝一份,如果对象占用内存大,并且该对象此时已经是一个将亡值,那么这样带来了许多不必要的开销,降低了程序的性能。这个时候就可以用右值引用进行优化,避免拷贝的开销

int main () {

    std::vector<A> vec;

    A a = A(10);

    vec.push_back(std::move(a));   

    return 0;   

}

 

//----------------output--------------------

// create Array,memory at: 0x600003a84030

// Array moved, memory at: 0x600003a84030

可以看到,这个时候虽然也重新创建了一个对象,但是调用的是这个构造函数A(A &&a) : array(a.array), size(a.size)这种采用右值引用作为参数的构造函数又称作移动构造函数),此时不需要额外的拷贝操作,也不需要新分配内存。

std::forward

std::forward的作用是完美转发,如果传递的是左值转发的就是左值引用,传递的是右值转发的就是右值引用。

在具体介绍std::forward之前,需要先了解C++的引用折叠规则,对于一个值引用的引用最终都会被折叠成左值引用或者右值引用。

T& & -> T& (对左值引用的左值引用是左值引用)

T& && -> T& (对左值引用的右值引用是左值引用)

T&& & ->T& (对右值引用的左值引用是左值引用)

T&& && ->T&& (对右值引用的右值引用是右值引用)

只有对于右值引用的右值引用折叠完还是右值引用,其他都会被折叠成左值引用,根据折叠规则,可以构造出一个通用引用。

#include<iostream>

template <typename T>

void foo(T&& param){

   if(std::is_rvalue_reference<decltype(param)>::value)

        std::cout<<"rvalue reference\n";

    else std::cout<<"lvalue reference\n";

}

int main(){

    int a = 0;

    foo(a);  

    foo(std::move(a)); 

    return 0;

}

 

//------------output----------

// lvalue reference

// rvalue reference

foo(a) ,T就是int &,则param的类型为T&&->int & &&->int &

foo(std::move(a)),std::move转成右值引用,那么T就是int&&,则param的类型为T &&->int && &&->int &&

前面提到的std::move可以减少不必要的拷贝开销,可以提高程序的效率,但是std::forward的作用是转发,左值引用转发成左值引用,右值引用还是右值引用,刚开始一直想不通这个API的意义到底是什么?

原来是在程序的执行过程中,对于引用的传递实际上会有额外的隐式的转化,一个右值引用参数经过函数的调用转发可能会转化成左值引用,但这就不是我们希望看到的结果。

在上面的程序上进行修改

#include <iostream>

#include <vector>

#include <string>

 

class A {

  public:

    A(){}

    A(size_t size): size(size), array((int*) malloc(size)) {

        std::cout 

          << "create Array,memory at: "  

          << array << std::endl;

        

    }

    ~A() {

        free(array);

    }

    A(A &&a) : array(a.array), size(a.size) {

        a.array = nullptr;

        std::cout 

          << "Array moved, memory at: " 

          << array 

          << std::endl;

    }

    A(A &a) : size(a.size) {

        array = (int*) malloc(a.size);

        for(int i = 0;i < a.size;i++)

            array[i] = a.array[i];

        std::cout 

          << "Array copied, memory at: " 

          << array << std::endl;

    }

    size_t size;

    int *array;

};

template<typename T>

void warp(T&& param) {

    if(std::is_rvalue_reference<decltype(param)>::value){

        std::cout<<"param is rvalue reference\n";

    }

    else std::cout<<"param is lvalue reference\n";

    A y = A(param);

    A z = A(std::forward<T>(param));

}

int main(){

    A a = A(100);

    warp(std::move(a));

    return 0;   

}

 

//----------------output----------------

// create Array,memory at: 0x600002e60000 //main函数中,A a = A(100);调用构造函数

// param is rvalue reference //使用了std::move,根据引用折叠规则,param是一个右值引用

// Array copied, memory at: 0x600002e60070 // A y = A(param); 可以看到调用的是拷贝的构造函数

// Array moved, memory at: 0x600002e60000。// A z = A(std::forward<T>(param)); 调用了移动构造函数

从程序的输出就可以看到,当一个右值引用再进行转发的时候,没使用std::forward进行二次转发的时候,实际上是会被隐式的转换,转发成一个左值引用,从而调用不符合期待的构造函数,带来额外的开销,所以std::forward的一个重要作用就是完美转发,确保转发过程中引用的类型不发生任何改变,左值引用转发后一定还是左值引用,右值引用转发后一定还是右值引用

总结

如果想要更深入和正确的理解这些概念还是需要看一些官方的资料以及API的实现,通过理解std::move和std::forward源码,感觉这两个API实际上并没有真正的move或者forward任何的数据或资源,真正做的事情只是对数据的类型进行强制的cast,以达到逻辑上(语义上)的区分值的左值引用和右值引用,但实际上要做的事情还需要额外的实现,例如上面程序中使用右值引用区分开了普通的构造函数和移动构造函数以减少拷贝的开销,但是实际上只是起到了区分以调用不同的构造函数,真正减少拷贝开销的还是函数中的程序实现过程,当然你也可以在移动构造函数里面也进行深拷贝,那么额外的开销还是存在的,和普通的构造函数没有什么区别,你也可以在普通构造函数里面不进行深拷贝,只赋值指针,那是不是也可以说成没有额外的开销?甚至即使没有这个右值引用的构造函数,我们实际上也可以引入额外的变量,以区分不同情况下到底是要进行深拷贝还是浅拷贝,也能得到类似的效果,但是从整个程序的逻辑和语义的角度上看,这样的实现就显得有点混乱("高级语言能实现的,用汇编也都能实现"

因此我个人觉得,右值引用、std::move、std::forward是服务于一些特定场景下值的转移和转发,引入这些概念能让程序的语义更加通顺,逻辑更清晰,同时还能避免一些不必要的开销,得到一些性能上的提升。

参考资料

std::move(expr)和std::forward(expr)参数推导的疑问?

浅谈std::move和std::forward原理_爱拼才会赢-CSDN博客_std::forward作用

https://stackoverflow.com/questions/3601602/what-are-rvalues-lvalues-xvalues-glvalues-and-prvalues

C++ 左值、右值与右值引用 | 曜彤.手记

 

 

from:https://zhuanlan.zhihu.com/p/469607144