【博览网】C++标准库——第二周课程笔记
第二周课程先简单对比了一下面向对象编程和泛型编程的优缺点,详细讲解了泛型编程的特点和基础。然后开始详细介绍几个常用容器的底层代码,本周介绍的容器有list、vector、array和forward-list。同时,为了让我们更好的理解源代码,本周还介绍了traits的原理和作用。
一、面向对象(OPP) vs 泛型编程(GP)
二者的主要特点为:
• OPP:将data和method合并,放置在class中,其核心技术为类的继承与虚函数覆盖(override)。
• GP:将data和method分开,两个部分分别实现,使用迭代器实现方法适应所有data,其核心技术为操作符重载和模板。
在实现标准库数据结构和算法上,GP开发方式的优势在于:
• Container和Algorithm团队可以分开独立开发其部分的内容。
• Algorithm通过Iterator确定操作范围,并通过Iterator取用Container元素。
在STL中,所有Algorithm其内部最终涉及的元素本身大的操作无非就是比大小。
二、分配器
分配器是STL中用于给容器分配内存的类,其底层是通过C++语言中new和delete函数实现。在大多数的编译器中,默认分配器allocator都是直接调用上述函数进行内存分配,没有做任何特殊处理。这种分配方式虽然十分简单,但是存在比较大的效率问题。由第一部分的课程我们知道,编译器在每次为对象分配内存时,不仅仅分配对象所需的空间,并且还会额外分配部分空间来存储一些相关信息,比如内存大小cookie等。因此直接使用这种方式分配内存会使得容器有许多额外的开销,在内存利用方面效率不高。
在GCC编译器中,allocator使用了一项特殊处理,在内存中提前申请了一大块内存,将这些内存按照8的倍数分为若干个存有对应大小的内存块的链表。在容器需要的时候,直接从内存链表中取对应的大小的内存,这样便大大减少了cookie所占的内存量。
关于这个分配器的具体运行细节,可以参考《STL源码剖析》。
三、迭代器设计原则与Iterator Traits的作用与设计
C++的迭代器是用来进行容器遍历和容器数据存取的一种智能指针,在之前的一些学习或者网上资料中我们可以了解到,迭代器主要分为5种,分别为:只读迭代器、只写迭代器、单向迭代器,双向迭代器和随机迭代器。迭代器分为这么多种是因为数据结构各自有其特点,有些迭代器操作在特定的数据结构上不能实现或者可以实现但是效率太低因此不提供这样的操作。举一个简单的例子,list如果需要将迭代器向前移动n步,其所需要消耗的时间为n,因此它不提供直接的向前移动n部的操作,是一个双向迭代器。而vector的迭代器如果需要向前移动n步,其所消耗的时间是常量时间,因此它提供这样一个操作,是一个随机迭代器。
我们知道,每种容器的迭代器由于容器内部的实现不同,因此迭代器的实现也大相径庭,因此每种容器都有一种对应的专属迭代器,那么算法是如何判断传入的迭代器是属于那种类型的呢?在这里,C++标准库没有使用面向对象常用的继承的方式实现类型判断,而是使用非常特殊的Traits来实现类型判断。
Traits使用typename重命名的方式,为一些特定的空类型重命名为统一的新名称,函数在查看迭代器对象类型时,直接调用统一的新名称便可以得到迭代器类型。
上图为一个iterator的示例,在里面实现了类型重定义。既然迭代器本身就可以实现类型重定义了,为什么要使用Traits呢?是因为在算法中除了可以传入普通迭代器,也可以传入普通的指针,所有需要Traits来为普通指针规定对应类型,使用的方法就是我们常见的偏特化技术。如下图所示:
关于为什么要是const指针需要使用非const的类型,在途中也讲解的非常清楚了。
除了迭代器本身的类型,迭代器中数据的相关类型也应该传给算法,方法也是使用Traits实现。因此每个迭代器都需要实现五种类型的重定义,分别为迭代器类型、值类型、指针类型、引用类型、迭代器距离所使用的值类型(可以使用该类型规定迭代器的理论最大大小)。其中,指针类型和引用类型在标准库算法中并未使用过,属于预先准备。
Traits的使用将来在讲解算法的时候结合算法的讲解或许会更加简明易懂。
四、容器
1.容器之间的关系
• array
• vector
○ heap
§ priority_queue
• list
• slist
• deque
○ stack
○ queue
• rb_tree(非公开)
○ set
○ map
○ multiset
○ multimap
• hashtable(非公开)
○ hash_set
○ hash_map
○ hash_multiset
○ hash_multimap
2.list容器
list是一个双向循环链表,其结点的基本结构由三个部分组成,指向前一个数据的指针prev、指向后一个数据的指针next和数据data。迭代器设计比较简单,由于list的容器特点,因此使用的是双向迭代器。迭代器++和迭代器–运算简单的读取next和prev就可以实现。容器本身还会提供链表的一些常见接口,如在前后插入元素等。为了实现容器常见的前闭后开的结构,链表额外增加了一个节点用来表示end数据块。
3.vector容器
vector容器是一个可以向后扩充的数组,其实现的基本思想是预先分配内存,在添加数据时,直接将内存分配给添加进的数据。在每次预留空间使用完后,vector会另外在其他地方寻找两倍于已分配空间的内存,以存放原来的数据。由于vector直接使用的是连续内存,因此容器直接使用原生指针作为迭代器。在高版本的GNU标准库中,对原生指针进行了一次封装作为迭代器,这种改变在侯捷老师看来是化简为繁的不必要之举。但是从软件开发的角度来看,这种繁琐可以使相似代码本身具有更好的一致性,在面对将来可能发生的变动时更加易于进行底层实现的修改。不过从标准库的阅读的角度来看,这种改变的确是没有必要的。
在使用vector时,需要注意数据增加时可能存在的大量数据拷贝成本。
4.array容器
array是定长数组,标准库中定义该类型的容器是为了与原生数组相对应,其实现非常简单,内部就是一个原生数组,迭代器使用原生指针。
5.forward_list容器
forward_list是一个单向链表,其实现与list类似,唯一的不同是它的迭代器是一个单向迭代器。