1. 上一章:第 9 章
  2. 下一章:第 11 章
Kotlin 图解指南 • 第 10 章

接收者和扩展

章节封面图片

独立函数和对象函数

早在第 2 章中,我们就学习了如何创建函数。这里有一个非常简单的函数,它会在String的开头和结尾添加单引号:

fun singleQuoted(original: String) = "'$original'"

如你所记得的,这个函数可以这样简单地调用:

val title = "The Robots from Planet X3"
val quotedTitle = singleQuoted(title)

println(quotedTitle) // 'The Robots from Planet X3'

然后在第 4 章中,我们了解到对象也可以包含函数。例如,String对象有一个名为uppercase()的函数,它返回相同字符串但所有字母都大写。你可以这样调用它:

val title = "The Robots from Planet X3"
val loudTitle = title.uppercase()

println(loudTitle) // THE ROBOTS FROM PLANET X3

当你像这样调用一个对象上的函数时,需要在函数调用前加上对象名点号。因此,这种书写函数调用的方式被称为点标记法

所以,这里我们有两种不同的函数类别

  • 独立于对象的独立函数
  • 对象上调用的函数。
singleQuoted(title) 函数在调用时没有前缀对象名。uppercase() 函数使用点号在 `String` 对象上调用。 No object name or dot Object name and dot singleQuoted (title) title. uppercase () Calling a function on an object Calling a standalone function

调用独立函数很容易,在对象上调用函数也很容易。

然而,当你合并调用这两种不同类型的函数时,事情就变得复杂了。看看这段代码,它调用了一个独立函数(singleQuoted()),并用点标记法调用了两个函数(removePrefix()uppercase())。

singleQuoted(title.removePrefix("The ")).uppercase()

你能弄清楚这些函数会按什么顺序被调用吗?

  1. 首先,调用 removePrefix()
  2. 然后,该调用的结果将作为参数传递给 singleQuoted() 函数。
  3. 最后,uppercase()将在从 singleQuoted() 返回的字符串对象上被调用。

从视觉上看,我们的大脑必须通过跳跃来处理这个表达式——从中间开始,然后移到左边,再移到右边。

上一个代码清单中的函数以与你阅读它们不同的顺序被调用。 singleQuoted (title. removePrefix ( "The " )). uppercase () 1 2 3

想象一下试着像这样读一本书!

很难阅读诸如 "fox jumps(the quick brown).over the lazy dog" 这样的文本。 fox jumps (the quick brown) over the lazy dog

如果所有这三个函数调用都以相同的方式工作,那么阅读和理解代码会更容易,这样我们就可以按单一方向阅读。例如,如果你可以用点标记法调用 singleQuoted() 函数——就像你对 removePrefix()uppercase() 所做的那样——那么理解起来就非常容易了。以下是它的样子:

val newTitle = title.removePrefix("The ").singleQuoted().uppercase()

由于 singleQuoted() 不是 String 类的一部分,这段代码实际上还不能工作。但你肯定能看到它有多容易阅读和理解,因为函数是按你阅读的相同顺序调用的。你只需从左到右跟随代码即可。

上一个清单中的代码易于阅读,因为它以你阅读的相同顺序运行——从左到右。 title. removePrefix ( "The " ). singleQuoted (). uppercase () 3 2 1

这些调用也可以垂直排列,每行一个,像这样:

val newTitle = title
    .removePrefix("The ")
    .singleQuoted()
    .uppercase()

同样,阅读起来很自然——从上到下1

除了使函数调用一致且易于阅读之外,有时候点标记法正好符合 Kotlin 开发者的期望。按照惯例,如果一个函数主要是对一个对象什么或者一个对象做什么,那么我们通常期望该函数存在于该对象

此外,如果你使用的是 IntelliJ 或 Android Studio 这样的 IDE,"在对象上"的函数更容易被发现。如果你有一个 String 对象,并且想知道可以在它上面调用哪些函数,只需输入点号,你就会看到可用函数的列表!这是探索不太熟悉的类的好方法。

Screenshot of IntelliJ showing the functions that can be called on a String object.

所以,在本章中,我们的目标是修改 singleQuoted(),使其可以通过点号调用,像这样:

val newTitle = title.singleQuoted()

让我们首先更仔细地看看独立函数和对象上调用的函数之间的异同。

它们并没有那么不同

这两类函数——独立函数和在对象上调用的函数——比你想象的有更多共同点。是的,调用函数时编写代码的方式——即语法——在每种情况下略有不同:

左边是一个独立函数,右边是一个对象函数。 singleQuoted (title) title. uppercase ()

但从概念上来看,它们实际上非常相似:

  1. 它们都以字符串开始。
  2. 它们都返回一个基于原始字符串的新字符串。

从这个角度来看,这些函数几乎都接受一个 String 参数。区别只在于调用函数时把这个参数放在哪里

两个函数都以字符串开始。 singleQuoted (title) title. uppercase () title argument title argument

当使用点号调用函数时,点号左侧的对象称为函数调用的接收者。接收者是本章的一个重要概念,但对于理解后续概念(如作用域函数和更高级的 lambda)也很重要,所以让我们深入了解!

接收者简介

一只训练有素的狗知道如何按命令吠叫。当你告诉你的狗 Fido “说话”时,你是在给它发送一个命令,而它就是这个命令的接收者。

A sender, command, and receiver in real life.

同样,当你对一个对象调用函数时,该对象就是该函数调用的接收者

The call site is the sender, the function call is the command, and the receiver is still the object receiving the command.

让我们用一些代码进一步说明。这是一个简单的 Dog 类,下面是一些告诉狗叫的代码。

class Dog {
    fun speak() {
        println("BARK!")
    }
}

val fido = Dog()
fido.speak()

由于 fido是你告诉它 speak() 的狗,所以 fido 是接收者。

当调用 `fido.speak()` 时,`fido` 是接收者。 fido. speak () Receiver

很简单,对吧?

现在,有时候你的狗不需要被告知要叫。有时候他会自己选择吠叫。(事实上,有时候你无法让它停止吠叫……问我怎么知道的!)

让我们更新 Dog 类,使得 Fido 每次开始玩耍时都会吠叫。

class Dog {
    fun speak() {
        println("BARK!")
    }
    fun play() { 
        this.speak()
    }
}

在这里,play() 函数调用 speak() 函数。正如你可能从第 4 章中记得的,关键字 this 指的是调用 play() 的同一个对象。换句话说,如果你调用 fido.play(),那么 speak()将在 fido 对象上被调用。在清单 10.9中,speak() 函数调用的接收者是 this

你也可能记得可以省略 this.,所以以下代码与上一个清单中的代码效果相同。

class Dog {
    fun speak() {
        println("BARK!")
    }
    fun play() { 
        speak()
    }
}

现在,speak() 前面没有对象名或点号,只有函数名。这是否意味着这里没有接收者?

当从 `play()` 内部调用 `speak()` 时有接收者吗? class Dog { fun speak () { println ( "BARK!" ) } fun play () { speak () } } Any receiver here?

实际上,这里确实有接收者!记住——任何时候当函数在对象上被调用时,该对象就是接收者。因为 speak() 正在一个 Dog 对象上被调用,所以该对象就是接收者。在 play() 函数内部,你可以在 speak() 前加上 this.,也可以省略它。两种方式的结果相同,接收者也相同。

所以,speak() 在这里有接收者!只是它在代码中没有明确说明,它是隐含的。这就是为什么这被称为隐式接收者。将其与上面清单 10.8中的显式接收者进行对比。下面展示 speak() 的两个调用点——一个使用隐式接收者,一个使用显式接收者。

`speak()` 的两个调用点——一个使用隐式接收者,一个使用显式接收者。 class Dog { fun speak () { println ( "BARK!" ) } fun play () { speak () } } val fido = Dog () fido. speak () (Implicit Receiver) Explicit Receiver

哇,关于接收者的信息很多,但我们可以总结如下:

  • 接收者是你正在调用其函数的对象。
  • 它可以是显式的,就像用点号调用函数时看到的那样,或者……
  • 它可以是隐式的,比如当一个函数在同一个类内部调用另一个函数时。

既然我们了解了接收者,我们可以用这些知识回到我们最初的目标——更新 singleQuoted() 函数,以便我们可以用点号调用它。

扩展函数简介

按照目前的写法,singleQuoted() 函数有一个名为 original 的单一参数,这是将被引号包裹的字符串。我们现在需要做的是更新函数,使其具有接收者而不是普通函数参数。

我们当前拥有的调用点与我们想要拥有的调用点之间的区别。 title. singleQuoted () What we want: singleQuoted (title) What we have:

当你希望能够用点号调用函数时,一种方法是将函数添加到类中。然而,你并不总是能够做到这一点。String 类是 Kotlin 标准库的一部分,所以你不能只是打开它的代码并在其中编写一个新函数!

值得庆幸的是,Kotlin 提供了一种用你自己的函数来扩展类的方法,这些函数可以用点号调用。这些被称为扩展函数

让我们看看在本章开头写的 singleQuoted() 函数。

fun singleQuoted(original: String) = "'$original'"

让我们将 original 参数改为接收者,这样 singleQuoted() 将成为一个扩展函数。这很容易做到:

  1. 首先,在函数名前加上你想要的接收者的类型,并添加一个点。在这种情况下,我们想要一个 String 类型的接收者。
  2. 其次,在函数体内使用 this 引用接收者。

以下是 singleQuoted() 在做这些更改之后的样子:

fun String.singleQuoted() = "'$this'"

在这段代码中:

  • String接收者类型。通过将其指定为String,你就可以在任何String对象上调用singleQuoted()
  • this接收者参数。它指向调用singleQuoted()的对象,因此若调用title.singleQuoted()this就指向title对象。
扩展函数的分解。 Receiver Type Receiver Parameter fun String. singleQuoted () = "' $this '"

你可以很容易地将普通函数转换为扩展函数:

  1. 将参数的类型放在函数名前,并添加一个点。
  2. 在你使用该参数的任何地方,将其重命名为 this
  3. 最后,从括号之间删除原始参数。
将独立函数转换为扩展函数。 fun String. singleQuoted () = "' $this '" fun singleQuoted (original: String) = "' $ original '"

如果你使用的是 IntelliJ 或 Android Studio,你也可以使用重构工具将函数转换为扩展函数。为此,请右键单击函数名,然后选择”Refactor”和”Change Signature”。从那里,选中你要转换为接收者的参数。

Using IntelliJ refactoring tools to convert a standalone function to an extension function.

通过这些更改,无论何时调用这个函数,你必须用接收者来调用它,像这样:

val quotedTitle = title.singleQuoted()

现在,将这个函数调用插入到调用链的中间很容易:

val title = "The Robots from Planet X3"
val newTitle = title
    .removePrefix("The ")
    .singleQuoted()
    .uppercase()

// 'ROBOTS FROM PLANET X3'

扩展函数在 Kotlin 代码中相当常见。Kotlin 的标准库也包含许多扩展函数。事实上,你可能会惊讶地发现 removePrefix()uppercase() 实际上并不是 String 类的成员——它们也是扩展函数!

扩展是为现有类型提供新功能的好方法,特别是对于你无法编辑类本身的类。请记住,扩展不能访问类的 private 成员。因此,尽管扩展函数的调用方式与成员函数相同,但它并不能访问成员函数所能访问的所有内容!

可空接收者类型

当你想要在可空对象上调用扩展函数时会怎样?

你会得到一个错误消息。

val title: String? = null
val newTitle = title.singleQuoted()
Error

你可能记得从第 6 章中,你可以通过使用安全调用运算符 ?. 来解决这个问题,这样 singleQuoted() 只会在 title 不为 null 时被调用。

val title: String? = null
val newTitle = title?.singleQuoted()

不过,Kotlin 还给了你另一个选择——你可以创建一个具有可空接收者类型的扩展函数。例如,不要将接收者类型设为非可空的 String,你可以将其设为可空的 String?,像这样:

fun String?.singleQuoted() =
    if (this == null) "(no value)" else "'$this'"

在这个函数内部,this 是可空的。如果这个版本的 singleQuoted() 在 null 上被调用,那么它返回一个说 (no value) 的字符串。否则,它的工作方式与上一个版本的 singleQuoted() 相同,如清单 10.12所示。

当扩展函数具有可空接收者类型时,你不必必须使用安全调用运算符调用它。你可以用常规的点运算符来调用它。

val title: String? = null
val newTitle = title.singleQuoted()

println(newTitle) // (no value)

另一方面,你仍然可以选择使用安全调用运算符来调用它,但在这种情况下,函数只会在接收者不为 null 时被调用。例如,以下清单与上一个清单的唯一区别是我们从常规点运算符更改为安全调用运算符。结果是 newTitlenull 而不是 (no value)

val title: String? = null
val newTitle = title?.singleQuoted()

println(newTitle) // null

所以,根据你的期望,仔细选择在点运算符和安全调用运算符之间。

扩展属性

除了扩展函数,你还可以创建扩展属性。但是,你不能使用扩展属性在类内部实际存储额外的值。例如,向 String 添加 ID 号码是不可能的。不过,它们可能有助于进行小的计算。

让我们创建一个扩展属性来告诉我们 String 是否超过 20 个字符。

val String.isLong: Boolean
    get() = this.length > 20

与扩展函数一样,扩展属性指定接收者类型接收者参数可用作 this

扩展属性的分解。 Receiver Type Receiver Parameter val String. isLong : Boolean get () = this . length > 20

如前所述,当你在隐式接收者上调用函数或属性时,你不需要包含 this.,所以你也可以不包含它来写这个属性:

val String.isLong: Boolean
    get() = length > 20

你可以像使用任何属性一样使用这个属性:

val string = "This string is long enough"
val isItLong = string.isLong

总结

在本章中,你学到了:

下一章中,我们将学习作用域和作用域函数。Kotlin 开发者经常使用作用域函数,在某些情况下,它们甚至可以成为扩展函数的有用替代品。到时候见!

感谢 David BlancMatt McKenna 审阅本章。


  1. 当函数按照自然阅读顺序(从左到右、从上到下)调用时,开发者通常将其称为流式接口(fluent interface)。然而,提出这一术语的Martin Fowler和Eric Evans指出,使用链式函数调用只是使接口具备流式特性的一部分。更多关于流式接口的思考可参阅Martin Fowler的文章。 ↩︎