前言
写时复制(Copy-On-Write,COW)是一种资源管理策略:多个对象共享同一份底层数据,只有当其中一个对象准备修改时,才把数据复制一份出来,让修改者独占。它在字符串、文件系统快照、fork后的进程地址空间等场景中都出现过。
关于标准库的std::string,这里必须先纠正一个流传很广的说法:C++11 及以后的标准不允许std::string采用写时复制。今天你在 GCC、Clang、MSVC 的主流标准库实现里看到的std::string都不是 COW,而是短字符串优化(Small String Optimization,SSO)或普通堆分配。COW 曾经真实存在——libstdc++ 的std::string在 GCC 5 之前的老 ABI 中就是引用计数加 COW,GCC 5.1 引入双 ABI 后默认实现改成了 SSO。
因此本文分两部分:先讲 COW 的原理与它为什么在 C++11 之后被标准排除,再讲如何自己实现一个遵守现代 C++ 规则的 COW 字符串类,作为理解该技术的练习。文中代码以 C++17 为基准,只做逐行推演,本机没有编译环境,行为以标准语义为准。
一、COW 的原理
一个典型的 COW 字符串由两部分组成:对象本身持有一个指向共享缓冲区的指针,缓冲区头部保存引用计数与容量、长度等信息。拷贝构造时只增加引用计数,不复制字符;读取操作直接访问共享缓冲区;一旦某个对象要写入,先检查引用计数,若大于 1 就复制出私有副本并把计数减一,然后在新缓冲区上修改。
用文字描述其状态变化:
| 操作 | 引用计数 | 是否复制字符 |
|---|
b = a(拷贝构造) | 1 → 2 | 否 |
读b[0] | 2 | 否 |
写b[0] = 'x' | 2 → 1(a 仍持有原缓冲区) | 是,复制整串 |
a析构 | 1 → 0,释放缓冲区 | 否 |
这种设计的好处是:以只读为主、偶尔拷贝的场景下,拷贝成本从 O(n) 降到 O(1),写入时才付出复制代价。代价则包括:
- 每次写入都要判断引用计数,非 const 的
operator[]不能再是简单的一次内存访问。 - 引用计数在多线程下必须原子维护,否则会双重释放;而原子操作本身有成本。
- 迭代器、引用、指针的失效规则变得复杂:一个看似只读的接口可能触发复制,使此前取得的所有引用失效。
- 需要一个额外的控制块,短字符串反而比直接存字符更占空间。
二、为什么 C++11 的标准 string 不允许 COW
C++11 修订了basic_string的失效保证:调用非 const 的operator[]、at、front、back、begin、end、data等成员函数,不会使指向该字符串元素的引用、指针、迭代器失效。而 COW 的写入路径恰恰相反——首次写入必须替换缓冲区,原来指向共享缓冲区的引用全部悬空。两者直接冲突。
更具体地说,C++11 要求&s[i] == &s[0] + i对所有合法的i成立,且data()返回的指针指向连续存放的字符数组。如果把s[i]的写访问做成「可能换缓冲区」,那么下面这段代码的语义就无法保证:
// 适用于 C++17
#include <string>
void demo(std::string s)
{
char& first = s[0]; // 取得引用
char& second = s[1]; // 再取一个引用
first = 'A'; // 若此处触发 COW 复制,second 会悬空
second = 'B'; // 读写悬空引用:未定义行为
}标准要求这两个引用指向同一块连续内存,且first的写入不影响second的有效性。COW 无法同时满足这两点,只能放弃。
另一个原因是 C++11 的线程安全模型:const 成员函数应当可以并发调用而不产生数据竞争。COW 在 const 路径上通常不修改引用计数,勉强可以做到;但operator[]的非 const 版本一旦复制,就会修改共享控制块,需要额外的同步。标准选择用更简单的规则——字符串对象之间不共享可变缓冲区——换取可预测的失效语义。
这也解释了为什么std::string的拷贝在 C++11 之后通常是 O(n):标准不保证拷贝是常数时间,实现也不能靠共享缓冲区来「偷懒」。真正需要 O(1) 拷贝时应使用移动语义,或者显式使用std::shared_ptr<const std::string>之类的共享只读方案。
三、历史与现状:SSO 取代 COW
| 实现 | 早期方案 | 现代方案 | 备注 |
|---|
| libstdc++(GCC) | COW + 引用计数 | SSO,GCC 5.1 起为新默认 ABI | 老 ABI 可用宏切回 |
| libc++(Clang) | 未采用 COW | SSO | 从设计之初即遵循 C++11 |
| MSVC STL | 未采用 COW | SSO | Windows 平台 |
libstdc++ 的这段历史值得了解,因为它造成了一个著名的兼容性问题:GCC 5 之前编译的目标文件与 GCC 5 之后(默认新 ABI)编译的目标文件,std::string的内存布局不同,混链会导致崩溃或内存破坏。GCC 为此提供了_GLIBCXX_USE_CXX11_ABI宏,取0使用老 ABI,取1使用新 ABI。这个宏必须在所有翻译单元和所有依赖库中保持一致,否则同一个std::string在一边按 COW 布局解释、在另一边按 SSO 布局解释,后果是未定义行为。
SSO 的思路是把短字符串直接存放在对象内部的固定数组里,不分配堆内存;超过阈值才转为堆分配。它同样让拷贝变成 O(n),但省掉了小字符串的分配开销,且失效规则简单:只要不修改,引用一直有效;一旦发生会改变容量或内容的操作,按标准规定失效。
需要强调:标准只规定basic_string的语义,不规定实现必须用 SSO。SSO 是常见实现选择,不是标准要求;标准也不保证sizeof(std::string)的具体值,不要依赖它做二进制布局假设。
四、自己实现一个 COW 字符串(教学示例)
理解了原理之后,可以用现代工具写一个行为正确的 COW 字符串。这里用std::shared_ptr<std::string>管理共享缓冲区,写操作前调用ensure_unique()分离。注意 C++17 起shared_ptr::unique()已弃用,C++20 移除,因此用use_count() == 1判断。
// 适用于 C++17
#include <cstddef>
#include <memory>
#include <string>
#include <utility>
class CowString {
public:
CowString() : data_(std::make_shared<std::string>()) {}
explicit CowString(const char* text)
: data_(std::make_shared<std::string>(text)) {}
explicit CowString(std::string text)
: data_(std::make_shared<std::string>(std::move(text))) {}
// 拷贝:只增加引用计数,不复制字符
CowString(const CowString&) = default;
CowString& operator=(const CowString&) = default;
CowString(CowString&&) noexcept = default;
CowString& operator=(CowString&&) noexcept = default;
std::size_t size() const noexcept { return data_->size(); }
// 只读访问:共享缓冲区,不触发复制
char operator[](std::size_t index) const { return (*data_)[index]; }
// 读写访问:若缓冲区被共享,先分离
char& operator[](std::size_t index)
{
ensure_unique();
return (*data_)[index];
}
void append(const std::string& suffix)
{
ensure_unique();
data_->append(suffix);
}
const std::string& str() const noexcept { return *data_; }
private:
void ensure_unique()
{
if (data_.use_count() != 1) {
data_ = std::make_shared<std::string>(*data_);
}
}
std::shared_ptr<std::string> data_;
};逐行检查几个关键点:
- 四个特殊成员函数全部
= default,拷贝构造只复制shared_ptr(增加计数),移动构造转移所有权。这里遵循的是「零规则」的变体:不手写资源管理,把计数交给shared_ptr。 ensure_unique()在use_count() != 1时新建一份std::string副本并替换指针。旧缓冲区若还有其他持有者,会由它们继续持有,计数自然递减。- const 版
operator[]返回char而不是const char&。这是刻意为之:如果返回引用,调用者可能通过const_cast写入共享缓冲区,破坏 COW 的前提。返回副本更安全,代价是一次字符拷贝。 - 非 const 版
operator[]返回char&。这个引用在下一次可能触发分离的操作之前有效;一旦调用append或其他写操作,此前的引用可能悬空。这正是标准库禁止 COW 的原因,在自己的类里必须把这条规则写进文档。 use_count()在多线程环境下只是瞬时值,其他线程可能同时复制或销毁shared_ptr,因此这个类不是线程安全的。若要多线程共享,外部仍需加锁,或者让每个线程持有独立副本。
如果需要不依赖shared_ptr的版本,可以手动在缓冲区头部放一个std::atomic<long>作为引用计数,拷贝时fetch_add(1, std::memory_order_relaxed),析构时fetch_sub(1, std::memory_order_acq_rel)并在减到零时释放。这能保证计数本身不丢,但对象内容的并发读写依然需要额外同步。
常见坑点
- ❌ 认为「标准库的
std::string是写时复制,所以拷贝很便宜」。
✅ C++11 起标准不允许 COW;拷贝是 O(n),需要常数时间请用移动或共享只读指针。
- ❌ 把 GCC 5 之前编译的静态库与新版编译器默认 ABI 混链,传递
std::string。
✅ 统一_GLIBCXX_USE_CXX11_ABI的设置,或在接口边界改用const char*/std::string_view。
- ❌ 从非 const
operator[]取得char&后,又对同一字符串做append,再使用那个引用。
✅ 引用只在下一次修改前有效;需要长期保存时拷贝一份字符。
- ❌ 在 COW 类的 const 成员函数里修改引用计数却不加
mutable或原子操作。
✅ 计数用std::atomic,且明确该计数只保证生命周期,不保护对象内容。
- ❌ 用
shared_ptr::unique()判断独占(C++17 弃用、C++20 移除)。
✅ 用use_count() == 1;同时知道它在多线程下只是瞬时快照。
- ❌ 把 COW 当成线程安全的同义词,多个线程直接读写同一个字符串对象。
✅ 每个对象仍需外部同步;COW 只解决缓冲区共享,不解决数据竞争。
- ❌ 依赖
sizeof(std::string)或内部布局做序列化、内存映射。
✅ 标准不规定布局;跨进程/跨编译器传递请用明确的字节协议。
总结
| 问题 | 结论 |
|---|
| COW 是什么 | 共享缓冲区 + 引用计数,写入时才复制 |
| 标准 string 能否 COW | C++11 起不允许,失效规则与线程模型都不兼容 |
| 历史实现 | libstdc++ 旧 ABI 用过,GCC 5.1 起改为 SSO |
| 现代替代 | SSO、移动语义、shared_ptr<const T> |
| 自实现要点 | 写前分离、计数原子、明确引用失效规则 |
COW 是一个有教学价值的技术:它把「拷贝开销」推迟到真正需要时,思路在缓存、数据库快照、容器镜像里都能见到。但对std::string而言,C++11 选择了更简单、更可预测的失效语义,COW 因此退出标准库实现。理解这段历史和背后的标准条款,比记住「string 是不是 COW」这个结论更重要——它解释了为什么现代 C++ 更强调移动语义和明确的所有权。