string 容器:从 C 字符串到会自己扩容的类
为什么需要 string
C 语言的字符串本质上是以 '\0' 结尾的字符数组。它能用,但用起来处处要人盯着:
c
#include <string.h>
#include <stdio.h>
int main(void)
{
char s1[10] = "hello";
char s2[10] = "world";
char s3[20];
strcpy(s3, s1); // 够不够放?自己保证
strcat(s3, s2); // 还有没有空间?还是自己保证
if (strcmp(s1, s2) == 0) // 不能用 s1 == s2,那是在比地址
printf("equal\n");
size_t len = strlen(s3); // 每次求长度都要扫到 '\0'
printf("%zu\n", len);
return 0;
}
问题归纳起来有五个:
- 长度要现算 。
strlen是 O(n),每次调用都从头扫一遍。 - 容量靠人记。拼接、插入之前必须先确认缓冲区够大,否则就是栈溢出或堆越界。
- 内存靠手工 。深拷贝要自己
malloc,释放要自己free,忘了就是泄漏,释放两次就是崩溃。 - 运算符残缺 。
==比的是指针地址,+不存在,<不存在,排序要自己写比较函数。 '\0'无处不在 。字符串中间一旦出现'\0',后面全部被截断。
C++ 的解法是把这套操作封装成一个类:string。
标准里的 string 并不是一个独立类型,而是 basic_string 针对 char 的实例化:
cpp
// 标准库中的概念性定义
typedef basic_string<char> string;
typedef basic_string<wchar_t> wstring; // 宽字符版本
basic_string 是类模板,char 只是其中一种字符类型。换一个模板实参,就得到面向另一种编码的字符串类型;string 只是其中的 char 版本。
💡 背景补充:
string在标准中属于序列式容器,但它不支持push_front、pop_front这类操作,因为字符序列通常只在尾部增长。它的接口设计和vector<char>相似,但多了字符串专属的find、substr、c_str等成员。
底层结构:三个成员
一个够用的简化版 string 只需要三个成员(引用计数版还要多一个计数指针,后面扩展再讲):
cpp
namespace bit
{
class string
{
private:
char* _str = nullptr; // 指向堆上的字符数组
size_t _size = 0; // 有效字符个数,不含 '\0'
size_t _capacity = 0; // 容量,不含 '\0'
};
}
_size 和 _capacity 都不包含 结尾的 '\0',但实际数组长度是 _capacity + 1。多出来的这一个字节永远留给 '\0'。
为什么不把 '\0' 算进容量?因为对使用者来说,"abc" 的长度就是 3,不是 4。而 c_str()、printf("%s")、strcpy 这些 C 接口又必须看到 '\0'。于是形成一个约定:对外 size 不含终止符,对内数组多开一格。
这个约定贯穿整个模拟实现,后面的 reserve 要多开 n + 1、insert 要连 '\0' 一起搬、erase 要连 '\0' 一起移,根源都在这里。
cpp
const size_t string::npos = -1;
npos 是"查找失败"的返回值。size_t 是无符号类型,-1 转换成 size_t 就是该类型的最大值。正常下标不可能达到这个值,所以它不会和合法位置冲突。C++17 之后也可以写成 inline static constexpr size_t npos = -1;。
为什么 string 是 basic_string 的别名
计算机内存里只有 0 和 1。字符要显示成人能看懂的样子,中间必须有一张"编号 → 符号"的表,这张表就是编码(Encoding)。
- ASCII:1 字节,编号 0--127,只覆盖英文和常见符号。
- Unicode:给全世界每个字符分配一个编号(码点),但码点怎么存成字节,由 UTF-8 / UTF-16 / UTF-32 决定。
- UTF-8:以 1 字节为单元,变长(1--4 字节)。兼容 ASCII,英文 1 字节,中文通常 3 字节。
- UTF-16:以 2 字节为单元,中文通常占 1 个单元(2 字节),生僻字占 2 个单元。
- UTF-32:每个字符固定 4 字节,整齐但浪费空间。
同一个 "中文",在不同编码下占的字节数不同(含结尾 '\0'):
| 写法 | 单元 | sizeof |
说明 |
|---|---|---|---|
"中文"(源码 UTF-8) |
1 字节 | 7 | 3 + 3 + 1 |
"中文"(源码 GBK,MSVC 默认) |
1 字节 | 5 | 2 + 2 + 1 |
u"中文" |
2 字节 | 6 | 2 + 2 + 2 |
U"中文" |
4 字节 | 12 | 4 + 4 + 4 |
L"中文" |
wchar_t |
Windows 6 / Linux 12 | 取决于 wchar_t 宽度 |
💡 背景补充:
"中文"的sizeof取决于源文件保存的编码。MSVC 在中文环境下通常按 GBK 读源码,所以是 5;把源文件另存为 UTF-8 后,同一个字面量会变成 7。课件里测出的 5,通常就是 GBK 的结果,不是 UTF-8。
字符类型也随之不同:
| 类型 | 大小 | 字面量前缀 | 对应的 string |
|---|---|---|---|
char |
1 字节(通常) | "" / u8"" |
string |
wchar_t |
平台相关:Windows 2,Linux 4 | L"" |
wstring |
char16_t(C++11) |
2 字节 | u"" |
u16string |
char32_t(C++11) |
4 字节 | U"" |
u32string |
C++20 起 u8"..." 的类型变成 const char8_t[],不再能直接赋给 string;这里按 C++11/14 的行为讨论。
这就是标准库把字符串做成模板的原因:
cpp
template<class CharT,
class Traits = std::char_traits<CharT>,
class Allocator = std::allocator<CharT>>
class basic_string;
typedef basic_string<char> string;
typedef basic_string<wchar_t> wstring;
typedef basic_string<char16_t> u16string;
typedef basic_string<char32_t> u32string;
string 只是 basic_string<char> 的别名。模拟实现只写 char 版本,是因为 strcpy、strlen、strstr 都是针对 char 的;真正的 basic_string 要通过 char_traits<CharT> 把"求长度、比较、拷贝"这些操作抽象出来,才能适配不同字符类型。
cpp
#include <iostream>
#include <string>
using namespace std;
int main()
{
cout << sizeof("中文") << endl; // UTF-8 源码:7;GBK 源码:5
cout << sizeof(u"中文") << endl; // 6
cout << sizeof(U"中文") << endl; // 12
cout << sizeof(L"中文") << endl; // Windows:6;Linux:12
string s8 = u8"中文"; // C++11/14:const char[]
u16string s16 = u"中文";
u32string s32 = U"中文";
wstring ws = L"中文";
return 0;
}
注意 cout << u"中文" 不会输出文字:cout 没有接收 const char16_t* 的重载,实参会退而匹配 const void*,结果是打印一个地址。cout << L"中文" 同理,打印的也是地址。宽字符要用 wcout,或者先转换成 string 再输出。
标准 string 的常用接口
在动手模拟之前,先分清哪些接口是日常要用的:
| 类别 | 接口 | 说明 |
|---|---|---|
| 构造 | string()、string(const char*)、string(const string&)、string(size_t n, char c) |
默认构造 / C 字符串 / 拷贝 / n 个字符 |
| 容量 | size()、length()、capacity()、empty()、reserve()、resize() |
size 与 length 完全等价 |
| 访问 | operator[]、at()、front()、back()、c_str() |
at 越界抛异常,[] 不检查 |
| 修改 | push_back()、append()、operator+=、insert()、erase()、clear() |
尾部追加最常用 |
| 查找 | find()、rfind()、find_first_of() |
失败返回 npos |
| 子串 | substr(pos, len) |
返回一个新的 string |
| 迭代器 | begin()、end()、rbegin()、rend() |
各有 const 版本 |
| 输入输出 | operator<<、operator>>、getline() |
>> 遇空白停止 |
用一张完整的程序把几种遍历方式串起来:
cpp
#include <iostream>
#include <string>
using namespace std;
int main()
{
string s = "hello world";
// 1. 下标遍历
for (size_t i = 0; i < s.size(); ++i)
cout << s[i];
cout << endl;
// 2. 迭代器遍历
for (auto it = s.begin(); it != s.end(); ++it)
cout << *it;
cout << endl;
// 3. 反向迭代器:++ 倒着走
for (auto rit = s.rbegin(); rit != s.rend(); ++rit)
cout << *rit;
cout << endl;
// 4. 范围 for:底层就是 begin() / end()
for (auto& ch : s)
ch -= 32; // 小写转大写
cout << s << endl;
return 0;
}
输出:
text
hello world
hello world
dlrow olleh
HELLO WORLD
反向迭代器和普通指针的区别值得注意:++rit 让迭代器向前一个字符 移动,而不是地址加一。所以它不能简单用 char* 伪装,标准库里的 reverse_iterator 是一个包装类。这也是模拟实现里只提供正向迭代器的原因------反向版本要额外写一层适配器。
模拟实现:构造、拷贝、赋值、析构
构造与析构
cpp
// 一个构造函数同时承担默认构造和 C 字符串构造
string(const char* str = "")
{
_size = strlen(str);
_capacity = _size; // 容量不含 '\0'
_str = new char[_capacity + 1]; // 多开一格给 '\0'
strcpy(_str, str); // strcpy 会连 '\0' 一起拷贝
}
~string()
{
if (_str)
{
delete[] _str; // new[] 必须配 delete[]
_str = nullptr; // 置空,避免悬垂指针
_size = _capacity = 0;
}
}
默认参数 = "" 让 string s; 和 string s("abc"); 共用一个函数。空串也必须 new char[1] 并把 _str[0] 置为 '\0',否则 c_str() 返回空指针,调用方一 printf 就崩。
delete[] 对空指针是安全的,所以 if (_str) 不是必需的;但成员仍要初始化为 nullptr。没有默认值时,if (_str) 判断的是随机地址,这个检查就失效了。
📖 参考:《高质量C++/C编程指南》第 7 章------
new[]必须与delete[]配对,delete只会析构首元素,其余全部泄漏。
拷贝构造:深拷贝是生命线
编译器自动生成的拷贝构造函数做的是位拷贝 :把 _str、_size、_capacity 三个值原样复制。结果是两个对象指向同一块堆内存:
text
s1._str ──┐
├──> [ h e l l o \0 ]
s2._str ──┘
任一对象析构,另一对象的 _str 就成了悬垂指针;两个都析构,就是同一块内存 delete[] 两次------未定义行为。
所以含指针的类必须自己写深拷贝:
cpp
// 现代写法:先构造完整副本,再交换资源
string(const string& s)
{
string tmp(s._str); // 复用 C 字符串构造,得到一份独立副本
swap(tmp); // 把副本的资源换到 this
}
void swap(string& s)
{
std::swap(_str, s._str);
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
}
tmp 是函数内的局部对象,构造完成时 this 已经拿到了资源;函数返回时 tmp 析构,释放的是原来那个空对象 的资源。整个过程没有一行显式的 delete,也没有显式检查空指针。
⚠️
_str/_size/_capacity必须在类内给默认值。现代写法会把this当前的三个成员换给tmp;如果它们是随机值,tmp析构时就会delete[]一个野指针。内置类型不会自动初始化,这一行默认值不是装饰。
另一个细节:string tmp(s._str) 走的是 const char* 构造,tmp 的容量刚好等于长度;string tmp(s) 走拷贝构造,会保留原对象的容量余量。两者都是深拷贝,前者省空间,后者保留扩容余量。
赋值:copy-and-swap
赋值运算符有一个经典陷阱:自赋值(s = s)。传统写法通常长这样:
cpp
string& operator=(const string& s)
{
if (this != &s)
{
delete[] _str; // 先释放
_str = new char[s._capacity + 1]; // 再申请
strcpy(_str, s._str);
_size = s._size;
_capacity = s._capacity;
}
return *this;
}
能跑,但两个问题:自赋值必须特判;更严重的是,如果 new 抛异常,_str 已经被 delete[],对象处于"半死不活"的状态,后续析构会再次释放野指针。
现代写法把这两件事一起解决:
cpp
string& operator=(string tmp) // 传值:实参先深拷贝出 tmp
{
swap(tmp); // 交换后,旧资源由 tmp 负责释放
return *this;
}
这是 copy-and-swap 惯用法:
- 自赋值:
s = s时tmp是s的独立副本,交换后内容不变,旧内存随tmp析构释放一次,不会 double free。 - 异常安全:拷贝发生在进入函数之前,失败则
*this完全没被碰过;交换阶段只做指针赋值,不会抛异常。
代价是参数多了一次拷贝。C++11 之后有了移动构造,右值实参可以直接"偷"资源,这个代价基本消失。
这两种实现有名字:自己开空间、自己拷贝的叫传统写法 ;构造临时对象再交换的叫现代写法(copy-and-swap)。两者都要做一次深拷贝,效率没有本质差别;现代写法胜在代码短、异常安全、自赋值不用特判。
swap 的代价:通用版本可能是三次深拷贝
C++98 的 std::swap 通用模板是这么写的:
cpp
// 通用版本(简化)
template<class T>
void swap(T& a, T& b)
{
T c(a); // 1 次拷贝构造
a = b; // 1 次赋值
b = c; // 1 次赋值
}
对没有移动语义的 bit::string 来说,这是 1 次拷贝构造 + 2 次赋值。每次赋值都要把字符整体拷一遍,容量不够还要重新分配------最坏情况下相当于 3 次深拷贝。交换两个字符串的代价比拷贝还大。
成员 swap 只交换三个成员,O(1):
cpp
void swap(string& s)
{
std::swap(_str, s._str); // 交换的是指针,不是字符串内容
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
}
这里的 std::swap 作用在 char* 和 size_t 上,是内置类型,不会触发深拷贝。
标准库为 basic_string 提供了非成员重载 swap(string&, string&),内部调用成员 swap。函数模板和普通函数同时存在时,重载解析优先选普通函数,所以 std::swap(s1, s2) 对标准 string 是 O(1)。自定义的 bit::string 没有这个重载,需要自己补一个:
cpp
void swap(string& s1, string& s2)
{
s1.swap(s2);
}
C++11 的 std::swap 改用移动语义,对可移动类型来说通用版本也便宜了;但模拟版没有移动构造,仍会走拷贝,所以不能省这个重载。
容量与修改:reserve、push_back、append
reserve:只扩不缩
cpp
void string::reserve(size_t n)
{
if (n > _capacity) // 只有需求更大才扩容
{
char* tmp = new char[n + 1]; // 多开一格给 '\0'
strcpy(tmp, _str); // 连 '\0' 一起搬
delete[] _str; // 新空间就绪后才释放旧空间
_str = tmp;
_capacity = n;
}
}
两个细节:
if (n > _capacity)意味着reserve不会缩小容量。想缩容,标准库有shrink_to_fit()。- 先分配、后释放。如果
new抛异常,旧数组仍然完整,对象可用。反过来写就是自毁。
reserve 只改容量,不改 _size,也不改内容 。这一点常和 resize 混淆:resize(n) 会真的把有效字符数改成 n,多出的位置补 '\0'。
push_back:满了才扩容
cpp
void string::push_back(char ch)
{
if (_size == _capacity) // 空间用完
{
reserve(_capacity == 0 ? 4 : _capacity * 2); // 首次开 4,之后翻倍
}
_str[_size] = ch; // 写到有效末尾
++_size;
_str[_size] = '\0'; // 新末尾补终止符
}
容量为 0 时必须特判,否则 0 * 2 永远是 0,第一次插入就死循环。这和手写顺序表时 capacity == 0 ? 4 : capacity * 2 是同一个坑。
翻倍扩容的均摊复杂度是 O(1):n 次插入总共搬移约 2n 个字符,平均到每次是常数。标准库的具体倍数不一定是 2(常见 1.5 倍或 2 倍),但思路一致。
append:追加一整串
cpp
void string::append(const char* str)
{
size_t len = strlen(str);
if (_size + len > _capacity) // 放不下
{
reserve(_size + len > 2 * _capacity ? _size + len : 2 * _capacity);
}
strcpy(_str + _size, str); // 从有效末尾开始覆盖
_size += len;
}
扩容策略分两种情况:追加后总长超过原容量两倍,就按实际需求开;否则二倍扩容。这样既避免"刚好够用"导致频繁扩容,也避免一次追加超长字符串时开出 4 倍、8 倍的浪费。
strcpy 会自动把 str 的 '\0' 一起写过去,不需要手动补。
operator+= 只是对 push_back 和 append 的包装,返回 *this 以支持连续追加:
cpp
string& string::operator+=(char ch)
{
push_back(ch);
return *this;
}
string& string::operator+=(const char* str)
{
append(str);
return *this;
}
插入与删除:insert、erase
插入单个字符
cpp
void string::insert(size_t pos, char ch)
{
assert(pos <= _size); // 允许尾插 pos == _size
if (_size == _capacity)
reserve(_capacity == 0 ? 4 : _capacity * 2);
size_t end = _size + 1; // 从 '\0' 开始搬
while (end > pos) // 从后往前,防止覆盖
{
_str[end] = _str[end - 1];
--end;
}
_str[pos] = ch;
++_size;
}
end 从 _size + 1 开始,因为 '\0' 也要往后挪一格。整个过程从后往前推进:
text
插入前: h e l l o \0 _size = 5
pos = 1
后移: h _ e l l o \0
写入: h x e l l o \0
循环写成 while (end > pos) 而不是 for (size_t i = ...; i >= pos; --i),是为了避开无符号数的下溢。size_t 减到 0 再减一,会变成 SIZE_MAX,循环永远不会结束。这是 C++ 里最经典的 bug 之一。
插入字符串
cpp
void string::insert(size_t pos, const char* s)
{
assert(pos <= _size);
size_t len = strlen(s);
if (_size + len > _capacity)
{
reserve(_size + len > 2 * _capacity ? _size + len : 2 * _capacity);
}
size_t end = _size + len; // '\0' 的新位置
while (end > pos + len - 1) // 每个字符后移 len 步
{
_str[end] = _str[end - len];
--end;
}
for (size_t i = 0; i < len; i++)
{
_str[pos + i] = s[i];
}
_size += len;
}
和单字符插入相比,位移步长从 1 变成 len。终点也要相应调整:原来 _size + 1 处的 '\0' 现在要搬到 _size + len。
删除
cpp
void string::erase(size_t pos, size_t len)
{
assert(pos < _size);
if (len >= _size - pos) // 从 pos 到末尾全部删掉
{
_str[pos] = '\0';
_size = pos;
}
else
{
for (size_t i = pos + len; i <= _size; i++) // 注意 <=,包含 '\0'
{
_str[i - len] = _str[i];
}
_size -= len;
}
}
删除是插入的逆操作:从前往后覆盖,把 pos + len 之后的内容整体左移 len 位。循环条件必须是 i <= _size,把结尾的 '\0' 也搬过来。如果写成 i < _size,新的末尾就没有终止符,c_str() 会一直读到堆上的垃圾数据。
erase 的默认参数是 npos,所以 s.erase(pos) 等价于删到末尾。
查找与截取:find、substr
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;
}
size_t string::find(const char* str, size_t pos)
{
assert(pos < _size);
const char* ptr = strstr(_str + pos, str); // C 库查找子串
if (ptr == nullptr)
return npos;
return ptr - _str; // 指针相减得到下标
}
字符查找是线性扫描,O(n)。子串查找直接借用了 C 库的 strstr,避免手写 KMP 之类的算法。strstr 返回的是子串首字符的地址,减去 _str 就是下标偏移------指针相减的结果是元素个数,不是字节数。
这里的 find 和下面的 substr 都没有加 const,这是模拟版的简化。标准库中它们是 const 成员函数,否则 const string 对象连查找都做不了。接口该不该加 const,标准是"这个操作会不会修改对象状态",而不是"想不想加"。
substr 负责截取:
cpp
string string::substr(size_t pos, size_t len)
{
assert(pos < _size);
if (len > _size - pos) // 超出剩余长度就截到末尾
{
len = _size - pos;
}
string sub;
sub.reserve(len); // 提前开空间,避免多次扩容
for (size_t i = 0; i < len; i++)
{
sub += _str[pos + i];
}
return sub; // 返回副本
}
sub 是局部对象,返回时按值传递。编译器通常会做返回值优化(RVO),构造发生在调用方的栈帧里,不一定真的拷贝一次。
比较与输入输出
比较运算符统一交给 strcmp:
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 s1 < s2 || s1 == s2;
}
bool operator>(const string& s1, const string& s2)
{
return !(s1 <= s2);
}
bool operator>=(const string& s1, const string& s2)
{
return !(s1 < s2);
}
bool operator!=(const string& s1, const string& s2)
{
return !(s1 == s2);
}
< 和 == 是基础,其余四个由它们推导。这样只需维护两条真正复杂的逻辑。
输出运算符遍历每个字符:
cpp
ostream& operator<<(ostream& out, const string& s)
{
for (auto ch : s)
{
out << ch;
}
return out;
}
输入运算符要多写一些。in >> s 会自动跳过空白,读不到空格;这里需要保留空格,所以用 get() 逐字符读取:
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')
{
buff[i++] = ch;
if (i == N - 1) // 缓冲快满,先追加进 s
{
buff[i] = '\0';
s += buff;
i = 0;
}
ch = in.get();
}
if (i > 0) // 处理最后一段残留
{
buff[i] = '\0';
s += buff;
}
return in;
}
分块缓冲的原因是输入长度未知。如果直接 char buff[100000],栈会溢出;如果每次读一个字符就 push_back,则会频繁触发扩容。256 字节一段,是折中。
这个简化版有一个明显的边界问题:没有检查流状态 。输入到文件末尾时,in.get() 返回 EOF,它既不是空格也不是换行,循环会一直读下去。标准库的实现会检查 eof() 并设置 failbit,模拟版省略了这一步。
扩展:引用计数的写时拷贝(了解)
深拷贝保险,但每次拷贝都要开空间、拷数据。有没有办法先共享,真要修改时再拷贝?有,这套方案叫 写时拷贝(Copy-On-Write,COW) ,靠 引用计数(Reference Counting) 判断资源被几个对象共享。
浅拷贝的两个问题
- 析构两次:两个对象指向同一块内存,各自析构时都
delete[],同一块内存被释放两次。 - 互相影响:一个对象修改内容,另一个对象看到的内容也跟着变。
引用计数解决第一个问题;写时拷贝解决第二个问题。
引用计数怎么工作
每个共享缓冲区配一个计数器,记录"当前有多少个对象指向我"。
text
s1 ──┐
├──> [ count=2 | h e l l o \0 ]
s2 ──┘
s2 修改后:
s1 ──> [ count=1 | h e l l o \0 ]
s2 ──> [ count=1 | h e l l o x \0 ]
规则只有三条:
- 拷贝构造:不分配新缓冲区,只复制指针,计数加一。
- 析构:计数减一;减到 0 才释放缓冲区。
- 修改:如果计数大于 1,先深拷贝出一份自己的缓冲区,计数减一,再修改。
最后一条就是"写时拷贝",也叫"谁写谁拷贝"。
代码骨架
cpp
class string
{
private:
char* _str = nullptr;
size_t _size = 0;
size_t _capacity = 0;
int* _pcount = nullptr; // 指向共享的引用计数
};
cpp
string(const string& s)
: _str(s._str)
, _size(s._size)
, _capacity(s._capacity)
, _pcount(s._pcount)
{
++(*_pcount); // 多一个对象共享
}
~string()
{
if (--(*_pcount) == 0) // 最后一个对象负责释放
{
delete[] _str;
delete _pcount;
}
}
void copy_on_write()
{
if (*_pcount > 1) // 有人在共享,不能直接改
{
char* tmp = new char[_capacity + 1];
strcpy(tmp, _str);
--(*_pcount); // 放弃旧共享块
_str = tmp;
_pcount = new int(1); // 新块由自己独占
}
}
默认构造还需要把 _pcount 初始化为 new int(1),否则第一次拷贝就会解引用空指针。所有写操作(push_back、insert、erase、非 const operator[])在修改前都要先调 copy_on_write()。
为什么说它是一场赌博
- 拷贝之后只读不写:省下一次深拷贝,赚了。
- 拷贝之后马上写:先拷贝一份再写,成本和一开始就深拷贝一样,还多一次计数判断。
- 传值返回、调用方只读的场景,COW 的优势最明显。
所以它不是"不拷贝",而是把拷贝推迟到真正需要的时候。
为什么现在被淘汰
- C++11 移动语义:移动构造/移动赋值可以直接转移资源,连引用计数都不需要,覆盖了 COW 的大部分收益。
- SSO:短字符串直接存在对象内部,连堆分配都省了,短字符串的 COW 共享也就没有意义。
- 语义复杂 :非 const
operator[]无法判断你是读还是写,只能一律触发拷贝;有些实现用代理对象区分读写,又让类型和重载变得复杂。多线程下引用计数还要原子化。
老版 libstdc++(GCC 5 之前)用的就是 COW;GCC 5 切换到新 ABI 后改成了 SSO。MSVC 一直是深拷贝加 SSO。今天再把 COW 当作主流方案已经过时,但"延迟拷贝"的思路在操作系统里仍然常见,比如 fork 之后的进程地址空间就是写时复制。引用计数这套机制本身还会在智能指针 shared_ptr 里再次出现,那时会讲得更细。
扩展:不同标准库的底层实现(了解)
标准只规定 std::string 的接口和行为,不规定内存布局。同一个 sizeof(std::string),在不同实现下可以差很多:
| 实现 | 对象大小(64 位) | 小字符串 | 拷贝策略 |
|---|---|---|---|
| MSVC(VS2019) | 28(32 位)/ 32(64 位)字节 | 16 字节内部缓冲,最多 15 字符 + '\0' |
深拷贝 |
| 老版 libstdc++(GCC < 5) | 8 字节(一个指针) | 无 | 引用计数 COW |
| 新版 libstdc++(GCC ≥ 5) | 32 字节 | 15 字符 + '\0' |
深拷贝 |
| libc++(Clang) | 24 字节 | 22 字符 + '\0' |
深拷贝 |
SSO:短字符串优化
SSO(Small String Optimization,短字符串优化) 把短字符串直接放进对象内部的缓冲区,不申请堆内存。
- MSVC:对象里带 16 字节 buffer,最多存 15 个字符加
'\0'。 - 新版 libstdc++:15 个字符加
'\0'。 - libc++:22 个字符加
'\0'。
短字符串在真实代码里占大多数,省掉一次 new / delete 的收益很可观。这也是 sizeof(std::string) 常常是 24 或 32 字节的原因:对象里本身就带着一块字符空间。
判断字符串长度用 s.size(),不要用 sizeof(s)------后者是对象大小,不是字符个数。
老版 libstdc++ 的 COW 布局
老版 libstdc++ 的对象只有一个指针(64 位下 8 字节),指向一块控制块:
text
string 对象 [ _M_p ] ──> [ length | capacity | refcount | h e l l o \0 ]
控制块和字符数组在同一块堆内存里,布局类似柔性数组(Flexible Array Member):结构体后面直接跟着变长数据,一次分配搞定。访问字符时跳过前三个字段即可,所以对象本身只需要一个指针。
💡 背景补充:C99 的柔性数组写法是
struct S { int n; char data[]; };,用malloc(sizeof(S) + len)一次分配。它不会自动增长,扩容仍然要realloc整块。老版 libstdc++ 只是采用了类似的"元数据 + 尾随字符"布局,并不是字面意义上的柔性数组成员。
这些差异意味着什么
- 不要假设
sizeof(std::string)、capacity()的初始值、扩容倍数在平台间一致。 - 不要跨编译器/跨标准库 ABI 传递
std::string对象(比如把 GCC 编译的动态库交给 MSVC 程序用),布局不同会直接崩。 - 读源码时先确认版本:GCC 5 前后的
std::string是两套完全不同的实现。
常见误区
1. 浅拷贝导致 double free。 不写拷贝构造,编译器生成位拷贝,两个对象共用一个 _str,析构两次就是未定义行为。含指针的类必须遵守"三法则":拷贝构造、赋值、析构,要么都不写,要么都写。
2. reserve 和 resize 混用。
cpp
string s = "hello";
s.reserve(10); // size = 5,capacity >= 10,内容不变
s.resize(10); // size = 10,末尾补 5 个 '\0'
s.reserve(10) 之后访问 s[9] 仍然是越界------容量变大不等于有效字符变多。
3. size() 返回无符号数,倒着遍历会下溢。
cpp
// 错误:size() 为 0 时,size() - 1 变成 SIZE_MAX
for (int i = s.size() - 1; i >= 0; --i)
cout << s[i];
// 正确:用 i > 0 作为条件,下标取 i - 1
for (size_t i = s.size(); i > 0; --i)
cout << s[i - 1];
4. 扩容后继续使用旧的指针或迭代器。
cpp
const char* p = s.c_str();
s += "some very long text that forces reallocation";
cout << p; // 未定义行为:p 指向已被 delete[] 的旧内存
任何可能触发扩容的操作(push_back、append、insert、reserve)都会让之前的指针、引用、迭代器失效。需要跨操作保存时,存下标,不存地址。
5. 以为 operator[] 会检查越界。 标准规定 operator[] 不做边界检查,越界是未定义行为;at() 才会抛 std::out_of_range。模拟实现里的 assert 只在 Debug 生效,Release 下会被去掉。
6. 把 string 对象直接传给 C 接口。 printf("%s", s) 是错的,必须写 s.c_str();cout << s 能用,是因为重载了 operator<<。
7. 对没有移动语义的类直接 std::swap。 通用模板会做 1 次拷贝构造 + 2 次赋值;对 bit::string 最坏是 3 次深拷贝。用成员 swap,或提供一个非成员 swap 重载。
8. 假设 sizeof(std::string) 是固定值。 标准不规定布局,MSVC、libstdc++、libc++ 的对象大小不同(8/24/32 字节都可能)。判断字符串长度用 size(),不要用 sizeof。
9. 以为所有字符串都能直接 cout。 cout << u"中文" 和 cout << L"中文" 都能编译,但会匹配到 const void* 重载,打印出来的是地址而不是文字。宽字符用 wcout,u16string / u32string 需要先转换。
10. 实现写时拷贝却忘了"写前拷贝"。 只要漏掉一个修改接口(尤其是非 const operator[]),就会改到共享缓冲区,另一个对象跟着变。COW 的每个写入口都必须先检查引用计数。
11. 看到 = 就以为是赋值。 string s2 = s1; 使用 =,但 s2 正在创建,调用的是拷贝构造 ;只有 s2 = s1; 中 s2 已经存在,才调用赋值运算符。
本节要点
string是basic_string<char>的 typedef;字符类型和编码决定用string/wstring/u16string/u32string。简化版内部是_str/_size/_capacity,数组多开一格给'\0';真实实现还可能多一个引用计数或内部缓冲。- 编译器默认的拷贝是位拷贝,含指针的类必须自己实现深拷贝;传统写法自己开空间,现代写法委托临时对象再 swap。
- 成员
swap交换三个成员,O(1);没有移动语义时,通用std::swap最坏会做 3 次深拷贝,标准库为非成员swap提供了高效重载。 reserve只改容量,resize改size;insert从后往前搬,erase从前往后覆盖,都要带上'\0'。扩容或搬移会让迭代器、指针、引用失效,长期保存位置用下标。- 引用计数写时拷贝与 SSO 是两种"了解级"实现方案;现代标准库以 SSO 为主,
sizeof(std::string)不可假设。
📖 参考:《高质量C++/C编程指南》第 7 章(内存管理、
new[]/delete[]配对)、第 9 章(Big-Three 与赋值函数四步)