【C++面试】手写String类:同时实现拷贝构造与移动构造

一、String类需要解决什么问题

假设自己设计一个字符串类:

复制代码
class String {
private:
    char *data_;
};

如果创建:

复制代码
String str("hello");

希望内部结构类似:

复制代码
String对象

┌──────────────┐
│ data_        │──────┐
└──────────────┘      │
                      ↓
                  堆内存

              ┌─────────────┐
              │ h e l l o \0│
              └─────────────┘

因为字符串长度运行时才能确定,所以可以:

复制代码
new char[len + 1];

动态申请空间。

但是只写构造和析构还不够。

例如:

复制代码
String s1("hello");

String s2 = s1;

这里会发生:

复制代码
拷贝构造

如果直接进行默认浅拷贝:

复制代码
s1.data_ ─────┐
              ↓
            "hello"
              ↑
s2.data_ ─────┘

两个对象指向同一块内存。

当两个对象析构:

复制代码
delete[] s1.data_;
delete[] s2.data_;

同一块内存就可能被释放两次。

因此必须:

复制代码
深拷贝

让两个对象拥有独立资源。

而如果源对象马上就不用了:

复制代码
String s2 = std::move(s1);

就没必要再复制整个字符串,可以直接:

复制代码
把s1的资源交给s2

这就是移动构造。

所以这道题实际上是在考:

复制代码
动态资源管理
      ↓
拷贝构造
      ↓
深拷贝
      ↓
移动构造
      ↓
资源所有权转移

二、先实现普通构造和析构

先写最基础版本:

cpp 复制代码
#include <cstring>
#include <iostream>

class String {
private:
    char *data_;
    size_t size_;

public:
    // 普通构造
    String(const char *str = "")
        : data_(nullptr), size_(0) {

        if (str == nullptr) {
            str = "";
        }

        size_ = std::strlen(str);

        data_ = new char[size_ + 1];

        std::memcpy(data_, str, size_ + 1);

        std::cout << "普通构造" << std::endl;
    }

    // 析构函数
    ~String() {
        delete[] data_;

        std::cout << "析构函数" << std::endl;
    }
};

例如:

复制代码
String s("hello");

执行:

复制代码
strlen("hello")
      ↓
size_ = 5
      ↓
new char[6]
      ↓
复制 hello\0

内存:

复制代码
栈:

s
┌──────────────┐
│ data_        │─────┐
│ size_ = 5    │     │
└──────────────┘     │
                     ↓
                   堆:

              ┌─────────────┐
              │ h e l l o \0│
              └─────────────┘

当:

复制代码
s

离开作用域:

复制代码
~String()
    ↓
delete[] data_

自动释放资源。

这其实也是:

复制代码
RAII

的典型应用。

三、实现拷贝构造:必须进行深拷贝

现在:

复制代码
String s1("hello");

String s2 = s1;

调用的是:

复制代码
String(const String &other);

实现:

cpp 复制代码
String(const String &other)
    : data_(nullptr), size_(other.size_) {

    data_ = new char[size_ + 1];

    std::memcpy(data_,
                other.data_,
                size_ + 1);

    std::cout << "拷贝构造" << std::endl;
}

注意这里不能:

复制代码
data_ = other.data_;

因为这样只是:

复制代码
复制指针地址

而不是复制字符串。

错误的浅拷贝:

复制代码
s1
┌────────────┐
│ data_      │─────┐
└────────────┘     │
                   ↓
                "hello"
                   ↑
┌────────────┐     │
│ data_      │─────┘
└────────────┘
s2

正确的深拷贝:

复制代码
s1
┌────────────┐
│ data_      │────────→ "hello"
└────────────┘


s2
┌────────────┐
│ data_      │────────→ "hello"
└────────────┘

虽然:

复制代码
字符串内容相同

但是:

复制代码
内存地址不同

因此:

复制代码
s2[0] = 'H';

不会影响:

复制代码
s1

这就是:

复制代码
深拷贝
Deep Copy

所以面试问:

为什么 String 类必须自己实现拷贝构造?

可以回答:

因为 String 内部管理动态内存,如果使用编译器默认拷贝构造,只会复制指针地址,两个对象会共享同一块内存,析构时可能产生重复释放。因此需要实现深拷贝,为新对象重新申请内存并复制字符串内容。

四、实现移动构造:直接接管资源

现在:

复制代码
String s1("hello");

String s2 = std::move(s1);

这里希望调用:

复制代码
String(String &&other);

移动构造可以写成:

复制代码
String(String &&other) noexcept
    : data_(other.data_),
      size_(other.size_) {

    other.data_ = nullptr;
    other.size_ = 0;

    std::cout << "移动构造" << std::endl;
}

移动之前:

复制代码
s1
│
└────→ "hello"

执行:

复制代码
data_ = other.data_;

以后:

复制代码
s1 ─────┐
        ↓
      "hello"
        ↑
s2 ─────┘

如果停在这里:

复制代码
两个对象还是指向同一资源

所以必须:

复制代码
other.data_ = nullptr;
other.size_ = 0;

最终:

复制代码
s1
↓
nullptr


s2
↓
"hello"

这样 s1 析构时:

复制代码
delete[] nullptr;

是安全的。

真正的 "hello":

复制代码
已经交给s2管理

所以:

复制代码
拷贝构造
↓
重新申请内存
↓
复制数据

而:

复制代码
移动构造
↓
不复制字符串
↓
直接接管指针

这就是移动语义性能更高的原因。

移动构造建议写:

复制代码
noexcept

即:

复制代码
String(String &&other) noexcept;

因为像:

复制代码
std::vector<String>

扩容时需要把旧元素搬到新的内存。

如果移动构造明确:

复制代码
不会抛异常

标准容器就更容易安全地优先使用移动构造,而不是拷贝构造。

五、完整实现:Rule of Five

既然 String 自己管理:

复制代码
动态内存

那么除了:

复制代码
析构函数
拷贝构造
移动构造

还应该考虑:

复制代码
拷贝赋值
移动赋值

这就是:

复制代码
Rule of Five
五法则

一个适合面试手写的完整版本:

复制代码
#include <cstring>
#include <iostream>
#include <utility>

class String {
private:
    char *data_;
    size_t size_;

public:
    // 1. 普通构造
    String(const char *str = "")
        : data_(nullptr), size_(0) {

        if (str == nullptr) {
            str = "";
        }

        size_ = std::strlen(str);

        data_ = new char[size_ + 1];

        std::memcpy(data_,
                    str,
                    size_ + 1);

        std::cout << "普通构造" << std::endl;
    }

    // 2. 析构函数
    ~String() {
        delete[] data_;

        std::cout << "析构函数" << std::endl;
    }

    // 3. 拷贝构造
    String(const String &other)
        : data_(nullptr),
          size_(other.size_) {

        data_ = new char[size_ + 1];

        std::memcpy(data_,
                    other.data_,
                    size_ + 1);

        std::cout << "拷贝构造" << std::endl;
    }

    // 4. 移动构造
    String(String &&other) noexcept
        : data_(other.data_),
          size_(other.size_) {

        other.data_ = nullptr;
        other.size_ = 0;

        std::cout << "移动构造" << std::endl;
    }

    // 5. 拷贝赋值
    String &operator=(const String &other) {
        if (this != &other) {

            // 先申请新空间
            char *newData =
                new char[other.size_ + 1];

            std::memcpy(newData,
                        other.data_,
                        other.size_ + 1);

            // 再释放自己原来的资源
            delete[] data_;

            data_ = newData;
            size_ = other.size_;
        }

        std::cout << "拷贝赋值" << std::endl;

        return *this;
    }

    // 6. 移动赋值
    String &operator=(String &&other) noexcept {
        if (this != &other) {

            // 先释放自己原来的资源
            delete[] data_;

            // 接管other的资源
            data_ = other.data_;
            size_ = other.size_;

            // other不再拥有资源
            other.data_ = nullptr;
            other.size_ = 0;
        }

        std::cout << "移动赋值" << std::endl;

        return *this;
    }

    // 获取字符串
    const char *c_str() const {
        return data_ ? data_ : "";
    }

    // 获取长度
    size_t size() const {
        return size_;
    }

    // 下标访问
    char &operator[](size_t index) {
        return data_[index];
    }

    const char &operator[](size_t index) const {
        return data_[index];
    }
};

测试:

复制代码
int main() {
    String s1("hello");

    // 拷贝构造
    String s2 = s1;

    // 移动构造
    String s3 = std::move(s1);

    String s4("world");

    // 拷贝赋值
    s4 = s2;

    String s5("C++");

    // 移动赋值
    s5 = std::move(s3);

    std::cout << "s2 = "
              << s2.c_str()
              << std::endl;

    std::cout << "s5 = "
              << s5.c_str()
              << std::endl;

    return 0;
}

整个区别可以直接记成:

复制代码
String s2 = s1;

调用:

复制代码
拷贝构造

过程:

复制代码
s1 → "hello"

重新申请内存
      ↓
复制hello
      ↓

s2 → "hello"

而:

复制代码
String s3 = std::move(s1);

调用:

复制代码
移动构造

过程:

复制代码
s1 → "hello"
      ↓
把指针交出去
      ↓
s1 → nullptr

s3 → "hello"

需要特别区分:

复制代码
String s2 = s1;

这是:

复制代码
拷贝构造

因为:

复制代码
s2原来不存在
现在正在创建

而:

复制代码
String s2("abc");
s2 = s1;

这是:

复制代码
拷贝赋值

因为:

复制代码
s2已经存在

需要先处理自己原来的资源,再复制 s1。

移动同样如此:

复制代码
String s2 = std::move(s1);

是:

复制代码
移动构造

而:

复制代码
String s2("abc");

s2 = std::move(s1);

是:

复制代码
移动赋值

所以面试中可以这样总结:

自定义 String 类内部通过 char* 管理动态内存。普通构造根据字符串长度申请内存;析构函数负责释放资源;拷贝构造需要进行深拷贝,重新申请一块独立内存并复制字符串;移动构造则直接接管源对象的指针,然后把源对象指针置空,从而避免大字符串复制。因为类自己管理资源,还需要同时考虑拷贝赋值和移动赋值,也就是 Rule of Five。

如果面试官继续问:

拷贝构造和移动构造最大的区别是什么?

可以回答:

拷贝构造不会破坏源对象,它需要创建一份独立资源;移动构造用于资源可以被转移的对象,直接接管源对象资源,并把源对象调整为合法但不再拥有原资源的状态,因此对于动态资源较大的对象通常效率更高。

如果继续问:

为什么移动以后要把 other.data_ 置空?

可以回答:

因为资源已经转移给新对象。如果源对象仍然保留原指针,那么两个对象析构时都会 delete[] 同一块内存,造成 Double Free,所以移动后需要让源对象不再拥有这块资源。

如果继续问:

为什么移动构造写 noexcept?

可以回答:

因为 STL 容器在扩容搬迁元素时需要考虑异常安全。如果移动构造声明为 noexcept,容器可以更放心地使用移动构造,从而避免成本较高的深拷贝。

整个 String 类的核心知识链就是:

复制代码
char*
 ↓
动态内存
 ↓
析构释放
 ↓
默认浅拷贝有问题
 ↓
拷贝构造实现深拷贝
 ↓
移动构造转移资源
 ↓
拷贝赋值
 ↓
移动赋值
 ↓
Rule of Five

如果这是现场笔试,时间比较紧,至少要把下面三个函数写正确:

复制代码
~String();

String(const String &other);

String(String &&other) noexcept;

也就是:

复制代码
析构
+
深拷贝
+
移动资源

这三个基本就是这道题最核心的考点。

0voice · GitHub

相关推荐
程序员老陆1 小时前
通过std::unique_ptr初始化std::shared_ptr
开发语言·c++
迷途之人不知返1 小时前
算法系列6:模拟
算法
旺仔仔仔1 小时前
# 机器人项目实战(1):选相机、试深度,再到手眼标定
算法
CoderYanger2 小时前
A.每日一题:1614. 括号的最大嵌套深度
java·程序人生·算法·leetcode·面试·学习方法
yuki蜜语2 小时前
别只会调包跑模型!一文讲透大模型训练底层的“递推、递归、三明治”三重逻辑
算法
成旭先生2 小时前
银行卡识别 API:一张照片读出卡号、BIN 与发卡行
人工智能·算法·ocr·api接口·金融科技·银行卡识别·支付风控
陌上花开缓缓归以2 小时前
mebdlts3.4.1安全启动全流程
服务器·算法
Nebula_g2 小时前
JavaSE拓展:可变参数
java·开发语言·算法·安全·javase·可变参数
129Lab2 小时前
BMS 核心算法:SOC 估算从安时积分到 LSTM-UKF 串级融合的 30 年演进(附 Python 实现)
python·算法·lstm·python3.11·bms·soc估算