❥关于C++之右值引用&移动语义┇移动构造&移动复制

Posted itzyjr

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了❥关于C++之右值引用&移动语义┇移动构造&移动复制相关的知识,希望对你有一定的参考价值。

右值引用(C++11)

传统的左值是一个表示数据的表达式(如变量名或解除引用的指针),程序可获取其地址。左值引用使得标识符关联到左值。

引用,这是使用&&表示的。

引用只能关联右,即可出现在赋值表达式右边。右包括字面常量(C-风格字符串除外,它表示地址)、诸如x+y等表达式及返回的函数(条件是该函数返回的不是引用):

int x = 10;
int y = 23;
int z = 45;
int&& r = z;// Error:Rvalue reference to type 'int' cannot bind to lvalue of type 'int'
int&& r1 = 13;// OK
int&& r2 = x + y;// OK
double&& r3 = sqrt(2.0);// OK

r2关联到的是当时计算x+y得到的结果。也就是说,r2关联到的是33,即使以后修改了x或y,也不会影响到r2。

将右值关联到右值引用会导致该右值被存储到特定的位置,且可以获取该位置的地址。也就是说,虽然不能将运算符&用于13,但可将其用于r1。通过将数据与特定的地址关联,使得可以通过右值引用来访问该数据。

左值右值?

所谓左值即有内存地址的表达式(包括变量),任何类型的变量如int x就是左值。

所谓右值就是没有内存地址处于缓存的值,即编译时未分配内存或使用寄存器存放的值,例如赋值语句x=3中使用的常量3。

通常情况下,判断某个表达式是左值还是右值,最常用的有以下 2 种方法:

1) 可位于赋值号(=)左侧的表达式就是左值;反之,只能位于赋值号右侧的表达式就是右值。

int a = 5;
5 = a; //错误,5 不能为左值

其中,变量 a 就是一个左值,而字面量 5 就是一个右值。值得一提的是,C++ 中的左值也可以当做右值使用,例如:

int b = 10; // b 是一个左值
a = b; // a、b 都是左值,只不过将 b 可以当做右值使用

2) 有名称的、可以获取到存储地址的表达式即为左值;反之则是右值。

以上面定义的变量 a、b 为例,a 和 b 是变量名,且通过 &a 和 &b 可以获得他们的存储地址,因此 a 和 b 都是左值;反之,字面量 5、10,它们既没有名称,也无法获取其存储地址(字面量通常存储在寄存器中,或者和代码存储在一起),因此 5、10 都是右值。

移动语义

引入右值引用的主要目的之一是实现移动语义。

先来看看C++11之前的复制过程。假设有如下代码:

vector<string> vstr;
// build up a vector of 20,000 strings, each of 1000 characters
...
vector<string> vstr_copy1(vstr);// make vstr_copy1 a copy of vstr

vector和string类都使用动态内存分配,因此它们必须定义使用某种new版本的复制构造函数。为初始化对象vstr_copy1,复制构造函数vector<string>将使用new给20000个string对象分配内存,而每个string对象又将调用string的复制构造函数,该构造函数使用new为1000个字符分配内存。接下来,全部20000000个字符都将从vstr控制的内存中复制到vstr_copy1控制的内存中。这里的工作量很大,但只要妥当就行。

以上确实妥当吗?有时答案是否定的。
例如,假设有一个函数,它返回一个vector<string>对象,然后使用它:

vector<string> allcaps(const vector<string>& vs) 
	vector<string> temp;
	// code that stores an all-uppercase version of vs in temp
	return temp;

——————————————————————————————————————————————————
vector<string> vstr;
// build up a vector of 20,000 strings, each of 1000 characters
vector<string> vstr_copy1(vstr);          // #1
vector<string> vstr_copy2(allcaps(vstr)); // #2

allcaps()创建了对象temp,该对象管理着20000000个字符;vector和string的复制构造函数创建这20000000个字符的副本,然后程序删除allcaps()返回的临时对象。这里的要点是,做了大量的无用功。

考虑到临时对象被删除了,如果编译器将对数据的所有权直接转让给vstr_copy2,不是更好吗?也就是说,不将20000000个字符复制到新地方,再删除原来的字符,而将字符留在原来的地方,并将vstr_copy2与之相关联。

这类似于在计算机中移动文件的情形:实际文件还留在原来的地方,而只修改记录。这种方法被称为移动语义(move semantics)
有点悖论的是,移动语义实际上避免了移动原始数据,而只是修改了记录。

要实现移动语义,需要采取某种方式,让编译器知道什么时候需要复制,什么时候不需要。这就是右值引用发挥作用的地方。
可以定义两个构造函数。其中一个是常规复制构造函数,它使用const左值引用作为参数,如语句#1中的vstr;另一个是移动构造函数,它使用右值引用作为参数,该引用关联到右值实参,如语句#2中allcaps(vstr)的返回值。
复制构造函数可执行深复制,而移动构造函数只调整记录。在将所有权转移给新对象的过程中,移动构造函数可能修改其实参,这意味着右值引用参数不应是const。

先看一个移动示例:

#include <iostream>
using namespace std;
class Useless 
private:
	int n;// number of elements
	char* pc;// pointer to data
	static int ct;// number of objects
	void ShowObject() const;
public:
	Useless();
	explicit Useless(int k);
	Useless(int k, char ch);
	Useless(const Useless& f);// regular copy constructor
	Useless(Useless&& f);// move constructor
	~Useless();
	Useless operator+(const Useless& f) const;
	// need operator=() in copy and move versions
	void ShowData() const;
;

int Useless::ct = 0;
Useless::Useless() // 【默认构造函数】
	++ct;
	n = 0;
	pc = nullptr;
	cout << "default constructor called; number of objects: " << ct << endl;
	ShowObject();

Useless::Useless(int k) : n(k) // 【(int)构造函数】
	++ct;
	cout << "(int) constructor called; number of objects: " << ct << endl;
	pc = new char[n];
	ShowObject();

Useless::Useless(int k, char ch) : n(k) // 【(int,char)构造函数】
	++ct;
	cout << "(int, char) constructor called; number of objects: " << ct << endl;
	pc = new char[n];
	for (int i = 0; i < n; i++)
		pc[i] = ch;
	ShowObject();

Useless::Useless(const Useless& f) : n(f.n) // 【复制构造函数】
	++ct;
	cout << "copy constructor called; number of objects: " << ct << endl;
	pc = new char[n];// 深拷贝
	for (int i = 0; i < n; i++)
		pc[i] = f.pc[i];
	ShowObject();

Useless::Useless(Useless&& f) : n(f.n) // 【★移动构造函数★】
	++ct;
	cout << "move constructor called; number of objects: " << ct << endl;
	pc = f.pc;// steal address
	f.pc = nullptr;// give old object nothing in return
	f.n = 0;
	ShowObject();

Useless::~Useless() // 【析构函数】
	cout << "destructor called; objects left: " << --ct << endl;
	cout << "deleted object:\\n";
	ShowObject();
	delete[] pc;

Useless Useless::operator+(const Useless& f) const // 【重载加法运算符】
	cout << "Entering operator+()\\n";
	Useless temp = Useless(n + f.n);
	for (int i = 0; i < n; i++)
		temp.pc[i] = pc[i];
	for (int i = n; i < temp.n; i++)
		temp.pc[i] = f.pc[i - n];
	cout << "temp object:\\n";
	cout << "Leaving operator+()\\n";
	return temp;

void Useless::ShowObject() const // 【打印成员变量:n和pc地址】
	cout << "Number of elements: " << n;
	cout << ", Data address: " << (void*)pc << endl;

void Useless::ShowData() const // 【打印成员变量pc指针所代表的字符串】
	if (n == 0)
		cout << "(object empty)";
	else
		for (int i = 0; i < n; i++)
			cout << pc[i];
	cout << endl;

int main() 
	
		Useless one(10, 'x');
		Useless two = one;// calls copy constructor
		Useless three(20, 'o');
		Useless four(one + three);// calls operator+()、move constructor AND destructor
		cout << "object one: ";
		one.ShowData();
		cout << "object two: ";
		two.ShowData();
		cout << "object three: ";
		three.ShowData();
		cout << "object four: ";
		four.ShowData();
	
    return 0;
(int, char) constructor called; number of objects: 1
Number of elements: 10, Data address: 007CF2E0
copy constructor called; number of objects: 2
Number of elements: 10, Data address: 007CF468
(int, char) constructor called; number of objects: 3
Number of elements: 20, Data address: 007CD960
Entering operator+()
(int) constructor called; number of objects: 4
Number of elements: 30, Data address: 007CBEE8
temp object:
Leaving operator+()
move constructor called; number of objects: 5
Number of elements: 30, Data address: 007CBEE8
destructor called; objects left: 4
deleted object:
Number of elements: 0, Data address: 00000000
object one: xxxxxxxxxx
object two: xxxxxxxxxx
object three: oooooooooooooooooooo
object four: xxxxxxxxxxoooooooooooooooooooo
destructor called; objects left: 3
deleted object:
Number of elements: 30, Data address: 007CBEE8
destructor called; objects left: 2
deleted object:
Number of elements: 20, Data address: 007CD960
destructor called; objects left: 1
deleted object:
Number of elements: 10, Data address: 007CF468
destructor called; objects left: 0
deleted object:
Number of elements: 10, Data address: 007CF2E0

移动构造函数

和其它构造函数不同,此构造函数使用右值引用形式的参数,又称为移动构造函数。并且在此构造函数中,pc指针变量采用的是浅拷贝的复制方式,同时在函数内部重置了f.pc、f.n,有效避免了“同一块对空间被释放多次”情况的发生。

它让pc指向现有的数据,以获取这些数据的所有权。此时,pc和f.pc指向相同的数据,调用析构函数时这将带来麻烦,因为程序不能对同一个地址调用delete [ ]两次。为避免这种问题,该构造函数随后将原来的指针设置为空指针,因为对空指针执行delete [ ]没有问题。这种夺取所有权的方式常被称为窃取(pilfering)。上述代码还将原始对象的元素数设置为零,这并非必不可少的,但让这个示例的输出更一致。注意,由于修改了f对象,这要求不能在参数声明中使用const。

对于Useless four(one + three); 调用了移动构造函数。

表达式one + three调用Useless::operator+(),而右值引用 f 将关联到该函数返回的临时对象。

注意到对象two是对象one的副本:它们显示的数据输出相同,但显示的数据地址不同(007CF2E0和007CF468)。另一方面,在方法Useless::operator+()中创建的对象的数据地址与对象four存储的数据地址相同(都是007CBEE8),其中对象four是由移动复制构造函数创建的。另外,注意到创建对象four后,为临时对象调用了析构函数。之所以知道这是临时对象,是因为其元素数和数据地址都是0。

当没有移动构造函数时,调用复制构造函数;移动构造和复制构造函数都有,生成临时对象时会调用移动构造。

移动赋值运算符

适用于构造函数的移动语义考虑也适用于赋值运算符。
下面演示了给Useless类编写复制赋值运算符和移动赋值运算符:

Useless& Useless::operator=(const Useless& f) // copy assignment
	if (this == &f)
		return *this;
	delete[] pc;
	n = f.n;
	pc = new char[n];
	for (int i = 0; i < n; i++)
		pc[i] = f.pc[i];
	return *this;


Useless& Useless::operator=(Useless&& f) // move assignment
	if (this == &f)
		return *this;
	delete[] pc;
	n = f.n;
	pc = f.pc;
	f.n = 0;
	f.pc = nullptr;
	return *this;

上述移动赋值运算符删除目标对象中的原始数据,并将源对象的所有权转让给目标。
不能让多个指针指向相同的数据,这很重要,因此上述代码将源对象中的指针设置为空指针。
与移动构造函数一样,移动赋值运算符的参数也不能是const引用,因为这个方法修改了源对象。

一般而言,编译器完全可以进行优化,只要结果与未优化时相同。即使你省略该程序中的移动构造函数。
机智的编译器可能自动消除额外的复制工作,但通过使用右值引用,程序员可指出何时该使用移动语义。 

深度理解:

为什么左值引用不需要实现移动构造呢?知道左值和右值的区别,这个问题就容易回答和解决了。所谓左值即有内存地址的表达式(包括变量),任何类型的变量如int x就是左值。所谓右值就是没有内存地址处于缓存的值,即编译时未分配内存或使用寄存器存放的值,例如赋值语句x=3中使用的常量3。注意这样的常量在汇编语言中又叫立即数,例如“MOVE EAX, 3”中的立即数3就是无址的右值,立即数用过即丢弃;而作为常量对象,就意味要进行析构。

对于类A的移动构造函数A(A&& a),形参a是一个右值,即它要求实参是一个常量对象。在定义“A x(A(3));”时,要构造变量对象x,当然先要构造常量对象A(3),构造完x要析构常量对象A(3)。若构造函数分配内存,析构函数释放内存,则合计有两次申请和一次释放。若将常量对象A(3)申请的内存移交给变量对象x,不用x申请了,并且让常量对象A(3)以后不释放内存,岂不是提高了程序的效率吗?反正常量对象A(3)用过就要丢弃的。

但对于构造函数 A(const A& v)就不一样了,形参v是左值要求实参是有址的,即实参通常是一个变量对象。在定义“A y(ā);”时,你不能立即析构 ā 释放其分配的内存,因为变量对象 ā 的生命期还没到死亡时刻,不像常量对象A(3)需要马上死亡,所以不能立即析构 ā,也不能将A(const A& v)实现为移动构造,而只能将其实现为深拷贝构造。 

以上是关于❥关于C++之右值引用&移动语义┇移动构造&移动复制的主要内容,如果未能解决你的问题,请参考以下文章

C++11移动构造函数详解

c++的左值(lvalue),右值(rvalue),移动语义(move),完美转发(forward)

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

cpp►C++11右值引用移动语义移动构造函数移动赋值运算符

cpp►C++11右值引用移动语义移动构造函数移动赋值运算符

C++ 专题 右值引用移动语义与完美转发