C++语言还定义了一个内容丰富的抽象数据类型库。其中,string 和 vector是两种最重要的标准库类型,前者支持可变长字符串,后者则表示可变长的集合。还有一种标准库类型是迭代器,它是string和vector的配套类型,常被用于访问string 中的字符或vector中的元素。内置数组是一种更基础的类型,string和l vector都是对它的某种抽象。本章将分别介绍数组以及标准库类型string和 vector。
内置类型是由C++语言直接定义的。这些类型,比如数字和字符,体现了大多数计算机硬件本身具备的能力。标准库定义了另外一组具有更高级性质的类型,它们尚未直接实现到计算机硬件中。string表示可变长的字符序列,vector存放的是某种给定类型对象的可变长序列。还将介绍内置数组类型,和其他内置类型一样,数组的实现与硬件密切相关。因此相较于标准库类型string和vector,数组在灵活性上稍显不足。
用到的库函数基本上都属于命名空间std,而程序也显式地将这一点标示了出来。例如,std::cin表示从标准输入中读取内容。此处使用作用域操作符( ::)的含义是:编译器应从操作符左侧名字所示的作用域中寻找右侧那个名字。因此,std: :cin的意思就是要使用命名空间std中的名字cin。通过更简单的途径也能使用到命名空间中的成员。本节将学习其中一种最安全的方法,也就是使用using声明( using declaration)。有了using 声明就无须专门的前缀(形如命名空间: :)也能使用所需的名字了。using声明具有如下的形式:
using namespace::name;
一旦声明了上述语句,就可以直接访问命名空间中的名字:
C++语言的形式比较自由,因此既可以一行只放一条using 声明语句,也可以一行放上多条。不过要注意,用到的每个名字都必须有自己的声明语句,而且每句话都得以分号结束。
位于头文件的代码一般来说不应该使用using声明。这是因为头文件的内容会拷贝到所有引用它的文件中去,如果头文件里有某个using声明,那么每个使用了该头文件的文件就都会有这个声明。对于某些程序来说,由于不经意间包含了一些名字,反而可能产生始料未及的名字冲突。
标准库类型string表示可变长的字符序列,使用string类型必须首先包含string头文件。作为标准库的一部分,string定义在命名空间std中。接下来的示例都假定已包含了下述代码:
#include
using std::string
C++标准一方面对库类型所提供的操作做了详细规定,另一方面也对库的实现者做出一些性能上的需求。因此,标准库类型对于一般应用场合来说有足够的效率。
如何初始化类的对象是由类本身决定的。一个类可以定义很多种初始化对象的方式,只不过这些方式之间必须有所区别:或者是初始值的数量不同,或者是初始值的类型不同。列出了初始化string对象最常用的一些方式,下面是几个例子:
string sl;//默认初始化,s1是一个空字符串
string s2 = sl;// s2是s1的副本
string s3 = "hiya";// s3是该字符串字面值的副本
string s4(10, 'c');// s4的内容是cccccccccc
可以通过默认的方式初始化一个string对象,这样就会得到一个空的string,也就是说,该string对象中没有任何字符。如果提供了一个字符串字面值,则该字面值中除了最后那个空字符外其他所有的字符都被拷贝到新创建的string对象中去。如果提供的是一个数字和一个字符,则string对象的内容是给定字符连续重复若干次后得到的序列。
C++语言有几种不同的初始化方式,通过string我们可以清楚地看到在这些初始化方式之间到底有什么区别和联系。如果使用等号(=)初始化一个变量,实际上执行的是拷贝初始化(copy initialization),编译器把等号右侧的初始值拷贝到新创建的对象中去。与之相反,如果不使用等号,则执行的是直接初始化( direct initialization)。
string s5 = "hiya";//拷贝初始化
string s6("hiya");//直接初始化
string s7(10, 'c');//直接初始化,s7的内容是cccccccccc
string s8 = string(10, 'c');//拷贝初始化,s8的内容是cccccccccc
s8的初始值是string(10, ’ c’),它实际上是用数字10和字符c两个参数创建出来的一个string对象,然后这个string对象又拷贝给了s8。
一个类除了要规定初始化其对象的方式外,还要定义对象上所能执行的操作。其中,类既能定义通过函数名调用的操作,就像sales_item类的isbn函数那样,也能定义<<、+等各种运算符在该类对象上的新含义。
os<将s写到输出流os当中,返回os |
|
is >> s | 从is 中读取字符串赋给s,字符串以空白分隔,返回is |
getline(is, s) | 从is 中读取一行赋给s,返回is |
s.empty() | s为空返回true,否则返回false |
s.size() | 返回s 中字符的个数 |
s[n] | 返回s中第n个字符的引用,位置n从0计起 |
s1 + s2 | 返回s1和 s2连接后的结果 |
sl = s2 | 用s2的副本代替s1中原来的字符 |
s1 == s2 | 如果s1和s2中所含的字符完全一样,则它们相等; string对象的相 |
s1 != s2 | 等性判断对字母的大小写敏感 |
<, <= ,>, >= | 利用字符在字典中的顺序进行比较,且对字母的大小写敏感 |
和内置类型的输入输出操作一样,string对象的此类操作也是返回运算符左侧的运算对象作为其结果。因此,多个输入或者多个输出可以连写在一起:
string s1,s2;
cin>>s1>>s2;
cout<<s1<<s2<<endl;
有时我们希望能在最终得到的字符串中保留输入时的空白符,这时应该用getline函数代替原来的>>运算符。getline函数的参数是一个输入流和一个string对象,函数从给定的输入流中读入内容,直到遇到换行符为止(注意换行符也被读进来了),然后把所读的内容存入到那个string对象中去(注意不存换行符)。getline只要一遇到换行符就结束读取操作并返回结果,哪怕输入的一开始就是换行符也是如此。如果输入真的一开始就是换行符,那么所得的结果是个空string。
empty函数根据string对象是否为空返回一个对应的布尔值。和 sales_item类的isbn成员一样,empty 也是 string 的一个成员函数。调用该函数的方法很简单,只要使用点操作符指明是哪个对象执行了empty函数就可以了。size函数返回string对象的长度(即string对象中字符的个数),可以使用size函数只输出长度超过80个字符的行:
string line;
while (getline(cin, line))
if (!line.empty())
if(line.size()>80)
cout << line << endl;
string类及其他大多数标准库类型都定义了几种配套的类型。这些配套类型体现了标准库类型与机器无关的特性,类型size_type即是其中的一种。在具体使用的时候,通过作用域操作符来表明名字size_type是在类string中定义的。
尽管我们不太清楚string : :size_type类型的细节,但有一点是肯定的:它是一个无符号类型的值而且能足够存放下任何string对象的大小。所有用于存放 string类的size函数返回值的变量,都应该是 string : :size_type类型的。
由于size函数返回的是一个无符号整型数,因此切记,如果在表达式中混用了带符号数和无符号数将可能产生意想不到的结果。例如,假设n是一个具有负值的int,则表达式s.size ()
相等性运算符(==和!=)分别检验两个string对象相等或不相等,string对象相等意味着它们的长度相同而且所包含的字符也全都相同。关系运算符<、<=、>、>=分别检验一个string对象是否小于、小于等于、大于、大于等于另外一个string对象。上述这些运算符都依照(大小写敏感的)字典顺序:
如果两个string对象的长度不同,而且较短string对象的每个字符都与较长string对象对应位置上的字符相同,就说较短string对象小于较长 string对象。
如果两个string 对象在某些对应的位置上不一致,则string对象比较的结果其实是string对象中第一对相异字符比较的结果。
一般来说,在设计标准库类型时都力求在易用性上向内置类型看齐,因此大多数库类型都支持赋值操作。对于string类而言,允许把一个对象的值赋给另外一个对象:
string st1(10,"c"),st2;
st1=st2;//此时均为空
两个string对象相加得到一个新的string对象,其内容是把左侧的运算对象与右侧的运算对象串接而成。也就是说,对string对象使用加法运算符(+)的结果是一个新的string对象,它所包含的字符由两部分组成:前半部分是加号左侧string对象所含的字符、后半部分是加号右侧string对象所含的字符。另外,复合赋值运算符(+=)负责把右侧string对象的内容追加到左侧string对象的后面:
string s1 = "hello, ", s2 = "world\n";
string s3 = s1 + s2; // s3的内容是hello, world\n
sl += s2;//等价于sl = s1 + s2
因为标准库允许把字符字面值和字符串字面值转换成string对象,所以在需要string对象的地方就可以使用这两种字面值来替代。当把string对象和字符字面值及字符串字面值混在一条语句中使用时,必须确保每个加法运算符(+)的两侧的运算对象至少有一个是string。
cctype头文件中的函数
isalnum(c ) | 当c是字母或数字时为真 |
isalpha( c) | 当c是字母时为真 |
iscntrl( c) | 当c是控制字符时为真 |
isdigit( c) | 当c是数字时为真 |
isgraph( c) | 当c不是空格但可打印时为真 |
islower( c) | 当c是小写字母时为真 |
isprint( c) | 当c是可打印字符时为真(即c是空格或c具有可视形式) |
ispunct( c) | 当c是标点符号时为真(即c不是控制字符、数字、字母、可打印空白中的一种) |
isspace( c) | 当c是空白时为真(即c是空格、横向制表符、纵向制表符、回车符、换行符、进纸符中的一种) |
isupper( c) | 当c是大写字母时为真 |
isxdigit( c) | 当c是十六进制数字时为真 |
tolower( c) | 如果c是大写字母,输出对应的小写字母:否则原样输出c |
toupper( c) | 如果c是小写字母,输出对应的大写字母; 否则原样输出c |
C++标准库中除了定义C+语言特有的功能外,也兼容了C语言的标准库。C语言的头文件形如name.h,C++则将这些文件命名为cname。也就是去掉了.h后缀,而在文件名name之前添加了字母c,这里的c表示这是一个属于C语言标准库的头文件。
因此,cctype头文件和 ctype.h头文件的内容是一样的,只不过从命名规范上来讲更符合C++语言的要求。特别的,在名为cname的头文件中定义的名字从属于命名空间std,而定义在名为.h的头文件中的则不然。
一般来说,C++程序应该使用名为cname的头文件而不使用name.h的形式,标准库中的名字总能在命名空间std中找到。如果使用.h形式的头文件,程序员就不得不时刻牢记哪些是从C语言那儿继承过来的,哪些又是C++语言所独有的。
如果想对string对象中的每个字符做点儿什么操作,目前最好的办法是使用C++11新标准提供的一种语句:范围for (range for)语句。这种语句遍历给定序列中的每个元素并对序列中的每个值执行某种操作,其语法形式是:
for (declaration : expression)
statement
其中,expression部分是一个对象,用于表示一个序列。declaration部分负责定义一个变量,该变量将被用于访问序列中的基础元素。每次迭代,declaration部分的变量会被初始化为expression部分的下一个元素值。
一个string对象表示一个字符的序列,因此 string对象可以作为范围for语句中的expression部分。举一个简单的例子,我们可以使用范围for语句把string对象中的字符每行一个输出出来:
string str(" some string");//每行输出str中的一个字符。
for (auto c : str)//对于str中的每个字符
cout << c << endl;//输出当前字符,后面紧跟一个换行符
for循环把变量c和str联系了起来,其中我们定义循环控制变量的方式与定义任意一个普通变量是一样的。此例中,通过使用auto关键字让编译器来决定变量c的类型,这里c的类型是char。每次迭代,str的下一个字符被拷贝给c,因此该循环可以读作“对于字符串str中的每个字符c,”执行某某操作。此例中的“某某操作”即输出一个字符,然后换行。
如果想要改变string对象中字符的值,必须把循环变量定义成引用类型。记住,所谓引用只是给定对象的一个别名,因此当使用引用作为循环控制变量时,这个变量实际上被依次绑定到了序列的每个元素上。使用这个引用,我们就能改变它绑定的字符。
新的例子不再是统计标点符号的个数了,假设我们想要把字符串改写为大写字母的形式。为了做到这一点可以使用标准库函数 toupper,该函数接收一个字符,然后输出其对应的大写形式。这样,为了把整个string对象转换成大写,只要对其中的每个字符调用toupper函数并将结果再赋给原字符就可以了:
string s("Hello world!!!");//转换成大写形式。
for (auto& c : s)
//对于s 中的每个字符(注意:c是引用)
c = toupper(c);
//c是一个引用,因此赋值语句将改变s中字符的值
cout << s << endl;
//输出:HELLO WORLD !!!
如果要处理string 对象中的每一个字符,使用范围for语句是个好主意。然而,有时我们需要访问的只是其中一个字符,或者访问多个字符但遇到某个条件就要停下来。例如,同样是将字符改为大写形式,不过新的要求不再是对整个字符串都这样做,而仅仅把string对象中的第一个字母或第一个单词大写化。
要想访问string对象中的单个字符有两种方式:一种是使用下标,另外一种是使用迭代器。下标运算符([ ])接收的输入参数是string : :size_type类型的值,这个参数表示要访问的字符的位置;返回值是该位置上字符的引用。string对象的下标从0计起。如果string对象s至少包含两个字符,则s [0]是第1个字符、s [1]是第2个字符、s [s.size()-1]是最后一个字符。
string对象的下标必须大于等于0而小于s.size()使用超出此范围的下标将引发不可预知的结果,以此推断,使用下标访问空string也会引发不可预知的结果。下标的值称作“下标”或“索引”,任何表达式只要它的值是一个整型值就能作为索引。不过,如果某个索引是带符号类型的值将自动转换成由string ::size_type表达的无符号类型。
在访问指定字符之前,首先检查s是否为空。其实不管什么时候只要对string对象使用了下标,都要确认在那个位置上确实有值。如果s为空,则s [0]的结果将是未定义的。
逻辑与运算符(&&)。如果参与运算的两个运算对象都为真,则逻辑与结果为真;否则结果为假。对这个运算符来说最重要的一点是,C++语言规定只有当左侧运算对象为真时才会检查右侧运算对象的情况。如此例所示,这条规定确保了只有当下标取值在合理范围之内时才会真的用此下标去访问字符串。也就是说,只有在index达到s.size ()之前才会执行s [index]。随着index的增加,它永远也不可能超过s.size ()的值,所以可以确保index比 s.size ()小。
标准库类型vector表示对象的集合,其中所有对象的类型都相同。集合中的每个对象都有一个与之对应的索引,索引用于访问对象。因为vector“容纳着”其他对象,所以它也常被称作容器( container)。要想使用vector,必须包含适当的头文件。在后续的例子中,都将假定做了如下using声明:
#include
using std::vector;
C++语言既有类模板(class template),也有函数模板,其中vector是一个类模板。只有对C++有了相当深入的理解才能写出模板。幸运的是,即使还不会创建模板,我们也可以先试着用用它。模板本身不是类或函数,相反可以将模板看作为编译器生成类或函数编写的一份说明。编译器根据模板创建类或函数的过程称为实例化( instantiation),当使用模板时,需要指出编译器应把类或函数实例化成何种类型。
对于类模板来说,我们通过提供一些额外信息来指定模板到底实例化成什么样的类,需要提供哪些信息由模板决定。提供信息的方式总是这样:即在模板名字后面跟一对尖括号,在括号内放上信息。
vector<int> ivec;// ivec保存int类型的对象
vector<sales_item> sales_vec;//保存sales_item类型的对象
vector<vector<string>> file;//该向量的元素是vector对象
vector能容纳绝大多数类型的对象作为其元素,但是因为引用不是对象,所以不存在包含引用的vector。除此之外,其他大多数(非引用)内置类型和类类型都可以构成vector对象,甚至组成vector的元素也可以是vector。
需要指出的是,在早期版本的C++标准中如果vector的元素还是vector(或者其他模板类型),则其定义的形式与现在的C++11新标准略有不同。过去,必须在外层vector对象的右尖括号和其元素类型之间添加一个空格,如应该写成vector
vector v1 | v1是一个空vector,它潜在的元素是T类型的,执行默认初始化 |
vector v2(v1) | v2中包含有v1所有元素的副本 |
vector v2 = v1 | 等价于v2(v1),v2中包含有v1所有元素的副本 |
vector v3(n, val) | v3包含了n个重复的元素,每个元素的值都是val |
vector v4(n) | v4包含了n个重复地执行了值初始化的对象 |
vector v5{ a,b, c… } | v5包含了初始值个数的元素,每个元素被赋予相应的初始值 |
vector v5 = {a, b, c… .} | 等价于v5{ a,b,c… } |
当然也可以在定义vector对象时指定元素的初始值。例如,允许把一个vector对象的元素拷贝给另外一个vector对象。此时,新 vector对象的元素就是原vector对象对应元素的副本。注意两个vector对象的类型必须相同:
vector<int> ivec;//初始状态为空
//在此处给ivec添加一些值
vector<int> ivec2(ivec);//把ivec的元素拷贝给ivec2
vector<int> ivec3 = ivec; // 把ivec的元素拷贝给ivec3
vector<string> svec(ivec2);//错误: svec的元素是string对象,不是int
通常情况下,可以只提供vector对象容纳的元素数量而不用略去初始值。此时库会创建一个值初始化的(value-initialized)元素初值,并把它赋给容器中的所有元素。这个初值由vector对象中元素的类型决定。
如果vector对象的元素是内置类型,比如int,则元素初始值自动设为0。如果元素是某种类类型,比如 string,则元素由类默认初始化:
vector<int> ivec(10);//10个元素,每个都初始化为0
vector<string> svec(10);//10个元素,每个都是空string对象
对这种初始化的方式有两个特殊限制:其一,有些类要求必须明确地提供初始值,如果vector对象中元素的类型不支持默认初始化,我们就必须提供初始的元素值。对这种类型的对象来说,只提供元素的数量而不设定初始值无法完成初始化工作。
vector<int> vi = 10;//错误:必须使用直接初始化的形式指定向量大小
这里的10是用来说明如何初始化vector对象的,我们用它的本意是想创建含有10个值初始化了的元素的vector对象,而非把数字10“拷贝”到vector中。因此,此时不宜使用拷贝初始化。
在某些情况下,初始化的真实含义依赖于传递初始值时用的是花括号还是圆括号。例如,用一个整数来初始化 vector时,整数的含义可能是vector对象的容量也可能是元素的值。类似的,用两个整数来初始化vector时,这两个整数可能一个是vector对象的容量,另一个是元素的初值,也可能它们是容量为2的vector对象中两个元素的初值。通过使用花括号或圆括号可以区分上述这些含义:
vector<int> v1(10);// v1有10个元素,每个的值都是О
vector<int> v2{ 10 };// v2有1个元素,该元素的值是10
vector<int> v3(10, 1); // v3有10个元素,每个的值都是1
vector<int> v4{ 10,1 }; // v4有2个元素,值分别是10和1
如果用的是圆括号,可以说提供的值是用来构造(construct) vector对象的。例如,v1的初始值说明了vector对象的容量; v3的两个初始值则分别说明了vector对象的容量和元素的初值。
如果用的是花括号,可以表述成我们想列表初始化(list initialize)该vector对象。也就是说,初始化过程会尽可能地把花括号内的值当成是元素初始值的列表来处理,只有在无法执行列表初始化时才会考虑其他初始化方式。在上例中,给v2和v4提供的初始值都能作为元素的值,所以它们都会执行列表初始化,vector对象v2包含一个元素而vector对象v4包含两个元素。
另一方面,如果初始化时使用了花括号的形式但是提供的值又不能用来列表初始化,就要考虑用这样的值来构造vector对象了。例如,要想列表初始化一个含有string对象的vector对象,应该提供能赋给string对象的初值。此时不难区分到底是要列表初始化vector对象的元素还是用给定的容量值来构造vector对象:
vector<string> v5{ "hi" }; //列表初始化:v5有一个元素
vector<string> v6("hi");//错误:不能使用字符串字面值构建vector对象
vector<string> v7{10};//v7有10个默认初始化的元素
vector<string> v8{ 10,"hi" };// v8有10个值为"hi"的元素
尽管在上面的例子中除了第二条语句之外都用了花括号,但其实只有v5是列表初始化。要想列表初始化 vector对象,花括号里的值必须与元素类型相同。显然不能用int初始化 string对象,所以v7和v8提供的值不能作为元素的初始值。确认无法执行列表初始化后,编译器会尝试用默认值初始化vector对象。
对vector对象来说,直接初始化的方式适用于三种情况:初始值已知且数量较少、初始值是另一个vector对象的副本、所有元素的初始值都一样。然而更常见的情况是:创建一个vector对象时并不清楚实际所需的元素个数,元素的值也经常无法确定。还有些时候即使元素的初值已知,但如果这些值总量较大而各不相同,那么在创建vector对象的时候执行初始化操作也会显得过于烦琐。
更好的处理方法是先创建一个空vector,然后在运行时再利用vector 的成员函数push_back向其中添加元素。push_back 负责把一个值当成vector对象的尾元素“压到( push)” vector对象的“尾端( back)”。例如:
vector<int> v2;//空vector对象
for (int i = 0; i != 100; ++i)
v2.push_back(i); //依次把整数值放到v2尾端//循环结束后v2有100个元素,值从0到99
同样的,如果直到运行时才能知道vector对象中元素的确切个数,也应该使用刚刚这种方法创建vector对象并为其赋值。例如,有时需要实时读入数据然后将其赋予vector对象:
// 从标准输入中读取单词,将其作为vector对象的元素存储string word;
vector<string> text;// 空vector对象
while (cin >> word) {
text.push_back(word); // 把 word添加到text后面
C++标准要求vector应该能在运行时高效快速地添加元素。因此既然vector对象能高效地增长,那么在定义vector对象的时候设定其大小也就没什么必要了,事实上如果这么做性能可能更差。只有一种例外情况,就是所有( all)元素的值都一样。一旦元素的值有所不同,更有效的办法是先定义一个空的vector对象,再在运行时向其中添加具体值。vector还提供了方法,允许我们进一步提升动态添加元素的性能。开始的时候创建空的vector对象,在运行时再动态添加元素,这一做法与C语言及其他大多数语言中内置数组类型的用法不同。特别是如果用惯了C或者Java,可以预计在创建vector对象时顺便指定其容量是最好的。然而事实上,通常的情况是恰恰相反。
由于能高效便捷地向vector对象中添加元素,很多编程工作被极大简化了。然而,这种简便性也伴随着一些对编写程序更高的要求:其中一条就是必须要确保所写的循环正确无误,特别是在循环有可能改变vector对象容量的时候。随着对vector的更多使用,我们还会逐渐了解到其他一些隐含的要求,其中一条是现在就要指出的:如果循环体内部包含有向vector对象添加元素的语句,则不能使用范围for循环。
除了push_back之外,vector还提供了几种其他操作,大多数都和string 的相关操作类似,列出了其中比较重要的一些。
v.empty() | 如果v不含有任何元素,返回真 : 否则返回假 |
v.size() | 返回v中元素的个数 |
v.push_back(t) | 向v的尾端添加一个值为t的元素 |
v[n] | 返回v中第n个位置上元素的引用 |
v1 = v2 | 用v2中元素的拷贝替换v1中的元素 |
v1 = { a, b,c… } | 用列表中元素的拷贝替换v1中的元素 |
v1== v2 | v1和v2相等当且仅当它们的元素数量相同且对应位置的元素值都相同 |
v1 != v2 | |
<, <= , >, >= | 顾名思义,以字典顺序进行比较 |
访问vector对象中元素的方法和访问string 对象中字符的方法差不多,也是通过元素在 vector对象中的位置。例如,可以使用范围for语句处理vector对象中的所有元素:
vector<int> v{ 1,2,3,4,5,6,7,8,9 };
for (auto& i : v)//对于v中的每个元素(注意:i是一个引用)
i *= i;//求元素值的平方
for (auto i : v)//对于v中的每个元素
cout << i << " ";//输出该元素
cout << endl;
第一个循环把控制变量 i 定义成引用类型,这样就能通过 i 给 v 的元素赋值,其中 i 的类型由auto关键字指定。这里用到了一种新的复合赋值运算符。如我们所知,+=把左侧运算对象和右侧运算对象相加,结果存入左侧运算对象;类似的,*=把左侧运算对象和右侧运算对象相乘,结果存入左侧运算对象。最后,第二个循环输出所有元素。
vector的 empty和 size两个成员与string的同名成员功能完全一致: empty检查vector对象是否包含元素然后返回一个布尔值; size则返回vector对象中元素的个数,返回值的类型是由vector定义的size_type类型。
各个相等性运算符和关系运算符也与string 的相应运算符(参见3.2.2节,第79页)功能一致。两个vector对象相等当且仅当它们所含的元素个数相同,而且对应位置的元素值也相同。关系运算符依照字典顺序进行比较:如果两个vector对象的容量不同,但是在相同位置上的元素值都一样,则元素较少的vector对象小于元素较多的vector对象;若元素的值有区别,则 vector对象的大小关系由第一对相异的元素值的大小关系决定。
刚接触C++语言的程序员也许会认为可以通过vector对象的下标形式来添加元素,事实并非如此。下面的代码试图为vector对象ivec添加10个元素:
vector<int> ivec; // 空vector对象
for (decltype (ivec.size()) ix = 0; ix != 10; ++ix)
ivec[ix] = ix; //严重错误:ivec不包含任何元素
然而,这段代码是错误的: ivec是一个空vector,根本不包含任何元素,当然也就不能通过下标去访问任何元素!如前所述,正确的方法是使用push_back:
for (decltype (ivec.size()) ix = 0; ix != 10; ++ix)
ivec.push_back(ix); //正确:添加一个新元素,该元素的值是ix
关于下标必须明确的一点是:只能对确知已存在的元素执行下标操作。例如,
vector<int> ivec;// 空vector对象
cout << ivec[0];// 错误:ivec不包含任何元素
vector<int> ivec2(10); // 含有10个元素的vector对象
cout << ivec2[10];//错误:ivec2元素的合法索引是从0到9
试图用下标的形式去访问一个不存在的元素将引发错误, 不过这种错误不会被编译器发现,而是在运行时产生一个不可预知的值。不幸的是, 这种通过下标访问不存在的元素的行为非常常见, 而且会产生很严重的后果。所谓的缓冲区溢出(buffer overflow)指的就是这类错误,这也是导致PC及其他设备上应用程序出现安全问题的一个重要原因。
我们已经知道可以使用下标运算符来访问 string对象的字符或vector对象的元素,还有另外一种更通用的机制也可以实现同样的目的,这就是迭代器(iterator)。除了vector之外,标准库还定义了其他几种容器。所有标准库容器都可以使用迭代器,但是其中只有少数几种才同时支持下标运算符。严格来说,string对象不属于容器类型,但是string支持很多与容器类型类似的操作。vector支持下标运算符,这点和 string一样; string支持迭代器,这也和vector是一样的。
类似于指针类型,迭代器也提供了对对象的间接访问。就迭代器而言,其对象是容器中的元素或者string对象中的字符。使用迭代器可以访问某个元素,迭代器也能从一个元素移动到另外一个元素。迭代器有有效和无效之分,这一点和指针差不多。有效的迭代器或者指向某个元素,或者指向容器中尾元素的下一位置:其他所有情况都属于无效。
和指针不一样的是,获取迭代器不是使用取地址符,有迭代器的类型同时拥有返回迭代器的成员。比如,这些类型都拥有名为begin和 end的成员,其中 begin 成员负责返回指向第一个元素(或第一个字符)的迭代器。如有下述语句:
//由编译器决定b和e的类型. b表示v的第一个元素,e表示v尾元素的下一位置
auto b = v.begin(), e = v.end(); //b和e的类型相同
end成员则负责返回指向容器(或string对象)“尾元素的下一位置(one past the end)”的迭代器,也就是说,该迭代器指示的是容器的一个本不存在的“尾后(off the end)”元素。这样的迭代器没什么实际含义,仅是个标记而已,表示我们已经处理完了容器中的所有元素。end 成员返回的迭代器常被称作尾后迭代器(off-the-end iterator)或者简称为尾迭代器(end iterator)。特殊情况下如果容器为空,则 begin和 end返回的是同一个迭代器。
表列举了迭代器支持的一些运算。使用==和!=来比较两个合法的迭代器是否相等,如果两个迭代器指向的元素相同或者都是同一个容器的尾后迭代器,则它们相等;否则就说这两个迭代器不相等。
*iter | 返回迭代器iter所指元素的引用 |
iter->mem | 解引用iter并获取该元素的名为mem的成员,等价于(*iter).mem |
++iter | 令iter指示容器中的下一个元素 |
–iter | 令iter指示容器中的上一个元素 |
iter1 == iter2 | 判断两个迭代器是否相等(不相等),如果两个迭代器指示的是同一个元素或者它们是同一个容器的尾后迭代器,则相等; 反之,不相等 |
iter1 != iter2 |
和指针类似,也能通过解引用迭代器来获取它所指示的元素,执行解引用的迭代器必须合法并确实指示着某个元素。试图解引用一个非法迭代器或者尾后迭代器都是未被定义的行为。
举个例子,利用下标运算符把string对象的第一个字母改为了大写形式,下面利用迭代器实现同样的功能:
string s(""some string" ) ;
if (s.begin() != s.end()) {// 确保s 非空
auto it = s.begin();// it表示s 的第一个字符
*it = toupper(*it);//将当前字符改成大写形式
}
//输出:Some string
迭代器使用递增(++)运算符来从一个元素移动到下一个元素。从逻辑上来说,迭代器的递增和整数的递增类似,整数的递增是在整数值上“加1”,迭代器的递增则是将迭代器“向前移动一个位置”。
因为 end返回的迭代器并不实际指示某个元素,所以不能对其进行递增或解引用的操作。
//依次处理s的字符直至我们处理完全部字符或者遇到空白
for (auto it = s.begin(); it != s.end() && !isspace(*it); ++it)
*it = toupper(*it); //将当前字符改成大写形式
和上文的那个程序一样,上面的循环也是遍历s的字符直到遇到空白字符为止,只不过之前的程序用的是下标运算符,现在这个程序用的是迭代器。循环首先用s.begin的返回值来初始化it,意味着it指示的是s 中的第一个字符(如果有的话)。条件部分检查是否已到达s 的尾部,如果尚未到达,则将it解引用的结果传入isspace函数检查是否遇到了空白。每次迭代的最后,执行++it令迭代器前移一个位置以访问s的下一个字符。循环体内部和上一个程序if语句内的最后一句话一样,先解引用it,然后将结果传入toupper函数得到该字母对应的大写形式,再把这个大写字母重新赋值给it所指示的字符。
原来使用C或Java的程序员在转而使用C++语言之后,会对for循环中使用!=而非<进行判断有点儿奇怪。C++程序员习惯性地使用!=,其原因和他们更愿意使用迭代器而非下标的原因一样:因为这种编程风格在标准库提供的所有容器上都有效。之前已经说过,只有string和 vector等一些标准库类型有下标运算符,而并非全都如此。与之类似,所有标准库容器的迭代器都定义了==和!-,但是它们中的大多数都没有定义<运算符。因此,只要我们养成使用迭代器和!=的习惯,就不用太在意用的到底是哪种容器类型。
就像不知道string和 vector的size_type成员到底是什么类型一样,一般来说我们也不知道(其实是无须知道)迭代器的精确类型。而实际上,那些拥有迭代器的标准库类型使用iterator和const_iterator来表示迭代器的类型:
vector<int>:: iterator it;// it能读写vector的元素
string :: iterator it2;// it2能读写string对象中的字符
vector<int> :: const_iterator it3; // it3只能读元素,不能写元素
string :: const_iterator it4; // it4 只能读字符,不能写字符
const_iterator和常量指针差不多,能读取但不能修改它所指的元素值。相反,iterator的对象可读可写。如果vector对象或string对象是一个常量,只能使用const_iterator;如果vector对象或string对象不是常量,那么既能使用iterator也能使用const_iterator。
迭代器这个名词有三种不同的含义:可能是迭代器概念本身,也可能是指容器定义的迭代器类型,还可能是指某个迭代器对象。重点是理解存在一组概念上相关的类型,我们认定某个类型是迭代器当且仅当它支持一套操作,这套操作使得我们能访问容器的元素或者从某个元素移动到另外一个元素。每个容器类定义了一个名为 iterator 的类型,该类型支持迭代器概念所规定的一套操作。
begin和 end返回的具体类型由对象是否是常量决定,如果对象是常量,begin和end返回const_iterator;如果对象不是常量,返回iterator:
vector<int> v;
const vector<int> cv;
auto it1 = v.begin(); // it1的类型是vector : : iterator
auto it2 = cv.begin(); // it2的类型是vector : : const_iterator
有时候这种默认的行为并非我们所要。如果对象只需读操作而无须写操作的话最好使用常量类型(比如 const_iterator)。为了便于专门得到const_iterator类型的返回值,C++11新标准引入了两个新函数,分别是cbegin和 cend:
auto it3 = v.cbegin(); // it3的类型是vector : : const_iterator
解引用迭代器可获得迭代器所指的对象,如果该对象的类型恰好是类,就有可能希望进一步访问它的成员。例如,对于一个由字符串组成的vector对象来说,要想检查其元素是否为空,令it是该vector对象的迭代器,只需检查it所指字符串是否为空就可以了
(*it).empty()//解引用it,然后调用结果对象的empty成员
* it.empty()//错误:试图访问it的名为empty的成员,但it是个迭代器,//没有empty成员
上面第二个表达式的含义是从名为it的对象中寻找其empty成员,显然it是一个迭代器,它没有哪个成员是叫empty的,所以第二个表达式将发生错误。
为了简化上述表达式,C++语言定义了箭头运算符(->)。箭头运算符把解引用和成员访问两个操作结合在一起,也就是说,it->mem和(*it).mem表达的意思相同。
例如,假设用一个名为text的字符串向量存放文本文件中的数据,其中的元素或者是一句话或者是一个用于表示段落分隔的空字符串。如果要输出text中第一段的内容,可以利用迭代器写一个循环令其遍历text,直到遇到空字符串的元素为止:
//依次输出text的每一行直至遇到第一个空白行为止
for (auto it = text.cbegin ();it != text.cend() && !it->empty0); ++it)
cout << *it << endl;
我们首先初始化it令其指向text的第一个元素,循环重复执行直至处理完了text的所有元素或者发现某个元素为空。每次迭代时只要发现还有元素并且尚未遇到空元素,就输出当前正在处理的元素。值得注意的是,因为循环从头到尾只是读取text的元素而未向其中写值,所以使用了cbegin和 cend来控制整个迭代过程。
虽然vector对象可以动态地增长,但是也会有一些副作用。已知的一个限制是不能在范围for循环中向vector对象添加元素。另外一个限制是任何一种可能改变vector对象容量的操作,比如 push_back,都会使该vector对象的迭代器失效。谨记,但凡是使用了迭代器的循环体,都不要向迭代器所属的容器添加元素。
迭代器的递增运算令迭代器每次移动一个元素,所有的标准库容器都有支持递增运算的迭代器。类似的,也能用==和!=对任意标准库类型的两个有效迭代器进行比较。string和 vector的迭代器提供了更多额外的运算符,一方面可使得迭代器的每次移动跨过多个元素,另外也支持迭代器进行关系运算。所有这些运算被称作迭代器运算( iterator arithmetic)。
要访问顺序容器和关联容器中的元素,需要通过“迭代器(iterator)”进行。迭代器是一个变量,相当于容器和操纵容器的算法之间的中介。迭代器可以指向容器中的某个元素,通过迭代器就可以读写它指向的元素。从这一点上看,迭代器和指针类似。不同容器的迭代器,其功能强弱有所不同。容器的迭代器的功能强弱,决定了该容器是否支持 STL 中的某种算法。例如,排序算法需要通过随机访问迭代器来访问容器中的元素,因此有的容器就不支持排序算法。
不同容器的迭代器的功能
容器 | 迭代器功能 |
---|---|
vector | 随机访问 |
deque | 随机访问 |
list | 双向 |
set / multiset | 双向 |
map / multimap | 双向 |
stack | 不支持迭代器 |
queue | 不支持迭代器 |
priority_queue | 不支持迭代器 |
可以令迭代器和一个整数值相加(或相减),其返回值是向前(或向后)移动了若干个位置的迭代器。执行这样的操作时,结果迭代器或者指示原vector对象(或string对象)内的一个元素,或者指示原vector对象(或string对象)尾元素的下一位置。
// 计算得到最接近vi中间元素的一个迭代器
auto mid = vi.begin() + vi.size() / 2;
如果vi有20个元素,vi.size()/2得10,此例中即令mid等于vi.begin ( )+10。已知下标从О开始,则迭代器所指的元素是vi[10],也就是从首元素开始向前相隔10个位置的那个元素。对于string或vector的迭代器来说,除了判断是否相等,还能使用关系运算符(<、<=、>、>=)对其进行比较。参与比较的两个迭代器必须合法而且指向的是同一个容器的元素(或者尾元素的下一位置)。
只要两个迭代器指向的是同一个容器中的元素或者尾元素的下一位置,就能将其相减,所得结果是两个迭代器的距离。所谓距离指的是右侧的迭代器向前移动多少位置就能追上左侧的迭代器,其类型是名为difference_type 的带符号整型数。string 和vector都定义了difference_type ,因为这个距离可正可负,所以difference_type是带符号类型的。
使用迭代器运算的一个经典算法是二分搜索。二分搜索从有序序列中寻找某个给定的值。二分搜索从序列中间的位置开始搜索,如果中间位置的元素正好就是要找的元素,搜索完成;如果不是,假如该元素小于要找的元素,则在序列的后半部分继续搜素;假如该元素大于要找的元素,则在序列的前半部分继续搜索。在缩小的范围中计算一个新的中间元素并重复之前的过程,直至最终找到目标或者没有元素可供继续搜索。
// text必须是有序的
// beg 和end表示我们搜索的范围
auto beg - text.begin(), end = text.end();
auto mid = text.begin() + (end - beg) / 2; // 初始状态下的中间点
//当还有元素尚未检查并且我们还没有找到sought时执行循环
while (mid != end & &*mid != sought) {
if (sought < *mid)//我们要找的元素在前半部分吗 ?
end = mid;// 如果是,调整搜索范围使得忽略掉后半部分
else//我们要找的元素在后半部分
beg = mid + 1; // 在mid之后寻找
mid = beg + (end - beg) / 2;//新的中间点
程序的一开始定义了三个迭代器: beg 指向搜索范围内的第一个元素、end指向尾元素的下一位置、mid指向中间的那个元素。初始状态下,搜索范围是名为text 的vector的全部范围。
循环部分先检查搜索范围是否为空,如果mid和end 的当前值相等,说明已经找遍了所有元素。此时条件不满足,循环终止。当搜索范围不为空时,可知 mid指向了某个元素,检查该元素是否就是我们所要搜索的,如果是,也终止循环。
当进入到循环体内部后,程序通过某种规则移动beg 或者end来缩小搜索的范围。如果mid所指的元素比要找的元素sought大,可推测若text含有sought,则必出现在mid所指元素的前面。此时,可以忽略mid后面的元素不再查找,并把mid赋给end即可。另一种情况,如果*mid 比 sought小,则要找的元素必出现在mid所指元素的后面。此时,通过令 beg 指向mid 的下一个位置即可改变搜索范围。因为已经验证过mid不是我们要找的对象,所以在接下来的搜索中不必考虑它。
循环过程终止时,mid或者等于end或者指向要找的元素。如果mid等于end,说明text中没有我们要找的元素。
按照迭代器的功能强弱,可以把迭代器分为以下几种类型:
输入迭代器 (input iterator)
输出迭代器 (output iterator)
前向迭代器 (forward iterator)
双向迭代器 (bidirectional iterator)
随机访问迭代器( random-access iterator)
迭代器iterator是C++ STL的组件之一,作用是用来遍历容器,而且是通用的遍历容器元素的方式,无论容器是基于什么数据结构实现的,尽管不同的数据结构,遍历元素的方式不一样,但是用迭代器遍历不同容器的代码是完全一样的。