【面试】手撕 String 复盘

7 阅读6分钟

一、String 本质上要管理什么

一个最基本的 String,本质上就是管理一段动态字符数组。
核心成员通常只有 3 个:

char* data_;
size_t size_;
size_t capacity_;

它们的含义分别是:

  • data_:真正存放字符数据的堆内存
  • size_:当前有效字符数,不包含结尾的 '\0'
  • capacity_:当前可容纳的有效字符数,不包含结尾的 '\0'

这里最重要的不变量是:

  • size_ <= capacity_
  • data_[size_] == '\0'

也就是说,无论对象处于什么状态,都要保证它仍然是一段合法字符串。


二、一个基础版 String 应该具备哪些特征

基础版 String 不需要追求花哨,但至少要有下面几个特征:

1. 自己管理内存

不能依赖 std::string,而是自己 new[] / delete[] 管字符数组。

2. 深拷贝

拷贝构造、拷贝赋值不能只是复制指针,否则会造成多个对象共享同一块内存,最终 double free。

3. 支持移动语义

移动构造、移动赋值要能“偷资源”,避免不必要的深拷贝。

4. 支持自动扩容

append 追加时,如果容量不够,要能自动扩容。

5. 保持 C 字符串兼容

底层虽然自己管理,但最好始终保证 data_[size_] == '\0',这样 c_str() 就能正常返回。

6. 最好支持二进制安全构造

即支持 String(const char* data, size_t size),这样即使数据中间有 '\0' 也能正确构造。


三、基础版至少要实现哪些功能

结合这次实现,一个面试基础版 String 至少要有这些内容。

1. 基本成员变量

char* data_;
size_t size_;
size_t capacity_;

2. 基本构造与析构

  • 默认构造
  • 析构函数
  • const char* 构造
  • const char* + size 构造(二进制安全)

3. 拷贝 / 移动语义

  • 拷贝构造
  • 移动构造
  • 拷贝赋值
  • 移动赋值

也就是常说的 Rule of Five

4. 基础访问接口

  • size()
  • capacity()
  • empty()
  • data()
  • c_str()

5. 修改接口

  • append(const char*)
  • append(const char*, size_t)
  • append(const String&)

6. 内部辅助函数

  • reserve(size_t new_size)
    用于扩容

四、我这次基础版代码的实现思路

下面按模块复盘这次代码到底做了什么。


1. 默认构造:先把空串状态建好

我这里的默认构造逻辑是:

String() : size_(0), capacity_(min_capacity_) {
  data_ = new char[capacity_ + 1];
  data_[0] = '\0';
}

这一步的意义是:

  • 初始字符串为空
  • 预留最小容量
  • 即使是空串,也保证 data_[0] = '\0'

这样后面很多逻辑都会更统一。


2. 析构:释放字符数组

~String() {
  delete[] data_;
}

这一步很简单,但非常关键。
因为 data_new[] 出来的,所以析构时必须 delete[]


3. 普通构造:支持 C 字符串输入

String(const char* str) {
  if (!str) {
    throw std::invalid_argument("nullptr ptr");
  }
  size_ = std::strlen(str);
  capacity_ = std::max(size_, min_capacity_);

  data_ = new char[capacity_ + 1];
  std::memcpy(data_, str, size_ + 1);
}

这一步解决的是:

  • 能直接用 "hello" 来构造 String
  • 根据长度分配空间
  • 拷贝时连结尾 '\0' 一起拷进去

4. 二进制安全构造:按长度复制

String(const char* data, size_t size) {
  if (!data) {
    throw std::invalid_argument("nullptr ptr");
  } 
  size_ = size;
  capacity_ = std::max(size_, min_capacity_);

  data_ = new char[capacity_ + 1];
  std::memcpy(data_, data, size_);
  data_[size_] = '\0';
}

这一版和普通构造最大的区别是:

  • 不依赖 strlen
  • 按给定长度复制
  • 允许源数据里间接出现 '\0'

这就是“二进制安全”的意义。


5. 拷贝构造:做深拷贝

String(const String& other) : size_(other.size_), capacity_(other.capacity_) {
  data_ = new char[capacity_ + 1];
  std::memcpy(data_, other.data_, size_ + 1);
}

这里不能直接写:

data_ = other.data_;

因为那样两个对象会共用同一块内存。
所以拷贝构造必须重新申请空间,再把内容复制过来。


6. 移动构造:接管资源

String(String&& other) noexcept : size_(other.size_), capacity_(other.capacity_) {
  data_ = other.data_;
  other.data_ = nullptr;
  other.size_ = 0;
  other.capacity_ = 0;
}

移动构造的核心不是“再拷贝一份”,而是:

  • 直接接管 other 的资源
  • other 置为空状态

这样避免了额外内存分配和拷贝,效率更高。


7. 拷贝赋值:先分配新空间,再释放旧空间

String& operator=(const String& other) {
  if (this == &other) return *this;
  size_ = other.size_;
  capacity_ = other.capacity_;
  char* temp = data_;
  data_ = new char[capacity_ + 1];
  std::memcpy(data_, other.data_, size_ + 1);
  delete[] temp;

  return *this;
}

这里做了两件重要的事:

  • 自赋值检查:防止 s = s
  • 旧资源释放:避免内存泄漏

8. 移动赋值:先释放自己,再接管别人

String& operator=(String&& other) noexcept {
  if (this == &other) return *this;

  delete[] data_;

  size_ = other.size_;
  capacity_ = other.capacity_;
  data_ = other.data_;

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

  return *this;
}

移动赋值和移动构造的本质一样,也是“接管资源”。

区别是:

  • 当前对象可能已经持有旧资源,所以要先 delete[] data_
  • 然后再接管 other 的资源

9. reserve:扩容的核心

void reserve(size_t new_size) {
  if (capacity_ == 0) capacity_ = min_capacity_;

  while (capacity_ < new_size) capacity_ *= 2;
  char* temp = data_;
  data_ = new char[capacity_ + 1];
  if (temp) {
    std::memcpy(data_, temp, size_ + 1);
  } else {
    data_[0] = '\0';
  }

  delete[] temp;
}

reserve 的作用是:

  • 当追加后容量不够时,扩大底层缓冲区
  • 把旧数据保留下来
  • 释放旧内存

这里我用了倍增扩容,因为这样能减少频繁扩容带来的性能损耗。


10. append:尾部追加的核心逻辑

String& append(const char* data, size_t size) {
  if (!data) throw std::invalid_argument("nullptr ptr");

  size_t new_size = size_ + size;
  
  if (new_size > capacity_) {
    reserve(new_size);
  }
  std::memcpy(data_ + size_, data, size);
  data_[new_size] = '\0';
  size_ = new_size;

  return *this;
}

append 的语义很明确:

把一段新的数据追加到当前字符串尾部

它主要做 4 件事:

  1. 计算追加后的新长度
  2. 如果容量不够,先扩容
  3. 把新数据拷到尾部
  4. 更新 size_,并补上结尾的 '\0'

另外两个重载:

String& append(const char* data)
String& append(const String& data)

本质上都是复用这个底层版本。


五、基础版 String 到这里已经具备了什么能力

到这一步,一个简化版 String 已经具备了这些能力:

  • 能构造空串
  • 能从 C 字符串构造
  • 能按长度构造
  • 能深拷贝
  • 能移动
  • 能自动扩容
  • 能做尾部追加
  • 能返回 c_str()

也就是说,已经完成了一个面试能讲通的基础版字符串类


六、基础版还没有完全处理的深入问题

虽然基础版主流程已经打通,但如果面试官继续深入问,最值得展开的点就是:

1. self-append / 重叠内存问题

比如:

String s("abc");
s.append(s);

或者:

s.append(s.data() + 1, 2);

这类场景的问题在于:

  • 如果 append 触发扩容,旧缓冲区会释放,源指针可能失效
  • 如果不扩容,但源区间和目标区间重叠,直接 memcpy 也是未定义行为

解决思路

完整版本里可以这样做:

  • 先判断源区间是否落在当前缓冲区内部
  • 如果是,就先备份到临时缓冲区
  • 再扩容、再追加

一句话概括就是:

先保护源数据,再扩容,再追加。

这块很适合作为面试深入问答点。


六、一眼复习版

基础版要实现的

  • data_ / size_ / capacity_
  • 默认构造
  • 析构
  • const char* 构造
  • const char* + size 构造
  • 拷贝构造
  • 移动构造
  • 拷贝赋值
  • 移动赋值
  • size / capacity / empty / data / c_str
  • append
  • reserve

七、这次手撕 String 最值得记住的点

最后用几句话收个尾,作为这次复盘最核心的结论。

1.

一个 String 类最核心的不是功能堆多少,而是:

对象语义要完整,内存管理要正确。

2.

只要实现好:

  • 深拷贝
  • 移动语义
  • 扩容
  • append

这个 String 就已经有了面试基础版的骨架。

3.

如果面试官继续深入问,最值得展开的点就是:

self-append 和重叠内存问题怎么处理。