Skip to content

Files

Latest commit

 

History

History
1468 lines (1073 loc) · 101 KB

File metadata and controls

1468 lines (1073 loc) · 101 KB

三、优化排序代码

在本章中,我们将开发排序代码并使其更通用。我们希望对更一般的内容进行排序,而不仅仅是字符串数组。基本上,我们将编写一个程序,可以排序任何可排序的。通过这种方式,我们将充分利用 Java 的一个主要优势——抽象

然而,抽象并不是没有价格标签的。当您有一个对字符串进行排序的类,并且您不小心将一个整数或其他非字符串的内容混合到可排序数据中时,编译器将对此进行抱怨。Java 不允许将int放入String数组。当代码更抽象时,这样的编程错误可能会溜进来。我们将研究如何通过捕获和抛出异常来处理此类异常情况。稍后,我们还将研究泛型,这是 Java 的一个特性,可以帮助在编译时捕获此类编程错误。

为了识别 bug,我们将使用单元测试,应用行业标准 JUnitVersion4。由于 JUnit 大量使用注释,而且由于注释很重要,我们还将了解一些注释。

之后,我们将修改代码以使用 Java 的泛型特性,该特性是在版本 5 中引入到语言中的。使用它,我们将捕获编译期间的编码错误。这比在运行时处理异常要好得多。越早发现 bug,修复的成本就越低。

对于构建,我们仍将使用 Maven,但这一次,我们将把代码分成几个小模块。因此,我们将有一个多模块的项目。对于排序模块的定义和不同的实现,我们将有单独的模块。这样,我们将了解类如何相互扩展和实现接口,通常,我们将真正开始以面向对象的方式编程。

我们还将讨论测试驱动开发TDD),在本节的最后,我们将开始使用版本 9 模块支持中引入的全新特性 Java。

在本章中,我们将介绍以下主题:

  • 面向对象编程原理
  • 单元测试实践
  • 算法复杂性与快速排序
  • 异常处理
  • 递归方法
  • 模块支持

通用排序程序

在上一章中,我们实现了一个简单的排序算法。代码可以对String数组的元素进行排序。我们这样做是为了学习。在实际应用中,JDK 中有一个现成的排序解决方案,可以对Collection对象中可比较的成员进行排序。

JDK 包含一个名为Collections的工具类,它本身包含一个静态方法Collections.sort。此方法可以对具有成员为Comparable的任何List进行排序(更准确地说,成员是实现Comparable接口的类的实例)。ListComparable是在 JDK 中定义的接口。因此,如果我们要对Strings列表进行排序,最简单的解决方案如下:

public class SimplestStringListSortTest {
    @Test
    public void canSortStrings() {
        var actualNames = new ArrayList(Arrays.asList(
                "Johnson", "Wilson",
                "Wilkinson", "Abraham", "Dagobert"
        ));
        Collections.sort(actualNames);
        Assert.assertEquals(new ArrayList<>(Arrays.asList(
                "Abraham", "Dagobert",
                "Johnson", "Wilkinson", "Wilson")),
                actualNames);
    }
}

这个代码片段来自一个示例 JUnit 测试,这就是我们在方法前面有@Test注解的原因。我们稍后将详细讨论。要执行该测试,我们可以发出以下命令:

$ mvn -Dtest=SimplestStringListSortTest test

然而,这种实现并不能满足我们的需要。主要原因是我们想学些新东西。使用 JDK 的sort()方法并没有教给您任何新的东西,除了该方法前面的@Test注解。

如果在前面的代码中有一些您无法理解的内容,那么您可以在本书中翻回一些页面,并查阅 JDK 的 Oracle 在线文档,但仅此而已。你已经知道这些事情了。

您可能想知道为什么我要将 JavaVersion9API 的 URL 写到链接中。好吧,现在是我写这本书时诚实和真实的时刻,Java11JDK 还没有最终的版本。事实上,甚至 Java10JDK 也只是预发布的。在第一版中,我在 MacBook 上使用 Java8 创建了大多数示例,后来我只测试了 Java10、10 或 11 特定的特性。当您阅读本书时,Java8 将可用,因此您可以尝试将 URL 中的一个数字从 9 改为 11,并获得版本 11 的文档。目前,我得到 HTTP 错误 404。有时,您可能需要旧版本的文档。您可以在 URL 中使用 3、4、5、6、7、8 或 9 而不是 11。3 和 4 的文档不能在线阅读,但可以下载。希望你永远都不需要。也许是第五版。第 6 版在本书第一版出版时仍被大公司广泛使用,自那以后没有太大变化。

尽管您可以从阅读其他程序员编写的代码中学到很多,但我不建议您在学习的早期阶段尝试从 JDK 源代码中学习。这些代码块经过了大量优化,不是教程代码,而且它们很旧。它们不会生锈,但是它们没有被重构以遵循 Java 成熟时更新的编码风格。在某些地方,您可以在 JDK 中找到一些非常难看的代码。

好吧,说我们需要开发一个新的,因为我们可以从中学习,这有点自作主张。我们需要一个排序实现的真正原因是我们想要的东西不仅可以对List数据类型和实现Comparable接口的东西进行排序,我们想要对一组对象进行排序。我们所需要的是,包含对象的提供了简单的方法,这些方法足以对它们进行排序,并有一个已排序的

最初我想用单词集合来代替,但是 Java 中有一个Collection接口,我想强调的是,我们不是在讨论对象的java.util.Collection

我们也不希望对象实现Comparable接口。如果我们要求对象实现Comparable接口,可能违反单一责任原则SRP)。

当我们设计一个类时,它应该对现实世界中的某个对象类进行建模。我们将用类来建模问题空间。类应该实现表示它所建模的对象行为的特性。如果我们看第二章学生的例子,那么一个Student类应该代表所有学生共享的特征,从建模的角度来看是重要的。一个Student对象应该能够说出学生的名字、年龄、去年的平均分数等等。但是,我们应该关注与我们的编程需求相关的特性。例如,所有学生都有脚,当然,每只脚都有一个大小,所以我们可能认为一个Student类也应该实现一个返回学生脚大小的方法。为了突出荒谬之处,我们可以实现数据结构和 API,为左脚注册一个大小,为右脚注册一个不同的大小。我们没有,因为脚的大小与模型的观点无关。

但是,如果我们想要对包含Student对象的列表进行排序,Student类必须实现Comparable接口。但是等等!你如何比较两个学生?按姓名、年龄或平均分数?

把一个学生和另一个学生作比较并不是这个类的基本特征。每个类或包、库或编程单元都应该有一个职责,它应该只实现这个职责,而不实现其他职责。这并不确切。这不是数学。有时,很难判断一个特性是否适合这个职责。可比性可能是某些数据类型的固有特征,例如IntegerDouble。其他类没有这种固有的比较特性。

有一些简单的技术可以确定特性是否应该是类的一部分。例如,对于一个学生,你可以问真人他们的名字和年龄,他们也可以告诉你他们的平均分。如果你让他们中的一个去compareTo(另一个学生),因为Comparable接口需要这个方法,他们很可能会问,“用什么属性或者怎么做?”如果他们不是有礼貌的类型,他们可以简单地回答“什么?”(更不用说缩写 WTF,它代表一周的最后三个工作日,在这种情况下很流行。)在这种情况下,您可能会怀疑实现该特性可能不在该类及其关注的领域;比较应该与原始类的实现分离开来。这也称为关注点分离,与 SRP 密切相关。

JDK 开发人员知道这一点。对Comparable元素中的List进行排序的Collections.sort并不是此类中唯一的排序方法。另一种方法是,如果传递第二个参数,则对任何List进行排序,该参数应该是实现Comparator接口的对象,并且能够比较List的两个元素。这是分离关注点的干净模式。在某些情况下,不需要分离比较。在其他情况下,这是可取的。Comparator接口声明了实现类必须提供的一个方法—compare。如果两个参数相等,则方法返回0。如果它们不同,它应该返回一个否定或肯定的int,这取决于哪个参数在另一个参数之前。

JDK 类java.util.Arrays中还有sort方法。它们对数组排序或仅对数组的一部分排序。该方法是方法重载的一个很好的例子。有一些方法具有相同的名称,但参数不同,可以对每个原始类型的整个数组进行排序,也可以对每个原始类型的片进行排序,还有两个方法用于实现Comparable接口的对象数组,还可以用于使用Comparator进行排序的对象数组。如您所见,JDK 中提供了一系列排序实现,在 99% 的情况下,您不需要自己实现排序。排序使用相同的算法,一个稳定的合并排序和一些优化。

我们要实现的是一种通用的方法,它可以用来排序列表、数组,或者任何有元素的东西,我们可以在比较器的帮助下进行比较,并且可以交换任意两个元素。我们将实现可用于这些接口的不同排序算法。

各种排序算法的简要概述

有许多不同的排序算法。正如我所说,有更简单和更复杂的算法,在许多情况下,更复杂的算法运行得更快。(毕竟,如果更高复杂度的算法运行得更慢,它会有什么好处?)在本章中,我们将实现冒泡排序和快速排序。在上一章中,我们已经实现了字符串的冒泡排序,因此在本例中,实现将主要集中在一般可排序对象排序的编码上。实现快速排序需要一些算法方面的兴趣。

请注意,本节只是让您体验一下算法的复杂性。这是远远不够精确,我在徒劳的希望,没有数学家阅读这一点,并把诅咒我。有些解释含糊不清。如果你想深入学习计算机科学,那么在读完这本书之后,找一些其他的书或者访问在线课程。

当我们讨论一般排序问题时,我们考虑的是一些对象的一般有序集合,其中任意两个对象可以在排序时进行比较和交换。我们还假设这是一种原地排序。这意味着我们不会创建另一个列表或数组来按排序顺序收集原始对象。当我们谈论算法的速度时,我们谈论的是一些抽象的东西,而不是毫秒。当我们想谈论毫秒时,实际的持续时间,我们应该已经有了一个在真实计算机上运行的编程语言的实现。

没有实现的抽象形式的算法不会这样做。不过,一个算法的时间和内存需求还是值得讨论的。当我们这样做的时候,我们通常会研究算法对于大量数据的行为。对于一小部分数据,大多数算法都很快。排序两个数字通常不是问题,是吗?

在排序的情况下,我们通常检查需要多少比较来对n个元素的集合进行排序。冒泡排序大约需要nn次)比较。我们不能说这就是,因为在n=2的情况下,结果是 1,n=3是 3,n=4是 6,依此类推。然而,随着n开始变大,实际需要的比较次数和将逐渐地具有相同的值。我们说冒泡排序的算法复杂度是O(n²)。这也称为大 O 表示法。如果你有一个算法是O(n²),它只适用于 1000 个元素,在一秒钟内完成,那么你应该期望同样的算法在大约 10 天到一个月内完成 100 万个元素。如果算法是线性的,比如说O(n),那么在一秒钟内完成 1000 个元素应该会让你期望在 1000 秒内完成 100 万个元素。这比喝咖啡的时间长一点,但午餐时间太短了。

这使得如果我们想要一些严肃的业务排序对象,我们需要比冒泡排序更好的东西成为可能。许多不必要的比较不仅浪费了我们的时间,而且浪费了 CPU 的能量,消耗了能源,污染了环境。

然而,问题是排序的速度有多快?有没有一个可以证明的最低限度,我们不能减少?

答案是肯定的,有一个可证明的最低限度。这一点的基础非常有趣,在我看来,每个 IT 工程师不仅应该知道实际答案,而且还应该知道背后的原因。毕竟,必要最小值的证明,只不过是纯粹的信息。下面,再次,不是一个数学证明,只是一种模糊的解释。

当我们实现任何排序算法时,实现将执行比较和元素交换。这是对对象集合进行排序的唯一方法,或者至少所有其他可能的方法都可以简化为以下步骤。比较的结果可以有两个值。假设这些值是01。这是一点信息。如果比较结果为1,则我们交换;如果比较结果为0,则我们不交换。

在开始比较之前,我们可以将对象按不同的顺序排列,不同的顺序数是n!n阶乘),即数字从 1 乘到n,换言之,n! = 1 x 2 x 3 x ... x (n - 1) x n

假设我们将单个比较的结果存储在一个数字中,作为排序中每个可能输入的一系列位。现在,如果我们反转排序的执行,从排序后的集合开始运行算法,用描述比较结果的位来控制交换,用另一种方式来控制交换,先进行最后一次交换,再进行排序时首先进行的交换,我们应该恢复物品原来的顺序。这样,每个原始顺序都与一个表示为位数组的数字唯一关联。

现在,我们可以用这种方式来表达最初的问题,描述n阶乘不同的数需要多少位?这正是我们需要对n元素进行排序的比较数。

要区分n!的位数,数字log2(n!)。用一些数学,我们会知道log2(n!)等于log2(1) + log2(2) + ... + log2(n)。如果我们看这个表达式的渐近值,那么我们可以说这与O(n * logn)一样的。我们不应该期望任何通用的排序算法更快。

对于特殊情况,有更快的算法。例如,如果我们要对 100 万个数字进行排序,每个数字都在 1 到 10 之间,那么我们只需要对不同的数字进行计数,然后创建一个包含那么多个 1、2 等等的集合。这是一个O(n)算法,但并不普遍适用。

同样,这不是一个正式的数学证明。

快速排序

查尔斯·安东尼·理查德·霍尔爵士于 1959 年开发了快速排序算法。它是一种典型的分治算法。事情是这样的。

要对长数组进行排序,请从数组中选择一个元素,该元素将成为所谓的枢轴元素。然后,对数组进行分区,使左侧包含所有小于轴的元素,右侧包含所有大于或等于轴的元素。当我们开始分区时,我们不知道左边会有多长,右边会从哪里开始。我们解决这个问题的精确方法将很快解释。现在,重要的是我们要将一个数组进行划分,以便从数组开始到某个索引的元素都小于轴,从那里到数组结束的元素都大于轴。这还有一个简单的结果,左边的元素都比右边的任何元素都小。这已经是偏序了。因为枢轴是从数组中选择的,所以可以保证任何一方都不能包含整个原始数组,使另一方成为空数组。

完成此操作后,可以通过递归调用排序来排序数组的左右两侧。在这些调用中,子数组的长度总是小于上一级的整个数组。当我们要排序的实际级别的数组段中有一个元素时,我们停止递归。在这种情况下,我们可以从递归调用返回,而不需要比较或重新排序;显然,一个元素总是排序的。

当算法部分地使用自身定义时,我们讨论递归算法。最著名的递归定义是斐波那契级数,0 和 1 表示前两个元素,而对于所有后续元素,第n个元素是第n-1和第n-2个元素的总和。递归算法通常在现代编程语言中实现,实现的方法进行一些计算,但有时会调用自身。在设计递归算法时,最重要的是要有停止递归调用的东西;否则,递归实现将为程序栈分配所有可用内存,当内存耗尽时,它将以错误停止程序。

算法的分区部分按照以下方式进行:我们将开始使用从开始到结束的两个索引来读取数组。我们将首先从一个小的索引开始,然后增加索引,直到它小于大的索引,或者直到找到一个大于或等于轴的元素。在此之后,我们将开始减少较大的索引,只要它大于较小的索引,并且索引的元素大于或等于轴。当我们停止时,我们交换两个索引所指向的两个元素。如果指数不一样,我们开始分别增加和减少小指数和大指数。如果索引是相同的,那么我们就完成了分区。数组的左侧是从开始到索引相接处的索引减 1;右侧是从要排序的数组末尾的索引结束处开始。

这种快速排序算法通常消耗O(n logn)时间,但在某些情况下,它可以退化为O(n²),具体取决于如何选择枢轴。例如,如果我们选择数组段的第一个元素作为轴心,并且数组已经排序,那么这种快速排序算法将退化为简单的冒泡排序。为了修正这一点,有不同的方法来选择轴心。在本书中,我们将使用最简单的方法选择可排序集合的第一个元素作为轴心。

项目结构和构建工具

这次的项目将包含许多模块。在本章中,我们仍将使用 Maven。我们将在 Maven 中建立一个所谓的多模块项目。在这样的项目中,目录包含了模块和pom.xml的目录。此顶级目录中没有源代码。此目录中的pom.xml文件有以下两个用途:

  • 它引用模块,可以用来编译、安装和部署所有模块
  • 它为所有模块定义相同的参数

每个pom.xml都有一个父级,这个pom.xml是模块目录中pom.xml文件的父级。为了定义模块,pom.xml文件包含以下行:

<modules>
    <module>SortSupportClasses</module>
    <module>SortInterface</module>
    <module>bubble</module>
    <module>quick</module>
    <module>Main</module>
</modules>

这些是模块的名称。这些名称用作目录名,在pom.xml模块中也用作artifactId。此设置中的目录如下所示:

$ tree
   |-SortInterface
   |---src/main/java/packt/java189fundamentals/ch03
   |-bubble
   |---src
   |-----main/java/packt/java189fundamentals/ch03/bubble
   |-----test/java/packt/java189fundamentals/ch03/bubble
   |-quick/src/
   |-----main/java
   |-----test/java

Maven 依赖关系管理

依赖项在 POM 文件中也扮演着重要的角色。上一个项目没有任何依赖项。这次我们将使用 JUnit,所以我们依赖于 JUnit。依赖项在pom.xml文件中使用dependencies标记定义。例如,冒泡排序模块包含以下代码:

<dependencies>
    <dependency>
        <groupId>packt.java189fundamentals</groupId>
        <artifactId>SortInterface</artifactId>
    </dependency>
    <dependency>
        <groupId>junit</groupId>
        <artifactId>junit</artifactId>
    </dependency>
</dependencies>

您可以下载的代码集中的实际pom.xml将包含比这个更多的代码。在印刷品中,我们通常会呈现一个版本或只是一小部分,有助于理解我们当时讨论的主题。

它告诉 Maven 模块代码使用类、接口和enum类型,这些类型是在存储库中可用的模块或库中定义的。

使用 Maven 编译代码时,代码使用的库可以从存储库中获得。当 Ant 被开发出来时,存储库的概念还没有被发明出来。当时,开发人员将库的版本复制到源代码结构中的文件夹中。通常,lib目录用于此目的。

这种方法有两个问题:一个是源代码存储库的大小。例如,如果 100 个不同的项目使用 JUnit,那么 JUnit 库的 JAR 文件被复制了 100 次。另一个问题是收集所有的库。当一个库使用另一个库时,开发人员必须阅读该库的文档,这些文档描述了使用该库所需的其他库。这往往是过时和不准确的。这些库必须以同样的方式下载和安装。这既耗时又容易出错。当库丢失而开发人员没有注意到它时,错误就会在编译时出现。如果依赖关系只能在运行时检测到,那么 JVM 就无法加载类。

为了解决这个问题,Maven 提供了一个内置的仓库管理器客户端。存储库是包含库的存储。由于存储库中可能有其他类型的文件,而不仅仅是库,Maven 术语是工件groupIdartifactIdversion数字标识伪影。有一个非常严格的要求,工件只能放入存储库一次。即使在发布过程中有一个错误在错误的发布被上传后被识别,工件也不能被覆盖。对于相同的groupIdartifactIdversion,只能有一个永远不会更改的文件。如果存在错误,则使用新版本号创建一个新工件,并且可以删除错误工件,但永远不会替换。

如果版本号以-SNAPSHOT结尾,则不保证或要求此唯一性。快照通常存储在单独的存储库中,不会发布到世界。

存储库包含以定义的方式组织的目录中的工件。当 Maven 运行时,它可以使用https协议访问不同的存储库。

以前,也使用了http协议。对于非付费客户,如自由/开源软件开发者,中央存储库只能通过http使用。然而,人们发现从存储库下载的模块可能会成为中间人安全攻击的目标,因此 Sonatype 将策略更改为仅使用https协议。千万不要配置或使用具有https协议的存储库,也不要信任通过 HTTP 下载的文件。

开发人员的机器上有一个本地存储库,通常位于~/.m2/repository目录中。在 Windows 上,用户的主目录通常是C:\Users\your_username。在 Unix 操作系统上,Shell 类似于 Windows 命令提示符应用,它使用~字符来引用这个目录。当您发出mvn install命令时,Maven 将创建的工件存储在这里。Maven 还通过 HTTPS 从存储库下载工件时,将其存储在此处。这样,后续的编译就不需要到网络上查找工件了。

公司通常会建立自己的存储库管理器。这些应用可以配置为与其他几个存储库通信,并根据需要从那里收集工件,基本上实现代理功能。工件以层次结构从远端存储库到更近的构建,到本地回购,如果项目的包装类型为warear,或者包含相关工件的其他格式,则构件将从更近的存储库转移到本地回购,实质上也会传递到最终工件。这基本上是文件缓存,不需要重新验证和缓存驱逐。这可以做到,因为工件永远不会被替换。

如果bubble项目是一个独立的项目,而不是多模块项目的一部分,那么依赖关系如下所示:

<dependencies>
    <dependency>
        <groupId>packt.java189fundamentals</groupId>
        <artifactId>SortInterface</artifactId>
        <version>1.0.0-SNAPSHOT</version>
    </dependency>
    <dependency>
        <groupId>junit</groupId>
        <artifactId>junit</artifactId>
        <version>4.12</version>
    </dependency>
</dependencies>

如果没有为依赖项定义version,Maven 将无法识别要使用的工件。如果是多模块项目,version可以在父级定义,模块继承版本。因为父对象不依赖于实际的工件,所以它应该只定义附加到groupIdartifactId的版本。因此,XML 标记不是dependencies,而是顶层project标记中的ddependencyManagement/dependencies,如下例所示:

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>packt.java189fundamentals</groupId>
            <artifactId>SortSupportClasses</artifactId>
            <version>${project.version}</version>
        </dependency>
        <dependency>
            <groupId>packt.java189fundamentals</groupId>
            <artifactId>SortInterface</artifactId>
            <version>${project.version}</version>
        </dependency>
        <dependency>
            <groupId>packt.java189fundamentals</groupId>
            <artifactId>quick</artifactId>
            <version>${project.version}</version>
        </dependency>
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.12</version>
            <scope>test</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

当模块要使用junit时,不需要指定版本。他们将从定义为 4.12 的父项目中获得它,这是 junit4 中的最新版本。如果有一个新版本,4.12.1,修复了一些严重的错误,那么修改版本号的唯一地方就是父 POM,当 Maven 执行下一步时,模块将使用新版本。

然而,当项目开发人员决定使用新的 JUnit 5 版本时,所有的模块都会被修改,因为 JUnit 5 不仅仅是一个新版本。junit5 与老版本 4 有很大的不同,它被分成几个模块。这样,groupIdartifactId也会改变。

还值得注意的是,实现来自SortInterface模块的接口的模块最终依赖于该模块。在这种情况下,版本定义如下:

<version>${project.version}</version>

这似乎有点重复(实际上是)。${project.version}属性是项目的版本,SortInterface模块继承这个值。这是其他模块所依赖的工件的版本。换句话说,模块总是依赖于我们当前开发的版本。

编写排序

为了实现排序,首先,我们将定义库应该实现的接口。在实际编码之前定义接口是一种很好的做法。当有许多实现时,有时建议首先创建一个简单的实现并开始使用它,这样接口就可以在这个开发阶段发展,当更复杂的实现到期时,接口就已经固定了。实际上,没有什么是固定的,因为编程中没有阿基米德点。

创建接口

本例中的接口非常简单:

public interface Sort {
    void sort(Sortable collection);
}

接口应该只做一件事,对可排序的内容进行排序。因此,我们定义了一个接口,实现这个接口的任何类都将是Sortable

public interface Sortable {
}

创建冒泡排序

现在,我们可以开始创建实现Sort接口的冒泡排序:

 ...
import java.util.Comparator;

public class BubbleSort implements Sort, SortSupport {
    @Override
    public void sort(Sortable collection) {
        var n = collection.size();
        while (n > 1) {
            for (int j = 0; j < n - 1; j++) {
                if (comparator.compare(collection.get(j),
                        collection.get(j + 1)) > 0) {
                    swapper.swap(j, j + 1);
                }
            }
            n--;
        }
    }
 ...

通常,算法需要两个操作。我们实现了一个比较两个元素并交换两个元素的数组。然而,这次排序实现本身并不知道应该对什么类型进行排序。它也不知道元素是如何存储的。它可以是数组、列表或其他一些。它知道它可以比较元素,而且它还可以交换两个元素。如果提供了这些,那么排序工作。

在 Java 术语中,它需要一个能够比较两个元素的comparator对象,需要一个能够交换集合中两个元素的swapper对象。

排序对象应该可以访问这些对象。拥有两个引用这些对象的字段是完美的解决方案。唯一的问题是字段如何获得对比较和交换对象的引用。我们现在遵循的解决方案是,我们提供了可以用来将这些依赖项注入排序对象的设置器。

这些设置器并不特定于冒泡排序算法。这些是相当一般的;因此,定义一个冒泡排序可以实现的接口是有意义的:

public interface SortSupport {
    void setSwapper(Swapper swap);

    void setComparator(Comparator compare);
}

BubbleSort类中的实现只是以下代码:

    private Comparator comparator = null;

    @Override
    public void setComparator(Comparator comparator) {
        this.comparator = comparator;
    }

    private Swapper swapper = null;

    @Override
    public void setSwapper(Swapper swapper) {
        this.swapper = swapper;
    }

@Override注解向 Java 编译器发出信号,表示该方法正在覆盖父类的方法,或者在本例中覆盖接口的方法。方法可以覆盖没有此注释的父方法;但是,如果使用注释,如果方法没有覆盖,编译将失败。这有助于您在编译时发现父类或接口中发生了更改,而我们在实现中没有遵循该更改,或者我们只是犯了一个错误,认为我们将覆盖一个方法,而实际上我们没有这样做。由于注释在单元测试中大量使用,我们将在后面更详细地讨论注释。

这也意味着我们需要两个新接口-SwapperComparator。我们很幸运,Java 运行时已经定义了一个正好符合目的的Comparator接口。您可能已经从下面的import语句中猜到了:

import java.util.Comparator;

当您需要一些非常基本的东西时,比如一个Comparator接口,它很可能是在运行时定义的。在编写自己的版本之前,最好先查阅运行时。但是,Swapper接口必须创建:

public interface Swapper {
    void swap(int i, int j);
}

由于它用于交换Sortable中索引指定的两个元素,因此有一种方法非常明显地命名为swap。但我们还没有准备好。如果您试图编译前面的代码,编译器会抱怨getget方法。算法需要它们来实现排序,但它们本身并不是排序本身的一部分。这是不应在排序中实现的功能。由于我们不知道将对哪种类型的集合进行排序,因此在排序中实现这些方法不仅是不可取的,而且也是不可能的。看来我们什么都分类不了。我们必须设置一些限制。排序算法必须知道我们排序的集合的大小,并且还应该通过索引访问元素,以便它可以将其传递给比较器。这些似乎是我们通常可以接受的相当合理的限制。

这些限制在Sortable接口中表示,我们刚刚将其留空,在第一个排序实现之前不知道需要什么:

public interface Sortable {
    Object get(int i);
    int size();
}

现在,我们已经准备好了接口和实现,可以继续测试代码了。但是,在此之前,我们将简要重申我们所做的以及我们为什么这样做。

架构考虑

我们创建了一个接口和一个简单的实现。在实现过程中,我们发现该接口需要支持该算法的其他接口和方法。这通常发生在代码的架构设计期间,在实现之前。出于说教的原因,我在开发代码时遵循了接口的构建。在现实生活中,当我创建接口时,我一步就创建了它们,因为我有足够的经验。我在 1983 年左右用 FORTRAN 编写了第一个快速排序代码。然而,这并不意味着我只是用任何问题来击中靶心,并给出最终的解决方案。碰巧这类问题太有名了。如果在开发过程中需要修改接口或设计的其他方面,请不要感到尴尬。这是一个自然的结果,也是一个证明,随着时间的推移,你对事物的理解会越来越好。如果架构需要更改,那么最好是这样做,而且越快越好。在实际的企业环境中,我们设计接口只是为了在开发过程中了解一些我们忘记的方面。它们的操作比排序集合要复杂一些。

在排序问题的例子中,我们抽象了我们想要排序到最可能的极限的东西。Java 内置的排序可以对数组或列表进行排序。如果要对不是列表或数组的对象进行排序,则必须创建一个类来实现java.util.List接口,该接口包含 24 个以上的方法,这些方法用于包装可排序对象,使其可以通过 JDK 排序。24 种方法似乎有很多,只是为了让我们的变得有点可分性。老实说,这并不是太多,在一个真实的项目中,我会把它作为一个选择。

我们不知道,也不知道,内置排序使用什么接口方法。那些应该在功能上实现的语句被使用,而那些语句可以包含一个简单的return语句,因为它们从未被调用,所以没有被使用。开发人员可以查阅 JDK 的源代码并查看实际使用的方法,但这不是搜索实现的契约。不能保证新版本仍然只使用这些方法。如果一个新版本开始使用我们用一个return语句实现的方法,排序将神奇地失败。

另外一个有趣的性能问题是,如何通过只使用List接口的搜索来实现两个元素的交换。List接口中没有put(int, Object)方法。有add(int, Object),但它插入了一个新元素,如果对象存储在磁盘上,那么将列表中的所有元素向上推可能会非常昂贵(消耗 CPU、磁盘、能量)。此外,下一步可能是删除我们刚刚插入的元素之后的元素,再次移动列表尾部的代价高昂。这就是put(int, Object)的琐碎实现。排序可能跟在后面,也可能跟不上。同样,这是不应该假设的。

当您使用来自 JDK、开源或商业库的库、类和方法时,您可以参考源代码,但不应依赖于实现。您应该只依赖于该库附带的 API 的契约和定义。当您从某个外部库实现一个接口时,您不需要实现它的某些部分,也不需要创建一些虚拟方法,您会感到危险。这是埋伏。很可能是库质量不好,或者你不知道如何使用它。我不知道哪个更糟。

在我们的例子中,我们将交换和比较与排序分开。集合应该实现这些操作并为排序提供它们。契约就是接口,要使用排序,必须实现我们定义的接口的所有方法。

SortSupport的接口定义了设置SwapperComparator的设置器。以这种方式设置依赖项可能会导致代码创建实现SortSortSupport接口的类的新实例,但在调用Sort之前不设置SwapperComparator。这将导致在第一次调用Comparator时调用NullPointerException(或者在实现首先调用Swapper时调用Swapper,这不太可能,但可能)。调用方法应该在使用类之前注入依赖项。通过设定器进行时,称为设置器注入。当我们使用诸如 Spring、Guice 或其他容器之类的框架时,大量使用这个术语。创建这些服务类并将实例注入到我们的类中一直是相当相似的。

容器实现以一般方式包含功能,并提供配置选项来配置要注入到其他对象中的实例。通常,这会导致代码更短、更灵活、更可读。然而,依赖注入并不是容器独有的。当我们在下一节中编写测试代码并调用设置器时,实际上是手动执行依赖注入。

还有另一种依赖注入方法可以避免未设置依赖的问题。这叫做构造器注入。在这种情况下,依赖项通常是没有值的final private字段。请记住,这些字段应在对象完全创建时获得其最终值。构造器注入将注入的值作为参数传递给构造器,构造器设置字段。这样,就可以保证在构建对象时设置字段。但是,这种注入不能在接口中定义,这在某些应用中可能是问题,也可能不是问题。

现在,我们已经有了代码,并且我们知道如何创建接口。是时候做些测试了。

创建单元测试

当我们编写代码时,我们应该测试它。至少在进行一些测试运行之前,还没有任何代码进入生产环境。(承认讽刺!)不同级别的测试有不同的目标、技术、行业实践和名称。

顾名思义,单元测试测试一个代码单元。集成测试测试单元如何集成在一起。冒烟测试测试一组有限的特性,只是为了看看代码是否完全被破坏。还有其他的测试,直到最后的测试,这是用户验收测试工作的证明。布丁的证据就在吃的时候。如果用户接受代码,那么代码就是好的。

很多时候,我告诉年轻人,名称“用户验收测试”有点误导,因为接受项目结果的不是用户,而是客户。顾名思义,顾客就是付账的人。专业发展是有报酬的,否则就不专业了。然而,术语是用户验收测试。碰巧的是,只有用户能够使用这个程序,客户才会接受这个项目。

当我们用 Java 开发时,单元测试测试独立类。换句话说,在 Java 开发中,当我们讨论单元测试时,单元是一个类。为了提供单元测试,我们通常使用 JUnit 库。还有其他的库,比如 TestNG,但是 JUnit 是使用最广泛的库,所以我们将使用 JUnit。要将它用作库,首先,我们必须将它作为依赖项添加到 Maven POM 中。

添加 JUnit 作为依赖项

回想一下,我们有一个多模块项目,依赖版本在父 POM 中的dependencyManagement标记中维护:

<dependencyManagement>
    <dependencies>
        ...
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.12</version>
            <scope>test</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

依赖关系的范围是test,这意味着只有在编译测试代码和执行测试时才需要这个库。JUnit 库不会进入最终发布的产品;不需要它。如果在已部署的生产 Web 存档WAR)或企业存档EAR)文件中发现 JUnit 库,请怀疑有人没有正确管理库的范围。

Maven 支持在项目生命周期中编译和执行 JUnit 测试。如果我们只想执行测试,我们应该发出mvn test命令。IDEs 还支持执行单元测试。通常,可以使用相同的菜单项来执行具有public static main()方法的类。如果该类是一个使用 JUnit 的单元测试,IDE 将识别它并执行测试,并且通常给出图形化的反馈,说明哪些测试执行得很好,哪些测试失败,以及如何执行。

编写BubbleSortTest

测试类与生产类分开。他们进入src/test/java目录。当我们有一个名为BubbleSort的类时,那么测试将被命名为BubbleSortTest。此约定有助于执行环境将测试与不包含测试但执行测试所需的类分开。为了测试我们刚刚创建的排序实现,我们可以提供一个类,该类目前只包含一个canSortStrings方法。

单元测试方法名称用于记录正在测试的功能。由于 JUnit 框架调用每个具有@Test注解的方法,因此测试的名称在我们的代码中不会被引用。我们可以大胆地使用任意长的方法名;它不会妨碍调用方法的地方的可读性:

package packt.java189fundamentals.ch03.main.bubble.simple;

// import statements are deleted from the print for brevity

public class BubbleSortTest {
    @Test
    public void canSortStrings() {
        var actualNames = new ArrayList(Arrays.asList(
            "Johnson", "Wilson",
            "Wilkinson", "Abraham", "Dagobert"
        ));

该方法包含一个ArrayList,其中包含我们已经熟悉的实际名称。由于我们有一个需要Sortable的排序实现和接口,我们将创建一个由ArrayList备份的排序实现和接口:

var names = new Sortable() {
    @Override
    public Object get(int i) {
        return actualNames.get(i);
    }
    @Override
    public int size() {
        return actualNames.size();
    }
};

我们声明了一个新对象,它具有Sortable类型,它是一个接口。要实例化实现Sortable的东西,我们需要一个类。我们无法实例化接口。在这种情况下,在实例化的位置定义类。这在 Java 中称为匿名类。名称来自于源代码中未定义新类的名称。Java 编译器将自动为新类创建一个名称,但这对程序员来说并不有趣。我们只需写new Sortable()并在{}之间立即提供所需的实现。在方法中定义这个匿名类非常方便,这样,它可以访问ArrayList,而不需要在类中传递对ArrayList的引用。

事实上,引用是需要的,但是 Java 编译器会自动补全这项工作。在本例中,Java 编译器还注意到,以这种方式传递的自动引用只能使用初始化的变量来完成,并且在匿名类实例化之后的代码执行期间不会更改。actualNames变量已设置,以后方法中不应更改。事实上,我们甚至可以将actualNames定义为final,如果我们使用 Java1.7 或更早版本,这将是一个要求。从 1.8 开始,要求变量实际上是final,我们可以跳过final声明。

接下来我们需要的是ArrayListSwapper实现。在这种情况下,我们将在方法中定义一个完整的类。它也可以是一个匿名类,但这次我决定使用一个命名类来演示一个类可以在一个方法中定义。通常,我们在生产项目中不会这样做:

class SwapActualNamesArrayElements implements Swapper {
    @Override
    public void swap(int i, int j) {
        final Object tmp = actualNames.get(i);
        actualNames.set(i, actualNames.get(j));
        actualNames.set(j, tmp);
    }
}
;

最后,但并非最不重要的是,在调用排序之前,我们需要一个比较器。正如我们有String要比较的,这是简单而直接的:

Comparator stringCompare = new Comparator() {
    @Override
    public int compare(Object first, Object second) {
        final String f = (String) first;
        final String s = (String) second;
        return f.compareTo(s);
    }
};

在为排序做了一切准备之后,我们最终需要一个Sort实现的实例。我们必须设置SortSort,最后调用sort

var sort = new BubbleSort();
sort.setComparator(stringCompare);
sort.setSwapper(new SwapActualNamesArrayElements());
sort.sort(names);

测试的最后但最重要的部分是断言结果是我们期望的结果。JUnit 在Assert类的帮助下帮助我们做到这一点:

Assert.assertEquals(List.of(
    "Abraham", "Dagobert",
    "Johnson", "Wilkinson", "Wilson"
), actualNames);

assertEquals的调用检查第一个参数,即预期结果,是否等于第二个参数,即排序后的actualNames。如果它们不同,则抛出一个AssertionError,否则,测试就可以结束了。

良好的单元测试

这是一个好的单元测试吗?如果你在这样一本教程里读到它,那一定是。其实不是。这是一个很好的代码来演示 JUnit 提供的一些工具和一些 Java 语言特性,但我不会在专业项目中使用它。

什么使单元测试好?为了回答这个问题,我们必须定义单元测试的用途。单元测试有两个目的。单元测试的目的是验证单元的正确功能并记录它。

单元测试不用于发现 bug。开发人员最终会在调试会话期间使用单元测试,但很多时候,为调试创建的测试代码是临时的。当 bug 修复后,用于查找它的代码将不会进入源代码存储库。对于每一个新的 bug,都应该创建一个新的测试来覆盖不能正常工作的功能,但是很难使用测试代码来查找 bug。这是因为单元测试主要用于文档。您可以使用 JavaDoc 对类进行文档化,但经验表明,文档化常常会过时。开发人员修改代码,但不修改文档。文件变得过时和具有误导性。然而,单元测试是由构建系统执行的,如果持续集成CI)正在使用(在专业环境中应该是这样),那么如果测试失败,构建将被破坏。所有的开发人员都会收到一封关于它的邮件通知,它会促使开发人员破坏构建来修复代码或测试。通过这种方式,测试在持续集成过程中验证代码没有被破坏,至少,没有使用单元测试可以发现的东西。

一个好的单元测试是可读的

我们的测试远没有可读性。一个测试用例是可读的,如果你看它,在 15 秒内你可以告诉它做什么。当然,它假设读者有一些 Java 方面的经验,但你明白这一点。我们的测试充斥着不是测试核心的支持类。

我们的测试也很难验证代码是否正常工作。实际上没有。其中有一些我故意放在那里的 bug,我们将在下面几节中找到并消除它们。对单个String数组进行排序的单个测试远远不能验证排序实现。如果我要将这个测试扩展到一个真实世界的测试,我们需要名称为canSortEmptyCollectioncanSortOneElementCollectioncanSortTwoElementscanSortReverseOrdercanSortAlreadySorted的方法。如果你看这些名字,你就会知道我们需要什么样的测试。由于排序问题的性质,实现可能对这些特殊情况下的错误相当敏感。

除了作为一个可接受的演示工具之外,我们的单元测试还有哪些优点?

单元测试很快

我们的单元测试运行得很快。当我们每次执行单元测试时,CI 启动一个构建,测试的执行不会持续太久。您不应该创建一个对数十亿个元素进行排序的单元测试。这是一种稳定性试验或负荷试验。它们应该在单独的测试期间运行,而不是每次构建运行时都运行。我们的单元测试对五个元素进行排序,这是合理的。

单元测试是确定性的

我们的单元测试是确定性的。不确定性单元测试是开发人员的噩梦。如果您所在的组中有一些构建在 CI 服务器上中断,而当一个构建中断时,您的开发伙伴会说您只需再试一次;不可能!如果单元测试运行,它应该一直运行。如果失败了,不管你启动它多少次,它都应该失败。在我们的例子中,一个不确定的单元测试是呈现随机数并对它们进行排序。它最终会在每个测试运行中使用不同的数组,并且,如果代码中出现了一些针对某个数组的 bug,我们将无法重现它。更不用说确保代码正常运行的断言也很难产生。

如果我们在单元测试中对一个随机数组进行排序(我们没有这样做),我们可以假设,断言该数组已排序,逐个比较元素,检查它们是否按升序排列。这也是完全错误的做法。

断言应该尽可能简单

如果断言很复杂,那么在断言中引入 bug 的风险会更高。断言越复杂,风险就越高。我们编写单元测试以简化我们的生活,而不是有更多的代码需要调试。

另外,一个测试应该只断言一件事。这个断言可以用多个Assert类方法进行编码,一个接着一个。尽管如此,这些功能的目的是维护单元的一个单一特性的正确性。

记住 SRP 一个测试,一个特性。一个好的测试就像一个好的狙击手一枪一杀。

单元测试是孤立的

当我们测试一个单元a时,另一个单元B中的任何更改或不同单元中的错误都不应影响我们对该单元a的单元测试。在我们的情况下,这很容易,因为我们只有一个单位。稍后,当我们为快速排序开发测试时,我们将看到这种分离并不是那么简单。

如果单元测试正确地分开,那么失败的单元测试会清楚地指出问题所在。在单元测试失败的单元中。如果测试没有将单元分开,那么一个测试中的失败可能是由不同单元中的 bug 引起的。在这种情况下,这些测试并不是真正的单元测试。

在实践中,你应该保持平衡。如果单元的隔离成本太高,您可以决定创建集成测试;如果它们仍然运行得很快,则由 CI 系统执行它们。同时,你也应该试着找出为什么隔离很难。如果在测试中不能很容易地隔离单元,则意味着单元之间的耦合太强,这可能不是一个好的设计。

单元测试涵盖了代码

单元测试应该测试功能的所有常规和特殊情况。如果有一种特殊情况的代码没有被单元测试覆盖,那么代码就处于危险之中。在排序实现的情况下,一般情况是排序,比如说,五个元素。特殊情况通常要多得多。如果只有一个元素或者没有元素,我们的代码是如何工作的?如果有两个呢?如果元素的顺序相反呢?如果已经分类了呢?

通常,规范中没有定义特殊情况。程序员在编写代码之前必须考虑这个问题,在编写代码的过程中会发现一些特殊的情况。困难的是,你只是无法判断你是否涵盖了所有的特殊情况和代码的功能。

您可以判断的是是否所有的代码行都是在测试期间执行的。如果 90% 的代码行是在测试期间执行的,那么代码覆盖率是 90%,这在现实生活中是相当好的,但是您永远不应该满足于任何低于 100% 的内容。

代码覆盖率与功能覆盖不相同,但存在相关性。如果代码覆盖率小于 100%,则以下两个语句中至少有一条为真:

  • 功能覆盖率不是 100%。
  • 测试单元中有一个未使用的代码,可以直接删除。

代码覆盖率可以测量,但功能覆盖率却无法合理地进行测量。工具和 IDE 支持代码覆盖率测量。这些测量值集成到编辑器中,这样您不仅可以获得覆盖率,而且编辑器将精确地显示覆盖着色行(例如 Eclipse 中)或编辑器窗口左侧的边沟(IntelliJ)中未覆盖哪些行。以下截图显示,在 IntelliJ 中,测试覆盖了檐沟上绿色指示的线条(在打印版本中,这只是一个灰色矩形):

重构测试

现在我们已经讨论了什么是好的单元测试,让我们改进一下测试。第一件事是将支持类移动到单独的文件中。我们将创建ArrayListSortable

package packt.java189fundamentals.ch03.main.bubble.simple;

import packt.java189fundamentals.ch03.Sortable;

import java.util.ArrayList;

public class ArrayListSortable implements Sortable {
    final private ArrayList actualNames;

    ArrayListSortable(ArrayList actualNames) {
        this.actualNames = actualNames;
    }

    @Override
    public Object get(int i) {
        return actualNames.get(i);
    }

    @Override
    public int size() {
        return actualNames.size();
    }
}

这个类封装了ArrayList,然后实现了getssize方法对ArrayList的访问。ArrayList本身声明为final。回想一下,final字段必须在构造器完成时定义。这保证了当我们开始使用对象时字段就在那里,并且在对象生存期内它不会改变。然而,注意,对象的内容,在这种情况下,ArrayList的元素可以改变。如果不是这样的话,我们就无法整理它。

下一个类是StringComparator。这非常简单,我不在这里列出它;我将把它留给您来实现可以比较两个Stringsjava.util.Comparator接口。这应该不难,特别是因为这个类已经是以前版本的BubbleSortTest类的一部分(提示这是一个匿名类,我们存储在名为stringCompare的变量中)。

我们还必须实现ArrayListSwapper,这也不应该是一个很大的惊喜:

package packt.java189fundamentals.ch03.main.bubble.simple;

import packt.java189fundamentals.ch03.Swapper;

import java.util.ArrayList;

public class ArrayListSwapper implements Swapper {
    final private ArrayList actualNames;

    ArrayListSwapper(ArrayList actualNames) {
        this.actualNames = actualNames;
    }

    @Override
    public void swap(int i, int j) {
        Object tmp = actualNames.get(i);
        actualNames.set(i, actualNames.get(j));
        actualNames.set(j, tmp);
    }
}

最后,我们的测试如下:

@Test
public void canSortStrings2() {
    var actualNames = new ArrayList(List.of(
        "Johnson", "Wilson",
        "Wilkinson", "Abraham", "Dagobert"
    ));
    var expectedResult = List.of(
        "Abraham", "Dagobert",
        "Johnson", "Wilkinson", "Wilson"
    );
    var names = new ArrayListSortable(actualNames);
    var sort = new BubbleSort();
    sort.setComparator(new StringComparator());
    sort.setSwapper(new ArrayListSwapper(actualNames));
    sort.sort(names);
    Assert.assertEquals(expectedResult, actualNames);
}

现在,这已经是一个可以在 15 秒内理解的测试了。它很好地记录了如何使用我们定义的某种实现。到目前为止,它仍在运行,没有发现任何 bug。

包含错误元素的集合

bug 并不简单,而且与往常一样,这不是算法的实现,而是在定义上,或者缺少它。如果我们排序的集合中不仅有字符串,程序应该怎么做?

如果我创建一个以以下行开始的新测试,它将抛出ClassCastException

@Test(expected = ClassCastException.class)
public void canNotSortMixedElements() {
    var actualNames = new ArrayList(Arrays.asList(
        42, "Wilson",
        "Wilkinson", "Abraham", "Dagobert"
    ));
    //... the rest of the code is the same as the previous test

这里的问题是 Java 集合可以包含任何类型的元素。您永远无法确定一个集合,例如ArrayList,只包含您期望的类型。即使您使用泛型(我们将在本章中了解),出现此类错误的可能性也较小,但它仍然存在。别问我怎么做,我不能告诉你。这就是虫子的本质,除非你消灭它们,否则你无法知道它们是如何工作的。问题是你必须为这种特殊情况做好准备。

异常处理

异常情况应该使用异常在 Java 中处理。ClassCastException在那里,当排序尝试使用StringComparator比较StringInteger时,就会发生这种情况,为此,它尝试将Integer转换为String

当程序使用throw命令或 Java 运行时抛出异常时,程序的执行将在该点停止,而不是执行下一个命令,而是在捕获异常的地方继续。它可以在同一个方法中,也可以在调用链中的某个调用方法中。要捕获异常,抛出异常的代码应该在一个try块中,try块后面的catch语句应该指定一个与抛出的异常兼容的异常。

如果没有捕获到异常,那么 Java 运行时将打印出异常消息以及栈跟踪,该跟踪将包含异常发生时调用栈上的所有类、方法和行号。在我们的例子中,如果我们移除@Test注解的(expected = ClassCastException.class)参数,测试执行将在输出中产生以下跟踪:

packt.java189fundamentals.ch03.main.bubble.simple.NonStringElementInCollectionException: There are mixed elements in the collection.

        at packt.java189fundamentals.ch03.main.bubble.simple.StringComparator.compare(StringComparator.java:13)
        at packt.java189fundamentals.ch03.main.bubble.BubbleSort.sort(BubbleSort.java:17)
        at packt.java189fundamentals.ch03.main.bubble.simple.BubbleSortTest.canNotSortMixedElements(BubbleSortTest.java:108)
        at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
        at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
        at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
        at java.base/java.lang.reflect.Method.invoke(Method.java:564)
        at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:50)
        at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
        at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:47)
        at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
        at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:325)
        at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:78)
        at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:57)
        at org.junit.runners.ParentRunner$3.run(ParentRunner.java:290)
        at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:71)
        at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:288)
        at org.junit.runners.ParentRunner.access$000(ParentRunner.java:58)
        at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:268)
        at org.junit.runners.ParentRunner.run(ParentRunner.java:363)
        at org.junit.runner.JUnitCore.run(JUnitCore.java:137)
        at com.intellij.junit4.JUnit4IdeaTestRunner.startRunnerWithArgs(JUnit4IdeaTestRunner.java:68)
        at com.intellij.rt.execution.junit.IdeaTestRunner$Repeater.startRunnerWithArgs(IdeaTestRunner.java:47)
        at com.intellij.rt.execution.junit.JUnitStarter.prepareStreamsAndStart(JUnitStarter.java:242)
        at com.intellij.rt.execution.junit.JUnitStarter.main(JUnitStarter.java:70)
Caused by: java.lang.ClassCastException: java.base/java.lang.Integer cannot be cast to java.base/java.lang.String
        at packt.java189fundamentals.ch03.main.bubble.simple.StringComparator.compare(StringComparator.java:9)
        ... 24 more

这个栈跟踪实际上并不长。在生产环境中,在应用服务器上运行的应用中,栈跟踪可能包含几百个元素。在这个跟踪中,您可以看到 IntelliJ 正在启动涉及 JUnitRunner 的测试执行,直到我们完成了对比较器的测试,在那里抛出了实际的异常。

这种方法的问题是,真正的问题不是类铸造失败。真正的问题是集合包含混合元素。只有当 Java 运行时试图强制转换两个不兼容的类时,它才能实现。我们的代码可以更智能。我们可以修改比较器:

public class StringComparator implements Comparator {

    @Override
    public int compare(Object first, Object second) {
        try {
            final String f = (String) first;
            final String s = (String) second;
            return f.compareTo(s);
        } catch (ClassCastException cce) {
            throw new NonStringElementInCollectionException(
                "There are mixed elements in the collection.", cce);
        }
    }
}

此代码捕获ClassCastException并抛出一个新的。抛出一个新异常的好处是,您可以确定这个异常是从比较器抛出的,问题是集合中确实存在混合元素。类转换问题也可能发生在代码的其他地方。一些应用代码可能希望捕获异常并处理该情况;例如,发送特定于应用的错误消息,而不是仅向用户转储栈跟踪。此代码也可以捕获ClassCastException,但无法确定异常的真正原因是什么。另一方面,NonStringElementInCollectionException是确定的。

NonStringElementInCollectionException是 JDK 中不存在的异常。我们必须创造它。异常是 Java 类,我们的异常如下:

package packt.java189fundamentals.ch03.main.bubble.simple;

public class NonStringElementInCollectionException extends RuntimeException {
    public NonStringElementInCollectionException(String message, Throwable cause) {
        super(message, cause);
    }
}

Java 有检查异常的概念。这意味着任何不扩展RuntimeException(直接或间接)的异常都应该在方法定义中声明。假设我们的异常声明如下:

package packt.java189fundamentals.ch03.main.bubble.simple;

public class NonStringElementInCollectionException extends Exception {
    public NonStringElementInCollectionException(String message, Throwable cause) {
        super(message, cause);
    }
}

然后,我们可以声明compare方法如下:

public int compare(Object first, Object second) throws NonStringElementInCollectionException

问题是方法抛出的异常是方法签名的一部分,这样,compare就不会覆盖接口的compare方法,这样类就不会实现Comparator接口。因此,我们的异常必须是运行时异常。

应用中可能有一个异常层次结构,新手程序员通常会创建它们的巨大层次结构。如果你有什么可以做的,并不意味着你应该做。层次结构应该尽可能保持平坦,对于异常情况尤其如此。如果 JDK 中有一个异常描述了您的异常情况,那么使用现成的异常。如果它已经准备好,那么它也同样适用于任何其他类,不要再次实现它。

同样重要的是要注意,抛出异常只能在异常情况下进行。它不是用来表示一些正常的操作条件。这样做会妨碍代码的可读性,也会消耗 CPU。对于 JVM 来说,抛出异常不是一件容易的事情。

它不仅仅是一个可以抛出的异常。throw命令可以抛出,catch命令可以捕获扩展Throwable类的任何内容。Throwable-ErrorException有两个子类。如果在 Java 代码执行过程中发生错误,则抛出一个Error。最臭名昭著的两个错误是OutOfMemoryErrorStackOverflowError。如果其中任何一个发生了,你就不能可靠地抓住他们。

JVM 中也有InternalErrorUnknownError,但是由于 JVM 相当稳定,您几乎不会遇到这些错误。

通过这种方式,当一些程序员意外地在名称中写入 42 个时,我们处理了这种特殊情况,但是如果在编译时识别错误会更好吗?为此,我们将引入泛型。

在我们去那里之前最后一个想法。我们用canNotSortMixedElements单元测试测试什么样的类行为?测试在BubbleSortTest测试类中,但功能在比较器实现StringComparator中。此测试检查超出单元测试类范围的内容。我可以用它来演示,但这不是一个单元测试。排序实现的真正功能可以用这种方式形式化,无论排序实现抛出什么样的异常,比较器都会抛出什么样的异常。您可以尝试编写这个单元测试,或者继续阅读;我们将在下一节中介绍它。

StringComparator类没有测试类,因为StringComparator是测试的一部分,我们永远不会为测试编写测试。否则,我们将陷入一个无尽的兔子洞。

泛型

泛型特性在版本 5 中被引入到 Java 中。从一个例子开始,到目前为止,我们的Sortable接口是这样的:

public interface Sortable {
    Object get(int i);
    int size();
}

在引入泛型之后,它将如下所示:

package packt.java189fundamentals.ch03.generic;

public interface Sortable<E> {
    E get(int i);
    int size();
}

E标识符表示一种类型。它可以是任何类型。如果类实现了接口,即两个方法-sizeget,那么它就是一个可排序的集合。get方法应该返回E类型的内容,不管E是什么。到目前为止,这可能还不太合理,但你很快就会明白重点。毕竟,泛型是一个困难的话题。

Sort接口如下:

package packt.java189fundamentals.ch03.generic;

public interface Sort<E> {
    void sort(Sortable<E> collection);
}

SortSupport变为:

package packt.java189fundamentals.ch03.generic;

import packt.java189fundamentals.ch03.Swapper;

import java.util.Comparator;

public interface SortSupport<E> {
    void setSwapper(Swapper swap);

    void setComparator(Comparator<E> compare);
}

这仍然没有提供比没有泛型的前一个版本更多的澄清,但是,至少,它做了一些事情。在实现Sort接口的实际类中,Comparator应该接受Sortable使用的相同类型。不可能SortableStrings起作用,我们为Integers注入了一个比较器。

BubbleSort的实现如下:

package packt.java189fundamentals.ch03.main.bubble.generic;

// ... imports were removed from printout ...

public class BubbleSort<E> implements Sort<E>, SortSupport<E> {
    private Comparator<E> comparator = null;
    private Swapper swapper = null;

    @Override
    public void sort(Sortable<E> collection) {
        var n = collection.size();
        while (n > 1) {
            for (int j = 0; j < n - 1; j++) {
                if (comparator.compare(collection.get(j),
                        collection.get(j + 1)) > 0) {
                    swapper.swap(j, j + 1);
                }
            }
            n--;
        }
    }

    @Override
    public void setComparator(Comparator<E> comparator) {
        this.comparator = comparator;
    }

    @Override
    public void setSwapper(Swapper swapper) {
        this.swapper = swapper;
    }
}

泛型的真正威力将在我们编写测试时显现。第一个测试没有太大变化,不过,对于泛型,它更明确:

    @Test
    public void canSortStrings() {
        var actualNames = new ArrayList<>(List.of(
            "Johnson", "Wilson",
            "Wilkinson", "Abraham", "Dagobert"
        ));
        var expectedResult = List.of(
            "Abraham", "Dagobert",
            "Johnson", "Wilkinson", "Wilson"
        );
        Sortable<String> names =
            new ArrayListSortable<>(actualNames);
        var sort = new BubbleSort<String>();
        sort.setComparator(String::compareTo);
        sort.setSwapper(new ArrayListSwapper<>
        (actualNames));
        sort.sort(names);
        Assert.assertEquals(expectedResult, 
        actualNames);
    }

当我们定义ArrayList时,我们还将声明列表中的元素将是字符串。当我们分配新的ArrayList时,不需要再次指定元素是字符串,因为它来自那里的实际元素。每一个字符都是一个字符串;因此,编译器知道唯一可以位于<<字符之间的是String

两个字符<<之间没有类型定义,称为菱形运算符。类型是推断的。如果您习惯了泛型,那么这段代码将为您带来有关集合所处理的类型的更多信息,代码的可读性也将提高。可读性和额外的信息不是唯一的问题。

我们知道,Comparator参数现在是Comparator<String>,我们可以利用自 Java8 以来 Java 的高级特性,将String::compareTo方法引用传递给比较器设置器。

第二个测试对我们现在来说很重要。这是确保Sort不干扰比较器抛出的异常的测试:

 1\. @Test
 2\. public void throwsWhateverComparatorDoes() {
 3\.     final ArrayList<String> actualNames =
 4\.         new ArrayList<>(List.of(
 5\.             42, "Wilson"
 6\.         ));
 7\.     final var names = new ArrayListSortable<>
        (actualNames);
 8\.     final var sort = new BubbleSort<>();
 9\.     final var exception = new RuntimeException();
10\.     sort.setComparator((a, b) -> {
11\.         throw exception;
12\.     });
13\.     final Swapper neverInvoked = null;
14\.     sort.setSwapper(neverInvoked);
15\.     try {
16\.         sort.sort(names);
17\.     } catch (Exception e) {
18\.         Assert.assertSame(exception, e);
19\.         return;
20\.     }
21\.     Assert.fail();
22\. }

问题是,它甚至不编译。编译器说它不能推断第四行的ArrayList<>类型。当asList方法的所有参数都是字符串时,该方法返回一个String元素列表,因此新操作符生成ArrayList<String>。这一次,有一个整数,因此编译器无法推断出ArrayList<>是针对String元素的。

将类型定义从ArrayList<>更改为ArrayList<String>并不是解决方法。在这种情况下,编译器将抱怨值42。这就是泛型的力量。当您使用具有类型参数的类时,编译器可以检测您何时提供了错误类型的值。要将值放入ArrayList以检查实现是否真的抛出异常,我们必须将值放入其中。我们可以尝试用一个空的String替换值42,然后添加下面的行,它仍然不会编译:

actualNames.set(0,42);

编译器仍然会知道您要在ArrayList中设置的值应该是String。要获得带有Integer元素的数组,你必须明确地解锁安全手柄并扣动扳机,射击自己:

((ArrayList)actualNames).set(0,42);

我们不这样做,即使是为了考试。我们不想测试 JVM 是否识别出一个Integer不能转换为一个String。该测试由不同的 Java 实现完成。我们真正测试的是,无论比较器抛出什么异常,sort都会抛出相同的异常。

现在,测试如下:

@Test
public void throwsWhateverComparatorDoes() {
    final var actualNames =
        new ArrayList<>(List.of(
            "", "Wilson"
        ));
    final var names = new ArrayListSortable<>(actualNames);
    final var sort = new BubbleSort<>();
    final var exception = new RuntimeException();
    sort.setComparator((a, b) -> {
        throw exception;
    });
    final Swapper neverInvoked = null;
    sort.setSwapper(neverInvoked);
    try {
        sort.sort(names);
    } catch (Exception e) {
        Assert.assertSame(exception, e);
        return;
    }
    Assert.fail();
}

现在,我们将变量actualNames的声明更改为var,以便从右侧表达式推断类型。在这种情况下,它是ArrayList<String>,泛型String参数是从调用List.of()创建的列表中推断出来的。此方法也有泛型参数,因此我们可以编写List.<String>of()。但是,在这个调用中,这个泛型参数是从参数中推断出来的。所有参数都是字符串,因此返回的列表是List<String>。在上一个未编译的示例中,创建的列表具有类型List<Object>。这与左侧的声明不兼容,编译器对此表示不满。如果我们使用var作为变量声明,编译器此时无法检测到此错误,我们将使用List<Object>变量而不是List<String>

我们将交换程序设置为null,因为它从未被调用。当我第一次写这段代码的时候,这对我来说是显而易见的。几天后,我读了代码,就停了下来。“为什么交换器为空?”过了一两秒钟我就想起来了。但是任何时候,当阅读和理解代码时,我都倾向于考虑重构。我可以在一行中添加一条注释,上面写着//never invoked,但注释往往会保留在那里,即使功能发生了变化。我在 2006 年艰难地学会了这一点,当时一个错误的注释使我无法看到代码是如何执行的。我是在调试时阅读注释的,而不是代码,在系统关闭时修复错误花了两天时间。我倾向于使用使代码表达所发生的事情的结构,而不是注释。额外的变量可能会使类文件变大几个字节,但它是由 JIT 编译器优化的,因此最终的代码不会运行得较慢。

抛出异常的比较器是作为 Lambda 表达式提供的。Lambda 表达式可以用于匿名类或命名类只有一个简单方法的情况。Lambda 表达式是匿名方法,存储在变量中或传入参数以供以后调用。我们将在第 8 章中讨论 Lambda 表达式的细节,“扩展我们的电子商务应用”。

现在,我们将继续实现QuickSort,为此,我们将使用 TDD 方法。

测试驱动开发

TDD 是一种代码编写方法,开发人员首先根据规范编写测试,然后编写代码。这与开发者社区所习惯的恰恰相反。我们遵循的传统方法是编写代码,然后为其编写测试。老实说,真正的做法是编写代码并用临时测试进行测试,而根本不使用单元测试。作为一个专业人士,你永远不会那么做,顺便说一句。你总是写测试。(现在,把它写一百遍——我会一直写测试。)

TDD 的优点之一是测试不依赖于代码。由于代码在创建测试时不存在,开发人员不能依赖单元的实现,因此,它不能影响测试创建过程。这通常是好的。单元测试应该尽可能采用黑盒测试。

黑盒测试是不考虑被测系统实现的测试。如果一个系统被重构,以不同的方式实现,但是它提供给外部世界的接口是相同的,那么黑盒测试应该可以正常运行。白盒测试取决于被测系统的内部工作情况。当代码更改白盒测试时,可能还需要对代码进行调优以跟踪更改。白盒测试的优点是测试代码更简单。不总是这样。灰盒测试是两者的混合。

单元测试应该是黑盒测试,但是,很多时候,编写黑盒测试并不简单。开发人员会编写一个他们认为是黑匣子的测试,但很多时候,这种想法被证明是错误的。当实现发生变化时,一些东西被重构,测试不再工作,需要进行纠正。开发人员,尤其是编写单元的开发人员,在了解实现的情况下,会编写一个依赖于代码内部工作的测试。在编写代码之前编写测试是防止这种情况的一种工具。如果没有代码,就不能依赖它。

TDD 还说开发应该是一种迭代的方法。一开始只写一个测试。如果你跑,它就会失败。当然,它失败了!由于还没有代码,它必须失败。然后,您将编写完成此测试的代码。没有更多,只有使这个测试通过的代码。然后,您将继续为规范的另一部分编写新的测试。你将运行它,但它失败了。这证明新的测试测试了一些尚未开发的东西。然后,您将开发代码以满足新的测试,并且可能还将修改在以前的迭代中已经编写的代码块。当代码准备就绪时,测试将通过。

很多时候,开发人员不愿意修改代码。这是因为他们害怕打破已经在工作的东西。当你遵循 TDD,你不应该,同时,你不必害怕这一点。所有已经开发的特性都有测试。如果某些代码修改破坏了某些功能,测试将立即发出错误信号。关键是在修改代码时尽可能频繁地运行测试。

实现快速排序

正如我们已经讨论过的,快速排序由两个主要部分组成。一个是分区,另一个是递归地进行分区,直到整个数组被排序。为了使我们的代码模块化并准备好演示 JPMS 模块处理特性,我们将把分区和递归排序开发成单独的类和单独的包。代码的复杂性不能证明这种分离是合理的。

分区类

分区类应该提供一个基于枢轴元素移动集合元素的方法,我们需要在方法完成后知道枢轴元素的位置。方法的签名应如下所示:

public int partition(Sortable<E> sortable, int start, int end, E pivot);

该类还应该可以访问SwapperComparator。在本例中,我们定义了一个类而不是一个接口;因此,我们将使用构造器注入。

这些构造,如设置器和构造器注入器,是如此的常见和频繁,以至于 IDE 支持这些构造的生成。您需要在代码中创建final字段,并使用代码生成菜单来创建构造器。

分区类将如下所示:

public class Partitioner<E> {

    private final Comparator<E> comparator;
    private final Swapper swapper;

    public Partitioner(Comparator<E> comparator, Swapper swapper) {
        this.comparator = comparator;
        this.swapper = swapper;
    }

    public int partition(Sortable<E> sortable, int start, int end, E pivot) {
        return 0;
    }
}

这段代码什么也不做,但 TDD 就是这样开始的。我们将创建需求的定义,提供代码的框架和调用它的测试。要做到这一点,我们需要一些我们可以分割的东西。最简单的选择是一个Integer数组。partition方法需要一个Sortable<E>类型的对象,我们需要一些包装数组并实现这个接口的东西。我们把那个类命名为ArrayWrapper。这是一个通用类。这不仅仅是为了考试。因此,我们将其创建为生产代码,因此,我们将其放在main目录中,而不是test目录中。因为这个包装器独立于Sort的实现,所以这个类的正确位置是在一个新的SortSupportClasses模块中。我们将创建新模块,因为它不是接口的一部分。实现依赖于接口,而不依赖于支持类。也可能有一些应用使用我们的库,可能需要接口模块和一些实现,但当它们自己提供包装功能时仍然不需要支持类。毕竟,我们不能实现所有可能的包装功能。SRP 也适用于模块。

Java 库往往包含不相关的功能实现。这不好。就短期而言,它使库的使用更简单。您只需要在 POM 文件中指定一个依赖项,就可以拥有所需的所有类和 API。从长远来看,应用变得越来越大,携带了许多属于某些库的类,但应用从不使用它们。

要添加新模块,必须创建模块目录以及源目录和 POM 文件。该模块必须添加到父 POM 中,并且还必须添加到dependencyManagement部分,以便QuickSort模块的测试代码可以使用它而不指定版本。新模块依赖于接口模块,因此必须将此依赖关系添加到支持类的 POM 中。

ArrayWrapper类简单而通用:

package packt.java189fundamentals.ch03.support;

import packt.java189fundamentals.ch03.generic.Sortable;

public class ArrayWrapper<E> implements Sortable<E> {
    private final E[] array;

    public ArrayWrapper(E[] array) {
        this.array = array;
    }

    public E[] getArray() {
        return array;
    }

    @Override
    public E get(int i) {
        return array[i];
    }

    @Override
    public int size() {
        return array.length;
    }
}

我们也需要的ArraySwapper类进入同一个模块。它和包装器一样简单:

package packt.java189fundamentals.ch03.support;

import packt.java189fundamentals.ch03.Swapper;

public class ArraySwapper<E> implements Swapper {
    private final E[] array;

    public ArraySwapper(E[] array) {
        this.array = array;
    }

    @Override
    public void swap(int k, int r) {
        final E tmp = array[k];
        array[k] = array[r];
        array[r] = tmp;
    }
}

有了这些类,我们可以创建第一个测试:

package packt.java189fundamentals.ch03.qsort.phase1;

// ... imports deleted from print ...

public class PartitionerTest {

在创建@Test方法之前,我们需要两个辅助方法来进行断言。断言并不总是简单的,在某些情况下,它们可能涉及一些编码。一般规则是,测试和其中的断言应该尽可能简单;否则,它们只是编程错误的一个可能来源。此外,我们创建它们是为了避免编程错误,而不是创建新的错误。

assertSmallElements方法认为cutIndex 之前的所有元素都小于pivot

private void assertSmallElements(Integer[] array, int cutIndex, Integer pivot) {
    for (int i = 0; i < cutIndex; i++) {
        Assert.assertTrue(array[i] < pivot);
    }
}

assertLargeElements方法确保cutIndex之后的所有元素至少与pivot一样大:

private void assertLargeElements(Integer[] array, int cutIndex, Integer pivot) {
    for (int i = cutIndex; i < array.length; i++) {
        Assert.assertTrue(pivot <= array[i]);
    }
}

该测试使用一个常量数组Integers并将其包装到一个ArrayWrapper类中:

@Test
public void partitionsIntArray() {
    final var partitionThis = new Integer[]{0, 7, 6};
    final var swapper = new ArraySwapper<> \   
    (partitionThis);
    final var partitioner =
            new Partitioner<Integer>(
                  (a, b) -> a < b ? -1 : a > b ? +1 : 0,
                    swapper);
    final Integer pivot = 6;
    final int cutIndex = partitioner.partition(
       new ArrayWrapper<>(partitionThis), 0, 2, pivot);
    Assert.assertEquals(1, cutIndex);
    assertSmallElements(partitionThis, cutIndex, pivot);
    assertLargeElements(partitionThis, cutIndex, pivot);
}

在 JDK 中,Integer类型没有Comparator,但是很容易将其定义为 Lambda 函数。现在,我们可以编写partition方法,如下所示:

 1\. public int partition(Sortable<E> sortable,
 2\.                      int start,
 3\.                      int end,
 4\.                      E pivot) {
 5\.     var small = start;
 6\.     var large = end;
 7\.     while (large > small) {
 8\.         while(comparator.compare(sortable.get(small), pivot) < 0
 9\.                 && small < large) {
10\.             small++;
11\.         }
12\.         while(comparator.compare(sortable.get(large), pivot) >= 0
13\.                 && small < large) {
14\.             large--;
15\.         }
16\.         if (small < large) {
17\.             swapper.swap(small, large);
18\.         }
19\.     }
20\.     return large;
21\. }

如果我们运行测试,它运行良好。然而,如果我们用覆盖率运行测试,那么 IDE 告诉我们覆盖率只有 92%。这个测试只覆盖了partition方法 14 行中的 13 行。

17行的天沟上有一个红色矩形。这是因为测试数组已经分区。当枢轴值为6时,不需要交换其中的任何元素。这意味着我们的测试很好,但还不够好。如果那条线上有错误怎么办?

为了修正这个问题,我们将扩展测试,将测试数组从{0, 7, 6 }改为{0, 7, 6, 2}。运行测试,它将失败。为什么?经过调试,我们将发现调用方法partition,并将固定参数2作为数组的最后一个索引。但是,我们把数组做得更长。为什么我们首先在那里写一个常数?这是一个坏做法。让我们用partitionThis.length-1替换。现在,它说cutIndex2,但我们期望1。我们忘记将断言调整为新数组。我们来修吧。现在它有效了。

最后一件事是重新考虑这些断言。代码越少越好。断言方法非常通用,我们将对单个测试数组使用它。断言方法非常复杂,它们值得自己测试。但是,我们不编写测试代码。相反,我们可以简单地删除这些方法,并将测试的最终版本如下所示:

@Test
public void partitionsIntArray() {
    final var partitionThis = new Integer[]{0, 7, 6, 2};
    final var swapper = new ArraySwapper<>(partitionThis);
    final var partitioner =
            new Partitioner<Integer>(
        (a, b) -> a < b ? -1 : a > b ? +1 : 0, swapper);
    final var pivot = 6;
    final var cutIndex = partitioner.partition(
            new ArrayWrapper<>(partitionThis),
            0,
            partitionThis.length - 1,
            pivot);
    Assert.assertEquals(2, cutIndex);
    final var expected = new Integer[]{0, 2, 6, 7};
    Assert.assertArrayEquals(expected, partitionThis);
}

再说一遍,这是黑箱测试吗?如果分区返回{2, 1, 7, 6}呢?这符合定义。我们可以创建更复杂的测试来覆盖这些情况。但是更复杂的测试本身也可能有一个 bug。作为一种不同的方法,我们可以创建可能更简单但依赖于实现的内部结构的测试。这些不是黑盒测试,因此也不是理想的单元测试。我会选择第二个,但如果有人选择另一个,我不会争辩。

递归排序

我们将使用qsort包中的一个额外类和分区类来实现快速排序,如下所示:

package packt.java189fundamentals.ch03.qsort;

// ... imports are deleted from print ...
public class Qsort<E> {
    final private Comparator<E> comparator;
    final private Swapper swapper;
// ... constructor setting fields deleted from print ...
    public void qsort(Sortable<E> sortable, int start, int end) {
        if (start < end) {
            final var pivot = sortable.get(start);
            final var partitioner = new Partitioner<E>(comparator, swapper);
            var cutIndex = partitioner.partition(sortable, start, end, pivot);
            if (cutIndex == start) {
                cutIndex++;
            }
            qsort(sortable, start, cutIndex - 1);
            qsort(sortable, cutIndex, end);
        }
    }
}

该方法得到Sortable<E>和两个指标参数。它不会对整个集合进行排序;它只对startend索引之间的元素进行排序。

非常精确的索引总是很重要的。通常,Java 中的起始索引没有问题,但是很多错误源于如何解释end索引。在这种方法中,end的值可能意味着索引已经不是待排序区间的一部分。在这种情况下,应该使用end-1调用partition方法,并使用end-1作为最后一个参数调用第一个递归调用。这是品味的问题。重要的是要精确定义指标参数的解释。

如果只有一个(start == end)元素,则没有要排序的内容,方法返回。这是递归的结束标准。该方法还假设end指数从不小于start指数。由于这种方法只在我们目前正在开发的库中使用,所以这样的假设不太冒险。

如果有要排序的内容,则该方法将要排序的间隔的第一个元素作为轴心并调用partition方法。当分区完成时,该方法递归地调用自己的两部分。

这个算法是递归的。这意味着该方法调用自身。当一个方法调用被执行时,处理器在一个名为的区域中分配一些内存,并在那里存储局部变量。这个属于栈中方法的区域称为栈帧。当方法返回时,释放此区域并恢复栈,只需将栈指针移动到调用之前的位置。这样,一个方法可以在调用另一个方法后继续执行;局部变量就在那里。

当一个方法调用它自己时,它没有什么不同。局部变量是方法实际调用的局部变量。当方法调用自身时,它会在栈上再次为局部变量分配空间。换句话说,这些是局部变量的新实例。

我们在 Java 中使用递归方法,在其他编程语言中,当算法的定义是递归的时,非常重要的是要理解当处理器代码运行时,它不再递归。在这一级别上,有指令、寄存器和内存加载和跳跃。没有什么比函数或方法更像,因此,在这个级别上,没有什么比递归更重要的了。

如果你明白了,很容易理解任何递归都可以被编码成循环。

事实上,在每个循环周围,也可以用递归的方式进行编码,但在开始函数编程之前,这并不真正有趣。

在 Java 和许多其他编程语言中,递归的问题是它可能会耗尽栈空间。对于快速排序,情况并非如此。您可以安全地假设 Java 中方法调用的栈只有几百层。快速排序需要一个深度约为log2(n)的栈,其中n是要排序的元素数。在 10 亿元素的情况下,这是 30,应该正好合适。

为什么栈没有移动或调整大小?这是因为耗尽栈空间的代码通常是糟糕的样式。它们可以以某种循环的形式以更可读的形式表示。一个更加健壮的栈实现只会吸引新手程序员去做一些可读性较差的递归编码。

递归有一个特例,叫做尾部递归。尾部递归方法将自己作为方法的最后一条指令调用。当递归调用返回代码时,调用方法只释放用于此方法调用的栈帧。换句话说,我们将在递归调用期间保留栈帧,以便在调用之后立即丢弃它。为什么不在电话前把它扔掉呢?在这种情况下,实际帧将被重新分配,因为这与保留的方法相同,并且递归调用被转换为跳转指令。这是一个 Java 没有做的优化。函数式语言正在这样做,但 Java 并不是真正的函数式语言,因此应该避免使用尾部递归函数,并将其转换为 Java 源代码级别的循环。

非递归排序

为了证明即使是非尾部递归方法也可以用非递归的方式来表示,这里有一个这样的快速排序:

 1\. public class NonRecursiveQuickSort<E> {
 2\. // ... same fields and constructor as in Qsort are  
    deleted from print ...
 3\. 
 4\.     private static class StackElement {
 5\.         final int begin;
 6\.         final int fin;
 7\. 
 8\.         public StackElement(int begin, int fin) {
 9\.             this.begin = begin;
10\.             this.fin = fin;
11\.         }
12\.     }
13\. 
14\.     public void qsort(Sortable<E> sortable, int  
        start, int end) {
15\.         final var stack = new  
        LinkedList<StackElement>();
16\.         final var partitioner = new Partitioner<E> 
            (comparator, swapper);
17\.         stack.add(new StackElement(start, end));
18\.         var i = 1;
19\.         while (!stack.isEmpty()) {
20\.             var it = stack.remove(0);
21\.             if (it.begin < it.fin) {
22\.                 final E pivot =  
                    sortable.get(it.begin);
23\.                 var cutIndex = 
              partitioner.partition(sortable, it.begin, 
              it.fin, pivot);
24\.                 if( cutIndex == it.begin ){
25\.                     cutIndex++;
26\.                 }
27\.                 stack.add(new StackElement(it.begin, 
                     cutIndex - 1));
28\.                 stack.add(new StackElement(cutIndex, 
                     it.fin));
29\.             }
30\.         }
31\.     }
32\. }

这段代码在 Java 级别实现了一个栈。虽然在stack中似乎还有一些被安排排序的内容,但它从栈中取出它并进行排序分区,并安排这两部分进行排序。

这段代码比前一段代码更复杂,您必须了解StackElement类的角色及其工作方式。另一方面,程序只使用一个Partitioner类实例,也可以使用线程池来安排后续排序,而不是在单个进程中处理任务。在多 CPU 机器上执行排序时,这可能会加快排序速度。但是,这是一个更复杂的任务,本章包含了许多没有多任务处理的新事物;因此,我们将在后面的两章中介绍多线程代码。

在排序的第一个版本中,我对它进行了编码,没有三行代码将cutIndex与间隔起始进行比较,并在if分支中增加它(第 24-26 行)。这是非常需要的。但是,我们在本书中创建的单元测试如果错过了这些行,就不会发现 bug。我建议您删除这些行并尝试编写一些失败的单元测试。然后,试着理解当这些行非常重要时的特殊情况是什么,并试着修改单元测试,以便尽可能简单地发现 bug。(最后,将四行放回原处,看看代码是否有效。)另外,找出一些不将此修改放在方法partition中的架构原因。在large == start的情况下,该方法只能返回large+1

实现 API 类

完成所有这些之后,我们最不需要的就是把QuickSort作为一个简单的类(所有真正的工作都已经在不同的类中完成了):

public class QuickSort<E> extends AbstractSort<E> {
    public void sort(Sortable<E> sortable) {
        final var n = sortable.size();
        final var qsort = new Qsort<E>(comparator,swapper);
        qsort.qsort(sortable, 0, n-1);
    }
}

别忘了我们还需要一个测试!但是,在这种情况下,这与BubbleSort没有太大区别:

    @Test
    public void canSortStrings() {
        final var actualNames = new String[]{
                "Johnson", "Wilson",
                "Wilkinson", "Abraham", "Dagobert"
        };
        final var expected = new String[]{"Abraham",
                "Dagobert", "Johnson", "Wilkinson", "Wilson"};
        var sort = new QuickSort<String>();
        sort.setComparator(String::compareTo);
        sort.setSwapper(new ArraySwapper<>(actualNames));
        sort.sort(new ArrayWrapper<>(actualNames));
        Assert.assertArrayEquals(expected, actualNames);
    }

这次我们用了String数组而不是ArrayList。这使得这个测试更简单,而且,这一次,我们已经有了支持类。

您可能认识到这不是单元测试。在BubbleSort的情况下,算法是在单个类中实现的。测试单个类是一个单元测试。在QuickSort的例子中,我们将函数划分为不同的类,甚至是不同的包。对QuickSort类的真正单元测试将揭示该类对其他类的依赖性。当这个测试运行时,它涉及到PartitionerQsort的执行,因此,它不是一个真正的单元测试。

我们应该为此烦恼吗?不是真的。我们希望创建涉及单个单元的单元测试,以便在单元测试失败时知道问题所在。如果只有集成测试,一个失败的测试用例将无助于指出问题所在。它只说明测试中涉及的类中存在一些问题。在本例中,只有有限数量的类(三个)参与了这个测试,并且它们被绑定在一起。它们实际上是紧密联系在一起的,而且彼此之间的联系如此紧密,以至于在实际的生产代码中,我可以在单个模块中实现它们。我在这里将它们分开,以演示如何测试单个单元,并演示 Java 模块支持,它需要的不仅仅是 JAR 文件中的单个类。

创建模块

模块处理,也称为项目 JigsawJPMS,是仅在 Java9 中提供的特性。这是一个计划已久的专题。首先,它是为 Java7 设计的,但是它太复杂了,所以被推迟到 Java8,然后是 Java9。最后,JPMS 被包含在 Java 的 Release9 中。与此同时,Oracle 引入了长期和短期支持发布的概念。只有在该语言的下一个版本发布之前,才支持短期版本。另一方面,长期版本的支持时间更长,很多次甚至在新版本甚至新的长期支持版本发布后的几年。在 Java9 之前,所有版本都是长期支持版本。如果有任何影响应用稳定性或安全性的重大缺陷,Oracle 正在创建新的次要版本。当 Java1.8 可用时,甚至还为 Java1.6 创建了新版本。

当时 ORACLE 宣布 Java9 和 Java9 将不再是长期受支持的版本。然而,根据新的版本控制方案编号的 Java9 或 Java18.9 是一个长期支持版本,因此,它是第一个实现了 JPMS 的长期支持版本。

为什么需要模块

我们已经看到 Java 中有四种访问级别。当类内部没有提供修饰符时,方法或字段可以是privateprotectedpublicdefault(也称为包私有)。当您开发一个用于多个项目的复杂库时,库本身将在许多包中包含许多类。当然会有一些类和方法,这些类和方法中的字段应该只在库中由来自不同包的其他类使用。这些类不能被库外的代码使用。使它们比public更不可见会使它们在库中无法使用。制造它们public将使它们从外面可见。这不好。

在我们的代码中,编译成 JAR 的 Maven 模块quick只有在sort方法可以调用qsort的情况下才能使用。但是,我们不希望qsort直接从外部使用。在下一个版本中,我们可能希望开发一个使用来自NonRecursiveQuickSort类的qsort的版本,我们不希望客户抱怨他们的代码由于库的小升级而无法编译或工作。我们可以证明,内部方法和类是公共的,它们不是用来使用的,而是徒劳的。使用我们库的开发人员不阅读文档。这也是为什么我们不写过多的注释。没有人会读它,甚至执行代码的处理器也不会。

什么是 Java 模块?

Java 模块是 JAR 或目录中类的集合,其中还包含一个名为module-info的特殊类。如果 JAR 或目录中有这个文件,那么它就是一个模块,否则,它只是classpath上的类的集合(或者不是)。Java8 和早期版本只会忽略该类,因为它从未用作代码。这样,使用较旧的 Java 不会造成伤害,并且保持了向后兼容性。

创建这样一个罐子有点棘手。module-info.class文件应具有符合 Java9 字节码或更高版本的字节码,但其他类应包含较旧版本的字节码。

模块信息定义了模块导出的内容及其所需的内容。它有一种特殊的格式。例如,我们可以将module-info.java放在我们的SortInterfaceMaven 模块中:

module packt.java189fundamentals.SortInterface{
    exports packt.java189fundamentals.ch03;
    exports packt.java189fundamentals.ch03.generic;
}

这意味着可以从外部使用publicpackt.java189fundamentals.ch03包内部的任何类。这个包是从模块导出的,但是从模块外部看不到其他包中的其他类,即使它们是public。命名要求与包的情况相同,应该有一个不可能与其他模块名称冲突的名称。反向域名是一个很好的选择,但它不是必须的,你可以在这本书中看到。还没有顶级域packt

我们还应该修改父 POM,以确保我们使用的编译器是 Java9 或更高版本,在project/build/plugins/处配置 Maven 编译器插件:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <version>3.7.0</version>
    <configuration>
        <source>1.10</source>
        <target>1.10</target>
    </configuration>
    <dependencies>
        <dependency>
            <groupId>org.ow2.asm</groupId>
            <artifactId>asm</artifactId>
            <version>6.1.1</version> 
        </dependency>
    </dependencies>
</plugin>

旧版本会与module-info.java文件混淆。(顺便说一句,即使是我在本书第一版中使用的 Java9 的早期访问版本有时也会给我带来困难。)

我们还在 Maven 模块中创建了一个module-info.java文件quick,如下所示:

module packt.java189fundamentals.quick {
    exports packt.java189fundamentals.ch03.quick;
    requires packt.java189fundamentals.SortInterface;
    }

这个模块导出另一个包,需要我们刚刚创建的packt.java189fundamentals.SortInterface模块。现在,我们可以编译模块,./quick/target./SortInterface/target目录中创建的 Jar 现在是 Java 模块。

为了测试模块支持的功能,我们将创建另一个名为Main的 Maven 模块。它只有一个类,叫做Main,有一个public static void main方法:

package packt.java189fundamentals.ch03.main;

// ... imports are deleted from print ...

public class Main {
    public static void main(String[] args) throws IOException {
        final var fileName = args[0];
        BufferedReader br = null;
        try {
            br = new BufferedReader(new InputStreamReader(new FileInputStream(new File(fileName))));
            final var lines = new LinkedList<String>();
            String line;
            while ((line = br.readLine()) != null) {
                lines.add(line);
            }
            String[] lineArray = lines.toArray(new String[0]);
            var sort = new FQuickSort<String>();
            sort.setComparator((a, b) -> ((String) a).compareTo((String) b));
            sort.setSwapper(new ArraySwapper<>(lineArray));
            sort.sort(new ArrayWrapper<>(lineArray));
            for (final String outLine : lineArray) {
                System.out.println(outLine);
            }
        } finally {
            if (br != null) {
                br.close();
            }
        }
    }
}

它接受第一个参数(不检查是否有一个参数,我们不应该在生产代码中使用它)并将其用作文件名。然后,它将文件的行读入一个String数组,对其排序,并将其打印到标准输出。

由于模块支持只对模块起作用,这个 Maven 模块也必须是 Java 模块,并且有一个module-info.java文件:

module packt.java189fundamentals.Main{
    requires packt.java189fundamentals.quick;
    requires packt.java189fundamentals.SortInterface;
    requires packt.java189fundamentals.SortSupportClasses;
}

此外,我们必须为支持模块创建一个module-info.java文件;否则,我们将无法从我们的模块中使用它。

在使用mvn install编译模块之后,我们可以运行它来打印已排序文件的行。例如,我们可以打印出排序后的父 POM 的行,这没有多大意义,但很有趣。下面是启动 Java 代码的 Windows 命令文件:

set MODULE_PATH=Main/target/Main-1.0.0-SNAPSHOT.jar;
set MODULE_PATH=%MODULE_PATH%SortInterface/target/SortInterface-1.0.0-SNAPSHOT.jar;
set MODULE_PATH=%MODULE_PATH%quick/target/quick-1.0.0-SNAPSHOT.jar;
set MODULE_PATH=%MODULE_PATH%SortSupportClasses/target/SortSupportClasses-1.0.0-SNAPSHOT.jar
java -p %MODULE_PATH% -m packt.java189fundamentals.Main/packt.java189fundamentals.ch03.main.Main pom.xml

JAR 文件位于模块路径上,该路径通过命令行选项-p提供给 Java 执行。要启动模块中类的public static void main()方法,仅指定类的完全限定名是不够的。我们必须使用-m选项,后跟模块和类的module/class格式规范。

现在,如果我们尝试直接访问Qsort,将下面的行Qsort<String> qsort = new Qsort<>(String::compareTo,new ArraySwapper<>(lineArray));插入main方法,Maven 会抱怨,因为模块系统对我们的Main类隐藏了它。

模块系统还支持基于java.util.ServiceLoader的类加载机制,这在本书中我们将不讨论。当使用 Spring、Guice 或其他依赖注入框架时,这是一种很少在企业环境中使用的老技术。如果您看到一个包含usesprovides关键字的module-info.java文件,那么请首先查阅 Java 文档中关于ServiceLoader的文档,然后是关于模块支持的 Java9 语言文档

总结

在本章中,我们开发了一个实现快速排序的通用排序算法。我们将项目修改为多模块 Maven 项目,并使用 Java 模块定义。我们使用 JUnit 开发单元测试,并使用 TDD 开发代码。我们使用泛型将代码从旧式 Java 转换为新的,并使用异常处理。在接下来的章节中,我们将开发一个猜谜游戏,这些是需要的基本工具。首先,我们将开发一个更简单的版本,在下一章中,我们将开发一个使用并行计算和多处理器的版本。