Kotlin-函数式指南-全-
Kotlin 函数式指南(全)
原文:
zh.annas-archive.org/md5/554454848b9d9cb3734b502ec33fb8ec
译者:飞龙
前言
随着 Google 在 2017 年 I/O 大会上的宣布,将 Kotlin 定为 Android 的官方语言,Kotlin 在全球开发者中的受欢迎程度正在上升。
然而,Kotlin 的使用和流行并不局限于 Android 社区。许多其他社区,如桌面、Web 和后端社区,也在接受 Kotlin。许多新的库和框架正在被创建,现有的也在为 Kotlin 提供支持。
随着越来越多的开发者加入 Kotlin 社区,以及其自然的灵活性,更多的编程风格正在被尝试。本书的目的是向广泛的 Kotlin 社区介绍函数式编程风格,引领和指导初学者,并提供基本工具以进一步学习更高级的概念。
本书面向的对象
这本书是为 Kotlin 用户(程序员、工程师、库作者和架构师)而写的,他们已经对 Kotlin 有基本的了解,并希望了解函数式编程的基本理念以及如何在实践中使用它(如果你对 Kotlin 完全陌生,我们的附录《Kotlin 快速入门》将为你提供语言快速入门)。
本书涵盖的内容
第一章,Kotlin - 数据类型、对象和类,介绍了 Kotlin 中的面向对象编程。Kotlin 主要是一种面向对象编程语言,我们将使用这些特性来引入函数式编程风格。
第二章,开始使用函数式编程,涵盖了使用 Kotlin 的面向对象编程特性来介绍函数式编程的基本原则。
第三章,不可变性 - 它很重要,强调不可变性是函数式编程中最重要概念之一。本章将深入讲解不可变性。
第四章,函数、函数类型和副作用,介绍了围绕函数、纯函数以及各种函数类型和副作用的函数式编程基本概念。
第五章,关于函数的更多内容,讨论了 Kotlin 在函数式编程方面的特性,例如扩展函数、操作符重载、DSL 和核心递归。
第六章,Kotlin 中的委托,介绍了 Kotlin 如何在语言级别支持委托。尽管委托是一种面向对象编程概念,但它们可以帮助使你的代码更加模块化。
第七章,使用协程进行异步编程,介绍了 Kotlin 中的异步编程,并比较了协程与其他不同风格的对比。
第八章,Kotlin 中的集合和数据操作,介绍了 Kotlin 的增强集合 API 和 Kotlin 集合框架提供的功能接口。
第九章,函数式编程和响应式编程,展示了如何将函数式编程与其他编程范式结合以获得最佳效果。本章讨论了您如何将函数式编程与面向对象编程和响应式编程结合使用。
第十章,函子、应用和单子,为您介绍了类型化函数式编程及其基本概念。它还讨论了如何在 Kotlin 中实现它。
第十一章,在 Kotlin 中使用流,为您介绍了 Kotlin 中的 Streams API。
第十二章,使用 Arrow 入门,介绍了如何使用 Arrow 及其扩展进行函数式编程、函数组合、柯里化、部分应用、记忆化和光学。
第十三章,箭头类型,帮助您理解箭头数据类型,如 Option、Either、Try 和 State 及其类型类、函子和单子。
附录,Kotlin 快速入门,提供了您开始编写 Kotlin 代码所需的一切,例如工具、基本语法结构以及其他资源,以帮助您在 Kotlin 之旅中进步。
要充分利用这本书
运行和编写 Kotlin 程序唯一推荐的软件是 IntelliJ IDEA(还有其他方法可以做到这一点,我们将在附录 Kotlin 快速入门 中介绍)。
您可以从 www.jetbrains.com/idea/download/
下载 IntelliJ IDEA。
您可以在 Windows、Mac 和 Linux 上安装 IntelliJ IDEA:
-
对于 Windows:您可以使用从 XP 到 10 的任何 Windows 版本。要在 Windows 上安装它,运行安装程序可执行文件并遵循说明。
-
对于 Mac:您可以使用从 10.8 开始的任何 macOS 版本。要在 macOS 上安装它,挂载磁盘映像文件并将 IntelliJ IDEA.app**60 复制到您的 Application 文件夹。
-
对于 Linux:您可以使用任何 GNOME 或 KDE 桌面。要在 Linux 上安装它,使用 tar -xzf idea-.tar.gz* 命令解压缩 tar.gz 文件,然后从 bin 子目录中运行 idea.sh。
下载示例代码文件
您可以从 www.packtpub.com 的账户下载这本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问 www.packtpub.com/support 并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
在 www.packtpub.com 上登录或注册。
-
选择“支持”选项卡。
-
点击“代码下载与勘误”。
-
在搜索框中输入书籍名称,并遵循屏幕上的说明。
文件下载后,请确保使用最新版本解压缩或提取文件夹:
-
WinRAR/7-Zip for Windows
-
Zipeg/iZip/UnRarX for Mac
-
7-Zip/PeaZip for Linux
本书代码包也托管在 GitHub 上,地址为 github.com/PacktPublishing/Functional-Kotlin
。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还有其他来自我们丰富图书和视频目录的代码包可供在 github.com/PacktPublishing/
上获取。去看看吧!
下载彩色图像
我们还提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载: www.packtpub.com/sites/default/files/downloads/FunctionalKotlin_ColorImages.pdf
。
使用约定
本书使用了多种文本约定。
CodeInText
: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“我们引入了一个新的BakeryGood
类,它具有Cupcake
和Biscuit
类共享的行为和状态,并且我们让这两个类都扩展了BakeryGood
。”
代码块设置如下:
open class BakeryGood(val flavour: String) {
fun eat(): String {
return "nom, nom, nom... delicious $flavour bakery good"
}
}
class Cupcake(flavour: String): BakeryGood(flavour)
class Biscuit(flavour: String): BakeryGood(flavour)
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
fun main(args: Array<String>) {
val emptyList1 = listOf<Any>() val emptyList2 = emptyList<Any>()
println("emptyList1.size = ${emptyList1.size}")
println("emptyList2.size = ${emptyList2.size}")
}
任何命令行输入或输出都按以下方式编写:
$ kotlin HelloKt
粗体: 表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“将出现一个对话框,询问您是否想将其打开为文件或项目。点击 Open As Project”
警告或重要注意事项如下所示。
技巧和技巧如下所示。
联系我们
我们始终欢迎读者的反馈。
一般反馈: 请通过电子邮件 feedback@packtpub.com
发送反馈,并在邮件主题中提及书籍标题。如果您对本书的任何方面有疑问,请通过电子邮件 questions@packtpub.com
联系我们。
勘误: 尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,如果您能向我们报告,我们将不胜感激。请访问 www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。
盗版: 如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过电子邮件 copyright@packtpub.com
与我们联系,并附上材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com.
评论
请留下评论。一旦您阅读并使用了这本书,为何不在您购买它的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,我们 Packt 可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!
关于 Packt 的更多信息,请访问 packtpub.com
第一章:Kotlin – 数据类型、对象和类
在本章中,我们将介绍 Kotlin 的类型系统、面向对象编程(OOP)与 Kotlin、修饰符、解构声明等。
Kotlin 主要是一种面向对象(OOP)语言,同时具有一些函数式特性。当我们使用面向对象的语言来解决问题时,我们试图以与问题相关的信息以抽象的方式模拟问题中的一部分对象。
如果我们为公司设计一个 HR 模块,我们将用状态或数据(姓名、出生日期、社会保险号等)和行为(支付薪水、调到另一个部门等)来模拟员工。因为一个人可能非常复杂,有些信息对于我们的问题或领域并不相关。例如,员工的自行车喜好风格对于我们的 HR 系统并不相关,但对于在线自行车店来说却非常相关。
一旦我们确定了对象(包括数据和行为)以及它们与我们领域其他对象的关系,我们就可以开始开发和编写代码,这些代码将成为我们软件解决方案的一部分。我们将使用语言结构(结构是一种说法,指的是允许的语法)来编写对象、类别、关系等等。
Kotlin 有许多我们可以用来编写程序的构造,在本章中,我们将介绍其中许多构造,例如:
-
类
-
继承
-
抽象类
-
接口
-
对象
-
泛型
-
类型别名
-
空类型
-
Kotlin 的类型系统
-
其他类型
类
类是 Kotlin 中的基础类型。在 Kotlin 中,类是一个模板,它为实例提供状态、行为和类型(稍后会有更多介绍)。
定义一个类只需要一个名称:
class VeryBasic
VeryBasic
不是非常有用,但仍然是一个有效的 Kotlin 语法。
VeryBasic
类没有任何状态或行为;尽管如此,你仍然可以声明VeryBasic
类型的值,如下面的代码所示:
fun main(args: Array<String>) {
val basic: VeryBasic = VeryBasic()
}
正如你所见,basic
值具有VeryBasic
类型。用不同的方式表达,basic
是VeryBasic
的一个实例。
在 Kotlin 中,类型可以被推断;因此,前面的例子等同于以下代码:
fun main(args: Array<String>) {
val basic = VeryBasic()
}
由于basic
是一个VeryBasic
实例,它具有VeryBasic
类型的副本状态和行为,即没有。真是太遗憾了。
属性
如前所述,类可以有状态。在 Kotlin 中,类状态由属性表示。让我们看看蓝莓纸杯蛋糕的例子:
class BlueberryCupcake {
var flavour = "Blueberry"
}
BlueberryCupcake
类有一个has-a
属性flavour
,其类型为String
。
当然,我们可以有BlueberryCupcake
类的实例:
fun main(args: Array<String>) {
val myCupcake = BlueberryCupcake()
println("My cupcake has ${myCupcake.flavour}")
}
现在,因为我们声明了flavour
属性为变量,它的内部值可以在运行时被改变:
fun main(args: Array<String>) {
val myCupcake = BlueberryCupcake()
myCupcake.flavour = "Almond"
println("My cupcake has ${myCupcake.flavour}")
}
在现实生活中这是不可能的。纸杯蛋糕不会改变它们的味道(除非它们变陈了)。如果我们将flavour
属性更改为一个值,它就不能被修改:
class BlueberryCupcake {
val flavour = "Blueberry"
}
fun main(args: Array<String>) {
val myCupcake = BlueberryCupcake()
myCupcake.flavour = "Almond" //Compilation error: Val cannot be reassigned
println("My cupcake has ${myCupcake.flavour}")
}
让我们声明一个新的类来表示杏仁纸杯蛋糕:
class AlmondCupcake {
val flavour = "Almond"
}
fun main(args: Array<String>) {
val mySecondCupcake = AlmondCupcake()
println("My second cupcake has ${mySecondCupcake.flavour} flavour")
}
这里有些奇怪。BlueberryCupcake
和AlmondCupcake
在结构上相同;只是内部值发生了变化。
在现实生活中,你不需要为不同的纸杯蛋糕风味准备不同的烤盘。同一个高质量的烤盘可以用于各种风味。同样,一个设计良好的Cupcake
类可以用于不同的实例:
class Cupcake(flavour: String) {
val flavour = flavour
}
Cupcake
类有一个带有参数flavour
的构造函数,该参数被分配给flavour
值。
因为这是一个非常常见的习语,Kotlin 有一些语法糖来更简洁地定义它:
class Cupcake(val flavour: String)
现在,我们可以定义具有不同风味的几个实例:
fun main(args: Array<String>) {
val myBlueberryCupcake = Cupcake("Blueberry")
val myAlmondCupcake = Cupcake("Almond")
val myCheeseCupcake = Cupcake("Cheese")
val myCaramelCupcake = Cupcake("Caramel")
}
方法
在 Kotlin 中,一个类的行为由方法定义。技术上,方法是一个成员函数,因此,我们在以下章节中学到的关于函数的知识也适用于方法:
class Cupcake(val flavour: String) {
fun eat(): String {
return "nom, nom, nom... delicious $flavour cupcake"
}
}
eat()
方法返回一个String
值。现在,让我们调用eat()
方法,如下面的代码所示:
fun main(args: Array<String>) {
val myBlueberryCupcake = Cupcake("Blueberry")
println(myBlueberryCupcake.eat())
}
以下表达式是前面代码的输出:
没有什么令人震惊的,但这是我们第一个方法。稍后,我们会做更多有趣的事情。
继承
随着我们继续在 Kotlin 中建模我们的领域,我们发现具体对象相当相似。如果我们回到我们的人力资源示例,员工和承包商相当相似;他们都有名字、出生日期等;他们也有一些差异。例如,承包商有日工资,而员工有月薪。很明显,他们是相似的——他们都是人;人是包含承包商和员工的超集。因此,他们都有自己独特的特征,使他们足够不同,可以分类到不同的子集中。
这就是继承的全部内容,有组和子组,它们之间有联系。在继承层次结构中,如果你向上移动层次结构,你会看到更多通用特性和行为,如果你向下移动,你会看到更具体的行为。玉米卷和微处理器都是对象,但它们有不同的用途和用途。
让我们引入一个新的Biscuit
类:
class Biscuit(val flavour: String) {
fun eat(): String {
return "nom, nom, nom... delicious $flavour biscuit"
}
}
再次,这个类看起来几乎与Cupcake
完全相同。我们可以重构这些类以减少代码重复:
open class BakeryGood(val flavour: String) {
fun eat(): String {
return "nom, nom, nom... delicious $flavour bakery good"
}
}
class Cupcake(flavour: String): BakeryGood(flavour)
class Biscuit(flavour: String): BakeryGood(flavour)
我们引入了一个新的BakeryGood
类,它具有Cupcake
和Biscuit
类的共享行为和状态,并且我们让这两个类都扩展了BakeryGood
。通过这样做,Cupcake
(和Biscuit
)现在与BakeryGood
有了一个是关系;另一方面,BakeryGood
是Cupcake
类的超类或父类。
注意BakeryGood
被标记为open
。这意味着我们特别设计了这个类以便扩展。在 Kotlin 中,你不能扩展一个不是open
的类。
将常见的行为和状态移动到父类的过程称为泛化。让我们看一下下面的代码:
fun main(args: Array<String>) {
val myBlueberryCupcake: BakeryGood = Cupcake("Blueberry")
println(myBlueberryCupcake.eat())
}
让我们尝试我们的新代码:
唉,这不是我们预期的。我们需要进一步折射它:
open class BakeryGood(val flavour: String) {
fun eat(): String {
return "nom, nom, nom... delicious $flavour ${name()}"
}
open fun name(): String {
return "bakery good"
}
}
class Cupcake(flavour: String): BakeryGood(flavour) {
override fun name(): String {
return "cupcake"
}
}
class Biscuit(flavour: String): BakeryGood(flavour) {
override fun name(): String {
return "biscuit"
}
}
它工作了!让我们看看输出结果:
我们声明了一个新的方法,name()
;它应该被标记为 open
,因为我们设计它可以在其子类中可选地修改。
在子类中修改方法定义称为 重写,这就是为什么两个子类中的 name()
方法都被标记为 override
。
在层次结构中扩展类和重写行为的过程称为 特殊化。
经验法则
将一般状态和行为放在层次结构的顶部(泛化),将特定状态和行为放在子类中(特殊化)。
现在,我们可以有更多的面包店商品了!让我们看看下面的代码:
open class Roll(flavour: String): BakeryGood(flavour) {
override fun name(): String {
return "roll"
}
}
class CinnamonRoll: Roll("Cinnamon")
子类也可以被扩展。它们只需要被标记为 open
:
open class Donut(flavour: String, val topping: String) : BakeryGood(flavour)
{
override fun name(): String {
return "donut with $topping topping"
}
}
fun main(args: Array<String>) {
val myDonut = Donut("Custard", "Powdered sugar")
println(myDonut.eat())
}
我们还可以创建具有更多属性和方法的方法。
抽象类
到目前为止,一切顺利。我们的面包店看起来不错。然而,我们当前模型有一个问题。让我们看看下面的代码:
fun main(args: Array<String>) {
val anyGood = BakeryGood("Generic flavour")
}
我们可以直接实例化 BakeryGood
类,这太通用。为了纠正这种情况,我们可以将 BakeryGood
标记为 abstract
:
abstract class BakeryGood(val flavour: String) {
fun eat(): String {
return "nom, nom, nom... delicious $flavour ${name()}"
}
open fun name(): String {
return "bakery good"
}
}
抽象类 是一个专为扩展而设计的类。抽象类不能被实例化,这解决了我们的问题。
什么是 abstract
与 open
的不同之处?
这两个修饰符都允许我们扩展一个类,但 open
允许我们实例化,而 abstract
不允许。
现在我们不能实例化,BakeryGood
类中的 name()
方法就不再那么有用,而且除了 CinnamonRoll
之外的所有子类都重写了它(CinnamonRoll
依赖于 Roll
的实现):
abstract class BakeryGood(val flavour: String) {
fun eat(): String {
return "nom, nom, nom... delicious $flavour ${name()}"
}
abstract fun name(): String
}
被标记为 abstract
的方法没有主体,只有签名声明(方法签名是识别方法的一种方式)。在 Kotlin 中,签名由方法名、参数数量、参数类型和返回类型组成。
任何直接扩展 BakeryGood
的类都必须重写 name()
方法。重写抽象方法的术语是 实现,从现在起,我们将使用它。所以,Cupcake
类实现了 name()
方法(Kotlin 没有用于方法实现的关键字;方法实现和方法重写都使用关键字 override
)。
让我们引入一个新的类,Customer
;面包店总是需要顾客的:
class Customer(val name: String) {
fun eats(food: BakeryGood) {
println("$name is eating... ${food.eat()}")
}
}
fun main(args: Array<String>) {
val myDonut = Donut("Custard", "Powdered sugar")
val mario = Customer("Mario")
mario.eats(myDonut)
}
eats(food: BakeryGood)
方法接受一个 BakeryGood
参数,因此任何扩展 BakeryGood
参数的类的实例,无论有多少层等级。只需记住,我们可以直接实例化 BakeryGood
。
如果我们想要一个简单的 BakeryGood
呢?例如,进行测试。
有一个替代方案,一个匿名子类:
fun main(args: Array<String>) {
val mario = Customer("Mario")
mario.eats(object : BakeryGood("TEST_1") {
override fun name(): String {
return "TEST_2"
}
})
}
这里引入了一个新关键字,object
。稍后我们将更详细地介绍 object
,但就目前而言,只需知道这是一个 对象表达式。对象表达式定义了一个匿名类的实例,该类扩展了一个类型。
在我们的例子中,对象表达式(技术上,是 匿名类)必须重写 name()
方法,并将值作为参数传递给 BakeryGood
构造函数,就像标准类一样。
记住,一个 object
表达式是一个实例,因此它可以用来声明值:
val food: BakeryGood = object : BakeryGood("TEST_1") {
override fun name(): String {
return "TEST_2"
}
}
mario.eats(food)
接口
公开和抽象类非常适合创建层次结构,但有时它们不够用。一些子集可能跨越看似无关的层次结构,例如,鸟类和大型灵长类动物都是两足动物,它们都是动物和脊椎动物,但它们并不直接相关。这就是为什么我们需要不同的结构,Kotlin 给我们提供了接口(其他语言以不同的方式处理这个问题)。
我们的面包店产品很棒,但我们需要先烹饪它们:
abstract class BakeryGood(val flavour: String) {
fun eat(): String {
return "nom, nom, nom... delicious $flavour ${name()}"
}
fun bake(): String {
return "is hot here, isn't??"
}
abstract fun name(): String
}
我们的新的 bake()
方法将烹饪我们所有惊人的产品,但是等等,甜甜圈不是烘焙的,而是油炸的。
如果我们能把 bake()
方法移动到第二个抽象类 Bakeable
中会怎样?让我们在下面的代码中尝试一下:
abstract class Bakeable {
fun bake(): String {
return "is hot here, isn't??"
}
}
class Cupcake(flavour: String) : BakeryGood(flavour), Bakeable() { //Compilation error: Only one class may appear in a supertype list
override fun name(): String {
return "cupcake"
}
}
错误!在 Kotlin 中,一个类不能同时扩展两个类。让我们看看下面的代码:
interface Bakeable {
fun bake(): String {
return "is hot here, isn't??"
}
}
class Cupcake(flavour: String) : BakeryGood(flavour), Bakeable {
override fun name(): String {
return "cupcake"
}
}
然而,它可以扩展多个接口。一个 接口 是一个定义行为的类型;在 Bakeable
接口的情况下,那就是 bake()
方法。
因此,公开/抽象类和接口之间的区别是什么?
让我们从以下相似之处开始:
-
它们都是类型。在我们的例子中,
Cupcake
与BakeryGood
有一个 is-a 关系,并且与Bakeable
也有一个 is-a 关系。 -
它们都通过方法定义行为。
-
虽然公开类可以直接实例化,但抽象类和接口不行。
现在,让我们看看以下的不同之处:
-
一个类可以扩展一个类(公开或抽象),但不能扩展多个接口。
-
公开/抽象类可以有构造函数。
-
公开/抽象类可以初始化自己的值。接口的值必须在扩展接口的类中初始化。
-
公开类必须声明可以重写的公开方法。抽象类可以同时有公开和抽象方法。
在接口中,所有方法都是公开的,没有实现的方法不需要抽象修饰符:
interface Fried {
fun fry(): String
}
open class Donut(flavour: String, val topping: String) : BakeryGood(flavour), Fried {
override fun fry(): String {
return "*swimming on oil*"
}
override fun name(): String {
return "donut with $topping topping"
}
}
何时使用其中一个或另一个?:
-
当使用公开/抽象类时:
- 这个类应该被扩展和实例化
-
当需要应用多重继承时使用抽象类:
-
这个类不能被实例化
-
需要一个构造函数
-
存在初始化逻辑(使用
init
块)
-
让我们看看以下代码:
abstract class BakeryGood(val flavour: String) {
init {
println("Preparing a new bakery good")
}
fun eat(): String {
return "nom, nom, nom... delicious $flavour ${name()}"
}
abstract fun name(): String
}
-
当需要使用接口时:
-
使用公开类:
-
不需要初始化逻辑
-
我的建议是,你应该始终从接口开始。接口更直接、更简洁;它们还允许更模块化的设计。如果需要数据初始化/构造函数,则可以转向抽象/开放的。
与抽象类一样,对象表达式可以与接口一起使用:
val somethingFried = object : Fried {
override fun fry(): String {
return "TEST_3"
}
}
对象
我们已经介绍了对象表达式,但关于对象还有更多内容。对象是自然的单例(这里的“自然”是指作为语言特性出现,而不是作为行为模式实现,如其他语言中的情况)。单例是一种只有一个实例的类型,并且 Kotlin 中的每个对象都是单例。这打开了许多有趣的模式(以及一些不良实践)。作为单例的对象对于协调系统中的操作很有用,但如果不小心使用来保持全局状态,也可能很危险。
对象表达式不需要扩展任何类型:
fun main(args: Array<String>) {
val expression = object {
val property = ""
fun method(): Int {
println("from an object expressions")
return 42
}
}
val i = "${expression.method()} ${expression.property}"
println(i)
}
在这种情况下,expression
值是一个没有特定类型的对象。我们可以访问其属性和函数。
有一个限制——没有类型的对象表达式只能在本地使用,即在方法内部,或者私有地,在类内部:
class Outer {
val internal = object {
val property = ""
}
}
fun main(args: Array<String>) {
val outer = Outer()
println(outer.internal.property) // Compilation error: Unresolved reference: property
}
在这种情况下,property
值无法访问。
对象声明
对象也可以有一个名称。这种对象被称为 对象声明:
object Oven {
fun process(product: Bakeable) {
println(product.bake())
}
}
fun main(args: Array<String>) {
val myAlmondCupcake = Cupcake("Almond")
Oven.process(myAlmondCupcake)
}
对象是单例;你不需要实例化 Oven
来使用它。对象还可以扩展其他类型:
interface Oven {
fun process(product: Bakeable)
}
object ElectricOven: Oven {
override fun process(product: Bakeable) {
println(product.bake())
}
}
fun main(args: Array<String>) {
val myAlmondCupcake = Cupcake("Almond")
ElectricOven.process(myAlmondCupcake)
}
伴随对象
在类/接口内部声明的对象可以被标记为伴随对象。观察以下代码中伴随对象的使用:
class Cupcake(flavour: String) : BakeryGood(flavour), Bakeable {
override fun name(): String {
return "cupcake"
}
companion object {
fun almond(): Cupcake {
return Cupcake("almond")
}
fun cheese(): Cupcake {
return Cupcake("cheese")
}
}
}
现在,可以直接使用类名来使用伴随对象中的方法,而不需要实例化它:
fun main(args: Array<String>) {
val myBlueberryCupcake: BakeryGood = Cupcake("Blueberry")
val myAlmondCupcake = Cupcake.almond()
val myCheeseCupcake = Cupcake.cheese()
val myCaramelCupcake = Cupcake("Caramel")
}
伴随对象的方法不能从实例中使用:
fun main(args: Array<String>) {
val myAlmondCupcake = Cupcake.almond()
val myCheeseCupcake = myAlmondCupcake.cheese() //Compilation error: Unresolved reference: cheese
}
伴随对象可以作为具有名称 Companion
的值在类外部使用:
fun main(args: Array<String>) {
val factory: Cupcake.Companion = Cupcake.Companion
}
或者,Companion
对象也可以有一个名称:
class Cupcake(flavour: String) : BakeryGood(flavour), Bakeable {
override fun name(): String {
return "cupcake"
}
companion object Factory {
fun almond(): Cupcake {
return Cupcake("almond")
}
fun cheese(): Cupcake {
return Cupcake("cheese")
}
}
}
fun main(args: Array<String>) {
val factory: Cupcake.Factory = Cupcake.Factory
}
它们也可以不命名使用,如下面的代码所示:
fun main(args: Array<String>) {
val factory: Cupcake.Factory = Cupcake
}
不要被这种语法弄混淆。没有括号的 Cupcake
值是伴随对象;Cupcake()
是一个实例。
泛型
这一部分只是泛型的一个简要介绍;稍后我们将详细讨论。
泛型编程是一种编程风格,它侧重于创建适用于一般问题的算法(以及相关数据结构)。
Kotlin 支持泛型编程的方式是使用类型参数。简而言之,我们用类型参数编写代码,然后在需要使用时,将这些类型作为参数传递。
以我们的 Oven
接口为例:
interface Oven {
fun process(product: Bakeable)
}
烤箱是一种机器,因此我们可以更广泛地推广它:
interface Machine<T> {
fun process(product: T)
}
Machine<T>
接口定义了一个类型参数 T
和一个 process(T)
方法。
现在,我们可以用 Oven
来扩展它:
interface Oven: Machine<Bakeable>
现在,Oven
使用 Bakeable
类型参数扩展了 Machine
,因此 process
方法现在接受 Bakeable
作为参数。
类型别名
类型别名提供了一种定义已存在类型名称的方法。类型别名可以帮助使复杂类型更容易阅读,并且还可以提供其他提示。
在某种意义上,Oven
接口只是一个名称,代表一个 Machine<Bakeable>
:
typealias Oven = Machine<Bakeable>
我们新的类型别名 Oven
与我们熟悉的 Oven
接口完全一样。它可以被扩展,并具有 Oven
类型的值。
类型别名也可以用来增强类型信息,提供与你的领域相关的有意义的名称:
typealias Flavour = String
abstract class BakeryGood(val flavour: Flavour) {
它也可以用于集合:
typealias OvenTray = List<Bakeable>
它也可以与对象一起使用:
typealias CupcakeFactory = Cupcake.Companion
可空类型
Kotlin 的一个主要特性是可空类型。可空类型允许我们显式地定义一个值是否可以包含或为空:
fun main(args: Array<String>) {
val myBlueberryCupcake: Cupcake = null //Compilation error: Null can not be a value of a non-null type Cupcake
}
在 Kotlin 中这并不有效;Cupcake
类型不允许空值。要允许空值,myBlueberryCupcake
必须有不同的类型:
fun main(args: Array<String>) {
val myBlueberryCupcake: Cupcake? = null
}
从本质上讲,Cupcake
是一个非空类型,而 Cupcake?
是一个可空类型。
在层次结构中,Cupcake
是 Cupcake?
的子类型。因此,在任何 Cupcake?
被定义的情况下,Cupcake
可以被使用,但反之则不行:
fun eat(cupcake: Cupcake?){
// something happens here
}
fun main(args: Array<String>) {
val myAlmondCupcake = Cupcake.almond()
eat(myAlmondCupcake)
eat(null)
}
Kotlin 编译器在可空类型和非空类型实例之间做出区分。
让我们以这些值为例:
fun main(args: Array<String>) {
val cupcake: Cupcake = Cupcake.almond()
val nullabeCupcake: Cupcake? = Cupcake.almond()
}
接下来,我们将对可空类型和非空类型都调用 eat()
方法:
fun main(args: Array<String>) {
val cupcake: Cupcake = Cupcake.almond()
val nullableCupcake: Cupcake? = Cupcake.almond()
cupcake.eat() // Happy days
nullableCupcake.eat() //Only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type Cupcake?
}
在 cupcake
上调用 eat()
方法就像吃馅饼一样简单;在 nullableCupcake
上调用 eat()
会产生编译错误。
为什么?对于 Kotlin 来说,从一个可空值中调用方法是有风险的,可能会抛出一个潜在的 NullPointerException
(以下简称 NPE)。因此,为了安全起见,Kotlin 将其标记为编译错误。
如果我们真的想从一个可空值中调用一个方法或访问一个属性,会发生什么?
好吧,Kotlin 提供了处理可空值的选择,所有这些都是显式的。在某种程度上,Kotlin 在告诉你,“展示给我,你知道你在做什么”。
让我们回顾一些选项(在接下来的章节中我们还将介绍更多选项)。
检查空值
在 if
块中将空值检查作为一个条件:
fun main(args: Array<String>) {
val nullableCupcake: Cupcake? = Cupcake.almond()
if (nullableCupcake != null) {
nullableCupcake.eat()
}
}
Kotlin 将执行智能转换。在 if
块内部,nullableCupcake
是 Cupcake
,而不是 Cupcake?
;因此,可以访问任何方法或属性。
检查非空类型
这与上一个类似,但它直接检查类型:
if (nullableCupcake is Cupcake) {
nullableCupcake.eat()
}
它也可以与 when
一起使用:
when (nullableCupcake) {
is Cupcake -> nullableCupcake.eat()
}
两种选项,检查空值和非空类型,都有些冗长。让我们看看其他选项。
安全调用
安全调用允许在值非空时访问可空值的属性和方法(在底层,在字节码级别,安全调用被转换为 if(x != null)
):
nullableCupcake?.eat()
但是,如果你在表达式中使用它呢?
val result: String? = nullableCupcake?.eat()
如果我们的值是空,它将返回空,所以 result
必须有 String?
类型。
这样就打开了在链上使用安全调用的机会,如下所示:
val length: Int? = nullableCupcake?.eat()?.length
Elvis (?😃 操作符
Elvis 操作符(?:
)在表达式中使用空值时返回一个替代值:
val result2: String = nullableCupcake?.eat() ?: ""
如果nullabluCupcake?.eat()
是null
,则?:
运算符将返回替代值""
。
显然,Elvis 运算符可以与一系列安全调用一起使用:
val length2: Int = nullableCupcake?.eat()?.length ?: 0
!!!
运算符
而不是null
值,!!
运算符将抛出一个 NPE:
val result: String = nullableCupcake!!.eat()
如果你能够处理 NPE,则!!
运算符提供了一个相当方便的功能,即免费智能转换:
val result: String = nullableCupcake!!.eat()
val length: Int = nullableCupcake.eat().length
如果nullableCupcake!!.eat()
没有抛出 NPE,Kotlin 将从下一行开始将其类型从Cupcake?
更改为Cupcake
。
Kotlin 的类型系统
类型系统是一组规则,用于确定语言构造的类型。
一个(好的)类型系统将帮助你:
-
确保程序组成部分以一致的方式连接
-
理解你的程序(通过减少你的认知负荷)
-
表达业务规则
-
自动低级优化
我们已经覆盖了足够的内容来理解 Kotlin 的类型系统。
Any
类型
Kotlin 中的所有类型都扩展自Any
类型(等等,实际上这不是真的,但为了解释的目的,请耐心等待)。
我们创建的每个类和接口都隐式扩展了Any
。因此,如果我们编写一个接受Any
作为参数的方法,它将接收任何值:
fun main(args: Array<String>) {
val myAlmondCupcake = Cupcake.almond()
val anyMachine = object : Machine<Any> {
override fun process(product: Any) {
println(product.toString())
}
}
anyMachine.process(3)
anyMachine.process("")
anyMachine.process(myAlmondCupcake)
}
可空值呢?让我们看看它:
fun main(args: Array<String>) {
val anyMachine = object : Machine<Any> {
override fun process(product: Any) {
println(product.toString())
}
}
val nullableCupcake: Cupcake? = Cupcake.almond()
anyMachine.process(nullableCupcake) //Error:(32, 24) Kotlin: Type mismatch: inferred type is Cupcake? but Any was expected
}
Any
与任何其他类型相同,并且也有一个可空对应物,Any?
。Any
扩展自Any?
。因此,最终,Any?
是 Kotlin 类型系统层次结构中的顶级类。
最小公共类型
由于类型推断和表达式评估,有时在 Kotlin 中存在一些表达式,其中不清楚返回哪种类型。大多数语言通过返回可能类型选项之间的最小公共类型来解决这个问题。Kotlin 采取了不同的路线。
让我们看看一个模糊表达式的例子:
fun main(args: Array<String>) {
val nullableCupcake: Cupcake? = Cupcake.almond()
val length = nullableCupcake?.eat()?.length ?: ""
}
length
的类型是什么?Int
还是String
?不,length
值的类型是Any
。这很合理。Int
和String
之间的最小公共类型是Any
。到目前为止,一切顺利。现在让我们看看以下代码:
val length = nullableCupcake?.eat()?.length ?: 0.0
按照这个逻辑,在这种情况下,length
应该有Number
类型(Int
和Double
之间的公共类型),对吧?
错误的,length
仍然是Any
。Kotlin 在这些情况下不会搜索最小公共类型。如果你想得到一个特定的类型,它必须被显式声明:
val length: Number = nullableCupcake?.eat()?.length ?: 0.0
Unit
类型
Kotlin 没有void
返回值的方法(就像 Java 或 C 那样)。相反,一个方法(或更准确地说,一个表达式)可以有一个Unit
类型。
Unit
类型表示表达式被调用是为了其副作用,而不是其返回值。Unit
表达式的经典例子是println()
,这是一个仅为了其副作用而被调用的方法。
Unit
,就像任何其他 Kotlin 类型一样,从Any
扩展而来,并且可以是可空的。Unit?
看起来很奇怪且不必要,但这是为了与类型系统保持一致性。拥有一致的类型系统有多个优点,包括更好的编译时间和工具支持:
anyMachine.process(Unit)
Nothing
类型
Nothing
是位于整个 Kotlin 层次结构底部的类型。Nothing
继承了所有 Kotlin 类型,包括 Nothing?
。
但是,为什么我们需要 Nothing
和 Nothing?
类型?
Nothing
代表一个无法执行的表达式(基本上是抛出异常):
val result: String = nullableCupcake?.eat() ?: throw RuntimeException() // equivalent to nullableCupcake!!.eat()
在 Elvis 操作符的一侧,我们有 String
。在另一侧,我们有 Nothing
。因为 String
和 Nothing
之间的公共类型是 String
(而不是 Any
),所以 result
的值是 String
。
Nothing
对于编译器也有特殊的意义。一旦在表达式中返回 Nothing
类型,之后的行就会被标记为不可达。
Nothing?
是空值的类型:
val x: Nothing? = null
val nullsList: List<Nothing?> = listOf(null)
其他类型
类、接口和对象是面向对象类型系统的良好起点,但 Kotlin 提供了更多构造,例如数据类、注解和枚举(还有一个额外的类型,称为密封类,我们将在后面介绍)。
数据类
在 Kotlin 中创建主要目的是持有数据的类是一种常见模式(在其他语言中也是一种常见模式,例如 JSON 或 Protobuff)。
Kotlin 有一种特定的类用于此目的:
data class Item(val product: BakeryGood,
val unitPrice: Double,
val quantity: Int)
要声明数据类,有一些限制:
-
主要构造函数应至少有一个参数
-
主要构造函数的参数必须是
val
或var
-
数据类不能是抽象的、开放的、密封的或内部的
在这些限制下,数据类提供了很多好处。
规范方法
规范方法是在 Any
中声明的。因此,Kotlin 中的所有实例都有这些方法。
对于数据类,Kotlin 会为所有规范方法创建正确的实现。
方法如下:
-
equals(other: Any?): Boolean
: 此方法比较值等价性,而不是引用。 -
hashCode(): Int
: 哈希码是实例的数值表示。当在同一个实例上多次调用hashCode()
时,它应该始终返回相同的值。当两个实例在equals
中返回 true 时,它们必须具有相同的hashCode()
。 -
toString(): String
: 实例的字符串表示。当实例被连接到字符串时,将调用此方法。
copy()
方法
有时,我们希望重用现有实例的值。copy()
方法允许我们创建数据类的新实例,并覆盖我们想要的参数:
val myItem = Item(myAlmondCupcake, 0.40, 5)
val mySecondItem = myItem.copy(product = myCaramelCupcake) //named parameter
在这种情况下,mySecondItem
从 myItem
复制 unitPrice
和 quantity
,并替换 product
属性。
析构方法
按照惯例,任何具有一系列名为 component1()
、component2()
等方法的类实例都可以用于析构声明。
Kotlin 将为任何数据类生成这些方法:
val (prod: BakeryGood, price: Double, qty: Int) = mySecondItem
prod
值使用 component1()
的返回值初始化,price
使用 component2()
的返回值,依此类推。尽管前面的示例使用了显式类型,但这些类型不是必需的:
val (prod, price, qty) = mySecondItem
在某些情况下,并不需要所有值。所有未使用的值都可以用(_
)替换:
val (prod, _, qty) = mySecondItem
注解
注解是一种将元信息附加到您的代码上的方式(例如文档、配置等)。
让我们看看以下示例代码:
annotation class Tasty
一个注解
本身可以被注解来修改其行为:
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
annotation class Tasty
在这种情况下,Tasty
注解可以应用于类、接口和对象,并且可以在运行时查询。
要获取完整的选项列表,请查看 Kotlin 文档。
注解可以有参数,但有一个限制,它们不能为空:
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
annotation class Tasty(val tasty:Boolean = true)
@Tasty(false)
object ElectricOven : Oven {
override fun process(product: Bakeable) {
println(product.bake())
}
}
@Tasty
class CinnamonRoll : Roll("Cinnamon")
@Tasty
interface Fried {
fun fry(): String
}
要在运行时查询注解值,我们必须使用反射 API(kotlin-reflect.jar
必须位于您的类路径中):
fun main(args: Array<String>) {
val annotations: List<Annotation> = ElectricOven::class.annotations
for (annotation in annotations) {
when (annotation) {
is Tasty -> println("Is it tasty? ${annotation.tasty}")
else -> println(annotation)
}
}
}
枚举
Kotlin 中的枚举是一种定义一组常量值的方式。枚举非常有用,但不仅限于配置值:
enum class Flour {
WHEAT, CORN, CASSAVA
}
每个元素都是一个扩展了Flour
类的对象。
就像任何对象一样,它们可以扩展接口:
interface Exotic {
fun isExotic(): Boolean
}
enum class Flour : Exotic {
WHEAT {
override fun isExotic(): Boolean {
return false
}
},
CORN {
override fun isExotic(): Boolean {
return false
}
},
CASSAVA {
override fun isExotic(): Boolean {
return true
}
}
}
枚举也可以有抽象方法:
enum class Flour: Exotic {
WHEAT {
override fun isGlutenFree(): Boolean {
return false
}
override fun isExotic(): Boolean {
return false
}
},
CORN {
override fun isGlutenFree(): Boolean {
return true
}
override fun isExotic(): Boolean {
return false
}
},
CASSAVA {
override fun isGlutenFree(): Boolean {
return true
}
override fun isExotic(): Boolean {
return true
}
};
abstract fun isGlutenFree(): Boolean
}
任何方法定义都必须在分隔最后一个元素的分号(;
)之后声明。
当枚举与when
表达式一起使用时,Kotlin 编译器会检查所有情况是否都已覆盖(单独或使用else
):
fun flourDescription(flour: Flour): String {
return when(flour) { // error
Flour.CASSAVA -> "A very exotic flavour"
}
}
在这个例子中,我们只检查了CASSAVA
,而没有检查其他元素;因此,它失败了:
fun flourDescription(flour: Flour): String {
return when(flour) {
Flour.CASSAVA -> "A very exotic flavour"
else -> "Boring"
}
}
摘要
在本章中,我们介绍了面向对象编程的基础以及 Kotlin 如何支持它。我们学习了如何使用类、接口、对象、数据类、注解和枚举。我们还探讨了 Kotlin 类型系统,并看到了它是如何帮助我们编写更好、更安全的代码的。
在下一章中,我们将从函数式编程的介绍开始。
第二章:函数式编程入门
函数式编程在过去五年中在软件行业中引起了巨大波澜,每个人都想搭上这趟车。函数式编程的历史要悠久得多,始于 20 世纪 50 年代,Lisp被认为是第一种编程语言(或者至少是第一个引入函数式特性的语言),它仍然以Common Lisp的形式存在,以及其他方言,如Scheme和Clojure。
在本章中,我们将涵盖以下主题:
-
什么是函数式编程?
-
基本概念
-
函数式集合
-
实现函数式列表
什么是函数式编程?
函数式编程是一种范式(一种组织程序的风格)。本质上,重点是使用表达式转换数据(理想情况下,这些表达式不应有副作用)。其名称“函数式”基于数学函数的概念(而不是子程序、方法或过程)。数学函数定义了一组输入和输出之间的关系。每个输入只有一个输出。例如,给定一个函数,f(x) = X²; f(5) 总是 25。
在编程语言中,为了保证调用带有参数的函数总是返回相同的值,需要避免访问可变状态:
fun f(x: Long) : Long {
return x * x // no access to external state
}
f
函数不访问任何外部状态;因此,调用 f(5) 总是返回 25:
fun main(args: Array<String>) {
var i = 0
fun g(x: Long): Long {
return x * i // accessing mutable state
}
println(g(1)) //0
i++
println(g(1)) //1
i++
println(g(1)) //2
}
另一方面,g
函数依赖于可变状态,并且对于相同的输入返回不同的值。
现在,在现实生活中的程序(一个内容管理系统(CMS)、购物车或聊天程序)中,状态会发生变化。因此,在函数式编程风格中,状态管理必须是明确和谨慎的。在后面的章节中,我们将介绍在函数式编程中管理状态变化的技术。
函数式编程风格将为我们提供以下好处:
-
代码易于阅读和测试:不依赖于外部可变状态的功能更容易推理和证明
-
状态和副作用是精心规划的:将状态管理限制在我们代码的个体和特定位置,使得维护和重构变得容易
-
并发性变得更安全且更自然:没有可变状态意味着并发代码在你的代码周围需要更少或没有锁
基本概念
函数式编程由几个定义良好的概念组成。以下是对这些概念的简要介绍,稍后将在下一章中深入探讨每个概念。
一等和高级函数
函数式编程最基础的概念是一等函数。支持一等函数的编程语言会将函数视为任何其他类型;这样的语言将允许你使用函数作为变量、参数、返回值、泛化类型等。说到参数和返回值,使用或返回其他函数的函数是高阶函数。
Kotlin 支持这两个概念。
让我们尝试一个简单的函数(在 Kotlin 的文档中这种函数被称为 lambda):
val capitalize = { str: String -> str.capitalize() }
fun main(args: Array<String>) {
println(capitalize("hello world!"))
}
capitalize
lambda 函数的类型是(String) -> String
;换句话说,capitalize
将接受String
并返回另一个String
——在这种情况下,一个首字母大写的String
。
作为 lambda 函数,capitalize
可以用带参数的括号(或者完全没有参数,具体取决于情况)来执行。
但(String) -> String
类型意味着什么呢?
(String) -> String
是一个快捷方式(有些人可能称之为语法糖),它是Function1<String, String>
的简称,Function1<P1, R>
是在 Kotlin 标准库中定义的一个接口。Function1<P1, R>
有一个标记为操作符的方法invoke(P1): R
(我们稍后会介绍操作符)。
Kotlin 的编译器可以在编译时将快捷语法转换为完整的函数对象(实际上,编译器还会应用更多的优化),如下所示:
val capitalize = { str: String -> str.capitalize() }
它等同于以下代码:
val capitalize = object : Function1<String, String> {
override fun invoke(p1: String): String {
return p1.capitalize()
}
}
正如你所见,capitalize
值的主体位于invoke
方法内部。
在 Kotlin 中,lambda 函数也可以用作其他函数的参数。
让我们看看以下示例:
fun transform(str:String, fn: (String) -> String): String {
return fn(str)
}
transform(String, (String) -> String)
函数接受一个String
并对其应用一个 lambda 函数。
从所有目的来看,我们可以泛化transform
:
fun <T> transform(t: T, fn: (T) -> T): T {
return fn(t)
}
使用transform
非常简单。看看下面的代码片段:
fun main(args: Array<String>) {
println(transform("kotlin", capitalize))
}
我们可以直接传递capitalize
作为参数,真是太棒了。
调用transform
函数的方式有很多。让我们再试几个:
fun reverse(str: String): String {
return str.reversed()
}
fun main(args: Array<String>) {
println(transform("kotlin", ::reverse))
}
reverse
是一个函数;我们可以使用双冒号(::
)来传递它的引用,如下所示:
object MyUtils {
fun doNothing(str: String): String {
return str
}
}
fun main(args: Array<String>) {
println(transform("kotlin", MyUtils::doNothing))
}
doNothing
是一个对象方法,在这种情况下,我们在MyUtils
对象名称后使用::
:
class Transformer {
fun upperCased(str: String): String {
return str.toUpperCase()
}
companion object {
fun lowerCased(str: String): String {
return str.toLowerCase()
}
}
}
fun main(args: Array<String>) {
val transformer = Transformer()
println(transform("kotlin", transformer::upperCased))
println(transform("kotlin", Transformer.Companion::lowerCased))
}
我们也可以传递实例或伴生对象的引用。但最常见的情况可能是直接传递一个 lambda 表达式:
fun main(args: Array<String>) {
println(transform("kotlin", { str -> str.substring(0..1) }))
}
使用it
隐式参数的简短版本如下:
fun main(args: Array<String>) {
println(transform("kotlin", { it.substring(0..1) }))
}
it
是一个隐式参数(你不需要显式声明它),它可以用作只有一个参数的 lambda 表达式。
虽然使用it
在所有情况下都很诱人,但一旦你开始使用连续或嵌套的 lambda,它们可能会很难阅读。要谨慎使用,并且当它清楚类型时(没有打趣的意思)。
如果一个函数将 lambda 作为最后一个参数接收,lambda 可以放在括号之外传递:
fun main(args: Array<String>) {
println(transform("kotlin") { str -> str.substring(0..1) })
}
这个特性为使用 Kotlin 创建领域特定语言(DSL)打开了可能性。
你知道Ruby中的unless
流程控制语句吗?unless
是一个在条件为false
时执行代码块的控制语句;它有点像否定if
条件,但没有else
子句。
让我们通过执行以下代码片段来为 Kotlin 创建一个版本:
fun unless(condition: Boolean, block: () -> Unit){
if (!condition) block()
}
fun main(args: Array<String>) {
val securityCheck = false // some interesting code here
unless(securityCheck) {
println("You can't access this website")
}
}
unless
接收一个布尔条件作为参数,并以 lambda () -> Unit
(无参数和无返回值)的形式阻塞执行。当 unless
执行时,它看起来就像 Kotlin 的任何其他控制流结构。
现在,类型别名可以与函数混合使用,以替换简单的接口。以下是一个例子,来自 第一章,Kotlin – 数据类型、对象和类:
interface Machine<T> {
fun process(product: T)
}
fun <T> useMachine(t: T, machine: Machine<T>) {
machine.process(t)
}
class PrintMachine<T> : Machine<T> {
override fun process(t: T) {
println(t)
}
}
fun main(args: Array<String>) {
useMachine(5, PrintMachine())
useMachine(5, object : Machine<Int> {
override fun process(t: Int) {
println(t)
}
})
}
它可以用类型别名替换,并使用所有函数的语法特性:
typealias Machine<T> = (T) -> Unit
fun <T> useMachine(t: T, machine: Machine<T>) {
machine(t)
}
class PrintMachine<T>: Machine<T> {
override fun invoke(p1: T) {
println(p1)
}
}
fun main(args: Array<String>) {
useMachine(5, PrintMachine())
useMachine(5, ::println)
useMachine(5) { i ->
println(i)
}
}
纯函数
纯函数 没有副作用,也没有内存或 I/O。纯函数有许多属性,包括引用透明性、缓存(记忆化)以及其他(我们将在下一章中介绍这些功能)。
在 Kotlin 中可以编写纯函数,但编译器不会像其他语言那样强制执行。是否创建纯函数以享受其好处取决于你。因为 Kotlin 不强制执行纯函数,所以许多程序员说 Kotlin 不是一个真正的函数式编程工具,也许他们是对的。是的,Kotlin 不强制执行纯函数式编程,这为你提供了极大的灵活性,包括如果你愿意,可以以纯函数式风格编写代码。
递归函数
递归函数 是调用自身的函数,具有某种停止执行的条件。在 Kotlin 中,递归函数维护一个栈,但可以使用 tailrec
修饰符进行优化。
让我们看一个例子,一个 阶乘
函数的实现。
首先,让我们看看一个典型的命令式实现,以下代码片段中的循环和状态变化:
fun factorial(n: Long): Long {
var result = 1L
for (i in 1..n) {
result *= i
}
return result
}
这没有什么特别之处,也不特别优雅。现在,让我们看看一个递归实现,没有循环,也没有状态变化:
fun functionalFactorial(n: Long): Long {
fun go(n: Long, acc: Long): Long {
return if (n <= 0) {
acc
} else {
go(n - 1, n * acc)
}
}
return go(n, 1)
}
我们使用一个内部递归函数;go
函数在达到条件之前调用自身。正如你所看到的,我们是从最后一个 n
值开始的,并在每次递归迭代中减少它。
优化后的实现类似,但带有 tailrec
修饰符:
fun tailrecFactorial(n: Long): Long {
tailrec fun go(n: Long, acc: Long): Long {
return if (n <= 0) {
acc
} else {
go(n - 1, n * acc)
}
}
return go(n, 1)
}
要测试哪个实现更快,我们可以编写一个简陋的分析函数:
fun executionTime(body: () -> Unit): Long {
val startTime = System.nanoTime()
body()
val endTime = System.nanoTime()
return endTime - startTime
}
对于我们的目的,executionTime
函数是可行的,但任何严肃的生产代码都应该使用适当的分析工具进行性能分析,例如 Java Microbenchmark Harness(JMH):
fun main(args: Array<String>) {
println("factorial :" + executionTime { factorial(20) })
println("functionalFactorial :" + executionTime { functionalFactorial(20) })
println("tailrecFactorial :" + executionTime { tailrecFactorial(20) })
}
以下是前面代码的输出:
tailrec
优化的版本甚至比正常的命令式版本更快。但 tailrec
不是一个魔法咒语,可以使你的代码运行得更快。一般来说,tailrec
优化的代码将比未优化的版本运行得更快,但并不总是能打败好的旧命令式代码。
让我们探索一个斐波那契数列的实现,从一个命令式实现开始如下:
fun fib(n: Long): Long {
return when (n) {
0L -> 0
1L -> 1
else -> {
var a = 0L
var b = 1L
var c = 0L
for (i in 2..n) {
c = a + b
a = b
b = c
}
c
}
}
}
现在,让我们看看一个函数式递归实现:
fun functionalFib(n: Long): Long {
fun go(n: Long, prev: Long, cur: Long): Long {
return if (n == 0L) {
prev
} else {
go(n - 1, cur, prev + cur)
}
}
return go(n, 0, 1)
}
现在,让我们检查其对应的 tailrec
版本,如下所示:
fun tailrecFib(n: Long): Long {
tailrec fun go(n: Long, prev: Long, cur: Long): Long {
return if (n == 0L) {
prev
} else {
go(n - 1, cur, prev + cur)
}
}
return go(n, 0, 1)
}
然后,再次,让我们用 executionTime
来查看其分析:
fun main(args: Array<String>) {
println("fib :" + executionTime { fib(93) })
println("functionalFib :" + executionTime { functionalFib(93) })
println("tailrecFib :" + executionTime { tailrecFib(93) })
}
输出将类似于以下内容:
tailrec
实现比递归版本快得多,但不如正常命令式实现快。
懒加载
一些函数式语言提供了 lazy
(非严格)评估模式。Kotlin 默认使用 贪婪(严格)评估。
Kotlin 本身不提供对懒加载的原生支持,但作为 Kotlin 标准库的一部分,以及一个名为 委托属性 的语言特性(我们将在未来的章节中详细讨论):
fun main(args: Array<String>) {
val i by lazy {
println("Lazy evaluation")
1
}
println("before using i")
println(i)
}
输出将类似于以下截图:
在 by
保留字之后,lazy()
高阶函数接收一个 (() -> T)
初始化 lambda 函数,该函数将在第一次访问 i
时执行。
但也可以使用正常的 lambda 函数来处理一些懒加载用例:
fun main(args: Array<String>) {
val size = listOf(2 + 1, 3 * 2, 1 / 0, 5 - 4).size
}
如果我们尝试执行这个表达式,它将抛出一个 ArithmeticException
异常,因为我们正在除以零:
fun main(args: Array<String>) {
val size = listOf({ 2 + 1 }, { 3 * 2 }, { 1 / 0 }, { 5 - 4 }).size
}
执行这个没有问题。出问题的代码没有被执行,实际上使其成为一个 lazy
评估。
功能集合
函数式集合 是那些通过高阶函数提供与其元素交互方式的集合。函数式集合具有名为 filter
、map
和 fold
等常见操作;这些名称是通过约定(类似于设计模式)定义的,并在多个库和语言中实现。
不要与纯函数式数据结构混淆——这是在纯函数式语言中实现的数据结构。纯函数式数据结构是不可变的,并使用 lazy
评估和其他函数式技术。
函数式集合可以是,但不一定是纯函数式数据结构。我们已经讨论了算法的命令式实现可以比函数式实现更快。
Kotlin 随带一个优秀的函数式集合库。让我们看看它:
val numbers: List<Int> = listOf(1, 2, 3, 4)
我们的价值 numbers
是一个 List<Int>
类型的值。现在,让我们按照以下方式打印其成员:
fun main(args: Array<String>) {
for(i in numbers) {
println("i = $i")
}
}
到目前为止,一切顺利,但它看起来并不太像函数式。
不必再担心;Kotlin 集合包括许多接收 lambda 来操作其成员的函数。我们可以用 lambda 替换这个循环,如下所示:
fun main(args: Array<String>) {
numbers.forEach { i -> println("i = $i") }
}
现在,让我们在以下代码中转换我们的集合:
val numbers: List<Int> = listOf(1, 2, 3, 4)
fun main(args: Array<String>) {
val numbersTwice: List<Int> = listOf()
for (i in numbers) {
numbersTwice.add(i * 2) //Compilation error: Unresolved reference: add
}
}
这段代码无法编译;numberTwice
没有提供 add(T)
方法。List<T>
是一个不可变列表;一旦初始化,它就可以被修改。要向列表中添加元素,它必须具有不同的类型——在我们的例子中是 MutableList<T>
:
val numbers: List<Int> = listOf(1, 2, 3, 4)
fun main(args: Array<String>) {
val numbersTwice: MutableList<Int> = mutableListOf()
for (i in numbers) {
numbersTwice.add(i * 2) //Nice!
}
}
MutableList<T>
扩展了 List<T>
;它添加了修改集合本身的方法,例如 add(T)
、remove(T)
、clear
以及其他方法。
Kotlin 的所有主要集合类型(List<T>
, Set<T>
, 和 Map<K, V>
)都有可变子类型(MutableList<T>
, MutableSet<T>
, 和 MutableMap<K, V>
)。
但我们可以将这个转换替换为以下代码中的单行表达式:
val numbers: List<Int> = listOf(1, 2, 3, 4)
fun main(args: Array<String>) {
val numbersTwice: List<Int> = numbers.map { i -> i * 2 }
}
map
操作允许你转换(技术上是对值进行映射)。这段代码有很多优点,并且更加简洁,现在 numbersTwice
的值是一个 List<Int>
列表,而不是 MutableList<T>
列表。
让我们再举几个例子。我们可以使用循环来计算数字的所有元素之和:
val numbers: List<Int> = listOf(1, 2, 3, 4)
fun main(args: Array<String>) {
var sum = 0
for (i in numbers) {
sum += i
}
println(sum)
}
这可以简化为只有一行,使用不可变的 sum
值如下:
val numbers: List<Int> = listOf(1, 2, 3, 4)
fun main(args: Array<String>) {
val sum = numbers.sum()
println(sum)
}
很好,但不是很吸引人,所以让我们提高难度:
val numbers: List<Int> = listOf(1, 2, 3, 4)
fun main(args: Array<String>) {
val sum = numbers.fold(0) { acc, i -> acc + i }
println(sum)
}
fold
方法遍历一个集合,保持一个累加器值。fold
接收一个 T
值作为初始值;在第一次迭代中,这个初始值将是累加器,后续迭代将使用 lambda 的返回值作为下一个累加器值:
val numbers: List<Int> = listOf(1, 2, 3, 4)
fun main(args: Array<String>) {
val sum = numbers.fold(0) { acc, i ->
println("acc, i = $acc, $i")
acc + i
}
println(sum)
}
输出将类似于以下截图:
与 fold
类似,reduce
遍历一个集合,有一个累加器但没有初始值:
val numbers: List<Int> = listOf(1, 2, 3, 4)
fun main(args: Array<String>) {
val sum = numbers.reduce { acc, i ->
println("acc, i = $acc, $i")
acc + i
}
println(sum)
}
输出将类似于以下截图:
fold
和 reduce
有对应的 foldRight
和 reduceRight
,它们从最后一个项目开始迭代到第一个项目。
实现一个函数式列表
在前两章学到的所有知识的基础上,我们可以实现一个纯函数式列表:
sealed class FunList<out T> {
object Nil : FunList<Nothing>()
data class Cons<out T>(val head: T, val tail: FunList<T>) : FunList<T>()
}
FunList
类是一个密封类;只有两个可能的子类存在——Nil
,一个空列表(在其他书中你可以看到它被定义为 Null
或 Empty
)和 Cons
(一个结构,名称来自 Lisp,它包含两个值)。
T
类型被标记为 out
;这是为了变异性,我们将在未来的章节中介绍变异性。
Nil
是一个对象(我们不需要 Nil
的不同实例)扩展 FunList<Nothing>
(记住 Nothing
是 Kotlin 类型层次结构的底部)。
Cons
值包含两个值——head
,一个单独的 T
,和 tail
,一个 FunList<T>
;因此,它可以是一个 Nil
值或另一个 Cons
。
让我们创建一个列表实例如下:
import com.packtpub.functionalkotlin.chapter02.FunList.Cons
import com.packtpub.functionalkotlin.chapter02.FunList.Nil
fun main(args: Array<String>) {
val numbers = Cons(1, Cons(2, Cons(3, Cons(4, Nil))))
}
它是函数式的,但不是很易读。我们可以创建一个更好的初始化函数:
import com.packtpub.functionalkotlin.chapter02.FunList.Cons
import com.packtpub.functionalkotlin.chapter02.FunList.Nil
fun intListOf(vararg numbers: Int): FunList<Int> {
return if (numbers.isEmpty()) {
Nil
} else {
Cons(numbers.first(), intListOf(*numbers.drop(1).toTypedArray().toIntArray()))
}
}
这里有很多新东西。参数 numbers
被标记为 vararg
,这意味着我们可以用尽可能多的参数调用这个函数。从所有目的来看,numbers
是一个 IntArray
值(一种特殊的数组类型)。如果 numbers
为空,我们可以返回 Nil
。如果不为空,我们可以提取第一个元素作为我们的 head
值,并递归调用 intListOf
来获取 tail
值。为了提取 tail
值,我们使用 drop
方法并将结果转换为 IntArray
值。但我们不能直接将任何数组作为 vararg
传递;因此,我们必须使用展开(*
)运算符来逐个传递数组的每个成员。
现在,我们可以创建我们的 FunList<Int>
值:
fun main(args: Array<String>) {
val numbers = intListOf(1, 2, 3, 4)
}
让我们按照以下方式实现 forEach
:
sealed class FunList<out T> {
object Nil : FunList<Nothing>()
data class Cons<out T>(val head: T, val tail: FunList<T>) : FunList<T>()
fun forEach(f: (T) -> Unit) {
tailrec fun go(list: FunList<T>, f: (T) -> Unit) {
when (list) {
is Cons -> {
f(list.head)
go(list.tail, f)
}
is Nil -> Unit//Do nothing
}
}
go(this, f)
}
}
forEach
的实现类似于我们在递归部分中的阶乘和斐波那契函数的例子,包括 tailrec
。
从技术上来说,FunList
是一个 代数数据类型(ADT)。FunList
可以是 Nil
或 Cons
,除此之外没有其他可能。Kotlin 的编译器可以使用这些信息来检查当 FunList
类型用作 when
控制结构中的参数时,两个值是否都被评估:
fun main(args: Array<String>) {
val numbers = intListOf(1, 2, 3, 4)
numbers.forEach { i -> println("i = $i") }
}
实现 fold
将类似于以下代码:
sealed class FunList<out T> {
/*Previous code here*/
fun <R> fold(init: R, f: (R, T) -> R): R {
tailrec fun go(list: FunList<T>, init: R, f: (R, T) -> R): R = when (list) {
is Cons -> go(list.tail, f(init, list.head), f)
is Nil -> init
}
return go(this, init, f)
}
}
你注意到这些函数实现起来非常简单吗?让我们看看下面的代码:
fun main(args: Array<String>) {
val numbers = intListOf(1, 2, 3, 4)
val sum = numbers.fold(0) { acc, i -> acc + i}
}
那么,Kotlin 的列表和我们的函数式列表之间来一场小比赛如何?
fun main(args: Array<String>) {
val funList = intListOf(1, 2, 3, 4)
val list = listOf(1, 2, 3, 4)
println("fold on funList : ${executionTime { funList.fold(0) { acc, i -> acc + i } }}")
println("fold on list : ${executionTime { list.fold(0) { acc, i -> acc + i } }}")
}
输出将类似于以下截图:
哎呀!我们的实现速度慢了 10 倍。别担心,Kotlin 的实现是一个高度优化的命令式解决方案,而我们的只是为了学习和娱乐(当然,是字面意义上的娱乐)。
那么 map
呢?要在函数式编程中实现 map
,我们需要先实现其他函数。让我们从 reverse
开始。
reverse
是一个返回反转顺序列表的函数:
sealed class FunList<out T> {
/*previous code*/
fun reverse(): FunList<T> = fold(Nil as FunList<T>) { acc, i -> Cons(i, acc) }
}
我们可以在每次迭代中重用 fold
并构建一个新的 Cons
值,使用 acc
值作为 tail
。这是函数式编程的一个大优点——重用现有函数。
现在,我们可以实现 foldRight
:
sealed class FunList<out T> {
/*previous code*/
fun <R> foldRight(init: R, f: (R, T) -> R): R {
return this.reverse().fold(init, f)
}
}
再次强调,我们正在重用现有函数。现在是时候实现我们的 map
函数了。在这个阶段,我们重用现有函数并不令人惊讶:
sealed class FunList<out T> {
/*previous code*
fun <R> map(f:(T) -> R): FunList<R> {
return foldRight(Nil as FunList<R>){ tail, head -> Cons(f(head), tail) }
}
}
foldRight
是我们所需要的全部。正如你所见,我们可以使用函数和其他基本概念作为构建块来实现一个完整的列表。这就是函数式编程的全部内容。
概述
在本章中,我们介绍了函数式编程的基础,包括高阶函数、纯函数、递归函数和惰性求值。我们还介绍了函数式集合,并使用函数式编程风格实现了一个函数式集合。
在下一章中,我们将介绍函数式编程的基础——不可变性。
第三章:不可变性 - 它很重要
因此,我们现在进入了《函数式 Kotlin》的第三章。在本章中,我们将讨论不可变性。不可变性可能是函数式编程最重要的方面;实际上,不仅在函数式编程中,面向对象编程也通过不可变对象为培养不可变性留出了一些空间。那么,为什么它如此重要?这意味着什么?我们如何在 Kotlin 中实现不可变性?让我们在本章中回答这些问题。
以下是我们将在本章中讨论的要点:
-
什么是不可变性?
-
不可变性的优点
-
Kotlin 中如何实现不可变性?
-
变量中的不可变性
-
val
与var
-
val
和const val
——它们真的是不可变的吗? -
编译器优化
-
不可变集合
-
不可变性的缺点
什么是不可变性?
函数式编程由于其本质是线程安全的;不可变性在使其线程安全方面起着重要作用。如果你按照词典的定义,不可变性意味着某物是不可改变的。所以,按照词典,一个不可变变量是一个不能改变的变量。那么,这如何有助于线程安全?
以下示例展示了一个简单的类,没有额外的线程安全保护措施:
class MutableObject {
var mutableProperty:Int = 1
}
想象一下同时从多个线程调用这个类的情况。这里没有保证完整性,对吧?
现在,假设将 mutableProperty
变为不可变的;问题部分解决了,对吧?
然而,如果你认为不可变性就是创建一个类并使其所有变量为只读,那么这样的简化解释不仅错误,而且糟糕。实际上,不可变性不是关于禁止改变,而是关于处理改变。不是直接改变属性的底层值,而是创建一个新的属性,并应用更改后复制值。这适用于 Kotlin 和 Java(甚至 C)中的原始数据类型。例如,在以下示例中,当我们编写 var y = x.capitalize()
时,x
的值保持不变,而是将应用更改后的 x
值复制到 y
:
fun main(args: Array<String>) {
var x:String = "abc"
var y = x.capitalize()
println("x = $x, y = $y")
}
大多数原始类型都以相同的方式操作;这就是所谓的不可变性。现在,让我们看看如何在 Kotlin 中实现不可变性,然后我们将探讨其优点和缺点。
在 Kotlin 中实现不可变性
与 Clojure、Haskell、F# 等语言不同,Kotlin 不是一个纯函数式编程语言,其中不可变性是强制性的;相反,我们可以将 Kotlin 称为函数式编程和面向对象语言完美融合的典范。它包含了两个世界的重大好处。因此,与纯函数式编程语言强制不可变性不同,Kotlin 鼓励不可变性,并在可能的情况下自动优先考虑它。
换句话说,Kotlin 有不可变变量(val
),但没有保证状态真正深度不可变的语言机制。如果一个 val
变量引用了一个可变对象,其内容仍然可以被修改。我们将对此进行更详细的讨论和深入探讨,但首先让我们看看如何在 Kotlin 中实现引用不可变性和 var
、val
和 const val
之间的区别。
通过真正的状态深度不可变性,我们指的是一个属性在每次被调用时都会返回相同的值,并且该属性永远不会改变其值;如果我们有一个具有自定义获取器的 val
属性,我们就可以轻松避免这种情况。更多详细信息,请参阅以下链接:artemzin.com/blog/kotlin-val-does-not-mean-immutable-it-just-means-readonly-yeah/
var
和 val
的区别
因此,为了鼓励不可变性,同时仍然让开发者有选择权,Kotlin 引入了两种类型的变量。第一种是 var
,它只是一个简单的变量,就像在任何命令式语言中一样。另一方面,val
让我们更接近不可变性;再次强调,它并不保证不可变性。那么,val
变量究竟为我们提供了什么?它强制只读,初始化后你不能写入 val
变量。所以,如果你使用没有自定义获取器的 val
变量,你可以实现引用不可变性。
让我们看看;以下程序将无法编译:
fun main(args: Array<String>) {
val x:String = "Kotlin"
x+="Immutable"//(1)
}
如我之前提到的,前面的程序将无法编译;它将在注释 (1)
处产生错误。因为我们已将变量 x
声明为 val
,所以 x
将是只读的,一旦我们初始化 x
,之后就不能修改它。
所以,你现在可能想知道为什么我们不能用 val
保证不可变性?让我们通过以下示例来检查:
object MutableVal {
var count = 0
val myString:String = "Mutable"
get() {//(1)
return "$field ${++count}"//(2)
}
}
fun main(args: Array<String>) {
println("Calling 1st time ${MutableVal.myString}")
println("Calling 2nd time ${MutableVal.myString}")
println("Calling 3rd time ${MutableVal.myString}")//(3)
}
在这个程序中,我们将 myString
声明为一个 val
属性,但实现了一个自定义的 get
函数,我们在返回之前修改了 myString
的值。首先看看输出,然后我们将进一步查看程序:
如你所见,尽管 myString
属性是 val
,但它每次被访问时都返回不同的值。现在,让我们查看代码以了解这种行为。
在注释 (1)
中,我们为 val
属性 myString
声明了一个自定义获取器。在注释 (2)
中,我们预增加了 count
的值,并将其添加到 field
值,即 myString
的值之后,并从获取器返回相同的值。所以,每次我们请求 myString
属性时,count
都会增加,在下一次请求时,我们得到不同的值。因此,我们破坏了 val
属性的不可变行为。
编译时常量
那么,我们如何克服这个问题?如何强制不可变性?const val
属性就是为了帮助我们而存在的。只需将val myString
修改为const val myString
,您就不能实现自定义获取器。
虽然val
属性是只读变量,但另一方面,const val
是编译时常量。您不能将函数的结果(结果)分配给const val
。让我们讨论一下val
和const val
之间的一些区别:
-
val
属性是只读变量,而const val
是编译时常量 -
val
属性可以有自定义获取器,但const val
不能 -
我们可以在 Kotlin 代码的任何地方拥有
val
属性,在函数内部,作为类成员,任何地方,但const val
必须是一个类/对象的顶层成员 -
您不能为
const val
属性编写委托 -
我们可以拥有任何类型的
val
属性,无论是我们的自定义类还是任何原始数据类型,但只有原始数据类型和String
可以使用const val
属性 -
我们不能在
const val
属性中使用可空数据类型;因此,const val
属性也不能有 null 值
因此,const val
属性保证了值的不可变性,但灵活性较低,并且您必须仅使用原始数据类型与const val
一起使用,这并不总是能满足我们的需求。
现在,我已经多次使用了“引用不可变性”这个词,让我们现在检查它的含义以及有多少种不可变性类型。
不可变性的类型
基本上有以下两种不可变性类型:
-
引用不可变性
-
不可变值
不可变引用(引用不可变性)
引用不可变性强制规定,一旦分配了引用,就不能将其分配给其他对象。想想看,就像它是一个自定义类的val
属性,甚至是MutableList
或MutableMap
;初始化属性后,您不能从该属性引用其他对象,除非是对象的基本值。例如,考虑以下程序:
class MutableObj {
var value = ""
override fun toString(): String {
return "MutableObj(value='$value')"
}
}
fun main(args: Array<String>) {
val mutableObj:MutableObj = MutableObj()//(1)
println("MutableObj $mutableObj")
mutableObj.value = "Changed"//(2)
println("MutableObj $mutableObj")
val list = mutableListOf("a","b","c","d","e")//(3)
println(list)
list.add("f")//(4)
println(list)
}
在我们解释程序之前,先看看输出结果:
因此,在这个程序中,我们有两个val
属性——list
和mutableObj
。我们使用MutableObj
的默认构造函数初始化mutableObj
,因为它是一个val
属性,它将始终引用那个特定的对象;但是,如果您关注注释(2),我们已经改变了mutableObj
的value
属性,因为MutableObj
类的value
属性是可变的(var
)。
与 list
属性类似,我们可以在初始化后向列表中添加项目,改变其底层值。list
和 mutableObj
都是不可变引用的完美例子;一旦初始化,属性就不能被分配给其他东西,但它们的底层值可以改变(你可以参考输出)。背后的原因是分配给这些属性的 数据类型。MutableObj
类和 MutableList<String>
数据结构本身都是可变的,因此我们无法限制其实例的值变化。
不可变值
另一方面,不可变值同样不允许对值进行更改;维护起来非常复杂。在 Kotlin 中,const val
属性强制值的不可变性,但它们缺乏灵活性(我们已讨论过它们)并且你只能使用原始类型,这在实际场景中可能会带来麻烦。
不可变集合
Kotlin 在可能的情况下会优先考虑不可变性,但将选择权留给开发者,决定是否以及何时使用它。这种选择权使语言更加强大。与大多数语言不同,它们要么只有可变集合(如 Java、C# 等),要么只有不可变集合(如 F#、Haskell、Clojure 等),Kotlin 两者都有,并且区分它们,让开发者有选择使用不可变或可变集合的自由。
Kotlin 为集合对象提供了两个接口——Collection<out E>
和 MutableCollection<out E>
;所有集合类(例如 List
、Set
或 Map
)都实现了这两个接口之一。正如其名所示,这两个接口是为不可变和可变集合分别设计的。让我们举一个例子:
fun main(args: Array<String>) {
val immutableList = listOf(1,2,3,4,5,6,7)//(1)
println("Immutable List $immutableList")
val mutableList:MutableList<Int> = immutableList.toMutableList()//(2)
println("Mutable List $mutableList")
mutableList.add(8)//(3)
println("Mutable List after add $mutableList")
println("Mutable List after add $immutableList")
}
输出如下:
因此,在这个程序中,我们通过 Kotlin 的 listOf
方法创建了一个不可变列表,注释(1)中提到。listOf
方法使用传递给它的元素(可变参数)创建一个不可变列表。此方法还有一个泛型类型参数,如果元素数组不为空,则可以省略。listOf
方法还有一个可变版本——mutableListOf()
,除了返回 MutableList
而外,其余都相同。我们可以使用 toMutableList()
扩展函数将不可变列表转换为可变列表,注释(2)中我们就是这样做的,以便在注释(3)中向其添加一个元素。然而,如果你检查输出,原始的 Immutable List
没有任何变化,而项目已经被添加到了新创建的 MutableList
中。
不可变性的优势
我们已经多次提到,不可变性带来了安全性,但这并非全部;以下是一个简短的列表,列出了不可变性带来的优势,我们将逐一讨论:
-
线程安全
-
低耦合
-
引用透明性
-
失败原子性
-
编译器优化
-
纯函数
让我们逐一讨论这些优点,以便更好地理解它们。
线程安全
我们可能已经看到过一千次不可变性如何带来线程安全。这实际上意味着什么?不可变性是如何实现线程安全的?与多个线程一起工作本身就是一项复杂的工作。当你从多个线程访问一个类时,你需要确保某些事情,比如锁定和释放对象以及同步,但如果你从多个线程访问任何不可变数据,则不需要这些。
感到困惑?让我们用一个关于线程和可变数据的例子来说明:
class MyData {
var someData:Int = 0
}
fun main(args: Array<String>) {
val myData:MyData = MyData()
async(CommonPool) {
for(i in 11..20) {
myData.someData+=i
println("someData from 1st async ${myData.someData}")
delay(500)
}
}
async(CommonPool) {
for(i in 1..10) {
myData.someData++
println("someData from 2nd async ${myData.someData}")
delay(300)
}
}
runBlocking { delay(10000) }
}
在这个程序中,我们使用了两个协程(我们将在第七章中详细讨论协程,使用协程的异步处理),它们在相同的数据上工作。让我们看看以下输出,然后我们将描述和讨论这个程序中的问题:
因此,仔细观察输出。由于两个协程同时在工作myData.someData
上,数据一致性在任何一个中都没有得到保证。
解决这个问题的传统方法是使用锁定-释放技术和同步,但这样你也需要为它编写大量的代码,并且为了避免在实现锁定和释放数据时出现死锁。
函数式编程通过不可变性提供了一个一站式解决方案来解决这个问题。让我们看看不可变性和局部变量如何在多线程中拯救你:
class MyDataImmutable {
val someData:Int = 0
}
fun main(args: Array<String>) {
val myData: MyDataImmutable = MyDataImmutable()
async(CommonPool) {
var someDataCopy = myData.someData
for (i in 11..20) {
someDataCopy += i
println("someData from 1st async $someDataCopy")
delay(500)
}
}
async(CommonPool) {
var someDataCopy = myData.someData
for (i in 1..10) {
someDataCopy++
println("someData from 2nd async $someDataCopy")
delay(300)
}
}
runBlocking { delay(10000) }
}
我们修改了之前的程序,将someData
设置为不可变(因为我们不使用这个变量的自定义获取器,所以它将保持不可变)并在两个协程内部使用了局部变量。
看看以下输出;它清楚地表明问题已解决:
低耦合
线程之间的代码依赖性被称为耦合。我们应该尽量降低耦合度,以避免复杂性并使代码库易于阅读和维护。现在,这实际上意味着什么?请参考我们访问和修改someData
值的程序,该程序使用了线程。这可以被称为耦合,因为两个线程都相互依赖。为了您的参考,我们复制了以下代码片段:
async(CommonPool) {
for(i in 11..20) {
myData.someData+=i
println("someData from 1st async ${myData.someData}")
delay(500)
}
}
async(CommonPool) {
for(i in 1..10) {
myData.someData++
println("someData from 2nd async ${myData.someData}")
delay(300)
}
}
在下一个程序中,我们引入了不可变性,耦合度降低了。在这里,两个线程都在读取相同的元素,但一个线程的操作和更改没有影响到另一个线程。
引用透明性
引用透明性的概念表明,一个表达式总是评估为相同的值,无论上下文或任何其他变化。更具体地说,你可以用一个函数的返回值来替换该函数。
通过纯函数的帮助,不可变性可以建立引用透明性。引用透明性强烈否认数据的可变状态。
失败原子性
在传统编程中,一个线程的失败很容易影响另一个线程。由于不可变性强制低耦合,即使我们在任何模块/线程上有异常,应用程序的内部状态也将保持一致。
原因很简单,不可变对象永远不会改变状态。因此,即使某个部分/模块/线程发生故障,它就会停止,并且没有机会传播到应用程序的其他部分。
缓存
由于不可变对象不会改变,它们可以很容易地缓存以提高性能。因此,你可以轻松避免对同一函数/变量进行多次调用,而是将其本地缓存,从而节省大量处理时间。以下是一些缓存的优势:
-
它减少了服务器资源的开销
-
通过提供缓存的输出,它提高了应用程序的性能
-
通过在内存中持久化数据,它减少了从数据库获取数据的 CPU 循环次数
-
它增加了可靠性
编译器优化
不可变性和引用透明性有助于编译器执行广泛的优化,取代了手动优化代码的需要,并使程序员从这种权衡中解脱出来。
例如,当你使用编译时常量(const val
)时,这适用,因为编译器知道这些变量的值永远不会改变。
纯函数
通过使用不可变性,我们可能得到的最大礼物是纯函数(下一章将介绍)。基本上,纯函数和不可变性不仅是伴侣,而且是互补的。
没有不可变性,我们无法实现纯函数,而没有纯函数,不可变性也不完整。
因此,既然我们已经了解了不可变性和其优点,那么现在让我们关注另一方面:不可变性的缺点,并检查它们是否真的是缺点。
不可变性的缺点
世界上没有只带来好处的东西。
有一个伟大的谚语:一切皆有其价。
你能听到的唯一反对不可变性的说法是,每次你想修改它时都需要创建一个新的对象。在某些情况下这是真的,尤其是在你处理大量对象时。然而,当你处理小数据集或对象时,它没有任何影响。
摘要
在本章中,我们学习了不可变性和如何使用 Kotlin 实现不可变性。我们了解到 Kotlin 为我们提供了根据需求选择不可变对象或可变对象的自由。我们不仅讨论了不可变性的优点,还讨论了其局限性。
下一章将重点介绍函数、函数类型和副作用。我们还将学习纯函数,它们不仅是不可变性的伴侣,而且是不可变性的补充部分,将在下一章中介绍。
那你还在等什么?现在就翻页吧。
第四章:函数、函数类型和副作用
函数式编程围绕不可变性和函数的概念展开。我们在上一章学习了不可变性;在讨论不可变性时,我们也对纯函数有了一定的了解。纯函数基本上是函数式编程提供的许多类型(但可能是最重要的一种)之一。
本章将围绕函数展开。要深入了解函数式编程,你需要对函数有坚实的基础。为了使你的概念清晰,我们将从普通的 Kotlin 函数开始,然后逐步讨论函数式编程定义的抽象函数概念。我们还将看到它们在 Kotlin 中的实现。
在本章中,我们将涵盖以下主题:
-
Kotlin 中的函数
-
函数类型
-
Lambda
-
高阶函数
-
理解副作用和纯函数
因此,让我们从定义函数开始。
Kotlin 中的函数
函数是编程中最重要的一部分。我们每周都要为我们的项目编写大量的函数。函数也是编程基础的一部分。为了学习函数式编程,我们必须对函数的概念有清晰的认识。在本节中,我们将介绍函数的基础知识,以便让你为下一节做好准备,在下一节中,我们将讨论抽象的函数概念及其在 Kotlin 中的实现。
因此,让我们先定义函数。
函数是一块组织良好、可重用的代码块,用于执行单个、相关的操作。
不太清楚?我们将解释,但首先,让我们了解为什么我们应该编写函数。简而言之,函数的功能是什么?看看:
-
函数允许我们将程序分解成一系列步骤和子步骤
-
函数鼓励代码重用
-
如果正确使用,函数可以帮助我们保持代码的整洁、有序和易于理解
-
函数使测试(单元测试)变得容易,测试程序的小部分比一次性测试整个程序要容易
在 Kotlin 中,函数通常看起来像以下这样:
fun appropriateFunctionName(parameter1:DataType1, parameter2:DataType2,...): ReturnType {
//do your stuff here
return returnTypeObject
}
在 Kotlin 中,函数声明以 fun
关键字开头,后跟函数名,然后是括号。在括号内,我们可以指定函数参数(可选)。括号之后,会有一个冒号(:
)和返回类型,它指定了要返回的值/对象的类型(如果你不打算从函数中返回任何内容,可以省略返回类型;在这种情况下,函数将被分配默认返回类型 Unit
)。之后,会有函数体,用花括号括起来(对于单表达式函数,花括号是可选的,将在第五章中介绍,更多关于函数的内容)。
Unit
是 Kotlin 中的一个数据类型。Unit
是其自身的单例实例,并持有 Unit
自身的值。Unit
对应于 Java 中的 void
,但它与 void
非常不同。虽然 void
在 Java 中意味着没有东西,且 void
不能包含任何东西,但在 Kotlin 中我们有 Nothing
用于此目的,它表示函数永远不会成功完成(由于异常或无限循环)。
现在,那些返回类型、参数(参数)和函数体是什么?让我们来探索它们。
以下是一个比之前展示的抽象函数更现实的函数示例:
fun add(a:int, b:Int):Int {
val result = a+b
return result
}
现在,让我们看看以下关于函数各部分的解释:
-
函数参数/参数:这些是函数要处理的数据(除非是 lambda)。在我们的例子中,
a
和b
是函数参数。 -
函数体:我们写在函数花括号内的所有内容都称为 函数体。它是函数的一部分,我们在其中编写逻辑或指令集以完成特定任务。在前面的例子中,花括号内的两行是函数体。
-
返回语句,数据类型:如果我们愿意从函数中返回某个值,我们必须声明我们愿意返回的值的类型;这个类型被称为
return
类型——在这种情况下,Int
是return
类型,return result
是返回语句,它使您能够向调用函数返回一个值。
我们可以通过删除 val result = a+b
并用 return a+b
替换返回语句来使前面的例子更短。在 Kotlin 中,我们可以进一步缩短这个例子,正如我们将在第五章 更多关于函数 中看到的。
虽然编写函数很容易,但 Kotlin 使之更加容易。
Kotlin 将各种功能捆绑到函数中,使开发者的生活更加轻松。以下是 Kotlin 捆绑的特性的简要列表:
-
单表达式函数
-
扩展函数
-
内联函数
-
中缀表示法及其他
我们将在第五章 更多关于函数 的 Lambda,泛型,递归,核心递归 部分详细介绍它们。
从函数中返回两个值
虽然,通常函数只能返回单个值,但在 Kotlin 中,通过利用 Pair
类型的优势和结构化声明的优势,我们可以从函数中返回两个变量。考虑以下示例:
fun getUser():Pair<Int,String> {//(1)
return Pair(1,"Rivu")
}
fun main(args: Array<String>) {
val (userID,userName) = getUser()//(2)
println("User ID: $userID t User Name: $userName")
}
在前面的程序中,在注释 (1)
处,我们创建了一个将返回 Pair<Int,String>
值的函数。
在注释(2)
中,我们以似乎返回两个变量的方式使用了该函数。实际上,解构声明允许你解构一个data class
/Pair
并获取其底层值作为独立的变量。当这个特性与函数一起使用时,它似乎函数返回多个值,尽管它只返回一个值,这个值是一个Pair
值或另一个data class
。
扩展函数
Kotlin 为我们提供了扩展函数。它们是什么?它们是在现有数据类型/类之上的一种临时函数。
例如,如果我们想计算字符串中的单词数,以下是一个传统的函数来完成这个任务:
fun countWords(text:String):Int {
return text.trim()
.split(Pattern.compile("\s+"))
.size
}
我们会将一个String
传递给一个函数,让我们的逻辑计算单词数,然后我们会返回这个值。
但你不觉得如果有一种方法可以直接在String
实例上调用这个函数会更好吗?Kotlin 允许我们执行这样的操作。
看看以下程序:
fun String.countWords():Int {
return trim()
.split(Pattern.compile("\s+"))
.size
}
仔细看看函数声明。我们声明了函数为String.countWords()
,而不是像之前那样只是countWords
;这意味着现在应该在一个String
实例上调用它,就像String
类的成员函数一样。就像以下代码:
fun main(args: Array<String>) {
val counts = "This is an example StringnWith multiple words".countWords()
println("Count Words: $counts")
}
你可以查看以下输出:
默认参数
我们可能有一个要求,即希望函数有一个可选参数。考虑以下示例:
fun Int.isGreaterThan(anotherNumber:Int):Boolean {
return this>anotherNumber
}
我们希望anotherNumber
参数是可选的;如果我们没有将其作为参数传递,我们希望它是0
。传统的方法是有一个没有参数的重载函数,它会用0
调用这个函数,如下所示:
fun Int.isGreaterThan(anotherNumber:Int):Boolean {
return this>anotherNumber
}
fun Int.isGreaterThan():Boolean {
return this.isGreaterThan(0)
}
然而,在 Kotlin 中,事情相当简单直接,并且它们不需要我们再次定义函数来仅使参数可选。为了使参数可选,Kotlin 为我们提供了默认参数,通过它我们可以在声明函数时立即指定默认值。
以下是被修改后的函数:
fun Int.isGreaterThan(anotherNumber:Int=0):Boolean {
return this>anotherNumber
}
我们会使用main
函数如下:
fun main(args: Array<String>) {
println("5>0: ${5.isGreaterThan()}")
println("5>6: ${5.isGreaterThan(6)}")
}
对于第一个,我们跳过了参数,对于第二个,我们提供了6
。所以,对于第一个,输出应该是 true(因为5
确实大于0
),而对于第二个,它应该是 false(因为5
不大于6
)。
以下截图输出确认了相同的结果:
嵌套函数
Kotlin 允许你在函数内部嵌套函数,我们可以在另一个函数内部声明和使用函数。
当你在另一个函数内部声明一个函数时,嵌套函数的可见性将仅限于父函数,并且不能从外部访问。
因此,让我们举一个例子:
fun main(args: Array<String>) {
fun nested():String {
return "String from nested function"
}
println("Nested Output: ${nested()}")
}
在前面的程序中,我们在main
函数内部声明并使用了一个函数——nested()
。
以下是你好奇的输出:
因此,我们在函数的基本知识上已经做好准备,让我们继续学习函数式编程。在下一节中,我们将学习函数类型。
函数式编程中的函数类型
函数式编程的主要目标之一是实现模块化编程。副作用(一个将在本章后面定义的功能性术语)通常是 bug 的来源;函数式编程希望你能完全避免副作用。
为了实现这一点,函数式编程定义了以下类型的函数:
-
Lambda 函数作为属性
-
高阶函数
-
纯函数
-
部分函数
在本节中,我们将按顺序讨论这些概念,以便对函数式编程范式有一个牢固的把握。
那么,让我们从 lambda 开始吧。
Lambda
Lambda,也可以称为匿名函数,在 Kotlin 中具有一等公民的支持。而 Java 中,lambda 的支持是从 Java 8 开始的,在 Kotlin 中,你可以从 JVM 6 开始使用 Kotlin,因此在 Kotlin 中实际上没有 lambda 的障碍。
现在,我们正在谈论 lambda、匿名类(或对象)和匿名函数,但它们究竟是什么呢?让我们来探索一下。
为了通用,lambda 或 lambda 表达式通常指的是匿名函数,即没有名称的函数,可以被赋值给变量、作为参数传递或从另一个函数返回。它是一种嵌套函数,但更灵活、更灵活。你还可以说所有的 lambda 表达式都是函数,但并非所有函数都是 lambda 表达式。匿名和无名称给 lambda 表达式带来了很多好处,我们将在下面讨论。
如我之前提到的,并非所有语言都支持 lambda,Kotlin 是其中最罕见的一种语言,它为 lambda 提供了广泛的支持。
那么,为什么叫 lambda 呢?现在让我们挖掘一点历史吧。
Lambda,Λ,λ(大写Λ,小写λ)是希腊字母的第 11 个字母。发音:lám(b)da。
来源:en.wikipedia.org/wiki/Lambda
在 20 世纪 30 年代,当时在普林斯顿大学学习数学的 Alonzo Church,使用希腊字母,特别是 lambda,来表示他所称的函数。需要注意的是,当时计算机中只有匿名函数;现代命名函数的概念尚未出现。
因此,随着 Alonzo Church 的这种实践,lambda 这个词就与匿名函数(那时唯一的函数类型)联系在了一起,时至今日,它仍然以同样的方式被引用。
阿隆佐·丘奇(1903 年 6 月 14 日-1995 年 8 月 11 日),是一位美国数学家和逻辑学家,他对数学逻辑和理论计算机科学的基础做出了重大贡献。他最著名的是λ演算、丘奇-图灵猜想、证明决定问题的不可判定性、弗雷格-丘奇本体论和丘奇-罗素定理。他还从事语言哲学的研究(例如,丘奇,1970 年)。
来源:en.wikipedia.org/wiki/Alonzo_Church
你不觉得我们已经对理论足够了吗?我们不应该现在专注于学习 lambda 实际上是什么,或者它究竟看起来像什么吗?我们将查看 Kotlin 中 lambda 的样子,但我们更愿意先向您介绍 Java 中的 lambda,然后在 Kotlin 中介绍,以便您完全理解 lambda 在 Kotlin 中拥有多大的力量,以及“一等公民”支持的确切含义。您还将了解 Java 和 Kotlin 中 lambda 的区别。
考虑以下 Java 示例。这是一个简单的例子,其中我们将接口的一个实例传递给一个方法,并在该方法中调用该实例的方法:
public class LambdaIntroClass {
interface SomeInterface {
void doSomeStuff();
}
private static void invokeSomeStuff(SomeInterface someInterface) {
someInterface.doSomeStuff();
}
public static void main(String[] args) {
invokeSomeStuff(new SomeInterface() {
@Override
public void doSomeStuff() {
System.out.println("doSomeStuff invoked");
}
});
}
}
因此,在这个程序中,SomeInterface
是一个接口(LambdaIntroClass
的内部接口),只有一个方法——doSomeStuff()
。静态方法invokeSomeStuff
(为了使它可以通过main
方法轻松访问而设置为静态)接受SomeInterface
的一个实例,并调用其doSomeStuff()
方法。
这只是一个简单的例子;现在,让我们让它更简单:让我们给它添加 lambda 表达式。看看以下更新的代码:
public class LambdaIntroClass {
interface SomeInterface {
void doSomeStuff();
}
private static void invokeSomeStuff(SomeInterface someInterface) {
someInterface.doSomeStuff();
}
public static void main(String[] args) {
invokeSomeStuff(()->{
System.out.println("doSomeStuff called");
});
}
}
因此,在这里,SomeInterface
和invokeSomeStuff()
的定义保持不变。唯一的不同之处在于传递SomeInterface
的实例。我们不是用一个新的SomeInstance
创建SomeInstance
的实例,而是写了一个表达式(粗体),这个表达式看起来非常像数学函数表达式(除了显然的System.out.println()
)。这个表达式被称为lambda 表达式。
难道这不是很棒吗?你不需要创建接口的实例,然后重写方法等所有这些操作。你所做的是一个非常简单的表达式。这个表达式将被用作接口内部doSomeStuff()
方法的主体。
两个程序输出的结果相同;如下截图所示:
Java 没有 lambda 的类型;你只能使用 lambda 在运行时创建类和接口的实例。Java 中 lambda 的唯一好处是它使 Java 程序更容易阅读(对人类来说),并减少了行数。
我们实际上不能责怪 Java。毕竟,Java 基本上是一种纯面向对象的语言。另一方面,Kotlin 是面向对象和函数式编程范式的完美结合;它将这两个世界更紧密地联系在一起。用我们的话说,如果你想在了解面向对象编程的基础上开始函数式编程,Kotlin 是最好的语言。
所以,不再有讲座了,让我们继续看代码。现在让我们看看同样的程序在 Kotlin 中的样子:
fun invokeSomeStuff(doSomeStuff:()->Unit) {
doSomeStuff()
}
fun main(args: Array<String>) {
invokeSomeStuff({
println("doSomeStuff called");
})
}
是的,这就是完整的程序(好吧,除了 import
语句和包名)。我知道你有点困惑;你在问这真的是同一个程序吗?接口定义在哪里呢?嗯,在 Kotlin 中实际上并不需要。
invokeSomeStuff()
函数实际上是一个高阶函数(将在下一节中介绍);我们向那里传递我们的 lambda,它直接调用该函数。
太棒了,不是吗?Kotlin 有很多与 lambda 相关的特性。让我们看看它们。
函数作为属性
Kotlin 还允许我们将函数作为属性。函数作为属性意味着函数可以用作属性。
例如,看看以下示例:
fun main(args: Array<String>) {
val sum = { x: Int, y: Int -> x + y }
println("Sum ${sum(10,13)}")
println("Sum ${sum(50,68)}")
}
在前面的程序中,我们创建了一个属性 sum
,它实际上将持有一个用于添加传递给它的两个数字的函数。
虽然 sum
是一个 val
属性,但它持有的是一个函数(或 lambda),我们可以像调用常规函数一样调用这个函数;这里没有任何区别。
如果你好奇,以下就是输出:
现在,让我们讨论 lambda 的语法。
在 Kotlin 中,lambda 总是包含在大括号内。这使得 lambda 很容易识别,与 Java 不同,在 Java 中参数/参数位于大括号之外。在 Kotlin 中,参数/参数位于大括号内,由 (->
) 与函数的逻辑分开。lambda 中的最后一个语句(可能只是一个变量/属性名或另一个函数调用)被视为返回语句。所以,lambda 最后一个语句的评估结果就是 lambda 的返回值。
此外,如果你的函数是单参数函数,你也可以省略属性名。那么,如果你不指定名称,你如何使用那个参数呢?Kotlin 为你提供了一个默认的 it
属性,用于单参数 lambda,其中你未指定属性名。
因此,让我们修改之前的 lambda 来添加它。看看下面的代码:
reverse = {
var n = it
var revNumber = 0
while (n>0) {
val digit = n%10
revNumber=revNumber*10+digit
n/=10
}
revNumber
}
我们跳过了完整的程序和输出,因为它们保持不变。
你一定注意到了,我们将函数参数的值赋给了另一个 var
属性(无论是当参数被命名还是用 it
表示时)。原因是,在 Kotlin 中,函数参数是不可变的,但与反向数字程序相比,我们需要一种改变值的方法;因此,我们将值赋给一个可变的 var
属性。
现在,你有了 lambda 作为属性,但它们的数据类型是什么呢?每个属性/变量都有一个数据类型(即使类型是推断的),那么 lambda 呢?让我们看看以下示例:
fun main(args: Array<String>) {
val reverse:(Int)->Int//(1)
reverse = {number ->
var n = number
var revNumber = 0
while (n>0) {
val digit = n%10
revNumber=revNumber*10+digit
n/=10
}
revNumber
}// (2)
println("reverse 123 ${reverse(123)}")
println("reverse 456 ${reverse(456)}")
println("reverse 789 ${reverse(789)}")
}
在前面的程序中,我们声明了一个reverse
属性作为函数。在 Kotlin 中,当你将属性声明为函数时,你应该在括号内提及参数/参数的数据类型,然后是一个箭头,然后是函数的返回类型;如果函数不打算返回任何内容,你应该提及Unit
。在将函数声明为属性时,你不需要指定参数/参数的名称,而在定义/分配函数到属性时,你可以省略提供属性的数据类型。
以下是输出结果:
因此,我们在 Kotlin 中对 lambda 和函数作为属性有了一个很好的理解。现在,让我们继续探讨高阶函数。
高阶函数
高阶函数是一个接受另一个函数作为参数或返回另一个函数的函数。我们刚刚看到我们可以如何将函数用作属性,所以很容易看出我们可以接受另一个函数作为参数,或者我们可以从函数中返回另一个函数。如前所述,技术上接收或返回另一个函数(可能不止一个)或两者兼而有之的函数被称为高阶****函数。
在 Kotlin 的第一个 lambda 示例中,invokeSomeStuff
函数是一个高阶函数。
以下是一个高阶函数的另一个示例:
fun performOperationOnEven(number:Int,operation:(Int)->Int):Int {
if(number%2==0) {
return operation(number)
} else {
return number
}
}
fun main(args: Array<String>) {
println("Called with 4,(it*2): ${performOperationOnEven(4,
{it*2})}")
println("Called with 5,(it*2): ${performOperationOnEven(5,
{it*2})}")
}
在前面的程序中,我们创建了一个高阶函数——performOperationOnEven
,它将接受一个Int
和一个对那个Int
执行的操作的 lambda 表达式。唯一的限制是,如果该Int
是偶数,该函数才会执行该操作。
这不是足够简单吗?让我们看看以下输出:
在我们之前的所有示例中,我们看到了如何将一个函数(lambda)传递给另一个函数。然而,这并不是高阶函数的唯一特性。高阶函数还允许你从它返回一个函数。
那么,让我们来探索一下。看看以下示例:
fun getAnotherFunction(n:Int):(String)->Unit {
return {
println("n:$n it:$it")
}
}
fun main(args: Array<String>) {
getAnotherFunction(0)("abc")
getAnotherFunction(2)("def")
getAnotherFunction(3)("ghi")
}
在前面的程序中,我们创建了一个函数getAnotherFunction
,它将接受一个Int
参数,并返回一个接受一个String
值并返回Unit
的函数。该return
函数打印其参数(一个String
)及其父参数(一个Int
)。
看看以下输出:
在 Kotlin 中,技术上你可以有嵌套的高阶函数到任何深度。然而,这样做可能会造成更多的伤害而不是帮助,甚至可能破坏可读性。所以,你应该避免它们。
纯函数和副作用
因此,我们已经学习了 lambda 函数和高阶函数。它们是函数式编程中最有趣和最重要的主题之一。在本节中,我们将讨论副作用和纯函数。
因此,让我们首先定义副作用。然后我们将逐步转向纯函数。
副作用
在计算机程序中,当一个函数修改其自身作用域之外的任何对象/数据时,这被称为副作用。例如,我们经常编写修改全局或静态属性的函数,修改其参数之一,抛出异常,将数据写入显示或文件,甚至调用具有副作用的另一个函数。
例如,看看以下程序:
class Calc {
var a:Int=0
var b:Int=0
fun addNumbers(a:Int = this.a,b:Int = this.b):Int {
this.a = a
this.b = b
return a+b
}
}
fun main(args: Array<String>) {
val calc = Calc()
println("Result is ${calc.addNumbers(10,15)}")
}
前面的程序是一个简单的面向对象程序。然而,它包含副作用。addNumbers()
函数修改了 Calc
类的状态,这在函数式编程中是一种不良做法。
虽然我们无法避免一些函数的副作用,尤其是在我们访问 IO 和/或数据库等情况时,但应尽可能避免副作用。
纯函数
纯函数的定义表明,如果一个函数的返回值完全依赖于其参数/参数,那么这个函数可以被称为纯函数。所以,如果我们声明一个函数为 fun func1(x:Int):Int
,那么它的返回值将严格依赖于其参数 x
;比如说,如果你用 3 N 次调用 func1
,那么每次调用的返回值都将相同。
定义还说明,纯函数不应主动或被动地引起副作用,也就是说,它不应直接引起副作用,也不应调用任何引起副作用的函数。
纯函数可以是 lambda 函数或命名函数。
那么,为什么它们被称为纯函数呢?原因很简单。编程函数起源于数学函数。随着时间的推移,编程函数演变成了包含多个任务并执行与传递参数处理无直接关系的匿名操作。因此,那些仍然类似于数学函数的函数被称为纯函数。
那么,让我们修改我们之前的程序,使其成为一个纯函数:
fun addNumbers(a:Int = 0,b:Int = 0):Int {
return a+b
}
fun main(args: Array<String>) {
println()
}
非常简单,不是吗?我们跳过了输出,因为这个程序真的很简单。
概述
在本章中,我们学习了函数,如何使用它们以及它们的分类。我们还介绍了 lambda 函数和高阶函数。我们学习了纯函数和副作用。
下一章将带你深入了解函数。正如我已经提到的,你需要掌握函数才能正确学习函数式编程。那么,你在等什么呢?现在就翻到下一页。
第五章:更多关于函数的内容
在前面的章节中,我们介绍了 Kotlin 函数的许多特性。但现在我们将扩展这些特性,其中大多数是从其他语言借用的,但它们在 Kotlin 的整体目标和风格中有一个新的转折——类型安全和实用简洁。
一些特性,例如领域特定语言(DSLs),允许开发者扩展语言以适应在 Kotlin 最初设计时未考虑到的领域。
在本章结束时,你将有一个关于所有函数特性的整体概念,包括:
-
扩展函数
-
操作符重载
-
类型安全的构建器
-
内联函数
-
递归和核心递归
单表达式函数
到目前为止,所有我们的例子都是以常规方式声明的。
sum
函数接受两个Int
类型的值并将它们相加。以常规方式声明,我们必须提供一个带有花括号和显式return
的主体:
fun sum(a:Int, b:Int): Int {
return a + b
}
我们的sum
函数其主体在花括号内声明,并包含一个return
子句。但如果我们的函数只是一个表达式,它也可以写成一行:
fun sum(a:Int, b:Int): Int = a + b
所以,没有花括号,没有return
子句,并且有一个等于(=
)符号。如果你注意的话,它看起来与 lambda 非常相似。
如果你想要减少更多字符,你还可以使用类型推断:
fun sum(a:Int, b:Int) = a + b
当返回类型非常明显时,可以使用类型推断来指定函数的返回类型。一个很好的经验法则是对于简单类型,如数值、布尔值、字符串和简单的data class
构造函数使用它。任何更复杂的事情,特别是如果函数执行任何转换,都应该有显式的类型。你未来的自己会为此感到高兴!
参数
一个函数可以有零个或多个参数。我们的函数basicFunction
接受两个参数,如下面的代码所示:
fun basicFunction(name: String, size: Int) {
}
每个参数都定义为parameterName: ParameterType
,在我们的例子中,name: String
和size: Int
。这里没有什么新的。
vararg
当参数有两种我们已经讨论过的类型时,事情变得有趣——vararg
和 lambda:
fun aVarargFun(vararg names: String) {
names.forEach(::println)
}
fun main(args: Array<String>) {
aVarargFun()
aVarargFun("Angela", "Brenda", "Caroline")
}
一个带有vararg
修饰符的参数的函数可以用零个或多个值来调用:
fun multipleVarargs(vararg names: String, vararg sizes: Int) {
// Compilation error, "Multiple vararg-parameters are prohibited"
}
一个函数不能有多个vararg
参数,即使是不同类型的也不行。
Lambda
我们已经讨论了如果函数的最后一个参数是一个 lambda,它不能放在括号外和花括号内,就像 lambda 本身是控制结构的主体一样。
我们在第二章“开始函数式编程”的“一等和高级函数”部分介绍了这个unless
函数。让我们看看以下代码:
fun unless(condition: Boolean, block: () -> Unit) {
if (!condition) block()
}
unless(someBoolean) {
println("You can't access this website")
}
现在,如果我们结合vararg
和 lambda 会发生什么?让我们在下面的代码片段中检查一下:
fun <T, R> transform(vararg ts: T, f: (T) -> R): List<R> = ts.map(f)
Lambda 可以放在带有vararg
参数的函数的末尾:
transform(1, 2, 3, 4) { i -> i.toString() }
让我们稍微冒险一点,一个 lambda 的vararg
参数:
fun <T> emit(t: T, vararg listeners: (T) -> Unit) = listeners.forEach { listener ->
listener(t)
}
emit(1){i -> println(i)} //Compilation error. Passing value as a vararg is only allowed inside a parenthesized argument list
我们不能将 lambda 放在括号外,但我们可以将多个 lambda 放在括号内:
emit(1, ::println, {i -> println(i * 2)})
命名参数
理想情况下,我们的函数不应该有太多的参数,但这并不总是如此。一些函数倾向于很大,例如,data class
构造函数(构造函数在技术上是一个返回新实例的函数)。
参数众多的函数有什么问题?
-
它们很难使用。这可以通过下一节中将要介绍的默认参数来缓解或修复。
-
它们很难阅读——命名参数来拯救。
-
它们可能做得太多。你确定你的函数不是太大了吗?尝试重构它并清理。寻找可能的副作用和其他有害做法。一个特殊情况是
data class
构造函数,因为它们只是自动生成的赋值。
使用命名参数,你可以增加任何函数调用的可读性。
让我们以 data class
构造函数为例:
typealias Kg = Double
typealias cm = Int
data class Customer(val firstName: String,
val middleName: String,
val lastName: String,
val passportNumber: String,
val weight: Kg,
val height: cm)
正常调用将看起来像这样:
val customer1 = Customer("John", "Carl", "Doe", "XX234", 82.3, 180)
但包括命名参数将增加读者/维护者可获得的信息量,并减少心理工作。我们也可以以更方便或更有意义的顺序传递参数:
val customer2 = Customer(
lastName = "Doe",
firstName = "John",
middleName = "Carl",
height = 180,
weight = 82.3,
passportNumber = "XX234")
当与 vararg
参数结合使用时,命名参数非常有用:
fun paramAfterVararg(courseId: Int, vararg students: String, roomTemperature: Double) {
//Do something here
}
paramAfterVararg(68, "Abel", "Barbara", "Carl", "Diane", roomTemperature = 18.0)
高阶函数上的命名参数
通常当我们定义高阶函数时,我们从不为 lambda(s) 命名参数:
fun high(f: (Int, String) -> Unit) {
f(1, "Romeo")
}
high { q, w ->
//Do something
}
但可以添加它们。因此,f
lambda 现在有了命名的参数——age
和 name
:
fun high(f: (age:Int, name:String) -> Unit) {
f(1, "Romeo")
}
这不会改变任何行为,只是为了更清晰地说明这个 lambda 的预期用途:
fun high(f: (age:Int, name:String) -> Unit) {
f(age = 3, name = "Luciana") //compilation error
}
但使用命名参数调用 lambda 是不可能的。在我们的例子中,使用名称调用 f
会导致编译错误。
默认参数
在 Kotlin 中,函数参数可以有默认值。对于 Programmer
,favouriteLanguage
和 yearsOfExperience
数据类有默认值(记住,构造函数也是一个函数):
data class Programmer(val firstName: String,
val lastName: String,
val favouriteLanguage: String = "Kotlin",
val yearsOfExperience: Int = 0)
因此,Programmer
可以只使用两个参数来创建:
val programmer1 = Programmer("John", "Doe")
但如果你想要传递 yearsOfExperience
,它必须作为一个命名参数:
val programmer2 = Programmer("John", "Doe", 12) //Error
val programmer2 = Programmer("John", "Doe", yearsOfExperience = 12) //OK
如果你想要传递所有参数,你仍然可以这样做,但如果你不使用命名参数,它们必须以正确的顺序提供:
val programmer3 = Programmer("John", "Doe", "TypeScript", 1)
扩展函数
毫无疑问,Kotlin 最好的特性之一是扩展函数。扩展函数允许你使用新函数修改现有类型:
fun String.sendToConsole() = println(this)
fun main(args: Array<String>) {
"Hello world! (from an extension function)".sendToConsole()
}
要向现有类型添加扩展函数,你必须将函数的名称写在类型名称旁边,并用点 (.
) 连接。
在我们的例子中,我们向 String
类型添加了一个扩展函数 (sendToConsole()
)。在函数体内部,this
指的是 String
类型的实例(在这个扩展函数中,string
是接收器类型)。
除了点(.
)和this
,扩展函数与普通函数具有相同的语法规则和功能。实际上,在幕后,扩展函数是一个普通函数,其第一个参数是接收者类型的值。因此,我们的sendToConsole()
扩展函数等同于以下代码:
fun sendToConsole(string: String) = println(string)
sendToConsole("Hello world! (from a normal function)")
因此,实际上我们并没有通过新函数修改类型。扩展函数是一种非常优雅地编写实用函数的方法,易于编写,使用起来非常有趣,阅读起来也很愉快——双赢。这也意味着扩展函数有一个限制——它们不能访问this
的私有成员,而一个合适的成员函数可以访问实例内的所有内容:
class Human(private val name: String)
fun Human.speak(): String = "${this.name} makes a noise" //Cannot access 'name': it is private in 'Human'
调用扩展函数与调用普通函数相同——使用接收者类型的实例(在扩展函数内部将引用为this
),通过名称调用函数。
扩展函数与继承
当我们谈论继承时,成员函数和扩展函数之间有很大的区别。
开放类Canine
有一个子类Dog
。一个独立的函数printSpeak
接收一个类型为Canine
的参数,并打印函数speak(): String
的结果内容:
open class Canine {
open fun speak() = "<generic canine noise>"
}
class Dog : Canine() {
override fun speak() = "woof!!"
}
fun printSpeak(canine: Canine) {
println(canine.speak())
}
我们已经在第一章,“Kotlin – 数据类型、对象和类”,在继承部分中讨论了这一点。具有open
方法的开放类(成员函数)可以被扩展并改变其行为。调用speak
函数的行为将根据实例的类型而有所不同。
printSpeak
函数可以用任何is-a
Canine
类的实例调用,无论是Canine
本身还是任何子类:
printSpeak(Canine())
printSpeak(Dog())
如果我们执行此代码,我们可以在控制台上看到以下内容:
虽然两者都是Canine
,但在两种情况下speak
的行为都不同,因为子类覆盖了父类实现。
但与扩展函数不同,许多事情都不同。
与前面的示例一样,Feline
是一个由Cat
类扩展的开放类。但现在speak
是一个扩展函数:
open class Feline
fun Feline.speak() = "<generic feline noise>"
class Cat : Feline()
fun Cat.speak() = "meow!!"
fun printSpeak(feline: Feline) {
println(feline.speak())
}
扩展函数不需要标记为override
,因为我们没有覆盖任何内容:
printSpeak(Feline())
printSpeak(Cat()
如果我们执行此代码,我们可以在控制台上看到以下内容:
在这种情况下,两次调用都产生相同的结果。虽然一开始看起来很混乱,但一旦分析清楚,就会变得清晰。我们调用了两次Feline.speak()
函数;这是因为我们传递给printSpeak(Feline)
函数的每个参数都是一个Feline
:
open class Primate(val name: String)
fun Primate.speak() = "$name: <generic primate noise>"
open class GiantApe(name: String) : Primate(name)
fun GiantApe.speak() = "${this.name} :<scary 100db roar>"
fun printSpeak(primate: Primate) {
println(primate.speak())
}
printSpeak(Primate("Koko"))
printSpeak(GiantApe("Kong"))
如果我们执行此代码,我们可以在控制台上看到以下内容:
在这种情况下,行为与前面的示例相同,但使用了正确的name
值。说到这里,我们可以用name
和this.name
来引用name
;两者都是有效的。
扩展函数作为成员
扩展函数可以声明为类的成员。声明了扩展函数的类的实例称为调度接收器。
Caregiver
公开类内部定义了针对两个不同类Feline
和Primate
的扩展函数:
open class Caregiver(val name: String) {
open fun Feline.react() = "PURRR!!!"
fun Primate.react() = "*$name plays with ${this@Caregiver.name}*"
fun takeCare(feline: Feline) {
println("Feline reacts: ${feline.react()}")
}
fun takeCare(primate: Primate){
println("Primate reacts: ${primate.react()}")
}
}
这两个扩展函数都打算在Caregiver
的实例内部使用。实际上,如果它们不是公开的,将成员扩展函数标记为私有是一个好习惯。
在Primate.react()
的情况下,我们使用了Primate
和Caregiver
中的name
值。要访问具有名称冲突的成员,扩展接收器(this
)具有优先级,要访问调度接收器的成员,必须使用限定this
语法。调度接收器的其他没有名称冲突的成员可以使用而不需要限定this
。
不要被我们已经覆盖的各种this
的用法所迷惑:
-
在类内部,
this
意味着该类的实例 -
在扩展函数内部,
this
意味着接收器类型的实例,就像我们工具函数中第一个参数的优雅语法一样:
class Dispatcher {
val dispatcher: Dispatcher = this
fun Int.extension(){
val receiver: Int = this
val dispatcher: Dispatcher = this@Dispatcher
}
}
回到我们的动物园示例,我们实例化了一个Caregiver
,一个Cat
和一个Primate
,并且我们使用这两个动物实例调用了Caregiver.takeCare
函数:
val adam = Caregiver("Adam")
val fulgencio = Cat()
val koko = Primate("Koko")
adam.takeCare(fulgencio)
adam.takeCare(koko)
如果我们执行此代码,我们可以在控制台上看到以下内容:
任何动物园都需要兽医。类Vet
扩展了Caregiver
:
open class Vet(name: String): Caregiver(name) {
override fun Feline.react() = "*runs away from $name*"
}
我们使用不同的实现覆盖了Feline.react()
函数。我们还直接使用了Vet
类的名称,因为Feline
类没有属性名:
val brenda = Vet("Brenda")
listOf(adam, brenda).forEach { caregiver ->
println("${caregiver.javaClass.simpleName} ${caregiver.name}")
caregiver.takeCare(fulgencio)
caregiver.takeCare(koko)
}
之后,我们得到以下输出:
具有冲突名称的扩展函数
当扩展函数与成员函数具有相同的名称时会发生什么?
Worker
类有一个work(): String
函数和一个私有函数rest(): String
。我们还有两个具有相同签名的扩展函数,work
和rest
:
class Worker {
fun work() = "*working hard*"
private fun rest() = "*resting*"
}
fun Worker.work() = "*not working so hard*"
fun <T> Worker.work(t:T) = "*working on $t*"
fun Worker.rest() = "*playing video games*"
具有相同签名的扩展函数不会导致编译错误,但会发出警告:“扩展函数被成员覆盖:public final fun work(): String”
声明一个与成员函数具有相同签名的函数是合法的,但成员函数始终具有优先级,因此扩展函数永远不会被调用。当成员函数是私有的时,这种行为会改变,在这种情况下,扩展函数具有优先级。
使用扩展函数也可以重载现有的成员函数:
val worker = Worker()
println(worker.work())
println(worker.work("refactoring"))
println(worker.rest())
在执行时,work()
调用成员函数,而work(String)
和rest()
是扩展函数:
对象的扩展函数
在 Kotlin 中,对象是一种类型,因此它们可以有函数,包括扩展函数(以及其他一些功能,如扩展接口等)。
我们可以向对象 Builder
添加一个 buildBridge
扩展函数:
object Builder {
}
fun Builder.buildBridge() = "A shinny new bridge"
我们可以包含伴随对象。类 Designer
有两个内部对象,companion
对象和 Desk
对象:
class Designer {
companion object {
}
object Desk {
}
}
fun Designer.Companion.fastPrototype() = "Prototype"
fun Designer.Desk.portofolio() = listOf("Project1", "Project2")
调用此函数的工作方式与任何正常对象成员函数一样:
Designer.fastPrototype()
Designer.Desk.portofolio().forEach(::println)
中缀函数
只有一个参数的函数(普通或扩展)可以标记为 中缀
并使用 中缀
表示法。对于某些领域,例如数学和代数运算,中缀
表示法有助于自然地表达代码。
让我们在 Int
类型上添加一个 中缀
扩展函数,superOperation
(这只是一个带有花哨名称的常规求和):
infix fun Int.superOperation(i: Int) = this + i
fun main(args: Array<String>) {
1 superOperation 2
1.superOperation(2)
}
我们可以使用 superOperation
函数以及 中缀
表示法或常规表示法。
另一个 中缀
表示法常用领域是断言库,例如 HamKrest (github.com/npryce/hamkrest
) 或 Kluent (github.com/MarkusAmshove/Kluent
)。用自然、易于理解的语言编写规范代码是一个巨大的优势。
Kluent 断言看起来像自然的英语表达:
"Kotlin" shouldStartWith "Ko"
Kluent 还提供了一个反引号版本,以增强可读性:
"Kotlin" `should start with` "Ko"
反引号(`
)允许你编写任意标识符,包括 Kotlin 中保留的单词。现在,你可以编写自己的表情包函数:
你可以将许多 中缀
函数链式调用以生成内部 DSL,或者重新创建经典梗:
object All {
infix fun your(base: Pair<Base, Us>) {}
}
object Base {
infix fun are(belong: Belong) = this
}
object Belong
object Us
fun main(args: Array<String>) {
All your (Base are Belong to Us)
}
your
函数接收 Pair<Base, Us>
作为参数(这是一种元组,它随 Kotlin 标准库提供并广泛使用)和 中缀
扩展函数 <K, V> K.to(v: V)
使用接收者作为第一个成员,参数作为第二个参数(to
可以用任何类型的组合调用)。
操作符重载
操作符重载 是一种多态形式。一些操作符在不同类型上会改变行为。经典的例子是操作符加 (+
)。在数值上,加是求和操作,在 String
上是连接。操作符重载是提供自然表面 API 的有用工具。假设我们正在编写 Time
和 Date
库;在时间单位上定义加法和减法操作符将是自然的。
Kotlin 允许你使用函数定义自己的或现有类型的操作行为,无论是普通函数还是扩展函数,只要使用 operator
修饰符标记即可:
class Wolf(val name:String) {
operator fun plus(wolf: Wolf) = Pack(mapOf(name to this, wolf.name to wolf))
}
class Pack(val members:Map<String, Wolf>)
fun main(args: Array<String>) {
val talbot = Wolf("Talbot")
val northPack: Pack = talbot + Wolf("Big Bertha") // talbot.plus(Wolf("..."))
}
操作符函数加返回一个 Pack
值。要调用它,你可以使用 中缀
操作符方式(Wolf + Wolf
)或常规方式(Wolf.plus(Wolf)
)。
关于 Kotlin 中操作符重载需要注意的一点是——你可以在 Kotlin 中重载的操作符是有限的;你不能创建任意的操作符。
二元操作符
二元操作符接收一个参数(有一些例外——invoke
和索引访问)。
Pack.plus
扩展函数接收一个 Wolf
参数并返回一个新的 Pack
。注意,MutableMap
也具有加号(+
)操作符:
operator fun Pack.plus(wolf: Wolf) = Pack(this.members.toMutableMap() + (wolf.name to wolf))
val biggerPack = northPack + Wolf("Bad Wolf")
下表将展示所有可能的可重载的二进制操作符:
操作符 | 等效 | 说明 |
---|---|---|
x + y |
x.plus(y) |
|
x - y |
x.minus(y) |
|
x * y |
x.times(y) |
|
x / y |
x.div(y) |
|
x % y |
x.rem(y) |
从 Kotlin 1.1 版本开始,之前为 mod . |
x..y |
x.rangeTo(y) |
|
x in y |
y.contains(x) |
|
x !in y |
!y.contains(x) |
|
x += y |
x.plussAssign(y) |
必须返回 Unit . |
x -= y |
x.minusAssign(y) |
必须返回 Unit . |
x *= y |
x.timesAssign(y) |
必须返回 Unit . |
x /= y |
x.divAssign(y) |
必须返回 Unit . |
x %= y |
x.remAssign(y) |
从 Kotlin 1.1 版本开始,之前为 modAssign 。必须返回 Unit . |
x == y |
x?.equals(y) ?: (y === null) |
检查 null . |
x != y |
!(x?.equals(y) ?: (y === null)) |
检查 null . |
x < y |
x.compareTo(y) < 0 |
必须返回 Int . |
x > y |
x.compareTo(y) > 0 |
必须返回 Int . |
x <= y |
x.compareTo(y) <= 0 |
必须返回 Int . |
x >= y |
x.compareTo(y) >= 0 |
必须返回 Int . |
调用
回到 第二章,开始函数式编程,在 一等和高级函数 部分,当我们介绍 lambda 函数时,展示了 Function1
的定义:
/** A function that takes 1 argument. */
public interface Function1<in P1, out R> : Function<R> {
/** Invokes the function with the specified argument. */
public operator fun invoke(p1: P1): R
}
invoke
函数是一个操作符,一个有趣的操作符。invoke
操作符可以在没有 name
的情况下被调用。
Wolf
类有一个 invoke
操作符:
enum class WolfActions {
SLEEP, WALK, BITE
}
class Wolf(val name:String) {
operator fun invoke(action: WolfActions) = when (action) {
WolfActions.SLEEP -> "$name is sleeping"
WolfActions.WALK -> "$name is walking"
WolfActions.BITE -> "$name is biting"
}
}
fun main(args: Array<String>) {
val talbot = Wolf("Talbot")
talbot(WolfActions.SLEEP) // talbot.invoke(WolfActions.SLEEP)
}
正因如此,我们可以直接用括号调用 lambda 函数;实际上,我们是在调用 invoke
操作符。
下表将展示 invoke
函数的不同声明及其不同参数数量:
操作符 | 等效 | 说明 |
---|---|---|
x() |
x.invoke() |
|
x(y) |
x.invoke(y) |
|
x(y1, y2) |
x.invoke(y1, y2) |
|
x(y1, y2..., yN) |
x.invoke(y1, y2..., yN) |
索引访问
索引访问操作符是使用方括号([]
)的数组读写操作,用于具有类似 C 语法语法的语言。在 Kotlin 中,我们使用 get
操作符进行读取,使用 set
进行写入。
使用 Pack.get
操作符,我们可以将 Pack
当作数组使用:
operator fun Pack.get(name: String) = members[name]!!
val badWolf = biggerPack["Bad Wolf"]
大多数 Kotlin 数据结构都有一个 get
操作符的定义,在这种情况下,Map<K, V>
返回一个 V?
。
下表将展示 get
函数的不同声明及其不同参数数量:
操作符 | 等效 | 说明 |
---|---|---|
x[y] |
x.get(y) |
|
x[y1, y2...] |
x.get(y1, y2...) |
|
x[y1, y2...] |
x.get(y1, y2...) |
set
操作符具有类似的语法:
enum class WolfRelationships {
FRIEND, SIBLING, ENEMY, PARTNER
}
operator fun Wolf.set(relationship: WolfRelationships, wolf: Wolf) {
println("${wolf.name} is my new $relationship")
}
talbot[WolfRelationships.ENEMY] = badWolf
get
和 set
运算符可以包含任意代码,但有一个非常著名且古老的约定,即索引访问用于读写。当你编写这些运算符(顺便说一下,所有其他运算符也是如此)时,使用“最小惊讶”原则。将运算符限制在其特定领域的自然含义上,从长远来看,使它们更容易使用和阅读。
下表将展示不同数量参数的 set
的不同声明:
运算符 | 等效 | 说明 |
---|---|---|
x[y] = z |
x.set(y, z) |
返回值被忽略 |
x[y1, y2] = z |
x.set(y1, y2, z) |
返回值被忽略 |
x[y1, y2..., yN] = z |
x.set(y1, y2..., yN, z) |
返回值被忽略 |
一元运算符
一元运算符没有参数,并直接作用于分发器。
我们可以向 Wolf
类添加一个 not
运算符:
operator fun Wolf.not() = "$name is angry!!!"
!talbot // talbot.not()
下表将展示所有可能被重载的一元运算符:
运算符 | 等效 | 说明 |
---|---|---|
+x |
x.unaryPlus() |
|
-x |
x.unaryMinus() |
|
!x |
x.not() |
|
x++ |
x.inc() |
后缀,它必须是对 var 的调用,应该返回与分发器类型兼容的类型,不应该修改分发器。 |
x-- |
x.dec() |
后缀,它必须是对 var 的调用,应该返回与分发器类型兼容的类型,不应该修改分发器。 |
++x |
x.inc() |
前缀,它必须是对 var 的调用,应该返回与分发器类型兼容的类型,不应该修改分发器。 |
--x |
x.dec() |
前缀,它必须是对 var 的调用,应该返回与分发器类型兼容的类型,不应该修改分发器。 |
后缀(递增和递减)返回原始值,然后更改变量为运算符返回的值。前缀返回运算符的返回值,然后更改变量为该值。
类型安全的构建器
在前两个部分(中缀函数和运算符重载)之后,我们为构建出色的 DSL 打下了良好的基础。DSL 是一种针对特定领域专门化的语言,与 通用语言(GPL)相对。经典的 DSL 示例(即使人们没有意识到)是 HTML(标记)和 SQL(关系数据库查询)。
Kotlin 提供了许多功能来创建内部 DSL(在宿主 GPL 内部运行的 DSL),但我们仍需要介绍一个特性,即类型安全的构建器。类型安全的构建器允许我们以(半)声明性的方式定义数据,并且对于定义 GUI、HTML 标记、XML 等非常有用。
一个漂亮的 Kotlin DSL 示例是 TornadoFX。TornadoFX (tornadofx.io/
) 是用于创建 JavaFX 应用程序的 DSL。
我们编写一个 FxApp
类,它扩展了 tornadofx.App
并接收一个 tornadofx.View
类(一个类引用,而不是一个实例):
import javafx.application.Application
import tornadofx.*
fun main(args: Array<String>) {
Application.launch(FxApp::class.java, *args)
}
class FxApp: App(FxView::class)
class FxView: View() {
override val root = vbox {
label("Functional Kotlin")
button("Press me")
}
}
在不到 20 行代码中,包括导入和主函数,我们可以创建一个 GUI 应用程序:
当然,现在它什么也不做,但用 TornadoFX 创建一个 JavaFX 应用程序很简单,如果你与 Java 进行比较。有 JavaFX 经验的人可能会说,你可以用 FXML(一种用于构建 JavaFX 布局的声明性 XML 语言)达到类似的效果,但就像任何其他 XML 文件一样,编写和维护都很困难,而 TornadoFX 的 DSL 更简单、更灵活,并且使用 Kotlin 的类型安全性进行编译。
但类型安全的构建器是如何工作的呢?
让我们从 Kotlin 标准库的一个例子开始:
val joinWithPipe = with(listOf("One", "Two", "Three")){
joinToString(separator = "|")
}
我们可以在其他语言中找到 with
块,例如 JavaScript 和 Visual Basic(包括 .Net)。with
块是一种语言结构,它允许我们使用传递为参数的值上的任何属性或方法。但在 Kotlin 中,with
不是一个保留关键字,而是一个具有特殊参数类型的普通函数。
让我们看看 with
声明:
public inline fun <T, R> with(receiver: T, block: T.() -> R): R {
return receiver.block()
}
第一个参数是类型 T
的任何值,一个接收器(就像扩展函数一样?),第二个参数 block
是类型 T.() -> R
的函数。在 Kotlin 的文档中,这种函数被称为具有接收器的 函数类型,并且对于 T
的任何实例,我们都可以调用 block
函数。不用担心 inline
修饰符,我们将在下一节中介绍它。
理解具有接收器的函数类型的一个技巧是将它想象成一个扩展函数。看看那个熟悉的点(.
)声明,并在函数内部,我们可以使用 this
来使用接收器类型的任何成员,就像扩展函数一样。
再举一个例子怎么样?让我们看看它:
val html = buildString {
append("<html>\n")
append("\t<body>\n")
append("\t\t<ul>\n")
listOf(1, 2, 3).forEach { i ->
append("\t\t\t<li>$i</li>\n")
}
append("\t\t<ul>\n")
append("\t</body>\n")
append("</htm
l>")
}
buildString
函数接收一个 StringBuilder.() -> Unit
参数并返回一个 String
;其声明非常简单:
public inline fun buildString(builderAction: StringBuilder.() -> Unit): String =
StringBuilder().apply(builderAction).toString()
apply
函数是一个类似于 with
的扩展函数,但它返回的是接收器实例,而不是 R
。通常,apply
用于 初始化 和 实例:
public inline fun <T> T.apply(block: T.() -> Unit): T {
block()
return this
}
如您所见,所有这些函数都非常容易理解,但它们极大地增加了 Kotlin 的实用性和可读性。
创建 DSL
我的一大爱好是骑自行车。运动的情感、努力、健康益处以及欣赏风景都是一些好处(我可以继续说下去)。
我想创建一种方式来记录我的自行车及其组件。在原型阶段,我会使用 XML,但以后我们可以改为不同的实现:
<bicycle description="Fast carbon commuter">
<bar material="ALUMINIUM" type="FLAT">
</bar>
<frame material="CARBON">
<wheel brake="DISK" material="ALUMINIUM">
</wheel>
</frame>
<fork material="CARBON">
<wheel brake="DISK" material="ALUMINIUM">
</wheel>
</fork>
</bicycle>
这是在 Kotlin 中创建类型安全构建器的完美场景。
最后,我的 bicycle
DSL 应该看起来像这样:
fun main(args: Array<String>) {
val commuter = bicycle {
description("Fast carbon commuter")
bar {
barType = FLAT
material = ALUMINIUM
}
frame {
material = CARBON
backWheel {
material = ALUMINIUM
brake = DISK
}
}
fork {
material = CARBON
frontWheel {
material = ALUMINIUM
brake = DISK
}
}
}
println(commuter)
}
我的 DSL 是常规的 Kotlin 代码,编译速度快,我的 IDE 会帮我自动完成代码,并在我出错时提醒我——这是一个双赢的局面。
让我们从程序开始:
interface Element {
fun render(builder: StringBuilder, indent: String)
}
在我的 DSL 中的 bicycle
的所有部分都将扩展/实现 Element
接口:
@DslMarker
annotation class ElementMarker
@ElementMarker
abstract class Part(private val name: String) : Element {
private val children = arrayListOf<Element>()
protected val attributes = hashMapOf<String, String>()
protected fun <T : Element> initElement(element: T, init: T.() -> Unit): T {
element.init()
children.add(element)
return element
}
override fun render(builder: StringBuilder, indent: String) {
builder.append("$indent<$name${renderAttributes()}>\n")
children.forEach { c -> c.render(builder, indent + "\t") }
builder.append("$indent</$name>\n")
}
private fun renderAttributes(): String = buildString {
attributes.forEach { attr, value -> append(" $attr=\"$value\"") }
}
override fun toString(): String = buildString {
render(this, "")
}
}
Part
是所有我的部分的基类;它有 children
和 attributes
属性;它还继承了具有 XML 实现的 Element
接口。改为不同的格式(JSON、YAML 等)不应太难。
initElement
函数接收两个参数,一个元素 T
和一个接收器为 T.() -> Unit
的 init
函数。内部,init
函数被执行,并将元素添加为子元素。
Part
使用 @ElementMarker
注解,该注解本身使用 @DslMarker
注解。它防止内部元素到达外部元素。
在这个例子中,我们可以使用 frame
:
val commuter = bicycle {
description("Fast carbon commuter")
bar {
barType = FLAT
material = ALUMINIUM
frame { } //compilation error
}
仍然可以使用 this
来显式执行:
val commuter = bicycle {
description("Fast carbon commuter")
bar {
barType = FLAT
material = ALUMINIUM
this@bicycle.frame{ }
}
现在,几个枚举来描述材料、杆类型和刹车:
enum class Material {
CARBON, STEEL, TITANIUM, ALUMINIUM
}
enum class BarType {
DROP, FLAT, TT, BULLHORN
}
enum class Brake {
RIM, DISK
}
其中一些部分有 material
属性:
abstract class PartWithMaterial(name: String) : Part(name) {
var material: Material
get() = Material.valueOf(attributes["material"]!!)
set(value) {
attributes["material"] = value.name
}
}
我们使用 Material
枚举的 material
属性,并将其存储在 attributes
映射中,转换值来来回回:
class Bicycle : Part("bicycle") {
fun description(description: String) {
attributes["description"] = description
}
fun frame(init: Frame.() -> Unit) = initElement(Frame(), init)
fun fork(init: Fork.() -> Unit) = initElement(Fork(), init)
fun bar(init: Bar.() -> Unit) = initElement(Bar(), init)
}
Bicycle
定义了一个 description
函数和 frame
、fork
和 bar
的函数。每个函数接收一个 init
函数,我们直接将其传递给 initElement
。
Frame
有一个后轮的函数:
class Frame : PartWithMaterial("frame") {
fun backWheel(init: Wheel.() -> Unit) = initElement(Wheel(), init)
}
Wheel
有一个使用 Brake
枚举的 brake
属性:
class Wheel : PartWithMaterial("wheel") {
var brake: Brake
get() = Brake.valueOf(attributes["brake"]!!)
set(value) {
attributes["brake"] = value.name
}
}
Bar
有一个用于其类型的属性,使用 BarType
枚举:
class Bar : PartWithMaterial("bar") {
var barType: BarType
get() = BarType.valueOf(attributes["type"]!!)
set(value) {
attributes["type"] = value.name
}
}
Fork
定义了一个前轮的函数:
class Fork : PartWithMaterial("fork") {
fun frontWheel(init: Wheel.() -> Unit) = initElement(Wheel(), init)
}
我们接近完成,我们现在需要的只是我们的 DSL 的入口函数:
fun bicycle(init: Bicycle.() -> Unit): Bicycle {
val cycle = Bicycle()
cycle.init()
return cycle
}
就这样。在 Kotlin 中使用 infix
函数、操作符重载和类型安全的构建器,DSL 非常强大,Kotlin 社区每天都在创建新的和令人兴奋的库。
内联函数
高阶函数非常有用且复杂,但它们伴随着一个缺点——性能惩罚。记住,从 第二章,“开始使用函数式编程”,在“一等和高级函数”部分,lambda 在编译时被转换为一个分配的对象,我们调用它的 invoke
操作符;这些操作消耗 CPU 力和内存,无论它们有多小。
这样的函数:
fun <T> time(body: () -> T): Pair<T, Long> {
val startTime = System.nanoTime()
val v = body()
val endTime = System.nanoTime()
return v to endTime - startTime
}
fun main(args: Array<String>) {
val (_,time) = time { Thread.sleep(1000) }
println("time = $time")
}
一旦编译,它将看起来像这样:
val (_, time) = time(object : Function0<Unit> {
override fun invoke() {
Thread.sleep(1000)
}
})
如果性能对你来说很重要(关键任务应用、游戏、视频流),你可以将高阶函数标记为 inline
:
inline fun <T> inTime(body: () -> T): Pair<T, Long> {
val startTime = System.nanoTime()
val v = body()
val endTime = System.nanoTime()
return v to endTime - startTime
}
fun main(args: Array<String>) {
val (_, inTime) = inTime { Thread.sleep(1000) }
println("inTime = $inTime")
}
一旦编译,它将看起来像这样:
val startTime = System.nanoTime()
val v = Thread.sleep(1000)
val endTime = System.nanoTime()
val (_, inTime) = (v to endTime - startTime)
整个函数执行被高阶函数的体和 lambda 的体所替换。inline
函数更快,尽管会生成更多的字节码:
每次执行 2.3 毫秒看起来并不多,但长期来看,并且随着更多的优化,可以产生明显的复合效应。
内联限制
内联 lambda 函数有一个重要的限制——它们不能以任何方式被操作(存储、复制等)。
UserService
存储了一个监听器列表 (User) -> Unit
:
data class User(val name: String)
class UserService {
val listeners = mutableListOf<(User) -> Unit>()
val users = mutableListOf<User>()
fun addListener(listener: (User) -> Unit) {
listeners += listener
}
}
将addListener
改为内联函数将产生编译错误:
inline fun addListener(listener: (User) -> Unit) {
listeners += listener //compilation error: Illegal use of inline-parameter listener
}
如果你仔细想想,这是有道理的。当我们内联一个 lambda 表达式时,我们实际上是在替换它的主体,而这并不是我们可以在Map
上存储的东西。
我们可以使用noinline
修饰符来解决这个问题:
//Warning: Expected performance impact of inlining addListener can be insignificant
inline fun addListener(noinline listener: (User) -> Unit) {
listeners += listener
}
在内联函数上使用noinline
只会内联高阶函数的主体,但不会内联noinline
lambda 参数(内联高阶函数可以同时有:inline
和noinline
lambda)。生成的字节码不如完全内联的函数快,编译器会显示警告。
内联 lambda 函数不能用于另一个执行上下文中(局部对象,嵌套 lambda)。
在这个例子中,我们无法在buildUser
lambda 中使用transform
:
inline fun transformName(transform: (name: String) -> String): List<User> {
val buildUser = { name: String ->
User(transform(name)) //compilation error: Can't inline transform here
}
return users.map { user -> buildUser(user.name) }
}
为了解决这个问题,我们需要一个crossinline
修饰符(或者我们可以使用noinline
,但会损失相关的性能):
inline fun transformName(crossinline transform: (name: String) -> String): List<User> {
val buildUser = { name: String ->
User(transform(name))
}
return users.map { user -> buildUser(user.name) }
}
fun main(args: Array<String>) {
val service = UserService()
service.transformName(String::toLowerCase)
}
生成的代码相当复杂。生成了许多部分:
-
一个扩展
(String) -> User
的类来表示buildUser
,并在内部使用String::toLowerCase
来转换名称 -
一个普通的内联代码,用于执行使用
buildUser
类的实例来执行List<User>.map()
-
List<T>.map()
是内联的,所以相应的代码也会被生成
一旦你意识到它的限制,内联高阶函数是提高你代码执行速度的绝佳方式。确实,Kotlin 标准库中的许多高阶函数都是inline
的。
递归和核心递归
在第二章,“函数式编程入门”,在“递归”部分,我们广泛地介绍了递归(尽管本书的范围不包括所有递归主题)。
我们使用递归编写了经典算法,如斐波那契(我们正在重用第二章,“函数式编程入门”中的tailrecFib
):
fun tailrecFib(n: Long): Long {
tailrec fun go(n: Long, prev: Long, cur: Long): Long {
return if (n == 0L) {
prev
} else {
go(n - 1, cur, prev + cur)
}
}
return go(n, 0, 1)
}
以及阶乘(同样,这里重用第二章,“函数式编程入门”中的tailrecFactorial
):
fun tailrecFactorial(n: Long): Long {
tailrec fun go(n: Long, acc: Long): Long {
return if (n <= 0) {
acc
} else {
go(n - 1, n * acc)
}
}
return go(n, 1)
}
在这两种情况下,我们从一个数字开始,并减少它以达到基本条件。
我们还考虑了另一个例子,即FunList
:
sealed class FunList<out T> {
object Nil : FunList<Nothing>()
data class Cons<out T>(val head: T, val tail: FunList<T>) : FunList<T>()
fun forEach(f: (T) -> Unit) {
tailrec fun go(list: FunList<T>, f: (T) -> Unit) {
when (list) {
is Cons -> {
f(list.head)
go(list.tail, f)
}
is Nil -> Unit//Do nothing
}
}
go(this, f)
}
fun <R> fold(init: R, f: (R, T) -> R): R {
tailrec fun go(list: FunList<T>, init: R, f: (R, T) -> R): R = when (list) {
is Cons -> go(list.tail, f(init, list.head), f)
is Nil -> init
}
return go(this, init, f)
}
fun reverse(): FunList<T> = fold(Nil as FunList<T>) { acc, i -> Cons(i, acc) }
fun <R> foldRight(init: R, f: (R, T) -> R): R = this.reverse().fold(init, f)
fun <R> map(f:(T) -> R): FunList<R> = foldRight(Nil as FunList<R>){ tail, head -> Cons(f(head), tail) }
}
函数forEach
和fold
是递归的。从完整的列表开始,我们减少它直到达到末尾(用Nil
表示),这是基本条件。其他函数——reverse
、foldRight
和map
只是使用不同变体的fold
。
因此,一方面,递归将一个复杂值减少到所需的答案,另一方面,核心递归从一个值开始,在此基础上构建以产生复合值(包括可能的无穷数据结构,如Sequence<T>
)。
由于我们使用fold
函数进行递归操作,我们可以使用unfold
函数:
fun <T, S> unfold(s: S, f: (S) -> Pair<T, S>?): Sequence<T> {
val result = f(s)
return if (result != null) {
sequenceOf(result.first) + unfold(result.second, f)
} else {
sequenceOf()
}
}
unfold
函数接受两个参数,一个初始的 S
值,它表示起始或基本步长,以及一个 f
lambda,该 lambda 接受该 S
步长并生成一个 Pair<T, S>?
(一个可空的 Pair
),其中包含要添加到序列中的 T
值和下一个 S
步长。
如果 f(s)
的结果是 null
,我们返回一个空序列,否则我们创建一个单值序列并添加 unfold
的新步长的结果。
使用 unfold
,我们可以创建一个函数,多次重复单个元素:
fun <T> elements(element: T, numOfValues: Int): Sequence<T> {
return unfold(1) { i ->
if (numOfValues > i)
element to i + 1
else
null
}
}
fun main(args: Array<String>) {
val strings = elements("Kotlin", 5)
strings.forEach(::println)
}
elements
函数接受一个元素并重复任意数量的值。内部,它使用 unfold
,传递 1
作为初始步长和一个 lambda,该 lambda 接受当前步长并与 numOfValues
进行比较,返回包含相同元素和当前步长 + 1
或 null
的 Pair<T, Int>
。
这是可以的,但并不非常有趣。那么返回一个阶乘序列怎么样?我们为你准备好了:
fun factorial(size: Int): Sequence<Long> {
return sequenceOf(1L) + unfold(1L to 1) { (acc, n) ->
if (size > n) {
val x = n * acc
(x) to (x to n + 1)
} else
null
}
}
同样的原理,唯一的区别是,我们的初始步长是 Pair<Long, Int>
(第一个元素用于携带计算,第二个用于与大小进行比较)因此,我们的 lambda 应该返回 Pair<Long, Pair<Long, Int>>
。
斐波那契序列看起来类似:
fun fib(size: Int): Sequence<Long> {
return sequenceOf(1L) + unfold(Triple(0L, 1L, 1)) { (cur, next</span>, n) ->
if (size > n) {
val x = cur + next
(x) to Triple(next, x, n + 1)
}
else
null
}
}
除了这个情况,我们使用 Triple<Long, Long, Int>
。
生成阶乘和斐波那契序列的核心递归实现是计算阶乘或斐波那契数的递归实现的镜像——有些人可能会认为这更容易理解。
概述
通过本章,我们已经涵盖了 Kotlin 函数式编程的大部分特性。我们回顾了如何使用单表达式函数编写更短的函数,不同类型的参数,如何使用扩展函数扩展我们的类型,以及如何使用 infix
函数和运算符编写自然易读的代码。我们还涵盖了使用类型安全构建器的 DSL 基础知识以及如何编写高效的高阶函数。最后,但同样重要的是,我们学习了递归和核心递归。
在下一章中,我们将学习 Kotlin 代理。
第六章:Kotlin 中的委托
在前两章中,我们学习了函数和函数类型在函数式编程中的应用。我们还学习了 Kotlin 提供的各种函数类型。
本章基于 Kotlin 中的委托。委托是 Kotlin 在函数式编程方面的优秀特性。如果你来自非 FP 背景,如 Java,你可能第一次听说委托。因此,在本章中,我们将尽力为你解开这些谜团。
我们将首先学习委托的基础知识,然后逐步过渡到 Kotlin 中委托的实现。
以下列表包含本章将涉及的主题:
-
委托简介
-
Kotlin 中的委托
-
委托属性
-
标准委托
-
自定义委托
-
委托映射
-
本地委托
-
类委托
那么,让我们开始学习委托。
委托简介
编程中委托的起源来自对象组合。对象组合是将简单对象组合成复杂对象的一种方式。对象组合是许多基本数据结构的关键构建块,包括标签联合、链表和二叉树。
为了使对象组合更可重用(尽可能像继承一样可重用),引入了一种新的模式——委托模式。
这种模式允许一个对象拥有一个辅助对象,而这个辅助对象被称为委托。这种模式允许原始对象通过委托给委托辅助对象来处理请求。
虽然委托是一种面向对象的设计模式,但并非所有语言都隐式支持委托(例如 Java,它不隐式支持委托)。在这些情况下,您仍然可以通过显式地将原始对象传递给委托方法作为参数/参数来使用委托。
但是,有了语言支持(例如在 Kotlin 中),委托变得更容易,通常感觉就像使用原始变量本身一样。
理解委托
随着时间的推移,委托模式已被证明是继承的更好替代方案。继承是代码重用的强大工具,尤其是在Liskov 替换模型中。此外,面向对象语言对它的直接支持使其更加强大。
然而,继承仍然有一些局限性,例如,一个类在程序执行期间不能动态地更改其超类;此外,如果你对超类进行小的修改,它将直接传播到子类,而这并不是我们每次都想要的。
另一方面,委托是灵活的。你可以把委托看作是多个对象的组合,其中一个对象将其方法调用传递给另一个对象,并称之为委托。如我之前提到的,委托是灵活的;你可以在运行时更改委托。
例如,考虑 Electronics
类和 Refrigerator
类。使用继承时,Refrigerator
应该实现/覆盖 Electronics
的方法调用和属性。然而,使用委托时,Refrigerator
对象将保持对 Electronics
对象的引用,并将方法调用与其一起传递。
既然我们知道 Kotlin 提供了对委托的支持,那么让我们开始学习 Kotlin 中的委托。
Kotlin 中的委托
Kotlin 提供了开箱即用的委托支持。Kotlin 为大多数常见的编程需求提供了某些标准委托。大多数时候,你会发现自己在使用这些标准委托,而不是创建自己的;然而,Kotlin 也允许你根据需求创建自己的委托。
Kotlin 不仅允许对属性进行委托,还允许有委托类。
因此,基本上,Kotlin 中有两种类型的委托,如下所示:
-
属性委托
-
类委托
因此,我们先来看一下属性委托,然后我们将继续讨论类委托。
属性委托(标准委托)
在上一节中,我们讨论委托时,我们了解到委托是一种方法传递/转发的技术。
对于属性委托,它几乎做了同样的事情。一个属性可以将它的获取器和设置器调用传递给委托,委托可以代表属性本身处理这些调用。
你可能正在想,将获取器和设置器调用传递给委托有什么好处?只有你使用的委托才能回答这个问题。Kotlin 为大多数常见用例提供了多个预定义的标准委托。让我们看一下以下列表,其中包含可用的标准委托:
-
Delegates.notNull
函数和lateinit
-
lazy
函数 -
Delegates.Observable
函数 -
Delegates.vetoble
函数
Delegates.notNull
函数和 lateinit
想象一个场景,你需要在一个类级别上声明一个属性,但你没有那里变量的初始值。你将在稍后某个时刻得到这个值,但在属性实际使用之前,你确信属性将在使用之前初始化,并且它不会是 null。但是,根据 Kotlin 语法,你必须初始化属性。快速修复方法是将其声明为 nullable var
属性,并分配一个默认的 null 值。但是,如我们之前提到的,由于你确信变量在使用时不会是 null,你不愿意将其声明为可空。
Delegates.notNull
函数就是为了在这种情况下帮助你。看看以下程序:
var notNullStr:String by Delegates.notNull<String>()
fun main(args: Array<String>) {
notNullStr = "Initial value"
println(notNullStr)
}
关注第一行——var notNullStr:String by Delegates.notNull<String>()
,我们声明了一个非空的 String var
属性,但没有初始化它。相反,我们写了 by Delegates.notNull<String>()
,但这是什么意思?让我们检查一下。by
操作符是 Kotlin 中的一个保留关键字,用于与委托一起使用。by
操作符与两个操作数一起工作,by
的左侧将是需要委托的属性/类,而右侧将是委托。
委托——Delegates.notNull
允许你在不初始化属性的情况下暂时使用。它必须在使用之前初始化(就像我们在 main
方法的第一行所做的那样),否则它将抛出异常。
因此,让我们通过添加另一个属性来修改程序,我们将在使用之前不初始化它,看看会发生什么:
var notNullStr:String by Delegates.notNull<String>()
var notInit:String by Delegates.notNull<String>()
fun main(args: Array<String>) {
notNullStr = "Initial value"
println(notNullStr)
println(notInit)
}
输出看起来如下:
因此,notInit
属性导致了异常——属性 notInit 应在使用前初始化
。
但是,变量声明——by Delegates.notNull()
听起来不是很顺耳吗?Kotlin 团队也这样认为。这就是为什么从 Kotlin 1.1 开始,他们添加了一个简单的关键字——lateinit
,以实现相同的目标。正如它简单说明了延迟初始化,它应该简单地是 lateinit
。
因此,让我们通过将 by Delegates.notNull()
替换为 lateinit
来修改最后一个程序。以下是修改后的程序:
lateinit var notNullStr1:String
lateinit var notInit1:String
fun main(args: Array<String>) {
notNullStr1 = "Initial value"
println(notNullStr1)
println(notInit1)
}
在这个程序中,我们必须重命名变量,因为你不能有两个同名的顶级(包级变量,没有任何类/函数)。除了变量名外,唯一改变的是我们添加了 lateinit
,而不是 by Delegates.notNull()
。
因此,现在让我们看一下以下输出,以确定是否有任何变化:
输出也是相同的,除了它稍微改变了错误信息。现在它说,lateinit 属性 notInit1 尚未初始化
。
懒加载函数
lateinit
关键字仅适用于 var
属性。Delegates.notNull()
函数也仅与 var
属性配合使用。
那么,当使用 val
属性时,我们应该怎么做呢?Kotlin 为你提供了另一个委托——lazy
,它仅适用于 val
属性。但它的工作方式略有不同。
与 lateinit
和 Delegates.notNull()
不同,你必须在声明变量时指定你想要如何初始化变量。那么,有什么好处呢?初始化将不会在变量实际使用之前调用。这就是为什么这个委托被称为 lazy
;它允许属性的延迟初始化。
以下是一个代码示例:
val myLazyVal:String by lazy {
println("Just Initialised")
"My Lazy Val"
}
fun main(args: Array<String>) {
println("Not yet initialised")
println(myLazyVal)
}
因此,在这个程序中,我们声明了一个 String val
属性——myLazyVal
,并使用(打印)了该属性在 main
函数的第二行。
现在,让我们专注于变量声明。lazy
委托接受一个 lambda,该 lambda 预期返回属性的值。
那么,让我们看看输出结果:
注意,输出清楚地显示,属性是在 main
方法的第一行执行后初始化的,即当属性实际上被使用时。这种属性的 lazy
初始化可以显著节省内存。在某些情况下,它也是一个方便的工具,例如,想象一下你想要使用其他属性/上下文来初始化属性,而这些属性/上下文只有在某个特定点之后才可用(但你已经有了属性名);在这种情况下,你可以简单地保持属性为 lazy
,然后当确认初始化将成功时再使用它。
使用 Delegates.observable
观察属性值变化
委托不仅用于最近/延迟初始化属性。正如我们所学的,委托允许将属性的获取器和设置器调用转发到委托。这使得委托能够提供比最近/延迟初始化更多的酷炫功能。
这样一个酷炫的功能来自于 Delegates.observable
。想象一下,你需要监视一个属性值的改变,并在这种改变发生时立即执行某些操作。我们首先想到的解决方案是重写设置器,但这会使代码显得很糟糕,并且使代码变得复杂,而委托正是为了拯救我们。
看看下面的例子:
var myStr:String by Delegates.observable("<Initial Value>") {
property, oldValue, newValue ->
println("Property `${property.name}` changed value from "$oldValue" to "$newValue"")
}
fun main(args: Array<String>) {
myStr = "Change Value"
myStr = "Change Value again"
}
这是一个简单的例子,我们使用 Delegates.observable
声明了一个 String
属性——myStr
(我们将在查看输出后不久描述这种初始化),然后,在 main
函数内部,我们两次改变了 myStr
的值。
看看下面的输出:
在输出中,我们可以看到,每次我们更改值时,都会打印出属性的旧值和新值。这个程序中的 Delegates.observable
块负责输出中的日志。所以现在,让我们仔细看看 Delegates.observable
块,并了解它是如何工作的:
var myStr:String by Delegates.observable("<Initial Value>") {
property, oldValue, newValue ->
println("Property `${property.name}` changed value from "$oldValue" to "$newValue"")
}
Delegates.observable
函数接受两个参数来创建委托。第一个参数是属性的初始值,第二个参数是每当检测到值变化时应执行的 lambda。
Delegates.observable
的 lambda 预期有三个参数:
- 第一个是一个
KProperty<out R>
的实例
KProperty
是 Kotlin stdlib
和 kotlin.reflect
包中的一个接口,它是一个属性;例如一个命名的 val
或 var
声明。这个类的实例可以通过 ::
操作符获取。更多信息,请访问:kotlinlang.org/api/latest/jvm/stdlib/kotlin.reflect/-k-property/
-
第二个参数包含属性的旧值(分配前的最后一个值)
-
第三个参数是分配给属性的最新值(在分配中使用的新值)
因此,既然我们已经了解了Delegates.observable
的概念,那么让我们继续使用一个新的委托,Delegates.vetoable
。
否决权 - Delegates.vetoable
Delegates.vetoable
是另一个标准委托,它允许我们否决值的变化。
否决权,拉丁语为“我禁止”,是指单方面停止官方行动的权力(例如,由国家官员使用)。更多信息请参阅:en.wikipedia.org/wiki/Veto
。
这种否决权允许我们对属性的每一项分配进行逻辑检查,我们可以决定是否继续分配。
以下是一个示例:
var myIntEven:Int by Delegates.vetoable(0) {
property, oldValue, newValue ->
println("${property.name} $oldValue -> $newValue")
newValue%2==0
}
fun main(args: Array<String>) {
myIntEven = 6
myIntEven = 3
println("myIntEven:$myIntEven")
}
在这个程序中,我们创建了一个Int
属性—myIntEven
;这个属性应该只接受偶数作为分配。Delegates.vetoable
委托的工作方式几乎与Delegates.observable
函数相同,只是在 lambda 中有细微的变化。在这里,lambda 预期返回一个布尔值;如果返回的布尔值为true
,则分配会被传递,否则分配会被拒绝。
回顾一下程序。当我们使用Delegates.vetoable
委托声明变量时,我们传递了0
作为初始值,然后在 lambda 中记录了一个分配调用,然后如果新值是偶数,我们将返回true
,如果是奇数则返回false
。
这里是输出:
因此,在输出中,我们可以看到两个分配日志,但当我们打印最后分配后的myIntEven
属性时,我们可以看到最后的分配并没有成功。
有趣,不是吗?让我们看看Delegates.vetoable
的另一个示例。看看以下代码:
var myCounter:Int by Delegates.vetoable(0) {
property, oldValue, newValue ->
println("${property.name} $oldValue -> $newValue")
newValue>oldValue
}
fun main(args: Array<String>) {
myCounter = 2
println("myCounter:$myCounter")
myCounter = 5
myCounter = 4
println("myCounter:$myCounter")
myCounter++
myCounter--
println("myCounter:$myCounter")
}
这个程序有一个属性—myCounter
,它预期在每次分配时增加。
在 lambda 中,我们检查newValue
值是否大于oldValue
值。以下是输出:
显示那些值增加的分配的输出是成功的,但那些值减少的分配被拒绝了。
即使当我们使用了递增和递减运算符时,递增运算符是成功的,但递减运算符却没有。没有委托,这个特性不会那么容易实现。
委托映射
因此,我们学习了如何使用标准委托,但 Kotlin 必须提供更多关于委托的功能。映射委托是委托带来的那些令人惊叹的功能之一。那么,它是什么呢?它是在函数/类构造函数中传递一个映射作为单个参数,而不是传递多个参数的自由。让我们看看。以下是一个应用映射委托的程序:
data class Book (val delegate:Map<String,Any?>) {
val name:String by delegate
val authors:String by delegate
val pageCount:Int by delegate
val publicationDate:Date by delegate
val publisher:String by delegate
}
fun main(args: Array<String>) {
val map1 = mapOf(
Pair("name","Reactive Programming in Kotlin"),
Pair("authors","Rivu Chakraborty"),
Pair("pageCount",400),
Pair("publicationDate",SimpleDateFormat("yyyy/mm/dd").parse("2017/12/05")),
Pair("publisher","Packt")
)
val map2 = mapOf(
"name" to "Kotlin Blueprints",
"authors" to "Ashish Belagali, Hardik Trivedi, Akshay Chordiya",
"pageCount" to 250,
"publicationDate" to SimpleDateFormat("yyyy/mm/dd").parse("2017/12/05"),
"publisher" to "Packt"
)
val book1 = Book(map1)
val book2 = Book(map2)
println("Book1 $book1 nBook2 $book2")
}
程序足够简单;我们定义了一个 Book
数据类,并在构造函数中,我们不是逐个取成员值,而是取一个映射,然后将所有值委托给映射代理。
这里需要注意的一点是,在映射中提到所有成员变量,并且键名应与属性名完全匹配。
这里是输出结果:
简单吧?是的,代理就是这样强大。但你有没有好奇如果我们省略了映射中提到的任何属性会发生什么?它将简单地跳过你省略的属性,如果你明确尝试访问它们,那么它将抛出一个异常—java.util.NoSuchElementException
。
自定义代理
到目前为止,在本章中,我们已经看到了 Kotlin 中可用的标准代理。然而,Kotlin 允许我们编写自己的自定义代理,以满足我们的特定需求。
例如,在程序中,当我们使用 Delegates.vetoable
检查 Even
时,我们只能丢弃值赋值,但无法自动将下一个偶数赋值给变量。
在下面的程序中,我们使用了 makeEven
,一个自定义代理,如果传递给赋值的数字是奇数,它会自动分配下一个偶数,否则如果传递给赋值的数字是偶数,它会传递那个数字。
看看下面的程序:
var myEven:Int by makeEven(0) {
property, oldValue, newValue, wasEven ->
println("${property.name} $oldValue -> $newValue, Even:$wasEven")
}
fun main(args: Array<String>) {
myEven = 6
println("myEven:$myEven")
myEven = 3
println("myEven:$myEven")
myEven = 5
println("myEven:$myEven")
myEven = 8
println("myEven:$myEven")
}
这里是输出结果:
输出清楚地显示,每次我们将偶数分配给 myEven
时,它都会被分配,但当我们分配奇数时,下一个偶数(+1
)会被分配。
对于这个代理,我们使用了与 Delegates.observable
几乎相同的 lambda 表达式,只是增加了一个额外的参数—wasEven:Boolean
,如果分配的数字是偶数,则包含 true
,否则包含 false
。
想知道我们是如何创建代理的吗?以下是代码:
abstract class MakeEven(initialValue: Int):ReadWriteProperty<Any?,Int> {
private var value:Int = initialValue
override fun getValue(thisRef: Any?, property: KProperty<*>) = value
override fun setValue(thisRef: Any?, property: KProperty<*>, newValue: Int) {
val oldValue = newValue
val wasEven = newValue %2==0
if(wasEven) {
this.value = newValue
} else {
this.value = newValue +1
}
afterAssignmentCall(property,oldValue, newValue,wasEven)
}
abstract fun afterAssignmentCall (property: KProperty<*>, oldValue: Int, newValue: Int, wasEven:Boolean):Unit
}
为了在 var
属性上创建代理,你需要实现 ReadWriteProperty
接口。
该接口有两个需要重写的方法—getValue
和 setValue
。这些函数实际上是属性的获取器和设置器的委托函数。你可以从 getValue
函数返回你想要的价值,然后它将被转发为属性的返回值。每次访问属性时,都会调用 getValue
函数。同样,每次属性被分配值时,setValue
函数都会被调用,我们从 setValue
函数返回的任何内容实际上就是属性最终分配的值。例如,假设属性 a
被分配了 X
,但从 setValue
函数返回了 Y
,那么在赋值语句之后,属性 a
实际上会持有 Y
而不是 X
。
所以,如果你想从委托的 getValue
函数返回属性的值,你必须将属性的值存储在某个地方(是的,你将无法从原始属性中获取值,可能是因为原始属性甚至不会存储值,因为属性知道它将被委托)。在这个程序中,我们使用了一个可变的 var
属性——value
,来存储属性的值。我们从 getValue
函数返回 value
。
在 setValue
函数内部,我们检查分配的 newValue
是否为偶数。如果是偶数,我们将 newValue
分配给 value
属性(它将来自 getValue
函数),如果 newValue
是奇数,我们将 newValue+1
分配给 value
属性。
在 MakeEven
类中,我们有一个抽象方法——afterAssignmentCall
。我们在 setValue
函数的末尾调用了这个方法。这个方法是为了日志记录目的。
因此,委托几乎已经准备好了,但抽象方法怎么办?我们需要扩展这个类来应用委托,对吧?但记住我们使用它的代码,像 by makeEven(0) {...}
,所以那里必须有一个函数,不是吗?是的,有一个函数,以下是其定义:
inline fun makeEven(initialValue: Int, crossinline onAssignment:(property: KProperty<*>, oldValue: Int, newValue: Int, wasEven:Boolean)->Unit):ReadWriteProperty<Any?, Int>
=object : MakeEven(initialValue){
override fun afterAssignmentCall(property: KProperty<*>, oldValue: Int, newValue: Int, wasEven: Boolean)
= onAssignment(property,oldValue,newValue,wasEven)
}
我们创建了一个 MakeEven
的匿名对象,并将其作为委托传递,并将参数 lambda——onAssignment
,作为抽象函数——afterAssignmentCall
传递。
所以,我们必须掌握委托,让我们继续前进,尝试一些关于委托的更有趣的方面。
局部委托
委托很强大,我们已经看到了这一点,但想想一个常见的情况,在方法内部我们声明并初始化一个属性,然后应用一个逻辑,这个逻辑要么使用属性,要么不使用它继续执行。例如,以下是这样的程序:
fun useDelegate(shouldPrint:Boolean) {
val localDelegate = "Delegate Used"
if(shouldPrint) {
println(localDelegate)
}
println("bye bye")
}
在这个程序中,我们将使用 localDelegate
属性,只有当 shouldPrint
的值为 true
时,否则我们不会使用它。但因为它被声明并初始化,所以它总是会占用内存空间。避免这种内存阻塞的一个选项是将属性放在 if
块内部,但这是一个简单的示例程序,在这里我们可以轻松地将变量声明移动到 if
块内部,而在许多现实场景中,将变量声明移动到 if
块内部是不可能的。
那么,解决方案是什么?是的,使用 lazy
委托可以拯救我们的生命。但在 Kotlin 1.1 之前,这是不可能的。
所以,以下是一个更新的程序:
fun useDelegate(shouldPrint:Boolean) {
val localDelegate by lazy {
"Delegate Used"
}
if(shouldPrint) {
println(localDelegate)
}
println("bye bye")
}
尽管我们在这个例子中只使用了 lazy
,但从 Kotlin 1.1 开始,我们可以在局部属性中应用任何委托。
类委托
类委托是 Kotlin 的另一个有趣特性。如何?只需想想以下情况。
你有一个接口,I
,以及两个类,A
和 B
。A
和 B
都实现了 I
接口。在你的代码中,你有一个 A
的实例,并且想要从这个 A
实例创建一个 B
的实例。
在传统的继承中,这是不可能直接实现的;你必须编写一大堆糟糕的代码才能达到这个目的,但类委托(class delegation)就是为了解决这个问题而存在的。
下面是相应的代码:
interface Person {
fun printName()
}
class PersonImpl(val name:String):Person {
override fun printName() {
println(name)
}
}
class User(val person:Person):Person by person {
override fun printName() {
println("Printing Name:")
person.printName()
}
}
fun main(args: Array<String>) {
val person = PersonImpl("Mario Arias")
person.printName()
println()
val user = User(person)
user.printName()
}
在这个程序中,我们创建了 User
的实例,其中包含其成员属性——person
,它是一个 Person
接口的实例。在主函数中,我们将 PersonImpl
的实例传递给 user
以创建 User
的实例。
现在,让我们看看 User
的声明。在颜色(:
)之后,短语 Person by person
表示类 User
继承自 Person
类,并且期望从提供的 person
实例复制 Person
的行为。
下面是输出结果:
输出显示了覆盖(overriding)工作如预期进行,我们还可以访问 person
的属性和函数,就像访问一个普通属性一样。
真的是一个很棒的功能,不是吗?
摘要
在本章中,我们学习了代理(delegates),并看到了如何以各种方式使用代理来使我们的代码高效且整洁。我们学习了代理的不同特性和组成部分,以及如何使用它们。
下一章将介绍协程,这是 Kotlin 的一个开创性特性,它能够在保持开发者生活简单直接的同时,实现无缝的异步处理。
所以,不要等待太久,现在就开始下一章吧。
第七章:使用协程进行异步编程
今天的软件开发格局使得异步处理成为最重要的主题之一。处理器和核心数量的不断增长以及对外部服务的巨大消耗(近年来随着微服务架构的采用而增长)是我们应该关注并努力采用良好异步方法的因素之一。
Kotlin 对协程的实现是构建异步应用程序的优秀工具。
在本章中,我们将涵盖以下主题:
-
协程
-
替代方法
-
异步处理
-
通道和演员
协程简介
让我们从没有协程的简单例子开始:
import kotlin.concurrent.thread
fun main(args: Array<String>) {
thread {
Thread.sleep(1000)
println("World!")
}
print("Hello ")
Thread.sleep(2000)
}
thread
函数在不同的线程中执行一段代码。在代码块内部,我们使用 Thread.sleep
模拟一个昂贵的 I/O 计算(例如通过 HTTP 从微服务访问数据)。Thread.sleep
将阻塞当前线程,直到传递的参数指定的毫秒数。在这个例子中,我们不等待计算完成,而是继续做其他事情;当其他计算正在执行时,我们打印另一条消息,"Hello"
。最后,我们等待两秒钟,直到计算完成。
这段代码并不美观,我们可以做得更好:
fun main(args: Array<String>) {
val computation = thread {
Thread.sleep(1000)
println("World!")
}
print("Hello ")
computation.join()
}
在这个版本中,我们有一个名为 computation
的线程引用;在最后,我们等待 join()
方法完成。这比仅仅等待固定的时间更聪明,因为现实生活中的计算可能具有不同的执行时间。
理解 JVM 线程
线程是 JVM(以及其他平台)上异步并发应用程序的构建块。大多数情况下,JVM 线程由硬件线程(如处理器内部的内核)支持。硬件线程可以支持多个软件线程(JVM 线程是一种软件线程),但在任何给定时间只能执行一个软件线程。
操作系统(或 JVM)决定在每个硬件线程上执行哪个软件线程,并在活动线程之间快速切换,从而给人一种似乎有多个软件线程同时执行的感觉,但实际上活跃的软件线程数量与硬件线程数量相同。但是,在大多数情况下,认为所有软件线程都在同时执行是有用的。
JVM 中的线程非常快且响应迅速,但它们也有代价。每个 Thread
在创建、销毁(当垃圾回收时)和上下文切换(当线程成为执行线程或停止执行时存储和恢复线程状态的过程)时都会消耗 CPU 时间和内存。由于这种成本相对较高,JVM 应用程序不能有大量的线程。
在典型的开发机器上,一个 JVM 应用程序可以轻松地处理 100 个线程:
fun main(args: Array<String>) {
val threads = List(100){
thread {
Thread.sleep(1000)
print('.')
}
}
threads.forEach(Thread::join)
}
如果您使用任何外部应用程序来监控 JVM 应用程序,例如 VisualVM 或 JConsole(以及其他),您将看到如下图形:
我们可以将线程数增加到 1,000,如下面的截图所示:
内存容量正在以较快的速度增长,达到 1.5 GB 以上。
我们能否将线程数增加到 10,000?请看以下截图:
答案是明确的否定;当应用程序因OutOfMemoryError
而死亡时,创建了大约 2,020 个线程(此应用程序使用默认设置运行;这些设置可以在启动时更改)。
让我们尝试 1,900,这是一个安全的执行量的合理估计:
是的,我们可以运行 1,900 个并发线程。
在现代 JVM 应用程序中,创建和销毁线程被视为一种不良做法;相反,我们使用Executor
,这是一个抽象,允许我们管理和重用线程,从而降低创建和销毁的成本:
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
fun main(args: Array<String>){
val executor = Executors.newFixedThreadPool(1024)
repeat(10000){
executor.submit {
Thread.sleep(1000)
print('.')
}
}
executor.shutdown()
}
我们创建了一个executor
值,其内部有一个最多 1,024 个线程的线程池。然后,我们提交 10,000 个任务;最后,我们关闭Executor
。当我们关闭Executor
时,它不能接受新的任务并执行所有挂起的任务,如下所示:
有许多选项可以微调和玩耍,例如Executor
的线程数和池的类型或其实际实现。
关于 JVM 线程的理论远比本书所能涵盖的多。如果您想阅读和学习更多关于线程和并发的知识,我们推荐经典书籍《Java 并发实践(2006)》由 Dough Lea、David Holmes、Joseph Bower、Joshua Block、Tim Peierls 和 Brian Goetz 所著,由 Addison-Wesley Professional 出版。我们还推荐 Venkat Subramanian 所著的《Programming Concurrency on the JVM(2011)》由 Pragmatic Bookshelf 出版,以及 Douglas Schmidt 所著的《Java Concurrency LiveLessons(2015)》视频,由 Addison-Wesley Professional 提供。最后但同样重要的是,我们建议 Javier Fernández Gonzáles 所著的《Java Concurrency》系列书籍和视频,由 Packt 出版。
嗨,协程世界!
现在,让我们用协程重写我们的Hello World
应用程序。
但是,嘿!什么是协程?基本上,协程是一个非常轻量级的线程,它运行一段代码,具有类似的生命周期,但可以带有返回值或异常完成。技术上讲,协程是可挂起计算的一个实例,这种计算可能会挂起。协程不绑定到特定的线程,可以在一个Thread
中挂起并在另一个中恢复执行:
import kotlinx.coroutines.experimental.delay
import kotlinx.coroutines.experimental.launch
import kotlinx.coroutines.experimental.runBlocking
fun main(args: Array<String>) = runBlocking {
launch {
delay(1000)
println("World")
}
print("Hello ")
delay(2000)
}
这里有一些需要讨论的内容:
-
runBlocking
:此函数创建一个协程并阻塞当前Thread
,直到协程完成,返回其结果值(在这种情况下为Unit
)。 -
launch
: 这个函数创建一个新的协程而不阻塞当前Thread
,并返回Job
(在此处忽略)。 -
delay
: 这个函数是一个挂起函数(稍后会有更多介绍),它延迟当前协程的执行而不阻塞当前线程。 -
suspend
: 一个挂起函数是一个可能挂起协程执行而不阻塞当前Thread
的函数;因此,挂起函数必须在协程内部调用——它不能从普通代码中调用。该函数必须带有suspend
修饰符。因此,delay
可以在runBlocking
和launch
中调用,这两个函数(以及其他函数)将挂起 lambda 作为最后一个参数——挂起 lambda 是一个带有suspend
修饰符的 lambda。
让我们在继续之前总结一下我们现在所知道的内容,以及一些其他概念:
概念 | 描述 |
---|---|
协程 | 一个非常轻的线程,可以返回一个值,并且可以挂起和恢复。 |
挂起函数 | 一个带有 suspend 修饰符的函数。它可以挂起协程而不阻塞线程。挂起函数必须在协程内部调用,例如 delay 。 |
挂起 lambda | 一个带有 suspend 修饰符的 lambda 函数。它可以挂起协程而不阻塞线程。 |
协程构建器 | 一个接受一个挂起 lambda 的函数,创建一个协程,可能返回一个结果,例如 runBlocking 。 |
挂起点 | 调用挂起函数的点。 |
延续 | 在挂起点处挂起的协程的状态,它代表了挂起点之后其执行剩余部分的状态。 |
让我们回到正事上来。
正如我们之前讨论的,计算可能具有不同的执行时间。因此,delay
在我们的 Hello World
示例中并不理想:
fun main(args: Array<String>) = runBlocking {
val job = launch {
delay(1000)
println("World")
}
print("Hello ")
job.join()
}
就像我们的线程示例一样,我们获取由 launch
创建的作业的引用,并使用挂起函数 join
在最后挂起它。
到目前为止,一切顺利。但协程真的这么轻吗?我们能拥有 10,000 个协程吗?
让我们通过执行以下代码片段来试一试:
fun main(args: Array<String>) = runBlocking {
val jobs = List(10000) {
launch {
delay(1000)
print('.')
}
}
jobs.forEach { job -> job.join() }
}
}
哦,确实如此!它工作了:
它们比 Executor
解决方案快得多,内存少得多,线程少得多(仅有大约七个线程),而且最重要的是,它们非常容易阅读。
让我们用一百万个协程来试试:
不到 2,000 个线程需要超过 1.5 GB 的内存。一百万个协程需要不到 700 MB 的内存——我的论点就此结束。结论是协程非常非常轻。
在现实生活中使用协程
微基准测试非常有趣,它们让我们了解了 Kotlin 协程的强大之处,但它们并不代表真实场景。
让我们介绍我们的真实场景:
enum class Gender {
MALE, FEMALE;
companion object {
fun valueOfIgnoreCase(name: String): Gender = valueOf(name.toUpperCase())
}
}
typealias UserId = Int
data class User(val id: UserId, val firstName: String, val lastName: String, val gender: Gender)
data class Fact(val id: Int, val value: String, val user: User? = null)
interface UserService {
fun getFact(id: UserId): Fact
}
我们的 UserService
接口只有一个方法——getFact
将返回有关我们用户的 Chuck Norris 风格的事实,该用户由用户 ID 标识。
实现应该首先在本地数据库中检查用户;如果用户不在数据库中,它应该从RandomUser API服务(randomuser.me/documentation
)获取它,然后存储以供将来使用。一旦服务有了用户,它应该再次在数据库中检查与该用户相关的信息;如果信息不在数据库中,它应该从Internet Chuck Norris Database API服务(www.icndb.com/api/
)获取,并将其存储在数据库中。一旦服务有了信息,就可以返回。服务必须尝试减少外部调用(数据库、API 服务)的数量,而不使用缓存。
现在,让我们介绍其他接口,HTTP 客户端——UserClient
和FactClient
:
interface UserClient {
fun getUser(id: UserId): User
}
interface FactClient {
fun getFact(user: User): Fact
}
我们将使用http4k
(www.http4k.org/
)实现客户端的 HTTP 通信,并使用 Kotson(github.com/SalomonBrys/Kotson
)进行 JSON 处理。这两个库都是为 Kotlin 设计的,但任何其他库都应该工作得很好:
import com.github.salomonbrys.kotson.*
import com.google.gson.GsonBuilder
import org.http4k.client.ApacheClient
abstract class WebClient {
protected val apacheClient = ApacheClient()
protected val gson = GsonBuilder()
.registerTypeAdapter<User> {
deserialize { des ->
val json = des.json
User(json["info"]["seed"].int,
json["results"][0]["name"]["first"].string.capitalize(),
json["results"][0]["name"]["last"].string.capitalize(),
Gender.valueOfIgnoreCase(json["results"][0]["gender"].string))
}
}
.registerTypeAdapter<Fact> {
deserialize { des ->
val json = des.json
Fact(json["value"]["id"].int,
json["value"]["joke"].string)
}
}.create()!!
}
两个客户端都将扩展一个包含http4k ApacheClient
和用 Kotson DSL 配置的Gson
值的公共父类:
import org.http4k.core.Method
import org.http4k.core.Request
class Http4KUserClient : WebClient(), UserClient {
override fun getUser(id: UserId): User {
return gson.fromJson(apacheClient(Request(Method.GET, "https://randomuser.me/api")
.query("seed", id.toString()))
.bodyString())
}
}
Http4KUserClient
非常简单,这两个库都很容易使用,我们将大量代码移动到父类:
class Http4KFactClient : WebClient(), FactClient {
override fun getFact(user: User): Fact {
return gson.fromJson<Fact>(apacheClient(Request(Method.GET, "http://api.icndb.com/jokes/random")
.query("firstName", user.firstName)
.query("lastName", user.lastName))
.bodyString())
.copy(user = user)
}
}
Http4KFactClient
使用copy
方法在Fact
实例中设置用户值。
这些类实现得非常好,但为了测试我们算法的实际性能,我们将模拟这些接口:
class MockUserClient : UserClient {
override fun getUser(id: UserId): User {
println("MockUserClient.getUser")
Thread.sleep(500)
return User(id, "Foo", "Bar", Gender.FEMALE)
}
}
class MockFactClient : FactClient {
override fun getFact(user: User): Fact {
println("MockFactClient.getFact")
Thread.sleep(500)
return Fact(Random().nextInt(), "FACT ${user.firstName}, ${user.lastName}", user)
}
}
看一下以下数据库存储库,UserRepository
和FactRepository
:
interface UserRepository {
fun getUserById(id: UserId): User?
fun insertUser(user: User)
}
interface FactRepository {
fun getFactByUserId(id: UserId): Fact?
fun insertFact(fact: Fact)
}
对于我们的存储库,我们将使用 Spring 5 的JdbcTemplate
。Spring 5 提供了对 Kotlin 的支持,包括用于简化 Kotlin 使用的扩展函数(你可以在任何应用程序中使用JdbcTemplate
,它不需要是 Spring 应用程序):
import org.springframework.dao.EmptyResultDataAccessException
import org.springframework.jdbc.core.JdbcTemplate
abstract class JdbcRepository(protected val template: JdbcTemplate) {
protected fun <T> toNullable(block: () -> T): T? {
return try {
block()
} catch (_: EmptyResultDataAccessException) {
null
}
}
}
与客户端一样,两个存储库也将有一个父类——在这种情况下,有一个将EmptyResultDataAccessException
(Spring 表示不存在的记录的方式)转换为可空——符合 Kotlin 语法的函数;
两个实现都很直接,如下所示:
import org.springframework.jdbc.core.queryForObject
class JdbcUserRepository(template: JdbcTemplate) : JdbcRepository(template), UserRepository {
override fun getUserById(id: UserId): User? {
return toNullable {
template.queryForObject("select * from USERS where id = ?", id) { resultSet, _ ->
with(resultSet) {
User(getInt("ID"),
getString("FIRST_NAME"),
getString("LAST_NAME"),
Gender.valueOfIgnoreCase(getString("GENDER")))
}
}
}
}
override fun insertUser(user: User) {
template.update("INSERT INTO USERS VALUES (?,?,?,?)",
user.id,
user.firstName,
user.lastName,
user.gender.name)
}
}
class JdbcFactRepository(template: JdbcTemplate) : JdbcRepository(template), FactRepository {
override fun getFactByUserId(id: Int): Fact? {
return toNullable {
template.queryForObject("select * from USERS as U inner join FACTS as F on U.ID = F.USER where U.ID = ?", id) { resultSet, _ ->
with(resultSet) {
Fact(getInt(5),
getString(6),
User(getInt(1),
getString(2),
getString(3),
Gender.valueOfIgnoreCase(getString(4))))
}
}
}
}
override fun insertFact(fact: Fact) {
template.update("INSERT INTO FACTS VALUES (?,?,?)", fact.id, fact.value, fact.user?.id)
}
}
对于我们的数据库,我们使用的是 H2 内存数据库,但任何数据库都可以工作(你可以使这个应用程序与一些不同的持久化机制一起工作,例如 NoSQL 数据库或任何缓存):
fun initJdbcTemplate(): JdbcTemplate {
return JdbcTemplate(JdbcDataSource()
.apply {
setUrl("jdbc:h2:mem:facts_app;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=false")
})
.apply {
execute("CREATE TABLE USERS (ID INT AUTO_INCREMENT PRIMARY KEY, FIRST_NAME VARCHAR(64) NOT NULL, LAST_NAME VARCHAR(64) NOT NULL, GENDER VARCHAR(8) NOT NULL);")
execute("CREATE TABLE FACTS (ID INT AUTO_INCREMENT PRIMARY KEY, VALUE_ TEXT NOT NULL, USER INT NOT NULL, FOREIGN KEY (USER) REFERENCES USERS(ID) ON DELETE RESTRICT)")
}
}
函数initJdbcTemplate
使用 H2 DataSource
创建JdbcTemplate
,一旦准备就绪,它就在apply
扩展函数内部创建表。apply
扩展函数用于配置属性和调用初始化代码,并返回相同的值:
public inline fun <T> T.apply(block: T.() -> Unit): T {
block()
return this
}
与客户端一样,为了测试,我们将使用模拟:
class MockUserRepository : UserRepository {
private val users = hashMapOf<UserId, User>()
override fun getUserById(id: UserId): User? {
println("MockUserRepository.getUserById")
Thread.sleep(200)
return users[id]
}
override fun insertUser(user: User) {
println("MockUserRepository.insertUser")
Thread.sleep(200)
users[user.id] = user
}
}
class MockFactRepository : FactRepository {
private val facts = hashMapOf<UserId, Fact>()
override fun getFactByUserId(id: UserId): Fact? {
println("MockFactRepository.getFactByUserId")
Thread.sleep(200)
return facts[id]
}
override fun insertFact(fact: Fact) {
println("MockFactRepository.insertFact")
Thread.sleep(200)
facts[fact.user?.id ?: 0] = fact
}
}
使用这些模拟,我们的最坏情况场景大约是 1,600 毫秒:
-
UserRepository.getUserById = 200ms ~
-
UserClient.getUser = 500ms ~
-
UserRepository = 200ms ~
-
FactClient.getFact = 500ms ~
-
FactRepository.insertRepository = 200ms ~
现在,我们将使用不同的异步风格实现UserService
,包括一个同步实现,这是我们的基础。
同步实现
同步代码易于编写,可预测,且易于测试,但在某些情况下,它并没有以最佳方式使用系统资源:
class SynchronousUserService(private val userClient: UserClient,
private val factClient: FactClient,
private val userRepository: UserRepository,
private val factRepository: FactRepository) : UserService {
override fun getFact(id: UserId): Fact {
val user = userRepository.getUserById(id)
return if (user == null) {
val userFromService = userClient.getUser(id)
userRepository.insertUser(userFromService)
getFact(userFromService)
} else {
factRepository.getFactByUserId(id) ?: getFact(user)
}
}
private fun getFact(user: User): Fact {
val fact = factClient.getFact(user)
factRepository.insertFact(fact)
return fact
}
}
这里没有什么花哨的,只是你正常的、老套的代码:
fun main(args: Array<String>) {
fun execute(userService: UserService, id: Int) {
val (fact, time) = inTime {
userService.getFact(id)
}
println("fact = $fact")
println("time = $time ms.")
}
val userClient = MockUserClient()
val factClient = MockFactClient()
val userRepository = MockUserRepository()
val factRepository = MockFactRepository()
val userService = SynchronousUserService(userClient,
factClient,
userRepository,
factRepository)
execute(userService, 1)
execute(userService, 2)
execute(userService, 1)
execute(userService, 2)
execute(userService, 3)
execute(userService, 4)
execute(userService, 5)
execute(userService, 10)
execute(userService, 100)
}
我们执行UserService.getFact
方法 10 次以预热 JVM(JVM 优化使得应用程序在一段时间后运行得更快)。不用说,执行时间是 1,600 毫秒,这里没有惊喜。
回调
异步代码的一种流行风格是在单独的线程中执行代码,当上述线程完成其执行时调用callback
函数。回调风格的一个缺点是我们的异步函数现在需要一个额外的参数。在 Kotlin 的 lambda 支持下,回调风格在 Kotlin 中很容易编写。
对于我们的回调实现,我们需要为我们的客户端和仓库提供适配器:
import kotlin.concurrent.thread
class CallbackUserClient(private val client: UserClient) {
fun getUser(id: Int, callback: (User) -> Unit) {
thread {
callback(client.getUser(id))
}
}
}
class CallbackFactClient(private val client: FactClient) {
fun get(user: User, callback: (Fact) -> Unit) {
thread {
callback(client.getFact(user))
}
}
}
class CallbackUserRepository(private val userRepository: UserRepository) {
fun getUserById(id: UserId, callback: (User?) -> Unit) {
thread {
callback(userRepository.getUserById(id))
}
}
fun insertUser(user: User, callback: () -> Unit) {
thread {
userRepository.insertUser(user)
callback()
}
}
}
class CallbackFactRepository(private val factRepository: FactRepository) {
fun getFactByUserId(id: Int, callback: (Fact?) -> Unit) {
thread {
callback(factRepository.getFactByUserId(id))
}
}
fun insertFact(fact: Fact, callback: () -> Unit) {
thread {
factRepository.insertFact(fact)
callback()
}
}
}
这些适配器在单独的线程中执行我们的代码,并在完成后调用回调,lambda:
class CallbackUserService(private val userClient: CallbackUserClient,
private val factClient: CallbackFactClient,
private val userRepository: CallbackUserRepository,
private val factRepository: CallbackFactRepository) : UserService {
override fun getFact(id: UserId): Fact {
var aux: Fact? = null
userRepository.getUserById(id) { user ->
if (user == null) {
userClient.getUser(id) { userFromClient ->
userRepository.insertUser(userFromClient) {}
factClient.get(userFromClient) { fact ->
factRepository.insertFact(fact) {}
aux = fact
}
}
} else {
factRepository.getFactByUserId(id) { fact ->
if (fact == null) {
factClient.get(user) { factFromClient ->
factRepository.insertFact(factFromClient) {}
aux = factFromClient
}
} else {
aux = fact
}
}
}
}
while (aux == null) {
Thread.sleep(2)
}
return aux!!
}
}
回调风格往往非常晦涩难懂;当多个回调嵌套时,情况更糟(在社区中亲切地称为回调地狱)。结尾的while
循环中使用Thread.sleep
看起来非常笨拙。它的执行速度也非常快,耗时 1,200 毫秒,但创建了大量的线程,内存消耗与之相匹配。
每个函数调用创建一个线程的回调实现将很快在生产场景中消耗掉应用程序的所有资源;因此,它应该基于某种Executor
实现或类似的东西。
Java 未来
由于回调风格难以维护,近年来出现了其他风格。其中一种风格是未来。未来是指可能在将来完成的计算。当我们调用Future.get
方法时,它将获取其结果,但我们也会阻塞线程:
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
class FutureUserService(private val userClient: UserClient,
private val factClient: FactClient,
private val userRepository: UserRepository,
private val factRepository: FactRepository) : UserService {
override fun getFact(id: UserId): Fact {
val executor = Executors.newFixedThreadPool(2)
val user = executor.submit<User?> { userRepository.getUserById(id) }.get()
return if (user == null) {
val userFromService = executor.submit<User> { userClient.getUser(id) }.get()
executor.submit { userRepository.insertUser(userFromService) }
getFact(userFromService, executor)
} else {
executor.submit<Fact> {
factRepository.getFactByUserId(id) ?: getFact(user, executor)
}.get()
}.also {
executor.shutdown()
}
}
private fun getFact(user: User, executor: ExecutorService): Fact {
val fact = executor.submit<Fact> { factClient.getFact(user) }.get()
executor.submit { factRepository.insertFact(fact) }
return fact
}
}
使用未来的实现与我们的同步实现非常相似,但到处都是那些奇怪的submit
和get
函数。我们还有一个需要关注的Executor
。总时间大约是 1,200 毫秒,创建了大量的线程,比回调示例中的更多。一个可能的选项是每个实例或全局地有一个Executor
,但在这种情况下,我们还需要有某种方式来管理其生命周期。
Kovenant 中的承诺
编写异步代码的另一种选择是使用承诺。承诺与未来类似(在许多框架中,未来和承诺是同义的),因为它代表了一个可能在将来完成的计算。我们有一个阻塞方法来获取其结果,但我们也可以以回调风格对其结果做出反应。
Kovenant (kovenant.komponents.nl/
) 是 Kotlin 中对承诺的实现:
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.task
import nl.komponents.kovenant.then
class PromiseUserService(private val userClient: UserClient,
private val factClient: FactClient,
private val userRepository: UserRepository,
private val factRepository: FactRepository) : UserService {
override fun getFact(id: UserId): Fact {
return (task {
userRepository.getUserById(id)
} then { user ->
if (user == null) {
task {
userClient.getUser(id)
} success { userFromService ->
userRepository.insertUser(userFromService)
} then { userFromService ->
getFact(userFromService).get()
}
} else {
task { factRepository.getFactByUserId(id) ?: getFact(user).get() }
}
}).get().get()
}
private fun getFact(user: User): Promise<Fact, Exception> = task {
factClient.getFact(user)
} success { fact ->
factRepository.insertFact(fact)
}
}
函数 task
创建 Promise<T, Exception>
(在我们之前的其他实现中未涉及)。我们可以以几种方式与 Promise<T, Exception>
交互:
-
get(): T
:这会阻塞当前线程并返回 promise 的结果。 -
then(bind: (T) -> R): Promise<R, Exception>
:这与函数式集合上的map
类似;它返回一个具有新类型的新Promise
值。 -
success(callback: (T) -> Unit): Promise<T, Exception>
:这是在Promise
执行成功时的回调。它对于副作用很有用。 -
fail(callback: (Exception) -> Unit): Promise<T, Exception>
:这是在失败时的回调,类似于catch
块。 -
always(callback: () -> Unit): Promise<T, Exception>
:这总是执行,就像finally
块一样。
代码一开始看起来很难理解,但一旦习惯了 promise 习惯用法,阅读起来就很容易。此外,请注意,promise 是一个未来,因此你可以编写类似于我们未来示例的代码,但不需要与 Executors
打扰。Java 8 包含一种名为 CompletableFuture<T>
的新类型未来,它可以被视为一个 promise。
首次执行(Kovenant 初始化阶段)的执行时间约为 1,350 毫秒,然后稳定在 1,200 毫秒左右。在默认配置下,Kovenant 尽可能使用尽可能多的线程,导致内存使用量很高,但 Kovenant 可以微调以使用更少的线程。
协程
现在,让我们用协程重新整理我们的示例:
import kotlinx.coroutines.experimental.Deferred
import kotlinx.coroutines.experimental.async
import kotlinx.coroutines.experimental.launch
import kotlinx.coroutines.experimental.runBlocking
class CoroutineUserService(private val userClient: UserClient,
private val factClient: FactClient,
private val userRepository: UserRepository,
private val factRepository: FactRepository) : UserService {
override fun getFact(id: UserId): Fact = runBlocking {
val user = async { userRepository.getUserById(id) }.await()
if (user == null) {
val userFromService = async { userClient.getUser(id) }.await()
launch { userRepository.insertUser(userFromService) }
getFact(userFromService)
} else {
async { factRepository.getFactByUserId(id) ?: getFact(user) }.await()
}
}
private suspend fun getFact(user: User):Fact {
val fact: Deferred<Fact> = async { factClient.getFact(user) }
launch { factRepository.insertFact(fact.await()) }
return fact.await()
}
}
我们代码的简洁性比我们的 Future
示例更接近,几乎接近我们的同步代码。我们在上一节中介绍了 runBlocking
和 launch
,但在这里引入了一个新的协程构建器,async
。
async
协程构建器接收一段代码块并异步执行它,返回 Deferred<T>
。Deferred
是一个带有 await
方法的 Future
,它会在协程完成前阻塞协程但不会阻塞线程;Deferred
还从 Job
继承,因此继承了所有它的方法,例如 join
。
协程代码感觉自然,当我们使用异步代码时又很明确,但由于资源消耗低,我们可以在代码中使用尽可能多的协程;例如,CoroutineUserService
比其他任何实现使用的线程和内存都少一半。
现在我们有了所有实现,我们可以比较代码复杂度和资源消耗:
代码复杂度 | 资源消耗 | |
---|---|---|
同步 | 代码复杂度非常低。 | 资源消耗非常低,但性能较慢。 |
回调 | 需要非常高的适配器;预期会有重复;嵌套回调难以阅读;并且有各种技巧。 | 资源消耗很高。使用共享 Executor 可以提高效率,但会增加代码复杂度。 |
Futures | 代码复杂度中等。Executors 和 get() 很吵,但仍然可读。 |
资源消耗高,但可以通过不同的 Executor 实现和共享执行器进行微调,但这会增加代码复杂度。 |
承诺 | 使用承诺风格(then 、success )时,代码复杂度中等。使用 futures 风格(get ),它可以像协程一样流畅,而不会影响性能。 |
资源消耗非常高,具有顶级性能,但可以通过不更改代码的方式进行微调。 |
协程 | 代码复杂度低;与同步风格相同的大小,具有显式的异步操作块。 | 资源消耗低,具有出色的性能。 |
总体而言,协程是明显的赢家,Kovenant 的承诺紧随其后。
协程上下文
协程总是在上下文中运行。所有协程构建器默认指定上下文,并且该上下文可以通过协程体内的 coroutineContext
值访问:
import kotlinx.coroutines.experimental.*
fun main(args: Array<String>) = runBlocking {
println("run blocking coroutineContext = $coroutineContext")
println("coroutineContext[Job] = ${coroutineContext[Job]}")
println(Thread.currentThread().name)
println("-----")
val jobs = listOf(
launch {
println("launch coroutineContext = $coroutineContext")
println("coroutineContext[Job] = ${coroutineContext[Job]}")
println(Thread.currentThread().name)
println("-----")
},
async {
println("async coroutineContext = $coroutineContext")
println("coroutineContext[Job] = ${coroutineContext[Job]}")
println(Thread.currentThread().name)
println("-----")
},
launch(CommonPool) {
println("common launch coroutineContext = $coroutineContext")
println("coroutineContext[Job] = ${coroutineContext[Job]}")
println(Thread.currentThread().name)
println("-----")
},
launch(coroutineContext) {
println("inherit launch coroutineContext = $coroutineContext")
println("coroutineContext[Job] = ${coroutineContext[Job]}")
println(Thread.currentThread().name)
println("-----")
}
)
jobs.forEach { job ->
println("job = $job")
job.join()
}
}
每个协程上下文还包括 CoroutineDispatcher
,它决定了协程在哪个线程上运行。例如,async
和 launch
这样的协程构建器默认使用 DefaultDispatcher
分派器(在当前协程版本 0.2.1 中,DefaultDispatcher
等于 CommonPool
;然而,这种行为在未来可能会改变)。
协程上下文还可以持有值;例如,你可以通过使用 coroutineContext[Job]
来恢复协程的工作。
协程上下文可以用来控制其子协程。我们的 100 万个协程示例可以被重新设计,以便将所有子协程连接起来:
fun main(args: Array<String>) = runBlocking {
val job = launch {
repeat(1_000_000) {
launch(coroutineContext) {
delay(1000)
print('.')
}
}
}
job.join()
}
而不是每个百万个协程都有自己的上下文,我们可以设置一个共享的协程上下文,它实际上来自外部的 launch
协程上下文。当我们连接外部的 launch
作业时,它也会连接所有其协程子作业。
通道
两个协程之间通信(或者协程与外部世界通信,如 async
)的一种方式是通过 Deferred<T>
:
import kotlinx.coroutines.experimental.delay
import kotlinx.coroutines.experimental.launch
import kotlinx.coroutines.experimental.runBlocking
fun main(args: Array<String>) = runBlocking {
val result = CompletableDeferred<String>()
val world = launch {
delay(500)
result.complete("World (from another coroutine)")
}
val hello =launch {
println("Hello ${result.await()}")
}
hello.join()
world.join()
}
对于单个值,延迟调用(Deferreds)是可行的,但有时我们希望发送一个序列或流。在这种情况下,我们可以使用 Channel
。Channel
类似于 BlockingQueue
,但使用的是挂起操作而不是阻塞操作,而且 Channel
可以被 close
:
import kotlinx.coroutines.experimental.channels.*
fun main(args: Array<String>) = runBlocking<Unit> {
val channel = Channel<String>()
val world = launch {
delay(500)
channel.send("World (from another coroutine using a channel)")
}
val hello = launch {
println("Hello ${channel.receive()}")
}
hello.join()
world.join()
}
让我们用通道的方式编写我们的 100 万个协程示例,如下所示:
fun main(args: Array<String>) = runBlocking<Unit> {
val channel = Channel<Char>()
val jobs = List(1_000_000) {
launch {
delay(1000)
channel.send('.')
}
}
repeat(1_000_000) {
print(channel.receive())
}
jobs.forEach { job -> job.join() }
}
当然,这不是通道的预期用途。通常,单个协程(或多个)向通道发送消息:
fun main(args: Array<String>) = runBlocking<Unit> {
val channel = Channel<Char>()
val sender = launch {
repeat(1000) {
delay(10)
channel.send('.')
delay(10)
channel.send(',')
}
channel.close()
}
for (msg in channel) {
print(msg)
}
sender.join()
}
通道本身就是一个迭代器,因此它可以在 for
循环中使用。
编写此代码的一个更简单的方法是使用 produce
构建器,如下所示:
fun dotsAndCommas(size: Int) = produce {
repeat(size) {
delay(10)
send('.')
delay(10)
send(',')
}
}
fun main(args: Array<String>) = runBlocking<Unit> {
val channel = dotsAndCommas(1000)
for (msg in channel) {
print(msg)
}
}
produce
构建器返回 ReceiveChannel<T>
,这是一种仅用于接收的通道类型。Channel<T>
同时扩展了 SendChannel<T>
和 ReceiveChannel<T>
这两种类型。
通道管道
当我们有通道时,我们可以有相关的模式,例如管道。一个 pipeline 是一系列连接消费者和生产者的通道,类似于 Unix 管道或 企业集成模式(EIP)。
让我们使用 EIPs 编写自己的销售系统。首先,让我们看看模型:
data class Quote(val value: Double, val client: String, val item: String, val quantity: Int)
data class Bill(val value: Double, val client: String)
data class PickingOrder(val item: String, val quantity: Int)
现在,让我们看看模式:
import kotlinx.coroutines.experimental.CoroutineContext
fun calculatePriceTransformer(coroutineContext: CoroutineContext, quoteChannel: ReceiveChannel<Quote>) = produce(coroutineContext) {
for (quote in quoteChannel) {
send(Bill(quote.value * quote.quantity, quote.client) to PickingOrder(quote.item, quote.quantity))
}
}
calculatePriceTransformer
函数从通道接收报价并将其转换为 Pair<Bill, PickingOrder>
:
fun cheapBillFilter(coroutineContext: CoroutineContext, billChannel: ReceiveChannel<Pair<Bill, PickingOrder>>) = produce(coroutineContext) {
billChannel.consumeEach { (bill, order) ->
if (bill.value >= 100) {
send(bill to order)
} else {
println("Discarded bill $bill")
}
}
}
cheapBillFilter
函数很好地过滤了低于 100
的 bill
值:
suspend fun splitter(filteredChannel: ReceiveChannel<Pair<Bill, PickingOrder>>,
accountingChannel: SendChannel<Bill>,
warehouseChannel: SendChannel<PickingOrder>) = launch {
filteredChannel.consumeEach { (bill, order) ->
accountingChannel.send(bill)
warehouseChannel.send(order)
}
}
splitter
将 Pair<Bill, PickingOrder>
分割成各自的通道:
suspend fun accountingEndpoint(accountingChannel: ReceiveChannel<Bill>) = launch {
accountingChannel.consumeEach { bill ->
println("Processing bill = $bill")
}
}
suspend fun warehouseEndpoint(warehouseChannel: ReceiveChannel<PickingOrder>) = launch {
warehouseChannel.consumeEach { order ->
println("Processing order = $order")
}
}
accountingEndpoint
和 warehouseEndpoint
都通过打印处理它们各自的消息,但在实际场景中,我们可以将这些消息存储到数据库中,发送电子邮件或使用 JMS、AMQP 或 Kafka 向其他系统发送消息:
fun main(args: Array<String>) = runBlocking {
val quoteChannel = Channel<Quote>()
val accountingChannel = Channel<Bill>()
val warehouseChannel = Channel<PickingOrder>()
val transformerChannel = calculatePriceTransformer(coroutineContext, quoteChannel)
val filteredChannel = cheapBillFilter(coroutineContext, transformerChannel)
splitter(filteredChannel, accountingChannel, warehouseChannel)
warehouseEndpoint(warehouseChannel)
accountingEndpoint(accountingChannel)
launch(coroutineContext) {
quoteChannel.send(Quote(20.0, "Foo", "Shoes", 1))
quoteChannel.send(Quote(20.0, "Bar", "Shoes", 200))
quoteChannel.send(Quote(2000.0, "Foo", "Motorbike", 1))
}
delay(1000)
coroutineContext.cancelChildren()
}
main
方法组装我们的销售系统并对其进行测试。
可以使用协程通道实现许多其他通道消息模式,例如扇入、扇出和 actors
。我们将在下一节中介绍 actors
。
管理可变状态
当我们处理异步代码时,主要关注点(以及噩梦的来源)是如何处理可变状态。我们在第三章不可变性 - 它很重要中介绍了如何使用函数式风格减少可变状态。但有时使用函数式不可变风格是不可能的。协程为此问题提供了一些替代方案。
在以下示例中,我们将使用几个协程来更新一个计数器:
import kotlin.system.measureTimeMillis
suspend fun repeatInParallel(times: Int, block: suspend () -> Unit) {
val job = launch {
repeat(times) {
launch(coroutineContext) {
block()
}
}
}
job.join()
}
fun main(args: Array<String>) = runBlocking {
var counter = 0
val time = measureTimeMillis {
repeatInParallel(1_000_000) {
counter++
}
}
println("counter = $counter")
println("time = $time")
}
对于较小的数字,counter
是正确的,但一旦我们开始增加大小,我们就会看到奇怪的数字。
现在,我们可以看看协程为我们提供的替代方案。
切换上下文
我们的第一种选择是使用不同的上下文来更新操作:
import kotlinx.coroutines.experimental.*
fun main(args: Array<String>) = runBlocking {
var counter = 0
val counterContext = newSingleThreadContext("CounterContext")
val time = measureTimeMillis {
repeatInParallel(1_000_000) {
withContext(counterContext) {
counter++
}
}
}
println("counter = $counter")
println("time = $time")
}
withContext
函数在特定的协程上下文中执行一个块——在这种情况下,是单线程的。切换上下文是一种强大的技术,它让我们能够以细粒度的方式操纵代码的运行方式。
线程安全结构
从 Java 5 开始,我们可以访问一些原子线程安全的结构,这些结构在协程中仍然很有用:
import java.util.concurrent.atomic.AtomicInteger
fun main(args: Array<String>) = runBlocking {
val counter = AtomicInteger(0)
val time = measureTimeMillis {
repeatInParallel(1_000_000) {
counter.incrementAndGet()
}
}
println("counter = ${counter.get()}")
println("time = $time")
}
AtomicInteger
给我们许多线程安全的原子操作。还有更多线程安全的结构,如其他原子原语和并发集合。
互斥锁
一个 Mutex
(互斥锁)对象允许多个协程访问相同的资源,但不能同时访问:
import kotilnx.coroutines.experimental.sync.Mutex
import kotlinx.coroutines.experimental.sync.withLock
fun main(args: Array<String>) = runBlocking {
val mutex = Mutex()
var counter = 0
val time = measureTimeMillis {
repeatInParallel(1_000_000) {
mutex.withLock {
counter++
}
}
}
println("counter = $counter")
println("time = $time")
}
Mutex
对象的工作方式与同步控制结构类似,但它不是阻塞线程,而是阻塞协程。
Actors
actor
是一种与其它 actor
和外部世界通过消息交互的对象。一个 actor
对象可以有一个私有的内部可变状态,该状态可以通过消息修改和访问,但不能直接访问。由于它们一致的编程模型,演员在近年来越来越受欢迎,并在多百万用户的应用程序中成功进行了测试,例如用 Erlang 编写的 WhatsApp,这种语言使演员成为焦点:
import kotlinx.coroutines.experimental.channels.actor
sealed class CounterMsg
object IncCounter : CounterMsg()
class GetCounter(val response: CompletableDeferred<Int>) : CounterMsg()
fun counterActor(start:Int) = actor<CounterMsg> {
var counter = start
for (msg in channel) {
when (msg) {
is IncCounter -> counter++
is GetCounter -> msg.response.complete(counter)
}
}
}
要编写一个 actor
,首先,我们需要定义我们想要发送哪些消息。在这里,我们创建了两个消息,IncCounter
和 GetCounter
。GetCounter
有一个 CompletableDeferred<Int>
值,这将让我们知道 actor
外部的计数器值。
我们可以使用 actor<CounterMsg>
构建器来创建 actor
。在我们的 actor
协程内部,我们可以访问 channel
属性,即 ReceiveChannel<CounterMsg>
,以接收消息并对它们做出反应。counterActor(Int)
函数将返回 SendChannel<CounterMsg>
;因此,我们只能调用 send(CounterMsg)
和 close()
这两个函数:
fun main(args: Array<String>) = runBlocking {
val counterActor = counterActor(0)
val time = measureTimeMillis {
repeatInParallel(1_000_000) {
counterActor.send(IncCounter)
}
}
val counter = CompletableDeferred<Int>()
counterActor.send(GetCounter(counter))
println("counter = ${counter.await()}")
println("time = $time")
}
最初,演员(Actors)可能难以理解,但一旦你理解了,actor
模型对于创建复杂和强大的系统来说非常直接。
在本书的示例代码中,你可以找到一个使用 actors
实现的 UserService
示例。你可以在网上观看它:github.com/MarioAriasC/FunctionalKotlin/blob/master/Chapter07/src/main/kotlin/com/packtpub/functionalkotlin/chapter07/facts.kt#L377
。
摘要
协程显示出改变我们思考异步代码和执行方式的高潜力。在本章中,我们介绍了如何编写协程以及如何使用协程上下文和通道。我们还全面探讨了如何处理异步共享可变状态。
在我们下一章中,我们将学习函数式集合及其操作。
第八章:Kotlin 中的集合和数据操作
在前面的章节中,我们已经涵盖了广泛的主题,从 Kotlin 中的数据类型、类和对象开始,到上一章中的不可变性、函数、委托和协程。在本章中,我们将讨论 Kotlin 中的集合框架和数据操作。Kotlin 从 Java 继承了集合框架,但在函数式编程方面与之有显著的不同。
Kotlin 提供的集合框架比 Java 更函数式,作为 Kotlin 的签名,它更容易使用和理解。
我们将从这个章节的集合基础知识开始,然后逐渐过渡到 Kotlin 中集合支持的数据操作。以下是本章将要涵盖的主题列表:
-
集合简介
-
Iterator
和Iterable
接口 -
Kotlin 中的集合类型—
Array
、List
、Map
和Set
-
可变性和不可变性
-
与列表一起工作
-
各种数据操作—
map
、sort
、filter
、flatMap
、partition
、fold
和group by
那么,我们还在等什么呢?让我们开始学习集合。
集合简介
集合框架是一组类和接口,它提供了一种统一的架构来执行常见的数据相关操作,如下所示:
-
搜索
-
排序
-
插入
-
删除
-
操作
我们在日常生活中使用的所有列表、映射和集合都是这个集合框架的一部分。
所有集合框架都包含以下内容:
-
接口:这些是抽象数据类型,用于表示集合。接口允许独立于其表示的细节来操作集合。在面向对象的语言中,这些通常形成层次结构。
-
实现:这些是实现接口集合的具体实现。本质上,这些是可重用的数据结构。
-
算法:执行有用计算的方法,例如搜索和排序(如前所述),在实现集合接口的对象上。这些算法被称为 多态。同一个方法可以用于许多不同实现的相关集合接口。简而言之,算法是可重用的功能。
除了 Java 和 Kotlin 集合框架之外,最著名的集合框架示例是 C++ 标准模板库(STL)和 Smalltalk 的集合层次结构。
集合框架的优势
那么,拥有集合框架有什么好处呢?有几个好处,但最重要的是,它减少了编程时间和努力。集合框架为开发者提供了高质量(在性能和代码优化方面)的有用数据结构和算法的实现,同时提供了与无关 API 之间的互操作性。您可以在程序中使用这些实现,从而减少编程努力和时间。
因此,既然我们已经了解了集合框架是什么,那么现在让我们来看看集合框架中类和接口的层次结构。
那么,让我们看一下以下图:
正如我们之前提到的,集合框架是一组数据类型和类,它使我们能够处理一组(或几组)数据。这组数据可能是一个简单的列表/映射/集合,或者任何其他数据结构。
上述图表示了 Kotlin 的集合框架。就像 Java 一样,Kotlin 中的所有集合接口都起源于Iterable
接口。然而,Kotlin 的集合框架与 Java 的不同;Kotlin 区分可变和不可变集合。
Kotlin 有两个基本集合接口,即Iterable
和MutableIterable
。Iterable
接口被Collection
接口扩展,该接口定义了基本的只读集合操作(如size
、isEmpty()
、contains()
等)。
MutableCollection
接口扩展了Collection
接口和MutableIterable
接口,增加了读写特性。
在 Java 引入集合框架之前,开发者通常使用数组、向量以及哈希表来处理一组数据。这种方法的缺点是它们都没有一些共同的方法。因此,集合框架被创建出来,通过提供跨各种类型集合的通用方法和操作,使开发者的生活变得更简单。
集合框架是在 Kotlin 语言形成之前在 Java 中引入的,并且从一开始就被包含在 Kotlin 中。
您难道不好奇为什么有这么多集合类型吗?让我们在接下来的章节中介绍它们时,找出一些最常用集合类型的目的。
List 和 MutableList
List 是使用最广泛的集合数据类型之一。它是一个Collection
接口的实现,用于处理一组有序数据。
列表中的数据可以根据添加的顺序进行排序(例如,如果我们将3
添加到Int List
中,那么4
将出现在列表中,在3
之前,就像数组一样)或者甚至可以根据其他排序算法进行排序。
正如我们之前提到的,Kotlin 区分可变和只读集合类型;因此,只读的List
接口只包含只读函数,如下所示:
-
fun get(index: Int):E
: 此方法用于从给定索引的列表中获取元素。 -
fun indexOf(element: @UnsafeVariance E):Int
: 此方法用于识别列表中元素的索引。此方法将在整个列表中搜索指定的元素,如果它在列表中,则返回元素的位位置。否则,它将返回-1
。 -
fun listIterator(): ListIterator<E>
: 如果您想获取ListIterator
的实例(这将在本章后面讨论,当我们讨论Iterator
和Iterable
时)。 -
fun subList(fromIndex: Int, toIndex: Int): List<E>
: 返回具有指定fromIndex
和toIndex
值的列表的一部分。
考虑到这一点,它只包含只读函数,我们如何有一个包含数据的列表?虽然您不能在创建后向不可变列表中添加数据,但您当然可以创建一个预填充数据的不可变列表(显然,否则拥有不可变列表就没有任何意义了)。您可以通过多种方式实现这一点,但最流行的方式是使用listOf
函数。
listOf
函数声明如下(可以在Collections.kt
中的kotlin.collections
包内找到):
public fun <T> listOf(vararg elements: T): List<T>
正如我们在函数声明中看到的,该函数接受一个泛型类型的vararg
参数作为元素;函数将返回一个包含这些元素的list
实例。正如您已经知道的,vararg
参数的重要性在于它可以包含 0 到几乎 64K 个参数(如果每个参数是 1 字节,一个函数可以有最大 64K 字节的分配,所以实际上会更少);因此,在用listOf
函数创建list
时,您甚至可以不传递任何参数来创建一个空列表,或者传递尽可能多的参数(假设您不需要超过 64K 字节)来创建包含这些参数的只读list
。
以下程序是listOf
函数的一个示例:
fun main(args: Array<String>) {
val list = listOf<Int>(1,2,3,4,5,6,7,8,9,10)
for (i in list) {
println("Item $i")
}
}
在前面的程序中,我们创建了一个包含数字1
到10
的list
值。然后我们使用for
循环遍历list
值中的每个元素并打印它。
让我们看一下以下输出以验证这一点:
for
循环花括号内的i in list
告诉for
循环遍历list
值中的所有元素,并将每个迭代中的元素复制到临时变量i
。
我们将在本章后面部分探讨更多与集合操作的方法,但首先让我们学习不同类型的集合。
因此,继续我们关于列表的讨论,我们已经看到了如何使用预定义元素创建不可变列表;现在,我们将探讨如何创建和操作可变列表,但在那之前,让我们看看创建空列表的方法。
让我们逐一分析以下程序:
fun main(args: Array<String>) {
val emptyList1 = listOf<Any>() val emptyList2 = emptyList<Any>()
println("emptyList1.size = ${emptyList1.size}")
println("emptyList2.size = ${emptyList2.size}")
}
因此,在前面的程序中,我们创建了空列表,一个使用不带参数的listOf
函数,另一个使用emptyList
函数。请注意,如果listOf
函数不带任何参数调用,则它内部调用emptyList
函数。
以下是输出截图:
因此,我们已经看到了如何使用预定义的一组元素与不可变列表一起工作,但如果我们需要动态地向list
值中添加项目怎么办?Kotlin 为此提供了可变列表。
以下示例将帮助您理解不可变列表:
fun main(args: Array<String>) {
val list = mutableListOf(1,2,4)//(1)
for (i in list) {
println("for1 item $i")
}
println("-----Adding Items-----")
list.add(5)//(2) list.add(2,3)//(3) list.add(6)//(4)
for (i in list) {
println("for2 item $i")
}
}
以下是程序的输出:
现在,让我们解释一下程序。首先,我们在注释(1)
处使用mutableListOf
函数创建了list
值,其中包含项目1
、2
和4
。请注意,这里我们跳过了类型参数,如果您将元素传递给函数,则这不是很重要,因为 Kotlin 有类型推断。我们在添加项目之前打印了list
值。
对于listOf
或其他任何集合函数,类型推断是一个问题。因此,如果您传递了元素或已提供集合的类型,则不需要指定正在使用的集合的泛型类型。
在注释(2)
处,我们使用List$add()
函数向list
中添加了项目5
,该函数将提供的项目追加到list
数组中。
然后,在注释(3)
处,我们使用带有索引参数的add
函数将项目4
添加到第二个位置(从0
开始计数,如通常一样)。
然后,我们又用5
向list
数组中添加了元素。
因此,我们在list
数组中添加了元素,并通过for
循环访问所有项目,但如何访问单个元素呢?让我们通过以下示例来了解如何在 Kotlin 中访问和修改单个元素。请看以下示例:
fun main(args: Array<String>) {
val list = listOf(
"1st Item",
"2nd Item",
"3rd Item",
"4th Item",
"5th Item"
)
println("3rd Item on the list - ${list.get(2)}")
println("4rd Item on the list - ${list[3]}")
}
我们使用索引2
访问了第三个元素,使用索引3
访问了第四个元素。原因很简单,因为在数组和列表中,计数从0
开始。
这里要注意的事情是,Kotlin 为列表提供了开箱即用的支持,并提供了一个方括号运算符([]
)来访问list
值的元素,就像数组一样。在第一个get
语句中,我们使用带有索引的get
函数来获取该索引的元素;在第二个get
语句中,我们使用了方括号,它反过来调用那个get
函数。
由于列表按顺序/索引存储项目,因此很容易通过索引从列表中获取项目;如果您只想从列表中获取特定元素并且知道该元素的索引,则可以轻松跳过循环。只需将元素传递给get
函数,您就有该元素了。
通过索引获取元素不支持其他集合接口,如set
(尽管OrderedSet
支持它们),它不支持元素的排序。
因此,既然我们已经对列表有了些许了解,让我们继续前进,看看集合。
集合和 MutableSet
就像 List
一样,Set
在 Kotlin 中也有以下两种变体:
-
Set
-
MutableSet
Set
是只读的,而 MutableSet
是 Set
的可变版本,它包含读写功能。
就像列表一样,集合的值也有只读函数和属性,如 size
、iterator()
等。我们在这里省略了它们的提及,以避免在这本书中重复内容。请注意,集合不像列表那样进行排序(除非你使用 OrderedSet
)。因此,它缺少涉及排序的函数,如 indexOf(item)
、add(index, item)
等。
集合中的集合表示数学集合(如集合论中的集合)。
以下是一个使用 MutableSet
的示例:
fun main(args: Array<String>) {
val set = mutableSetOf(1,2,3,3,2)
println("set $set")
set.add(4)
set.add(5)
set.add(5)
set.add(6)
println("set $set")
}
以下是输出:
输出清楚地显示,尽管我们在初始化和之后都向 set
中添加了多个重复项,但只有唯一的项被插入,所有重复的项都被忽略了。
现在,你可能很好奇,这与自定义类和数据类会发生什么;让我们通过以下示例来检查:
data class MyDataClass (val someNumericValue:Int, val someStringValue:String)
class MyCustomClass (val someNumericValue:Int, val someStringValue:String) {
override fun toString(): String {
return "MyCustomClass(someNumericValue=$someNumericValue, someStringValue=$someStringValue)"
}
}
fun main(args: Array<String>) {
val dataClassSet = setOf(
MyDataClass(1,"1st obj"),
MyDataClass(2,"2nd obj"),
MyDataClass(3,"3rd obj"),
MyDataClass(2,"2nd obj"),
MyDataClass(4,"4th obj"),
MyDataClass(5,"5th obj"),
MyDataClass(2,"will be added"),
MyDataClass(3,"3rd obj")
)
println("Printing items of dataClassSet one by one")
for(item in dataClassSet) {
println(item)
}
val customClassSet = setOf(
MyCustomClass(1,"1st obj"),
MyCustomClass(2,"2nd obj"),
MyCustomClass(3,"3rd obj"),
MyCustomClass(2,"2nd obj"),
MyCustomClass(4,"4th obj"),
MyCustomClass(5,"5th obj"),
MyCustomClass(5,"5th Obj"),
MyCustomClass(3,"3rd obj")
)
println("Printing items of customClassSet one by one")
for(item in customClassSet) {
println(item)
}
}
在这个程序中,我们首先创建了一个数据类和一个自定义类,然后使用它们创建了集合并插入了重复项。
让我们查看以下输出以检查集合是否没有重复项:
仔细查看前面的输出。虽然与数据类的情况一样,set
忽略了重复项,但在尝试与普通类做同样的事情时,它无法检测到重复插入并保留了它们。
在 dataClassSet
中最后添加的项是 MyDataClass(2,"will be added")
,如果你认为它是一个重复项,那么请再次检查,尽管这个对象的 someNumericValue
值与之前的相同,但 someStringValue
值与之前对象的 someStringValue
不同。
为什么这是一个异常?答案是简单明了——集合框架在向 set
值添加项时内部使用 hashCode()
和 equals()
函数来执行相等性检查,而自定义类中缺少这些函数。
在 Kotlin 中,编译器会自动提取 hashCode()
和 equals()
函数。因此,set
值能够区分重复项,而无需自定义这些函数的实现。有关数据类的更多信息,请访问以下链接:kotlinlang.org/docs/reference/data-classes.html
因此,如果我们实现这些函数,那么 set
也将能够区分 customClassSet
值中的重复项。显然,这对于数据类也是这样工作的。只需将以下代码添加到 MyCustomClass
定义中并运行程序,自己看看效果:
override fun hashCode() = someStringValue.hashCode()+someNumericValue.hashCode()
override fun equals(other: Any?): Boolean {
return other is MyCustomClass && other.someNumericValue == someNumericValue && other.someStringValue==someStringValue
}
很酷,不是吗?所以,我们已经完成了List
和Set
。现在让我们看看Map
接口;然后,我们将讨论集合框架提供的数据操作函数。
Map 和 MutableMap
集合框架中的Map
接口与其他所有我们之前覆盖的接口略有不同;与其他接口不同,它使用键值对。不,这并不类似于Pair
;Pair
只是两个值组合在一起,而映射是一组键值对。
在映射中,键是唯一的,不能重复。如果你添加两个具有相同键的值,那么后面的值将替换前面的值。另一方面,值可以是冗余的/重复的。这种行为背后的原因是,在映射中,值是根据其键存储和检索的,因此冗余的键将使得无法区分它们,也无法获取它们的值。
Kotlin 中Map
的声明读起来像接口Map<K, out V>
,K
值是键的泛型类型,而V
是值的泛型类型。
要了解更多关于集合的信息,让我们看看一些函数和属性。查看以下列表:
-
val size: Int
: 这个函数表示Map
接口的大小,即映射中驻留的键值对数量。 -
fun isEmpty(): Boolean
: 这个函数有助于检查Map
接口是否为空。 -
fun containsKey(key: K): Boolean
: 这个函数检查提供的key
是否在它拥有的键值对集合中,如果找到则返回true
。 -
operator fun get(key: K): V?
: 这个函数兼操作符(如果像数组一样使用方括号[]
)返回与键对应的值,如果键不存在则返回 null。 -
val keys: Set<K>
: 这个函数表示在该映射的某个时间点可用的键集合。由于键不能重复且无序,Set
值是存储它们的最佳数据结构。 -
val values: Collection<V>
: 包含map
值的所有值作为一个集合。 -
interface Entry<out K, out V>
: 这个函数定义在Map
接口内部。Entry
表示Map
接口中的单个键值对。键值对作为条目存储在map
值中。 -
val entries: Set<Map.Entry<K, V>>
: 这个函数可以获取映射中的所有条目。
之前的都是只读接口的Map
,因为它只支持只读操作。对于读写访问,你必须使用mutableMap
函数。因此,现在让我们看看mutableMap
提供的读写接口,如下所示:
-
fun put(key: K, value: V): V?
: 这个接口向Map
添加键值对,并返回与键关联的先前值(如果有,则为 null,如果键之前不在Map
中)。 -
fun remove(key: K): V?
:此接口从Map
接口中删除具有键的键值对,并返回值。如果键不存在于Map
接口中,则返回 null。 -
fun putAll(from: Map<out K, V>): Unit
:此接口从提供的map
值添加键值对。 -
fun clear(): Unit
:正如其名所示,此实例清除map
值。它移除map
值包含的每一项——每个键和每个值。
因此,既然我们现在知道了 Map
接口提供的接口和函数,让我们现在通过一个 Map
的例子来展示。
让我们通过以下示例进行说明:
fun main(args: Array<String>) {
val map = mapOf(
"One".to(1),
"Two".to(2),
"Three".to(3),
"Four".to(4),
"Five".to(0),//(1) We placed 0 instead of 5 here, will be replaced later
"Six".to(6),
"Five".to(5)//(2) This will replace earlier map of "Five".to(0)
)
println("The value at Key `Four` is ${map["Four"]}")
println("Contents in map")
for(entry in map) {
println("Key ${entry.key}, Value ${entry.value}")
}
val mutableMap = mutableMapOf<Int,String>()
mutableMap.put(1,"Item 1")
mutableMap.put(2,"Item 2")
mutableMap.put(3,"Item 3")
mutableMap.put(4,"Item 4")
println("Replacing value at key 1 - ${mutableMap.put(1,"Item 5")}")//(3)
println("Contents in mutableMap")
for(entry in mutableMap) {
println("Key ${entry.key}, Value ${entry.value}")
}
}
因此,我们展示了以下两种类型映射的使用:
-
只读
Map
-
可读写
MutableMap
Kotlin 为你提供了一个接受 Pair
类型的 vararg
参数的 mapOf()
函数版本。这使得你可以轻松地创建只读映射——只需将键值对作为 Pair
实例传递给 mapOf()
函数。
在进一步检查和讨论程序之前,让我们看看输出。请看以下截图:
在创建地图时,在注释 (1)
中,我们传递了一个 "Five".to(0)
对,在注释 (2)
中,我们传递了 "Five".to(5)
对到同一个 mapOf
函数,以检查 map
为 "Five"
键存储的值;输出表明 map
采用了第二个值——5
,正如我们之前所描述的,map
的值总是取相同键的最后一个值。
还要注意,Kotlin 还支持在 Map
中使用类似数组的方括号。你可以传递键而不是索引。
因此,当我们熟悉了 Kotlin 集合框架中的三个最重要的接口:List
、Set
和 Map
后,现在让我们继续前进,了解集合中的数据操作。
集合中的数据操作
Kotlin 为其集合框架提供了开箱即用的支持。因此,Kotlin 的集合框架充满了有趣的功能,使其与其他语言的集合框架(如 Java)区别开来。你已经接触到了其中的一些功能,例如为只读和可变集合提供单独的接口、类似方框操作符的数组等。我现在要介绍的是 Kotlin 集合框架中最有趣的功能之一,但通常被忽视——数据操作函数。
Kotlin 支持其所有集合框架接口、对象和类的数据操作函数。数据操作函数是指我们可以通过它们访问、处理或操作集合中的数据的操作符和函数;如果你熟悉 ReactiveX 框架/RxJava/RxKotlin,你会发现它们很相似,因为 Kotlin 主要从那里借鉴了它们。
下面是我们将要介绍的一些集合数据操作函数列表:
-
map
函数 -
filter
函数 -
flatMap
函数 -
drop
函数 -
take
函数 -
zip
函数
那么,我们还在等什么呢?让我们开始吧。
尽管如此,使用集合的数据操作函数会让你感觉像在使用流/Rx,但它们在本质上与流/Rx 没有任何相似之处。它们所做的只是使用高阶函数和扩展函数为你提供类似流的接口,并且在内部它们操作在相同的循环中(是的,你读对了,它们使用循环来产生结果,然后像简单的帝国程序一样从函数中返回它)。建议你在程序中避免使用这些函数的大链,因为最终你会得到多个循环。在这种情况下使用forEach
或你自己的循环是更好的选择,因为你可以使用forEach
或你自己的循环在一个循环中执行多个操作。然而,对于单个操作或小的链,你当然可以使用这些函数来使你的代码更有组织性。
地图函数
map
函数允许你将算法应用于整个集合,并作为结果集获得结果。这对于使你的代码更有组织性和编写循环很有帮助(尽管它会在内部使用循环,但你将免于编写那些样板代码)。
map
函数接收集合中的所有元素作为每次迭代的元素,并应返回应放置在结果列表中替代传递项的计算结果项。
以下是一个示例:
fun main(args: Array<String>) {
val list = listOf<Int>(1,2,3,4,5,6,7,8,9,10)
val modifiedList = list.map { it*2 }
println("modifiedList -> $modifiedList")
}
因此,我们有一个Int
列表,我们需要将列表中的每个项乘以2
,我们只需一行代码就能轻松完成——list.map { it*2 }
,这通常需要我们多两到三行样板代码。疯狂,不是吗?
以下是程序的输出:
如预期的那样,map
函数将提供的 lambda 函数应用于列表的每个元素,并返回结果列表。
filter
函数
想象一下你需要过滤集合中的项的情况。例如,当你想从整数列表中获取仅偶数时。filter
函数就是为了帮助你在这些场景中。
filter
函数接收集合中的所有元素作为每次迭代的元素,并应根据其确定是否应将传递的项放在结果列表中来返回true
或false
。
以下是一个程序示例:
fun main(args: Array<String>) {
val list = 1.until(50).toList()//(1)
val filteredListEven = list.filter { it%2==0 }//(2)
println("filteredListEven -> $filteredListEven")
val filteredListPSquare = list.filter {
val sqroot = sqrt(it.toDouble()).roundToInt()
sqroot*sqroot==it
}//(3)
println("filteredListPSquare -> $filteredListPSquare")
}
在这个程序中,我们首先使用IntRange
帮助获取一个包含从1
到50
数字的Int
列表。然后,我们在注释(2)
中过滤列表以获取偶数并打印它们。在注释(3)
中,我们过滤包含从1
到50
的Int
值的原始列表以获取完全平方数并打印它们。
以下是程序的输出:
之前的代码片段及其输出显示了这些数据操作函数可以消除多少样板代码。
平滑映射函数
集合框架中可用的另一个令人惊叹的函数是flatMap
函数。
就像map
函数一样,它将集合中的每个项目作为迭代接收,但与map
函数不同,它应该为传递的每个项目返回另一个集合。然后,这些返回的集合被组合起来创建结果集合。
看看以下示例:
fun main(args: Array<String>) {
val list = listOf(10,20,30)
val flatMappedList = list.flatMap {
it.rangeTo(it+2).toList()
}
println("flatMappedList -> $flatMappedList")
}
输出看起来如下:
尽管原始列表中只包含三个数字——10
、20
和30
,但结果列表中每个原始列表中的数字都增加了三个数字,这都要归功于flatMap
函数。
删除函数
可能存在一些场景,当你想要丢弃集合的一部分(比如说,前 5 个或最后 10 个)并处理剩余的部分时。Kotlin 的集合框架为你提供了一组drop
函数,这些函数可以帮助你在这些场景下。看看以下程序:
fun main(args: Array<String>) {
val list = 1.until(50).toList()
println("list.drop(25) -> ${list.drop(25)}")//(1)
println("list.dropLast(25) -> ${list.dropLast(25)}")//(2)
}
在前面的程序中,我们在注释(1)
中从列表中删除了前25
个项目,在注释(2)
中,我删除了最后25
个项目。
以下截图显示了程序的输出:
工作得完美,不是吗?
take
函数
take
函数与drop
函数的工作方式正好相反。你可以从集合中选择一部分,忽略其余部分。
看看以下程序:
fun main(args: Array<String>) {
val list = 1.until(50).toList()
println("list.take(25) -> ${list.take(25)}")//(1)
println("list.takeLast(25) -> ${list.takeLast(25)}")//(2)
println("list.takeWhile { it<=10 } -> ${list.takeWhile { it<=10 }}")//(3)
println("list.takeLastWhile { it>=40 } -> ${list.takeLastWhile { it>=40 }}")//(4)
}
而在注释(1)
和注释(2)
中的 while 语句与之前的drop
函数相反,它们只是从列表中取出并打印25
个项目。
注释(3)
中的语句有点不同,这里我们使用了takeWhile
函数。takeWhile
函数接受一个谓词,并在谓词返回true
时继续在结果集合中取项目;一旦谓词返回false
,takeWhile
值将停止检查更多项目,并返回结果集合。
takeLastWhile
值的工作方式与它相反。
以下是一个输出截图:
现在我们继续使用zip
函数。
zip
函数
zip
函数确实如其名所示,它将集合“压缩”在一起。令人困惑?让我们看看以下示例:
fun main(args: Array<String>) {
val list1 = listOf(1,2,3,4,5)
val list2 = listOf(
"Item 1",
"Item 2",
"Item 3",
"Item 4",
"Item 5"
)
val resultantList = list1.zip(list2)
println(resultantList)
}
我们创建了两张列表——一张是Int
类型的,另一张是String
类型的。然后我们通过将Int
列表与String
列表进行zip
操作创建了一个结果列表,并打印了这个结果列表。
那么,resultantList
值包含什么?zip
函数执行了什么操作?
让我们通过查看以下输出自行决定:
太棒了,不是吗?zip
函数接受另一个集合,将源集合与提供的集合组合起来,并为每个项目创建一个Pair
值。但如果集合的项目数量不同怎么办?如果我们想将列表中的每个项目与同一列表中的下一个项目组合起来呢?
让我们再举一个例子。看看以下代码:
fun main(args: Array<String>) {
val list1 = listOf(1,2,3,4,5,6,7,8)
val list2 = listOf(
"Item 1",
"Item 2",
"Item 3",
"Item 4",
"Item 5"
)
println("list1.zip(list2)-> ${list1.zip(list2)}")
println("list1.zipWithNext() -> ${list1.zipWithNext()}")
}
因此,这里的第一个println
语句回答了我们的第一个问题——它试图将两个项目数量不对称的列表组合起来。
在第二个println
语句中,我们使用了zipWithNext
函数,该函数将集合中的一个项目与同一集合的下一个项目进行压缩。那么,让我们看一下输出结果,以了解发生了什么。
以下为输出结果:
因此,zip
操作符仅将list1
中的那些项目与list2
中的配对项进行压缩,并跳过了剩余的项。另一方面,zipWithNext
操作符则按预期工作。
因此,我们已经完成了 Kotlin 集合框架中的数据操作函数。然而,Kotlin 为您提供了更多关于集合的能力;所以,让我们继续前进,看看它还能提供什么。
分组集合
Kotlin 的集合框架允许您根据需求对集合进行分组。例如,如果您有一个字符串列表,并希望根据它们的长度进行分组,您可以使用groupBy
函数轻松实现,该函数根据提供的逻辑对集合进行分组,并返回包含该组集合的Map
。
因此,以下是一个简短的示例:
fun main(args: Array<String>) {
val list = 1.rangeTo(50).toList()
println(list.groupBy { it%5 })
}
因此,我们在这里所做的是:我们创建了一个包含从1
到50
(包括两端)的数字的Int
列表,然后我们尝试根据它们除以5
的余数来分组它们。
因此,应该有五个组,从0
到5
,每个组都应该包含 10 个数字。让我们检查以下输出结果,看看这是否发生了。
因此,groupBy
函数按预期工作,并返回包含分组列表的Map<Int,List<Int>>
。
摘要
因此,本章是关于 Kotlin 中的集合和数据操作。我们通过探索 Kotlin 的集合框架和数据结构开始本章,然后逐渐转向学习 Kotlin 集合框架提供的开箱即用的数据操作和函数。
在下一章中,我们将学习如何一起处理函数式编程、响应式编程和面向对象编程。我们相信 Kotlin 是做这件事的最佳语言,因为它让您能够同时获得函数式编程和面向对象编程的好处。在下一章中,我们将看到如何利用这一点。
在下一章中,我们还将介绍 ReactiveX 框架,这是功能响应式编程中最受欢迎的框架之一。
因此,让我们继续前进;下一章就在眼前。
第九章:函数式编程和响应式编程
到目前为止,我们在过去的八章中取得了良好的进展。你已经学习了函数式编程(FP)的概念,以及一些令人惊叹的 Kotlin 特性,如协程和委托,它们并不完全来自 FP 理论(实际上,委托来自 OOP 范式),但所有这些都能使我们从 FP 中获得更多好处。
这篇简短的章节致力于将其他编程原则/范式与 FP 结合,以从它们中获得最佳输出。以下是本章我们将涵盖的主题列表:
-
将 FP 与 OOP 结合
-
函数式响应式编程
-
RxKotlin 简介
那么,让我们开始吧。
将 FP 与 OOP 结合
FP 和 OOP 都是老牌的编程范式,各有其优势和劣势。例如,在 FP 中严格遵循无副作用和所有纯函数是很困难的,尤其是对于 FP 初学者和面对复杂项目需求时。然而,在 OOP 系统中,很难避免副作用;此外,OOP 系统通常被称为并发程序的噩梦。
FP 不承认状态,而在现实生活中,状态是无法避免的。
通过使用/结合 OOP 和 FP,可以避免所有这些麻烦。最普遍的混合 OOP 和 FP 的风格可以概括为:在细节上函数式,在整体上面向对象。这是将 OOP 与 FP 结合的简单而高效的方法。这个概念讨论的是在代码中以更高的层次使用 OOP,即在模块化架构中,你可以使用 OOP 来定义类和接口,而在较低层次,即在编写方法/函数时,使用 FP。
为了打破这个概念,考虑一个结构,其中你像使用 OOP 一样编写类和接口,然后在编写函数/方法时,遵循 FP 风格的纯函数、单子和不变性。
如本书前面所述,我们相信如果你想要 OOP 和 FP 的混合,Kotlin 是最好的语言。
函数式响应式编程
函数式响应式编程的概念是通过将 FP 范式与响应式编程相结合而出现的。
函数式响应式编程的定义表明,它是一种用于响应式编程(异步数据流编程)的编程范式,使用 FP 的构建块(例如,map
、reduce
和filter
)。
因此,让我们从定义响应式编程开始,然后我们将讨论将它们与 FP 结合。
响应式编程是一种现代编程范式,它讨论的是变化的传播,也就是说,不是将世界表示为一系列状态,而是响应式编程模型行为。
响应式编程是一种异步编程范式,它围绕数据流和变化的传播。简单来说,那些将影响其数据/数据流的所有变化传播给所有相关方(如最终用户、组件和子部分,以及其他以某种方式相关的程序)的程序被称为响应式程序。
响应式编程最好通过以下章节中描述的《响应式宣言》来定义。
《响应式宣言》
《响应式宣言》(www.reactivemanifesto.org
)是一份文件,定义了四个响应式原则,如下所述:
-
响应性:系统及时响应。响应性系统侧重于提供快速和一致的反应时间,因此它们提供一致的服务质量。
-
弹性:如果系统遇到任何故障,它仍然保持响应。通过复制、遏制、隔离和委派来实现弹性。故障被包含在每个组件内部,使组件彼此隔离,因此当某个组件发生故障时,它不会影响其他组件或整个系统。
-
弹性:响应式系统可以响应变化,并在不同的工作负载下保持响应。响应式系统以成本效益的方式在通用硬件和软件平台上实现弹性。
-
消息驱动:为了建立弹性原则,响应式系统需要通过依赖异步消息传递来在组件之间建立边界。
通过实现前面提到的四个原则,系统变得可靠且响应迅速,因此是响应式的。
Kotlin 的功能响应式框架
为了编写响应式程序,我们需要一个库。目前有几种针对 Kotlin 的响应式编程库可以帮助我们。以下是可用库的列表:
-
RxKotlin
-
Reactor-Kotlin
-
Redux-Kotlin
-
RxKotlin/RxJava 和其他响应式 Java(ReactiveX)框架也可以与 Kotlin 一起使用(因为 Kotlin 与 Java 双向 100%兼容)
在这本书中,我们将重点关注 RxKotlin。
开始使用 RxKotlin
RxKotlin是针对 Kotlin 的响应式编程的具体实现,它受到 FP 的影响。它倾向于函数组合、避免全局状态和副作用。它依赖于生产者/消费者观察者模式,并有许多操作符允许组合、调度、节流、转换、错误处理和生命周期管理。ReactiveX 框架得到了一个大型社区和 Netflix 的支持。
Reactor-Kotlin 也是基于 FP 的;它被广泛接受,并得到 Spring 框架的支持。RxKotlin 和 Reactor-Kotlin 有很多相似之处(可能是因为Reactive Streams规范)。
下载和设置 RxKotlin
您可以从 GitHub 下载并构建 RxKotlin(github.com/ReactiveX/RxKotlin
)。它不需要任何其他依赖项。GitHub 维基百科页面上的文档结构良好。以下是您如何从 GitHub 检出项目并运行构建的步骤:
$ git clone https://github.com/ReactiveX/RxKotlin.git
$ cd RxKotlin/
$ ./gradlew build
您也可以使用 Maven 和 Gradle,如页面上的说明。
我们在这本书中使用 RxKotlin 版本 2.2.0。
现在,让我们看看 RxKotlin 究竟是什么。我们将从一个众所周知的事物开始,然后逐渐深入到库的秘密。
将拉取机制与 RxJava 推送机制进行比较
RxKotlin 围绕表示现实生活事件和数据系统的Observable
类型,旨在用于推送机制,因此它是懒加载的,可以以同步和异步两种方式使用。
如果我们从与数据列表一起工作的简单示例开始,这将更容易理解:
fun main(args: Array<String>) {
var list:List<Any> = listOf(1, "Two", 3, "Four", "Five", 5.5f) // 1
var iterator = list.iterator() // 2
while (iterator.hasNext()) { // 3
println(iterator.next()) // Prints each element 4
}
}
输出如下:
让我们逐行分析程序,了解它是如何工作的。
在注释1
中,我们创建了一个包含七个项目的list
(list
通过Any
类帮助包含混合数据类型的数据)。在注释2
中,我们从list
创建了一个iterator
,这样我们就可以遍历数据。在注释3
中,我们创建了一个while
循环,使用iterator
从list
中拉取数据,然后在注释4
中打印它。
这里需要注意的事情是,我们在当前线程阻塞直到收到并准备好数据时从list
中拉取数据。例如,想象一下从网络调用/数据库查询而不是从列表中获取数据,在这种情况下,线程将被阻塞多长时间。显然,您可以为此操作创建一个单独的线程,但这也会增加复杂性。
仔细思考一下,哪种方法更好,让程序等待数据,还是当数据可用时将数据推送到程序?
ReactiveX 框架(无论是 RxKotlin 还是 RxJava)的构建块是可观察的。Observable
类与迭代器相反。它有一个底层集合或计算,产生可以被消费者消费的值。但不同之处在于,消费者不像在迭代器模式中那样从生产者那里拉取这些值;相反,生产者将值作为通知推送到消费者。
因此,让我们再次使用相同的示例,这次使用observable
:
fun main(args: Array<String>) {
var list = listOf(1, "Two", 3, "Four", "Five", 5.5f) // 1
var observable = list.toObservable();
observable.subscribeBy( // named arguments for lambda Subscribers
onNext = { println(it) },
onError = { it.printStackTrace() },
onComplete = { println("Done!") }
)
}
这个程序的结果与上一个相同;它打印出列表中的所有项目。不同之处在于其方法。因此,让我们看看它实际上是如何工作的:
-
创建了一个列表(与上一个相同)
-
通过
list
创建了一个Observable
实例 -
我们订阅了观察者(我们使用 lambda 的命名参数;我们将在后面详细讨论它们)
当我们订阅observable
变量时,每个数据都会在准备好后推送到onNext
;当所有数据都推送后,它会调用onComplete
,如果发生任何错误,则会调用onError
。
因此,你学习了如何使用Observable
实例,并且它们与我们所熟悉的迭代器实例相当相似。我们可以使用这些Observable
实例来构建异步流并将数据更新推送到它们的订阅者(甚至是多个订阅者)。这是一个简单的响应式编程范式实现。数据正在传播到所有感兴趣的各方——订阅者。
可观察对象
如我们之前讨论的,在响应式编程中,Observable
有一个底层计算,产生可以被消费者(Observer
)消费的值。这里最重要的东西是,消费者(Observer
)在这里不是拉取值;而是Observable
将值推送到消费者。因此,我们可以说Observable
接口是一个基于推送、可组合的迭代器,通过一系列操作符将项目发射到最后一个Observer
,最终消费这些项目。现在让我们按顺序分解这些内容,以更好地理解:
-
Observer
订阅Observable
-
Observable
开始发出它所包含的项目 -
Observer
对Observable
发出的任何项目做出反应
那么,让我们深入了解Observable
是如何通过其事件/方法工作的,即onNext
、onComplete
和onError
。
Observable
的工作原理
如我们之前所述,一个Observable
值有以下三个最重要的事件/方法:
-
onNext
:Observable
接口将所有项目逐个传递给此方法 -
onComplete
:当所有项目都通过onNext
方法后,Observable
调用onComplete
方法 -
onError
:当Observable
遇到任何错误时,它调用onError
方法来处理错误,如果已定义
这里需要注意的一点是,我们所说的Observable
中的项目可以是任何东西;它定义为Observable<T>
,其中T
可以是任何类。甚至可以将数组/列表分配为Observable
。
让我们看看下面的图示:
下面是一个代码示例,以更好地理解它:
fun main(args: Array<String>) {
val observer = object :Observer<Any>{//1
override fun onComplete() {//2
println("All Completed")
}
override fun onNext(item: Any) {//3
println("Next $item")
}
override fun onError(e: Throwable) {//4
println("Error Occured $e")
}
override fun onSubscribe(d: Disposable) {//5
println("Subscribed to $d")
}
}
val observable = listOf(1, "Two", 3, "Four", "Five", 5.5f).toObservable() //6
observable.subscribe(observer)//7
val observableOnList = Observable.just(listOf("One", 2, "Three", "Four", 4.5, "Five", 6.0f),
listOf("List with 1 Item"),
listOf(1,2,3))//8
observableOnList.subscribe(observer)//9
}
在前面的示例中,我们在注释1
处声明了Any
数据类型的观察者实例。
在这里,我们利用了Any
数据类型的好处。在 Kotlin 中,每个类都是Any
类的子类。此外,在 Kotlin 中,一切都是类和对象;没有单独的原始数据类型。
Observer
接口中有四个声明的方法。注释 2
中的 onComplete()
方法在 Observable
完成所有项目且没有任何错误时被调用。在注释 3
中,我们定义了 onNext(item: Any)
函数,这个函数将由 observable
值对每个要发射的项目调用。在这个方法中,我们将数据打印到控制台。在注释 4
中,我们定义了 onError(e: Throwable)
方法,当 Observable
接口遇到错误时将被调用。在注释 5
中,onSubscribe(d: Disposable)
方法将在 Observer
订阅到 Observable
时被调用。在注释 6
中,我们从一个列表中创建了一个 Observable
(val observable
) 并使用注释 7
中的 observer
值订阅了 observable
值。在注释 8
中,我们再次创建 observable
(val observableOnList
),它包含列表作为项目。
程序的输出如下:
因此,正如你在输出中看到的,对于第一次订阅(注释 7
),当我们订阅到 observable
值时,它调用 onSubscribe
方法,然后 Observable
属性开始发射项目,因为 observer
开始在 onNext
方法中接收它们并将它们打印出来。当 Observable
属性的所有项目都被发射出来后,它调用 onComplete
方法来表示所有项目都已成功发射。第二个也是同样的,只是这里的每个项目都是一个列表。
随着我们逐渐掌握了 Observables
,你现在可以学习一些创建 Observable
工厂方法的方法。
Observable.create 方法
在任何时候,你都可以使用 Observable.create
方法创建自己的自定义 Observable
实现。这个方法接受一个 ObservableEmitter<T>
接口的实例作为观察的来源。看看下面的代码示例:
fun main(args: Array<String>) {
val observer: Observer<String> = object : Observer<String> {
override fun onComplete() {
println("All Completed")
}
override fun onNext(item: String) {
println("Next $item")
}
override fun onError(e: Throwable) {
println("Error Occured => ${e.message}")
}
override fun onSubscribe(d: Disposable) {
println("New Subscription ")
}
}//Create Observer
val observable:Observable<String> = Observable.create<String> {//1
it.onNext("Emitted 1")
it.onNext("Emitted 2")
it.onNext("Emitted 3")
it.onNext("Emitted 4")
it.onComplete()
}
observable.subscribe(observer)
val observable2:Observable<String> = Observable.create<String> {//2
it.onNext("Emitted 1")
it.onNext("Emitted 2")
it.onError(Exception("My Exception"))
}
observable2.subscribe(observer)
}
首先,我们创建了一个 Observer
接口的实例,就像之前的例子一样。我不会详细解释 observer
值,因为我们已经在之前的例子中看到了概述,并且将在本章后面详细讨论。在注释 1
中,我们使用 Observable.create
方法创建了一个 Observable
值。我们通过 onNext
方法从这个 Observable
值中发射了四个字符串,然后使用 onComplete
方法通知它完成。在注释 2
中,我们几乎做了同样的事情,只是在这里,我们不是调用 onComplete
,而是调用带有自定义 Exception
函数的 onError
。
下面是程序的输出:
Observable.create
方法很有用,尤其是在你使用自定义数据结构并且想要控制哪些值被发射出来时。你还可以从不同的线程向观察者发射值。
Observable 合约 (reactivex.io/documentation/contract.html
) 指出,Observables 必须按顺序(而不是并行)向观察者发出通知。它们可以从不同的线程发出这些通知,但通知之间必须存在正式的“发生之前”关系。
The Observable.from methods
Observable.from
方法相对于 Observable.create
方法来说比较简单。您可以使用 from 方法从几乎任何 Kotlin 结构中创建 Observable
实例。
在 RxKotlin 1 中,你将拥有 Observale.from
作为一种方法;然而,从 RxKotlin 2.0(与 RxJava2.0 相同),操作符重载已被重命名为后缀,例如 fromArray
、fromIterable
和 fromFuture
。
让我们看看以下代码:
fun main(args: Array<String>) {
val observer: Observer<String> = object : Observer<String> {
override fun onComplete() {
println("Completed")
}
override fun onNext(item: String) {
println("Received-> $item")
}
override fun onError(e: Throwable) {
println("Error Occured => ${e.message}")
}
override fun onSubscribe(d: Disposable) {
println("Subscription")
}
}//Create Observer
val list = listOf("Str 1","Str 2","Str 3","Str 4")
val observableFromIterable: Observable<String> = Observable.fromIterable(list)//1
observableFromIterable.subscribe(observer)
val callable = object : Callable<String> {
override fun call(): String {
return "I'm From Callable"
}
}
val observableFromCallable:Observable<String> = Observable.fromCallable(callable)//2
observableFromCallable.subscribe(observer)
val future:Future<String> = object : Future<String> {
val retStr = "I'm from Future"
override fun get() = retStr
override fun get(timeout: Long, unit: TimeUnit?) = retStr
override fun isDone(): Boolean = true
override fun isCancelled(): Boolean = false
override fun cancel(mayInterruptIfRunning: Boolean): Boolean = false
}
val observableFromFuture:Observable<String> = Observable.fromFuture(future)//3
observableFromFuture.subscribe(observer)
}
在注释 1
中,我们使用了 Observable.fromIterable
方法从 Iterable
实例(此处为 list
)创建 Observable
。在注释 2
中,我们调用了 Observable.fromCallable
方法从 Callable
实例创建 Observable
,在注释 3
中,我们调用了 Observable.fromFuture
方法从 Future
实例派生出 Observable
。
这里是输出:
Iterator.toObservable
多亏了 Kotlin 的扩展函数,您可以将任何 Iterable
实例(如 list
)轻松地转换为 Observable
。我们已经在 第一章,Kotlin – 数据类型、对象和类 中使用了此方法,但再次看看这个:
fun main(args: Array<String>) {
val observer: Observer<String> = object : Observer<String> {
override fun onComplete() {
println("Completed")
}
override fun onNext(item: String) {
println("Received-> $item")
}
override fun onError(e: Throwable) {
println("Error Occured => ${e.message}")
}
override fun onSubscribe(d: Disposable) {
println("Subscription")
}
}//Create Observer
val list:List<String> = listOf("Str 1","Str 2","Str 3","Str 4")
val observable: Observable<String> = list.toObservable()
observable.subscribe(observer)
}
输出如下:
因此,您难道不好奇想看看 toObservable
方法吗?让我们来做这件事。您可以在 RxKotlin
包提供的 observable.kt
文件中找到此方法:
fun <T : Any> Iterator<T>.toObservable(): Observable<T> = toIterable().toObservable()
fun <T : Any> Iterable<T>.toObservable(): Observable<T> = Observable.fromIterable(this)
fun <T : Any> Sequence<T>.toObservable(): Observable<T> = asIterable().toObservable()
fun <T : Any> Iterable<Observable<out T>>.merge(): Observable<T> = Observable.merge(this.toObservable())
fun <T : Any> Iterable<Observable<out T>>.mergeDelayError(): Observable<T> = Observable.mergeDelayError(this.toObservable())
因此,它内部使用 Observable.from
方法,这要归功于 Kotlin 的扩展函数。
Subscriber – the Observer interface
在 RxKotlin 1.x 中,Subscriber
操作符在 RxKotlin 2.x 中本质上变成了 Observer
类型。在 RxKotlin 1.x 中有一个 Observer
类型,但 Subscriber
值是你传递给 subscribe()
方法的,并且实现了 Observer
。在 RxJava 2.x 中,Subscriber
操作符仅在谈论 Flowables
时存在。
如您在本章前面的示例中所见,Observer
类型是一个接口,其中包含四个方法,即 onNext(item:T)
、onError(error:Throwable)
、onComplete()
和 onSubscribe(d:Disposable)
。如前所述,当我们连接 Observable
到 Observer
时,它会在 Observer
类型中寻找这四个方法并调用它们。以下是以下四个方法的简要描述:
-
onNext
:Observable
调用此方法将每个项逐个传递给Observer
-
onComplete
:当Observable
想表示它已经完成了向onNext
方法传递项的操作时,它调用Observer
的onComplete
方法 -
onError
:当Observable
遇到任何错误时,它会调用onError
方法来处理错误,如果Observer
类型中定义了错误处理,否则它将抛出异常 -
onSubscribe
:每当一个新的Observable
订阅到Observer
时,都会调用此方法
订阅和处置
所以,我们有Observable
(应该观察的事物)和Observer
类型(应该观察的类型),现在怎么办?我们如何将它们连接起来?Observable
和Observer
就像一个输入设备(无论是键盘还是鼠标)和计算机;我们需要某种东西来连接它们(即使是无线输入设备也有一些连接通道,无论是蓝牙还是 Wi-Fi)。
subscribe
操作符的作用是连接Observable
接口和Observer
,充当媒体的作用。我们可以向subscribe
操作符传递一到三个方法(onNext
、onComplete
和onError
),或者我们可以向subscribe
操作符传递Observer
接口的实例,以获取与Observer
连接的Observable
接口。
那么,现在让我们来看一个例子:
fun main(args: Array<String>) {
val observable = Observable.range(1,5)//1
observable.subscribe({//2
//onNext method
println("Next-> $it")
},{
//onError Method
println("Error=> ${it.message}")
},{
//onComplete Method
println("Done")
})
val observer: Observer<Int> = object : Observer<Int> {//3
override fun onComplete() {
println("All Completed")
}
override fun onNext(item: Int) {
println("Next-> $item")
}
override fun onError(e: Throwable) {
println("Error Occurred=> ${e.message}")
}
override fun onSubscribe(d: Disposable) {
println("New Subscription ")
}
}
observable.subscribe(observer)
}
在这个例子中,我们创建了一个Observable
实例(在注释1
处),并使用不同的重载subscribe
操作符使用了两次。在注释2
中,我们将三个方法作为参数传递给了subscribe
方法。第一个参数是onNext
方法,第二个是onError
方法,最后一个是onComplete
。在注释2
中,我们传递了Observer
接口的实例。
输出很容易预测,所以我们跳过它。
所以,我们已经了解了订阅的概念,现在可以这样做。如果你想在订阅一段时间后停止发射,怎么办?肯定有办法,对吧?那么,让我们检查一下。
记得Observer
的onSubscribe
方法吗?在那个方法中有一个我们还没有讨论过的参数。当你订阅时,如果你传递方法而不是Observer
实例,那么subscribe
操作符将返回一个Disposable
实例,或者如果你使用Observer
实例,那么你将在onSubscribe
方法的参数中得到Disposable
实例。
你可以使用Disposable
接口的实例在任何给定时间停止发射。让我们来看一个例子:
fun main(args: Array<String>) {
val observale = Observable.interval(100, TimeUnit.MILLISECONDS)//1
val observer = object : Observer<Long> {
lateinit var disposable: Disposable//2
override fun onSubscribe(d: Disposable) {
disposable = d//3
}
override fun onNext(item: Long) {
println("Received $item")
if (item >= 10 && !disposable.isDisposed) {//4
disposable.dispose()//5
println("Disposed")
}
}
override fun onError(e: Throwable) {
println("Error ${e.message}")
}
override fun onComplete() {
println("Complete")
}
}
runBlocking {
observale.subscribe(observer)
delay(1500)//6
}
}
这里,我们使用了Observable.interval
工厂方法。此方法接受两个参数,描述间隔期间和时间单位;然后按顺序发射从零开始的整数。使用interval
创建的Observable
永远不会完成,也永远不会停止,除非你取消它们,或者程序停止执行。我认为它非常适合这个场景,因为我们想在中间停止Observable
。
因此,在这个例子中,在注释1
处,我们使用Observable.interval
工厂方法创建了Observable
,该方法将在每个100
毫秒间隔后发射一个整数。
在注释2
中,我声明了lateinit var disposable
为Disposable
类型(lateinit
意味着变量将在稍后的时间点初始化)。在注释3
中,在onSubscribe
方法内部,我们将接收到的参数值分配给disposable
变量。
我们打算在序列达到10
后停止执行,也就是说,在10
被发出后,应立即停止发射。为了实现这一点,我们在onNext
方法内部放置了一个检查,在那里我们检查发出的项的值。我们检查它是否等于或大于10
,如果发射尚未停止(未处置),那么我们就处置发射(注释5
)。
这里是输出结果:
从输出中,我们可以看到在调用disposable.dispose()
方法后,没有整数被发出,尽管执行等待了 500 毫秒更多(100*10 = 1000 毫秒来打印序列直到10
,我们使用1500
调用延迟方法,因此是在发出10
后的 500 毫秒)。
如果你好奇想了解Disposable
接口,那么以下是其定义:
interface Disposable {
/**
* Dispose the resource, the operation should be idempotent.
*/
fun dispose()
/**
* Returns true if this resource has been disposed.
* @return true if this resource has been disposed
*/
val isDisposed:Boolean
}
它有一个属性表示发射已经被通知停止(已处置),以及一个通知发射停止(处置)的方法。
摘要
在本章中,你学习了如何将 FP 概念与 OOP 和响应式编程相结合。我们甚至讨论了 RxKotlin,并涵盖了 RxKotlin 的设置和基本用法。
下一章将介绍更高级的 FP 概念——单子、函子、应用,以及如何使用 Kotlin 实现它们。单子、函子和应用是一些必须了解的概念,通常被称为 FP 的构建块。所以,如果你真正想学习 FP,不要跳过下一章。现在就翻到下一页。
第十章:函子、应用和函子
函子、应用和函子是关于函数式编程中最常搜索的词汇之一,如果你考虑到没有人知道它们的意思(实际上,有一些聪明的人知道他们在说什么),这就有意义了。特别是关于函子的混淆已经成为编程社区中的一个笑话/模因:
“一个函子是端内函子的范畴中的幺半群,有什么问题?”
这句话是虚构地归因于詹姆斯·艾里(James Iry)在他的经典博客文章《编程语言的简短、不完整且大部分错误的历史》中引用的菲利普·瓦德勒(Philip Wadler),(james-iry.blogspot.co.uk/2009/05/brief-incomplete-and-mostly-wrong.html
)。
在本章中,我们将涵盖以下主题:
-
函子
-
选项、列表和作为函子的函数
-
摩纳哥(Monads)
-
应用(Applicatives)
函子(Functors)
如果我告诉你,你已经在 Kotlin 中使用了函子,你会感到惊讶吗?让我们看看以下代码:
fun main(args: Array<String>) {
listOf(1, 2, 3)
.map { i -> i * 2 }
.map(Int::toString)
.forEach(::println)
}
List<T>
类有一个函数,map(transform: (T) -> R): List<R>
。map
这个名字是从哪里来的?它来自范畴论。当我们从 Int
转换到 String
时,我们是从 Int
范畴映射到 String
范畴。在同样的意义上,在我们的例子中,我们是从 List<Int>
转换到 List<Int>
(不是很令人兴奋),然后从 List<Int>
转换到 List<String>
。我们没有改变外部类型,只是内部值。
那就是一个函子。一个 函子 是一个定义了如何转换或映射其内容的类型。你可以找到关于函子的不同定义,或多或少是学术性的;但原则上,它们都指向同一个方向。
让我们为函子类型定义一个泛型接口:
interface Functor<C<_>> { //Invalid Kotlin code
fun <A,B> map(ca: C<A>, transform: (A) -> B): C<B>
}
此外,它无法编译,因为 Kotlin 不支持高阶类型。
你可以在第十三章 Chapter 13 中找到关于 Kotlin 高阶类型(包括替代方案和 Kotlin 的未来)的更多信息,箭头类型**。
在支持高阶类型的语言中,例如 Scala 和 Haskell,可以定义一个 Functor
类型,例如 Scala cats 函子:
trait Functor[F[_]] extends Invariant[F] { self =>
def mapA, B(f: A => B): F[B]
//More code here
在 Kotlin 中,我们没有这些功能,但我们可以通过约定来模拟它们。如果一个类型有一个函数或扩展函数,那么 map
就是一个函子(这被称为结构化类型,通过其结构而不是其层次来定义类型)。
我们可以有一个简单的 Option
类型:
sealed class Option<out T> {
object None : Option<Nothing>() {
override fun toString() = "None"
}
data class Some<out T>(val value: T) : Option<T>()
companion object
}
然后,你可以为它定义一个 map
函数:
fun <T, R> Option<T>.map(transform: (T) -> R): Option<R> = when (this) {
Option.None -> Option.None
is Option.Some -> Option.Some(transform(value))
}
然后按照以下方式使用它:
fun main(args: Array<String>) {
println(Option.Some("Kotlin")
.map(String::toUpperCase)) //Some(value=KOTLIN)
}
现在,一个 Option
值对于 Some
和 None
将有不同的行为:
fun main(args: Array<String>) {
println(Option.Some("Kotlin").map(String::toUpperCase)) //Some(value=KOTLIN)
println(Option.None.map(String::toUpperCase)) //None
}
扩展函数非常灵活,以至于我们可以为函数类型编写一个 map
函数,即 (A) -> B
,因此将函数转换为函子:
fun <A, B, C> ((A) -> B).map(transform: (B) -> C): (A) -> C = { t -> transform(this(t)) }
我们在这里改变的是通过应用参数函数 transform: (B) -> C
到函数 (A) -> B
的结果,将返回类型从 B
改为 C
:
fun main(args: Array<String>) {
val add3AndMultiplyBy2: (Int) -> Int = { i: Int -> i + 3 }.map { j -> j * 2 }
println(add3AndMultiplyBy2(0)) //6
println(add3AndMultiplyBy2(1)) //8
println(add3AndMultiplyBy2(2)) //10
}
如果你在其他函数式编程语言中有所经验,你会认出这种行为是前向函数组合(更多关于函数组合的内容请参阅 第十二章,*使用箭头入门)。
Monads
monad 是一个定义了 flatMap
(或在其他语言中的 bind
)函数的 funtor 类型,该函数接收一个返回相同类型的 lambda。让我用一个例子来解释它。幸运的是,List<T>
定义了一个 flatMap
函数:
fun main(args: Array<String>) {
val result = listOf(1, 2, 3)
.flatMap { i ->
listOf(i * 2, i + 3)
}
.joinToString()
println(result) //2, 4, 4, 5, 6, 6
}
在 map
函数中,我们只是转换 List
值的内容,但在 flatMap
中,我们可以返回一个具有更少或更多项的新 List
类型,这使得它比 map
更强大得多。
因此,一个通用的 monad 将看起来像这样(记住我们没有高阶类型):
interface Monad<C<_>>: Functor<C> { //Invalid Kotlin code
fun <A, B> flatMap(ca:C<A>, fm:(A) -> C<B>): C<B>
}
现在,我们可以为我们的 Option
类型编写一个 flatMap
函数:
fun <T, R> Option<T>.flatMap(fm: (T) -> Option<R>): Option<R> = when (this) {
Option.None -> Option.None
is Option.Some -> fm(value)
}
如果你仔细观察,你会发现 flatMap
和 map
非常相似;相似到我们可以用 flatMap
重写 map
:
fun <T, R> Option<T>.map(transform: (T) -> R): Option<R> = flatMap { t -> Option.Some(transform(t)) }
现在我们可以用 flatMap
函数的强大功能以一些酷炫的方式使用它,这些方式在普通的 map
中是不可能的:
fun calculateDiscount(price: Option<Double>): Option<Double> {
return price.flatMap { p ->
if (p > 50.0) {
Option.Some(5.0)
} else {
Option.None
}
}
}
fun main(args: Array<String>) {
println(calculateDiscount(Option.Some(80.0))) //Some(value=5.0)
println(calculateDiscount(Option.Some(30.0))) //None
println(calculateDiscount(Option.None)) //None
}
我们的功能函数 calculateDiscount
接收并返回 Option<Double>
。如果价格高于 50.0
,我们返回一个包裹在 Some
中的 5.0
折扣,如果没有,则返回 None
。
flatMap
的一个酷炫技巧是它可以嵌套:
fun main(args: Array<String>) {
val maybeFive = Option.Some(5)
val maybeTwo = Option.Some(2)
println(maybeFive.flatMap { f ->
maybeTwo.flatMap { t ->
Option.Some(f + t)
}
}) // Some(value=7)
}
在内部的 flatMap
函数中,我们可以访问这两个值并对它们进行操作。
我们可以通过组合 flatMap
和 map
来稍微缩短这个示例的写法:
fun main(args: Array<String>) {
val maybeFive = Option.Some(5)
val maybeTwo = Option.Some(2)
println(maybeFive.flatMap { f ->
maybeTwo.map { t ->
f + t
}
}) // Some(value=7)
}
因此,我们可以将我们的第一个 flatMap
示例重写为两个列表的组合——一个包含数字,另一个包含函数:
fun main(args: Array<String>) {
val numbers = listOf(1, 2, 3)
val functions = listOf<(Int) -> Int>({ i -> i * 2 }, { i -> i + 3 })
val result = numbers.flatMap { number ->
functions.map { f -> f(number) }
}.joinToString()
println(result) //2, 4, 4, 5, 6, 6
}
这种嵌套多个 flatMap
或 flatMap
与 map
组合的技术非常强大,并且是另一个名为 monadic comprehensions(第十三章,箭头类型)的概念背后的主要思想,它允许我们组合 monadic 操作(更多关于 comprehensions 的内容请参阅)。
Applicatives
我们之前的例子,在同一个类型的包装器内部调用 lambda,并在同一个包装器内部传递参数,是介绍 applicatives 的完美方式。
applicative 是一个定义了两个函数的类型,一个 pure(t: T)
函数,它返回包裹在 applicative 类型中的 T
值,以及一个 ap
函数(在其他语言中称为 apply
),它接收一个包裹在 applicative 类型中的 lambda。
在上一节中,当我们解释 monads 时,我们让它们直接从 funtor 扩展,但事实上,monad 是从 applicative 扩展的,而 applicative 是从 funtor 扩展的。因此,我们的通用 applicative 的伪代码以及整个层次结构将看起来像这样:
interface Functor<C<_>> { //Invalid Kotlin code
fun <A,B> map(ca:C<A>, transform:(A) -> B): C<B>
}
interface Applicative<C<_>>: Functor<C> { //Invalid Kotlin code
fun <A> pure(a:A): C<A>
fun <A, B> ap(ca:C<A>, fab: C<(A) -> B>): C<B>
}
interface Monad<C<_>>: Applicative<C> { //Invalid Kotlin code
fun <A, B> flatMap(ca:C<A>, fm:(A) -> C<B>): C<B>
}
简而言之,一个 applicative 是一个更强大的 funtor,而一个 monad 是一个更强大的 applicative。
现在,让我们为 List<T>
写一个 ap
扩展函数:
fun <T, R> List<T>.ap(fab: List<(T) -> R>): List<R> = fab.flatMap { f -> this.map(f) }
我们可以回顾一下上一节中 Monads 部分的最后一个示例:
fun main(args: Array<String>) {
val numbers = listOf(1, 2, 3)
val functions = listOf<(Int) -> Int>({ i -> i * 2 }, { i -> i + 3 })
val result = numbers.flatMap { number ->
functions.map { f -> f(number) }
}.joinToString()
println(result) //2, 4, 4, 5, 6, 6
}
让我们用 ap
函数来重写它:
fun main(args: Array<String>) {
val numbers = listOf(1, 2, 3)
val functions = listOf<(Int) -> Int>({ i -> i * 2 }, { i -> i + 3 })
val result = numbers
.ap(functions)
.joinToString()
println(result) //2, 4, 6, 4, 5, 6
}
更容易阅读,但有一个警告——结果顺序不同。我们需要意识到并选择适合我们特定情况的最佳选项。
我们可以向我们的Option
类添加pure
和ap
:
fun <T> Option.Companion.pure(t: T): Option<T> = Option.Some(t)
Option.pure
只是Option.Some
构造函数的一个简单别名。
我们的Option.ap
函数非常迷人:
//Option
fun <T, R> Option<T>.ap(fab: Option<(T) -> R>): Option<R> = fab.flatMap { f -> map(f) }
//List
fun <T, R> List<T>.ap(fab: List<(T) -> R>): List<R> = fab.flatMap { f -> this.map(f) }
Option.ap
和List.ap
有相同的主体,使用flatMap
和map
的组合,这正是我们组合单子操作的方式。
使用单子,我们使用flatMap
和map
对两个Option<Int>
进行求和:
fun main(args: Array<String>) {
val maybeFive = Option.Some(5)
val maybeTwo = Option.Some(2)
println(maybeFive.flatMap { f ->
maybeTwo.map { t ->
f + t
}
}) // Some(value=7)
}
现在,使用应用函数:
fun main(args: Array<String>) {
val maybeFive = Option.pure(5)
val maybeTwo = Option.pure(2)
println(maybeTwo.ap(maybeFive.map { f -> { t: Int -> f + t } })) // Some(value=7)
}
这不是很容易阅读。首先,我们使用一个 lambda (Int) -> (Int) -> Int
(技术上是一个柯里化函数,关于柯里化函数的更多信息可以在第十二章,开始使用 Arrow)来映射maybeFive
,它返回一个Option<(Int) -> Int>
,可以作为maybeTwo.ap
的参数。
我们可以用一个小技巧(我从 Haskell 那里借来的)使事情更容易阅读:
infix fun <T, R> Option<(T) -> R>.`(*)`(o: Option<T>): Option<R> = flatMap { f: (T) -> R -> o.map(f) }
infix
扩展函数Option<(T) -> R>.
(*) ``将允许我们从左到右读取
sum操作;这有多酷?现在,让我们看看以下代码,它是使用应用函数对两个
Option
fun main(args: Array<String>) {
val maybeFive = Option.pure(5)
val maybeTwo = Option.pure(2)
println(Option.pure { f: Int -> { t: Int -> f + t } } `(*)` maybeFive `(*)` maybeTwo) // Some(value=7)
}
我们用pure
函数包装(Int) -> (Int) -> Int
lambda,然后逐个应用Option<Int>
。我们使用名称(*)
作为对 Haskell 的<*>
的致敬。
到目前为止,你可以看到应用函数让你可以做一些很酷的技巧,但单子更强大、更灵活。何时使用一个或另一个?这显然取决于你的特定问题,但我们的总体建议是使用尽可能少权力的抽象。你可以从函子map
开始,然后是应用函数ap
,最后是单子flatMap
。所有的事情都可以用flatMap
来完成(正如你所见,Option
、map
和ap
都是使用flatMap
实现的),但大多数时候map
和ap
可以更容易地推理。
回到函数,我们可以使一个函数表现得像一个应用函数。首先,我们应该添加一个纯函数:
object Function1 {
fun <A, B> pure(b: B) = { _: A -> b }
}
首先,我们创建一个对象Function1
,因为函数类型(A) -> B
没有伴随对象来添加新的扩展函数,就像我们为Option
所做的那样:
fun main(args: Array<String>) {
val f: (String) -> Int = Function1.pure(0)
println(f("Hello,")) //0
println(f("World")) //0
println(f("!")) //0
}
Function1.pure(t: T)
将一个T
值包装在一个函数中并返回它,无论我们使用什么参数。如果你有其他函数式语言的经验,你会认出函数的pure
作为一个identity
函数(关于identity
函数的更多信息可以在第十二章,开始使用 Arrow)。
让我们在一个函数(A) -> B
中添加flatMap
和ap
:
fun <A, B, C> ((A) -> B).map(transform: (B) -> C): (A) -> C = { t -> transform(this(t)) }
fun <A, B, C> ((A) -> B).flatMap(fm: (B) -> (A) -> C): (A) -> C = { t -> fm(this(t))(t) }
fun <A, B, C> ((A) -> B).ap(fab: (A) -> (B) -> C): (A) -> C = fab.flatMap { f -> map(f) }
我们已经涵盖了map(transform: (B) -> C): (A) -> C
,并且我们知道它表现得像一个正向函数组合。如果你仔细观察flatMap
和ap
,你会看到参数有点反方向(并且ap
被实现为所有其他类型的ap
函数)。
但是,我们能用函数的 ap
做些什么呢?让我们看看下面的代码:
fun main(args: Array<String>) {
val add3AndMultiplyBy2: (Int) -> Int = { i: Int -> i + 3 }.ap { { j: Int -> j * 2 } }
println(add3AndMultiplyBy2(0)) //6
println(add3AndMultiplyBy2(1)) //8
println(add3AndMultiplyBy2(2)) //10
}
嗯,我们可以组合函数,这并不令人兴奋,因为我们已经用 map
做过这样的事情。但是函数的 ap
有一个小技巧。我们可以访问原始参数:
fun main(args: Array<String>) {
val add3AndMultiplyBy2: (Int) -> Pair<Int, Int> = { i:Int -> i + 3 }.ap { original -> { j:Int -> original to (j * 2) } }
println(add3AndMultiplyBy2(0)) //(0, 6)
println(add3AndMultiplyBy2(1)) //(1, 8)
println(add3AndMultiplyBy2(2)) //(2, 10)
}
在函数组合中访问原始参数在多个场景下很有用,例如调试和审计。
摘要
我们已经介绍了很多名字吓人但背后理念简单的酷概念。函子、应用和单子类型为我们打开了几种抽象和更强大的函数概念的大门,这些内容我们将在接下来的章节中介绍。我们学习了 Kotlin 的一些局限性以及我们如何在创建函数来模拟不同类型的函子、应用和单子时克服它们。我们还探讨了函子、应用和单子之间的层次关系。
在下一章中,我们将介绍如何有效地处理数据流。
第十一章:在 Kotlin 中使用流
因此,我们正在逐步完成这本书。在本章中,我们将介绍 Kotlin 中的流以及如何使用它们。
流首次在 Java 8 中被引入。Kotlin 中的 Stream API 几乎与 Java API 相同,但包含了一些小的补充和扩展函数。
这是本章我们将要涵盖的内容:
-
流的介绍
-
集合与流
-
流与可观察对象(ReactiveX-RxKotlin/RxJava)
-
流的使用
-
创建流的多种方式
-
流的收集
那么,让我们开始吧。
流的介绍
如我们之前提到的,流(Streams)首次在 Java 8 中被引入。从 Java 8 开始,Java 开始更加关注函数式编程,并逐渐添加了函数式特性。
相反,Kotlin 从第一天开始就添加了函数式特性。Kotlin 添加了函数式特性和接口。当使用 Java 时,只有在使用 Java 8 及更高版本时才能使用流,但使用 Kotlin,即使在与 JDK 6 一起工作时,你仍然可以使用流。
那么,什么是流呢?你可以将流想象为一个抽象层,它覆盖了一系列元素以执行聚合操作。困惑吗?让我们通过一个代码示例来尝试理解:
fun main(args: Array<String>) {
val stream = 1.rangeTo(10).asSequence().asStream()
val resultantList = stream.skip(5).collect(Collectors.toList())
println(resultantList)
}
输出结果如下:
在之前的程序中,我们所做的是创建一个IntRange
值,从它创建一个Sequence
值,然后从它获取stream
值。然后我们跳过了前五个元素,并将其收集回一个List
实例。我们将在本章后面详细查看之前代码中使用的所有函数。
之前的程序使用了 Stream API 的函数式接口。
Stream API 拥有丰富的函数式接口,就像我们在集合中看到的那样。
集合与流
读到这儿,你可能正在想,在那个程序中我们执行的所有操作在 Kotlin 的集合中也是可能的,那么为什么我们要使用流呢?为了回答这个问题,我们首先应该了解流和集合之间的区别。让我们看一下以下列出的集合和流之间的区别列表:
-
正如集合的定义所说,集合是一个数据结构,它存储并允许你处理一组数据。另一方面,流不是数据结构,不存储任何东西;它们像管道或 IO 通道一样工作,按需从其源获取数据。
-
每个数据结构都必须有一个有限的大小限制,集合也是如此。但是,由于流不是数据结构,它们不需要有特定的尺寸限制。
-
当直接访问集合的元素时,你可以随时进行,即使是相同的位置,也不需要重新创建集合。但是,当与流(Streams)一起工作时,流中的元素在流的生命周期内只会被访问一次。就像迭代器一样,必须生成一个新的流来重新访问源中的相同元素。
-
集合 API 以急切的方式构建对象,总是准备好被消费。流 API 以懒加载、按需的方式创建对象。
-
集合(Collection)API 用于在多种数据结构中存储数据。流(Stream)API 用于在大量对象上计算数据。
因此,这些是集合 API 和流 API 之间非常基本的不同之处。乍一看,流(Streams)似乎像是 RxKotlin 的可观察者(Observables),它们提供了一种消费数据的方式,但流(Streams)和可观察者(Observables)之间有很多显著的差异。这些是流(Streams)和可观察者(Observables)之间的差异:
-
第一个值得注意的区别是,流(Streams)是基于拉取的,而可观察者(Observables)是基于推送的。这可能听起来过于抽象,但它有非常具体的重大影响。
-
使用可观察者(Observables),由于调度器(Schedulers)的存在,很容易更改线程或轻松指定链的线程池。但是,使用流(Streams)则有点棘手。
-
可观察者(Observables)在整个过程中都是同步的。这让你不必总是检查这些基本操作是否线程安全。
-
另一个显著的区别是,可观察者(Observables)比流(Streams)API 拥有更多的功能接口,这使得可观察者易于使用,并且有更多选项来完成特定任务。
因此,我们了解到了流(Streams)不是数据结构,而是数据源(可能是集合或其他任何东西)上方的抽象层,尽管流(Streams)以懒加载、按需的方式构建对象,但它们仍然是基于拉取的,并在其中使用循环。
要了解更多关于基于推送的架构和可观察者(Observables)的信息,你可以阅读 Rivu Chakraborty 所著的《Kotlin 中的响应式编程》一书。
处理流(Streams)
因此,我们学习了许多关于流(Streams)的理论,我们还了解到流(Streams)有一组功能接口来与之交互(实际上,功能接口是唯一与流(Streams)交互的方式),但正如我之前提到的,它们的工作方式与集合 API 略有不同。
为了更清楚地说明,请回顾以下示例:
fun main(args: Array<String>) {
val stream = 1.rangeTo(10).asSequence().asStream()
val resultantList = stream.filter{
it%2==0
}.collect(Collectors.toList())
println(resultantList)
}
上述程序是一个简单的例子;我们只是抓取了一个从1
到10
的数字流,并从该流中过滤出奇数,然后将结果收集到一个新的List
中。
但让我们尝试理解它是如何工作的机制。我们已经熟悉了函数式接口和 filter
函数,因为我们已经在前面的章节中介绍了它们,但这里不同的是 collect
函数和 Collectors
值,它们有助于将结果数据收集到新的 List
中。我们将在本章后面更详细地讨论 collect
方法以及 Collectors
值,但现在,让我们看看 Streams 提供的函数式接口和流类型。
因此,以下是从 Stream API 中提取的操作/函数式接口及其描述:
-
filter()
: 与 Kotlin 中的Collection.filter
功能相同。它返回一个由与此stream
中匹配给定谓词的元素组成的stream
值。 -
map()
: 与 Kotlin 中的Collection.map
功能相同。它返回一个由将给定函数应用于此stream
的每个元素的结果组成的stream
值。 -
mapToInt()
/mapToLong()
/mapToDouble()
: 与map
的功能相同,但返回的不是stream
值,而是分别返回IntStream
、LongStream
和DoubleStream
值。我们将在本章后面详细讨论IntStream
、LongStream
和DoubleStream
。 -
flatMap()
: 与 Kotlin 中的Collection.flatMap
功能相同。 -
flatMapToInt()
/flatMapToLong()
/flatMapToDouble()
: 与flatMap
的功能相同,但返回的不是stream
值,而是分别返回IntStream
、LongStream
和DoubleStream
值。 -
distinct()
: 与Collection.distinct
的功能相同。它返回一个包含不同元素的stream
值。 -
peek()
: 这个函数在 Kotlin 集合中没有对应的函数,然而在 RxKotlin/RxJava 中有对应的函数。这个函数返回由这个stream
的元素组成的stream
值,并在每个元素被消费的同时执行提供的操作,就像 RxJava 中的doOnNext
操作符一样。 -
anyMatch()
: 与Collection.any()
类似,它返回此stream
的任何元素是否都匹配提供的谓词。如果不需要评估所有元素来确定结果,则可能不会评估谓词。如果stream
值为空,则返回false
值,并且不会评估谓词。 -
allMatch()
: 与Collection.all
类似,它返回此stream
的所有元素是否都匹配提供的谓词。如果不需要评估所有元素来确定结果,则可能不会评估谓词。如果stream
值为空,则返回true
值,并且不会评估谓词。 -
noneMatch()
:类似于Collection.none
,它返回此stream
中是否有元素匹配提供的谓词。如果不需要确定结果,则可能不会评估谓词。如果stream
为空,则返回false
值,并且不会评估谓词。
我们省略了这些函数的示例,因为它们与Collection
函数和 RxJava/RxKotlin 操作符类似。
如果你对此有所疑问,那么是的,如果你的项目完全是 Kotlin(没有任何 Java 或其他语言代码),你可以安全地放弃 Streams,转而使用 Collections 和协程。
现在,让我们来看看之前提到的IntStream
、DoubleStream
和LongStream
值,并探讨它们的作用。
原始流
原始流是在 Java 8 中引入的,以便在使用 Streams 的同时利用 Java 中的原始数据类型(再次强调,Streams 基本上来自 Java,而 Kotlin 只是为 Streams API 添加了一些扩展函数)。IntStream
、LongStream
和DoubleStream
是这些原始流的一部分。
这些原始流的工作方式与普通 Stream 类似,但增加了一些原始数据类型的功能。
那么,让我们举一个例子;看看以下程序:
fun main(args: Array<String>) {
val intStream = IntStream.range(1,10)
val result = intStream.sum()
println("The sum of elements is $result")
}
因此,我们使用IntStream.range()
函数创建了一个IntStream
值,range
函数接受两个整数作为起始点和结束点,并创建一个从指定整数开始的 Stream,包括这两个整数。然后我们计算了总和并打印出来。程序看起来相当简单,显然要归功于IntStream
,为什么?想想用这种方式轻松计算元素的总和;没有IntStream
,我们就必须遍历所有元素来计算总和。
下面是另一个原始流的例子:
fun main(args: Array<String>) {
val doubleStream = DoubleStream.iterate(1.5,{item -> item*1.3})//(1)
val avg = doubleStream
.limit(10)//(2)
.peek {
println("Item $it")
}.average()//(3)
println("Average of 10 Items $avg")
}
在我们解释程序之前,先看看以下输出:
那么,让我们来解释一下这个程序:
-
在注释
(1)
中,我们使用iterate()
工厂方法创建了一个DoubleStream
值。iterate
方法接受一个double
作为 Stream 的种子,以及一个操作符,该操作符将被迭代应用以生成 Stream 的元素,例如,如果你传递x作为种子和f作为操作符,Stream 将返回x作为第一个元素,f(x)作为第二个元素,f(f(x))作为第三个元素,依此类推。此函数创建了一个无限大小的 Stream。 -
我们在注释
(2)
处使用了limit
操作符,因为我们只想从这个 Stream 中获取 10 个元素,而不是无限多的所有元素。在注释(3)
处,我们计算了average
。
那么,让我们来看看创建 Stream 的不同方法。
Stream 工厂方法
Streams API 提供了多种获取Stream
实例的方法。以下是创建 Streams 的几种方式,我们将对其进行介绍:
-
Stream Builder
-
Stream.empty()
-
Stream.of()
-
Stream.generate()
-
Stream.iterate()
-
Kotlin 扩展——
asStream()
在前面的列表中,我们已经看到了 Kotlin 扩展asStream
和Stream.iterate
函数的工作方式(它将以与前面示例中覆盖的DoubleStream.iterate
值相同的方式工作)。我们将查看其余部分。
Stream Builder
Stream Builder
接口使得轻松创建流实例变得非常容易。看看以下示例:
fun main(args: Array<String>) {
val stream = Stream.builder<String>()
.add("Item 1")
.add("Item 2")
.add("Item 3")
.add("Item 4")
.add("Item 5")
.add("Item 6")
.add("Item 7")
.add("Item 8")
.add("Item 9")
.add("Item 10")
.build()
println("The Stream is ${stream.collect(Collectors.toList())}")
}
输出如下:
Stream.builder()
方法返回一个Streams.Builder
实例。然后,我们使用了Builder.add
函数;add
函数接受要构建的stream
值的项,并返回相同的Stream.Builder
实例。然后build
函数使用提供给构建器的项创建stream
实例。
创建空流 – Stream.empty()
使用Streams.empty()
工厂方法创建空流非常简单。考虑以下示例:
fun main(args: Array<String>) {
val emptyStream = Stream.empty<String>()
val item = emptyStream.findAny()
println("Item is $item")
}
在前面的示例中,我们使用Stream.empty()
创建了emptyStream
值,然后使用findAny()
函数随机从该流中获取任何元素。findAny()
方法返回一个包含从流中随机选择的项的Optional
值,如果流为空,则返回一个空的Optional
。
以下是为前一个程序生成的输出:
通过传递元素创建流 – Stream.of()
我们还可以通过将元素提供给of
函数来获取流的一个实例。of
函数的工作方式与 RxJava/RxKotlin 中的Observable.just
方法类似。
看看以下示例:
fun main(args: Array<String>) {
val stream = Stream.of("Item 1",2,"Item 3",4,5.0,"Item 6")
println("Items in Stream = ${stream.collect(Collectors.toList())}")
}
输出如下:
简单直接,不是吗?
生成流 – Stream.generate()
我们还可以通过使用Stream.generate()
工厂方法来创建流。它接受一个 lambda/supplier 实例作为参数,并在每次需要项时使用它来生成项。此方法也会创建一个无限流。
考虑以下示例:
fun main(args: Array<String>) {
val stream = Stream.generate {
//return a random number
(1..20).random()
}
val resultantList = stream
.limit(10)
.collect(Collectors.toList())
println("resultantList = $resultantList")
}
输出如下:
因此,Stream API 调用了 lambda 来获取流中的每个元素——太棒了。
因此,现在我们相当熟悉如何使用流以及原始流,让我们继续前进,看看如何使用Collectors
。
收集器和 Stream.collect – 收集流
我们可以使用流执行许多操作,但我们可能会遇到需要将流中的元素重新包装到数据结构中的情况。Stream.collect()
方法帮助我们实现这一点。它是 Streams API 的终端方法之一。它允许你在流实例中持有的数据元素上执行可变的fold
操作(将元素重新包装到某些数据结构中并应用一些附加逻辑,连接它们等)。
collect()
方法接受一个 Collector
接口实现作为参数,用于收集策略(是否将它们重新包装到数据结构中、连接它们,或其他任何操作)。
那么,我们是否需要编写自己的 Collector
接口实现来将 Stream 重新包装成 List
/Set
值?当然不需要,Streams API 为一些最常见的用例提供了一些预定义的 Collector
实现。
Collectors
类包含预定义的 Collector
实现。所有这些都可以通过以下行导入:
import java.util.stream.Collectors
以下列表包含预定义的 Collector
实现:
-
Collectors.toList()
-
Collectors.toSet()
-
Collectors.toMap()
-
Collectors.toCollection()
-
Collectors.joining()
-
Collectors.groupingBy()
因此,让我们简要地看一下每个方法。
Collectors.toList()
、Collectors.toSet()
和 Collectors.toCollection()
方法
我们已经看到了 Collectors.toList()
的实现。Collectors.toList()
方法帮助将 Stream 的元素收集到 List
中。这里需要注意的是,你不能指定要使用哪个 List
实现;相反,它将始终使用默认的实现。
Collectors.toSet()
与 Collectors.toList()
方法类似,只是它将元素重新包装到集合中。同样,使用 Collectors.toSet()
,你将无法指定要使用哪个集合实现。
Collectors.toCollection()
方法是 toList()
和 toSet()
的补充版本;它允许你提供一个自定义的 Collection 来累积列表。
考虑以下示例来解释它:
fun main(args: Array<String>) {
val resultantSet = (0..10).asSequence().asStream()
.collect(Collectors.toCollection{LinkedHashSet<Int>()})
println("resultantSet $resultantSet")
}
输出如下所示:
收集到 Map – Collectors.toMap()
Collectors.toMap()
函数帮助我们重新包装 Stream 成 Map
实现。此函数提供了许多自定义选项。最简单的版本接受两个 lambda 表达式;第一个用于确定 Map Entry 的键,第二个用于确定 Map Entry 的值。请注意,Stream 中的每个元素都将表示为 Map
中的一个条目。
这两个 lambda 表达式将在单独的迭代中获取 Stream 的每个元素,并基于它们生成键/值。
看一下以下示例:
fun main(args: Array<String>) {
val resultantMap = (0..10).asSequence().asStream()
.collect(Collectors.toMap<Int,Int,Int>({
it
},{
it*it
}))
println("resultantMap = $resultantMap")
}
在这个程序中,我们使用了 Collectors.toMap()
函数的最简单版本。我们向其传递了两个 lambda 表达式,第一个用于确定条目的键,将返回传递给它的相同值,第二个则相反,计算并返回传递值的平方。这里需要注意的是,这两个 lambda 表达式将具有相同的参数。
输出如下所示:
字符串 Stream 的连接 – Collectors.joining()
Collectors.joining()
函数帮助您将包含字符串的 Stream 的元素连接起来。它有三个可选参数,即——delimiter
(分隔符)、prefix
(前缀)和postfix
(后缀)。
考虑以下程序的示例:
fun main(args: Array<String>) {
val resultantString = Stream.builder<String>()
.add("Item 1")
.add("Item 2")
.add("Item 3")
.add("Item 4")
.add("Item 5")
.add("Item 6")
.build()
.collect(Collectors.joining(" - ","Starts Here=>","<=Ends Here"))
println("resultantString $resultantString")
}
输出如下:
Stream 元素分组 – Collectors.groupingBy()
此函数允许我们在分组的同时将 Stream 的元素收集到一个 Map
函数中。此函数与 Collectors.toMap
的基本区别在于,此函数允许您创建一个 Map<K,List<T>>
函数,也就是说,它允许您创建一个 Map
函数,该函数将为每个组保留一个 List
值作为其值。
考虑以下示例:
fun main(args: Array<String>) {
val resultantSet = (1..20).asSequence().asStream()
.collect(Collectors.groupingBy<Int,Int> { it%5 })
println("resultantSet $resultantSet")
}
输出如下:
摘要
因此,在本章中,我们学习了关于 Streams 的内容。我们学习了如何创建 Streams,学习了如何与 Streams 一起工作,以及如何将 Stream 打包成 Collections。
在下一章中,我们将开始学习 Arrow 库,它使得在 Kotlin 中实现函数式编程变得容易。所以,不要只是等待,翻到下一页,开始学习 Arrow。
第十二章:开始使用 Arrow
Arrow (arrow-kt.io/
) 是一个 Kotlin 库,它提供了函数式构造、数据类型和其他抽象。Kotlin 语法强大且灵活,Arrow 利用这一点提供了一些标准中不包含的功能。
Arrow 是将两个最成功和最受欢迎的函数式库 funKTionale
和 Kategory
结合成一个的结果。在 2017 年晚些时候,两个开发者团队担心分裂会损害整个 Kotlin 社区,因此决定联合起来创建一个单一、统一的函数式库。
在本章中,我们将介绍如何使用现有函数构建新的和更丰富的函数。我们将涵盖的一些主题包括以下内容:
-
函数组合
-
部分应用
-
柯里化
-
缓存
-
管道
-
光学
函数组合
函数式编程作为一个概念的一个重要部分是,以我们使用任何其他类型的方式使用函数——作为值、参数、返回值等。我们可以用其他类型做的一件事是将它们作为构建其他类型的构建块;同样的概念也可以应用于函数。
函数组合是一种使用现有函数构建函数的技术;类似于 Unix 管道或通道管道,函数的结果值被用作下一个函数的参数。
在 Arrow 中,函数组合以一系列的 infix
扩展函数的形式出现:
函数 | 描述 |
---|---|
compose |
将右手函数调用的结果作为左手函数的参数。 |
forwardCompose |
将左手函数调用的结果作为右手函数的参数。 |
andThen |
是 forwardCompose 的别名。 |
让我们组合一些函数:
import arrow.syntax.function.andThen
import arrow.syntax.function.compose
import arrow.syntax.function.forwardCompose
import java.util.*
val p: (String) -> String = { body -> "<p>$body</p>" }
val span: (String) -> String = { body -> "<span>$body</span>" }
val div: (String) -> String = { body -> "<div>$body</div>" }
val randomNames: () -> String = {
if (Random().nextInt() % 2 == 0) {
"foo"
} else {
"bar"
}
}
fun main(args: Array<String>) {
val divStrong: (String) -> String = div compose strong
val spanP: (String) -> String = p forwardCompose span
val randomStrong: () -> String = randomNames andThen strong
println(divStrong("Hello composition world!"))
println(spanP("Hello composition world!"))
println(randomStrong())
}
要构建 divStrong: (String) -> String
函数,我们组合 div:(String) -> String
和 strong:(String) -> String
。换句话说,divStrong
等同于以下代码片段:
val divStrong: (String) -> String = { body -> "<div><strong>$body</div></strong>"}
对于 spanP:(String) -> String
,我们按照以下方式组合 span:(String) -> (String)
和 p:(String) -> String
:
val spanP: (String) -> String = { body -> "<span><p>$body</p></span>"}
注意我们使用的是相同的类型 (String) -> String
,但任何具有其他函数所需正确返回类型的函数都可以进行组合。
让我们用函数组合重写我们的 Channel
管道示例:
data class Quote(val value: Double, val client: String, val item: String, val quantity: Int)
data class Bill(val value: Double, val client: String)
data class PickingOrder(val item: String, val quantity: Int)
fun calculatePrice(quote: Quote) = Bill(quote.value * quote.quantity, quote.client) to PickingOrder(quote.item, quote.quantity)
fun filterBills(billAndOrder: Pair<Bill, PickingOrder>): Pair<Bill, PickingOrder>? {
val (bill, _) = billAndOrder
return if (bill.value >= 100) {
billAndOrder
} else {
null
}
}
fun warehouse(order: PickingOrder) {
println("Processing order = $order")
}
fun accounting(bill: Bill) {
println("processing = $bill")
}
fun splitter(billAndOrder: Pair<Bill, PickingOrder>?) {
if (billAndOrder != null) {
warehouse(billAndOrder.second)
accounting(billAndOrder.first)
}
}
fun main(args: Array<String>) {
val salesSystem:(Quote) -> Unit = ::calculatePrice andThen ::filterBills forwardCompose ::splitter
salesSystem(Quote(20.0, "Foo", "Shoes", 1))
salesSystem(Quote(20.0, "Bar", "Shoes", 200))
salesSystem(Quote(2000.0, "Foo", "Motorbike", 1))
}
salesSystem: (Quote) -> Unit
函数的行为非常复杂,但它是由其他函数作为构建块构建的。
部分应用
使用函数组合,我们通过取两个函数来创建第三个函数;使用部分应用,我们通过向现有函数传递参数来创建一个新的函数。
Arrow 提供了两种部分应用风格——显式和隐式。
显式风格使用一系列称为 partially1
、partially2
,一直到 partially22
的扩展函数。隐式风格通过一系列扩展,重载了 invoke
操作符:
package com.packtpub.functionalkotlin.chapter11
import arrow.syntax.function.invoke
import arrow.syntax.function.partially3
fun main(args: Array<String>) {
val strong: (String, String, String) -> String = { body, id, style -> "<strong id=\"$id\" style=\"$style\">$body</strong>" }
val redStrong: (String, String) -> String = strong.partially3("font: red") //Explicit
val blueStrong: (String, String) -> String = strong(p3 = "font: blue") //Implicit
println(redStrong("Red Sonja", "movie1"))
println(blueStrong("Deep Blue Sea", "movie2"))
}
两种风格都可以按以下方式链式使用:
fun partialSplitter(billAndOrder: Pair<Bill, PickingOrder>?, warehouse: (PickingOrder) -> Unit, accounting: (Bill) -> Unit) {
if (billAndOrder != null) {
warehouse(billAndOrder.second)
accounting(billAndOrder.first)
}
}
fun main(args: Array<String>) {
val splitter: (billAndOrder: Pair<Bill, PickingOrder>?) -> Unit = ::partialSplitter.partially2 { order -> println("TESTING $order") }(p2 = ::accounting)
val salesSystem: (quote: Quote) -> Unit = ::calculatePrice andThen ::filterBills forwardCompose splitter
salesSystem(Quote(20.0, "Foo", "Shoes", 1))
salesSystem(Quote(20.0, "Bar", "Shoes", 200))
salesSystem(Quote(2000.0, "Foo", "Motorbike", 1))
}
我们原始的 splitter
函数不够灵活,因为它直接调用了仓库和会计函数。partialSplitter
函数通过将 warehouse
和 accounting
作为参数来解决这个问题;然而,一个 (Pair<Bill, PickingOrder>?, (PickingOrder) -> Unit, (Bill) -> Unit)
函数不能用于组合。然后,我们部分应用了两个函数——一个 lambda 和一个引用。
绑定
部分应用的一个特殊情况是 绑定。使用绑定时,你向 (T) -> R
函数传递一个 T
参数,但不执行它,实际上返回一个 () -> R
函数:
fun main(args: Array<String>) {
val footer:(String) -> String = {content -> "<footer>$content</footer>"}
val fixFooter: () -> String = footer.bind("Functional Kotlin - 2018") //alias for partially1
println(fixFooter())
}
bind
函数只是 partially1
的别名,但给它一个单独的名字并使其语义更加正确是有意义的。
反转
反转接受任何函数并返回其参数顺序相反的函数(在其他语言中,此函数被称为 flip)。让我们看看以下代码:
import arrow.syntax.function.partially3
import arrow.syntax.function.reverse
fun main(args: Array<String>) {
val strong: (String, String, String) -> String = { body, id, style -> "<strong id=\"$id\" style=\"$style\">$body</strong>" }
val redStrong: (String, String) -> String = strong.partially3("font: red") //Explicit
println(redStrong("Red Sonja", "movie1"))
println(redStrong.reverse()("movie2", "The Hunt for Red October"))
}
我们的 redStrong
函数使用起来有些笨拙,因为我们期望首先有 id
然后有 body
,但可以通过 reverse
扩展函数轻松修复。reverse
函数可以应用于从参数 1
到 22
的函数。
管道
pipe
函数接受一个 T
值并使用它调用 (T) -> R
函数:
import arrow.syntax.function.pipe
fun main(args: Array<String>) {
val strong: (String) -> String = { body -> "<strong>$body</strong>" }
"From a pipe".pipe(strong).pipe(::println)
}
管道类似于函数组合,但不同之处在于我们不是生成新函数,而是可以链式调用函数以产生新的值,从而减少嵌套调用。管道在其他语言中,如 Elm 和 Ocaml 中,被称为操作符 |>
:
fun main(args: Array<String>) {
splitter(filterBills(calculatePrice(Quote(20.0, "Foo", "Shoes", 1)))) //Nested
Quote(20.0, "Foo", "Shoes", 1) pipe ::calculatePrice pipe ::filterBills pipe ::splitter //Pipe
}
两行是等价的,但第一行必须从后往前理解,第二行应该从左到右阅读:
import arrow.syntax.function.pipe
import arrow.syntax.function.pipe3
import arrow.syntax.function.reverse
fun main(args: Array<String>) {
val strong: (String, String, String) -> String = { body, id, style -> "<strong id=\"$id\" style=\"$style\">$body</strong>" }
val redStrong: (String, String) -> String = "color: red" pipe3 strong.reverse()
redStrong("movie3", "Three colors: Red") pipe ::println
}
当 pipe
应用于多参数函数时,使用其变体 pipe2
到 pipe22
,它表现得像 partially1
。
柯里化
将柯里化应用于具有 n 个参数的函数,例如 (A, B) -> R
,将其转换为 n
个函数调用的链,(A) -> (B) -> R
:
import arrow.syntax.function.curried
import arrow.syntax.function.pipe
import arrow.syntax.function.reverse
import arrow.syntax.function.uncurried
fun main(args: Array<String>) {
val strong: (String, String, String) -> String = { body, id, style -> "<strong id=\"$id\" style=\"$style\">$body</strong>" }
val curriedStrong: (style: String) -> (id: String) -> (body: String) -> String = strong.reverse().curried()
val greenStrong: (id: String) -> (body: String) -> String = curriedStrong("color:green")
val uncurriedGreenStrong: (id: String, body: String) -> String = greenStrong.uncurried()
println(greenStrong("movie5")("Green Inferno"))
println(uncurriedGreenStrong("movie6", "Green Hornet"))
"Fried Green Tomatoes" pipe ("movie7" pipe greenStrong) pipe ::println
}
柯里化形式的函数可以通过 uncurried()
转换为正常的多参数形式。
柯里化和部分应用之间的区别
在柯里化和部分应用之间存在一些混淆。一些作者将它们视为同义词,但它们是不同的:
import arrow.syntax.function.curried
import arrow.syntax.function.invoke
fun main(args: Array<String>) {
val strong: (String, String, String) -> String = { body, id, style -> "<strong id=\"$id\" style=\"$style\">$body</strong>" }
println(strong.curried()("Batman Begins")("trilogy1")("color:black")) // Curried
println(strong("The Dark Knight")("trilogy2")("color:black")) // Fake curried, just partial application
println(strong(p2 = "trilogy3")(p2 = "color:black")("The Dark Knight rises")) // partial application
}
这些区别是显著的,并且可以帮助我们决定何时使用其中一个:
柯里化 | 部分应用 | |
---|---|---|
返回值 | 当一个 N 元函数被柯里化时,它返回一个大小为 N 的函数链,(柯里化形式)。 | 当一个函数或 N 元函数被部分应用时,它返回一个 N - 1 元函数。 |
参数应用 | 柯里化后,只有链的第一个参数可以被应用。 | 任何参数都可以以任何顺序应用。 |
还原 | 可以将柯里化形式的函数还原为多参数函数。 | 由于部分应用不会改变函数形式,因此还原不适用。 |
部分应用可以更加灵活,但一些函数式风格倾向于偏好柯里化。重要的是要掌握的是,这两种风格都是不同的,并且都由 Arrow 支持。
逻辑补码
逻辑补码接受任何谓词(一个返回 Boolean
类型的函数)并将其否定。让我们看看以下代码:
import arrow.core.Predicate
import arrow.syntax.function.complement
fun main(args: Array<String>) {
val evenPredicate: Predicate<Int> = { i: Int -> i % 2 == 0 }
val oddPredicate: (Int) -> Boolean = evenPredicate.complement()
val numbers: IntRange = 1..10
val evenNumbers: List<Int> = numbers.filter(evenPredicate)
val oddNumbers: List<Int> = numbers.filter(oddPredicate)
println(evenNumbers)
println(oddNumbers)
}
注意,我们使用的是 Predicate<T>
类型,但它只是 (T) -> Boolean
的别名。从 0
到 22
参数的谓词都有补充扩展函数。
缓存
缓存是一种缓存纯函数结果的技巧。缓存函数的行为像一个普通函数,但它存储了与产生该结果提供的参数相关联的先前计算的结果。
缓存化的经典例子是斐波那契数列:
import arrow.syntax.function.memoize
import kotlin.system.measureNanoTime
fun recursiveFib(n: Long): Long = if (n < 2) {
n
} else {
recursiveFib(n - 1) + recursiveFib(n - 2)
}
fun imperativeFib(n: Long): Long {
return when (n) {
0L -> 0
1L -> 1
else -> {
var a = 0L
var b = 1L
var c = 0L
for (i in 2..n) {
c = a + b
a = b
b = c
}
c
}
}
}
fun main(args: Array<String>) {
var lambdaFib: (Long) -> Long = { it } //Declared ahead to be used inside recursively
lambdaFib = { n: Long ->
if (n < 2) n else lambdaFib(n - 1) + lambdaFib(n - 2)
}
var memoizedFib: (Long) -> Long = { it }
memoizedFib = { n: Long ->
if (n < 2) n else memoizedFib(n - 1) + memoizedFib(n - 2)
}.memoize()
println(milliseconds("imperative fib") { imperativeFib(40) }) //0.006
println(milliseconds("recursive fib") { recursiveFib(40) }) //1143.167
println(milliseconds("lambda fib") { lambdaFib(40) }) //4324.890
println(milliseconds("memoized fib") { memoizedFib(40) }) //1.588
}
inline fun milliseconds(description: String, body: () -> Unit): String {
return "$description:${measureNanoTime(body) / 1_000_000.00} ms"
}
我们缓存版本的执行速度比递归函数版本快 700 多倍(后者几乎比 lambda 版本快四倍)。命令式版本是无敌的,因为它被编译器高度优化:
fun main(args: Array<String>) = runBlocking {
var lambdaFib: (Long) -> Long = { it } //Declared ahead to be used inside recursively
lambdaFib = { n: Long ->
if (n < 2) n else lambdaFib(n - 1) + lambdaFib(n - 2)
}
var memoizedFib: (Long) -> Long = { it }
memoizedFib = { n: Long ->
println("from memoized fib n = $n")
if (n < 2) n else memoizedFib(n - 1) + memoizedFib(n - 2)
}.memoize()
val job = launch {
repeat(10) { i ->
launch(coroutineContext) { println(milliseconds("On coroutine $i - imperative fib") { imperativeFib(40) }) }
launch(coroutineContext) { println(milliseconds("On coroutine $i - recursive fib") { recursiveFib(40) }) }
launch(coroutineContext) { println(milliseconds("On coroutine $i - lambda fib") { lambdaFib(40) }) }
launch(coroutineContext) { println(milliseconds("On coroutine $i - memoized fib") { memoizedFib(40) }) }
}
}
job.join()
}
缓存函数内部使用线程安全的结构来存储其结果,因此可以在协程或任何其他并发代码上安全使用。
使用缓存函数存在潜在缺点。第一个缺点是读取内部缓存的进程比实际计算或内存消耗要高,因为目前,缓存函数不暴露任何行为来控制其内部存储。
部分函数
部分函数(不要与部分应用函数混淆)是一个对于其参数类型的每个可能值不一定有定义的函数。相比之下,总函数是对于其参数类型的每个可能值都有定义的函数。
让我们看看以下示例:
fun main(args: Array<String>) {
val upper: (String?) -> String = { s:String? -> s!!.toUpperCase()} //Partial function, it can't transform null
listOf("one", "two", null, "four").map(upper).forEach(::println) //NPE
}
upper
函数是一个部分函数;尽管 null
是一个有效的 String?
值,但它不能处理 null
值。如果你尝试运行此代码,它将抛出 NullPointerException
(NPE)。
Arrow 为类型 (T) -> R
的部分函数提供了一个显式类型 PartialFunction<T, R>
:
import arrow.core.PartialFunction
fun main(args: Array<String>) {
val upper: (String?) -> String = { s: String? -> s!!.toUpperCase() } //Partial function, it can't transform null
val partialUpper: PartialFunction<String?, String> = PartialFunction(definetAt = { s -> s != null }, f = upper)
listOf("one", "two", null, "four").map(partialUpper).forEach(::println) //IAE: Value: (null) isn't supported by this function
}
PartialFunction<T, R>
接收一个谓词 (T) -> Boolean
作为第一个参数,该参数必须返回 true
,如果函数对该特定值有定义。PartialFunction<T, R>
函数扩展自 (T) -> R
,因此它可以作为一个普通函数使用。
在这个例子中,代码仍然抛出异常,但现在类型为 IllegalArgumentException
(IAE),并带有有用的消息。
为了避免抛出异常,我们必须将我们的部分函数转换为总函数:
fun main(args: Array<String>) {
val upper: (String?) -> String = { s: String? -> s!!.toUpperCase() } //Partial function, it can't transform null
val partialUpper: PartialFunction<String?, String> = PartialFunction(definetAt = { s -> s != null }, f = upper)
listOf("one", "two", null, "four").map{ s -> partialUpper.invokeOrElse(s, "NULL")}.forEach(::println)
}
一种选择是使用 invokeOrElse
函数,在值 s
对此函数未定义时返回默认值:
fun main(args: Array<String>) {
val upper: (String?) -> String = { s: String? -> s!!.toUpperCase() } //Partial function, it can't transform null
val partialUpper: PartialFunction<String?, String> = PartialFunction(definetAt = { s -> s != null }, f = upper)
val upperForNull: PartialFunction<String?, String> = PartialFunction({ s -> s == null }) { "NULL" }
val totalUpper: PartialFunction<String?, String> = partialUpper orElse upperForNull
listOf("one", "two", null, "four").map(totalUpper).forEach(::println)
}
第二种选择是使用 orElse
函数创建一个总函数,该函数由几个部分函数组成:
fun main(args: Array<String>) {
val fizz = PartialFunction({ n: Int -> n % 3 == 0 }) { "FIZZ" }
val buzz = PartialFunction({ n: Int -> n % 5 == 0 }) { "BUZZ" }
val fizzBuzz = PartialFunction({ n: Int -> fizz.isDefinedAt(n) && buzz.isDefinedAt(n) }) { "FIZZBUZZ" }
val pass = PartialFunction({ true }) { n: Int -> n.toString() }
(1..50).map(fizzBuzz orElse buzz orElse fizz orElse pass).forEach(::println)
}
通过isDefinedAt(T)
函数,我们可以重用内部谓词,在这种情况下,用于构建fizzBuzz
的条件。当在orElse
链中使用时,声明顺序具有优先权,首先为某个值定义的部分函数将被执行,而链中的其他函数将被忽略。
身份和常量
身份和常量是简单的函数。identity
函数返回提供的参数值;类似于加法和乘法恒等性质,将 0 加到任何数上仍然是同一个数。
constant<T, R>(t: T)
函数返回一个新函数,该函数将始终返回t
值:
fun main(args: Array<String>) {
val oneToFour = 1..4
println("With identity: ${oneToFour.map(::identity).joinToString()}") //1, 2, 3, 4
println("With constant: ${oneToFour.map(constant(1)).joinToString()}") //1, 1, 1, 1
}
我们可以使用constant
重新编写我们的fizzBuzz
值:
fun main(args: Array<String>) {
val fizz = PartialFunction({ n: Int -> n % 3 == 0 }, constant("FIZZ"))
val buzz = PartialFunction({ n: Int -> n % 5 == 0 }, constant("BUZZ"))
val fizzBuzz = PartialFunction({ n: Int -> fizz.isDefinedAt(n) && buzz.isDefinedAt(n) }, constant("FIZZBUZZ"))
val pass = PartialFunction<Int, String>(constant(true)) { n -> n.toString() }
(1..50).map(fizzBuzz orElse buzz orElse fizz orElse pass).forEach(::println)
}
身份和常量函数在函数式编程或数学算法的实现中非常有用,例如,常量是 SKI 组合演算中的 K。
透镜
透镜是优雅地更新不可变数据结构的抽象。透镜的一种形式是Lens
(或透镜,具体取决于库实现)。Lens
是一个功能引用,可以聚焦(因此得名)到结构中,并读取、写入或修改其目标:
typealias GB = Int
data class Memory(val size: GB)
data class MotherBoard(val brand: String, val memory: Memory)
data class Laptop(val price: Double, val motherBoard: MotherBoard)
fun main(args: Array<String>) {
val laptopX8 = Laptop(500.0, MotherBoard("X", Memory(8)))
val laptopX16 = laptopX8.copy(
price = 780.0,
motherBoard = laptopX8.motherBoard.copy(
memory = laptopX8.motherBoard.memory.copy(
size = laptopX8.motherBoard.memory.size * 2
)
)
)
println("laptopX16 = $laptopX16")
}
要从现有值创建一个新的Laptop
值,我们需要使用多个嵌套的复制方法和引用。在这个例子中,这并不那么糟糕,但您可以想象在一个更复杂的数据结构中,事情可能会变得混乱。
让我们编写我们非常第一个Lens
值:
val laptopPrice: Lens<Laptop, Double> = Lens(
get = { laptop -> laptop.price },
set = { price -> { laptop -> laptop.copy(price = price) } }
)
laptopPrice
值是一个Lens<Laptop, Double>
,我们使用函数Lens<S, T, A, B>
(实际上是Lens.invoke
)初始化它。Lens
接受两个函数作为参数,作为get: (S) -> A
和set: (B) -> (S) -> T
。
如您所见,set
是一个柯里化函数,因此您可以像这样编写您的设置:
import arrow.optics.Lens
val laptopPrice: Lens<Laptop, Double> = Lens(
get = { laptop -> laptop.price },
set = { price: Double, laptop: Laptop -> laptop.copy(price = price) }.curried()
)
根据您的偏好,这可以使阅读和编写变得更加容易。
现在您已经拥有了第一个透镜,它可以用来设置、读取和修改笔记本电脑的价格。这并不太令人印象深刻,但透镜的魔力在于它们的组合:
import arrow.optics.modify
val laptopMotherBoard: Lens<Laptop, MotherBoard> = Lens(
get = { laptop -> laptop.motherBoard },
set = { mb -> { laptop -> laptop.copy(motherBoard = mb) } }
)
val motherBoardMemory: Lens<MotherBoard, Memory> = Lens(
get = { mb -> mb.memory },
set = { memory -> { mb -> mb.copy(memory = memory) } }
)
val memorySize: Lens<Memory, GB> = Lens(
get = { memory -> memory.size },
set = { size -> { memory -> memory.copy(size = size) } }
)
fun main(args: Array<String>) {
val laptopX8 = Laptop(500.0, MotherBoard("X", Memory(8)))
val laptopMemorySize: Lens<Laptop, GB> = laptopMotherBoard compose motherBoardMemory compose memorySize
val laptopX16 = laptopMemorySize.modify(laptopPrice.set(laptopX8, 780.0)) { size ->
size * 2
}
println("laptopX16 = $laptopX16")
}
我们通过将Laptop
到memorySize
的所有透镜组合创建了laptopMemorySize
;然后,我们可以设置笔记本电脑的价格并修改其内存。
尽管透镜很酷,但看起来有很多样板代码。不用担心,Arrow 可以为您生成这些透镜。
配置 Arrows 代码生成
在一个 Gradle 项目中,添加一个名为generated-kotlin-sources.gradle
的文件:
apply plugin: 'idea'
idea {
module {
sourceDirs += files(
'build/generated/source/kapt/main',
'build/generated/source/kaptKotlin/main',
'build/tmp/kapt/main/kotlinGenerated')
generatedSourceDirs += files(
'build/generated/source/kapt/main',
'build/generated/source/kaptKotlin/main',
'build/tmp/kapt/main/kotlinGenerated')
}
}
然后,在build.gradle
文件中,添加以下内容:
apply plugin: 'kotlin-kapt'
apply from: rootProject.file('gradle/generated-kotlin-sources.gradle')
在build.gradle
文件中添加一个新的依赖项:
dependencies {
...
kapt 'io.arrow-kt:arrow-annotations-processor:0.5.2'
...
}
一旦配置完成,您就可以使用常规的构建命令生成 Arrow 代码,./gradlew build
。
生成透镜
一旦配置了 Arrow 的代码生成,您可以将@lenses
注解添加到您希望生成透镜的数据类中:
import arrow.lenses
import arrow.optics.Lens
import arrow.optics.modify
typealias GB = Int
@lenses data class Memory(val size: GB)
@lenses data class MotherBoard(val brand: String, val memory: Memory)
@lenses data class Laptop(val price: Double, val motherBoard: MotherBoard)
fun main(args: Array<String>) {
val laptopX8 = Laptop(500.0, MotherBoard("X", Memory(8)))
val laptopMemorySize: Lens<Laptop, GB> = laptopMotherBoard() compose motherBoardMemory() compose memorySize()
val laptopX16 = laptopMemorySize.modify(laptopPrice().set(laptopX8, 780.0)) { size ->
size * 2
}
println("laptopX16 = $laptopX16")
}
Arrow 为我们的数据类生成与构造函数参数数量一样多的透镜,名称约定为classProperty
,并在同一包中,因此不需要额外的导入。
摘要
在本章中,我们介绍了 Arrow 的许多特性,这些特性为我们提供了创建、生成和丰富现有函数的工具。我们使用现有的函数组合成新的函数;我们包括了部分应用和柯里化。我们还通过记忆化缓存了纯函数的结果,并使用透镜修改了数据结构。
Arrow 的特性为使用基本函数式原则创建丰富且可维护的应用程序打开了可能性。
在下一章中,我们将介绍更多的 Arrow 特性,包括Option
、Either
和Try
等数据类型。
第十三章:箭头类型
Arrow 包含了许多传统函数类型的实现,如 Option
、Either
和 Try
,以及许多其他类型类,如 functor
和 monad
。
在本章中,我们将涵盖以下主题:
-
使用
Option
来管理空值 -
Either
和Try
用于管理错误 -
组合和转换器
-
State
用于管理应用程序状态
选项
Option<T>
数据类型是值 T
的存在或不存在的一种表示。在 Arrow 中,Option<T>
是一个密封类,有两个子类型,Some<T>
,一个表示值 T
存在的数据类,以及 None
,一个表示值不存在的一个对象。作为一个密封类,Option<T>
不能有其他子类型;因此编译器可以全面检查,如果 Some<T>
和 None
都被覆盖。
我知道(或者假装知道)你此刻的想法——为什么我需要 Option<T>
来表示 T
的存在或不存在,如果 Kotlin 中已经有 T
表示存在,T?
表示不存在呢?
你是对的。但是 Option
提供的比可空类型更多的价值,让我们直接跳到例子:
fun divide(num: Int, den: Int): Int? {
return if (num % den != 0) {
null
} else {
num / den
}
}
fun division(a: Int, b: Int, den: Int): Pair<Int, Int>? {
val aDiv = divide(a, den)
return when (aDiv) {
is Int -> {
val bDiv = divide(b, den)
when (bDiv) {
is Int -> aDiv to bDiv
else -> null
}
}
else -> null
}
}
division
函数接受三个参数——两个整数(a
,b
)和一个除数(den
),如果两个数都能被 den
整除,则返回一个 Pair<Int, Int>
,否则返回 null
。
我们可以用 Option
表达相同的算法:
import arrow.core.*
import arrow.syntax.option.toOption
fun optionDivide(num: Int, den: Int): Option<Int> = divide(num, den).toOption()
fun optionDivision(a: Int, b: Int, den: Int): Option<Pair<Int, Int>> {
val aDiv = optionDivide(a, den)
return when (aDiv) {
is Some -> {
val bDiv = optionDivide(b, den)
when (bDiv) {
is Some -> Some(aDiv.t to bDiv.t)
else -> None
}
}
else -> None
}
}
函数 optionDivide
接受除法操作的空值结果,并使用 toOption()
扩展函数将其作为 Option
返回。
与 division
相比,optionDivision
没有太大变化,它是以不同类型表达的同一种算法。如果我们在这里停止,那么 Option<T>
在可空类型之上并没有提供额外的价值。幸运的是,情况并非如此;有更多使用 Option
的方法:
fun flatMapDivision(a: Int, b: Int, den: Int): Option<Pair<Int, Int>> {
return optionDivide(a, den).flatMap { aDiv: Int ->
optionDivide(b, den).flatMap { bDiv: Int ->
Some(aDiv to bDiv)
}
}
}
Option
提供了几个函数来处理其内部值,在这个例子中,flatMap
(作为一个 monad)以及现在我们的代码看起来要短得多。
看看以下简短列表中的一些 Option<T>
函数:
函数 | 描述 |
---|---|
exists(p :Predicate<T>): Boolean |
如果存在值 T ,则返回谓词 p 的结果,否则返回 null。 |
filter(p: Predicate<T>): Option<T> |
如果值 T 存在并且满足谓词 p ,则返回 Some<T> ,否则返回 None 。 |
flatMap(f: (T) -> Option<T>): Option<T> |
一个 flatMap 转换函数(类似于 monad)。 |
<R> fold(ifEmpty: () -> R, some: (T) -> R): R<R> |
返回值被转换为 R ,对于 None 调用 ifEmpty ,对于 Some<T> 调用 some 。 |
getOrElse(default:() -> T): T |
如果存在值 T ,则返回值 T ,否则返回 default 结果。 |
<R> map(f: (T) -> R):Option<T> |
一个转换函数(类似于 functor )。 |
orNull(): T? |
将值 T 返回为可空的 T? 。 |
最后的除法实现将使用列表推导式:
import arrow.typeclasses.binding
fun comprehensionDivision(a: Int, b: Int, den: Int): Option<Pair<Int, Int>> {
return Option.monad().binding {
val aDiv: Int = optionDivide(a, den).bind()
val bDiv: Int = optionDivide(b, den).bind()
aDiv to bDiv
}.ev()
}
组合是一种计算技术,可以按顺序计算任何包含 flatMap
函数并能提供 monad 实例的类型(例如 Option
、List
等)。
在 Arrow 中,使用协程进行组合。是的,协程在异步执行域之外也很有用。
如果我们将之前示例中的延续进行概述,它将看起来像这样(这是一个有助于理解协程的有用心理模型)
fun comprehensionDivision(a: Int, b: Int, den: Int): Option<Pair<Int, Int>> {
return Option.monad().binding {
val aDiv: Int = optionDivide(a, den).bind()
// start continuation 1
val bDiv: Int = optionDivide(b, den).bind()
//start continuation 2
aDiv to bDiv
//end continuation 2
// end continuation 1
}.ev()
}
Option.monad().binding
是一个协程构建器,而 bind()
函数是一个挂起函数。如果你正确地回忆起我们的协程章节,延续是任何挂起点之后(即挂起函数被调用时)的代码表示。在我们的示例中,我们有两个挂起点和两个延续,当我们返回(在最后一行块中)时,我们处于第二个延续中,我们可以访问两个值,aDiv
和 bDiv
。
将此算法作为延续来阅读与我们的 flatMapDivision
函数非常相似。在幕后,Option.monad().binding
使用带有延续的 Option.flatMap
来创建组合;一旦编译,comprehensionDivision
和 flatMapDivision
在很大程度上是等价的。
ev()
方法将在下一节中解释。
Arrow 的类型层次结构
Kotlin 的类型系统存在一个限制——它不支持高阶类型(HKT)。不深入类型理论的话,HKT 是一种声明其他泛型值作为类型参数的类型:
class MyClass<T>() //Valid Kotlin code
class MyHigherKindedClass<K<T>>() //Not valid kotlin code
对于 Kotlin 的函数式编程来说,缺少 HKT 并不是一件好事,因为许多高级函数式构造和模式都使用了它们。
Arrow 团队正在致力于Kotlin 进化和增强过程(KEEP)——这是一个社区流程,用于添加新的语言特性,在 Kotlin 中称为类型类扩展(github.com/Kotlin/KEEP/pull/87
)以支持 HKT 和其他特性。目前,尚不清楚这个 KEEP(代码为 KEEP-87)何时会被纳入 Kotlin,但到目前为止,这是最受评论的提案,并吸引了大量关注。由于它仍在进行中,细节尚不明确,但其中透露出一丝希望。
Arrow 解决这个问题的方法是通过对称为基于证据的 HKTs 的技术进行模拟。
让我们看看一个 Option<T>
的声明:
package arrow.core
import arrow.higherkind
import java.util.*
/**
* Represents optional values. Instances of `Option`
* are either an instance of $some or the object $none.
*/
@higherkind
sealed class Option<out A> : OptionKind<A> {
//more code goes here
Option<A>
使用了 @higherkind
注解,这与我们上一章中提到的 @lenses
类似;这个注解用于生成支持基于证据的 HKTs 的代码。Option<A>
从 OptionKind<A>
扩展而来:
package arrow.core
class OptionHK private constructor()
typealias OptionKind<A> = arrow.HK<OptionHK, A>
@Suppress("UNCHECKED_CAST", "NOTHING_TO_INLINE")
inline fun <A> OptionKind<A>.ev(): Option<A> =
this as Option<A>
OptionKind<A>
是 HK<OptionHK, A>
的类型别名,所有这些代码都是使用 @higherkind
注解处理器生成的。OptionHK
是一个不可实例化的类,用作 HK
和 OptionKind
的唯一标签名称,而 OptionKind
是 HKT 的一种中间表示形式。Option.monad().binding
返回 OptionKind<T>
,这就是为什么我们需要在最后调用 ev()
来返回一个合适的 Option<T>
:
package arrow
interface HK<out F, out A>
typealias HK2<F, A, B> = HK<HK<F, A>, B>
typealias HK3<F, A, B, C> = HK<HK2<F, A, B>, C>
typealias HK4<F, A, B, C, D> = HK<HK3<F, A, B, C>, D>
typealias HK5<F, A, B, C, D, E> = HK<HK4<F, A, B, C, D>, E>
HK
接口(higher-kinded 的简称)用于表示一元到 HK5
的 HKT,其中 HK5
表示五元。在 HK<F, A>
上,F
表示类型,A
表示泛型参数,因此 Option<Int>
是 OptionKind<Int>
值,它是 HK<OptionHK, Int>
。
让我们看看 Functor<F>
:
package arrow.typeclasses
import arrow.*
@typeclass
interface Functor<F> : TC {
fun <A, B> map(fa: HK<F, A>, f: (A) -> B): HK<F, B>
}
Functor<F>
扩展 TC
,一个标记接口,并且,正如你可以猜到的,它有一个 map
函数。map
函数接收 HK<F, A>
作为第一个参数,并接收一个 lambda (A) -> B
来将 A
的值转换为 B
,并将其转换为 HK<F, B>
。
让我们创建我们的基本数据类型 Mappable
,它可以提供 Functor
类型类的实例:
import arrow.higherkind
@higherkind
class Mappable<T>(val t: T) : MappableKind<T> {
fun <R> map(f: (T) -> R): Mappable<R> = Mappable(f(t))
override fun toString(): String = "Mappable(t=$t)"
companion object
}
我们类 Mappable<T>
用 @higherkind
注解,并扩展 MappableKind<T>
,必须有一个伴随对象,无论它是空的还是非空的。
现在,我们需要创建我们的 Functor<F>
实现:
import arrow.instance
import arrow.typeclasses.Functor
@instance(Mappable::class)
interface MappableFunctorInstance : Functor<MappableHK> {
override fun <A, B> map(fa: MappableKind<A>, f: (A) -> B): Mappable<B> {
return fa.ev().map(f)
}
}
我们的 MappableFunctorInstance
接口扩展 Functor<MappableHK>
并用 @instance(Mappable::class)
注解。在 map
函数内部,我们使用第一个参数 MappableKind<A>
并使用其 map
函数。
@instance
注解将生成一个扩展接口的对象,MappableFunctorInstance
。它将创建一个 Mappable.Companion.functor()
扩展函数,使用 Mappable.functor()
(这是我们如何使用 Option.monad()
的方式)来获取实现 MappableFunctorInstance
的对象。
另一个替代方案是让 Arrow 衍生的实例自动提供,前提是你的数据类型具有正确的函数:
import arrow.deriving
@higherkind
@deriving(Functor::class)
class DerivedMappable<T>(val t: T) : DerivedMappableKind<T> {
fun <R> map(f: (T) -> R): DerivedMappable<R> = DerivedMappable(f(t))
override fun toString(): String = "DerivedMappable(t=$t)"
companion object
}
@deriving
注解将生成 DerivedMappableFunctorInstance
,这通常是你手动编写的。
现在,我们可以创建一个泛型函数来使用我们的 Mappable
函子:
import arrow.typeclasses.functor
inline fun <reified F> buildBicycle(mapper: HK<F, Int>,
noinline f: (Int) -> Bicycle,
FR: Functor<F> = functor()): HK<F, Bicycle> = FR.map(mapper, f)
buildBicycle
函数将接受任何 HK<F, Int>
作为参数,并使用由 arrow.typeclasses.functor
函数返回的 Functor
实现应用函数 f
,并返回 HK<F, Bicycle>
。
函数 arrow.typeclass.functor
在运行时解析,符合 Functor<MappableHK>
要求的实例:
fun main(args: Array<String>) {
val mappable: Mappable<Bicycle> = buildBicycle(Mappable(3), ::Bicycle).ev()
println("mappable = $mappable") //Mappable(t=Bicycle(gears=3))
val option: Option<Bicycle> = buildBicycle(Some(2), ::Bicycle).ev()
println("option = $option") //Some(Bicycle(gears=2))
val none: Option<Bicycle> = buildBicycle(None, ::Bicycle).ev()
println("none = $none") //None
}
我们可以使用 buildBicycle
与 Mappeable<Int>
或任何其他 HKT 类,例如 Option<T>
。
使用箭头方法处理 HKT 的问题之一是它必须在运行时解析其实例。这是因为 Kotlin 没有对隐式或编译时解决类型类实例的支持,这使得 Arrow 只能选择这个替代方案,直到 KEEP-87 被批准并包含在语言中:
@higherkind
class NotAFunctor<T>(val t: T) : NotAFunctorKind<T> {
fun <R> map(f: (T) -> R): NotAFunctor<R> = NotAFunctor(f(t))
override fun toString(): String = "NotAFunctor(t=$t)"
}
因此,你可以有一个具有 map
函数的 HKT,但没有 Functor
实例无法使用,但这并不是编译错误:
fun main(args: Array<String>) {
val not: NotAFunctor<Bicycle> = buildBicycle(NotAFunctor(4), ::Bicycle).ev()
println("not = $not")
}
使用 NotAFunctor<T>
函数调用 buildBicycle
会编译,但在运行时将抛出 ClassNotFoundException
异常。
现在我们已经了解了 Arrow 的层次结构如何工作,我们可以介绍其他类。
Either
Either<L, R>
表示两种可能值之一 L
或 R
,但不是同时表示。Either
是一个密封类(类似于 Option
),有两个子类型 Left<L>
和 Right<R>
。通常 Either
用于表示可能失败的结果,使用左侧表示错误,右侧表示成功的结果。因为表示可能失败的操作是常见场景,Arrow 的 Either
是右偏的,换句话说,除非有其他说明,否则所有操作都在右侧运行。
让我们将我们的除法示例从 Option
转换为 Either
:
import arrow.core.Either
import arrow.core.Either.Right
import arrow.core.Either.Left
fun eitherDivide(num: Int, den: Int): Either<String, Int> {
val option = optionDivide(num, den)
return when (option) {
is Some -> Right(option.t)
None -> Left("$num isn't divisible by $den")
}
}
现在而不是返回一个 None
值,我们正在向用户返回有价值的信息:
import arrow.core.Tuple2
fun eitherDivision(a: Int, b: Int, den: Int): Either<String, Tuple2<Int, Int>> {
val aDiv = eitherDivide(a, den)
return when (aDiv) {
is Right -> {
val bDiv = eitherDivide(b, den)
when (bDiv) {
is Right -> Right(aDiv.getOrElse { 0 } toT bDiv.getOrElse { 0 })
is Left -> bDiv as Either<String, Nothing>
}
}
is Left -> aDiv as Either<String, Nothing>
}
}
在 eitherDivision
中,我们使用 Arrow 的 Tuple<A, B>
而不是 Kotlin 的 Pair<A, B>
。元组比 Pair/Triple 提供更多功能,从现在开始我们将使用它。要创建 Tuple2
,可以使用扩展 infix
函数,toT
。
接下来,是一个 Either<L, R>
函数的简短列表:
函数 | 描述 |
---|---|
bimap(fa:(L) -> T, fb:(R) -> X): Either<T, X> |
使用 fa 对 Left 进行转换,使用 fb 对 Right 进行转换,以返回 Either<T, X> 。 |
contains(elem:R): Boolean |
如果 Right 值与 elem 参数相同,则返回 true ,对于 Left 返回 false 。 |
exists(p:Predicate<R>):Boolean |
如果 Right ,则返回谓词 p 的结果,对于 Left 总是返回 false 。 |
flatMap(f: (R) -> Either<L, T>): Either<L, T> |
与 Monad 中的 flatMap 函数类似,使用 Right 的值。 |
fold(fa: (L) -> T, fb: (R) -> T): T |
执行 fa 对 Left 和 fb 对 Right 返回 T 值。 |
getOrElse(default:(L) -> R): R |
返回 Right 值,或 default 函数的结果。 |
isLeft(): Boolean |
如果是 Left 的实例,则返回 true ,对于 Right 返回 false 。 |
isRight(): Boolean |
如果是 Right 的实例,则返回 true ,对于 Left 返回 false 。 |
map(f: (R) -> T): Either<L, T> |
与 Functor 中的 map 函数类似,如果 Right ,则使用函数 f 将其转换为 Right<T> ,如果 Left ,则返回相同值而不进行转换。 |
mapLeft(f: (L) -> T): Either<T, R> |
与 Functor 中的 map 函数类似,如果 Left ,则使用函数 f 将其转换为 Left<T> ,如果 Right ,则返回相同值而不进行转换。 |
swap(): Either<R, L> |
返回类型和值互换的 Either 。 |
toOption(): Option<R> |
对于 Right 返回 Some<T> ,对于 Left 返回 None 。 |
flatMap
版本看起来符合预期:
fun flatMapEitherDivision(a: Int, b: Int, den: Int): Either<String, Tuple2<Int, Int>> {
return eitherDivide(a, den).flatMap { aDiv ->
eitherDivide(b, den).flatMap { bDiv ->
Right(aDiv toT bDiv)
}
}
}
Either
具有单子实现,因此我们可以调用绑定函数:
fun comprehensionEitherDivision(a: Int, b: Int, den: Int): Either<String, Tuple2<Int, Int>> {
return Either.monad<String>().binding {
val aDiv = eitherDivide(a, den).bind()
val bDiv = eitherDivide(b, den).bind()
aDiv toT bDiv
}.ev()
注意 Either.monad<L>()
;对于 Either<L, R>
,它必须定义 L
类型:
fun main(args: Array<String>) {
eitherDivision(3, 2, 4).fold(::println, ::println) //3 isn't divisible by 4
}
在下一节中,我们将学习单子变换器。
单子变换器
Either
和Option
使用简单,但如果我们将两者结合会发生什么呢?
object UserService {
fun findAge(user: String): Either<String, Option<Int>> {
//Magic
}
}
UserService.findAge
返回Either<String, Option<Int>>
;Left<String>
表示访问数据库或其他基础设施时出错,Right<None>
表示数据库中没有找到值,Right<Some<Int>>
表示找到了值:
import arrow.core.*
import arrow.syntax.function.pipe
fun main(args: Array<String>) {
val anakinAge: Either<String, Option<Int>> = UserService.findAge("Anakin")
anakinAge.fold(::identity, { op ->
op.fold({ "Not found" }, Int::toString)
}) pipe ::println
}
要打印年龄,我们需要两个嵌套的折叠,没有什么太复杂的。问题出现在我们需要执行访问多个值的操作时:
import arrow.core.*
import arrow.syntax.function.pipe
import kotlin.math.absoluteValue
fun main(args: Array<String>) {
val anakinAge: Either<String, Option<Int>> = UserService.findAge("Anakin")
val padmeAge: Either<String, Option<Int>> = UserService.findAge("Padme")
val difference: Either<String, Option<Either<String, Option<Int>>>> = anakinAge.map { aOp ->
aOp.map { a ->
padmeAge.map { pOp ->
pOp.map { p ->
(a - p).absoluteValue
}
}
}
}
difference.fold(::identity, { op1 ->
op1.fold({ "Not Found" }, { either ->
either.fold(::identity, { op2 ->
op2.fold({ "Not Found" }, Int::toString) })
})
}) pipe ::println
}
摩纳哥不组合,这使得这些操作很快就会变得复杂。但是,我们总是可以依赖列表解析,不是吗?现在,让我们看看以下代码:
import arrow.core.*
import arrow.syntax.function.pipe
import arrow.typeclasses.binding
import kotlin.math.absoluteValue
fun main(args: Array<String>) {
val anakinAge: Either<String, Option<Int>> = UserService.findAge("Anakin")
val padmeAge: Either<String, Option<Int>> = UserService.findAge("Padme")
val difference: Either<String, Option<Option<Int>>> = Either.monad<String>().binding {
val aOp: Option<Int> = anakinAge.bind()
val pOp: Option<Int> = padmeAge.bind()
aOp.map { a ->
pOp.map { p ->
(a - p).absoluteValue
}
}
}.ev()
difference.fold(::identity, { op1 ->
op1.fold({ "Not found" }, { op2 ->
op2.fold({ "Not found" }, Int::toString) }) }) pipe ::println
}
这样更好,返回类型不那么长,fold
也更易于管理。让我们看看以下代码片段中的嵌套列表解析:
fun main(args: Array<String>) {
val anakinAge: Either<String, Option<Int>> = UserService.findAge("Anakin")
val padmeAge: Either<String, Option<Int>> = UserService.findAge("Padme")
val difference: Either<String, Option<Int>> = Either.monad<String>().binding {
val aOp: Option<Int> = anakinAge.bind()
val pOp: Option<Int> = padmeAge.bind()
Option.monad().binding {
val a: Int = aOp.bind()
val p: Int = pOp.bind()
(a - p).absoluteValue
}.ev()
}.ev()
difference.fold(::identity, { op ->
op.fold({ "Not found" }, Int::toString)
}) pipe ::println
}
现在,我们有两个值和结果具有相同的类型。但我们还有一个选择,摩纳哥转换器。
摩纳哥转换器是两个摩纳哥的组合,可以作为一个整体执行。在我们的例子中,我们将使用OptionT
(Option Transformer的缩写),因为Option
是嵌套在Either
内部的摩纳哥类型:
import arrow.core.*
import arrow.data.OptionT
import arrow.data.monad
import arrow.data.value
import arrow.syntax.function.pipe
import arrow.typeclasses.binding
import kotlin.math.absoluteValue
fun main(args: Array<String>) {
val anakinAge: Either<String, Option<Int>> = UserService.findAge("Anakin")
val padmeAge: Either<String, Option<Int>> = UserService.findAge("Padme")
val difference: Either<String, Option<Int>> = OptionT.monad<EitherKindPartial<String>>().binding {
val a: Int = OptionT(anakinAge).bind()
val p: Int = OptionT(padmeAge).bind()
(a - p).absoluteValue
}.value().ev()
difference.fold(::identity, { op ->
op.fold({ "Not found" }, Int::toString)
}) pipe ::println
}
我们使用OptionT.monad<EitherKindPartial<String>>().binding
。EitherKindPartial<String>
摩纳哥意味着包装类型是Either<String, Option<T>>
。
在binding
块内部,我们使用OptionT
对类型为Either<String, Option<T>>
(技术上是对类型为HK<HK<EitherHK, String>, Option<T>>
的值)的值调用bind(): T
,在我们的例子中T
是Int
。
之前我们只使用了ev()
方法,但现在我们需要使用value()
方法来提取OptionT
内部值。
在下一节中,我们将学习关于Try
类型的内容。
Try
Try表示可能成功或失败的计算。Try<A>
是一个密封类,有两个可能的子类—Failure<A>
,表示失败,Success<T>
表示成功操作。
让我们用Try
来写我们的除法示例:
import arrow.data.Try
fun tryDivide(num: Int, den: Int): Try<Int> = Try { divide(num, den)!! }
创建Try
实例的最简单方法是使用Try.invoke
操作符。如果块内部抛出异常,它将返回Failure
;如果一切顺利,例如返回Success<Int>
,则!!
操作符将抛出NPE
,如果除法返回 null:
fun tryDivision(a: Int, b: Int, den: Int): Try<Tuple2<Int, Int>> {
val aDiv = tryDivide(a, den)
return when (aDiv) {
is Success -> {
val bDiv = tryDivide(b, den)
when (bDiv) {
is Success -> {
Try { aDiv.value toT bDiv.value }
}
is Failure -> Failure(bDiv.exception)
}
}
is Failure -> Failure(aDiv.exception)
}
}
让我们看看Try<T>
函数的简短列表:
函数 | 描述 |
---|---|
exists(p: Predicate<T>): Boolean |
如果Success<T> 返回p 结果,在Failure 时总是返回false 。 |
filter(p: Predicate<T>): Try<T> |
如果操作成功并且通过谓词p ,则返回Success<T> ,否则返回Failure 。 |
<R> flatMap(f: (T) -> Try<R>): Try<R> |
flatMap 函数,类似于摩纳哥。 |
<R> fold(fa: (Throwable) -> R, fb:(T) -> R): R |
返回值转换为R ,如果为Failure 则调用fa 。 |
getOrDefault(default: () -> T): T |
返回值T ,如果为Failure 则调用默认值。 |
getOrElse(default: (Throwable) -> T): T |
返回值T ,如果为Failure 则调用默认值。 |
isFailure(): Boolean |
如果是 Failure 则返回 true ,否则返回 false 。 |
isSuccess(): Boolean |
如果是 Success 则返回 true ,否则返回 false 。 |
<R> map(f: (T) -> R): Try<R> |
如同在函子中转换函数。 |
onFailure(f: (Throwable) -> Unit): Try<T> |
在 Failure 上执行操作。 |
onSuccess(f: (T) -> Unit): Try<T> |
在 Success 上执行操作。 |
orElse(f: () -> Try<T>): Try<T> |
在 Success 上返回自身或在 Failure 上返回 f 的结果。 |
recover(f: (Throwable) -> T): Try<T> |
转换 map 函数用于 Failure 。 |
recoverWith(f: (Throwable) -> Try<T>): Try<T> |
转换 flatMap 函数用于 Failure 。 |
toEither() : Either<Throwable, T> |
转换为 Either ——Failure 转换为 Left<Throwable> ,Success<T> 转换为 Right<T> 。 |
toOption(): Option<T> |
转换为 Option ——Failure 转换为 None ,Success<T> 转换为 Some<T> 。 |
flatMap
的实现与 Either
和 Option
非常相似,展示了拥有一个共同的命名和行为约定的价值:
fun flatMapTryDivision(a: Int, b: Int, den: Int): Try<Tuple2<Int, Int>> {
return tryDivide(a, den).flatMap { aDiv ->
tryDivide(b, den).flatMap { bDiv ->
Try { aDiv toT bDiv }
}
}
}
Monadic 理解也适用于 Try
:
fun comprehensionTryDivision(a: Int, b: Int, den: Int): Try<Tuple2<Int, Int>> {
return Try.monad().binding {
val aDiv = tryDivide(a, den).bind()
val bDiv = tryDivide(b, den).bind()
aDiv toT bDiv
}.ev()
}
另一种使用 MonadError
实例的 monadic 理解:
fun monadErrorTryDivision(a: Int, b: Int, den: Int): Try<Tuple2<Int, Int>> {
return Try.monadError().bindingCatch {
val aDiv = divide(a, den)!!
val bDiv = divide(b, den)!!
aDiv toT bDiv
}.ev()
}
使用 monadError.bindingCatch
,任何抛出异常的操作都会提升到 Failure
,最后返回值会被包裹在 Try<T>
中。MonadError
也适用于 Option
和 Either
。
State
State 是一种提供函数式处理应用程序状态的结构的结构。State<S, A>
是 S -> Tuple2<S, A>
的抽象。S 代表状态类型,Tuple2<S, A>
是结果,其中 S
是新更新的状态,A
是函数返回值。
我们可以从一个简单的例子开始,一个返回两个东西的函数,一个价格和计算它的步骤。为了计算价格,我们需要加上 20% 的 VAT
,如果 price
值超过某个阈值,则应用折扣:
import arrow.core.Tuple2
import arrow.core.toT
import arrow.data.State
typealias PriceLog = MutableList<Tuple2<String, Double>>
fun addVat(): State<PriceLog, Unit> = State { log: PriceLog ->
val (_, price) = log.last()
val vat = price * 0.2
log.add("Add VAT: $vat" toT price + vat)
log toT Unit
}
我们有一个类型别名 PriceLog
用于 MutableList<Tuple2<String, Double>>
。PriceLog
将是我们的 State
表示;每个步骤都用 Tuple2<String, Double>
表示。
我们的第一个函数 addVat(): State<PriceLog, Unit>
表示第一步。我们使用 State
构建器编写这个函数,它接收 PriceLog
,在应用任何步骤之前的初始状态,并必须返回一个 Tuple2<PriceLog, Unit>
,我们使用 Unit
因为在这个点上我们不需要价格:
fun applyDiscount(threshold: Double, discount: Double): State<PriceLog, Unit> = State { log ->
val (_, price) = log.last()
if (price > threshold) {
log.add("Applying -$discount" toT price - discount)
} else {
log.add("No discount applied" toT price)
}
log toT Unit
}
applyDiscount
函数是我们的第二步。我们在这里引入的唯一新元素是两个参数,一个用于 threshold
,另一个用于 discount
:
fun finalPrice(): State<PriceLog, Double> = State { log ->
val (_, price) = log.last()
log.add("Final Price" toT price)
log toT price
}
最后一步由函数 finalPrice()
表示,现在我们返回 Double
而不是 Unit
:
import arrow.data.ev
import arrow.instances.monad
import arrow.typeclasses.binding
fun calculatePrice(threshold: Double, discount: Double) = State().monad<PriceLog>().binding {
addVat().bind() //Unit
applyDiscount(threshold, discount).bind() //Unit
val price: Double = finalPrice().bind()
price
}.ev()
为了表示步骤序列,我们使用 monadic 理解并按顺序使用 State
函数。从一个函数到下一个函数,PriceLog
状态隐式流动(只是某些协程连续性的魔法)。最后,我们产生最终价格。添加新步骤或切换现有步骤就像添加或移动行一样简单:
import arrow.data.run
import arrow.data.runA
fun main(args: Array<String>) {
val (history: PriceLog, price: Double) = calculatePrice(100.0, 2.0).run(mutableListOf("Init" toT 15.0))
println("Price: $price")
println("::History::")
history
.map { (text, value) -> "$text\t|\t$value" }
.forEach(::println)
val bigPrice: Double = calculatePrice(100.0, 2.0).runA(mutableListOf("Init" toT 1000.0))
println("bigPrice = $bigPrice")
}
要使用 calculatePrice
函数,你必须提供阈值和折扣值,然后使用初始状态调用扩展函数 run
。如果你只对价格感兴趣,可以使用 runA
,或者只对历史感兴趣,使用 runS
。
避免使用 State
产生的问题。不要混淆扩展函数 arrow.data.run
与扩展函数 kotlin.run
(默认导入)。
使用 State
的核心递归
State
在核心递归中很有用;我们可以用 State
重新编写我们的旧例子:
fun <T, S> unfold(s: S, f: (S) -> Pair<T, S>?): Sequence<T> {
val result = f(s)
return if (result != null) {
sequenceOf(result.first) + unfold(result.second, f)
} else {
sequenceOf()
}
}
我们原始的 unfold
函数使用一个函数,f: (S) -> Pair<T,S>?
,这与 State<S, T>
非常相似:
fun <T, S> unfold(s: S, state: State<S, Option<T>>): Sequence<T> {
val (actualState: S, value: Option<T>) = state.run(s)
return value.fold(
{ sequenceOf() },
{ t ->
sequenceOf(t) + unfold(actualState, state)
})
}
我们不再使用 lambda (S) -> Pair<T, S>?
,而是使用 State<S, Option<T>>
,并使用 Option
的 fold 函数,对于 None
使用空 Sequence
,对于 Some<T>
使用递归调用:
fun factorial(size: Int): Sequence<Long> {
return sequenceOf(1L) + unfold(1L to 1) { (acc, n) ->
if (size > n) {
val x = n * acc
(x) to (x to n + 1)
} else
null
}
}
我们旧的阶乘函数使用 unfold
与 Pair<Long, Int>
和 lambda—(Pair<Long, Int>) -> Pair<Long, Pair<Long, Int>>?
:
import arrow.syntax.option.some
fun factorial(size: Int): Sequence<Long> {
return sequenceOf(1L) + unfold(1L toT 1, State { (acc, n) ->
if (size > n) {
val x = n * acc
(x toT n + 1) toT x.some()
} else {
(0L toT 0) toT None
}
})
}
重新编写的阶乘使用 State<Tuple<Long, Int>, Option<Long>>
,但内部逻辑几乎相同,尽管我们新的阶乘没有使用 null,这是一个显著的改进:
fun fib(size: Int): Sequence<Long> {
return sequenceOf(1L) + unfold(Triple(0L, 1L, 1)) { (cur, next, n) ->
if (size > n) {
val x = cur + next
(x) to Triple(next, x, n + 1)
}
else
null
}
}
同样,fib
使用 unfold 与 Triple<Long, Long, Int>
和 lambda (Triple<Long, Long, Int>) -> Pair<Long, Triple<Long, Long, Int>>?
:
import arrow.syntax.tuples.plus
fun fib(size: Int): Sequence<Long> {
return sequenceOf(1L) + unfold((0L toT 1L) + 1, State { (cur, next, n) ->
if (size > n) {
val x = cur + next
((next toT x) + (n + 1)) toT x.some()
} else {
((0L toT 0L) + 0) toT None
}
})
}
重新编写的 fib
使用 State<Tuple3<Long, Long, Int>, Option<Long>>
。请注意,扩展操作符函数 plus
,与 Tuple2<A, B>
和 C
一起使用将返回 Tuple3<A, B, C>
:
fun main(args: Array<String>) {
factorial(10).forEach(::println)
fib(10).forEach(::println)
}
现在,我们可以使用我们的核心递归函数来生成序列。State
的其他许多用途我们在这里无法涵盖,例如来自 企业集成模式 的 消息历史(www.enterpriseintegrationpatterns.com/patterns/messaging/MessageHistory.html
)或具有多个步骤的表单导航,如飞机检查或长注册表单。
摘要
Arrow 提供了许多数据类型和类型类,可以显著简化复杂任务,并提供了一套标准的惯用和表达式。在本章中,我们学习了如何使用 Option
抽象 null 值,以及如何使用 Either
和 Try
表达计算。我们创建了一个数据类型类,还学习了单调组合和转换。最后但同样重要的是,我们使用了 State
来表示应用程序状态。
通过本章,我们到达了这次旅程的终点,但请放心,这并不是你学习函数式编程的终点。正如我们在第一章所学,函数式编程全部关于使用函数作为构建块来创建复杂程序。同样,通过在这里学到的所有概念,你现在可以理解和掌握新的、令人兴奋的、更强大的想法。
现在你开始了一段新的学习之旅。
第十四章:Kotlin 快速入门
本书旨在为已经熟悉 Kotlin 工作方式的人编写。但如果你对这个语言完全陌生,不用担心,我们为你提供了阅读、理解和充分利用本书所需的一切。
在本附录中,我们将涵盖以下主题:
-
使用 Kotlin 的不同方式
-
Kotlin 的基本控制结构
-
其他资源
编写和运行 Kotlin
我们将涵盖一套全面的选项来编写和运行 Kotlin,从最简单的到最专业的。
Kotlin 在线
没有需要安装的,只需打开 Kotlin (try.kotlinlang.org/
)。Kotlin 在线包含了编写和运行简单 Kotlin 程序所需的一切,包括 JVM 和 JavaScript 编译选项。如果你有账户,你甚至可以创建和保存你的程序:
在你的控制台中
对于任何严肃且打算用于生产代码的内容,在线选项并不是最佳选择。让我们来探讨如何在你的机器上安装它。
安装 SDKMAN
安装 Kotlin 最便捷的选项是使用 SDKMAN,这是一个用于安装和更新 JVM 工具的工具。
使用以下命令安装 SDKMAN(如果你还没有安装):
$ curl -s "https://get.sdkman.io" | bash
一旦 SDKMAN 安装完成,我们就可以使用它来安装 Kotlin 并保持其更新,以及其他工具,如 Gradle 和 Maven。
通过 SDKMAN 安装 Kotlin
要通过 SDKMAN 安装 Kotlin,你只需输入以下内容:
$ sdk install kotlin
现在我们已经在我们的控制台中有了 Kotlin 命令。
Kotlin 的 REPL
要与 Kotlin 的 REPL 互动,你可以输入以下内容:
$ kotlinc
现在你可以输入并执行 Kotlin 表达式:
$ kotlinc
Welcome to Kotlin version 1.2.21 (JRE 1.8.0_111-b14)
Type :help for help, :quit for quit
>>> println("Hello, World!")
Hello, World!
>>>
要退出 Kotlin 的 REPL,你可以输入:quit
编译和执行 Kotlin 文件
支持 Kotlin 的编辑器有多个——Micro、Vim、NeoVim、Atom 和 VS Code。
我首选使用的编辑器是 Micro,(micro-editor.github.io/index.html
)。它是一个快速且易于使用的编辑器(有点像超强大的 Nano):
在你喜欢的编辑器中,创建一个名为hello.kt
的文件,并输入以下代码:
fun main(args: Array<String>) {
println("Hello, World!")
}
现在在你的控制台中,使用以下命令编译你的程序:
$ kotlinc hello.kt
要执行它,请在你的控制台中输入以下内容:
$ kotlin HelloKt
使用 Gradle
对于任何超过几个文件的任何项目,构建工具都成为必需品。构建工具为你提供了一种实用方式来编译、管理库以及打包和执行应用程序。Gradle (docs.gradle.org/current/release-notes.html
) 是一个支持多种语言的构建工具,包括 Kotlin。
通过 SDKMAN 安装 Gradle
要通过 SDKMAN 安装 Gradle,你只需输入以下内容:
sdk install gradle
现在我们已经在我们的控制台中有了gradle
命令。
创建可分发的 Gradle 命令
虽然 Gradle 是一个伟大的工具,但共享依赖于 Gradle 构建的代码并不容易。我们开源项目的潜在用户可能没有安装 Gradle,甚至可能没有我们相同的 Gradle 版本。幸运的是,Gradle 提供了一种创建可分发 Gradle 工具或命令的方法。
在一个新、干净的目录中,在您的控制台输入以下内容:
$ gradle wrapper
此命令创建了一个可执行的 gradlew
脚本。第一次运行此命令时,它会下载所有必要的 Gradle 文件,然后成为一个可重复执行的命令。
创建 Gradle 项目文件
为了 Gradle 能够工作,我们必须有一个 build.gradle
文件。在这个文件中,我们将为 Gradle 设置不同的选项和设置,以便其使用和运行。
对于我们的基本 Hello World
程序,我们的文件必须看起来像这样:
group 'com.packtpub'
version '1.0'
buildscript {
ext.kotlin_version = '1.2.21'
repositories {
mavenCentral()
}
dependencies {
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
apply plugin: 'kotlin'
apply plugin: 'application'
mainClassName = 'com.packtpub.appendix.HelloKt'
defaultTasks 'run'
repositories {
mavenCentral()
}
dependencies {
compile "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
}
compileKotlin {
kotlinOptions.jvmTarget = "1.8"
}
compileTestKotlin {
kotlinOptions.jvmTarget = "1.8"
}
通常,在 build.gradle
文件中,我们定义一些插件(Gradle 插件定义了对语言和框架的支持),存储库以下载依赖项,依赖项本身,以及其他选项。
在此文件中,我们定义了两个插件——一个用于 Kotlin,另一个用于将我们的项目定义为具有起始点或主类的应用程序。
创建我们的 Hello World 代码
Gradle 默认搜索 Kotlin 文件的目录位置是 src/main/kotlin
。我们将 hello.kt
文件放置在 src/main/kotlin/com/packtpub/appendix
目录中:
package com.packtpub.appendix
fun main(args: Array<String>) {
println("Hello, World!")
}
现在我们可以使用以下命令一次性编译和运行:
$ ./gradlew
由于我们已定义默认任务为 run
且主类为 com.packtpub.appendix.HelloKt
,只需运行 ./gradlew
就可以构建并运行我们的程序。
使用 IntelliJ IDEA 或 Android Studio
IntelliJ IDEA 和 Android Studio(它基于 IntelliJ IDEA 的开源版本)是 Kotlin(以及其他语言,如 Java、Scala、Groovy 等)的出色 IDE。两者都提供自动完成、格式化和对 Gradle 和 Maven 的支持。
这两个 IDE 都有很好的文档,你可以在它们的网站上了解更多关于 Kotlin 的支持信息:
-
IntelliJ IDEA (
kotlinlang.org/docs/tutorials/getting-started.html
) -
Android Studio (
developer.android.com/kotlin/get-started.html
)
使用 IntelliJ IDEA 导入 Gradle 文件
我们可以在 IntelliJ IDEA 中导入我们的 Gradle 项目。一旦启动 IDEA,我们就可以打开我们的 build.gradle
文件:
将会弹出一个对话框,询问您是否想将其作为文件或项目打开。点击“打开为项目”:
如果您已安装 JDK 并在 IntelliJ IDEA 中设置,只需点击“确定”。如果您没有配置 JDK,那么请进行配置(IntelliJ IDEA 将引导您完成配置)然后继续点击“确定”:
现在,我们可以使用 IntelliJ IDEA 的所有功能来编辑和我们的项目:
你可以用同样的方式打开这本书中的所有示例代码。
基本 Kotlin 语法
对于有 C 风格语法(如 C、Java、Scala、Groovy 和 TypeScript)经验的开发者来说,Kotlin 语法看起来很熟悉。
通用特性
各种 Kotlin 功能在 JVM 语言中都很常见。如果你有 Java 的经验,你会觉得 Kotlin 很亲切。
包
包是一组文件(通常定义在同一个目录中),用于定义逻辑单元,例如控制器和存储库。
要设置特定包中的文件,请在第一行使用 package
关键字:
package com.packt.functionalkotlin
理想情况下,包 com.packt.functionalkotlin
内的文件应该位于目录 com/packt/functionalkotlin
中。这使文件更容易找到,但在 Kotlin 中不是强制性的。
字符串连接和插值
Kotlin 中的字符串连接使用加号(+
)运算符:
val temperature = 12
println("Current temperature: " + temperature + " Celsius degrees")
字符串插值是进行复杂连接的简单方法:
val temperature = 12
println("Current temperature: $temperature Celsius degrees")
使用符号美元($
)让你可以在字符串内部使用简单值:
val temperature = 12
println("Temperature for tonight: ${temperature - 4} Celsius degrees")
对于比仅仅使用值更复杂的情况,你可以使用带有大括号的美元符号(${... }
):
注释
单行注释使用双斜杠(//
):
// This is a single line comment
println("Hello, World!") // This is a single line comment too, after valid code
块注释使用斜杠和星号来打开块(/*
)以及星号和斜杠来关闭(*/
):
/*
This is a multi-line comment,
Roses are red
... and I forgot the rest
*/
println(/*block comments can be inside valid code*/ "Hello, World!")
控制结构
Kotlin 有四种基本的控制结构——if
、when
、for
和 while
。
if 表达式
Kotlin 中的 if
看起来与任何其他 C 风格语言完全一样:
if (2 > 1) { //Boolean expression
println("2 is greater than 1")
} else {
println("This never gonna happen")
}
在 Kotlin 中,if
(以及 when
)是一个表达式。这意味着 if
语句会返回一个值:
val message = if (2 > 1) {
"2 is greater than 1"
} else {
"This never gonna happen"
}
println(message)
Kotlin 没有三元表达式,但可以用单行写一个 if
表达式:
println(if(2 > 1) "2 is greater than 1" else "This never gonna happen")
when 表达式
与其他 C 风格语言不同,Kotlin 没有使用 switch
语句,但有一个更加灵活的 when
表达式:
val x: Int = /*Some unknown value here*/
when (x) {
0 -> println("x is zero")
1, 2 -> println("x is 1 or 2")
in 3..5 -> println("x is between 3 and 5")
else -> println("x is bigger than 5... or maybe is negative...")
}
when
是表达式:
val message = when {
2 > 1 -> "2 is greater than 1"
else -> "This never gonna happen"
}
println(message)
它们也可以用来替换 if
表达式:
for 循环
for
循环可以遍历任何提供迭代器的对象(例如,集合和范围):
for(i in 1..10) { // range
println("i = $i")
}
while 和 do 循环
while
和 do
循环是标准的 C 风格循环:
var i = 1
while (i <= 10) {
println("i = $i")
i++
}
do {
i--
println("i = $i")
} while (i > 0)
现在你已经拥有了所有基本的部分,你就有了一切阅读和理解这本书内容所需的东西。
进一步学习
如果你想要提升你的 Kotlin 知识和理解,前进的最佳方式是尝试 Kotlin Koans。
Kotlin Koans 是一个教程,它将引导你在几天内从初学者逐步成为一名非常熟练的 Kotlin 程序员,而且更好的是,它是免费的。
你可以在 try.kotlinlang.org/#/Kotlin%20Koans/Introduction/Hello,%20world!/Task.kt
尝试 Kotlin Koans: