今天来聊聊C里一个绕不开的话题——string的增删查改模拟实现。很多学C的同学都会有这种感觉std::string用起来挺顺手但一旦让你自己动手实现一个立刻就会卡住。不是不知道该有哪些接口而是当你在构造函数里分配内存、在拷贝构造里思考怎么处理指针时你才真正开始理解C的内存管理。这篇文章就把这件事说透从一个最简单的char*开始一步步用C实现一个支持增删查改的MiniString类讲清楚每个接口背后的设计思路、踩坑点和优化手段。为什么值得写这样一篇东西因为string看似简单实际是C里最能考察基本功的类之一。深拷贝与浅拷贝、动态内存管理、运算符重载、迭代器语义、容量增长策略这些面试八股里的高频考点在模拟实现string的过程中全都能碰到。你把这一个类从头到尾写干净了再去看vector、list的模拟实现会发现很多套路是相通的。适合正在学习C的同学、准备面试的求职者以及想加深对STL理解的一线开发。1. 动手前先想清楚为什么要手写一个string1.1 面试八股背后的真实价值很多人觉得模拟实现string是面试官故意刁难人实际工作中直接std::string不就行了话是没错但如果你从来没自己实现过一个管理动态内存的类你对底层机制的理解就永远是黑盒。我给你举个真实例子。有一次我看一位候选人写深拷贝的代码他写的拷贝构造是这样的MyString(const MyString s) : _str(s._str) // 浅拷贝直接让指针指向同一块内存 {}问他这样有什么问题他说没问题啊反正字符串内容一样。结果我追问了一句两个对象析构的时候会发生什么他才反应过来——同一块内存被释放两次直接double free崩溃。这就是为什么模拟实现有价值。只有亲手写过析构函数、亲自踩过浅拷贝的坑你才能真正理解一个类管理了堆资源就必须实现拷贝构造、赋值重载和析构这条规则。这不是背出来的是试错试出来的。1.2 你需要实现哪些核心功能模拟实现不是要把std::string的上百个接口全部抄一遍那不现实也没必要。一个能说明问题的实现至少需要覆盖这几块一是生命周期管理默认构造、带const char*参数的构造、拷贝构造、析构、拷贝赋值。这一块的核心是正确管理动态内存杜绝浅拷贝。二是容量管理size()返回有效字符个数capacity()返回当前容量reserve()预留空间resize()调整有效长度。这四兄弟是理解string扩容机制的钥匙。三是增删查改操作push_back()尾部追加单字符append()追加字符串insert()在指定位置插入erase()删除指定区间find()查找子串operator[]访问元素c_str()返回C风格字符串。这些就是标题里说的增删查改。四是现代C的优化移动构造和移动赋值。虽然初学者可以先把这块放一放但既然要写写出来能让整个类在vector、其他容器里使用时的性能好一个档次。我把这些整理成一张简化版接口表你写代码之前心里先有个谱分类接口核心作用构造析构默认构造、带参构造、拷贝构造、析构生命周期正确内存不泄漏不重复释放赋值拷贝赋值、移动赋值深拷贝或资源转移避免双重释放容量size、capacity、reserve、resize、empty管理有效长度和已分配空间增push_back、append、insert追加、拼接、指定位置插入删erase、clear、pop_back删除指定区间或清空内容查operator[]、find、c_str访问元素、查找子串、获得C字符串2. 基础设施类的骨架与三大件2.1 成员变量与构造析构设计写这个类第一步是定成员变量。我的习惯是很朴素的三件套class MyString { public: // 各种成员函数 private: char* _str; // 指向动态分配的字符数组 size_t _size; // 有效字符个数不含\0 size_t _capacity; // 当前缓冲区容量不含\0 };有的教科书喜欢用capacity表示包括\0在内的总空间两种约定都行但必须前后一致。我自己习惯在容量里不包含结尾的\0因为这样reserve(10)就明确表示能容纳10个有效字符更贴近使用直觉。如果你把\0也算进去reserve(10)实际能存9个字符容易在边界条件上绕晕。构造函数怎么写有讲究。默认构造和带参构造建议统一走一个底层函数避免大量重复代码MyString(const char* str ) : _str(nullptr), _size(0), _capacity(0) { if (str nullptr) { str ; } _size strlen(str); _capacity _size; _str new char[_capacity 1]; // 1给\0留位置 strcpy(_str, str); }这里有个我踩过的坑strcpy会把源字符串的\0一并拷贝过去所以分配空间时一定要想清楚strlen返回的长度和实际拷贝需要的字节数之间的关系。strlen(abc)返回3但abc在内存里实际占4字节因为结尾有\0。所以new char[_capacity 1]是必须的。析构就很简单了把堆内存还回去就行~MyString() { delete[] _str; _str nullptr; _size _capacity 0; }有人问为什么要置nullptr析构之后对象生命周期就结束了那是为了调试方便。如果你在析构里打印日志或者之后又误调用某些接口一个非空的野指针会误导你置空之后还能让其他代码不至于拿到一个看起来有效的地址。2.2 深浅拷贝的坑为什么必须写拷贝构造和赋值这是整个模拟实现里最重要的一个点我建议你停下来想明白再往下写。如果没有自定义拷贝构造编译器会生成一个逐成员拷贝的默认版本。对于指针成员逐成员拷贝意味着两个对象的_str指向同一块内存。这是浅拷贝后果是把解决问题的时间全部花在调试崩溃上。我给你画一下场景。假设有a和b两个MyString对象a里面存储着hello然后执行MyString b(a)。浅拷贝之后b._str a._str两块内存共享。程序结束时会依次调用析构函数b先析构delete[]掉了那块内存接着a析构再次delete[]同一个地址就成了double free。哪怕不崩溃如果你修改b的内容a也会跟着变因为本质上是同一块内存。正确的写法是深拷贝——重新开辟一块内存把内容复制过去MyString(const MyString s) { _size s._size; _capacity s._capacity; _str new char[_capacity 1]; strcpy(_str, s._str); // 或者用memcpy也行 }拷贝赋值重载同理但有三个细节必须处理第一检测自赋值。如果写成s s自己先释放旧内存再拷贝旧内存已经没了后面拷贝的就是野指针直接崩。第二先开新空间再释放旧空间。这是异常安全的基本素养万一new抛异常原对象还保持原状。你如果先delete掉旧内存再newnew抛出异常时对象已经处于内存泄漏的中间状态。第三赋值要处理已有资源。别人的_size是10你这个对象里可能已经有5个字符了不释放旧空间就是内存泄漏。我惯用的写法是copy-and-swap风格代码逻辑清晰void swap(MyString tmp) noexcept { std::swap(_str, tmp._str); std::swap(_size, tmp._size); std::swap(_capacity, tmp._capacity); } MyString operator(const MyString s) { if (this ! s) { MyString tmp(s); // 调用拷贝构造完成深拷贝 swap(tmp); // 交换后旧资源由tmp在析构时释放 } return *this; }这套写法的妙处在于深拷贝交给拷贝构造资源释放交给tmp的析构本身不用手动管理新旧内存的接替。它天然处理了自赋值因为自赋值时tmp是同一个对象的深拷贝交换后两边数据仍是原来的值不会出问题。2.3 高效扩容reserve与capacity的倍增策略string底层是一个动态字符数组append、insert、push_back都要先确认容量够不够。如果每次追加一个字符就重新new一次那性能就是灾难。std::string的容量是增长的而且通常是翻倍增长因为这样可以保证均摊时间复杂度为O(1)。模拟实现时reserve和扩容函数要分开写。reserve是用户主动申请空间扩容函数是内部自动触发的。我建议把确保容量足够封装成一个私有函数void reserve(size_t newCapacity) { if (newCapacity _capacity) { char* newStr new char[newCapacity 1]; if (_str) { strcpy(newStr, _str); } delete[] _str; _str newStr; _capacity newCapacity; } } void ensureCapacity(size_t requireSize) { if (requireSize _capacity) { size_t newCap _capacity 0 ? 4 : _capacity * 2; while (newCap requireSize) { newCap * 2; } reserve(newCap); } }我特意把newCapacity 0的情况考虑进去初始容量设为4而不是1因为实际使用时字符串很少只有一个字符4能减少一部分扩容频率。倍增、减半这套策略敲定之后你在push_back或insert的时候只需要调ensureCapacity就行不用每次关心到底分配多少。3. 核心增删查改如何一步步实现3.1 增push_back、append与insert的实现细节增操作是整个模拟实现里最容易互相触发bug的地方因为插入会移动数据还要维护\0的正确位置。push_back是最简单的追加单个字符到尾部void push_back(char ch) { ensureCapacity(_size 1); _str[_size] ch; _str[_size 1] \0; // 新结尾 _size; }注意顺序先写字符再写\0最后增加_size。如果先_size再写下标就要写成_size-1容易出错。我习惯把这个顺序固定成先写数据后改尺寸。append的实现跟拷贝构造很类似核心是把源字符串拼接进来MyString append(const char* str) { size_t len strlen(str); ensureCapacity(_size len); strcpy(_str _size, str); _size len; return *this; }这里有个很隐蔽很恶心的坑如果str指向的是当前对象内部的缓冲区比如myStr.append(myStr._str)或者类似自拼接的场景就会发生内存重叠。ensureCapacity里一旦扩容内部缓冲区的地址就变化了但str还指向旧地址同时旧缓冲区已被deletestr变成野指针。你直接调std::string的append都不见得能处理好每个这种case所以模拟实现可以不彻底解决但至少要意识到这个风险在注释里写明。insert是增操作里最复杂的因为它要移动元素。我的思路分三步扩容量、后移区间、插入新数据。以单字符插入为例void insert(size_t pos, char ch) { assert(pos _size); // 允许在末尾插入但不允许越界 ensureCapacity(_size 1); // 把[pos, _size)区间的字符整体后移一位 for (size_t i _size; i pos; i--) { _str[i] _str[i - 1]; } _str[pos] ch; _size; _str[_size] \0; }后移要特别注意循环方向。从后往前移动否则会把还没移动过的字符覆盖掉。我做面试官的时候会特意关注候选人写的是从前往后还是从后往前这是基本功的试金石。插入字符串也是同样的套路只不过后移的步长是lenvoid insert(size_t pos, const char* str) { size_t len strlen(str); assert(pos _size); ensureCapacity(_size len); // 先移动原字符串的尾部再拷贝插入内容 for (size_t i _size; i pos; i--) { _str[i len - 1] _str[i - 1]; } for (size_t j 0; j len; j) { _str[pos j] str[j]; } _size len; _str[_size] \0; }写完这两个函数你的MyString已经能实打实地增了。但也提醒一句insert后所有对字符串buffer的指针引用都会失效因为可能扩容重新分配了内存这是使用层面的常见坑不是实现层面的。3.2 删erase与clear的注意事项删操作相比增操作要简单一些但它不代表没坑。erase的语义是删除从pos位置开始的len个字符。如果len太大只能删到字符串末尾。判断越界的标准是pos _size才异常而不是pos len _size。我见过不少野路子实现直接让pos len决定是否越界导致本来合法的调用抛了异常。void erase(size_t pos, size_t len npos) { assert(pos _size); // 删除位置必须有效 if (len npos || pos len _size) { // 一直删到末尾 _str[pos] \0; _size pos; return; } // 把[poslen, _size)的字符搬到pos位置 size_t remain _size - pos - len; memmove(_str pos, _str pos len, remain 1); // 连\0一起搬 _size - len; }这里用memmove而不是memcpy是因为源和目的区间可能重叠memmove能正确处理重叠情况。正宗的std::string内部也是这么干的这算是一个性能细节。clear的实现非常简单直接把大小归零并写上\0就好void clear() { if (_str) { _str[0] \0; } _size 0; }有个小地方要提醒clear之后capacity是不变的也就是说你不会因为clear而释放内存下次继续append不会经历扩容。这其实是好事std::string也是这么做的。有些初学者觉得clear应该把内存也释放掉我不建议这样做因为频繁地clear再追加是常见模式每次都释放再分配会性能骤降。pop_back就不用多说了尾部弹出一个字符把_size减一然后在新尾部写\0。注意检查是否为空空字符串上调用pop_back应该是未定义行为——我在自己的实现里会加个assert(_size 0)。3.3 查find与operator[]的高效姿势查操作有两个层次一个是随机访问一个是查找子串。operator[]是返回非常量的引用所以允许用户直接修改指定位置的字符。最直接的实现是char operator[](size_t pos) { assert(pos _size); return _str[pos]; }你可能会问为什么不是直接返回_str[pos]就完事了还需要assert因为下标访问在整个STL里是不检查边界的操作越界访问结果未定义。但在模拟实现里加一个assert能在调试阶段帮你拦下一堆低级错误发布时还可以通过NDEBUG宏自动关掉成本几乎为零。const版本也要写否则const对象无法通过const MyString访问元素const char operator[](size_t pos) const { assert(pos _size); return _str[pos]; }find的实现是朴素的暴力匹配。对于一个小型精简string来说没必要上KMP或者BM除非你要处理特别长的文本。我写的find支持从指定位置开始查找size_t find(const char* sub, size_t pos 0) const { size_t subLen strlen(sub); if (pos _size || subLen 0 || _size - pos subLen) { return npos; } for (size_t i pos; i _size - subLen; i) { size_t j 0; while (j subLen _str[i j] sub[j]) { j; } if (j subLen) { return i; } } return npos; }我遇到的常见操作是把find的结果跟npos比较。一个合理的npos值可以定义为static const size_t npos (size_t)-1也就是size_t能表示的最大值这样任何合法的下标都不可能等于npos。还有c_str()它的行为是返回内部缓冲区的指针正常情况下一定以\0结尾。这就是为什么我们在所有增删操作里都要保证末尾有个\0否则c_str()就变成了一颗定时炸弹外部以为它是合法的C字符串实际内存早已越界读取。3.4 改replace与assign改操作我分两类来谈。一类是replace替换子串另一类是assign整体重新赋值。replace最常见的形态是替换[pos, poslen)区间的内容为另一个字符串。实现思路和我前面讲insert类似唯一的区别是要处理新串长度与旧串长度不一样时的数据搬移MyString replace(size_t pos, size_t len, const char* str) { assert(pos _size); size_t newLen strlen(str); // 先计算替换后的总长度 size_t oldPartLen _size - pos - len; // 被替换区间之后的长度 if (pos len _size) { oldPartLen 0; len _size - pos; } // 保证容量足够 size_t newSize pos newLen oldPartLen; ensureCapacity(newSize); // 把区间之后的内容搬到新位置 if (oldPartLen 0) { memmove(_str pos newLen, _str pos len, oldPartLen 1); } // 写入新串 for (size_t i 0; i newLen; i) { _str[pos i] str[i]; } _size newSize; _str[_size] \0; return *this; }replace这种操作最麻烦的就是保留尾部内容这件事。你的替换区间和保留区间可能重叠也可能不重叠直接用memmove能一并处理好。assign就简单太多了它的语义相当于一个加强版的赋值操作把当前对象的内容整个替换成别的字符串MyString assign(const char* str) { size_t len strlen(str); // 如果现有容量足够直接复用缓冲区 if (len _capacity) { strcpy(_str, str); _size len; } else { // 容量不够时才重新分配 MyString tmp(str); swap(tmp); } return *this; }assign的设计思路是能复用缓冲就复用不能复用才重新分配。这样可以让用户频繁地给同一个字符串变量赋不同的值而不会反复new/delete性能和内存碎片都更好。4. 现代C的优化移动语义与右值引用4.1 为什么移动拷贝和移动赋值能省一次深拷贝如果你是在C11之后的编译器上编译建议把移动构造和移动赋值也加上。它的核心原理是当用一个右值比如临时对象去构造或赋值时源对象马上就会被销毁这时候再开一块内存去做深拷贝纯属浪费不如直接把源对象的资源抢过来再把源对象置成空状态。以移动构造为例MyString(MyString s) noexcept : _str(s._str), _size(s._size), _capacity(s._capacity) { s._str nullptr; s._size 0; s._capacity 0; }这里没有任何拷贝只是搬了三个成员变量的值然后让s内部的指针不再指向那块内存。这样s析构时不会delete掉那块已经属于新对象的缓冲区。这就是移动语义省下的那次深拷贝。移动赋值差不多但要点是先把当前对象已有的资源释放掉再接管新资源MyString operator(MyString s) noexcept { if (this ! s) { delete[] _str; // 释放自己原来的内存 _str s._str; // 接管s的资源 _size s._size; _capacity s._capacity; s._str nullptr; // 把s置空 s._size 0; s._capacity 0; } return *this; }我个人的经验是一旦实现移动构造拷贝构造函数最好改成以值传递接收参数的形态或者直接提供两个版本的swap来优化。不过这是一个大改动需要配合测试来验证否则容易顾此失彼。4.2 完整模拟实现代码示例这里给出一个浓缩版的完整实现把前面讲的几个部分串起来。我特意把一些隐患性的细节在注释里标出来了后续你可以在这个基础上加接口。#include iostream #include cstring #include cassert #include cstddef class MyString { public: static const size_t npos (size_t)-1; MyString(const char* str ) : _str(nullptr), _size(0), _capacity(0) { if (str nullptr) { str ; } _size strlen(str); _capacity _size; _str new char[_capacity 1]; strcpy(_str, str); } MyString(const MyString s) : _str(nullptr), _size(s._size), _capacity(s._capacity) { _str new char[_capacity 1]; strcpy(_str, s._str); } MyString(MyString s) noexcept : _str(s._str), _size(s._size), _capacity(s._capacity) { s._str nullptr; s._size s._capacity 0; } ~MyString() { delete[] _str; } MyString operator(const MyString s) { if (this ! s) { MyString tmp(s); swap(tmp); } return *this; } MyString operator(MyString 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; } void swap(MyString s) noexcept { std::swap(_str, s._str); std::swap(_size, s._size); std::swap(_capacity, s._capacity); } size_t size() const { return _size; } size_t capacity() const { return _capacity; } bool empty() const { return _size 0; } const char* c_str() const { return _str ? _str : ; } char operator[](size_t pos) { assert(pos _size); return _str[pos]; } const char operator[](size_t pos) const { assert(pos _size); return _str[pos]; } private: void reserve(size_t newCapacity) { if (newCapacity _capacity) { char* newStr new char[newCapacity 1]; if (_str) { strcpy(newStr, _str); } delete[] _str; _str newStr; _capacity newCapacity; } } void ensureCapacity(size_t requireSize) { if (requireSize _capacity) { size_t newCap _capacity 0 ? 4 : _capacity * 2; while (newCap requireSize) { newCap * 2; } reserve(newCap); } } public: void push_back(char ch) { ensureCapacity(_size 1); _str[_size] ch; _str[_size 1] \0; _size; } MyString append(const char* str) { size_t len strlen(str); ensureCapacity(_size len); strcpy(_str _size, str); _size len; return *this; } MyString operator(const char* str) { return append(str); } MyString operator(char ch) { push_back(ch); return *this; } void insert(size_t pos, char ch) { assert(pos _size); ensureCapacity(_size 1); for (size_t i _size; i pos; i--) { _str[i] _str[i - 1]; } _str[pos] ch; _size; _str[_size] \0; } void insert(size_t pos, const char* str) { assert(pos _size); size_t len strlen(str); ensureCapacity(_size len); for (size_t i _size; i pos; i--) { _str[i len - 1] _str[i - 1]; } for (size_t j 0; j len; j) { _str[pos j] str[j]; } _size len; _str[_size] \0; } void erase(size_t pos, size_t len npos) { assert(pos _size); if (len npos || pos len _size) { _str[pos] \0; _size pos; return; } size_t remain _size - pos - len; memmove(_str pos, _str pos len, remain 1); _size - len; } void clear() { if (_str) { _str[0] \0; } _size 0; } void pop_back() { assert(_size 0); _size--; _str[_size] \0; } size_t find(const char* sub, size_t pos 0) const { size_t subLen strlen(sub); if (pos _size || subLen 0 || _size - pos subLen) { return npos; } for (size_t i pos; i _size - subLen; i) { size_t j 0; while (j subLen _str[i j] sub[j]) { j; } if (j subLen) { return i; } } return npos; } MyString replace(size_t pos, size_t len, const char* str) { assert(pos _size); size_t newLen strlen(str); size_t oldPartLen 0; if (pos len _size) { len _size - pos; // 替换到末尾 } else { oldPartLen _size - pos - len; } size_t newSize pos newLen oldPartLen; ensureCapacity(newSize); if (oldPartLen 0) { memmove(_str pos newLen, _str pos len, oldPartLen 1); } for (size_t i 0; i newLen; i) { _str[pos i] str[i]; } _size newSize; _str[_size] \0; return *this; } private: char* _str; size_t _size; size_t _capacity; };给几个简单的测试验证一下void testBasic() { MyString s1(hello); assert(strcmp(s1.c_str(), hello) 0); assert(s1.size() 5); MyString s2(s1); // 拷贝构造 assert(strcmp(s2.c_str(), hello) 0); // 修改s2s1不应受影响 s2[0] H; assert(strcmp(s1.c_str(), hello) 0); assert(strcmp(s2.c_str(), Hello) 0); s1.push_back(!); assert(strcmp(s1.c_str(), hello!) 0); s1.insert(5, world); assert(strcmp(s1.c_str(), helloworld!) 0); s1.erase(5, 5); assert(strcmp(s1.c_str(), hello!) 0); assert(s1.find(lo, 0) 3); assert(s1.find(xyz) MyString::npos); s1.replace(1, 2, ELL); assert(strcmp(s1.c_str(), hELLo!) 0); MyString s3; s3 s1; // 拷贝赋值 assert(strcmp(s3.c_str(), hELLo!) 0); std::cout all test passed std::endl; }如果这段测试跑起来没有一个断言挂掉你的模拟实现基本就是能用的了。5. 常见问题与调试经验5.1 运行崩溃的排查看这里浅拷贝、越界、迭代器失效我自己在指导别人写这个类的时候最常见的问题集中在三个地方。第一个就是浅拷贝。症状是崩溃时机很随机有时候不是当场崩而是在函数返回之后崩。排查技巧是在析构函数里打印一条日志然后创建两个对象进行拷贝看析构是否被调用了两次并且地址相同。如果日志里输出两个相同的delete地址基本可以断定是浅拷贝。第二个是越界访问。很多同学只记得new char[len]忘了还要给\0留位置结果c_str()在读越界的\0时踩到未分配内存。这种问题在gcc下往往不崩在MSVC的debug下狂报警告。稳妥的做法是把所有读尾部的逻辑都走一遍确保写\0的下标不超过capacity。第三个是迭代器失效的隐患。这个在模拟实现里主要体现在任何可能扩容的操作push_back、append、insert、replace之后之前通过c_str()或str[0]拿到的指针都会失效。我给你一个特别容易踩的场景const char* p s.c_str(); s.append( more); // 此刻p指向的内存可能已经被释放这不是你实现的问题而是使用层面的语义。但既然你在模拟实现文档和注释里写清楚这一点会显得你考虑问题很全面。5.2 reserve、resize和insert的边界坑reserve和resize是容易混的两个接口。我的经验是用一句话区分reserve只改容量不改大小resize改大小还会初始化新字符。reserve(20)之后size()还是原来的值只是后续append不用扩容而已。resize(20)则会真正让字符串变成20个字符新增部分可以是\0或者指定字符。如果你在实现resize的时候遇到缩小的情况只是把_size改了就行但记得在新末尾写\0。遇到扩大的情况可以复用ensureCapacity然后再补字符void resize(size_t newSize, char ch \0) { if (newSize _size) { _size newSize; if (_str) { _str[_size] \0; } } else { ensureCapacity(newSize); for (size_t i _size; i newSize; i) { _str[i] ch; } _size newSize; _str[_size] \0; } }insert边界上的坑是pos _size这个调用在逻辑上是合法的相当于在末尾追加。我在许多模拟实现里看到assert(pos _size)这没问题但如果你只写了assert(pos _size)那insert到末尾就会失败。一定要想清楚是小于还是小于等于。5.3 测试驱动怎么验证你的string实现是对的编写测试是很多自学的人容易忽略的环节但这恰恰是模拟实现最有价值的部分。测试不能只测正常流程要把边界情况都覆盖到。我的测试清单一般包含这些空字符串操作默认构造后调用size()、c_str()、empty()、find都不应崩溃。尤其是c_str()空字符串也必须返回一个以\0结尾的合法地址。单字符字符串push_back到空串第一次扩容要验证扩容后旧数据是否被正确拷贝。大量追加连续push_back50000个字符然后用c_str()验证内容完整。这一步能测试出扩容策略是否正确如果扩容时少拷贝了\0最后读到的字符串会多出奇怪字符。自赋值s s这种写法虽不推荐但必须验证它不崩溃且数据不丢失。find无匹配确保返回npos并且不会越界访问。insert在头部和尾部头插会移动所有字符尾插相当于append两种都必须正确。erase全部和部分erase到末尾时len传nposerase中间区间时验证后缀能正确前移。这些测试写下来即使你的实现有小bug调试起来也快得多。6. 写在最后模拟实现的真正收获把整个MyString写下来之后我最大的体会是代码本身并不长但每一行都踩在C内存管理的关键点上。你在学习std::string的时候看到的是接口的便利但当你自己实现的时候看到的是new与delete的对称、浅拷贝与深拷贝的代价、扩容与数据搬移的权衡。按照我个人的经验建议你写完这个类之后做三件扩展练习第一加一个插入字符串的operator版本比如支持MyString const char*的运算符重载练习返回新对象的写法。第二把find升级成支持从尾部查找的rfind可以顺带熟悉反向遍历。第三尝试给这个类增加一个采用小字符串优化SSO的版本——当字符串很短时直接用栈上数组存储只有当长度超过阈值时才转到堆上分配。这个优化机制在很多标准库实现里都存在模拟一遍能更深刻理解STL内部如何平衡性能与内存。最后一个真实建议写完后一定要用Sanitizer跑一遍。在编译时加上-fsanitizeaddress然后运行你的测试代码它会帮你抓出所有内存越界和释放后使用的问题。我的实践经验是一个初版模拟实现极少能在address sanitizer下一次通过大概率会有几处隐藏的越界读写这恰好就是你理解C内存模型的最好机会。这篇文章到这就该收尾了。我最后再分享一个小技巧把这份代码保存下来过两周再回头看一遍如果你不看源代码就能重新默写出来并且能解释每个成员函数为什么这样写那你对C动态内存管理的理解就已经到了一个新的层级。别只收藏动手写才是正经事。