侯捷C++八部曲笔记(二、STL标准库和泛型编程)
- STL
- 容器
-
- 分配器
-
- 迭代器
-
- 算法
- accumulate(InputIterator first, InputIterator last, T init)
- lower_bound(ForwardIterator first, ForwardIterator last, T target)
- upper_bound(ForwardIterator first, ForwardIterator last, T target)
- binary_search(ForwardIterator first, ForwardIterator last, const T& value)
- for_each(InputIterator first, InputIterator last, Function f)
- replace(ForwardIterator first, ForwardIterator last, const T& old_value, const T& new_value)
- replace_if(ForwardIterator first, ForwardIterator last, Predicate pred, const T& new_value)
- replace_copy(ForwardIterator first, ForwardIterator last, OutputIterator result, const T& old_value, const T& new_value)
- count(InputIterator first, InputIterator last, const T& value)
- count_if(InputIterator first, InputIterator last, Predicate pred)
- find(InputIterator first, InputIterator last, const T& value)
- find_if(InputIterator first, InputIterator last, const T& value)
- sort(InputIterator first, InputIterator last, Function f)
- 仿函数
- 适配器
-
- STL周围的东西
-
- OOP和GP(泛型编程)的区别
- 操作符重载
STL
Standard Template Library 六大部件:容器、算法、仿函数、迭代器、适配器、分配器 容器是class template 算法是function template 迭代器是class template 仿函数是class template 适配器是class template 分配器是class template
容器
前闭后开 大致分为两种容器:序列容器,关联容器 所谓关联容器,就是key和value的结构 序列容器:array、vector、deque、list、forward-list 关联容器:set、multiset、map、multimap,实现使用红黑树(高度平衡二叉树)做的 C++11中有Unordered容器,但他也属于关联容器,实现使用hash table做的
array
是C语言本来就有的东西,为什么要把他包装成容器?因为要让他有容器的行为,要有迭代器,才能配合算法。必须指定大小,不能拓展。没有构造函数,没有析构函数!
array<long, msize> c;
c.size();
c.front();
c.back();
c.data();
qsort(c.data(), msize, sizeof(long), compareLong);
bsreach(&target, c.data(), msize, sizeof(long), compareLong);
vector
在变量定义的行,可以不要缩进,这样就有辨别度 容器容量按2倍增长,扩充的时候需要找到和当前容量两倍大的连续的内存才行
动态增长数组。 有三个迭代器,所以sizeof(vector)的大小为12。 在进行增长的时候,会调用大量的构造函数
vector<int> c;
c.push_back(3);
c.size();
c.capacity();
c.front();
c.back();
c.data();
auto it = ::find(c.begin(), c.end(), target);
list
双向链表。看源码,只有一个protected类型的数据node,这是一个__list_node结构的指针,这个结构中包含list节点的值,指向前一个节点和后一个节点的指针,比如说list的list.begin()就是可以是这个node;还包含一个迭代器,这个迭代器就是一个pointer like,重载了一些操作符,包括:前++,后++,->,*。值得注意的是,这些容器都会模拟int型的操作方法,所以list不允许后++两次。因此,后++的重载是返回self类型,而不是self&。 上面这个图的设计有这么两个问题:1.__list_node中prev和next使用的是void*类型的指针,而不是一个确切的__list_node类型,传入的值会做一个类型转化;2.iterator的定义有三个模板参数,即T,T&,T*,这个显然是传一个T就够了,只要在模板类进行推导就行。 针对这个两个问题,GNU4.9作出了改进!
为什么要有这么多typedef? 一定要有5个typedef,也就是下图中括号行。这就牵扯到了迭代器的Traits设计。 迭代器需要遵循的原则:迭代器是连接容器和算法的中间件,因此迭代器必须能够回答算法的一些提问,这样算法才能更好的对容器进行操作。这5种迭代器关联类型分别为:pointer、reference、iterator_category(迭代器类型,比如说随机存取)、value_type(值存放类型)、difference_type(容器两个元素之间的距离类型)。
类中才能typedef,那如果一个迭代器不是一个类呢,他就不能回答算法的问题了?因此,会引入Traits机作为中间层,接收类迭代器和指针迭代器,会做相应的工作(模板偏特化),得到指针的5种迭代器关联类型。
list<int> c;
c.max_ize();
c.size();
c.front();
c.back();
c.push_back(2);
c.sort();
forward_list
单向链表,只提供头插法,尾插效率太低,计算size效率太低,寻找back效率太低,不提供。
forward_list<int> c;
c.push_front();
c.max_size();
c.front();
deque
类似vector,两边都能扩充,逻辑上是连续的,但是物理上是分段连续的。有一个map来存放这些段。当一个buffer使用完的之后,会有一个新的buffer,这个buffer的地址放在map中。这个buffer一次分配多大呢?这会影响到这个容器的效率~
map会将这些分段的node给串起来。 node指向map的哪一块 first和last指的是node的边界 cur指向当前node的元素 还会有一个start和finish的迭代器,保存双向队列的两端头元素。 map不够大,也会二倍扩充。
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();
queue
queue<int> c;
c.push(2);
c.pop();
c.front();
c.back();
c.size();
stack和queue没有自己的数据结构,是借用deque,是容器适配器,不提供迭代器,不提供遍历操作。 源码中queue和stack提供第二个模板参数来指定实现stack和queue的容器,可以使用list和deque,不能够使用vector、set、map实现
RBTree
关联式容器,通过key来找值,可以看成是小型数据库。set的key既是key也是value。 使用红黑树,有5个模板参数。sizeof(RBTree)=12(GNU2.9)。为什么要在右下角放双向链表呢?因为红黑树和双向链表都有一个不用的节点,双向链表是end后面的节点,红黑树是头节点。就相当于之前刷题里面的dummyhead。 第三个模板参数是指定如何取出key。
multiset
multiset<int> c;
c.insert(2);
c.begin();
c.end();
c.size();
c.max_size();
c.find(target);
multimap
通过key来排序,查找
multimap<int, int> c;
c.insert(pair<int, int>(2, 3));
c.begin();
c.end();
c.size();
c.max_size();
c.find(target);
set
map
这两种方法和前面的multi**使用方法一样,只是不允许有重复的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是比较函数,sizeof(hashtable)=20。使用hashtable最困难的事决定使用什么hash函数 具体写一个使用hashtable的例子: 标准库没有提供现成的hashstd::string
hashtable<
const char*,
const char*,
hash<const char*>,
identity<const char*>,
eqstr,
alloc>
ht(50, hash<const char*>(), eqstr());
unordered_multiset
unordered_multiset<int> c;
c.insert(2);
c.size();
c.max_size();
c.bucket_count();
c.load_factor();
c.end();
c.begin();
c.find(target);
unordered_multimap
使用hash表,冲突采用拉链表法. 如果元素的大小和bucket的大小一样大了,就会扩充bucket的大小
unordered_multimap<int, int> c;
c.insert(pair<int, int>(2, 3));
c.size();
c.max_size();
c.bucket_count();
c.load_factor();
c.end();
c.begin();
c.find(key);
容器之间的实现关系
分配器
allocator 容器需要一个东西来支持它对内存的使用,这个东西就是分配器,最好的情况下,我们不需要知道这个东西,所以需要一个默认的分配器。 比如说vector的模版定义如下,会有一个默认的分配器std::allocator<_Tp>,如果不指定分配器,就会默认使用这一个
template<typename _Tp, typename _Alloc = std::allocator<_Tp>>
class vector : protected _Vector_base<_Tp, _Alloc>
有以下分配器(不在标准库里面):
array_allocator
__mt_allocator
debug_allocator
__pool_allocator
bitmap_allocator
malloc_allocator
new_allocator
一般不推荐自己指定分配器,如果自己指定了不好的分配器,会影响容器的性能
operator new & malloc
C++层面用operator new C层面用malloc operator new会调用malloc 我们要求的是青色部分空间的大小,但是malloc会在青色部分外包裹其他东西。因此,会产生一些额外开销(如果要分配的区块小,那么额外开销就相对较大,不能忍受),那么这个问题有解决方案吗? allocator类中最重要的两个方法:allocate(分配内存,调用operator new,operator new调用malloc),deallocate(回收内存,调用operator delete,operator delete调用free)
卧槽,玩我呢?说了这么半天,上面这些方法是弃用的,没有任何容器使用上面的内存分配策略。
GNU2.9使用的是alloc,解决了上面的疑问(额外开销的问题)!实现行为如下,主要的思路就是减少malloc的次数,因为malloc一次就会附带一个头尾。 这些额外开销有什么用呢?00000041是两个cookie,保存了这个内存块的大小。但是对于容器来说,容器的一个块大小是固定的,有必要使用这个cookie吗?可以不要。 16条链表,负责不同大小的内存分配。8字节对齐。每个内存块不会都带cookie。只会在链表的头尾有cookie。
但是GUN4.9又用回allocator了!!!!!为什么呢?因为候捷也不知道!!!__pool_alloc就是前面说的GNU2.9的alloc
迭代器
前面说到迭代器中必须要有5种关联类型:pointer、reference、iterator_category(迭代器类型,比如说随机存取)、value_type(值存放类型)、difference_type(容器两个元素之间的距离类型)。
那么现在来详细讲讲这5种关联类型。
iterator_category
也有五种迭代器类型:随机存取迭代器(array、vector、deque)、双向迭代器(list、红黑树容器)、单向迭代器(forward_list,hash类容器)、输入迭代器(istream迭代器),输出迭代器(ostream迭代器)。
既然是tag,为什么要用有继承关系的class来实现呢?方便迭代器类型作为参数进行传递,如果是整数的是不方便的;还有一个原因,有些算法的实现没有实现特定类型的迭代器类别,就可以用继承关系去找父迭代器类别。
iterator_category对算法的影响
以这个distance函数为例,会根据迭代器的类别来调用不同的具体实现函数,一个是只包含一个减法操作的语句,一个是包含一个while循环的语句,可想而知,当真实距离很大时,有while循环的具体实现函数效率会非常低下。 看一个特别能体现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);
accumulate(nums, nums+3, init, minus<int>());
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)
默认从小到大排序,也可以指定自己的比较函数,可以是仿函数,可以是函数,仿函数必须传入该仿函数的实例。
list和forward_list自带sort方法
仿函数
只为算法服务
有三种仿函数:算术类(+、-、*、/等)、逻辑运算类(&&、 ||等)、相对关系类(返回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机要回答迭代器的问题一样。
适配器
可以把它理解为改造器,它要去改造一些东西;也可以理解为实现换肤功能。 已经存在的东西,改接口,改函数名等。。。
有:仿函数适配器、迭代器适配器、容器适配器 实现适配,可以使用继承、复合的两种方式实现。 共性:STL使用复合来实现适配
容器适配器
包括stack、queue
仿函数适配器
bind2nd
可以看到下面的这个例子,使用算法count_if,其中第三个参数是一个predicate,也就是判断雕件,有一个仿函数对象less<int>(),但是他被仿函数适配器bind2nd(将less的第二个参数帮定位40)和not1(取反)修饰,从而实现判断条件为是否小于40。 bind2nd调用binder2nd。 图上灰色的东西就是仿函数适配器和仿函数之间的问答!这里就体现了仿函数为什么要继承适合的unary_function或者binary_function等类的原因! 还有一个细节:适配器适配之后的仿函数也能够继续被适配,所以适配器要继承unary_function或者binary_function等类,这样才能回答另外一个适配器的问题。 所以,仿函数必须能够回答适配器的问题,这个仿函数才是可适配的!
not1
对一个Predicate取反
bind
替换了一些过时的仿函数适配器 可以绑定:
- functions
- function objects
- member functions, _1必须是某个object的地址
- data members, _1必须是某个object的地址
返回一个function object ret。调用ret相当于调用上述的1,2,3或者相当于取出4.
double my_divide(double x, double y){
return x / y;
}
---------------------绑定function,也就是前面的1---------------------
using namespace std::placeholder;
auto fn_five = bind(my_divide, 10, 2);
cout << fn_five() << endl;
auto fn_five = bind(my_divide, _1, 2);
cout << fn_five(10) << endl;
auto fn_five = bind(my_divide, _2, _1);
cout << fn_five(10, 2) << endl;
auto fn_five = bind<int>(my_divide, _2, _1);
cout << fn_five(10, 2) << endl;
---------------------绑定member functions,也就是前面的3---------------------
struct MyPair{
double a,b;
double multiply(){ return a * b; }
}
Mypair ten_two { 10, 2 };
auto bound_menfn = bind(&MyPair::multiply, _1);
cout << bound_menfn(ten_two) << endl;
---------------------绑定member data,也就是前面的4---------------------
auto bound_mendata = bind(&MyPair::a, ten_two);
cout << bound_mendata() << endl;
auto bound_mendata = bind(&MyPair::b, _1);
cout << bound_mendata(ten_two) << endl;
迭代器适配器
reverse_iterator
reverse_iterator
rbegin(){
return reverse_iterator(end());
}
reverse_iterator
rend(){
return reverse_iterator(begin());
}
也有五种关联类型,感觉适配器的使用就是要定义关联类型。 取值行为会进行调转,就如源码中的++、–操作
inserter
可以不用担心copy到的目的容器大小不匹配的问题。 copy是写死的,我们调用copy,希望完成在容器指定位置插入一些值,但是是copy的行为! 具体的实现:把相应的容器和迭代器传入inserter,对容器的迭代器中的=运算符进行重载,就能改变copy的行为!太特么妙了啊! 因为这个是对迭代器的=运算符行为进行重定义,所以是迭代器的适配器。
X适配器
包括ostream、istream迭代器适配器,为什么是X,因为不好归类
ostream_iterator
适配的是basic_ostream,也是重载=运算符,添加输出操作。
istream_iterator
cin>>x被替换为了x=*iit,适配basic_istream
STL周围的东西
hash function
hash code希望越乱越好
如果有一个自己的类,我们要怎么给这个类设计hash函数呢? 第一种想法,使用类中的成员变量的hash函数得到hash值,然后相加,这个太naive了,可能会产生很多冲突。 所以用下面的实现方法: args是C++11的新特性,任意多个参数都行,n个参数的args作为另外一个函数的输入的时候,会拆分成1 + n-1的形式。 也是使用想法一的思想,但是加入了更多的复杂的操作,使得得到的hash code冲突更少。
0x9e3779b9是什么呢?黄金比例!
tuple
一堆东西的组合
用例
tuple<string, int, int, complex<double>> t;
sizeof(t);
tuple<int, float, string> t1(41, 6.3, "test");
get<0>(t1);
get<1>(t1);
get<2>(t1);
auto t2 = make_tuple(22, 44, "test2");
tuple_size< tuple<int, float, string> >::value;
tuple_element< tuple<int, float, string> >::type;
继承的是自己,会自动形成一个类的继承关系,注意有一个空的tuple类。
type traits
泛化模板类,包括五种比较重要的typedef 默认构造函数重要吗? 拷贝构造函数重要嘛? 拷贝赋值构造函数重要嘛? 析构函数重要嘛? 是不是旧格式(struct,只有数据,没有方法)? 默认的回答都是重要的!
比如说对于int的ttype traits,五个问题的回答都不重要。一般是算法会对traits进行提问。 实用性不高。
现在的traits机,非常智能,只要把自己写的或者系统自带的类,作为is_()::value就能得到问题的答案,这些问题包括下面几种,不全: 这么强的功能,是怎么实现的呢?下面以is_void为例: 首先去掉const和volatile这两种对得到类特征无用的修饰关键字,做法如下(主要是用模板技巧); 然后将去掉cv关键字之后,传入__is_void_helper模板类中,让其自己匹配是不是空类型,匹配到不同的模板类,返回不同的bool值。
cout
是一个对象,不是一个类,源码如下: 想要用cout输出自己的类型,就可以重载<<运算符。
moveable元素对各种容器的速度效能影响
moveable指的是move构造、move赋值 move():是一种浅层拷贝,当用a初始化b后,a不再需要时,最好是初始化完成后就将a析构,使用move最优。 如果说,我们用a初始化了b后,仍要对a进行操作,用这种浅层复制的方法就不合适了。所以C++引入了移动构造函数,专门处理这种,用a初始化b后,就将a析构的情况。这种操作的好处是:将a对象的内容复制一份到b中之后,b直接使用a的内存空间,这样就避免了新的空间的分配,大大降低了构造的成本。这就是移动构造函数设计的初衷。 移动构造函数实现是:调用拷贝构造函数,但是会将原来的对象中的成员变量置0!这样就不会调用原对象的析构函数了!如下图加深的部分,而且用的是引用的引用&&!&&是右值引用,右值有一个很重要的性质:只能绑定到一个将要销毁的对象
move的使用场景是:原来的对象不再使用。 调用移动构造函数方法,显示调用move:classObj_1(std::move(classObj_2))
对vector: 3000000个元素为什么调用了7194303次构造函数?因为vector会进行空间增长,空间增长的时候,会把原来内存的值复制到新的内存取,这个时候就要调用构造函数。
OOP和GP(泛型编程)的区别
OOP是把数据和方法放在一个类里面 GP是把数据和方法分开,这么做有什么好处呢?容器和算法可以闭门造车,通过迭代器产生关联;
指定比较方法时,什么时候用仿函数,什么时候用函数呢? 使用容器指定比较方法时用仿函数,使用算法指定比较方法时使用函数。
所有算法,在操作容器的元素的时候,无非就是比较大小。
操作符重载
|