13. 如何实现一个支持移动语义的自定义字符串类?
这个问题,说实话,是每个C++开发者进阶路上的一道坎。我记得刚入行那会儿,面试官问我这个问题,我吭哧吭哧写了个带拷贝构造的String类,结果被追问「移动呢?」——当场卡壳。
后来我在一个网络库项目里,亲手踩过字符串拷贝的坑。每次数据包拼接都要复制好几遍,性能惨不忍睹。那时候我才真正理解:移动语义不是炫技,是刚需。
好,咱们今天就把这个String类从头撸一遍。你跟着我走,保证能写出一个工业级的、支持移动语义的字符串类。
核心设计思路
一个支持移动语义的字符串类,说白了就三件事:
- 资源管理:内部用动态分配的char数组存数据
- 拷贝语义:深拷贝,各自管各自的内存
- 移动语义:偷走别人的资源,让别人处于「空壳」状态
嗯,这里要注意:移动之后,源对象必须处于「可析构、可赋值」的安全状态。不能留下野指针。
代码实现
我直接给你一个完整的实现。你仔细看,每一行都有讲究。
#include <iostream>
#include <cstring>
#include <algorithm>
class MyString {
public:
// 默认构造函数
MyString() : data_(nullptr), size_(0) {}
// 从C风格字符串构造
MyString(const char* str) {
if (str) {
size_ = std::strlen(str);
data_ = new char[size_ + 1];
std::memcpy(data_, str, size_ + 1);
} else {
data_ = nullptr;
size_ = 0;
}
}
// 拷贝构造函数 —— 深拷贝
MyString(const MyString& other)
: data_(nullptr), size_(other.size_) {
if (other.data_) {
data_ = new char[size_ + 1];
std::memcpy(data_, other.data_, size_ + 1);
}
}
// 移动构造函数 —— 偷资源
MyString(MyString&& other) noexcept
: data_(other.data_), size_(other.size_) {
other.data_ = nullptr;
other.size_ = 0;
}
// 拷贝赋值运算符
MyString& operator=(const MyString& other) {
if (this != &other) {
MyString temp(other); // 拷贝构造临时对象
swap(temp); // 交换资源
}
return *this;
}
// 移动赋值运算符
MyString& operator=(MyString&& other) noexcept {
if (this != &other) {
delete[] data_; // 释放自己的资源
data_ = other.data_; // 偷别人的资源
size_ = other.size_;
other.data_ = nullptr;
other.size_ = 0;
}
return *this;
}
// 析构函数
~MyString() {
delete[] data_;
}
// 交换函数
void swap(MyString& other) noexcept {
std::swap(data_, other.data_);
std::swap(size_, other.size_);
}
// 获取C风格字符串
const char* c_str() const {
return data_ ? data_ : "";
}
// 获取长度
size_t size() const { return size_; }
private:
char* data_;
size_t size_;
};
关键点解析
这段代码里,有几个地方我想重点说说。
1. 移动构造函数为什么是 noexcept?
我当年写第一个版本时没加 noexcept,结果发现 std::vector 扩容时根本不调用我的移动构造,反而去调拷贝构造。为什么?因为标准库容器在强异常安全保证下,只有 noexcept 的移动操作才敢用。你想想看,如果移动过程中抛异常,源对象已经被修改了,那就没法回滚了。
记住一条铁律:移动构造函数和移动赋值运算符,只要可能,就标记为 noexcept。
2. 拷贝赋值运算符为什么用 copy-and-swap?
这是一种经典写法。先拷贝构造一个临时对象,然后交换资源。好处是:
- 代码简洁,不用重复写释放资源的逻辑
- 异常安全:如果拷贝构造抛异常,当前对象不受影响
- 自赋值安全:拷贝构造临时对象时,自赋值也能正确处理
我曾经在一个遗留代码里看到有人手写拷贝赋值,写了十几行,还漏了自赋值检查。用 copy-and-swap,三行搞定,不香吗?
3. 移动赋值运算符为什么直接 delete[]?
这里我没有用 copy-and-swap,而是直接释放自己的资源,然后偷别人的。为什么?因为移动操作本身不应该抛异常,而 new 可能抛异常。所以移动赋值里,我选择最直接的方式:先释放,再偷。
注意:移动赋值后,一定要把源对象的 data_ 置为 nullptr。否则析构时 double free,程序直接崩给你看。我曾经在代码审查时见过这种bug,排查了一下午。
移动语义的流程图
下面这张图,帮你理清移动构造和拷贝构造的区别。我建议你多看几遍,把流程刻在脑子里。
使用示例
写完了类,咱们看看怎么用。我建议你亲手跑一遍这段代码,感受一下移动语义的威力。
MyString createString() {
MyString s("Hello, World!");
return s; // 这里会触发移动构造(C++11起)
}
int main() {
MyString s1("Hello");
MyString s2 = s1; // 拷贝构造:s1不受影响
MyString s3 = std::move(s1); // 移动构造:s1被置空
s2 = s3; // 拷贝赋值
s2 = std::move(s3); // 移动赋值:s3被置空
MyString s4 = createString(); // 移动构造(返回值优化可能进一步优化掉)
return 0;
}
避坑指南
我这些年见过不少人在自定义字符串类上翻车,总结几个常见坑:
- 忘记处理自赋值:移动赋值里 if (this != &other) 不能省。虽然移动自赋值很少见,但一旦发生,没有这个判断就是 double free。
- 移动后源对象状态不明确:一定要保证移动后的源对象可以安全析构。我习惯把 data_ 置为 nullptr,size_ 置为 0。
- 忘记加 noexcept:这个前面说过了,不加的话标准容器会退化成拷贝,性能直接打回原形。
- 拷贝赋值没有异常安全保证:如果拷贝赋值里先 delete[] 再 new,new 抛异常时对象就废了。用 copy-and-swap 可以完美解决。
我的个人习惯:每次写完移动操作,我都会问自己三个问题:
- 移动后源对象能安全析构吗?
- 移动后源对象能安全赋值吗?
- 移动操作会抛异常吗?
三个问题都回答「是」,才算合格。
性能对比
咱们用数据说话。假设字符串长度是 1000 字节:
| 操作 | 拷贝 | 移动 |
|---|---|---|
| 内存分配 | 1次 new | 0次 |
| 数据复制 | 1000字节 memcpy | 0字节 |
| 时间复杂度 | O(n) | O(1) |
| 异常风险 | 可能抛 bad_alloc | 无(noexcept) |
看到了吧?移动操作就是零成本抽象的最佳体现。你想想看,如果你的代码里频繁传递大字符串,用移动语义能省掉多少不必要的内存分配和拷贝。
好了,这个自定义字符串类就讲到这里。你回去可以自己动手写一遍,然后跑几个测试用例,看看移动构造和拷贝构造分别被调用了多少次。实践出真知,光看代码是不够的。