第3章 字符串、向量和数组

3.1 命名空间的using声明

目前为止,我们用到的库函数基本上都属于命名空间std,而程序也显式地将这一点标示了出来。例如,std::cin 表示从标准输入中读取内容。此处使用作用域操作符(:😃(参见1.2节)的含义是:编译器应从操作符侧名字所示的作用域中寻找右侧那个名字。因此,std::cin的意思就是要使用命名空间 std 中的名字cin。
上面的方法显得比较烦琐,然而幸运的是,通过更简单的途径也能使用到命名空间中的成员。本节将学习其中一种最安全的方法,也就是使用using声明(using declaration),18.2.2节会介绍另一种方法。
有了 using 声明就无须专门的前缀 (形如命名空间::)也能使用所需的名字了using声明具有如下的形式:

using namespace::name;

一旦声明了上述语句,就可以直接访问命名空间中的名字:

#include <iostream>
//using声明,当我们使用名字cin时,从命名空间std中获取它
using std::cin;

每个名字都需要独立的using声明

按照规定,每个using声明引入命名空间中的一个成员。例如,可以把要用到的标准库中的名字都以using声明的形式表示出来。

#include <iostream>
using std::cin;
using std::cout; using std::endl;

头文件不应包含using声明

位于头文件的代码(参见2.6.3 节)一般来说不应该使用using声明。这是因为头文件的内容会拷贝到所有引用它的文件中去,如果头文件里有某个using声明,那么每个使用了该头文件的文件就都会有这个声明。对于某些程序来说,由于不经意间包含了一些名字,反而可能产生始料未及的名字冲突。

3.2 标准库类型string

标准库类型string表示可变长的字符序列,使用string类型必须首先包含string头文件。作为标准库的一部分,string定义在命名空间std中。接下来的示例都假定已包含了下述代码:

#include <iostream>
using std::string;

C++标准一方面对库类型所提供的操作做了详细规定,另一方面也对库的实现者做出一些性能上的需求。因此,标准库类型对于一般应用场合来说有足够的效率。

3.2.1 定义和初始化string对象

如何初始化类的对象是由类本身决定的。一个类可以定义很多种初始化对象的方式,只不过这些方式之间必须有所区别:或者是初始值的数量不同,或者是初始值的类型不同。表3.1列出了初始化string对象最常用的一些方式,下面是几个例子:

string s1;				//默认初始化,s1是一个空字符串
string s2 = s1;			//s2是s1的副本
string s3 = "hiya";		//s3是该字符串字面值的副本
string s4(10, 'c');		//s4的内容是cccccccccc

可以通过默认的方式(参见2.2.1节)初始化一个string对象,这样就会得到一个空的string,也就是说,该string对象中没有任何字符。如果提供了一个字符串字面值(参见2.1.3节),则该字面值中除了最后那个空字符外其他所有的字符都被拷贝到新创建的string对象中去。如果提供的是一个数字和一个字符,则string对象的内容是给定字符连续重复若干次后得到的序列。

image-20221117135637297

直接初始化和拷贝初始化

由2.2.1节的学习可知C++语言有几种不同的初始化方式,通过string我们可以清楚地看到在这些初始化方式之间到底有什么区别和联系。如果使用等号(=)初始化一个变量,实际上执行的是拷贝初始化 (copy initialization),编译器把等号右侧的初始值拷贝到新创建的对象中去。与之相反,如果不使用等号,则执行的是直接初始化 (direct initialization)
当初始值只有一个时,使用直接初始化或拷贝初始化都行。如果像上面的s4那样初始化要用到的值有多个,一般来说只能使用直接初始化的方式:

string s5 = "hiya";		//拷贝初始化
string s6("hiya");		//直接初始化
string s7(10,'c');		//直接初始化,s7的内容是cccccccccc

对于用多个值进行初始化的情况,非要用拷贝初始化的方式来处理也不是不可以,不过需要显式地创建一个(临时)对象用于拷贝:

string s8 = string(10,'c');		//拷贝初始化,s8的内容是cccccccccc

这条语句本质上等价于下面的两条语句:

string temp(10,'c');
string s8 = temp;

尽管初始化s8的语句合法,但和初始化s7的方式比较起来可读性较差,也没有任何补偿优势。

3.2.2 string对象上的操作

一个类除了要规定初始化其对象的方式外,还要定义对象上所能执行的操作。其中,类既能定义通过函数名调用的操作,就像Sales_item类的isbn函数那样(参见1.5.2节),也能定义<<、+等各种运算符在该类对象上的新含义。表3.2中列举了string的大多数操作。

image-20221117140559657

读写string对象

int main()
{
    string s;
    cin >> s;
    cout << s << endl;
    return 0;
}

在执行读取操作时,string对象会自动忽略开头的空白(即空格符、换行符、制表符等)并从第一个真正的字符开始读起,直到遇见下一处空白为止。

如上所述,如果程序的输入是” Hello World! “(注意开头和结尾处的空格),则输出将是“Hello”,输出结果中没有任何空格。
和内置类型的输入输出操作一样,string 对象的此类操作也是返回运算符左侧的运算对象作为其结果。因此,多个输入或者多个输出可以连写在一起:

string s1,s2;
cin >> s1 >> s2;			//把第一个输入读到s1中,第二个输入读到s2中
cout << s1 << s2 << endl;	//输出两个string对象

假设给上面这段程序输入与之前一样的内容” Hello World! “,输出将是“HelloWorld!”

读取未知数量的string对象

1.4.3节的程序可以读入数量未知的整数,下面编写一个类似的程序用于读取string对象:

int main()
{
    string word;
    while (cin >> word)			//反复读取,直至到达文件末尾
        cout << word << endl;	//逐个输出单词,每个单词后面紧跟一个换行
    return 0;
}

在该程序中,读取的对象是string而非int,但是while语句的条件部分和之前版本的程序是一样的。该条件负责在读取时检测流的情况,如果流有效,也就是说没遇到文件结束标记或非法输入,那么执行while语句内部的操作。此时,循环体将输出刚刚从标准输入读取的内容。重复若干次之后,一旦遇到文件结束标记或非法输入循环也就结束了。

使用getline读取一整行

有时我们希望能在最终得到的字符串中保留输入时的空白符,这时应该用getline函数代替原来的>>运算符。getline函数的参数是一个输入流和一个string对象,函数从给定的输入流中读入内容,直到遇到换行符为止(注意换行符也被读进来了),然后把所读的内容存入到那个string对象中去(注意不存换行符)。getline只要一遇到换行符就结束读取操作并返回结果,哪怕输入的一开始就是换行符也是如此,这样所得的结果是个空string。

和输入运算符一样,getline也会返回它的流参数。因此既然输入运算符能作为判断的条件(参见1.4.3节),我们也能用getline的结果作为条件。例如,可以通过改写之前的程序让它一次输出一整行,而不再是每行输出一个词了:

int main()
{
	string line;
    //每次读入一整行,直至到达文件末尾
    while(getline(cin,line))
        cout << line << endl;
    return 0;
}

因为line中不包含换行符,所以我们手动地加上换行操作符。和往常一样,使用endl结束当前行并刷新显示缓冲区。

触发getline函数返回的那个换行符实际上被丢弃掉了,得到的string对象中并不包含该换行符。

string的empty和size操作

顾名思义,empty函数根据string对象是否为空返回一个对应的布尔值(参见第2.1节)。和Sales_items类的isbn成员一样,empty也是string的一个成员函数。调用该函数的方法很简单,只要使用点操作符指明是哪个对象执行了empty函数就可以了。

通过改写之前的程序,可以做到只输出非空的行:

//每次读入一整行,遇到空行直接跳过
while(getline(cin,line))
    if(!line.empty())
        cout << line << endl;

在上面的程序中,if语句的条件部分使用了逻辑非运算符(!),它返回与其运算对象相反的结果。

size函数返回string对象的长度(即string对象中字符的个数),可以使用size函数只输出长度超过80个字符的行:

string line;
//每次读入一整行,输出其中超过80个字符的行
while(getline(cin,line))
    if(line.size() > 80)
        cout << line << endl;

string::size_type类型

对于size函数来说,返回一个int或者如前面2.1.1节所述的那样返回一个unsigned似乎都是合情合理的。但其实size函数返回的是一个string::size_type类型的值。

string类及其他大多数标准库类型都定义了几种配套的类型。这些配套类型体现了标准库类型与机器无关的特性,类型size_type即是其中的一种。在具体使用的时候,通过作用域操作符来表明名字size_type是在类string中定义的。

尽管我们不太清楚string::size_type类型的细节,但有一点是肯定的:它是一个无符号类型的值而且能足够存放下任何string对象的大小。所有用于存放string类的size函数返回值的变量,都应该是string::size_type类型的。

由于size函数返回的是一个无符号整型数,因此切记,如果在表达式中混用了带符号数和无符号数将可能产生意想不到的结果(参见2.1.2节)。例如,假设n是一个具有负值的int,则表达式s.size()<n的判断结果几乎肯定是true。这是因为负值n会自动地转换成一个比较大的无符号值。

如果一条表达式中已经有了size()函数就不要再使用int了,这样可以避免混用int和unsigned可能带来的问题。

比较string对象

string类定义了几种用于比较字符串的运算符。这些比较运算符逐一比较string对象中的字符,并且对大小写敏感。

相等性运算符(==和!=)分别检验两个string对象相等或不相等,string对象相等意味着它们的长度相同而且所包含的字符也全都相同。

关系运算符<、<=、>、>=分别检验一个string对象是否小于、小于等于、大于、大于等于另外一个string对象。上述这些运算符都按照(大小写敏感的)字典顺序:

  1. 如果两个string对象的长度不同,而且较短string对象的每个字符都与较长string对象对应位置上的字符相同,就说较短string对象小于较长string对象。
  2. 如果两个string对象在某些对应的位置上不一致,则string对象比较的结果其实是string对象中第一对相异字符比较的结果。

为string对象赋值

两个string对象相加得到一个新的string对象,其内容是把左侧的运算对象与右侧的运算对象串接而成。另外,复合赋值运算符(+=)(参见1.4.1节)负责把右侧string对象的内容追加到左侧string对象的后面:

string s1 = "hello, ", s2 = "world\n";
string s3 = s1 + s2;		//s3的内容是hello,world\n
s1 += s2;					//等价于s1 = s1 + s2

字面值和string对象相加

如2.1.2节所讲的,即使一种类型并非所需,我们也可以使用它,不过前提是该种类型可以自动转换成所需的类型。因为标准库允许把字符字面值和字符串字面值(参见2.1.3节)转换成string对象,所以在需要string对象的地方就可以使用这两种字面值来替代。

string s1 = "hello", s2 = "world";		//在s1和s2中都没有标点符号
string s3 = s1 + ", " + s2 + '\n';

当把string对象和字符字面值及字符串字面值混在一条语句中使用时,必须确保每个加法运算符(+)的两侧的运算对象至少有一个是string:

string s4 = s1 + ", ";				//正确:把一个string对象和一个字面值相加
string s5 = "hello" + ", ";			//错误:两个运算对象都不是string
string s6 = s1 + ", " + "world";	//正确:每个加法运算符都有一个运算对象是string
string s7 = "hello" + ", " + s2;	//错误:不能把字面值直接相加

因为某些历史原因,也为了与C兼容,所以C++语言中的字符串字面值并不是标准库类型string的对象。切记,字符串字面值与string是不同的类型。

3.2.3 处理string对象中的字符

我们经常需要单独处理string对象中的字符,比如检查一个string对象是否包含空白,或者把string对象中的字母改成小写,再或者查看某个特定的字符是否出现等。

这类处理的一个关键问题是如何获取字符本身。有时需要处理 string 对象中的每个字符,另外一些时候则只需处理某个特定的字符,还有些时候遇到某个条件处理就要停下来。以往的经验告诉我们,处理这些情况常常要涉及语言和库的很多方面。
另一个关键问题是要知道能改变某个字符的特性。在 cctype 头文件中定义了一组标准库函数处理这部分工作,表3.3 列出了主要的函数名及其含义。

image-20221119163047642

建议:使用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++语言所独有的

处理每个字符?使用基于范围的for语句

如果相对string对象中的每个字符做点儿什么操作,目前最好的办法是使用C++11新标准提供的一种语句:范围for(range for)语句。这种语句遍历给定序列中的每个元素并对序列中的每个值执行某种操作,其语法形式是:

for(declaration : expression)
    statement

其中,expression部分是一个对象,用于表示一个序列。declaration部分负责定义一个变量,该变量将被用于访问序列中的基础元素。每次迭代,declaration部分的变量会被初始化为expression部分的下一个元素值。

使用范围for语句把string对象中的字符每行一个输出出来:

string str("some string");
//每行输出str中的一个字符
for(auto c : str)			//对于str中的每个字符
    cout << c << endl;		//输出当前字符,后面紧跟一个换行符

使用范围for语句和ispunct函数来统计string对象中标点符号的个数:

string s("Hello World!!!");
decltype(s.size()) punct_cnt = 0;
for(auto c : s)
    if(ispunct(c))
        ++punct_cnt;
cout << punct_cnt << "punctuation characters in " << s << endl;

使用范围for语句改变字符中的字符

如果想要改变string对象中字符的值,必须把循环变量定义成引用类型。记住,所谓引用只是给定对象的一个别名,因此当使用引用作为循环控制变量时,这个变量实际上被依次绑定到了序列的每个元素上。使用这个引用,我们就能改变它绑定的字符。

将字符串改写为大写字母的形式:

string s("Hello World!!!");
for(auto &c : s)
    c = toupper(c);
cout << s << endl;

只处理一部分字符?

要想访问string对象中的单个字符有两种方式:一种是使用下标,另外一种是使用迭代器。

下标运算符([])接受的输入参数是string::size_tpye类型的值(参见3.2.2节),这个参数表示要访问的字符的位置:返回值是该位置上字符的引用。string对象的下标从0计起。

string对象的下标必须大于等于0而小于s.size()。

使用超出此范围的下标将引发不可预知的结果,以此推断,使用下标访问空string也会引发不可预知的结果。

下标的值称作“下标”或“索引”,任何表达式只要它的值是一个整型值就能作为索引。不过,如果某个索引是带符号类型的值将自动转换成由string::size_type(参见2.1.2节)表达的无符号类型。

使用下标执行迭代

将s的第一个词改成大写形式:

//依次处理s中的字符直至我们处理完全部字符或者遇到一个空白
for(decltype(s.size()) index = 0; 
    index!=s.size() && !isspace(s[index]); ++index)
    	s[index] = toupper(s[index]);

提示:注意检查下标的合法性

使用下标时必须确保其在合理范围之内,也就是说,下标必须大于等于0而小于字符串的size()的值。一种简便易行的方法是,总是设下标的类型为string::size_type,因为此类型是无符号数,可以确保下标不会小于0。此时,代码只需保证下标小于size()的值就可以了。

3.3 标准库类型vector

标准库类型vector表示对象的集合,其中所有对象的类型都相同。集合中的每个对象都有一个与之对应的索引,索引用于访问对象。因为vector“容纳着”其他对象,所以它也常被称作容器(container)

要想使用vector,必须包含适当的头文件。在后续的例子中,都将假定做了如下using声明:

#include <vector>
using std::vector;

C++语言既有类模板(class template),也有函数模板,其中vector是一个类模板。

模板本身不是类或函数,相反可以将模板看作为编译器生成类或函数编写的一份说明。编译器根据模板创建类或函数的过程称为实例化(instantiation),当使用模板时,需要指出编译器应把类或函数实例化成何种类型。

对于类模板来说,我们通过提供一些额外信息来指定模板到底实例化成什么样的类,需要提供哪些信息由模板决定。提供信息的方式总是这样:即在模板名字后面跟一对尖括号,在括号内放上信息。

vector<int> ivec;
vector<Sales_item> Sales_vec;
vector<vector<string>> file;

vector是模板而非类型,由vector生成的类型必须包含vector中元素的类型,例如vector<int>。

vector能容纳绝大多数类型的对象作为其元素,但是因为引用不是对象(参见2.3.1节),所以不存在包含引用的vector。除此之外,其他大多数(非引用)内置类型和类类型都可以构成vector对象,甚至组成vector的元素也可以是vector。

需要指出,在早期版本的C++标准中如果vector的元素还是vector(或者其他模板类型),则其定义的形式与现在的C++11新标准略有不同。过去,必须在外层vector对象的右尖括号和其元素类型之间添加一个空格,如应该写成vecotr<vector<int> >。

某些编译器可能仍需以老式的声明语句来处理元素为vector的vector对象,如vector<vector<int> >。

3.3.1 定义和初始化vector对象

和任何一种类类型一样,vector模板控制着定义和初始化向量的方法。表3.4列出了定义vector对象的常用方法。

image-20221121141023013

可以默认初始化vector对象(参见2.2.1节),从而创建一个指定类型的空vector:

vector<string> svec; //默认初始化,svec不含任何元素

当然也可以在定义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对象

C++11新标准还提供了另外一种为vector对象的元素赋初值的方法,即列表初始化(参见2.2.1节)。此时,用花括号括起来的0个或多个初始元素值被赋给vector对象:

vector<string> articles = {"a","an","the"};

上述vector对象包含三个元素:第一个是字符串”a”,第二个是字符串”an”,最后一个是字符串”the”。

之前已经讲过,C++语言提供了几种不同的初始化方式(参见2.2.1节)。在大多数情况下这些初始化方式可以相互等价地使用,不过也并非一直如此。目前已经介绍过的两种例外情况是:其一,使用拷贝初始化时 (即使用=时)(参见3.2.1节)只能提供一个初始值;其二,如果提供的是一个类内初始值(参见2.6.1节),则只能使用拷贝初始化或使用花括号的形式初始化。第三种特殊的要求是,如果提供的是初始元素值的列表,则只能把初始值都放在花括号里进行列表初始化,而不能放在圆括号里:

vector<string> v1{"a", "an", "the"};	//列表初始化
vector<string> v2{"a", "an", "the"};	//错误

创建指定数量的元素

还可以用vector对象容纳的元素数量和所有元素的统一初始值来初始化vector对象:

vector<int> ivec(10, -1);
vector<string> svec(10, "hi!");

值初始化

通常情况下,可以只提供vector对象容纳的元素数量而不用略去初始值。此时库会创建一个值初始化的 (value-initialized)元素初值,并把它赋给容器中的所有元素。这个初值由vector对象中元素的类型决定。
如果vector对象的元素是内置类型,比如int,则元素初始值自动设为0。如果元素是某种类类型,比如string,则元素由类默认初始化:

vector<int> ivec(10);		//10个元素,每个都初始化为0
vector<string> svec(10);	//10个元素,每个都是空string对象

对这种初始化的方式有两个特殊限制:其一,有些类要求必须明确地提供初始值(参见2.2.1节),如果vector对象中元素的类型不支持默认初始化,我们就必须提供初始的元素值。对这种类型的对象来说,只提供元素的数量而不设定初始值无法完成初始化工作。其二,如果只提供了元素的数量而没有设定初始值,只能使用直接初始化:

vector<int> vi = 10;	//错误:必须使用直接初始化的形式指定向量大小

这里的10是用来说明如何初始化vector对象的,我们用它的本意是想创建含有10个值初始化了的元素的vector对象,而非把数字10“拷贝”到 vector中。因此,此时不宜使用拷贝初始化,7.5.4节将对这一点做更详细的介绍。

列表初始值还是元素数量?

在某些情况下,初始化的真实含义依赖于传递初始值时用的是花括号还是圆括号。例如,用一个整数来初始化vector<int>时,整数的含义可能是vector对象的容量也可能是元素的值。类似的,用两个整数来初始化vector<int>时,这两个整数可能一个是vector对象的容量,另一个是元素的初值,也可能它们是容量为2的vector对象中两个元素的初值。通过使用花括号或圆括号可以区分上述这些含义:

vector<int> v1(10);				//v1有10个元素,每个的值都是0
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对象。

3.3.2 向vector对象中添加元素

对vector对象来说,直接初始化的方式适用于三种情况:初始值已知且数量较少初始值是另一个vector对象的副本、所有元素的初始值都一样。然而更常见的情况是:创建一个vector对象时并不清楚实际所需的元素个数,元素的值也经常无法确定。还有些时候即使元素的初值已知,但如果这些值总量较大而各不相同,那么在创建vector对象的时候执行初始化操作也会显得过于烦琐。
举个例子,如果想创建一个vector对象令其包含从0到9共10个元素,使用列表初始化的方法很容易做到这一点:但如果vector对象包含的元素是从0到99或者从0到999呢?这时通过列表初始化把所有元素都一一罗列出来就不太合适了。对于此例来说,更好的处理方法是先创建一个空 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对象能高效增长

C++标准要求vector应该能在运行时高效快速地添加元素。因此既然vector对象能高效地增长,那么在定义vector对象的时候设定其大小也就没什么必要了,事实上如果这么做性能可能更差。只有一种例外情况,就是所有(all)元素的值都一样。一旦元素的值有所不同,更有效的办法是先定义一个空的vector对象,再在运行时向其中添加具体值。此外,9.4节将介绍,vector还提供了方法,允许我们进一步提升动态添加元素的性能。

向vector对象添加元素蕴含的编程假定

由于能高效便捷地向vector对象中添加元素,很多编程工作被极大简化了。然而,这种简便性也伴随着一些对编写程序更高的要求:其中一条就是必须要确保所写的循环正确无误,特别是在循环有可能改变vector对象容量的时候。

随着对vector的更多使用,我们还会逐渐了解到其他一些隐含的要求,其中一条是现在就要指出的:如果循环体内部包含有向vector对象添加元素的语句,则不能使用规范for循环,具体原因将在5.4.3节详细解释。

范围for语句体内不应改变其所遍历序列的大小。

3.3.3 其他vector操作

除了push_back以外,vector还提供了几种其他操作,大多数都和string的相关操作类似,表3.5列出了其中比较重要的一些:

image-20221121165523754

访问vector对象中元素的方法和访问string对象中字符的方法差不多,也是通过元素在vector对象中的位置。例如,可以使用范围for语句处理vector对象中的所有元素:

vector<int> v{1,2,3,4,,5,6,7,8,9};
for(auto &i : v)
    i *= i;
for(auto i : v)
    cout << i << " ";
cout << endl;

vector的empty和size两个成员与string的同名成员功能完全一致:empty检查vector对象是否包含元素然后返回一个布尔值;size则返回vector对象中元素的个数,返回值的类型是由vector定义的size_type类型。

要使用size_type,需首先指定它是由哪种类型定义的。vector对象的类型总是包含着元素的类型(参见3.3节):

vector<int>::size_type //正确

vector::size_type //错误

不能用下标形式添加元素

下面的代码试图为vector对象ivec添加10个元素:

vector<int> ivec;
for( decltype(ivec.size()) ix = 0; ix != 10; ++ix)
    ivec[ix] = ix;		//严重错误:ivec不包含任何元素

实际上,ivec是一个空vector,根本不包含任何元素,当然也就不能通过下标去访问任何元素!正确的方法是使用push_back

vector对象(以及string对象)的下标运算符可用于访问已存在的元素,而不能用于添加元素。

提示:只能对确知已存在的元素执行下标操作!

试图用下标的形式去访问一个不存在的元素将引发错误,不过这种错误不会被编译器法线,而是在运行时产生一个不可预知的值。

不幸的是,这种通过下标访问不存在的元素的行为非常常见,而且会产生很严重的后果。所谓的缓冲区溢出(buffer overflow)指的就是这类错误,这也是导致PC及其他设备上应用程序出现安全问题的一个重要原因。

tips:确保下标合法的一种有效手段就是尽可能使用范围for语句。

3.4 迭代器介绍

我们已经知道可以使用下标运算符来访问string对象的字符或vector对象的元素,还有另外一种更通用的机制也可以实现同样的目的,这就是迭代器 (iterator)。在第Ⅱ部分中将要介绍,除了vector之外,标准库还定义了其他几种容器。所有标准库容器都可以使用迭代器,但是其中只有少数几种才同时支持下标运算符。严格来说,string对象不属于容器类型,但是string支持很多与容器类型类似的操作。
类似于指针类型(参见2.3.2 节),迭代器也提供了对对象的间接访问。就迭代器而言,其对象是容器中的元素或者string对象中的字符。使用迭代器可以访问某个元素,迭代器也能从一个元素移动到另外一个元素。迭代器有有效和无效之分,这一点和指针差不多。有效的迭代器或者指向某个元素,或者指向容器中尾元素的下一位置;其他所有情况都属于无效。

3.4.1 使用迭代器

和指针不一样的是,获取迭代器不是使用取地址符,有迭代器的类型同时拥有返回迭代器的成员。比如,这些类型都拥有名为beginend的成员,其中begin成员负责返回指向第一个元素(或第一个字符)的迭代器。如有下述语句:

//由编译器决定b和e的类型;参见2.5.2节(第61页)
// 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返回的是同一个迭代器。

如果容器为空,则begin和end返回的是同一个迭代器,都是尾后迭代器。

一般来说,我们不清楚(不在意)迭代器准确的类型到底是什么。在上面的例子中,使用auto关键字定义变量b和e(参见2.5.2节),这两个变量的类型也就是begin和end的返回值类型,第97页将对相关内容做更详细的介绍。

迭代器运算符

表3.6列举了选代器支持的一些运算。使用==和!=来比较两个合法的迭代器是否相等,如果两个迭代器指向的元素相同或者都是同一个容器的尾后迭代器,则它们相等;否则就说这两个迭代器不相等。

image-20221121182101160

和指针类似,也能通过解引用迭代器来获取它所指示的元素,执行解引用的迭代器必须合法并确实指示着某个元素(参见 2.3.2节)。试图解引用一个非法选代器或者尾后迭代器都是未被定义的行为。

举个例子,3.2.3节中的程序利用下标运算符把string对象的第一个字母改为了大写形式,下面利用迭代器实现同样的功能:

string s("some string");
if (s.begin() != s.end()){
    auto it = s.begin();
    *it = toupper(*it);
}

将迭代器从一个元素移动到另外一个元素

迭代器使用递增(++)运算符(参见1.4.1节)来从一个元素移动到下一个元素。从逻辑上来说,迭代器的递增和整数的递增类似,整数的递增是在整数值上“加1”,迭代器的递增则是将迭代器“向前移动一个位置”。

因为end返回的迭代器并不实际指示某个元素,所以不能对其进行递增或解引用的操作。

之前有一个程序把string对象中第一个单词改写为大写形式,现在利用迭代器及其递增运算符可以实现相同的功能:

for(auto it = s.begin(); it!=s.end() && !isspace(*it); ++it)
    *it = toupper(*it);

关键概念:泛型编程

原来使用C或Java的程序员在转而使用C++语言之后,会对 for 循环中使用!=而非<进行判断有点儿奇怪,比如上面的这个程序。C++程序员习惯性地使用!=,其原因和他们更愿意使用迭代器而非下标的原因一样:因为这种编程风格在标准库提供的所有容器上都有效。
之前已经说过,只有 string 和 vector 等一些标准库类型有下标运算符,而并非全都如此。与之类似,所有标准库容器的迭代器都定义了==和!=,但是它们中的大多数都没有定义<运算符。因此,只要我们养成使用迭代器和!=的习惯,就不用太在意用的到底是哪种容器类型。

迭代器类型

就像不知道string和vector的size_type成员(参见3.2.2节)到底是什么类型一样,一般来说我们也不知道(其实是无须知道)迭代器的精确类型。而实际上,那些拥有迭代器的标准库类型使用iterator和const_iterator来表示迭代器的类型:

vector<int>::iterator it;
string::iterator it2;

//只能读,不能写
vector<int>::const_iterator it3;
string::const_iterator it4;

const_iterator和常量指针 (参见2.4.2节)差不多,能读取不能修改它所指的元素值。相反,iterator 的对象可读可写。如果vector 对象或string 对象是一个常量,只能使用const_iterator;如果vector 对象或string 对象不是常量,那么既能使用iterator也能使用const_iterator。

术语:迭代器和迭代器类型

迭代器这个名词有三种不同的含义:可能是迭代器概念本身,也可能是指容器定义的迭代器类型,还可能是指某个迭代器对象。
重点是理解存在一组概念上相关的类型,我们认定某个类型是迭代器当且仅当它支持一套操作,这套操作使得我们能访问容器的元素或者从某个元素移动到另外一个元素。

每个容器类定义了一个名为 iterator 的类型,该类型支持迭代器概念所规定的一套操作。

begin和end运算符

begin和end返回的具体类型由对象是否是常量决定,如果对象是常量,begin和end返回const_iterator;如果对象不是常量,返回iterator:

vector<int> v;
const vector<int> cv;
auto it1 = v.begin();	//it1的类型是vector<int>::iterator
auto it2 = v.end();		//it2的类型是vector<int>::const_iterator

有时候这种默认的行为并非我们所要。在6.2.3节中将会看到,如果对象只需读操作而无须写操作的话最好使用常量类型(比如const_iterator)。为了便于专门得到const_iterator类型的返回值,C++11新标准引入了两个新函数,分别是cbegin和cend:

auto it3 = v.cbegin();	//it3的类型是vector<int>::const_iterator

类似于 begin 和end,上述两个新函数也分别返回指示容器第一个元素或最后元素下一位置的迭代器。有所不同的是,不论 vector 对象(或string 对象)本身是否是常量,返回值都是const_iterator。

结合解引用和成员访问操作

解引用迭代器可获得迭代器所指的对象,如果该对象的类型恰好是类,就有可能希望进一步访问它的成员。例如,对于一个由字符串组成的 vector 对象来说,要想检查其元素是否为空,令it 是该 vector 对象的选代器,只需检查 t 所指字符串是否为空就可以了,其代码如下所示:

(*it).empty()

注意,(*it).empty()中的圆括号必不可少,该表达式的含义是先对 it 解引用,然后解引用的结果再执行点运算符(参见1.5.2节)。如果不加圆括号,点运算符将由it来执行,而非it解引用的结果:

*it.empty()		//错误:试图访问it的名为empty的成员,但it是个迭代器,没有empty成员

为了简化上述表达式,C++语言定义了箭头运算符(->)。箭头运算符把解引用和成员访问两个操作结合在一起,也就是说,it->mem和(*it).mem 表达的意思相同。

某些对vector对象的操作会使迭代器失效

虽然vector对象可以动态地增长,但是也会有一些副作用。已知的一个限制是不能在范围for循环中向vector对象添加元素。另外一个限制是任何一种可能改变vector对象容量的操作,比如push_back,都会使该vector对象的迭代器失效。9.3.6节将详细解释迭代器是如何失效的

谨记,但凡是使用了迭代器的循环体,都不要向迭代器所属的容器添加元素。

3.4.2 迭代器运算

迭代器的递增运算令迭代器每次移动一个元素,所有的标准库容器都有支持递增运算的迭代器。类似的,也能用==和!=对任意标准库类型的两个有效迭代器(参见3.4 节)进行比较。
string和vector的迭代器提供了更多额外的运算符,一方面可使得迭代器的每次移动跨过多个元素,另外也支持迭代器进行关系运算。所有这些运算被称作迭代器运算(iterator arithmetic),其细节由表3.7列出。

image-20221123164108056

迭代器的算术运算

可以令迭代器和一个整数值相加(或相减),其返回值是向前(或向后)移动了若干个位置的迭代器。执行这样的操作时,结果迭代器或者指示原vector对象(或string对象)内的一个元素,或者指示原 vector 对象 (或string 对象)尾元素的下一位置。

举个例子,下面的代码得到一个迭代器,它指向某vector对象中间位置的元素:

//计算得到最接近vi中间元素的一个迭代器
auto mid = vi.begin() + vi.size() / 2;

对于string或vector的迭代器来说,除了判断是否相等,还能使用关系运算符(<、<=、>、>=)对其进行比较。参与比较的两个迭代器必须合法而且指向的是同一个容器的元素(或者尾元素的下一位置)。例如,假设 it 和 mid 是同一个 vector 对象的两个选代器,可以用下面的代码来比较它们所指的位置孰前孰后:

if(it < mid)
    //处理vi前半部分的元素

只要两个迭代器指向的是同一个容器中的元素或者尾元素的下一位置,就能将其相减,所得结果是两个迭代器的距离。所谓距离指的是右侧的迭代器向前移动多少位置就能追上左侧的迭代器,其类型是名为 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;
while(mid != end && *mid != sought)
{
	if(sought < *mid)
        end = mid;
    else
        beg = mid + 1;
    mid = beg + (end - beg)/2;	//新的中间点
}

3.5 数组

数组是一种类似于标准库类型vector(参见3.3节)的数据结构,但是在性能和灵活性的权衡上又与 vector 有所不同。与 vector 相似的地方是,数组也是存放类型相同的对象的容器,这些对象本身没有名字,需要通过其所在位置访问。与 vector不同的地方是,数组的大小确定不变,不能随意向数组中增加元素。因为数组的大小固定,因此对某些特殊的应用来说程序的运行时性能较好,但是相应地也损失了一些灵活性。

如果不清楚元素的确切个数,请使用vector。

3.5.1 定义和初始化内置数组

数组是一种复合类型(参见2.3节)。数组的声明形如a[d],其中 a 是数组的名字,d 是数组的维度。维度说明了数组中元素的个数,因此必须大于0。数组中元素的个数也属于数组类型的一部分,编译的时候维度应该是已知的。也就是说,维度必须是一个常量表达式(参见2.4.4节):

unsigned cnt = 42;			//不是常量表达式
constexpr unsigned sz = 42;	//常量表达式
int arr[10];				//含有10个整数的数组
int *parr[sz];				//含有42个整型指针的数组
string bad[cnt];			//错误:cnt不是常量表达式
string strs[get_size()];	//当get_size是constexpr时正确;否则错误

默认情况下,数组的元素被默认初始化(参见2.2.1节)。

和内置类型的变量一样,如果在函数内部定义了某种内置类型的数组,那么默认初始化会令数组含有未定义的值。

定义数组的时候必须指定数组的类型,不允许用auto关键字由初始值的列表推断类型。另外和vector一样,数组的元素应为对象,因此不存在引用的数组。

显示初始化数组元素

可以对数组的元素进行列表初始化(参见3.3.1节),此时允许忽略数组的维度。如果在声明时没有指明维度,编译器会根据初始值的数量计算并推测出来;相反,如果指明了维度,那么初始值的总数量不应该超出指定的大小。如果维度比提供的初始值数量大,则用提供的初始值初始化靠前的元素,剩下的元素被初始化成默认值(参见3.3.1节):

const unsigned sz = 3;
int ial[sz] = {0, 1, 2};
int a2[] = {0 ,1, 2};
int a3[5] = {0,1,2};			//等价于a3[] = {0,1,2,0,0}
string a4[3] = {"hi", "bye"};	//等价于a4[] = {"hi", "bye", ""}
int a5[2] = {0,1,2};			//错误:初始值过多

字符数组的特殊性

字符数组有一种额外的初始化形式,我们可以用字符串字面值(参见2.1.3 节)对此类数组初始化。当使用这种方式时,一定要注意字符串字面值的结尾处还有一个空字符,这个空字符也会像字符串的其他字符一样被拷贝到字符数组中去:

char a1[] = {'C', '+', '+'};			//列表初始化,没有空字符
char a2[] = {'C', '+', '+', '\0'};		//列表初始化,含有显示的空字符
char a3[] = "C++";						//自动添加表示字符串结束的空字符
const char a4[6] = "Daniel";			//错误:没有空间可存放空字符!

不允许拷贝和赋值

不能将数组的内容拷贝给其他数组作为其初始值,也不能用数组为其他数组赋值:

int a[] = {0,1,2};
int a2[] = a;		//错误:不允许使用一个数组初始化另一个数组
a2 = a;				//错误:不能把一个数组直接赋值给另一个数组

一些编译器支持数组的赋值,这就是所谓的编译器扩展(complier extension)。但一般来说,最好避免使用非标准特性,因为含有非标准特性的程序很可能在其他编译器上无法正常工作。

理解复杂的数组声明

和vector一样,数组能存放大多数类型的对象。例如,可以定义一个存放指针的数组。又因为数组本身就是对象,所以允许定义数组的指针及数组的引用。在这几种情况中,定义存放指针的数组比较简单和直接,但是定义数组的指针或数组的引用就稍微复杂一点了:

int *ptrs[10];					//ptrs是含有10个整型指针的数组
int &refs[10] = /* ? */;		//错误:不存在引用的数组
int (*Parray)[10] = &arr;		//Parray指向一个含有10个整数的数组
int (&arrRef)[10] = arr;		//arrRef引用一个含有10个整数的数组

默认情况下,类型修饰符从右向左依次绑定。对于 ptrs 来说,从右向左(参见2.3.3节)理解其含义比较简单:首先知道我们定义的是一个大小为 10 的数组,它的名字是ptrs,然后知道数组中存放的是指向int的指针。
但是对于Parray来说,从右向左理解就不太合理了。因为数组的维度是紧跟着被声明的名字的,所以就数组而言,由内向外阅读要比从右向左好多了。由内向外的顺序可帮助我们更好地理解 Parray 的含义:首先是圆括号括起来的部分,*Parray意味着Parray是个指针,接下来观察右边,可知道Parray是个指向大小为10的数组的指针,最后观察左边,知道数组中的元素是int。这样最终的含义就明白无误了,Parray 是个指针,它指向一个int数组,数组中包含10个元素。同理,(&arrRef)表示arrRef是一个引用,它引用的对象是一个大小为10的数组,数组中元素的类型是int。当然,对修饰符的数量并没有特殊限制:

int *(&arry)[10] = ptrs;	//arry是数组的引用,该数组含有10个指针

按照由内向外的顺序阅读上述语句,首先知道arry是一个引用,然后观察右边知道,arry引用的对象是一个大小为10的数组,最后观察左边知道,数组的元素类型是指向int的指针。这样,arry就是一个含有10个int型指针的数组的引用。

要想理解数组声明的含义,最好的方法是从数组的名字开始按照由内向外的顺序阅读。

3.5.2 访问数组元素

与标准库类型vector和string一样,数组的元素也能使用范围for语句或下标运算符来访问。数组的索引从0开始,以一个包含10个元素的数组为例,它的索引从0到9,而非从1到10。
在使用数组下标的时候,通常将其定义为size_t类型。size_t是一种机器相关的无符号类型,它被设计得足够大以便能表示内存中任意对象的大小。在cstddef头文件中定义了size_t类型,这个文件是C标准库stddef.h头文件的C++语言版本。

数组除了大小固定这一特点外,其他用法与vector基本类似。

检查下标的值

与vector和string一样,数组的下标是否在合理范围之内由程序员负责检查,所谓合理就是说下标应该大于等于0而且小于数组的大小。要想防止数组下标越界,除了小心谨慎注意细节以及对代码进行彻底的测试之外,没有其他好办法。对于一个程序来说,即使顺利通过编译并执行,也不能肯定它不包含此类致命的错误。

大多数常见的安全问题都源于缓冲区溢出错误。当数组或其他类似数据结构的下标越界并试图访问非法内存区域时,就会产生此类错误。

3.5.3 指针和数组

在C++语言中,指针和数组有非常紧密的联系。使用数组的时候编译器一般会把它转换成指针。
通常情况下,使用取地址符(参见2.3.2节)来获取指某个对象的指针,取地址符可以用于任何对象。数组的元素也是对象,对数组使用下标运算符得到该数组指定位置的元素。因此像其他对象一样,对数组的元素使用取地址符就能得到指向该元素的指针:

string nums[] = {"one", "two", "three"};	//数组的元素是string对象
string *p = &nums[0];						//p指向nums的第一个元素

然而,数组还有一个特性:在很多用到数组名字的地方,编译器都会自动地将其替换为一个指向数组首元素的指针

string *p2 = nums;	//等价于 p2 = &nums[0];

在大多数表达式中,使用数组类型的对象其实是使用一个指向该数组首元素的指针。

由上可知,在一些情况下数组的操作实际上是指针的操作,这一结论有很多隐含的意思。其中一层意思是当使用数组作为一个auto(参见2.5.2 节)变量的初始值时,推断得到的类型是指针而非数组:

int ia[] = {0,1,2,3,4,5,6,7,8,9};
auto ia2(ia);	//ia2是一个整型指针,指向ia的第一个元素,相当于auto ia2(&ia[0]);
ia2 = 42;		//错误:ia2是一个指针,不能用int值给指针赋值

但是当使用decltype(参见2.5.3节)关键字时上述转换不会发生,decltype(ia)返回的类型是由10个整数构成的数组:

decltype(ia) ia3 = {0,1,2,3,4,5,6,7,8,9};
ia3 = p;		//错误:不能用整型指针给数组赋值
ia3[4] = i;		//正确:把i的值赋给ia3的一个元素

指针也是迭代器

与2.3.2节介绍的内容相比,指向数组元素的指针拥有更多功能。vector和string的迭代器(参见3.4节)支持的运算,数组的指针全都支持。

标准库函数begin和end

尽管能计算得到尾后指针,但这种用法极易出错。为了让指针的使用更简单、更安全,C++11新标准引入了两个名为begin和end的函数。这两个函数与容器中的两个同名成员(参见3.4.1节)功能类似,不过数组毕竟不是类类型,因此这两个函数不是成员函数,它们定义在iterator头文件中。正确的使用形式是将数组作为它们的参数:

int ia[] = {0,1,2,3,4,5,6,7,8,9};
int *beg = begin(ia);		//指向ia首元素的指针
int *last = end(ia);		//指向arr尾元素的下一位置的指针

一个指针如果指向了某种内置类型数组的尾元素的“下一位置”,则其具备与vector的end函数返回的与迭代器类似的功能。特别要注意,尾后指针不能执行解引用和递增操作。

指针运算

指向数组元素的指针可以执行表3.6(第96页)和表3.7(第99页)列出的所有迭代器运算。

这里要注意,两个指针相减的结果的类型是一种名为 ptrdiff_t 的标准库类型,和 size_t 一样,ptrdiff_t 也是一种定义在 cstddef 头文件中的机器相关的类型。因为差值可能为负值,所以 ptrdiff_t 是一种带符号类型。

如果两个指针分别指向不相关的对象,则不能比较它们:

int i = 0 , sz = 42;
int *p = &i, *e = &sz;
//未定义的:p和e无关,因此比较毫无意义!
while(p < e)

尽管作用可能不是特别明显,但必须说明的是,上述指针运算同样适用于空指针(参见2.3.2节)和所指对象并非数组的指针。在后一种情况下,两个指针必须指向同一个对象或该对象的下一位置。如果 p 是空指针,允许给 p 加上或减去一个值为0的整型常量表达式(参见2.4.4节)。两个空指针也允许彼此相减,结果当然是0。

下标和指针

如前所述,在很多情况下使用数组的名字其实用的是一个指向数组首元素的指针。一个典型的例子是当对数组使用下标运算符时,编译器会自动执行上述转换操作。给定

int ia[] = {0,2,4,6,8};

此时,ia[0]是一个使用了数组名字的表达式,对数组执行下标运算其实是对指向数组元素的指针执行下标运算:

int i = ia[2];
//两种等价的方式
int *p = ia;
i = *(p + 2);

只要指针指向的是数组中的元素(或者数组中尾元素的下一位置),都可以执行下标运算:

int *p = &ia[2];		//p指向索引为2的元素
int j = p[1];			//p[1]等价于*(p+1),就是ia[3]
int k = p[-2];			//p[-2]是ia[0]

虽然标准库类型string和vector也能执行下标运算,但是数组与它们相比还是有所不同。标准库类型限定使用的下标必须是无符号类型,而内置的下标运算无此要求,上面的最后一个例子很好地说明了这一点。内置的下标运算符可以处理负值,当然,结果地址必须指向原来的指针所指同一数组中的元素(或是同一数组尾元素的下一位置)。

内置的下标运算符所用的索引值不是无符号类型,这一点与vector和string不一样。

3.5.4 C风格字符串

尽管C++支持C风格字符串,但在C++程序中最好还是不要使用它们。这是因为C风格字符串不仅使用起来不太方便,而且极易引发程序漏洞,是诸多安全问题的根本原因。

字符串字面值是一种通用结构的实例,这种结构即是C++由C继承而来的C风格字符串(C-style character string)。C风格字符串不是一种类型,而是为了表达和使用字符串而形成的一种约定俗成的写法。按此习惯书写的字符串存放在字符数组中并以空字符结束(null terminated)。以空字符结束的意思是在字符串最后一个字符后面跟着一个空字符(‘\0’)。一般利用指针来操作这些字符串。

C标准库String函数

表3.8列举了C语言标准库提供的一组函数,这些函数可用于操作C风格字符串,它们定义在cstring头文件中,cstring是C语言头文件string.h的C++版本。

image-20221124140256700

表3.8所列的函数不负责验证其字符串参数。

传入此类函数的指针必须指向以空字符作为结束的数组:

char ca[] = {'C', '+', '+'};		//不以空字符结束
cout << strlen(ca) << endl;			//严重错误:ca没有以空字符结束

此例中,ca 虽然也是一个字符数组但它不是以空字符作为结束的,因此上述程序将产生未定义的结果。strlen 函数将有可能沿着 ca 在内存中的位置不断向前寻找,直到遇到空字符才停下来。

比较字符串

比较两个C风格字符串的方法和之前学习过的比较标准库string对象的方法大相径庭。比较标准库string对象的时候,用的是普通的关系运算符和相等性运算符:

string s1 = "A string example";
string s2 = "A different string";
if(s1 < s2) //false:s2小于s1

如果把这些运算符用在两个C风格字符串上,实际比较的将是指针而非字符串本身:

const char ca1[] = "A string example";
const char ca2[] = "A different string";
if (ca1 < ca2) //未定义的:试图比较两个无关地址

上面的if条件实际上比较的是两个const char*的值。这两个指针指向的并非同一对象,所以将得到未定义的结果。

要想比较两个C风格字符串需要调用strcmp函数,此时比较的就不再是指针了。如果两个字符串相等,strcmp返回0;如果前面的字符串较大,返回正值;如果后面的字符串较大,返回负值:

if(strcmp(ca1, ca2) < 0)	//和两个string对象的比较s1 < s2效果一样

目标字符串的大小由调用者指定

连接或拷贝C风格字符串也与标准库string对象的同类操作差别很大。例如,要想把刚刚定义的那两个string对象s1和s2连接起来,可以直接写成下面的形式:

//将largeStr初始化成s1、一个空格和s2的连接
string largeStr = s1 + " " + s2;

对于C风格的字符串,则应该使用strcat函数和strcpy函数。要想使用这两个函数,还必须提供一个用于存放结果字符串的数组,该数组必须足够大以便容纳下结果字符串及末尾的空字符。下面的代码虽然很常见,但是充满了安全风险,极易引发严重错误:

//如果我们计算错了largeStr的大小将引发严重错误
strcpy(largeStr, ca1);		//将ca1拷贝给largeStr
strcat(largeStr, " ");		//在largeStr的末尾加上一个空格
strcat(largeStr, ca2);		//把ca2连接到largeStr后面

一个潜在的问题是,我们在估算 largeStr 所需的空间时不容易估准,而且 largeStr所存的内容一旦改变,就必须重新检查其空间是否足够。不幸的是,这样的代码到处都是,程序员根本没法照顾周全。这类代码充满了风险而且经常导致严重的安全泄漏。

对大多数应用来说,使用标准库string要比使用C风格字符串更安全、更高效。

3.5.5 与旧代码的接口

很多C++程序在标准库出现之前就已经写成了,它们肯定没用到string和vector类型。而且,有一些C++程序实际上是与C语言或其他语言的接口程序,当然也无法使用C++标准库。因此,现代的C++程序不得不与那些充满了数组和/或C风格字符串的代码衔接,为了使这一工作简单易行,C++专门提供了一组功能。

混用string对象和C风格字符串

3.2.1节介绍过允许使用字符串字面值来初始化string对象:

string s("Hello World");

更一般的情况是,任何出现字符串字面值的地方都可以用以空字符结束的字符数组来替代:

  • 允许使用以空字符结束的字符数组来初始化string对象或为string对象赋值。
  • 在string对象的加法运算中允许使用以空字符结束的字符数组作为其中一个运算对象(不能两个运算对象都是);在string对象的复合赋值运算中允许使用以空字符结束的字符数组作为右侧的运算对象。

上述性质反过来就不成立了:如果程序的某处需要一个C风格字符串,无法直接用string对象来代替它。例如,不能用string对象直接初始化指向字符的指针。为了完成该功能,string专门提供了一个名为c_str的成员函数:

char *str = s;	//错误:不能用string对象初始化char*
const char *str = s.c_str();	//正确

顾名思义,c_str 函数的返回值是一个 C 风格的字符串。也就是说,函数的返回结果是一个指针,该指针指向一个以空字符结束的字符数组,而这个数组所存的数据恰好与那个string对象的一样。结果指针的类型是 const char*,从而确保我们不会改变字符数组的内容。
我们无法保证 c_str 函数返回的数组一直有效,事实上,如果后续的操作改变了s的值就可能让之前返回的数组失去效用。

如果执行完c_str()函数后程序想一直都能使用其返回的数组,最好将该数组重新拷贝一份。

使用数组初始化vector对象

3.5.1介绍过不允许使用一个数组为另一个内置类型的数组赋初值,也不允许使用vector对象初始化数组。相反的,允许使用数组来初始化vector对象。要实现这一目的,只需指明要拷贝区域的首元素地址和尾后地址就可以了:

int int_arr[] = {0,1,2,3,4,5};
vector<int> ivec(begin(int_arr), end(int_arr));

在上述代码中,用于创建ivec的两个指针实际上指明了用来初始化的值在数组int_arr中的位置,其中第二个指针应指向待拷贝区域尾元素的下一位置。此例中,使用标准库函数begin和end(参见3.5.3节)来分别计算int_arr的首指针和尾后指针。在最终的结果中,ivec将包含6个元素,它们的次序和值都与数组 int_arr 完全一样。

用于初始化vector对象的值也可能仅是数组的一部分:

vector<int> subVec(int_arr + 1, int_arr + 4);

建议:尽量使用标准库类型而非数组

使用指针和数组很容易出错。一部分原因是概念上的问题:指针常用于底层操作,因此容易引发一些与烦琐细节有关的错误。其他问题则源于语法错误,特别是声明指针时的语法错误。
现代的 C++程序应当尽量使用 vector 和迭代器,避免使用内置数组和指针;应该尽量使用string,避免使用C风格的基于数组的字符串。

3.6 多维数组

严格来说,C++语言中没有多维数组,通常所说的多维数组其实是数组的数组。谨记这一点,对今后理解和使用多维数组大有益处。
当一个数组的元素仍然是数组时,通常使用两个维度来定义它:一个维度表示数组本身大小,另外一个维度表示其元素(也是数组)大小:

int ia[3][4];	//大小为3的数组,每个元素是含有4个整数的数组
//大小为10的数组,每个元素都是大小为20的数组,这些数组的元素是含有30个整数的数组
int arr[10][20][30] = {0};	//将所有元素初始化为0

对于二维数组来说,常把第一个维度称作行,第二个维度称作列。

多维数组的初始化

允许使用花括号括起来的一组值初始化多维数组,这点和普通的数组一样。下面的初始化形式中,多维数组的每一行分别用花括号括了起来:

int ia[3][4] = {
    {0,1,2,3},
    {4,5,6,7},
    {8,9,10,11}
};

其中内层嵌套着的花括号并非必需的,例如下面的初始化语句,形式上更为简洁,完成的功能和上面这段代码完全一样:

int ia[3][4] = {0,1,2,3,4,5,6,7,8,9,10,11};

类似于一维数组,在初始化多维数组时也并非所有元素的值都必须包含在初始化列表之内。如果仅仅想初始化每一行的第一个元素,通过如下的语句即可:

int ia[3][4] = {{0},{4},{8}};

其他未列出的元素执行默认值初始化,这个过程和一维数组(参见3.5.1节)一样。在这种情况下如果再省略掉内层的花括号,结果就大不一样了。

int ix[3][4] = {0,3,6,9};

它初始化的是第一行的4个元素,其他元素被初始化为0。

多维数组的下标引用

可以使用下标运算符来访问多维数组的元素,此时数组的每个维度对应一个下标运算符。

如果表达式含有的下标运算符数量和数组的维度一样多,该表达式的结果将是给定类型的元素;反之,如果表达式含有的下标运算符数量比数组的维度小,则表达式的结果将是给定索引处的一个内层数组:

ia[2][3] = arr[0][0][0];
int (&row)[4] = ia[1];	//把row绑定到ia的第二个4元素数组上

再举一个例子,程序中经常会用到两层嵌套的 for 循环来处理多维数组的元素:

constexpr size_t rowCnt = 3, colCnt = 4;
int ia[rowCnt][colCnt];
for(size_t i = 0; i != rowCnt; ++i)
{
	for(size_t j = 0; j != colCnt; ++j)
    {
        ia[i][j] = i * colCnt + j;
    }
}

使用范围for语句处理多维数组

由于在C++11新标准中新增了范围for语句,所以前一个程序可以简化为如下形式:

size_t cnt = 0;
for(auto &row : ia)
    for(auto &col : row){
        col = cnt;
        ++cnt;
    }

在上面的例子中,因为要改变数组元素的值,所以我们选用引用类型作为循环控制变量,但其实还有一个深层次的原因促使我们这么做。举一个例子,考虑如下的循环:

for (const auto &row : ia)
	for(auto col :row)
        cout << col << endl;

这个循环中并没有任何写操作,可是我们还是将外层循环的控制变量声明成了引用类型,这是为了避免数组被自动转成指针 (参见3.5.3)。假设不用引用类型,则循环如下述形式:

for(auto row : ia)
    for(auto col : row)

程序将无法通过编译。这是因为,像之前一样第一个循环遍历ia的所有元素,注意这些元素实际上是大小为4的数组。因为 row 不是引用类型,所以编译器初始化 row 时会自动将这些数组形式的元素(和其他类型的数组一样) 转换成指向该数组内首元素的指针。这样得到的row 的类型就是int*,显然内层的循环就不合法了,编译器将试图在一个int*内遍历,这显然和程序的初衷相去甚远。

要使用范围for语句处理多维数组,除了最内层的循环外,其他所有循环的控制变量都应该是引用类型。

指针的多维数组

当程序使用多维数组的名字时,也会自动地将其转换成指向数组首元素的指针。

因为多维数组实际上是数组的数组,所以由多维数组名转换得来的指针实际上是指向第一个内层数组的指针:

int ia[3][4];
int (*p)[4] = ia;
p = &ia[2];

在上述声明中,圆括号必不可少:

int *ip[4];			//整型指针的数组
int (*ip)[4];		//指向含有4个整数的数组的指针

随着C++11新标准的提出,通过使用auto或者decltype(参见2.5.2节)就能尽可能地避免在数组前面加上一个指针类型了:

//输出ia中每个元素的值,每个内层数组各占一行
//p指向含有4个整数的数组
for(auto p = ia; p != ia + 3; ++p)
{
    //q指向4个整数数组的首元素,也就是说,q指向一个整数
    for(auto q = *p; q != *p + 4; ++q)
    	cout << *q << ' ';
    cout << endl;
}

使用标准库函数begin和end(参见3.5.3节)也能实现同样的功能,而且看起来更简洁一些:

//p指向ia的第一个数组
for(auto p = begin(ia); p != end(ia); ++p){
    //q指向内层数组的首元素
    for(auto q = begin(*p); q != end(*p); ++q)
        cout << *q << ' ';	//输出q所指的整数值
    cout << endl;
}

在这一版本的程序中,循环终止条件由 end 函数负责判断。虽然我们也能推断出p的类型是指向含有4个整数的数组的指针,q的类型是指向整数的指针,但是使用auto关键字我们就不必再烦心这些类型到底是什么了。

类型别名简化多维数组的指针

读、写和理解一个指向多维数组的指针是一个让人不胜其烦的工作,使用类型别名(参见2.5.1节)能让这项工作变得简单一点儿,例如:

using int_array = int[4];
typedef int int_array[4];

for(int_array *p = ia; p != ia + 3; ++p){
    for(int *q = *p; q != *p + 4; ++q)
        cout << *q << ' ';
    cout << endl;
}

小结

string和vector是两种最重要的标准库类型。string对象是一个可变长的字符序列,vector对象是一组同类型对象的容器。

迭代器允许对容器中的对象进行间接访问,对于string对象和vector对象来说,可以通过迭代器访问元素或者在元素间移动。

数组和指向数组元素的指针在一个较低的层次上实现了与标准库类型string和vector类似的功能。一般来说,应该优先选用标准库提供的类型,之后再考虑C++语言内置的低层的替代品数组或指针。

原文地址:http://www.cnblogs.com/LingFengJ/p/16922709.html

1. 本站所有资源来源于用户上传和网络,如有侵权请邮件联系站长! 2. 分享目的仅供大家学习和交流,请务用于商业用途! 3. 如果你也有好源码或者教程,可以到用户中心发布,分享有积分奖励和额外收入! 4. 本站提供的源码、模板、插件等等其他资源,都不包含技术服务请大家谅解! 5. 如有链接无法下载、失效或广告,请联系管理员处理! 6. 本站资源售价只是赞助,收取费用仅维持本站的日常运营所需! 7. 如遇到加密压缩包,默认解压密码为"gltf",如遇到无法解压的请联系管理员! 8. 因为资源和程序源码均为可复制品,所以不支持任何理由的退款兑现,请斟酌后支付下载 声明:如果标题没有注明"已测试"或者"测试可用"等字样的资源源码均未经过站长测试.特别注意没有标注的源码不保证任何可用性