std::move和std::forward

发布于 2021-09-15  75 次阅读


基础知识

C++在C++11中引入了右值引用和移动语义的概念,在此基础上才有了std::move和std::forward这两个语法糖。首先需要明白什么是右值引用。

右值引用

左值和右值很好理解,左值可以取地址、位于等号左边;而右值没法取地址,位于等号右边。左值是变量,存在地址。右值是字面量,没有地址。举个例子:

struct A {

    A(int a = 0) {
        a_ = a;
    }
 
    int a_;
};
 
A a = A();

其中最后一行,a是一个变量,有自己的地址,是左值。A()是调用了构造函数,其结果临时存在于内存里,没法直接取地址,是右值。

当引用出现的时候情况又不一样了,引用实际上是对指针的包装,是一个变量的别名。

左值引用很常见,就是创建一个变量的别名:

int a = 5;
int &ref_a = a; // 左值引用指向左值,编译通过
int &ref_a = 5; // 左值引用指向了右值,会编译失败

普通的左值引用不能用来指向一个右值,但一个const的左值引用是可以指向右值的:

const int &ref_a = 5;  // 编译通过

这也就是某些函数使用const &作为函数参数的原因之一,使其可以接受一个字面量引用,避免额外的拷贝开销

这时候C++引入右值引用是干啥呢?就是避免额外的拷贝开销。右值引用的本质很简单,就是把临时的无法取地址的数据,直接原地构造成一个左值!这些数据既然存在于内存,那么它一定是已经被分配了内存空间,不同的是这些数据的生存期很短,随用随丢。但我们可以构造一个右值引用指向这些数据,延长它们的生命周期。

就像这样:

int &&ref_a = 5;
ref_a = 6; 

编译器已经在内存里分配了这个字面量5的内存空间,但是我们无法直接修改。这时候创建一个右值引用,它可以直接指向这个空间,我们就可以像对待普通变量一样修改它。

所以本质上,左值引用和右值引用都是一个对指针的包装。不同的部分就是在语义上。右值引用就是为了移动语义而生的。

移动语义

在对象的复制过程中,有两个概念:浅拷贝和深拷贝。

浅拷贝就是依次复制所有的成员变量,一般情况下没啥,但当成员里包含指针的时候,浅拷贝复制的就是指针的地址,这时候就会出现有两个对象共享一个指针。会出大问题。

所以有的时候需要进行深拷贝,即开一片新的内存,完完全全把原先指针指向的地址内容复制过去。

但有的时候深拷贝完全没有必要,我们只想要转移一下变量,比如把某个对象赋值给另一个对象的成员来管理,原先的变量在转移完就不用了。这时候就要用到移动语义:

移动语义执行浅拷贝,直接把源对象的内容地址赋值给新的对象,并把原对象置空,从而避免了深拷贝的开销。同样是对象含有指针的例子,实现移动语义的时候,直接进行浅拷贝,即指针的赋值,之后把源对象的对应指针置空,避免出现非法操作。

所以移动语义一般出现在对象的构造和赋值中,CPP新增了移动构造函数和移动赋值函数用于处理移动语义。

下面就是一个移动构造的例子:

class Array {
public:
    ......
 
    // 优雅
    Array(Array&& temp_array) {
        data_ = temp_array.data_;
        size_ = temp_array.size_;
        // 为防止temp_array析构时delete data,提前置空其data_      
        temp_array.data_ = nullptr;
    }
     
 
public:
    int *data_;
    int size_;
};

右值引用就是用来配合移动语义的,当出现一个右值引用的时候,编译器会认为这里执行的是移动语义,会自动去调用移动构造和移动赋值。所以右值引用的作用就是把一个需要被移动的东西用右值引用来包装,以执行移动语义。

一句话总结

右值引用是对象在<需要拷贝且被拷贝者之后不再被需要>的场景,用于触发移动语义的。

std::move

前面说到,右值引用和左值引用本质上没有任何区别,只是在语义上不同。所以我们可以通过CPP的强制类型转换来进行转换。

std::move 就是C++给的一个语法糖,它可以强制把一个左值引用转换成右值引用,内部使用static_cast

当使用了 std::move 之后,就可以触发移动语义了:

int main() {
    std::string str1 = "aacasxs";
    std::vector<std::string> vec;
     
    vec.push_back(str1); // 传统方法,copy
    vec.push_back(std::move(str1)); // 调用移动语义的push_back方法,避免拷贝,str1会失去原有值,变成空字符串
}

这里的str1就是 <需要拷贝且被拷贝者之后不再被需要> 的场景,想要把一个字符串对象塞到容器里头。用上移动语义就能避免拷贝。

这也就是为什么说 std::move 并没有move任何东西,只是用来触发移动语义。

std::forward

std::forward也充满了迷惑性,虽然名字含义是转发,但他并不会做转发,同样也是做类型转换。

在原理上涉及到C++的语法规则:万能引用(T &&)引用折叠(eg:& && → ?)

std::forward 的出现也是因为右值引用。现在出现了两种引用,在函数的参数传递过程中,各种引用的类型可能会发生变化。 std::forward 就是在传递参数的过程中,根据要求转换引用的类型,其定义如下:

std::forward<T>(u)有两个参数:T与 u。

  • 当T为左值引用类型时,u将被转换为T类型的左值;
  • 否则u将被转换为T类型右值。

看个例子:

void change2(int&& ref_r) {
    ref_r = 1;
}
 
void change3(int& ref_l) {
    ref_l = 1;
}
 
// change的入参是右值引用
// 有名字的右值引用是 左值,因此ref_r是左值
void change(int&& ref_r) {
    change2(ref_r);  // 错误,change2的入参是右值引用,需要接右值,ref_r是左值,编译失败
     
    change2(std::move(ref_r)); // ok,std::move把左值转为右值,编译通过
    change2(std::forward<int &&>(ref_r));  // ok,std::forward的T是右值引用类型(int &&),符合条件b,因此u(ref_r)会被转换为右值,编译通过
     
    change3(ref_r); // ok,change3的入参是左值引用,需要接左值,ref_r是左值,编译通过
    change3(std::forward<int &>(ref_r)); // ok,std::forward的T是左值引用类型(int &),符合条件a,因此u(ref_r)会被转换为左值,编译通过
    // 可见,forward可以把值转换为左值或者右值
}
 
int main() {
    int a = 5;
    change(std::move(a));
}

可以看到 std::forward本质就是把参数转换成所需要的引用类型,并没有转发任何东西。

通常std::forward是在模板编程中,对参数进行转发使用的,例如:

template<typename F, typename... Args>
auto add(int timeout, F&& f, Args&&... args) -> std::pair<int,std::future<decltype(f(args...))>> {
    auto taskPtr = std::make_shared<std::packaged_task<decltype(f(args...))()>>(
        std::bind(std::forward<F>(f), std::forward<Args>(args)...)
    );
}

作用就是把传入的引用类型原封不动的传给std::bind,传左值引用就转成左值引用,只要不是传左值引用,就会被转换成右值引用。


当其他人都认为你要鸽的时候,你鸽了,亦是一种不鸽