第4章 管理数组和字符串
在前几章中,您声明存储单个 int、char 或字符串的变量。然而,您可能想声明一组对象,如 20个int变量或一组Cat对象。
在本章中,您将学习:
• 什么是数组以及如何声明和使用它们;
• 什么是字符串以及如何使用字符数组来表示字符串;
• std::string简介。
4.1 什么是数组
array的字典定义与数组的概念很接近。韦氏字典指出,array是一组元素,它们形成一个整体,如一组太阳能电池板。
数组具有如下特点:
• 数组是一系列元素;
• 数组中所有元素的类型都相同;
• 这组元素形成一个完整的集合。
在C++中,数组让您能够按顺序将一系列相同类型的数据存储到内存中。
假设您要编写一个程序,它让用户输入5个整数并显示出来。为此,一种方式是声明5个独立的int变量,并使用它们来存储和显示值。声明类似于下面这样:
采用这种方式时,如果用户希望这个程序存储并显示500个整数,您将需要声明500个int变量。只要有足够的耐心和时间,这还是可行的。然而,如果用户要求存储并显示500000个整数,您该怎么办呢?
您应采取正确而聪明的方式,声明一个包含5个int元素的数组,并将每个元素都初始化为零,如下所示:
这样,当您被要求支持500000个整数时,便可以快速扩大数组,如下所示:
要定义一个包含5个字符的数组,可以这样做:
这样的数组被称为静态数组,因为在编译阶段,它们包含的元素数以及占用的内存量都是固定的。
在前一小节,您声明了一个名为MyNumbers的数组,它包含5个类型为int的元素(即整数),这些元素都被初始化为0。在C++中,数组声明遵循如下简单的语法:
在声明数组时,还可像下面这样分别初始化每个元素,这里将 5 个元素分别初始化为不同的整数:
可将数组的所有元素都初始化为相同的值,如下所示:
也可只初始化部分元素,如下所示:
可将数组长度(即数组包含的元素数)定义为常量,并在数组定义中使用该常量:
需要在多个地方访问并使用数组的长度(如遍历数组中的元素)时,这很有用。这样就无需在每个地方修改数组的长度,而只需修改 const int声明中的初始值。
如果您只初始化数组的部分元素,有些编译器可能将您忽略的元素初始化为零。
如果知道数组中每个元素的初始值,可不指定数组包含的元素数:
上述代码创建一个数组,它包含3个int元素,这些元素的初始值分别为2011、2052和-525。
前面声明的所有数组都是静态数组,因为它们的长度在编译阶段就已确定。这种数组不能存储更多的数据;同时,即便有部分元素未被使用,它们占据的内存也不会减少。
想想书架上放在一起的图书吧,这就是一个一维数组,因为它只沿一个维度延伸,这个维度就是元素数。每本书都是一个数组元素,而书架就像为存储这些图书而预留的内存,如图4.1所示。
这里给图书从零开始编号,这并非错误。后面您将看到,在C++中,数组索引从零而不是1开始。类似于书架上的5本图书,包含5个整数的数组MyNubers类似于图4.2。
注意到这个数组占用的内存空间包含 5 块,每块的大小都相同。块大小取决于数组存储的数据类型,这里是int。您可能还记得,第3章研究了int变量的长度,因此编译器为数组MyNumbers预留的内存量为 sizeof(int)* 5。一般而言,编译器为数组预留的内存量为(单位为字节):
图4.1 书架上的图书:一维数组
图4.2 内存中包含5个整数的数组MyNumbers
要访问数组中的元素,可使用从零开始的索引。这些索引之所以被称为从零开始的,是因为数组中第一个元素的索引为零。因此,存储在数组MyNumbers中的第一个整数值为MyNumbers[0],第二个为MyNumbers[1],依此类推。第5个元素为MyNumbers[4],换句话说,数组中最后一个元素的索引总是比数组长度少1。
被要求访问索引为N的元素时,编译器以第一个元素(索引为零)的内存地址为起点,加上偏移量N*sizeof(element),即向前跳N个元素,到达包含第N+1个元素的地址。C++编译器不会检查索引是否在数组的范围内,您可从只包含10个元素的数组中取回索引为1001的元素,但这样做将给程序带来安全和稳定性方面的风险。访问数组时,确保不超越其边界是程序员的职责。
访问数组时,如果超越其边界,结果将是无法预料的。在很多情况下,这将导致程序崩溃。应不惜一切代价避免访问数组时超越其边界。
程序清单4.1演示了如何声明一个int数组、初始化其元素并将元素的值显示到屏幕上。
程序清单4.1 声明一个int数组并访问其元素
输出:
分析:
第6行声明了一个包含5个int元素的数组,并给每个元素指定了初始值。接下来的几行代码使用cout、数组变量MyNumbers和合适的索引显示这些整数。
用于访问数组元素的索引从零开始,为帮助读者熟悉这个概念,从程序清单 4.1 开始,给代码行编号时都从零开始。
在前一个程序清单中,并未将用户定义的数据输入到数组中。给数组元素赋值的语法与给int变量赋值的语法很像。
例如,将2011赋给int变量的代码类似于下面这样:
程序清单 4.2 演示了如何使用常量指定数组的长度,还演示了如何在程序执行期间给数组元素赋值。
程序清单4.2 给数组元素赋值
输出:
分析:
第8行声明数组时,使用了int常量ARRAY_LENGTH,该常量之前被初始化为5。这是一个静态数组,其长度在编译期间是固定的。编译器将ARRAY_LENGTH替换为5,认为MyArray是一个包含5个元素的int数组。第10~12行询问用户要设置哪个数组元素,并将索引存储到int变量ElementIndex中。第15行使用这个int变量来修改数组的内容。输出表明,用户要修改索引为2的元素,而实际修改的是第3个元素,因为索引从零开始,您必须习惯这一点。
在数组包含5个int元素时,很多C++新手将第5个值赋给索引为5的元素。这超出了数组的边界,因为编译后的代码将试图访问数组的第6个元素,这不在定义的范围内。这种错误被称为篱笆柱(fence-post)错误。之所以叫这个名字,是因为建造篱笆时,需要的篱笆柱数总是比部分数多1。
程序清单4.2遗漏了一些必不可少的代码:没有检查用户输入的索引是否在数组的边界内。实际上,该程序应检查ElementIndex是否为0~4,如果不是,则拒绝修改数组。由于缺少这种检查,用户将被允许输入超越数组边界的值。在最糟糕的情况下,这将导致应用程序崩溃。执行检查将在第6章介绍。
使用循环遍历数组元素
按顺序处理数组及其元素时,应使用循环进行遍历。要了解如何使用 for 循环高效地插入或访问数组元素,请参阅程序清单6.10。
4.2 多维数组
到目前为止,读者看到的数组都类似于书架上的图书,书架越长,可放的书越多,书架越短,可放的书越少。也就是说,长度是决定书架容量的唯一维度,因此是一维的。如果要使用数组模拟图4.3所示的太阳能电池板,该如何办呢?不同于书架,太阳能电池板沿两个维度延伸:长度和宽度。
图4.3 屋顶的一组太阳能电池板
正如您在图 4.3 中看到的,6 块太阳能电池板以两维方式排列,组成两行、三列。从某种意义上说,可将这种布局视为一个包含两个元素的数组,其中每个元素本身是一个包含三块电池板的数组,换句话说,这是一个由数组组成的数组。在 C++中,可模拟二维数组,但并不限于二维数组,根据您的需求和应用程序的性质,还可在内存中模拟多维数组。
在C++中,要声明多维数组,可指定每维包含的元素数。因此,要声明一个int二维数组,以表示图4.3所示的电池板,可以像下面这样做:
在图4.3中,给每块电池板指定了一个ID,这6块电池板的ID为0~5。如果要以这样的方式初始化相应的int数组,可以像下面这样做:
正如您看到的,初始化语法与初始化两个一维数组的语法类似。这是一个包含两行的二维数组,而不是两个数组。如果该数组包含三行、三列,则初始化语法将类似于下面这样:
虽然C++让您能够模拟多维数组,但存储数组的内存是一维的。编译器将多维数组映射到内存,而内存只沿一个方向延伸。如果您愿意,也可像下面这样初始化数组SolarPanelIDs,其效果相同:
然而,前面的初始化方法更佳,因为它让您更容易将多维数组想象为数组的数组。
可将多维数组视为由数组组成的数组。因此,对于包含三行、三列的二维int数组,可将其视为一个包含3个元素的数组,其中每个元素都是一个包含3个int元素的数组。
在需要访问该数组中的int时,可使用第一个下标指出该int所属的数组,并使用第二个下标指出该int。请看下面的数组:
初始化方式让您能够将其视为三个数组,其中每个数组包含三个 int。其中,值为 206 的元素的位置为[0][1],值为456的元素的位置为[2][2]。程序清单4.3演示了如何访问该数组中的int元素。
程序清单4.3 访问多维数组中的元素
输出:
分析:
注意到访问元素时将每行视为一个数组,从第1行开始(其索引为0),到第3行结束(其索引为2)。由于每行都是一个数组,因此第10行访问第1行的第3个元素.
在程序清单 4.3 中,随着数组包含的元素或维度数的增加,代码长度将激增。在专业开发环境中,这种代码实际上不可行。
程序清单6.14演示了一种更高效的多维数组访问方式,它使用嵌套for循环来访问多维数组中的所有元素。使用 for 循环时,代码更短且不容易出错;另外,程序长度也不受数组包含的元素数的影响。
4.3 动态数组
假设要在应用程序中存储医院的病历,程序员将无法知道需要处理的病历数上限。就小医院而言,为稳妥起见,程序员可对上限做合理的假设。在这种情况下,程序员将预留大量的内存,进而降低系统的性能。
为减少占用的内存,可不使用前面介绍的静态数组,而使用动态数组,并在运行阶段根据需要增大动态数组。C++提供了 std::vector,这是一种方便且易于使用的动态数组,如程序清单 4.4所示。
程序清单4.4 创建int动态数组并动态地增大其容量
输出:
分析:
由于还未介绍矢量和模板,如果不明白程序清单4.4中的语法,也不用担心。请尝试将输出与代码关联起来。从输出可知,数组的初始长度为3,这与第7行的矢量声明一致。在知道这一点的情况下,第 15 行仍让用户输入第 4 个数字,而第 18 行使用 push_back()将这个数字压入到矢量中。这个矢量动态地调整其长度,以存储更多数据。输出证明了这一点:矢量的长度变成了4。访问矢量中的数据时,语法与访问静态数组类似。第 22 行访问最后一个元素,其位置是在运行阶段计算得到的。索引从零开始,而 size()返回矢量包含的元素数,因此最后一个元素的索引为size()-1。
要使用动态数组类std::vector,需要包含头文件vector,如程序清单4.4的第1行所示:
矢量将在第17章更详细地介绍。
4.4 C风格字符串
C风格字符串是一种特殊的字符数组。您在前面编写代码时使用过字符串字面量,它们就是C风格字符串:
这与下面使用数组的方式等价:
请注意,该数组的最后一个字符为空字符‘\0’。这也被称为字符串结束字符,因为它告诉编译器,字符串到此结束。这种C风格字符串是特殊的字符数组,因为总是在最后一个字符后加上空字符‘\0’。您在代码中使用字符串字面量时,编译器将负责在它后面添加‘\0’。
在数组中间插入‘\0’并不会改变数组的长度,而只会导致将该数组作为输入的字符串处理将到这个位置结束,程序清单4.5演示了这一点。
‘\0’看起来像两个字符。使用键盘输入它时,确实需要输入两个字符,但反斜杆是编译器能够理解的特殊转义编码,\0表示空,即它让编译器插入空字符或零。
您不能将其写做‘0’,因为它表示字符0,其ASCII编码为48。
要获悉字符0和其他字符的ASCII编码,请参阅附录E。
程序清单4.5 分析C风格字符串中的终止空字符
输出:
分析:
第 10行将“Hello World”中的空格替换为终止空字符。这样,该数组包含两个终止空字符,但只有第一个发挥了作用。将空格替换为空字符后,显示时字符串被截短为Hello。第7和12行的sizeof()的输出表明,数组的长度没变,虽然显示的字符串发生了很大变化。
在程序清单4.5中,如果在第5行声明并初始化字符数组时忘记添加‘\0’,则打印该数组时,Hello World后面将出现垃圾字符,这是因为 std::cout只有遇到空字符后才会停止打印,即便这将跨越数组的边界。
在有些情况下,这种错误可能导致程序崩溃,进而影响系统的稳定性。
C风格字符串充斥着危险,程序清单4.6演示了这一点。
程序清单4.6 分析C风格字符串中的终止空字符
输出:
分析:
输出说明了这种危险。该程序请求用户输入数据时不要超过20个字符,因为第7行声明了一个字符数组,用于存储用户输入,其长度是固定的(静态的),为 21 个字符。由于最后一个字符必须是终止空字符‘\0’,因此该数组最多可存储20个字符。第10行使用了strlen来计算该字符串的长度。strlen遍历该字符数组,直到遇到表示字符串末尾的终止空字符,并计算遍历的字符数。cin在用户输入的末尾插入终止空字符。strlen的这种行为非常危险,因为如果用户输入的文本长度超过了指定的上限, strlen将跨越字符数组的边界。程序清单6.2演示了如何实现相关的检查,以免写入数组时跨越其边界。
4.5 C++字符串:使用std::string
无论是处理文本输入,还是执行拼接等字符串操作,使用C++标准字符串都是效率最高的方式。
使用C语言编写的应用程序经常使用strcpy 等字符串复制函数、strcat等拼接函数,还经常使用strlen来确定字符串的长度;具有较强C语言背景的C++程序员编写的应用程序亦如此。
这些C风格字符串作为输入的函数非常危险,因为它们会寻找终止空字符,如果程序员没有在字符数组末尾添加空字符,这些函数将跨越字符数组的边界。
C++提供了 std::string,这是一种功能强大而安全的字符串操作方式,如程序清单 4.7 所示。不同于字符数组(C风格字符串实现),std::string是动态的,在需要存储更多数据时其容量将增大。
程序清单4.7 使用std::string初始化字符串、存储用户输入、复制和拼接字符串以及确定字符串的长度
输出:
分析:
请尝试将输出与代码关联起来,现在暂时不要管其中的新语法。该程序首先显示一个字符串,该字符串在第 7行被初始化为“Hello std::string”。接下来,它让用户输入两行文本,并将它们分别存储在变量Firstline和Secline中,如第12和16行所示。字符串拼接非常简单,看起来很像算术加法运算,如第19行所示。这里还在两行之间添加了一个空格。复制也很简单,只需赋值即可,如第24行所示。第27行对字符串调用length(),以确定其长度。
要使用C++字符串,需要包含头文件string:
如程序清单4.7的第1行所示。
要详细了解std::string的各种函数,请参阅第16章。由于还您未学习类和模板,请跳过该章不熟悉的部分,重点理解示例程序的要点。
4.6 总结
本章介绍了数组的基本知识:数组是什么及其用途。您学习了如何声明和初始化数组以及如何读写数组元素。您了解到,避免超越数组边界至关重要,这被称为缓冲区溢出。将输入用作索引前应对其进行检查,这有助于避免跨越数组边界。
动态数组让程序员无需在编译阶段考虑其最大长度,使用动态数组可更好地管理内存,以免分配过多的内存,而又不使用它们。
您还了解到,C 风格字符串是特殊的 char 数组,用终止空字符‘\0’标识末尾。更重要的是,您了解到,C++提供了更佳的选择——std::string,它包含一些方便的函数,让您能够判断字符串的长度、拼接字符串等。
4.7 问与答
问:为何要不怕麻烦,去初始化静态数组的元素?
答:数组不同于其他类型的变量,除非进行初始化,否则它将包含无法预测的值,因为内存保留最后一次操作时的内容。通过初始化数组,可确保内存包含确定的值。
问:需要基于前一个问题所说的原因初始化动态数组的元素吗?
答:实际上,不需要。动态数组相当聪明,无需将其元素初始化为默认值,除非应用程序要求数组包含特定的初始值。
问:在可以选择的情况下,您会使用需要以空字符结尾的C风格字符串吗?
答:除非有人拿枪指着您的头。C++ std::string更安全,并提供了很多功能,任何优秀的程序员都应避免使用C风格字符串。
问:计算字符串长度时,包括末尾的空字符吗?
答:不包括。字符串“Hello World”的长度为 11,这包括其中的空格,但不包括末尾的空字符。
问:如果要用char数组标识C风格字符串,应将数组声明为多长?
答:这是 C 风格字符串最复杂的地方之一。数组的长度应比它可能包含的最长字符串长 1,以便在末尾包含空字符。如果 char数组可能存储的最长字符串为“Hello World”,则应将该数组的长度声明为12(11+1)。
4.8 作业
作业包括测验和练习,前者帮助读者加深对所学知识的理解,后者提供了使用新学知识的机会。请尽量先完成测验和练习题,然后再对照附录 D 中的答案。在继续学习下一章之前,请务必弄懂这些答案。
1.对于程序清单4.1中的数组MyNumbers,第一个元素和最后一个元素的索引分别是多少?
2.如果需要让用户输入字符串,该使用C风格字符串吗?
3.在编译器看来,‘\0’表示多少个字符?
4.如果忘记在C风格字符串末尾添加终止空字符,使用它的结果将如何?
5.根据程序清单4.4中矢量的声明,尝试声明一个包含char元素的动态数组。
1.声明表示国际象棋棋盘的数组;该数组的类型应为枚举,该枚举定义了棋盘方格中的棋子。
2.查错:下面的代码段有什么错误?
3.查错:下面的代码段有什么错误?