
前言
学 C++ 到一定阶段,
std::string用起来顺手,但总感觉底下有一片黑盒子。我拷贝一个字符串,内存是怎么分配的?两个对象赋值,旧资源去哪了?函数结束时发生了什么?带着这些问题,我决定自己实现一遍 string 类。这篇文章记录整个过程:包括每个函数的设计思路、我踩过的坑、以及由此引出的 C++ 对象语义。如果你也在这个阶段,这篇文章大概对你有用。
目录
[一、为什么 string 是一个"资源管理类"](#一、为什么 string 是一个"资源管理类")
[四、深拷贝 vs 浅拷贝,以及 double free](#四、深拷贝 vs 浅拷贝,以及 double free)
[六、swap 的实现与意义](#六、swap 的实现与意义)
[九、PushBack 和 append](#九、PushBack 和 append)
[十五、c_str() 的意义](#十五、c_str() 的意义)
[十六、operator<< 和 operator>>](#十六、operator<< 和 operator>>)
[operator>>(我踩的最隐蔽的 bug)](#operator>>(我踩的最隐蔽的 bug))
[十七、引用计数:为什么现代 string 不用它](#十七、引用计数:为什么现代 string 不用它)
一、为什么 string 是一个"资源管理类"
在开始写代码之前,先理解 string 的本质。
普通的 int、double 变量,值存在栈上,函数结束自动消失,不用操心。但 string 不一样------字符数据存在堆上,对象只持有一个指针。这块堆内存的生命周期需要手动管理:什么时候分配、什么时候释放、被复制的时候怎么处理。
这种"持有资源、负责管理资源生命周期"的类,在 C++ 里叫做 RAII 类(Resource Acquisition Is Initialization)。string 就是最典型的例子之一:
- 构造函数:获取资源(new 内存)
- 析构函数:释放资源(delete 内存)
- 拷贝构造 / 赋值:处理资源的复制或转移
理解了这一点,后面所有的设计决策都能说清楚了。
二、整体结构设计
我选用三个成员变量来描述一个字符串的完整状态:
cpp
private:
char* _str; // 指向堆上的字符数组
size_t _size; // 当前字符串长度(不含 '\0')
size_t _capacity; // 当前已分配容量(不含 '\0')
static const size_t npos = -1;
_str 是真正存数据的地方,_size 记录当前有多少个字符,_capacity 记录申请了多大的空间。三者之间的关系始终满足:
cpp
_size <= _capacity
实际分配字节数 = _capacity + 1(多一个给 '\0')
npos 定义为静态常量,值是 (size_t)-1,也就是 size_t 类型的最大值(在 64 位系统上是 0xFFFFFFFFFFFFFFFF)。它的语义是"不存在"或"到末尾",和标准库一致。
关于头文件和源文件的分离:我把函数声明放在 String.h,定义放在 String.cpp,原因是避免重复定义。如果把函数体写在 .h 里,每个 include 这个头文件的翻译单元都会有一份定义,链接时报重定义错误。声明放 .h、定义放 .cpp 是 C++ 的标准做法。
整个类放在 namespace Jianyi 里。namespace 的作用是防止命名冲突------我们自己实现的 string 和标准库的 std::string 同名,用 namespace 隔开就不会互相干扰。
三、构造函数与析构函数
构造函数
cpp
string(const char* str = "")
{
assert(str);
_size = strlen(str);
_capacity = _size;
_str = new char[_capacity + 1];
strcpy(_str, str);
}
几个细节值得说:
首先,参数默认值是 ""(空字符串),而不是 nullptr。这样 string s; 和 string s(""); 都能工作,而且 assert(str) 能拦住真正传 null 的情况。
其次,new char[_capacity + 1] 多申请一个字节。_capacity 不计入 '\0',但数组必须留位置给它,strcpy 依赖这个终止符。这个"差一"的约定在整个实现中要保持一致,是容易出错的地方。
strcpy 会把 str 包括终止符在内整个复制过去,所以不需要单独写 _str[_size] = '\0'。
析构函数
cpp
~string()
{
delete[] _str;
_str = nullptr;
_size = _capacity = 0;
}
delete[] 对应 new[],这是硬性规则。如果用 delete(不带方括号),行为是未定义的------对于内置类型数组通常侥幸没问题,但不能依赖。
析构后把 _str 置为 nullptr,是防御性编程:如果析构后有代码试图再次访问这个指针,至少不会访问已释放的内存(会立即崩溃,比静默地读到垃圾数据容易发现问题)。
delete[] 的本质:它先调用数组中每个元素的析构函数(对 char 无意义,但对对象数组很重要),再释放整块内存。这是 new[] / delete[] 必须配对的原因------分配时 new[] 会在内存块头部记录元素数量,delete[] 靠这个数量来逐一调用析构函数。如果用 delete 释放,这个元数据就不会被读,析构函数就不会被逐一调用。
四、深拷贝 vs 浅拷贝,以及 double free
这是整个 string 实现最核心的概念。
浅拷贝 :直接复制成员变量的值。对于 _str 来说,就是复制指针的值,两个对象指向同一块堆内存。
s1._str ──┐
▼
[ h e l l o \0 ]
▲
s2._str ──┘
这个结构有致命问题:当 s1 和 s2 分别析构时,同一块内存会被 delete[] 两次,这就是 double free。double free 是未定义行为,轻则程序崩溃,重则内存结构被破坏、引发安全漏洞。
如果不手动实现拷贝构造函数和赋值运算符,编译器默认生成的版本就是浅拷贝。所以,凡是持有堆内存的类,必须自己实现"Big Three":析构函数、拷贝构造函数、赋值运算符。
深拷贝:为新对象单独申请内存,把数据完整复制一份。
s1._str ──► [ h e l l o \0 ]
s2._str ──► [ h e l l o \0 ] (独立的一份)
两者独立,析构互不影响。代价是每次拷贝都需要一次 new,但安全性有保证。
五、拷贝构造函数(我踩的第一个大坑)
我注释掉的第一版:直接深拷贝
html
// 被注释掉的版本
string(const string& s)
{
_str = new char[s._capacity + 1];
strcpy(_str, s._str);
_size = s._size;
_capacity = s._capacity;
}
这个版本逻辑上是完全正确的,就是朴素的深拷贝:给自己申请内存,把对方的数据复制过来。写法清晰,没有问题。
我最终用的版本:copy-and-swap
cpp
string(const string& s)
{
string tmp(s._str); // 用 char* 构造函数创建临时对象
swap(tmp); // 把 *this 和 tmp 互换
} // tmp 析构,释放 *this 原来(未初始化)的资源
这里用的是 copy-and-swap 惯用法 。理解这个版本,关键是搞清楚 swap 交换的是谁。
swap(tmp) 等价于 this->swap(tmp),交换的是 *this 和 tmp。执行完之后,*this 持有 tmp 创建的那块内存(也就是从 s._str 深拷贝来的数据),tmp 则拿走了 *this 原来的内容。由于 tmp 是局部变量,函数结束时它会自动析构,顺带释放掉 *this 原来那块资源。
我曾经写错的版本
// 错误写法!!
string(const string& s)
{
string tmp(s._str);
swap(s); // swap 的对象是 s,不是 tmp!
}
问题有两个:第一,swap(s) 交换的是 *this 和参数 s,tmp 根本没被用到;第二,s 是 const string&,不能被修改,而 swap 需要修改双方,这从设计上就矛盾了。
当时我把 swap 的签名也写错了:
void swap(const string& s) // 错误:const 参数无法被修改
正确的签名应该是:
void swap(string& s) // 去掉 const
swap 必须修改双方,参数加 const 在逻辑上就说不通。
六、swap 的实现与意义
cpp
void swap(string& s)
{
std::swap(_str, s._str);
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
}
这里内部调用的是 std::swap,交换的是指针和两个整数。不管字符串有多长,只有三次赋值操作,时间复杂度永远是 O(1)。
STL 为什么追求 O(1) 的 swap?因为 swap 在标准库算法中被大量使用(排序、partition、各种容器操作),如果 swap 是 O(n) 的,这些算法的复杂度就会恶化。
这里有一个关于模板的细节:std::swap 的通用实现是三次赋值(tmp = a, a = b, b = tmp),对 string 来说意味着两次深拷贝,是 O(n) 的。但标准库对 std::string 有特化版本,会调用成员函数 swap,也就是我们实现的这个版本,降回 O(1)。
普通函数和函数模板的优先级:当存在完全匹配的普通函数时,编译器优先选择普通函数,而不是实例化模板。std::string 的 swap 特化本质上就是这个机制的体现。我们自己的 Jianyi::string 没有做这个特化,但如果有人写 std::swap(a, b) 来交换我们的对象,就会走通用版本(O(n));写 a.swap(b) 才能走我们的成员函数(O(1))。
七、赋值运算符
我注释掉的第一版
// 被注释掉的版本
string& operator=(const string& s)
{
delete[] _str;
_str = new char[s._capacity + 1];
strcpy(_str, s._str);
_size = s._size;
_capacity = s._capacity;
return *this;
}
逻辑清晰:先释放自己的旧资源,再按对方重新分配。但有一个致命缺陷------自赋值问题。
如果写 s = s,进入函数后先执行 delete[] _str,此时 s._str 指向的内存已经被释放。接下来 strcpy(_str, s._str) 访问的是悬空指针,行为未定义。
我的错误判断
// 错误写法
if (_str != s._str)
这个判断想拦住自赋值,但条件判断的是指针值,不是对象地址。两个不同的对象,在浅拷贝场景下完全可能 _str 相同(指向同一块内存)。正确的自赋值判断是比较对象本身的地址:
if (this != &s)
最终版本:copy-and-swap
cpp
string& operator=(const string& s)
{
if (this != &s)
{
string tmp(s._str); // 深拷贝到临时对象
swap(tmp); // 交换资源,tmp 拿走旧资源
}
return *this;
}
这个版本的妙处在于:资源的释放由 tmp 的析构函数完成,不需要手动 delete。整个过程异常安全------如果 new 失败抛异常,tmp 根本没构造成功,*this 的状态没有被改动。
八、reserve:扩容的核心
cpp
void string::reserve(size_t n)
{
if (n >= _capacity)
{
char* temp = new char[n + 1];
strcpy(temp, _str);
delete[] _str;
_str = temp;
_capacity = n;
}
}
reserve 只扩容,不缩容(if (n >= _capacity) 保证了这一点)。这和 std::string::reserve 的语义一致------你可以申请更大的空间,但不能用 reserve 强行缩小。
操作顺序很关键:先 new,再 strcpy,再 delete[] 旧指针,最后更新 _str。顺序不能倒。如果先 delete[] 再 new,一旦 new 失败,_str 就成了悬空指针,对象处于损坏状态。
潜在优化点:strcpy 只适合复制以 '\0' 结尾的字符串,换成 memcpy(_str, _capacity + 1) 在某些实现里更快(省去逐字符检查终止符的开销),但这里用 strcpy 足够清晰。
九、PushBack 和 append
PushBack:追加单个字符
cpp
void string::PushBack(char ch)
{
if (_size == _capacity)
reserve(_capacity == 0 ? 4 : 2 * _capacity);
_str[_size] = ch;
_size++;
_str[_size] = '\0';
}
扩容策略是翻倍(2 * _capacity),初始容量为 0 时直接给 4。翻倍策略保证均摊 O(1) 的追加代价------即使偶尔触发扩容(O(n)),均摊到每次追加上依然是常数时间。
cpp
string& string::operator+=(char ch)
{
PushBack(ch);
return *this;
}
operator+= 直接复用 PushBack,逻辑不重复。
append:追加字符串
cpp
void string::append(const char* str)
{
assert(str);
size_t len = strlen(str);
if (_size + len > _capacity)
reserve(_size + len > 2 * _capacity ? _size + len : 2 * _capacity);
size_t n = 0;
while (n < len)
{
_str[n + _size] = str[n];
++n;
}
_size += len;
_str[_size] = '\0';
}
扩容判断:优先翻倍,如果翻倍后还不够就直接扩到需要的大小(_size + len)。这个逻辑在 insert 里也有类似写法,是一个常见的"至少满足需求,尽量翻倍"策略。
追加数据后必须手动设置 _str[_size] = '\0',因为这里用的是逐字符赋值,不像 strcpy 会自动附带终止符。
operator+= 有一个 const char* 版本声明在头文件里,但实现体我没有写(被注释掉了)。实际会调用 append:
cpp
string& string::operator+=(const char* str)
{
append(str);
return *this;
}
十、insert:插入时的无符号类型陷阱
插入单个字符
cpp
void string::insert(size_t pos, char ch)
{
assert(pos <= _size);
if (_size == _capacity)
reserve(_capacity == 0 ? 4 : 2 * _capacity);
size_t end = _size + 1;
while (end > pos)
{
_str[end] = _str[end - 1];
--end;
}
_str[pos] = ch;
_size += 1;
}
后移数据从末尾开始,end 从 _size + 1 往 pos 走,每次把 end - 1 位置的字符移到 end。
核心踩坑 :我一开始写的是 while (end >= pos)。end 和 pos 都是 size_t(无符号整数)。当 pos = 0、end 减到 0 之后再执行 --end,结果不是 -1,而是 size_t 的最大值(约 1.8 × 10^19),条件永远为真,死循环。
改成 while (end > pos),当 end == pos 时循环停止,从根本上避免了无符号数下溢。这是 C++ 里用 size_t 做循环变量的经典陷阱,C 语言的隐式类型转换让这类 bug 非常难发现。
代码注释里也写了:while(end <= (int)pos) 是另一种解法------强转成有符号类型,但不如直接改逻辑条件来得干净。
插入字符串
cpp
void string::insert(size_t pos, const char* str)
{
size_t len = strlen(str);
if (_size + len > _capacity)
reserve(_size + len > 2 * _capacity ? _size + len : 2 * _capacity);
size_t end = _size + len;
while (end > pos + len - 1)
{
_str[end] = _str[end - len];
--end;
}
for (size_t i = 0; i < len; ++i)
_str[pos + i] = str[i];
_size += len;
}
和单字符版本类似,但每次移动 len 个位置。先把 pos 之后的数据整体后移 len 格,再把 str 的内容填入 [pos, pos + len) 的位置。
注释里还有一个被废弃的版本:
cpp
// 废弃版本
for (size_t i = _size; i >= pos; --i)
{
_str[i + len] = _str[i];
}
同样是 size_t 的无符号下溢问题------i 减到 0 之后再减会绕回最大值,死循环。
十一、erase:删除子串
cpp
void string::erase(size_t pos, size_t len)
{
assert(pos <= _size);
if (len == npos || len > _size - pos)
{
_str[pos] = '\0';
_size = pos;
}
else
{
for (size_t i = pos + len; i <= _size; ++i)
_str[i - len] = _str[i];
_size -= len;
}
}
两种情况:如果 len 是 npos(表示"到末尾"),或者删除长度超出剩余部分,就直接截断------在 pos 处写 '\0',更新 _size 即可,不需要移动任何数据。
否则,把 pos + len 之后的数据前移 len 格。循环条件 i <= _size 包含了 _size 本身,这样连终止符 '\0' 也会一起被移过来,不需要单独设置。
边界注意:len > _size - pos 这个判断,如果 _size < pos 会发生无符号数下溢(值绕回变成超大数),但 assert(pos <= _size) 保证了进函数时 pos <= _size,所以 _size - pos 不会下溢,是安全的。
十二、find:字符串查找
查找单个字符
cpp
size_t string::find(char ch, size_t pos)
{
assert(pos < _size);
for (size_t i = pos; i < _size; ++i)
if (_str[i] == ch)
return i;
return npos;
}
从 pos 开始线性扫描,找到返回下标,找不到返回 npos。
查找子串
cpp
size_t string::find(char* str, size_t pos)
{
assert(pos < _size);
const char* temp = strstr(_str + pos, str);
if (temp == nullptr)
return npos;
else
return temp - _str;
}
这里借助了标准库函数 strstr。strstr 的原理是滑动匹配:从主串的每个位置开始,尝试和子串逐字符比较;如果当前位置不匹配,移动到下一个位置重新开始。朴素实现是 O(n × m),标准库的实现通常有优化(类似 KMP 或 Boyer-Moore),但接口语义是一样的。
strstr 返回的是指针,指向主串中子串开始的位置。用这个指针减去 _str(数组起始地址),就得到了下标。这是指针运算的一个典型用法:两个指针相减得到元素个数(距离),前提是两者指向同一块数组。
十三、substr:截取子串
cpp
string string::substr(size_t pos, size_t len)
{
if (len > _size - pos)
len = _size - pos;
string sub;
sub.reserve(len);
sub._str[0] = '\0';
for (size_t i = 0; i < len; ++i)
sub += _str[pos + i];
return sub;
}
先修正 len(不让它超出剩余长度),然后构造一个空字符串 sub,reserve 好空间,再逐字符追加。
注释里有一个优化版本:
cpp
// 优化版本(被注释掉)
string sub;
sub._str = new char[len + 1];
sub._capacity = len;
sub._size = len;
memcpy(sub._str, _str + pos, len);
sub._str[len] = '\0';
return sub;
这个版本一次性分配内存、用 memcpy 批量复制,性能更好,不需要逐字符调用 operator+= 可能触发多次扩容。代价是代码稍微复杂一点,需要直接操作成员变量(所以只能在类内部写,或者声明为友元)。
十四、比较运算符
cpp
bool operator<(const string& s1, const string& s2)
{
return strcmp(s1.c_str(), s2.c_str()) < 0;
}
bool operator==(const string& s1, const string& s2)
{
return strcmp(s1.c_str(), s2.c_str()) == 0;
}
bool operator>(const string& s1, const string& s2) { return strcmp(...) > 0; }
bool operator<=(const string& s1, const string& s2) { return (s1 < s2) || s1 == s2; }
bool operator>=(const string& s1, const string& s2) { return (s1 > s2) || s1 == s2; }
bool operator!=(const string& s1, const string& s2) { return !(s1 == s2); }
核心是 < 和 ==,其他运算符全部复用这两个。strcmp 返回负数表示"小于",0 表示"等于",正数表示"大于"------和返回 bool 的比较运算符语义完全对应。
这些运算符都是非成员函数 ,写在类外面。原因是 ==、< 等是对称的二元运算,左右操作数应该平等对待,如果写成成员函数,左操作数必须是 string 对象,会限制使用灵活性。
十五、c_str() 的意义
cpp
const char* c_str() const
{
return _str;
}
这个函数看起来简单,但它是 string 和 C 风格字符串交互的桥梁。很多 C 标准库函数(printf、fopen、strlen 等)只接受 const char*,不认识 C++ 的 string 对象。c_str() 提供了一个合法的、以 '\0' 结尾的字符指针,让 C++ string 能融入 C 的生态。
返回 const char* 而不是 char*,是有意为之:禁止外部通过这个指针修改字符串内容,保护对象的内部状态。
十六、operator<< 和 operator>>
为什么要实现这两个
标准库的 cout 和 cin 不认识我们自己实现的 string 类。如果不重载 operator<< 和 operator>>,cout << s 就没法编译。实现了之后,我们的 string 就能无缝接入 C++ 的流体系。
operator<<
cpp
ostream& operator<<(ostream& out, string& s)
{
for (auto ch : s)
out << ch;
return out;
}
逐字符输出。这里用了范围 for 循环,依赖 string 提供了 begin() 和 end() 迭代器:
cpp
typedef char* iterator;
typedef const char* const_iterator;
iterator begin() { return _str; }
iterator end() { return _str + _size; }
指针就是最简单的迭代器,对字符数组完全适用。
operator>>(我踩的最隐蔽的 bug)
错误版本(注释里):
cpp
// 错误写法
buff[i++] = ch;
s += buff; // buff 里有 ch
s += ch; // ch 又被单独加了一次 ------ 重复!
每个字符被写入了两次,一次在 buff 里追加到 s,一次单独 += ch。读进来的字符串会变成双倍。
正确版本:
cpp
istream& operator>>(istream& in, string& s)
{
s.clear();
const int N = 256;
char buff[N];
int i = 0;
char ch = in.get();
while (ch != ' ' && ch != '\n' && ch != EOF)
{
buff[i++] = ch;
if (i == N - 1)
{
buff[i] = '\0';
s += buff;
i = 0;
}
ch = in.get();
}
if (i > 0)
{
buff[i] = '\0';
s += buff;
}
return in;
}
每个字符只走一条路径:先进 buff,buff 满了就 flush 到 s,清空 buff 继续。读完循环后,把剩余不满一 buff 的数据再 flush 一次。
用缓冲区的好处:每 255 个字符才触发一次 operator+=,大幅减少了可能的扩容次数,比逐字符 s += ch 高效很多。
十七、引用计数:为什么现代 string 不用它
除了深拷贝,还有第三种资源管理策略:引用计数。思路是让多个对象共享同一块内存,同时用一个计数器记录有多少个对象指向它。
html
s1._str ──┐
▼
[ h e l l o \0 ] (count = 2)
▲
s2._str ──┘
拷贝时不申请新内存,只把 count 加 1。析构时把 count 减 1,只有 count 减到 0 时才真正 delete。这避免了频繁 new/delete 的开销,在大量拷贝场景下性能更好。
早期的 std::string 实现(GCC 的 libstdc++ 在 C++11 之前)确实用过引用计数。但 C++11 之后基本被废弃,原因有两个:
第一,多线程问题。引用计数本身需要原子操作来保证线程安全,而原子操作有不小的开销。在多核环境下,多个线程频繁地增减同一个引用计数,会导致缓存行频繁失效(false sharing),性能反而不如深拷贝。
第二,写时拷贝(COW)的隐患。引用计数通常配合写时拷贝使用:只有在修改字符串时才真正复制一份("lazy copy")。但这个机制在多线程下存在微妙的 race condition,实现正确性极难保证。
现代 string 普遍采用SSO(Small String Optimization):短字符串(通常 15 个字符以内)直接存在对象内部的固定缓冲区里,不 new 堆内存,彻底避免了动态分配的开销,只有超过阈值的长字符串才退化到堆分配。这是目前性能最好且实现最干净的方案。
十八、代码里可以优化的地方
结合整个实现,有几个地方可以做得更好:
substr 的逐字符追加 :当前实现用 operator+= 逐字符追加,每次都有可能触发扩容判断。改用 memcpy 一次性复制更高效(注释里已经有这个优化版本)。
operator>> 的参数 :operator<< 的第二个参数写的是 string&,应该改成 const string&,因为输出操作不修改对象。
substr 没有检查 pos :如果 pos > _size,_size - pos 会下溢(无符号数)。应该加一个 assert(pos <= _size) 或直接返回空字符串。
find 的 pos 边界 :find 里 assert(pos < _size) 会拒绝在空字符串或末尾位置查找,实际上 pos == _size 时应该允许(直接返回 npos),断言条件过严。
erase 没有更新 _str[_size] :在截断分支(直接写 '\0' 的那条),_str[pos] = '\0' 是正确的;在移动分支,循环已经把 _str[_size] 的 '\0' 移过来了。这里是对的,但值得在代码注释里说清楚,否则容易让人担心。
总结
写完这个 string 类,我对几个核心概念有了真实的理解:
深拷贝不只是"多 new 一份内存",它的本质是让每个对象对自己的资源负全责,互不干扰。copy-and-swap 惯用法把资源管理的复杂性封装进构造函数和析构函数里,让赋值运算符变得异常安全且简洁。swap 的 O(1) 不是魔法,而是因为交换的只是指针,不是数据。size_t 的无符号特性在循环边界处是一个持续的坑,必须特别小心。
C++ 的核心难点不是语法,是对象的生命周期 和资源归属。这一点写完 string 之后,我觉得理解得清楚多了。