1. 项目概述:为什么要手写字符串实现
在C++学习过程中,手动实现标准库的string类可以说是每个进阶开发者必经的"成人礼"。去年我在辅导新人时发现,那些认真实现过简化版string的同学,在后期的内存管理、异常处理等高级话题上明显表现更出色。这个"手撕丐版string"项目,就是要带大家从零开始构建一个最小可用的字符串类,过程中会涉及:
- 动态内存管理的核心套路
- 拷贝控制三件套(构造/拷贝/析构)的经典实现
- 常用字符串操作的底层逻辑
这个实现虽然功能简陋(所以叫"丐版"),但包含了字符串类最关键的骨架。完成它之后,你会突然发现标准库的string不再神秘,甚至能一眼看穿某些商业代码中的字符串相关bug。
2. 基础结构设计
2.1 成员变量选择
我们先来看类的基本结构。标准string需要动态管理字符数组,因此需要两个核心成员:
cpp复制class MiniString {
char* m_data; // 动态分配的字符数组
size_t m_size; // 当前字符串长度(不含结尾'\0')
};
这里有几个设计考量:
- 没有单独记录容量(capacity),因为我们暂时不做预留空间优化
- 坚持使用
size_t而不是int,与标准库保持一致 - 成员变量加
m_前缀是常见命名约定,避免与参数名冲突
注意:实际工程中还会包含allocator等模板参数,我们学习版暂不涉及
2.2 构造函数三部曲
基础构造函数需要处理三种常见情况:
cpp复制// 默认构造(空字符串)
MiniString() : m_data(new char[1]), m_size(0) {
m_data[0] = '\0';
}
// C风格字符串构造
MiniString(const char* str) {
m_size = strlen(str);
m_data = new char[m_size + 1];
strcpy(m_data, str);
}
// 拷贝构造(深拷贝!)
MiniString(const MiniString& other) {
m_size = other.m_size;
m_data = new char[m_size + 1];
strcpy(m_data, other.m_data);
}
关键点在于每次分配内存时都要多留一个字节给终止符'\0'。我曾经见过有人写出new char[m_size]然后导致随机内存访问错误,这种bug往往要几个小时才能查出来。
3. 关键功能实现
3.1 赋值操作符的异常安全写法
赋值操作符需要特别注意异常安全问题,以下是经典写法:
cpp复制MiniString& operator=(const MiniString& rhs) {
if (this != &rhs) {
char* temp = new char[rhs.m_size + 1]; // 先分配新内存
strcpy(temp, rhs.m_data); // 再拷贝数据
delete[] m_data; // 最后释放旧内存
m_data = temp;
m_size = rhs.m_size;
}
return *this;
}
这种实现保证了:
- 自赋值安全(开头的if判断)
- 强异常安全(先操作新内存,成功后再替换旧数据)
- 不泄露资源(所有new都有对应的delete)
3.2 常用操作实现
3.2.1 获取C风格字符串
cpp复制const char* c_str() const {
return m_data;
}
看似简单,但要注意:
- 返回
const char*防止外部修改 - 标准库的c_str()保证返回指针在下次非const操作前有效
3.2.2 获取字符串长度
cpp复制size_t size() const {
return m_size;
}
bool empty() const {
return m_size == 0;
}
这里我们选择存储长度而不是每次都strlen,这是典型的空间换时间策略。
3.2.3 下标访问
cpp复制char& operator[](size_t pos) {
if (pos >= m_size) throw std::out_of_range("...");
return m_data[pos];
}
const char& operator[](size_t pos) const {
if (pos >= m_size) throw std::out_of_range("...");
return m_data[pos];
}
注意提供const和非const两个版本,这是STL容器的通用做法。
4. 内存管理进阶
4.1 析构函数实现
cpp复制~MiniString() {
delete[] m_data;
}
虽然简单,但必须注意:
- 使用
delete[]匹配new[] - 标准库实现通常还会处理引用计数等高级特性
4.2 移动语义支持(C++11)
现代C++应该支持移动构造和移动赋值:
cpp复制// 移动构造
MiniString(MiniString&& other) noexcept
: m_data(other.m_data), m_size(other.m_size) {
other.m_data = nullptr;
other.m_size = 0;
}
// 移动赋值
MiniString& operator=(MiniString&& rhs) noexcept {
if (this != &rhs) {
delete[] m_data;
m_data = rhs.m_data;
m_size = rhs.m_size;
rhs.m_data = nullptr;
rhs.m_size = 0;
}
return *this;
}
移动操作的关键点:
- 转移资源所有权而非拷贝
- 将源对象置于有效但不确定的状态
- 标记为noexcept以便标准库优化
5. 常见问题与调试技巧
5.1 内存问题排查表
| 现象 | 可能原因 | 检查点 |
|---|---|---|
| 程序崩溃 | 访问已释放内存 | 检查拷贝构造/赋值是否实现深拷贝 |
| 乱码输出 | 忘记终止符 | 确认所有构造函数都正确添加'\0' |
| 内存泄漏 | 未配对delete | 使用valgrind检测 |
5.2 测试用例设计建议
好的测试应该包含这些边界情况:
cpp复制// 自赋值测试
MiniString s1("hello");
s1 = s1;
// 空字符串测试
MiniString s2;
assert(s2.empty());
// 长字符串测试(超过SSO阈值)
MiniString s3("very long string...");
5.3 性能优化方向
当基础版本稳定后,可以考虑:
- 实现SSO(Small String Optimization)
- 添加容量管理(类似vector的reserve)
- 实现COW(Copy-On-Write)
我在第一次实现时犯过一个典型错误:在operator=中先delete再new,当new抛出异常时对象就处于损坏状态。后来才学会要先new成功后再替换的写法。这种经验只有亲手实现过才能深刻理解。