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三步:
- 释放当前对象已有资源;
- 接管源对象的资源;
- 置源对象为可析构状态;
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限定符之后;