右值引用、移动构造、引用声明(3)——示例篇

转载自:知乎- Tinro 、知乎-蓝色

 

右值引用新特性的目的

右值引用 (Rvalue Referene) 是 C++ 新标准 (C++11, 11 代表 2011 年 ) 中引入的新特性 , 它实现了转移语义 (Move Sementics) 和精确传递 (Perfect Forwarding)。它的主要目的有两个方面:

  1. 消除两个对象交互时不必要的对象拷贝,节省运算存储资源,提高效率。
  2. 能够更简洁明确地定义泛型函数。

perfect forwarding 需要解决的问题是爆炸式函数重载。因为我们有了move ctor之后,我们显然会在函数参数声明时将参数声明为T&&,否则如果还是使用T const&来声明,那么我们将不能move ctor,这样就没有什么意义了。但是问题在于,用户可能传入一个右值,或者一个左值,那么我们可以重载:

void f(foo const& a);
void f(foo&& a);

但是当参数数量变多时怎么办,假设有N个参数,那么显然我们需要重载2^N个函数才能解决问题,所以引入了perfect forwarding。

function template

在使用perfect forwarding时,我们需要结合函数模板和右值引用,即

void g(int const&);
void g(int&&);

template<typename T>
void f(T&& v)
{
    g(forward<T>(v));
}

这里标准库函数forward完成了类型转发,forward() 传递给的g的类型的左右值属性是用户传入的属性。

注意,当且仅当参数为T&&,才会触发perfect forwarding,引述标准的文字

14.8.2.1/3

If P is a cv-qualified type, the top level cv-qualifiers of P’s type are ignored for type deduction. If P is a reference type, the type referred to by P is used for type deduction. If P is an rvalue reference to a cv-unqualified template parameter and
the argument is an lvalue, the type “lvalue reference to A” is used in place of A for type deduction.

Reference Collapsing Rule

我们知道在c++中引用的引用是非法,所以标准中引入如下规则,

  1. T& && = T&
  2. T&& & = T&
  3. T& & = T&
  4. T&& && = T&&

这里的意思是,当一个类型,比如T& &&时,最终得到的类型是T&。可以看出,仅当T&& &&的情况,类型才是右值引用,其他情况都是左值引用。

Deduction (建议参考:模板实参推导 阅读)

在讲解forwarding的原理前,先来了解下几个模板推导中用到的术语,表中定义,P就是函数模板的参数类型,A是函数模板调用时的用户给出的类型。

推导的目的就是为了匹配P和A(P=A),来解析出T。Deduced A就是P经过转换后的类型(见标准的14.8.2.1),transformed A是当P为特定条件时,变换后得到的类型。

推导开始前,对 P 和 A 进行下列调整:

1.若 P 不是引用类型,

     a) 若 A 是数组类型,则以从数组到指针转换获得的指针类型替换 A

     b) 否则,若 A 是函数类型,则以从函数到指针转换获得的指针类型替换 A

    c) 否则,若 A 是 cv 限定的类型,则为推导而忽略顶层 cv 限定符:

2.若 P 是 cv 限定类型,则为推导忽略顶层 cv 限定符。

3. 若 P 是引用类型,则用 P 所引用的类型推导。

4. 若 P 是到无 cv 限定模板形参的右值引用(是谓转发引用),且对应函数调用实参为左值,则将到 A 的左值引用类型用于 A 的位置推导(注意:这是 std::forward 的行动基础;注意:类模板实参推导中,类模板的模板形参决不会是转发引用 (C++17 起)。):

回到前面perfect forwarding的讲解,先来看函数模板f如何推导template parameter T。下面给出标准中的例子:

template<class T>
int f(T&&);       // P 是到无 cv 限定类型 T 的右值引用(转发引用)
template<class T>
int g(const T&&); // P 是到 cv 限定 T 的右值引用(非特殊)
 
int main()
{
    int i;
    int n1 = f(i); // 实参为左值:调用 f<int&>(int&) (特殊情况)
    int n2 = f(0); // 实参非左值:调用 f<int>(int&&)
 
//  int n3 = g(i); // 错误:推导出 g<int>(const int&&),它不能绑定右值引用到左值
}

 

下面举例说明。函数 forward_value 是一个泛型函数,它将一个参数传递给另一个函数 process_value。

forward_value 的定义为:

template <typename T> void forward_value(const T& val) { 
 process_value(val); 
} 
template <typename T> void forward_value(T& val) { 
 process_value(val); 
}

函数 forward_value 为每一个参数必须重载两种类型,T& 和 const T&,否则,下面四种不同类型参数的调用中就不能同时满足  :

int a = 0; 
const int &b = 1; 
forward_value(a); // int& 
forward_value(b); // const int& 
forward_value(2); // int& 理解需要后文的引用坍缩/折叠 

对于一个参数就要重载两次,也就是函数重载的次数和参数的个数是一个正比的关系。这个函数的定义次数对于程序员来说,是非常低效的。我们看看右值引用如何帮助我们解决这个问题  :

template <typename T> void forward_value(T&& val) { 
 process_value(val); 
}

只需要定义一次,接受一个右值引用的参数,就能够将所有的参数类型原封不动的传递给目标函数。四种不用类型参数的调用都能满足,参数的左右值属性和 const/non-cosnt 属性完全传递给目标函数 process_value。这个解决方案不是简洁优雅吗?

int a = 0; 
const int &b = 1; 
forward_value(a); // int& 
forward_value(b); // const int& 
forward_value(2); // int&&

C++11 中定义的 T&& 的推导规则为:

右值实参为右值引用,左值实参仍然为左值引用。

一句话,就是参数的属性不变。这样也就完美的实现了参数的完整传递。

右值引用,表面上看只是增加了一个引用符号,但它对 C++ 软件设计和类库的设计有非常大的影响。它既能简化代码,又能提高程序运行效率。每一个 C++ 软件设计师和程序员都应该理解并能够应用它。我们在设计类的时候如果有动态申请的资源,也应该设计转移构造函数和转移拷贝函数。在设计类库时,还应该考虑 std::move 的使用场景并积极使用它。

 

移动语义

右值引用解决的是各种情形下对象的资源所有权转移的问题。

C++11之前,移动语义的缺失是C++最令人诟病的问题之一。举个栗子:

问题一:如何将大象放入冰箱?
这个答案是众所周知的。首先你需要有一台特殊的冰箱,这台冰箱是为了装下大象而制造的。你打开冰箱门,将大象放入冰箱,然后关上冰箱门。

问题二:如何将大象从一台冰箱转移到另一台冰箱?
普通解答:打开冰箱门,取出大象,关上冰箱门,打开另一台冰箱门,放进大象,关上冰箱门。
2B解答:在第二个冰箱中启动量子复制系统,克隆一只完全相同的大象,然后启动高能激光将第一个冰箱内的大象气化消失。
等等,这个2B解答听起来很耳熟,这不就是C++中要移动一个对象时所做的事情吗?

“移动”,这是一个三岁小孩都明白的概念。将大象(资源)从一台冰箱(对象)移动到另一台冰箱,这个行为是如此自然,没有任何人会采用先复制大象,再销毁大象这样匪夷所思的方法。C++通过拷贝构造函数和拷贝赋值操作符为类设计了拷贝/复制的概念,但为了实现对资源的移动操作,调用者必须使用先复制、再析构的方式。否则,就需要自己实现移动资源的接口。

为了实现移动语义,首先需要解决的问题是,如何标识对象的资源是可以被移动的呢?这种机制必须以一种最低开销的方式实现,并且对所有的类都有效。C++的设计者们注意到,大多数情况下,右值所包含的对象都是可以安全的被移动的。

右值(相对应的还有左值)是从C语言设计时就有的概念,但因为其如此基础,也是一个最常被忽略的概念。不严格的来说,左值对应变量的存储位置,而右值对应变量的值本身(也可以理解为是否持久化)。

C++中右值可以被赋值给左值或者绑定到引用。类的右值是一个临时对象,如果没有被绑定到引用,在表达式结束时就会被废弃。于是我们可以在右值被废弃之前,移走它的资源进行废物利用,从而避免无意义的复制。被移走资源的右值在废弃时已经成为空壳,析构的开销也会降低。

右值中的数据可以被安全移走这一特性使得右值被用来表达移动语义。以同类型的右值构造对象时,需要以引用形式传入参数。右值引用顾名思义专门用来引用右值,左值引用和右值引用可以被分别重载,这样确保左值和右值分别调用到拷贝和移动的两种语义实现。对于左值,如果我们明确放弃对其资源的所有权,则可以通过std::move()来将其转为右值引用。std::move()实际上是static_cast<T&&>()的简单封装。

右值引用至少可以解决以下场景中的移动语义缺失问题:

  • 按值传入参数

按值传参是最符合人类思维的方式。基本的思路是,如果传入参数是为了将资源交给函数接受者,就应该按值传参。同时,按值传参可以兼容任何的cv-qualified左值、右值,是兼容性最好的方式。

class People {
public:
  People(string name) // 按值传入字符串,可接收左值、右值。接收左值时为复制,接收右值时为移动
  : name_(move(name)) // 显式移动构造,将传入的字符串移入成员变量
  {
  }
  string name_;
};
 
People a("Alice"); // 移动构造name
 
string bn = "Bob";
People b(bn); // 拷贝构造name

构造a时,调用了一次字符串的构造函数和一次字符串的移动构造函数。如果使用const string& name接收参数,那么会有一次构造函数和一次拷贝构造,以及一次non-trivial的析构。尽管看起来很蛋疼,尽管编译器还有优化,但从语义来说按值传入参数是最优的方式。

如果你要在构造函数中接收std::shared_ptr<X>并且存入类的成员(这是非常常见的),那么按值传入更是不二选择。拷贝std::shared_ptr<X>需要线程同步,相比之下移动std::shared_ptr是非常轻松愉快的。

  • 按值返回

和接收输入参数一样,返回值按值返回也是最符合人类思维的方式。曾经有无数函数为了返回容器而不得不写成这样
void str_split(const string& s, vector<string>* vec);// 一个按值语义定义的字符串拆分函数。这里不考虑分隔符,假定分隔符是固定的。

这样要求vec在外部被事先构造,此时尚无从得知vec的大小。即使函数内部有办法预测vec的大小,因为函数并不负责构造vec,很可能仍需要resize。对这样的函数嵌套调用更是痛苦的事情,谁用谁知道啊。

有了移动语义,就可以写成这样

vector<string> str_split(const string& s) {
  vector<string> v;
  // ...
  return v; // v是左值,但优先移动,不支持移动时仍可复制。
}

如果函数按值返回,return语句又直接返回了一个栈上的左值对象(输入参数除外)时,标准要求优先调用移动构造函数,如果不符再调用拷贝构造函数。尽管v是左值,仍然会优先采用移动语义,返回vector<string>从此变得云淡风轻。此外,无论移动或是拷贝,可能的情况下仍然适用编译器优化,但语义不受影响。

对于std::unique_ptr来说,这简直就是福音。

unique_ptr<SomeObj> create_obj(/*...*/) {
  unique_ptr<SomeObj> ptr(new SomeObj(/*...*/));
  ptr->foo(); // 一些可能的初始化
  return ptr;
}
//当然还有更简单的形式
unique_ptr<SomeObj> create_obj(/*...*/) {
  return unique_ptr<SomeObj>(new SomeObj(/*...*/));
}

在工厂类中,这样的语义是非常常见的。返回unique_ptr能够明确对所构造对象的所有权转移,特别的,这样的工厂类返回值可以被忽略而不会造成内存泄露。上面两种形式分别返回栈上的左值和右值,但都适用移动语义(unique_ptr不支持拷贝)。

  • 接收右值表达式

没有移动语义时,以表达式的值(例为函数调用)初始化对象或者给对象赋值是这样的:

vector<string> str_split(const string& s);

vector<string> v = str_split("1,2,3");// 返回的vector用以拷贝构造对象v。为v申请堆内存,复制数据,然后析构临时对象(释放堆内存)。

vector<string> v2;
v2 = str_split("1,2,3"); // 返回的vector被复制给对象v(拷贝赋值操作符)。需要先清理v2中原有数据,
                            将临时对象中的数据复制给v2,然后析构临时对象。
                            注:v的拷贝构造调用有可能被优化掉,尽管如此在语义上仍然是有一次拷贝操作。

//同样的代码,在支持移动语义的世界里就变得更美好了。
vector<string> str_split(const string& s);
vector<string> v = str_split("1,2,3"); // 返回的vector用以移动构造对象v。v直接取走临时对象的堆上内存,无需新申请。
                                          之后临时对象成为空壳,不再拥有任何资源,析构时也无需释放堆内存。

vector<string> v2;
v2 = str_split("1,2,3"); // 返回的vector被移动给对象v(移动赋值操作符)。先释放v2原有数据,
                            然后直接从返回值中取走数据,然后返回值被析构。

//注:v的移动构造调用有可能被优化掉,尽管如此在语义上仍然是有一次移动操作。

 

不用多说也知道上面的形式是多么常用和自然。而且这里完全没有任何对右值引用的显式使用,性能提升却默默的实现了。

  • 对象存入容器

这个问题和前面的构造函数传参是类似的。不同的是这里是按两种引用分别传参。参见std::vectorpush_back函数。

void push_back( const T& value ); // (1)
void push_back( T&& value ); // (2)

不用多说自然是左值调用1右值调用2。如果你要往容器内放入超大对象,那么版本2自然是不2选择。

vector<vector<string>> vv;
vector<string> v = {"123", "456"};

v.push_back("789"); // 临时构造的string类型右值被移动进容器v
vv.push_back(move(v)); // 显式将v移动进vv

困扰多年的难言之隐是不是一洗了之了?

  • std::vector的增长

又一个隐蔽的优化。当vector的存储容量需要增长时,通常会重新申请一块内存,并把原来的内容一个个复制过去并删除。对,复制并删除,改用移动就够了。

对于像vector<string>这样的容器,如果频繁插入造成存储容量不可避免的增长时,移动语义可以带来悄无声息而且美好的优化。

  • std::unique_ptr放入容器

曾经,由于vector增长时会复制对象,像std::unique_ptr这样不可复制的对象是无法放入容器的。但实际上vector并不复制对象,而只是“移动”对象。所以随着移动语义的引入,std::unique_ptr放入std::vector成为理所当然的事情。

容器中存储std::unique_ptr有太多好处。想必每个人都写过这样的代码:

MyObj::MyObj() {
  for (...) {
    vec.push_back(new T());
  }
// ...
}

MyObj::~MyObj() {
  for (vector<T*>::iterator iter = vec.begin(); iter != vec.end(); ++iter) {
    if (*iter) delete *iter;
   }
// ...
}

繁琐暂且不说,异常安全也是大问题。使用vector<unique_ptr<T>>,完全无需显式析构,unqiue_ptr自会打理一切。完全不用写析构函数的感觉,你造吗?

unique_ptr是非常轻量的封装,存储空间等价于裸指针,但安全性强了一个世纪。实际中需要共享所有权的对象(指针)是比较少的,但需要转移所有权是非常常见的情况。auto_ptr的失败就在于其转移所有权的繁琐操作。unique_ptr配合移动语义即可轻松解决所有权传递的问题。

注:如果真的需要共享所有权,那么基于引用计数的shared_ptr是一个好的选择。shared_ptr同样可以移动。由于不需要线程同步,移动shared_ptr比复制更轻量。

  • std::thread的传递

thread也是一种典型的不可复制的资源,但可以通过移动来传递所有权。同样std::future std::promise std::packaged_task等等这一票多线程类都是不可复制的,也都可以用移动的方式传递。

 

完美转发(Perfect Forwarding)

该部分内容来自实验楼-C++11/14 高速上手教程-实验三

引用坍缩/折叠 

前面我们提到了,一个声明的右值引用其实是一个左值。这就为我们进行参数转发(传递)造成了问题:

#include <iostream>
//using namespace std;


void reference(int& v) {
	std::cout << "左值" << std::endl;
}

void reference(int&& v) {
	std::cout << "右值" << std::endl;
}

template <typename T>
void pass(T&& v) {
	std::cout << "普通传参:";
	reference(v);   // 始终调用 reference(int& )
}

int main() {
	std::cout << "传递右值:" << std::endl;
	pass(1);        // 1是右值, 但输出左值: 进入移动语句,调用reference(v),但由于 v 是一个引用,所以同时也是左值。
					// 因此 reference(v) 会调用 reference(int&),输出『左值』。

	std::cout << "传递左值:" << std::endl;
	int v = 1;
	pass(v);        // v是左引用, 为什么会调用移动语句,输出左值?
					// 这是基于引用坍缩规则的:在传统 C++ 中,我们不能够对一个引用类型继续进行引用,
					// 但 C++ 由于右值引用的出现而放宽了这一做法,从而产生了引用坍缩规则,允许我们对引用进行引用,既能左引用,又能右引用。

	return 0;
}

对于 pass(1) 来说,虽然传递的是右值,但由于 v 是一个引用,所以同时也是左值。因此 reference(v) 会调用 reference(int&),输出『左值』。而对于pass(v)而言,v是一个左值,为什么会成功传递给 pass(T&&) 呢?

这是基于引用坍缩规则的:在传统 C++ 中,我们不能够对一个引用类型继续进行引用,但 C++ 由于右值引用的出现而放宽了这一做法,从而产生了引用坍缩规则,允许我们对引用进行引用,既能左引用,又能右引用。但是却遵循如下规则:

函数形参类型实参参数类型推导后函数形参类型
T&左引用T&
T&右引用T&
T&&左引用T&
T&&右引用T&&

因此,模板函数中使用 T&& 不一定能进行右值引用,当传入左值时,此函数的引用将被推导为左值。更准确的讲,无论模板参数是什么类型的引用,当且仅当实参类型为右引用时,模板参数才能被推导为右引用类型。这才使得 v 作为左值的成功传递。

完美转发就是基于上述规律产生的。所谓完美转发,就是为了让我们在传递参数的时候,保持原来的参数类型(左引用保持左引用,右引用保持右引用)。为了解决这个问题,我们应该使用 std::forward 来进行参数的转发(传递):

#include <iostream>
#include <utility>
void reference(int& v) {
    std::cout << "左值引用" << std::endl;
}
void reference(int&& v) {
    std::cout << "右值引用" << std::endl;
}
template <typename T>
void pass(T&& v) {
    std::cout << "普通传参:";
    reference(v);
    std::cout << "std::move 传参:";
    reference(std::move(v));
    std::cout << "std::forward 传参:";
    reference(std::forward<T>(v));

}
int main() {
    std::cout << "传递右值:" << std::endl;
    pass(1);

    std::cout << "传递左值:" << std::endl;
    int v = 1;
    pass(v);

    return 0;
}

 输出结果为:

传递右值:
普通传参:左值引用
std::move 传参:右值引用
std::forward 传参:右值引用
传递左值:
普通传参:左值引用
std::move 传参:右值引用
std::forward 传参:左值引用

无论传递参数为左值还是右值,普通传参都会将参数作为左值进行转发,所以 std::move 总会接受到一个左值,从而转发调用了reference(int&&) 输出右值引用。

唯独 std::forward 即没有造成任何多余的拷贝,同时完美转发(传递)了函数的实参给了内部调用的其他函数。

std::forward 和 std::move 一样,没有做任何事情,std::move 单纯的将左值转化为右值,std::forward 也只是单纯的将参数做了一个类型的转换,从是实现来看,std::forward<T>(v) 和 static_cast<T&&>(v) 是完全一样的。

 

std::move(expr)和std::forward(expr)原理

std::move是无条件的转为右值引用,而std::forward是有条件的转为右值引用,更准确的说叫做Perfect forwarding(完美转发),而std::forward里面蕴含着的条件则是Reference Collapsing(引用折叠)。

对于std::move来说,其boost的实现基本上等价于如下形式

template <typename T>
decltype(auto) move(T&& param)
{
   using return_type = std::remove_reference<T>::type&&;
   return static_cast<return_type>(param);
}

于是,我们可以看见这里面的逻辑其实是无论你的param是何种类型,都会被强制转为右值引用类型。

而唯一这里需要注意的是模版这里的T&& 类型,我愿意欣赏与接受Meyers的叫法,他把这样的类型叫做Universal Reference。对于Universal Reference来说,若你传递的param是一个左值,那么T 将会被 推断(deduce) 成 Lvalue Reference(左值引用),其Param Type也是左值引用。若你传递进来的param是右值,那么T则是正常的param类型,如int等,其Param Type结果是T&&。

举一个简单的栗子

template<typename T>
void foo(T&& param);

int i = 7;
foo(i);
foo(47);

i是一个左值,于是T被推断成int&,于是变为了 foo(int& &&) ;

而整个参数的结果类型,即Param Type为int&,C++不允许reference to reference,会进行引用折叠,这也是后面谈到的forward的核心。

而对于foo(47),由于47是右值,那么T被正常的deduce成int,于是变为了foo(int &&) ;

对于forward,其boost的实现基本可以等价于这样的形式:

template <typename T>
T&& forward(typename remove_reference<T>::type& param)
{
    return static_cast<T&&>(param);
}

 那么这里面是如何达到完美转发的呢?

template<typename T>
void foo(T&& fparam)
{
    std::forward<T>(fparam);
}

int i = 7;
foo(i);
foo(47);

如上文所述,这里的 i 是一个左值,于是,我们在void foo(T&& fparam)这里的话,T将会被推断成int& 然后Param Type为int&。(注意,我这里使用的变量名字为fparam,以便与forward的param进行区分)

那么为什么Param Type会是int&呢?因为按照正常的推断,我们将会得到

void foo(int& &&fparam);

根据前面的引用坍缩规则,void foo(T&& fparam) 这里T的Universal Reference让fparam拥有右值引用类型,那么则需要保证传递归来的参数为右值才可以,因为若是左值的话,T会推断成左值引用,结合引用折叠规则,fparam的类型会是左值引用类型。

于是我们现在来看,int& &&这样的情况属于Lvalue reference to Rvalue reference,结果则为左值引用。那么,我们这个时候带入到forward函数来看看,首先是T变为了int&,经过了remove_reference变为了int,结合后面跟上的&,则变为了int&。然后我们再次替换 static_cast和return type的T为int&,都得到了int& &&

template <typename T>
T&& forward(typename remove_reference<T>::type& param)
{
    return static_cast<T&&>(param);
}

//将T替换为int&

int& && forward(int& param)
{
    return static_cast<int& &&>(param);
}

于是再应用引用折叠规则,int& &&都划归为了int&

int& forward(int& param)
{
    return static_cast<int&>(param);
}

于是,我们可以发现我们fparam变量的左值引用类型被保留了下来。这里也需要注意,我们到达forward的时候就已经是左值引用了,所以forward并没有改变什么。

如我们这时候是47这样的右值,我们知道了T会被推断成int,经过了remove_reference,变为了int,跟上后面的&,成为了int&,然后再次替换static_cast和返回类型的T为int&&

int && forward(int& param)
{
    return static_cast<int&&)(param);
}

于是,我们也可以发现,我们fparam变量的右值引用类型也完美的保留了下来。

 

总结

移动语义绝不是语法糖,而是带来了C++的深刻革新。移动语义不仅仅是针对库作者的,任何一个程序员都有必要去了解它。尽管你可能不会去主动为自己的类实现移动语义,但却时时刻刻都在享受移动语义带来的受益。因此这绝不意味着这是一个可有可无的东西。

除了移动语义,右值引用还解决了C++03中引用语法无法转发右值的问题,实现了完美转发,才使得std::function能有一个优雅的实现。相对于移动语义来说,我觉得这已经是小问题了。这部分不再展开了。

-------------------------------------------------------------------

 

更多好的资料:qicosmosC++ Rvalue Reference Explainedc++11 rvalue reference & perfect forwarding

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值