You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

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 呢?

因为好处也是很大的。我们可以检查一下上面这个函数的替换接口形式:

  1. 我们可以使用 greet(const string&) 这样的按引用传参方式。这样的参数形式对 string 实参当然很友好,但对于字符串字面量就不友好了。虽然使用字符串字面量看起完全自动很正常,但编译器产生的代码是相当无聊和低效的:它会生成一个临时 string 对象,把字符串字面量中的内容全部复制进去,然后拿这个临时对象去调用 greet 函数,并在函数返回之后销毁这个临时的 string 对象。
  2. 我们可以使用 greet(const char*) 这样的传统接口。这样的参数形式对字符串字面量实参很友好,但对 string 对象来讲,就不方便了——我们会需要使用 s.c_str() 这样的形式来传参。还有,如果这个字符串很长,获取字符串的长度也会是一个低效的 O(n) 操作。此外,我们也没法直接使用 string 类提供的方便方法了,如 findsubstr 等。

如果我们把形参替换成 string_view 的话:

  1. 当我们传递的实参为 string 时,string 会使用内部指针和长度高效地生成 string_view 对象。
  2. 当我们传递的实参可退化为 const char* 时,那编译器会自动获取这个字符串的长度(通过调用 char_traits<char>::length(s))。这里又可以细分为两种情况:字符串内容在编译时确定(即字符串字面量),及字符串内容在编译时不确定。当字符串内容在编译时可确定时,string_view 具有最大的优势:不仅我们没有任何额外的开销,而且目前的主流优化编译器都可以在编译时算出字符串的长度,因而可以产生最高效的代码。否则,string_view 会在代码执行时去动态获取字符串的长度,在你后续需要字符串长度时也非常合适,不算额外开销。

此外,虽然 string_view 不是 string,它的成员函数跟 string 还是非常相似的。我们同样有 datasizebeginendfind 等方法。它跟 string 最为显著的不同点是:

  • 你不能修改字符串的内容。data 成员函数返回的是 const char*,而不像 stringdata 成员函数从 C++17 开始可以返回 char*,允许程序员直接通过指针修改底层的字符串(当然,不允许超过尾部)。
  • 没有 c_str 成员函数。从语义上说,stringdata 成员函数只是返回指针,在 C++11 之前甚至不保证字符串会零结尾;而只有 c_str 是从 C++98 开始就一直保证返回的字符串是零结尾的。string_viewdata 成员函数返回的字符串又不保证零结尾了,即使我们构造 string_view 使用的字符串是零结尾的——因为只有这样,我们才能高效地取出 string_view 的一部分,形成一个新的 string_view 对象。这也意味着,我们在需要把字符串指针传到期待零结尾字符串的 C 函数接口里去时,使用 string_view 是不合适的。
  • substr 成员函数返回的是一个新的 string_view,而非 string。产生新的指针和长度只是简单的加减运算,当然也就很高效,但别忘了,刚说过的,产生的结果可能不是零结尾,即使原始的 string_view 是零结尾。
  • 我们额外有成员函数 remove_prefixremove_suffix,可以修改当前 string_view 对象(但不会动底下的字符串)。remove_prefix 去掉开头的若干字符,因而如果 string_view 原先是零结尾的话,现在仍然是零结尾;remove_suffix 去掉结尾的若干字符,显然,即使 string_view 原先是零结尾的,在这个操作之后就不再是零结尾的了。

最后,强调一点,我上面一直在讲 string_view,那主要是因为对于不开发 Windows 应用的人来说,string_view 一般就已经够用了。实际上,string_viewstring 一样,是一个类型别名:std::string_view 相当于 std::basic_string_view<char>。我们是可以使用其他字符类型去特化 basic_string_view 的,系统也已经帮我们定义了相应的别名,如 wstring_viewu32string_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,我们也可以用连续存储的序列范围作为参数来构造 spanGSL 和 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_prefixstring_view::remove_suffix 代码风格不同,不修改自身)
  • last:结尾若干项组成的新 span(注意这和 string_view::remove_prefixstring_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 元素类型的容器(包括 mapunordered_mapvector<tuple<…>> 等),elements_view 的作用是形成所有元素中的某一项的视图。特别地,取第 0 项的也被称为 keys_viewkeys_view<R> 相当于 elements_view<R, 0>),取第 1 项的也被称为 values_viewvalues_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_viewspan 不同。不过,你仍然可以用 auto 来对它进行接收和复制,这些都是非常轻量的操作。程序实际产生的输出为:

{ one, two, three }

内容小结

本讲我们介绍了几个有用的视图类型。使用它们,你可以简化代码、统一函数的接口,同时保持程序的高效执行。这些类型的对象可以高效返回和复制,你唯一需要考虑的,就是保证视图里面实际指向的对象在视图的使用期间仍然一直存在。

课后思考

在很多使用视图类型的场景下(如 printincrease),我们可以使用一个函数模板来代替,把参数从 span<int> 变成类型模板参数(const T&T&)即可。请你想一想,两种方式各有什么优缺点?

你能不能利用迭代器(参考第 7 讲)和模板,在 C++17 下自行实现出一个 elements_view?这会是一个不错的小练习。

最后,别忘了代码库里有示例代码可供运行和参考。如有任何问题,欢迎留言和我讨论。

参考资料

1
1a
2
2a
3
4
4a
5
5a
6
6a
7