3.5 数组
第1章没有提到的一种特殊的变量声明就是数组声明。利用数组声明,可在单个变量中存储同一种类型的多个数据项,而且可利用索引来单独访问这些数据项。C#的数组索引从零开始,所以我们说C#数组基于零。
初学者主题:数组
可用数组变量声明同类型多个数据项的集合。每一项都用名为索引的整数值进行唯一性标识。C#数组的第一个数据项使用索引0访问。程序员需要小心确保访问数组时的索引值小于数组的数据项总数。由于C#数组基于零,因此数组中最后一个数据项的索引值为数据项总数量减1。在C# 8.0中,有一个“index from end”操作符。例如,索引值^1将访问数组中最后一个元素。
初学者可将索引想象成偏移量。第一项距数组开头的偏移量是0,第二项偏移量是1,以此类推。
数组是几乎所有编程语言的基本组成部分,所有开发者都应学习。虽然C#编程经常用到数组,初学者也确实应该掌握,但大多数程序现在都用泛型集合类型而非数组来存储数据集合。如果只是为了熟悉数组的实例化和赋值,可略读下一节。表3.2列出了要注意的重点。泛型集合将在第15章详细讲述。
此外,3.5.5节还会讲到数组的一些特点。
表3.2 数组的重点
3.5.1 数组的声明
C#用方括号声明数组变量。首先指定数组元素的类型,后跟一对方括号,再输入变量名。代码清单3.7声明字符串数组变量languages。
代码清单3.7 声明数组
显然,数组声明的第一部分标识了数组中存储的元素的类型。作为声明的一部分,方括号指定了数组的秩(rank),或者说维数。本例声明一维数组。类型和维数构成了languages变量的数据类型。
语言对比:C++和Java——数组声明
在C#中,作为数组声明一部分的方括号紧跟在数据类型之后,而不是在变量声明之后。这样所有类型信息都在一起,而不是像C++和Java那样分散于标识符前后,Java也允许方括号出现在数据类型或变量名之后。
代码清单3.7定义的是一维数组。方括号中的逗号用于定义额外的维。例如,代码清单3.8为井字棋(tic-tac-toe)棋盘定义了一个二维数组。
代码清单3.8 声明二维数组
代码清单3.8定义了一个二维数组。第一维对应从左到右的单元格,第二维对应从上到下的单元格。可用更多逗号定义更多维,数组总维数等于逗号数加1。注意,某一维上的元素数量不是变量声明的一部分。这是在创建(实例化)数组并为每个元素分配内存空间时指定的。
3.5.2 数组实例化和赋值
声明数组后,可在一对大括号中使用以逗号分隔的数据项列表来填充它的值。代码清单3.9声明一个字符串数组,将一对大括号中的9种语言名称赋给它。
代码清单3.9 声明数组的同时赋值
列表第一项成为数组的第一个元素,第二项成为第二个,以此类推。我们用大括号定义数组字面值。
只有在同一条语句中声明并赋值,才能使用代码清单3.9的赋值语法。声明后在其他地方赋值则需使用new关键字,如代码清单3.10所示。
代码清单3.10 声明数组后再赋值
自C# 3.0起不必在new后指定数组类型(string)。编译器能根据初始化列表中的数据类型推断数组类型。但方括号仍不可缺少。
C#支持将new关键字作为声明语句的一部分,所以可以像代码清单3.11那样在声明时赋值。
代码清单3.11 声明数组时用new赋值
new关键字的作用是指示“运行时”为数据类型分配内存,即指示它实例化数据类型(本例是数组)。
数组赋值时只要使用了new关键字,就可在方括号内指定数组大小,如代码清单3.12所示。
代码清单3.12 声明数组时用new关键字赋值并指定数组大小
指定的数组大小必须和大括号中的元素数量匹配。另外,也可分配数组但不提供初始值,如代码清单3.13所示。
代码清单3.13 分配数组但不提供初始值
分配数组但不指定初始值,“运行时”会将每个数组元素初始化为它们的默认值,如下所示:
·引用类型,不论是否为可空(比如string或者string?),都初始化为null。
·可空的值类型初始化为null。
·不可空的值类型初始化为0。
·bool初始化为false。
·char初始化为\0。
非基元值类型以递归方式初始化,每个字段都被初始化为默认值。所以,其实并不需要在使用数组前初始化它的所有元素。
由于数组大小不需要作为变量声明的一部分,所以可以在运行时指定数组大小。例如,代码清单3.14根据在Console.ReadLine()调用中用户指定的大小创建数组。
代码清单3.14 在运行时确定数组大小
C#以类似的方式处理多维数组。每一维的大小以逗号分隔。代码清单3.15初始化一个没有开始走棋的井字棋棋盘。
代码清单3.15 声明二维数组
还可以像代码清单3.16那样,将井字棋棋盘初始化成特定的棋子布局。
代码清单3.16 初始化二维整数数组
数组包含三个int[]类型的元素,每个元素大小一样(本例中凑巧也是3)。注意每个int[]元素的大小必须完全一样。也就是说,像代码清单3.17那样的声明是无效的。
代码清单3.17 大小不一致的多维数组会造成错误
表示棋盘并不需要在每个位置都使用整数。另一个办法是为每个玩家都单独提供虚拟棋盘,每个棋盘都包含一个bool来指出玩家选择的位置。代码清单3.18对应于一个三维棋盘。
代码清单3.18 初始化三维数组
本例初始化棋盘并显式指定每一维的大小。new表达式除了指定大小,还提供了数组的字面值。bool[,,]类型的字面值被分解成两个bool[,]类型的二维数组(大小均为3×3)。每个二维数组都由三个bool数组(大小为3)构成。
如前所述,多维数组(这种普通多维数组也称为“矩形数组”)每一维的大小必须一致。还可定义交错数组(jagged array),也就是由数组构成的数组。交错数组的语法稍微有别于多维数组,而且交错数组不需要具有一致的大小。所以,可以像代码清单3.19那样初始化交错数组。
代码清单3.19 初始化交错数组
交错数组不用逗号标识新维。相反,交错数组定义由数组构成的数组。代码清单3.19在int[]后添加[],表明数组元素是int[]类型的数组。
注意,交错数组要求为内部的每个数组都创建数组实例。这个例子使用new实例化交错数组的内部元素。遗失这个实例化部分会造成编译时错误。
3.5.3 数组的使用
使用方括号(称为数组访问符)访问数组元素。为获取第一个元素,要指定0作为索引。代码清单3.20将languages变量中的第5个元素(索引4)的值存储到变量language中。
代码清单3.20 声明并访问数组
从C# 8.0开始,你可以使用相对于末尾元素的索引来访问数组,该操作需要用到反向索引操作符(index from end operator),有时也称作^操作符或者“帽子操作符”。以代码清单3.20中的数组languages为例。索引^1访问的是数组最后一个元素,索引^9访问的是第一个元素,而索引^3则访问倒数第三个元素,即“Python”。
既然索引^1代表数组中最后一个元素,那么索引^0则代表了最后一个元素的下一个位置。类似地,当不带反向操作符的正向索引值等于数组长度时(比如上例中的languages数组的长度9),也代表最后一个元素的下一个位置。由于该位置上没有元素,因此无法访问该位置。此外,索引值也不允许为负数。
在数组索引的问题上,C#的规则看起来有些不统一。正向索引用从0开始计数,而反向索引则从^1开始。C#团队规定正向索引从0开始是为了与它所基于的前辈编程语言(C、C++、Java等)保持一致,而反向索引的概念在那些前辈语言中并不存在,因此C#选择了类似Python的方式,即从^1开始。但与Python不同的是,C#团队规定用^操作符来标记反向索引,而不是Python中的负数,这是为了区别于旧版C#中的集合索引(集合不是数组,集合索引可以为负数),从而保持向上兼容。此外,^操作符能够更好地支持区间索引,这个概念将在本章后面介绍。对于习惯于索引从0开始的人来说,反向索引的用法也可以这样记忆:既然对于正向索引来说,数组的末尾元素为“数组长度-1”,次末尾元素为“数组长度-2”,那么反向索引就是减号后面的那个正整数。数组中同一个元素的正向索引值和反向索引值之和总是等于数组长度。
最后要注意:^操作符后面不局限于使用字面量数字,也可以使用任何返回正整数的表达式,例如,
可以访问数组的首元素。
还可用方括号语法将数据存储到数组中。代码清单3.21交换了"C++"和"Java"的顺序。
代码清单3.21 交换数组中不同位置的数据
多维数组的元素用每一个维的索引来标识,如代码清单3.22所示。
代码清单3.22 初始化二维整数数组
交错数组元素的赋值稍有不同,这是因为它必须与交错数组的声明一致。第一个索引指定“由数组构成的数组”中的一个数组。第二个索引指定是该数组中的哪一项(参见代码清单3.23)。
代码清单3.23 声明交错数组
长度
像代码清单3.24那样获取数组长度。
代码清单3.24 获取数组长度
数组长度固定,除非重新创建数组,否则不能随便更改。此外,越过数组的边界(或长度)会造成“运行时”报错。用无效索引(指向的元素不存在)来访问(检索或者赋值)数组时就会发生这种情况。例如在代码清单3.25中,用数组长度作为索引来访问数组就会出错。
代码清单3.25 访问数组越界会抛出异常
注意 Length属性返回数组元素个数,而不是返回最高索引值。languages变量的Length属性是9,而languages数组的最高索引是8,是从起点能到达的最远位置,当使用大于8的索引来访问languages数组时,运行时会报告错误。
语言对比:C++——缓冲区溢出错误
非托管C++并非总是检查是否越过数组边界。这个错误不仅很难调试,而且有可能造成潜在的安全问题,也就是所谓的缓冲区溢出。相反,CLR能防止所有C#(和托管C++)代码越界,消除了托管代码中发生缓冲区溢出的可能。
在C# 8.0中,使用^0访问数组也会遇到同样问题:既然^1是末尾元素,那么^0就是末尾元素的下一个位置,该元素并不存在。
为避免越界,应使用长度检查来验证数组长度大于0。访问数组最后一项时,使用^1(C# 8.0开始)或Length-1而不是硬编码的值。例如,代码清单3.26修改了上个代码清单,在索引中使用了Length(减1获得最后一个元素的索引)。
代码清单3.26 在数组索引中使用Length-1
(当然,上面代码中访问数组前没有检查数组元素是否为null。在实际开发中,应当进行检查。)
设计规范
·访问数组之前应当检查数组变量是否为null,而不应该假设数组变量总是指向一个有效的数组。
·访问数组时,应当从Length属性获得数组长度,而不应该使用假设的长度。
·从C# 8.0以后,应当用^1来访问末尾元素,而不必再使用Length-1。
Length返回数组中元素的总数。因此,如果你有一个多维数组,比如大小为2×3×3的bool cells[,,]数组,那么Length会返回元素总数18。
对于交错数组,Length返回外部数组的元素数。因为交错数组是“数组构成的数组”,所以Length只作用于外部数组,只统计它的元素数(也就是具体由多少个数组构成),而不管各内部数组共包含了多少个元素。
区间
C# 8.0为数组提供了一个新的访问方法:数组切片。简单地说,数组切片就是将原数组中特定长度的一段连续元素提取出来形成新数组。我们将数组中一段连续的元素称为区间,用区间操作符..表示。在使用时,可将区间操作符写在两个索引值(包括反向索引)之间来表示区间,其中两个索引值为可选项。代码清单3.27展示了区间操作符的应用示例。
代码清单3.27 区间操作符的应用示例
区间操作符有一个非常重要的概念,即它所代表的区间为半闭半开区间。写在它左侧的索引所代表的元素被包含在区间内,而写在右侧的索引所代表的元素则不被包含。因此代码清单3.27中的区间0..3所代表的区间为从第0号元素开始的3个元素,而第4个元素(由于正向索引从0开始计数,因此索引值3代表第4个元素)则不包含在该区间内。上面代码中的第二个区间为^3..^0则从数组中提取最后3个元素。在这里^0不会造成问题,同样是因为^0作为区间操作符右侧的索引值,其所代表的元素不被包含在区间内。
区间操作符两侧的区间开始索引和截止索引都不是必须写出的。如果只写了开始索引,则表示从该索引开始到末尾元素的区间;如果只写了截止索引,则表示从首元素开始到该索引为止(不含)的区间;如果两个索引均未写出,则等同于整个数组,即0..^0。上面代码清单3.27里的第4到第6个例子展示了这种写法。
最后值得一提的是,在.NET/C#中,索引和区间类型为一等类型。它们的应用不局限于访问数组。下面的高级主题将具体介绍。
高级主题:System.Index和System.Range
在C#中,索引不是个单纯的整数,而是一种类型。使用反向操作符便是一种显示声明索引类型值的方式。索引类型可以在数组访问器的方括号之外单独使用。例如,你可以显示声明一个索引类型变量并用字面量数值为它赋值:System.Index index=^42。此外,普通的正整数也可以被直接赋值给一个System.Index类型变量。System.Index类型拥有两个属性:一个名为Value,其类型为int;另一个名为IsFromEnd,其类型为bool。后者显然是用于标记索引变量当前为正向还是反向。
此外,用于代表区间的类型为System.Range。与索引类型类似,你可以定义该类型的变量。若要将区间设置为代表全部元素,可以将其赋值为System.Range range=..^0,甚至System.Range range=..。System.Range有两个属性——Start和End,它们都是System.Index类型。
在这两个类型的帮助下,你便可以设计自己的集合类,并让它像数组一样支持反向索引和区间。(第17章将详细介绍如何创建自己的集合类。)
更多数组方法
数组提供了更多方法来操作数组中的元素,其中包括Sort()、BinarySearch()、Reverse()和Clear()等,如代码清单3.28所示。
代码清单3.28 更多数组方法
输出3.2展示了结果。
输出3.2
这些方法通过System.Array类提供。大多数都一目了然,但注意以下两点:
·使用BinarySearch()方法前要先对数组进行排序。如果值不按升序排序,会返回不正确的索引。目标元素不存在会返回负值,在这种情况下,可应用按位求补运算符~index返回比目标元素大的第一个元素的索引(如果有的话)[1]。
·Clear()方法不删除数组元素,不将长度设为零。数组大小固定,不能修改。所以Clear()方法将每个元素都设为其默认值(null、0或false)。这解释了在调用Clear()之后输出数组时,Console.WriteLine()为什么会创建一个空行。
语言对比:Visual Basic ——允许改变数组大小
Visual Basic提供Redim语句来更改数组元素数量。虽然没有等价的C#关键字,但.NET 2.0提供了System.Array.Resize()方法来重新创建数组,并将所有元素拷贝到新数组。
数组实例成员
类似于字符串,数组也有不从数据类型而是从变量访问的实例成员。Length就是一个例子,它通过数组变量来访问,而非通过类。其他常用实例成员还有GetLength()、Rank和Clone()。
获取特定维的长度不是用Length属性,而是用数组的GetLength()实例方法,调用时需指定返回哪一维的长度,如代码清单3.29所示。
代码清单3.29 获取特定维的大小
结果如输出3.3所示。
输出3.3
输出2,这是第一维的元素个数。
还可访问数组的Rank成员获取整个数组的维数。例如,cells.Rank返回3(见代码清单3.29)。
将一个数组变量赋给另一个默认只拷贝数组引用,而不是数组中单独的元素。要创建数组的全新拷贝需使用数组的Clone()方法。该方法返回数组拷贝,修改新数组不会影响原始数组。
3.5.4 字符串作为数组使用
访问string类型的变量类似于访问字符数组。例如,可调用palindrome[3]获取palindrome字符串的第4个字符。注意由于字符串不可变,所以不能向字符串中的特定位置赋值。所以,对于palindrome字符串来说,在C#中不允许,palindrome[3]='a'这样的写法。代码清单3.30使用数组访问符判断命令行上的参数是不是选项(选项的第一个字符是短划线)。
代码清单3.30 查找命令行选项
上述代码使用了要在第4章讲述的if语句。注意,第一个数组访问符[]获取字符串数组args的第一个元素,第二个数组访问符则获取该字符串的第一个字符。上述代码等价于代码清单3.31。
代码清单3.31 查找命令行选项(简化版)
不仅可用数组访问符单独访问字符串中的字符,还可使用字符串的ToCharArray()方法将整个字符串作为字符数组返回,再用System.Array.Reverse()方法反转数组中的元素,如代码清单3.32所示,该程序判断字符串是不是回文。
代码清单3.32 反转字符串
输出3.4展示了结果。
输出3.4
这个例子使用new关键字根据反转好的字符数组创建新字符串。
3.5.5 常见数组错误
前面描述了三种不同类型的数组:一维、多维和交错。一些规则和特点约束着数组的声明和使用。表3.3总结了一些常见错误,有助于巩固对这些规则的了解。阅读时最好先看“常见错误”一栏的代码(先不要看错误说明和改正后的代码),看自己是否能发现错误,检查你对数组及其语法的理解。
表3.3 常见数组编程错误
[1] 假定这个不存在的目标元素已插入数组并排好序。——译者注