Posted in

字符串视图

在某些较旧的 C++ 版本中,字符串处理可能会很麻烦。随着人们意识到 C++ 需要更强大的字符串处理能力,这一问题在 C++17 中得到了关注,并引入了 std::string_view

std::string

在深入探讨 std::string_view 重要性之前,我们需要先了解 std::string 的功能。std::string 本质上是 char*char[] 的一个友好封装。它允许我们存储连续分配的字符内存,并对其进行修改、迭代,最终用于显示。例如:

std::string str = "My str";
std::string prefix = "My ";
if (str.compare(0, prefix.size(), pre) == 0) {
    std::cout << str.substr(prefix.size()); // "str"
}

现在,compare 函数看起来有点像 C 语言的风格,但这里还有另一个问题:std::string::substr 会导致额外的 std::string 分配。因为它不会修改原始字符串实例,而是返回一个新的字符串实例,而在这里我们其实并不需要这个新实例。为了避免这种情况,我们必须这样做:

for (size_t i = prefox.size(); i < str.size(); ++i) {
    std::cout << str[i];
}

让我们来看另外一个示例:

bool validate(const std::string& str) {
    std::string start = "lstart", stop = "lstop";
    return str.compare(0, start.size(), start) == 0 && str.compare(str.size() - stop.size(), stop.size(), stop) == 0;
}

在这里,我们没有进行任何拷贝操作。但如果在函数签名中忘记加 & 符号,很容易导致一次拷贝。不过,C 风格的 compare 语法仍然存在。

std::string_view (C++17)

自 C++17 起,我们可以使用 std::string_view 实例来观察已分配的连续内存。这意味着我们可以获取一个子字符串视图,它支持迭代和比较操作,而无需为此分配新的 std::string 实例,同时也避免了 C 语言风格的语法。

std::string str = "My str";
std::string prefix = "My ";
std::string_view str_v = str; // no allocation performed
if (str_v.substr(0, prefix.size()) == prefix) { // no allocation
    std::cout << str_v.substr(prefix.size()); // no allocation
}

这意味着对于 validate 函数,我们现在可以直接传递 std::string_view,而无需使用 const & 说明符:

bool validate(std::string_view str) {
    std::string start = "lstart", stop = "lstop";
    return str.substr(0, start.size()) == start && str.substr(str.size() - stop.size()) == stop;
}

它是如何工作的

std::string_view 实际上是一个结构体,它包含指向字符缓冲区起始位置的指针和一个表示大小的成员。这些信息在构造函数中传递,并在 substr 函数中提取到一个新的实例中。
当从 std::string 实例构造 std::string_view 实例时,实际上是使用了 std::string::operator basic_string_view,然后再从 std::string_view 构造一个新的 std::string_view

更进一步

std::string_view 也可以由 char* 实例构造,或者通过 char*size_t 参数构造。这意味着,如果我们只是需要观察和分析编译时字符串(这些字符串被保存在二进制文件中,因此它们的地址是可用的),我们可以直接将它们赋值给 std::string_view 实例,而无需先构造 std::string 实例。

std::string_view str = "My str"; // no string allocation
std::string_view prefix = "My ";
if (str.substr(0, prefix.size()) == prefix) {
    std::cout << str.substr(prefix.size());
}
bool validate(std::string_view str) {
    std::string_view start = "lstart", stop = "lstop"; // no string allocations
    return str.substr(0, start.size()) == start && str.substr(str.size() - stop.size()) == stop;
}

需要注意的是:当使用 char* 构造 std::string_view 实例而不指定长度时,长度将由第一个空字符 (\0) 确定。因此,使用时需要格外小心。我们将在后续进一步讨论这个问题。

C++20/23 拓展

新的标准为 std::string_viewstd::string 对象带来了许多有用的新特性。在 C++20 中,我们获得了两个新的成员函数:starts_withends_with(它们非常适用于上述示例)。此外,从 C++23 开始,我们还新增了 contains 成员函数:

std::string_view str = "My str";
std::string_view prefix = "My ";
if (str.starts_with(prefix)) {
    std::cout << str.substr(prefix.size());
}
bool validate(std::string_view str) {
    return str.starts_with("lstart") && str.ends_with("lstop");
}

Constexpr

以上所有函数都可以在 constexpr 上下文中使用或实现。由于 std::string_view 不会分配任何新数据,它为编译时编程提供了一个开放的可能性。

constexpr std::string_view str = "My str";
constexpr std::string_view prefix = "My ";
if (str.starts_with(prefix)) {
    std::cout << str.substr(prefix.size());
}
constexpr bool validate(std::string_view str) {
    return str.starts_with("lstart") && str.ends_with("lstop");
}

最佳实践

std::string_view 旨在提高字符串分析的性能。然而,性能与安全性之间始终存在权衡,而在处理 std::string_view 时,这种权衡尤为重要。

规则1:永远不要返回 std::string_view

std::string_view func() {
    std::string str;
    std::cin >> str;
    return str;
}

这个看似无害的函数会导致不安全的内存访问。str 在堆上为输入的字符分配了新的内存空间。在返回 std::string_view 之后,它的析构函数释放了这块分配的内存。这意味着返回的 std::string_view 现在指向的是已被释放的内存。

需要注意的是,返回 std::string_view 并不总是会导致不安全的内存访问。如果返回的 std::string_view 指向的是静态存储区的内存,或者指向的是在函数外部仍然可访问的内存,则仍然是有效的。但这类情况在未来可能变得无效,因此最安全的做法是禁止在任何情况下返回 std::string_view

规则2:注意空字符终止符

正如之前提到的,不建议使用空字符终止符,在使用 std::string_view 时应始终牢记这一点。

std::string_view str = "my cool str";
str.remove_prefix(str.find(" "));
str.remove_suffix(str.size() - str.rfind(" "));
std::cout << str; // "cool" - OK
std::cout << str.data(); // "cool str"

remove_prefixremove_suffix 只会改变视图的起始和结束位置。这意味着 remove_suffix 并不会在末尾插入空字符终止符,因此直接打印底层数据时不会受到影响。为了修正这个问题,我们可以修改原始字符串(如果存在),或者从 std::string_view 构造一个新的 std::string,这样它会自动处理终止符,而不会修改原始字符串。

{ // Modifying owner
    std::string str = "cool str";
    std::string_view str_v = str;
    str_v.remove_suffix(4);
    str[4] = '\0';
 
    std::cout << str << "\n"; // "cool\0str"
    std::cout << str_v << "\n"; // "cool"
    std::cout << str_v.data(); // "cool"
}
 
{ // Allocating a new string
    std::string_view str = "cool str";
    str.remove_suffix(4);
    std::string modified_str(str);
    std::string_view mstr_v = modified_str;
     
    std::cout << str << "\n"; // "cool"
    std::cout << str.data() << "\n"; // "cool str"
    std::cout << modified_str << "\n"; // "cool"
    std::cout << mstr_v << "\n"; // "cool"
    std::cout << mstr_v.data(); // "cool"
}

规则3:不要丢失所有权

std::string_view 并不拥有其包含的字符串,因此不会对其进行保护或释放。除了可能导致未定义行为(UB)或非法内存访问外,在某些情况下,它还可能导致内存泄漏(这种情况可能发生在将原本使用 std::string 的代码转换为使用 std::string_view 时)。

const char* get() { return new char[]{"my new str"}; }
 
{
    std::string_view str = get();
    // Here we can call: delete str.data()
    str.remove_prefix(1); // Memory leak!
    // delete str.data() // Invalid call here. The pointer doesn't point to the allocated section head.
}

总结

std::string_view 可用于优化处理字符串的代码部分,提高性能和可读性。然而,任何使用都伴随着额外的责任,必须以正确的方式使用它,以避免出现意外行为(特别是在代码扩展和修改时)。这再次证明了“能力越大,责任越大”这一原则。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注