从第一章开始,我们就使用了各种内置 Kotlin 类型,如 Int、String 和 Bool。然后我们通过编写类引入了我们自己的自定义类型,如 Circle。在本章中,我们将深入研究接口,它将允许对象同时具有多种类型!
我们有很多有趣的内容要介绍,所以让我们开始吧!
Sue 创办农场
Sue 刚在乡下买了很多土地,她准备开始她的农场了!作为开始,她得到了她的第一只鸡,名字叫 Henrietta。每天早上,Sue 都会向 Henrietta 打招呼,Henrietta 也会忠实地向 Sue 咕咕叫回打招呼。
让我们编写一些 Kotlin 代码来表示鸡 Henrietta 和农场主 Sue。
class Chicken(val name: String, var numberOfEggs: Int = 0) {
fun speak() = println("Cluck!")
}
class Farmer(val name: String) {
fun greet(chicken: Chicken) {
println("Good morning, ${chicken.name}!")
chicken.speak()
}
}现在,我们可以实例化这两个类,Sue 可以向 Henrietta 打招呼。
val sue = Farmer("Sue")
val henrietta = Chicken("Henrietta")
sue.greet(henrietta)当你运行这个时,会得到以下输出:
Good morning, Henrietta! Cluck!
既然 Sue 的农场有了一个良好的开端,她准备添加另一个动物居民——这次是一头猪!农场主 Sue 也会每天早上向她的猪 Hamlet 打招呼!
class Pig(val name: String, val excitementLevel: Int) {
fun speak() {
repeat(excitementLevel) {
println("Oink!")
}
}
}这看起来与 Chicken 类非常相似。它也有一个 name 属性和一个 speak() 函数,但它没有 numberOfEggs 属性。相反,它有一个 excitementLevel 属性,决定了它发出多少”oink”声音。我们可以更新脚本,让 Sue 同时向鸡和猪打招呼,但当我们这样做时,会收到一个编译时错误:
val sue = Farmer("Sue")
val henrietta = Chicken("Henrietta")
val hamlet = Pig("Hamlet", 1)
sue.greet(henrietta)
sue.greet(hamlet)当然,这是有道理的。greet() 函数接受的是 Chicken,而不是 Pig。
为了解决这个问题,我们可以添加一个接受 Pig 的新函数。我们仍然可以将新函数命名为 greet(),但它接受的参数类型是 Pig 而不是 Chicken。当我们创建一个与另一个函数具有相同名称但不同参数类型的函数时,这称为对函数进行重载(overloading)。让我们为 greet() 创建一个接受 Pig 对象的重载版本。
class Farmer(val name: String) {
fun greet(chicken: Chicken) {
println("Good morning, ${chicken.name}!")
chicken.speak()
}
fun greet(pig: Pig) {
println("Good morning, ${pig.name}!")
pig.speak()
}
}通过此更新,Listing 12.4 中的代码现在可以编译并正常运行了。
既然 Sue 的农场已经有了一只鸡和一头猪,而且运转良好,她准备再添加一头牛!与其他动物一样,她每天早上都会向她的牛”Dairy Godmother”打招呼,并听到”哞”的回应!
让我们为 Cow 创建一个类。
class Cow(val name: String) {
fun speak() = println("Moo!")
}与猪的情况一样,当我们尝试让 Sue 向牛打招呼时,会收到一个错误。
val sue = Farmer("Sue")
val henrietta = Chicken("Henrietta")
val hamlet = Pig("Hamlet", 1)
val dairyGodmother = Cow("Dairy Godmother")
sue.greet(henrietta)
sue.greet(hamlet)
sue.greet(dairyGodmother)同样,为了解决这个问题,我们可以添加一个新的重载版本来向猪打招呼。
class Farmer(val name: String) {
fun greet(chicken: Chicken) {
println("Good morning, ${chicken.name}!")
chicken.speak()
}
fun greet(pig: Pig) {
println("Good morning, ${pig.name}!")
pig.speak()
}
fun greet(cow: Cow) {
println("Good morning, ${cow.name}!")
cow.speak()
}
}哎呀!这变得越来越难以控制了!每次 Sue 添加一种新的农场动物,我们就必须创建 greet() 函数的另一个重载版本。这很麻烦,因为农场主 Sue 有更大的计划!她想添加一头驴、一只山羊和一只羊驼!那意味着更多的重载……
所有这些函数都非常相似。事实上,唯一的区别是参数的名称和类型。
不要为每种新的农场动物添加新函数,如果 greet() 函数能够处理任何类型的农场动物(无论是鸡、猪、牛、驴、山羊、羊驼还是其他任何动物),那就太棒了。换句话说,我们想要的是这样的东西:
class Farmer(val name: String) {
fun greet(animal: FarmAnimal) {
println("Good morning, ${animal.name}!")
animal.speak()
}
}幸运的是,Kotlin 给了我们一个简单的方法来实现这一点——使用接口!
引入接口
幸运的是,Kotlin 为我们提供了一个简单的方法来实现这一点——接口!
引入接口
当我们查看这些动物类时,可以看到它们都非常相似——它们都有一个 name 属性和一个 speak() 函数。
Sue 的农场新添加的任何动物也都会有一个名字和一种在和她说话时发出的声音。因此,我们可以引入一个名为 FarmAnimal 的新类型,并给它一个 name 属性和一个 speak() 函数。你可能还记得,到目前为止,我们总是使用 class 关键字创建新类型。但是,我们不用类来引入 FarmAnimal 类型,而是使用接口。
与类>一样,接口描述了一个类型具有哪些属性和函数。但是,与类不同的是,你不必实际包含任何函数体!例如,我们可以像这样创建 FarmAnimal 接口:
interface FarmAnimal {
val name: String
fun speak()
}注意,我们没有在这里给 speak() 函数添加任何函数体。
然而,类和接口的一个区别是,我们不能实例化一个接口。例如,这样做不起作用:
val donkey = FarmAnimal("Phyllis")
donkey.speak()这是有道理的——毕竟,由于 FarmAnimal 的 speak() 没有函数体,我们期望 donkey.speak() 实际上做什么呢?
那么,如果接口不能被实例化,我们该如何使用它们呢?
我们更新现有的类,告诉 Kotlin 它们每个都是 FarmAnimal,除了是 Chicken、Pig 或 Cow 之外。首先,让我们更新 Cow 类,将其标记为 FarmAnimal:
class Cow(override val name: String) : FarmAnimal {
override fun speak() = println("Moo!")
}当一个类被这样标记上接口时,我们说这个类实现了该接口。换句话说,FarmAnimal 接口表示每个实现类必须包含一个 name 属性和一个 speak() 函数,但对动物在调用 speak() 时应该发出什么声音只字未提……只是说该函数必须存在。然而,该类提供了实现——也就是说,它有一个确实有函数体的 speak() 函数。
实现接口的类需要包含以下几点:
- 它必须声明它实现了该接口。要做到这一点,在主构造函数和类体的左大括号之间添加一个冒号和接口的名称。1
- 它必须为类从接口实现的每个属性和函数添加
override关键字。这个关键字告诉 Kotlin,这个属性或函数对应于接口中的那个。
一旦我们对 Chicken 和 Pig 做了同样的更改,我们就可以继续删除 Farmer 类上所有的 greet() 函数,并用一个函数替换它们,就像我们在清单 12.9中所做的那样(此处重复):
class Farmer(val name: String) {
fun greet(animal: FarmAnimal) {
println("Good morning, ${animal.name}!")
animal.speak()
}
}现在,我们可以对实现 FarmAnimal 接口的任何类调用 greet()。因此,随着新的驴、山羊和羊驼加入农场,我们只需要声明它们实现了 FarmAnimal 接口,农场主 Sue 就可以和它们打招呼,不需要任何新的重载……实际上,也不需要对 Farmer 类做任何更改。
让我们更仔细地看看类与它们实现的接口之间的关系,以理解这是为什么。
子类型和超类型
任何有形的实物通常可以用多种方式分类。例如,如果有人指着一只鸡问你它是什么,你可能会说”它是一只鸡”,或者说”它是一种农场动物”。一个更具体,一个更一般——但两者都是正确的。
具体和一般类型的概念也适用于 Kotlin。现在 Chicken 类被标记为 FarmAnimal,所有鸡对象现在既是 Chicken(更具体的类型)又是 FarmAnimal(更一般的类型)。
当我们在讨论 Kotlin 中的类型时……
- 实现接口的类称为该接口的子类型,因为该类是一个更具体(或”更低”——因此是”sub”)的类型。
- 相反,接口称为实现它的类的超类型,因为接口是一个更一般(或”更高”——因此是”super”)的类型。
回到第四章,我们创建了一些 UML 图来描述我们的类。下面是一个显示子类型/超类型关系的简单图表。
子类型和替换
当有人请求一个更一般的类型时,更具体的类型是合适的。例如,如果农场主 Sue 向你要一只”农场动物”,你给她一只鸡,她会很满意,因为鸡确实是一种农场动物。或者,你也可以给她一头牛或一头猪。她对任何一种都会满意,因为每一种都是一种农场动物。
同样,在 Kotlin 中,你可以在代码期望超类型的任何地方使用子类型。例如,如果一个变量、属性或函数期望一个 FarmAnimal,你可以给它一个 Chicken、Cow 或 Pig。我们已经在清单 12.13中的 greet(animal) 函数中看到了这一点,但这也适用于变量。
为了演示这一点,我们可以将变量的类型显式指定为 FarmAnimal,但为其分配一个 Chicken。
val henrietta: FarmAnimal = Chicken("Henrietta")同样,我们可以创建一个 FarmAnimal 对象的列表,并向其中添加一个 Chicken、一个 Cow 和一个 Pig。这是使用 List 的清单 12.7>的修订版本。
val sue = Farmer("Sue")
val animals: List<FarmAnimal> = listOf(
Chicken("Henrietta"),
Pig("Hamlet", 1),
Cow("Dairy Godmother"),
)
animals.forEach { sue.greet(it) }在这两种情况下,我们都声明了 FarmAnimal 类型,但能够提供 Chicken、Pig 或 Cow,因为每个类都实现了 FarmAnimal。
但是,当你将一个更具体类型的对象(例如,一个 Chicken 对象)分配给一个更一般的变量或参数(例如,一个类型为 FarmAnimal 的参数)时,你会失去对其进行特定操作的能力。例如,Chicken 类有一个 numberOfEggs 属性。当对象被分配给一个 Chicken 变量时,你可以正常使用这个属性,如下所示:
val henrietta: Chicken = Chicken("Henrietta")
henrietta.numberOfEggs = 1但是,仅仅把这个变量的类型从 Chicken 改为 FarmAnimal 后,你就无法对 numberOfEggs 做任何操作了:
val henrietta: FarmAnimal = Chicken("Henrietta")
henrietta.numberOfEggs = 1为什么会这样?
当一个具体类型的对象被分配给一个更一般类型的变量时,这就像是对象戴上了面具。那个面具遮住了在具体类型中声明的东西,但让你能够看到在更一般类型中声明的属性和函数。
例如,当将 Chicken 对象分配给 Chicken 变量时(如清单 12.16>所示),Chicken 类没有戴面具,所以你可以看到它的所有属性和函数。然而,在清单 12.17>中,Chicken 对象被分配给 FarmAnimal 变量,所以它戴着 FarmAnimal 面具,这只让 Kotlin 看到在 FarmAnimal 接口中声明的内容——name 和 speak()。
因为戴着面具,所以在 FarmAnimal 中声明的属性和函数是可见的,但在 Chicken 中声明的任何属性或函数都隐藏在面具后面,因此看不到。在某些情况下,这可能会阻止你做你想做的事情。例如,如果我们更新 greet() 函数,让农场主 Sue 也能说出她看到了多少鸡蛋,我们将在编译时收到一个错误。
val chicken: Chicken = Chicken("Henrietta")
class Farmer(val name: String) {
fun greet(animal: FarmAnimal) {
println("Hello, ${animal.name}!")
println("I see you have ${animal.numberOfEggs} eggs today!")
animal.speak()
}
}Kotlin 阻止我们这样做是好的。毕竟,如果我们要传递一个 Cow 对象而不是 Chicken 对象,Cow 就没有 numberOfEggs 属性,所以完全打印一行关于鸡蛋的内容就没有意义了!
那么,我们如何让 Kotlin 仅在 animal 实际上是 Chicken 时打印关于鸡蛋的那一行呢?为了做到这一点,我们需要让 animal 对象摘下它的面具!
类型转换
如果你想使用在子类型上声明的属性或函数,你必须先告诉对象摘下那个比喻性的面具。更改类型(例如从 FarmAnimal 到 Chicken)称为类型转换。有几种方法可以做到这一点。
智能转换
一种常见的类型转换方法是使用带有 is 关键字的条件语句>,如下所示:
fun greet(animal: FarmAnimal) {
println("Hello, ${animal.name}!")
if (animal is Chicken) {
println("I see you have ${animal.numberOfEggs} eggs today!")
}
animal.speak()
}现在,在该条件语句的内部>,animal 的类型变为 Chicken。但在那个函数体之外,它仍然是 FarmAnimal。
这被称为智能转换。如果这看起来很熟悉,那是因为我们在第六章>讨论 Kotlin 的空安全特性时已经看到了智能转换。这里是同样的道理,但不是从可空类型转换为非可空类型,而是从 FarmAnimal 转换为 Chicken。
智能转换只能在 Kotlin 可以确定值在条件语句和使用它的表达式之间不会改变时使用。例如,在上面的代码中,animal 的值不可能被重新赋值,所以 Kotlin 知道在这里使用智能转换是安全的。
然而,在其他情况下,值在条件语句评估之后完全有可能改变。例如,Kotlin 不会智能转换类的 var 属性,因为可能有其他代码同时运行,而该代码可能对该属性重新赋值。(到目前为止,我们还没有编写过这样的并发代码,但我们会在关于协程的 future 章节中看到这一点!)
显式转换
智能转换是转换类型的一种简单方法,但你也可以自己显式地进行转换。为此,你可以使用 as 关键字。下面是它的样子:
fun greet(animal: FarmAnimal) {
println("Hello, ${animal.name}!")
val chicken: Chicken = animal as Chicken
println("I see you have ${chicken.numberOfEggs} eggs today!")
animal.speak()
}这样做的问题是,如果 animal 实际上不是 Chicken——例如,如果你用 Cow 调用 greet(),那么你会在运行时收到一个错误。这就是为什么这有时被称为不安全转换。
或者,你可以使用 as? 关键字(带有问号),这称为安全转换。如下所示。
fun greet(animal: FarmAnimal) {
println("Hello, ${animal.name}!")
val chicken: Chicken? = animal as? Chicken
chicken?.let { println("I see you have ${it.numberOfEggs} eggs today!") }
animal.speak()
}as? 运算符将尝试将对象转换为指定的类型。它将计算为以下两种结果之一:
- 如果该对象实际上是指定的类型,那么它返回该对象。
- 否则,它返回 null。
在上面的代码中,如果用 Chicken 对象调用 greet(),那么 chicken 将是与管理 animal 相同的对象实例,但会具有编译时类型 Chicken?。换句话说,它摘下了面具……但你仍然需要处理一个可空类型。另一方面,如果用 Cow 对象调用 greet(),那么 chicken 变量将为 null。
请记住,因为 as? 计算结果为可空类型,你必须使用空安全工具>来处理 null。例如,在清单 12.21>中,我们使用了用于 null 检查的作用域函数>。
智能转换在很多情况下往往是更优雅的方法,所以如果你发现自己需要进行转换,这是一个很好的起点。只有在适合的情况下才考虑使用 as 或 as?。
多个接口
一个类实现多个接口是可能的。为了演示这一点,让我们把 FarmAnimal 接口拆分为两个独立的接口——一个用于 speak() 函数,一个用于 name 属性:
interface Speaker {
fun speak()
}
interface Named {
val name: String
}为了更新类以实现这两个接口,只需用逗号分隔接口的名称,如下所示:
class Cow(override val name: String) : Speaker, Named {
override fun speak() = println("Moo!")
}当你像这样将内容拆分为多个接口时,就好像你创建了多个”面具”,每个面具只暴露类的一小部分。
这使得在更广泛的情况下使用该类型成为可能。例如,你可以想象收集农场里每个人的名单,包括农场主 Sue。Farmer 类已经有了一个 name 属性,所以我们可以轻松地更新它来实现 Named 接口。
class Farmer(override val name: String) : Named {
// (eliding the class body for now)
}通过这一更改,现在我们可以收集农场里每个人的名单了!
val roster: List<Named> = listOf(
Farmer("Sue"),
Chicken("Henrietta"),
Pig("Hamlet", 1),
Cow("Dairy Godmother")
)然而,通过将 FarmAnimal 接口拆分为 Speaker 和 Named 接口,我们已经取消了 FarmAnimal 接口。这意味着 greet() 函数不再工作了。
class Farmer(override val name: String) : Named {
fun greet(animal: FarmAnimal) {
println("Good morning, ${animal.name}!")
animal.speak()
}
}接下来让我们修复这个问题!
接口继承
为了让 greet() 函数再次工作,我们可以简单地重新引入 FarmAnimal 接口,如下所示……
interface Speaker {
fun speak()
}
interface Named {
val name: String
}
interface FarmAnimal {
val name: String
fun speak()
}… and then update the classes so that they implement all three of these 接口, like this:
class Cow(override val name: String) : Speaker, Named, FarmAnimal {
override fun speak() = println("Moo!")
}这会产生一个如下图所示的图表。
哇——到处都是线条!当图表看起来如此令人困惑时,通常意味着我们的代码还有改进的空间。尽管这种三接口方法可以工作,但 Kotlin 为我们提供了一种更简洁的方式:接口可以从其他接口继承。2
当这种情况发生时,它自动包含它继承的接口中的所有属性和函数。例如,我们可以更新清单 12.27中的代码,使 FarmAnimal 继承 Speaker 和 Named 接口,如下所示。3
interface Speaker {
fun speak()
}
interface Named {
val name: String
}
interface FarmAnimal : Speaker, Named与清单 12.27一样,实现此 FarmAnimal 接口的类仍然需要在其上具有 name 属性和 speak() 函数。然而,通过继承 Speaker 和 Named 接口,FarmAnimal 现在是它们的子类型!这意味着每个 FarmAnimal 也是 Speaker 和 Named。
现在我们可以从上面的清单 12.28中移除 Speaker 和 Named 声明,因为它们作为 FarmAnimal 的一部分自动包含在内:
class Cow(override val name: String) : FarmAnimal {
override fun speak() = println("Moo!")
}尽管这个类只声明它是 FarmAnimal,但它仍然是 Named 和 Speaker 类型。结果是一个如下所示的 UML 图:
这个图看起来好多了!
现在这些类可以在很多情况下使用!例如,因为 Cow 是 FarmAnimal、Named 和 Speaker 的子类型,所以 Cow 对象可以作为参数传递给任何这些函数:
fun milk(cow: Cow) = // ...
fun feed(animal: FarmAnimal) = // ...
fun introduce(name: Named) = // ...
fun listenTo(speaker: Speaker) = // ...默认实现
接口中的默认函数
大多数时候,接口本身不包含任何代码——它们通常不包含函数体。但是,你确实可以包含一个函数体,以提供默认实现。如果类没有提供该函数自己的实现,则使用此默认实现。例如,让我们更新 Speaker 接口,使其具有 speak() 函数的默认实现。
interface Speaker {
fun speak() {
println("...")
}
}现在,我们可以创建一个实现 FarmAnimal 接口但不包含 speak() 函数的新类!
class Snail(override val name: String) : FarmAnimal
这个 Snail 类没有类体,更不用说 speak() 函数了。然而,我们仍然可以在 Snail 上调用 speak() 函数,当我们这样做时,它会打印 ...,表明蜗牛不太说话!
val snail = Snail("Slick")
snail.speak() // prints "..."接口中的默认属性
你也可以为属性提供默认实现,但你不能直接赋值。例如,这样做不起作用:
interface Named {
val name: String = "No name"
}相反,你可以为属性创建一个getter。一些编程语言称之为计算属性。
interface Named {
val name: String get() = "No name"
}getter 本质上是一个底层函数,每当获取属性值时就会调用它。所以当你执行 println(something.name) 时,它调用那个 get() 函数并返回该函数的结果。这里,我们只是返回了值 "No name"。
既然 FarmAnimal 对both name 和 speak() 都有默认实现,我们可以创建一个实现该接口但没有任何属性或函数的类。
class UnknownAnimal : FarmAnimal
val unknown = UnknownAnimal()默认实现 在向现有接口添加新属性或函数时尤其有用。例如,如果我们要向 FarmAnimal 接口添加一个新的 nickname 属性,那么 Chicken、Pig 和 Cow 类将全部需要更新以拥有该新属性,否则你将收到一个编译时错误。但是,如果 FarmAnimal 接口对该属性有默认实现(例如,可以默认设置为 null),那么代码将成功编译,即使对类没有其他更改。然后,如果牛是唯一有昵称的动物,你可以仅在> Cow 类中实现该属性。
总结
好了, Sue 的农场现在状态很好!随着她添加越来越多的动物,她将能够轻松地与它们打招呼。本章介绍了接口的概念,并涵盖了以下主题:
在下一章>中,我们将了解如何利用接口轻松地将函数和属性调用委托给其他对象。到时候见!
感谢 James Lorenzen 审阅本章!
-
在类没有构造函数或类体的情况下,写成这样就足够了:
class Chicken : FarmAnimal。 ↩︎ -
一些语言称之为”扩展”另一个接口,但由于这个术语很容易与扩展混淆,Kotlin 开发者倾向于称之为”继承”。继承不仅仅适用于接口,我们将在第十四章>中看到这一点。 ↩︎
-
这里的
FarmAnimal接口不包含函数体,但通常当你扩展一个接口时,你会给它一个(函数体)。这样,FarmAnimal将继承name和speak(),然后添加一些自己的属性或函数。 ↩︎