C++ Primer学习笔记 - 对象移动move

背景

C++ 11 新特性对象移动,可以移动对象而非拷贝。在某些情况下,对象拷贝后就立刻被销毁了,比如值传递参数,对象以值传递方式返回,临时对象构造另一个对象。在这些情况下,如果使用移动对象而非拷贝对象能大幅提升性能。

string s1(string("hello")); // 无名对象string("hello") 就是一个会在拷贝构造s1后,立即销毁临时对象

[======]

右值引用

提到对象移动,就不得不提到2个元素:右值引用和std::move库函数。

右值引用(rvalue reference)就是必须绑定到右值的引用,主要包括无名对象、表达式、字面量。通过 && 来获得右值的引用。
简单理解,右值引用就是临时对象的引用,但临时对象并不一定是右值,而是要立刻销毁的临时对象才是右值对象。比如函数内定义的有名对象是临时对象,并不是立刻销毁。

右值引用特性

1)右值引用只能绑定到一个将要销毁的对象。可以自由地将一个右值引用的资源“移动”到另一个对象上;
2)类似于左值引用,右值引用也是一个对象的别名;

右值引用和左值引用的区别

左值引用(lvalue reference)是我们熟悉的常规引用,为了区分右值引用而提出。特点是不能将左值引用绑定到1)要求转换的表达式;2)字面常量;3)返回右值的表达式;

例如,

int a = 2;
int &i = a * 2; // 错误:临时计算结果a * 2 是右值,不能绑定到左值引用
const int& ii = a * 2; // 正确:可以将一个const引用绑定到一个右值上
int &&r = a * 2; // 正确:std::move将左值a转换成了右值,能绑定到右值引用

int &i1 = 42; // 错误:42是字面常量,不能绑定到左值引用
int &&r1 = 42; // 正确:42是字面常量,能绑定到右值引用

int &i2 = std::move(a); // 错误:std::move将左值a转换成了右值,不能绑定到左值引用
int &&r2 = std::move(a); // 正确:std::move将左值a转换成了右值,能绑定到右值引用

注意:可以将一个const引用(不论const &,还是const &&)绑定到一个右值上

左值持久,右值短暂

左值和右值最明显的区别是:左值有持久的状态,不会立即销毁;右值要么是字面常量,要么是表达式求值过程中创建的临时对象。

因此,可以知道右值引用:
1) 所引用的对象将要被销毁;
2)该对象没有其他用户;

详见之前写的这篇文章C++ > 右值引用和左值引用的区别

变量是左值

变量是左值,不能将一个右值引用直接绑定到一个变量上,即使变量是右值引用类型。

int a = 42;
int &&rr1 = 42; // 正确:字面常量是右值
int &&rr2 = a;  // 错误:变量a是左值
int &&rr3 = rr1; // 错误:右值引用rr1是左值

std::move函数

头文件
不能将一个右值引用绑定到一个左值上,但可以通过调用std::move函数,将左值转换为对应的右值引用类型。

int &&rr1 = 42;
int &&rr4 = rr1; // 错误:不能将右值引用绑定到另一个右值引用
int &&rr5 = std::move(rr1); // OK

move函数告诉编译器:我们有一个左值,但希望像一个右值一样处理它。调用move意味着承诺:除对rr1赋值或销毁它之外,不能再使用它。在调用move之后,就不能对移动后源对象的值做任何假设。

int *p = new int(42);
int &&r = std::move(*p);
cout << r << endl;
r = 1;
*p = 3; // 编译器不报错,也不会阻止修改源对象值,但不建议这么做
cout << r << endl;
cout << *p << endl;

注意:与大多数标准库名字的使用不同,对move不提供using上面,建议是直接调用std::move而非move。由于move名字常见,应用程序经常定义该函数,为了避免与应用程序定义的move函数冲突,请使用std::move。
[======]

移动构造函数和移动赋值运算符

移动构造函数(又称move constructor)和移动赋值运算符(又称move assignment运算符),类似于copy函数(copy构造函数,copy assignment运算符),不过前2个函数是从给定对象“窃取”资源,而非拷贝资源。

除了完成资源移动,move constructor还必须确保移动后源对象处于这样的状态:销毁源对象是无害的。
一旦资源移动完成后,资源不再属于源对象而是属于新创建的对象,源对象必须不再指向被移动的资源。

例,为StrVec类定义move constructor,实现从一个StrVec到另一个StrVec的元素move而非copy:

class StrVec
{
public:
	StrVec(const StrVec &s); // copy constructor
	StrVec(StrVec &&s) noexcept; // move constructor
	...
private:
	string *elements;
	string *first_free;
	string *cap;
};

StrVec::StrVec(StrVec &&s) noexcept // move操作不应抛出任何异常
// 成员初始化器接管s中的资源
	: elements(s.elements), first_free(s.first_free), cap(s.cap)
{
	// 令s进入这一的状态 -- 对齐运行析构函数是安全的
	s.elements = s.first_free = s.cap = nullptr;
}

move构造函数中,新创建对象成员初始化器接管了源对象中的资源,并将源对象指向资源的指针都置空,就完成了资源的移动操作。源对象析构时,资源并不会被释放,因此新对象使用资源是安全的。
noexcept 表明该函数不抛出任何异常。

移动操作、标准库容器和异常

因为移动操作“窃取”资源,通常不分配任何资源。因此移动操作通常不会抛出异常。既然如此,为什么需要指明noexcept呢?
这是因为,除非编译器知道我们的move构造函数不会抛出异常,否则会认为移动我们的类对象可能抛出异常,并且为了处理这种可能而做一些额外工作。因此,如果确认不会抛出异常,就用noexcept显式指出。

TIPS:
不抛出异常的move构造函数和move assignment运算符必须标记为noexcept。

移动操作通常不抛出异常,但不代表不能抛出异常,而且标准库容器能对异常发生时自身的行为提供保障。比如,vector保证,调入push_back发生异常(如内存不够),vector自身不会改变。

为了避免这种潜在问题,除非vector知道元素类型的move构造函数不会抛出异常,否则,在重新分配内存的过程中,必须用copy构造函数而非move构造函数。
如果希望在vector重新分配内存这类情况下,对我们自定义类型的对象进行move而非copy,就必须显式告诉标准库我们的移动构造函数可以安全使用。

简而言之:move构造函数如果可能抛出异常,就使用copy构造函数构造对象。如果move构造函数不抛出异常,就用noexcept显式声明。

移动赋值运算符(move assignment)

move assignment执行与析构函数和move构造函数相同的工作。如果我们的move assignment运算符不抛出任何异常,就应该标记为noexcept。
定义move assignment三步:

  1. 释放当前对象已有资源;
  2. 接管源对象的资源;
  3. 置源对象为可析构状态;
class StrVec
{
public:
	...
	StrVec& operator=(StrVec &&rhs) noexcept; // move assignment
	...
private:
	string *elements;
	string *first_free;
	string *cap;
};

StrVec& StrVec::operator=(StrVec &&rhs) noexcept
{
	if (this != &rhs) {// 避免自移动,因为move返回结果可能是对象自身
		// 释放this对象已有元素, 相当于调用this->~StrVec
		free(); 
		// 从rhs接管资源
		elements = rhs.elements;
		first_free = rhs.first_free;
		cap = rhs.cap;

		// 将rhs置于可析构状态
		elements = first_free = cap = nullptr;
	}
	return *this;
}

移动后源对象必须可析构

资源从一个对象移动到另一个对象并不会销毁源对象,但有时在移动操作完成后,源对象会被销毁。因此,在编写移动操作时,必须确保移动后源对象进入可析构状态。否则,析构源对象可能导致资源释放,或者修改资源状态,导致接管对象出现异常。

移动资源完成后,程序不应该在依赖于源对象中的数据。虽然可能还能访问源对象中的数据,但结果是不确定的。

TIPS:移动之后,移后源对象必须保持有效的、可析构的状态,但是用户不能对其进行任何假设。什么时候可析构?取决于用户,通常可立即析构。

合成的move操作

如果我们不声明自己的copy函数,当需要的时候,编译器会为我们合成默认的版本。类似地,编译器也会为我们合成move函数(move constructor和move assignment运算符,即move操作)
copy操作可以被定义成三种情况:
1)bit-wise copy成员;
2)为对象赋值;
3)删除的函数;

什么时候编译器不合成move操作?
不同于copy函数,编译器不会为某些类合成move操作,特别一个类如果定义了3种成员函数:
1)copy构造函数;
2)copy assignment运算符;
3)析构函数;

什么时候编译器会合成move操作?
只有当一个类没有定义任何自己版本的copy控制成员(即copy函数),且类的每个非static数据成员都可以移动时(通常是内置类型、支持move操作的对象),编译器才会为它合成move操作。

当类没有move操作时,会发生什么?
当一个类没有move操作时,正常的函数匹配,类会使用copy操作来替代。

合成move操作示例:

struct X
{
	int i; // 内置类型
	string s; // string定义了自己的move操作
};
struct hasX
{
	X mem; // X有合成的move操作
};

int main()
{
	X x, x2 = std::move(x); // 使用合成的move构造函数
	hasX hx, hx2 = std::move(hx); // 使用合成的move构造函数
	return 0;
}

思考:当我们既没有定义copy函数,也没有定义move函数时,编译器何时使用合成的copy操作,何时使用合成的move操作?
我的理解:看函数匹配,如果用于构造或赋值的实参是左值,就用合成的copy操作;如果是右值,就用合成的move操作。

定义default、delete move操作
与copy操作不同,move操作不会隐式定义为delete。相反,如果我们用=default显式要求编译出合成move操作,但编译器不能移动所有成员,则编译器会将move操作定义为delete。

那么,什么时候编译器会将move操作定义为delete呢?
其原则是:

  • 与copy构造函数不同,move构造函数被定义为delete的条件是:存在数据成员有copy constructor没有move constructor,或者没有copy constructor但无法合成move constructor。move assignment情况类似。

  • 如果有数据成员的move操作被定义为delete或者不可访问的,那么类的move操作被定义为delete。

  • 类似copy构造函数,如果类的析构函数被定义为delete或不可访问的,则类的move构造函数被定义为delete。

  • 类似copy assignment运算符,如果有类数据成员是const的或引用,则类的move assignment被定义为删除的。

例如,假设Y是一个class,定义了copy构造函数,但没有定义move构造函数:

class Y {
public:
	Y() = default;
	Y(const Y&) { cout << "Y copy constructor invoked" << endl; }
	Y& operator=(const Y&) { cout << "Y copy assignment invoked" << endl; return *this; }
};

// 由于数据成员Y没有move函数(move构造函数和move assignment运算符),编译器不会为hasY合成move函数,相反合成copy函数
struct hasY {
	hasY() = default;
	hasY(hasY &&) = default; // 编译器并不会合成move构造函数
	hasY& operator=(hasY&&) = default; // 编译器不会合成move assignment运算符

	Y mem; // 将有一个delete的move constructor,move assignment运算符
};

int main()
{
	hasY hy, hy2 = std::move(hy); // 实际上并不会调用hasY的move constructor, 而是调用copy constructor
	hasY h3, h4;
	h4 = std::move(h3); // 实际上调用copy assignment运算符
	return 0;
}

运行结果:
可以看到,即使指示move函数为default,但实际上并没有调用move函数,而是调用的copy函数。

Y copy constructor invoked
Y copy assignment invoked

移动操作和合成的copy控制成员间还有相互作用:如果一个类定义了move函数,则该类合成对应的copy函数会被定义为delete。

move右值,copy左值

如果一个类既有move函数,也有copy函数,编译器使用普通的函数匹配规则确定使用哪个函数:左值使用copy函数,右值使用move函数。

StrVec v1, v2; // v1, v2是左值
v1 = v2; // v2是左值,使用copy assignment运算符
StrVec getVec(istream &); // getVec返回右值(即将销毁的临时对象)
v2 = getVec(cin); // getVec返回右值,使用move assignment运算符

如果没有move函数,就使用相应copy函数,即使是右值

当只有copy函数(包括合成的),没有move函数(包括编译器没有合成,用户设置函数为delete)时,即使是右值,也使用copy函数,而不是使用move函数(因为没有)。

class Foo {
public:
	Foo() = default; // default constructor
	Foo(const Foo&); // copy constructor
	... // 其他函数,但没有move constructor
};

Foo x; // 使用default constructor构建对象x
Foo y(x); // 使用copy constructor构建对象y
Foo z(std::move(x)); // 使用copy constructor,不使用move constructor,因为没有move constructor

std::move(x)返回的是一个绑定到x的Foo&&(右值),由于没有move构造函数,只有copy构造函数,因此即使构建对象z的时候,使用了右值,但实际上会把Foo&&转换为const Foo&,从而调用copy构造函数构造z。

拷贝并交换赋值运算符和move操作

std::swap可以实现资源的移动。

比如,我们定义class HashPtr:

// 定义default构造函数,move构造函数,不定义copy构造函数和move assignment运算符
// 可以推断编译器不会合成copy构造函数和copy assignment运算符( 隐式delete)
class HashPtr {
public:
	HashPtr() : ps(nullptr), i(0) { } // default构造函数
	HashPtr(HashPtr &&p) noexcept : ps(p.ps), i(p.i) { p.ps = 0; } // move构造函数,由于合成了move构造函数,所以不会合成copy操作
	// assignment(operator=) 既是move assignment运算符,也是copy assignment运算符
	// 注意与HashPtr& operator=(const HashPtr &rhs) {}和HashPtr& operator=(const HashPtr &&rhs) {}的区别
	HashPtr& operator=(HashPtr rhs) { swap(*this, rhs); return *this; } // assignment运算符
	// ... 
private:
	string *ps;
	int i;
};

operator=的参数是HashPtr rhs,意味着传参时,要进行一次copy构造,然而我们已经定义了move构造,编译器不会为我们合成copy构造,也就是说copy构造是隐式delete。

假定hp,hp2都是HashPtr对象:

HashPtr hp;
HashPtr hp2 = hp; // 错误:因为已经定义了move构造函数,编译器不会合成copy构造函数(delete),而hp是一个右值,无法调用move构造函数来构造hp2
HashPtr hp3 = std::move(hp); // OK:std::move将hp转换为右值,调用move构造函数构造hp3
HashPtr hp4, hp5;
hp4 = hp; // 错误:因为hp是左值,copy运算符会用到copy构造函数构造形参rhs,然而copy构造函数是隐式delete
hp5 = std::move(hp); // OK::std::move将hp转换为右值,调用move构造函数构造operator=形参rhs

建议:更新三/五法则

5个拷贝控制成员:
1)1个析构函数;
2)2个copy函数:copy构造函数,copy assignment运算符;
3)2个move函数:move构造函数,moveassignment运算符;

应该看作一个整体,一个类如果定义了任何一个拷贝操作,就应该定义所有5个操作。

  • 如果一个class自定义copy构造函数,那么它很可能需要定义copy assignment (同样适用于move函数);
  • 如果一个class自定义析构函数,那么它很可能需要定义copy函数,因为有自定义对象成员需要自定义copy函数来实现拷贝;
  • 如果一个class自定义析构函数,那么它很可能需要定义move函数,来减少copy资源带来的不必要开销;
  • 如果一个class定义了指针类型数据成员,那么它很可能需要定义析构函数,来释放动态申请的资源;

移动迭代器 move iterator

一般地,一个迭代器解引用(如,*it,it是某个迭代器)返回一个指向元素的左值,然而,move迭代器返回一个指向元素的右值引用。

标准库函数make_move_iterator可以将一个普通迭代器转换为一个move迭代器。
例,不使用move迭代器时,如果要扩张StrVec(自定义动态string数组)的尺寸,可以这样做:

void StrVec::reallocate()
{
	// 分配当前规模大小的2倍空间
	auto newcapacity = size() ? 2 * size() : 1;
	// 分配raw memory
	auto newdata = alloc.allocate(newcapacity);
	// 将数据从旧内存移动到新内存
 	auto dest = newdata; // 指向新数组空闲位置
	auto elem = elements; // 指向旧数组下一个元素

	// 在allocator分配的内存上,逐次调用class string的move构造函数
	for (size_t i = 0; i != size(); i++) {
		alloc.construct(dest++, std::move(*elem++));
	}

	free(); // 移动完成,释放旧内存
	// 更新指针
 	elements = newdata;
	first_free = dest;
	cap = elements + newcapacity;
}

使用move迭代器:

void StrVec::reallocate()
{
	// 分配当前大小2倍内存空间
	auto newcapacity = size() ? 2 * size() : 1;
	auto first = alloc.allocate(newcapacity);
	// 移动元素
	auto last = uninitialized_copy(make_move_iterator(begin()), make_move_iterator(end()), first);

	free(); // 释放旧空间
	// 更新指针
	elements = first; 
	first_free = last;
	cap = elements + newcapacity;
}

class StrVec完整源代码

点击查看代码
class StrVec {
public:
	StrVec() :  // allocator成员进行默认初始化
		elements(nullptr), first_free(nullptr), cap(nullptr) { }
	StrVec(const StrVec&);
	StrVec& operator=(const StrVec&);
	~StrVec();
	void push_back(const string&);

	size_t size() const { return first_free - elements; }
	size_t capacity() const { return cap - elements; }
	string *begin() const { return elements; }
	string *end() const { return first_free; }

private:
	static allocator<string> alloc;
	void chk_n_alloc() { if (size() == capacity()) reallocate(); }
	pair<string*, string*> alloc_n_copy(const string*, const string*);
	void free();
	void reallocate();

	string *elements; // 指向数组首元素的指针
	string *first_free; // 指向数组第一个空闲元素的指针
	string *cap; // 指向数组尾后位置的指针
};

allocator<string> StrVec::alloc;

StrVec::StrVec(const StrVec& s)
{
	auto newdata = alloc_n_copy(s.begin(), s.end());
	elements = newdata.first;
	first_free = cap = newdata.second;
}

StrVec& StrVec::operator=(const StrVec& rhs)
{
	auto data = alloc_n_copy(rhs.begin(), rhs.end());
	free();
	elements = data.first;
	first_free = cap = data.second;
	return *this;
}

StrVec::~StrVec()
{
	free();
}

void StrVec::push_back(const string& s)
{
	chk_n_alloc();
	alloc.construct(first_free++, s);
}

pair<string*, string*> StrVec::alloc_n_copy(const string* b, const string* e)
{
	auto data = alloc.allocate(e - b);
	return { data, uninitialized_copy(b, e, data) };
}

void StrVec::free()
{
	if (elements)
	{
		for (auto p = first_free; p != elements; )
		{
			alloc.destroy(--p);
		}
		alloc.deallocate(elements, cap - elements);
	}
}

void StrVec::reallocate()
{
	// 分配当前大小2倍内存空间
	auto newcapacity = size() ? 2 * size() : 1;
	auto first = alloc.allocate(newcapacity);
	// 移动元素
	auto last = uninitialized_copy(make_move_iterator(begin()), make_move_iterator(end()), first);

	free(); // 释放旧空间
	// 更新指针
	elements = first; 
	first_free = last;
	cap = elements + newcapacity;
}

// 客户端测试代码
int main()
{
	StrVec s;
	stringstream stream;
	string str;

	for (int i = 0; i < 50; i++) {
		stream << i + 1;
		stream >> str;
		s.push_back(str);
	}
	cout << s.size() << endl;

	StrVec s2;
	s2 = s;
	cout << s2.size() << endl;
	return 0;
}

注意:建议不要随意使用move操作。
1)标准库不保证哪些算法适用move迭代器,哪些不适用。
2)移后源对象具有不确定的状态,可能销毁源对象,也可能不销毁,对其调用std::move很危险的。

因此,如果要使用move操作,必须确保移后源对象没有其他用户,必须确信需要进行move操作是安全的。这并非C++语法要求,而是使用move操作应该遵循的规范。

右值引用和成员函数

除了构造函数和assignment运算符,右值引用也能应用于成员函数,提供成员函数的move版本。
成员函数允许同时提供两个版本重载函数:copy版本,move版本。copy版本接受一个指向const的左值引用,move版本接受一个指向非const的右值引用。
例如,定义了push_back的标准库容器vector,提供了这样2个版本。

void push_back(const X&); // 拷贝:绑定到任意类型的X
void push_back(X&&); // 移动:只能绑定到类型X的可修改的右值

我们可以在上一节StrVec基础上,为其添加2个版本push_back:

class StrVec
{
public:
	void push_back(const string& s); // copy元素
	void push_back(string &&s); // move元素
	...
};

// copy版本
void StrVec::push_back(const string& s)
{
	chk_n_alloc();  // 如果需要的话为StrVec重新分配内存
	alloc.construct(first_free++, s);
}

// move版本
void StrVec::push_back(string&& s)
{
	chk_n_alloc(); // 如果需要的话为StrVec重新分配内存
	alloc.construct(first_free++, std::move(s));
}

// 客户端
// 实参类型决定了新元素是copy还是move
string vec;
string s = "hello";
vec.push_back(s); // 调用push_back(const string&)
vec.push_back("test"); // 调用push_back(string&&)

右值和左值引用成员函数

通常在一个对象上调用成员函数,并不关心该对象是左值还是右值。
例如,我们在一个string右值(s1+s2)上调用find成员

string s1 = "this is a value", s2 = "another";
auto n = (s1 + s2).find('a');
cout << n << endl; // 打印8

旧标准无法阻止这种使用方式,为了维持向后兼容性,新标准库类仍然允许这种向右值赋值。但如果我们想希望在自己的class中,强制左侧运算对象(即this指向的对象)是一个左值,阻止向右值赋值,该怎么办?
答:可以在参数列表后放置一个引用限定符(reference qualifier),指出this的左值/右值属性。

规则:
加了引用限定符 & 的成员函数,只能被左值对象调用;
加了引用限定符 && 的成员函数,只能被右值对象调用;

//------ 限定向可修改的左值赋值 --------------
class Foo
{
public:
	Foo& operator=(const Foo&) &; // 指出this可以指向一个左值
};

Foo& operator=(const Foo& rhs) &
{
	// 将rhs赋予本对象
	...

	return *this;
}

//------ 限定向可修改的右值赋值 --------------
class Foo
{
public:
	Foo& operator=(Foo&) &&;  // 指出this可以指向一个右值
};

Foo& operator=(const Foo& rhs) &&
{
	// 将rhs移动给本对象
	...

	return *this;
}

注意:
1)类似于const限定符,引用限定符只能用于非static成员函数;
2)位置类同const限定符,但如果也有const限定符时,引用限定符只能放在const限定符之后;

posted @ 2021-12-05 22:48  明明1109  阅读(1123)  评论(0编辑  收藏  举报