Effective Modern C++ 条款23 理解std::move和std::forward
理解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的简单实现,它没有完全覆盖标准库的细节,不过很接近了。
template // 在std命名空间里` typename remove_reference::type&& move(T&& param) { using ReturnType = typename remove_reference::type&&; return static_cast(param); }
其实函数的本质就是类型转换,就如你所见,std::move接收一个对象的引用(准确地说,是通用引用,具体看条款24),然后返回相同对象的引用。
返回类型中的“&&”暗示着std::move返回的是一个右值引用,不过,就像条款28讲述那样,如果T的类型是个左值引用,T&&将会变成左值引用。为了防止这种事发生,我们对T使用了remove_reference(去除引用语义),因此确保了使用“&&”的类型不是引用类型,那就保证了std::move返回的是右值引用,这是很重要的,因为函数返回的右值引用是右值。因此,std::move把参数转换为一个右值,那就是它做的全部事情。
说点题外话,std::move在C++14的实现就没那么夸张了,感谢返回类型推断(看条款3)和标准库的别名模板std::remove_reference_t(看条款9),std::move可以这样写:
template // C++14,依然在std命名空间 decltype(auto) move(T&& param) { using ReturnType = remove_reference_t&&; return static_cast(param); }
是不是容易看多了?
因为std::move除了把参数转换为右值,没做其他事情,这表明类似rvalue_cast这样的名字或许更适合它。话虽如此,但我们用的名字是std::move,所以记住std::move做了什么和没做什么是重要的,它做的是转换,不是移动。
当然,右值会成为可移动的候选者,因此对一个对象使用std::move是告诉编译器,这个对象符合被移动的条件。那就是为什么std::move会有这个名字:很容易指出可能被移动的对象。
事实上,右值在通常情况下是唯一的可移动候选者。假如你要写一个代表注释的类,这个类的构造函数接受一个std::string参数(含有注释),然后把参数拷贝到成员变量,根据条款41,你声明的是值传递的参数:
class Annotation { public: explicit Annotation(std::string text); // 参数会被拷贝,值传递 ... };
不过因为注释类只是需要读text的值,不需要修改它,根据尽可能使用const这个悠久的历史,你修改了声明,把text修改成const:
class Annotation { public: explicit Annotation(const std::string text); ... };
为了避免拷贝text到成员变量的开销,你根据条款41的建议,对text使用std::move,由此产生一个右值:
class Annotation { public: explicit Annotation(const std::string text) : value(std::move(text)) // 把text"移动"成右值 { ... } // 但这代码的行为跟你看到的不一样 ... private: std::string value; };
代码编译,链接,运行,把成员变量value设置为text的内容。唯一把这代码和你眼中的完美实现分离的事情是text不是被移动到value的,它只是被拷贝。当然,text被std::move转换为右值了,但是text是被声明为const std::string,所以在转换之前,text是一个const std::string左值,转换后,是一个const std::string右值,在整个过程中,const的性质是一支存在的。
当编译器选择std::string构造函数时,有两个可能:
class string { // std::string实际上是std::basic_string的typedef public: ... string(const string& rhs); //拷贝构造 string(string&& rhs); //移动构造 ... };
在Annotation构造函数的初始化列表中,std::move(text)的结果是一个类型为const std::string的右值,这个右值不能传递给std::string的移动构造函数,因为移动构造函数接受的是non-const std::string的右值引用。不过这右值,可以传递给拷贝构造函数,因为一个lvalue-reference-to-const(const的左值引用)可以绑定const右值。所以,成员初始化列表调用了std::string的拷贝构造函数,即使text被转换成右值!这种行为对于维护const的正确性是必不可少的。把一个值搬离对象通常都会改变这个对象,所以C++不允许把const对象传递给会改变它们(对象)的函数(例如移动构造)。
在这个例子中我们可以得到两个教训。第一,如果你想要有能力移动对象,不要把它们声明为const。向一个const对象请求移动操作会默默转换为拷贝操作。第二,std::move不仅不会移动东西,还不能保证转换出来的对象有被移动的资格。你唯一能确保的事情是:对一个对象使用std::move,那个对象就被转换为右值。
std::forward的故事就比std::move简单多了,不过std::move是无条件把参数转换为右值,而std::forward在特定情况下才会这样做。std::forward是个有条件的类型转换。为了理解它什么时候转换,回忆一下std::forward一般是怎样使用的。最常见用法是一个模板函数接受全局引用,然后用std::forward把参数传递给另一个函数:
void process(const Widget& lvalArg); // 处理左值 void process(Widget&& rvalArg); // 处理右值 template void logAndProcess(T&& param) // 把参数传递给process的模板 { auto now = std::chrono::system_clock::now(); // 获取当前时间 makeLogEntry("Calling 'process'", now); process(std::forward(param)); }
考虑logAndProcess的两次调用,一次左值,一次右值:
Widget w; logAndProcess(w); // 左值参数调用 logAndProcess(std::move(w)); // 右值参数调用
在logAndProcess里面,参数param被传递给process函数,而process函数为了左值参数和右值参数进行重载。当我们用左值调用logAndProcess的时候,我们自然是希望把左值转发给process,而当我们用右值调用logAndProcess时,我们希望调用的是右值重载的process。
但是param,和所有的函数参数一样,是个左值。在logAndProcess里每次调用process都会使用左值重载的process。为了防止这样的事情,我们需要一项技术,当且仅当初始化param的参数——即传递给logAndProcess的参数——是右值时,在logAndProcess把param转换为右值。这就是std::forward干的事情了,这也是为什么说std::forward是个有条件的类型转换:仅当参数是用右值初始化时,才会把它转换为右值。
你可能想要知道std::forward是如何知道参数是否用右值初始化的。举个例子,上面的代码中,std::forward是怎样知道初始化param的,是左值还是右值呢?简短的答案是信息会被编码到logAndProces的模板参数T中。这个参数传递给std::forward模板,然后恢复编码的信息。具体细节看条款28。
倘若把std::move和std::forward把归结为类型转换,那么它们的差别是std::move总是会转换,std::forward只会在某些时刻转换,你可以会问我们是否可以摒弃std::move,只是用std::forward。从纯粹的技术角度看,答案是可以的:std::forward可以应付所有场景,std::move不是必须的。当然,没有一个函数是真的必须的,因为我们可以自己写转换,不过如果那样的话,是很恶心的。
std::move吸引人的地方在于它的方便,减少可能的错误,还有更简洁。试想在一个类中,我们要记录移动构造函数被调用了多少次。我们所需要的是个static计数器,它在移动构造中递增。假如类中的非static成员变量只有一个std::string,这里有个十分方便的方法(即使用std::move)实现我们的移动构造:
class Widget { public: Widget(Widget&& rhs) : s(std::move(rhs.s)) { ++moveCtorCalls; } ... private: static std::size_t moveCtorCalls; std::string s; };
用std::forward实现相同的效果,代码是这样的:
class Widget { public: Widget(Widget&& rhs) :s(std::forward(rhs.s)) { ++moveCtorCalls; } ... };
首先要注意到的是std::move只需要一个函数参数(rhs.s),而std::forward既需要一个函数参数(rhs.s)又需要一个模板类型参数(std::string)。然后需要注意的是我们一般传递给std::forward的参数类型是不带引用等待,那是因为这会很方便把参数编码成右值(看条款28)。结合起来,意味着std::move比起std::forward需要更少的类型,不用传递类型参数可以减少编码的麻烦。它还可以消除我们可能传递的类型错误(例如,std::string&, 使用std::forward的话,会导致成员变量拷贝构造,而不是移动构造)。
最重要的是,std::move是无条件转换,而std::forward只会将绑在右值上的参数转换为右值。这两个操作不一样,第一个操作通常会造成移动,而第二个操作只是传递——转发——一个对象给另一个函数,而保持原来的左值性质或者右值性质。因为这两个行为是不一样的,所以用两个不同的函数(和函数名)区分它们是很好的设计。
总结
需要记住的3点:
std::move表现为无条件的右值转换,就其本身而已,它不会移动任何东西。 std::forward仅当参数被右值绑定时,才会把参数转换为右值。 std::move和std::forward在运行时不做任何事情。上一篇: 计算机之批处理处理程序
下一篇: 云计算设计的可靠性