1. 项目概述:为什么我们要手撕一个string类?
如果你正在学习C++,尤其是刚刚从C语言过渡过来,或者正在准备面试,那么“手撕string类”几乎是一个绕不开的经典练习。这个项目标题“【C++】string类:模拟实现(适合新手的手撕string)”精准地指向了C++学习路径上的一个关键里程碑。它不是一个简单的语法练习,而是一次对C++核心思想——面向对象、资源管理、运算符重载——的综合性实战。
很多新手对C++标准库里的std::string既爱又怕。爱的是它用起来太方便了,再也不用像C语言那样操心字符数组的长度和\0结尾;怕的是它内部像个黑盒,一旦涉及到深拷贝、动态内存这些底层概念,就容易出问题,比如浅拷贝导致的“双重释放”(double free)或者内存泄漏。通过自己动手模拟实现一个简化版的MyString类,你才能真正理解std::string是如何优雅地管理一块动态内存的,理解拷贝构造、赋值运算符这些“特殊成员函数”为什么如此重要,以及“RAII”(资源获取即初始化)这个听起来高大上的概念,在实践中到底是怎么一回事。
我当年学C++的时候,也是通过实现自己的字符串类,才彻底搞明白了什么时候该用new,什么时候该delete,以及为什么需要自己写拷贝构造函数。这个过程会让你对指针、引用、const关键字有全新的认识。接下来,我将带你从零开始,一步步构建一个功能完整、健壮的MyString类,我会重点解释每一步“为什么”要这么做,并分享我在实现过程中踩过的坑和总结的技巧。
2. 整体设计与核心思路拆解
在动手写代码之前,我们必须先想清楚我们的MyString类要长什么样,以及它需要遵循哪些设计原则。我们不能简单地照搬std::string的所有接口,那太复杂了。我们的目标是实现一个教学版的、能体现核心机制的字符串类。
2.1 类的数据成员设计
一个字符串类最核心的任务就是管理一段动态分配的、以\0结尾的字符数组(C风格字符串)。因此,我们的类至少需要两个数据成员:
char* _str: 一个指针,指向堆上分配的字符数组的首地址。这是我们字符串数据的实际存放地。size_t _size: 一个无符号整数,记录字符串的实际长度(不包括结尾的\0)。这能让我们以O(1)的时间复杂度获取长度,而不是每次都去遍历字符串计算。size_t _capacity: 一个无符号整数,记录当前分配的内存空间能容纳多少字符(不包括结尾的\0)。这是为了支持高效的追加操作(如+=),避免每次追加都重新分配内存。
为什么需要_capacity?想象一下,如果你每次调用+=或者push_back都在堆上重新申请一块刚好够大的新内存,然后把旧数据拷贝过去,再释放旧内存,这个过程的性能开销是巨大的。有了_capacity,我们就可以实现一个简单的“容量翻倍”策略:当_size即将等于_capacity时,我们一次性申请一块更大的内存(比如原容量的2倍),从而摊平多次插入的平均时间成本。这是几乎所有动态数组(如std::vector)的基础策略。
2.2 六大默认成员函数的考量
C++类有六个特殊的默认成员函数:构造函数、析构函数、拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符。对于管理资源的类(我们的MyString管理着堆内存),我们必须慎重对待它们。
- 构造函数:我们需要实现多种构造函数,比如默认构造一个空字符串、用C风格字符串构造、用单个字符构造等。
- 析构函数:这是最重要的!它必须负责释放构造函数中申请的堆内存(
delete[] _str),否则必然导致内存泄漏。 - 拷贝构造函数:当用一个
MyString对象去初始化另一个对象时(如MyString s2(s1);),它会被调用。这里我们必须进行“深拷贝”,即为新对象申请一块独立的内存,并把原对象的数据拷贝过来。如果使用编译器生成的默认拷贝构造,它只会进行“浅拷贝”(复制指针值),导致两个对象的_str指向同一块内存,析构时这块内存会被释放两次,程序崩溃。 - 拷贝赋值运算符:当两个已存在的对象进行赋值时(如
s2 = s1;),它会被调用。它比拷贝构造更复杂,因为目标对象s2可能已经持有资源。我们必须遵循“先释放旧资源,再分配新资源,最后拷贝数据”的原则,并且要处理好“自赋值”(s1 = s1;)的情况。 - 移动构造与移动赋值:这是C++11引入的,用于优化临时对象(右值)的资源转移。对于新手项目,我们可以选择先不实现,专注于理解拷贝语义。但我会在高级技巧部分简要介绍其思路。
我们的核心战斗,就是围绕如何正确实现这前四个函数展开的。
2.3 接口设计:模拟std::string的常用功能
为了让我们的类有用,我们需要实现一些常用接口:
- 容量相关:
size(),capacity(),empty(),reserve(),clear()。 - 元素访问:
operator[](重载下标运算符,分常量和非常量版本),c_str()(返回底层的C风格字符串指针)。 - 修改操作:
push_back(char c),append(const char* str),operator+=,insert(),erase()。 - 字符串操作:
find(),substr()。
我们会挑选最核心、最能体现设计思想的接口来实现,而不是追求大而全。
3. 核心细节解析与实操要点
3.1 深拷贝与浅拷贝:生死攸关的区别
这是模拟实现string类最核心、也最容易出错的概念。我必须要用最直白的方式讲清楚。
浅拷贝(Shallow Copy):只复制指针的值。结果是两个对象的指针成员指向同一块堆内存。
// 假设这是编译器为我们生成的默认拷贝构造函数 MyString(const MyString& s) : _str(s._str), _size(s._size), _capacity(s._capacity) {}这行代码非常危险。当s1和s2的_str都指向同一块内存时,如果s1被析构,它释放了那块内存。那么s2的_str就变成了一个“悬空指针”(dangling pointer),指向一块已经被释放的无效内存。随后当s2也被析构时,它会尝试再次释放同一块内存,这就是“双重释放”,会导致程序崩溃。更糟糕的是,在这期间如果通过s2修改了字符串内容,也会影响到s1(如果s1还存在的话),这违背了对象的独立性。
深拷贝(Deep Copy):为新对象申请一块全新的、大小足够的内存,然后把原对象内存中的数据(包括结尾的\0)逐个字节地拷贝过来。
// 正确的拷贝构造函数 MyString(const MyString& s) { _str = new char[s._capacity + 1]; // 多申请1个字节放'\0' strcpy(_str, s._str); // 拷贝数据,包括'\0' _size = s._size; _capacity = s._capacity; }这样,s1和s2就拥有了各自独立的数据副本,互不干扰,析构时也各自释放自己的内存,安全无误。
实操心得:在C++中,但凡你的类有指针成员指向动态分配的资源(堆内存、文件句柄、网络连接等),你几乎总是需要自己编写拷贝构造函数和拷贝赋值运算符来实现深拷贝,或者使用C++11的“
= delete”语法明确禁止拷贝。绝对不能依赖编译器生成的默认版本。
3.2 拷贝赋值运算符的现代写法
拷贝赋值运算符operator=比拷贝构造函数更难写,因为它要处理一个已经存在的对象。一个健壮的实现需要解决三个问题:
- 防止自赋值(
a = a)。 - 安全地释放旧资源。
- 正确地拷贝新资源。
传统的写法是这样的(常被称为“拷贝并交换” idiom 的基础):
MyString& operator=(const MyString& s) { if (this != &s) { // 1. 检查自赋值 char* tmp = new char[s._capacity + 1]; // 2. 先分配新资源 strcpy(tmp, s._str); delete[] _str; // 3. 再释放旧资源(顺序很重要!) _str = tmp; _size = s._size; _capacity = s._capacity; } return *this; // 4. 返回自身引用以支持链式赋值 a=b=c }这里的关键是先分配新内存,成功后再释放旧内存。如果先释放旧内存,然后新内存分配失败(new可能抛出std::bad_alloc异常),对象就会处于一个资源已释放但新资源未获取的无效状态,非常危险。先分配新资源保证了“强异常安全性”:即使分配失败抛出异常,旧资源依然完好。
3.3 关于reserve和resize的设计
reserve(n)用于增加容量,它保证容量至少为n。如果当前_capacity已经大于等于n,则什么都不做。它不改变字符串的内容和_size。resize(n, char ch)用于改变字符串的_size。如果n > _size,则用字符ch填充多出的部分;如果n < _size,则截断字符串(相当于erase)。它可能会触发容量的重新分配。
在实现时,reserve的逻辑相对独立,而resize的实现内部可能会调用reserve来保证有足够的空间。清晰地区分这两个函数的目的,能让代码更易读和维护。
4. 实操过程与核心环节实现
下面,我将分步骤实现我们的MyString类,并附上详细的注释。
4.1 类的框架与基本成员函数
首先,我们定义类的基本结构和构造函数、析构函数。
#include <iostream> #include <cstring> // 用于strcpy, strlen等 #include <cassert> // 用于断言检查 namespace my { // 放在自己的命名空间里,避免污染全局 class string { private: char* _str; size_t _size; size_t _capacity; static const size_t npos = -1; // 模仿std::string,表示未找到的位置 public: // 默认构造函数:构造空字符串 string() : _str(new char[1]), _size(0), _capacity(0) { _str[0] = '\0'; } // 用C风格字符串构造 string(const char* str) { assert(str != nullptr); // 防御性编程,防止传入空指针 _size = strlen(str); _capacity = _size; _str = new char[_capacity + 1]; // +1 给'\0' strcpy(_str, str); } // 拷贝构造函数(深拷贝) string(const string& s) { _size = s._size; _capacity = s._capacity; _str = new char[_capacity + 1]; strcpy(_str, s._str); } // 析构函数 ~string() { delete[] _str; _str = nullptr; _size = _capacity = 0; } // 获取C风格字符串(常量版本) const char* c_str() const { return _str; } // 获取大小 size_t size() const { return _size; } // 获取容量 size_t capacity() const { return _capacity; } // 判断是否为空 bool empty() const { return _size == 0; } }; } // namespace my要点解析:
- 我们在堆上分配内存时,总是分配
_capacity + 1个字节,多出的那个字节专门用于存放字符串结束符\0。这保证了c_str()总能返回一个合法的C风格字符串。 - 析构函数中,在
delete[]之后,将指针置为nullptr是一个好习惯,可以防止后续误用成为悬空指针。虽然这里对象即将销毁,但养成这个习惯很重要。 - 所有不修改对象状态的成员函数(如
size(),c_str())都声明为const,这既是良好的设计,也使得常量对象能调用这些函数。
4.2 实现reserve和push_back
接下来实现动态扩容的核心reserve和基础的添加字符操作push_back。
// 在类内部继续添加成员函数 void reserve(size_t n) { if (n > _capacity) { char* tmp = new char[n + 1]; // 申请新空间 strcpy(tmp, _str); // 拷贝旧数据 delete[] _str; // 释放旧空间 _str = tmp; _capacity = n; } // 如果 n <= _capacity,什么都不做 } void push_back(char ch) { if (_size == _capacity) { // 如果容量已满,需要扩容。如果当前容量为0,则扩容到4(或1),否则翻倍。 // 这是常见的策略,避免频繁扩容。 size_t new_capacity = (_capacity == 0) ? 4 : _capacity * 2; reserve(new_capacity); } _str[_size] = ch; ++_size; _str[_size] = '\0'; // 别忘了添加新的结束符 }要点解析:
reserve的实现体现了“先申请新,再释放旧”的安全原则。push_back中的扩容策略(从0到4,之后翻倍)是std::vector等容器的典型策略。你可以调整初始值和增长因子(比如1.5倍),但翻倍是一个简单有效的选择,能在空间和时间效率之间取得平衡。- 每次修改字符串内容后,都必须手动维护结尾的
\0,这是C风格字符串的规则,我们的MyString在底层依赖它。
4.3 实现append和operator+=
有了push_back,实现追加字符串就简单了。append是基础,operator+=可以复用append。
// 追加一个C风格字符串 string& append(const char* str) { size_t len = strlen(str); if (_size + len > _capacity) { // 确保容量足够,通常也是按需扩容(比如至少扩大到 _size+len) reserve(_size + len); } strcpy(_str + _size, str); // 从原字符串结尾开始拷贝 _size += len; // strcpy已经拷贝了str的结束符,所以这里不需要再添加'\0' return *this; // 返回自身引用,支持链式调用 } // 追加一个MyString对象 string& append(const string& s) { return append(s.c_str()); // 复用上面的版本 } // 重载 += 运算符(字符版本) string& operator+=(char ch) { push_back(ch); return *this; } // 重载 += 运算符(C字符串版本) string& operator+=(const char* str) { append(str); return *this; } // 重载 += 运算符(MyString版本) string& operator+=(const string& s) { append(s); return *this; }要点解析:
append和operator+=都返回string&,这是为了支持链式编程,例如s1.append(s2).append(s3)或s1 += s2 += s3。strcpy(_str + _size, str)这行代码很关键。_str + _size是一个指针运算,它指向原字符串的末尾(即\0的位置)。strcpy会从这里开始覆盖,将str的内容(包括其自身的\0)拷贝过来,从而完成拼接。
4.4 实现拷贝赋值运算符
现在我们来攻克最复杂的拷贝赋值运算符。我们将采用一种更现代、更安全、更简洁的写法,它巧妙地利用了拷贝构造函数。
// 拷贝赋值运算符的“现代写法” string& operator=(string s) { // 注意!这里参数是传值,而不是常引用 swap(s); // 交换当前对象和临时对象s的资源 return *this; } // 需要一个swap成员函数来交换两个对象的所有成员 void swap(string& s) { // 使用标准库的std::swap来交换每个成员 std::swap(_str, s._str); std::swap(_size, s._size); std::swap(_capacity, s._capacity); }为什么这种写法更优秀?
- 自动处理自赋值:参数
s是传值,如果发生自赋值a = a,那么会调用拷贝构造函数生成一个a的副本(临时对象s)。然后swap(*this, s)交换了当前对象和这个副本的资源。最后,临时对象s在函数结束时被析构,释放掉的是当前对象原来的旧资源。完美! - 强异常安全保证:所有的资源分配(
new)都发生在拷贝构造函数中。如果分配失败,拷贝构造函数会抛出异常,这个异常会直接传播到operator=的调用者,而当前对象*this的状态完全没有被改变。 - 代码简洁:避免了手动检查自赋值、手动分配和释放内存的繁琐逻辑。
这种写法是C++中“拷贝-交换”(copy-and-swap)惯用法的体现,是编写安全赋值运算符的推荐方法。你需要做的就是实现一个不抛异常的swap函数和一个正确的拷贝构造函数。
4.5 实现operator[]和insert/erase
访问和修改特定位置的字符,以及插入和删除操作。
// 重载下标运算符(非常量版本),允许修改 char& operator[](size_t pos) { assert(pos < _size); // 检查下标越界 return _str[pos]; } // 重载下标运算符(常量版本),用于常量对象,不允许修改 const char& operator[](size_t pos) const { assert(pos < _size); return _str[pos]; } // 在pos位置插入一个字符 string& insert(size_t pos, char ch) { assert(pos <= _size); // 允许在末尾插入(pos == _size) if (_size == _capacity) { size_t new_capacity = (_capacity == 0) ? 4 : _capacity * 2; reserve(new_capacity); } // 将pos及之后的字符向后移动一位 // 注意:要从后往前移动,避免覆盖数据 for (size_t i = _size; i > pos; --i) { _str[i] = _str[i - 1]; } _str[pos] = ch; ++_size; _str[_size] = '\0'; return *this; } // 在pos位置插入一个C风格字符串 string& insert(size_t pos, const char* str) { assert(pos <= _size); size_t len = strlen(str); if (_size + len > _capacity) { reserve(_size + len); } // 将原字符串从pos开始的部分向后移动len位 for (size_t i = _size + len; i > pos + len - 1; --i) { // 注意循环条件 _str[i] = _str[i - len]; } // 拷贝插入的字符串 for (size_t i = 0; i < len; ++i) { _str[pos + i] = str[i]; } _size += len; // 移动操作覆盖了原结尾符,新结尾符在移动时已经处理好 return *this; } // 从pos位置开始删除len个字符 string& erase(size_t pos, size_t len = npos) { assert(pos < _size); if (len == npos || pos + len >= _size) { // 如果len是npos或者要删除到末尾,直接截断 _str[pos] = '\0'; _size = pos; } else { // 否则,将后面的字符向前移动覆盖 for (size_t i = pos; i < _size - len; ++i) { _str[i] = _str[i + len]; } _size -= len; _str[_size] = '\0'; } return *this; }要点解析:
- 我们提供了
operator[]的两个版本,以同时支持非常量对象(可修改)和常量对象(只读)的下标访问。这是C++标准库容器的常见做法。 insert和erase涉及元素的移动。移动时必须注意方向:向后移动元素时要从后往前遍历,防止数据被覆盖;向前移动时可以从前往后。画个图能帮助你理清下标关系。erase的默认参数npos表示删除到字符串末尾,这是一个常用的设计。
4.6 实现find和substr
最后实现两个常用的字符串查找和子串操作。
// 从pos位置开始查找字符ch size_t find(char ch, size_t pos = 0) const { assert(pos <= _size); for (size_t i = pos; i < _size; ++i) { if (_str[i] == ch) { return i; } } return npos; } // 从pos位置开始查找C风格字符串str size_t find(const char* str, size_t pos = 0) const { assert(pos <= _size && str != nullptr); // 这里我们简单实现,可以使用strstr库函数,但为了教学,手动实现一个 // 实际中更推荐用KMP等高效算法,这里用朴素匹配 size_t len = strlen(str); if (len == 0) return pos <= _size ? pos : npos; // 空串总是被“找到” if (pos + len > _size) return npos; for (size_t i = pos; i <= _size - len; ++i) { size_t j = 0; for (; j < len; ++j) { if (_str[i + j] != str[j]) { break; } } if (j == len) { return i; // 匹配成功 } } return npos; } // 返回从pos开始,长度为len的子串 string substr(size_t pos = 0, size_t len = npos) const { assert(pos <= _size); size_t real_len = len; if (len == npos || pos + len > _size) { real_len = _size - pos; } string sub; sub.reserve(real_len); // 预分配空间,提高效率 for (size_t i = 0; i < real_len; ++i) { sub.push_back(_str[pos + i]); } return sub; }要点解析:
find函数我们实现了最简单的朴素匹配算法。在实际的std::string中,查找算法可能更优化。对于学习目的,理解其工作原理即可。substr函数中,我们创建了一个新的string对象sub,并利用reserve预分配了足够空间,然后逐个字符push_back。最后返回这个局部对象。这里涉及到一个重要的C++优化:返回值优化(RVO)。编译器通常会优化掉这里的拷贝,直接在新对象sub的位置构造返回值,效率很高。
5. 常见问题与排查技巧实录
自己实现一个类,调试是必不可少的环节。下面是我在实现和教学过程中,学生们最常遇到的几个问题。
5.1 程序崩溃:访问违规或双重释放
- 症状:程序运行中突然崩溃,调试器提示“Access Violation”或“Segmentation fault”,或者在析构时崩溃。
- 可能原因及排查:
- 浅拷贝问题:这是头号杀手。检查你的拷贝构造函数和赋值运算符是否实现了深拷贝。一个快速的测试方法是:创建一个
MyString对象s1,然后用它初始化s2(MyString s2(s1);),接着修改s2的内容,最后观察s1是否被意外修改。如果被修改,就是浅拷贝。 - 指针未初始化或野指针:在构造函数中,确保
_str被正确初始化(即使是空字符串,也应指向一个包含\0的内存块)。在析构函数delete[]之后,将_str置为nullptr是个好习惯。 - 下标越界:在
operator[]、insert、erase等函数中,务必使用assert检查pos参数是否在有效范围[0, _size)内(对于insert,允许pos == _size)。 - 自赋值处理不当:在传统的
operator=实现中,忘记检查if(this != &s)会导致在释放自身内存后又试图访问它。使用我们介绍的“现代写法”可以天然避免这个问题。
- 浅拷贝问题:这是头号杀手。检查你的拷贝构造函数和赋值运算符是否实现了深拷贝。一个快速的测试方法是:创建一个
5.2 字符串内容乱码或丢失
- 症状:字符串打印出来是乱码,或者末尾多了奇怪的字符,或者内容不全。
- 可能原因及排查:
- 忘记维护结尾的
\0:这是最常见的原因。任何修改字符串长度的操作(push_back,append,insert,erase,=等)之后,都必须确保_str[_size] = '\0'。一个检查方法是:在每次修改_size的地方,都跟上一句设置结束符的代码。 - 内存分配大小错误:记住,分配的大小是
_capacity + 1。在reserve、拷贝构造等地方,检查你的new char[...]表达式是否正确。 strcpy使用不当:strcpy会一直拷贝直到遇到源字符串的\0。确保目标缓冲区足够大,并且源字符串是合法的(以\0结尾)。在我们的实现中,源字符串来自我们自己的_str或合法的C字符串,所以重点是保证目标缓冲区大小。
- 忘记维护结尾的
5.3 性能问题:频繁重新分配内存
- 症状:当大量使用
+=或push_back时,程序运行速度很慢。 - 可能原因及排查:
- 扩容策略过于保守:如果你的
push_back在每次_size == _capacity时只扩容1个字符,那么连续插入n个字符的时间复杂度是O(n²)。检查并实现我们提到的翻倍(或1.5倍)扩容策略。 - 未使用
reserve预分配:如果你事先知道最终字符串的大致长度,应该在操作前调用reserve一次性分配足够内存,避免中间多次扩容。例如:my::string s; s.reserve(1000); for(int i=0; i<1000; ++i) s.push_back('a');
- 扩容策略过于保守:如果你的
5.4 关于const正确性
- 问题:定义了一个
const my::string对象,却无法调用size()或c_str()。 - 解决:确保所有不修改对象状态的成员函数都声明为
const,例如:size_t size() const;。这样常量对象和非常量对象都能调用它们。
5.5 高级话题:移动语义(C++11)
虽然我们的主要目标是理解拷贝语义,但了解移动语义能让你的类在现代C++中效率更高。移动语义的核心是“资源转移”而非“资源拷贝”。
移动构造函数:
// 移动构造函数 string(string&& s) noexcept // && 表示右值引用,noexcept 声明不抛异常 : _str(s._str), _size(s._size), _capacity(s._capacity) { s._str = nullptr; // 关键!将源对象置于有效但空的状态 s._size = s._capacity = 0; }移动赋值运算符:
// 移动赋值运算符 string& operator=(string&& s) noexcept { if (this != &s) { delete[] _str; // 释放自己的旧资源 _str = s._str; _size = s._size; _capacity = s._capacity; s._str = nullptr; s._size = s._capacity = 0; } return *this; }当发生my::string s2 = std::move(s1);(s1是即将消亡的临时对象)时,移动构造函数会被调用,它直接“窃取”了s1内部的指针(资源),然后将s1的指针置空。这个过程没有深拷贝,效率极高。实现了移动语义后,我们的“现代写法”operator=会变得更加高效,因为传参时如果实参是右值,会优先匹配移动构造函数而不是拷贝构造函数。
手撕一个完整的string类是一项艰巨但收获巨大的工程。它强迫你去思考C++中关于对象生命周期、资源管理、接口设计的最基本问题。当你调试通最后一个bug,看着自己的MyString像std::string一样工作时,那种成就感是无与伦比的。我建议你在实现过程中,多写测试用例,覆盖边界情况(空串、自赋值、大量数据等),并使用调试器一步步跟踪,观察内存和变量的变化,这比读十遍理论都管用。最后,不妨对比一下你的实现和标准库的实现(如果用的是GCC或Clang,可以查看其源码),看看工业级的代码在异常安全、算法优化、内存分配器等方面做了哪些更精细的处理,这会是学习的下一个台阶。