第9章 跨度(Spans)
# 第9章 跨度(Spans)
为了处理范围,C++20引入了一些视图类型。通常,这些视图类型不会在自身内存中存储元素,而是引用存储在其他范围或视图中的元素。std::span<>
类模板就是其中一种视图。
然而,从历史角度看,跨度是C++17中引入的字符串视图的一种泛化。跨度可以引用任意元素类型的任意数组。它只是原始指针和大小的组合,为存储在连续内存中的元素提供了常见的集合读写接口。
由于要求跨度只能引用连续内存中的元素,其迭代器可以直接是原始指针,这使得它们开销较低。这也意味着该集合支持随机访问(这样你就可以跳转到范围中的任意位置),这意味着你可以使用这个视图对元素进行排序,或者使用一些操作来获取位于底层范围中间或末尾的n个元素的子序列。
使用跨度既便宜又快速(应该始终按值传递)。然而,它也存在潜在风险,因为与原始指针一样,使用跨度时需要程序员确保所引用的元素序列仍然有效。此外,跨度支持写访问这一事实可能会导致常量正确性被破坏的情况(或者至少其工作方式可能与你预期的不同)。
# 9.1 使用跨度
让我们来看一些使用跨度的初步示例。不过,首先我们需要讨论如何指定跨度中的元素数量。
# 9.1.1 固定和动态范围
声明跨度时,你可以选择指定固定数量的元素,或者不指定元素数量,以便跨度引用的元素数量可以变化。
具有指定固定数量元素的跨度称为具有固定范围(fixed extent)的跨度。可以通过指定元素类型和大小作为模板参数,或者使用数组(原始数组或std::array<>
)、迭代器和大小来初始化它:
int a5[5] = {1, 2, 3, 4, 5};
std::array arr5{1, 2, 3, 4, 5};
std::vector vec{1, 2, 3, 4, 5, 6, 7, 8};
std::span sp1 = a5; // 具有5个元素固定范围的跨度
std::span sp2{arr5}; // 具有5个元素固定范围的跨度
std::span<int, 5> sp3 = arr5; // 具有5个元素固定范围的跨度
std::span<int, 3> sp4{vec}; // 具有3个元素固定范围的跨度
std::span<int, 4> sp5{vec.data(), 4}; // 具有4个元素固定范围的跨度
std::span sp6 = sp1; // 具有5个元素固定范围的跨度
2
3
4
5
6
7
8
9
10
对于这样的跨度,成员函数size()
总是返回作为类型一部分指定的大小。这里不能调用默认构造函数(除非范围大小为0)。
元素数量在其生命周期内不稳定的跨度称为具有动态范围(dynamic extent)的跨度。元素数量取决于跨度引用的元素序列,并且可能会因为分配了新的底层范围而发生变化(没有其他方法可以改变元素数量)。例如:
int a5[5] = {1, 2, 3, 4, 5};
std::array arr5{1, 2, 3, 4, 5};
std::vector vec{1, 2, 3, 4, 5, 6, 7, 8};
std::span<int> sp1; // 具有动态范围的跨度(初始时0个元素)
std::span sp2{a5, 3}; // 具有动态范围的跨度(初始时3个元素)
std::span<int> sp3{arr5}; // 具有动态范围的跨度(初始时5个元素)
std::span sp4{vec}; // 具有动态范围的跨度(初始时8个元素)
std::span sp5{arr5.data(), 3}; // 具有动态范围的跨度(初始时3个元素)
std::span sp6{a5+1, 3}; // 具有动态范围的跨度(初始时3个元素)
2
3
4
5
6
7
8
9
10
请注意,确保跨度引用具有足够元素的有效范围是程序员的责任。对于这两种情况,让我们来看一些完整的示例。
# 9.1.2 使用具有动态范围跨度的示例
下面是一个使用具有动态范围跨度的示例:
// lib/spandyn.cpp
#include <iostream>
#include <string>
#include <vector>
#include <ranges>
#include <algorithm>
#include <span>
template<typename T, std::size_t Sz>
void printSpan(std::span<T, Sz> sp) {
for (const auto& elem : sp) {
std::cout << "\"" << elem << "\" ";
}
std::cout << "\n";
}
int main() {
std::vector<std::string> vec{ "New York ", "Tokyo ", "Rio ", "Berlin ", "Sydney "};
// 定义指向前3个元素的视图:
std::span<const std::string> sp{vec.data(), 3};
std::cout << "first 3: ";
printSpan(sp);
// 对引用向量中的元素进行排序:
std::ranges::sort(vec);
std::cout << "first 3 after sort(): ";
printSpan(sp);
// 插入一个新元素:
// - 如果向量重新分配了新内存,则必须重新分配其内部数组
auto oldCapa = vec.capacity();
vec.push_back("Cairo ");
if (oldCapa != vec.capacity()) {
sp = std::span{vec.data(), 3};
}
std::cout << "first 3 after push_back(): ";
printSpan(sp);
// 让跨度引用整个向量:
sp = vec;
std::cout << "all : ";
printSpan(sp);
// 让跨度引用最后5个元素:
sp = std::span{vec.end()-5, vec.end()};
std::cout << "last 5: ";
printSpan(sp);
// 让跨度引用最后4个元素:
sp = std::span{vec}.last(4);
std::cout << "last 4: ";
printSpan(sp);
}
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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
该程序的输出如下:
first 3: "New York" "Tokyo" "Rio"
first 3 after sort(): "Berlin" "New York" "Rio"
first 3 after push_back(): "Berlin" "New York" "Rio"
all: "Berlin" "New York" "Rio" "Sydney" "Tokyo" "Cairo"
last 5: "New York" "Rio" "Sydney" "Tokyo" "Cairo"
last 4: "Rio" "Sydney" "Tokyo" "Cairo"
2
3
4
5
6
让我们逐步分析这个示例。
# 声明跨度
在main()
函数中,我们首先用向量的前三个元素初始化一个包含三个常量字符串的跨度:
std::vector<std::string> vec{ "New York ", "Rio ", "Tokyo ", "Berlin ", "Sydney "};
std::span<const std::string> sp{vec.data(), 3};
2
在初始化时,我们传递了序列的起始位置和元素数量。在这种情况下,我们引用了vec
的前三个元素。
关于这个声明有很多需要注意的地方:
确保元素数量与跨度的范围匹配并且元素有效是程序员的责任。如果向量没有足够的元素,行为是未定义的:
std::span<const std::string> sp{vec.begin(), 10}; // 未定义行为
1通过指定元素为
const std::string
,我们不能通过跨度修改它们。请注意,将跨度本身声明为const
并不能为引用的元素提供只读访问(与通常的视图一样,const
不会被传播):std::span<const std::string> sp1{vec.begin(), 3}; // 元素不能被修改 const std::span<std::string> sp2{vec.begin(), 3}; // 元素可以被修改
1
2为跨度使用与引用元素不同的元素类型,看起来好像可以为跨度使用任何能转换为底层元素类型的类型。然而,这是不正确的。你只能添加诸如
const
之类的限定符:
std::vector<int> vec{ ... };
std::span<long> sp{vec.data(), 3}; // 错误
2
# 传递和打印跨度
接下来,我们将跨度传递给一个通用的打印函数来打印它:
printSpan(sp);
这个打印函数可以处理任何跨度(只要为元素定义了输出运算符):
template<typename T, std::size_t Sz>
void printSpan(std::span<T, Sz> sp) {
for (const auto& elem : sp) {
std::cout << "\"" << elem << "\" ";
}
std::cout << "\n";
}
2
3
4
5
6
7
你可能会惊讶,即使printSpan<>()
函数模板有一个用于跨度大小的非类型模板参数,它仍然可以被调用。这是因为std::span<T>
是具有伪大小std::dynamic_extent
的跨度的快捷方式:
std::span<int> sp; // 等同于std::span<int, std::dynamic_extent>
实际上,std::span<>
类模板声明如下:
namespace std {
template<typename ElementType, size_t Extent = dynamic_extent>
class span {
...
};
}
2
3
4
5
6
这使得程序员可以提供像printSpan<>()
这样的泛型代码,它对具有固定范围和动态范围的跨度都适用。当使用具有固定范围的跨度调用printSpan<>()
时,范围大小会作为模板参数传递:
std::span<int, 5> sp{ ... };
printSpan(sp); // 调用printSpan<int, 5>(sp)
2
如你所见,跨度是按值传递的。这是传递跨度的推荐方式,因为它们复制起来开销很低,因为在内部,跨度只是一个指针和一个大小。
在打印函数内部,我们使用基于范围的for
循环来遍历跨度中的元素。这是可行的,因为跨度通过begin()
和end()
提供了迭代器支持。
然而,要注意:无论我们是按值还是按常量引用传递跨度,只要元素没有被声明为const
,在函数内部仍然可以修改它们。这就是为什么通常将跨度的元素声明为const
是有意义的。
# 处理引用语义
接下来,我们对span
所引用的元素进行排序(这里我们使用新的std::ranges::sort()
,它将容器视为一个整体进行操作):
std::ranges::sort(vec);
由于span
具有引用语义,这种排序操作也会影响span
所引用的元素,结果是span
现在引用了不同的值。
如果我们的span
不是指向常量元素,还可以直接对span
调用sort()
函数。
引用语义意味着在使用span
时必须小心,这一点在示例的后续语句中得到了体现。这里,我们向span
所引用元素所在的vector
中插入一个新元素。由于span
的引用语义,我们必须格外小心,因为如果vector
分配了新的内存,它会使指向其元素的所有迭代器和指针失效。因此,重新分配内存也会使指向vector
元素的span
失效,此时span
引用的元素已经不存在了。
出于这个原因,我们在插入前后检查vector
的容量(已分配内存可容纳的最大元素数量)。如果容量发生变化,我们重新初始化span
,使其指向新内存中的前三个元素:
auto oldCapa = vec.capacity();
vec.push_back("Cairo");
if (oldCapa != vec.capacity()) {
sp = std::span{vec.data(), 3};
}
2
3
4
5
我们能够进行这种重新初始化,是因为span
本身不是const
的。
# 将容器赋给span
接下来,我们将整个vector
赋给span
并打印出来:
std::span<const std::string> sp{vec.begin(), 3};
...
sp = vec;
2
3
可以看到,对具有动态范围的span
进行赋值操作可以改变其元素数量。只要容器通过成员函数data()
提供对元素的访问,span
就可以接受任何类型的容器或范围,前提是这些容器将元素存储在连续内存中。
然而,由于模板类型推导的限制,不能将这样的容器传递给期望接收span
的函数。必须显式指定要将vector
转换为span
:
printSpan(vec); // 错误:这里模板类型推导不起作用
printSpan(std::span{vec}); // 正确
2
# 赋予不同的子序列
一般来说,span
的赋值运算符允许我们赋予另一个元素序列。示例中利用这一点,使span
后来引用vector
中的最后三个元素:
std::span<const std::string> sp{vec.data(), 3};
...
// 赋值视图,使其指向最后五个元素:
sp = std::span{vec.end()-5, vec.end()};
2
3
4
这里还可以看到,我们可以使用两个迭代器来指定引用的序列,这两个迭代器定义了序列的起始和结束位置,形成一个半开区间(包含起始值,不包含结束值)。要求起始和结束迭代器满足std::sized_sentinel_for
概念,这样构造函数才能计算出它们之间的差值。
不过,如下述语句所示,也可以使用span
的成员函数来赋予最后n
个元素:
std::vector<std::string> vec{"New York", "Tokyo", "Rio", "Berlin", "Sydney"};
std::span<const std::string> sp{vec.data(), 3};
...
// 赋值视图,使其指向最后四个元素:
sp = std::span{vec}.last(4);
2
3
4
5
span
是唯一一种能够获取范围中间或末尾元素序列的视图。
只要元素类型匹配,就可以传递任何其他类型的元素序列。例如:
std::vector<std::string> vec{"New York", "Tokyo", "Rio", "Berlin", "Sydney"};
std::span<const std::string> sp{vec.begin(), 3};
...
std::array<std::string, 3> arr{"Tick", "Trick", "Track"};
sp = arr; // 正确
2
3
4
5
然而,请注意,span
不支持元素类型的隐式类型转换(除了添加const
修饰符)。例如,以下代码无法编译:
std::span<const std::string> sp{vec.begin(), 3};
...
std::array arr{"Tick", "Trick", "Track"}; // 推导为std::array<const char*, 3>
sp = arr; // 错误:元素类型不同
2
3
4
# 9.1.3 使用非const元素的span示例
在初始化span
时,我们可以使用类模板参数推导,这样元素的类型(以及范围)会被自动推导出来:
std::span sp{vec.begin(), 3}; // 推导为:std::span<std::string>
这样,span
会声明其元素类型与底层范围的元素类型一致,这意味着只要底层范围没有将其元素声明为const
,就可以修改底层范围的值。
这个特性可以用于在一条语句中让span
修改范围中的元素。例如,可以对部分元素进行排序,如下例所示:
// lib/spanview.cpp
#include <iostream>
#include <string>
#include <vector>
#include <ranges>
#include <algorithm>
#include <span>
void print(std::ranges::input_range auto&& coll) {
for (const auto& elem : coll) {
std::cout << "\"" << elem << "\" ";
}
std::cout << "\n";
}
int main() {
std::vector<std::string> vec{"New York", "Tokyo", "Rio", "Berlin", "Sydney"};
print(vec);
// 对中间的三个元素进行排序:
std::ranges::sort(std::span{vec}.subspan(1, 3));
print(vec);
// 打印最后三个元素:
print(std::span{vec}.last(3));
}
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
这里,我们创建临时span
来对vector vec
中的部分元素进行排序,并打印出vector
的最后三个元素。
该程序的输出如下:
"New York" "Tokyo" "Rio" "Berlin" "Sydney"
"New York" "Berlin" "Rio" "Tokyo" "Sydney"
"Rio" "Tokyo" "Sydney"
2
3
span
是视图。要处理范围的前n
个元素,还可以使用范围工厂std::views::counted()
。如果对指向连续内存中元素范围的迭代器调用std::views::counted()
,它会创建一个具有动态范围的span
:
std::vector<int> vec{1, 2, 3, 4, 5, 6, 7, 8, 9};
auto v = std::views::counted(vec.begin()+1, 3); // span,包含vec中第2到第4个元素
2
# 9.1.4 使用固定范围span的示例
作为固定范围span
的第一个示例,我们修改前面的示例,声明一个固定范围的span
:
// lib/spanfix.cpp
#include <iostream>
#include <string>
#include <vector>
#include <ranges>
#include <algorithm>
#include <span>
template<typename T, std::size_t Sz>
void printSpan(std::span<T, Sz> sp) {
for (const auto& elem : sp) {
std::cout << "\"" << elem << "\" ";
}
std::cout << "\n";
}
int main() {
std::vector<std::string> vec{"New York", "Tokyo", "Rio", "Berlin", "Sydney"};
// 定义指向前3个元素的视图:
std::span<const std::string, 3> sp3{vec.data(), 3};
std::cout << "first 3: ";
printSpan(sp3);
// 对引用的元素进行排序:
std::ranges::sort(vec);
std::cout << "first 3 after sort(): ";
printSpan(sp3);
// 插入一个新元素:
// - 如果vector重新分配内存,则必须重新分配其内部数组
auto oldCapa = vec.capacity();
vec.push_back("Cairo");
if (oldCapa != vec.capacity()) {
sp3 = std::span<std::string, 3>{vec.data(), 3};
}
std::cout << "first 3: ";
printSpan(sp3);
// 让span指向最后三个元素:
sp3 = std::span<const std::string, 3>{vec.end()-3, vec.end()};
std::cout << "last 3: ";
printSpan(sp3);
// 让span指向最后三个元素:
sp3 = std::span{vec}.last<3>();
std::cout << "last 3: ";
printSpan(sp3);
}
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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
该程序的输出如下:
first 3: "New York" "Tokyo" "Rio"
first 3 after sort(): "Berlin" "New York" "Rio"
first 3: "Berlin" "New York" "Rio"
last 3: "Sydney" "Tokyo" "Cairo"
last 3: "Sydney" "Tokyo" "Cairo"
2
3
4
5
同样,让我们逐步分析这个程序示例中的重要部分。
# 声明span
这一次,我们首先初始化一个包含三个常量字符串的固定范围span
:
std::vector<std::string> vec{"New York", "Rio", "Tokyo", "Berlin", "Sydney"};
std::span<const std::string, 3> sp3{vec.data(), 3};
2
对于固定范围,我们既要指定元素的类型,也要指定大小。
同样,程序员需要确保元素的数量与span
的范围相匹配。如果作为第二个参数传递的数量与范围不匹配,行为是未定义的。
std::span<const std::string, 3> sp3{vec.begin(), 4}; // 未定义行为
# 赋予不同的子序列
对于固定范围的span
,只能赋予包含相同数量元素的新底层范围。因此,这一次我们只赋予包含三个元素的span
:
std::span<const std::string, 3> sp3{vec.data(), 3};
...
sp3 = std::span<const std::string, 3>{vec.end()-3, vec.end()};
2
3
注意,以下代码将无法编译:
std::span<const std::string, 3> sp3{vec.data(), 3};
...
sp3 = std::span{vec}.last(3); // 错误
2
3
原因是赋值运算符右侧的表达式创建了一个动态范围的span
。然而,通过使用last()
并以模板参数的语法指定元素数量,我们可以得到一个具有相应固定范围的span
:
std::span<const std::string, 3> sp3{vec.data(), 3};
...
sp3 = std::span{vec}.last<3>(); // 正确
2
3
我们仍然可以使用类模板参数推导来赋予数组元素,甚至可以直接赋值:
std::span<const std::string, 3> sp3{vec.data(), 3};
...
std::array<std::string, 3> arr{"Tic", "Tac", "Toe"};
sp3 = std::span{arr}; // 正确
sp3 = arr; // 正确
2
3
4
5
# 9.1.5 固定范围与动态范围
固定范围和动态范围都有各自的优点。
指定固定大小能让编译器在运行时甚至编译时检测大小违规的情况。例如,不能将元素数量错误的std::array<>
赋给固定范围的span
:
std::vector vec{1, 2, 3};
std::array arr{1, 2, 3, 4, 5, 6};
std::span<int, 3> sp3{vec};
std::span sp{vec};
sp3 = arr; // 编译时错误
sp = arr; // 正确
2
3
4
5
6
7
固定范围的span
也需要更少的内存,因为它们不需要一个成员来存储实际大小(大小是类型的一部分)。
使用动态范围的span
则提供了更大的灵活性:
std::span<int> sp; // 正确
...
std::vector vec{1, 2, 3, 4, 5};
sp = vec; // 正确(span有5个元素)
sp = {vec.data()+1, 3}; / 正确(span有3个元素)
2
3
4
5
6
# 9.2 跨度(Spans)的潜在问题
跨度引用外部的值序列。因此,它存在具有引用语义的类型通常会遇到的问题。确保跨度引用的序列有效是程序员的责任。
错误很容易出现。例如,如果函数getData()
按值返回一个int
类型的集合(例如vector
、std::array
或原生数组),以下语句会导致严重的运行时错误:
std::span<int, 3> first3{getData()}; // 错误:引用临时对象
std::span sp{getData().begin(), 3}; // 错误:引用临时对象
sp = getData(); // 错误:引用临时对象
2
3
在使用基于范围的for
循环时,问题可能更隐蔽:
// 对返回的最后3个元素进行操作:
for (auto s : std::span{arrayOfConst()}.last(3)) // 严重的运行时错误
2
这段代码会导致未定义行为,因为基于范围的for
循环存在一个缺陷,即对临时对象的引用进行迭代时,使用的是对象已被销毁后的值(详见http://wg21.link/p2012)。
编译器可以通过对标准类型进行特殊的 “生命周期检查” 来检测这些问题,目前主流编译器正在实现这一功能。然而,这种检查只能检测到像跨度与其初始化对象之间那样简单的生命周期依赖关系。
此外,你必须确保所引用的元素序列始终有效。如果程序的其他部分在跨度仍在使用时结束了所引用序列的生命周期,就会出现问题。
如果我们引用对象内部(比如vector
内部),即使vector
仍然存在,这种有效性问题也可能发生。例如:
std::vector<int> vec{1, 2, 3, 4, 5, 6, 7, 8};
std::span sp{vec}; // 指向vec内存的视图
// ...
vec.push_back(9); // 可能会重新分配内存
std::cout << sp[0]; // 严重的运行时错误(引用的数组不再有效)
2
3
4
5
作为一种解决方法,你必须使用原始的vector
重新初始化跨度。
一般来说,使用跨度和使用原始指针以及其他视图类型一样危险,使用时需格外小心。
# 9.3 跨度的设计要点
设计一个引用元素序列的类型并非易事,需要全面考虑并权衡诸多方面:
- 性能与安全性
- 常量正确性(const correctness)
- 可能的隐式和显式类型转换
- 对支持类型的要求
- 支持的API
首先,让我明确一下跨度不是什么:
- 跨度不是容器。它可能具备容器的一些特性(例如,能够使用
begin()
和end()
遍历元素),但由于其引用语义,也存在一些问题:- 如果跨度是
const
的,元素也应该是const
的吗? - 赋值操作的含义是什么:是分配一个新的序列,还是给引用的元素赋新值?
- 我们应该提供
swap()
函数吗?如果提供,它的功能是什么?
- 如果跨度是
- 跨度不是(带大小的)指针。提供
*
和->
运算符没有意义。
std::span
类型是对元素序列的一种特殊引用。正确理解这些特殊之处对于正确使用该类型至关重要。
Barry Revzin写了一篇非常有帮助的博客文章,强烈推荐你阅读:http://brevzin.github.io/c++/2018/12/03/span-best-span/ (opens new window) 。
注意,C++20还提供了其他处理(子)序列引用的方式,比如子范围(subranges)。它们也适用于非连续内存存储的序列。通过使用范围工厂函数std::views::counted()
,你可以让编译器决定哪种类型最适合由起始位置和大小定义的范围。
# 9.3.1 跨度的生命周期依赖
由于跨度具有引用语义,只有在底层值序列存在时,才能对跨度进行迭代。然而,迭代器并不受创建它们的跨度的生命周期限制。
跨度的迭代器并不指向创建它们的跨度,而是直接指向底层范围。因此,跨度是一个借用范围。这意味着即使跨度不再存在(当然,元素序列必须仍然存在),你仍然可以使用这些迭代器。但是要注意,当底层范围不再存在时,迭代器仍然可能悬空。
# 9.3.2 跨度的性能
跨度的设计目标是实现最佳性能。在内部,它仅使用一个指向元素序列的原始指针。然而,原始指针要求元素顺序存储在一块连续的内存中(否则,当指针前后移动时,无法计算元素的位置)。因此,跨度要求元素存储在连续内存中。
基于这个要求,跨度在所有视图类型中性能最佳。跨度不需要任何内存分配,也不存在间接寻址。使用跨度的唯一开销是构造它的开销。
由于跨度在内部只是一个指针和一个大小,复制它们的成本非常低。因此,你应该优先按值传递跨度,而不是按const
引用传递(不过,这对于同时处理容器和视图的泛型函数来说是个问题)。
# 类型擦除
跨度通过指向内存的原始指针来访问元素,这意味着跨度类型会擦除元素存储位置的信息。指向vector
元素的跨度与指向数组元素的跨度具有相同的类型(前提是它们的范围相同):
std::array arr{1, 2, 3, 4, 5};
std::vector vec{1, 2, 3, 4, 5};
std::span<int> vecSpanDyn{vec};
std::span<int> arrSpanDyn{arr};
std::same_as<decltype(arrSpanDyn), decltype(vecSpanDyn)> // true
2
3
4
5
然而,注意跨度的类模板参数推导会从数组中推导出固定的范围,从vector
中推导出动态的范围。这意味着:
std::array arr{1, 2, 3, 4, 5};
std::vector vec{1, 2, 3, 4, 5};
std::span arrSpan{arr}; // 推导出std::span<int, 5>
std::span vecSpan{vec}; // 推导出std::span<int>
std::span<int, 5> vecSpan5{vec};
std::same_as<decltype(arrSpan), decltype(vecSpan)> // false
std::same_as<decltype(arrSpan), decltype(vecSpan5)> // true
2
3
4
5
6
7
8
# 跨度与子范围
元素存储的连续性要求是跨度与子范围的主要区别,子范围也是在C++20中引入的。在内部,子范围仍然使用迭代器,因此可以引用所有类型的容器和范围。然而,这可能会带来显著更高的开销。
此外,跨度不要求其引用的类型支持迭代器。你可以传递任何提供data()
成员以访问元素序列的类型。
# 9.3.3 跨度的常量正确性
跨度是具有引用语义的视图。从这个意义上说,它们的行为类似于指针:如果一个跨度是const
的,并不自动意味着它所引用的元素也是const
的。
这意味着,对于const
跨度,只要元素不是const
的,你就可以对其进行写访问:
std::array a1{1, 2, 3, 4, 5, 6,7 ,8, 9, 10};
std::array a2{0, 8, 15};
const std::span<int> sp1{a1}; // 跨度/视图是const的
std::span<const int> sp2{a1}; // 元素是const的
sp1[0] = 42; // 正确
sp2[0] = 42; // 错误
sp1 = a2; // 错误
sp2 = a2; // 正确
2
3
4
5
6
7
8
9
注意,只要std::span<>
的元素未声明为const
,即使对于const
跨度,一些操作也会提供对元素的写访问,而这可能出乎你的意料(遵循普通容器的规则):
operator[]
、first()
、last()
data()
begin()
、end()
、rbegin()
、rend()
std::cbegin()
、std::cend()
、std::crbegin()
、std::crend()
std::ranges::cbegin()
、std::ranges::cend()
、std::ranges::crbegin()
、std::ranges::crend()
是的,所有旨在确保元素为const
的c*
函数在std::span
中都出现了问题。例如:
template<typename T>
void modifyElemsOfConstColl (const T& coll) {
coll[0] = {}; // 对于跨度是正确的,对于常规容器是错误的
auto ptr = coll.data();
*ptr = {}; // 对于跨度是正确的,对于常规容器是错误的
for (auto pos = std::cbegin(coll); pos != std::cend(coll); ++pos) {
*pos = {}; // 对于跨度是正确的,对于常规容器是错误的
}
}
std::array arr{1, 2, 3, 4, 5, 6, 7 ,8, 9, 10};
modifyElemsOfConstColl(arr); // 错误:元素是const的
modifyElemsOfConstColl(std::span{arr}); // 糟糕:编译通过并修改了a1的元素
2
3
4
5
6
7
8
9
10
11
12
13
14
这里的问题不在于std::span
有缺陷,而是像std::cbegin()
和std::ranges::cbegin()
这样的函数,对于具有引用语义的集合(如视图)目前存在问题。
为了确保函数只接受无法以这种方式修改元素的序列,你可以要求const
容器的begin()
返回一个指向const
元素的迭代器:
template<typename T>
void ensureReadOnlyElemAccess (const T& coll)
requires std::is_const_v<std::remove_reference_t<decltype(*coll.begin())>> {
// ...
}
2
3
4
5
在C++20标准化之后,即使是std::cbegin()
和std::ranges::cbegin()
也提供写访问的情况仍在讨论中。提供cbegin()
和cend()
的初衷是确保在迭代元素时无法修改它们。最初,跨度确实为const_iterator
类型、cbegin()
和cend()
提供了成员,以确保无法修改元素。然而,在C++20完成前的最后阶段,发现std::cbegin()
仍然会迭代可变元素(std::ranges::cbegin()
也存在同样的问题)。但并没有修复std::cbegin()
和std::ranges::cbegin()
,而是删除了跨度中用于const
迭代器的成员(详见http://wg21.link/lwg3320 (opens new window)),这使得问题更加严重,因为在C++20中,除非元素是const
的,否则现在没有简单的方法对跨度进行只读迭代。似乎std::ranges::cbegin()
将在C++23中得到修复(详见http://wg21.link/p2278 (opens new window)),然而,std::cbegin()
仍然会存在问题(真遗憾)。
# 9.3.4 在泛型代码中使用跨度作为参数
如前所述,你可以通过以下声明为所有跨度实现一个泛型函数:
template<typename T, std::size_t Sz>
void printSpan(std::span<T, Sz> sp);
2
这甚至适用于动态范围的跨度,因为它们只是使用特殊值std::dynamic_extent
作为大小。
因此,在实现中,你可以如下处理固定范围和动态范围之间的差异:
// lib/spanprint.hpp
#ifndef SPANPRINT_HPP
#define SPANPRINT_HPP
#include <iostream>
#include <span>
template<typename T, std::size_t Sz>
void printSpan(std::span<T, Sz> sp) {
std::cout << " [ " << sp.size() << " elems ";
if constexpr (Sz == std::dynamic_extent) {
std::cout << " (dynamic) ";
}
else {
std::cout << " (fixed) ";
}
std::cout << " :";
for (const auto& elem : sp) {
std::cout << ' ' << elem;
}
std::cout << "]\n";
}
#endif // SPANPRINT_HPP
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
你可能还会考虑将元素类型声明为const
:
template<typename T, std::size_t Sz>
void printSpan(std::span<const T, Sz> sp);
2
然而,在这种情况下,你无法传递元素类型为非const
的跨度。从非const
类型到const
类型的转换不会传播到模板中(这是有充分理由的)。
缺乏类型推导和转换也使得无法将普通容器(如vector
)传递给这个函数。你需要显式指定类型或进行显式转换:
printSpan(vec); // 错误:模板类型推导不起作用
printSpan(std::span{vec}); // 正确
printSpan<int, std::dynamic_extent>(vec); // 正确(前提是vec是int类型的vector)
2
3
因此,对于处理连续内存中存储的元素序列的泛型函数,std::span<>
不应该用作通用类型。
出于性能考虑,你可能会这样做:
template<typename E>
void processSpan(std::span<typename E>) {
... // 跨度特定的实现
}
template<typename T>
void print(const T& t) {
if constexpr (std::ranges::contiguous_range<T> t) {
processSpan<std::ranges::range_value_t<T>>(t);
}
else {
// ... // 适用于所有容器/范围的通用实现
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
# 将跨度(Spans)用作范围和视图
由于跨度是视图,它们满足std::ranges::view
概念。
原C++20标准要求视图必须有默认构造函数,但固定大小的跨度并不满足这一点。不过,后来通过http://wg21.link/P2325R3 (opens new window)移除了该要求。
这意味着跨度可用于所有针对范围和视图的算法与函数。在讨论所有视图详细信息的章节中,我们列出了跨度特有的视图属性。
跨度的一个属性是它们属于借用范围(borrowed ranges),这意味着迭代器的生命周期并不依赖于跨度的生命周期。因此,我们可以在那些会生成指向它的迭代器的算法中,将临时跨度用作范围:
std::vector<int> coll{25, 42, 2, 0, 122, 5, 7};
auto pos1 = std::ranges::find(std::span{coll.data(), 3}, 42); // 没问题
std::cout << *pos1 << "\n";
2
3
不过,请注意,如果跨度引用的是临时对象,这就是一个错误。下面的代码虽然能编译,但返回的是指向已销毁临时对象的迭代器:
auto pos2 = std::ranges::find(std::span{getData().data(), 3}, 42);
std::cout << *pos2 << "\n"; // 运行时错误
2
# 9.4 跨度操作
本节详细介绍跨度的类型和操作。
# 9.4.1 跨度操作和成员类型概述
表“跨度操作”列出了跨度提供的所有操作。值得注意的是,有一些操作是不支持的:
- 比较操作(甚至连
==
都不支持) swap()
assign()
at()
- 输入/输出操作符
cbegin()
、cend()
、crbegin()
、crend()
- 哈希操作
- 用于结构化绑定的类似元组的API
也就是说,跨度既不是(传统C++ STL意义上的)容器,也不是常规类型。
关于静态成员和成员类型,跨度提供了容器通常具备的成员(const_iterator
除外),以及两个特殊成员:element_type
和extent
(见表“跨度的静态和类型成员”)。
注意,std::value_type
并非指定的元素类型(不像std::array
及其他一些类型的value_type
通常那样)。它是去掉const
和volatile
限定的元素类型。
操作 | 效果 |
---|---|
构造函数 析构函数 = empty() size() size_bytes() [] front() 、back() begin() 、end() rbegin() 、rend() first(n) first<n>() last(n) last<n>() subspan(offs) subspan(offs , n) subspan<offs>() subspan<offs , n>() data() as_bytes() as_writable_bytes() | 创建或复制一个跨度 销毁一个跨度 分配新的一组值 返回跨度是否为空 返回元素数量 返回所有元素占用的内存大小 访问一个元素 访问第一个或最后一个元素 提供迭代器支持(不支持 const_iterator )提供常量反向迭代器支持 返回包含前 n 个元素、动态大小的子跨度返回包含前 n 个元素、固定大小的子跨度返回包含最后 n 个元素、动态大小的子跨度返回包含最后 n 个元素、固定大小的子跨度返回跳过前 offs 个元素、动态大小的子跨度返回跳过前 offs 个元素后、包含n 个元素、动态大小的子跨度返回跳过前 offs 个元素、大小不变的子跨度返回跳过前 offs 个元素后、包含n 个元素、固定大小的子跨度返回指向元素的原始指针 将元素的内存作为只读的 std::bytes 跨度返回将元素的内存作为可写的 std::bytes 跨度返回 |
表9.1 跨度操作
成员 | 效果 |
---|---|
extent | 元素数量,如果大小可变则为std::dynamic_extent |
size_type | extent 的类型(始终是std::size_t ) |
difference_type | 指向元素的指针的差值类型(始终是std::difference_type ) |
element_type | 指定的元素类型 |
pointer | 指向元素的指针类型 |
const_pointer | 用于只读访问元素的指针类型 |
reference | 指向元素的引用类型 |
const_reference | 用于只读访问元素的引用类型 |
iterator | 指向元素的迭代器类型 |
reverse_iterator | 指向元素的反向迭代器类型 |
value_type | 去掉const 和volatile 限定的元素类型 |
表9.2 跨度的静态和类型成员
# 9.4.2 构造函数
对于跨度,只有在其大小是动态的或大小为0时,才会提供默认构造函数:
std::span<int> sp0a; // 没问题
std::span<int, 0> sp0b; // 没问题
std::span<int, 5> sp0c; // 编译时错误
2
3
如果这种初始化有效,size()
为0,data()
为nullptr
。
原则上,你可以为数组、带有哨兵(结束迭代器)的起始位置,以及带有大小的起始位置初始化一个跨度。如果beg
指向连续内存中的元素,视图std::views::counted(beg, sz)
会使用最后一种方式。类模板参数推导也受支持。
这意味着,当用原始数组或std::array<>
初始化跨度时,会推导得到一个固定大小的跨度(除非仅指定了元素类型):
int a[10] {};
std::array arr{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15};
std::span sp1a{a}; // std::span<int, 10>
std::span sp1b{arr}; // std::span<int, 15>
std::span<int> sp1c{arr}; // std::span<int>
std::span sp1d{arr.begin() + 5, 5}; // std::span<int>
auto sp1e = std::views::counted(arr.data() + 5, 5); // std::span<int>
2
3
4
5
6
7
8
当用std::vector<>
初始化跨度时,除非显式指定大小,否则会推导得到一个动态大小的跨度:
std::vector vec{1, 2, 3, 4, 5};
std::span sp2a{vec}; // std::span<int>
std::span<int> sp2b{vec}; // std::span<int>
std::span<int, 2> sp2c{vec}; // std::span<int, 2>
std::span<int, std::dynamic_extent> sp2d{vec}; // std::span<int>
std::span<int, 2> sp2e{vec.data() + 2, 2}; // std::span<int, 2>
std::span<int> sp2f{vec.begin() + 2, 2}; // std::span<int>
auto sp2g = std::views::counted(vec.data() + 2, 2); // std::span<int>
2
3
4
5
6
7
8
9
如果从右值(临时对象)初始化跨度,元素必须是const
类型的:
std::span sp3a{getArrayOfInt()}; // 错误:右值且不是const类型
std::span<int> sp3b{getArrayOfInt()}; // 错误:右值且不是const类型
std::span<const int> sp3c{getArrayOfInt()}; // 没问题
std::span sp3d{getArrayOfConstInt()}; // 没问题
std::span sp3e{getVectorOfInt()}; // 错误:右值且不是const类型
std::span<int> sp3f{getVectorOfInt()}; // 错误:右值且不是const类型
std::span<const int> sp3g{getVectorOfInt()}; // 没问题
2
3
4
5
6
7
用返回的临时集合初始化跨度可能会导致致命的运行时错误。例如,你绝不应使用基于范围的for
循环来迭代直接初始化的跨度:
for (auto elem : std::span{getCollOfConst()}) ... // 致命运行时错误
for (auto elem : std::span{getCollOfConst()}.last(2)) ... // 致命运行时错误
for (auto elem : std::span<const Type>{getColl()}) ... // 致命运行时错误
2
3
问题在于,基于范围的for
循环在迭代临时对象返回的引用时会导致未定义行为,因为在循环内部开始迭代之前,临时对象就已被销毁。多年来,C++标准委员会一直不愿修复这个错误(见 http://wg21.link/p2012 (opens new window))。
作为一种解决方法,你可以使用带初始化的新的基于范围的for
循环:
for (auto&& coll = getCollOfConst(); auto elem : std::span{coll}) ... // 没问题
无论你是用一个迭代器和一个长度,还是用两个定义有效范围的迭代器来初始化跨度,这些迭代器都必须指向连续内存中的元素(满足std::contiguous_iterator
概念):
std::vector<int> vec{1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
std::span<int> sp6a{vec}; // 没问题,指向所有元素
std::span<int> sp6b{vec.data(), vec.size()}; // 没问题,指向所有元素
std::span<int> sp6c{vec.begin(), vec.end()}; // 没问题,指向所有元素
std::span<int> sp6d{vec.data(), 5}; // 没问题,指向前5个元素
std::span<int> sp6e{vec.begin()+2, 5}; // 没问题,指向第3到第7个元素(包括第3和第7个)
std::list<int> lst{ ... };
std::span<int> sp6f{lst.begin(), lst.end()}; // 编译时错误
2
3
4
5
6
7
8
9
如果跨度有固定大小,它必须与传递范围中的元素数量匹配。一般来说,编译器在编译时无法检查这一点:
std::vector<int> vec{1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
std::span<int, 10> sp7a{vec}; // 没问题,指向所有元素
std::span<int, 5> sp7b{vec}; // 运行时错误(未定义行为)
std::span<int, 20> sp7c{vec}; // 运行时错误(未定义行为)
std::span<int, 5> sp7d{vec, 5}; // 编译时错误
std::span<int, 5> sp7e{vec.begin(), 5}; // 没问题,指向前5个元素
std::span<int, 3> sp7f{vec.begin(), 5}; // 运行时错误(未定义行为)
std::span<int, 8> sp7g{vec.begin(), 5}; // 运行时错误(未定义行为)
std::span<int, 5> sp7h{vec.begin()}; // 编译时错误
2
3
4
5
6
7
8
9
你也可以直接用原始数组或std::array
创建并初始化跨度。在这种情况下,由于元素数量无效导致的一些运行时错误会变成编译时错误:
int raw[10];
std::array arr{1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
std::span<int> sp8a{raw}; // 没问题,指向所有元素
std::span<int> sp8b{arr}; // 没问题,指向所有元素
std::span<int, 5> sp8c{raw}; // 编译时错误
std::span<int, 5> sp8d{arr}; // 编译时错误
std::span<int, 5> sp8e{arr.data(), 5}; // 没问题
2
3
4
5
6
7
8
也就是说:你要么整体传递一个包含连续元素的容器,要么传递两个参数来指定元素的初始范围。在任何情况下,元素数量都必须与指定的固定大小匹配。
# 带隐式转换的构造
跨度的元素类型必须与它所引用序列的元素类型一致。不支持转换(即使是隐式标准转换)。不过,允许使用额外的限定符,如const
。这同样适用于复制构造函数:
std::vector<int> vec{1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
std::span<const int> sp9a{vec}; // 没问题:带有const的元素类型
std::span<long> sp9b{vec}; // 编译时错误:无效的元素类型
std::span<int> sp9c{sp9a}; // 编译时错误:去掉了const限定
std::span<const long> sp9d{sp9a}; // 编译时错误:不同的元素类型
2
3
4
5
为了让容器能够引用用户定义容器的元素,这些容器必须表明它们或它们的迭代器要求所有元素都在连续内存中。为此,它们必须满足contiguous_iterator
概念。
构造函数还允许在跨度之间进行以下类型转换:
- 固定大小的跨度可以转换为具有相同固定大小且带有额外限定符的跨度。
- 固定大小的跨度可以转换为动态大小的跨度。
- 动态大小的跨度可以转换为固定大小的跨度,前提是当前大小合适。
使用条件显式(conditional explicit),只有固定大小跨度的构造函数是显式的。在这种情况下,如果初始值需要转换,则无法进行复制初始化(使用=
):
std::vector<int> vec{1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
std::span<int> spanDyn{vec.begin(), 5}; // 没问题
std::span<int> spanDyn2 = {vec.begin(), 5}; // 没问题
std::span<int, 5> spanFix{vec.begin(), 5}; // 没问题
std::span<int, 5> spanFix2 = {vec.begin(), 5}; // 错误
2
3
4
5
6
因此,只有在转换为动态大小或相同固定大小的跨度时,才隐式支持转换:
void fooDyn(std::span<int>);
void fooFix(std::span<int, 5>);
fooDyn({vec.begin(), 5}); // 没问题
fooDyn(spanDyn); // 没问题
fooDyn(spanFix); // 没问题
fooFix({vec.begin(), 5}); // 错误
fooFix(spanDyn); // 错误
fooFix(spanFix); // 没问题
spanDyn = spanDyn; // 没问题
spanDyn = spanFix; // 没问题
spanFix = spanFix; // 没问题
spanFix = spanDyn; // 错误
2
3
4
5
6
7
8
9
10
11
12
13
# 9.4.3 子跨度操作
创建子跨度的成员函数可以创建动态大小或固定大小的跨度。将大小作为调用参数传递通常会得到一个动态大小的跨度。将大小作为模板参数传递通常会得到一个固定大小的跨度。至少对于first()
和last()
成员函数来说,总是如此:
std::vector vec{1.1, 2.2, 3.3, 4.4, 5.5};
std::span spDyn{vec};
auto sp1 = spDyn.first(2); // 前2个元素,动态大小
auto sp2 = spDyn.last(2); // 后2个元素,动态大小
auto sp3 = spDyn.first<2>(); // 前2个元素,固定大小
auto sp4 = spDyn.last<2>(); // 后2个元素,固定大小
std::array arr{1.1, 2.2, 3.3, 4.4, 5.5};
std::span spFix{arr};
auto sp5 = spFix.first(2); // 前2个元素,动态大小
auto sp6 = spFix.last(2); // 后2个元素,动态大小
auto sp7 = spFix.first<2>(); // 前2个元素,固定大小
auto sp8 = spFix.last<2>(); // 后2个元素,固定大小
2
3
4
5
6
7
8
9
10
11
12
13
14
15
然而,对于subspan()
函数,结果有时可能会出乎意料。传递调用参数总是会得到动态大小的跨度:
std::vector vec{1.1, 2.2, 3.3, 4.4, 5.5};
std::span spDyn{vec};
auto s1 = spDyn.subspan(2); // 第3个到最后一个元素,动态大小
auto s2 = spDyn.subspan(2, 2); // 第3个到第4个元素,动态大小
auto s3 = spDyn.subspan(2, std::dynamic_extent); // 第3个到最后一个元素,动态大小
std::array arr{1.1, 2.2, 3.3, 4.4, 5.5};
std::span spFix{arr};
auto s4 = spFix.subspan(2); // 第3个到最后一个元素,动态大小
auto s5 = spFix.subspan(2, 2); // 第3个到第4个元素,动态大小
auto s6 = spFix.subspan(2, std::dynamic_extent); // 第3个到最后一个元素,动态大小
2
3
4
5
6
7
8
9
10
11
12
13
然而,当传递模板参数时,结果可能与你预期的不同:
std::vector vec{1.1, 2.2, 3.3, 4.4, 5.5};
std::span spDyn{vec};
auto s1 = spDyn.subspan<2>(); // 第3个到最后一个元素,动态大小
auto s2 = spDyn.subspan<2, 2>(); // 第3个到第4个元素,固定大小
auto s3 = spDyn.subspan<2, std::dynamic_extent>(); // 第3个到最后一个元素,动态大小
std::array arr{1.1, 2.2, 3.3, 4.4, 5.5};
std::span spFix{arr};
auto s4 = spFix.subspan<2>(); // 第3个到最后一个元素,固定大小
auto s5 = spFix.subspan<2, 2>(); // 第3个到第4个元素,固定大小
auto s6 = spFix.subspan<2, std::dynamic_extent>(); // 第3个到最后一个元素,固定大小
2
3
4
5
6
7
8
9
10
11
12
13
# 9.5 补充说明
跨度最初由Lukasz Mendakiewicz和Herb Sutter在http://wg21.link/n3851 (opens new window)中作为array_views
提出,Neil MacIntosh在http://wg21.link/p0122r0 (opens new window)中首次进行了提议。最终被接受的表述由Neil MacIntosh和Stephan T. Lavavej在http://wg21.link/p0122r7 (opens new window)中拟定。
随后又进行了一些修改,比如Tony Van Eerd在http://wg21.link/P1085R2 (opens new window)中提议移除所有比较操作符,以及通过http://wg21.link/lwg3320 (opens new window)的决议移除对const_iterator
的支持。
C++20发布后,视图的定义发生了变化,现在跨度始终属于视图(见http://wg21.link/p2325r3 (opens new window))。