c++17完整导引-爱代码爱编程
C++17完整导引-组件之std::string_view)
在C++17
中,C++
标准库引入了一个特殊的字符串类:std::string_view
,位于头文件<string_view>
中,它能让我们像处理字符串一样处理字符序列,而不需要为它们分配内存空间。也就是说,std::string_view
类型的对象只是引用一个外部的字符序列,而不需要持有它们。因此,一个字符串视图对象可以被看作字符串序列的 引用 。
使用字符串视图的开销很小,速度却很快(以值传递一个string_view
的开销总是很小)。然而,它也有一些潜在的危险,就和原生指针一样,在使用string_view
时也必须由程序员自己来保证引用的字符串序列是有效的。
引子-静态字符串
静态字符串是编译时已经固定的字符串,他们存储在二进制文件的静态存储区,而且程序只能读取,不能改动。
#include <iostream>
#include <string_view>
using namespace std;
int main() {
const char* str_ptr = "this is a static string";
// 字符串数组
char str_array[] = "this is a static string";
// std::string
std::string str = "this is a static string";
// std::string_view
std::string_view sv = "this is a static string";
}
汇编代码如下:
- 直接设置字符串指针,
str_ptr
的汇编代码如下,静态字符串会把指针指向静态存储区,字符串只读。如果尝试修改,会导致段错误
mov QWORD PTR [rbp-24], OFFSET FLAT:.LC0
- 字符串数组,这里使用一个很取巧的办法,不使用循环,而是使用多个
mov
语句把字符串设置到堆栈。在栈上分配一块空间,长度等于字符串的长度+1(因为还需要包括末尾的’\0’字符),然后把字符串拷贝到缓冲区。上述代码编译器把一个长字符串分开为几个64bit
的长整数,逐次mov
到栈缓冲区中, 那几个长长的整数其实是:0x2073692073696874=[ si siht],$0x6369746174732061=[citats a],0x21676e6972747320=[gnirts]
,刚好就是字符串的反序,编译器是用这种方式来提高运行效率的。
movabs rax, 2338328219631577204
movabs rdx, 7163384644223836257
mov QWORD PTR [rbp-64], rax
mov QWORD PTR [rbp-56], rdx
movabs rax, 29113321772053280
mov QWORD PTR [rbp-48], rax
lea rax, [rbp-33]
mov QWORD PTR [rbp-32], rax
- 使用
std::string
。如下汇编代码esi
保存了字符串开始地址,调用std::string
的构造函数。只在寄存器设置了字符串的起始指针,调用了basic_string( const CharT* s,const Allocator& alloc = Allocator() )
构造函数,总之动态内存分配与字符串拷贝是肯定会发生的事情,在构造函数里面至少会有如下操作:确定字符串长度(如strlen
,遍历一遍字符串),按字符串长度(或者预留更多的长度)新建一块内存空间,拷贝字符串到新建的内存空间(第二次遍历字符串)。
lea rdx, [rbp-33]
lea rax, [rbp-96]
mov esi, OFFSET FLAT:.LC0
mov rdi, rax
call std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::basic_string<std::allocator<char> >(char const*, std::allocator<char> const&)
- 使用
std::string_view
,如下汇编代码直接设置字符串的长度0x18
,也就是24Bytes
,还有字符串的起始指针,没有堆内存分配。是单纯设置静态字符串的起始指针和长度,没有其他调用,连内存分配都是栈上的!跟std::string
相比,在创建std::string_view
对象的时候,没有任何动态内存分配,没有对字符串多余的遍历。
mov QWORD PTR [rbp-112], 23
mov QWORD PTR [rbp-104], OFFSET FLAT:.LC0
完整如下:
.LC0:
.string "this is a static string"
main:
push rbp
mov rbp, rsp
push rbx
sub rsp, 104
mov QWORD PTR [rbp-24], OFFSET FLAT:.LC0
movabs rax, 2338328219631577204
movabs rdx, 7163384644223836257
mov QWORD PTR [rbp-64], rax
mov QWORD PTR [rbp-56], rdx
movabs rax, 29113321772053280
mov QWORD PTR [rbp-48], rax
lea rax, [rbp-33]
mov QWORD PTR [rbp-32], rax
nop
nop
lea rdx, [rbp-33]
lea rax, [rbp-96]
mov esi, OFFSET FLAT:.LC0
mov rdi, rax
call std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::basic_string<std::allocator<char> >(char const*, std::allocator<char> const&)
lea rax, [rbp-33]
mov rdi, rax
call std::__new_allocator<char>::~__new_allocator() [base object destructor]
nop
mov QWORD PTR [rbp-112], 23
mov QWORD PTR [rbp-104], OFFSET FLAT:.LC0
lea rax, [rbp-96]
mov rdi, rax
call std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::~basic_string() [complete object destructor]
mov eax, 0
jmp .L10
mov rbx, rax
lea rax, [rbp-33]
mov rdi, rax
call std::__new_allocator<char>::~__new_allocator() [base object destructor]
nop
mov rax, rbx
mov rdi, rax
call _Unwind_Resume
.L10:
mov rbx, QWORD PTR [rbp-8]
leave
ret
.LC1:
.string "basic_string: construction from null is not valid"
和std::string
的不同之处
再看下std::string
的常量字符串的例子
#include <iostream>
#include <string>
using namespace std;
int main()
{
char str_1[]{ "Hello !!, string view" };
string str_2{ str_1 };
string str_3{ str_2 };
return 0;
}
汇编代码如下
main:
push rbp
mov rbp, rsp
push rbx
sub rsp, 120
movabs rax, 2387224940415575368
movabs rdx, 7382362297425403948
mov QWORD PTR [rbp-64], rax
mov QWORD PTR [rbp-56], rdx
movabs rax, 32487705556775535
mov QWORD PTR [rbp-48], rax
lea rax, [rbp-25]
mov QWORD PTR [rbp-24], rax
nop
nop
lea rdx, [rbp-25]
lea rcx, [rbp-64]
lea rax, [rbp-96]
mov rsi, rcx
mov rdi, rax
call std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::basic_string<std::allocator<char> >(char const*, std::allocator<char> const&)
lea rax, [rbp-25]
mov rdi, rax
call std::__new_allocator<char>::~__new_allocator() [base object destructor]
nop
lea rdx, [rbp-96]
lea rax, [rbp-128]
mov rsi, rdx
mov rdi, rax
call std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::basic_string(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&) [complete object constructor]
mov ebx, 0
lea rax, [rbp-128]
mov rdi, rax
call std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::~basic_string() [complete object destructor]
lea rax, [rbp-96]
mov rdi, rax
call std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::~basic_string() [complete object destructor]
mov eax, ebx
jmp .L12
mov rbx, rax
lea rax, [rbp-25]
mov rdi, rax
call std::__new_allocator<char>::~__new_allocator() [base object destructor]
nop
mov rax, rbx
mov rdi, rax
call _Unwind_Resume
mov rbx, rax
lea rax, [rbp-96]
mov rdi, rax
call std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::~basic_string() [complete object destructor]
mov rax, rbx
mov rdi, rax
call _Unwind_Resume
.L12:
mov rbx, QWORD PTR [rbp-8]
leave
ret
.LC0:
.string "basic_string: construction from null is not valid"
从汇编代码可以看到:为了两次查看“Hello !!, string view”, std ::string
对内存执行了两次开销。但是这里的任务是读取字符串(“Hello !!, string view”),不需要对其进行写操作。所以只是为了显示一个字符串却进行多次内存分配。
和std::string
相比,std::string_view
对象有以下特点:
- 底层的字符序列是只读的。没有操作可以修改底层的字符。你只能赋予一个新值、交换值、把视图缩小为字符序列的子序列。
- 字符序列不保证有空字符终止。因此,字符串视图并不是一个 空字符终止的字节流(NTBS) 。
data()
返回的值可能是nullptr
。例如,当用默认构造函数初始化一个字符串视图之后,调用data()
将返回nullptr
。- 没有分配器支持。
因为可能返回nullptr
,并且可能不以空字符结尾,所以在使用operator[]
或data()
之前应该总是使用size()
获取长度(除非你已经知道了长度)。
#include <iostream>
using namespace std;
#include <string_view>
// Driver code
int main()
{
string_view str_1{ "Hello !!, string view" };
string_view str_2{ str_1 };
string_view str_3{ str_2 };
return 0;
}
再来看下汇编代码,是否会感觉清爽了许多。输出是相同的,但不会再创建字符串“Hello !!,string view”的副本。std::string_view
是一个字符串的视图,不需要创建副本。当我们复制std::string_view
时,新的std::string_view
观察到与被复制的std::string_view
相同的字符串,这意味着str
没有创建任何字符串更多的副本,它们只是现有字符串“Hello !!,string view”的视图。
.LC0:
.string "Hello !!, string view"
main:
push rbp
mov rbp, rsp
sub rsp, 48
lea rax, [rbp-16]
mov esi, OFFSET FLAT:.LC0
mov rdi, rax
call std::basic_string_view<char, std::char_traits<char> >::basic_string_view(char const*) [complete object constructor]
mov rax, QWORD PTR [rbp-16]
mov rdx, QWORD PTR [rbp-8]
mov QWORD PTR [rbp-32], rax
mov QWORD PTR [rbp-24], rdx
mov rax, QWORD PTR [rbp-32]
mov rdx, QWORD PTR [rbp-24]
mov QWORD PTR [rbp-48], rax
mov QWORD PTR [rbp-40], rdx
mov eax, 0
leave
ret
应用场景
字符串视图有两个主要的应用:
- 你可能已经分配或者映射了字符序列或者字符串的数据,并且想在不分配更多内存的情况下使用这些数据。典型的例子是内存映射文件或者处理长文本的子串。
- 你可能想提升接收字符串为参数并以只读方式使用它们的函数/操作的性能,且这些函数/操作不需要结尾有空字符。
这种情况的一种特殊形式是想以类似于string
的API
来处理字符串字面量对象:
std::string_view hello{"hello world"};
第一个应用通常意味着只需要传递字符串视图,然而程序逻辑必须保证底层的字符序列仍然有效(即内存映射文件不会中途取消映射)。你也可以在任何时候使用一个字符串视图来初始化或赋值给std::string
。
注意 不要把字符串视图当作“更好的string
”来使用。这样可能导致性能问题和一些运行时错误。
好的地方
下面是使用字符串视图作为只读字符串的例子,这个例子定义了一个函数将传入的字符串视图作为前缀,之后打印一个集合中的元素:
#include <string_view>
template<typename T>
void printElems(const T& coll, std::string_view prefix = {})
{
for (const auto& elem : coll) {
if (prefix.data()) { // 排除nullptr
std::cout << prefix << ' ';
}
std::cout << elem << '\n';
}
}
这里,把函数参数声明为std::string_view
,与声明为std::string
比较起来,可能会减少一次分配堆内存的调用。具体的情况依赖于是否传递的是短字符串和是否使用了短字符串优化(SSO)。例如,如果我们像下面这么声明:
template<typename T>
void printElems(const T& coll, const std::string& prefix = {});
然后传递了一个字符串字面量,那么这个调用会创建一个临时的string
,这将会在堆上分配一次内存,除非使用了短字符串优化。通过使用字符串视图,将不会分配内存,因为字符串视图只 指向 字符串字面量。
注意在使用值未知的字符串视图前应该检查data()
来排除nullptr
。这里为了避免写入额外的空格分隔符,必须检查nullptr
。值为nullptr
的字符串视图写入到输出流时不应该写入任何字符。
另一个例子是使用字符串视图作为只读的字符串来改进std::optional<>
的asInt()
示例,改进的方法就是把参数声明为字符串视图:
#include <optional>
#include <string_view>
#include <charconv> // for from_chars()
#include <iostream>
// 尝试将string转换为int:
std::optional<int> asInt(std::string_view sv)
{
int val;
// 把字符序列读入int:
auto [ptr, ec] = std::from_chars(sv.data(), sv.data() + sv.size(), val);
// 如果有错误码,就返回空值:
if (ec != std::errc{}) {
return std::nullopt;
}
return val;
}
int main()
{
for (auto s : {"42", " 077", "hello", "0x33"}) {
// 尝试把s转换为int,并打印结果:
std::optional<int> oi = asInt(s);
if (oi) {
std::cout << "convert '" << s << "' to int: " << *oi << "\n";
}
else {
std::cout << "can't convert '" << s << "' to int\n";
}
}
}
将asInt()
的参数改为字符串视图之后需要进行很多修改。首先,没有必要再使用std::stoi()
来转换为整数,因为stoi()
的参数是string
,而根据string view
创建string
的开销相对较高。作为代替,我们向新的标准库函数std::from_chars()
传递了字符范围。这个函数需要两个字符指针为参数,分别代表字符序列的起点和终点,并进行转换。注意这意味着我们可以避免单独处理空字符串视图,这种情况下data()
返回nullptr
,
size()
返回0,因为从nullptr
到nullptr+0
是一个有效的空范围(任何指针类型都支持与0相加,并且不会有任何效果)。
std::from_chars()
返回一个std::from_chars_result
类型的结构体,它有两个成员:一个指针ptr
指向未被处理的第一个字符,另一个成员ec
的类型是std:errc
,std::errc{}
代表没有错误。因此,使用返回值中的ec
成员初始化ec
之后(使用了结构化绑定),下面的检查将在转换失败时返回nullopt
:
if (ec != std::errc{}) {
return std::nullopt;
}
使用字符串视图还可以显著提升子字符串排序的性能。
有害的一面
通常“智能对象”例如智能指针会比相应的语言特性更安全(至少不会更危险)。因此,你可能会有一种印象:字符串视图是一种字符串的引用,应该比字符串引用更安全或者至少一样安全。然而不幸的是,事实并不是这样的,字符串视图远比字符串引用或者智能指针更危险。它们的行为更近似于原生字符指针。
不要把临时字符串赋值给字符串视图
考虑声明一个返回新字符串的函数:
std::string retString();
使用返回值总是安全的:
- 用返回值来初始化一个
string
或者用auto
声明的对象是安全的:
std::string s1 = retString(); // 安全
- 用返回值初始化常量
string
引用,只在局部使用时也是安全的。因为引用会延长返回值的生命周期:
std::string& s2 = retString(); // 编译期ERROR(缺少const)
const std::string& s3 = retString(); // s3延长了返回的string的生命周期
std::cout << s3 << '\n'; // OK
auto&& s4 = retString(); // s4延长了返回的string的生命周期
std::cout << s4 << '\n'; // OK
字符串视图没有这么安全,它 既不 拷贝 也不 延长返回值的生命周期:
std::string_view sv = retString(); // sv不延长返回值的生命周期
std::cout << sv << '\n'; // 运行时ERROR:返回值已经被销毁
这里,在第一条语句结束时返回的字符串已经被销毁了,所以使用指向它的字符串视图sv
将会导致未定义的运行时错误。
这个问题类似于如下调用:
const char* p = retString().c_str();
或者:
auto p = retString().c_str();
因此,当使用返回的字符串视图时必须非常小心:
// 非常危险:
std::string_view substring(const std::string& s, std::size_t idx = 0);
// 因为:
auto sub = substring("very nice", 5); // 返回临时string的视图
// 但是临时string已经被销毁了
std::cout << sub << '\n'; // 运行时ERROR:临时字符串s已经被销毁
返回值类型是字符串视图时不要返回字符串
返回值类型是字符串视图时返回字符串是非常危险的。因此,你 不应该 像下面这样写:
class Person {
std::string name;
public:
...
std::string_view getName() const { // 不要这么做
return name;
}
};
这是因为,下面的代码将会产生运行时错误并导致未定义行为:
Person createPerson();
auto n = createPerson().getName(); // OOPS:delete临时字符串
std::cout << "name: " << n << '\n'; // 运行时错误
如果把getName()
改为返回一个字符串类型的值或引用就不会有这个问题了,因为n
将会变为返回值的拷贝。
函数模板应该使用auto
作为返回值类型
注意无意中把字符串作为字符串视图返回是很常见的。例如,下面的两个函数单独看起来都很有用:
// 为字符串视图定义+,返回string:
std::string operator+ (std::string_view sv1, std::string_view sv2) {
return std::string(sv1) + std::string(sv2);
}
// 泛型连接函数
template<typename T>
T concat (const T& x, const T& y) {
return x + y;
}
然而,如果把它们一起使用就很容易导致运行时错误:
std::string_view hi = "hi";
auto xy = concat(hi, hi); // xy是std::string_view
std::cout << xy << '\n'; // 运行时错误:指向的string已经被销毁了
这样的代码很可能在无意中被写出来。真正的问题在于concat()
的返回类型。如果你把返回类型交给编译期自动推导,上面的例子将会把xy
初始化为std::string
:
// 改进的泛型连接函数
template<typename T>
auto concat (const T& x, const T& y) {
return x + y;
}
不要在调用链中使用字符串视图来初始化字符串
在一个中途或者最后需要字符串的调用链中使用字符串视图可能会适得其反。例如,如果你定义了一个有如下构造函数的类Person
:
class Person {
std::string name;
public:
Person (std::string_view n) // 不要这样做
: name {n} {
}
...
};
传递一个字符串字面量或者之后还会使用的string是没问题的:
Person p1{"Jim"}; // 没有性能开销
std::string s = "Joe";
Person p2{s}; // 没有性能开销
然而,使用move
的string
将会导致不必要的开销。因为传入的string
首先要隐式转换为字符串视图,之后会再用它创建一个新的`string``,会再次分配内存:
Person p3{std::move(s)}; // 性能开销:move被破坏
不要在这里使用std::string_view
。以值传参然后把值move
到成员里仍然是最佳的方案(除非你想要双倍的开销):
class Person {
std::string name;
public:
Person (std::string n) : name{std::move(n)} {
}
...
};
如果我们必须创建/初始化一个string
,直接作为参数创建可以让我们在传参时享受所有可能的优化。最后只需要简单的把参数move
就行了,这个操作开销很小。因此,如果我们使用一个返回临时字符串的辅助函数来初始化一个字符串:
std::string newName()
{
...
return std::string{...};
}
Person p{newName()};
强制省略拷贝特性将把新string
的实质化过程推迟到值被传递给构造函数。构造函数里我们有了一个叫n
的string
,这是一个有内存地址的对象(一个 广义左值(glvalue) )。之后把这个对象的值move
到成员name
里。
安全使用字符串视图的总结
总结起来就是 小心地使用std::string_view
,也就是说你应该按下面这样调整你的编码风格:
- 不要在那些会把参数传递给
string
的API
中使用string view
。 - 不要用
string view
形参来初始化string
成员。 - 不要把
string
设为string view
调用链的终点。 - 不要返回
string view
。 - 除非它只是转发输入的参数,或者你可以标记它很危险,例如,通过命名来体现危险性。
- 函数模板 永远不应该返回泛型参数的类型 T 。
- 作为替代,返回
auto
类型。 - 永远不要用返回值来初始化
string view
。 - 不要 把返回泛型类型的函数模板的返回值赋给
auto
。 - 这意味着AAA( 总是
auto
(Almost Always Auto) )原则不适用于string view
。
如果因为这些规则太过复杂或者太困难而不能遵守,那就完全不要使用std::string_view
(除非你知道自己在做什么)。
类型和操作
类型
在头文件<string_view>
中,C++
标准库为basic_string_view<>
提供了很多特化版本:
- 类
std::string_view
是预定义的字符类型为char
的特化模板:
namespace std {
using string_view = basic_string_view<char>;
}
- 对于使用宽字符集,例如
Unicode
或者某些亚洲字符集的字符串,还定义了另外三个类型:
namespace std {
using u8string_view = basic_string_view<char8_t>;/*c++20起*/
using u16string_view = basic_string_view<char16_t>;
using u32string_view = basic_string_view<char32_t>;
using wstring_view = basic_string_view<wchar_t>;
}
在下文中,使用哪一种字符串视图并没有任何区别。这几种字符串视图类的用法和问题都是一样的,因为它们都有相同的接口。因此,“string view”意味着任何string view
类型:string_view
、u8string_view
、u16string_view
、u32string_view
、wstring_view
。
操作
表字符串视图的操作列出了字符串视图的所有操作。
操作 | 效果 |
---|---|
构造函数 | 创建或拷贝一个字符串视图 |
析构函数 | 销毁一个字符串视图 |
= | 赋予新值 |
swap() | 交换两个字符串视图的值 |
==、!=、<、<=、>,>=、compare() | 比较字符串视图 |
empty() | 返回字符串视图是否为空 |
size()、length() | 返回字符的数量 |
max_size() | 返回可能的最大字符数 |
[]、at() | 访问一个字符 |
front()、back() | 访问第一个或最后一个字符 |
<< | 将值写入输出流 |
copy() | 把内容拷贝或写入到字符数组 |
data() | 返回nullptr 或常量字符数组(没有空字符终止) |
查找函数 | 查找子字符串或字符 |
begin()、end() | 提供普通迭代器支持 |
cbegin()、cend() | 提供常量迭代器支持 |
rbegin()、 rend() | 提供反向迭代器支持 |
crbegin()、crend() | 提供常量反向迭代器支持 |
substr() | 返回子字符串 |
remove_prefix() | 移除开头的若干字符 |
remove_suffix() | 移除结尾的若干字符 |
hash<> | 计算哈希值的函数对象的类型 |
除了remove_prefix
和remove_suffix()
之外,所有字符串视图的操作std::string
也都有。然而,相应的操作的保证可能有些许不同,data()
的返回值可能是nullptr
或者没有空字符终止。
构造
constexpr basic_string_view() noexcept;
constexpr basic_string_view(const basic_string_view&) noexcept = default;
constexpr basic_string_view(const CharT* str);
constexpr basic_string_view(const CharT* str, size_type len);
你可以使用很多种方法来创建字符串视图:用默认构造函数创建、用拷贝函数构造创建、从原生字符数组创建(空字符终止或者指明长度)、从std::string
创建或者从带有sv
后缀的字面量创建。然而,注意以下几点:
- 默认构造函数
constexpr basic_string_view() noexcept;
。构造空的 std::basic_string_view。构造后,data() 等于 nullptr,而 size() 等于 0。因此,operator[]
调用将无效。
std::string_view sv;
auto p = sv.data(); // 返回nullptr
std::cout << sv[0]; // ERROR:没有有效的字符
- 当使用空字符终止的字节流初始化字符串视图时,最终的大小是不包括
'\0'
在内的字符的数量,另外索引空字符所在的位置是无效的:
constexpr basic_string_view(const CharT* str);
constexpr basic_string_view(const CharT* str, size_type len);
std::string_view sv{"hello"};
std::cout << sv; // OK
std::cout << sv.size(); // 5
std::cout << sv.at(5); // 抛出std::out_of_range异常
std::cout << sv[5]; // 未定义行为
std::cout << sv.data(); // OOPS:恰好sv后边还有个'\0',所以能直接输出字符指针
- 你可以指定传递的字符数量来把空字符初始化为字符串视图的一部分:
#include <iostream>
#include <iterator> // For std::size
#include <string_view>
int main() {
// No null-terminator.
char vowels[]{'a', 'e', 'i', 'o', 'u'};
// 不是以null结尾。我们需要手动传递长度
// 因为vowels是一个数组,所以可以使用std::size来获取它的长度。
std::string_view str{vowels, std::size(vowels)};
std::cout << str << '\n'; // 这是安全的。std::cout知道如何打印std::string_view。
std::string_view sv{"hello", 6}; // NOTE:包含'\0'的6个字符
std::cout << sv.size() << std::endl; // 6
std::cout << sv.at(5) <<std::endl; // OK,打印出'\0'的值
std::cout << sv[5] <<std::endl;; // OK,打印出'\0'的值
std::cout << sv.data()<<std::endl;; // OK
return 0;
}
- 为了从一个
string
创建一个字符串视图,有一个为std::string
定义的隐式转换运算符。再强调一次,string
保证在最后一个字符之后有一个空字符,字符串视图没有这个保证:
std::string s = "hello";
std::cout << s.size(); // 5
std::cout << s.at(5); // 抛出std::out_of_range异常
std::cout << s[5]; // OK,打印出'\0'的值
std::cout << s.data(); // OK
std::string_view sv{s};
std::cout << sv.size(); // 5
std::cout << sv.at(5); // 抛出std::out_of_range异常
std::cout << sv[5]; // 未定义行为
std::cout << sv.data(); // OOPS:只有当sv后有'\0'时才能正常工作
预处理代码如下:
std::basic_string<char> s = std::basic_string<char>("hello", std::allocator<char>());
...
std::basic_string_view<char, std::char_traits<char> > sv = {static_cast<std::basic_string_view<char, std::char_traits<char> >>(s.operator std::basic_string_view<char, std::char_traits<char> >())};
...
汇编代码如下:
;std::string s = "hello";
lea rdx, [rbp-25]
lea rax, [rbp-64]
mov esi, OFFSET FLAT:.LC0
mov rdi, rax
call std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::basic_string<std::allocator<char> >(char const*, std::allocator<char> const&)
;std::string_view sv{s};
lea rax, [rbp-64]
mov rdi, rax
call std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::operator std::basic_string_view<char, std::char_traits<char> >() const
mov QWORD PTR [rbp-80], rax
mov QWORD PTR [rbp-72], rdx
lea rax, [rbp-64]
mov rdi, rax
call std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::~basic_string() [complete object destructor]
mov eax, 0
jmp .L10
- 从字符数组字面量组成
string_view
inline namespace literals {
inline namespace string_view_literals {
// suffix for basic_string_view literals
constexpr string_view operator""sv(const char* str, size_t len) noexcept;
constexpr u8string_view operator""sv(const char8_t* str, size_t len) noexcept;
constexpr u16string_view operator""sv(const char16_t* str, size_t len) noexcept;
constexpr u32string_view operator""sv(const char32_t* str, size_t len) noexcept;
constexpr wstring_view operator""sv(const wchar_t* str, size_t len) noexcept;
}
}
str
- 指向无修饰字符数组字面量起始的指针len
- 无修饰字符数组字面量的长度
因为后缀sv
定义了字面量运算符,所以可以像下面这样创建一个字符串视图:
using namespace std::literals;
auto s = "hello"sv;
注意std::char_traits
成员被改为了constexpr
,所以你可以在编译期用一个字符串字面量初始化字符串视图:
constexpr string_view hello = "Hello World!";
预处理代码如下:
std::basic_string_view<char, std::char_traits<char> > s = std::operator""sv("hello", 5UL);
综合示例:
#include <iostream>
#include <string_view>
#include <typeinfo>
void print_each_character(const std::string_view sw)
{
for (char c : sw)
std::cout << (c == '\0' ? '@' : c);
std::cout << '\n';
}
int main()
{
using namespace std::literals;
std::string_view s1 = "abc\0\0def";
std::string_view s2 = "abc\0\0def"sv;
std::cout << "s1.size(): " << s1.size() << "; s1: ";
print_each_character(s1);
std::cout << "s2.size(): " << s2.size() << "; s2: ";
print_each_character(s2);
std::cout << "substr(1, 4): " << "abcdef"sv.substr(1, 4) << '\n';
auto value_type_info = []<typename T>(T)
{
using V = typename T::value_type;
std::cout << "sizeof " << typeid(V).name() << ": " << sizeof(V) << '\n';
};
value_type_info("char A"sv);
value_type_info(L"wchar_t ∀"sv);
value_type_info(u8"char8_t ∆"sv);
value_type_info(u"char16_t ∇"sv);
value_type_info(U"char32_t ∃"sv);
value_type_info(LR"(raw ⊞)"sv);
}
运行结果如下:
s1.size(): 3; s1: abc
s2.size(): 8; s2: abc@@def
substr(1, 4): bcde
sizeof c: 1
sizeof w: 4
sizeof c: 1
sizeof Ds: 2
sizeof Di: 4
sizeof w: 4
结尾的空字符
一般情况下,字符串视图的值不以空字符结尾,甚至可能是nullptr
。因此,你应该 总是 在访问字符串视图的字符之前检查size()
(除非你知道了长度)。然而,你 可能 会遇到两个让人迷惑的特殊场景:
- 你可以确保字符串视图的值以空字符结尾,尽管空字符并不是值的一部分。当你用字符串字面量初始化字符串视图时就会遇到这种情况:
std::string_view sv1{"hello"}; // sv1的结尾之后有一个'\0'
这里,字符串视图的状态可能让人很困惑。这种状态有明确的定义所以可以将它用作空字符结尾的字符序列。然而,只有当我们明确知道这个字符串视图后有一个不属于自身的空字符时才有明确的定义。
- 你可以确保
'\0'
成为字符串视图的一部分。例如:
std::string_view sv2{"hello", 6}; // 参数6使'\0'变为值的一部分
这里,字符串视图的状态可能让人困惑:打印它的话看起来像是只有5个字符,但它的实际状态是持有6个字符(空字符成为了值的一部分,使它变得更像一个两段的字符串(视图)(binary string(view
)))。
问题在于要想确保字符串视图后有一个空字符的话,这两种方式哪一种更好。我倾向于不要用这两种方式中的任何一种,但截止目前,C++
还没有更好的实现方式。看起来我们似乎还需要一个既保证以空字符结尾又不需要拷贝字符的字符串视图类型(就像std::string
一样)。在没有更好的替代的情况下,字符串视图就只能这么用了。
哈希
C++
标准库保证值相同的字符串和字符串视图的哈希值相同。
template<class T> struct hash;
template<> struct hash<string_view>;
template<> struct hash<u8string_view>;
template<> struct hash<u16string_view>;
template<> struct hash<u32string_view>;
template<> struct hash<wstring_view>;
std::hash
对各种字符串类的模板特化允许用户获得字符串的哈希。
这些哈希等于对应std::basic_string_view
类的哈希:若 S
是这些字符串类型之一,SV
是对应的字符串视图类型,而 s
是 S
类型的对象,则 std::hash<S>()(s) == std::hash<SV>()(SV(s))
。
#include <iostream>
#include <string_view>
#include <unordered_set>
using namespace std::literals;
int main()
{
std::cout << "\"A\" #: " << std::hash<std::string_view>{}("A"sv) << '\n';
std::cout << "L\"B\" #: " << std::hash<std::wstring_view>{}(L"B"sv) << '\n';
// std::cout << "u8\"C\" #: " << std::hash<std::u8string_view>{}(u8"C"sv) << '\n';/*c++20引入*/
std::cout << "u\"D\" #: " << std::hash<std::u16string_view>{}(u"D"sv) << '\n';
std::cout << "U\"E\" #: " << std::hash<std::u32string_view>{}(U"E"sv) << '\n';
/*
string_view类型的std::hash使得我们可以将这些视图类型存储在unordered_*关联容器中,例如unordered_set。
但是要确保所引用的字符串的生命周期不少于容器的生命周期,即不会出现悬空引用。
*/
std::unordered_set stars{"Rigel"sv, "Capella"sv, "Vega"sv, "Arcturus"sv};
for (std::string_view const& s : stars)
std::cout << s << ' ';
std::cout << '\n';
}
输出结果如下:
"A" #: 6919333181322027406
L"B" #: 11959850520494268278
u"D" #: 312659256970442235
U"E" #: 18073225910249204957
Arcturus Vega Capella Rigel
修改
- 你可以赋予新值或者交换两个字符串视图的值:
#include <iostream>
#include <string_view>
#include <typeinfo>
using namespace std;
int main() {
std::string_view sv1 = "hey";
std::string_view sv2 = "world";
cout << "before sv1 = " << sv1 << "; sv2 = " << sv2 << endl;
sv1.swap(sv2); //交换
cout << "After sv1 = " << sv1 << "; sv2 = " << sv2 << endl;
sv2 = sv1;
cout << "assign sv1 = " << sv1 << "; sv2 = " << sv2 << endl;
}
结果如下:
before sv1 = hey; sv2 = world
After sv1 = world; sv2 = hey
assign sv1 = world; sv2 = world
- 你可以跳过开头或结尾的字符(即把起始位置后移或者把结尾位置前移)。
#include <iostream>
#include <string_view>
using namespace std;
int main() {
std::string_view sv = "I like my kindergarten";
sv.remove_prefix(2);
sv.remove_suffix(8);
std::cout << sv <<'\n'; // 打印出:like my kind
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';
}
输出
like my kind
Peach
each
ea
注意没有对operator+
的支持。因此:
std::string_view sv1 = "hello";
std::string_view sv2 = "world";
auto s1 = sv1 + sv2; // ERROR
一个操作数必须是string:
auto s2 = std::string(sv1) + sv2; // OK
注意字符串视图没有到string
的隐式类型转换,因为这个操作会分配内存所以开销很大。因此,只能使用显式的转换。
其他支持
理论上讲,任何需要传递字符串值的地方都可以传递字符串视图,前提是接收者只读取值且不需要空字符结尾。然而,到目前为止,C++
标准只为大多数重要的场景添加了支持:
- 使用字符串时可以联合使用字符串视图:
- 你可以从一个字符串视图创建一个
string
(构造函数是explicit
的)。如果字符串视图没有值(data()
返回nullptr
),字符串将被初始化为空。 - 你可以把字符串视图用作字符串的赋值、扩展、插入、替换、比较或查找操作的参数。
- 存在从
string
到string view
的隐式类型转换。 - 你可以把字符串视图传给
std::quoted
,它把参数用双引号括起来输出。例如:
using namespace std::literals;
auto s = R"(some\value)"sv; // raw string view
std::cout << std::quoted(s); // 输出:"some\value"
- 你可以使用字符串视图初始化、扩展或比较文件系统路径。
其他对字符串视图的支持,例如C++
标准库中的正则表达式库的支持,仍然缺失。
在API中使用字符串视图
字符串视图开销很小并且每一个std::string
都可以用作字符串视图。因此,看起来好像std::string_view
是更好的用作字符串参数的类型。然而,有一些细节很重要…
首先,只有当函数按照如下约束使用参数时,使用std::string_view
才有意义:
- 它并不需要结尾有空字符。给一个以单个
const char*
为参数而没有长度参数的C函数传递参数时就不属于这种情况。 - 它不会违反传入参数的生命周期。通常,这意味着接收函数只会在传入值的生命周期结束之前使用它。
- 调用者函数不应该更改底层字符的所有权(例如销毁它、改变它的值或者释放它的内存)。
- 它可以处理参数值为
nullptr
的情况。
注意同时有std::string
和std::string_view
重载的函数可能会导致歧义:
void foo(const std::string&);
void foo(std::string_view);
foo("hello"); // ERROR:歧义
最后,记住上文提到的警告:
- 不要把临时字符串赋给字符串视图。
- 不要返回字符串视图。
- 不要在调用链中使用字符串视图来初始化或重设字符串的值。
带着这些考虑,让我们来看一些使用字符串视图进行改进的例子。
使用字符串视图代替string
考虑下列代码:
// 带前缀输出时间点:
void print (const std::string& prefix, const std::chrono::system_clock::time_point& tp)
{
// 转换为日历时间:
auto rawtime{std::chrono::system_clock::to_time_t(tp)};
std::string ts{std::ctime(&rawtime)}; // 注意:不是线程安全的
ts.resize(ts.size()-1); // 跳过末尾的换行符
std::cout << prefix << ts;
}
可以被替换为下列代码:
void print (std::string_view prefix, const std::chrono::system_clock::time_point& tp)
{
auto rawtime{std::chrono::system_clock::to_time_t(tp)};
std::string_view ts{std::ctime(&rawtime)}; // 注意:不是线程安全的
ts.remove_suffix(1); // 跳过末尾的换行符
std::cout << prefix << ts;
}
最先想到也是最简单的改进就是把只读字符串引用prefix
换成字符串视图,只要我们不使用会因为没有值或者没有空终止符而失败的操作就可以。这个例子中我们只是打印字符串视图的值,这是没问题的。如果字符串视图没有值(data()
返回nullptr
)将不会输出任何字符。注意字符串视图是以值传参的,因为拷贝字符串视图的开销很小。
我们也对内部ctime()
返回的值使用了字符串视图。然而,我们必须小心保证当我们在字符串视图中使用它时它的值还存在。也就是说,这个值只有在下一次ctime()
或者asctime()
调用之前有效。因此,在多线程环境下,这个函数将导致问题(使用string时也有一样的问题)。如果函数返回把前缀和时间点连接起来的字符串,代码可能会像下面这样:
std::string toString (std::string_view prefix, const std::chrono::system_clock::time_point& tp)
{
auto rawtime{std::chrono::system_clock_to_time_t(tp)};
std::string_view ts{std::ctime(&rawtime)}; // 注意:不是线程安全的
ts.remove_suffix(1); // 跳过末尾的换行符
return std::string{prefix} + ts; // 很不幸没有两个字符串视图的+运算符
}
注意我们不能简单地用operator+
连接两个字符串视图。我们必须把其中一个转换为std::string
(很不幸这个操作会分配不必要的内存)。如果字符串视图没有值(data()
返回nullptr
),字符串将为空。
另一个使用字符串视图的例子是使用字符串视图和并行算法来排序子字符串:
sort(std::execution::par, coll.begin(), coll.end(),
// 译者注:此处原文是
// sort(coll.begin(), coll.end(),
// 应是作者笔误
[] (const auto& a, const auto& b) {
return std::string_view{a}.substr(2) < std::string_view{b}.substr(2);
});
要比使用string
的子字符串快得多:
sort(std::execution::par, coll.begin(), coll.end(),
[] (const auto& a, const auto& b) {
return a.substr(2) < b.substr(2);
});
这是因为string
的substr()
函数会返回一个分配自己内存的新字符串。
constexpr std::string_view
#include <iostream>
#include <string_view>
int main()
{
constexpr std::string_view s{ "Hello, world!" };
std::cout << s << '\n'; // s will be replaced with "Hello, world!" at compile-time
return 0;
}
且看汇编代码
.LC0:
.string "Hello, world!"
main:
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-16], 13
mov QWORD PTR [rbp-8], OFFSET FLAT:.LC0
mov eax, 0
pop rbp
ret
将新字符串分配给 std::string_view
导致 std::string_view
以查看新字符串。
它不会导致以任何方式更改正在查看的先前字符串。
#include <iostream>
#include <string>
#include <string_view>
int main() {
std::string name{"Alex"};
std::string_view sv{name};
std::cout << sv << '\n'; // 打印出 Alex
sv = "John"; // sv现在是 "John" 的窗口了(不会改变原有的name的值)
std::cout << sv << '\n'; // 打印出 John
std::cout << name << '\n'; // 打印出 Alex
return 0;
}
输出
Alex
John
Alex
std::string_view
->std::string
的昂贵转换
std::string
会复制其初始化器(这很昂贵),因此C++
不允许将std::string_view
隐式转换为std::string
。但是,我们可以使用std::string_view
初始化程序显式创建一个td::string
,或者使用static_cast
将现有的std::string_view
转换为std::string
#include <iostream>
#include <string>
#include <string_view>
void printString(std::string str)
{
std::cout << str << '\n';
}
int main()
{
std::string_view sv{ "balloon" };
std::string str{ sv }; // okay, we can create std::string using std::string_view initializer
// printString(sv); // compile error: won't implicitly convert std::string_view to a std::string
printString(static_cast<std::string>(sv)); // okay, we can explicitly cast a std::string_view to a std::string
return 0;
}
参考
[1] C++17剖析:string_view的实现,以及性能
[2] class std::string_view in C++17
[3] std::string_view (part 2)