2、移动构造函数:深拷贝与浅拷贝问题、移动构造函数的定义与实现、移动构造函数调用时机
2.1 深拷贝与浅拷贝——老生常谈的坑
先聊聊浅拷贝。说白了,就是编译器默认生成的拷贝构造函数,它只做成员变量的逐位复制。如果你的类里有指针成员,浅拷贝会让两个对象指向同一块内存。
我在项目中遇到过这样一个bug:一个日志类里有个 char* buffer,两个对象析构时都去 delete[] 同一块内存,程序直接崩了。嗯,这就是典型的浅拷贝灾难。
深拷贝就好多了。它会给新对象分配独立的内存,然后把数据一个一个复制过去。代码大概是这样的:
class String {
char* data_;
size_t size_;
public:
// 深拷贝构造函数
String(const String& other)
: size_(other.size_)
, data_(new char[size_ + 1])
{
std::memcpy(data_, other.data_, size_ + 1);
}
~String() { delete[] data_; }
};
深拷贝安全,但代价高。你想想看,每次拷贝都要 new 内存、memcpy,如果对象很大,性能损耗非常明显。
核心矛盾:深拷贝保证了安全,但浪费了性能。特别是那些临时对象,用完就销毁,何必还要深拷贝一份呢?
2.2 移动构造函数的定义与实现
移动构造函数就是来解决这个矛盾的。它的思路很简单:把临时对象的资源「偷」过来,再把临时对象置为空。
定义方式如下:
class String {
char* data_;
size_t size_;
public:
// 移动构造函数
String(String&& other) noexcept
: data_(other.data_) // 偷走指针
, size_(other.size_)
{
// 把源对象置为可析构的空状态
other.data_ = nullptr;
other.size_ = 0;
}
~String() { delete[] data_; }
};
注意几个关键点:
- 参数是
String&&,也就是右值引用 - 函数体里没有 new 操作,只是指针的交换
- 必须把源对象的指针置空,否则析构时会释放掉我们刚偷来的内存
- 建议加上
noexcept,这样标准库容器在重新分配内存时才会优先使用移动构造
个人习惯:我写移动构造函数时,一定会把源对象置为「空但可析构」的状态。曾经有个同事忘了置空,结果两个对象析构时 double free,查了一下午才找到原因。
2.3 移动构造函数的调用时机
什么时候会调用移动构造函数?说白了,就是当实参是一个右值的时候。常见场景有:
- 返回局部对象:函数返回一个局部变量时,编译器会优先尝试移动构造
- std::move 显式转换:把左值转成右值引用,强制触发移动
- 临时对象传参:比如
func(String("hello")) - 容器操作:vector 扩容时,如果元素有移动构造函数,就会用移动代替拷贝
看个例子:
String createString() {
String s("hello");
return s; // 这里会尝试移动构造(RVO 优化前)
}
int main() {
String s1("world");
String s2 = std::move(s1); // 显式触发移动构造
String s3 = createString(); // 返回临时对象,触发移动构造
}
我曾经踩过的坑:在移动构造函数里抛了异常,结果源对象已经被修改了,析构时又出问题。所以移动构造函数一定要 noexcept,要么就别抛异常。标准库容器在保证强异常安全时,如果移动不是 noexcept,它会退化成拷贝。
2.4 知识体系总览
下面这张图帮你理清移动构造函数在整个资源管理中的位置:
2.5 避坑指南
最后分享几个我实际工作中遇到的教训:
- 别忘了处理自移动:虽然很少见,但
std::move(*this)这种写法是合法的。移动构造函数里要判断一下源对象是不是自己。 - 移动后对象仍可析构:源对象被移动后,它的析构函数仍然会被调用。所以必须保证它处于一个「安全」的状态,比如指针置空、大小置零。
- 不要过度使用 std::move:有些人看到左值就加 move,反而会阻止编译器的 RVO(返回值优化)。让编译器自己决定,它比你聪明。
一句话总结:移动构造函数就是「偷」临时对象的资源,省掉深拷贝的开销。但偷完之后记得把源对象收拾干净,别留下烂摊子。