CC++ & Algorithm

string_view轻量字符串视图

极难4
语言版本:通用
概述:学习一种不复制字符串就能读取其内容的“视野”工具,就像透过透明塑料片看文字,既快捷又不费纸。

用 string_view:像用手指指文字一样看字符串

什么是 string_view?为什么需要它?

在 C++ 中,处理字符串时经常要传递 std::stringconst char*。如果函数参数是 const std::string&,虽然避免了一次拷贝,但构造时仍可能触发隐式转换和临时对象。如果参数是 const char*,则丢失了长度信息,函数内部还需要调用 strlen 计算长度,效率低且不安全。

std::string_view(C++17 引入)是一种不拥有数据的只读字符串视图。它只保存两个东西:指向字符串首字符的指针长度。可以把它想象成一张透明的塑料片,你把它放在一段文字上,就能透过它看到文字的内容,但塑料片本身不复制文字。你还可以移动塑料片(取子串),裁剪出不同区域,依然不复制任何文字。

生活中的类比

  • 看告示牌:你站在告示牌前,指着上面的一段字让朋友读。朋友读的是原始告示牌上的字,你不需要把告示牌复印一份给他。
  • 用书签标段落:你有一本厚书,你让同学看第100页第3段。同学直接翻开书到那页,看到的还是书上的原文,不是你抄下来的副本。
  • 看手机截图:你拍了一张照片发给朋友,朋友看到的是一张复制图(占空间)。但如果直接让朋友看你的手机屏幕,他看到的只是你的屏幕(视图),没有复制图片。

string_view 就是“朋友看你的手机屏幕”这种方式——效率高,但不复制数据。

关键概念讲解

1. 构造与生命周期(最重要的坑)

string_view 可以从 std::stringconst char*、字符串字面量构造。构造时不会复制字符串字符,只是记录指针和长度。

#include <iostream>
#include <string>
#include <string_view>
using namespace std;

int main() {
    string str = "hello";
    string_view view(str);          // view 指向 str 的内部数据
    
    const char* cptr = "world";
    string_view view2(cptr);        // view2 指向常数字符串字面量
    
    string_view view3("temp");       // 直接从字面量构造
    
    // 注意:字面量在整个程序运行期间都有效,所以 view3 安全
    // 但 view 必须保证 str 在 view 使用期间存在
    return 0;
}

关键警告:如果源字符串被销毁,string_view 就成了“悬挂指针”(dangling pointer),访问会导致未定义行为(可能崩溃、乱码等)。这就像你指着告示牌,但告示牌被人搬走了,你的手指还在空中,朋友顺着你手指的方向看到的只是一堵墙。

string_view bad_view;
{
    string temp = "dangerous";
    bad_view = temp;   // bad_view 指向 temp 的内部
} // temp 销毁,bad_view 失效
// cout << bad_view;   // 危险!不要这么做

2. 常见操作:查看、裁剪、查找

string_view 提供了与 std::string 类似的大部分只读接口:

操作说明例子
size() / length()返回视图长度(字符数)sv.size()
empty()判断是否为空sv.empty()
data()返回底层字符指针(注意:不以'\0'结尾)sv.data()
front() / back()首/末字符sv.front()
substr(pos, count)返回子视图(不复制!)sv.substr(2, 3)
find(str, pos)查找子串位置sv.find("world")
starts_with() / ends_with() (C++20)判断前缀/后缀sv.starts_with("he")
remove_prefix(n)去掉前 n 个字符(修改视图本身)sv.remove_prefix(2)
remove_suffix(n)去掉后 n 个字符(修改视图本身)sv.remove_suffix(1)

注意:substr() 返回的是一个新的 string_view,依然指向原始数据的某一部分,不会有动态内存分配。这一点与 std::string::substr() 不同(后者会复制字符)。

3. 与 std::string 的对比

特性std::stringstd::string_view
拥有数据是,自己管理内存否,只观察别人的数据
内存分配可能动态分配(堆内存)无(只存指针+长度)
是否可修改可修改只读
构造与拷贝成本高(复制字符);引用传递成本低极低(只复制两个整数)
生命周期要求独立,不受外界影响必须保证原始数据在视图有效期内存在
适用场景需要修改、拥有、长期存储作为函数参数、临时查看、子串处理

4. 作为函数参数的优越性

传统上函数参数用 const std::string&,但如果你传入一个字符串字面量或 const char*,会隐式构造一个临时 string(需要分配内存复制字符)。而用 std::string_view 则完全避免这次构造。

void process(const string& str);  // 如果传字面量,会复制一次
void process(string_view sv);     // 不会复制,只是记录指针和长度

因此,在不需要修改字符串且只在函数内部临时使用的场景下,string_view 通常比 const string& 更高效。但它也有代价:调用者要保证字符串在函数调用期间有效,而 const string& 会延长临时对象的生命周期。权衡后,对于长期存储或跨线程传递,还是用 string 更安全。

新手容易犯的错误

❌ 错误 1:返回 string_view 指向局部字符串

string_view get_greeting() {
    string s = "Hi";
    return s;   // 返回后 s 销毁,view 悬挂
}
// 调用处使用返回的 view 会导致未定义行为

正确做法:要么返回 std::string,要么确保数据是静态或全局的,比如返回字面量的视图:return "Hi";(字面量是静态存储期)。

❌ 错误 2:假设 data() 返回以 '\0' 结尾的字符串

string_view 不保证底层数据以空字符结尾。如果将该指针传给 C 风格函数(如 printf("%s", sv.data())),可能输出过长或乱码。更好的做法是用 sv.begin()sv.end() 遍历,或构造临时 std::string 再传。

string_view sv = "hello"; // 字面量本身以 '\0' 结尾,但这是巧合
string_view sv2 = sv.substr(0, 3); // "hel" 可能不以 '\0' 结尾
printf("%s", sv2.data()); // 危险!可能输出 "hel" 之后随机内容

安全方式

cout << sv2; // 用 operator<< 重载,它知道长度
// 或
string str(sv2); // 复制一份,保证以 '\0' 结尾
printf("%s", str.c_str());

❌ 错误 3:混淆 remove_prefixsubstr

remove_prefix(n) 修改视图本身,使其指向从第 n 个字符开始的位置;而 substr(pos, count) 返回一个新视图,不修改原视图。初学者可能误以为 remove_prefix 会修改底层数据。

string_view sv = "abcdef";
sv.remove_prefix(2);  // sv 现在指向 "cdef"
// 原字符串 "abcdef" 没有变化

完整可运行示例

下面是一个综合示例,展示 string_view 的各种用法,以及如何安全处理:

#include <iostream>
#include <string>
#include <string_view>
#include <vector>
using namespace std;

// 函数接收 string_view,不拷贝传入的字符串
void describe(string_view sv) {
    cout << "视图内容: \"" << sv << "\"\n";
    cout << "长度: " << sv.size() << ", 是否为空: " << boolalpha << sv.empty() << "\n";
}

// 返回视图指向静态字面量(安全)
string_view get_motto() {
    return "Practice makes perfect!";
}

int main() {
    // 1. 从不同来源构造
    string str = "C++17 string_view";
    string_view sv_from_string = str;                // 从 string
    string_view sv_from_cstr = "Hello, world!";       // 从字面量
    string_view sv_from_ptr = sv_from_cstr.data();    // 从指针(但不推荐,因丢失长度)
    
    describe(sv_from_string);
    
    // 2. 取子视图(零拷贝)
    string_view sub = sv_from_cstr.substr(7, 5);      // "world"
    cout << "子视图 (7,5): \"" << sub << "\"\n";
    
    // 3. 查找
    size_t pos = sv_from_cstr.find("world");
    if (pos != string_view::npos) {
        cout << "找到 \"world\" 在位置 " << pos << "\n";
    }
    
    // 4. 比较
    string_view sv_a = "apple";
    string_view sv_b = "banana";
    if (sv_a < sv_b) {
        cout << sv_a << " < " << sv_b << "\n";
    }
    
    // 5. 修改视图本身(只修改视角,不修改底层)
    string_view sv_trim = "   extra spaces   ";
    // 去掉前后空格(示例:手动移除前导空格)
    while (!sv_trim.empty() && sv_trim.front() == ' ') {
        sv_trim.remove_prefix(1);
    }
    while (!sv_trim.empty() && sv_trim.back() == ' ') {
        sv_trim.remove_suffix(1);
    }
    cout << "去掉空格后: \"" << sv_trim << "\"\n";
    
    // 6. 获取安全 C 风格字符串(如果需要)
    string safe_string(sv_trim);  // 复制一份,保证 '\0' 结尾
    cout << "C风格: " << safe_string.c_str() << "\n";
    
    // 7. 返回静态视图
    string_view motto = get_motto();
    cout << "格言: " << motto << "\n";
    
    // 8. 与 vector 结合:存储多个视图(但必须保证原始字符串存在)
    vector<string> names = {"Alice", "Bob", "Charlie"};
    vector<string_view> views;
    for (const auto& name : names) {
        views.push_back(name);   // views 指向 names 中的字符串
    }
    // 只要 names 不被销毁,views 安全
    for (auto v : views) {
        cout << v << " ";
    }
    cout << "\n";
    
    // 9. 错误演示(注释掉)
    // string_view danger;
    // {
    //     string temp = "bad";
    //     danger = temp;
    // }
    // cout << danger; // 未定义行为,不要运行
    
    return 0;
}

Python 中的等价思路

虽然 C++ 的 string_view 没有直接对应的 Python 原生类型,但 Python 字符串是不可变的,传递字符串只是传递引用,不会复制字符。但 Python 的切片(如 s[7:12])会复制子串内容。如果处理大字符串且想避免复制,可以使用 memoryview(适用于字节串)或自定义类(如示例中给出的 StringView)。

对于学习 C++ 概念的中小学生,理解“视图”比“复制”更高效的思想更重要。Python 的 memoryview 类似一个字节视图,但主要针对 bytesbytearray

# Python 简单模拟 StringView 类(已在原文中给出,此处不再重复)

实际开发中,如果你在 Python 中处理超长字符串且需要频繁取子串,尽量用索引和偏移量来标记范围,而不是每次切片复制。例如:

text = "A very long string..." * 100000
start, end = 7, 12
# 避免 text[start:end] 复制,而是每次需要时再切片
def get_sub(source, s, e):
    return source[s:e]   # 注意:每次调用还是会复制

# 更好的方式是引入自定义类存储引用和范围,就像 C++ 的 string_view

总结与相关指引

  • std::string_view 是 C++17 引入的轻量、只读、非拥有字符串视图。
  • 用它可避免不必要的字符串拷贝,提升性能,尤其适合函数参数传递和子串处理。
  • 核心原则:确保视图的生命周期不超过原始数据。避免返回视图指向局部变量。
  • 不要假设 data() 返回 '\0' 结尾,需要 C 风格字符串时用 std::string(sv).c_str()
  • 可以使用 remove_prefix() / remove_suffix() 修改视图范围,而不是复制子串。

相关知识点链接

  • std::string 的基本操作与动态内存管理
  • const char* 与 C 风格字符串的陷阱
  • C++17 的其他实用特性:std::optional, std::variant
  • 智能指针 std::unique_ptrstd::shared_ptr 的“拥有”语义对比
  • 高效字符串处理:std::stringstreamfmtlib 格式化库

通过 string_view,你就像戴了一副“文字透视眼镜”,能轻松看到字符串的任意部分,却不占用额外纸张(内存)。记住它的使用规则,就能在 C++ 编程中既快又安全地处理字符串。

例题精讲

1单选题

以下关于std::string_view的用法中,哪一项可能导致未定义行为?

Astd::string_view sv = "hello";
Bstd::string_view sv = std::string("hello");
Cstd::string_view sv("hello", 5);
Dstd::string_view sv = std::string_view("hello");
2判断题

std::string_view的substr()成员函数会分配新的内存来存储子字符串。

3填空题
使用std::string_view实现一个函数,判断字符串str是否以prefix开头。请补全代码:

bool starts_with(std::string_view str, std::string_view prefix) {
    return str.___(0, prefix.size()) == prefix;
}
4单选题

关于std::string_view的remove_prefix和remove_suffix成员函数,以下描述正确的是:

A它们会修改原始字符串的内容
B它们会修改string_view内部的指针和长度,但不会修改底层数据
C它们返回一个新的string_view对象,原视图不变
D它们只能在非常量string_view对象上调用
5填空题
给定一个const char*指针ptr和长度len,创建一个std::string_view对象,并遍历输出每个字符。补全代码:

const char* ptr = "Hello";
size_t len = 5;
std::string_view sv(ptr, len);
for (char c : sv) {
    std::cout << c;
}
// 但这里缺少了???的声明,请问?处应填什么?
// 提示:利用了string_view的___函数?
6填空题
请使用std::string_view实现一个函数,将字符串中的前导空白字符(空格、制表符、换行符)去除,并返回新的视图。函数签名:std::string_view trim_left(std::string_view sv)。补全代码中的空白:

std::string_view trim_left(std::string_view sv) {
    while (!sv.empty() && (sv[0] == ' ' || sv[0] == '\t' || sv[0] == '\n')) {
        sv.___(1);
    }
    return sv;
}