Kotlin-设计模式实用指南-全-

Kotlin 设计模式实用指南(全)

原文:zh.annas-archive.org/md5/f61b77e3e4bb835c082a2ac01826bbbb

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

设计模式使您作为开发者能够通过提供经过测试、证明的开发范例来加快开发过程。重用设计模式有助于防止可能导致重大问题的复杂问题,并改进您的代码库,促进代码重用,并使架构更加健壮。

本书的目标是简化 Kotlin 中设计模式的采用,并为程序员提供良好的实践。

本书首先向您展示 Kotlin 中更智能编码的实际方面,解释了 Kotlin 的基本语法和设计模式的影响。此外,本书在深入介绍函数式编程之前,对经典设计模式进行了深入解释,例如创建型、结构型和行为型模式。然后,它引导您了解反应式和并发模式,教授您关于流、线程和协程的知识,以编写更好的代码。在书的结尾,您将了解架构的最新趋势,探索微服务的设计模式,并讨论在选择不同架构(如微服务和 MVC)时的考虑因素。

到本书结束时,您将能够有效地解决开发应用程序时遇到的常见问题,并能够舒适地在任何规模的可扩展和可维护的项目上工作。

本书面向对象

本书是为希望用 Kotlin 掌握设计模式以构建高效和可扩展应用的开发者而编写的。假设读者具备基本的 Java 或 Kotlin 编程知识。

本书涵盖的内容

第一章,开始使用 Kotlin,涵盖了基本语言概念和语法,例如类型、函数、类和流程控制结构。

第二章,使用创建型模式,解释了哪些经典创建型模式被嵌入到语言中,以及如何实现那些未被嵌入的模式。它讨论了单例和工厂等模式。

第三章,理解结构模式,重点介绍了如何扩展我们对象的功能并适应变化。

第四章,熟悉行为模式,解释了如何在运行时改变对象的行为,遍历复杂的数据结构,以及使用可观察设计模式在对象之间进行通信。

第五章,函数式编程,深入探讨了函数式编程的原则以及它们如何适应 Kotlin。将深入讨论诸如数据不可变性和函数作为一等值等主题。

第六章,流式传输您的数据,展示了应用函数式编程的原则如何帮助我们处理可能无限的数据流。

第七章,保持响应性,解释了响应式原则是什么,并基于名为 Rx 的响应式扩展框架提供了大量示例。

第八章,线程和协程,展示了在 Kotlin 中如何轻松地处理并发代码,利用其轻量级线程模型。

第九章,设计用于并发,涵盖了帮助我们同时处理许多任务的设计模式,使用协程。

第十章,惯用语句和反模式,提供了在使用 Kotlin 开发时可能遇到的某些最佳实践和陷阱的指南。

第十一章,使用 Kotlin 的响应式微服务,详细介绍了如何使用 Kotlin、Vert.x 和 PostgreSQL 编写微服务的示例。

为了充分利用本书

在本书中,我们假设读者具备 Java 编程语言的基本知识以及 JVM 是什么。

假设读者熟悉使用命令行。

本书使用的几个命令行示例基于 OSX,但可以轻松地适应 Windows 或 Linux。

下载示例代码文件

您可以从www.packtpub.com的账户下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

您可以通过以下步骤下载代码文件:

  1. www.packtpub.com登录或注册。

  2. 选择支持选项卡。

  3. 点击代码下载与勘误。

  4. 在搜索框中输入书名,并遵循屏幕上的说明。

文件下载完成后,请确保使用最新版本解压缩或提取文件夹:

  • WinRAR/7-Zip for Windows

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

本书代码包也托管在 GitHub 上,地址为github.com/PacktPublishing/Hands-on-Design-Patterns-with-Kotlin。如果代码有更新,它将在现有的 GitHub 仓库中更新。

我们还有其他来自我们丰富的图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!

使用的约定

本书使用了多种文本约定。

CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“Kotlin 的扩展通常是.kt。”

代码块设置如下:

var s = "I'm a string"
s = 1 // s is a String

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

var s = "I'm a string"
s = 1 // s is a String

任何命令行输入或输出都应如下所示:

I would suggest: a guitar

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“Java 开发者最常见的任务之一是创建另一个 普通的 Java 对象POJO)。”

警告或重要注意事项看起来是这样的。

小贴士和技巧看起来是这样的。

联系我们

我们始终欢迎读者的反馈。

一般反馈:请发送电子邮件至 feedback@packtpub.com,并在邮件主题中提及书籍标题。如果您对本书的任何方面有疑问,请通过 questions@packtpub.com 发送电子邮件给我们。

勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告这一错误。请访问 www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。

盗版:如果您在互联网上以任何形式发现我们作品的非法副本,我们将不胜感激,如果您能向我们提供位置地址或网站名称。请通过 copyright@packtpub.com 发送电子邮件,并附上材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com

评论

请留下评论。一旦您阅读并使用了这本书,为什么不在这家购买书籍的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,Packt 可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!

关于 Packt 的更多信息,请访问 packtpub.com

第一章:Kotlin 入门

在本章中,我们将介绍基本的 Kotlin 语法,并讨论哪些设计模式是好的,以及为什么应该在 Kotlin 中使用它们。

本章的目标不是涵盖整个语言词汇表,而是让你熟悉一些基本概念和惯用语。接下来的章节将逐渐向你展示更多与我们将讨论的设计模式相关的语言特性。

本章将涵盖以下主题:

  • 基本语言语法和特性

  • 设计模式简介

基本语言语法和特性

无论你是从 Java、C#、Scala 还是任何其他静态类型编程语言开始,你都会发现 Kotlin 语法非常熟悉。这不是巧合,而是为了让那些在其他语言中有经验的人尽可能顺利地过渡到这门新语言。除了这种熟悉感之外,Kotlin 还带来了大量的特性,如更好的类型安全性。随着我们前进,你会发现它们都在尝试解决现实世界的问题。这种实用主义方法在语言中非常一致。例如,Kotlin 最强的优势之一是完整的 Java 互操作性。你可以在 Java 和 Kotlin 类旁边使用,并且可以自由地使用任何可用于 Kotlin 项目的 Java 库。

总结来说,语言的目标如下:

  • 实用主义

  • 具有清晰的语法

  • 具有类型安全性

  • 互操作性

第一章将讨论如何实现这些目标。

多范式

编程语言中的主要范式包括过程范式、面向对象范式和函数式范式。

Kotlin 是一个实用的语言,它允许使用任何这些范式。它有类和继承,来自面向对象的方法。它有来自函数式编程的高阶函数。但如果你不想的话,不必将所有内容都封装在类中。你可以将整个代码结构为一系列过程和结构。你将看到所有这些方法是如何结合在一起的,因为不同的示例将使用不同的范式来解决讨论中的问题。

代码结构

当你开始用 Kotlin 编程时,你需要做的第一件事是创建一个新文件。Kotlin 的扩展名通常是 .kt

与 Java 不同,文件名和类名之间没有强烈的关系。你可以将尽可能多的公共类放入文件中,只要这些类彼此相关,并且文件不要太长,难以阅读。

无分号

在 Java 中,每一行代码都必须以分号结束:

System.out.println("Hello"); //<- This is a semicolon System.out.println("World"); //<- I still see you, semicolon 

但 Kotlin 是一种实用主义语言。因此,它会在编译期间推断出应该放置分号的位置:

println("Hello") //<- No semicolon here
println("World") //<- Not here

大多数情况下,你不需要在代码中添加分号。它们被认为是可选的。

命名约定

作为一个惯例,如果你的文件中只有一个类,那么将文件名与类名相同。

如果你的文件包含多个类,那么文件名应该描述这些类的共同用途。根据 Kotlin 编码规范,使用驼峰式命名文件:kotlinlang.org/docs/reference/coding-conventions.html#naming-rules

实际上,你不需要为简单的代码片段编写文件。你还可以在线与语言互动:尝试kotlinlang.org/或安装 Kotlin 后运行kotlinc并使用 REPL 和交互式 shell。

如果你的所有类和函数都在同一个文件夹或同一个命名空间下,这并不方便。这就是为什么 Kotlin,类似于许多其他语言,使用包的概念。

与 Java 一样,Kotlin 使用包:

package me.soshin.controllers

如果你混合使用 Java 和 Kotlin,Kotlin 文件应遵循 Java 包规则。

在纯 Kotlin 项目中,可以从文件夹结构中省略常见的包前缀。例如,如果你的所有项目都在me.soshin包下,请将控制器放在/controllers文件夹中,而不是像 Java 那样放在/me/soshin/controllers文件夹中。

类型

我们将从 Kotlin 类型系统开始,并将其与 Java 提供的进行比较。

Java 示例是为了熟悉,并不是为了证明 Kotlin 在任何一个方面都优于 Java。

类型推断

让我们在 Java 中定义一个简单的字符串:

String s = "Hello World";

我们定义了sString类型。但为什么?在这个时候不是显然的吗?

Kotlin 为我们提供了类型推断:

val s = "Hello World"

现在,编译器将决定应该使用哪种类型的变量。与解释型语言(如 JavaScript、Groovy 或 Ruby)不同,变量的类型只定义一次。这不会起作用:

var s = "I'm a string"
s = 1 // s is a String

你可能会想知道为什么我们使用了一个var和一个val来定义变量。我们很快就会解释。

valvar

在 Java 中,变量可以被声明为finalfinal变量只能赋值一次:

final String s = "Hi";
s = "Bye"; // Doesn't work

Kotlin 强烈建议尽可能使用不可变数据。Kotlin 中的final变量只是val

val s = "Hi"
s = "Bye" // Doesn't work

如果你确实有一个想要重新分配变量的情况,请使用var

var s = "Hi"
s = "Bye" // Works now

比较

我们在 Java 的早期学习中就被告知,使用==比较对象不会产生预期的结果,因为它测试的是引用相等性,我们需要使用equals()来做到这一点。

JVM 在基本情况下进行字符串池化以防止这种情况,因此为了示例,我们将使用new String()来避免这种情况:

String s1 = "ABC";
String s2 = new String(s1);

System.out.println(s1 == s2); // false

Kotlin 将==转换为equals()

val s1 = "ABC"
val s2 = String(s1.toCharArray())

println(s1 == s2) // true

如果你确实想检查引用相等性,请使用===

println(s1 === s2) // false

空安全

在 Java 世界中,最臭名昭著的异常可能是NullPointerException

这种异常背后的原因是 Java 中的每个对象都可以是null。这里的代码展示了原因:

String s = "Hello";
...
s = null;
System.out.println(s.length); // Causes NullPointerException

在这种情况下,将s标记为final将防止异常发生。

但这个呢:

public class Printer {    
    public static void printLength(final String s) {
       System.out.println(s.length);
    }
}

从代码的任何地方都可以传递null

Printer.printLength(null); // Again, NullPointerException

自 Java 8 以来,有一个optional构造:

if (optional.isPresent()) {
    System.out.println(optional.get());
}

在更函数式的方式中:

optional.ifPresent(System.out::println);

但是……这并没有解决我们的问题。我们仍然可以用null代替正确的Optional.empty()并使程序崩溃。

Kotlin 在编译时就会检查它:

val s : String = null // Won't compile

让我们回到我们的printLength()函数:

fun printLength(s: String) {
    println(s.length)
}

使用 null 调用这个函数将无法编译:

printLength(null) // Null can not be a value of a non-null type String

如果您希望您的类型能够接收 null 值,您需要使用问号将其标记为可空:

val notSoSafe : String? = null

声明函数

在 Java 中,一切都是对象。如果您有一个不依赖于任何状态的方法,它仍然必须被类包裹。您可能熟悉 Java 中许多只包含静态方法的Util类,它们的唯一目的是满足语言要求并将这些方法捆绑在一起。

在 Kotlin 中,函数可以声明在类外部,而不是以下代码:

public class MyFirstClass {

    public static void main(String[] args) {
        System.out.println("Hello world");
    }
}

只需这样就可以了:

fun main(args: Array<String>) {
    println("Hello, world!")
}

在任何类外部声明的函数已经是静态的。

本书中的许多示例都假设我们提供的代码被包裹在主函数中。如果您没有看到函数的签名,它可能应该是这样的:

fun main(args: Array<String>)

声明函数的关键字是fun。参数类型跟在参数名称之后,而不是之前。如果函数不返回任何内容,可以完全省略返回类型。

如果您确实想声明返回类型?同样,它将紧跟在函数声明之后:

fun getGreeting(): String {
    return "Hello, world!"
}

fun main(args: Array<String>) {
    println(getGreeting())
}

关于函数声明还有很多其他主题,比如默认和命名参数、默认参数和可变数量的参数。我们将在接下来的章节中介绍它们,并附上相关示例。

控制流

可以说,控制流是编写程序的基础。我们将从两个条件表达式开始:ifwhen

使用 if 表达式

之前提到 Kotlin 喜欢变量只分配一次。它也不太喜欢 null。您可能想知道这在现实世界中是如何工作的。在 Java 中,这样的结构相当常见:

public String getUnixSocketPolling(boolean isBsd) {
    String value = null;
    if (isBsd) {
        value = "kqueue";
    }
    else {
        value = "epoll";
    }

    return value;
}

当然,这是一个过于简化的情况,但仍然,您有一个变量,在某个时刻绝对必须是null,对吧?

在 Java 中,if只是一个语句,不返回任何内容。相反,在 Kotlin 中,if是一个表达式,意味着它返回一个值:

fun getUnixSocketPolling(isBsd : Boolean) : String {
    val value = if (isBsd) {
        "kqueue"
    } else {
        "epoll"
    }
    return value
}

如果您熟悉 Java,您可以轻松阅读这段代码。这个函数接收一个布尔值(不能为 null),并返回一个字符串(永远不会为 null)。但由于它是一个表达式,它可以返回一个结果。并且结果只分配给我们的变量一次。

我们甚至可以进一步简化它:

  1. 返回类型可以推断

  2. 作为最后一行的返回可以省略

  3. 一个简单的if表达式可以写在一行中

因此,我们的最终结果在 Kotlin 中将看起来像这样:

fun getUnixSocketPolling(isBsd : Boolean) = if (isBsd) "kqueue" else "epoll"

Kotlin 中的单行函数非常酷且实用。但您应该确保除了您之外的其他人也能理解它们的功能。请谨慎使用。

使用when表达式

如果(不是字面意义上的玩笑)我们想在if语句中有更多的条件呢?

在 Java 中,我们使用switch语句。在 Kotlin 中,有一个更强大的when表达式,因为它可以嵌入一些其他的 Kotlin 特性。

让我们创建一个基于将产生建议一个不错的生日礼物的原因的金额的方法:

fun suggestGift(amount : Int) : String {
    return when (amount) {
        in (0..10) -> "a book"
        in (10..100) -> "a guitar"
        else -> if (amount < 0) "no gift" else "anything!"
    }
}

如你所见,when也支持一系列值。默认情况由else块处理。在下面的示例中,我们将更详细地阐述使用这个表达式的更强大方式。

一般规则是,如果有超过两个条件,使用when。对于简单的检查,使用if

字符串插值

如果我们想要实际打印这些结果呢?

首先,正如你可能已经注意到的,在上面的一个示例中,Kotlin 提供了一个巧妙的println()标准函数,它封装了 Java 中更庞大的System.out.println()

但是,更重要的是,就像许多其他现代语言一样,Kotlin 支持使用${}语法进行字符串插值。在之前的示例之后继续:

println("I would suggest: ${suggestGift(10)} ")

上述代码将打印以下内容:

I would suggest: a book

如果你正在插值一个变量,而不是一个函数,可以省略大括号:

val gift = suggestGift(100)
println("I would suggest: $gift ")

这将打印以下输出:

I would suggest: a guitar

类和继承

虽然 Kotlin 是多范式的,但它与基于类的 Java 编程语言有着强烈的亲和力。考虑到 Java 和 JVM 的互操作性,Kotlin 也有类和经典继承的概念也就不足为奇了。

为了声明一个class,我们使用类关键字,就像在 Java 中一样:

class Player {
}

Kotlin 中没有new关键字。类的实例化看起来就像这样:

// Kotlin figured out you want to create a new player
val p = Player() 

如果类没有主体,就像这个简单的例子一样,我们可以省略大括号:

class Player // Totally fine

继承

正如 Java 中的情况一样,抽象类由abstract关键字标记,接口由interface关键字标记:

abstract class AbstractDungeonMaster {
    abstract val gameName: String

    fun startGame() {
        println("Game $gameName has started!")
    }
}

interface Dragon

就像 Java 8 一样,Kotlin 中的接口可以有函数的默认实现,只要它们不依赖于任何状态:

interface Greeter {
 fun sayHello() {
 println("Hello")
 }
}

Kotlin 中没有inheritsimplements关键字。相反,抽象类的名称以及该类实现的所有接口的名称都放在冒号后面:

class DungeonMaster: Greeter, AbstractDungeonMaster() {
    override val gameName: String
        get() = "Dungeon of the Beholder"
}

我们仍然可以通过其名称后面的括号来区分抽象类,并且仍然只能有一个abstract类,因为 Kotlin 中没有多重继承。

我们的DungeonMaster可以访问GreeterAbstractDungeonMaster中的两个函数:

val p = DungeonMaster()
p.sayHello()  // From Greeter interface
p.startGame() // From AbstractDungeonMaster abstract class

调用前面的代码,它将打印以下输出:

Hello
Game Dungeon of the Beholder has started!

构造函数

我们的DungeonMaster现在看起来有点尴尬,因为它只能宣布开始一个游戏。让我们给我们的abstract类添加一个非空构造函数来解决这个问题:

abstract class AbstractDungeonMaster(private val gameName : String) {
    fun startGame() {
        println("Game $gameName has started!")
    }
}

现在,我们的DungeonMaster必须接收游戏名称并将其传递给abstract类:

open class DungeonMaster(gameName: String):
        Greeter, AbstractDungeonMaster(gameName)

如果我们想要通过一个EvilDungeonMaster来扩展DungeonMaster呢?

在 Java 中,除非被标记为final,否则所有类都可以被扩展。在 Kotlin 中,除非被标记为open,否则没有类可以被扩展。这同样适用于抽象类中的函数。这就是为什么我们最初将DungeonMaster声明为open的原因。

我们将对AbstractDungeonMaster进行一些修改,以赋予邪恶统治者更多的权力:

open fun startGame() {
    // Everything else stays the same
}

现在,我们在我们的EvilDungeonMaster实现中添加以下内容:

class EvilDungeonMaster(private val awfulGame: String) : DungeonMaster(awfulGame) {
    override fun sayHello() {
        println("Prepare to die! Muwahaha!!!")
    }

    override fun startGame() {
        println("$awfulGame will be your last!")
    }
}

而在 Java 中,@Override是一个可选的注解,在 Kotlin 中则是一个强制的关键字。

你不能隐藏超类型方法,并且没有显式使用override的代码将无法编译。

属性

在 Java 中,我们习惯于 getter 和 setter 的概念。一个典型的类可能看起来像这样:

public class Person {
    private String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    // More methods come here
}

如果我们要获取一个人的名字,我们调用getName()。如果我们想更改它,我们调用setName()。这很简单。

如果我们只想在对象实例化期间设置一次名字,我们可以指定非默认构造函数并删除 setter,如下所示:

public class ImmutablePerson {
    private String name;

    public ImmutablePerson(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }
}

所有这些都可以追溯到 Java 的起点,大约在 1995 年左右。

但如果你例如与 C#一起工作过,你可能熟悉属性的概念。为了理解它们,让我们回到第一个示例并稍作修改:

public class PublicPerson {
    public String name;
}

读取一个人的名字并不短多少:p.name

此外,更改名字的方式更加直观:p.name = "Alex";

但这样做,我们失去了对我们对象的大量控制。我们无法使PublicPerson不可变。如果我们想让每个人都能读取人的名字,他们也可以在任何时候更改它。如果我们后来决定所有名字都必须是大写字母,我们可以通过 setter 来实现这一点。但不是通过公共字段。

属性为所有这些问题提供了一个解决方案:

class Person() {
    var name : String = ""
}

这可能看起来与 Java 示例相同,带有所有其问题。但实际上,在幕后,它被编译为一个 getter 和 setter 对,就像第一个示例一样。

由于 Kotlin 中的属性被转换为 getter 和 setter,我们也可以控制它们的行为:

class Person {
    var name : String = ""
    set(value) {
        field = value.toUpperCase()
    }
}

注意,我们不需要检查value是否为 null。String类型简单地不能接收 null 值。

来自 Java,使用以下赋值可能看起来很直观:this.name = value.toUpperCase()。但在 Kotlin 中,这将创建一个循环依赖。相反,我们使用了一个field标识符,它是自动提供的。

数据类

记得 Kotlin 的全部都是关于生产力吗?Java 开发者最常见的任务之一是创建另一个 普通的 Java 对象 (POJO)。如果你不熟悉 POJO,它基本上是一个只有 getter、setter 以及equalshashCode方法实现的对象。

这个任务如此常见,以至于 Kotlin 将其内置到语言中:

data class User (val username : String, val password : String)

这将生成一个具有两个 getter 而没有 setter(注意val部分)的类,它还将以正确的方式实现equalshashCodeclone函数。

数据类的引入是语言中减少样板代码的最大改进之一。

更多的控制流 - 循环

现在让我们讨论另一个常见的控制结构——循环。循环对于大多数开发者来说是一个非常自然的结构。没有循环,重复相同的代码块将非常困难(尽管我们将在后面的章节中讨论如何在没有循环的情况下做到这一点)。

For 循环

Java 中的 for 循环,它将字符串的每个字符打印在新的一行上,可能看起来像这样:

final String word = "Word";
for (int i = 0; i < word.length; i++) {

}

Kotlin 中的相同循环如下:

val word = "Word";
for (i in 0..(word.length-1)) {
    println(word[i])
}

注意,虽然 Java 中的常规 for 循环是排他的(它根据定义排除了最后一个索引,除非有其他指定),但 Kotlin 中对范围的 for 循环是包含的。这就是为什么我们必须从长度中减去一来防止溢出(字符串索引超出范围):(word.length-1)

如果你想避免这种情况,可以使用 until 函数:

val word = "Word";
for (i in 0 until word.length) {
    println(word[i])
}

与其他一些语言不同,反转范围索引不起作用:

val word = "Word";
for (i in (word.length-1)..0) {
    println(word[i])
} // Doesn't print anything

如果你想要以相反的顺序打印单词,例如,使用 downTo 函数:

val word = "Word";
for (i in (word.length-1) downTo 0) {
    println(word[i])
}

它将打印以下输出:

d
r
o
W

可能会让人困惑的是,尽管 untildownTo 看起来更像操作符,但它们被称为函数。这是 Kotlin 另一个有趣的功能,称为 中缀调用,稍后我们将讨论。

For-each 循环

当然,如果你对 Java 有点熟悉,你可能会争辩说,前面的代码可以通过使用 for-each 构造来改进:

final String word = "Word";

for (Character c : word.toCharArray()) {
    System.out.println(c);
}

在 Kotlin 中,这将是相同的:

val word = "Word"

for (c in word) {
    println(c)
}

While 循环

while 循环的功能没有变化,所以我们非常简短地介绍它们:

var x = 0
while (x < 10) { 
   x++ 
   println(x)
}

这将打印从 1 到 10 的数字。请注意,我们被迫将 x 定义为 var。在接下来的章节中,我们将讨论更多更习惯性的方法来做这件事。

较少使用的 do while 循环也存在于该语言中:

var x = 5
   do { 
      println(x)
      x--
} while (x > 0)

扩展函数

你可能已经注意到,从前面的例子中,Kotlin 中的 String 有一些方法,其 Java 对应版本缺少,例如 reversed()。如果它与 Java 中的 String 类型相同,并且我们知道,Java 中的 String 不能被任何其他类扩展,因为它被声明为 final,那么这是如何实现的呢?

如果你查看源代码,你会发现以下内容:

public inline fun String.reversed(): String {
    return (this as CharSequence).reversed().toString()
}

这个特性被称为扩展函数,它也存在于一些其他语言中,例如 C# 或 Groovy。

要在不继承的情况下扩展一个类,我们可以在示例中函数名 reversed 前面加上我们想要扩展的类名。

请注意,扩展函数不能覆盖成员函数。inline 关键字将在后面的章节中讨论。

设计模式简介

现在我们对 Kotlin 的基本语法有点熟悉了,我们可以继续讨论设计模式是什么。

设计模式是什么?

围绕设计模式存在不同的误解。一般来说,它们如下:

  • 缺失的语言特性

  • 在动态语言中不是必需的

  • 仅适用于面向对象的语言

  • 仅适用于企业

但实际上,设计模式只是解决常见问题的一种经过验证的方法。作为一个概念,它们并不局限于特定的编程语言(例如 Java),也不局限于语言家族(例如 C 家族),甚至不局限于编程本身。你可能甚至听说过软件架构中的设计模式,它们讨论了不同的系统如何高效地相互通信。有面向服务的架构模式,你可能知道它为面向服务架构SOA),以及从 SOA 演变而来的微服务设计模式,这些模式在过去几年中逐渐出现。未来肯定会给我们带来更多的设计模式家族

即使在物理世界中,在软件开发之外,我们也被设计模式和某些问题的常见解决方案所包围。让我们看看一个例子。

生活中的设计模式

你最近乘坐过电梯吗?电梯墙上有没有镜子?为什么会有这样的设计?

当你上次乘坐没有镜子也没有玻璃墙的电梯时,你感觉如何?

我们在电梯中普遍安装镜子的主要原因是为了解决一个常见问题。乘坐电梯是无聊的。我们可以放一张图片。但如果你每天至少乘坐两次相同的电梯,图片也会很快变得无聊。虽然便宜,但改善不大。

我们可以安装一个电视屏幕,就像有些人做的那样。但这会使电梯更贵。而且它还需要大量的维护。我们需要在屏幕上放置一些内容,以避免重复。所以要么有一个人的责任是偶尔更新内容,要么有一个第三方公司为我们做这件事。我们还得处理屏幕硬件及其背后的软件可能出现的不同问题。当然,看到“蓝屏死机”是很有趣的,但只是轻微的乐趣。

一些建筑师甚至选择将电梯井安装在建筑外部,并使部分墙壁透明。这可能提供一些令人兴奋的视野。但这个解决方案也需要维护(脏窗户不会提供最好的视野),以及大量的建筑规划。

因此,我们安装了镜子。即使你独自乘坐,你也能看到吸引人的面孔。一些研究表明,我们发现自己比实际更吸引人。也许你有机会在重要会议之前最后一次审视你的外表。镜子从视觉上扩展了视觉空间,使整个旅程不那么令人窒息,或者不那么尴尬,如果这是新的一天,电梯真的很拥挤。

设计过程

让我们尝试理解我们刚才做了什么。

我们没有在电梯里发明镜子。我们见过它们成千上万次。但我们正式化了这个问题(乘坐电梯很无聊)并讨论了替代方案(电视屏幕、玻璃墙)以及常用解决方案的好处(解决问题,易于实现)。这就是设计模式的所有内容。

设计过程的基本步骤是:

  1. 准确定义当前的问题。

  2. 考虑不同的替代方案,基于其优缺点。

  3. 选择解决问题的方案,同时最好地符合你的具体约束。

为什么在 Kotlin 中使用设计模式?

Kotlin 出现是为了解决当今的现实世界问题。在接下来的章节中,我们将讨论由四人帮在 94 年首次提出的 设计模式,以及从函数式编程范式出现的设计模式。

你会发现,一些设计模式非常常见或有用,以至于它们已经作为保留关键字或标准函数内置到语言中。其中一些需要结合一组语言特性。而一些则不再那么有用,因为世界已经前进,它们正被一些其他模式所取代。

但无论如何,熟悉设计模式和最佳实践扩展了你的“开发者工具箱”,并在你和你同事之间创造了共享的词汇。

摘要

因此,在本章中,我们介绍了 Kotlin 编程语言的主要目标。

我们讨论了定义的变量,例如 valvar、空安全性和类型推断。我们观察了程序流程是如何通过 ifwhenforwhile 等命令来控制的,我们还查看了解决类和接口定义的不同关键字:classinterfacedataabstract 类。我们学习了如何构建新的类,以及我们如何从接口继承和实现类。最后,我们学习了设计模式的好处,以及为什么在 Kotlin 中需要它们。

在下一章中,我们将开始讨论三种设计模式家族中的第一个:创建型模式。

第二章:与创建型模式一起工作

在本章中,我们将介绍如何在 Kotlin 中实现经典创建型模式。这些模式处理如何何时创建你的对象。掌握这些模式将使你能够更好地管理对象,适应变化,并编写易于维护的代码。

在本章中,我们将涵盖以下主题:

  • 单例模式

  • 工厂方法

  • 抽象工厂

  • 构造器

  • 原型模式

单例模式

这是邻里的最受欢迎的单个男孩。每个人都认识他,每个人都谈论他,任何人都可以轻易找到他。

即使那些在其他设计模式被提及时会皱眉的人也会知道它的名字。在某个时候,它甚至被宣布为反模式,但这仅仅是因为它的广泛流行。所以,对于那些第一次听说它的人来说,这个模式是关于什么的?

通常,如果你有一个对象,你可以创建尽可能多的其实例。比如说,你有一个Cat类:

class Cat

你可以生产尽可能多的其实例(具体来说,是猫),你想要多少就有多少:

val firstCat = Cat()
val secondCat = Cat()
val yetAnotherCat = Cat()

没有任何问题。

如果我们想要禁止这种行为呢?显然,我们第一次必须以某种方式创建一个对象。但从第二次开始,我们需要认识到这个对象已经被初始化过一次,并返回其实例。这就是单例背后的主要思想。

在 Java 和一些其他语言中,这个任务相当复杂。仅仅将构造函数设为私有并记住该对象已经被初始化至少一次是不够的。我们还需要防止竞态条件,即两个不同的线程试图同时初始化它。如果我们允许这样做,就会破坏单例的整个概念,因为两个线程将持有同一对象的两个实例的引用。

在 Java 中解决此问题需要执行以下操作之一:

  • 接受单例将在应用程序启动时立即初始化,而不是在首次访问时初始化

  • 编写一些智能代码来防止这种竞态条件,同时保持性能

  • 使用已经解决这个问题的框架

Kotlin 只是为这个引入了一个保留关键字。看,一个如下所示的对象:

object MySingelton{}

你不需要那里的大括号。它们只是为了视觉一致性。

这将声明和初始化结合在一个关键字中。从现在起,MySingleton可以从你的代码的任何地方访问,并且将只有一个实例。

当然,这个对象并没有做任何有趣的事情。让我们让它计算调用次数:

object CounterSingleton {
    private val counter = AtomicInteger(0)

    fun increment() = counter.incrementAndGet()
}

我们现在不会测试它的线程安全性,这是一个将在第八章中讨论的主题,即线程和协程,它涉及到线程。现在,我们只测试它是如何调用我们的单例的:

for (i in 1..10) {
    println(CounterSingleton.increment())
}

这将按预期打印出 1 到 10 之间的数字。正如你所看到的,我们根本不需要getInstance()方法。

object关键字不仅用于创建 Singleton。我们稍后会深入讨论。

对象不能有构造函数。如果你想为你的 Singleton 添加一些初始化逻辑,比如第一次从数据库或网络上加载数据,你可以使用init块代替:

object CounterSingleton {

    init {
        println("I was accessed for the first time")
    }
    // More code goes here
}

还演示了 Kotlin 中的 Singleton 是延迟初始化的,而不是像一些人从其声明的简便性所怀疑的那样是立即初始化的。就像常规类一样,对象可以扩展其他类并实现接口。我们将在第十章中回到这一点,惯用和反模式

工厂方法

工厂方法完全是关于创建对象。但为什么我们需要一个方法来创建对象?这不是构造函数的全部内容吗?

嗯,构造函数有其固有的局限性,我们即将讨论。

工厂

我们从《设计模式》这本书中 Gang of Four 提出的工厂方法开始。

这是我教给学生们的第一个模式之一。他们通常对设计模式的整体概念非常焦虑,因为它有一种神秘和复杂的氛围。所以,我通常会问他们以下问题。

假设你有一些类声明,例如:

class Cat {
    val name = "Cat"
}

你能写一个函数返回该类的新实例吗?大多数人都能成功:

fun catFactory() : Cat {
    return Cat()
}

检查一切是否正常工作:

val c = catFactory() 
println(c.name) // Indeed prints "Cat"

嗯,这真的很简单,对吧?

现在,基于我们提供的参数,这个方法能否创建两个对象之一?

假设我们现在有一个Dog

class Dog {
    val name = "Dog"
}

在两个类型的对象之间进行实例化选择只需要传递一个参数:

fun animalFactory(animalType: String) : Cat {
    return Cat()
}

当然,我们现在不能总是返回一个Cat。因此,我们创建一个公共接口来返回:

interface Animal {
   val name : String
}

剩下的就是使用when表达式返回正确类的实例:

return when(animalType.toLowerCase()) {
    "cat" -> Cat()
    "dog" -> Dog()
    else -> throw RuntimeException("Unknown animal $animalType")
}

这就是工厂方法的核心:

  • 获取一些值。

  • 返回实现公共接口的其中一个对象。

当从配置创建对象时,这个模式非常有用。想象一下,我们有一个文本文件,其内容如下,来自兽医诊所:

dog, dog, cat, dog, cat, cat

现在,我们希望为每种动物创建一个空配置文件。假设我们已经读取了文件内容并将它们分割成一个列表,我们可以做以下操作:

val animalTypes = listOf("dog", "dog", "cat", "dog", "cat", "cat")

for (t in animalTypes) {
  val c = animalFactory(t) 
    println(c.name)
} 

listOf是一个来自 Kotlin 标准库的函数,它创建了一个不可变列表,包含提供的对象。

如果你的工厂方法不需要状态,我们可以将其留为一个函数。

但如果我们想为每个动物分配一个唯一的顺序标识符呢?看看下面的代码块:

interface Animal {
   val id : Int
   // Same as before
}

class Cat(override val id: Int) : Animal { 
    // Same as before
}

class Dog(override val id: Int) : Animal {
    // Same as before
}

注意我们可以在构造函数内部覆盖值。

我们的工厂现在成为一个合适的类:

class AnimalFactory { 
    var counter = 0

    fun createAnimal(animalType: String) : Animal {
        return when(animalType.trim().toLowerCase()) {
            "cat" -> Cat(++counter)
            "dog" -> Dog(++counter)
            else -> throw RuntimeException("Unknown animal $animalType")
        }
    } 
}

因此,我们还需要初始化它:

val factory = AnimalFactory()
for (t in animalTypes) {
    val c = factory.createAnimal(t) 
    println("${c.id} - ${c.name}")
} 

上述代码的输出如下:

1 - Dog 
2 - Dog 
3 - Cat 
4 - Dog 
5 - Cat 
6 - Cat

这是一个相当直接的例子。我们为我们的对象提供了一个公共接口(在这个例子中是Animal),然后根据一些参数,我们决定实例化哪个具体类。

如果我们决定支持不同的品种呢?看看下面的代码:

val animalTypes = listOf("dog" to "bulldog", 
                         "dog" to "beagle", 
                         "cat" to "persian", 
                         "dog" to "poodle", 
                         "cat" to "russian blue", 
                         "cat" to "siamese")

就像我们在第一章“Kotlin 入门”中看到的downTo函数一样,它看起来像是一个操作符,但实际上是一个创建一对对象的函数:在我们的例子中是(cat, siamese)。当我们深入讨论infix函数时,我们还会回到它。

我们可以将实际的对象实例化委托给其他工厂:

class AnimalFactory {
    var counter = 0
    private val dogFactory = DogFactory()
    private val catFactory = CatFactory()

    fun createAnimal(animalType: String, animalBreed: String) : Animal {
        return when(animalType.trim().toLowerCase()) {
            "cat" -> catFactory.createDog(animalBreed, ++counter)
            "dog" -> dogFactory.createDog(animalBreed, ++counter)
            else -> throw RuntimeException("Unknown animal $animalType")
        }
    }
}

工厂重复相同的模式:

class DogFactory {
    fun createDog(breed: String, id: Int) = when(breed.trim().toLowerCase()) {
        "beagle" -> Beagle(id)
        "bulldog" -> Bulldog(id)
        else -> throw RuntimeException("Unknown dog breed $breed")
    }
}

你可以通过自己实现BeagleBulldogCatFactory以及所有不同的猫品种来确保你理解这个例子。

最后一点要注意的是我们现在如何使用一对参数调用我们的AnimalFactory

for ((type, breed) in animalTypes) {
    val c = factory.createAnimal(type, breed)
    println(c.name)
}

这被称为解构声明,在处理这样的数据对时特别有用。

静态工厂方法

静态工厂方法是由 Joshua Bloch 在他的书《Effective Java》中推广的。为了更好地理解它,让我们看看 Java 标准库本身的例子,即valueOf()方法:

Long l1 = new Long("1");
Long l2 = Long.valueOf("1");

构造函数和valueOf()方法都接收String作为输入,并产生Long作为输出。

那么,为什么静态工厂方法有时比构造函数更好?

静态工厂方法的优点

下面是静态工厂方法相对于构造函数的一些优点:

  • 它为构造函数提供了一个更好的名称,它期望的,有时甚至是什么它产生的。

  • 我们通常不期望构造函数抛出异常。另一方面,常规方法抛出的异常是完全有效的。

  • 说到期望,我们期望构造函数是快速的。

但这些都是心理上的优势。这种方法还有一些技术上的优势。

缓存

静态工厂方法可能提供缓存,就像Long类那样。它不是为任何值总是返回一个新的实例,而是valueOf()方法会检查缓存中是否已经解析了这个值。如果是,它将返回缓存的实例。反复使用相同的值调用静态工厂方法可能产生的垃圾回收量比始终使用构造函数要少。

子类化

当调用构造函数时,我们总是实例化我们指定的类。另一方面,调用静态工厂方法可能产生类的实例,或者其子类之一。在讨论了在 Kotlin 中实现这种设计模式的实现之后,我们再回到这一点。

Kotlin 中的静态工厂方法

我们已经在单例部分中讨论了object关键字。现在我们将看到它的另一个用途是companion对象。

在 Java 中,静态工厂方法是声明为static的。但在 Kotlin 中,没有这样的关键字。相反,不属于类实例的方法可以声明在companion对象内部:

class NumberMaster {
    companion object {
        fun valueOf(hopefullyNumber: String) : Long {
            return hopefullyNumber.toLong()
        }
    }
}

伴随对象可以有名称:例如,companion object Parser。但这只是为了清晰说明这个对象的目标。

调用companion对象不需要实例化一个类:

println(NumberMaster.valueOf("123")) // Prints 123

此外,直接在类的实例上调用它将不起作用,这与 Java 不同:

println(NumberMaster().valueOf("123")) // Won't compile

该类可能只有一个伴侣对象。

伴侣对象

在 Java 中,静态工厂方法声明如下:

private static class MyClass { 

 // Don't want anybody to use it but me 
  private MyClass() { 
  } 

 // This will replace the public constructor 
  public static MyClass create() { 
    return new MyClass(); 
  } 
} 

它们被这样称呼:

MyClass myClass = MyClass.create(); 

但在 Kotlin 中,没有这样的关键字叫做静态。相反,不属于类实例的方法可以在companion对象内部声明。

我们之前在单例部分讨论了object关键字,现在我们将通过以下示例来探讨这个重要关键字的其他用法:

   class NumberMaster { 
       companion object { 
           fun valueOf(hopefullyNumber: String) : Long { 
               return hopefullyNumber.toLong() 
           }  
       } 
   } 

正如你所见,在我们的类中,我们声明了一个以companion关键字为前缀的对象。

这个对象有一套自己的函数。这样的好处是什么?你可能想知道。

就像 Java 中的静态方法一样,调用companion对象不需要实例化一个类:

println(NumberMaster.valueOf("123")) // Prints 123 

此外,直接在类的实例上调用它将不起作用,这与 Java 不同:

println(NumberMaster().valueOf("123")) // Won't compile 

companion对象可能有一个名称解析器,例如。但这只是为了清晰说明这个对象的目标。

该类可能只有一个companion对象。

通过使用companion对象,我们可以实现与 Java 中完全相同的行为:

private class MyClass private constructor() { 

    companion object { 
        fun create(): MyClass { 
            return MyClass() 
        } 
    } 
} 

我们现在可以像以下代码所示那样实例化我们的对象:

// This won't compile 
//val instance = MyClass() 

// But this works as it should 
val instance = MyClass.create() 

Kotlin 证明了自己是一种非常实用的语言。它里面的每一个关键字都有接地气的含义。

抽象工厂

抽象工厂是一个被广泛误解的模式。它因非常复杂和奇特而臭名昭著,但实际上,它相当简单。如果你理解了工厂方法,你很快就会理解这个模式。这是因为抽象工厂是工厂的工厂。实际上就是这样。工厂是一个能够创建其他类的函数或类。抽象工厂是一个创建工厂的类。

你可能已经理解了这一点,但仍想知道这种模式的用途可能是什么。在现实世界中,抽象工厂的主要用途可能是框架,特别是 Spring 框架,它使用抽象工厂的概念通过注解和 XML 文件创建其组件。但鉴于创建我们自己的框架可能相当繁琐,让我们看看另一个这个模式非常有用的例子——战略游戏。

我们将其称为CatsCraft 2: 狗的复仇

抽象工厂的实际应用

我们的战略游戏将包括建筑和单位。让我们从声明所有建筑共有的内容开始:

interface Building<in UnitType, out ProducedUnit> 
        where UnitType : Enum<*>, ProducedUnit : Unit {
    fun build(type: UnitType) : ProducedUnit
}

所有建筑都应该实现build()函数。在这里,我们第一次看到了 Kotlin 中的泛型,所以让我们稍微讨论一下。

Kotlin 泛型简介

泛型是指定类型之间关系的一种方式。嗯,这并没有帮助解释多少,对吧?让我们再试一次。泛型是类型的抽象。不,仍然很糟糕。

我们将尝试一个示例,然后:

    val listOfStrings = mutableListOf("a", "b", "c") 

好吧,这很简单;我们已经多次覆盖了它。这段代码只是创建了一个字符串列表。但它实际上意味着什么?

让我们尝试以下代码行:

listOfStrings.add(1) 

这行代码无法编译。那是因为 mutableListOf() 函数使用了泛型:

public fun <T> mutableListOf(vararg elements: T): MutableList<T> 

泛型创建了一个期望。无论我们使用什么类型来创建我们的列表,从现在起我们只能放那种类型进去。这是一个伟大的语言特性,因为一方面,我们可以泛化我们的数据结构或算法。无论它们包含什么类型,它们都会以完全相同的方式工作。

另一方面,我们仍然有类型安全。listOfStringsfirst() 函数保证返回一个 String(在这种情况下)而不是其他任何东西。

在泛型的方面,Kotlin 使用一种类似于 Java 但略有不同的方法。我们不会在本节中涵盖泛型的所有方面,但将只提供一些指导,以便更好地理解这个例子。随着我们的深入,我们将遇到更多泛型的用法。

让我们看看另一个例子。

我们将创建一个名为 Box 的类。我知道这很无聊:

class Box<T> { 
    private var inside: T? = null 

    fun put(t: T) { 
        inside = t 
    }    
    fun get(): T? = inside 
} 

然而,这个盒子的好处是,通过使用泛型,我可以把它几乎任何东西放进去,例如,一只猫:

class Cat 

当我创建一个盒子的实例时,我指定它可以容纳什么:

val box = Box<Cat>() 

在编译时,泛型将确保它只会持有正确类型的对象:

box.put(Cat()) // This will work 
val cat = box.get() // This will always return a Cat, because that's what our box holds 
box.put("Cat") // This won't work, String is not a Cat 

如你所知,Java 使用 ? extends 和 super 关键字来指定只读和只写类型。

Kotlin 使用 inoutwhere 的概念。

被标记为 in 的类型可以用作参数,但不能用作返回值。这也被称为协变。实际上,这意味着我们可以返回 ProducedUnit 或从它继承的类型,但不能返回在层次结构中高于 ProducedUnit 的类型。

被标记为 out 的类型只能用作返回值,不能用作参数。这被称为逆变。

此外,我们还可以使用 where 关键字对类型引入约束。在我们的情况下,我们要求第一个类型实现 Type 接口,而第二个类型实现 Unit 接口。

类型本身的名称,UnitTypeProducedUnit,可以是任何我们想要的,例如 TP。但为了清晰起见,我们将使用更冗长的名称。

回到我们的基地

HQ 是一个可以生产其他建筑的特殊建筑。它记录了它至今为止所建造的所有建筑。同类型的建筑可以建造多次:

class HQ {
    val buildings = mutableListOf<Building<*, Unit>>()

    fun buildBarracks(): Barracks {
        val b = Barracks()
        buildings.add(b)
        return b
    }

    fun buildVehicleFactory(): VehicleFactory {
        val vf = VehicleFactory()
        buildings.add(vf)
        return vf
    }
}

你可能想知道关于泛型,星号 (*) 的含义是什么。它被称为星投影,意味着 我对这个类型一无所知。它与 Java 的原始类型类似,但它具有类型安全性。

所有其他建筑都会生产单位。单位可以是陆军或装甲车辆:

interface Unit 

interface Vehicle : Unit

interface Infantry : Unit

陆军可以是有步枪兵或火箭兵:

class Rifleman : Infantry

class RocketSoldier : Infantry

enum class InfantryUnits {
    RIFLEMEN,
    ROCKET_SOLDIER
}

在这里,我们第一次看到 enum 关键字。车辆可以是坦克或装甲人员运输车APCs):

class APC : Vehicle

class Tank : Vehicle

enum class VehicleUnits {
    APC,
    TANK
}

一个兵营是一个生产步兵的建筑:

class Barracks : Building<InfantryUnits, Infantry> {
    override fun build(type: InfantryUnits): Infantry {
        return when (type) {
            RIFLEMEN -> Rifleman()
            ROCKET_SOLDIER -> RocketSoldier()
        }
    }
}

我们不需要在 when 中使用 else 块。这是因为我们使用了 enum,Kotlin 确保了 enum 上的 when 是穷尽的。

一个车辆工厂是一个生产不同类型装甲车辆的建筑:

class VehicleFactory : Building<VehicleUnits, Vehicle> {
    override fun build(type: VehicleUnits) = when (type) {
        APC -> APC()
        TANK -> Tank()
    }
}

我们现在可以确保能够构建不同的单元:

val hq = HQ()
val barracks1 = hq.buildBarracks()
val barracks2 = hq.buildBarracks()
val vehicleFactory1 = hq.buildVehicleFactory()

接下来是单元的生成:

val units = listOf(
        barracks1.build(InfantryUnits.RIFLEMEN),
        barracks2.build(InfantryUnits.ROCKET_SOLDIER),
        barracks2.build(InfantryUnits.ROCKET_SOLDIER),
        vehicleFactory1.build(VehicleUnits.TANK),
        vehicleFactory1.build(VehicleUnits.APC),
        vehicleFactory1.build(VehicleUnits.APC)
)

我们已经看到了标准库中的 listOf() 函数。它将创建一个只读列表,其中包含我们建筑生产的不同单元。你可以遍历这个列表,确保这些确实是所需的单元。

进行改进

有些人可能会说,拥有 VehicleFactoryBarracks 类太繁琐了。毕竟,它们没有任何状态。相反,我们可以用对象来替换它们。

我们可以不用之前的 buildBarracks() 实现方式,而是有以下方式:

fun buildBarracks(): Building<InfantryUnits, Infantry> {
    val b = object : Building<InfantryUnits, Infantry> {
        override fun build(type: InfantryUnits): Infantry {
            return when (type) {
                InfantryUnits.RIFLEMEN -> Rifleman()
                InfantryUnits.ROCKET_SOLDIER -> RocketSoldier()
            }
        }
    }
    buildings.add(b)
    return b
}

我们已经看到了 object 关键字的不同用法:一次是在单例设计模式中,另一次是在工厂方法设计模式中。这里是我们使用 object 的第三种方式:用于动态创建匿名类。毕竟,Barracks 是一个建筑,给定 InfantryUnitType,它会产生 Infantry

如果我们的逻辑简单,我们甚至可以进一步缩短声明:

fun buildVehicleFactory(): Building<VehicleUnits, Vehicle> {
    val vf = object : Building<VehicleUnits, Vehicle> {
        override fun build(type: VehicleUnits) = when (type) {
            VehicleUnits.APC -> APC()
            VehicleUnits.TANK -> Tank()
        }
    }
    buildings.add(vf)

    return vf
}

让我们回到本章的开头。我们提到抽象工厂结合了多个相关的工厂。那么,在我们的案例中,所有工厂的共同点是什么?它们都是建筑,并且都生产单元。

有了这个原则,你就可以将其应用于许多不同的场景。如果你熟悉策略游戏,通常它们至少有两个不同的派系。每个派系可能都有不同的结构和单位。为了实现这一点,你可以根据需要重复这个模式。

假设我们现在有两个不同的派系,猫和狗,坦克和火箭步兵是这个派系的特权。狗有重型坦克和掷弹兵。我们需要在我们的系统中做出哪些改变?

首先,HQ 变成了一个接口:

interface HQ {
    fun buildBarracks(): Building<InfantryUnits, Infantry>
    fun buildVehicleFactory(): Building<VehicleUnits, Vehicle>
}

之前 HQ 的功能现在变成了 CatHQ

class CatHQ : HQ { 
// Remember to add override to your methods
}

DogHQ 将需要重复相同的步骤,但使用不同的构建逻辑。

这种适应大变化的能力使得抽象工厂在某些用例中非常强大。

构建器

有时候,我们的对象非常简单,只有一个构造函数,无论是空的还是非空的。但有时,它们的创建非常复杂,基于很多参数。我们已经看到了一个提供 更好的构造函数 的模式——静态工厂方法设计模式。现在,我们将讨论构建器设计模式,它在某些方面相似,在某些方面不同。

编写电子邮件

作为软件架构师,我主要的沟通渠道之一是电子邮件。这可能对大多数软件开发角色都适用。

一封电子邮件包含以下内容:

  • 地址(至少一个必填)

  • CC(零个或多个,可选)

  • 标题(可选)

  • 正文(可选)

  • 附件(零个或多个,可选)

假设我真的很懒,并且想在我在街区骑自行车的时候安排发送电子邮件。

实际的调度逻辑将被推迟到第八章线程和协程和第九章设计用于并发,这两章讨论了调度和并发。现在,让我们看看我们的 Mail 类可能看起来像什么:

data class Mail(val to: String, 
           val cc: List<String>, 
           val bcc: List<String>,
           val title: String?,
           val message: String)

因此,我们在前面的章节中已经看到了 data class 的实际应用。我们还讨论了可空和非可空类型,例如 String?String

现在是讨论 Kotlin 中集合如何工作的好时机,因为这是我们第一次直接处理它们的类。

Kotlin 中的集合类型

Kotlin 的主要目标之一是 Java 互操作性。所以 Kotlin 集合与 Java 兼容也就不足为奇了。当你指定你的函数接收 List<T> 时,实际上是你所熟悉的相同的 Java List<T>

但 Kotlin 区分可变和不可变集合。listOf() 函数委托给 Arrays.asList(),并生成一个不可变列表,而 mutableListOf() 简单地调用 ArrayList()

除了数据之外,Kotlin 集合还有许多有用的扩展方法,我们将在后面讨论。

创建电子邮件 - 第一次尝试

因此,在上午 10 点,我计划在我当地的咖啡馆喝咖啡。但我也想联系我的经理,因为我的工资条昨天没有到达。我尝试创建我的第一个电子邮件如下:

val mail = Mail("manager@company.com", // TO
    null,   // CC
    null,   // BCC
    "Ping", // Title
    null    // Message)

这可能在 Java 中有效,但在 Kotlin 中不会编译,因为我们不能将 null 传递给 List<String>。空安全在 Kotlin 中非常重要:

val mail = Mail("manager@company.com", // TO
    listOf(),  // CC
    listOf(),  // BCC
    "Ping",    // Title
    null       // Message) 

注意,由于我们的构造函数接收了很多参数,我不得不添加一些注释,这样我就不会迷路了。

Kotlin 编译器足够智能,可以推断我们传递的列表类型。由于我们的构造函数接收 List<String>,传递 listOf() 对于空列表就足够了。我们不需要像这样指定类型:listOf<String>()。在 Java 中,菱形运算符起到相同的作用。

哦,但我忘了附件。让我们改变我们的构造函数:

data class Mail(val to: String, 
           val cc: List<String>, 
           val bcc: List<String>,
           val title: String?,
           val message: String?,
           val attachments: List<java.io.File>)

但然后我们的实例化又停止编译了:

val mail = Mail("manager@company.com", // TO
    listOf(), listOf(),
    "Ping",
    null) // Compilation error, No value passed for for parameter 'attachments'

这显然变得一团糟。

创建电子邮件 - 第二次尝试

让我们尝试一种流畅的设置方法。在我们的构造函数中,我们只有必填字段,其他所有字段都将成为设置器,因此创建一个新电子邮件看起来可能像这样:

Mail("manager@company.com").title("Ping").cc(listOf<String>())

这在很多方面都好多了:

  • 字段的顺序现在可以是任意的,与构造函数不同。

  • 哪个字段被设置现在更清晰,不再需要注释。

  • 可选字段根本不需要设置。例如,CC 字段被设置,而 BCC 字段被省略。

让我们看看实现这种方法的一种方式。还有其他方便的方法来做这件事,我们将在第十章惯用和反模式中讨论:

data class Mail(// Stays the same
                private var _message: String = "",
                // ...) {
    fun message(message: String) : Mail {
        _message = message
        return this
    }
    // Pattern repeats for every other variable
}

在 Kotlin 中,使用下划线作为私有变量的命名约定是一种常见做法。这允许我们避免重复 this.message = message 和像 message = message 这样的错误。

这很好,非常类似于我们可能在 Java 中实现的效果。尽管我们现在不得不使我们的消息可变。但 Kotlin 提供了两种其他方法,你可能觉得它们更有用。

创建电子邮件——Kotlin 方式

和其他一些现代语言一样,Kotlin 提供了为函数参数设置 默认值 的能力:

data class Mail(val to: String, 
    val title: String = "",
    val message: String = "",
    val cc: List<String> = listOf(), 
    val bcc: List<String> = listOf(), 
    val attachments: List<java.io.File> = listOf())

因此,如果你想要发送一个没有 CC 的电子邮件,现在可以这样操作:

val mail = Mail("one@recepient.org", "Hi", "How are you")

但是,如果你想要发送带有 BCC 的电子邮件怎么办?而且,使用流畅设置器不需要指定顺序,这非常方便。Kotlin 提供了 命名参数 来实现这一点:

val mail = Mail(title= "Hello", message="There", to="my@dear.cat")

将默认参数与命名参数结合使用,使得在 Kotlin 中创建复杂对象比以前容易得多。还有另一种实现类似行为的方法:apply() 函数。这是 Kotlin 中每个对象都有的扩展函数之一。不过,为了使用这种方法,我们需要将所有可选字段改为变量而不是值:

然后,我们可以这样创建我们的电子邮件:

val mail = Mail("hello@mail.com").apply {
    message = "Something"
    title = "Apply"
}

apply() 函数是 作用域函数 家族中唯一的函数。我们将在后面的章节中讨论作用域函数的工作原理及其用途。现在,虽然我的老板认为我正在努力发送所有这些电子邮件,但我可以回到我的咖啡那里。现在它已经凉了!

创建电子邮件——Kotlin 方式——第二次尝试

让我们尝试一种流畅的设置方法,而不是这样。在我们的构造函数中,我们只有必填字段,其余的都将变成设置器。因此,要创建一个新的电子邮件,我们不再需要做以下操作:

   val mail = Mail("manager@company.com") 
   mail.title("Ping") 
   mail.cc(listOf<String>()) 

相反,我们将这样做:

Mail("manager@company.com").title("Ping").cc(listOf<String>())

流畅设置器允许我们将一个设置调用链接到另一个。

这在很多方面都更加方便:

  • 字段的顺序现在可以是任意的,与构造函数中使用的顺序不同。

  • 现在更清楚哪个字段正在被设置;不再需要注释。

  • 可选字段根本不需要设置。例如,CC 字段被设置,而 BCC 字段被省略。

让我们看看实现这种方法的一种方式。还有其他方便的方法来做这件事,我们将在第十章第二百三十六部分“惯用句和反模式”中讨论:

   data class Mail(// Stays the same 
                   private var _message: String = "", 
                   // ...) { 
       fun message(message: String) : Mail { 
           _message = message 
return this } 
       // Pattern repeats for every other variable 
   } 

在 Kotlin 中,使用下划线作为私有变量的命名约定是一种常见做法。这允许我们避免重复短语 this.message = message 和错误,例如 message = message

这很好,并且非常类似于我们可能在 Java 中实现的效果,尽管我们现在不得不使我们的消息可变。

当然,我们也可以实现一个完整的构建器设计模式:

class MailBuilder(val to: String) { 
    private var mail: Mail = Mail(to) 
    fun title(title: String): MailBuilder { 
        mail.title = title 
        return this 
    }
    // Repeated for other properties 
    fun build(): Mail { 
        return mail 
    } 
} 

你可以用以下方式创建你的电子邮件:

val email = MailBuilder("hello@hello.com").title("What's up?").build()

但 Kotlin 提供了两种其他方法,你可能觉得它们更有用。

原型

这个设计模式完全是关于定制和创建相似但略有不同的对象。为了更好地理解它,我们将从一个例子开始。

构建你的电脑

想象一下,你有一个卖电脑的商店。

普通电脑由以下组成:

  • 主板

  • CPU

  • 图形卡

  • 内存

大多数顾客实际上并不关心你在这台电脑中放入了什么组件。他们真正关心的是这台电脑是否能够在 60fps(每秒帧数)下运行《壮丽盗车 7》

因此,你决定这样构建它:

data class PC(val motherboard: String = "Terasus XZ27",
             val cpu: String = "Until Atom K500",
             val ram: String = "8GB Microcend BBR5",
             val graphicCard: String = "nKCF 8100TZ")

所以当一位新顾客进来,想要尝试在邻里中大家都在谈论的这个游戏时,你只需这样做:

val pc = PC()

他们已经朝着家的方向出发,准备分享他们从 MPC7 获得的新经验。实际上,你的生意做得如此好,以至于你有一台电脑就放在那里,随时准备迎接下一个顾客的到来。

但然后又来了一位顾客。这位顾客很懂技术。所以,坦白说,他们认为对于他们玩的游戏来说,nKCF 8100TZ 图形卡根本不够。他们也了解到现在有BBR6 内存可用,他们想要16GB。当然,他们希望立即得到。但他们愿意用现金支付。

那就是你想对仓库里坐着的这台电脑稍作修改的时候,而不是组装一台新的。

从原型开始

原型的整个想法是能够轻松地克隆一个对象。你可能有很多原因想要这样做:

  • 创建你的对象非常昂贵。你需要从数据库中检索它。

  • 你创建的对象彼此相似但不同,你不想一遍又一遍地重复相似的部分。

使用这个模式还有更多高级的理由。例如,JavaScript 语言使用原型来实现类似于类的继承行为,而不使用类。

幸运的是,Kotlin 修复了损坏的Java clone() 方法。对于数据类,有copy()方法,它接受一个现有的数据类,并创建它的一个新副本,在此过程中可以选择更改一些属性:

val pcFromWarehouse = PC() // Our boring PC

val pwnerPC = pcFromWarehouse.copy(graphicCard = "nKCF 8999ZTXX",
        ram = "16GB BBR6") // Amazing PC

println(pwnerPC) // Make sure that PC created correctly

默认情况下,clone()方法创建一个浅拷贝,这可能对经验不足的开发者来说是不预期的。在 Java 中正确实现clone()方法非常困难。你可以在dzone.com/articles/shallow-and-deep-java-cloning上阅读关于各种陷阱的文章。

与我们在 Builder 设计模式中看到的情况类似,命名参数允许我们以任意顺序指定可以更改的属性。

剩下的唯一事情就是数现金,再买一些那些nKCF 图形卡。以防万一。

摘要

在本章中,我们学习了何时以及如何使用创建型家族中的设计模式。我们了解了object关键字的不同用法:作为单例、作为静态工厂方法的容器,以及作为接口匿名实现的实现。然后,我们通过使用inoutwhere关键字,在 Kotlin 中看到了解构声明和泛型的工作原理。我们还学习了默认参数值和命名参数,随后是数据类的copy()函数。

在下一章中,我们将介绍设计模式的第二组,即结构型模式。这些模式有助于扩展我们对象的功能。

第三章:理解结构型模式

本章涵盖了 Kotlin 中的结构型模式。一般来说,结构型模式处理对象之间的关系。

我们将讨论如何在不产生复杂的类层次结构的情况下扩展我们对象的功能,以及如何适应未来的变化或如何修复过去所做的某些决定,以及如何减少我们程序的内存占用。

在本章中,我们将涵盖以下主题:

  • 装饰者

  • 适配器

  • 桥接

  • 组合

  • 外观

  • 享元

  • 代理

装饰者

在上一章中,我们讨论了原型设计模式,它允许创建具有略微(或不是那么略微)不同数据的类的实例。

如果我们想要创建一组具有略微不同行为的类呢?好吧,由于 Kotlin 中的函数是一等公民(更多内容将在后面讨论),你可以使用原型设计模式来实现这一点。毕竟,这正是 JavaScript 成功做到的事情。但本章的目标是讨论解决同一问题的另一种方法。毕竟,设计模式都是关于方法的。

通过实现这个设计模式,我们允许我们的代码用户指定他们想要添加的能力。

增强类

你的老板——抱歉,敏捷大师——昨天向你提出了一个紧急要求。从现在开始,你系统中的所有映射数据结构都必须变成HappyMaps

那么,你不知道什么是HappyMaps吗?它们是目前最热门的东西。它们就像常规的HashMap一样,但是当你覆盖现有的值时,它们会打印出以下输出:

Yay! Very useful

所以,你在编辑器中输入以下代码:

class HappyMap<K, V>: HashMap<K, V>() {
    override fun put(key: K, value: V): V? {
        return super.put(key, value).apply {
            this?.let {
                println("Yay! $key")
            }
        }
    }
}

当我们在上一章讨论构建者设计模式时已经看到了apply(),而this?.let { ... }是一个更优雅的方式来表达if (this != null) { ... }

我们可以使用以下代码来测试我们的解决方案:

fun main(args : Array<String>) {
    val happy = HappyMap<String, String>()
    happy["one"] = "one"
    happy["two"] = "two"
    happy["two"] = "three"
}

前面的代码按预期打印以下输出:

Yay! two

那就是唯一被重载的关键。

运算符重载

稍等一下,当我们扩展一个映射时,方括号是如何继续工作的?它们不是某种魔法吗?

嗯,实际上没有。这里没有魔法。正如你可能从本节标题猜到的,Kotlin 支持运算符重载。运算符重载意味着同一个运算符根据它接收到的参数类型的不同而表现出不同的行为。

如果你曾经使用过 Java,你已经熟悉运算符重载了。想想加号运算符是如何工作的。让我们看看这里给出的例子:

System.out.println(1 + 1); // 2
System.out.println("1" + "1") // 11

根据两个参数是字符串还是整数,+符号的行为不同。

但是,在 Java 的世界里,这只有语言本身才能做到。无论我们怎么尝试,以下代码都无法编译:

List<String> a = Arrays.asList("a");
List<String> b = Collections.singletonList("b"); // Same for one argument
List<String> c = a + b;

在 Java 9 中,还有一个List.of(),它服务于与Arrays.asList()类似的目的。

在 Kotlin 中,相同的代码打印出[a, b]

val a = listOf("a")
val b = listOf("b")
println(a + b)

好吧,这很有道理,但也许它只是语言特性:

data class Json(val j: String)
val j1 = Json("""{"a": "b"}""")
val j2 = Json("""{"c": "d"}""")
println(j1 + j2) // Compilation error!

我告诉过您,这很神奇!您不能简单地将两个任意的类组合在一起。

但等等。如果我们为我们的Json类创建一个扩展函数plus(),如下所示:

operator fun Json.plus(j2: Json): Json {
   // Code comes here
}

除了第一个关键字operator之外,其他所有内容都应该对您很熟悉。我们通过添加一个新的函数来扩展Json对象,该函数获取另一个Json并返回Json

我们实现函数体是这样的:

val jsonFields = this.j.split(":") + j2.j.split(":")
val s = (jsonFields).joinToString(":")
return Json ("""{$s}""")

这实际上并不是连接任何 JSON,而是在我们的例子中连接Json。我们从我们的Json中取值,从另一个Json中取值,然后将它们连接起来,并在它们周围加上一些花括号。

现在看看这一行:

println(j1 + j2)

上述代码会打印出以下输出:

{{"a": "b"}:{"c": "d"}}

实际上,它会打印出:Json(j={{"a": "b"}}:{"c": "d"}})。这是因为我们为了简洁起见,在示例中没有重写toString()方法。

那么,这个operator关键字是什么意思呢?

与其他一些语言不同,您不能覆盖 Kotlin 语言中存在的每个操作符,而只能选择一些。

尽管有限,但可以重写的所有操作符列表相当长,所以我们在这里不列出。您可以在官方文档中查阅:

kotlinlang.org/docs/reference/operator-overloading.html

尝试将您的扩展方法重命名为:

  • prus(): 只是一个打字错误的名称

  • minus(): 与减号-相关联的现有函数

您会发现您的代码无法编译。

我们一开始使用的方括号被称为索引访问操作符,与get(x)set(x, y)方法相关联。

嘿,我的映射在哪里?

第二天,您的产品经理联系您。显然,他们现在想要一个SadMap,每次从其中删除一个键时,它都会变得悲伤。按照之前的模式,您再次扩展了映射:

class SadMap<K, V>: HashMap<K, V>() {
    override fun remove(key: K): V? {
        println("Okay...")
        return super.remove(key)
    }
}

但然后首席架构师要求在某些情况下,一个映射可以同时感到高兴和悲伤。CTO 已经有一个关于SuperSadMap的伟大想法,它将打印出以下输出两次:

Okay...

那么,我们需要的是结合我们对象行为的能力。

伟大的组合者

这次我们将有所不同。我们不会逐个组合解决方案,而是先看完整的解决方案,然后分解它。这里的代码将帮助您理解原因:

class HappyMap<K, V>(private val map: MutableMap<K, V> =                                        mutableMapOf()) : 
      MutableMap<K, V> by map {

    override fun put(key: K, value: V): V? {
        return map.put(key, value).apply {
            this?.let { println("Yay! $key") }
        }
    }
}

这里的难点在于理解签名。在装饰器模式中,我们需要的是:

  • 能够接收我们正在装饰的对象

  • 保留对其的引用

  • 当我们的装饰器被调用时,我们决定是否要改变我们持有的对象的行为,或者要委托调用

由于我们需要实际做很多事情,这个声明相当复杂。毕竟,它在一行中做了很多事情,这应该相当令人印象深刻。让我们逐行分解:

class HappyMap<K, V>(...

我们班级的名字是HappyMap,它有两个类型参数,KV,分别代表

... (private val map: MutableMap<K, V> ...

在我们的构造函数中,我们接收带有类型KVMutableMap,与我们的相同:

... = mutableMapOf()) ...

如果没有传递映射,我们使用默认参数值初始化我们的属性,这个默认值是一个空的可变映射:

... : MutableMap<K, V> ...

我们的这个类扩展了MutableMap接口:

... by map

它也将所有未重写的方法委托给我们将要包装的对象,在我们的例子中是一个映射。

使用代理的SadMap代码被省略了,但你可以通过组合HappyMap的声明和之前SadMap的实现轻松地重新生成它。

现在我们来组合我们的SadHappyMap,以取悦首席架构师:

val sadHappy = SadMap(HappyMap<String, String>())
sadHappy["one"] = "one"
sadHappy["two"] = "two"

sadHappy["two"] = "three"
sadHappy["a"] = "b"
sadHappy.remove("a")

我们得到以下输出:

Yay! two // Because it delegates to HappyMap
Okay...  // Because it is a SadMap

同样地,我们现在可以创建SuperSadMap

val superSad = SadMap(HappyMap<String, String>())

我们也可以取悦 CTO。

装饰器设计模式在java.io.*包中广泛使用,包括如 reader 和 writer 等类。

注意事项

装饰器设计模式很棒,因为它让我们可以即时组合对象。使用 Kotlin 的by关键字将使其简单易行。但仍然有一些限制,你需要注意。

首先,你无法看到装饰器的内部

println(sadHappy is SadMap<*, *>) // True

那是顶层包装器,所以没问题:

println(sadHappy is MutableMap<*, *>) // True

那就是我们实现的接口,所以编译器知道它:

println(sadHappy is HappyMap<*, *>) // False

尽管SadMap包含HappyMap并且可能表现得像它,但它并不是一个HappyMap!在执行类型转换和类型检查时,请记住这一点。

第二点,与第一点相关,是装饰器通常不知道它直接包装的是哪个类,这很难进行优化。想象一下我们的 CTO 要求SuperSadMap打印Okay... Okay...然后就是它,在同一行。为了做到这一点,我们需要捕获整个输出,或者调查我们将要包装的所有类,这些任务相当复杂。

使用这个强大的设计模式时,请记住这些要点。它允许动态地向对象添加新责任(在我们的例子中,打印Yay是一种责任),而不是通过子类化对象。每个新的责任都是你添加的新包装层。

适配器

适配器,或者有时被称为包装器,其主要目标是转换一个接口到另一个接口。在物理世界中,最好的例子就是一个电源插头适配器,或者一个 USB 适配器。

想象一下自己在晚上很晚的时候,手机电量只剩下 7%。你的手机充电器忘在了办公室,在城市的另一头。你只有一款欧盟插头充电器和一根 USB 迷你线。但你的手机是 USB Type-C,因为你不得不升级。而且你人在纽约,所以所有的插座当然都是 US Type-A。你该怎么办?哦,很简单。你在半夜找一根 USB 迷你转 USB Type-C 适配器,并希望在这个过程中也不要忘记带上那个欧盟转 US 插头适配器。电量只剩下 5%,时间正在流逝。

因此,现在我们更好地理解了适配器在物理世界中的作用,让我们看看我们如何在代码中应用同样的方法。

让我们从接口开始:

interface UsbTypeC
interface UsbMini

interface EUPlug
interface USPlug

现在,我们可以声明一个手机和一个电源插座:

// Power outlet exposes USPlug interface
fun powerOutlet() : USPlug {
    return object : USPlug {}
}

fun cellPhone(chargeCable: UsbTypeC) {

}

当然,我们的充电器在各个方面都是错误的:

// Charger accepts EUPlug interface and exposes UsbMini interface
fun charger(plug: EUPlug) : UsbMini {
    return object : UsbMini {}
}

这里我们遇到了以下错误:

Type mismatch: required EUPlug, found USPlug: charger(powerOutlet())

Type mismatch: required UsbTypeC, found UsbMini: cellPhone(charger(powerOutlet()))

不同的适配器

因此,我们需要两种类型的适配器。

在 Java 中,你通常会为此创建一对类。在 Kotlin 中,我们可以用扩展函数来替换它们。

我们可以通过以下扩展函数采用美国插头来与欧盟插头一起工作:

fun USPlug.toEUPlug() : EUPlug {
    return object : EUPlug {
        // Do something to convert 
    }
}

我们可以用类似的方式创建一个迷你 USB 和 type-C USB 之间的 USB 适配器:

fun UsbMini.toUsbTypeC() : UsbTypeC {
    return object : UsbTypeC {
        // Do something to convert
    }
}

最后,通过将这些适配器组合在一起,我们重新上线:

cellPhone(
    charger(
        powerOutlet().toEUPlug()
    ).toUsbTypeC()
)

如你所见,我们不需要在对象内部组合一个对象来适配它们。幸运的是,我们也不需要继承接口和实现。有了 Kotlin,我们的代码保持简短而直接。

适配器在现实世界中的应用

你可能也遇到过这些适配器。大多数情况下,它们在概念实现之间进行适配。例如,让我们以集合的概念与的概念为例:

val l = listOf("a", "b", "c")

fun <T> streamProcessing(stream: Stream<T>) { 
    // Do something with stream
}

即使这样做在逻辑上可能合理,你也不能简单地将一个集合传递给接收流的功能:

streamProcessing(l) // Doesn't compile

幸运的是,集合为我们提供了.stream()方法:

streamProcessing(l.stream()) // Adapted successfully

使用适配器的注意事项

你有没有通过适配器将 110v 的美国电器插入 220v 的欧盟插座,然后完全烧毁过?

如果你不小心,这可能会发生在你的代码上。以下使用另一个适配器的示例可以编译:

fun <T> collectionProcessing(c: Collection<T>) {
    for (e in c) {
        println(e)
    }
}

val s = Stream.generate { 42 }
collectionProcessing(s.toList())

但是它永远不会完成,因为Stream.generate()产生了一个无限整数列表。所以,要小心,并且明智地应用这个模式。

桥接

与我们遇到的其他设计模式不同,桥接模式更少关注于以智能方式组合对象,而是更多地关于如何不滥用继承的指导原则。它的工作方式实际上非常简单。

让我们回到我们正在构建的策略游戏。我们为所有的步兵单位都有一个接口:

interface Infantry {
    fun move(x: Long, y: Long)

    fun attack(x: Long, y: Long)
}

我们有具体的实现:

open class Rifleman : Infantry {
    override fun attack(x: Long, y: Long) {
        // Shoot
    }

    override fun move(x: Long, y: Long) {
        // Move at its own pace
    }
}

open class Grenadier : Infantry {
    override fun attack(x: Long, y: Long) {
        // Throw grenades
    }

    override fun move(x: Long, y: Long) {
        // Moves slowly, grenades are heavy
    }
}

如果我们想要升级我们的单位的能力呢?

升级后的单位应该有双倍的伤害,但移动速度保持不变:

class UpgradedRifleman : Rifleman() {
    override fun attack(x: Long, y: Long) {
        // Shoot twice as much
    }
}

class UpgradedGrenadier : Grenadier() {
    override fun attack(x: Long, y: Long) {
        // Throw pack of grenades
    }
}

现在,我们的游戏设计师决定我们也需要这些单位的轻量版。也就是说,它们以与常规单位相同的方式攻击,但移动速度是它们的两倍:

class LightRifleman : Rifleman() {
    override fun move(x: Long, y: Long) {
        // Running with rifle
    }
}

class LightGrenadier : Grenadier() {
    override fun move(x: Long, y: Long) {
        // I've been to gym, pack of grenades is no problem
    }
}

由于设计模式都是关于适应变化的,所以我们的设计师来了,并要求所有步兵单位都能够呼喊,也就是说,大声清晰地宣布他们的单位名称:

interface Infantry {
    // As before, move() and attack() functions

    fun shout() // Here comes the change
}

我们现在该做什么呢?

我们去更改六个不同类的实现,幸运的是只有六个而不是十六个。

桥接变化

根据你的看法,桥接设计模式可能类似于我们已讨论的适配器,或者我们在下一章将要讨论的策略

桥接设计模式背后的想法是简化当前深度为三级的类层次结构:

Infantry --> Rifleman  --> Upgraded Rifleman                                                                           --> Light Rifleman             
         --> Grenadier --> Upgraded Grenadier       
                       --> Light Grenadier

为什么我们有这样一个复杂的层次结构?

这是因为我们有三个正交属性:武器类型、武器强度和移动速度。

也就是说,如果我们把那些属性传递给一个实现我们一直使用的相同接口的类的构造函数:

class Soldier(private val weapon: Weapon,
              private val legs: Legs) : Infantry {
    override fun attack(x: Long, y: Long) {
        // Find target
        // Shoot
        weapon.causeDamage()
    }

    override fun move(x: Long, y: Long) {
        // Compute direction
        // Move at its own pace
        legs.move()
    }
}

Soldier接收的属性应该是接口,这样我们就可以稍后选择它们的实现:

interface Weapon {
    fun causeDamage(): PointsOfDamage
}

interface Legs {
    fun move(): Meters
}

MetersPointsOfDamage是什么?它们是我们声明在某个地方的类或接口吗?

让我们短暂地偏离一下。

类型别名

首先,我们将看看它们是如何声明的:

typealias PointsOfDamage = Long
typealias Meters = Int

我们在这里使用了一个新的关键字,typealias。从现在起,我们可以使用Meters而不是普通的Int来从我们的move()方法返回。它们不是新类型。Kotlin 编译器在编译过程中始终会将PointsOfDamage转换为Long。使用它们提供了两个优点:

  • 更好的语义,正如我们的情况。我们可以确切地知道我们返回的值的含义。

  • Kotlin 的主要目标之一是简洁。类型别名允许我们隐藏复杂的泛型表达式。我们将在接下来的章节中展开讨论。

你现在在军队里了

回到我们的Soldier类。我们希望它尽可能适应,对吧?他知道他可以移动或使用他的武器来做出更大的贡献。但他究竟会如何做呢?

我们完全忘记了实现这些部分!让我们从我们的武器开始:

class Grenade : Weapon {
    override fun causeDamage() = GRENADE_DAMAGE
}

class GrenadePack : Weapon {
    override fun causeDamage() = GRENADE_DAMAGE * 3
}

class Rifle : Weapon {
    override fun causeDamage() = RIFLE_DAMAGE
}

class MachineGun : Weapon {
    override fun causeDamage() = RIFLE_DAMAGE * 2
}

现在,让我们看看我们如何移动:

class RegularLegs : Legs {
    override fun move() = REGULAR_SPEED
}

class AthleticLegs : Legs {
    override fun move() = REGULAR_SPEED * 2
}

常量

我们将所有参数定义为常量:

const val GRENADE_DAMAGE : PointsOfDamage = 5L
const val RIFLE_DAMAGE = 3L
const val REGULAR_SPEED : Meters = 1

这些值非常有效,因为它们在编译时是已知的。

与 Java 中的static final变量不同,它们不能放在类内部。你应该将它们放在你的包的顶层,或者嵌套在object内部。

注意,尽管 Kotlin 有类型推断,但我们仍然可以明确指定常量的类型,甚至可以使用类型别名。那么,在你的代码中使用DEFAULT_TIMEOUT : Seconds = 60而不是

DEFAULT_TIMEOUT_SECONDS = 60如何?

一种致命的武器

剩下的就是让我们看到,在新层次结构中,我们仍然可以做完全相同的事情:

val rifleman = Soldier(Rifle(), RegularLegs())
val grenadier = Soldier(Grenade(), RegularLegs())
val upgradedGrenadier = Soldier(GrenadePack(), RegularLegs())
val upgradedRifleman = Soldier(MachineGun(), RegularLegs())
val lightRifleman = Soldier(Rifle(), AthleticLegs())
val lightGrenadier = Soldier(Grenade(), AthleticLegs())

现在,我们的层次结构看起来是这样的:

Infantry --> Soldier

Weapon --> Rifle
       --> MachineGun
       --> Grenade
       --> GrenadePack 

Legs --> RegularLegs
     --> AthleticLegs

更简单地进行扩展和理解。与之前讨论的一些其他设计模式不同,我们没有使用任何我们不了解的特殊语言特性,只是使用了一些工程最佳实践。

组合

你可能在这个部分结束时会有一种这种感觉,这个模式有点尴尬。这是因为它有一个灵魂伴侣,它的伴随模式,迭代器,我们将在下一章讨论。当两者结合时,它们才能真正发光。所以,如果你感到困惑,在你熟悉了迭代器之后,再回到这个模式。

话虽如此,我们可以开始分析这个模式。有一个组合设计模式可能看起来有点奇怪。毕竟,所有结构模式不都是关于组合对象吗?

与桥接设计模式的情况类似,名称可能并不反映其真正的优势。

一起

回到我们的策略游戏中,我们有一个新的概念:一个排。一个排由零个或多个步兵单位组成。这将是复杂数据结构的一个很好的例子。

这里是我们拥有的接口和类:

interface InfantryUnit

class Rifleman : InfantryUnit

class Sniper : InfantryUnit

你会如何实现它?我们将在下一节中看到。

Squad,显然,必须是一组步兵单位。所以,这应该很简单:

class Squad(val infantryUnits: MutableList<InfantryUnit> =         mutableListOf())

我们甚至设置了一个默认参数值,这样其他程序员就不需要传递他自己的士兵列表,除非他真的需要。

为了确保它能够工作,我们将创建三个士兵并将他们放入其中:

val miller = Rifleman()
val caparzo = Rifleman()
val jackson = Sniper()

val squad = Squad()

squad.infantryUnits.add(miller)
squad.infantryUnits.add(caparzo)
squad.infantryUnits.add(jackson)

println(squad.infantryUnits.size) // Prints 3

但第二天,Dave,那位程序员,带着新的要求来找我们。他认为添加士兵需要太多的代码行,甚至使用mutableListOf()传递它们。

他希望像这样初始化排:

val squad = Squad(miller, caparzo, jackson)

看起来不错,但究竟我们该如何做到这一点呢?

可变参数和次要构造函数

到目前为止,我们一直在使用类的默认构造函数。这是在类名之后声明的那个。但在 Java 中,我们可以为类定义多个构造函数。为什么 Kotlin 限制我们只能有一个?

实际上,并不是这样。我们可以使用constructor关键字为类定义次要构造函数:

class Squad(val infantryUnits: MutableList<InfantryUnit> = mutableListOf()) {
    constructor(first: InfantryUnit) : this(mutableListOf()) {
        this.infantryUnits.add(first)
    }

    constructor(first: InfantryUnit, 
                second: InfantryUnit) : this(first) {
        this.infantryUnits.add(second)
    }

    constructor(first: InfantryUnit, 
                second: InfantryUnit, 
                third: InfantryUnit) : 
        this(first, second) {
        this.infantryUnits.add(third)
    }
}

注意我们如何将一个构造函数委托给另一个:

    constructor(first: InfantryUnit) : this(mutableListOf()) {
    }                                     ⇑
                                          ⇑
    constructor(first: InfantryUnit,      ⇑ // Delegating
                second: InfantryUnit) : this(first) {    
    }

但这显然不是正确的方法,因为我们无法预测 Dave 可能传递给我们多少个元素。如果你来自 Java,你可能已经考虑过可变参数函数,它可以接受任意数量的相同类型的参数。在 Java 中,你会将参数声明为InfantryUnit... units

Kotlin 为我们提供了vararg关键字来达到同样的目的。结合这两种方法,我们得到了以下这段不错的代码:

class Squad(val infantryUnits: MutableList<InfantryUnit> =     mutableListOf()) {

    constructor(vararg units: InfantryUnit) : this(mutableListOf()) {
        for (u in units) {
            this.infantryUnits.add(u)
        }
    }
}

计算子弹数量

游戏设计师在傍晚时分抓住你,当然是在你准备回家的那一刻。他想为整个排添加弹药计数,这样每个排都能报告它还剩下多少弹药:

fun bulletsLeft(): Long {
    // Do your job
}

有什么问题吗?

好吧,你看,狙击手有单独的子弹作为弹药:

class Bullet

步兵将子弹存放在弹匣中:

class Magazine(capacity: Int) {
    private val bullets = List(capacity) { Bullet() }
}

幸运的是,你的排里还没有机枪手,因为他们携带弹药在弹带上...

所以,你有一个可能嵌套也可能不嵌套的复杂结构。你需要对这个结构作为一个整体执行某些操作。这正是组合设计模式真正发挥作用的地方。

你看,这个名字有点令人困惑。组合(Composite)与其说是关于组合对象,不如说是关于将不同类型的对象作为同一棵树的节点来处理。为此,它们都应该实现相同的接口。

这可能一开始并不明显。毕竟,一个步兵显然不是一个。但与其将接口视为一种is-a关系,我们更应该将其视为一种能力提供者。例如,Android 经常采用这种模式。

我们的能力是计数子弹:

interface CanCountBullets {
    fun bulletsLeft(): Int
}

SquadInfantryUnit都应该实现此接口:

interface InfantryUnit : CanCountBullets

class Squad(...) : CanCountBullets {
    ...
}

class Magazine(...): CanCountBullets {
    ...
}

现在,由于每个人都有同样的能力,无论嵌套有多深,我们都可以要求顶层对象查询其下的一切。

MagazineSniper简单地计数它们包含的子弹。以下示例展示了我们如何跟踪Magazines中的子弹数量:

class Magazine(...): CanCountBullets {
    ...
    override fun bulletsLeft() = bullets.size
}

以下示例展示了我们如何跟踪狙击手Sniper拥有的子弹数量:

class Sniper(initalBullets: Int = 50) : InfantryUnit {
    private val bullets = List(initalBullets) { Bullet () }
    override fun bulletsLeft() = bullets.size
}

对于Rifleman,我们可以检查他们的Magazines并查看它们包含多少子弹:

class Rifleman(initialMagazines: Int = 3) : InfantryUnit {
    private val magazines = List<Magazine>(initialMagazines) {
        Magazine(5)
    }

    override fun bulletsLeft(): Int {
        return magazines.sumBy { it.bulletsLeft() }
    }
}

最后,对于小队,我们计算小队包含的所有单位的计数总和:

override fun bulletsLeft(): Int {
    return infantryUnits.sumBy { it.bulletsLeft() }
}

明天,当你的产品经理突然发现他需要实现一个排(这是一个小队的集合),你将装备齐全,准备就绪。

外观

在不同的实现和方案中,外观可能类似于适配器抽象工厂

它的目标似乎很简单——简化与另一个类或类族交互:

  • 当我们想到简化时,我们通常想到适配器设计模式

  • 当我们想到类族时,我们通常想到抽象工厂

那就是所有混淆通常都来自的地方。为了更好地理解它,让我们回到我们用于抽象工厂设计模式的示例。

保持简单

假设我们想要实现loadGame()方法。这个方法将需要一个我们已创建的文件(我们稍后会讨论),或者至少需要以下内容:

  1. 至少需要创建两个总部(否则游戏已经胜利)

  2. 每个总部都必须生产它拥有的建筑

  3. 每个建筑都必须生产它拥有的单位

  4. 所有单位都必须神奇地传送到游戏保存时它们所在的位置

  5. 如果给单位下达了任何命令(比如摧毁所有敌方基地),它们应该继续执行这些命令

我们将在下一章讨论我们实际上如何通过命令设计模式向我们的单位下达命令,敬请期待。

现在,通常情况下,除非是Minecraft (TM),否则不会只有一个人在制作游戏。还有那个其他的人,Dave,他处理所有的命令逻辑。他对建造建筑不太感兴趣。但在他的角色中,他也需要经常加载保存的游戏。

作为所有属于你的基地的开发者,你可以给他一套你写的指令,说明游戏应该如何正确加载。他可能会或可能不会遵循这套指令。也许他会忘记移动单位或建造建筑。游戏可能会崩溃。你可以使用外观设计模式来简化他的工作。

Dave 现在面临的主要问题是什么?

要加载游戏,他需要与至少三个不同的界面进行交互:

  • 总部

  • 建筑物

  • 单位

他希望只有一个接口,类似于:

interface GameWorld

那正好是他需要的所有方法:

fun loadGame(file: File) GameWorld

嘿,但那看起来像是一个静态工厂方法!

是的,有时候,设计模式是相互嵌入的。我们使用静态工厂方法来创建我们的类,但它的目标是作为其他更复杂类的门面。使用门面并不意味着我们不向客户端暴露门面背后隐藏的接口。Dave 在游戏加载成功后仍然可以使用每个小单元发出命令。

简单,对吧?

轻量级模式

轻量级模式是一个没有任何状态的对象。这个名字来源于非常轻

如果你已经阅读了前两章中的任意一章,你可能已经想到了一种应该非常轻的对象类型:data类。但data类完全是关于状态的。那么,它与轻量级设计模式有什么关系呢?

为了更好地理解这个模式,我们需要回顾二十年前。

回到 1994 年,当原始的《设计模式》一书出版时,你的普通 PC 有 4 MB 的 RAM。其中一个主要目标就是节省那宝贵的 RAM,因为你可以将其装入其中的内容是有限的。

现在,一些手机有 4 GB 的 RAM。当我们讨论轻量级设计模式时,请记住这个事实。

保守的做法

想象一下我们正在构建一个 2D 横向卷轴街机平台游戏。也就是说,你有你的角色,你可以用箭头键或游戏手柄来控制它。你的角色可以向左、向右移动,并且可以跳跃。

由于我们是一家由一名开发者(同时也是图形设计师、产品经理和销售代表)、两只猫和一只名叫迈克尔的金丝雀组成的小型独立公司,我们在游戏中只使用了十六种颜色。我们的角色高 64 像素,宽 64 像素。我们称他为Maronic,这也是我们游戏的名字。

我们的角色有很多敌人,主要由食肉性的坦桑尼亚蜗牛组成:

class TansanianSnail

由于它是一个 2D 游戏,每个蜗牛只有两个移动方向——向左和向右:

enum class Direction {
   LEFT,
   RIGHT
}

每个蜗牛持有一对图像和一个方向:

class TansanianSnail() {
   val directionFacing = Direction.LEFT

   val sprites = listOf(java.io.File("snail-left.jpg"), 
                        java.io.File("snail-right.jpg"))
}

我们可以获取当前显示蜗牛面向哪个方向的精灵:

    fun TansanianSnail.getCurrentSprite() : java.io.File {
        return when (directionFacing) {
            Direction.LEFT -> sprites[0]
            Direction.RIGHT -> sprites[1]
        }
    }

我们可以在屏幕上绘制它:

      _____
  \|_\___  \
  /________/    <-- With a bit of imagination you'll see it's a snail

但当它移动时,它基本上只是向左或向右滑动。我们希望有三个动画精灵来重现蜗牛的动作:

---------------------------------------------------------------------
     _____ |    _____ |    _____ |    _____ |    _____ |    _____        
 _|_/___∂ \|\|_/___∂ \|\/_/___∂ \|\|_/___∂ \|\|_/___∂ \|\|_/___∂ \
 /____\___/| \___\___/|/____\___/|/____\___/|/____\___/|/____\___/
---------------------------------------------------------------------
left-3      left-2     left-1     right-1    right-2    right-3      

要在我们的代码中实现它:

    val sprites = List(8) { i ->
              java.io.File(when {
                        i == 0 -> "snail-left.jpg"
                        i == 1 -> "snail-right.jpg"
                        i in 2..4 -> "snail-move-left-${i-1}.jpg"
                        else -> "snail-move-right${(4-i)}.jpg"
                    })
            }

我们通过传递一个block函数作为构造函数初始化一个包含八个元素的列表。对于每个元素,我们决定获取哪个图像:

  • 位置 0 和 1 用于静止图像

  • 位置 2 到 4 用于向左移动

  • 位置 5 到 7 用于向右移动

现在让我们来做一些数学计算。每个蜗牛是一个 64 x 64 的图像。假设每种颜色正好占用一个字节,单个图像在内存中占用 4 KB 的 RAM。由于一个蜗牛有八个图像,我们需要为每个蜗牛分配 32 KB 的 RAM,这样我们才能在 1 MB 的内存中容纳 32 只蜗牛。

由于我们希望在屏幕上拥有成千上万这种危险且极快的生物,并且能够在十年前的手机上运行我们的游戏,因此我们显然需要一个更好的解决方案。

节省内存

我们蜗牛的问题是什么?它们实际上相当胖,是重量级的蜗牛。

我们希望把它们放在节食中。每个蜗牛在其蜗牛身体内存储了八张图片。但实际上,这些图片对每个蜗牛来说都是相同的。

如果我们提取那些精灵:

val sprites = List(8) { i ->
    java.io.File(when {
        i == 0 -> "snail-left.jpg"
        i == 1 -> "snail-right.jpg"
        i in 2..4 -> "snail-move-left-${i-1}.jpg"
        else -> "snail-move-right${(4-i)}.jpg"
    })
}

然后我们每次都把这个列表传递给getCurrentSprite()函数:

fun TansanianSnail.getCurrentSprite(sprites: List<java.io.File>) :     java.io.File { ... }

这样,无论我们生成多少蜗牛,我们只会消耗 256 KB 的内存。我们可以生成数百万个,而不会影响我们程序的大小。

当然,我们应该担心我们传递的数据的不可变性。这意味着在任何时候,我们都不能将null分配给我们的sprites变量,如下所示:

sprites = null

这样会产生NullPointerException。而且,如果有人要clear()这个列表,那将会是灾难性的:

sprites.clear()

幸运的是,Kotlin 为我们处理了这个问题。因为我们使用了val,列表被精确地分配了一次。而且,因为我们使用了 List,它产生了一个不可变列表,这个列表不能被更改或清除。

所有的前述行甚至无法编译:

sprites.clear()
sprites[0] = File("garbage")
sprites[0] = null

即使现在内存很充足,你仍然可以争论这种模式的有用性。但,正如我们之前所说的,工具箱中的工具并不占多少空间,拥有另一个设计模式可能仍然是有用的。

代理

这是一个行为不当的设计模式。就像装饰者一样,它扩展了对象的功能。但是,与总是按指示行事的装饰者不同,拥有一个代理可能意味着当被要求时,对象会做完全不同的事情。

简单地进入 RMI 世界

在讨论代理时,许多资料,尤其是与 Java 相关的资料,会转向讨论另一个概念,RMI。

在 JVM 世界中,RMI 代表远程方法调用,这是一种远程过程调用(RPC)。这意味着你能够调用一些不在你本地机器上,而是在某个远程机器上的代码。

虽然这是一个非常聪明的解决方案,但它非常特定于 JVM,在微服务时代已经变得不那么流行了,因为在微服务时代,每一块代码可能都是用完全不同的编程语言编写的。

一个替代方案

当我们讨论创建型模式时,我们已经讨论了昂贵对象的想法。例如,访问网络资源的对象,或者创建需要花费很多时间的对象。

Funny Cat App(由金丝雀 Michael 发明;还记得他从 Flyweight 模式中吗?)我们每天为用户提供有趣的猫图片。在我们的主页和移动应用程序上,每个用户都会看到很多有趣的猫缩略图。当他点击或触摸任何这些图片时,它就会扩展到全屏的辉煌。

但在网络中获取猫的图片非常昂贵,并且消耗大量内存,尤其是如果这些是那些在晚餐后倾向于再来一份甜点的猫的图片。所以,我们想要做的是拥有一个智能对象,某种可以自我管理的对象。

当第一个用户访问此图像时,它将通过网络获取。没有避免的方法。

但是当它第二次被访问时,无论是通过这个还是其他用户,我们都希望避免再次访问网络,而是返回之前缓存的那个结果。这就是我们所说的 行为不当 部分。与每次都通过网络访问的预期行为不同,我们有点懒,返回我们已经准备好的结果。这有点像走进一家便宜的小餐馆,点了一份汉堡,两分钟后就拿到了,但却是冷的。嗯,这是因为有人不喜欢洋葱,所以之前就把它退回厨房了。这是真的。

这听起来像很多逻辑。但是,正如你可能猜到的,特别是在遇到装饰器设计模式之后,Kotlin 可以通过减少你需要编写的样板代码来达到你的目标,从而创造奇迹:

data class CatImage(private val thumbnailUrl: String, 
                    private val url: String) {
    val image: java.io.File by lazy {
        // Actual fetch goes here
    }
}

如你所注意到的,我们使用 by 关键字将此字段的初始化委托给一个名为 lazy 的标准函数。

第一次调用 image 将会执行我们的代码块并将结果保存到 image 属性中。

有时,代理设计模式被分为三个子模式:

  • 虚拟代理:懒加载结果

  • 远程代理:向远程资源发出调用

  • 保护或访问控制代理:拒绝未经授权的访问

根据你的观点,你可以将我们的示例视为虚拟代理或虚拟和远程代理的组合。

懒加载委托

你可能会想知道如果两个线程同时尝试初始化图像会发生什么。默认情况下,lazy() 函数是同步的。只有一个线程会获胜,其他线程将等待图像准备好。

如果你不在乎两个线程执行 lazy 块(例如,它并不那么昂贵),你可以使用 by lazy(LazyThreadSafetyMode.PUBLICATION)

如果你绝对需要性能,并且你绝对确信两个线程永远不会同时执行相同的块,你可以使用 LazyThreadSafetyMode.NONE

概述

在本章中,我们学习了结构型设计模式如何帮助我们创建更灵活的代码,这些代码可以轻松适应变化,有时甚至可以在运行时进行。我们已经介绍了 Kotlin 中的操作符重载及其局限性。你应该知道如何使用 typealias 创建类型名的快捷方式,以及如何使用 const 定义有效的常量。

我们已经介绍了在 Kotlin 中如何通过实现相同的接口并使用 by 关键字来实现委托给另一个类的工作方式。

此外,我们还介绍了可以使用 vararg 接收任意数量参数的函数,以及使用 lazy 进行懒初始化。

在下一章中,我们将讨论经典设计模式的第三组:行为模式。

第四章:熟悉行为模式

本章讨论了 Kotlin 中的行为模式。行为模式处理对象之间的交互方式。

我们将看到对象如何根据情况以完全不同的方式表现,对象如何在不了解彼此的情况下进行通信,以及我们如何轻松地遍历复杂结构。

在本章中,我们将涵盖以下主题:

  • 策略

  • 迭代器

  • 状态

  • 命令

  • 责任链

  • 解释器

  • 调解者

  • 备忘录

  • 访问者

  • 模板方法

  • 观察者

策略

记得我们在第三章中讨论外观设计模式时设计的平台游戏Maronic吗?

好吧,作为我们小型独立游戏开发公司中的游戏设计师,迈克尔想出了一个很好的主意。如果我们给我们的英雄提供一套武器来保护我们免受那些可怕的肉食性蜗牛的伤害,会怎么样呢?

所有武器都会朝着我们英雄面对的方向发射弹丸(你不想靠近那些危险的蜗牛),:

enum class Direction {
    LEFT, RIGHT
}

所有弹丸都应该有一对坐标(记住,我们的游戏是 2D 的?)和一个方向:

abstract class Projectile(private val x: Int,
                          private val y: Int,
                          private val direction: Direction)

如果我们只发射一种类型的弹丸,那会很简单,因为我们已经在第二章中介绍了工厂模式,使用创建型模式

class OurHero {
    private var direction = Direction.LEFT
    private var x: Int = 42
    private var y: Int = 173

    fun shoot(): Projectile {
        return object : Projectile(x, y, direction) {
            // Draw and animate projectile here
        }
    }
}

但迈克尔希望我们的英雄至少有三种不同的武器:

  • 豌豆射手:发射直飞的豌豆。我们的英雄一开始就有它。

  • 石榴:击中敌人时会爆炸,就像手榴弹一样。

  • 香蕉:当它到达屏幕末端时会像回旋镖一样返回。

来吧,迈克尔,给我们点空间!你不能就坚持使用所有都一样的普通枪吗?!

果实军火库

首先,让我们讨论一下如何用Java 方式解决这个问题。

在 Java 中,我们会创建一个接口,来抽象变化。在我们的情况下,变化的是我们的英雄的武器:

interface Weapon {
    fun shoot(x: Int,
              y: Int,
              direction: Direction): Projectile
}

然后所有其他武器都会实现这个接口:

class Peashooter : Weapon {
    override fun shoot(x: Int,
                       y: Int,
                       direction: Direction) = 
                        object : Projectile(x, y, direction) {
        // Fly straight
    }
}

class Pomegranate : Weapon {
    override fun shoot(x: Int,
                       y: Int,
                       direction: Direction)  = 
                        object : Projectile(x, y, direction) {
        // Explode when you hit first enemy
    }
}

class Banana : Weapon {
    override fun shoot(x: Int,
                       y: Int,
                       direction: Direction)  = 
                        object : Projectile(x, y, direction) {
        // Return when you hit screen border
    }
}

然后,我们的英雄会持有一个武器的引用(最初是Peashooter):

private var currentWeapon : Weapon = Peashooter()

它会将实际的射击过程委托给它:

fun shoot(): Projectile = currentWeapon.shoot(x, y, direction)

剩下的就是装备另一件武器的能力:

fun equip(weapon: Weapon) {
    currentWeapon = weapon
}

这就是策略设计模式的主要内容。现在,我们的算法(Maronic 的武器,在这种情况下)是可互换的。

市民函数

在 Kotlin 中,有一个更有效的方法来实现相同的功能,使用更少的类。这要归功于 Kotlin 中函数是一等公民的事实。

这是什么意思?

首先,我们可以将函数分配给类的变量,就像任何其他正常值一样。

显然,你可以将一个原始值分配给你的变量:

val x = 7

你可以将其分配给一个对象:

var myPet = Canary("Michael")

那么,为什么你应该能够将一个函数分配给你的变量?如下所示:

val square = fun (x: Int): Long {
    return (x * x).toLong()
}

在 Kotlin 中,这是完全有效的。

转换立场

那么,高阶函数是如何帮助我们这里的呢?

首先,我们将为所有武器定义一个命名空间。这不是强制性的,但有助于保持一切井然有序:

object Weapons {
    // Functions we'll be there soon
}

然后,而不是类,我们每件武器都将成为一个函数:

val peashooter = fun(x: Int, y: Int, direction: Direction):             Projectile {
        // Fly straight
}

val banana = fun(x: Int, y: Int, direction: Direction): 
    Projectile {
        // Return when you hit screen border
}

val pomegranate = fun(x: Int, y: Int, direction: Direction):             Projectile {
        // Explode when you hit first enemy
}    

最有趣的部分是我们的英雄。它现在持有两个函数:

class OurHero {
    // As before
    var currentWeapon = Weapons.peashooter

    val shoot = fun() {
        currentWeapon(x, y, direction)
    }
}

可互换的部分是 currentWeapon,而 shoot 现在是一个包装它的匿名函数。

为了测试我们的想法是否可行,我们可以先射击默认武器一次,然后切换到“香蕉”并再次射击:

val h = OurHero()
h.shoot()
h.currentWeapon = Weapons.banana
h.shoot()

注意,这大大减少了我们需要编写的类的数量,同时保持了相同的功能。

迭代器

在上一章讨论 组合 设计模式时,我们注意到这个设计模式感觉有点不完整。现在是时候让出生时分开的双胞胎重聚了。就像阿诺德·施瓦辛格和丹尼·德维托一样,他们非常不同,但很好地互补。

一,二...许多

我们回到了我们的小队和班在 CatsCraft 2: Dogs 的复仇 策略游戏中。

如你从上一章所记得的,SquadInfantryUnits 组成:

interface InfantryUnit

class Squad(val infantryUnits: MutableList<InfantryUnit> =         mutableListOf()) {   
}

每个小队现在也应该有一个指挥官了。

叫做“军士”的小队指挥官也是一个 InfantryUnit

class Squad(...) {
    val commander = Sergeant()
}

class Sergeant: InfantryUnit

请忽略我们的军士没有名字并且是即时创建的事实。我们离发布这款游戏并击败竞争对手还有两天。名字现在不重要。

班是由小队组成的,它也有一个指挥官,称为 中尉

class Platoon(val squads: MutableList<Squad> = mutableListOf()) {
    val commander = Lieutenant()
}

class Lieutenant: InfantryUnit

我们的首席执行官想要的是一个班,并且能够知道它由哪些单位组成。

因此,当我们在代码中有以下行时:

val rangers = Squad("Josh", "Ew    an", "Tom")
val deltaForce = Squad("Sam", "Eric", "William")
val blackHawk = Platoon(rangers, deltaForce)

for (u in blackHawk) {
    println(u)
}

我们将按照资历顺序打印:

Lieutenant, Sergeant, Josh, Ewan, Tom, ...

现在,这个任务可能对你来说微不足道,尤其是如果你来自 Java 世界。但在 1994 年,数据结构主要是原始类型的数组。是的,Array<Int>,我在看着你。

在 Java 中遍历数组并不难:

int[] array = new int[] {1, 2, 3};

for (int i = 0; i < array.length; i++) {
    System.out.println(i);
}

对于更复杂的东西我们该怎么办?

传递价值观

如果你使用像 IntelliJ 这样的 IDE,它将给出有关可能问题的提示:

for (u in blackHawk) { <== For-loop range must have an 'iterator()'                            method
    // Wanted to do something here
}

因此,我们的班需要有一个名为 iterator() 的函数。由于它是一个特殊函数,我们需要使用 operator 关键字。

operator fun iterator() = ...

我们函数返回的是一个实现了 Iterator<T> 接口的匿名对象:

... = object: Iterator<InfantryUnit> {
    override fun hasNext(): Boolean {
        // Are there more objects to iterate over?
    }

    override fun next(): InfantryUnit {
        // Return next InfantryUnit
    }
}

迭代器设计模式背后的思想是将对象存储数据的方式(在我们的例子中,它类似于一棵树)与我们遍历这些数据的方式分开。正如你可能知道的,树可以通过两种方式之一进行迭代:

  • 深度优先(也称为 深度优先搜索 (DFS))

  • 广度优先(也称为 广度优先搜索 (BFS))

但当我们需要获取所有元素时,我们真的在乎吗?

因此,我们将这两个关注点分开:存储一边,重复一边。

要遍历所有元素,我们需要实现两个方法,一个用于获取下一个元素,另一个用于让循环知道何时停止。

作为例子,我们将为Squad实现这个对象。对于排,逻辑将是类似的,但需要更多的数学。

首先,我们需要为我们的迭代器提供一个状态。它将记住最后返回的元素:

operator fun iterator() = object: Iterator<InfantryUnit> {
    var i = 0
    // More code here
}

接下来,我们需要告诉它何时停止。在我们的例子中,元素的总数是队伍的所有单位加上中士:

override fun hasNext(): Boolean {
    return i < infantryUnits.size + 1
}

最后,我们需要知道返回哪个单位。如果是第一次调用,我们将返回中士。接下来的调用将返回队伍成员之一:

override fun next() =
    when (i) {
        0 -> commander
        else -> infantryUnits[i - 1]
    }.also { i++ }

注意,我们想要返回下一个单位,同时也要增加我们的计数器。

为了这个,我们使用also {}块。

这只是这个模式的一种用法。

同一个对象也可能有多个迭代器。例如,我们可以有一个用于我们的队伍的第二个迭代器,它会按顺序遍历元素。

要使用它,我们需要通过名称调用它:

for (u in deltaForce.reverseIterator()) {
    println(u)
}

由于现在它只是一个返回迭代器的简单函数,我们不需要operator关键字:

fun reverseIterator() = object: Iterator<InfantryUnit> {
    // hasNext() is same as before
}

唯一的变化发生在next()方法中:

override fun next() =
        when (i) {
            infantryUnits.size -> commander
            else -> infantryUnits[infantryUnits.size - i - 1]
        }.also { i++ }

有时候,将迭代器作为函数的参数也是有意义的:

fun <T> printAll(iter: Iterator<T>) {
    while (iter.hasNext()) {
        println(iter.next())
    }
}

这个函数将遍历任何提供迭代器的对象:

printAll(deltaForce.iterator())
printAll(deltaForce.reverseIterator())

这将打印我们的队伍成员两次,一次是正常顺序,一次是逆序。

作为一名普通的开发者,他或她并不以发明新的数据结构为生,你现在可能经常实现迭代器。但了解它们在幕后是如何工作的仍然很重要。

状态

你可以将状态设计模式视为一个有偏见的策略,我们在本章的开头讨论了它。但是,虽然策略是由外部,即客户改变的,状态可能会根据它接收到的输入内部改变。

看看这个客户与策略之间的对话:

客户:这里有一些新的事情要做,从现在开始做。

策略:好的,没问题。

客户:我喜欢你的地方是,你从不和我争论。

与这个比较一下:

客户:这里有一些我从你那里得到的新输入。

状态:哦,我不知道。也许我会开始做一些不同的事情。也许不会。

客户应该也期望状态可能会拒绝一些输入:

客户:这里有一些东西让你思考,状态。

状态:我不知道那是什么!你没看到我很忙吗?去烦一下策略吧!

那么,为什么客户仍然容忍我们那个状态呢?嗯,状态真的很擅长控制一切。

五十种状态

肉食性蜗牛对这个马罗尼英雄已经受够了。他向它们扔豌豆和香蕉,结果却只是到达另一个令人遗憾的城堡。现在它们要行动了!

默认情况下,蜗牛应该静止不动以节省蜗牛能量。但是当英雄靠近时,它将积极地向他冲刺。

如果英雄设法伤害了它,它应该撤退去舔伤口。然后它将重复攻击,直到其中一个死去。

首先,我们声明蜗牛生命中可能发生的事情:

interface WhatCanHappen {
    fun seeHero()

    fun getHit(pointsOfDamage: Int)

    fun calmAgain()
}

我们的蜗牛实现了这个接口,因此它可以被通知任何可能发生的事情,并相应地采取行动:

class Snail : WhatCanHappen {
    private var healthPoints = 10

    override fun seeHero() {
    }

    override fun getHit(pointsOfDamage: Int) {
    }

    override fun timePassed() {
    }
}

现在,我们声明Mood类,我们用sealed关键字标记它:

sealed class Mood {
   // Some abstract methods here, like draw(), for example
}

密封类是抽象的,不能被实例化。我们将在稍后看到使用它们的优点。但在那之前,让我们声明其他状态:

class Still : Mood() 

class Aggressive : Mood()

class Retreating : Mood()

class Dead : Mood()

这些都是我们蜗牛的不同状态,对不起,是情绪。

在状态设计模式术语中,Snail是上下文。它持有状态。因此,我们为它声明一个成员:

class Snail : WhatCanHappen {
    private var mood: Mood = Still()
    // As before
}

现在让我们定义当Snail看到我们的英雄时应该做什么:

override fun seeHero() {
        mood = when(mood) {
            is Still -> Aggressive()
        }
    }

编译错误!嗯,这就是sealed类发挥作用的地方。就像enum一样,Kotlin 知道从它扩展的类数量是有限的。因此,它要求我们的when是详尽的,并在其中指定所有不同的案例。

如果你使用 IntelliJ 作为你的 IDE,它甚至会自动建议你“添加剩余的分支”。

让我们描述我们的状态:

override fun seeHero() {
    mood = when(mood) {
        is Still -> Aggressive()
        is Aggressive -> mood
        is Retreating -> mood
        is Dead -> mood
    }
}

当然,else仍然有效:

override fun timePassed() {
    mood = when(mood) {
        is Retreating -> Aggressive()
        else -> mood
    }
}

当蜗牛被击中时,我们需要决定它是死是活。为此,我们可以使用不带参数的when

override fun getHit(pointsOfDamage: Int) {
    healthPoints -= pointsOfDamage

    mood = when {
        (healthPoints <= 0) -> Dead()
        mood is Aggressive -> Retreating()
        else -> mood
    }
}

注意,我们使用了is关键字,这与 Java 中的instanceof相同,但更简洁。

国家状况

之前的方法在大多数逻辑都在我们的上下文中。你有时可能会看到不同的方法,这在你的上下文变大时是有效的。

在这种方法中,Snail会变得非常瘦:

class Snail {
    internal var mood: Mood = Still(this)

    private var healthPoints = 10
    // That's all!
}

注意,我们将mood标记为internal。这允许同一包中的其他类修改它。

而不是让Snail实现WhatCanHappen,我们的Mood将:

sealed class Mood : WhatCanHappen

现在逻辑位于我们的状态对象中:

class Still(private val snail: Snail) : Mood() {
    override fun seeHero() = snail.mood.run {
            Aggressive(snail)
        }

    override fun getHit(pointsOfDamage: Int) = this
    override fun timePassed() = this
}

注意,我们的状态对象现在在构造函数中接收对其上下文的引用。

这是第一次遇到run扩展函数。它的等效函数将是:

override fun seeHero(): Mood {
    snail.mood = Aggressive(snail)
    return snail.mood
}

通过使用run,我们可以保留相同的逻辑,但省略函数体。

你需要决定使用哪种方法。在我们的例子中,这实际上会产生更多的代码,将不得不自己实现所有的方法。

命令

这种设计模式允许你将动作封装在对象中,稍后执行。

此外,如果我们可以在稍后执行一个动作,为什么不执行多个呢?为什么不精确地安排何时执行?

这正是我们现在在CatsCraft 2: 狗的复仇游戏中需要做的。Dave,我们最近雇佣的一位新开发者,整个周末都在努力工作,没有人敢打扰他。他为我们的毛茸茸的士兵实现了以下抽象方法

class Soldier(...)... {
    fun attack(x: Long, y: Long) {
        println("Attacking ($x, $y)")
        // Actual code here
    }

    fun move(x: Long, y: Long) {
        println("Moving to ($x, $y)")
        // Actual code here
    }
}

他甚至可能使用了上一章中提到的桥接设计模式来做到这一点。

我们现在需要解决的问题是士兵只能记住一个命令。就是这样。如果他从(0, 0),屏幕的顶部开始,我们首先告诉他move(20, 0),即向右移动 20 步,然后到move(20, 20),所以他将会直线移动到(20, 20),并且可能会被完全摧毁,因为必须不惜一切代价避开狗敌人:

cat ⇒  good direction  ⇒    (20, 0)

          [dog] [dog]                   ⇓
        [dog] [dog] [dog]               ⇓
           [dog] [dog]
            (5, 20)                  (20, 20)

如果你从本书的开头开始阅读,或者至少从第三章,“理解结构型模式”开始加入,你可能已经有一个想法,我们需要做什么,因为我们已经讨论了语言中函数作为一等公民的概念。

但即使你决定只是弄清楚命令设计模式在 Kotlin 中应该如何工作,或者随机打开这本书到这一节,我们也会简要解释如何解决这个狗障碍。

让我们为它画一个骨架。我们知道我们想要保持一个对象的列表,但我们还不知道它们应该是哪种类型。所以现在我们将使用Any

class Soldier {
    private val orders = mutableListOf<Any>() 

    fun anotherOrder(action: Any) {
        this.orders.add(command)
    }
    // More code here
}

然后,我们想要遍历列表并执行我们拥有的命令:

class Soldier {
    ...
    // This will be triggered from the outside once in a while
    fun execute() {
        while (!orders.isEmpty()) {
            val action = orders.removeAt(0)
            action.execute() // Compile error for now
        }
    }
    ...
}

所以,即使你不熟悉命令设计模式,你也可以猜到我们可以定义一个只有一个方法的接口,execute()

interface Command {
    fun execute()
}

然后在成员属性中保持相同时间的一个列表:

private val commands = mutableListOf<Command>()

根据需要实现此接口。这基本上就是大多数情况下 Java 实现此模式所建议的。但难道没有更好的方法吗?

让我们再次看看命令。它的execute()方法不接收任何东西,也不返回任何东西,但做了一些事情。它与以下代码相同:

fun command(): Unit {
  // Some code here
}

完全没有区别。我们可以进一步简化这个:

() -> Unit

我们不再需要一个名为Command的接口,而将使用typealias

typealias Command = ()->Unit

现在,这一行再次停止编译:

command.execute() // Unresolved reference: execute 

好吧,那是因为execute()只是我们发明的一个名字。在 Kotlin 中,函数使用invoke()

command.invoke() // Compiles

这很好,但 Dave 编写的函数接收参数,而我们的函数没有任何参数。

一个选项是改变我们的Command签名以接收两个参数:

(x: Int, y: Int)->Unit

但如果某些命令不接收任何参数,或者只接收一个,或者接收两个以上呢?我们还需要记住在每一步传递给invoke()的内容。

一个更好的方法是拥有一个函数生成器。也就是说,一个返回另一个函数的函数。

如果你曾经使用过 JavaScript 语言,使用闭包来限制作用域和记住东西是一种常见的做法。我们也会这样做:

val moveGenerator = fun(s: Soldier,
                        x: Int,
                        y: Int): Command {
    return fun() {
        s.move(x, y)
    }
}

当用适当的参数调用时,moveGenerator将返回一个新的函数。这个函数可以在我们找到合适的时候调用,并且它将记住

  • 调用哪个方法

  • 使用哪些参数

  • 在哪个对象上

现在,我们的Soldier可能有一个像这样的方法:

fun appendMove(x: Int, y: Int) = apply {
        commands.add(moveGenerator(this, x, y))
}

它为我们提供了一个流畅的语法:

val s = Soldier()
s.appendMove(20, 0)
    .appendMove(20, 20)
    .appendMove(5, 20)
    .execute()

这段代码将打印以下内容:

Moving to (20, 0)
Moving to (20, 20)
Moving to (5, 20)

撤销命令

虽然不是直接相关,但命令设计模式的一个优点是能够撤销命令。如果我们想要支持这样的功能呢?

撤销通常非常棘手,因为它涉及到以下之一:

  • 返回到之前的状态(如果有多个客户端则不可能,需要大量内存)

  • 计算增量(实现起来很棘手)

  • 定义相反的操作(不一定总是可能的)

在我们的情况下,命令从(0,0)移动到(0,20)的反面将是从你现在所在的位置移动到(0,0)。这可以通过存储一对命令来实现:

private val commands = mutableListOf<Pair<Command, Command>>()

你也可以添加命令对:

fun appendMove(x: Int, y: Int) = apply {
    val oppositeMove = /* If it's the first command, generate move to current location. Otherwise, get the previous command */
    commands.add(moveGenerator(this, x, y) to oppositeMove)
}

实际上,计算反走法相当复杂,因为我们没有保存我们士兵当前的位置(这本来是戴夫应该实现的事情),我们还得处理一些边缘情况。

责任链

我是一个糟糕的软件架构师,我不喜欢和人说话。因此,当我坐在 The Ivory Tower(这是我经常去的咖啡馆的名字)里时,我写了一个小的网络应用程序。如果一个开发者有问题,他不应该直接找我,哦不。他需要通过这个系统给我发送一个适当的请求,而我只有在我认为这个请求有价值时才会回答他。

过滤链是网络服务器中一个非常常见的概念。通常,当一个请求到达你那里时,预期的是:

  • 它的参数已经过验证

  • 如果可能的话,用户已经认证过了

  • 用户角色和权限已知,并且用户被授权执行操作

所以,我最初写的代码看起来像这样:

fun handleRequest(r: Request) {
    // Validate
    if (r.email.isEmpty() || r.question.isEmpty()) {
        return
    }

    // Authenticate
    // Make sure that you know whos is this user
    if (r.email.isKnownEmail()) {
        return
    }

    // Authorize
    // Requests from juniors are automatically ignored by architects
    if (r.email.isJuniorDeveloper()) {
        return
    }

    println("I don't know. Did you check StackOverflow?")
}

稍显混乱,但它是有效的。

然后,我注意到一些开发者决定他们可以一次性给我提出两个问题。我得给这个函数添加更多的逻辑。但是等等,我毕竟是个架构师。难道没有更好的方法来委派这个任务吗?

这次,我们不会学习新的 Kotlin 技巧,而是使用我们已经学过的。我们可以从一个这样的接口开始实现:

interface Handler {
    fun handle(request: Request): Response
}

我们从未讨论过我对开发者的回应是什么样子。那是因为我保持我的责任链非常长且复杂,通常,他们倾向于自己解决问题。坦白说,我从未需要回答过他们中的任何一个。但至少我们知道他们的请求看起来是什么样子:

data class Request(val email: String, val question: String)

data class Response(val answer: String)

然后,我们可以用 Java 的方式来做,并在每个处理器内部实现每块逻辑:

class BasicValidationHandler(private val next: Handler) : Handler {
    override fun handle(request: Request): Response {
        if (request.email.isEmpty() || request.question.isEmpty()) {
            throw IllegalArgumentException()
        }

        return next.handle(request)
    }
}

其他过滤器看起来会非常类似这个。我们可以按任何顺序组合它们:

val req = Request("developer@company.com", 
        "Who broke my build?")

val chain = AuthenticationHandler(
                BasicValidationHandler(
                    FinalResponseHandler()))

val res = chain.handle(req)

println(res) 

但这次我不会问你关于更好方法的修辞问题。当然有更好的方法,我们现在在 Kotlin 世界。我们已经在上一节中看到了函数的使用。所以,我们将为那个任务定义一个函数:

typealias Handler = (request: Request) -> Response

为什么会有一个单独的类和接口来接收请求并返回响应,简而言之:

val authentication = fun(next: Handler) =
    fun(request: Request): Response {
        if (!request.email.isKnownEmail()) {
            throw IllegalArgumentException()
        }
        return next(request)
    }

在这里,authentication是一个函数,它实际上接收一个函数并返回一个函数。

再次,我们可以组合这些函数:

val req = Request("developer@company.com", 
    "Why do we need Software Architects?")

val chain = basicValidation(authentication(finalResponse()))

val res = chain(req)

println(res)

选择哪种方法取决于你。使用接口更明确,如果你正在创建一个其他人可能想要扩展的库或框架,这将更适合你。

使用函数更简洁,如果你只是想以更可管理的方式分割你的代码,这可能是一个更好的选择。

解释器

这种设计模式可能看起来非常简单或非常复杂,这完全取决于你在计算机科学方面的背景。一些讨论经典软件设计模式的书籍甚至决定完全省略它,或者把它放在最后,仅供好奇的读者阅读。

这背后的原因是解释器设计模式处理翻译某些语言。但为什么我们需要这样做呢?我们不是已经有编译器来做了吗?

我们需要更深入地了解

在本节中,我们讨论了所有开发者都必须掌握多种语言或子语言。即使是普通开发者,我们也使用不止一种语言。想想那些构建你项目的工具,比如 Maven 或 Gradle。你可以将它们的配置文件、构建脚本视为具有特定语法的语言。如果你将元素顺序搞错,你的项目将无法正确构建。这是因为这样的项目有解释器来分析配置文件并对它们进行操作。

其他例子可以是 查询语言,无论是 SQL 的变体之一还是特定于 NoSQL 数据库的语言之一。

如果你是一名 Android 开发者,你可能也会将 XML 布局视为这类语言。甚至 HTML 也可以被视为定义 用户界面 的语言。当然,还有其他语言。

也许你曾经使用过定义了自定义测试语言的测试框架之一,例如 Cucumber:github.com/cucumber

这些示例中的每一个都可以被称为 领域特定语言DSL)。一种语言中的语言。我们将在下一节中讨论它是如何工作的。

你自己的语言

在本节中,我们将定义一个简单的 DSL-for-SQL 语言。我们不会定义其格式或语法,而只是提供一个示例,说明它应该看起来像什么:

val sql = select("name, age", {
              from("users", {
                  where("age > 25")
              }) // Closes from
          }) // Closes select

println(sql) // "SELECT name, age FROM users WHERE age > 25"

我们语言的目标是提高可读性并防止一些常见的 SQL 错误,例如拼写错误(如 FORM 而不是 FROM)。我们将在过程中获得编译时验证和自动完成。

我们将从最简单的一部分——select——开始:

fun select(columns: String, from: SelectClause.()->Unit): 
    SelectClause {
    return SelectClause(columns).apply(from)
}

我们可以用单表达式表示法来写这个,但我们使用更冗长的版本以便于示例的清晰性。

这是一个有两个参数的函数。第一个是一个 String,很简单。第二个是一个接收什么都不返回的函数。

最有趣的部分是我们为我们的 lambda 指定了接收者:

SelectClause.()->Unit

这是一个非常聪明的技巧,所以请确保跟得上:

SelectClause.()->Unit == (SelectClause)->Unit

虽然看起来这个 lambda 没有接收任何东西,但实际上它接收了一个参数,一个类型为 SelectClause 的对象。

第二个技巧在于使用我们之前见过的 apply() 函数。

看看这个:

SelectClause(columns).apply(from)

这翻译成这样:

val selectClause = SelectClause(columns)
from(selectClause)
return selectClause

以下是将执行的前置代码的步骤:

  1. 初始化 SelectClause,这是一个简单的对象,它在构造函数中接收一个参数。

  2. 使用 from() 函数,并传入一个 SelectClause 实例作为唯一参数。

  3. 返回一个 SelectClause 实例。

那段代码只有在 from()SelectClause 做一些有用的事情时才有意义。

让我们再次看看我们的 DSL 示例:

select("name, age", {
 this@select.from("users", {
        where("age > 25")
    })
})

我们现在已经明确指定了接收者,这意味着 from() 函数将调用 SelectClause 对象上的 from() 方法。

你可以开始猜测这个方法的样子。很明显,它接收一个 String 作为其第一个参数,并接收另一个 lambda 作为其第二个参数:

class SelectClause(private val columns: String) {
    private lateinit var from : FromClause
    fun from(table: String, where: FromClause.()->Unit): FromClause {
        this.from = FromClause(table)
        return this.from.apply(where)
    }
}

这又可以进一步缩短,但这样我们可能需要在 apply() 中使用 apply(),这可能会让人感到困惑。

这是第一次遇到 lateinit 关键字。这个关键字相当危险,所以请谨慎使用。记住,Kotlin 编译器对空安全非常严格。如果我们省略 lateinit,它将要求我们使用默认值初始化变量。但由于我们将在稍后知道它,我们要求编译器稍微放松一下。注意,如果我们没有履行我们的承诺并忘记初始化它,当我们第一次访问它时,我们会得到 UninitializedPropertyAccessException

回到我们的代码;我们做的只是:

  1. 创建一个 FromClause 的实例

  2. 将其存储为 SelectClause 的成员

  3. FromClause 的实例传递给 where lambda

  4. 返回一个 FromClause 的实例

希望你现在开始理解其精髓:

select("name, age", {
    this@select.from("users", {
 this@from.where("age > 25")
    })
})

这意味着什么?在理解了 from() 方法之后,这应该会简单得多。FromClause 必须有一个名为 where() 的方法,它接收一个 String 类型的参数:

class FromClause(private val table: String) {
    private lateinit var where: WhereClause

    fun where(conditions: String) = this.apply {
        where = WhereClause(conditions)
    }
}

注意,我们这次履行了我们的承诺,缩短了方法。

我们使用接收到的字符串初始化了一个 WhereClause 的实例,并返回了它。就这么简单:

class WhereClause(private val conditions: String) {
    override fun toString(): String {
        return "WHERE $conditions"
    }
}

WhereClause 只打印出单词 WHERE 和它接收到的条件:

class FromClause(private val table: String) {
    // More code here...
    override fun toString(): String {
        return "FROM $table ${this.where}"
    }
}

FromClause 打印出单词 FROM 以及它接收到的表名,以及 WhereClause 打印出的所有内容:

class SelectClause(private val columns: String) {
    // More code here...
    override fun toString(): String {
        return "SELECT $columns ${this.from}"
    }
}

SelectClause 打印出单词 SELECT,它获取的列,以及 FromClause 打印出的任何内容。

休息一下

Kotlin 提供了创建可读性和类型安全的 DSL 的美丽功能。但解释器设计模式是工具箱中最难的一个。如果你一开始没有理解,请花些时间调试这段代码。理解每一步中的 this 的含义,以及我们调用函数和调用对象方法的时候。

调用后缀

为了不让你感到困惑,我们在本节结束前省略了一个 Kotlin DSL 的最后概念。

看看这个 DSL:

val sql = select("name, age", {
              from("users", {
                  where("age > 25")
              }) // Closes from
          }) // Closes select

它可以被重写为:

val sql = select("name, age") {
              from("users") {
                  where("age > 25")
              } // Closes from
          } // Closes select

这在 Kotlin 中是一种常见做法。如果我们的函数接收另一个函数作为其最后一个参数,我们可以将其传递出括号。

这使得 DSL 更加清晰,但一开始可能会让人感到困惑。

中介

没有其他办法。中介者设计模式就是一个控制狂。它不喜欢一个对象直接与另一个对象交流。当这种情况发生时,它有时会变得很生气。不,每个人都应该只通过他来交流。他的解释是什么?它减少了对象之间的耦合。不是每个人都应该知道一些其他对象,而是每个人都应该只认识他,即中介者。

森林中的麻烦

抛开建筑笑话不谈,我们,Maronic 开发团队,确实有一些实际问题。这些问题与代码没有直接关系。你可能还记得,我们的小型独立公司只有我一个人,一只名叫迈克尔的金丝雀,它担任产品经理,还有两位猫设计师,他们大部分时间都在睡觉,但偶尔也会制作出一些不错的原型。我们没有质量保证团队(即质量保证人员)。也许这就是我们的游戏总是频繁崩溃的原因之一。

最近,迈克尔介绍我认识了一只名叫 Kenny 的鹦鹉,他恰好是质量保证人员:

interface QA {
    fun doesMyCodeWork(): Boolean
}

interface Parrot {
    fun isEating(): Boolean
    fun isSleeping(): Boolean
}

object Kenny : QA, Parrot {
    // Implements interface methods based on parrot schedule
}

为了简化,本节将使用对象。

鹦鹉质量保证人员非常有动力。他们随时准备测试我游戏的最新版本。但他们真的很不喜欢在睡觉或吃饭时被打扰:

class MyMind {
    val qa = Kenny

    fun taskCompleted() {
        if (!qa.isEating() && !qa.isSleeping()) {
            println(qa.doesMyCodeWork())
        }
    }
}

如果 Kenny 有任何问题,我会给他我的直接电话号码:

object Kenny : ... {
    val developer = Me
}

Kenny 是一只勤奋的鹦鹉。但我们有如此多的虫子,以至于我们不得不雇佣第二只鹦鹉质量保证人员,Brad。如果 Kenny 有空,我会把工作交给他,因为他更熟悉我们的项目。但如果他忙,我会检查 Brad 是否有空,然后把这项任务交给他:

class MyMind {
    ...
    val qa2 = Brad

    fun taskCompleted() {
        ...
        else if (!qa2.isEating() && !qa2.isSleeping()) {
            println(qa2.doesMyCodeWork())
        }
    }
}

Brad 作为初级成员,通常会先向 Kenny 汇报。而且 Kenny 也把我的电话号码给了他:

object Brad : QA, Parrot {
    val senior = Kenny
    val developer = Me
    ...
}

然后 Brad 介绍我认识 GeorgeGeorge 是一只猫头鹰,所以他的睡眠时间与 Kenny 和 Brad 不同。这意味着他可以在夜间检查我的代码。问题是,George 是一名狂热的足球迷。所以在给他打电话之前,我们需要检查他是否正在看比赛:

class MyMind {
    ...
    val qa3 = George

    fun taskCompleted() {
        ...
        else if (!qa3.isWatchingFootball()) {
            println(qa3.doesMyCodeWork())
        }
    }
}

Kenny 习惯性地也会向 George 汇报,因为 George 是一只非常博学的猫头鹰:

object Kenny : QA, Parrot {
    val peer = George
    ...
}

George 会检查 Kenny,因为 Kenny 似乎也对足球感兴趣:


object George : QA, Owl {
    val mate = Kenny
    ...
}

George 喜欢在夜间用他的问题打扰我:

object George : QA, Owl {
    val developer = Me
    ...
}

然后是 Sandra。她是一种不同类型的鸟,因为她不是质量保证人员,而是一名文案:

interface Copywriter {
    fun areAllTextsCorrect(): Boolean
}

interface Kiwi

object Sandra : Copywriter, Kiwi {
    override fun areAllTextsCorrect(): Boolean {
        return ...
    }
}

我尽量不去打扰她,除非是重大发布:

class MyMind {
    ...
    val translator = Sandra

    fun taskCompleted(isMajorRelease: Boolean) {
        ...
        if (isMajorRelease) {
            println(translator.areAllTranslationsCorrect())
        }
    }
}

好吧,现在我有一些问题:

  • 首先,我试图记住所有这些名字时,我的大脑几乎要爆炸了。你的可能也是。

  • 其次,我还需要记住如何与每个人互动。在给他们打电话之前,所有的检查都是由我来做的。

  • 第三,注意乔治如何试图与肯尼确认每一件事,肯尼也总是与乔治确认?幸运的是,到目前为止,乔治总是在肯尼打电话时看足球比赛。而肯尼在乔治需要确认某事时却在睡觉。否则,他们可能会在电话上陷入永恒的僵局...

  • 第四,最让我烦恼的是,Kenny 打算不久后离开去开创自己的创业公司,ParrotPi。想象一下我们现在得改多少代码!

我只想检查我的代码是否一切正常。其他人应该做所有这些谈话!

中间人

因此,我决定迈克尔应该管理所有这些流程:

interface Manager {
    fun isAllGood(majorRelease: Boolean): Boolean
}

只有他知道所有其他的鸟儿

object Michael: Canary, Manager {
    private val kenny = Kenny(this)
    // And all the others
    ...

    override fun isAllGood(majorRelease: Boolean): Boolean {
        if (!kenny.isEating() && !kenny.isSleeping()) {
            println(kenny.doesMyCodeWork())
        }
        // And all the other logic I had in MyMind
        ...
    }
}

我只会记住他,他会做剩下的:

class MyPeacefulMind(private val manager: Manager) {
    fun taskCompleted(isMajorRelease: Boolean) {
        println(manager.isAllGood(isMajorRelease))
    }
}

我也会更改我的电话号码,并确保每个人都只得到迈克尔的:


class Brad(private val manager: Manager) : ... {
   // No reference to Me here
   ...
}

现在,如果有人需要别人的意见,他们需要先通过迈克尔。

class Kenny(private val manager: Manager) : ... {
   // No reference to George, or anyone else
   ...
}

风味

中介有两个风味。我们将它们称为严格宽松。我们之前看到的是严格版本。我们告诉中介确切要做什么,并期望它给出回应。

宽松版本将期望我们通知中介发生了什么,但不需要期望立即的回应。相反,如果他需要反过来通知我们,他应该调用我们。

注意事项

迈克尔突然变得非常重要。每个人都只知道他,而且只有他可以管理他们的互动。他甚至可能成为一个神对象,无所不知且全能,这是来自第九章的反模式,设计用于并发。即使他如此重要,也一定要定义这个中介应该做什么,更重要的是,它不应该做什么。

备忘录

自从迈克尔成为经理以来,如果我有问题很难找到他。而且当我确实问他问题时,他只是扔下东西就跑向下一个会议。

昨天,我问他我们将在我们的Maronic游戏中引入的下一件武器应该是什么。他告诉我应该是一个椰子大炮,一目了然。但今天,当我向他展示这个功能时,他愤怒地对我尖叫!他说他告诉我实现一个菠萝发射器。我很幸运他只是一个金丝雀...

但如果我能记录他,当我们在另一次会议中遇到麻烦,因为他没有全神贯注时,我就可以重新播放他说的所有内容。

记忆

首先总结我的问题——迈克尔的想法是他的,而且只有他的:

class Manager {
    private var lastThought = "Should get some coffee"
    private var repeatThat = 3
    private var thenHesitate = "Or maybe tea?"
    private var secretThought = "No, coffee it is"
    ...
}

此外,它们相当复杂且分散。我无法访问它们,只能访问它们的副产品:

class Manager {
    ...
    fun whatAreYouThinking() {
        for (i in 1..repeatThat) {
            println(lastThought)
        }
        println(thenHesitate)
    }
    ...
}

即使记录他说的内容也很困难(因为他不返回任何东西)。

即使我真的记录了他,迈克尔也可以声称那是他说的话,而不是他的意思:

你为什么给我带茶?我想要咖啡!

解决方案可能看起来很明显。让我们使用一个内部类,thought,来捕捉这个最后的想法:

class Manager {
    ...
    class Thought {
        fun captureThought(): CapturedThought {
            return CapturedThought(lastThought, 
                                   repeatThat,                              
                                   thenHesitate, 
                                   secretThought)
        }
    }

    data class CapturedThought(val thought: String, 
                               val repeat: Int, 
                               val hesitate: String,
                               val secret: String)
}

唯一的问题是这个代码无法编译。这是因为我们遗漏了一个新的关键字,inner,用来标记我们的类。如果我们省略这个关键字,类将被称为Nested,类似于 Java 中的静态嵌套类。

现在我们可以记录迈克尔此刻说的话:

val michael = Manager()

val captured = michael.Thought().captureThought()

让我们假设迈克尔在某个时刻改变了主意。我们将添加另一个函数来处理这种情况:

class Manager {
    ...
    fun anotherThought() {
        lastThought = "Tea would be better"
        repeatThat = 2
        thenHesitate = "But coffee is also nice"
        secretThought = "Big latte would be great"
    }
}
michael.anotherThought()

我们可以反复思考我们捕捉到的想法:

michael.whatAreYouThinking()

这将打印:

Tea would be better
Tea would be better
But coffee is also nice

让我们检查我们捕捉到了什么:

println(captured)

这将打印:

CapturedThought(thought=Should get some coffee, repeat=3, hesitate=Or maybe tea?, secret=No, coffee it is)

如果迈克尔允许,我们甚至可以倒带他的想法:

class Manager {
    ...
    inner class Thought {
        ...
        fun rewindThought(val previousThought: CapturedThought) {
            with(previousThought) {
                lastThought = thought
                repeatThat = repeat
                thenHesitate = hesitate
                secretThought = secret
            }
        }
    }
    ...
}

注意这里我们如何使用 with 标准函数来避免在每一行重复 previousThought

访问者

这种设计模式通常是我们在 第三章 讨论的 Composite 设计模式的亲密朋友,理解结构型模式。它可以从复杂的树形结构中提取数据,或者向树的每个节点添加行为,就像 Decorator 设计模式一样。

因此,作为一个懒惰的软件架构师,我的计划实施得相当顺利。我的邮件发送系统来自 Builder,我的请求回答系统来自 Chain of Responsibility,都运行得相当好。但一些开发者开始怀疑我有点儿骗子。

为了混淆他们,我计划每周发送包含所有最新流行词汇文章链接的电子邮件。当然,我并不打算亲自阅读它们,只是从一些流行的技术网站上收集它们。

编写爬虫

让我们看看以下结构,这与我们讨论 Iterator 设计模式时非常相似:

Page(Container(Image(),
               Link(),
               Image()),
     Table(),
     Link(),
     Container(Table(),
               Link()),
     Container(Image(),
               Container(Image(),
                         Link())))

Page 是其他 HtmlElements 的容器,但本身不是 HtmlElementContainer 持有其他容器、表格、链接和图像。Imagesrc 属性中持有其链接。Linkhref 属性。

我们首先创建一个函数,该函数将接收我们的对象树根,在这个例子中是一个 Page,并返回所有可用的链接列表:

fun collectLinks(page: Page): List<String> {
    // No need for intermediate variable there
    return LinksCrawler().run {
        page.accept(this)
        this.links
    }
}

使用 run 允许我们控制从块体返回的内容。在这种情况下,我们将返回我们收集到的 links

在 Java 中,实现 Visitor 设计模式的建议是为每个接受我们新功能的类添加一个方法。我们将这样做,但不是为所有类。相反,我们只为容器元素定义此方法:

private fun Container.accept(feature: LinksCrawler) {
    feature.visit(this)
}

// Same as above but shorter
private fun Page.accept(feature: LinksCrawler) = feature.visit(this)

我们的功能需要内部持有集合,并且仅暴露读取目的。在 Java 中,我们会为该成员指定只读的 getter 而没有 setter。在 Kotlin 中,我们可以指定值而不需要后端字段:

class LinksCrawler {
    private var _links = mutableListOf<String>()

    val links
        get()= _links.toList()
    ...
}

我们希望我们的数据结构是不可变的。这就是我们调用 toList() 的原因。

如果我们使用 Iterator 设计模式,迭代分支的函数可以进一步简化。

对于容器,我们只需将它们的元素传递下去:

class LinksCrawler {
    ...
    fun visit(page: Page) {
        visit(page.elements)
    }

    fun visit(container: Container) = visit(container.elements)
    ...
}

将父类指定为 sealed 帮助编译器进一步优化:

sealed class HtmlElement

class Container(...) : HtmlElement(){
    ...
}

class Image(...) : HtmlElement() {
    ...
}

class Link(...) : HtmlElement() {
    ...
}

class Table : HtmlElement()

最有趣的逻辑在叶子节点:

class LinksCrawler {
    ...
    private fun visit(elements: List<HtmlElement>) {
        for (e in elements) {
            when (e) {
                is Container -> e.accept(this)
                is Link -> _links.add(e.href)
                is Image -> _links.add(e.src)
                else -> {}
            }
        }
    }
}

注意,在某些情况下,我们不想做任何事情。这通过 else 中的空块来指定:else -> {}

这是我们第一次在 Kotlin 中看到 smart casts

注意,在我们检查元素是 Link 之后,我们获得了对其 href 属性的类型安全访问。这是因为编译器为我们做了类型转换。同样,对于 Image 元素也是如此。

尽管我们实现了我们的目标,但这个模式的可用性可以争论。正如你所看到的,它是一个更冗长的元素之一,并且引入了接收额外行为和访问者本身的紧密耦合。

模板方法

一些懒惰的人将他们的懒惰变成了艺术。以我为例。以下是我的日常日程:

  1. 8:00–9:00: 到达办公室

  2. 9:00–10:00: 喝咖啡

  3. 10:00–12:00: 参加一些会议或审查代码

  4. 12:00–13:00: 外出吃午餐

  5. 13:00–16:00: 参加一些会议或审查代码

  6. 16:00: 悄悄溜回家

如你所见,日程表的一些部分永远不会改变,而有些则会改变。起初,我以为我可以用那个 setupteardown 逻辑来“装饰”我变化的日程,这些逻辑发生在“之前”和“之后”。但午餐是神圣的,对于建筑师来说,它发生在“之间”。

Java 在你该做什么方面非常明确。首先,你创建一个抽象类。所有你想自己实现的方法你标记为私有:

abstract class DayRoutine {
    private fun arriveToWork() {
        println("Hi boss! I appear in the office sometimes!")
    }

    private fun drinkCoffee() {
        println("Coffee is delicious today")
    }

    ...

    private fun goToLunch() {
        println("Hamburger and chips, please!")
    }

    ...

    private fun goHome() {
        // Very important no one notices me
        println()
    }

    ...
}

对于所有每天都会变化的方法,你定义一个抽象类:

abstract class DayRoutine {
    ...
    abstract fun doBeforeLunch()
    ...
    abstract fun doAfterLunch()
    ...
}

如果你允许方法的变化,但想提供一个默认实现,你可以将其设置为公开:

abstract class DayRoutine {
    ...
    open fun bossHook() {
        // Hope he doesn't hook me there
    }
    ...
}

最后,你有一个执行你算法的方法。默认情况下它是最终的:

abstract class DayRoutine {
    ...
    fun runSchedule() {
        arriveToWork()
        drinkCoffee()
        doAfterLunch()
        goToLunch()
        doAfterLunch()
        goHome()
    }
}

如果我们现在想有一个周一的日程表,我们只需实现缺失的部分:

class MondaySchedule : DayRoutine() {
    override fun doBeforeLunch() {
        println("Some pointless meeting")
        println("Code review. What this does?")
    }

    override fun doAfterLunch() {
        println("Meeting with Ralf")
        println("Telling jokes to other architects")
    }

    override fun bossHook() {
        println("Hey, can I have you for a sec in my office?")
    }
}

Kotlin 在这个基础上添加了什么?它通常做的事情——简洁。正如我们之前所看到的,这可以通过函数来实现。

我们有三个“移动部件”——两个强制性活动(软件架构师必须在午餐前后做些事情)和一个可选的(老板可能在他在家之前或之后偷偷溜走之前阻止他):

fun runSchedule(beforeLunch: ()->Unit,
                afterLunch: ()->Unit,
                bossHook: (()->Unit)? = fun() { println() }) {
    ...
}

我们将有一个函数,它接受最多三个其他函数作为其参数。前两个是强制性的,第三个可能根本不提供,或者用 null 分配,以明确表示我们不希望该函数发生:

fun runSchedule(...) {
    ...
    arriveToWork()
    drinkCoffee()
    beforeLunch()
    goToLunch()
    afterLunch()
    bossHook?.let { it() }
    goHome()
}

在这个函数内部,我们将有我们的算法。对 beforeLunch()afterLunch() 的调用应该是清晰的;毕竟,这些是我们作为参数传递给我们的函数。第三个,bossHook,可能为 null,所以我们只有在它不为 null 时才执行它:?.let { it() }

但其他函数怎么办,那些我们希望总是自己实现的函数呢?Kotlin 有一个局部函数的概念。这些函数位于其他函数中:

fun runSchedule(...) {
    fun arriveToWork(){
        println("How are you all?")
    }

    val drinkCoffee = { println("Did someone left the milk out?") }

    fun goToLunch() = println("I would like something italian")

    val goHome = fun () {
        println("Finally some rest")
    }

    arriveToWork()
    drinkCoffee()
    ...
    goToLunch()
    ...
    goHome()
}

这些都是声明局部函数的有效方式。无论你如何定义它们,它们的调用方式都是相同的。

我们得到了相同的结果,正如你所看到的。定义算法结构,但让其他人决定在某些点的操作:这就是模板方法的核心。

观察者

这可能是本章的一个亮点,这个设计模式将为我们提供一个通往以下章节的桥梁,这些章节专门介绍函数式编程。

那么,观察者模式是什么?你有一个发布者,它也可能被称为主题,它可能有多个订阅者,它们也可能被称为观察者。每当发布者发生有趣的事情时,它应该更新所有订阅者。

这可能看起来很像中介者设计模式,但有一个转折。订阅者应该能够在运行时注册或注销自己。

在经典实现中,所有订阅者/观察者都需要实现一个特定的接口,以便发布者能够更新它们。但是,由于 Kotlin 有高阶函数,我们可以省略这部分。发布者仍然需要提供观察者订阅和取消订阅的方式。

动物合唱团

所以,动物们决定有自己的合唱团。猫被选为合唱团的指挥(它根本不喜欢唱歌)。

问题在于动物们逃离了 Java 世界,没有共同的接口。相反,每个动物都有自己的方法来发出声音:

class Bat {
    fun screech() {
        println("Eeeeeee")
    }
}

class Turkey {
    fun gobble() {
        println("Gob-gob")
    }
}

class Dog {
    fun bark() {
        println("Woof")
    }

    fun howl() {
        println("Auuuu")
    }
}

幸运的是,猫不仅因为嗓音不佳而被选为指挥,而且因为它足够聪明,能够一直读到这一章。所以它知道在 Kotlin 世界中,它可以接受函数:

class Cat {
    ...
    fun joinChoir(whatToCall: ()->Unit) {
        ...
    }

    fun leaveChoir(whatNotToCall: ()->Unit) {
        ...
    }
    ...
}

之前,我们看到了如何传递一个新函数作为参数,以及如何传递一个字面函数。但是,我们如何传递成员函数的引用?

这就是成员引用操作符的作用——::

val catTheConductor = Cat()

val bat = Bat()
val dog = Dog()
val turkey = Turkey()

catTheConductor.joinChoir(bat::screech)
catTheConductor.joinChoir(dog::howl)
catTheConductor.joinChoir(dog::bark)
catTheConductor.joinChoir(turkey::gobble)

现在,猫需要以某种方式保存所有这些订阅者。幸运的是,我们可以把它们放在一个映射中。那这个键会是什么?它可以是函数本身:

class Cat {
    private val participants = mutableMapOf<()->Unit, ()->Unit>()

    fun joinChoir(whatToCall: ()->Unit) {
        participants.put(whatToCall, whatToCall)
    }
    ...
}

如果所有这些()->Unit实例让你感到头晕,请务必使用typealias来赋予它们更多的语义意义,例如订阅者。

蝙蝠决定离开合唱团。毕竟,没有人能听到它那美妙的歌声:

class Cat {
    ...
    fun leaveChoir(whatNotToCall: ()->Unit) {
        participants.remove(whatNotToCall)
    }
    ...
}

所有Bat需要做的就是再次传递其订阅者函数:

catTheConductor.leaveChoir(bat::screech)

这就是为什么我们最初使用映射的原因。现在Cat可以调用所有合唱团成员并告诉他们唱歌。好吧,发出声音:

typealias Times = Int

class Cat {
    ...
    fun conduct(n: Times) {
        for (p in participants.values) {
            for (i in 1..n) {
                p()
            }
        }
    }
}

排练进行得很顺利。但Cat在做了所有这些循环之后感到非常累。它宁愿将工作委托给合唱团成员。这根本不是问题:

class Cat {
    private val participants = mutableMapOf<(Int)->Unit, (Int)->Unit>()

    fun joinChoir(whatToCall: (Int)->Unit) {
        ...
    }

    fun leaveChoir(whatNotToCall: (Int)->Unit) {
        ...
    }

    fun conduct(n: Times) {
        for (p in participants.values) {
            p(n)
        }
    }
}

我们的所有订阅者看起来都像火鸡:

class Turkey {
    fun gobble(repeat: Times) {
        for (i in 1..repeat) {
            println("Gob-gob")
        }
    }
}

实际上,这有点问题。如果Cat要告诉每个动物发出什么声音:高音还是低音?我们不得不再次更改所有订阅者,以及Cat本身。

在设计你的发布者时,传递具有许多属性的单个数据类,而不是数据类的集合或其他类型。这样,如果添加了新的属性,你就不必重构你的订阅者:

enum class SoundPitch {HIGH, LOW}
data class Message(val repeat: Times, val pitch: SoundPitch)

class Bat {
    fun screech(message: Message) {
        for (i in 1..message.repeat) {
            println("${message.pitch} Eeeeeee")
        }
    }
}

确保你的消息是不可变的。否则,你可能会遇到奇怪的行为!

如果你有来自同一发布者的不同消息集合,你会怎么办?

使用智能转换:

interface Message {
    val repeat: Times
    val pitch: SoundPitch 
}

data class LowMessage(override val repeat: Times) : Message {
    override val pitch = SoundPitch.LOW
}

data class HighMessage(override val repeat: Times) : Message {
    override val pitch = SoundPitch.HIGH
}

class Bat {
    fun screech(message: Message) {
        when (message) {
            is HighMessage -> {
                for (i in 1..message.repeat) {
                    println("${message.pitch} Eeeeeee")
                }
            }
            else -> println("Can't :(")
        }
    }
}

摘要

这是一章很长的内容。但我们同时也学到了很多。我们已经完成了对所有经典设计模式的覆盖,包括十一个行为模式。在 Kotlin 中,函数可以被传递给其他函数,从函数中返回,以及分配给变量。这就是“函数作为一等公民”的概念所在。如果你的类主要是关于行为,那么用函数来替换它通常是有意义的。迭代器是语言中的另一个 operator。密封类有助于使 when 语句变得详尽。run 扩展函数允许控制从它返回的内容。带有接收器的 lambda 在你的 DSLs 中提供了更清晰的语法。另一个关键字 lateinit 告诉编译器在它的空安全检查上稍微放松一些。请谨慎使用!最后,我们介绍了如何使用 :: 引用现有方法。

在下一章中,我们将从具有众所周知设计模式的面向对象编程范式转向另一种范式——函数式编程。

第五章:函数式编程

在本章中,我们将讨论函数式编程的基本原则,以及它们如何融入 Kotlin 编程语言。我们不会介绍很多新的语法,正如你很快就会看到的。在前面几章中,如果不涉及诸如 数据不可变性函数作为一等值 等概念,很难讨论该语言的好处。但就像我们之前做的那样,我们将从不同的角度来审视这些特性:不是如何用它们以更好的方式实现已知的设计模式,而是它们的用途。

在本章中,我们将涵盖以下主题:

  • 为什么选择函数式编程?

  • 不可变性

  • 作为值的函数

  • 表达式,而不是语句

  • 递归

为什么选择函数式编程?

函数式编程几乎与其他编程范式一样历史悠久,比如过程式编程和面向对象编程,如果不是更久。但近 10 年来,它已经取得了巨大的进展。原因在于其他一些事情停滞了:CPU 速度。我们不能再像过去那样大幅提高 CPU 速度,因此我们必须并行化我们的程序。结果证明,函数式编程范式在运行并行任务方面非常出色。

多核处理器的演变本身就是一个非常有趣的话题,但我们只能简要地介绍。工作站自 20 世纪 80 年代以来至少就有多个处理器,以支持并行运行来自不同用户的任务。由于工作站本身就很大,它们不需要担心将所有东西都挤在一个芯片上。但到了 2005 年左右,消费市场出现了多处理器,就需要一个能够并行工作的物理单元。这就是为什么我们的 PC 或笔记本电脑上有一个芯片上有多个核心的原因。

但这并不是一些人坚信函数式编程的唯一原因。这里还有更多:

  • 函数式编程倾向于纯函数,而纯函数通常更容易推理和测试

  • 以函数方式编写的代码通常比命令式代码更具声明性,处理的是 what 而不是 how

不可变性

函数式编程的一个关键概念是不可变性。这意味着从函数接收输入的那一刻起,到函数返回输出的那一刻止,对象不会改变。你怎么可能改变呢?让我们看看一个简单的例子:

fun <T> printAndClear(list: MutableList<T>) {
    for (e in list) {
        println(e)
        list.remove(e)
    }
}
printAndClear(mutableListOf("a", "b", "c"))

输出将会是首先 "a",然后我们会收到ConcurrentModificationException

如果我们一开始就能保护自己免受此类运行时异常的侵害,那岂不是很好?

元组

在函数式编程中,元组是在创建后无法更改的数据片段。Kotlin 中最基本的元组之一是 Pair:

val pair = "a" to 1

一对包含两个属性,第一个和第二个,且是不可变的:

pair.first = "b" // Doesn't work
pair.second = 2  // Still doesn't

我们可以将一对拆分为两个单独的值:

val (key, value) = pair
println("$key => $value")

当遍历映射时,我们会收到另一个元组,Map.Entry

for (p in mapOf(1 to "Sunday", 2 to "Monday")) {
   println("${p.key} ${p.value}")
}

通常,数据类通常是元组的良好实现。但是,正如我们将在值突变部分中看到的,并非每个数据类都是合适的元组。

值突变

在 Maronic 中,我们希望计算一千场比赛的平均分。为此,我们有一个以下的数据类:

data class AverageScore(var totalScore: Int = 0,
                        var gamesPlayed: Int = 0) {
    val average: Int
        get() = if (gamesPlayed <= 0)
                    0
                else
                    totalScore / gamesPlayed
}

我们很聪明:我们通过检查除以零来保护自己免受任何无效输出的影响。

但当我们编写以下代码时会发生什么?

val counter = AverageScore()

thread(isDaemon = true) {
    while(true) counter.gamesPlayed = 0
}

for (i in 1..1_000) {
    counter.totalScore += Random().nextInt(100)
    counter.gamesPlayed++

    println(counter.average)
}

很快,你将不可避免地收到 ArithmeticException。我们的计数器不知何故变成了零。

如果你希望你的数据类是不可变的,请确保将所有属性指定为 val(值),而不是 var(变量)。

不可变集合

我想我们的初级开发者已经吸取了教训。相反,他们产生了以下代码,虽然效率不高,但去掉了那些变量:

data class ScoreCollector(val scores: MutableList<Int> = mutableListOf())

val counter = ScoreCollector()

for (i in 1..1_000) {
    counter.scores += Random().nextInt(100)

    println(counter.scores.sumBy { it } / counter.scores.size)
}

但邪恶的线程再次发动攻击:

thread(isDaemon= true, name="Maleficent") {
    while(true) counter.scores.clear()
}

我们再次收到 ArithmeticException

如果你的数据类只包含值,那就不够了。如果它的值是一个集合,那么为了使数据类被认为是不可变的,这个集合必须是不可变的。同样的规则也适用于其他数据类中包含的类:

data class ImmutableScoreCollector(val scores: List<Int>)

现在,邪恶的线程甚至不能调用这个集合上的 clear()。但我们该如何向其中添加分数呢?

一种选择是将整个列表传递给构造函数:

val counter = ImmutableScoreCollector(List(1_000) {
    Random().nextInt(100)
})

函数作为值

我们已经在专门介绍设计模式的章节中涵盖了 Kotlin 的一些功能特性。其中,策略命令设计模式只是少数几个大量依赖接受函数作为参数、返回函数、将它们作为值存储或将它们放入集合中的能力。在本节中,我们将介绍 Kotlin 中函数式编程的一些其他方面,例如函数纯度和柯里化。

高阶函数

正如我们之前讨论的,在 Kotlin 中,一个函数可以返回另一个函数:

fun generateMultiply(): (Int, Int) -> Int {
    return { x: Int, y: Int -> x * y}
}

函数也可以分配给变量或值,稍后调用:

val multiplyFunction = generateMultiply()
...
println(multiplyFunction(3, 4))

分配给变量的函数通常被称为字面函数。也可以将函数指定为参数:

fun mathInvoker(x: Int, y: Int, mathFunction: (Int, Int) -> Int) {
    println(mathFunction(x, y))
}

mathInvoker(5, 6, multiplyFunction)

如果函数是最后一个参数,它也可以在括号外以特定方式提供:

mathInvoker(7, 8) { x, y ->
   x * y
}

通常,没有名称的函数被称为匿名函数。如果一个没有名称的函数使用简短语法,它被称为 lambda:

val squareAnonymous = fun(x: Int) = x * x
val squareLambda = {x: Int -> x * x} 

纯函数

纯函数是没有副作用的函数。以下函数就是一个例子:

fun sayHello() {
    println("Hello")
}

你如何测试 "Hello" 是否确实被打印出来?这个任务并不像看起来那么简单,因为我们需要某种方法来捕获标准输出,即我们通常看到打印内容的同一个控制台。

将它与以下函数进行比较:

fun hello() = "Hello"

以下函数没有任何副作用。这使得它更容易测试:

fun testHello(): Boolean {
    return "Hello" == hello()
}

hello()函数看起来有点无意义吗?这实际上是纯函数的一个特性。它们的调用可以被它们的结果所替代(如果我们知道所有结果的话)。这通常被称为引用透明性

并非所有用 Kotlin 编写的函数都是纯函数:

fun <T> removeFirst(list: MutableList<T>): T {
    return list.removeAt(0)
}

如果我们在同一个列表上两次调用该函数,它将返回不同的结果:

val list = mutableListOf(1, 2, 3)

println(removeFirst(list)) // Prints 1
println(removeFirst(list)) // Prints 2

尝试这个:

fun <T> withoutFirst(list: List<T>): T {
    return ArrayList(list).removeAt(0)
}

现在函数是完全可预测的,无论我们调用多少次:

val list = mutableListOf(1, 2, 3)

println(withoutFirst(list)) // It's 1
println(withoutFirst(list)) // Still 1

如您所见,我们这次使用了一个不可变接口 List<T>,它通过防止我们甚至有可能修改输入来帮助我们。与上一节中的不可变值一起,纯函数提供了一种非常强大的工具,它通过提供可预测的结果和算法的并行化,使我们更容易进行测试。

柯里化

柯里化是将接受多个参数的函数转换为一系列每个函数只接受一个参数的函数的方法。这听起来可能有些令人困惑,所以让我们看看一个简单的例子:

fun subtract(x: Int, y: Int): Int {
    return x - y
}
println(subtract(50, 8))

这是一个返回两个参数的函数。结果非常明显。但也许我们希望用以下语法调用这个函数:

subtract(50)(8)

我们已经看到如何从一个函数中返回另一个函数:

fun subtract(x: Int): (Int) -> Int {
    return fun(y: Int): Int {
        return x - y
    }
}

这里是它的简短形式:

fun subtract(x: Int) = fun(y: Int): Int {
    return x + y
}

这里还有一个更简短的形式:

fun subtract(x: Int) = {y: Int -> x - y}

虽然它本身可能不太有用,但掌握这个概念仍然很有趣。如果你是一位正在寻找新工作的 JavaScript 开发者,确保你真正理解它,因为几乎在每次面试中都会问到它。

记忆化

如果我们的函数总是对相同的输入返回相同的输出,我们就可以轻松地在先前的输入和输出之间建立映射,并将其用作缓存。这种技术被称为记忆化

class Summarizer {
    private val resultsCache = mutableMapOf<List<Int>, Double>()

    fun summarize(numbers: List<Int>): Double {
        return resultsCache.computeIfAbsent(numbers, ::sum)
    }

    private fun sum(numbers: List<Int>): Double {
        return numbers.sumByDouble { it.toDouble() }
    }
}

我们使用方法引用操作符 :: 来告诉 computeIfAbsent 在输入尚未缓存的情况下使用 sum() 方法。

注意,sum() 是一个纯函数,而 summarize() 则不是。后者对相同的输入会有不同的行为。但这正是我们想要的:

val l1 = listOf(1, 2, 3)
val l2 = listOf(1, 2, 3)
val l3 = listOf(1, 2, 3, 4)

val summarizer = Summarizer()

println(summarizer.summarize(l1)) // Computes, new input
println(summarizer.summarize(l1)) // Object is the same, no compute
println(summarizer.summarize(l2)) // Value is the same, no compute
println(summarizer.summarize(l3)) // Computes

不变对象、纯函数和平凡的类的组合为我们提供了一种强大的性能优化工具。只需记住,没有什么是免费的。我们只是用 CPU 时间交换另一种资源,即内存。而决定哪种资源对你来说更贵,则取决于你。

表达式,而不是语句

语句是一段不返回任何内容的代码块。另一方面,表达式返回一个新的值。由于语句不产生结果,它们唯一有用的方式就是改变状态。而函数式编程试图尽可能避免改变状态。理论上,我们越依赖表达式,我们的函数就越纯,从而获得所有函数式纯度的益处。

我们已经多次使用了if表达式,因此它的一些好处应该很清楚:它比if语句更简洁,因此更不容易出错。

模式匹配

模式匹配的概念对于来自 Java 的人来说就像switch/case的强化版。我们已经在第一章,“Kotlin 入门”,中看到了when表达式是如何使用的,所以让我们简要讨论一下为什么这个概念对于函数式范式来说很重要。

如你所知,Java 中的switch只能接受一些原始类型、字符串或枚举。

考虑以下 Java 代码:

class Cat implements Animal {
    public String purr() {
        return "Purr-purr";
    }
}

class Dog implements Animal {
    public String bark() {
        return "Bark-bark";
    }
}

interface Animal {}

如果我们要决定调用哪个函数,我们需要像这样:

public String getSound(Animal animal) {
    String sound = null;
    if (animal instanceof Cat) {
        sound = ((Cat)animal).purr();
    }
    else if (animal instanceof Dog) {
        sound = ((Dog)animal).bark();
    }

    if (sound == null) {
        throw new RuntimeException();
    }
    return sound;
}

这个方法可以通过引入多个返回来简化,但在实际项目中,通常不推荐使用多个返回。

由于我们没有为类提供switch语句,我们需要使用if语句代替。

与下面的 Kotlin 代码比较一下:

fun getSound(animal: Animal) = when(animal) {
    is Cat -> animal.purr()
    is Dog -> animal.bark()
    else -> throw RuntimeException()
}

由于when是一个表达式,我们完全避免了中间变量的使用。更重要的是,使用模式匹配,我们还可以避免大部分与类型检查和转换相关的代码。

递归

递归是一个函数用新的参数调用自己:

fun sumRec(i: Int, numbers: List<Int>): Long {
    return if (i == numbers.size) {
        0
    } else {
        numbers[i] + sumRec(i + 1, numbers)
    }
}

我们通常避免递归,因为如果我们的调用栈太深,我们可能会收到栈溢出错误。你可以用一个包含一百万个数字的列表调用这个函数来体验它:

val numbers = List(1_000_000) {it}
println(sumRec(0,  numbers)) // Crashed pretty soon, around 7K 

尾递归的一个巨大好处是它避免了可怕的栈溢出异常。

让我们使用一个新的关键字tailrec重写我们的递归函数,以避免这个问题:

tailrec fun sumRec(i: Int, sum: Long, numbers: List<Int>): Long {
    return if (i == numbers.size) {
        return sum
    } else {
        sumRec(i+1, numbers[i] + sum, numbers)
    }
}

现在编译器将优化我们的调用并完全避免异常。

摘要

你现在应该对函数式编程及其好处有了更好的理解。我们讨论了不可变性和纯函数的概念。两者的结合通常会产生更易于测试的代码,也更容易维护。

柯里化和记忆化是两个源自函数式编程的有用模式。

Kotlin 有一个tailrec关键字,允许编译器优化尾递归。我们还探讨了高阶函数、表达式与语句的区别,以及模式匹配。

在下一章中,我们将把所学知识应用于实践,并了解如何通过在函数式编程的基础上构建,来创建可扩展和健壮的系统。

第六章:数据流

在本章中,我们将讨论集合的高级函数。对于 Java 开发者来说,它们首次出现在 Java 8 中,随着 Stream API 的引入。但在函数式语言中,它们已经存在很长时间了。

首先,由于我们预计许多读者都熟悉 Java 8,让我们简要介绍一下 Java 中的 Stream API 是什么。

Java8 中的流与一些具有相似名称的 I/O 类(如InputStreamOutputStream)不可混淆。后者表示数据,而前者是相同类型元素的序列。

如果这些是序列,并且它们都具有相同的类型,那么它们与Lists有何不同?嗯,流可以是无限的,而集合不是。

为 Java 流定义了一系列操作。这些操作不仅对任何类型的流都是相同的,而且对于那些来自完全不同语言的操作,它们还有熟悉的名字。例如,JavaScript 中的map()函数与 Java 中的map()方法做的是相同的事情。

大量使用小型、可重用和可组合函数的想法直接来自我们在上一章讨论的函数式编程。它们允许我们以描述我们想要做什么的方式编写代码,而不是我们想要如何做

但在 Java 中,要使用这些函数,我们必须接收一个流或从集合创建一个流。

在 Java 中,为了获取集合的所有这些功能,我们可以做以下操作:

Arrays.asList("a", "b", "c") // Initialize list
    .stream() // Convert to stream
    .map(...) // Do something functional here   
    .toList() // Convert back to list

在 Kotlin 中,你可以做同样的事情:

listOf("a", "b", "c").stream().map{...}.toList()

但所有这些方法以及更多方法都直接在集合上可用:

listOf("a", "b", "c").map(...) 

那就是全部;除非您最初计划操作无限数据,否则不需要从流转换回集合。

当然,事情并没有那么简单,但我们在本章末尾的流是懒的,集合不是部分中涵盖了差异和陷阱。让我们先了解那些奇怪函数实际上做什么。

在本章中,我们无法涵盖集合上所有可用的函数,但我们将介绍最常用的那些。

示例将相对无聊,主要是数字、字母和人的列表。这样可以让您专注于每个函数的实际工作方式。我们将在下一章回到一些疯狂示例。敬请期待。

it 表示法

我们在前几章中简要地提到了it的概念,但为了本章,我们需要更深入地理解它(有意为之)。

Kotlin 的一切都关于简洁。首先,如果我们的 lambda 没有参数,我们就不需要指定任何内容:

val noParameters = { 1 } // () -> Int implicitly

但如果我们有一个接受另一个函数作为参数(为了简单起见,我们不对其做任何操作)的函数呢?请看以下代码:

fun oneParameter(block: (Int)->Long){ }

我们可以明确指定参数名称和类型,并将它们放在括号中,就像调用其他任何函数一样:

val oneParameterVeryVeryExplicit = oneParameter( {x: Int -> x.toLong() })

但由于 lambda 是最后一个参数(在这种情况下,也是唯一的参数),我们可以省略括号:

val oneParameterVeryExplicit = oneParameter {x: Int -> x.toLong() }

由于编译器可以推断参数的类型,我们也可以省略它:

val oneParameterExplicit = oneParameter {x -> x.toLong() }

由于 x 是唯一的参数,我们可以使用它的隐含名称,即 it

val oneParameterImplicit = oneParameter { it.toLong() }

在接下来的大多数示例中,我们将使用最简短的表示法。

map() 函数

集合中最知名的高阶函数之一是 map()

假设你有一个函数,它接收一个字符串列表并返回一个新列表,其大小与原列表相同,每个字符串都与其自身连接:

val letters = listOf("a", "b", "c", "d")

println(repeatAll(letters)) // [aa, bb, cc, dd]

这个任务相当简单:

fun repeatAll(letters: List<String>): MutableList<String> {
    val repeatedLetters = mutableListOf<String>()

    for (l in letters) {
        repeatedLetters.add(l + l)
    }
    return repeatedLetters
}

但对于这样一个简单的任务,我们不得不写很多代码。为了将每个字符串改为大写而不是重复两次,我们需要做些什么改变?我们只想改变这一行:

repeatedLetters.add(l + l) ----> repeatedLetters.add(l.toUpperCase())

但我们必须为这个创建另一个函数。

当然,在 Kotlin 中,我们可以将一个函数作为第二个参数传递。由于我们实际上并不关心类型是什么,只要输入和输出相同即可,我们可以使用泛型:

fun <T> repeatSomething(input: List<T>, action: (T) -> T): MutableList<T> {
    val result = mutableListOf<T>()

    for (i in input) {
        result.add(action(i))
    }
    return result
}

现在,我们可以这样调用我们的 泛型化 函数:

println(repeatSomething(letters) {
    it.toUpperCase()
})

这几乎就是 .map() 所做的:

println(letters.map {
    it.toUpperCase()
})

map() 的另一种变体是 mapTo()

除了 lambda,它还接收一个目的地,结果应该被合并到那里。

你可以这样做:

val letters = listOf("a", "B", "c", "D")
val results = mutableListOf<String>()

results.addAll(letters.map {
    it.toUpperCase()
})

results.addAll(letters.map {
    it.toLowerCase()
})

println(results)

mapTo() 允许你这样做:

val letters = listOf("a", "B", "c", "D")
val results = mutableListOf<String>()

letters.mapTo(results) {
    it.toUpperCase()
}

letters.mapTo(results) {
    it.toLowerCase()
}

println(results)

在第二种情况下,我们使用结果列表作为参数,这允许我们减少代码嵌套。

过滤家族

另一个常见任务是过滤集合。你知道该怎么做。你遍历它,只将符合你标准的值放入新的集合中。例如,如果给定 1-10 的数字范围,我们只想返回奇数。当然,我们已经从上一个例子中学到了这个教训,不会简单地创建一个名为 filterOdd() 的函数,因为后来我们还需要实现 filterEven()filterPrime() 等等。我们将立即接收到一个 lambda 作为第二个参数:

fun filter(numbers: List<Int>, check: (Int)->Boolean): MutableList<Int> {
    val result = mutableListOf<Int>()

    for (n in numbers) {
        if (check(n)) {
            result.add(n)
        }
    }

    return result
}

调用它将只打印奇数。多么奇怪:

println(filter((1..10).toList()) {
    it % 2 != 0
}) // [1, 3, 5, 7, 9]

当然,我们已经有了一个内置的函数,它正好做这件事:

println((1..10).toList().filter {
    it % 2 != 0
})

查找家族

假设你有一个无序的对象列表:

data class Person(val firstName: String, 
                  val lastName: String,
                  val age: Int)
val people = listOf(Person("Jane", "Doe", 19),
            Person("John", "Doe", 24),
            Person("John", "Smith", 23))

并且你想找到第一个符合 某些标准 的对象。使用扩展函数,你可以编写如下内容:

fun <T> List<T>.find(check: (T) -> Boolean): T? {
    for (p in this) {
        if (check(p)) {
            return p
        }
    }
    return null
}

然后,当你有一个对象列表时,你可以简单地调用 find()

println(people.find {
    it.firstName == "John"
}) // Person(firstName=John, lastName=Doe)

幸运的是,你不需要实现任何内容。这个方法已经在 Kotlin 中为你实现了。

此外,还有一个配套的 findLast() 方法,它执行相同的操作,但以集合的最后一个元素开始:

println(people.findLast {
    it.firstName == "John"
}) // Person(firstName=John, lastName=Smith)

删除家族

好吧,如果你必须遍历集合中的所有元素,这很酷。但在 Java 的 for 循环中,你可以这样做:

// Skips first two elements
for (int i = 2; i < list.size(); i++) {
   // Do something here
}

你打算用你那些古怪的功能如何实现这一点,嗯?

好吧,为此我们有 drop()

val numbers = (1..5).toList()
println(numbers.drop(2)) // [3, 4, 5]

请注意,这不会以任何方式修改原始集合:

println(numbers) // [1, 2, 3, 4, 5]

如果你想要提前停止你的 循环,可以使用 dropLast()

println(numbers.dropLast(2)) // [1, 2, 3]

另一个有趣的功能是dropWhile(),它接收一个谓词而不是数字。它跳过直到谓词第一次返回 true:

val readings = listOf(-7, -2, -1, -1, 0, 1, 3, 4)

println(readings.dropWhile {
    it <= 0
}) // [1, 3, 4]

并且还有相应的dropLastWhile()

排序家族

别担心,我们不需要实现自己的排序算法。这不是计算机科学 101。

在拥有前一个find()示例中的人员列表的情况下,我们希望按年龄对他们进行排序:

val people = listOf(Person("Jane", "Doe", 19),
        Person("John", "Doe", 24),
        Person("John", "Smith", 23))

这可以通过sortedBy()轻松实现:

println(people.sortedBy { it.age })

前一个代码打印以下输出:

[Person(firstName=Jane, lastName=Doe, age=19), Person(firstName=John, lastName=Smith, age=23), Person(firstName=John, lastName=Doe, age=24)]

还有sortedByDescending(),它将反转结果的顺序:

println(people.sortedByDescending { it.lastName })

前一个代码打印以下输出:

[Person(firstName=John, lastName=Smith, age=23), Person(firstName=John, lastName=Doe, age=24), Person(firstName=Jane, lastName=Doe, age=19)]

如果你想要根据多个参数进行比较,请使用sortedWithcompareBy的组合:

println(people.sortedWith(compareBy({it.lastName}, {it.age})))

ForEach

这是我们将要看到的第一个 终止符。终止符函数返回的不是新集合,因此你不能将此调用的结果链式调用到其他调用中。

forEach()的情况下,它返回Unit。所以它就像普通的旧for循环:

val numbers = (0..5)

numbers.map { it * it}          // Can continue
       .filter { it < 20 }      // Can continue
       .sortedDescending()      // Still can
       .forEach { println(it) } // Cannot continue

请注意,forEach()与传统的for循环相比,有一些轻微的性能影响。

还有forEachIndexed(),它提供了集合中的索引和实际值:

numbers.map { it * it }
        .forEachIndexed { index, value ->
    print("$index:$value, ")
}

前一个代码的输出将如下所示:

0:1, 1:4, 2:9, 3:16, 4:25, 

自从 Kotlin 1.1 以来,还有一个onEach()函数,它更有用一些,因为它会再次返回集合:

numbers.map { it * it}         
       .filter { it < 20 }     
       .sortedDescending()     
       .onEach { println(it) } // Can continue now
       .filter { it > 5 }

连接家族

在先前的例子中,我们使用了打印到控制台的外部效应,这在函数式编程中并不理想。更重要的是,我们还在输出末尾有一个难看的逗号,如下所示:

0:1, 1:4, 2:9, 3:16, 4:25,

肯定有更好的方法。

你有多少次不得不实际编写代码来简单地将一些值列表连接成一个字符串?好吧,Kotlin 有一个函数可以做到这一点:

    val numbers = (1..5)

    println(numbers.joinToString { "$it"})

前一个代码将给出以下输出:

1, 2, 3, 4, 5

简直太美了,不是吗?

如果你想要用其他字符分隔,或者不想有空格,有一种方法可以配置它:

println(numbers.joinToString(separator = "#") { "$it"})

前一个代码的输出将如下所示:

1#2#3#4#5

Fold/Reduce

forEach()类似,fold()reduce()都是终止函数。但它们不是以无用的Unit终止,而是以相同类型的单个值终止。

reduce最常用的例子当然是累加。在先前的例子中的人员列表中,我们可以做以下操作:

println(people.reduce {p1, p2 ->
        Person("Combined", "Age", p1.age + p2.age)
    })

前一个代码的输出将如下所示:

Person(firstName=Combined, lastName=Age, age=64)

嗯,把很多人合并成一个没有太多意义,除非你是某些恐怖电影的粉丝。

但使用reduce,我们还可以计算列表中最老或最年轻的人:

println(people.reduce {p1, p2 ->
    if (p1.age > p2.age) { p1 } else { p2 }
})

我们将要讨论的第二个函数fold()reduce()非常相似,但它还接受另一个参数,即初始值。当你已经计算了一些东西,现在想使用这个中间结果时,它很有用:

println(people.drop(1) // Skipping first one
       .fold(people.first()) // Using first one as initial value
             {p1, p2 ->
    Person("Combined", "Age", p1.age + p2.age)
})

平铺家族

假设你有一系列其他列表。你可能从不同的数据库查询中得到了它,或者可能来自不同的配置文件:

val listOfLists = listOf(listOf(1, 2),
        listOf(3, 4, 5), listOf(6, 7, 8))

// [[1, 2], [3, 4, 5], [6, 7, 8]]

而您希望将它们转换成一个如下的单列表:

[1, 2, 3, 4, 5, 6, 7, 8]

合并这些列表的一种方法是通过编写一些命令式代码:

val results = mutableListOf<Int>()

for (l in listOfLists) {
    results.addAll(l)
}

但调用 flatten() 会为您做同样的事情:

listOfLists.flatten()

您也可以使用 flatMap() 控制这些结果的处理:

println(listOfLists.flatMap {
    it.asReversed()
})

注意,在这种情况下,它指的是其中一个子列表。

您也可以选择使用 flatMap() 进行类型转换:

println(listOfLists.flatMap {
    it.map { it.toDouble() }
//  ^        ^
// (1)      (2)
})

上述代码打印出以下输出:

[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]

我们将所有整数转换为双精度浮点数,然后合并到一个列表中。

注意,第一个 it 指的是列表中的一个,而第二个 it 指的是当前列表中的一个单独的项目。

flatten() 而言,它只会扁平化一层。为了证明这一点,我们将使用 Set 作为第一层嵌套,List 作为第二层嵌套,再次使用 Set 作为第三层嵌套:

val setOfListsOfSets = setOf(
//                     ^
//                    (1)
        listOf(setOf(1, 2), setOf(3, 4, 5), setOf(6, 7, 8)), 
//      ^      ^
//     (2)    (3)
        listOf(setOf(9, 10), setOf(11, 12))
//      ^      ^
//     (2)    (3)
)
// Prints [[[1, 2], [3, 4, 5], [6, 7, 8]], [[9, 10], [11, 12]]]

如果我们只调用一次 flatten,我们只会收到第一层扁平化:

println(setOfListsOfSets.flatten())

上述代码打印出以下输出:

[[1, 2], [3, 4, 5], [6, 7, 8], [9, 10], [11, 12]]

要完全扁平化列表,我们需要调用 flatten() 两次:

println(setOfListsOfSets.flatten().flatten())

上述代码打印出以下输出:

[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]

Kotlin 阻止我们三次调用 flatten(),因为它识别出我们有多少嵌套:

//Won't compile
println(setOfListsOfSets.flatten().flatten().flatten())

Slice

假设我们有一个元素列表,如下所示:

val numbers = (1..10).toList()
// Prints [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

我们可以使用 slice() 只取列表的一部分:

println(numbers.slice((0..3)))
// Prints [1, 2, 3, 4], last index is included

我们正在使用 Kotlin 范围,这是一个很好的语法。

在 Java 中,有一个 subList() 方法,它与它类似,但不包含:

println(numbers.subList(0, 3))
// Prints [1, 2, 3], last index is excluded

Chunked

在生产代码中,这种分块逻辑非常常见。

您从某处读取了一个庞大的标识符列表,您需要检查您的数据库或某些远程服务是否包含它们。但是,对单个请求中可以传递的标识符数量有限制。例如,数据库通常对查询的参数数量和总查询长度有限制:

fun dbCall(ids: List<Int>) {
    if (ids.size > 1000) {
        throw RuntimeException("Can't process more than 1000 ids")
    }
    // Does something here
}

我们不能简单地将整个列表传递给我们的函数:

// That will fail at runtime
dbCall(hugeList)

因此,我们编写了大量命令式代码:

val pageSize = 1000
val pages = hugeList.size / pageSize

for (i in 0..pages) {
    val from = i * pageSize
    val p = (i+1) * pageSize
    val to = minOf(p, hugeList.size)
    dbCall(hugeList.slice(from until to))
}

幸运的是,自从 Kotlin 1.2 以来,有 chunked() 函数可以做到这一点:

hugeList.chunked(pageSize) {
    dbCall(it)
}

Zip/Unzip

与存档无关,zip() 允许我们根据索引从两个列表中创建对。这可能听起来有些令人困惑,所以让我们看看一个例子。

我们有两个函数,一个用于获取所有活跃的员工,另一个用于员工在我们初创公司工作的天数:

val employeeIds = listOf(5, 8, 13, 21, 34, 55, 89)
val daysInCompany = listOf(176, 145, 117, 92, 70, 51, 35, 22, 12, 5)

在两者之间调用 zip() 将产生以下结果:

println(employeeIds.zip(daysInCompany))

上述代码打印出以下输出:

[(5, 176), (8, 145), (13, 117), (21, 92), (34, 70), (55, 51), (89, 35)]

注意,由于我们在第二个函数中有一个错误,返回了已经离开我们初创公司的员工的日期,因此两个列表的长度一开始就不相等。调用 zip() 总是会产生最短的对列表:

println(daysInCompany.zip(employeeIds))

上述代码打印出以下输出:

[(176, 5), (145, 8), (117, 13), (92, 21), (70, 34), (51, 55), (35, 89)]

注意,这不是一个映射,而是一对列表。

有这样一个列表,我们还可以将其解包:

val employeesToDays = employeeIds.zip(daysInCompany)

val (employees, days) = employeesToDays.unzip()
println(employees)
println(days)

上述代码打印出以下内容:

[5, 8, 13, 21, 34, 55, 89]
[176, 145, 117, 92, 70, 51, 35]

流是懒加载的,集合不是

虽然要对大型集合上的那些函数小心谨慎。大多数函数都会为了不可变性而复制集合。

as 开头的函数不会这样做:

// Returns a view, no copy here
(1..10).toList().asReversed()

// Same here
(1..10).toList().asSequence()

要了解差异,请检查以下代码:

val numbers = (1..1_000_000).toList()
println(measureTimeMillis {
    numbers.stream().map {
        it * it
    }
}) // ~2ms

println(measureTimeMillis {
    numbers.map {
        it * it
    }
}) // ~19ms

你会注意到使用 stream() 的代码实际上从未执行。流是惰性的,它们等待一个终止函数调用。另一方面,集合上的函数是依次执行的。

如果我们添加终止调用,我们会看到完全不同的数字:

println(measureTimeMillis {
    numbers.stream().map {
        it * it
    }.toList()
}) // ~70ms

将流转换回列表是一个昂贵的操作。在决定使用哪种方法时,请考虑这些要点。

序列

由于流仅在 Java 8 中引入,但 Kotlin 与 Java 6 兼容,因此需要提供另一种解决方案以处理无限集合的可能性。这个解决方案被命名为 sequenced,因此当 Java 流可用时,它不会与之冲突。

你可以从 1 开始生成一个无限序列:

val seq = generateSequence(1) { it + 1 }

要只取前 100 个,我们使用 take() 函数:

    seq.take(100).forEach {
        println(it)
    }

通过返回 null 可以创建有限数量的序列:

val finiteSequence = generateSequence(1) {
    if (it < 1000) it + 1 else null
}

finiteSequence.forEach {
        println(it)
} // Prints numbers up to 1000

通过调用 asSequence() 可以从范围或集合创建有限数量的序列:

(1..1000).asSequence()

摘要

本章致力于练习函数式编程原则,并学习 Kotlin 中函数式编程的构建块。

现在,你应该知道如何使用 map()/mapTo() 转换你的数据,如何 filter() 集合,以及根据标准 find() 元素。

你还应该熟悉如何使用 drop() 跳过元素,如何 sort() 集合,以及如何使用 forEach()onEach() 迭代它们。

使用 join() 将集合转换为字符串,使用 fold()reduce() 对集合求和,使用 flatten()flatTo() 减少集合嵌套。

slice() 是获取集合一部分的方法,而 chunked() 用于将集合分成相等的部分。

最后,zip()unzip() 将两个集合组合成一对,或者将一对拆分成两部分。

在下一章中,我们将讨论熟悉这些方法如何帮助我们真正地实现响应式编程。

第七章:保持响应式

一旦我们熟悉了函数式编程及其构建块,我们就可以开始讨论响应式编程的概念。虽然它并不与函数式编程耦合(你甚至可以在编写面向对象或过程式代码时变得响应式),但在了解函数式编程及其基础之后讨论会更好。

在本章中,我们将涵盖以下主题:

  • 响应式原则

  • 响应式扩展

响应式原则

那么,什么是响应式编程呢?

响应式宣言很好地总结了这一点:www.reactivemanifesto.org

引用它,响应式程序是:

  • 响应性

  • 弹性

  • 弹性

  • 消息驱动

为了理解这四个主题,让我们想象有 10 个人站在收银员队伍中。他们中的每一个人只能看到前面的人,但看不到前面有多少人在排队,或者收银员在做什么。你脑海中有没有这个画面?那么,我们就从这里开始。

响应性

你会站在那个收银员队伍中吗?

这取决于紧急程度和你的时间。如果你很匆忙,你可能会在到达收银台之前空手而归。

这是一种系统对你不响应的情况。当你通过电话联系某家服务提供商的客户服务中心时,你经常会遇到这种情况。你会被要求在电话线上等待,然后你就等待。但是,更常见的情况是,一个友好的自动语音会告诉你,在你前面有多少人在等待,甚至告诉你你需要等待多长时间。

在这两种情况下,结果都是一样的。你在排队或电话线上浪费了时间。但第二个系统对你的需求做出了响应,你可以据此做出决定。

弹性

让我们继续讨论弹性。你已经在电话线上等了 10 分钟,然后队伍消失了。或者,你联系到了一位客户服务代表,但他们不小心挂断了你的电话。这种情况有多常见?这就是系统对失败不具弹性的表现。或者,你排队等了半小时去看医生,但他们突然离开办公室去打高尔夫球,让你明天再来。这是一个面对失败时没有响应的系统。

响应式宣言讨论了实现弹性的各种方法:

  • 委派

  • 复制

  • 隔离

  • 隔离

委派是指医生走出办公室告诉你,“我今天不能见你,但敲一下另一扇门;他们很快就会见你。”

复制是为了诊所总是有两名医生可用,以防其中一名医生今晚错过了他们最喜欢的球队比赛。这与弹性有关,我们将在下一节讨论。

限制和隔离通常一起讨论。如果你实际上不需要看医生呢?也许你只需要他们的处方。那么,你可以给他们留条消息(我们很快会讨论消息传递,因为它也是反应性的一个重要部分),当他们游戏间隙时,他们会给你发送处方。你从看医生的需求中解脱出来。这也让你从医生的失败或问题中得到了隔离。你不知道的是,在他们打印处方的时候,他们的电脑两次崩溃,他们对此非常焦虑。但因为你不在他们面前,他们把这件事留给了自己。

弹性

所以,在前一节中,我们讨论了复制。为了防止失败,我们的诊所总是有两个医生可用。也许第二位医生服务了一些病人,或者他们只是耐心地等待第一位医生离开去踢足球比赛开始工作。

但是,如果突然爆发流感疫情或一群狂怒的松鼠开始在附近的公园攻击市民,那么那个弹性系统会发生什么?两位医生将无法处理所有病人,然后,我们再次面临弹性问题。

但如果我们有一批退休医生坐在家里打麻将呢?当然,我们可以叫他们来帮助包扎所有那些松鼠受害者。在他们都得到适当治疗后,医生们可以回到他们的麻将桌。

这是一个根据工作量而弹性的系统。

弹性建立在可扩展性之上。我们之所以能够治疗所有那些病人,是因为每位医生可以独立工作。但如果所有的绷带都存放在一个盒子里呢?那么就会形成瓶颈,所有医生都站在那里等待下一包绷带。

消息驱动

这也被称为异步消息传递。所以,我们在弹性部分看到,如果你能给医生留条消息,这可能会使系统更具弹性。

如果所有的病人都只留下消息呢?那么每位医生都可以优先处理它们或批量处理这些消息。例如,一起打印所有处方,而不是在不同任务之间切换。

除了松散耦合和隔离之外,还有位置透明性。你不知道你的医生是在开车回家的路上给你发送这个处方的(在你留言的时候,他们从窗户溜出去)。但你并不在乎,因为你得到了你想要的东西。

使用消息还允许一个有趣的选项,即背压。如果医生收到的消息太多,他们可能会因为压力而崩溃。为了避免这种情况,他们可能会给你发短信说你需要等待更长一点时间才能收到处方。或者,如果他们有秘书,我们甚至可以要求他们这样做。再次强调,我们在这里讨论的是委托,因为所有这些原则都是相关的。

消息也是非阻塞的。在您离开消息后,您不必等待医生的回复。您通常会回家,继续您的常规任务。在等待时执行其他任务的能力是并发的一个基石。

反应式扩展

本章的其余部分将致力于 Kotlin 中反应式原则的具体实现。这个领域的首选库是 RxJava。由于 Kotlin 与 Java 库完全兼容,RxKotlin 只是原始 RxJava 的一层薄包装。因此,我们将将其视为同一个库,并突出显示任何差异。

一旦我们开始讨论 RxJava,您就会认出它是建立在我们在 第四章 中讨论的 观察者 设计模式之上的。

我们将首先在我们的 Gradle 项目中添加以下依赖项:

compile "io.reactivex.rxjava2:rxjava:2.1.14"

目前,这是 RxJava2 的最新版本,但当你阅读这一章时,可能已经有了更新的版本。请随意使用它。

您可能还记得,该模式由两个对象组成:

  • 发布者:产生数据

  • 订阅者:消费数据

在 RxJava 中,发布者被称为 Observable

以下代码将创建我们的第一个发布者:

val publisher = Observable.fromArray(5, 6, 7)

要开始消费这些数字,我们可以向 subscribe() 函数提供一个 lambda 表达式:

publisher.subscribe {
    println(it)
} // Prints 1, 2, 3

Observable 上还有其他可用的函数,您会立即认出:例如 map()filter()。这些函数与 Kotlin 中常规数组上的函数相同:

publisher.filter {
    it > 5
}.map {
    it + it
}.subscribe {
    println(it)
}

好的,这很好,但我们已经在上一章讨论了序列中的集合和流。为什么还要再次讨论?

让我们看看以下示例:

val publisher = Observable.interval(1, TimeUnit.SECONDS)

publisher.subscribe {
    println("P1 $it")
}

publisher.subscribe {
    println("P2 $it")
}

Thread.sleep(TimeUnit.SECONDS.toMillis(5))

此代码将在终止前等待五毫秒,并打印以下内容:

Sleeping <= This was the last line in our code, actually
P2 0     <= P2 came after P1 in code, but it comes before now
P1 0
P2 1
P1 1
P2 2
P1 2

这是不预期的。Sleeping 是代码中的最后一行,但它首先被打印出来。然后注意,如果您多次运行此示例,有时 P2 会先于 P1 打印。有时,P1 会先于 P2 打印,这与代码中的情况相似。这里发生了什么?

这就是异步操作的实际应用。我们需要在这里使用 Thread.sleep() 以允许我们的监听器运行一段时间,否则我们的程序将终止。并且当它们被调用时,它们被放置在代码中的位置无关紧要。

在本章中,我们将大量使用 Thread.sleep()CountDownLatch 来演示异步操作的工作原理。在实际应用中,您永远不应该使用 Thread.sleep()。如果您还不熟悉 CountDownLatch,不要担心,我们将在第一次遇到它时在 Flowables 部分解释它的工作原理。

好吧,这就是观察者设计模式自然的行为。但观察者还有一个取消订阅的选项。我们在这里是如何实现的?

让我们用以下代码替换第二个监听器:

...
val subscription = publisher.subscribe {
    println("P2 $it")
}

println("Sleeping")
Thread.sleep(TimeUnit.SECONDS.toMillis(2))
subscription.dispose()
...

调用 subscribe() 返回一个 Disposable。当你不再想接收更新时,你可以调用它上的 dispose(),这与 取消订阅 同义。

你的输出可能看起来像这样:

Sleeping
P1 0        <= Notice that P1 is the first one now
P2 0
P1 1
Sleeping    <= This is after dispose/unsubscribe
P2 1        <= But it may still take some time, so P2 prints again
P1 2 
P1 3
P1 4        <= No more prints from P2, it unsubscribed

如果我们要创建自己的 Observable,具有它自己的特定逻辑怎么办?有一个 create() 方法可以做到这一点:

val o = Observable.create<Int> {
    for (i in 1..10_000) {
        it.onNext(i)
    }
    it.onComplete()
}

我们创建一个发布数字的 Observable。要向所有听众推送新值,我们使用 onNext() 方法。我们通过 onComplete() 通知听众没有更多数据了。最后,如果发生错误,我们可以调用 onError(),并提供异常作为参数。

你会注意到,如果我们尝试实际调用 onError(),我们会得到一个异常:

val o = Observable.create<Int> {
    it.onError(RuntimeException())
}

o.subscribe {
    println("All went good: $it")
} // OnErrorNotImplementedException

这是因为我们使用了带有 lambda 监听器的简写形式。

如果我们想要正确处理错误,我们还需要提供 错误处理器 作为第二个参数:

o.subscribe({
    println("All went good: $it")
}, {
    println("There was an error $it")
})

还有一个第三个参数,即 onComplete 处理器:

o.subscribe({
    println("All went good: $it")
}, {
    println("There was an error $it")
}, {
    println("Publisher closed the stream")
})

在我们的示例中,我们很少使用错误处理器,因为我们的代码非常基础。但在实际应用中,你应该始终提供它们。

热观察者

Observable 是我们在本章中会大量使用的一个术语,与冷 Observable 相对。我们之前讨论的所有 Observable 都是冷的。这意味着它们知道从时间开始发生的一切,每次有人礼貌地询问时,它们都可以重复整个历史。热 Observable 只知道现在发生的事情。例如,想想天气预报和天气历史。天气预报是热的——你将每分钟得到当前的天气。天气历史是冷的——如果你关心它,你可以得到整个天气变化的历史。如果你仍然不理解这个概念,不要过于担心。我们还有一半的章节要覆盖它。

如你可能已经注意到的,到目前为止,所有我们的订阅者总是得到所有数据,无论他们何时订阅:

publisher.subscribe {
    println("S1 $it")
} // Prints 10K times

publisher.subscribe {
    println("S2 $it")
} // Also prints 10K times

但情况并不总是这样。更常见的是,数据源来自外部,而不是每次都由 publisher 创建:

val iterator = (1..10).iterator()

val publisher = Observable.create<Int> {
    while (iterator.hasNext()) {
        val nextNumber = iterator.next()
        it.onNext(nextNumber)
    }
}

在这里,我们不是在内部创建列表,而是有一个对其迭代器的引用。

让我们看看以下代码现在是如何表现的:

publisher.subscribeOn(Schedulers.newThread()).subscribe {
    println("S1: $it")
    Thread.sleep(10)
}

Thread.sleep(50)

publisher.subscribeOn(Schedulers.newThread()).subscribe {
    println("S2: $it")
    Thread.sleep(10)
}

Thread.sleep(50)

我们有两个订阅者,就像之前一样。到目前为止,所有订阅者都在我们运行的同一个线程上执行。对于这个例子,我们为每个订阅者分配了一个单独的线程。这将允许我们模拟运行一段时间(在这种情况下是 10 毫秒)的操作。要指定订阅者应该在哪个线程上运行,我们使用 subscribeOn()Schedulers 是一个实用类,类似于 Java 5 中的 Executors。在这种情况下,它将为每个听众分配一个新的线程。

输出可能看起来像这样:

S1: 1
S1: 2
S1: 3
S1: 4
S1: 5
S2: 6 <= That's where "Subscriber 2" begins listening
S1: 7
S2: 8
S1: 9
S2: 10

注意,如果每个消费者之前都接收到了所有数据,现在第二个订阅者将永远不会接收到数字 1-5。

在第二个订阅者连接后,每次只有其中之一会接收到数据。

如果我们想同时向所有订阅者发布数据怎么办?

多播

有一个 publish() 方法可以做到这一点:

val iterator = (1..5).iterator()
val subject = Observable.create<Int> {
    while (iterator.hasNext()) {
        val number = iterator.nextInt()
        println("P: $number")
        it.onNext(number)
        Thread.sleep(10)
    }
}.observeOn(Schedulers.newThread()).publish()

我们再次创建了一个稍微热的Observable,但这次我们指定它将在单独的线程上运行,使用observeOn()。我们还使用了publish()方法,它将我们的Observable转换为ConnectableObservable

如果我们仅仅订阅这种类型的Observable,将不会发生任何事情。我们需要告诉它何时开始运行。我们使用connect()方法来做这件事。由于connect()方法是阻塞的,我们将在这个例子中从单独的线程执行它:

thread { // Connect is blocking, so we run in on another thread
    subject.connect() // Tells observer when to start
}

现在,我们将让发布者工作几毫秒,然后连接我们的第一个监听器:

Thread.sleep(10)
println("S1 Subscribes")
subject.subscribeOn(Schedulers.newThread()).subscribe {
    println("S1: $it")
    Thread.sleep(100)
}

经过一段时间后,我们连接第二个监听器,并允许它们完成:

Thread.sleep(20)

println("S2 Subscribes")
subject.subscribeOn(Schedulers.newThread()).subscribe {
    println("S2: $it")
    Thread.sleep(100)
}
Thread.sleep(2000)

让我们看看现在的输出,因为它相当有趣:

P: 1 *<= Publisher starts publishing even before someone subscribes*
*S1 Subscribes*
P: 2
P: 3
S1: 3 *<= Subscriber actually missed some values*
*S2 Subscribes*
P: 4
P: 5
P: 6 *<= Publisher completes here*
S1: 4
S2: 4 
S1: 5
S2: 5 *<= Both subscribers receive same values*

当然,拥有这个connect()并不总是舒适的。

因此,我们有一个名为refCount()的方法,它将我们的ConnectableObservable转换回常规的Observable。它将保持订阅者的引用计数,并且只有在所有订阅者都这样做之后才会取消订阅:

// This is a connectable Observable
val connectableSource = Observable.fromIterable((1..3)).publish()

// Should call connect() on it
dataSource.connect()

// This is regular Observable which wraps ConnectableObservable
val regularSource = connectableSource.refCount()

regularSource.connect() // Doesn't compile

如果调用publish().refCount()太繁琐,还有一个share()方法可以做到这一点:

val regularSource = Observable.fromIterable((1..3)).publish().refCount()

val stillRegular = Observable.fromIterable((1..3)).share()

Subject

理解Subject的最简单方式是Subject = Observable + Observer

一方面,它允许其他人subscribe()订阅它。另一方面,它可以subscribe到其他Observable

val dataSource = Observable.fromIterable((1..3))

val multicast = PublishSubject.create<Int>()

multicast.subscribe {
    println("S1 $it")
}

multicast.subscribe {
    println("S2 $it")
}

dataSource.subscribe(multicast)

Thread.sleep(1000)

以下代码将打印六行,每个订阅者三行:

S1 1
S2 1
S1 2
S2 2
S1 3
S2 3

注意,我们没有在dataSource上使用publish(),所以它是冷的。冷意味着每次有人订阅这个源时,它都会重新开始发送数据。另一方面,热的Observable并没有所有数据,它只会从这一刻开始发送它所拥有的数据。

因此,我们需要首先连接所有监听器,然后才开始监听dataSource

如果我们使用热的dataSource,我们可以切换调用:

val dataSource = Observable.fromIterable((1..3)).publish()

val multicast = PublishSubject.create<Int>()

dataSource.subscribe(multicast)

multicast.subscribe {
    println("S1 $it")
}
println("S1 subscribed")

multicast.subscribe {
    println("S2 $it")
}
println("S2 subscribed")

dataSource.connect()

Thread.sleep(1000)

如前所述,我们使用connect()来告诉dataSource何时开始发射数据。

ReplaySubject

除了我们在上一节中讨论的PublishSubject之外,还有其他主题可用。为了理解ReplaySubject是如何工作的,让我们首先看看以下使用PublishSubject的例子:

val list = (8..23).toList() // Some non trivial numbers
val iterator = list.iterator()
val o = Observable.intervalRange(0, list.size.toLong(), 0, 10, TimeUnit.MILLISECONDS).map {
    iterator.next()
}.publish()

val subject = PublishSubject.create<Int>()

o.subscribe(subject)

o.connect() // Start publishing

Thread.sleep(20)

println("S1 subscribes")
    subject.subscribe {
        println("S1 $it")
    }
    println("S1 subscribed")

    Thread.sleep(10)

    println("S2 subscribes")
    subject.subscribe {
        println("S2 $it")
    }
    println("S2 subscribed")

    Thread.sleep(1000)

这将打印以下内容:

S1 11 <= Lost 8, 9, 10
S1 12
S2 12 <= Lost also 11
S1 13
S2 13
...

显然,一些事件已经永久丢失。

现在,让我们用ReplaySubject替换PublishSubject并检查输出:

val subject = ReplaySubject.create<Int>()

以下输出将被打印:

S1 subscribes
S1 8
S1 9
S1 10 <= S1 catchup 
S1 subscribed
S1 11
S1 12
S2 subscribes
S2 8
S2 9
S2 10
S2 11
S2 12 <= S2 catchup 
S2 subscribed
S1 13 <= Regular multicast from here
S2 13
...

使用ReplaySubject,不会丢失任何事件。然而,从输出中可以看出,直到某个点,事件不会多播,即使有多个subscriber。相反,对于每个subscriberReplaySubject会执行一种追赶,以弥补到目前为止错过的事件。

这种方法的优点是显而易见的。我们将看似热的Observable转换成了相当冷的。但也有一些限制。通过使用ReplaySubject.create,我们产生了一个无界的主题。如果它尝试记录太多事件,我们可能会简单地耗尽内存。为了避免这种情况,我们可以使用createWithSize()方法:

val subject = ReplaySubject.createWithSize<Int>(2)

它创建了以下输出:

S1 subscribes
S1 9 <= lost 8
S1 10
S1 subscribed
S1 11
S2 subscribes
S1 12
S2 11 <= lost 8, 9, 10
S2 12
S2 subscribed
S1 13
S2 13
...

如您所见,现在我们的主题记住的项目更少,因此最早的事件丢失了。

BehaviorSubject

想象一下这种情况:你每分钟都会有一串更新。你想要显示你收到的最新值,然后在有新数据进来时继续更新它。你可以使用大小为 1 的ReplaySubject。但还有BehaviorSubject正好适用于这种情况:

val subject = BehaviorSubject.create<Int>()

输出结果如下:

S1 subscribes
S1 10 <= This was the most recent value, 8 and 9 are lost
S1 subscribed
S1 11 <= First update 
S2 subscribes
S2 11 <= This was most recent value, 8, 9 and 10 lost
S2 subscribed
S1 12 <= As usual from here
S2 12 

AsyncSubject

这是一个奇怪的subject,因为它与其他不同,它不会更新其订阅者。那么它有什么好处呢?

如果你想有一个非常基本的功能,只是更新屏幕上的最新值,并且直到屏幕关闭不再刷新:

val subject = AsyncSubject.create<Int>()

这里是输出结果:

S1 subscribes
S1 subscribed
S2 subscribes
S2 subscribed
S1 23 <= This is the final value
S2 23

但是要小心。由于AsyncSubject等待序列完成,如果序列是无限的,它将永远不会调用其订阅者:

// Infinite sequence of 1
val o = Observable.generate<Int> { 1 }.publish()
...
o.connect() // Hangs here forever

SerializedSubject

重要的是不要从不同的线程调用onNext()/onComplete()/onError()方法,因为这会使调用非序列化。

这是一个某种形式的代理,围绕任何常规subject,它同步调用不安全的方法。你可以使用toSerialized()方法将任何subject包装在SerializedSubject中:

val subject = ReplaySubject.createWithSize<Int>(2).toSerialized()

Flowables

在所有之前的例子中,我们使用Observablesubject来发射数据,它们也扩展了Observable,并且效果相当不错。

但我们的听众并没有做什么。如果他们要做更多实质性的事情会怎样?

让我们看看以下示例。我们将生成大量的唯一字符串:

val source = Observable.create<String> {
    var startProducing = System.currentTimeMillis()
    for (i in 1..10_000_000) {
        it.onNext(UUID.randomUUID().toString())

        if (i % 100_000 == 0) {
            println("Produced $i events in ${System.currentTimeMillis() - startProducing}ms")
            startProducing = System.currentTimeMillis()
        }
    }
    latch.countDown()
}

我们使用CountDownLatch以便主线程能够等待我们完成。此外,我们还打印了发射 100,000 个事件所需的时间。这将在以后很有用。

subscribe()方法中,我们将重复这些字符串 1,000 次:

val counter = AtomicInteger(0)
source.observeOn(Schedulers.newThread())
        .subscribe( {
            it.repeat(500)
            if (counter.incrementAndGet() % 100_000 == 0) {
                println("Consumed ${counter.get()} events")
            }
        }, {
            println(it)
        })

AtomicInteger用于以线程安全的方式计数线程中处理的事件的数量。

我们显然消费的速度比生产慢:

Produced 100000 events in 1116ms
Produced 200000 events in 595ms
Produced 300000 events in 734ms
Consumed 100000 events
Produced 400000 events in 815ms
Produced 500000 events in 705ms
Consumed 200000 events
Produced 600000 events in 537ms
Produced 700000 events in 390ms
Produced 800000 events in 529ms
Produced 900000 events in 387ms
Consumed 300000 events
Produced 1000000 events in 531ms
Produced 1100000 events in 537ms
Produced 1200000 events in 11241ms <= What happens here?
Consumed 400000 events
Produced 1300000 events in 19472ms
Produced 1400000 events in 31993ms
Produced 1500000 events in 52650ms

但有趣的是,在一段时间之后,生产时间将急剧增加。

这是我们开始耗尽内存的点。现在,让我们用Flowable替换我们的Observable

val source = Flowable.create<String> ({
    var startProducing = System.currentTimeMillis()
    for (i in 1..10_000_000) {
        it.onNext(UUID.randomUUID().toString())

        if (i % 100_000 == 0) {
            println("Produced $i events in ${System.currentTimeMillis() - startProducing}ms")
            startProducing = System.currentTimeMillis()
        }
    }
    it.onComplete()
    latch.countDown()
}, BackpressureStrategy.DROP)

如您所见,我们不仅传递了一个 lambda 表达式,还传递了第二个参数,即BackpressureStrategy。幕后发生的事情是Flowable有一个有界缓冲区。这与我们如何使ReplaySubject有界非常相似。第二个参数告诉Flowable当缓冲区达到限制时应该发生什么。在这种情况下,我们要求它丢弃这些事件。

现在,我们应该检查我们输出的最后部分:

...
Produced 9500000 events in 375ms
Produced 9600000 events in 344ms
Produced 9700000 events in 344ms
Consumed 2800000 events
Produced 9800000 events in 351ms
Produced 9900000 events in 333ms
Produced 10000000 events in 340ms

首先,请注意,我们没有在任何地方卡住。实际上,我们的生产速度是恒定的。

第二,你应该注意,尽管我们生产了 1,000 万次事件,但我们消费了只有 280 万次。其他所有事件都被丢弃了。

但我们没有耗尽内存,这是Flowable的巨大好处。

如果你确实想让Flowable表现得像Observable,你可以指定BackpressureStrategy.BUFFER,并看到它开始在这些行周围出现卡顿。

作为一般指南,当以下情况发生时使用Flowable

  • 你计划发射超过 1,000 个项目(有些人可能会说 10,000)

  • 你正在读取文件

  • 你正在查询数据库

  • 你有一些网络流需要处理

如此使用Observable

  • 你计划发射有限数量的数据。

  • 你处理用户输入。人类并不像他们想象的那么快,也不产生很多事件。

  • 你关心流的性能:Observable更简单,因此更快。

当我们使用 lambda 表达式时,我们没有在FlowableObservable之间注意到太大的区别。

取而代之,现在我们将使用匿名类,并看看这种方法提供了哪些好处:

source.observeOn(Schedulers.newThread())
        .subscribe(object : Subscriber<String> {
    lateinit var subscription: Subscription

    override fun onSubscribe(s: Subscription?) {
        s?.let {
            this.subscription = it
        } ?: throw RuntimeException()
    }

    override fun onNext(t: String?) {
        ...
    }

    override fun onError(t: Throwable?) {
        ...
    }

    override fun onComplete() {
        ...
    }
})

这显然是更多的代码。现在我们需要实现四个方法。

我们最感兴趣的是onSubscribe()方法。在这里,我们接收一个新的对象,称为Subscription,并将其存储在一个属性中。

现在,我们将丢弃我们在监听器之前使用的花哨代码,并简单地打印我们接收到的每个新字符串:

override fun onNext(t: String?) {
    println(t)
}

嗯?这很奇怪。我们的监听器没有打印任何东西。

让我们进入我们的onSubscribe并稍作修改:

override fun onSubscribe(s: Subscription) {
    this.subscription = s
    this.subscription.request(100)
}

Subscription有一个名为request()的方法,它接收我们愿意接收的项目数量。

你可以再次运行代码,现在我们的订阅者打印了前 100 个字符串,然后又沉默了。

我们已经讨论了BackpressureStrategy.DROPBackpressureStrategy.BUFFER策略。现在让我们专注于BackpressureStrategy.MISSING策略。这个名字有点令人困惑;自定义可能更好。我们很快就会看到原因:

val source = Flowable.create<String> ({
    ...
}, BackpressureStrategy.MISSING)

然后,我们将回到onNext(),它实际上做了一些事情:

override fun onNext(t: String) {
    t.repeat(500) // Do something

    println(counter.get()) // Print index of this item
    this.subscription.request(1) // Request next

    if (counter.incrementAndGet() % 100_000 == 0) {
        println("Consumed ${counter.get()} events")
    }
}

因此,我们又回到了重复字符串。在完成每个字符串后,我们通过subscription.request(1)要求我们的Flowable提供下一个字符串。

尽管如此,我们很快就会收到MissingBackpressureException

这是因为我们指定了BackpressureStrategy.MISSING策略,但没有指定缓冲区的大小。

为了解决这个问题,我们将使用onBackpressureBuffer()方法:

val source = Flowable.create<String> ({
    ...
}, BackpressureStrategy.MISSING).onBackpressureBuffer(10_000)

这推迟了问题,但我们仍然因为MissingBackpressureException而崩溃。

在这种情况下,我们需要的不是创建一个Flowable,而是生成它:

val count = AtomicInteger(0)
// This is not entirely correct, but simplifies our code
val startTime = System.currentTimeMillis()
val source = Flowable.generate<String> {
        it.onNext(UUID.randomUUID().toString())

        if (count.incrementAndGet() == 10_000_000) {
            it.onComplete()
            latch.countDown()
        }

        if (count.get() % 100_000 == 0) {
            println("Produced ${count.get()} events in ${System.currentTimeMillis() - startTime}ms")
            startTime = System.currentTimeMillis()
        }
    }

注意,与create()不同,generate()接收一个 lambda,它代表单个操作。因此,我们无法在其中使用循环。相反,我们将状态(如果有的话)存储在外部。

输出如下所示:

Produced 100000 events in 3650ms
Produced 200000 events in 1942ms
Produced 300000 events in 1583ms
Produced 400000 events in 1630ms
...

注意现在生产速度有多慢。这是因为我们在向消费者提供下一批之前等待消费者处理事件。

保持状态

在闭包中捕获这些值可能看起来有点丑陋。有一个更函数式的替代方案,但它很难掌握。Generate可以接收两个函数而不是一个:

<T, S> Flowable<T> generate(Callable<S> initialState, BiFunction<S, Emitter<T>, S> generator)

嗯,这听起来有点复杂。让我们试着理解那里发生了什么。

第一个初始状态是 () -> State。在我们的例子中,状态可以表示如下:

data class State(val count: Int, val startTime: Long)

为了简单起见,我们不向我们的函数传递 CountDownLatch 的实例。你很快就会明白为什么。

因此,我们的第一个参数是 () -> State 函数,它没有参数并返回一个 State。现在,第二个参数应该是一个函数,即 (State, Emitter<T>) -> State。在我们的例子中,我们发射字符串,所以我们的函数是 (State, Emitter<String>) -> State

由于这不仅仅对我们,也对 Kotlin 编译器来说有点混乱,我们明确指定了这些函数的类型,即 Callable<State>BiFunction<State, Emitter<String>, State>

val source = Flowable.generate<String, State>(
    Callable<State> { State(0, System.currentTimeMillis()) },
    BiFunction<State, Emitter<String>, State> { state, emitter ->
        emitter.onNext(UUID.randomUUID().toString())

        // In other cases you could use destructuring
        val count = state.count + 1
        var startTime = state.startTime
        if (count == 10_000_000) {
            emitter.onComplete()
            latch.countDown()
        }

        if (count % 100_000 == 0) {
            println("Produced ${count} events in ${System.currentTimeMillis() - startTime}ms")
            startTime = System.currentTimeMillis()
        }

        // Return next state
        State(count, startTime)
    }
)

如你所见,有时纯函数式代码可能非常复杂。幸运的是,Kotlin 允许我们根据不同情况选择不同的方法。

FlowableProcessor

就像任何 Subject 同时是 ObserverObservable 一样,任何 FlowableProcessor 都是一个既是 Publisher 也是 SubscriberFlowable

为了理解这个陈述,让我们以 ReplaySubject 为例,并使用 ReplayProcessor 重新编写它:

val list = (8..23).toList() // Some non trivial numbers
val iterator = list.iterator()
val o = Observable.intervalRange(0, list.size.toLong(), 0, 10, TimeUnit.MILLISECONDS).map {
    iterator.next()
}.toFlowable(BackpressureStrategy.DROP).publish()

任何 Observable 都可以使用 toFlowable() 方法转换为 Flowable。与任何 Flowable 一样,我们需要指定要使用哪种策略。在我们的例子中,我们使用 BackpressureStrategy.DROP

正如你所见,Flowable 支持与 Observable 相同的 publish() 方法:

val processor = ReplayProcessor.createWithSize<Int>(2)

我们不是创建 ReplaySubject,而是创建 ReplayProcessor,它也支持大小限制:

o.subscribe(processor)

o.connect() // Start publishing

Thread.sleep(20)

println("S1 subscribes")
processor.subscribe {
    println("S1 $it")
}
println("S1 subscribed")

Thread.sleep(10)

println("S2 subscribes")
processor.subscribe {
    println("S2 $it")
}
println("S2 subscribed")

Thread.sleep(1000)

输出实际上是相同的:

S1 subscribes
S1 9
S1 10
S1 subscribed
S1 11
S2 subscribes
S2 10
S2 11
S2 subscribed
S1 12
S2 12

但在处理大量输入时,我们现在有了背压来保护我们。

批处理

有时候,减慢生产者是不可能的。那么,我们是否又回到了原始问题,即丢弃一些事件或耗尽内存?幸运的是,Rx 仍然有一些锦囊妙计。批量处理数据通常更有效率。我们已经在上一章讨论了这种情况。为此,我们可以为我们的 subscriber 指定 buffer()

缓冲区有三种类型。第一种是按大小批处理:

val latch = CountDownLatch(1)
val o = Observable.intervalRange(8L, 15L, 0L, 100L, TimeUnit.MILLISECONDS)

o.buffer(3).subscribe({
    println(it)
}, {}, { latch.countDown()})

latch.await()

它输出以下内容:

[8, 9, 10]
[11, 12, 13]
[14, 15, 16]
[17, 18, 19]
[20, 21, 22]

第二种是每时间间隔的批次。想象一下,我们有一个屏幕,上面显示最新的新闻,并且每几秒钟就会更新。但对我们来说,每五秒刷新一次视图就足够了:

val latch = CountDownLatch(1)
val o = Observable.intervalRange(8L, 15L, 0L, 100L, TimeUnit.MILLISECONDS)

o.buffer(300L, TimeUnit.MILLISECONDS).subscribe ({
    println(it)
}, {}, { latch.countDown() })

latch.await()

它输出以下内容:

[8, 9, 10, 11]
[12, 13, 14]
[15, 16, 17]
[18, 19, 20]
[21, 22]

第三种类型允许我们依赖于另一个 Observable。我们将批量处理,直到它要求我们刷新数据:

val latch = CountDownLatch(1)
val o = Observable.intervalRange(8L, 15L, 0L, 100L, TimeUnit.MILLISECONDS)

o.buffer(Observable.interval(200L, TimeUnit.MILLISECONDS)).subscribe ({
    println(it)
}, {}, { latch.countDown() })

latch.await()

它输出以下内容:

[8, 9, 10]
[11, 12]
[13, 14]
[15, 16]
[17, 18]
[19, 20]
[21, 22]
[]

节流

消费者端的节流类似于生产者端的丢弃。但它不仅可以应用于 Flowable,也可以应用于 Observable

你指定时间间隔,然后在该间隔内每次只获取一个元素,要么是第一个,要么是最后一个:

val o = PublishSubject.intervalRange(8L, 15L, 0L, 100L, TimeUnit.MILLISECONDS).publish()

o.throttleFirst(280L, TimeUnit.MILLISECONDS).subscribe {
    println(it)
}

o.buffer(280L,  TimeUnit.MILLISECONDS).subscribe {
    println(it)
}

o.connect()

Thread.sleep(100 * 15)

执行这个示例几次,你就会看到你得到不同的结果。节流对时间非常敏感。

throttleFirst() 输出 [8, 11, 15, 17, 21],因为它接收到了以下窗口:

8
[8, 9, 10]
11
[11, 12, 13]
14
[14, 15, 16]
17
[17, 18, 19]
20
[20, 21]
[22]

注意,[22]被节流并且从未打印出来。

现在,让我们看看当我们使用throttleLast()时会发生什么:

val o = Observable.intervalRange(8L, 15L, 5L, 100L, TimeUnit.MILLISECONDS)

o.throttleLast(280L, TimeUnit.MILLISECONDS).subscribe {
    println(it)
}

o.buffer(280L,  TimeUnit.MILLISECONDS).subscribe {
    println(it)
}

Thread.sleep(100 * 30)

throttleLast()输出[10, 13, 16, 19, 22],因为它接收到了以下窗口:

10
[8, 9, 10]
13
[11, 12, 13]
16
[14, 15, 16]
19
[17, 18, 19]
21
[20, 21]
[22]

再次,[22]被节流并且从未打印出来。

节流是本章我们将讨论的最后一种弹性工具,但它可能是最有用的之一。

摘要

在本章中,我们学习了反应式系统的主要好处。这些系统应该是响应的、弹性的、可伸缩的,并由消息驱动。

我们还讨论了 Java 9 反应式流 API 及其最流行的实现,即 Rx。

现在,你应该更好地理解了冷Observable和热Observable之间的区别。一个冷Observable只有在有人订阅它时才开始工作。另一方面,热Observable始终发出事件,即使没有人监听。

我们还讨论了Flowable实现的背压概念。它允许生产者和消费者之间有反馈机制。

此外,你应该熟悉使用主题进行多播的概念。它允许我们向多个监听器发送相同的消息。

最后,我们讨论了一些弹性机制,例如缓冲和节流,这些机制允许我们在无法及时处理消息时积累或丢弃消息。

在下一章中,我们将开始讨论线程,如果你有 Java 背景,你应该熟悉这个概念,以及协程,这是 Kotlin 1.1 中引入的轻量级线程。

第八章:线程和协程

在本章中,我们将讨论我们的应用程序如何高效地每秒处理数千个请求。在前一章中,我们已经对其有所了解——响应式流使用多个不同的线程(由Schedulers API 公开),我们甚至不得不使用thread()函数创建一个线程一次或两次。但在深入细节之前,让我们首先讨论线程能够解决哪些类型的问题。

在你的笔记本电脑中,你有一个具有多个核心的 CPU,可能四个。这意味着它可以同时进行四个不同的计算,这相当令人印象深刻,考虑到 10 年前,单核 CPU 是默认的,甚至双核 CPU 也只针对爱好者。

但即使在那时,你实际上并没有限制于一次只能执行一个任务,对吧?你可以一边听音乐一边浏览互联网,甚至在单核 CPU 上也能做到。你的 CPU 是如何做到这一点的呢?嗯,和你的大脑一样。它处理多个任务。当你一边读书一边听朋友说话时,你的一部分时间并不是真的在读书,另一部分时间并不是真的在听。直到我们的大脑中至少有两个核心。

你运行代码的服务器具有几乎相同的 CPU。这意味着它们可以同时处理四个请求。但如果你每秒有 10,000 个请求怎么办?你不能并行处理它们,因为你没有 10,000 个 CPU 核心。但你可以尝试并发处理它们。

在本章中,我们将涵盖以下主题:

  • 线程

  • 协程

  • 通道

线程

最基本的并发模型是由 JVM 线程提供的。线程允许我们并发地运行代码(但不一定是并行),从而更好地利用多个 CPU 核心,例如。它们比进程更轻量级。一个进程可能产生数百个线程。与进程不同,线程之间共享数据很容易。但这也会带来很多问题,我们稍后会看到。

让我们先看看如何在 Java 中创建两个线程:

new Thread(() -> {
   for (int i = 0; i < 100; i++) {
      System.out.println("T1: " + i);
   }
}).start();

new Thread(() -> {
   for (int i = 0; i < 100; i++) {
      System.out.println("T2: " + i);
   }
}).start();

输出将类似于以下内容:

...
T2: 12
T2: 13
T1: 60
T2: 14
T1: 61
T2: 15
T2: 16
...

注意,输出将在不同的执行之间有所不同,并且在任何时候都没有保证它是交错进行的。

Kotlin 中的相同代码如下所示:

val t1 = thread {
    for (i in 1..100) {
        println("T1: $i")
    }
}

val t2 = thread {
    for (i in 1..100) {
        println("T2: $i")
    }
}

在 Kotlin 中,由于有一个帮助我们创建新线程的函数,所以代码更简洁。请注意,与 Java 不同,我们不需要调用start()来启动线程。它默认启动。如果我们想稍后启动它,我们可以将start参数设置为false

val t2 = thread(start = false) {
    for (i in 1..100) {
        println("T2: $i")
    }
}
...
// Later
t2.start()

Java 中另一个有用的概念是守护线程。这些线程不会阻止 JVM 退出,非常适合非关键的后台任务。

在 Java 中,API 不是流畅的,所以我们必须将我们的线程分配给一个变量,将其设置为守护线程,然后启动它:

Thread t1 = new Thread(() -> {
    for (int i = 0; i < 100; i++) {
        System.out.println("T1: " + i);
    }
});
t1.setDaemon(true);
t1.start();

在 Kotlin 中,这要简单得多:

val t3 = thread(isDaemon = true) {
    for (i in 1..1_000_000) {
        println("T3: $i")
    }
}

注意,尽管我们要求这个线程打印到一百万,但它只打印了几百个。这是因为它是一个守护线程。当父线程停止时,它也会停止。

线程安全

关于线程安全有许多书籍,这是有充分理由的。由缺乏线程安全引起的并发错误是最难追踪的。它们很难重现,因为你通常需要很多线程在同一资源上竞争,以实际发生竞争。因为这本书是关于 Kotlin 而不是一般的线程安全,所以我们只会触及这个话题的表面。如果您对 JVM 语言的线程安全主题感兴趣,您应该查看 Brian Goetz 所著的《Java Concurrency in Practice》这本书。

我们将从以下示例开始,该示例创建 100,000 个线程来增加计数器:

var counter = 0
val latch = CountDownLatch(100_000)
for (i in 1..100_000) {
    thread {
        counter++
        latch.countDown()
    }
}

latch.await()
println("Counter $counter")

如果您对并发编程有一些经验,您会立刻明白为什么这段代码打印的数字小于 100,000。原因是++操作不是原子的。所以尝试增加我们的计数器的线程越多,数据竞争的机会就越多。

但是,与 Java 不同,Kotlin 中没有synchronized关键字。原因是 Kotlin 的设计者认为一种语言不应该针对特定的并发模型进行定制。相反,有一个synchronized()函数:

var counter = 0
val latch = CountDownLatch(100_000)
for (i in 1..100_000) {
    thread{
        synchronized(latch) {
            counter++
            latch.countDown()
        }
    }
}

latch.await()
println("Counter $counter")

现在代码打印出预期的100000

如果您真的很怀念 Java 中的同步方法,Kotlin 中有@Synchronized注解。也没有volatile关键字,而是使用@Volatile注解。

线程很昂贵

每次我们创建一个新线程时,都需要付出代价。每个线程都需要一个新的内存栈。

如果我们在每个线程内部模拟一些工作,让它进入休眠状态会怎样?

在以下代码片段中,我们将尝试创建 10,000 个线程,每个线程休眠相对较短的时间:

val counter = AtomicInteger()
try {
    for (i in 0..10_000) {
        thread {
            counter.incrementAndGet()
            Thread.sleep(100)
        }
    }
} catch (oome: OutOfMemoryError) {
    println("Spawned ${counter.get()} threads before crashing")
    System.exit(-42)
}

根据您的操作系统,这可能会导致OutOfMemoryError或整个系统变得非常缓慢。当然,有方法可以限制同时运行的线程数量,使用 Java 5 中的executors API

我们创建一个指定大小的新的线程池:

// Try setting this to 1, number of cores, 100, 2000, 3000 and see what happens
val pool = Executors.newFixedThreadPool(100)

现在我们想提交一个新任务。我们通过调用pool.submit()来完成这个任务:


val counter = AtomicInteger(0)

val start = System.currentTimeMillis()
for (i in 1..10_000) {
    pool.submit {
        // Do something
        counter.incrementAndGet()

        // Simulate wait on IO
        Thread.sleep(100)

        // Do something again
        counter.incrementAndGet()
    }
}

然后我们需要确保池终止,通过以下几行代码:

pool.awaitTermination(20, TimeUnit.SECONDS)
pool.shutdown()

println("Took me ${System.currentTimeMillis() - start} millis to complete ${counter.get() / 2} tasks")

注意,我们花了 20 秒才完成。这是因为新任务不能开始,直到之前的任务醒来并完成它们的工作。

这正是多线程系统中发生的情况,它并不足够并发。

在下一节中,我们将讨论协程如何尝试解决这个问题。

协程

除了 Java 提供的线程模型之外,Kotlin 还引入了协程模型。协程可以被认为是轻量级的线程,我们很快就会看到它们相对于现有线程模型的优势。

你需要知道的第一件事是,协程不是语言的一部分。它们只是 JetBrains 提供的另一个库。因此,如果我们想使用它们,我们需要在我们的 Gradle 配置文件build.gradle中指定:

dependencies {
    ...
    compile "org.jetbrains.kotlinx:kotlinx-coroutines-core:0.21"
    ...
}

截至 Kotlin 1.2,协程仍然被视为实验性的。但这并不意味着它们工作得不好,尽管有些人可能会这样认为。这只意味着 API 的一些部分可能在下一个版本中发生变化。

可能会发生什么变化?例如,在 0.18 版本中,我们将在此章节后面讨论的 Actor 暴露了一个通道成员。在 0.21 版本中,这个成员被设置为私有,并添加了一个方法。因此,你将不再调用actor.channel.send(),而是调用actor.send()

如果你现在还不清楚actorchannel是什么,我们将在接下来的几节中简要介绍这些术语。

因此,在你添加这个依赖项并开始使用它们之后,你可能会在编译或你的 IDE 中收到警告:

The feature "coroutines" is experimental

你可以使用以下 Gradle 配置来隐藏这些警告:

kotlin {
    experimental {
        coroutines 'enable'
    }
}

现在,让我们开始学习协程。

启动协程

我们已经看到了如何在 Kotlin 中启动新线程。现在让我们启动一个新的协程。

我们将创建一个几乎与线程相同的示例。每个协程将增加某个计数器,暂停一段时间来模拟某种 I/O,然后再次增加:

val latch = CountDownLatch(10_000)
val c = AtomicInteger()

val start = System.currentTimeMillis()
for (i in 1..10_000) {
    launch(CommonPool) {
        c.incrementAndGet()
        delay(100)
        c.incrementAndGet()
        latch.countDown()
    }
}

latch.await(10, TimeUnit.SECONDS)

println("Executed ${c.get() / 2} coroutines in ${System.currentTimeMillis() - start}ms")

启动新协程的第一种方式是使用launch()函数。再次提醒,这只是一个函数,而不是语言构造。

这个函数接收一个参数:context: CoroutineContext

在底层,协程仍然使用线程池。因此,我们可以指定要使用哪个线程池。CommonPool是库中提供的单例。

另一个有趣的地方是关于我们使用的delay()函数,它用于模拟一些 I/O 密集型工作,比如从数据库或网络上获取数据。

就像Thread.sleep()方法一样,它会使当前协程休眠。但与Thread.sleep()不同,其他协程可以在这个协程休眠时继续工作。这是因为delay()被标记为带有挂起关键字,我们将在“等待协程”部分讨论它。

如果你运行这段代码,你会看到使用协程的任务大约需要 200 毫秒,而使用线程要么需要 20 秒,要么耗尽内存。而且我们并没有对代码做太多修改。这都要归功于协程在本质上高度并发。它们可以在不阻塞运行它们的线程的情况下被挂起。不阻塞线程是件好事,因为我们可以用更少的操作系统线程(这些线程成本较高)来完成更多的工作。

当然,它们并不是神奇的。让我们为我们的协程创建一个工厂,它将能够生成短运行或长运行的协程:

object CoroutineFactory {
    fun greedyLongCoroutine(index: Int) = async {
        var uuid = UUID.randomUUID()
        for (i in 1..100_000) {
            val newUuid = UUID.randomUUID()

            if (newUuid < uuid) {
                uuid = newUuid
            }
        }

        println("Done greedyLongCoroutine $index")
        latch.countDown()
    }

    fun shortCoroutine(index: Int) = async {
        println("Done shortCoroutine $index!")
        latch.countDown()
    }
}

我们实际上并不需要工厂方法设计模式,但这是一个很好的提醒。你很快就会明白为什么长时间运行的协程被称为贪婪

如果你忘记了工厂方法(Factory Method)是什么,你应该再次查看第二章,使用创建型模式工厂方法部分。简而言之,它是一个返回对象的函数。在我们的情况下,它返回什么对象呢?当然是一个代表协程的工作!我们很快就会解释工作是什么。

工作

运行异步任务的结果被称为工作。就像Thread对象代表一个实际的操作系统线程一样,job对象代表一个实际的协程。工作有一个简单的生命周期。

它可以是以下两种情况之一:

  • 新:已创建,但尚未开始。

  • 活动:例如,刚刚由launch()函数创建。这是默认状态。

  • 完成:一切顺利。

  • 取消:出了点问题。

与有子工作的工作相关的还有两个状态:

  • 完成:在完成之前等待子协程执行完毕

  • 取消:在取消之前等待子协程执行完毕

如果你想要了解更多关于父工作和子工作,请跳转到本章的父工作部分。

工作还有一些有用的方法,我们将在接下来的章节中讨论。

协程饥饿

我们将分别调用greedyLongCoroutine()shortCoroutine()方法 10 次,并等待它们完成:

val latch = CountDownLatch(10 * 2)
fun main(args: Array<String>) {

    for (i in 1..10) {
        CoroutineFactory.greedyLongCoroutine(i)
    }

    for (i in 1..10) {
        CoroutineFactory.shortCoroutine(i)
    }

    latch.await(10, TimeUnit.SECONDS)
}

显然,由于协程是异步的,我们首先会看到短协程的前 10 行,然后是长协程的前 10 行:

Done greedyLongCoroutine 2
Done greedyLongCoroutine 4
Done greedyLongCoroutine 3
Done greedyLongCoroutine 5
Done shortCoroutine 1! <= You should have finished long ago!
Done shortCoroutine 2!
Done shortCoroutine 3!
Done shortCoroutine 4!
Done shortCoroutine 5!
Done shortCoroutine 6!
Done shortCoroutine 7!
Done shortCoroutine 8!
Done shortCoroutine 9!
Done shortCoroutine 10!
Done greedyLongCoroutine 6
Done greedyLongCoroutine 7
Done greedyLongCoroutine 1
Done greedyLongCoroutine 8
Done greedyLongCoroutine 9
Done greedyLongCoroutine 10

哦,这不是你预期的结果。看起来长协程以某种方式阻塞了短协程。

这种行为的原因是协程背后仍然有一个基于线程池的事件循环。由于我的笔记本电脑有四个核心,四个长协程占用了所有资源,直到它们完成 CPU 密集型任务,其他协程无法开始。为了更好地理解这一点,让我们深入了解协程是如何工作的。

协程的内部机制

因此,我们提到了几次以下事实:

  • 协程就像轻量级线程。它们需要的资源比常规线程少,因此你可以创建更多的它们。

  • 协程在幕后使用线程池。

  • 与阻塞整个线程不同,协程会挂起。

但这实际上是如何工作的呢?

让我们看看一个抽象的例子。我们该如何构建一个用户资料?

fun profile(id: String): Profile {
    val bio = fetchBioOverHttp(id) // takes 1s
    val picture = fetchPictureFromDB(id) // takes 100ms
    val friends = fetchFriendsFromDB(id) // takes 500ms
    return Profile(bio, picture)
}

总结起来,我们的函数现在完成大约需要 1.6 秒。

但我们已经了解了线程。让我们重构这个函数,使用它们来代替!

fun profile(id: String): Profile {
    val bio = fetchBioOverHttpThread(id) // still takes 1s
    val picture = fetchPictureFromDBThread(id) // still takes 100ms
    val friends = fetchFriendsFromDBThread(id) // still takes 500ms
    return Profile(bio, picture)
}

现在我们的函数平均需要 1 秒钟,这是三个请求中最慢的一个。但由于我们为每个请求创建了一个线程,我们的内存占用是原来的三倍。而且我们可能会很快耗尽内存。

因此,让我们使用线程池来限制内存占用:

fun profile(id: String): Profile {
    val bio = fetchBioOverHttpThreadPool()
    val picture = fetchPictureFromDBThreadPool()
    val friends = fetchFriendsFromDBThreadPool()
    return Profile(bio, picture)
}

但如果我们现在调用这个函数 100 次会发生什么呢?如果我们有一个包含 10 个线程的线程池,前 10 个请求将进入池中,第 11 个请求将卡住,直到第一个请求完成。这意味着我们可以同时服务三个用户,第四个用户将等待直到第一个用户得到他的/她的结果。

与协程相比,这有什么不同呢?协程将你的方法分解成更小的方法。

让我们深入了解其中一个函数,了解它是如何完成的:

fun fetchBioOverHttp(id: String): Bio {
    doSomething() // 50ms
    val result = httpCall() // 900ms
    return Bio(result) // 50ms
}

这是一个执行时间将达 1 秒的函数。

尽管如此,我们可以用suspend关键字标记httpCall()

suspend fun httpCall(): Result { 
    ...
}

当 Kotlin 编译器看到这个关键字时,它知道它可以像这样拆分并重写函数:

fun fetchBioOverHttp(id: String): Bio {
   doSomething() // 50ms
   httpCall() { // It was marked as suspend, so I can rewrite it!
      callback(it)
   } // Thread is released after 50ms
}

// This will be called after 950ms
fun callback(httpResult: Result) {
   return Bio(httpResult) 
}

通过这样做重写,我们能够更早地释放执行协程的线程。

对于单个用户来说,这并不重要。他仍然会在 1 秒后得到结果。

但从更大的角度来看,这意味着通过使用相同数量的线程,我们可以服务 20 倍多的用户,这都要归功于 Kotlin 以智能的方式重写我们的代码。

解决饥饿问题

让我们在我们的工厂中使用扩展方法添加另一个方法:

fun CoroutineFactory.longCoroutine(index: Int) = launch {
    var uuid = UUID.randomUUID()
    for (i in 1..100_000) {
        val newUuid = UUID.randomUUID()

        if (newUuid < uuid) {
            uuid = newUuid
        }

        if (i % 100 == 0) {
            yield()
        }
    }

    println("Done longCoroutine $index")
    latch.countDown()
}

我们在第一个循环中调用这个方法:

...
for (i in 1..10) {
    CoroutineFactory.longCoroutine(i)
}
...

现在我们运行它,我们得到了最初预期的输出:

Done shortCoroutine 0!
Done shortCoroutine 1!
Done shortCoroutine 2!
Done shortCoroutine 3!
Done shortCoroutine 5!
Done shortCoroutine 6!
Done shortCoroutine 7!
Done shortCoroutine 8!
Done shortCoroutine 9!
Done shortCoroutine 4!
Done longCoroutine 4 <= That makes more sense
Done longCoroutine 2
Done longCoroutine 3
Done longCoroutine 9
Done longCoroutine 5
Done longCoroutine 1
Done longCoroutine 10
Done longCoroutine 6
Done longCoroutine 7
Done longCoroutine 8

现在我们来了解实际上发生了什么。我们使用了一个新的函数:yield()。我们本可以在每次循环迭代中都调用yield(),但决定每 100 次迭代才这样做。它会询问线程池是否还有其他人想要执行一些工作。如果没有其他人,当前协程的执行将恢复。否则,另一个协程将从它之前停止的地方开始或恢复。

注意,如果没有在函数或协程生成器(如launch())上使用suspend关键字,我们无法调用yield()。这对于任何标记为suspend的函数都适用:它应该从另一个suspend函数或从协程中调用。

等待协程

到目前为止,为了让我们的异步代码完成,我们使用了Thread.sleep()CountDownLatch。但线程和协程有更好的选择。与线程类似,一个任务也有join()函数。通过调用它,我们可以等待协程的执行完成。

看看下面的代码:

val j = launch(CommonPool) {
    for (i in 1..10_000) {
        if (i % 1000 == 0) {
            println(i)
            yield()
        }
    }
}

尽管它应该打印 10 行,但实际上并没有打印任何东西。这是因为我们的主线程在给协程一个开始的机会之前就终止了。

通过添加以下行,我们的示例将打印预期的结果:

runBlocking {
    j.join()
}

你问这个runBlocking是什么?记住,我们只能从另一个协程中调用yield(),因为它是一个挂起函数join()也是同样的道理。由于我们的主方法不是协程,我们需要在我们的常规代码(即不是挂起函数)和协程之间有一个桥梁。这个函数正是这样做的。

取消协程

如果您是 Java 开发者,您可能知道停止线程相当复杂。

例如,Thread.stop()方法已被弃用。有Thread.interrupt(),但并非所有线程都会检查这个标志,更不用说设置自己的volatile标志了,这通常被建议,但非常繁琐。

如果您使用线程池,您将得到Future,它有cancel(boolean mayInterruptIfRunning)方法。在 Kotlin 中,launch()函数返回一个任务。

这个任务可以被取消。尽管如此,前一个示例中的规则仍然适用。如果您的协程从未调用另一个suspend方法或产生输出,它将忽略cancel()

为了演示这一点,我们将创建一个偶尔产生输出的良好协程:

val cancellable = launch {
    try {
        for (i in 1..1000) {
            println("Cancellable: $i")
            computeNthFibonacci(i)
            yield()
        }
    }
    catch (e: CancellationException) {
        e.printStackTrace()
    }
}

另一个不产生输出的协程:

val notCancellable = launch {
    for (i in 1..1000) {
        println("Not cancellable $i")
        computeNthFibonacci(i)
    }
}

我们将尝试取消两者:

println("Canceling cancellable")
cancellable.cancel()
println("Canceling not cancellable")
notCancellable.cancel()

等待结果:

runBlocking {
    cancellable.join()
    notCancellable.join()
}

几个有趣的观点:

  1. 取消良好协程不会立即发生。在取消之前,它可能还会打印一两行。

  2. 我们可以捕获CancellationException,但无论如何,我们的协程将被标记为已取消。

返回结果

调用launch()就像调用返回Unit的函数一样。但我们的大多数函数都返回某种类型的结果。为此,我们有一个async()函数。它也会启动一个协程,但它返回的是Deferred<T>,其中T是您期望稍后获得的数据类型。

想象一下您想要从一个来源获取用户的配置文件,从另一个来源获取他们的历史记录的情况。这可能涉及两个数据库查询,或者是对两个远程服务的网络调用,或者任何组合。

您必须展示配置文件和历史记录,但您不知道哪个先返回。通常,检索配置文件更快。但有时可能会有延迟,因为配置文件经常更新,而历史记录会先返回。

我们运行一个协程,它将返回用户的配置文件字符串,在我们的例子中:

val userProfile = async {
    delay(Random().nextInt(100))
    "Profile"
}

我们将运行另一个来返回历史记录。为了简单起见,我们只返回一个整数列表:

val userHistory = async {
    delay(Random().nextInt(200))
    listOf(1, 2, 3)
}

为了等待结果,我们使用await()函数:

runBlocking {
    println("User profile is ${userProfile.await()} and his history is ${userHistory.await()}")
}

设置超时

如果,正如某些情况下发生的那样,获取用户配置文件花费的时间太长怎么办?如果我们决定如果配置文件返回超过 0.5 秒,我们就只显示无配置文件怎么办?

这可以通过使用withTimeout()函数来实现:

 val coroutine = async {
    withTimeout(500, TimeUnit.MILLISECONDS) {
        try {
            val time = Random().nextInt(1000)

            println("It will take me $time to do")

            delay(time)

            println("Returning profile")
            "Profile"
        }
        catch (e: TimeoutCancellationException) {
            e.printStackTrace()
        }
    }
}

我们将超时设置为 500 毫秒,并且我们的协程将在 0 到 1,000 毫秒之间延迟,给它 50%的失败机会。

我们将从协程中等待结果并看看会发生什么:

val result = try {
    coroutine.await()
}
catch (e: TimeoutCancellationException) {
    "No Profile"
}

println(result)

在这里,我们得益于try在 Kotlin 中是一个表达式的这一事实。因此,我们可以立即从它返回一个结果。

如果协程在超时之前成功返回,则result的值变为配置文件。否则,我们收到TimeoutCancellationException,并将result的值设置为无配置文件

有趣的是,我们的协程总是接收到 TimeoutCancellationException,我们可以处理它。如果发生超时,返回配置文件将永远不会打印出来。

超时和 try-catch 表达式的组合是一个非常强大的工具,它允许我们创建健壮的交互。

父任务

如果我们想同时取消多个协程呢?这就是父任务发挥作用的地方。记住,launch() 接收 CoroutineContext,通常是 CommonPool?它还可以接收其他参数,我们很快就会看到。

我们将从一个工作一段时间的中断函数开始:

suspend fun produceBeautifulUuid(): String {
    try {
        val uuids = List(1000) {
            yield()
            UUID.randomUUID()
        }

        println("Coroutine done")
        return uuids.sorted().first().toString()
    } catch (t: CancellationException) {
        println("Got cancelled")
    }

    return ""
}

我们希望启动 10 个这样的协程,并在 100 毫秒后取消它们。

为了做到这一点,我们将使用一个父任务:

val parentJob = Job()

List(10) {
    async(CommonPool + parentJob) {
        produceBeautifulUuid()
    }
}

delay(100)
parentJob.cancel()
delay(1000) // Wait some more time

如你所见,父任务只是一个任务。我们将其传递给 async() 函数。我们可以使用 + 号,因为 CoroutineContext 覆盖了 plus() 函数。你也可以使用命名参数来指定它:

async(CommonPool, parent= parentJob)

一旦我们在父任务上调用 cancel(),它的所有子任务也会被取消。

通道

到目前为止,我们学习了如何生成协程并控制它们。但如果有两个协程需要相互通信呢?

在 Java 中,线程通过使用 wait()/notify()/notifyAll() 模式或使用 java.util.concurrent 包中的一组丰富的类来通信。例如:BlockingQueueExchanger

在 Kotlin 中,正如你可能已经注意到的,没有 wait()/notify() 方法。但是有通道,它们与 BlockingQueue 非常相似。但是,通道不是阻塞线程,而是挂起协程,这要便宜得多。

为了更好地理解通道,让我们创建一个简单的两人游戏,他们将会互相投掷随机数。如果你的数字更大,你就赢了。否则,你将输掉这一轮:

fun player(name: String,
           input: Channel<Int>,
           output: Channel<Int>) = launch {
    for (m in input) {
        val d = Random().nextInt(100)
        println("$name got $m, ${if (d > m) "won" else "lost" }")

        delay(d)
        output.send(d)
    }
}

每个玩家有两个通道。一个用于接收数据,另一个用于发送数据。

我们可以使用常规的 for 循环遍历通道,它将挂起,直到接收到下一个值。

当我们想要将结果发送给另一玩家时,我们只需使用 send() 方法。

现在让我们玩一秒钟这个游戏:

fun main(vararg args: String) {
    val p1p2 = Channel<Int>()
    val p2p1 = Channel<Int>()

    val player1 = player("Player 1", p2p1, p1p2)
    val player2 = player("Player 2", p1p2, p2p1)

    runBlocking {
        p2p1.send(0)
        delay(1000)
    }
}

我们的结果可能看起来像这样:

...
Player 1 got 62, won
Player 2 got 65, lost
Player 1 got 29, lost
Player 2 got 9, won
Player 1 got 46, won
Player 2 got 82, lost
Player 1 got 81, lost
...

如你所见,通道是不同协程之间通信的一种方便且类型安全的途径。但我们不得不手动定义通道,并按正确的顺序传递它们。在接下来的两个部分中,我们将看到如何进一步简化这一点。

生产者

在 第七章,保持响应性,该章节专门讨论了响应式编程,我们讨论了产生值流的 Observablesubject。同样,Kotlin 也为我们提供了 produce() 函数。

此函数创建的协程由 ReceiveChannel<T> 支持,其中 T 是协程产生的类型:

val publisher: ReceiveChannel<Int> = produce {
        for (i in 2018 downTo 1970) { // Years back to Unix
            send(i)
            delay(20)
        }
}

在 Rx 中,有我们在 第七章 中介绍的 onNext() 方法,保持响应性

生产者有一个 send() 函数,它与它非常相似。

与提供了 subscribe() 方法的 Rx Observable 类似,这个通道有 consumeEach() 函数:

publisher.consumeEach {
    println("Got $it")
}

它将打印以下内容:

Got 35
Got 34
Got 33
Got 32
Got 31
Got 30
Got 29

通道提供的另一个伟大能力是 select()

如果我们有多个生产者,我们可以 subscribe 到它们的通道,并获取第一个可用的结果:

val firstProducer = produce<String> {
    delay(Random().nextInt(100))
    send("First")
}

val secondProducer = produce<String> {
    delay(Random().nextInt(100))
    send("Second")
}

val winner = select<String> {
    firstProducer.onReceive {
        it.toLowerCase()
    }
    secondProducer.onReceive {
        it.toUpperCase()
    }
}

println(winner)

这将随机打印 "First" 或 "Second"。

注意,select() 只发生一次。一个常见的错误是将 select() 应用在两个生成数据流的协程上,而没有将其包含在循环中:

// Producer 1
val firstProducer = produce {
    for (c in 'a'..'z') {
        delay(Random().nextInt(100))
        send(c.toString())
    }

}

// Producer 2
val secondProducer = produce {
    for (c in 'A'..'Z') {
        delay(Random().nextInt(100))
        send(c.toString())
    }
}

// Receiver
println(select<String> {
    firstProducer.onReceive {
        it
    }
    secondProducer.onReceive {
        it
    }
})

与打印字母表不同,这将只打印 "a" 或 "A",然后退出。请确保你的 select() 被包含在一个循环中。

这将打印它接收到的第一个 10 个字符:

// Receiver
for (i in 1..10) {
    println(select<String> {
        firstProducer.onReceive {
            it
        }
        secondProducer.onReceive {
            it
        }
    })
}

另一个选项是使用 close() 函数来发送信号:

// Producer 2
val secondProducer = produce {
    for (c in 'A'..'Z') {
        delay(Random().nextInt(100))
        send(c.toString())
    }
    close()
}

在接收器内部使用 onReceiveOrNull()

// Receiver
while(true) {
    val result = select<String?> {
        firstProducer.onReceiveOrNull {
            it
        }
        secondProducer.onReceiveOrNull {
            it
        }
    }

    if (result == null) {
        break
    }
    else {
        println(result)
    }
}

此选项将打印字符,直到第一个生产者决定关闭通道。

演员

本章最后介绍的是演员。与 producer() 类似,actor() 是一个与通道绑定的协程。但与通道从协程 出去 不同,这里有一个通道进入协程。如果你觉得这太学术了,请继续阅读,以获得另一种解释。

那么,演员究竟是什么呢?让我们看看迈克尔和我之间的互动,一个想象中的产品经理,你可能还记得从 第四章 "熟悉行为模式",他碰巧是一只金丝雀。迈克尔有一系列需要在冲刺/周/月结束前完成的任务。他只是把它们扔给我,希望我能施展魔法,将一些模糊的规格翻译成可工作的代码。他并不等待我的回复。他只是期望这最终会发生——而且越快越好。对迈克尔来说,我是一个演员。不是因为我去过戏剧学校,而是因为我根据他的要求采取行动。

如果你使用过 Scala 或其他具有演员的编程语言,你可能熟悉与我们描述的略有不同的演员模型。在一些实现中,演员既有入站通道也有出站通道(通常称为邮箱)。但在 Kotlin 中,一个演员只有一个入站邮箱。

要创建一个新的演员,我们使用 actor() 函数:

data class Task (val description: String)
val me = actor<Task> {
    while (!isClosedForReceive) {
        println(receive().description.repeat(10))
    }
}

注意,与 select() 的工作方式相同,除非我们将演员的 receive() 包含在某种循环中,否则它只会执行一次。如果你尝试将其发送到已关闭的通道,你会得到 ClosedSendChannelException

你使用 send() 与演员进行通信:

// Imagine this is Michael the PM
fun michael(actor: SendChannel<Task>) {

    runBlocking {
        // He has some range of tasks
        for (i in 'a'..'z') {
            // That he's sending to me
            actor.send(Task(i.toString()))
        }
        // And when he's done with the list, he let's me know
        actor.close()
        // That doesn't mean I'm done working on it, though
    }
}

// And he's calling me
michael(me)

对于演员来说,另一种模式是使用 receiveOrNull() 函数:

val meAgain = actor<Task> {
    var next = receiveOrNull()

    while (next != null) {
        println(next.description.toUpperCase())
        next = receiveOrNull()
    }
}

// Michael still can call me in the same manner
michael(meAgain)

如您所见,我们不是检查演员的通道是否已关闭,而是在通道上接收到了 null。如果演员从许多 管理者 那里接收任务,这种方法可能更可取。

第三种选项,通常是首选,是遍历通道:

val meWithRange = actor<Task> {
    for (t in channel) {
        println(t.description)
    }

    println("Done everything")
}

michael(meWithRange)

如您所见,这是三种实现中最干净的一种。

演员对于需要维护某种状态的后台任务非常有用。例如,你可以创建一个生成报告的演员。它将接收要生成哪种类型的报告,并确保同一时间只生成一个报告:

data class ReportRequest(val name: String,
                                 val from: LocalDate,
                                 val to: LocalDate)
val reportsActor = actor<ReportRequest>(capacity=100) {
    for (req in this) {
        generateReport(req)
    }
}

限制演员可以接收的消息容量通常是一个好主意。

然后,我们可以发送给这个演员要生成哪种类型的报告:

reportsActor.send(ReportRequest("Monthly Report",
        LocalDate.of(2018, 1, 1),
        LocalDate.of(2018, 1, 31)))

摘要

在本章中,我们介绍了如何在 Kotlin 中创建线程和协程,以及协程的好处。

与 Java 相比,Kotlin 创建线程的语法简化了。但它们仍然有内存和性能开销。协程能够解决这些问题;在需要并发执行某些代码时,请使用协程。

如果你想在两个协程之间进行通信,请使用通道。

Kotlin 还提供了actor()函数来创建演员,该函数也会启动一个带有附加输入流的协程来处理事件。如果你需要创建一个值流,可以使用produce()函数。

在下一章中,我们将讨论如何使用这些并发原语来创建适合我们需求的可扩展和健壮的系统。

第九章:为并发设计

在本章中,我们将讨论最常见的并发设计模式,这些模式使用协程实现,以及协程如何同步它们的执行。

并发设计模式帮助我们同时管理许多任务。是的,我知道,我们在上一章就是这样做的。那是因为其中一些设计模式已经内置到语言中。

在本章中,我们将简要介绍你需要自己实现的设计模式和并发设计模式,这些模式需要付出很少的努力。

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

  • 活动对象

  • 延迟值

  • 障碍

  • 调度器

  • 管道

  • 扇出

  • 扇入

  • 缓冲通道

  • 无偏选择

  • 互斥锁

  • 在关闭时选择

  • 伴随通道

  • 延迟通道

活动对象

这种设计模式允许一个方法以安全的方式在另一个线程上执行。猜猜还有什么是在另一个线程上执行的?

你完全正确:actor()

因此,它是一种已经内置到语言中的设计模式。或者,更准确地说,内置到其中一个兼容库中。

我们已经看到了如何向actor()发送数据。但我们如何从它那里接收数据?

一种方法是为它提供一个输出通道:

fun activeActor(out: SendChannel<String>) = actor<Int> {
    for (i in this) {
        out.send(i.toString().reversed())
    }
    out.close()
}

记得在你完成时关闭输出通道。

测试

为了测试活动对象模式,我们将启动两个作业。一个将数据发送到我们的 actor:

val channel = Channel<String>()
val actor = activeActor(channel)

val j1 = launch {
    for (i in 42..53) {
        actor.send(i)
    }
    actor.close()
}

另一个将等待输出通道上的输出:

val j2 = launch {
    for (i in channel) {
        println(i)
    }
}

j1.join()
j2.join()

延迟值

我们已经在第八章的线程和协程部分遇到了延迟值,返回结果部分。Deferredasync()函数的结果,例如。你可能也知道它们来自 Java 或 Scala 的Future,或者来自 JavaScript 的Promise

有趣的是,Deferred 是一种我们在前面章节中遇到的代理设计模式。

就像 Kotlin 的Sequence与 Java8 的Stream非常相似一样,Kotlin 的 Deferred 与 Java Future 也非常相似。你很少需要自己创建 Deferred。通常,你会使用async()返回的那个。

在你需要返回一个未来将评估的值的占位符的情况下,你可以这样做:

val deferred = CompletableDeferred<String>()

launch {
    delay(100)
    if (Random().nextBoolean()) {
        deferred.complete("OK")
    }
    else {
        deferred.completeExceptionally(RuntimeException())
    }
}

println(deferred.await())

这段代码将有一半的时间打印OK,另一半的时间抛出RuntimeException

确保你总是完成你的延迟。通常,将包含延迟的任何代码包装在try...catch块中是一个好主意。

如果你对延迟的结果不再感兴趣,也可以取消延迟。只需在它上面调用cancel()即可:

deferred.cancel()

障碍

障碍设计模式为我们提供了在进一步操作之前等待多个并发任务的手段。一个常见的用例是从不同的来源组合对象。

例如,以下是一个类:

data class FavoriteCharacter(val name: String, val catchphrase: String, val repeats: Int)

假设我们在获取名称、catchphrase和数字。这个catchphrase正从三个不同的来源重复。

最基本的方法是使用 CountDownLatch,就像我们在一些之前的例子中所做的那样:

val latch = CountDownLatch(3)

var name: String? = null
launch {
    delay(Random().nextInt(100))
    println("Got name")
    name = "Inigo Montoya"
    latch.countDown()
}

var catchphrase = ""
launch {
    delay(Random().nextInt(100))
    println("Got catchphrase")
    catchphrase = "Hello. My name is Inigo Montoya. You killed my father. Prepare to die."
    latch.countDown()
}

var repeats = 0
launch {
    delay(Random().nextInt(100))
    println("Got repeats")
    repeats = 6
    latch.countDown()
}

latch.await()

println("${name} says: ${catchphrase.repeat(repeats)}")

你会注意到异步任务完成的顺序正在改变:

Got name
Got catchphrase
Got repeats

但最终,我们总是打印出相同的结果:

Inigo Montoya says: Hello. My name is Inigo Montoya. ...

但这个解决方案带来了很多问题。我们需要处理可变变量,要么为它们设置默认值,要么使用空值。

此外,只要我们使用闭包,这也会工作。如果我们的函数比几行长呢?

CountDownLatch

当然,我们可以传递它们闩锁。我们已经见过几次的闩锁允许一个线程等待,直到其他线程完成工作:

private fun getName(latch: CountDownLatch) = launch {
    ...
    latch.countDown()
}

但这并不是一个清晰的职责分离。我们真的想要指定这个函数应该如何同步吗?

让我们再试一次:

private fun getName() = async {
    delay(Random().nextInt(100))
    println("Got name")
    "Inigo Montoya"
}

private fun getCatchphrase() = async {
    delay(Random().nextInt(100))
    println("Got catchphrase")
    "Hello. My name is Inigo Montoya. You killed my father. Prepare to die."
}

private fun getRepeats() = async {
    delay(Random().nextInt(100))
    println("Got repeats")
    6
}

只是一个提醒,fun getRepeats() = async { ... } 中并没有什么魔法。它的更长等效形式是:

private fun getCatchphrase(): Deferred<String> {
    return async {
        ...
    }
}

我们可以调用我们的代码来得到与之前相同的结果:

val name = getName()
val catchphrase = getCatchphrase()
val repeats = getRepeats()

println("${name.await()} says: ${catchphrase.await().repeat(repeats.await())}")

但我们可以通过使用我们的老朋友,数据类,来进一步改进它。

数据类作为屏障

现在我们的数据类是屏障:

val character = FavoriteCharacter(getName().await(), getCatchphrase().await(), getRepeats().await())

// Will happen only when everything is ready
with(character) {
    println("$name says: ${catchphrase.repeat(repeats)}")    
}

数据类作为屏障的额外好处是能够轻松地解构它们:

val (name, catchphrase, repeats) = character
println("$name says: ${catchphrase.repeat(repeats)}")

如果我们从不同的异步任务中接收到的数据类型差异很大,这会工作得很好。在这个例子中,我们接收到了 StringInt

在某些情况下,我们从不同的来源接收相同类型的数据。

例如,让我们问问迈克尔(我们的金丝雀产品负责人),杰克(我们的咖啡师),以及我,我们最喜欢的电影角色是谁:

object Michael {
    fun getFavoriteCharacter() = async {
        // Doesn't like to think much
        delay(Random().nextInt(10))
        FavoriteCharacter("Terminator", "Hasta la vista, baby", 1)
    }
}

object Jake {
    fun getFavoriteCharacter() = async {
        // Rather thoughtful barista
        delay(Random().nextInt(100) + 10)
        FavoriteCharacter("Don Vito Corleone", "I'm going to make him an offer he can't refuse", 1)
    }
}

object Me {
    fun getFavoriteCharacter() = async {
        // I already prepared the answer!
        FavoriteCharacter("Inigo Montoya", "Hello, my name is...", 6)
    }
}

在那种情况下,我们可以使用列表来收集结果:

val favoriteCharacters = listOf(Me.getFavoriteCharacter().await(),
        Michael.getFavoriteCharacter().await(),
        Jake.getFavoriteCharacter().await())

println(favoriteCharacters)

Scheduler

这是我们在 启动协程 部分简要讨论过的另一个概念,在 第八章 中,线程和协程

记得我们的 launch()async() 可以接收 CommonPool 吗?

这里有一个例子来提醒你,你可以明确指定它:

// Same as launch {}
launch(CommonPool) {
...
}

// Same as async {}
val result = async(CommonPool) {
...
}

这个 CommonPool 是一个伪装成调度器的调度器设计模式。许多异步任务可能被映射到同一个调度器。

运行以下代码:

val r1 = async(CommonPool) {
    for (i in 1..1000) {
        println(Thread.currentThread().name)
        yield()
    }
}

r1.await()

有趣的是,同一个协程被不同的线程选中:

ForkJoinPool.commonPool-worker-2
ForkJoinPool.commonPool-worker-3
...
ForkJoinPool.commonPool-worker-3
ForkJoinPool.commonPool-worker-1

你也可以指定上下文为 Unconfined

val r1 = async(Unconfined) {
    ...
}

这将在主线程上运行协程。它打印:

main
main
...

你也可以从你的父协程继承上下文:

val r1 = async {
    for (i in 1..1000) {
        val parentThread = Thread.currentThread().name
        launch(coroutineContext) {
            println(Thread.currentThread().name == parentThread)
        }
        yield()
    }
}

注意,但运行在同一个上下文中并不意味着我们在同一个线程上运行。

你可能会问自己:继承上下文和使用 Unconfined 之间有什么区别?我们将在下一节详细讨论这个问题。

理解上下文

为了理解不同的上下文,让我们看看下面的代码:

val r1 = async(Unconfined) {
    for (i in 1..1000) {
        println(Thread.currentThread().name)
        delay(1)
    }
}

r1.await()

我们不是使用 yield(),而是使用 delay() 函数,它也会挂起当前的协程。

但与 yield() 相比,输出是不同的:

main
kotlinx.coroutines.DefaultExecutor
...

在第一次调用 delay() 之后,协程已经切换了上下文,从而导致了线程的切换。

因此,不建议对于 CPU 密集型任务或需要在特定线程上运行的任务(如 UI 渲染)使用Unconfined

您也可以为协程创建自己的线程池来运行:

val pool = newFixedThreadPoolContext(2, "My Own Pool")
val r1 = async(pool) {
    for (i in 1..1000) {
        println(Thread.currentThread().name)
        yield()
    }
}

r1.await()
pool.close()

它打印:

...
My Own Pool-2
My Own Pool-1
My Own Pool-2
My Own Pool-2
...

如果你创建了自己的线程池,请确保你使用close()释放它或重用它,因为创建新的线程池并持有它从资源角度来看是昂贵的。

管道

在我们的StoryLand中,同样的懒散建筑师,也就是我,正在努力解决一个问题。回到第四章,熟悉行为模式,我们编写了一个 HTML 页面解析器。但它取决于是否有人已经为我们抓取了要解析的页面。它也不够灵活。

我们希望有一个协程产生无限的新闻流,而其他协程则逐步解析这个流。

要开始使用 DOM,我们需要一个库,例如kotlinx.dom。如果你使用Gradle,请确保将以下行添加到你的build.gradle文件中:

repositories {
    ...
    jcenter()
}

dependencies {
    ...
    compile "org.jetbrains.kotlinx:kotlinx.dom:0.0.10"
}

现在,让我们着手处理当前的任务。

首先,我们希望偶尔抓取新闻页面。为此,我们将有一个生产者:

fun producePages() = produce {
    fun getPages(): List<String> {
        // This should actually fetch something
        return listOf("<html><body><H1>Cool stuff</H1></body></html>",
                "<html><body><H1>Event more stuff</H1></body></html>").shuffled()
    }
    while (this.isActive) {
        val pages = getPages()
        for (p in pages) {
            send(p)
        }
        delay(TimeUnit.SECONDS.toMillis(5))
    }
}

我们在这里使用shuffled(),这样列表元素的顺序就不会总是相同。

只要协程正在运行且未被取消,isActive标志将为真。在可能运行很长时间的循环中检查此属性是一种良好的做法,这样它们就可以在迭代之间停止。

每次我们收到新的标题时,我们就将它们发送到下游。

由于科技新闻更新并不频繁。我们可以偶尔使用delay()检查更新。在实际代码中,延迟可能是几分钟,甚至几小时。

下一步是创建包含 HTML 的原始字符串的文档对象模型DOM)。为此,我们将有一个第二个生产者,这个生产者接收一个连接到第一个生产者的通道:

fun produceDom(pages: ReceiveChannel<String>) = produce {

    fun parseDom(page: String): Document {
         return kotlinx.dom.parseXml(*page.toSource()*)
    }

    for (p in pages) {
        send(parseDom(p))
    }
}

我们可以使用for循环遍历通道,直到有更多数据到来。这是从通道中消费数据的一种非常优雅的方式。

在这个生产者中,我们最终使用了我们之前导入的 DOM 解析器。我们还引入了一个方便的String扩展函数:

private fun String.toSource(): InputSource {
    return InputSource(StringReader(this))
}

这是因为parseXml()期望InputSource作为其输入。基本上,这是一个适配器设计模式的应用:

fun produceTitles(parsedPages: ReceiveChannel<Document>) = produce {
    fun getTitles(dom: Document): List<String> {
        val h1 = dom.getElementsByTagName("H1")
        return h1.asElementList().map {
            it.textContent
        }
    }

    for (page in parsedPages) {
        for (t in getTitles(page)) {
            send(t)
        }
    }
}

我们正在寻找标题,因此使用getElementsByTagName("H1")。对于找到的每个标题,可能有多个,我们使用textContent获取其文本。

最后,我们将每个页面的每个标题发送到下一个。

建立管道

现在,为了建立我们的管道:

val pagesProducer = producePages()

val domProducer = produceDom(pagesProducer)

val titleProducer = produceTitles(domProducer)

runBlocking {
    titleProducer.consumeEach {
        println(it)
    }
}

我们有以下内容:

pagesProducer |> domProducer |> titleProducer |> output

管道是将长过程分解成更小步骤的绝佳方式。请注意,每个生产协程都是一个纯函数,因此它也很容易测试和推理。

整个管道可以通过在第一个协程上调用cancel()来停止。

我们可以通过使用扩展函数来实现更友好的 API:

private fun ReceiveChannel<Document>.titles(): ReceiveChannel<String> {
    val channel = this
    fun getTitles(dom: Document): List<String> {
        val h1 = dom.getElementsByTagName("H1")
        return h1.asElementList().map {
            it.textContent
        }
    }

    return produce {
        for (page in channel) {
            for (t in getTitles(page)) {
                send(t)
            }
        }
    }
}

private fun ReceiveChannel<String>.dom(): ReceiveChannel<Document> {
    val channel = this
    return produce() {
        for (p in channel) {
            send(kotlinx.dom.parseXml(p.toSource()))
        }
    }
}

然后,我们可以这样调用我们的代码:

runBlocking {
    producePages().dom().titles().consumeEach {
        println(it)
    }
}

Kotlin 在创建表达性和流畅的 API 方面真的很出色。

扇出设计模式

如果我们管道中不同步骤的工作量差异很大怎么办?

例如,获取 HTML 比解析它花费的时间要多得多。或者如果我们根本没有任何管道,只是有很多任务我们希望在协程之间分配。

这就是扇出设计模式发挥作用的地方。协程的数量可以从同一个通道读取,分配工作。

我们可以有一个协程产生一些结果:

private fun producePages() = produce {
    for (i in 1..10_000) {
        for (c in 'a'..'z') {
            send(i to "page$c")
        }
    }
}

并有一个函数可以创建一个读取这些结果的协程:


private fun consumePages(channel: ReceiveChannel<Pair<Int, String>>) = async {
    for (p in channel) {
        println(p)
    }
}

这使我们能够生成任意数量的消费者:

val producer = producePages()

val consumers = List(10) {
    consumePages(producer)
}

runBlocking {
    consumers.forEach {
        it.await()
    }
}

扇出设计模式允许我们高效地将工作分配给多个协程、线程和 CPU。

扇入设计模式

如果我们的协程总是可以自己做出决定,那会很好。但它们如果需要将计算结果返回给另一个协程怎么办?

扇出相反的是扇入设计模式。不是多个协程从同一个通道读取,而是多个协程可以将他们的结果写入同一个通道。

想象一下,你正在从两个显赫的科技资源中阅读新闻:techBunchtheFerge

每个资源以自己的速度产生值,并将它们通过通道发送:

private fun techBunch(collector: Channel<String>) = launch {
    repeat(10) {
        delay(Random().nextInt(1000))
        collector.send("Tech Bunch")
    }
}

private fun theFerge(collector: Channel<String>) = launch {
    repeat(10) {
        delay(Random().nextInt(1000))
        collector.send("The Ferge")
    }
}

通过提供相同的通道,我们可以合并他们的结果:

val collector = Channel<String>()

techBunch(collector)
theFerge(collector)

runBlocking {
    collector.consumeEachIndexed {
        println("${it.index} Got news from ${it.value}")
    }
}

结合扇出和扇入设计模式是Map/Reduce算法的良好基础。

为了证明这一点,我们将生成 10,000,000 个随机数,并通过多次分割这个任务来计算它们中的最大数。

首先,生成 10,000,000 个随机整数的列表:

val numbers = List(10_000_000) {
    Random().nextInt()
}

管理工人

现在,我们将有两种类型的工人:

  • 分割工人将接收到数字列表,确定列表中的最大数,并将其发送到输出通道:
fun divide(input: ReceiveChannel<List<Int>>, 
           output: SendChannel<Int>) = async {
    var max = 0
    for (list in input) {
        for (i in list) {
            if (i > max) {
                max = i
                output.send(max)
            }
        }
    }
}
  • 收集器将监听这个通道,并且每次有新的子最大数到达时,将决定它是否是史上最大的:
fun collector() = actor<Int> {
    var max = 0
    for (i in this) {
        max = Math.max(max, i)
    }
    println(max)
}

现在,我们只需要建立那些通道:

val input = Channel<List<Int>>()
val output = collector()
val dividers = List(10) {
    divide(input, output)
}

launch {
    for (c in numbers.chunked(1000)) {
        input.send(c)
    }
    input.close()
}

dividers.forEach {
    it.await()
}

output.close()

注意,在这种情况下,我们不会获得性能上的好处,而简单的numbers.max()会产生更好的结果。但随着需要收集的数据量增加,这种模式变得更加有用。

缓冲通道

到目前为止,我们使用的所有通道的容量都是正好一个元素。

这意味着如果你向这个通道写入,但没有人在读取,发送者将被挂起:

val channel = Channel<Int>()

val j = launch {
    for (i in 1..10) {
        channel.send(i)
        println("Sent $i")
    }
}

j.join()

这段代码没有打印任何东西,因为协程正在等待有人从通道读取。

为了避免这种情况,我们可以创建一个缓冲通道:

val channel = Channel<Int>(5)

现在,只有在通道容量达到时才会发生挂起。

它会打印:

Sent 1
Sent 2
Sent 3
Sent 4
Sent 5

由于produce()actor()也由通道支持,我们也可以将其设置为缓冲:

val actor = actor<Int>(capacity = 5) {
    ...
}

val producer = produce<Int>(capacity = 10) {
    ...        
}

无偏选择

与通道一起工作的最有用的一种方式是我们之前在第八章“生产者”部分中看到的select {}子句,线程和协程

但选择是固有的有偏见的。如果两个事件同时发生,它将选择第一个子句。

在下面的例子中,我们将有一个生产者,它以很短的延迟发送五个值:

fun producer(name: String, repeats: Int) = produce {
    repeat(repeats) {
        delay(1)
        send(name)
    }
}

我们将创建三个这样的生产者并查看结果:

val repeats = 10_000
val p1 = producer("A", repeats)
val p2 = producer("B", repeats)
val p3 = producer("C", repeats)

val results = ConcurrentHashMap<String, Int>()
repeat(repeats) {
    val result = select<String> {
        p1.onReceive { it }
        p2.onReceive { it }
        p3.onReceive { it }
    }

    results.compute(result) { k, v ->
        if (v == null) {
            1
        }
        else {
            v + 1
        }
    }
}

println(results)

我们运行了五次这个代码。以下是一些结果:

{A=8235, B=1620, C=145}
{A=7850, B=2062, C=88}
{A=7878, B=2002, C=120}
{A=8260, B=1648, C=92}
{A=7927, B=2011, C=62}

如你所见,A几乎总是赢,而C总是第三。你设置的repeats越多,偏差就越大。

现在我们使用selectUnbiased代替:

...
val result = selectUnbiased<String> {
    p1.onReceive { it }
    p2.onReceive { it }
    p3.onReceive { it }
}
...

前五次执行的结果可能看起来像这样:

{A=3336, B=3327, C=3337}
{A=3330, B=3332, C=3338}
{A=3334, B=3333, C=3333}
{A=3334, B=3336, C=3330}
{A=3332, B=3335, C=3333}

现在数字分布得更均匀了,而且所有子句都有相同的机会被选中。

互斥锁

也称为互斥排他,互斥锁提供了一种保护共享状态的手段。

让我们从那个陈旧、令人讨厌的反例开始:

var counter = 0

val jobs = List(10) {
    launch {
        repeat(1000) {
            counter++
            yield()
        }
    }
}

runBlocking {
    jobs.forEach {
        it.join()
    }
    println(counter)
}

如你所猜,这会打印出除了10*100的结果之外的所有内容。真是尴尬至极。

为了解决这个问题,我们引入了一个互斥锁:

var counter = 0
val mutex = Mutex()

val jobs = List(10) {
    launch {
        repeat(1000) {
            mutex.lock()
            counter++
            mutex.unlock()
            yield()
        }
    }
}

现在我们的例子总是打印出正确的数字。

这对简单情况很有用。但如果关键部分(即lock()unlock()之间)的代码抛出异常怎么办?

然后,我们必须将所有内容包裹在try...catch中,这并不方便:

repeat(1000) {
    try {
        mutex.lock()
        counter++                     
   }
 finally {
        mutex.unlock()                    
    }

    yield()
}

正是为了这个目的,Kotlin 还引入了withLock()

...
repeat(1000) {
    mutex.withLock {
        counter++
    }
    yield()
}
...

选择在关闭时

使用select()从通道中读取直到它关闭是件好事。

你可以在这里看到那个问题的例子:

val p1 = produce {
    repeat(10) {
        send("A")
    }
}

val p2 = produce {
    repeat(5) {
        send("B")
    }
}

runBlocking { 
    repeat(15) {
        val result = selectUnbiased<String> {
            p1.onReceive {
                it
            }
            p2.onReceive {
                it
            }
        }

        println(result)
    }
}

虽然数字相加,但我们运行此代码时可能会经常收到ClosedReceiveChannelException。这是因为第二个生产者有更少的物品,一旦它完成,它将关闭其通道。

为了避免这种情况,我们可以使用onReceiveOrNull,它将同时返回一个可空版本。一旦通道关闭,我们在select中就会收到null

我们可以按任何我们想要的方式处理这个空值,例如,通过使用elvis运算符:

repeat(15) {
    val result = selectUnbiased<String> {
        p1.onReceiveOrNull {
            // Can throw my own exception
            it ?: throw RuntimeException()
        }
        p2.onReceiveOrNull {
            // Or supply default value
            it ?: "p2 closed"
        }
    }

    println(result)
}

利用这些知识,我们可以通过跳过空结果来排空两个通道:

var count = 0
while (count < 15) {
    val result = selectUnbiased<String?> {
        p1.onReceiveOrNull {
            it
        }
        p2.onReceiveOrNull {
            it
        }
    }

    if (result != null) {
        println(result)
        count++
    }
}

辅助通道

到目前为止,我们只讨论了select作为接收者的用法。但我们也可以使用select将项目发送到另一个通道。

让我们看看以下例子:

val batman = actor<String> {
    for (c in this) {
        println("Batman is beating some sense into $c")
        delay(100)
    }
}

val robin = actor<String> {
    for (c in this) {
        println("Robin is beating some sense into $c")
        delay(250)
    }
}

我们有一个超级英雄及其助手作为两个演员。由于超级英雄更有经验,他们通常花费更少的时间来击败他们面对的恶棍。

但在某些情况下,它们仍然手头很忙,所以需要一个助手来帮忙。

我们将向这对组合投掷五个恶棍,并观察它们的反应,同时加入一些延迟:

val j = launch {
    for (c in listOf("Jocker", "Bane", "Penguin", "Riddler", "Killer Croc")) {
        val result = select<Pair<String, String>> {
            batman.onSend(c) {
                Pair("Batman", c)
            }
            robin.onSend(c) {
                Pair("Robin", c)
            }
        }
        delay(90)
        println(result)
    }
}

它打印出:

Batman is beating some sense into Jocker
(Batman, Jocker)
Robin is beating some sense into Bane
(Robin, Bane)
Batman is beating some sense into Penguin
(Batman, Penguin)
Batman is beating some sense into Riddler
(Batman, Riddler)
Robin is beating some sense into Killer Croc
(Robin, Killer Croc)

注意,这个选择的类型参数指的是从块中返回的内容,而不是发送到通道的内容。

这就是为什么我们在这里使用Pair<String, String>的原因。

延迟通道

你与协程工作得越多,就越习惯于等待结果。在某个时刻,你将开始在通道中发送延迟值。

我们将首先创建 10 个异步任务。第一个将延迟很长时间,其余的我们将延迟很短的时间:

val elements = 10
val deferredChannel = Channel<Deferred<Int>>(elements)

launch(CommonPool) {
    repeat(elements) { i ->
        println("$i sent")
        deferredChannel.send(async {
            delay(if (i == 0) 1000 else 10)
            i
        })
    }
}

我们将把这些结果放入一个缓冲通道中。

现在,我们可以从这个通道读取,并使用第二个select块,等待结果:

val time = measureTimeMillis {
    repeat(elements) {
        val result = select<Int> {
            deferredChannel.onReceive {
                select {
                    it.onAwait { it }
                }
            }
        }
        println(result)
    }
}

println("Took ${time}ms")

注意,结果时间是最慢的任务的时间:

Took 1010ms

你还可以使用onAwait()作为另一个通道的停止信号。

因此,我们将创建一个将在 600 毫秒内完成的异步任务:

val stop = async {
    delay(600)
    true
}

并且,就像上一个例子一样,我们将通过缓冲通道发送 10 个延迟值:

val channel = Channel<Deferred<Int>>(10)

repeat(10) {i ->
    channel.send(async {
        delay(i * 100)
        i
    })
}

然后,我们将等待新的值或通知通道应该关闭:

runBlocking {
    for (i in 1..10) {
        select<Unit> {
            stop.onAwait {
                channel.close()
            }
            channel.onReceive {
                println(it.await())
            }
        }
    }
}

如预期的那样,这仅打印了十个值中的六个,在 600 毫秒后停止。

摘要

在本章中,我们介绍了与 Kotlin 中并发相关的各种设计模式。其中大多数都是基于协程、通道、延迟值或它们的组合。

管道扇入扇出有助于分配工作和收集结果。延迟值用作稍后解决某事的占位符。调度器帮助我们管理资源,主要是支持协程的线程。互斥锁屏障有助于控制并发。

现在,你应该理解了select块以及它如何有效地与通道和延迟值结合使用。

在下一章中,我们将讨论 Kotlin 的惯用用法、最佳实践以及随着语言出现的某些反模式。

第十章:习语和反模式

本章讨论了 Kotlin 的最佳和最坏实践。你将了解惯用的 Kotlin 代码应该是什么样子,以及哪些模式应该避免。

完成这一章后,你应该能够编写更易读、更易于维护的 Kotlin 代码,并避免一些常见的陷阱。

在本章中,我们将涵盖以下主题:

  • Let

  • Apply

  • Also

  • Run

  • With

  • 实例检查

  • 尝试使用资源

  • 内联函数

  • 重新声明

  • 常量

  • 构造函数重载

  • 处理空值

  • 显式异步

  • 验证

  • 密封,而不是枚举

  • 更多同伴

  • Scala 函数

Let

通常,我们使用let()只在对象not null时执行某些操作:

val sometimesNull = if (Random().nextBoolean()) "not null" else null

sometimesNull?.let {
    println("It was $it this time")
}

这里一个常见的陷阱是let()本身也可以用于空值:

val alwaysNull = null

alwaysNull.let { // No null pointer there
    println("It was $it this time") // Always prints null
}

使用let()进行空值检查时,不要忘记问号?

let()的返回值与其操作的类型无关:

val numberReturned = justAString.let {
    println(it)
    it.length
}

这段代码将打印"string"并返回其长度为Int 6

Apply

我们已经在之前的章节中讨论了apply()。它返回它操作的对象,并将上下文设置为this。这个函数最有用的用例是设置可变对象的字段。

想想你有多少次不得不创建一个空构造函数的类,然后依次调用很多 setter:

class JamesBond {
    lateinit var name: String
    lateinit var movie: String
    lateinit var alsoStarring: String
}

val agentJavaWay = JamesBond()
agentJavaWay.name = "Sean Connery"
agentJavaWay.movie = "Dr. No"

我们只能设置namemovie,但alsoStarring留空,如下所示:

val `007` = JamesBond().apply {
    this.name = "Sean Connery"
    this.movie = "Dr. No"
}

println(`007`.name)

由于上下文设置为this,我们可以将其简化为以下漂亮的语法:

val `007` = JamesBond().apply {
    name = "Sean Connery"
    movie = "Dr. No"
}

这个函数在你处理通常有很多 setter 的 Java 类时特别有用。

Also

单表达式函数非常简洁和优雅:

fun multiply(a: Int, b: Int): Int = a * b

但通常,你有一个单语句函数,它还需要写入日志,例如。

你可以写成以下方式:

fun multiply(a: Int, b: Int): Int {
    val c = a * b
    println(c)
    return c
}

但这样它就不再是单语句函数了,对吧?

我们还引入了另一个变量。来拯救,also()

fun multiply(a: Int, b: Int): Int = (a * b).also { println(it) }

这个函数将表达式的结果设置到it,并返回表达式的结果。

这也适用于你想要在一系列调用中产生副作用的情况:

val l = (1..100).toList()

l.filter{ it % 2 == 0 }
    .also { println(it) } // Prints, but doesn't change anything
    .map { it * it }

Run

与线程无关,run()let()类似,但它将上下文设置为this而不是使用it

val justAString = "string"

val n = justAString.run { 
    this.length
}

通常,this可以省略:

val n = justAString.run { 
    length
}

当你计划在同一个对象上调用多个方法时,这非常有用,就像apply()一样。

apply()不同,返回结果可能完全不同:

val year = JamesBond().run {
    name = "ROGER MOORE"
    movie = "THE MAN WITH THE GOLDEN GUN"
    1974 // <= Not JamesBond type
}

With

与其他四个作用域函数不同,with()不是一个扩展函数。

这意味着你不能这样做:

"scope".with { ... }

相反,with()接收一个作为参数的对象,你想要作用域的对象:

with("scope") {
    println(this.length) // "this" set to the argument of with()
}

通常情况下,我们可以省略this

with("scope") {
    length
}

就像run()let()一样,你可以从with()返回任何结果。

实例检查

来自 Java,你可能倾向于检查你的对象类型,使用is,然后使用as进行转换:

interface Superhero
class Batman : Superhero {
    fun callRobin() {
        println("To the Bat-pole, Robin!")
    }
}

class Superman : Superhero {
    fun fly() {
        println("Up, up and away!")
    }
}

fun doCoolStuff(s : Superhero) {
    if (s is Superman) {
        (s as Superman).fly()
    }
    else if (s is Batman) {
        (a as Batman).callRobin()
    }
}

但正如你可能知道的,Kotlin 有智能转换,所以在这种情况下,隐式转换不是必需的:

fun doCoolStuff(s : Superhero) {
    if (s is Superman) {
        s.fly()
    }
    else if (s is Batman) {
        s.callRobin()
    }
}

此外,在大多数情况下,使用when()进行智能转换会产生更干净的代码:

fun doCoolStuff(s : Superhero) {
    when(s) {
        is Superman -> s.fly()
        is Batman -> s.callRobin()
        else -> println("Unknown superhero")
    }
}

作为一条经验法则,你应该避免使用类型转换,并尽可能多地依赖智能转换:

// Superhero is clearly not a string
val superheroAsString = (s as String)

但如果你绝对必须,还有一个安全的类型转换操作符:

val superheroAsString = (s as? String)

使用 try-with-resources

Java7 引入了 AutoCloseable 的概念和 try-with-resources 语句。

这个声明允许我们提供一组资源,这些资源在代码使用完毕后会自动关闭。不再有忘记关闭文件的风险(或者至少风险更小)。

在 Java7 之前,那是一个完全的混乱:

BufferedReader br = null; // Nulls are bad, we know that
try {
    br = new BufferedReader(new FileReader("/some/peth"));
    System.out.println(br.readLine());
}
finally {
    if (br != null) { // Explicit check
        br.close(); // Boilerplate
    }
}

在 Java7 之后:

try (BufferedReader br = new BufferedReader(new FileReader("/some/peth"))) {
    System.out.println(br.readLine());
}

在 Kotlin 中,this 语句被 use() 函数替换:

val br = BufferedReader(FileReader(""))

br.use {
    println(it.readLine())
}

内联函数

你可以将内联函数视为编译器的复制/粘贴指令。每次编译器看到标记为内联的函数调用时,它都会用具体的函数体替换调用。

只有当它是一个高阶函数,并且其中一个参数是 lambda 表达式时,才使用内联函数:

inline fun doesntMakeSense(something: String) {
    println(something)
}

这是最常见的使用场景,你希望使用 inline

inline fun makesSense(block: () -> String) {
    println("Before")
    println(block())
    println("After")
}

你可以像往常一样调用它,使用代码块体:

makesSense {
    "Inlining"
}

但如果你查看字节码,你会发现它实际上被转换成了生成的行,而不是函数调用:

println("Before")
println("Inlining")
println("After")

在实际代码中,你会看到以下内容:

String var1 = "Before"; <- Inline function call
System.out.println(var1);
var1 = "Inlining";
System.out.println(var1);
var1 = "After";
System.out.println(var1);

var1 = "Before"; // <- Usual code
System.out.println(var1);
var1 = "Inlining";
System.out.println(var1);
var1 = "After";
System.out.println(var1);

注意,这两个代码块之间没有任何区别。

由于内联函数是复制/粘贴的,所以如果你有超过几行代码,就不应该使用它。将其作为常规函数会更有效率。

Reified

由于内联函数是复制的,我们可以消除 JVM 的一项主要限制——类型擦除。毕竟,在函数内部,我们知道我们得到的确切类型。

让我们看看以下示例。你希望创建一个泛型函数,该函数将接收一个数字,但只有在它与函数类型相同时才会打印它。

你可以尝试编写如下内容:

fun <T> printIfSameType(a: Number) {
    if (a is T) { // <== Error
        println(a)   
    }
}

但这段代码会因为以下错误而无法编译:

Cannot check for instance of erased type: T

在这种情况下,我们通常在 Java 中这样做,即传递类作为参数:

fun <T: Number> printIfSameType(clazz: KClass<T>, a: Number) {
    if (clazz.isInstance(a) ) {
        println(a)
    }
}

我们可以通过运行以下两行来检查这段代码:

printIfSameType(Int::class, 1) // Print 1, as 1 is Int
printIfSameType(Int::class, 2L) // Prints nothing, as 2 is Long

这段代码有几个缺点:

  • 我们不得不使用反射,为此,我们必须包含 kotlin-reflect 库:
compile group: 'org.jetbrains.kotlin', name: 'kotlin-reflect', version: '1.2.31'
  • 我们不能使用 is 操作符,而必须使用 isInstance() 函数。

  • 我们必须传递正确的类:

clazz: KClass<T>

相反,我们可以使用一个 reified 函数:

reified T> printIfSameTypeReified(a: Number) {
    if (a is T) {
        println(a)
    }
}

我们可以测试我们的代码是否仍然按预期工作:

printIfSameTypeReified<Int>(3) // Prints 3, as 3 is Int
printIfSameTypeReified<Int>(4L) // Prints nothing, as 4 is Long
printIfSameTypeReified<Long>(5) // Prints nothing, as 5 is Int
printIfSameTypeReified<Long>(6L) // Prints 6, as 6 is Long

这样我们就能获得该语言的所有好处:

  • 没有必要使用另一个依赖项

  • 清晰的方法签名

  • 使用 is 构造的能力

当然,与常规内联函数相同的规则适用。这段代码将被复制,所以它不应该太大。

考虑另一个关于函数重载的例子:

fun printList(list: List<Int>) {
    println("This is a lit of Ints")
    println(list)
}

fun printList(list: List<Long>) {
    println("This is a lit of Longs")
    println(list)
}

这将无法编译,因为存在平台声明冲突。在 JVM 方面,两者具有相同的签名:printList(list: List)

但使用 reified,我们可以实现这一点:

const val int = 1
const val long = 1L
inline fun <reified T : Any> printList(list: List<T>) {
    when {
        int is T -> println("This is a list of Ints")
        long is T -> println("This is a list of Longs")
        else -> println("This is a list of something else")
    }

    println(list)
}

常量

由于 Java 中的所有内容都是对象(除非你是原始类型),我们习惯于将所有常量作为静态成员放入我们的对象中。

由于 Kotlin 有伴随对象,我们通常尝试将它们放在那里:

class MyClass {
    companion object {
        val MY_CONST = "My Const"
    }
}

这将有效,但你应该记住,毕竟伴随对象是一个对象。

因此,这将被翻译成以下代码,或多或少:

public final class Spock {
   @NotNull
   private static final String MY_CONST = "My Const";
   public static final Spock.Companion Companion = new Spock.Companion(...);

   public static final class Companion {
      @NotNull
      public final String getMY_CONST() {
         return MyClass.MY_CONST;
      }

      private Companion() {
      }
   }
}

我们常量的调用看起来像这样:

String var1 = Spock.Companion.getSENSE_OF_HUMOR();
System.out.println(var1);

因此,我们在 Spock 类中有一个类,但我们想要的只是 static final String

现在我们将这个值标记为常量:

class Spock {
    companion object {
        const val SENSE_OF_HUMOR = "None"
    }
}

这里是字节码的变化:

public final class Spock {
   @NotNull
   public static final String SENSE_OF_HUMOR = "NONE";
   public static final Spock.Companion Companion = new Spock.Companion(...);
   )
   public static final class Companion {
      private Companion() {
      }
        ...
   }
}

这里是调用:

String var1 = "NONE";
System.out.println(var1);

注意,根本就没有调用这个常量,因为编译器已经为我们内联了它的值。毕竟,它是常量。

如果你只需要一个常量,你也可以在任何类外部设置它:

const val SPOCK_SENSE_OF_HUMOR = "NONE"

如果你需要命名空间,你可以将其包裹在一个对象中:

object SensesOfHumor {
    const val SPOCK = "NONE"
}

构造函数重载

在 Java 中,我们习惯于有重载的构造函数:

class MyClass {
    private final String a;
    private final Integer b;
    public MyClass(String a) {
        this(a, 1);
    }

    public MyClass(String a, Integer b) {
        this.a = a;
        this.b = b;
    }
}

我们可以在 Kotlin 中模拟相同的行为:

class MyClass(val a: String, val b: Int, val c: Long) {

    constructor(a: String, b: Int) : this(a, b, 0) 

    constructor(a: String) : this(a, 1)

    constructor() : this("Default")
}

但通常更好的做法是使用默认参数值和命名参数:


class BetterClass(val a: String = "Default",
                  val b: Int = 1,
                  val c: Long = 0)

处理空值

空值是不可避免的,尤其是在你使用 Java 库或从数据库获取数据时。

但你可以用 Java 的方式检查空值:

// Will return "String" half of the time, and null the other half
val stringOrNull: String? = if (Random().nextBoolean()) "String" else null 

// Java-way check
if (stringOrNull != null) {
    println(stringOrNull.length)
}

或者用更短的形式,使用 Elvis 操作符。如果长度不为空,这个操作符将返回其值。否则,它将返回我们提供的默认值,在这个例子中是零:

val alwaysLength = stringOrNull?.length ?: 0

println(alwaysLength) // Will print 6 or 0, but never null

如果你有一个嵌套对象,你可以链式调用这些检查:

data class Json(
        val User: Profile?
)

data class Profile(val firstName: String?,
                   val lastName: String?)

val json: Json? = Json(Profile(null, null))

println(json?.User?.firstName?.length)

最后,你可以使用 let() 块来进行这些检查:

println(json?.let {
    it.User?.let {
        it.firstName?.length
    }
})

如果你想要消除所有地方的 it(),你可以使用 run:

println(json?.run {
    User?.run {
        firstName?.length
    }
})

尽可能避免不安全的 !! 空值操作符:

println(json!!.User!!.firstName!!.length)

这将导致 KotlinNullPointerException

显式异步

正如你在上一章中看到的,在 Kotlin 中引入并发非常容易:

fun getName() = async {
   delay(100)
   "Ruslan"
}

但这种并发可能对函数的用户来说是不预期的行为,因为他们可能期望一个简单的值:

println("Name: ${getName()}")

它会打印:

Name: DeferredCoroutine{Active}@...

当然,这里缺少的是 await()

println("Name: ${getName().await()}")

但如果我们相应地命名我们的函数,这会显得更加明显:

fun getNameAsync() = async {
   delay(100)
   "Ruslan"
}

通常,你应该建立某种约定来区分异步函数和常规函数。

验证

你有多少次不得不编写如下代码:

fun setCapacity(cap: Int) {
    if (cap < 0) {
        throw IllegalArgumentException()
    }
    ...
}

相反,你可以使用 require() 来检查参数:

fun setCapacity(cap: Int) {
    require(cap > 0)
}

这使得代码更加流畅。

你可以使用 require() 来检查嵌套的空值:

fun printNameLength(p: Profile) {
    require(p.firstName != null)
}

但也有 requireNotNull() 来处理这种情况:

fun printNameLength(p: Profile) {
    requireNotNull(p.firstName)
}

使用 check() 来验证你对象的状态。这在你提供用户可能没有正确设置的对象时很有用:

private class HttpClient {
    var body: String? = null
    var url: String = ""

    fun postRequest() {
        check(body != null) {
            "Body must be set in POST requests"
        }
    }

    fun getRequest() {
        // This one is fine without body
    }
}

再次强调,对于 null 有一个快捷方式:checkNotNull()

密封,而不是枚举

来自 Java,你可能想给你的 enum 赋予功能:

// Java code
enum PizzaOrderStatus {
    ORDER_RECEIVED, 
    PIZZA_BEING_MADE, 
    OUT_FOR_DELIVERY, 
    COMPLETED;

    public PizzaOrderStatus nextStatus() {
        switch (this) {
            case ORDER_RECEIVED: return PIZZA_BEING_MADE;
            case PIZZA_BEING_MADE: return OUT_FOR_DELIVERY;
            case OUT_FOR_DELIVERY: return COMPLETED;
            case COMPLETED:return COMPLETED;
        }
    }
}

相反,你可以使用 sealed 类:

sealed class PizzaOrderStatus(protected val orderId: Int) {
    abstract fun nextStatus() : PizzaOrderStatus
    class OrderReceived(orderId: Int) : PizzaOrderStatus(orderId) {
        override fun nextStatus(): PizzaOrderStatus {
            return PizzaBeingMade(orderId)
        }
    }

    class PizzaBeingMade(orderId: Int) : PizzaOrderStatus(orderId) {
        override fun nextStatus(): PizzaOrderStatus {
            return OutForDelivery(orderId)
        }
    }

    class OutForDelivery(orderId: Int) : PizzaOrderStatus(orderId) {
        override fun nextStatus(): PizzaOrderStatus {
            return Completed(orderId)
        }
    }

    class Completed(orderId: Int) : PizzaOrderStatus(orderId) {
        override fun nextStatus(): PizzaOrderStatus {
            return this
        }
    }
}

这种方法的优点是,我们现在可以传递数据以及状态:

var status: PizzaOrderStatus = OrderReceived(123)

while (status !is Completed) {
    status = when (status) {
        is OrderReceived -> status.nextStatus()
        is PizzaBeingMade -> status.nextStatus()
        is OutForDelivery -> status.nextStatus()
        is Completed -> status
    }
}

通常,如果你想要将数据与状态关联起来,密封类是很好的选择。

更多伴随对象

你只能有一个伴随对象在你的类中:

class A {
   companion {
   }
   companion {
   }
}

但你可以在你的类中拥有任意多的对象:

class A {
   object B {
   }
   object C {
   }
}

这有时用于产生命名空间。命名空间很重要,因为它为你提供了更好的命名约定。想想看,当你创建了像 SimpleJsonParser 这样的类,它继承自 JsonParser,而 JsonParser 又继承自 Parser 时的情况。你可以将这个结构转换为 Json.Parser,例如,这要简洁得多,也更实用,因为 Kotlin 代码应该是这样的。

Scala 函数

从 Scala 转向 Kotlin 的开发者有时可能会这样定义他们的函数:

fun hello() = {
    "hello"
}

调用此函数不会打印你期望的内容:

println("Say ${hello()}")

它会打印以下内容:

 Say () -> kotlin.String

我们缺少的是第二组括号:

println("Say ${hello()()}")

它会打印以下内容:

Say hello

这是因为单表达式定义可以翻译成:

fun hello(): () -> String {
    return {
        "hello"
    }
}

它可以进一步翻译成:

fun helloExpandedMore(): () -> String {
    return fun():String {
        return "hello"
    }
}

现在,你至少可以看到那个函数的来源了。

摘要

在本章中,我们回顾了 Kotlin 中的最佳实践以及语言的一些注意事项。现在你应该能够编写更符合语言习惯、性能良好且易于维护的代码。

你应该利用作用域函数,但确保不要过度使用它们,因为它们可能会使代码变得混乱,尤其是对于那些刚开始学习这门语言的人来说。

确保正确处理空值和类型转换,使用 let()Elvis 操作符以及语言提供的智能转换。

在下一章和最后一章中,我们将通过编写一个真实生活中的微服务来应用这些技能,使用我们所学的一切。

第十一章:使用 Kotlin 的响应式微服务

在本章中,我们将通过使用 Kotlin 编程语言构建一个微服务来运用我们迄今为止学到的技能。我们还想让这个微服务是响应式的,并且尽可能接近现实生活。为此,我们将使用 Vert.x 框架,其优势将在下一节中列出。

你可能已经厌倦了创建待办事项或购物清单。

因此,相反,这个微服务将是一个 猫收容所。这个微服务应该能够做到以下事情:

  • 提供一个我们可以 ping 的端点来检查服务是否正在运行

  • 列出目前收容所中的猫咪

  • 提供一种方法来添加新的猫咪

你需要开始以下内容:

  • JDK 1.8 或更高版本

  • IntelliJ IDEA

  • Gradle 4.2 或更高版本

  • PostgreSQL 9.4 或更高版本

本章将假设你已经安装了 PostgreSQL 并且你对它有基本的工作知识。如果没有,请参阅官方文档:www.postgresql.org/docs/9.4/static/tutorial-install.html

在本章中,我们将涵盖以下主题:

  • 开始使用 Vert.x

  • 处理请求

  • 测试

  • 与数据库一起工作

  • EventBus

开始使用 Vert.x

我们将为我们的微服务使用的框架称为 Vert.x。它是一个与 reactive extensions 共享许多共同点的响应式框架,我们在 第七章 保持响应式 中讨论了它。它是异步和非阻塞的。

让我们通过一个具体的例子来理解这意味着什么。

我们将从一个新的 Kotlin Gradle 项目开始。从你的 IntelliJ IDEA 中,打开 File | New | Project,在 New Project 向导中选择 Gradle | Kotlin,然后点击 Finish。给你的项目一个 GroupId(我选择了 me.soshin)和一个 ArtifactId(在我的例子中是 catsShelter)。

Gradle 是一个构建工具,类似于 Maven 和 Ant。它有一个很好的语法,并以优化的方式编译你的项目。你可以在这里了解更多:gradle.org/

在下一屏上,选择 Use auto-import 和 Create directories for empty content roots,然后点击 Finish。

接下来,将以下依赖项添加到你的 build.gradle 文件中。

dependencies {
    def $vertx_version = '3.5.1'
    ...
    compile group: 'io.vertx', name: 'vertx-core', version: $vertx_version
    compile group: 'io.vertx', name: 'vertx-web', version: $vertx_version
    compile group: 'io.vertx', name: 'vertx-lang-kotlin', version: $vertx_version
    compile group: 'io.vertx', name: 'vertx-lang-kotlin-coroutines', version: $vertx_version
}

以下是对每个依赖项的解释:

  • vertx-core 是核心库

  • vertx-web 是必需的,因为我们希望我们的服务是基于 REST 的

  • vertx-lang-kotlin 提供了使用 Vert.x 编写 Kotlin 代码的惯用方法

  • 最后,vertx-lang-kotlin-coroutines 与我们在 第九章 详细讨论的协程集成,专为并发设计

注意,我们定义了一个变量来指定我们应该使用 Vert.x 的哪个版本。截至目前,最新稳定版本是 3.5.1,但到你阅读这本书的时候,它将是 3.5.2 或甚至 3.6.0。

作为一般规则,所有 Vert.x 库应该使用相同的版本,这时变量就变得很有用了。

src/main/kotlin 文件夹中创建一个名为 Main.kt 的文件,内容如下:

fun main(vararg args: String) {
   val vertx = Vertx.vertx()

   vertx.createHttpServer().requestHandler{ req ->
            req.response().end("OK")
        }.listen(8080)
}

这就是你需要启动一个当你在浏览器中打开 localhost:8080 时会响应 OK 的网络服务器的所有内容。

现在,让我们了解这里实际上发生了什么。我们使用第三章的 工厂方法 从 Understanding Structural Patterns 创建一个 Vert.x 实例。

Handler 只是一个简单的监听器,或者是一个订阅者。如果你不记得它是如何工作的,请查看第四章的 Getting Familiar with Behavioral Patterns,了解 Observable 设计模式。在我们的情况下,它将为每个新的请求被调用。这就是 Vert.x 的异步特性在起作用。

注意,requestHandler() 是一个接收块的函数。像任何其他惯用的 Kotlin 代码一样,你不需要括号。

如果你使用的是 IntelliJ IDEA 等集成开发环境,你可以直接运行它。另一种选择是将以下行添加到你的 build.gradle 文件中:

apply plugin: 'application'
mainClassName = "com.gett.MainKt"

然后,你可以简单地使用以下命令启动它:

./gradlew run

另一个选择是使用 VertxGradlePlugin (github.com/jponge/vertx-gradle-plugin),它将做同样的事情。

路由

注意,无论我们指定哪个 URL,我们总是得到相同的结果。

当然,这不是我们想要达到的目标。让我们先添加最基本的服务端点,它只会告诉我们服务正在运行。

为了做到这一点,我们将使用 Router

val vertx = Vertx.vertx() // Was here before
val router = Router.router(vertx)
...

Router 允许你为不同的 HTTP 方法和 URL 指定处理器。

但是,默认情况下,它不支持协程。让我们通过创建一个扩展函数来解决这个问题:

fun Route.asyncHandler(fn : suspend (RoutingContext) -> Unit) {
    handler { ctx ->
        launch(ctx.vertx().dispatcher()) {
            try {
                fn(ctx)
            } catch(e: Exception) {
                ctx.fail(e)
            }
        }
    }
}

如果你熟悉现代 JavaScript,这类似于 async() => {}

现在,我们可以使用这个新的扩展方法:

router.get("/alive").asyncHandler {
    // Some response comes here
    // We now can use any suspending function in this context
}

我们看到了如何在第一个示例中返回一个平面文本响应。所以,让我们返回 JSON 代替。大多数实际应用程序使用 JSON 进行通信。

将以下行添加到你的处理器中:

...
val json = json {
    obj (
       "alive" to true
    )
}
it.respond(json.toString())
...

我们声明的另一个扩展函数是 respond()。它看起来如下所示:

fun RoutingContext.respond(responseBody: String = "", status: Int = 200) {
    this.response()
            .setStatusCode(status)
            .end(responseBody)
}

现在将你的路由器连接到服务器。

你可以通过用以下行替换之前的服务器实例化来实现这一点:

vertx.createHttpServer().
   requestHandler(router::accept).listen(8080)

现在,所有路由都将由 Router 处理。

你可以在浏览器中打开 http://localhost:8080/alive 并确保你得到 {"alive": true} 的响应。

恭喜!你已经成功创建了第一个返回 JSON 的路由。从现在起,无论何时你不确定你的应用程序是否正在运行,你都可以简单地使用这个 URL 来检查它。当你使用负载均衡器时,这一点尤为重要,因为负载均衡器需要知道在任何时候有多少应用程序可用。

处理请求

我们接下来的任务是向我们的虚拟收容所添加第一只猫。

这应该是一个 POST 请求,其中请求体的内容可能看起来像这样:{"name": "Binky", "age": 4}

如果你熟悉像 curlPostman 这样的工具来发出 POST 请求,那很好。如果不熟悉,我们将在下一节编写一个测试来检查这个场景。

我们首先需要做的是在我们初始化我们的路由器之后添加以下行:

router.route().handler(BodyHandler.create())

这将告诉 Vert.x 将请求体解析为 JSON,适用于任何请求。另一种方法是使用 router.route("/*")

现在,让我们确定我们的 URL 应该是什么样子。良好的实践是将我们的 API URL 进行版本控制,所以我们希望它如下所示:

api/v1/cats

因此,我们可以假设以下:

  • GET api/v1/cats 将返回我们庇护所中所有的猫。

  • POST api/v1/cats 将添加一只新的猫。

  • GET api/v1/cats/34 如果存在,将返回 ID=34 的猫,否则返回 404。

理解了这一点后,我们可以继续如下操作:

router.post("/api/v1/cats").asyncHandler { ctx ->
    // Some code of adding a cat comes here
}
router.get("/api/v1/cats").asyncHandler { ctx ->
    // Code for getting all the cats
}

最后一个端点需要接收一个路径参数。我们使用分号符号来表示:

router.get("/api/v1/cats/:id").asyncHandler { ctx ->
    // Fetches specific cat
}

Verticles

现在遇到了一个问题。我们的代码位于 Main.kt 文件中,它越来越大。我们可以通过使用 verticles 来开始分割它。

你可以把 verticle 看作是一个轻量级 actor。让我们看看以下代码的例子:

class ServerVerticle: CoroutineVerticle() {

    override suspend fun start() {
        val router = router()
        vertx.createHttpServer().requestHandler(router::accept).listen(8080)
    }

    private fun router(): Router {
        val router = Router.router(vertx)
        // Our router code comes here now
        ...
        return router
    }
}

现在我们需要启动这个 verticle。有几种不同的方法可以做到这一点,但最简单的方法是将这个类的实例传递给 deployVerticle() 方法:

vertx.deployVerticle(ServerVerticle())

现在我们的代码被分成两个文件,ServerVerticle.ktMain.kt

注意,但是 /api/v1/cats/ 每次都会重复。有没有一种方法可以消除这种冗余?实际上,有。它被称为 子路由

子路由

我们将保持 /alive 端点不变,但我们将所有其他端点提取到一个单独的函数中:

private fun apiRouter(): Router {
    val router = Router.router(vertx)

    router.post("/cats").asyncHandler { ctx ->
        ctx.respond(status=501)
    }
    router.get("/cats").asyncHandler { ctx ->
        ...
    }
    router.get("/cats/:id").asyncHandler { ctx ->
        ...
    }
    return router
}

有一种更流畅的方式来定义它,但我们保留了原来的方式,因为它更易读。

就像我们向 Vert.x 服务器实例提供主路由器一样,我们现在将子路由器按如下方式提供给主路由器:

router.mountSubRouter("/api/v1", apiRouter())

保持我们的代码干净和良好分离非常重要。

测试

在我们继续将猫添加到数据库之前,让我们首先编写一些测试来确保到目前为止一切正常。

为了做到这一点,我们将使用 TestNG 测试框架。你也可以使用 JUnitVertxUnit 来达到同样的目的。

首先,将以下行添加到你的 build.gradledependencies 部分:

testCompile group: 'org.testng', name: 'testng', version: '6.11'

现在,我们将创建我们的第一个测试。它应该位于 /src/test/kotlin/<your_package>

所有集成测试的基本结构看起来像这样:

class ServerVerticleTest {
    // Usually one instance of VertX is more than enough
    val vertx = Vertx.vertx()

    @BeforeClass
    fun setUp() {
        // You want to start your server once
        startServer()
    }

    @AfterClass
    fun tearDown() {
        // And you want to stop your server once
        vertx.close()
    }

    @Test
    fun testAlive() {
        // Here you assert something
    }

    // More tests come here
    ...
}

一个好技巧是使用 Kotlin 反引号符号来命名你的测试。

你可以像这样命名你的测试:

@Test
fun testAlive() {
    ...
}

但更好的命名测试的方式是这样的:

@Test
fun `Tests that alive works`() {
    ...
}

现在我们想要向我们的 /alive 端点发出实际的 HTTP 请求,例如,并检查响应代码。为此,我们将使用 Vert.x 网络客户端。

将其添加到你的 build.gradle 依赖项部分:

compile group: 'io.vertx', name: 'vertx-web-client', version: $vertx_version

如果你打算只在测试中使用它,你可以指定 testCompile 而不是 compile。但 WebClient 非常有用,你最终可能还是会将其用在代码中。

辅助方法

在我们的测试中,我们将创建两个辅助函数,分别称为 get()post(),它们将向我们的测试服务器发出 GETPOST 请求。

我们将从 get() 开始:

private fun get(path: String): HttpResponse<Buffer> {
    val d1 = CompletableDeferred<HttpResponse<Buffer>>()

    val client = WebClient.create(vertx)
    client.get(8080, "localhost", path).send {
        d1.complete(it.result())
    }

    return runBlocking {
        d1.await()
    }
}

第二种方法 post() 将非常相似,但它还将有一个请求体参数:


private fun post(path: String, body: String = ""): HttpResponse<Buffer> {
    val d1 = CompletableDeferred<HttpResponse<Buffer>>()

    val client = WebClient.create(vertx)
    client.post(8080, "localhost", path).sendBuffer(Buffer.buffer(body), { res ->
        d1.complete(res.result())
    })

    return runBlocking {
        d1.await()
    }
}

这两个函数都使用了 Kotlin 提供的默认参数值协程。

你应该编写自己的辅助函数或根据你的需求修改它们。

我们还需要另一个辅助函数 startServer(),我们已经在 @BeforeClass 中提到过它。它应该看起来像这样:

private fun startServer() {
    val d1 = CompletableDeferred<String>()
    vertx.deployVerticle(ServerVerticle(), {
        d1.complete("OK")
    })
    runBlocking {
        println("Server started")
        d1.await()
    }
}

我们需要两个新的扩展函数来方便我们。这些函数将把服务器响应转换为 JSON:

private fun <T> HttpResponse<T>.asJson(): JsonNode {
    return this.bodyAsBuffer().asJson()
}

private fun Buffer.asJson(): JsonNode {
    return ObjectMapper().readTree(this.toString())
}

现在我们已经准备好编写我们的第一个测试:

@Test
fun `Tests that alive works`() {
    val response = get("/alive")
    assertEquals(response.statusCode(), 200)

    val body = response.asJson()
    assertEquals(body["alive"].booleanValue(), true)
}

运行 ./gradlew test 以检查这个测试是否通过。

接下来,我们将编写另一个测试;这次是为猫的创建端点。

起初,它将失败:

@Test
fun `Makes sure cat can be created`() {
   val response = post("/api/v1/cats",
                """
                {
                    "name": "Binky",
                    "age": 5
                }
                """)

   assertEquals(response.statusCode(), 201)
   val body = response.asJson()

   assertNotNull(body["id"])
   assertEquals(body["name"].textValue(), "Binky")
   assertEquals(body["age"].intValue(), 5)
}

注意,我们的服务器返回状态码 501 Not Implemented,并且没有返回 cat ID。

我们将在下一节讨论数据库持久性时修复这个问题。

与数据库一起工作

如果没有将我们的对象(即猫)保存到某种持久存储的能力,我们将无法取得更大的进展。

为了做到这一点,我们首先需要连接到数据库。

将以下两行添加到你的 build.gradle 依赖部分:

compile group: 'org.postgresql', name: 'postgresql', version: '42.2.2'
compile group: 'io.vertx', name: 'vertx-jdbc-client', version: $vertx_version

第一行代码获取 PostgreSQL 驱动。第二行添加了 Vert.x JDBC 客户端,这使得 Vert.x 在拥有驱动程序的情况下可以连接到任何支持 JDBC 的数据库。

管理配置

现在我们想要将数据库配置存储在某个地方。对于本地开发,可能将配置硬编码是可行的。

当我们连接到数据库时,我们至少需要指定以下参数:

  • 用户名

  • 密码

  • 主机

  • 数据库名

我们应该在哪里存储它们?

当然,一个选项当然是将这些值硬编码。这对于本地环境来说是可以的,但当我们部署这个服务到其他地方时怎么办呢?

你会去,我不能来!XDSpringBoot 做的,或者我们可以尝试从环境变量中读取它们。无论如何,我们需要一个封装这个逻辑的对象,如下面的代码所示:

object Config {
    object Db {
        val username = System.getenv("DATABASE_USERNAME") ?: "postgres"
        val password = System.getenv("DATABASE_PASSWORD") ?: ""
        val database = System.getenv("DATABASE_NAME") ?: "cats_db"
        val host = System.getenv("DATABASE_HOST") ?: ""

        override fun toString(): String {
            return mapOf("username" to username,
                    "password" to password,
                    "database" to database,
                    "host" to host).toString()
        }
    }

    override fun toString(): String {
        return mapOf(
                "Db" to Db
        ).toString()
    }
}

这当然只是你可以采取的一种方法。

现在,我们将使用此配置代码创建 JDBCClient

fun CoroutineVerticle.getDbClient(): JDBCClient {
    val postgreSQLClientConfig = JsonObject(
            "url" to "jdbc:postgresql://${Config.Db.host}:5432/${Config.Db.database}",
            "username" to Config.Db.username,
            "password" to Config.Db.password)
    return JDBCClient.createShared(vertx, postgreSQLClientConfig)
}

在这里,我们选择了一个扩展函数,它将在所有 CoroutineVerticles 上工作。

为了简化与 JDBCClient 一起工作,我们将向其中添加一个名为 query() 的方法:

fun JDBCClient.query(q: String, vararg params: Any): Deferred<JsonObject> {
    val deferred = CompletableDeferred<JsonObject>()
    this.getConnection { conn ->
        conn.handle({
            result().queryWithParams(q, params.toJsonArray(), { res ->
                res.handle({
                    deferred.complete(res.result().toJson())
                }, {
                    deferred.completeExceptionally(res.cause())
                })
            })
        }, {
            deferred.completeExceptionally(conn.cause())
        })
    }

    return deferred
}

我们还会添加 toJsonArray() 方法,因为这是我们 JDBCClient 使用的:

private fun <T> Array<T>.toJsonArray(): JsonArray {
    val json = JsonArray()

    for (e in this) {
        json.add(e)
    }

    return json
}

注意这里 Kotlin 泛型是如何被用来简化转换同时保持类型安全的。

我们还会添加一个 handle() 函数,它将为我们提供一个简单的 API 来处理异步错误:

inline fun <T> AsyncResult<T>.handle(success: AsyncResult<T>.() -> Unit, failure: () -> Unit) {
    if (this.succeeded()) {
        success()
    }
    else {
        this.cause().printStackTrace()
        failure()
    }
}

为了确保一切正常工作,我们将在我们的/alive路由上添加一个检查:

val router = Router.router(vertx)
val dbClient = getDbClient()
...
router.get("/alive").asyncHandler {
    val dbAlive = dbClient.query("select true as alive")
    val json = json {
        obj (
                "alive" to true,
                // This is JSON, but we can access it as an array
                "db" to dbAlive.await()["rows"]
        )
    }
    it.respond(json)
}

需要添加的行用粗体标出。

在添加这些行并打开localhost:8080/alive之后,你应该得到以下 JSON 代码:

{"alive":true, "db":[{"alive":true}]}

管理数据库

当然,我们的测试没有通过。这是因为我们还没有创建我们的数据库。确保你在命令行中运行以下行:

$ createdb cats_db

在我们确认数据库正在运行之后,让我们实现我们的第一个真实端点。

我们将保持我们的 SQL 与实际代码的清晰分离。将以下内容添加到你的ServerVerticle中:

private val insert = """insert into cats (name, age)
            |values (?, ?::integer) RETURNING *""".trimMargin()

我们在这里使用多行字符串,通过|trimMargin()来重新对齐它们。

现在用以下代码调用这个查询:

...
val db = getDbClient()
router.post("/cats").asyncHandler { ctx ->
    db.queryWithParams(insert, ctx.bodyAsJson.toCat(), {
       it.handle({
          // We'll always have one result here, since it's our row
          ctx.respond(it.result().rows[0].toString(), 201)
       }, {
          ctx.respond(status=500)
       })
   })
}

注意,我们没有在任何地方打印错误信息。这是因为我们定义了handle()函数来处理这个任务。

我们还定义了自己的函数来解析请求体,将JsonObject转换为JsonArray,这是JDBCClient所期望的:

private fun JsonObject.toCat() = JsonArray().apply {
   add(this@toCat.getString("name"))
   add(this@toCat.getInteger("age"))
}

注意,这里有两个不同的this版本。一个指的是apply()函数的内部作用域。另一个指的是toCat()函数的外部作用域。要引用外部作用域,我们使用@scopeName注解。

正如你所见,扩展函数是清理代码的极其强大的工具。

当你再次运行我们的测试时,你会注意到它仍然失败,但现在有一个不同的错误代码。这是因为我们还没有创建我们的表。让我们现在就创建它。有几种方法可以做到这一点,但最方便的方法是简单地运行以下命令:

psql -c "create table cats (id bigserial primary key, name varchar(20), age integer)" cats_db

再次运行你的测试以确保它通过。

EventBus

这是第二次我们遇到了相同的问题:我们的类越来越大,我们通常尽可能避免这种情况。

如果我们再次将创建猫的逻辑拆分到一个单独的文件中呢?让我们称它为CatVerticle.kt

但是,我们需要一种方法让ServerVerticleCatVerticle通信。在像SpringBoot这样的框架中,你会使用依赖注入来达到这个目的。但是对于响应式框架呢?

消费者

为了解决通信问题,Vert.x 使用EventBus。它是我们在第四章中讨论的Observable设计模式的实现,熟悉行为模式。任何 verticle 都可以通过事件总线发送消息,在这些两种模式之间进行选择:

  • send()将消息发送给单个订阅者

  • publish()将消息发送给所有订阅者

无论使用哪种方法发送消息,你都可以使用 EventBus 上的consumer()方法来订阅它:

const val CATS = "cats:get"

class CatVerticle : CoroutineVerticle() {

    override suspend fun start() {
        val db = getDbClient()
        vertx.eventBus().consumer<JsonObject>(CATS) { req ->
            ...
        }
    }
}

类型指定了我们期望接收消息的对象。在这种情况下,它是JsonObject。常量CATS是我们订阅的键。它可以是任何字符串。通过使用命名空间,我们确保未来不会发生冲突。如果我们要在我们的收容所中添加狗,我们将使用另一个具有另一个命名空间的常量。例如:

const val DOGS  = "dogs:get" // Just an example, don't copy it

现在我们添加以下两个查询,它们只是多行字符串常量:

private const val QUERY_ALL = """select * from cats"""
class CatVerticle : CoroutineVerticle() {

    private val QUERY_WITH_ID = """select * from cats
                     where id = ?::integer""".trimIndent()
...
}

为什么我们在类内部放置一个,在类外部放置另一个?

QUERY_ALL是一个简短的查询,它适合一行。我们可以允许自己将其作为一个常量。另一方面,QUERY_WITH_ID是一个较长的查询,需要一些缩进。由于我们只在运行时移除缩进,所以我们不能将其作为一个常量。因此,我们使用成员值。在现实世界的项目中,你的大部分查询可能都需要是私有值。但了解两种方法之间的区别很重要。

我们用以下代码填充我们的消费者:

...
try {
    val body = req.body()
    val id: Int? = body["id"]
    val result = if (id != null) {
        db.query(QUERY_WITH_ID, id)
    } else {
        db.query(QUERY_ALL)
    }
    launch {
        req.reply(result.await())
    }
}
catch (e: Exception) {
    req.fail(0, e.message)
}
...

如果请求中包含猫的 ID,我们就获取这只特定的猫。否则,我们获取所有可用的猫。

我们使用launch()是因为我们想要await()结果,并且我们没有返回值。

生产者

剩下的就是从ServerVerticle调用猫。为此,我们将在我们的CoroutineVerticle中添加另一个方法:

fun <T> CoroutineVerticle.send(address: String,
                               message: T,
                               callback: (AsyncResult<Message<T>>) -> Unit) {
    this.vertx.eventBus().send(address, message, callback)
}

然后我们可以这样处理我们的请求:

...
router.get("/cats").asyncHandler { ctx ->
    send(CATS, ctx.queryParams().toJson()) {
        it.handle({
            val responseBody = it.result().body()
            ctx.respond(responseBody.get<JsonArray>("rows").toString())
        }, {
            ctx.respond(status=500)
        })
    }
}
...

注意,我们正在重用之前定义的同一个常量,称为CATS

这样,我们可以轻松地检查谁可以发送这个事件,谁消费它。如果成功,我们将返回一个 JSON。否则,我们将返回一个 HTTP 错误代码。

我们添加的另一个方法是toJson()MultiMap上。MultiMap是一个包含我们的查询参数的对象:

private fun MultiMap.toJson(): JsonObject {
    val json = JsonObject()

    for (k in this.names()) {
        json.put(k, this[k])
    }

    return json
}

为了确保一切按预期工作,让我们为我们的新端点创建两个额外的测试。

只别忘了在你的Main.kt文件和测试中的startServer()函数中添加以下行:

...
vertx.deployVerticle(CatVerticle())
...

更多测试

现在添加以下基本测试:

@Test
fun `Make sure that all cats are returned`() {
    val response = get("/api/v1/cats")
    assertEquals(response.statusCode(), 200)

    val body = response.asJson()

    assertTrue(body.size() > 0)
}

为了确保你理解所有这些是如何协同工作的,这里有一些你可能希望完成的额外任务:

  1. 将添加新猫的逻辑移动到CatVerticle

  2. 实现获取单个猫的功能。注意代码与获取所有猫的代码非常相似?重构它以使用 Kotlin 的一个酷特性——局部函数,我们之前已经讨论过了。

  3. 按照同样的原则实现删除和更新猫的功能。

摘要

本章汇总了我们关于 Kotlin 设计模式和习惯用法所学的所有内容,以生成一个可扩展的微服务。而且,多亏了 Vert.x,它也是反应式的,这使得它具有极高的可扩展性。它还进行了测试,正如任何现实世界的应用程序应该的那样。

在我们的应用程序中,类是根据领域而不是层来划分的,这与通常的 MVC 架构相反。Vert.x 中的最小工作单元被称为 verticle,verticles 通过 EventBus 进行通信。

我们的 API 遵循了所有 REST 的最佳实践:使用 HTTP 动词和有意义的路径来访问资源,以及消费和生成 JSON。

你可以将同样的原则应用到你要编写的任何其他实际应用中,我们确实希望你会选择 Vert.x 和 Kotlin 来实现这一点。

posted @ 2025-10-09 13:23  绝不原创的飞龙  阅读(2)  评论(0)    收藏  举报