在 C++ 中,只要一个变量有了名字,通过变量名去使用它时就属于左值。因此,当我们使用一个已有变量初始化新对象或放入容器时,编译器默认认为后面还会用到它,总是会执行拷贝操作。
但在实际开发中,有时我们非常确定某个变量在这一步之后就不再需要了。如果它内部管理着一大块字符串或动态数组内存,怎么告诉编译器 “不用复制了,直接把它的内存转走吧”?这时就需要用到 C++11 标准提供的 std::move()。
std::move 的基本用法
在 C++ 中,我们可以使用标准库 <utility> 头文件中的 std::move() 函数,把有名字的左值对象转换为右值引用,让接收方直接接管它内部的内存资源,省去重新分配内存和复制数据的开销。
语法:
#include <utility>
std::move(obj)说明:
其中,obj 是准备转出资源的左值对象。平时使用 std::move() 时,小伙伴们记得显式引入 <utility> 头文件,不要依赖 <string> 等其他头文件间接带入,以免更换编译环境时报错。
形象来说,普通的拷贝初始化就像把一份文件重新复印一份交给对方,原件保持不变。而调用 std::move(obj) 则相当于直接把 obj 手里装数据的 “文件袋” 转交给对方,省去了重新复印的过程。下面我们以 std::string 为例来看一下实际效果。
示例 1:转移字符串资源
#include <iostream>
#include <string>
#include <utility>
int main() {
std::string source = "欢迎来到绿叶网!";
// 将 source 的字符串资源转移给 target
std::string target = std::move(source);
std::cout << "移动后 target:[" << target << "]\n";
std::cout << "移动后 source:[" << source << "]\n";
// 给移走资源后的 source 重新赋新值
source = "新的文本内容";
std::cout << "重新赋值后 source:[" << source << "]\n";
return 0;
}运行结果如下。
移动后 target:[欢迎来到绿叶网!]
移动后 source:[]
重新赋值后 source:[新的文本内容]分析:
std::string target = std::move(source); 这句代码表示让 target 直接接管 source 原本持有的字符串内存。因此移动完成后,target 拿到了 "欢迎来到绿叶网!",原变量 source 内部则变为了空字符串 ""。
如果这里不加 std::move()、直接写成 std::string target = source;,就会把字符串完整复制一份,source 保持不变。同时可以看到,移走资源后的 source 依然是合法对象,不仅能正常销毁,也可以像代码末尾那样重新赋上新值继续使用。
注意 对象被移走资源后处于 “有效但具体值不确定” 的状态,可以正常析构或重新赋新值。虽然大多数编译器会把移动后的 std::string 变为空串 "",但在重新赋新值前,千万不要再去读取或依赖它的旧值。
除了用来初始化新对象之外,当我们向 std::vector 容器中添加后续不再需要的局部大对象时,配合 std::move() 使用也是很常见的写法。请看下面例子。
示例 2:向 vector 移动元素
#include <iostream>
#include <string>
#include <utility>
#include <vector>
int main() {
std::vector<std::string> courses;
std::string item1 = "C++ 教程";
std::string item2 = "Python 教程";
courses.push_back(item1); // 拷贝放入容器
courses.push_back(std::move(item2)); // 移动放入容器
std::cout << courses[0] << "、" << courses[1] << "\n";
std::cout << "拷贝后 item1:[" << item1 << "]\n";
std::cout << "移动后 item2:[" << item2 << "]\n";
return 0;
}运行结果如下。
C++ 教程、Python 教程
拷贝后 item1:[C++ 教程]
移动后 item2:[]分析:
在这个例子中,courses.push_back(item1); 传入的是左值,容器会把 item1 完整复制一份存进去,因此 item1 依然保留原来的内容。
而当局部字符串 item2 只是临时组装好数据准备放进容器、后续不再需要使用时,写出 courses.push_back(std::move(item2)); 可以让容器直接接管 item2 的内部内存,省去重新复制字符串的开销。
std::move 的工作原理
体验完 std::move() 的效果后,有一点底层机制需要小伙伴们搞清楚:不要被 move(移动)这个名字误导了,以为调用 std::move(obj) 的那一刻,数据就已经被它搬走了。
实际上,std::move() 内部一行搬移内存的代码都没有写。它的本质只是一个类型转换函数,唯一做的事情就是把传入的 “左值” 强制转换成 “右值引用”。
换句话说,调用 std::move(obj) 只是在对象 obj 身上贴了一张 “允许搬走” 的标签。至于它里面的资源最终有没有被搬走,完全取决于后面是谁接收了这个结果:
- 接收方支持移动操作:如示例 1 中的
std::string target = std::move(source);,接收方是std::string的移动构造函数。它看到右值引用后,就会真正动手把source内部的内存接管过来。 - 接收方未执行资源转移:如果接收方只是一个不涉及资源接管的普通函数,那么就算贴了
std::move()标签,原对象里的数据也不会发生任何变化。
下面我们定义两个只负责打印字符串、不转移资源的重载函数 inspect(),验证一下调用 inspect(std::move(msg)) 之后,原变量 msg 的值还在不在。
示例 3:未接管资源时的表现
#include <iostream>
#include <string>
#include <utility>
// 重载版本 1:接收左值引用
void inspect(const std::string& value) {
std::cout << "匹配左值引用版本:" << value << "\n";
}
// 重载版本 2:接收右值引用
void inspect(std::string&& value) {
std::cout << "匹配右值引用版本:" << value << "\n";
}
int main() {
std::string msg = "Hello, C++";
inspect(msg);
std::cout << "第一次调用后 msg:" << msg << "\n";
inspect(std::move(msg));
std::cout << "第二次调用后 msg:" << msg << "\n";
return 0;
}运行结果如下。
匹配左值引用版本:Hello, C++
第一次调用后 msg:Hello, C++
匹配右值引用版本:Hello, C++
第二次调用后 msg:Hello, C++分析:
第二次调用 inspect(std::move(msg)) 时,虽然 std::move(msg) 把左值转换成了右值引用,成功匹配到了 inspect(std::string&&) 版本,但函数执行完后再次打印 msg,它的值依然是 "Hello, C++"。
这是因为上一节我们讲过,形参 value 在函数内部有了名字,当作表达式使用时属于左值。inspect(std::string&&) 内部只是读取并打印了 value,并没有调用移动操作把它的内存转走。
这也印证了 std::move() 本身只负责转换类型,真正动手转移资源的,是下一节将要学习的移动构造与移动赋值。
std::move 的注意事项
在实际开发中使用 std::move() 时,并不是看到变量就随便套上去,我们需要遵循以下 3 条原则:
- 后续还要读取时不要使用:写下
std::move(obj)就意味着我们主动放弃了obj当前保存的值。如果后面的代码还需要继续读取obj原来的数据,千万不要使用std::move(),直接使用普通拷贝即可。 - 基本数据类型不需要使用:像
int、double、char、bool这类基本数据类型,数值直接保存在变量自身的几个字节里,并没有额外的堆内存可供转移。对基本类型使用std::move()和直接复制数值没有任何区别,平时直接赋值即可。 - 不要对 const 常对象使用:移动操作接管资源时必须修改来源对象的内部状态,比如把内部指针置空或清空原字符串。如果来源对象带有
const修饰,语法上禁止修改它的任何数据,此时调用std::move()只会退回普通的拷贝操作。
对于第三点,很多初学者平时写代码时容易忽略,因为编译器不会报错,而是静默退回成拷贝。下面我们拿 const std::string 和前面的示例 1 对比一下就清楚了。
示例 4:const 对象退化为拷贝
#include <iostream>
#include <string>
#include <utility>
int main() {
const std::string s1 = "Hello, lvyenet!";
std::string s2 = std::move(s1); // 对 const 对象调用 std::move
std::cout << "s2 的内容:[" << s2 << "]\n";
std::cout << "s1 的内容:[" << s1 << "]\n";
return 0;
}运行结果如下。
s2 的内容:[Hello, lvyenet!]
s1 的内容:[Hello, lvyenet!]分析:
从运行结果可以看出,虽然写了 std::string s2 = std::move(s1);,但执行完之后,原字符串 s1 并没有像示例 1 那样变空,依然完整保留着 "Hello, lvyenet!"。
为什么没有触发移动呢?因为 s1 是 const 常对象,std::move(s1) 转换出来的类型是带有只读限制的 const std::string&&,而移动操作需要修改源对象,无法接收带 const 的参数。
因此编译器只好退而匹配形参为 const std::string& 的拷贝构造函数,把字符串重新复制了一份。小伙伴们要记住:如果希望某个对象后续能被移动,定义它时就不要加上 const。
