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,排查了一下午。

移动语义的流程图

下面这张图,帮你理清移动构造和拷贝构造的区别。我建议你多看几遍,把流程刻在脑子里。

拷贝构造 vs 移动构造 流程对比 拷贝构造 源对象:data_ → "Hello" size_ = 5 new char[6] 分配新内存 memcpy 复制数据 目标对象:data_ → "Hello" (新内存) 源对象:data_ → "Hello" (不变) 移动构造 源对象:data_ → "Hello" size_ = 5 直接偷走指针:data_ 赋值 不分配新内存! 目标对象:data_ → "Hello" 源对象:data_ → nullptr (已置空) vs

使用示例

写完了类,咱们看看怎么用。我建议你亲手跑一遍这段代码,感受一下移动语义的威力。

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 可以完美解决。

我的个人习惯:每次写完移动操作,我都会问自己三个问题:

  1. 移动后源对象能安全析构吗?
  2. 移动后源对象能安全赋值吗?
  3. 移动操作会抛异常吗?

三个问题都回答「是」,才算合格。

性能对比

咱们用数据说话。假设字符串长度是 1000 字节:

操作 拷贝 移动
内存分配 1次 new 0次
数据复制 1000字节 memcpy 0字节
时间复杂度 O(n) O(1)
异常风险 可能抛 bad_alloc 无(noexcept)

看到了吧?移动操作就是零成本抽象的最佳体现。你想想看,如果你的代码里频繁传递大字符串,用移动语义能省掉多少不必要的内存分配和拷贝。

好了,这个自定义字符串类就讲到这里。你回去可以自己动手写一遍,然后跑几个测试用例,看看移动构造和拷贝构造分别被调用了多少次。实践出真知,光看代码是不够的。