7. 移动构造函数(C++11):移动语义基础、移动构造函数语法、移动构造函数与拷贝构造函数的区别
好,咱们来聊聊移动构造函数。说实话,C++11 引入移动语义之前,我写代码时经常被一些「不必要的拷贝」搞得头疼。尤其是那些临时对象传来传去的时候,性能损耗简直让人抓狂。
你想想看,一个函数返回一个巨大的 std::vector,传统拷贝构造函数会老老实实地把数据复制一份。但问题是——那个临时对象马上就要销毁了,复制它干嘛?直接「偷」走它的资源不香吗?
嗯,这就是移动构造函数要干的事。
移动语义基础
移动语义的核心思想很简单:把资源从一个对象「转移」到另一个对象,而不是复制。说白了,就是「你既然不用了,那你的东西我就拿走了」。
我个人习惯把移动语义理解为「借东西不还」——源对象把自己的资源借给目标对象,然后自己进入一个「空壳」状态。这个空壳状态是合法的,可以被安全析构,但不能再持有原来的资源。
关键点:移动操作完成后,源对象必须处于「有效但未指定」的状态。它仍然可以被析构、被赋值,但不能假定它持有任何特定值。
我在项目中遇到过这样一个场景:一个类内部维护了一个 100MB 的缓冲区。如果每次拷贝都复制这 100MB,性能直接崩掉。用移动语义,只需要交换几个指针——O(1) 操作,爽得很。
移动构造函数语法
移动构造函数的语法和拷贝构造函数很像,但参数是右值引用:
class MyBuffer {
public:
// 默认构造函数
MyBuffer(size_t size) : size_(size), data_(new int[size]) {
std::cout << "构造: 分配 " << size << " 个元素\n";
}
// 拷贝构造函数
MyBuffer(const MyBuffer& other) : size_(other.size_), data_(new int[other.size_]) {
std::copy(other.data_, other.data_ + size_, data_);
std::cout << "拷贝构造: 深拷贝 " << size_ << " 个元素\n";
}
// 移动构造函数 —— 重点在这里
MyBuffer(MyBuffer&& other) noexcept
: size_(other.size_), data_(other.data_) {
// 把源对象的指针置空,让它不再持有资源
other.data_ = nullptr;
other.size_ = 0;
std::cout << "移动构造: 偷走资源,源对象已置空\n";
}
// 析构函数
~MyBuffer() {
delete[] data_;
std::cout << "析构: 释放资源\n";
}
private:
size_t size_;
int* data_;
};
注意几个细节:
- 参数是
MyBuffer&&—— 两个 & 表示右值引用 - 函数标记为
noexcept—— 移动操作不应该抛异常,这能让标准库更高效地使用你的类型 - 源对象的资源指针被置为
nullptr—— 这是必须的,否则析构时会 double free
小技巧:我习惯在移动构造函数里用 noexcept。为什么?因为 std::vector 扩容时,如果移动构造函数是 noexcept 的,它会优先使用移动;否则只能退化为拷贝。这个坑我曾经踩过,性能直接掉了 10 倍。
什么时候触发移动构造?
移动构造函数在以下场景被调用:
- 函数返回局部对象(返回值优化失效时)
- 用
std::move()显式转换 - 临时对象作为参数传递(比如
func(MyBuffer(100))) - 标准容器的扩容操作(如果移动构造是 noexcept 的)
MyBuffer createBuffer() {
MyBuffer buf(1000);
// ... 做一些操作
return buf; // 优先移动,而非拷贝
}
int main() {
MyBuffer a(100);
MyBuffer b = std::move(a); // 显式调用移动构造
// 此时 a 处于空壳状态,不能再使用
MyBuffer c = createBuffer(); // 函数返回触发移动
}
移动构造函数与拷贝构造函数的区别
咱们用一张表来对比:
| 对比维度 | 拷贝构造函数 | 移动构造函数 |
|---|---|---|
| 参数类型 | const T&(左值引用) |
T&&(右值引用) |
| 资源处理 | 深拷贝,复制所有数据 | 浅拷贝 + 转移所有权 |
| 性能 | O(n),与资源大小成正比 | O(1),通常只是指针交换 |
| 源对象状态 | 源对象不变,仍可用 | 源对象被置空,不可再用 |
| 异常安全 | 通常安全(可分配内存失败) | 应标记 noexcept,不抛异常 |
| 适用场景 | 需要保留源对象时 | 源对象即将销毁时 |
说白了,拷贝构造函数是「复制一份给你,我留着我的」;移动构造函数是「你的就是我的,你的我拿走了,你爱咋咋地」。
警告:千万不要在移动构造后继续使用源对象!我曾经见过一个同事,移动完一个 std::string 后还去访问它,结果程序崩溃了。虽然标准库保证移动后的字符串处于有效状态,但具体值是未指定的——你没法依赖它。
移动语义的核心逻辑
下面这张图展示了移动构造和拷贝构造的流程对比:
从图上可以看得很清楚:拷贝构造需要分配新内存、复制所有数据;而移动构造只是把指针「偷」过来,再把源对象的指针置空。这就是为什么移动操作能带来数量级的性能提升。
什么时候用移动,什么时候用拷贝?
我个人的经验是:
- 默认用拷贝,除非你能确定源对象不再需要
- 函数返回局部对象时,编译器会自动选择移动(甚至直接优化掉)
- 显式传递临时对象时,用
std::move触发移动语义 - 容器扩容时,如果移动构造是 noexcept 的,标准库会自动用移动
避坑指南:我曾经在代码里写了一个移动构造函数,但忘了把源对象的指针置空。结果源对象析构时把资源释放了,目标对象拿着一个悬空指针——程序崩溃得莫名其妙。记住:移动构造不只是「偷」,还要「擦屁股」。
嗯,移动构造函数就聊到这儿。记住它的核心:资源转移,而非复制。写对了,性能起飞;写错了,bug 满天飞。