移动语义与右值引用实战
移动语义是 C++11 最重要的性能特性,但也是最容易被"乱用"的特性。核心结论先说:移动语义解决的是"把即将销毁的对象的资源偷过来",不是"加速所有赋值"。
一、值类别速记
- 左值(lvalue):有名字、可取地址,如变量、
*p、v[0]; - 将亡值(xvalue):生命周期即将结束的对象,如
std::move(x)的结果; - 纯右值(prvalue):临时量,如
1 + 2、Foo()。
左值 + 将亡值统称泛左值,将亡值 + 纯右值统称右值。移动构造绑定的是右值。
二、右值引用与移动构造
class Buffer {
public:
explicit Buffer(std::size_t n) : data_(new char[n]), size_(n) {}
~Buffer() { delete[] data_; }
// 拷贝构造:深拷贝
Buffer(const Buffer& o) : data_(new char[o.size_]), size_(o.size_) {
std::memcpy(data_, o.data_, size_);
}
// 移动构造:接管指针
Buffer(Buffer&& o) noexcept : data_(o.data_), size_(o.size_) {
o.data_ = nullptr;
o.size_ = 0;
}
Buffer& operator=(Buffer&& o) noexcept {
if (this != &o) {
delete[] data_;
data_ = o.data_;
size_ = o.size_;
o.data_ = nullptr;
o.size_ = 0;
}
return *this;
}
private:
char* data_ = nullptr;
std::size_t size_ = 0;
};
注意 noexcept:移动构造若不声明 noexcept,std::vector 扩容时会退化为拷贝,性能优势消失。
三、std::move 只做类型转换
std::move 不移动任何东西,它只是把左值转成右值引用:
std::string a = "hello";
std::string b = std::move(a); // 真正的"移动"发生在 b 的移动构造里
// 此后 a 处于"有效但未指定"状态,只能赋值或析构,不要读取
对没有移动构造的类型(只定义了拷贝构造),std::move 完全无效果,仍然走拷贝。对 int、double、裸指针等平凡类型,移动等于拷贝。
四、移动的典型收益场景
场景 1:容器扩容
std::vector 扩容时,如果元素类型的移动构造是 noexcept 的,就移动而非拷贝元素。这就是自定义类型务必标 noexcept 的原因。
场景 2:从函数返回大对象
现代编译器有 RVO/NRVO,直接返回局部变量通常不产生拷贝,不用刻意写 return std::move(x);——反而会阻止 NRVO,属于常见错误。
std::vector<int> make() {
std::vector<int> v(1000);
return v; // ✅ NRVO
// return std::move(v); // ❌ 阻碍 NRVO,可能更慢
}
场景 3:容器插入
std::vector<std::string> v;
std::string s = "long string ...";
v.push_back(std::move(s)); // 所有权转移,避免复制
v.emplace_back("in place"); // 就地构造,同样避免临时对象
emplace_back 直接从参数构造元素,一般优于 push_back(T(...))。
五、五法则与零法则
定义析构、拷贝构造、拷贝赋值、移动构造、移动赋值中的任意一个,通常需要重新审视全部五个(五法则)。更推荐零法则:让成员(如 std::string、std::vector、std::unique_ptr)自己管理资源,你就一行都不用写。
只有在封装裸资源(文件句柄、sqlite3*、malloc 内存)时才手写移动操作,并立即考虑用 RAII 包装类收口。
六、完美转发与万能引用
template <class T>
void relay(T&& arg) { // T&& 在推导语境下是"万能引用"
target(std::forward<T>(arg));
}
- 传入左值时
T推导为T&,std::forward保持左值; - 传入右值时
T推导为T,std::forward转成右值。
如果不用 std::forward 而直接传 arg,参数永远以左值形式传递,右值实参的移动语义就丢了。
template <class... Args>
std::unique_ptr<Foo> makeFoo(Args&&... args) {
return std::unique_ptr<Foo>(new Foo(std::forward<Args>(args)...));
}
七、常见误用清单
| 误用 | 后果 | 正确做法 |
|---|---|---|
对 const 对象 std::move | 静默退化拷贝 | 移动前确认对象非 const |
return std::move(local) | 阻碍 NRVO | 直接 return local |
| 移动后继续读取原对象 | 未定义行为(标准为"未指定状态") | 移动后只赋值或析构 |
移动构造未标 noexcept | 容器扩容退化为拷贝 | 加 noexcept |
对 int/指针大量 std::move | 无收益,噪声 | 直接赋值 |
八、自查清单
迁移老代码时按顺序检查:
- 管理裸资源(
new/malloc/句柄)的类是否补了移动构造与移动赋值,并标noexcept; - 是否有大量"构造临时对象再插入容器"的地方,可改用
emplace; - 是否有
return std::move(x)的错误写法; - 是否有移动后仍被读取的对象;
- 移动构造里是否把源对象置为可安全析构的状态(指针置空)。
移动语义的价值在于"让资源流动一次",而不是"让每行代码看起来更快"。