一、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 件事:
- 计算追加后的新长度
- 如果容量不够,先扩容
- 把新数据拷到尾部
- 更新
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_strappendreserve
七、这次手撕 String 最值得记住的点
最后用几句话收个尾,作为这次复盘最核心的结论。
1.
一个 String 类最核心的不是功能堆多少,而是:
对象语义要完整,内存管理要正确。
2.
只要实现好:
- 深拷贝
- 移动语义
- 扩容
- append
这个 String 就已经有了面试基础版的骨架。
3.
如果面试官继续深入问,最值得展开的点就是:
self-append 和重叠内存问题怎么处理。