侯捷——2.C++标准库 体系结构与内核分析

侯捷——2.C++标准库 体系结构与内核分析

1. C++标准库 vs STL

在这里插入图片描述

在这里插入图片描述
C++标准库包含STL。标准库都用 std 这个命名空间包装起来。

2. STL体系结构基础介绍

在这里插入图片描述
allocators(分配器)是给containers(容器)分配内存的。当要创建容器时,必须先有分配器分配内存。在创建容器时,可以指定分配器,也可以不指定,编译器会默认指定分配器。

在这里插入图片描述

2.1 复杂度

在这里插入图片描述

2.2 迭代器采用前闭后开区间

在这里插入图片描述
begin()指向第一个元素,而end()指向最后一个元素的下一个元素。

2.3 range-based for statement(since C++11)

在这里插入图片描述

2.4 auto关键字

在这里插入图片描述

3. 容器——结构与分类(1)

在这里插入图片描述
循序式容器,是根据你放进去的顺序来决定顺序的。
Array、Vector和Deque的底部都是在内存中占用的空间都是连续的。
List和Forward-List都是空间可以不连续的。

在这里插入图片描述
关联式容器,就是Set/MultiSet和Map/Mulimap,底部实现都是红黑树。

关联式容器都是内部有序的,而Set和Map是内部元素不可重复的,MultiSet和MultiMap是内部元素可以重复的。

Unordered Containers(无序容器),内部元素都是无序的,底部实现是哈希表。

在这里插入图片描述

3.1 Deque

在这里插入图片描述
Deque(双向队列),其实它内部是有可能不连续的,但为了让它看起来是连续的,则每当读到一个buffer最后一个元素时,要自动跳到邻近buffer的第一个元素。

stack(栈)和queue(队列)底部都是deque实现的,只是限制了deque的进出元素的方式。
在这里插入图片描述
在这里插入图片描述
stack和queue是没有iterator的,因为stack是先进后出,queue是先进先出,如果有iterator的话,就有可能会通过iterator修改它们中间的值,从而破坏规则。

3.2 无序容器(unordered)

在这里插入图片描述
无序容器包括unordered_set、unordered_multiset、unordered_map和unordered_multimap。它们底部实现是哈希表,如上图,哈希表的位置称之为篮子。篮子的个数会随着元素的增加而扩充。
例如当篮子只有10个的话,当元素达到10个,篮子数量就会扩充到20个,然后把原来的元素打散,按照新的表达式计算出各个元素的新位置。

所以一般篮子的个数都会比元素的个数多。

4. 分配器(allocator)

容器的背后需要一个东西来支持它内存的使用,这个东西就是分配器。


容器的模板都有第二参数,就是分配器。但可以不指定,会自动使用默认的分配器。

4.1 用法

在这里插入图片描述
在这里插入图片描述

不推荐单独使用分配器。

5. OOP(Object-Oriented programming) vs GP(Generic programming)

5.1 OOP

OOP(面向对象编程)一般是将 datas(数据) 和 methods(方法)合并在一起的。
在这里插入图片描述
list无法使用全局的sort()。需要自己实现。
在这里插入图片描述

5.2 GP

GP是将数据和方法分开来的。
在这里插入图片描述
容器和算法要取得联系得通过迭代器。

vector和deque就可以使用全局的sort()

在这里插入图片描述
采用GP的好处:

  1. 容器和算法可各自工作,互不影响,用迭代器沟通即可。
  2. 算法通过迭代器确定操作范围,并通过迭代器取得容器中的元素。

如果类中有实现sort(),则使用自己类中的。如果没有,则使用全局的。

在这里插入图片描述

6. 操作符重载和模板(泛化、全特化、偏特化)

6.1 全特化

当某个类型有更好的处理方法时,可以使用特化。就是指定某种类型时,调用对应的方法。要注意特化的语法表示,如下图所示。
在这里插入图片描述

6.2 偏特化

在这里插入图片描述

7. 分配器(allocators)

分配器的好坏会影响容器的效率。因为分配器是为容器分配内存的,而容器中元素的进出都会关系到内存的分配。

7.1 先谈operator new()和malloc()

所有的内存分配动作最终都会跑到malloc()下去。

在这里插入图片描述
operator new()也不例外。malloc在分配内存时,会多分配出 32+4 字节的灰色部分,会两块红色的cookie部分,还有根据情况而定的绿色填充部分。所以当你想要分配的内存比较小的时候,灰色和红色这些内存是会固定分配的,所以会导致,实际上使用的内存占比比较小,而当你要申请的内存比较大的时候,则实际使用的内存占比就会比较大。

7.1 分配器的源码

以下是VC6低下的allocator的源码:
在这里插入图片描述
allocator两个比较重要的函数就是allocate()deallocate()allocate()底部会调用operator new(),而operator new()会调用malloc(),所以归根结底就是allocate()会调用malloc()。而deallocate()也同理,底部会调用operator delete(),而operator delete()底部会调用free()

分配器在申请和释放内存时都需要指定大小,这是很不方便的,释放时很难去记住当初申请的大小。所以一般不推荐直接使用分配器。

VC下的 allocator 没有特殊的设计,背后只是调用 malloc 和 free 来实现内存的分配和释放。

VC下、BC下和GNU下的allocator的实现都差不多。但GNU下实现的allocator不怎么会去使用它,而是使用alloc

在这里插入图片描述
alloc 的主要改变是为了避免每分配一个元素需要在该元素上下消耗的8byte的cookie(记录大小),因为每次malloc都带来额外的内存开销。每个元素根据是 8 的几倍挂到上图对应的区域。当对应倍数下的链表没有内存时,系统还是会使用 malloc 向系统申请一大片内存。当然一使用malloc就会有额外开销。

8. 容器之间的实现关系与分类

在这里插入图片描述
本图以缩排形式表达“基层与衍生层”的关系。这里所谓的衍生,并非继承而是复合。
例如set、map、multiset和multimap与rb_tree(红黑树)的关系就是复合关系,也就是set、map、multiset和multimap里有rb_tree,它们底部需要红黑树作为支撑。

9. 深度探索list

在这里插入图片描述
这里的 __list_node 节点设计中有一些小缺陷,该结构体左右节点指向的是 void* ,也就是在使用时还得注意转型。一般来说,节点两端指向的都是自己本身这种类型。

除了array和vector,其它容器的 iterator 都要自己通过写出 iterator 类来实现,才能使 iterator 变聪明,可以通过 ++ 而指向下一个。

9.1 list的iterator

在这里插入图片描述
iterator少不了操作符重载,因为要使它像指针。

operator++()是前置的++,相当于是i++;而为了区分前置跟后置,所以后置的加了一个参数,作为区分,这个参数是没有用到的,operator(int)

在这里插入图片描述
C++不允许连续两次后置++。
为了前置++可以两次,所以它的返回类型是引用。为了使后置++不可以两次,所以它的返回类型不是引用。

在这里插入图片描述

9.2 list不同版本的比较

在这里插入图片描述
在这里插入图片描述

10. 迭代器的设计原则和Iterator Traits的作用与设计

10.1 Iterator 需要遵循的原则

在这里插入图片描述
value_type 指的是容器的类型,如果容器的类型是int,则 value_type 为 int。
difference_type指的是两个iterator之间的距离,应该用什么类型来表现,如 unsigned_int。

迭代器需要回答算法的五个问题:
iterator 必须定义的五种类型,iterator_category(),difference_type,value_type,reference和pointer。以此来回答算法提出的问题。虽然reference和pointer从来没有被使用到。
在这里插入图片描述

ptrdiff_t是比编译器已经定义好了的,好像是unsigned_long。

当iterator不是一个class时,而是一个自然指针(相当于一种退化的iterator),这个时候算法想要问它问题,就问不出什么,为了解决这个问题,引入了trait。

10.2 Iterator Traits的作用与设计

在这里插入图片描述
Iterator Traits(萃取机)必须要能区分iterator到底是class类型的,还是不是class类型的。可以把迭代器放进萃取机,问它一些事情,比如问迭代器是哪种类型等。

traits相当于中介。

在这里插入图片描述

在这里插入图片描述

11. vector深度探索

容器是一个前闭后开的区间。finish和end_of_storage都指向下一个。
在这里插入图片描述
vector有三个iterator变量,分别是start(指向容器的开始)、finish(指向容器中最后一个元素的下一个)和end_of_storage(指向容器的末端的下一个)。当finish与end_of_storage相等时,如果还继续放入元素,则空间会以两倍原先的空间进行扩展,而这种扩展不会在原来的地方进行扩展,而是在别处寻找两倍的空间进行扩展。

11.1 源码(G2.9)

在这里插入图片描述
容器只要是存储在连续空间的就会提供operator[]

在这里插入图片描述
在这里插入图片描述
每次的扩展就会引发大量的拷贝动作,而拷贝就会调用拷贝构造函数。而原来的空间也要释放,所以会调用析构函数。所以每次的扩展会调用大量的拷贝构造函数和析构函数,这是一个大的成本,在使用vector时要注意。

11.2 vector的迭代器

vector的迭代器不用设计得太复杂,因为vector内存是连续的,不用像list一样,把iterator单独设计成一个class,原因是list是内存不连续的。而vector的iterator只需要设计成指针就行了。
在这里插入图片描述

11.3 G4.9版本

在这里插入图片描述
在这里插入图片描述
G4.9的iterator是一个class,不是一个单纯的指针。
在这里插入图片描述

12. array、forward_list(单向链表)深度探索

12.1 array

在这里插入图片描述
G4.9 版本
在这里插入图片描述

12.2 forward_list(单向链表)

在这里插入图片描述

13. deque(双向队列)、stack和queue的深度探索

在这里插入图片描述
deque的底层,有一个vector(也就是图中的map)用来装指针,指针指向buffer(缓冲区),当deque想往前扩充时,只需要创建一个buffer,然后在vector前面的空地方用一个指针指向它就好。往后扩充也是同理。

deque是一个分段连续的容器,连续是假象,分段是事实。

13.1 源码

在这里插入图片描述
size_type类型的元素大小为4。

一个deque对象本身占40字节(两个iterator,2*16,加上4+4)。

13.2 deque的iterator

在这里插入图片描述

13.3 deque< T >::insert()

因为deque可以两端插入,所以在进行元素插入时可以比较聪明。当插入的位置处于deque所有元素的偏前面时,移动的是将插入位置前面的所有元素,而不是去移动后面的,这样做的目的是提高效率,因为每次的移动都需要调用构造函数和析构函数。
在这里插入图片描述

在这里插入图片描述

13.4 deque如何模拟连续空间

全是deque iterators的功劳。
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述
set_node()也可以用来往前跳。

+=和+是实现iterator进行多个元素的跳转。
在这里插入图片描述
进行多个元素的跳转有两种情况,第一种是要跳的距离还是在同一个缓冲区内,这种情况就比较简单;第二种情况是跳的距离要跨越缓冲区,这时就得继续需要跨越多少个缓冲区,如何在存储缓冲区指针的vector种,先移动对应的几个,到达对应的缓冲区,然后再移动到对应的位置。

在这里插入图片描述

G4.9版本
在这里插入图片描述
在这里插入图片描述

13.5 queue的源码

在这里插入图片描述
stack和queue底部容器都是deque,只是封住某些端口。

13.6 stack的源码

在这里插入图片描述

13.7 queue和stack,关于其iterator和底层结构

在这里插入图片描述
queue和stack的底层结构默认是deque,但也可以选择list。速度的话,是deque比较快。

还有一个需要注意的点就是,stack和queue都不允许遍历,所有也不提供iterator。 因为一个是先进后出,一个是先进先出,如果允许在指定位置进行插入的话,就会干扰到原来的模式。

在这里插入图片描述
注意: stack还可以选择vector作为底层结构,而queue不能。

在这里插入图片描述
还有就是stack和queue不可以选择set或map作为底层结构。因为只要一些函数无法调用,就没办法实现原本的模式。

14. 容器rb_tree(红黑树)

在这里插入图片描述
我们不应该使用 rb_tree 的 iterators 改变元素值(因为元素尤其独特的排列规则),例如图中 6 改成 60 是不被允许的。但编程层面并未杜绝此事。不过这样设计是正确的,因为 rb_tree 是为 set 和 map 服务(作为其底部支撑),而 map 允许元素的 data 部分被改变,只有元素的 key 才是不可被改变的。

rb_tree 提供两种插入操作:insert_unique() 和 insert_equal()。前者表示插入的 key 在整个 tree 中是独一无二的,否则插入失败;后者表示插入的 key 是可以重复的。

在这里插入图片描述
第三个模板参数KeyOfValue表示从value中取出key的方式。

在这里插入图片描述
size_typeunsigned_int,所以大小为4字节;link_type是指针,所以大小也为4字节;key_compare是一种仿函数,仿函数是一个大小为0的class,但会给它分配1字节的大小。所以rb_tree整个类的大小为9,编译器的默认对齐方式为4字节,所以它的大小为12字节。

G4.9的红黑树
在这里插入图片描述

15. 容器set和multiset

value包含key和data。
set/multiset 元素的 value 和 key 合一:value就是key,相当于就是没有data。

在这里插入图片描述

在这里插入图片描述
identity 就是将key看成value,取value时,返回key。

15.1 set in VC6

在这里插入图片描述
VC6里的_Kfn等同于identity()。

16. 容器map和multimap

在这里插入图片描述

在这里插入图片描述
map不允许通过迭代器来修改key,它是通过在pair中,直接让key为const。

16.1 map in VC6

在这里插入图片描述
VC6里没有select1st(),这个函数的作用是查找容器中的对应的key。

16.2 multimap

在这里插入图片描述
multimap不可使用 [] 做插入操作。
在这里插入图片描述
当用[]想map插入元素时,首先会先检查map里有没有该value,如果有,则用 iterator 指向其中第一个value(如果是multimap的话,有多个value相等的);如果没有,则 iterator 会找到最适合插入该value的位置。

17. 容器hashtable(哈希表)

在这里插入图片描述

18. 迭代器的分类

在这里插入图片描述
算法看不见容器,对其一无所知。所以,它所需要的一切信息都必须从迭代器(iterators)取得,而 iterarors (有containers提供)必须能完成 algorithm 的要求,才能搭配该 algorithm 的所有操作。

在这里插入图片描述
迭代器的分类如上图所示,使用结构体来表现,而且有的还是继承关系。

注意上面的连线,Forward-List(单向链表)用的迭代器是forward_iterator_tag(单向迭代器);Array、Vector、Deque(虽然它底层不是完全连续) 用的迭代器是random_access_iterator_tag(随机访问迭代器);List(双向链表)、Set/Multiset、Map/Multimap 用的迭代器是 bidirectional_iterator_tag(双向迭代器)。

unordered_set/multiset 和 unordered_map/multimap 视情况而定。

18.1 自己编写能够得到容器迭代器类型的代码

当我们想要用代码来识别当前容器的迭代器是什么类型时,可以使用下图这种方法,使用萃取机(iterator_traits< I >::iterator_category)来获得当前容器的迭代器的类型。根据编写函数重载的函数,就可以输出对应容器迭代器的类型。
在这里插入图片描述
注意: istream_iterator 的类型就是input_iterator;ostream_iterator 的类型就是 output_iterator。

18.2 利用标准库自身的标识(各种容器的迭代器的iterator_category 的typeid)

要记得包含同文件 #include< typeinfo > 。
在这里插入图片描述

18.3 迭代器分类对算法的影响

18.3.1 一个例子(求两个迭代器之间的距离)

在这里插入图片描述
distance()是一个算法,用于计算两个迭代器之间的距离。但它的距离的类型不是 int,迭代器中有一种数据类型是用来代表两个迭代器之间的距离的(difference_type)。所以要把我们的迭代器类型传给萃取机,让萃取机帮我们找到对应的 difference_type,所以这个函数的返回类型是 iterator_traits< InputIterator >::difference_type。

它有一个对外开放的版本,底部调用就有两个版本,根据迭代器的类型来选择对应的函数。注意上面代码中的 category() ,这种语法的使用,就是一个临时对象,type name加上括号可以代表一个临时对象。

在数据很多的情况下,使用第二个 distance 会比第一个快很多。

这这里就可以看出,为什么迭代器类型要用结构体来表示而不是用枚举类型?
使用结构体来表示迭代器类型可以方便地封装相关数据和行为,并实现多态性,从而提高代码的灵活性和可复用性。例如上面的例子,五种迭代器类型,而只有两种迭代器类型的函数,这时,如果是 forward_iterator_tag 或者是 bidirectional_iterator_tag 类型就会直接调用第一种函数。

18.3.2 一个例子(让迭代器移动对应的距离)

在这里插入图片描述
跟上面是一样的道理。

18.3.3 另一个例子(拷贝函数)

在这里插入图片描述
上面memmove()是C语言的一个拷贝字节的函数,属于低阶动作,速度较块;当最后判断是< T*,T* >或< const T*,T* >的话,就会经过 Type Traits 的判断,如果是不重要的(has trivial op=())话,就调用 mommove()处理,如果是重要的就用__copy_d()处理。

总之就是根据参数的类型去调用最快的函数。

18.3.4 另一个例子(destroy)

在这里插入图片描述

19. 算法源码剖析(11个例子)

20. 仿函数(Functors)

functors 是六大部件中大多时候比较会自己编写的一种,就是写一些能融入到STL(标准库)里的东西就是这种东西。 仿函数又称函数对象,专门为算法服务,当算法需要一些指定的规则(操作)时,就可以使用functors;例如,sort算法中我们可以自己指定排序的顺序,就可以创建一个functors,然后把functors传进去。

在这里插入图片描述

functors有三大类:算术类、逻辑运算类和相对关系类(比大小)。仿函数的特点就是重载小括号运算符。

在这里插入图片描述

20.1 举个例子

在这里插入图片描述
当我们自己实现的仿函数不继承STL里的类时就没有融入到STL里去,其实标准库中的 functors 都会有继承关系,虽然上面 myobj 和 less< int >() 实现的效果是一样,但 myobj 没有继承 STL 中的 binary_function或unary_function,就没有融入到STL中去,虽然上面执行没问题,但如果再执行某些操作,就会出现问题。因为它没有达到STL的全部要求。

20.2 为什么要继承(functors的可适配条件)

在这里插入图片描述
STL规定每个可适配的函数都应挑选上面两个其中的一个来继承,因为函数的适配器(Adapter)会向仿函数提问题。可适配的意思就是可以被修改以达成某种操作。

unary_function适合单操作数的情况,binary_function适合双操作数的情况。这两个类都没有成员变量和成员函数,所以理论上大小为0,但编译器会让它为1。当它们作为父类被继承时,给子类增加的大小就为0。

在这里插入图片描述
所以 less 继承 binary_function 后没有任何坏处,所增加的大小为0,而且有了 binary_function 里的三种数据类型,这三个数据类型可以回答适配器的问题。

所以当我们想要我们写的 functors 可以被修改,被适配就需要选择上面两种中合适的一种去继承。

总结: 仿函数就是一个类或结构体里面重载小括号,这样的类或结构体创建出来的对象就叫做函数对象或仿函数。在大大小小的算法中,我们为了搭配算法就会写出相应的仿函数出来。但如果我们没有去继承上面两种类的话,在未来就没有被改造的机会。如果我们想要我们写的 functors 能融入到STL的体系中去,就必须选择其中合适的一种去继承。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值