C++_标准模板库STL概念介绍2-泛型编程

有了之前使用STL的经验后,接下来讨论泛型编程及其底层的理念

首先我们知道STL只是泛型编程的一种;

面向对象的编程方式关注的是编程的数据方面

而泛型编程关注的是算法

但是,他们之间的一个重要共同点是:创建可重用的代码;但是理念却完全不一样。

 

接下来讨论泛型编程的理念:

这种编程方式旨在编写独立于数据类型的代码;

在C++中,完成通用程序的工具是模板

模板使得能够按照泛型要求的方式定义函数或类;

而STL通过算法更进了一步。

模板让这一切成为了可能,但必须对元素进行仔细地设计。

为了了解模板和设计是如何协同工作的,首先要看一下需要迭代器的原因。

===================================================

一、为何使用迭代器

模板使得算法独立于存储的数据类型。

而迭代器使得算法独立于使用的容器类型。  //容器类型是是啥?下面的讨论会体现

模板和迭代器都是STL通用方法的重要组成部分。 

 

为了了解为何要使用迭代器,我们来看一个例子,如何为两种不同的数据表示实现find函数。

然后来看如何推广这种方法。

//double是存储的数据类型、数组是使用的容器类型;

首先看一个在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;

}

如果在数组中找到该值,则在数组中返回该值在数组中的地址,否则返回一个空指针。

该函数使用下标来遍历数组。这个时候可以用模板将这种算法推广到包含==运算符、任意类型的数组。

但是尽管如此,这种算法仍然与一种特定的数据结构(数组)关联在一起。//这就是为什么要进一步将算法与特定数据结构(即容器类型)解绑的原因。

 

//接下来的容器类型变成了链表;

那么接下来搜索另一种数据结构——链表。

链表由链接在一起的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函数,迭代器要具备哪些特征呢?就着这个问题继续往下分析:

将特征罗列如下://对一些特征用表达式进行定义!

1、能够对迭代器执行解除引用的操作,以便能够访问它引用的值。即如果p是一个迭代器,则应对*p进行定义;//解引用的意思就是能够访问它引用的值,解释引用了啥,访问引用的值。

2、应能够将一个迭代器赋值给另一个,即如果p和q都是迭代器,则应对表达式p=q进行定义;

3、应能够将迭代器与另一个进行比较,看它们是否相等。即如果p和q都是迭代器,则应对p == q, p!=q进行定义;

4、应能够使用迭代器遍历容器中的所有元素,这可以通过为迭代器p定义++p和p++来实现;

 

迭代器其实还可以完成更多的操作。但是目前来看对于find函数,有这些功能就足够了。实际上,STL按照功能的强弱定义了多种级别的迭代器,这将在后面介绍。

顺便说一句,常规的指针就能满足迭代器的要求;所以不妨定义如下:

typedef double * iterator;

 

然后这样重写find_arr()函数:

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;

}

//可以发现这个函数使得算法独立于容器类型了,用iterator代替了。

 

接下来对函数做出更好的改进,可以修改函数的参数,使之接受两个指示区间的指针参数,其中的一个指向数组的起始位置,另一个指向数组的超尾

同时函数可以通过返回尾指针,来指出没有找到要找的值。 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;

}

 

那么对于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->p_next;

    return *this;

  }

  iterator operator++(int)  //for it++

  {

    iterator tmp = *this;

    pt = pt->p_next;

    return tmp;

  }

 //... operator ==(), operator!=() etc.

};

++运算符有前缀版本和后缀版本;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的容器类的话题。有很多容器类(list、vector、deque等)都要定义相应的迭代器类型

对于其中的某个容器类,迭代器可能是指针,也可能是对象。但是不管迭代器的实现方式如何,迭代器都要提供所需的操作,如*和++(有些类的需要的操作可能比其他类更多)。

其次,每个容器类都有一个超尾标记,当迭代器递增到超越容器的最后一个值的时候,这个值将被赋给迭代器。

每个容器类都有begin()和end()方法,它们分别返回一个指向容器第一个元素和超尾位置的迭代器。

每个容器都使用++操作,让迭代器从指向第一个元素逐步指向超尾位置,从而遍历容器中的每一个元素。

 

使用容器类的时候,无需知道迭代器是怎么实现的,也无需知道超尾是怎么实现的。

只需要它有迭代器,其中begin()返回一个指向第一个元素的迭代器,end()返回一个指向超尾位置的迭代器即可。

 

例如:假设要打印vector<double>对象中的值,则可以这样做。

vector<double>::iterator pr;

for (pr = scores.begin(); pr != scores.end();pr++)

  cout<<*pr<<endl;

其中将pr的类型声明为vector<double>类的迭代器;

 

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

使用C++11新增的自动类型判断,可对代码进一步简化:

for(auto pr =score.begin(); pr != scores.end(); pr++)

  cout<<*pr<<endl;

实际上,作为一种编程风格,最好避免直接使用迭代器,而尽可能使用STL的函数来处理细节。

也可以使用C++11新增的基于范围的for循环:

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

 

最后总结一下STL的方法

处理容器的算法,尽可能使用通用的术语来表达算法,使之独立于数据类型和数据类型。

为使通用算法能够适用于具体情况,应定义能够满足算法需求的迭代器,并把要求加到容器设计上。

基于算法的要求,设计基本迭代器的特征和容器特征。

====================================================

二、迭代器类型

不同的算法对迭代器的要求也不同。

例如,查找算法需要定义++运算符,以便迭代器能够遍历整个容器,这种算法要求能够读取数据,但不要求写数据。

排序算法要求能够随机访问,以便能够交换两种不相邻的元素。

如果iter是一个迭代器,则可以通过定义+运算符来实现随机访问,这样就可以使用像iter+10这样的表达式了。

另外排序算法要求能够读写数据。

 

STL定义了5种迭代器,并根据所需的迭代器类型对算法进行了描述。

这5种迭代器分别是:输入迭代器、输出迭代器、正向迭代器、双向迭代器、随机访问迭代器。

 

1、输入迭代器

 “输入”是对于程序而言的,即来自容器的信息的被视为输入,就像来自键盘的信息对程序来说是输入的一样。

因此,输入迭代器可被程序用来读取容器中的信息

简单来说就是,对输入迭代器解引用,能够使得程序读取容器中的值,但不一定能让程序修改值。

因此需要输入迭代器的算法将不会修改容器中的值。

 

输入迭代器必须能够访问容器中的所有值,这是通过支持++运算符来实现的。

如果将输入迭代器设置为指向容器中的第一个元素,并不断将其递增,知道到达超尾的位置,则它将依次指向容器中的每个元素。

 

基于输入迭代器的任何算法都应当是单向的。可以递增,不能倒退。不依赖前一次遍历时的迭代器值,也不依赖与本次遍历中前面的迭代器值。

 

2、输出迭代器

这里的输出指的是将信息从程序传输给容器的迭代器。因此程序的输出就是容器的输入。

这种迭代器能够让程序修改容器的值,而不能读取。

STL足够通用,其容器就能表示输出设备。

总而言之, 单通行,只读算法就能使用输入迭代器。单通行,只写算法就能使用输出迭代器。

 

3、正向迭代器

 

4、双向迭代器

 

5、随机访问迭代器

 

====================================================

三、迭代器层次结构

迭代器类型形成了一个层次结构。

正向迭代器具有输入迭代器和输出迭代器的全部功能,同时还具有自己的功能;

双向迭代器具有正向迭代器全部的功能,同时还具有自己的功能;

随机访问迭代器具有正向迭代器的全部功能,同时还有自己的功能;

====================================================

四、概念、改进和模型

我们根据算法的要求设计迭代器。在STL文献中使用术语概念(concept)来描述一系列的要求

概念可以具有类似继承的关系。但不能用于C++继承机制用于迭代器。

STL文献使用术语改进(refinement)来表示这种概念上的继承。

因此,双向迭代器是对正向迭代器概念的一种改进。

概念的具体体现被称为模型(model)。因此,指向int的常规指针是一个随机访问迭代器模型,也是一个正向迭代器模型,因为它满足该概念的所有要求。

1、将指针用作迭代器

迭代器是一种广义的指针,而指针满足所有的迭代器的要求。迭代器是STL算法的接口,而指针是迭代器,

因此STL算法可以使用指针来对基于指针的非STL容器进行操作。 

意思就是可以调用STL的函数来对非STL容器进行操作,这样就可以把算法用到了非容器身上。

例如:Receipts是一个double数组,并要按升序对它进行排序

const int SIZE =100;

double Receipts[SIZE];

STL sort()函数接受指向容器第一个元素的迭代器和指向超尾的迭代器作为参数。

&Receipts[0]是第一个元素的地址,&Receipts[SIZE]是数组最后一个元素后面的元素的地址;

因此,下面的函数调用对数组进行排序:

sort(Receipts, Receipts+SIZE);

C++确保了表达式Receipts+n是被定义的,只要表达式的结果位于数组中。

因此,C++将超尾概念用于数组,使得可以将STL算法用于常规数组

同样地,STL算法也可以用于自己设计的数组,只要自己提供迭代器。

 

STL提供了一些预定义迭代器。为了解其中的原因。这里举一个例子来探讨。

有一种算法叫作copy(),可以将数据从一个容器复制到另一个容器中。

这种算法是以迭代器的方式实现的,所以它可以实现从一种容器到另一种容器的赋值。

甚至在数组之间的复制,因为可以将指向数组的指针作为迭代器。

代码如下:

int casts[10] = {6,7,2,9,4,11,8,7,10,5};

vector<int> dice[10];

copy(casts, casts+10, dice.begin());  //copy array to vector

分析一下代码:

copy()的前两个参数表示要复制的元素的范围,最后一个迭代器参数表示要将第一个元素复制到什么位置。

前两个参数必须是(或者最好是)输入迭代器,最后一个参数必须是(或者最好是)输出迭代器。

Copy()函数将覆盖目标容器中已有的数据,同时必须保证目标容器足够大。

因此不能使用Copy()将数据放到空矢量中——至少目前是这样的,不过如果采取后面的方法,可能就不一样了

 

 

现在讨论一个新的例子

#include <iterator>

...

ostream_iterator<int, char> out_iter(cout, " ");  

//有个迭代器模板,叫ostream_iterator,用这个模板创建了一个迭代器out_iter。

//这个模板有两个参数,第一个参数是指被发送给输出流的数据类型;第二个模板参数是指输出流使用的字符类型;

//用这个模板创建了一个类,也是一个迭代器,叫作out_iter;

//out_iter的构造函数的第一个参数指出了要使用的输出流为cout,当然也可以替换成用于文件的输出流;

//第二个字符串参数指的是在发送给输出流的每个数据项后显示的分隔符;

 

接下来使用迭代器

*out_iter++ = 15  // works like cout<<15<<" ";

这里的意思与常规指针不一样,这意味着将15和空格组成的字符串发送到cout管理的输出流中,并为下一个输出操作做好了准备。

 

接下来可以将copy()用于迭代器,

copy(dice.begin(), dice.end(), out_iter);  //copy vector to outstream

当然甚至可以创建一个匿名的迭代器,如下:

copy(dice.begin(), dice.end(), ostream_iterator<int, char>(cout, " ")); 

 

通过上面的例子可以发现,C++的头文件iterator中预定了一些迭代器模板。

用户可以根据需要,从模板生成具体的迭代器。这就是预定义迭代器要讲述的内容。

两个迭代器模板:istream_iterator、ostream_iterator

 

2、其他有用的迭代器

除了上面两个迭代器模板外,iterator还提供了另外一些预定义的迭代器类型。

它们是reverse_iteratorback_insert_iteratorfront_insert_iteratorinsert_iterator

 

我们先来看reverse_iterator的功能。对reverse_itertor执行递增操作将导致它被递减。

为什么不直接对常规迭代器进行递减?->主要原因是为了简化对已有的函数的使用。

 

假设要显示dice容器的内容,正如刚才介绍的,可以使用copy()和ostream_iterator来将内容复制到输出流:

ostream_iterator<int, char> out_iter(cout, " ");

copy(dice.begin(), dice.end(), out_iter);   //display in forward order 

假设现在要反向打印容器的内容。有很多方法都不管用,但与其在这里耽误工夫,不如来看看能够完成这种任务的方法。

vector类有一个名为rbegin()的成员函数和一个名为rend()的成员函数。前者返回一个指向超尾的反向迭代器,后者返回一个指向第一个元素的反向迭代器。

所以可以使用下面的语句来反向显式内容:

copy(dice.rbegin(), dice.rend(),out_iter)

但是这里必须对反向指针做一种特殊补偿,假设rp是一个被初始化为dice.rbegin()的反转指针。那么*rp是什么呢?

因为rbegin()指向的是超尾,因此不能对该地址进行解引用。

同样,如果rend()是第一个元素的位置,则copy()必须提早一个位置停止,因为区间的结尾处不包括在区间中。

反向指针通过,先递减再解除引用的方式解决这两个问题。

 

 1 //copyit.cpp -- copy() and iterator
 2 #include <iostream>
 3 #include <iterator>
 4 #include <vector>
 5 
 6 int main()
 7 {
 8     using namespace std;
 9     
10     int casts[10] = {6,7,2,9,4,11,8,7,10,5};
11     vector<int> dice(10);
12     // copy from array to vector
13     copy(casts, casts+10, dice.begin());
14     cout<<"Let the dice be cast!\n";
15     
16     // create an ostream iterator
17     ostream_iterator<int, char> out_iter(cout, " ");
18     // copy from vector to output
19     copy(dice.begin(), dice.end(), out_iter);
20     cout<<endl;
21 
22     cout<<"Implicit use of reverse iterator.\n";
23     copy(dice.rbegin(), dice.rend(), out_iter);
24     cout<<endl;  
25     
26     cout<<"Explicit use of reverse iterator.\n";
27     vector<int>::reverse_iterator ri;
28     for (ri = dice.begin(); ri !=dice.rend(); ++ri)
29         cout<<*ri<<' ';
30     cout<<endl;
31 
32     return 0;    
33 }

 

 

以下三种迭代器解决了上述讨论的对于copy()会出现覆盖原有内容,以及dice空间不够的问题。

back_insert_iterator、front_insert_iterator、insert_iterator;

back_insert_iterator 将元素插入到容器的尾部;

front_insert_iterator将元素插入到容器的前端;

insert_iterator将元素插入到insert_iterator的构造函数中参数指定的位置前面。

这三种插入迭代器都是输出容器概念的模型。

 

另外记住迭代器必须使用适合的容器方法。所以也必须声明相应的容器类型。

 

如果你被这些迭代器搞晕,只需要记住,使用就会熟悉它们。另外还请记住,这些预定义迭代器提高了STL算法的通用性

====================================================

五、容器种类

STL具有容器概念和容器类型。

概念是具有名称的通用类别(如容器、序列容器、关联容器)。

容器类型可以用创建具体容器对象的模板。

以前的11个容器类型分别是:deque、list、queue、priority_queue、stack、vector、map、multimap、set、multiset、bitset(C+11中不视bitset为容器,而是一个独立的类别);

C++11新增了forwar_list、unordered_map、unordered_multimap、unordered_set和unordered_multiset。

1、容器的概念

容器概念指定了所有STL容器类都必须遵循的一系列要求。

容器类是一种概念化的抽象基类——说它概念化,是因为容器类并不真正使用继承机制。

容器是存储对象的对象。被存储的对象必须是同一种类型的。

被存储的对象可以是OOP意义上的对象,也可以是内置类型值。

存储在容器中的数据为容器所有,意味着当容器过时,容器中的数据也过期了。

然而存储的数据是指针的话,指针指向的数据不一定过期。

 

不能将任何类型的对象存储在容器中。具体地说,类型必须是可复制构造的和可赋值的。

基本类型能够满足这些要求;

只要类定义没有将复制构造函数和赋值运算符声明为私有或保护的,则满足这种要求;

C++11改进了这些概念,添加了术语可复制插入和可移动插入。

 

基本容器不能保证其元素都按特定的顺序存储,也不能保证元素的顺序不变。

 

复杂度:描述了执行操作所需的时间。

这个表列出了3种可能性,从快到慢依次为:

编译时间

固定时间;——意味着操作发生在运行阶段;

线性时间;——时间与元素数目成正比; 

 

复杂度要求是STL特征。虽然实现细节可以隐藏,但性能规格应该公开,以便程序员能够知道完成特定操作的计算成本。

 

2、C++11新增的容器要求

 

3、序列

可以通过添加要求来改进基本的容器概念。

序列是一种重要的改进。

7种STL容器类型都是序列(deque、forward_list、list、queue、priority、stack、vector)。

array也被归到序列容器,虽然它并不满足序列的所有要求

序列要求其元素按严格的线性顺序排列。

即存在第一个元素、最后一个元素、除第一个元素和最后一个元素之外每个元素的前后分别有一个元素。

数组和链表都是序列,但分支结构不是。

 

序列的要求、序列的可选要求。

 

 下面详细介绍这7种序列容器类型

1)vector

vector是数组的一种类表示。

它提供了自动内存管理功能。 可以动态地改变vector对象的长度,并随着元素的添加和删除而增大和缩小。

它提供对元素的随机访问。

在尾部添加和删除元素的时间是固定的。

在头部或中间插入和删除元素的复杂度是线性的。

 

除序列外,vector还是可反转容器概念的模型。

这增加了两个类方法:rbegin()和rend()。

前者返回一个指向反转序列的第一个元素的迭代器。

后者返回一个指向反转序列的超尾迭代器。

 

vector模板是最简单的序列类型,除非其他类型的特殊优点能够更好地满足程序的要求,否则应默认使用这种类型。

 

2)deque

deque是双端队列(double-end-queue)的缩写。

主要和vector的区别在于,从deque对象的开始位置插入和删除元素的时间是固定的。而不像vector种那样是线性时间的。

所以,如果多数操作发生在序列的起始和结尾处,则应考虑使用deque数据结构。

 

实际上deque对象比vector对象更加复杂。因此尽管二者都提供了对元素的随机访问和在序列中部执行线性时间的插入和删除操作,但vector容器执行这些操作时速度更快些。

 

3)list

list表示双向链表。除了第一个和最后一个元素外,每个元素斗鱼前后的元素衔接。这意味着可以双向遍历链表。

它与vector的区别在于,list在链表中的任一位置进行插入和删除时间的操作时间都是固定的。

 

因此vector强调的是通过随机访问来进行快速访问。

list强调的是元素的快速插入和删除。

 

另外list不支持数组表示法和随机访问。

list也是一个可反转容器。

 

4)程序说明

 

 

5)list工具箱

list方法组成了一个工具箱。可以方便对列表进行一些常见的操作。

 

6)forward_list(C++11)

它实现了单链表。

在这种链表中,每个节点都只链接到下一个节点,而没有链接到前一个节点。

因此forward_list只需要正向迭代器,而不需要双向迭代器。

因此,不同于vector和list,forward_list是不可反转容器。 

forward_list更简单,更紧凑,相对功能也更少。

 

7)queue

queue是一个适配器类。//何为适配器,其实就是一种给底层类提供接口的意思。

ostream_iterator就是一个适配器,让输出流能够使用迭代器接口。

queue模板让底层类(默认为deque)展示典型的队列接口。queue模板的限制比deque更多。

它不允许随机访问队列元素,甚至不允许遍历队列。

它把使用限制在定义队列的基本操作上。

 

 

8)priority_queue

priority_queue是另一种适配器类

它支持的操作与queue一样。

明显的区别是:最大的元素被移到队首。

内部的区别是:其默认底层类是vector。

 

 

9)stack

stack也是一种适配器类。它给底层类(默认情况下为vector类)提供了典型的栈接口。

stack模板的限制比vector更多。它不仅不允许随机访问栈元素,甚至不允许遍历栈。

它把使用限制在定义栈的基本操作上,即可以将亚茹推到栈顶、从栈顶弹出元素、查看栈顶的值、检查元素数目和测试栈是否为空。

 

10)array(C++11)

array并非STL容器,因为它的长度是固定的。

array没有定义容器大小的操作。

====================================================

六、关联容器

关联容器是对容器概念的另一种改进。

关联容器将值与键关联在一起,并使用键来查找值。

 

关联容器的优点在于:它提供了对元素的快速访问。与序列相似,关联容器也允许插入新元素。但不能指定插入的位置。

这是因为关联容器自身拥有确定数据存放位置的算法,以便能够快速检索信息。

 

关联容器是用某种树实现的。树是一种数据结构,其根节点链接到一个或两个节点,而这些节点又链接到一个或两个节点。

从而形成分支结构。像链表一样,节点使得添加或删除数据项比较简单,但相对于链表,树的查找速度更快。

 

STL提供了4种关联容器:set、multiset、map和multimap;

最简单的关联容器是set,其值类型与键相同。键是唯一的,这意味着集合中不会有多个相同的键。对于set来说,值就是键。

multiset类似于set,只是可能一个键对应多个值。或者说是多个值得键相同。

 

在map中,值与键的类型不同,键是唯一的,每个键只对应一个值。

multimap和map相似,只是一个键可以与多个值相关联。

 

====================================================

七、无序关联容器(C++11)

无序关联容器是对容器概念的另一种改进。

关联容器一样,无序关联容器也将值与键关联起来,并用键来查找值。

但底层差别在于,关联容器是基于树结构的;

而无序容器是基于数据结构的哈希表的。

这个旨在提高添加和删除元素的速度以及提高查找算法的效率。

有四种无序关联容器:unordered_map、unordered_multimap、unordered_set和unordered_multiset

posted @ 2019-03-03 21:34  Grooovvve  阅读(383)  评论(0编辑  收藏  举报