C++std::string_view不是字符串:从悬空引用到安全用法
摘要:
std::string_view能减少不必要的字符串复制,但它不拥有字符数据。只要底层字符串被销毁、重新分配或修改,视图就可能失效。本文通过可编译示例解释它的对象模型、常见悬空场景、接口设计原则,以及什么时候应该改用std::string。
std::string_view是 C++17 引入的只读字符串视图。它通常可以理解为一对数据:指向字符序列开头的指针,以及字符序列的长度。
它不会分配内存,也不会复制字符。这个特点让它适合只读参数、解析器和文本切片,但也带来一个核心约束:std::string_view的生命周期不能超过底层字符数据。
先看对象模型
下面这段程序可以在支持 C++17 的编译器中运行:
#include<iostream>#include<string>#include<string_view>intmain(){std::string text="hello, string_view";std::string_view view=text;std::cout<<"text.data(): "<<static_cast<constvoid*>(text.data())<<'\n';std::cout<<"view.data(): "<<static_cast<constvoid*>(view.data())<<'\n';std::cout<<"view: "<<view<<'\n';}一种编译方式如下:
g++-std=c++17-Wall-Wextra-Wpedanticdemo.cpp-odemo ./demo在同一次运行中,text.data()与view.data()通常指向同一段字符数据。view只是观察text,并没有保存一份副本。
这也解释了为什么复制std::string_view很便宜:复制的是指针和长度,而不是整段字符串。
危险场景一:返回局部字符串的视图
下面的代码可以通过编译,但返回值在函数结束时就已经悬空:
#include<string>#include<string_view>std::string_viewmake_message(){std::string message="temporary message";returnmessage;}message在make_message返回时被销毁,视图里保存的指针不再指向有效字符序列。之后读取这个视图属于未定义行为。
不能通过“调试时还能打印出来”判断代码安全。未定义行为可能暂时看似正常,也可能随着编译器版本、优化级别或周围代码变化而暴露。
如果调用者需要拥有返回内容,应返回std::string:
std::stringmake_message(){return"temporary message";}现代编译器可以通过返回值优化或移动语义减少多余复制。为了避免一次未经测量的复制而返回悬空视图,通常得不偿失。
危险场景二:从临时对象构造
这段代码同样存在问题:
std::string_view view=std::string("temporary");右侧临时std::string在完整表达式结束时被销毁,下一条语句开始时,view就已经悬空。
但字符串字面量的情况不同:
std::string_view view="static storage";字符串字面量具有静态存储期,程序运行期间一直存在,因此视图不会因为离开当前作用域而失效。关键不是“右侧看起来像字符串”,而是字符数据由谁拥有、能活多久。
危险场景三:底层字符串重新分配
即使原始std::string仍在作用域内,视图也不一定始终有效:
#include<iostream>#include<string>#include<string_view>intmain(){std::string text="abc";std::string_view view=text;text+=std::string(10'000,'x');// 不要再读取 view:text 的扩容可能已经使原指针失效。std::cout<<"new size: "<<text.size()<<'\n';}std::string扩容时可能申请新的存储空间,并把字符移动过去。此时旧视图仍保存原地址,却无法知道地址已经失效。
还需要警惕这些操作:
- 对底层字符串执行可能触发重新分配的追加、插入和赋值;
- 移动、交换或销毁底层字符串;
- 对容器进行可能使元素或其内部存储失效的操作;
- 把视图保存到比所有者寿命更长的对象中。
标准库对不同操作的失效规则有细节差异。设计代码时,不应依赖某次运行中容量“刚好够用”。
作为函数参数时为什么好用
只读函数通常可以接受std::string_view:
#include<iostream>#include<string>#include<string_view>voidprint_name(std::string_view name){std::cout<<name<<'\n';}intmain(){print_name("Alice");std::string user="Bob";print_name(user);}这个接口可以接收字符串字面量、std::string和其他连续字符序列的视图,函数内部也不需要复制内容。前提是函数只在调用期间使用视图,不把它保存到更长寿命的位置。
下面这个类的设计就需要额外约束:
classUserView{public:explicitUserView(std::string_view name):name_(name){}private:std::string_view name_;};如果构造参数来自局部std::string,UserView可能比字符串活得更久。除非能严格保证外部所有者的生命周期,否则成员字段更适合使用std::string:
classUser{public:explicitUser(std::string name):name_(std::move(name)){}private:std::string name_;};substr的返回类型容易混淆
std::string::substr和std::string_view::substr名字相同,语义却不同:
#include<string>#include<string_view>#include<type_traits>intmain(){std::string text="abcdef";std::string_view view=text;autoowned=text.substr(1,3);autoborrowed=view.substr(1,3);static_assert(std::is_same_v<decltype(owned),std::string>);static_assert(std::is_same_v<decltype(borrowed),std::string_view>);}前者创建拥有字符数据的std::string,后者只创建原字符序列的另一个窗口。borrowed的有效期仍受text约束。
不保证以\0结尾
std::string_view表示“指针加长度”,视图末尾不一定有空字符:
std::string text="hello world";std::string_viewword(text.data(),5);把word.data()直接传给只接受 C 风格字符串的接口,可能导致读取越过视图边界。若接口支持长度,应该同时传递长度:
#include<cstdio>std::printf("%.*s",static_cast<int>(word.size()),word.data());如果目标接口只接受以\0结尾的字符串,则需要创建拥有内容的std::string:
std::stringowned(word);legacy_api(owned.c_str());一个实用的接口选择表
| 需求 | 更合适的类型 | 原因 |
|---|---|---|
| 函数只读参数,调用期间使用 | std::string_view | 避免复制,可接收多种字符串来源 |
| 函数需要修改自己的副本 | std::string | 明确拥有数据 |
| 返回新生成的文本 | std::string | 返回值需要独立生命周期 |
| 返回输入参数的切片 | std::string_view | 可以零复制,但必须说明依赖输入寿命 |
| 类需要长期保存文本 | 通常是std::string | 避免外部生命周期耦合 |
| 调用只接受 C 字符串的旧接口 | std::string或长度感知接口 | 视图不保证空字符结尾 |
如何借助工具发现问题
编译器警告不能发现所有生命周期错误,但应该先打开基础警告:
g++-std=c++20-Wall-Wextra-Wpedantic-Wconversiondemo.cpp-odemoAddressSanitizer 有时能捕获已经发生的越界或释放后使用:
g++-std=c++20-O1-g-fsanitize=address,undefined demo.cpp-odemo ./demo不过,工具没有报错不代表视图一定安全。某些悬空指针仍可能指向尚未被覆盖的内存。代码审查时仍要明确回答两个问题:
- 这段字符数据由谁拥有?
- 所有使用视图的地方,是否都早于所有者失效?
结论
std::string_view的价值不是“比std::string更高级”,而是让接口表达借用一段只读字符序列。
当生命周期关系清晰时,它能减少复制并让接口更通用;当生命周期不清晰时,它会把原本由类型管理的问题变成调用者必须自行证明的约束。
可以记住一条简单规则:短期观察用std::string_view,需要保存或返回新数据时用std::string。性能优化应该建立在正确的所有权模型和实际测量之上。