C++ split() 字符串分割的五种实现与性能实测 最近在做一个跨平台的消息中间件需要频繁解析配置文件里的键值对和标志位列表形如key1value1;flag1;flag2hello world这种格式。按常规思路第一反应是去找std::string有没有现成的split()接口。翻了一圈标准库答案是没有。C 就是这么有个性它把“怎么拆字符串”这个问题完全留给开发者自己解决。于是我又一次老老实实地自己写了一个split()顺便把这几年的心得整理成这篇文章。这篇文章不打算讲太高深的东西主要围绕“C 实现字符串分割函数 split()”这件事把我实际用过的方案、踩过的坑、性能实测数据以及最终沉淀下来的工程级实现都写清楚。如果你正在处理配置解析、日志清洗、命令行参数拆分这类需求这篇文章可以直接当参考。1. 这件事的起点为什么需要自己写 split1.1 项目里的真实需求场景我遇到的场景很典型一个配置项是一个长字符串由分号分隔多个子项每个子项内部可能又是keyvalue结构。如果 C 标准库自带了 split代码大概会写成auto items split(config_str, ;); for (auto item : items) { auto kv split(item, ); // ... }但标准库没给这个接口所以上面这段代码在 C 里无法直接编译必须自己定义一个 split。这类需求在 C 项目里出现的频率非常高不只是配置解析像网络协议的自定义报文分割、命令行参数的解析、CSV 格式的简易解析全都绕不开“把字符串按某个分隔符拆开”这个基础操作。有人可能会说用 Python 或者脚本语言干这活多简单C 的字符串处理就是笨重。这话有一定道理但在要求高性能、低延迟或需要嵌入到客户端 SDK 的场景里C 依然是主力语言字符串拆分这种高频基础操作只能自己造轮子。作为一个合格的 C 开发者手写一个健壮的 split 是基本功。1.2 标准库为什么一直没加 split很多人好奇C 标准库为什么这么多年都不提供 split原因主要有三个。第一标准库的设计哲学是“提供最小但通用的构件”而不是把每个功能都封装成现成函数。std::string::find、std::string::substr、std::istringstream、std::getline、std::regex这些已经覆盖了拆分的全部能力标准委员会认为你再组合一下就能得到 split不需要单独提供。第二split 本身存在太多变体。返回值怎么定是vectorstring还是vectorstring_view分隔符是单个字符还是字符串要不要支持多个分隔符空字符串是保留还是丢弃首尾分隔符怎么处理这些语义没有统一答案标准库一旦提供某一种就会限制其他用法的发挥。第三历史包袱。C 标准库演进极其谨慎任何新接口都必须在多个委员会会议里反复打磨像 split 这种“一百个人有一百种接口定义”的功能优先级排得很低以至于 C23 才出现了相对可用的 ranges 方案。1.3 设计 split 前必须先想清楚的几个问题写 split 很简单写一个“好用且不坑”的 split 却需要先回答几个问题。返回值选vectorstring还是vectorstring_view这直接关系到性能。vectorstring每个元素都是一次堆分配数据量大时开销明显vectorstring_view则只是原字符串上的视图零拷贝但代价是要求源字符串在视图生命周期内保持有效而且切割后无法直接修改每个元素的内容。空串策略也很关键。按a,,b中逗号拆分标准做法应该得到[a, , b]还是[a, b]不同项目诉求完全不同解析 CSV 可能希望保留空字段解析配置文件可能恨不得把空串全扔掉。分隔符支持单个字符还是任意字符串是否支持多个分隔符这决定了一个函数是只能处理split(str, ,)还是能处理split(str, ,;\t)这种多分隔符场景。这些决策必须在一开始就定下来否则后面返工很痛苦。2. 五种主流实现方案的横向对比2.1 思路一基于 getline 的流式拆分这是最容易被想到的方案利用std::istringstream加std::getline的重载以指定字符作为行分隔符#include sstream #include string #include vector std::vectorstd::string splitByGetline(const std::string s, char delim) { std::vectorstd::string tokens; std::istringstream iss(s); std::string token; while (std::getline(iss, token, delim)) { tokens.push_back(token); } return tokens; }这个实现的优点是代码极短逻辑直白不容易写错。但隐藏问题不少std::getline不会保留空串如果输入是a,,b得到的是[a, b]中间的空字段丢了其次它有两个failbit相关的坑当字符串结尾正好是分隔符时最后一个空子串不会输出再者一律假设输入以文本行形式存在遇到\0之类的字符时行为会变得微妙。实测下来这个方案的性能在中规中矩的级别因为istringstream内部有 locale 处理和流缓冲机制相比直接find要慢不少。它适合逻辑简单、不在乎性能、并且明确知道不需要保留空串的场景比如按空格拆分用户输入的命令行单词。2.2 思路二find 加 substr 手工切割这是最经典的手工方案也是我在生产环境最常用的基础版本#include string #include vector std::vectorstd::string splitByFind(const std::string s, const std::string delim) { std::vectorstd::string tokens; if (delim.empty()) { tokens.push_back(s); return tokens; } size_t start 0; size_t pos 0; while ((pos s.find(delim, start)) ! std::string::npos) { tokens.push_back(s.substr(start, pos - start)); start pos delim.size(); } tokens.push_back(s.substr(start)); return tokens; }这个方案能正确处理多字符分隔符比如按-拆分a-b-c这是 getline 方案做不到的。同时它天然保留空字段输入a,,b按,拆分得到[a, , b]。性能方面比 getline 好不少因为少了流对象的构造和 locale 开销只调用find和substr。缺点是每次substr都会产生新的字符串拷贝如果被分割的字符串很长、次数很多内存分配压力会显现。可以在循环前tokens.reserve(...)预留容量也可以换成string_view版本进一步降低拷贝。2.3 思路三正则表达式统一切割正则方案可以优雅地处理复杂分隔符比如同时按逗号、分号、空白分割还能支持变长空白#include regex #include string #include vector std::vectorstd::string splitByRegex(const std::string s, const std::string pattern) { std::regex re(pattern); std::sregex_token_iterator begin(s.begin(), s.end(), re, -1); std::sregex_token_iterator end; return std::vectorstd::string(begin, end); }调用方式变成splitByRegex(a,b;c d, [,;\\s])相当灵活。std::sregex_token_iterator配合-1表示“匹配模式之外的片段”这个用法是最容易踩坑的地方很多人写成0结果拿到的是分隔符本身而不是分割结果。正则方案最大的代价是性能。std::regex的构造和匹配开销非常大实测用正则做一次简单拆分比find版本慢十倍甚至几十倍都很正常。所以我的建议是能用find就别用正则只有分隔符确实是复杂语境时才值得上正则而且最好把std::regex对象缓存起来复用不要每次调用都重新构造。2.4 思路四C23 ranges 的 split_view如果你的编译器已经支持 C23标准库终于提供了std::ranges::views::split这算是从语言层面补上了 split 的缺失#include ranges #include string #include vector std::vectorstd::string splitCxx23(const std::string s, char delim) { std::vectorstd::string tokens; for (auto part : s | std::views::split(delim)) { tokens.emplace_back(part.begin(), part.end()); } return tokens; }这段代码在 C23 模式下编译语义很清晰“把 s 按 delim 切分成若干部分”。它的底层采用惰性求值遍历过程不会一次性生成所有拷贝内存占用更友好。但也要注意早期实现里split_view得到的每个子范围不是直接能当std::string用的类型需要手动用std::string(part.begin(), part.end())构造这点和 Python 的split()完全不同初学者容易懵。2.5 方案对比一览我用几组典型场景做了一个横向对比方便你按项目诉求选型。方案保留空串多字符分隔符多分隔符性能量级代码复杂度适用场景getline 流式否否否中极低简单命令行拆分find substr是是否高低通用场景首选find 多分隔符辅助可配置是是高中生产环境推荐std::regex是是是极低中复杂分隔规则C23 split_view是否否中高低新项目尝鲜从这张表能看出find substr在大多数情况下都是性价比最高的方案它兼顾了语义灵活性和性能。我的建议是先把这一版吃透再根据需求扩展成多分隔符版本这样能应付绝大部分工程场景。3. 工程级 split 的完整设计与实现3.1 接口设计能干活但别挖坑写一个能上生产环境的 split我不会只提供一个单薄的重载。经过多次迭代现在一般提供下面几组接口namespace StrUtils { // 基础版本按单个字符拆分 std::vectorstd::string split(const std::string s, char delim); // 按多字符字符串拆分 std::vectorstd::string split(const std::string s, const std::string delim); // 按一组分隔字符拆分分隔符集合中的任意字符都算分隔符 std::vectorstd::string splitAny(const std::string s, const std::string delims); // 高级版本支持保留空串策略和前后修剪策略 std::vectorstd::string split(const std::string s, const std::string delim, bool keepEmpty, bool trim); }接口设计的关键是保持一致性。split(s, ,)应该和split(s, std::string(,))行为完全一致不能因为入参类型不同导致空串处理策略不同否则调用方会被这种“隐式差异”坑得很惨。另外我把keepEmpty和trim默认值都设置成符合直觉的状态keepEmpty false表示默认丢弃空串trim true表示默认修剪首尾空白这样配置解析场景里最常见的写法可以保持干净。3.2 核心实现find 加 substr 的强化版多字符分隔符的基础版本在前面已经展示过了这里直接给出高级版本的完整实现这是我在多个项目里反复打磨后的形态#include algorithm #include cctype #include string #include vector namespace StrUtils { static inline std::string trim_copy(const std::string s) { size_t begin 0; size_t end s.size(); while (begin end std::isspace(static_castunsigned char(s[begin]))) { begin; } while (end begin std::isspace(static_castunsigned char(s[end - 1]))) { --end; } return s.substr(begin, end - begin); } std::vectorstd::string split(const std::string s, const std::string delim, bool keepEmpty, bool trim) { std::vectorstd::string tokens; if (delim.empty()) { tokens.push_back(trim ? trim_copy(s) : s); return tokens; } const size_t delimLen delim.size(); size_t start 0; size_t pos 0; while ((pos s.find(delim, start)) ! std::string::npos) { std::string token s.substr(start, pos - start); if (trim) token trim_copy(token); if (keepEmpty || !token.empty()) { tokens.push_back(std::move(token)); } start pos delimLen; } // 处理最后一段 std::string tail s.substr(start); if (trim) tail trim_copy(tail); if (keepEmpty || !tail.empty()) { tokens.push_back(std::move(tail)); } return tokens; } std::vectorstd::string splitAny(const std::string s, const std::string delims) { std::vectorstd::string tokens; if (delims.empty()) { tokens.push_back(s); return tokens; } size_t start 0; size_t pos 0; while ((pos s.find_first_of(delims, start)) ! std::string::npos) { tokens.push_back(s.substr(start, pos - start)); start pos 1; } tokens.push_back(s.substr(start)); return tokens; } }这个实现有几个细节值得说明。s.find(delim, start)返回的pos是分隔符起点所以截取子串的终点是pos下一个查找起点必须跳过整个分隔符长度即start pos delimLen。如果分隔符是多字节字符串这个逻辑依然正确如果分隔符是单个字符delimLen就是 1。trim_copy里我特意用了static_castunsigned char(s[begin])这是为了防止char在高位为 1 时比如 UTF-8 中文的某个字节被std::isspace当作负数处理。不转会让程序在某些编译器上出现未定义行为。std::isspace的入参要求必须是EOF或在unsigned char范围内的值直接传char是踩坑高发区。std::move(token)推入vector是避免多余的拷贝构造。tokens.push_back(std::move(token))会把临时字符串的所有权转移给 vector 元素省掉一次深拷贝。虽然token本身就是局部变量但显式move比让编译器自己推导更可控读代码的人也一眼能看出意图。3.3 空字符串与连续分隔符的处理细节关于空子串的取舍我提一个容易忽略的细节字符串以分隔符开头或结尾时语义会有歧义。例如split(,a,b, ,)如果保留空串结果应该是[, a, b]因为第一个逗号前面有一个空子串。如果丢弃空串结果应该是[a, b]。而split(a,b,, ,)保留空串时循环结束后还有最后一段结果是[a, b, ]。丢弃空串时最后一段为空不放入结果得到[a, b]。这个看似简单的行为在解析 CSV 时却涉及到字段对齐。比如一行a,,c表示三列中间列是空值如果用丢弃空串的策略就会变成两列行数据错位后面的业务逻辑全部乱套。所以我在接口里把keepEmpty独立出来做开关解析 CSV 时传true解析配置时传false互不干扰。连续分隔符的处理逻辑同样在上面代码里体现,,a按逗号拆分第一个 token 是空第二个 token 是空第三个 token 是a。保留空串时得到[, , a]丢弃得到[a]。实际项目中如果分隔符本身可能连续出现且用户希望合并连续分隔符比如a,,b想得到[a, b]那需要另写一个“压缩模式”通常借助find_first_of加手动跳过空 token 实现。我的一般建议是不要在基础 split 里内置偷懒逻辑让调用方根据业务语义决定。3.4 多分隔符与分隔符集合有时你要按多个字符中任意一个来拆分比如按,或;或\t拆分日志行。find_first_of在这里很顺手splitAny(a,b;c\td, ,;\t) // - [a, b, c, d]这段代码内部不断用find_first_of找到任一分隔符的位置再把[start, pos)之间的内容取出。需要注意的是find_first_of是按“字符集合”匹配的不是按“字符串”匹配的如果分隔符是; 分号加空格那任意一个分号或空格都能切开这在多数场景里正好符合预期。多分隔符版本我没加入keepEmpty参数是因为它的语义和单分隔符版本可以保持一致只是内部用find_first_of替代find。如果你要同时支持“多分隔符 保留空串 修剪”自己扩展起来很容易照着 3.2 的代码把find换成find_first_of就行。3.5 性能细节reserve、string_view 与内存分配的博弈实现里的另一个关键点是预分配。如果提前知道切分数量可以用tokens.reserve(count 1)减少 vector 扩容时的拷贝。但很多场景下无法预知切分数量所以我在实际生产代码里往往先快速估计一个保守容量。一个朴素的做法是预先统计分隔符出现次数size_t estimate std::count(s.begin(), s.end(), delim) 1; tokens.reserve(estimate);对于单字符分隔符std::count的开销极低但能显著减少 vector 的多次扩容尤其是当 segment 数量大到成百上千时效果肉眼可见。对于多字符分隔符std::count不适用可以使用find循环计数或者干脆不预分配因为多字符分隔符的切分数量通常没那么大。如果追求极致性能可以写一个split的string_view版本返回vectorstring_view。它完全不分配新的字符串内存省去所有深拷贝代价是调用方必须保证源字符串的生命周期长于结果视图。这个约束在函数内部无法强制检查只能靠调用约定和代码注释约束所以我通常只在性能敏感模块里提供string_view版的私有工具函数不轻易放进公共接口。公共接口保持vectorstring最稳妥。4. 踩坑实录与性能实测实战里最容易翻车的细节4.1 常见问题速查表我在各种评审和代码 review 里见过无数次同样的 split 踩坑这里整理成一个速查表直接对着查就行。问题现象根因解决方案分割结果丢失最后一个空字段getline 方案遇到末尾分隔符不产生输出改用 findsubstr或显式检查尾串中文等非 ASCII 字符出现乱码按字节截断substr按字节数切割确认需要的是字节切分还是 UTF-8 码点切分前者用 substr 没问题后者要引入字符解码分隔符是\\0或空字符串时空指针崩溃对delim.empty()未做防御开头判断delim.empty()时返回整个字符串使用std::isspace时传入负值导致断言char有符号且字符超出 ASCII 范围先转unsigned char再传入isspacestd::regex拆分性能极慢正则引擎编译匹配开销大缓存std::regex对象或用 find 方案替代C23 split_view 得到的子范围无法直接赋值给 string子范围类型是subrange用std::string(part.begin(), part.end())构造大字符串拆分时反复扩容内存抖动vector 容量不足导致频繁 reallocate先reserve估算容量或改用 string_view分隔符连续出现时结果不符合预期保留空串语义和丢弃空串语义混淆显式指定keepEmpty策略不要依赖默认值4.2 性能实测记录我在一台普通开发机处理器频率约 2.6GHz内存类型为常见的 DDR4上用一段长度约 1MB 的字符串做了拆分压测分隔符是逗号共约 20 万个小字段。对比了 getline、findsubstr、regex 三种方案结果如下方案平均耗时毫秒结果数量find substr未 reserve约 38200001find substrreserve 后约 31200001getline 流式约 66200000std::regex 动态构造约 1120200001std::regex 复用对象约 95200001看到没有正则的动态构造版本比 find 方案慢了接近 30 倍即使复用了std::regex对象也慢了 3 倍左右。这个数据很有代表性如果只是按单个字符分割正则从来都不是明智选择但如果分隔符是一个复杂的模式比如,\\s这种“逗号加任意空白”正则的语义优势就体现出来了此时 95 毫秒处理 1MB 文本也算可以接受。再补充一个string_view版本的性能参考同样的输入vectorstring_view版本耗时只有大约 2 毫秒。这就是零拷贝的威力但它要求源字符串在结果使用期间不能被销毁或修改。我在解析一次性读取的配置文件时经常用这个版本逻辑上完全安全。4.3 生产代码里的防御性细节防御性编程对 split 来说不是过度设计而是刚需。第一个防御点是空指针问题。如果接口接收的是const char*在构造std::string之前就要判断是否为nullptr否则string(nullptr)直接崩溃std::vectorstd::string split(const char* s, char delim) { if (s nullptr) return {}; return split(std::string(s), delim); }第二个防御点是空分隔符问题。前面代码已经演示了delim.empty()时返回{s}而不是进入死循环。如果不加这个判断find(delim, start)传入空字符串时会返回start然后start pos 0永远停留在原地成为死循环这是个特别隐蔽的 bug。第三个防御点是分隔符长度大于源字符串长度的问题。比如split(abc, abcdef)find返回npos循环不进入直接返回[abc]看起来没问题。但如果分隔符是空串就没这么幸运了所以空分隔符判断必须单独处理。4.4 编码问题再谈 UTF-8 下的字符串拆分中国开发者使用 C 时经常遇到中文场景。C 的std::string本质上只处理字节序列不感知字符编码。按逗号拆分一段包含中文的字符串拆分后每个子串的字节数是 3 的倍数还好说很多时候会出现在某个汉字的中间字节处切断的情况这时单独打印子串看起来可能是乱码。但大多数配置解析场景下目标子串本身是完整的不需要按汉字边界切分所以没必要过度设计。真正的坑在于判断字符是否为标点或空白时直接对char调用std::isspace会因为符号扩展导致未定义行为这在 3.2 节已经提到过。再补充一个场景如果你需要按中文逗号或者中文分号拆分只要分隔符本身是完整的 UTF-8 字符串find正常按字节序列匹配即可std::string的多字节字符串查找不涉及乱码问题。但如果你用find_first_of且把当字符集合传入那就错了因为它会把 UTF-8 编码的三个字节当成三个独立字符来匹配。这个区别在文档里写清楚能少走很多弯路。5. 实战选型建议与一个可以直接抄的工具版本5.1 不同业务场景下的选型建议配置解析类需求比如keyvalue对和分号分隔的选项列表优先用 findsubstr 的通用版本开启trimtrue、keepEmptyfalse能省去大量空串和空白判断的样板代码。CSV 解析类需求必须保留空字段用keepEmptytrue的版本同时注意 CSV 中可能存在引号包裹字段的类型如果字段内部包含分隔符那就要写一个状态机解析器单纯 split 已经不够用了。日志清洗类需求数据量大且格式多变建议用string_view版本按行切分充分享受零拷贝带来的性能收益但是要小心日志字符串在异步线程里被复用或覆盖的并发问题最好在切分后立刻把需要的字段拷贝成std::string存进结果结构。命令行参数拆分空间分隔和引号处理规则比较特殊istringstream能轻松处理简单的空白分隔但如果参数里带引号和转义建议直接引入一个成熟的全功能参数解析库不要重新发明轮子省下来维护转义规则的时间能做好多别的事。5.2 一个可以直接抄进项目的完整工具头文件为了让你少做重复工作我把上面讲的精华浓缩成一个单文件工具头支持单字符、多字符、多分隔符拆分支持修剪和空串策略按 LGPL 类似宽松许可放出来你自己接进代码库就可以用// str_split.h #pragma once #include algorithm #include cctype #include string #include vector namespace StrUtils { inline std::string trim_copy(const std::string s) { size_t b 0; size_t e s.size(); while (b e std::isspace(static_castunsigned char(s[b]))) b; while (e b std::isspace(static_castunsigned char(s[e-1]))) --e; return s.substr(b, e - b); } inline std::vectorstd::string split(const std::string s, const std::string delim, bool keepEmpty false, bool trim true) { std::vectorstd::string out; if (delim.empty()) { out.push_back(trim ? trim_copy(s) : s); return out; } const size_t dlen delim.size(); size_t start 0; size_t pos 0; while ((pos s.find(delim, start)) ! std::string::npos) { std::string t s.substr(start, pos - start); if (trim) t trim_copy(t); if (keepEmpty || !t.empty()) out.push_back(std::move(t)); start pos dlen; } std::string tail s.substr(start); if (trim) tail trim_copy(tail); if (keepEmpty || !tail.empty()) out.push_back(std::move(tail)); return out; } inline std::vectorstd::string split(const std::string s, char delim, bool keepEmpty false, bool trim true) { return split(s, std::string(1, delim), keepEmpty, trim); } inline std::vectorstd::string splitAny(const std::string s, const std::string delims) { std::vectorstd::string out; if (delims.empty()) { out.push_back(s); return out; } size_t start 0; size_t pos 0; while ((pos s.find_first_of(delims, start)) ! std::string::npos) { out.push_back(s.substr(start, pos - start)); start pos 1; } out.push_back(s.substr(start)); return out; } }用法示例#include str_split.h #include iostream int main() { namespace su StrUtils; auto v1 su::split( apple, banana ,cherry , ,); // v1 {apple, banana, cherry} auto v2 su::split(a-b-c, -); // v2 {a, b, c} auto v3 su::split(a,,b,, ,, true, false); // v3 {a, , b, } auto v4 su::splitAny(a;b,c\t d, ,;\t ); // v4 {a, b, c, d} for (const auto s : v1) std::cout [ s ]\n; return 0; }这个头文件应该足够覆盖最常见的需求。如果你需要string_view版本自己照葫芦画瓢把std::string替换成std::string_view把substr的返回值类型改掉即可核心逻辑不变。5.3 要不要用 C23 的 split_view我的偏向如果你所在项目已经切换到 C23 编译环境std::views::split值得一试。可读性确实比手写 find 循环好很多代码意图一目了然。但在决定全面使用前我建议先做一次时间和兼容性评估。一方面当前主流编译器对 C23 ranges 的支持程度不一致部分标准库实现的 split_view 在特定输入下处理空范围时还偶尔出现奇怪行为遇到这些问题排查成本不低。另一方面现有代码库若要迁移从vectorstring到视图迭代的接口差异需要改动很多调用点不是单纯把 split 函数换掉就行。所以我更愿意持一种务实态度新项目在条件允许下优先尝试 C23老项目继续用我已经跑过千万次调用的手写版本。手写版不丢人它稳定、可控、无隐藏行为这也是它能在生产环境长期存在的原因。最后再分享一点实际经验用 C 写字符串拆分这件事我前前后后写了不下十遍从最开始只有二十行的 getline 版本到后来带空串策略、多分隔符、修剪能力的完整工具最大的体会是接口设计比实现本身更考验功力。千万不能图省事把所有行为都藏在一个“智能”的默认值里否则不同调用方会互相踩对方的语义预期。好的 split 应该把决定权交给调用者用参数显式表达需求。还有一个小技巧如果你的项目有规约要求禁止使用异常那所有返回 vector 的 split 函数都应确保没有抛出异常的机会。std::string的默认分配器在内存不足时会抛std::bad_alloc这本质上无法完全避免但只要不在 split 内部做业务逻辑判断理论上异常只可能来自内存分配。业务上更重要的是别在 split 里塞入自定义删除逻辑比如“字段名不要以_开头就过滤掉”这类需求应该放到调用方的循环里处理保持 split 的纯粹单一职责。最后关于性能不要盲目追求 string_view 或者花式优化先明确你的数据量级。每天处理几万个短字符串随便写都能满足每秒处理上百万行日志才值得把 reserve、move、string_view 全部用上。把有限的精力投入到真正需要的地方比复制一段“看起来很酷”的代码更有意义。希望这篇拆解能帮你少踩几个坑如果你有更特殊的拆分需求也可以沿着这里的思路自己扩展。