12 容器

12.1 引言

大多数计算任务都会涉及创建值的集合,然后对该集合执行某种操作。一个简单的例子是读取字符串并保存到string中,然后打印这个string对象中的字符串。如果一个类的主要用途就是保存对象,那么这个类就被称为容器(container)。为特定任务选择合适的容器,及其上有用的基本操作,是构建任何程序的重要步骤。

这里以一个保存姓名和电话号码的简单程序为例,说明标准库容器的用法。这就是那种对不同背景的人都显得简单而明显的程序。用户自定义的Entry类,用于表示一个简单的电话簿条目。例如:

这里有意忽略了很多现实世界中的复杂因素,例如一个真正意义上的电话号码,未必能用一个“unsigned long long”类型的整数表示。

12.2 vector

最有用的标准库容器当属vector(动态数组)。vector就是一个特定类型元素的序列,其中的元素存储在一段连续的内存空间中。典型的vector实现会包含一个句柄,保存指向首元素的指针(elem)。此外,还包含一个指向尾元素下一个位置的指针(space),和一个指向所分配内存空间后第一个字节的指针(last)。另一些实现可能会用偏移量,等价地替代这些指针。除了这些指针(或者偏移量),vector还包含一个分配器(alloc),用于为容器中的元素分配内存空间。默认的分配器使用new和delete分配和释放内存。使用稍微先进一点的实现技术,就可以避免在vector对象中存储简单分配器的任何数据。

可以用一组值来初始化vector,当然,值的类型必须与vector元素的类型一致。例如:

可以通过下标访问vector中的元素。例如:

下标从0开始。vector的成员函数size,返回容器中元素的个数。

vector中的元素构成了一个范围,因此也可以使用基于范围的for循环,遍历其中的元素。例如:

创建vector容器时,可为其指定初始大小(元素数)。例如:

可以在一对圆括号中显式指定vector容器的初始大小,即初始元素数,如“vector<double*> v5(5)”。默认情况下,初始元素取默认值,如整型元素初始化为0,指针型元素初始化为nullptr。如果不想用默认值初始化容器中的元素,可以通过构造函数的第二个参数显式指定元素初值,如“vector<double> v4(5, 1.23)”。

vector容器的大小并非一成不变,随着程序的运行,新元素不断加入,其大小也会随之改变。vector最常用的一个操作就是push_back,它在元素序列的末尾追加一个新元素,从而使其大小加一。例如:

以上代码从标准输入读取Entry,并保存到phoneBook中,直至遇到文件尾或输入格式错误。

标准库的vector经过精心设计,即便不断调用push_back函数扩展vector中的元素序列,也不会导致性能下降。为了说明这一点,下面给出vector容器的形式化实现:

标准库的vector类模板有capacity、reserve和push_back等几个成员函数。vector容器的使用者和其它成员函数都能通过reserve函数扩展可用空间,以容纳更多的元素。在此过程中可能需要重新分配内存,并将现有元素拷贝到新分配的内存中。当reserve函数将所有元素移至更大的可用空间后,所有之前取得的元素指针都将因此而失效,并不能再继续使用。

在push_back函数中,对可用空间的扩展采用启发式的倍增策略,如“reserve(size() ? size() * 2 : 8)”,以此降低执行内存分配和元素迁移的频率。也正是因为如此,vector容器的使用者通常很少在外部手动调用reserve函数,除非需要使用元素指针,为避免其因元素迁移而失效,而在最开始的时候通过reserve函数,一次性预留出足够的内存空间。

vector支持拷贝构造、拷贝赋值、转移构造和转移赋值。例如:

这一切都是通过相应的构造函数和赋值操作符函数实现的。vector容器的拷贝,会连同其中的元素一起拷贝。当容器中包含很多元素时,这样一个看起来无害的初始化或赋值操作可能会非常耗时。因此,当拷贝并非必要时,应尽可能在容器中保存引用或指针。当然,以转移代替拷贝也是一个不错的选择。

标准库的vector非常灵活且高效,应当将其作为默认容器。换言之,除非有充分的理由使用其它容器,否则都应该使用vector。如果想以效率作为选择其它容器的理由,那么通过测试证明这一点是有必要的,毕竟有关容器性能方面的任何直觉,通常都不怎么靠得住。

12.2.1 元素

与所有标准库容器类似,vector是某种(T)类型元素的容器,即vector<T>。几乎任何类型都可以作为vector中元素的类型,包括但不限于内置的数值类型(char、int、double、······)、用户自定义类型(string、Entry、list<int>、Matrix<double, 2>、······)、指针类型(const char*、Shape*、double*、······),等等。当向一个vector容器存入元素时,它的值被拷贝到容器中。例如将整数7存入vector,vector中真的会有一个值为7的整数元素,而不是指向某个包含7的对象的引用或指针。这样的策略促成了精巧、紧凑、访问快速的容器。对于那些十分在意内存大小和运行时性能的开发者而言,这一点非常关键。

容器
拷贝
元素
元素
元素
元素
对象

如果在一个类的层次结构中存在依赖于虚函数的多态性,那么就不应该在容器中保存对象,而应该保存对象的指针,或智能指针。例如:

12.2.2 范围检查

vector的下标操作符([])并不进行范围检查。例如:

以上代码会导致什么样的输出,完全无法确定。类似这样的下标越界问题,并非程序设计者的本意,却又非常难以避免。vector的at成员函数,会在其表示下标的参数发生越界时,抛出一个类型为out_of_range的异常。例如:

发生下标越界的代码会抛出一个异常,然后捕获该异常。如果用户不捕获该异常,程序会以良好定义的方式被终止运行,而不是继续执行或以未定义的方式失败。为了避免因未捕获异常而导致其它问题,可以将main函数的整个函数体放在一个try块中,并用“catch (...)”捕获所有未被显式捕获的异常。例如:

这段代码提供了默认的异常处理分支(catch (...) { ... }),因此,当某个异常未被其它catch成功捕获时,就会进入该分支,并在cerr上打印错误信息:unknown exception thrown。

vector的下标操作符([])为什么不做范围检查?有很多性能关键型应用程序都在使用vector,对所有下标操作都进行范围检查,意味着需要额外付出大约10%的性能开销。这么大的开销足以引发人们心中的恐惧,并导致他们最终放弃标准容器,而转投相对不安全的内置数组。现在,可以在调试阶段使用带范围检查的at函数,确认无误后,再在正式发布的版本中使用不带范围检查的下标操作符,以优化性能。

基于范围的for循环,通过隐式访问范围内的所有元素,零开销地避免了下标越界风险。只要参数有效,标准库算法都用类似的方法,保证在没有范围检查的前提下,不发生下标越界错误。

某些编译器还提供了一个编译选项,可以人为指定vector的下标操作符带不带范围检查。

12.3 list

标准库提供了名为list的双向线性链表容器:

第5个节点
第4个节点
第3个节点
第2个节点
第1个节点
元素
链接
元素
链接
元素
链接
元素
链接
元素
链接

如果希望在一个序列中插入、删除元素时无须移动其它元素,则可以使用list容器。对电话簿而言,插入、删除操作可能会很频繁,因此list容器很适合于简单电话簿的实现。例如:

象使用动态数组那样使用链表是不合适的。不能通过下标访问链表中的特定元素,但是可以在链表中搜索某个与给定条件匹配的元素。为了完成这样的操作,只需牢记list也是一个由其中的元素组成的序列即可。例如:

getNumberByName函数从phoneBook链表的第一个元素开始搜索,直到找到姓名与name参数匹配的条目,返回该条目中的电话号码,或到达链表末尾,查找失败,返回0。

人们有时需要在list中定位一个元素,并在后续操作中删除这个元素,或该元素前面插入一个新元素。为此,可以使用迭代器。list的迭代器就象一个指向其中元素的指针,可用于对list中的每个元素做迭代遍历,这也正是“迭代器”一词的由来。每个标准库容器都提供begin和end两个成员函数,分别返回一个指向首元素的起始迭代器,和一个指向尾元素的下一个位置的终止迭代器。getNumberByName函数的迭代器版本如下,虽然不够优雅:

使用范围for循环的版本更简练,也更不容易出错。事实上,编译器对范围for循环的实现,基本上与迭代器版本一致。对于迭代器p,“*p”表示它所指向的元素,“++p”令它指向下一个元素,“--p”令它指向前一个元素。如果p所指向的元素是一个类类型的对象,且其包含一个名为m的成员,访问该成员可以使用“p->m”或者“(*p).m”。

向list中插入元素,以及从list中删除元素,都非常简单。例如:

对list而言,“insert(p,elem)”将一个新元素elem插到p所指向的元素之前,并返回指向新插入元素的迭代器,“erase(p)”删除并销毁p所指向的元素,同时返回指向被删除元素下一个位置的迭代器。在这两个操作中,p都可以是起始或者终止迭代器:

以上这些例子里的list,都可以被等价地替换为vector。不可思议的是(除非充分了解机器的体系架构),vector的性能经常会优于list。一般而言,除非有充分的理由使用list,否则vector总是被优先选择的。vector无论是遍历(如find、count等),还是排序和搜索(如sort、binary_search等),性能都优于list。

12.4 forward_list

标准库提供了名为forward_list的单向线性链表容器:

第5个节点
第4个节点
第3个节点
第2个节点
第1个节点
元素
链接
元素
链接
元素
链接
元素
链接
元素
链接

forward_list和list的区别仅在于,它只有一个迭代方向,即forward_list的迭代器只能“++”不能“--”,这是由其单向链表的结构特点决定的。forward_list的价值在于它占用更少的内存空间,因为每个节点只需要保存一个指向下一个节点的指针。一个空forward_list的大小仅与一个指针相当。forward_list甚至不保存元素的个数。每次获取元素数,都要在遍历中统计。如果这个开销是无法容忍的,那么根本就不应该使用forward_list。

C++11:单向链表(forward_list)

12.5 map

在一个由键值对(比如姓名和电话号码)组成的序列中,根据键(比如姓名)顺序查找所对应的值(比如电话号码),是一件令人深感厌烦的工作。而且,除非这个序列足够短,顺序搜索的效率也会低到令人发指的地步。标准库提供了名为map的平衡二分搜索树(通常是红黑树)容器:

第1.1个节点
第1.2个节点
第1个节点
第1.1.1个节点
键值
链接
第1.1.2个节点
键值
链接
键值
链接
第1.2.1个节点
键值
链接
第1.2.2个节点
键值
链接
键值
链接
键值
链接

map亦称关联数组或者字典。

标准库的map是个键值对的容器,经过特殊优化可提高其搜索性能。可以向初始化vector或者list那样初始化map。例如:

map也支持下标操作,不过下标值并非元素在序列中的序号,而应该是map的第一个类型参数型对象,作为键。下标操作的结果是map的第二个类型参数型对象,即与该键相对应的值。例如:

map下标操作的本质就是在平衡二分搜索树中执行一次搜索。如果map中存在与给定键匹配的键值对,则返回该键所对应的值,若没有该键,则插入一个新元素,键取给定的键,值取默认值。例如:

在本例中,unsigned long long类型整数的默认值是0,恰好用来表示无效的电话号码。如果不想将无效的电话号码添加到电话簿中,可以使用map的find成员函数,代替其下标操作。例如:

12.6 unordered_map

搜索map的平均时间复杂度是O(log(n))n是map中的元素数。通常情况下,这样的性能表现已经相当完美了。假设map中包含100万个元素,最多只要执行20次比较和间接寻址操作,即可得到与查找目标匹配的元素。但在某些情况下,人们还希望能做得更好,那就要用哈希查找,取代顺序比较(如“<”)。标准库中的哈希容器也被称作无序容器,因为其中的元素不需要提供对顺序比较的支持。

哈希表
哈希值
哈希值
哈希值
键值
键值
键值
键值

例如:

与map的情况的类似,unordered_map也支持下标形式的搜索、插入操作。例如:

和find成员函数。例如:

标准库为所有内置类型和string等自定义类型,提供了默认的哈希函数。必要时,程序员可以自己定义哈希函数,特别是当元素中的键是某种自定义类型的对象时。哈希函数通常以函数对象的形式提供。例如针对自定义类型:

的哈希函数:

在构造容器时指定哈希函数:

设计优秀的哈希函数是一门艺术,这需要对所处理的数据有深刻的认识。相比之下,通过按位异或(^)组合现有哈希函数的计算结果,常常更加简单而有效。当然,要注意确保所有的原始信息都参与了哈希值的运算,以提高不同元素的区分度,减少发生哈希冲突的机会。

还可以通过特例化标准库哈希函数模板的形式,提供针对自定义类型的自定义哈希函数。例如:

这样在构造容器时就可以避免显式提供哈希函数参数。例如:

注意map和unordered_map的区别:

在给定了优秀哈希函数的前提下,搜索unordered_map要比搜索map快得多,尤其是对包含海量元素的容器而言。但是,在最坏的情况下,比如配备了糟糕的哈希函数,unordered_map的性能可能远比map要差。

C++11:哈希容器(unordered_map、unordered_multimap、unordered_set和unordered_multiset)

12.7 分配器

默认情况下,标准库容器所持有的动态内存,都是通过new和delete操作符分配和释放的。标准库容器利用这些内存保存任意大小的对象,并负责管理这些对象的生命周期。但是,默认内存管理机制的时间和空间开销,未必总能尽如人意。为此,在某些特定情形下,标准库容器允许用户安装自定义的分配器,针对元素对象和使用场景的特殊性,提供更加高效且安全的动态内存管理策略。这种机制常被用于解决那些在编程实践中广泛存在的,诸如性能问题(如内存池化)、安全问题(如内存泄漏)、竞态问题(如内存共享),以及异构问题(如不同类型的对象适合使用不同的内存区域)等等。

假定有一个重要且长时间运行的系统,一到多个生产者线程负责向事件队列中压入事件对象,同时一到多个消费者线程从该队列中弹出事件并使用,每个事件的最后一个使用者会隐式地释放该事件所占用的内存空间:

从逻辑上看,上面的代码会运行得很好,条理清晰,健壮且易维护。然而不幸的是,这会导致大量的内存碎片。当16个生产者和4个消费者处理了10万个事件后,6GB以上的内存将被碎片吞噬。

内存碎片问题的传统解决方案是使用内存池。内存池负责内存资源的实际分配与释放,借助精巧的生命周期策略,最大限度地复用已分配而未使用的内存,降低内存分配的频率,减少内存碎片的产生。

C++标准库通过名为synchronized_pool_resource的内存池分配器,支持上述功能。它被定义在std命名空间的pmr子空间中。例如:

经过以上修改,当16个生产者和4个消费者处理了10万个事件后,内存碎片不会超过3M。这是近2000倍的改进!自然,实际使用的内存量并没有改变,改变的只是因碎片而浪费掉的部分。在消除了内存碎片以后,内存占用会在很长时间内保持稳定,系统可在数月甚至数年内持续、平稳地运行。

类似的技术在C++的早期阶段就已经被广泛地应用并获得很好的收益,但那个时候需要对每个特殊的容器重新编写代码。现在,标准库容器可以选择性地接受分配器参数。容器的默认分配器使用new和delete分配和释放内存。常用的多态分配器包括:

C++17:多态分配器

12.8 容器概述

标准库提供了一些最通用也最具实用价值的容器类型:

标准库容器说明
vector<T>动态数组
deque<T>双端队列
list<T>双向链表
forward_list<T>单向链表
map<K,V>关联数组
multimap<K,V>键可重复的关联数组
set<T>集合
multiset<T>值可重复的集合
unordered_map<K,V>支持哈希查找的关联数组
unordered_multimap<K,V>支持哈希查找的键可重复的关联数组
unordered_set<T>支持哈希查找的集合
unordered_multiset<T>支持哈希查找的值可重复的集合

程序员可以根据应用的需要选择最合适的容器。所有支持哈希查找的容器,也叫无序容器或者哈希容器,借助哈希表,对键(通常是字符串)的搜索进行了优化。

标准库容器都定义的std命名空间中,并通过<vector>、<deque>、<list>、<map>、<set>等头文件提供给使用者。此外,标准库还提供了一组适配器容器,如:stack<T>、queue<T>、priority_queue<T>等。标准库定义了两个仅用于特殊场合的容器:array<T,N>和bitset<N>。

站在使用者的角度,所有标准库容器都提供高度相似的基本操作,且具备一致的语义。下表列出的每一种基本操作,都可应用于多种容器:

基本操作说明
value_type容器中元素的数据类型
c.begin()获取指向容器中首元素的迭代器
c.cbegin()获取指向容器中首元素的常迭代器
c.end()获取指向容器中尾元素下一个位置的迭代器
c.cend()获取指向容器中尾元素下一个位置的常迭代器
c.size()获取容器中的元素个数
c.empty()判断容器是否为空
c.resize(k)设置容器中的元素个数,减少者被销毁,新增者取默认值
c.capacity()获取容器在不新增内存的前提下,最多容纳的元素个数
c.reserve(k)设置容器在不新增内存的前提下,最多容纳的元素个数,只能比当前值更多
c[i]访问容器中的特定元素,不做越界检查
c.at[i]访问容器中的特定元素,越界抛出out_of_range异常
c.push_back(x)在容器的尾元素之后再添加一个元素
c.emplace_back(a)在容器的尾元素之后再构建一个元素
c.insert(p,x)在容器中的指定位置之前插入一个元素,返回指向新插入元素的迭代器
c.erase(p)删除容器中指定位置的元素,返回指向被删除元素下一个位置的迭代器
c1=c2将源容器中的所有元素复制到目标容器中,目标容器的原有元素被销毁
c1==c2对两个容器中对应位置的元素做相等性比较
c1<=>c2对两个容器中对应位置的元素做字典序比较
表达式的值为负数表示c1<c2,值为零表示c1==c2,值为正数表示c1>c2
其它比较操作符,如<、<=、>、>=、!=等,都自动从“<=>”操作符生成

形式和语义上的一致性,使程序员可以很容易地设计出自己的容器,并在使用方式上高度近似于标准库容器。另一方面,容器接口的一致性,还有助于设计出类型无关的泛型算法。但是,功能上的一致性,未必意味着性能上的一致性。例如在vector中插入或删除元素,往往伴随着元素的移动,相对于无需移动元素即可完成插入删除的list而言,效率差强人意。如果元素序列较短,元素对象本身也不大,那么vector通常比list更高效,即便是insert或erase操作亦是如此。一般的建议是,除非有足够的使用其它容器的理由,否则应尽量使用vector容器。

标准库还提供单向链表容器forward_list,这是一种针对空序列做了特别优化的容器。极端情况下,它只占用一个指针大小的内存空间,而即使是空vector,所占用的内存也不止一个指针。这种容器更适合存放那些经常为空,或只包含少量元素的序列。

就地(emplace)操作,比如emplace_back,只接受传递给元素类型构造函数的参数,并在容器中的恰当位置直接构建元素对象,而不是将容器外部的对象拷贝或转移一份到容器内部。例如:

当然,象上面这样的简单操作,优化器完全有能力将两种写法优化为同等性能。

C++11:哈希容器(unordered_map、unordered_multimap、unordered_set和unordered_multiset)

C++11:容器的emplace操作

12.9 建议