手写C++ string:拆解内存布局、深拷贝与SSO优化

📅 发布时间:2026/10/20 15:54:08
手写C++ string:拆解内存布局、深拷贝与SSO优化
很多人第一次接触std::string时都把它当成一个“会自己长大的字符数组”来用。确实、、find、substr这些接口用起来太顺手了以至于我们很少去想这个对象在内存里到底是什么样子为什么它比 C 风格的char[]好用这么多等到面试被问到底层实现或者项目里真出现性能瓶颈时才意识到自己对 string 的理解还停留在“会用”的层面。这篇博文我想带你从头手写一个可用的 string 类把底层的内存布局、深拷贝、扩容策略、小字符串优化这些核心问题逐个拆开。内容不求像标准库那么极致但保证逻辑闭环、能跑能用。适合刚学完 C 语法、打算进阶看源码的初学者也适合准备面试想系统梳理 string 底层原理的人。看完之后你再回头看std::string会觉得自己拿到的是一份“有说明书”的代码而不是一个黑盒。1. 为什么初学者一定要手写一个 string1.1 这是理解 C 内存管理最直接的一课C 程序员绕不开的话题就是“谁分配、谁释放”。std::string内部管理着一块动态内存它什么时候分配、什么时候扩容、什么时候释放背后全是内存管理的经典套路。手写 string相当于把教科书里的new[]/delete[]、深拷贝、析构函数这些概念全部实战一遍。很多初学者写完一个能跑的 string 之后才真正理解为什么 C 要区分“栈对象”和“堆内存”为什么析构函数不能省为什么拷贝构造和拷贝赋值要一起写。这些经验是看多少篇博客都换不来的必须自己踩过坑、调试过崩溃才能真正内化。1.2 看懂 STL 源码的敲门砖标准库的std::string实现非常复杂直接去读 libstdc 或者 libc 的源码大部分初学者会被模板、分配器、异常处理这些枝叶绕晕。但 string 的核心骨骼其实很清晰一块动态分配的字符缓冲区一个记录当前长度的变量一个记录缓冲区容量的变量只要你自己写出过这个结构再去看那些源码就会发现它只是在你的骨架之上做了大量优化和扩展。先掌握这三点等于拿到了阅读 STL 源码的入场券。从“只能调用接口”到“能读懂内部实现”这个跨越就是手写 string 给你的最大回报。2. 两种主流内存布局从基础版到 SSO 优化版2.1 三成员布局指针 大小 容量最经典的 string 底层布局是三个成员变量class String { private: char* m_data; // 指向堆上的字符数组 size_t m_size; // 当前字符串长度不包含末尾的 \0 size_t m_capacity; // 当前缓冲区能容纳的字符数不含 \0 };这里有个关键设计size 和 capacity 必须分开存。很多人刚上手时只存一个 size每次追加字符就new一块新内存、把旧数据搬过去、再释放旧内存。这样做功能没问题但频繁分配释放的开销非常大。capacity 存在的意义就是“提前多分配一些”让后续追加操作不需要每次都触发内存分配。我见过一种更紧凑的布局用char* begin; char* end; char* finish;三个指针分别指向起始、有效字符末尾、容量末尾。它和“指针 size capacity”表达的信息完全一样只是换了一种存储方式。前者算长度用指针减法后者直接读变量。说实话效率上没有本质差异但在教学场景里“指针 两个整数”读起来更直观。2.2 为什么标准库要搞个 SSO 小字符串优化如果你用一个超大的 string 对象测试就会发现sizeof(std::string)往往不是 24 字节或 32 字节而是高达 32 字节甚至更多。为什么一个“字符串”对象要占这么大答案就是 SSOSmall String Optimization小字符串优化。思路很简单大多数实际场景里的字符串都很短。比如用户名、状态码、单词、文件名平均长度可能就十几个字符。如果这些短字符串也走“堆分配”这条路每次构造都要new[]、析构都要delete[]再加上分配器本身的元数据开销成本高得离谱。SSO 的做法是在 string 对象内部内置一个固定大小的字符缓冲区比如 15 字节或 22 字节取决于实现。当字符串长度不超过这个阈值时直接在这个内部缓冲区里存字符完全不碰堆。只有当长度超过阈值时才切换到堆分配。这个设计在真实项目里能省掉大量小内存块的分配是 string 高性能的关键之一。2.3 SSO 内部缓冲区到底怎么共存问题来了SSO 的内部缓冲区需要占用对象空间而堆模式下又需要指针成员两者怎么共存答案是union。我见过一个简化的 SSO 布局是这样的class String { private: union Buffer { char* m_heap_ptr; // 堆模式指向堆上的数据 char m_local[16]; // SSO模式内部小缓冲区 } m_buffer; size_t m_size; size_t m_capacity; bool m_is_local; // 标记当前处于哪种模式 };这里 union 让 8 字节的指针和 16 字节的缓冲区共用同一块内存对象体积取两者最大值。加上 size、capacity、标志位整个对象就有 32 字节左右。你看这就是为什么 string 对象体积大的原因——它把“短字符串的快速通道”直接塞进了对象本身。我之前看某个开源项目的实现时见过更极限的优化它把 SSO 标志位藏在 capacity 的最高位里省掉了单独的bool还把本地缓冲区凑成 22 字节。这种“扣字节”的优化确实有趣但初学者理解不了也没关系。知道 union 共存这个思路已经是很大一步了。3. 从 0 到 1手写一个可用的 String 类3.1 先搞清最基本的骨架我不打算一步到位直接上 SSO因为那样难点会堆在一起容易劝退。先把堆版本写通再讨论优化。先看基础框架#include cstring #include cstddef #include stdexcept #include algorithm class String { public: // 构造函数默认空串 从 C 字符串构造 String(const char* str ) { m_size std::strlen(str); m_capacity m_size; m_data new char[m_capacity 1]; // 多一个位置放 \0 std::memcpy(m_data, str, m_size 1); } // 析构函数必须释放堆内存 ~String() { delete[] m_data; } size_t size() const { return m_size; } size_t capacity() const { return m_capacity; } const char* c_str() const { return m_data; } private: char* m_data; size_t m_size; size_t m_capacity; };这里有个小细节值得注意m_capacity m_size表示分配多少容量就存多少字符不浪费。但代价是之后每次追加都要扩容。实际上真实场景中通常会多分配一些余量这在第 3.3 节扩容策略里会细说。另一个细节是m_data[m_size] \0这个保证。C 字符串接口比如跟 C 库函数交互要求末尾必须是\0所以c_str()返回的指针必须能直接当const char*用。这一点很容易漏。3.2 深拷贝三件套拷贝构造、拷贝赋值、swap编译器自动生成的拷贝构造只会做“逐成员拷贝”也就是把m_data这个指针复制一份。两个对象共享同一块堆内存后析构的那个会delete[]掉前一个还在用的数据这是经典的“浅拷贝崩溃”。所以必须自己写出深拷贝String(const String other) { m_size other.m_size; m_capacity other.m_capacity; m_data new char[m_capacity 1]; std::memcpy(m_data, other.m_data, m_size 1); }拷贝赋值算是一个容易写错的“隐形陷阱”。新手最容易写出这种// 错误示例自赋值时会崩溃 String operator(const String other) { delete[] m_data; m_data new char[other.m_capacity 1]; std::memcpy(m_data, other.m_data, other.m_size 1); m_size other.m_size; m_capacity other.m_capacity; return *this; }如果写a a第一步就把自己的数据删了后面再拷贝就是非法访问。而且如果new char[]抛异常内存不足这个对象已经被删得只剩半条命后续也没法用。推荐用copy-and-swap 技巧统一处理拷贝赋值void swap(String other) noexcept { std::swap(m_data, other.m_data); std::swap(m_size, other.m_size); std::swap(m_capacity, other.m_capacity); } String operator(const String other) { String temp(other); // 先拷贝构造一份临时对象 swap(temp); // 交换内部状态 return *this; // 临时对象析构时释放旧数据 }为什么推荐这种写法它一次性解决了两个问题自赋值天然安全临时对象和*this互不干扰异常安全如果拷贝构造抛异常*this保持原状态不变我在带新人时发现很多人在写赋值运算符时总想着“怎么把旧内存复用一下”觉得每次新建临时对象再交换是浪费。其实多了一次分配的开销换来的正确性和简洁性绝对值。真到性能敏感场景你可以再单独写一套按容量复用的逻辑但那是后话。3.3 扩容策略1.5 倍还是 2 倍这是个问题reserve和扩容是 string 的核心。先理解为什么需要 reserve如果每次operator(char)都重新分配、拷贝、释放把一个字符一个字符拼接起来的耗时是 O(n²)字符串一长就崩不住。我第一次实现时用的是“每次不够就翻倍”代码很简单void reserve(size_t newCap) { if (newCap m_capacity) return; size_t newCapacity std::max(newCap, m_capacity * 2); char* newData new char[newCapacity 1]; if (m_data ! nullptr) { std::memcpy(newData, m_data, m_size 1); delete[] m_data; } m_data newData; m_capacity newCapacity; } void push_back(char c) { if (m_size 1 m_capacity) { reserve(m_size 1); } m_data[m_size] c; m_data[m_size] \0; }但这里有一个值得讨论的点为什么翻倍不是三倍也不是 1.5 倍翻倍指数扩容可以保证均摊时间复杂度是 O(1)但内存浪费比较大。比如一个 string 变成 100MB 时capacity 可能是 160MB多出来的 60MB 不容易被其他对象复用。1.5 倍在这方面更“省内存”因为每次预留的增量相对更小。很多标准库实现采用接近 1.5 倍的因子。实际测试下来我没有发现哪条策略有压倒性优势。std::string的扩容因子在不同编译环境里并不相同这也说明它不是一个非黑即白的问题。对于教学版 String我建议用 2 倍。原因有三均摊成本容易证明、代码简单、又不至于差到哪里去。真遇到内存敏感的场景再调成 1.5 倍也不迟。顺带说一个常见的坑reserve的参数是“计划容纳的字符数”它只会增加 capacity不会改变 size也不会截断已有内容。很多人以为reserve(100)之后operator[]就能越界访问这是错误的。实现operator和operator也很直观String operator(const char* str) { size_t len std::strlen(str); if (m_size len m_capacity) { reserve(m_size len); } std::memcpy(m_data m_size, str, len); m_size len; m_data[m_size] \0; return *this; }这里注意一个细节memcpy自己拷的时候如果str恰好指向*this内部的缓冲区比如s s.c_str() 1这种自追加场景那么m_data m_size和src可能有重叠。稳妥起见可以用std::memmove替代std::memcpy它在重叠时也能保证正确行为。3.4 常用接口查找、比较与修改一个“能用”的 string 除了拼接少不了一些基本操作。我把几个容易忽略边界条件的接口列出来int find(char c) const { for (size_t i 0; i m_size; i) { if (m_data[i] c) return static_castint(i); } return -1; } int find(const char* str) const { if (str nullptr || m_size 0) return -1; size_t len std::strlen(str); if (len 0) return 0; for (size_t i 0; i len m_size; i) { if (std::strncmp(m_data i, str, len) 0) { return static_castint(i); } } return -1; } void clear() { m_size 0; m_data[0] \0; }find的返回值我采用的是很多教学实现的简化做法找到了返回下标找不到返回-1。注意static_castint是为了处理下标超过int上限的问题在 64 位系统上size_t转int可能溢出所以这里显式转换表示“技术上超出了 int 范围”。标准库的做法是返回npos也就是(size_t)-1你可以按这个思路调整。比较操作符建议直接复用标准库的strcmp没必要自己逐字符比bool operator(const String other) const { return std::strcmp(m_data, other.m_data) 0; }3.5 移动语义这个可别漏了自从 C11 引入右值引用后string 的实现又多了一个关键点移动构造和移动赋值。如果你只写深拷贝那么把一个 string 作为返回值传给另一个对象时会白白多一次深拷贝。写了移动构造后就能把临时对象的内部资源“偷”过来String(String other) noexcept : m_data(other.m_data), m_size(other.m_size), m_capacity(other.m_capacity) { other.m_data nullptr; other.m_size 0; other.m_capacity 0; } String operator(String other) noexcept { if (this ! other) { delete[] m_data; m_data other.m_data; m_size other.m_size; m_capacity other.m_capacity; other.m_data nullptr; other.m_size 0; other.m_capacity 0; } return *this; }这里有个非常隐蔽的坑移动构造把源对象的m_data置空后源对象的析构函数依然会执行delete[] m_data。delete[] nullptr是安全的所以没问题。但如果你忘了把源对象置空源对象析构时就会把刚移动过来的内存释放掉。我当年第一次写移动构造就踩过一次这个坑调试半天才发现是析构把数据“抢”走了。4. 底层实现的高频疑难与避坑实录4.1 深浅拷贝 → 悬垂指针与双重释放深拷贝的设计直接关系到对象生命周期。没有正确实现深拷贝时最容易出现的两个问题悬垂指针对象 A 和对象 B 共享同一块堆内存。A 析构后B 里的指针还指向那块已经释放的内存再访问就是未定义行为表现可能是乱码、崩溃、或者碰巧正常。双重释放A 和 B 都析构两块堆内存被delete两次直接触发运行时错误。我调试过的实际案例里这种现象经常发生在“把一个 string 放进 vector然后 vector 扩容拷贝”的场景。vector 扩容时会调用拷贝构造生成新元素然后销毁旧元素。如果拷贝是浅拷贝旧元素析构时新元素的数据就没了程序崩溃是必然的。理解了底层你就能想明白凡是管理堆内存的类三/五法则必须完整。4.2 迭代器与引用失效c_str 不是定心丸用std::string时有时会这样写const char* p s.c_str(); s more data; // 此时 p 还有效吗如果operator触发了扩容它会delete[]旧缓冲区并new[]一块新缓冲区p指向的旧内存已经不再属于这个对象。继续用p就是悬垂指针。这个问题的根因是扩容会导致底层存储地址发生改变所有指向旧缓冲区的指针、迭代器、引用同时失效。如果只是修改字符内容不触发扩容地址不变失效不成立但只要触发了重新分配一切归零。所以凡是要长期保存一个指向 string 内部数据的指针时先问问自己“这个 string 后续还会不会扩容”。如果拿不准就尽量存偏移量而不是存指针或者提前reserve足量容量让指针保持稳定。说到reserve我提一个很多人不知道的小知识标准库里reserve(0)是合法的它只是把 capacity 调整到不小于 size 的值。而shrink_to_fit()是从 C11 才加入的非强制请求实现可以选择做也可以选择不做所以不要依赖它能把内存真正压缩到和 size 一样小。4.3 为什么 COW 写时复制渐渐不受待见曾经有很长一段时间某些标准库实现里 string 用的是 COWCopy-On-Write写时复制。思路是多个 string 对象共享同一块内存只有发生写操作时才对共享内存做深拷贝复制。听起来很聪明但后来大家发现它有两个致命问题第一线程安全。当你读取共享内存时无法保证另一个线程会不会刚好要写入并触发复制。要实现线程安全的共享计数必须加锁或原子操作而原子操作本身就有开销把小字符串的优化空间打没了。第二C11 之后移动语义的出现让不少 COW 的需求被“偷资源”的方式替代而且移动语义比 COW 更容易推理不会出现“我以为拷贝了其实在共享”的意外。所以现在主流标准库实现基本都放弃了 COW转向 SSO。这给我的启示是优化手段要和语言特性、硬件环境同步迭代不能只看教科书上的理论。5. 性能优化进阶把 String 按真实需求打磨5.1 亲手实现一个简化版 SSO前面说了 SSO 的思路这里我给一个简化版的实现框架能运行、能理解class String { public: String(const char* str ) { m_size std::strlen(str); if (m_size LOCAL_CAP) { m_is_local true; std::memcpy(m_local, str, m_size 1); } else { m_is_local false; m_heap new char[m_size 1]; std::memcpy(m_heap, str, m_size 1); m_capacity m_size; } } ~String() { if (!m_is_local) delete[] m_heap; } private: static const size_t LOCAL_CAP 15; union { char* m_heap; char m_local[LOCAL_CAP 1]; }; size_t m_size; size_t m_capacity; bool m_is_local; };核心逻辑是所有写操作前先检查m_is_local一旦长度将要超过阈值就把本地缓冲区的数据搬移到堆上并切换标志。这个切换只发生一次之后一直走堆路径。为什么不直接把LOCAL_CAP设成 32 或更大因为内部缓冲区是对象的一部分它越大sizeof(String)越大。当 string 被放进 vector 或者作为函数参数时每个元素占用内存就越多缓存命中率和拷贝成本都会恶化。选择 15 或 22 这些数字是标准库实现方反复测量后的平衡点并非拍脑袋。5.2 什么时候该用 reserve什么时候别用用reserve的目标只有一个避免多次扩容带来的反复分配与拷贝。最典型的场景是循环拼接String s; s.reserve(1024); // 提前预留避免循环里一次次扩容 for (int i 0; i 1024; i) { s.push_back(static_castchar(a (i % 26))); }如果不用 reserve第一次 push 时 capacity1第二次又要扩容、拷贝直到 capacity 增长到足以容纳 1024 个字符。这个过程中可能发生 10 几次分配与复制看起来不多但一旦每个字符的代价放大到真实场景的大字符串性能差异就非常明显。但也有一种情况别盲目 reserve你根本不确定最终大小而且字符串普遍很短。如果上来就reserve(10000)而实际只用了 20 个字符那块预留的大块内存就是浪费甚至可能比 SSO 路径慢得多。一个可靠的准则是预估好增长趋势再 reserve拿不准就交给扩容策略去兜底。5.3 移动语义、返回值优化与 string 的配合从 C11 开始函数返回局部 string 时标准允许编译器做 NRVOnamed return value optimization命名返回值优化直接把对象构造到调用方的存储空间省掉拷贝和移动。就算编译器没有做 NRVO也会退而求其次选择移动构造而不是深拷贝。这就是为什么你写std::string s makeString();几乎不需要担心大字符串的拷贝开销的原因。但前提是你的类写了移动构造。如果你只写拷贝构造没写移动构造那么返回时即使临时对象马上要销毁编译器也只能乖乖拷贝。我在项目里见过有人为了方便给自定义类型只写了拷贝构造结果函数返回一个大容器时性能掉得厉害加个移动构造就把耗时的深拷贝消除了。这个经验在自定义类里非常通用不只对 string。6. 实测对比与个人经验小结为了验证前面讲的这些设计我做了一个小实验分别用“每次追加都重新分配”的极简版、“翻倍扩容”的版本和“SSO 翻倍扩容”的版本拼接 100 万个字符。结果没有悬念极简版慢到没法忍因为反复new[]/delete[]的次数太多翻倍扩容版很快均摊 O(1) 的优势在长字符串上体现得淋漓尽致SSO 版本在处理几百个短字符串时的优势非常明显堆分配次数接近零如果你写业务代码这些细节平时可能感觉不到但一旦代码是核心路径上的高频操作一次多余的堆分配都可能成为热点的起点。最后分享几个我自己沉淀下来的习惯。第一任何管理堆内存的自定义类三法则或五法则永远写全。偷懒省掉移动构造最终会在某个性能测试里加倍还回来。第二写扩容逻辑时顺手加上std::memmove而不是std::memcpy防止自追加的重叠问题。虽然标准场景里memcpy够用但少一个隐患总归是好事。第三拿到一个 string先想清楚它是不是只读的。如果只读尽量传const std::string或std::string_view而不是复制一份如果要长期保存指针提前reserve。这篇就是从零开始把 string 的底层实现走了一遍。纸上得来终觉浅我强烈建议你放下这篇文章自己打开编辑器从空类开始一步一步补齐这些代码。等你真的跑通那些边界测试、真的看到自己的 String 能配合 vector 正常工作那种感觉远比我在这里写一万字更有价值。