std::string vs std::string_view
std::string vs std::string_view 详解
std::string_view是 C++17 引入的一个非拥有、只读的字符串视图。
它常被拿来和老牌的std::string做对比 —— 二者表面看起来很像,但语义、所有权、生命周期完全不同。用得好能大幅提升性能,用得不好就是悬空引用的重灾区。
本文会从"它们各自是什么"开始,讲到差异、使用场景、性能分析,以及踩坑指南。
1. 各自是什么?
1.1std::string
一个拥有所有权的可变字符串容器。
- 内部通常包含三个字段:
ptr、size、capacity(再加上 SSO 小串优化的 union buffer)。 - 管理内存:构造时分配,析构时释放,拷贝时深拷贝。
- 可变:支持
+=、append、replace、resize等修改操作。 - 以
\0结尾:c_str()返回的是保证零结尾的 C 字符串。
1.2std::string_view
一个不拥有任何字符的轻量"视图"。
- 内部只有两个字段:
ptr(指向字符的指针)+size(长度)。整个对象通常只有 16 字节。 - 不管理内存:不分配、不释放、不拷贝字符内容,只保存指针和长度。
- 只读:不提供
operator[]的非 const 版本,也没有append/resize这类修改操作。 - 不保证以
\0结尾:data()返回的可能不是零结尾的 C 字符串。
一句话:
string是"字符串的所有者",string_view是"对某段字符串的借用"。
2. 核心区别对比
| 维度 | std::string | std::string_view |
|---|---|---|
| 所有权 | 拥有字符内存 | 不拥有,只是借用 |
| 可变性 | 可读可写 | 只读 |
| 内存管理 | 自动分配 / 释放 | 完全不管 |
| 拷贝成本 | O(n),深拷贝字符 | O(1),只拷贝指针 + 长度 |
| 大小 | 通常 24 ~ 32 字节(带 SSO) | 通常 16 字节 |
| 零结尾 | 保证以\0结尾 | 不保证 |
| 生命周期 | 自管理 | 依赖底层字符串的生命周期 |
| 引入版本 | C++98 | C++17 |
| 适合做什么 | 需要持有、修改字符串 | 只读地观察、传递字符串 |
| 适合当参数 | 需要"接管"字符串时 | 只读形参,几乎总是最优选择 |
3. 为什么需要string_view?
3.1 传统const std::string&参数的隐藏开销
看这个典型写法:
boolstarts_with_hello(conststd::string&s){returns.rfind("hello",0)==0;}starts_with_hello("hello world");// ①starts_with_hello(some_c_str);// ②虽然参数写的是"引用",但"hello world"和some_c_str都是const char*,
要绑定到const std::string&会隐式构造一个临时std::string。
这里有个细节常被混淆:临时string对象本身一定是在栈上(它就是一个普通的临时变量),
但它"管理的字符存储"放哪儿,取决于长度:
| 字符串长度 | 字符存储位置 |
|---|---|
| 短串(命中 SSO) | 对象内联的 inline buffer(也在栈上,无堆分配) |
| 长串(超出 SSO 容量) | 堆上malloc一块,析构时free |
三大实现的 SSO 上限:libstdc++ 15 字节、libc++ 22 字节、MSVC STL 15 字节。
所以"hello world"(11 字节)其实不会上堆,只有短字符的内联拷贝 + 构造 + 析构开销。
但只要字符串稍长(例如一条完整日志行、一段 JSON 字段、一个文件路径),
就会真的触发malloc/free。在热路径上每秒被调用百万次时,
"偶尔上堆"的那部分很容易主宰整个函数的耗时。
即便命中 SSO,隐式构造仍然要:
memcpy这些字节到 inline buffer;- 填好
size、把ptr指向 inline buffer; - 函数返回时再析构一次。
这些都是string_view完全没有的工作。
3.2string_view消除这种开销
boolstarts_with_hello(std::string_view s){returns.substr(0,5)=="hello";}此时:
- 传
"hello world"(const char*)→ 只构造{ptr, len},指针直接指向.rodata段里的字面量,零拷贝、零分配。 - 传
std::string→ 隐式转换为string_view,指针指向string原本的字符缓冲(栈上的 SSO 或堆上的 buffer 都行),零拷贝。 - 传
std::string_view→ 直接传两个 word,零拷贝。
所有主流字符串来源都能"零成本"喂给它,这就是string_view的价值。
4. 典型使用场景
4.1 只读形参(最常见)
voidlog(std::string_view msg);voidparse(std::string_view input);经验法则:只读的字符串形参,永远优先用
std::string_view。
4.2 切片 / 子串(零拷贝)
std::string_view sv="hello,world";autocomma=sv.find(',');autoleft=sv.substr(0,comma);// "hello" —— 仍然只是视图autoright=sv.substr(comma+1);// "world" —— 仍然只是视图相比std::string::substr会分配一个新的字符串,string_view::substr不拷贝任何字符。
4.3 解析 / 分词
std::vector<std::string_view>split(std::string_view s,chardelim){std::vector<std::string_view>out;size_t start=0;for(size_t i=0;i<=s.size();++i){if(i==s.size()||s[i]==delim){out.emplace_back(s.substr(start,i-start));start=i+1;}}returnout;}只要s的原始存储还活着,out里的所有切片都有效,整个分词过程零分配。
4.4 返回字符串常量
std::string_viewname()constnoexcept{return"anonymous";// 静态存储期的字面量,永远有效}5.std::string_view的陷阱(重点!)
string_view本质上是"指针 + 长度",所以它继承了所有指针能犯的错。
陷阱 1:指向临时对象(最经典的悬空)
std::stringmake(){return"hello";}std::string_view sv=make();// ⚠️ 临时 string 立刻析构std::cout<<sv;// 💥 UB:悬空视图右侧的std::string是个临时对象,表达式结束就被销毁了,sv保存的指针立刻变野指针。编译器通常不会报警告。
正确写法:
std::string s=make();// 延长生命周期std::string_view sv=s;// ok陷阱 2:成员变量存string_view
structConfig{std::string_view name;// ⚠️ 小心};Config c;{std::string tmp="abc";c.name=tmp;}// tmp 析构use(c.name);// 💥 UB如果类是要"持有"字符串的,就老老实实用std::string。
只有确信视图指向的数据比类的生命周期更长(比如全局字面量)时,才考虑存string_view。
陷阱 3:误当成 C 字符串用
voidprint(std::string_view sv){std::printf("%s\n",sv.data());// 💥 data() 不保证 \0 结尾}sv.data()只返回起点指针,长度在sv.size()里,printf("%s")会一路读到\0为止 —— 越界读取。
正确做法:
std::printf("%.*s\n",(int)sv.size(),sv.data());或者先转成std::string:
std::printf("%s\n",std::string(sv).c_str());// 代价:一次分配陷阱 4:substr之后string失效
std::string s="hello,world";std::string_view v=s;s=std::string(1000,'x');// s 重新分配了内存(旧缓冲已释放)use(v);// 💥 UB:旧指针已失效只要底层string做了重分配(reserve、append超过容量、赋值为更长的串等),
之前取出的string_view就可能全部失效 —— 类比vector迭代器失效。
6. 互相转换
std::string s1="hello";std::string_view v1=s1;// 隐式转换,O(1)std::string_view v2{s1.data(),3};// "hel"std::string s2{v1};// 显式,O(n) 拷贝std::string s3=std::string(v1);// 同上注意:string_view → string必须是显式的,因为它会分配内存,C++ 标准故意让你"看得见这个代价"。
7. 什么时候该用std::string?
并不是说string_view是银弹,下面这些场景老老实实用std::string:
- 需要修改字符串内容(
+= "..."、replace等)。 - 需要拥有字符串(存进容器、成员变量,要活过某个作用域)。
- 需要一个保证
\0结尾的 C 字符串给老 C API用。 - 字符串由函数内部构造并要返回出去(返回
string_view容易悬空)。 - 和
std::string比较 / 作为std::map<std::string, ...>的 key 时(除非 C++20 的异构查找搭好)。
8. 性能数据直观感受
下面的伪基准说明典型差距(具体数字因编译器、SSO、堆分配器而异,但数量级是稳定的):
调用一百万次 starts_with(文字): const std::string&,短串(SSO 命中,如 "hello world")→ ~8 ms (栈上 inline 拷贝 + 构造析构) const std::string&,长串(超出 SSO 触发 malloc) → ~30+ ms (每次都堆分配 + 释放) std::string_view → ~2 ms (仅传 2 个 word,零拷贝)可以看到string_view的优势有两个来源:
- 消除堆分配:长串场景下
malloc/free的成本,是string_view和const string&之间的主要差距。 - 消除隐式构造/析构:即使短串命中 SSO、不上堆,也依然有拷贝字节和析构的开销,
string_view完全没有。
在一次真实的解析类工作负载中(拆分日志行),把string/const string&全部换成string_view往往能带来2× ~ 5×的整体提速,同时让堆分配次数从"百万级"降到"接近零"。
9. 经验法则速查
| 场景 | 推荐类型 |
|---|---|
| 只读参数 | std::string_view |
| 需要修改的参数 | std::string& |
| 要接管 / 存起来的参数 | std::string(按值) |
| 返回字符串给调用方保存 | std::string |
| 返回对自身成员的只读视图(且文档说明) | std::string_view |
| 成员变量存字符串 | std::string |
| 成员变量存"对外部长寿字符串的引用" | std::string_view |
接 C API,要\0结尾 | std::string |
10. 和其它兄弟类型的关系
const char*:最老派的 C 风格字符串。长度靠strlen算 O(n),易越界。现代 C++ 基本可以被string_view全面取代。std::span<const char>:C++20,和string_view更像,但语义是"字节区间",没有字符串相关的find/substr/compare。字符串场景仍优先string_view。std::u8string_view/u16string_view/u32string_view:同样的机制,字符类型变为 UTF-8/16/32 的字符。- C++20 的
starts_with/ends_with/contains:string和string_view都加了这些成员函数,让前缀匹配终于可以一行搞定。
总结
std::string=字符串的所有者:深拷贝、可变、自管理内存、\0结尾。std::string_view=字符串的"借用"视图:浅引用、只读、不管理内存、不保证\0。- 能用
string_view当只读形参,就不要用const std::string&,这是现代 C++ 最容易拿到的性能红利之一。 - 但
string_view绝不是"免费午餐":它是个带指针的借用语义,所有悬空、失效的规则都适用。
一句话:要写入 / 要持有,用string;只读 / 只看,用string_view。
