从-Java-到-Kotlin-全-

从 Java 到 Kotlin(全)

原文:zh.annas-archive.org/md5/60979b6b5be768c04f4e9f76ebdb673a

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

你好,我是邓肯和纳特。当你阅读这篇前言时,你可能正在考虑是否值得投入时间来阅读本书的其余部分。那么让我们来直接谈谈:

这本书不会教你如何在 Kotlin 中编写计算机程序。

我们开始写一本书,本来是打算的,但很快就明白 Kotlin 是一门庞大的语言,所以这本书将需要比我们期望的更长时间来写。在这个领域已经有一些很棒的书籍,我们不喜欢和这些优秀作品竞争。

相反,我们决定通过专注于将 Kotlin 教给 Java 开发人员来简化我们的生活,这基于我们开展的一个名为“重构到 Kotlin”的研讨会。这通过转换现有代码来教授 Kotlin 语言,并且(根据我们的营销材料)专为希望利用现有知识加速 Kotlin 采用的 Java 团队设计。

我们开始写那本书,但很快就清楚 Kotlin 依然是一门庞大的语言,所以我们会仍然花很长时间来写。我们还发现,有动力和经验丰富的 Java 开发人员可以很快掌握 Kotlin 的大部分内容。对于我们的目标读者来说,逐个研究语言特性显得有些居高临下,他们可能一看就会欣然接受并采用。所以我们放弃了那个想法,结果是:

这本书不会教你 Kotlin 语言。

那么为什么你应该阅读它呢?因为我们写了这本书,这是我们在首次采用 Kotlin 时希望能使用的书籍。我们是经验丰富的程序员,深知 Java 和 Java 生态系统。我们希望你也是。和我们一样,你可能在许多其他语言中有经验。你已经学会了 Kotlin 的基础知识,并且意识到要充分利用这门语言,你需要不同的系统设计方式。你已经发现在 Java 中繁琐的一些事情在 Kotlin 中要简单得多,而一些特性,比如检查异常,在 Kotlin 中根本不存在。你不想最终只是用 Kotlin 语法写 Java 代码。

或许你对此非常关注。也许你在技术领导岗位上,或者已成功说服你的团队采用 Kotlin。你可能已经花了一些政治资本将 Kotlin 引入项目中。现在你需要确保过渡顺利进行。

也许你负责一个 Java 代码库,并且希望确保引入 Kotlin 不会使其现有的关键业务代码不稳定。或者你正在从零开始启动一个 Kotlin 项目,但意识到你的设计直觉更容易转向 Java 和对象,而不是 Kotlin 和函数。

如果您和我们一样,正处于这种情况,那么您来对地方了。本书将帮助您调整思维和设计,以利用 Kotlin 的优势。然而,仅此还不够,因为您有现有的代码需要维护和增强。因此,我们还展示了如何使用内置于 IntelliJ IDE 的自动重构工具,逐步安全地从 Java 迁移到 Kotlin 语法和 Kotlin 思维。

本书的组织方式

本书讨论的是如何从 Java 过渡到 Kotlin,主要集中在代码上,但也涉及到项目和组织。每章都涉及此过渡的一个方面,探讨了一些典型 Java 项目的方面,在这个过程中可以改进。它们以Java Way to Kotlin Way的模式命名,我们建议您更喜欢后者而不是前者。也许 Kotlin 使得一种在 Java 中困难的方法更容易,或者 Kotlin 阻止了一个在 Java 中常见的方法,以指导设计朝着更少出错、更简洁和更易于工具处理的方向发展。

我们不仅建议您采用 Kotlin 的方式;章节还展示了如何进行转换。不是简单地重写 Java,而是通过逐步重构它到 Kotlin 的方式,这样做是安全的,并且允许我们维护混合语言的代码库。

我们如何选择这些主题的?

我们首先分析了 Java 和 Kotlin 开发者对各自语言的使用,并进行了采访,以确定差异和混淆的领域。这得到了对 33,459 个开源 Java 和 Kotlin 代码库的机器学习分析的支持。这些被标记为一种到另一种形式的候选项,然后根据频率和开发者痛苦指数对它们进行了排名,以确定哪些应该被选中。最后,我们按照…

…没错,我们不能对您说谎。

事实是我们开始选择我们想写的主题,并且我们认为这些主题将是有趣且富有信息的。第十五章,封装集合到类型别名,第九章,多到单表达式函数,和第二十章,执行 I/O 到传递数据是这些章节的典型代表。我们还寻找 Kotlin 和 Java 的谷粒明显不同的地方,因为这些地方是我们通过问为什么学到最多的地方。这导致了像第四章,Optional 到 Nullable,第六章,Java 到 Kotlin 集合,和第八章,静态方法到顶层函数这样的章节的产生。

当我们写这些章节时,其他主题也呈现出来并被添加到列表中。特别是在为某章节写重构步骤时,我们经常发现自己在修改代码,觉得这些修改值得成为独立章节。第十三章,《从流到可迭代对象到序列》,第十章,《函数到扩展函数》,以及第十一章,《方法到属性》就是这些例子。

这个过程的结果绝不是穷尽的。如果你已经浏览过目录或索引,会发现一些重要的主题未被涵盖。以协程为例:这段话是对这个重要主题唯一的提及,因为我们发现它们并没有改变我们编写服务器端代码的方式,所以我们不打算写它们。还有一些主题,如果有空间和时间的话,我们本来也想涵盖,包括:构建器、领域特定语言、反射、依赖注入框架、事务……清单可以继续列下去!

我们希望我们所写的内容能够引起您的兴趣。这本书主要是战术书而非战略书,侧重于我们可以在战壕里赢得的小战役,而不是整个师的指挥下可能取得的成就。随着更大的主题逐渐显现,我们会尝试将它们联系起来,并在最终章节第二十三章,《继续旅程》中汇聚一切,讨论我们在写作过程中学到的东西。

复杂性

我们应该如何评价软件的内部质量呢?假设它做到了我们的客户希望或需要的功能,我们怎样比较两种潜在的实现,或者决定一个变更是使其更好还是更差?你们作者选择的答案是复杂性。其他条件相同的情况下,我们更青睐于能够产生可预测行为的简单设计。

当然,在某种程度上,简单和复杂是取决于观察者的视角。我们的作者确实有稍微不同的个人偏好,因此有时会对某种实现方式的优劣持不同意见。在这种情况下,我们有时会在相关章节中探讨替代方案。然而,我们都坚信函数式编程结合面向对象的消息传递能够降低系统复杂性的强大威力。

多年来,Java 一直朝这个方向发展。Scala 朝着函数式编程迈进,但远离面向对象。我们发现 Kotlin 的特性让我们能够以一种方式混合函数式和面向对象编程,从而降低复杂性并发挥普通开发者的最佳潜力。

完美代码

关于凡人的问题,我们应该谈谈代码质量。当将代码提交到书中时,很难不去追求完美。我们知道你将根据这里的代码来评判我们,像许多开发者一样,我们自己的自尊与我们所产生的工作质量紧密相连。

与此同时,我们是工程师而不是艺术家。我们的工作是为我们的客户平衡范围、进度和成本。除了当它影响到这三个更高价值之一时,没有人真正关心代码的质量。

因此,在我们的示例中,我们试图展示真实的生产代码。起点有时不像我们想要的那样好;毕竟,我们正在试图展示改进它们的方法。通常,重构会使事情变得更糟,然后再变得更好,因此绝对不要通过一章中间的代码来判断我们。到了一章的末尾,我们的目标是有足够好的代码,但不要太完美,以至于我们被指责浪费客户的钱。

尽管如此,我们有一个政策,即即使我们已经涵盖了我们打算阐明的主题,我们也会采取具有成本效益的变化来整理,而且我们不止一次地发明了一个主题,并写了一章内容,只是为了让代码处于我们满意的状态。最终,我们既是艺术家,也是工程师。

代码格式化

我们的代码遵循(我们对)Java 和 Kotlin 标准编码约定的解释,尽可能地。

对于打印的代码示例,实际的行长要比我们这些天在 IDE 中通常使用的 120 个字符短得多,因此我们不得不经常分割行以使代码适应页面宽度。我们的生产代码可能会在一行上有四五个参数或参数;在本书中,我们通常只有一个。通过为页面格式化示例,我们已经喜欢上了更垂直的风格。我们发现 Kotlin 自然似乎想要占用比 Java 更多的垂直空间,但即使是 Java 的可读性也通过更短的行、更多的换行和更多的视觉对齐而得到了改进。当然,在 IDE 中横向滚动几乎和在书中一样不方便,我们的配对会话因减少滚动和增加并排窗口而得到改善。每个参数一行也极大地改善了代码版本之间的差异。我们希望至少你不会觉得阅读起来太痛苦,如果你不觉得,那么请尝试为自己的代码尝试一下。

我们有时会隐藏与讨论无关的代码。以三个点的省略号开头的行表示,出于清晰或简洁的原因,我们省略了一些代码。例如:

fun Money(amount: String, currency: Currency) =
    Money(BigDecimal(amount), currency)

... and other convenience overloads

本书中使用的约定

本书使用以下排版约定:

斜体

表示新术语、URL、电子邮件地址、文件名和文件扩展名。

等宽字体

用于程序清单,以及段落内部引用程序元素,如变量或函数名、数据库、数据类型、环境变量、语句和关键字。

小贴士

这个元素表示一个提示或建议。

注意

这个元素表示一般说明。

警告

这个元素表示警告或注意事项。

使用代码示例

本书中的大部分代码示例(来自重构部分的示例)都可以在线访问 GitHub。参考文献立即在代码后面,像这样:

class TableReaderAcceptanceTests {
    @Test
    fun test() {
    }
}

示例 0.1 [table-reader.1:src/test/java/travelator/tablereader/TableReaderAcceptanceTests.kt] (差异)

如果您在设备上阅读本书,则引用应该是指向 GitHub 上该版本文件的超链接。在真正的纸张上,您可以随心所欲地点击;但是什么也不会发生,抱歉。但是,如果您取得示例编号,例如在这种情况下是0.1,并将其输入到本书网站上的表单,它会显示带您到同一位置的链接。

在 Git 中,不同的代码示例(有时跨越多个章节)会在单独的分支上演变。这些步骤都被标记了,table-reader.1就是这种情况下的标记。GitHub 链接指向了具有该标记的代码,因此您可以查看显示的文件(此处为src/test/java/travelator/tablereader/TableReaderAcceptanceTests.kt)以及该版本中的其他示例。您还可以选择其他标签以查看不同的版本,以及选择不同的分支以查看不同的示例。为了更快地导航,您可以克隆存储库,打开它在 IntelliJ 中,并使用 Git 工具窗口切换分支和版本。

警告

代码示例并不是真实的!代码库可以构建并通过测试,但它是虚构的。有些地方示例不正确地连接起来,有些地方如果您窥视幕后,您会看到我们在摇晃杆杆。我们试图保持诚实,但更愿意发货!

如果您有技术问题或者在使用代码示例时遇到问题,请访问本书网站或发送电子邮件至bookquestions@oreilly.com

本书旨在帮助您完成工作任务。一般情况下,如果本书提供了示例代码,您可以在自己的程序和文档中使用它。除非您复制了代码的大部分内容,否则您无需联系我们以获得许可。例如,编写一个使用本书中几个代码块的程序不需要许可。出售或分发 O'Reilly 图书的示例代码需要许可。通过引用本书并引用示例代码来回答问题不需要许可。将本书中大量示例代码合并到您产品的文档中需要许可。

我们感谢,但通常不要求署名。署名通常包括标题、作者、出版商和 ISBN 号。例如:“Java to Kotlin by Duncan McGregor and Nat Pryce (O’Reilly). Copyright 2021 Duncan McGregor and Nat Pryce, 978-1-492-08227-9。”

如果您认为您使用的代码示例超出了公平使用范围或上述授权,请随时通过permissions@oreilly.com联系我们。

致谢

感谢 Hadi Hariri 建议 O’Reilly 我们应该写一本书,以及 Zan McQuade 相信他。感谢我们的编辑 Sarah Grey,她不得不忍受后果,以及 Kerin Forsyth 和 Kate Galloway 整理一切并实际将其出版。

许多朋友和同事,以及一些可爱的陌生人,审阅了从早期的不协调到令人心驰神往的几乎完成的草稿。感谢 Yana Afanasyeva、Jack Bolles、David Denton、Bruce Eckel、Dmitry Kandalov、Kevin Peel、James Richardson、Ivan Sanchez、Jordan Stewart、Robert Stoll、Christoph Sturm、Łukasz Wycisk 和 Daniel Zappold,以及我们的技术审阅者 Uberto Barbini、James Harmon、Mark Maynard 和 Augusto Rodriguez。我们非常感谢你们的建议、鼓励和坦率。

极限编程彻底改变了软件编写的方式 — 我们都应感谢沃德·坎宁安(Ward Cunningham)和肯特·贝克(Kent Beck)。同时也要感谢马丁·福勒(Martin Fowler),如果没有他,这本书可能就不会写出来了。在英国,极限星期二俱乐部自 1999 年以来一直在这些思想上进行创新,并吸引了一群开发者。我们很幸运能与这个团体的许多才华横溢的成员共事并从中学习。如果你有问题,如果没有其他人能帮助你,如果你能找到他们,也许你可以雇佣他们。

邓肯的部分

我不认为我的妻子会理解我以何为生,也没有机会让她读完这本书,但她可能会读到这里。所以感谢你,乔·麦格雷戈(Jo McGregor),容忍我写作而不是陪伴你,并且在我与你共度时光时谈论写作。没有你的支持和鼓励,我无法完成这本书。同时也要感谢我们的两个出色儿子,卡勒姆(Callum)和阿利斯泰尔(Alistair),你们让我们感到无比骄傲。

感谢维基·肯尼什(Vickie Kennish)在我们的 COVID 封锁期间对成为作家母亲的事情极为关注,并在我们的散步中检查进展。我已故的父亲约翰(John)肯定会更随意一些,但肯定会向他的朋友们吹嘘这本书。同样已去但未被遗忘的是我们美丽的猫咪 Sweet Pea,它在大部分写作过程中陪伴着我,但在完成前不久去世了。

罗宾·赫利韦尔(Robin Helliwell)的友谊和支持是我成年后生活中的一贯支持。同样,还有我的姐姐露西·希尔(Lucy Seal)以及许多其他无法逐一列出的家庭成员。在我的职业生涯中,除了那些提供反馈的人,还要感谢艾伦·戴克(Alan Dyke)、理查德·凯尔(Richard Care)和加雷斯·西尔维斯特·布拉德利(Gareth Sylvester-Bradley),他们的支持和帮助远远超出了职责范围。

Nat 的部分

当我告诉我的妻子拉曼(Lamaan)我打算再写一本书时,她的第一反应并不是恐惧。为此,以及她始终不断的鼓励,我要多谢她。

向我姐姐罗伊斯·普赖斯(Lois Pryce)和姐夫奥斯汀·文斯(Austin Vince)致敬,他们的摩托车之旅、书籍和电影激发了本书中使用的陆地旅行规划应用程序的示例代码。

最后感谢奥利弗(Oliver)和亚历克斯(Alex)。现在书已经完成,我再次可以为音乐和游戏编程提供咨询服务了。

第一章:介绍

编程语言的纹理

像木头一样,编程语言也有纹理。在木工和编程中,当你顺着纹理工作时,事情会顺利进行。当你逆着纹理工作时,事情就更加困难。当你逆着编程语言的纹理工作时,你必须写比必要多的代码,性能下降,容易引入缺陷,通常必须覆盖便捷的默认设置,并且在每一步都必须与工具抗争。

逆流而上涉及不断努力,而收益却不确定。

例如,在 Java 8 之前,将 Java 代码编写成函数式风格是可能的,但很少有程序员这样做,原因很充分。

这里是 Kotlin 代码,通过使用加法运算符对列表中的数字进行折叠来计算它们的总和:

val sum = numbers.fold(0, Int::plus)

让我们将其与 Java 1.0 中执行相同操作所需进行比较。

时光的雾气会在将您运送到 1995 年的过程中流逝…

Java 1.0 没有一等函数,所以我们必须将函数实现为对象,并为不同类型的函数定义自己的接口。例如,加法函数接受两个参数,因此我们必须定义两个参数函数的类型:

public interface Function2 {
    Object apply(Object arg1, Object arg2);
}

然后我们必须编写 fold 高阶函数,隐藏 Vector 类所需的迭代和变异。(1995 年的 Java 标准库还不包括 Collections 框架。)

public class Vectors {
    public static Object fold(Vector l, Object initial, Function2 f) {
        Object result = initial;
        for (int i = 0; i < l.size(); i++) {
            result = f.apply(result, l.get(i));
        }
        return result;
    }

    ... and other operations on vectors
}

我们必须为想要传递给我们的 fold 函数的每个函数定义单独的类。加法运算符不能作为值传递,并且该语言此时既没有方法引用,也没有 lambda 表达式或闭包,甚至没有内部类。Java 1.0 也没有泛型或自动装箱——我们必须将参数转换为预期类型,并在引用类型和基本类型之间进行装箱:

public class AddIntegers implements Function2 {
    public Object apply(Object arg1, Object arg2) {
        int i1 = ((Integer) arg1).intValue();
        int i2 = ((Integer) arg2).intValue();
        return new Integer(i1 + i2);
    }
}

最后,我们可以使用所有这些来计算总和:

int sum = ((Integer) Vectors.fold(counts, new Integer(0), new AddIntegers()))
    .intValue();

对于 2020 年主流语言中的单个表达式来说,这需要大量的努力。

但这还不是全部。因为 Java 没有标准的函数类型,我们无法轻松地组合以函数式风格编写的不同库。我们必须编写适配器类来映射不同库中定义的函数类型。而且,由于虚拟机没有 JIT 和简单的垃圾收集器,我们的函数式代码比命令式的替代方案性能更差:

int sum = 0;
for (int i = 0; i < counts.size(); i++) {
    sum += ((Integer)counts.get(i)).intValue();
}

1995 年,写 Java 的函数式风格的付出,并不能带来足够的好处来证明其价值。Java 程序员发现编写迭代集合并改变状态的命令式代码更容易。

编写函数式代码 违背了 Java 1.0 的纹理。

随着时间的推移,语言的粒度逐渐形成,设计者和用户在构建语言特性互动方面建立了共同的理解,并在库中编码他们的理解和偏好,供其他人基于此构建。这种粒度影响程序员在语言中编写代码的方式,进而影响语言及其库和编程工具的演变,改变粒度,改变程序员在语言中编写代码的方式,如此循环互动和演变不断进行。

例如,随着时间的推移,Java 1.1 向语言添加了匿名内部类,Java 2 向标准库添加了集合框架。匿名内部类意味着我们不需要为要传递给我们的fold函数的每个函数编写命名类,但由此产生的代码可能更难阅读:

int sum = ((Integer) Lists.fold(counts, new Integer(0),
    new Function2() {
        public Object apply(Object arg1, Object arg2) {
            int i1 = ((Integer) arg1).intValue();
            int i2 = ((Integer) arg2).intValue();
            return new Integer(i1 + i2);
        }
    })).intValue();

功能性习惯仍然不符合 Java 2 的粒度。

快进到 2004 年,Java 5 是显著改变语言的下一个版本。它添加了泛型和自动装箱功能,增强了类型安全性并减少了样板代码:

public interface Function2<A, B, R> {
    R apply(A arg1, B arg2);
}
int sum = Lists.fold(counts, 0,
    new Function2<Integer, Integer, Integer>() {
        @Override
        public Integer apply(Integer arg1, Integer arg2) {
            return arg1 + arg2;
        }
    });

Java 开发者通常使用Google 的 Guava 库来为集合添加一些常见的高阶函数(尽管fold不在其中),但即使是 Guava 的作者也建议默认情况下编写命令式代码,因为它具有更好的性能并且通常更易读。

功能性编程仍然大多不符合 Java 5 的粒度,但我们可以看到一种趋势的开始。

Java 8 向语言添加了匿名函数(又称 lambda 表达式)和方法引用,并向标准库添加了 Streams API。编译器和虚拟机优化 lambda 以避免匿名内部类的性能开销。Streams API 充分支持功能性习惯,最终允许:

int sum = counts.stream().reduce(0, Integer::sum);

然而,事情并非一帆风顺。我们仍然无法将加法运算符作为参数传递给 Streams 的reduce函数,但我们有标准库函数Integer::sum可以实现同样的功能。Java 的类型系统仍然因引用类型和原始类型之间的区别而产生一些尴尬的边缘情况。如果来自功能性语言(甚至是 Ruby),Streams API 缺少一些常见的高阶函数。检查异常与 Streams API 及功能性编程一般不兼容。而使用具有值语义的不可变类仍然涉及大量样板代码。但是,通过 Java 8,Java 在根本上已经改变,使功能风格运行起来,即使不完全符合语言的粒度,至少也不再与之相悖。

Java 8 之后的发布增加了多种较小的语言和库特性,支持更多功能性编程习惯,但并未改变我们的求和计算方式。这将我们带回到现代。

在 Java 的情况下,语言的粒度以及程序员对其的适应方式,通过几种不同的编程风格演变而来。

Java 编程风格的一个有趣历史

像古代诗人一样,我们将 Java 编程风格的发展分为四个明显的时代:原始时代、豆子时代、企业时代和现代时代。

原始时代风格

最初设计用于家用电器和交互式电视,直到 Netscape 在其极其流行的 Navigator 浏览器中采用了 Java 小程序,Java 才真正起飞。Sun 发布了 Java 开发工具包 1.0,Microsoft 将 Java 包含在 Internet Explorer 中,突然之间,每个拥有 Web 浏览器的人都有了 Java 运行时环境。对 Java 作为一种编程语言的兴趣激增。

到那个时候,Java 的基本原理已经奠定了基础:

  • Java 虚拟机及其字节码和类文件格式

  • 原始类型和引用类型,空引用,垃圾收集

  • 类和接口,方法和控制流语句

  • 用于错误处理的检查异常,抽象窗口工具包

  • 用于与互联网和 Web 协议进行网络编程的类

  • 在运行时加载和链接代码,由安全管理器沙箱化

但是,Java 还没有准备好进行通用编程:JVM 速度慢,标准库稀少。

Java 看起来像 C++ 和 Smalltalk 的混合体,这两种语言影响了当时的 Java 编程风格。其他语言的程序员取笑的 “getFoo/setFoo” 和 “AbstractSingletonProxyFactoryBean” 约定还没有普及。

Java 的一个未被赞扬的创新是官方的编码约定,明确了程序员应该如何命名包、类、方法和变量。C 和 C++ 程序员遵循了看似无穷无尽的编码约定,而结合了多个库的代码最终看起来像一顿狗的晚餐,显得有些不一致。Java 的唯一真正的编码约定意味着 Java 程序员可以将陌生的库无缝地集成到他们的程序中,并鼓励了一个持续发展的充满活力的开源社区。

豆子时代风格

在 Java 初始成功后,Sun 开始使其成为构建应用程序的实用工具。Java 1.1(1996 年)增加了语言特性(尤其是内部类),改进了运行时(尤其是即时编译和反射),并扩展了标准库。Java 1.2(1998 年)添加了标准集合 API 和 Swing 跨平台 GUI 框架,确保 Java 应用程序在每个桌面操作系统上看起来和感觉都一样笨拙。

当时,Sun 公司正密切关注微软和 Borland 在企业软件开发领域的主导地位。Java 有潜力成为 Visual Basic 和 Delphi 的强有力竞争对手。Sun 添加了一系列 API,这些 API 在很大程度上受到了微软 API 的启发:JDBC 用于数据库访问(相当于微软的 ODBC),Swing 用于桌面 GUI 编程(相当于微软的 MFC),以及对 Java 编程风格影响最大的框架,JavaBeans。

JavaBeans API 是 Sun 公司对微软 ActiveX 组件模型的回应,用于低代码、图形化、拖放式编程。Windows 程序员可以在其 Visual Basic 程序中使用 ActiveX 组件,或将其嵌入公司内部网的办公文档或网页中。尽管使用 ActiveX 组件非常容易,但编写它们却非常困难。JavaBeans 则简单得多;你只需遵循一些额外的编码约定,你的 Java 类就可以被视为一个“bean”,可以在图形化设计师中实例化和配置。“一次编写,到处运行”的承诺意味着你也可以在任何操作系统上使用或销售 JavaBean 组件,而不仅仅是在 Windows 上。

要使一个类成为 JavaBean,它需要有一个不带参数的构造函数,可以序列化,并声明由公共属性组成的 API,这些属性可以被读取和(可选地)写入,可以调用的方法,以及对象在类中发出的事件。这个理念是程序员可以在图形应用程序设计师中实例化 beans,通过设置它们的属性来配置它们,并将 beans 发出的事件连接到其他 beans 的方法。默认情况下,Beans API 通过以 getset 开头的方法对来定义属性。虽然可以重写这个默认设置,但这样做需要程序员编写更多的样板代码。程序员通常只在将现有类改造为 JavaBeans 时才会这样做。在新代码中,沿着这个方向进行更容易。

Beans 风格的缺点在于它严重依赖可变状态,并且需要更多的状态是公共的,而不是像普通的 Java 对象那样,因为视觉构建工具不能将参数传递给对象的构造函数,而是必须设置属性。用户界面组件作为 beans 工作得很好,因为它们可以安全地用默认内容和样式初始化,并在构造后进行调整。当我们有没有合理默认值的类时,将它们视为相同的方式是容易出错的,因为类型检查器不能告诉我们何时提供了所有必需的值。Beans 约定使得编写正确的代码更加困难,并且依赖关系的变化可能会悄无声息地破坏客户端代码。

最终,JavaBeans 的图形组合并未成为主流,但其编码约定却深入人心。即使程序员并不打算将其类用作 JavaBean,Java 程序员仍然遵循 JavaBean 的约定。Beans 对 Java 编程风格产生了巨大而持久的影响,尽管并非完全正面。

企业风格

Java 最终在企业中获得了普及。它没有如预期般取代企业桌面上的 Visual Basic,而是取代了 C++成为服务器端首选语言。1998 年,Sun 发布了 Java 2 企业版(当时称为 J2EE,现在是 JakartaEE),这是用于编程服务器端事务处理系统的一套标准 API。

J2EE API 存在抽象反转问题。JavaBeans 和小程序 API 也有抽象反转问题——例如,它们都不允许向构造函数传递参数,但在 J2EE 中问题更为严重。J2EE 应用程序没有单一的入口点。它们由许多由应用容器管理生命周期的小组件组成,并通过 JNDI 名称服务相互暴露。应用程序需要大量样板代码和可变状态来查找其依赖的资源。程序员们通过发明依赖注入(DI)框架来应对这些问题,这些框架负责资源查找、绑定和管理生命周期。其中最成功的是 Spring。它建立在 JavaBeans 编码约定的基础上,利用反射从类似 Bean 的对象组合应用程序。

就编程风格而言,依赖注入(DI)框架鼓励程序员避免直接使用new关键字,而是依赖框架来实例化对象。Android API 也表现出抽象反转,Android 程序员也倾向于使用 DI 框架来帮助他们编写 API。DI 框架更专注于机制而非领域建模,导致出现了像 Spring 那种臭名昭著的AbstractSingletonProxyFactoryBean这样的企业级类名。

不过,企业时代也见证了 Java 5 的发布,该版本为语言添加了泛型和自动装箱,这是迄今为止最重大的变化。该时代还看到 Java 社区对开源库的大规模采用,这得益于 Maven 打包约定和中央仓库。顶级开源库的可用性推动了 Java 在关键业务应用开发中的采纳,并导致更多的开源库出现,形成良性循环。此后还出现了一流的开发工具,包括我们在本书中使用的 IntelliJ IDE。

现代风格

Java 8 为语言带来了下一个重大变化——lambda 表达式——以及对标准库的重大增强以利用它们。流 API 鼓励一种函数式编程风格,其中处理是通过转换不可变值流而不是改变可变对象的状态来完成的。一个新的日期/时间 API 忽略了 JavaBeans 编码约定,而是遵循了原始时代的编码约定。

云平台的增长意味着程序员不再需要将服务器部署到 JavaEE 应用容器中。轻量级的 Web 应用框架允许程序员编写一个main函数来组合他们的应用程序。许多服务器端程序员停止使用 DI 框架——函数和对象组合已经足够好了——因此 DI 框架发布了大大简化的 API 以保持相关性。没有 DI 框架或可变状态,就不需要遵循 JavaBean 编码约定。在单一代码库中,暴露不可变值字段也没问题,因为如果需要,IDE 可以在瞬间将字段封装在访问器后面。

Java 9 引入了模块,但到目前为止,除了 JDK 本身外,它们还没有被广泛采用。最令人兴奋的是最近 Java 版本的模块化和将鲜为人知的模块(如 CORBA)从 JDK 移入可选扩展中。

未来

Java 的未来承诺提供更多功能,使现代风格更易于应用:记录、模式匹配、用户定义的值类型,最终将原始类型和引用类型统一为一致的类型系统。

然而,这是一个具有挑战性的工作,需要花费多年时间才能完成。Java 最初存在一些根深蒂固的不一致性和边界情况,很难统一为干净的抽象并保持向后兼容性。Kotlin 有着 25 年的远见和从头开始的干净板,这是一个巨大的优势。

Kotlin 的风格

Kotlin 是一种年轻的语言,但它显然有着与 Java 不同的风格。

当我们写这篇文章时,Kotlin 首页的“为什么选择 Kotlin”部分列出了四个设计目标:简洁、安全、可互操作性和工具友好性。语言和其标准库的设计者还编码了有助于实现这些设计目标的隐含偏好。这些偏好包括:

Kotlin 更喜欢转换不可变数据而不是变异状态。

数据类使定义具有值语义的新类型变得容易。标准库使得转换不可变数据集合比就地迭代和变异数据更容易且更简洁。

Kotlin 更喜欢行为明确。

例如,类型之间没有隐式强制转换,即使是从较小范围到较大范围也是如此。Java 会将int值隐式转换为long值,因为没有精度损失。在 Kotlin 中,你必须显式调用Int.toLong()。在控制流方面,对显式性的偏好尤为强烈。虽然你可以为自己的类型重载算术和比较运算符,但不能重载快捷逻辑运算符(&&||),因为这样会允许你定义不同的控制流。

Kotlin 更青睐静态绑定而非动态绑定。

Kotlin 鼓励一种类型安全的、组合式的编码风格。扩展函数在静态绑定时绑定。默认情况下,类不可扩展,方法不具有多态性。你必须显式地选择多态性和继承。如果要使用反射,你必须添加一个特定于平台的库依赖。Kotlin 从一开始就被设计用于与语言感知的 IDE 一起使用,该 IDE 静态分析代码以指导程序员、自动化导航和自动化程序转换。

Kotlin 不喜欢特殊情况。

与 Java 相比,Kotlin 的特殊情况较少,并且交互方式不可预测。没有基本类型和引用类型之分。没有void类型用于返回但不返回值的函数;Kotlin 中的函数要么返回一个值,要么根本不返回。扩展函数允许你为现有类型添加新操作,而在调用点看起来相同。你可以将新的控制结构编写为内联函数,breakcontinuereturn语句的行为与内置控制结构中的行为相同。

Kotlin 为了简化迁移而打破了自己的规则。

Kotlin 语言具有允许在同一代码库中存在惯用 Java 和 Kotlin 代码的功能。其中一些功能会移除类型检查器提供的保证,仅应用于与旧版 Java 进行交互。例如,lateinit打开了类型系统的漏洞,以便 Java 依赖注入框架可以通过反射初始化对象并通过编译器通常强制执行的封装边界注入值。如果将属性声明为lateinit var,则由你来确保代码在读取属性之前初始化它。编译器不会捕获你的错误。

当我们,Nat 和 Duncan,重新审视我们最早用 Kotlin 编写的代码时,它往往看起来像是用 Kotlin 语法打扮成的 Java。我们在写了很多年的 Java 后转向 Kotlin,养成了影响我们编写 Kotlin 代码的习惯。我们写了不必要的样板文件,没有充分利用标准库,并且避免使用 null,因为我们还不习惯类型检查器强制执行空安全。我们团队中的 Scala 程序员走得太远了——他们的代码看起来像是 Kotlin 试图成为 Scala,扮演 Haskell。我们当中没有人找到与 Kotlin 的纹理相匹配的甜蜜点。

通往惯用 Kotlin 的道路受到我们必须保持工作的 Java 代码的影响。实际上,仅仅学习 Kotlin 是不够的。我们必须同时处理 Java Kotlin 的不同特点,对两者都持有同情心,逐渐从一个转向另一个。

Kotlin 重构

当我们开始向 Kotlin 进发时,我们负责维护和增强业务关键系统。我们从未能只专注于将我们的 Java 代码库转换为 Kotlin。我们总是不得不在同时将代码迁移到 Kotlin,同时改变系统以满足新的业务需求,我们在此过程中保持了一个混合的 Java/Kotlin 代码库。我们通过进行小的更改来管理风险,使每个更改易于理解,并且如果我们发现它破坏了某些东西,就廉价丢弃。我们的流程首先将 Java 代码转换为 Kotlin,给我们带来了 Kotlin 语法中的 Java 式设计。然后我们逐步应用 Kotlin 语言特性,使代码变得越来越易于理解,更加类型安全,更加简洁,并且具有更具组合结构的结构,更易于改变而不会有不愉快的惊喜。

从惯用的 Java 到惯用的 Kotlin,进行了一系列小的、安全的、可逆的改变,以改善设计。

在语言之间进行重构通常比在单一语言中进行重构更难,因为重构工具在语言之间的边界上效果不佳,如果有的话。从一种语言迁移到另一种语言必须手动完成,这需要更长的时间,并引入了更多的风险。一旦使用了多种语言,语言边界会妨碍重构,因为当您重构一种语言中的代码时,IDE 不会更新其他语言中编写的依赖代码以使其兼容。

使 Java 和 Kotlin 的组合独特的是两种语言之间(相对)无缝的边界。由于 Kotlin 语言的设计、它被映射到 JVM 平台的方式,以及 JetBrains 对开发工具的投资,将 Java 重构为 Kotlin 和重构合并的 Java/Kotlin 代码库几乎和在单一代码库中进行重构一样容易。

我们的经验表明,我们可以将 Java 重构为 Kotlin 而不影响生产力,并且随着我们将更多的代码库转换为 Kotlin,生产力会加速提升。

重构原则

自从马丁·福勒在 1999 年出版的书籍《重构:改善既有代码的设计》(Addison-Wesley)中首次流行以来,重构的实践已经走了很长一段路。该书甚至详细说明了像重命名标识符这样简单的重构的手动步骤,但指出一些先进的开发环境已经开始提供自动化支持来减少这种单调乏味的工作。如今,我们期望我们的工具甚至能自动化复杂的情况,比如提取接口或改变函数签名。

尽管这些单独的重构很少是孤立的。现在建筑块重构可以自动执行,我们有时间和精力将它们结合起来,对代码库进行更大规模的改变。当 IDE 没有为我们希望执行的大规模转换提供明确的用户界面操作时,我们必须将其作为一系列更细粒度的重构操作序列来执行。我们尽可能使用 IDE 的自动重构,当 IDE 不能自动化我们需要的转换时,我们则退而使用文本编辑。

通过编辑文本来进行重构是单调且容易出错的。为了减少风险和无聊感,我们尽量减少需要编辑文本的次数。如果我们必须编辑文本,我们更希望编辑影响单个表达式。因此,我们使用自动重构来转换代码,使这种操作成为可能,然后编辑一个表达式,再利用自动重构将其整理回我们的最终目标状态。

当我们第一次描述大规模重构时,我们将逐步展示每个步骤的代码变化。这在页面上占据了相当多的空间,并需要一些阅读时间来跟进。然而,在实践中,这些大规模重构是快速应用的。通常只需几秒钟,最多几分钟。

随着工具的改进,我们预计这里发布的重构技术会迅速更新。各个 IDE 的步骤可能会更名,一些组合可能会作为独立的重构技术实现。在您的环境中进行实验,找到逐步和安全地转换代码的方法,比我们提供的更好,并与世界分享。

我们假设测试覆盖率良好。

正如马丁·福勒在重构:改善现有代码的设计中所说:“[如果你想要重构,重要的前提是拥有可靠的测试。]”良好的测试覆盖率确保了我们只想要改进设计的代码转换没有无意间改变系统行为。在本书中,我们假设您有良好的测试覆盖率。我们不讨论如何编写自动化测试。其他作者已经更详细地讨论了这些主题,例如:通过示例驱动开发肯特·贝克(Addison-Wesley)和由测试指导的面向对象软件开发迈克尔·费瑟斯(Pearson)。

我们在参考文献中列出了更多关于这些主题的书籍。

我们为 Git Bisect 提交

正如我们不明确说明何时运行测试,也不明确说明何时提交更改。假设我们在代码增加了价值时提交更改,无论多么小。

我们知道我们的测试套件并不完美。如果我们意外破坏了某些未被测试捕获的内容,我们希望尽快找到引入错误的提交并修复它。

git bisect 命令自动化了该搜索。我们编写一个新的测试来演示错误,git bisect 对历史进行二进制搜索,找到使该测试失败的第一个提交。

如果我们历史中的提交很大,并且包含杂乱无章的不相关更改,git bisect 的帮助就不那么大了。它无法告诉哪个提交的源更改引入了错误。如果提交混合了重构行为更改,那么恢复错误的重构步骤可能会破坏系统中的其他行为。

因此,我们提交小而专注的更改,将重构与行为更改分开,以便轻松理解发生了什么变化并修复任何错误的更改。出于同样的原因,我们很少压缩提交。

注意

我们更喜欢直接将更改提交到主线分支——“基于主干的开发”——但在分支上工作并较少频繁合并时,通过一系列小的独立提交进行代码更改同样有益。

我们正在做什么?

在接下来的章节中,我们将从Travelator代码库中获取示例,这是一个虚构的用于规划和预订国际陆面旅行的应用程序。我们(仍然是虚构的)用户通过海上、铁路和公路规划路线;搜索住宿和景点;通过价格、时间和景观比较选项;最后通过 Web 和移动前端预订他们的旅行,这些前端通过 HTTP 调用后端服务。

每一章节从 Travelator 系统的不同部分提取一个信息丰富的例子,但它们共享共同的领域概念:货币、货币转换、旅程、行程安排、预订等等。

我们的目标是,就像我们的 Travelator 应用程序一样,这本书将帮助您从 Java 过渡到 Kotlin 规划您的旅程。

让我们开始吧!

足够的闲聊。 您可能急于将所有 Java 代码转换为 Kotlin。 我们将在下一章开始,通过向项目的构建文件添加 Kotlin 支持。

第二章:Java 到 Kotlin 项目

从纯 Java 到混合,再到越来越多的 Kotlin 代码库的旅程的第一步是什么?

策略

当我们,Nat 和 Duncan,第一次将 Kotlin 引入 Java 代码库时,我们是一个包括六名开发人员的小团队的成员,正在构建一个相对初期的项目。我们已经用 Kotlin 部署了一些 Web 应用,但是我们的企业架构师坚持要求我们用 Java 8 编写新系统。那时 Kotlin 1.0 刚发布不久,但在 Google 宣布 Kotlin 是官方 Android 语言之前,架构师对承诺一个未来不确定语言用于战略系统持谨慎态度,因为他们预计这个系统将在未来几十年内使用。

在 Java 中,我们倾向于使用函数式方法,将核心应用程序领域模型设计为通过管道转换的不可变数据类型。然而,我们不断碰到 Java 的限制:实现不可变值类型所需的冗长代码、原始类型和引用类型之间的区别、空引用以及 Streams 缺乏常见的高阶函数。与此同时,我们看到 Kotlin 在行业内和公司内部的采用率不断增加。当我们看到 Google 的公告时,我们决定开始将 Java 转换为 Kotlin。

我们的判断是从核心领域模型开始会带来最大的效益。Kotlin 的数据类显著减少了代码量,在某些情况下,用单一声明替换了数百行代码。我们开始小心翼翼地使用 IntelliJ 转换一个没有依赖于标准库之外其他类的小值类,并检查这如何影响我们 Java 代码库的其他部分。结果令人振奋,它完全没有影响!受到这一成功的鼓舞,我们加快了步伐。每当新功能需要更改 Java 领域模型类时,我们首先将其转换为 Kotlin 数据类,提交转换,然后再实现该功能。

随着越来越多的领域模型逻辑变成纯 Kotlin,我们能够更好地利用 Kotlin 的特性。例如,我们用 Kotlin 标准集合和序列的标准函数替换了对 Stream API 的调用。尽管如此,最大的改进是用可空引用替换了 Java 的 Optional 类型。这简化了我们的代码,并增强了对空安全性的信心。

公司的另一个项目出于不同的原因采用了 Kotlin。他们有一个成熟的 Java 系统,建立在依赖注入框架之上。开发人员发现,框架使用反射和注解使得代码在 IDE 中难以理解和导航。Kotlin 的轻量级闭包语法为定义他们的应用程序结构提供了一种方法,并区分了为整个应用程序、每个 HTTP 请求或每个数据库事务实例化的对象图。他们逐步重构了系统的基础架构,从一个模糊系统架构的框架转向组合函数的风格,并使得架构在代码中可见。这项工作成为了 http4k 网络编程工具包。

正如这两个例子所示,您的选择起点应取决于许多因素,包括您的团队采用 Kotlin 的原因,代码库的大小以及其变化频率。您了解您的项目,并可以决定要进行哪些最重要的更改。

如果您选择 Kotlin 是因为其语言特性,那么将您经常使用的类转换为 Kotlin 是有意义的,就像我们在第一个项目中所做的那样。如果您选择 Kotlin 是为了使用特定的库,那么从 API 开始编写 Kotlin 并进行注解,使得您的 Kotlin 代码对其余应用程序中的 Java 代码更加方便,然后继续进行。

在小团队中,很容易为您的系统建立 Kotlin 编码风格(超出标准风格指南),例如错误处理约定,代码组织到文件中的方式,什么应该是顶层声明,什么应该在对象中等等。

超过一定规模,您会发现 Kotlin 代码可能会因为不同部分的人制定自己的约定而变得不一致。因此,值得从系统中的一个小子团队开始,制定约定并建立示例代码库。一旦确立了一些约定,就可以将努力扩展到团队的其他部分和系统的其他部分。

在本书的其余部分,我们将详细讨论如何进展,如何在引入 Kotlin 的同时保持 Java 代码的可维护性,以及如何利用 Kotlin 的特性在 IntelliJ 执行其转换魔法后进一步简化代码。但这一切都源自第一小步。

在 Java 构建中添加 Kotlin 支持。

如果我们想要重构我们的 Java 到 Kotlin,我们必须首先做的一项更改是使自己能够在代码库中编写 Kotlin 代码。幸运的是,Kotlin 构建工具和 IDE 让这变得非常简单。在 Gradle 构建配置中增加几行代码就可以使其同时编译 Kotlin 和 Java。当我们重新同步构建文件时,IntelliJ 将会获取这些配置,允许我们几乎无缝地在两种语言之间导航、自动完成和重构。

要将 Kotlin 添加到我们的 Gradle 构建中,我们需要添加 Kotlin 插件。Kotlin 支持每个目标的不同插件(JVM、JavaScript 和本地代码),以及用于构建多平台项目的插件。因为我们有一个 Java 项目,所以我们可以忽略其他平台并使用 Kotlin JVM 插件。

我们还需要将 Kotlin 标准库添加到我们的依赖项中,并指定输出字节码将支持的最低 JVM 版本。我们的项目目标是 JDK 11(在撰写本文时为最新的 LTS 版本)。在撰写本文时,Kotlin 编译器可以生成与 JDK 1.6 或 JDK 1.8 兼容的字节码。JDK 1.8 的字节码更高效,并且在 JDK 11 上运行良好,因此我们将选择它。

Kotlin 版本

Kotlin 语言和标准库仍在不断发展中,但 JetBrains 的政策是提供清晰的迁移路径。当我们开始写这本书时,当前的 Kotlin 版本是 1.3。而我们完成时,1.5 版本刚刚发布,废弃了一些我们代码示例中使用的标准 API!我们选择不迁移到它们的替代方案,以便代码能在 Kotlin 1.4 和 1.5 上运行。

这是我们在进行更改之前 build.gradle 的相关部分:

plugins {
    id("java")
}

java.sourceCompatibility = JavaVersion.VERSION_11
java.targetCompatibility = JavaVersion.VERSION_11
... and other project settings ...

dependencies {
    implementation "com.fasterxml.jackson.core:jackson-databind:2.10.0"
    implementation "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.10.0"
    implementation "com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.10.0"
    ... and the rest of our app's implementation dependencies

 testImplementation "org.junit.jupiter:junit-jupiter-api:5.4.2"
 testImplementation "org.junit.jupiter:junit-jupiter-params:5.4.2"
 testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:5.5.2"
 testRuntimeOnly "org.junit.platform:junit-platform-launcher:1.4.2"
 ... and the rest of our app's test dependencies
}

... and the rest of our build rules

示例 2.1 [projects.0:build.gradle] (diff)

添加了 Kotlin 插件之后,我们的构建文件如下所示:

plugins {
    id 'org.jetbrains.kotlin.jvm' version "1.5.0"
}

java.sourceCompatibility = JavaVersion.VERSION_11
java.targetCompatibility = JavaVersion.VERSION_11
... and other project settings ...

dependencies {
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8"
    ... and the rest of our app's dependencies
}

tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile) {
    kotlinOptions {
        jvmTarget = "11"
        javaParameters = true
        freeCompilerArgs = ["-Xjvm-default=all"]
    }
}

... and the rest of our build rules

示例 2.2 [projects.1:build.gradle] (diff)

给出这些更改后,我们可以重新运行构建,并看到…

…构建仍然有效!

如果我们在 IntelliJ 中重新同步 Gradle 项目(这可能在保存时自动发生),我们可以在 IDE 中运行我们的测试和程序。

我们的测试仍然通过,所以我们没有破坏任何东西,但我们也没有证明我们可以在项目中使用 Kotlin。让我们通过编写一个“Hello World”程序来测试一下。我们在 Java 源代码树的根包 src/main/java 下创建一个名为 HelloWorld.kt 的文件:

fun main() {
    println("hello, world")
}

示例 2.3 [projects.2:src/main/java/HelloWorld.kt] (diff)

Kotlin 源代码放置在哪里

Kotlin 构建插件添加了额外的源根目录 src/main/kotlinsrc/test/kotlin,并编译这些目录及其子目录中找到的 Kotlin 源文件。

它还将编译 Java 源树中发现的 Kotlin 源代码,特别是src/main/javasrc/test/java。虽然您可以按语言分隔源文件,将 Java 文件放在java目录中,将 Kotlin 文件放在kotlin目录中,但实际上我们的作者并不在乎。能够查看目录并看到相应包的所有源文件而不是在文件系统中漫游是很好的。然而,为了使其工作,我们将 Kotlin 源代码保留在反映包结构的目录中,而不是利用 Kotlin 能够在单个目录中具有多个包的能力。

同样地,虽然 Kotlin 确实允许在单个类中定义多个公共类,但是当我们在项目中混合 Java 和 Kotlin 时,为了一致性,我们倾向于每个文件一个类。

我们可以通过在 IDE 中单击左侧边栏旁边的小绿箭头来运行它,该箭头位于fun main()旁边。

我们可以使用java命令在命令行中运行我们的构建,并从中运行它。编译名为HelloWorld.kt的源文件将创建一个名为HelloWorldKt的 Java 类文件。稍后我们将更详细地了解 Kotlin 源代码如何转换为 Java 类文件,但现在我们可以使用java命令运行我们的程序,如下所示:

$ java -cp build/classes/kotlin/main HelloWorldKt
hello, world

它活着了!

让我们删除HelloWorld.kt ——它已经完成了它的工作——提交并推送。

现在我们在项目中有了使用 Kotlin 的选择;本章的第一部分提供了一些开始使用它的指针。

接下来

我们预计本章中的技术信息会非常快速地过时,因为 Gradle 及其插件的接口并不是非常稳定的。您当前的 Java 构建文件几乎肯定也与我们的示例在某些关键方面不兼容。尽管如此,将 Kotlin 添加到 Java 构建中通常是直截了当的。

制定从 Java 到 Kotlin 迁移代码的策略更为复杂且具体。或者至少是不同的复杂性和具体性。个别项目应该检查 Java 在哪些地方工作,哪些地方不工作,以及在哪些地方使用 Kotlin 将缓解问题并改进代码。您可以选择深入编写一些 Kotlin 代码,或者将现有的 Java 类转换为 Kotlin。在本书的精神中,我们将采取后者的方法,在第三章,Java 到 Kotlin 类中。

第三章:Java 到 Kotlin 类

类是 Java 中代码组织的基本单位。我们如何将 Java 类转换为 Kotlin,当我们这样做时会看到什么不同?

在本书中,我们将一起在 Travelator 中工作,这是我们虚构的旅行计划 Web 应用程序。想象一下,我们有一个要实现的功能,但我们想趁机把我们的代码做得更好一些。你和 Nat 或 Duncan 之一成对工作(选择你最喜欢的,只是不要让 Nat 知道)。这对是我们重构讨论中的“我们”,不仅仅是你的作者,还有作为 Travelator 团队一部分的你。欢迎加入!

一个简单的值类型

让我们深入代码库的深处,并转换一些现有的 Java 代码为 Kotlin,从EmailAddress开始。这是一个值类型,保存了,你猜对了,电子邮件地址的两个部分:

public class EmailAddress {
    private final String localPart; ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
    private final String domain;

    public static EmailAddress parse(String value) { ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/2.png)
        var atIndex = value.lastIndexOf('@');
        if (atIndex < 1 || atIndex == value.length() - 1)
            throw new IllegalArgumentException(
                "EmailAddress must be two parts separated by @"
            );
        return new EmailAddress(
            value.substring(0, atIndex),
            value.substring(atIndex + 1)
        );
    }

    public EmailAddress(String localPart, String domain) { ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/3.png)
        this.localPart = localPart;
        this.domain = domain;
    }

    public String getLocalPart() { ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/4.png)
        return localPart;
    }

    public String getDomain() { ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/4.png)
        return domain;
    }

    @Override
    public boolean equals(Object o) { ![5](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/5.png)
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        EmailAddress that = (EmailAddress) o;
        return localPart.equals(that.localPart) &&
            domain.equals(that.domain);
    }

    @Override
    public int hashCode() { ![5](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/5.png)
        return Objects.hash(localPart, domain);
    }

    @Override
    public String toString() { ![6](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/6.png)
        return localPart + "@" + domain;
    }
}

示例 3.1 [classes.0:src/main/java/travelator/EmailAddress.java] (diff)

这个类非常简单;它除了包装两个字符串外,不提供任何操作。即便如此,它也有很多代码:

1

值是不可变的,因此该类将其字段声明为 final。

2

有一个静态工厂方法来从字符串parse一个EmailAddress;这调用了主构造函数。

3

字段在构造函数中初始化。

4

其属性的访问方法遵循 JavaBean 命名约定。

5

该类实现了equalshashCode方法,以确保具有相同字段的两个EmailAddress值比较为相等。

6

toString返回规范形式。

你的作者来自 Java 学派,假设我们传递、存储或返回的所有内容都不是 null,除非显式指定。你看不到这个约定,因为它导致参数缺少@Nullable注解或空检查(第四章讨论了可空性)。你能看到的是,为了表示由其他两个值组成的值,需要大量的样板代码。幸运的是,我们的 IDE 为我们生成了equalshashCode方法,但是如果更改了类的字段,我们必须记得删除并重新生成这些方法,以避免混淆的错误。

Java 就到此为止;我们这里是为了 Kotlin。我们如何转换?IntelliJ 很有帮助地提供了一个名为“将 Java 文件转换为 Kotlin 文件”的操作。当我们调用它时,IntelliJ 会询问是否需要更改其他文件以保持一致。因为转换可能会修改整个项目中的文件,最好是同意。

提示

在将 Java 源代码转换为 Kotlin 之前,请确保没有未提交的更改,这样您可以轻松查看转换对代码库其余部分的影响,并在转换做出意外操作时进行还原。

在这种情况下,IntelliJ 不必更改任何其他文件。它已将我们的EmailAddress.java文件替换为同一目录下的EmailAddress.kt,尽管:

class EmailAddress(val localPart: String, val domain: String) {
    override fun equals(o: Any?): Boolean {
        if (this === o) return true
        if (o == null || javaClass != o.javaClass) return false
        val that = o as EmailAddress
        return localPart == that.localPart && domain == that.domain
    }

    override fun hashCode(): Int {
        return Objects.hash(localPart, domain)
    }

    override fun toString(): String {
        return "$localPart@$domain"
    }

    companion object {
        @JvmStatic
        fun parse(value: String): EmailAddress {
            val atIndex = value.lastIndexOf('@')
            require(!(atIndex < 1 || atIndex == value.length - 1)) {
                "EmailAddress must be two parts separated by @"
            }
            return EmailAddress(
                value.substring(0, atIndex),
                value.substring(atIndex + 1)
            )
        }
    }
}

示例 3.2 [classes.2:src/main/java/travelator/EmailAddress.kt] (diff)

Kotlin 类明显更为简洁,因为它在主构造函数中声明了其属性:在类名之后的参数。用val标记的参数被视为属性,因此代替了所有这些 Java 代码:

private final String localPart;
private final String domain;

public EmailAddress(String localPart, String domain) {
    this.localPart = localPart;
    this.domain = domain;
}

public String getLocalPart() {
    return localPart;
}

public String getDomain() {
    return domain;
}

示例 3.3 [classes.1:src/main/java/travelator/EmailAddress.java] (diff)

主构造函数的语法很方便,但它确实影响了类的可扫描性。遵循标准编码约定的 Java 类总是以相同的顺序定义它们的元素:类名、超类、接口,然后,在类主体内部,字段、构造函数和方法。这使得快速浏览类并快速定位感兴趣的特性变得很容易。

要找到 Kotlin 类的各个部分就没那么容易了。Kotlin 类定义有一个头部部分,包括类名、主构造函数(可以包含参数和/或属性定义)、超类(也可能是对超类构造函数的调用)和接口。然后,在类主体内部,还有更多的属性、更多的构造函数、方法和伴生对象。

从 Java 过来,Nat 和 Duncan 起初确实发现阅读类更加困难,尽管我们最终习惯了,但有时我们仍然发现难以将类格式化为最大可读性,特别是头部有很多内容时。一个简单的解决方法是逐行布置构造函数参数列表。在参数列表内部,我们可以使用 Alt-Enter 和“Put parameters on separate lines”来实现这一点。有时,在头部之后加一行空行也有帮助:

class EmailAddress(
    val localPart: String,
    val domain: String
) {

    override fun equals(o: Any?): Boolean {
        if (this === o) return true
        if (o == null || javaClass != o.javaClass) return false
        val that = o as EmailAddress
        return localPart == that.localPart && domain == that.domain
    }

    override fun hashCode(): Int {
        return Objects.hash(localPart, domain)
    }

    override fun toString(): String {
        return "$localPart@$domain"
    }

    companion object {
        @JvmStatic
        fun parse(value: String): EmailAddress {
            val atIndex = value.lastIndexOf('@')
            require(!(atIndex < 1 || atIndex == value.length - 1)) {
                "EmailAddress must be two parts separated by @"
            }
            return EmailAddress(
                value.substring(0, atIndex),
                value.substring(atIndex + 1)
            )
        }
    }
}

示例 3.4 [classes.3:src/main/java/travelator/EmailAddress.kt] (diff)

Kotlin 显然比 Java 更加冗长的一个地方是在它使用伴生对象来托管静态状态和方法的地方,比如parse()。在 Kotlin 中,我们通常更喜欢顶层状态和函数而不是这些类作用域的成员。第八章讨论了其利弊。

我们目前有使用静态方法的 Java 代码,例如测试:

public class EmailAddressTests {

    @Test
    public void parsing() {
        assertEquals(
            new EmailAddress("fred", "example.com"),
            EmailAddress.parse("fred@example.com")
        );
    }

    @Test
    public void parsingFailures() {
        assertThrows(
            IllegalArgumentException.class,
            () -> EmailAddress.parse("@")
        );
        ...
    }

    ...
}

示例 3.5 [classes.0:src/test/java/travelator/EmailAddressTests.java] (diff)

伴生对象与@JVMStatic注解结合使用,意味着在将类转换为 Kotlin 时不必更改此内容,因此我们暂时保留parse如其现状。我们将在第八章中讨论如何重构为顶级函数。

如果您是 Kotlin 的新手,您可能想知道getLocalPart()getDomain()访问器方法去哪了。声明domain属性会导致编译器生成一个私有的domain字段和一个getDomain()方法,以便 Java 代码仍然可以调用它。这是一段支持营销计划的临时代码:

public class Marketing {

    public static boolean isHotmailAddress(EmailAddress address) {
        return address.getDomain().equalsIgnoreCase("hotmail.com");
    }
}

示例 3.6 [classes.3:src/main/java/travelator/Marketing.java] (diff)

你可以看到 Java 通过getDomain()方法访问域属性。相反,当类是 Java 并且具有显式的getDomain()方法时,Kotlin 代码可以调用它作为address.domain。我们将在第十一章中更详细地讨论属性。

到目前为止,将我们的类转换为 Kotlin 已经为我们节省了 14 行代码,但我们还没有完成。像这样的值类型非常有用,但正确和保持正确是如此乏味,Kotlin 在语言级别支持它们。如果我们使用data修饰符标记类,编译器将为我们生成任何未定义的equalshashCodetoString方法。这将EmailAddress类减少到:

data class EmailAddress(
    val localPart: String,
    val domain: String
) {

    override fun toString(): String { ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
        return "$localPart@$domain"
    }

    companion object {
        @JvmStatic
        fun parse(value: String): EmailAddress {
            val atIndex = value.lastIndexOf('@')
            require(!(atIndex < 1 || atIndex == value.length - 1)) {
                "EmailAddress must be two parts separated by @"
            }
            return EmailAddress(
                value.substring(0, atIndex),
                value.substring(atIndex + 1)
            )
        }
    }
}

示例 3.7 [classes.4:src/main/java/travelator/EmailAddress.kt] (diff)

1

我们不想要生成的toString()方法,因此我们定义我们需要的方法。

坦率地说,parse方法仍然让人不爽;它占用了不成比例的空间来执行它的工作。我们将在第九章中最终解决这种紧张情况。不过目前,我们已经将我们的EmailAddress Java 类转换为 Kotlin。

数据类的局限性

数据类的一个缺点是它们没有封装性。我们看到编译器为数据类生成了equalshashCodetoString方法,但没有提到它还生成了一个copy方法,用于创建具有不同属性值的新副本。

例如,以下代码创建一个EmailAddress的副本,其localPart为“postmaster”,域相同:

val postmasterEmail = customerEmail.copy(localPart = "postmaster")

对于许多类型,这非常方便。然而,当一个类抽象其内部表示或在其属性之间维持不变性时,那个copy方法允许客户端代码直接访问值的内部状态,这可能会破坏其不变性。

让我们看看 Travelator 应用中的一个抽象数据类型,Money类:

public class Money {
    private final BigDecimal amount;
    private final Currency currency;

    private Money(BigDecimal amount, Currency currency) { ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
        this.amount = amount;
        this.currency = currency;
    }

    public static Money of(BigDecimal amount, Currency currency) { ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
        return new Money(
            amount.setScale(currency.getDefaultFractionDigits()),
            currency);
    }

    ... and convenience overloads

    public BigDecimal getAmount() { ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/2.png)
        return amount;
    }

    public Currency getCurrency() { ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/3.png)
        return currency;
    }

    @Override
    public boolean equals(Object o) { ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/3.png)
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Money money = (Money) o;
        return amount.equals(money.amount) &&
            currency.equals(money.currency);
    }

    @Override
    public int hashCode() { ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/3.png)
        return Objects.hash(amount, currency);
    }

    @Override
    public String toString() { ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/4.png)
        return amount.toString() + " " + currency.getCurrencyCode();
    }

    public Money add(Money that) { ![5](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/5.png)
        if (!this.currency.equals(that.currency)) {
            throw new IllegalArgumentException(
                "cannot add Money values of different currencies");
        }

        return new Money(this.amount.add(that.amount), this.currency);
    }
}

示例 3.8 [values.4:src/main/java/travelator/money/Money.java] (diff)

1

构造函数是私有的。其他类通过调用静态的Money.of方法来获取Money值,这保证了金额的刻度与货币的最小单位数量一致。大多数货币有一百个最小单位(两位数),但有些货币少一些,有些多一些。例如,日本日元没有最小单位,约旦第纳尔由一千菲尔组成。

of方法遵循了现代 Java 的编码规范,在源代码中区分具有身份的对象(由 new 操作符构造)和从静态方法获取的值。这个规范由 Java 时间 API(例如,LocalDate.of(2020,8,17))和集合 API 的最新添加(例如,List.of(1,2,3)创建一个不可变列表)所遵循。

该类提供了一些方便的of方法重载,用于字符串或整数金额。

2

一个Money值使用 JavaBean 约定公开其金额和货币属性,即使它实际上不是 JavaBean。

3

equalshashCode方法实现了值语义。

4

toString方法返回一个属性的表示形式,可以展示给用户,而不仅仅是用于调试。

5

Money提供了用于计算货币值的操作。例如,你可以将货币值相加。add方法通过直接调用构造函数(而不是使用Money.of)来构造新的Money值,因为BigDecimal.add的结果已经具有正确的刻度,因此我们可以避免在Money.of中设置刻度的开销。

注意

BigDecimal.setScale方法令人困惑。虽然其名称类似于 JavaBean 的 setter 方法,但它实际上并不会改变 BigDecimal 对象。像我们的EmailAddressMoney类一样,BigDecimal是一个不可变的值类型,因此setScale返回一个具有指定刻度的新BigDecimal值。

Sun 在 Java 1.1 标准库中添加了BigDecimal类。此版本还包括 JavaBeans API 的第一个版本。围绕 Beans API 的炒作推广了 JavaBeans 编码约定,并被广泛采用,即使是对于像BigDecimal这样的不是 JavaBeans 的类(参见“Bean Style”)。没有 Java 约定来定义值类型。

如今,我们避免为不改变其接收器的方法使用“set”前缀,并改用强调方法返回接收器变换的名称。一个常见的约定是为影响单个属性的变换使用前缀“with”,这将使我们的Money类中的代码读起来如下:

 amount.withScale(currency.getDefaultFractionDigits())

在 Kotlin 中,我们可以编写扩展函数来修复这样的历史错误。如果我们要编写大量使用BigDecimal进行计算的代码,这样做可能会值得,以提高代码的清晰度:

fun BigDecimal.withScale(int scale, RoundingMode mode) =
    setScale(scale, mode)

Money转换为 Kotlin 会生成以下代码:

class Money
private constructor(
    val amount: BigDecimal,
    val currency: Currency
) {
    override fun equals(o: Any?): Boolean {
        if (this === o) return true
        if (o == null || javaClass != o.javaClass) return false
        val money = o as Money
        return amount == money.amount && currency == money.currency
    }

    override fun hashCode(): Int {
        return Objects.hash(amount, currency)
    }

    override fun toString(): String {
        return amount.toString() + " " + currency.currencyCode
    }

    fun add(that: Money): Money {
        require(currency == that.currency) {
            "cannot add Money values of different currencies"
        }
        return Money(amount.add(that.amount), currency)
    }

    companion object {
        @JvmStatic
        fun of(amount: BigDecimal, currency: Currency): Money {
            return Money(
                amount.setScale(currency.defaultFractionDigits),
                currency
            )
        }

        ... and convenience overloads
    }
}

示例 3.9 [values.5:src/main/java/travelator/money/Money.kt] (diff)

Kotlin 类仍然有一个主构造函数,但该构造函数现在标记为私有。语法有些笨拙:我们重新格式化了翻译器生成的代码,以使其更易于扫描。像EmailAddress.parse一样,静态的of工厂函数现在是伴生对象上的方法,并标记为@JvmStatic。总体而言,该代码比原始 Java 代码并没有更加简洁。

我们可以通过将其变为数据类来进一步缩小吗?

当我们将class改为data class时,IntelliJ 会用警告突出主构造函数的private关键字:

Private data class constructor is exposed via the generated 'copy' method."

这是怎么回事?

Money的实现中隐藏了一个细节。该类在其属性之间维护不变性,保证金额字段的比例等于货币字段的默认小货币位数。私有构造函数阻止Money类外的代码创建违反不变性的值。Money.of(BigDecimal,Currency)方法确保新的Money值符合不变性。add方法维护不变性,因为将具有相同比例的两个BigDecimal值相加会产生一个具有相同比例的BigDecimal,因此可以直接调用构造函数。因此,构造函数只需分配字段,确信从不会使用违反类不变性的参数调用它。

然而,数据类的copy方法总是公开的,因此可以允许客户端代码创建违反不变性的Money值。与EmailAddress不同,像Money类这样的抽象数据类型不能由 Kotlin 数据类实现。

警告

如果必须在其属性之间保持不变性,请勿将值类型定义为数据类。

我们仍然可以通过 Kotlin 的特性使类更加简洁和便利,这些特性我们将在后续章节中遇到。所以我们暂时将Money类放在一边,并在第十二章重新审视它,进行彻底的改造。

接下来

对于大多数类来说,将 Java 转换为 Kotlin 是快速且简单的。结果与现有的 Java 代码完全兼容。

如果我们需要值语义,data类允许我们为像EmailAddress这样的简单类去除更多样板代码。因为数据类创建起来如此迅速简便,并且无需维护,所以我们在 Kotlin 中更频繁地使用它们来定义新的值类型,而不像在 Java 中那样:声明特定于应用程序的“微类型”,包装原始值,保存计算管道的中间结果,或将数据转换为更易于编写应用逻辑的临时结构。

如果我们的值类型必须保持不变或封装它们的表示,数据类就不适合。在这种情况下,我们必须自己实现值语义。

我们将EmailAddressMoney仍然看起来很像 Java…Java 风格?...Java-esque?...不管怎样。在接下来的章节中,我们将探讨如何应用 Kotlin 习惯用语,使代码更加简洁、类型安全,更易于构建代码。第九章,从多表达式函数到单表达式函数,探讨了如何通过重构将计算函数和方法,比如这两个类的toString方法或者equalshashCode方法,改为单表达式形式,使之更为简洁。在第十二章,从函数到操作符,我们通过定义操作符而非方法,使Money类型在 Kotlin 中更便于使用。

并非我们所有的 Java 类都是值类型。盛行的 Java 编码风格偏爱可变对象。在第五章,从 Bean 到值类型,我们研究了在 Java 使用可变对象的地方使用值类型的优势,并展示了如何将代码从变异对象重构为转换值。

Java 代码中的许多类存在于保存静态实用方法。在 Kotlin 中,函数和数据是一流的功能。它们不需要声明为类的成员。第八章,从静态方法到顶层函数,探讨了如何将实用方法的 Java 类转换为顶层声明。

第四章:Optional to Nullable

托尼·霍尔(Tony Hoare)可能认为空引用的发明是他的十亿美元错误[¹],但在软件系统中我们仍然需要表示缺失的事物。我们如何利用 Kotlin 在保持软件安全的同时接纳空值呢?

表示缺失

对于 Java 程序员来说,Kotlin 最具吸引力的特性之一可能是其在类型系统中对空值的表示。这是 Java 和 Kotlin 的另一个不同的领域。

在 Java 8 之前,Java 依赖于约定、文档和直觉来区分可以为 null 或不可以为 null 的引用。我们可以推断从集合中返回项的方法必须能够返回null,但addressLine3可以为null吗?或者在没有信息时我们使用空字符串?

多年来,您的作者及其同事们已经形成了一个惯例,假设 Java 引用在未做特殊标记时为非 null。因此,我们可能会将字段命名为addressLine3OrNull,或者方法命名为previousAddressOrNull。在代码库中,这效果还不错(即使有点啰嗦,并需要永久警惕以避免NullPointerException的瘟疫)。

一些代码库选择使用@Nullable@NotNullable注解,通常由检查工具支持,以确保正确性。2014 年发布的 Java 8 加强了对注解的支持,使得像Checker Framework这样的工具可以静态检查不仅仅是空指针安全。然而更重要的是,Java 8 还引入了标准的Optional类型。

到了这个时候,许多 JVM 开发者已经涉猎过 Scala。他们开始欣赏在可能缺失时使用Optional类型(在 Scala 的标准库中称为Option),以及在不可能缺失时使用普通引用的优势。Oracle 混淆了局势,告诉开发者不要将其Optional用于字段或参数值,但和 Java 8 引入的许多功能一样,它已经足够好,并被广泛应用于 Java 的主流使用中。

根据其年龄,您的 Java 代码可能会使用某些或所有这些策略来处理缺失。在一个几乎从未见到Null⁠Pointer​Excep⁠tion的代码库中是完全可能的,但现实情况是这需要大量的工作。Java 受空指针的困扰,并因其半吊子的Optional类型而感到尴尬。

相比之下,Kotlin 拥抱 null。将选项性作为类型系统的一部分,而不是标准库的一部分,意味着 Kotlin 代码库在处理缺失值时具有令人耳目一新的统一性。并非一切都完美:如果 Map<K, V>.get(key) 没有找到 key 的值,会返回 null;但是 List<T>.get(index) 在索引 index 没有值时会抛出 IndexOutOfBoundsException。同样,Iterable<T>.first() 在没有元素时会抛出 NoSuchElementException 而不是返回 null。这些不完美通常是为了与 Java 的向后兼容性而设计的。

在 Kotlin 拥有自己的 API 的地方,它们通常是安全使用 null 表示可选属性、参数和返回值的好例子,我们可以通过学习它们来学到很多。一旦体验了一流的空安全性,回到不支持此功能的语言会感觉不安全;你清楚地意识到,只需一次解引用操作就可能引发 NullPointerException,而且你在依赖约定来找到安全路径时。

函数式程序员可能建议你在 Kotlin 中使用可选类型(也称为 Maybe),而不是空安全性。我们建议不要这样做,尽管它会给你提供使用相同(单子化的—是的,我们说了)工具来表示可能的缺失、错误、异步等选项。在 Kotlin 中不使用 Optional 的一个原因是你将失去专门设计支持空安全性的语言特性;在这方面,Kotlin 的粒度与 Scala 等语言是不同的。

不使用包装类型来表示可选性的另一个理由是微妙但重要的。在 Kotlin 类型系统中,TT? 的子类型。如果你有一个不能为 null 的 String,你可以随时将其用在需要可空 String 的地方。相比之下,T 不是 Optional<T> 的子类型。如果你有一个返回 Optional<String> 的函数,并且后来发现总是返回结果,将返回类型更改为 String 将会打破所有客户端的兼容性。如果返回类型是可空的 String?,你可以在保持兼容性的同时将其强化为 String。同样的情况也适用于数据结构的属性:你可以轻松地从可选的转变为非可选的,但具有讽刺意味的是,使用 Optional 则不行。

你们的作者们喜爱 Kotlin 对空安全的支持,并学会依赖它来解决许多问题。摆脱避免 null 的习惯需要一段时间,但一旦做到了,就可以探索和利用全新的表达方式。

在 Travelator 中没有这种便利似乎是一种遗憾,所以让我们看看如何从使用 Optional 的 Java 代码迁移到 Kotlin 和可空类型。

重构:从 Optional 到 Nullable

Travelator 旅行被分成Leg,其中每个Leg是一次不间断的旅程。这是我们在代码中找到的其中一个实用函数:

public class Legs {

    public static Optional<Leg> findLongestLegOver(
        List<Leg> legs,
        Duration duration
    ) {
        Leg result = null;
        for (Leg leg : legs) {
            if (isLongerThan(leg, duration))
                if (result == null ||
                    isLongerThan(leg, result.getPlannedDuration())
                ) {
                    result = leg;
                }
        }
        return Optional.ofNullable(result);
    }

    private static boolean isLongerThan(Leg leg, Duration duration) {
        return leg.getPlannedDuration().compareTo(duration) > 0;
    }
}

示例 4.1 [nullability.0:src/main/java/travelator/Legs.java] (差异)

测试检查代码是否按预期工作,并允许我们一目了然地看到其行为:

public class LongestLegOverTests {

    private final List<Leg> legs = List.of(
        leg("one hour", Duration.ofHours(1)),
        leg("one day", Duration.ofDays(1)),
        leg("two hours", Duration.ofHours(2))
    );
    private final Duration oneDay = Duration.ofDays(1);

    @Test
    public void is_absent_when_no_legs() {
        assertEquals(
            Optional.empty(),
            findLongestLegOver(emptyList(), Duration.ZERO)
        );
    }

    @Test
    public void is_absent_when_no_legs_long_enough() {
        assertEquals(
            Optional.empty(),
            findLongestLegOver(legs, oneDay)
        );
    }

    @Test
    public void is_longest_leg_when_one_match() {
        assertEquals(
            "one day",
            findLongestLegOver(legs, oneDay.minusMillis(1))
                .orElseThrow().getDescription()
        );
    }

    @Test
    public void is_longest_leg_when_more_than_one_match() {
        assertEquals(
            "one day",
            findLongestLegOver(legs, Duration.ofMinutes(59))
                .orElseThrow().getDescription()
        );
    }

    ...
}

示例 4.2 [nullability.0:src/test/java/travelator/LongestLegOverTests.java] (差异)

让我们看看我们能做些什么来让 Kotlin 变得更好。将Legs.java转换为 Kotlin,我们得到了这个(稍作重新格式化后):

object Legs {
    @JvmStatic
    fun findLongestLegOver(
        legs: List<Leg>,
        duration: Duration
    ): Optional<Leg> {
        var result: Leg? = null
        for (leg in legs) {
            if (isLongerThan(leg, duration))
                if (result == null ||
                    isLongerThan(leg, result.plannedDuration))
                    result = leg
        }
        return Optional.ofNullable(result)
    }

    private fun isLongerThan(leg: Leg, duration: Duration): Boolean {
        return leg.plannedDuration.compareTo(duration) > 0
    }
}

示例 4.3 [nullability.3:src/main/java/travelator/Legs.kt] (差异)

方法参数与我们预期的一样,Kotlin 的List<Leg>会透明地接受一个java.util.List。(我们在第六章中更详细地讨论了 Java 和 Kotlin 集合。)值得在这里提到的是,当 Kotlin 函数声明一个非空参数(这里是legsduration)时,编译器会在函数体之前插入一个空检查。这样,如果 Java 调用者偷偷传入一个null,我们会立即知道。由于这些防御性检查,Kotlin 会尽可能地检测到意外的空值,与 Java 相反,在 Java 中,一个引用可以在很长一段时间和空间内设置为null,直到最终爆炸的地方。

回到例子,Kotlin 的for循环与 Java 的非常相似,除了使用in关键字而不是:,并且同样适用于任何扩展Iterable的类型。

转换后的findLongestLegOver代码在 Kotlin 中不太符合惯用法。(可以说,自从引入了流之后,它在 Java 中也不太符合惯用法。)我们应该寻找一些更具表意性的东西来替代for循环,但是让我们暂时搁置这个问题,因为我们的主要任务是从Optional迁移到可空类型。我们将通过逐个转换测试来说明这一点,这样我们就会有一个混合体,就像我们要迁移的代码库中一样。为了在客户端利用可空性,它们必须是 Kotlin,所以让我们转换测试:

class LongestLegOverTests {
    ...
    @Test
    fun is_absent_when_no_legs() {
        Assertions.assertEquals(
            Optional.empty<Any>(),
            findLongestLegOver(emptyList(), Duration.ZERO)
        )
    }

    @Test
    fun is_absent_when_no_legs_long_enough() {
        Assertions.assertEquals(
            Optional.empty<Any>(),
            findLongestLegOver(legs, oneDay)
        )
    }

    @Test
    fun is_longest_leg_when_one_match() {
        Assertions.assertEquals(
            "one day",
            findLongestLegOver(legs, oneDay.minusMillis(1))
                .orElseThrow().description
        )
    }

    @Test
    fun is_longest_leg_when_more_than_one_match() {
        Assertions.assertEquals(
            "one day",
            findLongestLegOver(legs, Duration.ofMinutes(59))
                .orElseThrow().description
        )
    }

    ...
}

示例 4.4 [nullability.4:src/test/java/travelator/LongestLegOverTests.kt] (差异)

现在为了逐步迁移,我们需要两个版本的findLongestLegOver:现有的返回Optional<Leg>的版本和返回Leg?的新版本。我们可以通过提取当前实现的要点来做到这一点。目前是这样的:

@JvmStatic
fun findLongestLegOver(
    legs: List<Leg>,
    duration: Duration
): Optional<Leg> {
    var result: Leg? = null
    for (leg in legs) {
        if (isLongerThan(leg, duration))
            if (result == null ||
                isLongerThan(leg, result.plannedDuration))
                result = leg
    }
    return Optional.ofNullable(result)
}

示例 4.5 [nullability.4:src/main/java/travelator/Legs.kt] (diff)

对这个 findLongestLegOver 的所有部分进行“提取函数”。我们不能给它相同的名称,所以我们使用 longestLegOver;我们将其设为公共,因为这是我们的新接口:

@JvmStatic
fun findLongestLegOver(
    legs: List<Leg>,
    duration: Duration
): Optional<Leg> {
    var result: Leg? = longestLegOver(legs, duration)
    return Optional.ofNullable(result)
}

fun longestLegOver(legs: List<Leg>, duration: Duration): Leg? {
    var result: Leg? = null
    for (leg in legs) {
        if (isLongerThan(leg, duration))
            if (result == null ||
                isLongerThan(leg, result.plannedDuration))
                result = leg
    }
    return result
}

示例 4.6 [nullability.5:src/main/java/travelator/Legs.kt] (diff)

重构留下了 findLongestLegOver 中的一个残留 result 变量。我们可以选择它,然后“内联”以得到:

@JvmStatic
fun findLongestLegOver(
    legs: List<Leg>,
    duration: Duration
): Optional<Leg> {
    return Optional.ofNullable(longestLegOver(legs, duration))
}

示例 4.7 [nullability.6:src/main/java/travelator/Legs.kt] (diff)

现在我们有了两个版本的接口,一个是基于另一个定义的。我们可以让我们的 Java 客户端使用 findLongestLegOver 中的 Optional,并将我们的 Kotlin 客户端转换为调用可空返回的 longestLegOver。让我们通过测试来展示转换。

我们先处理缺失的调用。它们目前调用 assert⁠Equals​(Optional.empty<Any>(), findLongestLegOver…)

@Test
fun is_absent_when_no_legs() {
    assertEquals(
        Optional.empty<Any>(),
        findLongestLegOver(emptyList(), Duration.ZERO)
    )
}

@Test
fun is_absent_when_no_legs_long_enough() {
    assertEquals(
        Optional.empty<Any>(),
        findLongestLegOver(legs, oneDay)
    )
}

示例 4.8 [nullability.6:src/test/java/travelator/LongestLegOverTests.kt] (diff)

所以我们将它们改为 assertNull(longestLegOver(...)

@Test
fun `is absent when no legs`() {
    assertNull(longestLegOver(emptyList(), Duration.ZERO))
}

@Test
fun `is absent when no legs long enough`() {
    assertNull(longestLegOver(legs, oneDay))
}

示例 4.9 [nullability.7:src/test/java/travelator/LongestLegOverTests.kt] (diff)

请注意,我们已更改测试名称以使用 backtick quoted identifiers。如果我们在带有 _ 测试中的下划线的 function_names 上按 Alt-Enter,IntelliJ 将为我们执行此操作。

现在是不返回空的调用:

@Test
fun is_longest_leg_when_one_match() {
    assertEquals(
        "one day",
        findLongestLegOver(legs, oneDay.minusMillis(1))
            .orElseThrow().description
    )
}

@Test
fun is_longest_leg_when_more_than_one_match() {
    assertEquals(
        "one day",
        findLongestLegOver(legs, Duration.ofMinutes(59))
            .orElseThrow().description
    )
}

示例 4.10 [nullability.6:src/test/java/travelator/LongestLegOverTests.kt] (diff)

Optional.orElseThrow()(Java 10 之前的 get() 的 Kotlin 等效)的 Kotlin 版本是 !!(叹号叹号或该死)操作符。Java 的 orElseThrow 和 Kotlin 的 !! 都会返回值,如果没有值则抛出异常。Kotlin 逻辑上会抛出 NullPointerException。Java 逻辑上会抛出 NoSuchElementExecption;他们只是以不同的方式考虑缺失!只要我们没有依赖于异常的类型,我们就可以将 findLongestLegOver(...).orElseThrow() 替换为 longestLegOver(...)!!

@Test
fun `is longest leg when one match`() {
    assertEquals(
        "one day",
        longestLegOver(legs, oneDay.minusMillis(1))
            !!.description
    )
}

@Test
fun `is longest leg when more than one match`() {
    assertEquals(
        "one day",
        longestLegOver(legs, Duration.ofMinutes(59))
            ?.description
    )
}

示例 4.11 [nullability.8:src/test/java/travelator/LongestLegOverTests.kt] (diff)

我们已经将第一个非空返回测试(is longest leg when one match)转换为使用!!运算符。如果它失败了(虽然它并没有,但我们喜欢为这些事情做计划),它将抛出NullPointerException而不是提供良好的诊断信息。在第二种情况下,我们使用了安全调用操作符?.来解决这个问题,它仅在接收者不为null时继续评估。这意味着如果腿确实为null,错误信息将如下所示,这要好得多:

Expected :one day
Actual   :null

测试是我们在实践中使用!!的少数几个地方之一,即使在这里通常还有更好的选择。

我们可以通过将客户端重构来进行这种重构,将它们转换为 Kotlin,然后使用 longestLegOver。一旦我们将它们全部转换完毕,就可以删除返回 OptionalfindLongestLegOver

重构成惯用的 Kotlin

现在这个示例中的所有代码都是 Kotlin,并且我们已经看到了如何从可选类型迁移到可空类型。我们可以在此停止,但是考虑到我们进行额外重构的政策,我们将继续前进,看看这段代码还能教给我们什么。

这是当前版本的 Legs

object Legs {
    fun longestLegOver(
        legs: List<Leg>,
        duration: Duration
    ): Leg? {
        var result: Leg? = null
        for (leg in legs) {
            if (isLongerThan(leg, duration))
                if (result == null ||
                    isLongerThan(leg, result.plannedDuration))
                    result = leg
        }
        return result
    }

    private fun isLongerThan(leg: Leg, duration: Duration): Boolean {
        return leg.plannedDuration.compareTo(duration) > 0
    }
}

示例 4.12 [nullability.9:src/main/java/travelator/Legs.kt] (diff)

函数包含在一个 object 中,因为我们的 Java 方法是静态的,所以转换需要一个地方来放置它们。正如我们将在 第八章 中看到的,Kotlin 不需要这种额外的命名空间级别,因此我们可以在 longestLegOver 上执行“移动到顶层”的操作。在撰写本文时,这并不是很有效,因为 IntelliJ 未能将调用 isLongerThan 的函数带入,使其保留在 Legs 中。但这个破损很容易修复,留下一个顶层函数和现有代码中修复的引用:

fun longestLegOver(
    legs: List<Leg>,
    duration: Duration
): Leg? {
    var result: Leg? = null
    for (leg in legs) {
        if (isLongerThan(leg, duration))
            if (result == null ||
                isLongerThan(leg, result.plannedDuration))
                result = leg
    }
    return result
}

private fun isLongerThan(leg: Leg, duration: Duration) =
    leg.plannedDuration.compareTo(duration) > 0

示例 4.13 [nullability.10:src/main/java/travelator/Legs.kt] (diff)

你可能已经注意到 isLongerThan 已经失去了它的大括号和返回语句。我们将讨论单表达式函数的利弊,在 第九章 中详细说明。

在我们进行这个重构的时候,isLongerThan(leg, ...) 这个短语有些奇怪。在英语中读起来并不对劲。毫无疑问,我们对扩展函数的迷恋可能会使你感到厌烦(尤其是在 第十章 的结尾之前),但是在你还愿意继续之际,让我们在 leg 参数上按下 Alt-Enter 并选择“将参数转换为接收者”,这样我们就可以编写 leg.isLongerThan(...)

fun longestLegOver(
    legs: List<Leg>,
    duration: Duration
): Leg? {
    var result: Leg? = null
    for (leg in legs) {
        if (leg.isLongerThan(duration))
            if (result == null ||
                leg.isLongerThan(result.plannedDuration))
                result = leg
    }
    return result
}

private fun Leg.isLongerThan(duration: Duration) =
    plannedDuration.compareTo(duration) > 0

示例 4.14 [nullability.11:src/main/java/travelator/Legs.kt] (diff)

到目前为止,我们所有的更改都是结构性的,改变了代码的定义位置和调用方式。结构性重构在本质上是相当安全的(大多数情况下,而不是完全)。它们可以改变依赖多态性(通过方法或函数)或反射的代码行为,但如果代码继续编译,那它可能仍然是有效的。

现在我们将注意力转向 longestLegOver 中的算法。重构算法更危险,特别是那些依赖变异的算法,因为工具支持转换它们并不好。尽管如此,我们有良好的测试,通过阅读它很难弄清楚它的功能,所以让我们看看我们能做什么。

IntelliJ 提供的唯一建议是用 > 替换 compareTo,所以让我们首先这样做。此时,至少 Duncan 已经用完了重构的天赋(如果我们实际上在配对,也许你会有建议?),所以决定从头开始重写这个函数。

为了重新实现功能,我们要问自己,“代码尝试做什么?” 答案很明显,在函数名 longestLegOver 中已经给出了:帮助我们计算最长的腿。为了实现这个计算,我们可以找到最长的腿,如果它比持续时间长,就返回它,否则返回 null。在函数开头输入 legs. 后,我们查看建议,并找到 maxByOrNull。我们最长的腿将是 legs.maxByOrNull(Leg::plannedDuration)。这个 API 友好地返回 Leg?(并包含短语 orNull)来提醒我们,如果 legs 是空的,它不能给出结果。将我们的算法“找到最长的腿,如果它比持续时间长,返回它,否则返回 null”直接转换成代码,我们得到:

fun longestLegOver(
    legs: List<Leg>,
    duration: Duration
): Leg? {
    val longestLeg: Leg? = legs.maxByOrNull(Leg::plannedDuration)
    if (longestLeg != null && longestLeg.plannedDuration > duration)
        return longestLeg
    else
        return null
}

示例 4.15 [nullability.12:src/main/java/travelator/Legs.kt] (差异)

这通过了测试,但是多次返回看起来很丑陋。IntelliJ 会友好地建议将 returnif 中提取出来:

fun longestLegOver(
    legs: List<Leg>,
    duration: Duration
): Leg? {
    val longestLeg: Leg? = legs.maxByOrNull(Leg::plannedDuration)
    return if (longestLeg != null && longestLeg.plannedDuration > duration)
        longestLeg
    else
        null
}

示例 4.16 [nullability.13:src/main/java/travelator/Legs.kt] (差异)

现在,Kotlin 的空安全支持允许多种方式来重构这个问题,这取决于你的喜好。

我们可以使用 Elvis 操作符 ?:,它如果左侧为 null 则评估为左侧,否则评估为右侧。这让我们可以在没有最长腿的情况下提前返回:

fun longestLegOver(
    legs: List<Leg>,
    duration: Duration
): Leg? {
    val longestLeg = legs.maxByOrNull(Leg::plannedDuration) ?:
        return null
    return if (longestLeg.plannedDuration > duration)
        longestLeg
    else
        null
}

示例 4.17 [nullability.14:src/main/java/travelator/Legs.kt] (差异)

我们可以采用单个 ?.let 表达式。?. 如果给定 null 则评估为 null;否则,它会将最长的腿导入 let 块:

fun longestLegOver(
    legs: List<Leg>,
    duration: Duration
): Leg? =
    legs.maxByOrNull(Leg::plannedDuration)?.let { longestLeg ->
        if (longestLeg.plannedDuration > duration)
            longestLeg
        else
            null
    }

示例 4.18 [nullability.15:src/main/java/travelator/Legs.kt] (差异)

因此,在let内部,longestLeg不能为null。这很简洁,是一个令人愉悦的单表达式,但一眼看去可能难以理解。用when明确表达选项更清晰:

fun longestLegOver(
    legs: List<Leg>,
    duration: Duration
): Leg? {
    val longestLeg = legs.maxByOrNull(Leg::plannedDuration)
    return when {
        longestLeg == null -> null
        longestLeg.plannedDuration > duration -> longestLeg
        else -> null
    }
}

示例 4.19 [nullability.17:src/main/java/travelator/Legs.kt] (差异)

为了进一步简化,我们需要一个技巧,邓肯(撰写此文的人)迄今未能内化:takeIf如果断言为true,则返回其接收者;否则返回null。这正是我们先前let块的逻辑。因此,我们可以写成:

fun longestLegOver(
    legs: List<Leg>,
    duration: Duration
): Leg? =
    legs.maxByOrNull(Leg::plannedDuration)?.takeIf { longestLeg ->
        longestLeg.plannedDuration > duration
    }

示例 4.20 [nullability.16:src/main/java/travelator/Legs.kt] (差异)

根据我们团队对 Kotlin 的经验,这可能太微妙了。Nat 认为没问题,但我们会选择显式表达,所以when版本会留下来,至少在下次重构之前是这样。

最后,让我们将legs参数转换为扩展函数的接收者。这使我们能够将函数重命名为更少可疑的内容:

fun List<Leg>.longestOver(duration: Duration): Leg? {
    val longestLeg = maxByOrNull(Leg::plannedDuration)
    return when {
        longestLeg == null -> null
        longestLeg.plannedDuration > duration -> longestLeg
        else -> null
    }
}

示例 4.21 [nullability.18:src/main/java/travelator/Legs.kt] (差异)

在我们完成本章之前,抽出时间将此版本与原始版本进行比较。旧版本有什么优势吗?

public class Legs {

    public static Optional<Leg> findLongestLegOver(
        List<Leg> legs,
        Duration duration
    ) {
        Leg result = null;
        for (Leg leg : legs) {
            if (isLongerThan(leg, duration))
                if (result == null ||
                    isLongerThan(leg, result.getPlannedDuration())
                ) {
                    result = leg;
                }
        }
        return Optional.ofNullable(result);
    }

    private static boolean isLongerThan(Leg leg, Duration duration) {
        return leg.getPlannedDuration().compareTo(duration) > 0;
    }
}

示例 4.22 [nullability.0:src/main/java/travelator/Legs.java] (差异)

通常我们会说“这取决于”,但在这种情况下,我们认为新版本在几乎所有方面都更好。它更短,更简单;更容易理解其工作原理;在大多数情况下,它导致对getPlannedDuration()的调用更少,这是一个相对昂贵的操作。如果我们在 Java 中采取相同的方法呢?直接翻译如下:

public class Legs {

    public static Optional<Leg> findLongestLegOver(
        List<Leg> legs,
        Duration duration
    ) {
        var longestLeg = legs.stream()
            .max(Comparator.comparing(Leg::getPlannedDuration));
        if (longestLeg.isEmpty()) {
            return Optional.empty();
        } else if (isLongerThan(longestLeg.get(), duration)) {
            return longestLeg;
        } else {
            return Optional.empty();
        }
    }

    private static boolean isLongerThan(Leg leg, Duration duration) {
        return leg.getPlannedDuration().compareTo(duration) > 0;
    }
}

示例 4.23 [nullability.1:src/main/java/travelator/Legs.java] (差异)

实际上,这并不差,但与 Kotlin 版本相比,你可以看到Optional几乎在每行方法中都添加了噪音。因此,尽管存在与 Kotlin takeIf相同的理解问题,使用Optional.filter版本可能更可取。也就是说,邓肯无法在运行测试之前确认其有效性,但 Nat 更喜欢这个版本。

public static Optional<Leg> findLongestLegOver(
    List<Leg> legs,
    Duration duration
) {
    return legs.stream()
        .max(Comparator.comparing(Leg::getPlannedDuration))
        .filter(leg -> isLongerThan(leg, duration));
}

示例 4.24 [nullability.2:src/main/java/travelator/Legs.java] (diff)

向前迈进

在我们的代码中,信息的存在或缺失是不可避免的。通过将其提升为一级状态,Kotlin 确保我们在需要时考虑缺失,并在不需要时不被其压倒。相比之下,Java 的 Optional 类型显得笨拙。幸运的是,当我们还没有准备好将所有代码转换为 Kotlin 时,我们可以轻松地从 Optional 转换为可空类型,并同时支持两者。

在第十章,从函数到扩展函数中,我们将看到可空类型如何与其他 Kotlin 语言特性——安全调用和 Elvis 运算符,以及扩展函数——结合,形成一个粒度,导致设计与我们在 Java 中编写的设计截然不同。

但这已经超出了我们的范畴。在下一章中,我们将看一下典型的 Java 类,并将其翻译成典型的 Kotlin 类。从 Java 到 Kotlin 的翻译不仅仅是句法上的转换:这两种语言在对可变状态的接受上也有所不同。

¹ “空引用:百亿美元的错误” YouTube 上的视频

第五章:从豆到值

许多 Java 项目已经采用了可变的 JavaBeans 或 POJO(普通的旧 Java 对象)约定来表示数据。然而,可变性带来了复杂性。为什么不可变值是一个更好的选择,以及如何在代码库中减少可变性的成本?

正如我们在“Bean Style”中讨论的那样,JavaBeans 的引入是为了允许以 Visual Basic 风格开发拖放式 GUI 构建器。开发人员可以将按钮拖放到表单上,更改其标题和图标,然后连接一个点击处理程序。在幕后,GUI 构建器会编写代码来实例化一个按钮对象,然后调用开发人员更改的属性的 setter。

要定义一个 JavaBean,一个类需要有一个默认(无参数)构造函数,用于其属性的 getter,以及用于其可变属性的 setter。(我们将忽略Serializable要求,因为即使是 Sun 公司也从未真正认真对待过这一点。)这对于具有许多属性的对象是有意义的。GUI 组件通常具有前景色和背景色、字体、标签、边框、大小、对齐方式、填充等等。大多数情况下,这些属性的默认值都很好,因此仅为特定值调用 setter 可以最大程度地减少生成的代码量。即使在今天,对于 GUI 工具包来说,可变的组件模型仍然是一个坚实的选择。

然而,当引入 JavaBeans 时,我们认为大多数对象都是可变的,不仅仅是 UI 组件。我是说,为什么不呢?对象的目的是封装属性并管理它们之间的关系。它们被设计来解决诸如在组件边界更改时更新其宽度或在添加项目时更新购物车总额之类的问题。对象是管理可变状态问题的解决方案。在当时,Java 因其具有不可变的String类而显得相当激进(尽管它自己也无法抑制,并且仍然选择了可变的Date)。

作为一个行业,我们如今对此有了更加复杂的理解。我们明白我们可以使用对象来表示不同类型的事物 — 值、实体、服务、操作、事务等等。然而,Java 对象的默认模式仍然是 JavaBean,一个具有其属性的可变对象,其属性有相应的 getter 和 setter。尽管对于 UI 工具包可能是合适的,但这不是一个好的默认模式。对于大多数我们想要用对象表示的事物,值会更好。

Value 是英语中一个负载很重的术语。在计算机领域,我们说变量、参数和字段具有值:它们绑定的基本类型或引用。当我们在本书中提到 a value 时,我们指的是一种特定类型的基本类型或引用:那些具有值语义的类型。如果只有它的值在其交互中是显著的,而不是其标识,则对象具有值语义。Java 的原始类型都具有值语义:每个 7 都等于其他每个 7。对象可能具有值语义,也可能没有;特别是,可变对象没有。在后面的章节中,我们将探讨更细致的区别,但现在,让我们简单地将 value 定义为一个不可变的数据片段,将 value type 定义为定义不可变数据行为的类型。

因此,7 是一个值,而装箱的 Integer 是一个值类型(因为装箱类型是不可变的),banana 是一个值(因为 String 是不可变的),URI 是一个值(因为 URI 是不可变的),但 java.util.Date 不是一个值类型(因为我们可以调用 setYear 等方法来修改日期)。

不可变的 DBConnectionInfo 实例是一个值,但 Database 的实例不是一个值,即使其所有属性都是不可变的。这是因为它不是一个数据片段;它是一种访问和修改数据片段的手段。

JavaBeans 是值吗?UI 组件的 JavaBeans 不是值,因为 UI 组件不仅仅是数据——两个外观相同的按钮有不同的标识。对于用于表示普通数据的 bean,这将取决于它们是否是不可变的。可以创建不可变的 bean,但大多数开发者更倾向于将它们视为普通的 Java 对象。

POJOs 是值吗?这个术语是用来指代那些不必扩展框架类型就能够有用的类的。它们通常表示数据,并符合 JavaBeans 的访问器方法约定。许多 POJOs 将不具有默认构造函数,而是定义构造函数来初始化没有明显默认值的属性。由于这个原因,不可变的 POJOs 是常见的,可能具有值语义。但可变的 POJOs 仍然似乎是默认的,以至于许多人认为在 Java 中的面向对象编程就等同于可变对象。可变的 POJOs 不是值。

总结一下,一个 bean 从技术上讲可以是一个值,但很少是。在现代 Java 时代,POJOs 更经常具有值语义。所以虽然 Beans to Values 听起来很简洁,但在这一章中,我们真正关注的是从可变对象到不可变数据的重构,也许我们应该把它称为 Mutable POJOs to Values。希望您原谅这个粗糙的标题。

为什么我们应该偏好值?

值是不可变数据。为什么我们应该更喜欢不可变对象而不是可变对象,以及代表数据的对象而不是其他类型的对象?这是本书中我们将一再讨论的主题。现在,让我们只说不可变对象更容易推理,因为它们不会改变,所以:

  • 我们可以将它们放入集合中或将它们用作映射键。

  • 我们永远不必担心不可变集合在我们迭代其内容时会发生变化。

  • 我们可以在不必深拷贝初始状态的情况下探索不同的场景(这也使得实现撤销和重做变得容易)。

  • 我们可以在不同线程之间安全地共享不可变对象。

将 Bean 重构为值

让我们来看看将可变 bean 或 POJO 重构为值的过程。

Travelator 有一个移动应用程序,Android 版本是用 Java 编写的。在该代码中,我们使用一个UserPreferencesJavaBean 来表示用户的偏好设置。

public class UserPreferences {

    private String greeting;
    private Locale locale;
    private Currency currency;

    public UserPreferences() {
        this("Hello", Locale.UK, Currency.getInstance(Locale.UK));
    }

    public UserPreferences(String greeting, Locale locale, Currency currency) {
        this.greeting = greeting;
        this.locale = locale;
        this.currency = currency;
    }

    public String getGreeting() {
        return greeting;
    }

    public void setGreeting(String greeting) {
        this.greeting = greeting;
    }

    ... getters and setters for locale and currency
}

示例 5.1 [beans-to-values.0:src/main/java/travelator/mobile/UserPreferences.java] (diff)

Application有一个preferences属性,它将其传递给需要它的视图:

public class Application {

    private final UserPreferences preferences;

    public Application(UserPreferences preferences) {
        this.preferences = preferences;
    }

    public void showWelcome() {
        new WelcomeView(preferences).show();
    }

    public void editPreferences() {
        new PreferencesView(preferences).show();
    }
    ...
}

示例 5.2 [beans-to-values.0:src/main/java/travelator/mobile/Application.java] (diff)

(任何与实际 UI 框架的相似性,无论是现存还是已故,都是纯属巧合。)

最后,当用户进行更改时,PreferencesView会更新其preferences。我们知道已经发生了变化,因为将调用onThingChange()

public class PreferencesView extends View {

    private final UserPreferences preferences;
    private final GreetingPicker greetingPicker = new GreetingPicker();
    private final LocalePicker localePicker = new LocalePicker();
    private final CurrencyPicker currencyPicker = new CurrencyPicker();

    public PreferencesView(UserPreferences preferences) {
        this.preferences = preferences;
    }

    public void show() {
        greetingPicker.setGreeting(preferences.getGreeting());
        localePicker.setLocale(preferences.getLocale());
        currencyPicker.setCurrency(preferences.getCurrency());
        super.show();
    }

    protected void onGreetingChange() {
        preferences.setGreeting(greetingPicker.getGreeting());
    }

    protected void onLocaleChange() {
        preferences.setLocale(localePicker.getLocale());
    }

    protected void onCurrencyChange() {
        preferences.setCurrency(currencyPicker.getCurrency());
    }
    ...
}

示例 5.3 [beans-to-values.0:src/main/java/travelator/mobile/PreferencesView.java] (diff)

尽管简单,这种设计充满了典型可变数据的复杂性,例如:

  • 如果PreferencesViewWelcomeView都处于活动状态,则WelcomeView可能与当前值不同步。

  • UserPreferences的相等性和哈希码取决于其属性的值,这些值可能会改变。因此,我们不能可靠地在集合中使用UserPreferences或将其用作映射键。

  • 没有任何迹象表明WelcomeView仅从偏好中读取。

  • 如果读取和写入发生在不同的线程上,我们必须在偏好属性级别管理同步。

在我们重构以使用不可变值之前,让我们将ApplicationUser​Pre⁠ferences转换为 Kotlin,这将帮助我们看到我们模型的本质。 Application很简单:

class Application(
    private val preferences: UserPreferences
) {
    fun showWelcome() {
        WelcomeView(preferences).show()
    }

    fun editPreferences() {
        PreferencesView(preferences).show()
    }
    ...
}

示例 5.4 [beans-to-values.1:src/main/java/travelator/mobile/Application.kt] (diff)

UserPreferences更加复杂。在 IntelliJ 中的“转换为 Kotlin”产生了这个:

class UserPreferences @JvmOverloads constructor(
    var greeting: String = "Hello",
    var locale: Locale = Locale.UK,
    var currency: Currency = Currency.getInstance(Locale.UK)
)

示例 5.5 [beans-to-values.1:src/main/java/travelator/mobile/UserPreferences.kt] (差异)

这是一个非常复杂的转换。@JVMOverloads 注解告诉编译器生成多个构造函数,允许默认 greetinglocalecurrency 的组合。这不是我们原来的 Java 所做的;它只有两个构造函数(其中一个是默认的无参数构造函数)。

在这个阶段,我们没有改变应用程序的功能,只是简化了其表达方式。那些 var(而不是 val)属性表明我们有可变数据。值得在此时提醒自己的是 Kotlin 编译器将为每个属性生成一个私有字段、一个 getter 方法和一个 setter 方法,以便我们的 Java 继续将数据类视为一个 bean。Kotlin 接受 beans 命名约定,并且 var 属性允许我们定义可变 bean,无论好坏。

假设更糟,现在如何使 UserPreferences 不可变?毕竟,我们需要应用中所见的首选项反映用户所做的任何更改。答案是移动突变。与本书中许多重构相似,我们要将问题(在这种情况下是突变)上移。也就是说,朝着入口点,或者说进入更高级别、更应用特定的代码。

我们不会突变首选项,而是将引用更新到 Application 中。我们将使用的引用将是 PreferencesView 返回的更新副本。简而言之,我们的策略是将对可变对象的不可变引用替换为对不可变值的可变引用。为什么?这样做既减少了潜在移动部件的数量和可见性,也减少了引起问题的突变可见性。

我们会逐步进行,首先将 PreferencesView 转换为 Kotlin:

class PreferencesView(
    private val preferences: UserPreferences
) : View() {
    private val greetingPicker = GreetingPicker()
    private val localePicker = LocalePicker()
    private val currencyPicker = CurrencyPicker()

    override fun show() {
        greetingPicker.greeting = preferences.greeting
        localePicker.locale = preferences.locale
        currencyPicker.currency = preferences.currency
        super.show()
    }

    protected fun onGreetingChange() {
        preferences.greeting = greetingPicker.greeting
    }

    ... onLocaleChange, onCurrencyChange
}

示例 5.6 [beans-to-values.3:src/main/java/travelator/mobile/PreferencesView.kt] (差异)

show() 方法重写了 View 中的一个方法,该方法使视图可见并阻塞调用线程直到其被解散。为了避免突变,我们希望有一个版本,该版本返回对应于应用了任何更改的 UserPreferences 的副本,但我们无法给 View 方法添加返回类型。因此,我们将把 show 重命名为 showModal,在 super.show() 返回后返回现有的可变 preferences 属性:

fun showModal(): UserPreferences {
    greetingPicker.greeting = preferences.greeting
    localePicker.locale = preferences.locale
    currencyPicker.currency = preferences.currency
    show()
    return preferences
}

示例 5.7 [beans-to-values.4:src/main/java/travelator/mobile/PreferencesView.kt] (差异)

Application.editPreferences() 调用了 preferencesView.show(),并依赖于它与 PreferencesView 共享可变对象的事实来查看任何编辑。现在我们将把 Application.preferences 改为可变属性,从 showModal 的结果设置:

class Application(
    private var preferences: UserPreferences ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
) {
    ...

    fun editPreferences() {
        preferences = PreferencesView(preferences).showModal()
    }
    ...
}

示例 5.8 [beans-to-values.4:src/main/java/travelator/mobile/Application.kt] (diff)

1

现在是 var

showModal 方法当前返回与构造函数中传递给视图的相同对象,因此实际上这并没有改变任何东西。事实上,我们处于最坏的两个世界:一个可变引用到可变数据。

虽然我们还没有完成;我们可以通过使 PreferencesView 中的 preferences 属性也可变来使情况更糟,以便在更新任何 UI 元素时将其设置为新的 UserPreferences 对象:

class PreferencesView(
    private var preferences: UserPreferences
) : View() {
    private val greetingPicker = GreetingPicker()
    private val localePicker = LocalePicker()
    private val currencyPicker = CurrencyPicker()

    fun showModal(): UserPreferences {
        greetingPicker.greeting = preferences.greeting
        localePicker.locale = preferences.locale
        currencyPicker.currency = preferences.currency
        show()
        return preferences
    }

    protected fun onGreetingChange() {
        preferences = UserPreferences(
            greetingPicker.greeting,
            preferences.locale,
            preferences.currency
        )
    }

    ... onLocaleChange, onCurrencyChange
}

示例 5.9 [beans-to-values.5:src/main/java/travelator/mobile/PreferencesView.kt] (diff)

实际上,我们说“更糟”,但现在已删除了 UserPreferences 上所有 setters 的使用。没有 setters,我们可以将其变成一个真正的值,通过在其构造函数中初始化其属性并且永不修改它们来实现。在 Kotlin 中,这意味着将 var 属性改为 val 并内联任何对默认构造函数的使用。这使我们可以将 UserPreferences 简化为:

data class UserPreferences(
    val greeting: String,
    val locale: Locale,
    val currency: Currency
)

示例 5.10 [beans-to-values.6:src/main/java/travelator/mobile/UserPreferences.kt] (diff)

敏锐的读者会注意到我们偷偷地将 UserPreferences 设为数据类。之前我们没有这样做,因为它是可变的。虽然 Kotlin 允许 可变数据类,但我们对它们应该比对其他可变类更加警惕,因为数据类实现了 equalshashCode

到目前为止我们取得了什么成就呢?我们用两个可变引用替换了共享可变数据的两个不可变引用。现在我们可以一眼看出哪些视图可以更新首选项,如果我们必须在线程之间管理更新,我们可以在应用程序级别进行管理。

然而,在 PreferencesView 中拥有可变引用有点烦人。我们可以通过根本不持有引用,而是在 showModal 中将首选项传递进去来修复它。PreferencesView 不需要 UserPreferences 属性;它只需在显示自身之前将其值分发到 UI,并在完成时重新收集它们:

class PreferencesView : View() {
    private val greetingPicker = GreetingPicker()
    private val localePicker = LocalePicker()
    private val currencyPicker = CurrencyPicker()

    fun showModal(preferences: UserPreferences): UserPreferences {
        greetingPicker.greeting = preferences.greeting
        localePicker.locale = preferences.locale
        currencyPicker.currency = preferences.currency
        show()
        return UserPreferences(
            greeting = greetingPicker.greeting,
            locale = localePicker.locale,
            currency = currencyPicker.currency
        )
    }
}

示例 5.11 [beans-to-values.7:src/main/java/travelator/mobile/PreferencesView.kt] (diff)

这里仍然存在突变,因为我们正在将值设置到选择器中,但这些是 UI 组件,并且只有默认构造函数,因此这必须发生在某个地方。为了完成工作,我们还必须更新Application,将PreferencesView构造函数中的preferences参数移动到showModal中:

class Application(
    private var preferences: UserPreferences
) {
    fun showWelcome() {
        WelcomeView(preferences).show()
    }

    fun editPreferences() {
        preferences = PreferencesView().showModal(preferences)
    }
    ...
}

示例 5.12 [beans-to-values.7:src/main/java/travelator/mobile/Application.kt] (差异)

现在我们只有一个地方可以更改偏好设置,通过editPreferences中的赋值来明确。而且showWelcome只能从对象中读取是明确的。即使没有任何更改,从showModal返回一个新的UserPreferences可能看起来有点浪费。如果你习惯于共享可变对象,这甚至可能看起来是危险的。然而,在值的世界里,几乎所有意图相同的两个具有相同值的UserPreferences对象都是相同的(参见“对象相等性”),你必须在一个非常受限制的环境中才能检测到额外的分配。

继续前进

在本章中,我们看到了不可变值相对于可变对象的一些优点。重构示例展示了如何通过将对可变对象的不可变引用替换为对不可变对象的可变引用,将突变迁移到我们应用程序的入口点和事件处理程序。结果是,我们的代码不再需要处理可变性的后果和复杂性。

话虽如此,JavaBeans 设计用于在用户界面框架中使用,并且从许多方面来看,UI 是可变对象的最后堡垒。如果我们有更严格的活性需求——例如,在问候偏好更改时更新WelcomeView——我们可能更喜欢使用带有更改事件的共享对象,而不是使用不可变值。

将可变对象转换为值和转换是一个重复的主题。第六章,Java 到 Kotlin 集合,继续讨论与集合相关的内容。第十四章,积累对象到转换,探讨了如何将使用累积参数的代码转换为使用集合的高阶函数。

第六章:Java 到 Kotlin 集合

乍一看,Java 和 Kotlin 拥有非常相似的集合库;它们确实以非常无缝的方式进行互操作。它们之间有什么区别,是什么促使了这些差异,以及我们在从 Java 转向 Kotlin 集合时需要注意什么?

Java 集合

在第五章中,我们看到 Java 在我们将对象视为基本上是有状态和可变的时代成长。这在集合中尤为明显——我的意思是,如果不能向列表中添加东西,那么它的意义何在?我们通过创建一个空的集合并向其中添加项目来构建集合。需要从购物车中移除商品?改变列表。洗牌一副牌?显然这会改变牌组的顺序。每次需要牛奶或带猫去兽医时,我们不会创建新的纸质待办事项列表。可变集合反映了我们的现实世界经验。

在其发布时,其内置集合的质量是采用 Java 的一个很好的理由。在那些日子里,许多语言的标准库中没有可调整大小的集合。对象技术使我们能够安全地定义和使用可变集合。现在我们被赋予了这种超能力,自然而然地使用它是理所当然的,所以我们继续使用VectorHashTable,如同 Sun 所期望的那样。也就是说,我们创建它们,然后改变它们。因为所有的构造函数都创建了空集合,所以没有选择。

Java 2(在 Java 必须与 C#版本号竞争之前是版本 1.2)引入了修订后的集合库。这清理了临时的VectorStackHashtable类,并创建了一个通用的Collection接口,其中包括更有用的实现,如ArrayListHashSet。现在可以将一个集合创建为另一个集合的副本。静态的Collections类提供了一些有用的实用操作,如sortreverse。Java 5 引入了泛型,并巧妙地将它们改造成了现有的集合,因此现在我们可以声明像List<Journey>这样的类型。

尽管 Java 集合保持可变性,但非常可变。不仅可以添加和删除项目,还有像排序这样的操作仅被定义为改变;没有标准库函数可以返回一个已排序的List的副本。

正如我们一直在说的,变异是我们在复杂性方面许多问题的根源,因为它允许一个地方的状态与另一个地方的状态不同步。例如,在 Travelator 中,我们可以将路线表示为JourneyList。还有一个叫做痛苦分数的概念:痛苦分数越低,路线可能越愉快。以下是我们如何为路线计算痛苦分数的方法:

public static int sufferScoreFor(List<Journey> route) {
    Location start = getDepartsFrom(route);
    List<Journey> longestJourneys = longestJourneysIn(route, 3);
    return sufferScore(longestJourneys, start);
}

示例 6.1 [collections.0:src/main/java/travelator/Suffering.java] (diff)

那个start本地变量没有太多帮助,所以我们决定将其内联:

public static int sufferScoreFor(List<Journey> route) {
    List<Journey> longestJourneys = longestJourneysIn(route, 3);
    return sufferScore(longestJourneys, getDepartsFrom(route));
}

示例 6.2 [collections.1:src/main/java/travelator/Suffering.java] (diff)

我们的测试通过了,我们发布到生产环境,但我们收到了 bug 报告,表明并非一切顺利。深入分析后我们发现:

public static Location getDepartsFrom(List<Journey> route) {
    return route.get(0).getDepartsFrom();
}

示例 6.3 [collections.0:src/main/java/travelator/Routes.java] (diff)

public static List<Journey> longestJourneysIn(
    List<Journey> journeys,
    int limit
) {
    journeys.sort(comparing(Journey::getDuration).reversed()); ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
    var actualLimit = Math.min(journeys.size(), limit);
    return journeys.subList(0, actualLimit);
}

示例 6.4 [collections.0:src/main/java/travelator/Suffering.java] (diff)

1

sort改变了journeys参数

啊,现在我们可以看到,找到最长旅程已经改变了明显的出发位置。一个开发者在一个参数(journeys)上调用了方法来解决一个问题,结果在系统的其他地方导致了代码的破坏!你只需花费数百个小时来调试由于别名错误而引起的问题,就会得出一个结论:不可变数据会是一个更好的默认选择。对于 JDK 开发者来说,显然是在引入 Java 2 之后,我们一直被困在可变集合接口中。

公平地说,尽管 Java 的集合理论上是可变的,在实践中多年来变得越来越不可变。即使在最初,也可以用例如Collections.unmodifiableList来包装一个集合。结果仍然是一个List;它仍然有所有的变异方法,但它们都会抛出UnsupportedOperationException。我们本可以通过在loadJourneys的结果中包装一个UnmodifiableList来发现shortestJourneyIn修改我们的列表的问题。任何结合这两者的代码的测试都会很快失败,尽管只有在运行时而不是在编译时才会如此。遗憾的是,我们不能依赖类型系统来确保正确性,但我们不能回到过去,所以这是一个实用的补丁。

UnmodifiableList包装列表解决了依赖代码修改我们的集合的问题。然而,如果原始列表可以被修改,我们仍然可能会遇到问题,因为包装器会透过到其底层集合。因此,UnmodifiableList不能保证它永远不变,只是通过包装器无法修改它。在这些情况下,如果我们要与原始集合的变化隔离开来,我们必须对原始列表进行防御性拷贝。Java 10 中添加了List.copyOf(collection)来复制一个底层集合作为AbstractImmutableList,既不可修改也不受原始集合变化的影响。

在是否修改集合的源或目标的二次猜测,并采取适当的行动,这是令人厌烦且容易出错的。这个问题适用于任何可变数据,但修改集合尤为恶劣,因为我们经常会从中提取出可能会过时的值(例如departsFrom)。与其在每个函数边界处采取防御性副本,许多团队,包括本书作者在内,采用了一种更简单和更有效的策略。

这种策略并不阻止我们在函数内创建可变集合并填充它们,但是代码应该只能更改它刚刚创建的集合。一旦我们将引用作为结果返回,我们应该将其视为不可变的 —— 创建,而不是变异。我们偶尔可能通过包装Collections.unmodifiableList(…)等来强制这种不可变性,但在一个配合良好的开发团队中,这是不必要的,因为没有人会将共享集合视为可变的。

当然,规则总有例外,通常出于效率考虑,我们希望将一个集合作为可变集合共享的情况。在这些情况下,我们可以通过命名(accumulator是个不错的开始)和尽可能限制共享的范围来获取特例情况的许可。在函数内部是理想的,类中的私有方法之间是可以接受的,跨模块边界极少这样做。第十四章讨论了如何避免在这些情况下(显然)使用可变集合。

采用这种约定的项目团队可以生成简单且可靠的软件,尽管集合是可变的。总体而言,将集合视为不可变的好处超过了类型系统欺骗你的问题,因为值实在是太宝贵了。虽然 JVM 的库可能仍然依赖于可变性是正常的,但这是一个 Java 正在向不可变性转变的案例,领先于这种变化要比落后于它要好。

Kotlin 集合

与 Java 相比,Kotlin 及其标准库是在可变性已经过时的时代设计的。然而,与 Java 的平稳互操作性是一个关键目标,而 Java 有可变集合。Scala 曾试图引入其自己的复杂持久(不可变但数据共享)集合,但这迫使开发人员在互操作边界上在集合之间复制信息,这既低效又令人讨厌。Kotlin 如何圆满解决这一难题,并使不可变集合与 Java 无缝互操作呢?

Kotlin 开发人员从 Java 集合接口中移除了变异方法,并将它们发布在 kotlin.collections 包中,例如 Collection<E>List<E> 等。然后,这些接口被 MutableCollection<E>MutableList<E> 等扩展,添加了 Java 的变异方法。因此,在 Kotlin 中,我们有 MutableList,它是 List 的子类型,ListCollection 的子类型。MutableList 还实现了 MutableCollection

表面上看,这是一个简单的方案。可变集合与非可变集合具有相同的操作,再加上变异方法。将MutableList作为期望List的参数传递是安全的,因为所有List方法都将存在并可调用。按照里斯科夫替换原则,我们可以用MutableList替换List而不影响程序的正确性。

一点编译器魔法允许 Kotlin 代码接受 java.util.List 作为 kotlin.collections.List

val aList: List<String> = SomeJavaCode.mutableListOfStrings("0", "1")
aList.removeAt(1) // doesn't compile

这种魔法也使得 Kotlin 可以将 Java 的 List 接受为 kotlin.collections.MutableList

val aMutableList: MutableList<String> = SomeJavaCode.mutableListOfStrings(
    "0", "1")
aMutableList.removeAt(1)
assertEquals(listOf("0"), aMutableList)

实际上,因为 Java 的 List 在这里实际上是可变的,我们可以(但几乎总是不应该)向下转换为 Kotlin 的 MutableList 并进行变异:

val aList: List<String> = SomeJavaCode.mutableListOfStrings("0", "1")
val aMutableList: MutableList<String> = aList as MutableList<String>
aMutableList.removeAt(1)
assertEquals(listOf("0"), aMutableList)

反过来,编译器将允许在需要 java.util.List 的地方同时接受 kotlin.collections.MutableListkotlin.collections.List

val aMutableList: MutableList<String> = mutableListOf("0", "1")
SomeJavaCode.needsAList(aMutableList)
val aList: List<String> = listOf("0", "1")
SomeJavaCode.needsAList(aList)

表面上看,到目前为止一切都非常合理。不幸的是,当涉及到可变性时,替换比巴巴拉·里斯科夫的原则更复杂。正如我们在“Java 集合”中看到的,仅因为我们在类型为 kotlin.collections.List 的引用上看不到变异器,这并不意味着内容不能改变。实际类型可能是可变的 java.util.List。在某些方面,这在 Kotlin 中更糟,因为我们可以将 MutableList 转换为传递的 List

val aMutableList = mutableListOf("0", "1")
val aList: List<String> = aMutableList

现在假设我们在某处接受了一个List<String>,并且对其不可变性深信不疑:

class AValueType(
    val strings: List<String>
) {
    val first: String? = strings.firstOrNull()
}

一切看起来都很正常:

val holdsState = AValueType(aList)
assertEquals(holdsState.first, holdsState.strings.first())

但等等,我们还保留着对 MutableList 的引用吗?

aMutableList[0] = "banana"
assertEquals(holdsState.first, holdsState.strings.first()) ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)

1

期望 "0",实际 "banana"

AValueType原来毕竟是可变的!因此,初始化时的first可能会过时。拥有非可变集合接口并未导致不可变集合!

不可变、只读、可变

官方说法是,非可变 Kotlin 集合不是不可变的,而是集合的只读视图。与 Java 的 UnmodifiableList 一样,只读集合不能通过其接口更改,但可能通过其他机制更改。只有真正的不可变集合才能保证永远不会更改。

JVM 上可能存在真正的不可变集合(例如 java.util.List.of(...) 的结果),但这(尚)不是 Kotlin 的标准功能。

这是将可变集合扩展到否则不可变集合的不幸后果;接收非可变集合的接收者不能修改它,但不能知道它不会改变,因为类型为非可变 List 的引用实际上可能指向类型为 MutableList 的对象。

这个问题的严格解决方案是通过不具有子类型关系来将可变和不可变集合分离。在这种方案中,如果我们有一个可变列表并希望得到其不可变副本,我们必须复制数据。一个很好的类比是 StringBuilder。这实际上是一个可变的 String,但不是 String 的子类型。一旦我们有了想要发布的结果,我们需要调用 .toString(),并且对 StringBuilder 的后续修改不会影响先前的结果。Clojure 和 Scala 都采用了这种构建器方法来处理他们的可变集合 —— 为什么 Kotlin 没有?

我们怀疑答案是:因为 Kotlin 的设计者,像您的作者一样,已采纳了 “不要改变共享集合” 中描述的惯例。如果将任何作为参数接收、作为结果返回或以其他方式在代码之间共享的集合视为不可变,则使可变集合扩展非可变集合实际上是相当安全的。诚然在“相当”的意义上,而不是完全的,但仍然是收益大于成本的。

Kotlin 集合使得这一方案更加强大。在 Java 中,我们面临的情况是,理论上可以改变任何集合,因此类型系统无法告诉我们何时是安全的或其他情况。在 Kotlin 中,如果我们将所有普通引用声明为不可变版本,我们可以使用一个 MutableCollection 来记录我们实际上认为集合是可变的时候。尽管接受了在很大程度上是理论风险的条件下,我们通过与 Java 的非常简单和高效的互操作性获得了回报。实用主义是 Kotlin 的特点之一;在这种情况下,可以表达为“尽可能安全,但不要过度安全。”

我们说另一种表达“不要改变共享集合”的方式是,我们的代码只应该改变它刚刚创建的集合。如果我们查看 Kotlin 标准库,我们可以看到这一点。例如,这里是 map 的(简化版本的)定义:

inline fun <T, R> Iterable<T>.map(transform: (T) -> R): List<R> {
    val result = ArrayList<R>()
    for (item in this)
        result.add(transform(item))
    return result
}

这里的列表通过变异就地构建,然后作为只读返回。这既简单又高效。从技术上讲,我们可以将结果向下转型为 MutableList 并更改结果,但我们不应该这样做。相反,我们应该接受结果类型的表面价值。这样,任何使用此集合的代码都不必担心它会更改。

从 Java 转换到 Kotlin 集合

由于早期描述的 Java 和 Kotlin 集合之间的平滑互操作,使用集合转换代码通常是无缝的,至少在语法层面上是这样的。但是,如果我们的 Java 代码依赖于对集合的变异,我们可能需要特别小心,以避免在 Kotlin 中破坏不变式。

一个很好的方法是在将 Java 代码转换为 Kotlin 之前,将其重构为“不要改变共享集合”中使用的约定。这就是我们在这里要做的。

修复 Java

让我们再次看一下我们之前在 Travelator 中看到的代码。我们一直在看的静态方法位于名为 Suffering 的类中:

public class Suffering {

    public static int sufferScoreFor(List<Journey> route) {
        Location start = getDepartsFrom(route);
        List<Journey> longestJourneys = longestJourneysIn(route, 3);
        return sufferScore(longestJourneys, start);
    }

    public static List<Journey> longestJourneysIn(
        List<Journey> journeys,
        int limit
    ) {
        journeys.sort(comparing(Journey::getDuration).reversed()); ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
        var actualLimit = Math.min(journeys.size(), limit);
        return journeys.subList(0, actualLimit);
    }

    public static List<List<Journey>> routesToShowFor(String itineraryId) {
        var routes = routesFor(itineraryId);
        removeUnbearableRoutes(routes);
        return routes;
    }

    private static void removeUnbearableRoutes(List<List<Journey>> routes) {
        routes.removeIf(route -> sufferScoreFor(route) > 10);
    }

    private static int sufferScore(
        List<Journey> longestJourneys,
        Location start
    ) {
        return SOME_COMPLICATED_RESULT();
    }
}

示例 6.5 [collections.0:src/main/java/travelator/Suffering.java] (差异)

1

longestJourneysIn 通过改变其参数来违反我们的规则。

正如我们之前所看到的,因为 longestJourneysIn 改变了其参数,所以我们无法改变 sufferScoreForgetDepartsFromlongestJourneysIn 的评估顺序。在我们修复此问题之前,我们必须确保没有其他代码依赖于此变异。这可能很困难,这本身就是不允许从一开始修改集合的一个很好的理由。如果我们对测试有信心,我们可以尝试进行编辑并查看是否会有任何问题。否则,我们可能需要添加测试和/或使用我们的代码和依赖性分析进行推理。让我们决定在 Travelator 中继续进行更改是安全的。

我们不想就地对集合进行排序,因此我们需要一个函数,该函数返回一个已排序的列表的副本,而不修改原始列表。即使在 Java 16 中似乎也没有这样的功能。耐人寻味的是,List.sort 实际上会创建其自身的已排序版本,然后使自身变异以匹配:

@SuppressWarnings({"unchecked", "rawtypes"})
default void sort(Comparator<? super E> c) {
    Object[] a = this.toArray();
    Arrays.sort(a, (Comparator) c);
    ListIterator<E> i = this.listIterator();
    for (Object e : a) {
        i.next();
        i.set((E) e);
    }
}

这正显示了在编写本文时的 Java 8 时代,Java 的思维方式是多么易变。现在有了 Stream.sorted,但根据我们的经验,流很少在处理小集合时表现良好(参见第十三章)。也许我们不应该关心性能,但我们无法自制!我们通过推理来为自己的放纵辩护,即我们知道代码中有几个地方目前正在就地排序,因此必须更改以删除共享集合的变异。通过推理,List.sort 的作者实际上对 Java 的效率知道一些,我们复制他们的代码并写下:

@SuppressWarnings("unchecked")
public static <E> List<E> sorted(
    Collection<E> collection,
    Comparator<? super E> by
) {
    var result = (E[]) collection.toArray();
    Arrays.sort(result, by);
    return Arrays.asList(result);
}

示例 6.6 [collections.3:src/main/java/travelator/Collections.java] (差异)

在我们继续之前,值得考虑的是我们如何确保这段代码是正确的。因为变异的原因,这确实相当困难。我们必须确保 Arrays.sort 不会影响输入集合,这意味着需要检查 Collection.toArray 的文档。当我们这样做时,我们找到了魔法词语“调用者因此可以自由修改返回的数组”,所以没问题;我们已经将输入与输出解耦了。这个函数是接受变异在我们创建集合的作用域内部的经典示例,但在外部则不是——创建,而不是变异。

在我们继续深入探讨的时候,我们需要知道我们返回什么,以及它是否可变。Arrays.asList 返回一个 ArrayList,但不是标准的 ArrayList。这个 ArrayListArrays 内部私有的,并且会直接写入我们的 result。由于它由数组支持,所以我们不能添加或移除项。它是不可调整大小的。事实证明,Java 集合不仅仅是可变、不可变或不可修改的;它们有时是可变的,只要我们不改变它们的结构!这些区别都没有在类型系统中反映出来,因此可能会进行类型保持的更改,这些更改在运行时根据代码路径和我们尝试修改的方式而定,可能会导致我们尝试修改的集合在运行时中断。这正是为什么我们应该尽量避开这个问题,并且永远不要修改共享集合的另一个原因。

回到我们的重构,我们可以在 longestJourneysIn 中使用我们的新 sorted 来停止修改共享集合。

使用 sort,我们有:

public static List<Journey> longestJourneysIn(
    List<Journey> journeys,
    int limit
) {
    journeys.sort(comparing(Journey::getDuration).reversed());
    var actualLimit = Math.min(journeys.size(), limit);
    return journeys.subList(0, actualLimit);
}

示例 6.7 [collections.2:src/main/java/travelator/Suffering.java] (差异)

我们的新 sorted 函数允许我们编写:

static List<Journey> longestJourneysIn(
    List<Journey> journeys,
    int limit
) {
    var actualLimit = Math.min(journeys.size(), limit);
    return sorted(
        journeys,
        comparing(Journey::getDuration).reversed()
    ).subList(0, actualLimit);
}

示例 6.8 [collections.3:src/main/java/travelator/Suffering.java] (差异)

现在 sufferScoreFor 不再受 longestJourneysIn 中副作用的影响,我们可以内联其局部变量:

public static int sufferScoreFor(List<Journey> route) {
    return sufferScore(
        longestJourneysIn(route, 3),
        getDepartsFrom(route));
}

示例 6.9 [collections.4:src/main/java/travelator/Suffering.java] (差异)

内联局部变量可能看起来并没有太多好处,但这是一个更大主题的小例子。在第七章中,我们将看看如何通过避免变异以不安全的方式重构代码。

离开来看看 sufferScoreFor 的调用者时,我们发现:

public static List<List<Journey>> routesToShowFor(String itineraryId) {
    var routes = routesFor(itineraryId);
    removeUnbearableRoutes(routes);
    return routes;
}

private static void removeUnbearableRoutes(List<List<Journey>> routes) {
    routes.removeIf(route -> sufferScoreFor(route) > 10);
}

示例 6.10 [collections.4:src/main/java/travelator/Suffering.java] (差异)

嗯,这太病态突变了,可能写成了书中的例子!至少 removeUnbearableRoutes 告诉我们它必须通过返回 void 来突变某些东西。我们可以通过将函数更改为返回它正在突变的参数并使用结果来采取小步骤,以此来使事情变得更糟然后再变得更好:

public static List<List<Journey>> routesToShowFor(String itineraryId) {
    var routes = routesFor(itineraryId);
    routes = removeUnbearableRoutes(routes);
    return routes;
}

private static List<List<Journey>> removeUnbearableRoutes
    (List<List<Journey>> routes
) {
    routes.removeIf(route -> sufferScoreFor(route) > 10);
    return routes;
}

示例 6.11 [collections.5:src/main/java/travelator/Suffering.java] (diff)

这次我们将使用 Stream.filter 来替换 removeUnbearableRoutes 中的突变。顺便说一句,我们可以借此机会将其重命名为:

public static List<List<Journey>> routesToShowFor(String itineraryId) {
    var routes = routesFor(itineraryId);
    routes = bearable(routes);
    return routes;
}

private static List<List<Journey>> bearable
    (List<List<Journey>> routes
) {
    return routes.stream()
        .filter(route -> sufferScoreFor(route) <= 10)
        .collect(toUnmodifiableList());
}

示例 6.12 [collections.6:src/main/java/travelator/Suffering.java] (diff)

注意现在更容易找到一个好的简短名称来命名我们的函数;removeUnbearableRoutes 变成了 bearable

routesToShowFor 中对 routes 的重新赋值看起来很丑,但是却是故意的,因为它允许我们与 第五章 中的重构进行对比。在那里,我们将原地突变数据更改为用突变后的值替换引用,这也是我们在这里所做的。当然,我们真的不需要局部变量,所以让我们完全摆脱它。连续两次调用内联重构就可以很好地完成:

public static List<List<Journey>> routesToShowFor(String itineraryId) {
    return bearable(routesFor(itineraryId));
}

private static List<List<Journey>> bearable
    (List<List<Journey>> routes
) {
    return routes.stream()
        .filter(route -> sufferScoreFor(route) <= 10)
        .collect(toUnmodifiableList());
}

示例 6.13 [collections.7:src/main/java/travelator/Suffering.java] (diff)

转换为 Kotlin

现在我们已经从我们的 Java 集合中移除了所有的突变,是时候转换为 Kotlin 了。在我们的 Suffering 类上,“将 Java 文件转换为 Kotlin 文件”做得相当不错,但是当我们编写这个时,它会混淆,推断集合及其泛型类型的可空性。转换后,我们不得不从一些复杂的类型中去除 ?,比如 List<List<Journey?>>?,以得到:

object Suffering {
    @JvmStatic
    fun sufferScoreFor(route: List<Journey>): Int {
        return sufferScore(
            longestJourneysIn(route, 3),
            Routes.getDepartsFrom(route)
        )
    }

    @JvmStatic
    fun longestJourneysIn(
        journeys: List<Journey>,
        limit: Int
    ): List<Journey> {
        val actualLimit = Math.min(journeys.size, limit)
        return sorted(
            journeys,
            comparing { obj: Journey -> obj.duration }.reversed()
        ).subList(0, actualLimit)
    }

    fun routesToShowFor(itineraryId: String?): List<List<Journey>> {
        return bearable(Other.routesFor(itineraryId))
    }

    private fun bearable(routes: List<List<Journey>>): List<List<Journey>> {
        return routes.stream()
            .filter { route -> sufferScoreFor(route) <= 10 }
            .collect(Collectors.toUnmodifiableList())
    }

    private fun sufferScore(
        longestJourneys: List<Journey>,
        start: Location
    ): Int {
        return SOME_COMPLICATED_RESULT()
    }
}

示例 6.14 [collections.8:src/main/java/travelator/Suffering.kt] (diff)

我们还重新格式化并整理了一些导入。正面的是,调用我们的 Kotlin 的 Java 代码没有变化。例如,在这里,测试将普通的 Java List 传递给 Kotlin longestJourneyIn

@Test public void returns_limit_results() {
    assertEquals(
        List.of(longJourney, mediumJourney),
        longestJourneysIn(List.of(shortJourney, mediumJourney, longJourney), 2)
    );
}

示例 6.15 [collections.8:src/test/java/travelator/LongestJourneyInTests.java] (diff)

回到 Kotlin,我们现在可以利用 Kotlin 集合上提供的许多实用程序来简化代码。例如,longestJourneysIn 是这样的:

@JvmStatic
fun longestJourneysIn(
    journeys: List<Journey>,
    limit: Int
): List<Journey> {
    val actualLimit = Math.min(journeys.size, limit)
    return sorted(
        journeys,
        comparing { obj: Journey -> obj.duration }.reversed()
    ).subList(0, actualLimit)
}

示例 6.16【collections.8:src/main/java/travelator/Suffering.kt】差异

sortedByDescending替换sorted,用take替换subList得到:

@JvmStatic
fun longestJourneysIn(journeys: List<Journey>, limit: Int): List<Journey> =
    journeys.sortedByDescending { it.duration }.take(limit)

示例 6.17【collections.9:src/main/java/travelator/Suffering.kt】差异

现在如果我们将longestJourneysIn转换为扩展函数(参见第十章),我们可以将其名称简化为longestJourneys

@JvmStatic
fun List<Journey>.longestJourneys(limit: Int): List<Journey> =
    sortedByDescending { it.duration }.take(limit)

示例 6.18【collections.10:src/main/java/travelator/Suffering.kt】差异

因为longestJourneys不修改其参数,所以我们把它变成了单表达式函数(第九章)。它仍然可以从 Java 中调用作为静态方法,但从 Kotlin 中调用时特别流畅,尤其是如果我们给参数命名:

@JvmStatic
fun sufferScoreFor(route: List<Journey>): Int {
    return sufferScore(
        route.longestJourneys(limit = 3), ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
        Routes.getDepartsFrom(route)
    )
}

示例 6.19【collections.10:src/main/java/travelator/Suffering.kt】差异

1

命名参数

继续讨论bearable

private fun bearable(routes: List<List<Journey>>): List<List<Journey>> {
    return routes.stream()
        .filter { route -> sufferScoreFor(route) <= 10 }
        .collect(Collectors.toUnmodifiableList())
}

示例 6.20【collections.10:src/main/java/travelator/Suffering.kt】差异

在这里,我们可以使用第十三章中的技术将Stream转换为 Kotlin。我们删除对.stream()的调用,因为 Kotlin 在List上作为扩展函数提供了filter。然后我们不需要终端的toUnmodifiableList,因为 Kotlin 的filter直接返回一个List

private fun bearable(routes: List<List<Journey>>): List<List<Journey>> =
    routes.filter { sufferScoreFor(it) <= 10 }

示例 6.21【collections.11:src/main/java/travelator/Suffering.kt】差异

有趣的是,这个地方的结果潜在的更易变动,比我们的 Java 在这方面更多。在 Java 中,我们使用Collectors.toUnmodifiableList()进行收集。Kotlin 的filter声明其返回类型为List(只读视图),但实际运行时类型为可变的ArrayList。只要我们不向下转型,这应该不是问题,特别是因为我们现在即使在 Java 中也将我们的共享集合视为不可变的。

因此,这就是最终的代码:

object Suffering {
    @JvmStatic
    fun sufferScoreFor(route: List<Journey>): Int =
        sufferScore(
            route.longestJourneys(limit = 3),
            Routes.getDepartsFrom(route)
        )

    @JvmStatic
    fun List<Journey>.longestJourneys(limit: Int): List<Journey> =
        sortedByDescending { it.duration }.take(limit)

    fun routesToShowFor(itineraryId: String?): List<List<Journey>> =
        bearable(routesFor(itineraryId))

    private fun bearable(routes: List<List<Journey>>): List<List<Journey>> =
        routes.filter { sufferScoreFor(it) <= 10 }

    private fun sufferScore(
        longestJourneys: List<Journey>,
        start: Location
    ): Int = SOME_COMPLICATED_RESULT()
}

示例 6.22【collections.11:src/main/java/travelator/Suffering.kt】差异

我们说最终的,但实际上在这一点上我们可能不会完成这个重构。那些List<List<Journey>>类型暗示着一些类型试图脱颖而出,在 Kotlin 中,我们通常不会像这样在对象中发布静态方法;我们更喜欢顶层函数定义。第八章至少会解决后者。

继续前行

曾经,Java 更倾向于使用可变性进行编程。这已经不再流行,但更多是基于约定而非强制。Kotlin 在其集合中采取了非常实用的可变性方法,提供了流畅的操作和简单的编程模型,但仅在 Java 的约定与其方法一致的情况下。

为了帮助 Java 和 Kotlin 之间的平滑互操作:

  • 注意,Java 可以修改传递给 Kotlin 的集合。

  • 注意,Java 可以(至少试图)修改从 Kotlin 接收到的集合。

  • 从你对 Java 集合的使用中去除变异。无法去除变异时,请采用防御性拷贝。

我们在第十五章,封装集合到类型别名中对集合有更多的阐述。在这段代码示例中,第八章,静态方法到顶层函数继续了这一章节的内容。

第七章:从动作到计算

Java 和 Kotlin 都没有明确区分命令式代码和函数式代码,尽管 Kotlin 强调不可变性和表达式通常导致更多的函数式程序。通过增加函数式编程的比例,我们能改善我们的代码吗?

函数

作为一个行业,我们发明了很多短语来描述大型程序中的可调用子程序。我们有非常通用的 子程序。一些语言(尤其是 Pascal)区分 返回结果的函数不返回结果的过程;但大多数开发人员将这些术语互换使用。然后有 方法,这是与对象相关联的子程序(或者在静态方法的情况下是与类相关联的)。

C 语言将它们都称为函数,但有一个特殊的 void 类型表示没有返回值。这一点在 Java 中也保留了下来。Kotlin 几乎以相同的方式使用 Unit,不过 Unit 并不表示没有返回值,而是返回的是一个单例值。

在本书中,我们使用术语 函数 来指代既返回结果又不返回结果的子程序,无论是独立存在还是与对象相关联。在需要强调与对象关联的情况下,我们将其称为方法。

无论我们称其为什么,函数都是我们软件的基本构建块之一。我们通过某种标记(通常是我们正在使用的编程语言)来定义它们。它们在程序运行期间通常是固定的;至少在静态语言中,我们通常不会在运行时重新定义函数。

这与另一个基本构建块不同:数据。我们期望数据随着程序运行而变化,不同的数据绑定到变量上。变量被称为变量,因为它们是,等待它,可变的。即使它们是 finalval,它们在函数的不同调用中通常绑定到不同的数据上。

我们之前暗示过将函数划分为返回结果和不返回结果的类型。这似乎是一个基本差异,但实际上有一种更有用的方法来划分函数:计算动作

动作是依赖于何时或多少次运行的函数;而计算是不依赖于运行时间的函数,它们是永恒的。我们编写的大多数函数都是动作,因为我们必须特别小心编写不依赖于运行时间的代码。我们该如何做到这一点?

计算

要成为计算,函数在给定相同输入时必须始终返回相同结果。函数的输入是其参数,在调用函数时与参数绑定。因此,使用相同参数调用计算函数时,总是返回相同结果。

fullName 函数为例:

fun fullName(customer: Customer) = "${customer.givenName} ${customer.familyName}"

fullName是一个计算:当提供相同的Customer时,它将始终返回相同的值。这仅在Customer是不可变的情况下成立,或者至少givenNamefamilyName不能改变。为了保持简单,我们将说计算只能有作为值定义的参数,如第五章所定义。

方法和伪装成成员属性的方法也可以是计算:

data class Customer(
    val givenName: String,
    val familyName: String
) {
    fun upperCaseGivenName() = givenName.toUpperCase()

    val fullName get() = "$givenName $familyName"
}

对于方法或扩展,接收器this和通过this访问的任何属性也是输入。因此,upperCaseGivenNamefullName都是计算,因为givenNamefamilyName都是值。

如果它所依赖的数据是值,扩展函数或属性也可以是计算:

fun Customer.fullName() = "$givenName $familyName"

val Customer.fullName get() = "$givenName $familyName"

计算结果可能依赖于未作为参数传递的数据,但仅当该数据不改变时。否则,函数在更改前后的结果会不同,这使它成为一个行动。即使函数对于相同的参数总是返回相同的结果,如果它改变了某些东西(参数或外部资源,如全局变量或数据库),它仍然可能是一个行动。例如:

println("hello")

println给定相同的hello输入始终返回相同的Unit结果,但它不是计算。它是一个行动。

行动

println是一个行动,因为它确实依赖于它何时以及多少次被运行。如果我们不调用它,就不会输出任何内容,这与调用一次是不同的,这与调用两次也是不同的。调用println以不同参数的顺序对我们在控制台上看到的结果也是有影响的。

我们调用println是为了它的副作用——它对环境的影响。副作用是一个有点误导的术语,因为与药物副作用不同,它们通常确实是我们想要发生的事情。也许“外部效应”会是一个更好的名称,以强调它们是函数参数、局部变量和返回值外部的影响。无论如何,具有可观察副作用的函数都是行动而不是计算。返回voidUnit的函数几乎总是行动,因为如果它们做任何事情,它们必须通过副作用来做。

正如我们之前所见,从外部可变状态读取的代码也必须是一个行动(只要确实有任何东西实际上改变了状态)。

让我们看一下Customers服务:

class Customers {
    fun save(data: CustomerData): Customer {
        ...
    }
    fun find(id: String): Customer? {
        ...
    }
}

savefind都是行动;save在我们的数据库中创建一个新的客户记录并返回它。这是一个行动,因为我们调用它时数据库的状态会发生变化。find的结果也是时间敏感的,因为它依赖于对save的先前调用。

没有参数的函数(这不包括方法或扩展函数,它们可以通过 this 访问隐式参数)必须要么返回一个常量,要么从其他源读取数据,因此被归类为动作。在没有查看其源代码的情况下,我们可以推断顶层函数 requestRate 几乎肯定是一个动作,从某个全局可变状态读取数据:

fun requestRate(): Double {
    ...
}

如果一个具有相同表面签名的函数被定义为一个方法,那么它可能是依赖于Metrics属性的计算(假设Metrics是不可变的):

class Metrics(
   ...
) {

    fun requestRate(): Double {
        ...
    }
}

我们说 可能 是因为在像 Java 或 Kotlin 这样允许从任何代码中进行输入、输出或访问全局可变数据的语言中,除了检查它及其调用的所有函数之外,没有其他方法确保一个函数代表一个计算或动作。我们很快会回到这个问题。

为什么我们应该关心呢?

我们显然应特别关注软件中的某些操作。将相同的电子邮件发送两次给每个用户是一个 bug,就像完全不发送一样。我们确切地关心它被发送的次数。我们甚至可能关心它是否在早餐时段准确地在上午 8:07 发送,这样我们为免费头等舱升级的优惠就能出现在顾客的收件箱顶部。

其他看似无害的行为可能比我们想象的更有害。更改读取和写入操作的顺序会导致并发 bug。如果两个顺序操作中的第二个失败,而第一个已成功,错误处理会变得更加复杂。这些操作阻止我们随意重构代码,因为这样做可能会改变它们何时或是否被调用。

另一方面,计算可以随时调用,多次调用它们不会有什么后果,除了浪费时间和精力。如果我们正在重构代码并发现我们不需要计算的结果,我们可以安全地不调用它。如果这是一个昂贵的计算,我们可以安全地缓存其结果;如果它很廉价,我们可以根据需要安全地再次计算它,以简化事务。正是这种安全感让函数式编程人员在脸上挂上了得意的笑容(这也包括知道一个单子仅仅是一种自函子范畴中的单子)。那些函数式编程人员还为使函数成为计算的属性创造了一个术语:引用透明性。如果一个函数具有引用透明性,我们可以用其结果替换其调用,而只有在不管何时或是否调用它都没有影响时才能这样做。

为什么更喜欢计算呢?

我们喜欢计算,因为它们更容易处理,但最终我们的软件需要对世界产生影响,这就是一个操作。然而没有重叠;代码不能同时是操作和计算,既是无时间概念又是有时间概念的。如果我们拿一些计算代码并让它调用一个操作,那么它就会变成一个操作,因为它现在将取决于何时或是否被调用。我们可以将计算视为更纯净的代码,其中代码继承了其所有依赖关系中最受污染的层次。在第十九章中,我们看到了在错误易感性方面的相同情况。如果我们重视纯度(在所有这些情况下都带来了推理和重构的便利性),我们必须努力将不纯净代码与纯净代码之间的边界拉到系统的外层—靠近入口点的那些部分。如果我们成功了,那么我们的代码中的一个重要部分可以是计算,因此可以轻松测试、推理和重构。

如果我们无法保持操作在调用堆栈的底部,那么我们可以通过重构来解决问题!

将操作重构为计算

让我们来看看如何识别和重构现有代码中的操作。

现有代码

Travelator 中有一个 HTTP 端点,允许客户端应用获取关于客户当前行程的信息:

public class CurrentTripsHandler {

    private final ITrackTrips tracking;
    private final ObjectMapper objectMapper = new ObjectMapper();

    public CurrentTripsHandler(ITrackTrips tracking) {
        this.tracking = tracking;
    }

    public Response handle(Request request) {
        try {
            var customerId = request.getQueryParam("customerId").stream()
                .findFirst();
            if (customerId.isEmpty())
                return new Response(HTTP_BAD_REQUEST);
            var currentTrip = tracking.currentTripFor(customerId.get());
            return currentTrip.isPresent() ?
                new Response(HTTP_OK,
                    objectMapper.writeValueAsString(currentTrip)) :
                new Response(HTTP_NOT_FOUND);
        } catch (Exception x) {
            return new Response(HTTP_INTERNAL_ERROR);
        }
    }
}

示例 7.1 [actions.0:src/main/java/travelator/handlers/CurrentTripsHandler.java] (diff)

操作是对时间敏感的代码,因此像CurrentTripsHandler中的current这样的词就是一个明显的线索。handle方法是一个操作,这没问题:我们系统的边缘经常会有这种情况。

处理程序委托给一些业务逻辑,由Tracking实现:

class Tracking implements ITrackTrips {

    private final Trips trips;

    public Tracking(Trips trips) {
        this.trips = trips;
    }

    @Override
    public Optional<Trip> currentTripFor(String customerId) {
        var candidates = trips.currentTripsFor(customerId).stream()
            .filter((trip) -> trip.getBookingStatus() == BOOKED)
            .collect(toList());
        if (candidates.size() == 1)
            return Optional.of(candidates.get(0));
        else if (candidates.size() == 0)
            return Optional.empty();
        else
            throw new IllegalStateException(
                "Unexpectedly more than one current trip for " + customerId
            );
    }
}

示例 7.2 [actions.0:src/main/java/travelator/Tracking.java] (diff)

使用current规则,Tracking.currentTripFor显然也是一个操作,Trips.currentTripsFor也是。这是它在InMemoryTrips中的实现,用于测试而不是使用数据库查询实现的版本:

public class InMemoryTrips implements Trips {

    ...
    @Override
    public Set<Trip> currentTripsFor(String customerId) {
        return tripsFor(customerId).stream()
            .filter(trip -> trip.isPlannedToBeActiveAt(clock.instant()))
            .collect(toSet());
    }
}

示例 7.3 [actions.0:src/test/java/travelator/InMemoryTrips.java] (diff)

Set<Trip>Trips.currentTripsFor的结果)到Optional<Trip>Tracking.currentTripFor返回的结果)的转换似乎是因为有一个业务规则,即任何时候只能有一次活动行程—这个规则在持久层中没有被强制执行。

直到我们到达这里,我们一直依赖于我们对单词含义(特别是current)的知识,来推断 Java 方法表示的是操作而不是计算。然而,这里确实有一个罪证。你能发现它吗?

是的:clock.instant()。这确实取决于我们何时调用它。(如果你找到了另一个动作,干得漂亮,但现在还是先留着。我们以后会回来处理。)

即使我们选择不继续进行此重构的其他部分,现在我们应该做出一项改变。我们已经讨论过计算和动作适用于命名的代码块,但它们也适用于表达式级别。一旦开始区分动作和计算,将随机动作插入纯计算是不合理的。让我们将动作分离出来,以便表达式的其余部分是纯的:选择 clock.instant() 并“引入变量”,将其命名为 now

@Override
public Set<Trip> currentTripsFor(String customerId) {
    return tripsFor(customerId).stream()
        .filter(trip -> {
            Instant now = clock.instant();
            return trip.isPlannedToBeActiveAt(now);
        })
        .collect(toSet());
}

示例 7.4 [actions.1:src/test/java/travelator/InMemoryTrips.java] (diff)

这仍然处于表达式中间位置,让我们将其移到上面(并在移动过程中转换为 var):

@Override
public Set<Trip> currentTripsFor(String customerId) {
    var now = clock.instant();
    return tripsFor(customerId).stream()
        .filter(trip -> trip.isPlannedToBeActiveAt(now))
        .collect(toSet());
}

示例 7.5 [actions.2:src/test/java/travelator/InMemoryTrips.java] (diff)

这个简单的举动让我们意识到,之前我们每次都是与稍有不同的时间比较每一次旅行!这是个问题吗?可能在这里不是,但你可能会在其他系统中工作过,那里可能会是个问题。例如,Duncan 最近完成了一个问题的诊断,其中一半银行交易在一天内完成,而另一半在第二天完成。

除了使我们的代码更难重构外,动作还使测试更加困难。让我们看看这是如何表现出来的:

public class TrackingTests {

    final StoppedClock clock = new StoppedClock();

    final InMemoryTrips trips = new InMemoryTrips(clock);
    final Tracking tracking = new Tracking(trips);

    @Test
    public void returns_empty_when_no_trip_planned_to_happen_now() {
        clock.now = anInstant();
        assertEquals(
            Optional.empty(),
            tracking.currentTripFor("aCustomer")
        );
    }

    @Test
    public void returns_single_active_booked_trip() {
        var diwaliTrip = givenATrip("cust1", "Diwali",
            "2020-11-13", "2020-11-15", BOOKED);
        givenATrip("cust1", "Christmas",
            "2020-12-24", "2020-11-26", BOOKED);

        clock.now = diwaliTrip.getPlannedStartTime().toInstant();
        assertEquals(
            Optional.of(diwaliTrip),
            tracking.currentTripFor("cust1")
        );
    }

    ...
}

示例 7.6 [actions.0:src/test/java/travelator/TrackingTests.java] (diff)

为了保证可预测的结果,我们不得不在 InMemoryTrips 中使用一个假时钟。之前说过 clock.instant() 的调用取决于我们何时调用它;但在我们的测试中,它并不是这样(至少不完全是)。我们本可以根据测试运行时的时间设置行程,但这样会使我们的测试更难理解,并且在午夜附近运行时容易失败。

是否需要注入时钟来解决 测试引起的设计损害?在这种情况下是需要的。这个假时钟让我们能够解决一个测试问题,但代价是使代码变得更加复杂。它也让我们避免了重新考虑的可能性,这可能会导致……

更好的设计

在这里,一个更好的设计会是什么样子呢?

为了使这段代码不那么依赖时间,我们可以将时间作为方法的参数提供。虽然这会强迫 调用者 知道时间,但对于调用者来说,询问时间与询问这个方法一样容易。这是我们重构以避免依赖其他全局状态的特殊案例;我们不是在函数内部读取值,而是将值传递给它。

我们希望将时间传递到 Trips.currentTripsFor 的函数中,因此我们首先添加了一个 Instant 参数。在此之前,代码如下:

public interface Trips {
    ...
    Set<Trip> currentTripsFor(String customerId);
}

示例 7.7 [actions.0:src/main/java/travelator/Trips.java] (diff)

我们使用 IntelliJ 的“更改签名”重构来添加参数,称为 at。当我们添加参数时,我们需要告诉 IntelliJ 在更新我们函数的调用者时应使用什么值。因为我们尚未在方法中使用该值(这是 Java),我们应该能够在不破坏任何东西的情况下使用 null。运行测试显示我们是正确的,它们仍然通过。

Trips 现在如下所示:

public interface Trips {
    ...
    Set<Trip> currentTripsFor(String customerId, Instant at);
}

示例 7.8 [actions.3:src/main/java/travelator/Trips.java] (diff)

这里是被调用的方法:

class Tracking implements ITrackTrips {
    ...

    @Override
    public Optional<Trip> currentTripFor(String customerId) {
        var candidates = trips.currentTripsFor(customerId, null) ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
            .stream()
            .filter((trip) -> trip.getBookingStatus() == BOOKED)
            .collect(toList());
        if (candidates.size() == 1)
            return Optional.of(candidates.get(0));
        else if (candidates.size() == 0)
            return Optional.empty();
        else
            throw new IllegalStateException(
                "Unexpectedly more than one current trip for " + customerId
            );
    }
}

示例 7.9 [actions.3:src/main/java/travelator/Tracking.java] (diff)

1

IntelliJ 将 null 作为参数值引入

请记住,在我们的 Trips 实现中,我们尚未使用时间的值;我们只是尝试在系统外部提供它,以便尽可能多地将代码转换为计算。Tracking 并非我们互动的外部,因此我们选择了 nullInstant 并“引入参数”将其添加到 Tracking.currentTripFor 的签名中:

@Override
public Optional<Trip> currentTripFor(String customerId, Instant at) { ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
    var candidates = trips.currentTripsFor(customerId, at) ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
        .stream()
        .filter((trip) -> trip.getBookingStatus() == BOOKED)
        .collect(toList());
        ...
}

示例 7.10 [actions.4:src/main/java/travelator/Tracking.java] (diff)

1

我们新的 Instant 参数

当我们“引入参数”时,IntelliJ 将表达式(在本例中为 null)从方法体移动到调用者,因此 CurrentTripsHandler 仍然可以编译:

public Response handle(Request request) {
    try {
        var customerId = request.getQueryParam("customerId").stream()
            .findFirst();
        if (customerId.isEmpty())
            return new Response(HTTP_BAD_REQUEST);
        var currentTrip = tracking.currentTripFor(customerId.get(), null); ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
        return currentTrip.isPresent() ?
            new Response(HTTP_OK,
                objectMapper.writeValueAsString(currentTrip)) :
            new Response(HTTP_NOT_FOUND);
    } catch (Exception x) {
        return new Response(HTTP_INTERNAL_ERROR);
    }
}

示例 7.11 [actions.4:src/main/java/travelator/handlers/CurrentTripsHandler.java] (diff)

1

null 参数值

TrackingTests 同样被修复:

@Test
public void returns_empty_when_no_trip_planned_to_happen_now() {
    clock.now = anInstant();
    assertEquals(
        Optional.empty(),
        tracking.currentTripFor("cust1", null) ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
    );
}

@Test
public void returns_single_active_booked_trip() {
    var diwaliTrip = givenATrip("cust1", "Diwali",
        "2020-11-13", "2020-11-15", BOOKED);
    givenATrip("cust1", "Christmas",
        "2020-12-24", "2020-11-26", BOOKED);

    clock.now = diwaliTrip.getPlannedStartTime().toInstant();
    assertEquals(
        Optional.of(diwaliTrip),
        tracking.currentTripFor("cust1", null) ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
    );
}

示例 7.12 [actions.4:src/test/java/travelator/TrackingTests.java] (diff)

1

null 参数值

此时一切编译通过并通过测试,但我们实际上并未使用从处理程序传递下来的 (null) 时间。让我们在我们开始的 InMemoryTrips 中解决这个问题。我们之前的代码如下:

public class InMemoryTrips implements Trips {

    ...
    @Override
    public Set<Trip> currentTripsFor(String customerId, Instant at) {
        var now = clock.instant();
        return tripsFor(customerId).stream()
            .filter(trip -> trip.isPlannedToBeActiveAt(now))
            .collect(toSet());
    }
}

示例 7.13 [actions.4:src/test/java/travelator/InMemoryTrips.java] (diff)

现在我们有了时间作为参数,我们可以使用它,而不是询问 clock

public class InMemoryTrips implements Trips {

    ...
    @Override
    public Set<Trip> currentTripsFor(String customerId, Instant at) {
        return tripsFor(customerId).stream()
            .filter(trip -> trip.isPlannedToBeActiveAt(at))
            .collect(toSet());
    }
}

示例 7.14 [actions.5:src/test/java/travelator/InMemoryTrips.java] (diff)

这导致使用 InMemoryTrips 的测试失败,并出现 NullPointerException,因为该方法现在使用参数的值,而测试传递 null

@Test
public void returns_empty_when_no_trip_planned_to_happen_now() {
    clock.now = anInstant();
    assertEquals(
        Optional.empty(),
        tracking.currentTripFor("cust1", null) ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
    );
}

@Test
public void returns_single_active_booked_trip() {
    var diwaliTrip = givenATrip("cust1", "Diwali",
        "2020-11-13", "2020-11-15", BOOKED);
    givenATrip("cust1", "Christmas",
        "2020-12-24", "2020-11-26", BOOKED);

    clock.now = diwaliTrip.getPlannedStartTime().toInstant();
    assertEquals(
        Optional.of(diwaliTrip),
        tracking.currentTripFor("cust1", null) ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
    );
}

示例 7.15 [actions.5:src/test/java/travelator/TrackingTests.java] (diff)

1

这些 null 现在在 InMemoryTrips 中被解引用。

我们需要传递测试设置到 clock 中的值,而不是 null。一个巧妙的重构是将 null 替换为 clock.now

@Test
public void returns_empty_when_no_trip_planned_to_happen_now() {
    clock.now = anInstant();
    assertEquals(
        Optional.empty(),
        tracking.currentTripFor("cust1", clock.now)
    );
}

@Test
public void returns_single_active_booked_trip() {
    var diwaliTrip = givenATrip("cust1", "Diwali",
        "2020-11-13", "2020-11-15", BOOKED);
    givenATrip("cust1", "Christmas",
        "2020-12-24", "2020-11-26", BOOKED);

    clock.now = diwaliTrip.getPlannedStartTime().toInstant();
    assertEquals(
        Optional.of(diwaliTrip),
        tracking.currentTripFor("cust1", clock.now)
    );
}

示例 7.16 [actions.6:src/test/java/travelator/TrackingTests.java] (diff)

这使得我们的测试通过了,因为我们现在传递了正确的时间作为参数,尽管是通过设置并立即读取 StoppedClock 中的字段。为了修复这个问题,我们将 clock.now 读取替换为 clock.now 写入的值。然后 clock 就没用了,我们可以删除它:

public class TrackingTests {

    final InMemoryTrips trips = new InMemoryTrips();
    final Tracking tracking = new Tracking(trips);

    @Test
    public void returns_empty_when_no_trip_planned_to_happen_now() {
        assertEquals(
            Optional.empty(),
            tracking.currentTripFor("cust1", anInstant())
        );
    }

    @Test
    public void returns_single_active_booked_trip() {
        var diwaliTrip = givenATrip("cust1", "Diwali",
            "2020-11-13", "2020-11-15", BOOKED);
        givenATrip("cust1", "Christmas",
            "2020-12-24", "2020-11-26", BOOKED);

        assertEquals(
            Optional.of(diwaliTrip),
            tracking.currentTripFor("cust1",
                diwaliTrip.getPlannedStartTime().toInstant())
        );
    }

    ...
}

示例 7.17 [actions.8:src/test/java/travelator/TrackingTests.java] (diff)

这是我们在向更加函数化的代码重构时经常看到的模式。随着我们减少操作的范围,我们的测试变得更简单,因为它们可以通过参数表达更多的变化,而不是准备测试状态。我们将在第十七章再次看到这一点。

终局

我们现在几乎完成了。(重构永远不完全完成。)

在所有对测试的关注中,我们准备在检查之前登记,然后才意识到我们还没有完成对 CurrentTripsHandler 的重构:

public Response handle(Request request) {
    try {
        var customerId = request.getQueryParam("customerId").stream()
            .findFirst();
        if (customerId.isEmpty())
            return new Response(HTTP_BAD_REQUEST);
        var currentTrip = tracking.currentTripFor(customerId.get(), null); ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
        return currentTrip.isPresent() ?
            new Response(HTTP_OK,
                objectMapper.writeValueAsString(currentTrip)) :
            new Response(HTTP_NOT_FOUND);
    } catch (Exception x) {
        return new Response(HTTP_INTERNAL_ERROR);
    }
}

示例 7.18 [actions.8:src/main/java/travelator/handlers/CurrentTripsHandler.java] (diff)

1

我们仍然传递 null

现在我们的 currentTripFor 方法都不再获取时间,CurrentTripHandler 是唯一的操作——我们需要调用 Instant.now() 的地方。我们可以通过插入调用来修复问题,最终得到:

public class CurrentTripsHandler {
    private final ITrackTrips tracking;
    private final ObjectMapper objectMapper = new ObjectMapper();

    public CurrentTripsHandler(ITrackTrips tracking) {
        this.tracking = tracking;
    }

    public Response handle(Request request) {
        try {
            var customerId = request.getQueryParam("customerId").stream()
                .findFirst();
            if (customerId.isEmpty())
                return new Response(HTTP_BAD_REQUEST);
            var currentTrip = tracking.currentTripFor(
                customerId.get(),
                Instant.now() ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
            );
            return currentTrip.isPresent() ?
                new Response(HTTP_OK,
                    objectMapper.writeValueAsString(currentTrip)) :
                new Response(HTTP_NOT_FOUND);
        } catch (Exception x) {
            return new Response(HTTP_INTERNAL_ERROR);
        }
    }
}

示例 7.19 [actions.9:src/main/java/travelator/handlers/CurrentTripsHandler.java] (差异)

1

现在我们的操作位于应用程序的入口点。

查看我们的代码,我们发现(尖叫声)处理程序没有任何单元测试。如果我们想要添加它们,这是我们现在注入Clock的层次,而不是单独的服务。模拟或存根允许我们测试操作,但很少需要测试计算。

我们不会展示它,但我们也必须考虑Trips的生产实现,即从我们的数据库中读取的实现。我们很幸运地发现,它将当前时间传递到其 SQL 查询中,因此现在我们可以直接使用Trips.currentTripsFor(String customerId, Instant at)at参数的值。如果 SQL 查询使用数据库服务器的当前时间,例如CURRENT_TIMESTAMPNOW等特定于数据库的表达式,那么情况将更加复杂。与非 SQL 代码一样,操作如此普遍,即使这使得测试更加复杂,代码本身也不太灵活。如果我们的查询使用了数据库时间,我们将不得不重写它,以从我们的函数中接收时间参数,并心理记录不要再犯同样的错误。

With that done, we review our changes and find that we haven’t converted any code to Kotlin!

这很重要。这种对计算和操作的思考方式并不依赖于我们的实现语言,而 Java 的粒度随着时间的推移变得更加功能化。尽管如此,我们发现 Kotlin 对于不可变数据和其他函数构造的自然支持意味着区分的成本更低,因此成本/收益比看起来更有利。还要注意,本章(以及其他章节)中采取的许多重构步骤之所以安全,仅因为我们在计算和操作的调用之间移动。

在完成本章之前,我们暗示的另一个操作呢?这里是InMemoryTrips的实现,现在已转换为 Kotlin:

class InMemoryTrips : Trips {
    private val trips: MutableMap<String, MutableSet<Trip>> = mutableMapOf()

    fun addTrip(trip: Trip) {
        val existingTrips = trips.getOrDefault(trip.customerId, mutableSetOf())
        existingTrips.add(trip)
        trips[trip.customerId] = existingTrips
    }

    override fun tripsFor(customerId: String) =
        trips.getOrDefault(customerId, emptySet<Trip>())

    override fun currentTripsFor(customerId: String, at: Instant): Set<Trip> =
        tripsFor(customerId)
            .filter { it.isPlannedToBeActiveAt(at) }
            .toSet()
}

示例 7.20 [actions.10:src/test/java/travelator/InMemoryTrips.kt] (差异)

MutableMap中的MutableSet是某些东西随时间变化的迹象。如果它们有相同的客户端,那么在我们调用addTrip之后,tripsFor的结果将不同。因此,tripsFor是一个操作,而不是一个计算。如果tripsFor是一个操作,那么调用它的任何东西也是一个操作,包括我们的currentTripsFor。同样的情况显然也适用于读写数据库的生产版本的Trips。在所有这些工作之后,我们实际上并没有将我们的操作提升为一个计算!

我们应该沮丧吗?不。尽管我们先前断言函数要么是计算,要么是操作,但实际上操作性是分级的,操作可能更或少易受时间影响。在这种情况下,除非其他代码在此互动中也要获取客户的行程并找到不一致,否则我们可以将Trips视为实际上不可变。因此,tripsFor,以及由此推广出的currentTripsFor,实际上是计算。在这方面,我们的InMemoryTrips比我们的数据库实现不太安全,因为如果在多个线程中访问,它可能会突变由tripsFor返回的集合,在filter实现中可能导致ConcurrentModification​Excep⁠tions。将我们的代码分类为计算和操作有助于我们看到这些问题,并为在特定上下文中它们是否重要提供了一个框架。

最后,请注意,Kotlin 更倾向于使用不可变数据,这使得这种分类变得更加容易。例如,在 Java 中当你看到List时,你必须找到创建或引用它的地方来确定它的可变性,从而推断访问它的代码是否可能是一个操作。在 Kotlin 中,当你看到MutableList时,你可以推断这是一个操作,尽管正如我们在InMemoryTrips中看到的,暴露一个带有只读别名的可变集合可能会导致操作假装成计算。

继续前进

将代码分类为计算和操作(以及数据)是埃里克·诺曼德在他的书籍《通晓简易:用功能性思维驾驭复杂软件》(Manning 出版社)中引入的一种形式化方法。作为开发者,我们可以直觉地区分它们,并很快学会依赖我们的直觉,但通常并不意识到如何或为什么。给这些类别命名,并研究它们的特性,使我们能够在更有意识和有效的水平上推理。

在第五章,从 Bean 到值中,我们从可变的 bean 重构为不可变的值。类似地,在第六章,Java 到 Kotlin 集合中,我们从可变集合重构为不可变集合。在这两种情况下,我们通过返回一个修改后的副本来代替对对象的突变,将一个操作转换为计算。通过这样做,我们获得了本章中看到的优势:更好的理解、更容易的测试和可预测的重构。我们的代码中计算的部分越多,我们就越好。

我们将在第十四章,从累积对象到转换中回到从操作到计算的主题。在第十五章,封装集合到类型别名中,我们将看到不可变数据如何与 Kotlin 的扩展函数和类型别名结合,让我们以 Java 不可能的方式组织我们的代码。

第八章:从静态方法到顶级函数

独立函数是软件的基本构建块之一。在 Java 中,它们必须声明为类的方法,但在 Kotlin 中,我们可以将它们声明为顶级实体。我们何时应该优先使用顶级函数,以及如何从 Java 重构到那里呢?

Java 的静态

Java 程序中的所有值和函数都必须属于一个类:它们是该类的 成员。Java 将成员值称为 字段,成员函数称为 方法。默认情况下,字段值是每个类的实例独有的:不同的实例具有不同的值。方法也是每个实例的,因为它们可以访问调用它们的实例的状态。但是,如果我们将字段标记为 static,它们将在类的所有实例之间共享。静态方法只能访问这个共享状态(以及其他类中可见的静态字段),但作为对此限制的回报,我们可以在不需要类的实例的情况下调用它们。

为了简化 Java,语言设计者将所有代码和数据都与类绑定在一起。我们有类范围的静态状态,所以我们需要类范围的静态方法。他们本可以添加独立的数据和函数,但是静态字段和方法足够了。如果语言有选项,开发人员就必须在它们之间进行选择,而较少的选择通常更好。然后,设计者将这种语言设计决策延续到了 Java 虚拟机,而 Java 虚拟机又没有办法表达顶级代码或数据。

有时我们有一个类,其中既有非静态方法又有静态方法作用于相同的类型——例如,在第三章中看到的具有静态解析方法的电子邮件类。然而,通常情况下,我们最终得到的是一个只包含静态方法的类。当没有静态状态可供它们共享时,这些方法实际上只是一组独立的函数,通过它们的类名组合在一起并调用,就像 java.util.Collections 类的方法一样:

var max = Collections.max(list);

令人惊讶的是,行业并没有真正注意到 Collections. 前缀有多么烦人一段时间。这是因为我们通过向我们拥有的类型添加越来越多的方法来编写我们的程序,所以我们很少需要静态函数。当我们想要添加功能而不是向类型添加方法时,静态函数是有用的。这可能是因为我们的类已经承受了我们已经添加到它们的所有方法的重量,或者因为我们不拥有该类,因此无法将方法添加到其中。使用静态函数而不是方法的另一个原因是因为功能仅适用于泛型类型的某些实例化,因此无法将其声明为泛型的成员。例如,Collections.max 仅适用于具有可比较元素的集合。

随着时间的推移,我们开始欣赏使用标准接口(例如 Java 集合)的优势,而不是使用我们自己的抽象。Java 5(带有泛型)是第一个允许我们直接使用集合而不是用自己的类包装它们的版本。因此,Java 5 还带来了import static java.util.Collections.max的能力,以便我们可以编写:

var m = max(list);

请注意,这实际上只是编译器提供的一种便利,因为 JVM 仍然只支持静态方法而不是真正的顶级函数。

Kotlin 顶级函数、对象和伴生对象

Kotlin 允许在类外声明函数(以及属性和常量)。在这种情况下,由于 JVM 没有其他地方可以存放它们,编译器会为这些顶级声明生成一个具有静态成员的类。默认情况下,它会根据定义函数的文件的名称来派生类的名称。例如,在top-level.kt中定义的函数最终会成为类Top_levelKt上的静态方法。如果我们知道类的名称,我们可以通过静态导入Top_LevelKt.foo或直接调用Top_levelKt.foo()来引用它。如果我们不喜欢Top_LevelKt的丑陋,我们可以通过在文件顶部添加注解@file:JvmName来显式命名生成的类,我们将在本章后面看到。

除了这些顶级函数之外,Kotlin 还允许我们定义属性和函数,它们的作用域与 Java 静态类似,而不是实例。Kotlin 不仅将这些标记为static,而且还借鉴了 Scala,并将它们收集到object声明中。这种类型的object声明(与创建匿名类型的object表达式相对)定义了一个单例:一个只有一个实例的类型,为该实例提供了全局访问点。对象的所有成员将编译为具有对象名称的类的成员。除非特别标记为@JvmStatic,否则它们实际上不会是静态方法。这是因为 Kotlin 允许这些对象扩展类和实现接口,而这与静态声明不兼容。

当我们需要将静态成员和非静态成员分组到同一个类中时,我们可以在(否则非静态的)类声明内部声明静态部分为companion object。这样可以将它们组合在文件中,并且伴生对象中的代码可以访问其包含类实例的私有状态。伴生对象还可以扩展另一个类并实现接口——这是 Java 静态无法做到的。但与 Java 静态相比,如果我们只想定义一两个静态方法,伴生对象会显得很繁琐。

因此,在 Kotlin 中,我们可以将非实例作用域函数编写为顶级函数或单例对象上的方法,而这个对象可能是伴生对象或不限于类型的范围。

在这些选择中,一切平等的情况下,我们应该默认使用顶层函数。它们最简单声明和引用,并且可以在包内的不同文件中移动而不影响 Kotlin 客户端代码(但请参见“移动顶层函数”中的警告)。我们将函数声明为单例对象的方法,而不是顶层函数,是为了当我们需要实现接口或者更紧密地组合函数时使用。当我们需要在类内混合静态和非静态行为,或者编写类似MyType.of(...)的工厂方法时,我们使用伴生对象。

就像编程的许多方面一样,我们从最简单可能运行的东西开始,通常是顶层函数,仅在它为我们的客户提供更具表现力的 API 或者对我们更易于维护时,才将其重构为更复杂的解决方案。

从静态方法重构到顶层函数

尽管我们更喜欢使用顶层声明,但 IntelliJ 内置的 Java 到 Kotlin 转换不这样做。它将我们的 Java 静态方法转换为对象方法。让我们看看如何从 Java 重构,通过对象声明到顶层函数。

在 Travelator,我们允许我们的客户建立短列表,例如在计划旅行时的路线短列表,或者在这些路线上的酒店客房短列表。用户可以按不同的标准对短列表中的项目进行排名,并丢弃项目以缩小结果到最终选择。遵循“不要改变共享集合”的指导,短列表存储为不可变列表。用于操作短列表(返回修改后的副本而不是变异列表)的函数作为Shortlists类的静态方法实现:

public class Shortlists {
    public static <T> List<T> sorted(
        List<T> shortlist,
        Comparator<? super T> ordering
    ) {
        return shortlist.stream()
            .sorted(ordering)
            .collect(toUnmodifiableList());
    }

    public static <T> List<T> removeItemAt(List<T> shortlist, int index) {
        return Stream.concat(
            shortlist.stream().limit(index),
            shortlist.stream().skip(index + 1)
        ).collect(toUnmodifiableList());
    }

    public static Comparator<HasRating> byRating() {
        return comparingDouble(HasRating::getRating).reversed();
    }

    public static Comparator<HasPrice> byPriceLowToHigh() {
        return comparing(HasPrice::getPrice);
    }

    ... and other comparators
}

示例 8.1 [static-to-top-level.0:src/main/java/travelator/Shortlists.java] (diff)

Shortlists中的函数是静态方法,必须如此引用。如果用长格式拼写出来,看起来是这样的:

var reordered = Shortlists.sorted(items, Shortlists.byValue());

示例 8.2 [static-to-top-level.5:src/test/java/travelator/ShortlistsTest.java] (diff)

尽管通常我们会static import这些方法,它们以此方式命名以更好地阅读:

var reordered = sorted(items, byPriceLowToHigh());

示例 8.3 [static-to-top-level.5:src/test/java/travelator/ShortlistsTest.java] (diff)

用 IntelliJ 将 Java 转换为 Kotlin,我们得到:

object Shortlists {
    @JvmStatic
    fun <T> sorted(shortlist: List<T>, ordering: Comparator<in T>): List<T> {
        return shortlist.stream().sorted(ordering)
            .collect(toUnmodifiableList())
    }

    @JvmStatic
    fun <T> removeItemAt(shortlist: List<T>, index: Int): List<T> {
        return Stream.concat(
            shortlist.stream().limit(index.toLong()),
            shortlist.stream().skip((index + 1).toLong())
        ).collect(toUnmodifiableList())
    }

    @JvmStatic
    fun byRating(): Comparator<HasRating> {
        return comparingDouble(HasRating::rating).reversed()
    }

    @JvmStatic
    fun byPriceLowToHigh(): Comparator<HasPrice> {
        return comparing(HasPrice::price)
    }

    ... and other comparators
}

示例 8.4 [static-to-top-level.5:src/main/java/travelator/Shortlists.kt] (diff)

实际上,这并不完全正确。在撰写本文时,转换器给类型添加了一些不必要的可空性,撤销了静态导入(使我们得到 Collectors.to​Un⁠modi⁠fiableList(),例如),并且成功创建了一个无法编译的导入列表。手动修复文件使我们相信机器还需要几年的时间才能取代我们的工作。

在 第三章 中,我们看到将具有静态方法和非静态方法的 Java 类转换为 Kotlin 类时会产生一个伴生对象。在这里,转换仅产生了一个顶级对象。因为这个 Java 类没有非静态方法或状态,所以 Kotlin 转换不需要包含一个可实例化的类。具有静态方法和非静态方法的类不太适合转换为顶级函数。

尽管在 Kotlin 层面上转换并不完全顺利,但好处是,在此过程中没有损坏任何 Java 代码。客户端代码保持不变,因为 @JvmStatic 注解允许 Java 代码将这些方法视为 Shortlists 类上的静态方法,就像转换之前一样。

我们希望将这些方法转换为顶级函数,但我们不能简单地移动它们,因为 Java 只理解方法,而不是函数。如果这些函数被编译为名为 Shortlists 的类上的方法,Java 就会很高兴,这就是我们之前提到的 @file:JvmName 注解的作用。我们可以在文件顶部手动添加注解,并移除 object 作用域和 @JvmStatic 注解,得到:

@file:JvmName("Shortlists")
package travelator

...

fun <T> sorted(shortlist: List<T>, ordering: Comparator<in T>): List<T> {
    return shortlist.stream().sorted(ordering)
        .collect(toUnmodifiableList())
}

fun <T> removeItemAt(shortlist: List<T>, index: Int): List<T> {
    return Stream.concat(
        shortlist.stream().limit(index.toLong()),
        shortlist.stream().skip((index + 1).toLong())
    ).collect(toUnmodifiableList())
}

... etc.

示例 8.5 [static-to-top-level.6:src/main/java/travelator/Shortlists.kt] (差异)

这保持了我们的 Java 代码的正常运行,但令人恼火的是,它破坏了一些调用这些方法的 Kotlin 代码。例如,这是一个测试的导入语句:

import org.junit.jupiter.api.Test
import travelator.Shortlists.byPriceLowToHigh
import travelator.Shortlists.byRating
import travelator.Shortlists.byRelevance
import travelator.Shortlists.byValue
import travelator.Shortlists.removeItemAt
import travelator.Shortlists.sorted

示例 8.6 [static-to-top-level.6:src/test/java/travelator/hotels/ShortlistScenarioTest.kt] (差异)

这些代码正在导入静态的 Java 方法,但 Kotlin 不能以同样的方式导入自己的顶级函数,因此这些行会因为 Unresolved reference: Shortlists 而失败。就 Kotlin 而言,这些函数是在包的范围内定义的,而不是在该包中的一个类中。编译器可能会将它们编译为 ShortlistsKt JVM 类的静态方法,但该类是编译器将 Kotlin 语言概念映射到 JVM 平台的实现细节,并且在编译时不可见于我们的 Kotlin 代码。

我们可以浏览所有编译错误,并手动修复导入以引用包作用域的函数。例如,我们必须将import travelator.Shortlists.sorted更改为import travelator.sorted。如果更改只影响了几个文件,这是很容易的,但是如果更改广泛影响了许多文件,修复所有导入将是一个繁琐的工作,尽管这可能可以通过全局搜索和替换一次性完成。

幸运的是,在我们撰写本书的过程中,IntelliJ 增加了“移动到顶层”的重构功能。让我们回退最后的 Kotlin 更改,回到对象声明,并再试一次。

移动到顶层

正如我们撰写本书时所说,这种重构是如此新颖,以至于在重构菜单中还不可用,但在对象方法名称上使用 Alt-Enter 会提供“移动到顶层”的选项。我们首先会处理sorted。IntelliJ 将方法从对象范围移到文件级别:

@JvmStatic
fun <T> sorted(shortlist: List<T>, ordering: Comparator<in T>): List<T> {
    return shortlist.stream().sorted(ordering)
        .collect(toUnmodifiableList())
}

示例 8.7 [static-to-top-level.7:src/main/java/travelator/Shortlists.kt] (diff)

不幸的是,它未能删除@JvmStatic注解,因此我们必须自己删除才能使代码编译通过。一旦我们这样做了,我们发现它至少修复了调用者,这正是我们在自行移动方法时遇到的问题。在 Java 中明确引用方法为ShortLists.sorted的地方,现在我们有了:

var reordered = ShortlistsKt.sorted(items, Shortlists.byValue());

示例 8.8 [static-to-top-level.8:src/test/java/travelator/ShortlistsTest.java] (diff)

由于某种原因,在我们进行了静态导入之后,情况变得更糟:

var reordered = travelator.ShortlistsKt.sorted(items, byPriceLowToHigh());

示例 8.9 [static-to-top-level.8:src/test/java/travelator/ShortlistsTest.java] (diff)

我们可以通过 Alt-Enter 和“Add on demand static import…”来解决这个问题。我们必须在每个受影响的文件中执行一次这样的操作;在重构之前,我们应该已经进行了检查,以便轻松查看更改了哪些文件:

var reordered = sorted(items, byPriceLowToHigh());

示例 8.10 [static-to-top-level.9:src/test/java/travelator/ShortlistsTest.java] (diff)

相比我们先前手动添加@file:JvmName("Shortlists")注解的方法,现在我们的 Java 客户端暴露出那个令人讨厌的ShortlistsKt名称。尽管方法名设计为与静态导入一起使用,但几乎总是隐藏在导入块中,没有人会去看,所以我们已经准备好忍受这一点。作为这种牺牲的回报,这种转换还修复了sorted在 Kotlin 调用者中的问题。现在在 Kotlin 中引用它为travelator.sorted,而不是travelator.Shortlists.sorted,这正是我们的目的。

现在我们可以以同样的方式移动 Shortlists 的其余方法。这有点乏味,但至少当它移动最后一个方法时,IntelliJ 会删除空对象,使我们留下:

fun <T> sorted(shortlist: List<T>, ordering: Comparator<in T>): List<T> {
    return shortlist.stream().sorted(ordering)
        .collect(toUnmodifiableList())
}

fun <T> removeItemAt(shortlist: List<T>, index: Int): List<T> {
    return Stream.concat(
        shortlist.stream().limit(index.toLong()),
        shortlist.stream().skip((index + 1).toLong())
    ).collect(toUnmodifiableList())
}

fun byRating(): Comparator<HasRating> {
    return comparingDouble(HasRating::rating).reversed()
}

fun byPriceLowToHigh(): Comparator<HasPrice> {
    return comparing(HasPrice::price)
}

... and other comparators

示例 8.11 [static-to-top-level.10:src/main/java/travelator/Shortlists.kt] (diff)

当我们编写此文时,“移至顶级”重构限制为一次仅移动一个方法。如果方法彼此依赖,这可能会导致一些问题,正如我们将在 第十章 中看到的那样。

Kotlin 化

当然,我们不是无缘无故将方法移到顶级函数。嗯,至少不是完全为了无缘无故。现在我们的函数位于 Kotlin 的惯用位置,让我们完成惯用的 Kotlin 工作。

第十三章提供了关于将 Java 流转换为 Kotlin 的指导;对于 sorted,我们可以直接使用 Kotlin 的 sortedWith 扩展函数:

fun <T> sorted(shortlist: List<T>, ordering: Comparator<in T>): List<T> {
    return shortlist.sortedWith(ordering)
}

示例 8.12 [static-to-top-level.11:src/main/java/travelator/Shortlists.kt] (diff)

这使得一个非常合乎逻辑的扩展函数 (第十章):

fun <T> List<T>.sorted(ordering: Comparator<in T>): List<T> {
    return sortedWith(ordering)
}

示例 8.13 [static-to-top-level.12:src/main/java/travelator/Shortlists.kt] (diff)

Java 仍然将其作为静态方法调用:

var reordered = sorted(items, byPriceLowToHigh());

示例 8.14 [static-to-top-level.12:src/test/java/travelator/ShortlistsTest.java] (diff)

从 Kotlin 调用读起也很舒服:

val hotelsByPrice = hotels.sorted(byPriceLowToHigh())

示例 8.15 [static-to-top-level.12:src/test/java/travelator/hotels/ShortlistScenarioTest.kt] (diff)

这些 Kotlin 用法真的没有比原始 Kotlin API 更有优势,所以我们可以直接内联它们:

val hotelsByPrice = hotels.sortedWith(byPriceLowToHigh())

示例 8.16 [static-to-top-level.13:src/test/java/travelator/hotels/ShortlistScenarioTest.kt] (diff)

这使得 sorted 函数保留给 Java 调用。看起来,它现在与短列表无关了。我们应该将它移动到更通用的命名空间吗?也许以后;现在我们只需继续处理文件的其余部分来达成:

fun <T> Iterable<T>.sorted(ordering: Comparator<in T>): List<T> =
    sortedWith(ordering)

fun <T> Iterable<T>.withoutItemAt(index: Int): List<T> =
    take(index) + drop(index + 1)

fun byRating(): Comparator<HasRating> =
    comparingDouble(HasRating::rating).reversed()

fun byPriceLowToHigh(): Comparator<HasPrice> =
    comparing(HasPrice::price)

... and other comparators

示例 8.17 [static-to-top-level.14:src/main/java/travelator/Shortlists.kt] (diff)

你可能已经注意到,我们将 removeItemAt 重命名为 withoutItemAt。像 withwithout 这样的介词是一个有用的工具,可以让读者知道我们不是在改变一个对象,而是返回一个副本。

继续前进

静态函数是我们程序的核心。在 Java 中,这些必须是类上的静态方法,但在 Kotlin 中,我们可以并且应该将其定义为顶级函数。

将一个 Java 类的静态方法自动转换为 Kotlin 会创建一个 object 声明,可从 Java 和 Kotlin 都访问。然后我们可以逐个将对象上的方法移到顶层,仍然可以被两种语言访问,在利用更多 Kotlin 特性的其他重构之前。

下一个最可能的重构是将我们的顶级函数重构为扩展函数,这是第十章,“从函数到扩展函数”的主题。

第九章:多到单个表达式函数

Nat 和 Duncan 都喜欢 Kotlin 的单个表达式函数定义。我们什么时候应该使用这种形式,为什么我们可能更喜欢它,并且我们可以使用哪些 Kotlin 功能使更多函数成为单个表达式?

与 Java 一样,Kotlin 函数中的代码通常是{在大括号内定义},并使用return来定义函数的结果(除非是Unit,Kotlin 对void的别名):

fun add(a: Int, b: Int): Int {
    return a + b
}

但是,如果代码的顶层是单个表达式,则可以选择省略结果类型,并使用等号后的表达式定义代码:

fun addToo(a: Int, b: Int): Int = a + b

我们可以这样阅读:函数add的结果等于a + b。这在单个表达式中是有意义的,当该表达式本身由子表达式组成时,也可以很好地阅读:

fun max(a: Int, b: Int): Int =
    when {
        a > b -> a
        else -> b
    }

对于具有副作用的函数,特别是执行 I/O 或写入可变状态的函数,这种解释则变得不那么合理。例如:

fun printTwice(s: String): Unit = println("$s\n$s")

我们不能将这读作printTwice的结果等于println(..),因为println没有结果,或者至少它不返回结果。它的功能完全是副作用,正如我们在第七章中探讨的那样。

保留单个表达式函数用于计算

如果我们采用保留单个表达式函数用于计算(“计算”)的惯例,那么当我们使用它们时,我们就有了一种传达意图的方式。当我们看到一个单个表达式函数时,我们会知道它不是一个动作(“动作”),因此重构起来更安全。

实际上,这意味着单个表达式函数不应返回Unit,也不应从可变状态中读取或写入,包括执行 I/O。

作者发现,尝试将尽可能多的函数转换为单个表达式有助于改善我们的软件。首先,如果我们将单个表达式形式保留用于计算,那么这将减少我们的代码中是动作的比例,使其更易于理解和修改。单个表达式还往往比替代方案更短,限制了每个函数的复杂性。当函数变得过大以至于难以理解时,单个表达式风格使我们更容易进行重构以提高清晰度,因为减少了依赖副作用和操作执行顺序的逻辑被破坏的风险。

我们也更喜欢表达式而不是语句。表达式是声明性的:我们声明我们希望函数计算什么,然后让 Kotlin 编译器和运行时决定如何计算该计算。我们不必在脑海中运行代码来弄清楚函数的功能。

例如,在第三章的末尾,我们留下了EmailAddress的这段代码:

data class EmailAddress(
    val localPart: String,
    val domain: String
) {

    override fun toString() = "$localPart@$domain"

    companion object {
        @JvmStatic
        fun parse(value: String): EmailAddress {
            val atIndex = value.lastIndexOf('@')
            require(!(atIndex < 1 || atIndex == value.length - 1)) {
                "EmailAddress must be two parts separated by @"
            }
            return EmailAddress(
                value.substring(0, atIndex),
                value.substring(atIndex + 1)
            )
        }
    }
}

示例 9.1 [single-expressions.0:src/main/java/travelator/EmailAddress.kt] (diff)

toString方法已经是一个很好的简单单一表达式。不过,正如我们当时所说的,parse方法中所需的代码量对于不得不在伴生对象中声明静态方法这一事实来说更是一种侮辱。也许专注于使函数成为一个简单的单一表达式会有所帮助?

在我们继续之前,我们应该说一下,本书中呈现的许多重构序列都是“这是我之前准备好的一个”。我们向你展示了成功的步骤。而现实生活中的重构,就像从头开始编写代码一样,不会像这样。我们尝试的事情根本行不通,或者我们采取的路线比最终编辑中展示的要绕远得多。因为这只是一个相对较小的例子,我们抓住了这个机会展示了当我们试图将parse转换为单一表达式时实际发生的情况。我们认为在这个过程中有宝贵的经验教训,但如果你只想要最终结果,你应该跳到“第 4 步:退后一步”。

第 1 步:内联

让我们分析一下代码,看看是什么阻止了这个函数成为一个很好的单一表达式:

fun parse(value: String): EmailAddress {
    val atIndex = value.lastIndexOf('@') ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
    require(!(atIndex < 1 || atIndex == value.length - 1)) { ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/2.png)
        "EmailAddress must be two parts separated by @"
    }
    return EmailAddress( ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/3.png)
        value.substring(0, atIndex),
        value.substring(atIndex + 1)
    )
}

示例 9.2 [single-expressions.1:src/main/java/travelator/EmailAddress.kt] (diff)

1

atIndex的赋值是一个语句。

2

require的调用是一个语句。

3

创建EmailAddress是一个单一表达式,依赖于valueatIndex

第一个语句是对atIndex的赋值。在 Kotlin 中,赋值是一个语句,而不是一个表达式(不像 Java,我们可以链式赋值)。它在代码中的位置也很重要——它必须在这里发生,以便将atIndex的值可用于编译函数的其余部分。然而,绑定到变量的表达式,value.lastIndexOf(Char)是一个计算,这意味着对于相同的参数它总是返回相同的结果(当我们调用方法时,this被视为一个参数)。因此,我们可以内联变量atIndex而不改变函数的结果,得到:

fun parse(value: String): EmailAddress {
    require(!(
        value.lastIndexOf('@') < 1 ||
            value.lastIndexOf('@') == value.length - 1)) {
        "EmailAddress must be two parts separated by @"
    }
    return EmailAddress(
        value.substring(0, value.lastIndexOf('@')),
        value.substring(value.lastIndexOf('@') + 1)
    )
}

示例 9.3 [single-expressions.2:src/main/java/travelator/EmailAddress.kt] (diff)

这个版本不会产生相同的字节码,也不会运行得那么快(可能是因为极难预测 HotSpot 的行为),但它会返回相同的结果。尽管如此,我们仍然需要处理那个require调用,并且似乎已经让一切变得更加难懂了,所以让我们撤销这个改动,尝试另一种方法。

第二次尝试:引入一个函数

另一种移除赋值语句的方法是有一个范围,在这个范围内atIndex总是被定义的。我们可以使用一个函数作为这样一个范围,因为函数将其参数绑定到其参数的单次评估。通过选择赋值语句之前的所有代码并提取一个函数emailAddress,我们可以看到这一点:

fun parse(value: String): EmailAddress {
    val atIndex = value.lastIndexOf('@')
    return emailAddress(value, atIndex)
}

private fun emailAddress(value: String, atIndex: Int): EmailAddress {
    require(!(atIndex < 1 || atIndex == value.length - 1)) {
        "EmailAddress must be two parts separated by @"
    }
    return EmailAddress(
        value.substring(0, atIndex),
        value.substring(atIndex + 1)
    )
}

示例 9.4 [single-expressions.3:src/main/java/travelator/EmailAddress.kt] (diff)

现在我们可以内联parse中的atIndex变量,因为atIndex参数已经为我们捕获了它的值:

fun parse(value: String): EmailAddress {
    return emailAddress(value, value.lastIndexOf('@'))
}

private fun emailAddress(value: String, atIndex: Int): EmailAddress {
    require(!(atIndex < 1 || atIndex == value.length - 1)) {
        "EmailAddress must be two parts separated by @"
    }
    return EmailAddress(
        value.substring(0, atIndex),
        value.substring(atIndex + 1)
    )
}

示例 9.5 [single-expressions.4:src/main/java/travelator/EmailAddress.kt] (diff)

现在parse是一个单表达式,但emailAddress(...)不是,所以我们还不能宣布胜利。require总是会给我们带来一些问题,因为它的工作是阻止评估继续进行。这与表达式相反,表达式需要评估为一个值。

当我们在重构时遇到这种僵局时,经常内联问题的原因会让我们看到前进的道路。所以让我们内联require。(暂时搁置怀疑吧;事情会变得更糟再变好。)

private fun emailAddress(value: String, atIndex: Int): EmailAddress {
    if (!!(atIndex < 1 || atIndex == value.length - 1)) {
        val message = "EmailAddress must be two parts separated by @"
        throw IllegalArgumentException(message.toString())
    }
    return EmailAddress(
        value.substring(0, atIndex),
        value.substring(atIndex + 1)
    )
}

示例 9.6 [single-expressions.5:src/main/java/travelator/EmailAddress.kt] (diff)

这里有很多冗余,我们可以移除它。在if条件上按 Alt-Enter 将移除双重否定!!,然后在多余的toString上按 Alt-Enter 将移除它。这使我们能够内联message,产生:

private fun emailAddress(value: String, atIndex: Int): EmailAddress {
    if ((atIndex < 1 || atIndex == value.length - 1)) {
        throw IllegalArgumentException(
            "EmailAddress must be two parts separated by @"
        )
    }
    return EmailAddress(
        value.substring(0, atIndex),
        value.substring(atIndex + 1)
    )
}

示例 9.7 [single-expressions.6:src/main/java/travelator/EmailAddress.kt] (diff)

现在我们可以引入一个else来看结构:

private fun emailAddress(value: String, atIndex: Int): EmailAddress {
    if ((atIndex < 1 || atIndex == value.length - 1)) {
        throw IllegalArgumentException(
            "EmailAddress must be two parts separated by @"
        )
    } else {
        return EmailAddress(
            value.substring(0, atIndex),
            value.substring(atIndex + 1)
        )
    }
}

示例 9.8 [single-expressions.7:src/main/java/travelator/EmailAddress.kt] (diff)

在这一点上,我们有一个由if选择的两个语句的函数。这离单表达式如此诱人,以至于甚至 IDE 都能感觉到它:在if上按 Alt-Enter,IntelliJ 提供“将返回值从if中提取出来”:

private fun emailAddress(value: String, atIndex: Int): EmailAddress {
    return if ((atIndex < 1 || atIndex == value.length - 1)) {
        throw IllegalArgumentException(
            "EmailAddress must be two parts separated by @"
        )
    } else {
        EmailAddress(
            value.substring(0, atIndex),
            value.substring(atIndex + 1)
        )
    }
}

示例 9.9 [single-expressions.8:src/main/java/travelator/EmailAddress.kt] (diff)

就在那里——我们的单表达式。在return上按 Alt-Enter 提供“转换为表达式体”:

private fun emailAddress(value: String, atIndex: Int): EmailAddress =
    if ((atIndex < 1 || atIndex == value.length - 1)) {
        throw IllegalArgumentException(
            "EmailAddress must be two parts separated by @"
        )
    } else {
        EmailAddress(
            value.substring(0, atIndex),
            value.substring(atIndex + 1)
        )
    }

示例 9.10 [single-expressions.9:src/main/java/travelator/EmailAddress.kt] (diff)

当我们将函数定义为单个表达式时,使用when通常比if更清晰。如果我们在if上按下 Alt-Enter,IntelliJ 将为我们执行此操作。在这里,我们还去掉了不必要的大括号,内联了message,最后将parse也转换为了单个表达式:

fun parse(value: String) =
    emailAddress(value, value.lastIndexOf('@'))

private fun emailAddress(value: String, atIndex: Int): EmailAddress =
    when {
        atIndex < 1 || atIndex == value.length - 1 ->
            throw IllegalArgumentException(
                "EmailAddress must be two parts separated by @"
            )
        else -> EmailAddress(
            value.substring(0, atIndex),
            value.substring(atIndex + 1)
        )
    }

示例 9.11 [single-expressions.10:src/main/java/travelator/EmailAddress.kt] (diff)

比较一下,这是原始的代码:

fun parse(value: String): EmailAddress {
    val atIndex = value.lastIndexOf('@')
    require(!(atIndex < 1 || atIndex == value.length - 1)) {
        "EmailAddress must be two parts separated by @"
    }
    return EmailAddress(
        value.substring(0, atIndex),
        value.substring(atIndex + 1)
    )
}

示例 9.12 [single-expressions.11:src/main/java/travelator/EmailAddress.kt] (diff)

我们对结果满意吗?

实际上并非如此。我们现在有了更多代码,而那个emailAddress函数似乎除了捕获atIndex之外没有增加任何值。

重构通常是一个探索过程。我们心中有一个目标,但并不总能预见结果。我们(作者)的经验是,尝试找到函数的单个表达式形式通常会改进我们的代码,但我们无法对你说在这里发生了什么。

我们可以放弃这个想法,或者我们可以继续努力并尝试从这里达到目标。不过,让我们回到原点,尝试第三种方法,这种方法受到我们刚刚获得的经验的启发。

Take 3: Let

我们提取emailAddress函数的原因是为了给我们一个范围,在这个范围内atIndex的值在整个块中都是定义的,而不必被分配给一个局部变量。当我们只需要替换一个变量时,使用let块可以为我们提供这个功能,而不必定义一个函数。我们可以通过先在赋值后的代码周围添加let来逐步实现这一点:

fun parse(value: String): EmailAddress {
    val atIndex = value.lastIndexOf('@')
    atIndex.let {
        require(!(atIndex < 1 || atIndex == value.length - 1)) {
            "EmailAddress must be two parts separated by @"
        }
        return EmailAddress(
            value.substring(0, atIndex),
            value.substring(atIndex + 1)
        )
    }
}

示例 9.13 [single-expressions.12:src/main/java/travelator/EmailAddress.kt] (diff)

现在我们可以将let块外的返回值提取出来;不幸的是,IntelliJ 这次没有提供帮助:

fun parse(value: String): EmailAddress {
    val atIndex = value.lastIndexOf('@')
    return atIndex.let {
        require(!(atIndex < 1 || atIndex == value.length - 1)) {
            "EmailAddress must be two parts separated by @"
        }
        EmailAddress(
            value.substring(0, atIndex),
            value.substring(atIndex + 1)
        )
    }
}

示例 9.14 [single-expressions.13:src/main/java/travelator/EmailAddress.kt] (diff)

目前,在let块中,atIndex指的是我们试图删除的本地变量。如果我们添加一个同名的 lambda 参数,它将绑定到那个参数:

fun parse(value: String): EmailAddress {
    val atIndex = value.lastIndexOf('@')
    return atIndex.let { atIndex -> ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
        require(!(atIndex < 1 || atIndex == value.length - 1)) {
            "EmailAddress must be two parts separated by @"
        }
        EmailAddress(
            value.substring(0, atIndex),
            value.substring(atIndex + 1)
        )
    }
}

示例 9.15 [single-expressions.14:src/main/java/travelator/EmailAddress.kt] (diff)

1

警告名称遮蔽:atIndex,这就是重点

atIndex变量内联,我们就得到了我们的单个表达式:

fun parse(value: String): EmailAddress {
    return value.lastIndexOf('@').let { atIndex ->
        require(!(atIndex < 1 || atIndex == value.length - 1)) {
            "EmailAddress must be two parts separated by @"
        }
        EmailAddress(
            value.substring(0, atIndex),
            value.substring(atIndex + 1)
        )
    }
}

示例 9.16 [single-expressions.15:src/main/java/travelator/EmailAddress.kt] (差异)

现在在返回上按下 Alt-Enter,让我们“转换为表达式主体”:

fun parse(value: String): EmailAddress =
    value.lastIndexOf('@').let { atIndex ->
        require(!(atIndex < 1 || atIndex == value.length - 1)) {
            "EmailAddress must be two parts separated by @"
        }
        EmailAddress(
            value.substring(0, atIndex),
            value.substring(atIndex + 1)
        )
    }

示例 9.17 [single-expressions.16:src/main/java/travelator/EmailAddress.kt] (差异)

我们已经到了无法回头的地步!我们对结果满意吗?

邓肯正在写这个,他确实在经过 15 次重构步骤后来到这里感到相当欣慰。这个示例确实达到了展示一些技巧的目的,使我们能够实现单个表达式函数。尽管如此,他并不认为这证明了追求单个表达式会带来显著回报。这仍然似乎是很多代码,而且没有一行代码感觉像是在发挥作用。

我们是否可以通过提高抽象级别来改进这一点?让我们尝试第四种方法。

第四步:退一步

如果我们迈出机械重构的步伐,我们会发现我们正在创建一个EmailAddress,它由两个非空字符串组成,这两个字符串由特定字符@分隔,本例中为@。找到两个由字符分隔的非空字符串听起来像是一个我们可以朝着重构的高级概念。

最后回滚一次,然后返回到:

fun parse(value: String): EmailAddress {
    val atIndex = value.lastIndexOf('@')
    require(!(atIndex < 1 || atIndex == value.length - 1)) {
        "EmailAddress must be two parts separated by @"
    }
    return EmailAddress(
        value.substring(0, atIndex),
        value.substring(atIndex + 1)
    )
}

示例 9.18 [single-expressions.17:src/main/java/travelator/EmailAddress.kt] (差异)

这次我们不集中精力在atIndex上,而是集中精力在那些substring调用上。我们将它们提取到变量中:

fun parse(value: String): EmailAddress {
    val atIndex = value.lastIndexOf('@')
    require(!(atIndex < 1 || atIndex == value.length - 1)) {
        "EmailAddress must be two parts separated by @"
    }
    val leftPart = value.substring(0, atIndex)
    val rightPart = value.substring(atIndex + 1)
    return EmailAddress(
        leftPart,
        rightPart
    )
}

示例 9.19 [single-expressions.18:src/main/java/travelator/EmailAddress.kt] (差异)

现在,再来一次,有感觉的一次。我们可以提取除了返回语句以外的所有函数:

fun parse(value: String): EmailAddress {
    val (leftPart, rightPart) = split(value)
    return EmailAddress(
        leftPart,
        rightPart
    )
}

private fun split(value: String): Pair<String, String> {
    val atIndex = value.lastIndexOf('@')
    require(!(atIndex < 1 || atIndex == value.length - 1)) {
        "EmailAddress must be two parts separated by @"
    }
    val leftPart = value.substring(0, atIndex)
    val rightPart = value.substring(atIndex + 1)
    return Pair(leftPart, rightPart)
}

示例 9.20 [single-expressions.19:src/main/java/travelator/EmailAddress.kt] (差异)

IntelliJ 在这里表现得非常聪明,将结果变为了一个Pair,因为它有两个值需要返回。

这个split将是一个很好的通用函数,如果它是由字符参数化的话,我们可能会在其他地方使用它。“引入参数”在'@'上让这成为可能。我们在那里时“将参数转换为接收者”在value上得到一个小的局部扩展函数:

fun parse(value: String): EmailAddress {
    val (leftPart, rightPart) = value.split('@')
    return EmailAddress(
        leftPart,
        rightPart
    )
}

private fun String.split(divider: Char): Pair<String, String> {
    val atIndex = lastIndexOf(divider)
    require(!(atIndex < 1 || atIndex == length - 1)) {
        "EmailAddress must be two parts separated by @"
    }
    val leftPart = substring(0, atIndex)
    val rightPart = substring(atIndex + 1)
    return Pair(leftPart, rightPart)
}

示例 9.21 [single-expressions.20:src/main/java/travelator/EmailAddress.kt] (差异)

现在我们可以引入一个let,就像之前所做的那样,得到:

fun parse(value: String): EmailAddress =
    value.split('@').let { (leftPart, rightPart) ->
        EmailAddress(leftPart, rightPart)
    }

示例 9.22 [single-expressions.21:src/main/java/travelator/EmailAddress.kt] (diff)

最后,这确实是一个值得努力的单表达式函数!

最后,我们可以应用本章的技术来使 split 成为单表达式。因此,这里是最终的 EmailAddress.kt

data class EmailAddress(
    val localPart: String,
    val domain: String
) {

    override fun toString() = "$localPart@$domain"

    companion object {
        @JvmStatic
        fun parse(value: String): EmailAddress =
            value.splitAroundLast('@').let { (leftPart, rightPart) ->
                EmailAddress(leftPart, rightPart)
            }
    }
}

private fun String.splitAroundLast(divider: Char): Pair<String, String> =
    lastIndexOf(divider).let { index ->
        require(index >= 1 && index != length - 1) {
            "string must be two non-empty parts separated by $divider"
        }
        substring(0, index) to substring(index + 1)
    }

示例 9.23 [single-expressions.22:src/main/java/travelator/EmailAddress.kt] (diff)

splitAroundLast 感觉像一个更好的名称,不会与标准的 String.split 冲突,并且暗示分割的两侧都必须非空。像 around 这样在标识符中不寻常的词语,应该提示代码的读者暂时搁置对函数实际操作的假设,并实际查找它。

尽管 splitAroundLast 确实感觉像一个通用的实用函数,如果我们想将其提升为公共函数,我们应该为其编写一些单元测试。尽管今天我们已经花了足够的时间,但我们将心里记下如果将来需要一个 String.splitAroundLast,最后提交这个更改。

继续前行

将我们的计算定义为单表达式函数可以让我们传达它们与具有副作用的操作不同的信息。试图将函数表达为简单的单表达式是一种有用的纪律,可以导致良好分解的干净代码。要实现单表达式形式,通常需要将子表达式分解为它们自己的函数。

单表达式形式是声明性的:表达式描述了函数的结果,而不是计算结果所需的计算机操作。将子表达式分解为它们自己的函数促使我们考虑这些子表达式应表示什么,因此引导我们编写更清晰的代码。例如,String.splitAroundLast('@') 更好地描述了我们想计算的内容,而不是 emailAddress(value: String, atIndex: Int)

在更深层次上,本章不仅关于单表达式;它还关于我们如何重新排列代码而不改变其行为。许多不同的语句和表达式排列方式将具有相同的行为;重构是找到更好排列方式的艺术,并安全地达到那里。我们能够可视化的排列方式越多,我们能够规划的安全路径越多,我们就有越多选项来改进我们的代码。

重构并不总是在第一次、第二次甚至第三次尝试时成功。作为开发者,我们并不总能有重复尝试的奢侈,但是我们练习改进代码中的沟通方式的次数越多,我们就越能在放弃之前达到目标。

第十章:从函数到扩展函数

Kotlin 有一种特殊类型的程序称为扩展函数,它像方法一样被调用,但实际上(通常)是顶层函数。将普通函数转换为扩展函数以及反向转换都很容易。什么时候我们应该选择其中一种?

函数和方法

面向对象编程是通过向对象发送消息来解决问题的艺术。想知道myString的长度?通过发送消息myString.length()来询问它。想将该字符串打印到控制台?将字符串放入消息中,并向表示控制台的另一个对象询问以打印它:System.out.println(myString)。在经典的面向对象语言中,我们通过在类上定义方法来定义对象如何对消息作出响应。方法绑定到它们的类,并且可以访问与特定实例相关联的成员(字段和其他方法)。当我们调用方法时,运行时会安排调用正确的版本(取决于对象的运行时类型),并让其访问实例状态。

相比之下,在函数式编程中,我们通过传递值调用函数来解决问题。我们通过将myString传递给函数来获取其长度:length(myString)。我们通过println(myString)将内容打印到控制台,如果想在其他地方打印,也可以将其传递给函数:println(myString, System.err)。函数并不是在类型上定义的,函数的参数和结果有一个类型。

范式各有利弊,但目前让我们只考虑可发现性和可扩展性。

这里是一个Customer类型:

data class Customer(
    val id: String,
    val givenName: String,
    val familyName: String
) {
    ...
}

这是一个类,因此我们立即知道可以发送消息来询问idgivenNamefamilyName。其他操作呢?在基于类的系统中,我们只需向下滚动查看我们可以发送的另一条消息:

data class Customer(
    val id: String,
    val givenName: String,
    val familyName: String
) {
    val fullName get() = "$givenName $familyName"
}

我们通常甚至没有看定义的那么远。如果我们有一个变量val customer: Customer,我们可以输入customer.,我们的 IDE 将急切地告诉我们可以调用idgivenNamefamilyNamefullName。事实上,这种自动完成在许多方面比查看类定义更好,因为它还显示了在超类型中定义的其他操作(如equalscopy等)或语言中隐含的操作。

在功能分解中,fullName将是一个函数,如果我们怀疑它存在,我们需要在代码库中搜索它。在这种情况下,它将是一个函数,其唯一参数的类型是Customer。让 IntelliJ 帮助我们是令人惊讶的难。按参数类型分组的“查找用途”将完成任务,但实际上并不方便。实际上,我们期望在源文件中找到Customer及其基本操作的定义,可能在同一个文件中或至少在相同的命名空间中,因此我们可以导航到那里并找到我们期望的函数,但我们的工具并不是非常有用。

面向对象的发现能力得分一分。那么可扩展性呢?当我们想要向Customer添加一个操作时会发生什么?营销部门希望以familyName大写的方式反向渲染名称以进行某些报告或其他操作。(您可能会注意到,每当我们需要一个简单但是任意的示例时,我们就会责怪营销部门。)

如果我们拥有代码,我们可以直接添加一个方法:

data class Customer(
    val id: String,
    val givenName: String,
    val familyName: String
) {
    val fullName get() = "$givenName $familyName"
    fun nameForMarketing() = "${familyName.uppercase()}, $givenName}"
}

如果我们没有拥有代码,那么我们就不能添加一个方法,所以我们必须退而求其次使用一个函数。在 Java 中,我们可以在名为MarketingCustomerUtils的类中拥有这些静态函数的集合。在 Kotlin 中,我们可以将它们作为顶级函数(请参阅第八章),但原理是相同的:

fun nameForMarketing(customer: Customer) =
    "${customer.familyName.uppercase()}, $customer.givenName}"

那么函数式解决方案怎么样?嗯,这也是函数式解决方案。因此,从可扩展性的角度来看,函数式解决方案可能更好,因为扩展操作与原始作者提供的操作(如fullName)是不可区分的,而面向对象的解决方案则要求我们寻找两种不同类型的实现:方法和函数。

即使我们拥有Customer类的代码,我们也应该谨慎添加像nameForMarketing这样的方法。如果Customer类是我们应用程序中的基本领域类,那么许多其他代码将依赖于它。添加一个用于营销的报告不应该强制我们重新编译和重新测试所有内容,但如果我们添加了一个方法,则会这样做。因此,最好将Customer保持尽可能小,并将非核心操作作为外部函数,即使这意味着它们不像方法那样易于发现。

在 Kotlin 中,这些函数不必像我们说的那么难找到;它们可以是扩展函数。

扩展函数

Kotlin 的扩展函数看起来像方法,但实际上只是函数(正如我们在第八章中看到的,从技术上讲,它们也是方法,因为在 JVM 上所有代码都必须定义在方法中)。在"扩展函数作为方法"中,我们将看到扩展函数实际上也可以是另一个类的非静态方法。

正如它们的名字所示,扩展函数为我们提供了在类型上扩展操作的能力。它们支持直观的点号调用方法,这使得它们可以像方法那样易于发现,通过按下 Ctrl-Space。

因此,我们可以定义一个扩展函数:

fun Customer.nameForMarketing() = "${familyName.uppercase()}, $givenName}"

然后我们可以像调用方法一样调用它:

val s = customer.nameForMarketing()

IntelliJ 将自动建议扩展函数以及实际的方法,即使它们需要导入以将它们引入范围内。

Java 并不是那么有帮助——它只把扩展函数看作是静态函数:

var s = MarketingStuffKt.nameForMarketing(customer);

MarketingStuffKt是包含我们顶级声明作为静态方法的类的名称;请参阅第八章。

有趣的是,我们无法从 Kotlin 以同样的方式调用该函数:

nameForMarketing(customer) // doesn't compile

这导致编译失败,出现以下错误:

未解析的引用。由于接收器类型不匹配,以下候选项均不适用:public fun Customer.nameForMarketing(): String ...

顺便说一句,接收者是 Kotlin 中用于扩展函数(或普通方法)中的名为this的对象的术语:接收消息的对象。

注意,扩展函数没有对它们扩展的类的私有成员有任何特殊访问权限;它们只具有其作用域中普通函数的相同权限。

扩展和函数类型

虽然我们不能在 Kotlin 中像普通函数一样调用扩展函数,但我们可以将它们分配给普通函数引用。因此以下内容编译通过:

val methodReference: (Customer.() -> String) =
    Customer::fullName
val extensionFunctionReference: (Customer.() -> String) =
    Customer::nameForMarketing

val methodAsFunctionReference: (Customer) -> String =
    methodReference
val extensionAsFunctionReference: (Customer) -> String =
    extensionFunctionReference

我们可以按预期调用这些函数:

customer.methodReference()
customer.extensionFunctionReference()

methodAsFunctionReference(customer)
extensionAsFunctionReference(customer)

我们也可以将with-receiver引用视为接收者作为第一个参数:

methodReference(customer)
extensionFunctionReference(customer)

但是,我们无法将普通引用视为具有接收者的引用。这两行都无法编译通过,出现未解析的引用错误:

customer.methodAsFunctionReference()
customer.extensionAsFunctionReference()

扩展属性

Kotlin 还支持扩展属性。正如我们在第十一章讨论的那样,Kotlin 属性访问器实际上是方法调用。与扩展函数像方法一样调用一样,扩展属性是像属性一样调用的静态函数,这些属性又是方法。扩展属性不能存储任何数据,因为它们实际上不会向其类添加字段——它们的值只能计算。

nameForMarketing函数可以被定义为一个扩展属性

val Customer.nameForMarketing get() = "${familyName.uppercase()}, $givenName}"

实际上,它可能应该是一个属性,正如我们将在第十一章中讨论的那样。

我们关于扩展函数的大部分内容都适用于扩展属性,除非我们明确区分它们。

扩展不是多态的

虽然调用扩展函数看起来像是方法调用,但实际上并不是发送消息给对象。对于多态方法调用,Kotlin 使用接收者的动态类型在运行时选择要执行的方法。对于扩展,Kotlin 使用接收者的静态类型在编译时选择要调用的函数。

如果我们需要以多态方式使用扩展,我们通常可以通过从扩展函数调用多态方法来实现这一点。

转换

到目前为止,我们已经看到扩展函数将操作添加到类型中。从一种类型到另一种类型的转换是一种常见情况。Travelator 需要将客户详细信息转换为 JSON 和 XML,并从中转换。我们应该如何从JsonNode转换为Customer

我们可以添加一个构造函数:Customer(JsonNode),它知道如何提取相关数据,但是在我们的 Customer 类中污染具体的 JSON 库的依赖感觉并不对劲,然后可能是 XML 解析器,然后呢?相同的论点适用于将转换添加到 JsonNode 类中。即使我们可以改变它的代码,很快它也将变得难以管理,带有所有 JsonNode.toMyDomainType() 方法。

在 Java 中,我们会编写形式为实用函数的类:

static Customer toCustomer(JsonNode node) {
        ...
}

或者使用 Nat 和 Duncan 偏爱的命名约定:

static Customer customerFrom(JsonNode node) {
        ...
}

分别调用转换并不是太可怕:

var customer = customerFrom(node);
var marketingName = nameForMarketing(customer);

但是,如果我们需要组合函数,事情开始变得混乱:

var marketingLength = nameForMarketing(customerFrom(node)).length();

我们都是开发人员,在阅读函数调用时很容易低估搜索内部调用并通过函数和方法调用从内到外计算表达式的认知负荷。不是它评估为什么,而是它评估的顺序。在 Kotlin 中,我们可以将转换编写为 JsonNode 的扩展,并享受从左到右的流畅流程:

fun JsonNode.toCustomer(): Customer = ...

val marketingLength = jsonNode.toCustomer().nameForMarketing().length

啊…这样读起来更清晰了。

可空参数

当我们处理可选数据时,扩展函数真正发挥作用。当我们向可能为null的对象发送消息时,我们可以使用我们在第四章中看到的安全调用运算符?.。这对参数没有帮助;要将可为空的引用作为参数传递给接受非空参数的函数,我们必须将调用包装在条件逻辑中:

val customer: Customer? = loggedInCustomer()
val greeting: String? = when (customer) {
    null -> null
    else -> greetingForCustomer(customer)
}

Kotlin 的作用域函数,例如 letapplyalso,可以在这里提供帮助。特别是 let 将其接收者转换为 lambda 参数:

val customer: Customer? = loggedInCustomer()
val greeting: String? = customer?.let { greetingForCustomer(it) }

在这里,?. 确保仅当客户不为null时才调用 let,这意味着 lambda 参数 it 永不为 null,并且可以传递给 lambda 体内的函数。您可以将 ?.let 视为(单个)参数的安全调用运算符。

如果函数返回可为空的结果,并且我们必须将该结果传递给期望非空参数的另一个函数,则作用域函数开始变得笨拙:

val customer: Customer? = loggedInCustomer()

val reminder: String? = customer?.let {
    nextTripForCustomer(it)?.let {
        timeUntilDepartureOfTrip(it, currentTime)?.let {
            durationToUserFriendlyText(it) + " until your next trip!"
        }
    }
}

即使我们可以将嵌套的空检查展平为调用管道,所有这些额外的机制都会增加语法噪音并且遮蔽了代码的意图

val reminder: String? = customer
    ?.let { nextTripForCustomer(it) }
    ?.let { timeUntilDepartureOfTrip(it, currentTime) }
    ?.let { durationToUserFriendlyText(it) }
    ?.let { it + " until your next trip!" }

如果我们将有问题的参数转换为扩展函数的接收器,我们可以直接链式调用,将应用逻辑置于前端:

val reminder: String? = customer
    ?.nextTrip()
    ?.timeUntilDeparture(currentTime)
    ?.toUserFriendlyText()
    ?.plus(" until your next trip!")

当 Nat 和 Duncan 刚开始使用 Kotlin 时,他们很快发现扩展和可空性形成了一个良性循环。使用扩展函数处理可选数据更容易,因此他们将文件中私有的扩展或重构函数成扩展,使逻辑更易于编写。他们发现这些扩展的命名可以比等效的函数更简洁,而不会掩盖意图。因此,他们编写了更多的扩展来使应用逻辑更加简洁。私有扩展通常在其他地方也很有用,因此他们将它们移到了可以轻松共享的公共模块中。这使得在应用程序的其他部分使用可选数据变得更容易,从而导致他们编写了更多的扩展,从而使应用逻辑更加简洁……依此类推。

尽管推广扩展作为扩展第三方类型的一种方法,允许的简洁命名和类型系统中的可空性,鼓励我们也在自己的类型上定义扩展。Kotlin 的一部分是这些特性如何交互以平滑我们的道路。

可空接收器

调用方法和调用函数之间的一个主要区别在于对 null 引用的处理方式。如果我们有一个 null 引用,我们不能向其发送消息,因为没有任何对象可以发送消息——如果我们尝试,JVM 会抛出 Null​Poin⁠terException。相反,我们可以拥有 null 参数。我们可能不知道如何处理它们,但它们并不妨碍运行时找到要调用的代码。

因为扩展函数中的接收器实际上是一个参数,它可以null。因此,虽然 anObject.method()anObject.extensionFunction() 看起来是等效的调用,但如果接收器可空,method 永远无法被调用,而 extensionFunction 可以在接收器可空时被调用。

我们可以利用这一点,在 Trip? 的扩展中提取出生成前一个流水线中提醒的步骤:

fun Trip?.reminderAt(currentTime: ZonedDateTime): String? =
    this?.timeUntilDeparture(currentTime)
        ?.toUserFriendlyText()
        ?.plus(" until your next trip!")

请注意,我们必须使用安全调用操作符来解引用扩展中的 this。虽然 this 在方法中从不为 null,但在可空类型的扩展中,它可能为 null。如果你从 Java 转到 Kotlin,nullthis 可能会让人感到意外,因为在 Java 中这是不可能发生的,但对于扩展而言,Kotlin 将 this 视为另一个可空参数。

我们可以在可空的 Trip 上调用这个函数,而不需要使用 ?. 的噪音:

val reminder: String? = customer.nextTrip().reminderAt(currentTime)

另一方面,我们使调用函数中的空值流程更难理解,因为虽然经过类型检查,但在调用扩展的流水线代码中看不到它。

Trip?.reminderAt 有另一个更显眼的缺点:即使在非空 Trip 上调用,返回类型始终是可空的 String?。在这种情况下,我们将会写出这样的代码:

val trip: Trip = ...
val reminder: String = trip.reminderAt(currentTime) ?: error("Should never happen")

当周围的代码发生变化时,这是一个等待发生错误的 bug,因为我们使得类型检查器无法检测到不兼容的更改。

提示

不要在可空类型上编写返回null的扩展函数,如果接收器为null。请在非可空类型上编写扩展,并使用安全调用运算符来调用它。

尽管在返回非空结果时,可空类型的扩展可以很有用。它们充当了从可空值的领域返回非可空值的逃逸路径,终止了安全调用的管道。例如,我们可以使reminderAt扩展即使客户没有下次旅行时也返回一些有意义的文本:

fun Trip?.reminderAt(currentTime: ZonedDateTime): String =
    this?.timeUntilDeparture(currentTime)
        ?.toUserFriendlyText()
        ?.plus(" until your next trip!")
        ?: "Start planning your next trip.  The world's your oyster!"

类似地,在这里有两个扩展函数,我们可能应该在第四章中引入。第一个是在任何可空类型上定义的,但始终返回非空结果:

fun <T : Any> T?.asOptional(): Optional<T> = Optional.ofNullable(this)
fun <T : Any> Optional<T>.asNullable(): T? = this.orElse(null)

这很好地提出了通用扩展的主题。

泛型

就像普通函数一样,扩展函数可以有泛型参数,当接收器是泛型时,事情变得非常有趣。

这是一个有用的扩展函数,因某种原因不是标准库的一部分。它被定义为在任何类型上的扩展,包括null引用:

fun <T> T.printed(): T = this.also(::println)

当我们想要在原地调试表达式的值时,我们可以使用这个:

val marketingLength = jsonNode.toCustomer().nameForMarketing().length

如果我们需要查看客户的值以进行调试,通常需要提取一个变量:

val customer = jsonNode.toCustomer()
println(customer)
val marketingLength = customer.nameForMarketing().length

有了printed,我们有一个打印接收器值并返回它的函数,这样我们就可以写:

val marketingLength = jsonNode.toCustomer().printed().nameForMarketing().length

这比较少干扰,易于在提交前进行搜索。

注意,即使我们能够为Any?添加方法,也没有办法让方法表明它返回与其接收器相同的类型。如果我们写过:

class Any {
    fun printed() = this.also(::println)
}

返回类型本来会是Any,所以我们无法在结果上调用nameForMarketing()等。

我们也可以为特殊的泛型类型定义扩展函数,例如,Iterable<Customer>

fun Iterable<Customer>.familyNames(): Set<String> =
    this.map(Customer::familyName).toSet()

这个扩展函数适用于任何Collection<Customer>,但不适用于其他类型的集合。这使我们能够使用集合来表示领域概念,而不是定义我们自己的类型,正如我们将在第十五章中看到的那样。我们还可以将集合管道的部分提取为命名操作;参见“提取管道的一部分”。

扩展函数作为方法

通常情况下,我们将扩展函数定义为顶层函数。不过,它们也可以类定义内部定义。在这种情况下,它们可以访问其自身类的成员,并扩展另一个类型:

class JsonWriter(
    private val objectMapper: ObjectMapper,
) {
    fun Customer.toJson(): JsonNode = objectMapper.valueToTree(this)
}

这里Customer.toJson可以访问两个值的this。它可以引用扩展函数的Customer接收器或方法的JsonWriter实例。详细写出来,该函数是:

fun Customer.toJson(): JsonNode =
    this@JsonWriter.objectMapper.valueToTree(this@toJson)

这并不是我们应该经常使用的技术(没有 IDE 的帮助很难解释哪个接收者适用),但它可以通过允许简单的从左到右阅读扩展函数来简化代码,同时隐藏那些会使事情复杂化的细节。特别是它允许 DSL 隐藏客户端不应被打扰的细节(比如ObjectMapper)。

重构为扩展函数

将静态方法转换为扩展函数的实际机制很简单,但我们必须培养一种感觉,看看哪里可以用扩展函数让事情变得更好。让我们逐步完成一部分 Travelator,看看我们的表现如何。

市场部的聪明人设计了一张电子表格,根据客户对公司的预期未来支出来评分,他们不断调整算法,因此不希望我们自动化这一过程。相反,他们导出一个以制表符分隔的客户数据、分数和支出文件,我们从中生成摘要报告。以下是我们的测试:

class HighValueCustomersReportTests {

    @Test
    public void test() throws IOException {
        List<String> input = List.of(
            "ID\tFirstName\tLastName\tScore\tSpend",
            "1\tFred\tFlintstone\t11\t1000.00",
            "4\tBetty\tRubble\t10\t2000.00",
            "2\tBarney\tRubble\t0\t20.00",
            "3\tWilma\tFlintstone\t9\t0.00"
        );
        List<String> expected = List.of(
            "ID\tName\tSpend",
            "4\tRUBBLE, Betty\t2000.00",
            "1\tFLINTSTONE, Fred\t1000.00",
            "\tTOTAL\t3000.00"
        );
        check(input, expected);
    }

    @Test
    public void emptyTest() throws IOException {
        List<String> input = List.of(
            "ID\tFirstName\tLastName\tScore\tSpend"
        );
        List<String> expected = List.of(
            "ID\tName\tSpend",
            "\tTOTAL\t0.00"
        );
        check(input, expected);
    }

    @Test
    public void emptySpendIs0() {
        assertEquals(
            new CustomerData("1", "Fred", "Flintstone", 0, 0D),
            HighValueCustomersReport.customerDataFrom("1\tFred\tFlintstone\t0")
        );
    }

    private void check(
        List<String> inputLines,
        List<String> expectedLines
    ) throws IOException {
        var output = new StringWriter();
        HighValueCustomersReport.generate(
            new StringReader(String.join("\n", inputLines)),
            output
        );
        assertEquals(String.join("\n", expectedLines), output.toString());
    }
}

示例 10.1 [extensions.0:src/test/java/travelator/marketing/HighValueCustomersReportTests.java] (diff)

你可以看到我们没有在这些上大动干戈,因为市场部的人有改变主意的习惯,但报告的本质是需要列出得分为 10 或更高的客户,按支出排序,并附带总计行。

这是代码:

public class HighValueCustomersReport {

    public static void generate(Reader reader, Writer writer) throws IOException {
        List<CustomerData> valuableCustomers = new BufferedReader(reader).lines()
            .skip(1) // header
            .map(line -> customerDataFrom(line))
            .filter(customerData -> customerData.score >= 10)
            .sorted(comparing(customerData -> customerData.score))
            .collect(toList());

        writer.append("ID\tName\tSpend\n");
        for (var customerData: valuableCustomers) {
            writer.append(lineFor(customerData)).append("\n");
        }
        writer.append(summaryFor(valuableCustomers));
    }

    private static String summaryFor(List<CustomerData> valuableCustomers) {
        var total = valuableCustomers.stream()
            .mapToDouble(customerData -> customerData.spend)
            .sum();
        return "\tTOTAL\t" + formatMoney(total);
    }

    static CustomerData customerDataFrom(String line) {
        var parts = line.split("\t");
        double spend = parts.length == 4 ? 0 :
            Double.parseDouble(parts[4]);
        return new CustomerData(
            parts[0],
            parts[1],
            parts[2],
            Integer.parseInt(parts[3]),
            spend
        );
    }

    private static String lineFor(CustomerData customer) {
        return customer.id + "\t" + marketingNameFor(customer) + "\t" +
            formatMoney(customer.spend);
    }

    private static String formatMoney(double money) {
        return String.format("%#.2f", money);
    }

    private static String marketingNameFor(CustomerData customer) {
        return customer.familyName.toUpperCase() + ", " + customer.givenName;
    }
}

示例 10.2 [extensions.0:src/main/java/travelator/marketing/HighValueCustomersReport.java] (diff)

你可以看到这已经是相当功能性的(而不是面向对象的)解决方案。这将使其易于转换为顶级函数,而顶级函数易于转换为扩展函数。

但首先,这是CustomerData

public class CustomerData {
    public final String id;
    public final String givenName;
    public final String familyName;
    public final int score;
    public final double spend;

    public CustomerData(
        String id,
        String givenName,
        String familyName,
        int score,
        double spend
    ) {
        this.id = id;
        this.givenName = givenName;
        this.familyName = familyName;
        this.score = score;
        this.spend = spend;
    }

    ... and equals and hashcode
}

示例 10.3 [extensions.0:src/main/java/travelator/marketing/CustomerData.java] (diff)

这并不试图代表客户的所有信息,只是我们在此报告中关心的数据,这就是为什么编写它的人只使用了字段。(第十一章讨论了这种权衡。) 我怀疑我们(呃,写这个的人)如果不是因为emptySpendIs0测试,甚至可能都不会费事写equalshashCode。支出的double看起来也有点可疑,但目前还没有引起任何问题,所以我们会暂时置之不理,然后将整个东西转换为 Kotlin 数据类(参见第五章)再继续。

通常,由于出色的互操作性,这本来是一件非常简单的工作,但事实证明(在撰写本文时),转换器无法相信有人会屈服于原始字段访问。因此,它不会更新 Java 的访问,例如 customerData.score,以调用 customerData.getScore()(Kotlin 属性),导致大量编译错误。与其修复这些问题,我们回退,并使用“封装字段”重构将 Customer 中的所有字段和字段访问转换为获取器:

public class CustomerData {
    private final String id;
    private final String givenName;
    private final String familyName;
    private final int score;
    private final double spend;

    ...

    public String getId() {
        return id;
    }

    public String getGivenName() {
        return givenName;
    }
    ...
}

示例 10.4 [extensions.1:src/main/java/travelator/marketing/CustomerData.java] (diff)

重构还更新了客户端代码以调用获取器:

private static String lineFor(CustomerData customer) {
    return customer.getId() + "\t" + marketingNameFor(customer) + "\t" +
        formatMoney(customer.getSpend());
}

示例 10.5 [extensions.1:src/main/java/travelator/marketing/HighValueCustomersReport.java] (diff)

获取器使我们能够将 CustomerData 转换为 Kotlin 数据类,而不会破坏 Java。“将 Java 文件转换为 Kotlin 文件”,然后添加 data 修饰符并删除 equalshashCode 覆盖,我们得到:

data class CustomerData(
    val id: String,
    val givenName: String,
    val familyName: String,
    val score: Int,
    val spend: Double
)

示例 10.6 [extensions.2:src/main/java/travelator/marketing/CustomerData.kt] (diff)

现在我们可以继续将 HighValueCustomerReport 转换为 Kotlin,因为它是完全自包含的。这并不顺利,因为转换后 customerDataFrom 无法编译:

object HighValueCustomersReport {
    ...
    @JvmStatic
    fun customerDataFrom(line: String): CustomerData {
        val parts = line.split("\t".toRegex()).toTypedArray()
        val spend: Double = if (parts.size == 4) 0 else parts[4].toDouble() ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
        return CustomerData(
            parts[0],
            parts[1],
            parts[2], parts[3].toInt(), ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/2.png)
            spend
        )
    }
    ...
}

示例 10.7 [extensions.3:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (diff)

1

整数文字不符合预期的 Double 类型

2

奇怪的格式化。

转换器没有足够聪明,不知道 Kotlin 不会将整数 0 强制转换为 double,导致编译错误。让我们点击错误并使用 Alt-Enter 修复它,希望在机器统治世界时它也会回报。重新格式化后,我们得到:

object HighValueCustomersReport {
    ...
    @JvmStatic
    fun customerDataFrom(line: String): CustomerData {
        val parts = line.split("\t".toRegex()).toTypedArray()
        val spend: Double = if (parts.size == 4) 0.0 else parts[4].toDouble()
        return CustomerData(
            parts[0],
            parts[1],
            parts[2],
            parts[3].toInt(),
            spend
        )
    }
    ...
}

示例 10.8 [extensions.4:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (diff)

正如我们在第八章中讨论的那样,这些转换将函数放置在 object HighValueCustomersReport 中,以便 Java 代码仍然能找到它们。如果我们尝试使用该章节中的技术将它们转换为顶级函数,我们会发现方法之间的依赖关系有时导致代码无法编译。我们可以通过先移动私有方法或者暂时忽略编译器直到 HighValueCustomersReport 被清空和移除来解决这个问题。

package travelator.marketing

...

@Throws(IOException::class)
fun generate(reader: Reader?, writer: Writer) {
    val valuableCustomers = BufferedReader(reader).lines()
        .skip(1) // header
        .map { line: String -> customerDataFrom(line) }
        .filter { (_, _, _, score) -> score >= 10 }
        .sorted(Comparator.comparing { (_, _, _, score) -> score })
        .collect(Collectors.toList())
    writer.append("ID\tName\tSpend\n")
    for (customerData in valuableCustomers) {
        writer.append(lineFor(customerData)).append("\n")
    }
    writer.append(summaryFor(valuableCustomers))
}

private fun summaryFor(valuableCustomers: List<CustomerData>): String {
    val total = valuableCustomers.stream()
        .mapToDouble { (_, _, _, _, spend) -> spend }
        .sum()
    return "\tTOTAL\t" + formatMoney(total)
}

fun customerDataFrom(line: String): CustomerData {
    val parts = line.split("\t".toRegex()).toTypedArray()
    val spend: Double = if (parts.size == 4) 0.0 else parts[4].toDouble()
    return CustomerData(
        parts[0],
        parts[1],
        parts[2],
        parts[3].toInt(),
        spend
    )
}

private fun lineFor(customer: CustomerData): String {
    return customer.id + "\t" + marketingNameFor(customer) + "\t" +
        formatMoney(customer.spend)
}

private fun formatMoney(money: Double): String {
    return String.format("%#.2f", money)
}

private fun marketingNameFor(customer: CustomerData): String {
    return customer.familyName.toUpperCase() + ", " + customer.givenName
}

例子 10.9 [extensions.5:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (diff)

好了,现在是时候寻找可以改进代码的扩展函数的地方了。在末尾是我们之前看到的 marketingNameFor(稍微不同版本)。如果我们在 customer 参数上按 Alt-Enter,IntelliJ 将提供“将参数转换为接收者”。这给出:

private fun lineFor(customer: CustomerData): String {
    return customer.id + "\t" + customer.marketingNameFor() + "\t" +
        formatMoney(customer.spend)
}

...
private fun CustomerData.marketingNameFor(): String {
    return familyName.toUpperCase() + ", " + givenName
}

例子 10.10 [extensions.6:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (diff)

现在 marketingNameFor 中的 For 有点混乱,因为我们将参数移动为接收者,所以 For 没有主语。让我们“将函数转换为名为 marketingName 的属性”(第十一章 解释了如何以及为什么),然后“转换为表达式体”。哦,并且在两个字符串上都使用“转换为模板连接”。哇,这一连串的 Alt-Enter 操作给出了:

private fun lineFor(customer: CustomerData): String =
    "${customer.id}\t${customer.marketingName}\t${formatMoney(customer.spend)}"

private fun formatMoney(money: Double): String {
    return String.format("%#.2f", money)
}

private val CustomerData.marketingName: String
    get() = "${familyName.toUpperCase()}, $givenName"

例子 10.11 [extensions.7:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (diff)

现在 formatMoney 让我们失望了,所以我们可以再次“将参数转换为接收者”,重命名为 toMoneyString,并“转换为表达式体”:

private fun lineFor(customer: CustomerData): String =
    "${customer.id}\t${customer.marketingName}\t${customer.spend.toMoneyString()}"

private fun Double.toMoneyString() = String.format("%#.2f", this)

例子 10.12 [extensions.8:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (diff)

String.format 有点让人不舒服。Kotlin 允许我们写 "%#.2f".format(this),但我们更喜欢交换参数和接收者以获得:

private fun Double.toMoneyString() = this.formattedAs("%#.2f")

private fun Double.formattedAs(format: String) = String.format(format, this)

例子 10.13 [extensions.9:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (diff)

Double.formattedAs 是我们编写的第一个带有参数的扩展函数。这是因为其他函数一直都是非常具体的转换,但这一个更通用。在考虑通用性时,formattedAs 同样可以应用于任何类型,包括 null,因此我们可以升级它为:

private fun Any?.formattedAs(format: String) = String.format(format, this)

示例 10.14 [extensions.10:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (diff)

现在感觉很适合移入我们的通用 Kotlin 函数库。

接下来,我们关注customerDataFrom。目前是这样的:

fun customerDataFrom(line: String): CustomerData {
    val parts = line.split("\t".toRegex()).toTypedArray()
    val spend: Double = if (parts.size == 4) 0.0 else parts[4].toDouble()
    return CustomerData(
        parts[0],
        parts[1],
        parts[2],
        parts[3].toInt(),
        spend
    )
}

示例 10.15 [extensions.11:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (diff)

在继续之前,让我们观察一下CharSequence.split()String.toRegex()Collection<T>.toTypedArray()String.toDouble()String.toInt()都是 Kotlin 标准库提供的扩展函数。

在我们解决customerDataFrom签名之前,有很多可以整理的工作。Kotlin 提供了CharSequence.split(delimiters)可以替代正则表达式。然后我们可以内联spend,按下 Alt-Enter,然后选择“添加名称以调用参数”,以帮助理解构造函数调用:

fun customerDataFrom(line: String): CustomerData {
    val parts = line.split("\t")
    return CustomerData(
        id = parts[0],
        givenName = parts[1],
        familyName = parts[2],
        score = parts[3].toInt(),
        spend = if (parts.size == 4) 0.0 else parts[4].toDouble()
    )
}

示例 10.16 [extensions.12:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (diff)

第九章主张支持单表达式函数。这当然不是必须是单表达式,但让我们练习一下:

fun customerDataFrom(line: String): CustomerData =
    line.split("\t").let { parts ->
        CustomerData(
            id = parts[0],
            givenName = parts[1],
            familyName = parts[2],
            score = parts[3].toInt(),
            spend = if (parts.size == 4) 0.0 else parts[4].toDouble()
        )
    }

示例 10.17 [extensions.13:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (diff)

最后,我们可以开始转换为扩展函数了。同样,我们将名称更改为toCustomerData以使调用站点有意义:

fun String.toCustomerData(): CustomerData =
    split("\t").let { parts ->
        CustomerData(
            id = parts[0],
            givenName = parts[1],
            familyName = parts[2],
            score = parts[3].toInt(),
            spend = if (parts.size == 4) 0.0 else parts[4].toDouble()
        )
    }

示例 10.18 [extensions.14:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (diff)

注意,我们的测试中的 Java 仍然可以调用此方法作为静态方法:

@Test
public void emptySpendIs0() {
    assertEquals(
        new CustomerData("1", "Fred", "Flintstone", 0, 0D),
        HighValueCustomersReportKt.toCustomerData("1\tFred\tFlintstone\t0")
    );
}

示例 10.19 [extensions.14:src/test/java/travelator/marketing/HighValueCustomersReportTests.java] (diff)

现在让我们来处理summaryFor

private fun summaryFor(valuableCustomers: List<CustomerData>): String {
    val total = valuableCustomers.stream()
        .mapToDouble { (_, _, _, _, spend) -> spend }
        .sum()
    return "\tTOTAL\t" + total.toMoneyString()
}

示例 10.20 [extensions.15:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (diff)

那个解构有点奇怪,但我们可以手动将流转换为 Kotlin 来摆脱它。当我们编写时,IntelliJ 无法完成此操作,但我们在第十三章中提供了指导。我们将同时移除字符串连接:

private fun summaryFor(valuableCustomers: List<CustomerData>): String {
    val total = valuableCustomers.sumByDouble { it.spend }
    return "\tTOTAL\t${total.toMoneyString()}"
}

示例 10.21 [extensions.16:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (diff)

现在我们熟悉的组合转换为一个合适命名的单表达式扩展函数:

private fun List<CustomerData>.summarised(): String =
    sumByDouble { it.spend }.let { total ->
        "\tTOTAL\t${total.toMoneyString()}"
    }

示例 10.22 [extensions.17:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (diff)

在这个阶段,只有 generate 还没有改进:

@Throws(IOException::class)
fun generate(reader: Reader?, writer: Writer) {
    val valuableCustomers = BufferedReader(reader).lines()
        .skip(1) // header
        .map { line: String -> line.toCustomerData() }
        .filter { (_, _, _, score) -> score >= 10 }
        .sorted(Comparator.comparing { (_, _, _, score) -> score })
        .collect(Collectors.toList())
    writer.append("ID\tName\tSpend\n")
    for (customerData in valuableCustomers) {
        writer.append(lineFor(customerData)).append("\n")
    }
    writer.append(valuableCustomers.summarised())
}

示例 10.23 [extensions.18:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (diff)

再次,我们目前必须手动将 Java 流转换为 Kotlin 列表操作:

@Throws(IOException::class)
fun generate(reader: Reader, writer: Writer) {
    val valuableCustomers = reader.readLines()
        .drop(1) // header
        .map(String::toCustomerData)
        .filter { it.score >= 10 }
        .sortedBy(CustomerData::score)
    writer.append("ID\tName\tSpend\n")
    for (customerData in valuableCustomers) {
        writer.append(lineFor(customerData)).append("\n")
    }
    writer.append(valuableCustomers.summarised())
}

示例 10.24 [extensions.19:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (diff)

Appendable.appendLine() 是另一个允许我们简化输出阶段的扩展函数:

@Throws(IOException::class)
fun generate(reader: Reader, writer: Writer) {
    val valuableCustomers = reader.readLines()
        .drop(1) // header
        .map(String::toCustomerData)
        .filter { it.score >= 10 }
        .sortedBy(CustomerData::score)
    writer.appendLine("ID\tName\tSpend")
    for (customerData in valuableCustomers) {
        writer.appendLine(lineFor(customerData))
    }
    writer.append(valuableCustomers.summarised())
}

示例 10.25 [extensions.20:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (diff)

感觉我们应该能够通过提取函数来移除 // header 注释。“从流中提取部分”详细说明了如何从链中提取函数,但是当我们尝试该技术时,未将 withoutHeader 转换为扩展函数时会发生什么:

@Throws(IOException::class)
fun generate(reader: Reader, writer: Writer) {
    val valuableCustomers = withoutHeader(reader.readLines())
        .map(String::toCustomerData)
        .filter { it.score >= 10 }
        .sortedBy(CustomerData::score)
    writer.appendLine("ID\tName\tSpend")
    for (customerData in valuableCustomers) {
        writer.appendLine(lineFor(customerData))
    }
    writer.append(valuableCustomers.summarised())
}

private fun withoutHeader(list: List<String>) = list.drop(1)

示例 10.26 [extensions.21:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (diff)

我们丢失了从左到右,从上到下的流水线流畅性:withoutHeader 在文本中的顺序在执行顺序中位于 readLines 之前但在其后。在 withoutHeader 中的 list 参数上使用“Convert Parameter to Receiver”可恢复流畅性:

@Throws(IOException::class)
fun generate(reader: Reader, writer: Writer) {
    val valuableCustomers = reader.readLines()
        .withoutHeader()
        .map(String::toCustomerData)
        .filter { it.score >= 10 }
        .sortedBy(CustomerData::score)
    writer.appendLine("ID\tName\tSpend")
    for (customerData in valuableCustomers) {
        writer.appendLine(lineFor(customerData))
    }
    writer.append(valuableCustomers.summarised())
}

private fun List<String>.withoutHeader() = drop(1)

示例 10.27 [extensions.22:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (diff)

我们可以通过两个额外的扩展函数使这更加表达,List<String>.to⁠Val⁠uableCustomers()CustomerData.outputLine

@Throws(IOException::class)
fun generate(reader: Reader, writer: Writer) {
    val valuableCustomers = reader
        .readLines()
        .toValuableCustomers()
        .sortedBy(CustomerData::score)
    writer.appendLine("ID\tName\tSpend")
    for (customerData in valuableCustomers) {
        writer.appendLine(customerData.outputLine)
    }
    writer.append(valuableCustomers.summarised())
}

private fun List<String>.toValuableCustomers() = withoutHeader()
    .map(String::toCustomerData)
    .filter { it.score >= 10 }
...

private val CustomerData.outputLine: String
    get() = "$id\t$marketingName\t${spend.toMoneyString()}"

示例 10.28 [extensions.23:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (diff)

这仍然不如我们所希望的那样甜美,但我们已经证明了扩展函数的观点。第二十章和第二十一章将完成此重构。与此同时,这是整个文件:

@Throws(IOException::class)
fun generate(reader: Reader, writer: Writer) {
    val valuableCustomers = reader
        .readLines()
        .toValuableCustomers()
        .sortedBy(CustomerData::score)
    writer.appendLine("ID\tName\tSpend")
    for (customerData in valuableCustomers) {
        writer.appendLine(customerData.outputLine)
    }
    writer.append(valuableCustomers.summarised())
}

private fun List<String>.toValuableCustomers() = withoutHeader()
    .map(String::toCustomerData)
    .filter { it.score >= 10 }

private fun List<String>.withoutHeader() = drop(1)

private fun List<CustomerData>.summarised(): String =
    sumByDouble { it.spend }.let { total ->
        "\tTOTAL\t${total.toMoneyString()}"
    }

internal fun String.toCustomerData(): CustomerData =
    split("\t").let { parts ->
        CustomerData(
            id = parts[0],
            givenName = parts[1],
            familyName = parts[2],
            score = parts[3].toInt(),
            spend = if (parts.size == 4) 0.0 else parts[4].toDouble()
        )
    }

private val CustomerData.outputLine: String
    get() = "$id\t$marketingName\t${spend.toMoneyString()}"

private fun Double.toMoneyString() = this.formattedAs("%#.2f")

private fun Any?.formattedAs(format: String) = String.format(format, this)

private val CustomerData.marketingName: String
    get() = "${familyName.toUpperCase()}, $givenName"

示例 10.29 [extensions.23:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (差异)

请注意,除入口点外的每个函数都是单表达式扩展函数。我们没有将 generate 定义为扩展函数,因为没有自然的参数可以作为接收器;它在 ReaderWriter 上不像一个自然的操作。当我们在第二十章中继续重构此代码时,这可能会改变。我们拭目以待,对吗?

继续前进

扩展函数和属性是 Kotlin 语言中不为人知的英雄。它们的典型用途是为我们无法自行修改的类型添加操作。

然而,Kotlin 语言的特性和工具结合起来,坚定地鼓励我们为我们自己的类型编写扩展函数。Kotlin 的安全调用运算符使通过潜在的空引用调用扩展函数比将引用传递给非空函数作为参数更加方便。独立的通用扩展的类型可以表达接收器与其结果之间的关系,这是无法通过开放方法表达的。在 IntelliJ 中,自动补全会将扩展函数与可以调用值的方法一起显示,但不会显示您可以将值传递给的函数作为参数。

结果,扩展函数使我们能够编写更容易发现、理解和维护的代码。这本书中提出的许多其他技术都是基于扩展函数的,我们将在第十五章,封装集合到类型别名,第十八章,从开放到密封类等章节中看到。

第十一章:方法到属性

Java 不区分属性访问方法和其他类型。另一方面,Kotlin 对待属性与成员函数不同。我们何时应该更喜欢计算属性而不是返回结果的函数?

Fields, Accessors, and Properties

大多数编程语言都允许我们以某种方式将数据组合在一起,为复合类型的属性命名(通常还包括类型)。

这里,例如,在 ALGOL W 中是一个record,由三个fields组成的,是最早支持记录类型的通用语言之一。(ALGOL W 也是 Tony Hoare 引入空引用的语言。)

RECORD PERSON (
    STRING(20) NAME;
    INTEGER AGE;
    LOGICAL MALE;
);

那时情况有所不同:真正的程序员只有大写字母,并且性别是一个布尔值。

在 ALGOL W 中,我们可以(好吧,可能会)更新PERSON记录中保存的年龄:

AGE(WILMA) := AGE(WILMA) + 1;

在这种情况下,编译器将发出指令,以便访问记录的内存,找到表示威尔玛年龄的字节,并对其进行递增。记录,在其他语言中也称为structs(结构),是一个方便的方法,用于组合相关数据。这里没有信息隐藏,只是组合。

大多数早期的面向对象系统(尤其是 C++)都是基于这种记录机制的。实例变量只是记录字段,而方法(也称为成员函数)是持有指向函数的指针的字段。Smalltalk 则不同。Smalltalk 对象可以有实例变量,但是访问这种状态是通过向对象发送消息询问其值来实现的。消息,而不是字段,是基本的抽象。

Java 实现者们采取了各自方法的一点。对象可以有公共字段,但客户端不能只需调用字节码指令即可访问它们的值;他们必须调用字节码指令才能访问它们的值。这使我们可以将类视为记录,同时允许运行时强制执行私有字段访问。

尽管允许直接访问字段,但从一开始就被不鼓励。如果客户端直接访问字段,我们无法更改数据的内部表示形式,至少不能在不改变这些客户端的情况下。如果客户端可以直接变异字段,我们也无法保持字段之间的任何不变关系,正如我们在第五章中所见,在那些日子里对象都是关于变异的。字段访问也不是多态的,因此子类无法更改其实现。在那些日子里,对象也都是关于子类化的。

所以,与其在 Java 中直接访问字段,我们通常编写访问器方法:getter 和(如果需要)setter。Getter 通常只是返回字段的值,但它们也可以计算其他字段的值。Setter 可能会保持不变性或触发事件,以及更新一个字段或者,也许,多个字段。

但有时候,数据只是数据。当数据是这样时,直接访问公共字段可能是可以接受的,特别是当我们有不可变值(也就是说,不可变类型的最终字段)时。对于更复杂的模型,多态行为和/或统一的访问值的方式变得有用,这时访问器方法就变得尤为重要。

Kotlin 设计者选择为我们做出决定,并且只支持访问器方法。语言不支持直接访问字段。Kotlin 将生成代码来访问 Java 类的公共字段,但不会自己定义公共字段。(特殊的注解 @JvmField 提供了一个后门,如果你确实需要的话。)他们这样做是为了鼓励我们使用访问器,这样我们可以在不影响客户端的情况下更改表示。

为了进一步鼓励使用访问器,Kotlin 允许我们在单个 属性 声明中生成一个私有成员变量和一个访问器。

因此,在 Java 中,我们可以直接访问字段:

public class PersonWithPublicFields {
    public final String givenName;
    public final String familyName;
    public final LocalDate dateOfBirth;

    public PersonWithPublicFields(
        String givenName,
        String familyName,
        LocalDate dateOfBirth
    ) {
        this.givenName = givenName;
        this.familyName = familyName;
        this.dateOfBirth = dateOfBirth;
    }
}

或者,我们可以编写自己的访问器:

public class PersonWithAccessors {
    private final String givenName;
    private final String familyName;
    private final LocalDate dateOfBirth;

    public PersonWithAccessors(
        ...
    }

    public String getGivenName() {
        return givenName;
    }

    public String getFamilyName() {
        return familyName;
    }

    ...
}

在 Kotlin 中,我们只有属性:

data class PersonWithProperties(
    val givenName: String,
    val familyName: String,
    val dateOfBirth: LocalDate
) {
}

这个声明将生成私有字段:givenNamefamilyNamedateOfBirth,访问器方法 getGivenName() 等等,以及一个构造函数来初始化所有字段。

在 Java 中,我们可以直接访问(可见的)字段或调用访问器:

public static String accessField(PersonWithPublicFields person) {
    return person.givenName;
}

public static String callAccessor(PersonWithAccessors person) {
    return person.getGivenName();
}

public static String callKotlinAccessor(PersonWithProperties person) {
    return person.getGivenName();
}

在 Kotlin 中,我们可以直接访问可见字段(来自 Java 类)或者像访问字段一样调用访问器:

fun accessField(person: PersonWithPublicFields): String =
    person.givenName

fun callAccessor(person: PersonWithAccessors): String =
    person.givenName

fun callKotlinAccessor(person: PersonWithProperties): String =
    person.givenName

属性是一种便利,由一些编译器魔法支持。它们使得在 Kotlin 中使用字段和访问器与在 Java 中使用普通字段一样简单,因此我们自然会编写可以利用封装的代码。例如,我们可能会发现我们想要在接口中定义一个属性或计算以前存储的属性。

计算属性 是那些不由字段支持的属性。如果我们有由字段支持的 givenNamefamilyName,那么就没有必要存储 fullName;我们可以在需要时计算它:

public class PersonWithPublicFields {
    public final String givenName;
    public final String familyName;
    public final LocalDate dateOfBirth;

    public PersonWithPublicFields(
        ...
    }

    public String getFullName() {
        return givenName + " " + familyName;
    }
}

如果我们在 Java 中使用直接字段访问,现在访问存储和计算属性之间有所不同:

public static String fieldAndAccessor(PersonWithPublicFields person) {
    return
        person.givenName + " " +
        person.getFullName();
}

这在 Kotlin 中并非如此,即使访问 Java 字段和方法时也是如此,这点很好:

fun fieldAndAccessor(person: PersonWithPublicFields) =
    person.givenName + " " +
    person.fullName

在 Kotlin 中,我们在构造函数之外定义计算属性:

data class PersonWithProperties(
    val givenName: String,
    val familyName: String,
    val dateOfBirth: LocalDate
) {
    val fullName get() = "$givenName $familyName"
}

所以,在 Java 中,我们可以定义直接访问字段的类,但通常应该使用访问器,这些访问器只是以getset前缀(按照约定,但不总是这样)命名的方法。在 Kotlin 中,我们不能单独定义字段和访问器。当我们在 Kotlin 中定义属性时,编译器会生成一个字段和遵循 Java 命名约定的访问器。当我们在 Kotlin 中引用属性时,语法与 Java 中的字段访问语法相同,但如果存在并且遵循 Java 命名约定,编译器将生成对访问器的调用。这甚至适用于互操作边界:当我们引用 Java 对象的属性时,如果存在并且遵循 Java 命名约定,编译器将生成对访问器的调用。

如何选择

回到本章开头的问题:考虑到计算属性只是带有糖封装的方法,我们何时应选择计算属性,何时应选择方法?

一个很好的经验法则是,当属性仅依赖于类型上的其他属性并且计算成本低廉时,使用属性是一个好主意。这适用于fullName,因此这是一个好的计算属性。那么一个人的年龄呢?

我们可以从dateOfBirth属性轻松计算年龄(忽略时区),因此在 Java 中我们可能会写成fred.getAge()。但这不仅取决于其他属性,还取决于调用的时间。尽管可能性不大,fred.age == fred.age可能会返回false

年龄是一个动作(“动作”);其结果取决于调用的时间。属性应该是计算(“计算”),是无时间限制且仅依赖于它们的输入,在这种情况下是dateOfBirth属性。因此,age()应该是一个函数,而不是属性:

data class PersonWithProperties(
    val givenName: String,
    val familyName: String,
    val dateOfBirth: LocalDate
) {
    fun age() = Period.between(dateOfBirth, LocalDate.now()).years
}

那么对象的所有其他属性的加密哈希呢?对于不可变对象来说,这是一个计算,但如果计算成本高昂,应该是一个方法hash(),而不是属性hash。我们甚至可能想在其名称中暗示方法的成本:

data class PersonWithProperties(
    val givenName: String,
    val familyName: String,
    val dateOfBirth: LocalDate
) {
    fun computeHash(): ByteArray =
        someSlowHashOf(givenName, familyName, dateOfBirth.toString())
}

我们可以通过预先计算并将其存储在字段中来创建一个属性:

data class PersonWithProperties(
    val givenName: String,
    val familyName: String,
    val dateOfBirth: LocalDate
) {
    val hash: ByteArray =
        someSlowHashOf(givenName, familyName, dateOfBirth.toString())
}

这样做的缺点是使每个实例创建变慢,无论其hash是否被访问。我们可以通过惰性属性来分担这一差异:

data class PersonWithProperties(
    val givenName: String,
    val familyName: String,
    val dateOfBirth: LocalDate
) {
    val hash: ByteArray by lazy {
        someSlowHashOf(givenName, familyName, dateOfBirth.toString())
    }
}

在有限的范围内这样做可能还可以,但如果该类被广泛使用,我们至少应该通过将计算属性隐藏在函数背后来暗示潜在的首次调用性能问题:

data class PersonWithProperties(
    val givenName: String,
    val familyName: String,
    val dateOfBirth: LocalDate
) {
    private val hash: ByteArray by lazy {
        someSlowHashOf(givenName, familyName, dateOfBirth.toString())
    }
    fun hash() = hash
}

在这种情况下,我们可以考虑使用扩展属性。正如我们在第十章中看到的,扩展属性只能计算,而不能由字段支持,因此不能延迟加载。除此之外,这里的大部分讨论也适用于扩展函数与扩展属性的比较。

可变属性

那么可变属性呢?Kotlin 允许我们将属性定义为var,表示可变。

如果你已经阅读到这里,你会知道我们的作者喜欢保持其数据(第五章)和集合(第六章)的不可变性。我们可以想象使用 Kotlin 定义一个可变属性,以便与某些需要它的 Java 代码集成,但实际上几乎从不在公共属性中使用可变性。我们可能偶尔会定义一个随时间变化的属性(例如提供对计数的访问),但几乎从不允许客户端设置它们。事实上,我们发现带有复制方法的数据类在几乎所有可能需要使用 setter 的情况下效果更好;实际上,我们甚至可以说允许数据类中使用var属性是一种语言设计上的错误。

重构为属性

IntelliJ 提供了出色的重构支持,可以在 Kotlin 方法和属性之间进行转换。这在某种程度上很简单,因为两者只是方法调用,但在与 Java 互操作时又有些复杂,因为它依赖命名约定来识别访问器。让我们从 Travelator 中看一个例子。

我们有一些喜欢露营的坚韧客户,因此我们在应用程序中列出了露营地:

public class CampSite {
    private final String id;
    private final String name;
    private final Address address;
    ...

    public CampSite(
        String id,
        String name,
        Address address
        ...
    ) {
        this.id = id;
        this.name = name;
        this.address = address;
        ...
    }

    public String getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public String getCountryCode() {
        return address.getCountryCode();
    }

    public String region() {
        return address.getRegion();
    }

    ...
}

示例 11.1 [methods-to-properties.0:src/main/java/travelator/CampSite.java] (差异)

这是多年来增长的域类的典型案例。它有很多属性,有些由字段支持,如idname,有些计算的(计算量较小),如countryCoderegion。有人没有遵循 bean 约定,通过将访问器命名为region而不是getRegion,但我们清楚地知道他们的意图。

这是一些使用访问器的代码:

public class CampSites {

    public static Set<CampSite> sitesInRegion(
        Set<CampSite> sites,
        String countryISO,
        String region
    ) {
        return sites.stream()
            .filter( campSite ->
                campSite.getCountryCode().equals(countryISO) &&
                    campSite.region().equalsIgnoreCase(region)
            )
            .collect(toUnmodifiableSet());
    }
}

示例 11.2 [methods-to-properties.0:src/main/java/travelator/CampSites.java] (差异)

让我们使用 IntelliJ 将Campsite转换为 Kotlin(然后将其作为数据类):

data class CampSite(
    val id: String,
    val name: String,
    val address: Address,
    ...
) {
    val countryCode: String
        get() = address.countryCode

    fun region(): String {
        return address.region
    }

    ...
}

示例 11.3 [methods-to-properties.1:src/main/java/travelator/CampSite.kt] (差异)

我们的字段支持的属性已成为构造函数属性,而计算的countryCode成为计算属性。然而,IntelliJ 没有意识到region是一个属性,因为它没有遵守 getter 命名约定,只是简单地转换了方法。最终结果是客户端代码无需更改。如果我们想要纠正这个疏忽,我们可以在region上按 Alt-Enter,并选择“将函数转换为属性”,得到:

val region: String
    get() {
        return address.region
    }

示例 11.4 [methods-to-properties.2:src/main/java/travelator/CampSite.kt] (差异)

与大多数计算属性一样,这更适合作为单个表达式(参见第九章):

val region: String get() = address.region

示例 11.5 [methods-to-properties.3:src/main/java/travelator/CampSite.kt] (差异)

将 Kotlin 的region方法更改为属性意味着访问器方法现在将被命名为getRegion;值得庆幸的是,IntelliJ 足够智能,可以为我们修复我们的客户端:

public static Set<CampSite> sitesInRegion(
    Set<CampSite> sites,
    String countryISO,
    String region
) {
    return sites.stream()
        .filter( campSite ->
            campSite.getCountryCode().equals(countryISO) &&
                campSite.getRegion().equalsIgnoreCase(region) ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
        )
        .collect(toUnmodifiableSet());
}

示例 11.6 [methods-to-properties.3:src/main/java/travelator/CampSites.java] (差异)

1

campsite.region()已被campsite.getRegion()替换。

如果我们现在将sitesInRegion转换为 Kotlin,我们得到以下结果:

object CampSites {
    fun sitesInRegion(
        sites: Set<CampSite>,
        countryISO: String,
        region: String?
    ): Set<CampSite> {
        return sites.stream()
            .filter { campSite: CampSite ->
                campSite.countryCode == countryISO &&
                    campSite.region.equals(region, ignoreCase = true) ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
            }
            .collect(Collectors.toUnmodifiableSet())
    }
}

示例 11.7 [methods-to-properties.4:src/main/java/travelator/CampSites.kt] (差异)

1

campsite.getRegion()现在被campsite.region调用。

我们看到如何将sitesInRegion移动到第八章的顶层,以及移动到第十章的扩展函数中:

fun Set<CampSite>.sitesInRegion(
    countryISO: String,
    region: String
): Set<CampSite> {
    return stream()
        .filter { campSite: CampSite ->
            campSite.countryCode == countryISO &&
                campSite.region.equals(region, ignoreCase = true)
        }
        .collect(Collectors.toUnmodifiableSet())
}

示例 11.8 [methods-to-properties.5:src/main/java/travelator/CampSites.kt] (差异)

流到迭代器到序列(第十三章)和多到单表达式函数(第九章)展示了如何完成任务:

fun Iterable<CampSite>.sitesInRegion(
    countryISO: String,
    region: String
): Set<CampSite> =
    filter { site ->
        site.countryCode == countryISO &&
            site.region.equals(region, ignoreCase = true)
    }.toSet()

示例 11.9 [methods-to-properties.6:src/main/java/travelator/CampSites.kt] (差异)

由于围绕方法、访问器和属性的出色工具和互操作性,这是一次幸运的简短重构。因此,在我们添加最后一个微调时,我们期待着您的谅解。

sitesInRegion是一个有点奇怪的方法。它弥补了我们建模的不足之处,即区域只是字符串而不是实体。如果我们只根据区域名称“Hampshire”进行过滤,没有国家代码,我们有可能返回一个站点集合,其中大多数位于英格兰的县,但有一个(月光露营——听起来很可爱)在加拿大的一个岛上。在我们能够修复之前,如果我们将过滤断言提取到自己的方法中会怎样?

fun Iterable<CampSite>.sitesInRegion(
    countryISO: String,
    region: String
): Set<CampSite> =
    filter { site ->
        site.isIn(countryISO, region)
    }.toSet()

fun CampSite.isIn(countryISO: String, region: String) =
    countryCode == countryISO &&
        this.region.equals(region, ignoreCase = true)

示例 11.10 [methods-to-properties.7:src/main/java/travelator/CampSites.kt] (差异)

现在我们有了Campsite.isIn(...),也许sitesInRegion可以内联到调用它的地方,因为现在代码真的很容易理解。我们更喜欢找出并公开客户端可以构建的基本操作,而不是将它们隐藏在函数内部。顺著这条线索,我们可以通过使region可选来扩展isIn的功能:

fun CampSite.isIn(countryISO: String, region: String? = null) =
    when (region) {
        null -> countryCode == countryISO
        else -> countryCode == countryISO &&
            region.equals(this.region, ignoreCase = true)
    }

示例 11.11 [方法到属性.8:src/main/java/travelator/CampSites.kt] (差异)

纳特更喜欢相同的,但带有 Elvis 运算符:

fun CampSite.isIn(countryISO: String, region: String? = null) =
    countryCode == countryISO &&
        region?.equals(this.region, ignoreCase = true) ?: true

示例 11.12 [方法到属性.9:src/main/java/travelator/CampSites.kt] (差异)

邓肯喜欢好的 Elvis,但认为他的方法代码更清晰。你的团队可能会有这些小冲突(选择邓肯的方式)。

isIn这样的基本操作现在可能被提升为Campsite的一个方法(而不是一个扩展函数),或者更好地,是Address的一个方法。这样,区域不是实体的问题就限制在最接近问题的类型上,修复起来对代码库的其余部分影响最小。

继续前进

Kotlin 提供了一种方便的语法,用于支持基于字段和计算的属性,使我们能够表达访问属性和调用函数之间的差异,即使它们在幕后是相同的消息传递机制。

当它适用于值类型,仅依赖于值并且计算成本不高时,我们应该优先选择属性而不是方法。在这些情况下,从方法重构为属性是简单的,能使我们的代码更易于理解。

第十二章:函数到操作符

如果我们有一个庞大的 Java 代码库,我们的 Java 和 Kotlin 将需要一段时间共存。在我们逐步将系统转换为 Kotlin 的同时,我们可以做些什么来支持两种语言的惯例?

到目前为止,我们已经展示了从 Java 到 Kotlin 的代码转换是一气呵成的。我们使用自动重构来安全地进行转换,但最终,所有受影响的代码都已转换为习惯用语化的 Kotlin。

在大型代码库中,这种做法并非总是可行的。在引入 Kotlin 的同时,我们必须继续在 Java 中发展功能。在两者之间存在边界时,我们希望在一侧使用传统的 Java,在另一侧使用传统的 Kotlin。特别是在转换支持系统大部分功能的基础类时,这一点尤为重要。

一个基础类:Money

每个系统都包含一些基础类,代码库的许多部分都在使用。在 Travelator 中的一个例子是Money类,我们在第三章中首次见到它。旅行者需要为他们的旅行预算。他们希望比较不同旅行选项的成本,将这些成本转换为他们首选的货币,预订事物,支付费用等等。Money类被广泛使用,我们无法一口气将它及其依赖的所有代码转换为习惯用语化的 Kotlin。在转换进行中,我们必须继续处理使用Money的功能。

这让我们左右为难。我们应该在转换依赖它的代码为 Kotlin 的同时将Money保留为 Java 类,这样可以限制依赖代码中可以使用的 Kotlin 特性吗?还是应该在我们仍然有 Java 代码使用它的同时将Money类转换为 Kotlin,这样可以在依赖代码中使用 Kotlin 特性,但会使剩余的 Java 代码不一致和不规范?

我们有这些选择的事实证明了 Kotlin/Java 互操作性在双向上的良好表现。实际上,我们无需做出选择。通过一些巧妙的重构策略和一些注解来控制 Kotlin 编译器为 JVM 生成代码,我们可以兼得两者之利。我们可以在 Kotlin 中定义Money,利用 Kotlin 的特性,同时为我们维护的 Java 代码提供习惯用语化的 API。

我们在第三章将Money类转换为了 Kotlin。自那一章结束以来,我们(很抱歉,没有你的情况下)能够使代码更加简洁,而不影响依赖它的 Java 代码。我们将大多数方法重构为单表达式形式(第九章),并利用 Kotlin 的流感知类型推断大幅简化了equals方法。

现在是时候看看Money了。它并没有显著不同,但语法上的噪音少了很多:

class Money private constructor(
    val amount: BigDecimal,
    val currency: Currency
) {
    override fun equals(other: Any?) =
        this === other ||
            other is Money &&
            amount == other.amount &&
            currency == other.currency

    override fun hashCode() =
        Objects.hash(amount, currency)

    override fun toString() =
        amount.toString() + " " + currency.currencyCode

    fun add(that: Money): Money {
        require(currency == that.currency) {
            "cannot add Money values of different currencies"
        }
        return Money(amount.add(that.amount), currency)
    }

    companion object {
        @JvmStatic
        fun of(amount: BigDecimal, currency: Currency) = Money(
            amount.setScale(currency.defaultFractionDigits),
            currency
        )

        ... and convenience overloads
    }
}

示例 12.1 [operators.0:src/main/java/travelator/money/Money.kt] (差异)

然而,它仍保留了 Java 的风格,正如使用它的 Kotlin 代码所示。Money类遵循了现代 Java 中常见的值类型的约定,但这并不是 Kotlin 中通常的做法。特别是,它使用伴生对象的方法创建值,并且对于算术运算,它使用方法而不是运算符。

在单语言代码库中,解决这些问题将会相当直接。然而,我们仍然有大量使用Money类的 Java 代码。我们将继续在 Java 和 Kotlin 中进行更改,直到 Kotlin 完全取代 Java。与此同时,我们希望确保使用Money的代码在任一语言中都足够传统,以免惊扰读者。

添加用户定义的运算符

使用Money值进行计算的 Kotlin 代码仍然相当笨拙:

val grossPrice = netPrice.add(netPrice.mul(taxRate))

它与等价的 Java 代码没有显著不同:

final var grossPrice = netPrice.add(netPrice.mul(taxRate));

使用算术操作方法会使计算变得难以阅读。在 Java 中,这是我们能做的最好的事情,但在 Kotlin 中,我们可以为自己的类定义算术运算符,使我们可以将该计算写为:

val grossPrice = netPrice + netPrice * taxRate

以加法为例,让我们看看如何给我们的Money类添加算术运算符。

通过编写一个名为plus的运算符方法或扩展函数,我们为类赋予了+运算符。对于我们的Money类,我们可以将现有的add方法重命名为plus并添加operator修饰符:

class Money private constructor(
    val amount: BigDecimal,
    val currency: Currency
) {
    ...

    operator fun plus(that: Money): Money {
        require(currency == that.currency) {
            "cannot add Money values of different currencies"
        }
        return Money(amount.add(that.amount), currency)
    }

    ...
}

示例 12.2 [operators.2:src/main/java/travelator/money/Money.kt] (差异)

通过这个改变,我们的 Kotlin 代码可以使用+运算符添加Money值,而 Java 代码则调用plus作为方法。

然而,当我们准备检入时,我们发现我们的重命名已经波及了数百个 Java 代码文件,引入了一个不遵循 Java 约定的名称。标准库中具有算术操作的 Java 类,如BigDecimalBigInteger,都使用名称add,而不是plus

我们可以通过在其定义中使用@JvmName注解使函数在 Java 和 Kotlin 中呈现不同的名称。让我们撤销刚刚做出的更改,再试一次,首先用@JvmName("add")注解方法:

@JvmName("add")
fun add(that: Money): Money {
    require(currency == that.currency) {
        "cannot add Money values of different currencies"
    }
    return Money(amount.add(that.amount), currency)
}

示例 12.3 [operators.3:src/main/java/travelator/money/Money.kt] (差异)

现在当我们将方法重命名为plus时,我们的 Java 代码保持不变,并且将其标记为运算符允许 Java 和 Kotlin 代码根据各自的语言约定调用该方法:

@JvmName("add")
operator fun plus(that: Money): Money {
    require(currency == that.currency) {
        "cannot add Money values of different currencies"
    }
    return Money(amount.add(that.amount), currency)
}

示例 12.4 [operators.4:src/main/java/travelator/money/Money.kt] (差异)

这是可取的吗?在同一代码库的不同部分中以不同的名称出现相同的方法可能会相当令人困惑。另一方面,因为它是一个运算符方法,所以plus名称只应出现在方法的定义中,而 Kotlin 中所有对该方法的使用都应通过+运算符进行。短语operator fun plus更像是一个语言关键字而不是方法名。IntelliJ 在 Java 中调用add和 Kotlin 中定义的operator plus之间可以无缝导航。总的来说,您的作者认为在这种情况下使用@JvmName注解是值得的,但通常您需要就如何使用@JvmName注解调整 Kotlin 类以满足 Java 客户端的要求达成一致。

从现有的 Kotlin 代码调用我们的运算符

查看我们的 Kotlin 客户端代码,我们发现我们仍然有问题。在撰写本文时,IntelliJ 没有自动重构来替换对运算符方法的所有直接调用,而是使用相应运算符。在我们将其转换为运算符之前,我们的任何 Kotlin 代码都会调用Money.add方法的任何部分将保持调用Money.plus作为方法而不是使用+运算符。IntelliJ 可以自动将这些调用点从方法调用重构为运算符,但我们必须一个接一个地处理它们,逐个调用重构。

要解决这个问题,我们可以使用一系列重构步骤一次性将 所有 我们的 Kotlin 代码切换到同时使用+运算符,并在代码中保留重新播放步骤的能力,以便将更多的 Java 类转换为 Kotlin。所以让我们重新还原我们的改动,并再次尝试转换。

这次,我们将整个add方法的主体提取为名为plus的方法,并将其作为公共的运算符方法:

fun add(that: Money): Money {
    return plus(that)
}

operator fun plus(that: Money): Money {
    require(currency == that.currency) {
        "cannot add Money values of different currencies"
    }
    return Money(amount.add(that.amount), currency)
}

示例 12.5 [operators.6:src/main/java/travelator/money/Money.kt] (差异)

使用 IntelliJ 的自动重构,我们在调用plus时明确了this

fun add(that: Money): Money {
    return this.plus(that)
}

示例 12.6 [operators.7:src/main/java/travelator/money/Money.kt] (差异)

从此形式,IntelliJ 让我们自动重构从方法调用到运算符:

fun add(that: Money): Money {
    return this + that
}

示例 12.7 [operators.8:src/main/java/travelator/money/Money.kt] (差异)

最后,我们可以将add方法转换为单表达式形式:

fun add(that: Money) = this + that

operator fun plus(that: Money): Money {
    require(currency == that.currency) {
        "cannot add Money values of different currencies"
    }
    return Money(amount.add(that.amount), currency)
}

示例 12.8 [operators.9:src/main/java/travelator/money/Money.kt] (diff)

现在我们有两种加法方法。plus运算符实现了加法逻辑,并且这是我们希望所有将来的 Kotlin 代码使用的,但目前没有直接调用它的代码。add方法将继续供我们的 Java 代码使用,只要它存在,并且它的主体包含我们希望在 Kotlin 代码中使用的理想语法。

我们可以将所有添加Money值的 Kotlin 代码转换为使用操作符语法,通过内联Money.add方法来实现。当我们这样做时,IntelliJ 报告说无法内联所有add的使用。这正是我们想要的!我们不能将 Kotlin 代码内联到 Java 中,因此 IntelliJ 仅将add方法的主体内联到 Kotlin 调用位置,并保留其在Money类中的定义,因为它仍然被 Java 调用。现在我们所有的 Kotlin 代码都使用+运算符,而我们的 Java 代码则保持不变。

在将更多将Money值添加到 Kotlin 的 Java 类翻译成 Kotlin 的未来中,我们可以再次内联add方法,以便转换后的 Kotlin 类使用+运算符而不是方法调用语法。只要我们的代码库中还有调用它的 Java 代码,IntelliJ 就会保留add方法。在我们转换了最后一个将Money添加的 Java 类之后,IntelliJ 将删除这个现在不再使用的add方法作为内联重构的一部分。我们的代码库随后将只使用+运算符。

现有 Java 类的操作符

在我们处理plus方法时,我们还可以利用机会在方法内部使用+运算符。Money类将其amount属性表示为BigDecimal,这是 Java 标准库中的一个类。我们可以将对Big​Deci⁠mal.add方法的调用替换为+运算符:

operator fun plus(that: Money): Money {
    require(currency == that.currency) {
        "cannot add Money values of different currencies"
    }
    return Money(this.amount + that.amount, currency)
}

示例 12.9 [operators.11:src/main/java/travelator/money/Money.kt] (diff)

我们的代码继续编译。这是如何可能的?

Kotlin 标准库包括为 Java 标准库中的类定义操作符的扩展函数:数学类,如BigIntegerBigDecimal,以及集合类,如List<T>Set<T>。由于这些扩展函数在kotlin包中定义,它们对任何包都是自动可用的:我们不需要导入它们。

表示值的惯例

伴生对象上的静态of函数用于表示Money值,也打破了 Kotlin 的惯例。

Java 语法区分了使用 new 操作符实例化一个类和获取方法调用结果的对象。现代 Java 的一个约定是,对于具有显著标识的有状态对象,使用 new 操作符进行构造,而对于值则使用静态工厂函数进行表示。例如,表达式 new ArrayList<>() 构造一个新的可变列表,与任何其他可变列表不同,而表达式 List.of("a","b","c") 表示一个不可变列表值。

Kotlin 不区分对象的构造和函数的调用:实例化类的语法与调用函数的语法相同。也没有编码约定来区分构造具有不同标识的新有状态对象和表示不变量的值。

警告

尽管 Kotlin 代码中调用函数和实例化类的语法看起来相同,但它们由不同的 JVM 字节码实现。在调用构造函数和函数之间进行源代码兼容的更改将不会是二进制兼容的。

对于需要多个工厂函数的类,如我们的Money类,通常将它们定义为顶级函数,而不是类的伴生对象中的方法。IntelliJ 在这种风格上有所帮助:它更擅长自动建议顶级函数而不是类的伴生对象中的方法。

因此,如果我们像Money(...)或者另外一种选择moneyOf(...)这样的表达式来创建Money的实例,会更加常规化,而不是Money.of(...)

正如我们在第三章中看到的,Money 具有私有构造函数(并不是数据类),以保留其货币与其amount精度之间的关系。因此,最简单的选择似乎是在与Money类相同的源文件中定义顶级moneyOf函数。然而,这些moneyOf函数如果需要调用Money类的构造函数,如果其仍然声明为private则无法调用,但如果我们将构造函数更改为internal则可以调用。

内部可见性将使构造函数对同一编译单元(Gradle 子项目或 IntelliJ 模块)中的任何 Kotlin 代码可见,但阻止其被其他编译单元中的 Kotlin 代码调用。编译单元而不是类,负责通过不适当调用其构造函数来保证 Money 类的不变量。如果不是我们系统转换为 Kotlin 过程中将继续维护的那些 Java 部分,这种做法已经足够安全了。

Java 和 JVM 没有内部可见性的概念。Kotlin 编译器将类的内部特性翻译为生成的 JVM 类文件中的公共特性,并将内部可见性记录为额外的元数据,这些元数据由 Kotlin 编译器处理但被 Java 编译器忽略。因此,Kotlin 中声明为内部的特性在 Java 编译器和 JVM 中表现为公共的,这使得我们在项目的 Java 代码中意外地创建无效的Money值。这使得顶级的moneyOf函数成为一个不太吸引人的选项。

相反,我们可以再次依赖于 Kotlin 的运算符重载。如果我们为Money类的伴生对象定义一个函数调用运算符,Kotlin 代码可以使用与直接调用构造函数相同的语法创建Money值:

val currently = Money.of(BigDecimal("9.99"), GBP))

val proposal = Money(BigDecimal("9.99"), GBP))

实际上这并不是一个构造函数调用;长写法是:

val proposal = Money.Companion.invoke(BigDecimal("9.99", GBP))

正如我们在将add方法重命名为plus时所发现的那样,如果仅仅将of重命名为invoke,这将对我们的 Java 代码产生连锁效应。创建Money值的 Java 代码将从读作Money.of(BigDecimal(100), EUR)变为Money.invoke(BigDecimal(100), EUR)of方法有两个责任:在构造Money值时强制执行类不变量,并在调用者中提供符合现代 Java 约定表示值的语法糖。从of重命名为invoke不影响前者,但破坏了后者。

当我们重构我们的 Kotlin 代码遵循 Kotlin 约定时,我们可以使用相同的提取方法和重构调用提取方法和内联方法的组合来避免对我们的 Java 代码产生任何负面影响。

首先,将of方法的整个主体提取为一个名为invoke的方法:

class Money private constructor(
    val amount: BigDecimal,
    val currency: Currency
) {
    ...

    companion object {
        @JvmStatic
        fun of(amount: BigDecimal, currency: Currency) =
            invoke(amount, currency)

        private fun invoke(amount: BigDecimal, currency: Currency) =
            Money(
                amount.setScale(currency.defaultFractionDigits),
                currency
            )

        ... and convenience overloads
    }
}

示例 12.10 [operators.12:src/main/java/travelator/money/Money.kt] (差异)

然后将invoke作为公共操作符方法:

@JvmStatic
fun of(amount: BigDecimal, currency: Currency) =
    invoke(amount, currency)

operator fun invoke(amount: BigDecimal, currency: Currency) =
    Money(
        amount.setScale(currency.defaultFractionDigits),
        currency
    )

示例 12.11 [operators.13:src/main/java/travelator/money/Money.kt] (差异)

现在我们可以将Money伴生对象称为一个看起来像构造函数的函数。那么在invoke方法体中调用Money(...)为什么不会溢出调用栈?在invoke方法内部,对Money(...)的调用并不是对invoke的递归调用,而是实际调用了私有的Money构造函数。在类外部,对Money(...)的调用会调用伴生对象的invoke方法,因为私有构造函数不可见。我们拥有了两全其美的局面:用于创建类实例的传统语法,以及确保类不变量的封装边界。

为了让现有的 Kotlin 代码使用新的语法,我们首先需要让伴生对象的of方法调用自身作为函数:

@JvmStatic
fun of(amount: BigDecimal, currency: Currency) =
    this(amount, currency)

示例 12.12 [operators.14:src/main/java/travelator/money/Money.kt] (diff)

然后,我们将of方法内联到我们的 Kotlin 代码中。再次强调,Java 代码不会受到影响,当没有 Java 代码调用of方法时,IDE 会自动为我们移除它。

在内联重构之前,创建Money值的 Kotlin 代码看起来像这样:

interface ExchangeRates {
    fun rate(fromCurrency: Currency, toCurrency: Currency): BigDecimal

    @JvmDefault
    fun convert(fromMoney: Money, toCurrency: Currency): CurrencyConversion {
        val rate = rate(fromMoney.currency, toCurrency)
        val toAmount = fromMoney.amount * rate
        val toMoney = Money.of(toAmount, toCurrency)
        return CurrencyConversion(fromMoney, toMoney)
    }
}

示例 12.13 [operators.16:src/main/java/travelator/money/ExchangeRates.kt] (diff)

在内联重构之后,看起来像这样:

interface ExchangeRates {
    fun rate(fromCurrency: Currency, toCurrency: Currency): BigDecimal

    @JvmDefault
    fun convert(fromMoney: Money, toCurrency: Currency): CurrencyConversion {
        val rate = rate(fromMoney.currency, toCurrency)
        val toAmount = fromMoney.amount * rate
        val toMoney = Money(toAmount, toCurrency)
        return CurrencyConversion(fromMoney, toMoney)
    }
}

示例 12.14 [operators.17:src/main/java/travelator/money/ExchangeRates.kt] (diff)

无论我们是从 Kotlin 还是 Java 中使用它,我们都得到了一个传统和方便的类。

继续前进

Java 和 Kotlin 有不同的规范,适用于这两种语言的不同粒度。

我们不希望我们对 Kotlin 的使用对我们的 Java 产生负面影响,也不希望将我们的 Kotlin 代码仅仅留在 Kotlin 语法中作为 Java。

使用注解和委托,我们可以确保 Kotlin 和 Java 代码在转换为 Kotlin 时遵循各自的语言约定。提取和内联重构组合使得将其添加到我们的代码库变得容易,并且在不再需要时也容易移除。

第十三章:流到可迭代对象到序列

Java 和 Kotlin 都允许我们转换和减少集合。不过它们具有不同的设计目标和实现。Kotlin 在何时使用而不是 Java 流,何时进行转换,以及如何进行呢?

Java 流

Java 8 在 2014 年引入了流,充分利用了新的 lambda。假设我们想计算一些字符串的平均长度,但空白字符串(只包含空白字符)应视为空字符串处理。以前我们可能会这样写:

public static double averageNonBlankLength(List<String> strings) {
    var sum = 0;
    for (var s : strings) {
        if (!s.isBlank())
            sum += s.length();
    }
    return sum / (double) strings.size();
}

使用 Java 流,我们可以将这个算法表达为 filtermapreduce,首先将 List 转换为 Stream 并应用转换:

public static double averageNonBlankLength(List<String> strings) {
    return strings
        .stream()
        .filter(s -> !s.isBlank())
        .mapToInt(String::length)
        .sum()
        / (double) strings.size();
}

与在我们的头脑中运行 for 循环来查看这段代码在做什么相比,我们可以逐行声明算法的步骤,并依赖运行时来为我们实现这些步骤。

如果我们真的急于获得这些结果,我们甚至可以这样写:

public static double averageNonBlankLength(List<String> strings) {
    return strings
        .parallelStream() ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
        .filter(s -> !s.isBlank())
        .mapToInt(String::length)
        .sum()
        / (double) strings.size();
}

1

parallelStream 将工作分配到多个线程中。

这里进行了不同类型的基本操作:map 改变了项目的类型,而不是它们的数量;filter 根据某些属性保留或拒绝项目,但保持它们的类型不变;而 sum 是将集合减少到单个属性。在这个例子中未显示的操作还有 skip(n)limit(n)。它们分别返回无头部和尾部 n 个元素的流。

Java 流是惰性的:strings.filter(...).mapToInt(...) 仅设置了一个管道用于某些终端操作,如本例中的 sum,来吸收值。惰性意味着后续的管道阶段可以限制前面阶段必须执行的工作量。考虑翻译一个单词列表直到我们看到单词 STOP。循环版本可能如下所示:

public static List<String> translatedWordsUntilSTOP(List<String> strings) {
    var result = new ArrayList<String>();
    for (var word: strings) {
        String translation = translate(word);
        if (translation.equalsIgnoreCase("STOP"))
            break;
        else
            result.add(translation);
    }
    return result;
}

通过退出循环,我们不需要翻译所有的单词,只翻译我们需要的最少数量。Java 9 引入了 dropWhiletakeWhile,使我们可以表达如下:

public static List<String> translatedWordsUntilSTOP(List<String> strings) {
    return strings
        .stream()
        .map(word -> translate(word))
        .takeWhile(translation -> !translation.equalsIgnoreCase("STOP"))
        .collect(toList());
}

这起作用是因为 collect 导致值通过管道被吸收,而 takeWhile 在其谓词返回 false 时停止从其前身吸收。

在提到吸收的主题上,对于小集合而言,流可能会令人惊讶地慢。它们非常适合大规模数据处理,其中我们希望投入所有可用的核心解决问题,但对于计算购物车中五件物品的成本来说效果不佳。问题在于 Java 流被设计为提供一般集合转换、惰性评估以及并行处理,而这些具有不同的需求。Kotlin 不尝试实现并行操作,留下了两种抽象;可迭代对象适合于转换和减少集合,而序列则提供惰性评估。

Kotlin 可迭代对象

Kotlin 不是通过创建一个新的接口来定义集合操作,而是提供了 Iterable 上的扩展函数。相同算法的最简单 Kotlin 表达式是:

fun averageNonBlankLength(strings: List<String>): Double =
    (strings
        .filter { it.isNotBlank() }
        .map(String::length)
        .sum()
        / strings.size.toDouble())

这里 filterIterable 上的一个扩展函数。不同于 Stream.filter 返回另一个 Stream,Kotlin 的 filter 返回一个 List(它是 Iterable,所以我们可以继续链式调用);map 也返回一个 List,因此这个单一表达式在内存中创建了两个额外的列表。

第一个是非空字符串的 List,第二个是这些字符串长度的 List。当(如果)我们关心性能时,这可能是一个问题,因为这两个列表都需要时间来填充和支持内存。

长度的 List 是一个特殊的问题,因为整数将会被 装箱(包装在 Integer 对象中)以适应列表。Java 流示例使用了 mapToInt(String::length) 来避免这个问题。IntStream(以及 LongStreamDoubleStream,但奇怪的是不包括 BooleanStreamCharStream)被创建出来以防止流必须进行装箱和拆箱,但是你必须记住使用它们,而且 IntStream 不是一个 Stream<Integer>

我们应该关心性能吗?大多数情况下不需要——这个 Kotlin 会很快,除非我们有大型集合,与之相反的是流,只有 如果 我们有大型集合才会很快。当我们有大型集合时,我们可以切换到序列。

Kotlin 序列

Kotlin 的 Sequence 抽象提供了与 Java 流相同的惰性评估。Sequence 上的 map 操作返回另一个 Sequence:链中的操作只有在某些终端操作需要对它们进行评估时才执行。如果我们有一个 CollectionIterable 或者甚至是一个 Iterator,都可以使用 asSequence() 扩展函数进行转换。之后,API 就会变得令人熟悉:

fun averageNonBlankLength(strings: List<String>): Double =
    (strings
        .asSequence()
        .filter { it.isNotBlank() }
        .map(String::length)
        .sum()
        / strings.size.toDouble())

熟悉性令人怀疑,因为所有这些操作(filtermapsum)现在都是 Sequence 而不是 Iterable 的扩展,并且它们不返回一个 List;它们返回另一个 Sequence。除了 sum,它甚至不能假装在没有读取所有数据的情况下完成它的工作,所以它是一个终端操作。这段代码读起来与可迭代版本相同,但是每个函数实际上是不同的。

averageNonBlankLength 的序列版本不会付出创建中间列表以保存每个阶段结果的代价,但是对于少量项目,设置和执行流水线的成本可能高于创建列表。在这种情况下,Int 长度仍然会被装箱为 Integer,尽管只是一个接一个地装箱,而不是创建一个完整的列表。在许多情况下,API 设计者会提供一个聪明的解决方案来消除装箱。在这种情况下,就是 sumBy

fun averageNonBlankLength(strings: List<String>): Double =
    (strings
        .asSequence()
        .filter { it.isNotBlank() }
        .sumBy(String::length)
        / strings.size.toDouble())

sumBy(也作为Iterable的扩展可用)通过接受返回Int的函数来避免装箱。它能做到这一点是因为它是一个终端操作,所以它不会返回另一个序列或集合。

多次迭代

如果您使用 Java 流,您可能已经尝试过像这样做一些事情:

public static double averageNonBlankLength(List<String> strings) {
    return averageNonBlankLength(strings.stream());
}

public static double averageNonBlankLength(Stream<String> strings) {
    return strings
        .filter(s -> !s.isBlank())
        .mapToInt(String::length)
        .sum()
        / (double) strings.count();
}

这看起来非常合理:我们刚刚提取了一个函数,它接受一个Stream参数而不是List。在Stream上没有size属性,但count()给出了相同的结果,所以我们使用它。然而,当我们运行它时,我们得到java.lang.IllegalState​Excep⁠tion: stream has already been operated upon or closed

问题在于Stream具有隐藏状态。一旦我们消耗了它的所有项目(sum就是这样做的),我们不能再去count它们了。即使sum实际上是IntStream上的一个方法,流管道中的每个流都消耗了其前驱,所以输入stringssum消耗了。

在 Java 中,这已经足以让你放弃将Stream操作提取到函数中。让我们尝试一下使用 Kotlin 的Sequence

fun averageNonBlankLength(strings: List<String>): Double =
    averageNonBlankLength(strings.asSequence())

fun averageNonBlankLength(strings: Sequence<String>): Double =
    (strings
        .filter { it.isNotBlank() }
        .sumBy(String::length)
        / strings.count().toDouble())

在 Kotlin 中,我们可以从List版本调用Sequence版本,一切都很顺利...目前为止。

然而,我们正在积累麻烦。为了理解为什么,让我们再多加一层,添加一个接受Iterator的函数:

fun averageNonBlankLength(strings: Iterator<String>): Double =
    averageNonBlankLength(strings.asSequence())

如果我们调用这个函数,我们现在会得到java.lang.IllegalStateException: This sequence can be consumed only once.(与流错误进行比较,我们可以看到 Kotlin 开发者似乎比 JVM 开发者更注重语法上的严谨性。)现在Sequence的行为就像 Java 的Stream一样,但之前不是。发生了什么变化?

原来有一些序列可以安全地多次迭代:那些由内存中保存的集合支持的序列,例如。其他则不能。现在我们的SequenceIterator提供,第一次运行(计算sum)持续到Iterator.hasNext()返回false。如果我们尝试再次运行Sequence(来计算count),Iterator的状态不会改变,因此hasNext()会立即返回false。这将导致strings.count()返回0,从而导致averageNonBlankLength始终返回Infinity(如果有任何输入)。

这种行为有点,嗯,不可取,因此包装迭代器的序列通过Sequence.constrainOnce()有意地进行了限制,以防止多次消费。如果我们试图两次消费,constrainOnce()将抛出IllegalStateException

Sequence的另一个经典例子是不能被多次消费的一个Sequence,它是由读取外部资源(比如文件或网络套接字)支持的。在这种情况下,我们通常不能简单地回溯并重放输入以进行再次迭代。

不幸的是,两种类型的Sequence之间的差异并未在类型系统中反映出来,因此我们只能在运行时发现算法与输入之间的任何不兼容性。正如我们将在第二十章中看到的那样,这种情况会被常见的使用sequenceOf(...)List.asSequence()作为我们的测试数据的技术加剧;这些序列确实支持多次迭代,并且不会警告我们这个问题。

在实践中,这个问题通常只是一种烦恼,导致了一些浪费的时间和重新工作。如果你正在从流代码转换,那么这个问题通常不会出现,因为最初的流代码本身就没有这个问题,而是在从头开始应用Sequence或从Iterable转换时才会出现。

在这种特定情况下,我们可以通过在第一次迭代时即时统计项目数量来解决问题,而不是在最后再次计数:

fun averageNonBlankLength(strings: Sequence<String>): Double {
    var count = 0
    return (strings
        .onEach { count++ }
        .filter { it.isNotBlank() }
        .sumBy(String::length)
        / count.toDouble())
}

这是本书中我们用可变的局部变量解决的第一个问题!我们可以将我们的羞愧隐藏在一个更通用的实用工具类中:CountingSequence

class CountingSequence<T>(
    private val wrapped: Sequence<T>
) : Sequence<T> {
    var count = 0
    override fun iterator() =
        wrapped.onEach { count++ }.iterator()
}

fun averageNonBlankLength(strings: Sequence<String>): Double {
    val countingSequence = CountingSequence(strings)
    return (countingSequence
            .filter { it.isNotBlank() }
            .sumBy(String::length)
            / countingSequence.count.toDouble())
}

这是 Kotlin 算法中的一个反复出现的主题:我们偶尔可能需要通过变异来实现某些有意义或高效的东西,但我们通常可以隐藏变异,以减少其可见性并形成一个有用的抽象。在这种情况下,Sequence是一个仅有一个方法的接口,因此我们很容易自己实现它。Java 的Stream也是一个接口,但它有 42 个方法,且没有AbstractStream类来提供默认实现!

在我们离开本节之前,自从我们介绍了Stream.count()以来,您可能一直在默默发怒。如果没有,您能看出问题是什么吗?

StreamSequence的一个优点是它们允许我们处理任意大的数据集,通过逐个计数来确定这些数据集的大小并不是很有效,即使有时可以这样做。总的来说,即使在实践中我们可以多次迭代Sequence,在最初使用Sequence的那些用例中,这样做可能是低效的。

只对序列进行一次迭代

通常来说,如果它们操作的是一个Sequence,我们的算法应该在单次遍历内完成。这样,它们就能处理那些不支持多次迭代且能够高效处理大量项目的序列。

我们可以在测试中使用Sequence.constrainOnce()来确保我们不会意外再次遍历。

选择流、可迭代对象和序列之间的区别

如果我们已经有了使用 Java 流的代码,在 JVM 上转换为 Kotlin 后,它将继续正常运行。甚至看起来会更好看一些,因为 Kotlin 可以将 lambda 移到方法外部,并允许使用隐式的it lambda 参数:

fun averageNonBlankLength(strings: List<String>): Double =
    (strings
        .stream()
        .filter { it.isNotBlank() }
        .mapToInt(String::length)
        .sum()
        / strings.size.toDouble())

此外,我们可以使用扩展函数添加操作到流中,就像 Kotlin 定义其Sequence操作一样。

如果我们的代码正在处理大型集合,特别是使用parallelStream(),那么默认情况应该是保持流不变,因为在这些情况下它们由 JVM 进行了优化。Kotlin 标准库甚至提供了扩展Stream<T>.asSequence()Sequence<T>.asStream(),允许我们在中途转换操作,嗯,Stream

如果我们决定转换为 Kotlin 抽象,则可以根据流代码是否利用惰性评估来选择IterableSequence

如果需要惰性评估:

  • 我们需要在完成读取输入之前产生结果。

  • 我们需要处理比我们可以放入内存中的更多数据(包括中间结果)。

惰性评估可能会为以下情况提供更好的性能:

  • 具有许多管道阶段的大型集合,其中构建中间集合可能会很慢。

  • 在早期阶段可能会被跳过的管道,取决于仅在后期阶段可用的信息。

我们可以用与流相同的翻译示例来说明最后一点:

public static List<String> translatedWordsUntilSTOP(List<String> strings) {
    return strings
        .stream()
        .map(word -> translate(word))
        .takeWhile(translation -> !translation.equalsIgnoreCase("STOP"))
        .collect(toList());
}

我们可以将此转换为等效的可迭代表达式:

fun translatedWordsUntilSTOP(strings: List<String>): List<String> =
    strings
        .map { translate(it) }
        .takeWhile { !it.equals("STOP", ignoreCase = true) }

但是然后所有输入List中的单词将通过map被翻译为另一个List,即使是在STOP之后的单词也是如此。使用Sequence可以避免翻译我们不打算返回的单词:

fun translatedWordsUntilSTOP(strings: List<String>): List<String> =
    strings
        .asSequence()
        .map { translate(it) }
        .takeWhile { !it.equals("STOP", ignoreCase = true) }
        .toList()

如果我们不需要惰性评估,并且对于较小的集合或在 Kotlin 中从头开始编写时,Iterable管道简单、通常快速且易于理解。你的作者通常会将流转换为可迭代对象,以利用 Kotlin 提供的更丰富的 API。如果可迭代对象在处理大型集合时过于慢(或者有时对内存过于贪婪),那么我们可以转换为序列。如果这还不够,我们可以转向(希望不要再转回)流,甚至利用并行处理。

代数变换

惰性和并行当然会影响我们管道阶段何时被调用。如果我们的任何算法依赖于操作的顺序,如果我们在流、可迭代和序列之间切换,可能会导致破坏。我们想要的是具有可预测代数的代码:一组规则,用于操作操作同时保持行为。

我们在第七章中看到,我们可以根据它们运行的时间依赖性对函数(实际上包括任何代码,包括 lambda)进行分类。计算(“Calculations”)是安全重构的,因为我们可以移动它们的调用而不影响它们的结果或任何其他代码的结果。相反,将一个操作(“Actions”)从可迭代对象移动到序列中,或者反之,可能会改变其调用时间,从而影响程序的结果。我们的代码中表达为计算的部分越多,我们就越能把它的表示视为可以根据规则转换的东西。

我们还可以应用另一个代数——算术——来简化我们对averageNonBlankLength的定义。当前情况是:

class CountingSequence<T>(
    private val wrapped: Sequence<T>
) : Sequence<T> {
    var count = 0
    override fun iterator() =
        wrapped.onEach { count++ }.iterator()
}

fun averageNonBlankLength(strings: Sequence<String>): Double {
    val countingSequence = CountingSequence(strings)
    return (countingSequence
            .filter { it.isNotBlank() }
            .sumBy(String::length)
            / countingSequence.count.toDouble())
}

所有这些复杂性的原因是因为我们不想要简单的平均数,而是在空白字符串被视为空的情况下的平均数。从总和中过滤空白,但不过滤计数,是实现这一目标的一种方法。尽管从数学上来说,它等效于以下方式:

fun averageNonBlankLength(strings: Sequence<String>): Double =
    strings
        .map { if (it.isBlank()) 0 else it.length }
        .average()

这是一种数学上的重新排列,并且与我们的代码重构一样,仅当所有操作都是计算时才有效。这也是非常具有诱惑力的,因为我们又滑回了将整数装箱以传递给average的地步。

我们需要一个sumBy的平均数版本。我们可以通过将 Kotlin 运行时定义的Sequence.sumBySequence.average相结合来实现这一点:

inline fun <T> Sequence<T>.averageBy(selector: (T) -> Int): Double {
    var sum: Double = 0.0
    var count: Int = 0
    for (element in this) {
        sum += selector(element)
        checkCountOverflow(++count)
    }
    return if (count == 0) Double.NaN else sum / count
}

这又是出于效率考虑而妥协到变异,最终使我们能够编写:

fun averageNonBlankLength(strings: Sequence<String>): Double =
    strings.averageBy {
        if (it.isBlank()) 0 else it.length
    }

我们为什么不一开始就这样写呢?嗯,有时我们看到这些等价,有时我们看不到!记住我们从这里开始:

public static double averageNonBlankLength(List<String> strings) {
    var sum = 0;
    for (var s : strings) {
        if (!s.isBlank())
            sum += s.length();
    }
    return sum / (double) strings.size();
}

鉴于这段代码,将if语句转换为filter是很自然的:

public static double averageNonBlankLength(List<String> strings) {
    return strings
        .stream()
        .filter(s -> !s.isBlank())
        .mapToInt(String::length)
        .sum()
        / (double) strings.size();
}

如果我们的原始代码更加函数式呢?而不是使用if语句来决定是否添加,它可能使用三元表达式来计算要添加的数量:

public static double averageNonBlankLength(List<String> strings) {
    var sum = 0;
    for (var s : strings) {
        sum += s.isBlank() ? 0 : s.length();
    }
    return sum / (double) strings.size();
}

啊,那么我们最初的翻译可能是:

public static double averageNonBlankLength(List<String> strings) {
    return strings
        .stream()
        .mapToInt(s -> s.isBlank() ? 0 : s.length())
        .average()
        .orElse(Double.NaN);
}

在那种情况下,我们可能会有一个更短的章节,但学到的东西会更少。

从流到可迭代和序列的重构

Travelator 在运行时记录操作事件,因此我们知道它正如我们所期望的那样工作。这些事件以 JSON 格式发送到一个索引服务器,该服务器可以使用其自己的查询语言生成漂亮的图形和警报。不过,市场部门的人总是问一些我们无法为之编写查询的问题。

在这些情况下,我们从服务器获取事件并在本地处理它们。事件的查询、编组和分页隐藏在一个简单的EventStore接口背后,该接口返回一个Iterator<Map<String, Object>>,其中Map<String, Object>表示 JSON 对象:

public interface EventStore {

    Iterator<Map<String, Object>> query(String query);

    default Stream<Map<String, Object>> queryAsStream(String query) {
        Iterable<Map<String, Object>> iterable = () -> query(query);
        return StreamSupport.stream(iterable.spliterator(), false);
    }
}

示例 13.1 [streams-to-sequences.0:src/main/java/travelator/analytics/EventStore.java] (差异)

接口包含了将Iterator转换为Stream的便利方法。(令人惊讶的是,JDK 中没有内置的转换函数。)

这是我们无法用索引服务器的查询语言编写的一种情况。它计算了顾客成功完成预订所需的平均互动次数:

public double averageNumberOfEventsPerCompletedBooking(
    String timeRange
) {
    Stream<Map<String, Object>> eventsForSuccessfulBookings =
        eventStore
            .queryAsStream("type=CompletedBooking&timerange=" + timeRange)
            .flatMap(event -> {
                String interactionId = (String) event.get("interactionId");
                return eventStore.queryAsStream("interactionId=" + interactionId);
            });
    Map<String, List<Map<String, Object>>> bookingEventsByInteractionId =
        eventsForSuccessfulBookings.collect(groupingBy(
            event -> (String) event.get("interactionId"))
        );
    var averageNumberOfEventsPerCompletedBooking =
        bookingEventsByInteractionId
            .values()
            .stream()
            .mapToInt(List::size)
            .average();
    return averageNumberOfEventsPerCompletedBooking.orElse(Double.NaN);
}

示例 13.2 [streams-to-sequences.0:src/main/java/travelator/analytics/MarketingAnalytics.java] (差异)

当我们编写这段代码时,我们尽力使其易于理解。我们为中间变量命名,并在似乎有帮助时指定它们的类型,并仔细格式化——但它看起来仍然像是有人把代码扔到地板上,然后试图把它重新拼凑起来,希望我们没有注意到。我们有时会陷入这样一场失败的战斗:我们可以提取一个函数以简化调用点的代码,但如果我们无法给出一个好的名称,我们只是把问题推到了源文件。

隐式或显式类型

有时变量的类型对于理解代码的工作方式至关重要;其他时候,它只会使已经冗长的代码块更加混乱。在这方面,显式类型就像注释一样,但它们具有编译器检查和强制执行的额外优势。与注释一样,我们应该尽量编写不需要显式变量类型的代码。良好的命名可以帮助,重构成可以显示返回类型的函数也可以帮助。

如果这些方法失败了,那么在提高代码可读性的情况下展示变量的类型并没有什么可耻的,我们应该更愿意通过类型来交流,而不是通过注释。

我们将这段代码转换为 Kotlin,热切地希望我们最喜爱的语言能让我们做得更好。这是自动转换的结果:

fun averageNumberOfEventsPerCompletedBooking(
    timeRange: String
): Double {
    val eventsForSuccessfulBookings = eventStore
        .queryAsStream("type=CompletedBooking&timerange=$timeRange")
        .flatMap { event: Map<String?, Any?> ->
            val interactionId = event["interactionId"] as String?
            eventStore.queryAsStream("interactionId=$interactionId")
        }
    val bookingEventsByInteractionId = eventsForSuccessfulBookings.collect(
        Collectors.groupingBy(
            Function { event: Map<String, Any> ->
                event["interactionId"] as String?
            }
        )
    )
    val averageNumberOfEventsPerCompletedBooking = bookingEventsByInteractionId
        .values
        .stream()
        .mapToInt { obj: List<Map<String, Any>> -> obj.size }
        .average()
    return averageNumberOfEventsPerCompletedBooking.orElse(Double.NaN)
}

示例 13.3 [streams-to-sequences.1:src/main/java/travelator/analytics/MarketingAnalytics.kt] (差异)

在撰写本文时,Java 到 Kotlin 转换器在两种语言之间的 lambda 映射方面还不够聪明。这在流代码中尤为明显,因为那里是大多数 Java lambda 的地方。大多数问题都可以通过在奇怪的代码上按下 Alt-Enter 并接受快速修复来解决。让我们从整理空值开始,移除剩余的Function,并简化那个丑陋的mapToInt lambda。

fun averageNumberOfEventsPerCompletedBooking(
    timeRange: String
): Double {
    val eventsForSuccessfulBookings = eventStore
        .queryAsStream("type=CompletedBooking&timerange=$timeRange")
        .flatMap { event ->
            val interactionId = event["interactionId"] as String
            eventStore.queryAsStream("interactionId=$interactionId")
        }
    val bookingEventsByInteractionId = eventsForSuccessfulBookings.collect(
        groupingBy { event -> event["interactionId"] as String }
    )
    val averageNumberOfEventsPerCompletedBooking = bookingEventsByInteractionId
        .values
        .stream()
        .mapToInt { it.size }
        .average()
    return averageNumberOfEventsPerCompletedBooking.orElse(Double.NaN)
}

示例 13.4 [streams-to-sequences.2:src/main/java/travelator/analytics/MarketingAnalytics.kt] (差异)

在转换之前的 Java 代码中,混合了一些旧式显式类型的变量,例如Stream<Map<String, Object>>,以及隐式的var averageNumberOf​Event⁠sPerCompletedBooking。转换已经删除了显式类型。这种方式确实少了些威胁感,但如果我们真的关心它是如何做到的,它也更难以理解。我们暂时保持这种状态,但在完成之前会重新审视我们的决定。

此时,我们有一个使用 Java 流运行良好的 Kotlin 代码。我们可以不做任何更改。Travelator 非常成功,每天完成数千次预订,流对于吞吐量是一个很好的选择,那么为什么要转换为 Kotlin 呢?不过你不是为了这种态度而买这本书的,所以我们会在假装在每个阶段测量性能,并且如果看到性能显著降低,我们会停下来。

首先是 Iterable

查看代码时,我们可以看到它有两个阶段。第一个阶段处理一个长度不确定的输入,生成内存中的一个集合:

val eventsForSuccessfulBookings = eventStore
    .queryAsStream("type=CompletedBooking&timerange=$timeRange")
    .flatMap { event ->
        val interactionId = event["interactionId"] as String
        eventStore.queryAsStream("interactionId=$interactionId")
    }
val bookingEventsByInteractionId = eventsForSuccessfulBookings.collect(
    groupingBy { event -> event["interactionId"] as String }
)

示例 13.5 [streams-to-sequences.2:src/main/java/travelator/analytics/MarketingAnalytics.kt] (diff)

第二个阶段处理该集合:

val averageNumberOfEventsPerCompletedBooking = bookingEventsByInteractionId
    .values
    .stream()
    .mapToInt { it.size }
    .average()
return averageNumberOfEventsPerCompletedBooking.orElse(Double.NaN)

示例 13.6 [streams-to-sequences.2:src/main/java/travelator/analytics/MarketingAnalytics.kt] (diff)

正如我们之前看到的,Java 在这两种情况下都使用流,而在 Kotlin 中,我们倾向于使用Sequence来处理长度未知的输入,使用Iterable来处理内存中的数据。操作内存数据更容易理解,所以我们将首先转换averageNumberOfEventsPerCompletedBooking

在 IntelliJ 提出自动重构之前,我们只能手工操作。通常情况下,我们会有测试来使这个过程更安全,但这是快速变动和任意的分析代码,所以我们略有疏忽。在正式进行重构之前,我们编写了一个快速的测试,与生产环境交互,并显示昨天的结果为 7.44;现在我们可以继续运行以检查它是否发生变化。

我们知道我们可以直接对Map.values应用集合操作,因此我们可以去除.stream();在 Java 中,average()IntStream的操作,但是 Kotlin 方便地声明了Iterable<Int>.average(),因此我们不需要mapToInt,只需map。最后,在 Java 中,如果流没有元素,IntStream.average()返回一个空的OptionalDouble,而 Kotlin 的Iterable<Int>.average()返回NaN(不是一个数字),这意味着我们可以直接使用结果:

val averageNumberOfEventsPerCompletedBooking = bookingEventsByInteractionId
    .values
    .map { it.size }
    .average()
return averageNumberOfEventsPerCompletedBooking

示例 13.7 [streams-to-sequences.3:src/main/java/travelator/analytics/MarketingAnalytics.kt] (diff)

不过这次改动是否好呢?

查看代码,我们现在创建了一个中间的 List<Int> 来调用 average()。这将导致每个值都要装箱,而这次没有 averageBy()(就像之前的例子中有 sumBy() 一样)来阻止这种情况。

这段代码的性能优劣将取决于 Map 中值的数量,我们特定的 JVM 如何优化装箱,以及 HotSpot 在这条路径上的优化程度;只有在真实条件下进行测量才能告诉我们。如果我们必须选择一个通用解决方案,我们可能应该编写自己的 Collection.averageBy。这样我们可以利用了解 Collection 大小的优势。我们可以使用本章前面准备的(尽管是用于 Sequence 的)那个,或者从这里重构。我们可以通过提取 values 并使用 sumBy() 从这里重构:

val values = bookingEventsByInteractionId.values
return values.sumBy { it.size } / values.size.toDouble()

示例 13.8 [streams-to-sequences.4:src/main/java/travelator/analytics/MarketingAnalytics.kt] (差异)

现在在返回的表达式上“提取函数” averageBy

val values = bookingEventsByInteractionId.values
return averageBy(values)

示例 13.9 [streams-to-sequences.5:src/main/java/travelator/analytics/MarketingAnalytics.kt] (差异)

private fun averageBy(
    values: MutableCollection<MutableList<MutableMap<String, Any>>>
): Double {
    return values.sumBy { it.size } / values.size.toDouble()
}

示例 13.10 [streams-to-sequences.5:src/main/java/travelator/analytics/MarketingAnalytics.kt] (差异)

噢唷!原来 bookingEventsByInteractionId 的类型比我们想要的要可变得多。它来自 Collectors.groupingBy,这是一个只返回 Java 集合的流操作。我们将其更改为暂时使用 Collection 代替 MutableCollection,然后在 lambda 上“引入参数”命名为 selector

private fun averageBy(
    values: Collection<MutableList<MutableMap<String, Any>>>,
    selector: (MutableList<MutableMap<String, Any>>) -> Int
): Double {
    return values.sumBy(selector) / values.size.toDouble()
}

示例 13.11 [streams-to-sequences.6:src/main/java/travelator/analytics/MarketingAnalytics.kt] (差异)

现在我们不想关心 Collection 中项的实际类型。如果我们选择 MutableList<MutableMap<String, Any>> 并且“提取/引入类型参数”,我们会得到以下结果:

private fun <T : MutableList<MutableMap<String, Any>>> averageBy(
    values: Collection<T>,
    selector: (T) -> Int
): Double {
    return values.sumBy(selector) / values.size.toDouble()
}

示例 13.12 [streams-to-sequences.7:src/main/java/travelator/analytics/MarketingAnalytics.kt] (差异)

那个重构是如此巧妙,以至于我们不介意告诉 IntelliJ,T 实际上可以是任何东西(通过移除 MutableList<MutableMap<String, Any>> 类型限制):

private fun <T> averageBy(
    values: Collection<T>,
    selector: (T) -> Int
): Double {
    return values.sumBy(selector) / values.size.toDouble()
}

示例 13.13 [流到序列.8:src/main/java/travelator/analytics/MarketingAnalytics.kt] (差异)

IntelliJ 也由于某种原因在调用时添加了类型:

val values = bookingEventsByInteractionId.values
return averageBy<MutableList<MutableMap<String, Any>>>(values) { it.size }

示例 13.14 [流到序列.7:src/main/java/travelator/analytics/MarketingAnalytics.kt] (差异)

因此我们也从那里删除了 MutableList<MutableMap<String, Any>>

最后,我们可以将 averageBy 变成它天生应该是的小型单表达式内联扩展函数(参见第九章和第十章):

inline fun <T> Collection<T>.averageBy(selector: (T) -> Int): Double =
    sumBy(selector) / size.toDouble()

示例 13.15 [流到序列.9:src/main/java/travelator/analytics/MarketingAnalytics.kt] (差异)

这个版本不会装箱整数,也不会多次迭代,因此它可能是我们能够获得的最有效的版本。但是再次强调,只有在我们特定的情况下测量才能确定。

注意,当我们之前编写 Sequence.averageNonBlankLength 时,我们必须计算项数。通过将 averageBy 定义为 Collection 的扩展而不是 Iterable 的扩展,我们可以利用内存中集合的 size 来避免繁琐的记账工作。

然后是序列

到目前为止,我们已经将内存中的管道转换为了 Kotlin。现在我们剩下的是从 eventStore 读取未知数量事件的代码。我们希望保持这段代码是惰性的。

回到入口点,我们现在有:

fun averageNumberOfEventsPerCompletedBooking(
    timeRange: String
): Double {
    val eventsForSuccessfulBookings = eventStore
        .queryAsStream("type=CompletedBooking&timerange=$timeRange")
        .flatMap { event ->
            val interactionId = event["interactionId"] as String
            eventStore.queryAsStream("interactionId=$interactionId")
        }
    val bookingEventsByInteractionId = eventsForSuccessfulBookings.collect(
        groupingBy { event -> event["interactionId"] as String }
    )
    return bookingEventsByInteractionId.values.averageBy { it.size }
}

示例 13.16 [流到序列.9:src/main/java/travelator/analytics/MarketingAnalytics.kt] (差异)

现在变量 bookingEventsByInteractionId 只是在算法中提供了一个检查点:它命名了一个中间值,希望有助于理解。在函数中向上移动,eventsForSuccessfulBookings 是一个 Stream,因此我们可以将 collect(groupingBy(...)) 转换为 Kotlin 的 asSequence().groupBy {...};lambda 保持不变:

val bookingEventsByInteractionId = eventsForSuccessfulBookings
    .asSequence()
    .groupBy { event ->
        event["interactionId"] as String
    }

示例 13.17 [流到序列.10:src/main/java/travelator/analytics/MarketingAnalytics.kt] (差异)

用类似名称的另一种方法(或扩展函数)替换一种方法,它接受兼容的 lambda,这表明我们走在正确的轨道上。

现在来看那个 flatMap,用于获取任何完成预订的交互的所有事件:

val eventsForSuccessfulBookings = eventStore
    .queryAsStream("type=CompletedBooking&timerange=$timeRange")
    .flatMap { event ->
        val interactionId = event["interactionId"] as String
        eventStore.queryAsStream("interactionId=$interactionId")
    }

示例 13.18 [streams-to-sequences.10:src/main/java/travelator/analytics/MarketingAnalytics.kt] (diff)

如果我们使用的是序列而不是流,这也很可能只是工作™。幸运的是,我们知道如何从Stream转换为Sequence:这是由 Kotlin JDK 互操作提供的.asSequence()扩展。我们需要将其应用于两个流:

val eventsForSuccessfulBookings = eventStore
    .queryAsStream("type=CompletedBooking&timerange=$timeRange")
    .asSequence()
    .flatMap { event ->
        val interactionId = event["interactionId"] as String
        eventStore
            .queryAsStream("interactionId=$interactionId")
            .asSequence()
    }

示例 13.19 [streams-to-sequences.11:src/main/java/travelator/analytics/MarketingAnalytics.kt] (diff)

令人惊讶的是,这继续编译并通过了我们(粗略的)测试!这是因为,尽管我们已经将eventsForSuccessfulBookings的类型从Stream更改为Sequence,但我们随后调用了eventsForSuccessfulBookings.asSequence()

val bookingEventsByInteractionId = eventsForSuccessfulBookings
    .asSequence()
    .groupBy { event ->
        event["interactionId"] as String
    }

示例 13.20 [streams-to-sequences.11:src/main/java/travelator/analytics/MarketingAnalytics.kt] (diff)

这解析为Sequence.asSequence(),这是一个无操作。我们可以内联asSequence来证明它:

val bookingEventsByInteractionId = eventsForSuccessfulBookings
    .groupBy { event ->
        event["interactionId"] as String
    }

示例 13.21 [streams-to-sequences.12:src/main/java/travelator/analytics/MarketingAnalytics.kt] (diff)

回到eventsForSuccessfulBookings,我们现在有:

val eventsForSuccessfulBookings = eventStore
    .queryAsStream("type=CompletedBooking&timerange=$timeRange")
    .asSequence()
    .flatMap { event ->
        val interactionId = event["interactionId"] as String
        eventStore
            .queryAsStream("interactionId=$interactionId")
            .asSequence()
    }

示例 13.22 [streams-to-sequences.11:src/main/java/travelator/analytics/MarketingAnalytics.kt] (diff)

我们真正想要的是EventStore支持queryAsSequence。我们可以通过引入扩展函数来实现这一点,而无需修改它:

fun EventStore.queryAsSequence(query: String) =
    this.queryAsStream(query).asSequence()

示例 13.23 [streams-to-sequences.12:src/main/java/travelator/analytics/MarketingAnalytics.kt] (diff)

这使我们可以从调用函数中移除asSequence调用:

fun averageNumberOfEventsPerCompletedBooking(
    timeRange: String
): Double {
    val eventsForSuccessfulBookings = eventStore
        .queryAsSequence("type=CompletedBooking&timerange=$timeRange")
        .flatMap { event ->
            val interactionId = event["interactionId"] as String
            eventStore
                .queryAsSequence("interactionId=$interactionId")
        }
    val bookingEventsByInteractionId = eventsForSuccessfulBookings
        .groupBy { event ->
            event["interactionId"] as String
        }
    return bookingEventsByInteractionId.values.averageBy { it.size }
}

示例 13.24 [streams-to-sequences.12:src/main/java/travelator/analytics/MarketingAnalytics.kt] (diff)

好的,是时候进行审查了。我们已经将我们的 Java 转换为 Kotlin,并且正在使用可迭代对象来处理内存中的操作,并使用序列(由EventStore中的流支持)来处理无界操作。不过,我们真的不能声称算法的结构变得更清晰了。有点嘈杂减少了,是的,但很难表达。

函数目前分为三部分,如果我们诚实的话,它们是相当随意的。有时候,通过内联所有内容并查看我们拥有的东西,我们可以获得更深入的洞察,所以让我们这样做:

fun averageNumberOfEventsPerCompletedBooking(
    timeRange: String
): Double {
    return eventStore
        .queryAsSequence("type=CompletedBooking&timerange=$timeRange")
        .flatMap { event ->
            val interactionId = event["interactionId"] as String
            eventStore
                .queryAsSequence("interactionId=$interactionId")
        }.groupBy { event ->
            event["interactionId"] as String
        }.values
        .averageBy { it.size }
}

示例 13.25 [streams-to-sequences.13:src/main/java/travelator/analytics/MarketingAnalytics.kt] (diff)

看起来从flatMap开始到groupBy之前的部分可能是独立的。让我们看看如何将管道的一部分提取为自己的函数。

提取管道的一部分

首先,我们从管道的开头选择到我们想要包含的最后一个阶段,所以从eventStore.groupBy之前。“提取函数”,称其为(在本例中)allEventsInSameInteractions

fun averageNumberOfEventsPerCompletedBooking(
    timeRange: String
): Double {
    return allEventsInSameInteractions(timeRange)
        .groupBy { event ->
            event["interactionId"] as String
        }.values
        .averageBy { it.size }
}

private fun allEventsInSameInteractions(timeRange: String) = eventStore
    .queryAsSequence("type=CompletedBooking&timerange=$timeRange")
    .flatMap { event ->
        val interactionId = event["interactionId"] as String
        eventStore
            .queryAsSequence("interactionId=$interactionId")
    }

示例 13.26 [streams-to-sequences.14:src/main/java/travelator/analytics/MarketingAnalytics.kt] (diff)

现在我们选择管道中不需要的部分,即e⁠v⁠e⁠n⁠t​S⁠t⁠o⁠r⁠e.flatMap之前,并且“引入参数”。接受 IntelliJ 选择的任何名称——它不会存活很长时间:

fun averageNumberOfEventsPerCompletedBooking(
    timeRange: String
): Double {
    return allEventsInSameInteractions(
        eventStore
            .queryAsSequence("type=CompletedBooking&timerange=$timeRange")
    )
        .groupBy { event ->
            event["interactionId"] as String
        }.values
        .averageBy { it.size }
}

private fun allEventsInSameInteractions(
    sequence: Sequence<MutableMap<String, Any?>>
) = sequence
    .flatMap { event ->
        val interactionId = event["interactionId"] as String
        eventStore
            .queryAsSequence("interactionId=$interactionId")
    }

示例 13.27 [streams-to-sequences.15:src/main/java/travelator/analytics/MarketingAnalytics.kt] (diff)

那真的很丑,但是一旦我们将allEventsInSame​In⁠teractionssequence参数转换为接收者并重新格式化,我们有:

fun averageNumberOfEventsPerCompletedBooking(
    timeRange: String
): Double {
    return eventStore
        .queryAsSequence("type=CompletedBooking&timerange=$timeRange")
        .allEventsInSameInteractions()
        .groupBy { event ->
            event["interactionId"] as String
        }.values
        .averageBy { it.size }
}

fun Sequence<Map<String, Any?>>.allEventsInSameInteractions() =
    flatMap { event ->
        val interactionId = event["interactionId"] as String
        eventStore
            .queryAsSequence("interactionId=$interactionId")
    }

示例 13.28 [streams-to-sequences.16:src/main/java/travelator/analytics/MarketingAnalytics.kt] (diff)

正如我们在第十章中讨论的,扩展函数在我们链式操作时真正展现了自己的价值。在 Java 中,我们无法通过allEventsInSameInteractions()扩展 Streams API,因此我们最终通过调用函数或引入解释性变量来打破链条。

最终整理

这仍然有些笨拙,我们可能可以通过在分组时不创建列表来提高效率,但这样做就足够了。哦,除了一个薄如纸片的类型别名和扩展属性外:

typealias Event = Map<String, Any?>

val Event.interactionId: String? get() =
    this["interactionId"] as? String

示例 13.29 [streams-to-sequences.17:src/main/java/travelator/analytics/MarketingAnalytics.kt] (diff)

这让我们在阅读代码时可以集中精力处理困难的部分:

class MarketingAnalytics(
    private val eventStore: EventStore
) {
    fun averageNumberOfEventsPerCompletedBooking(
        timeRange: String
    ): Double = eventStore
        .queryAsSequence("type=CompletedBooking&timerange=$timeRange")
        .allEventsInSameInteractions()
        .groupBy(Event::interactionId)
        .values
        .averageBy { it.size }

    private fun Sequence<Event>.allEventsInSameInteractions() =
        flatMap { event ->
            eventStore.queryAsSequence(
                "interactionId=${event.interactionId}"
            )
        }
}

inline fun <T> Collection<T>.averageBy(selector: (T) -> Int): Double =
    sumBy(selector) / size.toDouble()

fun EventStore.queryAsSequence(query: String) =
    this.queryAsStream(query).asSequence()

示例 13.30 [streams-to-sequences.17:src/main/java/travelator/analytics/MarketingAnalytics.kt] (diff)

顺便提一下,注意allEventsInSameInteractions是我们在第十章讨论过的一个扩展函数作为方法的示例。它可以访问MarketingAnalytics中的this(以访问eventStore)以及Sequence<Event>中的this

进入下一步

我们并不打算声称这个示例中重构后的 Kotlin 代码很美观,但我们确实认为它在原始 Java 代码的基础上有了显著的改进。扩展函数、Kotlin 的 lambda 语法以及改进的类型推断结合在一起,减少了与 Java 流相关的许多噪音。当我们使用内存中的集合时,使用迭代器而不是流可能更高效、更清晰。

第十四章:累积对象到转换

Java 程序通常依赖于可变状态,因为即使使用 Streams API,在 Java 中定义值类型和转换值仍然非常困难。如何将依赖于可变对象和副作用的 Java 代码最佳地转换为 Kotlin 代码,以转换不可变值?

使用累加器参数进行计算

我们的旅行者最关心的事情之一是他们的冒险将花费多少。国际旅行使这变得相当复杂。旅行会在多个货币中产生成本,因为它穿越边界,但旅行者希望能够比较总体成本,以便做出关于路线和住宿地点的决定。因此,Travelator 通过本地货币和旅行者首选货币汇总成本,然后显示首选货币的总体总额。它使用了 CostSummaryCostSummaryCalculator 类。让我们看一下它们的使用方法,然后再看看它们的实现。

Itinerary 类有一个用于汇总其成本的操作,使用 CostSummaryCalculator。它的使用方式如下:

val fx: ExchangeRates = ...
val userCurrency = ...
val calculator = CostSummaryCalculator(userCurrency, fx) ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)

fun costSummary(i: Itinerary): CostSummary {
    i.addCostsTo(calculator) ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/2.png)
    return calculator.summarise() ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/3.png)
}

示例 14.1 [accumulator.0:src/test/java/travelator/itinerary/Itinerary_CostTest.kt] (diff)

1

在此,代码使用旅行者首选的货币和货币汇率来源创建了 CostSummaryCalculator

2

这告诉 Itinerary 将其成本添加到计算器中。作为响应,Itinerary 添加其元素的成本:沿途旅程、住宿和其他收费服务的成本。

3

这调用计算器的 summarise 方法,在收集所有成本后获取 CostSummary

Itinerary 类已被转换为 Kotlin,但 addCostsTo 的实现仍带有 Java 的风格:

data class Itinerary(
    val id: Id<Itinerary>,
    val route: Route,
    val accommodations: List<Accommodation> = emptyList()
) {
    ...

    fun addCostsTo(calculator: CostSummaryCalculator) {
        route.addCostsTo(calculator)
        accommodations.addCostsTo(calculator)
    }

    ...
}

fun Iterable<Accommodation>.addCostsTo(calculator: CostSummaryCalculator) {
    forEach { a ->
        a.addCostsTo(calculator)
    }
}

示例 14.2 [accumulator.0:src/main/java/travelator/itinerary/Itinerary.kt] (diff)

此逻辑依赖于副作用,在 CostSummaryCalculator 的可变状态中累积成本。

这种设计的好处在于,我们可以使用计算器总结域模型中任何对象的成本,而无需了解该对象的结构。对象负责将其成本添加到计算器中,并将计算器传递给其子对象,以便它们可以添加 它们 的成本。这样一来,需要成本的代码与提供成本的代码解耦,使我们能够独立地演化它们。

例如,一个Route包含一个Journey列表,每个Journey都有成本,而Accommodation有房费、住宿天数以及诸如餐费和酒店服务等额外成本。Itinerary不需要知道或关心这些对象的结构,也不需要知道如何收集它们各自的成本。这些知识被封装在RouteAccommodation类中。

然而,我们使用可变状态有两个显著的缺点。

首先,它引入了别名错误的可能性。别名错误会产生“鬼魅般的远程作用”(正如爱因斯坦描述量子纠缠时所说),这在源代码中并不立即明显。我们在第六章看到了一个例子,一个函数对可变列表参数进行了排序,却破坏了其调用者。

对于CostSummaryCalculator,如果我们重用计算器来总结多个实体的成本,我们必须在每次计算之间重置其状态。如果我们不重置计算器状态,一个计算过程中收集的成本将包含在下一个计算中。类型系统不能帮助我们避免这个错误。

本章开头的示例可能会出现这种错误。计算器不是局部于costSummary方法,并且costSummary在每次计算之前都不会重置计算器。我们无法单凭查看costSummary方法来判断这是否是一个问题。我们必须理解该方法在更广泛上下文中的使用方式,并且在我们修改该上下文时,确保这些变化不会破坏我们对costSummary方法使用方式的假设。

可变状态的第二个问题是,它将我们的算法实现分散在代码中。我们将在本章后面返回这个问题。

在我们看CostSummaryCalculator之前,让我们先看看它计算的CostSummary。它使用CurrencyConversion(幸运的是已经是 Kotlin):

data class CurrencyConversion(
    val fromMoney: Money,
    val toMoney: Money
)

示例 14.3 [accumulator.0:src/main/java/travelator/money/CurrencyConversion.kt] (diff)

CostSummary是一个可变的 POJO(如第五章描述的那样),它保存了从本地货币到旅行者首选货币的CurrencyConversion列表:

public class CostSummary {
    private final List<CurrencyConversion> lines = new ArrayList<>();
    private Money total;

    public CostSummary(Currency userCurrency) {
        this.total = Money.of(0, userCurrency);
    }

    public void addLine(CurrencyConversion line) {
        lines.add(line);
        total = total.add(line.getToMoney());
    }

    public List<CurrencyConversion> getLines() {
        return List.copyOf(lines);
    }

    public Money getTotal() {
        return total;
    }
}

示例 14.4 [accumulator.0:src/main/java/travelator/itinerary/CostSummary.java] (diff)

CostSummary还报告了首选货币中的总成本。它将总成本存储在一个字段中,而不是在getTotal中计算,因为应用程序经常按其CostSummary.total排序项目,而每次进行比较时重新计算的成本会成为一个瓶颈。这意味着当添加CurrencyConversion时,CostSummary必须更新total

CostSummary也是一个有效的共享可变集合。因为这违反了我们在“不要变更共享集合”中的经验法则,它在getLines中执行复制以限制损害。

现在是CostSummaryCalculator。当调用addCost时,它在currencyTotals字段中为每个Currency保持一个运行总数。summarise方法使用汇率来源构建CostSummary,将本地成本转换为旅行者偏好的货币:

public class CostSummaryCalculator {
    private final Currency userCurrency;
    private final ExchangeRates exchangeRates;
    private final Map<Currency, Money> currencyTotals = new HashMap<>();

    public CostSummaryCalculator(
        Currency userCurrency,
        ExchangeRates exchangeRates
    ) {
        this.userCurrency = userCurrency;
        this.exchangeRates = exchangeRates;
    }

    public void addCost(Money cost) {
        currencyTotals.merge(cost.getCurrency(), cost, Money::add);
    }

    public CostSummary summarise() {
        var totals = new ArrayList<>(currencyTotals.values());
        totals.sort(comparing(m -> m.getCurrency().getCurrencyCode()));

        CostSummary summary = new CostSummary(userCurrency);
        for (var total : totals) {
            summary.addLine(exchangeRates.convert(total, userCurrency));
        }

        return summary;
    }

    public void reset() {
        currencyTotals.clear();
    }
}

示例 14.5 [accumulator.0:src/main/java/travelator/itinerary/CostSummaryCalculator.java] (差异)

因此,CostSummary的计算分散在两个类之间,这些类交织着以下责任:

  • 保留计算上下文中需要计算摘要的信息。

  • 计算每种货币的总计,以便计算不积累舍入误差。

  • 将成本转换为旅行者偏好的货币。

  • 计算旅行者偏好的货币中的总计。

  • 按照原始货币代码的字母顺序对货币转换进行排序。

  • 存储货币转换和总计,以便向旅行者显示。

当我们通过改变共享状态计算时,责任在类之间的扩散是常见的。我们希望分解责任并简化实现。我们应该针对什么最终结构?

一个线索在CostCurrencyCalculator类的名称中。在语言学术语中,CostCurrencyCalculator是一个代理名词:一个从动词派生的名词,仅指执行由动词识别的动作的事物,如driverbakercalculatorCostCurrencyCalculator是一个所谓的“执行者类”。

另一个线索在类所持有的数据中。旅行者偏好的货币和汇率来源是计算的上下文。它们由应用程序的其他位置管理,并由CostCurrencyCalculator持有,以便在其计算中随时可用。按货币(currencyTotals)的总计映射包含计算的瞬时中间结果,在计算完成后是无关紧要的,并且实际上应该被丢弃以避免别名错误。该类并不拥有任何数据,仅出于运行原因暂时持有。

CostCurrencyCalculator类不代表我们应用程序领域模型中的概念,而是我们在该领域模型的元素上执行的函数。在 Kotlin 中,我们通常不是用对象实现函数,而是用函数实现。

让我们将计算从可变类重构为使用不可变数据的函数。

重构为不可变数据上的函数

将两个类转换为 Kotlin 后,我们用 Kotlin 语法中的 Java 来处理。在稍微整理和重新排列后,这里是CostSummary

class CostSummary(userCurrency: Currency) {
    private val _lines = mutableListOf<CurrencyConversion>()

    var total: Money = Money.of(0, userCurrency)
        private set

    val lines: List<CurrencyConversion>
        get() = _lines.toList()

    fun addLine(line: CurrencyConversion) {
        _lines.add(line)
        total += line.toMoney
    }
}

示例 14.6 [accumulator.1:src/main/java/travelator/itinerary/CostSummary.kt] (差异)

CostSummaryCalculator 的自动转换需要整理工作更少:

class CostSummaryCalculator(
    private val userCurrency: Currency,
    private val exchangeRates: ExchangeRates
) {
    private val currencyTotals = mutableMapOf<Currency, Money>()

    fun addCost(cost: Money) {
        currencyTotals.merge(cost.currency, cost, Money::add)
    }

    fun summarise(): CostSummary {
        val totals = ArrayList(currencyTotals.values)
        totals.sortWith(comparing { m: Money -> m.currency.currencyCode })

        val summary = CostSummary(userCurrency)
        for (total in totals) {
            summary.addLine(exchangeRates.convert(total, userCurrency))
        }
        return summary
    }

    fun reset() {
        currencyTotals.clear()
    }
}

示例 14.7 [accumulator.1:src/main/java/travelator/itinerary/CostSummaryCalculator.kt] (差异)

我们可以从这里开始,重构掉可变性。我们将从内部开始,使 CostSummary 成为一个不可变的值类型,并逐渐将不可变性向外推进到 CostSummaryCalculator。不过,在我们开始之前,我们一直被 Java 对于在原地排序集合的痴迷所困扰,所以我们先解决这个问题:

fun summarise(): CostSummary {
    val totals = currencyTotals.values.sortedBy {
        it.currency.currencyCode
    }
    val summary = CostSummary(userCurrency)
    for (total in totals) {
        summary.addLine(exchangeRates.convert(total, userCurrency))
    }
    return summary
}

示例 14.8 [accumulator.2:src/main/java/travelator/itinerary/CostSummaryCalculator.kt] (差异)

现在我们看到一个在突变代码中常见的模式:创建一个对象(在这种情况下是 CostSummary),调用一些初始化步骤,然后返回它。每当我们看到像这样的初始化步骤时,我们应该使用 apply

fun summarise(): CostSummary {
    val totals = currencyTotals.values.sortedBy {
        it.currency.currencyCode
    }
    val summary = CostSummary(userCurrency).apply {
        for (total in totals) {
            addLine(exchangeRates.convert(total, userCurrency))
        }
    }
    return summary
}

示例 14.9 [accumulator.3:src/main/java/travelator/itinerary/CostSummaryCalculator.kt] (差异)

使用 apply 允许我们将初始化步骤分组到一个块中,以更好地表达我们的意图。这就像一个迷你构建器:summarise 函数从未看到过部分初始化的 CostSummary 的引用,只看到完成的对象。

这是小规模的功能性思维——试图在函数内部限制突变的范围。功能性思维还帮助我们看到,循环遍历 totals,为每个创建一个 CurrencyConversion,然后调用 addLine,与创建一个 conversions 列表并遍历它是一样的:

fun summarise(): CostSummary {
    val conversions = currencyTotals.values.sortedBy {
        it.currency.currencyCode
    }.map { exchangeRates.convert(it, userCurrency) }

    return CostSummary(userCurrency).apply {
        conversions.forEach(this::addLine)
    }
}

示例 14.10 [accumulator.4:src/main/java/travelator/itinerary/CostSummaryCalculator.kt] (差异)

为什么要做这个改变?嗯,我们想将 CostSummary 剥离到其不可变的本质。如果 CostSummary 是不可变的,客户端代码将不得不将行列表传递给其构造函数,而不是调用其 addLine 方法。CostSummary 不应负责货币转换,所以我们让 apply 块看起来像我们希望其构造函数看起来的样子。从这里开始,我们添加一个次要构造函数,复制这个初始化逻辑:

class CostSummary(userCurrency: Currency) {
    private val _lines = mutableListOf<CurrencyConversion>()

    var total: Money = Money.of(0, userCurrency)
        private set

    val lines: List<CurrencyConversion>
        get() = _lines.toList()

    constructor(
        userCurrency: Currency,
        lines: List<CurrencyConversion>
    ): this(userCurrency) {
        lines.forEach(::addLine)
    }

    fun addLine(line: CurrencyConversion) {
        _lines.add(line)
        total += line.toMoney
    }
}

示例 14.11 [accumulator.5:src/main/java/travelator/itinerary/CostSummary.kt] (差异)

现在我们可以修改CostSummaryCalculator.summarise方法,调用新的构造函数,将CostSummary类视为不可变值类型:

fun summarise(): CostSummary {
    val conversions = currencyTotals.values.sortedBy {
        it.currency.currencyCode
    }.map { exchangeRates.convert(it, userCurrency) }

    return CostSummary(userCurrency, conversions)
}

示例 14.12 [accumulator.5:src/main/java/travelator/itinerary/CostSummaryCalculator.kt] (diff)

这样一来,我们可以确保CostSummary类从外部实际上是不可变的:

class CostSummary(
    userCurrency: Currency,
    val lines: List<CurrencyConversion>
) {

    var total: Money = Money.of(0, userCurrency)
        private set

    init {
        lines.forEach {
            total += it.toMoney
        }
    }
}

示例 14.13 [accumulator.6:src/main/java/travelator/itinerary/CostSummary.kt] (diff)

正如我们从那个恶心的varinit中看到的,一旦变异发生,有时很难摆脱它,特别是对于像这样的累加器;fold在这里是我们的朋友。我们有一系列操作(“操作”)作用于可变变量total,而fold将这些操作转换为单个计算(“计算”),我们可以用来初始化不可变变量:

class CostSummary(
    userCurrency: Currency,
    val lines: List<CurrencyConversion>
) {
    val total = lines
        .map { it.toMoney }
        .fold(Money.of(0, userCurrency), Money::add)
}

示例 14.14 [accumulator.7:src/main/java/travelator/itinerary/CostSummary.kt] (diff)

既然它完全是不可变的,我们可以将CostSummary改为数据类,只要我们可以将total作为主构造函数参数。我们可以通过将当前构造函数转换为次构造函数来实现这一点,但我们将所有计算移动到CostSummaryCalculator中,仅保留CostSummary来保存计算结果。

为此,我们首先选择total属性定义中等号右侧的表达式,并使用 IDE 的“引入参数”重构将表达式作为构造函数参数推出:

class CostSummary(
    val lines: List<CurrencyConversion>,
    total: Money
) {
    val total = total
}

示例 14.15 [accumulator.8:src/main/java/travelator/itinerary/CostSummary.kt] (diff)

现在total属性被标记为样式警告:IDE 检测到该属性可以在构造函数参数中声明。通过快捷键 Alt-Enter 可以将类声明为:

class CostSummary(
    val lines: List<CurrencyConversion>,
    val total: Money
)

示例 14.16 [accumulator.9:src/main/java/travelator/itinerary/CostSummary.kt] (diff)

与此同时,在CostSummaryCalculator中,IntelliJ 已经将计算合并到summarise方法中,代码看起来像这样:

fun summarise(): CostSummary {
    val lines = currencyTotals.values
        .sortedBy { it.currency.currencyCode }
        .map { exchangeRates.convert(it, userCurrency) }

    val total = lines
        .map { it.toMoney }
        .fold(Money.of(0, userCurrency), Money::add)

    return CostSummary(lines, total)
}

示例 14.17 [accumulator.9:src/main/java/travelator/itinerary/CostSummaryCalculator.kt] (diff)

现在我们可以将CostSummary定义为一个数据类。它的唯一责任是保存过滤、排序和显示计算结果的数据:

data class CostSummary(
    val lines: List<CurrencyConversion>,
    val total: Money
)

示例 14.18 [accumulator.10:src/main/java/travelator/itinerary/CostSummary.kt] (差异)

我们之前说过,可变状态会将算法模糊化,并将其在代码中传播。现在我们可以回顾一下,看看CostSummary确实如此。当我们到达时,计算总和被分割成了初始化一个可变的total属性并在addLine方法中更新它:

class CostSummary(userCurrency: Currency) {
    private val _lines = mutableListOf<CurrencyConversion>()

    var total: Money = Money.of(0, userCurrency)
        private set

    val lines: List<CurrencyConversion>
        get() = _lines.toList()

    fun addLine(line: CurrencyConversion) {
        _lines.add(line)
        total += line.toMoney
    }
}

示例 14.19 [accumulator.1:src/main/java/travelator/itinerary/CostSummary.kt] (差异)

现在计算是在summarise中的一个单一表达式:

val total = lines
    .map { it.toMoney }
    .fold(Money.of(0, userCurrency), Money::add)

示例 14.20 [accumulator.9:src/main/java/travelator/itinerary/CostSummaryCalculator.kt] (差异)

让我们再次做这件事

同样,货币相关的情况仍然隐藏在CostSummaryCalculator中的剩余突变中:

class CostSummaryCalculator(
    private val userCurrency: Currency,
    private val exchangeRates: ExchangeRates
) {
    private val currencyTotals = mutableMapOf<Currency, Money>()

    fun addCost(cost: Money) {
        currencyTotals.merge(cost.currency, cost, Money::add)
    }

    fun summarise(): CostSummary {
        val lines = currencyTotals.values
            .sortedBy { it.currency.currencyCode }
            .map { exchangeRates.convert(it, userCurrency) }

        val total = lines
            .map { it.toMoney }
            .fold(Money.of(0, userCurrency), Money::add)

        return CostSummary(lines, total)
    }

    fun reset() {
        currencyTotals.clear()
    }
}

示例 14.21 [accumulator.9:src/main/java/travelator/itinerary/CostSummaryCalculator.kt] (差异)

我们可以应用类似的过程来消除这些,但这次我们不会添加第二个构造函数。相反,我们将通过添加一个接受成本的summarise方法的重载来应用“扩展和收缩重构”:

fun summarise(costs: Iterable<Money>): CostSummary {
    val delegate = CostSummaryCalculator(userCurrency, exchangeRates)
    costs.forEach(delegate::addCost)
    return delegate.summarise()
}

示例 14.22 [accumulator.11:src/main/java/travelator/itinerary/CostSummaryCalculator.kt] (差异)

这很狡猾。旧的summarise方法是一个动作:它的结果取决于调用addCostreset的过去历史。这个新的summarise是一个计算:它的结果只取决于它的输入值(costs参数加上它访问的userCurrencyexchangeRates属性的值)。然而新的summarise使用了旧的方法;它只是将突变范围限制在一个局部变量中,将其转换为一个计算。

当我们使用这个版本的summarise时,我们对费用总结计算的上下文进行了区分,我们将其作为userCurrencyexchangeRates传递给构造函数,以及特定计算的参数(我们传递给summarise方法的costs)。这在后面会变得重要(“丰富我们发现的抽象”)。

现在我们有了两个summarise方法,我们可以将调用者移到新的方法上。为了切换到使用新的summarise,我们将不再要求实体将其成本添加到我们传递进去的可变计算器中,而是提取我们想要汇总的实体的成本,并组合它们。

我们最终将会这样使用计算器:

val fx: ExchangeRates = ...
val userCurrency = ...
val calculator = CostSummaryCalculator(userCurrency, fx)

fun costSummary(i: Itinerary) =
    calculator.summarise(i.costs())

示例 14.23 [accumulator.12:src/test/java/travelator/itinerary/Itinerary_CostTest.kt] (差异)

我们将从我们的领域模型报告成本如下:

data class Itinerary(
    val id: Id<Itinerary>,
    val route: Route,
    val accommodations: List<Accommodation> = emptyList()
) {
    ...

    fun costs(): List<Money> = route.costs() + accommodations.costs()
    ...
}

fun Iterable<Accommodation>.costs(): List<Money> = flatMap { it.costs() }

示例 14.24 [accumulator.12:src/main/java/travelator/itinerary/Itinerary.kt] (差异)

当应用程序中所有CostSummaryCalculator的使用都使用我们的新summarise方法时,我们可以将currencyTotalsCostSummary的计算合并到该方法中,目前该方法使用本地变量来完成:

fun summarise(costs: Iterable<Money>): CostSummary {
    val delegate = CostSummaryCalculator(userCurrency, exchangeRates)
    costs.forEach(delegate::addCost)
    return delegate.summarise()
}

示例 14.25 [accumulator.11:src/main/java/travelator/itinerary/CostSummaryCalculator.kt] (差异)

我们可以有效地将整个类内联到此方法中,使用本地变量而不是使用currencyTotals字段:

fun summarise(costs: Iterable<Money>): CostSummary {
    val currencyTotals = mutableMapOf<Currency, Money>()
    costs.forEach {
        currencyTotals.merge(it.currency, it, Money::plus)
    }
    val lines = currencyTotals.values
        .sortedBy { it.currency.currencyCode }
        .map { exchangeRates.convert(it, userCurrency) }
    val total = lines
        .map { it.toMoney }
        .fold(Money(0, userCurrency), Money::add)
    return CostSummary(lines, total)
}

示例 14.26 [accumulator.13:src/main/java/travelator/itinerary/CostSummaryCalculator.kt] (差异)

我们的测试仍然通过,并且 IntelliJ 告诉我们CostSummary​Cal⁠cu⁠lator的所有其他方法现在都未使用,currencyTotals字段也是如此,因此通过删除它们,我们最终成功地从类中移除了所有可变状态。但是并不是从那个方法中移除——我们仍然有一个可变映射!这是我们之前提到的算法扩散的最后残余。我们终于将所有逻辑带入了这一个方法中,因为我们所有的逻辑都在一个地方,我们知道它在一个时间内发生,并且安全地重构为任何等效形式。

那个形式是什么?我们必须考虑一下,但是得出的结论是MutableMap.merge正在按货币累积总数。由于我们现在同时拥有所有数据,我们可以通过按货币分组并对列表求和来执行相同的计算:

class CostSummaryCalculator(
    private val userCurrency: Currency,
    private val exchangeRates: ExchangeRates
) {
    fun summarise(costs: Iterable<Money>): CostSummary {
        val currencyTotals: List<Money> = costs
            .groupBy { it.currency }
            .values
            .map { moneys -> moneys.reduce(Money::add) }
        val lines: List<CurrencyConversion> = currencyTotals
            .sortedBy { it.currency.currencyCode }
            .map { exchangeRates.convert(it, userCurrency) }
        val total = lines
            .map { it.toMoney }
            .fold(Money(0, userCurrency), Money::add)
        return CostSummary(lines, total)
    }
}

示例 14.27 [accumulator.14:src/main/java/travelator/itinerary/CostSummaryCalculator.kt] (差异)

稍显恼人的是,我们必须使用reduce来求和金额,而不是拥有一个漂亮的Iterable<Money>.sum()扩展函数。我们应该修复这个问题。现在计算都在一个地方了,我们可以思考一下为什么在一个表达式中使用了reduce,而在另一个表达式中使用了fold(提示,这是有意义的),但这些都是因为现在代码都集中在一处而能够有的想法。

关键是我们现在可以更清楚地看到summarise计算的形状了。它是一个纯函数,应用于成本集合,并在一些汇率和旅行者首选货币的背景下进行评估。该函数将我们领域模型的嵌套实体转换为成本的平面集合,然后将成本转换为每种货币的总数的映射,再将每种Currency的总数转换为CurrencyConversion列表,最后将货币转换列表转换为CostSummary

提示

函数式程序将其输入转换为输出。

如果你不能轻松地一步写成,将输入转换为易于转换为输出的中间表示。

引入中间形式和转换,直到你有一个简单转换的管道,将中间形式之间的输入转换为你想要的输出。

我们将在第十六章更详细地讨论在上下文中评估的纯函数。

丰富我们发现的抽象

Travelator 在汇率和旅行者首选货币方面比简单成本汇总做得更多。例如,当用户浏览酒店房间时,它显示每个房间在本地货币和首选货币中的费用。也就是说,酒店房间浏览器对单个成本执行货币转换。CostSummary​Cal⁠cu⁠lator还必须对单个成本执行货币转换以计算汇总。如果我们将该功能提取为一个公共方法,我们可以称之为toUserCurrency,我们可以用CostSummary​Cal⁠cul⁠ator初始化酒店房间浏览器,而不是传递汇率和首选货币。我们还可以从酒店房间浏览器中删除重复的货币转换计算代码。

在那一点上,该类不再是成本汇总的计算器。它为任何单个旅行者的定价提供上下文。因此,让我们重新命名它以反映它的新职责。目前我们想不出比PricingContext更好的名称了,这使得我们的类看起来像这样:

class PricingContext(
    private val userCurrency: Currency,
    private val exchangeRates: ExchangeRates
) {
    fun toUserCurrency(money: Money) =
        exchangeRates.convert(money, userCurrency)

    fun summarise(costs: Iterable<Money>): CostSummary {
        val currencyTotals: List<Money> = costs
            .groupBy { it.currency }
            .values
            .map {
                it.sumOrNull() ?: error("Unexpected empty list")
            }
        val lines: List<CurrencyConversion> = currencyTotals
            .sortedBy { it.currency.currencyCode }
            .map(::toUserCurrency)
        val total = lines
            .map { it.toMoney }
            .sum(userCurrency)
        return CostSummary(lines, total)
    }
}

示例 14.28 [accumulator.16:src/main/java/travelator/itinerary/PricingContext.kt] (diff)

这样一来,之前使用CostSummaryCalculator的代码看起来像这样:

val fx: ExchangeRates = ...
val userCurrency = ...
val pricing = PricingContext(userCurrency, fx)

fun costSummary(i: Itinerary) = pricing.summarise(i.costs())

示例 14.29 [accumulator.16:src/test/java/travelator/itinerary/Itinerary_CostTest.kt] (变更)

现在我们在代码库中有了这个概念,我们可以识别出其他可以使用它的应用程序部分。我们可以将逻辑从这些部分移动到Pricing​Con⁠text上,使其成为将货币金额转换为旅行者首选货币的操作的一站式店。如果最终充满了不同用例的不同方法,那么我们可以将操作从方法移动到扩展函数中,以使它们更接近需要它们的地方(参见第十章)。

继续前进

我们在本章开始时使用了依赖共享的可变状态进行计算。这种做法复制了标准库中的逻辑,并引入了别名错误的风险。到了本章结束时,我们已经将同样的计算重构为对不可变数据的转换。

为了实现这一点,我们将变异从代码中移出,向两个方向进行:向外和向内。向外是显而易见的:我们让CostSummaryCalculatorCostSummary类视为不可变值类型,然后使CostSummary不可变。然后我们让CostSummaryCalculator的使用者将其视为计算的不可变上下文,然后使CostSummaryCalculator不可变。但是向内呢?我们用标准的高阶函数调用(如groupingByfoldreduce)替换了那些变异集合和字段的命令式代码。在幕后,这些函数可能会改变状态,但它们将这种变异隐藏在其调用者之外。从外部来看,这些函数就是计算。

当需要时,我们可以在我们自己的代码中采用相同的方法。有时候变异集合是最简单的事情。标准库并不总是具有按照我们希望的方式转换数据的高阶函数。如果我们确实需要可变集合,我们可以将该变异隐藏在计算中,以限制任何潜在别名错误的影响范围。然而,每个发布版本都会向标准库添加更多的函数,因此随着时间的推移,这种需求会减少。

函数式编程并不消除可变状态,而是将其责任委托给运行时。函数式程序声明了运行时应该计算的内容,并让运行时负责执行该计算。Kotlin 并不是纯函数式语言,但我们可以通过遵循这一原则来获益。

第十五章:封装集合到类型别名

在 Java 中,我们将对象集合封装在类中以控制变异并添加操作。在 Kotlin 中,控制变异的重要性较低,我们可以使用扩展函数来添加操作。如果没有封装,我们的设计会更好,如何做到这一点?

在 第六章 中,我们探讨了 Java 和 Kotlin 在处理集合时的差异。Java 的集合接口,符合其面向对象的根源,基本上是可变的,而 Kotlin 则将集合视为值类型。正如我们所见,如果我们改变共享集合,就会遇到各种问题。通过不改变共享集合,我们 可以 避免那些麻烦(“不要改变共享集合”),但在 Java 中,当 addset 方法仅仅是一个自动完成时,这很难做到。大多数 Java 代码通常选择更安全的方法,即简单地不共享原始集合,而是将集合隐藏在另一个对象中。

例如,在 Travelator 中,这是一个 Route

public class Route {
    private final List<Journey> journeys; ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)

    public Route(List<Journey> journeys) {
        this.journeys = journeys; ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/2.png)
    }

    public int size() { ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/3.png)
        return journeys.size();
    }

    public Journey get(int index) { ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/3.png)
        return journeys.get(index);
    }

    public Location getDepartsFrom() { ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/4.png)
        return get(0).getDepartsFrom();
    }

    public Location getArrivesAt() { ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/4.png)
        return get(size() - 1).getArrivesAt();
    }

    public Duration getDuration() { ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/4.png)
        return Duration.between(
            get(0).getDepartureTime(),
            get(size() - 1).getArrivalTime());
    }

    ...
}

示例 15.1 [encapsulated-collections.0:src/main/java/travelator/itinerary/Route.java] (diff)

1

Route 封装了 JourneyList

2

数据原始数据通过构造函数传递。

3

访问数据,例如用于 UI 显示,由 sizeget 方法提供。

4

Route 类实现应用逻辑,使用封装列表的内容。

一旦有了 Route 类,它就是一个方便的命名空间,用于托管路由操作,比如 getDepartsFromgetDuration。在这种情况下,所有显示的方法仅使用其他公共方法,并且没有多态行为,因此这些操作 可以 定义为接受 Route 参数的静态方法。我们可以将 Route 视为更像是一个命名空间而不是一个类:操作不一定 必须 是方法;只是在 Java 中,静态函数不像方法那样易于发现。在 Kotlin 中,正如我们在 第十章 中看到的,将操作转换为扩展函数会让我们像调用方法一样找到并调用它们。Route 作为一个类,然后就不会为 List of Journey 添加任何值,只是阻止人们更改它。在全部使用 Kotlin 的代码库中,该 List 本质上是不可变的。

实际上,RouteList<Journey> 添加了更少的值。如果我们有一个 List<Journey>,我们的前端代码可以在渲染时使用它的 Iterator

public void render(Iterable<Journey> route) {
    for (var journey : route) {
        render(journey);
    }
}

示例 15.2 [封装集合.0:src/main/java/travelator/UI.java] (差异)

使用Route,我们回到了上世纪 80 年代的编程方式:

public void render(Route route) {
    for (int i = 0; i < route.size(); i++) {
        var journey = route.get(i);
        render(journey);
    }
}

示例 15.3 [封装集合.0:src/main/java/travelator/UI.java] (差异)

如果我们封装一个集合,我们减少了可以用于处理其内容的操作,仅限于封装类定义的那些操作。当我们想以新的方式处理数据时,最容易的方法是向类中添加新方法。我们添加的方法越多,类增加了应用程序不同部分之间的耦合。在我们意识到之前,为了支持新的 UI 功能而添加一个操作会导致重新编译我们的数据访问层。

组合领域集合

如果我们不将集合封装起来——如果我们使我们的领域模型成为合适的数据结构,而不是将其隐藏在另一个类边界内——我们扩展了可用于处理数据的操作。这样,我们就拥有了应用特定的操作集合定义的所有操作。客户端代码可以根据丰富的集合 API 定义它所需的操作,而无需将它们添加到类中。

与其让一个Route类吸纳所有的路由功能,从而耦合应用程序的所有部分,我们可以将功能视为通过导入扩展函数来组合的操作。UI 可以定义渲染List<Journey>的函数,然后导入转换Iterable<Journey>的函数。持久化层可以将数据库响应转换为List<Journey>,并且根本不需要特定的“路由”概念。

我们可以在 Java 中这样编程,但是静态函数的可发现性不足,再加上可变集合,与语言的本质相悖。Kotlin 提供了扩展函数以增强静态函数的可发现性,并提供了不可变集合,这样将我们的领域模型拆分为集合类型和单独的操作变得更加顺畅。

如果我们不需要控制对集合的访问以防止尴尬的变异,并且我们不需要编写一个类来托管特定类型的集合操作,那么我们的Route类对我们来说有什么作用呢?嗯,它为List<Journey>提供了一个名称,还为这个List<Journey>提供了一个类型,这可能使它与其他List<Journey>有所区别——例如关于本周旅行者预订的所有行程的报告。除此之外,在某些方面,它实际上会妨碍我们,正如我们将在“替代类型别名”中看到的那样。

关键区分不同类型的旅程列表时,Kotlin 允许我们使用类型别名将名称RouteList<Journey>关联起来,而不必使用类来实现这一点:

typealias Route = List<Journey>

在 Kotlin 中,消除了将集合用作领域类型的障碍。封装不可变集合应该是例外而不是规则。

具有其他属性的集合

当然,我们并不总是可以简单地用类型别名替换类。例如,看看我们的Itinerary类:

class Itinerary(
    val id: Id<Itinerary>,
    val route: Route
) {
    ...
}

示例 15.4 [封装集合.0:src/main/java/travelator/itinerary/Itinerary.kt] (diff)

除了其route中当前隐藏的Journey之外,Itinerary还具有一个Id,允许我们将其视为实体。在这些情况下,我们不能简单地用其集合替换类。

在这些情况下,通过使Itinerary实现List<Journey>,我们可以获得许多未封装集合的优势。但是现在很难做到这一点,因为Route本身并没有实现该接口,但随着更多领域被表达为完整集合,这是一个好策略。我们将在“具有其他属性的集合重构”中实现这一点。

重构封装集合

我们 Travelator 应用程序的核心服务之一是路线规划。

我们之前看到的Route是一系列旅程,可以将旅行者从一个地点带到另一个地点。我们希望添加一些功能,使我们能够销售住宿,其中Route在几天内分割,但作为关键的领域抽象,Route正在因我们已经添加到其中的所有操作而变得笨重,并将代码库中不同的部分耦合在一起。让我们看看是否可以重构Route以腾出一些空间,然后再开始新功能的工作。

这里再次是 Java Route类:

public class Route {
    private final List<Journey> journeys;

    public Route(List<Journey> journeys) {
        this.journeys = journeys;
    }

    public int size() {
        return journeys.size();
    }

    public Journey get(int index) {
        return journeys.get(index);
    }

    public Location getDepartsFrom() {
        return get(0).getDepartsFrom();
    }

    ... many methods
}

[ 示例 15.5 [封装集合.1:src/main/java/travelator/itinerary/Route.java] (diff)

将操作转换为扩展

通过将其操作从方法转移到函数,我们将使Route更加不笨重(甚至更加灵巧)。扩展函数使这成为一个合理的策略,但仅限于 Kotlin,因为在那里它们更容易被发现。因此,我们只会在大多数情况下使用 Kotlin 时尝试这一技巧。幸运的是,我们的团队非常喜欢将 Java 转换为 Kotlin,并且一直在通过本书的章节进行努力,所以我们准备尝试这种重构。

最终,我们希望解封装集合,使我们的客户端使用List<Journey>而不是Route来工作,并且操作由该List<Journey>上的扩展函数提供。

Route类转换为 Kotlin 后,稍作整理,得到:

class Route(
    private val journeys: List<Journey>
) {
    fun size(): Int = journeys.size

    operator fun get(index: Int) = journeys[index]

    val departsFrom: Location
        get() = get(0).departsFrom

    ... many methods
}

示例 15.6 [encapsulated-collections.2:src/main/java/travelator/itinerary/Route.kt] (diff)

和往常一样,假设我们在重构之间运行测试,以确保我们没有破坏任何东西。目前一切正常。

一旦类转为 Kotlin,IntelliJ 可以将方法转换为扩展方法。让我们在departsFrom属性上尝试这种重构:选择它,按下 Alt-Enter,然后选择“Convert member to extension”。方法消失并重新出现在文件的顶层:

val Route.departsFrom: Location
    get() = get(0).departsFrom

示例 15.7 [encapsulated-collections.3:src/main/java/travelator/itinerary/Route.kt] (diff)

Kotlin 代码将继续能够作为属性访问route.departsFrom,但 Java 代码不能。IntelliJ 已经很好地将唯一的 Java 使用修复为查看属性作为静态方法:

public void renderWithHeader(Route route) {
    renderHeader(
        RouteKt.getDepartsFrom(route), ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
        route.getArrivesAt(),
        route.getDuration()
    );
    for (int i = 0; i < route.size(); i++) {
        var journey = route.get(i);
        render(journey);
    }
}

示例 15.8 [encapsulated-collections.3:src/main/java/travelator/UI.java] (diff)

1

Route.kt中调用静态方法

“Convert member to extension”对只调用Route公共 API 的方法效果很好。如果我们尝试对withJourneyAt进行转换,它将失败:

fun withJourneyAt(index: Int, replacedBy: Journey): Route {
    val newJourneys = ArrayList(journeys)
    newJourneys[index] = replacedBy
    return Route(newJourneys)
}

示例 15.9 [encapsulated-collections.3:src/main/java/travelator/itinerary/Route.kt] (diff)

这引用了当前私有的journeys属性,因此对扩展函数不可见。在此时,我们可以将该属性公开(前提是我们不会通过 Java 代码滥用对List的突变)。这修复了扩展函数:

fun Route.withJourneyAt(index: Int, replacedBy: Journey): Route {
    val newJourneys = ArrayList(journeys)
    newJourneys[index] = replacedBy
    return Route(newJourneys)
}

示例 15.10 [encapsulated-collections.4:src/main/java/travelator/itinerary/Route.kt] (diff)

我们可以继续将成员转换为扩展,直到没有成员剩余;即使sizeget也可以移出,前提是我们愿意在任何剩余的 Java 客户端静态使用它们:

public void render(Route route) {
    for (int i = 0; i < RouteKt.getSize(route); i++) {
        var journey = RouteKt.get(route, i);
        render(journey);
    }
}

示例 15.11 [encapsulated-collections.5:src/main/java/travelator/UI.java] (diff)

(请注意,由于我们已将size方法转换为size扩展属性,Java 看到的是getSize函数。)

因此,这就是曾经臃肿的Route类留下的所有内容:

class Route(
    val journeys: List<Journey>
)

val Route.size: Int
    get() = journeys.size

operator fun Route.get(index: Int) = journeys[index]

...

示例 15.12 [encapsulated-collections.5:src/main/java/travelator/itinerary/Route.kt] (diff)

现在它的所有操作(除了访问journeys)都是扩展函数,尽管在同一个文件中。但现在它们 扩展函数,我们可以将它们从这个文件移到其他文件,甚至是不同的模块,以更好地解耦我们的依赖关系。

替换类型别名

现在我们已经实现了将Route功能从类中解耦的目标,那这个类是否多余呢?实际上,包装List不仅仅是多余的:它阻止我们轻松地使用 Kotlin 标准库中所有有用的扩展函数来构造、转换和处理路由。引用 Alan Perlis 的《编程的箴言》中的一句话:“与其让 10 个函数操作 10 种数据结构,不如让 100 个函数操作一个数据结构。”我们不希望Route 拥有一个List of Journey;我们希望它 一个List of Journey。在 Kotlin 中通过委托这是非常容易实现的:

class Route(
    val journeys: List<Journey>
) : List<Journey> by journeys

示例 15.13 [encapsulated-collections.6:src/main/java/travelator/itinerary/Route.kt] (diff)

事实上,我们可能不只是希望RouteList of Journey;我们可能希望List of Journey 是一个Route。为了理解为什么,让我们看看之前忽略的withJourneyAt函数。

当旅行者决定不再乘坐骆驼时,我们不能简单地替换一个Journey,因为Route是不可变的。相反,我们返回一个新的Route,其中journeys是一个替换了相关Journey的副本:

@Test
fun replaceJourney() {
    val journey1 = Journey(waterloo, alton, someTime(), someTime(), RAIL)
    val journey2 = Journey(alton, alresford, someTime(), someTime(), CAMEL)
    val journey3 = Journey(alresford, winchester, someTime(), someTime(), BUS)
    val route = Route(listOf(journey1, journey2, journey3))

    val replacement = Journey(alton, alresford, someTime(), someTime(), RAIL)
    val replaced = route.withJourneyAt(1, replacement)

    assertEquals(journey1, replaced.get(0))
    assertEquals(replacement, replaced.get(1))
    assertEquals(journey3, replaced.get(2))
}

示例 15.14 [encapsulated-collections.5:src/test/java/travelator/itinerary/RouteTests.kt] (diff)

(顺便说一句,注意,由于只能使用get来访问route的组件而使得这个测试变得更加复杂。我们现在可以通过直接访问journeys属性来修复这个问题。)

再次看看实现:

fun Route.withJourneyAt(index: Int, replacedBy: Journey): Route {
    val newJourneys = ArrayList(journeys)
    newJourneys[index] = replacedBy
    return Route(newJourneys)
}

示例 15.15 [encapsulated-collections.4:src/main/java/travelator/itinerary/Route.kt] (diff)

因为Route包装了journeys,我们不能简单地操作journeys;我们必须解包、操作,然后重新包装。如果List<Journey>是一个Route,那么我们可以使用一个很好的泛型函数,例如:

fun <T> Iterable<T>.withItemAt(index: Int, replacedBy: T): List<T> =
    this.toMutableList().apply {
        this[index] = replacedBy
    }

示例 15.16 [encapsulated-collections.7:src/main/java/travelator/itinerary/Route.kt] (diff)

就像现在一样,即使使用withItemAt,我们仍然必须处理包装器:

fun Route.withJourneyAt(index: Int, replacedBy: Journey): Route =
    Route(journeys.withItemAt(index, replacedBy))

示例 15.17 [封装集合.7:src/main/java/travelator/itinerary/Route.kt] (diff)

任何转换Route的操作都会遇到这个问题——这是一个问题,如果我们仅仅使用类型别名来表示RouteList<Journey>是相同的类型,这个问题就不存在了。

要达到这一点,我们必须删除所有对Route构造函数的调用以及对journeys属性的访问,实际上是展开我们精心设计的封装。虽然有一个自动化的技巧可以做到这一点,但它依赖于将所有Route的客户端转换为 Kotlin。使用类型别名也是如此,因此如果还有任何 Java 客户端,我们不得不接受一些手动编辑。

我们要做的是用类型别名替换类,并同时添加临时定义来模拟类的 API。当前的 API 是:

class Route(
    val journeys: List<Journey>
) : List<Journey> by journeys

示例 15.18 [封装集合.6:src/main/java/travelator/itinerary/Route.kt] (diff)

我们用以下方式模拟它:

typealias Route = List<Journey>

fun Route(journeys: List<Journey>) = journeys

val Route.journeys get() = this

示例 15.19 [封装集合.8:src/main/java/travelator/itinerary/Route.kt] (diff)

因为在 Kotlin 中没有new关键字,我们可以用同名函数来模拟构造函数调用Route(...)。类似地,我们用返回接收者本身的扩展属性来替换journeys属性。其结果是我们的 Kotlin 客户端继续针对这个新 API 编译:

val route = Route(listOf(journey1, journey2, journey3)) ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)

val replacement = Journey(alton, alresford, someTime(), someTime(), RAIL)

assertEquals(
    listOf(journey1, replacement, journey3),
    route.withJourneyAt(1, replacement).journeys ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/2.png)
)

示例 15.20 [封装集合.8:src/test/java/travelator/itinerary/RouteTests.kt] (diff)

1

我们的新函数,而不是构造函数

2

扩展属性,而不是类属性

内联函数和属性的完成重构。封装的集合现在只是一个集合:

val route = listOf(journey1, journey2, journey3) ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)

val replacement = Journey(alton, alresford, someTime(), someTime(), RAIL)

assertEquals(
    listOf(journey1, replacement, journey3),
    route.withJourneyAt(1, replacement) ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/2.png)
)

示例 15.21 [封装集合.9:src/test/java/travelator/itinerary/RouteTests.kt] (diff)

1

Route是一个空操作

2

以及journeys

当我们用类型别名替换Route类时,任何剩余的 Java 客户端都会出问题,因为 Java 不理解类型别名。我们通过手动替换RouteList<Journey>来修复这些问题:

public void render(List<Journey> route) {
    for (int i = 0; i < RouteKt.getSize(route); i++) {
        var journey = RouteKt.get(route, i);
        render(journey);
    }
}

示例 15.22 [封装集合.8:src/main/java/travelator/UI.java] (差异)

我们的转换几乎完成。我们仍然有 sizeget 函数:

val Route.size: Int
    get() = this.size

operator fun Route.get(index: Int) = this[index]

示例 15.23 [封装集合.9:src/main/java/travelator/itinerary/Route.kt] (差异)

因为这些与其方法在 List 上的签名相同,编译器警告我们它们被遮蔽;我们的 Kotlin 将调用方法,而不是扩展。这意味着如果没有任何 Java 客户端代码将扩展调用为静态方法,我们可以删除它们。

不过,我们确实有一个 Java 客户端——那个讨厌的渲染代码,它仍然在 RouteKt 中调用扩展作为 getSizeget。这些扩展调用了我们想要使用的方法,但我们无法从 Kotlin 内联到 Java,所以我们将无论如何删除这些扩展。现在编译器将告诉我们需要手动修复 Java 的地方:

public void render(List<Journey> route) {
    for (int i = 0; i < route.size(); i++) {
        var journey = route.get(i);
        render(journey);
    }
}

示例 15.24 [封装集合.10:src/main/java/travelator/UI.java] (差异)

实际上,我们会用以下内容替换:

public void render(Iterable<Journey> route) {
    for (var journey : route) {
        render(journey);
    }
}

示例 15.25 [封装集合.10:src/main/java/travelator/UI.java] (差异)

Kotlin 客户端对删除扩展并不感到意外,因为它们一直在调用 List 上的方法,所以转换几乎完成。现在我们还可以内联 withJourneyAt,因为它也是一个无操作。这样,Route 就像这样:

typealias Route = List<Journey>

val Route.departsFrom: Location
    get() = first().departsFrom

val Route.arrivesAt: Location
    get() = last().arrivesAt

val Route.duration: Duration
    get() = Duration.between(
        first().departureTime,
        last().arrivalTime
    )
... other operations moved

示例 15.26 [封装集合.10:src/main/java/travelator/itinerary/Route.kt] (差异)

我们的 Kotlin 使用只是 List 操作:

val route = listOf(journey1, journey2, journey3)
assertEquals(
    listOf(journey1, replacement, journey3),
    route.withItemAt(1, replacement)
)

示例 15.27 [封装集合.10:src/test/java/travelator/itinerary/RouteTests.kt] (差异)

任何残留的 Java 代码是可读的,尽管有点丑陋:

public void renderWithHeader(List<Journey> route) {
    renderHeader(
        RouteKt.getDepartsFrom(route),
        RouteKt.getArrivesAt(route),
        RouteKt.getDuration(route)
    );
    for (var journey : route) {
        render(journey);
    }
}

示例 15.28 [封装集合.10:src/main/java/travelator/UI.java] (差异)

用其他属性重构集合

正如我们之前看到的,当我们的类型包含其他属性的集合时,我们无法使用类型别名。我们看了一下 Itinerary,它将 idRoute 结合在一起:

class Itinerary(
    val id: Id<Itinerary>,
    val route: Route
) {

    fun hasJourneyLongerThan(duration: Duration) =
        route.any { it.duration > duration }

    ...
}

示例 15.29 [封装集合.11:src/main/java/travelator/itinerary/Itinerary.kt] (diff)

通过代理实现Route,我们可以直接查询Journey带来的好处:

class Itinerary(
    val id: Id<Itinerary>,
    val route: Route
) : Route by route { ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)

    fun hasJourneyLongerThan(duration: Duration) =
        any { it.duration > duration }

    ...
}

示例 15.30 [封装集合.12:src/main/java/travelator/itinerary/Itinerary.kt] (diff)

1

by route子句声明Itinerary对象将所有Route接口上的方法委托给其构造函数传递的route参数。类可以通过提供委托接口的自己实现来覆盖此行为,但我们不希望对Itinerary这样做。

现在我们可以将Itinerary视为Route,我们可以将hasJourneyLongerThan作为扩展移出,并使其对任何Route都可用,而不仅仅是对Itinerary

fun Route.hasJourneyLongerThan(duration: Duration) =
    any { it.duration > duration }

示例 15.31 [封装集合.13:src/main/java/travelator/itinerary/Itinerary.kt] (diff)

所有那些我们从方法移动到扩展中的Route(又名List<Journey>)也同样适用于Itinerary

fun Iterable<Itinerary>.shortest() =
    minByOrNull {
        it.duration ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
    }

示例 15.32 [封装集合.13:src/main/java/travelator/itinerary/itineraries.kt] (diff)

1

这是Route.duration,也就是List<Journey>.duration

唯一不能如此轻易实现的是从现有的Itinerary创建一个新的Itinerary。这在Route中现在变得很容易,因为在List<Journey>(实际上通常是Iterable<Journey>,正如我们在第六章中看到的)上的标准 API 操作返回List<Journey>,这也是Route的另一个名称:

fun Route.withoutJourneysBy(travelMethod: TravelMethod) =
    this.filterNot { it.method == travelMethod }

示例 15.33 [封装集合.13:src/main/java/travelator/itinerary/itineraries.kt] (diff)

对于Itinerary,我们必须创建一个新的Itinerary以重新包装结果:

fun Itinerary.withoutJourneysBy(travelMethod: TravelMethod) =
    Itinerary(
        id,
        this.filterNot { it.method == travelMethod }
    )

示例 15.34 [封装集合.13:src/main/java/travelator/itinerary/itineraries.kt] (diff)

这又是另一个数据类发挥作用的地方:

data class Itinerary(
    val id: Id<Itinerary>,
    val route: Route
) : Route by route {

    ...
}

示例 15.35 [封装集合.14:src/main/java/travelator/itinerary/Itinerary.kt] (diff)

Itinerary作为数据类意味着我们可以仅仅通过修改路线来复制它,无论它还有多少其他属性:

fun Itinerary.withoutJourneysBy(travelMethod: TravelMethod) =
    copy(route = filterNot { it.method == travelMethod } )

示例 15.36 [封装集合.14:src/main/java/travelator/itinerary/itineraries.kt] (diff)

更好的是,我们可以添加一个方法withTransformedRoute

data class Itinerary(
    val id: Id<Itinerary>,
    val route: Route
) : Route by route {

    fun withTransformedRoute(transform: (Route).() -> Route) =
        copy(route = transform(route))

    ...
}

示例 15.37 [封装集合.15:src/main/java/travelator/itinerary/Itinerary.kt] (diff)

这使我们能够创建一个转换后的Itinerary几乎和创建一个转换后的Route一样容易:

fun Itinerary.withoutJourneysBy(travelMethod: TravelMethod) =
    withTransformedRoute {
        filterNot { it.method == travelMethod }
    }

fun Itinerary.withoutLastJourney() =
    withTransformedRoute { dropLast(1) }

示例 15.38 [封装集合.15:src/main/java/travelator/itinerary/itineraries.kt] (diff)

继续前进

我们从一个 Java 类开始,该类封装了可变集合以保证值语义。随着我们将更多代码转换为 Kotlin,我们可以依赖 Kotlin 的类型系统来防止修改集合,不再需要将其封装在类中。这使我们可以将操作从方法转换为扩展,并将它们的定义移动到它们被使用的地方附近。因为我们的类封装了单个集合,所以我们能够完全消除这个类,并用类型别名替换它。

不可变集合和扩展使我们能够以 Java 不可用的方式组织代码。我们可以将应用程序特定功能所需的所有逻辑组织在同一个模块中,而不考虑逻辑适用的领域类。但是,如果我们希望这些领域类的方法成为多态方法,我们必须在这些类上定义它们,而不是在我们的特性模块中定义。在第十八章,开放到密封类,我们将看到密封类,这是一种替代面向对象多态性的方式,当我们在代码的一部分定义类型层次结构并在另一部分上对这些类型进行操作时,这种方式更为方便。

最后,请注意,重用像List这样的内置类型而不是定义特定类型并非没有代价。我们可能将项目存储在List中作为实现细节而不是建模选择。针对特定包装类查找“使用情况”要比查找通用特化要容易得多。然而,标准集合类型是普遍存在的,因为它们是如此良好的抽象——所以好,以至于我们通常不应该隐藏它们。第二十二章,从类到函数,探讨了如果我们采纳这个想法会发生什么。

第十六章:函数接口

在 Java 中,我们使用接口来指定定义某些功能的代码和需要该功能的代码之间的合同。这些接口将这两个方面耦合在一起,这可能会使我们的软件更难维护。函数类型如何帮助解决这个问题?

想象一下,如果可以的话,你需要从你正在编写的一些代码中发送电子邮件。现在只是这样——不接收邮件,不列出已发送的消息——只是点火并忘记。

描述电子邮件的代码足够简单:

data class Email(
    val to: EmailAddress,
    val from: EmailAddress,
    val subject: String,
    val body: String
)

给定一个Email,客户端代码希望调用发送它的最简单的函数,即:

fun send(email: Email) {
    ...
}

当然,当我们来实现这个函数时,我们发现要实际发送电子邮件,我们需要各种其他信息。不是电子邮件本身的信息,而是关于如何发送它的配置信息。诸如发送服务器的主机名和安全凭据——所有这些你的非技术亲戚不知道的东西,但你需要设置他们新电脑时。我们将向sendEmail添加三个额外的参数来代表所有这些配置:

fun sendEmail(
    email: Email,
    serverAddress: InetAddress,
    username: String,
    password: String
) {
    ...
}

作为客户端,事情变得不那么方便了。我们想要发送电子邮件的每个地方都必须知道这个配置;我们将在代码库的顶部和底部之间传递它。通过将细节隐藏在全局变量中来解决这个问题,在发现每次运行单元测试套件现在都发送 50 封电子邮件时,这种方法就很好用!必须有一种更好的方法来隐藏这些琐碎的细节。

面向对象封装

面向对象语言对这个问题有现成的解决方案——对象可以封装数据:

class EmailSender(
    private val serverAddress: InetAddress,
    private val username: String,
    private val password: String
) {
    fun send(email: Email) {
        sendEmail(
            email,
            serverAddress,
            username,
            password
        )
    }
}

现在当我们想发送电子邮件时,我们需要访问一个EmailSender(而不是静态函数)。一旦我们有了EmailSender,我们不是调用一个函数,而是调用一个方法,我们不需要告诉方法所有琐碎的细节,因为它已经知道它们;它们是其类的字段:

// Where we know the configuration
val sender: EmailSender = EmailSender(
    inetAddress("smtp.travelator.com"),
    "username",
    "password"
)

// Where we send the message
fun sendThanks() {
    sender.send(
        Email(
            to = parse("support@internationalrescue.org"),
            from = parse("support@travelator.com"),
            subject = "Thanks for your help",
            body = "..."
        )
    )
}

通常情况下,我们知道配置的地方与我们想要发送电子邮件的地方在代码中是分离的,通常有许多层次。通常在面向对象中,sender将被捕获为类的属性,并由其方法使用:

// Where we know the configuration
val subsystem = Rescuing(
    EmailSender(
        inetAddress("smtp.travelator.com"),
        "username",
        "password"
    )
)

// Where we send the message
class Rescuing(
    private val emailSender: EmailSender
) {
    fun sendThanks() {
        emailSender.send(
            Email(
                to = parse("support@internationalrescue.org"),
                from = parse("support@travelator.com"),
                subject = "Thanks for your help",
                body = "..."
            )
        )
    }
}

我们经常会提取一个接口:

interface ISendEmail {
    fun send(email: Email)
}
class EmailSender(
        ...
) : ISendEmail {
    override fun send(email: Email) {
        sendEmail(
            email,
            serverAddress,
            username,
            password
        )
    }
}

如果我们的客户端代码依赖于ISendEmail接口而不是EmailSender类,我们可以配置我们的测试使用ISendEmail的假实现,它实际上不发送电子邮件,而是允许我们检查如果发送了电子邮件会发送什么。我们不仅可以提供根本不发送电子邮件的伪装,还可以提供不同的真实实现,如SmtpEmailSenderX400EmailSender,每个实现都会向其客户端隐藏其配置和实现。我们最初为信息隐藏而来,却为实现隐藏而留下了。

当我们说 隐藏 时,听起来有点贬义,但这种隐藏对客户端和实现者都很有用。前者不必在使用点提供配置详细信息;后者可以与其用户分离演化(只要它不改变 API,在接口中表达)。

在我们离开面向对象的领域之前,请注意我们不必创建一个命名类来实现ISendEmail;我们可以匿名地完成它:

fun createEmailSender(
    serverAddress: InetAddress,
    username: String,
    password: String
): ISendEmail =
    object : ISendEmail {
        override fun send(email: Email) =
            sendEmail(
                email,
                serverAddress,
                username,
                password
            )
    }

为什么我们要这样做呢?嗯,当我们无法控制我们代码的所有客户端时(例如,我们发布一个组织外的库),这样做使我们可以灵活地更改我们的实现,而不必担心客户端依赖于特定的实现类并通过向下转型调用其他方法。我们称之为 闭包 的对象,在此处关闭它需要的值(函数调用的上下文),以便以后引用。

在 Kotlin 1.4 中,我们可以将我们的 ISendEmail 接口声明为 fun interface(只有一个抽象方法)。这样,我们可以用 lambda 定义单个操作的实现,而不是使用一个只有一个方法的对象:

fun interface ISendEmail {
    fun send(email: Email)
}

fun createEmailSender(
    serverAddress: InetAddress,
    username: String,
    password: String
) = ISendEmail { email ->
    sendEmail(
        email,
        serverAddress,
        username,
        password
    )
}

再次强调,这里的 lambda 是一个闭包,捕获其封闭函数参数的值。

函数封装

看到面向对象程序员如何解决封装繁琐细节的问题,使得客户端无需在使用点提供它们,那么函数式程序员如何解决同样的问题呢?

记住,我们试图实现一个具有这个签名的函数:

fun send(email: Email) {
    ...
}

但实际上我们需要所有这些信息来发送消息:

fun sendEmail(
    email: Email,
    serverAddress: InetAddress,
    username: String,
    password: String
) {
    ...
}

从功能的角度来看,这是 部分应用 的一个例子:固定函数的一些参数以产生具有更少参数的函数。尽管有些语言提供了内置支持,但在 Kotlin 中,最简单的方法是编写一个函数来部分应用我们的配置。

我们想要的是一个函数,它接受配置并返回一个知道如何发送电子邮件的函数:

fun createEmailSender(
    serverAddress: InetAddress,
    username: String,
    password: String
): (Email) -> Unit { ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
    ...
}

1

我们函数的返回类型本身就是一个接受 Email 并返回 Unit 的函数。

因此,createEmailSender 是一个构造函数。不是类的构造函数,而是扮演同样角色的函数。createEmailSender::EmailSender 都是返回一个知道如何发送消息的对象的函数。

要了解这在函数中如何运作,我们可以先用长写法来定义一个内部函数,它捕获其父级所需的参数:

fun createEmailSender(
    serverAddress: InetAddress,
    username: String,
    password: String
): (Email) -> Unit {

    fun result(email: Email) {
        sendEmail(
            email,
            serverAddress,
            username,
            password
        )
    }
    return ::result
}

然后我们可以将结果转化为一个 lambda 表达式:

fun createEmailSender(
    serverAddress: InetAddress,
    username: String,
    password: String
): (Email) -> Unit {

    val result: (Email) -> Unit =
        { email ->
            sendEmail(
                email,
                serverAddress,
                username,
                password
            )
        }
    return result
}

如果我们内联 result 并将整个函数转换为单一表达式,我们得到以下函数定义:

fun createEmailSender(
    serverAddress: InetAddress,
    username: String,
    password: String
): (Email) -> Unit =
    { email ->
        sendEmail(
            email,
            serverAddress,
            username,
            password
        )
    }

因此,createEmailSender 是一个返回 lambda 的函数,该 lambda 调用 sendEmail,将 lambda 的单个 Email 参数与自己参数的配置结合起来。这在函数式编程中是一个闭包,这与带有 fun interfaceobject 定义的 OO 版本非常相似,这并非巧合。

要使用此函数,我们可以在一个地方创建它,在另一个地方调用它,非常类似于对象解决方案:

// Where we know the configuration val sender: (Email) -> Unit = createEmailSender(
    inetAddress("smtp.travelator.com"),
    "username",
    "password"
)

// Where we send the message fun sendThanks() {
    sender( ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
        Email(
            to = parse("support@internationalrescue.org"),
            from = parse("support@travelator.com"),
            subject = "Thanks for your help",
            body = "..."
        )
    )
}

1

这里隐藏了一个隐式的 invoke 调用。

这与 OO 案例相同(如果我们将隐藏的invoke替换为send):

fun sendThanks() {
    sender.send(
        Email(
            to = parse("support@internationalrescue.org"),
            from = parse("support@travelator.com"),
            subject = "Thanks for your help",
            body = "..."
        )
    )
}

如果您是从 JavaScript 或 Clojure 转来,函数形式可能很熟悉,但如果您从 Java 来到 Kotlin,这种解决方案可能会感觉非常陌生。

Java 中的函数类型

对象形式和函数形式都允许我们封装事物(在本例中是配置,但同样可以是协作者),将它们从已知位置传输到使用位置。任何数据结构都可以做到这一点,但因为对象和函数都有可以运行的操作(分别是sendinvoke),客户端可以对配置的细节毫不知情,只需传递每次调用特定的信息(Email)。

统一函数式和 OO 解决方案的一种方法是将函数视为具有单个 invoke 方法的对象。这正是 Java 8 引入 lambda 时所做的。为了引用函数类型,Java 使用具有所需签名的单抽象方法(SAM)的接口。Java 中的 lambda 是一种特殊的语法,用于实现 SAM 接口。Java 运行时定义了按角色命名的 SAM 接口:ConsumerSupplierFunctionBiFunctionPredicate 等等。它还提供了原始特化,如 DoublePredicate,以避免装箱问题。

用 Java 表达的函数式解决方案是:

// Where we know the configuration Consumer<Email> sender = createEmailSender(
    inetAddress("example.com"),
    "username",
    "password"
);

// Where we send the message public void sendThanks() {
    sender.accept( ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
        new Email(
            parse("support@internationalrescue.org"),
            parse("support@travelator.com"),
            "Thanks for your help",
            "..."
        )
    );
}

1

acceptConsumer 接口上的单抽象方法的名称。

createEmailSender 可以用 lambda 实现:

static Consumer<Email> createEmailSender(
    InetAddress serverAddress,
    String username,
    String password
) {
    return email -> sendEmail(
        email,
        serverAddress,
        username,
        password
    );
}

这相当于创建接口的匿名实现,这种技术对于之前在 Java 中编程的人来说非常熟悉:

static Consumer<Email> createEmailSender(
    InetAddress serverAddress,
    String username,
    String password
) {
    return new Consumer<Email>() {
        @Override
        public void accept(Email email) {
            sendEmail(
                email,
                serverAddress,
                username,
                password
            );
        }
    };
}

我们说“相当于创建接口的匿名实现”,但在幕后,该实现更复杂,以避免不必要地定义类和实例化对象。

请注意,我们不能将 Kotlin createEmailSender(Email) -> Unit 结果分配给类型为 Consumer<Email> 的变量。这是因为 Kotlin 运行时使用其自己的函数类型,并且编译器将 (Email) -> Unit 编译为 Function1<Email, Unit>。对于不同数量参数,Kotlin 还有一系列的 FunctionN 接口。

由于接口不兼容,在这个功能级别上混合使用 Java 和 Kotlin 时,有时我们需要 thunk。给定一个 Kotlin 函数类型(Email) -> Unit

// Kotlin function type
val sender: (Email) -> Unit = createEmailSender(
    inetAddress("smtp.travelator.com"),
    "username",
    "password"
)

我们不能简单地将sender赋给Consumer<Email>

val consumer: Consumer<Email> = sender // Doesn't compile ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)

1

类型不匹配。需要:Consumer<Email> 找到:(Email) -> Unit

不过,我们可以通过 lambda 表达式进行转换:

val consumer: Consumer<Email> = Consumer<Email> { email ->
    sender(email)
}

有一种情况无需转换,即调用接受 SAM 参数的 Java 方法,例如这个构造函数:

class Rescuing {
    private final Consumer<Email> emailSender;

    Rescuing(Consumer<Email> emailSender) {
        this.emailSender = emailSender;
    }
    ...
}

在这里,编译器确实能够将(Email) -> Unit转换为Consumer<Email>,因为 Kotlin 会自动转换参数,这样我们可以说:

Rescuing(sender)

混合和匹配

抽象有两个方面,客户端代码和实现代码。到目前为止,客户端和实现者都是面向对象或函数式的。在面向对象的情况下,字段携带配置,并且客户端调用方法。在函数式方案中,函数闭合配置,并且客户端调用函数。

我们能否统一这些方法,将面向对象实现传递给期望函数的客户端,反之亦然?或者用 Kotlin 术语来说,我们能否将ISendEmail转换为(Email) -> Unit,反之亦然?答案是肯定的!

请记住,在 Java 和 Kotlin 中,函数类型只是接口。因此,EmailSender可以分别通过定义具有函数类型签名的方法来实现类型Consumer<Email>(Email) -> Unit

在 Java 中,我们可以写成:

public class EmailSender
    implements ISendEmail,
        Consumer<Email> ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
{
        ...
    @Override
    public void accept(Email email) { ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/2.png)
        send(email);
    }

    @Override
    public void send(Email email) {
        sendEmail(email, serverAddress, username, password);
    }
}

1

声明

2

实现

Kotlin 的等价代码如下:

class EmailSender(
        ...
) : ISendEmail,
    (Email) -> Unit ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
{
    override operator fun invoke(email: Email) =
        send(email) ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/2.png)

    override fun send(email: Email) {
        sendEmail(
            email,
            serverAddress,
            username,
            password
        )
    }
}

1

声明

2

实现

如果我们这样做,我们可以在功能上使用基于类的发送器替代我们的函数式发送器。现在我们继续使用 Kotlin:

// Where we know the configuration val sender: (Email) -> Unit = EmailSender(
    inetAddress("smtp.travelator.com"),
    "username",
    "password"
)

// Where we send the message fun sendThanks() {
    sender( ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
        Email(
            to = parse("support@internationalrescue.org"),
            from = parse("support@travelator.com"),
            subject = "Thanks for your help",
            body = "..."
        )
    )
}

1

这里有一个隐式的invoke

现在,我们的面向对象实现增加了一个invoke方法以适应函数式方法。这引发了我们的ISendEmail接口的实用性问题。我们可以看到它等同于函数类型(Email) -> Unit。它所做的只是给send一个名称,表明调用它时会发生什么。也许我们可以在所有地方都使用类型(Email) -> Unit来代替ISendEmail

如果你觉得这不够表达性,那么也许你不是一个函数式程序员。幸运的是,我们可以采用类型别名来为函数类型命名,从而传达我们的意图:

typealias EmailSenderFunction = (Email) -> Unit

class EmailSender(
    ...
) : EmailSenderFunction {
    override fun invoke(email: Email) {
        sendEmail(
            email,
            serverAddress,
            username,
            password
        )
    }
}

实际上,我们可能会称之为EmailSenderFunctionEmailSender。这里我们给它起了一个不同的名字,以避免与面向对象版本混淆,但我们想要称它们为同一个东西表明它们从客户端的角度来看是具有相同目的的。

还有另一种方法可以弥合面向对象和函数式编程之间的差距,而不涉及使我们的类实现函数类型:在翻译点创建一个函数引用。这是我们旧的基于类的解决方案:

class EmailSender(
    private val serverAddress: InetAddress,
    private val username: String,
    private val password: String
) {
    fun send(email: Email) {
        sendEmail(
            email,
            serverAddress,
            username,
            password
        )
    }
}

我们可以使用 lambda 将 EmailSender 的实例转换为函数类型:

val instance = EmailSender(
    inetAddress("smtp.travelator.com"),
    "username",
    "password"
)
val sender: (Email) -> Unit = { instance.send(it) }

或者只是使用方法引用:

val sender: (Email) -> Unit = instance::send

虽然我们展示了这些在 Kotlin 中的转换,它们在 Java 中也适用(语法略有不同)。它们也适用于 ISendEmail 接口上的 send 方法,尽管如果我们使用函数类型,接口对我们来说并没有多大用处,这一点并不清楚。

我们可以反过来将我们的函数式发送器传递给期望 ISendEmail 的某些东西吗?这需要更多的仪式感,因为我们必须创建一个实现 ISendEmail 的匿名对象来执行这个 thunk:

val function: (Email) -> Unit = createEmailSender(
    inetAddress("smtp.travelator.com"),
    "username",
    "password"
)

val sender: ISendEmail = object : ISendEmail {
    override fun send(email: Email) {
        function(email)
    }
}

如果我们使用了 Kotlin 1.4 的 fun interface,我们可以再次减少一些样板代码:

fun interface ISendEmail {
    fun send(email: Email)
}

val sender = ISendEmail { function(it) }

比较方法

让我们回顾一下面向对象的方法。

首先我们定义一个类型:

class EmailSender(
    private val serverAddress: InetAddress,
    private val username: String,
    private val password: String
) {
    fun send(email: Email) {
        sendEmail(
            email,
            serverAddress,
            username,
            password
        )
    }
}

然后我们创建实例并调用方法:

// Where we know the configuration
val sender: EmailSender = EmailSender(
    inetAddress("smtp.travelator.com"),
    "username",
    "password"
)

// Where we send the message
fun sendThanks() {
    sender.send(
        Email(
            to = parse("support@internationalrescue.org"),
            from = parse("support@travelator.com"),
            subject = "Thanks for your help",
            body = "..."
        )
    )
}

在函数式领域中,我们不必定义一个类型,因为 (Email) -> Unit 就已经存在(也就是说,由运行时提供),所以我们可以直接说:

// Where we know the configuration val sender: (Email) -> Unit = createEmailSender(
    inetAddress("smtp.travelator.com"),
    "username",
    "password"
)

// Where we send the message fun sendThanks() {
    sender( ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
        Email(
            to = parse("support@internationalrescue.org"),
            from = parse("support@travelator.com"),
            subject = "Thanks for your help",
            body = "..."
        )
    )
}

1

有或没有 invoke

使用 object 的客户端必须知道调用 send 方法来发送电子邮件;相比之下,使用 function 的客户端只需调用它,但他们只知道这个函数发送电子邮件,因为它被分配了名字 sender。如果这个名字在调用层次结构中丢失了,我们只能从函数签名猜测发生了什么。

面向对象客户端必须知道调用 send 方法的回报是,我们可以将一些与电子邮件相关的操作打包成一个 EmailSystem,例如 sendlistdelete 等方法,并将所有这些功能一次性传递给客户端。客户端然后可以选择在哪个上下文中需要哪个:

interface EmailSystem {
    fun send(email: Email)
    fun delete(email: Email)
    fun list(folder: Folder): List<Email>
    fun move(email: Email, to: Folder)
}

要在功能上实现这一点,要么传递单个函数,要么传递某种名称到函数的映射,也许是类本身的一个实例:

class EmailSystem(
    val send: (Email) -> Unit,
    val delete: (Email) -> Unit,
    val list: (folder: Folder) -> List<Email>,
    val move: (email: Email, to: Folder) ->  Unit
)

鉴于这样的对象,客户端可以非常像使用接口的实现一样对待它:

fun sendThanks(sender: EmailSystem) {
    sender.send(
        Email(
            to = parse("support@internationalrescue.org"),
            from = parse("support@travelator.com"),
            subject = "Thanks for your help",
            body = "..."
        )
    )
}

但这与面向对象的代码不同。这里并不是调用 send 方法,实际上发生的是我们调用 getSender 来访问函数类型的属性,然后在该函数上调用 invoke

fun sendThanks(sender: EmailSystem) {
    sender.send.invoke(
        Email(
            to = parse("support@internationalrescue.org"),
            from = parse("support@travelator.com"),
            subject = "Thanks for your help",
            body = "..."
        )
    )
}

代码可能看起来相同,但生成了非常不同且基本不兼容的字节码。

耦合

表达依赖性为 ISendEmail 的实现或函数类型 (Email) -> Unit 之间微妙的差别是客户端和实现之间的耦合,特别是当它们位于不同的代码模块中时。

ISendEmail必须在某处定义。客户端不能定义它,因为实现者将依赖于接口,而客户端将依赖于实现,导致循环依赖。因此,接口必须在实现中定义,或者在一个独立的地方(包或 JAR 文件)定义,被实现及其客户端依赖。(这是依赖反转原则的应用)理论上更可取,但在实践中往往被忽视,因为需要更多工作。

无论是否有依赖反转,结果都是客户端和实现通过接口耦合,这可能使系统难以理解和重构。对EmailSystem上方法的任何更改都可能影响依赖接口的所有代码。

相反,在函数式世界中,运行时定义了所有的函数类型,因此它们不会在客户端和实现之间引入编译时依赖。与我们必须在某处定义的ISendEmail不同,(Email) -> Unit(或在 Java 中,Consumer<Email>)是语言的一部分。当然,会存在运行时依赖——构造函数的代码需要在依赖创建的地方可见,并且客户端必须能够调用实现代码——但这些会导致较少的耦合。例如,当依赖表达为函数类型时,我们可以重命名EmailSystem.send,而客户端代码唯一需要修改的是使用不同的方法引用;sendThanks的内部不受影响。

只传递你拥有的类型或运行时定义的类型

早期面向对象系统的一个经验法则是,我们在系统内部时,应该按照我们拥有的类型来编程,而不是依赖于库提供的类型。这样我们就能够隔离我们无法控制的变化,并且更有可能编写可通过不同实现重复使用的代码。

这个规则的一个例外是依赖于运行时提供的类型;这些类型变化的可能性非常小。函数类型允许我们轻松地从不稳定的接口转换为稳定的接口,使系统的各部分可以以不同的速度发展。

面向对象还是函数式?

面向对象和函数式方法都可以实现相同的目标,并具有类似的表达能力。我们应该选择哪一种呢?

让我们从客户端代码的角度来考虑这个问题。如果我们的客户端只需要列出电子邮件,它应该依赖于一个单一的(Folder) -> List<Email>函数。这样就不会与实现耦合,依赖可以由任何实现函数类型的东西满足,包括:

  • 一个普通函数

  • 实现函数类型的对象

  • 选择具有所需签名的方法引用

  • 具有所需签名的 lambda

即使我们已经有了一个接口,比如EmailSystem,它定义了所需的方法,比如sendmovedelete

interface EmailSystem {
    fun send(email: Email)
    fun delete(email: Email)
    fun list(folder: Folder): List<Email>
    fun move(email: Email, to: Folder)
}

当函数类型足够时,我们不应无端将客户端与此接口耦合:

class Organiser(
    private val listing: (Folder) -> List<Email>
) {
    fun subjectsIn(folder: Folder): List<String> {
        return listing(folder).map { it.subject }
    }
}

val emailSystem: EmailSystem = ...
val organiser = Organiser(emailSystem::list)

依赖更广泛的接口会错失精确表达我们需要的操作的机会,并迫使客户端提供整个接口的实现。这在测试中特别令人恼火,因为我们将不得不引入虚假对象才能使我们的测试代码编译通过。

通信和减少耦合的驱动力是如此强大,以至于即使我们的客户端需要发送和删除电子邮件,而实际上这些将由单个EmailSystem提供,客户端可能应该依赖于两个函数而不是接口:

class Organiser(
    private val listing: (Folder) -> List<Email>,
    private val deleting: (Email) -> Unit
) {
    fun deleteInternal(folder: Folder) {
        listing(rootFolder).forEach {
            if (it.to.isInternal()) {
                deleting.invoke(it)
            }
        }
    }
}

val organiser = Organiser(
    emailSystem::list,
    emailSystem::delete
)

只有当客户需要三个相关操作时,多方法接口才感觉应该是默认的:

class Organiser(
    private val emails: EmailSystem
) {
    fun organise() {
        emails.list(rootFolder).forEach {
            if (it.to.isInternal()) {
                emails.delete(it)
            } else {
                emails.move(it, archiveFolder)
            }
        }
    }
}

val organiser = Organiser(emailSystem)

即使在这里,客户端接受仅支持所需操作的对象可能会更好。我们可以使用一个新的接口(这里称为Dependencies),由一个object实现:

class Organiser(
    private val emails: Dependencies
) {
    interface Dependencies {
        fun delete(email: Email)
        fun list(folder: Folder): List<Email>
        fun move(email: Email, to: Folder)
    }

    fun organise() {
        emails.list(rootFolder).forEach {
            if (it.to.isInternal()) {
                emails.delete(it)
            } else {
                emails.move(it, archiveFolder)
            }
        }
    }
}

val organiser = Organiser(object : Organiser.Dependencies {
    override fun delete(email: Email) {
        emailSystem.delete(email)
    }

    override fun list(folder: Folder): List<Email> {
        return emailSystem.list(folder)
    }

    override fun move(email: Email, to: Folder) {
        emailSystem.move(email, to)
    }
})

这确实相当恼人;也许这是一个函数类更好的地方:

class Organiser(
    private val emails: Dependencies
) {
    class Dependencies(
        val delete: (Email) -> Unit,
        val list: (folder: Folder) -> List<Email>,
        val move: (email: Email, to: Folder) -> Unit
    )

    fun organise() {
        emails.list(rootFolder).forEach {
            if (it.to.isInternal()) {
                emails.delete(it)
            } else {
                emails.move(it, archiveFolder)
            }
        }
    }
}

val organiser = Organiser(
    Organiser.Dependencies(
        delete = emailSystem::delete,
        list = emailSystem::list,
        move = emailSystem::move
    )
)

因此,直到变得困难,我们应默认将客户端的需求表达为函数类型。然后我们的实现可以仅仅是一个函数,或者是实现函数类型的东西,或者是通过方法引用或 Lambda 转换为函数类型的方法,以在上下文中最合理的方式进行。

Java 的遗产

尽管我们之前说过“我们的运行时定义了所有的函数类型”,但直到 Java 8 引入了SupplierConsumerPredicate等功能以及使用方法引用或 Lambda 实现它们的能力之前,Java 并不是这样的。

因此,遗留的 Java 代码通常使用相同的多方法接口来表达依赖关系,就像我们将它们按子系统(如EmailSystem)分组一样,即使只有一个方法需要实现功能。这导致了前面描述的耦合问题。它还导致了需要模拟(或更加严谨地说是伪造)框架来创建广泛接口的测试实现,尽管实际上只有一个方法会被调用。这些伪造物然后会在你调用不打算调用的方法时提前失败的功能。

一旦我们在代码库中引入了一个模拟框架(或者更通常的是两到三个模拟框架以迎合不同的喜好),它们允许我们解决问题,比如生成未使用方法的实现并且创建外部系统交互的存根。通常情况下,我们的代码通过重构来避免对模拟的需求会更好。表达依赖关系作为函数类型是一个例子;另一个例子是将与外部系统的交互移动到代码的外层,正如我们将在第二十章中看到的那样。第十七章讨论了如何通过重构测试来减少模拟的使用,使其更具功能性形式。

可追溯性

使用函数类型表达依赖关系有一个缺点,并且添加间接层次是一个常见问题。如果我们使用 IntelliJ 查找EmailSystem.send的调用者,踪迹在将EmailSystem::send转换为(Email) -> Unit时中断。IDE 不知道函数调用实际上是在调用方法。就像我们的英雄进入河流,追踪者必须在上游和下游的两岸搜索,才能找到他们出去的地方。

这也是我们通过方法调用引入间接性所付出的代价,但是我们的工具对此了如指掌,至少可以找到特定方法的所有实现位置,以及通过接口调用实现的位置。就像使用未封装集合(第十五章)一样,我们为解耦和通用性付出的代价是工具和开发人员在分析时的上下文更少。我们相信 IDE 支持将改进其功能分析,同时,我们可以通过不将函数类型传递得太远来提供帮助,直到它们被初始化和使用的地方。

从接口到函数的重构

Travelator 在 Java 风格中设计得相当好,接口表达了组件之间的关系。例如,Recommendations 引擎依赖于FeaturedDestinationsDistanceCalculator

public class Recommendations {
    private final FeaturedDestinations featuredDestinations;
    private final DistanceCalculator distanceCalculator;

    public Recommendations(
        FeaturedDestinations featuredDestinations,
        DistanceCalculator distanceCalculator
    ) {
        this.featuredDestinations = featuredDestinations;
        this.distanceCalculator = distanceCalculator;
    }
    ...
}

示例 16.1 [interfaces-to-funs.0:src/main/java/travelator/recommendations/Recommendations.java] (diff)

FeaturedDestinations 接口有几个方法,分组功能以访问远程服务:

public interface FeaturedDestinations {
    List<FeaturedDestination> findCloseTo(Location location);
    FeaturedDestination findClosest(Location location);

    FeaturedDestination add(FeaturedDestinationData destination);
    void remove(FeaturedDestination destination);
    void update(FeaturedDestination destination);
}

示例 16.2 [interfaces-to-funs.0:src/main/java/travelator/destinations/FeaturedDestinations.java] (diff)

看起来我们已经将DistanceCalculator接口转换为 Kotlin。它也有多个方法,并且隐藏了另一个外部服务:

interface DistanceCalculator {
    fun distanceInMetersBetween(
        start: Location,
        end: Location
    ): Int

    fun travelTimeInSecondsBetween(
        start: Location,
        end: Location
    ): Int
}

示例 16.3 [interfaces-to-funs.0:src/main/java/travelator/domain/DistanceCalculator.kt] (差异)

尽管依赖了七种方法,但 Recommendations 实际上只在其实现中使用了其中两种:

public List<FeaturedDestinationSuggestion> recommendationsFor(
    Set<Location> journey
) {
    var results = removeDuplicates(
        journey.stream()
            .flatMap(location ->
                recommendationsFor(location).stream()
            )
    );
    results.sort(distanceComparator);
    return results;
}

public List<FeaturedDestinationSuggestion> recommendationsFor(
    Location location
) {
    return featuredDestinations
        .findCloseTo(location) ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
        .stream()
        .map(featuredDestination ->
            new FeaturedDestinationSuggestion(
                location,
                featuredDestination,
                distanceCalculator.distanceInMetersBetween( ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/2.png)
                    location,
                    featuredDestination.getLocation()
                )
            )
        ).collect(toList());
}

示例 16.4 [interfaces-to-funs.0:src/main/java/travelator/recommendations/Recommendations.java] (差异)

1

FeaturedDestinations 上的方法

2

DistanceCalculator 上的方法

RecommendationsTests 使用模拟提供了其 Distance​Cal⁠cula⁠torFeaturedDestinations 的实现,这些实现被传递给正在测试的 Recommendations 实例:

public class RecommendationsTests {

    private final DistanceCalculator distanceCalculator =
        mock(DistanceCalculator.class);
    private final FeaturedDestinations featuredDestinations =
        mock(FeaturedDestinations.class);
    private final Recommendations recommendations = new Recommendations(
        featuredDestinations,
        distanceCalculator
    );
    ...
}

示例 16.5 [interfaces-to-funs.0:src/test/java/travelator/recommendations/RecommendationsTests.java] (差异)

测试指定预期与模拟对象的交互使用两种方法:givenFeaturedDestinationsForgivenADistanceBetween,我们不会详细说明:

@Test
public void returns_recommendations_for_multi_location() {
    givenFeaturedDestinationsFor(paris,
        List.of(
            eiffelTower,
            louvre
        ));
    givenADistanceBetween(paris, eiffelTower, 5000);
    givenADistanceBetween(paris, louvre, 1000);

    givenFeaturedDestinationsFor(alton,
        List.of(
            flowerFarm,
            watercressLine
        ));
    givenADistanceBetween(alton, flowerFarm, 5300);
    givenADistanceBetween(alton, watercressLine, 320);

    assertEquals(
        List.of(
            new FeaturedDestinationSuggestion(alton, watercressLine, 320),
            new FeaturedDestinationSuggestion(paris, louvre, 1000),
            new FeaturedDestinationSuggestion(paris, eiffelTower, 5000),
            new FeaturedDestinationSuggestion(alton, flowerFarm, 5300)
        ),
        recommendations.recommendationsFor(Set.of(paris, alton))
    );
}

示例 16.6 [interfaces-to-funs.0:src/test/java/travelator/recommendations/RecommendationsTests.java] (差异)

引入函数

在我们开始从接口转换为函数之前,我们将 Recommendations 转换为 Kotlin。 这个类目前用接口表达其依赖关系,而 Kotlin 函数类型比 Java 的不那么笨重。

转换为 Kotlin 并应用第 10 和第十三章介绍的重构后,得到:

class Recommendations(
    private val featuredDestinations: FeaturedDestinations,
    private val distanceCalculator: DistanceCalculator
) {
    fun recommendationsFor(
        journey: Set<Location>
    ): List<FeaturedDestinationSuggestion> =
        journey
            .flatMap { location -> recommendationsFor(location) }
            .deduplicated()
            .sortedBy { it.distanceMeters }

    fun recommendationsFor(
        location: Location
    ): List<FeaturedDestinationSuggestion> =
        featuredDestinations.findCloseTo(location)
            .map { featuredDestination ->
                FeaturedDestinationSuggestion(
                    location,
                    featuredDestination,
                    distanceCalculator.distanceInMetersBetween(
                        location,
                        featuredDestination.location
                    )
                )
            }
}

private fun List<FeaturedDestinationSuggestion>.deduplicated() =
    groupBy { it.suggestion }
        .values
        .map { suggestionsWithSameDestination ->
            suggestionsWithSameDestination.closestToJourneyLocation()
        }

private fun List<FeaturedDestinationSuggestion>.closestToJourneyLocation() =
    minByOrNull { it.distanceMeters } ?: error("Unexpected empty group")

示例 16.7 [interfaces-to-funs.3:src/main/java/travelator/recommendations/Recommendations.kt] (差异)

要查看 Recommendations 的内部如何使用函数而不是接口,而不必立即更改其接口,我们可以添加一个从接口方法初始化的属性。 让我们为 featuredDestinations::find​Clo⁠seTo 添加一个属性,将其称为 destinationFinder

class Recommendations(
    private val featuredDestinations: FeaturedDestinations,
    private val distanceCalculator: DistanceCalculator
) {
    private val destinationFinder: ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
        (Location) -> List<FeaturedDestination> =
        featuredDestinations::findCloseTo

    ...

    fun recommendationsFor(
        location: Location
    ): List<FeaturedDestinationSuggestion> =
        destinationFinder(location) ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/2.png)
            .map { featuredDestination ->
                FeaturedDestinationSuggestion(
                    location,
                    featuredDestination,
                    distanceCalculator.distanceInMetersBetween(
                        location,
                        featuredDestination.location
                    )
                )
            }
}

示例 16.8 [interfaces-to-funs.4:src/main/java/travelator/recommendations/Recommendations.kt] (差异)

1

从接口中提取一个函数。

2

在方法的位置使用它。

这通过了测试,所以我们正在做正确的事情。感觉应该有一个重构来将destinationFinder移到构造函数中,但我们还没有找到比将定义剪切并粘贴到所需位置更好的方法:

class Recommendations(
    private val featuredDestinations: FeaturedDestinations,
    private val distanceCalculator: DistanceCalculator,
    private val destinationFinder:
        (Location) -> List<FeaturedDestination> =
        featuredDestinations::findCloseTo
) {

示例 16.9 [interfaces-to-funs.5:src/main/java/travelator/recommendations/Recommendations.kt] (差异)

这又一次是“扩展和收缩重构”中的扩展。不幸的是,Java 无法理解默认参数,所以我们必须修复调用站点以添加函数参数。这并不重要,因为这才是我们真正想要的:

private final Recommendations recommendations = new Recommendations(
    featuredDestinations,
    distanceCalculator,
    featuredDestinations::findCloseTo
);

示例 16.10 [interfaces-to-funs.5:src/test/java/travelator/recommendations/RecommendationsTests.java] (差异)

现在Recommendations中没有使用featuredDestinations属性,因此我们可以将其删除(contract):

class Recommendations(
    private val distanceCalculator: DistanceCalculator,
    private val destinationFinder: (Location) -> List<FeaturedDestination>
) {

示例 16.11 [interfaces-to-funs.6:src/main/java/travelator/recommendations/Recommendations.kt] (差异)

我们代码中创建Recommendations的地方现在看起来是这样的:

private final Recommendations recommendations = new Recommendations(
    distanceCalculator,
    featuredDestinations::findCloseTo
);

示例 16.12 [interfaces-to-funs.6:src/test/java/travelator/recommendations/RecommendationsTests.java] (差异)

如果你习惯使用模拟进行测试重构,那么测试继续通过这种重构可能会让你感到惊讶。我们可以推断它们应该通过——调用绑定到featuredDestinations::findCloseTo的函数的效果仍然是在模拟接口上调用方法——但我们的推理经常被运行测试证明是错误的,所以我们不要数我们的鸡蛋。

不过,我们确实喜欢单一的篮子,所以让我们用同样的方法处理distance​Cal⁠cul⁠ator,这次一举两得,不管那是什么:

class Recommendations(
    private val destinationFinder: (Location) -> List<FeaturedDestination>,
    private val distanceInMetersBetween: (Location, Location) -> Int
) {
    ...
    fun recommendationsFor(
        location: Location
    ): List<FeaturedDestinationSuggestion> =
        destinationFinder(location)
            .map { featuredDestination ->
                FeaturedDestinationSuggestion(
                    location,
                    featuredDestination,
                    distanceInMetersBetween( ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
                        location,
                        featuredDestination.location
                    )
                )
            }
}

示例 16.13 [interfaces-to-funs.7:src/main/java/travelator/recommendations/Recommendations.kt] (差异)

1

调用新函数

构造函数调用现在是:

private final Recommendations recommendations = new Recommendations(
    featuredDestinations::findCloseTo,
    distanceCalculator::distanceInMetersBetween
);

示例 16.14 [interfaces-to-funs.7:src/test/java/travelator/recommendations/RecommendationsTests.java] (差异)

注意,在函数变量命名上稍加思考可以使它们在使用时显得自然,尽管这有时会使它们在定义时显得有些神秘。

测试仍然通过,这让我们对我们的生产代码能够以相同方式进行转换感到自信。特别是我们已经展示了我们可以同时跨越方法/函数边界和 Java/Kotlin 边界。也许这种互操作性最终会顺利进行!

Moving On

我们希望我们的代码简单且灵活。为此,库需要将实现细节隐藏在客户端代码之外,并且我们希望能够用另一个功能的实现替换一个功能的实现。

在面向对象编程中,我们将配置和实现隐藏在类中,并通过接口表达可替代的功能。在函数式编程中,函数承担了这两个角色。我们可能认为函数更为基础,但我们可以将对象视为函数的集合,将函数视为仅有一个方法的对象。Kotlin 和 Java 都允许我们在实现和客户端之间的边界之间移动,但 Kotlin 的本地函数类型语法鼓励使用函数类型而不是接口。这比定义自己的接口更能实现解耦,并应成为我们的默认方法。

我们继续重构这个示例,并在第十七章,Mocks to Maps中检查这种关系。

第十七章:Mocks 到 Maps

Mocks 是一种常见的技术,用于将面向对象的代码与其生产依赖解耦。在 Kotlin 中是否有更好的解决方案?

这是一个简短的额外章节,接续于 第十六章。在那一章中,我们看到我们的测试使用了 Mocks,因为它们必须实现两个多方法接口,尽管大多数方法并未使用。我们完成了重构,将多方法接口的依赖替换为仅需要执行任务的两个操作的依赖。尽管如此,测试仍然模拟整个接口,并将所需方法的引用传递给被测试对象(Recommendations):

public class RecommendationsTests {

    private final DistanceCalculator distanceCalculator =
        mock(DistanceCalculator.class);
    private final FeaturedDestinations featuredDestinations =
        mock(FeaturedDestinations.class);
    private final Recommendations recommendations = new Recommendations(
        featuredDestinations::findCloseTo,
        distanceCalculator::distanceInMetersBetween
    );
    ...
}

示例 17.1 [interfaces-to-funs.7:src/test/java/travelator/recommendations/RecommendationsTests.java] (diff)

测试将模拟抽象为方法 givenFeaturedDestinationsForgivenADistanceBetween

@Test
public void returns_recommendations_for_single_location() {
    givenFeaturedDestinationsFor(paris,
        List.of(
            eiffelTower,
            louvre
        ));
    givenADistanceBetween(paris, eiffelTower, 5000);
    givenADistanceBetween(paris, louvre, 1000);

    assertEquals(
        List.of(
            new FeaturedDestinationSuggestion(paris, louvre, 1000),
            new FeaturedDestinationSuggestion(paris, eiffelTower, 5000)
        ),
        recommendations.recommendationsFor(Set.of(paris))
    );
}

示例 17.2 [interfaces-to-funs.7:src/test/java/travelator/recommendations/RecommendationsTests.java] (diff)

这是 givenADistanceBetween 的实现:

private void givenADistanceBetween(
    Location location,
    FeaturedDestination destination,
    int result
) {
    when(
        distanceCalculator.distanceInMetersBetween(
            location,
            destination.getLocation())
    ).thenReturn(result);
}

示例 17.3 [interfaces-to-funs.7:src/test/java/travelator/recommendations/RecommendationsTests.java] (diff)

Nat 渴望指出,他和 Steve Freeman 在 Growing Object-Oriented Software Guided by Tests 中写到的 Mocks 实际上从未打算用于实现像 findCloseTodistanceInMetersBetween 这样的查询功能,而仅限于改变状态的方法。但 Duncan 并不记得注意到这一点,而且个人并不反对以这种方式使用 Mocks,因为在实践外部驱动测试开发时,无论是读取还是写入,它们仍然是指定协作对象期望的一种好方式。最终,也许这并不重要,因为根据我们的经验,大多数 Java 代码库都使用这种方式的 Mocks,而大多数 Kotlin 代码库在没有它们的情况下表现更好。

不过目前我们仍在使用 Mocks,但之前的重构导致我们向被测代码传递了窄接口(函数类型)。既然我们不需要实现未调用的方法,那么我们是否仍然需要 Mocks 呢?让我们看看这条线索会引导我们走向何方。

用 Maps 替换 Mocks

在我们继续之前,我们将测试转换为 Kotlin,因为它对函数类型有更好的支持。我们可以继续使用 Java,但那样我们就必须弄清楚 Java 函数类型(FunctionBiFunction 等)中哪些表达了操作。而且我们仍将拥有 Java。

自动转换非常顺利,尽管由于某种原因,转换器在Recommendations构造函数调用中创建了 Lambda 表达式而不是使用方法引用,我们必须手动替换设置为:

class RecommendationsTests {
    private val distanceCalculator = mock(DistanceCalculator::class.java)
    private val featuredDestinations = mock(FeaturedDestinations::class.java)

    private val recommendations = Recommendations(
        featuredDestinations::findCloseTo,
        distanceCalculator::distanceInMetersBetween
    )
    ...

示例 17.5 [mocks-to-maps.0:src/test/java/travelator/recommendations/RecommendationsTests.kt] (diff)

我们可以使用 Kotlin 的具体化类型来避免那些::class.java参数,但我们正在摆脱模拟,而不是朝着它们前进,所以我们抵制。

when这个术语是 Kotlin 中的关键字,但转换器足够智能,在需要时引用它:

private fun givenFeaturedDestinationsFor(
    location: Location,
    result: List<FeaturedDestination>
) {
    Mockito.`when`(featuredDestinations.findCloseTo(location))
        .thenReturn(result)
}

示例 17.6 [mocks-to-maps.0:src/test/java/travelator/recommendations/RecommendationsTests.kt] (diff)

要理解如何去除嘲讽,有助于将函数类型视为其输入参数(作为元组)与其结果之间的映射。因此,destinationFinder是一个将单个Location映射到List<FeaturedDestination>的映射,distanceInMetersBetween是将Pair<Location, Location>映射到Int的映射。Map数据结构是我们表达一组映射的方式——名称不是偶然的。因此,我们可以通过使用参数键和预期结果值填充Map来伪造一个函数,并用提供的参数查找替换函数调用。您可能已经使用此技巧来缓存昂贵计算的结果。在这里,我们不会缓存,而是用参数和预期结果填充Map

首先处理destinationFinder案例,我们将创建一个属性来持有MapfeaturedDestinations

private val featuredDestinations =
    mutableMapOf<Location, List<FeaturedDestination>>()
        .withDefault { emptyList() }

示例 17.7 [mocks-to-maps.1:src/test/java/travelator/recommendations/RecommendationsTests.kt] (diff)

givenFeaturedDestinationsFor可以填充destinationLookup Map,而不是在模拟上设置期望:

private fun givenFeaturedDestinationsFor(
    location: Location,
    destinations: List<FeaturedDestination>
) {
    featuredDestinations[location] = destinations.toList()
}

示例 17.8 [mocks-to-maps.1:src/test/java/travelator/recommendations/RecommendationsTests.kt] (diff)

如果我们使RecommendationsfeaturedDestinations Map中读取,我们的测试通过了:

private val recommendations =
    Recommendations(
        featuredDestinations::getValue,
        distanceCalculator::distanceInMetersBetween
    )

示例 17.9 [mocks-to-maps.1:src/test/java/travelator/recommendations/RecommendationsTests.kt] (diff)

getValueMap的扩展。它的作用类似于get,但遵循由Map.withDefault设置的默认值(在本例中返回emptyList()),因此不返回可空结果。

当我们对distanceInMetersBetween执行相同操作时,将会让我们完全摆脱对 Mockito 的依赖:

class RecommendationsTests {

    private val featuredDestinations =
        mutableMapOf<Location, List<FeaturedDestination>>()
            .withDefault { emptyList() }
    private val distanceInMetersBetween =
        mutableMapOf<Pair<Location, Location>, Int>()
            .withDefault { -1 }

    private val recommendations =
        Recommendations(
            featuredDestinations::getValue,
            { l1, l2 -> distanceInMetersBetween.getValue(l1 to l2) }
        )
    ...
}

示例 17.10 [mocks-to-maps.2:src/test/java/travelator/recommendations/RecommendationsTests.kt] (差异)

private fun givenADistanceFrom(
    location: Location,
    destination: FeaturedDestination,
    distanceInMeters: Int
) {
    distanceInMetersBetween[location to destination.location] =
        distanceInMeters
}

示例 17.11 [mocks-to-maps.2:src/test/java/travelator/recommendations/RecommendationsTests.kt] (差异)

或许需要多次尝试才能看到它的运作方式;这些是模拟框架为我们隐藏的细节。您可以放心地忽略它们,如果您将来执行此重构,请回到这里。

Recommendations 构造函数调用中,必须使用 lambda 而不是方法引用有点恼人。我们可以通过一个本地的 getValue 扩展函数来整理它。我们提到了我们有多喜欢扩展函数吗?

private fun <K1, K2, V> Map<Pair<K1, K2>, V>.getValue(k1: K1, k2: K2) =
    getValue(k1 to k2)

示例 17.12 [mocks-to-maps.3:src/test/java/travelator/recommendations/RecommendationsTests.kt] (差异)

这让我们说:

private val recommendations =
    Recommendations(
        featuredDestinations::getValue,
        distanceInMetersBetween::getValue
    )

示例 17.13 [mocks-to-maps.3:src/test/java/travelator/recommendations/RecommendationsTests.kt] (差异)

哦,我们还可以通过一些谨慎的参数命名和辅助方法提高测试方法的可读性。以前,我们只是普通的函数调用:

@Test
fun deduplicates_using_smallest_distance() {
    givenFeaturedDestinationsFor(
        alton,
        flowerFarm, watercressLine
    )
    givenFeaturedDestinationsFor(
        froyle,
        flowerFarm, watercressLine
    )
    givenADistanceFrom(alton, flowerFarm, 5300)
    givenADistanceFrom(alton, watercressLine, 320)
    givenADistanceFrom(froyle, flowerFarm, 0)
    givenADistanceFrom(froyle, watercressLine, 6300)
    assertEquals(
        listOf(
            FeaturedDestinationSuggestion(froyle, flowerFarm, 0),
            FeaturedDestinationSuggestion(alton, watercressLine, 320)
        ),
        recommendations.recommendationsFor(setOf(alton, froyle))
    )
}

示例 17.14 [mocks-to-maps.3:src/test/java/travelator/recommendations/RecommendationsTests.kt] (差异)

一点努力就可以得到:

@Test
fun deduplicates_using_smallest_distance() {
    givenFeaturedDestinationsFor(alton, of(flowerFarm, watercressLine))
    givenADistanceFrom(alton, to = flowerFarm, of = 5300)
    givenADistanceFrom(alton, to = watercressLine, of = 320)

    givenFeaturedDestinationsFor(froyle, of(flowerFarm, watercressLine))
    givenADistanceFrom(froyle, to = flowerFarm, of = 0)
    givenADistanceFrom(froyle, to = watercressLine, of = 6300)

    assertEquals(
        listOf(
            FeaturedDestinationSuggestion(froyle, flowerFarm, 0),
            FeaturedDestinationSuggestion(alton, watercressLine, 320)
        ),
        recommendations.recommendationsFor(setOf(alton, froyle))
    )
}

示例 17.15 [mocks-to-maps.4:src/test/java/travelator/recommendations/RecommendationsTests.kt] (差异)

有时像 of 这样定义一个微小的本地函数可以大幅减少我们的大脑阅读代码时的努力,而不是花费精力去解释它:

private fun of(vararg destination: FeaturedDestination)
    = destination.toList()

示例 17.16 [mocks-to-maps.4:src/test/java/travelator/recommendations/RecommendationsTests.kt] (差异)

在 Kotlin 中伪造

有时候,即使在 Kotlin 中,我们也希望只为测试实现接口的一些方法。在 JVM 上,我们可以结合动态代理、匿名对象、委托和选择性重写来编写以下内容:

inline fun <reified T> fake(): T =
    Proxy.newProxyInstance(
        T::class.java.classLoader,
        arrayOf(T::class.java)
    ) { _, _, _ ->
        TODO("not implemented")
    } as T

val sentEmails = mutableListOf<Email>()
val testCollaborator: EmailSystem =
    object : EmailSystem by fake() {
        override fun send(email: Email) {
            sentEmails.add(email)
        }
    }

但我们真的已经摆脱了模拟吗?

啊,现在这是一个好问题!

在某些方面,我们只是实现了一个贫乏的模拟框架:我们没有参数匹配器,没有方法未被调用时失败的方式,也没有表达执行顺序的方式。

然而,换个角度看,我们已经将推荐引擎的依赖项实现为两个映射。Recommendations.recommendationsFor开始看起来像一个简单的计算(“计算”)。该计算的结果取决于journey参数以及启用我们查找特色目的地和距离的那些映射的内容。实际上,我们知道我们调用recommendationsFor确实很重要;它实际上是一个动作(“动作”)。位置之间的距离可能不会随时间改变,但是我们在周围找到的目的地将会随着我们将它们从任何数据库中添加或删除而改变。不过,在我们的测试中,这种区别不重要,我们可以将recommendationsFor视为一种计算,就像我们在第七章中看到的InMemoryTrips一样。计算比动作更容易测试——我们只需检查给定输入返回给定输出——因此让我们深入研究一下这个问题。

目前,我们在测试中调用recommendationsFor时,也很重要,因为结果将取决于featuredDestinationsdistanceInMetersBetween映射的内容。这些初始为空,并通过调用givenFeaturedDestinationsForgivenADistanceFrom进行填充。这就是时间敏感性。我们需要的是一种将动作转换为计算的方式,我们可以通过操作作用域来实现。

在第十六章中,我们看到我们可以将方法视为通过将它们作为字段捕获而部分应用其某些参数的函数。在测试中,我们可以反向进行此过程。我们可以编写一个函数,每次调用时从其依赖项创建对象。如果我们将填充对象称为测试的主题,我们可以像这样从测试状态创建它:

private fun subjectFor(
    featuredDestinations: Map<Location, List<FeaturedDestination>>,
    distances: Map<Pair<Location, Location>, Int>
): Recommendations {
    val destinationsLookup = featuredDestinations.withDefault { emptyList() }
    val distanceLookup = distances.withDefault { -1 }
    return Recommendations(destinationsLookup::getValue, distanceLookup::getValue)
}

示例 17.17 [mocks-to-maps.5:src/test/java/travelator/recommendations/RecommendationsTests.kt] (diff)

在这里,我们每次调用Recommendations时都创建一个新的实例,以便它可以捕获代表系统状态的不可变映射。

现在,我们可以编写一个使用subjectForresultFor函数:

private fun resultFor(
    featuredDestinations: Map<Location, List<FeaturedDestination>>,
    distances: Map<Pair<Location, Location>, Int>,
    locations: Set<Location>
): List<FeaturedDestinationSuggestion> {
    val subject = subjectFor(featuredDestinations, distances)
    return subject.recommendationsFor(locations)
}

示例 17.18 [mocks-to-maps.5:src/test/java/travelator/recommendations/RecommendationsTests.kt] (diff)

resultFor函数的作用域之外,没有时间敏感性,因此它实际上是一个计算。

现在我们有了输入到输出的简单映射(resultFor),我们可以编写简单的测试调用它。每个测试只需指定输入参数并检查结果是否符合预期,测试中根本不需要状态。

然后,每个测试可以是以下形式:

private fun check(
    featuredDestinations: Map<Location, List<FeaturedDestination>>,
    distances: Map<Pair<Location, Location>, Int>,
    recommendations: Set<Location>,
    shouldReturn: List<FeaturedDestinationSuggestion>
) {
    assertEquals(
        shouldReturn,
        resultFor(featuredDestinations, distances, recommendations)
    )
}

示例 17.19 [mocks-to-maps.5:src/test/java/travelator/recommendations/RecommendationsTests.kt] (差异)

这为先前令人困惑的测试提供了令人愉悦的简单性:

class RecommendationsTests {
    companion object {
        val distances = mapOf(
            (paris to eiffelTower.location) to 5000,
            (paris to louvre.location) to 1000,
            (alton to flowerFarm.location) to 5300,
            (alton to watercressLine.location) to 320,
            (froyle to flowerFarm.location) to 0,
            (froyle to watercressLine.location) to 6300
        )
    }

    ...

    @Test
    fun returns_no_recommendations_when_no_featured() {
        check(
            featuredDestinations = emptyMap(),
            distances = distances,
            recommendations = setOf(paris),
            shouldReturn = emptyList()
        )
    }

    ...

    @Test
    fun returns_recommendations_for_multi_location() {
        check(
            featuredDestinations = mapOf(
                paris to listOf(eiffelTower, louvre),
                alton to listOf(flowerFarm, watercressLine),
            ),
            distances = distances,
            recommendations = setOf(paris, alton),
            shouldReturn = listOf(
                FeaturedDestinationSuggestion(alton, watercressLine, 320),
                FeaturedDestinationSuggestion(paris, louvre, 1000),
                FeaturedDestinationSuggestion(paris, eiffelTower, 5000),
                FeaturedDestinationSuggestion(alton, flowerFarm, 5300)
            )
        )
    }
    ...
}

示例 17.20 [mocks-to-maps.5:src/test/java/travelator/recommendations/RecommendationsTests.kt] (差异)

比较这一点与原始测试是具有启发性的:

@Test
public void returns_recommendations_for_multi_location() {
    givenFeaturedDestinationsFor(paris,
        List.of(
            eiffelTower,
            louvre
        ));
    givenADistanceBetween(paris, eiffelTower, 5000);
    givenADistanceBetween(paris, louvre, 1000);

    givenFeaturedDestinationsFor(alton,
        List.of(
            flowerFarm,
            watercressLine
        ));
    givenADistanceBetween(alton, flowerFarm, 5300);
    givenADistanceBetween(alton, watercressLine, 320);

    assertEquals(
        List.of(
            new FeaturedDestinationSuggestion(alton, watercressLine, 320),
            new FeaturedDestinationSuggestion(paris, louvre, 1000),
            new FeaturedDestinationSuggestion(paris, eiffelTower, 5000),
            new FeaturedDestinationSuggestion(alton, flowerFarm, 5300)
        ),
        recommendations.recommendationsFor(Set.of(paris, alton))
    );
}

示例 17.21 [interfaces-to-funs.0:src/test/java/travelator/recommendations/RecommendationsTests.java] (差异)

诚然,这是 Java 代码,通过givenADistanceBetween调用进行了一些分解,但您可以看到这种重构是如何将我们的测试从可能具有或不具有共同结构的模糊函数迁移到清晰地测试输入与输出之间的过程。

进展

Mock 对象在软件中有其用处,并且外部驱动测试开发(TDD)无疑可以通过允许我们原型化如何在协作对象之间分配功能来改善设计,而无需承诺完成实现。然而,它们有一种掩盖设计问题的习惯,因为它们允许我们测试设计表达为对象交互的方式,而更好地看作是数据流的方式。

在这个例子中,我们看到如何专注于数据可以简化我们的测试,特别是在我们只读取值的情况下。在第二十章,《执行 I/O 以传递数据》中,我们探讨了如何将这种技术应用于写入操作。

第十八章:开放给密封类

我们的系统由类型和操作组成,名词和动词。在 Java 中,名词以类和接口的形式表达,动词则以方法的形式存在;但是 Kotlin 添加了密封类层次结构和独立函数。它们为此增添了什么新功能呢?

变化是设计软件时持续面对的挑战。使用我们软件的人越多,他们就会想到越多他们希望软件执行的任务。为了支持新的用例,我们需要添加能够处理现有数据类型的新函数,以及能够与现有函数配合使用的新数据类型。如果我们的设计与软件演变的方式高度一致,我们可以通过添加新代码和对现有代码进行少量的局部更改来添加新功能。如果不一致,当我们添加新数据类型时,我们将不得不更改许多函数,或者在需要添加功能时更改许多数据类型。

我们在领域模型的核心实体中特别感受到数据类型和函数变化的张力。例如,旅行者的行程表是我们 Travelator 应用程序的核心实体。应用程序的许多功能展示、修改或计算关于行程的信息。因此,用户的许多功能请求影响了我们的Itinerary类型并不奇怪。我们的旅行者希望在他们的行程中包括更多类型的事物:不仅仅是旅程和住宿,正如我们在第十章中看到的,现在还包括沿途的餐馆预订和景点。他们还希望在行程中做更多的事情。在第十四章中,我们看到如何估算它们的成本,但我们的客户也希望按照成本、时间或舒适度进行比较,在地图上查看它们,将它们导入日历,与朋友分享...他们的想象力是无限的。

当我们上次查看第十四章中的Itinerary类时,我们将行程表建模为数据类,其中包含路线属性和沿途所需的住宿属性:

data class Itinerary(
    val id: Id<Itinerary>,
    val route: Route,
    val accommodations: List<Accommodation> = emptyList()
) {
    ...
}

示例 18.1 [accumulator.17:src/main/java/travelator/itinerary/Itinerary.kt] (diff)

从那时起,我们已向应用程序添加了更多功能,因此,也向行程表中添加了更多类型的项目。我们发现,将每种类型的行程项目保存在单独的集合中变得越来越繁琐,因为我们的大部分代码涉及合并这些集合或对不同集合应用相同的过滤器和转换。因此,我们决定,一个Itinerary将维护一个单一的ItineraryItem集合,而不是将每种类型的项目保存在单独的集合中:

data class Itinerary(
    val id: Id<Itinerary>,
    val items: List<ItineraryItem>
) : Iterable<ItineraryItem> by items

示例 18.2 [open-to-sealed.0:src/main/java/travelator/itinerary/Itinerary.kt] (diff)

ItineraryItem是一个接口,由我们之前看到的具体项类型实现:JourneyAccommodation,以及新类型RestaurantBookingAttraction

interface ItineraryItem {
    val id: Id<ItineraryItem>
    val description: String
    val costs: List<Money>
    val mapOverlay: MapOverlay
    ... and other methods
}

示例 18.3 [open-to-sealed.0:src/main/java/travelator/itinerary/ItineraryItem.kt] (diff)

Itinerary的操作不依赖于其项的具体类型。例如,要在地图上显示行程表,我们创建一个MapOverlay,它将在前端的地图瓦片上渲染。Itinerary的叠加层是它包含的所有项的叠加层组。Itinerary类及其客户端不知道也不需要知道每个项如何表示自己作为地图叠加层。

val Itinerary.mapOverlay
    get() = OverlayGroup(
        id = id,
        elements = items.map { it.mapOverlay })

示例 18.4 [open-to-sealed.0:src/main/java/travelator/itinerary/Itinerary.kt] (diff)

这种多态性使得向系统添加新类型的ItineraryItem非常容易,而不必更改使用Itinerary类型的应用程序部分。

不过,最近我们发现,大部分添加到 Travelator 的新功能涉及向ItineraryItineraryItem添加新操作,而不是新的ItineraryItem类型。对ItineraryItem接口及其实现的更改常常是导致团队成员在不同功能上工作时发生合并冲突的常见原因。随着每个新功能的增加,ItineraryItem变得越来越庞大。它似乎吸引了支持应用程序远程相关部分的行为,具备支持渲染、成本估算、按舒适度排名、绘制地图等属性,以及更多隐藏的内容在…及其他方法之后。悖论的是,在我们应用程序的核心,面向对象的多态性正在增加耦合!

面向对象的多态性使得数据类型的变化可以与一组不经常变化的操作相结合。一段时间以来,这正是我们代码库所需要的,但现在它已经稳定下来,我们需要相反的:针对一组不经常变化的数据类型进行操作的多样性。

如果我们使用 Java(至少到 Java 16),在这个维度上,没有语言特性可以帮助我们应对变化性。Java 支持变化性的主要特性是面向对象的多态性,但当操作频繁变化时,它并不能提供帮助,因为操作变化的频率超过了数据类型的变化。

我们可以使用双重分派模式,但它涉及大量样板代码,并且由于与检查异常不兼容,Java 中并不常用。相反,Java 程序员经常采用运行时类型检查,使用instanceof和向下转型运算符来针对不同类的对象运行不同的代码:

if (item instanceof Journey) {
    var journey = (Journey) item;
    return ...
} else if (item instanceof Accommodation) {
    var accommodation = (Accommodation) item;
    return ...
} else if (item instanceof RestaurantBooking) {
    var restaurant = (RestaurantBooking) item;
    return ...
} else {
    throw new IllegalStateException("should never happen");
}

示例 18.5 [open-to-sealed.0:src/main/java/travelator/itinerary/ItineraryItems.java] (diff)

那个IllegalStateException显示这种方法是有风险的。尽管编译器可以对我们对多态方法的调用进行类型检查,但我们手工编写的运行时类型检查和强制类型转换明确地规避了编译时检查。类型检查器无法判断我们的强制类型转换是否正确或我们的条件语句是否穷尽:是否适用于所有可能的子类。如果从方法返回一个值,我们必须编写一个else子句来返回一个虚拟值或抛出异常,即使我们为ItineraryItem的每个子类都有分支,但“不可能执行™”的else子句也不能执行。

即使在编写代码时覆盖了所有ItineraryItem的子类型,如果稍后添加新类型,我们也必须找到所有这样的代码并进行更新。结果表明,我们在这里没有这样做,所以如果将Attraction添加到Itinerary中,此代码将因IllegalArgumentException而失败。面向对象解决了这个问题,但我们规避了解决方案,因为我们厌倦了在添加操作时不得不更新大量类。

Kotlin 也可以进行类型检查和向下转型,而且具有相同的开销和风险。但是,Kotlin 还有另一种机制来组织类和行为,使得运行时类型检查安全且方便:密封类。密封类是一个具有固定直接子类集的抽象类。我们必须在同一编译单元和包中定义密封类及其子类;编译器会阻止我们在其他地方扩展密封类。由于这种限制,密封类层次结构的运行时类型检查不会像 Java 中那样存在问题。静态类型检查器可以保证对密封类子类型进行运行时类型检查的when表达式涵盖所有可能的情况,而且仅涵盖可能的情况。

当语句不被检查为穷尽时。

编译器对when表达式进行穷尽性检查,但不检查when语句;如果整个when表达式的值不被使用,则when变成语句。您可以通过使用when的结果来强制编译器进行穷尽性检查,即使它的类型是Unit

如果 when 是函数体中唯一的语句,您可以将函数重构为单表达式形式。如果 when 是多语句函数中的最后一条语句,您可以使用 return 关键字显式地使用其值。当 when 在函数体中间时,将其提取为自己的函数可能是有意义的。

当这些选项都不适用时,您可以使用以下实用函数来强制进行详尽性检查:

val <T> T.exhaustive get() = this

在这种用法中,当 when 不详尽时,将阻止编译:

when (instanceOfSealedClass) {
    is SubclassA -> println("A")
    is SubclassB -> println("B")
}.exhaustive

与多态方法相比,密封类和 when 表达式使得添加适用于固定类型层次结构的新操作变得简单,尽管如果向该层次结构添加新类型仍需更改所有这些操作。此时编译器将通过检查确保所有这些操作涵盖层次结构中的所有可能类型。

多态还是密封类?

一些语言具有机制,允许我们在不修改现有代码的情况下变化类型和操作。Haskell 拥有类型类,Scala 拥有隐式参数,Rust 拥有特质,Swift 拥有协议,而 Clojure 和 Common Lisp 则拥有根据多个参数的类分发的多态函数。

Kotlin 没有类似的功能。在 Kotlin 中设计时,我们必须根据程序在演变过程中最频繁变化的维度——类型或操作——选择面向对象的多态或密封类。当数据类型集更频繁地变化时,面向对象的多态更为适用;而当操作集更频繁地变化时,则适合使用密封类层次结构。

只在密封类层次结构中向下转型

只在密封类层次结构的根部使用类型转换,将其转换为详尽的 when 表达式中的一个子类。否则,从静态类型转型是有风险的。实现值的实际类可能具有违反其静态类型表达的约束的操作。

例如,正如我们在第六章中看到的,静态类型 List 防止了变异,但 Kotlin 的高阶函数返回可以被变异的列表,如果您从 List 强制转换为 MutableList。一个将列表参数从 List 强制转换为 MutableList 并进行突变的函数很可能会在代码中引入错误,因为它违反了其调用者的预期。它可能会引入非常难以找到的别名错误,因为函数签名的类型声明中并未明确表达远程操作的可能性。如果 Kotlin 标准库的未来版本从其高阶函数返回不可变列表,该函数将继续成功编译但在运行时崩溃。

仅仅因为你 可以 从超类型向子类型转换,并不意味着你打算这样做。这种可能性很可能只是一个实现细节。密封类层次结构表明向下转换是有意的,得到支持,并且通过编译器的完整性检查确保安全。

将接口转换为密封类

我们即将添加另一个涉及行程和行程项的功能:使 Itinerary 出现在旅行者的日历应用程序中。我们不想在已经臃肿的 ItineraryItem 接口中添加更多方法,并且将我们应用程序域的核心类与另一个外围模块的需求耦合在一起。现在是时候下定决心,将 ItineraryItem 从多态方法的接口转换为密封类层次结构和独立函数,并将这些独立函数移动到使用它们的模块中。

我们在撰写本文时,Kotlin 1.4 是当前版本,因此我们必须在同一文件中定义密封类及其直接子类。因此,我们的第一步是使用 IDE 的“移动类”重构将 ItineraryItem 的实现移动到与接口相同的文件中。完成后,我们可以将接口及其实现转换为密封类层次结构。IntelliJ 没有自动的重构工具,所以我们必须通过手动编辑类定义来完成。至少将所有类移动到同一个文件中已经使任务变得更加容易:

sealed class ItineraryItem { ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
    abstract val id: Id<ItineraryItem> ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/2.png)
    abstract val description: String
    abstract val costs: List<Money>
    abstract val mapOverlay: MapOverlay
    ... and other methods
}

data class Accommodation(
    override val id: Id<Accommodation>,
    val location: Location,
    val checkInFrom: ZonedDateTime,
    val checkOutBefore: ZonedDateTime,
    val pricePerNight: Money
) : ItineraryItem() { ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/3.png)
    val nights = Period.between(
        checkInFrom.toLocalDate(),
        checkOutBefore.toLocalDate()
    ).days
    val totalPrice: Money = pricePerNight * nights

    override val description
        get() = "$nights nights at ${location.userReadableName}"
    override val costs
        get() = listOf(totalPrice)
    override val mapOverlay
        get() = PointOverlay(
            id = id,
            position = location.position,
            text = location.userReadableName,
            icon = StandardIcons.HOTEL
        )

    ... and other methods
}

... and other subclasses

示例 18.6 [open-to-sealed.2:src/main/java/travelator/itinerary/ItineraryItem.kt] (diff)

1

我们将 ItineraryItem 声明为 sealed class 而不是接口。

2

因为现在它是一个类,我们必须显式地将其方法标记为 abstract。如果接口中有任何具有默认实现的方法,我们将不得不将它们声明为 open,以便子类仍然可以重写它们。

3

我们用超类构造函数替换具体项类中接口的声明。

注意

Kotlin 1.5(在我们完成本书后发布)支持密封 接口,这使得此重构更加容易。不必将子类移动到同一个文件中或调用构造函数。

ItineraryItem 现在是一个密封类。它的操作仍然是多态方法,但我们可以通过编写使用 when 表达式安全分派具体项类型的扩展函数,而无需更改 ItineraryItem 类来添加 操作。

首先,我们将编写我们需要将 Itinerary 转换为日历的扩展函数。完成后,我们将继续重构,使 ItineraryItem 上的其他操作工作方式相同:

fun ItineraryItem.toCalendarEvent(): CalendarEvent? = when (this) {
    is Accommodation -> CalendarEvent(
        start = checkInFrom,
        end = checkOutBefore,
        description = description,
        alarms = listOf(
            Alarm(checkInFrom, "Check in open"),
            Alarm(checkOutBefore.minusHours(1), "Check out")
        )
    )
    is Attraction -> null
    is Journey -> CalendarEvent(
        start = departureTime,
        end = arrivalTime,
        description = description,
        location = departsFrom,
        alarms = listOf(
            Alarm(departureTime.minusHours(1)))
    )
    is RestaurantBooking -> CalendarEvent(
        start = time,
        description= description,
        location = location,
        alarms = listOf(
            Alarm(time.minusHours(1)))
    )
}

示例 18.7 [open-to-sealed.3:src/main/java/travelator/calendar/ItineraryToCalendar.kt] (diff)

现在,让我们将 ItineraryItem 的其余方法从多态方法重构为扩展函数,这些函数在(现在是密封的)类上使用 when 表达式切换项目的类型。我们将通过 mapOverlay 属性的过程进行步骤。

当我们在 ItineraryItemmapOverlay 定义上按 Alt-Enter 时,上下文菜单中包括操作“将成员转换为扩展”。真的那么简单吗?不幸的是,不是的。在撰写本文时,IDE 操作仅使我们部分完成,并留下了不能编译的代码:

sealed class ItineraryItem {
    abstract val id: Id<ItineraryItem>
    abstract val description: String
    abstract val costs: List<Money> ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
    ... and other methods
}

val ItineraryItem.mapOverlay: MapOverlay ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/2.png)
    get() = TODO("Not yet implemented")

data class Accommodation(
    override val id: Id<Accommodation>,
    val location: Location,
    val checkInFrom: ZonedDateTime,
    val checkOutBefore: ZonedDateTime,
    val pricePerNight: Money
) : ItineraryItem() {
    val nights = Period.between(
        checkInFrom.toLocalDate(),
        checkOutBefore.toLocalDate()
    ).days
    val totalPrice: Money = pricePerNight * nights

    override val description
        get() = "$nights nights at ${location.userReadableName}"
    override val costs
        get() = listOf(totalPrice)
    override val mapOverlay ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/3.png)
        get() = PointOverlay(
            id = id,
            position = location.position,
            text = location.userReadableName,
            icon = StandardIcons.HOTEL
        )

    ... and other methods
}

示例 18.8 [open-to-sealed.4:src/main/java/travelator/itinerary/ItineraryItem.kt] (diff)

1

IDE 从 ItineraryItem 类中删除了 mapOverlay 方法…

2

…并替换为一个扩展函数。不幸的是,该扩展函数只包含一个抛出 UnsupportedOperationExceptionTODO

3

在子类中,IDE 在 mapOverlay 属性上保留了 override 修饰符,但在超类中不再具有要覆盖的方法。

通过在子类中移除 override 修饰符,我们可以使代码再次编译。然后,通过将扩展函数的主体实现为 when 表达式来使代码实际起作用,该表达式根据 ItineraryItem 的类型调用每个具体类上现在单态的 mapOverlay getter:

val ItineraryItem.mapOverlay: MapOverlay get() = when (this) {
    is Accommodation -> mapOverlay
    is Attraction -> mapOverlay
    is Journey -> mapOverlay
    is RestaurantBooking -> mapOverlay
}

示例 18.9 [open-to-sealed.5:src/main/java/travelator/itinerary/ItineraryItem.kt] (diff)

在我们覆盖了 ItineraryItem 的所有子类之前,when 表达式将无法编译。IntelliJ 还会突出显示每个子类 mapOverlay 属性的读取,以显示编译器的流感知类型智能地将隐式 this 引用从 ItineraryItem 转换为正确的子类。

现在这次重构的目的是防止每个 ItineraryItem 实现都必须知道地图覆盖层。但目前每个实现仍然知道,因为每个都有自己的 mapOverlay 属性 - 最初在接口中覆盖属性的属性:

data class Accommodation(
...
) : ItineraryItem() {
    ...
    val mapOverlay
        get() = PointOverlay(
            id = id,
            position = location.position,
            text = location.userReadableName,
            icon = StandardIcons.HOTEL
        )
    ...

示例 18.10 [open-to-sealed.5:src/main/java/travelator/itinerary/ItineraryItem.kt] (diff)

我们可以通过使用“将成员转换为扩展”来解决此问题:

data class Accommodation(
...
) : ItineraryItem() {
    ...
}

val Accommodation.mapOverlay
    get() = PointOverlay(
        id = id,
        position = location.position,
        text = location.userReadableName,
        icon = StandardIcons.HOTEL
    )

示例 18.11 [open-to-sealed.6:src/main/java/travelator/itinerary/ItineraryItem.kt] (diff)

现在ItineraryItem.mapOverlay看起来完全没有变化:

val ItineraryItem.mapOverlay: MapOverlay get() = when (this) {
    is Accommodation -> mapOverlay
    is Attraction -> mapOverlay
    is Journey -> mapOverlay
    is RestaurantBooking -> mapOverlay
}

示例 18.12 [open-to-sealed.6:src/main/java/travelator/itinerary/ItineraryItem.kt] (diff)

仔细看(好吧,在 IntelliJ 中悬停),我们会发现这些属性访问现在是扩展属性,而不是方法调用——Accommodation等等不再依赖于MapOverlay。现在ItineraryItem.mapOverlay和所有子类属性都是扩展,它们不需要在同一个文件中定义封闭类。我们可以将它们移动到使用它们的模块或包中,它们不会混淆我们的核心域抽象:

package travelator.geo

import travelator.itinerary.*

val ItineraryItem.mapOverlay: MapOverlay get() = when (this) {
    is Accommodation -> mapOverlay
    is Attraction -> mapOverlay
    is Journey -> mapOverlay
    is RestaurantBooking -> mapOverlay
}

private val Accommodation.mapOverlay
    get() = PointOverlay(
        id = id,
        position = location.position,
        text = location.userReadableName,
        icon = StandardIcons.HOTEL
    )

 ... Attraction.mapOverlay etc

示例 18.13 [open-to-sealed.7:src/main/java/travelator/geo/ItineraryToMapOverlay.kt] (diff)

我们可以对ItineraryItem的其他成员执行相同的操作,直到封闭类仅声明类型的基本属性。目前对于ItineraryItem,只有id属性是真正基本的:在封闭类上声明id为抽象属性会强制每个子类都具有标识符。

其他属性中,有些明显只是支持应用程序特定功能的,如mapOverlaytoCalendar。其他,如description,处于灰色地带:它们支持应用程序的许多功能,但不是ItineraryItem的基本属性。例如,每个子类型都从其基本属性派生其描述。Nat 更喜欢将这样的属性定义为扩展,而 Duncan 更喜欢将它们定义为类的成员。Nat 正在编写此示例,所以我们将description定义为扩展:

val ItineraryItem.description: String
    get() = when (this) {
        is Accommodation ->
            "$nights nights at ${location.userReadableName}"
        is Attraction ->
            location.userReadableName
        is Journey ->
            "${departsFrom.userReadableName} " +
                "to ${arrivesAt.userReadableName} " +
                "by ${travelMethod.userReadableName}"
        is RestaurantBooking -> location.userReadableName
    }

示例 18.14 [open-to-sealed.8:src/main/java/travelator/itinerary/ItineraryDescription.kt] (diff)

在您自己的代码中,您将不得不做出自己的判断。这样一来,封闭的ItineraryItem类仅声明id属性,而其子类声明它们的基本属性。整个层次结构如下:

sealed class ItineraryItem {
    abstract val id: Id<ItineraryItem>
}

data class Accommodation(
    override val id: Id<Accommodation>,
    val location: Location,
    val checkInFrom: ZonedDateTime,
    val checkOutBefore: ZonedDateTime,
    val pricePerNight: Money
) : ItineraryItem() {
    val nights = Period.between(
        checkInFrom.toLocalDate(),
        checkOutBefore.toLocalDate()
    ).days
    val totalPrice: Money = pricePerNight * nights
}

data class Attraction(
    override val id: Id<Attraction>,
    val location: Location,
    val notes: String
) : ItineraryItem()

data class Journey(
    override val id: Id<Journey>,
    val travelMethod: TravelMethod,
    val departsFrom: Location,
    val departureTime: ZonedDateTime,
    val arrivesAt: Location,
    val arrivalTime: ZonedDateTime,
    val price: Money,
    val path: List<Position>,
    ... and other fields
) : ItineraryItem()

data class RestaurantBooking(
    override val id: Id<RestaurantBooking>,
    val location: Location,
    val time: ZonedDateTime
) : ItineraryItem()

示例 18.15 [open-to-sealed.8:src/main/java/travelator/itinerary/ItineraryItem.kt] (diff)

现在我们的 ItineraryItem 模型是一个纯数据类的密封类层次结构。应用程序特性需要的操作全部是这些特性模块中的扩展函数。只有 id 属性作为多态的 val 保留,因为它是类型的基本属性,不特定于应用程序的任何一个特性。

Moving On

随着我们的软件不断发展,我们必须向系统中添加新的数据类型和新的操作。在 Kotlin 中,就像在 Java 中一样,面向对象的多态性使我们能够轻松地添加新的数据类型,而无需更改现有函数的代码。我们还可以使用密封类和安全的运行时类型检查,轻松地在现有数据类型上添加新的函数,而无需更改定义这些类型的代码。我们的选择取决于代码演化中最频繁变化的部分:数据类型还是操作。在 Kotlin 中管理变化涉及掌握何时应用这两种机制到我们的领域模型。

如果我们的赌注最终证明是错误的,我们必须从一个形式重构到另一个形式。当所有代码都在单个代码库中时,Kotlin 和 IntelliJ 使得在两种形式之间进行重构变得非常简单。本章描述了从我们在 Java 中编写的面向对象多态到 Kotlin 的密封类的过程。反向操作涉及到 Martin Fowler 在 重构:改善既有代码的设计 中描述的重构步骤,比如“用多态替换条件表达式”,因此我们不会在本书中涵盖这部分内容。

第十九章:抛出到返回

Java 使用已检查和未检查的异常来表示和处理错误。Kotlin 支持异常,但并不像 Java 那样在语言中内置已检查的异常。为什么 Kotlin 拒绝了 Java 的方法,我们应该使用什么代替呢?

您不需要长时间为计算机编写程序才会发现事情会出错…

…以 如此多 的方式。

在他们的职业生涯早期,您的作者倾向于忽略错误。至少在项目早期,我们经常仍然这样做。但随着系统的增长,我们学会了故障如何影响应用程序,并开始添加处理代码——起初是零散的,后来是根据经验制定的某种策略。在这方面,我们的错误处理与我们软件设计的其他方面一样发展。有时我们从一开始就设计,利用我们对类似系统的经验;其他时候,我们允许软件编写教导我们它需要什么。

在没有更明确的策略的情况下,大多数系统默认在出现问题时引发异常,并在某个外部级别捕获和记录这些异常。在这种情况下,命令行实用程序将只是退出,希望提供足够的信息给用户以纠正问题并重试。服务器应用程序或具有事件循环的 GUI 通常只会中止当前交互并继续下一个。

通常这只是对我们用户的不良体验,但有时错误会损坏系统的持久状态,因此更正初始问题并重试是无效的。这是“关闭并重新启动”的明智建议的源头。我们的系统主要在安全状态下启动,因此重新启动后重试应该成功。如果不行,嗯,您可能曾经处于重新安装操作系统的情况——这是删除受损持久状态的终极方法。

如果错误没有得到很好的管理,但尽管如此系统变得成功,由于错误导致的损坏的诊断和修复可能会占用整个团队的所有时间。这对于软件项目来说并不是一个好的位置。问我们是怎么知道的!

因此,我们不希望出现错误,因为它们会让我们的用户感到烦恼,并且可能导致需要花费大量精力才能修复的损坏,如果我们可以修复的话。我们看到了什么样的错误呢?

程序出错的原因有很多。当我们说 程序 时,我们也指的是函数、方法、过程——我们调用的任何代码。当我们说 出错 时,我们指的是未能完成我们期望它们完成的工作。

导致此故障的原因包括:

  • 有时程序需要与其他系统通信,但通信在某种程度上失败了。

  • 我们经常没有为软件提供执行其工作所需的正确输入。

  • 显然,一些程序员会犯错误:甚至会指示他们的计算机取消引用空引用或读取超出集合末尾的内容!

  • 我们运行的环境由于某种原因而失败;例如,它可能耗尽内存或无法加载类。

有些失败情况不属于这些类别,但大多数情况下是适用的。

这似乎不是一个太长的列表,然而作为一个行业,我们在可靠性方面并不享有很好的声誉。错误处理似乎很难。为什么呢?

首先,我们经常不知道操作是否会失败,以及如果失败的话会是怎样的失败。如果我们知道,那么处理错误的知识可能在离问题检测点很远的代码中。然后检测错误的代码和从错误中恢复的代码很难与正常路径分离,因此很难测试。再加上错误可能使我们的系统陷入无法恢复的状态,我们最终面临的情况是,大多数开发者宁愿抱有乐观期望,而不愿承担艰苦的工作并且最终还是会出错。

艰苦的工作 容易出错?计算机不是应该解放我们,从事这些苦活使我们能专注于有趣的创造性工作吗?是的,它们确实应该,因此我们将通过编程语言如何使程序员的工作更安全更简单的角度来关注错误处理。

异常之前的错误处理

大多数错误处理如今都基于异常,但在某些情况下仍然可以使用其他技术。我们将首先看看这些技术的优缺点。缺点将告诉我们为什么异常现在占据主导地位;而优点则可能在异常不适用时为我们提供选择。

忽略错误

我们可以忽略错误。要么失败的例程不做任何事情来引起调用者的注意,要么调用者根本不去检查。

这可能导致持久数据的损坏和静默地未能完成任务,所以在大多数情况下,我们需要有更高的要求。

仅仅崩溃

一些程序在检测到错误时会直接退出。

结合错误重启的监控程序和仔细编码以防止持久状态的破坏,这是一种经过考验的战略,可能是适当的。抛出异常来中止操作是将这种技术应用于过程而不是整个程序的表现。

返回一个特殊值

返回一个特殊值来表示错误可以是一种有用的技术。例如,当在列表中找不到项时,函数可以返回-1而不是索引。

当所有返回值都是函数的有效结果时,此技术无法使用。它也可能很危险,因为调用者必须了解(并记住)约定。如果我们尝试通过减去它们的索引来计算列表中两个项之间的距离,当其中一个找不到并返回-1时,除非我们明确处理特殊情况,否则我们的计算将是错误的。我们不能依赖类型检查器来帮助我们避免错误。

在发生错误时返回特殊值的一个特殊情况是返回 null。在大多数语言中,这样做非常危险,因为如果调用者没有显式检查 null,那么使用结果将抛出NullPointerException,这可能比初始问题更严重。不过,在 Kotlin 中,类型检查强制调用者处理 null,这使得这种技术是安全和有效的。

设置全局标志

返回特殊值的一个问题是很难表明发生了几种可能的错误之一。为了解决这个问题,我们可以将特殊值与设置全局变量结合起来。当检测到特殊值时,调用者可以读取errno,例如,以确定问题所在。

这种技术在 C 语言中很流行,但在很大程度上被基于异常的错误处理所取代。

返回状态码

另一种在异常出现之前的技术是返回状态码。当函数不返回值(完全是副作用)或以其他方式返回值,通常通过改变传递的引用参数时,可以使用这种技术。

调用特殊函数

当出现错误时调用特殊函数有时是一个好策略。通常,错误函数作为参数传递给被调用的函数。如果检测到问题,则调用错误函数,并将表示错误的值作为参数传递。有时,错误函数可以通过其返回值来表示失败的操作是否应重试或中止。另一种技术是让错误函数提供应由被调用函数返回的值。

这种技术是错误处理中应用策略模式的一个例子。即使异常可用,它在特定情况下也是一个有用的工具。

异常处理与异常

所有这些技术都存在一个缺点,即调用代码能够在更大或更小程度上忽略错误的发生。

异常解决了这个问题。在错误发生时,操作会自动中止,并且调用者显式处理异常。如果调用者没有处理它,异常将继续传播到调用栈的更深层,直到有地方处理它;如果没有处理异常,线程将终止。

Java 和 Checked Exceptions

当 Java 发布时,异常相对较新,语言设计者决定在这一领域进行创新。他们使方法可能引发的异常成为其签名的一部分。这样,调用者可以知道,例如,一个方法可能因为它正在读取的网络资源不再可用而失败。如果一个方法声明它可能以这种方式失败,那么调用该方法的每个调用者都必须处理该失败(通过指定在catch块中如何处理)或声明它也可能因相同的异常而失败。这确保了程序员考虑到这些错误的可能性。这些异常称为检查异常,因为编译器检查它们是否已处理(或重新声明为调用方法抛出)。

检查异常设计用于当程序员可能合理地找到一种恢复方法时:例如重试数据库写入或重新打开套接字。语言设计者确定了另外两种类型:错误和运行时异常。

错误

java.lang.Error的子类专门用于严重到 JVM 无法保证运行时正确功能的故障。可能是无法加载类,或者系统内存耗尽。这些情况可能发生在程序执行的任何时刻,因此可能导致任何函数执行失败。由于任何方法都可能以这种方式失败,所以在每个方法签名中包含它们是没有价值的,因此不需要声明Error

运行时异常

RuntimeException的子类代表其他错误。其意图是这些错误应该保留给程序员错误造成的问题,例如访问空引用或尝试读取集合的边界外。在这两种情况下,程序员本可以更加谨慎。尽管如此,每一行代码都可能存在程序员错误,因此RuntimeExceptions也免除了必须声明的义务。

这种方案迫使开发人员处理可能因 I/O 错误或其他超出其控制范围的事物(检查异常)而失败的操作,允许经济有效的防御性编程。在另一极端,如果抛出Error,最好的默认方法是尽快退出进程,以免对持久状态造成更多损害。

RuntimeException是一个折中的情况。如果它们代表了程序员的错误,我们可能应该假设我们刚刚证明了我们并不真正知道我们的程序正在做什么,并中止当前操作或整个应用程序。否则,我们可能会尝试恢复,特别是如果我们的系统已被设计为限制对持久状态可能造成的损害。

你的作者们确实非常喜欢检查异常,但似乎他们是少数派,因为多年来 Java 中的检查异常已经不受欢迎。从一开始,检查异常就受到奇怪的决定的影响,使得未检查的RuntimeException成为否定其他检查异常的Exception的子类,因此那些想要处理所有检查异常的代码发现自己也在捕获未检查的异常,隐藏了编程错误。它们也没有因为 Java API 的不一致而得到帮助。以从字符串中提取数据为例:URL构造函数URL(String)抛出检查MalformedURLException,而Integer.parseInt(String)抛出未检查NumberFormatException

混淆对于使用哪种类型的异常的困惑加剧了,不久之后,大多数 Java 库声明的唯一检查异常都是IOExceptions。即使是数据库库如 Hibernate,明确在网络上传输并且明确会遭遇IOExceptions的情况下,也只会抛出RuntimeExceptions

一旦你的代码中有相当一部分使用了未检查异常,情况就不妙了。无法依赖于检查异常来警示你函数可能失败的方式。相反,你只能采取一些战术性的防御编程和老旧技术,将其投入生产,查看日志中记录的错误,并添加代码来处理那些看起来不太合适的情况。

Java 8 引入 lambda 函数是检查异常的最后一击。决定在支持 lambda 的函数式接口的签名中不声明异常类型,因此这些异常无法传播。这并非是无法克服的问题,但公正地说,你的作者们也可能在这里放弃。然而,结果是,旧的标准 Java API 声明了检查异常(特别是IOException),而新的标准 API(特别是 streams)迫使开发者否认它们。

Kotlin 与异常

Kotlin 支持异常,因为它运行在 JVM 上,并且异常是平台内置的。尽管如此,Kotlin 并没有特别处理检查异常,因为 Java 在这方面已经败北了,并且与 Java 一样,它们难以与高阶函数协调。Kotlin 能够大部分忽略检查异常,因为它们不是 JVM 的特性,而是 Java 编译器的特性。编译器确实在字节码中记录了方法声明的检查异常(以便进行检查),但 JVM 本身并不关心。

结果是,当涉及错误处理时,默认情况下,Kotlin 程序与大多数 Java 程序一样既不好也不坏。

这个例外是,正如我们早些时候观察到的那样,Kotlin 可以使用null来指示错误,因此调用者将不得不考虑到null的可能性。例如,在运行时中有<T> Iterable<T>.firstOrNull(): T?。不过,值得注意的是,运行时也定义了first(),如果集合为空则会抛出NoSuchElementException

超越异常:函数式错误处理

静态类型的函数式编程语言通常不接受异常,而是倾向于另一种基于Either Types的错误处理技术。我们很快会看到 Either Type 是什么,但为什么函数式编程人员不喜欢异常呢?

函数式编程的一个显著特征是引用透明性。当一个表达式是引用透明的时候,我们可以安全地用其评估结果替换它。所以如果我们写下:

val secondsIn24hours = 60 * 60 * 24

然后我们可以用3600替换60 * 60,或者用1440替换60 * 24而不影响结果。事实上,编译器可以决定为我们用86400替换整个表达式,而(除非我们检查字节码或使用调试器)我们不会知道这一点。

相比之下:

secondsIn(today())

不是引用透明的,因为today()的结果与昨天不同,并且任何一天都可能增加一秒。因此,secondsIn(today())的值可能因调用时间不同而异,并且我们不能每次使用它时都用相同的值替换表达式。

这与我们在第七章中看到的概念相同。“Calculations”是引用透明的;“Actions”不是。

为什么我们应该关心呢?因为引用透明性大大简化了对程序行为的推理,这反过来会减少错误并提供更多重构和优化的机会。如果我们希望实现这些目标(至少我们不希望出现更多错误和减少机会),那么我们应该追求引用透明性。

这与错误处理有什么关系?让我们回到我们的Integer.​par⁠seInt(String)示例中来看看。对于给定的有效输入,parseInt将始终返回相同的值,因此它可以是引用透明的。然而,在String不表示整数的情况下,parseInt会抛出异常而不是返回结果。我们无法用异常替换函数调用的结果,因为表达式的类型是Int,而Exception不是Int。异常破坏了引用透明性。

如果我们不使用异常,而是回到使用特殊值来表示错误的旧技巧,我们将具有引用透明性,因为该错误值可以替换表达式。在 Kotlin 中,null在这里非常适合,因此我们可以定义parseInt返回Int?。但是如果我们需要知道第一个不是数字的字符是什么呢?我们可以通过异常传达这些信息,但不能在返回类型为Int?中。

我们能否找到一种方法,让我们的函数返回要么Int,要么它失败的方式?

如他们所说,答案就在问题中。我们定义了一个类型Either,它可以同时持有两种类型中的一种:

sealed class Either<out L, out R>

data class Left<out L>(val l: L) : Either<L, Nothing>()

data class Right<out R>(val r: R) : Either<Nothing, R>()

在 Kotlin 中,密封类(第十八章)非常适合这种情况,因为我们可以定义自己的子类型,但其他人无法访问。

Either用于错误处理时,约定是Right用于结果,Left用于错误。如果我们遵循这个约定,我们可以定义:

fun parseInt(s: String): Either<String, Int> = try {
    Right(Integer.parseInt(s))
} catch (exception: Exception) {
    Left(exception.message ?: "No message")
}

我们如何使用这个?正如我们在第十八章中看到的,when表达式和智能转换非常适合让我们编写如下代码:

val result: Either<String, Int> = parseInt(readLine() ?: "")
when (result) {
    is Right -> println("Your number was ${result.r}")
    is Left -> println("I couldn't read your number because ${result.l}")
}

通过返回Either,我们强制客户端处理可能失败的情况。这在功能形式上具有一些检查异常的优势。为了采纳这种风格,我们使得所有在 Java 中声明会抛出检查异常的函数返回Either。调用者可以解开成功的部分并对其进行操作,或者传递任何失败:

fun doubleString(s: String): Either<String, Int> {
    val result: Either<String, Int> = parseInt(s)
    return when (result) {
        is Right -> Right(2 * result.r)
        is Left -> result
    }
}

尽管使用when来解开Either是合乎逻辑的,但也很冗长。这种模式经常出现,因此我们定义map如下:

inline fun <L, R1, R2> Either<L, R1>.map(f: (R1) -> R2): Either<L, R2> =
    when (this) {
        is Right -> Right(f(this.r))
        is Left -> this
    }

这使我们可以将前面的函数写成:

fun doubleString(s: String): Either<String, Int> = parseInt(s).map { 2 * it }

为什么那个函数叫做map而不是invokeUnlessLeft?嗯,如果你眯起眼睛,你可能能看出它和List.map有些相似。它将一个函数应用于容器的内容,并将结果放入另一个容器中。对于Either来说,map仅在是Right(非错误)时应用该函数;否则,它保持不变传递Left

练习那种眯眼看的技能,因为我们现在要定义:

inline fun <L, R1, R2> Either<L, R1>.flatMap(
    f: (R1) -> Either<L, R2>
): Either<L, R2> =
    when (this) {
        is Right -> f(this.r)
        is Left -> this
    }

这将解开我们的值并使用它来调用一个可能失败的函数(因为它返回Either)。我们能对此做些什么呢?好吧,假设我们想要从Reader中读取并打印结果的两倍。我们可以定义一个readLine的包装器,它返回Either而不是用异常失败:

fun BufferedReader.eitherReadLine(): Either<String, String> =
    try {
        val line = this.readLine()
        if (line == null)
            Left("No more lines")
        else
            Right(line)
    } catch (x: IOException) {
        Left(x.message ?: "No message")
    }

这允许我们使用flatMapeitherReadLinedoubleString组合起来:

fun doubleNextLine(reader: BufferedReader): Either<String, Int> =
    reader.eitherReadLine().flatMap { doubleString(it) }

如果eitherReadLine失败,此代码将返回一个带有失败的Left;否则,它将返回doubleString的结果,该结果本身可能是一个带有最终Int结果的LeftRight。通过这种方式,一系列map和/或flatMap调用就像一系列表达式,可能会抛出异常;第一个失败会中止其余的计算。

如果你来自面向对象的背景,这种风格确实需要一些时间来适应。根据我们的经验,再多的阅读也不会有帮助;你只需认真开始以这种方式编写代码,直到它变得不那么陌生。我们将通过在后续的示例中与你合作来分享你的痛苦。

Kotlin 中的错误处理

现在我们知道了开放给我们的错误处理选项,我们在 Kotlin 项目中应该使用哪种,以及如何迁移我们的 Java 代码?

像往常一样,这要看情况而定。

使用可空类型来表示失败非常有效,前提是你不需要传达失败原因的任何信息。

使用异常作为默认策略不会导致你被解雇。然而,缺乏类型检查使得很难明确哪些代码会失败,这反过来使得构建可靠系统变得困难。更加让人难堪的是,你将失去参考透明性的好处,这样一来,重构和修复不可靠系统就更加困难了。

我们更倾向于从那些在 Java 中会抛出已检查异常的操作中返回Either类型,无论是因为 I/O 问题,还是像parseInt一样,它们无法为所有输入提供结果。这使我们可以将异常保留给更为恶劣的问题。对于不可恢复的程序错误,仍然适用Errors:在这种情况下,我们应设计我们的系统,使得程序退出并由其他进程重新启动。RuntimeExceptions在我们作为程序员犯错误时仍然是很好的信号:例如IndexOutOfBounds等情况。如果我们精心设计了我们的系统,它应该能够处理这些问题并处理不会遇到同样问题的其他输入。

你应该选择哪种Either类型?在撰写本文时,内置的 Kotlin Result类型是一个令人沮丧的占位符,它只是一种挑逗并且会妨碍进展。它专为协程设计,受限于Exception(实际上是Throwable)作为其错误值,并且如果你将其用作属性类型,IntelliJ 会抱怨。如果它没有发布在kotlin包中,这就合理了。然而,它确实发布在那里,所以如果你尝试使用一个更有用的名为Result的类型,你会得到奇怪的错误消息,直到你记住编译器假设Result指的是你不应该使用的kotlin.Result类型。

还有许多其他的结果类型可用,但是在本书中我们将使用 Result4k,这不是巧合,它是由 Nat 编写的。与我们之前介绍的通用 Either 类型相比,Result4k 定义了 Result<SuccessType, FailureType>,具有子类型 SuccessFailure,而不是 LeftRight。由于它专门用于表示错误,Result4k 通过将成功类型作为泛型参数的第一个来反转 Either 惯例。它还可以提供诸如 onFailurerecover 等操作,在 Either 上是不合理的。在重构时,我们将看到其中的一些操作。

将异常重构为错误

现在我们知道了可用的错误处理选项,让我们重构一些 Java 代码到 Kotlin,逐步转换错误处理。

Travelator 中有一个 HTTP 端点,允许客户端应用注册一个 Customer

public class CustomerRegistrationHandler {

    private final IRegisterCustomers registration;
    private final ObjectMapper objectMapper = new ObjectMapper();

    public CustomerRegistrationHandler(IRegisterCustomers registration) {
        this.registration = registration;
    }

    public Response handle(Request request) {
        try {
            RegistrationData data = objectMapper.readValue(
                request.getBody(),
                RegistrationData.class
            );
            Customer customer = registration.register(data);
            return new Response(HTTP_CREATED,
                objectMapper.writeValueAsString(customer)
            );
        } catch (JsonProcessingException x) {
            return new Response(HTTP_BAD_REQUEST);
        } catch (ExcludedException x) {
            return new Response(HTTP_FORBIDDEN);
        } catch (DuplicateException x) {
            return new Response(HTTP_CONFLICT);
        } catch (Exception x) {
            return new Response(HTTP_INTERNAL_ERROR);
        }
    }
}

示例 19.1 [errors.0:src/main/java/travelator/handlers/CustomerRegistrationHandler.java] (diff)

CustomerRegistrationHandler 的工作是从请求体中提取数据,将其传递给 registration 进行处理,并返回一个 JSON 表示的 Customer 或适当的错误状态码响应。

CustomerRegistration 实现了业务规则,即潜在客户应该经过 ExclusionList 的审核。我们不希望允许已知的不良分子注册并滥用我们的服务,因此我们在这一点上拒绝他们:

public class CustomerRegistration implements IRegisterCustomers {

    private final ExclusionList exclusionList;
    private final Customers customers;

    public CustomerRegistration(
        Customers customers,
        ExclusionList exclusionList
    ) {
        this.exclusionList = exclusionList;
        this.customers = customers;
    }

    public Customer register(RegistrationData data)
        throws ExcludedException, DuplicateException {
        if (exclusionList.exclude(data)) {
            throw new ExcludedException();
        } else {
            return customers.add(data.name, data.email);
        }
    }
}

示例 19.2 [errors.0:src/main/java/travelator/CustomerRegistration.java] (diff)

查看 register 方法的 throws 子句。它告诉我们,这个方法可能因为明确的排除而失败,但也可能因为 customers.add 导致 DuplicateException 失败。这是 Customers 接口:

public interface Customers {

    Customer add(String name, String email) throws DuplicateException;

    Optional<Customer> find(String id);
}

示例 19.3 [errors.0:src/main/java/travelator/Customers.java] (diff)

最后,Customer 是另一个值类型。在这里它转换为 Kotlin 后的样子:

data class Customer(
    val id: String,
    val name: String,
    val email: String
)

示例 19.4 [errors.1:src/main/java/travelator/Customer.kt] (diff)

这是你们作者的典型 Java 风格。它表达了可能发生的事情,如检查的 ExcludedExceptionDuplicateException,这些都在 handle 的顶层捕获,然后作为 HTTP 状态码报告给调用者。你的风格可能是使用未检查的异常,在这种情况下,这段代码的写法会类似,但方法签名中不包括异常。

我们看不到与将Customer持久化到Customers::add失败相关的任何检查异常。这个方法将通过网络与数据库通信,但显然我们的查询代码在某个点上吞掉了IOException并替换为RuntimeException。这些异常将传播到Customer​Regis⁠tration::register的顶层,被CustomerRegistrationHandler捕获,并作为HTTP_INTERNAL_ERROR(500)传递回客户端。很遗憾我们没有记录关于这些零散RuntimeException的任何信息,因为它们可能揭示系统性连接问题或者在一些低级代码中隐藏着频繁的NullPointer​Excep⁠tion。在此期间,有人应该解决这个问题,但与此同时,至少我们在本书中展示了一个较短的示例。

我们的转换策略

如果我们仅将此代码转换为 Kotlin,我们将失去检查异常提供的优势,无法告诉我们可能出现什么问题以及我们在哪里处理了这些问题。因此,在转换过程中,我们将使用 Result4k 替换基于异常的错误处理功能性替代方案。

在这个例子中,我们将从最底层开始,逐步向上工作,保持更高级别的工作,直到那些当前表达为检查异常的可预测错误案例不再使用异常。与此同时,我们必须注意,JVM 中几乎任何指令都可能失败,因此我们需要防范这些运行时问题。

从底层开始

如果我们将Customers转换为 Kotlin,我们得到:

interface Customers {

    @Throws(DuplicateException::class) ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
    fun add(name: String, email: String): Customer

    fun find(id: String): Optional<Customer>
}

示例 19.5 [errors.3:src/main/java/travelator/Customers.kt] (差异)

1

虽然 Kotlin 没有检查异常,但@Throws注解允许通过将异常添加到方法的字节码签名中与 Java 代码进行互操作。如果没有它,一个 Java 实现的Customers如果抛出DuplicateException将无法实现该方法。更糟的是,调用接口上的方法的 Java 代码将无法捕获异常或声明它被传递,因为 Java 代码处理编译器无法看到的检查异常将是一个编译错误。

我们的策略是在接口中添加Customers::add的一个版本,该版本不会抛出异常,而是返回Result<Customer, DuplicateException>。如果我们从头开始,我们不会将DuplicateException作为错误类型使用,但在这里它让我们可以轻松地与 Java 进行交互。目前我们会保留当前的抛出版本,以免破坏现有的调用者。然后,当我们可以时,我们将转换这些调用者以使用Result版本,然后删除旧版本。没错,这是我们的老朋友“扩展和收缩重构”。

我们应该如何称呼像Customers::add但返回Result的方法?我们不能再命名为add,因为两者具有相同的参数,所以暂时称其为addToo。如果新方法委托给add,我们可以将其作为默认方法,以便所有实现都可以使用:

interface Customers {

    @Throws(DuplicateException::class)
    fun add(name: String, email: String): Customer

    fun addToo(name:String, email:String)
        : Result<Customer, DuplicateException> =
        try {
            Success(add(name, email))
        } catch (x: DuplicateException) {
            Failure(x)
        }

    fun find(id: String): Optional<Customer>
}

示例 19.6 [errors.5:src/main/java/travelator/Customers.kt] (差异)

现在我们既有异常版本又有结果版本的方法,我们可以迁移调用异常版本的调用者。虽然我们可以从 Java 中使用 Result4k,但从 Kotlin 更加方便。因此,让我们来看看CustomerRegistration(调用add的地方):

public class CustomerRegistration implements IRegisterCustomers {

    private final ExclusionList exclusionList;
    private final Customers customers;

    public CustomerRegistration(
        Customers customers,
        ExclusionList exclusionList
    ) {
        this.exclusionList = exclusionList;
        this.customers = customers;
    }

    public Customer register(RegistrationData data)
        throws ExcludedException, DuplicateException {
        if (exclusionList.exclude(data)) {
            throw new ExcludedException();
        } else {
            return customers.add(data.name, data.email);
        }
    }
}

示例 19.7 [errors.5:src/main/java/travelator/CustomerRegistration.java] (差异)

将其转换为 Kotlin 如下:

class CustomerRegistration(
    private val customers: Customers,
    private val exclusionList: ExclusionList
) : IRegisterCustomers {

    @Throws(ExcludedException::class, DuplicateException::class)
    override fun register(data: RegistrationData): Customer {
        return if (exclusionList.exclude(data)) {
            throw ExcludedException()
        } else {
            customers.add(data.name, data.email)
        }
    }

}

示例 19.8 [errors.6:src/main/java/travelator/CustomerRegistration.kt] (差异)

那个customers.add表达式就是可能抛出DuplicateException的表达式。我们将其替换为调用addToo,但保持行为不变。因此,我们将result作为局部变量提取出来:

@Throws(ExcludedException::class, DuplicateException::class)
override fun register(data: RegistrationData): Customer {
    return if (exclusionList.exclude(data)) {
        throw ExcludedException()
    } else {
        val result = customers.add(data.name, data.email)
        result
    }
}

示例 19.9 [errors.7:src/main/java/travelator/CustomerRegistration.kt] (差异)

如果我们现在调用addToo而不是add,它将不再抛出异常,但异常将在Result中返回。但这暂时无法编译:

@Throws(ExcludedException::class, DuplicateException::class)
override fun register(data: RegistrationData): Customer {
    return if (exclusionList.exclude(data)) {
        throw ExcludedException()
    } else {
        val result: Result<Customer, DuplicateException> =
            customers.addToo(data.name, data.email)
        result ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
    }
}

示例 19.10 [errors.8:src/main/java/travelator/CustomerRegistration.kt] (差异)

1

类型不匹配。要求:Customer 实际:Result<Customer, Duplicate​Excep⁠tion>

我们有一个Result,所以我们需要解开它。当它是Success时,我们希望返回包装的值;当是Failure时,抛出包装的DuplicateException(以保持register的当前行为):

@Throws(ExcludedException::class, DuplicateException::class)
override fun register(data: RegistrationData): Customer {
    return if (exclusionList.exclude(data)) {
        throw ExcludedException()
    } else {
        val result: Result<Customer, DuplicateException> =
            customers.addToo(data.name, data.email)
        when (result) {
            is Success<Customer> ->
                result.value
            is Failure<DuplicateException> ->
                throw result.reason
        }
    }
}

示例 19.11 [errors.9:src/main/java/travelator/CustomerRegistration.kt] (差异)

恰巧的是,当错误类型是Exception时,Result4k 有一个函数来简化此情况:Result::orThrow

@Throws(ExcludedException::class, DuplicateException::class)
override fun register(data: RegistrationData): Customer {
    return if (exclusionList.exclude(data)) {
        throw ExcludedException()
    } else {
        val result: Result<Customer, DuplicateException> =
            customers.addToo(data.name, data.email)
        result.orThrow()
    }
}

示例 19.12 [errors.10:src/main/java/travelator/CustomerRegistration.kt] (差异)

现在我们可以内联以回到更简洁的形式:

@Throws(ExcludedException::class, DuplicateException::class)
override fun register(data: RegistrationData): Customer {
    return if (exclusionList.exclude(data)) {
        throw ExcludedException()
    } else {
        customers.addToo(data.name, data.email).orThrow()
    }
}

示例 19.13 [errors.11:src/main/java/travelator/CustomerRegistration.kt] (diff)

最后,嵌套过多让人感到困惑,所以让我们通过使用“用‘when’替换‘if’”、“用‘when’表达式替换 return”和“从所有‘when’条目中删除大括号”来简化它。Alt-Enter 一切!

@Throws(ExcludedException::class, DuplicateException::class)
override fun register(data: RegistrationData): Customer {
    when {
        exclusionList.exclude(data) -> throw ExcludedException()
        else -> return customers.addToo(data.name, data.email).orThrow()
    }
}

示例 19.14 [errors.12:src/main/java/travelator/CustomerRegistration.kt] (diff)

卓越的。我们用结果类型替换了异常的一种用法;让我们稍作休息。

合同

准备好再开始了吗?好的。

我们现在必须选择是深度优先还是广度优先。深度优先会解决CustomerRegistration::register的调用者;广度优先会先修复Customers::add的其他调用者,以便我们可以删除它。恰好,我们的示例代码没有add的其他调用者,所以广度优先不是一个选项,我们可以继续扩展和收缩的合同阶段。

我们目前有两个Customers::add的实现。一个是与数据库交互的生产实现,另一个是测试实现。我们的代码现在通过我们添加到接口的Customers::addToo的默认实现来调用它们。我们想要删除add实现,因此我们需要直接实现addToo。让我们看一下(不是线程安全的)测试版本:

public class InMemoryCustomers implements Customers {

    private final List<Customer> list = new ArrayList<>();
    private int id = 0;

    @Override
    public Customer add(String name, String email) throws DuplicateException {
        if (list.stream().anyMatch( item -> item.getEmail().equals(email)))
            throw new DuplicateException(
                "customer with email " + email + " already exists"
            );
        int newId = id++;
        Customer result = new Customer(Integer.toString(newId), name, email);
        list.add(result);
        return result;
    }

    @Override
    public Optional<Customer> find(String id) {
        return list.stream()
            .filter(customer -> customer.getId().equals(id))
            .findFirst();
    }

    // for test
    public void add(Customer customer) {
        list.add(customer);
    }

    public int size() {
        return list.size();
    }
}

示例 19.15 [errors.12:src/test/java/travelator/InMemoryCustomers.java] (diff)

在这里实现addToo的最简单方法可能就是复制add并修复它,将我们抛出的地方返回Failure,将快乐路径返回Success

@SuppressWarnings("unchecked")
@Override
public Result<Customer, DuplicateException> addToo(
    String name, String email
) {
    if (list.stream().anyMatch( item -> item.getEmail().equals(email)))
        return new Failure<>(
            new DuplicateException(
                "customer with email " + email + " already exists"
            )
        );
    int newId = id++;
    Customer result = new Customer(Integer.toString(newId), name, email);
    list.add(result);
    return new Success<Customer>(result);
}

示例 19.16 [errors.13:src/test/java/travelator/InMemoryCustomers.java] (diff)

我们也可以使用这种策略将addToo添加到Customers的生产实现中;我们将跳过细节。完成后,我们可以从实现和接口中删除未使用的add,然后将addToo重命名为add,留下:

interface Customers {

    fun add(name:String, email:String): Result<Customer, DuplicateException>

    fun find(id: String): Optional<Customer>
}

示例 19.17 [errors.14:src/main/java/travelator/Customers.kt] (diff)

Customers的客户现在又开始调用add,尽管是返回Result的版本,而不是声明受检异常:

class CustomerRegistration(
    private val customers: Customers,
    private val exclusionList: ExclusionList
) : IRegisterCustomers {

    @Throws(ExcludedException::class, DuplicateException::class)
    override fun register(data: RegistrationData): Customer {
        when {
            exclusionList.exclude(data) -> throw ExcludedException()
            else -> return customers.add(data.name, data.email).orThrow()
        }
    }
}

示例 19.18 [errors.14:src/main/java/travelator/CustomerRegistration.kt] (diff)

我们将 InMemoryCustomers 保留为 Java 仅仅是为了演示我们可以从旧代码返回 Result4k 类型,但我们无法抵制转换,因为现在代码有许多类型为 Not annotated [X] overrides @NotNull [X] 的警告。

转换后,包括从流操作到 Kotlin 集合操作的移动(第十三章),我们有:

class InMemoryCustomers : Customers {

    private val list: MutableList<Customer> = ArrayList()
    private var id = 0

    override fun add(name: String, email: String)
        : Result<Customer, DuplicateException> =
        when {
            list.any { it.email == email } -> Failure(
                DuplicateException(
                    "customer with email $email already exists"
                )
            )
            else -> {
                val result = Customer(id++.toString(), name, email)
                list.add(result)
                Success(result)
            }
        }

    override fun find(id: String): Optional<Customer> =
        list.firstOrNull { it.id == id }.toOptional()

    // for test
    fun add(customer: Customer) {
        list.add(customer)
    }

    fun size(): Int = list.size
}

示例 19.19 [errors.15:src/test/java/travelator/InMemoryCustomers.kt] (diff)

让我们回顾一下我们现在的状态。Customers 现在是 Kotlin,add 返回 Result 而不是抛出 DuplicateException

interface Customers {

    fun add(name:String, email:String): Result<Customer, DuplicateException>

    fun find(id: String): Optional<Customer>
}

示例 19.20 [errors.15:src/main/java/travelator/Customers.kt] (diff)

IRegisterCustomers 仍然是 Java,并且仍然会抛出两种类型的异常:

public interface IRegisterCustomers {
    Customer register(RegistrationData data)
        throws ExcludedException, DuplicateException;
}

示例 19.21 [errors.15:src/main/java/travelator/IRegisterCustomers.java] (diff)

CustomerRegistration 现在是 Kotlin,我们现在在 Result.ErrorDuplicateException 之间进行 thunk 操作,使用 orThrow

class CustomerRegistration(
    private val customers: Customers,
    private val exclusionList: ExclusionList
) : IRegisterCustomers {

    @Throws(ExcludedException::class, DuplicateException::class)
    override fun register(data: RegistrationData): Customer {
        when {
            exclusionList.exclude(data) -> throw ExcludedException()
            else -> return customers.add(data.name, data.email).orThrow()
        }
    }
}

示例 19.22 [errors.15:src/main/java/travelator/CustomerRegistration.kt] (diff)

我们已经将交互的整个层次转换为使用结果类型,并可以继续到下一个阶段。

走出去

如果我们要像处理 CustomersIRegisterCustomers::register 一样遵循相同的模式——提供异常抛出和错误返回之间的适配器的默认实现——我们将不得不解决一个函数可能由于两个原因失败的结果表达的问题。这是因为 register 目前声明同时抛出 ExcludedExceptionDuplicateException 检查异常。在代码中,我们希望得到类似 Result<Customer, Either​<Exclu⁠dedException, DuplicateException>> 的东西。

我们可以使用通用的 Either 类型,但这只能作为一种策略,这和 Java 不同,我们声明异常的顺序不重要,Either<Exclu⁠ded​Excep⁠tion, DuplicateException> 不同于 Either<DuplicateException, ExcludedException>Either 最多只是令人困惑的东西,如果我们有超过两个异常,情况将变得更糟:OneOf<ExcludedException, Duplicate​Ex⁠cep⁠tion, SomeOtherProblem> 简直糟透了。

另一个选择是提升到这两个异常的共同超类,并将返回类型声明为 Result<Customer, Exception>。这不符合通信测试:我们无法查看签名并获取任何关于我们期望的错误类型的线索。

相反,我们在这里的最佳策略不是试图用现有类型来表达错误,而是映射到一个新类型。

由于“异常”和“错误”都是负载过重的术语,我们选择了RegistrationProblem,其子类型有Excluded(不携带额外信息,因此可以是一个object)和Duplicate(携带原始DuplicateException的任何消息):

sealed class RegistrationProblem

object Excluded : RegistrationProblem()

data class Duplicate(
    val message: String?
) : RegistrationProblem()

示例 19.23 [errors.16:src/main/java/travelator/IRegisterCustomers.kt] (diff)

通过将RegistrationProblem定义为密封类,我们在编译时知道哪些子类可能存在,因此知道必须处理的错误,这非常类似于方法的检查异常签名。

当我们遵循先前的模式时,我们可以在接口中添加registerToo的默认实现,并使用返回Result<Customer, RegistrationProblem>

interface IRegisterCustomers {

    @Throws(ExcludedException::class, DuplicateException::class)
    fun register(data: RegistrationData): Customer

    fun registerToo(data: RegistrationData):
        Result<Customer, RegistrationProblem> =
        try {
            Success(register(data))
        } catch (x: ExcludedException) {
            Failure(Excluded)
        } catch (x: DuplicateException) {
            Failure(Duplicate(x.message))
        }
}

示例 19.24 [errors.16:src/main/java/travelator/IRegisterCustomers.kt] (diff)

现在我们可以将register的调用者迁移到registerToo。我们将从Customer​Regis⁠trationHandler开始,首先将其转换为 Kotlin:

class CustomerRegistrationHandler(
    private val registration: IRegisterCustomers
) {
    private val objectMapper = ObjectMapper()

    fun handle(request: Request): Response {
        return try {
            val data = objectMapper.readValue(
                request.body,
                RegistrationData::class.java
            )
            val customer = registration.register(data)
            Response(
                HTTP_CREATED,
                objectMapper.writeValueAsString(customer)
            )
        } catch (x: JsonProcessingException) {
            Response(HTTP_BAD_REQUEST)
        } catch (x: ExcludedException) {
            Response(HTTP_FORBIDDEN)
        } catch (x: DuplicateException) {
            Response(HTTP_CONFLICT)
        } catch (x: Exception) {
            Response(HTTP_INTERNAL_ERROR)
        }
    }
}

示例 19.25 [errors.17:src/main/java/travelator/handlers/CustomerRegistrationHandler.kt] (diff)

现在,就像之前一样,我们转而调用新方法(registerToo)而不是旧方法(register),并使用when表达式解释返回类型:

class CustomerRegistrationHandler(
    private val registration: IRegisterCustomers
) {
    private val objectMapper = ObjectMapper()

    fun handle(request: Request): Response {
        return try {
            val data = objectMapper.readValue(
                request.body,
                RegistrationData::class.java
            )
            val customerResult = registration.registerToo(data)
            when (customerResult) {
                is Success -> Response(
                    HTTP_CREATED,
                    objectMapper.writeValueAsString(customerResult.value)
                )
                is Failure -> customerResult.reason.toResponse()

            }
        } catch (x: JsonProcessingException) {
            Response(HTTP_BAD_REQUEST)
        } catch (x: ExcludedException) {
            Response(HTTP_FORBIDDEN)
        } catch (x: DuplicateException) {
            Response(HTTP_CONFLICT)
        } catch (x: Exception) {
            Response(HTTP_INTERNAL_ERROR)
        }
    }
}

private fun RegistrationProblem.toResponse() = when (this) {
    is Duplicate -> Response(HTTP_CONFLICT)
    is Excluded -> Response(HTTP_FORBIDDEN)
}

示例 19.26 [errors.18:src/main/java/travelator/handlers/CustomerRegistrationHandler.kt] (diff)

最后,我们可以删除不必要的异常情况,并使用maprecover简化错误情况。Result::recover是一个 Result4k 的扩展函数,如果是Success则解包结果,否则返回映射失败的reason的结果:

fun handle(request: Request): Response =
    try {
        val data = objectMapper.readValue(
            request.body,
            RegistrationData::class.java
        )
        registration.registerToo(data)
            .map { value ->
                Response(
                    HTTP_CREATED,
                    objectMapper.writeValueAsString(value)
                )
            }
            .recover { reason -> reason.toResponse() }
    } catch (x: JsonProcessingException) {
        Response(HTTP_BAD_REQUEST)
    } catch (x: Exception) {
        Response(HTTP_INTERNAL_ERROR)
    }

示例 19.27 [errors.19:src/main/java/travelator/handlers/CustomerRegistrationHandler.kt] (diff)

请注意,这段代码仍然不是无异常的。首先,ObjectMapper仍然可以抛出JSONProcessingException。这是 Java(实际上大多数 Kotlin)API 的现实,但代码是安全的且表达清晰,因为抛出和捕获在同一个方法中。其次,我们仍然需要考虑可能从任何地方抛出的其他RuntimeException,例如NullPointerException等。这些异常可能已经跨越了函数边界并最终泄漏到这里,在这里的顶级全捕捉中返回HTTP_INTERNAL_ERROR。现实情况是,我们仍然可能会遇到意外的异常,但预期的失败情况现在由Results表示并在我们的代码中传达。

更多修复

现在我们可以承认,RegistrationHandlerTests在几步之前就已经出问题了。通常我们会立即修复它们,但那样会打断我们的解释。

问题在于这些测试是模拟测试,期望调用IRegister.register,但我们现在却调用了registerToo。例如:

public class CustomerRegistrationHandlerTests {

    final IRegisterCustomers registration =
        mock(IRegisterCustomers.class);
    final CustomerRegistrationHandler handler =
        new CustomerRegistrationHandler(registration);

    final String fredBody = toJson(
        "{ 'name' : 'fred', 'email' : 'fred@bedrock.com' }"
    );
    final RegistrationData fredData =
        new RegistrationData("fred", "fred@bedrock.com");

    @Test
    public void returns_Created_with_body_on_success()
        throws DuplicateException, ExcludedException {
        when(registration.register(fredData))
            .thenReturn(
                new Customer("0", fredData.name, fredData.email)
            );

        String expectedBody = toJson(
            "{'id':'0','name':'fred','email':'fred@bedrock.com'}"
        );
        assertEquals(
            new Response(HTTP_CREATED, expectedBody),
            handler.handle(new Request(fredBody))
        );
    }

    @Test
    public void returns_Conflict_for_duplicate()
        throws DuplicateException, ExcludedException {

        when(registration.register(fredData))
            .thenThrow(
                new DuplicateException("deliberate")
            );

        assertEquals(
            new Response(HTTP_CONFLICT),
            handler.handle(new Request(fredBody))
        );
    }
    ...

    private String toJson(String jsonIsh) {
        return jsonIsh.replace('\'', '"');
    }
}

示例 19.28 [errors.20:src/test/java/travelator/handlers/CustomerRegistrationHandlerTests.java] (diff)

为了修复测试,我们需要将调用register改为调用registerToo,后者返回Result<Customer, RegistrationProblem>或抛出异常:

@Test
public void returns_Created_with_body_on_success() {

    when(registration.registerToo(fredData))
        .thenReturn(new Success<>(
            new Customer("0", fredData.name, fredData.email)
        ));

    String expectedBody = toJson(
        "{'id':'0','name':'fred','email':'fred@bedrock.com'}"
    );
    assertEquals(
        new Response(HTTP_CREATED, expectedBody),
        handler.handle(new Request(fredBody))
    );
}

@Test
public void returns_Conflict_for_duplicate() {

    when(registration.registerToo(fredData))
        .thenReturn(new Failure<>(
            new Duplicate("deliberate")
        ));

    assertEquals(
        new Response(HTTP_CONFLICT),
        handler.handle(new Request(fredBody))
    );
}
    ...

示例 19.29 [errors.21:src/test/java/travelator/handlers/CustomerRegistrationHandlerTests.java] (diff)

现在我们的测试变得更简单了,因为我们不再需要选择thenReturnthenThrow,而是总是使用thenReturn进行模拟,分别返回SuccessFailure

现在我们的测试再次通过了,我们可以返回到生产代码并直接实现CustomerRegistration::registerToo。如果没有更聪明的想法,我们可以通过复制register方法并调整错误处理来做到这一点。我们使用Result::mapFailure(Result4k 的一部分)将DuplicateException转换为Duplicate

class CustomerRegistration(
    private val customers: Customers,
    private val exclusionList: ExclusionList
) : IRegisterCustomers {

    @Throws(ExcludedException::class, DuplicateException::class)
    override fun register(data: RegistrationData): Customer {
        when {
            exclusionList.exclude(data) -> throw ExcludedException()
            else -> return customers.add(data.name, data.email).orThrow()
        }
    }

    override fun registerToo(
        data: RegistrationData
    ): Result<Customer, RegistrationProblem> {
        return when {
            exclusionList.exclude(data) -> Failure(Excluded)
            else -> customers.add(data.name, data.email)
                .mapFailure { exception: DuplicateException -> ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
                    Duplicate(exception.message)
                }
        }
    }
}

示例 19.30 [errors.22:src/main/java/travelator/CustomerRegistration.kt] (diff)

1

请注意,在mapFailure中我们显式指定了 lambda 参数的类型。正如我们后面将看到的,这样做可以在我们将add的返回类型更改为不同的失败类型时,强制我们更改处理方式。

这里有两个问题。首先,registerToo没有测试代码;其次,我们由于复制register来创建registerToo而导致重复逻辑。我们可以通过将register实现为registerToo的调用来解决这两个问题,这与我们在Customers中所做的相反:

class CustomerRegistration(
    private val customers: Customers,
    private val exclusionList: ExclusionList
) : IRegisterCustomers {

    @Throws(ExcludedException::class, DuplicateException::class)
    override fun register(data: RegistrationData): Customer =
        registerToo(data).recover { error ->  ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
            when (error) {
                is Excluded -> throw ExcludedException()
                is Duplicate -> throw DuplicateException(error.message)
        }
    }

    override fun registerToo(
        data: RegistrationData
    ): Result<Customer, RegistrationProblem> {
        return when {
            exclusionList.exclude(data) -> Failure(Excluded)
            else -> customers.add(data.name, data.email)
                .mapFailure { exception: DuplicateException ->
                    Duplicate(exception.message)
                }
        }
    }
}

示例 19.31 [errors.23:src/main/java/travelator/CustomerRegistration.kt] (diff)

1

委托给registerToo并处理Error类型。

现在我们的CustomerRegistrationTests,工作于register的调用者,将为我们测试registerToo

public class CustomerRegistrationTests {

    InMemoryCustomers customers = new InMemoryCustomers();
    Set<String> excluded = Set.of(
        "cruella@hellhall.co.uk"
    );
    CustomerRegistration registration = new CustomerRegistration(customers,
        (registrationData) -> excluded.contains(registrationData.email)
    );

    @Test
    public void adds_a_customer_when_not_excluded()
        throws DuplicateException, ExcludedException {
        assertEquals(Optional.empty(), customers.find("0"));

        Customer added = registration.register(
            new RegistrationData("fred flintstone", "fred@bedrock.com")
        );
        assertEquals(
            new Customer("0", "fred flintstone", "fred@bedrock.com"),
            added
        );
        assertEquals(added, customers.find("0").orElseThrow());
    }

    @Test
    public void throws_DuplicateException_when_email_address_exists() {
        customers.add(new Customer("0", "fred flintstone", "fred@bedrock.com"));
        assertEquals(1, customers.size());

        assertThrows(DuplicateException.class,
            () -> registration.register(
                new RegistrationData("another name", "fred@bedrock.com")
            )
        );
        assertEquals(1, customers.size());
    }

    ...
}

示例 19.32 [errors.23:src/test/java/travelator/CustomerRegistrationTests.java] (diff)

在我们从 Java 迁移到 Kotlin 和错误类型时,保留registerregisterToo是一个不错的方式。然而,在这种情况下,测试实际上是register的最后调用者,所以让我们将它们转换为调用registerToo。我们可以花时间展示如何在 Java 中使用 Result4k,但我们对这个示例已经厌倦了,所以我们将把测试用例转换为 Kotlin,然后让它们调用register,并说上一些不朽的话:“这是我之前制作的一个例子”:

@Test
fun `adds a customer when not excluded`() {
    assertEquals(Optional.empty<Any>(), customers.find("0"))
    val added = registration.registerToo(
        RegistrationData("fred flintstone", "fred@bedrock.com")
    ).valueOrNull()
    assertEquals(
        Customer("0", "fred flintstone", "fred@bedrock.com"),
        added
    )
    assertEquals(added, customers.find("0").orElseThrow())
}

@Test
fun `returns Duplicate when email address exists`() {
    customers.add(Customer("0", "fred flintstone", "fred@bedrock.com"))
    assertEquals(1, customers.size())
    val failure = registration.registerToo(
        RegistrationData("another name", "fred@bedrock.com")
    ).failureOrNull()
    assertEquals(
        Duplicate("customer with email fred@bedrock.com already exists"),
        failure
    )
    assertEquals(1, customers.size())
}

    ...

示例 19.33 [errors.24:src/test/java/travelator/CustomerRegistrationTests.kt] (diff)

现在我们没有register的调用者了,我们终于可以删除它,并将registerToo重命名为register,最终得到无异常的 Kotlin:

interface IRegisterCustomers {
    fun register(data: RegistrationData):
        Result<Customer, RegistrationProblem>
}

sealed class RegistrationProblem

object Excluded : RegistrationProblem()

data class Duplicate(
    val message: String?
) : RegistrationProblem()

示例 19.34 [errors.25:src/main/java/travelator/IRegisterCustomers.kt] (diff)

interface Customers {

    fun add(name:String, email:String): Result<Customer, DuplicateException>

    fun find(id: String): Optional<Customer>
}

示例 19.35 [errors.25:src/main/java/travelator/Customers.kt] (diff)

嗯,因为那个DuplicateException,并不完全是无异常的。它实际上不再从任何地方抛出,只是被创建并放入了一个Failure中。我们可以通过将类重命名为DuplicateCustomerProblem并停止它继承Exception来解决这个问题,或者重用RegistrationProblem的现有Duplicate子类。哪个更好呢?

层次

如果我们从层次角度来思考,Customers位于比Registration更低的层次,后者依赖于它。因此,Customers不应该依赖于更高级别的RegistrationPro⁠blem。我们可以尝试反转依赖关系,使RegistrationProblemDuplicate子类成为在存储库层声明的DuplicateCustomerPro⁠blem的子类型(甚至仅是相同类型)。这在这里可以工作,但如果Customers::add需要声明另一种可能失败的方式,这可能是一个死胡同。例如,如果我们想在我们的结果中显示数据库通信可能会失败,我们不能(或者不应该)将其作为DuplicateCustomerProblem的子类型。因此,我们将再次面临在单个结果中表达多个错误类型的问题。

让我们跟踪一下。如果 Customers::add 需要声明不止一种可能失败的方式——我们之前的 DuplicateCustomerProblem 和我们的新 DatabaseCustomer​Pro⁠blem——我们将引入一个封闭的 CustomersProblem 作为错误类型,并使这两个已知问题成为其唯一的子类:

interface Customers {

    fun add(name:String, email:String): Result<Customer, CustomersProblem>

    fun find(id: String): Optional<Customer>
}

sealed class CustomersProblem

data class DuplicateCustomerProblem(val message: String): CustomersProblem()

data class DatabaseCustomerProblem(val message: String): CustomersProblem()

示例 19.36 [errors.27:src/main/java/travelator/Customers.kt] (差异)

CustomerRegistration 调用 Customers::add 并仅处理 mapFailure 中的 DuplicateCustomerProblem

class CustomerRegistration(
    private val customers: Customers,
    private val exclusionList: ExclusionList
) : IRegisterCustomers {

    override fun register(
        data: RegistrationData
    ): Result<Customer, RegistrationProblem> {
        return when {
            exclusionList.exclude(data) -> Failure(Excluded)
            else -> customers.add(data.name, data.email)
                .mapFailure { duplicate: DuplicateCustomerProblem ->
                    Duplicate(duplicate.message)
                }
        }
    }
}

示例 19.37 [errors.26:src/main/java/travelator/CustomerRegistration.kt] (差异)

现在这不再编译,因为失败的类型现在是 CustomersProblem 基类。您可以看到我们正在获得已检查异常的好处:代码正在传达它可以失败的方式,并强制我们处理这些情况。

现在 Customers::add 承认它可能以一种新颖的方式失败,register 也被迫处理事实。它决定将这一知识传递给其调用者(好吧,我们替它决定),通过添加现有 RegistrationProblem 封闭类的新 DatabaseProblem 子类型:

sealed class RegistrationProblem

object Excluded : RegistrationProblem()

data class Duplicate(val message: String) : RegistrationProblem()

data class DatabaseProblem(val message: String) : RegistrationProblem()

示例 19.38 [errors.27:src/main/java/travelator/IRegisterCustomers.kt] (差异)

现在我们可以通过转换 add 可能失败的方式(DuplicateCustomerProblemDatabaseCustomerProblem)以及 register 可能失败的方式(分别是 DuplicateDatabaseProblem)来修复 register。现在 map​Fai⁠lure 的选择已经清晰:

override fun register(
    data: RegistrationData
): Result<Customer, RegistrationProblem> {
    return when {
        exclusionList.exclude(data) -> Failure(Excluded)
        else -> customers.add(data.name, data.email)
            .mapFailure { problem: CustomersProblem ->
                when (problem) {
                    is DuplicateCustomerProblem ->
                        Duplicate(problem.message)
                    is DatabaseCustomerProblem ->
                        DatabaseProblem(problem.message)
                }
            }
    }
}

示例 19.39 [errors.27:src/main/java/travelator/CustomerRegistration.kt] (差异)

最后,因为我们已经扩展了 RegistrationProblem 封闭层次结构,编译器现在通过编译 CustomerRegistrationHandler 来强迫我们考虑 DatabaseProblem 在上一层中的影响。

private fun RegistrationProblem.toResponse() = when (this) {
    is Duplicate -> Response(HTTP_CONFLICT)
    is Excluded -> Response(HTTP_FORBIDDEN)
    is DatabaseProblem -> Response(HTTP_INTERNAL_ERROR) ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
}

示例 19.40 [errors.27:src/main/java/travelator/handlers/CustomerRegistrationHandler.kt] (差异)

1

我们必须添加一个 DatabaseProblem 案例以使 when 表达式编译通过。

因为 CustomerRegistrationHandler 是此交互的入口点,我们的工作现在已经完成。

继续前进

这是一个很长的章节,但其长度与其重要性成比例。

您的 Java 项目可能已经宣告了异常破产,没有系统地使用已检查的异常。在这种情况下,Kotlin 将一切视为未检查异常的政策将是合适的。

如果你依赖于受检异常并希望转换为 Kotlin,或者希望在转换过程中提升错误处理能力,那么使用结果类型是最佳策略。当操作可能以多种方式失败时,我们可以使用密封类来列举失败模式,尽管这会导致不能在多个层次中传播相同类型。当我们有多个层次时,事情会变得繁琐,但至少不容易出错。

我们或许应该(maybe should)撰写一本完整的关于错误处理的书籍,但与此同时,你可以跟随邓肯在他的博客中的探索之旅。除了这里涵盖的材料外,这还展示了如何减少因为它们是部分函数而会失败的函数的数量。

减少我们的函数可能失败的数量是很重要的,因为受错误影响的代码与我们在第七章,“从操作到计算”中看到的行为非常相似。操作会污染它们的调用者:默认情况下,调用操作的代码本身也成为操作。同样,调用可能失败的代码的代码本身也可能失败。我们可以通过尽可能将它们移动到系统入口点附近来减少两者的影响,以便它们污染的代码最少。

在本章中,我们简要提到了当错误发生时使我们的代码健壮的方法。操作在这里也是一个问题,因为它们会影响我们系统的状态。当两个事物需要更新时,第一个操作写入,但第二个操作由于在调用之前发生错误而没有执行时,状态可能会被损坏。严格区分操作和计算的差异是制作健壮软件的关键。

我们将在第二十一章,“从值到异常”中回顾错误处理。

第二十章:执行 I/O 以传递数据

代码中的输入和输出存在问题。当文件消失或网络套接字失败时,我们的程序容易出错。I/O 也是一种动作,因此限制了我们理解和重构代码的能力。我们如何限制 I/O 导致的问题范围?

现在早期章节已经打下了一些基础,我们将加快进度,直接进行重构,并在过程中学习教训。

听从测试的建议

在 第十章 中,我们查看了一些生成市场报告的 Java 代码。当我们离开这段代码时,我们已经将扩展函数引入了 HighValue​Custo⁠mersReport,得到了:

@Throws(IOException::class)
fun generate(reader: Reader, writer: Writer) {
    val valuableCustomers = reader
        .readLines()
        .toValuableCustomers()
        .sortedBy(CustomerData::score)
    writer.appendLine("ID\tName\tSpend")
    for (customerData in valuableCustomers) {
        writer.appendLine(customerData.outputLine)
    }
    writer.append(valuableCustomers.summarised())
}

private fun List<String>.toValuableCustomers() = withoutHeader()
    .map(String::toCustomerData)
    .filter { it.score >= 10 }

private fun List<String>.withoutHeader() = drop(1)

private fun List<CustomerData>.summarised(): String =
    sumByDouble { it.spend }.let { total ->
        "\tTOTAL\t${total.toMoneyString()}"
    }

示例 20.1 [io-to-data.0:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (diff)

转换为 Kotlin 后的测试如下:

class HighValueCustomersReportTests {

    @Test
    fun test() {
        check(
            inputLines = listOf(
                "ID\tFirstName\tLastName\tScore\tSpend",
                "1\tFred\tFlintstone\t11\t1000.00",
                "4\tBetty\tRubble\t10\t2000.00",
                "2\tBarney\tRubble\t0\t20.00",
                "3\tWilma\tFlintstone\t9\t0.00"
            ),
            expectedLines = listOf(
                "ID\tName\tSpend",
                "4\tRUBBLE, Betty\t2000.00",
                "1\tFLINTSTONE, Fred\t1000.00",
                "\tTOTAL\t3000.00"
            )
        )
    }

    ...
    private fun check(
        inputLines: List<String>,
        expectedLines: List<String>
    ) {
        val output = StringWriter()
        generate(
            StringReader(inputLines.joinToString("\n")),
            output
        )
        assertEquals(expectedLines.joinToString("\n"), output.toString())
    }
}

示例 20.2 [io-to-data.1:src/test/java/travelator/marketing/HighValueCustomersReportTests.kt] (diff)

我们并没有真正关注 第十章 中的测试,但如果我们现在看一下,在你们作者对行动和计算 (第七章) 的痴迷背景下,什么是显著的?特别是看看那个 check 函数。

check 显然不是一个计算(“Calculations”),因为它完全通过抛出异常而不是返回值来工作。但如果我们这样看呢?

private fun check(
    inputLines: List<String>,
    expectedLines: List<String>
) {
    val output = StringWriter()
    val reader = StringReader(inputLines.joinToString("\n"))
    generate(reader, output)
    val outputLines = output.toString().lines()

    assertEquals(expectedLines, outputLines)
}

示例 20.3 [io-to-data.2:src/test/java/travelator/marketing/HighValueCustomersReportTests.kt] (diff)

尽管 generate 是一种动作,依赖于读取和写入其参数的副作用,但我们可以通过限制其副作用的范围仅限于局部变量,将其转换为计算。

如果我们停下来听一听,我们可以听到测试在告诉我们。“看,那个报告生成本质上是一个计算:它将 List<String> 转换为 List<String>。我们知道它这样做,因为这是我们正在检查的内容。”

因此,测试告诉我们generate的基本签名是generate(lines: List<String>): List<String>。如果是签名,则它也不必声明它抛出IOException,因为所有 I/O 都将在函数外部发生。I/O 必须发生在某个地方,但是与其他操作一样,我们可以将其移到系统入口点附近,这样我们就可以进行更轻松的计算。

我们应该朝这个目标重构吗?你是对的,那是一个修辞问题。

I/O to Data

作为重构的第一阶段,让我们尝试使generate摆脱其reader参数。当前代码是:

@Throws(IOException::class)
fun generate(reader: Reader, writer: Writer) {
    val valuableCustomers = reader
        .readLines()
        .toValuableCustomers()
        .sortedBy(CustomerData::score)
    writer.appendLine("ID\tName\tSpend")
    for (customerData in valuableCustomers) {
        writer.appendLine(customerData.outputLine)
    }
    writer.append(valuableCustomers.summarised())
}

示例 20.4 [io-to-data.3:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (diff)

我们可以通过在reader.readLines()表达式上调用“引入参数”,将generate转换为从List读取。命名参数为lines。因为该表达式是现有reader参数的唯一用法,所以 IntelliJ 会自动删除我们的reader

@Throws(IOException::class)
fun generate(writer: Writer, lines: List<String>) {
    val valuableCustomers = lines
        .toValuableCustomers()
        .sortedBy(CustomerData::score)
    writer.appendLine("ID\tName\tSpend")
    for (customerData in valuableCustomers) {
        writer.appendLine(customerData.outputLine)
    }
    writer.append(valuableCustomers.summarised())
}

示例 20.5 [io-to-data.4:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (diff)

重构已经将readLines()移到调用者中;这是测试的结果:

private fun check(
    inputLines: List<String>,
    expectedLines: List<String>
) {
    val output = StringWriter()
    val reader = StringReader(inputLines.joinToString("\n"))
    generate(output, reader.readLines())
    val outputLines = output.toString().lines()

    assertEquals(expectedLines, outputLines)
}

示例 20.6 [io-to-data.4:src/test/java/travelator/marketing/HighValueCustomersReportTests.kt] (diff)

现在响应测试的是我们一直在低声说的事情。我们不得不从一系列行中创建StringReader来解析generate中的行。现在测试步骤在同一个地方,我们可以省略它们以删除Reader

private fun check(
    inputLines: List<String>,
    expectedLines: List<String>
) {
    val output = StringWriter()
    generate(output, inputLines)
    val outputLines = output.toString().lines()

    assertEquals(expectedLines, outputLines)
}

示例 20.7 [io-to-data.5:src/test/java/travelator/marketing/HighValueCustomersReportTests.kt] (diff)

现在我们从一个List中读取。让我们回过头来看如何返回一个List,而不是修改Writer。这是代码:

writer.appendLine("ID\tName\tSpend")
for (customerData in valuableCustomers) {
    writer.appendLine(customerData.outputLine)
}
writer.append(valuableCustomers.summarised())

示例 20.8 [io-to-data.5:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (diff)

而不是在思考我们想要如何以命令方式变异Writer的方式,让我们以我们想要写入的数据为导向来思考:

val resultLines = listOf("ID\tName\tSpend") +
    valuableCustomers.map(CustomerData::outputLine) +
    valuableCustomers.summarised()

示例 20.9 [io-to-data.6:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (diff)

然后我们可以将其一次性写入writer

@Throws(IOException::class)
fun generate(writer: Writer, lines: List<String>) {
    val valuableCustomers = lines
        .toValuableCustomers()
        .sortedBy(CustomerData::score)
    val resultLines = listOf("ID\tName\tSpend") +
        valuableCustomers.map(CustomerData::outputLine) +
        valuableCustomers.summarised()
    writer.append(resultLines.joinToString("\n"))
}

示例 20.10 [io-to-data.6:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (差异)

这个函数现在是两个语句,构成了一个计算,并且最终采取了计算结果的动作。如果我们现在用计算行“提取函数”,并使其成为公共的并将其称为 generate,我们得到以下结果:

@Throws(IOException::class)
fun generate(writer: Writer, lines: List<String>) {
    val resultLines = generate(lines)
    writer.append(resultLines.joinToString("\n"))
}

fun generate(lines: List<String>): List<String> {
    val valuableCustomers = lines
        .toValuableCustomers()
        .sortedBy(CustomerData::score)
    val resultLines = listOf("ID\tName\tSpend") +
        valuableCustomers.map(CustomerData::outputLine) +
        valuableCustomers.summarised()
    return resultLines
}

示例 20.11 [io-to-data.7:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (差异)

内联两个已经过时的 resultLines 得到:

@Throws(IOException::class)
fun generate(writer: Writer, lines: List<String>) {
    writer.append(generate(lines).joinToString("\n"))
}

fun generate(lines: List<String>): List<String> {
    val valuableCustomers = lines
        .toValuableCustomers()
        .sortedBy(CustomerData::score)
    return listOf("ID\tName\tSpend") +
        valuableCustomers.map(CustomerData::outputLine) +
        valuableCustomers.summarised()
}

示例 20.12 [io-to-data.8:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (差异)

再内联一次,这次是旧的 generate 函数。这将替换客户端代码中的调用,将这部分留在测试中:

private fun check(
    inputLines: List<String>,
    expectedLines: List<String>
) {
    val output = StringWriter()
    output.append(generate(inputLines).joinToString("\n"))
    val outputLines = output.toString().lines()

    assertEquals(expectedLines, outputLines)
}

示例 20.13 [io-to-data.9:src/test/java/travelator/marketing/HighValueCustomersReportTests.kt] (差异)

这次重构将 generate 的动作部分移到了一个级别外,留下了漂亮的纯计算部分。另一种看待这个问题的方式是,我们原来的 Writer 是一个累积对象,我们已经用转换来替换了它,正如我们在 第十四章 中所看到的那样。我们的测试本来就不想测试一个动作,所以它们再次有了冗余的 I/O,我们可以简化为我们的目标形式:

private fun check(
    inputLines: List<String>,
    expectedLines: List<String>
) {
    assertEquals(expectedLines, generate(inputLines))
}

示例 20.14 [io-to-data.10:src/test/java/travelator/marketing/HighValueCustomersReportTests.kt] (差异)

让我们盘点一下我们的新 generate

fun generate(lines: List<String>): List<String> {
    val valuableCustomers = lines
        .toValuableCustomers()
        .sortedBy(CustomerData::score)
    return listOf("ID\tName\tSpend") +
        valuableCustomers.map(CustomerData::outputLine) +
        valuableCustomers.summarised()
}

private fun List<String>.toValuableCustomers() = withoutHeader()
    .map(String::toCustomerData)
    .filter { it.score >= 10 }

private fun List<String>.withoutHeader() = drop(1)

示例 20.15 [io-to-data.11:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (差异)

现在 generate 函数的工作量大大减少了,很难说函数 toValuable​Cus⁠tomers() 是否值得。重新审视一下,我们发现它在混合级别上工作,进行转换和过滤。让我们尝试内联它:

fun generate(lines: List<String>): List<String> {
    val valuableCustomers = lines
        .withoutHeader()
        .map(String::toCustomerData)
        .filter { it.score >= 10 }
        .sortedBy(CustomerData::score)
    return listOf("ID\tName\tSpend") +
        valuableCustomers.map(CustomerData::outputLine) +
        valuableCustomers.summarised()
}

示例 20.16 [io-to-data.12:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (差异)

这样更好。局部变量 valuableCustomers 很好地告诉我们表达式的含义,并且列表操作明确了实现方式。这个函数是一个情况,单表达式函数(第九章)可能会使情况变得更糟,所以我们将它保留为两部分。我们还将继续抵制诱惑,暂时不将其作为扩展函数 List<String>.toReport()

高效写作

我们对这次重构感到非常满意。它简化了我们的测试和生产代码,我们已经从混合 I/O 和逻辑转向了没有副作用的简单计算。

一段时间内,生产中一切都很好,但随着 COVID-19 旅行限制的放松,Travelator 成为我们都知道的成功产品。不过,市场营销部门的可爱同事们开始抱怨报告生成出现 OutOfMemoryError。我们能不能看一下呢?

(除了内存不足,我们在这段代码中还遇到过两个其他错误。这两次,输入文件被证明是格式错误的,但市场部门就在旁边,如果出现这些问题,他们就会叫我们过去帮忙。在这些情况下,他们会给我们蛋糕,所以我们目前并没有动力更好地处理错误(但参见第二十一章)。如果我们能迅速修复 OutOfMemoryError,我们觉得我们看到了一些小圆面包…)

到目前为止,我们并没有打扰你们的细节,但有一个 main 方法调用我们的报告。它被设计成通过 shell 重定向调用,从标准输入读取文件并将结果写入从标准输出收集的文件。这样,我们的处理过程不必从命令行读取文件名:

fun main() {
    InputStreamReader(System.`in`).use { reader ->
        OutputStreamWriter(System.out).use { writer ->
            generate(reader, writer)
        }
    }
}

示例 20.17 [io-to-data.0:src/main/java/travelator/marketing/HighValueCustomersMain.kt] (diff)

当我们将 generate 重构为使用 List 而不是 ReaderWriter 时,IntelliJ 自动更新了 main 以产生:

fun main() {
    System.`in`.reader().use { reader ->
        System.out.writer().use { writer ->
            writer.append(
                generate(
                    reader.readLines()
                ).joinToString("\n")
            )
        }
    }
}

示例 20.18 [io-to-data.9:src/main/java/travelator/marketing/HighValueCustomersMain.kt] (diff)

啊,这就是我们的问题。我们将整个输入读入内存 (readLines()),进行处理,然后在内存中创建整个输出 (joinToString()),然后再写回去。

我们有时会遇到功能分解这样的问题。在这种情况下,原始的 ReaderWriter 代码并没有这个问题,所以我们在追求良好风格的名义下给自己惹上了麻烦。我们可以快速恢复原来的代码,然后看看还有没有小圆面包,或者我们可以找到更加功能化的解决方案。

让我们回到generate函数,看看我们有哪些余地:

fun generate(lines: List<String>): List<String> {
    val valuableCustomers = lines
        .withoutHeader()
        .map(String::toCustomerData)
        .filter { it.score >= 10 }
        .sortedBy(CustomerData::score)
    return listOf("ID\tName\tSpend") +
        valuableCustomers.map(CustomerData::outputLine) +
        valuableCustomers.summarised()
}

示例 20.19 [io-to-data.12:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (差异)

现在我们只关注输出部分,可以看到我们正在构建输出行的List;然后main函数接收结果中的每个String,并使用joinToString()方法将它们组合成一个大字符串。此时,无论是单独的输出行还是它们的整体都会占用内存。为了避免内存耗尽,我们需要推迟中间集合的创建,正如我们在第十三章中所看到的,Sequence就是为此设计的。

我们可以系统地或迅速地将generate转换为返回Sequence。这一次,我们选择迅速地将return表达式中的listOf替换为sequenceOf

fun generate(lines: List<String>): Sequence<String> {
    val valuableCustomers = lines
        .withoutHeader()
        .map(String::toCustomerData)
        .filter { it.score >= 10 }
        .sortedBy(CustomerData::score)
    return sequenceOf("ID\tName\tSpend") +
        valuableCustomers.map(CustomerData::outputLine) +
        valuableCustomers.summarised()
}

示例 20.20 [io-to-data.13:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (差异)

现在,当迭代Sequence时,我们只会逐个创建输出行;每行都可以迅速处理,而不是等到整个文件都写完。

测试必须更改以将返回的Sequence转换为List

private fun check(
    inputLines: List<String>,
    expectedLines: List<String>
) {
    assertEquals(
        expectedLines,
        generate(inputLines).toList()
    )
}

示例 20.21 [io-to-data.13:src/test/java/travelator/marketing/HighValueCustomersReportTests.kt] (差异)

有趣的是,尽管如此,main并没有:

fun main() {
    System.`in`.reader().use { reader ->
        System.out.writer().use { writer ->
            writer.append(
                generate(
                    reader.readLines()
                ).joinToString("\n")
            )
        }
    }
}

示例 20.22 [io-to-data.13:src/main/java/travelator/marketing/HighValueCustomersMain.kt] (差异)

现在main需要重新编译,因为generate现在返回Sequence而不是List,但是它的源代码无需更改。这是因为IterableSequence都定义了joinToString()的扩展函数,都返回String

它可能并不需要改变,但是除非main确实变,否则我们仍然会在写入操作之前创建一个包含所有输出的大字符串。为了避免这种情况,我们需要再次变得命令式,并像我们最初的generate函数那样逐个写入每个输出行:

fun main() {
    System.`in`.reader().use { reader ->
        System.out.writer().use { writer ->
            generate(
                reader.readLines()
            ).forEach { line ->
                writer.appendLine(line)
            }
        }
    }
}

示例 20.23 [io-to-data.14:src/main/java/travelator/marketing/HighValueCustomersMain.kt] (差异)

一丝不苟的读者(别担心,你在朋友之中)会发现这种行为与joinToString("\n")版本略有不同。我们深信尾随的换行符不会引发任何问题,所以我们继续前进。

我们可以假装我们没有在循环,通过将迭代隐藏在我们假设 Kotlin 标准库会定义但似乎没有的Writer::appendLines扩展函数内部:

fun main() {
    System.`in`.reader().use { reader ->
        System.out.writer().use { writer ->
            writer.appendLines(
                generate(reader.readLines())
            )
        }
    }
}

fun Writer.appendLines(lines: Sequence<CharSequence>): Writer {
    return this.also {
        lines.forEach(this::appendLine)
    }
}

示例 20.24 [io-to-data.15:src/main/java/travelator/marketing/HighValueCustomersMain.kt] (diff)

注意,虽然Writer::appendLines的定义是一个单表达式,但我们在第九章中同意在函数是动作时使用长形式,而appendLines绝对是这样的。

现在我们在这里,我们意识到我们可以通过在main中仅迭代原始结果List,逐个写入每一行,就像我们现在在Sequence中所做的那样,来推迟我们的内存危机。这种解决方案将使用更少的内存,尽管如此,我们将其提交,用少量更改购买了大量的剩余空间,并赚取了我们的烤饼。有没有黄油?

高效阅读

如果我们不完成工作并假装我们也需要在读取时节省内存,那我们就会做得不周全。让我们再看看generate

fun generate(lines: List<String>): Sequence<String> {
    val valuableCustomers = lines
        .withoutHeader()
        .map(String::toCustomerData)
        .filter { it.score >= 10 }
        .sortedBy(CustomerData::score)
    return sequenceOf("ID\tName\tSpend") +
        valuableCustomers.map(CustomerData::outputLine) +
        valuableCustomers.summarised()
}

示例 20.25 [io-to-data.15:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (diff)

构建valuableCustomers的操作管道将构建中间List:每个阶段一个,每个都占用内存。输入中的每一行将一次性全部在内存中,以及每一行都有一个CustomerData对象。

我们可以通过从Sequence中读取来避免中间集合,尽管这将带来一些自己的问题。如果我们将generate中的代码更改为将lines转换为Sequence并修复接受List的方法,我们就可以看到这一点:

fun generate(lines: List<String>): Sequence<String> {
    val valuableCustomers: Sequence<CustomerData> = lines
        .asSequence()
        .withoutHeader()
        .map(String::toCustomerData)
        .filter { it.score >= 10 }
        .sortedBy(CustomerData::score)
    return sequenceOf("ID\tName\tSpend") +
        valuableCustomers.map(CustomerData::outputLine) +
        valuableCustomers.summarised()
}

private fun Sequence<String>.withoutHeader() = drop(1)

private fun Sequence<CustomerData>.summarised(): String =
    sumByDouble { it.spend }.let { total ->
        "\tTOTAL\t${total.toMoneyString()}"
    }

示例 20.26 [io-to-data.16:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (diff)

这通过了单元测试。我们完成了吗?这又是一个修辞问题吗?

我们将直截了当地说,问题在于我们最终会在sumByDouble中迭代valuableCustomers两次,一次是在generate之前返回,另一次是我们的调用者迭代返回的Sequence以打印报告之后。如果我们两次迭代Sequence,那么我们将两次完成创建Sequence的所有工作,在本例中:两次删除标题、映射和过滤以及排序。更糟糕的是,当我们尝试在生产中使用代码时,传递一个读取标准输入的Sequence,我们将无法两次迭代它,导致IllegalState​Excep⁠tion。正如我们在第十三章中看到的那样,Sequence的实例在类型系统中未表达的方面有所不同,并且它们还携带隐藏的状态。迭代Sequence看起来像迭代List,但通过消耗其内容会改变Sequence本身。

我们可以通过添加.constrainOnce()调用来证明我们正在滥用这个Sequence

    val valuableCustomers: Sequence<CustomerData> = lines
        .asSequence()
        .constrainOnce()
        .withoutHeader()
        .map(String::toCustomerData)
        .filter { it.score >= 10 }
        .sortedBy(CustomerData::score)

示例 20.27 [io-to-data.17:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (diff)

这将导致我们的测试以IllegalStateException失败。最简单的修复方法是使用.toList()调用解析Sequence

    val valuableCustomers: List<CustomerData> = lines
        .asSequence()
        .constrainOnce()
        .withoutHeader()
        .map(String::toCustomerData)
        .filter { it.score >= 10 }
        .sortedBy(CustomerData::score)
        .toList()

示例 20.28 [io-to-data.18:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (diff)

这在该语句中终止了序列(因此最终读取了整个文件),但至少我们只运行了一次管道,并且每行的内存在解析为toCustomerData后可以立即丢弃。实际上,在这个函数中,我们确实需要读取整个输入,因为Sequence.sortedBy需要读取每个项目来执行排序——它可能返回一个Sequence,但它不是惰性的。

现在我们可以重播我们在本章开头使用的“引入参数”重构。在那里,我们将Reader参数转换为List;现在我们将List转换为Sequence。我们引入的参数是表达式lines.as​Se⁠quence().constrainOnce()

fun generate(lines: Sequence<String>): Sequence<String> {
    val valuableCustomers = lines
        .withoutHeader()
        .map(String::toCustomerData)
        .filter { it.score >= 10 }
        .sortedBy(CustomerData::score)
        .toList()
    return sequenceOf("ID\tName\tSpend") +
        valuableCustomers.map(CustomerData::outputLine) +
        valuableCustomers.summarised()
}

private fun List<CustomerData>.summarised(): String =
    sumByDouble { it.spend }.let { total ->
        "\tTOTAL\t${total.toMoneyString()}"
    }

示例 20.29 [io-to-data.19:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (diff)

重构将ListSequence的转换提升到测试中:

private fun check(
    inputLines: List<String>,
    expectedLines: List<String>
) {
    assertEquals(
        expectedLines,
        generate(
            inputLines.asSequence().constrainOnce()
        ).toList()
    )
}

示例 20.30 [io-to-data.19:src/test/java/travelator/marketing/HighValueCustomersReportTests.kt] (diff)

它也将其提升到main中:

fun main() {
    System.`in`.reader().use { reader ->
        System.out.writer().use { writer ->
            writer.appendLines(
                generate(
                    reader.readLines().asSequence().constrainOnce()
                )
            )
        }
    }
}

示例 20.31 [io-to-data.19:src/main/java/travelator/marketing/HighValueCustomersMain.kt] (diff)

这是我们真正能够节省内存的地方。我们可以通过buffered().lineSequence()Reader获取Sequence,而不是一次性读取所有行并转换为Sequence

fun main() {
    System.`in`.reader().use { reader ->
        System.out.writer().use { writer ->
            writer.appendLines(
                generate(
                    reader.buffered().lineSequence()
                )
            )
        }
    }
}

示例 20.32 [io-to-data.20:src/main/java/travelator/marketing/HighValueCustomersMain.kt] (diff)

现在generate将逐行将行加载到内存中,因为它执行其流水线。我们现在在使用内存方面非常高效并且运行速度令人愉悦。我们能否抵挡最后一次小修补?如果main使用更多扩展函数会更好吗?

fun main() {
    System.`in`.reader().use { reader ->
        System.out.writer().use { writer ->
            reader
                .asLineSequence()
                .toHighValueCustomerReport()
                .writeTo(writer)
        }
    }
}

示例 20.33 [io-to-data.21:src/main/java/travelator/marketing/HighValueCustomersMain.kt] (diff)

最终回答了我们在第十章末尾提出的问题:是的,我们最终将报告生成作为扩展函数。计划成功时我们感到十分满意:

fun Sequence<String>.toHighValueCustomerReport(): Sequence<String> {
    val valuableCustomers = this
        .withoutHeader()
        .map(String::toCustomerData)
        .filter { it.score >= 10 }
        .sortedBy(CustomerData::score)
        .toList()
    return sequenceOf("ID\tName\tSpend") +
        valuableCustomers.map(CustomerData::outputLine) +
        valuableCustomers.summarised()
}

示例 20.34 [io-to-data.21:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (diff)

继续前进

这次重构的动机是为了简化我们的代码。通过将 I/O 移动到我们程序的入口点,内部工作可以是计算而不是动作。它们还可以放弃对 I/O 错误的责任。这一切都很好,但是计算需要接收和返回值,而形成大文件整体内容的值有时对今天的计算机来说可能太多了。

为了解决这个问题,我们转而将我们的List转换为Sequence。序列具有状态,不是值,但是通过一些小心处理,我们可以像延迟值一样对待它们——它们不需要或立即返回它们的全部内容,而是可以按需读取或提供它们。它们不像列表那样简单,但它们的兼容 Kotlin API 允许在两个世界中获得最好的东西的某种形式。

我们最初的ReaderWriter版本的generate需要关注 I/O 错误,而ListList版本将所有 I/O 移到其调用者。Sequence版本介于中间。它不需要关注 I/O 错误,因为Sequence抽象封装了ReaderWriter。这并不意味着它们不可能发生,只是generate不负责处理它们。我们将在第二十一章,Values 的例外中看看我们的市场同事是否有更多基于面糊的奖励,然后再解决这个问题。

第二十一章:从异常到值

在第十九章中,我们讨论了 Kotlin 的错误处理策略,以及如何从 Java 中的异常重构为更功能化的技术。事实上,大多数代码都忽略了错误,希望它们不会发生。我们能做得更好吗?

新加入市场营销团队的人员开始调整我们在第二十章中最后看到的电子表格,这个表格用于生成高价值客户评分。我们不清楚他们具体在做什么,但他们不断导出破坏我们解析的文件,然后要求我们解释堆栈跟踪是什么。这在关系的两端都有些尴尬,所以情况开始变得尴尬。还能有更多的激励吗?

嗯,是的,可能会有。我们还被要求编写一个无人参与的作业,以便市场可以将文件保存到服务器上,而我们将自动编写摘要版本。没有人在其中来解释那些堆栈跟踪,似乎我们必须找到一种适当报告错误的方法。

确定可能出现问题的内容

这是我们离开时的代码:

fun Sequence<String>.toHighValueCustomerReport(): Sequence<String> {
    val valuableCustomers = this
        .withoutHeader()
        .map(String::toCustomerData)
        .filter { it.score >= 10 }
        .sortedBy(CustomerData::score)
        .toList()
    return sequenceOf("ID\tName\tSpend") +
        valuableCustomers.map(CustomerData::outputLine) +
        valuableCustomers.summarised()
}

private fun List<CustomerData>.summarised(): String =
    sumByDouble { it.spend }.let { total ->
        "\tTOTAL\t${total.toMoneyString()}"
    }

private fun Sequence<String>.withoutHeader() = drop(1)

internal fun String.toCustomerData(): CustomerData =
    split("\t").let { parts ->
        CustomerData(
            id = parts[0],
            givenName = parts[1],
            familyName = parts[2],
            score = parts[3].toInt(),
            spend = if (parts.size == 4) 0.0 else parts[4].toDouble()
        )
    }

private val CustomerData.outputLine: String
    get() = "$id\t$marketingName\t${spend.toMoneyString()}"

private fun Double.toMoneyString() = this.formattedAs("%#.2f")

private fun Any?.formattedAs(format: String) = String.format(format, this)

private val CustomerData.marketingName: String
    get() = "${familyName.toUpperCase()}, $givenName"

示例 21.1 [exceptions-to-values.0:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (diff)

如果我们要做好错误处理的彻底工作,首先要做的是确定可能出现的问题。正如我们在第十九章中所看到的,在 Kotlin 中,我们没有检查异常来提供线索,但是在大多数 Java 代码中,它们的使用非常糟糕,因此在这方面两种语言之间没有太大的区别。除非代码已经编写好来传达它可能失败的方式,否则我们将不得不依靠检查、直觉和经验来解决问题。在这种情况下,经验告诉我们,我们实际遇到的失败是由于缺少字段,因此我们可以集中精力解决这个问题,但我们仍然应该对代码的所有方面进行尽职调查。让我们从清单底部的函数开始逐步分析,寻找潜在的错误。

CustomerData.marketingName看起来是无害的:

private val CustomerData.marketingName: String
    get() = "${familyName.toUpperCase()}, $givenName"

示例 21.2 [exceptions-to-values.0:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (diff)

如果CustomerData是用 Java 实现的,我们可能会发现familyName解析为null,因此在尝试toUpperCase()时会抛出异常,但在 Kotlin 中不会这样,所以也不会发生。与所有代码一样,该函数可能会抛出Error的子类(如OutOfMemoryError),但通常是安全的。从现在开始,我们将不将抛出Error视为普通情况,因此不在我们的分析之内。

现在是formattedAs

private fun Any?.formattedAs(format: String) = String.format(format, this)

示例 21.3 [exceptions-to-values.0:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (diff)

String.format(format, this) 实现为 java.lang.String::format,如果 format 与其它输入不兼容,文档中说明会抛出 IllegalFormatException。这是一个偏函数:只针对某些参数值返回结果。在这种情况下,它可以为所有 Double 的值返回结果,但只有在我们使用非常特定的 format 值时才能。幸运的是,我们只是传递了一个特定的 format,即值 %#.2f,我们知道它是有效的,所以这个函数及其唯一的调用者 Double.toMoneyString() 不应该失败。如果它们失败了,那是因为我们的分析有误(或其假设已不再成立),运行时错误是一种合理的方式来表明程序员的错误。

接下来我们有:

private val CustomerData.outputLine: String
    get() = "$id\t$marketingName\t${spend.toMoneyString()}"

示例 21.4 [exceptions-to-values.0:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (diff)

这只调用我们刚刚推断不应该失败的代码,因此按失败的传递性原则,它也应该是安全的。

注意,到目前为止都很简单,因为这些函数都是计算函数(“Calculations”)。它们不依赖于任何外部状态,所以我们可以通过查看它们来推理。

到目前为止一切顺利,现在是 String.toCustomerData()

internal fun String.toCustomerData(): CustomerData =
    split("\t").let { parts ->
        CustomerData(
            id = parts[0],
            givenName = parts[1],
            familyName = parts[2],
            score = parts[3].toInt(),
            spend = if (parts.size == 4) 0.0 else parts[4].toDouble()
        )
    }

示例 21.5 [exceptions-to-values.0:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (diff)

好吧,这又是一个偏函数:几乎没有 String 接收者的值会导致该函数能够返回结果。幸运的是,实践中我们获取的几乎所有值都是可以接受的,这就是为什么错误处理现在才成为优先事项的原因。但是,可能出现什么问题呢?

从函数顶部开始,如果我们传递空分隔符给String.split,它可能会表现得很奇怪,但我们并没有。接着,可能会出现部分不足的情况,这样parts[n]会抛出IndexOutOfBoundsException。最后,parts[3]可能不表示一个Int,或者parts[4]可能不表示一个Double,这两种情况都会抛出NumberFormatException

已经确定 toCustomerData 如果传入不符合我们格式规范的 String 可能会失败,那我们该怎么办呢?目前,所有可能导致失败的方式都会抛出异常,程序会以不友好的错误消息中止,并且会有市场部门找我们。这引出了两个后续问题:“我们应该中止吗?”以及“如何改进错误消息以便市场部门理解?”

正如我们在 第十九章 中看到的,我们不应该使用异常来中止可预测的错误。 Kotlin 中的未检查异常的缺失(以及在 Java 中的不使用)意味着如果我们这样做,我们失去了表明代码容易失败的机会。然后,我们代码的调用者必须像我们目前正在做的那样:推理出实现中的每一行代码。即使在那之后,实现可能会发生变化,默默地使结果无效。

如果我们不打算抛出异常,那么最便宜的更改(只要我们的调用方都是 Kotlin)就是在失败时返回 null。客户端代码将被迫考虑 null 情况并相应地采取行动。例如:

internal fun String.toCustomerData(): CustomerData? =
    split("\t").let { parts ->
        if (parts.size < 4)
            null
        else
            CustomerData(
                id = parts[0],
                givenName = parts[1],
                familyName = parts[2],
                score = parts[3].toInt(),
                spend = if (parts.size == 4) 0.0 else parts[4].toDouble()
            )
    }

[示 示例 21.6 [exceptions-to-values.1:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (diff)

我们本可以选择简单地将整个实现包装在 try 块中,并从 catch 中返回 null,但在这里,我们比反应更主动。这意味着如果相关字段无法转换为 IntDouble,代码仍将抛出异常。我们会解决这个问题。

这个变化破坏了 toHighValueCustomerReport,现在必须考虑失败的可能性:

fun Sequence<String>.toHighValueCustomerReport(): Sequence<String> {
    val valuableCustomers = this
        .withoutHeader()
        .map(String::toCustomerData)
        .filter { it.score >= 10 } ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
        .sortedBy(CustomerData::score)
        .toList()
    return sequenceOf("ID\tName\tSpend") +
        valuableCustomers.map(CustomerData::outputLine) +
        valuableCustomers.summarised()
}

示例 21.7 [exceptions-to-values.1:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (diff)

1

因为 it 是可空的,所以无法编译。

现在,如果我们只想忽略格式错误的输入行,我们可以使用 filterNotNull 让一切重新运行:

fun Sequence<String>.toHighValueCustomerReport(): Sequence<String> {
    val valuableCustomers = this
        .withoutHeader()
        .map(String::toCustomerData)
        .filterNotNull()
        .filter { it.score >= 10 }
        .sortedBy(CustomerData::score)
        .toList()
    return sequenceOf("ID\tName\tSpend") +
        valuableCustomers.map(CustomerData::outputLine) +
        valuableCustomers.summarised()
}

示例 21.8 [exceptions-to-values.2:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (diff)

我们没有任何测试来支持这一点,而且我们真的应该写一些测试,但是现在我们将继续进行,因为这是一种探索性的尝试性解决方案。从这里开始,我们可以使用 null 来表示我们知道 toCustomerData 可以失败的其他方式:

internal fun String.toCustomerData(): CustomerData? =
    split("\t").let { parts ->
        if (parts.size < 4)
            return null
        val score = parts[3].toIntOrNull() ?:
            return null
        val spend = if (parts.size == 4) 0.0 else parts[4].toDoubleOrNull() ?:
            return null
        CustomerData(
            id = parts[0],
            givenName = parts[1],
            familyName = parts[2],
            score = score,
            spend = spend
        )
    }

示例 21.9 [exceptions-to-values.3:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (diff)

注意,Kotlin 标准库通过提供 String::toSomethingOrNull 函数来帮助我们处理此错误处理约定。现在,由回到 toHighValueCustomer​Re⁠port 并想想如何处理它们,而不是假装它们没有发生(读作 filterNotNull)。

我们可以在第一个错误上中止,但值得额外努力收集所有的问题行并以某种方式报告它们。某种方式有点模糊,但有趣的是它有一个类型:在这种情况下是(String) -> Unit。也就是说,我们可以将该做什么委托给一个接受错误行并且不影响结果的函数。我们在“异常之前的错误处理”中提到了这种技术。为了说明这一点,让我们添加一个测试:

@Test
fun `calls back on parsing error`() {
    val lines = listOf(
        "ID\tFirstName\tLastName\tScore\tSpend",
        "INVALID LINE",
        "1\tFred\tFlintstone\t11\t1000.00",
    )

    val errorCollector = mutableListOf<String>()
    val result = lines
        .asSequence()
        .constrainOnce()
        .toHighValueCustomerReport { badLine -> ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
            errorCollector += badLine
        }
        .toList()

    assertEquals(
        listOf(
            "ID\tName\tSpend",
            "1\tFLINTSTONE, Fred\t1000.00",
            "\tTOTAL\t1000.00"
        ),
        result
    )
    assertEquals(
        listOf("INVALID LINE"),
        errorCollector
    )
}

示例 21.10 [exceptions-to-values.4:src/test/java/travelator/marketing/HighValueCustomersReportTests.kt] (diff)

1

这个 lambda 在下一个示例中实现了onErrorLine

让我们用可能能够正常工作的最简单的方法来实现它:

fun Sequence<String>.toHighValueCustomerReport(
    onErrorLine: (String) -> Unit = {}
): Sequence<String> {
    val valuableCustomers = this
        .withoutHeader()
        .map { line ->
            val customerData = line.toCustomerData()
            if (customerData == null)
                onErrorLine(line)
            customerData
        }
        .filterNotNull()
        .filter { it.score >= 10 }
        .sortedBy(CustomerData::score)
        .toList()
    return sequenceOf("ID\tName\tSpend") +
        valuableCustomers.map(CustomerData::outputLine) +
        valuableCustomers.summarised()
}

示例 21.11 [exceptions-to-values.4:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (diff)

这仍然在过滤出错误行,但只有在将它们传递给onErrorLine后才会这样做,它可以决定要执行什么操作。在main中,我们将使用它将错误打印到System.err,然后中止:

fun main() {
    System.`in`.reader().use { reader ->
        System.out.writer().use { writer ->
            val errorLines = mutableListOf<String>()
            val reportLines = reader
                .asLineSequence()
                .toHighValueCustomerReport {
                    errorLines += it
                }
            if (errorLines.isNotEmpty()) {
                System.err.writer().use { error ->
                    error.appendLine("Lines with errors")
                    errorLines.asSequence().writeTo(error)
                }
                exitProcess(-1)
            } else {
                reportLines.writeTo(writer)
            }
        }
    }
}

示例 21.12 [exceptions-to-values.4:src/main/java/travelator/marketing/HighValueCustomersMain.kt] (diff)

这是本书中我们仅有的几处借助可变List的地方之一。为什么在这里?例如,我们可以将toHighValueCustomerReport更改为返回Pair<Sequence<String>, List<String>>,其中第二个元素是错误。这种方案的主要优点是它允许调用者通过在onErrorLine中抛出异常来尽早中止。为了最大灵活性,我们甚至可以有一个具有签名(String) -> CustomerData?的错误处理策略,以便调用者可以提供替代方法,从而允许从任何特定行的错误中恢复。

在第二十章中,我们费了一番功夫将toHighValueCustomerReport从一个操作转换为一个计算。然后,我们通过从Sequence读取和写入来放松了纯度,但在这里,我们引入了一个返回Unit的错误处理函数,这是我们引入了一个操作的明确迹象。只要该操作的范围限定在错误处理中,并且任何副作用都像在这个main中一样,限制在局部变量中,这是另一个合理的折衷方案。这是一种灵活且表达清晰的应急错误处理解决方案,但并非纯粹。

表示错误

现在我们正在传达我们的解析可能失败(通过返回可空类型),以及它在哪里失败(通过回调传递行),我们是否可以更好地传达为什么它失败了?

返回结果类型而不是可空类型允许我们指定存在哪些失败模式并在发生时提供详细信息。让我们将String.to​Custo⁠merData()更改为返回Result而不是可空:

internal fun String.toCustomerData(): Result<CustomerData, ParseFailure> =
    split("\t").let { parts ->
        if (parts.size < 4)
            return Failure(NotEnoughFieldsFailure(this))
        val score = parts[3].toIntOrNull() ?:
            return Failure(ScoreIsNotAnIntFailure(this))
        val spend = if (parts.size == 4) 0.0 else parts[4].toDoubleOrNull() ?:
            return Failure(SpendIsNotADoubleFailure(this))
        Success(
            CustomerData(
                id = parts[0],
                givenName = parts[1],
                familyName = parts[2],
                score = score,
                spend = spend
            )
        )
    }

示例 21.13 [exceptions-to-values.5:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (diff)

正如我们在第十九章中所做的那样,我们创建一个密封类来表示解析失败的原因:

sealed class ParseFailure(open val line: String)
data class NotEnoughFieldsFailure(override val line: String) :
    ParseFailure(line)
data class ScoreIsNotAnIntFailure(override val line: String) :
    ParseFailure(line)
data class SpendIsNotADoubleFailure(override val line: String) :
    ParseFailure(line)

示例 21.14 [exceptions-to-values.5:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (diff)

说实话,在这种情况下(一个单一的数据类携带失败的行和一个字符串原因就足够了),这有些过度,但我们正在举例展示错误工程的卓越性。我们可以通过调用toCustomerData的调用者并在ParseFailure中保持的数据上调用onErrorLine,然后在出现Error时产生null来修复toCustomerData的调用者。这样可以通过当前的测试:

fun Sequence<String>.toHighValueCustomerReport(
    onErrorLine: (String) -> Unit = {}
): Sequence<String> {
    val valuableCustomers = this
        .withoutHeader()
        .map { line ->
            line.toCustomerData().recover {
                onErrorLine(line)
                null
            }
        }
        .filterNotNull()
        .filter { it.score >= 10 }
        .sortedBy(CustomerData::score)
        .toList()
    return sequenceOf("ID\tName\tSpend") +
        valuableCustomers.map(CustomerData::outputLine) +
        valuableCustomers.summarised()
}

示例 21.15 [exceptions-to-values.5:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (diff)

然而,我们真正想要的是暴露ParseFailure。让我们首先更改测试以收集ParseFailure而不是带有错误行的行:

val errorCollector = mutableListOf<ParseFailure>()
val result = lines
    .asSequence()
    .constrainOnce()
    .toHighValueCustomerReport { badLine ->
        errorCollector += badLine
    }
    .toList()
assertEquals(
    listOf(NotEnoughFieldsFailure("INVALID LINE")),
    errorCollector
)

示例 21.16 [exceptions-to-values.6:src/test/java/travelator/marketing/HighValueCustomersReportTests.kt] (diff)

现在我们可以更改onErrorLine以接受失败:

fun Sequence<String>.toHighValueCustomerReport(
    onErrorLine: (ParseFailure) -> Unit = {}
): Sequence<String> {
    val valuableCustomers = this
        .withoutHeader()
        .map { line ->
            line.toCustomerData().recover {
                onErrorLine(it)
                null
            }
        }
        .filterNotNull()
        .filter { it.score >= 10 }
        .sortedBy(CustomerData::score)
        .toList()
    return sequenceOf("ID\tName\tSpend") +
        valuableCustomers.map(CustomerData::outputLine) +
        valuableCustomers.summarised()
}

示例 21.17 [exceptions-to-values.6:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (diff)

这样main就可以报告原因和行:

if (errorLines.isNotEmpty()) {
    System.err.writer().use { error ->
        error.appendLine("Lines with errors")
        errorLines.asSequence().map { parseFailure ->
            "${parseFailure::class.simpleName} in ${parseFailure.line}"
        }.writeTo(error)
    }
    exitProcess(-1)
} else {
    reportLines.writeTo(writer)
}

示例 21.18 [exceptions-to-values.6:src/main/java/travelator/marketing/HighValueCustomersMain.kt] (diff)

我们可能没有使用ParseFailure的运行时类型来处理不同的错误,但我们已经在错误消息中使用了其名称,因此至少我们从我们的小密封类层次结构中获得了一些价值。如果生成的错误消息不足以让营销部门修复其输入,那么我们可以在密封类上使用when表达式来区分不同类型的失败,就像我们在“层次”中看到的那样。

此时,一切都在编译并且我们的测试通过,所以至少在这个小世界中一切都很好。如果我们有更多客户端代码调用此 API,或者我们的更改需要通过更多代码层级传播,我们可能会选择比在一个文件中更改代码并修复错误更复杂的重构策略。然而,通常情况下,当我们能够在最多几分钟内使代码编译并通过测试时,这种努力是不值得的。如果我们发现我们的野心超过了我们的承受能力,那么回退并采取更加考虑周到的方法是很容易的。

现在测试通过了,我们应该回头确保一切都尽可能整洁和表达力强。特别是,在toHighValueCustomerReport中,我们做的是为了让一切重新工作而采取的最快的方法:

fun Sequence<String>.toHighValueCustomerReport(
    onErrorLine: (ParseFailure) -> Unit = {}
): Sequence<String> {
    val valuableCustomers = this
        .withoutHeader()
        .map { line ->
            line.toCustomerData().recover {
                onErrorLine(it)
                null
            }
        }
        .filterNotNull()
        .filter { it.score >= 10 }
        .sortedBy(CustomerData::score)
        .toList()
    return sequenceOf("ID\tName\tSpend") +
        valuableCustomers.map(CustomerData::outputLine) +
        valuableCustomers.summarised()
}

示例 21.19 [exceptions-to-values.6:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (差异)

recover块中产生null然后用filterNotNull跳过这些东西有点不令人满意。它不能直接传达它的工作方式,并且妨碍了愉快的路径。我们希望能找到一个更好的valuableCustomers表达式的表达方式,但事实上,在你们的作者看来,其他一切都有点更糟。如果您找到一个简单而好的方法,请告诉我们。

类似地,toCustomerData中的提前返回看起来有点丑陋:

internal fun String.toCustomerData(): Result<CustomerData, ParseFailure> =
    split("\t").let { parts ->
        if (parts.size < 4)
            return Failure(NotEnoughFieldsFailure(this))
        val score = parts[3].toIntOrNull() ?:
            return Failure(ScoreIsNotAnIntFailure(this))
        val spend = if (parts.size == 4) 0.0 else parts[4].toDoubleOrNull() ?:
            return Failure(SpendIsNotADoubleFailure(this))
        Success(
            CustomerData(
                id = parts[0],
                givenName = parts[1],
                familyName = parts[2],
                score = score,
                spend = spend
            )
        )
    }

示例 21.20 [exceptions-to-values.6:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (差异)

“适当”的功能性错误处理不会提前返回,而是使用flatMap链。易于紧张的读者可能希望离开:

internal fun String.toCustomerData(): Result<CustomerData, ParseFailure> =
    split("\t").let { parts ->
        parts
            .takeUnless { it.size < 4 }
            .asResultOr { NotEnoughFieldsFailure(this) }
            .flatMap { parts ->
                parts[3].toIntOrNull()
                    .asResultOr { ScoreIsNotAnIntFailure(this) }
                    .flatMap { score: Int ->
                        (if (parts.size == 4) 0.0
                        else parts[4].toDoubleOrNull())
                            .asResultOr { SpendIsNotADoubleFailure(this) }
                            .flatMap { spend ->
                                Success(
                                    CustomerData(
                                        id = parts[0],
                                        givenName = parts[1],
                                        familyName = parts[2],
                                        score = score,
                                        spend = spend
                                    )
                                )
                            }
                    }
            }
    }

示例 21.21 [exceptions-to-values.7:src/main/java/travelator/marketing/HighValueCustomersReport.kt] (差异)

你们的作者甚至比大多数人更喜欢单一表达式,但如果这是Result(故意的双关语),那就不行了。在这里,我们显然可以通过引入更多函数来简化(例如,asResultOr ... flatMap看起来就像是一个试图获得的概念)。其他一些结果库可以让我们滥用协程或异常来达到与之前提前返回相同的效果,但是没有更好的语言支持来避免每个语句都需要缩进,Kotlin 的语法更青睐于在这些情况下提前返回。虽然我们在本书中没有专门讨论过这一点,但 Lambda 可以被内联编译,因此支持从其包围函数返回的事实鼓励我们在这种情况下使用命令式代码。因此,对我们来说,提前返回是可以接受的。

最后,在我们最后检查之前返回main

fun main() {
    System.`in`.reader().use { reader ->
        System.out.writer().use { writer ->
            val errorLines = mutableListOf<ParseFailure>()
            val reportLines = reader
                .asLineSequence()
                .toHighValueCustomerReport {
                    errorLines += it
                }
            if (errorLines.isNotEmpty()) {
                System.err.writer().use { error ->
                    error.appendLine("Lines with errors")
                    errorLines.asSequence().map { parseFailure ->
                        "${parseFailure::class.simpleName} in ${parseFailure.line}"
                    }.writeTo(error)
                }
                exitProcess(-1)
            } else {
                reportLines.writeTo(writer)
            }
        }
    }
}

例子 21.22 [exceptions-to-values.6:src/main/java/travelator/marketing/HighValueCustomersMain.kt] (差异)

这三层嵌套的use使实际结构变得模糊,而函数深处的exitProcess也有些靠不住。我们可以定义自己的using重载来解决前者,并传递一个退出码来解决后者(这是使用数据而不是控制流来处理错误的示例)。我们还可以提取一个扩展函数来打印错误:

fun main() {
    val statusCode = using(
        System.`in`.reader(),
        System.out.writer(),
        System.err.writer()
    ) { reader, writer, error ->
        val errorLines = mutableListOf<ParseFailure>()
        val reportLines = reader
            .asLineSequence()
            .toHighValueCustomerReport {
                errorLines += it
            }
        if (errorLines.isEmpty()) {
            reportLines.writeTo(writer)
            0
        } else {
            errorLines.writeTo(error)
            -1
        }
    }
    exitProcess(statusCode)
}

inline fun <A : Closeable, B : Closeable, C : Closeable, R> using(
    a: A,
    b: B,
    c: C,
    block: (A, B, C) -> R
): R =
    a.use {
        b.use {
            c.use {
                block(a, b, c)
            }
        }
    }

private fun List<ParseFailure>.writeTo(error: OutputStreamWriter) {
    error.appendLine("Lines with errors")
    asSequence().map { parseFailure ->
        "${parseFailure::class.simpleName} in ${parseFailure.line}"
    }.writeTo(error)
}

例子 21.23 [exceptions-to-values.8:src/main/java/travelator/marketing/HighValueCustomersMain.kt] (差异)

I/O 怎么样?

那几乎是足够好的了。然而,在我们离开之前,我们应该考虑一下 I/O 错误。由于我们引入了List然后是Sequence,我们的报告生成代码不必担心写入失败,因为迭代结果行并实际执行写入是调用代码的责任。在这种情况下的main函数合理地假设System.out总是存在,但当我们实现激发此重构的无人看管作业时,我们将不得不处理文件或网络套接字即使在我们开始时它们是打开的也可能消失的可能性。

读取时也存在类似的情况。我们现在正在迭代Sequence中的每个String。在测试代码中,这些都在内存中,但在生产中,它们是从文件(通过System.in)中获取的。因此,我们的Sequence操作可能会因为报告生成不知情的IOExceptions而失败。

在这些情况下,toHighValueCustomerReport()很少能或应该做什么。一旦我们开始在这里读取,就没有实际的恢复 I/O 错误的方法——中止整个操作是明智的选择。幸运的是,现在责任完全落在调用者(在这种情况下是main)身上。toHighValueCustomerReport通过其onErrorLine参数信号化它知道的错误(解析失败)及其表示方式(ParseFailure的子类)。IOException不是它的责任。正是main将 I/O 支持的Sequence传入toHighValueCustomerReport,因此main应该意识到toHighValueCustomerReport可能因此失败并相应地处理。让我们添加那段代码:

fun main() {
    val statusCode = try {
        using(
            System.`in`.reader(),
            System.out.writer(),
            System.err.writer()
        ) { reader, writer, error ->
            val errorLines = mutableListOf<ParseFailure>()
            val reportLines = reader
                .asLineSequence()
                .toHighValueCustomerReport {
                    errorLines += it
                }
            if (errorLines.isEmpty()) {
                reportLines.writeTo(writer)
                0
            } else {
                errorLines.writeTo(error)
                -1
            }
        }
    } catch (x: IOException) {
        System.err.println("IO error processing report ${x.message}")
        -1
    }
    exitProcess(statusCode)
}

例子 21.24 [exceptions-to-values.9:src/main/java/travelator/marketing/HighValueCustomersMain.kt] (差异)

对于这个应用来说可能有点过头了,但它展示了捕获和处理我们期望的异常模式(例如为IOException打印一个相对友好的消息),但允许其他所有异常泄露并退出应用程序。如果我们遵循第十九章的策略,意外的异常要么是不可恢复的环境错误,要么是程序员错误。在这两种情况下,默认的 JVM 行为是在打印堆栈跟踪后退出进程,这样我们有一定的机会诊断问题。当我们将其转换为无人看管的服务器作业时,我们将类似地处理顶层处理程序函数中的预期错误。我们可能会在IOException上中止或重新尝试整个交互,如果认为问题可能是暂时性的。我们知道重新尝试对解析错误无济于事,因此我们必须记录这些错误和/或将通知发送到某个地方。处理程序函数中的意外错误通常允许泄漏到通用异常处理代码,该代码将记录它们并在返回线程到其池之前发送内部服务器错误状态。

继续前进

在工程中,我们经常不得不做出妥协。特别是,试图简化一件事往往会使另一件事复杂化。I/O 以两种方式使我们的软件复杂化。它是一种行为,因此我们不能简单地忽视它何时发生或重构时发生;它也容易出现错误,如果我们想要一个强大的系统,就必须处理这些错误。这些错误可能是简单的环境故障导致无法读取或写入,或者是因为我们正在读取的内容不符合我们的预期——例如,当营销文件以不良格式结尾时。

行为和错误都会影响调用者,而解决方法在两种情况下是一样的:将代码移近入口点,以减少系统的污染。因此,在这个领域,我们无需做出妥协,却可以一举解决两个问题。通过将 I/O 操作移到系统外部,我们可以减少行为和错误增加代码复杂性的方式。

第二十二章:从类到函数

面向对象的程序员擅长通过创建类型来解决问题。而函数式程序员则倾向于使用函数来增强现有类型。在不定义新类型的情况下,我们能走多远呢?

在 第十五章,《封装集合到类型别名》 中,我们看到使用原始集合的优势,在 第十六章,《接口到函数》 中,我们探讨了使用内置函数类型而不是创建新类型。在本章中,我们将应用我们学到的经验,从头开始编写一些 Kotlin 代码。

即使在如今的 REST API 和 Webhooks 的时代,许多自动化的企业间通信仍然以通过安全文件传输协议(SFTP)交换的表格式文本数据形式存在。Travelator 必须导入关于营地位置、景点、未解决的账单等数据,这些数据通常以常规的行和列形式存在,使用不同的列分隔符,并且有时候有表头来命名其余的行。在 第二十章 中,我们看到一个团队已经创建了自己的解析器;在其他地方,我们使用了备受信赖的 Apache Commons CSV 库。老实说,对于大多数情况,我们仍然会使用 Commons CSV,因为它可以直接使用,对特殊情况进行了很好的配置,并且与 Kotlin 非常兼容。

今天我们要看看一个干净的 Kotlin 解析器会是什么样子。完成后,我们将比较我们的成果与 Commons CSV 的功能,以便看到 Java 和 Kotlin 的差异 API 和实现。

一个验收测试

正如你可能从前面的章节中看到的那样,Travelator 的开发人员是极限编程者(《极限编程解密:拥抱变化》)。我们先编写测试,从高级别的验收测试开始。我们正在处理一个表格阅读器,因此我们创建了一个名为 TableReaderAcceptanceTests 的类,并添加了一个存根方法,然后检查它是否运行:

class TableReaderAcceptanceTests {
    @Test
    fun test() {
    }
}

示例 22.1 [table-reader.1:src/test/java/travelator/tablereader/TableReaderAcceptanceTests.kt] (差异)

它确实运行了(甚至通过了测试!),现在我们可以开始编写真正的代码了。

验收测试的一部分工作是帮助我们决定接口应该是什么样子。在解析了一些文件后,我们知道我们几乎总是希望做的事情是读取文件并返回某种域类型的值列表,每个(非表头)行一个。让我们将其草拟为我们的测试,以 Measurement 作为我们的域类型:

class TableReaderAcceptanceTests {
    data class Measurement(
        val t: Double,
        val x: Double,
        val y: Double,
    )

    @Test
    fun `acceptance test`() {
        val input = listOf(
            "time,x,y",
            "0.0,  1,  1",
            "0.1,1.1,1.2",
            "0.2,1.2,1.4",
        )
        val expected = listOf(
            Measurement(0.0, 1.0, 1.0),
            Measurement(0.1, 1.1, 1.2),
            Measurement(0.2, 1.2, 1.4)
        )
        assertEquals(
            expected,
            someFunction(input)
        )
    }

    private fun someFunction(input: List<String>): List<Measurement> {
        TODO("Not yet implemented")
    }
}

示例 22.2 [table-reader.2:src/test/java/travelator/tablereader/TableReaderAcceptanceTests.kt] (差异)

这里的Measurement是一个值类型,代表我们要从每行表中提取的数据。在 Java 中,我们可能会从创建TableReader类开始,但是从测试中我们可以看出,读取表只是一个计算:将输入行映射到我们想要的数据列表(“Calculations”)。因此,我们将默认使用顶层的someFunction,直到我们被迫做出更复杂的事情。

我们可以想象各种神奇的方式来实现我们的 API 如何执行someFunction,但是除非它对Measurement类型有特殊的了解(而库不了解我们的类型,这是反过来的错误方法),否则我们将不得不告诉它如何从行的某种表示映射到Measurement

这是我们第二次使用单词map了。也许map是关键?(一个偶然的双关语。)如果someFunction看起来像这样会怎么样?

private fun someFunction(input: List<String>): List<Measurement> =
    readTable(input) ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
        .map { record -> ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/2.png) ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/3.png)
        Measurement(
            record["time"].toDouble(), ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/4.png)
            record["x"].toDouble(), ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/4.png)
            record["y"].toDouble(), ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/4.png)
        )
    }

示例 22.3 [table-reader.3:src/test/java/travelator/tablereader/TableReaderAcceptanceTests.kt] (差异)

1

readTable是我们的表读取 API 入口点

2

它返回一个具有map实现的东西。

3

record是我们在表中表示一行的数据。

4

我们可以通过字段名索引record,得到一个String,然后我们可以将其转换为其他类型。

这段代码无法编译,因为我们还没有实现readTable,但是如果我们在错误上按 Alt-Enter,IntelliJ 会为我们创建这个函数:

private fun readTable(input: List<String>): Any {
    TODO("Not yet implemented")
}

示例 22.4 [table-reader.3:src/test/java/travelator/tablereader/TableReaderAcceptanceTests.kt] (差异)

我们还没有给 IntelliJ 足够的关于readTable返回类型的线索,所以它选择了Any,因此someFunction仍然无法编译通过。我们能返回什么类型来解决这个问题呢?如果我们从readTable返回一个List,那么map就是List的一个操作。如果这个List包含Map<String, String>,那么我们的record变量就会是Map<String, String>,这样我们就可以调用record["time"]等操作。唯一的问题是Map.get返回一个可空值。这已经足够接近了——在someFunction中,我们可以通过在get返回null时引发错误来处理它:

private fun someFunction(input: List<String>): List<Measurement> =
    readTable(input).map { record ->
        Measurement(
            record["time"]?.toDoubleOrNull() ?: error("in time"),
            record["x"]?.toDoubleOrNull() ?: error("in x"),
            record["y"]?.toDoubleOrNull() ?: error("in y"),
        )
    }

fun readTable(input: List<String>): List<Map<String, String>> {
    TODO("Not yet implemented")
}

示例 22.5 [table-reader.4:src/test/java/travelator/tablereader/TableReaderAcceptanceTests.kt] (差异)

这编译通过了,尽管显然TODO未通过测试。(你可能会问为什么我们对错误采取这种鲁莽的态度,与我们的法医第二十一章相比。答案是这只是测试代码:Map.get的 API 正在迫使我们考虑在错误情况下该怎么做,而我们的测试选择抛出。)

我们戴上客户的帽子来编写验收测试,这些测试显示我们至少可以使用readTable函数的签名将行转换为Measurement列表。现在我们有了一个合理的 API,我们可以将readTable的定义移到src/main/travelator/tablereader/table-reading.kt中:

fun readTable(input: List<String>): List<Map<String, String>> {
    TODO("Not yet implemented")
}

示例 22.6 [table-reader.5:src/main/java/travelator/tablereader/table-reading.kt] (diff)

最后,在这个第一阶段中,我们可以内联someFunction以提供我们的验收测试:

@Disabled
@Test
fun `acceptance test`() {
    val input = listOf(
        "time,x,y",
        "0.0,  1,  1",
        "0.1,1.1,1.2",
        "0.2,1.2,1.4",
    )
    val expected = listOf(
        Measurement(0.0, 1.0, 1.0),
        Measurement(0.1, 1.1, 1.2),
        Measurement(0.2, 1.2, 1.4)
    )
    assertEquals(
        expected,
        readTable(input).map { record ->
            Measurement(
                t = record["time"]?.toDoubleOrNull() ?: error("in time"),
                x = record["x"]?.toDoubleOrNull() ?: error("in x"),
                y = record["y"]?.toDoubleOrNull() ?: error("in y"),
            )
        }
    )
}

示例 22.7 [table-reader.5:src/test/java/travelator/tablereader/TableReaderAcceptanceTests.kt] (diff)

请注意,我们已经禁用了这个测试,因为在让它运行之前还需要一些时间。对于验收测试来说这没问题。我们不指望很快通过它们,更多的是告诉我们何时完成。目前,它已经完成了它的工作,帮助我们勾画出一个简单的 API,现在我们可以实现它了。

在我们继续之前,让我们反思一下,我们成功定义了解析器的接口,而没有定义任何新的类型,而是使用了StringListMap。通过使用标准类型,我们知道我们有丰富的 Kotlin API 来提供我们正在读取的List,并解释我们正在返回的MapList

单元测试

现在我们有了一个要实现的接口,我们可以停止验收测试并编写一个最小的单元测试。什么是最小的?我们喜欢从空白开始:如果我们读取一个空文件会发生什么?

class TableReaderTests {
    @Test
    fun `empty list returns empty list`() {
        val input: List<String> = emptyList()
        val expectedResult: List<Map<String, String>> = emptyList()
        assertEquals(
            expectedResult,
            readTable(input)
        )
    }
}

示例 22.8 [table-reader.6:src/test/java/travelator/tablereader/TableReaderTests.kt] (diff)

readTable返回预设结果是通过的最简单方法:

fun readTable(input: List<String>): List<Map<String, String>> {
    return emptyList()
}

示例 22.9 [table-reader.7:src/main/java/travelator/tablereader/table-reading.kt] (diff)

这一步通过了。看起来可能微不足道,但是为了空输入编写一个测试总是一个好主意。我们的算法越复杂,它在这种情况下失败的可能性就越大。然而,总是返回空结果的解析器是不好的,所以让我们继续前进。遵循 TDD(通过示例驱动开发),我们首先需要添加一个失败的测试来给我们改变实现的理由。我们选择添加读取没有标题行但有一行数据的表格的情况。

为什么要这样做,而不是使用标题和一行数据呢?坦率地说,这只是我脑海中的第一件事;也许如果我们真的在这个时候一起工作,你会建议使用标题行。我们的选择让我们不得不决定如何命名列,并且我们决定使用它们的索引的 String 表示,第一列为 "0",第二列为 "1",以此类推;这感觉是我们生成 String 键的最简单方法:

@Test
fun `empty list returns empty list`() {
    assertEquals(
        emptyList<Map<String, String>>(),
        readTable(emptyList())
    )
}

@Test
fun `one line of input with default field names`() {
    assertEquals(
        listOf(
            mapOf("0" to "field0", "1" to "field1")
        ),
        readTable(listOf(
            "field0,field1"
        ))
    )
}

示例 22.10 [table-reader.8:src/test/java/travelator/tablereader/TableReaderTests.kt] (diff)

我们可以选择在没有标题行时使 readTable 返回 <Map<Int, String>>。如果你有空闲时间,这可能是一个值得探索的路径。

回到我们当前的困境,我们有一个失败的测试,我们可以聪明地处理,也可以快速解决。我们选择快速方式,通过再次硬编码结果直接使测试通过:

fun readTable(lines: List<String>): List<Map<String, String>> {
    return if (lines.isEmpty())
        emptyList()
    else listOf(
        mapOf("0" to "field0", "1" to "field1")
    )
}

示例 22.11 [table-reader.8:src/main/java/travelator/tablereader/table-reading.kt] (diff)

现在我们的测试通过了,我们可以通过注意到我们希望输出的每一行与输入的每一行对应来简化实现。Iterable::map 可以做到这一点,使我们可以移除 if 表达式:

fun readTable(lines: List<String>): List<Map<String, String>> {
    return lines.map {
        mapOf("0" to "field0", "1" to "field1")
    }
}

示例 22.12 [table-reader.9:src/main/java/travelator/tablereader/table-reading.kt] (diff)

这样继续通过测试,并且现在可以处理更多行(相同的数据)!但这只是一个过渡阶段,允许我们将 lambda 提取为一个函数:

fun readTable(lines: List<String>): List<Map<String, String>> {
    return lines.map(::parseLine)
}

private fun parseLine(line: String) = mapOf("0" to "field0", "1" to "field1")

示例 22.13 [table-reader.10:src/main/java/travelator/tablereader/table-reading.kt] (diff)

现在我们将通过将键值对分为 keysvalues 来开始移除硬编码的值:

private fun parseLine(line: String): Map<String, String> {
    val keys = listOf("0", "1")
    val values = listOf("field0", "field1")
    return keys.zip(values).toMap()
}

示例 22.14 [table-reader.11:src/main/java/travelator/tablereader/table-reading.kt] (diff)

我们仍然在坚定地作弊,但是现在我们可以看到keys中的模式,并从values中生成这些:

private fun parseLine(line: String): Map<String, String> {
    val values = listOf("field0", "field1")
    val keys = values.indices.map(Int::toString)
    return keys.zip(values).toMap()
}

示例 22.15 [table-reader.12:src/main/java/travelator/tablereader/table-reading.kt] (差异)

对于values,我们可以将行按逗号分割:

private fun parseLine(line: String): Map<String, String> {
    val values = line.split(",")
    val keys = values.indices.map(Int::toString)
    return keys.zip(values).toMap()
}

示例 22.16 [table-reader.13:src/main/java/travelator/tablereader/table-reading.kt] (差异)

成功:我们已经移除了硬编码的键和值,测试仍然通过。因为我们在readTable中使用了lines.map,我们相信该函数对任意数量的行都有效,但最好有一个测试来确认这一点。

我们要做个笔记,因为有些东西让我们感到不安,我们想先看一下。如果你和作者一样老(或更年轻且有天赋),你可能已经对代码产生了直觉,当你看到那个split时,它可能会让你感到不安。如果我们试图分割一个空行会发生什么?实际上,当readTable读取到一个空行时应该返回什么?

讨论之后,我们得出结论,一个空行应该返回一个空的Map。这感觉很清晰,所以我们编写了一个测试来记录我们的决定并验证它是否有效:

@Test
fun `empty line returns empty map`() {
    assertEquals(
        listOf(
            emptyMap<String, String>()
        ),
        readTable(listOf(
            ""
        ))
    )
}

示例 22.17 [table-reader.14:src/test/java/travelator/tablereader/TableReaderTests.kt] (差异)

啊哈!

org.opentest4j.AssertionFailedError:
Expected :[{}]
Actual   :[{0=}]

在一番调查之后,我们发现在空String上调用split会返回一个单个空StringList。也许在其他情况下这是有道理的。也许是这样,但这混乱了我们的算法,所以我们不得不在parseLine中特别处理:

private fun parseLine(line: String): Map<String, String> {
    val values = if (line.isEmpty()) emptyList() else line.split(",")
    val keys = values.indices.map(Int::toString)
    return keys.zip(values).toMap()
}

示例 22.18 [table-reader.14:src/main/java/travelator/tablereader/table-reading.kt] (差异)

这样测试通过了,但是让parseLine函数变得混乱了。所以我们将混乱的部分提取到了一个名为splitFields的函数中:

private fun parseLine(line: String): Map<String, String> {
    val values = splitFields(line)
    val keys = values.indices.map(Int::toString)
    return keys.zip(values).toMap()
}

private fun splitFields(line: String): List<String> =
    if (line.isEmpty()) emptyList() else line.split(",")

示例 22.19 [table-reader.15:src/main/java/travelator/tablereader/table-reading.kt] (差异)

如果我们将splitFields作为扩展函数,并引入一个separators参数,我们就得到了我们真正想要split成为的函数:

private fun parseLine(line: String): Map<String, String> {
    val values = line.splitFields(",")
    val keys = values.indices.map(Int::toString)
    return keys.zip(values).toMap()
}

private fun String.splitFields(separators: String): List<String> =
    if (isEmpty()) emptyList() else split(separators)

示例 22.20 [table-reader.16:src/main/java/travelator/tablereader/table-reading.kt] (差异)

到目前为止,我们已经使代码能够处理空输入和单行输入。如果我们编写了一个命令式解决方案,现在可能必须添加一个循环来处理更多输入,但 map 已经为我们做好了,因为它将始终返回与我们给它的一样多的项目。我们相信 readTable 应该适用于程序员已知的所有数字:0、1 和无穷大(好吧,好吧,不是实际的无穷大而是 2³¹ - 1)。

“信任但验证”他们说,所以我们添加了一个测试:

@Test
fun `two lines of input with default field names`() {
    assertEquals(
        listOf(
            mapOf("0" to "row0field0", "1" to "row0field1"),
            mapOf("0" to "row1field0", "1" to "row1field1")
        ),
        readTable(listOf(
            "row0field0,row0field1",
            "row1field0,row1field1"
        ))
    )
}

示例 22.21 [table-reader.17:src/test/java/travelator/tablereader/TableReaderTests.kt] (差异)

它通过了,我们推断 (0, 1, 2) 接近于 (0, 1, 2147483647),所以我们暂时完成了。这似乎是一个检查点,可以去喝杯新鲜咖啡,然后处理掉上一杯咖啡,继续工作了。

头部

准备好再次启动了吗?好的,头部行呢?

首先,我们的 API 应该如何知道要期望一个头部?我们可以在 readTable 中添加一个标志来告诉它我们的数据有一个头部,或者我们可以添加另一个函数。通常我们更喜欢为不同的功能添加不同的函数,所以让我们添加一个名为 readTableWithHeader 的函数。

readTable 一样,我们首先添加一个调用我们希望有的函数的测试:

@Test
fun `takes headers from header line`() {
    assertEquals(
        listOf(
            mapOf("H0" to "field0", "H1" to "field1")
        ),
        readTableWithHeader(
            listOf(
                "H0,H1",
                "field0,field1"
            )
        )
    )
}

示例 22.22 [table-reader.18:src/test/java/travelator/tablereader/TableReaderTests.kt] (差异)

readTableWithHeader 上的编译错误上按 Alt-Enter,IntelliJ 将为我们创建它。然后我们现在可以命名参数并委托给我们的原始函数:

fun readTableWithHeader(lines: List<String>): List<Map<String, String>> {
    return readTable(lines)
}

fun readTable(lines: List<String>): List<Map<String, String>> {
    return lines.map(::parseLine)
}

示例 22.23 [table-reader.18:src/main/java/travelator/tablereader/table-reading.kt] (差异)

这个编译了,但是测试失败了,正如我们所预料的:

org.opentest4j.AssertionFailedError:
Expected :[{H0=field0, H1=field1}]
Actual   :[{0=H0, 1=H1}, {0=field0, 1=field1}]

要使测试通过,我们可以像以前一样硬编码结果,但这次我们将修改代码以为功能留出空间。当我们说 留出空间 时,我们的目标是编写当前代码(使用 Int::toString 字段名称)并且我们能够 扩展 而不是修改以支持新功能。然后,新功能将是一个添加 而不是 修改(开闭原则)。

目前,字段名信息被深埋在 parseLine 中:

private fun parseLine(line: String): Map<String, String> {
    val values = line.splitFields(",")
    val keys = values.indices.map(Int::toString)
    return keys.zip(values).toMap()
}

示例 22.24 [table-reader.18:src/main/java/travelator/tablereader/table-reading.kt] (差异)

我们将其从这里提取到一个我们可以使用头部行来提供的地方。

Int::toString 是我们从索引到键的当前映射。让我们准备通过引入一个名为 headerProvider 的变量来使其可配置:

private fun parseLine(line: String): Map<String, String> {
    val values = line.splitFields(",")
    val headerProvider: (Int) -> String = Int::toString
    val keys = values.indices.map(headerProvider)
    return keys.zip(values).toMap()
}

示例 22.25 [table-reader.19:src/main/java/travelator/tablereader/table-reading.kt] (diff)

尽管我们的测试仍然通过,但新的takes headers from header line仍然失败。我们真的不应该在测试失败时进行重构,因为每次运行测试时,我们都必须检查任何失败是否确实是我们期望的失败。所以我们暂时将其@Disabled,仅在重构完成的功能上运行测试。

headerProvider行上“引入参数”,并将其命名为headerProvider,将允许我们支持不同的行为:

private fun parseLine(
    line: String,
    headerProvider: (Int) -> String
): Map<String, String> {
    val values = line.splitFields(",")
    val keys = values.indices.map(headerProvider)
    return keys.zip(values).toMap()
}

示例 22.26 [table-reader.20:src/main/java/travelator/tablereader/table-reading.kt] (diff)

不幸的是,IntelliJ 目前无法使此重构生效,导致readTable出现问题:

fun readTableWithHeader(lines: List<String>): List<Map<String, String>> {
    return readTable(lines)
}

fun readTable(lines: List<String>): List<Map<String, String>> {
    return lines.map(::parseLine) ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
}

示例 22.27 [table-reader.20:src/main/java/travelator/tablereader/table-reading.kt] (diff)

1

我们之前可以在parseLine只有一个参数时使用函数引用。现在它需要两个参数,但map只能提供一个。

“在重构之前用 lambda 替换函数引用”现在本应使一切正常,但我们将失败前进,现在扩展 lambda 并将Int::toString添加为headerProvider来使编译通过:

fun readTableWithHeader(lines: List<String>): List<Map<String, String>> {
    return readTable(lines)
}

fun readTable(lines: List<String>): List<Map<String, String>> {
    return lines.map { parseLine(it, Int::toString) }
}

示例 22.28 [table-reader.21:src/main/java/travelator/tablereader/table-reading.kt] (diff)

所有测试仍然通过,所以我们相当自信我们没有破坏任何东西。

我们这样做的目的是让新的readTableWithHeader读取头行以创建headerProvider以传递给parseLine。坐在readTableWithHeaderparseLine之间的是我们旧的readTable调用,因此它也需要一个headerProvider参数,以便它可以中继该值。所以这又是“引入参数”(带“引入默认值”),这次在Int::toString中的readTable上:

fun readTableWithHeader(lines: List<String>): List<Map<String, String>> {
    return readTable(lines)
}

fun readTable(
    lines: List<String>,
    headerProvider: KFunction1<Int, String> = Int::toString ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
): List<Map<String, String>> {
    return lines.map { parseLine(it, headerProvider) }
}

示例 22.29 [table-reader.22:src/main/java/travelator/tablereader/table-reading.kt] (diff)

1

不编译:Unresolved reference: KFunction1

很难说为什么 IntelliJ(写作时)有时在重构时使用函数类型,有时使用KFunctionN类型。如果能保持一致,或者至少生成编译的代码,那就太好了。我们将手动将KFunction1转换为(Int) -> String,对于这第二次失败的重构,我们有点怨念:

fun readTableWithHeader(lines: List<String>): List<Map<String, String>> {
    return readTable(lines)
}

fun readTable(
    lines: List<String>,
    headerProvider: (Int) -> String = Int::toString
): List<Map<String, String>> {
    return lines.map { parseLine(it, headerProvider) }
}

示例 22.30 [table-reader.23:src/main/java/travelator/tablereader/table-reading.kt] (diff)

正面的是,由于headerProvider参数有默认值,我们的测试保持不变,仍然通过。

现在我们可以解析标题行了;readTableWithHeader需要读取标题,创建一个headerProvider(一个(Int) -> String记住),然后委托给readTable。它需要将行拆分为标题(Iterable.first())和其余部分(Iterable.drop(1))。如果没有行,Iterable.first将失败,因此我们注意要添加一个针对此情况的测试。至于将标题行转换为headerProvider,我们假装有一个叫做headerProviderFrom(String)的函数可以做到:

fun readTableWithHeader(lines: List<String>): List<Map<String, String>> {
    return readTable(
        lines.drop(1),
        headerProviderFrom(lines.first())
    )
}

示例 22.31 [table-reader.24:src/main/java/travelator/tablereader/table-reading.kt] (diff)

在新函数的调用上按 Alt-Enter 允许我们创建它,得到:

fun headerProviderFrom(header: String): (Int) -> String {
    TODO("Not yet implemented")
}

示例 22.32 [table-reader.24:src/main/java/travelator/tablereader/table-reading.kt] (diff)

这是一个需要返回函数类型的函数。我们可以用一个 lambda 实现返回值,该 lambda 接受一个Int索引并返回一个String。我们需要返回的String是该索引处的标题字段。我们可以再次使用我们的splitFields

private fun headerProviderFrom(header: String): (Int) -> String {
    val headers = header.splitFields(",")
    return { index -> headers[index] }
}

示例 22.33 [table-reader.25:src/main/java/travelator/tablereader/table-reading.kt] (diff)

我们注意到在 lambda 外部分割了header;否则,它将发生在表的每一行之后。我们的测试仍然通过,如果我们正确,之前禁用的readTableWithHeader的测试也会通过。让我们解禁它:

@Test
fun `takes headers from header line`() {
    assertEquals(
        listOf(
            mapOf("H0" to "field0", "H1" to "field1")
        ),
        readTableWithHeader(
            listOf(
                "H0,H1",
                "field0,field1"
            )
        )
    )
}

示例 22.34 [table-reader.26:src/test/java/travelator/tablereader/TableReaderTests.kt] (diff)

这通过了,万岁!我们准备宣布我们暂时完成了,直到我们看到待办事项清单,记得我们预测readTableWithHeader在空输入时应该失败。所以我们写了一个测试来断言所需的行为,即返回一个空的List

@Test
fun `readTableWithHeader on empty list returns empty list`() {
    assertEquals(
        emptyList<String>(),
        readTableWithHeader(
            emptyList()
        )
    )
}

示例 22.35 [table-reader.26:src/test/java/travelator/tablereader/TableReaderTests.kt] (diff)

正如我们所料,这会因为在空的List上调用lines.first()而失败,抛出java.util.NoSuchElementException: List is empty.

fun readTableWithHeader(lines: List<String>): List<Map<String, String>> {
    return readTable(
        lines.drop(1),
        headerProviderFrom(lines.first())
    )
}

示例 22.36 [table-reader.25:src/main/java/travelator/tablereader/table-reading.kt] (diff)

我们对未完成感到不满,但是我们的正确性使我们感到宽慰!最简单的修复方法是将函数拆分为两个定义,并使用when来选择它们之间的函数。这通过了所有的测试并清空了我们的待办事项列表。因此,这是我们的公共 API:

fun readTableWithHeader(
    lines: List<String>
): List<Map<String, String>> =
    when {
        lines.isEmpty() -> emptyList()
        else -> readTable(
            lines.drop(1),
            headerProviderFrom(lines.first())
        )
    }

fun readTable(
    lines: List<String>,
    headerProvider: (Int) -> String = Int::toString
): List<Map<String, String>> =
    lines.map { parseLine(it, headerProvider) }

示例 22.37 [table-reader.26:src/main/java/travelator/tablereader/table-reading.kt] (diff)

这很好。现在我们的客户可以选择是否使用标题行进行阅读。但是等等!看着代码,我们意识到,如果他们想要为readTable指定自己的字段名称,可以通过重写readTable中的默认headerProvider来实现这一点。我们免费提供了一个功能!让我们编写一个测试来演示它:

@Test
fun `can specify header names when there is no header row`() {
    val headers = listOf("apple", "banana")
    assertEquals(
        listOf(
            mapOf(
                "apple" to "field0",
                "banana" to "field1",
            )
        ),
        readTable(
            listOf("field0,field1"),
            headers::get
        )
    )
}

示例 22.38 [table-reader.27:src/test/java/travelator/tablereader/TableReaderTests.kt] (diff)

看看使用方法引用headers::getList<String>转换为我们的标题提供者函数(Int) -> String是多么容易?这是查看集合的一种有趣方式。我们可以查看:

类型 作为函数类型 通过
List<T> (index: Int) -> T List.get(index)
Set<T> (item: T) -> Boolean Set.contains(item)
Map<K, V> (key: K) -> V? Map.get(key)

如果我们能够将依赖表达为这些函数类型之一,那么我们的客户和我们的测试可以使用标准集合来提供实现。

现在我们已经实现了带标题的表格读取,我们可以尝试运行我们的验收测试。这是:

@Disabled
@Test
fun `acceptance test`() {
    val input = listOf(
        "time,x,y",
        "0.0,  1,  1",
        "0.1,1.1,1.2",
        "0.2,1.2,1.4",
    )
    val expected = listOf(
        Measurement(0.0, 1.0, 1.0),
        Measurement(0.1, 1.1, 1.2),
        Measurement(0.2, 1.2, 1.4)
    )
    assertEquals(
        expected,
        readTable(input).map { record ->
            Measurement(
                t = record["time"]?.toDoubleOrNull() ?: error("in time"),
                x = record["x"]?.toDoubleOrNull() ?: error("in x"),
                y = record["y"]?.toDoubleOrNull() ?: error("in y"),
            )
        }
    )
}

示例 22.39 [table-reader.26:src/test/java/travelator/tablereader/TableReaderAcceptanceTests.kt] (diff)

当我们编写测试时,我们认为我们将调用readTable函数,结果发现实际上调用的是readTableWithHeader,所以我们进行了更改并运行了测试:

assertEquals(
    expected,
    readTableWithHeader(input).map { record ->
        Measurement(
            t = record["time"]?.toDoubleOrNull() ?: error("in time"),
            x = record["x"]?.toDoubleOrNull() ?: error("in x"),
            y = record["y"]?.toDoubleOrNull() ?: error("in y"),
        )
    }

示例 22.40 [table-reader.27:src/test/java/travelator/tablereader/TableReaderAcceptanceTests.kt] (diff)

它通过了,我们迎来了一点多巴胺的满足感,检查了代码并休息一下喝咖啡。

不同的字段分隔符

喝完咖啡回来后,我们快速调查了 Travelator 中读取表格的不同位置。有趣的是,我们只有一个用例读取经典的“逗号”,“分隔”,“变量”(带引号),但有几个需要使用分号作为字段分隔符。看起来,一些法国 SQL Server 导出作业正在使用分号,然后将文件保存为 .CSV 扩展名;也许 C 代表分号?我们将在接下来解决读取这些内容,但尝试找到一个可以处理更复杂引号和转义规则的接口。为了增加灵活性,我们需要识别一个抽象,就像我们之前对 headerProvider 所做的那样。这里的抽象是什么?

查看代码,我们发现标题和正文解析都调用了 splitFields

private fun headerProviderFrom(header: String): (Int) -> String {
    val headers = header.splitFields(",")
    return { index -> headers[index] }
}

private fun parseLine(
    line: String,
    headerProvider: (Int) -> String
): Map<String, String> {
    val values = line.splitFields(",")
    val keys = values.indices.map(headerProvider)
    return keys.zip(values).toMap()
}

private fun String.splitFields(separators: String): List<String> =
    if (isEmpty()) emptyList() else split(separators)

示例 22.41 [table-reader.28:src/main/java/travelator/tablereader/table-reading.kt] (diff)

既不是标题解析也不是正文解析真正想要依赖于分割应该如何发生的细节,因此让我们将其抽象为一个函数 (String) -> List<String>。为什么是这个签名而不只是将字符参数化?

这是一个有趣的问题,谢谢你问。向 parseLineheaderProviderFrom 引入 separators 参数,最终引入他们的调用者 readTablereadTableWithHeader,会是我们可以做的最简单的事情。然而,使用函数类型会给我们带来更多的灵活性,因为我们可以隐藏分割、引用和转义的所有细节在这个签名后面。在 Java 中,使用 lambda 之前,灵活性的好处并不值得引入和实现一个 SAM 接口的成本,至少在我们真正需要所有那些控制权之前是这样。在 Java 中,这种灵活性的好处并不值得引入和实现一个 SAM 接口的成本,至少在我们真正需要所有这些控制权之前是这样。在 Kotlin 中,从一开始就设计了函数类型作为语言的一部分,我们更容易使用它们。一旦我们需要参数化代码的一个方面,自然会问是否函数会比一个简单值提供更多的价值。

让我们从 parseLine 开始。为了提取当前的分割实现,我们可以选择 line.splitFields(",") 并选择“引入函数参数”,选择参数名称 splitter

fun readTable(
    lines: List<String>,
    headerProvider: (Int) -> String = Int::toString
): List<Map<String, String>> =
    lines.map {
        parseLine(it, headerProvider) { line -> ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
            line.splitFields(",")
        }
    }

...

private fun parseLine(
    line: String,
    headerProvider: (Int) -> String,
    splitter: (String) -> List<String>, ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/2.png)
): Map<String, String> {
    val values = splitter(line)
    val keys = values.indices.map(headerProvider)
    return keys.zip(values).toMap()
}

示例 22.42 [table-reader.29:src/main/java/travelator/tablereader/table-reading.kt] (diff)

1

这个 lambda…

2

…实现了分割器。

如果我们继续这个过程,将分隔符 lambda 提取到顶层将会使我们的生活变得更轻松,因此我们选择readTable中的 lambda,命名为splitOnComma并选择“引入变量”:

fun readTable(
    lines: List<String>,
    headerProvider: (Int) -> String = Int::toString
): List<Map<String, String>> =
    lines.map {
        val splitOnComma: (String) -> List<String> = { line ->
            line.splitFields(",")
        }
        parseLine(it, headerProvider, splitOnComma)
    }

示例 22.43 [table-reader.30:src/main/java/travelator/tablereader/table-reading.kt] (diff)

现在我们可以从函数中删除val并移到顶层。在撰写时,感觉应该有自动重构的工具,但目前还没有什么有效的方法:

fun readTable(
    lines: List<String>,
    headerProvider: (Int) -> String = Int::toString
): List<Map<String, String>> =
    lines.map {
        parseLine(it, headerProvider, splitOnComma)
    }

val splitOnComma: (String) -> List<String> = { line ->
    line.splitFields(",")
}

示例 22.44 [table-reader.31:src/main/java/travelator/tablereader/table-reading.kt] (diff)

现在splitOnComma是一个全局属性,我们可以方便地将其作为默认值使用。我们在readTable中选择对它的引用,然后选择“引入参数”,并使用“引入默认值”,命名新参数为splitter。结果如下:

fun readTable(
    lines: List<String>,
    headerProvider: (Int) -> String = Int::toString,
    splitter: (String) -> List<String> = splitOnComma
): List<Map<String, String>> =
    lines.map {
        parseLine(it, headerProvider, splitter)
    }

val splitOnComma: (String) -> List<String> = { line ->
    line.splitFields(",")
}

示例 22.45 [table-reader.32:src/main/java/travelator/tablereader/table-reading.kt] (diff)

由于有了默认值,我们无需更改任何客户端,并且测试继续通过。目前,readTable正在使用提供的splitter,但headerProviderFrom没有:

private fun headerProviderFrom(header: String): (Int) -> String {
    val headers = header.splitFields(",")
    return { index -> headers[index] }
}

示例 22.46 [table-reader.32:src/main/java/travelator/tablereader/table-reading.kt] (diff)

header.splitFields(...)引入一个函数参数产生:

fun readTableWithHeader(
    lines: List<String>
): List<Map<String, String>> =
    when {
        lines.isEmpty() -> emptyList()
        else -> readTable(
            lines.drop(1),
            headerProviderFrom(lines.first()) { header -> ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
                header.splitFields(",")
            }
        )
    }

...

val splitOnComma: (String) -> List<String> = { line ->
    line.splitFields(",")
}

private fun headerProviderFrom(
    header: String,
    splitter: (String) -> List<String> ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/2.png)
): (Int) -> String {
    val headers = splitter(header)
    return { index -> headers[index] }
}

示例 22.47 [table-reader.33:src/main/java/travelator/tablereader/table-reading.kt] (diff)

1

这个 lambda…

2

…实现了分隔符。

现在readTableWithHeader中的 lambda 与splitOnComma的代码相同,因此我们使用它:

fun readTableWithHeader(
    lines: List<String>
): List<Map<String, String>> =
    when {
        lines.isEmpty() -> emptyList()
        else -> readTable(
            lines.drop(1),
            headerProviderFrom(lines.first(), splitOnComma)
        )
    }

...

val splitOnComma: (String) -> List<String> = { line ->
    line.splitFields(",")
}

示例 22.48 [table-reader.34:src/main/java/travelator/tablereader/table-reading.kt] (diff)

你可以看到这里的模式。现在我们将splitOnComma引用提取为一个参数,再次使用默认值以避免破坏现有客户端:

fun readTableWithHeader(
    lines: List<String>,
    splitter: (String) -> List<String> = splitOnComma
): List<Map<String, String>> =
    when {
        lines.isEmpty() -> emptyList()
        else -> readTable(
            lines.drop(1),
            headerProviderFrom(lines.first(), splitter)
        )
    }

示例 22.49 [table-reader.35:src/main/java/travelator/tablereader/table-reading.kt] (diff)

最后,在readTableWithHeader中调用readTable时没有提供splitter,因此它将使用默认的splitOnComma。我们不希望如此,因此我们将参数传递下去。标题和正文应该使用相同的分隔符,所以我们将它从readTableWithHeader传递到内部的readTable

fun readTableWithHeader(
    lines: List<String>,
    splitter: (String) -> List<String> = splitOnComma
): List<Map<String, String>> =
    when {
        lines.isEmpty() -> emptyList()
        else -> readTable(
            lines.drop(1),
            headerProviderFrom(lines.first(), splitter),
            splitter ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
        )
    }

fun readTable(
    lines: List<String>,
    headerProvider: (Int) -> String = Int::toString,
    splitter: (String) -> List<String> = splitOnComma
): List<Map<String, String>> =
    lines.map {
        parseLine(it, headerProvider, splitter)
    }

例子 22.50 [table-reader.36:src/main/java/travelator/tablereader/table-reading.kt] (diff)

1

传递splitter

一些测试驱动开发者可能会坚持先写一个失败的测试来展示最后一步的必要性。我们确实应该编写一个测试来演示使用分隔符的情况,但在此之前,让我们更方便地创建一个。这是splitOnComma

val splitOnComma: (String) -> List<String> = { line ->
    line.splitFields(",")
}

例子 22.51 [table-reader.36:src/main/java/travelator/tablereader/table-reading.kt] (diff)

如果能够不必每次都定义一个 lambda 来创建分隔符,那就太好了。这样,我们的法国客户就可以使用splitter = splitOn(";")来调用readTablesplitOn函数将接收分隔符并返回一个(String) -> List<String>类型的函数值。我们可以尝试从当前的splitOnComma lambda 中提取这个函数,但重构工作很繁琐,所以我们直接定义这个函数并调用它:

fun splitOn(
    separators: String
): (String) -> List<String> = { line: String ->
    line.splitFields(separators)
}

val splitOnComma: (String) -> List<String> = splitOn(",")
val splitOnTab: (String) -> List<String> = splitOn("\t")

例子 22.52 [table-reader.37:src/main/java/travelator/tablereader/table-reading.kt] (diff)

我们还定义了一个splitOnTab,这样我们就可以在我们承诺要写的新测试中使用它:

@Test
fun `can specify splitter`() {
    assertEquals(
        listOf(
            mapOf(
                "header1" to "field0",
                "header2" to "field1",
            )
        ),
        readTableWithHeader(
            listOf(
                "header1\theader2",
                "field0\tfield1"
            ),
            splitOnTab
        )
    )
}

例子 22.53 [table-reader.38:src/test/java/travelator/tablereader/TableReaderTests.kt] (diff)

这一切顺利进行,给了我们安心和文档支持。让我们将其检入并在休息几分钟后回来看看情况。

序列

现在我们已经拥有了一个基本的表格解析器,而且除了标准 Kotlin 运行时中的类型之外,我们没有引入任何新类型。这在更功能化的方法中经常发生。Kotlin 的特点是利用标准库提供的丰富抽象,而 Java 程序更有可能定义新类型。正如我们在第六章和第十五章中看到的那样,这种差异的原因之一是 Kotlin 允许我们将集合视为值,这使它们比 Java 的可变对象更安全地可组合。我们能够定义一个接受和返回集合类型的 API,而不必担心别名问题。

值类型可能导致由可预测计算组成的 API,但它们可能会带来自己的问题。我们的天真 API 遇到了与我们在第二十章中看到的相同问题:它在加载到内存中的List<String>上运行,并且生成一个同样在内存中的List<Map<String, String>>。即使不考虑数据结构的成本,readTable的内存占用量是输入字节的两倍,这可能是 UTF-8 编码文件包含的数据的两倍大小。为了处理大文件,最好是按照序列而不是列表的方式工作,因为如果需要,序列可以在管道的每个阶段仅保留一个项目在内存中。

正如我们在第十三章中所看到的,我们可以非常容易地将Sequence转换为List,然后再转回(有些限制),因此我们可以通过委托给现有的List API 来实现Sequence函数。然而,这并不会减少我们的内存占用,所以我们将编写Sequence版本,并将List版本委托给它们。如果我们聪明的话,我们可以通过便捷的List API 进行测试,从而一举获得两套测试。

目前,readTable看起来像这样:

fun readTable(
    lines: List<String>,
    headerProvider: (Int) -> String = Int::toString,
    splitter: (String) -> List<String> = splitOnComma
): List<Map<String, String>> =
    lines.map {
        parseLine(it, headerProvider, splitter)
    }

示例 22.54 [table-reader.39:src/main/java/travelator/tablereader/table-reading.kt] (diff)

我们可以通过在管道的中间转换为和从Sequence尝试我们的计划:

fun readTable(
    lines: List<String>,
    headerProvider: (Int) -> String = Int::toString,
    splitter: (String) -> List<String> = splitOnComma
): List<Map<String, String>> =
    lines
        .asSequence()
        .map {
            parseLine(it, headerProvider, splitter)
        }
        .toList()

示例 22.55 [table-reader.40:src/main/java/travelator/tablereader/table-reading.kt] (diff)

这些测试都通过了,它们都通过这个函数汇聚,所以这很令人放心。现在我们可以将内部工作提取出来,以一个接受并返回Sequence的函数;这是根据“提取管道的一部分”的描述提取的一部分:

fun readTable(
    lines: List<String>,
    headerProvider: (Int) -> String = Int::toString,
    splitter: (String) -> List<String> = splitOnComma
): List<Map<String, String>> =
    readTable(
        lines.asSequence(),
        headerProvider,
        splitter
    ).toList()

fun readTable(
    lines: Sequence<String>,
    headerProvider: (Int) -> String = Int::toString,
    splitter: (String) -> List<String> = splitOnComma
) = lines.map {
        parseLine(it, headerProvider, splitter)
    }

示例 22.56 [table-reader.41:src/main/java/travelator/tablereader/table-reading.kt] (diff)

这给我们提供了一个Sequence版本的readTableList版本调用它,并且List版本已经经过良好测试。现在轮到外部的readTableWithHeader。它看起来像这样:

fun readTableWithHeader(
    lines: List<String>,
    splitter: (String) -> List<String> = splitOnComma
): List<Map<String, String>> =
    when {
        lines.isEmpty() -> emptyList()
        else -> readTable(
            lines.drop(1),
            headerProviderFrom(lines.first(), splitter),
            splitter
        )
    }

示例 22.57 [table-reader.42:src/main/java/travelator/tablereader/table-reading.kt] (diff)

目前,readTableWithHeader正在委托给List版本的readTable。如果我们想生成一个Sequence版本(确实是这样),它应该调用readTableSequence版本,因此我们在这里内联调用以得到:

fun readTableWithHeader(
    lines: List<String>,
    splitter: (String) -> List<String> = splitOnComma
): List<Map<String, String>> =
    when {
        lines.isEmpty() -> emptyList()
        else -> readTable(
            lines.drop(1).asSequence(),
            headerProviderFrom(lines.first(), splitter),
            splitter
        ).toList()
    }

示例 22.58 [table-reader.43:src/main/java/travelator/tablereader/table-reading.kt] (diff)

现在,手动创建一个linesAsSequence作为变量,并将其用作lines的替代。这几乎可以工作:

fun readTableWithHeader(
    lines: List<String>,
    splitter: (String) -> List<String> = splitOnComma
): List<Map<String, String>> {
    val linesAsSequence = lines.asSequence()
    return when {
        linesAsSequence.isEmpty() -> emptySequence() ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/j2kt/img/1.png)
        else -> {
            readTable(
                linesAsSequence.drop(1),
                headerProviderFrom(linesAsSequence.first(), splitter),
                splitter
            )
        }
    }.toList()
}

示例 22.59 [table-reader.44:src/main/java/travelator/tablereader/table-reading.kt] (diff)

1

编译失败是因为没有Sequence<T>.isEmpty()

我们如何判断Sequence是否为空?linesAsSequence.firstOrNull() == null就可以:

fun readTableWithHeader(
    lines: List<String>,
    splitter: (String) -> List<String> = splitOnComma
): List<Map<String, String>> {
    val linesAsSequence = lines.asSequence()
    return when {
        linesAsSequence.firstOrNull() == null -> emptySequence()
        else -> {
            readTable(
                linesAsSequence.drop(1),
                headerProviderFrom(linesAsSequence.first(), splitter),
                splitter
            )
        }
    }.toList()
}

示例 22.60 [table-reader.45:src/main/java/travelator/tablereader/table-reading.kt] (diff)

这通过了测试,因此我们可以再次提取在return.toList()之间的表达式作为我们正在寻找的函数。提取并整理后,我们有了readTableWithHeaderSequence版本:

fun readTableWithHeader(
    lines: List<String>,
    splitter: (String) -> List<String> = splitOnComma
): List<Map<String, String>> =
    readTableWithHeader(
        lines.asSequence(),
        splitter
    ).toList()

fun readTableWithHeader(
    lines: Sequence<String>,
    splitter: (String) -> List<String> = splitOnComma
) = when {
    lines.firstOrNull() == null -> emptySequence()
    else -> {
        readTable(
            lines.drop(1),
            headerProviderFrom(lines.first(), splitter),
            splitter
        )
    }
}

示例 22.61 [table-reader.46:src/main/java/travelator/tablereader/table-reading.kt] (diff)

在这一点上,我们有readTablereadTableWithHeader的两个版本:每个都有一个List版本和一个Sequence版本。考虑到将List参数转换为Sequence,将Sequence结果转换为List是多么容易,也许List变体并没有起到多大作用?让我们把它们的定义移到测试中,因为我们没有任何生产用途。这样,测试可以使用它们保持简单,而生产代码则保持最小化。

因此,这是我们表解析器的整个公共接口:

fun readTableWithHeader(
    lines: Sequence<String>,
    splitter: (String) -> List<String> = splitOnComma
): Sequence<Map<String, String>> =
    when {
        lines.firstOrNull() == null -> emptySequence()
        else -> readTable(
            lines.drop(1),
            headerProviderFrom(lines.first(), splitter),
            splitter
        )
    }

fun readTable(
    lines: Sequence<String>,
    headerProvider: (Int) -> String = Int::toString,
    splitter: (String) -> List<String> = splitOnComma
): Sequence<Map<String, String>> =
    lines.map {
        parseLine(it, headerProvider, splitter)
    }

val splitOnComma: (String) -> List<String> = splitOn(",")
val splitOnTab: (String) -> List<String> = splitOn("\t")

fun splitOn(
    separators: String
) = { line: String ->
    line.splitFields(separators)
}

示例 22.62 [table-reader.47:src/main/java/travelator/tablereader/table-reading.kt] (diff)

这由三个实用函数支持:

private fun headerProviderFrom(
    header: String,
    splitter: (String) -> List<String>
): (Int) -> String {
    val headers = splitter(header)
    return { index -> headers[index] }
}

private fun parseLine(
    line: String,
    headerProvider: (Int) -> String,
    splitter: (String) -> List<String>,
): Map<String, String> {
    val values = splitter(line)
    val keys = values.indices.map(headerProvider)
    return keys.zip(values).toMap()
}

// Necessary because String.split returns a list of an empty string
// when called on an empty string.
private fun String.splitFields(separators: String): List<String> =
    if (isEmpty()) emptyList() else split(separators)

示例 22.63 [table-reader.47:src/main/java/travelator/tablereader/table-reading.kt] (diff)

当我们回顾代码时,我们意识到不清楚为什么我们需要splitFields,因此我们添加了注释。回顾时,我们通常更容易理解我们要返回的代码,而不是我们刚刚写的代码。除此之外,我们认为代码已经相当自解释了。有时我们对此会有误解。如果我们读这段代码时需要更多时间才能理解正在发生的事情,我们将利用这个机会添加更多注释,或者更好地重构以提升表达力。

从文件读取

这在抽象中似乎是一个很好的接口,但是当我们第一次愤怒地使用它时,我们遇到了一个障碍。让我们通过一个测试来说明问题。这调用了readTableWithHeaderSequence版本:

@Test
fun `read from reader`() {
    val fileContents = """
        H0,H1
        row0field0,row0field1
        row1field0,row1field1
    """.trimIndent()
    StringReader(fileContents).useLines { lines ->
        val result = readTableWithHeader(lines).toList()
        assertEquals(
            listOf(
                mapOf("H0" to "row0field0", "H1" to "row0field1"),
                mapOf("H0" to "row1field0", "H1" to "row1field1")
            ),
            result
        )
    }
}

示例 22.64 [table-reader.48:src/test/java/travelator/tablereader/TableReaderTests.kt] (差异)

你能看出为什么会失败吗?如果我们说它失败了,会出现java.lang.IllegalState​Ex⁠ception: This sequence can be consumed only once.吗?

是的,再次(“多次迭代”),Sequence让我们感到困扰,因为我们没有测试两种类型——可以和不可以两次消耗的输入:

fun readTableWithHeader(
    lines: Sequence<String>,
    splitter: (String) -> List<String> = splitOnComma
): Sequence<Map<String, String>> =
    when {
        lines.firstOrNull() == null -> emptySequence()
        else -> readTable(
            lines.drop(1),
            headerProviderFrom(lines.first(), splitter),
            splitter
        )
    }

示例 22.65 [table-reader.47:src/main/java/travelator/tablereader/table-reading.kt] (差异)

因此,lines.firstOrNull()消耗了这个序列,当从Reader中读取时,我们不能简单地返回并重新开始以评估lines.drop(1)lines.first()。我们所有的单元测试都是从所有文件行的List开始的;这些序列可以被再次消耗,因为它们保存在内存中。

要在文件中使用我们的Sequence接口,我们要么必须将所有数据加载到内存中,要么找到一种方法来获取Sequence的第一个和其余部分,而不必尝试两次读取它。鉴于我们专门引入了Sequence以避免一次性加载所有数据到内存中,我们选择了后者。那么我们所需要做的就是在不消耗Sequence的情况下检查它是否有任何项。你能看到吗?

啊,那是一个诡计问题。要检查,我们必须Sequence上调用iterator(),这正是消耗它的东西。我们无法看到Sequence是否为空,然后稍后再次使用它。但有时在逻辑上,当我们无法单独执行我们想要的事情时,我们可以一起执行我们想要的事情和另一件事情。在这种情况下,我们不仅想要查看Sequence是否为空;我们还想将其拆分为其头部和尾部(如果它不为空)。我们可以通过使用这样一个函数来解构Sequence来实现更广泛的目标:

fun <T> Sequence<T>.destruct()
    : Pair<T, Sequence<T>>? {
    val iterator = this.iterator()
    return when {
        iterator.hasNext() ->
            iterator.next() to iterator.asSequence()
        else -> null
    }
}

示例 22.66 [table-reader.49:src/main/java/travelator/tablereader/table-reading.kt] (差异)

如果Sequence为空,则此destruct返回null;否则,它返回头部和尾部的Pair(其中尾部可能是一个空的Sequence)。它消耗了原始(通过调用iterator()),但提供了一个新的Sequence来继续处理。我们可以使用它来重构当前的readTableWithHeader

fun readTableWithHeader(
    lines: Sequence<String>,
    splitter: (String) -> List<String> = splitOnComma
): Sequence<Map<String, String>> =
    when {
        lines.firstOrNull() == null -> emptySequence()
        else -> readTable(
            lines.drop(1),
            headerProviderFrom(lines.first(), splitter),
            splitter
        )
    }

示例 22.67 [table-reader.48:src/main/java/travelator/tablereader/table-reading.kt] (差异)

这绝对不是一个微不足道的重新排列,但我们可以将其转化为:

fun readTableWithHeader(
    lines: Sequence<String>,
    splitter: (String) -> List<String> = splitOnComma
): Sequence<Map<String, String>> {
    val firstAndRest = lines.destruct()
    return when {
        firstAndRest == null -> emptySequence()
        else -> readTable(
            firstAndRest.second,
            headerProviderFrom(firstAndRest.first, splitter),
            splitter
        )
    }
}

示例 22.68 [table-reader.49:src/main/java/travelator/tablereader/table-reading.kt] (差异)

新形式通过所有测试,因为它不会多次消耗lines。如果感觉有点笨拙,我们可以结合?.let、解构和 Elvis 运算符,给出一个你可能接受的单一表达式。结果是这个公共 API:

fun readTableWithHeader(
    lines: Sequence<String>,
    splitter: (String) -> List<String> = splitOnComma
): Sequence<Map<String, String>> =
    lines.destruct()?.let { (first, rest) ->
        readTable(
            rest,
            headerProviderFrom(first, splitter),
            splitter
        )
    } ?: emptySequence()

fun readTable(
    lines: Sequence<String>,
    headerProvider: (Int) -> String = Int::toString,
    splitter: (String) -> List<String> = splitOnComma
): Sequence<Map<String, String>> =
    lines.map {
        parseLine(it, headerProvider, splitter)
    }

val splitOnComma: (String) -> List<String> = splitOn(",")
val splitOnTab: (String) -> List<String> = splitOn("\t")

fun splitOn(
    separators: String
) = { line: String ->
    line.splitFields(separators)
}

示例 22.69 [table-reader.50:src/main/java/travelator/tablereader/table-reading.kt] (差异)

我们几乎完成了,我们保证。

现在 API 已经围绕两个函数结晶化,最后一步是利用这个机会使测试更具表现力:

class TableReaderTests {
    @Test
    fun `empty input returns empty`() {
        checkReadTable(
            lines = emptyList(),
            shouldReturn = emptyList()
        )
    }

    @Test
    fun `one line of input with default field names`() {
        checkReadTable(
            lines = listOf("field0,field1"),
            shouldReturn = listOf(
                mapOf("0" to "field0", "1" to "field1")
            )
        )
    }

    ...
    @Test
    fun `can specify header names when there is no header row`() {
        val headers = listOf("apple", "banana")
        checkReadTable(
            lines = listOf("field0,field1"),
            withHeaderProvider = headers::get,
            shouldReturn = listOf(
                mapOf(
                    "apple" to "field0",
                    "banana" to "field1",
                )
            )
        )
    }

    @Test
    fun `readTableWithHeader takes headers from header line`() {
        checkReadTableWithHeader(
            lines = listOf(
                "H0,H1",
                "field0,field1"
            ),
            shouldReturn = listOf(
                mapOf("H0" to "field0", "H1" to "field1")
            )
        )
    }

    ...
}

private fun checkReadTable(
    lines: List<String>,
    withHeaderProvider: (Int) -> String = Int::toString,
    shouldReturn: List<Map<String, String>>,
) {
    assertEquals(
        shouldReturn,
        readTable(
            lines.asSequence().constrainOnce(),
            headerProvider = withHeaderProvider,
            splitter = splitOnComma
        ).toList()
    )
}

private fun checkReadTableWithHeader(
    lines: List<String>,
    withSplitter: (String) -> List<String> = splitOnComma,
    shouldReturn: List<Map<String, String>>,
) {
    assertEquals(
        shouldReturn,
        readTableWithHeader(
            lines.asSequence().constrainOnce(),
            splitter = withSplitter
        ).toList()
    )
}

示例 22.70 [table-reader.52:src/test/java/travelator/tablereader/TableReaderTests.kt] (差异)

这是一个重要的步骤。正如我们在第十七章中所看到的,找到我们测试中的模式,并在函数中表达它们(比如checkReadTable),既帮助测试的读者看到代码在做什么,也可以帮助我们找到测试覆盖中的空白。例如,当字段多于标题或反之时,我们的解析器的行为是什么?我们为了在测试驱动实现时获得快速反馈而编写的测试,如果返回实现并对其进行修改,可能不太可能有效地用于 API 沟通、问题发现或捕捉回归。如果我们将 TDD 作为设计技术使用,我们不能忘记确保最终的测试适合确定正确性、添加文档和防止回归。

与 Commons CSV 的比较

我们在本章开始时说,在大多数实际情况下,我们会选择 Apache Commons CSV 而不是自己编写解析器。在我们完成本章之前,让我们将我们的 API 与 Commons 的等效 API 进行比较。

表解析器最常见的用例是读取具有已知列的文件,并将每行转换为某些数据类。以下是我们如何使用我们的解析器来实现这一点:

@Test
fun example() {
    reader.useLines { lines ->
        val measurements: Sequence<Measurement> =
            readTableWithHeader(lines, splitOnComma)
                .map { record ->
                    Measurement(
                        t = record["time"]?.toDoubleOrNull()
                            ?: error("in time"),
                        x = record["x"]?.toDoubleOrNull()
                            ?: error("in x"),
                        y = record["y"]?.toDoubleOrNull()
                            ?: error("in y"),
                    )
                }
        assertEquals(
            expected,
            measurements.toList()
        )
    }
}

示例 22.71 [table-reader.53:src/test/java/travelator/tablereader/CsvExampleTests.kt] (差异)

真实世界的代码可能需要更多的错误处理(我们可以在第二十一章中看到如何处理),但这展示了基本的用例。我们使用 Kotlin 的 Reader.useLines 扩展函数生成一个 Sequence<String>,然后我们的解析器将其转换为 Sequence<Map<String, String>>。我们可以对这些 Map 进行 map 操作,按字段名索引提取我们需要的数据并将其转换为我们实际想要的类型(Measurement)。这个设计并非偶然——这是我们在最开始做出的决策,尽管当时使用的是 List 而不是 Sequence

下面是 Commons CSV 版本的代码:

@Test
fun `commons csv`() {
    reader.use { reader ->
        val parser = CSVParser.parse(
            reader,
            CSVFormat.DEFAULT.withFirstRecordAsHeader()
        )
        val measurements: Sequence<Measurement> = parser
            .asSequence()
            .map { record ->
                Measurement(
                    t = record["time"]?.toDoubleOrNull()
                        ?: error("in time"),
                    x = record["x"]?.toDoubleOrNull()
                        ?: error("in x"),
                    y = record["y"]?.toDoubleOrNull()
                        ?: error("in y"),
                )
            }
        assertEquals(
            expected,
            measurements.toList()
        )
    }
}

示例 22.72 [table-reader.53:src/test/java/travelator/tablereader/CsvExampleTests.kt] (diff)

它也有一个静态函数入口点,CSVParser.parse,它还接受关于表格格式的配置(在本例中为 CSVFormat.DEFAULT.withFirstRecordAsHeader();在我们的例子中为 splitOnComma)。我们有两个函数来区分是否有标题的文件;Apache API 将这些函数合并到 CSVFormat 中。

Commons 的 parse 接受一个 Reader,而不是我们的 Sequence<String>。这使得它能够处理除换行符外的记录分隔符,并处理字段中间有换行符的情况,但会导致 parse 方法的泛滥。有多个变体接受 PathFileInputStreamStringURL。开发者可能认为这些变体是必要的,因为 Java 对于这些类型的源和安全处理它们的支持很少。由 parse 静态方法返回的 CSVParser 具有大量代码来管理资源。我们的 API 将这些委托给 Sequence 的工作以及 Kotlin 生命周期函数如 useuseLines

在处理行的问题上,你必须在代码示例中读懂它们之间的联系,但 CSVParser 实现了 Iterable<CSVRecord>。这是一个巧妙的设计选择,因为它允许 Java 开发者使用 for 循环遍历记录,并允许 Kotlin 开发者使用 .asSequence 将其转换为 Sequence。事实上,Kotlin 的易用性归功于 Kotlin 标准库的设计,该标准库建立在与 Apache 开发者同样利用的 Iterable 抽象之上。

接下来,两个示例中创建单个 Measurement 的代码看起来完全相同:

.map { record ->
    Measurement(
        t = record["time"]?.toDoubleOrNull()
            ?: error("in time"),
        x = record["x"]?.toDoubleOrNull()
            ?: error("in x"),
        y = record["y"]?.toDoubleOrNull()
            ?: error("in y"),
    )
}

示例 22.73 [table-reader.53:src/test/java/travelator/tablereader/CsvExampleTests.kt] (diff)

尽管我们的解析器中 record 的类型是 Map<String, String>,但在 Commons 情况下是 CSVRecordCSVRecord 有一个 get(String) 方法,这是 record["time"] 等的解决方法。它还有方法:get(int) 通过索引检索字段,我们可以使用 Map.values.get(Int)size() 而不是 Map.size();以及 isSet(String) 代替 Map.hasKey(String)

基本上,CSVRecord 手工复制了 Map 接口,而不仅仅是成为一个 Map。为什么呢?因为正如我们在 第六章 讨论的那样,Java Map 接口是可变的,在从文件中读取字段的上下文中,变异毫无意义;变异显然不会写回到源中。在 Java 编程中,我们发现自己不得不创建新类型来解决问题,而在 Kotlin 中,我们可以用标准类型表达自己,然后享受 Kotlin API 的丰富性。

Commons CSV 库 Excels™️ 的一个优点是它提供了现成的解析器默认值。这些值在 CSVFormat 类中表示为常量。我们已经见过 CSVFormat.DEFAULT,但还有许多其他值,比如 CSVFormat.EXCEL。有了 CSVFormat,你可以像我们之前看到的那样将其传递给 CSVParser.parse 方法,或者直接使用它,例如 CSVFormat.EXCEL.parse(reader)。我们能否在不在 API 中定义新类型的情况下提供此功能?比如,使用 splitOnComma 就好像它是我们的配置:

@Test
fun `configuration example`() {
    reader.use { reader ->
        val measurements = splitOnComma.readTableWithHeader(reader)
            .map { record ->
                Measurement(
                    t = record["time"]?.toDoubleOrNull()
                        ?: error("in time"),
                    x = record["x"]?.toDoubleOrNull()
                        ?: error("in x"),
                    y = record["y"]?.toDoubleOrNull()
                        ?: error("in y"),
                )
            }
        assertEquals(
            expected,
            measurements.toList()
        )
    }
}

示例 22.74 [table-reader.54:src/test/java/travelator/tablereader/CsvExampleTests.kt] (差异)

我们可以通过将 splitOnComma.readTableWithHeader(reader) 定义为函数类型的扩展函数来实现这一点:

fun ((String) -> List<String>).readTableWithHeader(
    reader: StringReader
): Sequence<Map<String, String>> =
    readTableWithHeader(reader.buffered().lineSequence(), this)

示例 22.75 [table-reader.54:src/main/java/travelator/tablereader/table-reading.kt] (差异)

实际上,CSVFormat 表示了一整套策略,用于转义规则、空白行处理等,而不仅仅是如何分割一行。当我们的解析器增加了这些功能时,可能希望创建一个数据类来收集它们。在此之前,我们一直能够使用内置类型和 Kotlin 语言特性来取得进展。

Commons 接口提供了另一个有用的功能,而我们的接口却没有,这最终需要我们创建一个类型来实现。Commons CSV 有 CSVParser.getHeaderNames 来提供对头信息的访问。我们能否在不修改当前 API 或至少不需要更改客户端代码的情况下添加此功能?

对于许多输入,我们可以简单地在输出 Sequence 的第一个上调用 Map.keys,但如果表没有数据行,只有标题,这种方法就行不通了。要返回标题信息和解析记录,我们可以返回一个 Pair<List<String>, Sequence<Map<String, String>>,但这将强制我们当前的客户端丢弃一对中的第一个。相反,我们可以返回一个实现 Sequence<Map<String, String>> 且具有标题属性的 Table 类型。这样一来,我们所有现有的调用者都保持不变,但我们可以在需要时访问 headers

@Test
fun `Table contains headers`() {
    val result: Table = readTableWithHeader(
        listOf(
            "H0,H1",
            "field0,field1"
        ).asSequence()
    )
    assertEquals(
        listOf("H0", "H1"),
        result.headers
    )
}

@Test
fun `Table contains empty headers for empty input`() {
    assertEquals(
        emptyList<String>(),
        readTableWithHeader(emptySequence()).headers
    )
}

示例 22.76 [table-reader.55:src/test/java/travelator/tablereader/TableReaderTests.kt] (差异)

我们会略过重构步骤,但这里是实现代码:

class Table(
    val headers: List<String>,
    val records: Sequence<Map<String, String>>
) : Sequence<Map<String, String>> by records

fun readTableWithHeader(
    lines: Sequence<String>,
    splitter: (String) -> List<String> = splitOnComma
): Table =
    lines.destruct()?.let { (first, rest) ->
        tableOf(splitter, first, rest)
    } ?: Table(emptyList(), emptySequence())

private fun tableOf(
    splitter: (String) -> List<String>,
    first: String,
    rest: Sequence<String>
): Table {
    val headers = splitter(first)
    val sequence = readTable(
        lines = rest,
        headerProvider = headers::get,
        splitter = splitter
    )
    return Table(headers, sequence)
}

示例 22.77 [table-reader.55:src/main/java/travelator/tablereader/table-reading.kt] (差异)

移动

在我们旅程的最后阶段,我们豁免了从头开始编写 Kotlin 而不是重构我们现有的 Java 的奢侈。即便如此,我们还是从测试开始,然后将测试数据复制到我们的实现中,并从那里进行重构。我们不能以这种方式编写所有的代码,但当我们的代码只是计算时,这确实效果很好,而且代码中计算的部分越多,我们的代码工作得就越好。

我们看到了在 第十五章,封装集合到类型别名 和 第十六章,接口到函数 中重复使用内置类型的强大,以及在 第十章,函数到扩展函数 中定义 API 作为扩展函数。在这个例子中,集合和函数类型很好地结合在一起,我们甚至设法在函数类型上定义了一个扩展函数!在 Java 中,如果我们需要定义新的类来封装可变集合,并且编写操作这些集合的方法,那么我们将 Kotlin 的不可变集合传递给我们的函数,并在这些集合类型上编写应用程序特定的扩展。如果我们需要在 Java 中定义接口,我们可以使用 Kotlin 的函数类型。

再次强调,并非所有问题都可以或应该这样解决,但作者们发现,虽然让 Java 朝这个方向弯曲是困难的,但 Kotlin 的特性结合起来积极鼓励这种风格。我们不应该在不定义新类型上纠缠不放,但也不应该一下子用一个新类解决每一个问题。

第二十三章:继续旅程

我们已经到达了本书的结尾。感谢您加入我们的旅程。您的作者很荣幸能与许多优秀的开发者共事,并从他们学到了很多,现在您也加入了这个名单。即使您跳过了几章,或者在某次重构中不知不觉地走神,能与您交流还是非常愉快的。我们不能再一起改进 Travelator 了,但我们从旅行中学到了什么呢?

当 O’Reilly 问我们是否想要写一本关于 Kotlin 的书时,我们不得不思考我们想要写什么,以及足够多的人可能想要阅读什么。我们知道我们在采用这门语言的旅程上,并且在目的地感到舒适,但我们也知道我们的起点并不是典型的 Java 开发者的起点。我们看到大多数现有的书籍教 Kotlin 就好像它只是 Java 的另一种语法,可以在减少打字的同时实现更多,但并不需要改变方法。但这并不是我们的经验;我们发现 Kotlin 的最佳实践需要比 Java 更多的函数式思维。然而,关于 Kotlin 中的函数式编程的书籍似乎要求读者放下他们对对象编程的所有了解,并加入一个新的信仰。我们对此也感到不舒服。类和对象是表达行为的一种人道主义方式,特别是与许多函数式习语相比。既然有足够的空间,为什么要从我们的工具箱中移除工具呢?我们不能只是拥有更多的工具,并为工作选择合适的工具吗?

粒子

这种思维方式使得 Nat 想出了一个比喻:编程语言有一个粒子,影响我们在其中编写程序的设计。这种粒子使得某些设计风格容易应用,而使其他设计艰难或有风险。

Kotlin 的粒子与 Java 不同。Java 的粒子偏爱可变对象和反射,而以组合性和类型安全为代价。与 Java 相比,Kotlin 更倾向于转换不可变值和独立函数,并拥有一个不显眼但有帮助的类型系统。虽然可以使用 IntelliJ 轻松将 Java 转换为 Kotlin,但如果我们也改变了思维方式,我们可以利用这门新语言所能提供的一切,而不是简单地在 Kotlin 语法中使用 Java。

Java 和 Kotlin 可以在同一代码库中共存,它们之间的交互界限几乎是无缝的,但是当您从 Kotlin 的严格类型世界传递信息到 Java 的松散类型世界时,会存在一些风险。通过小心谨慎,我们发现可以使用自动重构工具在小而安全的步骤中将习惯用法的 Java 代码转换为习惯用法的 Kotlin 代码,必要时可以通过编辑文本来修改。在必须维护 Java 代码的同时,我们还可以同时支持两种语言的约定。

函数式思维

正如我们在一些历史课上看到的那样,Java 的基因形成于 20 世纪 90 年代,当时我们相信面向对象编程是神奇的银弹。当面向对象并未能解决所有问题时,主流编程语言,甚至是 Java 本身,开始采纳函数式编程的思想。Kotlin 就诞生于这个时代的 Java,并且,就像我们的孩子比我们更适应未来一样,Kotlin 比 Java 更适合现代编程。

我们说的函数式思维是什么意思?

我们的软件终究受限于我们理解它的能力。我们的理解能力受到我们创建的软件复杂性的限制,其中许多复杂性源于对何时发生事情的困惑。函数式程序员学会了简化这种复杂性的最简单方法就是减少事情发生的次数。他们称发生事情为效应:在某些范围内可观察到的变化。

在函数内部改变变量或集合是一种影响,但除非该变量在函数外共享,否则不会影响其他任何代码。当影响的范围局限于函数内部时,我们在推理系统行为时可以忽略它。一旦我们改变了共享状态(例如函数的参数、全局变量、或者文件或网络套接字),我们的局部影响就成为任何能看到共享对象的范围内的影响,这迅速增加了复杂性并使理解变得更加困难。

函数不实际改变共享状态是不够的。如果有可能函数可能改变共享状态,我们必须检查函数的来源,并递归地检查每个调用的函数,以理解我们的系统行为。每一片全局可变状态都使每个函数都成为嫌疑对象。同样地,如果我们在一个每个函数都可以写入数据库的环境中编程,我们将失去预测此类写入何时发生以及相应规划的能力。

函数式程序员通过减少变异来驯服复杂性。有时,他们会使用强制控制变异的语言(比如 Clojure 和 Haskell)。否则,他们依靠约定来工作。如果我们在更通用的语言中采纳这些约定,我们就能更好地推理我们的代码。Kotlin 选择不强制控制效果,但语言及其运行时提供了一些内置约定来引导我们朝正确的方向前进。例如,与 Java 相比,我们有一个不可变的val声明,而不是可选的final修饰符,集合的只读视图,以及简洁的数据类来鼓励写时复制而不是变异。本书的许多章节描述了更微妙的约定,目的都是相同的:第五章,从 Bean 到 Value,第六章,Java 到 Kotlin 集合,第七章,从操作到计算,第十四章,累积对象到转换,以及第二十章,执行 I/O 到传递数据

函数式编程远不止于简单地避免突变共享状态。但是,如果我们专注于解决问题而不突变(或者突变是目的,我们最小化其范围),我们的系统变得更容易理解和改变。就像“不要重复自己”(又称“一次且仅一次”),坚持应用一个简单的规则会产生深远的影响。不要突变共享状态和“一次且仅一次”有一个共同的特性——如果我们不小心,应用这些规则可能会比减少复杂性更快增加复杂性。我们需要学习技术,使我们能够管理突变(并消除重复、便于测试等),而不使我们的代码变得更难理解,并且在看到这些技术时能够识别它们。这些技术在不同的语言、环境和领域可能有所不同,是我们职业的工艺。

如果你研究函数式技术,你会遇到很多反对面向对象的情绪。这似乎根植于一种看法,即面向对象编程完全是关于可变对象,但我们不应该把消息传递的婴儿与可变的洗澡水一起倒掉。尽管我们可以使用面向对象编程来管理共享的可变状态,但实际上,现如今我们通常使用对象来封装不可变状态,或表示服务及其依赖关系。我们在第十六章,接口与函数中看到,我们可以使用闭包函数和具有属性的类来封装数据。两者都可以隐藏代码细节,并允许客户端与不同的实现交互。我们需要这些转折点来构建灵活、稳健和可测试的系统。在 Java 中,我们传统上使用子类化作为工具,而 Kotlin 则通过其默认封闭的类,鼓励一种更具组合性的风格。我们不再覆盖受保护的方法,而是使用表示策略或协作者的函数类型属性。我们应该倾向于这种风格,但在简化我们的实现时,不必尴尬地定义类和子类层次。同样,在第十章,函数到扩展函数中,扩展函数非常好,它们可以在我们的代码库中减少不同关注点之间的耦合,但当我们需要多态方法时,它们不能替代多态方法。

最终,编程的一个吸引人的地方是它的数学与人文的结合。对象和类,对你们的作者来说,至少是一种更具人性化的建模世界的方式,这往往是一个很好的起点。当我们需要严谨性(这通常是需要的,但不像普通人认为的那样频繁)时,函数式编程就在那里为我们服务。当我们可以有两个帐篷并在两个帐篷之间移动时,我们没有理由必须选择一个阵营,而 Kotlin 让我们比我们找到的任何其他语言做得更好。

简单设计

如果复杂性是我们软件的限制因素,而函数式思维是减少复杂性的工具,那么它如何与其他格言相配合——特别是 Kent Beck 的简单设计规则(极限编程解释:拥抱变化)?这两条规则已经为我们服务了二十年,指出一个简单的设计:

  • 通过测试

  • 揭示意图

  • 没有重复

  • 元素最少

在这些规则中,“揭示意图”是最开放于解释的,因此让我们来探讨这个问题。

意图是“一个目标或计划”:它意味着变化。它意味着行动。通过区分我们代码中的行为和计算,我们展示了我们期望发生的事情和不期望发生的事情:哪些事情可能会受到其他事情的影响,哪些事情不会。当我们的大部分代码都是计算形式时,我们可以明确指出哪些函数是行为,进而更好地展示我们的意图。

正如我们在第七章,从动作到计算,和第二十章,执行 I/O 到传递数据中所看到的,我们将解开计算与动作的纠缠的主要技术是将动作移到我们交互的入口点,以使其污染最少的代码。这既不容易也不是灵丹妙药,但我们发现这确实能够产生更简单的设计和更少复杂的代码。

函数式编程和文本推理

当我们完成这本书时,我们惊讶地发现我们没有包含任何软件设计图示。

坦率地说,部分原因是出于懒惰。光是在通过重构时管理示例代码的多个版本就已经够难的了,更别提还要担心其他视图了。但我们也养成了一种习惯,尽可能地仅使用我们手头的编程语言来表达自己。如果我们在原始文本中能够达到足够的理解,那么在日常工作中,我们就不会被迫切换上下文去查看可能与代码不同步的图示。

当我们谈到面向对象设计时,我们依赖于图示来展示软件的动态结构和行为,以及源代码变动如何影响其动态行为。在面向对象软件中,这种动态结构——对象之间的图形及其消息传递方式——大部分是隐含的。这使得很难将源代码中看到的内容与运行时的实际发生联系起来,因此可视化是面向对象编程的重要组成部分。在 20 世纪 80 年代和 90 年代,软件设计界的权威们创造了各种图示符号来可视化面向对象软件。到了 1990 年代中期,最流行的符号设计师格雷迪·布奇、伊瓦尔·雅各布森和詹姆斯·兰博合并了他们的努力,创造了统一建模语言(UML)。

函数式编程社区并不像面向对象设计那样专注于图示和可视化。函数式编程的目标是代数推理:通过操作其文本表达式来推理程序的行为。引用透明度和静态类型允许我们仅通过使用源代码的语法来推理我们的程序。这导致我们的代码越来越函数式时,我们能够更加理解我们系统的行为,而无需深思疑难于源代码中并不立即显现但必须通过可视化理解的机制。

重构

除了务实的函数式编程之外,重构是本书的另一个关键原则。重构在我们的专业生活中扮演着重要角色,因为如果我们对系统的最终形式知之甚少,无法一次就将其设计正确,那么我们就必须将现有的内容转换为我们需要的内容。至少在你的作者们看来,从未有过对系统的最终形式有足够了解,以便第一次就把设计做对。即使是那些我们从详细的需求开始的应用,最终交付时也与规格书中的说明大不相同。

在项目的后期和受到时间压力的情况下学习如何重构代码并不合适。相反,我们抓住每一个机会来练习重构。正如我们在第二十二章,从类到函数中看到的,即使是从头开始编写代码,我们也经常会硬编码值以使测试通过,然后重构以消除测试与生产代码之间的重复。我们始终在寻找快速通过测试的新方法,然后通过重构的方式使代码看起来像是我们早已计划好的那样。有时我们会发现 IntelliJ 内置了一个新的自动重构;有时我们会找到一种方法来结合现有的重构来实现我们的目标。

当改动范围较小时,我们可以手动编辑一个定义,然后调整其用法以匹配,或者有时,更有用的是反过来。然而,当一个改动影响到许多文件时,这种做法就变得乏味且容易出错,因此,练习使用工具来实现即使是小改动,都会让我们在面对更大的重构挑战时做好准备。当我们进行多阶段的重构,或者当我们需要在多个地方手动应用改动时,“扩展和收缩重构”使我们能够在整个过程中保持系统的构建和工作。当一个改动可能需要多天甚至几周时,这是至关重要的,因为它允许我们不断地将我们的工作与系统中的其他变更合并。一旦你因为最后的大爆炸合并不可能而放弃了一个月的工作,你就会意识到这种技术的价值,并且在没有严格必要时也想要练习它。

我们希望本书中的重构能够扩展你的野心。你的作者们有幸与一些世界级的从业者合作过,这些人如果在重构过程中引发了编译错误就会抱怨。我们展示的转换可能不是最优的(即使它们是最优的,技术和语言的发展也会随着工具和语言的变化而改变),但它们是真实的,并且确实反映了我们编写和重构代码的方式。

重构和功能式思维

正如我们在我们的旅程中所看到的,功能性思维与重构之间存在着一种关系。重构是我们代码的重新排列,而当该代码表示动作(“动作”)——即依赖于运行时机的代码——重排可能会改变动作运行的时间,从而影响软件的功能。相比之下,计算(“计算”)则可以安全地重排,但最终是无效的。(如果没有读写操作,我们的代码只是在产生热量。)功能性思维鼓励我们识别和控制动作,并通过这样做使得重构变得更加安全。

作者们是通过艰难的方式学到了这一点。我们在可变对象时代学会了重构,并在未能预测后果时引入了错误。这本来可能会导致我们放弃重构,但我们仍然不够聪明,不能在设计系统时一开始就做到正确。相反,我们发现一种特定的编程风格——面向对象但使用不可变对象——是表达性和可理解的,可以重构且安全。当我们在我们的 Java 代码中采用这种风格时,经常是在逆流中努力,但尽管如此,它比其他选择更加高效。在发现了 Kotlin 后,我们意识到这对我们来说是一个甜蜜点。现在我们可以使用一种现代化语言,其中功能性思维是设计的一部分,对象仍然得到良好支持,而重构工具不是一个事后的想法。

正如肯特·贝克所说:“让变化变得容易,然后再做容易的变化。”持续进行重构,以便每次需要做出的变更都变得容易。重构是解决软件固有复杂性的基本实践。

一路平安。

posted @ 2024-06-15 12:23  绝不原创的飞龙  阅读(9)  评论(0编辑  收藏  举报