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