理解 Std::move 和 Std::forward

本文最后更新于:April 20, 2022 am

本篇为介绍右值引用、移动语义和完美转发系列博客的第一篇,本博文会对std::movestd::forward进行初步介绍

右值引用,移动语义、完美转发系列目录:右值、移动语义和完美转发

概述

在开始了解std::movestd::forward之前,不妨先搞清楚它们干什么,不干什么——std::move并不移动任何东西,std::forward也并不转发任何东西。在运行时,它们俩什么也不干,并且,它们在编译后并不生成任何可执行代码。

std::movestd::forward仅仅是一些,用来做类型转换的函数模板。std::move无条件地将参数转换成右值,std::forward仅仅在满足一定条件时才进行转换,这就是它俩所做的所有工作。

以上的解释会引出一堆新问题,但是至少,这个解释基本上说明了完整故事。

std::move

为了让叙述更为具体,以下是一份C++11的std::move的实现例子。它并不完全和C++11标准中相符,但是它很接近。

1
2
3
4
5
//in namespace std
template<typename T> typename remove_reference<T>::type&& std::move(T&& param){
using ReturnType= typename remove_reference<T>::type &&;//参见Item 9
return static_cast<ReturnType>(param);
}

需要关注的,仅仅是函数名和返回语句,最好不要将注意力耗散在返回值类型声明上。返回语句清楚地表明了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
2
3
4
5
template<typename T>
decltype(auto) std::move(T&& param){
using ReturnType= remove_reference<T>&&;
return static_cast<ReturnRype>(param);
}

正是由于std::move除了将参数转换为右值,同时保持表达式的类型以外,什么都不做,有人建议将它重命名为rvalue_cast之类。尽管如此,std::move还是被叫做move,但是需要记住它不move任何东西,只是将参数转换为右值,且保持表达式类型不变。

当然,右值可以被应用于移动操作,因此对一个对象进行std::move 操作,就相当于告诉了编译器,这个对象的内容可以被移动出去。std::move被命名为此,正是因为它让定位可能被执行移动操作的对象变得更容易。

事实上,右值仅仅是可以被应用于移动操作。假设我们正在编写一个用于表示注释的类。类的构造器接受一个std::string的参数类型,用于构成注释本身,然后构造函数将这个string对象拷贝给一个数据成员。结合Item 41中所描述的信息,我们会声明通过值传递的参数

1
2
3
4
5
class Annotation{
public:
explicit Annotation(std::string text);
// parameter to be copied, see Item 41
}

由于Annotation类的构造器只需要读取text的值,而并不想改变它。结合应该尽可能地使用const的悠久传统,我们会这样写

1
2
3
4
5
class Annotation{
public:
explicit Annotation(const std::string text)
...
}

为了避免将text拷贝给成员变量的开销,我们在坚持Item 41的建议的同时,将text的内容std::move给了value

1
2
3
4
5
6
7
8
9
class Annotation{
public:
explicit Annotation(const std::string test)
:value(std::move(text))
{ ... }
...
private:
std::string value;
}

这份代码能通过编译,能链接成功,能跑起来,也会把成员变量value的内容置为text的内容,但是,并不是通过移动构造的方式,而是通过拷贝!

由于在参数声明时加了const,在做move之前,text的类型是const std::string的左值,在std::move之后,其变成了const std::string类型的右值,自始至终,const都还在。

上面的场景中,当编译器需要决定调用std::string的以下哪个函数时,会发生什么?

1
2
3
4
5
6
7
8
class string{
//string 其实是std::basic_string<char>的typedef
public:
...
string(const string& rhs);//拷贝构造
string(string&& rhs);//移动构造
...
}

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
2
3
4
5
6
7
8
9
10
void process(const Widget& lvalArg);	//process lvalues
void process(Widget&& rvalArg); //process rvalues

template<typename T>
void logAndProcess(T&& param){
auto now=std::chrono::system_lock::now();

makeLogEntry("Calling 'process'",now);
process(std::forward<T>(param));
}

考虑以下对于logAndProcess的两个调用,其中一个是用的左值,另一个用的右值。

1
2
3
Widget w;
logAndProcess(w);
logAndProcess(std::move(w));

在函数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::movestd::forward都仅仅是类型转换,并且唯一的区别仅仅是进行转换的条件不一样之后,可能有同学会说,我们可以踢掉std::move,然后在任何地方都使用std::forward来代替之。从一个纯技术的角度来看,这样没有错,std::forward的功能是std::move的超集。其实,两者中的任何一个函数都是没必要的,因为我们都可以用手写的类型转换来代替之,只不过写起来有点令人生厌。

std::move主打便捷性、低错误率以及更好地代码清晰度。考虑这样一个情况,我们需要跟踪某个类的移动构造函数被调用了多少次。显然,需要给类定义一个静态的计数器,在每一次调用移动构造函数时自增1。假设唯一一个非静态的类的数据成员是std::string类型的对象,这里有一份比较方便的实现代码

1
2
3
4
5
6
7
8
9
10
class Widget{
public:
Widget(Widget&& rhs):s(std::move(rhs.s)){
++moveCtorCalls;
};
...
private:
static std::size_t moveCtorCalls;
std::string s;
}

如果用std::forward来写,会是这样:

1
2
3
4
5
6
7
class Widget{
public:
Widget(Widget&& rhs):s(std::forward<std::string>(rhs.s)){
++moveCotrCalls;
}
...
}

注意

第一,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 协议 ,转载请注明出处!