☰
手写C++ string:从内存管理到增删查改的完整实现
2026/10/10 20:34:48 网站建设 项目流程

说实话,接触C++这么多年,我一直有一种“被STL惯坏”的感觉。尤其是std::string,用起来太顺手了,+、find、substr、replace,想怎么拼就怎么拼,以至于我从来没认真想过,这个类在底层到底是怎么管理内存的。

直到有次线上排查一个诡异的字符串乱码问题,我盯着代码里那段经过多次insert和erase的操作,才意识到自己对 string 的内部机制了解得多么肤浅。为了彻底搞明白,我干脆从零手写了一个string的增删查改模拟实现,把_str、_size、_capacity三个成员变量,到扩容、内存搬移、深浅拷贝,全部走了一遍。这篇文章就是这次手写过程的完整记录。

如果你只想当一个普通的C++使用者,那std::string完全够用;但如果你想搞懂背后的原理,或者准备面试时手撕代码,这篇文章应该能帮你少走不少弯路。

1. 为什么要手写 string:被 STL 惯坏的人的自救

1.1 用了一年 std::string,我却答不出这三个问题

先说个真实经历。有次刷题,我用std::string写了个字符串拼接,代码大概长这样:

std::string result; for (int i = 0; i < 100000; ++i) { result += "some text"; }

程序跑起来了,就是有点慢。我随口问了一句:“如果我自己实现一个动态字符串,应该怎么扩容才高效?”

同事直接反问:“你觉得std::string内部是怎么扩容的?插入和删除的时候,内存是怎么搬的?”

我当场愣住。说实话,我当时只模糊知道 string 底层是“字符数组”,但再往下一层——什么时候扩容、搬移数据时为什么必须从后往前、erase之后会不会缩容、两个 string 赋值到底是浅拷贝还是深拷贝——我一个都答不全。

那次对话之后,我决定自己动手写一个string的模拟实现。在我眼里,这是破除 STL 黑盒心态最直接的办法。你不需要把整个标准库复刻一遍,只需要把使用频率最高的增、删、查、改四条主线完整写出来,底层的内存管理逻辑就全暴露在眼前了。

1.2 手写 string 的正确姿势:锁定增删查改四条主线

我给自己划定的目标是:不追求和std::string100%兼容,不做分配器,不搞复杂的迭代器,只实现一个能用的MyString类,并且把以下四个核心能力完整落地:

  • 增:push_back、append、insert
  • 删:pop_back、erase
  • 查:find、rfind、substr
  • 改:operator[]、replace、赋值运算符

为什么选这四条线?因为字符串操作翻来覆去就是这四件事。把这四条线实现出来,你会碰到动态数组最核心的三大问题:内存扩容、数据搬移、生命周期管理。这三个问题搞清楚了,你再看std::vector、std::string的源码,思路就顺畅多了。

有人可能会说:“标准库源码就在那里,直接看不行吗?”我的体会是,直接看源码和手写一遍完全是两码事。源码里有大量宏定义、内存分配策略、SSO优化,初学者很容易被劝退。而自己从零开写,遇到一个问题解决一个问题,那些概念才会真正变成你自己的。

2. 先搭骨架:三个成员变量和一个动态数组

2.1 成员设计:为什么是 _str、_size、_capacity 三件套

说干就干。写MyString之前,我先确定了类的基本骨架:

class MyString { public: typedef char* iterator; typedef const char* const_iterator; static const size_t npos = -1; MyString(const char* str = ""); MyString(const MyString& s); MyString& operator=(const MyString& s); ~MyString(); // 增 void push_back(char ch); void append(const char* str); MyString& insert(size_t pos, char ch); MyString& insert(size_t pos, const char* str); // 删 void pop_back(); MyString& erase(size_t pos = 0, size_t len = npos); // 查 size_t find(char ch, size_t pos = 0) const; size_t find(const char* str, size_t pos = 0) const; size_t rfind(const char* str, size_t pos = npos) const; MyString substr(size_t pos = 0, size_t len = npos) const; // 改 char& operator[](size_t pos); const char& operator[](size_t pos) const; MyString& replace(size_t pos, size_t len, const char* str); const char* c_str() const; size_t size() const; size_t capacity() const; bool empty() const; void swap(MyString& other) noexcept; private: char* _str; size_t _size; size_t _capacity; void reserve(size_t newCapacity); };

三个成员变量的含义我建议在一开始就定清楚,不然后面全是坑:

  • _str:指向堆上动态分配的字符数组的指针。
  • _size:当前有效字符个数,不包含结尾的'\0'。
  • _capacity:当前能容纳的有效字符个数上限,也不包含结尾的'\0'。

也就是说,实际开出来的堆内存大小是_capacity + 1字节,多出来的那一个专门放字符串结束符。这跟标准库的语义接近:size()返回的是有效字符数,capacity()表示在扩容之前最多还能塞多少字符。

为什么不用固定数组,比如char _str[100]?因为字符串长度是动态的,固定数组要么浪费,要么不够。更重要的是,固定数组的生命周期跟着对象走,无法在堆上自由调整大小,也就根本不可能实现动态扩容。所以必须用char*指针 +new[]/delete[]。

2.2 构造函数、析构函数与深浅拷贝的岔路口

构造函数很简单,但有几个细节需要特别注意。先看代码:

MyString::MyString(const char* str) : _size(strlen(str)), _capacity(_size) { _str = new char[_capacity + 1]; strcpy(_str, str); }

我故意把_capacity初始化为_size,而不是预留更多空间。这样第一次push_back时就会触发扩容,能够最直观地演示扩容机制是怎么运作的。

析构函数就一行,但这一行承载着整个类的内存安全底线:

MyString::~MyString() { delete[] _str; }

真正的重头戏是拷贝构造函数。我先说一个很多初学者踩过的坑:如果你不写拷贝构造,编译器会生成一个默认版本,这个默认版本做的事情是逐个成员复制。也就是说,新对象的_str指针会直接指向旧对象的同一块堆内存。

这会造成什么后果?两个对象共享同一块内存,任何一方修改数据,另一方都会感知;更致命的是,析构时双方都会执行delete[] _str,同一块内存被释放两次,程序直接崩溃。这就是典型的“浅拷贝”。

正确写法必须是“深拷贝”,也就是为新对象单独分配一块内存,把数据完整复制过去:

MyString::MyString(const MyString& s) : _size(s._size), _capacity(s._capacity) { _str = new char[_capacity + 1]; strcpy(_str, s._str); }

有印象的老读者可能记得,老版C++里还有一种“写时拷贝”的实现思路,就是刚开始大家共享内存,只有某个对象要修改数据时才真正复制。听起来很美,但羊群效应太严重,而且需要原子操作来维护引用计数,线程安全方面开销不小。现代std::string的主流实现更倾向于“小字符串优化”,后面我会单独聊。

2.3 扩容策略:为什么按倍数扩容而不是每次加一个位置

动态字符串最核心的机制之一就是扩容。假如现在_size等于_capacity,你又要往里塞一个字符,内存不够了怎么办?

一个很自然的想法是:每次都多分配一个字符的位置。但这样做的效率极其糟糕。假设要从空字符串开始连续插入一万个字符,每次插入都会触发一次new[]、一次内存复制、一次delete[],总复杂度是 O(n²)。这在实际场景里是完全不可接受的。

正确的做法是按倍数扩容。我们来看reserve的实现:

void MyString::reserve(size_t newCapacity) { if (newCapacity > _capacity) { char* newStr = new char[newCapacity + 1]; if (_str) { strcpy(newStr, _str); delete[] _str; } _str = newStr; _capacity = newCapacity; } }

每次容量不够时,按照当前容量的 2 倍重新分配:

if (_size + 1 > _capacity) { reserve(_capacity == 0 ? 4 : _capacity * 2); }

为什么是翻倍而不是 1.5 倍或者 2.5 倍?核心思路都一样:让扩容的次数变成 O(log n) 级别,整体均摊下来,每次追加操作的时间复杂度是 O(1) 的。选择 2 倍纯粹是代码简单、位运算快,而且在实际测试中表现稳定。有些库会用 1.5 倍,主要是为了更充分地复用之前释放的内存块,避免频繁向操作系统申请新地址;但在教学实现里,2 倍是最直观的选择。

这里打个比方:扩容就像搬家。如果你每次只比之前多买一间房,那每增加一个家庭成员就得搬一次家;如果你一次性把房子换成面积翻倍,虽然前期投入大,但很长一段时间都不用再折腾了。扩容的本质就是用空间换时间。

写到这里,骨架就算搭好了。接下来进入正题,把增删查改四个功能逐一实现。

3. 增:从 push_back 到 insert,扩容与数据搬移的全过程

3.1 push_back:最朴素追加背后的扩容时机

追加单个字符是最简单的“增”,但它是理解扩容机制的最佳入口。

void MyString::push_back(char ch) { if (_size + 1 > _capacity) { reserve(_capacity == 0 ? 4 : _capacity * 2); } _str[_size] = ch; ++_size; _str[_size] = '\0'; }

代码逻辑很直白:先检查是否需要扩容,然后把字符放到_size位置,自增,再补上结束符。这里我要特别强调最后一行_str[_size] = '\0'。

我最初写的时候漏了这行,结果字符串后面总是带着一截莫名其妙的旧数据。因为strlen是靠扫描'\0'来确定字符串长度的,找不到结束符就会一直往后读,读到别人的内存区域,轻则乱码,重则越界崩溃。所以动态字符串里,任何改变长度的操作,最后都必须保证新末尾有一个'\0'。

3.2 append:批量追加与扩容预判

追加单个字符用push_back,追加一串字符串就要用append了:

void MyString::append(const char* str) { size_t len = strlen(str); if (_size + len > _capacity) { reserve(_size + len); } memcpy(_str + _size, str, len + 1); _size += len; }

注意这里的扩容方式和push_back不一样。因为append是知道要追加多少数据的,所以直接reserve(_size + len),一次性把容量扩到位,避免做多次无效扩容。这就是批量操作的思路:能预判长度,就精确分配,别傻乎乎地按倍数撞运气。

memcpy的最后一个参数我写的是len + 1,为什么?因为要把字符串末尾的'\0'一起拷贝过去,保证新字符串合法结束。这一步是很多新手容易忽略的。

3.3 insert:任意位置插入,为什么必须从后往前搬数据

push_back和append都是在尾部操作,难度不大。真正的考验是insert——在任意位置插入,涉及内存搬移。

先看插入单个字符:

MyString& MyString::insert(size_t pos, char ch) { assert(pos <= _size); if (_size + 1 > _capacity) { reserve(_capacity == 0 ? 4 : _capacity * 2); } size_t end = _size + 1; while (end > pos) { _str[end] = _str[end - 1]; --end; } _str[pos] = ch; ++_size; _str[_size] = '\0'; return *this; }

搬运数据这一段,我建议你亲手在纸上画一下。假设字符串是hello,_size是 5,'\0'在下标 5。要在下标 2 的位置插入X,最终结果应该是heXllo。

画一遍就会发现:需要搬移的数据不只是llo,连末尾的'\0'也必须一起往后挪。所以搬运起点是_size + 1,也就是原来'\0'所在位置的下标加 1。

还有个关键点:必须从后往前搬。

如果从前往后搬,比如先把下标 2 的数据搬到 3,再把下标 3 的数据搬到 4,你会发现下标 3 的原始数据已经被覆盖了,后面搬的全是错的值。从后往前搬则没有这个问题,先把最后面的数据挪走,再挪前面一点的,就像移积木时先把顶上的拿走,底下的才露得出来。

接着是插入一整段字符串。逻辑稍微复杂一点,但核心思路还是“从后往前搬”:

MyString& MyString::insert(size_t pos, const char* str) { assert(pos <= _size); size_t len = strlen(str); if (_size + len > _capacity) { reserve(_size + len); } size_t end = _size + 1; while (end > pos) { _str[end + len - 1] = _str[end - 1]; --end; } for (size_t i = 0; i < len; ++i) { _str[pos + i] = str[i]; } _size += len; _str[_size] = '\0'; return *this; }

这里第一个循环的作用是把[pos, _size]区间内的所有字符(包括'\0')整体向后平移len个位置,把pos开始的区间空出来;第二个循环再把新字符串复制进去。

insert返回*this也是一个有讲究的设计。这样调用方可以写链式表达式,比如s.insert(1, "A").insert(3, "B")。真实的std::string接口也是这么设计的,我建议自己实现的版本也保持这个习惯。

4. 删:erase 里藏着 memmove 与缩容权衡

4.1 pop_back 的懒惰式删除

删除操作里最温柔的是pop_back:

void MyString::pop_back() { assert(_size > 0); --_size; _str[_size] = '\0'; }

它并不真正把最后一个字符“擦掉”,而只是把_size减 1,然后在新末尾补上'\0'。那个字符的数据还留在内存里,但后续所有逻辑都不会再读到它。这种“懒惰删除”的思路在STL里很常见——std::vector::pop_back也不会清掉元素值,因为清掉还要多花一次赋值操作,毫无必要。

4.2 erase 的三种分支逻辑

真正的挑战在erase。它的语义是:从pos位置开始,连续删除len个字符。如果len删到头了,或者len是npos,那就直接截断。

MyString& MyString::erase(size_t pos, size_t len = npos) { assert(pos < _size); if (len >= _size - pos) { _size = pos; _str[_size] = '\0'; return *this; } memmove(_str + pos, _str + pos + len, _size - pos - len + 1); _size -= len; return *this; }

这段代码有两个关键点。

第一,分支判断。如果len大于等于从pos到末尾的距离,说明要删的已经包含剩余全部字符了,这时候根本不用搬数据,直接把_size改成pos,补个结尾符,完事。

第二,中间删除时用memmove把后面的数据整体向前搬。

memmove的第三个参数是_size - pos - len + 1,这个 +1 又是为了带上'\0'。比如字符串有 10 个字符,从下标 2 删 3 个,剩余有效字符是 5 个,末尾的'\0'也要一起往前挪,所以总共搬 6 个字节。

4.3 memmove 与 memcpy:重叠内存不是玄学

我专门把memmove拎出来说,是因为这里藏着一个特别经典的坑。

C语言标准明确规定:memcpy在源和目标内存区域重叠时,行为是未定义的。而erase恰恰就是一个典型的重叠场景——源数据和新位置有大量交集。你可能会说:“我机器上跑memcpy也是对的啊。”那只能说明你的编译器/平台在这个特定场景下碰巧处理对了,换个编译器,换一个数据规模,可能就直接出错。

memmove就是为了解决重叠问题而生的,它能保证即使源和目标有重叠,也能正确完成拷贝。实现上可能多一层判断和临时缓冲,但安全第一,这里必须用memmove。

顺便回答一个很多人会问的问题:erase之后要不要缩容?

真实世界的std::string在erase后通常不会缩小容量。容量是提前换来的“空间冗余”,一旦缩容,就要重新分配内存、复制数据,下次再插入又得触发扩容,一缩一扩等于白折腾。我们的模拟实现也保持这个策略:erase只动_size,不动_capacity。如果你真的需要释放多余内存,C++11 里有个shrink_to_fit()可以做这件事,但标准也不保证它一定生效,把它当成“给实现的一个建议”就好。

5. 查:find 实现朴素的模式匹配,substr 返回新对象

5.1 find 从朴素匹配开始

字符串的“查”,最常见的就是找子串位置。我实现的第一版find用了最朴素的两层遍历:

size_t MyString::find(const char* str, size_t pos = 0) const { size_t len = strlen(str); if (len == 0) return pos; if (pos + len > _size) return npos; for (size_t i = pos; i + len <= _size; ++i) { size_t j = 0; while (j < len && _str[i + j] == str[j]) { ++j; } if (j == len) { return i; } } return npos; }

这个算法的时间复杂度是 O(n × m),其中 n 是字符串长度,m 是子串长度。对字符串查找来说不算快,但胜在直观、不容易写错,作为教学版本非常合适。

很多数据结构和算法教材一讲到字符串匹配就直接上 KMP,但我个人觉得,学 KMP 之前应该先把朴素匹配写一遍。只有亲手写出了“匹配失败要回头重新比”的过程,才能理解 KMP 里的next数组到底优化掉了什么。

find(char ch, size_t pos)是上面函数的一种更简单特例,本质就是单层循环,这里不再展开。

5.2 边界与返回值:npos 它凭什么写成 -1

代码里出现的npos,定义是static const size_t npos = -1;。

初看觉得匪夷所思:npos不是应该是个正的大数吗,怎么写成-1?原理其实很简单:-1先被转换成size_t,也就是无符号整数类型,而无符号数里-1会被转成当前类型的最大值,也就是0xFFFFFFFFFFFFFFFF(64位系统上)。这个数比任何合法字符串位置都大,所以可以用“查不到”的哨兵值。

写到这里顺便提一个 C++ 老手也容易搞混的知识点:static const size_t npos = -1;在类内初始化是允许的,因为它是整数类型的常量表达式。但如果程序里真的“使用”了这个成员(比如取它的地址),C++17 以前可能还需要在类外补一个定义,否则链接时会报错。自己玩不涉及这些,但面试聊起来,知道这个细节会加分不少。

rfind是从后往前找,实现思路就是把find的循环方向反过来。它的细节处理和find类似,考虑到文章篇幅,这里就不贴完整代码了,核心逻辑是:先定位开始搜索的位置,然后倒着移动起点,每次比较都从当前起点向后逐个字符对比。

5.3 substr 截断与深拷贝返回

substr返回的是一个全新的字符串对象,这个语义很多人没仔细想过:

MyString MyString::substr(size_t pos, size_t len) const { assert(pos <= _size); if (len == npos || pos + len > _size) { len = _size - pos; } char* buf = new char[len + 1]; memcpy(buf, _str + pos, len); buf[len] = '\0'; MyString result(buf); delete[] buf; return result; }

这里会顺手处理一个边界情况:如果len太长,超出字符串尾部,那就截断到字符串末尾,而不是报错。这也符合标准库的行为。

拿到substr返回的对象后,你任意修改它,都不会影响原字符串。这就是“值语义”——传出来的是一份独立的拷贝,不是共享内存。这也是为什么substr必须走一遍深拷贝流程,如果不深拷贝,你改返回对象的时候原字符串就会莫名其妙跟着变,这个 bug 非常难排查。

6. 改:replace 的组合实现与 operator= 的自我救赎

6.1 replace:erase + insert 组合拳

“改”这个动作,最容易想到的方式就是先删后插:

MyString& MyString::replace(size_t pos, size_t len, const char* str) { assert(pos < _size); if (len > _size - pos) { len = _size - pos; } erase(pos, len); insert(pos, str); return *this; }

这个组合拳的实现非常简洁。先把[pos, pos+len)区间删掉,再在pos的位置插入新字符串,内部的数据搬移逻辑完全复用前面写的erase和insert。

它的缺点也很明显:如果新串长度和旧串长度相差很多,中间会经历一次不必要的数据搬移。比如把 3 个字符替换成 100 个字符,先删后插意味着数据要挪两次。

但作为一个教学版本,先“把功能做对”,再“把性能做好”,顺序不能反。真要在生产环境追求极致,可以改成直接在新容量上一次性处理,但代码复杂度会成倍上升,而且很容易引入内存边界问题。我的建议是:先拿下组合拳,等遇到真实性能瓶颈了,再针对性优化。

6.2 operator=:自赋值、深拷贝、异常安全的三重难题

赋值运算符是所有手写类里最容易写错的函数,没有之一。它要同时解决三个问题。

先看我最开始的实现:

MyString& MyString::operator=(const MyString& s) { if (this != &s) { char* newStr = new char[s._capacity + 1]; strcpy(newStr, s._str); delete[] _str; _str = newStr; _size = s._size; _capacity = s._capacity; } return *this; }

这个版本已经处理了自赋值判断和深拷贝,基本能工作。但它有一个隐藏的异常安全问题:如果new char[s._capacity + 1]抛异常(内存不足时会发生),对象会保持原样,这倒没问题;但如果在delete[] _str之后再发生点什么问题,旧数据就已经没了。虽然这里逻辑简单不容易出岔子,但工程上更优雅的写法是copy-and-swap:

void MyString::swap(MyString& other) noexcept { std::swap(_str, other._str); std::swap(_size, other._size); std::swap(_capacity, other._capacity); } MyString& MyString::operator=(const MyString& s) { if (this != &s) { MyString tmp(s); // 深拷贝一个临时对象 swap(tmp); // 把临时对象的数据“偷”过来 } return *this; }

这段代码的精妙之处在于:

  1. tmp(s)如果抛异常,当前对象毫发无损,异常安全得到保障。
  2. swap只交换指针和整数,不涉及内存分配,理论上不会失败。
  3. tmp走出作用域销毁时,会连原本属于旧对象的那块堆内存一起释放掉,自动完成“释放旧资源”。

自赋值问题也被天然兜住了:就算this == &s,大不了多一次深拷贝和交换,结果依然正确,只是多花了一点时间。

6.3 operator[]:改字符的唯一入口,别忘了 const 重载

“改”的另一个维度是改单个字符,入口就是operator[]:

char& MyString::operator[](size_t pos) { return _str[pos]; } const char& MyString::operator[](size_t pos) const { return _str[pos]; }

注意这里必须提供两个重载版本。非const版本返回char&,这样才能让s[0] = 'A'这种表达式成为可能——用户拿到的是字符的引用,往里写值的操作会直接作用到内部数组上。const版本返回const char&,保证只读不写。

我之前见过有人直接写char operator[](size_t pos),返回字符的值。这样s[0]确实能读到字符,但s[0] = 'A'会直接编译失败,因为一个表达式返回的是右值,不允许赋值。所以“返回引用”不是可选的优化,而是让operator[]具备修改能力的必要条件。

7. 避坑实录:迭代器失效、多重释放与调试经验

7.1 迭代器失效:扩容后旧指针全变“野指针”

我们自己写的MyString还没有正式实现迭代器,但c_str()和&s[0]返回的指针,本质上就是指向内部缓冲区的迭代器。这里存在一个特别容易出事的坑:任何可能触发扩容的操作,都会让之前拿到的指针/迭代器全部失效。

举个典型场景:

MyString s("hello"); char* p = s.c_str(); s.push_back('!'); // 如果这次触发了扩容,p 就已经是野指针了

为什么?因为扩容时会把老内存delete[]掉,然后从新的堆位置分配一块更大的内存。p还傻傻地指着那块已经被释放的地址,你再通过p去读数据,轻则读到被覆盖的乱码,重则直接崩溃。

这和你用std::vector时遇到的迭代器失效是一个道理。真正的std::string把迭代器封装成了一个类,使用者往往感知不到内部指针的变化;但一旦你直接操作c_str()返回的裸指针,这个责任就回到了你自己身上。凡是做可能扩容的操作,之后就不要再使用旧指针了,必须重新获取。

7.2 多重释放:没有写拷贝构造的下场

我在写骨架的时候反复强调深拷贝,因为浅拷贝导致的“多重释放”是C++新手最容易犯的致命错误。这里用一个具象例子说明:

假设你没有实现拷贝构造,那么下面这行代码会出大问题:

MyString a("hello"); MyString b = a; // 调用默认拷贝构造,b._str 和 a._str 指向同一块内存

函数结束,a和b各自析构,同一个delete[] _str被执行两次。堆管理器把同一块内存释放两次,属于典型的 heap corruption,具体表现可能是崩在这里,也可能是崩在完全不相干的地方,非常难排查。

对策就两条路:要么用= delete禁用拷贝,让这种代码编译期就报错;要么老老实实实现深拷贝。既然是模拟 string,肯定要支持拷贝的,那就把拷贝构造和赋值运算符都写到位。

7.3 调试建议:VSCode 断点 + AddressSanitizer

手写这种涉及内存管理的类,调试工具一定要用好。我在 VSCode 里调这个MyString时,最常用的三个手段:

第一,断点加监视。在reserve函数里打上断点,然后把_str加入监视变量,查看它的地址。每次扩容后对比地址是否变化,就能直观看到“扩容后旧指针失效”这一现象。

第二,使用 AddressSanitizer。编译时加上-fsanitize=address,undefined -g -O0,一旦程序里出现越界、双重释放,运行时立刻会打出完整的错误调用栈。这比自己在代码里瞎猜要高效得多。我的编译命令长这样:

g++ -std=c++11 -g -O0 -fsanitize=address,undefined main.cpp -o main

第三,小规模逐步打印。写一个简单的print()辅助函数,把_str、_size、_capacity都打出来。在每次push_back、erase之后观察三者的变化,很容易发现逻辑错误。尤其是_size忘了自增、'\0'位置不对这类问题,打印出来一眼就能看出来。

8. 完整测试与后续扩展思路

8.1 测试用例设计:把增删查改四个功能测到位

功能写完后,测试才是真正检验代码有没有问题的关键。我的测试思路很简单:每一类操作都覆盖正常情况、边界情况和失败情况。这里列一个清单:

功能必测用例边界情况
构造普通字符串、空字符串拷贝构造、多次构造析构
增push_back 单个字符、append 字符串、insert 到中间insert 到 0 位置、insert 到末尾
删pop_back、erase 中间一段erase 到末尾、erase len=npos
查find 存在子串、substr 正常截取find 不存在、find 空串、substr 越界截断
改replace 短换长、长换短、operator[] 修改字符自赋值、assign 后原对象仍正常

测试代码我习惯直接写断言,简单粗暴:

int main() { MyString s("hello"); assert(s.size() == 5); s.push_back('!'); assert(strcmp(s.c_str(), "hello!") == 0); s.insert(0, ">>"); assert(strcmp(s.c_str(), ">>hello!") == 0); s.erase(0, 2); assert(strcmp(s.c_str(), "hello!") == 0); assert(s.find("ell") == 1); MyString sub = s.substr(0, 5); assert(strcmp(sub.c_str(), "hello") == 0); s.replace(1, 2, "ELL"); assert(strcmp(s.c_str(), "hELLo!") == 0); s[0] = 'H'; assert(strcmp(s.c_str(), "HELLo!") == 0); return 0; }

在-fsanitize=address的加持下,这个测试跑完,基本可以确认类的内存管理没有明显问题。

8.2 还能怎么玩:KMP、SSO、写时拷贝都是可选方向

只能说“基本可以确认”,因为真正的std::string还有太多可以继续深挖的空间。写完这个版本之后,我梳理了几个值得继续探索的方向:

  • KMP 优化 find:如果我们这个类要做大量子串查找,朴素匹配的 O(n×m) 就比较吃力了。KMP 可以把复杂度降到 O(n+m),代价是预计算一个next数组。感兴趣的话,可以把find里的朴素匹配替换成 KMP,然后自己构造一些特殊用例对比耗时。
  • 小字符串优化:现代std::string之所以分配次数少,一个重要原因是 SSO——短字符串直接存在对象内部的栈数组里,只有长字符串才走堆分配。它的实现思路是把_str、_size、_capacity换成union,一部分字节存短字符串,另一部分存指针。这个改造对理解内存布局帮助很大。
  • 完整迭代器:正式版本的iterator应该是一个封装的类,支持operator++、operator--、operator*等操作,这样MyString就能配合范围 for 循环和 STL 算法了。

我个人在实际操作中的体会是,模拟实现string最有价值的地方不是“写出了一个能用的类”,而是把动态数组那几个最核心的机制——扩容、搬移、深浅拷贝——从“听说过”变成了“亲手踩过坑”。尤其是insert的从后往前搬数据和erase的截断分支,你不自己写一遍,永远只是背了个概念。等到哪天线上程序真的出现字符串乱码或者内存崩溃,你再去排查时,就会发现当初手写过的这些细节全都变成了肌肉记忆。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询