左右值引用,move语义,完美转发

现代 c++ 把表达式分为三种主要类型,每一个c++表达式都可以被确切的分为以下某一类:

  1. lvalue(左值)
  2. prvalue(pure rvalue, 纯右值)
  3. xvalue(eXpiring value, 将亡值)

实际上 prvalue 和 xvalue 都属于右值。

值分类

左值(lvalue)

左值不能简单理解为就是等号左边的值,其实只要能取地址,那这个表达式就是左值。可以取地址意味着在程序的某块内存地址上已经存储了他的内容。

int a = 1;
const char* str = "null";

这里 a 是左值,因为 a 这个变量确实被存到内存里了,并且在内存里面写入的值是1。同理,str 也是左值。但1是临时值,字符常量,作为立即数,直接存储在指令中。

而这个null字符串却是个左值,所有的字符串常量都被放在静态内存区。所以你直接对这个字符串常量 取地址(&),是完全可以取到的。能取到地址说明他就是个左值。

若运行时再让寄存器构造一个字符串的值,不是高效的做法。既然编译就知道了程序用到了哪些字符串常量,提前把所有的字符串常量都放在某块内存地址上,用到的时候再从这里拷贝就好了。况且如果是字符串常量重复使用的话,还可以节省效率。

左值不一定能赋值(例如字符串),但一定可以取地址

纯右值

右值是临时产生的值,不能对右值取地址,因为它本身就没存在内存地址空间上。

举例纯右值如下:

  1. 除字符串以外的常量,如 1,true,nullptr
  2. 返回非引用的函数或操作符重载的调用语句
  3. a++, a–是右值

其实就是一些运算时的中间值,这些值只存在寄存器中辅助运算,不会实际写到内存地址空间中,因此也无法对他们取地址。

将亡值(xvalue)

就是即将销毁的东西。xvalue 也是右值的一种。

  1. 返回右值引用的函数或者操作符重载的调用表达式。如某个函数返回值是 std::move(x),并且函数返回类型是 T&&
  2. 目标为右值引用的类型转换表达式,如 static<int&&>(a)

xvalue 和 prvalue 都是属于右值,不必对它们过度的区分。

左值引用

左值引用可以分为两种:非const左值引用 和 const左值引用。

有很重要的一点是,非const左值引用只能绑定左值;const左值引用既能绑定左值,又能绑定右值。

void print(int& a) 
只能绑定int左值
void print(const int& a);
无论入参是左值和右值,函数都能正常接收

右值引用

右值引用只能绑定到右值上

	int t = 2;
	//int&& rr_t = t; // error,右值引用只能绑定到右值上,b是一个左值
	int&& rr_t = 2; // ok
	cout << rr_t << endl;  // 输出 2
	rr_t++;
	cout << rr_t << endl;  // 输出 3

move语义

move 唯一做的事情其实就是个类型转换。如cppreference原文:

In particular, std::move produces an xvalue expression that identifies
its argument t. It is exactly equivalent to a static_cast to an rvalue
reference type.

move(x) 产生一个将亡值(xvalue)表达式来标识其参数x。他就完全等同于 static_cast<T&&>(x)。所以说,move 并不作任何的资源转移操作。

单纯的 move(x) 不会有任何的性能提升,不会有任何的资源转移。它的作用仅仅是产生一个标识x的右值表达式。

而经过 move 之后,就能用右值引用将其绑定:

	int t = 2;
	int&& rr_t = move(t);

函数重载

以下是摘自 cppreference的一个例子:

void f(int& x)
{
    std::cout << "lvalue reference overload f(" << x << ")\n";
}

void f(const int& x)
{
    std::cout << "lvalue reference to const overload f(" << x << ")\n";
}

void f(int&& x)
{
    std::cout << "rvalue reference overload f(" << x << ")\n";
}

int main()
{
    int i = 1;
    const int ci = 2;
    f(i);  // calls f(int&)
    f(ci); // calls f(const int&)
    f(3);  // calls f(int&&)
           // would call f(const int&) if f(int&&) overload wasn't provided
    f(std::move(i)); // calls f(int&&)
    // rvalue reference variables are lvalues when used in expressions
    int&& x = 1;
    f(x);            // calls f(int& x)
    f(std::move(x)); // calls f(int&& x)
}

当函数参数既有左值引用重载,又有右值引用重载的时候, 我们得到重载规则如下

  1. 若传入参数是非const左值,调用非const左值引用重载函数
  2. 若传入参数是const左值,调用const左值引用重载函数
  3. 若传入参数是右值,调用右值引用重载函数(即使是有 const 左值引用重载的情况下)

因此,f(3) 和 f(std::move(i))会调用 f(int&&),因为他们提供的入参都是右值。所以,通过 move语义 和 右值引用的配合,我们能提供右值引用的重载函数。

这给我们一个可以利用右值的机会。特别是对于将亡值来说,他们都是即将销毁的资源,如果我们能最大程度利用这些资源的话,这显然会极大的增加效率、节省空间。

实现真正的资源转移

考虑一个很简单的 string 类,我们提供简单的 构造函数 和 拷贝构造函数:

class string{
public:
	int length;
	char* str;
	string(const char* a, int l){
		length = l;
		str = new char[l];
		memcpy(str,a,l);
	}
	string(const string& b) {
		length = b.length;
		str = new char[b.length];
		memcpy(str, b.str, b.length);
	}
};

由于ptr 是个指针,需要在堆上申请内存空间存放实际的字符串。因此在实现拷贝构造函数的时候,必须要深拷贝,即重新申请内存空间,并且将其内存数据使用 memcpy 拷贝过来。

当向一个数组里面添加 string 元素时:

tmeplate<T>
class vector<T> {
  void push_back(const T& v) {
    // 调用拷贝构造函数复制对象副本
    T a(v);
    // ...
  }
};
void fun() {
	vector<String> list;
	String a("hello world", 11);
	// 这里会调用拷贝构造函数, 将 a 对象拷贝一份
	//vector 再把这个副本添加到 vector 中
	list.push_back(a);
	return;
}

push_back 会先创建临时对象,然后将临时对象拷贝到容器中,最后销毁临时对象。然而实际上我们可以看出来,fun 函数中 a 这个对象已经没用了,出了作用域就被析构掉了。

有没有办法能把 a 对象的资源移动,而不是重新拷贝一份。有两个问题:

  1. push 函数如何通过入参来区分对象是应该拷贝资源还是应该移动资源
  2. 如何用已有的 string 对象通过资源转移构造出另一个 string,而不是调用拷贝构造函数

右值可以用来标识对象即将要销毁,因为他是临时值,只要push_back 函数能区分入参是左值还是右值就知道应该拷贝还是移动。然而 const T& 这种形参既能接收左值,又能接收右值。因此需要为 push 函数提供右值引用的重载,根据调用规则,右值会优先调用到右值引用参数的函数。

如何调用到这个右值引用重载的版本呢,使用 move 。 std::move(a) 产生一个将亡值,将亡值的含义就代表这个变量将要销毁,不应该在使用。

注意,move本身只相当于一个类型转换,而并未对变量做什么移动操作。所以实际上你仍然可以使用 move 后的变量,但这是未定义行为。

使用右值引用作为参数来重载构造函数,这个函数能够通过转移旧对象的资源去构造新对象。

	String(String&& b) {
		length = b.length;
		str = b.str;
		b.str = nullptr;//这里把转移后的str制空防止被析构
	}

这个函数叫做 移动构造函数。它的参数是右值引用,并且从实现中可以看到,并没有像拷贝构造函数那样重新调用 malloc 申请资源,而是直接用了另一个对象的堆上的资源。

也就是在移动构造函数中,才真正完成了资源的转移。根据前面左右引用函数重载的规则,要想调用移动构造函数,那么必须传入参数为右值才行。使用 move 可以将左值转换为右值:

	String a("hello world", 11);
	String b(move(a));

这里通过移动构造函数将对象 a 资源移动到对象 b 中。还需要完善右值引用重载的 push_back 方法:

// 右值引用重载版本
void push_back(string&& v) {
  // 调用 移动构造函数
  String a(std::move(v));
}

注意必须要加 move。因为 v 虽然是右值引用,但是他是个左值(参考前面所说,具有名字的右值引用是一个左值)。如果没有 move, 那么入参是个左值,将会调用拷贝构造函数。

当我们传入右值时,此时优先匹配到函数 push(string&& v), 自然就调用移动构造函数了。

String a("hello world", 11);
list.push_back(std::move(a));

STL 标准库的 vector 容器已经提供了右值引用的push_back重载,我们自定义类的移动资源操作都需要自己通过编写移动构造函数来实现。

移动构造函数对比拷贝构造函数而言,大多数地方都是相同的复制操作。其实只要是栈上的资源,都是采用复制的方式。而只有堆上的资源,才能复用旧的对象的资源。

栈上的资源不能复用,因为你不知道旧的对象何时析构,旧的对象一旦析构,其栈上所占用的资源也会完全被销毁掉,新的对象如果复用的这些资源就会产生崩溃。

堆上的资源可以复用,因为堆上的资源不会自动释放,除非你手动去释放资源。在移动构造函数特意将旧对象的 m_ptr 指针置为 null,就是为了预防外面对其进行 delete 释放资源。

所以说,只有当你的类申请到了堆上的内存资源的时候,才需要专门实现移动构造函数,否则其实没有必要,因为他的消耗跟拷贝构造函数是一模一样的。

举个例子,如果类成员中有 std::string,那么自己实现移动构造函数是合理的,因为 string 里面存在堆上的资源。反之,如果类成员全是一些 int 变量,那就没必要额外去实现移动构造函数。

万能引用与完美转发

利用 模板 或 typedef,允许出现引用的引用。这些引用会按照一定的规则最终折叠起来:

  • 右值引用的右值引用折叠为右值引用
  • 其他所有类型折叠为左值引用
typedef int&  lref;
typedef int&& rref;
int n;

lref&  r1 = n; // type of r1 is int&
lref&& r2 = n; // type of r2 is int&
rref&  r3 = n; // type of r3 is int&
rref&& r4 = 1; // type of r4 is int&&

万能引用:万能引用又被叫做转发引用,他既可能是左值引用,又可能是右值引用。 当满足以下两种情况时,此时属于万能引用:

函数参数是T&&, 且T是这个函数模板的模板类型

template<class T>
int f(T&& x)  // x is a forwarding reference

auto&&,并且不能是由初始化列表推断出来。

在 C++ 中,并不是所有情况下 && 都代表是一个右值引用,具体的场景体现在模板和自动类型推导中,如果是模板参数需要指定为 T&&,如果是自动类型推导需要指定为 auto &&,在这两种场景下 && 被称作未定的引用类型。

const T&& 表示一个右值引用,不是未定引用类型。

为什么说他是万能引用,是因为它同时支持左值和右值入参。当我们入参传入左值时,他就是个左值引用;当我们入参传入右值时,他就是个右值引用。通过这个规则,我们可以进而推断出 T 的类型,以 string 为模板为例:

  • 假设入参是一个 string 左值: 此时 T&& 应该等同于 string &, 根据引用折叠的规则,T 应该是一个左值引用,于是得到 T 为 string &,即非const左值引用
  • 假设入参是一个 const string 左值: 此时 T&& 等同于 const string&,得到 T 为 const string &,即const 左值引用。
  • 假设入参是右值,如 move(string): 此时 T&& 等同于 string&&, 于是得到 T 为 string&&,即右值引用。

我们再思考另外一个问题,当需要在 f 函数中调用其他函数,并且转发参数的时候,例如调用之前讲的 push_back 函数:

template<class T>
int f(T&& x) {
  push_back(x);
}

由于这里是万能引用,传进来的入参有可能是个左值,有可能是一个右值。然而形参 x 一定是一个左值,因为他是个具名的对象。直接 push_back(x) 的话,就相当于入参传递的一定是左值了。

也就是说,不论我们实际入参是左值还是右值,最后都会被当做左值来转发。即我们丢失了它本身的值类型。有没有办法能仍然保留其值属性?左值就按照左值转发,右值按照右值转发?

完美转发 std::forward 就派上用场了,在转发时,只需要这样做就行了:

template<class T>
int f(T&& x) {
  push(std::forward<T>(x));
}
template< class T >
T&& forward( typename std::remove_reference<T>::type& t ) 
noexcept;

观察 std::forward 的返回值,是 T&&。 根据前面推断模板类型 T 的过程:

若入参是 string 左值,则 T 为 string&. 那么 T&& = string& && = string&. 也就是等同于push_back(string&) , 自然就会调用到左值引用重载去。

若入参是 const string 左值,T为 const string&, 同理得到 push(const
string&),优先匹配const左值引用重载。

若入参是 string 右值,T 为 string&&, T&& = string&& && = string&&; 得到 push(string&&), 调用右值引用重载。

可以看到,forward 让完美的保留了参数的值类型,左值就按照左值转发,右值按照右值转发。这也是为什么他可以叫做完美转发。

编译器的一个重要优化:copy elision

简单来说就是在某些情况下,编译器会智能的省略拷贝操作,实现零拷贝,从而提升效率。

在函数 return 语句中,返回的操作数是一个与函数返回类型(忽略 const )相同的 纯右值,如:

T f() {return T();}
// 只会调用一次构造函数
T a = f();

在这种情况下,编译器不会调用的拷贝构造函数或是移动构造函数,而是直接使用这个临时变量。整个过程只会调用一次构造函数,没有任何拷贝。这个优化过程叫做 Return Value Optimization(RVO)。

A g() {
  A a;
  return a;
}

函数 return 语句中,返回的操作数是一个与返回类型相同的(忽略const)非volatile对象,并且不是函数参数。这个优化叫 Named Return Value Optimization(NRVO)。NRVO 是可选的。

然而一些错误写法可能会导致 RVO 无法实施,从而反而降低了性能。

  • 返回 std::move,并且函数返回类型是值类型
A f() {
  A a;
  return std::move(a);
}

由于返回返回类型与 return 的操作数的类型不一致(返回类型为A,而return的操作数是右值引用),NRVO 无法实施,从而进行了拷贝。不论是拷贝构造还是移动构造都降低了性能。

  • 返回 std::move,且函数返回类型是右值引用
A&& f() {
  A a;
  return std::move(a);
}

这种情况倒是不会产生拷贝,但返回了局部对象的引用,会导致运行时错误。如果返回的是左值引用,一样的道理。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值