ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++之拼接长字符串问题

C++之拼接长字符串问题 前言std::string的拼接大概是 C 里最早学会、也最容易被写坏的一件小事。拼十来个短片段时怎么写都对可一旦进入把几万段日志、几十万个字段拼成一个大字符串的场景不同写法之间的差距就会从风格问题变成能不能跑完的问题。最常见的误解是把s s part和s part当成同义词。两者语义上确实等价代价上却不等价前者每一轮都要构造一个临时对象很可能伴随一次堆分配再把结果赋回去后者直接在已有缓冲区尾部追加只在容量不够时才扩容。第二个误解是关于reserve。很多人知道预留容量能提速却以为reserve(n)之后字符串长度就变成 n 了。实际上reserve只动capacity()不动size()改长度的是resize。本文以 C17 为基准GCC 13 / Clang 17 / MSVC 19.3x 均可编译讲清std::string的存储与扩容、几种拼接手段的适用面以及拼长字符串时真正会咬人的坑。一、存储与扩容为什么长才是分水岭std::string是std::basic_stringchar的别名。标准只规定它连续存储并提供size()、capacity()、data()等接口并没有规定内部结构。实际实现分成两条路短字符串优化SSO, Small String Optimization长度不超过某个阈值的字符串直接存在对象自身的内嵌缓冲里完全不碰堆。堆存储超过阈值时在堆上分配capacity() 1个字节最后一位留给结尾的\0。这个阈值是实现定义的不能写进业务代码的if判断。三家的典型值如下不同版本可能微调以你手上的头文件为准实现64 位下sizeof(std::string)SSO 可容纳字符数libstdcGCC 1332 字节15libcClang 1724 字节22MSVC STL19.3x32 字节15扩容因子同样是实现定义的libstdc 的basic_string::_M_create走的是至少翻倍策略MSVC STL 走的是约 1.5 倍增长。两者都是均摊意义上的线性代价但常数不同。所以不要写std::string一定是两倍扩容这种断言。把上面两点拼起来就能解释为什么长串拼接会被写坏。设每轮追加的内容长度为 m当前长度为 n写法每轮发生了什么s s part分配临时的 nm 字节 → 拷贝 n → 拷贝 m → 把临时赋给s可能再分配一次→ 释放临时s part容量够就只拷贝 m不够才重新分配并按增长因子放大在 n 已经很大的时候前者每轮都要整体搬一遍已有内容总量是平方级的后者靠增长因子把总拷贝量压到线性。这就是全部道理不需要任何基准测试数字来佐证——读者可以自己按第四节的计时骨架在自己的机器上跑。二、拼接手段与它们的适用面标准库提供的拼接入口主要有这几个签名全部来自stringbasic_string operator(const basic_string)、operator(charT)、operator(const charT*)、operator(std::initializer_listcharT)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()在 C17 里返回的是拷贝也就是说结果字符串被完整复制了一遍。C20 才给basic_stringbuf和basic_ostringstream加上右值重载的str() 可以移动出来。所以如果目标是极致开销reserve仍然更直接。三、实战一次 reserve 拼完整段下面这段代码是完整的、可直接编译的。核心思路是先把总长度算准一次分配到位后续追加不再触发任何扩容。// C17, g -stdc17 / clang -stdc17 / cl /std:c17 #include iostream #include sstream #include string #include vector // 方案 A先算总长一次 reserve再逐个追加 std::string join_fast(const std::vectorstd::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); // 只改 capacitysize 仍然是 0 for (std::size_t i 0; i parts.size(); i) { if (i ! 0) { out sep; } out parts[i]; } return out; // 返回时容量恰好够不会再分配 } // 方案 Bostringstream适合拼接 数字格式化混合场景 std::string build_report(const std::vectorstd::string lines, int code) { std::ostringstream oss; oss code code \n; for (const std::string line : lines) { oss line \n; } return oss.str(); // C17 下这次返回是拷贝 } int main() { std::vectorstd::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_caststd::chrono::microseconds(t1 - t0).count(); }四、C17 的 string_view只能看不能拼std::string_viewC17头文件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; }顺带一提C17 起非 const 的std::string::data()返回char*C11 到 C14 只有返回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 阈值和增长因子它们是实现细节写代码时不该依赖但理解它们能解释你观察到的现象。
返回列表