条款23:理解std::move和std::forward

时间:2022-12-27 19:58:13

通过了解std::move和std::forward不做什么来理解它们很有用。std::move不移动任何东西,std::forward也不转移任何东西。在运行时(runtime),他们什么都不做,一行代码也不产生。

std::move和std::forward仅仅是进行类型转换的函数(实际上是函数模板)。std::move无条件的将其参数转换为右值,而std::forward只在必要情况下进行这个转换,就是这样。这个解释会引起一系列问题,但是基本上这就是完整的故事。

为了让故事更加具体,这有一个在c++11中std::move的模拟实现:

template<typename T>                                   // in namespace std

typename remove_reference<T>::type&&

move(T&& param)

{

    using ReturnType =                                            // alias declaration;

        typename remove_reference<T>::type&&;    // see Item 9

    return static_cast<ReturnType>(param);

}

我高亮了两处代码。一个是函数的名字,因为返回值很繁琐,我怕你失去忍耐。另一处是函数的精华本质部分:转换。如你所见,std::move的参数为一个对象的引用(统一引用,详见条款24),并且返回的也是该对象的引用。

函数返回值的“&&”部分表示std::move返回了一个右值引用,但是如条款28所述,假如类型T碰巧是个左值引用,T&&就会成为一个左值引用。为阻止这个发生,一个type trait(见条款9)std::remove_reference被用在T上,这样可以确保“&&”可以应用在一个不是引用的类型上,这个很重要,因为函数返回的右值引用必须是右值。于是,std::move将其参数转换右值,这就是它所有做的事情。

此外,std::move在c++14中的实现显得更简短。由于函数返回类型推导(条款3)和标准库里的别名模板std::remove_reference_t (见条款9),std::move可以这样实现:

template<typename T>                     // C++14; still in
decltype(auto) move(T&& param)    // namespace std
{
    using ReturnType = remove_reference_t<T>&&;
    return static_cast<ReturnType>(param);
}

看上去更简单些,不是?

因为std::move除了把参数转换为右值以外不做别的事情,有建议给它一个更好的名字也许类似rvalue_cast,即使如此,我们现在拥有的名字是std::move,所以认识到std::move做什么和不做什么很重要。它做的是转换,不做移动。

当然,右值适合被移动,所以std::move应用在一个对象上时就是告诉编译器对象可以被移动。这就是为什么std::move拥有这样的名字:使得指定可以被移动的对象更容易些。

事实上,右值是仅有的可以被移动的对象。假如你写了个类代表注释(annotation),类的构造函数使用std::string类型做为参数表示注释内容,并且拷贝参数到一个成员变量里。根据条款41的信息,你声明了一个传值参数:

class Annotation {
public:
  explicit Annotation(std::string text); // param to be copied,

  …                                                    // so per Item 41,
};                                                       // pass by value

但是Annotation的构造函数仅仅需要读取text的值,不需要改变它。根据我们固有的传统,尽可能的使用const,你改变了声明如下:

class Annotation {
public:
  explicit Annotation(const std::string text)
  …
};

为了避免当拷贝text到数据成员中时消耗一次拷贝操作,你使用了条款41的建议,将std::move应用到text上,于是产生了一个右值:

class Annotation {
public:
  explicit Annotation(const std::string text)
  : value(std::move(text))                                      // "move" text into value; this code
  { … }                                                                   // doesn't do what it seems to!
  …
private:
  std::string value;
};

代码编译链接都可以正常,也把设置了数据成员为text的内容。唯一使得这段代码和你想象中的完美实现不一样的地方是text不是移动到value的,是拷贝的。当然,text是通过std::move转换成一个右值,但是text是被声明成const std::string,所以在转换前,text是一个左值的const std::string,转换的结果是一个右值const std::string,最终常量性保留了下来。

考虑下当编译器必须决定哪个std::string构造函数必须调用时的效果,有两个可能:

class string {         // std::string is actually a
public:                  // typedef for std::basic_string<char>
    …
    string(const string& rhs); // copy ctor 拷贝构造函数
    string(string&& rhs);        // move ctor move移动构造函数
    …
};

在Annotation的构造函数的成员初始化列表里,std::move(text)的结果是一个类型为const std::string的右值。这个右值不能传递给std::string的move构造函数,因为move构造函数需要一个指向非常量std::string的右值引用作为参数。然而这个右值可以传递给拷贝构造函数,因为指向常量的左值引用是允许绑定到一个常量右值的。于是成员初始化就会调用std::string的拷贝构造函数,即使text已经转换成一个右值。这样的行为是维持常量正确性的一个必需,因为移动一个对象到另一个值通常会改变这个对象,所以语言就不应该允许常量对象被传递给一个能修改他们的函数(比如move构造)。

从这个例子可以学到两个教训。第一,假如你想对象能够被移动,不要声明对象为const,在const对象上的移动操作默默的被转换成了拷贝操作。第二,std::move不仅不移动任何东西,甚至不能保证被转换的对象可以被移动。唯一可以确认的是应用std::move的对象结果是个右值。


std::forward的故事和std::move的故事很类似,但std::move是无条件的转换其参数为一个右值,而std::forward是在某些特定条件下进行转换。std::forward是一个有条件转换。为了理解什么时候转换什么时候不转换,回忆一下std::forward是怎么使用的。最常见的场景是在函数模板,参数是一个统一引用参数,这个参数会被传递给另一个函数。

void process(const Widget& lvalArg);        // process lvalues

void process(Widget&& rvalArg);           // process rvalues

template<typename T>
                          // template that passes
void logAndProcess(T&& param)
           // param to process
{
auto now =
                                              // get current time

std::chrono::system_clock::now();

makeLogEntry("Calling 'process'", now);

process(std::forward<T>(param));
}

考虑两种调用logAndProcess的情形,一种是左值,一种是右值:

Widget w;

logAndProcess(w);                   // call with lvalue

logAndProcess(std::move(w)); // call with rvalue

在logAndProcess内部,参数param被传递给函数process,process被重载为左值和右值。当我们通过左值去调用logAndProcess时,我们很自然的期望那个左值可以同样作为一个左值转移到process函数,当我们通过右值去调用logAndProcess时,我们期望重载的右值函数可以被调用。

但是param,像所有函数参数一样,是个左值。在logAndProcess内部每一个对process的调用会调起左值重载。为了避免这个,我们需要一个机制来把param转换成一个右值,当且仅当传入的用来初始化parm的实参(就是传递到logAndProcess的参数)是个右值。

你可能疑惑std::forward是怎么知道它的参数是通过一个右值来初始化的。比如在以上代码中,std::forward是怎么知道param是被一个左值或右值来初始化呢?简单的回答是这个信息被编码到logAndProcess的模板参数T里面。这个参数被传递给std::forward,然后恢复了编码的信息。详细的描述见条款28。

既然std::move和std::forward都归结为转换,唯一不同就是std::move始终进行转换,而std::forward仅仅有时候转换,你可能会问我们是否可以废弃std::move,而只用std::forward。从纯技术角度来看,答案是肯定的:std::forward可以做到,std::move不是必须的。当然函数也不是必须的,我们可以写转换代码,但是我希望我们会觉得那样比较恶心。

std::move吸引人之处在于方便,减少了错误的可能性,而且更加清晰。考虑一个类,可以跟踪我们使用了多少次的move构造函数。我们所需要的是一个静态变量的计数器,在move构造函数使用时增加。假设类里面的唯一一个非静态数据成员是std::string,这里有个方便的方法(也就是利用std::move)去实现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)                      // unconventional,
: s(std::forward<std::string>(rhs.s))  // undesirable
{ ++moveCtorCalls; }                        // implementation

};

注意首先std::move只需要一个函数参数(rhs.s),而std::forward既需要一个函数参数(rhs.s),又需要一个模板类型参数(std::string)。然后我们注意到我们传递给std::forward的参数类型必须一个非引用的,因为编码规范上说明了传递的参数必须是一个右值(见条款28)。std::move需要更少的打字,and it spares us the trouble of passing a type argument。也减少了我们传递一个错误类型(比如std::string&,这会导致数据成员s被拷贝构造而不是move构造)的可能性。

更重要的是,使用std::move会无条件转换为一个右值,而使用std::forward只会把是绑定到右值引用的参数转换成右值。这是两个非常不同的行为。第一个是典型的move,而第二个是仅仅传递(转移)一个对象到另一个函数,通过这种办法来保留对象原始的左值性或右值性。因为两者如此不同,所以最好我们使用不同的函数(名)来区分它们。

                                          要记住的事情

1.std::move执行一个无条件的转化到右值。它本身并不移动任何东西;

2.std::forward把其参数转换为右值,仅仅在那个参数被绑定到一个右值时;

3.std::move和std::forward在运行时(runtime)都不做任何事。