20 KiB
36|访问对象的代理对象:视图类型
你好,我是吴咏炜。
前面我们用连续五讲讨论了内存相关的很多问题,这是因为在 C++ 里,开发人员需要认真考虑对象的生命周期,包括对其内存进行管理。我们需要保证对象使用的内存,不会在对象还在使用时就会被释放。在其他一些语言里,或者使用跟踪垃圾收集,或者使用引用计数,可以自动化这一过程。C++ 理论上来讲也可以这样做,如到处使用 shared_ptr
,但这样的话,程序的执行性能就会受到影响。我们之所以有这么多灵活的机制来控制内存的使用,就是为了让程序员对内存的分配和释放有最大的控制权,在需要的场合下得到最高的效率。付出的代价当然就是语言的复杂性了。
但我们还有另外一类问题,我们在使用一个对象时,明确知道这个对象在使用过程中一直存在,它的生命周期一定会超出我们的使用时间。在这种情况下,如果我们要使用这个对象,或者这个对象的一部分,就没有必要进行内存分配和对象创建、复制、销毁了。按引用或指针来访问这类对象是一种可能性,但通过一个代理对象来访问底层数据往往更加灵活和方便,并可以提供接口上的一致性。这类代理对象我们通常以值的方式进行传参和返回,非常简单、也非常高效。string_view
就是这样的一种对象类型。
string_view
string_view
是 C++17 引入的一种新类型,它提供了非常方便的传递字符串(或其中一部分)的方式 [1]。我们先来看看它的基本用法。
示例
下面是一个非常简单的使用 string_view
的例子:
string greet(string_view name)
{
string result("Hi, ");
result += name;
result += '!';
return result;
}
这个 greet
函数接受一个 string_view
,然后生成一个字符串返回。显然,我们可以传递一个 string_view
对象给这个函数,但更重要的是,我们可以传递其他更常用的字符串类对象,包括字符串字面量和 string
。
这样是可以的:
auto greeting = greet("C++");
这样也是可以的:
string name;
getline(cin, name);
auto greeting = greet(name);
原理
我们可以这样做的原因,是因为 string_view
可以通过(常)字符指针来构造,而 string
也能自动转换成 string_view
。究其本质,string_view
只保存两样东西:
- 一个
const char*
,指向字符串的开头 - 一个
size_t
,表示字符串的长度
换句话说,string_view
是一个字符串的视图,不保存字符串,而只保存字符串的指针和长度。使用者需要确保在使用 string_view
的时候,底层的字符串一直存在。
想要构造一个 string_view
,你可以提供一个指针加一个长度。不过,更常见的用法,仍然是通过字符串字面量来构造 string_view
,及把 string
自动转换成 string_view
。
这里顺便提一下第 3 讲里说过的字符串字面量是左值的原因。对于一个字符串字面量,编译器实际上是会默认生成一个静态的字符串对象的,即上面的前一种写法基本等效于:
static const char _str1[] = "C++";
auto greeting = greet(_str1);
无论使用上面的前一种写法(使用字符串字面量作为实参)还是后一种写法(使用 string
作为实参),显然,在 greet
函数的运行期间,我们都完全不需要担心字符串的生命周期。
生命周期问题
反过来,当然我们也会有一些可能出问题的场合。比如,下面这种写法就是有问题的:
string_view name = "C++"s;
估计这种代码一般不会有人写,但这个代码在语法上是完全合法的。它的意思是从一个临时 string
对象来生成一个 string_view
,而问题在于,在这行语句执行结束时,临时 string
对象就已经不存在。因此 string_view
对象会指向已经被销毁的字符串对象,导致未定义行为。你后面再去使用 name
的话,会发现它有时有你期望的内容,有时则是乱码,有时甚至可能导致程序崩溃。遗憾的是,目前(2022 年)的主流 C++ 编译器里,只有 Clang 会对这样的代码进行告警。
另外一种可能的出错场景是把 string_view
存下来或返回。在 greet
的执行期间,正常的代码没有任何理由会修改底层字符串或发生生命周期问题;即使我们用类似上面错误的方式写 greet("C++"s)
,代码仍然是完全合法的,因为临时字符串对象的析构动作会发生在 greet
函数返回之后。但如果这个函数把 string_view
存下来或返回,则又是另外一个故事——类似于上面的错误就又可能发生了。
string_view 的好处
你可能会想,既然有生命周期的陷阱,那我们为什么要使用 string_view
呢?
因为好处也是很大的。我们可以检查一下上面这个函数的替换接口形式:
- 我们可以使用
greet(const string&)
这样的按引用传参方式。这样的参数形式对string
实参当然很友好,但对于字符串字面量就不友好了。虽然使用字符串字面量看起完全自动很正常,但编译器产生的代码是相当无聊和低效的:它会生成一个临时string
对象,把字符串字面量中的内容全部复制进去,然后拿这个临时对象去调用greet
函数,并在函数返回之后销毁这个临时的string
对象。 - 我们可以使用
greet(const char*)
这样的传统接口。这样的参数形式对字符串字面量实参很友好,但对string
对象来讲,就不方便了——我们会需要使用s.c_str()
这样的形式来传参。还有,如果这个字符串很长,获取字符串的长度也会是一个低效的 O(n) 操作。此外,我们也没法直接使用string
类提供的方便方法了,如find
、substr
等。
如果我们把形参替换成 string_view
的话:
- 当我们传递的实参为
string
时,string
会使用内部指针和长度高效地生成string_view
对象。 - 当我们传递的实参可退化为
const char*
时,那编译器会自动获取这个字符串的长度(通过调用char_traits<char>::length(s)
)。这里又可以细分为两种情况:字符串内容在编译时确定(即字符串字面量),及字符串内容在编译时不确定。当字符串内容在编译时可确定时,string_view
具有最大的优势:不仅我们没有任何额外的开销,而且目前的主流优化编译器都可以在编译时算出字符串的长度,因而可以产生最高效的代码。否则,string_view
会在代码执行时去动态获取字符串的长度,在你后续需要字符串长度时也非常合适,不算额外开销。
此外,虽然 string_view
不是 string
,它的成员函数跟 string
还是非常相似的。我们同样有 data
、size
、begin
、end
、find
等方法。它跟 string
最为显著的不同点是:
- 你不能修改字符串的内容。
data
成员函数返回的是const char*
,而不像string
的data
成员函数从 C++17 开始可以返回char*
,允许程序员直接通过指针修改底层的字符串(当然,不允许超过尾部)。 - 没有
c_str
成员函数。从语义上说,string
的data
成员函数只是返回指针,在 C++11 之前甚至不保证字符串会零结尾;而只有c_str
是从 C++98 开始就一直保证返回的字符串是零结尾的。string_view
的data
成员函数返回的字符串又不保证零结尾了,即使我们构造string_view
使用的字符串是零结尾的——因为只有这样,我们才能高效地取出string_view
的一部分,形成一个新的string_view
对象。这也意味着,我们在需要把字符串指针传到期待零结尾字符串的 C 函数接口里去时,使用string_view
是不合适的。 substr
成员函数返回的是一个新的string_view
,而非string
。产生新的指针和长度只是简单的加减运算,当然也就很高效,但别忘了,刚说过的,产生的结果可能不是零结尾,即使原始的string_view
是零结尾。- 我们额外有成员函数
remove_prefix
和remove_suffix
,可以修改当前string_view
对象(但不会动底下的字符串)。remove_prefix
去掉开头的若干字符,因而如果string_view
原先是零结尾的话,现在仍然是零结尾;remove_suffix
去掉结尾的若干字符,显然,即使string_view
原先是零结尾的,在这个操作之后就不再是零结尾的了。
最后,强调一点,我上面一直在讲 string_view
,那主要是因为对于不开发 Windows 应用的人来说,string_view
一般就已经够用了。实际上,string_view
跟 string
一样,是一个类型别名:std::string_view
相当于 std::basic_string_view<char>
。我们是可以使用其他字符类型去特化 basic_string_view
的,系统也已经帮我们定义了相应的别名,如 wstring_view
、u32string_view
等等。你可以根据自己的需要进行选用。
span
C++20 引入的 span
是另外一个非常有用的视图类型 [2]。如果你想在 C++14/17 的环境里使用 span
的话,则可以使用微软 GSL 库中定义的 gsl::span
[3]。除了名空间的不同(std
还是 gsl
),它们目前行为基本一致,除了一点:gsl::span
会做越界检查,因而更安全,但也可能因此带来一些性能问题。我们后面会再来讨论这一点。
示例
同样,我们先通过一些例子来对 span
有一些直观的了解。
假设我们有一个通用的打印整数序列的函数:
void print(span<int> sp)
{
for (int n : sp) {
cout << n << ' ';
}
cout << '\n';
}
我们可以使用各种各样提供连续存储的整数“容器”作为实参传给 print
函数。比如,下面这些变量都是可以传递给 print
的:
array a{1, 2, 3, 4, 5};
int b[]{1, 2, 3, 4, 5};
vector v{1, 2, 3, 4, 5};
而不提供连续存储的容器则不能这么用,如:
list lst{1, 2, 3, 4, 5};
但是,如果你认为 span<char>
和 string_view
有对应关系的话,那就错了。最核心的区别在于,span<char>
会允许你更改底层的数据,而 string_view
不允许。刨除接口上的区别,span<const char>
跟 string_view
有相似之处。我上面给出的 print
实际是 const 不正确的,你如果有一个容器的 const 引用的话,将无法使用 print
函数来打印。
正确的 print
版本和另外一个修改容器内容的 increase
函数如下所示:
void print(span<const int> sp)
{
for (int n : sp) {
cout << n << ' ';
}
cout << '\n';
}
void increase(span<int> sp,
int value = 1)
{
for (int& n : sp) {
n += value;
}
}
如果我们调用 increase(a)
的话,a
的内容就会变为 {2, 3, 4, 5, 6}
。
一些技术细节
我们可以直接使用指针加长度来构造 span
,我们也可以用连续存储的序列范围作为参数来构造 span
(GSL 和 C++20 使用了不同的方法来限制容器类型,但结果仍是基本一致的),一般有:
- C 风格数组
array
vector
- 其他
span
跟连续存储的序列容器(如 vector
)及 string_view
一样,span
具有一些标准的 STL 成员函数,如:
begin
end
front
back
size
empty
data
operator[]
- ……
span
也有一些自己特有的成员函数:
size_bytes
:字节数来计算的序列大小(而非元素数)first
:开头若干项组成的新span
(注意这和string_view::remove_prefix
和string_view::remove_suffix
代码风格不同,不修改自身)last
:结尾若干项组成的新span
(注意这和string_view::remove_prefix
和string_view::remove_suffix
代码风格不同,不修改自身)subspan
:根据给定的偏移量和长度组成的新span
(这和string_view::substr
就比较类似了)
span
还有一个特点,它的长度可以是编译期确定的。它有第二个模板参数 extent
,默认值是 dynamic_extent
,代表动态的长度,这种方式较为常用和灵活。但如果你的 span
可以在编译期确定长度的话,你也完全可以利用这一特性来对代码进行进一步的优化。事实上,对于数组和 array
的情况,如果你不指定模板参数的话,默认推导就会得出一个编译期固定的长度。
比如,对于我们前面定义的变量 a
,我们使用 span sp{a};
这样的声明会产生的实际类型不是 span<int, dynamic_extent>
,而是 span<int, 5>
。由于长度编码在类型里,长度不占用内存空间,因而它比 span<int>
一般要少占用一半内存(虽然这通常不重要)。同时,动态长度的 span
能通过静态长度的 span
构造出来,因此把这个静态长度的 sp
传给 print
函数也没有问题。
最后,再重复一遍,span
本质上就是指针加长度的一个语法糖,程序员必须保证在使用 span
时,底层的数据一直合法地存在,否则会导致未定义行为。我曾经见过一个很隐晦的 bug,本质上代码差不多是下面这个样子(Data
是某个结构体):
span<Data> sp;
…
if (…) {
vector<Data> v = …;
sp = v;
}
DoSomething(sp);
这就是一个典型的释放后使用。麻烦的是,在单线程的情况下,代码运行通常不会出错,你很难发现里面的问题。问题通常在多线程环境才会暴露出来:有其他线程正好分配到了被释放的内存,并在 DoSomething
执行完之前往里写入了其他内容。这显然不是一个可以非常容易复现的问题,你可以想象一下测试人员在抓这个虫子的时候有多么的苦恼……
gsl::span 的性能问题
前面我提到过,gsl::span
会做越界检查,更安全,但也因此可能带来一些性能问题。最典型的情况就是把一个 span
的内容复制到另一个 span
里去,如:
std::copy(sp1.begin(),
sp1.end(),
sp2.begin());
目前测试下来,除了 MSVC 标准库的 copy
实现对 span
有特殊的处理逻辑,其他环境都会因为每拷贝一个元素都要执行越界检查而导致巨大的性能损失。当然,取决于具体的编译器,产生的影响也各不相同。在最坏的情况下,我看到过使用 gsl::span
要比使用 std::span
性能劣化几十倍!
所幸,这个问题有一个非常简单的解决方法,使用 gsl::copy
即可:
gsl::copy(sp1, sp2);
这个写法简单、有边界检查,也没有额外的开销,看一下 gsl::copy
的源代码,你就知道它是先检查边界,再使用指针和长度进行拷贝:
Expects(dest.size() >= src.size());
std::copy_n(src.data(), src.size(), dest.data());
有兴趣的话,你可以拿我放在代码库的测试程序来自行测试一下。
视图类型
通过以上两个例子,我想你基本已经知道视图类型是怎么回事了。一般而言,视图类型:
- 是个小对象,可以在常数时间拷贝、移动或赋值
- 一般以传值方式来使用(除非你想修改视图本身,如将其缩小)
- 跟容器一样支持遍历操作
- 不持有数据,使用者需要保证在视图存续期间其指向的数据一直存在(不过,像
shared_ptr
一样通过引用计数来持有底层对象在实现上也是允许的)
到 C++17 为止,视图还不是一个语言层面能真正表达的概念。而到了 C++20,我们就真正有了 view
这个概念,来支持对视图的表达 [4]。
不过,从实用的角度,程序员更高兴的应该是 C++20 范围库里提供的各种有用的视图了 [5]。我在第 29 讲里介绍过一些,今天我再讲一个 elements_view
作为例子 [6]。
对于一个有类 tuple
元素类型的容器(包括 map
、unordered_map
、vector<tuple<…>>
等),elements_view
的作用是形成所有元素中的某一项的视图。特别地,取第 0 项的也被称为 keys_view
(keys_view<R>
相当于 elements_view<R, 0>
),取第 1 项的也被称为 values_view
(values_view<R>
相当于 elements_view<R, 1>
)。这就使得我们访问一个 map
中的所有“键”(keys)或所有“值”(values)变得非常方便。
比如,如果使用我之前介绍的 output_container 的“升级”版本 output_range [7],我们可以用下面的代码来输出 map
中的第二项:
map<int, string> mp{{1, "one"},
{2, "two"},
{3, "three"}};
auto vv = mp | views::values;
cout << vv << endl;
vv
就是一个 mp
里所有“值”的视图,它的实际类型相当复杂,你不会想手工把它写出来的——这点上,范围库里的视图跟我们前面介绍的 string_view
和 span
不同。不过,你仍然可以用 auto
来对它进行接收和复制,这些都是非常轻量的操作。程序实际产生的输出为:
{ one, two, three }
内容小结
本讲我们介绍了几个有用的视图类型。使用它们,你可以简化代码、统一函数的接口,同时保持程序的高效执行。这些类型的对象可以高效返回和复制,你唯一需要考虑的,就是保证视图里面实际指向的对象在视图的使用期间仍然一直存在。
课后思考
在很多使用视图类型的场景下(如 print
和 increase
),我们可以使用一个函数模板来代替,把参数从 span<int>
变成类型模板参数(const T&
和 T&
)即可。请你想一想,两种方式各有什么优缺点?
你能不能利用迭代器(参考第 7 讲)和模板,在 C++17 下自行实现出一个 elements_view
?这会是一个不错的小练习。
最后,别忘了代码库里有示例代码可供运行和参考。如有任何问题,欢迎留言和我讨论。
参考资料
1
1a
2
2a
3
4
4a
5
5a
6
6a
7