C++ STL之string详解:从使用到底层,再到面试八股
本文面向面试和日常开发,先讲调用,再讲原理,最后给口语化面试答案。
一、用法速查
1.1 初始化
#include <string>
string s; // 空
string s = "hello"; // C字符串
string s(5, 'a'); // "aaaaa"
string s = {'h','e','l'}; // "hel"
string b = s.substr(0, 3); // 子串
1.2 常用函数
| 方法 | 含义 | 复杂度 |
|---|---|---|
s.size() / s.length() | 长度 | O(1) |
s.empty() / s.clear() | 判空/清空 | O(1)/O(N) |
s.push_back(c) / pop_back() | 末尾增删字符 | O(1) |
s += str | 追加 | O(N) |
s.substr(pos, len) | 子串,不修改原串 | O(len) |
s.find(str) | 查找,未找到返回 npos | O(N) |
s.c_str() | 返回 const char*,保证 \0 结尾 | O(1) |
s.data() | C++17起返回可写 char*,保证 \0 结尾 | O(1) |
stoi(s) / to_string(x) | 字符串与数字互转 | O(N) |
1.3 遍历
for (char c : s) // 只读
for (char &c : s) // 可修改
for (auto it = s.begin(); it != s.end(); ++it) // 迭代器
1.4 读入注意
cin >> s; // 遇空格停
getline(cin, s); // 读整行
// cin 后接 getline,必须先 cin.ignore() 吃掉残留换行符
二、底层原理
2.1 string 的内存到底长什么样?
要理解 string 的底层,我们先看一段代码,在不同编译器下 sizeof(string) 的返回值是不同的:
cout << sizeof(string) << "\n";
// GCC/libstdc++:32 字节
// Clang/libc++: 24 字节
// MSVC/x64: 32 字节
这个差异的根源在于 std::string 并不是一个简单的 char*。标准库只规定了接口,内部实现完全由编译器厂商自己决定。目前主流实现都采用了一种叫做 SSO(Short String Optimization) 的技术,我们接下来详细展开。
先看 GCC 的 std::string 在 x64 下的内存布局(简化版):
┌──────────────────┬──────────────────┬───────────────────────┐
│ _M_dataplus │ _M_string_length│ _M_local_buf[16] │
│ (指向数据的指针) │ (当前字符串长度) │ (本地缓冲区,栈上) │
└──────────────────┴──────────────────┴───────────────────────┘
8 字节 8 字节 16 字节
总共 32 字节。其中 _M_dataplus 是一个指针,它可能指向堆上的内存,也可能指向本地缓冲区 _M_local_buf。
那 Clang 为什么只有 24 字节?因为 clang 的 libc++ 采用了更紧凑的设计——它把容量(capacity)字段和 SSO 标记位巧妙地压缩到了一个字节里,省下了 8 字节的空间。具体做法是:如果字符串在 SSO 模式下,最高位字节存的是 SSO容量 - 当前长度;切换到堆模式后,这个字节存的是 capacity 的一部分。这种位压缩技术让 clang 在 24 字节内塞下了 22 个字符的 SSO 缓冲区,比 gcc 的 15 个多了将近 50%。
2.2 SSO:为什么存 “hello” 不需要堆分配?
SSO(Short String Optimization)的含义是:当字符串长度不超过一定阈值时,数据直接存在 string 对象内部的栈空间里,不分配堆内存。
为什么需要这个优化?因为实际编程中,绝大多数字符串都很短——日志消息、配置项、JSON key、文件路径,长度超过 20 个字符的反而是少数。如果每次创建一个 string 都要 new 一块堆内存,那性能开销就太大了。SSO 让这些短字符串的创建和销毁都变成零堆分配,性能极高。
以 gcc 为例,阈值是 15 个字符。来看一段验证代码:
#include <string>
#include <iostream>
using namespace std;
int main() {
string s = "hello"; // 5 字符
cout << s.capacity() << "\n"; // 输出 15(SSO 容量)
cout << (void*)s.data() << "\n"; // 指向栈上
cout << (void*)&s << "\n"; // string 对象本身的地址
s = "this is a long string"; // 超过 15 字符
cout << s.capacity() << "\n"; // 输出扩容后的容量(30/31/46 看编译器)
cout << (void*)s.data() << "\n"; // 指向堆上,地址明显不同
}
你可以自己跑一下,会发现在 s = "this is a long string" 之后,s.data() 的地址发生了跳变——从栈上跳到了堆上。这就是 SSO 和堆模式的切换。
gcc 的字符串内部有一个 union 结构,用来实现 SSO 和堆模式之间的切换:
union {
size_t _M_allocated_capacity; // 堆模式:已分配的容量
char _M_local_buf[16]; // SSO 模式:本地缓冲(15字符+1个\0)
};
- 当
size() <= 15:指针指向_M_local_buf,使用栈空间 - 当
size() > 15:分配堆内存,指针指向堆空间,_M_allocated_capacity记录容量
这里有个容易忽略的细节:capacity() 的返回值在 SSO 模式和堆模式下含义不同。SSO 模式下 capacity() 返回的是本地缓冲区的容量(15),而堆模式下返回的是实际分配在堆上的容量。所以你不能用 capacity() 来判断字符串是否在 SSO 模式下。
另外,gcc 的 capacity() 和 size() 对 SSO 字符串返回同样的值(15),这意味着在 SSO 模式下,capacity 的概念被弱化了——实际上 15 字节缓冲区中还包含了 \0 的位置,真正能用的字符数是 15。这和堆模式下 capacity() - 1 才是最多能存的无重分配字符数的语义是一致的。
2.3 COW:一个被 C++11 判了死刑的优化
在 C++98 时代,gcc 的 std::string 默认使用了一种叫做 COW(Copy-on-Write,写时复制) 的优化策略。它的核心思想是:多个 string 对象共享同一块堆数据,只有当你尝试修改其中某一个时,才真正执行数据的拷贝。
这听起来很美好——省内存、省拷贝。但实际上 COW 带来了大量问题。
第一个问题是线程安全。 COW 需要一个引用计数器来追踪有多少个 string 对象在共享同一块数据。在多线程环境下,每次拷贝、赋值、析构都要对这个计数器做原子操作(atomic increment/decrement)。原子操作本身是有开销的——而短字符串的拷贝其实非常快(可能就几个字节),相比之下原子操作反而更慢。也就是说,COW 省的是"大字符串拷贝"的开销,却给"小字符串赋值"加了负担。
第二个问题更严重:operator[] 的行为变得不可预测。 看这段代码:
string a = "hello";
string b = a; // COW:a 和 b 共享数据
char c = b[0]; // "读"操作——会触发 COW 吗?
答案取决于实现。在 COW 模式下,编译器无法区分 b[0] 是读还是写——因为 operator[] 返回的是一个非 const 引用。为了安全,很多实现选择在调用非 const 的 operator[] 时就做拷贝(“unshare”),这意味着一个纯粹的"读"操作也可能触发内存分配和数据拷贝。这完全违背了程序员的直觉。
在单线程下,这只是性能问题;在多线程下,这直接导致数据竞争——线程 A 在读 b[0] 时触发 unshare,线程 B 同时在读 b[1],两个 unshare 互相打架。
第三个问题是迭代器失效。 COW 模式下的 unshare 会导致所有共享同一个数据块的 string 对象的迭代器失效——因为数据被复制到新位置了。这违反了 std::string 的标准语义。
C++11 的决定很干脆:废掉 COW。原因有三:
- 移动语义(move semantics)的出现让拷贝成本大幅降低——不需要 COW 来省拷贝了
- COW 与
operator[]、迭代器等基础操作的语义冲突无法调和 - 标准委员会认为:让拷贝真的拷贝、让移动真的移动,比引入不可预测的 COW 要好得多
这之后,gcc 5.0 起彻底移除了 COW 实现,全面使用 SSO + 移动语义的架构。
面试时如果你被问到 COW,回答要点是:“COW 是老版本 gcc 用的优化,多线程下原子引用计数反而更慢,而且 operator[] 的行为不可预测。C++11 引入移动语义后,拷贝已经不贵了,所以废掉了。记住结论就行:现代 C++ 里你不需要关心 COW。但如果你维护老代码,gcc 4.x 的 string 可能在用 COW——这个在面试里说出来的话挺加分的。”
2.4 扩容策略:2 倍 vs 1.5 倍之争
当我们不断往 string 里追加字符,总有一刻容量不够用。这时 string 需要分配一块更大的内存,把旧数据拷过去,再释放旧内存。问题来了:新内存分配多大?
主流编译器的策略:
| 编译器 | 扩容倍数 |
|---|---|
| GCC | 2 倍 |
| Clang | 2 倍 |
| MSVC | 1.5 倍 |
为什么选这些倍数而不是加固定大小?这就是经典的均摊分析(Amortized Analysis):如果每次扩容都翻倍,push_back N 次的总拷贝次数大约是 O(N),均摊下来每次 push_back 是 O(1)。如果每次只加固定大小(比如 10 个字节),总拷贝次数就是 O(N²)。
但为什么 MSVC 选 1.5 倍?这涉及到内存碎片的问题。假设我们从容量 16 开始,每次 2 倍扩容:
分配块大小:16 → 32 → 64 → 128 → 256 → 512 ...
注意一个关键性质:每次分配的新块大小,都比之前所有已分配块的总和还要大。16+32=48 < 64,16+32+64=112 < 128。这意味着:当你释放旧块时,它们永远无法被合并成一块足够大的连续内存来满足下一次扩容。旧块就成了永久的内存碎片。如果是在长时间运行的服务里反复创建和销毁大字符串,这种碎片会越来越严重。
而 1.5 倍扩容的序列是:
16 → 24 → 36 → 54 → 81 → 121 ...
你会发现 16+24+36=76,已经大于 81 的下一个 121 的一半了。换句话说,之前释放的内存有可能被合并起来复用(取决于内存分配器的实现),碎片化程度比 2 倍要好。
那 gcc 为什么不改用 1.5 倍?因为 gcc 的 libstdc++ 在扩容时,不是简单地重新 new——它会先尝试用 realloc 在原地扩展。如果原地扩展成功,就不需要拷贝旧数据,也不需要释放旧块,碎片问题就不存在。而 MSVC 的内存分配器不支持 realloc 原地扩展的优化,所以选了 1.5 倍来弥补。
小结:2 倍在 realloc 路径下最优(gcc/clang),1.5 倍在 malloc+free 路径下碎片更友好(MSVC)。
2.5 c_str() 和 data() 的历史恩怨
这两个函数的存在本身就是 C++ 向 C 兼容的痕迹。它们的演进历史:
| 版本 | c_str() | data() |
|---|---|---|
| C++98/03 | 保证 \0 结尾 | 不保证 \0 结尾 |
| C++11/14 | 保证 \0 结尾 | 保证 \0 结尾(等同于 c_str,返回 const) |
| C++17 | 同 C++11 | 返回 char*(可写),保证 \0 结尾 |
C++98 时代,data() 不一定以 \0 结尾。这意味着你不能安全地把 s.data() 传给 printf——这可能打印出字符串后面的垃圾数据。当时的标准设计意图是:c_str() 是给 C 接口用的,data() 是给直接操作底层字节用的。
C++11 统一了两者:都保证 \0 结尾。data() 返回 const char*——这样做的原因是 C++11 要求 string 的存储必须是连续的,且 \0 结尾。这个要求使得 data() 和 c_str() 在语义上完全等价。
C++17 进一步开放权限:data() 可以返回非 const 的 char*,允许直接修改字符串内容(前提是你不越界)。
日常开发建议:需要 const char* 时用 c_str();C++17 起需要直接修改内容时用 data();两者现在都是 O(1) 操作,不涉及拷贝。
2.6 std::string_view:一个不拥有数据的神器
C++17 引入的 std::string_view 是一个非拥有(non-owning)的字符串视图。它不拷贝数据,只持有两个东西:一个指向字符串首字符的指针,和一个长度。
class string_view {
const char* data_; // 指向别人的数据
size_t size_; // 长度
};
它的核心价值在于零拷贝传参。以前你写一个接受字符串参数的函数,要么用 const string&(只能接受 string 类型),要么写多个重载。现在用 string_view,一个函数就能接受 string、const char*、string_view,而且完全没有拷贝开销:
void process(string_view sv) {
// sv 只是一个"视图",没有拷贝任何数据
auto sub = sv.substr(0, 5); // substr 也是 O(1)!只调整指针和长度
if (sv.starts_with("http")) { } // C++20 新增
}
process("literal"); // OK
process(s); // OK,string → string_view 隐式转换
process(sv); // OK,传 string_view 本身
string_view 的 substr 为什么是 O(1)?因为它不复制数据,只是把内部的 data_ 和 size_ 调整一下:
string_view substr(size_t pos, size_t count) const {
return string_view(data_ + pos, min(count, size_ - pos));
}
就是一个指针偏移——没有内存分配,没有数据拷贝。
但是,string_view 不拥有数据这一点是一切坑的根源。看这个经典的反例:
string_view get_config() {
string s = read_from_file(); // s 是局部变量
return string_view(s); // 悬垂!s 在函数返回后销毁
} // ← s 的生命周期结束,string_view 指向已释放的内存
任何返回 string_view 的函数,都必须保证原始数据在 string_view 的使用期内始终有效。另一个常见陷阱是临时 string 对象的隐式转换:
string_view sv = string("hello").substr(1);
// "hello".substr(1) 产生临时 string "ello"
// 然后转换为 string_view
// 临时对象在这行结束后被销毁
// sv 指向已释放的内存——悬垂引用
还有一个要注意的点:string_view 不保证 \0 结尾。你不能直接把 string_view 传给 printf、fopen 等 C 函数。需要的话,先构造一个 string:
// 错误:sv 不保证 \0 结尾!
printf("%s", sv.data());
// 正确:先转 string,再取 c_str()
string tmp(sv);
printf("%s", tmp.c_str());
这也是为什么从 string_view 构造 string 不能是隐式的——标准委员会特意把 string(string_view) 声明为 explicit,防止你不经意间做了拷贝。
三、面试题 + 口语化答案
Q1:sizeof(string) 是多少?
“看编译器。gcc 是 32 字节,clang 是 24 字节,MSVC 是 32 字节(x64)。不同是因为 SSO 阈值不一样,gcc 存 15 个字符,clang 存 22 个。”
Q2:什么是 SSO?什么时候触发?
“短字符串优化。字符串长度不超过阈值(gcc 15)时,数据直接存在 string 对象内部的栈空间里,不需要堆分配。比如 string s = "hello",五个字符,零堆分配。”
Q3:COW 是什么?为什么 C++11 废弃了?
“Copy-on-Write,写时复制。多个 string 共享同一块数据,只在修改时复制。废掉的原因:多线程下引用计数的原子操作反而比直接拷贝更慢;operator[] 行为不可预测;C++11 移动语义让拷贝成本已经很低了。如果你面试时能说出’gcc 4.x 在 C++98 下用过 COW,5.0 起废弃’,面试官会觉得你基础扎实。”
Q4:c_str() 和 data() 有什么区别?
“C++11 之后基本一样,都保证 \0 结尾。C++17 起 data() 返回非 const,可以直接修改。调 C API 用 c_str() 就行。”
Q5:string 怎么扩容?为什么 MSVC 用 1.5 倍?
“gcc 和 clang 是 2 倍,MSVC 是 1.5 倍。扩容是为了 push_back 均摊 O(1)。2 倍扩容的问题是内存碎片——每次新分配都比之前所有旧块的总和还大,旧的无法复用。1.5 倍可以逐步复用。gcc 因为用 realloc 原地扩展,不受碎片影响,所以 2 倍没问题。”
Q6:string 和 vector<char> 有什么区别?
“string 保证 \0 结尾(方便跟 C API 交互),有 SSO 优化,有 find/substr/c_str 等字符串专用方法。vector<char> 没有 SSO,没有 \0 保证。存二进制数据用 vector<char>,存文本用 string。”
Q7:string_view 什么时候用?有什么坑?
“用作函数参数替代 const string&,零拷贝,同时接受 string、const char*、string_view。substr 是 O(1)。坑:不拥有数据——原始字符串被销毁后变成悬垂引用;不保证 \0 结尾,不能直接给 printf。”
Q8:string s = "abc"; s[3] = 'd'; 会怎样?
“未定义行为。s[3] 访问了 \0 的位置,写入可能破坏 SSO 缓冲区边界。operator[] 不做边界检查,要用 at() 才会抛异常。”
一句话总结:string 的底层远比 API 复杂——SSO、扩容策略、COW 历史、c_str/data 演进、string_view 陷阱,这些都是面试高频考点。理解原理的目的不是炫技,是在性能敏感场景下做正确的技术决策。

769

被折叠的 条评论
为什么被折叠?



