C++/C学习笔记(九)
——学习和使用STL
1.STL简介
STL(Standard Template Library)是C++标准库的最主要和最重要的组成部分。STL是一个标准规范,只是为容器、迭代器和泛型算法等组件定义了一整套统一的上层访问接口及各种组件搭配运用的一般规则,而没有定义组件底层的具体实现方法,因此不同的软件供应商都可以提供自己的STL实现版本,如Microsoft、Borland、HP、SGI等,虽然这些版本之间可能存在某些差异,但是概念和接口都是一致的。我们可以把STL看做是一个概念模型库或者是一个组件架构。
STL主要班阔下面这些组件:I/O流、string类、容器类(Container)、迭代器(Iterator)、存储分配器(Allocator)、适配器(Adapter)、函数对象(Functor)、泛型算法(Algorithm)、数值运算、国际化和本地化支持,以及标准异常类等。最主要的“六大组件”为:容器、存储分配器、迭代器、泛型算法、函数对象、适配器。容器类相当于数据结构,算法用于操作容器中的数据,而迭代器则是它们之间的一个桥梁。
这些组件的相互关系如图所示:
2.STL头文件的分布
STL源代码头文件一般存放在开发环境的include目录下。STL组件都被纳入了名字空间std::,所以在使用其中的组件之前需使用
using声明或using指令,或者也可以在每一处都直接使用完全限定名std::。
(1)容器类
容器类定义在下表所示的头文件中。关联式容器multimap和multiset也都分别定义在<map>和<set>中,hash_multimap和hash_multiset定义在<hash_map>和<hash_set>中。
STL容器的头文件
头文件 | 内 容 |
<vector> | 元素类型为T的向量,包括了特化vector<bool> |
<list> | 元素类型为T的双向列表 |
<deque> | 元素类型为T的双端队列 |
<queue> | 元素类型为T的普通队列,包括了priority_queue |
<stack> | 元素类型为T的堆栈 |
<map> | 元素类型为T的映射 |
<set> | 元素类型为T的集合 |
<bitset> | 布尔值的集合(实际不是真正意义上的集合) |
<hash_map> | 元素类型为T的hash映射 |
<hash_set> | 元素类型为T的hash集合 |
(2)泛型算法
只要是由一系列元素构成的结构原则上都可以应用泛型算法,像C++/C数组、字符串、I/O流等特殊的容器也可以使用某些泛型算法———它们定义在头文件<algorithm>和<utility>中。
(3)迭代器
迭代器就是用来遍历元素序列或元素集合的“通用指针”,但是每一种容器都定义了适合自己的迭代器,那些具有特殊功能的迭代器,如输入/输出迭代器、插入迭代器、反向迭代器等都是迭代器适配器,定义在头文件<iterator>中。
(4)数学运算库
STL有几个专门为数学运算设计的类和算法,定义在下表所示头文件中。
STL数学运算库
头文件 | 内容 |
<complex> | 复数及相关操作 |
<valarray> | 数值向量及相关操作 |
<numerics> | 通用数学运算 |
<limits> | 常用数值类型的极限值和精度等 |
(5)通用工具
下表定义了在STL容器和泛型算法中用到的辅助组件,有标准的函数对象、pair<>、auto_ptr<>类等。
STL通用工具
头文件 | 内容 |
<utility> | 运算符重载和pair<>定义 |
<functional> | 标准的函数对象及其便捷函数定义 |
<memory> | 存储分配器和auto_ptr<>类 |
(6)其他头文件
除以上头文件外,还有一些经常使用的组件,分别定义在下面这些头文件中:<typeinfo>、<stdexcept>、<strsteam>、<string>、<istream>、<ostream>、<iostream>、<new>、<iomanip>、<fstream>等等。
3.容器设计原理
(1)内存映像
容器就是能够容纳其他对象作为其元素的对象,例如数学中的集合就是一种容器概念。由于容器在概念上是一种可以动态增大和减小的模型,所以其元素对象在实现上不可能直接保存在容器对象里面,而应该保存在自由内存(Free Memory)或堆(Heap)上。要区分两个概念:“容器对象”和“容器元素对象”。容器本身就是一个C++类的对象,其大小在运行时不可能改变,因此容器必须有办法指示其每一个元素对象在内存中的位置,以便用户能够通过容器对象找到其中的元素对象。
先介绍一个典型的容器std::vector<T>的内存映像,来展示C++中STL是如何实现容器的。
在C++中,我们使用信息隐藏和封装技术把数据隐藏在类内部不许外部直接操作,同时提供访问器(如get_xxx成员函数)和修改器(如set_xxx成员函数)。STL容器的设计原理如出一辙,只是它们在实现时考虑的问题更多、更复杂而已。容器不仅把元素对象隐藏了起来,而且把元素对象的内存申请和释放操作也全部隐藏了起来(通过存储分配器),这就使程序员彻底摆脱了直接操纵底层内存指针的麻烦,也避免了危险。
(2)存储方式和访问方式
向量(vector)和链表(linked list)是两种最基本的动态结构,也是STL中两种最基本的容器,分别对应动态数组和链表结构,同时它们分别代表了内存中同类型批量数据存放的两种基本方式:连续存储和随机存储。
不同的存储方式决定了元素的不同访问方式,即随机访问和顺序访问。所谓随机访问就是指可以直接通过开销恒定的算术运算来得到任一元素的内存地址的访问方法,而无法直接得到任一中间元素对象的地址。C++/C的内置数组和vector都是既可以随机访问又可以顺序访问的容器,而list则只能顺序访问。
只要底层存储机制采取连续存储方式的容器,就可以随机访问其中任一元素对象,否则只能顺序访问;而任何容器都可以顺序访问,即遍历。
(3)顺序容器和关联式容器的比较
这里的“顺序”和“关联”指的是上层接口表现出来的访问方式,并非底层存储方式。顺序容器主要采用向量和链表及其组合作为基本存储结构,如堆栈和各种队列;而关联式容器采用平衡二叉搜索树作为底层存储结构。
链表(list)作为顺序容器的典型代表,其特点就是“天然有序(Sequential)”,即各元素之间具有天然的相对位置关系,这就决定了理论上它可以存储任意多个元素,并且不需要对所有元素按值的大小进行排序。
集合(set)是关联式容器的典型代表,需要从两个层次来理解:概念模型和实现方式。集合在概念上是一种无序的容器,这不仅指其中的各个元素之间没有相对位置关系,而且元素不需要按值的大小排序。但是如果要在计算机上实现这样的模型,不但要使上层访问接口正确反映集合的概念,还要考虑实现的效率。因此关联式容器在实现上必须是有序的和排列的,只不过要对用户透明(不让用户知道)。平衡二叉搜索树就能够满足这些要求。
由于关联式容器在概念上是无序的,所以它只能通过元素的值来定位其中的元素对象,这就是关联式容器具有find()函数的原因,也是调用这种容器的insert()函数和erase()函数时可以不指定插入位置和删除位置而仅指定元素值或者索引值的原因(关联式容器会自动地为新插入的元素安排一个合适的位置)。
由于顺序容器本来就有“序”,所以它是通过元素对象在容器中的位置来标识一个元素的,而不是通过元素的值(因为它可以存储值相等的多个元素对象,而且它们的位置不一定相邻)。这也就是调用顺序容器的insert()函数和erase()函数时必须指定插入位置和删除位置而不能仅指定元素值的原因。当然,关联式容器也能存储值相等的元素,比如multimap和multiset等,但是它们在容器中的位置肯定是相邻的。
映射(map)是一种独特的关联式容器,它是一个二维的索引表,其每一个元素是一个<关键字,值>的二元组合,即pair<Key,Value>。这就是说,set是Key与自己关联,而map则是Key与Value关联。显然,map应该提供通过Key值来定位Value对象的接口,这就是map具有operator[]运算符函数的原因。
如果经常进行的操作是元素查找,应使用关联式容器;经常插入和删除,则应用顺序容器。
关联式容器在实现上必须维持元素的有序性,因此不能再企图修改已经插入到其中的元素对象,否则就会破坏这种有序性,从而影响性能和正确性。为此,set把iterator强制声明为const_iterator来达到修改的目的,map则把pair中的Key强制声明为const Key,map仅允许修改pair中的Value。这也就是说,你无法直接为关联式容器的元素对象赋予新值或者调用它们的non-const方法。相反,顺序容器允许修改元素对象的值,或者直接赋值,或者调用non-const方法。
(4)遍历容器
STL容器范围表示为“前闭后开”区间法,即[first,last],其中迭代器last要么指向最后一个有效元素的末尾,要么指向一个空白节点,反正不是指向最后一个元素。
遍历容器较好的方法见下例:(把Container替换为具体的泛型容器类,theContainerObj替换为该容器类的对象即可)
/*******************************************************
for(Container::iterator first=theContainerObj.begin(),
last=theContainerObj.end();
first!=last;
++first)//或者使用反向迭代器rbegin()和rend()
{
cout<<first->...<<endl;
cout<<(*first)...<<endl;
...
}
template<typename InputIterator,typename T>
InputIterator find(InputIterator first,InputIterator last,const T& value)
{
while(first!=last&&first!=value) ++first;
return first;
}
/*****************************************************************
(5)存储空间重分配问题
存储空间重分配问题起源于容器元素对象的动态创建和连续存储特性,因此只有连续存储的容器才可能需要运行时的存储空间重分配,典型如vector,deque。
对于一个已经存在的vector,其元素被保存在地址连续的存储空间上,当向该元素vector中插入一个新元素时,必须保证新的容器仍然满足元素连续存储的条件,这就要求扩张原有容器的空间。若当前vector容器没有那么多空余容量,则必须要给整个vector的所有元素重新分配存储空间,把所有元素拷贝到新地址处,释放原来的存储空间,最后将原来的容器对象调整为指向新的内存位置。
若当前vector容器有足够的空余容量来容纳待插入的新元素,此时不需要重新分配存储空间,但不是在尾部进行插入操作的话,同样需要移动vector的部分元素和调整指针。
因此要尽量在容器的尾部执行插入操作,效率最高。对于顺序容器来说,它们本身并不要求元素排序,因此完全没必要在开头或中间插入元素;对于关联式容器,它们在实现时就是有序的,即调用insert()的时候会自动进行重新排序。