Chapter 15: String
std::string 看起来像一个普通值类型:可以拷贝、拼接、查找、截取,也可以交给 C 接口使用。源码层面需要先把它拆成三个对象关系:字符串对象本身持有一段字符序列,字符序列处在连续存储中,末尾还有一个由库维护的空字符位置。读 string 相关代码时,真正要追踪的是所有权、容量、指针有效性和字符序列语义。
本章使用一个贯穿材料:从网络或日志输入中构造一行可变字符串,追加片段,查找分隔符,截取字段,再把内容传给只读解析接口。这个材料覆盖 string 最常见的工程路径:写入时可能分配,查找时只读扫描,截取时产生新对象,视图传参时依赖原对象生命周期。
#include <string>
#include <string_view>
#include <vector>
std::string line = "Content-Length: ";
line.append("42");
line.push_back('\n');
auto colon = line.find(':');
std::string key = line.substr(0, colon);
std::string_view view = line;
读完本章后,应能按固定顺序判断一个 string 操作:先判断对象是否拥有字符序列,再判断操作是否可能改变 size() 或 capacity(),接着判断旧指针、旧引用、旧 iterator 和旧 string_view 是否仍然可用,最后判断接口是否依赖空字符、编码或隐式分配。
15.1 basic_string、char_traits 与 allocator 参与
std::string 是 std::basic_string<char> 的别名。源码阅读时应先看 basic_string 的三个模板参数:CharT 决定元素类型,Traits 决定字符级操作,Allocator 决定动态存储的分配与释放。常见写法只出现 std::string,但实现内部仍然围绕这三类职责展开。
basic_string 的模板形状可以简化成下面这样。这里是教学用骨架,用来定位角色关系,并非某个标准库的真实源码。
template<
class CharT,
class Traits = std::char_traits<CharT>,
class Allocator = std::allocator<CharT>
>
class basic_string;
using string = std::basic_string<char>;
using u8string = std::basic_string<char8_t>; // C++20
using u16string = std::basic_string<char16_t>; // C++11
using u32string = std::basic_string<char32_t>; // C++11
CharT 表示 string 存储的最小单元。std::string 的 CharT 是 char,它通常承载字节或 UTF-8 code unit。std::u16string 的 CharT 是 char16_t,它承载 16 位 code unit。标准库不会因为类型名里有 string 就自动理解 Unicode 字符、语言文字或字形边界;它管理的是 CharT 序列。
char_traits 是字符操作的策略层。basic_string 做比较、查找、长度计算和字符移动时,会通过 Traits::eq、Traits::lt、Traits::compare、Traits::length、Traits::find、Traits::copy、Traits::move 这类静态操作表达“怎样处理一个字符序列”。这使 basic_string 可以在不写死 char 操作的情况下服务不同 CharT。
std::string a = "abc";
std::string b = "abd";
bool same_first = std::char_traits<char>::eq(a[0], b[0]);
int order = std::char_traits<char>::compare(a.data(), b.data(), 3);
这个例子对应 string 比较和查找的底层思路:成员函数暴露的是 find、compare 这样的字符串接口,实现路径会落到 traits 提供的字符级操作。自定义 traits 可以改变字符相等和排序的含义,但这种选择会影响整个 string 类型的行为,也会降低与普通 std::string 的互操作性。工程代码中更常见的做法是保持 std::char_traits<char>,把大小写折叠、编码解析和本地化排序放到更明确的业务层。
Allocator 控制动态存储来源。string 是 allocator-aware container,它在需要外部存储时通过 allocator 分配和释放 CharT 数组。和节点容器相比,string 的元素是字符式对象,标准要求 string 不通过 allocator 的自定义 construct / destroy 成员来逐个构造和销毁字符。读实现时可以把 allocator 参与点理解成“申请连续字符缓冲区”和“释放连续字符缓冲区”。
#include <memory_resource>
#include <string>
std::pmr::monotonic_buffer_resource pool;
std::pmr::string msg{&pool};
msg.append("header");
msg.append(": value");
std::pmr::string 把 allocator 参数换成 std::pmr::polymorphic_allocator<char>。这会改变动态存储来自哪里,却不会改变 string 的连续存储、空字符、size()、capacity() 和查找语义。阅读这类代码时,判断重点是分配路径、资源释放时机和上游 memory_resource 生命周期。
15.2 连续内存、null terminator 与 size / capacity
现代 basic_string 的核心存储承诺是连续性:对于一个 string 对象 s,有效字符处在 [s.data(), s.data() + s.size()) 这个范围内。s.data() + s.size() 指向一个值为 CharT() 的终止位置,也就是 C 字符串语境里的 null terminator。这个终止位置由 string 维护,用来支持 c_str() 和需要零结尾字符数组的接口。
这条规则把 string 和 vector<char> 明确区分开。string 的 size() 统计有效字符数量,终止位置不计入 size()。capacity() 表示当前已预留存储中可容纳的有效字符数量,通常也不把终止位置当成用户可写容量。代码可以读取 data()[size()] 看到终止字符;把它改成其他值会破坏 string 的对象不变量,行为进入未定义区域。
std::string s = "abc";
// 有效字符范围:[data(), data() + size())
char first = s.data()[0];
char last = s.data()[s.size() - 1];
// 终止位置由 string 维护。
char terminator = s.data()[s.size()];
size() 和 capacity() 的关系服务于修改操作。size() 是当前字符序列长度,capacity() 是无需重新分配即可增长到的上限。reserve(n) 请求至少容纳 n 个有效字符,之后的 append 或 push_back 在长度没有超过容量时通常可以原地写入;超过容量时需要分配新缓冲区,移动或复制原字符,再释放旧缓冲区。
std::string s;
s.reserve(32);
const char* p = s.c_str();
s.append("Content-Length"); // 通常仍在预留容量内
s.append(64, '!'); // 可能触发重新分配
上面代码里,p 的有效性取决于后续操作是否改变了 string 的内部缓冲区。标准层面,传给接收非 const string 引用的标准库函数、调用多数非 const 成员函数,都可能使指向字符元素的指针、引用和 iterator 失效。operator[]、at、data、front、back、begin、end 这类访问函数属于较窄的例外范围;修改长度或容量的操作应统一按“旧位置对象需要重新获取”处理。
c_str() 和 data() 的版本边界也影响接口判断。C++11 之后,data() 返回的范围具有零结尾承诺;C++17 之后,非 const string 的 data() 可以返回可写 char*。可写 data() 适合把缓冲区交给明确按长度写入的低层接口,但调用方仍要维护 size() 与终止字符不变量。写入超过当前 size() 的位置,或者写坏 data()[size()],都不会让 string 自动修复长度。
std::string buffer = "abc";
char* raw = buffer.data(); // C++17 起可写
raw[1] = 'B'; // 修改有效字符,buffer == "aBc"
把 string 传给 C 接口时,需要先区分接口读取的是零结尾还是显式长度。std::strlen(s.c_str()) 会在第一个 \0 处停止,s.size() 可以统计内嵌零字符。二进制协议、压缩数据和部分网络字段可能含有零字节,此时以 c_str() 作为唯一边界会截断数据。
std::string payload{'A', '\0', 'B'};
std::size_t logical_size = payload.size(); // 3
std::size_t c_size = std::strlen(payload.c_str()); // 1
这一节的工程结论是:string 的连续内存支持数组式访问和 C 接口互操作,size() 支持现代 C++ 的显式长度边界,null terminator 支持历史 C 字符串边界。一个接口拿到 string 后采用哪一种边界,决定了它能否正确处理内嵌零字符、是否产生悬垂指针,以及是否依赖后续不发生重新分配。
15.3 append、insert、erase、substr 与 find
string 常用操作可以按两类读:修改字符序列的操作改变对象状态,只读查询操作返回位置或新对象。贯穿材料中的 append、push_back、insert、erase 都会改变当前 string;find 扫描当前字符序列并返回下标;substr 从当前范围构造一个新的 string。
append 只在尾部增长。实现路径通常先计算新长度,检查 max_size() 和容量,再选择原地追加或重新分配。原地追加时,新增字符写到旧尾部之后,并更新终止位置;重新分配时,旧 data() 指针和基于旧缓冲区的 string_view 都要重新判断。
std::string line = "Content-Length";
line.append(": ");
line.append("42");
line.push_back('\n');
这段代码的对象状态变化是线性的:size() 递增,字符序列尾部扩展,null terminator 被移动到新末尾。只要容量足够,字符位置的物理地址通常保持;一旦容量不足,整个字符数组转移到新存储。工程代码应把这种差异折叠成一条稳定规则:调用修改长度的操作后,外部保存的 data()、iterator、引用和 string_view 需要重新取得。
insert 在中间写入,成本高于尾部追加。它需要把插入点之后的字符向后移动,再写入新内容。即使容量足够,插入点之后的元素位置也会变化;容量不足时还会叠加重新分配。读源码时可以把 insert(pos, range) 看成“定位下标 → 扩张缓冲 → 移动后缀 → 填入新片段 → 更新长度和终止位置”。
std::string header = "ContentLength: 42";
header.insert(7, "-"); // "Content-Length: 42"
erase 删除一个区间。它需要把删除区间后面的字符向前移动,并缩短 size()。多数实现不会因为 erase 立刻释放容量,所以 capacity() 常常保持原值;这个行为适合后续继续增长,但会让“删除后马上省内存”的直觉失效。需要请求回收多余存储时,可以调用 shrink_to_fit(),它是非绑定请求,实现可以根据策略决定实际效果。
std::string value = "value\r\n";
if (!value.empty() && value.back() == '\n') {
value.pop_back();
}
if (!value.empty() && value.back() == '\r') {
value.pop_back();
}
find 返回的是下标,失败时返回 std::string::npos。npos 通常是 size_type(-1),作为无效位置哨兵使用。下标 API 的好处是它不直接绑定到底层地址;后续重新分配不会改变已经计算出的数字含义,但它仍然依赖同一个字符串内容没有发生结构性变化。
std::string line = "Content-Length: 42";
std::size_t colon = line.find(':');
if (colon != std::string::npos) {
std::string key = line.substr(0, colon);
std::string value = line.substr(colon + 1);
}
substr 返回新的 string。它会从源 string 的 [pos, pos + count) 范围构造独立对象,因此结果拥有自己的字符序列。这个语义安全、直接,也可能产生分配和复制。热路径解析中,大量 substr 会把只读切片变成堆分配;需要只读窗口时,std::string_view 更适合表达“引用原缓冲区的一段”。
std::string line = "Content-Length: 42";
auto colon = line.find(':');
std::string key_copy = line.substr(0, colon);
std::string_view key_view{line.data(), colon};
这两个变量表达完全不同的所有权。key_copy 与 line 后续变化解耦;key_view 指向 line 的内部缓冲区,line 发生重新分配或销毁后,key_view 失去有效对象来源。选择 substr 还是 string_view,应由生命周期和分配成本共同决定。
这一组操作还体现 string 的异常安全边界。标准要求 string 成员函数或操作符抛出异常时,对当前 string 对象没有其他效果。实现通常通过先分配新缓冲、完成字符复制或移动,再提交新状态来保护旧对象。读实现时应找到提交点:提交前失败,旧 data()、size()、capacity() 保持旧状态;提交后,新状态成为唯一有效状态。
下面的 Mermaid 图只描述 append 的关键状态路径,用来定位容量检查、分配和提交点。
图中的核心分支是容量是否足够。原地追加只移动末尾和终止字符;重新分配会替换内部缓冲区。外部代码关心的是提交后果:修改操作结束后,继续使用旧地址对象需要重新证明有效性。
15.4 SSO 与 copy-on-write 历史边界
小字符串优化(Small String Optimization,SSO)是常见实现策略。它把较短的字符序列直接放进 string 对象内部的固定小缓冲区;字符串超过小缓冲区容量时,再使用堆分配。SSO 的目标是减少短字符串场景的动态分配,因为配置项、字段名、文件扩展名、HTTP header key 这类字符串常常很短。
常见实现形状可以抽象成“内部小缓冲区 + 外部指针 + size + capacity”。不同标准库的小缓冲区大小、对象布局和标记方式不同,标准只承诺可观察语义,不承诺某个 sizeof(std::string) 或某个 SSO 阈值。
// 教学示意:表达常见实现形状,非真实标准库源码。
class string_like {
std::size_t size_;
std::size_t capacity_;
union {
char small_[16];
char* heap_;
} storage_;
};
SSO 会改变性能判断。短字符串构造、拷贝和移动可能只在对象内部复制十几个字节,完全没有堆分配;长字符串构造和增长可能涉及 allocator、堆缓冲和旧内容迁移。移动一个长 string 常常可以交换或接管外部指针;移动一个短 string 通常仍要复制内部字符,因为字符本来就在源对象内部。
std::string short_name = "id";
std::string long_path = "/var/log/service/2026/06/06/content-length.log";
std::string a = std::move(short_name);
std::string b = std::move(long_path);
上面代码只能推出“move 进入移动构造路径”。a 的构造很可能复制小缓冲区字符;b 的构造更可能接管外部缓冲区。源码阅读时,应先判断当前实现是否处在 small 模式,再判断 move、swap、append、reserve 的真实路径。
copy-on-write(COW)是 string 的历史实现策略:多个 string 对象共享同一段字符缓冲区,某个对象发生写入时再复制。这个策略在早期单线程、读多写少环境中有吸引力,但会让非 const 访问、引用稳定性、并发修改和可观察性能变得复杂。
std::string x = "hello";
std::string y = x;
y[0] = 'H';
在 COW 模型里,y[0] = 'H' 可能触发 detach,也就是从共享缓冲区复制出 y 的独立缓冲区。在现代标准约束下,传统可观察 COW 路径很难满足 string 对连续存储、引用和 iterator 失效规则、并发读写成本以及非 const 元素访问的组合要求。工程判断应以当前目标标准库实现为准,性能推断应建立在当前实现的所有权模型、SSO 阈值和容量增长策略上。
SSO 与 COW 的边界可以这样记:SSO 是对象内部布局优化,保留独立所有权语义;COW 是对象之间共享表示的策略,会改变写入时的可观察成本和状态路径。现代 C++ 代码通常可以假设主流 std::string 使用 SSO 加独立所有权模型,但具体小缓冲大小、增长策略和 move 后状态仍属于实现细节。
15.5 string_view 与 vector<char> 对比
std::string_view 表达只读字符串视图。它通常只保存两个值:指向字符序列的指针和长度。它不拥有字符,不负责释放存储,也不要求被观察的范围末尾存在 null terminator。它适合作为只读参数类型,用来接收 string、字符串字面量或外部缓冲区的一段窗口。
void parse_header(std::string_view line) {
auto colon = line.find(':');
if (colon == std::string_view::npos) {
return;
}
std::string_view key = line.substr(0, colon);
std::string_view value = line.substr(colon + 1);
}
std::string line = "Content-Length: 42";
parse_header(line);
parse_header("Host: example.com");
这个接口没有接管输入字符的所有权。调用方只需要保证 parse_header 执行期间,传入的字符范围保持有效。函数内部的 substr 也只是生成新的视图窗口,不会分配新 string。热路径解析、日志过滤和协议字段扫描中,这种表达比反复构造 string 更贴近实际需求。
string_view 的主要风险来自生命周期。下面的函数返回视图,但视图指向局部 string 的内部缓冲区;函数返回后,局部 string 已经销毁,返回值指向失效存储。
std::string_view bad_view() {
std::string tmp = "Content-Length";
return std::string_view{tmp};
}
安全写法需要让被观察的字符序列由调用方或更长生命周期对象持有。string_view 适合参数、局部切片和只读扫描结果;存入对象字段、缓存、异步任务或跨线程队列时,需要显式证明底层 string 的生命周期和重新分配边界。
std::string owner = "Content-Length: 42";
std::string_view view = owner;
owner.reserve(1024); // 可能替换内部缓冲区
// 此后应重新构造 view。
view = owner;
vector<char> 表达可变字节数组。它拥有连续存储,支持按字节追加、修改和传给二进制接口。它的语义不包含“文本字符串”这个约定,也不自动维护末尾 null terminator。需要和 C 字符串接口互操作时,调用方要自己追加 \0,并保证长度边界和终止边界一致。
std::vector<char> bytes{'A', '\0', 'B'};
bytes.push_back('\0');
const char* c = bytes.data();
std::size_t n = bytes.size();
std::string、std::string_view 和 std::vector<char> 可以用同一组维度比较。
| 对象 | 是否拥有存储 | 是否可变 | 是否维护 null terminator | 主要边界 |
|---|---|---|---|---|
std::string | 拥有 | 可变 | 维护 | 修改可能重新分配,旧视图和旧指针需要重取 |
std::string_view | 不拥有 | 只读视图 | 不维护 | 依赖外部生命周期和外部长度 |
std::vector<char> | 拥有 | 可变 | 不维护 | 适合字节缓冲,文本边界由调用方定义 |
选择顺序可以固定为四步。第一步,数据需要独立保存时选拥有型对象;第二步,只读传参或局部切片优先考虑 string_view;第三步,接口需要零结尾文本时使用 string 或手动维护终止符;第四步,数据是二进制字节流时优先使用 vector<char>、std::byte 缓冲或更明确的字节容器。
15.6 工程常见坑
string 相关问题通常来自四类边界:生命周期、隐式分配、编码假设和接口约定。排查时先看对象所有权,再看是否发生修改和重新分配,然后看接口使用的是 size() 还是 null terminator,最后看代码是否把 code unit 当成用户可见字符。
第一类问题是悬垂视图和悬垂指针。c_str()、data()、iterator、引用和 string_view 都指向某个 string 的当前字符缓冲区。string 销毁、移动赋值、增长、插入、读取输入流、swap 或其他修改路径都可能让旧位置对象失效。稳定写法是把这些位置对象的作用域压缩到最近一次读取,并在每次修改后重新获取。
std::string s = "abc";
std::string_view v = s;
s += "defghijklmnopqrstuvwxyz"; // 可能重新分配
v = s; // 修改后重建视图
第二类问题是隐式分配。operator+、substr、从 string_view 构造 string、从 const char* 构造 string、格式化输出和容器插入都可能触发分配。单次调用可读性很好,循环热路径里会表现为分配次数、复制字节数和缓存局部性成本。
std::string make_key(std::string_view prefix, int id) {
std::string key;
key.reserve(prefix.size() + 16);
key.append(prefix);
key.push_back('#');
key.append(std::to_string(id));
return key;
}
这段代码先 reserve,再分段追加。它把可能的多次增长压缩成更少的容量调整,也把构造结果的所有权留在返回值里。这里仍然有 std::to_string 的临时分配或内部格式化成本;如果这是极端热路径,可以进一步换成 std::to_chars 写入栈上缓冲区。
第三类问题是编码假设。std::string::size() 返回 char 个数,并不返回用户感知字符数。UTF-8 中,一个汉字通常由多个字节组成;emoji、组合音标和区域标志还会让“用户看到的一个字符”跨越多个 code point。substr(0, n) 按字节截断,可能切开 UTF-8 序列。
std::string word = "中文";
std::size_t bytes = word.size(); // 通常是 6,取决于源文件编码和执行字符集
需要按 Unicode code point、grapheme cluster、大小写折叠或本地化排序处理文本时,应使用明确的文本处理库或平台 API。string 在这里提供的是存储和字节级序列操作,编码解释属于更上层的文本模型。
第四类问题是 C 接口边界。很多 C 函数只接收 const char*,并用 \0 判断结束;现代 C++ 接口通常更适合接收 std::string_view 或 (pointer, length)。当字符串可能包含内嵌零字符时,c_str() 和 strlen 的边界会早于 size()。
void write_all(const char* data, std::size_t size);
std::string payload{'A', '\0', 'B'};
write_all(payload.data(), payload.size());
第五类问题是把 find 的结果直接参与算术。npos 是无效位置哨兵,先判断 pos != npos,再计算子串范围。把 npos + 1 当作正常下标,会因为无符号回绕得到意外结果。
std::string line = "no colon here";
auto pos = line.find(':');
if (pos != std::string::npos) {
auto value = line.substr(pos + 1);
}
最终的 string 判断顺序可以压缩成六步:确认对象是否拥有存储;确认操作是只读、尾部增长、中间修改还是构造新对象;确认 size()、capacity() 和 null terminator 的状态变化;确认旧指针、旧引用、旧 iterator 和旧视图是否要重取;确认接口使用显式长度还是零结尾;确认文本处理是否需要编码层支持。按这个顺序读源码,string 就不再只是一个便利 API,而是一个带连续存储、对象所有权、容量策略和接口边界的容器。
最小自检任务
阅读下面代码,判断 key、value_copy、value_view、raw 在 line.append("; charset=utf-8") 之后分别是否仍然表达稳定结果,并说明每个判断依赖哪条规则。
std::string line = "Content-Type: text/html";
std::size_t colon = line.find(':');
std::string key = line.substr(0, colon);
std::string value_copy = line.substr(colon + 2);
std::string_view value_view{line.data() + colon + 2, line.size() - colon - 2};
const char* raw = line.c_str();
line.append("; charset=utf-8");
答案要点
key 和 value_copy 是独立 string,它们由 substr 构造并拥有自己的字符序列,后续修改 line 不会改变这两个对象保存的内容。它们的代价是可能分配和复制。
value_view 是视图,指向 line 修改前的内部缓冲区。append 可能触发重新分配,也会改变 line 的长度和终止位置,因此修改之后应重新构造视图。即使某次运行中容量足够,工程判断也应按修改后重取处理。
raw 是 line.c_str() 返回的旧指针。append 属于修改长度的操作,可能使旧指针失效。需要传给 C 接口时,应在最后一次修改之后重新调用 line.c_str(),并确认接口使用 null terminator 还是显式长度。
本题的判断顺序是:先区分拥有型对象和非拥有视图,再识别 append 的容量变化风险,然后处理旧位置对象失效,最后确认 C 字符串接口边界。
本章知识点总结
- 模板角色:
basic_string由CharT、Traits和Allocator三类参数共同决定字符类型、字符操作和动态存储来源。 - 字符单元:
std::string管理char序列,size()统计 code unit 数量,不承担 Unicode 字符边界解释。 - traits 作用:
char_traits为比较、查找、长度计算、复制和移动提供字符级操作策略。 - allocator 边界:allocator 参与 string 的动态缓冲区分配与释放,不改变 string 的字符序列语义。
- 连续存储:现代 string 的有效字符处在
[data(), data() + size()),适合数组式访问和显式长度接口。 - 终止字符:
data() + size()指向由 string 维护的 null terminator,终止字符不计入size()。 - 容量判断:
capacity()表示无需重新分配即可容纳的有效字符数量,增长超过容量会替换内部缓冲区。 - 位置失效:修改长度或容量后,旧指针、旧引用、旧 iterator 和旧
string_view应重新取得。 - 操作路径:
append尾部增长,insert移动后缀,erase前移后缀,find返回下标,substr构造新 string。 - SSO 边界:SSO 是常见对象内部小缓冲优化,具体阈值和布局属于实现细节。
- COW 历史:传统 copy-on-write string 属于历史实现策略,现代性能判断应以当前标准库的独立所有权和 SSO 路径为准。
- 视图语义:
string_view只保存指针和长度,不拥有字符,也不维护 null terminator。 - 字节容器:
vector<char>适合表达可变字节缓冲,文本终止和编码含义由调用方定义。 - 接口边界:C 接口可能使用 null terminator,现代 C++ 解析接口更适合显式长度或
string_view。 - 排查顺序:先看所有权,再看修改路径,再看位置对象有效性,再看长度边界和编码假设。