第15章 指针和容器
第15章 指针和容器
- 介绍
- 指针
- unique_ptr 和 shared_ptr ;
- span
- 容器
- array ;
- bitset ;
- pair ;
- tuple
- 替代方案
- variant ;
- optional ;
- any
- 建议
15.1 介绍
C++提供了简单的内置低级类型来持有和引用数据:对象和数组用来持有数据;指针和数组用来引用这些数据。然而,我们需要支持更多专门化和更通用的方式来持有和使用数据。例如,标准库容器(第12章)和迭代器(§13.3)被设计用来支持一般算法。
容器和指针抽象之间的主要共同点是,它们的正确和高效使用需要将数据与一组访问和操纵这些数据的函数封装在一起。例如,指针是机器地址的非常通用和高效的抽象,但事实证明,正确使用它们来表示资源的所有权是非常困难的。因此,标准库提供了资源管理指针;也就是说,封装了指针的类,并提供了简化其正确使用的操作。
这些标准库抽象封装了内置语言类型,并且需要在时间和空间上表现良好,就像正确使用这些类型一样。
这些类型没有什么“神奇”之处。我们可以根据需要,使用与标准库相同的技术,设计和实现自己的“智能指针”和专用容器。
15.2 指针
指针 的一般概念是允许我们引用一个对象并根据其类型访问它。内置指针,如 int*,就是一个例子,但还有很多其他的例子。
T* | 内置指针类型:指向类型为 T 的对象,或指向连续分配的一系列 T 类型的元素 |
T& | 内置引用类型:引用一个类型为 T 的对象;带隐式解引用的指针(§1.7) |
unique_ptr<T> | 指向一个 T 类型对象owning pointer |
shared_ptr<T> | 共享指针,指向一个 T 类型的对象;该对象的所有权由所有指向它的 shared_ptr 共享 |
weak_ptr<T> | 一个指向由 shared_ptr 所拥有对象的指针;如需访问该对象,必须将其转换为 shared_ptr 。 |
span<T> | 指向一个连续 T 序列的指针(§15.2.2) |
string_view<T> | 指向一个 const 子字符串的指针(§10.3) |
X_iterator<C> | 来自 C 的一系列元素;名称中的 X 表示迭代器的种类。(§13.3) |
可以有多个指针指向同一个对象。 owning pointer 是指负责最终删除它所引用的对象的指针。 non-owning pointer (例如, T* 或 span )可能会 悬空 (dangle);也就是说,指向已被删除或已超出作用域的对象的位置。
通过悬空指针进行读取或写入是最棘手的错误之一。这样做的结果是技术上未定义的。在实践中,这通常意味着访问恰好占据该位置的对象。然后,读取意味着获取一个任意值,而写入则会破坏一个不相关的数据结构。我们所能希望的最好结果就是程序崩溃;这通常比错误的结果更可取。 C++核心指南[CG]提供了避免这种情况的规则,并建议进行静态检查以确保它永远不会发生。然而,以下是避免指针问题的一些方法:
• 在对象超出作用域后,不要保留指向局部对象的指针。特别是,永远不要从函数中返回指向局部对象的指针,也不要将来源不确定的指针存储在长期存在的数据结构中。系统地使用容器和算法(第12章,第13章)通常可以使我们避免使用导致难以避免指针问题的编程技术。
• 使用 owning pointer来管理在自由存储区上分配的对象。
• 指向静态对象(例如,全局变量)的指针不会悬空。
• 将指针算术留给资源句柄(如 vector 和 unordered_map )的实现来处理。
• 记住, string_view 和 span 是一种 non-owning pointer。
15.2.1 unique_ptr 和 shared_ptr
任何非平凡程序的关键任务之一是管理资源。资源是指必须被获取并在稍后的某个时刻(显式或隐式地)释放的东西。例如,内存、锁、套接字、线程句柄和文件句柄都是资源。对于长期运行的程序,如果未能及时释放资源(即“泄漏”),可能会导致严重的性能下降(§ 12.7),甚至可能导致程序崩溃。即使对于短程序,资源泄漏也可能成为一个尴尬的问题,例如,由于资源短缺而导致运行时间增加几个数量级。
标准库组件被设计为不会泄漏资源。为了做到这一点,它们依赖于使用构造函数/析构函数对来管理资源的基本语言支持,以确保资源不会比负责它的对象存活更长时间。在 Vector 中使用构造函数/析构函数对来管理其元素的生命周期就是一个例子(§ 5.2.2),并且所有标准库容器都以类似的方式实现。重要的是,这种方法与使用异常的错误处理正确交互。例如,标准库锁类就使用了这种技术:
mutex m; // 用于保护对共享数据的访问
void f()
{
scoped_lock lck{m}; // 获取互斥锁m
// ...操作共享数据...
}
线程 不会继续执行,直到 lck 的构造函数已经获取了 互斥锁 (§ 18.3)。相应的析构函数会释放 互斥锁 。因此,在这个例子中,当控制线程离开 f 函数(通过返回、“从函数末尾落下”,或通过抛出异常)时, scoped_lock 的析构函数会释放 互斥锁 。
这是RAII(资源获取即初始化技术;§ 5.2.2)的一个应用。RAII是C++中资源管理习惯用法的基础。容器(如 vector 、 map 、 string 和 iostream )也以类似的方式管理它们的资源(如文件句柄和缓冲区)。
到目前为止的例子都处理了作用域内定义的对象,在作用域退出时释放它们获取的资源,但是自由存储区上分配的对象呢?在 <memory> 中,标准库提供了两个“智能指针”来帮助管理自由存储区上的对象:
- unique_ptr 表示唯一所有权(其析构函数会销毁其对象)
- shared_ptr 表示共享所有权(最后一个 shared_ptr 的析构函数会销毁其对象)
这些“智能指针”最基本的用法是防止由于粗心编程导致的内存泄漏。例如:
void f(int i, int j) // X* vs. unique_ptr<X>
{
X* p = new X; // 分配一个新的X
unique_ptr<X> sp{new X}; // 分配一个新的X并将其指针交给unique_ptr
// ...
if (i<99) throw Z{}; // 可能会抛出异常
if (j<77) return; // 可能会“提前”返回
// ...使用p和sp..
delete p; // 销毁*p
}
在这里,如果 i<99 或 j<77 ,我们“忘记”了删除 p 。另一方面, unique_ptr 确保了无论我们如何退出 f() (通过抛出异常、执行 return 或通过“从函数末尾落下”),其对象都会被正确销毁。讽刺的是,我们本可以通过不使用指针和不使用 new 来解决这个问题:
void f(int i, int j) // 使用局部变量
{
X x;
// ...
}
不幸的是, new (以及指针和引用)的过度使用似乎是一个日益严重的问题。
然而,当你真正需要指针的语义时, unique_ptr 是一个轻量级的机制,与正确使用内置指针相比,它在空间和时间上都没有开销。它的进一步用途包括在函数之间传递自由存储区分配的对象:
unique_ptr<X> make_X(int i) // 创建一个X并立即将其交给unique_ptr
{
// ...检查i等...
return unique_ptr<X>{new X{i}};
}
unique_ptr 是对单个对象(或数组)的句柄,与 vector 是对对象序列的句柄非常相似。两者都控制其他对象的生命周期(使用RAII),并且两者都依赖于消除复制或使用移动语义来使返回操作简单且高效(§ 6.2.2)。
shared_ptr 与 unique_ptr 类似,不同之处在于 shared_ptr 是被复制的而不是被移动的。一个对象的 shared_ptr 共享对该对象的所有权;当最后一个 shared_ptr 被销毁时,该对象也会被销毁。例如:
void f(shared_ptr<fstream>);
void g(shared_ptr<fstream>);
void user(const string& name, ios_base::openmode mode)
{
shared_ptr<fstream> fp{new fstream(name,mode)};
if (!*fp) // 确保文件被正确打开
throw No_file{};
f(fp);
g(fp);
// ...
}
现在,由 fp 的构造函数打开的文件将由最后一个显式或隐式销毁 fp 副本的函数关闭。注意, f() 或 g() 可能会启动一个持有 fp 副本的任务,或以某种方式存储一个比 user() 存活时间更长的副本。因此, shared_ptr 提供了一种垃圾收集形式,它尊重基于析构函数的内存管理对象的资源管理。这既不是免费的,也不是昂贵的,但它确实使得共享对象的生命周期难以预测。只有在你确实需要共享所有权时,才使用 shared_ptr 。
在自由存储区上创建一个对象,然后将指向它的指针传递给智能指针有点冗长。它还允许出现错误,例如忘记将指针传递给 unique_ptr ,或者将不是自由存储区上的对象的指针传递给 shared_ptr 。为了避免这类问题,标准库(在 <memory> 中)提供了用于构造对象并返回适当的智能指针的函数,即 make_shared() 和 make_unique() 。例如:
struct S {
int i;
string s;
double d;
// ...
};
auto p1 = make_shared<S>(1,"Ankh Morpork",4.65); // p1是一个shared_ptr<S>
auto p2 = make_unique<S>(2,"Oz",7.62); // p2是一个unique_ptr<S>
现在, p2 是一个 unique_ptr<S> ,指向一个自由存储区上分配的类型为 S 的对象,其值为 {2,"Oz"s,7.62} 。
使用 make_shared() 不仅比单独使用 new 创建对象然后将其传递给 shared_ptr 更方便,而且它还更高效,因为它不需要为在 shared_ptr 实现中必不可少的引用计数进行单独的分配。
有了 unique_ptr 和 shared_ptr ,我们可以为许多程序实现一个完整的“无裸 new ”策略(§ 5.2.2)。然而,这些“智能指针”在概念上仍然是指针,因此它们只是我进行资源管理的第二选择——在容器和其他在更高概念层面上管理资源的类型之后。特别是, shared_ptr 本身并不提供关于其所有者中哪些可以读取和/或写入共享对象的任何规则。数据竞争(§ 18.5)和其他形式的混乱并不能仅仅通过消除资源管理问题来解决。
我们何时使用“智能指针”(如 unique_ptr )而不是具有专门为资源设计的操作的资源句柄(如 vector 或 thread )?不出所料,答案是“当我们需要指针语义时”。
- 当我们共享一个对象时,我们需要指针(或引用)来引用共享对象,因此 shared_ptr 成为明显的选择(除非有一个明显的单一所有者)。
- 当我们在传统的面向对象代码(§ 5.5)中引用一个多态对象时,我们需要一个指针(或引用),因为我们不知道所引用对象的确切类型(甚至其大小),因此 unique_ptr 成为明显的选择。
- 一个共享的多态对象通常需要 shared_ptr 。
我们不需要使用指针来从函数返回一个对象集合;一个作为资源句柄的容器将简单地并高效地通过依赖拷贝省略(§ 3.4.2)和移动语义(§ 6.2.2)来完成这项工作。
15.2.2 span
传统上,范围错误一直是C和C++程序中严重错误的主要来源,导致错误的结果、崩溃和安全问题。容器(第12章)、算法(第13章)以及范围 for 循环的使用已经显著减少了这个问题,但仍然有更多的工作可以做。
范围错误的一个主要来源是人们传递指针(原始指针或智能指针),然后依赖约定来了解所指向的元素数量。对于资源句柄之外的代码,最好的建议是假设最多只指向一个对象[CG: F.22],但如果没有支持,这个建议就难以管理。
标准库中的 string_view (§ 10.3)可以提供帮助,但它是只读的,并且仅适用于字符。大多数程序员需要更多的功能。例如,在编写低级软件中的缓冲区读写操作时,在保持高性能的同时避免范围错误(“缓冲区溢出”)是非常困难的。 <span> 中的 span 基本上是一个(指针,长度)对,表示一个元素序列:
span 提供了一种访问连续元素序列的方式。这些元素可以以多种方式存储,包括 vector 和内置数组。与指针一样, span 并不拥有它所指向的字符。在这方面,它类似于 string_view (§ 10.3)和STL迭代器对(§ 13.3)。
考虑一种常见的接口风格:
void fpn(int* p, int n)
{
for (int i = 0; i < n; ++i)
p[i] = 0;
}
我们假设 p 指向 n 个整数。不幸的是,这个假设仅仅是一种约定,所以我们不能使用它来编写范围 for 循环,编译器也无法实现廉价且有效的范围检查。此外,我们的假设可能是错误的:
void use(int x)
{
int a[100];
fpn(a,100);
fpn(a,1000); // 哎呀,我的手指滑了一下!(fpn中的范围错误)
fpn(a+10,100); // fpn中的范围错误
fpn(a,x); // 可疑,但看起来无害
}
使用 span 我们可以做得更好:
void fs(span<int> p)
{
for (int& x : p)
x = 0;
}
我们可以这样使用 fs :
void use(int x)
{
int a[100];
fs(a); // 隐式创建span<int>{a,100}
fs(a,1000); // 错误:预期为span
fs({a+10,100}); // fs中的范围错误
fs({a,x}); // 显然可疑
}
也就是说,直接从数组创建 span 的常见情况现在是安全的(编译器计算元素数量)并且符号简单。在其他情况下,由于程序员必须显式地组合一个 span ,因此出错的概率降低了,并且错误检测变得更容易。
span 从函数传递到函数的常见情况比(pointer,count)接口更简单,并且显然不需要额外的检查:
void f1(span<int> p);
void f2(span<int> p)
{
// ...
f1(p);
}
对于容器,当使用 span 进行下标访问(例如, r[i] )时,不进行范围检查,并且越界访问是未定义行为。当然,实现可以将这种未定义行为实现为范围检查,但遗憾的是很少这样做。来自Core Guidelines支持库的原始 gsl::span 确实支持范围检查。
15.3 容器
标准库提供了几种并不完全适合STL框架(第12章、第13章)的容器。例如,内置数组、 array 和 string 。我有时会把它们称为“类容器”,但这并不完全准确:它们持有元素,所以它们是容器,但每个都有其限制或附加设施,使它们在STL的上下文中使用起来不方便。将它们单独描述可以简化STL的说明。
T[N] | 内置数组:固定大小的连续分配的 N 个 T 类型元素序列;隐式转换为 T* |
array<T,N> | 固定大小的连续分配的 N 个 T 类型元素序列;类似于内置数组,但解决了大多数问题 |
bitset<N> | 固定大小的 N 位序列 |
vector<bool> | 位的紧凑存储,作为 vector 的特化,优化了存储空间 |
pair<T,U> | 两个类型分别为 T 和 U 的元素 |
tuple<T...> | 任意数量任意类型的元素序列 |
basic_string<C> | 类型为 C 的字符序列;提供了字符串操作 |
valarray<T> | 类型为 T 的数值数组;提供了数值运算功能 |
标准库提供如此多的容器的原因在于,它们服务于常见但不同(且经常重叠)的需求。如果标准库不提供这些容器,许多人将不得不自行设计和实现。例如:
- pair 和 tuple 是异构的;所有其他容器都是同构的(所有元素都是相同的类型)。
- array 和 tuple 的元素是连续分配的;而 list 和 map 是链接结构。
- bitset 和 vector<bool> 持有位并通过代理对象访问它们;所有其他标准库容器都可以持有各种类型并直接访问元素。
- basic_string 要求其元素是某种形式的字符,并提供字符串操作,如拼接和与区域设置相关的操作。
- valarray 要求其元素是数字,并提供数值操作。
所有这些容器都可以被看作是为庞大的程序员社区提供所需的专业化服务。没有单一的容器能够满足所有这些需求,因为有些需求是相互矛盾的,例如,“能够增长”与“保证在固定位置分配”,以及“添加元素时元素不移动”与“连续分配”。
有些场景需要容器能够在运行时动态增加容量(如 vector ),这通常意味着它可能不会始终位于内存中的同一位置。另外,有些应用可能需要元素被保证分配在固定的地址上,这样可以快速且直接地访问(如静态数组 array 或全局变量),但这牺牲了动态增长的能力。
某些情况下添加新元素时需要保证已有元素的地址不变(如 list ,它通过链表结构实现,插入元素不影响其他元素的位置),而另一些情况则需要元素连续存储以优化访问速度和缓存使用(如前面提到的 vector ),但连续存储意味着插入操作可能会导致已有元素的移动。
15.3.1 array
<array> 中定义的 array 是一个给定类型的固定大小元素序列,其元素数量在编译时指定。因此, array 可以与其元素一起在栈上、对象中或静态存储区中分配。元素在定义 array 的作用域中分配。 array 最好被理解为内置数组,其大小固定不变,没有隐式的、可能令人惊讶的到指针类型的转换,并提供了一些便利的函数。与使用内置数组相比,使用 array 没有额外的时间或空间开销。 array 不遵循STL容器的“‘handle to elements”模型。相反, array 直接包含其元素。它只不过是内置数组的一个更安全版本。
"Handle to elements" 是一个计算机科学中的概念,特别是在面向对象编程和高级数据结构设计的上下文中广泛使用。这个概念描述了一种设计模式,其中数据结构(如容器)不直接暴露其内部元素,而是通过某种间接引用(即“句柄”handle)来管理和操作这些元素。这里的“句柄”可以想象成是一个轻量级的代理或指针,它代表了对实际数据的访问权限,而不是数据本身。
这意味着 array 必须通过初始化列表进行初始化:
array<int,3> a1 = {1,2,3};
初始化器中的元素数量必须小于或等于为 array 指定的元素数量。元素数量不是可选的,且必须是一个常量表达式,必须为正数,必须明确指定元素类型:
void f(int n)
{
array<int> a0 = {1,2,3}; // 错误:未指定大小
array<string,n> a1 = {"John's", "Queens' "}; // 错误:大小不是常量表达式
array<string,0> a2; // 错误:大小必须为正数
array<2> a3 = {"John's", "Queens' "}; // 错误:未指定元素类型
// ...
}
如果需要元素数量为变量,请使用 vector 。
在必要时, array 可以显式传递给期望指针的C风格函数。例如:
void f(int* p, int sz); // C风格接口
void g()
{
array<int,10> a;
f(a, a.size()); // 错误:没有转换
f(a.data(), a.size()); // C风格使用
// ...
}
auto p = find(a,777); // C++/STL风格使用(传递一个范围)
// ...
当我们有更加灵活的 vector 时,为什么还要使用 array ? array 的灵活性较低,因此它更简单。有时,直接访问栈上分配的元素而不是在自由存储区上分配元素、通过 vector (一个句柄)间接访问它们,然后再释放它们,会有显著的性能优势。另一方面,栈是一种有限的资源(特别是在一些嵌入式系统上),栈溢出是很糟糕的。此外,还有一些应用领域,如安全严格的实时控制,其中禁止自由存储区分配。例如,使用 delete 可能会导致碎片化(§12.7)或内存耗尽(§4.3)。
当我们可以使用内置数组时,为什么还要使用 array ? array 知道自己的大小,因此很容易与标准库算法一起使用,并且可以使用 = 进行复制。例如:
array<int,3> a1 = {1, 2, 3 };
auto a2 = a1; // 复制
a2[1] = 5;
a1 = a2; // 赋值
然而,我更喜欢 array 的主要原因是它可以避免令人惊讶和讨厌的到指针的转换。考虑一个涉及类层次结构的例子:
void h()
{
Circle a1[10];
array<Circle,10> a2;
// ...
Shape* p1 = a1; // OK:灾难等待发生
Shape* p2 = a2; // 错误:没有从array<Circle,10>到Shape*的转换(好!)
p1[3].draw(); // 灾难
}
“灾难”注释假设 sizeof(Shape)<sizeof(Circle) ,因此通过 Shape*对 Circle[] 进行下标访问会给出错误的偏移量。所有标准容器相比内置数组都提供了这一优势。
15.3.2 bitset
系统的某些方面,如输入流的状态,通常表示为一系列标志,指示二进制条件,如好/坏、真/假、开/关。C++通过整数上的位运算(§1.4)高效地支持小标志集的概念。 bitset<N> 类通过提供对 N 位序列 [0:N) 的操作来泛化这一概念,其中 N 在编译时已知。对于不适合放入 long long int (通常是64位)的位集合,使用 bitset 比直接使用整数更方便。对于较小的集合, bitset 通常是经过优化的。如果你想给位命名,而不是编号,你可以使用集合(§12.5)或枚举(§2.4)。
bitset 可以用整数或字符串初始化:
bitset<9> bs1 {"110001111"};
bitset<9> bs2 {0b1'1000'1111}// 使用数字分隔符的二进制字面量(§1.4)
可以应用常用的位运算符(§1.4)以及左移和右移运算符( << 和 >> ):
bitset<9> bs3 = ~bs1;// 补码:bs3=="001110000"
bitset<9> bs4 = bs1&bs3;// 全零
bitset<9> bs5 = bs1<<2;// 左移:bs5 = "000111100"
左移运算符(这里是 << )将“移入”零。
to_ullong() 和 to_string() 操作提供了与构造函数相反的操作。例如,我们可以写一个 int 的二进制表示:
void binary(int i)
{
bitset<8*sizeof(int)> b = i; // 假设8位字节(§17.7)
cout << b.to_string() << '\n'; // 写出i的位
}
这将从左到右打印出表示为 1 和 0 的位,最左边的位是最高位,因此参数123的输出将是
00000000000000000000000001111011
对于这个例子,直接使用 bitset 的输出运算符更简单:
void binary2(int i)
{
bitset<8*sizeof(int)> b = i;
cout << b << '\n';
}
bitset 提供了许多函数来使用和操作位集合,如 all() 、 any() 、 none() 、 count() 、 flip() 。
15.3.3 pair
函数返回两个值的情况相当常见。实现这一点有很多方法,最简单且通常最好的方法是为此定义一个 结构体 。例如,我们可以返回一个值和一个成功指示器:
struct My_res {
Entry∗ ptr;
Error_code err;
};
My_res complex_search(vector<Entry>& v, const string& s)
{
Entry∗ found = nullptr;
Error_code err = Error_code::found;
// ... 在v中搜索s ...
return {found,err};
}
void user(const string& s)
{
My_res r = complex_search(entry_table,s);
if (r.err != Error_code::good) {
// ... 处理错误 ...
}
// ... 使用r.ptr ....
}
我们可以认为将失败编码为结束迭代器或 nullptr 更为优雅,但这只能表达一种失败。通常,我们希望返回两个单独的值。为每个值对定义一个具有特定名称的结构体通常效果很好,并且如果“值对”结构体及其成员的名称选择得当,则相当易读。然而,对于大型代码库,它可能会导致名称和约定的激增,并且对于需要一致命名的通用代码而言,它并不适用。因此,标准库提供了 pair 作为“值对”用例的一般支持。使用 pair ,我们的简单示例变为:
pair<Entry∗,Error_code> complex_search(vector<Entry>& v, const string& s)
{
Entry∗ found = nullptr;
Error_code err = Error_code::found;
// ... 在v中搜索s ...
return {found,err};
}
void user(const string& s)
{
auto r = complex_search(entry_table,s);
if (r.second != Error_code::good) {
// ... 处理错误 ...
}
// ... 使用r.first ....
}
pair 的成员被命名为 first 和 second 。从实现者的角度来看,这是有意义的,但在应用程序代码中,我们可能希望使用自己的名称。结构化绑定(§3.4.5)可用于处理这种情况:
void user(const string& s)
{
auto [ptr,success] = complex_search(entry_table,s);
if (success != Error_code::good)
// ... 处理错误 ...
}
标准库中的 pair (来自 <utility> )经常用于标准库和其他地方的“值对”用例。例如,标准库算法 equal_range 返回一个迭代器对,指定满足谓词的子序列:
template<typename Forward_iterator, typename T, typename Compare>
pair<Forward_iterator,Forward_iterator>
equal_range(Forward_iterator first, Forward_iterator last, const T& val, Compare cmp);
给定一个已排序的序列 [first:last) , equal_range() 将返回表示匹配谓词 cmp 的子序列的 pair 。我们可以使用它来在已排序的 Record 序列中进行搜索:
auto less = [](const Record& r1, const Record& r2) { return r1.name<r2.name;}; // 比较名称
void f(const vector<Record>& v) // 假设v根据“name”字段排序
{
auto [first,last] = equal_range(v.begin(),v.end(),Record{"Reg"},less);
for (auto p = first; p!=last; ++p) // 打印所有相等的记录
cout << ∗p;// 假设为Record定义了<<运算符
}
pair 提供了诸如 = 、 == 和 < 等运算符,如果其元素支持的话。类型推导使得在不显式提及其类型的情况下创建 pair 变得容易。例如:
void f(vector<string>& v)
{
pair p1 {v.begin(),2}; // 一种方式
auto p2 = make_pair(v.begin(),2); // 另一种方式
// ...
}
p1 和 p2 的类型都是 pair<vector<string>::iterator,int> 。
当代码不需要是通用的时,具有命名成员的简单结构体通常会带来更易维护的代码。
15.3.4 tuple
和数组一样,标准库容器是同构的;也就是说,它们的所有元素都是单一类型。然而,有时我们想要将不同类型的元素序列作为一个单一对象来处理;也就是说,我们需要一个异构容器; pair 就是一个例子,但并不是所有这样的异构序列都只有两个元素。标准库提供了 tuple 作为 pair 的泛化,可以包含零个或多个元素:
tuple t0 {}; // 空元组
tuple<string,int,double> t1 {"Shark",123,3.14}; // 类型被显式指定
auto t2 = make_tuple(string{"Herring"},10,1.23); // 类型被推导为tuple<string,int,double>
tuple t3 {"Cod"s,20,9.99}; // 类型被推导为tuple<string,int,double>
元组 的元素(成员)是独立的;它们之间没有维护不变性(§4.3)。如果我们想要一个不变性,我们必须将 元组 封装在一个类中,该类强制实施这个不变性。
对于单一、特定的用途,一个简单的 结构体 通常是理想的,但有许多通用用途, 元组 的灵活性使我们不必定义许多 结构体 ,代价是没有成员的记忆化名称。 元组 的成员是通过一个 get 函数模板来访问的。例如:
string fish = get<0>(t1); // 获取第一个元素:"Shark"
int count = get<1>(t1); // 获取第二个元素:123
double price = get<2>(t1); // 获取第三个元素:3.14
元组 的元素是编号的(从 0 开始),并且 get() 的参数必须是一个常量。 get 是一个模板函数,它将索引作为模板值参数(§7.2.2)。
通过索引访问元组的成员是通用的、丑陋的,并且容易出错。幸运的是,元组中唯一类型的元素可以通过其类型来“命名”:
auto fish = get<string>(t1);
auto count = get<int>(t1);
auto price = get<double>(t1);
我们还可以使用 get<> 来写入:
get<string>(t1) = "Tuna";
get<int>(t1) = 7;
get<double>(t1) = 312;
元组 的大多数用途都隐藏在更高级结构的实现中。例如,我们可以使用结构化绑定(§3.4.5)来访问 t1 的成员:
auto [fish, count, price] = t1;
cout << fish << ' ' << count << ' ' << price << '\n';
fish = "Sea Bass";
通常,这种绑定及其底层对元组的使用是用于函数调用:
auto [fish, count, price] = todays_catch();
cout << fish << ' ' << count << ' ' << price << '\n';
元组 的真正优势在于,当你需要存储或传递未知数量的未知类型元素作为对象时。
显式地,遍历 元组 的元素有点麻烦,需要递归和编译时评估函数体:
template <size_t N = 0, typename... Ts>
constexpr void print(tuple<Ts...> tup)
{
if constexpr (N<sizeof...(Ts)) { // 还没到末尾?
cout << get<N>(tup) << ' '; // 打印第N个元素
print<N+1>(tup); // 打印下一个元素
}
}
在这里, sizeof...(Ts) 给出了 Ts 中元素的数量。
使用 print() 很简单:
print(t0); // 没有输出
print(t2); // Herring 10 1.23
print(tuple{ "Norah", 17, "Gavin", 14, "Anya", 9, "Courtney", 9, "Ada", 0 });
像 pair 一样,如果其元素支持, tuple 也提供 = 、 == 和 < 操作符。在有两个成员的情况下, pair 和 tuple 之间也存在转换。
15.4 替代方案
标准提供了三种类型来表达选择项:
union | 内置类型,用于保存一组可选类型中的一个(§2.5) |
variant<T...> | 来自指定集合中的一个可选类型(位于 <variant> 头文件中) |
optional<T> | 一个类型为 T 的值或没有值(位于 <optional> 头文件中) |
any | 一个来自无界限的可选类型集合的值(位于 <any> 头文件中) |
这些类型为用户提供了相关的功能。遗憾的是,它们并没有提供一个统一的接口。
15.4.1 variant
variant<A,B,C> 通常是比显式使用 union (§2.5)更安全、更方便的替代方案。 最简单的例子可能是返回一个值或一个错误代码:
variant<string,Error_code> compose_message(istream& s)
{
string mess;
// ... 从s读取并组合消息 ...
if (no_problems)
return mess; // 返回一个字符串
else
return Error_code{some_problem}; // 返回一个Error_code
}
当你用一个值赋值或初始化一个 variant 时,它会记住该值的类型。之后,我们可以查询 variant 持有的类型并提取该值。例如:
auto m = compose_message(cin);
if (holds_alternative<string>(m)) {
cout << get<string>(m);
}
else {
auto err = get<Error_code>(m);
// ... 处理错误 ...
}
这种风格吸引了一些不喜欢异常的人(§4.4),但还有更有趣的用途。例如,一个简单的编译器可能需要区分具有不同表示的不同类型的节点:
using Node = variant<Expression,Statement,Declaration,Type>;
void check(Node* p)
{
if (holds_alternative<Expression>(*p)) {
Expression& e = get<Expression>(*p);
// ...
}
else if (holds_alternative<Statement>(*p)) {
Statement& s = get<Statement>(*p);
// ...
}
// ... Declaration 和 Type ...
}
这种检查替代方案以决定适当操作的模式非常常见,但相对效率低下,因此值得直接支持:
void check(Node* p)
{
visit(overloaded {
[](Expression& e) { /* ... */ },
[](Statement& s) { /* ... */ },
// ... Declaration 和 Type ...
}, *p);
}
这基本上等同于一个虚函数调用,但可能更快。与所有性能声明一样,这个“可能更快”应该在性能至关重要时通过测量来验证。对于大多数用途,性能差异是不显著的。 overloaded 类是必要的,但奇怪的是,它不是标准的。它是一个“魔法片段”,可以从一组参数(通常是 lambda 表达式)构建一个重载集:
template<class... Ts>
struct overloaded : Ts... {
using Ts::operator()...;
};
// 变参模板(§8.4)
template<class... Ts>
overloaded(Ts...) -> overloaded<Ts...>; // 推导引导
然后,“访问者” visit 将 () 应用于重载对象,根据重载规则选择最合适的 lambda 表达式来调用。
推导引导是一种解决微妙歧义的机制,主要用于基础库中的类模板构造函数(§7.2.3)。
如果我们尝试访问一个持有与预期不同类型的 variant ,则会抛出 bad_variant_access 异常。
15.4.2 optional
optional<A> 可以看作是一种特殊的 variant (类似于 variant<A,nothing> ),或者看作是 A* 的一种泛化, A* 既可以指向一个对象,也可以是 nullptr 。
optional 对于可能返回也可能不返回对象的函数很有用:
optional<string> compose_message(istream& s)
{
string mess;
// ... 从s读取并组合消息 ...
}
if (no_problems)
return mess;
return {}; // 空的optional
鉴于此,我们可以写为:
if (auto m = compose_message(cin))
cout << *m; // 注意解引用(*)
else {
// ... 处理错误 ...
}
这对一些不喜欢异常的人很有吸引力(见§4.4)。注意* **** 的奇特用法。** optional** 被视为指向其对象的指针,而不是对象本身。 与 nullptr 等价的 optional 是空对象 {} 。例如:
int sum(optional<int> a, optional<int> b)
{
int res = 0;
if (a) res+=*a;
if (b) res+=*b;
return res;
}
int x = sum(17,19); //36
int y = sum(17,{}); //17
int z = sum({},{}); //0
如果我们尝试访问一个不包含值的 optional ,结果是未定义的;不会抛出异常。因此, optional 并不能保证类型安全。不要尝试:
int sum2(optional<int> a, optional<int> b)
{
return *a+*b; // 自找麻烦
}
15.4.3 any
any 可以持有任意类型,并且知道它持有的是哪种类型(如果有的话)。它基本上是 variant 的一个无约束版本:
any compose_message(istream& s)
{
string mess;
// ... 从s读取并组合消息 ...
}
if (no_problems)
return mess; // 返回一个字符串
else
return error_number; // 返回一个整数
当你用一个值给 any 赋值或初始化时,它会记住该值的类型。之后,我们可以通过断言值的预期类型来提取 any 持有的值。例如:
auto m = compose_message(cin);
string& s = any_cast<string>(m);
cout << s;
如果我们尝试访问一个持有与预期不同类型的 any ,将会抛出 bad_any_access 异常。
15.5 建议
- 一个库不必很大或很复杂才能有用;§16.1。
- 资源是需要获取并(显式或隐式地)释放的任何东西;§15.2.1。
- 使用资源句柄来管理资源(RAII);§15.2.1;[CG: R.1]。
- T* 的问题是它可以用来表示任何东西,所以我们无法轻易确定一个“原始”指针的用途;§15.2.1。
- 使用 unique_ptr 来引用多态类型的对象;§15.2.1;[CG: R.20]。
- 使用 shared_ptr 来引用共享对象;§15.2.1;[CG: R.20]。
- 优先使用具有特定语义的资源句柄,而不是智能指针;§15.2.1。
- 在可以使用局部变量的情况下,不要使用智能指针;§15.2.1。
- 优先使用 unique_ptr 而不是 shared_ptr ;§6.3,§15.2.1。
- 仅当需要转移所有权责任时,才将 unique_ptr 或 shared_ptr 用作参数或返回值;§15.2.1;[CG: F.26] [CG: F.27]。
- 使用 make_unique ()来构造 unique_ptr ;§15.2.1;[CG: R.22]。
- 使用 make_shared ()来构造 shared_ptr ;§15.2.1;[CG: R.23]。
- 优先使用智能指针而不是垃圾收集;§6.3,§15.2.1。
- 优先使用 span 而不是指针加计数的接口;§15.2.2;[CG: F.24]。
- span 支持范围 for 循环;§15.2.2。
- 当你需要一个具有 constexpr 大小的序列时,使用 array ;§15.3.1。
- 优先使用 array 而不是内置数组;§15.3.1;[CG: SL.con.2]。
- 如果你需要 N 位,并且 N 不一定是内置整数类型中的位数,那么使用 bitset ;§15.3.2。
- 不要过度使用 pair 和 tuple ;命名结构体通常会导致更可读的代码;§15.3.3。
- 当使用 pair 时,使用模板参数推导或 make_pair() 来避免冗余的类型说明;§15.3.3。
- 当使用 tuple 时,使用模板参数推导或 make_tuple() 来避免冗余的类型说明;§15.3.3;[CG: T.44]。
- 优先使用 variant 而不是显式使用 union ;§15.4.1;[CG: C.181]。
- 当使用 variant 在一组备选项中进行选择时,考虑使用 visit() 和 overloaded() ;§15.4.1。
- 如果 variant 、 optional 或 any 可能有多个备选项,请在访问前检查标签;§15.4。