理解 Std::move 和 Std::forward
本文最后更新于:April 20, 2022 am
本篇为介绍右值引用、移动语义和完美转发系列博客的第一篇,本博文会对std::move
和std::forward
进行初步介绍
右值引用,移动语义、完美转发系列目录:右值、移动语义和完美转发
概述
在开始了解std::move
和std::forward
之前,不妨先搞清楚它们干什么,不干什么——std::move
并不移动任何东西,std::forward
也并不转发任何东西。在运行时,它们俩什么也不干,并且,它们在编译后并不生成任何可执行代码。
std::move
和std::forward
仅仅是一些,用来做类型转换的函数模板。std::move
无条件地将参数转换成右值,std::forward
仅仅在满足一定条件时才进行转换,这就是它俩所做的所有工作。
以上的解释会引出一堆新问题,但是至少,这个解释基本上说明了完整故事。
std::move
为了让叙述更为具体,以下是一份C++11的std::move
的实现例子。它并不完全和C++11标准中相符,但是它很接近。
1 |
|
需要关注的,仅仅是函数名和返回语句,最好不要将注意力耗散在返回值类型声明上。返回语句清楚地表明了std::move
在干什么——做静态类型转换
正如你所见,std::move
拿到某个对象的引用(更准确地来说,是一个万能引用),并且返回关于同一个对象的引用——“&&”表明,返回的引用是个右值引用。但由于引用折叠(参见Item28),当T是一个左值引用是,T&&
会变成一个左值引用。为了防止这种情况的发生,此处在typename T
上使用类型萃取(参见Item 9)std::remove_reference<T>
,以此来保证“&&”的确被用于一个非引用的类型名。通过以上种种,std::move
完成了将参数转换为右值的工作,这也是std::move
所做的所有工作。
顺便提一句,在C++14中可以更为简洁地实现std::move
,这都要归功于函数返回值类型推导和标准库的别名模板remove_reference_t
(参见Item 9)
1 |
|
正是由于std::move
除了将参数转换为右值,同时保持表达式的类型以外,什么都不做,有人建议将它重命名为rvalue_cast之类。尽管如此,std::move
还是被叫做move,但是需要记住它不move任何东西,只是将参数转换为右值,且保持表达式类型不变。
当然,右值可以被应用于移动操作,因此对一个对象进行std::move
操作,就相当于告诉了编译器,这个对象的内容可以被移动出去。std::move
被命名为此,正是因为它让定位可能被执行移动操作的对象变得更容易。
事实上,右值仅仅是可以被应用于移动操作。假设我们正在编写一个用于表示注释的类。类的构造器接受一个std::string
的参数类型,用于构成注释本身,然后构造函数将这个string对象拷贝给一个数据成员。结合Item 41中所描述的信息,我们会声明通过值传递的参数
1 |
|
由于Annotation
类的构造器只需要读取text的值,而并不想改变它。结合应该尽可能地使用const
的悠久传统,我们会这样写
1 |
|
为了避免将text拷贝给成员变量的开销,我们在坚持Item 41的建议的同时,将text的内容std::move
给了value
1 |
|
这份代码能通过编译,能链接成功,能跑起来,也会把成员变量value的内容置为text的内容,但是,并不是通过移动构造的方式,而是通过拷贝!
由于在参数声明时加了const
,在做move之前,text的类型是const std::string
的左值,在std::move
之后,其变成了const std::string
类型的右值,自始至终,const
都还在。
上面的场景中,当编译器需要决定调用std::string
的以下哪个函数时,会发生什么?
1 |
|
在Annotation
类的构造函数的初始化列表中,std::move(text)
的结果是 const std::string
的右值。这个右值不能被传入到std::string
的移动构造器中,因为后者只能接受不带const
的右值,也就是std::string
类型的右值。但是,在这里,这个引用可以被传到拷贝构造函数中去,众所周知,常量左值引用可以被绑定到一个常量右值上面。最终,即使text已经被转换成了右值,还是调用了std::string
的拷贝构造函数。这种操作可能看起来有点离谱,但是为了保证const
意义的一惯性,编译器必须这么做——从一个对象A移动到对象B时,一般会对对象A做修改,因此不应该允许带有const
的对象被传入到可能对其进行修改的函数中去(比如某个移动构造函数)。
在这个案例里,能够吸取两样教训
- 当希望从对象中移动内容出去时,不要将之声明为
const
,因为在const
对象上的std::move
操作都会被默默转换成拷贝操作 std::move
不仅不能完成对象的移动操作——毕竟它只是个cast,也不能保证cast之后的返回值能够被移动。对于std::move
,唯一能确定的就是,它的返回值是右值。
std::forward
std::forward
的故事和std::move
差不多,但是std::forward
的类型转换是有条件的。为了理解std::forward
何时进行类型转换,我们需要回忆std::forward
是如何被使用的。最常规的情况是,一个函数模板,其参数是一个万能引用,并且它需要将之传给其它函数:
1 |
|
考虑以下对于logAndProcess
的两个调用,其中一个是用的左值,另一个用的右值。
1 |
|
在函数logAndProcess
内部,参数param
都被传递给了函数process
process
函数有两个重载版本,当我们用左值来调用logAndProcess
时,会自然地期望被传入process
函数的参数仍然是个左值。类似地,当我们用右值调用logAndProcess
,自然会期望传递给process
函数右值。
但是,就像在其它函数中的参数一样,param是个左值,在logAndProcess
内部,对于process
的每一个调用,都会倾向于调用其左值重载版本。为了阻止这一点,需要一种机制,使得:
当且仅当用于初始化param
的参数是个右值时——也就是当传递给logAndProcess
的实参是个右值时,将param
转换为一个右值,并且传递给process
以上就是std::forward
所做的所有内容,这也是为什么std::forward
被叫做有条件的转型——因为仅仅在std::forward
的实参是被右值初始化时,才会做转换
你可能想问,std::forward
如何能知道它的实参是不是通过一个右值来初始化的?更具体地,在上面的代码中,std::forward
如何区分param
是由左值还是右值初始化的。一个简略的回答是,通过类型参数T
,可以认为这些信息被编码在T
中。注意,在调用std::forward
时,我们同时将T
也传入了std::forward
函数,T
使得std::forward
可以恢复这些信息,具体内容参见Item 28
在知道了std::move
和std::forward
都仅仅是类型转换,并且唯一的区别仅仅是进行转换的条件不一样之后,可能有同学会说,我们可以踢掉std::move
,然后在任何地方都使用std::forward
来代替之。从一个纯技术的角度来看,这样没有错,std::forward
的功能是std::move
的超集。其实,两者中的任何一个函数都是没必要的,因为我们都可以用手写的类型转换来代替之,只不过写起来有点令人生厌。
std::move
主打便捷性、低错误率以及更好地代码清晰度。考虑这样一个情况,我们需要跟踪某个类的移动构造函数被调用了多少次。显然,需要给类定义一个静态的计数器,在每一次调用移动构造函数时自增1。假设唯一一个非静态的类的数据成员是std::string
类型的对象,这里有一份比较方便的实现代码
1 |
|
如果用std::forward
来写,会是这样:
1 |
|
注意
第一,std::move
只需要函数参数,std::forward
还额外需要一个模板参数;
第二,传入std::forward
的模板类型参数应该是一个非引用类型——因为按照习惯,传入非引用类型是为了表明被传入的参数是一个右值
以上,std::move
让我们不用刻意将类型参数传入函数来表明传入std::forward
的参数是一个右值;同时,这也消除了传入错误参数类型的情况(比如传入std::string&
为类型参数, 这会使得s
被使用拷贝构造函数初始化,而非移动构造函数)
更重要的是,使用std::move
代表的无条件转换,以及std::forward
代表的有条件转换,这两者本身就是不同的动作。std::move
通常是为了移动构造或者移动赋值做准备,而std::forward
是为了以参数维持参数原有左右值类型的方式将参数传递给下一层函数调用。正是由于这两种操作如此不同,用两个函数来加以区分也不失为一种好方法。
总结
std::move 执行无条件地cast,返回一个右值,但其本身并不移动任何内容
std::forward 仅在自己接受的参数是被绑定到右值时才会执行cast
std::move 和 std::forward 在运行时,什么也不干
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!