blog_how_to_use_move
- tags
- cpp_move_semantics
什么叫语言级别的资源转移
- cpp03_no_res_transferring 这个博文谈到 c++03 缺少了资源转移的语言支持,那理想中的应该怎么支持移动语义?下面几个是典型的场景:
- 用旧变量构造、赋值新变量是最可能需要资源转移的。
- 所以移动语义应该和拷贝构造类似,都能由编译器默认生成、用户明确定义、编译器可以在合适的时候自动调用。
- 引入移动构造、赋值后,标准库选择移动、拷贝语义时,要遵守之前定下的异常规则(如vector拷贝不能抛异常): 如果没有异常,要在函数声明后加上 noexcept 关键字,如果不加,只能回退到拷贝。
- 需要有一个类型,右值引用(&&)参数,明确指出这个变量即将会进行资源转移。
- 需要一个函数(std::move)将变量转化为右值引用类型,再传递到合适移动操作的函数转移资源。
- 用旧变量构造、赋值新变量是最可能需要资源转移的。
语法定义 — 移动构造、移动赋值的声明形式
- 要实现移动语义,我们需要增加2个成员函数
- 移动构造函数
- 移动赋值函数, 和拷贝赋值类似,需要注意 自赋值 的情况
class Datas {
public:
...
// 移动构造:直接"偷"走对方的资源
Datas(Datas&& o) noexcept // 注意:参数是右值引用 T&&,不是 const T&
: data(o.data)
, size(o.size)
{
o.data = nullptr; // 把源对象掏空
o.size = 0;
}
// 移动赋值
Datas& operator=(Datas&& o) noexcept {
if (this != &o) {
delete[] data; // 1. 先释放自己的旧资源
data = o.data; // 2. 偷走对方的资源
size = o.size;
o.data = nullptr; // 3. 把对方掏空
o.size = 0;
}
return *this;
}
private:
int* data;
size_t size;
}
- 移动拷贝
Datas myData(10000000);
Datas submitData(std::move(myData)); // 调用移动构造
- 移动赋值
Datas submitData(0);
submitData = std::move(myData); // 调用移动赋值
// myData 现在是空壳,submitData 持有 1000 万条记录
- 如果你不写移动构造/移动赋值,编译器会不会帮你生成?答案是: 有条件 。
六个特殊成员函数
C++11 有六个编译器可能自动生成的函数:
- 默认构造函数
- 析构函数
- 拷贝构造函数
- 拷贝赋值运算符
- 移动构造函数 (C++11 新增)
- 移动赋值运算符 (C++11 新增)
生成规则
移动操作只在 三个条件同时满足 时自动生成:
- 没有用户声明的拷贝操作
- 没有用户声明的移动操作
- 没有用户声明的析构函数
换句话说: 一旦你碰了拷贝或析构,移动就不会自动生成 。
class Widget {
public:
~Widget() { /* ... */ } // 声明了析构函数
// → 移动构造、移动赋值都不会自动生成
// → 所有"移动"操作退化为拷贝
};
这个设计是有意为之的——如果你需要自定义析构,说明你在管理某种资源,默认的逐成员移动大概率是错的。编译器选择"不生成"而不是"生成一个可能出错的版本"。
Rule of Five
由此引出一条实践法则:
> 拷贝构造、拷贝赋值、移动构造、移动赋值、析构函数—— 要不一个都不定义,要不五个全定义 。
“全都不定义"适用于成员都是值类型(`int`、`std::string`、`std::vector`)的类——编译器自动生成的版本就是正确的。
“全定义"适用于管理资源的类——你写了析构释放资源,就必须写移动来转移资源,写拷贝来复制资源(或 `= delete` 禁止拷贝)。
移动操作的耦合关系
两个移动操作是耦合的:
class Widget {
public:
Widget(Widget&&) { /* ... */ } // 声明了移动构造
// → 移动赋值不会自动生成!
};
声明一个移动操作,编译器就不会生成另一个。这是合理的——如果你需要自定义移动构造,移动赋值的默认行为大概率也不对。
声明移动后,拷贝被删除
class Widget {
public:
Widget(Widget&&) { /* ... */ } // 声明了移动构造
// → 拷贝构造被隐式删除
};
这也是合理的——能移动的对象通常不应该被意外拷贝(想想 `unique_ptr`)。
右值参数与move
- 有了移动语义,当我们需要传递一个即将被转移资源的变量时,我们可以使用右值引用 &&
- 所以,转移资源的接口我们可以定义为 Datas&& d, 这样我们看接口就知道d这个变量的资源在函数内部会被转移
- 在转移d的状态前,我们可能需要使用d,所以在函数内部d还是一个正常的变量(准确来说是左值变量, 也就是可以取地址)
- 所以如果直接用d去构造,调用的是拷贝构造,因为我们需要允许在资源d转移之前拷贝d的
- 我们真正转移状态是靠移动赋值、移动拷贝,而那2个函数都要求参数类型是右值引用,所以我们使用std::move(d)将d再转为右值再传参给移动操作运算符,完成资源的转移
- 所以实际上 std::move() 并不转移资源(实际上是只类型转化static_cast<Datas&&>),只是再次提醒编译器,我们这时准备要转移资源了,请调用相应的移动操作函数
void archiveToPolice(Datas&& d) {
std::cout << "存储部门归档 " << d.size << " 条记录\n";
Datas copiedData(d); //!!!注意这时是调用的拷贝构造
Datas innerData(std::move(d)); //这才调用移动构造
// innerData 接管了 d 的资源,离开作用域时由其析构
...
}
总结
- c++11的资源转移机制核心就是移动构造、移动赋值
- 移动操作自动生成或删除有比较复杂的机制,我推荐用暴力的准则
- 要么全部不定义
- 要不全部定义
- 如果不抛出异常,移动操作要加上noexcept修饰
- 如果接口需要资源转移,我们要将类型定义为右值引用类型(&&)
- 在函数内部,资源转移前,这个右值引用类型的变量是左值,直接调用拷贝、赋值是调用的拷贝赋值;只有通过std::move转化为右值时,才能调用相应的移动操作