目录
源代码之分布(VC, GCC)
面向对象编程(Object-Oriented Programming,OOP) vs. 泛型编程 (Generic Programming,GP)
阅读C++标准库源码(Source Code)之必要基础:操作符重载(Operator Overloading)and 模板(Templates)
分配器(allocators)
迭代器(Iterator)的设计原则和 Iterator Traits 的作用与设计
容器之间的实现关系与分类
深度探索 vector
深度探索 list
浅谈 array & forward_list
1. 源代码之分布(VC, GCC)
- VC:...\include
- GCC:...\4.9.2\include\C++
- 原生标准库:...\include\C++\bits
- 扩充标准库:...\include\C++\ext
2. 面向对象编程(Object-Oriented Programming,OOP) vs. 泛型编程 (Generic Programming,GP)
问:为什么 list 不能使用 ::sort() 排序?
答:只有随机访问迭代器才能进行上述加减乘除,而 list 在内存里面是一个一个结点用指针串起来的,并不是一个连续空间,所以它所具备的迭代器是不能够跳来跳去的,它只能一个一个前进或后退。
因此标准库中的 ::sort() 所需要的迭代器是 list 中提供的迭代器所不能满足的。
操作符重载扮演了非常重要的角色。
如果需要定制特殊的算法,对特定“比大小”功能的设计就显得尤为重要。
3. 阅读C++标准库源码(Source Code)之必要基础:操作符重载(Operator Overloading)and 模板(Templates)
3.1 操作符重载(Operator Overloading)
因为之前的笔记中对操作符重载已经有比较详细的介绍,这里就不赘述了,只简单的罗列一些要注意的点:
当一个重载的运算符是成员函数时, this 绑定到左侧运算对象。成员运算符函数的(显式)参数数量比运算对象的数量要少一个。
可以(不能)被重载的运算符:
通常情况下,不应该重载逗号、取地址、逻辑与和逻辑或运算符。
3.2 模板(Templates)
模板是 C++ 中泛型编程的基础。一个模板就是一个创建类或函数的蓝图或者说公式。
模板定义以关键字 template 开始,后跟一个模板参数列表(template parameter list),其不能为空。
当使用模板时,我们(隐式地或显式地)指定模板实参(template argument),将其绑定到模板参数上。
模板类型参数前必须使用关键字 class 或 typename。
在模板参数列表中,这两个关键字的含义相同,可以互换使用。
3.2.1 模板类型
主要有类模板,函数模板和成员模板三种类型:
3.2.1.1 类模板(Class Templates)
编译器不能为类模板推断模板参数类型,因此必须显式使用。
3.2.1.2 函数模板(Function Templates)
编译器自动为函数模板推断模板参数类型。
3.2.1.3 成员模板(Member Templates)
成员模板不能是虚函数。
3.2.2 模板特化(Specialization)
模板的特化就是一个独立的定义,在其中一个或多个模板参数被指定为特定的类型。
注意:特化的本质是实例化一个模板,而非重载它。因此,特化不影响函数匹配。
特化:
偏特化(个数的偏特化和范围的偏特化):
4. 分配器(allocators)
标准库 allocator 类定义在头文件 memory 中,它帮助我们将内存分配和对象构造分离开来。它提供一种类型感知的内存分配方法,它分配的内存是原始的、未构造的。
蓝色部分:切实需要的空间大小
灰色部分:debug mode 模式的空间需求
红色部分:cookie,帮助编译器识别此段空间的内容
绿色部分:用来调整边界,使空间分配统一化
4.1 VC6 STL 中的分配器设计
- (int*)0:只为了告诉函数是 int 型
- typename 后面加()构成一个临时对象
- VC 中的分配器最终就是调用 C 中的 malloc 和 free, 没有任何特殊的设计
4.2 BC5 STL 中的分配器设计
4.3 G2.9 STL 中的分配器设计(侯大师推荐!可读性非常高!)
此处的设计并未被使用。
设计目的:减少额外开销(避免之前那种用cookie包起来的大量额外开销)
4.4 G4.9 STL 中的分配器设计
又用回传统方式了-。-
若要使用 G2.9 中设计的 alloc,就要像用例中那样。
5. 迭代器(Iterator)的设计原则和 Iterator Traits 的作用与设计
5.1 迭代器的设计原则
不论是泛型思维或 STL 的实际运用,迭代器都扮演者重要的角色。 STL 的中心思想在于:将数据容器(containers)和算法(algorithms)分开,彼此独立设计,最后再通过“粘合剂”将它们撮合在一起。容器和算法的泛型化,从技术角度来看并不困难,C++ 的 class templates 和 function templates 可分别达成目标。如何设计出两者之间的良好胶粘剂,才是大难题。
迭代器是一种行为类似指针的对象,而指针的各种行为中最常见也最重要的便是内容提领(dereference)和成员访问(member access),因此,迭代器最重要的编程工作就是对 operator* 和 operator-> 进行重载(overloading)工作。关于这一点,C++ 标准程序库有一个 auto_ptr 可供我们参考。这是一个用来包装原生指针(native pointer)的对象,声名狼藉的内存泄漏(memory leak)问题可藉此获得解决。简化版的 auto_ptr(源代码在头文件<memory>中) 如下,可具体说明 auto_ptr 的行为与能力:
template<typename T>
class auto_ptr {
public:
explicit auto_ptr(T *p = 0):pointee(p) {}
template<typename U>
auto_ptr(auto_ptr<U>& rhs):pointee(rhs.release()) {}
~auto_ptr() { delete pointee; }
template<typename U>
auto_ptr<T>& operator = (auto_ptr<U>& rhs) {
if (this != &rhs) reset(rhs.release());
return *this;
}
T& operator*() const { return *pointee; }
T* operator->() const { return pointee; }
T* get() const { return pointee; }
// ...
private:
T *pointee;
};
5.2 迭代器相应型别(associated types)
在算法中运用迭代器时,很可能会用到其相应型别(associated type)。什么是相应型别呢?迭代器所指之物的型别便是其一。假设算法中有必要声明一个变量,以“迭代器所指对象的型别”为型别,如何是好?毕竟 C++ 只支持 sizeof(),并未支持 typeof()!即便动用 RTTI 性质中的 typeid(),获得的也只是型别名称,不能拿来做变量声明之用。
那么解决办法是:利用 function template 的参数推导(argument deduction)机制。例如:
template <class I, class T>
void func_impl(I iter, T t)
{
T tmp; // 这里解决了问题。T 就是 迭代器所指之物的型别,本例为 int
// ... 这里做原本 func() 应该做的全部工作
};
tempalte <class I>
inline
void func(I iter)
{
func_impl(iter, *iter); // func 的工作全部移往 func_impl
}
int main()
{
int i;
func(&i);
}
我们以 func() 为对外接口,却把实际操作全部置于 func_impl() 之中。由于 func_impl() 是一个function template,一旦被调用,编译器会自动进行 template 参数推导。于是导出型别 T,顺利解决了问题。
迭代器相应型别(associated types)不只是“迭代器所指对象的型别”一种而已。最常用的型别有五种,然而并非任何情况下任何一种都可利用上述的 template 参数推导机制来取得。于是 traits 应运而生。
5.3 Traits 编程技法——STL 源代码门钥
下面这个 class template 专门用来“萃取”迭代器的特性,而 value type 正是迭代器的特性之一:
template <class I>
struct iterator_traits { // traits 意为“特性”
typedef typename I::value_type value_type;
};
这个所谓的 traits,其意义是,如果 I 定义有自己的 value type,那么通过这个 traits 的作用,萃取出来的 value_type 就是 I::value_type。
根据经验,最常用到的迭代器相应型别有五种: value type(迭代器所指对象的类型),difference type(两个迭代器之间的距离),pointer,reference,iterator category(迭代器的分类)。
C++ 标准库中只出现过以下三种相应型别:
声明一个无法赋值(因 const 之故)的暂时变量,没什么用!因此,如果迭代器是个 pointer-to-const,我们应该设法令其 value type 为一个 non-const 型别。利用偏特化,我们就可进行如下设计:
完整的 iterator_traits 如下:
除了 iterator traits 以外,标准库里还设计了各式各样的 traits 满足不同的需求,如:
6. 容器之间的实现关系与分类
本图以内缩方式来表达基层与衍生层的关系。这里所谓的衍生,并非派生(inheritance)关系,而是内含(containment)关系。例如 heap 内含一个 vector。
此处 sizeof() 为控制数据结构本身所需要的空间大小,而不是放入其中的数据的占用空间。
接下来我们分门别类讲讲几种典型的序列式容器(sequential containers)。
7. 深度探索 vector
vector 的数据安排以及操作方式,与 array 非常相似。两者的唯一差别在于空间的运用的灵活性:
array 是静态空间,一旦配置了就不能改变;要换个大(或小)一点的空间,就非常麻烦:首先配置一块新空间,然后将元素从旧址一一搬往新址,再把原来的空间释还给系统。
vector 是动态空间,随着元素的加入,它的内部机制会自行扩充空间以容纳新元素。因此,vector 的运用对于内存的合理利用与运用的灵活性有很大的帮助,我们再也不必害怕空间不足而一开始就要求一个大块头 array 了,我们可以安心使用 vector,吃多少用多少。
vector 的实现技术,关键在于其对大小的控制以及重新配置时的数据移动效率。一旦 vector 旧空间满载,如果客户端每新增一个元素,vector 内部只是扩充一个元素的空间,实为不智,因为所谓扩充空间(不论多大),一如稍早所说,是“配置新空间——数据移动——释还旧空间”的大工程,时间成本很高,应该加入某种未雨绸缪的考虑,因此标准库中的 vector 使用了“二倍成长”的空间配置策略:
当我们以 push_back() 将新元素插入于 vector 尾端时,该函数首先检查是否还有备用空间,如果有就直接在备用空间上构造元素,并调整迭代器 finish,使 vector 变大。如果没有备用空间了,就扩充空间(重新配置、移动数据、释放原空间):
这段代码不仅被 push_back 用,也被 insert 用,所以还要拷贝安插点后的原内容
注意:所谓动态增加大小,并不是在原空间之后接续新空间(因为无法保证原空间之后尚有可供配置的空间),而是以原大小的两倍另外配置一块较大空间,然后将原内容拷贝过来,最后才开始在原内容之后构造新元素,并释放原空间。因此,对 vector 的任何操作,一旦引起空间重新配置,指向原 vector 的所以迭代器就都失效了。这是程序员容易犯的一个错误,务必小心!
8. 深度探索 list
8.1 list 概述
相较于 vector 的连续线性空间,list 就显得复杂许多,它的好处是每次插入或删除一个元素,就配置或释放一个元素空间。因此,list 对于空间的运用有着绝对的精准,一点也不浪费。而且,对于任何位置的元素插入或元素移除,list 永远是常数时间。
list 和 vector 是两个最常被使用的容器。什么时机下最适合使用哪一种容器,必须视元素的多寡、元素的构造复杂度(有无 non-trivial copy constructor,非默认拷贝构造函数,non-trival copy assignment operator,非默认拷贝赋值运算符)、元素存取行为的特性而定。
- __list_node 中 prev 和 next 的型别都为 void*,使用时还要类型转换,不太理想,其实可设为 __list_node<T>*
- list 是一个环状双向链表,所以它只需要一个指针,便可以完整表现整个链表。
- 如果让指针 node 指向刻意置于尾端的一个空白节点,node 便能符合 STL 对于“前闭后开”区间的要求,成为 last 迭代器。这么一来,begin() 、end()、empty()、size() 、front()、back() 等函数便可以轻易完成。
8.2 list 的迭代器
list 不再能够像 vector 一样以普通指针作为迭代器,因为其节点不保证在存储空间中连续存在。list 迭代器必须有能力指向 list 的节点,并有能力进行正确的递增、递减、取值、成员存取等操作(递增时指向下一个节点,递减时指向上一个节点,取值时取的是节点的数据值,成员取用时取用的是节点的成员),如下图:
由于 STL list 是一个双向链表(double linked-list),迭代器必须具备前移、后移的能力,所以 list 提供的是 Bidirectional Iterators。
list 有一个重要性质:插入操作(insert)和接合操作(splice)都不会造成原有的 list 迭代器失效。这在 vector 是不成立的,因为 vector 的插入操作可能造成记忆体重新配置,导致原有的迭代器全部失效。甚至 list 的元素删除操作(erase),也只有“指向被删除元素”的那个迭代器失效,其它迭代器不受任何影响。
- self operator++(int) 中的 int 无意义,用来表示后置++,函数体{ }内编译器先遇到重载的“=”,调用拷贝构造 copy ctor
- 前++的返回值带引用,后++不带引用,是向 int 型的设计看齐,保持一致性。(不允许后++两次,而前++可以)
G4.9 中 list iterator 的实现:
与G2.9 相比复杂太多
9. 浅谈 array & forward_list
array 就是固定内存空间的 vector,而 forward_list 是“阉割版”的 list,相关知识这里就不再赘述了。
部分内容参考自《C++ Primer 中文版(第5版)》以及《STL 源码剖析》