Kotlin 学习
编程之本:变量和函数
变量
Kotlin 中定义一个变量,只允许在变量前声明两种关键字:val和var。
val(value 的简写)用来声明一个不可变的变量,这种变量在初始赋值之后就再也不能重新赋值,对应 Java 中的final变量。
var(variable 的简写)用来声明一个可变的变量,这种变量在初始赋值之后仍然可以再被重新赋值,对应 Java 中的非final变量。
如果你有 Java 编程经验的话,可能会在这里产生疑惑,仅仅使用val或者var来声明一个变量,那么编译器怎么能知道这个变量是什么类型呢?这也是 Kotlin 比较有特色的一点,它拥有出色的类型推导机制。
fun main() {
val a = 10
println("a = " + a)
}- 注意,Kotlin 每一行代码的结尾是不用加分号的,如果你写惯了 Java 的话,在这里得先熟悉一下。*
在上述代码中,我们使用val关键字定义了一个变量a,并将它赋值为 10,这里a就会被自动推导成整型变量。因为既然你要把一个整数赋值给a,那么a就只能是整型变量,而如果你要把一个字符串赋值给a的话,那么a就会被自动推导成字符串变量,这就是 Kotlin 的类型推导机制。
但是 Kotlin 的类型推导机制并不总是可以正常工作的,比如说如果我们对一个变量延迟赋值的话,Kotlin 就无法自动推导它的类型了。这时候就需要显式地声明变量类型才行,Kotlin 提供了对这一功能的支持,语法如下所示:
val a: Int = 10可以看到,我们显式地声明了变量a为Int类型,此时 Kotlin 就不会再尝试进行类型推导了。如果现在你尝试将一个字符串赋值给a,那么编译器就会抛出类型不匹配的异常。
如果你学过 Java 并且足够细心的话,你可能发现了 Kotlin 中Int的首字母是大写的,而 Java 中int的首字母是小写的。不要小看这一个字母大小写的差距,这表示 Kotlin 完全抛弃了 Java 中的基本数据类型,全部使用了对象数据类型。在 Java 中int是关键字,而在 Kotlin 中Int变成了一个类,它拥有自己的方法和继承结构。
接下来我们尝试对变量a进行一些数学运算,比如说让a变大10倍,可能你会很自然地写出如下代码:
fun main() {
val a: Int = 10
a = a * 10
println("a = " + a)
}很遗憾,如果你这样写的话,编译器一定会提示一个错误:Val cannot be reassigned 。这是在告诉我们,使用val关键字声明的变量无法被重新赋值。出现这个问题的原因是我们在一开始定义a的时候将它赋值成了 10,然后又在下一行让它变大 10 倍,这个时候就是对a进行重新赋值了,因而编译器也就报错了。
解决这个问题的办法也很简单,前面已经提到了,val关键字用来声明一个不可变的变量,而var关键字用来声明一个可变的变量,所以这里只需要把val改成var即可,如下所示:
fun main() {
var a: Int = 10
a = a * 10
println("a = " + a)
}那么我们应该什么时候使用val,什么时候使用var呢?这里我告诉你一个小诀窍,就是永远优先使用val来声明一个变量,而当val没有办法满足你的需求时再使用var。这样设计出来的程序会更加健壮,也更加符合高质量的编码规范。
函数
其他编程语言一样,Kotlin 也允许我们自由地定义函数,语法规则如下:
fun methodName(param1: Int, param2: Int): Int {
return 0
}下面我来解释一下上述的语法规则,首先fun(function 的简写)是定义函数的关键字,无论你定义什么函数,都一定要使用fun来声明。
紧跟在fun后面的是函数名,这个就没有什么要求了,你可以根据自己的喜好起任何名字,但是良好的编程习惯是函数名最好要有一定的意义,能表达这个函数的作用是什么。
函数名后面紧跟着一对括号,里面可以声明该函数接收什么参数,参数的数量可以是任意多个,例如上述示例就表示该函数接收两个Int类型的参数。参数的声明格式是“参数名: 参数类型”,其中参数名也是可以随便定义的,这一点和函数名类似。如果不想接收任何参数,那么写一对空括号就可以了。
参数括号后面的那部分是可选的,用于声明该函数会返回什么类型的数据,上述示例就表示该函数会返回一个Int类型的数据。如果你的函数不需要返回任何数据,这部分可以直接不写。
最后两个大括号之间的内容就是函数体了,我们可以在这里编写一个函数的具体逻辑。由于上述示例中声明了该函数会返回一个Int类型的数据,因此在函数体中我们简单地返回了一个 0。
这就是定义一个函数最标准的方式了,虽然 Kotlin 中还有许多其他修饰函数的关键字,但是只要掌握了上述函数定义规则,你就已经能应对 80% 以上的编程场景了,至于其他的关键字,我们会在后面慢慢学习。
接下来我们尝试按照上述定义函数的语法规则来定义一个有意义的函数,如下所示:
fun largerNumber(num1: Int, num2: Int): Int {
return max(num1, num2)
}这里定义了一个名叫largerNumber()的函数,该函数的作用很简单,接收两个整型参数,然后总是返回两个参数中更大的那个数。
这就是 Kotlin 中最基本也是最常用的函数用法,虽然这里我们实现的largerNumber()函数很简单,但是掌握了函数的定义规则之后,你想实现多么复杂的函数都是可以的。
在本小节的最后,我们再来学习一个 Kotlin 函数的语法糖,这个语法糖在以后的开发中会起到相当重要的作用。
当一个函数中只有一行代码时,Kotlin 允许我们不必编写函数体,可以直接将唯一的一行代码写在函数定义的尾部,中间用等号连接即可。比如我们刚才编写的largerNumber()函数就只有一行代码,于是可以将代码简化成如下形式:
fun largerNumber(num1: Int, num2: Int): Int = max(num1, num2)使用这种语法,return关键字也可以省略了,等号足以表达返回值的意思。另外,还记得Kotlin 出色的类型推导机制吗?在这里它也可以发挥重要的作用。由于max()函数返回的是一个Int值,而我们在largerNumber()函数的尾部又使用等号连接了max()函数,因此 Kotlin 可以推导出largerNumber()函数返回的必然也是一个Int值,这样就不用再显式地声明返回值类型了,代码可以进一步简化成如下形式:
fun largerNumber(num1: Int, num2: Int) = max(num1, num2)程序的逻辑控制
if 条件语句
Kotlin 中的条件语句主要有两种实现方式:if和when。
首先学习if,Kotlin 中的if语句和 Java 中的if语句几乎没有任何区别。
还是以上一节中的largerNumber()函数为例,之前我们借助了 Kotlin 内置的max()函数来实现返回两个参数中的较大值,但其实这是没有必要的,因为使用if判断同样可以轻松地实现这个功能。将largerNumber()函数的实现改成如下写法:
fun largerNumber(num1: Int, num2: Int): Int {
var value = 0
if (num1 > num2) {
value = num1
} else {
value = num2
}
return value
}这段代码相信不需要我多做解释,任何有编程基础的人都应该能看得懂。但是有一点我还是得说明一下,这里使用了var关键字来声明value这个变量,这是因为初始化的时候我们先将value赋值为 0,然后再将它赋值为两个参数中更大的那个数,这就涉及了重新赋值,因此必须用var关键字才行。
到目前为止,Kotlin 中的if用法和 Java 中是完全一样的。但注意我前面说的是“几乎没有任何区别”。也就是说,它们还是存在不同之处的,那么接下来我们就着重看一下不同的地方。
Kotlin 中的if语句相比于 Java 有一个额外的功能,它是可以有返回值的,返回值就是if语句每一个条件中最后一行代码的返回值。因此,上述代码就可以简化成如下形式:
fun largerNumber(num1: Int, num2: Int): Int {
val value = if (num1 > num2) {
num1
} else {
num2
}
return value
}注意这里的代码变化,if语句使用每个条件的最后一行代码作为返回值,并将返回值赋值给了value变量。由于现在没有重新赋值的情况了,因此可以使用val关键字来声明value变量,最终将value变量返回。
仔细观察上述代码,你会发现value其实也是一个多余的变量,我们可以直接将if语句返回,这样代码将会变得更加精简,如下所示:
fun largerNumber(num1: Int, num2: Int): Int {
return if (num1 > num2) {
num1
} else {
num2
}
}到这里为止,你觉得代码足够精简了吗?确实还不错,但是我们还可以做得更好。回顾一下刚刚在上一节里学过的语法糖,当一个函数只有一行代码时,可以省略函数体部分,直接将这一行代码使用等号串连在函数定义的尾部。虽然上述代码中的largerNumber()函数不止只有一行代码,但是它和只有一行代码的作用是相同的,只是返回了一下if语句的返回值而已,符合该语法糖的使用条件。那么我们就可以将代码进一步精简:
fun largerNumber(num1: Int, num2: Int) = if (num1 > num2) {
num1
} else {
num2
}前面我之所以说这个语法糖非常重要,就是因为它除了可以应用于函数只有一行代码的情况,还可以结合 Kotlin 的很多语法来使用,所以它的应用场景非常广泛。
当然,如果你愿意,还可以将上述代码再精简一下,直接压缩成一行代码:
fun largerNumber(num1: Int, num2: Int) = if (num1 > num2) num1 else num2when 条件语句
fun getScore(name: String) = if (name == "Tom") {
86
} else if (name == "Jim") {
77
} else if (name == "Jack") {
95
} else if (name == "Lily") {
100
} else {
0
}这里定义了一个getScore()函数,这个函数接收一个学生姓名参数,然后通过if判断找到该学生对应的考试分数并返回。可以看到,这里再次使用了单行代码函数的语法糖,正如我所说,它真的很常用。
虽然上述代码确实可以实现我们想要的功能,但是写了这么多的if和else,你有没有觉得代码很冗余?没错,当你的判断条件非常多的时候,就是应该考虑使用when语句的时候,现在我们将代码改成如下写法:
fun getScore(name: String) = when (name) {
"Tom" -> 86
"Jim" -> 77
"Jack" -> 95
"Lily" -> 100
else -> 0
}when语句允许传入一个任意类型的参数,然后可以在when的结构体中定义一系列的条件,格式是:
匹配值 -> { 执行逻辑 }当你的执行逻辑只有一行代码时,{ }可以省略。这样再来看上述代码就很好理解了吧?
除了精确匹配之外,when语句还允许进行类型匹配。什么是类型匹配呢?这里我再举个例子。定义一个checkNumber()函数,如下所示:
fun checkNumber(num: Number) {
when (num) {
is Int -> println("number is Int")
is Double -> println("number is Double")
else -> println("number not support")
}
}上述代码中,is关键字就是类型匹配的核心,它相当于 Java 中的instanceof关键字。由于checkNumber()函数接收一个Number类型的参数,这是 Kotlin 内置的一个抽象类,像Int、Long、Float、Double等与数字相关的类都是它的子类,所以这里就可以使用类型匹配来判断传入的参数到底属于什么类型,如果是Int型或Double型,就将该类型打印出来,否则就打印不支持该参数的类型。
循环语句
熟悉 Java 的人应该都知道, Java 中主要有两种循环语句:while循环和for循环。而 Kotlin 也提供了while循环和for循环,其中while循环不管是在语法还是使用技巧上都和 Java 中的while循环没有任何区别,因此我们就直接跳过不进行讲解了。如果你没有学过 Java 也没有关系,只要你学过 C、C++ 或其他任何主流的编程语言,它们的while循环用法基本是相同的。
Kotlin 在for循环方面做了很大幅度的修改,Java 中最常用的for-i循环在 Kotlin 中直接被舍弃了,而 Java 中另一种for-each循环则被 Kotlin 进行了大幅度的加强,变成了for-in循环,所以我们只需要学习for-in循环的用法就可以了。
在开始学习for-in循环之前,还得先向你普及一个区间的概念,因为这也是 Java 中没有的东西。我们可以使用如下 Kotlin 代码来表示一个区间:
val range = 0..10这种语法结构看上去挺奇怪的吧?但在 Kotlin 中,它是完全合法的。上述代码表示创建了一个 0 到 10 的区间,并且两端都是闭区间,这意味着 0 到 10 这两个端点都是包含在区间中的,用数学的方式表达出来就是[0, 10]。
其中,..是创建两端闭区间的关键字,在..的两边指定区间的左右端点就可以创建一个区间了。
有了区间之后,我们就可以通过for-in循环来遍历这个区间,比如在main()函数中编写如下代码:
fun main() {
for (i in 0..10) {
println(i)
}
}运行结果:
0
1
2
3
4
5
6
7
8
9
10但是在很多情况下,双端闭区间却不如单端闭区间好用。为什么这么说呢?相信你一定知道数组的下标都是从 0 开始的,一个长度为 10 的数组,它的下标区间范围是 0 到 9,因此左闭右开的区间在程序设计当中更加常用。Kotlin 中可以使用 until 关键字来创建一个左闭右开的区间,如下所示:
val range = 0 until 10上述代码表示创建了一个0 到 10 的左闭右开区间,它的数学表达方式是[0,10)。修改main()函数中的代码,使用 until替代..关键字,你就会发现最后一行10不会再打印出来了。
默认情况下,for-in循环每次执行循环时会在区间范围内递增 1,相当于 Java for-i循环中i++的效果,而如果你想跳过其中的一些元素,可以使用step关键字:
fun main() {
for (i in 0 until 10 step 2) {
println(i)
}
}上述代码表示在遍历[0, 10) 这个区间的时候,每次执行循环都会在区间范围内递增 2,相当于for-i循环中i = i + 2的效果。
不过,前面我们所学习的..和until关键字都要求区间的左端必须小于等于区间的右端,也就是这两种关键字创建的都是一个升序的区间。如果你想创建一个降序的区间,可以使用downTo关键字,用法如下:
fun main() {
for (i in 10 downTo 1) {
println(i)
}
}这里我们创建了一个[10, 1]的降序区间。
如果让我总结一下的话,我觉得for-in循环并没有传统的for-i循环那样灵活,但是却比for-i循环要简单好用得多,而且足够覆盖大部分的使用场景。如果有一些特殊场景使用for-in循环无法实现的话,我们还可以改用while循环的方式来进行实现。
面向对象编程
类与对象
创建一个类,这是一个空的类实现,可以看到,Kotlin 中也是使用class关键字来声明一个类的,这一点和 Java 一致。
class Person {
}完善一些内容:
class Person {
var name: String = ""
var age: Int = 0
fun eat() {
println(name + " is eating. He is " + age + " years old.")
}
}Person类已经定义好了,接下来我们看一下如何对这个类进行实例化,代码如下所示:
val p = Person()Kotlin 中实例化一个类的方式和 Java 是基本类似的,只是去掉了new关键字而已。之所以这么设计,是因为当你调用了某个类的构造函数时,你的意图只可能是对这个类进行实例化,因此即使没有new关键字,也能清晰表达出你的意图。Kotlin 本着最简化的设计原则,将诸如new、行尾分号这种不必要的语法结构都取消了。
上述代码将实例化后的类赋值到了p这个变量上面,p就可以称为Person类的一个实例,也可以称为一个对象。
下面我们开始在main()函数中对p对象进行一些操作:
fun main() {
val person = Person()
person.name = "John"
person.age = 25
person.eat()
}继承与构造函数
现在我们要定义一个Student类,每个学生都有自己的学号和年级,因此我们可以在Student类中加入sno和grade字段。但同时学生也是人呀,学生也会有姓名和年龄,也需要吃饭,如果我们在Student类中重复定义name、age字段和eat()函数的话就显得太过冗余了。这个时候就可以让Student类去继承Person类,这样Student就自动拥有了Person中的字段和函数,另外还可以定义自己独有的字段和函数。
class Student {
var sno = ""
var grade = 0
}现在Student和Person这两个类之间是没有任何继承关系的,想要让Student类继承Person类,我们得做两件事才行。
第一件事,使Person类可以被继承。 可能很多人会觉得奇怪,尤其是有 Java 编程经验的人。一个类本身不就是可以被继承的吗?为什么还要使Person类可以被继承呢?这就是 Kotlin 不同的地方,在 Kotlin 中任何一个非抽象类默认都是不可以被继承的,相当于 Java 中给类声明了final关键字。之所以这么设计,其实和val关键字的原因是差不多的,因为类和变量一样,最好都是不可变的,而一个类允许被继承的话,它无法预知子类会如何实现,因此可能就会存在一些未知的风险。Effective Java 这本书中明确提到,如果一个类不是专门为继承而设计的,那么就应该主动将它加上final声明,禁止它可以被继承。
很明显,Kotlin 在设计的时候遵循了这条编程规范,默认所有非抽象类都是不可以被继承的。之所以这里一直在说非抽象类,是因为抽象类本身是无法创建实例的,一定要由子类去继承它才能创建实例,因此抽象类必须可以被继承才行,要不然就没有意义了。由于 Kotlin 中的抽象类和
Java 中并无区别,这里我就不再多讲了。
既然现在Person类是无法被继承的,我们得让它可以被继承才行,方法也很简单,在Person类的前面加上open关键字就可以了,如下所示:
open class Person {
...
}加上open关键字之后,我们就是在主动告诉 Kotlin 编译器,Person这个类是专门为继承而设计的,这样Person类就允许被继承了。
第二件事,要让Student类继承Person类。在 Java 中继承的关键字是extends,而在 Kotlin 中变成了一个冒号,写法如下:
class Student : Person() {
var sno = ""
var grade = 0
}继承的写法如果只是替换一下关键字倒也挺简单的,但是为什么Person类的后面要加上一对括号呢?Java 中继承的时候好像并不需要括号。对于初学 Kotlin 的人来讲,这对括号确实挺难理解的,也可能是 Kotlin 在这方面设计得太复杂了,因为它还涉及主构造函数、次构造函数等方面的知识,这里我尽量尝试用最简单易懂的讲述来让你理解这对括号的意义和作用,同时顺便学习一下 Kotlin 中的主构造函数和次构造函数。
任何一个面向对象的编程语言都会有构造函数的概念,Kotlin 中也有,但是 Kotlin 将构造函数分成了两种:主构造函数和次构造函数。
主构造函数将会是你最常用的构造函数,每个类默认都会有一个不带参数的主构造函数,当然你也可以显式地给它指明参数。主构造函数的特点是没有函数体,直接定义在类名的后面即可。比如下面这种写法:
class Student(val sno: String, val grade: Int) : Person() {
}这里我们将学号和年级这两个字段都放到了主构造函数当中,这就表明在对Student类进行实例化的时候,必须传入构造函数中要求的所有参数。比如:
val student = Student("a123", 5)这样我们就创建了一个Student的对象,同时指定该学生的学号是 a123,年级是 5。另外,由于构造函数中的参数是在创建实例的时候传入的,不像之前的写法那样还得重新赋值,因此我们可以将参数全部声明成val。
你可能会问,主构造函数没有函数体,如果我想在主构造函数中编写一些逻辑,该怎么办呢?Kotlin 给我们提供了一个init结构体,所有主构造函数中的逻辑都可以写在里面:
class Student(val sno: String, val grade: Int) : Person() {
init {
println("sno is " + sno)
println("grade is " + grade)
}
}这里我只是简单打印了一下学号和年级的值,现在如果你再去创建一个Student类的实例,一定会将构造函数中传入的值打印出来。
到这里为止都还挺好理解的吧?但是这和那对括号又有什么关系呢?这就涉及了 Java 继承特性中的一个规定,子类中的构造函数必须调用父类中的构造函数,这个规定在 Kotlin 中也要遵守。
那么回头看一下Student类,现在我们声明了一个主构造函数,根据继承特性的规定,子类的构造函数必须调用父类的构造函数,可是主构造函数并没有函数体,我们怎样去调用父类的构造函数呢?你可能会说,在init结构体中去调用不就好了。这或许是一种办法,但绝对不是一种好办法,因为在绝大多数的场景下,我们是不需要编写init结构体的。
Kotlin 当然没有采用这种设计,而是用了另外一种简单但是可能不太好理解的设计方式:括号。子类的主构造函数调用父类中的哪个构造函数,在继承的时候通过括号来指定。因此再来看一遍这段代码,你应该就能理解了吧。
class Student(val sno: String, val grade: Int) : Person() {
}在这里,Person类后面的一对空括号表示Student类的主构造函数在初始化的时候会调用Person类的无参数构造函数,即使在无参数的情况下,这对括号也不能省略。
而如果我们将Person改造一下,将姓名和年龄都放到主构造函数当中,如下所示:
open class Person(val name: String, val age: Int) {
...
}此时你的Student类一定会报错,当然,如果你的main()函数还保留着之前创建Person实例的代码,那么这里也会报错,但是它和我们接下来要讲的内容无关,你可以自己修正一下,或者干脆直接删掉这部分代码。
现在回到Student类当中,它一定会提示如下图所示的错误。

这里出现错误的原因也很明显,Person类后面的空括号表示要去调用Person类中无参的构造函数,但是Person类现在已经没有无参的构造函数了,所以就提示了上述错误。
如果我们想解决这个错误的话,就必须给Person类的构造函数传入name和age字段,可是Student类中也没有这两个字段呀。很简单,没有就加呗。我们可以在Student类的主构造函数中加上name和age这两个参数,再将这两个参数传给Person类的构造函数,代码如下所示:
class Student(val sno: String, val grade: Int, name: String, age: Int) :
Person(name, age) {
...
}注意,我们在Student类的主构造函数中增加name和age这两个字段时,不能再将它们声明成val,因为在主构造函数中声明成val或者var的参数将自动成为该类的字段,这就会导致和父类中同名的name和age字段造成冲突。因此,这里的name和age参数前面我们不用加任何关键字,让它的作用域仅限定在主构造函数当中即可。
现在就可以通过如下代码来创建一个Student类的实例:
val student = Student("a123", 5, "Jack", 19)学到这里,我们就将 Kotlin 的主构造函数基本掌握了,是不是觉得继承时的这对括号问题也不是那么难以理解?但是,Kotlin 在括号这个问题上的复杂度并不仅限于此,因为我们还没涉及Kotlin 构造函数中的另一个组成部分——次构造函数。
你要知道,任何一个类只能有一个主构造函数,但是可以有多个次构造函数。次构造函数也可以用于实例化一个类,这一点和主构造函数没有什么不同,只不过它是有函数体的。
Kotlin 规定,当一个类既有主构造函数又有次构造函数时,所有的次构造函数都必须调用主构造函数(包括间接调用)。这里我通过一个具体的例子就能简单阐明,代码如下:
class Student(val sno: String, val grade: Int, name: String, age: Int) :
Person(name, age) {
constructor(name: String, age: Int) : this("", 0, name, age) {
}
constructor() : this("", 0) {
}
}次构造函数是通过constructor关键字来定义的,这里我们定义了两个次构造函数:第一个次构造函数接收name和age参数,然后它又通过this关键字调用了主构造函数,并将sno和grade这两个参数赋值成初始值;第二个次构造函数不接收任何参数,它通过this关键字调用
了我们刚才定义的第一个次构造函数,并将name和age参数也赋值成初始值,由于第二个次构造函数间接调用了主构造函数,因此这仍然是合法的。
那么现在我们就拥有了 3 种方式来对Student类进行实体化,分别是通过不带参数的构造函数、通过带两个参数的构造函数和通过带 4 个参数的构造函数,对应代码如下所示:
val student1 = Student()
val student2 = Student("Jack", 19)
val student3 = Student("a123", 5, "Jack", 19)这样我们就将次构造函数的用法掌握得差不多了,但是到目前为止,继承时的括号问题还没有进一步延伸,暂时和之前学过的场景是一样的。
那么接下来我们就再来看一种非常特殊的情况:类中只有次构造函数,没有主构造函数。这种情况真的十分少见,但在 Kotlin 中是允许的。当一个类没有显式地定义主构造函数且定义了次构造函数时,它就是没有主构造函数的。我们结合代码来看一下:
class Student : Person {
constructor(name: String, age: Int) : super(name, age) {
}
}注意这里的代码变化,首先Student类的后面没有显式地定义主构造函数,同时又因为定义了次构造函数,所以现在Student类是没有主构造函数的。那么既然没有主构造函数,继承Person类的时候也就不需要再加上括号了。其实原因就是这么简单,只是很多人在刚开始学习 Kotlin 的时候没能理解这对括号的意义和规则,因此总感觉继承的写法有时候要加上括号,有时候又不要加,搞得晕头转向的,而在你真正理解了规则之后,就会发现其实还是很好懂的。
另外,由于没有主构造函数,次构造函数只能直接调用父类的构造函数,上述代码也是将this关键字换成了super关键字,这部分就很好理解了,因为和 Java 比较像,我也就不再多说了。
接口
Kotlin 中接口部分和 Java 几乎是完全一致的。
首先创建一个Study接口,并在其中定义几个学习行为:
interface Study {
fun readBooks()
fun doHomework()
}接下来就可以让Student类去实现Study接口了,这里我将Student类原有的代码调整了一下,以突出继承父类和实现接口的区别:
class Student(name: String, age: Int) : Person(name, age), Study {
override fun readBooks() {
println("$name is reading.")
}
override fun doHomework() {
println("$name is doing homework.")
}
}熟悉 Java 的人一定知道,Java 中继承使用的关键字是extends,实现接口使用的关键字是implements,而 Kotlin 中统一使用冒号,中间用逗号进行分隔。上述代码就表示Student类继承了Person类,同时还实现了Study接口。另外接口的后面不用加上括号,因为它没有构造函数可以去调用。
Study接口中定义了readBooks()和doHomework()这两个待实现函数,因此Student类必须实现这两个函数。Kotlin 中使用override关键字来重写父类或者实现接口中的函数,这里我们只是简单地在实现的函数中打印了一行日志。
现在我们可以在main()函数中编写如下代码来调用这两个接口中的函数:
fun main() {
val student = Student("Jack", 19)
doStudy(student)
}
fun doStudy(study: Study) {
study.readBooks()
study.doHomework()
}这样我们就将 Kotlin 中接口的用法基本学完了,是不是很简单?不过为了让接口的功能更加灵活,Kotlin 还增加了一个额外的功能:允许对接口中定义的函数进行默认实现。其实 Java 在 JDK1.8 之后也开始支持这个功能了,因此总体来说,Kotlin 和 Java 在接口方面的功能仍然是一模一样的。
下面我们学习一下如何对接口中的函数进行默认实现,修改Study接口中的代码,如下所示:
interface Study {
fun readBooks()
fun doHomework() {
println("do homework default implementation.")
}
}可以看到,我们给doHomework()函数加上了函数体,并且在里面打印了一行日志。如果接口中的一个函数拥有了函数体,这个函数体中的内容就是它的默认实现。现在当一个类去实现Study接口时,只会强制要求实现readBooks()函数,而doHomework()函数则可以自由选择实现或者不实现,不实现时就会自动使用默认的实现逻辑。
现在你已经掌握了 Kotlin 面向对象编程中最主要的一些内容,接下来我们再学习一个和 Java 相比变化比较大的部分——函数的可见性修饰符。

数据类与单例类
在一个规范的系统架构中,数据类通常占据着非常重要的角色,它们用于将服务器端或数据库中的数据映射到内存中,为编程逻辑提供数据模型的支持。或许你听说过 MVC 、MVP 、MVVM 之类的架构模式,不管是哪一种架构模式,其中的 M 指的就是数据类。
数据类通常需要重写equals()、hashCode()、toString()这几个方法。其中,equals()方法用于判断两个数据类是否相等。hashCode()方法作为equals()的配套方法,也需要一起重写,否则会导致 HashMap、HashSet 等 hash 相关的系统类无法正常工作。toString()方法用于提供更清晰的输入日志,否则一个数据类默认打印出来的就是一行内存地址。
这里我们新构建一个手机数据类,字段就简单一点,只有品牌和价格这两个字段。如果使用Java 来实现这样一个数据类,代码就需要这样写:
public class Cellphone {
String brand;
double price;
public Cellphone(String brand, double price) {
this.brand = brand;
this.price = price;
}
@Override
public boolean equals(Object obj) {
if(obj instanceof Cellphone) {
Cellphone other = (Cellphone) obj;
return other.brand.equals(brand) && other.price == price;
}
return false;
}
@Override
public int hashCode() {
return brand.hashCode() + (int) price;
}
@Override
public String toString() {
return "Cellphone(brand=$brand, price=$price)";
}
}看上去挺复杂的吧?关键是这些代码还是一些没有实际逻辑意义的代码,只是为了让它拥有数据类的功能而已。而同样的功能使用 Kotlin 来实现就会变得极其简单:
data class Cellphone(val brand: String, val price: Double)你没看错,只需要一行代码就可以实现了!神奇的地方就在于data这个关键字,当在一个类前面声明了data关键字时,就表明你希望这个类是一个数据类,Kotlin 会根据主构造函数中的参数帮你将equals()、hashCode()、toString()等固定且无实际逻辑意义的方法自动生成,从而大大减少了开发的工作量。
另外,当一个类中没有任何代码时,还可以将尾部的大括号省略。
接下来我们再来看另外一个 Kotlin 中特有的功能——单例类,这里就演示一种最常见的 Java 写法吧:
public class Singleton {
private static Singleton instance;
private Singleton() {
}
public synchronized static Singleton getInstance() {
if(instance == null) {
instance = new Singleton();
}
return instance;
}
public void singletonTest() {
System.out.println("Singleton method called");
}
}这段代码其实很好理解,首先为了禁止外部创建Singleton的实例,我们需要用private关键字将Singleton的构造函数私有化,然后给外部提供了一个getInstance()静态方法用于获取Singleton的实例。在getInstance()方法中,我们判断如果当前缓存的Singleton实例为null,就创建一个新的实例,否则直接返回缓存的实例即可,这就是单例模式的工作机制。
而如果我们想调用单例类中的方法,也很简单,比如想调用上述的singletonTest()方法,
就可以这样写:
Singleton singleton = Singleton.getInstance();
singleton.singletonTest();虽然 Java 中的单例实现并不复杂,但是 Kotlin 明显做得更好,它同样是将一些固定的、重复的逻辑实现隐藏了起来,只暴露给我们最简单方便的用法。
在 Kotlin 中创建一个单例类的方式极其简单,只需要将class关键字改成object关键字即可。
object Singleton {
}现在Singleton就已经是一个单例类了,我们可以直接在这个类中编写需要的函数,比如加入一个singletonTest()函数:
object Singleton {
fun singletonTest() {
println("singletonTest is called.")
}
}可以看到,在 Kotlin 中我们不需要私有化构造函数,也不需要提供getInstance()这样的静态方法,只需要把class关键字改成object关键字,一个单例类就创建完成了。而调用单例类中的函数也很简单,比较类似于 Java 中静态方法的调用方式:
Singleton.singletonTest()这种写法虽然看上去像是静态方法的调用,但其实 Kotlin 在背后自动帮我们创建了一个Singleton类的实例,并且保证全局只会存在一个Singleton实例。
Lambda 编程
集合的创建与遍历
集合的函数式 API 是用来入门 Lambda 编程的绝佳示例,不过在此之前,我们得先学习创建集合的方式才行。
传统意义上的集合主要就是 List 和 Set,再广泛一点的话,像 Map 这样的键值对数据结构也可以包含进来。List、Set 和 Map 在 Java 中都是接口,List 的主要实现类是ArrayList和LinkedList,Set 的主要实现类是HashSet,Map 的主要实现类是HashMap。
现在我们提出一个需求,创建一个包含许多水果名称的集合。如果是在Java中你会怎么实现?可能你首先会创建一个ArrayList的实例,然后将水果的名称一个个添加到集合中。当然,在Kotlin中也可以这么做:
val list = ArrayList<String>()
list.add("Apple")
list.add("Banana")
list.add("Orange")
list.add("Pear")
list.add("Grape")但是这种初始化集合的方式比较烦琐,为此Kotlin专门提供了一个内置的listOf()函数来简化初始化集合的写法,如下所示:
val list = listOf("Apple", "Banana", "Orange", "Pear", "Grape")现在我们就尝试一下使用for-in循环来遍历这个水果集合,在main()函数中编写如下代码:
fun main() {
val list = listOf("Apple", "Banana", "Orange", "Pear", "Grape")
for (fruit in list) {
println(fruit)
}
}不过需要注意的是,listOf()函数创建的是一个不可变的集合。你也许不太能理解什么叫作不可变的集合,因为在 Java 中这个概念不太常见。不可变的集合指的就是该集合只能用于读取,我们无法对集合进行添加、修改或删除操作。
至于这么设计的理由,和val关键字、类默认不可继承的设计初衷是类似的,可见 Kotlin 在不可
变性方面控制得极其严格。那如果我们确实需要创建一个可变的集合呢?也很简单,使用mutableListOf()函数就可以了,示例如下:
fun main() {
val list = mutableListOf("Apple", "Banana", "Orange", "Pear", "Grape")
list.add("Watermelon")
for (fruit in list) {
println(fruit)
}
}这里先使用mutableListOf()函数创建一个可变的集合,然后向集合中添加了一个新的水果,最后再使用for-in循环对集合进行遍历。
前面我们介绍的都是 List 集合的用法,实际上 Set 集合的用法几乎与此一模一样,只是将创建集合的方式换成了setOf()和mutableSetOf()函数而已。需要注意,Set 集合中是不可以存放重复元素的,如果存放了多个相同的元素,只会保留其中一份,这是和 List 集合最大的不同之处。当然这部分知识属于数据结构相关的内容,这里就不展开讨论了。
最后再来看一下 Map 集合的用法。Map 是一种键值对形式的数据结构,因此在用法上和 List、Set集合有较大的不同。传统的 Map 用法是先创建一个HashMap的实例,然后将一个个键值对数据添加到 Map 中。比如这里我们给每种水果设置一个对应的编号,就可以这样写:
val map = HashMap<String, Int>()
map.put("Apple", 1)
map.put("Banana", 2)
map.put("Orange", 3)
map.put("Pear", 4)
map.put("Grape", 5)我之所以先用这种写法,是因为这种写法和Java语法是最相似的,因此可能最好理解。但其实在Kotlin 中并不建议使用put()和get()方法来对 Map 进行添加和读取数据操作,而是更加推荐使用一种类似于数组下标的语法结构:
val map = HashMap<String, Int>()
map["Apple"] = 1
map["Banana"] = 2
map["Orange"] = 3
map["Pear"] = 4
map["Grape"] = 5当然,这仍然不是最简便的写法,因为 Kotlin 毫无疑问地提供了一对mapOf()和mutableMapOf()函数来继续简化 Map 的用法。在mapOf()函数中,我们可以直接传入初始化的键值对组合来完成对 Map 集合的创建:
val map = mapOf("Apple" to 1, "Banana" to 2, "Orange" to 3, "Pear" to 4, "Grape" to 5)最后再来看一下如何遍历 Map 集合中的数据吧,其实使用的仍然是for-in循环。在main()函数中编写如下代码:
fun main() {
val map = mapOf("Apple" to 1, "Banana" to 2, "Orange" to 3, "Pear" to 4, "Grape" to 5)
for ((fruit, number) in map) {
println("fruit is " + fruit + ", number is " + number)
}
}集合的函数式API
集合的函数式API有很多个,这里我并不打算带你涉猎所有函数式 API 的用法,而是重点学习函数式API 的语法结构,也就是 Lambda 表达式的语法结构。
首先我们来思考一个需求,如何在一个水果集合里面找到单词最长的那个水果?当然这个需求很简单,也有很多种写法,你可能会很自然地写出如下代码:
fun main() {
val list = listOf("Apple", "Banana", "Orange", "Pear", "Grape", "Watermelon")
var maxLengthFruit = ""
for (fruit in list) {
if(fruit.length > maxLengthFruit.length) {
maxLengthFruit = fruit
}
}
println("max length fruit is " + maxLengthFruit)
}这段代码很简洁,思路也很清晰,可以说是一段相当不错的代码了。但是如果我们使用集合的函数式API,就可以让这个功能变得更加容易:
fun main() {
val list = listOf("Apple", "Banana", "Orange", "Pear", "Grape", "Watermelon")
val maxLengthFruit = list.maxBy { it.length }
println("max length fruit is " + maxLengthFruit)
}上述代码使用的就是函数式API的用法,只用一行代码就能找到集合中单词最长的那个水果。
首先来看一下 Lambda 的定义,如果用最直白的语言来阐述的话,Lambda 就是一小段可以作为参数传递的代码。
从定义上看,这个功能就很厉害了,因为正常情况下,我们向某个函数传参时只能传入变量,而借助 Lambda 却允许传入一小段代码。这里两次使用了“一小段代码”这种描述,那么到底多少代码才算一小段代码呢?Kotlin 对此并没有进行限制,但是通常不建议在 Lambda 表达式中编写太长的代码,否则可能会影响代码的可读性。
接着我们来看一下Lambda表达式的语法结构:
{参数名1: 参数类型, 参数名2: 参数类型 -> 函数体}这是 Lambda 表达式最完整的语法结构定义。首先最外层是一对大括号,如果有参数传入到 Lambda 表达式中的话,我们还需要声明参数列表,参数列表的结尾使用一个->符号,表示参数列表的结束以及函数体的开始,函数体中可以编写任意行代码(虽然不建议编写太长的代码),并且最后一行代码会自动作为 Lambda 表达式的返回值。
当然,在很多情况下,我们并不需要使用 Lambda 表达式完整的语法结构,而是有很多种简化的写法。但是简化版的写法对于初学者而言更难理解,因此这里我准备使用一步步推导演化的方式,向你展示这些简化版的写法是从何而来的,这样你就能对 Lambda 表达式的语法结构理解得更加深刻了。那么接下来我们就由繁入简开始吧。
还是回到刚才找出最长单词水果的需求,前面使用的函数式 API 的语法结构看上去好像很特殊,但其实maxBy就是一个普通的函数而已,只不过它接收的是一个Lambda类型的参数,并且会在遍历集合时将每次遍历的值作为参数传递给 Lambda 表达式。maxBy函数的工作原理是根据我们传入的条件来遍历集合,从而找到该条件下的最大值,比如说想要找到单词最长的水果,那么条件自然就应该是单词的长度了。
理解了maxBy函数的工作原理之后,我们就可以开始套用刚才学习的 Lambda 表达式的语法结构,并将它传入到maxBy函数中了,如下所示:
val list = listOf("Apple", "Banana", "Orange", "Pear", "Grape", "Watermelon")
val lambda = { fruit: String -> fruit.length }
val maxLengthFruit = list.maxBy(lambda)可以看到,maxBy函数实质上就是接收了一个 Lambda 参数而已,并且这个 Lambda 参数是完全按照刚才学习的表达式的语法结构来定义的,因此这段代码应该算是比较好懂的。
这种写法虽然可以正常工作,但是比较啰嗦,可简化的点也非常多,下面我们就开始对这段代码一步步进行简化。
首先,我们不需要专门定义一个 lambda 变量,而是可以直接将 lambda 表达式传入 maxBy 函数当中,因此第一步简化如下所示:
val maxLengthFruit = list.maxBy({ fruit: String -> fruit.length })然后 Kotlin 规定,当 Lambda 参数是函数的最后一个参数时,可以将 Lambda 表达式移到函数括号的外面,如下所示:
val maxLengthFruit = list.maxBy() { fruit: String -> fruit.length }接下来,如果 Lambda 参数是函数的唯一一个参数的话,还可以将函数的括号省略:
val maxLengthFruit = list.maxBy { fruit: String -> fruit.length }这样代码看起来就变得清爽多了吧?但是我们还可以继续进行简化。由于 Kotlin 拥有出色的类型推导机制,Lambda 表达式中的参数列表其实在大多数情况下不必声明参数类型,因此代码可以进一步简化成:
val maxLengthFruit = list.maxBy { fruit -> fruit.length }最后,当 Lambda 表达式的参数列表中只有一个参数时,也不必声明参数名,而是可以使用 it 关键字来代替,那么代码就变成了:
val maxLengthFruit = list.maxBy { it.length }集合中的 map 函数是最常用的一种函数式 API,它用于将集合中的每个元素都映射成一个另外的值,映射的规则在 Lambda 表达式中指定,最终生成一个新的集合。比如,这里我们希望让所有的水果名都变成大写模式,就可以这样写:
fun main() {
val list = listOf("Apple", "Banana", "Orange", "Pear", "Grape", "Watermelon")
val newList = list.map { it.toUpperCase() }
for (fruit in newList) {
println(fruit)
}
}可以看到,我们在 map 函数的 Lambda 表达式中指定将单词转换成了大写模式,然后遍历这个新生成的集合。
map 函数的功能非常强大,它可以按照我们的需求对集合中的元素进行任意的映射转换,上面只是一个简单的示例而已。除此之外,你还可以将水果名全部转换成小写,或者是只取单词的首字母,甚至是转换成单词长度这样一个数字集合,只要在 Lambda 表示式中编写你需要的逻辑即可。
接下来我们再来学习另外一个比较常用的函数式 API —— filter 函数。顾名思义,filter 函数是用来过滤集合中的数据的,它可以单独使用,也可以配合刚才的 map 函数一起使用。
比如我们只想保留5个字母以内的水果,就可以借助 filter 函数来实现,代码如下所示:
fun main() {
val list = listOf("Apple", "Banana", "Orange", "Pear", "Grape", "Watermelon")
val newList = list.filter { it.length <= 5 } .map { it.toUpperCase() }
for (fruit in newList) {
println(fruit)
}
}接下来我们继续学习两个比较常用的函数式API—— any 和 all 函数。其中 any 函数用于判断集合中是否至少存在一个元素满足指定条件,all 函数用于判断集合中是否所有元素都满足指定条件。由于这两个函数都很好理解,我们就直接通过代码示例学习了:
fun main() {
val list = listOf("Apple", "Banana", "Orange", "Pear", "Grape", "Watermelon")
val anyResult = list.any { it.length <= 5 }
val allResult = list.all { it.length <= 5 }
println("anyResult is " + anyResult + ", allResult is " + allResult)
}这里还是在 Lambda 表达式中将条件设置为 5 个字母以内的单词,那么 any 函数就表示集合中是否存在 5 个字母以内的单词,而 all 函数就表示集合中是否所有单词都在 5 个字母以内。
Java 函数式 API 的使用
现在我们已经学习了 Kotlin 中函数式 API 的用法,但实际上在 Kotlin 中调用 Java 方法时也可以使用函数式 API,只不过这是有一定条件限制的。具体来讲,如果我们在 Kotlin 代码中调用了一个 Java方法,并且该方法接收一个 Java 单抽象方法接口参数,就可以使用函数式 API。Java 单抽象方法接口指的是接口中只有一个待实现方法,如果接口中有多个待实现方法,则无法使用函数式 API。
如果你觉得上面的描述有些模糊的话,没关系,下面我们通过一个具体的例子来学习一下,你就能明白了。Java 原生 API 中有一个最为常见的单抽象方法接口—— Runnable 接口。这个接口中只有一个待实现的run()方法,定义如下:
public interface Runnable {
void run();
}根据前面的讲解,对于任何一个 Java 方法,只要它接收 Runnable 参数,就可以使用函数式 API。那么什么 Java 方法接收了 Runnable 参数呢?这就有很多了,不过 Runnable 接口主要还是结合线程来一起使用的,因此这里我们就通过 Java 的线程类Thread来学习一下。
Thread 类的构造方法中接收了一个 Runnable 参数,我们可以使用如下 Java 代码创建并执行一
个子线程:
new Thread(new Runnable() {
@Override
public void run() {
System.out.println("Thread is running");
}
}).start();注意,这里使用了匿名类的写法,我们创建了一个 Runnable 接口的匿名类实例,并将它传给了Thread 类的构造方法,最后调用 Thread 类的start()方法执行这个线程。
而如果直接将这段代码翻译成 Kotlin 版本,写法将如下所示:
Thread(object : Runnable {
override fun run() {
println("Thread is running")
}
}).start()Kotlin 中匿名类的写法和 Java 有一点区别,由于 Kotlin 完全舍弃了 new 关键字,因此创建匿名类实例的时候就不能再使用 new 了,而是改用了 object 关键字。这种写法虽然算不上复杂,但是相比于Java 的匿名类写法,并没有什么简化之处。
但是别忘了,目前 Thread 类的构造方法是符合 Java 函数式 API 的使用条件的,下面我们就看看
如何对代码进行精简,如下所示:
Thread(Runnable {
println("Thread is running")
}).start()这段代码明显简化了很多,既可以实现同样的功能,又不会造成任何歧义。因为 Runnable 类中只有一个待实现方法,即使这里没有显式地重写run()方法,Kotlin 也能自动明白 Runnable 后面的Lambda 表达式就是要在run()方法中实现的内容。
另外,如果一个 Java 方法的参数列表中有且仅有一个 Java 单抽象方法接口参数,我们还可以将
接口名进行省略,这样代码就变得更加精简了:
Thread({
println("Thread is running")
}).start()不过到这里还没有结束,和之前 Kotlin 中函数式 API 的用法类似,当 Lambda 表达式是方法的最后一个参数时,可以将 Lambda 表达式移到方法括号的外面。同时,如果 Lambda 表达式还是方法的唯一一个参数,还可以将方法的括号省略,最终简化结果如下:
Thread {
println("Thread is running")
}.start()或许你会觉得,既然本书中所有的代码都是使用 Kotlin 编写的,这种 Java 函数式 API 应该并不常用吧?举个例子,Android 中有一个极为常用的点击事件接口 OnClickListener,其定义如下:
public interface OnClickListener {
void onClick(View v);
}可以看到,这又是一个单抽象方法接口。假设现在我们拥有一个按钮 button 的实例,然后使用 Java代码去注册这个按钮的点击事件,需要这么写:
button.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
}
});而用 Kotlin 代码实现同样的功能,就可以使用函数式 API 的写法来对代码进行简化,结果如下:
button.setOnClickListener {
}空指针检查
可空类型系统
Kotlin 将空指针异常的检查提前到了编译时期,如果我们的程序存在空指针异常的风险,那么在编译的时候会直接报错,修正之后才能成功运行,这样就可以保证程序在运行时期不会出现空指针异常了。
那如果我们的业务逻辑就是需要某个参数或者变量为空该怎么办呢?不用担心,Kotlin 提供了另外一套可为空的类型系统,只不过在使用可为空的类型系统时,我们需要在编译时期就将所有潜在的空指针异常都处理掉,否则代码将无法编译通过。
那么可为空的类型系统是什么样的呢?很简单,就是在类名的后面加上一个问号。比如,Int表示不可为空的整型,而Int?就表示可为空的整型;String表示不可为空的字符串,而String?就表示可为空的字符串。
判空辅助工具
首先学习最常用的?.操作符。这个操作符的作用非常好理解,就是当对象不为空时正常调用相应的方法,当对象为空时则什么都不做。比如以下的判空处理代码:
if (a != null) {
a.doSomething()
}这段代码使用?.操作符就可以简化成:
a?.doSomething()下面我们再来学习另外一个非常常用的?:操作符。这个操作符的左右两边都接收一个表达式,如果左边表达式的结果不为空就返回左边表达式的结果,否则就返回右边表达式的结果。观察如下代码:
val c = if (a ! = null) {
a
} else {
b
}这段代码的逻辑使用?:操作符就可以简化成:
val c = a ?: b接下来我们通过一个具体的例子来结合使用?.和?:这两个操作符,从而让你加深对它们的理解。
比如现在我们要编写一个函数用来获得一段文本的长度,使用传统的写法就可以这样写:
fun getTextLength(text: String?): Int {
if (text != null) {
return text.length
}
return 0
}由于文本是可能为空的,因此我们需要先进行一次判空操作,如果文本不为空就返回它的长度,如果文本为空就返回0。
这段代码看上去也并不复杂,但是我们却可以借助操作符让它变得更加简单,如下所示:
fun getTextLength(text: String?) = text?.length ?: 0这里我们将?.和?:操作符结合到了一起使用,首先由于 text 是可能为空的,因此我们在调用它的length字段时需要使用?.操作符,而当 text 为空时,text?.length会返回一个null值,这个时候我们再借助?:操作符让它返回0。怎么样,是不是觉得这些操作符越来越好用了呢?
不过 Kotlin 的空指针检查机制也并非总是那么智能,有的时候我们可能从逻辑上已经将空指针异常处理了,但是 Kotlin 的编译器并不知道,这个时候它还是会编译失败。
var content: String? = "hello"
fun main() {
if (content != null) {
printUpperCase()
}
}
fun printUpperCase() {
val upperCase = content.toUpperCase()
println(upperCase)
}这里我们定义了一个可为空的全局变量content,然后在main()函数里先进行一次判空操作,当content不为空的时候才会调用printUpperCase()函数,在printUpperCase()函数里,我们将content转换为大写模式,最后打印出来。
看上去好像逻辑没什么问题,但是很遗憾,这段代码一定是无法运行的。因为printUpperCase()函数并不知道外部已经对content变量进行了非空检查,在调用toUpperCase()方法时,还认为这里存在空指针风险,从而无法编译通过。
在这种情况下,如果我们想要强行通过编译,可以使用非空断言工具,写法是在对象的后面加上!!,如下所示:
fun printUpperCase() {
val upperCase = content!!.toUpperCase()
println(upperCase)
}这是一种有风险的写法,意在告诉 Kotlin,我非常确信这里的对象不会为空,所以不用你来帮我做空指针检查了,如果出现问题,你可以直接抛出空指针异常,后果由我自己承担。
虽然这样编写代码确实可以通过编译,但是当你想要使用非空断言工具的时候,最好提醒一下自己,是不是还有更好的实现方式。你最自信这个对象不会为空的时候,其实可能就是一个潜在空指针异常发生的时候。
最后我们再来学习一个比较与众不同的辅助工具—— let 。let 既不是操作符,也不是什么关键字,而是一个函数。这个函数提供了函数式 API 的编程接口,并将原始调用对象作为参数传递到 Lambda 表达式中。示例代码如下:
obj.let { obj2 ->
// 编写具体的业务逻辑
}可以看到,这里调用了 obj 对象的let函数,然后 Lambda 表达式中的代码就会立即执行,并且
这个 obj 对象本身还会作为参数传递到 Lambda 表达式中。不过,为了防止变量重名,这里我将参数名改成了 obj2,但实际上它们是同一个对象,这就是let函数的作用。
你可能就要问了,这个let函数和空指针检查有什么关系呢?其实let函数的特性配合?.操作符可以在空指针检查的时候起到很大的作用。
fun doStudy(study: Study?) {
study?.readBooks()
study?.doHomework()
}虽然这段代码我们通过?.操作符优化之后可以正常编译通过,但其实这种表达方式是有点啰嗦的,如果将这段代码准确翻译成使用if判断语句的写法,对应的代码如下:
fun doStudy(study: Study?) {
if(study != null) {
study.readBooks()
}
if (study != null) {
study.doHomework()
}
}也就是说,本来我们进行一次if判断就能随意调用 study 对象的任何方法,但受制于?.操作符的限制,现在变成了每次调用 study 对象的方法时都要进行一次if判断。
这个时候就可以结合使用?.操作符和let函数来对代码进行优化了,如下所示:
fun doStudy(study: Study?) {
study?.let { stu ->
stu.readBooks()
stu.doHomework()
}
}我来简单解释一下上述代码,?.操作符表示对象为空时什么都不做,对象不为空时就调用let函数,而let函数会将 study 对象本身作为参数传递到 Lambda 表达式中,此时的 study 对象肯定不为空了,我们就能放心地调用它的任意方法了。
另外还记得 Lambda 表达式的语法特性吗?当 Lambda 表达式的参数列表中只有一个参数时,可以不用声明参数名,直接使用it关键字来代替即可,那么代码就可以进一步简化成:
fun doStudy(study: Study?) {
study?.let {
it.readBooks()
it.doHomework()
}
}本文来自:《第一行代码 Android 第3版》
评论已关闭