C++17 string_view 观察报告:好用,但有点费命

春天到了,又到了 C++ 程序员传参的季节。

在广袤的堆内存上,一种名为 std::string 的物种正缓慢地进行着分配仪式。

突然,镜头里闪过一道残影,哦吼吼~std::string_view 出现了。

它没有实体,不留后代,不筑巢穴。

它只是看了一眼 string 的领地,然后迅速地在函数调用栈上留下一个已阅的爪印。

这是生存方式虽然高效,但如果宿主突然死亡,观察者将迅速触发未定义行为哟。


为什么需要 string_view

我们写 C++ 久了,可能会有这种情况:一个函数明明只想看一眼字符串,结果一看调用栈,好家伙,堆分配跟放鞭炮似的噼里啪啦。

1. 传统字符串传递的痛

以前我们传字符串,无非两种姿势:

c++ 复制代码
void foo(const std::string& str); // 常量引用
void bar(std::string str); // 按值拷贝(是个狠人)

第一个看起来挺聪明:用 const &,不拷贝了吧?

但有个坑:调用方如果传的是字符串字面量 "hello",类型是 const char*,不是 std::string。

编译器一看参数类型对不上,立马在后台偷偷帮我们 new 了一个临时 std::string 对象,把 "hello" 拷进去,传完再析构掉。

我们本意是省拷贝,结果反倒因为隐式转换被坑了一次堆分配。

第二个姿势 std::string str 就更别说了,明牌拷贝,实打实的堆分配。

2. 临时对象与拷贝

这个更别说了,我们以为没开销?其实编译器在背后偷笑。

这问题在子串操作上尤其明显,比如我们要从一大段文本里抠点东西:

c++ 复制代码
std::string extract_fields(const std::string& s) 
{
    return s.substr(0, 5); // 返回的是新 string,又一次堆分配 + 拷贝
}

如果我们在一个循环里做解析,比如处理几万行日志,每行拆个时间戳、IP、状态码,substr 每调一次就分配一次。

最后我们的火焰图上,malloc 和 free 两个玩意肩并肩占了一大半的 CPU。

更隐蔽的是比较操作,我们写:

c++ 复制代码
if (some_string == "error") { ... }

右边的 "error" 是个字面量,如果没有针对const char*的重载,编译器会考虑隐式构造成临时 std::string 才跟左边比较。

一次比较,一次分配。你品,你细品,一比一个不吱声。

3. 解决方案

这时候就需要 std::string_view 出来救场了。

它就一句话:

我不生产数据,我只是数据的搬运工的......小眼睛。

它的内存布局就两个东西:

  • 一个 const char* 指针,指向别人的数据开头。
  • 一个 size_t 长度,记住数据有多大。

没有堆分配,没有引用计数,没有析构责任,就是一双小眼睛(°∀°)。

先看个对比:

场景 std::string std::string_view
传参 "hello" 临时对象 → 堆分配 → 拷贝 → 析构 指针指向字面量,长度记 5。
取子串 新 string → 堆分配 + 拷贝 新 string_view,指针挪一下,长度改一下。
比较字面量 隐式构造临时 string 直接逐字节比较,零分配

代码对比:

c++ 复制代码
// 老办法:可能触发隐式构造
void process_old(const std::string& str);

// 新办法:字面量、string、const char* 通吃,零拷贝
void process_new(std::string_view sv) 
{
    // 只读访问 sv[0]、sv.find(...)、sv.substr(...) 全都不分配
}

process_new("hello"); // 直接绑定字面量,零分配
process_new(some_string); // 直接借用 string 内部数据,零分配
process_new(some_char_ptr); // 直接指向,零分配(只要保证 ptr 活着)

子串操作更不得了了:

c++ 复制代码
std::string str = "6202-04-15 22:23:10 ERROR";
// 老办法:分配三个新 string
auto date = str.substr(0, 10);
auto time = str.substr(11, 8);
auto level = str.substr(20, 5);

// 用 string_view
std::string_view sv = str;
auto date_sv = sv.substr(0, 10); // 还是指向原 str 内部
auto time_sv = sv.substr(11, 8); // 只是指针偏移
auto level_sv = sv.substr(20, 5); // 长度不同而已

string_view 版本几乎是瞬发,因为它什么都不干,就挪了挪指针。

但先别急着用,凡事都有代价的

string_view 有两条规则,违反任意一条当场进 ICU:

  • 它不拥有数据。所以它观察的那个原始字符串必须比它活得长。我们把它存进容器,原始 string 却先析构了,悬垂指针又来咯。
  • 它不以 \0 结尾。data() 返回的东西别直接丢给 printf、fopen 这些指望 \0 的 C 函数。我们得用 sv.size() 控制长度,或者临时包一层 std::string(sv)。

string_view 用好了,代码又快又干净;用飘了,调试器里看堆栈看到怀疑人生。


基本用法

用法和 string 差不多,还是介绍一下吧。

1. 头文件与声明

c++ 复制代码
#include <string_view> // 没它别想上车

声明一个 std::string_view 跟声明个 int 差不多轻量:

c++ 复制代码
std::string_view sv; // 空的,data() == nullptr, size() == 0

空视图啥也干不了,但它是合法的,相当于我们瞪着俩眼珠子往虚空里瞅,没猫病,只要我们别真去 sv[0]。

2. 构造方式

string_view 的构造函数来者不拒,只要我们能提供一段连续的字符序列,它就能上。

我们给它什么 怎么构造 备注
const char* std::string_view sv = "hello"; 长度自动用 strlen 算。
const char* + 长度 std::string_view sv(ptr, 5); 避免 strlen 扫描。
std::string std::string_view sv = str; 隐式转换,直接借用 str 的内部数据和长度,零开销。
另一个 string_view std::string_view sv2 = sv1; 浅拷贝,两个视图盯同一块地方。
字面量运算符 sv using namespace std::literals; auto sv = "hello"sv; C++17 提供,直接生成 string_view,长度编译期确定,零开销。

一个小坑: std::string_view sv = "hello"; 这行代码背后调了 strlen("hello") 来确定长度。对于字面量编译器通常能优化成常量 5,但如果我们传的是一个 const char* 变量,它真会跑去 strlen 遍历一遍。

如果就是字面量,用 "hello"sv 最干净,编译期长度,连 strlen 优化的脑细胞都省了。

3. 常用操作

string_view 的接口是 std::string 的只读子集,我们熟悉的那些不用改内容的操作基本都有。

访问元素

c++ 复制代码
sv[0]; // 不检查边界,跟数组一样野
sv.at(0); // 安全但稍慢
sv.front(); // 第一个字符
sv.back(); // 最后一个字符
sv.data(); // 返回 const char*,但不以 \0 结尾

容量

c++ 复制代码
sv.size(); // 长度,O(1)
sv.length(); // 同上,纯粹为了跟 string 对齐
sv.empty(); // 判断是否为空

子串

c++ 复制代码
sv.substr(pos, count); // 返回新的 string_view,不拷贝数据

这是 string_view 最香的功能,之前也用过了。

修改视图本身(不是内容)

c++ 复制代码
sv.remove_prefix(n); // 把视图头部砍掉 n 个字符
sv.remove_suffix(n); // 把视图尾部砍掉 n 个字符

这俩函数只改了视图本身的范围,但没动原数据。

查找:跟 string 一个味

c++ 复制代码
sv.find("lo"); // 找子串,返回位置索引
sv.rfind('o'); // 反向找
sv.find_first_of("aeiou");
sv.find_last_not_of(" ");
// ... find 全家桶都有

比较:直接比内容

c++ 复制代码
sv1 == sv2; // 内容相同就 true
sv1.compare(sv2); // 三路比较

我们可以拿 string_view 跟 string、const char*、另一个 string_view 直接比较。

4. 转换为 std::string

虽然 string_view 能帮我们省掉很多临时对象,但有些场景我们必须拿到一个拥有内存的 std::string,比如要存进容器、返回给调用者、或者需要修改内容。

转换方式简单粗暴:显式构造

c++ 复制代码
std::string_view sv = "temporary view";
std::string str(sv); // 直接构造,会拷贝数据
std::string str2 = std::string(sv); // 同上

string_view 没有隐式转换成 std::string 的能力。

这是故意的,防止我们无意间触发堆分配,需要明明白白地写 std::string(sv),表示"我认了,我要拷贝"。

如果我们想把 string_view 传给一个只接受 const std::string& 的老函数?不好意思,不行。

我们得临时造一个:

c++ 复制代码
void old_func(const std::string& s);

old_func(std::string(sv)); // 产生临时对象,函数结束后析构

要是能把老接口参数直接改成 std::string_view 那最好不过了;如果改不了,那就在调用链最上层尽早转成 string,别在循环里反复转。


一些注意的点

我知道你很急,但你先别急,因为我要去泡杯茶喝(๑¯∀¯๑)。

1. 悬垂 string_view

经典咏流传了属于是,这些悬垂问题。

死因:string_view 不拥有数据,它观察的数据没了,它还指着那儿

来看几个经典作案现场:

第一集:半空中飘荡的亡魂

c++ 复制代码
std::string_view get_view() 
{
    std::string temp = "I'll be gone soon";
    return temp; // temp 隐式转成 string_view,然后 temp 析构了
}

int main() 
{
    auto sv = get_view();
    std::cout << sv; // 悬垂
}

编译通过,运行可能打印乱码,可能崩,可能恰好打印出来让你以为没问题,未定义行为最恶心的形态。

第二集:string 的背刺

c++ 复制代码
std::string str = "hello";
std::string_view sv = str;
str += " world, this is a very long string that might reallocate";
std::cout << sv; // 如果 str 扩容重新分配了内存,sv 指向旧地址

std::string 内部有一个小字符串优化(SSO),短字符串存在栈上,长字符串存堆上。

当 string 从短变长触发扩容时,它会 new 一块新内存把数据搬过去,旧内存被释放。我们的 sv 还傻傻指着旧址。

第三集:容器的定时炸弹

c++ 复制代码
std::vector<std::string> words = {"hello", "world"};
std::vector<std::string_view> views;
for (const auto& w : words) 
{
    views.push_back(w); // 视图指向 vector 内元素
}
words.push_back("oops"); // vector 扩容,所有元素可能搬家,旧内存失效
// 哦豁,views 里所有东西全变悬垂

家人们,谁懂啊,今天看到一个虾头男,好像叫什么 string_view,以下是它的避雷指南:

  • 永远不要让 string_view 活得比它观察的字符串长。
  • 函数返回类型慎用 string_view,除非我们返回的是静态字面量、全局常量、或者明确生命周期很长的数据。
  • 如果必须存视图,确保原始 string 不会发生扩容或析构,或者干脆拷成 string 再存。
  • 把 string_view 当成一个长得像值的指针,用指针时怎么防悬垂,用 string_view 同理。

2. 空终止符的假设

在之前我们提了一嘴,现在详细讲讲。

string_view 有一个著名的坑爹特性:data() 返回的指针不以 \0 结尾。

它只保证 [data(), data() + size()] 范围内是合法字符,超出 size() 的字节爱是啥是啥。

这意味着以下代码全是高危操作:

c++ 复制代码
void log(std::string_view sv) 
{
    printf("%s\n", sv.data()); // 如果 sv 没有 \0,printf 会一直读到碰见 \0 为止
}

FILE* f = fopen(sv.data(), "r"); // 同上,文件名可能被读飞

正确姿势:

c++ 复制代码
// 方式一:用 string_view 专属打印
std::cout << sv;  // 安全,按 size() 控制长度

// 方式二:用 printf 的精度控制
printf("%.*s\n", static_cast<int>(sv.size()), sv.data());

// 方式三:临时构造 string(明确拷贝)
std::string tmp(sv);
printf("%s\n", tmp.c_str());

为什么 string_view 不保证 \0?

因为它的设计目标之一就是能观察非 \0 结尾的二进制数据片段(比如网络包 payload、文件映射视图)。

如果强制要求 \0,它就没法高效覆盖这些场景了,代价就是我们跟 C 接口交互时得多写一行代码。

3. 隐式转换导致的性能问题

string_view 被发明出来是为了分配,但用在不对的地方,它就会偷偷帮我们把省下来的通通花掉。

3.1 从 const char 构造时的 strlen*

c++ 复制代码
const char* ptr = get_some_c_string(); // 假设长度已知
std::string_view sv = ptr; // 这里调用了 strlen(ptr) 来确定长度

如果我们的 ptr 指向一个很长的字符串,strlen 就是一次 O(n) 扫描。

如果 ptr 压根没有 \0,那就直接越界读到天荒地老。

解决方案:如果我们知道长度,那就传长度。

c++ 复制代码
std::string_view sv(ptr, known_length);

3.2 老接口适配时的临时对象

这里之前介绍过,但还是得提一下:

c++ 复制代码
void legacy(const std::string& s);

void modern(std::string_view sv) 
{
    legacy(sv);  // 编译错误,不能隐式转换
    legacy(std::string(sv)); // 这样写:临时分配 + 拷贝,用了再扔
}

我们原本用 string_view 是为了零拷贝,结果为了兼容老接口反而多了一次临时构造。

这口黑锅不该给 string_view 背,虽然有时候它容易让人产生"用了 string_view 性能一定好"的错觉。

4. 修改底层数据

string_view 是只读视图,它不能修改内容,但这不代表底层数据不能被别人修改。

如果有人改了原始字符串,string_view 会毫不知情地继续指向新内容,这可能正是我们想要的,也可能是个隐蔽 bug。

c++ 复制代码
std::string str = "hello";
std::string_view sv = str;
str[0] = 'H'; // 修改底层
std::cout << sv << std::endl; // 输出 "Hello",视图跟着变了,这是预期行为

str.clear(); // 底层内容被清空,长度归零
std::cout << sv.size() << std::endl; // 还是 5 哟,size 没跟着变
std::cout << sv << std::endl; // 打印出被清空前的残留内容或乱码

string_view 在构造时快照了当时的长度,后续底层 string 的长度变化它感知不到。如果底层 string 变短了,string_view 会包含已释放或无效的内存区域。

所以当我们持有 string_view 时,别让底层数据发生破坏性修改。

5. string_view 作为哈希键

有时候我们想用 string_view 作为 unordered_map 的键,实现零拷贝查找。

标准库很贴心地提供了 std::hashstd::string_view 特化,所以我们可以写:

c++ 复制代码
std::unordered_map<std::string_view, int> map;

但是啊但是,键是 string_view,它不拥有数据。

这意味着我们必须确保作为键的那些字符串字面量或 string 对象在整个 map 存活期间一直有效。

c++ 复制代码
std::unordered_map<std::string_view, int> cache;

void add_to_cache(std::string key) 
{
    cache[key] = 21; // key 是临时 string 构造的视图,函数结束 key 析构
}

// 还是让 map 拥有数据方便
std::unordered_map<std::string, int> cache;  // 省心

当然了,如果我们真的想用 string_view 做键来避免拷贝查找开销,也是有办法滴。

那就是使用 std::equal_to 进行比较两个对象是否相等:

c++ 复制代码
std::unordered_map<std::string, int, std::hash<std::string_view>, std::equal_to<>> cache;
// 现在它会用 string_view 做查找,而存储的键仍然是拥有内存的 string
auto it = cache.find("key"); // 零拷贝查找

解释:

  • std::hashstd::string_view:我们调用 cache.find("key") 时,"key" 会被隐式转换为 std::string_view,然后计算哈希值。
  • std::equal_to<>:它允许比较 std::string 与任意可比较的类型,只要 == 运算符支持即可。

与 const char* 的对比

string_view 算是 const char* 的平替,所以我们来看看它到底与 const char* 有哪些不同的地方。

1. 信息携带量

const char* 本质上就是一个地址,它自己不知道指向的字符串有多长。

每次我们需要长度的时候,要么调用 strlen,要么靠另一个参数传递长度。

string_view 把指针和长度打包成一个对象,自包含、自描述,我们拿着它,随时知道边界在哪。

c++ 复制代码
void process_sv(std::string_view sv); // 一个参数,指针和长度都在里面

这就意味着 string_view 可以安全地处理非 \0 结尾的数据

2. 便利性

string_view 自带全套 find、substr、compare 成员函数,用起来跟 std::string 一个体验。

const char* 呢?我们得调用 C 标准库的 strstr、strncmp、strchr......这些函数:

  • 名字不统一
  • 很多需要手动管理 \0
  • 容易写出越界 bug
c++ 复制代码
// const char* 取子串
const char* str = "hello world";
char sub[6];
strncpy(sub, str, 5);
sub[5] = '\0';

// string_view
std::string_view sv = "hello world";
auto sub_sv = sv.substr(0, 5); // 干净利落

3. 安全性

大哥不说二弟,string_view 在这方面好一点,但有限:

  • 它不能解引用 nullptr:默认构造 string_view 的 data() 是 nullptr,operator[] 会崩。
  • 它有边界检查版本:sv.at(pos) 越界会抛异常。
  • 它仍然会悬垂:这一点跟指针一个毛病,因为其内部就是个指针。

不过,string_view 通过成员函数封装降低了我们手滑的概率,因为访问都用 sv.front()、sv.back() 或者 operator[] 自动限制在长度内。

4. 性能

构造开销:

c++ 复制代码
const char* ptr = "hello"; // 纯赋值
std::string_view sv = "hello"; // 隐式调用 strlen 确定长度

这是 const char* 唯一可能比 string_view 快的地方,毕竟它压根不记录长度,所以构造时什么也不干。

但后面的所有操作,string_view 都因为知道长度而更快:size() 是 O(1),substr 是 O(1),比较时有长度可以提前退出。

const char* 干同样的事必须 strlen 或者传长度参数。


结尾

std::string_view 这东西,本质上就是个带脑子的 const char*。

它知道自己几斤几两(长度),也知道自己不该干什么(修改)。

行了,就这样吧,std::string_view 是个好东西,希望我们能够驾驭它。

相关推荐
努力努力再努力wz3 小时前
【Linux网络系列】深入理解 I/O 多路复用:从 select 痛点到 poll 高并发服务器落地,基于 Poll、智能指针与非阻塞 I/O与线程池手写一个高性能 HTTP 服务器!(附源码)
java·linux·运维·服务器·c语言·c++·python
努力努力再努力wz3 小时前
【Linux网络系列】万字硬核解析网络层核心:IP协议到IP 分片重组、NAT技术及 RIP/OSPF 动态路由全景
java·linux·运维·服务器·数据结构·c++·python
minji...3 小时前
Linux 线程同步与互斥(四) POSIX信号量,基于环形队列的生产者消费者模型
linux·运维·服务器·c语言·开发语言·c++
王老师青少年编程3 小时前
csp信奥赛C++高频考点专项训练之贪心算法 --【排序贪心】:拼数
c++·算法·贪心·csp·信奥赛·排序贪心·拼数
程序猿编码4 小时前
给Linux程序穿“隐身衣”——ELF运行时加密器全解析(C/C++代码实现)
linux·c语言·c++·网络安全·elf·内存安全
John_ToDebug4 小时前
从 Win10 到 Win11 22H2+:任务栏美化中的“蒙版”和“Hover 色块”渲染原理解析
c++·chrome·windows
谭欣辰4 小时前
AC自动机:多模式匹配的高效利器
数据结构·c++·算法
三月微暖寻春笋5 小时前
【和春笋一起学C++】(六十三)虚函数特性(二)
c++·基类·派生类·虚函数特性
历程里程碑5 小时前
MySQL事务深度解析:ACID到MVCC实战+万字长文解析
开发语言·数据结构·数据库·c++·sql·mysql·排序算法