很多人第一次接触std::string时,都把它当成一个“会自己长大的字符数组”来用。确实,+、+=、find、substr这些接口用起来太顺手了,以至于我们很少去想:这个对象在内存里到底是什么样子?为什么它比 C 风格的char[]好用这么多?等到面试被问到底层实现,或者项目里真出现性能瓶颈时,才意识到自己对 string 的理解还停留在“会用”的层面。
这篇博文,我想带你从头手写一个可用的 string 类,把底层的内存布局、深拷贝、扩容策略、小字符串优化这些核心问题逐个拆开。内容不求像标准库那么极致,但保证逻辑闭环、能跑能用。适合刚学完 C++ 语法、打算进阶看源码的初学者,也适合准备面试想系统梳理 string 底层原理的人。看完之后,你再回头看std::string,会觉得自己拿到的是一份“有说明书”的代码,而不是一个黑盒。
1. 为什么初学者一定要手写一个 string
1.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 字节甚至更多。为什么一个“字符串”对象要占这么大?答案就是 SSO(Small 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_cast<int>(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_cast<int>(i); } } return -1; } void clear() { m_size = 0; m_data[0] = '\0'; }find的返回值我采用的是很多教学实现的简化做法:找到了返回下标,找不到返回-1。注意static_cast<int>是为了处理下标超过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 移动语义:这个可别漏了
自从 C++11 引入右值引用后,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()是从 C++11 才加入的非强制请求,实现可以选择做也可以选择不做,所以不要依赖它能把内存真正压缩到和 size 一样小。
4.3 为什么 COW 写时复制渐渐不受待见
曾经有很长一段时间,某些标准库实现里 string 用的是 COW(Copy-On-Write,写时复制)。思路是多个 string 对象共享同一块内存,只有发生写操作时才对共享内存做深拷贝复制。听起来很聪明,但后来大家发现它有两个致命问题:
第一,线程安全。当你读取共享内存时,无法保证另一个线程会不会刚好要写入并触发复制。要实现线程安全的共享计数,必须加锁或原子操作,而原子操作本身就有开销,把小字符串的优化空间打没了。第二,C++11 之后移动语义的出现,让不少 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_cast<char>('a' + (i % 26))); }如果不用 reserve,第一次 push 时 capacity=1,第二次又要扩容、拷贝,直到 capacity 增长到足以容纳 1024 个字符。这个过程中可能发生 10 几次分配与复制,看起来不多,但一旦每个字符的代价放大到真实场景的大字符串,性能差异就非常明显。
但也有一种情况别盲目 reserve:你根本不确定最终大小,而且字符串普遍很短。如果上来就reserve(10000),而实际只用了 20 个字符,那块预留的大块内存就是浪费,甚至可能比 SSO 路径慢得多。一个可靠的准则是:预估好增长趋势再 reserve,拿不准就交给扩容策略去兜底。
5.3 移动语义、返回值优化与 string 的配合
从 C++11 开始,函数返回局部 string 时,标准允许编译器做 NRVO(named 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 正常工作,那种感觉远比我在这里写一万字更有价值。