☰
从零手写C++ string:深拷贝、移动语义与SSO优化全解析
2026/10/20 15:54:04 网站建设 项目流程

很多学C++的朋友都会在某个阶段冒出同一个念头:string不就是个封装好的字符数组吗?直接拿char*不也能干活?真到自己动手去写string、去读STL源码的时候才发现,底层要考虑的事情远比想象中多。这篇不是源码逐行注释,而是一次"带着问题去实现"的过程记录。我会从对象布局讲到内存管理,再讲深拷贝、移动语义、扩容和SSO优化,最后是一堆调试现场踩出来的坑。这些内容适合刚把语法过完、准备进阶的初学者,也适合工作几年但没系统梳理过容器实现的人当作查缺补漏。

1. 从char*到 string:为什么必须有一个"类"来管理内存

1.1 裸指针的三大痛点,string 到底解决了什么

先看一段很常见的C风格字符串操作:

const char* name = "hello"; char buf[32]; strcpy(buf, name); strcat(buf, ", world");

表面看没啥问题,但稍微复杂点的场景立刻翻车。第一个痛点是缓冲区溢出,strcpy不会检查目标缓冲区够不够大,一旦源字符串超长,内存越界是必然的。第二个痛点是手动内存管理,如果字符串长度在运行时才能确定,就得malloc,用完了还得记得free,中间任何分支提前return都会内存泄漏。第三个痛点是字符串操作函数都是"裸函数",strlen、strcmp、strcat全都围着char*转,既没有边界信息也没有所有权概念。

string 把这些问题收敛到一个类里。它内部维护了三个核心数据:指向字符缓冲区的指针、当前字符串长度、当前缓冲区容量。所有操作都围绕这三者展开,越界、泄漏、忘记初始化这类问题大部分在类内部就消化掉了。

1.2 自己写 string 前必须想清楚的三个设计问题

动手实现之前,我建议先回答三个问题,这三个问题直接决定代码架构。

第一个问题:谁拥有缓冲区内存?string 对象拷贝的时候,是共享同一块内存还是各自复制一份?答案通常是各自复制,也就是深拷贝。这样语义最简单,也最安全,但不一定是最快的方案。

第二个问题:缓冲区是固定大小还是动态增长?动态增长意味着每次追加内容都可能发生 realloc,如果每次只多申请一个字节,性能会非常难看。必须有合理的扩容策略。

第三个问题:短字符串也要走堆内存吗?如果字符串只有几个字符,却每次都在堆上分配一块内存,开销性价比太低了。这就是后来的 SSO(Small String Optimization)要解决的。

想清楚这三个问题,再往下看就顺了。

2. 手写一个最简 string:对象布局与基础框架

2.1 成员变量怎么定:size、capacity、pointer 缺一不可

先把最朴素的版本写出来。成员变量直接对应前面说的三个问题:

class MyString { public: // 构造、析构、拷贝、赋值等成员函数 private: char* data_; // 指向字符缓冲区的指针 size_t size_; // 当前有效字符数(不含结尾的'\0') size_t capacity_; // 缓冲区最多能容纳的字符数(不含结尾的'\0') };

为什么要同时存size_和capacity_?因为字符串长度和缓冲区大小是两回事。我只存了10个字符,但缓冲区可能已经申请了32个字符的空间。size_告诉外面"现在有多少内容",capacity_告诉内部"还能往缓冲区里塞多少字符而不需要重新分配"。这有点像酒店的房间总数和入住人数的区别:capacity是房间总数,size是已经住进去的人数。没有了后者,追加操作就无法判断是否触顶。

2.2 构造函数和析构函数的正确姿势

最简单的构造是从C字符串构造:

MyString::MyString(const char* str) { if (str == nullptr) { data_ = new char[1]; data_[0] = '\0'; size_ = 0; capacity_ = 0; return; } size_ = strlen(str); capacity_ = size_; data_ = new char[capacity_ + 1]; // 多出来的1给'\0' memcpy(data_, str, size_ + 1); // 连结束符一起复制 }

注意两点。第一,capacity_ = size_意味着刚构造出来的对象没有多余空间,第一次追加肯定触发扩容,这是合理的,因为不确定这个字符串会不会继续变长。第二,给空字符串分配了1个字节放'\0',这个细节很多人会漏。漏掉的话,所有依赖c_str()返回合法C字符串的逻辑都会出问题。

析构函数倒很简单,但必须写:

MyString::~MyString() { delete[] data_; }

我曾经见过有人写string不用析构函数,理由是"反正程序退出会回收内存"。这在测试demo里确实看不出问题,但在循环里创建临时字符串时,内存就像漏水的桶一样不停涨。RAII是C++的立身之本,析构和构造同等重要。

3. 拷贝、赋值与移动:浅拷贝翻车现场

3.1 默认拷贝为什么是埋雷

如果上面这个类不写拷贝构造函数,编译器会默认生成一个逐成员拷贝。结果就是两个 string 对象指向同一块data_缓冲区。看起来还凑合?一旦其中一个对象析构,delete[]把缓冲区释放掉,另一个对象就变成悬空指针。这还算运气好的,如果两个对象都析构,就是 double free,程序直接崩溃。

这其实是C++里最经典的坑。拿String a = b;来说,如果没有深拷贝构造,a和b共享同一块内存空间,改a的内容会同时影响b。很多刚入门的同学在这里栽过跟头。

必须自己实现拷贝构造:

MyString::MyString(const MyString& other) { size_ = other.size_; capacity_ = other.capacity_; data_ = new char[capacity_ + 1]; memcpy(data_, other.data_, size_ + 1); }

这里memcpy复制了size_ + 1个字节,刚好把结尾的'\0'也带上。复制整个容量范围可以吗?可以,但慢。只复制有效字符加结束符就够了,因为缓冲区里超出size_的部分本来就是未初始化的脏数据,复制过来没有任何意义。

3.2 赋值运算符的三个经典检查

赋值运算符比拷贝构造多一个陷阱:它面对的对象可能已经有缓冲区了。如果你直接delete[] data_再重新分配,万一传进来的实参就是对象本身,等于一边释放一边拷贝,直接崩溃。所以必须做自赋值检查:

MyString& MyString::operator=(const MyString& other) { if (this == &other) { return *this; } if (capacity_ < other.size_) { delete[] data_; capacity_ = other.size_; data_ = new char[capacity_ + 1]; } size_ = other.size_; memcpy(data_, other.data_, size_ + 1); return *this; }

注意到一个小优化:如果本身容量够用,就不释放重新分配,直接复用旧缓冲区。这个 copy-and-reuse 的思路在实际工程里很常见,避免频繁分配内存。

赋值运算符还有一个必须做对的事:返回*this的引用。这是为了让a = b = c这种链式赋值能够工作。返回引用就避免了中间对象的构造与析构。

3.3 移动构造:把"深拷贝"变成"偷指针"

深拷贝很安全,但有些场景下很浪费。函数返回一个临时string时,如果不做任何优化,会拷贝一次根本没人要的临时对象。C++11引入右值引用后,这个场景用移动构造解决:

MyString::MyString(MyString&& other) noexcept : data_(other.data_) , size_(other.size_) , capacity_(other.capacity_) { other.data_ = nullptr; other.size_ = 0; other.capacity_ = 0; }

移动构造的实质是"转移所有权":把别人的指针直接拿过来用,然后立刻把别人置空。注意noexcept很重要,标准库容器在扩容重分配时会优先用移动构造,但前提是这个移动操作保证不抛异常,否则它会退回去用拷贝构造。

我实际测试过,启用移动语义后,vector里频繁push_back string的性能提升非常明显,因为不再反复深拷贝了。

4. 容量管理:扩容策略与SSO小字符串优化

4.1 扩容为什么必须是倍增

push_back 追加字符时,如果发现size_ == capacity_,就说明缓冲区满了,必须扩容。最直接的想法是"加1个字节,刚好够用",这是性能灾难:每次都重新分配、复制全部字符,push_back 的时间复杂度退化成 O(n²)。

正确的做法是成倍扩容。常见标准库实现是每次增长到原来的1.5~2倍。为什么是2倍?因为均摊下来每个字符的复制次数是常数,push_back 均摊复杂度 O(1)。为什么有些实现用1.5倍而不是2倍?因为如果内部有个vector ,2倍扩容会导致之前分配的内存无法被后续对象复用,整体内存碎片更高。对单容器的string来说,2倍最直观。

void reserve(size_t n) { if (n <= capacity_) { return; } char* new_data = new char[n + 1]; memcpy(new_data, data_, size_ + 1); delete[] data_; data_ = new_data; capacity_ = n; } void push_back(char c) { if (size_ >= capacity_) { size_t new_cap = capacity_ == 0 ? 16 : capacity_ * 2; reserve(new_cap); } data_[size_++] = c; data_[size_] = '\0'; }

注意new_cap首次扩容不要从1开始翻倍,直接给个16,避免太小的初始容量导致前几次扩容都是白干活。这个"首扩容保底值"可以当作经验值来看。

4.2 SSO:短字符串根本不走堆

堆分配的开销有多大?一次new[]在用户态可能只要几纳秒,但涉及分配器锁、空闲链表查找,在频繁构造短字符串的场景下损耗不小。比如一个函数内部构造了临时字符串std::string msg = "ok";,每次调用都要堆分配,性能显然不好。

大厂的标准库实现普遍采用 SSO 策略:在 string 对象内部直接放一个固定大小的字符数组,当字符串长度小于等于某个阈值(通常是15或22),直接存在对象内部,不碰堆。只有超出阈值时才切换到堆缓冲区。

手写SSO其实不复杂。核心思路是把原来char* data_变成一个联合体:

static constexpr size_t SSO_MAX = 15; union Buffer { char* ptr; // 长字符串用堆指针 char local[SSO_MAX + 1]; // 短字符串直接用对象内数组 };

加一个布尔标志记住当前走的是堆还是local。这样一来,sizeof(MyString) 会变大一点,但短字符串操作完全免掉堆分配。对高频构造、频繁复制的小字符串来说,收益是实打实的。

我在项目里做过对比:用默认参数的std::string和手动禁止SSO的全堆版跑同样的字符串拼接循环,时间差了将近两倍。初学阶段不一定需要自己实现SSO,但一定要理解它的存在意义,否则看到相同的字符串对象内存地址时容易被绕晕。

4.3 reserve、shrink_to_fit 和 clear 的底层行为

reserve(n)的作用是提前扩容,多次追加字符串时就省去反复扩容的代价。比如在一个循环里逐步拼SQL语句,提前reserve(1024)能避免几十次 realloc。

shrink_to_fit相反,它把容量缩回到刚好等于size。这个操作通常要分配新缓冲区、复制内容、释放旧缓冲区,开销不小。标准库也不强制它一定生效,只是"请求"缩容。日常开发里不建议频繁调用,只有在确认字符串不再变化时可以调一次节省内存。

clear()比较有意思。它通常只把size置为0,并在第0位写'\0',并不会释放缓冲区。也就是说clear()之后 capacity 不变,立即再 push_back 不会触发扩容。这个行为很多人误解为"清空并释放内存"。

5. 常用接口背后的实现细节

5.1 operator[]、at 与越界检查的分工

operator[]不检查越界,直接返回data_[pos],速度快,但行为是未定义的。at(pos)会先判断pos >= size_,是则抛出std::out_of_range,多了一次比较和分支判断,性能略差。

char& operator[](size_t pos) { return data_[pos]; } char& at(size_t pos) { if (pos >= size_) { throw std::out_of_range("MyString::at: pos out of range"); } return data_[pos]; }

这里有个隐藏细节:正常字符串末尾的'\0'可以通过operator[]访问吗?下标等于size()时访问到的就是结束符,这在C++11标准里是允许的,但下标大于size就是越界。

5.2 find 与比较运算如何保证高效和一致

朴素find实现就是双层循环匹配。标准库里的实际实现会用BM、KMP之类的算法,但对短字符串,简单遍历通常已经足够快。

size_t find(const MyString& needle, size_t pos = 0) const { if (needle.size_ == 0) { return pos <= size_ ? pos : npos; } for (size_t i = pos; i + needle.size_ <= size_; ++i) { size_t j = 0; while (j < needle.size_ && data_[i + j] == needle.data_[j]) { ++j; } if (j == needle.size_) { return i; } } return npos; }

比较运算operator<的底层逻辑是按字典序逐字符比较,等价于memcmp但遇到'\0'不会停下来,必须精心处理长度差异。很多人直接拿strcmp去比较 string 的内部缓冲区,遇到内嵌'\0'的字符串就出错,因为C字符串函数会把结束符当成终点,而string是可以合法包含'\0'的。

5.3 c_str()、data() 和长度关系

C++11之后,data()和c_str()行为完全一致,都返回以'\0'结尾的 C 风格字符串。这个保证要求内部缓冲区在size_位置始终维护一个'\0',我在前面 push_back 的实现里特意写了一句data_[size_] = '\0',就是为了维持这个不变量。

如果哪次操作忘了维护结束符,外部拿到c_str()就可能读到越界内容。这在调试版上很难查,因为往往要到下游用strlen时才炸。把"结尾始终保持'\0'"作为类的一个明确不变量,是写好string的关键之一。

6. 调试实录:字符串类最容易翻车的几个现场

6.1 悬空引用:返回了内部指针也不会报警

我写过一个返回char*的接口:

char* getData() { return data_; }

调用方把它存起来,之后又对string对象做了append操作。扩容触发了delete[] data_; data_ = new_data;,之前那个指针一下子变成悬空的。这种Bug在Release版上极其隐蔽,因为它不会立刻报错,直到下一次分配覆盖了那块内存才会呈现为乱码。

正确的做法是:外部如果需要长期持有数据,就自己复制一份;短期的临时访问则控制在string的生命周期和容量不变周期内。比如先string s = someFunc(); const char* p = s.c_str();用完p后马上结束,别再穿插对s的任何写操作。

6.2 迭代器失效:扩容之后整个世界都不一样了

string 的迭代器本质就是指针。扩容后旧迭代器全失效。曾经在循环里这样写:

for (auto it = s.begin(); it != s.end(); ++it) { s.push_back('x'); // 扩容后 it 可能失效 }

这里不崩溃是运气好。正确做法是在变动后再重新取迭代器,或者按下标访问。想顺手练习的同学,可以观察vector里存string时的迭代器失效场景,那件事更常见。

6.3 屏蔽调试:如何快速定位越界写

字符串越界写常见的表现是:声明了一个局部string,在它后面定义的变量值突然变掉,或者堆内存被破坏最后崩在free()。用Valgrind或者AddressSanitizer能快速定位:

g++ -fsanitize=address -g test.cpp -o test ./test

一旦有越界写,程序会直接崩在出错点并打印具体调用栈,比在乱码里碰运气高效得多。建议初学自研string时全程开着ASan,能抓出一大半内存问题。

6.4 一个小小的性能自查:避免不必要的深拷贝

用自研string做大量字符串拼接时,语义是安全的,但性能可能出乎意料地差。揭开迷雾的关键是看临时对象数量和分配次数。比如:

MyString s = "a"; for (int i = 0; i < 100000; ++i) { s = s + "b"; // 生成临时对象、深拷贝、再析构 }

换成s.push_back('b'),性能立刻上升了一个数量级。底层原理是避免了每次循环里的堆分配和深拷贝。选对操作接口,很多时候比重构算法更立竿见影。

写在最后的一点心得

把string亲手写一遍,最大的收获不是"我实现了库",而是真正摸清了RAII、深拷贝、移动语义、迭代器失效这些概念的来龙去脉。读源码时觉得那些函数很枯燥,但当自己设计并踩坑后,再看标准库实现就完全换了一副眼光。后面再深入学习vector、list,或者自己写智能指针,这套"先实现、再看源码、再复盘"的学习节奏都适用。如果时间紧张,可以先只实现不带SSO的基础版本,把核心的扩容和拷贝语义跑通,SSO留到第二遍再补。每个阶段都有自己的收获,没必要一步到位。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询