4. 移动构造函数:定义与实现,从源对象“偷”资源,避免深拷贝
好,咱们进入移动语义的核心地带了。
前面我们聊了右值引用,说白了它就是个「身份标记」——告诉编译器:这个对象是临时的,你可以随便折腾它,不用客气。那移动构造函数,就是把这个标记真正用起来的地方。
4.1 为什么要移动?深拷贝的痛
先想想看,一个传统的拷贝构造函数在干什么?
class MyString {
public:
MyString(const char* s) {
size_ = strlen(s);
data_ = new char[size_ + 1];
memcpy(data_, s, size_ + 1);
}
// 拷贝构造函数:老老实实深拷贝
MyString(const MyString& other) {
size_ = other.size_;
data_ = new char[size_ + 1]; // 重新分配内存
memcpy(data_, other.data_, size_ + 1); // 复制数据
std::cout << "深拷贝,好累...\n";
}
~MyString() { delete[] data_; }
private:
char* data_;
size_t size_;
};
这段代码有什么问题?
嗯,问题大了去了。每次拷贝都要 new 一块新内存,再把数据一个一个复制过去。如果字符串有 10MB,那就得老老实实复制 10MB。我当年在做一个日志处理系统时,就因为这个深拷贝,程序跑起来 CPU 直接飙到 100%,内存分配器都快哭了。
但你想,如果源对象是个临时对象——比如函数返回的局部字符串——它马上就要被销毁了。我们为什么还要费劲去复制它的数据?直接把它的指针「偷」过来不就行了?
4.2 移动构造函数的定义
语法其实很简单:参数是「右值引用」,也就是 T&&。
class MyString {
public:
// 移动构造函数
MyString(MyString&& other) noexcept
: data_(other.data_) // 偷指针,不重新分配
, size_(other.size_)
{
// 把源对象置为空,防止析构时释放我们的数据
other.data_ = nullptr;
other.size_ = 0;
std::cout << "移动构造,轻松偷资源!\n";
}
// ... 其他成员函数不变
};
看到区别了吗?
- 参数类型:
MyString&& other—— 只接受右值 - 操作: 直接
data_ = other.data_,没有new,没有memcpy - 关键一步: 把
other.data_置为nullptr
delete[] 掉我们刚偷来的数据,那就全完了。
我曾经就犯过这个错。有一次写一个网络缓冲区类,移动构造函数里忘了把源对象的指针置空。结果程序跑着跑着,缓冲区里的数据莫名其妙就没了。调试了两天才发现,原来是源对象析构时把数据释放了。嗯,从那以后我写移动构造函数,第一件事就是检查「源对象是否被清空」。
4.3 什么时候会调用移动构造函数?
说白了,就是当右值被用来构造一个新对象时:
MyString s1("Hello, World!");
// 场景1:用临时对象构造
MyString s2(MyString("Temporary")); // 调用移动构造
// 场景2:std::move 强制转换
MyString s3(std::move(s1)); // 调用移动构造
// 注意:s1 现在处于「有效但未指定」状态,不要再用了
你可能会问:为什么 std::move(s1) 也能触发移动构造?
因为 std::move 本质上就是个类型转换,它把左值 s1 转成了右值引用。编译器一看参数是右值引用,就匹配到移动构造函数了。
std::move。因为编译器有 RVO(返回值优化),它会自动选择移动构造甚至直接构造。你手动 std::move 反而可能抑制优化。
4.4 移动 vs 拷贝:性能对比
咱们来做个简单的对比:
| 操作 | 拷贝构造函数 | 移动构造函数 |
|---|---|---|
| 内存分配 | 需要 new 新内存 | 不需要,直接偷指针 |
| 数据复制 | O(n) 逐字节复制 | O(1) 指针赋值 |
| 源对象状态 | 保持不变 | 被置为空 |
| 异常安全 | 可能抛出 bad_alloc | 通常 noexcept |
| 适用场景 | 需要保留源对象 | 源对象即将销毁 |
你看,移动构造几乎就是「零成本」操作。对于像 std::vector、std::string 这样的容器,移动构造只是交换几个指针和大小值,完全不需要遍历元素。
4.5 noexcept 的重要性
你可能注意到了,我在移动构造函数后面加了个 noexcept。
MyString(MyString&& other) noexcept
: data_(other.data_), size_(other.size_) { ... }
为什么这么写?
因为标准库容器(比如 std::vector)在重新分配内存时,会优先使用移动构造函数。但如果移动构造函数可能抛出异常,容器就会退而使用拷贝构造函数——因为拷贝构造至少能保证源对象不变,异常安全。
换句话说:如果你的移动构造函数没有标记 noexcept,容器可能根本不会用它!
noexcept。这不仅是文档,更是性能的关键。
我记得有一次优化一个大型游戏服务器,里面有个 std::vector<PlayerSession>,每次扩容都要拷贝所有玩家数据。我加了移动构造函数,但忘了加 noexcept。结果性能纹丝不动。查了半天才发现,std::vector 压根没调用我的移动构造,还是老老实实走拷贝。加上 noexcept 后,性能直接提升了 3 倍。
4.6 移动构造函数的正确实现模式
总结一下,一个标准的移动构造函数应该长这样:
class MyBuffer {
public:
// 移动构造函数
MyBuffer(MyBuffer&& other) noexcept
: ptr_(other.ptr_) // 1. 偷资源
, size_(other.size_)
{
// 2. 源对象置空
other.ptr_ = nullptr;
other.size_ = 0;
}
// 拷贝构造函数(如果需要)
MyBuffer(const MyBuffer& other)
: ptr_(new char[other.size_])
, size_(other.size_)
{
memcpy(ptr_, other.ptr_, size_);
}
// 析构函数
~MyBuffer() { delete[] ptr_; }
private:
char* ptr_;
size_t size_;
};
核心就三步:
- 偷: 把源对象的资源指针直接拿过来
- 清: 把源对象的指针置空,大小置零
- 保: 标记为
noexcept,保证容器能放心使用
你想想看,这三步做完,源对象就变成了一个「空壳子」。它还能正常析构(因为 delete nullptr 是安全的),但不再拥有任何资源。这就是移动语义的精髓——所有权转移。
4.7 知识体系图
下面这张图帮你理清移动构造函数在整个移动语义中的位置:
从图上可以看得很清楚:移动构造函数的核心就是「定义-实现-调用」三个环节。定义时用 && 和 noexcept,实现时偷资源并置空源对象,调用时由右值或 std::move 触发。三者缺一不可。
4.8 避坑指南
最后,分享几个我踩过的坑:
- 别忘了置空源对象: 这是最常见的 bug。移动后源对象必须处于「可安全析构」的状态,否则 double free 等着你。
- 别忘了加 noexcept: 标准库容器会检查这个标记。不加的话,移动语义可能根本不会生效。
- 不要对 const 对象使用移动:
const T&&虽然能编译,但移动构造函数无法修改源对象,实际上会退化成拷贝。 - 移动后不要再使用源对象: 除非你重新给它赋值。这是语义约定,不是编译器强制。
嗯,移动构造函数就聊到这里。说白了,它就是 C++11 给我们的一个「偷懒」工具——不用再傻傻地复制了,直接拿过来用就行。但偷懒也要偷得优雅,该加的 noexcept 要加,该置空的要置空,这样才能写出既高效又安全的代码。