- 内容提要
- 前言
- 第 1 章 预备知识
- 第 2 章 开始学习 C++
- 第 3 章 处理数据
- 第 4 章 复合类型
- 第 5 章 循环和关系表达式
- 第 6 章 分支语句和逻辑运算符
- 第 7 章 函数——C++的编程模块
- 第 8 章 函数探幽
- 第 9 章 内存模型和名称空间
- 第 10 章 对象和类
- 第 11 章 使用类
- 第 12 章 类和动态内存分配
- 第 13 章 类继承
- 第 14 章 C++中的代码重用
- 第 15 章 友元、异常和其他
- 第 16 章 string 类和标准模板库
- 第 17 章 输入、输出和文件
- 第 18 章 探讨 C++新标准
- 附录 A 计数系统
- 附录 B C++保留字
- 附录 C ASCII 字符集
- 附录 D 运算符优先级
- 附录 E 其他运算符
- 附录 F 模板类 string
- 附录 G 标准模板库方法和函数
- 附录 H 精选读物和网上资源
- 附录 I 转换为 ISO 标准 C++
- 附录 J 复习题答案
16.4 泛型编程
有了一些使用 STL 的经验后,来看一看底层理念。STL 是一种泛型编程(generic programming)。面向对象编程关注的是编程的数据方面,而泛型编程关注的是算法。它们之间的共同点是抽象和创建可重用代码,但它们的理念绝然不同。
泛型编程旨在编写独立于数据类型的代码。在 C++中,完成通用程序的工具是模板。当然,模板使得能够按泛型定义函数或类,而 STL 通过通用算法更进了一步。模板让这一切成为可能,但必须对元素进行仔细地设计。为解模板和设计是如何协同工作的,来看一看需要迭代器的原因。
16.4.1 为何使用迭代器
理解迭代器是理解 STL 的关键所在。模板使得算法独立于存储的数据类型,而迭代器使算法独立于使用的容器类型。因此,它们都是 STL 通用方法的重要组成部分。
为了解为何需要迭代器,我们来看如何为两种不同数据表示实现 find 函数,然后来看如何推广这种方法。首先看一个在 double 数组中搜索特定值的函数,可以这样编写该函数:
如果函数在数组中找到这样的值,则返回该值在数组中的地址,否则返回一个空指针。该函数使用下标来遍历数组。可以用模板将这种算法推广到包含= =运算符的、任意类型的数组。尽管如此,这种算法仍然与一种特定的数据结构(数组)关联在一起。
下面来看搜索另一种数据结构——链表的情况(第 12 章使用链表实现了 Queue 类)。链表由链接在一起的 Node 结构组成:
假设有一个指向链表第一个节点的指针,每个节点的 p_next 指针都指向下一个节点,链表最后一个节点的 p_next 指针被设置为 0,则可以这样编写 find_ll( ) 函数:
同样,也可以使用模板将这种算法推广到支持= =运算符的任何数据类型的链表。然而,这种算法也是与特定的数据结构——链表关联在一起。
从实现细节上看,这两个 find 函数的算法是不同的:一个使用数组索引来遍历元素,另一个则将 start 重置为 start->p_next。但从广义上说,这两种算法是相同的:将值依次与容器中的每个值进行比较,直到找到匹配的为止。
泛型编程旨在使用同一个 find 函数来处理数组、链表或任何其他容器类型。即函数不仅独立于容器中存储的数据类型,而且独立于容器本身的数据结构。模板提供了存储在容器中的数据类型的通用表示,因此还需要遍历容器中的值的通用表示,迭代器正是这样的通用表示。
要实现 find 函数,迭代器应具备哪些特征呢?下面是一个简短的列表。
- 应能够对迭代器执行解除引用的操作,以便能够访问它引用的值。即如果 p 是一个迭代器,则应对*p 进行定义。
- 应能够将一个迭代器赋给另一个。即如果 p 和 q 都是迭代器,则应对表达式 p=q 进行定义。
- 应能够将一个迭代器与另一个进行比较,看它们是否相等。即如果 p 和 q 都是迭代器,则应对 p= =q 和 p!=q 进行定义。
- 应能够使用迭代器遍历容器中的所有元素,这可以通过为迭代器 p 定义++p 和 p++来实现。
迭代器也可以完成其他的操作,但有上述功能就足够了,至少对于 find 函数是如此。实际上,STL 按功能的强弱定义了多种级别的迭代器,这将在后面介绍。顺便说一句,常规指针就能满足迭代器的要求,因此,可以这样重新编写 find_arr( ) 函数:
然后可以修改函数参数,使之接受两个指示区间的指针参数,其中的一个指向数组的起始位置,另一个指向数组的超尾(程序清单 7.8 与此类似);同时函数可以通过返回尾指针,来指出没有找到要找的值。下面的 find_ar( ) 版本完成了这些修改:
对于 find_ll( ) 函数,可以定义一个迭代器类,其中定义了运算符*和++:
为区分++运算符的前缀版本和后缀版本,C++将 operator++作为前缀版本,将 operator++(int)作为后缀版本;其中的参数永远也不会被用到,所以不必指定其名称。
这里重点不是如何定义 iterator 类,而是有了这样的类后,第二个 find 函数就可以这样编写:
这和 find_ar( ) 几乎相同,差别在于如何谓词已到达最后一个值。find_ar( ) 函数使用超尾迭代器,而 find_ll( ) 使用存储在最后一个节点中的空值。除了这种差别外,这两个函数完全相同。例如,可以要求链表的最后一个元素后面还有一个额外的元素,即让数组和链表都有超尾元素,并在迭代器到达超尾位置时结束搜索。这样,find_ar( ) 和 find_ll( ) 检测数据尾的方式将相同,从而成为相同的算法。注意,增加超尾元素后,对迭代器的要求变成了对容器类的要求。
STL 遵循上面介绍的方法。首先,每个容器类(vector、list、deque 等)定义了相应的迭代器类型。对于其中的某个类,迭代器可能是指针;而对于另一个类,则可能是对象。不管实现方式如何,迭代器都将提供所需的操作,如*和++(有些类需要的操作可能比其他类多)。其次,每个容器类都有一个超尾标记,当迭代器递增到超越容器的最后一个值后,这个值将被赋给迭代器。每个容器类都有 begin( ) 和 end( ) 方法,它们分别返回一个指向容器的第一个元素和超尾位置的迭代器。每个容器类都使用++操作,让迭代器从指向第一个元素逐步指向超尾位置,从而遍历容器中的每一个元素。
使用容器类时,无需知道其迭代器是如何实现的,也无需知道超尾是如何实现的,而只需知道它有迭代器,其 begin( ) 返回一个指向第一个元素的迭代器,end( ) 返回一个指向超尾位置的迭代器即可。例如,假设要打印 vector<double>对象中的值,则可以这样做:
其中,下面的代码行将 pr 的类型声明为 vector<double>类的迭代器:
如果要使用 list<double>类模板来存储分数,则代码如下:
唯一不同的是 pr 的类型。因此,STL 通过为每个类定义适当的迭代器,并以统一的风格设计类,能够对内部表示绝然不同的容器,编写相同的代码。
使用 C++11 新增的自动类型推断可进一步简化:对于矢量或列表,都可使用如下代码:
实际上,作为一种编程风格,最好避免直接使用迭代器,而应尽可能使用 STL 函数(如 for_each( ))来处理细节。也可使用 C++11 新增的基于范围的 for 循环:
来总结一下 STL 方法。首先是处理容器的算法,应尽可能用通用的术语来表达算法,使之独立于数据类型和容器类型。为使通用算法能够适用于具体情况,应定义能够满足算法需求的迭代器,并把要求加到容器设计上。即基于算法的要求,设计基本迭代器的特征和容器特征。
16.4.2 迭代器类型
不同的算法对迭代器的要求也不同。例如,查找算法需要定义++运算符,以便迭代器能够遍历整个容器;它要求能够读取数据,但不要求能够写数据(它只是查看数据,而并不修改数据)。而排序算法要求能够随机访问,以便能够交换两个不相邻的元素。如果 iter 是一个迭代器,则可以通过定义+运算符来实现随机访问,这样就可以使用像 iter + 10 这样的表达式了。另外,排序算法要求能够读写数据。
STL 定义了 5 种迭代器,并根据所需的迭代器类型对算法进行了描述。这 5 种迭代器分别是输入迭代器、输出迭代器、正向迭代器、双向迭代器和随机访问迭代器。例如,find( ) 的原型与下面类似:
这指出,这种算法需要一个输入迭代器。同样,下面的原型指出排序算法需要一个随机访问迭代器:
对于这 5 种迭代器,都可以执行解除引用操作(即为它们定义了*运算符),也可进行比较,看其是相等(使用= =运算符,可能被重载了)还是不相等(使用!=运算符,可能被重载了)。如果两个迭代器相同,则对它们执行解除引用操作得到的值将相同。即如果表达式 iter1 == iter2 为真,则下述表达式也为真:
is true, then the following is also true:
当然,对于内置运算符和指针来说,情况也是如此。因此,这些要求将指导您如何对迭代器类重载这些运算符。下面来看迭代器的其他特征。
1.输入迭代器
术语“输入”是从程序的角度说的,即来自容器的信息被视为输入,就像来自键盘的信息对程序来说是输入一样。因此,输入迭代器可被程序用来读取容器中的信息。具体地说,对输入迭代器解除引用将使程序能够读取容器中的值,但不一定能让程序修改值。因此,需要输入迭代器的算法将不会修改容器中的值。
输入迭代器必须能够访问容器中所有的值,这是通过支持++运算符(前缀格式和后缀格式)来实现的。如果将输入迭代器设置为指向容器中的第一个元素,并不断将其递增,直到到达超尾位置,则它将依次指向容器中的每一个元素。顺便说一句,并不能保证输入迭代器第二次遍历容器时,顺序不变。另外,输入迭代器被递增后,也不能保证其先前的值仍然可以被解除引用。基于输入迭代器的任何算法都应当是单通行(single-pass)的,不依赖于前一次遍历时的迭代器值,也不依赖于本次遍历中前面的迭代器值。
注意,输入迭代器是单向迭代器,可以递增,但不能倒退。
2.输出迭代器
STL 使用术语“输出”来指用于将信息从程序传输给容器的迭代器,因此程序的输出就是容器的输入。输出迭代器与输入迭代器相似,只是解除引用让程序能修改容器值,而不能读取。也许您会感到奇怪,能够写,却不能读。发送到显示器上的输出就是如此,cout 可以修改发送到显示器的字符流,却不能读取屏幕上的内容。STL 足够通用,其容器可以表示输出设备,因此容器也可能如此。另外,如果算法不用读取作容器的内容就可以修改它(如通过生成要存储的新值),则没有理由要求它使用能够读取内容的迭代器。
简而言之,对于单通行、只读算法,可以使用输入迭代器;而对于单通行、只写算法,则可以使用输出迭代器。
3.正向迭代器
与输入迭代器和输出迭代器相似,正向迭代器只使用++运算符来遍历容器,所以它每次沿容器向前移动一个元素;然而,与输入和输出迭代器不同的是,它总是按相同的顺序遍历一系列值。另外,将正向迭代器递增后,仍然可以对前面的迭代器值解除引用(如果保存了它),并可以得到相同的值。这些特征使得多次通行算法成为可能。
正向迭代器既可以使得能够读取和修改数据,也可以使得只能读取数据:
4.双向迭代器
假设算法需要能够双向遍历容器,情况将如何呢?例如,reverse 函数可以交换第一个元素和最后一个元素、将指向第一个元素的指针加 1、将指向第二个元素的指针减 1,并重复这种处理过程。双向迭代器具有正向迭代器的所有特性,同时支持两种(前缀和后缀)递减运算符。
5.随机访问迭代器
有些算法(如标准排序和二分检索)要求能够直接跳到容器中的任何一个元素,这叫做随机访问,需要随机访问迭代器。随机访问迭代器具有双向迭代器的所有特性,同时添加了支持随机访问的操作(如指针增加运算)和用于对元素进行排序的关系运算符。表 16.3 列出了除双向迭代器的操作外,随机访问迭代器还支持的操作。其中,X 表示随机迭代器类型,T 表示被指向的类型,a 和 b 都是迭代器值,n 为整数,r 为随机迭代器变量或引用。
表 16.3 随机访问迭代器操作
表 达 式 | 描 述 |
---|---|
a + n | 指向 a 所指向的元素后的第 n 个元素 |
n + a | 与 a + n 相同 |
a - n | 指向 a 所指向的元素前的第 n 个元素 |
r += n | 等价于 r = r + n |
r -= n | 等价于 r = r – n |
a[n] | 等价于*(a + n) |
b - a | 结果为这样的 n 值,即 b = a + n |
a < b | 如果 b – a > 0,则为真 |
a > b | 如果 b < a,则为真 |
a >= b | 如果 !( a < b),则为真 |
a <= b | 如果 !( a > b),则为真 |
像 a+n 这样的表达式仅当 a 和 a+n 都位于容器区间(包括超尾)内时才合法,
16.4.3 迭代器层次结构
您可能已经注意到,迭代器类型形成了一个层次结构。正向迭代器具有输入迭代器和输出迭代器的全部功能,同时还有自己的功能;双向迭代器具有正向迭代器的全部功能,同时还有自己的功能;随机访问迭代器具有正向迭代器的全部功能,同时还有自己的功能。表 16.4 总结了主要的迭代器功能。其中,i 为迭代器,n 为整数。
表 16.4 迭代器性能
迭代器功能 | 输 入 | 输 出 | 正 向 | 双 向 | 随 机 访 问 |
---|---|---|---|---|---|
解除引用读取 | 有 | 无 | 有 | 有 | 有 |
解除引用写入 | 无 | 有 | 有 | 有 | 有 |
固定和可重复排序 | 无 | 无 | 有 | 有 | 有 |
++i i++ | 有 | 有 | 有 | 有 | 有 |
− −i i − − | 无 | 无 | 无 | 有 | 有 |
i[n] | 无 | 无 | 无 | 无 | 有 |
i + n | 无 | 无 | 无 | 无 | 有 |
i - n | 无 | 无 | 无 | 无 | 有 |
i + = n | 无 | 无 | 无 | 无 | 有 |
i − = n | 无 | 无 | 无 | 无 | 有 |
根据特定迭代器类型编写的算法可以使用该种迭代器,也可以使用具有所需功能的任何其他迭代器。所以具有随机访问迭代器的容器可以使用为输入迭代器编写的算法。
为何需要这么多迭代器呢?目的是为了在编写算法尽可能使用要求最低的迭代器,并让它适用于容器的最大区间。这样,通过使用级别最低的输入迭代器,find( ) 函数便可用于任何包含可读取值的容器。而 sort( ) 函数由于需要随机访问迭代器,所以只能用于支持这种迭代器的容器。
注意,各种迭代器的类型并不是确定的,而只是一种概念性描述。正如前面指出的,每个容器类都定义了一个类级 typedef 名称——iterator,因此 vector<int>类的迭代器类型为 vector<int> :: interator。然而,该类的文档将指出,矢量迭代器是随机访问迭代器,它允许使用基于任何迭代器类型的算法,因为随机访问迭代器具有所有迭代器的功能。同样,list<int>类的迭代器类型为 list<int> :: iterator。STL 实现了一个双向链表,它使用双向迭代器,因此不能使用基于随机访问迭代器的算法,但可以使用基于要求较低的迭代器的算法。
16.4.4 概念、改进和模型
STL 有若干个用 C++语言无法表达的特性,如迭代器种类。因此,虽然可以设计具有正向迭代器特征的类,但不能让编译器将算法限制为只使用这个类。原因在于,正向迭代器是一系列要求,而不是类型。所设计的迭代器类可以满足这种要求,常规指针也能满足这种要求。STL 算法可以使用任何满足其要求的迭代器实现。STL 文献使用术语概念(concept)来描述一系列的要求。因此,存在输入迭代器概念、正向迭代器概念,等等。顺便说一句,如果所设计的容器类需要迭代器,可考虑 STL,它包含用于标准种类的迭代器模板。
概念可以具有类似继承的关系。例如,双向迭代器继承了正向迭代器的功能。然而,不能将 C++继承机制用于迭代器。例如,可以将正向迭代器实现为一个类,而将双向迭代器实现为一个常规指针。因此,对 C++而言,这种双向迭代器是一种内置类型,不能从类派生而来。然而,从概念上看,它确实能够继承。有些 STL 文献使用术语改进(refinement)来表示这种概念上的继承,因此,双向迭代器是对正向迭代器概念的一种改进。
概念的具体实现被称为模型(model)。因此,指向 int 的常规指针是一个随机访问迭代器模型,也是一个正向迭代器模型,因为它满足该概念的所有要求。
1.将指针用作迭代器
迭代器是广义指针,而指针满足所有的迭代器要求。迭代器是 STL 算法的接口,而指针是迭代器,因此 STL 算法可以使用指针来对基于指针的非 STL 容器进行操作。例如,可将 STL 算法用于数组。假设 Receipts 是一个 double 数组,并要按升序对它进行排序:
STL sort( ) 函数接受指向容器第一个元素的迭代器和指向超尾的迭代器作为参数。&Receipts[0](或 Receipts)是第一个元素的地址,&Receipts[SIZE](或 Receipts + SIZE)是数组最后一个元素后面的元素的地址。因此,下面的函数调用对数组进行排序:
C++确保了表达式 Receipts + n 是被定义的,只要该表达式的结果位于数组中。因此,C++支持将超尾概念用于数组,使得可以将 STL 算法用于常规数组。由于指针是迭代器,而算法是基于迭代器的,这使得可将 STL 算法用于常规数组。同样,可以将 STL 算法用于自己设计的数组形式,只要提供适当的迭代器(可以是指针,也可以是对象)和超尾指示器即可。
copy( )、ostream_iterator 和 istream_iterator
STL 提供了一些预定义迭代器。为了解其中的原因,这里先介绍一些背景知识。有一种算法(名为 copy( ))可以将数据从一个容器复制到另一个容器中。这种算法是以迭代器方式实现的,所以它可以从一种容器到另一种容器进行复制,甚至可以在数组之间复制,因为可以将指向数组的指针用作迭代器。例如,下面的代码将一个数组复制到一个矢量中:
copy( ) 的前两个迭代器参数表示要复制的范围,最后一个迭代器参数表示要将第一个元素复制到什么位置。前两个参数必须是(或最好是)输入迭代器,最后一个参数必须是(或最好是)输出迭代器。Copy( ) 函数将覆盖目标容器中已有的数据,同时目标容器必须足够大,以便能够容纳被复制的元素。因此,不能使用 copy( ) 将数据放到空矢量中——至少,如果不采用本章后面将介绍的技巧,则不能这样做。
现在,假设要将信息复制到显示器上。如果有一个表示输出流的迭代器,则可以使用 copy( )。STL 为这种迭代器提供了 ostream_iterator 模板。用 STL 的话说,该模板是输出迭代器概念的一个模型,它也是一个适配器(adapter)——一个类或函数,可以将一些其他接口转换为 STL 使用的接口。可以通过包含头文件 iterator(以前为 iterator.h)并作下面的声明来创建这种迭代器:
out_iter 迭代器现在是一个接口,让您能够使用 cout 来显示信息。第一个模板参数(这里为 int)指出了被发送给输出流的数据类型;第二个模板参数(这里为 char)指出了输出流使用的字符类型(另一个可能的值是 wchar_t)。构造函数的第一个参数(这里为 cout)指出了要使用的输出流,它也可以是用于文件输出的流(参见第 17 章);最后一个字符串参数是在发送给输出流的每个数据项后显示的分隔符。
可以这样使用迭代器:
对于常规指针,这意味着将 15 赋给指针指向的位置,然后将指针加 1。但对于该 ostream_iterator,这意味着将 15 和由空格组成的字符串发送到 cout 管理的输出流中,并为下一个输出操作做好了准备。可以将 copy( ) 用于迭代器,如下所示:
这意味着将 dice 容器的整个区间复制到输出流中,即显示容器的内容。
也可以不创建命名的迭代器,而直接构建一个匿名迭代器。即可以这样使用适配器:
iterator 头文件还定义了一个 istream_iterator 模板,使 istream 输入可用作迭代器接口。它是一个输入迭代器概念的模型,可以使用两个 istream_iterator 对象来定义 copy( ) 的输入范围:
与 ostream_iterator 相似,istream_iterator 也使用两个模板参数。第一个参数指出要读取的数据类型,第二个参数指出输入流使用的字符类型。使用构造函数参数 cin 意味着使用由 cin 管理的输入流,省略构造函数参数表示输入失败,因此上述代码从输入流中读取,直到文件结尾、类型不匹配或出现其他输入故障为止。
2.其他有用的迭代器
除了 ostream_iterator 和 istream_iterator 之外,头文件 iterator 还提供了其他一些专用的预定义迭代器类型。它们是 reverse_iterator、back_insert_iterator、front_insert_iterator 和 insert_iterator。
我们先来看 reverse -iterator 的功能。对 reverse_iterator 执行递增操作将导致它被递减。为什么不直接对常规迭代器进行递减呢?主要原因是为了简化对已有的函数的使用。假设要显示 dice 容器的内容,正如刚才介绍的,可以使用 copy( ) 和 ostream_iterator 来将内容复制到输出流中:
现在假设要反向打印容器的内容(可能您正在从事时间反演研究)。有很多方法都不管用,但与其在这里耽误工夫,不如来看看能够完成这种任务的方法。vector 类有一个名为 rbegin( ) 的成员函数和一个名为 rend( ) 的成员函数,前者返回一个指向超尾的反向迭代器,后者返回一个指向第一个元素的反向迭代器。因为对迭代器执行递增操作将导致它被递减,所以可以使用下面的语句来反向显示内容:
甚至不必声明反向迭代器。
注意:
rbegin( ) 和 end( ) 返回相同的值(超尾),但类型不同(reverse_iterator 和 iterator)。同样,rend( ) 和 begin( ) 也返回相同的值(指向第一个元素的迭代器),但类型不同。
必须对反向指针做一种特殊补偿。假设 rp 是一个被初始化为 dice.rbegin( ) 的反转指针。那么*rp 是什么呢?因为 rbegin( ) 返回超尾,因此不能对该地址进行解除引用。同样,如果 rend( ) 是第一个元素的位置,则 copy( ) 必须提早一个位置停止,因为区间的结尾处不包括在区间中。
反向指针通过先递减,再解除引用解决了这两个问题。即*rp 将在*rp 的当前值之前对迭代器执行解除引用。也就是说,如果 rp 指向位置 6,则*rp 将是位置 5 的值,依次类推。程序清单 16.10 演示了如何使用 copy( )、istream 迭代器和反向迭代器。
程序清单 16.10 copyit.cpp
程序清单 16.10 中程序的输出如下:
如果可以在显式声明迭代器和使用 STL 函数来处理内部问题(如通过将 rbegin( ) 返回值传递给函数)之间选择,请采用后者。后一种方法要做的工作较少,人为出错的机会也较少。
另外三种迭代器(back_insert_iterator、front_insert_iterator 和 insert_iterator)也将提高 STL 算法的通用性。很多 STL 函数都与 copy( ) 相似,将结果发送到输出迭代器指示的位置。前面说过,下面的语句将值复制到从 dice.begin( ) 开始的位置:
这些值将覆盖 dice 中以前的内容,且该函数假设 dice 有足够的空间,能够容纳这些值,即 copy( ) 不能自动根据发送值调整目标容器的长度。程序清单 16.10 考虑到了这种情况,将 dice 声明为包含 10 个元素。然而,如果预先并不知道 dice 的长度,该如何办呢?或者要将元素添加到 dice 中,而不是覆盖已有的内容,又该如何办呢?
三种插入迭代器通过将复制转换为插入解决了这些问题。插入将添加新的元素,而不会覆盖已有的数据,并使用自动内存分配来确保能够容纳新的信息。back_insert_iterator 将元素插入到容器尾部,而 front_insert_iterator 将元素插入到容器的前端。最后,insert_iterator 将元素插入到 insert_iterator 构造函数的参数指定的位置前面。这三个插入迭代器都是输出容器概念的模型。
这里存在一些限制。back_insert_iterator 只能用于允许在尾部快速插入的容器(快速插入指的是一个时间固定的算法,将在本章后面的“容器概念”一节做进一步讨论),vector 类符合这种要求。front_insert_iterator 只能用于允许在起始位置做时间固定插入的容器类型,vector 类不能满足这种要求,但 queue 满足。insert_iterator 没有这些限制,因此可以用它把信息插入到矢量的前端。然而,front_insert_iterator 对于那些支持它的容器来说,完成任务的速度更快。
提示:
可以用 insert_iterator 将复制数据的算法转换为插入数据的算法。
这些迭代器将容器类型作为模板参数,将实际的容器标识符作为构造函数参数。也就是说,要为名为 dice 的 vector<int>容器创建一个 back_insert_iterator,可以这样做:
必须声明容器类型的原因是,迭代器必须使用合适的容器方法。back_insert_iterator 的构造函数将假设传递给它的类型有一个 push_back( ) 方法。copy( ) 是一个独立的函数,没有重新调整容器大小的权限。但前面的声明让 back_iter 能够使用方法 vector<int>::push_back( ),该方法有这样的权限。
声明 front_insert_iterator 的方式与此相同。对于 insert_iterator 声明,还需一个指示插入位置的构造函数参数:
程序清单 16.11 演示了这两种迭代器的用法,还使用 for_each( ) 而不是 ostream 迭代器进行输出。
程序清单 16.11 inserts.cpp
程序清单 16.11 中程序的输出如下:
第一个 copy( ) 从 s1 中复制 4 个字符串到 words 中。这之所以可行,在某种程度上说是由于 words 被声明为能够存储 4 个字符串,这等于被复制的字符串数目。然后,back_insert_iterator 将 s2 中的字符串插入到 words 数组的末尾,将 words 的长度增加到 6 个元素。最后,insert_iterator 将 s3 中的两个字符串插入到 words 的第一个元素的前面,将 words 的长度增加到 8 个元素。如果程序试图使用 words.end( ) 和 words.begin( ) 作为迭代器,将 s2 和 s3 复制到 words 中,words 将没有空间来存储新数据,程序可能会由于内存违规而异常终止。
如果您被这些迭代器搞晕,则请记住,只要使用就会熟悉它们。另外还请记住,这些预定义迭代器提高了 STL 算法的通用性。因此,copy( ) 不仅可以将信息从一个容器复制到另一个容器,还可以将信息从容器复制到输出流,从输入流复制到容器中。还可以使用 copy( ) 将信息插入到另一个容器中。因此使用同一个函数可以完成很多工作。copy( ) 只是是使用输出迭代器的若干 STL 函数之一,因此这些预定义迭代器也增加了这些函数的功能。
16.4.5 容器种类
STL 具有容器概念和容器类型。概念是具有名称(如容器、序列容器、关联容器等)的通用类别;容器类型是可用于创建具体容器对象的模板。以前的 11 个容器类型分别是 deque、list、queue、priority_queue、stack、vector、map、multimap、set、multiset 和 bitset(本章不讨论 bitset,它是在比特级处理数据的容器);C++11 新增了 forward_list、unordered_map、unordered_multimap、unordered_set 和 unordered_multiset,且不将 bitset 视为容器,而将其视为一种独立的类别。因为概念对类型进行了分类,下面先讨论它们。
1.容器概念
没有与基本容器概念对应的类型,但概念描述了所有容器类都通用的元素。它是一个概念化的抽象基类——说它概念化,是因为容器类并不真正使用继承机制。换句话说,容器概念指定了所有 STL 容器类都必须满足的一系列要求。
容器是存储其他对象的对象。被存储的对象必须是同一种类型的,它们可以是 OOP 意义上的对象,也可以是内置类型值。存储在容器中的数据为容器所有,这意味着当容器过期时,存储在容器中的数据也将过期(然而,如果数据是指针的话,则它指向的数据并不一定过期)。
不能将任何类型的对象存储在容器中,具体地说,类型必须是可复制构造的和可赋值的。基本类型满足这些要求;只要类定义没有将复制构造函数和赋值运算符声明为私有或保护的,则也满足这种要求。C++11 改进了这些概念,添加了术语可复制插入(CopyInsertable)和可移动插入(MoveInsertable),但这里只进行简单的概述。
基本容器不能保证其元素都按特定的顺序存储,也不能保证元素的顺序不变,但对概念进行改进后,则可以增加这样的保证。所有的容器都提供某些特征和操作。表 16.5 对一些通用特征进行了总结。其中,X 表示容器类型,如 vector;T 表示存储在容器中的对象类型;a 和 b 表示类型为 X 的值;r 表示类型为 X&的值;u 表示类型为 X 的标识符(即如果 X 表示 vector<int>,则 u 是一个 vector<int>对象)。
表 16.5 一些基本的容器特征
表 达 式 | 返 回 类 型 | 说 明 | 复 杂 度 |
---|---|---|---|
X :: iterator | 指向 T 的迭代器类型 | 满足正向迭代器要求的任何迭代器 | 编译时间 |
X :: value_type | T | T 的类型 | 编译时间 |
X u; | 创建一个名为 u 的空容器 | 固定 | |
X( ); | 创建一个匿名的空容器 | 固定 | |
X u(a); | 调用复制构造函数后 u == a | 线性 | |
X u = a; | 作用同 X u(a); | 线性 | |
r = a; | X& | 调用赋值运算符后 r == a | 线性 |
(&a)->~X( ) | void | 对容器中每个元素应用析构函数 | 线性 |
a.begin( ) | 迭代器 | 返回指向容器第一个元素的迭代器 | 固定 |
a.end( ) | 迭代器 | 返回超尾值迭代器 | 固定 |
a.size( ) | 无符号整型 | 返回元素个数,等价于 a.end( )– a.begin( ) | 固定 |
a.swap(b) | void | 交换 a 和 b 的内容 | 固定 |
a = = b | 可转换为 bool | 如果 a 和 b 的长度相同,且 a 中每个元素都等于(= =为真)b 中相应的元素,则为真 | 线性 |
a != b | 可转换为 bool | 返回!(a= =b) | 线性 |
表 16.5 中的“复杂度”一列描述了执行操作所需的时间。这个表列出了 3 种可能性,从快到慢依次为:
- 编译时间;
- 固定时间;
- 线性时间。
如果复杂度为编译时间,则操作将在编译时执行,执行时间为 0。固定复杂度意味着操作发生在运行阶段,但独立于对象中的元素数目。线性复杂度意味着时间与元素数目成正比。即如果 a 和 b 都是容器,则 a = = b 具有线性复杂度,因为= =操作必须用于容器中的每个元素。实际上,这是最糟糕的情况。如果两个容器的长度不同,则不需要作任何的单独比较。
固定时间和线性时间复杂度
假设有一个装满大包裹的狭长盒子,包裹一字排开,而盒子只有一端是打开的。假设任务是从打开的一端取出一个包裹,则这将是一项固定时间任务。不管在打开的一端后面有 10 个还是 1000 个包裹,都没有区别。
现在假设任务是取出盒子中没有打开的一端的那个包裹,则这将是线性时间任务。如果盒子里有 10 个包裹,则必须取出 10 个包裹才能拿到封口端的那个包裹;如果有 100 个包裹,则必须取出 100 个包裹。假设是一个不知疲倦的工人来做,每次只能取出 1 个包裹,则需要取 10 次或更多。
现在假设任务是取出任意一个包裹,则可能取出第一个包裹。然而,通常必须移动的包裹数目仍旧与容器中包裹的数目成正比,所以这种任务依然是线性时间复杂度。
如果盒子各边都可打开,而不是狭长的,则这种任务的复杂度将是固定时间的,因为可以直接取出想要的包裹,而不用移动其他的包裹。
时间复杂度概念描述了容器长度对执行时间的影响,而忽略了其他因素。如果超人从一端打开的盒子中取出包裹的速度比普通人快 100 倍,则他完成任务时,复杂度仍然是线性时间的。在这种情况下,他取出封闭盒子中包裹(一端打开,复杂度为线性时间)的速度将比普通人取出开放盒子中包裹(复杂度为固定时间)要快,条件是盒子里没有太多的包裹。
复杂度要求是 STL 特征,虽然实现细节可以隐藏,但性能规格应公开,以便程序员能够知道完成特定操作的计算成本。
2.C++11 新增的容器要求
表 16.6 列出了 C++11 新增的通用容器要求。在这个表中,rv 表示类型为 X 的非常量右值,如函数的返回值。另外,在表 16.5 中,要求 X::iterator 满足正向迭代器的要求,而以前只要求它不是输出迭代器。
表 16.6 C++11 新增的基本容器要求
表 达 式 | 返 回 类 型 | 说 明 | 复 杂 度 |
---|---|---|---|
X u(rv); | 调用移动构造函数后,u 的值与 rv 的原始值相同 | 线性 | |
X u = rv; | 作用同 X u(rv); | ||
a = rv; | X& | 调用移动赋值运算符后,u 的值与 rv 的原始值相同 | 线性 |
a.cbegin( ) | const_iterator | 返回指向容器第一个元素的 const 迭代器 | 固定 |
a.cend( ) | const_iterator | 返回超尾值 const 迭代器 | 固定 |
复制构造和复制赋值以及移动构造和移动赋值之间的差别在于,复制操作保留源对象,而移动操作可修改源对象,还可能转让所有权,而不做任何复制。如果源对象是临时的,移动操作的效率将高于常规复制。第 18 章将更详细地介绍移动语义。
3.序列
可以通过添加要求来改进基本的容器概念。序列(sequence)是一种重要的改进,因为 7 种 STL 容器类型(deque、C++11 新增的 forward_list、list、queue、priority_queue、stack 和 vector)都是序列(本书前面说过,队列让您能够在队尾添加元素,在队首删除元素。deque 表示的双端队列允许在两端添加和删除元素)。序列概念增加了迭代器至少是正向迭代器这样的要求,这保证了元素将按特定顺序排列,不会在两次迭代之间发生变化。array 也被归类到序列容器,虽然它并不满足序列的所有要求。
序列还要求其元素按严格的线性顺序排列,即存在第一个元素、最后一个元素,除第一个元素和最后一个元素外,每个元素前后都分别有一个元素。数组和链表都是序列,但分支结构(其中每个节点都指向两个子节点)不是。
因为序列中的元素具有确定的顺序,因此可以执行诸如将值插入到特定位置、删除特定区间等操作。表 16.7 列出了这些操作以及序列必须完成的其他操作。该表格使用的表示法与表 16.5 相同,此外,t 表示类型为 T(存储在容器中的值的类型)的值,n 表示整数,p、q、i 和 j 表示迭代器。
表 16.7 序列的要求
表 达 式 | 返 回 类 型 | 说 明 |
---|---|---|
X a(n, t); | 声明一个名为 a 的由 n 个 t 值组成的序列 | |
X(n, t) | 创建一个由 n 个 t 值组成的匿名序列 | |
X a(i, j) | 声明一个名为 a 的序列,并将其初始化为区间[i,j) 的内容 | |
X(i, j) | 创建一个匿名序列,并将其初始化为区间[i,j) 的内容 | |
a. insert(p, t) | 迭代器 | 将 t 插入到 p 的前面 |
a.insert(p, n, t) | void | 将 n 个 t 插入到 p 的前面 |
a.insert(p, i, j) | void | 将区间[i,j) 中的元素插入到 p 的前面 |
a.erase(p) | 迭代器 | 删除 p 指向的元素 |
a.erase(p, q) | 迭代器 | 删除区间[p,q) 中的元素 |
a.clear( ) | void | 等价于 erase(begin( ), end( )) |
因为模板类 deque、list、queue、priority_queue、stack 和 vector 都是序列概念的模型,所以它们都支持表 16.7 所示的运算符。除此之外,这 6 个模型中的一些还可使用其他操作。在允许的情况下,它们的复杂度为固定时间。表 16.8 列出了其他操作。
表 16.8 序列的可选要求
表 达 式 | 返 回 类 型 | 含 义 | 容 器 |
---|---|---|---|
a.front( ) | T& | *a.begin( ) | vector、list、deque |
a.back( ) | T& | *- -a.end( ) | vector、list、deque |
a.push_front(t) | void | a.insert(a.begin( ), t) | list、deque |
a.push_back(t) | void | a.insert(a.end( ), t) | vector、list、deque |
a.pop_front(t) | void | a.erase(a.begin( )) | list、deque |
a.pop_back(t) | void | a.erase(- -a.end( )) | vector、list、deque |
a[n] | T& | *(a.begin( )+ n) | vector、deque |
a.at(t) | T& | *(a.begin( )+ n) | vector、deque |
表 16.8 有些需要说明的地方。首先,a[n]和 a.at(n) 都返回一个指向容器中第 n 个元素(从 0 开始编号)的引用。它们之间的差别在于,如果 n 落在容器的有效区间外,则 a.at(n) 将执行边界检查,并引发 out_of_range 异常。其次,可能有人会问,为何为 list 和 deque 定义了 push_front( ),而没有为 vector 定义?假设要将一个新值插入到包含 100 个元素的矢量的最前面。要腾出空间,必须将第 99 个元素移到位置 100,然后把第 98 个元素移动到位置 99,依此类推。这种操作的复杂度为线性时间,因为移动 100 个元素所需的时间为移动单个元素的 100 倍。但表 16.8 的操作被假设为仅当其复杂度为固定时间时才被实现。链表和双端队列的设计允许将元素添加到前端,而不用移动其他元素,所以它们可以以固定时间的复杂度来实现 push_front( )。图 16.4 说明了 push_front( ) 和 push_back( )。
图 16.4 push_front( ) 和 push_back( )
下面详细介绍这 7 种序列容器类型。
(1)vector
前面介绍了多个使用 vector 模板的例子,该模板是在 vector 头文件中声明的。简单地说,vector 是数组的一种类表示,它提供了自动内存管理功能,可以动态地改变 vector 对象的长度,并随着元素的添加和删除而增大和缩小。它提供了对元素的随机访问。在尾部添加和删除元素的时间是固定的,但在头部或中间插入和删除元素的复杂度为线性时间。
除序列外,vector 还是可反转容器(reversible container)概念的模型。这增加了两个类方法:rbegin( ) 和 rend( ),前者返回一个指向反转序列的第一个元素的迭代器,后者返回反转序列的超尾迭代器。因此,如果 dice 是一个 vector<int>容器,而 Show(int) 是显示一个整数的函数,则下面的代码将首先正向显示 dice 的内容,然后反向显示:
这两种方法返回的迭代器都是类级类型 reverse_iterator。对这样的迭代器进行递增,将导致它反向遍历可反转容器。
vector 模板类是最简单的序列类型,除非其他类型的特殊优点能够更好地满足程序的要求,否则应默认使用这种类型。
(2)deque
deque 模板类(在 deque 头文件中声明)表示双端队列(double-ended queue),通常被简称为 deque。在 STL 中,其实现类似于 vector 容器,支持随机访问。主要区别在于,从 deque 对象的开始位置插入和删除元素的时间是固定的,而不像 vector 中那样是线性时间的。所以,如果多数操作发生在序列的起始和结尾处,则应考虑使用 deque 数据结构。
为实现在 deque 两端执行插入和删除操作的时间为固定的这一目的,deque 对象的设计比 vector 对象更为复杂。因此,尽管二者都提供对元素的随机访问和在序列中部执行线性时间的插入和删除操作,但 vector 容器执行这些操作时速度要快些。
(3)list
list 模板类(在 list 头文件中声明)表示双向链表。除了第一个和最后一个元素外,每个元素都与前后的元素相链接,这意味着可以双向遍历链表。list 和 vector 之间关键的区别在于,list 在链表中任一位置进行插入和删除的时间都是固定的(vector 模板提供了除结尾处外的线性时间的插入和删除,在结尾处,它提供了固定时间的插入和删除)。因此,vector 强调的是通过随机访问进行快速访问,而 list 强调的是元素的快速插入和删除。
与 vector 相似,list 也是可反转容器。与 vector 不同的是,list 不支持数组表示法和随机访问。与矢量迭代器不同,从容器中插入或删除元素之后,链表迭代器指向元素将不变。我们来解释一下这句话。例如,假设有一个指向 vector 容器第 5 个元素的迭代器,并在容器的起始处插入一个元素。此时,必须移动其他所有元素,以便腾出位置,因此插入后,第 5 个元素包含的值将是以前第 4 个元素的值。因此,迭代器指向的位置不变,但数据不同。然后,在链表中插入新元素并不会移动已有的元素,而只是修改链接信息。指向某个元素的迭代器仍然指向该元素,但它链接的元素可能与以前不同。
除序列和可反转容器的函数外,list 模板类还包含了链表专用的成员函数。表 16.9 列出了其中一些(有关 STL 方法和函数的完整列表,请参见附录 G)。通常不必担心 Alloc 模板参数,因为它有默认值。
表 16.9 list 成员函数
函 数 | 说 明 |
---|---|
void merge(list<T, Alloc>& x) | 将链表 x 与调用链表合并。两个链表必须已经排序。合并后的经过排序的链表保存在调用链表中,x 为空。这个函数的复杂度为线性时间 |
void remove(const T & val) | 从链表中删除 val 的所有实例。这个函数的复杂度为线性时间 |
void sort( ) | 使用<运算符对链表进行排序;N 个元素的复杂度为 NlogN |
void splice(iterator pos, list<T, Alloc>x) | 将链表 x 的内容插入到 pos 的前面,x 将为空。这个函数的的复杂度为固定时间 |
void unique( ) | 将连续的相同元素压缩为单个元素。这个函数的复杂度为线性时间 |
程序清单 16.12 演示了这些方法和 insert( ) 方法(所有模拟序列的 STL 类都有这种方法)的用法。
程序清单 16.12 list.cpp
下面是程序清单 16.12 中程序的输出:
(4)程序说明
程序清单 16.12 中程序使用了 for_each() 算法和 outint( ) 函数来显示列表。在 C++11 中,也可使用基于范围的 for 循环:
insert( ) 和 splice( ) 之间的主要区别在于:insert( ) 将原始区间的副本插入到目标地址,而 splice( ) 则将原始区间移到目标地址。因此,在 one 的内容与 three 合并后,one 为空。(splice( ) 方法还有其他原型,用于移动单个元素和元素区间)。splice( ) 方法执行后,迭代器仍有效。也就是说,如果将迭代器设置为指向 one 中的元素,则在 splice( ) 将它重新定位到元素 three 后,该迭代器仍然指向相同的元素。
注意,unique( ) 只能将相邻的相同值压缩为单个值。程序执行 three.unique( ) 后,three 中仍包含不相邻的两个 4 和两个 6。但应用 sort( ) 后再应用 unique( ) 时,每个值将只占一个位置。
还有非成员 sort( ) 函数(程序清单 16.9),但它需要随机访问迭代器。因为快速插入的代价是放弃随机访问功能,所以不能将非成员函数 sort( ) 用于链表。因此,这个类中包括了一个只能在类中使用的成员版本。
(5)list 工具箱
list 方法组成了一个方便的工具箱。例如,假设有两个邮件列表要整理,则可以对每个列表进行排序,合并它们,然后使用 unique( ) 来删除重复的元素。
sort( )、merge( ) 和 unique( ) 方法还各自拥有接受另一个参数的版本,该参数用于指定用来比较元素的函数。同样,remove( ) 方法也有一个接受另一个参数的版本,该参数用于指定用来确定是否删除元素的函数。这些参数都是谓词函数,将稍后介绍。
(6)forward_list(C++11)
C++11 新增了容器类 forward_list,它实现了单链表。在这种链表中,每个节点都只链接到下一个节点,而没有链接到前一个节点。因此 forward_list 只需要正向迭代器,而不需要双向迭代器。因此,不同于 vector 和 list,forward_list 是不可反转的容器。相比于 list,forward_list 更简单、更紧凑,但功能也更少。
(7)queue
queue 模板类(在头文件 queue(以前为 queue.h)中声明)是一个适配器类。由前所述,ostream_iterator 模板就是一个适配器,让输出流能够使用迭代器接口。同样,queue 模板让底层类(默认为 deque)展示典型的队列接口。
queue 模板的限制比 deque 更多。它不仅不允许随机访问队列元素,甚至不允许遍历队列。它把使用限制在定义队列的基本操作上,可以将元素添加到队尾、从队首删除元素、查看队首和队尾的值、检查元素数目和测试队列是否为空。表 16.10 列出了这些操作。
表 16.10 queue 的操作
方 法 | 说 明 |
---|---|
bool empty( )const | 如果队列为空,则返回 true;否则返回 false |
size_type size( )const | 返回队列中元素的数目 |
T& front( ) | 返回指向队首元素的引用 |
T& back( ) | 返回指向队尾元素的引用 |
void push(const T& x) | 在队尾插入 x |
void pop( ) | 删除队首元素 |
注意,pop( ) 是一个删除数据的方法,而不是检索数据的方法。如果要使用队列中的值,应首先使用 front( ) 来检索这个值,然后使用 pop( ) 将它从队列中删除。
(8)priority_queue
priority_queue 模板类(在 queue 头文件中声明)是另一个适配器类,它支持的操作与 queue 相同。两者之间的主要区别在于,在 priority_queue 中,最大的元素被移到队首(生活不总是公平的,队列也一样)。内部区别在于,默认的底层类是 vector。可以修改用于确定哪个元素放到队首的比较方式,方法是提供一个可选的构造函数参数:
greater< >( ) 函数是一个预定义的函数对象,本章稍后将讨论它。
(9)stack
与 queue 相似,stack(在头文件 stack——以前为 stack.h——中声明)也是一个适配器类,它给底层类(默认情况下为 vector)提供了典型的栈接口。
stack 模板的限制比 vector 更多。它不仅不允许随机访问栈元素,甚至不允许遍历栈。它把使用限制在定义栈的基本操作上,即可以将压入推到栈顶、从栈顶弹出元素、查看栈顶的值、检查元素数目和测试栈是否为空。表 16.11 列出了这些操作。
表 16.11 stack 的操作
方 法 | 说 明 |
---|---|
bool empty( )const | 如果栈为空,则返回 true;否则返回 false |
size_type size( )const | 返回栈中的元素数目 |
T& top( ) | 返回指向栈顶元素的引用 |
void push(const T& x) | 在栈顶部插入 x |
void pop( ) | 删除栈顶元素 |
与 queue 相似,如果要使用栈中的值,必须首先使用 top( ) 来检索这个值,然后使用 pop( ) 将它从栈中删除。
(10)array(C++11)
第 4 章介绍过,模板类 array 是否头文件 array 中定义的,它并非 STL 容器,因为其长度是固定的。因此,array 没有定义调整容器大小的操作,如 push_back( ) 和 insert( ),但定义了对它来说有意义的成员函数,如 operator [] () 和 at( )。可将很多标准 STL 算法用于 array 对象,如 copy( ) 和 for_each( )。
16.4.4 关联容器
关联容器(associative container)是对容器概念的另一个改进。关联容器将值与键关联在一起,并使用键来查找值。例如,值可以是表示雇员信息(如姓名、地址、办公室号码、家庭电话和工作电话、健康计划等)的结构,而键可以是唯一的员工编号。为获取雇员信息,程序将使用键查找雇员结构。前面说过,对于容器 X,表达式 X::value_type 通常指出了存储在容器中的值类型。对于关联容器来说,表达式 X::key_type 指出了键的类型。
关联容器的优点在于,它提供了对元素的快速访问。与序列相似,关联容器也允许插入新元素,但不能指定元素的插入位置。原因是关联容器通常有用于确定数据放置位置的算法,以便能够快速检索信息。
关联容器通常是使用某种树实现的。树是一种数据结构,其根节点链接到一个或两个节点,而这些节点又链接到一个或两个节点,从而形成分支结构。像链表一样,节点使得添加或删除数据项比较简单;但相对于链表,树的查找速度更快。
STL 提供了 4 种关联容器:set、multiset、map 和 multimap。前两种是在头文件 set(以前分别为 set.h 和 multiset.h)中定义的,而后两种是在头文件 map(以前分别为 map.h 和 multimap.h)中定义的。
最简单的关联容器是 set,其值类型与键相同,键是唯一的,这意味着集合中不会有多个相同的键。确实,对于 set 来说,值就是键。multiset 类似于 set,只是可能有多个值的键相同。例如,如果键和值的类型为 int,则 multiset 对象包含的内容可以是 1、2、2、2、3、5、7、7。
在 map 中,值与键的类型不同,键是唯一的,每个键只对应一个值。multimap 与 map 相似,只是一个键可以与多个值相关联。
有关这些类型的信息很多,无法在本章全部列出(但附录 G 列出了方法),这里只介绍一个使用 set 的简单例子和一个使用 multimap 的简单例子。
1.set 示例
STL set 模拟了多个概念,它是关联集合,可反转,可排序,且键是唯一的,所以不能存储多个相同的值。与 vector 和 list 相似,set 也使用模板参数来指定要存储的值类型:
第二个模板参数是可选的,可用于指示用来对键进行排序的比较函数或对象。默认情况下,将使用模板 less< >(稍后将讨论)。老式 C++实现可能没有提供默认值,因此必须显式指定模板参数:
请看下面的代码:
与其他容器相似,set 也有一个将迭代器区间作为参数的构造函数(参见表 16.6)。这提供了一种将集合初始化为数组内容的简单方法。请记住,区间的最后一个元素是超尾,s1 + N 指向数组 s1 尾部后面的一个位置。上述代码片段的输出表明,键是唯一的(字符串“for”在数组中出现了 2 次,但在集合中只出现 1 次),且集合被排序:
数学为集合定义了一些标准操作,例如,并集包含两个集合合并后的内容。如果两个集合包含相同的值,则这个值将在并集中只出现一次,这是因为键是唯一的。交集包含两个集合都有的元素。两个集合的差是第一个集合减去两个集合都有的元素。
STL 提供了支持这些操作的算法。它们是通用函数,而不是方法,因此并非只能用于 set 对象。然而,所有 set 对象都自动满足使用这些算法的先决条件,即容器是经过排序的。set_union( ) 函数接受 5 个迭代器参数。前两个迭代器定义了第一个集合的区间,接下来的两个定义了第二个集合区间,最后一个迭代器是输出迭代器,指出将结果集合复制到什么位置。例如,要显示集合 A 和 B 的并集,可以这样做:
假设要将结果放到集合 C 中,而不是显示它,则最后一个参数应是一个指向 C 的迭代器。显而易见的选择是 C.begin( ),但它不管用,原因有两个。首先,关联集合将键看作常量,所以 C.begin( ) 返回的迭代器是常量迭代器,不能用作输出迭代器。不直接使用 C.begin( ) 的第二个原因是,与 copy( ) 相似,set_union( ) 将覆盖容器中已有的数据,并要求容器有足够的空间容纳新信息。C 是空的,不能满足这种要求。但前面讨论的模板 insert_iterator 可以解决这两个问题。前面说过,它可以将复制转换为插入。另外,它还模拟了输出迭代器概念,可以用它将信息写入容器。因此,可以创建一个匿名 insert_iterator,将信息复制给 C。前面说过,其构造函数将容器名称和迭代器作为参数:
函数 set_intersection( ) 和 set_difference( ) 分别查找交集和获得两个集合的差,它们的接口与 set_union( ) 相同。
两个有用的 set 方法是 lower_bound( ) 和 upper_bound( )。方法 lower_bound( ) 将键作为参数并返回一个迭代器,该迭代器指向集合中第一个不小于键参数的成员。同样,方法 upper_bound( ) 将键作为参数,并返回一个迭代器,该迭代器指向集合中第一个大于键参数的成员。例如,如果有一个字符串集合,则可以用这些方法获得一个这样的区间,即包含集合中从“b”到“f”的所有字符串。
因为排序决定了插入的位置,所以这种类包含只指定要插入的信息,而不指定位置的插入方法。例如,如果 A 和 B 是字符串集合,则可以这样做:
程序清单 16.13 演示了集合的这些用途。
程序清单 16.13 setops.cpp
下面是程序清单 16.13 中程序的输出:
和本章中大多数示例一样,程序清单 16.13 在处理名称空间 std 时采取了偷懒的方式:
这样做旨在简化表示方式。这些示例使用了名称空间 std 中非常多的元素,如果使用 using 声明或作用域运算符,代码将变得混乱:
2.multimap 示例
与 set 相似,multimap 也是可反转的、经过排序的关联容器,但键和值的类型不同,且同一个键可能与多个值相关联。
基本的 multimap 声明使用模板参数指定键的类型和存储的值类型。例如,下面的声明创建一个 multimap 对象,其中键类型为 int,存储的值类型为 string:
第 3 个模板参数是可选的,指出用于对键进行排序的比较函数或对象。在默认情况下,将使用模板 less< >(稍后将讨论),该模板将键类型作为参数。老式 C++实现可能要求显式指定该模板参数。
为将信息结合在一起,实际的值类型将键类型和数据类型结合为一对。为此,STL 使用模板类 pair<class T, class U>将这两种值存储到一个对象中。如果 keytype 是键类型,而 datatype 是存储的数据类型,则值类型为 pair<const keytype, datatype>。例如,前面声明的 codes 对象的值类型为 pair<const int, string>。
例如,假设要用区号作为键来存储城市名(这恰好与 codes 声明一致,它将键类型声明为 int,数据类型声明为 string),则一种方法是创建一个 pair,再将它插入:
也可使用一条语句创建匿名 pair 对象并将它插入:
因为数据项是按键排序的,所以不需要指出插入位置。
对于 pair 对象,可以使用 first 和 second 成员来访问其两个部分了:
如何获得有关 multimap 对象的信息呢?成员函数 count( ) 接受键作为参数,并返回具有该键的元素数目。成员函数 lower_bound( ) 和 upper_bound( ) 将键作为参数,且工作原理与处理 set 时相同。成员函数 equal_range( ) 用键作为参数,且返回两个迭代器,它们表示的区间与该键匹配。为返回两个值,该方法将它们封装在一个 pair 对象中,这里 pair 的两个模板参数都是迭代器。例如,下面的代码打印 codes 对象中区号为 718 的所有城市:
在声明中可使用 C++11 自动类型推断功能,这样代码将简化为如下所示:
程序清单 16.14 演示了上述大部分技术,它也使用 typedef 来简化代码:
程序清单 16.14 multimap.cpp
下面是程序清单 16.14 中程序的输出:
16.4.5 无序关联容器(C++11)
无序关联容器是对容器概念的另一种改进。与关联容器一样,无序关联容器也将值与键关联起来,并使用键来查找值。但底层的差别在于,关联容器是基于树结构的,而无序关联容器是基于数据结构哈希表的,这旨在提高添加和删除元素的速度以及提高查找算法的效率。有 4 种无序关联容器,它们是 unordered_set、unordered_multiset、unordered_map 和 unordered_multimap,将在附录 G 更详细地介绍。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论