1. 为什么 string 值得单独拎出来讲
C++ 里没有哪个类型比std::string更“日用而不知”了。写业务代码的人天天用它拼日志、传参数、做解析;写底层的人又嫌它重、嫌它慢、嫌它和const char*之间来回转换烦。但真要把 string 用明白,其实能避开不少性能坑和逻辑坑。
这篇东西不是标准文档的复述,而是我这些年写 C++ 项目时对 string 的用法梳理。从最基本的构造、容量管理,到 C++17 的string_view、C++20 的三路比较和starts_with/ends_with,再到实际项目里踩过的坑,都会覆盖到。适合已经会写 C++、但想把 string 用得更稳更快的朋友,也适合刚接触现代 C++ 标准、想看看新特性到底能省多少事的人。
核心关键词就几个:构造与容量、SSO 短字符串优化、string_view、C++20 新特性、性能避坑。下面按这个脉络展开。
2. string 的底层设计与容量管理
2.1 从 SSO 说起:为什么小字符串不分配堆内存
很多人以为std::string内部就是一个char*加一个长度,其实主流实现(libstdc++、libc++、MSVC STL)都用了SSO(Short String Optimization,短字符串优化)。意思是:当字符串足够短时,字符直接存在对象内部的缓冲区里,不碰堆。
以 64 位 libstdc++ 为例,sizeof(std::string)是 32 字节,内部大致是:
- 一个指向数据的指针(8 字节)
- 长度 size(8 字节)
- 容量 capacity 或内部缓冲区(16 字节)
当字符串长度不超过 15 字节时,数据就放在那 16 字节的缓冲区里,data()返回的是对象内部地址。超过 15 字节才去堆上分配。这个阈值不同实现不一样,MSVC 一般是 15,libc++ 是 22。
为什么要在意这个?因为短字符串的构造、拷贝、析构几乎零成本,而长字符串每次拷贝都要 malloc/free。你在循环里频繁构造短字符串,性能其实没你想的那么差;但如果是长字符串,就要警惕了。
std::string a = "hello"; // SSO,无堆分配 std::string b = "this is a long string over 15 bytes"; // 堆分配提示:不要假设 SSO 阈值一定是 15,跨平台代码里不要依赖这个数字,只把它当成“短字符串通常更快”的直觉即可。
2.2 size、capacity、reserve 的关系
size()是当前字符数,capacity()是不重新分配内存能容纳的最大字符数(不含结尾的\0)。当你 push_back 超过 capacity 时,string 会按一定倍数扩容(libstdc++ 通常是 2 倍),然后把所有字符搬到新内存,旧内存释放。
这个搬移是有成本的。如果你提前知道大概要放多少字符,用reserve()一次性把容量要够:
std::string s; s.reserve(1024); for (int i = 0; i < 1000; ++i) { s.push_back('a'); // 不会触发扩容 }实测下来,在拼接大量字符串的场景,提前 reserve 能省掉多次 realloc 和拷贝,性能提升非常明显。我做过一个日志拼接的测试,10 万次 append,不 reserve 大概 8ms,reserve 之后降到 3ms 左右,差距主要就在扩容搬移上。
但 reserve 也不是越大越好。你 reserve 了 1MB 却只用了 10 字节,那这 1MB 内存就一直占着,直到 string 析构。所以reserve 要基于合理预估,别拍脑袋给个大数。
2.3 shrink_to_fit 到底该不该用
shrink_to_fit()请求把 capacity 降到 size。注意是“请求”,标准不保证一定生效。它的典型用途是:你曾经放了很多数据,后来 erase 掉大部分,想把这部分内存还给系统。
std::string s(10000, 'x'); s.resize(10); s.shrink_to_fit(); // 尝试释放多余容量但我要泼盆冷水:大多数情况下不要随便调 shrink_to_fit。因为它可能触发一次重新分配加拷贝,而且如果之后你又往里加数据,还得再扩容。除非你确定这个 string 会长期保持小尺寸且内存紧张,否则让它保持大容量反而更划算。这是典型的“空间换时间”取舍。
3. 构造、赋值与元素访问的实操细节
3.1 各种构造方式的适用场景
string 的构造函数很多,常用的有这几类:
std::string s1; // 空串 std::string s2 = "hello"; // 从字面量 std::string s3(5, 'a'); // "aaaaa" std::string s4(s2); // 拷贝构造 std::string s5(s2, 1, 3); // 从 s2 下标 1 开始取 3 个:"ell" std::string s6(s2.begin(), s2.begin()+3); // 迭代器区间:"hel"这里有个容易踩的坑:std::string s5(s2, 1, 3)的第二个参数是起始位置,第三个是长度,不是结束位置。很多人第一次写会以为是区间,结果取错。如果起始位置超过 size,会抛std::out_of_range。
还有一个经典陷阱:
std::string s(10, 'a'); // 10 个 'a' std::string t(10, 97); // 也是 10 个 'a',97 是 'a' 的 ASCII std::string u(10, "a"); // 编译错误或未定义行为,别这么写std::string(10, "a")这种写法在某些编译器上会走const char*的构造,行为不可预期,一定要避免。
3.2 at 和 operator[] 的区别
访问单个字符有两个方式:operator[]和at()。
operator[]不做边界检查,越界是未定义行为,可能读到垃圾数据,也可能直接崩。at()做边界检查,越界抛std::out_of_range。
std::string s = "hello"; char c1 = s[10]; // 未定义行为,危险 char c2 = s.at(10); // 抛异常,安全性能上operator[]略快,因为省了一次检查。但在大多数业务代码里,这点差异可以忽略。我的建议是:在不确定下标是否合法时用 at(),在循环里已经确认范围时用 operator[]。比如遍历时:
for (size_t i = 0; i < s.size(); ++i) { process(s[i]); // 范围已确认,用 [] 没问题 }注意:
s.size()返回的是无符号类型,和int比较时会有符号转换问题。循环变量最好用size_t或std::string::size_type,避免i < s.size()里 i 是负数时的诡异行为。
3.3 front、back 与 data 的正确用法
C++11 之后有了front()和back(),分别取首尾字符。空串调用它们是未定义行为,用之前一定要判空。
data()在 C++11 到 C++14 返回const char*,C++17 起有了非 const 重载,返回char*,可以直接改内容。但要注意,data()返回的指针在 string 发生扩容、修改后可能失效。
std::string s = "hello"; const char* p = s.data(); s += " world"; // 可能触发扩容 // p 现在可能悬空,不要再用这个坑在把data()或c_str()的指针存起来、后面又改 string 的场景里特别常见。只要 string 可能被修改,就不要长期持有它的内部指针。
4. 修改操作:append、insert、erase、replace
4.1 拼接字符串的几种方式对比
拼接是最常用的操作,方式有好几种:
std::string s = "hello"; s += " world"; // operator+= s.append("!!!"); // append s.push_back('!'); // 单字符 s = s + "abc"; // operator+,产生临时对象+=和append是原地修改,效率高。operator+会创建一个新 string,把两边都拷进去,再赋值回来,多了一次分配和拷贝。在循环里用s = s + x是典型的性能杀手:
// 差:每次循环都可能重新分配 for (auto& part : parts) { result = result + part; } // 好:原地追加 for (auto& part : parts) { result += part; }如果拼接的片段很多,还可以用reserve预估总长度,进一步减少扩容。
4.2 insert 和 erase 的下标语义
insert(pos, str)在下标 pos 处插入,erase(pos, len)从 pos 开始删 len 个字符。这两个操作都可能引起后续字符的移动,复杂度是 O(n)。
std::string s = "hello"; s.insert(2, "XX"); // "heXXllo" s.erase(2, 2); // "hello"erase还有一个重载接受迭代器,配合std::remove_if可以删除满足条件的字符:
std::string s = "a1b2c3"; s.erase(std::remove_if(s.begin(), s.end(), [](char c){ return std::isdigit(c); }), s.end()); // s == "abc"这是删除字符的惯用法,remove_if把要保留的移到前面,返回新的逻辑结尾,erase再把尾部删掉。记住这个组合,比手写循环删字符安全得多。
4.3 replace 的灵活用法
replace(pos, len, newstr)把从 pos 开始的 len 个字符替换成 newstr。长度可以不一样,string 会自动调整。
std::string s = "I like cats"; s.replace(7, 4, "dogs"); // "I like dogs"replace 也支持迭代器版本和 C 字符串版本。做文本替换时,如果替换后的长度和原来不同,后续的下标会变,循环替换要小心。一个常见做法是从后往前替换,或者每次替换后重新计算位置。
实操心得:做批量替换时,我一般先收集所有匹配位置,再从后往前替换,这样前面的下标不受影响,省去反复调整的麻烦。
5. 查找与子串:find 家族和 substr
5.1 find、rfind、find_first_of 的区别
查找家族成员不少,容易混:
| 函数 | 作用 |
|---|---|
find | 从左往右找子串或字符,返回首次出现位置 |
rfind | 从右往左找,返回最后一次出现位置 |
find_first_of | 找任意一个给定字符首次出现的位置 |
find_last_of | 找任意一个给定字符最后出现的位置 |
find_first_not_of | 找第一个不在给定字符集里的字符 |
std::string s = "hello world"; s.find("world"); // 6 s.rfind('l'); // 9 s.find_first_of("aeiou"); // 1('e') s.find_first_not_of("helo "); // 6('w')这些函数找不到时返回std::string::npos,它是一个很大的无符号数。判断时要用== npos或!= npos,不要和 -1 比较(虽然 npos 通常就是 -1 转换来的,但直接写 -1 不严谨)。
if (s.find("xyz") == std::string::npos) { // 没找到 }5.2 substr 的开销与陷阱
substr(pos, len)返回一个新的 string,内容是原串从 pos 开始的 len 个字符。注意它返回的是拷贝,不是视图。如果原串很大,你只取一小段,也会产生一次分配和拷贝。
std::string s = "a very long string ..."; std::string sub = s.substr(2, 5); // 拷贝出 5 个字符如果只是临时查看,不需要修改,用std::string_view更合适(后面会讲)。另外,substr的 pos 如果超过 size,会抛std::out_of_range。len 超过剩余长度时,会取到末尾,不报错。
std::string s = "hello"; s.substr(3, 100); // "lo",len 超了不报错 s.substr(10); // 抛 out_of_range注意:
substr不带 len 参数时,默认取到末尾。这个默认行为很多人不知道,写s.substr(pos)就是取 pos 之后的所有字符。
6. C++17 string_view:只读视图的正确打开方式
6.1 string_view 解决什么问题
std::string_view是 C++17 引入的,本质是“一个指针加一个长度”,指向别人的字符数据,自己不拥有内存。它解决的核心问题是:函数参数如果只需要读字符串,用 string_view 可以避免不必要的拷贝和构造。
以前写一个打印函数:
void print(const std::string& s); // 传 const char* 会构造临时 string print("hello"); // 隐式构造 std::string,有分配用 string_view:
void print(std::string_view sv); print("hello"); // 无分配,直接指向字面量对于const char*、std::string、字符数组,string_view 都能零成本接住。这在解析、日志、配置读取这类大量传字符串的场景里,能省下可观的分配开销。
6.2 string_view 的生命周期陷阱
string_view 不拥有数据,所以它指向的原始数据必须活得比它久。这是最大的坑:
std::string_view sv; { std::string s = "temporary"; sv = s; // sv 指向 s 的数据 } // s 析构,sv 悬空 std::cout << sv; // 未定义行为还有一种更隐蔽的:
std::string_view sv = std::string("temp") + "orary"; // 临时 string 在表达式结束后析构,sv 悬空所以用 string_view 的铁律是:不要从临时 string 构造 string_view,不要把 string_view 存到比源数据更长的生命周期里。它适合做函数参数、局部变量,不适合做成员变量长期持有(除非你能保证源数据生命周期)。
6.3 string_view 与 string 的互操作
string_view 可以隐式转成 string(会拷贝),但 string 转 string_view 需要显式或用构造函数:
std::string s = "hello"; std::string_view sv = s; // 隐式,OK std::string s2(sv); // 显式构造,拷贝 std::string s3 = std::string(sv); // 同上注意 string_view 没有c_str(),因为它不保证以\0结尾。如果要传给需要 C 字符串的接口,得先转成 string:
std::string_view sv = "hello"; some_c_api(std::string(sv).c_str());这个转换有拷贝成本,但没办法,C 接口就认const char*。
7. C++20 新特性:三路比较与 starts_with/ends_with
7.1 starts_with 和 ends_with:终于不用手写前缀判断了
C++20 之前,判断前缀要么用compare,要么用find:
// C++17 及以前 if (s.compare(0, 5, "hello") == 0) { ... } if (s.find("hello") == 0) { ... }C++20 直接给了starts_with和ends_with:
std::string s = "hello world"; if (s.starts_with("hello")) { ... } // true if (s.ends_with("world")) { ... } // true if (s.starts_with('h')) { ... } // 也支持单字符这两个函数也支持 string_view 参数,配合起来非常顺手。可读性比 compare 好太多,代码意图一眼就清楚。实测在 GCC 10+、Clang 10+、MSVC 19.27+ 上都可用,用之前确认下编译器版本和-std=c++20。
7.2 三路比较运算符与 string 排序
C++20 给 string 加了<=>(三路比较运算符),返回std::strong_ordering。它让 string 的比较更统一,也自动生成了==、!=、<、>等。
std::string a = "apple", b = "banana"; auto r = a <=> b; if (r < 0) { /* a < b */ }实际写业务时,你很少直接调<=>,但它的好处是让 string 在容器里排序、在std::set里做键都更自然。而且 C++20 之后,string 和 string_view 之间的比较也补齐了,不用再手动转换。
std::string s = "hello"; std::string_view sv = "hello"; if (s == sv) { ... } // C++20 起可以直接比7.3 contains:查找子串的新写法
C++23 给 string 和 string_view 加了contains,判断是否包含子串或字符:
std::string s = "hello world"; if (s.contains("world")) { ... } // true if (s.contains('w')) { ... } // true在 C++23 之前,只能写s.find("world") != std::string::npos。contains语义更清晰,代码更短。如果你的项目还没上 C++23,可以自己封装一个:
bool contains(const std::string& s, std::string_view sub) { return s.find(sub) != std::string::npos; }8. 性能避坑与常见问题排查
8.1 常见性能陷阱速查表
| 陷阱 | 问题 | 正确做法 |
|---|---|---|
循环里s = s + x | 每次重新分配拷贝 | 用s += x或先 reserve |
| 函数参数用值传递 string | 每次调用都拷贝 | 用const std::string&或 string_view |
| 返回大 string 的局部变量 | 可能触发拷贝 | 依赖 RVO/NRVO,或返回后 move |
| 频繁 substr | 每次拷贝 | 只读场景用 string_view |
| 长期持有 c_str/data 指针 | 扩容后悬空 | 需要时重新获取 |
用int和size()比较 | 符号转换问题 | 用size_t或auto |
8.2 传参方式的选择
函数参数怎么传 string,是个高频问题。我的经验规则:
- 只读,且不介意临时构造:
const std::string&,兼容性最好。 - 只读,且想避免构造:
std::string_view,但要注意生命周期。 - 需要修改副本:按值传递
std::string,配合 move。 - 需要修改原串:传引用
std::string&。
void read_only(const std::string& s); // 通用 void read_only_sv(std::string_view sv); // 零拷贝,注意生命周期 void modify_copy(std::string s); // 内部可 move void modify_ref(std::string& s); // 改原串实操心得:在接口边界(比如对外 API)我倾向用
const std::string&,稳妥;在内部高频调用的工具函数里用 string_view,省开销。两者混用时注意别把临时 string 传给 string_view 参数。
8.3 编码与多字节字符的坑
std::string存的是字节,不是字符。对 UTF-8 字符串调size()得到的是字节数,不是字符数。substr按字节切,可能把一个多字节字符切成两半,产生乱码。
std::string s = "你好"; // UTF-8 下通常是 6 字节 s.size(); // 6,不是 2 s.substr(0, 1); // 半个字符,乱码处理多字节文本时,要么用专门的 Unicode 库,要么自己按 UTF-8 规则解析。不要用 string 的下标直接切多字节字符,这是新手最容易犯的错之一。
8.4 常见问题排查清单
遇到 string 相关的诡异问题,按这个顺序查:
- 越界访问:是不是用了
operator[]且下标超了?换成at()试试会不会抛异常。 - 悬空指针:是不是存了
c_str()/data()/string_view 的指针,之后又改了原串? - 乱码:是不是按字节切了多字节字符?确认编码。
- 性能差:是不是循环里
s = s + x?是不是没 reserve? - 比较结果不对:是不是拿
npos和 -1 比了?是不是大小写敏感没处理? - 编译错误:是不是用了 C++20 特性但没开
-std=c++20?是不是 string_view 和 string 混用没转换?
9. 我个人的几条使用习惯
写了这么多年 C++,关于 string 我形成了几个固定习惯,分享出来供参考。
第一,能用 string_view 做只读参数就用,但绝不让它活过源数据。第二,拼接前先 reserve,尤其是循环里,这个习惯帮我省过很多性能问题。第三,删除字符用 remove_if + erase 组合,不手写循环。第四,判断前缀后缀优先用 C++20 的 starts_with/ends_with,可读性提升明显。第五,多字节文本不碰下标切分,交给专门的库处理。
还有一个小技巧:如果你经常需要把 string 转成小写做比较,可以封装一个to_lower函数,内部用std::transform加std::tolower,比每次手写循环干净:
std::string to_lower(std::string s) { std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c){ return std::tolower(c); }); return s; }注意 lambda 参数用unsigned char,直接传char给std::tolower在字符为负时会出问题,这是个很隐蔽的坑。
后续如果项目上了 C++23,contains和std::string::resize_and_overwrite也值得关注,后者能在填充字符串时避免先初始化再覆盖的浪费。这些新特性用起来,string 的代码会越来越简洁,但底层的容量、生命周期、编码这些基本功,还是得扎实。