前言
std::string 是 C++ 里用得最多的类型,但很多人对它的理解停留在"能装字符串"。一旦涉及性能调优 或未定义行为排查,就暴露出几个经典盲区:
- 为什么
sizeof(std::string)是 32 而不是 8? - 为什么
c_str()拿到的指针有时会失效? - 为什么传
string参数有时候不拷贝? - 为什么
s1 + s2在循环里会慢得离谱?
本文把 basic_string 的内存布局、对象语义、迭代器失效规则讲清楚。
一、std::string 到底是什么
它不是一个类,而是一个模板的别名:
cpp
// 真实身份
template<class CharT, class Traits = std::char_traits<CharT>,
class Allocator = std::allocator<CharT>>
class basic_string;
// 我们平时用的
using string = std::basic_string<char>;
同理还有 std::wstring(wchar_t)、u8string(char8_t,C++20)、u16string、u32string。
关键点 :string 是模板实例化 ,所以它的所有实现细节由标准库实现 决定 ------ libstdc++(GCC)、libc++(Clang)、MSVC STL 三者布局并不相同。
二、内存布局:短字符串优化(SSO)
2.1 先看一个反直觉的事实
cpp
#include <string>
#include <iostream>
int main() {
std::cout << sizeof(std::string) << '\n'; // libstdc++: 32
std::cout << sizeof(char*) << '\n'; // 8
}
一个"字符串",为什么占了 4 个指针的大小?
原因:短字符串优化(Short String Optimization, SSO)。
如果字符串很短,不分配堆内存 ,直接把字符存进对象内部的一块缓冲区里。这就省掉了一次 malloc ------ 对小字符串(日常使用中占比极高)性能提升非常明显。
2.2 libstdc++ 的布局
+----------------------------+ <-- string 对象起始
| char* _M_dataplus._M_p | 8 字节:指向实际字符数据
+----------------------------+
| size_t _M_string_length | 8 字节:当前长度
+----------------------------+
| union { | 16 字节:
| char _M_local_buf[16];| - 短串时:本地缓冲区
| size_t _M_allocated_cap;| - 长串时:堆容量
| } |
+----------------------------+
总计 32 字节
SSO 的临界值 :libstdc++ 中本地缓冲区是 16 字节,其中 1 字节要留给结尾的 '\0',所以长度 ≤ 15 的字符串不分配堆内存。
2.3 动手验证
cpp
#include <string>
#include <iostream>
int main() {
std::string s = "short";
// 观察:短串时 data() 指向对象内部,长串时指向堆
std::cout << "对象地址: " << (void*)&s << '\n';
std::cout << "data()地址: " << (void*)s.data() << '\n';
std::cout << "--- 追加到超过 15 字节 ---\n";
s += "0123456789ABCDEF";
std::cout << "对象地址: " << (void*)&s << '\n';
std::cout << "data()地址: " << (void*)s.data() << '\n';
}
在 libstdc++ 下,第一组两个地址非常接近 (data 在对象内部),第二组则相差很远(data 在堆上)。
2.4 三个实现对比
| 实现 | sizeof(string) |
SSO 容量 | 备注 |
|---|
|-----------------|----|----|-------------|
| libstdc++ (GCC) | 32 | 15 | 三指针 + union |
|----------------|----|----|-----------|
| libc++ (Clang) | 24 | 22 | 单指针 + 位压缩 |
|----------|----|----|--------------|
| MSVC STL | 32 | 15 | 类似 libstdc++ |
⚠️ 所以:sizeof(std::string) 的值不可移植,不要依赖它。
三、对象语义:拷贝、移动与 COW
3.1 拷贝是深拷贝
cpp
std::string a = "hello world, this is a long string";
std::string b = a; // 深拷贝:b 拥有独立的内存
b[0] = 'H';
// a 仍是 "hello...",不受影响
3.2 移动是"偷指针"
cpp
std::string a = "hello world, this is a long string";
std::string b = std::move(a); // O(1),只交换了内部指针
// 此后 a 处于"有效但未指定"状态,不要依赖其内容
移动的代价 :对长字符串是 O(1)(偷指针),对短字符串(SSO)反而要逐字节拷贝那 16 字节,可能比预期慢一点点。
3.3 COW 的历史(重要背景)
C++98 时代,GCC 的 std::string 曾用写时复制(Copy-On-Write):多个 string 共享同一块内存,直到有人要写才真正拷贝。
问题在于 :COW 要求 operator[] 返回拷贝 而非引用(否则写操作无法被感知),这与 operator[] 的语义冲突。同时多线程下引用计数是性能瓶颈。
C++11 起,标准明确禁止了 COW 实现 (要求 &s[0] 和 &*s.begin() 语义一致、operator[] 返回引用)。所以现代 std::string 都是深拷贝语义。
如果你在面试中被问到"string 是 COW 吗",正确答案是:标准禁止,现代实现都不是。
四、踩坑集锦
坑 1:c_str() 的指针会失效
cpp
// ❌ 悬垂指针
const char *p = s.c_str();
s += " more text"; // 可能触发扩容,p 指向的内存已被释放
printf("%s", p); // 未定义行为
// ✅ 正确:随用随取
s += " more text";
printf("%s", s.c_str());
规则 :任何可能改变容量 的操作(append、+=、insert、resize、reserve)之后,之前取得的 c_str() / data() / 迭代器 / 引用全部失效。
坑 2:c_str() 不能持久保存
cpp
// ❌ 函数返回临时对象的 c_str()
const char* bad() {
std::string tmp = "hello";
return tmp.c_str(); // tmp 析构,指针悬垂
}
// ✅ 返回 string 本身
std::string good() {
return "hello";
}
坑 3:迭代器失效规则
cpp
std::string s = "hello";
auto it = s.begin();
s += " world, this is a long string"; // 扩容!
// it 已失效,再用就是 UB
记住这张表:
| 操作 | 迭代器是否失效 |
|---|
|------------------------------------------|-----------------|
| append / += / insert / push_back | 可能全部失效(扩容时) |
|----------------------|-----------|
| erase / pop_back | 被删位置之后的失效 |
|-----------|----------------|
| reserve | 若改变容量,全部失效 |
|---------|------|
| clear | 全部失效 |
|--------------------------------|-----|
| [] / at / front / back | 不失效 |
所以:任何修改操作后,都不要复用之前的迭代器。
坑 4:循环里用 + 拼接
cpp
// ❌ 每次都产生临时 string,O(n²)
std::string result;
for (int i = 0; i < 10000; i++) {
result = result + std::to_string(i) + ",";
}
// ✅ 原地追加,均摊 O(1)
std::string result;
for (int i = 0; i < 10000; i++) {
result += std::to_string(i);
result += ',';
}
// ✅✅ 已知大小时先 reserve,避免多次扩容
std::string result;
result.reserve(10000 * 6);
for (int i = 0; i < 10000; i++) {
result += std::to_string(i);
result += ',';
}
扩容策略 :libstdc++ 通常是翻倍 (2 倍或 1.5 倍),所以不 reserve 的话会有 log n 次重新分配 + 拷贝。
坑 5:substr 返回新对象,不是视图
cpp
std::string s = "hello world";
// ❌ substr 会拷贝出一份新字符串
auto sub = s.substr(0, 5); // 分配了内存
// ✅ C++17:string_view 零拷贝
#include <string_view>
std::string_view v(s.data(), 5);
⚠️ string_view 不拥有内存,必须保证底层 string 的生命周期长于 view,否则同样是悬垂。
坑 6:size() 返回无符号数
cpp
std::string s = "abc";
// ❌ 死循环:i 是 int,与无符号比较时会转成无符号
for (int i = 0; i < s.size() - 1; i++) { }
// 更危险的经典 bug
if (s.size() - 1 > 0) { } // 空串时 size()-1 回绕成极大值
规则 :size() 返回 size_t,做减法前先判空。
坑 7:std::string 与 UTF-8
cpp
std::string s = "你好"; // UTF-8 编码,占 6 字节
std::cout << s.size() << '\n'; // 输出 6,不是 2
// ❌ 按字节遍历会撕裂多字节字符
for (char c : s) { /* 拿到的是半个汉字 */ }
std::string 是字节容器,不是字符容器。 要正确处理 Unicode,需要专门的库(ICU、utf8cpp)或 C++20 的 std::u8string + 手动解码。
五、传参的正确姿势
cpp
// ❌ 值传递:每次都深拷贝
void bad(std::string s);
// ✅ 只读:传 const 引用,不拷贝
void good(const std::string& s);
// ✅ 只读且接受字面量:C++17 起用 string_view
void better(std::string_view s);
// ✅ 需要内部存一份:值传递 + move(拷贝省略 / 移动构造)
struct Widget {
std::string name_;
explicit Widget(std::string name) : name_(std::move(name)) {}
};
关于 string_view 的警告 :它不接受 const char* 的隐式 转换中的临时对象安全 ------ 确切地说,std::string_view sv = std::string("tmp"); 会悬垂。所以用 string_view 参数时,调用方必须保证底层数据存活。
六、性能优化小结
| 场景 | 做法 |
|---|
|--------|---------------|
| 已知最终长度 | 先 reserve() |
|------|---------------|
| 循环拼接 | 用 += 而非 + |
|------|-------------------------------------------|
| 只读传参 | const std::string& 或 std::string_view |
|-------|-------------------|
| 需要存一份 | 值传递 + std::move |
|------|-----------------------|
| 只读切片 | string_view(注意生命周期) |
|-------|---------------------------|
| 大量小对象 | 注意 SSO 边界(15 字节),别以为一定不分配 |
|------|-----------------------------|
| 频繁增删 | 考虑 std::deque<char> 或分段结构 |
七、总结
std::string内存布局不可移植 ,三大家实现各不相同,sizeof是 24 或 32。- SSO 让短字符串(libstdc++ ≤ 15 字节)不分配堆内存,这是它性能好的关键。
- C++11 起标准禁止 COW,现代实现都是深拷贝语义。
c_str()/data()/ 迭代器在扩容后全部失效,这是最常见的 UB 来源。size()返回无符号数,减法前先判空。- 它是字节容器不是字符容器,处理中文要格外小心。
- 传参看用途 :只读用
const&/string_view,要存用值传递 +move。
把上面这几条记住,string 相关的性能问题和崩溃至少能少一半。
文中布局数据基于 libstdc++ 13 / libc++ 17 / MSVC 19.38 实测,不同版本可能略有差异。有疑问欢迎评论区讨论。