C++知识树整理——C++STL体系结构和内核分析
C++STL体系结构和内核分析
-
- STL包含六大部件
- C++ Standard Library包含了STL的所有内容并且包含了一些零碎的组件,STL等于C++SL的子集
-
-
容器(Containers)
-
结构分类
-
顺序容器
-
Array
- Size: 12
- 使用时需明确要指定大小,在创建时就申请了指定大小的空间
-
Vector
-
Size:
iterator start; //指针 指向数据头 iterator finsh; //指针 指向数据尾 iterator end_of_storage; //指针 指向目前这个vector容器的大小 //size = 4+4+4 = 12
-
前闭后开,头指针指向头数据位置,尾指针指向最后一个数据的下一个位置
-
创建时对内存空间进行检测,如果原大小为0则是新new数据,默认开辟空间为1。
-
当备用空间已经不足时,开辟空间为原开辟空间*2,前半段用来放置原数据,后半段准备用来放置新数据。之后将前半段数据复制到新开辟的空间中(所以当原数据量已经很大时,复制的开销也大)之后调整迭代器指向新vector
-
开辟的内存大小随着使用动态分配,内容量越大就开辟越多,但是不会自己缩小回来。
-
gc2.9时迭代器就是一个基本的指针T*,gc4.9封装成了一个对象,本质没区别,都是和指针一样逻辑
-
-
Deque(双向队列)
-
Size:
iterator: T* cur; //当前的位置 T* frist; //当前buffer的起始点 T* last; //当前buffer的结束点 T* node; //指向最上层的buffer容器的位置 //iterator size = 4+4+4 = 16 iterator start; //指针 指向数据头 iterator finsh; //指针 指向数据尾 size_type map_size; //size_type大小也是4 map大小 map_pointer map; //指针的指针 buffer的指针 //size = 16*2+4+4 = 40
-
双端可拓展容器
-
迭代器的核心如下,当迭代器移动到last的位置时,会自动根据node找到下一个buffer的位置跳到指定位置,模拟成连续的空间(所以对算法询问宣称连续实际上实现并不连续),当指定的指针是itrators+=i ,i>1时,如果跳的大小在buffer内就直接移动就行,如果超过了last,就要先计算跨越了几个buffer的size,再根据剩余的差值将指针移到新的buffer的新的下标上
T* cur //当前的位置 T* frist //当前buffer的起始点 T* last //当前buffer的结束点 T* node //指向最上层的buffer容器的位置
-
当预分配的空间不够时会再去new一块新的内存空间,再将数据居中拷贝到新的空间内,保证双端都有富余的空间进行数据插入,不然可能又要分配空间
-
内部维护了一个
map_pointer map
里面是指针数组,每个指针指向了一个buffer -
再G2.9中,每个buffer分配的大小,如果有预设值,则使用预设值,如果没有,则判断对象的大小是否小于512,如果小于则返回大小为512/size,如果大于512则传回1,每个buffer只缓存一个对象空间大小,而在GC新版本,已经没有预设值这个选项了,虽然对外封闭了但是从定制化角度的话还是稍稍逊色。
-
-
List(双向链表)
-
Size:
//早期版本只有一个指针node指向尾部空节点 容器size为4 list_node*<T> node; //最新版本分为头尾指针分别指向空节点的头尾,两个指针,size为8 list_node_base* _M_next; list_node_base* _M_prev;
-
环状双向链表,正向next 逆向prev
-
-
Forward-List(单向链表)
- Size:指向尾部空节点,一个指针,大小4
- 只有next没有prev,所以不供应operator--()
-
Queue(队列)
- 先进先出
-
Stack(堆栈)
- 后进先出
-
Queue和Stack共通点
- Size:底层就是deque容器的大小,40
- 底层封装了一个deque,并且封闭了deque的某些方法,只支持这个容器的特定操作
- 都不允许遍历,不提供iterator
- 实际上是容器Deque的适配器实现
- 底层封装的容器也能替换为List,实际上并不是一定要为deque,也能替换为其他容器,但是需要支持对应的方法,且底层用的queue代表用deque容器在性能上肯定是最好的。
-
-
关联容器(底层都是用红黑树实现的,并不是因为只能用红黑树而是红黑树是这个数据结构下的一种很好的解决方案,所以大部分语言都是用这种解决方式,红黑树其实就是一个自平衡二叉树,在数据插入的时候会自动维持二叉树的平衡 不会出现某个分支过长)
-
RB_Tree(红黑树)未公开的容器,stl私有的容器
-
Size:
//旧版本 size_type node_count;//size4 rb_tree大小 rb_tree_node* hader;//指针 不是根节点,指向根节点 Compare key_compare;//key大小比较准则 function obj,理论上是0,但是编译器实现上基本都是1(c++空类默认1字节) //size = 4+4+1 = 9,按4位对其就对其最近的12,所以size = 12 //新版本实现大改 Rb_tree_node_base Rb_tree_color _M_Color; Base_Ptr base_Parent; Base_Ptr base_left; Base_Ptr base_right; //size 3个指针+1个color枚举 //size 4*3+12 = 24
-
红黑树是一个平衡的二叉搜索树(详见数据结构:红黑树)
-
iterators++遍历是中序遍历,获得的就是排序状态的元素
-
rb_tree提供两种insertion操作:insert_unique()和insert_equal(),前者表示节点的key一定是唯一的,否则安插失败,但是不会报错和抛出异常,就是单纯的没做事。后者表示支持重复的key
-
rb_tree声明
rb_tree< int, //key int, //value identity<int>, //怎么从value中获取到key less<int>, //两个value怎么比较 alloc //分配函数
-
-
Set/Multiset(key is value)
- Size:内部封装了rb_tree,rb_tree多大就多大,所以老版本12新版本24
- 底层就是一个rb_tree,rb_tree多大就多大,所以老版本12新版本24
- 底层是rb_tree,所以元素具有自动排序特性
- set的元素必须独一无二所以insert调用的是insert_unique()
-
-
-
multiset 元素可以重复所以insert调用的是insert_equal()
- 所有操作其实都调用的是rb_tree操作,从这层意义上看,其实set也是一个容器适配器-
Map/Multimap(键值对)
- Size:内部封装了rb_tree,rb_tree多大就多大,所以老版本12新版本24
- 底层也是一个rb_tree,所以元素具有自动排序特性,排序的依据是key
- 无法使用iterators改变元素的key,但可以用它来改变元素的data,所以map内部自动将用户指定的key加上了const,就禁止了用户对key进行赋值
- Map的元素必须独一无二所以insert调用的是insert_unique()
-
-
MultiMap 元素可以重复所以insert调用的是insert_equal()
- Map特定的支持[]底层的操作符重载,先通过二分查找在data中搜寻value,找到则返回,如果没找到会寻找到data中最适合插入该元素的位置,再进行insert。所以相比于传统的insert,这里会先进行二分查找再insert,效率上会低,但是写法上会简洁明了。-
无序容器(底层是用hash Tab实现的)
-
hashTable:
-
Size:
hasher hash;//hash函数 key_equal;//比较函数 ExtractKey;//怎么从data中获得key Vector<node*,alloc> buckets;//数据存放的链表 size_type num_elements;//元素大小 //size = 1+1+1+12+4 = 19 对齐后 20
-
hash散列可能会造成碰撞,当碰撞后可以选择继续散列放在其他位置,但是可能会继续碰撞,散列表方式是通过一个散列表的形式在碰撞点做一个链表来保存(详见数据结构:hashTab)
-
所以如果当element的大小等于size的篮子大小时,代表再添加元素必定会发生碰撞,所以这时会扩充篮子的大小,大小近似2倍,是内部定死的质数值,之后将之前的数据重新计算hashcode,放进新的篮子的位置中,所以扩充时是一次开销。
-
-
Unordered Set/Multiset(key is value)
- size就是hash Table大小20
-
-
-
相比于Set,无序
- 其他和Set一致-
Unordered Map/Multimap(键值对)
- size就是hash Table大小20
- 相比于Set,无序
-
其他和Set一致
-
-
根据实际使用情况选择合理的时间复杂度的容器
-
-
分配器(Allocators)
- VC BC 和GC新版本的allocator只是以::operator new 和::operator delete完成allocate()和deallocate(),内部就是malloc和free,没有任何特殊设计,其中分配对象空间时会在生成对象空间大小的同时头和对象尾部各自生成一个4字节的cookie,标记这个对象的长度等信息。如果new了100w个对象就多了800w位的额外空间
- GC老版本的内存分配是用了自用的alloc,内部维护了16个的链表,分配方式是按8位对其,之后是16,再来是24...最高位128,内部对申请的空间进行切分,分配的空间是对象实际的空间大小(并且补齐8位的空间),没有额外的头尾的cookie的浪费。
-
迭代器(Iterators)
- iteraor需要遵循的原则(必须提供的5种 associated type)
- 迭代器必须有能力回答算法的提问(调用)这样的提问C++标准库开发中设计出五种,三种常用,refreence和pointer并未被使用过
- iterator_category //迭代器的分类
- value_type //容器中存放数据的类型
- difference_type //迭代器之间的距离,即两个迭代器间包含的元素
- pointer (没被用过不知道)
- reference (没被用过不知道)
- 迭代器的分类
- 5种iterator category
- input_iterator_tag <——forwar_iterator_tag <——bidirectional_iterator_tag <——random_access_iterator_tag
- 独立的output_iterator_tag(osteram_iterator)
- 5种iterator category
- 迭代器萃取器
- 以上所述迭代器都是 class, 但是有时 所用迭代器是指针,比如此时的容器元素不是连续空间,如果是连续空间的容器如array和vector,迭代器可以使用纯指针,指针可以说是退化的迭代器。
- 那一个算法使用的时候如何区别使用不同的迭代器呢,那就需要通过Traits来实现。称为迭代器的萃取机Traits
- 如果传入traits的是类迭代器,则用询问的方式获取迭代器的实现方法
- 如果传入的是指针或者const指针类型,就用萃取器自己实现的一套封装的方式来应对算法提出的问题。
- iteraor需要遵循的原则(必须提供的5种 associated type)
-
算法(Algorithms)
-
STL的算法和容器是互相分离的,算法看不见容器的内部设计,他需要的一切信息都是依托于容器的迭代器取得的,所以迭代器必须要能够回答算法的“询问”,所以就有了萃取器的概念,算法内部根据不同的迭代器类型做不同的操作。
-
-
算法内部通过特化的方式,对不同的迭代器做了不同的方法重载,造成了有些类型的迭代器很快就能计算出来的结果在不同类型的迭代器上效率有很大的区别,比如如果是random类型的迭代器,两个迭代器之间的距离end - frist就能得到,而传统的input迭代器则是遍历每个元素做个count++,效率上区别很大。
-
仿函数(Functors)
-
通过操作符重载复写()方法,使对象能够类似函数一样被func()调用,就是仿函数
-
stl的标准库的仿函数就是复写了()操作符,不过他们和我们写出来的仿函数还多继承了两种类,binary_function和unary_function,只有继承了这两个其中的一个,才能融入stl库中,否则往后继续就会报错,这是因为这两个类里实现了可适配Adapter的条件,使得仿函数能够回答Adapter的询问。
-
适配器(Adapter)
- 适配器是什么?
- 在GOF的《设计模式:可复用面向对象软件的基础》中是这样说的:将一个类的接口转换成客户希望的另外一个接口。适配器模式使得原本由于接口不兼容而不能一起工作的那些类可以一起工作。好比日本现在就只提供110V的电压,而我的电脑就需要220V的电压,那怎么办啦?适配器就是干这活的,在不兼容的东西之间搭建一座桥梁,让二者能很好的兼容在一起工作。
- 所以将一个类的接口转换成客户希望的另外一个接口。Adapter 模式使得原本由于接口不兼容而不能一起工作的那些类可以一起工作。
- 适配器的分类
- 类的适配器
- 直接继承自Adaptee类,所以,在Adapter类中可以对Adaptee类的方法进行重定义
- 如果在Adaptee中添加了一个抽象方法,那么Adapter也要进行相应的改动,这样就带来高耦合;
- 如果Adaptee还有其它子类,而在Adapter中想调用Adaptee其它子类的方法时,使用类适配器是无法做到的。
- 对象适配器
- 有的时候,你会发现,不是很容易去构造一个Adaptee类型的对象;
- 当Adaptee中添加新的抽象方法时,Adapter类不需要做任何调整,也能正确的进行动作;
- 可以使用多肽的方式在Adapter类中调用Adaptee类子类的方法。
- 代表这个对象拥有另一个对象且封装了他
- 类的适配器
- STL中adapter的应用
- 容器适配器
- 内置了其他的容器,选择性开放,关闭,更改容器的接口,本质都是调用被封装的容器来完成功能
- Stack
- Queue
- ...
- 仿函数适配器
- 一个通用化模板,其中通过问询binary_function和unary_function来让编译器自动的推导出要适配的对象进行泛型适配
- bind
- Not1
- 迭代器适配器
- 通过操作符重载修饰迭代器操作符
- inserter
- X适配器
- 实现上也是通过操作符重载修饰迭代器操作符
- ostream_iterator
- istream_iterator
- 容器适配器
- 适配器是什么?
-
-
-
opp企图将datas和methods关联在一起
-
GP欲是将datas和methods分开
-
-
-
类模板
- 等同于C#的泛型Class
- 等同于C#的泛型Class
-
函数模板
-
和c#的Func
不同,这里的函数模板是编译器会通过调用的参数进行实参推导,比如 template <class T> inline const T& min(const T& a,const T& b) { return b < a ? b : a }
如果对象是int,double则正常比较,如果对象是class,则会检查是否村存在操作符重载'<'方法,有则调用
-
-
成员模板
-
特化
- 如定义了模板
template <class T> class XXX
后如果想要为更细分的某个对象进行特殊化处理就可以用template<> class XXX<int>
做特殊化的专门处理
- 如定义了模板
-
偏特化
- 部分参数偏特化
template <class T> class<bool,T>{...}
- 范围偏特化
template <class T> class XXX
指针范围特殊处理template <class T*> class XXX<T*>
- 部分参数偏特化
-
-
-
tuple(元组)
-
用法:
tuple<int,float,string>t1(41,6.3,"name"); count<<"t1:"<<get<0>(t1)<<' '<<get<1>(t1)<<' '<<get<2>(t1)<<endl; //还有很多用法这里不举例
-
实现原理:
底层利用了C++新版本的特性,
template<tempname head, typename... Tail>
将元素分成一个和一堆,继续递归自身,直至最后调用到0个参数的方法和1个元素的方法。层层递归后让编译器推导继承关系,拿tuple<int,float,string>举例:tuple<> <—— tuple<string> <--tuple<float,string> <-- tuple<int,float,string>
-
-
Type traits
-
用法:
官方提供的类型萃取器,可以通过问询的方式获得他是否是一个类?是否有构造函数、虚函数、析构函数、虚析构函数、拷贝赋值、拷贝构造等等应有尽有,返回的是1(true)和0(false)
-
实现原理:
本质上也是利用了模板的特化,比如拿is_intergral举例:
template <typename> struct __is__intergral:public false_type { //写一个泛型默认都是false } template <> struct __is__intergral<long>:public true_type { //对long类型特化指定为true } template <> struct __is__intergral<int>:public true_type { //对int类型特化指定为true } ...//可以看到就是通过特化的形式来对stl库中的类型给出特化定义来达成实现
而对于is_class,is_union等等这种函数,stl库也没给出具体实现,侯捷大佬推断是在编译器阶段进行推演的,编译器肯定能知道你是不是class等等信息,所以就能够完成实现。
-
-
count
简单来说就是封装的ostream,然后对于不同类型的对象都有不同参数的操作符重载<<使得count支持各式各样的输出
-
moveable元素对于容器效率的影响
- 对于线性存储的容器,使用了moveable和未使用moveable的区别是巨大的,甚至move可以快上10几倍,为什么呢,因为moveable类似浅拷贝操作,只是对指针进行移动,而传统的copy则是在申请新空间然后进行值的搬移,根本的区别是这样。
- 对于链式存储的容器(包含deque,这货声明线性其实也是链式的)区别不大,因为本身他们也是基于指针实现的。
- 也是因为使用了指针的缘故,会导致原来容器的指针废弃,所以move的先决条件是不能再使用之前的老对象了。
- 再深入的探索放在C++11的笔记里,那里面会深入探索moveable这个新语法,还有moveable的使用限制。
-