STL源码剖析笔记

目录

第二章:空间配置器

预备知识

std::allocator与std::alloc

详解std::alloc

空间配置

空间释放

构造与析构基本工具:construct()和destroy()

内存基本处理工具:uninitialized_copy()、uninitialized_fill()、uninitialized_fill_n()

小结

第三章 迭代器概念与traits编程技法

前言

迭代器相应型别与Traits编程技法

相应型别之iterator_categoty及其实现技巧

__type_traits技术

第四章 序列式容器

vector

vector中的迭代器失效问题

vector中的迭代器

list

list的节点定义

list的迭代器

list的数据结构

deque

deque的迭代器

deque的数据结构

slist

slist的节点与迭代器

adapter(配接器)

stack

queue

priority_queue

第五章 关联式容器

前言

RB-tree

树的旋转

RB-tree上节点的插入

RB-tree的相关设计

set、map、multiset与multimap

hash table

hash table相关设计

hash_set、hash_map、hash_multiset与hash_multimap

第六章 算法

STL算法的一般形式

copy--强化效率无所不用其极

第七章 仿函数

第八章 配接器

前言

container adapters(容器配接器)

iterator adapters(迭代器配接器)

function adapters(函数配接器)

总结


第二章:空间配置器

预备知识

首先我们来了解new operator(new运算符)与allocator的区别。new有一些灵活性上的局限,具体之一就是new将内存分配与对象构造组合在了一起,但有时候我们需要将内存分配与对象构造分离开来,这就需要allocator,allocator是专门用来分配内存的额,其分配的内存是原始的,未构造的。

为了进一步说明new,我们再来详解一下new operator和operator new的区别。new operator就是我们常见的new表达式,当我们使用new表达式,其主要执行了两步操作,第一步调用名为operator new(或者operator new[])函数分配一块足够大的、原始的、未命名的内存空间。第二步调用对象的构造函数完成对象的构造,并返回指向该对象的指针。(C++ Primer说是三步,其实只不过书上的二、三两步合并为上面的第二步)。所以说,operator new是一个函数,它只负责分配内存,不负责对象的构造,其只是new operator(new 表达式)的第一步,new表达式是需要完成第二步对象的构造的。我们常说的重载new和重载delete,其实我们并不能自定义new表达式和delete表达式的行为。但是我们可以重载的是operator new函数以及operator delete函数,也就是说我们可以自定义内存的分配方式,但是不能自定义new表达式的行为。标准库定义了operator new函数与operator delete的8个重载版本,前四个可能抛出异常,后四个承诺不会抛出异常,如下所示。

//这些版本可能抛出异常
void *operator new(size_t);//分配一个对象
void *operator new[](size_t);//分配一个数组
void *operator delete(void*) noexcept;//释放一个对象
void *operator delete[](void*) noexcept;//释放一个数组

//这些版本承诺不会抛出异常
void *operator new(size_t,nothrow_t&)noexcept;
void *operator new[](size_t,nothrow_t&)noexcept;
void *operator delete(void*,nothrow_t&) noexcept;
void *operator delete[](void*,nothrow_t&) noexcept;

至于为什么要有抛出异常和不抛出异常的版本,根据Effective C++第三版条款49的介绍,在早期,operator new必须在无法分配足够内存的时候返回NULL,也就是说其不会抛出异常,而新一代的operator new在无法分配足够内存的时候应该抛出bad_alloc异常。然而很多程序是新规范出来前写的,所以有不少场合还是需要不抛出异常版本的。对于上述的8个函数,其实我们也是可以自定义去重载的,前提就是自定义的版本必须位于全局作用域或者类作用域中。对于operator new函数或者operator new[]函数来说,第一个形参类型必须为size_t且该形参不能含有默认实参。除了上述8个函数,其实我们定义其他版本的operator new,也就是其还可以添加额外的形参,除了仅有的形参只有一个size_t类型的operator new,即void *operator new(size_t);其他版本的operator new都是placement new,即定位new,所以void *operator new(size_t,nothrow_t&)noexcept;是一个placement new。这是placement new的一般定义,即带任何额外参数的new,参见Effective C++第三版条款52。但是大多数时候,我们谈到placement new,指的是它的一个特定版本,如下所示。

void *operator new(size_t,void*);//不允许重载的placement new

特别注意,这个版本的new已经被纳入C++程序库,其是绝对不可以被用户重载的,一般情况下,我们可以自定义具有任何形参的operator new,只要第一个形参是size_t类型即可,但这是特例,决不允许重载。实际上,这个版本的operator new并不会分配内存,它只是简单的返回指针形参。当我们使用定位new表达式的时候,调用的operator new就是void *operator new(size_t,void*),new表达式在该函数返回的指针所指的地址处构造对象。定位new表达式的形式有如下几种:

new (place_address) type
new (place_address) type (initializers)
new (place_address) type [size]
new (place_address) type [size] {braced initializer list}

上面说到,这个特殊版本的operator new并不负责分配内存,其只是简单的返回指针形参,这个指针所指的地址就是上面括号中的place_address。那么它的内存从哪来呢,原来定位new表达式它是在一块已经分配好的内存上构造对象,所以其不用担心内存分配失败,因为其压根不需要分配内存,而且在已经分配好的内存上进行对象构建,构建速度快,同时已分配好的内存重复利用,可以有效的避免内存碎片问题。下面是一个定位new表达式的例子。

void* ptr = ::operator new(20);//调用全局operator new函数分配20byte的内存
int* intptr = new (ptr) int(5);//定位new表达式  在已经分配好的内存上构造对象,new表达式第一步调用的是不允许重载的operator new函数

上面已经具体分析了new operator和operator new的区别,中间顺带讲了placement new。总结这三者:new operator就是new表达式,它的默认行为一共两步,分别是调用operator new分配内存,然后调用对象的构造函数进行对象构造并返回指向对象的指针。而operator new只不过是new表达式的第一步而已,其只负责内存分配。operator new是可以被重载的,这样用户可以自定义内存的分配方式。唯一不能重载的是我们大多数时候说到的placement new版本,即void *operator new(size_t,void*)。当我们使用placement new表达式的时候,其第一步会去调用placement new版本的operator new函数,它仅仅返回一个已经分配好的内存的指针,自己并不负责内存分配,然后由placement new表达式去完成对象的构造

经过上文对new表达式的介绍,我们发现new具有一定的局限性。举例来说,

string *p=new string[100];
for(int i=0;i<5;++i)
  {
      p[i]="abcd";
  }

对于上面的例子,new的时候把100个string对象都已经构造好了,都没默认初始化为空串。而后对前5个string对象,又都赋值为"abcd",也就是说,前5个string被赋值了两次,第一次的默认初始化没有意义。这对于对效率要求极高的STL是不能容忍的,而且有些类没有默认构造函数,这样的话使用new表达式就不能动态分配数组了。所以,STL库使用allocator类,使用里面的allocate与deallocate分别进行内存的分配与释放,使用construct与destroy函数进行对象的构造与析构。也就是说,construct类似于上文的operator new函数,其只负责分配内存(不过,为什么有了operator new,还要有allocate呢?这两者功能不是差不多吗?目前,我的理解是allocate相当于一个接口函数吧,allocator相当于接口类,方便我们编程,其内部的allocate函数实现甚至可能包含operator new,在我看到的一些例子当中可以佐证我的观点,有些allocate内部其实是调用了operator new来分配内存的,例如本书2.1.1节设计一个简单的空间配置器(JJ::allocator),还有SGI STL中标准的空间配置器std::allocator,内部也是对::operator new做了一层薄薄的包装,不过还有一些allocate是调用的malloc等分配内存的,如SGI STL特殊的空间配置器std::alloc的第一级配置器)。不过,operator new的第一个形参为size_t类型,表示要分配的具体byte,而allocate函数的形参虽然也是size_t类型,但是其表示元素的个数,其根据具体的元素类型推算分配内存的大小。以下为使用方法:

allocator a;
a.allocate(n);

首先创建一个allocator类的对象a,然后调用成员函数allocate去分配内存,分配的内存大小为n*sizeof(T)。

std::allocator与std::alloc

好了,其实接下来要说的才是STL源码剖析第二章的内容,之前的就当是预备知识吧,至少对第二章有个更深刻的理解。

第二章主要讲的是众多STL库当中最经典的一款SGI STL,该STL库实现了标准的空间配置器std::allocator,但是正如书中所说,它只是对::operator new做了一层薄薄的包装,效率不佳,因此SGI自己从未使用它。SGI STL的空间配置有自己的法宝,即它实现的特殊的空间配置器std::alloc,其不接受任何参数,而一般的配置器std::allocator是需要接受类型参数T的。例如,一般情况下的std::allocator声明一个存放int类型元素的容器时,其写法为:

vector> a;

通常来说,我们一般使用的都是缺省的空间配置器,很少需要自行指定配置器名称,而SGI STL的每一个容器都已经指定其缺省的空间配置器为alloc,所以这样的设计不会给我们带来困扰。例如vector的声明

template//缺省使用alloc为配置器
class vector{...}

这样的话,当我们声明一个存放int类型元素的容器时,不需要写成

vector a;

而直接写成下面这样即可,因为它缺省的第二个参数有默认值alloc。

vector a;

同时也可以看到,正如我们上述所说,std::alloc是不需要接受类型参数的。好吧,接下来讲讲STG STL的特殊空间配置器std::alloc吧。

详解std::alloc

空间配置

考虑到小型区块所可能造成的内存碎片问题,SGI设计了双层级配置器。

第一级配置器__malloc_alloc_template直接使用malloc()、free()、realloc()等C函数执行实际的内存配置、释放、重配置操作,并实现了类似于C++中的new handler机制,当调用malloc()内存分配失败后,调用用oom_malloc(),当调用realloc()内存分配失败后,调用oom_realloc()。oom_malloc()与oom_realloc()都有内循环,在内部不断调用用户自定义的处理例程,企图在某次调用之后有内存被释放从而自己分配到了足够的内存后而圆满完成任务。但是如果"内存不足处理例程"用户并未设定,那么oom_malloc()和oom_realloc()就会抛出bad::alloc异常或者利用exit(1)硬生生终止程序。

上面说到C++的new handler机制,这里来简单谈谈这一机制。简而言之,就是operator new函数在分配内存失败的时候,其会抛出一个bad::alloc异常,但是在抛出异常之前,如果客户通过set_new_handler函数指定了一个内存不足处理函数,其会调用内存不足处理函数,尝试分配内存,如果分配成功则很好,如果一直分配失败,则抛出异常。set_new_handler接受一个指针,该指针指向内存不足处理函数,如果用于没有指定内存不足处理函数,set_new_handler(0)表示没有指定内存不足处理函数,那么调用operator new内存分配失败后,立刻抛出异常。举个例子:

class A
{
...
static void OutOfMem();
...
};
A::set_new_handler(OutOfMem);//指定内存不足处理函数
A *p1 = new A;//如果内存分配失败 调用OutOfMem
A::set_new_handler(0);//没有指定内存不足处理函数
A *p2 = new A;//如果内存分配失败 直接抛出异常

由于SGI以malloc而非::operator new来配置内存,这一方面是历史原因,另一方面在于C++没有realloc这种尝试重新调整之前调用malloc或者calloc所分配的ptr所指向内存的大小的这种内存操作,因此SGI也就不能直接使用C++的set_new_handler()函数,而只能仿写一个类似的,这也就是为什么说第一级配置器实现了类似于C++的new handler机制。

第二级配置器__default_alloc_template视情况采用不同的策略,当需要配置的区块超过128bytes时,视之为足够大,便调用第一级配置器;当配置区块小于128bytes时,视之为过小,为了避免太多小额区块造成内存的碎片,以及减小配置时的额外负担,其采用内存池的方式,而不再求助于第一级配置器。每次配置一大块内存,并维护对应之自由链表下次若再有相同大小的内存需求,就直接从free-lists中拨出。如果客端释还小额区块,就由配置器回收到free-lists中。为了方便管理,SGI第二级配置器会主动将任何小额区块的内存需求量上调至8的倍数(例如客端要求30bytes,就自动调整为32bytes),并维护16个free-list,大小分别是8,16,24,32,40,48,56,64,72,80,88,96,104,112,120,128bytes。

下面来看看free-lists的节点结构,其设计的非常巧妙:

union obj{
    union obj *free_list_link;
    char client_data[1];/*The client see this*/
}

为了维护链表,每个节点都需要额外指针(指向下一个节点),我们好像觉得这么做会造成额外负担,但实际上这种精巧的设计使我们的这种顾虑显得没有必要,因为它压根不会造成额外负担。这一切都得益于uinon了。我们知道,union是一种节省空间的类,其一大特点就是其可以有多个数据成员,但是在任意时刻只有一个数据成员有值。当我们给union的某个成员赋值之后,该union的其他成员都变成未定义的状态了(参见C++ Primer P749)

STL源码剖析笔记_第1张图片

我们结合上图来仔细分析一下,首先我们要知道,union obj的第一个成员union obj *free_list_link,其是一个链表指针,指向下一块空闲的内存块,而它的第二个成员主要是给用户使用的。具体来说,上图中凡是有指针指着的区块都是未分配的,也就是说用户还未索取这块内存,所以union的第一个成员总是指向下一个空闲区块,第二个成员是未定义的状态。而没有指针指着的区块是已经分配给用户的内存,这时候其第一个成员是未定义的状态,第二个成员client_data为有定义的状态,而client_data是一个数组,占用一个字节,同时我们可以把这个数组名看作指针,指向这个数组的首地址。也就是说,用户通过这个首地址,就得到了这块内存的起始位置,我们然后当然就可以对这块内存进行读写等操作了,这是为什么union obj的定义第二个成员后面注释有/*The client sees this.*/了,其主要是给用户看的。而如果我们要回收这块内存的时候,我们只需要将把client_data这个数组首地址赋给free_list_link,就可以回收到free-list中了。这也是书上说的,obj一物两用,从第一个字段观之,obj可以看做一个指针,指向相同形式的另一个obj,从第二个字段观之,obj仍视为一个指针,只不过指向的是实际的区块。

如果你理解了上面的这段话,我想你也就知道为什么这样的结构设计并没有造成额外负担了。总结来说,就是当这个区块给用户使用时,并没有节点去指向这个内存块,用户可以完整的使用整个区块,而有节点指向的内存块,都还是未分配的,用户也不可能使用它,所以对于用户要使用的内存来说,并没有任何的额外负担。

接下来讲讲第二级空间配置器配置函数allocate()的实现了。

以下为allocate的思路:

算法:allocate
输入:申请内存的大小size
输出:若分配成功,则返回一个内存的地址,否则返回NULL
{
    if(size大于128){ 
启动第一级分配器直接调用malloc分配所需的内存并返回内存地址;
}
    else {
        将size向上round up成8的倍数并根据大小从free_list中取对应的表头free_list_head;
        if(free_list_head不为空){
              从该列表中取下第一个空闲块并调整free_list;
              返回free_list_head;
        } else {
             调用refill算法建立空闲块列表并返回所需的内存地址;
        }
   }
}

总的来说,就是大于128bytes,调用第一级配置器,小于等于128bytes,寻找16个free-list中对应大小的那一个,看看这个free-list的表头指针是否为空,如果空了,说明没有对应大小的区块空间了,转而调用refill()为free list重新填充空间

那么接下来看看refill吧,refill()主要是为free list重新填充新的空间。新的空间将取自内存池(经由chunk_alloc()完成。缺省取得20个新区块,但万一内存池空间不够,则获得的区块数量可能小于20)。refill()函数思路如下:

算法: refill
输入:内存块的大小size
输出:建立空闲块链表并返回第一个可用的内存块地址
{
    调用chunk_alloc算法分配若干个大小为size的连续内存区域并返回起始地址chunk和成功分配的块数nobj;
    if(块数为1)直接返回chunk;
    否则
    {
         开始在chunk地址块中建立free_list;
         根据size取free_list中对应的表头元素free_list_head;
         将free_list_head指向chunk中偏移起始地址为size的地址处, 即free_list_head=(obj*)(chunk+size);
         再将整个chunk中剩下的nobj-1个内存块串联起来构成一个空闲列表;
         返回chunk,即chunk中第一块空闲的内存块;
     }
}

总结来说,refill()就是先调用chunk_alloc(),获取内存池空间,如果只得到一个区块,就返回,如果得到了不止一个区块,就把free list给串起来,这样的话以后再有同样大小的区块申请就方便了。感觉这个refill主要是为了以后区块分配方便所设计的

接下来看看最核心的chunk_alloc函数吧,其主要工作是从内存池取空间给free list使用。以下是它的思路。

算法:chunk_alloc
输入:内存块的大小size,预分配的内存块块数nobj(以引用传递)
输出:一块连续的内存区域的地址和该区域内可以容纳的内存块的块数
{
      计算总共所需的内存大小total_bytes;
      if(内存池中足以分配,即end_free - start_free >= total_bytes) {
          则更新start_free;
          返回旧的start_free;
      } else if(内存池中不够分配nobj个内存块,但至少可以分配一个){
         计算可以分配的内存块数并修改nobj;
         更新start_free并返回原来的start_free;
      } else { //内存池连一块内存块都分配不了
         先将内存池的内存块链入到对应的free_list中后(充分利用内存池中不够分配的剩余零头,添加到相应的free_list中);
         调用malloc操作重新分配内存池,大小为2倍的total_bytes加附加量,start_free指向返回的内存地址;
         if(分配不成功) {
             if(16个空闲列表中尚有空闲块)
                尝试将16个空闲列表中空闲块回收到内存池中再调用chunk_alloc(size, nobj);
            else {
                   调用第一级分配器尝试out of memory机制是否还有用;
            }
         }
         更新end_free为start_free+total_bytes,heap_size为2倍的total_bytes;
         调用chunk_alloc(size,nobj);
    }
}

总结来说,chunk_alloc首先计算内存池的剩余空间,如果剩余空间完全满足需求,直接返回。如果内存剩余空间不能完全满足需求,但足够供应一个(含)以上区块的话,也可以返回。(因为用户申请的区块其实是一个,但是由于free list对应这个区块大小的空闲列表中没有可用区块了,所以refill会多分配几个区块给free list,这样的话下次再需要这个大小区块的时候就不用再调用refill以及chunk_alloc了。所以满足第一个if是满足了refill的调用要求,满足第二个else if是说明至少满足了用户的索取需求)。如果上述两项需求都不能满足,chunk_alloc先将内存池的内存块链入到对应的free_list中后(充分利用内存池中不够分配的剩余零头,添加到相应的free_list中)。例如,假设我们需求的区块大小为16bytes,而byte_left=8byte,这样的话就把这个零头添加到管理8byte的free list中。然后调用malloc分配内存,如果分配失败,我们就检验手上拥有的内存块,遍历所有的free list,看看他们的指针是否为空,不为空,说明还有未用区块,将他们回收至内存池。这样假设原本free list就还剩8byte,刚刚又有8byte编入对应的free list,这样加起来正好16byte,嘿嘿嘿,满足要求了,申请成功了!!!。相当于之前把内存池的残余零头添加到free list中了,我们现在再去看看free list中有没有能够满足我们分配条件的,大致就是这个意思。如果还是不行,只能调用第一级配置器,利用其内存不足调用处理函数的机制进行处理了。

空间释放

空间释放主要是由deallocate函数来完成的,以下为deallocate的思路:

算法:deallocate
输入:需要释放的内存块地址p和大小size
{
    if(size大于128字节)直接调用free(p)释放;
    else{
        将size向上取8的倍数,并据此获取对应的空闲列表表头指针free_list_head;
       调整free_list_head将p链入空闲列表块中;
    }
}

主要就是大于128byte直接调用free,小于128byte要回收区块,调整free list。

构造与析构基本工具:construct()和destroy()

construct()主要负责对象的构造,其实现非常简单。

template
inline void construct(T1* p,const T2& value)
{
    new (p) T1(value);
}

其主要使用的placement new的形式,即定位new表达式,p指向一块原始内存,在该内存上构造对象。

destroy()主要负责对象的析构,其有两个特化版本,还有根据对象的destructor是否是trival的,去调用不同的版本。如果是trival的,则什么也不做,如果是non-trival的,则逐一调用对象的析构函数完成析构。以下是construct()与destory()示意。

STL源码剖析笔记_第2张图片

内存基本处理工具:uninitialized_copy()、uninitialized_fill()、uninitialized_fill_n()

包括uninitialized_copy()、uninitialized_fill()、uninitialized_fill_n()三个函数,他们其实和construct()功能差不多,都是负责在allocate()分配的空间下完成对象的构造。区别在于其效率一般比construct()高,最差的情况就是调用construct()。一般情况下,它会判断对象的是否为POD,如果是POD类型,则调用STL库的泛型算法copy()、fill()、fill_n()进行构造,如果不是,则调用construct()完成对象构造。所谓POD,即Plain Old Data.POD指的是这样一些数据类型:基本数据类型、指针、union、数组、构造函数是trival的类。POD用来表明C++中与C相兼容的数据类型,可以按照C的方式来处理。而非POD类型的数据与C不兼容,只能按照C++特有的方式进行处理。以下是三个函数的版本示意图。

STL源码剖析笔记_第3张图片

小结

allocator类的用法:

allocator a;
a.allocate(n);//分配n个T类型的内存
a.deallocate(p,n);//释放内存 p必须是之前allocate()返回的指针 n必须是创建时所要求的的大小
a.construct(p,args);//在p指向的内存处构造对象 p必须是一个类型为T*的指针,指向一块原始内存
a.destory(p);//p必须是一个类型为T*的指针,对对象进行析构

第三章 迭代器概念与traits编程技法

前言

迭代器是一种行为类似指针的对象,而指针的各种行为中最常见也最重要的便是内容提领与成员访问,因此迭代器最重要的编程工作就是对operator*(内容提领)和operator->(成员访问)进行重载。这两个运算符的重载比较简单,但是刚开始我一直困惑的是书中实现的一个简化版的auto_ptr类,其中,里面的这两个操作符是这样重载的,如图所示。

STL源码剖析笔记_第4张图片

对于上面的图,我一直纳闷的是为什么operator->重载箭头运算符返回的是一个指针而不是一个对象或者对象的引用。后来,经过我仔细的研究发现,重载箭头运算符必须返回指向类对象的指针或者是一个重载了operator->的类的对象(具体关于箭头运算符的重载参见我的另一篇博客日常学习总结2019.12.06)。具体来说,就是重载了箭头运算符的话,其在实际调用的时候最后一层永远用的是内置版本的箭头运算符。本例中返回的是一个指针,这里假设实例化一个模板类auto_ptr,例如:

auto_ptr p(new string("hello!"));
cout<size()<

由于重载箭头运算符返回的是指针,这里ps->size()中的箭头运算符中的箭头其实是内置版本的箭头运算符,表达式等价于(*ps).size().即首先解引用该指针,然后从所得对象中获取指定的成员。

不过,迭代器最大的责任,是提供相应的型别,以使得其对外接口是一致的,这个接口可以给泛型算法使用,泛型算法只需要知道相应的型别就足够了,其压根不关注迭代器内部是如何实现的。但是设计适当的迭代器,需要知道容器的具体细节,因为每一种容器具有不同的特点,例如vector容器是连续的内存空间,其每一个单元就是一个数据成员,而链表list的内存空间是不连续的,其每个单元是一个节点,每个节点有指向前节点与后节点的指针以及一个数据成员。vector中迭代器所需要的操作行为与普通指针一样,所以operator*,operator->等完全不需要重载。而list中operator*必须要重载,以获得迭代器所指单元(也就是一个节点)中的数据成员。从这个例子就可以看出,我们不可能孤立于具体的容器去设计一个通用的迭代器,对于迭代器而言,其只需要提供相应的型别给迭代器的设计者即可,以使得迭代器对外接口是一致的

迭代器相应型别与Traits编程技法

根据经验,最常用到的迭代器相应型别有5种,value_type、 difference_type、pointer、 reference、 iterator_category,这5个类型的含义如下。仔细研究发现,这里的迭代器是一个模板类。后面会讲到,并不是所有迭代器都是类,原生指针就不是。但他们都通过iterator_traits模板类进行萃取,以获得相应的5个型别,作为迭代器对外的公共接口

template  
struct iterator { 
  typedef Category  iterator_category; //迭代器本身所属的类型
  typedef T         value_type; //迭代器所指数据类型
  typedef Distance  difference_type; //表示两个迭代器之间距离的类型
  typedef Pointer   pointer; //迭代器所指数据的指针类型
  typedef Reference reference; //迭代器所指数据的引用类型
}; 

下面来看看Traits编程技法,它主要是利用"内嵌型别"的编程技巧与编译器的template参数推导功能

template 
struct iterator_traits {
    typedef typename Iterator::iterator_category iterator_category;
    typedef typename Iterator::value_type value_type;
    typedef typename Iterator::difference_type difference_type;
    typedef typename Iterator::pointer pointer;
    typedef typename Iterator::reference reference;
};

可以看到,iterator_traits 就是一个针对不同的迭代器设计的一个模板类,其将各个迭代器的特性给榨取了出来,得到了迭代器的响应型别,可以看到这里面用到了内嵌型别(类里面typedef的运用)以及编译器的template参数推导功能(依据不同的迭代器去推导)。下面这幅图很生动的揭示了iterator_traits的作用,其就相当于一个特性萃取机,以具体的迭代器去实例化iterator_traits模板类,将迭代器的5个相应型别给榨取出来,这样就可以作为一个对外接口了。

STL源码剖析笔记_第5张图片

我们注意到上图中出现了这样一句话,通过class template partial speclization的作用,不论是原生指针或class-type iterators,都可以让外界方便地取其相应型别。这里我们需要知道什么是class template partial speclization,即类的偏特化,以及为什么需要偏特化。

所谓偏特化,就是针对任何模板参数更进一步的条件限制所设计出来的一个特化版本。仔细研究,我们发现上面第二个代码框中iterator是一个迭代器类,而我们知道,原生指针例如int*其不是一个类(class type),因此如果没有一个偏特化版本的类iterator_traits,我们是没有办法对原生指针萃取出5个相应性别的(例如,如果这样实例化一个iterator_traits,iterator_traits显然是错误的,因为int*不是一个类类型!!!),而显然,迭代器必须要支持原生指针作为一种迭代器,在STL的泛型编程中我们知道原生指针是可以作为迭代器的。所以,特性萃取机iterator_traits必须偏特化出原生指针版本,如下所示,是iterator_traits模板类的针对原生指针与const指针的偏特化版本,这样的话,如果我们iterator_traits这样去实例化就不会报错了,其会去调用iterator_traits的原生指针的偏特化版本。


// 针对原生指针的偏特化处理
template 
struct iterator_traits {
    typedef random_access_iterator_tag        iterator_category;
    typedef T                                 value_type;
    typedef ptrdiff_t                         difference_type;
    typedef T*                                pointer;
    typedef T&                                reference;
};

// 针对const指针的偏特化处理
template 
struct iterator_traits {
    typedef random_access_iterator_tag          iterator_category;
    typedef T                                   value_type;
    typedef ptrdiff_t                           difference_type;
    typedef const T*                            pointer;
    typedef const T&                            reference;
};

相应型别之iterator_categoty及其实现技巧

下面来介绍一下相应型别中的iterator_categoty,虽然针对每一个容器都有其独自的迭代器,但是细分起来,无非也就5个大类,如下图所示。箭头表示从属关系而非继承关系,例如Random Access Iterator属于Bidirectional Iterator,也就是说图下方的Iterator是从属于图上方的Iterator,越靠下的Iterator一般其功能越强大,即其越高阶,一个算法如果接受一个低阶迭代器,那么其必然接受高阶迭代器,反之则不成立。举例来说,假如一个接受Input Iterator的算法,如果我们给他Random Access Iterator,那么也是可行的,但是Random Access Iterator的强大的功能就无法在这个算法中展现了,其只能使用Input Iterator所具有的功能。这就好比一道题有三种解法,甲(Input Iterator)就会法一,乙(Random Access Iterator)会法一、法二、法三共三种解法,并且使用法二、法三解决这道题效率更高,但是这道题(指定的算法)要求你只能使用法一求解,这样的话乙虽然会效率更高的法二和法三,但是其无处施展。

STL源码剖析笔记_第6张图片

下面以advance函数观traits编程技法以及重载技法,首先说一下advance函数的作用。advance函数接受两个参数,迭代器p和数值n,表示将迭代器p前进n个距离。针对input iterator,其必须要一个距离一个距离的前进,针对bidirectional iterator,其必须要考虑n是正的还是负的,以决定是一个距离一个距离的向前挪还是向后挪,而对于高阶的random access iterator,其直接步进n即可,效率最高。这样我们在写advance函数的时候,可以在advance函数内部判断迭代器的类型,然后选择哪种版本,如下图所示。

STL源码剖析笔记_第7张图片

但是这样的做法有一个非常大的弊端,就是必须要在程序执行期才能决定使用哪个版本,这会影响到程序的效率,于是对效率要求极高的STL想到了一种编程技巧成功的解决了这个问题,即重载机制,其可以让编译器在编译的时候就知道要调用什么版本。其定义了5个空类,并运用了继承机制。

// 五种迭代器类型
struct input_iterator_tag {};
struct output_iterator_tag {};
struct forward_iterator_tag : public input_iterator_tag {};
struct bidirectional_iterator_tag : public forward_iterator_tag {};
struct random_access_iterator_tag : public bidirectional_iterator_tag {};

两个形参的advance函数在内部调用3个形参的__advance函数,而__advance的第三个参数类型就是上述5个空类中的一种,并且第三个参数只声明了型别,并未指定参数名称,其不过是为了激活函数的重载机制。如下所示,

// 该函数非常方便的提取某个迭代器的类型iterator_category
template 
inline typename iterator_traits::iterator_category iterator_category(const Iterator&) 
{
    typedef typename iterator_traits::iterator_category category;
    return category();
}

// 迭代器前进
template 
inline void advance(InputIterator& i, Distance n) 
{
    __advance(i, n, iterator_category(i));
}

// random迭代器前进
template 
inline void __advance(RandomAccessIterator& i, Distance n, random_access_iterator_tag)
{
    i += n;
}

// bidirectional迭代器前进
template 
inline void __advance(BidirectionalIterator& i, Distance n, bidirectional_iterator_tag) 
{
    if (n >= 0)
        while (n--) ++i;
    else
        while (n++) --i;
}

// input迭代器前进
template 
inline void __advance(InputIterator& i, Distance n, input_iterator_tag) 
{
    while (n--) ++i;
}

我们结合上面的程序来分析一下程序用到的技巧,我们可以看到,advance内部调用的是__advance函数,该函数的第三个形参是iterator_category(i),i表示一个迭代器,而iterator_category()是一个函数,我们来看看这个函数,这个函数内部利用triats萃取机制萃取出类标签,即5个空类中的一种,然后返回这个空类的一个临时对象,依据这个临时对象的类型我们在编译的时候就可以知道要调用的函数版本了。

举个例子,假设i是一个input Iterator,设该迭代器指向的元素类型为int,那么由之前iterator模板类的定义我们知道,实例化一个迭代器对象i可能是这样实例化的:

iterator i;

即iterator模板类的第一个模板参数Category使用input_iterator_tag去实例化的,此时,iterator类中的iterator_category就是input_iterator_tag,那么我们此时调用iterator_category(i)函数,我们利用萃取机制,最后返回的相当于input_iterator_tag(),即input_iterator_tag类的一个临时对象,这样的话依据重载机制我们不就知道要调用哪个版本的__advance了

那么可能有人会问为什么五个空类要使用继承机制呢?使用继承机制可以使得我们不用为每一个迭代器版本都去定义一个函数,很多时候,有些迭代器在一个算法里其程序可能是一模一样的,因此就没必要重复。例如上述的advance函数,我们不需要为4个迭代器定义4个版本(未考虑output iterator),实际上我们只定义了三个版本,那么未定义的forward iterator对应的forward_iterator_tag继承于input_iterator_tag,其会执行input iterator版本的advance函数。

__type_traits技术

在上文中我们用iterator_traits模板类去萃取迭代器的5个相应型别,以作为对外的接口。在SGI STL中,其将这种技法拓展到了迭代器以外的世界,于是有了所谓的__type_traits,其负责萃取型别的特性(__type_traits是SGI STL内部所用的东西,不在STL标准规范之内)。这里的特性指这个型别是否具有non-traivial默认构造函数、拷贝构造函数、拷贝赋值运算符、析构函数。如果不具备,我们可以在对这个型别进行构造、析构、拷贝、赋值等操作时,就可以采用最有效率的措施(例如根本不去调用不谋实事的构造函数或者析构函数),而采用内存直接管理操作如malloc()、memcpy()等等,获得最高效率。这对于大规模而操作频繁的容器来说,有着显著的效率提升

如下所示,

struct __true_type { };
struct __false_type { };

template 
struct __type_traits
{
    typedef __true_type this_dummy_member_must_be_first;
    typedef __false_type has_trivial_default_constructor;
    typedef __false_type has_trivial_copy_constructor;
    typedef __false_type has_trivial_assignment_operator;
    typedef __false_type has_trivial_destructor;
    typedef __false_type is_POD_type; // 是否是POD类型
};

这里为什么要将__true_type和__false_type定义为空类,而不是bool类型的值,因为编译器在做参数推导时其只会对类类型的参数做参数推导

上面的萃取机我们都认为他们的默认构造函数、拷贝构造函数、拷贝赋值运算符、析构函数都是non-trival的,并且他们不是POD类型,这是一种保守做法,然后其对所有内置类型进行偏特化,例如,

__STL_TEMPLATE_NULL struct __type_traits {
    typedef __true_type has_trivial_default_constructor;
    typedef __true_type has_trivial_copy_constructor;
    typedef __true_type has_trivial_assignment_operator;
    typedef __true_type has_trivial_destructor;
    typedef __true_type is_POD_type;
};

这样的话,对内置类型的操作效率就会显著提高。对于我们自定义的类,如果里面有trival的构造函数等,我们也可以进行偏特化。

来看一个应用:

STL源码剖析笔记_第8张图片

上面的例子就是通过__type_traits萃取机制得到第四个形参上数据是否为POD类型,注意is_POD()是一个类临时对象,要么是__true_type(),要么是__false_type(),然后根据是否是POD类型调用不同的__uninitialized_fill_n_max版本。

第四章 序列式容器

本章讲解各种序列式容器内部具体的实现细节,因此本章我只会对各种容器做一个大体的概括总结,具体的实现代码还是要仔细阅读课本,细细品味源码确实收获很多。

vector

vector与array非常相似,两者唯一的差别在于空间运用的灵活性。array即数组,它是静态空间,其大小是固定的,一旦配置了就不能更改。如果要换个更大的空间,我们只能客户自己另辟一段更大的新空间,将元素从旧址一一搬到新址,再将原来的空间释还给系统。而vector它是动态空间,其随着元素的加入,它的内部进制会自行扩充空间以容纳新元素,因而其不需要客户操心。

动态大小的实现:以push_back为例,当插入数据时,vector检查空间是不是已经满了,如果没满,直接插入到容器的尾部,如果满了,就会重新申请一块更大的内存,如果原来的vector大小为0,就会申请大小为1的内存,否则申请原内存大小的两倍空间。注意,动态大小的增加,不是接续在原空间之后,因为无法保证原空间之后尚有可供配置的连续空间(我们知道vector中的地址空间是连续的)。然后其将原内容拷贝过来,然后在原内容之后构造新元素,并释放就空间。注意,一旦引起空间的重新配置,指向原vector的所有迭代器都将失效。有人可能会问,为什么要申请原空间大小的两倍呢,直接按需分配不就好了,该申请多少就申请多少。其实不然,我们知道,配置新空间/数据移动/释还旧空间是一个大工程,时间成本很大,因此需要在配置的时候加入未雨绸缪的考虑。另外,vector的insert操作的分配策略大体上和push_back差不多,唯一的区别在于insert插入的时候如果一下子插入的元素个数n比vector原大小还要大,这时候其分配的空间是原大小+要分配的大小,因为如果还是分配原大小的两倍插入进来内存空间还是不够的,这也很好理解。代码如下,具体参见原书P125。

const size_type len = old_size + max(old_size, n);

vector中的迭代器失效问题

上面说到,vector中一旦引起空间的重新分配,其所有迭代器都失效了。而如果没有重新分配呢,当我们插入元素的时候会导致元素重新分配吗?直接上例子,这是在VS2015上的测试样例,注意VS中vector的分配策略是1.5倍而不是2倍,而GCC上是2倍,这可能是他们基于不同的STL吧。

int main()
{
	vector a ;
	a.push_back(1);
	cout << "capacity=" << a.capacity() << endl;//capacity=1
	a.push_back(2);
	cout << "capacity=" << a.capacity() << endl;//capacity=2
	a.push_back(3);	
	cout << "capacity=" << a.capacity() << endl;//capacity=3
	a.push_back(4);	
	cout << "capacity=" << a.capacity() << endl;//capacity=4
	a.push_back(5);
	cout <<"capacity="<< a.capacity() << endl;//capacity=6
	auto it = a.begin();
	auto it1 = a.end() - 1;
	cout << *it << endl;//1
	cout << *it1 << endl;//5
	auto it2 = a.begin() + 1;
	cout << *it2 << endl;//2
	auto it3=a.insert(it2, 7);
	cout <<"capacity="<< a.capacity() << endl;//capacity=6
	cout << *it << endl;//1
	cout << *it1 << endl;//4
	cout << *it2 << endl;//7
	cout << *it3 << endl;//7
	return 0;
}

我们看到,由于VS中vector是1.5倍原大小的分配策略,所以当容器中放入5的时候,其容量由4变为了6,当我们插入一个7之后,并没有引起内存的重新配置。我们发现,由于没有内存重新分配,程序仍可以正常执行,迭代器依然可以访问元素并且不会报错,但是我们发现插入点之前的迭代器还是可以访问到原来的元素,而插入点之后(包括插入点)的迭代器虽然可以访问元素,但是指向的元素与原来不同了,所以插入点及插入点之后的迭代器失效了。

再来看下面的一个例子:

int main()
{
	vector a ;
	a.push_back(1);
	cout << "capacity=" << a.capacity() << endl;//capacity=1
	a.push_back(2);
	cout << "capacity=" << a.capacity() << endl;//capacity=2
	a.push_back(3);	
	cout << "capacity=" << a.capacity() << endl;//capacity=3
	a.push_back(4);	
	cout << "capacity=" << a.capacity() << endl;//capacity=4
	a.push_back(5);
	cout <<"capacity="<< a.capacity() << endl;//capacity=6
	auto it = a.begin();
	auto it1 = a.end() - 1;
	a.push_back(6);
	cout << "capacity=" << a.capacity() << endl;//capacity=6
	cout << *it << endl;//1
	cout << *it1 << endl;//5
	auto it2 = a.begin() + 1;
	cout << *it2 << endl;//2
	auto it3=a.insert(it2, 7);
	cout <<"capacity="<< a.capacity() << endl;//capacity=9
	cout << *it << endl;//报错
	cout << *it1 << endl;//报错
	cout << *it2 << endl;//报错
	cout << *it3 << endl;//报错
	return 0;
}

上面的例子中,在放入元素6之后,容器已满,所以当插入元素7之后,引起了内存的重新配置,我们发现,这时候访问所有迭代器都报错,所有迭代器均失效。

再来看一下删除操作:

int main()
{
	vector a ;
	a.push_back(1);
	cout << "capacity=" << a.capacity() << endl;//capacity=1
	a.push_back(2);
	cout << "capacity=" << a.capacity() << endl;//capacity=2
	a.push_back(3);	
	cout << "capacity=" << a.capacity() << endl;//capacity=3
	a.push_back(4);	
	cout << "capacity=" << a.capacity() << endl;//capacity=4
	a.push_back(5);
	cout <<"capacity="<< a.capacity() << endl;//capacity=6
	auto it = a.begin();
	auto it1 = a.end() - 2;
	a.push_back(6);
	cout << "capacity=" << a.capacity() << endl;//capacity=6
	cout << *it << endl;//1
	cout << *it1 << endl;//4
	auto it2 = a.begin() + 1;
	a.erase(it2);
	cout << "capacity=" << a.capacity() << endl;//capacity=6
	cout << *it << endl;//1
	cout << *it1 << endl;//报错
	return 0;
}

从上面的例子可以看出,删除操作不会改变vector的容量,也就是不会引起内存的重新配置,但是会造成删除点及其之后的迭代器通通失效且不可访问,但删除点之前的迭代器还是有效的。

总结来说,只要引起内存重新配置,必然所有迭代器失效且不可访问,而当没有引起内存重新配置时,insert操作会使得包括插入点在内的其后的迭代器通通失效,但是仍然可以访问元素,只是和之前指向的元素不一样了,插入点之前的迭代器仍然有效。而erase删除操作会使得包括删除点在内的其后的迭代器通通失效且不可访问,而删除点前面的迭代器仍然有效

vector中的迭代器

vector维护的是一个连续线性空间,所以不论元素是什么型别,普通指针都可以作为vector的迭代器而满足所有必要条件,所以vector的迭代器其实就是普通指针,因此其完全不用重载operator*、operator->,operator++等操作符,因为这些操作符普通指针天生就具备。vector支持随机存取,普通指针也支持,所以vector提供的是Random Access Iterator

因为vector的迭代器就是普通指针,所以其完全没有必要定义一个迭代器类。这里我们节选vector定义中的一部分。非常重要的一点在于其实也非常简单,但对于理解程序非常关键,vector::iterator等价于int*,知道这个有助于在特性萃取机那边利用偏特化版本的iterator_traits的理解。

STL源码剖析笔记_第9张图片

从代码typedef value_type* iterator可以看出,这里的迭代器的就是一个普通指针。一般情况下,iterator应该是一个迭代器类,如下面要介绍的list中的迭代器,但是这里由于vector的迭代器就是一个普通指针,因此就没必要定义为一个类了。再仔细研究发现,这里面用到了内嵌型别技术,并且新定义了一个size_type类型,但是却唯独没有之前迭代器5个相应型别中的iterator_category,这是怎么回事呢?这是因为vector的迭代器就是一个普通指针,而迭代器要想作为一个对外的公共接口,其需要经过iterator_traits的萃取机,而我们知道,iterator_traits作为一个模板类,其为原生指针定义了一个偏特化版本,如下所示。

// 针对原生指针的偏特化处理
template 
struct iterator_traits {
    typedef random_access_iterator_tag        iterator_category;
    typedef T                                 value_type;
    typedef ptrdiff_t                         difference_type;
    typedef T*                                pointer;
    typedef T&                                reference;
};

发现了什么,其将原生指针的iterator_category声明了random_access_iterator_tag,而我们知道,普通指针就应该是一个Random Access Iterator。所以加入一个算法操作的是容器,还是以第3章的advance函数为例,代码如下所示

vector a={1,2,3,4,5};
vector::iterator it=a.begin();
advance(it,2);

这段代码是让迭代器指向a的vector的容器从起始位置前进2格。我们根据第三章的分析知道,advance内部会调用iterator_category()函数,而该函数内部会利用iterator_traits特性萃取机。然后根据萃取结果决定调用哪个版本的__advance函数。

// 该函数非常方便的提取某个迭代器的类型iterator_category
template 
inline typename iterator_traits::iterator_category iterator_category(const Iterator&) 
{
    typedef typename iterator_traits::iterator_category category;
    return category();
}

所以特性萃取机相当于

tpyedef typename iterator_traits::iterator>::iterator_category category

而我们知道,依据vector中的定义,vector::iterator等价于int*,(int即为value_type),所以特性萃取机又进一步等价于

tpyedef typename iterator_traits::iterator_category category

这不正好对应iterator_traits的偏特化版本吗!所以更进一步上面的category对应于random_access_iterator_tag ,此时advance就知道要调用的是针对Random Access Iterator版本的__advance函数了。

所以这里还是要再重申一下,以进一步加深对第三章的理解。从vector的迭代器实现我们发现,迭代器本身如何实现完全是依赖于具体的容器,反正其最后都将迭代器通过特性萃取机iterator_traits,得到5个相应型别,得到对外的一致接口,供给各个泛型算法使用,泛型算法只需要知道这5个相应型别就够了,其压根不关注你迭代器内部是如何实现的。再一次感叹特性萃取机iterator_traits真的好神奇,好强大的感觉!!!

list

list是链表,而且其是一个成环的双向链表。list的每一个单元称为一个节点,每个节点由指向前节点与后节点的指针以及一个数据成员组成。list的节点不保证在空间上是连续的,因此list的迭代器必然不具备随机访问的能力,而我们知道list是一个双向链表,因此其迭代器必须具备前移、后移能力,因此list的迭代器是一个Bidirectional Iterator。list有一个重要性质:插入(insert)操作和接合(splice)操作都不会造成原有的迭代器失效,因为这不会导致内存空间的重新配置。另外,其删除操作也只会使指向被删除元素的迭代器失效,其他迭代器不受影响。

list的节点定义

template 
struct __list_node 
{
    typedef void* void_pointer;
    void_pointer prev; // 指向前一个节点
    void_pointer next; // 指向后一个节点
    T data; // 存储的数据
};

list的迭代器

template
struct __list_iterator 
{
    typedef __list_iterator iterator;
    typedef __list_iterator self;

    typedef bidirectional_iterator_tag iterator_category;
    typedef T value_type;
    typedef Ptr pointer;
    typedef Ref reference;
    typedef __list_node* link_type;
    typedef size_t size_type;
    typedef ptrdiff_t difference_type;

    link_type node; // 迭代器内部的核心数据,指向链表中的某一个节点

    // 构造函数
    __list_iterator(link_type x) : node(x) {}
    
    __list_iterator() {}
    
    __list_iterator(const iterator& x) : node(x.node) {}
    
    bool operator==(const self& x) const { return node == x.node; }
    
    bool operator!=(const self& x) const { return node != x.node; }
    
    reference operator*() const { return (*node).data; }
    
    pointer operator->() const { return &(operator*()); }

    // 迭代器++就是下一个节点
    self& operator++()
    {
        node = (link_type)((*node).next);
        return *this;
    }
    
    self operator++(int)
    {
        self tmp = *this;
        ++*this;
        return tmp;
    }

    // 迭代器--就是上一个节点
    self& operator--()
    {
        node = (link_type)((*node).prev);
        return *this;
    }
    
    self operator--(int)
    {
        self tmp = *this;
        --*this;
        return tmp;
    }
};

这里我们注意到,与vector迭代器不同,list的迭代器是一个类,并且我们看到这个类里面为我们提供了迭代器的5个相应型别,并且进行了一定的重载。在vector的例子中,因为其迭代器就是原生指针,指针的行为和迭代器的行为是完全一致的,因此其没有重载。那么这里为什么要重载呢,还是因为其和具体的容器细节有关。仔细研究这个类的定义我们发现,在这个迭代器类里定义了一个node成员,其是一个指针,指向list链表中的一个节点(所以个人感觉并不是迭代器指向了链表中的节点,而是迭代器的成员node指向了链表节点),而这个节点包括了两个指针以及一个数据成员,普通的解引用操作符显然无法对这样的节点进行解引用,我们解引用需要得到的节点中的数据成员,所以可以发现operator*的重载里面返回的node指针指向的节点的数据成员,如果离开了list内部的具体细节,显然我们无法设计一个通用的迭代器去访问所有容器的数据,这再次说明了迭代器的设计是容器的责任。

list的数据结构

这里只贴出list的部分代码:

template  
class list 
{
protected:
    typedef __list_node list_node;
    
public:
    typedef list_node* link_type;
    typedef __list_iterator iterator;
    
protected:
    link_type node; // 只要一个指针,便可以表示整个环状双向链表  该指针永远指向元素末尾
    ...
};

我们依据list的定义去窥探其iterator的门道,仍然以advance函数为例:

list a;
a.push_back(1);
a.push_back(2);
a.push_back(3);
a.push_back(4);
a.push_back(5);
list::iterator it=a.begin();
advance(it,2);

当我们定义一个迭代器list::iterator,此时由list的内嵌型别声明我们知道list::iterator等价于__list_iterator,当我们使用特性萃取机进行萃取时,特性萃取机相当于执行了

tpyedef typename iterator_traits::iterator>::iterator_category category

也即执行了

tpyedef typename iterator_traits<__list_iterator>::iterator_category category

显然其会调用模板类iterator_traits进行特性萃取:

template 
struct iterator_traits {
    typedef typename Iterator::iterator_category iterator_category;
    typedef typename Iterator::value_type value_type;
    typedef typename Iterator::difference_type difference_type;
    typedef typename Iterator::pointer pointer;
    typedef typename Iterator::reference reference;
};

而我们知道__list_iterator::iterator_category等于bidirectional_iterator_tag,因此其就知道要调用哪个版本的__advance函数了。

另外,list的数据结构设计有一个小的技巧,为了让迭代器满足STL标准中区间的前开后闭的要求,list的指针成员node指向一个刻意至于尾端的空白节点,这个空白节点有指针成员,但是没有数据成员。这个空白节点是在创建一个空链表的时候就创建的(调用list的默认构造函数)。具体参见原书P134,默认构造函数里调用了empty_initialize()函数产生一个空链表。

STL源码剖析笔记_第10张图片

因此,我们发现list中的几个成员函数是这样定义的,就以begin()函数为例,其里面还是有一些门道的。我们知道,node指向的是尾端的空白节点,那么其下一个节点必然就是起始节点了。begin函数其实返回值的时候会调用__list_iterator形参为link_type版本的构造函数,我们知道return后面的值类型被强制转换为link_type类型,而iterator被声明为__list_iterator,所以这里面会调用__list_iterator类中的形参为link_type版本的构造函数,得到一个__list_iterator迭代器类的类对象。

STL源码剖析笔记_第11张图片

至于list相关方法的实现还是参见原书代码,没有太多要讲的。

deque

deque是一个双向开口的连续线性空间,但是其是打引号的连续空间,即物理上deque的空间是不连续的,但是逻辑上是连续的,其通过一定的手法维护了其整体连续的假象。那是如何做到的呢,且看下图。

STL源码剖析笔记_第12张图片

deque采用了中控器-缓冲区结构,其中,中控器是一段连续的内存空间,其中存放着一堆指向各个缓冲区的指针。每个缓冲区内部是一个连续的内存空间,但是缓冲区与缓冲区的之间的内存空间不一定是连续的,缓冲区才是deque存放数据成员的地方。整个deque由一个map指针指向其中控器的首地址,map指针是一个二级指针,内存空间连续的中控器内部的每个元素又是一个指针,指向每个缓冲区。当map指针所指的中控器内存空间不够时,我们会配置一块更大的连续内存空间用来作为中控器,将原中控器中指向各个缓冲区的指针拷贝到新的中控器中,再释放原来的中控器内存空间。我们发现,即使是内存空间不够了,我们也只是对中控器的内存空间进行了重新分配,对真正存储数据成员的缓冲区,其地址并没有发生改变

其与vector的最大差异,一是在于deque允许常数时间内对头部进行元素的插入与删除操作,而vector在头部虽然也可以调用insert插入或者erase删除,但其效率低下,涉及到后续元素的移动。二在于deque没有容量的概念,因为它是动态地以分段连续空间组合而成,随时可以增加一段新的空间并链接进来,故而vector中因旧空间不足而重新配置一块更大空间,然后复制元素,再释放旧空间这样的事情不会在deque上发生。也就是说,deque中存储元素的缓冲区的地址是不会发生变化的。这在上一段已经讨论过。

deque的迭代器

根据之前的介绍,我们知道deque缓冲区内部内存空间是连续的,缓冲区之间的内存空间其实是不连续的,但是他维护了整体连续的假象,它对外提供了一种按照连续线性空间的访问方法,这一切都得益于其迭代器的设计,其中主要是重载operator++和operator--的功劳

为了维护其整体连续的假象,我们不难发现,deque的迭代器首先必须能够指出分段连续空间(即缓冲区)在哪里,其次它必须能够判断自己是否已经出去其所在缓冲区的边缘,如果是,那么一旦前进或后退时就必须跳跃至下一个或上一个缓冲区,为了能够正确的跳跃,deque必须依赖于中控器

我们先来简要的看一看deque的迭代器类__deque_iterator,这里只节选了一部分。

template 
struct __deque_iterator 
{ 
    typedef __deque_iterator iterator;
    typedef __deque_iterator const_iterator;
    static size_t buffer_size() {return __deque_buf_size(BufSiz, sizeof(T)); }

    //未继承std::iterator,所以必须自行撰写五个必要的迭代器相应型别
    typedef random_access_iterator_tag iterator_category; // (1)
    typedef T value_type;                                // (2)
    typedef Ptr pointer;                                 // (3)
    typedef Ref reference                                // (4)
    typedef size_t size_type;
    typedef ptrdiff_t difference_type;                   // (5)
    typedef T** map_pointer;
    typedef __deque_iterator self;

    // 一下4个数据确定一个数据在deque中的位置
    T* cur;        // 指向缓冲区中的当前元素
    T* first;    // 指向缓冲区的头
    T* last;    // 指向缓冲区的尾(含备用空间)

    map_pointer node; // 指向中控器中的一个节点
...
};

结合上面的代码与下面的图来看,我们发现deque的迭代器中包含了4个数据成员,其中,cur成员指向缓冲区当前元素的位置,first指向缓冲区的头,last指向缓冲区的尾(含备用空间), node成员指向中控器中的一个节点,这个节点里存放一个指针指向缓冲区。我们用一个迭代器去表征缓冲区里面的一个节点。注意我这里用到的词语"表征",而不是"指向",我觉得用表征更为恰当,其实我们知道,只有迭代器内部的cur成员才是指向缓冲区内的存放数据的节点的,但是我们的设计给我们一种抽象的感觉,好像一个迭代器就指向了一个缓冲区内的数据成员。当然了,这也都是迭代器的内部设计所带给我们的抽象感觉,例如我们解引用一个迭代器就可以得到数据成员,我们可以想见,这个迭代器内部一定对operator*操作符进行了重载,里面其实是return *cur,也就是解引用这个迭代器返回的是cur指针指向的成员,这与上文中list中的node指针很像。所以归根结底,这都是迭代器内部设计给我们造成的抽象感觉,你可以简单的认为一个迭代器指向了一个数据成员,但是深究后你会发现很可能是迭代器内部的某个成员指向了该数据成员,而这完全都取决于我们的迭代器的内部设计。还有人可能会问,那么为什么迭代器里要放置这四个数据成员呢,直接就cur不就好了,确实cur指向了我们的目标数据,凭借cur我们就可以访问到它。但是,其实在cur前进后退的过程中,其必须要依赖于first与last,以判断其是否到达边缘地带,而且需要cur指向的是哪个缓冲区上的元素。也就是说,是cur所依赖的操作决定了其必须依赖其他3个成员,否则这个迭代器和一个普通指针有什么区别呢,普通指针能维护整体连续的假象吗?所以我们可以说,其实是node、first、last成员为cur前进、后退铺平了道路,导致其仿佛在一片完全连续的空间上行走,离开了这3个成员,cur将无所适从

STL源码剖析笔记_第13张图片

再仔细研究一下__deque_iterator类的代码,我们发现deque的迭代器中iterator_category为random_access_iterator_tag,因为我们知道,deque的迭代器设计维护了deque整体连续的假象,整体连续意味着其支持随机访问,因此其迭代器类型为random access iterator,我们知道vector的迭代器也是random access iterator,他们有区别吗?其实是有区别的,区别在于内部实现,因为deque实际上内存空间是分段连续的,而不像vector是真正的连续,所以其为了维护整体连续的假象,在设计的时候必然要花一些功夫,其复杂度必然比vector的迭代器要复杂的多,因此,除非必要,我们应尽可能选择使用vector而非deque。举个例子vector的迭代器it执行it+=n和deque的迭代器it执行it+=n,其效率肯定是不一样的,deque执行it+=n,其实内部必然要考虑是否到达缓冲区边缘,是否要跳跃至下一个或上一个缓冲区等。

说了这么多,我们还是以几个__deque_iterator类中的重载操作符来直观的理解。

// 跳到目标缓冲区,注意没有设置cur指针,所以需要单独指定
void set_node(map_pointer new_node) {
    node = new_node;
    first = *new_node;
    last = first + difference_type(buffer_size());
}

reference operator*() const { return *cur; }

pointer operator->() const { return &(operator*()); }

//前置++
self& operator++() 
{
    ++cur;//切换至下一个元素
    if (cur == last)//如果到达所在缓冲区尾端
    {
        set_node(node + 1);//切换至下一个缓冲区
        cur = first;       //的第一个元素
    }
    return *this;
}


// 迭代器跳跃n个距离
self& operator+=(difference_type n) 
{
    difference_type offset = n + (cur - first);
    if (offset >= 0 && offset < difference_type(buffer_size()))
    {//目标位置在同一个缓冲区
        cur += n;
    }
    else 
    {//目标位置不在同一个缓冲区
        difference_type node_offset = offset > 0 ? offset / difference_type(buffer_size())
: -difference_type((-offset - 1) / buffer_size()) - 1;

    set_node(node + node_offset);
    cur = first + (offset - node_offset * difference_type(buffer_size()));
    }
    
    return *this;
}

上面由于篇幅关系,只展示了部分重载操作符,但是我们把上面的理解了剩下的也都不是问题。我们就以前置++与+=操作符来看,我们发现其内部都判断了是否到达缓冲区尾,然后依据不同的情况执行了不同的操作。因此,可以想见deque迭代器的++和vector迭代器的++执行的效率必然是不一样的。

deque的数据结构

这里不打算细讲,大致对其做一个总结。deque除了维护一个指向中控器的指针外,也维护start、finish两个迭代器,分别指向第一缓冲区的第一个元素和最后缓冲区的最后一个元素(的下一个位置)(其实是迭代器内部的cur成员指向元素)。此外,它必须记住目前中控器的内存大小,因为一旦中控器所提供的节点不足,就必须重新配置更大的一块map。一般情况下,一个中控器至少包含8个节点,最多是所需节点数加2.并且在实际分配的时候,中控器中的节点都是靠近最中间,以保证头尾两端如果有元素要扩充进来,其扩充的裕量一样大。当中控器内存空间不够时,其会配置更大的内存空间,将原来的中控器中的节点拷贝过来,并释放旧空间,缓冲区地址不变。

slist

前面我们了解到list是一个双向环状的链表,而这里要说的slist是一个单向链表,因此其迭代器属于单向的Forward Iterator,因此其功能相较于list会有所受限,但是优点在于其耗用的空间更小,某些操作更快,在特定场合不失为一种更好的选择。需要说明的是,slist并不在标准规格之内。

list的迭代器在插入、删除、结合等操作都不会引起原有迭代器失效,当然了,删除操作指向被删除元素的迭代器,必然是会失效的。另外,根据STL的习惯,插入操作会将元素插入到指定的位置之前,而由于slist是单向链表,其无法方便的找到前节点,因此必须要从头找起,所以除非在slist的起始点附近进行insert操作,否则是不明智的,效率会很低,erase也是同理。为此,slist设计了insert_after以及erase_after,可以在指定位置之后进行元素插入或者删除。基于同样的原因,slist只提供push_front,不提供push_back。但是slist还是提供了insert和erase,只不过不建议使用。

slist的节点与迭代器

知悉了list后,其实slist就很简单了,很多方面都很像。但也是有区别的,区别在于slist节点与迭代器设计,运用了继承关系。如下图所示,迭代器类继承了迭代器基类,节点类继承了节点基类。迭代器的有些实现是在基类里实现的,有些是在派生类里实现的,例如基类里实现的重载操作符operator==,当两个slist的迭代器比较是否相等,其是比较__slist_node_base* node是否相等,而不是比较__slist_node* node是否相等,这在下图中有所体现。

STL源码剖析笔记_第14张图片

在list中,我们知道其为了表征前开后闭区间,其在list类中定义了一个指针成员node,一直指向尾后的空白节点,以表征list的末尾。那么在slist中是如何体现的呢?

在slist中,我们节选了其中的部分定义:

typedef __slist_iterator iterator;
iterator end() {return iterator(0)};

也就是说,假设我调用了slist的end()成员函数,其会返回给我们一个临时对象,

__slist_iterator(0);//产生一个临时对象,调用构造函数

由于__slist_iterator类里定义了如下构造函数,所以上述语句又会执行下述构造函数:

__slist_iterator(list_node* x):__slist_iterator_base(x){}

也就是说,调用__slist_iterator类的构造函数又会去调用__slist_iterator_base的构造函数,也就是其基类的构造函数,因此上式等价于

__slist_iterator_base(0)

而__slist_iterator_base的源代码中有:

struct __slist_iterator_base
{
    __slist_node_base* node;
    __slist_iterator_base(__slist_node_base* x):node(x) {}
    ...
};

因此也就相当于

node(0);

把指向节点基类__slist_node_base的指针成员node初始化为空,也就是是该指针什么都不指,如下图中的islist.end().

STL源码剖析笔记_第15张图片

那么它和slist的最后一个元素指向的迭代器相等吗,答案是不等的。如下图:

STL源码剖析笔记_第16张图片

islist.end()是一个迭代器,其将指向节点基类__slist_node_base的指针成员node设为空,也就是说这个迭代器其实并没有指向一个__slist_node_base节点基类,而迭代器ite指向了节点基类__slist_node_base的一个类的对象,只不过类对象里面的成员next是空的而已,故而他们是不等的,因此可以实现前开后闭

adapter(配接器)

除了之前介绍的很多序列式容器之外,STL中还定义了3个容器适配器,stack、queue和priority_queue.之所以称他们为容器适配器,是因为他们的底层是之于之前介绍的序列式容器实现的,其只不过修改了他们的接口,使之呈现不同的风貌而已。

stack

stack以双向开头的deque作为底部结构,我们都知道,stack是先进后出的数据结构,其只有一个出口,不允许有遍历行为,其每次只能访问栈顶元素。因此,stack将双向开口的deque的头端开口封闭起来。由于stack不提供走访功能,所以其不提供迭代器。以下是stack的实现细节,非常简单。

template  >
class stack 
{
    
friend bool operator== __STL_NULL_TMPL_ARGS (const stack&, const stack&);  
    
friend bool operator< __STL_NULL_TMPL_ARGS (const stack&, const stack&);
    
public:
    typedef typename Sequence::value_type value_type;
    typedef typename Sequence::size_type size_type;
    typedef typename Sequence::reference reference;
    typedef typename Sequence::const_reference const_reference;
    
protected:
    Sequence c;  // 存放数据的容器
    
public:
    // stack是否为空
    bool empty() const 
    { 
        return c.empty(); 
    }
    
    // stack大小
    size_type size() const 
    { 
        return c.size(); 
    }
    
    // 查询栈顶数据
    reference top() 
    { 
        return c.back();
    }
    
    // 常函数查询栈顶数据
    const_reference top() const 
    {
        return c.back(); 
    }

    // 栈顶插入数据
    void push(const value_type& x) 
    { 
        c.push_back(x); 
    }
    
    // 栈顶弹出数据
    void pop() 
    { 
        c.pop_back();
    }
};

// 判断两个栈的Sequence是否是同一个
template 
bool operator==(const stack& x, const stack& y)
{
    return x.c == y.c;
}

// 判断两个栈Sequence的大小
template 
bool operator<(const stack& x, const stack& y)
{
    return x.c < y.c;
}

queue

queue与stack非常类似,一般情况下其也是基于deque实现的,queue是一种先进先出的数据结构,其也不允许有遍历行为,因此也没有迭代器。另外,由于list也是双向开口的结构,所以其也可以基于list实现queue。

priority_queue

priority_queue是一个优先级队列,其允许用户以任何次序将任何元素推入容器内,但每次取出时一定是优先级最高(也就是数值最高)的元素。其内部实现默认是基于vector的,当然也可以指定其他容器,但是其在构建优先级队列中的时候用到了堆排序(heap sort)算法,并且用的是最大堆。

第五章 关联式容器

前言

标准的STL关联式容器分为set(集合)和map(映射表)两大类,以及这两大类的衍生体multiset(多键集合)和multimap(多键映射表),也就是说,set和map的键值key不可以重复,而multiset与multimap的键值key可以重复。这些容器的底层都是以RB-tree(红黑树)完成的。RB-tree也是一个独立的容器,但是其不对外开放。

除此之外,SGI STL还以hash table(散列表)为底层机制实现了hash_set、hash_map、hash_multiset和hash_multimap,由于RB-tree具有对键值自动排序功能,所以以RB-tree为底层的set与map等都是键值有序的,而以hash table为底层实现的set和map等都是无序的,所以后来以hash table为底层实现的set和map等分别被称为unordered_set、unordered_map、unordered_multiset和unordered_multimap。

因此要想搞懂关联式容器,其实只要搞懂RB-tree和hash table的实现手法即可。我们知道,所谓关联式容器,其必须要为我们提供良好的搜寻效率,因此无论是RB-tree还是hash table,其容器内部在存放元素的时候必须要按照某种特定规则将元素放到合适的位置,这样才能实现快速查找。好了,那我们就来分别来看看RB-tree与hash table的实现手法吧,看看它究竟是如何实现快速查找的。

RB-tree

前人栽树,后人乘凉,这里首先必须得强推一篇博客,关于红黑树的,写的特别好,博客链接:https://blog.csdn.net/hackbuteer1/article/details/7740956。接下来我的关于RB-tree的总结也是在这篇博客的基础上引入我自己的一些见解。

看过大话数据结构这本书的一定知道,AVL树,也就是平衡二叉树,其是一种高度平衡的二叉搜索树,平衡因子的绝对值必须小于等于1,否则树就是不平衡的,保持高度平衡的好处就是当我们搜寻元素的时候,由于不存在某个节点过深的情况,因此查询的效率就会比较高,总是能在较短时间内就找到目标值。RB-tree与AVL树很类似,也是一种平衡二叉树,但是其平衡要求与AVL树不一样,没有AVL树要求那么严苛,由于AVL树对平衡的要求过于严苛,其插入或删除操作往往会引起多次的旋转操作,因此其插入或删除效率比较低,而RB-tree插入操作最多只需要2次旋转,删除操作最多需要3次旋转。而查询效率方面,由于AVL是高度平衡的,所以其比RB-tree是要好一些的,但是统计数据表明,RB-tree与AVL树的查询效率相差并不大,因为怎么说呢,RB-tree它本身也是相对比较平衡的二叉树,其查询性能最多比相同内容的AVL树多查询一次,相比于维护AVL树的开销,显然RB-tree是一种更好的选择,因此STL的set与map是以RB-tree为底层实现的。

RB-tree需要满足的条件:

RB-tree作为一个二叉搜索树,需满足以下4个条件:

  • 每个节点不是红色就是黑色
  • 根节点为黑色
  • 如果节点为红色,其子节点必须为黑色,即父子节点不能同时为红色
  • 任一节点至NULL(树尾端)的任何路径,所含黑色节点数必须相同

根据上述规则4,我们可以推知新增节点必为红色;根据规则3,新增节点的父节点必须为黑色。当红黑树因插入节点而导致不满足4个规则时,我们需要适当的调整节点颜色并旋转树形。

树的旋转

树的旋转很多人根本记不住,觉得他很繁琐。其实旋转真的很简单,只要牢牢记住下面两张动图即可。

 

STL源码剖析笔记_第17张图片 左旋
STL源码剖析笔记_第18张图片 右旋

以树的左旋为例,首先我们需要将节点S的左子树挂接为节点E的右子树然后令节点S的左子树为E为根节点的那个子树最后用S顶替E的地位,即原来E是这一小块的根节点,现在应该变为S,这样的话,加入上面有树的话,不会影响上面树对下面树的关系。

RB-tree上节点的插入

这部分绝大多数照搬上面那篇博客的内容,同时加以部分自己的理解。  在讨论红黑树的插入操作之前必须要明白,任何一个即将插入的新结点的初始颜色都为红色。这一点很容易理解,因为插入黑点会增加某条路径上黑结点的数目,从而导致整棵树黑高度的不平衡。但如果新结点的父结点为红色时(如下图所示),将会违反红黑树的性质:一条路径上不能出现相邻的两个红色结点。这时就需要通过一系列操作来使红黑树保持平衡。

STL源码剖析笔记_第19张图片

 

为了清楚地表示插入操作以下在结点中使用“新”字表示一个新插入的结点;使用“父”字表示新插入点的父结点;使用“叔”字表示“父”结点的兄弟结点;使用“祖”字表示“父”结点的父结点。插入操作分为以下几种情况:

1、黑父

     如下图所示,如果新节点的父结点为黑色结点,那么插入一个红点将不会影响红黑树的平衡,此时插入操作完成。红黑树比AVL树优秀的地方之一在于黑父的情况比较常见,从而使红黑树需要旋转的几率相对AVL树来说会少一些。

2、红父

     如果新节点的父结点为红色,这时就需要进行一系列操作以保证整棵树红黑性质。如下图所示,由于父结点为红色,此时可以判定,祖父结点必定为黑色。这时需要根据叔父结点的颜色来决定做什么样的操作。青色结点表示颜色未知。由于有可能需要根结点到新点的路径上进行多次旋转操作,而每次进行不平衡判断的起始点(我们可将其视为新点)都不一样。所以我们在此使用一个蓝色箭头指向这个起始点,并称之为判定点。

STL源码剖析笔记_第20张图片

2.1 红叔

当叔父结点为红色时,如下图所示,无需进行旋转操作,只要将父和叔结点变为黑色,将祖父结点变为红色即可。但由于祖父结点的父结点有可能为红色,从而违反红黑树性质。此时必须将祖父结点作为新的判定点继续向上(迭代)进行平衡操作。同时需要注意迭代结束之后务必要保证根节点为黑色

STL源码剖析笔记_第21张图片

需要注意的是,无论“父节点”在“叔节点”的左边还是右边,无论“新节点”是“父节点”的左孩子还是右孩子,它们的操作都是完全一样的(其实这种情况包括4种,只需调整颜色,不需要旋转树形)。

2.2 黑叔

当叔父结点为黑色时,需要进行旋转,以下图示了所有的旋转可能:

Case1:父左新左

STL源码剖析笔记_第22张图片

Case2:父左新右

STL源码剖析笔记_第23张图片

Case3:父右新左

STL源码剖析笔记_第24张图片

Case4:父右新右

STL源码剖析笔记_第25张图片

 可以观察到,当旋转完成后,新的旋转根全部为黑色,此时不需要再向上回溯进行平衡操作,插入操作完成。需要注意,上面四张图的“叔”、“1”、“2”、“3”结点有可能为黑哨兵结点(即NULL节点)。

来总结一下红黑树的插入操作:

  1. 黑父:直接插入 什么也不需要做
  2. 红父红叔:以新节点为判定点检查是否违反红黑树性质3,若违反,调整颜色,再以新节点的祖父节点为新的判定点向上迭代,重复执行,最后要保证根节点为黑色。红父红叔只需调整节点颜色,不需旋转。
  3. 红父黑叔:父节点与新节点都在外侧,只需要单旋,父节点与新节点都在左侧,右旋;父节点与新节点都在右侧,左旋。    父节点在外侧,新节点在内侧,双旋。父节点在左,新节点在右,LR旋转,父节点在右,新节点在左,RL旋转。注意,所有的旋转都是以新节点作为判定点,即使双旋第一步之后再旋仍是以新节点为判定点,也就是说,都是把要旋转的那个子树的根节点最终设为新节点。

RB-tree的相关设计

RB-tree的节点与迭代器的关系:

STL源码剖析笔记_第26张图片

RB-tree的节点与迭代器都是一种双层架构,派生类继承至基类,仅仅将派生类对外开放,将基类进行了封装,所以我们无法改变基类。

我们来看一下关于RB-tree节点的基类与派生类,首先是基类 __rb_tree_node_base:

typedef bool __rb_tree_color_type;
const __rb_tree_color_type __rb_tree_red=false;//红色为0
const __rb_tree_color_type __rb_tree_black=true;//黑色为1
struct __rb_tree_node_base
{
    typedef  __rb_tree_color_type color_type;
    typedef  __rb_tree_node_base* base_ptr;
    color_type color;  //节点颜色,非红即黑
    base_ptr parent;//RB树的许多操作,必须知道父节点
    base_ptr left;//指向左节点
    base_ptr right; //指向右节点
    static base_ptr minimum(base_ptr x)
    {
        while(x->left!=0) x=x->left;//一直向左走,就会找到最小值,这是二叉搜索树的特性
        return x;
    }
    static base_ptr maximum(base_ptr x)
    {
        while(x->right!=0) x=x->right;//一直向右走,就会找到最大值,这是二叉搜索树的特性
        return x;
    }
}

我们再来看一下派生类__rb_tree_node:

template 
struct __rb_tree_node:public __rb_tree_node_base
{
    typedef __rb_tree_node* link_type;
    Value value_field;//节点值
}

为什么要用双层架构,其实可以从上面的__rb_tree_node_base基类和派生类__rb_tree_node类就可以看出来了。从上面的定义中我们看出,基类__rb_tree_node_base并不是一个模板类,其将RB-tree的必要部分都准备好,但是__rb_tree_node是一个模板类,因为我们知道,RB-tree中的节点值类型并不是固定的,举例来说,set容器的节点值的类型为键值key类型,而map容器的节点值类型为键值key与数据data的组合类型,因此其对外以__rb_tree_node作为模板类接口,这样的话基类部分就可以封装起来,不对外开放。同理,RB-tree的迭代器设计也是这个道理。

RB-tree的迭代器为双向迭代器,但不具备随机定位能力。RB-tree的迭代器较为特殊的地方在于其前进和后退操作。因为是二叉搜索树,其前进操作必须使得访问的元素逐渐增大,所以要专门设计前进与后退操作,也即要重载operator++与operator--。RB-tree的迭代器__rb_tree_iterator的operator++与operator--是藉由RB-tree的基层迭代器__rb_tree_base_iterator中的increment()与decrement()函数实现的,这两个函数的具体细节此处不做讨论,详见书本P216。

RB-tree的数据结构:RB-tree的数据结构中一个比较独到的设计在于其类里定义了一个header指针成员,header指针指向一个节点,该节点与root节点互为对方的父节点,并且该节点的左孩子为RB-tree的最小节点,该节点的右孩子为RB-tree的最大节点,这样我们通过header指针很快就可以访问树的最小最大节点,也可以很快通过begin()与end()函数返回迭代器,这是实现上的一个技巧。

STL源码剖析笔记_第27张图片

另外,RB-tree针对插入的元素是否可以重复设计了两个版本的插入函数insert_unique()与insert_equal().

set、map、multiset与multimap

上述这4个容器都是基于RB-tree这一容器作为底层进行实现的,类似于第四章中的容器适配器。所以着实没什么好讲的。主要来讲一讲几个重要的地方,先以set容器观之,只选取了部分代码:

 

template , class Alloc= alloc>
class set
{
    public:
        typedef Key key_type;
        typedef Key value_type;
        typedef Compare key_compare;
        typedef Compare value_compare;
    private:
        typedef rb_tree,key_compare,Alloc> rep_type;
        rep_type t;//采用红黑树来表现set
   public:
        typedef typename rep_type::const_iterator iterator;//set不允许用户在任意处进行写入操作
        template
        set(InputIterator first,InputIterator last)
        :t(Compare()){t.insert_unique(first,last);}
}

从上面来看,我们发现set的key_type与value_type类型都为Key,因为set容器键值类型与元素类型相同。同时我们发现set的迭代器其实是RB-tree的const_iterator,为什么要是const_iterator呢?因为set元素值就是键值,关系到set元素的排列规则。如果任意改变set元素,会严重破坏set组织,所以它的迭代器被设计为RB-tree的const版本的迭代器。另外,由于set的键值不允许重复,我们可以看到其插入元素调用的是rb_tree的insert_unique()。

再来窥探一下map吧。

template , class Alloc= alloc>
class map
{
    public:
        typedef Key key_type;//键值类型
        typedef T data_type;//数据(实值)类型
        typedef T mapped_type;
        typedef pair value_type;//元素型别(键值/实值) 注意Key前的const
        typedef Compare key_compare;
    private:
        typedef rb_tree,key_compare,Alloc> rep_type;
        rep_type t;//采用红黑树来表现map
   public:
        typedef typename rep_type::iterator iterator;//map并没有像set一样将iterator定义为RB-tree的const_iterator
        template
        map(InputIterator first,InputIterator last)
        :t(Compare()){t.insert_unique(first,last);}
}

从上面来看,我们发现map的元素类型为pair,这也是符合逻辑的,因为RB-tree中的节点存放的元素包括了map的键值与实值。另外我们注意到,map的迭代器用的就是rb_tree的普通迭代器,其并不是const的,这也是合乎逻辑的,因为有时候我们需要通过键值去改变实值,但是map的键值是不允许改变的,我们注意到,上面的pair里面的Key前面加了const,这就是一个实现细节了,通过这样的设计可以防止map的键值被篡改。另外,map的键值也不允许重复,所以其插入操作也是调用的是rb_tree的insert_unique()。

multiset与multimap与set和map唯一的差别在于他允许键值重复,所以插入操作调用的是rb_tree的insert_equal()而不是insert_unique()。

hash table

hash table相关设计

hash table相较于RB-tree要简单很多,hash table是以开链的手法实现的。其首先以vector来承载元素,每一个元素称为一个bucket(桶子),桶子里的每个单元存放一个指针,指针类型为__hashtable_node,也就是一个链表节点

STL源码剖析笔记_第28张图片

__hashtable_node的定义如下:

template
struct __hashtable_node
{
    __hashtable_node* next;
    Value val;
}

上面的模板参数Value就是要存放的元素类型,对set来说,即为key,对map来说,即为key与data组合而形成的pair类型。因当注意到,hash table中的buckets vector本身是并不存放我们想要存放的元素的,其内部存放的是指针,去指向我们想要存放的链表节点,用户存放的数据其实是保存在链表节点中的,hash table中的buckets vector只不过为我们提供头指针,方便我们查询想要的数据。

我们都知道,利用hash table可以很快的查找到我们想要查询的元素,那么它是如何做到的呢,其是他主要通过hash function在元素与元素所对应的bucket之间形成了一种映射关系,这样可以方便的定位到我们要查找的元素。也就是说,通过hash function,我们可以计算出元素的位置,更准确的说,应该是元素所对应的bucket(桶子)的位置,这样我们通过桶子里存放的指针,去遍历链表,知道找到我们想要的元素(其实就是通过hash function去获得hash值)。那么这个hash function要怎么设计呢,标准库其实也就为特定的一些类型为我们定义了hash function,大多数情况下都需要我们自己提供一个hash function,总之就是越乱越好。标准库对一些数值类型定义的hash function,直接返回对应的值作为hash值,然后利用hash值与buckets vector的大小取余,以决定存放在哪一个桶子中。通过hash值取余buckets vector的大小,这个方法一般所有库都是这样做的,但是如何设计hash function去获得hash key,这个因人而异,只要尽可能的乱就好了。例如,标准库对char*与const char*是这样设计以得到hash值的。

inline size_t __stl_hash_string(const char* s)
{
    unsigned long h=0;
    for(;*s;++s)
        h=5*h+*s;
    return size_t(h);         
}
__STL_TEMPLATE_NULL struct hash
{
    size_t operator()(const* s) const {return __stl_hash_string(s);}
};
__STL_TEMPLATE_NULL struct hash
{
    size_t operator()(const* s) const {return __stl_hash_string(s);}
};

hash table元素的放入:

STL源码剖析笔记_第29张图片

如上图所示,buckets vector的大小为53,因为55%53=2,2%53=2,55%53=2,所以它们挂在同一个链表上,同时,我们应当注意到,hash table的元素插入是没有顺序的,也就是说他不会自动排序,最后插入的55直接插在了bucket的最前面。另外,一旦当前插入元素的个数大于buckets vector的大小,也就是元素个数比桶子多的时候,buckets vector会自动扩容,扩大到一个实现规定好的约等于原先大小两倍附近的一个质数,这是SGI STL的实现方式。

hash table的迭代器是一个forward iterator,其最关键的地方在于要维系着整个buckets vector的关系,并记录目前指向的节点,这一切主要靠重载operator++()实现。如果当前迭代器指向的节点并不位于链表的尾端,直接利用链表的next指针即可前进,而如果当前指向的节点位于链表尾端,迭代器前进的时候需要能够跳到下一个bucket上,实现起来并不难。举个例子,以上面的图为例,当迭代器指向2所在的节点时,前进操作只要直接利用next指针指向108所对应的节点即可,但是如果现在迭代器指向108对应的节点,接下来前进的话需要能够跳到59对应的节点。

hash_set、hash_map、hash_multiset与hash_multimap

hash_set、hash_map、hash_multiset与hash_multimap与set、map、multiset与multimap唯一的不同就是他们的底层是基于hash table实现的,他们所使用的迭代器等其实都是hash table提供的,他们本身也就相当于容器适配器。不过也正是因为他们是基于hash table实现的,所以他们的键值并不具备排序功能,键值是unordered的,这一点务必要注意

第六章 算法

说实话,这一章我并没有太仔细的看,原因在于本章节介绍的更多的是具体泛型算法的实现,而这其实已经脱离了C++语言本身,更多的设计了数据结构与算法方面的。不过,仍然有一些需要注意的地方,主要是STL算法的一般形式,知悉这些,对于我们用好标准库提供给我们的泛型算法很有帮助。

STL算法的一般形式

  • 所有STL算法前两个参数都是一对迭代器,通常称为first和last,前闭后开区间
  • 每个STL算法的声明中,都表现了它所需要的最低程度的迭代器类型,我们知道迭代器有5类,我们可以传递给算法它所要求的迭代器类型的强化版,尽管这样可能会导致算法效率降低,但是千万不能传递给它所要求的迭代器的弱化版。传递给一个泛型算法弱化版的迭代器类型,编译器并不能捕捉到错误,原因在于泛型算法本身是一个函数模板(function template),所谓的迭代器类型并不是真正的型别,其只不过是一个函数模板的型别参数而已,所以传递给一个泛型算法弱化版的迭代器类型,很可能会导致运行时出错。
  • STL为很多算法提供了if版本,如find和find_if,if版本的算法通常接受额外的参数,接受外界传入的一个仿函数,表示一种在满足特定条件下执行某种操作。
  • STL为很多算法提供了copy版本,例如replace和replace_copy,copy版本接受一个额外的参数,参数是一个输出迭代器,表示将要执行的操作的结果拷贝到输出迭代器所执行的位置,比较常见的是与迭代器适配器(iterator adapters)搭配使用。
  • 泛型算法分质变算法(mutating algorithms)和非质变算法(nonmutating algorithms),质变算法表示会改变操作对象的值,如果不是copy版本,就表示在原来的迭代器区间改变区间对象里的值,如果是copy版本,表示原区间的操作对象值不改变,将改变后的值都拷贝到要输出迭代器所指向的位置,例如reverse与reverse_copy,反转元素次序,一个是在原区间直接反转,一个是将反转的结果拷贝到另一个地方。非质变算法表示算法本身不会改变操作对象的值,例如find查找算法。
  • 泛型算法可能会改变操作对象的值,即质变算法,但是永远不会改变操作区间的大小,例如传给算法一对指向容器的迭代器,算法本身绝对不会改变容器的大小,其最多改变容器内的元素值。

copy--强化效率无所不用其极

这里以copy为例来展现泛型算法在合适不过了,通过copy算法我们可以看到诸多技巧,包括函数重载、型别特性萃取、偏特化等,而这些技巧的使用其实都是为了使算法的效率得到加强,copy算法真的是STL泛型算法设计中无所不用其极强化程序效率的典范了。

STL源码剖析笔记_第30张图片 SGI STL中copy算法完整脉络

copy算法首先实现了两个特化版本,针对参数类型为const char*与const wchar_t*这两种原生指针类型,其内部直接调用底层的memmove,直接进行内存拷贝操作。memmove是一种内存的底层操作,速度极快。对于其他类型的输入参数,其首先会调用完全泛化版本的copy,其内部调用了__copy_dispatch,这个copy函数定义如下:

template
inline OutputIterator 
copy(InputIterator first, InputIterator last, OutputIterator result)
{
  return __copy_dispatch()
      (first,last,result); 
}

__copy_dispatch()又针对参数为原生指针形式设计了两种偏特化版本,即__copy_t,__copy_t会根据其指针T所指的对象类型中是否有trival assignment operator(无意义的赋值运算符),如果所指之物的赋值运算符是无意义的,其就会去调用底层的memmove,非常快。而如果所指之物的赋值运算符是non-trivial的,则会调用__copy_d,这个函数会以n决定循环的执行次数,速度相较于memmove肯定是要差的,但效率也还可以。那么SGI STL是如何识别所谓的trival assignment operator,这里就用到了萃取机制,即__type_traits<>编程技巧。需要注意的是,这种技法是SGI STL库所特有的,C++语言本身是无法检测摸个对象的型别是否具有trival assignment operator。之前说到__copy_dispatch()内部会依据是否是原生指针去调用偏特化版本,如果不是原生指针,则会调用泛化版本的__copy,泛化版本的__copy又会根据迭代器的型别去调用不同的函数。如果迭代器型别是输入迭代器,则会去一个个拷贝元素,在拷贝元素的过程中,要不断的判断first与last迭代器是否相等,以决定拷贝是否要继续,这个速度是比较慢的。而如果迭代器类型是随机迭代器,我们知道随机迭代器是可以常数时间访问任何元素的,因此他调用的是前面针对原生指针的__copy_d,这个copy与前面的输入迭代器的copy相比,它不需要每次判断迭代器是否相等,而是直接以一个变量n来决定拷贝的次数,速度上比输入迭代器版本的拷贝算法要快。

从上述的copy算法中,我们可以看到里面运用了大量的偏特化、函数重载与型别萃取,这些都是为了调用到合适版本的copy,以实现算法效率的最强化,真的令人拍案叫绝!!!

第七章 仿函数

所谓仿函数,其本质上是一个类对象,但是由于其内部重载了operator()操作符,使得类对象成为了一种可调用对象,一种具有函数特质的对象,因而被称为仿函数。仿函数最主要的作用就是搭配STL的算法使用,这样我们就可以给算法指定我们想要的操作,这样可以极大的增加程序的灵活性。

不过,要想使仿函数完全融入STL这个大家庭,STL中的仿函数务必要具有配接能力,所谓配接能力,即每一个仿函数都必须定义自己的相应型别,就好像迭代器至少需要保证定义iterator_category、value_type、differennce_type、pointer、reference等5个相应型别一样。这些相应型别是为了能够让配接器取得仿函数的某些信息。有时候我们定义的一些仿函数没有定义自己的相应型别,因而也就没有配接能力,虽然执行STL算法的时候也是正常的,但是并不保证在其他某些情况的时候其也能正常运作,某些情况下,我们必须要回答相应型别,如果我们没有提供,编译器就会报错。

那么到底怎样算仿函数具有配接能力呢,前面说了,就是定义相应型别。那么到底要定义什么样的相应型别呢,仿函数的相应型别主要是指仿函数所接受的函数参数型别与传回值型别。为了方便起见,STL要求我们按个人所需继承下面其中一个class,分别代表一元仿函数和二元仿函数,STL不支持三元仿函数。只要继承了其中一个类,仿函数就自动拥有了相应型别,也就自动拥有了配接能力。

这两个仿函数clss定义如下:

首先是一元仿函数:

template
struct unary_function{
    typedef Arg argument_type;
    typedef Result result_type;
}

接下来是二元仿函数:

template
struct binary_function{
    typedef Arg1 first_argument_type;
    typedef Arg2 second_argument_type;
    typedef Result result_type;
}

应当注意到,上面的这两个仿函数class,内部只不过提供了一些typedef,也就是提供了相应型别,所有必要的操作在编译期就全部完成了,对程序的执行效率没有带来任何影响,不会带来任何额外负担。

第八章 配接器

前言

所谓配接器,就是将一个class的接口转化为另一个class的接口,是原本因为接口不兼容而不能合作的classes,可以一起运作。

配接器主要分为三种,container adapter(容器配接器)、iterator adapter(迭代器配接器)、function adapter(函数配接器)。

container adapters(容器配接器)

容器配接器非常简单,这个在前面第4章已经讲过,其只不过是以一个容器作为底层容器,修改该底层容器的一些接口,是指对外呈现不同的面貌,如stack是一个配接器,作用于底层容器deque之上。

iterator adapters(迭代器配接器)

迭代器配接器分为insert iterators、,reverse iterators和stream iterators等几种。迭代器配接器多多少少与其他两类配接器有所区别,原因在于迭代器配接器很少以迭代器为直接参数,例如insert iterators是以容器为直接参数,reverse iterators确实是以迭代器为直接参数,而stream iterators则是把迭代器绑定到了stream(数据流)对象上。而我们知道容器适配器其就是对容器去进行配接,仿函数配接器对仿函数进行配接,迭代器配接器并不一定是对迭代器进行配接

下面我们以以insert iterators(插入迭代器)中的一个例子来看一下里面绝妙的设计技巧,插入迭代器的适配器包括back_insert_iterator、front_insert_iterator以及insert_iterator,这里我们以insert_iterator类为例,来看看迭代器适配器。

insert_iterator类的实现如下:

STL源码剖析笔记_第31张图片

我们以原书P318的copy函数为例来讲讲为啥要这样设计,原书P318的copy函数其实会调用不同的版本,这里仅选择等价的其中一个版本为例:

template
OutputIterator 
copy(InputIterator first, InputIterator last, OutputIterator result)
{
    while(first!=last)
    {
    *result=*first;
    ++result;
    ++first;
    }
    return result;
}

我们知道,copy函数的前两个参数是输入迭代器,第三个参数是一个输出迭代器。copy的作用就是把两个输入迭代器范围内的元素拷贝到输出迭代器result指向的空间。为什么说迭代器适配器设计的非常精妙呢,原因就在于其操作符的重载。里面重载了operator=、operator*、operator++以及operator++(int)。

让我们再结合具体的实例来讲解重载操作符的精巧设计。

list foo,bar;
for(int i=1;i<=5;i++)
{
    foo.push_back(i);
    bar.push_back(i*10);
}
list::iterator it=foo.begin();
advance(it,3);//使迭代器前进3个元素位置
copy(bar.begin(),bar.end(),inserter(foo,it));

以下面一张图来展示copy的过程。

 

上面的这个例子把迭代器适配器中的插入迭代器insert_iterator与泛型算法中的copy函数结合在了一起。首先,inserter是一个辅助函数,其把insert_iterator做了一层封装,方便用户使用insert_iterator。当我们调用inserter(foo,it)时,其就调用了insert_iterator类的构造函数去创建了insert_iterator类对象,这个类对象也就赋给了copy中的第三个参数,也就是输出迭代器result。接下来,让我们看看为什么要这样重载操作符。我们只需关注与result有关的操作符。首先在copy函数中,第一个就是*result中的operator*,我们发现,operator*的重载只是简单的返回自己,接下来是=,我们发现,operator=内部调用了container的insert函数,在本例中对应的就是list的insert函数,然后把list的迭代器成员iter前进一个元素,这样就完成了list的元素插入操作。接下来,++result也就是前置++,我们发现重载后还是返回自己。这些正好与我们的copy函数的各种操作是契合的。其正好把原来的赋值操作通过适配器以后变成插入操作,形成了表面上是赋值,实际上是插入的景象。所以上面的例子也就不难理解了,*first就相当于bar中的10、20、30、40、50,其通过插入迭代器适配器被安插在foo中的元素3之后。

另外有一点需要注意,C++中5类迭代器的从属关系如下,注意,这里的从属并不是指继承关系,而是指强化关系,箭头的朝向表示强化。也就是说,假设一个STL泛型算法接受输出迭代器,其必然也接受前向迭代器。

STL源码剖析笔记_第32张图片

因此,下面的例子:

	vector a = { 1,3,4,2 };
	vector c;
	c.resize(4);
	copy(a.begin(), a.end(), c.begin());

我们知道,vector的迭代器是一种随机迭代器,所以上述copy中的第三个参数是随机迭代器,而我们知道泛型算法copy的第三个参数是输出迭代器(实际上其只是模板参数,并不特指output_iterator_tag,模板参数OutputIterator只是表示算法所接受的最弱层次的迭代器),随机迭代器是输出迭代器的强化,因此必然是可行的。实际上,c.begin()是一个双向迭代器。

另外,我们也需关注一下stream iterators中的一些设计,其和前面介绍的insert_iterator都有共通之处,技术点主要在于操作符的重载。这里看istream_iterator,istream_iterator内部维护着一个istream member(通过一个stream指针进行维护),客户端对这个迭代器所做的operator++操作,会被导引导迭代器内部所含的那个istream member的输入操作(operator>>)。因此,istream_iterator关键点就在于operator++的重载,每++一次,就会读入一次元素,例如上面例子中copy算法的++first操作,就会引发读操作。另外,需要注意的是,istream_iterator在执行构造函数的时候就会开启读入,这与一般我们的理解有点出入,我们一般认为对象还没构造好,怎么就开始读元素了呢,但是这里就是这样,在构造过程中就开始读元素。只有我们输入了元素,构造过程才算完成。

function adapters(函数配接器)

仍然以一例来观之,那就是binder2nd,代码呈上:

template class binder2nd
:public unary_function
{
   protected:
       Operation op;//内部成员 op为一种操作的实例对象 
       typename Operation::second_argument_type value;//回答操作的第二参数类型
   public:
       binder2nd(const Operation& x,const typename Operation::second_argument_type& y)
       :op(x),value(y){}
       typename Operation::result_type//回答操作的结果类型
       operator()(const typename Operation::first_argument_type& x) const{
           return op(x,value);
       }
}

注意,上面是一个类模板,其通过一个模板函数bind2nd作为接口给用户使用,接口函数bind2nd定义如下:

template
inline binder2nd bind2nd(const Operation& op,const T& x)
{
    typedef typename Operation::second_argument_type arg2_type;
    return binder2nd(op,arg2_type(x));
    //注意,x需要先转型为op的第二参数型别 以less为例,x的类型能不能在less的准则下进行比较
}

接下来展示一个应用实例:

list iv={2,21,12,7,19,23};
count_if(iv.begin(),iv.end(),bind2nd(less(),12));

这里又涉及到count_if以及less的定义,先来看count_if:

template
typename iterator_traits::difference_type
count_if(InputIterator first,InputIterator last,Predicate pred)
{
    typename iterator_traits::difference_type n=0;
    for(;first!=last;++first)
     if(pred(*first))
      ++n;
    return n;    
}

接下来到less了:

template
struct less:public binary_function
{
    bool operator()(const T& x,const T& y) const
    {
        return x < y;
    }
}

好了,所有的定义都定义好了,一起来看看函数适配器是如何来适配的吧!

首先,count_if调用bind2nd接口函数去统计iv中小于12的元素个数。bind2nd函数返回一个类对象binder2nd(op,arg2_type(x))。当我们这样调用bind2nd(less(),12)时,因为我们知道bind2nd本身是一个模板函数,所以我们就推知所谓的class Operation即为less,因此集中精力到biner2nd类,由于Operation我们已经可以由编译器推出来是less,因而这个类里的op只不过是less类的一个对象。我们来看看count_if的第三个参数,类型为Predicate,而由于我们调用count_if的方式,我们传给Predicate的是一个函数bind2nd,该函数传回一个binder2nd>类对象,因此Predicate类等价于binder2nd>。所以传给形参pred的实参就是binder2nd>类的一个对象,因此count_if函数内部pred(*first),相当于调用了binder2nd>类的operator()重载操作符,这个重载操作符相当于执行less(*first,value),也就是less(*first,12),即将链表iv中的元素与12比较,看看是否小于12,这大概就是整个流程。

这里有两个细节需要注意,第一,注意bind2nd函数内部一开始就获取操作Operation的second_argument_type ,有必要吗?

 typedef typename Operation::second_argument_type arg2_type;

答案是有,我们知道binder2nd的第2个参数x,我们传给他的是12,因此其对应的类型T等价于int,而我们知道操作,在本例中就是less,我们在把一个个元素与12比较的时候,务必要保证这样的操作是可行的。什么意思呢,就是你提供给我的less操作必须要能和12比,你必须要告诉编译器这样的事情,假如你写成bind2nd(less(),12),Stone是我们定义的一个类,也就是我们比较的准则是比较石头Stone的大小,但是这里你却用石头的比较准则去与12比较,可以吗?这要看12到底能不能转换为Stone类型,如果不行,编译器在编译的时候就会给我们提示错误,而不至于编译的时候通过,执行的时候却发现无法比较。同理,binder2nd类里也有一堆typename也是同样的道理,这是编译器需要知道的,我们必须要回答他。

第二,binder2nd继承了

unary_function

为什么要继承呢?我们知道,仿函数配接器配接以后,自身还是一个仿函数配接器,所以它理应回答自己的参数型别与返回值类型。至于他为什么继承于unary_function,那是因为binder2nd将参数12与自己绑定后,自身就剩下一个参数,所以就应该继承于unary_function而不是binary_function

另外,对于一般的函数我们想要传给STL算法,就语言本身来说也是可以的,就好像原生指针也可被当做迭代器传给STL算法,但是我们最好调用ptr_fun这一仿函数配接器,否则一般函数无配接问题,在某些情况下可能出错,如果一般函数不通过ptr_fun做一层包装,其无法和其他配接器完全接轨。另外,对于成员函数,我们可以调用mem_fun等8个配接器去进行配接。

总结

对配接器这一章做一个总结:contatiner adapters内藏了一个container member,reverse iterator(adapters)内藏了一个iterator member,stream iterator(adapters)内藏了一个pointer to stream,insert iterator(adapters)内藏了一个pointer to container,function adapters内藏了一个member object,其型别等同于他要配接的对象(那个对象当然是一个可配接的仿函数,adaptable functor)

这里援引原书448页的一些话,算是对STL全书的一些总结。容器是以class templates完成,算法以function templates完成,仿函数是一种将operator()重载的class template,迭代器是一种将operator++和operator*等指针行为重载的class template.配接器其实也是class template。再以下图为例对整个STL源码剖析做一个小小的总结。

STL源码剖析笔记_第33张图片

首先,分配器Allocator为容器Containers分配相应的内存空间,算法Algorithms是对容器Containers内的元素进行某些操作,而算法Algorithms本身不知道容器Containers是什么结构,其需要容器Containers本身设计相应的迭代器,迭代器Iterators在算法Algorithms与容器Containers间架起了一道桥梁,使得算法Algorithms可以作用到容器Containers身上。而算法Algorithms又赋予我们客户指定某种操作的权利,这就需要仿函数Functors的作用。而至于配接器Adapters,其不过是改变类的接口,使得接口兼容,允许原本不能合作的classes可以一起运作。这就是STL六大部件之间的关系。

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

你可能感兴趣的:(STL源码剖析笔记)