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

C++ 中使用 std::string_view 的优势与注意事项

在现代 C++ 编程中,std::string_view 是一个非常有用且强大的工具。它可以提供高效、灵活的字符串操作能力,并减少临时对象的创建和内存分配。然而,在使用它时也需要注意一些潜在的风险。本文将详细介绍 std::string_view 的基本用法及其注意事项。

1. std::string_view 简介

头文件与声明

#include 
<string_view>

声明一个 std::string_view 变量非常简单:

std::string_view sv;

空的视图变量表示没有指向任何字符串。可以通过多种方式构造 std::string_view 实例:

  • 从 C 字符串
    std::string_view sv = "hello";
  • 指定字符序列及长度
    const char* ptr = "hello";
    std::string_view sv(ptr, 5);
  • 从 std::string
    std::string str = "hello";
    std::string_view sv = str;
  • 使用 C++17 字面量运算符
    using namespace std::literals;
    auto sv = "hello"sv;

2. std::string_view 常用操作

访问元素

sv[0]; // 不检查边界,可能会触发未定义行为
sv.at(0); // 安全但稍微慢一些
sv.front(); // 返回第一个字符
sv.back(); // 返回最后一个字符
sv.data(); // 返回 `const char*` 指针

容量信息

sv.size(); // 返回字符串长度
sv.length(); // 同 size()
sv.empty(); // 判断视图是否为空

子串操作

std::string_view substr = sv.substr(pos, count);

这里 substr 是一个新的 std::string_view 对象,指向原数据但不进行拷贝。

修改视图范围

sv.remove_prefix(n); // 移除前 n 个字符
sv.remove_suffix(n); // 移除后 n 个字符

查找操作

size_t pos = sv.find("lo");
size_t rpos = sv.rfind('o');
size_t first_of_pos = sv.find_first_of("aeiou");
size_t last_not_of_pos = sv.find_last_not_of(" ");

这些函数提供了标准字符串查找功能。

比较操作

bool cmp = (sv1 == sv2);
int res_cmp = sv1.compare(sv2); // 返回 -1, 0 或 1 表示三路比较结果

3. 转换为 std::string

为了获取拥有自身内存的字符串,可以显式构造:

std::string str(sv);

如果需要调用仅接受 const std::string& 的函数,则需将其转换:

void func(const std::string& s);
func(std::string(sv));

4. 注意事项

悬垂视图

当 std::string_view 观察的原始字符串被销毁时,视图就会变成悬垂指针。常见的悬垂情况包括:

  • 局部变量生命周期

    std::string_view get_view() {
        std::string temp = "I'll be gone soon";
        return temp;
    }
    auto sv = get_view(); // 返回值临时存储
  • 字符串扩容问题

    std::string str = "hello";
    std::string_view sv = str;
    str += " world"; // 可能会导致内存重新分配,视图失效
  • 容器内的悬垂风险

    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"); // 可能会导致内存重新分配,旧视图失效

总之,在使用 std::string_view 的时候应当确保它始终能够访问到其观察的原始字符串,并且避免在不安全的情况下将悬垂指针传递给其他函数。

通过遵循这些原则和提示,你可以充分利用 std::string_view 提供的强大功能来提高代码性能和灵活性。同时注意潜在的风险可以防止常见的内存错误和其他问题的发生。

4. 数据生命周期管理

使用 std::string_view 时需要特别注意数据的生命周期管理。尽管 std::string_view 只是一个视图,并不拥有底层数据的实际所有权,但它仍然会引用该数据并用于后续操作中。

例如,在以下代码示例中:

std::string str = "hello";
std::string_view view(str);
str.clear();

当 str.clear() 被调用时,str 的内容会被清空。但是由于 view 仍然持有对旧数据的指针引用,所以访问 view.size() 或者使用 view.substr() 等操作会返回不正确的结果。

具体来说:

  • Size 不变:即使原始字符串被清除了,std::string_view 中保存的数据长度不会改变。如上例中输出 view.size() 仍然为5。
  • 潜在的内存问题:如果后续继续使用 view,可能会导致未定义行为或读取无效数据。

因此,在持有 std::string_view 的同时,请确保底层字符串对象不会发生修改性操作(如清空、插入等),以避免不必要的麻烦和潜在的风险。

5. 避免隐式转换带来的性能开销

在使用 std::string_view 时,我们可能会不经意间引入一些额外的性能开销。这通常发生在将 const char* 转换为 std::string_view 的过程中,因为编译器会自动调用 strlen() 来确定字符串长度。

例如:

const char *ptr = get_data();
std::string_view view(ptr);

在这种情况下,构造 std::string_view 时需要执行一次 O(n) 时间复杂度的 strlen() 调用。如果指针指向的数据非常大,则会严重影响性能。

因此,应该直接提供字符串长度来避免不必要的开销:

const char *ptr = get_data();
size_t length = get_length(ptr);
std::string_view view(ptr, length);

此外,在将 const char* 转换为 std::string_view 的时候,注意不要误用隐式转换,这可能会导致不必要的性能损失:

void legacy(const std::string& str) { /* ... */ }

void modern(std::string_view sv) 
{
    legacy(sv); // 编译错误,需要显式类型转换
}

为了解决这个问题,我们在调用老接口时应该创建一个临时的 std::string 对象:

legacy(std::string(sv));

虽然这种方法会带来额外的内存分配和拷贝操作,但它确保了代码能够正常工作,并且是推荐的做法。

6. string_view 在哈希表中的使用

在设计基于哈希的数据结构时,如 std::unordered_map,我们可以利用 std::string_view 来实现零拷贝查找。但是需要注意的是,由于 std::string_view 不拥有底层数据的所有权,因此我们需要确保作为键的那些字符串字面量或实际对象在整个哈希表生命周期内保持有效。

例如:

std::unordered_map<std::string_view, std::string> cache;

void add_to_cache(const char* key) 
{
    // 键是临时 string 构造的视图,函数结束后 key 会失效
    cache[key] = "value";
}

为了避免这种问题,我们可以让哈希表直接使用 std::string 类型:

std::unordered_map<std::string, std::string> cache;
cache["key"] = "value"; // 确保键对象长期有效

当然,如果我们确实希望利用 std::string_view 来避免拷贝查找开销的话,可以通过自定义哈希函数和比较函数来实现:

#include 
<functional>

std::unordered_map<std::string, std::string,
    std::hash<std::string_view>, 
    std::equal_to<>> cache;

void add_to_cache(const char* key) 
{
    // 使用 string_view 进行查找,但存储的键仍然是拥有内存的 string
    cache[key] = "value";
}

这种设计确保了我们可以在不复制数据的情况下进行高效的哈希表操作。

7. const char * vs. std::string_view

在选择使用 const char* 还是 std::string_view 的时候,我们需要考虑到它们的特性和适用场景。以下是两者之间的一些关键区别:

信息携带量

  • **const char ***: 简单地指向字符串数据,并不包含任何额外的信息,如长度。
  • std::string_view: 包含指针和长度两个部分,在初始化时可以获取整个字符串的完整信息。

这意味着 std::string_view 可以更好地处理非零终止的数据,而不需要手动计算或传递长度参数。

便利性

  • **const char ***: 通常需要使用 C 库函数(例如 strlen()、strncmp() 等)进行操作。
  • std::string_view: 提供了丰富的成员方法(如 find()、substr() 和 compare()),简化和统一了字符串处理流程。

安全性

  • **const char ***: 对于越界访问没有内置的保护机制,需要开发者自行小心避免。
  • std::string_view: 通过成员函数可以减少错误操作的可能性,并且能够抛出异常来警告边界问题(如 at() 方法)。

性能

构造开销方面,const char* 相对更轻量级,因为它不需要额外的长度计算。然而,在后续的操作中,知道确切长度的 std::string_view 能够提供更快的 size() 和 substr() 等操作。

结论

std::string_view 是一个强大且灵活的数据结构,可以减少内存分配并简化字符串处理。但它的使用也伴随着一些注意事项和最佳实践(比如数据生命周期管理、避免隐式转换所引入的性能损失等)。通过理解这些差异并明智地选择何时何地使用它,我们可以充分利用 std::string_view 的优势,并在代码中实现更高的效率与安全性。

希望这篇文章能够帮助你更好地理解和应用 std::string_view。