章节目录

std::string_view(第2部分)

本节阅读量:

在前面的课程中,我们介绍了两种字符串类型:std::string 和 std::string_view。

由于 std::string_view 是我们第一次接触到的“视图”类型,因此我们会花一些额外的时间来进一步讨论它。我们将重点介绍如何安全地使用 std::string_view,并通过一些示例说明常见的错误用法。最后,我们会给出一些关于何时使用 std::string、何时使用 std::string_view 的指导。


所有者与查看者

让我们做一个类比。假设你决定画一幅自行车的画。但你并没有自行车!你该怎么办?

嗯,你可以去当地的自行车商店买一辆。那辆自行车就属于你了。这样做有一些好处:你现在拥有一辆自行车,可以骑它,可以保证在需要时随时能用它,还可以装饰它、随意移动它。但这种方案也有一些缺点。自行车很贵。一旦你买了它,你就要对它负责。你必须定期保养它。等到你不再需要它时,还必须妥善处置它。

拥有的代价可能很高。作为所有者,你要负责获取、管理并妥善处置你所拥有的对象。

在你出门的路上,你瞥了一眼窗外。你注意到邻居把自行车停在你窗户对面。你完全可以画一幅邻居的自行车(从你自己的窗户望出去)。这种方式有不少好处:你省下了买自行车的钱,不需要保养它,也不必在结束时去处理它。画完之后,你可以拉上窗帘,继续自己的生活。这相当于结束对该对象的“查看”,但对象本身不会因此受到影响。当然这种方式也有潜在的缺点:你无法给邻居的自行车上色或改装,观察过程中邻居可能会改变自行车的外观,甚至把它从你视野中完全移走,你可能会意外看到一些出乎意料的情况。

而查看的代价则较低。作为查看者,你对所查看的对象不负任何责任,但同样也无法控制它们。


std::string 是所有者

你可能会好奇:为什么 std::string 要费力地复制一份其初始化值的昂贵副本?当一个对象被实例化时,系统会为它分配一块内存,用来存储其整个生命周期内所需使用的数据。这块内存是为该对象预留的,并保证在对象存在期间一直存在。这是一个安全的空间。std::string(以及大多数其他对象)会把传入的初始化值复制到这块内存中,这样它就拥有了一份独立的值,可以随时访问和修改。一旦复制完成,对象就不再以任何方式依赖于初始化值。

这其实是件好事,因为一旦初始化完成,通常就不能再信赖初始化值。把初始化过程想象成一次用来初始化对象的函数调用:是谁传入了这个初始化值?是调用方。初始化完成后,控制权会回到调用方手中。此时,初始化语句已经结束,通常会发生下面两种情况之一:

  1. 如果初始化值是临时值或临时对象,它会立即被销毁。
  2. 如果初始化值是变量,调用方依然可以访问该对象,并可以在其上进行任意操作,包括修改或销毁它。

因为 std::string 自己保存了一份值的副本,所以它不必担心初始化完成后发生了什么:初始化值可以被销毁也可以被修改,都不会影响到 std::string。缺点是,这种独立性的代价是较高的复制开销。

在我们的类比中,std::string 就是所有者。它拥有自己管理的数据。当它被销毁时,会自行清理。


我们并不总是需要一份副本

让我们回顾一下上一课中的这个例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
#include <iostream>
#include <string>

void printString(std::string str) // str 复制了传入的值
{
    std::cout << str << '\n';
}

int main()
{
    std::string s{ "Hello, world!" };
    printString(s);

    return 0;
}

在调用 printString(s) 时,str 会生成 s 的一份昂贵副本。该函数打印了这个副本,然后将其销毁。

请注意,s 本身就已经保存了要打印的字符串。我们是否可以直接使用已保存的字符串,而不再复制一份呢?我们需要评估以下三个条件:

  1. 当 str 仍在使用时,s 是否可能被销毁?不会。str 在函数结尾处销毁,而 s 存在于调用者作用域中,函数返回之前它不会被销毁。
  2. 当 str 仍在使用时,s 是否可能被修改?不会。str 在函数结尾处销毁,调用者在函数返回之前没有机会修改 s。
  3. str 是否会以调用者不期望的方式修改这个字符串?不会。该函数根本不会修改字符串。

由于这三个条件都为“否”,复制就没有任何风险。既然字符串的复制开销如此昂贵,我们又何必为一份并不需要的副本买单呢?


std::string_view 是查看者

std::string_view 采用了不同的初始化方式。std::string_view 创建的是对字符串的廉价视图,而不是对字符串的昂贵副本。一旦初始化完成,就可以通过 std::string_view 来访问对应的字符串。

在我们的类比中,std::string_view 就是查看者。它查看一个已存在于别处的对象,并且不能修改它。当视图被销毁时,所查看的对象不会受到任何影响。

需要注意的是,std::string_view 在其整个生命周期中,仍然依赖于它的初始化值。如果所查看的字符串在查看器仍在使用时被修改或销毁,就会导致意外结果或未定义行为。

每当使用查看者时,都要确保上述情况不会发生。

查看已销毁字符串的 std::string_view 有时被称为悬空视图(dangling view)。


std::string_view 最适合作为只读函数参数

std::string_view 最合适的用途是作为只读函数参数。这样我们就可以在不复制的情况下传入 C 风格字符串、std::string 或 std::string_view 实参,因为 std::string_view 只会创建对该实参的视图。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
#include <string>
#include <string_view>

void printSV(std::string_view str) // std::string_view, 只是传入实参的一个视图
{
    std::cout << str << '\n';
}

int main()
{
    printSV("Hello, world!"); // 使用 C 风格字符串调用

    std::string s2{ "Hello, world!" };
    printSV(s2); // 使用 std::string 调用

    std::string_view s3 { s2 };
    printSV(s3); // 使用 std::string_view 调用
       
    return 0;
}

由于函数参数 str 是在函数返回前被创建、初始化、使用并销毁的,因此不用担心在 str 使用期间,它所查看的字符串会被修改或销毁。


std::string_view 的错误用法

让我们来看看一些滥用 std::string_view 会引发问题的例子。

第一个示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
#include <iostream>
#include <string>
#include <string_view>

int main()
{
    std::string_view sv{};

    { // 创建一个嵌套的代码块
        std::string s{ "Hello, world!" }; // 创建一个局部的 std::string
        sv = s; // sv 现在是 s 的视图
    } // s 被销毁, sv 正在查看一个已失效的字符串

    std::cout << sv << '\n'; // 未定义行为

    return 0;
}

在这个示例中,我们在嵌套块中创建了 std::string s,然后让 sv 成为 s 的视图。而变量 s 会在代码块结束时被销毁。sv 并不知道 s 已经被销毁了。再使用 sv 时,我们实际上访问的是一个无效的对象,从而导致未定义行为。

下面是同一问题的另一种变体,使用函数返回值来初始化 std::string_view:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
#include <iostream>
#include <string>
#include <string_view>

std::string getName()
{
    std::string s { "Alex" };
    return s;
}

int main()
{
  std::string_view name { getName() }; // 用函数返回值初始化 name
  std::cout << name << '\n'; // 未定义行为

  return 0;
}

这与前面的示例类似。getName() 函数返回包含字符串 “Alex” 的 std::string。返回值是一个临时对象,它会在包含该函数调用的完整表达式末尾被销毁。我们必须立即使用这个返回值,或者将它复制保存下来以供后续使用。

但 std::string_view 并不会复制,而是为这个临时返回值创建了一个视图。这就使 std::string_view 处于悬空状态(指向无效对象),之后打印它会导致未定义行为。

下面是上述问题一个不那么明显的变体:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#include <iostream>
#include <string>
#include <string_view>

int main()
{
    using namespace std::string_literals;
    std::string_view name { "Alex"s }; // "Alex"s 创建了一个临时的 std::string
    std::cout << name << '\n'; // 未定义行为

    return 0;
}

std::string 字面值(通过 s 后缀创建)会生成一个临时的 std::string 对象。因此在本例中,“Alex"s 创建了一个临时的 std::string,并用它作为 name 的初始值。随后该临时 std::string 被销毁,留下一个悬空的 name。当再使用 name 变量时,就会产生未定义行为。

如果修改正在被查看的字符串,也会导致未定义行为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
#include <iostream>
#include <string>
#include <string_view>

int main()
{
    std::string s { "Hello, world!" };
    std::string_view sv { s }; // sv 正在查看 s

    s = "Hello, universe!";    // 修改 s, 会使 sv 失效 (s 本身仍然有效)
    std::cout << sv << '\n';   // 未定义行为

    return 0;
}

在本例中,sv 被设置为 s 的视图,随后 s 被修改。当 std::string 被修改后,指向这个 std::string 的所有视图都会失效。使用失效的视图会导致未定义行为。因此当打印 sv 时,就会产生未定义行为。


让失效的 std::string_view 重新生效

无效的对象通常可以通过将其重置到已知的有效状态来重新生效。对于失效的 std::string_view,我们可以给它赋一个有效的字符串,从而让它重新生效。

下面是与前面相同的示例,但我们会让 sv 重新生效:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
#include <iostream>
#include <string>
#include <string_view>

int main()
{
    std::string s { "Hello, world!" };
    std::string_view sv { s }; // sv 现在查看 s

    s = "Hello, universe!";    // 修改 s, 使 sv 失效 (s 本身仍然有效)
    std::cout << sv << '\n';   // 未定义行为

    sv = s;                    // 让 sv 重新生效: sv 再次查看 s
    std::cout << sv << '\n';   // 打印 "Hello, universe!"

    return 0;
}

在通过修改 s 让 sv 失效之后,我们通过语句 sv = s 让 sv 再次成为 s 的有效视图。当我们再次打印 sv 时,它就会输出 “Hello, universe!"。


谨慎返回 std::string_view

std::string_view 也可以用作函数的返回值。然而,这通常是有风险的。

由于局部变量会在函数结束时被销毁,因此如果 std::string_view 是一个局部变量的视图,那么返回的 std::string_view 就是失效的,再使用它会导致未定义行为。例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
#include <string>
#include <string_view>

std::string_view getBoolName(bool b)
{
    std::string t { "true" };  // 局部变量
    std::string f { "false" }; // 局部变量

    if (b)
        return t;  // 返回查看 t 的视图

    return f; // 返回查看 f 的视图
} // t 和 f 在函数结束时销毁

int main()
{
    std::cout << getBoolName(true) << ' ' << getBoolName(false) << '\n'; // 未定义行为

    return 0;
}

在上面的示例中,当调用 getBoolName(true) 时,函数返回一个正在查看 t 的 std::string_view。然而 t 在函数结束时就已被销毁,这意味着返回的 std::string_view 正在查看一个已被销毁的对象。因此当打印返回的 std::string_view 时,就会产生未定义行为。

对于这种情况,编译器可能会给出警告,也可能不会。

在两种主要情形下,返回 std::string_view 是安全的。

首先,由于 C 风格字符串字面值在整个程序执行期间一直存在,因此可以从返回类型为 std::string_view 的函数中返回 C 风格字符串字面值。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
#include <iostream>
#include <string_view>

std::string_view getBoolName(bool b)
{
    if (b)
        return "true";  // 返回 "true" 的视图

    return "false"; // 返回 "false" 的视图
} // "true" 和 "false" 在函数结束时不会失效

int main()
{
    std::cout << getBoolName(true) << ' ' << getBoolName(false) << '\n'; // ok

    return 0;
}

输出为:

1
true false

当调用 getBoolName(true) 时,函数会返回一个指向 C 风格字符串 “true” 的 std::string_view。由于 “true” 在整个程序运行期间都存在,因此在 main() 中使用返回的 std::string_view 打印 “true” 不会有任何问题。

其次,对于类型为 std::string_view 的函数参数,通常也可以将其作为返回值返回。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>
#include <string>
#include <string_view>

std::string_view firstAlphabetical(std::string_view s1, std::string_view s2)
{
    if (s1 < s2)
        return s1;
    return s2;
}

int main()
{
    std::string a { "World" };
    std::string b { "Hello" };

    std::cout << firstAlphabetical(a, b) << '\n'; // 打印 "Hello"

    return 0;
}

这段代码为什么没问题,可能不太明显。首先,要注意实参 a 和 b 存在于调用者作用域中。当函数被调用时,函数参数 s1 是 a 的视图,函数参数 s2 是 b 的视图。当函数返回 s1 或 s2 时,实际上就是把查看 a 或 b 的视图返回给了调用方。由于此时 a 和 b 仍然存在,因此返回的 std::string_view 可以合法地继续查看 a 或 b。

这里有一个重要的细节需要注意。如果实参是临时对象(会在包含函数调用的完整表达式末尾被销毁),那么函数返回的 std::string_view 必须在同一个表达式中立即使用。表达式一旦结束,临时对象就会被销毁,std::string_view 就会变为悬空状态。


修改视图的功能

想象一下你家的窗户,正望着停在街边的一辆汽车。你可以透过窗户看到那辆车,但无法触碰它,也无法移动它。你的窗户只是提供对车辆的一个视图,而车辆本身是一个完全独立的对象。

很多窗户都带有窗帘,这样我们就可以通过调整窗帘来改变可视区域。我们可以拉上左边或右边的窗帘,从而缩小能看到的范围。这并不会改变外面的物体,只是减少了可见区域。

由于 std::string_view 是一个视图,它也提供了一些类似“拉窗帘”的函数,让我们可以修改视图本身。这些操作完全不会修改被查看的字符串,只会修改视图。

  1. remove_prefix() 成员函数会从视图的左侧移除若干字符。
  2. remove_suffix() 成员函数会从视图的右侧移除若干字符。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
#include <string_view>

int main()
{
	std::string_view str{ "Peach" };
	std::cout << str << '\n';

	// 从视图左侧移除一个字符
	str.remove_prefix(1);
	std::cout << str << '\n';

	// 从视图右侧移除两个字符
	str.remove_suffix(2);
	std::cout << str << '\n';

	str = "Peach"; // 重置视图
	std::cout << str << '\n';

	return 0;
}

该程序的输出为:

1
2
3
4
Peach
each
ea
Peach

与真实的窗帘不同,一旦调用了 remove_prefix() 或 remove_suffix(),想要重置视图的唯一办法就是再次给它赋一个源字符串。


std::string_view 可以查看子字符串

这就带来了 std::string_view 的一个重要用途。虽然 std::string_view 可以在不复制字符串的情况下查看整个字符串,但它在不复制字符串的情况下查看子字符串时同样非常有用。子字符串是已有字符串中一段连续的字符序列。例如,对于字符串 “snowball”,“snow”、“all” 和 “now” 都是它的子字符串。而 “owl” 则不是 “snowball” 的子字符串,因为这些字符在 “snowball” 中并不连续出现。


std::string_view 可能以 null 结尾,也可能不以 null 结尾

查看子字符串的能力带来了一个值得注意的后果:std::string_view 可能以 null 结尾,也可能不以 null 结尾。考虑字符串 “snowball”,它是以 null 结尾的。如果 std::string_view 查看的是整个字符串,那么它看到的就是一个以 null 结尾的字符串。但如果 std::string_view 只查看其中的 “now” 子串,那么这个子串就不是以 null 结尾的(下一个字符是 “b”)。

在几乎所有场景下,这都不会造成影响——std::string_view 会记录它所查看的字符串或子字符串的长度,因此并不依赖 null 结尾符。无论 std::string_view 是否以 null 结尾,都可以将其转换为 std::string。


关于何时使用 std::string 与 std::string_view 的快速指南

本指南并不涵盖所有情况,但旨在突出最常见的用法:

在以下情况下,使用 std::string 变量:

  1. 你需要一个可以被修改的字符串。
  2. 你需要存储用户输入的文本。
  3. 你需要存储返回 std::string 的函数的返回值。

在以下情况下,使用 std::string_view 变量:

  1. 你需要对已经存在于别处的字符串(部分或全部)进行只读访问,并且在该 std::string_view 使用结束前,它不会被修改或销毁。
  2. 需要定义 C 风格字符串的符号常量。
  3. 你需要继续查看返回 C 风格字符串或非悬空 std::string_view 的函数的返回值。

在以下情况下,使用 std::string 作为函数参数:

  1. 函数需要在不影响调用方的情况下修改传入的字符串。这是较少见的情况。
  2. 使用的语言标准早于 C++17。
  3. 需要传递左值引用(后续课程会介绍引用)。

在以下情况下,使用 std::string_view 作为函数参数:

  1. 函数只需要一个只读字符串。

在以下情况下,使用 std::string 作为返回类型:

  1. 返回值是一个 std::string 局部变量。
  2. 返回值来自一个按值返回 std::string 的函数调用或运算符。
  3. 返回值需要按引用传递(后续课程会介绍引用)。

在以下情况下,使用 std::string_view 作为返回类型:

  1. 返回 C 风格字符串字面值。
  2. 返回类型为 std::string_view 的函数参数。

关于 std::string 的注意事项:

  1. 初始化和复制 std::string 的开销很大,应尽可能避免。
  2. 避免按值传递 std::string,因为这样会产生副本。
  3. 如果可能,避免创建短生命周期的 std::string 对象。
  4. 修改一个 std::string 会使指向该字符串的所有视图失效。

关于 std::string_view 的注意事项:

  1. 由于 C 风格字符串字面值在整个程序执行期间都有效,因此可以把 std::string_view 设置为 C 风格字符串字面值。
  2. 当字符串被销毁时,指向该字符串的所有视图都会失效。
  3. 使用失效的视图会导致未定义行为。
  4. std::string_view 可能不是以 null 结尾的。

5.9 std::string_view简介

上一节

5.11 第五章总结

下一节