很多C++学习者都有这样一个阶段:string用了大半年,拼接、查找、替换、截取信手拈来,结果某天被面试官问一句“string的底层实现是怎么样的”,当场卡住。我当年也是这样。真正促使我下决心把string底层研究透的,是一次线上内存泄漏排查:进程内存持续爬升,定位到根因后发现,一个自定义类里放了string成员,因为默认拷贝构造让两个对象共享了同一块堆内存,析构时一个delete了,另一个变成悬垂指针,后续访问直接写坏堆内存结构。
自此我意识到,string底层实现不是无聊的面试考点,它是C++内存管理、值语义、资源获取与释放的最小完整案例。从C++初阶跨向中阶,很多人靠的就是把这块弄明白。这篇文章我会从一个手写迷你string的角度出发,把深浅拷贝、动态扩容、COW写时拷贝与SSO小字符串优化这些核心机制全部摊开讲。每个关键点都会给出代码、解释为什么这样设计,以及实际踩坑记录。你只需要掌握基础的类与指针知识就能看懂;但想把收获最大化,请一定亲手把代码敲一遍。
1. 为什么说string底层实现是C++初阶到中阶的分水岭
1.1 一个字符串类到底要管理什么资源
先别急着写代码,想清楚一个问题:字符串在内存里到底是怎么存的?C语言里我们习惯用一个char*指针指向一段以'\0'结尾的连续内存,字符串的长度靠遍历去算。这种方式完全够用,但代价是所有的内存分配、扩容、释放都得你自己管,稍不注意就漏了或者越界。
C++的string做的事情,本质上就是把这堆脏活累活封装起来。要封装动态内存,一个类至少需要三样东西:指向堆内存的指针(知道数据在哪)、当前有效长度(知道数据有多长)、当前容量(知道已经申请了多少内存,避免频繁扩容)。这三个成员构成string家族最原始的基因。
很多初学者会问:为什么不用一个固定大小的char数组成员?答案有两层。第一,固定大小写死了上限,不够灵活;第二,栈空间很宝贵,默认栈大小通常只有数MB,如果一个字符串对象本身占几百字节,再被放进数组、容器里,栈很容易爆掉。动态堆内存可以按需分配、按需扩容,这是string存在的根基。
1.2 三种主流实现思路:长什么样、为什么存在
string底层实现经历了三个阶段的演进。最朴素的是“深拷贝+堆存储”,每个对象独立持有自己的堆内存,拷贝就是完整复制一份。第二种是“写时拷贝COW”,多个对象共享同一块内存,只有在修改内容时才真正复制。第三种是“小字符串优化SSO”,对象内部预留一小块栈上缓冲区,短字符串直接存在对象里,长字符串才去申请堆内存。
三者的取舍很有意思。深拷贝实现最简单,但拷贝成本高;COW想省拷贝,却引入了引用计数和并发安全问题;SSO是从工程统计出发——绝大多数字符串都很短,直接把短的存对象内部,省掉堆分配的开销。现代主流标准库实现基本都采用SSO方案,具体细节后面有专门一章讲。
| 实现方案 | 拷贝行为 | 内存位置 | 优点 | 主要问题 |
|---|---|---|---|---|
| 深拷贝堆存储 | 每次拷贝复制整个内容 | 堆 | 实现简单、线程安全天然 | 拷贝开销大、短字符串频繁分配堆内存 |
| 写时拷贝COW | 共享内存,修改时才复制 | 堆 | 拷贝接口调用成本低 | 引用计数并发问题、实现复杂、已被标准淘汰 |
| 小字符串优化SSO | 短串在对象内复制,长串堆拷贝 | 栈+堆 | 短字符串零堆分配、缓存友好 | 实现复杂、对象体积变大 |
1.3 学习string底层最有价值的三件事
学string底层,大部分人最后得到的不是一份能背的代码,而是三套通用能力。
第一是彻底搞懂深拷贝与浅拷贝。C++里凡是管理了堆内存的类,默认生成的拷贝构造和赋值运算符都是浅拷贝,必然导致双重释放。理解了String的实现,再去看vector、map这些容器的拷贝行为,思路完全相通。
第二是动态扩容能力。一个容器如何用均摊O(1)的成本完成无数次push_back?答案是按倍数扩容而不是每次加一。这个思想在vector、哈希表、磁盘日志等场景里反复出现。
第三是接口设计的取舍。为什么string提供c_str而不是直接暴露内部指针?为什么at会抛异常而operator[]不抛?这些接口关系实际是工程上安全性与性能的权衡。比如operator[]不检查越界,是为了跟内建数组风格保持一致,追求极致速度;at()检查越界,是为了给debug场景留一条安全通道。理解了这种取舍,你以后设计自己的类,面对“要不要加检查”这种问题时,就不再纠结了。
2. 手写第一版:一份能跑的深拷贝String
2.1 类骨架:成员变量选型
class String { public: // 构造、析构、拷贝、赋值... private: char* _str; // 指向堆上字符数组 size_t _size; // 当前有效字符数(不含'\0') size_t _capacity; // 当前已分配容量(不含'\0') };三个成员各司其职:_str指向真正存字符的堆内存;_size表示用户视角的字符串长度;_capacity表示缓冲区最多能容纳多少字符。为什么capacity要比size大?因为预留空间,push_back时不用每次都触发重新分配。
有个细节值得注意:_str指向的缓冲区末尾始终保留一个'\0',但_size不统计它。这样c_str()可以直接返回_str,不用再临时拼接。这个约定贯穿整个String实现,后面所有修改字符串的接口,都必须在结尾补'\0'。
2.2 构造函数:缺省、带参、拷贝
缺省构造和带const char*构造可以合在一起,用缺省参数实现:
String(const char* str = "") { _size = strlen(str); _capacity = _size; _str = new char[_capacity + 1]; memcpy(_str, str, _size + 1); }这里有两个细节。第一,为什么用memcpy而不是strcpy?strcpy按'\0'停止拷贝,遇到中途含'\0'的数据(比如二进制块)就断。初阶String虽然一般只处理文本,但从设计上养成“长度明确就按长度拷贝”的习惯更好。第二,构造函数里new了堆内存,随之而来的责任是:类里必须自定义析构函数、拷贝构造、拷贝赋值。这就是C++的“三/五法则”:当一个类需要自定义析构函数时,几乎必然也需要自定义拷贝构造和拷贝赋值。只写析构不写拷贝控制,就是给自己埋雷。
2.3 析构函数:delete和delete[]的区别
~String() { delete[] _str; }这里是最经典的坑位。new的是一个字符数组,释放时必须是delete[],写成delete是未定义行为。在部分编译器上delete和delete[]碰巧都能跑,但一旦数组里装的是有自定义析构函数的对象,delete只会调用第一个元素的析构函数,后面元素一概不管,资源泄漏悄悄发生。基本规则必须刻进脑子里:new/delete配对,new[]/delete[]配对。
另外,析构函数里把_str置空或不置空都行,因为对象马上就要销毁了。但我调试时有个小习惯:在析构里打印一条日志。跑一次程序你就知道对象在哪个阶段销毁,排查生命周期问题非常有用。
2.4 拷贝构造:从源头杜绝双重释放
不写拷贝构造,系统会生成默认的浅拷贝:_str指针原样复制,两个对象指向同一块堆内存。析构时第一个对象释放内存,第二个对象再去释放同一块,直接double free崩溃。
自定义深拷贝很简单:
String(const String& s) { _str = new char[s._capacity + 1]; _size = s._size; _capacity = s._capacity; memcpy(_str, s._str, _size + 1); }按s的capacity分配,而不是按size,拷完以后新对象的容量和原对象一致,后续如果还有push_back,可以复用这部分预留空间。如果只按size+1分配,新对象一上来就要扩容一次,白白多一次new/delete。这个细节在性能敏感的代码里会有肉眼可见的差距。
2.5 赋值运算符:先new再delete的顺序问题
拷贝赋值比拷贝构造多一个环节:对象可能已经持有旧内存,得先释放掉。很多初学者写成这样:
// 错误示范 String& operator=(const String& s) { if (this != &s) { delete[] _str; // 先释放旧内存 _str = new char[s._capacity + 1]; // 再申请新内存 memcpy(_str, s._str, s._size + 1); _size = s._size; _capacity = s._capacity; } return *this; }这个写法正常情况下能工作,但异常安全性差:如果new失败抛异常,_str指向的旧内存已经被delete了,对象进入非法状态。正确做法是“先new成功,再delete旧的”,或者用临时对象加swap。先new再delete的版本:
String& operator=(const String& s) { if (this != &s) { char* tmp = new char[s._capacity + 1]; memcpy(tmp, s._str, s._size + 1); delete[] _str; _str = tmp; _size = s._size; _capacity = s._capacity; } return *this; }先申请新内存,成功后再释放旧内存。这样即使new抛异常,原对象仍保持原来的有效状态。自我赋值检查一定要留,否则当s就是*this时,第一步就把自己的内存释放了,后面全是未定义行为。虽然写if (this != &s)的自我赋值检查在深拷贝里不是必需(先new再delete天然兼容),但保留它能让逻辑更清晰,也顺便挡掉了移动赋值里可能出现的坑。
3. 让String真正“能打”:扩容与常用接口
3.1 reserve与扩容策略:为什么按倍数扩容
把核心接口补全,先看reserve和push_back:
void reserve(size_t n) { if (n > _capacity) { char* tmp = new char[n + 1]; memcpy(tmp, _str, _size + 1); delete[] _str; _str = tmp; _capacity = n; } } void push_back(char c) { if (_size + 1 > _capacity) { reserve(_capacity == 0 ? 4 : _capacity * 2); } _str[_size] = c; _str[_size + 1] = '\0'; ++_size; }reserve的本质是“如果新容量大于当前容量,就重新分配一块更大的内存,把内容搬过去”。很多人会想:为什么push_back时不直接每次加1?因为每次加1意味着每插入一个字符就可能触发一次new/delete加整体拷贝,n次插入的总代价是O(n²)。而按倍数扩容时,内存拷贝的总字符数是1+2+4+8+...+2^k,约等于2n,总体是O(n),均摊到每次插入就是O(1)。数据量小看不出来,字符串一旦上百万字符,差异就是天壤之别。
至于扩容倍率选2还是1.5,各有讲究。扩容越大,均摊成本越低,但内存浪费越严重;扩容太小,浪费少但重新分配频繁。STL的vector通常取2或1.5,String的push_back场景也类似。手写版本取2,简单直观,足够用。
3.2 push_back的边界处理
push_back有个最容易错的地方:判断条件要写成_size + 1 > _capacity,把结尾的'\0'也算进去。如果写成_size >= _capacity,看起来差不多,但实际效果是:当_size正好等于_capacity时,当前缓冲区已经没有多余位置放字符了,却可能因为判断失误直接越界写入。这个bug不会每次都崩,但一旦触发就是堆破坏,排查起来极其痛苦。
另一个细节是扩容的起始值。_capacity为0时,我给了4,相当于一次分配4个字符。这个值不一定要很大,但必须非零。标准库的做法会更精细,它会结合SSO本地缓冲判断,但原理一致:给一个小而合理的初始值,避免首次push_back时分配0字节或1字节的内存。
3.3 拼接与访问接口的复用思想
String& operator+=(const String& s) { if (_size + s._size + 1 > _capacity) { reserve(_size + s._size); } memcpy(_str + _size, s._str, s._size + 1); _size += s._size; return *this; } char& operator[](size_t index) { return _str[index]; } const char* c_str() const { return _str; } size_t size() const { return _size; }operator+=实现里没有新写一套扩容逻辑,而是复用reserve。思路是:先判断拼接后的总长是否超过容量,超了就reserve,然后从size位置memcpy,最后更新size。这里用memcpy而不是循环逐个赋值,也是“按长度拷贝”的思路,编译器有机会做向量化优化,性能更好。
真正的std::string还提供append、insert、replace等接口,它们的内部逻辑都是同一个套路:确认容量、移动数据、更新长度、补'\0'。把operator+=理解透,其他接口基本是体力活。
c_str()返回内部指针,意味着调用方一旦通过非const接口修改了字符串内容(比如触发了扩容),之前拿到的const char*就失效了。这是使用层面最常见的陷阱,后面避坑实录会专门展开。
4. 现代std::string的庐山真面目:COW与SSO
4.1 写时拷贝COW:曾经的主流,现在的反面教材
COW的核心思想是:拷贝构造时不复制内容,只让新对象和旧对象共享同一块内存,同时把这块内存前面的引用计数加1;只有当某个对象要修改内容时,才真正复制一份,并把原引用计数减1。
实现上,引用计数一般放在字符串数据的前面。假设分配一个带头的缓冲区:
[引用计数(int)][ 'h' 'e' 'l' 'l' 'o' '\0' ] ^ _str指向这里拷贝构造时,新对象的_str直接指向同一个位置,计数加1;写操作(比如operator[]非const版本、append等)执行前先判断计数是否大于1,若是则复制内容,计数减1,再改写自己那份。这样c_str()可以直接返回_str,计数区对用户完全透明。
COW听起来很美,但有几大致命伤。第一,多线程安全:引用计数的加减必须是原子操作,在多核机器上,get/put计数的cache line竞争非常严重,性能甚至比直接深拷贝还差。第二,C++11之后标准库规定,const成员函数的并发调用必须是安全的——而COW的const操作(比如c_str、operator[] const)也可能要修改共享的引用计数,这就直接踩了标准红线。第三,共享可变状态在多线程环境里容易产生数据竞争,一旦一个线程在写、另一个线程在读,谁也说不清内容到底是什么。所以C++11之后,主流标准库实现陆续放弃COW,转向SSO方案。
4.2 小字符串优化SSO:把短字符串塞进对象内部
现代标准库真正普遍采用的是SSO。它的核心洞察来自工程统计:绝大多数字符串长度都在十几二十个字符以内。于是类内部用一个union同时扮演两个角色——长度短时当字符数组用,长度长时当指针用,指向堆内存。
用GCC系标准库的实现来理解布局:
class string { char* _M_p; // 指向本地缓冲或堆内存 size_t _M_string_length; // 当前长度 union { char _M_local_buf[16]; // 本地缓冲区,能存15个字符加'\0' size_t _M_allocated_capacity; // 堆容量 }; };短字符串时,_M_p指向_M_local_buf,整个String对象被拷贝时,这16字节的字符数组就跟着一起拷走了,零堆分配、零堆释放;长字符串时,_M_p指向堆内存,_M_allocated_capacity记录堆容量。判断当前是本地模式还是堆模式,只需要比较_M_p是否等于_M_local_buf,不需要额外的标志位,非常巧妙。
MSVC系标准库的思路类似:用一个union,本地缓冲验收短字符串,堆指针接收长字符串。所以经常有人问sizeof(std::string)是多少——在常见64位环境下,一个指针是8字节、一个size是8字节、一个union通常也是16字节,加起来基本在32字节左右。不同标准库版本会有细微差异,但这个量级说明一个问题:SSO的代价是string对象本身变大了。一个对象占32字节,换来的是短字符串完全不碰堆分配,对缓存非常友好。
4.3 移动语义:为什么string需要右值引用
回到手写的String,它现在的拷贝成本很高,因为没有任何移动语义。C++11引入右值引用和移动构造后,标准string可以做到:当实参是临时对象时,直接把对方的堆指针“偷”过来,再置空对方,整个过程不分配任何新内存。给手写版加上移动构造:
String(String&& s) noexcept : _str(s._str), _size(s._size), _capacity(s._capacity) { s._str = nullptr; s._size = 0; s._capacity = 0; }注意原对象被置空后,它的析构函数还是会执行delete[],而nullptr的delete是合法且安全的,所以不会出事。有了移动构造,函数按值返回String时,编译器会优先选择移动而不是深拷贝,性能立刻上一个台阶。
同时要提供移动赋值:
String& operator=(String&& 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; }noexcept非常关键。如果不标noexcept,标准容器比如vector在扩容时,会担心移动构造抛出异常导致容器状态不一致,所以宁愿选择拷贝也不动移动,性能优势就消失了。给移动构造和移动赋值标noexcept是约定俗成的规范。
4.4 从这一层看深浅拷贝的完整图谱
现在可以把知识串成一条完整的线:
- 只有析构函数,没有拷贝控制:浅拷贝,双重释放风险。
- 补上拷贝构造和拷贝赋值:深拷贝,安全但昂贵。
- 补上移动构造和移动赋值:临时对象不再深拷贝,性能提升。
- 加上SSO:短字符串连堆分配都省了,拷贝一个String对象可能只是按位复制几十字节。
- 再看COW:试图省拷贝,但因为并发问题被标准淘汰。
面试官问“string的底层实现是什么”,真正想听到的往往是这一整套递进:从浅拷贝到深拷贝,从深拷贝到移动语义,从移动语义到小字符串优化,最后能说出为什么COW被废弃。想明白这条线,std::string在你的认知里就不再是黑盒了。
5. 高频问题与避坑实录
5.1 悬垂指针与迭代器失效
手写版本里reserve之后_str指针会变化,所有在reserve之前拿到的char*或迭代器全部失效。这是string使用中最容易忽略的问题:
String s("hello"); const char* p = s.c_str(); s.push_back('!'); // 触发扩容 // 此时p已经悬垂,访问它是未定义行为真实std::string的行为虽然高度依赖实现,但语义上同样如此:任何导致重新分配的操作都会使迭代器、指针、引用失效。所以在循环里一边扩容一边持有旧指针去访问是典型的未定义行为源头。规则很简单:凡是可能触发扩容的操作之后,重新获取指针,不要复用旧值。
5.2 strlen与sizeof:别傻傻分不清
这类问题在笔试里很常见。对一个const char* s = "hello",strlen(s)是5,sizeof(s)是指针大小,64位下是8;对char arr[] = "hello",sizeof(arr)是6,因为数组名代表整个数组,包含了结尾的'\0'。而对我们的String对象,sizeof(String)是三个成员变量大小之和,不随字符串长度变化。
这个区别的根源是“运行期长度 vs 编译期类型信息”。strlen要扫描到'\0'才停,是O(n);sizeof是编译期常量,零运行成本。不要在热代码里反复调用strlen取长度,先缓存好再用。
5.3 reserve与resize:一字之差,天壤之别
reserve(n)只改容量,不改大小,不会让字符串变长;resize(n)会把字符串长度改成n,多的部分填充,少的部分截断。很多新手把两者弄混,结果reserve之后以为字符串变长了,直接越界访问,程序莫名其妙的崩溃。
一个清晰的用法区分:如果预先知道要拼接多少字符,用reserve预留容量,然后接着用operator+=或append;如果希望字符串的内容长度就是n,用resize。调试时也可以利用resize的语义做清空保容量:s.resize(0),比clear以后再频繁分配高效很多。
5.4 参数传递姿势:const引用是默认选项
函数形参要接收string,只要函数内部不修改内容,就尽量用const String&而不是String。const引用不产生拷贝,还能同时接收临时对象和字符串字面量,非常灵活。
什么时候用值传参?当函数内部需要一份独立副本、且调用者通常传入临时对象时,按值传参配合移动构造,成本并不高甚至更优。一个常见的反模式是:先写const String&,函数内部又需要拷贝一份,于是手动拷贝,这比直接按值传参还多绕一道。正确姿势是交给编译器:临时对象来的时候走移动构造,具名对象来的时候先拷贝,反正成本都清晰可控。
5.5 一次内存泄漏调试记录
最后讲一个我实际遇到的案例。某模块里有一段代码:
class UserInfo { public: UserInfo(const char* name) : _name(name) {} private: String _name; };表面上没问题:构造时把传入的字符串拷贝一份到_name。但问题出在调用方:
std::vector<UserInfo> users; for (int i = 0; i < 100000; ++i) { String name("user_" + std::to_string(i)); users.push_back(UserInfo(name)); }这里name是局部String,UserInfo构造时拷贝一次,vector push_back时又拷贝一次。如果String没有移动构造,整个过程会产生巨量的堆分配与释放。当时我通过在String的各个构造函数里加计数器日志,发现分配次数远超预期,定位到问题后,给String补上移动构造,并把push_back改成emplace_back,内存分配次数直接下降了接近一半。
这个经历说明:把string底层实现搞明白,不光是能答面试题,遇到真实性能问题时,你能快速判断瓶颈是拷贝、是分配还是扩容。这种判断力,就是初阶到中阶最实质的提升。
6. 结束语:这套经验还能迁移到哪
手写String这套思路,稍加改动就能迁移到vector、list、unordered_map的模拟实现上。它们的共同骨架都一样:资源管理部分(构造、析构、拷贝、移动)、容量管理部分(reserve、resize、扩容策略)、访问接口部分(operator[]、迭代器、边界检查)。把String吃透,再去看任何标准容器的源码,都不会觉得无从下手。
我个人在实际操作中的体会是:动手实现一个能跑的String,比看十篇原理文章都有效。你可以从最简单的深拷贝版本开始,跑通所有接口,再尝试加入SSO优化,观察sizeof(String)的变化;更进一步,试着给String加上写时拷贝,看看并发读写时会出现什么问题。这个螺旋上升的过程,会在你脑子里建立一张非常扎实的C++内存知识网。下次再有同事跟你抱怨string传参慢、拷贝慢,你能直接从底层逻辑给出答案,而不只是跟着猜。