前言
std::string的拼接大概是 C++ 里最早学会、也最容易被写坏的一件小事。拼十来个短片段时怎么写都对;可一旦进入"把几万段日志、几十万个字段拼成一个大字符串"的场景,不同写法之间的差距就会从"风格问题"变成"能不能跑完的问题"。
最常见的误解是把s = s + part和s += part当成同义词。两者语义上确实等价,代价上却不等价:前者每一轮都要构造一个临时对象(很可能伴随一次堆分配),再把结果赋回去;后者直接在已有缓冲区尾部追加,只在容量不够时才扩容。
第二个误解是关于reserve。很多人知道"预留容量能提速",却以为reserve(n)之后字符串长度就变成 n 了。实际上reserve只动capacity(),不动size();改长度的是resize。
本文以 C++17 为基准(GCC 13 / Clang 17 / MSVC 19.3x 均可编译),讲清std::string的存储与扩容、几种拼接手段的适用面,以及拼长字符串时真正会咬人的坑。
一、存储与扩容:为什么"长"才是分水岭
std::string是std::basic_string<char>的别名。标准只规定它连续存储,并提供size()、capacity()、data()等接口,并没有规定内部结构。实际实现分成两条路:
- 短字符串优化(SSO, Small String Optimization):长度不超过某个阈值的字符串直接存在对象自身的内嵌缓冲里,完全不碰堆。
- 堆存储:超过阈值时在堆上分配
capacity() + 1个字节(最后一位留给结尾的'\0')。
这个阈值是实现定义的,不能写进业务代码的if判断。三家的典型值如下(不同版本可能微调,以你手上的头文件为准):
| 实现 | 64 位下sizeof(std::string) | SSO 可容纳字符数 |
|---|---|---|
| libstdc++(GCC 13) | 32 字节 | 15 |
| libc++(Clang 17) | 24 字节 | 22 |
| MSVC STL(19.3x) | 32 字节 | 15 |
扩容因子同样是实现定义的:libstdc++ 的basic_string::_M_create走的是"至少翻倍"策略,MSVC STL 走的是约 1.5 倍增长。两者都是均摊意义上的线性代价,但常数不同。所以不要写"std::string一定是两倍扩容"这种断言。
把上面两点拼起来,就能解释为什么长串拼接会被写坏。设每轮追加的内容长度为 m,当前长度为 n:
| 写法 | 每轮发生了什么 |
|---|---|
s = s + part | 分配临时的 n+m 字节 → 拷贝 n → 拷贝 m → 把临时赋给s(可能再分配一次)→ 释放临时 |
s += part | 容量够就只拷贝 m;不够才重新分配并按增长因子放大 |
在 n 已经很大的时候,前者每轮都要整体搬一遍已有内容,总量是平方级的;后者靠增长因子把总拷贝量压到线性。这就是全部道理,不需要任何基准测试数字来佐证——读者可以自己按第四节的计时骨架在自己的机器上跑。
二、拼接手段与它们的适用面
标准库提供的拼接入口主要有这几个,签名全部来自<string>:
basic_string& operator+=(const basic_string&)、operator+=(charT)、operator+=(const charT*)、operator+=(std::initializer_list<charT>)basic_string& append(const basic_string&)、append(const charT* s)、append(const charT* s, size_type n)、append(size_type n, charT c)- 非成员的
operator+,返回新的basic_string(总是产生拷贝) std::ostringstream,配合<<做混合格式化
| 手段 | 分配行为 | 适用场景 |
|---|---|---|
s += part | 原地追加,容量不足才扩容 | 循环拼接的默认选择 |
s = s + part | 每轮至少一次临时分配 | 单次拼接、可读性优先 |
s.append(p, n) | 同+=,但能指定长度 | 追加定长缓冲、含'\0'的数据 |
operator+链式 | 每层一个临时 | 少量短串,一次性表达式 |
std::ostringstream | 内部缓冲自行增长,str()再拷一次 | 拼接中夹杂数字格式化 |
reserve+ 循环 | 全程零扩容(若估算准确) | 已知总长度的大规模拼接 |
有一条容易忽略的差别:std::ostringstream::str()在 C++17 里返回的是拷贝,也就是说结果字符串被完整复制了一遍。C++20 才给basic_stringbuf和basic_ostringstream加上右值重载的str() &&,可以移动出来。所以如果目标是极致开销,reserve++=仍然更直接。
三、实战:一次 reserve 拼完整段
下面这段代码是完整的、可直接编译的。核心思路是先把总长度算准,一次分配到位,后续追加不再触发任何扩容。
// C++17, g++ -std=c++17 / clang++ -std=c++17 / cl /std:c++17 #include <iostream> #include <sstream> #include <string> #include <vector> // 方案 A:先算总长,一次 reserve,再逐个追加 std::string join_fast(const std::vector<std::string>& parts, const std::string& sep) { std::size_t total = 0; for (const std::string& p : parts) { total += p.size(); } if (parts.size() > 1) { // parts 为空时 size()-1 会回绕成极大值,必须先判断 total += sep.size() * (parts.size() - 1); } std::string out; out.reserve(total); // 只改 capacity,size 仍然是 0 for (std::size_t i = 0; i < parts.size(); ++i) { if (i != 0) { out += sep; } out += parts[i]; } return out; // 返回时容量恰好够,不会再分配 } // 方案 B:ostringstream,适合"拼接 + 数字格式化"混合场景 std::string build_report(const std::vector<std::string>& lines, int code) { std::ostringstream oss; oss << "code=" << code << '\n'; for (const std::string& line : lines) { oss << line << '\n'; } return oss.str(); // C++17 下这次返回是拷贝 } int main() { std::vector<std::string> parts; parts.reserve(1000); for (int i = 0; i < 1000; ++i) { // const char* 在左、std::string 在右,走的是非成员 operator+ parts.push_back("item-" + std::to_string(i)); } const std::string joined = join_fast(parts, ","); std::cout << "size = " << joined.size() << '\n'; std::cout << "head = " << joined.substr(0, 40) << '\n'; const std::string report = build_report({"alpha", "beta"}, 200); std::cout << report; return 0; }join_fast返回时out.size()恰好等于total,所以这次返回是移动(std::string的移动构造是noexcept的),不会再有拷贝。
要把"哪种写法更快"变成自己的结论,用下面这个骨架自己跑,不要相信任何人给的数字:
// 计时骨架:同一台机器、同一份数据,交替跑多轮再对比 #include <chrono> #include <iostream> template <class F> long long time_us(F f) { const auto t0 = std::chrono::steady_clock::now(); f(); const auto t1 = std::chrono::steady_clock::now(); return std::chrono::duration_cast<std::chrono::microseconds>(t1 - t0).count(); }四、C++17 的 string_view:只能看,不能拼
std::string_view(C++17,头文件<string_view>)是一个非拥有的字符视图,本质是"指针 + 长度"。它最大的价值是让只读函数参数摆脱临时std::string的构造,但它不拥有内存,所以不能用来拼长字符串——拼接结果总得落到某个真正持有内存的容器上。
更要命的是它极易悬垂:视图本身不会延长被观察对象的生命周期。
#include <string> #include <string_view> // ❌ 返回即悬垂:local 在函数返回时就销毁了 std::string_view dangling_view() { std::string local = "temporary"; return std::string_view(local); } // ✅ 让 string_view 只做参数,不越过生命周期边界 std::size_t count_char(std::string_view sv, char c) { std::size_t n = 0; for (char ch : sv) { if (ch == c) { ++n; } } return n; }顺带一提,C++17 起非 const 的std::string::data()返回char*(C++11 到 C++14 只有返回const char*的重载),这给了直接写入底层缓冲的途径;&s[0]也可以,但要求s非空。
常见坑点
| 场景 | ❌ 错误写法 | ✅ 正确写法 | 说明 |
|---|---|---|---|
| 循环拼接 | for (auto& p : v) s = s + p; | for (auto& p : v) s += p; | 前者每轮一个临时,长循环下是平方级 |
| 字面量相加 | std::string s = "abc" + "def"; | std::string s = std::string("abc") + "def"; | 两个const char*相加是指针算术,越界访问是 UB |
缓存c_str() | const char* p = s.c_str(); s += "x"; use(p); | 每次使用前重新取s.c_str() | 任何可能改容量的操作都会让p悬垂 |
| 绑定临时 | const char* p = (a + b).c_str(); | const std::string t = a + b; use(t.c_str()); | 临时对象在整条语句结束时销毁 |
| 混淆 reserve | s.reserve(100); assert(s.size() == 100); | 用s.resize(100)改长度 | reserve只改容量,size()不变 |
| 空容器求分隔符 | sep.size() * (v.size() - 1) | 先判断v.size() > 1 | 无符号回绕会得到天文数字的预留量 |
ostringstream复用 | 每轮迭代都新建一个ostringstream | 循环外建一个,用oss.str("")清空复用 | 构造流对象本身也有开销 |
| 多线程共享拼接 | 多线程对同一个std::string做+= | 各线程拼各自的串,最后单线程合并 | 数据竞争是 UB,标准不保证任何行为 |
oss.str("")是basic_ostringstream的成员函数,用来同时重置缓冲内容;注意它只清内容,不清错误状态和格式标志,要彻底复位得配合oss.clear()。
总结
| 场景 | 推荐做法 |
|---|---|
| 已知总长度的大规模拼接 | reserve一次到位,然后循环+= |
| 长度不好估算 | 直接循环+=,交给增长因子处理 |
| 拼接中夹杂数字/浮点格式化 | std::ostringstream,循环外建一个 |
| 只读传参、避免临时串 | std::string_view参数,但绝不返回局部视图 |
| 少量短片段 | operator+链式,可读性优先 |
拼长字符串的核心只有两句话:让追加留在同一个缓冲区里(用+=而不是=加号),并且尽量让扩容次数变成零(用reserve)。至于 SSO 阈值和增长因子,它们是实现细节,写代码时不该依赖,但理解它们能解释你观察到的现象。