
写 C 写了几年你会发现最常打交道的不是花哨的模板技巧而是看起来平平无奇的 std::string。代码里到处是它拼参数、解析文本、拼路径、做协议……但很多人对 string 的了解停在比 char* 好使一点真遇到深拷贝、查找边界、和 C 接口互操作时就开始踩坑。这篇文章把 std::string 的简单使用从头到尾过一遍——构造、遍历、拼接、查找、子串、数字互转、缓冲区写入、容量管理每个都讲清楚什么时候用、为什么这么用、边界在哪。新手可以照着代码敲一遍建立手感写 C 字符串出身的老手也可以查漏补缺把二十来个高频接口用扎实。1. 从 C 字符串手里接班std::string 解决了什么1.1 C 风格字符串的经典翻车现场要理解 std::string 的价值先回顾一下 C 风格字符串的日子。假如你要把一段用户信息和一段提示拼在一起输出char buf[64]; const char* user visitor_01; strcpy(buf, user); strcat(buf, is learning C); printf(%s\n, buf);这段代码现在能跑但问题全藏在看不见的地方。buf 只有 64 字节strcpy 和 strcat 根本不知道目标缓冲区还剩多少空间。哪天 user 变长一点、拼接内容再多一点缓冲区就溢出了表现可能是乱码、崩溃也可能是在完全无关的代码里爆出一个莫名其妙的逻辑错误。更烦人的是语义问题strcmp 返回 0 表示相等返回负数表示前者小于后者这跟人的直觉完全相反复制字符串要 strcpy、求长度要 strlen、比大小要写循环而且每一步都得小心翼翼地保证 null 终结符存在、指针不越界。用 C 字符串写业务逻辑一半的精力都花在和这些边界较劲上。1.2 std::string 替你管好了三件事std::string 的核心价值可以压缩成一句话把内存管理和字符串语义都封装好让你专注内容本身。第一是内存管理。string 内部维护一块动态缓冲区长度不够自动扩容析构时自动释放不需要手动 new/delete也不需要先猜一个足够大的数组。第二是值语义。std::string s1 s2;就是复制一份内容改 s1 不影响 s2直觉上和 int 赋值没有区别。第三是接口丰富查找、替换、比较、数字转换都有现成函数不用每次自己造轮子。理解之后你会发现大部分情况下只需要记住一点std::string 是值类型。它像 int 一样可以被拷贝、被返回、被放进容器代价就是拷贝要花钱。后面关于性能的讨论几乎都围绕这句话展开。2. 构造与初始化第一关最容易踩的坑2.1 常用构造方式逐个过string 的初始化方式非常多工作中用得上的主要就下面几种我按使用频率排了一下写法结果说明std::string s;空串size() 0std::string s(hello);hello从 const char* 构造std::string s(5, x);xxxxxcount 单字符std::string s(hello, 3);hel取前 3 个字符std::string s(other);other 的副本拷贝构造std::string s(begin, end);迭代器区间内容通用性最强表格第四行特别容易看走眼std::string s(hello, 3);生成的是hel也就是前 3 个字符而不是从下标 3 开始。因为第二个参数是字符数count不是起始位置。想要下标 3 起写法是std::string s(hello 3, 2);得到lo。这类细节基础不牢时最容易栽。迭代器构造也常用比如从别的容器或字符串里截一段std::vectorchar vec {H, e, l, l, o}; std::string s(vec.begin(), vec.end()); // Hello std::string part(vec.begin() 1, vec.begin() 3); // el注意迭代器构造的第二个参数是区间终点不含和 substr 的起始位置 长度是两套语义混在一起最容易出 bug。两个接口我建议分开记一个管从哪里到哪里一个管从哪里开始拿几个。2.2 能编译但会出事的写法比起构造不出来更危险的是构造出来但内容是错的或者干脆是未定义行为。先看两个高危写法std::string s(nullptr); // 未定义行为 std::string t(0); // 未定义行为0 被当作空指针常量nullptr 会隐式转换成 const char*于是编译器老老实实调用了从指针构造的构造函数但标准规定从空指针构造 string 是未定义行为。大部分实现是直接崩溃个别老实现甚至可能表面成功了一下留个定时炸弹。写成std::string t(0);更隐蔽因为 0 在 C 里恰好可以当空指针常量用一样中招。我见过不止一个开发者因为这种写法在线上闪退排查半天才发现是构造那行的问题。还有一个常见失败std::string s a;这种写法编译不过char 不会隐式变成字符串。想要单字符字符串要么std::string s(1, a);要么std::string s; s.push_back(a);。编译错了别硬记理解成需要一个能转成字符串的来源就行。顺带一提判断空串要用s.empty()不要用s.size() 0更不要用s 。三种写法结果一样但 empty() 语义最直白性能上也是 O(1)。3. 遍历、拼接、查找日常三类操作的完整地图3.1 遍历的几种姿势与选择遍历字符串是高频操作姿势很多选哪种取决于你想干什么。std::string s Hello; // 1. 下标遍历 for (std::size_t i 0; i s.size(); i) { std::cout s[i]; } // 2. 范围 for for (char c : s) { std::cout c; } // 3. 迭代器遍历 for (auto it s.begin(); it ! s.end(); it) { std::cout *it; }绝大多数场景直接用范围 for 最省心。需要知道下标时才用第一种注意下标类型用std::size_t不要写int i不然编译器会刷一屏 signed/unsigned mismatch 的警告次数多了大家就当没看见了习惯不好。需要用 STL 算法比如 std::count_if、std::transform时用迭代器版本。访问单个字符时s[i]不检查越界s.at(i)会检查并在越界时抛出std::out_of_range。性能敏感的关键路径用operator[]其他情况尤其是处理用户输入时建议用 at 给自己留条后路。调试模式下某些标准库实现会对 operator[] 越界打断言但发布版就完全裸奔了不要依赖那层保护。3.2 拼接operator 与 append 的边界拼接是 string 最爽的功能但两个坑必须知道。std::string a Hello; std::string b World; std::string c a b; // HelloWorld std::string d a b; // 没问题 std::string e Hello World; // 编译错误最后一个为什么错因为字符串字面量Hello和 World的类型都是const char[6]本质上是指针两个指针相加没有意义编译器当然拒绝。只要有一边是 std::string才能正常工作。这也是初学者报错最多的场景之一。推荐日常拼接用operator或者append注意几种形态std::string s a; s b; // 追加 C 字符串 s c; // 追加单个字符 s.append(de); // 追加字符串 s.push_back(f); // 追加单个字符语义比 append 更精确 s.append(xyz, 2); // 追加 xy第二个参数是前 n 个字符追加大段字符串到循环里时要提前 reserve这个放到第 7 节专门讲。还有一个隐藏陷阱std::string(a) 65是能编译的因为 65 这个 int 会被隐式转换为 char对应 A结果是 aA。如果你本意是拼数字 65这样写就错得莫名其妙了。数字转字符串千万别图省事用这种写法老老实实走第 5 节的 to_string。3.3 查找find 全家与 npos查找接口很多先记一个前提任何查找失败都返回std::string::npos。npos 是 size_t 类型的最大值用 -1 初始化。所以判断时一定要写成pos ! std::string::npos不要直接拿 pos 当布尔值判断也不要随手转成 int 比较。如果哪段代码里出现pos -1那基本是在慢性中毒。std::string s Hello World, hello C; std::size_t pos s.find(hello); // 第一个 hello 的位置 std::size_t pos2 s.rfind(hello); // 从后往前找 std::size_t pos3 s.find(hello, 10); // 从下标 10 开始找find 系列里最容易误解的是 find_first_of它找的不是整个子串而是任意一个字符。s.find_first_of(abc)返回第一个等于 a、b 或 c 的位置。想找整串就用 find。还有配套的 find_last_of、find_first_not_of、find_last_not_of处理找空白字符找非数字字符这类需求很顺手。循环找子串是高频套路比如统计一个词出现多少次std::string text the quick brown fox jumps over the lazy dog; std::size_t pos 0; int count 0; while ((pos text.find(the, pos)) ! std::string::npos) { count; pos 3; // 越过当前匹配 } std::cout the 出现 count 次\n;注意pos 3这个细节如果不加find 会永远命中同一个位置直接死循环。碰到匹配串长度不确定时用pos matched.size();更稳妥的做法是配合 substr 先确认子串内容。4. 提取子串与文本解析按分隔符拆数据的实战思路4.1 substr 的正确姿势substr 的签名是basic_string substr(size_type pos 0, size_type count npos) const;含义是从 pos 开始取 count 个字符count 超出实际长度时就取到末尾。std::string s 2024-06-01; std::string year s.substr(0, 4); // 2024 std::string month s.substr(5, 2); // 06 std::string day s.substr(8); // 01注意两点。第一substr 返回的是新字符串本质是一次拷贝频繁截取会有性能开销后面讲 string_view 时再展开。第二pos 越界会抛std::out_of_range写防御性代码时用 try-catch 包一下或者先判断pos s.size()。至于取到末尾这个行为有时候反而是福利比如用s.substr(0, s.find(,))提取逗号前的部分如果找不到逗号find 返回 npos而 npos 会被当成最大可能长度于是得到整个原串。这种写法在实际解析里非常常见建议记下来。4.2 实战案例按分隔符切分写一个最朴素的 splitstd::vectorstd::string split(const std::string s, char delim) { std::vectorstd::string result; std::size_t start 0; std::size_t pos; while ((pos s.find(delim, start)) ! std::string::npos) { result.push_back(s.substr(start, pos - start)); start pos 1; } result.push_back(s.substr(start)); return result; }调用split(apple,banana,orange, ,)会得到三个元素。边界情况要心里有数空字符串返回一个空串元素如果输入是 a,,b中间连续两个分隔符会得到一个空串元素在中间这就是 CSV 解析里常见的空字段问题如果末尾有分隔符比如 a,b,split 结果末尾也会多一个空串。真实业务里要不要跳过空字段取决于你的数据格式约定。更现代的思路是不拷贝子串直接存std::string_view切片。把 split 的返回值改成std::vectorstd::string_viewsubstr 逻辑不变同样能跑省掉若干次堆分配。不过 string_view 不持有数据原字符串的生命周期必须覆盖到视图使用完毕这个约束后面还会再提。5. 数字与字符串互转to_string 和 stoi 的正确打开方式5.1 数字转字符串to_string 的精度问题C11 开始有std::to_string用法极简int n 42; double pi 3.14159; std::string sn std::to_string(n); // 42 std::string sp std::to_string(pi); // 3.141590看到没double 转出来是3.141590因为 to_string 对浮点走的是类似 sprintf(buf, %f, value) 的固定 6 位小数逻辑不是最精确最短表示。所以整数转换放心用浮点转换要先看清楚需求。如果只是输出日志这个精度通常可以接受如果要精确格式化老实的做法还是 snprintf 加一个 char 数组char buf[64]; snprintf(buf, sizeof(buf), %.2f, pi); std::string sp(buf); // 3.14C20 的std::format能优雅解决格式化问题那是另一个话题这里不展开了。5.2 字符串转数字stoi 与它的兄弟们stoi 的完整签名值得细看int stoi(const std::string str, std::size_t* pos nullptr, int base 10);第一个参数是被解析的字符串第二个参数是输出参数接收解析结束的位置也就是第一个没能解析成数字的字符下标第三个参数是进制。解析失败时抛std::invalid_argument数字超出范围抛std::out_of_range两个异常都要接住。std::string s 42abc; std::size_t pos 0; int value std::stoi(s, pos); // value 42, pos 2 // s 中下标 2 开始是 a解析到那里结束这个 pos 特性用来做连续解析很好用std::string data 12,34,56; std::size_t current 0; while (current data.size()) { std::size_t pos 0; int v std::stoi(data.substr(current), pos); std::cout v ; current pos 1; // 跳过逗号 }输出 12 34 56 。当然每个循环里都 substr 整个尾部数据量大时有复制开销这里理解思路为主。第二个参数还能让你在解析之后立刻检查是不是整个串都合法如果pos ! s.size()说明后面还有非数字内容业务上要不要拒绝就看你自己。另外 stoi 支持进制前缀std::stoi(0xff, nullptr, 16)返回 255。别用 C 语言的 atoi 接这活它出错时返回 0你根本分不清输入的到底是 0 还是非法输入。5.3 常见互转误区一个常见误区是拿std::stringstream当万能转换器。它确实能转但比 to_string/stoi 啰嗦也慢一个数量级只有偶尔转换复杂对象时才用得着。另一个误区是把 stoi 当 atoi 用忘记捕获异常一旦用户输入非法数字程序直接抛异常进程就崩了。解析用户输入属于安全边界不能裸奔。我给自己的习惯是凡是处理外部输入stoi 外面必包 try-catch或者先写个安全包装函数统一处理。6. 和 C 接口互操作c_str、data 与缓冲区写入的边界6.1 只读场景传给 C 风格接口到现在为止string 还是免不了要和 C 接口打交道最常见的就是获取 const char* 传给外部函数。最经典的一句是std::string filename data.txt; FILE* f fopen(filename.c_str(), r);c_str() 返回 const char*以 \0 结尾适合所有只读的 C API。C11 之后data() 也保证返回以 \0 结尾的 const char*和 c_str() 行为一致C17 还加了非 const 的 data() 返回 char*用于写入。历史经验里data() 不一定以 NULL 结尾的说法在 C11 以后就不再成立。核心风险是生命周期。c_str() 返回的指针只在字符串未被修改期间有效std::string s Hello; const char* p s.c_str(); s World; // 可能触发重新分配p 立即失效 printf(%s\n, p); // 未定义行为更深一层的建议是把指针当成一次性用品用完马上用不要存到另一个变量里跨语句使用。我审查代码时看到缓存 c_str 结果的写法基本都会建议改掉。6.2 写入场景把 string 当缓冲区有些 C API 不满足于只读而是要你提供一个可写缓冲区比如网络 recv、文件 read。不少人的第一反应是 char buf[4096] 然后转 string其实 string 自己就能当缓冲区std::string buf; buf.resize(4096); // 保证 buf[0] 可写 int n recv(fd, buf[0], 4096, 0); // 写入 buf.resize(n 0 ? n : 0); // 缩到实际长度这里有两个要点第一buf[0]只有在 size() 0 时才能安全用于写入所以必须先 resize第二读取完成后立刻把 size 调整到实际字节数否则收完数据还带着后面一堆 \0打印、拼接都会出事。C17 以后可以写buf.data()更直白。另一个经常踩的坑是混用 C 接口和 string 的生命周期。比如把 string 传给某个回调回调里又追加内容函数返回后原引用失效但调用方还在用——这种问题通常不会当场暴露等堆被写坏了才在莫名其妙的地方崩溃排错成本极高。我的建议是跨模块边界时接口尽量传 const std::string内部要用原始指针就局部取、局部用绝不外带。6.3 从 string 生成 C 字符串时的额外注意还有个小细节std::string s abc\0def;这个写法得到的 string 长度是 3而不是 7因为从 const char* 构造时用的是 strlen会在 \0 停下。真想构造含 \0 的字符串用std::string s(abc\0def, 7);或者迭代器构造。这个细节在做二进制数据、协议报文时特别重要。同理s.length() 和 s.size() 在 string 里完全一样别被 C 语言的 length 思维带偏。7. 容量管理、SSO 与经典坑进阶使用者的经验之谈7.1 SSO小字符串为什么又贵又便宜很多从 C 来的人担心 string 每次构造都堆分配。实际上现代标准库基本都有 SSOSmall String Optimization小字符串优化机制字符串小于某个阈值常见实现是 15 或 22 字节时数据直接存在 string 对象内部的栈空间里不触发堆分配。这就是为什么std::string s abc;看起来和 char[4] 差不多快。代价是 string 对象本身变大了。在常见的 64 位 Linux 实现里sizeof(std::string) 一般是 32 字节MSVC 实现一般是 40 字节。如果你设计一个每个对象都持有一个短字符串的数据结构内存开销会有感知。知道 SSO 存在的意义在于不要一提 string 就嫌慢也不要盲目把所有字符串改成 char[]一切以测出来的性能数据为准。7.2 reserve 与 clear容量是可控的string 的容量概念和 vector 一模一样size 是有效字符数capacity 是已分配内存能容纳的字符数不够时自动扩容。扩容一般按 1.5 到 2 倍增长均摊下每次追加是 O(1)但批量拼接时会有大量拷贝和分配可以优化。场景很常见循环里拼一个长字符串比如生成 CSVstd::string out; for (int i 0; i 100000; i) { out std::to_string(i); out.push_back(,); }这样每次 都可能触发扩容和数据搬运。可以先估计总量out.reserve(600000);再进循环实测吞吐差异非常明显。如果实在不知道总量也可以先让循环跑起来中间判断out.capacity() out.size()再加 reserve但工程上通常直接按最坏情况预留即可。clear() 只清 size不动 capacity这对复用缓冲区是好消息std::string s; for (int i 0; i 100; i) { s.clear(); // 往 s 里填内容 process(s); }反复 clear 不会反复分配所有写网络收包循环的人都应该掌握这个习惯。反过来如果确实想释放多余内存C11 有 shrink_to_fit()非强制实现可能忽略老代码用std::string().swap(s);这个惯用法能把 capacity 归零。7.3 经典坑迭代器失效与引用悬垂string 被修改时如果触发了重新分配所有指向其内部数据的迭代器、指针、引用都会失效。最常见的翻车现场是边遍历边修改std::string s a quick brown fox; for (auto it s.begin(); it ! s.end(); it) { if (*it ) { s.erase(it); // erase 之后 it 失效这里直接 it 是未定义行为 } }erase 会返回指向下一个元素的迭代器正确写法是for (auto it s.begin(); it ! s.end(); ) { if (*it ) it s.erase(it); else it; }这里的核心原则是不要在任何可能改变容器结构的操作之后继续使用旧迭代器。如果遇到更复杂的删除场景std::remove erase 惯用法通常更稳那是另一个话题。我只想强调任何形式的用旧指针碰新数据都是集成了才会爆的硬 bug。7.4 参数传递什么时候用 const 引用、值还是 string_view最后给一个争议少一点的实操建议。函数内部只需查看字符串内容时优先const std::string零成本需要一份属于自己的可变副本时按值传参让调用方决定是拷贝还是 movevoid consume(const std::string data) { ... } // 只读 void take(std::string data) { ... } // 自己持有但更现代的做法是参数能接受各种字符串来源std::string、const char*、string_view时传std::string_view。它是 C17 引入的只读视图持有指针和长度不拷贝数据构造几乎零成本。代价是不拥有数据——临时对象产生的 view 生命周期很短底层 string 一旦析构view 就是悬垂的。我的原则是函数内部立即使用可以放心用 view会把数据存起来的场景一律复制成 string。其实写到这里回头看std::string 的简单使用并不简单。很多年长项目里大家把它当高级字符数组用能用但没吃到接口红利。我自己的体会是先把 find 和 npos 的约定吃透再把 c_str 的生命周期边界刻进脑子里最后学会 reserve 和 string_view 这两把性能钥匙日常开发里跟 string 相关的坑基本就能绕开。剩下的就是多写几个解析和拼装的小例子让手感熟起来。