☰
C++之拼接长字符串问题
2026/10/8 8:40:56 网站建设 项目流程

前言

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());临时对象在整条语句结束时销毁
混淆 reserves.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 阈值和增长因子,它们是实现细节,写代码时不该依赖,但理解它们能解释你观察到的现象。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询