AndroidStudio3 和 Kotlin 学习手册(二)
四、使用类
我们将介绍的内容:
-
接口
-
类
-
数据类别
-
访问修饰符
-
对象声明
Kotlin 和 Java 一样,是一种基于类的面向对象的语言。它使用接口和类来定义自定义类型。Kotlin 处理类型的方式与我们在 Java 中处理类型的方式非常相似,但是也有一些地方 Kotlin 会觉得我们不熟悉。在这一章中,我们将探讨这些相似之处和不同之处。
接口
与 Java 一样,Kotlin 中接口的基本形式类似于清单 4-1 中的代码。
interface Fax {
fun call(number: String) = println("Calling $number")
fun print(doc: String) = println("Fax:Printing $doc")
fun answer()
}
Listing 4-1Interface Fax
它仍然使用接口关键字,并且它还包含抽象函数。Kotlin 接口的显著之处在于,它们可以(1)包含属性,( 2)具有带实现的功能——换句话说,具体的功能。尽管 Java 8 确实允许 Java 中的默认实现,所以最后一个不再是 Kotlin 独有的,但仍然非常有用,我们将在后面看到。不要太担心接口有属性——你会习惯的。虽然我们不会在这一节讨论属性,但是我们将在后面的一节(类)中讨论它们。为了实现一个接口,Kotlin 使用冒号操作符,如清单 4-2 所示。
| -什么 | 使用冒号操作符,而不是 Java 的*实现*关键字。冒号也用于继承类。 | | ➋ | 我们必须为`answer()`函数提供一个实现,因为它在接口定义中没有实现。另一方面,我们不必为`call()`和`print()`提供实现,因为它们在接口定义中有实现。您可能还注意到,我们在这个函数中使用了`override`关键字。为了向编译器澄清我们不打算隐藏或掩盖接口定义中的`answer()`函数,使用它是必要的。相反,我们打算替换它,这样它就可以是多态的。我们想在这个类中为`answer()`函数提供我们自己的行为。 |class MultiFunction : Fax { ➊
override fun answer () { ➋
}
}
Listing 4-2class MultiFunction Implementing Fax
你可能想知道为什么 Kotlin 允许我们在接口中提供实现。难道接口不应该只包含抽象函数并将实现留给将实现接口的类吗?这样,您就可以在类型之间实施契约。嗯,在 Java 的早期,这正是接口的使用方式;它们纯粹是一种抽象的结构。然而,从 Java 8 开始,您已经可以在接口上提供默认实现。
允许这样做有一些实际的原因。接口上的默认实现将允许我们随着时间的推移来发展接口。想象一下,如果我们今天用成员函数 a(),【b(),, c() 写接口 Foo ,并且这个发布给其他开发者。将来,如果我们给接口 Foo 添加函数 d() ,那么所有使用 Foo 的代码现在都将被中断。然而,如果我们为 d(),提供一个默认实现,那么现有的代码就不必中断。这是接口上的函数实现可能有用的用例之一。
钻石问题
当一个类继承了两个超类型,并且这两个超类型实现了完全相同的函数或方法时,就会出现“菱形问题”。代码示例见清单 4-3 。
interface A {
fun foo() {
println("A:foo")
}
}
interface B {
fun foo() {
println("B:foo")
}
}
class Child : A, B {
}
Listing 4-3Diamond Problem
清单 4-3 中显示的代码不会编译,因为不清楚从子类的实例调用函数 foo() 时会有什么行为; foo() 由接口 A 和 B 定义,两个接口都为函数提供默认实现。这就是所谓的“钻石问题”一个类继承自两个超类型,一个行为被定义在一个以上的类型上,这些类型是类的来源。在清单 4-3 中,如果我们从子的一个实例中调用 foo() ,那么它将表现出哪种行为是不明确的——它将打印“A:foo”还是“B:foo”。在 Kotlin 中,解决这个问题的方法是让子类提供冲突函数的实现——在本例中,函数 foo() 。清单 4-4 给出了解决方案。
interface A {
fun foo() {
println("A:foo")
}
}
interface B {
fun foo() {
println("B:foo")
}
}
class Child : A, B {
override fun foo () {
println("Child:foo")
}
}
fun main(args: Array<String>) {
var child: Child = Child()
child.foo()
}
Listing 4-4Diamond Problem, Solved
调用超级行为
像 Java 一样,Kotlin 的函数可以调用其父类型的函数,如果它有实现的话。此外,像在 Java 中一样,Kotlin 使用 super 关键字来实现这一点。Kotlin 中的 super 关键字与 Java 中的含义相同——它是对超类型实例的引用。要调用超类型的函数,你需要三样东西:(super 关键字;(2)包含在一对尖括号中的超类型的名称;以及(3)您想要在超类型上调用的函数的名称。它看起来像下面的代码片段:
super<NameOfSuperType>.functionName()
让我们扩展一下本章前面的传真和多功能示例。
interface Printable {
fun print(doc:String) = println("Printer:Printing $doc")
}
interface Fax {
fun call(number: String) = println("Calling $number")
fun print(doc: String) = println("Fax:Printing $doc")
fun answer() = println("answering")
}
class MultiFunction : Printable, Fax {
override fun print(doc:String) {
println(“Multifunction: printing”)
}
}
Listing 4-5Printable, Fax, and MultiFunction
清单 4-5 显示了之前的传真和多功能示例。我们添加了一个名为 Printable 的新接口,它还定义了一个print()函数。我们修改后的代码清单显示了从 Fax 和新的 Printable 接口继承而来的多功能类。多功能类覆盖print()功能;它必须这样做,因为print()函数继承自 Printable 和 Fax 接口,并且在这两个接口上都有默认的实现。
多功能中被覆盖的print()功能有一个简单的 println 语句。为了演示如何调用超类型上的函数,我们将从多功能中被覆盖的print()内调用两个超类型上的print()函数。清单 4-6 向我们展示了如何做到这一点。
class MultiFunction : Printable, Fax {
override fun print(doc:String) {
super<Fax>.print(doc)
super<Printable>.print(doc)
println("Multifunction: printing")
}
}
Listing 4-6MultiFunction, Calling Functions on Supertype
现在,当我们调用print()函数时,它将调用传真中的print(),然后是可打印,最后是多功能中被覆盖的print()中的任何语句。清单 4-7 显示了这个例子的完整代码。
interface Printable {
fun print(doc:String) = println("Printer:Printing $doc")
}
interface Fax {
fun call(number: String) = println("Calling $number")
fun print(doc: String) = println("Fax:Printing $doc")
fun answer() = println("answering")
}
class MultiFunction : Printable, Fax {
override fun print(doc:String) {
super<Fax>.print(doc)
super<Printable>.print(doc)
println("Multifunction: printing")
}
}
fun main(args: Array<String>) {
val mfc = MultiFunction()
mfc.print("The quick brown fox")
mfc.call("12345")
}
Listing 4-7MultiFunction, Printable, and Fax
类
使用(1)关键字 class 定义一个类;(2)标识符,这将是它的名称;(3)可选的报头;和(4)任选的主体。清单 4-8 显示了一个基本类。
class Person() {
}
Listing 4-8A basic class in Kotlin
类的头部是一对括号。头可能包含参数,但是在这个例子中,它没有任何参数。这对花括号组成了类的主体。头文件和类主体都是可选的,但是我们在书中使用的大多数代码都会包含它们。
要实例化 Person 类,我们可以编写如下代码:
var person = Person()
如果不是因为明显缺少 new 关键字,它看起来很像我们在 Java 中创建对象的方式。类型名( Person )后面的一对括号是对无参数构造函数(ctor)的调用。让我们稍微回顾一下清单 4-8 ,仔细看看类定义的头部。这是少数几个 Kotlin 看起来和感觉上与 Java 有点不同的地方之一。Java 类没有头文件,但是 Kotlin 有。这个头实际上是一个构造函数定义。
构造器
Kotlin 类的定义中可以有多个构造函数。这与 Java 没有太大的不同,因为它的类也可以包含多个 ctor。然而,Kotlin 区分了初级和次级细胞。主 ctor 写在类的头部,就像你在清单 4-8 中看到的那样,而次 ctor 写在主体中。清单 4-9 显示了一个带有主构造函数的类。
| -什么 | 当一个构造函数写在类头上时,就像这样,它是主 ctor。这种编写 ctor 的方式与我们在清单 4-8 中的例子基本相同,除了清单 4-8 不包含*构造函数*关键字,并且在这里(清单 4-9 ,我们的 ctor 接受一个参数。 | | ➋ | 这是一个成员变量,将保存`_name`的值。 | | ➌ | 这是一个*初始化器*块,类似于 Java 的*初始化器*。每当创建一个类的实例时,就会执行这个操作。在你的类中可以有不止一个初始化器块,当这种情况发生时,*初始化器*将按照它们在类中被定义的顺序被执行。一个*初始化器*块是一对以关键字`init`为前缀的花括号,当你拥有的唯一构造函数是主构造函数时,你通常会使用它们,因为主构造函数不能包含任何代码(无论是语句还是表达式)。 | | -你好 | 我们可以访问从初始化器块传递给主 ctor 的参数。 |class Person constructor(_name: String) { ➊
var name:String ➋
init { ➌
name = _name ➍
}
}
Listing 4-9Person Class with Primary Constructor
当主 ctor 没有(或不需要)注释或可见性修饰符时,我们可以省略构造函数关键字,如下所示:
class Person (_name: String) {
var name:String
init {
name = _name
}
}
我们可以通过在语句中加入 init 块和 name 变量的声明来进一步简化和缩短代码。Kotlin 很聪明。
class Person (_name: String) {
var name:String = _name
}
构造函数也可以在类体内定义,就像在 Java 中那样。当它们被写成这样时,它们被称为二级构造函数。清单 4-10 显示了一个带有辅助 ctor 的示例代码。
class Employee {
var name:String
constructor(_name: String) {
name = _name
}
}
Listing 4-10Employee Class, with Secondary Constructor
注意,在清单 4-11 中,我们不必使用 init 块,因为 name 成员变量的初始化是在构造函数体中完成的。与主 ctor 不同,辅助 ctor 可以包含代码。
| -什么 | 我们必须初始化我们的成员变量,因为 Kotlin 不能告诉我们正在做什么初始化。 | | ➋ | 二级构造函数需要有*构造函数*关键字。这个 ctor 没有身体;这样写没问题。此外,这个 ctor 调用另一个 ctor—一个接受两个参数的 ctor。 | | ➌ | 为 Employee 类定义了另一个辅助构造函数。这个函数有两个参数:一个名字和一个雇员 id。 |class Employee {
var name:String = "" ➊
var empid:String = ""
constructor(_name: String) : this(_name, "1001") ➋
constructor(_name:String, _id: String) { ➌
name = _name
empid = _id
}
}
Listing 4-11class Employee, with Two Secondary Constructors
你可以在 Kotlin 中重载你的构造函数,就像我们在 Java 中做的那样,正如你在清单 4-11 中看到的。而且,和在 Java 中一样,我们可以使用 this 关键字调用其他构造函数。Kotlin 中的 this 关键字与 Java 中的相同,它指的是你自己的一个实例——这并不奇怪。不过,请注意,我们是如何使用 this 构造将调用委托给另一个二级构造函数的。您需要使用冒号将这个调用链接到构造函数定义(参见清单 4-11 的第 2 项)。
虽然 Kotlin 允许我们通过重载在构造函数上进行参数多态性,但这并不是真正惯用的 Kotlin,因为使用 Kotlin 为函数参数提供默认值的能力可以获得相同的结果。参见清单 4-12 中 Employee 类示例的简化版本。
class Employee (_name:String, _empid:String = "1001") {
val name = _name
val empid = _empid
}
Listing 4-12Simplified Employee class
清单 4-12 中的代码更短更简洁。此外,通过将构造函数参数移动到主构造函数,它允许我们使用 val 而不是 var 来声明成员变量。在 Kotlin 中,使用不可变变量是一种首选技术,因为它可以减少整体编码错误。如果一个属性的值一开始就是不可变的,那么你不可能意外地改变它。
遗产
默认情况下,Kotlin 类是 final ,而 Java 类是“开放的”或非 final。如清单 4-13 所示的代码不会编译,因为 Person 类是 final 。
class Person {
}
class Employee : Person() {
}
Listing 4-13Person and Employee class
为了编译我们的代码样本,我们必须显式地告诉 Kotlin 类 Person 是 open ,这意味着我们打算让它被扩展或继承(参见清单 4-14 )。Kotlin 类的这种默认行为被认为是正确的行为和良好的实践。套用 Joshua Bloch 的有效 Java (Addison-Wesley,2008)中的一句话:“为 继承 *设计和文档,否则禁止它。”*这实际上意味着所有您不打算扩展或覆盖的类和方法都应该被声明为 final 。在 Kotlin,这是自动行为。清单 4-14 再次显示了 Person 类,但是这一次,它具有 open 修饰符,这意味着类 Person 可以被扩展。
open class Person {
}
class Employee : Person() {
}
Listing 4-14Person and Employee class
将 final 作为默认行为的行为不只是针对类;Kotlin 中的成员函数也是如此。当一个函数没有使用 open 修饰符时,它就是最终的。
| -什么 | 函数需要特别标记为 open,这样它们就可以被子类型覆盖。 | | ➋ | 子类型需要用 *override* 关键字来标记函数,以使其具有多态性。IntelliJ 足够聪明,当它感觉到您正在子类型上定义一个函数,而该子类型在父类型上有一个精确的签名,并且没有使用 *override* 关键字时,它会阻止编译发生。 | | ➌ | 我们可以从这里称超行为;这有效地调用了类 *Person 中的`talk()`函数。* | | -你好 | 我们正在覆盖`toString()`函数。这个行为是从 *Person* 类继承来的,而后者又是从 *Any* 类继承来的。您可以将类 *Any* 视为 *java.lang.Object.* 的模拟 |open class Person(_name:String) {
val name = _name
open fun talk() { ➊
println("${this.javaClass.simpleName} talking")
}
}
class Employee(_name:String, _empid:String = "1001") : Person(_name) {
val empid = _empid
override fun talk() { ➋
super.talk() ➌
println("Hello")
}
override fun toString():String{ ➍
return "name: $name | id: $empid"
}
}
Listing 4-15Method Overriding
你需要记住,当一个函数已经被标记为 open 时,它将保持开放,以便被它的直接子类型甚至间接子类型覆盖,除非该函数再次被标记为 final 。为了说明这一点,让我们考虑列出 4-16 。
| -什么 | `talk()`功能第一次被标记为打开。 | | ➋ | 我们可以从这里超越`talk()`。 | | ➌ | 即使类 Employee 没有将函数标记为 open,我们仍然可以在这里覆盖`talk()`。函数`talk()`在继承层次结构中保持隐式打开,除非它在继承链中的某处被标记为 final。 |open class Person(_name:String) {
val name = _name
open fun talk() { ➊
println("${this.javaClass.simpleName} talking")
}
}
open class Employee(_name:String, _empid:String = "1001") : Person(_name) {
val empid = _empid
override fun talk() { ➋
super.talk()
println("Employee overriding talk()")
}
override fun toString():String{
return "name: $name | id: $empid"
}
}
class Programmer(_name:String) : Employee(_name) {
override fun talk() { ➌
super.talk()
println("Programmer overriding talk()")
}
}
Listing 4-16class Person, Employee, and Programmer
清单 4-17 演示了如何在继承链中再次“关闭”一个函数。
| -什么 | 在同一行上看到 final 和 override 关键字确实有点奇怪,但这是完全合法的。这意味着我们正在覆盖函数,同时为了进一步的继承而“关闭”它。该函数中的 final 关键字只影响 Employee 类的子类型,而不影响 Employee 类本身。 | | ➋ | 这个不会再编译了。 |open class Person(_name:String) {
val name = _name
open fun talk() {
println("${this.javaClass.simpleName} talking")
}
}
open class Employee(_name:String, _empid:String = "1001") : Person(_name) {
val empid = _empid
override fun talk() {
super.talk()
println("Employee overriding talk()")
}
final override fun toString():String{ ➊
return "name: $name | id: $empid"
}
}
class Programmer(_name:String) : Employee(_name) {
override fun talk() { ➋
super.talk()
println("Programmer overriding talk()")
}
}
Listing 4-17How to Make a Function Final, Again
性能
传统上,类或对象中的属性是通过定义成员变量并为其提供访问器方法来创建的。这些方法通常会遵循一些命名约定,其中成员变量的名称会以 get 和 set 为前缀。
class Person {
private String name;
public String getName() {
return this.name;
}
public void setName(String arg) {
this.name = arg;
}
public static void main(String []args) {
Person person = new Person();
person.setName("John Doe");
System.out.println(person.getName());
}
}
Listing 4-18Person Class in Java with a Single Property
清单 4-18 显示了一个简单的 Java 类,它定义了一个名为 name 的属性。这是通过定义一个私有的成员变量来实现的,这样对这个状态的访问就只能通过访问器— getName()和setName()来控制。这种编码在 Java 中是惯用的,因为它没有对属性的本地语言支持。我们仍然可以在 Kotlin 中遵循这种编码风格,但我们不必这样做,因为 Kotlin 有对属性的语言支持。
如果我们用 Kotlin 重写清单 4-18 ,它看起来会像清单 4-19 中的代码。
| -什么 | 构造函数接受一个参数。这允许我们在创建时设置对象的名称。 | | ➋ | 我们可以从这里通过构造函数访问参数。 | | ➌ | 这看起来像是我们在直接访问 name 成员变量,但实际上不是。这实际上调用了 get 访问器方法。 |class Person(_name:String) { ➊
val name:String = _name ➋
}
fun main(args: Array<String>) {
var person = Person("John Smith")
println(person.name) ➌
}
Listing 4-19Person class With a Single Property
清单 4-19 中的 Person 类定义可以进一步简化为清单 4-20 中的定义。
class Person(val name:String)
fun main(args: Array<String>) {
var person = Person("John Smith")
println(person.name)
}
Listing 4-20Simplified Person class
这里的代码是用 Kotlin 定义属性的最简洁的方式。也被认为是惯用的。请注意我们在代码中所做的更改:
-
主构造函数中的参数现在有了一个 val 声明。这实际上使构造函数参数成为一个属性。我们本可以使用 var ,它也会同样有效。
-
我们不再需要区分构造函数参数中的标识符和成员变量;因此,我们删除了
_name变量中的前导下划线。 -
我们可以放弃整个类,因为我们不再需要它了。类体只包含将构造函数参数的值传递给成员变量的代码。由于 Kotlin 将自动为构造函数参数定义一个支持字段,我们不必在类体中做任何事情。
清单 4-20 中的代码展示了在 Kotlin 中定义数据对象的最基本的方法(java 程序员称之为 POJOs 或普通 Java 对象)。通过简单地在主构造函数参数中使用 val 或 var ,我们可以用适当的赋值函数方法自动定义属性。然而,在某些情况下,您仍然需要对这些属性的“获取”和“设置”过程进行更多的控制。Kotlin 也允许我们这样做。
我们可以通过执行以下操作来接管“获取”和“设置”的自动过程:
-
在类体中声明属性,而不是在主构造函数中。
-
在类体中提供 getter 和 setter 方法。
声明属性的完整语法如下:
var <property name>:[<property type>][=<initializer>]
[<getter>]
[<setter>]
清单 4-21 展示了定制访问器方法的一些基本用法。
| -什么 | 我们在类体内声明并定义了*属性*,而不是在主构造函数中将其作为参数捕获。我们首先将其初始化为一个空字符串。 | | ➋ | `get()`的语法看起来很像定义*函数、*的语法,除了我们没有在它前面写 *fun* 关键字。 | | ➌ | 这是您编写自定义代码的地方。每当有人试图访问`name`属性时,就会执行该语句。 | | -你好 | *字段*关键字是一个特殊的关键字。它指的是*支持字段*,当我们定义一个名为`name`的属性时,Kotlin 会自动提供这个字段。`name`成员变量不是简单变量;Kotlin 为它创建了一个自动的*支持字段*,但是我们不能直接访问那个变量。然而,我们可以通过*字段*关键字来访问它,就像我们在这里所做的那样。 | | ➎ | `value`参数对应于创建 Employee 对象后将分配给该属性的值(参见项目符号➐). | | ➏ | 在我们执行了自定义逻辑之后,我们现在可以设置字段的值。 | | -好的 | 这将触发我们的 set 访问器逻辑,参见➎. | | -好的 | 这将触发我们的 get 访问器逻辑,参见➋. |class Employee {
var name: String = "" ➊
get() { ➋
Log("Getting lastname") ➌
return field ➍
}
set(value) { ➎
Log("Setting value of lastname")
field = value ➏
}
}
fun Log(msg:String) {
println(msg)
}
fun main(args: Array<String>) {
var emp = Employee()
emp.name = "John Doe" ➐
println(emp.name) ➑
}
Listing 4-21Custom Accessor Methods
您可能想知道为什么我们在 getter 和 setter 方法中使用字段关键字。为什么我们不能像在 Java 中那样编写访问器方法呢(参见清单 4-22 )?这是为属性编写 getter 和 setter 代码的错误方式。
| -什么 | 这会导致递归调用,最终会抛出 *StackOverflowError。* | | ➋ | 这个也会 |class Employee {
var name: String = ""
get() {
Log("Getting lastname")
return this.name ➊
}
set(value) {
Log("Setting value of lastname")
this.name = value ➋
}
}
在清单 4-22 中,表达式this.name并不真正访问成员变量名。相反,当您为类定义属性时,它调用 Kotlin 自动提供的默认访问器方法。因此,从访问函数内部调用this.name将导致递归调用的混乱,最终运行时将抛出一个 StackOverflowError 。为了防止这种情况发生,当从访问器函数中引用属性名的后台字段时,应该使用 field 关键字。
数据类别
当 POJOs 被创建时,有时它们会被存储在集合中(例如, ArrayList , HashMap , *HashSet,*等等)。).为了正确利用这些 POJOs,在 Java 中,我们需要覆盖equals(), hashCode(),和toString()方法。请记住,在 Java 中,当它们存储在集合中时,我们可以正确地使用它们——特别是对 hashCode 敏感的集合。
在上一节中,我们已经看到了在 Kotlin 中创建 POJOs 的模拟是多么容易。我们可以简单地在类中定义属性,这样就可以了。对于简单的用例,我们在上一节中创建的数据对象应该足够好了。但是当您需要做一些事情,比如在集合中存储值对象,或者比较对象之间的内容是否相等时,您会发现有属性的类是不够的。为了在集合对象中正确地利用值对象,我们需要能够可靠地比较对象。在 Java 中,我们通过覆盖java.lang.Object的一些方法来解决这类问题,即equals()和hashCode()方法。当我们进行对象比较时,这些方法是关键。
class Employee(val name:String)
fun main(args: Array<String>) {
val e1 = Employee("John Doe")
val e2 = Employee("John Doe")
println(e1 == e2) // output is false
}
Listing 4-22Comparing Two Employee Objects
记住,在 Kotlin 中,double equals 操作符实际上调用被比较的操作数的equals()函数——由于 Kotlin 中的所有东西都是对象,它们都有equals()函数,因为它是从父类型 Any 继承的。如果我们让 Employee 类像清单 4-22 中那样,它将使用来自类 Any 的equals()函数的实现,并且它不知道如何比较 Employee 对象。为了解决这个问题,我们可以重写 equals()方法,并提供一个关于如何比较 Employee 对象的实现。
注意
像 Java 一样,Kotlin 遵循单根类继承。如果我们不在类定义中指定一个超类,这个类将隐式地扩展 Any 。这个类是 Kotlin 中所有不可空类型的超类型。
要修复清单 4-22 中的代码,我们通常必须覆盖清单 4-23 中所示的equals()和hashCode()函数。
import java.util.*
class Employee(val name:String){
override fun equals(obj:Any?):Boolean { ➊
var retval = false
if(obj is Employee) { ➋
retval = name == obj.name ➌
}
return retval
}
override fun hashCode(): Int { ➍
return Objects.hash(name)
}
}
fun main(args: Array<String>) {
val e1 = Employee("John Doe")
val e2 = Employee("John Doe")
println(e1) ➎
println(e1 == e2) ➏
}
Listing 4-23Overriding the hashCode() and equals() Functions
这种编码实践在 Java 中非常常见,因此,相当多的 ide 都具有生成 toString()、equals()和 hashCode()样板代码的能力。虽然我们仍然可以在 Kotlin 做这些事情,但我们没有必要这样做。在 Kotlin 中,我们需要做的唯一一件事就是让 Employee 成为一个数据类。清单 4-24 向我们展示了如何操作。
| -什么 | 要使 Kotlin 中的任何类成为数据类,只需在类声明中使用关键字 ?? 数据。 | | ➋ | 我们得到了一个额外的好处,数据类的输出更好。这个现在打印“雇员(姓名=John Doe)”。 | | ➌ | 另外,`equals()`比较返回 true。 |data class Employee(val name:String) ➊
fun main(args: Array<String>) {
val e1 = Employee("John Doe")
val e2 = Employee("John Doe")
println(e1) ➋
println(e1 == e2) ➌
}
Listing 4-24Employee Data Class
可见性修改器
Kotlin 使用与 Java 几乎相同的关键字来控制可见性。关键字 public 、 *private、*和 protected 在 Kotlin 中的意思和在 Java 中完全一样。但是,缺省可见性是区别所在。在 Kotlin 中,无论何时省略可见性修饰符,默认可见性都是 public 。
class Foo {
var bar:String = ""
fun doSomething() {
}
}
Listing 4-25Class Foo
在清单 4-25 中,Foo 类及其成员是公开可见的。如果你想改变可见性,你必须显式声明。相比之下,Java 的默认可见性是包私有,这意味着它只对同一个包中的类可用。Kotlin 没有包-私有等价,因为 Kotlin 不使用包来管理可见性。Kotlin 中的包只是一种组织文件和防止名称冲突的方法。
代替 Java 的 package-private,Kotlin 引入了 internal 关键字,这意味着它在模块中是可见的。模块只是文件的集合,它可以是(IntelliJ 模块或项目;(2)一个 Eclipse 项目;(3)一个 Maven 项目;或者(4)一个 Gradle 项目。为了演示一些可见性修改器的运行,请参见清单 4-26 。
| -什么 | 类 *Foo* 被标记为 *internal,*这使得它只在同一个模块中的类和顶级函数中可见,并且它们的可见性也被标记为 *internal。* | | ➋ | 这是一个错误。*扩展函数*被标记为 *public,*但是函数(Foo)的接收者被标记为 internal。类 *Foo* 比扩展函数更不可见;因此 Kotlin 不允许我们。 | | ➌ | 对该类是私有的,因此我们无法从这里访问它。 | | -你好 | 受到保护,我们无法从这里到达。 |internal open class Foo { ➊
private fun boo() = println("boo")
protected fun doo() = println("doo")
}
fun Foo.bar() { ➋
boo() ➌
doo() ➍
}
fun main(args: Array<String>) {
var fu = Foo()
fu.bar()
}
Listing 4-26Demonstrating Visibility Modifiers
为了让清单 4-26 顺利运行,我们需要修复可见性错误。清单 4-27 显示了解决方案。
internal open class Foo {
internal fun boo() = println("boo")
internal fun doo() = println("doo")
}
internal fun Foo.bar() {
boo()
doo()
}
fun main(args: Array<String>) {
var fu = Foo()
fu.bar()
}
Listing 4-27class Foo, Corrected Visibility Errors
访问修饰符
Kotlin 的访问修饰符是 *final、open、abstract、*和 override 。它们影响遗传。我们已经在本章前面使用了 final、open、和覆盖,所以我们唯一没有使用的关键字是 abstract 。abstract 关键字在 Kotlin 中的含义与在 Java 中的含义相同。它适用于类和函数。
当你将一个类标记为抽象时,它也变成了隐式开放,所以你不需要使用开放修饰符,这就变得多余了。接口不需要声明为抽象和开放,因为它们已经是隐式的了,抽象和开放。
对象声明
Java 的 static 关键字没有在 Kotlin 的关键字列表中出现。在 Kotlin 没有静态对等;取而代之的是,Kotlin 引入了对象和伴侣关键字。
对象关键字允许我们同时定义一个类和它的实例。更具体地说,它只定义了该类的一个实例,这使得这个关键字成为在 Kotlin 中定义单例的好方法。清单 4-28 显示了对象关键字的基本用法。
object Util {
fun foo() = println("foo")
}
fun main(args: Array<String>) {
Util.foo() // prints "foo"
}
Listing 4-28Using the Object Keyword to Define a Singleton
我们用对象关键字代替类关键字。这实际上是定义类并创建它的一个实例。为了调用这个对象中定义的函数,我们给点加上前缀(.)加上对象的名称——非常类似于我们在 Java 中调用静态方法的方式。
对象声明可以包含您可以在类中编写的大部分内容,比如初始化器、属性、函数和成员变量。唯一不能写在对象声明中的是构造函数。这样做的原因是因为你不需要构造函数。对象声明已经在定义时创建了一个实例,因此不需要构造函数。清单 4-29 显示了对象声明的一些基本用法和定义。
object Util {
var name = ""
set(value) {
field = value
}
init {
println("Initializing Util")
}
fun foo() = println(name)
}
fun main(args: Array<String>) {
Util.name = "Bar"
Util.foo() // prints "Bar"
}
Listing 4-29Initializers, Properties, Functions, and Member Variables in Object Declarations
章节总结
-
Kotlin 接口与 Java 的接口几乎相似,只是你可以在接口中声明属性,尽管它们仍然不允许有后台字段。像 Java 8 一样,Kotlin 接口可以有默认的实现。
-
Kotlin 类的定义与 Java 类略有不同。默认情况下,类是 final 和 public。
-
Kotlin 有两种构造函数:可以定义主构造函数和次构造函数。主构造函数是创建简单值对象的好方法。然而,要创建真正有用的值对象,Kotlin 的数据类是一个很好的方法。
-
Kotlin 拥有与 Java 几乎相同的控制可见性的机制,除了 Kotlin 用内部关键字替换了 Java 的包私有。
在下一章,我们将涉足函数式编程的世界。
五、Lambdas 和高阶函数
我们将介绍的内容:
-
高阶函数
-
希腊字母的第 11 个
-
关闭
-
使用并应用
在第二章中,我们讨论了 Kotlin 函数的机制,你已经看到了它们与 Java 函数是多么的相似;你也看到了他们是多么的不同。在这一章中,我们将回到对函数的讨论,但是是一种不同的函数——支持函数编程的函数。你可能在 Java 8 中用过 lambdas 同样,Kotlin 也有对兰姆达斯的支持。在本章中,我们将探讨这两个主题。
高阶函数
高阶函数是对其他函数进行操作的函数,要么将它们作为参数接受,要么返回它们。术语高阶函数来自数学界,在数学界,函数和其他值之间有更正式的区别。
在我们开始讨论“为什么我们需要高阶函数?”我们需要注意它的结构。我们需要知道如何写它们,它们看起来像什么。关于高阶函数“为什么”的讨论可能会在后面的章节中出现,当我们讨论 Android 编程时,有很多机会可以很好地使用高阶函数。
下面的清单 5-1 展示了一个将另一个函数作为参数的函数的例子。
fun executor(action:() -> Unit) {
action()
}
Listing 5-1A Function That Accepts Another Function
注意清单 5-1 中参数是怎么写的,动作是参数的名称,其类型写成()-> Unit,也就是说它的类型是函数。一个函数类型是用一对括号写的,后跟箭头操作符(一个破折号加上大于号),然后是函数应该返回的类型。在清单 5-1 的例子中,我们的函数参数不返回任何东西——因此它被声明为单元。
这乍一看可能很奇怪,尤其是如果你没有使用过函数和变量被同等对待的语言。在 Kotlin 中,像任何支持高阶函数的语言一样,函数是一等公民。我们可以从任何可以传递(或返回)变量的地方传递(或返回)函数。在任何可以使用变量的地方,也可以使用函数。
让我们回到清单 5-1 。如果我们希望动作参数的类型为字符串,那么我们可以编写类似清单 5-2 中的内容。
fun executor(action:String) {
action()
}
Listing 5-2If Action Was of Type String
但事实并非如此;我们希望动作是类型功能。在 Kotlin 中,函数不仅仅是一个命名的语句集合,它还是一种类型。所以,就像 String 、 *Int、*或 Float 一样,我们可以将一个变量声明为类型 function 。一个函数类型有三个组成部分:(1)带括号的参数类型列表;(2)箭头运算符;以及(3)返回类型。
在清单 5-1 中,带括号的参数类型列表是空的,但并不总是这样。它现在是空的,因为我们打算传递给executor()的函数不接受任何参数。executor()的返回类型是 Unit,因为我们打算传递给它的函数不返回任何值——也不总是这样,有时你可能想返回一个 Int 或 String。
现在我们已经了解了如何将参数声明为函数类型,让我们来看看如何将变量声明和定义为函数类型。参见清单 5-3 。
val doThis:() -> Unit = {
println("action")
}
Listing 5-3How to Declare and Define a Function Type
LHS(左手边)不需要太多解释,我们只是简单地声明一个名为doThis的变量是类型函数,这个函数不返回任何东西,所以它声明的返回类型是 Unit。RHS(右侧)看起来像一个没有标题的函数(关键字 fun 和函数名),这是一个 lambda。我们将在下一节讨论兰姆达斯。回到我们的代码示例,清单 5-4 展示了如何将executor()和doThis放在一起。
val doThis:() -> Unit = { ➊
println("action")
}
fun executor(action:() -> Unit) { ➋
action() ➌
action.invoke() ➍
}
fun main(args: Array<String>) {
executor(doThis) ➎
}
Listing 5-4Complete Code for doThis and executor() Examples
在清单 5-4 中,我们将doThis写成一个值为λ的属性。这很好,但是这可能不像是编写函数的自然方式。编写清单 5-4 的另一种方式如清单 5-5 所示。
fun doThis() { ➊
println ("action")
}
fun executor(action:() -> Unit) {
action()
}
fun main(args: Array<String>) {
executor(::doThis) ➋
}
Listing 5-5Another Way of Writing the doThis and executor() Examples
Lambda 和匿名函数
Lambdas 和匿名函数被称为函数文字。这些函数没有声明,而是作为表达式直接传递给更高阶的函数。因此他们不需要名字。我们已经在本章前面使用了 lambda 表达式。在清单 5-3 中,我们定义了一个名为 doThis 的属性,它的类型是一个函数,但是这是一个相当冗长的处理函数类型的方法。我们实际上不需要显式地编写函数的返回类型,因为 Kotlin 可以为我们推断出它。清单 5-6 显示了清单 5-3 的更简洁版本。
val doThis = {
println("action")
}
Listing 5-6Concise Version of Listing 5-3
正如您在上一节中看到的,这种代码旨在作为参数传递给更高阶的函数。但是您实际上可以使用它,而不用将它传递给更高阶的函数。要调用它,您可以做类似下面的事情——大概是在 function main 或任何其他顶级函数内部
doThis()
或者类似这样的东西
doThis.invoke()
前者看起来更自然;它也被认为更符合习惯,所以我们可能应该使用它。无论如何,lambda 表达式不应该这样使用。当用在高阶函数的上下文中时,它们真的闪闪发光。在清单 5-5 中,当我们将一个命名的 lambda 表达式传递给一个高阶函数时,我们使用了 lambda 表达式的完整语法形式。虽然你当然可以这样做,但这可能不是你在野外遇到 lambda 表达式的通常方式。清单 5-7 是清单 5-5 的重写,但是这一次,我们没有声明和定义一个已命名的 lambda,而是简单地将它作为参数传递给高阶函数执行器,如清单 5-7 所示。
| -什么 | 这是*函数的字面意思*。在清单 5-5 中,我们传递了一个属性`doThis,`,它的值是一个 lambda 表达式。在这个例子中,我们将 lambda 表达式本身直接传递给高阶函数。lambda 表达式包含在一对花括号中,就像函数体一样。 |fun main(args: Array<String>) {
executor(
{ println("do this") } ➊
)
}
fun executor(action:() -> Unit) {
action()
}
Listing 5-7Pass a lambda to a Higher Order Function
Lambda 表达式中的参数
考虑清单 5-8 中的代码。如果我们把它写成一个 lambda,它看起来像清单 5-9 。
{ msg:String -> println("Hello $msg") }
Listing 5-9display Function Written As lambda
fun display(msg:String) {
println("Hello $msg")
}
Listing 5-8Simple Function to Display a String
您会注意到整个函数头、关键字 fun 和函数名都完全消失了,参数列表被重新定位在 lambda 表达式中。整个表达式用一对花括号括起来。在 lambda 表达式中,参数列表写在箭头操作符的左边,函数体写在右边。您还会注意到,lambda 表达式中的参数不需要在一对括号内,因为箭头操作符将参数列表与 lambda 的主体分开。
同样,在清单 5-9 中,可以省略参数中 String 的类型声明,这样就可以像清单 5-10 中那样写了。
{ msg -> println("Hello $msg") }
Listing 5-10Omitted Type Declaration in Parameter List
在某些情况下,lambda 表达式只接受一个参数,比如清单 5-10 中所示的代码示例,Kotlin 允许我们省略参数声明,甚至省略箭头操作符。我们可以用更短的方式重写清单 5-10 (参见清单 5-11 )。
{ println("Hello $it") }
Listing 5-11The Implicit It
如果上下文需要一个只有一个参数的 lambda,并且可以推断出它的类型,则生成参数名。清单 5-12 展示了如何在高阶函数的上下文中声明和使用 lambda 表达式的完整代码。现在我们有了 Hello World 示例的函数编程版本。
fun main(args: Array<String>) {
executor({ println("Hello $it") })
}
fun executor(display:(msg:String) -> Unit) {
display("World")
}
Listing 5-12Full Code for the lambda Example
编写和使用带有多个参数的 lambdas 与我们的单参数示例没有太大区别,只要您在箭头操作符的左侧编写参数列表。参见清单 5-13 中的示例。
fun main(args: Array<String>) {
doer({ x,y -> println(x + y) })
}
fun doer(sum:(x:Int,y:Int) -> Unit) {
sum(1,2)
}
Listing 5-13lambdas With More Than One Parameter
有时,高阶函数会将一些其他参数与函数类型一起接受。这样一个函数看起来像清单 5-14 。
fun executor(arg: String = "Mondo", display:(msg:String) -> Unit) {
display(arg)
}
Listing 5-14Higher Order Function With Multiple Parameters
我们可以用这个调用这个函数
executor("Earth", {println("Hola $it")})
由于执行器的第一个参数有一个默认值,我们仍然可以像这样调用它
executor({println("Hola $it")})
Kotlin 允许我们对 lambdas 的语法更加精确。如果 lambda 是高阶函数中的最后一个参数,我们可以将 lambda 写在调用函数的括号之外,如下所示:
executor() { println("Hello $it")}
如果 lambda 是唯一的参数,我们甚至可以完全省略括号,就像这样:
executor { println("Hello $it")}
这种简化现在可能看起来没什么大不了的,但是我相信随着你编写越来越多的 lambda 表达式,你会欣赏到语法上的改进。Kotlin 标准库大量使用了这些东西。
关闭
当您在函数中使用 lambda 表达式时,lambda 可以访问它的闭包。闭包由外部作用域中的局部变量以及封闭函数的所有参数组成。参见清单 5-15 中的示例。
|-什么
|
我们向executor()函数传递了一个int的列表。使用运算符形式(..)的 rangeTo 函数是一种生成从 1 到 1000 的整数列表的简便方法。但是你必须使用flatten()函数把它变成一个整型数的列表。
|
| --- | --- |
| ➋ | forEach是高阶函数;它接受一个 lambda,允许我们遍历列表中的项目。forEach只有一个参数,我们可以使用隐式的it参数名来访问这个参数。 |
| ➌ | sum变量是闭包的一部分;它在定义 lambda 的函数体内。兰姆达斯可以使用他们的闭包??。 |
fun main(args: Array<String>) {
executor(listOf(1..1000).flatten()) ➊
}
fun executor(numbers:List<Int>) {
var sum = 0;
numbers.forEach { ➋
if ( it % 2 == 0 ) {
sum += it ➌
}
}
println("Sum of all even numbers = $sum")
}
Listing 5-15lambda Accessing Its Closure
注意
在 Java lambdas 中,只有当一个变量是 final 时,才能在它的闭包中访问该变量。在 Kotlin 没有这种限制。
使用并应用
兰姆达斯在 Kotlin 被大量使用,他们的足迹遍布 Kotlin 的图书馆。在这一节中,我们将看看来自标准库的函数 with 和 apply ,特别是来自 Standard.kt 的函数。这些函数展示了 Kotlin 的 lambdas 的功能,以及是什么让它们从 Java 同类产品中脱颖而出。Kotlin lambdas 能够调用不同对象的方法,而无需在 lambda 的主体中添加额外的限定符。这种兰姆达斯被称为带接收器的兰姆达斯。
带有和 apply 的函数特别令人感兴趣,不是因为它们允许我们在同一个对象上执行多个操作,而不用重复对象的名称——这是一个受欢迎的特性——而是因为它们看起来像是被融入了语言中,但事实并非如此。它们只是由扩展函数和λs使之变得特殊的函数。
清单 5-16 展示了一个简单类的定义以及如何设置它的一些属性。一个事件实例的创建及其各种属性的设置都发生在函数 main 内部。请注意,对于我们设置的每个属性,我们都必须显式地将属性解析回对象引用,这可能很好——毕竟,这就是我们在 Java 中的编码方式,这是意料之中的事情。
import java.util.Date
data class Event(val title:String) {
var date = Date()
var time = ""
var attendees = mutableListOf<String>()
fun create() {
print(this)
}
}
fun main(args: Array<String>) {
val mtg = Event("Management meeting")
mtg.date = Date(2018,1,1)
mtg.time = "0900H"
mtg.attendees.add("Ted")
mtg.create()
}
Listing 5-16class Event
如果我们使用带有函数的来重构代码,它将看起来像清单 5-17 中的代码。
fun main(args: Array<String>) {
val mtg = Event("Management meeting")
with(mtg) {
date = Date(2018,1,1)
time = "0900H"
attendees.add("Ted")
}
}
Listing 5-17Using the With Function
带有函数的接受一个对象( mtg )和一个 lambda。在 lambda 内部,我们可以使用 mtg 对象,而不需要显式引用它。这之所以成为可能,是因为 mtg 对象被做成 lambda 的接收器*——还记得第三章中的扩展函数吗?而且因为 mtg 是接收方,在 lambda 内部,这个关键字指向 mtg 对象。我们可以在代码中显式地引用这个*,但是这不会比我们第一次使用这个例子时更好。通过省略对这个的显式引用,得到的代码要干净得多。此外,将 lambda 放在括号外的约定在这种情况下肯定有效,因为它使构造看起来好像带有的是 Kotlin 语言的一部分。
应用功能可以实现同样的事情;除了返回接收者(传递给它的对象)之外,它几乎与带有函数的非常相似——带有函数的不返回接收者。
| -什么 | `Apply`是一个扩展函数,而 *mtg* 对象成为它的*接收者。* | | ➋ | 又因为 *mtg* 对象是*接收者*,*这个*指的是 *mtg* 对象。 | | ➌ | lambda 返回时,返回*接收器*,是一个 *mtg* 对象;因此,我们可以将一些调用链接到其中。 | fun main(args: Array<String>) {
val mtg = Event("Management meeting")
mtg.apply { ➊
date = Date() ➋
time = "0900H"
attendees.add("Ted")
}.create() ➌
}
标准版还有更多功能。Kt 喜欢跑,让,还等。,但是这两个使用 with 和 apply 的例子应该让我们对 lambdas 的能力有所了解。
章节总结
-
Kotlin 中的函数不仅仅是一个命名的语句集合。他们也是一个类型。一个函数类型可以在其他任何可以使用其他类型的地方使用——函数在 Kotlin 中是一等公民。
-
Lambdas 和匿名函数是函数文字。它们就像普通的函数,但是没有名字。它们可以作为表达式立即传递给其他函数。
-
Kotlin lambdas 不像他们的 Java lambdas(至少在撰写本文时是 Java 9),可以在其闭包中变异变量。
-
高阶函数是对其他函数进行运算的函数。它们可以接受函数类型作为参数,或者返回函数类型。
在下一章,我们将探索 Kotlin 的集合类。
六、集合和数组
我们将介绍的内容:
-
数组
-
收集
-
过滤并应用
现实世界中收藏的一个类比是一个钱包或一个装满硬币等各种东西的袋子。硬币将会是物品,而袋子本身就是收藏。因此,基于这个类比,我们可以说集合是各种各样的容器,其中可能有零个、一个或多个条目。你可能记得我们已经有了类似的东西——一个数组。该数组完全符合这一描述,因为它可以包含零个、一个或多个项目。如果是这样的话,我们真的需要学习其他容器吗?在这一章中,我们将看看 Kotlin 集合框架中的数组、集合和一些函数。
数组
从 Java 开始,在使用 Kotlin 数组之前,您需要稍微后退一步。在 Java 中,这些是特殊类型;他们在语言层面有一流的支持。在 Kotlin 中,数组只是类型;更确切地说,它们是参数化类型。如果您想创建一个字符串数组,您可能会认为下面的代码片段可能有用:
var arr = {"1", "2", "3", "4", "5"}
这段代码对 Kotlin 来说没有意义——它没有把数组当作特殊类型。如果我们想创建一个类似例子的字符串数组,我们可以用几种方法来实现。Kotlin 有一些库函数,如 arrayOf、emptyArray 和 arrayOfNulls ,我们可以用它们来简化数组的创建。清单 6-1 展示了如何使用 emptyArray 函数创建并填充一个数组。
var arr = emptyArray<String>();
arr += "1"
arr += "2"
arr += "3"
arr += "4"
arr += "5"
Listing 6-1Using the emptyArray Function
向 Kotlin 数组添加元素不像在 Java 中那样冗长,但是不要被漂亮的语法所迷惑。数组在创建时仍然是固定大小的,即使在 Kotlin 中也是如此。通过创建一个比旧数组更大的新数组,然后将旧数组的元素复制到新数组中,可以将元素添加到数组中。所以,你看,这仍然是一个昂贵的操作——即使我们有一个漂亮的甜语法。清单 6-2 展示了如何使用 arrayOfNulls 函数来做同样的事情。
var arr2 = arrayOfNulls<String>(2)
arr2.set(0, "1")
arr2.set(1, "2")
Listing 6-2Using the arrayOfNulls Function
arrayOfNulls 函数的整数参数是要创建的数组的大小。与清单 6-1 中的空数组不同,这个函数让您有机会为将要创建的数组提供一个大小。顺便说一下,你仍然可以对 Kotlin 数组使用括号语法,数组的 get 和 set 方法只是方便的函数。清单 6-3 展示了括号语法以及新的 get 和 set 函数的使用。
var arr2 = arrayOfNulls<String>(2)
// arr2.set(0, "1")
// arr2.set(1, "2")
arr2[0] = "1"
arr2[1] = "2"
println(arr2[0]) // same as arr2.get(0)
println(arr2[1])
Listing 6-3Get and Set Methods of Array
创建数组的另一种方法是使用函数的array。清单 6-4 显示了代码片段。
var arr4 = arrayOf("1", "2", "3")
Listing 6-4Using the arrayOf Function
这个函数可能是我们能得到的最接近 Java 数组文字的语法,这可能是为什么它被程序员更多地使用的原因。您可以将逗号分隔的值列表传递给函数,这样会自动填充新创建的数组。
最后,可以使用数组构造函数创建数组。构造函数接受两个参数,第一个参数是要创建的数组的大小,第二个参数是一个 lambda 函数,它可以返回每个元素的初始值。
var arr3 = Array<String>(5, {it.toString()})
Listing 6-5Using the Array Constructor
在大多数需要处理数字数组的情况下,使用数组类就足够了。然而,你需要记住,例如,Array<Int>,将 int 表示为整数对象,而不是整数原语。因此,如果您需要从代码中挤出更多的性能,并真正使用原始数字类型,您可以使用 Kotlin 的专用数组类型。
像 ByteArray 、 IntArray、ShortArray、和 LongArray 这样的专用类表示原始类型的数组(就像 Java 中的数组)。这些类型让您可以使用数组,而不需要像使用 number 原语的对象对应物的数组那样的装箱和拆箱开销。这些专用类型实际上并不继承自数组,但是它们有相同的方法和属性集。此外,它们有专门的工厂功能,使它们更容易使用。参见清单 6-6 中的示例。
var z = intArrayOf(1,2,3)
var y = longArrayOf(1,2,3)
var x = byteArrayOf(1,2,3)
var w = shortArrayOf(1,2,3)
println(Arrays.toString(z))
println(Arrays.toString(y))
println(Arrays.toString(x))
println(Arrays.toString(w))
Listing 6-6Special Array Types
我使用了Arrays.toString()函数,这样我们在打印内容时就可以得到可读的输出。如果你只是简单地打印没有帮助函数的数组,它看起来就像胡言乱语,就像这样
println(z) // outputs Ljava.lang.String;@6ad5c04e
遍历数组有几种方法。首先,您可以将可信任的用于循环,如清单 [6-7 所示。
for (i in z) {
println("$i zee")
}
Listing 6-7Using a for Loop to Process Each Array Element
或者你可以使用 forEach 函数,就像这样。
y.forEach { i -> println("$i why") }
如果需要跟踪数组的索引和元素,可以使用 forEachIndexed 函数,如清单 6-8 所示。
x.forEachIndexed { index, element ->
println("$index : $element")
}
Listing 6-8Using the forEachIndexed Function to Traverse the Array
在我们离开数组的主题之前,我们需要记住,如果您不希望数组的内容有任何重复,您必须自己编写程序逻辑。数组不能保证内容的唯一性。
虽然数组在许多情况下非常有用,但正如您在前面的讨论中看到的,它们也有局限性。向数组中添加新元素虽然语法友好,但仍然是一项开销很大的操作。如果不使用助手函数,就无法打印出来(虽然这没什么大不了的)。最后,它没有约束元素的工具(例如,强制唯一性)。在某些情况下,这些限制可能没什么大不了的,但在某些情况下,这些限制可能会成为交易杀手。因此,当我们遇到数组的限制时,我们就进入了集合的领域——它们帮助我们处理这样的限制。
作为开发工具包的一部分,集合框架的可用性对您来说可能不是一件大事。毕竟,你来自 Java,它有一个令人印象深刻的集合框架。但是你需要记住,在 Java、C#、Python 等语言之前。没有集合框架。程序员不得不编写他们自己的程序逻辑来处理诸如可调整大小的数组、后进先出访问、哈希表或哈希表等问题。这些不是简单的存储问题,而是数据结构问题。自己实现这种数据结构逻辑相当困难;有很多边缘情况需要纠正。尽管仍然有合理的理由实现自己的数据结构(可能是因为性能原因),但在大多数情况下,使用内置的集合框架会更好。
收集
Kotlin 收藏馆实际上是 JDK 收藏馆的直接实例。不涉及包装的转换。因此,如果您在使用 Java 时没有忽略对集合的研究,那现在肯定会派上用场。尽管 Kotlin 没有定义自己的集合代码,但它确实为框架添加了相当多的便利函数,这是一个受欢迎的附加功能,因为它使集合更容易使用。
在我们讨论代码示例和更多细节之前,需要说明一下为什么它被称为集合框架。之所以称之为框架,是因为数据结构本身非常多样化。其中一些限制了我们浏览整个系列的方式;它们强加了特定遍历顺序。一些集合约束数据元素的唯一性;他们不允许你放复制品。其中一些让我们成对地使用集合——就像在字典条目中,你将有一个具有相应值的键。
图 6-1
集合框架
图 6-1 显示了 Kotlin 集合框架的层次结构。在层次的顶端是接口 Iterable 和mutable talible——它们是我们将使用的所有集合类的父类。正如您在图中注意到的,每个 Java 集合在 Kotlin 中都有两种表示:一种是只读的,一种是可变的。可变接口直接映射到 Java 接口,而不可变接口缺少可变接口的所有 mutator 方法。
Kotlin 没有创建列表或集合的专用语法,但是它为我们提供了方便创建的库函数。表 6-1 列出了其中的一些。
表 6-1
Kotlin 收藏及其创作功能
|募捐
|
只读
|
易变的
|
| --- | --- | --- |
| 目录 | listOf | mutableListOf, arrayListOf |
| 设置 | setOf | mutableSetOf, hashSetOf, linkedSetOf, sortedSetOf |
| 地图 | mapOf | mutableMapOf, hashMapOf, linkedMapOf, sortedMapOf |
注意
尽管 map 类没有从 Iterable 或mutable talible(图 6-1 )继承,但它在 Kotlin 中仍然表现为两个不同的版本:一个是可变的,一个是不可变的。
列表
列表是一种具有特定迭代顺序的集合。这意味着,如果我们向列表中添加几个元素,然后遍历它,这些元素会以非常特定的顺序出现——即它们被添加或插入的顺序。它们不会以随机的顺序或颠倒的时间顺序出现,而是精确地按照它们被添加的顺序出现。它意味着列表中的每个元素都有一个放置顺序,一个指示其顺序位置的索引号。要添加的第一个元素的索引为 0,第二个元素的索引为 1,第三个元素的索引为 2,依此类推。所以,就像数组一样,它是从零开始的。清单 6-9 显示了列表的基本用法。
| -什么 | 创建一个可变列表,构造函数允许我们传递一个变量参数来填充列表。在这种情况下,我们只通过了一个论点——我们本可以通过更多的论点。 | | ➋ | 向列表中添加元素;“Orange”将紧跟在“Apple”之后,因为我们没有指定插入的顺序位置。 | | ➌ | 向列表中添加另一个元素,但是这一次,我们告诉它应该将元素放在哪里。这一个碰撞“橙色”元素,然后插入它自己。自然,它后面的所有元素的序号位置或索引将会改变。 | | -你好 | 您可以按名称删除元素。当一个元素被移除时,它旁边的元素将取代它的位置。它后面的所有元素的顺序位置将相应地改变。 | | ➎ | 您也可以通过指定元素在列表中的位置来移除元素。 | | ➏ | 你可以问`first()`柠檬是否等于“草莓”。 | | -好的 | 还可以测试一下`last()`柠檬是否等于“香蕉”。 |fun main(args: Array<String>) {
val fruits = mutableListOf<String>("Apple") ➊
fruits.add("Orange") ➋
fruits.add(1, "Banana") ➌
fruits.add("Guava")
println(fruits) // prints [Apple, Banana, Orange, Guava]
fruits.remove("Guava") ➍
fruits.removeAt(2) ➎
println(fruits.first() == "Strawberries") ➏
println(fruits.last() == "Banana") ➐
println(fruits) // prints [Apple, Banana]
}
Listing 6-9Basic Usage of Lists
设置
集合和列表在操作和结构上都非常相似,所以我们所学的关于列表的所有东西也适用于集合。集合与列表的不同之处在于它们对元素的唯一性进行约束。它们不允许在一个集合中有重复的元素或相同的元素。对许多人来说,“相同”意味着什么似乎是显而易见的,但是 Kotlin 和 Java 一样,对“相同性”有特定的含义。当我们说两个对象相同时,这意味着我们已经对这两个对象进行了结构相等的测试。Java 和 Kotlin 都定义了一个叫做equals(),的方法,它允许我们确定对象之间的等价关系。这就是我们通常所说的“相同”清单 6-10 显示了集合的一些基本操作。
val nums = mutableSetOf("one", "two") ➊
nums.add("two") ➋
nums.add("two") ➌
nums.add("three") ➍
println(nums) // prints [one, two, three]
val numbers = (1..1000).toMutableSet() ➎
numbers.add(6)
numbers.removeIf { i -> i % 2 == 0 } ➏
println(numbers)
Listing 6-10Basic Usage for Sets
地图
与列表或集合不同,映射不是单个值的集合;相反,它们是成对值的集合。把地图想象成字典或电话簿。它的内容使用键值对来组织。对于映射中的每个键,有且只有一个对应的值。在一个字典示例中,键将是术语的*,其值将是术语的含义的或定义的。*
映射中的键是唯一的。像集合一样,贴图不允许重复的键。然而,映射中的值不受相同的唯一性约束;map 中的两对或更多对可能具有相同的值。清单 6-11 显示了地图的一些基本用法。
| -什么 | Ca 可变映射 | | ➋ | 向映射添加新的键和值 | | ➌ | 将`dict`图分配给一个新变量。这不会创建新的地图。它仅向现有地图添加一个对象引用。 | | -你好 | 向映射添加另一个键值对 | | ➎ | 打印{bar = 2,baz = 3,foo=1} | | ➏ | 还打印{bar = 2,baz = 3,foo=1},因为`snapshot`和`dict`都指向同一个地图。 | | -好的 | 使用键从映射中获取值 |val dict = hashMapOf("foo" to 1) ➊
dict["bar"] = 2 ➋
val snapshot: MutableMap<String, Int> = dict ➌
snapshot["baz"] = 3 ➍
println(snapshot) ➎
println(dict) ➏
println(snapshot["bar"]) // prints 2 ➐
Listing 6-11Basic Operations on a Map
现在我们已经看到了集合的一些基本用法的例子,您可能已经注意到它们有一些共同的特征——可能不是 100%像地图一样,但是列表和集合有相当多的重叠。使用集合框架的一个好处是整个集合中某些操作的一致性或规律性。例如,我们从列表工作中学到的技能和知识也可以很好地在集合和地图之间转换。因此,熟悉集合协议是一个好主意。表 6-2 列出了集合上一些更常见的操作。
表 6-2
集合的常见操作
|功能或属性
|
描述
|
| --- | --- |
| Size | 告诉您集合中有多少元素。使用列表、集合和地图。 |
| isEmpty() | 如果集合为空,则返回 True,否则返回 False。使用列表、集合和地图。 |
| contains(arg) | 如果 arg 在集合中,则返回 True。使用列表、集合和地图。 |
| add(arg) | 向集合中添加参数。如果添加了 arg,则该函数返回 true 对于列表,将始终添加 arg。在集合的情况下,第一次将添加 arg 并返回 true,但是如果第二次添加相同的 arg,它将返回 False。在地图上找不到此成员函数。 |
| remove(arg) | 如果从集合中移除了 arg,则返回 True 如果集合未被修改,则返回 False。 |
| iterator() | 返回对象元素的迭代器。这是从 Iterable 接口继承的。使用列表、集合和地图。 |
集合遍历
到目前为止,我们已经知道如何使用基本集合。我们知道如何创建它们,以及如何在其中添加和删除项目。我们有效处理集合需要的另一项技能是循环遍历集合的能力。为此,让我们回到图 6-1 并回忆集合框架的继承结构。
在图 6-1 中,你会注意到集合继承了可迭代接口。一个可迭代的定义了一些可以被迭代或跳过的东西。当一个类继承了一个 Iterable 接口时,不管是直接的还是间接的,这意味着我们可以从中取出一个迭代器,并一个接一个地遍历它的元素。在每一步中,我们还可以提取每个元素的值——这取决于您的程序逻辑,您想用这些值做什么;例如,您可以转换它们,在算术运算中使用它们,或者将它们保存在存储器中。
我们可以使用各种方法来遍历集合中的元素。如果你喜欢,我们可以使用可靠的而和用于循环,但是使用更现代的用于每个更习惯——而且有点流行。清单 6-12 展示了如何使用 while 和 for 循环遍历列表。
val basket = listOf("apple", "banana", "orange")
var iter = basket.iterator()
while (iter.hasNext()) {
println(iter.next())
}
for (i in basket) {
println(i)
}
Listing 6-12Using while and for Loops for Collections
清单 6-12 可能与您在 Java 中处理集合的方式很相似,所以看起来应该很熟悉。清单 6-13 显示了使用 forEach 函数时的等效代码。
| -什么 | `forEach`的 lambda 表达式有一个隐式的`it`参数。`it`参数是当前元素的值。这个语句的意思是,对于*水果*中的每一项,执行 lambda 中的操作,在我们的例子中,lambda 就是`println().` | | ➋ | 同样的事情也适用于*集合* | | ➌ | 同样的事情也适用于*地图* | | -你好 | 这是上面第 3 条的变体,但是这一条允许我们分别处理*键*和*值*。 |fruits.forEach { println(it) } ➊
nums.forEach { println(it) } ➋
// for maps
dict.forEach { println(it) } ➌
dict.forEach { t, u -> println("$t | $u") } ➍
Listing 6-13Using forEach
过滤和映射
为了有效地使用集合,过滤和映射是您需要掌握的基本技能的一部分。过滤允许我们有选择地使用集合中的元素。它缩小了范围。它基本上返回原始集合的子集。另一方面,映射允许我们转换元素或集合本身。
比方说,我们有一个数字列表——确切地说是整数,就像这样
val ints = (1..100).toList()
变量ints包含从 1 到 100 的整数列表,增量为 1。如果我们只想处理这个列表中的偶数,我们可以这样做:( 1)创建一个新列表;(2)迭代整数列表并对偶数执行模校验;然后(3)如果正在处理的当前元素是偶数,我们将其添加到新列表中。这些代码可能看起来像清单 6-14 。
val evenInts2 = mutableListOf<Int>()
for (i in ints) {
if (i % 2 == 0) {
evenInts2.add(i)
}
}
Listing 6-14Using a for Loop to Sieve Out the Even Numbers
清单 6-14 可以被称为过滤事物的“必要”方式。没什么不好——就是有点啰嗦,仅此而已。但是它可读性很好,即使对于刚开始编程的人也是如此。然而,在 Kotlin 中,更惯用的缩小集合的方法是使用过滤器函数。如果我们用过滤器来做这件事,它会像这样
val evenInts = ints.filter { it % 2 == 0 }
我甚至不再给它贴上列表标签,因为这是不必要的——它只是一行。filter 函数是集合库中的标准函数。你已经知道花括号里的表达式是λ。然而,对于过滤器,更恰当的术语是 lambda 谓词。lambda 谓词也是一个函数文字,但是其中的表达式必须产生一个布尔值。
回到我们的例子,过滤器是针对一个集合调用的——例如,一个 int 列表。过滤操作的结果是一个较小的列表或子集。通过遍历每个元素并根据 lambda 谓词中指定的条件对它们进行测试,对列表进行了精简。任何通过谓词测试的项目都将包含在结果子集中。
让我们继续我们的例子,并与我们的偶数整数较小的列表。假设我们现在想要的是对偶整数列表中的每个元素求平方。这要求我们操作并转换列表中的每个元素,然后返回一个包含已转换元素的新列表。如果我们使用 for 循环来解决这个问题,它将看起来像清单 6-15 。
val squaredInts2 = mutableListOf<Int>()
for (i in evenInts2) {
squaredInts2.add( i * i )
}
println(squaredInts2)
Listing 6-15Generate a List of Squared Ints Using a for Loop
或者我们可以在集合中使用 forEach 函数来解决这个问题。它看起来就像清单 6-16 。
val squaredInts2 = mutableListOf<Int>()
evenInts2.forEach { squaredInts2.add(it * it) }
Listing 6-16Generate a List of Squared Ints Using forEach
这实际上看起来更好,但是转换集合中的元素实际上是 map 函数的范围。所以,让我们用地图来解决整数平方的问题。清单 6-17 显示了代码。
val squaredInts = evenInts.map { it * it}
println("Sum of squares of even nos <= 100 is ${squaredInts.sum()}")
Listing 6-17Using the Map Function
清单 6-17 中唯一相关的语句是第一条。第二条语句只打印出从 1 到 100 的所有偶数的总和。另外,第二行展示了集合框架中的另一个内置函数,sum()函数。它的作用非常明显——它总结了集合中的值。
章节总结
-
当处理一组值时,我们可以使用数组或集合。对于简单的数据结构使用数组,但是当您需要动态调整数据组的大小时,或者您需要对它施加更多的约束时,例如唯一性约束,集合可能会更好。
-
Kotlin 中的数组不同于 Java 中的数组;他们不享受特殊待遇。在 Kotlin 中,数组只是类。
-
如果您觉得需要使用数组而不需要装箱和拆箱的开销,Kotlin 为数组提供了专门的类。
-
Kotlin 集合与 Java 集合非常相似,但是每个 Java 集合类都以两种方式表示:可变的和不可变的。
-
Kotlin 集合具有像 filter、map 和 sum 这样的内置函数,这使得使用集合变得更加容易。
在下一章,我们将探索 Kotlin 如何处理泛型。
七、泛型
我们将介绍的内容:
-
使用泛型
-
限制
-
变化
-
具体化的泛型
啊,仿制药。这个迂回的话题甚至出现在初学者的文章中。这个题目让许多初学者犯了错误,因为它很难理解,解释起来更难。但是我们需要处理它,因为没有泛型,就很难使用集合。
在很大程度上,Kotlin 泛型的工作方式与 Java 泛型相同;但是他们有一些不同。在这一章中,我们将看看如何使用泛型,以及 Kotlin 的泛型与 Java 的有多相似(或不同)——另外,不要太担心泛型的复杂性,在这一章中我们不会做任何疯狂的事情。
为什么是仿制药
泛型是在 2004 年左右来到 Java 的,当时 JDK 1.5 发布了。在泛型出现之前,你可以编写清单 7-1 中的代码。
List v = new ArrayList();
v.add("test");
Integer i = (Integer) v.get(0); // Run time error
Listing 7-1Using a Raw List, Java
你可能会说,“但是你为什么要做如此粗心和明显愚蠢的事情呢?从清单 7-1 中可以清楚地看到,我们在 ArrayList 中放了一个字符串;所以,不要做任何不适合字符串的操作。问题解决了。”这可能并不总是那么容易。示例代码显然是精心设计的,现在很容易发现错误,但是如果您正在做一些重要的事情,列表包含的内容可能并不总是很明显。
关于示例代码,需要注意的另一点——实际上也是最重要的一点——是代码可以顺利编译。你只能在运行时发现错误。编译器没有办法警告我们将要做的事情不是类型安全的。这是泛型试图解决的主要问题:类型安全。
回到清单 7-1 ,我们知道变量 v 是一个列表。如果我们知道列表中存储了哪些内容,那会更有用。正是在这些情况下,泛型是有帮助的。它允许我们说类似“这是一个字符串列表”或“这是一个整型数列表”这样的话——编译器事先就知道;因为编译器知道这一点,所以它可以防止我们做一些不恰当的事情,比如把一个字符串转换成 Int 或者用字符串做减法等等。清单 7-2 展示了如何在我们的代码中使用泛型。
List<String> v = new ArrayList<String>();
v.add("test");
Integer i = v.get(0); // (type error) compilation-time error
Listing 7-2List, with Generics: Java
现在编译器已经预知了列表中有哪些内容,它可以防止我们在列表上进行不受支持的操作。
清单 7-1 和 7-2 中的代码在 Java 中都是有效的,这意味着您可以选择不在集合中使用泛型(原始类型)。Java 必须这样做,因为它需要保持与 JDK 5 之前编写的代码的向后兼容性。另一方面,Kotlin 不需要维护任何与遗留代码的兼容性。所以,在 Kotlin 中,你不能使用原始类型。所有 Kotlin 集合都需要类型参数。你总是必须使用泛型。
术语
泛型编程是 Kotlin 的一个语言特性。有了它,我们可以定义接受类型参数的类、函数和接口。参数化类型允许我们重用算法来处理不同的类型;这确实是参数多态性的一种形式。图 7-1 显示了类型参数和类型实参在泛型类中的位置。
| -什么 | **尖括号**。当一个类的名字后面有尖括号时,它被称为泛型类(也有泛型函数和接口)。 | | ➋ | **类型参数**。它定义了这个类可以处理的数据类型。您可以将其视为类实现的一部分。现在,我们使用字母 **T** 来表示类型参数,但是这是任意的。你可以随便叫它什么,可以是任何字母,也可以是字母的组合;如果我是你,我会坚持使用 **T** ,因为这是许多开发人员遵循的惯例。你可以在类内的整个代码中使用 **T** ,就像它是一个真实的类型一样。这是一个类型的占位符*。在本例中,我们使用 *T* 作为**项目**属性的类型,并作为 **getLeaf** 函数的返回类型。* | | ➌ | **类型自变量**。为了使用泛型类,你必须提供**类型参数**。现在我们正在创建节点类的一个实例, **T** 将被*类型参数*所替代(在本图中为 *Int* 和 *String* )。 |图 7-1
类型变量和类型参数
在前面的章节中你已经看到了泛型代码,具体在章 6 (集合)。Kotlin 的所有集合类都使用泛型。我之前说过,Kotlin 没有原始类型。不可能只创建一个列表—你必须明确它是哪种列表(例如,一个“字符串列表”列表<字符串> 或“整数列表”列表< Int >)。
在函数中使用泛型
若要创建泛型函数,请在函数名之前声明类型参数。然后,您可以在函数中的任何地方使用类型参数。
| -什么 | 类型参数 **T** 用作函数参数 **arg 的类型。** | | ➋ | 我们只是返回串接在字符串中的**参数**。 |fun <T> fooBar(arg:T) : String { ➊
return "Heya $arg" // ➋
}
println(fooBar("Joe")) // prints "Heya Joe"
println(fooBar(10)) // prints "Heya 10"
这很容易理解。我们只是在一个地方使用了 param 类型,不管 param 是什么类型,函数都返回一个字符串。另一个例子,见清单 7-4 。
| -什么 | 在这个例子中,我们使用*类型参数*作为**arg**(fooBar 函数的参数)的类型以及函数本身的返回类型。 | | ➋ | 我们正在测试 **arg** 是否是字符串类型。如果是的话,我们也有效地把它转换成一个字符串;聪明的演员,记得吗? | | ➌ | 我们将返回“Hello world”,并(强制)将其转换为 **T** 。我们不能在这里返回“字符串”类型,因为 fooBar 期望返回类型 **T** 给它的调用者,而不是字符串。 |fun <T> fooBar(arg:T) : T { ➊
var retval:T = 0 as T
when (arg) {
is String -> { ➋
retval = "Hello world" as T ➌
}
is Number -> {
retval = 100 as T
}
}
return retval
}
Listing 7-4A More Complex fooBar Function
您还可以将泛型用于扩展函数。如果你正在创建一个处理列表的函数,你可能希望它能处理任何类型的列表,而不仅仅是字符串或整型。清单 7-5 展示了如何在扩展函数中使用泛型。
| -什么 | 可以使用*接收方* **(列表< T > )** 中的类型参数和扩展函数的返回类型。 | | ➋ | 我们不要做任何花哨的事情;让我们返回一个给定索引的项。在生产代码中,您可能希望在返回索引之前检查它是否存在。如果您忘记了这个指的是什么,它指的是列表本身(它是 receiver 对象)。 | | ➌ | 我们的扩展函数处理字符串列表。 | | -你好 | 它也适用于一个整型列表。 | | ➎ | 这个有点花里胡哨,但是最后还是返回了一个列表,所以我们的扩展函数应该还是可以的。 |fun <T> List<T>.getIt(index:Int): T { ➊
return this[index] ➋
}
fun main(args: Array<String>) {
val lfruits = listOf("Apples", "Bananas", "Oranges") ➌
val lnumbers = listOf(1,3,5) ➍
val lnumlist = (1..100).toList().filter { it % 5 == 0 } ➎
println(lnumlist.getIt(5))
println(lfruits.getIt(1))
}
Listing 7-3fooBar, Generic Function
在类中使用泛型
像在 Java 中一样,您可以通过在类名后面放一对尖括号并将类型参数放在尖括号之间来创建 Kotlin 泛型类。之后,您可以在类中的任何地方使用类型参数。清单 7-6 展示、注释并解释了如何编写一个泛型类。
| -什么 | 类型参数声明在类名**节点之后。我们使用 **T** 作为参数**项的类型。**** | | ➋ | 我们还使用 **T** 作为函数 **getLeaf 的返回值。** | | ➌ | 我们向 Node 的构造函数传递一个 Int。我们可以详细一点,指定 Int 作为类型参数,**节点< Int >。** | | -你好 | 节点可以推断出类型参数是什么,所以我们可以跳过尖括号。这样写也没问题。 | | ➎ | 因为它是一个泛型类,所以它也可以处理字符串。 |class Node<T>(val item:T) { ➊
fun getLeaf() : T { ➋
return item
}
}
fun main(args: Array<String>) {
val m = Node<Int>(1) ➌
val n = Node(1) ➍
val o = Node<String>("World") ➎
}
Listing 7-6Writing a Generic Class
您可以约束或限制可用作类或函数的类型参数的类型。目前,我们的节点类应该可以处理任何类型,因为类型参数的默认父类(或上界)如果不指定约束,是 any 吗?(可空类型,所以包含问号)。
当您为类型参数指定上限约束时,这将限制可用于实例化该类的类型。例如,如果我们希望节点类只接受整型、双精度型或浮点型,我们可以使用 Number 作为上限约束。代码示例见清单 7-7 。
| -什么 | 现在我们对类型参数 **<** **T:编号** **>** 进行约束。我们可以用来实例化这个类的唯一类型必须是数量为**的**的子类型。 | | ➋ | Int 是数字的子类型,所以没问题。 | | ➌ | 浮动也可以。 | | -你好 | 这已经行不通了。IntelliJ 会告诉你“类型参数不在界限内”。 | | ➎ | 这对 Double 仍然有效,因为它是 number 的子类。 |class Node<T:Number>(val item:T) { ➊
fun getLeaf() : T {
return item
}
}
fun main(args: Array<String>) {
val m = Node<Int>(1) ➋
val n = Node(1.0F) ➌
val o = Node<String>("World") ➍
val p = Node(1.0) ➎
}
Listing 7-7Node Class, with Constraint
如果除了类型参数的可空性之外没有任何限制,可以简单地使用 Any 作为类型参数的上限;参见清单 7-8 。
class Node<T:Any>(val item:T) {
fun getLeaf() : T {
return item
}
}
Listing 7-5Generics in Extension Function
Listing 7-8Prevent Null Type Arguments
变化
我们需要回顾一些面向对象编程(OOP)的基础知识,为讨论差异做准备。希望我们能唤起你的记忆,记住一些 OOP 的基本原则。
OOP 是开发者的福音;正因为如此,我们可以编写类似清单 7-9 的代码。
val a:Int = 1
val b:Number = a
println("b:$b is of type ${b.javaClass.name}")
Listing 7-9Assign an Int Variable to Number Type
我们也可以编写类似清单 7-10 的函数。
foo(1)
foo(100F)
foo(120)
fun foo(arg:Number) {
println(arg)
}
Listing 7-10Function That Accepts a Number Type
清单 7-9 和 7-10 中的代码是可能的,因为利斯科夫替代原理 (LSP)。这是 OOP 中最重要的部分之一——在需要父类型的地方,你可以用子类型来代替它。我们使用更一般化的类型(如清单 7-10 中的号)的原因是,将来如果需要,我们可以编写一个子类型的实现,并插入到现有的工作代码中。这是开闭原则的精髓(声明一个类必须对扩展开放,但对修改关闭)。
注意
利斯科夫替代原理和开闭原理是立体设计原理的一部分。这是 OOP 中最流行的设计原则之一。实线代表(S)单一责任(O)开/闭(L)利斯科夫替代(I)界面分离和(D)依赖性倒置
再举一个例子,见清单 7-11 。
| -什么 | employee_1 的类型是 **Employee** ,我们给它分配了一个**程序员**对象。这没关系。程序员是员工的一个*子类型*。 | | ➋ | 这里同样的事情,类型**测试者**是**雇员**的一个子类型,所以分配应该没问题。 |open class Employee(val name:String) {
override fun toString(): String {
return name
}
}
class Programmer(name:String) : Employee(name) {}
class Tester(name:String) : Employee(name) {}
fun main(args: Array<String>) {
val employee_1 :Employee = Programmer("Ted") ➊
val employee_2 :Employee = Tester("Steph") ➋
println(employee_1)
println(employee_2)
}
Listing 7-11Employee, Programmer, and Tester
毫无疑问,利斯科夫原理仍然在起作用。即使您将程序员和雇员放在一个列表中,类型关系也会保留。
val list_1: List<Programmer> = listOf(Programmer("James"))
val list_2: List<Employee> = list_1
Listing 7-12Employee and Programmer in Lists
目前为止,一切顺利。下一个代码是什么?你认为这行得通吗?(参见清单 7-13 。)
class Group<T>
val a:Group<Employee> = Group<Programmer>()
Listing 7-13Group of Employees and Programmers
这是泛型最棘手的部分之一。清单 7-13 ,按照目前的情况,是行不通的。即使我们知道程序员是雇员的子类型,并且我们所做的是类型安全的,编译器也不会让我们通过,因为代码中的第二条语句有问题。
当你使用泛型时,永远记住默认情况下组<雇员>,组<程序员>,和组<测试员> 没有任何类型关系——即使我们知道测试员和程序员是雇员的子类型。默认情况下,类组中的类型参数是不变量。为了使第二条语句(在清单 7-13 中)起作用,组< T > 必须是协变的。我们将在清单 7-14 中解决。
| -什么 | 当您将 **out** 关键字放在类型参数之前时,这使得类型参数*协变。* | | ➋ | 这段代码之所以有效,是因为,**组<程序员>** 是现在*组**员工<>**的一个子类型,这要归功于 **out** 关键字。* |class Group<out T> ➊
open class Employee(val name:String) {
override fun toString(): String {
return name
}
}
class Programmer(name:String) : Employee(name) {}
class Tester(name:String) : Employee(name) {}
fun main(args: Array<String>) {
val a:Group<Employee> = Group<Programmer>() ➋
}
Listing 7-14Classes Employee, Programmer, Tester, and Group
从这些例子中,我们现在可以归纳出,如果 type Programmer 是 Employee 的一个子类型,并且组是协变的,那么组<程序员>是组< Employee >的一个子类型。此外,我们可以归纳出,如果对于给定的类型雇员和程序员,组<程序员> 不是组<雇员>的子类型,那么泛型类就像 Group 一样,在类型参数上是不变的。****
现在我们已经处理了不变量和协变量。我们需要处理的最后一个术语是逆变。如果组< T > 的类型参数是逆变的,对于相同的给定类型雇员和程序员,那么我们可以说组<雇员> 是组<程序员> 的一个子类型——与共变正好相反。
| -什么 | 关键字中的**使类型参数**<****T****>**逆变,意思是;** | | ➋ | 类型**组<雇员>** 现在是**组<程序员>的子类型。** |class Group<in T> ➊
open class Employee(val name:String) {
override fun toString(): String {
return name
}
}
class Programmer(name:String) : Employee(name) {}
class Tester(name:String) : Employee(name) {}
fun main(args: Array<String>) {
val a:Group<Programmer> = Group<Employee>() ➋
}
Listing 7-15Use the in Keyword for Contravariance
子类与子类型
好吧。我怀疑你在过去 10 分钟里读到的东西让你觉得很苦涩。怎么会出现程序员是员工的子类型,列表<程序员>是列表<员工>、的子类型,而组<程序员>** 不是**组<员工>的子类型?让我们通过回到类、类型、子类和子类型的概念来尝试回答这个问题。****
我们认为类在某种程度上是类型的同义词,一般来说是这样的——至少对于非泛型类来说是这样,在大多数情况下也是这样。我们知道一个类至少有一种类型——它与类本身的类型相同。回到你第一次学习 Java 类的时候——你的老师、导师或者可能是你最喜欢的作者一定是这样定义一个对象的类型的:“它是它所有公共行为的总和,或者被称为对象的方法或契约”或者类似的东西。我们姑且说它是对象拥有的行为集合。
回到“一个类至少有一种类型”,它可以有更多类型。只看图 7-2 。
图 7-2
一堆类和接口的层次结构
从图 7-2 中,我们可以说:
-
任何都在类图表的顶部;类 Any 相当于 java.lang.Object
-
员工是 Any 的子类。Employee 有两种类型:一种是从 Any 继承的,另一种是它自己——因为 Employee 类可以定义自己的一组行为(方法),所以可以算作一种类型。
-
程序员是 **Employee 的子类,**是 Any 的子类,也就是说程序员有三种类型:一种来自 Any,一种来自 Employee,还有一种来自程序员类本身。
-
号是 Any 的子类型,但它也实现了compatible接口。因此,Number 有三种类型:一种来自 Any,另一种来自自身,还有一种来自可比接口。我们可以说,数字是任何的一个子类型,也是可比的一个子类型——无论你期望可比做什么,数字都能做;任何人能做的事,数字也能做。这是基本的 OOP。
-
字符串类有四种类型:一种来自 Any ,另一种来自 Comparable ,另一种来自 CharSequence,,最后来自自己的类。
根据陈述和图表,可以互换使用子类和子类型。两者没有太大区别。当我们开始考虑可空类型时,它们的区别将变得明显。
可空类型就是一个子类不同于子类型的例子。见图 7-3 。
图 7-3
可空类型
当你在一个类型的名字后面加上一个问号时,它就变成了该类型的可空版本。在 Kotlin 中,我们可以从同一个类中创建两种类型:可空版本和不可空版本。我们真的不能说程序员是程序员的子类?因为程序员只有一个类定义,而程序员(不可空版本)是程序员的子类型?(可空的那个)。同样, Any 是 **Any 的子类型?但是有吗?**不是 Any 的子类型——反方向不成立。
写没问题
var j:Programmer? = Programmer("Ted") // assign non-null to nullable Programmer
j = null. // then we assign a null to j
但是不可以写
var i:Programmer = j // assign j (which is null) to non-nullable Programmer
现在我们来看泛型。图 7-4 应该有助于我们阐明我们需要解决的下一组概念。
图 7-4
泛型类型
我们知道第一个关系雇员是程序员的父类型。我们还知道列表<员工>会接受列表<程序员>;我们在清单 7-12 中对此进行了测试——您可能不太清楚它为什么会工作,所以在我们处理完第三组盒子后,我将回到这一点。****
现在,给定密码
class Group<T>
val a:Group<Employee> = Group<Programmer>() // not sure
为什么我们不能可靠地回答“难道集团<员工> 是集团<程序员> 的超类型?”
这是因为虽然组是一个类,但是组<雇员> 不是,并且推而广之,组<程序员> 不是组<雇员> 的子类——如果你现在想列出<雇员>和<程序员>,请停止。我说过我会回到那个话题。先和团队<员工>和团队<程序员>在一起。表 7-1 应该可以帮助我们总结其中的一些东西。
表 7-1
类别与类型
| |是类
|
是 a 型
|
| --- | --- | --- |
| Programmer | 是 | 是 |
| Programmer? | 不 | 是 |
| List | 是 | 是 |
| List<Programmer> | 不 | 是 |
| Group | 是 | 是 |
| Group<Programmer> | 不 | 是 |
现在我们可以确定组与组没有类型关系,即使类 Employee 与程序员有类型关系。默认情况下,组中的类型参数是不变量(没有类型关系)。为了改变< T >的方差,你需要使用 out (使其协变)或 in**(使其逆变)关键字。**
所以,如果我们想让组成为组的子类型,我们需要这样写组类:
class Group<out T>
val a:Group<Employee> = Group<Programmer>() // this is ok now
现在我们可以循环回到列表和列表问题。为什么以及如何工作?为什么写这个可以?
var m:List<Employee> = listOf(Programmer("Ted"))
简单的答案在于 List 接口的定义,为了方便你,我复制了 List7-16 中 List 接口的源代码;我把所有的评论都删了。
| -什么 | 类型参数是协变的。List 在类型参数 **E.** 前使用 **out** 关键字 |public interface List<out E> : Collection<E> { ➊
override val size: Int
override fun isEmpty(): Boolean
override fun contains(element: @UnsafeVariance E): Boolean
override fun iterator(): Iterator<E>
override fun containsAll(elements: Collection<@UnsafeVariance E>): Boolean
public operator fun get(index: Int): E
public fun indexOf(element: @UnsafeVariance E): Int
public fun lastIndexOf(element: @UnsafeVariance E): Int
public fun listIterator(): ListIterator<E>
public fun listIterator(index: Int): ListIterator<E>
public fun subList(fromIndex: Int, toIndex: Int): List<E>
}
Listing 7-16Excerpt of the List Interface Source Code
之所以把 List 赋给 List 没问题,是因为 List 上的类型参数是协变。因此,如果类型员工是程序员的超类型,并且列表< E > 是协变的,那么列表<程序员> 是列表<员工>的子类型。
现在我们对类型和子类型有了更好的理解,就像在昆汀·塔伦蒂诺的电影中一样,我希望你们回到 20 分钟前,再读一遍关于“方差”的部分。
具体化的泛型
我们先来处理一下“具体化”的含义。它的意思是“让事情变得真实”,我们在同一个语句中使用 rify 和 generics 的原因是因为 Java 的类型擦除。
类型擦除的意思和你想的完全一样。Java 和 Kotlin 在运行时清除泛型类型信息。这有很好的理由,但不幸的是,我们不会讨论语言设计如此的原因——但我们会讨论它的影响。因为类型擦除,你不能执行任何反射活动,也不能对一个类型做任何运行时检查,如果它是泛型的话。参见清单 7-17 中的示例。
| -什么 | 这不会编译。错误是"*无法检查擦除类型的实例。* |fun checkInfo(items:List<Any>) {
if(items is List<String>) { ➊
println("item is a list of Strings")
}
}
}
Listing 7-17Check for Type at Runtime
在运行时, is 关键字对泛型类型不起作用;由于类型擦除,智能转换中断。如果你对列表的运行时类型有一些信心,你可以做一个推测性的决定,使用作为关键字进行转换,就像这样:
val i = item as List<String>
编译器会让你通过,但这是一件危险的事情。让我们再考虑一个例子,我们可以建立一个更强的案例来解释为什么我们需要在运行时保留类型信息。
假设我有一个对象列表,程序员和测试员对象。我想创建一个函数,可以传递一个类型参数,并使用该类型参数过滤列表。我希望函数返回过滤后的列表。清单 7-18 向我们展示了如何做到这一点的代码示例——由于类型擦除问题,该代码示例当然不会工作,但是先通读一遍,我们稍后会修复它。
| -什么 | 让我们创建一个程序员和测试人员对象的列表。 | | ➋ | 让我们调用一个名为 **typeOf** 的扩展函数(列表类型的)。我们将**程序员**作为类型参数传递,这意味着我们希望这个函数只返回程序员对象的列表。 | | ➌ | 我们只是遍历列表中的每一项。我们打印了 *name* 属性和 Java simpleName。 | | -你好 | 现在我们来看扩展函数的定义。我们正在定义一个类型参数,我们使用 **T** 作为这个函数的返回类型。此外,我们希望这个函数可以处理任何类型的列表——这就是语法。 | | ➎ | 让我们定义一个可变列表;我们将用它来保存过滤后的列表。 | | ➏ | 这是无法编译的代码,因为我们不知道在运行时这是什么类型的列表。像 Java 一样,Kotlin 删除类型信息。但是让我们假设 Kotlin 确实保留了泛型类型信息;如果是这样的话,那么这段代码是没问题的。 | | -好的 | 如果条件没问题,我们把当前项加到返回值上。 | | -好的 | 最后,让我们返回过滤后的列表。 |fun main(args: Array<String>) {
val mlist = listOf(Programmer("Ted"), Tester("Steph")) ➊
val mprogs = mlist.typeOf<Programmer>() ➋
mprogs.forEach { ➌
println("${it.toString()} : ${it.javaClass.simpleName}")
}
}
fun <T> List<*>.typeOf() : List<T> { ➍
val retlist = mutableListOf<T>() ➎
this.forEach {
if (it is T) { ➏
retlist.add(it) ➐
}
}
return retlist ➑
}
open class Employee(val name:String) {
override fun toString(): String {
return name
}
}
class Programmer(name:String) : Employee(name) {}
class Tester(name:String) : Employee(name) {}
Listing 7-18Filtering a List Using a Type Parameter
如果 List.typeOf 能够在运行时记住它是什么类型的列表,列表 7-18 将会完美地工作。为了解决这个问题,我们将使用内联和具体化关键字。清单 7-19 向我们展示了如何做到这一点。
| -什么 | 使函数**内联**并在类型参数前使用**具体化的**关键字。这样做之后,函数可以在运行时保留类型信息。 |inline fun <reified T> List<*>.typeOf() : List<T> { ➊
val retlist = mutableListOf<T>()
this.forEach {
if (it is T) {
retlist.add(it)
}
}
return retlist
}
Listing 7-19How to Use Reified and Inline in a Function
您只能具体化内联函数。当您内联一个函数时,编译器会用它的实际字节码(不仅仅是函数的地址)替换对该函数的每个调用。这就像在调用函数的地方复制并粘贴函数的字节码一样。这就是编译器知道您用作类型参数的确切类型的方式。因此,编译器可以为用作类型参数的特定类生成字节码。
所以,如果我们打这样一个电话:
val mprogs = mlist.typeOf<Programmer>()
如果我们对编译器将为我们的具体化函数生成的字节码进行逆向工程,它可能看起来像清单 7-20 。
val retlist = mutableListOf<Programmer>()
this.forEach {
if (it is Programmer) {
retlist.add(it)
}
}
return retlist
Listing 7-20Reified Function
如你所见,我们不再测试是否是 T——我们测试是否是程序员。生成的字节码引用了特定的类(程序员),而不是类型参数(T)。这就是具体化函数不受类型擦除影响的原因。这当然会增加运行时程序的大小,所以要谨慎使用。清单 7-21 显示了具体化示例的完整和修改后的代码。
fun main(args: Array<String>) {
val mlist = listOf(Programmer("Ted"), Tester("Steph"))
val mprogs = mlist.typeOf<Programmer>()
mprogs.forEach {
println("${it.toString()} : ${it.javaClass.simpleName}")
}
}
inline fun <reified T> List<*>.typeOf() : List<T> {
val retlist = mutableListOf<T>()
this.forEach {
if (it is T) {
retlist.add(it)
}
}
return retlist
}
open class Employee(val name:String) {
override fun toString(): String {
return name
}
}
class Programmer(name:String) : Employee(name) {}
class Tester(name:String) : Employee(name) {}
Listing 7-21Filtering a List Using a Type Parameter
章节总结
-
泛型编程让我们可以重用算法。
-
Kotlin 中的所有集合都使用泛型。
-
Kotlin 没有原始类型,像 Java。
-
有三个方差你需要知道:(1)不变性;(2)协方差;(3)逆变。
-
Kotlin 和 Java 一样,在运行时删除泛型类型信息;但是如果你想保留类型信息,内联你的函数并使用具体化的关键字。
这是本书 Kotlin 部分的结尾。在下一章,我们将开始讨论 Android 编程。我们将通过设置 Android Studio 开发环境来解决这个问题。