《C++ Primer Plus》第16章:string类和标准模板库(5)

泛型编程

有了一些使用 STL 的经验后,来看一看底层理念。STL 是一种泛型编程(geniric programming)。面向对象编程关注的是编程的数据方面,而泛型编程关注的是算法。它们之间的共同点是抽象和创建可重用代码,但它们的理念决然不同。

泛型编程旨在编写独立于数据类型的代码。在 C++ 中,完成通用程序的工具是模板。当然,模板使得能够按泛型定义函数或类,而 STL 通过通用算法更进了一步。模板让这一切成为可能,但必须对元素进行仔细地设计。为解模板和设计是如何协同工作的,来看一看需要迭代器的原因。

为何使用迭代器

理解迭代器是理解 STL 的关键所在。模板使得算法独立于存储的数据类型,而迭代器使算法独立于使用的容器类型。因此,它们都是 STL 通用方法的重要组成部分。

为了解为何需要迭代器,我们来看如何为两种不同数据表示实现 find 函数,然后来看如何推广这种方法。首先看一个在 double 数组中搜索特定值的函数,可以这样编写该函数:

double * find_ar(double * ar, int n, const double & val){
	for (int i = 0; i < n; i++){
		if(ar[i] == val )
			return &ar[i];
	}
	return 0;	// or, in C++11, return nullptr;
}

如果函数在数组中找到这样的值,则返回该值在数组中的地址,否则返回一个空指针。该函数使用下标来遍历数组。可以用模板将这种算法推广到包含==运算符、任意类型的数组。尽管如此,这种算法仍然与一种特定的数据结构(数组)关联在一起。

下面来看搜索另一种数据结构——链表的情况(第12章使用链表实现了 Queue 类)。链表由链接在一起的 Node 结构组成:

struct Node {
	double item;
	Node * p_next;
};

假设有一个指向链表第一个节点的指针,每个节点的 p_next 指针都指向下一个节点,链表最后一个节点的 p_next 指针被设置为 0, 则可以这样编写 find_ll() 函数:

Node * find_ll (Node * head, const double & val) {
	Node * start;
	for (start = head; start !=0; start = start->p_next){
		if (start->item == val){
			return start;
		}
	}
	return 0;
}

同样,也可以使用模板将这种算法推广到支持==运算符的任何数据类型的链表。然而,这种算法也是与特定的数据结构——链表关联在一起。

从实现细节上看,这两个 find 函数的算法是不同的:一个使用数组索引来遍历元素,另一个则将 start 重置为 start -> p_next。但从广义上说,这两种算法是相同的:将值依次与容器中的每个值进行比较,直到找到匹配的为止。

泛型编程旨在使用同一个 find 函数来处理数组、链表或任何其他容器类型。即函数不仅独立于容器中存储的数据类型,而且独立于容器本身的数据结构。模板提供了存储在容器中的数据类型的通用表示,因此还需要遍历容器中的值得通用表示,迭代器正是这样的通用表示。

要实现 find 函数,迭代器应具备哪些特征呢?下面是一个简短的列表:

  • 应能够对迭代器执行解除引用的操作,以便能够访问它引用的值。即如果p是一个迭代器,则应对 *p 进行定义。
  • 应能够将一个迭代器赋给另一个。即如果p和q都是迭代器,则应对表达式 p=q 进行定义。
  • 应能够将一个迭代器与另一个进行比较,看它们是否相等。即如果p和q都是迭代器,则应对 p==q,p!=q 进行定义。
  • 应能够使用迭代器遍历容器中的所有元素,这可以通过为迭代器p定义++p和p++来实现。

迭代器也可以完成其他的操作,但有上述操作功能对find函数就足够了。实际上,STL按功能的强弱定义了多种级别的迭代器,这将在后面介绍。顺便说一句,常规指针就能满足迭代器的要求,因此,可以这样重新编写 find_arr() 函数:

typedef double * iterator;
iterator find_ar(iterator ar, int n, const double & val) {
	for (int i = 0; i < n; i++, ar++){
		if(*ar == val)
			return ar;
	}
	return 0;
}

然后可以修改函数参数,使之接受两个指示区间的指针参数,其中的一个指向数组的起始位置,另一个指向数组的超尾;同时函数可以通过返回尾指针,来指出没有找到要找的值。下面的 find_ar() 版本完成了这些修改:

typedef double * iterator;
iterator find_ar(iterator begin, iterator end, const double & val){
	iterator ar;
	for (ar = begin; ar != end; ar++){
		if (*ar == val){
			return ar;
		}
	}
	return end;	// indicates val not found
}

对于 find_ll() 函数,可以定义一个迭代器类,其中定义了运算符* 和 ++;

struct Node{
	double item;
	Node * p_next;
};

class iterator {
	Node * pt;
public:
	iterator() : pt(0) {}
	iterator (Node * pn) : pt(pn) {}
	double operator*() { return pt->item;}
	iterator & operator++() {		// for ++it
		pt = pt -> next;
		return *this;
	}
	iterator operator++(int) {	// for it++
		iterator temp = *this;
		pt = pt->p_next;
		return temp;
	}
// ... operator==(), operator!=(), etc.
};

为区分++运算符的前缀版本和后缀版本,C++将operator++作为前缀版本,将operator++(int)作为后缀版本;其中的参数永远也不会被用到,所以不必指定其名称。

这里重点不是如何定义 iterator 类,而是有了这样的类后,第二个 find 函数就可以这样编写:

iterator find_ll(iterator head, const double & val) {
	iterator start;
	for (start = head; start!=0; ++start){
		if(*start == val){
			return start;
		}
	}
	return 0;
}

这和 find_ar() 几乎相同,差别在于如何谓词已到达最后一个值。find_ar() 函数使用超尾迭代器,而find_ll() 使用存储在最后一个节点中的空值。除了这种差别外,这两个函数完全相同。例如,可以要求链表的最后一个元素后面还有一个额外的元素,即让数组和链表都有超尾元素,并在迭代器到达超尾位置时结束搜索。这样,find_ar() 和 find_ll() 检测数据尾的方式相同,从而成为相同的算法。注意,增加超尾元素后,对迭代器的要求变成了对容器类的要求。

STL遵循上面介绍的方法。首先,每个容器类(vector、list、deque等)定义了相应的迭代器类型。对于其中的某个类,迭代器可能是指针;而对于另一个类,则可能是对象。不管实现方式如何,迭代器都将所需的操作,如* 和 ++ (有些类需要的操作可能比其他类多)。其次,每个容器类都有一个超尾标记,当迭代器递增到超越容器的最后一个值后,这个值将被赋给迭代器。每个容器类都有 begin() 和 end() 方法,它们分别返回一个指向容器的第一个元素和超尾位置的迭代器。每个容器类都使用++操作,让迭代器从指向第一个元素逐步指向超尾位置,从而遍历容器中的每一个元素。

使用容器类时,无需知道其迭代器是如何实现的,也无需知道超尾是如何实现的,而只需知道它有迭代器,其 begin() 返回一个指向超尾位置的迭代器即可。例如,假设要打印 vector<double> 对象中的值,则可以这样做:

vector<double>::iterator pr;
for (pr = scores.begin(); pr != scores.end(); pr++ ){
	cout << *pr << endl;
}

其中,下面的代码行将 pr 的类型声明为 vector<double> 类的迭代器:

vector<double> class;
vector<double>::iterator pr;

如果要使用 list<double> 类模板来存储分数,则代码如下:

list<double>::iterator pr;
for (pr = scores.begin(); pr != scores.end(); pr++){
	cout << *pr << endl;
}

唯一不同的是pr的类型。因此,STL 通过为每个类定义适当的迭代器,并以统一的风格设计类,能够对内部表示绝然不同的容器,编写相同的代码。

使用C++11新增的自动类型推断可进一步简化:对于vector 或 链表,都可使用如下代码:

for (auto pr = scores.begin(); pr != scores.end(); pr++){
	cout << *pr << endl;
}

实际上,作为一种编程风格,最好避免直接使用迭代器,而应尽可能使用 STL 函数(如 for_each())来处理细节。也可使用 C++11 新增的基于范围的 for 循环:

for(auto x : scores) cout << x << endl;

来总结一下 STL 方法。首先是处理容器的算法,应尽可能用通用的术语来表达算法,使之独立于数据类型和容器类型。为使通用算法能够适用于具体情况,应定义能够满足算法需求的迭代器,并把要求加到容器设计上。即基于算法的要求,设计基本迭代器的特征和容器特征。

迭代器类型

不同的算法对迭代器的要求也不同。例如,查找算法需要定义++运算符,以便迭代器能够遍历整个容器;它要求能够读取数据,但不要求能够写数据(它指示查看数据,并不修改数据)。而排序算法要求能够随机访问,以便能够交换两个不相邻的元素。如果 iter 是一个迭代器,则可以通过定义+运算符来实现随机访问,这样就可以使用像 iter+10 这样的表达式了。另外,排序算法要求能够读写数据。

STL 定义了 5 种迭代器,并根据所需的迭代器类型对算法进行了描述。这5种迭代器分别是输入迭代器、输出迭代器、正向迭代器、双向迭代器和随机访问迭代器。例如,find() 的原型与下面类似:

template<class InputIterator, class T>
InputIterator find(InputIterator first, InputIterator last, const T& value);

这指出,这种算法需要一个输入迭代器。同样,下面的原型指出排序算法需要一个随机访问迭代器:

template<class RandomAccessIterator>
void sort(RandomAccessIterator first, RandomAccessIterator last);

对于这5种迭代器,都可以执行解除引用操作(即为它们定义了*运算符),也可进行比较,看其是相等(使用==运算符,可能被重载了)还是不相等(使用!=运算符,可能被重载了)。如果两个迭代器相同,则对它们执行解除引用操作得到的值相同。即如果表达式 iter1 == iter2 为真,则下述表达式也为真:

*iter1 == *iter2

当然,对于内置运算符和指针来说,情况也是如此。因此,这些要求将指导您如何对迭代器类重载这些运算符。下面来看迭代器的其他特征。

  1. 输入迭代器
    术语“输入”是从程序的角度说的,即来自容器的信息被视为输入,就像来自键盘的信息对程序来说是输入一样。因此,输入迭代器可被程序用来读取容器中的信息。具体地说,对输入迭代器解除引用将使程序能够读取容器中的值,但不一定能让程序修改值。因此,需要输入迭代器的算法将不会修改容器中的值。

    输入迭代器必须能够访问容器中所有的值,这是通过支持++运算符(前缀格式和后缀格式)来实现的。如果将输入迭代器设置为指向容器中的第一个元素,并不断将其递增,直到到达超尾位置,则它将依次指向容器中的每一个元素。顺便说一句,并不能保证输入迭代器第二次遍历容器时,顺序不变。另外,输入迭代器被递增后,也不能保证其先前的值仍然可以被解除引用。基于输入迭代器的任何算法都应当是单通行(single-pass)的,不依赖于前一次遍历时的迭代器值,也不依赖于本次遍历中前面的迭代器值。

    注意,输入迭代器是单向迭代器,可以递增,但不能倒退。

  2. 输出迭代器

    STL 使用术语“输出”来指用于将信息从程序传输给容器的迭代器,因此程序的输出就是容器的输入。输出迭代器与输入迭代器相似,只是解除引用让程序能够修改容器值,而不能读取。也许您会感到奇怪,能够写,却不能读。发送到显示器上的输出就是如此,cout 可以修改发送到显示器的字符流,却不能读取屏幕上的内容。STL足够通用,其容器可以表示输出设备,因此容器也可能如此。另外,如果算法不用读取作容器的内容就可以修改它(如通过生成要存储的新值),则没有理由要求它使用能够读取内容的迭代器。
    简而言之,对于单通行、只读算法,可以使用输入迭代器;而对于单通行、只写算法,则可以使用输出迭代器。

  3. 正向迭代器
    与输入迭代器和输出迭代器相似,正向迭代器只使用++运算符来遍历容器,所以它每次沿容器向前移动一个元素;然而,与输入和输出迭代器不同的是,它总是按相同的顺序遍历一系列值。另外,将正向迭代器递增后,仍然可以对前面的迭代器值解除引用(如果保存了它),并可以得到相同的值。这些特征使得多次通行算法成为可能。

    正向迭代器既可以使得能够读取和修改数据,也可以使得只能读取数据:

    int *pirw;			// read-write iterator
    const int * pir;		// read-only iterator
    
  4. 双向迭代器
    假设算法需要能够双向遍历容器,情况将如何呢?例如,reverse 函数可以交换第一个元素和最后一个元素、将指向第一个元素的指针加1、将指向第二个元素的指针减1,并重复这种处理过程。双向迭代器具有正向迭代器的所有特性,同时支持两种(前缀和后缀)递减运算符。

  5. 随机访问迭代器
    有些算法(如标准排序和二分检索)要求能够直接跳到容器中的任何一个元素,这叫做随机访问,需要随机访问迭代器。随机访问迭代器具有双向迭代器的所有特性,同时添加了支持随机访问的操作(如指针增加运算)和用于对元素进行排序的关系运算符。下表列出了除双向迭代器的操作外,随机访问迭代器还支持的操作。其中,X表示随机迭代器类型,T表示被指向的类型,a 和 b 都是迭代器值,n 为整数,r 为随机迭代器变量或引用。

表达式描述
a + n指向a所指向的元素后的第n个元素
n + a与 a + n 相同
a - n指向 a 所指向的元素前的第 n 个元素
r += n等价于 r = r + n
r -= n等价于 r = r - n
a[n]等价于 *(a+n)
b - a结果为这样的 n 值,即 b = a + n
a < b如果 b - a > 0,则为真
a > b如果 b < a ,则为真
a >= b如果 !(a<b),则为真
a <= b如果 !(a>b),则为真

像 a+n 这样的表达式仅当 a 和 a+n 都位于容器区间(包括超尾)内时才合法。

迭代器层次结构

您可能已经注意到,迭代器类型形成了一个层次结构。正向迭代器具有输入迭代器和输出迭代器的全部功能,同时还有自己的功能;双向迭代器具有正向迭代器的全部功能,同时还有自己的功能;随机访问迭代器具有双向迭代器的所有功能,同时还有自己的功能。下表总结了主要的迭代器功能。其中,i 为迭代器, n 为整数。

迭代器功能输入输出正向双向随机访问
解除引用读取
解除引用写入
固定和可重复排序
++i i++
–i i–
i[n]
i+n
i-n
i+=n
i-=n

根据特定迭代器类型编写的算法可以使用该种迭代器,也可以使用具有所需功能的任何其他迭代器。所以具有随机访问迭代器的容器可以使用为输入迭代器编写的算法。

为何需要这么多迭代器呢?目的是为了在编写算法时尽可能使用要求最低的迭代器,并让它适用于容器的最大区间。这样,通过使用级别最低的输入迭代器,find() 函数便可用于任何包含可读取值的容器。而sort() 函数由于需要随机访问迭代器,所以只能用于支持这种迭代器的容器。

注意,各种迭代器的类型并不是确定的,而只是一种概念性描述。正如前面指出的,每个容器类都定义了一个类级 typedef 名称——iterator,因此 vector<int> 类的迭代器类型为 vector<int>::iterator。然而,该类的文档将指出,vector迭代器是随机访问迭代器,它允许使用基于任何迭代器类型的算法,因为速记访问迭代器具有所有迭代器的功能。同样,list<int> 类的迭代器类型为 list<int>::iterator。STL 实现了一个双向链表,它使用双向迭代器,因此不能使用基于随机访问迭代器的算法,但可以使用基于要求较低的迭代器的算法。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值