0. 什么是对象移动
对象移动解决的问题是一个类含有大量动态内存,在对其进行拷贝时产生的临时内存问题。
如下情况的需求:
class MyIntVector {
private:
int *elements;
int *first_free;
int *cap;
public:
// 默认构造函数
MyIntVector();
// 拷贝构造函数
MyIntVector(const MyIntVector&);
// 拷贝赋值运算符
MyIntVector &operator=(const MyIntVector&);
// 析构函数
~MyIntVector();
void push_back(const int &);
// 其他省略...
};
我们观察到,MyIntVector
中有三个int *
指针,该类的功能与std::vector<int>
一样,因此需要我们动态管理内存。
我们先看拷贝系函数(拷贝构造函数和拷贝赋值运算符),他在如下情况会被调用:
// 声明了一个返回MyIntVector的函数
MyIntVector get_vec(istream &);
// 创建一个实例, 并push一个元素
MyIntVector v1;
v1.push_back(2);
// 这里会调用MyIntVector的
// 拷贝构造函数
MyIntVector v2(v1);
// 调用MyIntVector的
// 拷贝赋值运算符
MyIntVector v3;
v3 = get_vec(cin);
上述代码逻辑上没有任何问题,v1、v2和v3独立管理了三个不相关的内存,对于MyIntVector
的使用,和内置类型的对象int、float等一样。
有“问题”的就是第14行,这里有无用的临时内存产生。
我们可以画图将第14行代码的内存过程表示出来。
当函数返回类实例时,由于C++函数值传递的设计,会产生一个临时的返回值,该返回值会调用拷贝构造函数。当这个临时返回值再赋值给v3时,还会调用一次拷贝赋值运算函数。
我们把一个临时量管理的内存内容完整的拷贝了一遍,临时量在作用域结束后就消失了,此时有2次拷贝过程:函数返回初始化一个临时量、临时量初始化v3。
因为临时量之后我们根本无法访问到,那么有没有办法不做这次拷贝,直接让v3获得临时量中动态内存的管理权限?
答案就是对象移动:把动态内存内容的管理权限进行交接,从外部看,仿佛内存内容从一个对象中移动到了另一个对象中,没有拷贝出一份新的内存内容。
1. 怎样实现对象移动
我们怎样获得临时量的控制权呢?这里就要引入C++11的新技术:右值引用。
关于右值引用的内容,我们要从左值和右值说起。这两部分内容,请参考【深度C++】之“左值与右值”。
总而言之,我们可以使用&&
运算符为某类型定义一个右值引用,引用的是表达式的右值结果。右值引用是一个全新的数据类型,它与之前使用&
定义的左值引用毫无瓜葛。
在读者了解了右值引用之后,我们接着引入C++11的另一项新技术:移动构造函数和移动赋值运算符。
2. 移动构造函数
我们通过使用&&
,可以为类定义移动构造函数:
MyIntVector::MyIntVector(MyIntVector &&src) noexcept
: elements(src.elements),
first_free(src.first_free),
cap(src.cap) {
src.elements = src.first_free = src.cap = nullptr;
}
2.1 返回值
与其他系的构造函数一样,无返回值。
2.2 函数名
与其他系的构造函数一样,与类类型同名。
2.3 参数
- 第一个参数必须是该类类型的非常量右值引用
- 任何除了第一个参数的额外参数都必须有默认实参
2.4 函数功能规范
- 使用成员初始值列表接管
src
中的资源 - 在函数体中,令
src
进入这样的状态:对其运行析构函数是安全的
2.5 noexcept
移动构造函数不应该抛出任何异常。
原因:
- 移动操作“窃取”资源,不分配资源,一般不会抛出异常
- 当自己的类用在容器中(比如在
vector<MyIntVector>
),在某些情况下,只有声明为noexcept
,标准库中的函数才会使用移动构造函数,否则将使用拷贝构造函数替代。
2.6 调用条件
若给类类型定义了移动构造函数,当初始化该类类型的某个对象时传入的是该类类型的一个右值,将会默认使用移动构造函数。例如:
// 声明了一个返回MyIntVector的函数
MyIntVector get_vec(istream &);
// 将使用移动构造函数
MyIntVector v4(std::move(get_vec(cin)));
// 此处注意编译器的问题、理论上不需要使用std::move
// 但编者实际测试下来,必须使用才能调用移动构造函数
// 但显示使用std::move, 下行代码一定会调用移动构造函数
// MyIntVector v4(std::move(get_vec(cin)));
3. 移动赋值运算符
我们通过使用&&
,可以为类定义重载了的移动赋值运算符:
MyIntVector &MyIntVector::operator=(MyIntVector &&rhs) noexcept {
// ① 检测自赋值
if (this != &rhs) {
// ② 调用类的清理函数,释放已有资源
...
// ③ 移动资源到本类
this->elements = rhs.elements;
this->first_free = rhs.first_free;
this->cap = rhs.cap;
// ④ 将rhs置于可析构状态
rhs.elements = rhs.first_free = rhs.cap = nullptr;
}
return *this;
}
3.1 返回值
必须返回该类类型的一个引用,以处理连续赋值
3.2 函数名
- 必须定义为成员函数
- 函数名为
operator=
3.3 参数
只有一个参数作为=
的右侧值,必须是该类类型的非常量右值引用。
3.4 函数功能规范
- 检测自赋值情况
- 清理当前对象中管理的资源
- 从rhs中接管资源
- 令
rhs
进入这样的状态:对其运行析构函数是安全的
3.5 noexcept
重载了的移动赋值运算符不应该抛出任何异常。
3.6 调用条件
若给类类型M定义了重载了的移动赋值运算符,当=
左侧是M的对象、右侧是M的右值时,就会自动调用移动赋值运算。
// 声明了一个返回MyIntVector的函数
MyIntVector get_vec(istream &);
// 将使用移动赋值运算
MyIntVector v5;
v5 = std::move(get_vec(cin));
4. 合成的移动系函数
与拷贝构造函数和拷贝赋值运算符一样,编译器也会合成移动构造函数和移动赋值运算符。
合成移动系函数的条件:
- 没有定义任何拷贝系函数
- 没有定义析构函数
- 他的所有数据成员都能移动构造或移动赋值
可以看到,合成移动系函数的条件十分苛刻。我们通常都会定义析构函数,所以要注意此时是没有移动系函数的。
5. 拷贝 vs 移动
当一个类既有拷贝系函数,又有移动系函数,则编译器在判断函数调用时,会遵循“拷贝左值,移动右值”的原则,即按照一般的函数匹配原则,严格区分左值和右值。
例如,假设之前声明的get_vec
函数返回的是MyIntVector
的左值引用:
// 声明了一个返回MyIntVector的左值引用的函数
MyIntVector &get_vec(istream &, MyIntVector &);
// 将使用拷贝构造函数
MyIntVector v6 = get_vec(cin);
当一个类没有移动系函数,编译器会调用拷贝系函数完成构造或赋值。
当一个类既没有拷贝系函数,也没有移动系函数,那么编译器会为类自动合成拷贝系函数,所以不用担心拷贝初始化和赋值情况会编译不通过。是否合成移动系函数,要看符不符合第4节提到的条件。
6. 小心编译器
前述都C++标准理论上的内容,经编者测试,某些平台对返回类类型进行了处理,想要使用移动构造函数,必须显示使用std::move
才可以,请读者注意。
7. 总结
当某类类型有动态管理内存的需求,在拷贝和赋值该类类型对象时,某些情况下,拷贝源在使用后就被销毁了。
当出现上述情况,使用对象移动操作可以避免二次开销。
通过使用移动构造函数和移动赋值运算符进行对象移动。两个函数必须遵守设计规范,并且保证不抛出异常noexcept
。
编译器也会为类合成移动系函数,但是条件比较苛刻。
拷贝系函数是移动系函数的保底操作,在移动系函数不充足的情况下,会调用拷贝系函数。