侯捷C++八部曲笔记(二、STL标准库和泛型编程)

侯捷C++八部曲笔记(二、STL标准库和泛型编程)

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

在这里插入图片描述

array

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

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); //二分查找,指定查找对象

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();//list容器中自己的sort,调用的是标准库种的sort,这个和容器特性有关,那么为什么要这么设计呢?因为list的内存分配不连续,而::sort()里面是针对连续空间实现的排序算法,因此::sort不实用list排序

forward_list

单向链表,只提供头插法,尾插效率太低,计算size效率太低,寻找back效率太低,不提供。

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

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);//内置find,红黑树

multimap

通过key来排序,查找

multimap<int, int> c;
c.insert(pair<int, int>(2, 3));//会放在该放置的位置,放进去就会根据key被排序
c.begin();
c.end();
c.size();
c.max_size();
c.find(target);//内置find,红黑树
//无c.front()
//multimap不能用下标[]来插入值

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 //multi thread
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);//不指定具体怎么操作,默认为加法,输出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)

默认从小到大排序,也可以指定自己的比较函数,可以是仿函数,可以是函数,仿函数必须传入该仿函数的实例。

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

替换了一些过时的仿函数适配器
在这里插入图片描述
可以绑定:

  1. functions
  2. function objects
  3. member functions, _1必须是某个object的地址
  4. 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;// 输出5

auto fn_five = bind(my_divide, _1, 2);
cout << fn_five(10) << endl;// 输出5

auto fn_five = bind(my_divide, _2, _1);
cout << fn_five(10, 2) << endl;// 输出0.2

auto fn_five = bind<int>(my_divide, _2, _1);
cout << fn_five(10, 2) << endl;// 输出0,因为指定了返回类型

---------------------绑定member functions,也就是前面的3---------------------
struct MyPair{
	double a,b;
	double multiply(){ return a * b; }
}
Mypair ten_two { 10, 2 };

//member function其实有一个this指针
auto bound_menfn = bind(&MyPair::multiply, _1);
cout << bound_menfn(ten_two) << endl;// 输出20


---------------------绑定member data,也就是前面的4---------------------
auto bound_mendata = bind(&MyPair::a, ten_two);
cout << bound_mendata() << endl;// 输出10

auto bound_mendata = bind(&MyPair::b, _1);
cout << bound_mendata(ten_two) << endl;// 输出2

迭代器适配器

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); // 32, 为什么是32,而不是28呢?啊~侯捷也不理解啊!

tuple<int, float, string> t1(41, 6.3, "test");
get<0>(t1); // 41
get<1>(t1); // 6.3
get<2>(t1); // test

auto t2 = make_tuple(22, 44, "test2"); // t2也是一个tuple

tuple_size< tuple<int, float, string> >::value; // 3
tuple_element< tuple<int, float, string> >::type; // 取tuple里面的类型

继承的是自己,会自动形成一个类的继承关系,注意有一个空的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是把数据和方法分开,这么做有什么好处呢?容器和算法可以闭门造车,通过迭代器产生关联;

指定比较方法时,什么时候用仿函数,什么时候用函数呢?
使用容器指定比较方法时用仿函数,使用算法指定比较方法时使用函数。

所有算法,在操作容器的元素的时候,无非就是比较大小。

操作符重载

在这里插入图片描述

  • 12
    点赞
  • 81
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值