第二十一章 数组

[TOC]

第二十一章 数组

初始化和清理 一章的最后,你已经学过如何定义和初始化一个数组。

简单来看,数组需要你去创建和初始化,你可以通过下标对数组元素进行访问,数组的大小不会改变。大多数时候你只需要知道这些,但有时候你必须在数组上进行更复杂的操作,你也可能需要在数组和更加灵活的 集合 (Collection)之间做出评估。因此本章我们将对数组进行更加深入的分析。

注意: 随着 Java Collection 和 Stream 类中高级功能的不断增加,日常编程中使用数组的需求也在变少,所以你暂且可以放心地略读甚至跳过这一章。但是,即使你自己避免使用数组,也总会有需要阅读别人数组代码的那一天。那时候,本章依然在这里等着你来翻阅。

数组特性

明明还有很多其他的办法来保存对象,那么是什么令数组如此特别?

将数组和其他类型的集合区分开来的原因有三:效率,类型,保存基本数据类型的能力。在 Java 中,使用数组存储和随机访问对象引用序列是非常高效的。数组是简单的线性序列,这使得对元素的访问变得非常快。然而这种高速也是有代价的,代价就是数组对象的大小是固定的,且在该数组的生存期内不能更改。

速度通常并不是问题,如果有问题,你保存和检索对象的方式也很少是罪魁祸首。你应该总是从 ArrayList (来自 集合)开始,它将数组封装起来。必要时,它会自动分配更多的数组空间,创建新数组,并将旧数组中的引用移动到新数组。这种灵活性需要开销,所以一个 ArrayList 的效率不如数组。在极少的情况下效率会成为问题,所以这种时候你可以直接使用数组。

数组和集合(Collections)都不能滥用。不管你使用数组还是集合,如果你越界,你都会得到一个 RuntimeException 的异常提醒,这表明你的程序中存在错误。

在泛型前,其他的集合类以一种宽泛的方式处理对象(就好像它们没有特定类型一样)。事实上,这些集合类把保存对象的类型默认为 Object,也就是 Java 中所有类的基类。而数组是优于 预泛型 (pre-generic)集合类的,因为你创建一个数组就可以保存特定类型的数据。这意味着你获得了一个编译时的类型检查,而这可以防止你插入错误的数据类型,或者搞错你正在提取的数据类型。

当然,不管在编译时还是运行时,Java都会阻止你犯向对象发送不正确消息的错误。然而不管怎样,使用数组都不会有更大的风险。比较好的地方在于,如果编译器报错,最终的用户更容易理解抛出异常的含义。

一个数组可以保存基本数据类型,而一个预泛型的集合不可以。然而对于泛型而言,集合可以指定和检查他们保存对象的类型,而通过 自动装箱 (autoboxing)机制,集合表现地就像它们可以保存基本数据类型一样,因为这种转换是自动的。

下面给出一例用于比较数组和泛型集合:

// arrays/CollectionComparison.java
// (c)2017 MindView LLC: see Copyright.txt
// We make no guarantees that this code is fit for any purpose.
// Visit http://OnJava8.com for more book information.
import java.util.*;
import onjava.*;
import static onjava.ArrayShow.*;

class BerylliumSphere {
  private static long counter;
  private final long id = counter++;
  @Override
  public String toString() {
    return "Sphere " + id;
  }
}

public class CollectionComparison {
  public static void main(String[] args) {
    BerylliumSphere[] spheres =
      new BerylliumSphere[10];
    for(int i = 0; i < 5; i++)
      spheres[i] = new BerylliumSphere();
    show(spheres);
    System.out.println(spheres[4]);

    List<BerylliumSphere> sphereList = Suppliers.create(
      ArrayList::new, BerylliumSphere::new, 5);
    System.out.println(sphereList);
    System.out.println(sphereList.get(4));

    int[] integers = { 0, 1, 2, 3, 4, 5 };
    show(integers);
    System.out.println(integers[4]);

    List<Integer> intList = new ArrayList<>(
      Arrays.asList(0, 1, 2, 3, 4, 5));
    intList.add(97);
    System.out.println(intList);
    System.out.println(intList.get(4));
  }
}
/* Output:
[Sphere 0, Sphere 1, Sphere 2, Sphere 3, Sphere 4,
null, null, null, null, null]
Sphere 4
[Sphere 5, Sphere 6, Sphere 7, Sphere 8, Sphere 9]
Sphere 9
[0, 1, 2, 3, 4, 5]
4
[0, 1, 2, 3, 4, 5, 97]
4
*/

Suppliers.create() 方法在泛型一章中被定义。上面两种保存对象的方式都是有类型检查的,唯一比较明显的区别就是数组使用 [ ] 来随机存取元素,而一个 List 使用诸如 add()get() 等方法。数组和 ArrayList 之间的相似是设计者有意为之,所以在概念上,两者很容易切换。但是就像你在集合中看到的,集合的功能明显多于数组。随着 Java 自动装箱技术的出现,通过集合使用基本数据类型几乎和通过数组一样简单。数组唯一剩下的优势就是效率。然而,当你解决一个更加普遍的问题时,数组可能限制太多,这种情形下,您可以使用集合类。

用于显示数组的实用程序

在本章中,我们处处都要显示数组。Java 提供了 Arrays.toString() 来将数组转换为可读字符串,然后可以在控制台上显示。然而这种方式视觉上噪音太大,所以我们创建一个小的库来完成这项工作。

第一个方法适用于对象数组,包括那些包装基本数据类型的数组。所有的方法重载对于不同的数据类型是必要的。

第二组重载方法可以让你显示带有信息 字符串 前缀的数组。

为了简单起见,你通常可以静态地导入它们。

一等对象

不管你使用的什么类型的数组,数组中的数据集实际上都是对堆中真正对象的引用。数组是保存指向其他对象的引用的对象,数组可以隐式地创建,作为数组初始化语法的一部分,也可以显式地创建,比如使用一个 new 表达式。数组对象的一部分(事实上,你唯一可以使用的方法)就是只读的 length 成员函数,它能告诉你数组对象中可以存储多少元素。[ ] 语法是你访问数组对象的唯一方式。

下面的例子总结了初始化数组的多种方式,并且展示了如何给不同的数组对象分配数组引用。同时也可以看出对象数组和基元数组在使用上是完全相同的。唯一的不同之处就是对象数组存储的是对象的引用,而基元数组则直接存储基本数据类型的值。

数组 a 是一个未初始化的本地变量,编译器不会允许你使用这个引用直到你正确地对其进行初始化。数组 b 被初始化成一系列指向 BerylliumSphere 对象的引用,但是并没有真正的 BerylliumSphere 对象被存储在数组中。尽管你仍然可以获得这个数组的大小,因为 b 指向合法对象。这带来了一个小问题:你无法找出到底有多少元素存储在数组中,因为 length 只能告诉你数组可以存储多少元素;这就是说,数组对象的大小并不是真正存储在数组中对象的个数。然而,当你创建一个数组对象,其引用将自动初始化为 null,因此你可以通过检查特定数组元素中的引用是否为 null 来判断其中是否有对象。基元数组也有类似的机制,比如自动将数值类型初始化为 0,char 型初始化为 (char)0,布尔类型初始化为 false

数组 c 展示了创建数组对象后给数组中各元素分配 BerylliumSphere 对象。数组 d 展示了创建数组对象的聚合初始化语法(隐式地使用 new 在堆中创建对象,就像 c 一样)并且初始化成 BeryliumSphere 对象,这一切都在一条语句中完成。

下一个数组初始化可以被看做是一个“动态聚合初始化”。 d 使用的聚合初始化必须在 d 定义处使用,但是使用第二种语法,你可以在任何地方创建和初始化数组对象。例如,假设 hide() 是一个需要使用一系列的 BeryliumSphere对象。你可以这样调用它:

你也可以动态地创建你用作参数传递的数组:

很多情况下这种语法写代码更加方便。

表达式:

显示了你如何获取指向一个数组对象的引用并将其分配给另一个数组对象。就像你可以处理其他类型的对象引用。现在 ad 都指向了堆中的同一个数组对象。

ArrayOptions.java 的第二部分展示了基元数组的语法就像对象数组一样,除了基元数组直接保存基本数据类型的值。

返回数组

假设你写了一个方法,这个方法不是返回一个元素,而是返回多个元素。对 C++/C 这样的语言来说这是很困难的,因为你无法返回一个数组,只能是返回一个指向数组的指针。这会带来一些问题,因为对数组生存期的控制变得很混乱,这会导致内存泄露。

而在 Java 中,你只需返回数组,你永远不用为数组担心,只要你需要它,它就可用,垃圾收集器会在你用完后把它清理干净。

下面,我们返回一个 字符串 数组:

flavorset() 创建名为 resultsString 类型的数组。 这个数组的大小 n 取决于你传进方法的参数。然后从数组 FLAVORS 中随机选择 flavors 并且把它们放进 results 里并返回。返回一个数组就像返回其他任何对象一样,实际上返回的是引用。数组是在 flavorSet() 中或者是在其他什么地方创建的并不重要。垃圾收集器会清理你用完的数组,你需要的数组则会保留。

如果你必须要返回一系列不同类型的元素,你可以使用 泛型 中介绍的 元组

注意,当 flavorSet() 随机选择 flavors,它应该确保某个特定的选项没被选中。这在一个 do 循环中执行,它将一直做出随机选择直到它发现一个元素不在 picked 数组中。(一个字符串

比较将显示出随机选中的元素是不是已经存在于 results 数组中)。如果成功了,它将添加条目并且寻找下一个( i 递增)。输出结果显示 flavorSet() 每一次都是按照随机顺序选择 flavors。

一直到现在,随机数都是通过 java.util.Random 类生成的,这个类从 Java 1.0 就有,甚至更新过以提供 Java 8 流。现在我们可以介绍 Java 8 中的 SplittableRandom ,它不仅能在并行操作使用(你最终会学到),而且提供了一个高质量的随机数。这本书的剩余部分都使用 SplittableRandom

多维数组

要创建多维的基元数组,你要用大括号来界定数组中的向量:

每个嵌套的大括号都代表了数组的一个维度。

这个例子使用 Arrays.deepToString() 方法,将多维数组转换成 String 类型,就像输出中显示的那样。

你也可以使用 new 分配数组。这是一个使用 new 表达式分配的三维数组:

倘若你不对基元数组进行显式的初始化,它的值会自动初始化。而对象数组将被初始化为 null

组成矩阵的数组中每一个向量都可以是任意长度的(这叫做不规则数组):

第一个 new 创建了一个数组,这个数组首元素长度随机,其余的则不确定。第二个 new 在 for 循环中给数组填充了第二个元素,第三个 new 为数组的最后一个索引填充元素。

  • [1] Java 8 增加了 Arrays.setAll() 方法,其使用生成器来生成插入数组中的值。此生成器符合函数式接口 IntUnaryOperator ,只使用一个非 默认 的方法 ApplyAsint(int操作数)Arrays.setAll() 传递当前数组索引作为操作数,因此一个选项是提供 n -> n 的 lambda 表达式来显示数组的索引(在上面的代码中很容易尝试)。这里,我们忽略索引,只是插入递增计数器的值。

非基元的对象数组也可以定义为不规则数组。这里,我们收集了许多使用大括号的 new 表达式:

数组初始化时使用自动装箱技术:

以下是如何逐个构建非基元的对象数组:

i * j 在这里只是为了向 Integer 中添加有趣的值。

Arrays.deepToString() 方法同时适用于基元数组和对象数组:

同样的,在 IntegerDouble 数组中,自动装箱可为你创建包装器对象。

泛型数组

一般来说,数组和泛型并不能很好的结合。你不能实例化参数化类型的数组:

类型擦除需要删除参数类型信息,而且数组必须知道它们所保存的确切类型,以强制保证类型安全。

但是,可以参数化数组本身的类型:

比起使用参数化类,使用参数化方法很方便。您不必为应用它的每个不同类型都实例化一个带有参数的类,但是可以使它成为 静态 的。你不能总是选择使用参数化方法而不用参数化的类,但通常参数化方法是更好的选择。

你不能创建泛型类型的数组,这种说法并不完全正确。是的,编译器不会让你 实例化 一个泛型的数组。但是,它将允许您创建对此类数组的引用。例如:

无可争议的,这可以通过编译。尽管不能创建包含泛型的实际数组对象,但是你可以创建一个非泛型的数组并对其进行强制类型转换:

一旦你有了对 List[] 的引用 , 你会发现多了一些编译时检查。问题是数组是协变的,所以 List[] 也是一个 Object[] ,你可以用这来将 **ArrayList ** 分配进你的数组,在编译或者运行时都不会出错。

如果你知道你不会进行向上类型转换,你的需求相对简单,那么可以创建一个泛型数组,它将提供基本的编译时类型检查。然而,一个泛型 Collection 实际上是一个比泛型数组更好的选择。

一般来说,您会发现泛型在类或方法的边界上是有效的。在内部,擦除常常会使泛型不可使用。所以,就像下面的例子,不能创建泛型类型的数组:

擦除再次从中作梗,这个例子试图创建已经擦除的类型数组,因此它们是未知的类型。你可以创建一个 对象 数组,然后对其进行强制类型转换,但如果没有 @SuppressWarnings 注释,你将会得到一个 "unchecked" 警告,因为数组实际上不真正支持而且将对类型 T 动态检查 。这就是说,如果我创建了一个 String[] , Java将在编译时和运行时强制执行,我只能在数组中放置字符串对象。然而,如果我创建一个 Object[] ,我可以把除了基元类型外的任何东西放入数组。

Arrays的fill方法

通常情况下,当对数组和程序进行实验时,能够很轻易地生成充满测试数据的数组是很有帮助的。 Java 标准库 Arrays 类包括一个普通的 fill() 方法,该方法将单个值复制到整个数组,或者在对象数组的情况下,将相同的引用复制到整个数组:

你既可以填充整个数组,也可以像最后两个语句所示,填充一系列的元素。但是由于你只能使用单个值调用 Arrays.fill() ,因此结果并非特别有用。

Arrays的setAll方法

在Java 8中, 在RaggedArray.java 中引入并在 ArrayOfGenerics.java.Array.setAll() 中重用。它使用一个生成器并生成不同的值,可以选择基于数组的索引元素(通过访问当前索引,生成器可以读取数组值并对其进行修改)。 static Arrays.setAll() 的重载签名为:

  • void setAll(int[] a, IntUnaryOperator gen)

  • void setAll(long[] a, IntToLongFunction gen)

  • void setAll(double[] a, IntToDoubleFunctiongen)

  • void setAll(T[] a, IntFunction<? extendsT> gen)

除了 int , long , double 有特殊的版本,其他的一切都由泛型版本处理。生成器不是 Supplier 因为它们不带参数,并且必须将 int 数组索引作为参数。

  • [1] 这里,我们只是将数组索引作为值插入数组。这将自动转化为 longdouble 版本。

  • [2] 这个函数只需要接受索引就能产生正确结果。这个,我们忽略索引值并且使用 val 生成结果。

  • [3] 方法引用有效,因为 Bob 的构造器接收一个 int 参数。只要我们传递的函数接收一个 int 参数且能产生正确的结果,就认为它完成了工作。

  • [4] 为了处理除了 intlongdouble 之外的基元类型,请为基元创建包装类的数组。然后使用 setAll() 的泛型版本。请注意,getChar() 生成基元类型,因此这是自动装箱到 Character

增量生成

这是一个方法库,用于为不同类型生成增量值。

这些被作为内部类来生成容易记住的名字;比如,为了使用 Integer 工具你可以用 new Conut.Interger() , 如果你想要使用基本数据类型 int 工具,你可以用 new Count.Pint() (基本类型的名字不能被直接使用,所以它们都在前面添加一个 P 来表示基本数据类型'primitive', 我们的第一选择是使用基本类型名字后面跟着下划线,比如 int_double_ ,但是这种方式违背Java的命名习惯)。每个包装类的生成器都使用 get() 方法实现了它的 Supplier 。要使用Array.setAll() ,一个重载的 get(int n) 方法要接受(并忽略)其参数,以便接受 setAll() 传递的索引值。

注意,通过使用包装类的名称作为内部类名,我们必须调用 java.lang 包来保证我们可以使用实际包装类的名字:

对于 intlongdouble 这三个有特殊 Supplier 接口的原始数据类型来说,PintPlongPdouble 实现了这些接口。

这里是对 Count 的测试,这同样给我们提供了如何使用它的例子:

注意到原始数组类型 int[]long[]double[] 可以直接被 Arrays.setAll() 填充,但是其他的原始类型都要求用包装器类型的数组。

通过 Stream.generate() 创建的包装数组显示了 toArray() 的重载用法,在这里你应该提供给它要创建的数组类型的构造器。

随机生成

我们可以按照 Count.java 的结构创建一个生成随机值的工具:

对于除了 intlongdouble 之外的所有基本类型元素生成器,只生成数组,而不是 Count 中看到的完整操作集。这只是一个设计选择,因为本书不需要额外的功能。

下面是对所有 Rand 工具的测试:

注意(除了 String 部分之外),这段代码与 TestCount.java 中的代码相同,CountRand 替换。

泛型和基本数组

在本章的前面,我们被提醒,泛型不能和基元一起工作。在这种情况下,我们必须从基元数组转换为包装类型的数组,并且还必须从另一个方向转换。下面是一个转换器可以同时对所有类型的数据执行操作:

primitive() 的每个版本都创建一个准确长度的适当基元数组,然后从包装类的 in 数组中复制元素。如果任何包装的数组元素是 null ,你将得到一个异常(这是合理的—否则无法选择有意义的值进行替换)。注意在这个任务中自动装箱如何发生。

下面是对 ConvertTo 中所有方法的测试:

在每种情况下,原始数组都是为包装类型创建的,并使用 Arrays.setAll() 填充,正如我们在 TestCouner.java 中所做的那样(这也验证了 Arrays.setAll() 是否能同 IntegerLong ,和 Double )。然后 ConvertTo.primitive() 将包装器数组转换为对应的基元数组,ConverTo.boxed() 将其转换回来。

数组元素修改

传递给 Arrays.setAll() 的生成器函数可以使用它接收到的数组索引修改现有的数组元素:

[1] Lambdas在这里特别有用,因为数组总是在lambda表达式的范围内。

数组并行

我们很快就不得不面对并行的主题。例如,“并行”一词在许多Java库方法中使用。您可能听说过类似“并行程序运行得更快”这样的说法,这是有道理的—当您可以有多个处理器时,为什么只有一个处理器在您的程序上工作呢? 如果您认为您应该利用其中的“并行”,这是很容易被原谅的。 要是这么简单就好了。不幸的是,通过采用这种方法,您可以很容易地编写比非并行版本运行速度更慢的代码。在你深刻理解所有的问题之前,并行编程看起来更像是一门艺术而非科学。 以下是简短的版本:用简单的方法编写代码。不要开始处理并行性,除非它成为一个问题。您仍然会遇到并行性。在本章中,我们将介绍一些为并行执行而编写的Java库方法。因此,您必须对它有足够的了解,以便进行基本的讨论,并避免出现错误。

在阅读并发编程这一章之后,您将更深入地理解它(但是,唉,这还远远不够。只是这些的话,充分理解这个主题是不可能的)。 在某些情况下,即使您只有一个处理器,无论您是否显式地尝试并行,并行实现是惟一的、最佳的或最符合逻辑的选择。它是一个可以一直使用的工具,所以您必须了解它的相关问题。

最好从数据的角度来考虑并行性。对于大量数据(以及可用的额外处理器),并行可能会有所帮助。但您也可能使事情变得更糟。

在本书的其余部分,我们将遇到不同的情况:

  • 1、所提供的惟一选项是并行的。这很简单,因为我们别无选择,只能使用它。这种情况是比较罕见的。

  • 2、有多个选项,但是并行版本(通常是最新的版本)被设计成在任何地方都可以使用(甚至在那些不关心并行性的代码中),如案例#1。我们将按预期使用并行版本。

  • 3、案例1和案例2并不经常发生。相反,您将遇到某些算法的两个版本,一个用于并行使用,另一个用于正常使用。我将描述并行的一个,但不会在普通代码中使用它,因为它也许会产生所有可能的问题。

我建议您在自己的代码中采用这种方法。

[http://gee.cs.oswego.edu/dl/html/StreamParallelGuidance.html](要进一步了解为什么这是一个难题,请参阅Doug Lea的文章。)

parallelSetAll()

流式编程产生优雅的代码。例如,假设我们想要创建一个数值由从零开始填充的长数组:

实际上可以存储到将近1000万,但是之后就会耗尽堆空间。常规的 setAll() 是有效的,但是如果我们能更快地处理如此大量的数字,那就更好了。 我们可以使用 setAll() 初始化更大的数组。如果速度成为一个问题,Arrays.parallelSetAll() 将(可能)更快地执行初始化(请记住并行性中描述的问题)。

数组分配和初始化是在单独的方法中执行的,因为如果两个数组都在 main() 中分配,它会耗尽内存(至少在我的机器上是这样。还有一些方法可以告诉Java在启动时分配更多的内存)。

Arrays工具类

您已经看到了 java.util.Arrays 中的 fill()setAll()/parallelSetAll() 。该类包含许多其他有用的 静态 程序方法,我们将对此进行研究。

概述:

  • asList(): 获取任何序列或数组,并将其转换为一个 列表集合 (集合章节介绍了此方法)。

  • copyOf():以新的长度创建现有数组的新副本。

  • copyOfRange():创建现有数组的一部分的新副本。

  • equals():比较两个数组是否相等。

  • deepEquals():多维数组的相等性比较。

  • stream():生成数组元素的流。

  • hashCode():生成数组的哈希值(您将在附录中了解这意味着什么:理解equals()和hashCode())。

  • deepHashCode(): 多维数组的哈希值。

  • sort():排序数组

  • parallelSort():对数组进行并行排序,以提高速度。

  • binarySearch():在已排序的数组中查找元素。

  • parallelPrefix():使用提供的函数并行累积(以获得速度)。基本上,就是数组的reduce()。

  • spliterator():从数组中产生一个Spliterator;这是本书没有涉及到的流的高级部分。

  • toString():为数组生成一个字符串表示。你在整个章节中经常看到这种用法。

  • deepToString():为多维数组生成一个字符串。你在整个章节中经常看到这种用法。对于所有基本类型和对象,所有这些方法都是重载的。

数组拷贝

与使用for循环手工执行复制相比,copyOf()copyOfRange() 复制数组要快得多。这些方法被重载以处理所有类型。

我们从复制 intInteger 数组开始:

[1] 这是复制的基本方法;只需给出返回的复制数组的大小。这对于编写需要调整存储大小的算法很有帮助。复制之后,我们把a1的所有元素都设为1,以证明a1的变化不会影响a2中的任何东西。

[2] 通过更改最后一个参数,我们可以缩短或延长返回的复制数组。

[3] copyOf()copyOfRange() 也可以使用包装类型。copyOfRange() 需要一个开始和结束索引。

[4] copyOf()copyOfRange() 都有一个版本,该版本通过在方法调用的末尾添加目标类型来创建不同类型的数组。我首先想到的是,这可能是一种从原生数组生成包装数组的方法,反之亦然。 但这没用。它的实际用途是“向上转换”和“向下转换”数组。也就是说,如果您有一个子类型(派生类型)的数组,而您想要一个基类型的数组,那么这些方法将生成所需的数组。

[5] 您甚至可以成功地“向下强制转换”,并从超类型的数组生成子类型的数组。这个版本运行良好,因为我们只是“upcast”。

[6] 这个“数组转换”将编译,但是如果类型不兼容,您将得到一个运行时异常。在这里,强制将基类型转换为派生类型是非法的,因为派生对象中可能有基对象中没有的属性和方法。

实例表明,原生数组和对象数组都可以被复制。但是,如果复制对象的数组,那么只复制引用—不复制对象本身。这称为浅拷贝(有关更多细节,请参阅附录:传递和返回对象)。

还有一个方法 System.arraycopy() ,它将一个数组复制到另一个已经分配的数组中。这将不会执行自动装箱或自动卸载—两个数组必须是完全相同的类型。

数组比较

数组 提供了 equals() 来比较一维数组,以及 deepEquals() 来比较多维数组。对于所有原生类型和对象,这些方法都是重载的。

数组相等的含义:数组必须有相同数量的元素,并且每个元素必须与另一个数组中的对应元素相等,对每个元素使用 equals()(对于原生类型,使用原生类型的包装类的 equals() 方法;例如,int的Integer.equals()。

最初,a1和a2是完全相等的,所以输出是true,但是之后其中一个元素改变了,这使得结果为false。a1w和a2w是对一个封装类型数组重复该练习。

md1md2 是通过 twoDArray() 以相同方式初始化的多维字符串数组。注意,deepEquals() 返回 true,因为它执行了适当的比较,而普通的 equals() 错误地返回 false。如果我们更改数组中的一个元素,deepEquals() 将检测它。

流和数组

stream() 方法很容易从某些类型的数组中生成元素流。

只有“原生类型” intlongdouble 可以与 Arrays.stream() 一起使用;对于其他的,您必须以某种方式获得一个包装类型的数组。

通常,将数组转换为流来生成所需的结果要比直接操作数组容易得多。请注意,即使流已经“用完”(您不能重复使用它),您仍然拥有该数组,因此您可以以其他方式使用它----包括生成另一个流。

数组排序

根据对象的实际类型执行比较排序。一种方法是为不同的类型编写对应的排序方法,但是这样的代码不能复用。

编程设计的一个主要目标是“将易变的元素与稳定的元素分开”,在这里,保持不变的代码是一般的排序算法,但是变化的是对象的比较方式。因此,使用策略设计模式而不是将比较代码放入许多不同的排序源码中。使用策略模式时,变化的代码部分被封装在一个单独的类(策略对象)中。

您将一个策略对象交给相同的代码,该代码使用策略模式来实现其算法。通过这种方式,您将使用相同的排序代码,使不同的对象表达不同的比较方式。

Java有两种方式提供比较功能。第一种方法是通过实现 java.lang.Comparable 接口的原生方法。这是一个简单的接口,只含有一个方法 compareTo()。该方法接受另一个与参数类型相同的对象作为参数,如果当前对象小于参数,则产生一个负值;如果参数相等,则产生零值;如果当前对象大于参数,则产生一个正值。

这里有一个类,它实现了 Comparable 接口并演示了可比性,而且使用Java标准库方法 Arrays.sort():

当您定义比较方法时,您有责任决定将一个对象与另一个对象进行比较意味着什么。这里,在比较中只使用i值和j值 将被忽略。

get() 方法通过使用随机值初始化CompType对象来构建它们。在 main() 中,get()Arrays.setAll() 一起使用,以填充一个 CompType类型 数组,然后对其排序。如果没有实现 Comparable接口,那么当您试图调用 sort() 时,您将在运行时获得一个 ClassCastException 。这是因为 sort() 将其参数转换为 Comparable类型

现在假设有人给了你一个没有实现 Comparable接口 的类,或者给了你一个实现 Comparable接口 的类,但是你不喜欢它的工作方式而愿意有一个不同的对于此类型的比较方法。为了解决这个问题,创建一个实现 Comparator 接口的单独的类(在集合一章中简要介绍)。它有两个方法,compare()equals()。但是,除了特殊的性能需求外,您不需要实现 equals(),因为无论何时创建一个类,它都是隐式地继承自 ObjectObject 有一个equals()。您可以只使用默认的 Object equals() 来满足接口的规范。

集合类(注意复数;我们将在下一章节讨论它) 包含一个方法 reverseOrder(),它生成一个来 Comparator(比较器)反转自然排序顺序。这可以应用到比较对象:

您还可以编写自己的比较器。这个比较CompType对象基于它们的j值而不是它们的i值:

Arrays.sort 的使用

使用内置的排序方法,您可以对实现了 Comparable 接口或具有 Comparator 的任何对象数组 或 任何原生数组进行排序。这里我们生成一个随机字符串对象数组并对其排序:

注意字符串排序算法中的输出。它是字典式的,所以它把所有以大写字母开头的单词放在前面,然后是所有以小写字母开头的单词。(电话簿通常是这样分类的。)无论大小写,要将单词组合在一起,请使用 String.CASE_INSENSITIVE_ORDER ,如对sort()的最后一次调用所示。

Java标准库中使用的排序算法被设计为最适合您正在排序的类型----原生类型的快速排序和对象的归并排序。

并行排序

如果排序性能是一个问题,那么可以使用 Java 8 parallelSort(),它为所有不可预见的情况(包括数组的排序区域或使用了比较器)提供了重载版本。为了查看相比于普通的sort(), parallelSort() 的优点,我们使用了用来验证代码时的 JMH

parallelSort() 算法将大数组拆分成更小的数组,直到数组大小达到极限,然后使用普通的 Arrays .sort() 方法。然后合并结果。该算法需要不大于原始数组的额外工作空间。

您可能会看到不同的结果,但是在我的机器上,并行排序将速度提高了大约3倍。由于并行版本使用起来很简单,所以很容易考虑在任何地方使用它,而不是 Arrays.sort ()。当然,它可能不是那么简单—看看微基准测试。

binarySearch二分查找

一旦数组被排序,您就可以通过使用 Arrays.binarySearch() 来执行对特定项的快速搜索。但是,如果尝试在未排序的数组上使用 binarySearch(),结果是不可预测的。下面的示例使用 Rand.Pint 类来创建一个填充随机整形值的数组,然后调用 getAsInt() (因为 Rand.Pint 是一个 IntSupplier)来产生搜索值:

在while循环中,随机值作为搜索项生成,直到在数组中找到其中一个为止。

如果找到了搜索项,Arrays.binarySearch() 将生成一个大于或等于零的值。否则,它将产生一个负值,表示如果手动维护已排序的数组,则应该插入元素的位置。产生的值是 -(插入点) - 1 。插入点是大于键的第一个元素的索引,如果数组中的所有元素都小于指定的键,则是 a.size()

如果数组包含重复的元素,则无法保证找到其中的那些重复项。搜索算法不是为了支持重复的元素,而是为了容忍它们。如果需要没有重复元素的排序列表,可以使用 TreeSet (用于维持排序顺序)或 LinkedHashSet (用于维持插入顺序)。这些类自动为您处理所有的细节。只有在出现性能瓶颈的情况下,才应该使用手工维护的数组替换这些类中的一个。

如果使用比较器(原语数组不允许使用比较器进行排序)对对象数组进行排序,那么在执行 binarySearch() (使用重载版本的binarySearch())时必须包含相同的比较器。例如,可以修改 StringSorting.java 来执行搜索:

比较器必须作为第三个参数传递给重载的 binarySearch() 。在本例中,成功是有保证的,因为搜索项是从数组本身中选择的。

parallelPrefix并行前缀

没有“prefix()”方法,只有 parallelPrefix()。这类似于 Stream 类中的 reduce() 方法:它对前一个元素和当前元素执行一个操作,并将结果放入当前元素位置:

这里我们对数组应用Integer::sum。在位置0中,它将先前计算的值(因为没有先前的值)与原始数组位置0中的值组合在一起。在位置1中,它获取之前计算的值(它只是存储在位置0中),并将其与位置1中先前计算的值相结合。依次往复。

使用 Stream.reduce(),您只能得到最终结果,而使用 Arrays.parallelPrefix(),您还可以得到所有中间计算,以确保它们是有用的。注意,第二个 Stream.reduce() 计算的结果已经在 parallelPrefix() 计算的数组中。

使用字符串可能更清楚:

如前所述,使用流进行初始化非常优雅,但是对于大型数组,这种方法可能会耗尽堆空间。使用 setAll() 执行初始化更节省内存:

因为正确使用 parallelPrefix() 可能相当复杂,所以通常应该只在存在内存或速度问题(或两者都有)时使用。否则,Stream.reduce() 应该是您的首选。

本章小结

Java为固定大小的低级数组提供了合理的支持。这种数组强调的是性能而不是灵活性,就像C和c++数组模型一样。在Java的最初版本中,固定大小的低级数组是绝对必要的,这不仅是因为Java设计人员选择包含原生类型(也考虑到性能),还因为那个版本对集合的支持非常少。因此,在早期的Java版本中,选择数组总是合理的。

在Java的后续版本中,集合支持得到了显著的改进,现在集合在除性能外的所有方面都优于数组,即使这样,集合的性能也得到了显著的改进。正如本书其他部分所述,无论如何,性能问题通常不会出现在您设想的地方。

使用自动装箱和泛型,在集合中保存原生类型是毫不费力的,这进一步鼓励您用集合替换低级数组。由于泛型产生类型安全的集合,数组在这方面也不再有优势。

如本章所述,当您尝试使用泛型时,您将看到泛型对数组是相当不友好的。通常,即使可以让泛型和数组以某种形式一起工作(在下一章中您将看到),在编译期间仍然会出现“unchecked”警告。

有几次,当我们讨论特定的例子时,我直接从Java语言设计人员那里听到我应该使用集合而不是数组(我使用数组来演示特定的技术,所以我没有这个选项)。

所有这些问题都表明,在使用Java的最新版本进行编程时,应该“优先选择集合而不是数组”。只有当您证明性能是一个问题(并且切换到一个数组实际上会有很大的不同)时,才应该重构到数组。这是一个相当大胆的声明,但是有些语言根本没有固定大小的低级数组。它们只有可调整大小的集合,而且比C/C++/java风格的数组功能多得多。例如,Python有一个使用基本数组语法的列表类型,但是具有更大的功能—您甚至可以从它继承:

前一章介绍了基本的Python语法。在这里,通过用方括号包围以逗号分隔的对象序列来创建列表。结果是一个运行时类型为list的对象(print语句的输出显示为同一行中的注释)。打印列表的结果与在Java中使用Arrays.toString()的结果相同。 通过将 : 操作符放在索引操作中,通过切片来创建列表的子序列。list类型有更多的内置操作,通常只需要序列类型。 MyList是一个类定义;基类放在括号内。在类内部,def语句生成方法,该方法的第一个参数在Java中自动与之等价,除了在Python中它是显式的,而且标识符self是按约定使用的(它不是关键字)。注意构造函数是如何自动继承的。

虽然一切在Python中真的是一个对象(包括整数和浮点类型),你仍然有一个安全门,因为你可以优化性能关键型的部分代码编写扩展的C, c++或使用特殊的工具设计容易加速您的Python代码(有很多)。通过这种方式,可以在不影响性能改进的情况下保持对象的纯度。

PHP甚至更进一步,它只有一个数组类型,既充当int索引数组,又充当关联数组(Map)。

在经历了这么多年的Java发展之后,我们可以很有趣地推测,如果重新开始,设计人员是否会将原生类型和低级数组放在该语言中(同样在JVM上运行的Scala语言不包括这些)。如果不考虑这些,就有可能开发出一种真正纯粹的面向对象语言(尽管有这样的说法,Java并不是一种纯粹的面向对象语言,这正是因为它的底层缺陷)。关于效率的最初争论总是令人信服的,但是随着时间的推移,我们已经看到了从这个想法向更高层次的组件(如集合)的演进。此外,如果集合可以像在某些语言中一样构建到核心语言中,那么编译器就有更好的机会进行优化。

撇开““Green-fields”的推测不谈,我们肯定会被数组所困扰,当你阅读代码时就会看到它们。然而,集合几乎总是更好的选择。

最后更新于