11. 固定大小的 1D 数组与数组惯用法¶
到目前为止,在本课程中,当我们需要在单个对象中存储大量数据时,我们一直使用 List 。
在这方面,列表有很多优点,但它们并不是把多个值组合进一个对象的唯一方式。
在本模块中,我们将学习另一种名为 数组 (array)的数据结构。
数组比列表原始得多,并且是许多编程语言中用来构建更复杂数据结构的主要构件之一。 虽然使用列表通常更容易,但理解数组的工作原理构成了所有计算机科学家都应掌握的基本编程知识。
数组 是一组相邻存储位置的具名集合——也就是彼此紧挨着的存储位置——这些位置存放着相同类型的数据项。 不过,使用数组时,其大小从一开始就是固定的。 你可以把数组想象成一系列并排声明的相同变量,其中每个单独的变量用它在组内的序号或位置(即它的 索引 )来标识。
你可能也注意到了, ArrayList 这个类的名字里含有 "array"。
这是因为 ArrayList 类在内部使用数组来存储列表中的数据。
不过,两者之间有一些差异,我们接下来会看到。
你常常会看到用类似这样的示意图来描绘数组:
11.1. 创建数组¶
数组 是值的序列;数组中的值称为 元素 。
你可以创建由 int 、 double 、 String 或任何其他类型组成的数组,但数组中所有值的类型必须相同。
数组中存储的值的类型称为数组的 元素类型 。
要创建数组,你必须先声明一个具有数组类型的变量,然后再创建数组本身。
数组类型看起来与其他 Java 类型相似,只是它们后面跟着方括号( [] )。
Java 中并没有特殊的 "array" 关键字;你只需在任意类型的末尾加上方括号,就能把它变成数组类型。
例如,下面的代码行声明了 counts 是一个"整数数组", values 是一个"double 数组"。
int[] counts = new int[4];
double[] values = new double[size];
要创建数组本身,你必须使用 new 运算符,你之前已经用它创建过其他所有对象。
new 运算符会为数组 分配 内存,并自动将其所有元素初始化为零。
第一个声明把变量 counts 初始化为引用一个包含四个整数的新数组。
第二个声明把 values 初始化为引用一个 double 数组,但元素的个数取决于 size 的值(在数组创建时)。
任何整数表达式都可以用作数组的大小,只要它的值非负即可。
例如,如果你试图创建一个包含 -4 个元素的数组,就会得到 NegativeArraySizeException 。
允许创建包含零个元素的数组,这种数组有一些特殊用途,它们常被称为"空数组"。
重要的是,一旦我们确定了数组的大小,就无法再改变它。 如果我们突然决定需要一个不同大小的数组,就只能另外创建一个新的数组对象。
与对象一样,数组也是用 new 运算符实例化的,而且它们也有字段(例如 length )。
与对象的变量一样,引用数组的变量被视为引用变量。
当数组作为参数传递时,传递的是数组的引用,而不是整个数组的副本。
数组与完整对象之间的主要区别在于,数组并不是通过 Array 类来定义的。
因此,数组不属于 Java 的 Object 继承体系。
它们不会从 Object 继承任何属性,也不能被子类化。
最重要的是,这意味着数组本身虽然是对象,却 没有实用的方法 。 相反,你需要使用库类来访问对数组进行操作的方法。
当创建 String 数组或任何对象类型的数组(比如 Jeroo 或 Pixel)时,数组中的每一项都会被初始化为 null 。
如果我们创建的是 int(或 double)数组,数组中的每一项都会被初始化为 0;而创建布尔数组时,每一项都会被初始化为 false 。
如果我们创建数组时就知道想要放入数组的内容,还有第二种创建数组的方式。
例如,假设我们想创建一个数组来存储数字:97、43 和 2。
int[] counts = {97, 43, 2};
这会创建一个大小为 3 且包含这三个值的数组。 花括号中的这个值序列用于指定我们想放入所创建数组各个槽位中的初始值。
11.2. 访问数组中的元素¶
不过,数组访问值的方式略有不同,它再次用到了方括号。
在引用数组中的元素时,我们指的是该元素在数组中的位置。
例如,如果数组名为 values ,那么元素就分别命名为 values[0] 、 values[1] 、 values[2] ,……, values[n - 1] ,其中 n 表示数组中的元素个数。
这种命名方式也反映了这样一个事实:数组的数据存放在彼此相邻的存储位置中。
请注意,这与引用 List 中的位置或 String 中的字符位置是相同的概念。
数组第一个"槽位"中的元素位于索引 0,而数组中最后一个元素位于数组大小减 1 的位置。
例如,大小为 5 的数组中,第一个元素位于索引 0,最后一个位于索引 4。
试图访问索引 5 会在运行时导致 ArrayIndexOutOfBoundsException 。
引用数组元素的语法使用方括号来给出所需的位置(索引):
arrayname[subscript]
这里, arrayname 是数组的名字(任何合法标识符都可以), subscript 是该元素在数组中的位置。
下标 (subscript)是方括号内的一个整数值,用于按位置或索引值标识数组元素。
下标必须是整数值或整数表达式。
例如,下面所有代码行都是访问 values 数组中某个值的合法方式:
values[4]
values[x]
values[x + y]
这些例子表明,当表达式(如 x + y )用作下标时,会在进行引用之前先求值为一个具体的整数值。
使用非整数类型作为数组下标属于语法错误。以下每个表达式都是无效的:
// will not work!
arr[5.0]
arr["5"]
11.3. 设置数组中的元素¶
使用 List 时,如果想修改某个值,我们可以使用 set 方法。例如:
ArrayList <String> arr = new ArrayList<String>();
arr.add("Brazil");
arr.add("France");
arr.add("Ethiopia");
arr.set(0, "Columbia"); // changes the item at index 0 from Brazil to Columbia
执行这段代码后,这个 List 将按顺序包含以下串:Columbia、France 和 Ethiopia。
使用数组时,事情就简单多了。
我们不需要调用方法(因为数组没有实用的方法)。
相反,引用数组中的位置时直接使用方括号中的下标。
因此,当我们要修改数组中的某个值时,可以把 arrayname[<any subscript>] 当作一个变量来使用。
String[] countries = new String[3];
countries[0] = "Brazil";
countries[1] = "France";
countries[2] = "Ethiopia";
countries[0] = "Columbia"; // changes the item at index 0 from Brazil to Columbia
11.4. 数组与列表(或 ArrayList)的比较¶
11.5. 综合应用¶
为了把这些内容综合起来,我们来看一个全新的数组对象:
int[] counts = new int[4];
请记住,当你用 new 运算符创建数组时,元素会被初始化为零,如下面这个 counts 数组当前的内存示意图所示。
箭头表示 counts 的值是对数组的一个 引用 。
你应该把 数组 与引用它的 变量 视为两个不同的东西。
你很快就会看到,我们可以让另一个变量引用同一个数组,也可以修改 counts 的值,让它引用一个不同的数组。
方框里的粗体数字是数组的元素。 方框外的浅色数字是用于标识数组中每个位置的索引。 与串一样,第一个元素的索引是 0,而不是 1。 因此,我们有时把第一个元素称为"第零个"(zeroth)元素。
[] 运算符从数组中选取元素:
System.out.println("The zeroth element is " + counts[0]);
你可以在表达式的任何位置使用 [] 运算符:
counts[0] = 7;
counts[1] = counts[0] * 2;
counts[2]++;
counts[3] -= 60;
执行这些代码行之后,数组的内容将变为:
任何 int 类型的表达式都可以用作索引。
给数组索引最常用的方式之一是使用循环变量。例如:
int i = 0;
while (i < 4)
{
System.out.println(counts[i]);
i++;
}
这个 while 循环从 0 一直数到 4。当 i 为 4 时,条件不成立,循环终止。
因此,循环体只在 i 为 0、1、2 或 3 时执行。
在这个上下文中,变量名 i 是 "index"(索引)的缩写。
每次循环,我们都用 i 作为数组的索引,显示第 i 个元素。
这类对数组的处理通常写成 for 循环:
for (int i = 0; i < 4; i++)
{
System.out.println(counts[i]);
}
对于 counts 数组,唯一合法的索引是 0、1、2 和 3。
如果索引为负数或大于 3,结果就会抛出 ArrayIndexOutOfBoundsException 。
11.6. 遍历数组¶
假设我们想遍历一个整数数组中的所有值并把它们全部打印出来。
我们可以用计数器控制的循环或 for-each 循环来完成。
11.6.1. 基于计数器的数组循环¶
要为数组编写计数式 for 循环,我们需要知道数组有多少个槽位。
幸运的是,每个数组都知道自己的长度,我们可以通过它的 length 字段来访问。
int[] values = new int[ ... ];
for (int i = 0; i < values.length; i++)
{
values[i] = 2 * i;
}
请注意,这里我们用 values.length 来访问数组的长度。
与大多数需要调用方法的其他对象不同,数组没有实用的方法,而是通过一个名为 length 的特殊 只读 字段提供其长度。
不要把它与串的 length() 方法或列表的 size() 方法混淆——它只是一个字段,所以字段名后面不需要加括号。
请记住,由于数组一旦创建就不能改变大小,你不能给数组的 length 字段赋值——它是只读的。
总结一下这个计数式 for 循环:
我们要用到的第一个下标位于位置 0,所以我们创建一个名为
i的新变量并将其初始化为 0。我们的最后一个下标位置是数组长度减 1。这意味着当计数器不再小于数组长度时,我们就应停止。因此,循环条件为
i < values.length。我们希望遍历数组中的每一个索引,所以更新步要写成
i++,使每次循环重复时i都加 1。
同时使用 List 和数组时,很容易把什么时候用 size() 方法、什么时候用 length 搞混。
同样棘手的是,访问 String 变量的长度时,我们用的是 方法 length() 。
String[] words = new String[3];
int x = words.length; // x is set to 3
String str = "Hello";
int y = str.length(); // y is set to 5
List<String> moreWords = new ArrayList<>();
int z = moreWords.size(); // z is set to 0
务必仔细留意你正在处理的数据类型,这样才能正确地访问它的长度。
11.6.2. 数组的 for-each 循环¶
对数组进行 for-each 循环与对列表或其他结构进行 for-each 循环完全相同:
String[] coffees = {"Espresso", "Mocha", "Decaf", "Americano"};
for (String coffee : coffees)
{
System.out.println(coffee);
}
不过,与计数式 for 循环相比,有两个关键差异:
在循环体内你无法访问当前的位置或下标值,因此不能在循环内的任何计算中使用它。
你只能访问数组中存储的每个值,但不能修改数组中存储的值。循环变量(例如这个循环中的
coffee)只是循环内部的一个局部变量。虽然你可以给这个变量赋新值,但这不会影响数组本身,也不会影响数组的内容。
for-each 循环有很多优点,因为它们写起来简短,而且在管理索引/位置或条件方面几乎不会出错,几乎不可能写出无限循环。 不过,这些优点也伴随着一些限制。 幸运的是,数组天然支持这两种风格的 for 循环,所以请选择最适合你需求的风格。
11.7. 初始化数组内容¶
如上所述,当创建一个新的数组对象时,它会自动初始化。通常,值会初始化为零、false 或 null ,具体取决于数组元素类型。
但是,如果你不想要全为零或全为 null 的数组呢?
正如你所看到的,你可以显式地列出自己的初始值:
String[] coffees = {"Espresso", "Mocha", "Decaf", "Americano"};
这对小数组或方便把全部值写出来的情况非常合适。 但是,如果数组有 100 个槽位呢?或者 1000 个槽位呢?
例如,如果你想给一张大图中的所有像素都初始化为蓝色呢?
如果你想给数组初始化为除零或 null 以外的值,只需用计数式 for 循环遍历数组的所有槽位,并用赋值语句按需设置值即可。
例如,要将一个包含 100 个整数的数组初始化为 0-99:
int[] values = new int[100];
for (int i = 0; i < values.length; i++)
{
values[i] = i;
}
11.8. 打印数组¶
你可以用 println() 来显示数组,但它可能不会按你期望的方式输出。
例如,假设你这样打印数组:
int[] values = {1, 2, 3, 4};
System.out.println(values);
输出如下所示:
[I@bf3f7e0
方括号表示该值是一个数组,I 代表 "integer"(整数),其余部分表示数组在内存中的地址。
如果我们想显示数组的元素,可以自己动手实现:
public void printArray(int[] values)
{
System.out.print("[" + values[0]);
for (int i = 1; i < values.length; i++)
{
System.out.print(", " + values[i]);
}
System.out.println("]");
}
对前面那个数组, printArray() 的输出如下:
[1, 2, 3, 4]
幸运的是,Java 库中已经包含了一个类 java.util.Arrays ,它提供了许多处理数组的方法。
其中之一 toString() 会返回数组的串表示形式。
请记住,数组 本身不提供实用的方法 ,但这个工具类确实为你提供了这些方法。
导入 Arrays 之后,我们可以这样调用 toString() :
System.out.println(Arrays.toString(values));
输出如下所示:
[1, 2, 3, 4]
请注意, Arrays.toString() 会在数组元素两侧加上方括号,生成的输出与 List 对象的 toString() 方法看起来一样。
11.9. 复制数组变量¶
数组变量包含对数组的引用。当你对数组变量进行赋值时,它只是复制引用,并不会复制数组本身。例如:
double[] a = new double[3];
double[] b = a;
这些语句创建一个包含三个 double 值的数组,并让两个不同的变量引用它。
无论通过哪个变量所做的修改,另一个变量都能看到。
例如,如果我们设置 a[0] = 17.0; ,然后显示 b[0] ,结果就是 17.0 。
因为 a 和 b 是同一个东西的两个不同名字,它们有时被称为 别名 。
如果你真的想复制数组本身,而不只是引用,那么你必须创建一个新数组,并把元素从一个复制到另一个,如下所示:
double[] b = new double[3];
for (int i = 0; i < 3; i++)
{
b[i] = a[i];
}
java.util.Arrays 提供了一个名为 copyOf() 的方法来完成这项工作。所以你可以用一行代码替换前面的代码:
double[] b = Arrays.copyOf(a, 3);
第二个参数是要复制的元素个数,因此 copyOf() 也可以用于复制数组的一部分。
以这种方式调用 Arrays.copyOf() 之后,这两个变量最终会处于如下状态:
到目前为止的例子都只对包含三个元素的数组有效。
更好的做法是把代码推广到任意大小的数组。
我们可以用 a.length 替换魔法数字 3 来实现:
double[] b = new double[a.length];
for (int i = 0; i < a.length; i++)
{
b[i] = a[i];
}
循环最后一次执行时, i 是 a.length - 1 ,也就是最后一个元素的索引。
当 i 等于 a.length 时,条件不成立,循环体不会执行——这是件好事,因为试图访问 a[a.length] 会抛出异常。
当然,我们也可以用 Arrays.copyOf() 并以 a.length 作为第二个参数来完全替换这个循环。
下面这一行产生的结果与上面相同:
double[] b = Arrays.copyOf(a, a.length);
Arrays 类还提供了许多其他实用的方法,比如 Arrays.compare() 、 Arrays.equals() 、 Arrays.fill() 和 Arrays.sort 。
花点时间在网上搜索 java.util.Arrays 阅读一下文档。
11.10. 数组变量的命名¶
为数组变量取名可能有些棘手。例如,考虑下面这个数组。
String[] words = {"loop", "array", "method", "class"};
这里我们选择了一个复数名称,这是数组命名中最常见的模式。 当你经常把整个数组当作一个对象来引用,并希望给它一个能代表整组值的名字时,就应该使用复数名称。 在编写循环、把数组作为参数传给其他方法等场合,复数名称有助于提高可读性,因为它时刻提醒你:数组是把整组值组合进一个对象。
不过,在其他一些情况下,你可能主要关注的是数组中的单个槽位,而不是数组本身。
如果代码主要只是处理单个数组槽位,那么单数名称会更有用。
例如,如果我们有一个单词数组,但最常想到的是引用数组中的 "word 1"、"word 3" 或 "word 0",那么把数组命名为 word 而不是 words ,就可以让我们使用 word[1] 、 word[3] 或 word[0] 这样的下标表达式。
因为这个模式恰好与我们谈论数组中单个值时的自然说法一致,有时它是更好的选择。
在实践中,你需要根据具体情况判断哪个更合适。 拿不准时,请使用复数名称,因为它更常见,并且即使在某些使用场景下单数名称更贴切,复数名称也能避免产生误导。
11.11. 编写涉及数组的测试断言¶
用数组值进行测试时,有时基本断言就很方便,但有时可能会很复杂。
你可以用 assertThat(...).isEqualTo(...) 来检查数组是否具有预期的值,但还有很多其他选项。
// We expect result is {"Espresso", "Mocha", "Decaf", "Americano"}
String[] result = someObject.MethodThatReturnsArray();
// If you want to check the size
assertThat(result.length).isEqualTo(4);
// Alternate way of checking the size
assertThat(result).hasSize(4);
// If you want to check a specific index
assertThat(result[1]).isEqualTo("Mocha");
// If you want to check the entire array
String[] coffees = {"Espresso", "Mocha", "Decaf", "Americano"};
assertThat(result).isEqualTo(words);
不过,有时数组可能很长,或者把所有值写出来不太方便,又或者你可能希望避免声明/创建/初始化一个单独的数组进行比较。 幸运的是,在对数组做断言时,你还可以使用一些额外的方法。
第一个方法是 containsExactly() ,它允许你直接列出你期望在数组中找到的值:
// If you want to check the entire array
assertThat(result).containsExactly(
"Espresso", "Mocha", "Decaf", "Americano");
当你用 containsExactly() 做断言时,你按期望的顺序列出数组的 全部 内容,就这样。
如果数组恰好只包含你指定的值,并且顺序与你列出的顺序一致,那么 containsOnly() 就会通过。
它是一个有用的简写方式,省去了创建一个单独数组对象来比较的麻烦。
更妙的是,它 对列表对象同样有效 !
不过,有时如果数组真的很长,你并不想把所有值都列出来。 这时你可以改用:
// If you want to check the entire array
assertThat(result).contains("Americano", "Mocha");
contains() 方法允许你列出任意数量的值。
不过,只要数组中的任何位置以 任意顺序 包含这些指定的值(与其他槽位中的值混在一起), contains() 就会通过。
数组可能更长,除了你列出的值之外还可能包含其他值,但它必须包含你指定的值。
当数组中只有少数几个值比较重要,而数组又太长、无法全部列出时,这个方法就很有用。
同样,它 对列表也有效 。
在编写测试时,你还可以对 数组或列表 使用另外几个方法,你可以根据需要的多少来指定待检查的值:
// If you want to check values are not present
assertThat(result).doesNotContain("table", "chair");
// To check the first few elements, without worrying about others
assertThat(result).startsWith("Espresso", "Mocha");
// To check the last few elements, without worrying about others
assertThat(result).endsWith("Mocha", "Decaf", "Americano");
// To check these occur only once in array, are not duplicated
assertThat(result).containsOnlyOnce("Mocha", "Americano");
11.12. 在问题中应用数组¶
这个视频讨论了一个 "Web-Analyzer"(网页分析器)项目,以及数组如何在这种类型的问题中发挥作用。 你不必过多担心这个项目做什么或如何工作,也不用担心完成视频中给出的练习。 它只是一个更详细的示例,用来解释数组以及如何在问题中使用它们。
