(转)一文读懂C++右值引用和std::move

目录

1. 什么是左值、右值

2. 什么是左值引用、右值引用

2.1 左值引用

2.2 右值引用

2.3 对左右值引用本质的讨论

2.3.1 右值引用有办法指向左值吗?

2.3.2 左值引用、右值引用本身是左值还是右值?

3. 右值引用和std::move的应用场景

3.1 实现移动语义

3.2 实例:vector::push_back使用std::move提高性能

4. 完美转发 std::forward


原文链接:一文读懂C++右值引用和std::move - 知乎 (zhihu.com)

阅读之前,思考几个问题,带着问题去读文章

  • 什么是左值、右值?右值没有地址吗?
  • 为什么?是什么?怎么用?
  • 左值引用、右值引用是左值还是右值?
  • 在C++11 右值引用和move出现之前,C++如何解决拷贝构造的开销的?
  • std::move用来做什么?
  • 有没有思考过vector.push_back()/emplace_back()为什么能直接传入值?
  • 了解了左值和右值,如何在模板中灵活的运用?

同时,本文原作中也存在着一些问题:move是为了转移对象的所有权,并不是移动对象,跟生活中的移动不一样(日常生活中的移动是把物体从一个地方变动到另外一个地方)

对于上述标红部分,请看另一篇转载的文章:

什么是move?理解C++ Value categories,move, move in Rust - 知乎 (zhihu.com)

此文对move的本质进行了深入的讲解,结合本文会有所收获。 

作者:rickonji 冀铭哲

C++11引入了右值引用,有一定的理解成本,工作中发现不少同事对右值引用理解不深,认为右值引用性能更高等等。本文从实用角度出发,用尽量通俗易懂的语言讲清左右值引用的原理,性能分析及其应用场景,帮助大家在日常编程中用好右值引用和std::move。

1. 什么是左值、右值

首先不考虑引用以减少干扰,可以从2个角度判断:左值可以取地址、位于等号左边;而右值没法取地址,位于等号右边

int a = 5;
  • a可以通过 & 取地址,位于等号左边,所以a是左值。
  • 5位于等号右边,5没法通过 & 取地址,所以5是个右值。

再举个例子:

struct A {
    A(int a = 0) {
        a_ = a;
    }
 
    int a_;
};
 
A a = A();
  • 同样的,a可以通过 & 取地址,位于等号左边,所以a是左值。
  • A()是个临时值,没法通过 & 取地址,位于等号右边,所以A()是个右值。

可见左右值的概念很清晰,有地址的变量就是左值,没有地址的字面值、临时值就是右值

转注:这里原作可能有误,右值并不是没有地址,而是无法取到地址。从汇编的角度来讲,右值可保存在寄存器中,并随后move到一个变量中,并不是没有地址。 

2. 什么是左值引用、右值引用

引用本质是别名,可以通过引用修改变量的值,传参时传引用可以避免拷贝,其实现原理和指针类似。 个人认为,引用出现的本意是为了降低C语言指针的使用难度,但现在指针+左右值引用共同存在,反而大大增加了学习和理解成本。

2.1 左值引用

左值引用大家都很熟悉,能指向左值,不能指向右值的就是左值引用

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

引用是变量的别名,由于右值没有地址,没法被修改,所以左值引用无法指向右值。

但是,const左值引用是可以指向右值的:

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

const左值引用不会修改指向值,因此可以指向右值,这也是为什么要使用const &作为函数参数的原因之一,如std::vectorpush_back

void push_back (const value_type& val);

如果没有constvec.push_back(5)这样的代码就无法编译通过了。

2.2 右值引用

再看下右值引用,右值引用的标志是&&,顾名思义,右值引用专门为右值而生,可以指向右值,不能指向左值

int &&ref_a_right = 5; // ok
 
int a = 5;
int &&ref_a_left = a; // 编译不过,右值引用不可以指向左值
 
ref_a_right = 6; // 右值引用的用途:可以修改右值

2.3 对左右值引用本质的讨论

下边的论述比较复杂,也是本文的核心,对理解这些概念非常重要。

2.3.1 右值引用有办法指向左值吗?

有办法,std::move

int a = 5; // a是个左值
int &ref_a_left = a; // 左值引用指向左值
int &&ref_a_right = std::move(a); // 通过std::move将左值转化为右值,可以被右值引用指向
 
cout << a; // 打印结果:5

在上边的代码里,看上去是左值a通过std::move移动到了右值ref_a_right中,那是不是a里边就没有值了?并不是,打印出a的值仍然是5。

std::move是一个非常有迷惑性的函数,不理解左右值概念的人们往往以为它能把一个变量里的内容移动到另一个变量,但事实上std::move移动不了什么,唯一的功能是把左值强制转化为右值,让右值引用可以指向左值。其实现等同于一个类型转换:static_cast<T&&>(lvalue)。 所以,单纯的std::move(xxx)不会有性能提升,std::move的使用场景在第三章会讲。

同样的,右值引用能指向右值,本质上也是把右值提升为一个左值,并定义一个右值引用通过std::move指向该左值:

int &&ref_a = 5;
ref_a = 6; 
 
等同于以下代码:
 
int temp = 5;
int &&ref_a = std::move(temp);
ref_a = 6;

2.3.2 左值引用、右值引用本身是左值还是右值?

被声明出来的左、右值引用都是左值。 因为被声明出的左右值引用是有地址的,也位于等号左边。仔细看下边代码:

// 形参是个右值引用
void change(int&& right_value) {
    right_value = 8;
}
 
int main() {
    int a = 5; // a是个左值
    int &ref_a_left = a; // ref_a_left是个左值引用
    int &&ref_a_right = std::move(a); // ref_a_right是个右值引用
 
    change(a); // 编译不过,a是左值,change参数要求右值
    change(ref_a_left); // 编译不过,左值引用ref_a_left本身也是个左值
    change(ref_a_right); // 编译不过,右值引用ref_a_right本身也是个左值
     
    change(std::move(a)); // 编译通过
    change(std::move(ref_a_right)); // 编译通过
    change(std::move(ref_a_left)); // 编译通过
 
    change(5); // 当然可以直接接右值,编译通过
     
    cout << &a << ' ';
    cout << &ref_a_left << ' ';
    cout << &ref_a_right;
    // 打印这三个左值的地址,都是一样的
}

看完后你可能有个问题,std::move会返回一个右值引用int &&,它是左值还是右值呢? 从表达式int &&ref = std::move(a)来看,右值引用ref指向的必须是右值,所以move返回的int &&是个右值。所以右值引用既可能是左值,又可能是右值吗? 确实如此:右值引用既可以是左值也可以是右值,如果有名称则为左值,否则是右值

或者说:作为函数返回值的 && 是右值,直接声明出来的 && 是左值。 这同样也符合第一章对左值,右值的判定方式:其实引用和普通变量是一样的,int &&ref = std::move(a)和 int a = 5没有什么区别,等号左边就是左值,右边就是右值。

最后,从上述分析中我们得到如下结论:

  1. 从性能上讲,左右值引用没有区别,传参使用左右值引用都可以避免拷贝。
  2. 右值引用可以直接指向右值,也可以通过std::move指向左值;而左值引用只能指向左值(const左值引用也能指向右值)。
  3. 作为函数形参时,右值引用更灵活。虽然const左值引用也可以做到左右值都接受,但它无法修改,有一定局限性。
void f(const int& n) {
    n += 1; // 编译失败,const左值引用不能修改指向变量
}

void f2(int && n) {
    n += 1; // ok
}

int main() {
    f(5);
    f2(5);
}

3. 右值引用和std::move的应用场景

按上文分析,std::move只是类型转换工具,不会对性能有好处;右值引用在作为函数形参时更具灵活性,看上去还是挺鸡肋的。他们有什么实际应用场景吗?

3.1 实现移动语义

在实际场景中,右值引用和std::move被广泛用于在STL和自定义类中实现移动语义,避免拷贝,从而提升程序性能。 在没有右值引用之前,一个简单的数组类通常实现如下,有构造函数拷贝构造函数赋值运算符重载析构函数等。深拷贝/浅拷贝在此不做讲解。

class Array {
public:
    Array(int size) : size_(size) {
        data = new int[size_];
    }
     
    // 深拷贝构造
    Array(const Array& temp_array) {
        size_ = temp_array.size_;
        data_ = new int[size_];
        for (int i = 0; i < size_; i ++) {
            data_[i] = temp_array.data_[i];
        }
    }
     
    // 深拷贝赋值
    Array& operator=(const Array& temp_array) {
        delete[] data_;
 
        size_ = temp_array.size_;
        data_ = new int[size_];
        for (int i = 0; i < size_; i ++) {
            data_[i] = temp_array.data_[i];
        }
    }
 
    ~Array() {
        delete[] data_;
    }
 
public:
    int *data_;
    int size_;
};

该类的拷贝构造函数、赋值运算符重载函数已经通过使用左值引用传参来避免一次多余拷贝了,但是内部实现要深拷贝,无法避免。 这时,有人提出一个想法:是不是可以提供一个移动构造函数,把被拷贝者的数据移动过来,被拷贝者后边就不要了,这样就可以避免深拷贝了,如:

class Array {
public:
    Array(int size) : size_(size) {
        data = new int[size_];
    }
     
    // 深拷贝构造
    Array(const Array& temp_array) {
        ...
    }
     
    // 深拷贝赋值
    Array& operator=(const Array& temp_array) {
        ...
    }
 
    // 移动构造函数,可以浅拷贝
    Array(const Array& temp_array, bool move) {
        data_ = temp_array.data_;
        size_ = temp_array.size_;
        // 为防止temp_array析构时delete data,提前置空其data_      
        temp_array.data_ = nullptr;
    }
     
 
    ~Array() {
        delete [] data_;
    }
 
public:
    int *data_;
    int size_;
};

这么做有2个问题:

  • 不优雅,表示移动语义还需要一个额外的参数(或者其他方式)。
  • 无法实现!temp_array是个const左值引用,无法被修改,所以temp_array.data_ = nullptr;这行会编译不过。当然函数参数可以改成非const:Array(Array& temp_array, bool move){...},这样也有问题,由于左值引用不能接右值,Array a = Array(Array(), true);这种调用方式就没法用了。

可以发现左值引用真是用的很不爽,右值引用的出现解决了这个问题,在STL的很多容器中,都实现了以右值引用为参数移动构造函数移动赋值重载函数,或者其他函数,最常见的std::vector的push_backemplace_back。参数为左值引用意味着拷贝,为右值引用意味着移动。

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_;
};

如何使用:

// 例1:Array用法
int main(){
    Array a;
 
    // 做一些操作
    .....
     
    // 左值a,用std::move转化为右值
    Array b(std::move(a));
}

3.2 实例:vector::push_back使用std::move提高性能

// 例2:std::vector和std::string的实际例子
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会失去原有值,变成空字符串
    vec.emplace_back(std::move(str1)); // emplace_back效果相同,str1会失去原有值
    vec.emplace_back("axcsddcas"); // 当然可以直接接右值
}
 
// std::vector方法定义
void push_back (const value_type& val);
void push_back (value_type&& val);
 
void emplace_back (Args&&... args);

在vector和string这个场景,加个std::move会调用到移动语义函数,避免了深拷贝。

除非设计不允许移动,STL类大都支持移动语义函数,即可移动的。 另外,编译器会默认在用户自定义的classstruct中生成移动语义函数,但前提是用户没有主动定义该类的拷贝构造等函数(具体规则自行百度哈)。 因此,可移动对象在<需要拷贝且被拷贝者之后不再被需要>的场景,建议使用std::move触发移动语义,提升性能。

moveable_objecta = moveable_objectb; 
改为: 
moveable_objecta = std::move(moveable_objectb);

还有些STL类是move-only的,比如unique_ptr,这种类只有移动构造函数,因此只能移动(转移内部对象所有权,或者叫浅拷贝),不能拷贝(深拷贝):

std::unique_ptr<A> ptr_a = std::make_unique<A>();

std::unique_ptr<A> ptr_b = std::move(ptr_a); // unique_ptr只有‘移动赋值重载函数‘,参数是&& ,只能接右值,因此必须用std::move转换类型

std::unique_ptr<A> ptr_b = ptr_a; // 编译不通过

std::move本身只做类型转换,对性能无影响。 我们可以在自己的类中实现移动语义,避免深拷贝,充分利用右值引用和std::move的语言特性。

4. 完美转发 std::forward

std::move一样,它的兄弟std::forward也充满了迷惑性,虽然名字含义是转发,但他并不会做转发,同样也是做类型转换.

与move相比,forward更强大,move只能转出来右值,forward都可以。

std::forward<T>(u)有两个参数:T与 u。 a. 当T为左值引用类型时,u将被转换为T类型的左值; b. 否则u将被转换为T类型右值。

举个例子,有main,A,B三个函数,调用关系为:main->A->B,建议先看懂2.3节对左右值引用本身是左值还是右值的讨论再看这里:

void B(int&& ref_r) {
    ref_r = 1;
}
 
// A、B的入参是右值引用
// 有名字的右值引用是左值,因此ref_r是左值
void A(int&& ref_r) {
    B(ref_r);  // 错误,B的入参是右值引用,需要接右值,ref_r是左值,编译失败
     
    B(std::move(ref_r)); // ok,std::move把左值转为右值,编译通过
    B(std::forward<int>(ref_r));  // ok,std::forward的T是int类型,属于条件b,因此会把ref_r转为右值
}
 
int main() {
    int a = 5;
    A(std::move(a));
}

例2:

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最主要运于模版编程的参数转发中,想深入了解需要学习万能引用(T &&)引用折叠(eg:& && → ?)等知识,本文就不详细介绍这些了。

如有错误,请指正!

最后插入一段原文下的评论,感觉从GC的角度来评价move的作用,值得参考和学习:

补充一下吧,你们这样理解实在还是太复杂了,其实很容易理解,move其实是在编译阶段的垃圾回收机制。传统的垃圾回收机制都是在运行时对内存做引用计数,或者设置一个生命值,当计数为0或者生命值为0的时候就回收这部分内存,但是这样做会降低程序执行性能,因为必须划分出一点时间用来做内存回收操作。为什么会出现运行时来回收内存呢?因为C语言的作用域不是基于代码块,而是内存栈的!也就是说不使用static这种关键字的话,所有的C代码其实一直都在同一个作用域下运行,根本无法区分出来那块内存已经超出了代码作用域了。而move这种做法就是弥补C语言缺少代码作用域的缺陷,编译器通过跟踪关键字move等词来追踪内存是否已经超出了代码作用域,当代码作用域结束的时候,如果还有内存没有释放,编译器就能在编译的时候检查出来。其实就是把人工跟踪内存使用,改成了机器自动跟踪的功能罢了。道理非常简单

End,如有更好的理解和思路请多多交流。 

  • 6
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
### 回答1: c++中的std::move和std::forward都是用于实现完美发的工具。 std::move是将一个左值强制换为右值引用,从而实现将资源所有权从一个对象移到另一个对象的目的。使用std::move可以避免不必要的复制和赋值操作,提高程序的效率。 std::forward则是用于在函数模板中实现完美发,将参数按照原来的类型发给下一个函数。它可以保证参数的类型和值被完美地发,避免了不必要的拷贝和移动操作,提高了程序的效率。 总的来说,std::move和std::forward都是用于提高程序效率和避免不必要的拷贝和移动操作的工具。 ### 回答2: C++标准库中提供了两个模板函数std::move和std::forward,它们在C++11中引入,用于实现移动语义和完美发。 std::move的作用是将一个左值强制换为右值引用,使得该对象的所有权能够被移,而不是进行复制或者赋值。通过调用移动构造函数或者移动赋值运算符来减少开销。移动语义是C++11中的一个重要特性,它可以提高程序的效率并且使得程序更加高效。 std::forward的作用是实现完美发,将函数参数原封不动地发到另一个函数中,使得函数模板可以保持参数类型和实参类型一致。std::forward用于实现通用类型的泛型编程,解决了模板函数中参数类型无法确定的问题。 实际上,std::move和std::forward的实现方式都非常简单,都是使用了static_cast进行类型换。但是它们在C++11中的引入,以及其实现的本质却给C++程序的效率提高和泛型编程提供了重要的支持。 总之,std::move和std::forward是C++11中非常重要的语言特性,它们可以帮助程序员实现移动语义和完美发,提高程序的性能和可读性。要注意正确使用它们,以避免出现不必要的开销和错误。 ### 回答3: C++ 11中引入了两个新的特殊函数模板std::move()和std::forward(),用来实现完美发和移动语义,提高了代码的效率和简洁性。 std::move的作用就是将一个左值换成右值引用,将左值的所有权抢过来,但不进行任何内存拷贝。通常用于移动语义,可以提高程序的效率。用法很简单,就是std::move(左值变量)。比如,若有个vector<int> a和一个vector<int> b,我想把b中的元素全部移动到a中,可以这样写:a.insert(a.end(), std::make_move_iterator(b.begin()), std::make_move_iterator(b.end()));这里,std::make_move_iterator()是一个语法糖,将它们的元素包装成可以引用的右值。 std::forward的作用是保持参数本来的类型(左值或右值),既可以接收左值也可以接收右值,并将参数传递给其他函数,这就是所谓的完美发。完美发可以达到只有一个函数就可以处理所有情况的目的。用法就是std::forward<参数类型>(参数变量)。比如,若有个函数template<class T> void f(T&& t),其中参数t是万能引用,需要把t传递给其他函数g(),我们可以这样写:g(std::forward<T>(t));这样就可以达到完美发的目的。 需要注意的是,std::move和std::forward虽然看起来相似,但作用是不同的,std::move是将左值换成右值引用,而std::forward是维持参数的原类型,用于完美发。同时,它们都需要加上相应的模板类型,以便让编译器进行类型推导。在使用时,需要根据情况选择合适的函数,以达到更好的效果。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值