10. std::string_view
# 10. std::string_view
自C++11引入移动语义(move semantics)以来,传递字符串变得快多了。然而,你可能会遇到很多临时字符串副本的问题。在C++17中,出现了一种新类型string_view
。它允许你创建一个指向连续字符序列的常量、非拥有型视图。你可以操作这个视图并传递它,而无需复制被引用的数据。不过,这个特性也有一些代价:你需要注意避免出现“悬空”视图,而且通常这样的视图可能不是以空字符结尾的。
在本章中,你将学到:
- 什么是
string_view
? - 为什么它能加快你的代码速度?
- 使用
string_view
对象会涉及哪些风险? - 什么是引用生存期延长,它对
string_view
意味着什么? - 如何使用
string_view
使你的应用程序编程接口(API)更通用?
# 基础
让我们做个小实验:在下面的示例中会创建多少个字符串副本?
// string function:
std::string StartFromWordStr(const std::string& strArg, const std::string& word) {
return strArg.substr(strArg.find(word));
}
// call:
std::string str {"Hello Amazing Programming Environment" };
auto subSt r = StartFromWordStr(str, "Programming Environment");
std::cout << subSt r << '\n ';
2
3
4
5
6
7
8
你能数清楚吗?
答案取决于编译器,可能是3个或5个,但通常应该是3个。
- 第一个是
str
。 - 第二个是
StartFromWordStr
函数的第二个参数——该参数是const string&
类型,由于我们传递的是const char*
,所以会创建一个新字符串。 - 第三个来自
substr
,它返回一个新字符串。 - 然后,由于对象是从函数中返回的,可能还会有一两个额外的副本。但通常编译器可以进行优化并省略这些副本(特别是在C++17之后,在这种情况下拷贝省略已成为强制要求)。
- 如果字符串很短,那么可能不会进行堆分配,因为有小字符串优化¹ 。
上面的示例比较简单。然而,你可以想象在实际生产代码中,字符串操作经常发生。在那种情况下,甚至很难数清编译器创建的所有临时字符串。
解决额外临时副本问题的一个更好模式是使用std::string_view
。顾名思义,你不再使用原始字符串,而是只获取它的一个非拥有型视图。大多数时候,它是一个指向连续字符序列的指针和长度。你可以传递它,并使用大多数常规的字符串操作。
视图在处理字符串操作(如子串操作substr
)时表现出色。在典型情况下,每个子串操作都会创建一个更小的字符串副本。而使用string_view
,substr
只会映射原始缓冲区的不同部分,无需额外的内存使用或动态分配。
下面是使用string_view
更新后的代码:
std::string_view StartFromWord(std::string_view str, std::string_view word) {
return str.substr(str.find(word));
}
// call:
std::string str {"Hello Amazing Programming Environment"};
auto subView = StartFromWord(str, "Programming Environment");
std::cout << subView << '\n ';
2
3
4
5
6
7
在上述代码中,我们只进行了一次分配——仅为主要字符串str
分配。string_view
的任何操作都不会为新字符串调用拷贝或进行额外的内存分配。当然,string_view
会被拷贝——但由于它只是一个指针和一个长度,所以比拷贝整个字符串高效得多。
有一点需要注意:虽然这个示例展示了字符串视图的优化能力,但请继续阅读以了解该代码的风险和前提条件!或许你现在就能发现一些问题?那么,什么时候应该使用string_view
呢?
# 使用场景
- 优化:你可以仔细检查代码,用
string_view
替换各种字符串操作。在大多数情况下,你的代码会更快,内存分配也会更少。 - 作为
const std::string&
参数的可能替代:特别是在那些不需要拥有字符串所有权且不存储字符串的函数中。 - 处理来自其他API的字符串:如
QString
、CString
、const char*
等等,只要是存储在连续内存块中且基本字符类型相同的字符串。
你可以编写一个接受string_view
的函数,这样就无需对其他实现进行转换。
在任何情况下,都要记住它只是一个非拥有型视图,所以如果原始对象不存在了,这个视图就会变得毫无意义,你可能会遇到问题。
此外,string_view
可能不包含空字符终止符,所以你的代码也必须支持这一点。例如,将string_view
传递给一个接受以空字符结尾的字符串的函数,从来都不是一个好主意。关于这一点,我们将在单独的“string_view
的风险”部分详细讨论 。
# std::basic_string_view
类型
虽然我们一直在说string_view
,但需要知道它只是一个名为basic_string_view
的模板类的特化版本:
template <
class CharT ,
class Traits = std::char_traits<CharT> > class basic_string_view;
2
3
Traits
类用于抽象字符类型上的操作,例如如何比较字符、如何在序列中查找某个字符。
这种层次结构与std::string
类似,std::string
是std::basic_string
的特化版本。
我们有以下特化类型:
std::string_view | std::basic_string_view<char> |
---|---|
std::wstring_view | std::basic_string_view<wchar_t> |
std::u16string_view | std::basic_string_view<char16_t> |
std::u32string_view | std::basic_string_view<char32_t> |
如你所见,这些特化类型使用了不同的底层字符类型。
为了方便起见,在本章的其余部分,我们仅讨论string_view
。
# std::string_view
的创建
你可以通过几种方式创建string_view
:
- 从
const char*
创建——提供一个指向以空字符结尾的字符串的指针; - 从
const char*
和长度创建; - 通过
std::string
转换创建; - 使用
""sv
字面量创建。
下面是各种创建方式的示例:
const char* cstr = "Hello World";
// the whole string:
std::string_view sv1 { cstr };
std::cout << sv1 << ", len: " << sv1.size() << '\n ';
// slice
std::string_view sv2 { cstr, 5 };
std::cout << sv2 << ", len: " << sv2.size() << '\n ';
// from string:
std::string str = "Hello String";
std::string_view sv3 = str;
std::cout << sv3 << ", len: " << sv3.size() << '\n ';
// ""sv literal
using namespace std::literals;
std::string_view sv4 = "Hello\0 Super World"sv;
std::cout << sv4 << ", len: " << sv4.size() << '\n ';
std::cout << sv4.data() << " - till zero\n ";
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
代码输出如下:
Hello World, len: 11
Hello, len: 5
Hello String, len: 12
Hello Super World, len: 18
Hello - till zero
2
3
4
5
请注意最后两行:sv4
中间包含'\0'
,但std::cout
仍然可以打印整个序列。在最后一行,我们尝试使用.data()
打印,得到一个字符串指针,因此打印在空字符终止符处中断。
# 其他操作
string_view
的设计与std::string
非常相似。然而,string_view
并不拥有数据,因此任何修改数据的操作都不能出现在其接口中。以下是可以用于这个新类型的方法简要列表:
# 迭代器
方法 | 描述 |
---|---|
cbegin() 、begin() | 返回指向第一个字符的迭代器 |
crbegin() 、rbegin() | 返回指向反向视图中第一个字符的反向迭代器,它对应序列中的最后一个字符 |
cend() 、end() | 返回指向序列最后一个字符之后位置的迭代器 |
crend() 、rend() | 返回指向反向序列末尾的迭代器,它对应于反向序列第一个字符之前的位置 |
请注意,上述所有方法都是constexpr
且const
的,所以你得到的总是一个常量迭代器(即使是begin()
或end()
)。
# 访问元素
方法 | 描述 |
---|---|
operator[] | 返回指定位置字符的常量引用,不检查边界 |
at() | 返回指定位置字符的常量引用,并进行边界检查(可能会抛出std::out_of_range 异常) |
front() | 返回序列中第一个字符的常量引用 |
back() | 返回序列中最后一个字符的常量引用 |
data() | 返回指向底层数据的指针 |
如果视图为空,对operator[]
、front()
、back()
和data()
的调用会导致未定义行为。
# 大小和容量
方法 | 描述 |
---|---|
size() /length() | 返回序列中的字符数量 |
max_size() | 返回basic_string_view 可以引用的最大字符类对象数量 |
empty() | 返回size == 0 |
# 修改器
方法 | 描述 |
---|---|
remove_prefix(size_type n) | 相当于:data_ += n; size_ -= n; |
remove_suffix(size_type n) | 相当于:size_ -= n; |
swap(basic_string_view& s) | 交换*this 和s 的值 |
# 其他方法
方法 | 描述 |
---|---|
copy(charT* s, size_type n, size_type pos) | 从位置pos 开始将n 个字符复制到s 中,不是constexpr ,复杂度为O(1),不像std::string 中是O(n) |
substr(size_type pos, size_type n) | 从位置pos 开始,返回长度为n 的子视图 |
compare(...) | 比较字符串,与std::basic_string::compare 类似 |
find(...) | 返回输入字符串或basic_string_view::npos 第一次出现的位置 |
rfind(...) | 返回输入字符串或basic_string_view::npos 最后一次出现的位置 |
find_first_of(...) | 返回与输入模式中任何字符相等的第一个字符的位置,若未找到则返回basic_string_view::npos |
find_last_of(...) | 返回与输入模式中任何字符相等的最后一个字符的位置,若未找到则返回basic_string_view::npos |
find_first_not_of(...) | 返回与输入模式中任何字符都不相等的第一个字符的位置,若未找到则返回basic_string_view::npos |
find_last_not_of(...) | 返回与输入模式中任何字符都不相等的最后一个字符的位置,若未找到则返回basic_string_view::npos |
... 表示该方法有多个重载。 |
# 非成员函数
函数 | 描述 |
---|---|
比较运算符:== 、!= 、<= 、>= 、< 、> | 按字典序比较两个字符串视图 |
operator << | 用于ostream 输出 |
关于上述方法、函数和类型的关键要点:
- 上述所有方法(除了
copy
、operator <<
和std::hash
特化)也都是constexpr
的!有了这个特性,现在你可以在常量表达式中处理连续的字符序列。 - 上述列表几乎与所有不可变字符串操作相同。不过,有两个新方法:
remove_prefix
和remove_suffix
- 它们不是const
的,会修改string_view
对象。请注意,它们仍然不能修改所引用的数据。 operator[]
、at
、front
、back
、data
- 也是const
的 - 因此你不能更改底层字符序列(只有“读访问”权限)。在std::string
中,这些方法有返回引用的重载,所以你有“写访问”权限。但string_view
不支持。string_view
也有针对std::hash
的特化。string_view
有字符串字面量""sv
,你可以像这样定义变量:auto sv = "hello"sv;
# C++20中的更多特性
在C++20中,我们将获得两个新方法:
starts_with()
ends_with()
它们同时在std::basic_string_view
和std::basic_string
中实现。截至2019年8月,Clang 6.0、GCC 9.0和VS 2019 16.2已支持这两个方法。
# 使用string_view
的风险
std::string_view
被添加到标准库中主要是为了实现性能优化。然而,它并不能完全替代字符串!因此,在使用视图时,你必须留意一些潜在风险。
# 处理非空终止字符串
string_view
的字符串末尾可能没有\0
。所以你必须对此有所准备。
string_view
在与所有接受传统C字符串的函数配合使用时可能会有问题,因为string_view
不符合C字符串的终止假设。如果一个函数只接受const char*
参数,将string_view
传递给它可能不是个好主意。另一方面,如果一个函数同时接受const char*
和长度参数,那么传递string_view
可能是安全的。- 转换为字符串时 - 你不仅需要指定指向连续字符序列的指针,还需要指定长度。
# 引用和临时对象
string_view
并不拥有内存,所以在处理临时对象时必须格外小心。
一般来说,string_view
的生命周期绝不能超过拥有该字符串的对象的生命周期。
在以下情况中,这一点可能至关重要:
- 从函数返回
string_view
- 该视图必须指向函数结束后仍然存在的数据。 - 在对象或容器中存储
string_view
- 这类似于在容器中存储指针。当你访问容器中的元素时,被引用的数据必须仍然存在。
为了探究所有这些问题,让我们从本章最初的示例开始。
# 初始示例的问题
引言部分展示了这样一个示例:
std::string_view StartFromWord(std::string_view str, std::string_view word) {
return str.substr(str.find(word));
}
2
3
这段代码在处理非空终止字符串时没有任何问题,因为所有函数都来自string_view
的接口。
然而,临时对象呢?如果调用以下代码会发生什么?
auto str = "My Super"s;
auto sv = StartFromWord(str + " String", "Super");
// 后续代码中使用`sv`...
2
3
这样的代码可能会出错!
"Super"
是一个临时的const char*
字面量,并作为string_view
类型的word
参数传递给函数。这没问题,因为临时对象的生命周期会持续到整个函数调用结束。
然而,字符串拼接str + " String"
的结果是一个临时对象,并且函数返回了这个临时对象的string_view
,超出了调用范围!
所以在这种情况下,一般的建议是,虽然可以从函数返回string_view
,但你必须小心,确保底层字符串的状态是正确的。
为了理解临时值的问题,了解引用生命周期延长(reference lifetime extension)是很有帮助的。
# 引用生命周期延长
在以下情况中会发生什么?
std::vector<int> GenerateVec() {
return std::vector<int>(5, 1);
}
const std::vector<int>& refv = GenerateVec();
2
3
4
上述代码安全吗?
是的 - C++规则规定,绑定到常量引用的临时对象的生命周期会延长到该引用本身的生命周期。
以下是从标准(C++17草案 - N4687)中引用的完整示例,15.2临时对象[class.temporary]:
[示例:
struct S {
S();
S(int);
friend S operator+(const S&, const S&);
~S();
};
S obj1;
const S& cr = S(16)+S(23);
S obj2;
表达式S(16) + S(23)创建了三个临时对象:第一个临时对象T1用于保存表达式S(16)的结果,第二个临时对象T2用于保存表达式S(23)的结果,第三个临时对象T3用于保存这两个表达式相加的结果。然后,临时对象T3被绑定到引用cr。T1和T2哪个先创建是未指定的。在T1先于T2创建的实现中,T2应在T1之前销毁。临时对象T1和T2被绑定到operator+的引用参数;这些临时对象在包含对operator+调用的完整表达式结束时被销毁。绑定到引用cr的临时对象T3在cr的生命周期结束时被销毁,即程序结束时。此外,T3的销毁顺序考虑了其他具有静态存储期的对象的销毁顺序。也就是说,因为obj1在T3之前构造,T3在obj2之前构造,所以obj2应在T3之前销毁,T3应在obj1之前销毁。 - 示例结束]
2
3
4
5
6
7
8
9
10
11
12
13
虽然最好不要对所有变量都编写这样的代码,但在类似以下的情况中,这可能是一个有用的特性:
for (auto &elem : GenerateVec()) {
// ...
}
2
3
在上述示例中,GenerateVec
在基于范围的for
循环内被绑定到一个引用(向量起始位置的右值引用)。如果没有延长生命周期的支持,这段代码会出错。
这与string_view
有什么关系呢?
对于string_view
,以下代码通常容易出错:
std::string func() {
std::string s;
// 构建s...
return s;
}
std::string_view sv = func();
// 没有临时对象生命周期延长!
2
3
4
5
6
7
这可能不太容易理解 - string_view
也是一个常量视图,所以它的行为应该与常量引用类似。但根据现有的C++规则,情况并非如此 - 编译器会在整个表达式结束后立即销毁临时对象。在这种情况下,生命周期无法延长。
string_view
只是一个代理对象,类似于以下代码:
std::vector<int> CreateVector() { ... }
std::string GetString() { return "Hello"; }
auto &x = CreateVector()[10];
auto pStr = GetString().c_str();
2
3
4
在这两种情况下,x
和pStr
都不会延长在CreateVector()
或GetString()
中创建的临时对象的生命周期。
你可以通过以下方式修复这个问题:
std::string func() {
std::string s;
// 构建s...
return s;
}
auto temp = func();
std::string_view sv { temp };
// 通过`temp`延长了临时对象的生命周期,没问题
2
3
4
5
6
7
8
每次你赋值某个函数的返回值时,都必须确保对象的生命周期是正确的。
有一个提案旨在修复
string_view
和其他应该具有延长引用生命周期语义的类型的问题:查看P0936 (opens new window) 。
# 从string_view
初始化字符串成员
由于string_view
在函数传参时可以作为const string&
的潜在替代品,我们可以考虑用它来初始化字符串成员的情况。string_view
在这种场景下是最佳选择吗?看下面这个例子:
class UserName {
std::string mName;
public:
UserName(const std::string& str) : mName(str) { }
};
2
3
4
5
可以看到,构造函数接收const std::string& str
。另一种选择是使用string_view
:
UserName(std::string_view sv) : mName(sv) { }
让我们在三种情况下比较这两种实现方式:从字符串字面量创建对象、从左值(lvalue)创建对象以及从右值引用(rvalue reference)创建对象:
// 从字符串字面量创建对象
UserName u1{"John With Very Long Name"};
// 从左值创建对象
std::string s2 {"Marc With Very Long Name"};
UserName u2 { s2 };
// 后续使用s2...
// 从右值引用创建对象
std::string s3 {"Marc With Very Long Name"};
UserName u3 { std::move(s3) };
// 第三种情况也类似于接收返回值
std::string GetString() { return "some string..."; }
UserName u4 { GetString() };
2
3
4
5
6
7
8
9
10
11
12
现在我们来分析UserName
构造函数的两个版本,一个使用字符串引用,另一个使用string_view
。
请注意,这里不考虑s2
和s3
的内存分配及创建过程,我们只关注构造函数调用时发生的情况。对于s2
,我们还假设它在代码中后续会被使用。
- 使用
const std::string&
的情况:u1
:两次内存分配。第一次创建一个临时字符串并将其绑定到输入参数,然后再将其复制到mName
中。u2
:一次内存分配。我们可以无开销地将引用绑定到s2
,然后将其复制到成员变量mName
中。u3
:一次内存分配。同样是无开销地将引用绑定到输入参数,然后再复制到成员变量mName
中。- 对于
u1
的情况,你可以编写一个接收右值引用的构造函数来减少一次内存分配;对于u3
的情况,也可以通过从右值引用移动数据来减少一次复制操作。
- 使用
std::string_view
的情况:u1
:一次内存分配。输入参数无需复制或分配内存,仅在创建mName
时进行一次内存分配。u2
:一次内存分配。为参数创建string_view
的开销很小,然后将其复制到成员变量mName
中。u3
:一次内存分配。同样是为参数创建string_view
的开销很小,接着复制到成员变量mName
中。- 对于
u3
的情况,如果你想减少一次内存分配,也需要编写一个接收右值引用的构造函数,因为这样可以从右值引用移动数据。
虽然在传递字符串字面量时string_view
表现更好,但在使用现有字符串或从现有字符串移动数据时,它并没有优势。
然而,自C++11引入移动语义(move semantics)以来,通常将字符串按值传递然后再移动它,这种方式更好且更安全。
例如:
class UserName {
std::string mName;
public:
UserName(std::string str) : mName(std::move(str)) { }
};
2
3
4
5
现在来看使用std::string
按值传递的结果:
- u1
:一次内存分配。为输入参数分配内存,然后将其移动到mName
中。这比使用const std::string&
时的两次内存分配要好,和使用string_view
的方式类似。
- u2
:一次内存分配。我们需要将值复制到参数中,然后再将其移动到mName
中。
- u3
:无需内存分配,仅进行两次移动操作。这比使用string_view
和const string&
的方式都要好!
按值传递std::string
不仅代码更简洁,而且也无需为右值引用编写单独的重载函数。
完整代码示例见string_view/initializing_from_string_view.cpp
文件。
这种按值传递的方式与斯科特·迈耶斯(Scott Meyers)所著《Effective Modern C++》中第41条建议 “对于可拷贝、移动开销小且总是会被拷贝的参数,考虑按值传递” 一致。
然而,std::string
的移动操作开销真的小吗?
虽然C++标准没有明确规定,但通常情况下,字符串是通过小字符串优化(Small String Optimisation,SSO)来实现的,即字符串对象包含额外的空间,可以在不进行额外内存分配的情况下存储字符 。这意味着移动一个字符串和复制它的开销是一样的。而且由于字符串较短,复制操作也很快。
小字符串优化(SSO)并未标准化,且可能会发生变化。目前,在 MSVC(VS 2017)/GCC(8.1)中,小字符串优化的字符数限制是 15 个字符,而在 Clang(6.0)中是 22 个字符。对于跨平台代码而言,基于小字符串优化来进行假设性的优化并不是一个好主意。
让我们重新考虑在字符串较短时按值传递的示例:
UserName u1{"John"};
std::string s2 {"Marc"};
UserName u2 { s2 };
std::string s3 {"Marc"};
UserName u3 { std::move(s3) };
2
3
4
5
记住,每次移动操作和复制操作的开销相同。
- 使用
const std::string&
的情况:u1
:两次复制。一次是将输入的字符串字面量复制到临时字符串参数中,另一次是将临时字符串复制到成员变量中。u2
:一次复制。将现有字符串绑定到引用参数,然后再复制到成员变量中。u3
:一次复制。将右值引用无开销地绑定到输入参数,之后再复制到成员变量中。
- 使用
std::string_view
的情况:u1
:一次复制。输入参数无需复制,仅在初始化mName
时进行一次复制。u2
:一次复制。输入参数无需复制,因为创建string_view
很快,然后再复制到成员变量中。u3
:一次复制。创建string_view
的开销很小,然后将参数复制到mName
中。另外,string_view
还存在指向已删除字符串的风险。
- 使用
std::string
按值传递的情况:u1
:两次复制。输入参数由字符串字面量创建,然后再复制到mName
中。u2
:两次复制。一次复制到参数中,然后再复制到成员变量中。u3
:两次复制。一次复制到参数中(移动操作等同于复制操作),然后再复制到成员变量中。
可以看到,对于短字符串,当传递现有字符串时,按值传递可能会 “更慢”,因为会进行两次复制而不是一次。另一方面,当编译器看到对象而不是引用时,可能会对代码进行更好的优化。而且,短字符串的复制开销很小,所以这种潜在的 “速度减慢” 甚至可能察觉不到。
总之,按值传递字符串参数然后再移动它是更优的解决方案。对于较长的字符串,这种方式代码简单且性能更好。
一如既往,如果你的代码对性能要求极高,那么就必须对所有可能的情况进行测量。
# 其他类型和自动化
本节讨论的问题也适用于其他可拷贝和可移动的类型。如果移动操作的开销很小,那么按值传递可能比按引用传递更好。你还可以使用自动化工具,比如Clang-Tidy,它可以检测出潜在的优化点。Clang-Tidy针对这种情况有单独的规则,详见clang-tidy - modernize-pass-by-value
。
下面是传递字符串和初始化字符串成员的总结:
输入参数 | const string& | string_view | string 并移动 |
---|---|---|---|
const char* | 2次分配 | 1次分配 | 1次分配 + 1次移动 |
const char* (适用SSO) | 2次复制 | 1次复制 | 2次复制 |
左值 | 1次分配 | 1次分配 | 1次分配 + 1次移动 |
左值(适用SSO) | 1次复制 | 1次复制 | 2次复制 |
右值 | 1次分配 | 1次分配 | 2次移动 |
右值(适用SSO) | 1次复制 | 1次复制 | 2次复制 |
# 处理非空终止字符串
如果你从一个字符串创建string_view
,它将指向一段以空字符终止的内存:
std::string s = "Hello World";
std::cout << s.size() << '\n ';
std::string_view sv = s;
std::cout << sv.size() << '\n ';
2
3
4
这两条cout
语句都会输出11。
但是,如果只取字符串的一部分呢:
std::string s = "Hello World";
std::cout << s.size() << '\n ';
std::string_view sv = s;
auto sv2 = sv.substr(0, 5);
std::cout << sv2.data() << '\n ';
2
3
4
5
sv2
应该只包含"Hello"
,但是当你访问底层内存的指针时,得到的却是指向整个字符串的指针。表达式cout << sv2.data()
会输出整个字符串,而不只是它的一部分!sv2.data()
返回的是指向字符串s
对象中"Hello World"
字符数组的指针。
当然,当你输出sv2
时会得到正确的结果:
std::cout << sv2 << '\n ';
// 输出 "Hello"
2
这是因为std::cout
可以处理string_view
类型。
这个例子展示了所有假定字符串以空字符终止的第三方API可能存在的问题。例如:
- 使用
printf()
打印:
std::string s = "Hello World";
std::string_view sv = s;
std::string_view sv2 = sv.substr(0, 5);
printf("My String %s", sv2.data());
2
3
4
你应该使用:
printf("%.*s\n", static_cast<int>(sv2.size()), sv2.data());
.*
用于指定精度,详见printf
的规范:精度不是在格式字符串中指定的,而是作为一个额外的整数值参数,放在需要格式化的参数之前。
- 类似
atoi()
/atof()
的转换函数:
std::string number = "123.456";
std::string_view svNum { number.data(), 3 };
auto f = atof(svNum.data());
std::cout << f << '\n ';
2
3
4
atof
只接受指向以空字符终止的字符串的指针,所以string_view
与之不兼容。
要解决这个问题,可以考虑使用from_chars
函数(C++17中也添加了该函数):
// 使用from_chars(C++17)
std::string number = "123.456";
std::string_view svNum { number.data(), 3 };
int res = 0;
std::from_chars(svNum.data(), svNum.data()+svNum.size(), res);
std::cout << res << '\n ';
2
3
4
5
6
- 通用解决方案:如果你的API只支持以空字符终止的字符串,且无法切换到带有额外计数或大小参数的函数,那么就需要将
string_view
转换为字符串。
例如,在string_view/string_view_null.cpp
文件中:
void ConvertAndShow(const char *str) {
auto f = atof(str);
std::cout << f << '\n ';
}
std::string number = "123.456";
std::string_view svNum { number.data(), 3 };
// ... 一些代码
std::string tempStr { svNum.data(), svNum.size() };
ConvertAndShow(tempStr.c_str());
2
3
4
5
6
7
8
9
10
ConvertAndShow
函数只处理以空字符终止的字符串,所以我们唯一的办法是创建一个临时字符串tempStr
,然后将其传递给该函数。
如果你想从string_view
创建一个字符串对象,记住要使用.data()
和.size()
,这样才能正确引用底层字符序列的切片。
# 性能和内存考量
将string_view
添加到标准库背后的核心思想是提高性能和减少内存消耗。利用string_view
,可以有效地避免创建许多临时字符串,从而提升性能。
在内存方面,string_view
通常实现为[ptr, len]
,即一个指针和一个通常为size_t
类型的变量来表示可能的大小。
这就是为什么它的大小通常是8字节或16字节(取决于架构是x86还是x64)。
对于std::string
类型,由于常见的小字符串优化,std::string
的大小通常是24字节或32字节,是string_view
的两倍。如果字符串长度超过了小字符串优化(SSO)的缓冲区大小,那么std::string
会在堆上分配内存。如果不支持小字符串优化(这种情况很少见),那么std::string
将由一个指向已分配内存的指针和大小组成。
在字符串操作的性能方面,string_view
仅支持字符串操作的一个子集,即那些不会修改所引用字符序列的操作。像find()
这样的函数,其性能应该与std::string
中的对应函数相同。
另一方面,string_view
中的substr
操作只是复制两个元素,而std::string
的substr
操作会复制一段内存范围。它们的时间复杂度分别是O(1)和O(n)。所以,如果你需要分割一个较长的字符串并处理分割后的子串,string_view
的实现方式应该会更快。
# 常量表达式中的字符串
string_view
的一个有趣特性是,除了复制操作、operator<<
以及为string_view
专门化的std::hash
函数外,它的所有方法都被标记为constexpr
。有了这个特性,你可以在编译时对字符串进行操作。
例如,在string_view/string_view_constexpr.cpp
文件中:
#include <string_view>
int main() {
using namespace std::literals;
constexpr auto st rv = "Hello Programming World"sv;
constexpr auto st rvCut = st rv.substr("Hello "sv.size());
static_assert(st rvCut == "Programming World"sv);
return strvCut.size();
}
2
3
4
5
6
7
8
9
10
如果你使用像GCC 8.1这样的现代编译器,并加上-std=c++1z -Wall -pedantic -O2
这些选项,那么编译后的汇编代码应该如下所示:
main:
movl $17, %eax
ret
2
3
类似的代码,如果使用std::string
,会生成更多代码。由于这个例子使用了长字符串,无法进行小字符串优化(Small String Optimisation),所以编译器必须生成用于new
/delete
的代码来管理字符串的内存。
# 从boost::string_ref
和boost::string_view
迁移
和C++17中的大多数新类型一样,string_view
也受到了Boost库的启发。马歇尔·克洛(Marshall Clow)在1.53版本(2012年2月)中实现了boost::string_ref
,之后它演进为boost::string_view
(在1.61版本中添加 - 2016年5月)。
boost::string_ref
和boost::string_view
的主要区别在于对constexpr
的支持。
boost::string_view
实现了与std::string_view
相同的功能,还添加了一些新函数:
starts_with
ends_with
完整的头文件可查看boost/doc/libs/1_67_0/boost/utility/string_view.hpp
,也可查看Boost实用库 。
关于string_ref
弃用的讨论链接:“string_view
与string_ref
的对比” 。
# 示例
下面是两个使用string_view
的示例。
# 与不同的字符串API协同工作
string_view
一个有趣的用例是在处理不同字符串实现的代码中使用它。
例如,你可能会用到MFC中的CString
、C语言API中的const char*
、QT中的QString
,当然还有std::string
。
与其为不同的字符串类型创建重载函数,不如利用string_view
!例如:
void Process(std::string_view sv) { }
如果你想在不同的字符串实现中使用Process
函数,你所要做的就是从你的字符串类型创建一个string_view
。大多数字符串类型都应该很容易做到这一点。
例如:
// MFC字符串:
CString cstr;
Process(std::string_view{cstr.GetString(), cstr.GetLength()});
// QT字符串:
QString qstr;
Process(std::string_view{qstr.toLatin1().constData()});
// 你自己的实现:
MySuperString myStr;
// MySuperString::GetData() - 返回char*
// MySuperString::Length() - 返回字符串长度
Process(std::string_view{myStr.GetData(), myStr.Length()});
2
3
4
5
6
7
8
9
10
11
12
13
假设Process()
函数可以实现为Process(const char*, int len)
,但使用string_view
会使代码更清晰、更简单。此外,你还可以使用string_view
的所有可用方法,这样的代码比C风格的代码更方便。
# 字符串分割
string_view
可能是字符串分割的一种潜在优化方式。如果你有一个大的持久化字符串,你可能希望创建一个string_view
对象列表,来映射这个大字符串中的单词。
请注意,这段代码的灵感来自马可·阿雷纳(Marco Arena)的文章 “string_view
:爱恨交加 (opens new window)” 。
在string_view/string_view_split.cpp
文件中:
#include <vector>
#include <string_view>
#include <algorithm>
std::vector<std::string_view> splitSV(std::string_view strv, std::string_view delims = " ") {
std::vector<std::string_view> output;
auto first = strv.begin();
while (first != strv.end()) {
const auto second = std::find_first_of(
first, std::cend(strv),
std::cbegin(delims), std::cend(delims)
);
if (first != second) {
output.emplace_back(strv.substr(
std::distance(strv.begin(), first),
std::distance(first, second)
));
}
if (second == strv.end()) {
break;
}
first = std::next(second);
}
return output;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
示例用例:
const std::string str { "Hello Extra,,, Super, Amazing World" };
for (const auto& word : splitSV(str, " ,"))
std::cout << word << '\n';
2
3
4
这将输出:
Hello
Extra
Super
Amazing World
2
3
4
该算法遍历输入的string_view
,查找分隔符(即与分隔符列表匹配的字符)。然后,代码提取上一个分隔符和新分隔符之间的字符序列片段,并将这个子视图存储在输出向量中。
关于这个实现的一些说明:
- 该算法的
string_view
版本假设输入字符串是持久化的,而不是临时对象。要注意返回的string_view
向量,因为它也指向输入字符串。 if (first != second)
这条指令可以防止在相邻出现多个分隔符(如连续空格)的情况下添加空 “单词”。- 该算法使用了
std::find_first_of
,也可以使用string_view::find_first_of
。成员方法返回的不是迭代器,而是字符串中的位置。 - 在一些测试中,当分隔符数量较少时,
string_view
的成员方法似乎比std::find_first_of
版本慢。
如果你想查看关于本节代码的一些实验,可以看看 “C++17中std::string_view
与std::string
的性能对比 (opens new window)” 以及 “加速string_view
字符串分割的实现 (opens new window)” 。这两篇博客文章描述了基准测试结果,并对代码提出了更多可能的改进方法。
# 总结
关于std::string_view
,需要记住以下几点:
- 它是
std::basic_string_view<charType, traits<charType>>
的特化版本,其中charType
等于char
。 - 它是对连续字符序列的非拥有式视图。
- 它末尾可能不包含空终止符。
- 它可用于优化代码,减少对字符串临时副本的需求。
- 它包含了大多数不会改变底层字符的
std::string
操作。 - 它的操作也被标记为
constexpr
。
但是:
- 要确保底层字符序列仍然存在!
- 虽然
std::string_view
看起来像是对字符串的常量引用,但语言并不会延长绑定到std::string_view
的返回临时对象的生命周期。 - 当从
string_view
构建字符串时,始终记得使用stringView.size()
。size()
方法可以正确标记string_view
的末尾。 - 当将
string_view
传递给接受以空字符终止的字符串的函数时要小心,除非你确定你的string_view
包含空终止符。
编译器支持情况:
特性 | GCC | Clang | MSVC |
---|---|---|---|
std::string_view | 7.1 | 4.0 | VS 2017 15.0 |