45、移动语义与右值引用:移动构造、移动赋值、std::move、完美转发
说实话,C++11 引入的移动语义,是我这些年用得最爽的特性之一。
记得我刚入行那会儿,写代码最怕的就是大对象拷贝。一个 std::vector<std::string> 传进函数,拷贝一次;返回时又拷贝一次。性能咔咔往下掉。后来我干脆全用指针,结果内存泄漏追得我头皮发麻。
直到我真正理解了右值引用和移动语义——嗯,世界清净了。
右值引用:先搞懂什么是“右值”
要理解移动语义,得先分清左值和右值。
- 左值(lvalue):有名字、能取地址、生命周期长。比如
int a = 5;里的a。 - 右值(rvalue):临时对象、字面量、表达式结果。比如
5、a + b、std::string("hello")。
右值引用用 && 表示。它专门绑定到右值上。为什么需要它?因为右值是“将亡之物”——它的资源马上要释放了,我们何不“偷”过来用?
核心思想:右值引用让我们能区分“这个对象还能不能用”和“这个对象马上要死了”。对将死的对象,直接抢它的资源,省掉拷贝开销。
移动构造函数:偷资源,而不是复制
我项目中写过这样一个类:
class MyBuffer {
public:
// 普通构造函数
MyBuffer(size_t size) : size_(size), data_(new int[size]) {
std::cout << "构造: 分配内存\n";
}
// 拷贝构造函数 —— 深拷贝,开销大
MyBuffer(const MyBuffer& other)
: size_(other.size_), data_(new int[other.size_]) {
std::copy(other.data_, other.data_ + size_, data_);
std::cout << "拷贝构造: 深拷贝\n";
}
// 移动构造函数 —— 偷资源,开销小
MyBuffer(MyBuffer&& other) noexcept
: size_(other.size_), data_(other.data_) {
other.data_ = nullptr; // 把原对象的指针置空
other.size_ = 0;
std::cout << "移动构造: 偷资源\n";
}
~MyBuffer() { delete[] data_; }
private:
size_t size_;
int* data_;
};
看到了吗?移动构造只是把指针“拿过来”,然后把原对象的指针置空。没有新分配内存,没有拷贝数据。这就是移动语义的精髓。
我的习惯:移动构造函数和移动赋值运算符一定要加 noexcept。为什么?因为 std::vector 在扩容时,如果移动构造不保证不抛异常,它会退化成拷贝构造。加了 noexcept,容器才敢放心地移动元素。
移动赋值运算符:同理,但别忘了自赋值
移动赋值和移动构造思路一样,但多了一个步骤:先释放自己已有的资源。
MyBuffer& operator=(MyBuffer&& other) noexcept {
if (this != &other) { // 自赋值检查,我曾经踩过这个坑
delete[] data_; // 释放自己的旧资源
data_ = other.data_; // 偷对方的资源
size_ = other.size_;
other.data_ = nullptr; // 把对方置空
other.size_ = 0;
}
return *this;
}
我曾经在重构代码时忘了写自赋值检查。结果有个地方不小心把自己移动给自己,delete[] data_ 把资源释放了,然后又把 nullptr 赋给自己——程序直接崩了。嗯,从那以后我写移动赋值第一件事就是加 if (this != &other)。
std::move:它什么都没移动,只是做了个转换
很多人以为 std::move 会“移动”什么东西。其实它只是把左值转换成右值引用。说白了,就是一个 static_cast<T&&>。
MyBuffer buf1(100);
MyBuffer buf2 = std::move(buf1); // 触发移动构造
// 此时 buf1 的资源已被偷走,不要再使用 buf1 的数据
注意:被 std::move 之后的对象仍然存在,但处于“有效但未指定”的状态。你可以重新给它赋值,但不要假设它原来的数据还在。我见过有人 move 之后还去读原对象的数据,结果查了半天 bug。
完美转发:让参数带着它的“左右值属性”传下去
完美转发解决的是这样一个问题:我想写一个包装函数,把参数原封不动地传给另一个函数。如果传进来的是左值,就调用左值版本;如果是右值,就调用右值版本。
怎么做?用 std::forward 配合万能引用(也叫转发引用)。
template<typename T>
void wrapper(T&& arg) {
// 完美转发:保持 arg 的左右值属性
target_function(std::forward<T>(arg));
}
这里的 T&& 不是右值引用,而是万能引用。它既能绑定左值,也能绑定右值。std::forward<T> 会根据 T 的类型决定返回左值引用还是右值引用。
我个人习惯在写工厂函数、代理类、或者需要转发构造参数的场景时,一定会用完美转发。比如 std::make_shared、std::make_unique 内部就是这么干的。
知识体系图
避坑指南:我踩过的几个坑
- 不要对 const 对象使用移动语义:
const MyBuffer&&会退化成拷贝构造,因为 const 对象不能修改。我见过有人写了std::move(const_obj),结果还是调了拷贝构造,白忙活一场。 - 移动后不要访问原对象数据:虽然对象还在,但资源已经被偷走了。你只能重新赋值或析构它。
- 移动构造函数和移动赋值要成对实现:只写一个,另一个可能还是走拷贝路径。
- 在类中显式 =default 或 =delete:如果你不写移动构造,编译器在某些条件下会生成,但行为可能不是你想要的。我习惯显式声明。
我的建议:如果你在写一个类,里面有指针成员或动态分配的资源,一定要实现移动构造和移动赋值。这是现代 C++ 的基本素养。而且,一旦你写了移动构造,记得把拷贝构造和拷贝赋值也一并考虑清楚——要么实现,要么 =delete。
移动语义不是玄学,它就是 C++ 给我们的一个工具,让我们能写出既高效又安全的代码。你想想看,一个 std::vector 的拷贝是 O(n),移动是 O(1)——这差距,不值得你花十分钟搞懂它吗?
公众号:蓝海资料掘金营,微信deep3321