右值引用、移动语义和完美转发

  C++作为一门极为讲求运行效率的编程语言,在新标准中引入了右值引用和移动语义,同时通过右值引用还可以实现参数的完美转发,方便进行通用模板编程。

一、右值引用

1.1 左值和右值

  在C++中,对象具有两种属性:(1)有身份:在程序有有对象的名字,或者指向该对象的指针,或者指向该对象的引用;(2)可移动:能否把对象的内容移动出来,比如把对象的值移动到其他对象中去,该对象移动后处于合法但是未指定的状态。
  所以,根据上面两种属性总共有四种组合出现,不过没有身份又无法移动的对象不重要也不需要讨论,因此可以看到:
right-val
  从上图看到,一个经典的左值有身份但是不能移动,一个经典的右值允许执行移出操作,而其中的特殊值常常是用std::move()方式得到的,他本身具有身份,但是通过上面的操作也允许其执行移出的许可。纯右值一般是函数以非引用方式返回的临时变量、运算表达式(比如运算、关系、位、后置递增递减等)产生的临时变量、原始字面量、lambda表达式等,都是纯右值的概念,这些对象即将被消亡且没有其他的用户;特殊值是C++11中新产生的,比如将要被移动的对象、T&&函数返回值、std::move()返回值和转换为T&&类型的转换函数的返回值。

1.2 右值引用

  在C++中,有左值引用、const左值引用、右值引用三种类型,他们的使用目的各不相同:普通左值引用所引用的对象表明用户可以执行写入、修改操作;const左值引用的对象对用户看来是不可修改的;右值引用对应于一个临时对象,用户可以修改这个对象,并且确认这个对象以后不会再用了。
  右值引用就是对一个右值进行引用的类型,右值通常没有身份,但是通过引用的方式可以操作它,右值引用接管该对象后,此时右值的生命周期就延长到了引用的生命周期了,只要改引用还存在,则临时值也会存活下去。右值引用只可以绑定到右值,不过const左值引用作为一个万能引用也可以绑定到右值上面,不过正如上面所提到的,两者的使用目的不一致,右值引用通常对其所引用的对象会进行一个破坏性的操作。
  除了下面将会描述的移动操作,通常右值引用也可以用于优化非引用函数的返回,减少一次对象的拷贝操作,而且const左值引用也有类似的效果哦:

1
2
3
Widget getWidget();
Widget&& a = GetWidget();
const Widget& ra = GetWidget();

1.3 Universe Reference和引用折叠

  说到这里,还是要和模板推导相关联起来。考虑一下三个模板函数:

1
2
3
template<typename T> void f1(T& p);
template<typename T> void f2(const T& p);
template<typename T> void f3(T&& p);

  f1只能接收一个普通左值,此时其所能接收的类型只能是一个普通变量或者返回引用类型的表达式,当然实参可以是const的也可以不是,但是f1(5)这样传递右值是不允许的;f2则可以接收任何类型,可以是const或者非const对象、一个临时对象、或者一个字面常量值;但是f3的参数是一个右值引用,正常绑定规则告知我们只能传递给它一个右值,比如f3(5)。
  如果只到这里的话,基本是没得玩了。不过C++在正常绑定规则之外,又定义了两个例外的规则:
  (1) 当我们将一个普通左值传递给模板类型参数T &&的时候,编译器将会推断模板类型参数为实参的左值引用类型,比如:

1
2
int i = 5;
f3(i); // T -> int &

  这样看来,f3的参数相当于是一个int&类型的右值引用。
  (2) 左值引用具有优先权,也就是所有的右值引用叠加到右值引用上,得到的仍然还是一个右值引用;所有其他引用之间的叠加豆浆变为一个左值引用。
  我们把指向模板参数类型(T)的右值引用(T&&)称之为universe reference,其常见的环境为模板函数(还有一种情况就是auto &&的参数推导情况),也就是用在参数推导的上下文当中,没有参数推导的&&就是普通的右值引用的意义。此时如果调用的实参是一个左值,则推导出来的模板实参是一个左值引用,同时根据引用折叠的规则,得到整个函数参数将被实例化成为一个普通的左值引用T&。

二、移动语义和std::move

  C++标准库utility中的std::move是右值引用模板的一个经典例子,std::move负责返回其实参的右值引用,赋予了将其移出的许可。
  经过移动操作之后,被移动的源对象将会进入一个只允许被析构或者重新赋值的状态,其他对其访问方式都是非法的。

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

  上面就是std::move()短小而又精悍的定义,其接受的参数既可以是左值,也可以是右值:当传递的参数是普通右值时候(比如string),则T被推导为string,move最终的返回类型是string &&;当传递的参数是左值的时候(比如string),则T被推导为string&,通过模板折叠的规则我们知道&和&&会被折叠成&,所以通过type trait之remove_reference来得到其不带引用的数据类型,然后通过static_cast后其返回类型确保是string&&。
  static_cast将一个string&转换成string&&,既说明通过static_cast显式地将一个左值引用转换成右值引用是被允许的。

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

  STL标准容器、string、shared_ptr既支持移动也支持拷贝,而IO类、unique_ptr、future、thread这些类型则只可以可以移动而不支持拷贝,这些都是C++开发中常用的组件,所以善用这些标准组件的移动操作在一定程度上已经可以优化程序的性能了。如果更上一个层次,在我们设计自己类的时候,可以考虑添加移动构造函数和移动赋值运算符,这样在可以移动的时候便可以得到性能的优化。不过,我们在提供右值引用的移动操作的时候,通常也会提供常量左值引用的拷贝操作,以保证移动不成的时候还可以用拷贝操作完成任务。
  比如以下string类,其中同时定义了拷贝构造函数和移动构造函数的原型,以同时支持拷贝和移动操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
class string{
public:
string(const string&); // copy
string(string &&); // move
};
class Annotation{
public:
explicit Annotation(const std::string text):
value_(std::move(text)) { ... }
private:
std::string value_;
};

  我们知道,右值可以绑定到&&或者const &,但是他们的区别是前者基本都会改变源对象,而后者不会。所以,对于上面的Annotation类本身的想法是优化value_的初始化,但是通过std::move之后得到的结果是const std::string而不是std::string,const std::string的参数类型只能调用拷贝构造函数,而不能调用移动构造函数,所以上面的优化是无效的!
  所以,如果想得到一个移动操作,则其类型不能被声明为const,即使我们使用了std::move,其所得到的结果也不见得就是真正可以被移动的。

三、完美转发和std::forward

  在进行模板通用编程的时候,经常需要将一个或者多个实参连同其类型完整地转发给其他函数(比如std::make_shared),这里需要转发的属性会包含const限定符以及实参是左值还是右值这类属性,而外部函数只起到类似一层包装的作用。
  通过将一个函数的参数定义为指向模板类型的右值引用T&&,则可以保持其对应实参的所有类型信息,而且当参数为引用类型时,const是作为底层引用处理,该const属性也会被保留。

1
2
3
4
5
6
7
8
9
template<typename T>
T&& forward(remove_reference<T>::type& t) noexcept {
return static_cast<T&&> (t);
}
template<typename T>
T&& forward(remove_reference<T>::type&& t) noexcept {
static_assert(!is_lvalue_reference<T>, "forward of lvalue error!");
return static_cast<T&&> (t);
}

  不过,这只对左值引用有效。要记住一条法则:所有的表达式,包括函数的形参,其永远是一个lvalue左值类型,虽然其类型可能是右值引用类型
  针对比如下面的调用,虽然f函数的形参i是int &&右值引用的类型,但是i本身确是货真价实的lvalue,所以在下面的调用中,如果t2以右值的方式传参,但是t2本身却是个lvalue,那么f函数无法将一个lvalue绑定到其右值引用的形参i上面,所以编译会失败。而且,根据这个法则,我们也无法将一个右值绑定到一个右值引用上面。

1
2
3
4
5
6
7
8
9
10
int && i =23;
int && j = i; //Error
int f(int &&i, int &j) {}
template<typename F, typename T1, typename T2>
void filp1(F f, T1 &&t1, T2 && t2) {
f(t2, t1); // Error
}

  解决上面的办法,就是使用标准库的std::forward这个工具,这个工具使用的时候需要指定模板实参,然后返回该模板实参的右值引用类型,即std::forward返回T &&。其使用形如:

1
2
3
4
template<typename F, typename T1, typename T2>
void filp2(F f, T1 &&t1, T2 && t2) {
f(std::forward<T2>(t2), std::forward<T1>(t1));
}

  所以上面看来,std::move和std::forward都是模板函数的调用,前者在其内部执行一个无条件的static_cast成对应类型的rvalue类型,而后者只有在其参数确实是右值引用的时候,才会显式进行static_cast将参数转换成右值引用以实现其类型的完美转发。

四、避免重载universe reference

  结合现代容器支持的特性更加完善,合理的借助参数转发和移动操作可能会获得更好的性能,比如:

1
2
3
4
5
6
7
8
9
template<typename T>
void logAndAdd(T&& name) {
...; names.emplace(std::forward<T>(name));
}
std::string petName("Darla");
logAndAdd(petName); // copy
logAndAdd(std::string("Persephone")); // move rvalue
logAndAdd("Patty Dog"); // direct create std::string

  第一个调用,传入进来的是正常的lvalue,所以容器按照传值的方式拷贝进容器中;第二个调用会创建一个临时的string对象,而这个临时对象是个右值,则容器的emplace接收一个右值的时候会采用移动操作;第三个调用传入的是一个字符常量,转发到emplace后会在容器中进行一个就地构造(比拷贝、移动更加高效),其调用参数会转发给对应类型的构造函数。
  如果只是这样使用,一切都很好,但是如果给logAndAdd添加一个重载版本的话:

1
2
3
void logAndAdd(int idx) {
...
}

  此时,除非调用的参数是准确的int,才会调用上面的版本,而其他类型(比如short)类型的参数,都会采用universe reference的方式进行推导。此处通过universe reference推导得到的将会是精确匹配,相比而言会优于short采用整型提升要好,但是根据上面的情形看来,这常常不是我们的本意。Universe reference是一个贪婪的东西,和这种类型进行函数重载是极度危险的,应当尽量避免。
  上面的例子还不是最恶毒的,如果写出带有参数转发的构造函数,其结果将会更糟。

1
2
3
4
5
6
7
8
9
10
11
12
class Person {
public:
template<typename T>
explicit Person(T&& n): name_(std::forward<T>(n)) {}
explicit Person(int idx);
Person(const Person& rhs);
Person(Person&& rhs);
private:
std::string name_;
};

  在C++中,如果有一个使用universe reference转发参数的构造函数,即使通过模板实例化可以产生出拷贝构造、移动构造函数,但是C++还是会自己生成他们。针对上面的情况,如果有以下调用:

1
2
3
4
5
Person p("Nancy");
const Person cp("Nancy");
auto cloneOfP1(p); // call templated ctor
auto cloneOfP2(cp); // call copy ctor

  表面上都会调用拷贝构造函数,但是:编译器生成的拷贝构造函数其参数为const Person&,而模板化的构造函数其参数类型为Person &,如果通过使用一个非const的对象进行“拷贝”,编译器会调用后面模板实例化的构造函数(但是将一个Person类型转发然后初始化std::string,肯定会编译报错);如果使用一个const对象进行拷贝,虽然模板实例化也会产生一个和标准拷贝函数签名相同的函数,但是根据规则编译器会优先调用非实例化的函数。
  如果再将问题放到继承中,那么问题也会变的复杂:

1
2
3
4
5
6
7
class SpecialPerson: public Person {
public:
SpecialPerson(const SpecialPerson& rhs):
Person(rhs) {} // call base forward ctor
SpecialPerson(SpecialPerson&& rhs):
Person(std::move(rhs)) {} // call base forward ctor
};

  上面是在派生体系中普通构造函数和拷贝构造函数的标准写法,尤其在拷贝构造函数中,派生类默认会用基类的默认构造函数初始化基类部分,除非显示使用派生类对象作为参数调用基类的拷贝构造函数。根据同样的法则,基类的模板构造函数会使用SpecialPerson类型进行实例化,当然根据上面的定义,使用SpecialPerson初始化std::string必将编译报错。

本文完!

参考