string 容器:从 C 字符串到会自己扩容的类

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;
}

问题归纳起来有五个:

  1. 长度要现算 。strlen 是 O(n),每次调用都从头扫一遍。
  2. 容量靠人记。拼接、插入之前必须先确认缓冲区够大,否则就是栈溢出或堆越界。
  3. 内存靠手工 。深拷贝要自己 malloc,释放要自己 free,忘了就是泄漏,释放两次就是崩溃。
  4. 运算符残缺 。== 比的是指针地址,+ 不存在,< 不存在,排序要自己写比较函数。
  5. '\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) 判断资源被几个对象共享。

浅拷贝的两个问题

  1. 析构两次:两个对象指向同一块内存,各自析构时都 delete[],同一块内存被释放两次。
  2. 互相影响:一个对象修改内容,另一个对象看到的内容也跟着变。

引用计数解决第一个问题;写时拷贝解决第二个问题。

引用计数怎么工作

每个共享缓冲区配一个计数器,记录"当前有多少个对象指向我"。

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 的优势最明显。

所以它不是"不拷贝",而是把拷贝推迟到真正需要的时候。

为什么现在被淘汰

  1. C++11 移动语义:移动构造/移动赋值可以直接转移资源,连引用计数都不需要,覆盖了 COW 的大部分收益。
  2. SSO:短字符串直接存在对象内部,连堆分配都省了,短字符串的 COW 共享也就没有意义。
  3. 语义复杂 :非 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 与赋值函数四步)


相关推荐
程序猿编码1 小时前
纯 C++ 手写 LLM 推理引擎:分页 KV 缓存、连续批、多卡张量并行全部内置
c++·深度学习·缓存·大模型推理·自动微分·模型推理
程序边界1 小时前
顶刊级论文配图全流程:用 Python 复刻 Nature/Science 风格图表
开发语言·python
普通人61 小时前
uniapp模版JS
开发语言·javascript·小程序·uni-app·1024程序员节
G018_star sky♬2 小时前
nodeJS他是如何用JavaScript封装C++的呢
开发语言·javascript·c++
wyhwust2 小时前
程序设计基础--C语言输入输出单元测试
c语言·开发语言
choumin2 小时前
C++中std::move和std::forward的关系和区别
c++·std move·std forward·关系和区别
云和数据.ChenGuang2 小时前
python sorted函数详细剖析
开发语言·人工智能·windows·python·1024程序员节
程序猿编码2 小时前
YOLOv5/v7/YOLOX 全支持:基于 TensorRT 的工业视觉推理底座详解
c++·yolo·机器学习·推理·模型推理
外收内放2 小时前
FastAPI猜字谜实战(4):让AI主持人开口
开发语言·python·学习·fastapi