前言
std::string是日常使用频率最高的类之一,但它的底层并不是「一个char*加一个长度」这么简单。主流实现要在三件事之间做权衡:短字符串能否避免堆分配(小字符串优化,small string optimization, SSO)、长字符串如何管理容量、对象本身要占多少字节。
常见误解有三个。第一,认为std::string内部就是一个char*,所以sizeof(std::string)应该等于指针大小——实际通常是三到四个机器字。第二,认为字符串一定是堆分配的,c_str()返回的地址在对象移动后就失效——SSO 下短字符串存在对象内部,移动时可能复制缓冲区。第三,把 C++98 时代的「写时复制」(copy-on-write, COW)当成现在的行为——C++11 起标准的多项要求实际上让std::string不再能对普通用户透明地 COW。
本文讲标准保证了什么、常见实现通常怎么做,二者严格区分。标准没有规定std::string的布局、SSO 的阈值、扩容因子,任何涉及具体字节数的结论都要以你的标准库实现为准。
一、标准保证的接口契约
先从标准层面确认几条硬性规定:
std::string的元素在内存中连续存储(C++11 起明确要求),因此&s[0] + i可以取到第 i 个字符。c_str()与data()返回指向以空字符结尾的数组的指针;C++11 起data()也保证空终止(C++17 起还有非const重载,返回char*)。size()不含结尾空字符,capacity()是可以不重新分配就能容纳的字符数(不含结尾空字符)。- 任何可能触发重新分配的操作(
push_back、append、insert、reserve、resize超出容量)都会使指针、引用、迭代器失效。 - 不触发重新分配的修改,也会使「指向被修改位置之后」的指针失效;
c_str()的指针在任何非const操作后都不应继续使用。 - 被移动后的
std::string处于「有效但未指定」状态,只能销毁或重新赋值。
标准没有规定:
sizeof(std::string)。- SSO 的容量阈值。
- 扩容倍数。
capacity()的具体数值(只规定下界)。- 内部是否用引用计数。
二、常见布局:三指针或「指针 + 长度 + 缓冲区」
最直观的实现是三个指针:begin、size、capacity_end。size()用end - begin计算,capacity()用cap_end - begin。这种布局在 64 位平台上通常是 24 字节。
另一种常见布局把「容量」与「小缓冲区」放进一个union:短字符串时,union 里存的是内联字符数组;长字符串时,union 里存的是容量值。这样对象可以同时支持 SSO 与动态分配,而尺寸仍控制在三到四个机器字。
// 适用于 C++17
// 仅用于说明布局思路,不是任何标准库的真实实现
#include <cstddef>
#include <cstring>
class StringLayoutDemo {
public:
const char* data() const {
return is_small_ ? small_ : large_;
}
std::size_t size() const { return size_; }
std::size_t capacity() const {
return is_small_ ? kSmallCap : capacity_;
}
bool is_small() const { return is_small_; }
private:
static constexpr std::size_t kSmallCap = 15;
char small_[kSmallCap + 1] = {}; // 短字符串内联存储,含结尾空字符
char* large_ = nullptr; // 长字符串堆缓冲区
std::size_t size_ = 0;
std::size_t capacity_ = 0;
bool is_small_ = true; // 判别标志
};这段代码为了清晰,把内联缓冲区与堆指针分开存放,因此对象比真实实现更大。真正的实现通常用union复用这两块存储:短字符串时 union 里是字符数组,长字符串时是容量或指针,再用某个字段的冗余位判别当前处于哪种模式。这样能减小对象尺寸,但读取非活跃的 union 成员需要额外的类型双关技巧,不适合在示例里简化展示。不同标准库的判别方式也不同,有的用容量字段的最高位,有的用缓冲区首字节。
需要强调的是,不能通过sizeof或内存布局去推断某个字符串是否发生了 SSO。标准没有提供查询接口,实现也可以随时改变策略。
三、小字符串优化:短字符串不进堆
SSO 的动机很直接:大量字符串很短(标识符、键名、数字转字符串),每次都new一块堆内存,分配与释放的开销远大于拷贝十几个字节。把小缓冲区放进对象内部,短字符串完全不需要堆分配,构造、拷贝、销毁都变成栈上的字节复制。
代价是对象变大:即使字符串是空的,对象也要容纳那块内联缓冲区。这也是为什么sizeof(std::string)通常远大于sizeof(char*)。
// 适用于 C++17
#include <iostream>
#include <string>
int main() {
std::string a = "hi"; // 很可能走 SSO
std::string b(200, 'x'); // 必然堆分配
std::cout << a.size() << ' ' << a.capacity() << '\n';
std::cout << b.size() << ' ' << b.capacity() << '\n';
a.reserve(100); // 超过 SSO 阈值后转为堆分配
std::cout << a.capacity() << '\n';
}按标准语义,a.capacity()至少为 2,b.capacity()至少为 200;具体数值由实现决定。reserve(100)之后容量至少为 100,字符串内容不变,但若发生重新分配,之前保存的c_str()指针全部失效。
SSO 还影响移动语义:移动一个短字符串时,实现不能简单地把内部缓冲区的指针「偷」走(那块内存在源对象体内),只能逐字节复制;移动一个长字符串时,可以只转移堆指针,把源对象置为空。所以「移动一定比拷贝快」对短字符串并不成立,具体取决于实现。
四、写时复制、容量与只读视图
C++98 时代,部分实现(包括 libstdc++ 的旧 ABI)对std::string使用 COW:多个字符串共享同一块缓冲区,引用计数为 1 时才真正写入。它省内存、让拷贝变成 O(1),但带来两个问题:多线程下引用计数的原子操作开销,以及operator[]必须返回代理对象才能检测写入,导致&s[0]的类型不再是char*。
C++11 起,标准要求operator[]返回reference(即char&),要求data()返回连续存储的指针,还要求容器操作在并发只读时安全。这些要求叠加起来,使得对普通std::string透明地做 COW 变得不可行。因此现代实现普遍放弃 COW,改为「每次拷贝都深拷贝 + SSO 减少小字符串的分配」。
这也解释了一个常见困惑:为什么把std::string传给函数「按值」看起来没有想象中那么贵?短字符串走 SSO,长字符串有一次真实拷贝;如果只想读,应该传const std::string&或std::string_view。
容量管理与常见操作的成本
| 操作 | 是否可能重新分配 | 复杂度 | 备注 |
|---|
size()/empty() | 否 | O(1) |
operator[] | 否 | O(1) | 越界不检查 |
at() | 否 | O(1) | 越界抛std::out_of_range |
push_back | 是(超出容量) | 均摊 O(1) | 扩容因子未规定 |
append/+= | 是 | 均摊 O(n) | 追加长度 |
insert中间 | 是 | O(n) | 搬移后续字符 |
find | 否 | 最坏 O(n·m) | 朴素匹配,实现可能优化 |
substr | 否(返回新串) | O(k) | k 为子串长度 |
reserve | 可能 | O(n) | 容量至少为 n;n 不大于当前容量时通常无效果 |
shrink_to_fit | 可能 | O(n) | 非强制,实现可忽略 |
reserve的语义是「容量至少达到 n」,不是「恰好 n」。shrink_to_fit是「请求」缩减,标准明确说它是非绑定的(non-binding),实现可以不做。依赖这两个函数的具体容量值,都是不可移植的。
扩容时,实现通常按某个因子增长(1.5 倍或 2 倍都常见),以保证push_back的均摊 O(1)。标准不规定因子,所以「预留多少」应按业务上界估算,而不是猜倍数。
string_view:只读视图不是字符串
C++17 引入std::string_view,它只持有指针与长度,不拥有字符、不保证空终止。它适合做「只读参数」,避免临时std::string的构造与拷贝,但不能长期保存指向临时对象的视图。
// 适用于 C++17
#include <string>
#include <string_view>
#include <iostream>
void print(std::string_view sv) {
std::cout.write(sv.data(), static_cast<std::streamsize>(sv.size()));
std::cout << '\n';
}
int main() {
std::string owned = "hello world";
print(owned); // 不拷贝
print("literal"); // 字面量,OK
print(owned.substr(0, 5)); // substr 返回新 string,临时对象!
}第三行要特别注意:owned.substr(0, 5)返回一个临时std::string,print内部使用期间它仍然存活(临时对象到完整表达式结束才销毁),所以这次调用是安全的;但如果把std::string_view存起来,临时字符串销毁后视图就悬垂了。
常见坑点
- ❌ 保存
s.c_str()返回的指针,之后又对s做append。
✅ 任何非const操作都可能让指针失效;需要长期保存就拷贝一份std::string。
- ❌ 认为
sizeof(std::string) == sizeof(char*)。
✅ 常见实现包含指针、长度、容量或内联缓冲区,尺寸更大;具体值是实现定义。
- ❌ 假设短字符串一定在堆上,或长字符串一定不在。
✅ SSO 阈值由实现决定,标准不提供查询方式。
- ❌ 依赖
capacity()的具体数值或扩容倍数。
✅ 标准只保证下界;按业务上界reserve,不要猜。
- ❌ 认为
shrink_to_fit()一定释放内存。
✅ 它是非绑定请求,实现可以忽略。
- ❌ 在 C++17 之前通过
data()写入字符。
✅ C++17 起非constdata()才返回可写指针;更早版本用&s[0]且需保证非空。
- ❌ 把
std::string_view指向临时std::string并长期保存。
✅ 视图不拥有数据;确保被指向的字符串活得更久。
- ❌ 认为
std::string的拷贝一定是深拷贝、移动一定 O(1)。
✅ 短字符串的移动可能逐字节复制;具体看实现与 SSO。
总结
| 主题 | 标准保证 | 常见实现 |
|---|
| 存储连续性 | 是(C++11 起) | 连续 |
| 空终止 | c_str()/data()保证 | 是 |
| 对象大小 | 未规定 | 通常 3~4 个机器字 |
| SSO | 未规定 | 普遍支持,阈值各异 |
| 扩容因子 | 未规定 | 常见 1.5 或 2 倍 |
| COW | 实际上不可行 | 现代实现已放弃 |
shrink_to_fit | 非绑定 | 可能忽略 |
| 移动后状态 | 有效但未指定 | 通常为空或短字符串 |
理解std::string的关键,是把「标准契约」与「实现策略」分开:连续性、空终止、失效规则是必须遵守的;SSO、容量数值、对象大小只是某个实现的选择。写代码时只依赖前者,性能调优时用reserve和string_view减少分配与拷贝,而不是假设某个库的内部布局。这样换一个标准库、换一个平台,代码仍然正确。