侯捷C++ (二--STL标准库)

C++STL标准库与泛型编程

Standard Template Library

STL六大部件

  • 容器 Containers
  • 分配器 Allocators   一种用来修饰容器或仿函数或迭代器接口的东西
  • 算法 Algorithms   处理容器数据
  • 迭代器 Iterators   算法和数据之间的桥梁
  • 适配器 Adapters
  • 仿函数 Functors

 可以不写分配器,容器会默认分配器。分配器也是一个模板,告诉它每次分配的是什么东西。
bind2nd:绑定第二个参数。这里就是求出所有大于等于40的数的个数(用了count_if,带一个条件,原来是小于40,然后再用not1变成是大于等于40的意思)。

容器


前闭后开
大致分为两种容器:序列容器,关联容器
所谓关联容器,就是key和value的结构
序列容器:array、vector、deque、list、forward-list
关联容器:set、multiset、map、multimap,实现使用红黑树(高度平衡二叉树)做的
不定序容器(也是一种关联式容器):C++11中有Unordered容器,但他也属于关联容器,实现是用hash table做的

vector<int> c;
    vector<int>::iterator ite = c.begin();
    for (; ite != c.end(); ++ite);

对于关联式容器,在循环中使用erase,需要在erase中it++;对于序列式容器,不需要++操作;erase操作返回的是删除当前迭代器的下一个迭代器

Set和Map是红黑树实现的。事实上,标准库并没有规定set和map应该用什么实现,但是由于红黑树实现很好所以各家编译器都用红黑树来实现。
Set的key就是value、value就是key。
而Multiset和Multimap表示key可以重复。
目前hash table做的最好的做法是separate chain(链地址法),当然这里一条链子不能太长,所以还会有进一步的细节处理。

list

链表是一个非连续空间,所以不希望一个iterator是单纯的一个指针,否则++就不会按链表去走。所以希望iterator足够聪明,++的时候会去看next指针指向list下一个节点。所以iterator是一个“聪明的指针”,所以会设计成一个class
再来看用法: 

由于要模拟指针,所以要去实现++、–等操作,且每个iterator的实现类至少要有这五个typedef,如下: 

 前++ 和 后++ 本身都没有参数,但是为了区分定义,后++的函数定义上有一个形式上的参数,但是没有用,只是为了区分

后++ 的返回值不是reference ,所以不能连串后++

为什么要有这么多typedef?
一定要有5个typedef,也就是下图中括号行。这就牵扯到了迭代器的Traits(特征特性)设计。
迭代器需要遵循的原则:迭代器是连接容器和算法的中间件,因此迭代器必须能够回答算法的一些提问,这样算法才能更好的对容器进行操作。这5种迭代器关联类型分别为:pointer、reference、iterator_category(迭代器类型,比如说随机存取)、value_type(值存放类型)、difference_type(容器两个元素之间的距离类型)。

类中才能typedef,那如果一个迭代器不是一个类呢,他就不能回答算法的问题了?因此,会引入Traits机作为中间层,接收类迭代器和指针迭代器,会做相应的工作(模板偏特化),得到指针的5种迭代器关联类型。

 之前说了链表的iterator里头有一堆typedef,而里头有五个typedef是必须的,这就牵扯到traits了。

迭代器的设计原则和iterator Traits的作用与设计

traits,英文释义为特征,特性,特点。
所谓traits就是一种人为制造的萃取机。一种机器。希望丢给它什么东西就萃取出你想要的特征。

参考:
https://www.cnblogs.com/mangoyuan/p/6446046.html

traits,又被叫做特性萃取技术,说得简单点就是提取“被传进的对象”对应的返回类型,让同一个接口实现对应的功能。因为STL的算法和容器是分离的,两者通过迭代器链接。算法的实现并不知道自己被传进来什么。萃取器相当于在接口和实现之间加一层封装,来隐藏一些细节并协助调用合适的方法,这需要一些技巧(例如,偏特化)。

标准库有好几种traits,这里谈谈针对iterator的traits(还有type traits、character traits、pointer traits等等)。即萃取出iterator的特性,
首先来看看iterator遵循的原则:

如上图,value_type就是链表的元素类型 T,category就是表现链表迭代器是双向类型的bidirectional_iterator_tag
而指针也算一种iterator,是一种退化的iterator,而它不是class形式的,无法回答这五个问题,就要加一个中间层萃取机,萃取机就需要进行区分。

补,之前所述的list的设计:

算法想知道I的五种相关类型,就来问traits,traits就转问 I (如上图)
这里通过了模板的偏特化来进行区分。如上图的1、2、3.
而这里iterator若是const int* ,其萃取出来的value_type应该是int而非const int,如上图的3与右下角的解释。

vector

 容器容量按2倍增长,扩充的时候需要找到和当前容量两倍大的连续的内存才行

在进行增长的时候,会调用大量的构造函数

事实上没有什么东西能原地扩充,因为不知道后面的空间是否使用。vector也是如此,每次会去找一个可以扩充的空间。
最终没找到两倍大的空间,这个容器就失败(失效)了。
如上图右侧实现,vector类自身就是三根指针(start、finish、end_of_storage),所以其本身大小是12个字节。

从下图也可以看出两倍增长(特殊情况:当old_size为0的时候成长为1): 

Deque

Deque 两端开口

其中是一个一个的 buffer ,Deque 做出其中是连续的假象,比如通过重载了操作符++每次扩充一个 buffer。其实 stack 和 queue 内部都是用 deque 去实现的

map会将这些分段的node给串起来。
node指向map的哪一块
first和last指的是node的边界
cur指向当前node的元素
还会有一个start和finish的迭代器,保存双向队列的两端头元素。
map不够大,也会二倍扩充。

一个vector(图中的map,源代码是用vector实现的,也被叫做控制中心)内放置指向各个缓冲区(buffer,也有人叫节点)的指针,需要扩充的时候就新分配一个缓冲区,然后把指针放入vector中。

再看上图的迭代器的设计,这个迭代器的node就指向控制中心对应的位置,first和last就指向缓冲区的边界位置,用作标志。所以当iterator每次++或者–的时候,就会通过first和last去判断是否走到边界,然后若已经到达边界要跳到下一个buffer的时候,就通过node回到控制中心去找到对应的位置。
第一个cur就是当前指向的元素。

几乎所有的容器都提供begin和end,对应上图中begin就对应start,end就对应finish。

再来看看对应的源码(侯老师讲解主要是以G2.9版本讲):

从上图可以看到,一个deque有两个迭代器,下面讲了一个是16字节,然后这里的map_size 4字节,map 在这里定义是一个T**,4字节,所以一共40字节。
这里的模板参数BufSiz就是每个buffer容纳的元素个数。所以G2.9版允许我们指定buffer的大小,但是新版是不允许的。

从下图可以看到,一个迭代器内部四个指针,大小4 * 4 = 16字节:

 

这里insert有个考量,毕竟deque是可以两端扩充的,所以要是插入的时候前端元素比较少,那就应该往前推而不是往后推(效率高)。所以这里可以看到如果insert是头就push_front,是尾端就是push_back,其他就是insert_aux辅助函数。 

deque还需要指定buffer size,也就是每一个buffer容纳的元素个数,默认是0,就会做相应的操作来存放默认数量的元素,但是肯定不会是让一个buffer存放0个元素的。迭代器类型用的是随机存取(也就是连续的)是deque类做的伪装。迭代器做了模拟连续空间的操作!

deque<int> c;
c.push_back(2);
c.pop_back();
c.push_front(2);
c.pop_front();
c.max_size();
c.front();
c.back();

stack

stack<int> c;
c.push(2);
c.pop();
c.top();
c.size();

其实就是内含一个deque,然后封锁住一些功能。
因为这样的实现,所以有时候也不会把queue叫做一个容器,转而叫做一个适配器。 

queue

queue<int> c;
c.push(2);
c.pop();
c.front();
c.back();
c.size();

由于stack和queue是由deque实现的,所以从技术上来讲不算容器,叫adapter,不过也无所谓
由于queue有先进先出的性质,所以不会提供iterator的操作(不会提供函数让你得到iterator),否则就会破坏内部结构。stack同理。 

RB-Tree

红黑树是平衡二分搜索树的一种。平衡二分搜索树的特征:排列规则有利于 search 和 insert,并保证适当平衡--无任何节点过深

红黑树提供“遍历”操作以及iterators,按照正常的++ite,就能得到里头所有元素的一个排序状态。
我们不应该使用iterators去改变元素的值。但是编程层面并未禁止这件事。因为我们在红黑树里排序的是元素的key,而map允许元素的data被改变,只有元素的key才是不可被改变的。

使用红黑树,有5个模板参数。sizeof(RBTree)=12(GNU2.9)。为什么要在右下角放双向链表呢?因为红黑树和双向链表都有一个不用的节点,双向链表是end后面的节点,红黑树是头节点。

set 

map

这两种方法和前面的multi xx 使用方法一样,只是不允许有重复的key值。
map可以通过下标[]来插入
set不能够通过迭代器来修改容器里面的key值,因为是根据key来进行排序,实现的方法是使用const的迭代器。
map也不能够修改key值,但是可以修改key对应的value值,具体的实现方法是RBTree的迭代器指定模板参数的时候,value_type对应的类型为pair<const key, T>,即key是const类型的,但是value不是。
set和map大多数工作都是交给RBTree,从这个角度看,set和map也是一种容器适配器

unordered开头的容器,之前是用hash开头

HashTable


使用hash表,冲突采用拉链表法.
如果元素的大小和bucket的大小一样大了(不管有没有填满),就会扩充bucket的大小,变为当前bucket大小的倍数附近的质数,然后每个元素rehash,插入。这是一条经验法则。

需要指定6个模板参数!ExtractKey是放入的Object的Key,EqualKey是比较函数,使用hashtable最困难的事决定使用什么hash函数

具体写一个使用hashtable的例子:
标准库没有提供现成的hashstd::string

hashtable<
	const char*,
	const char*,
	hash<const char*>,
	identity<const char*>,
	eqstr,
	alloc> 
ht(50, hash<const char*>(), eqstr());

multiset

multimap

array

是C语言本来就有的东西,为什么要把他包装成容器?因为要让他有容器的行为,要有迭代器,才能配合算法。必须指定大小,不能拓展。没有构造函数,没有析构函数!

如果没有这么包装的话,array就会在六大部件之外,就不能享受算法、仿函数等的功能。

 

 

array<long, msize> c;
c.size();
c.front();
c.back();
c.data();//返回array的起点地址
qsort(c.data(), msize, sizeof(long), compareLong); // 快速排序,指定地址,多少个元素,每个元素大小是多少,比较方法是怎样
bsreach(&target, c.data(), msize, sizeof(long), compareLong); //二分查找,指定查找对象

 forward_list

forward_list<int> c;
c.push_front(); //头插
c.max_size();
c.front();
//无c.back()
//无c.size()

分配器 allocator

比如说vector的模版定义如下,会有一个默认的分配器std::allocator<_Tp>,如果不指定分配器,就会默认使用这一个

template<typename _Tp, typename _Alloc = std::allocator<_Tp>>
class vector : protected _Vector_base<_Tp, _Alloc>

容器需要一个东西来支持它对内存的使用,这个东西就是分配器,最好的情况下,我们不需要知道这个东西,所以需要一个默认的分配器。

上图右边示范直接使用分配器,当然一般我们用不到。只是示范一下。
这里直接用分配器负担很重,因为每次deallocate还需要还当初分配的单元数,所以用的时候应该直接针对容器而不应该去直接使用分配器,小的单元内存不如用new/free(因为不需要记住分配了多少字节,直接free指针就行了)

malloc 会自带一个固定大小的header

有效空间只占其中的一部分,如果每次malloc一个小空间然后多次malloc,那么header的所占空间将会大的无法忍受

这些额外开销有什么用呢?00000041是两个cookie,保存了这个内存块的大小。但是对于容器来说,容器的一个块大小是固定的,有必要使用这个cookie吗?可以不要。容器的元素大小是固定的。比如说一百万个元素,一个元素是8个字节,前面却要记录一百万次这个元素是8字节,完全没有必要。所以在容器的应用情况下可以不需要cookie,所以GNU C就是从这个情况下着手。

GNU2.9使用的是alloc,解决了上面的疑问(额外开销的问题)!实现行为如下,主要的思路就是减少malloc的次数,因为malloc一次就会附带一个头尾。

16条链表,负责不同大小的内存分配。8字节对齐。每个内存块不会都带cookie。只会在链表的头尾有cookie。

比如上图,第0号链表负责的是8个字节的大小,第7号就是负责8 * 8 = 64个字节的大小,依次类推,最后一个15号,负责的就是16 * 8个字节的大小。
所有的容器需要内存的时候都来往这个分配器要内存。而容器的元素大小因此就会调整到8的倍数,比如元素大小是50字节就会调整到56字节,那么此时就是第6号链表(7 * 8 = 56),然后就会看这号链表里面有没有内存块,如果没有就会调用malloc去向操作系统要一大块去切割,然后用单向链表去存储。所以这样切出来的一大块就会不带cookie,就没有这样的额外开销。

那么比如有一个容器,放一百万个元素,这样做,由于这一百万个元素都不带cookie,于是就可以省下800万个字节的开销。这可不是小数目。当然这是一个直观的想象,实际上每一次调malloc要一大块内存的时候都会带有一个cookie,那么按照我的理解,如果一次malloc想要分配100万个元素的空间,那么就只会带一个8字节的cookie,这当然可以忽略不计。
这是GNU C尤其是alloc的分配器所表现出来的好处。
不过也有缺陷,需要在后面的内存管理章节去讲。

不过经过比较这一种分配器(alloc)是最好的。

分配器最重要的函数就是allocate和deallocate,而allocate则会调用operator new,而operator new最终则会调用malloc。deallocate则是调用operator delete,而operator delete则最终调用free,与malloc配合。
示范中(allocator()),一个typename加()就会形成一个临时对象(object)。
当然,不鼓励直接用分配器

迭代器  

前面说到迭代器中必须要有5种关联类型:pointer、reference、iterator_category(迭代器类型,比如说随机存取)、value_type(值存放类型)、difference_type(容器两个元素之间的距离类型)。

iterator_category

也有五种迭代器类型:随机存取迭代器(array、vector、deque)、双向迭代器(list、红黑树容器)、单向迭代器(forward_list,hash类容器)、输入迭代器(istream迭代器),输出迭代器(ostream迭代器)。

  • Array、Vector、Deque:都是连续空间(这里包括deque的假象),所以都是random_access_iterator_tag。
  • List:不连续空间,但是是双向链表,所以是bidirectional_iterator_tag
  • Forward-List:既然是单向链表,所以应该是forward_iterator_tag
  • Set、Map、Multiset、Multimap:红黑树的底部支撑,通过我们之前的分析,我们知道红黑树都应该是双向的,所以是bidirectional_iterator_tag
  • Unordered Set/Multiset、Unordered Map/Multimap:hashtable的底部支撑,通过之前的分析我们知道,要看每个篮子的链表是双向还是单向链表,所以应该是forward_iterator_tag(对应hashtable内部实现是单向的链表)或是bidirectional_iterator_tag(对应hashtable内部实现是双向的链表)。而按照之前分析的版本实现来看,G2.9是单向链表实现,所以G2.9版本就应该对应着forward_iterator_tag

iterator_category对算法的影响

以这个distance函数为例,会根据迭代器的类别来调用不同的具体实现函数,一个是只包含一个减法操作的语句,一个是包含一个while循环的语句,可想而知,当真实距离很大时,有while循环的具体实现函数效率会非常低下。

如果是随机存取的内存连续的容器的迭代器,就可以直接用尾指针减去头指针计算 distance ,但是如果不是的话,就只能一步一步走一个一个算,比较费时

看一个特别能体现C++注重效率的体现:
copy实现,到最终的实现细节,经过了很多判断,这些判断是为了找到最高效率的实现,就是判断迭代器的分类。

算法

必须要是下面的两种形式的函数,才是STL中的算法,比如说qsort和bsreach的参数就不是传入迭代器,所以不是C++STL中的算法,而是C中的函数。

template<typename Iterator>
Algorithm(Iterator it1, Iterator it2){
...
}

template<typename Iterator, typename Cmp>
Algorithm(Iterator it1, Iterator it2, Cmp comp){
...
}

算法看不见容器,关于容器的一切信息都必须通过迭代器获得,所以又和前面的Traits机联系到一起了。

accumulate(InputIterator first, InputIterator last, T init)


另外一个版本为:accumulate(InputIterator first, InputIterator last, T init, BinaryOperation binary_op)

上面这个binary_op指明是一个二元操作数的函数,可以是仿函数(实质上是一个类),也可以是函数,只要是能够在该算法的函数体内通过小括号调用就行!!!!也就是能够这么用:binary_op(a, b);所以,之前的疑虑就可以消除了。就算是在算法(函数)里面,也能够使用仿函数,但是传入的是仿函数的对象实例,而如果要传入函数的话,就传函数名就可以了。

int init = 100;
int nums[] = {10,20,30};
accumulate(nums, nums+3, init);//不指定具体怎么操作,默认为加法,输出160
accumulate(nums, nums+3, init, minus<int>()); //这minus时减法的意思,所以输出为40

lower_bound(ForwardIterator first, ForwardIterator last, T target)

二分查找的一个版本,如果找到对应的值,则返回指向其中第一个元素的迭代器,如果不存在,则返回最适合安插这个target的点的迭代器 ,也就是说它返回迭代器指向第一个不小于target的元素,也就是说它返回的是不破坏排序得以安插target的第一个适当位置

upper_bound(ForwardIterator first, ForwardIterator last, T target)
binary_search(ForwardIterator first, ForwardIterator last, const T& value)
源码中就是调用lower_bound

for_each(InputIterator first, InputIterator last, Function f)
对容器区间内的元素做同样的事情

replace(ForwardIterator first, ForwardIterator last, const T& old_value, const T& new_value)
将容器区间内的元素进行替换,如果元素值等于old_value就把它替换为new_value.

replace_if(ForwardIterator first, ForwardIterator last, Predicate pred, const T& new_value)
Predicate为一个条件,判断式子,如果符合条件就进行替换

replace_copy(ForwardIterator first, ForwardIterator last, OutputIterator result, const T& old_value, const T& new_value)
范围内所有等同于old_value的都以new_value放置新的区间中,不符合原值的也放入新的区间

count(InputIterator first, InputIterator last, const T& value)
区间内有和value相等的元素count+1。

红黑树、hash容器中有自己的count

count_if(InputIterator first, InputIterator last, Predicate pred)
区间内有符合pred条件的count+1

find(InputIterator first, InputIterator last, const T& value)
循序查找,返回第一个和value相等的迭代器
红黑树、hash容器中有自己的find

find_if(InputIterator first, InputIterator last, const T& value)
循序查找,查找符合条件的第一个元素的迭代器

sort(InputIterator first, InputIterator last, Function f)
默认从小到大排序,也可以指定自己的比较函数,可以是仿函数,可以是函数,仿函数必须传入该仿函数的实例。

reverse iterator ,rbegin()  rend  逆向迭代器

仿函数

只为算法服务

有三种仿函数:算术类(+、-、*、/等)、逻辑运算类(&&、 ||等)、相对关系类(返回bool),一共大概24个仿函数。

给一个加法的仿函数

template<class T>
struct plus : public binary_function<T, T, T>{
	T operator()(const T& x, const T& y) const{
		return x + y;
	}
}

再写写binary_function的定义:

template<class Arg1, class Arg2, class Result>
struct bianry_fucntion{
	typedef Arg1 first_argument_type;
	typedef Arg2 second_argument_type;
	typedef Result result_type;
}

为什要让仿函数继承这些类呢?
首先,继承他们,不会增加仿函数的内存大小,其次,继承了他们,会有了first_argument_type等的typedef,后续可以根据这个类型进行一些修改。
一个仿函数的可适配条件是什么?就是必须(合适地)继承binary_function,unary_function等类,才能回答适配器的问题,就像Traits机要回答迭代器的问题一样。

一个示例:

template<class Pair>
struct select1st :public unary_function<pair, typename Pair::first_type>
{
	const typename Pair::first_type& operator()(const Pair& x) const
	{
		return x.first;
	}
};

参考文章:侯捷C++八部曲笔记(二、STL标准库和泛型编程)_侯捷stl_Wanncye的博客-CSDN博客

C++STL(上)——开篇介绍、迭代器设计原则与序列式容器(Sequence Containers)_序列式容器是不是都有begin和end-CSDN博客

参考书籍:《STL源码剖析》 

  • 17
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值