怎样理解 C++ 11中的move语义

一、简介  

  move语义使得你可以用廉价的move赋值替代昂贵的copy赋值完美转发使得你可以将传来的任意参数转发给其他函数,而右值引用使得move语义和完美转发成为可能。然而,慢慢地你发现这不那么简单,你发现std::move并没有move任何东西,完美转发也并不完美,而T&&也不一定就是右值引用……

二、move语义

  最原始的左值和右值定义可以追溯到C语言时代,左值是可以出现在赋值符的左边和右边,然而右值只能出现在赋值符的右边。在C++里,这种方法作为初步判断左值或右值还是可以的,但不只是那么准确了。你要说C++中的右值到底是什么,这真的很难给出一个确切的定义。你可以对某个值进行取地址运算,如果不能得到地址,那么可以认为这是个右值。例如:

1 int& foo();
2 foo() = 3; //ok, foo() is an lvalue
3  
4 int bar();
5 int a = bar(); // ok, bar() is an rvalue

为什么要move语义呢?它可以让你写出更高效的代码。看下面代码:

1 string foo();
2 string name("jack");
3 name = foo();

第三句赋值会调用string的赋值操作符函数,发生了以下事情:

  • 首先要销毁name的字符串吧
  • 把foo()返回的临时字符串拷贝到name吧
  • 最后还要销毁foo()返回的临时字符串吧

这就显得很不高效,在C++11之前,你要些的高效点,可以是swap交换资源。C++11的move语义就是要做这事,这时重载move赋值操作符

1 string& string::operator=(string&& rhs);

move语义不仅仅用于右值,也用于左值。标准库提供了std::move方法,将左值转换成右值。因此,对于swap函数,我们可以这样实现:

1 template<class T>
2 void swap(T& a, T& b)
3 {
4     T temp(std::move(a));
5     a = std::move(b);
6     b = std::move(temp);
7 }

三、右值引用

3.1 简介

string&& 这个类型就是所谓的右值引用,而把T&称之为左值引用。注意,不要见到T&&就认为是右值引用,例如,下面这个就不是右值引用:

1 T&& foo = T(); //右值引用
2 auto&& bar = foo; // 不是右值引用

  实际上,T&&有两种含义,一种就是常见的右值引用;另一种是即可以是右值引用,也可以是左值引用,Scott Meyers把这种称为Universal Reference,后来C++委员把这个改成forwarding reference,毕竟forwarding reference只在某些特定上下文才出现

3.2 对于类的影响

  有了右值引用,C++11增加了move构造和move赋值。考虑这个情况:

1 void foo(X&& x)
2 {
3   // ...
4 }

3.3 引用的对象的类型如何区分

那么问题来了,x的类型是右值引用,指向一个右值,但x本身是左值还是右值呢?C++11对此做出了区分:

1 Things that are declared as rvalue reference can be lvalues or rvalues. 
2 The distinguishing criterion is: if it has a name, then it is an lvalue. Otherwise, it is an rvalue.
3 
4 声明为右值引用的事物可以是左值或右值。 区分标准是:如果它有名字,那么它就是一个左值。 否则,它是一个右值。

由此可知,x是个左值。考虑到派生类的move构造,我们因这样写才正确:

1 Derived(Derived&& rhs):Base(std::move(rhs) //std::move不可缺
2 { ... }

3.4 如何是下面move函数

有一点必须明白,那就是std::move不管接受的参数是lvalue,还是rvalue都返回rvalue。因此我们可以给出std::move的实现如下(很接近于标准实现):

1 template <class T>
2 typename remove_reference<T>::type&& move(T&& t) 
3 {
4     using RRefType = typename remove_reference<T>::type&&;
5     return static_cast<RRefType>(t);
6 }

四、std::move函数

4.1 std::move的功能和优点

功能:返回传入参数的右值引用。右值引用的概念是在C++11才提出来的。在此之前只有一种引用。说白点,就是将所有权进行转移,若转移成功后,所有权到达新的对象,原对象不再拥有所有权,再次访问会发生未知错误,例如段错误,未知值等等。或者说,move 只是纯粹的将一个左值转化为了一个右值。

优点:调用此函数不会引起任何数据争用。(Calling this function introduces no data races.)
需要注意的重点是:

  • 1)如果只是调用std::move然后使用右值引用接收后,原对象仍然拥有该内存的所有权,因为只是获取了右值引用,并未完全移动给另一个对象
  • 2)如果该右值是以匿名对象的方式传入,则原对象的所有权丢失

例如:

 1 //1)
 2 string A("abc");
 3 string &&Rval = std::move(A);           // move完之后是一个右值,所以必须是&&才是取右值引用;少一个&代表左值引用,但会报错,因为右值无法给左值做引用。注意这里只是获取了A的引用,并未实现move转移。
 4 string B(Rval);                         // this is a copy , not move.并且注意,这里只是简单的传右值引用,那么它也只是拷贝,而不会真正移走所有权,即上面的注意点 1,即使运行了这一行代码,A仍然拥有所有权,B只是copy了一份。
 5 
 6 //2)
 7 string A("abc");
 8 string C(std::move(A));                 // 和上面不一样的是,虽然也是返回一个右值引用,但实际可以认为是匿名对象,A的所有权照样会被夺去,因为我们学过,当一个匿名对象被刚创建的对象接收时,他是不会重新创建内存的,所以此时C已经拥有所有权,A丢失
 9 cout << A << endl;                      // output ""
10 
11 cout << C << endl;                      // output "abc",因为A的所有权通过匿名对象到了C当中。

关于上面的代码,我们需要在下面的完美转发std::forward才能进一步对比分析。

下面先看std::move的移动所有权。

 1 #include <iostream>
 2 #include <vector>
 3 #include <string>
 4 using namespace std;
 5 
 6 void test01()
 7 {
 8   std::string vs   = "vs-string";
 9   std::string code = "code-string";
10   std::vector<std::string> v;
11  
12   v.push_back(vs);                      // copy
13   v.push_back(std::move(code));         // move,匿名对象传参。
14  
15   std::cout << "v contains:";
16   for (std::string& x:v) 
17     std::cout << ' ' << x;
18   std::cout << std::endl;
19   
20   std::cout << "vs:" << vs << std::endl;        // output: "vs:vs-string"
21   std::cout << "code:" << code << std::endl;    // output: ""
22 }
23 
24 void test02()
25 {
26   std::string vs   = "vs-string";
27   std::string code = "code-string";
28   std::vector<std::string> v;
29  
30   v.push_back (vs);                                // copy
31   v.push_back (code);                            // copy
32  
33   std::cout << "v contains:";
34   for (std::string& x : v) 
35     std::cout << ' ' << x;
36   std::cout << std::endl;
37   
38   std::cout << "vs:" << vs << std::endl;        // output: "vs:vs-string"
39   std::cout << "code:" << code << std::endl;    // output: "code:code-string"
40 }
41 
42 int main()
43 {
44     test01();
45     test02();
46 
47     return 0;
48 }

test01输出结果,因为code被成功的移动所有权,所以此时code默认是被赋一个空值。

 

 

test02输出结果,由于只是正常的拷贝,所以不会进行所有权的更改,code正常输出。

五、完美转发

5.1 示例

假设有一个函数foo,我们写出如下函数,把接受到的参数转发给foo:

1 template<class T>
2 void fwd(TYPE t)
3 {
4     foo(t);
5 }

我们一个个来分析:

  • 如果TYPE是T的话,假设foo的参数引用类型,我会修改传进来的参数,那么fwd(t)和foo(t)将导致不一样的效果。
  • 如果TYPE是T&的话,那么fwd传一个右值进来,没法接受,编译出错。
  • 如果TYPE是T&,而且重载个const T&来接受右值,看似可以,但如果多个参数呢,你得来个排列组合的重载,因此是不通用的做法。

你很难找到一个好方法来实现它,右值引用的引入解决了这个问题,在这种上下文时,它成为forwarding reference。

5.2 完美转发的原则

 

5.2.1 引用折叠原则

这就涉及到两条原则。第一条原则是引用折叠原则

  • A& & 折叠成 A&
  • A& && 折叠成 A&
  • A&& & 折叠成 A&
  • A&& && 折叠成 A&&

5.2.2 特殊模板参数推导原则

第二条是特殊模板参数推导原则

  • 如果fwd传进的是个A类型的左值,那么T被决议为A&。 2.如果fwd传进的是个A类型的右值,那么T被决议为A

5.3 实现

将两条原则结合起来,就可以实现完美转发。

1 A x; 
2 fwd(x); //推导出fwd(A& &&) 折叠后fwd(A&)
3  
4 A foo();
5 fwd(foo());//推导出fwd(A&& &&) 折叠后 fwd(A&&)

std::forward应用于forwarding reference,代码看起来如下:

1 template<class T>
2 void fwd(T&& t)
3 {
4     foo(std::forward<T>(t));
5 }

  要想展开完美转发的过程,我们必须写出forward的实现。接下来就尝试forward该如何实现,分析一下,std::forward是条件cast的,T的推导类型取决于传参给t的是左值还是右值。因此,forward需要做的事情就是当且仅当右值传给t时,也就是当T推导为非引用类型时,forward需要将t(左值)转成右值。forward可以如下实现:

1 template<class T>
2 T&& forward(typename remove_reference<T>::type& t)
3 {
4     return static_cast<T&&>(t);
5 }

5.4 验证分析

现在来看看完美转发是怎么工作的,我们预期当传进fwd的参数是左值,从forward返回的是左值引用;传进的是右值,forward返回的是右值引用。假设传给fwd是A类型的左值,那么T被推导为A&:

1 void fwd(A& && t)
2 {
3     foo(std::forward<A&>(t));
4 }

forward<A&>实例化:

1 A& && forward(typename remove_reference<A&>::type& t)
2 {
3     return static_cast<A& &&>(t);
4 }

引用折叠后:

1 A& forward(A& t)
2 {
3     return static_cast<A&>(t);
4 }

可见,符合预期。再看看传入fwd是右值时,那么T被推导为A:

1 void fwd(A && t)
2 {
3     foo(std::forward<A>(t));
4 }

也就是:

1 A&& forward(A& t)
2 {
3     return static_cast<A&&>(t);
4 }

forward返回右值引用,很好,完全符合预期。

六、std::forward函数

  当我们将一个右值引用传入函数时,他在形参中有了命名,所以继续往下传或者调用其他函数时,根据C++ 标准的定义,这个参数变成了一个左值。那么他永远不会调用接下来函数的右值版本,这可能在一些情况下造成拷贝。
  为了解决这个问题 C++ 11引入了完美转发,根据右值判断的推倒,调用forward 传出的值,若原来是一个右值,那么他转出来就是一个右值,否则为一个左值。这样的处理就完美的转发了原有参数的左右值属性,不会造成一些不必要的拷贝。

 1 #include <iostream>
 2 #include <vector>
 3 #include <string>
 4 using namespace std;
 5 
 6 void test03()
 7 {
 8     string A("abc");
 9     string &&Rval = std::move(A);           // move完之后是一个右值,所以必须是&&才是取右值引用;少一个&代表左值引用,但会报错,因为右值无法给左值做引用。注意这里只是获取了A的引用,并未实现move转移。
10     string B(Rval);                         // this is a copy , not move.  Rval不是一个匿名对象,是一个简单的右值引用,所以不会被夺走所有权。
11     cout << A << endl;                      // output "abc"
12 
13     string C(std::forward<string>(Rval));   // move.
14     cout << A << endl;                      // output ""
15     cout << C << endl;                      // output "abc",因为A的值通过Rval完美转发到了C当中。
16 }
17 
18 //这个test04是为了验证通过获取std::move的右值引用后再传参数,与直接传std::move作为参数返回匿名对象的区别。
19 void test04()
20 {
21     string A("abc");
22     string C(std::move(A));                 // 和上面不一样的是,虽然也是返回一个右值引用,但实际可以认为是匿名对象,A的所有权照样会被夺去,因为我们学过,当一个匿名对象被刚创建的对象接收时,他是不会重新创建内存的,注意对比test03
23     cout << A << endl;                      // output ""
24 
25     cout << C << endl;                      // output "abc",因为A的所有权通过匿名对象到了C当中。
26 }
27 
28 int main()
29 {
30     test01();
31     test02();
32     test03();
33     test04();
34     
35     return 0;
36 }

test03结果:

  分析test03,一开始我们分析了move的作用,首先它只是先获取A的右值引用,并未进行了所有权的移动。然后执行string B(Rval);时,由于只是简单的传右值引用变量,所以此时B只是拷贝了一份,A此时仍然拥有该内存所有权。注意对比std::move的test01中的v.push_back(std::move(code)),vector的push_back是支持右值的并且认为其是一个匿名对象,所以此时code的所有权就已经被成功移动了,code值必定为空,而这里的B只是拷贝,不会移动所有权。

test04结果:

  第一行输出了空字符串,是由于直接传std::move作为参数,string的构造认为是一个匿名对象,那么直接夺走A的所有权给C,所以C输出了abc。

七、那么如何解决std::move传右值后(即上面std::move的注意点1),在被调用的函数内部仍被转成左值的方法呢?

  答:就是test03里面的std::forward完美转发了,上面test03可以看到,如果我们这样传右值 string B(Rval);,被调用的函数内部是不会被当成右值处理,而string C(std::forward(Rval));后,就肯定是,原来是右值的就当右值使用,原来是左值的就当左值使用,避免被调用函数内部不必要的拷贝,实现完美转发

  或者换句话说,在std::move获取右值引用后传入而非以匿名变量传入,std::forward就是把std::move存在的缺陷补全了,因为获取右值引用后传入而非以匿名变量传入在被调用函数内部被转成了左值拷贝,而std::forward不会。
  不过两者一般是配合使用,首先std::move获取右值,然后通过std::forward进行完美转发,这样就可以实现完美转发了。

 

八、总结

 

C++11之前,auto_ptr不能放入容器中,C++11的move语义解决了这个问题,unique_ptr就是auto_ptr的替代版。如果声明个指向引用的引用类型的变量,比如你写出如下代码:

1 int a = 3;
2 auto & & b = a;

这是不合法,编译器会报错。再看看完美转发:

1 void f(vector<int> vi);
2 f({1,2,3});//ok
3 fwd({1,2,3})//error

还有些其他情况,你需要明白,完美转发也不完美。

 

八、参考文章

 

https://blog.csdn.net/weixin_44517656/article/details/118905702

https://blog.csdn.net/booirror/article/details/45057689

posted @ 2021-07-25 09:38  Mr-xxx  阅读(620)  评论(0编辑  收藏  举报