
1. 从零开始为什么我们需要自己实现一个string类在C的日常开发中std::string几乎是每个程序员最熟悉的老朋友。它封装了字符数组的复杂性提供了size()、substr()、find()等便捷接口让我们能像操作普通对象一样处理字符串。但你是否想过这个看似简单的类内部是如何运作的当面试官问起“string类的底层实现”时你是否只能回答“动态数组”或“写时拷贝”这些模糊的概念却说不清具体的细节这就是“手撕STL”系列的意义所在。今天我们不依赖任何标准库从零开始用C亲手实现一个功能完整的MyString类。这个过程绝非简单的“造轮子”而是一次深入理解C核心机制的绝佳实践。你将亲手处理动态内存管理、拷贝控制、运算符重载和迭代器设计等关键问题。你会发现一个健壮的string类需要考虑的边界情况远超想象空字符串如何处理拷贝时是深拷贝还是浅拷贝operator[]的const和非const版本有何区别如何高效地实现find和substr通过这次“手撕”你不仅能彻底吃透std::string的设计精髓更能深刻理解C中RAII资源获取即初始化原则如何应用于实际资源管理以及异常安全为何如此重要。这对于应对C面试中关于STL底层原理、内存管理的高频问题如“string的拷贝构造函数如何实现”、“reserve和resize的区别”至关重要。接下来让我们抛开标准库的“黑盒”从最原始的char*开始一步步构建起我们自己的字符串世界。2. MyString类的骨架设计与核心数据成员一个字符串类最核心的任务就是管理一段动态分配的、以空字符\0结尾的字符数组。因此我们的类设计必须围绕这个核心资源展开。2.1 数据成员与基础构造我们首先定义类的私有数据成员。一个最经典的实现需要三个成员变量char* _str: 指向动态分配的字符数组的指针这是字符串数据的实际存储位置。size_t _size: 记录字符串当前的实际长度不包括结尾的\0。size_t _capacity: 记录当前已分配内存的总容量通常大于或等于_size 1为\0预留空间。为什么需要_capacity这是实现高效内存管理的关键。如果每次添加字符都重新分配内存性能将极其低下。_capacity允许我们进行预分配当字符串增长时如果空间足够就直接使用只有当空间不足时才进行一次更大的内存分配这即是std::string中reserve方法的思想基础。基于此我们搭建出MyString类的最初骨架class MyString { public: // 构造函数们将在后续实现 MyString(const char* str ); MyString(const MyString s); // 拷贝构造 MyString operator(const MyString s); // 拷贝赋值 ~MyString(); // 析构函数 // 后续将实现的各种成员函数... 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; } private: char* _str; size_t _size; size_t _capacity; // 一个辅助函数用于统一进行内存分配和容量计算 void _reserve(size_t new_capacity); };默认构造函数的实现需要特别注意参数const char* str 提供了一个空字符串的默认值。这意味着MyString s;和MyString s();都将创建一个有效的空字符串对象其_str指向一个只包含\0的堆内存块_size为0_capacity至少为1。这里的一个关键细节是我们必须为\0分配空间这是C风格字符串的约定也是我们后续许多操作如c_str()正确的前提。2.2 辅助函数_reserve的设计逻辑_reserve是我们的核心内存管理助手。它的职责是确保_str指向的内存块至少有new_capacity的大小注意这个容量应包含\0的位置。其实现逻辑是如果请求的new_capacity小于或等于当前的_capacity则什么都不做这是std::string::reserve的常见行为即不会缩容。否则向堆申请new_capacity字节的新内存。将旧内存中的数据包括\0拷贝到新内存。释放旧内存并更新_str指针和_capacity。这里有一个重要的经验技巧在重新分配内存时我们通常使用new char[new_capacity]而不是new char[new_capacity]()。后者会对内存进行值初始化对于char就是设为0这会带来不必要的性能开销。我们只需要保证字符串结尾的\0由我们自己正确设置即可其他位置的数据在拷贝时会被覆盖。void MyString::_reserve(size_t new_capacity) { if (new_capacity _capacity) { return; } // 通常我们会采用一个增长因子如1.5或2倍来分配以减少频繁重分配。 // 这里为了简化直接使用请求的容量。 char* new_str new char[new_capacity]; if (_str) { // 拷贝现有数据包括结尾的\0 strcpy(new_str, _str); // 假设我们暂时使用strcpy后续会替换 } delete[] _str; // 释放旧内存 _str new_str; _capacity new_capacity; }注意上面的实现中使用了strcpy这要求_str必须是以\0结尾的有效C字符串。在我们的设计中这由构造函数保证。但在更严谨的实现中我们应避免依赖C库函数而是使用memcpy并手动设置\0这能更好地处理包含\0的二进制数据虽然标准std::string也不支持内容包含\0。3. 基石拷贝控制“三件套”与RAII原则C类的核心魅力也是难点在于其对对象生命周期的精细控制。对于管理资源的类如我们的MyString管理着堆内存拷贝构造函数、拷贝赋值运算符和析构函数被称为“拷贝控制三件套”它们的正确实现是类能否安全使用的基石。这背后体现的是RAII思想资源在构造函数中获取在析构函数中释放。3.1 析构函数资源释放的唯一出口析构函数最简单也最重要。它的任务就是释放构造函数或赋值操作中申请的所有资源防止内存泄漏。MyString::~MyString() { if (_str) { delete[] _str; _str nullptr; // 一个好习惯防止悬空指针被误用 _size 0; _capacity 0; } }这里将指针置为nullptr是一个防御性编程技巧。虽然对象即将销毁但如果在某些复杂场景下比如析构函数被意外调用多次这可以避免delete一个已经释放的内存即“重复释放”这通常会导致程序崩溃。3.2 拷贝构造函数深拷贝的必要性默认的拷贝构造函数由编译器生成执行的是浅拷贝即仅仅复制指针的值。如果两个MyString对象的_str指向同一块内存那么当一个对象被修改或析构时另一个对象将受到影响悬空指针或重复释放这是灾难性的。因此我们必须实现深拷贝。MyString::MyString(const MyString s) : _str(nullptr), _size(0), _capacity(0) { // 先初始化为空状态 // 直接调用_reserve分配足够空间包含\0 _reserve(s._capacity); // 拷贝数据 _size s._size; // 使用memcpy避免依赖源字符串的\0更通用 if (s._str) { memcpy(_str, s._str, (_size 1) * sizeof(char)); // 连同\0一起拷贝 } else { _str[0] \0; } }关键点在拷贝构造函数中我们先初始化成员变量为一个“空”的安全状态特别是_strnullptr。这是因为_reserve内部可能会delete[] _str如果_str是未初始化的野指针会导致未定义行为。这种“先置空”的模式在写拷贝赋值运算符时更为重要。3.3 拷贝赋值运算符处理自赋值与异常安全拷贝赋值运算符operator是最复杂的一个。它需要处理两个核心问题自赋值s1 s1;。如果不处理在释放自身内存后又试图从自身拷贝数据会导致访问已释放的内存。异常安全如果在分配新内存或拷贝数据时抛出异常比如bad_alloc要保证原对象的状态不被破坏。一个强异常安全且能处理自赋值的通用实现是“拷贝并交换”copy-and-swap idiom。但这里我们先展示一个更直观、效率也不错的版本MyString MyString::operator(const MyString s) { // 1. 处理自赋值 if (this s) { return *this; } // 2. 分配新内存并拷贝数据可能抛出异常 char* new_str nullptr; if (s._str) { new_str new char[s._capacity]; memcpy(new_str, s._str, (s._size 1) * sizeof(char)); } else { new_str new char[1]; new_str[0] \0; } // 3. 释放旧资源此操作不会抛出异常 delete[] _str; // 4. 接管新资源 _str new_str; _size s._size; _capacity s._capacity; return *this; }这个实现保证了基本异常安全如果第2步new操作失败抛出std::bad_alloc则new_str仍为nullptr第3步的delete[] _str仍然会执行释放旧内存对象状态变为“空字符串”。这虽然改变了原对象旧字符串丢了但至少没有内存泄漏且对象仍处于一个有效的、可析构的状态。这是一种可接受的异常安全保证。实操心得在面试中被要求手写operator时务必主动提及并处理自赋值和异常安全。即使不要求写出完整代码说出这两个关键点也能极大加分。一个常见的错误答案是直接delete[] _str然后new和memcpy这无法处理自赋值。4. 核心功能实现模拟std::string的常用接口有了稳固的基石我们就可以为MyString添砖加瓦实现那些让std::string如此好用的成员函数了。我们将挑选几个最具代表性的进行实现。4.1 元素访问operator[]与atoperator[]需要提供两个版本const版本用于只读访问非const版本用于读写访问。它们通常不进行边界检查以追求最高性能。// 非const版本允许修改 char MyString::operator[](size_t pos) { // 断言检查在Debug模式下帮助发现问题 assert(pos _size); return _str[pos]; } // const版本用于const对象只读 const char MyString::operator[](size_t pos) const { assert(pos _size); return _str[pos]; }而at成员函数则提供带边界检查的访问如果越界则抛出std::out_of_range异常。char MyString::at(size_t pos) { if (pos _size) { throw std::out_of_range(MyString::at: pos out of range); } return _str[pos]; } const char MyString::at(size_t pos) const { if (pos _size) { throw std::out_of_range(MyString::at: pos out of range); } return _str[pos]; }为什么要有两个operator[]这是C const正确性的体现。当一个const MyString对象调用operator[]时编译器会选择const版本返回const char从而禁止修改保证了对象的常量性。4.2 容量操作reserve,resize,clear,push_backreserve: 我们已经有了内部的_reserve公开接口只是它的一个包装但通常reserve不改变_size和内容。void MyString::reserve(size_t new_capacity) { _reserve(new_capacity); }resize: 改变字符串的_size。如果新的size更大则多出的部分用指定字符填充默认\0如果更小则截断字符串但不会释放多余容量。void MyString::resize(size_t n, char c \0) { if (n _size) { // 截断只需在新位置设置\0并更新_size _str[n] \0; _size n; } else { // 扩大可能需要扩容然后用字符c填充新增部分 if (n 1 _capacity) { _reserve(n 1); // 保证容量至少为n1 } for (size_t i _size; i n; i) { _str[i] c; } _str[n] \0; // 设置新的结尾 _size n; } }clear: 清空内容但不释放容量。这符合常见STL容器的行为。void MyString::clear() { if (_str) { _str[0] \0; } _size 0; }push_back: 在末尾追加一个字符。这是引发容量增长的典型操作。void MyString::push_back(char c) { if (_size 1 _capacity) { // 需要为新增字符和\0预留空间 // 常见的增长策略如果容量为0则分配至少15或类似小值否则翻倍。 size_t new_cap _capacity 0 ? 16 : _capacity * 2; _reserve(new_cap); } _str[_size] c; _size; _str[_size] \0; // 别忘了设置新的结尾 }容量增长策略是string类性能的关键。像上面这样简单的翻倍策略或1.5倍能在摊还分析下达到O(1)的均摊时间复杂度。如果每次只增加1个字符的空间性能将退化为O(n)。4.3 字符串操作append,find,substrappend: 追加一个C风格字符串或另一个MyString。这里以追加C字符串为例。MyString MyString::append(const char* s) { if (!s) return *this; // 处理空指针 size_t len strlen(s); if (_size len 1 _capacity) { _reserve(_size len 1); // 精确分配所需空间 } memcpy(_str _size, s, len 1); // 拷贝包括\0 _size len; return *this; // 支持链式调用 s1.append(s2).append(s3) }find: 查找子串。实现一个简单的暴力匹配算法KMP等更高效但这里为了清晰。size_t MyString::find(const char* sub, size_t pos 0) const { if (!sub || pos _size) return npos; // npos通常定义为static const size_t npos -1; const char* result strstr(_str pos, sub); if (result) { return result - _str; } return npos; }注意我们使用了strstr这个C库函数它内部也是暴力匹配。在真正的“手撕”练习中你可以尝试自己实现匹配逻辑这对于理解算法很有帮助。substr: 提取子串。需要特别注意参数校验和内存分配。MyString MyString::substr(size_t pos, size_t len npos) const { if (pos _size) { throw std::out_of_range(MyString::substr: pos out of range); } // 计算实际要拷贝的长度 size_t actual_len len; if (len npos || pos len _size) { actual_len _size - pos; } // 构造一个新的MyString对象 MyString result; result._reserve(actual_len 1); memcpy(result._str, _str pos, actual_len); result._str[actual_len] \0; result._size actual_len; return result; // 依赖编译器的返回值优化RVO/NRVO }5. 迭代器、运算符重载与现代C特性为了让我们的MyString用起来更像标准库的容器我们还需要为其添加迭代器支持并重载一些常用的运算符。5.1 迭代器让MyString融入STL生态迭代器本质是指针的抽象。对于连续存储的MyString其迭代器就是简单的char*。class MyString { public: // 类型别名符合STL惯例 using iterator char*; using const_iterator const char*; iterator begin() { return _str; } iterator end() { return _str _size; } // 指向最后一个字符的下一个位置\0 const_iterator begin() const { return _str; } const_iterator end() const { return _str _size; } const_iterator cbegin() const { return _str; } const_iterator cend() const { return _str _size; } };有了迭代器我们就可以在范围for循环中使用MyString也可以使用algorithm中的许多函数如std::find,std::sort对字符串字符排序等。MyString s hello; for (char c : s) { // 使用范围for循环 c toupper(c); } // s 现在为 HELLO5.2 运算符重载,,,和:通常实现为成员函数直接修改左操作数通常实现为非成员函数或友元返回一个新对象。// 成员函数 operator MyString MyString::operator(const MyString s) { return append(s.c_str()); // 复用append逻辑 } // 非成员函数 operator (通常声明为友元或使用公共接口) MyString operator(const MyString lhs, const MyString rhs) { MyString result(lhs); // 拷贝构造左值 result rhs; // 追加右值 return result; // 依赖RVO }比较运算符,!,,等。以和为例。// 非成员函数方便左右操作数对称处理 bool operator(const MyString lhs, const MyString rhs) { if (lhs.size() ! rhs.size()) return false; return memcmp(lhs.c_str(), rhs.c_str(), lhs.size()) 0; } bool operator(const MyString lhs, const MyString rhs) { // 按字典序比较 int cmp memcmp(lhs.c_str(), rhs.c_str(), std::min(lhs.size(), rhs.size())); if (cmp 0) return true; if (cmp 0) return false; // 前面部分相等长度小的字符串小 return lhs.size() rhs.size(); } // 其他比较运算符可以通过 和 组合实现 bool operator!(const MyString lhs, const MyString rhs) { return !(lhs rhs); } bool operator(const MyString lhs, const MyString rhs) { return rhs lhs; } bool operator(const MyString lhs, const MyString rhs) { return !(rhs lhs); } bool operator(const MyString lhs, const MyString rhs) { return !(lhs rhs); }5.3 考虑移动语义C11在现代C中移动语义可以极大提升涉及临时对象操作的性能。对于MyString实现移动构造函数和移动赋值运算符非常简单因为它们本质上就是“窃取”临时对象右值的资源。// 移动构造函数 MyString::MyString(MyString s) noexcept // noexcept很重要用于优化 : _str(s._str), _size(s._size), _capacity(s._capacity) { // 将源对象置于可安全析构的状态 s._str nullptr; s._size 0; s._capacity 0; } // 移动赋值运算符 MyString MyString::operator(MyString s) noexcept { if (this ! s) { delete[] _str; // 释放自身旧资源 // 窃取资源 _str s._str; _size s._size; _capacity s._capacity; // 置空源对象 s._str nullptr; s._size 0; s._capacity 0; } return *this; }实现移动语义后像MyString s3 s1 s2;这样的操作s1 s2产生的临时对象在构造s3时就会触发移动构造避免了不必要的深拷贝性能得到提升。6. 测试、边界条件与常见陷阱实现完所有功能后 rigorous 的测试是必不可少的。我们需要编写测试用例覆盖正常功能、边界情况和异常情况。6.1 基础功能测试void test_basic() { // 默认构造 MyString s1; assert(s1.empty() s1.size() 0 strcmp(s1.c_str(), ) 0); // C字符串构造 MyString s2(Hello); assert(s2.size() 5 strcmp(s2.c_str(), Hello) 0); // 拷贝构造 MyString s3 s2; assert(s3 s2 s3.c_str() ! s2.c_str()); // 内容相等但地址不同深拷贝 // 拷贝赋值 MyString s4; s4 s2; assert(s4 s2); // 自赋值 s4 s4; assert(s4 s2); // 自赋值后内容应不变 // 移动构造和赋值 MyString s5 std::move(s2); assert(s5 MyString(Hello) s2.empty()); // s2资源被移走应为空 }6.2 容量与修改操作测试void test_modifiers() { MyString s(Hi); assert(s.capacity() 3); // 初始容量 s.push_back(!); assert(s Hi!); s.append( World); assert(s Hi! World); s.resize(3, X); assert(s Hi!); // 截断填充字符不应出现 s.resize(6, X); assert(s Hi!XXX); s.clear(); assert(s.empty() s.size() 0 strcmp(s.c_str(), ) 0); assert(s.capacity() 0); // clear不释放容量 }6.3 边界条件与异常安全测试这是最能暴露问题的地方。void test_edge_cases() { // 空字符串操作 MyString s; s.push_back(a); assert(s a); // 访问越界 (使用at应抛出异常使用operator[]在Debug下断言) MyString s2(test); bool exception_thrown false; try { s2.at(10); } catch (const std::out_of_range) { exception_thrown true; } assert(exception_thrown); // find 边界 assert(s2.find(st) 2); assert(s2.find(xyz) MyString::npos); assert(s2.find(, 0) 0); // 查找空子串 // substr 边界 MyString s3 s2.substr(1, 2); assert(s3 es); s3 s2.substr(2, 100); // len超出范围 assert(s3 st); s3 s2.substr(5); // pos超出范围应抛异常 // ... 异常测试 }6.4 实际开发中容易忽略的陷阱c_str()返回的指针失效任何可能引起内存重新分配的操作如append,push_back,reserve等都会使之前通过c_str()获取的指针失效。这是一个经典的坑。MyString s(hello); const char* p s.c_str(); s.append( world); // 可能导致内存重分配 // 此时再使用 p 是危险的可能访问到已释放的内存。迭代器失效与指针失效同理在修改字符串后之前获取的迭代器可能失效。operator[]的 const 重载务必提供const版本否则const对象将无法使用[]运算符。内存对齐与sizeof(char)在memcpy、memcmp时我们乘以sizeof(char)就是1更多是为了代码清晰和模板泛化时的习惯。对于char这不是必须的但对于其他类型则至关重要。reserve与resize的混淆reserve只改容量不改内容和大小resize改大小可能改内容也可能触发容量改变。这是面试常考点。通过这样从设计到实现再到测试和陷阱分析的全过程我们不仅“手撕”出了一个可用的string类更重要的是我们深入理解了C中类设计、资源管理、接口设计的思想。下次当你再使用std::string时你看到的将不再是一个简单的工具而是一个凝聚了诸多设计智慧和妥协的复杂对象。这才是“手撕STL”带来的最大收获。