第2章 探究新语言,快速入门Kotlin编程

在Android系统诞生的前9年时间里,Google都只提供了Java这一种语言来开发Android应用程序,虽然在Android 1.5系统中Google 引入了NDK功能,支持使用C和C++语言来进行一些本地化开发,但是这丝毫没有影响过Java的正统地位。

不过从2017开始,一切都发生了改变。Google在2017年的I/O大会上宣布,Kotlin正式成为Android的一级开发语言,和Java平起平坐,Android Studio也对Kotlin进行了全面的支持。两年之后,Google又在2019年的I/O大会上宣布,Kotlin已经成为Android的第一开发语言,虽然Java仍然可以继续使用,但Google更加推荐开发者使用Kotlin来编写Android应用程序,并且未来提供的官方API也将会优先考虑Kotlin版本。

然而现实情况是,很多人对Java太熟悉了,不太愿意花费额外的时间再去学习一门新语言,再加上国内不少公司对于新技术比较保守,不敢冒然改用新语言去承担一份额外的风险,因此目前Kotlin在国内的普及程度并不高。

可是在海外,Kotlin的发展速度已是势如破竹。根据统计,Google Play商店中排名前1000的App里,有超过60%的App已使用了Kotlin语言,并且这个比例每年还在不断上升。Android官网文档的代码已优先显示Kotlin版本,官方的视频教程以及Google的一些开源项目,也改用了Kotlin来实现。

为此,我坚定了使用Kotlin来编写本书第3版的信心。前面已经说了,目前国内Kotlin的普及程度还不高,我希望这本书能为国内Kotlin的推广和普及贡献一份力量。

其实,这次编写第3版对我来说挑战还是蛮大的,因为我要在这本书里同时讲两门技术:Kotlin和Android。Kotlin是Android程序的开发语言,一定得先掌握语言才能开发Android程序,但是如果我们先去学了小半本书的Kotlin语法,然后再开始学Android开发,这一定会非常枯燥。因此我准备将Kotlin和Android穿插在一起讲解,先通过一章的内容带你快速入门Kotlin编程,然后使用目前已掌握的知识开始学习Android开发,之后我们每章都会结合相应章节的内容再学习一些Kotlin的进阶知识,等全部学完本书之后,你将能同时熟练地掌握Kotlin和Android这两门技术。

如果你还想学习如何使用Java来开发Android应用程序,那么请参阅本书的第2版。

2.1 Kotlin语言简介

我想大多数人听说或知道Kotlin的时间并不长,但其实它并不是一门很新的语言。Kotlin是由JetBrains公司开发与设计的,早在2011年,JetBrains就公布了Kotlin的第一个版本,并在2012年将其开源,但在早期,它并没有受到太多的关注。

2016年,Kotlin发布了1.0正式版,这代表着Kotlin已经足够成熟和稳定了,并且JetBrains也在自家的旗舰IDE开发工具IntelliJ IDEA中加入了对Kotlin的支持,自此Android开发语言终于有了另外一种选择,Kotlin逐渐受到广泛的关注。

接下来的事情你已经知道了,2017年Google宣布Kotlin正式成为Android一级开发语言,Android Studio也加入了对Kotlin的支持,Kotlin自此开始大放异彩。

看到这里,或许你会产生一些疑惑:Android操作系统明明是由Google开发的,为什么JetBrains作为一个第三方公司,却能够自己设计出一门编程语言来开发Android应用程序呢?

想要搞懂这个问题,我们得先来探究一下Java语言的运行机制。编程语言大致可以分为两类:编译型语言和解释型语言。编译型语言的特点是编译器会将我们编写的源代码一次性地编译成计算机可识别的二进制文件,然后计算机直接执行,像C和C++都属于编译型语言。解释型语言则完全不一样,它有一个解释器,在程序运行时,解释器会一行行地读取我们编写的源代码,然后实时地将这些源代码解释成计算机可识别的二进制数据后再执行,因此解释型语言通常效率会差一些,像Python和JavaScript都属于解释型语言。

那么接下来我要考你一个问题了,Java是属于编译型语言还是解释型语言呢?对于这个问题,即使是做了很多年Java开发的人也可能会答错。有Java编程经验的人或许会说,Java代码肯定是要先编译再运行的,初学Java的时候都用过javac这个编译命令,因此Java属于编译型语言。如果这也是你的答案的话,那么恭喜你,答错了!虽然Java代码确实是要先编译再运行的,但是Java代码编译之后生成的并不是计算机可识别的二进制文件,而是一种特殊的class文件,这种class文件只有Java虚拟机(Android中叫ART,一种移动优化版的虚拟机)才能识别,而这个Java虚拟机担当的其实就是解释器的角色,它会在程序运行时将编译后的class文件解释成计算机可识别的二进制数据后再执行,因此,准确来讲,Java属于解释型语言。

了解了Java语言的运行机制之后,你有没有受到一些启发呢?其实Java虚拟机并不直接和你编写的Java代码打交道,而是和编译之后生成的class文件打交道。那么如果我开发了一门新的编程语言,然后自己做了个编译器,让它将这门新语言的代码编译成同样规格的class文件,Java虚拟机能不能识别呢?没错,这其实就是Kotlin的工作原理了。Java虚拟机不关心class文件是从Java编译来的,还是从Kotlin编译来的,只要是符合规格的class文件,它都能识别。也正是这个原因,JetBrains才能以一个第三方公司的身份设计出一门用来开发Android应用程序的编程语言。

现在你已经明白了Kotlin的工作原理,但是Kotlin究竟凭借什么魅力能够迅速得到广大开发者的支持,并且仅在1.0版本发布一年后就成为Android官方支持的开发语言呢?

这就有很多原因了,比如说Kotlin的语法更加简洁,对于同样的功能,使用Kotlin开发的代码量可能会比使用Java开发的减少50% 甚至更多。另外,Kotlin的语法更加高级,相比于Java比较老旧的语法,Kotlin增加了很多现代高级语言的语法特性,使得开发效率大大提升。还有,Kotlin在语言安全性方面下了很多工夫,几乎杜绝了空指针这个全球崩溃率最高的异常,至于是如何做到的,我们在稍后就会学到。

然而Kotlin在拥有众多出色的特性之外,还有一个最为重要的特性,那就是它和Java是100%兼容的。Kotlin可以直接调用使用Java编写的代码,也可以无缝使用Java第三方的开源库。这使得Kotlin在加入了诸多新特性的同时,还继承了Java的全部财富。

那么既然Kotlin和Java之间有这样千丝万缕的关系,学习Kotlin之前是不是必须先会Java呢?我的回答是:如果你掌握了Java再来学习Kotlin,你将会学得更好。如果你没学过Java,但是学过其他编程语言,那么直接学习Kotlin也是可以的,只是可能在某些代码的理解上,相比有Java基础的人会相对吃力一些。而如果你之前没有任何编程基础,那么本书可能不太适合你阅读,建议你还是先从最基础的编程入门书看起。

另外,本书不会讲解任何Java基础方面的知识,所以如果你准备先去学习Java的话,请参考其他相关书。

好了,对Kotlin的介绍就先讲这么多吧,在正式开始学习Kotlin之前,我们先来学习一下如何将一段Kotlin代码运行起来。

2.2 如何运行Kotlin代码

本章的目标是快速入门Kotlin编程,因此我只会讲解Kotlin方面的知识,整个章节都不会涉及Android开发。既然暂时和Android无关了,那么我们首先要解决的一个问题就是怎样独立运行一段Kotlin代码。

方法大概有以下3种,下面逐个进行介绍。

第一种方法是使用IntelliJ IDEA。这是JetBrains的旗舰IDE开发工具,对Kotlin支持得非常好。在IntelliJ IDEA里直接创建一个Kotlin项目,就可以独立运行Kotlin代码了。但是这种方法的缺点是你还要再下载安装一个IDE工具,有点麻烦,因此这里我们就不使用这种方法了。

第二种方法是在线运行Kotlin代码。为了方便开发者快速体验Kotlin编程,JetBrains专门提供了一个可以在线运行Kotlin代码的网站,地址是:https://try.kotlinlang.org,打开网站之后的页面如图2.1所示。

图像说明文字

只要点击一下右上方的“Run”按钮就可以运行这段Kotlin代码了,非常简单。但是在线运行Kotlin代码有一个很大的缺点,就是使用国内的网络访问这个网站特别慢,而且经常打不开,因此为了学习的稳定性着想,我们也不准备使用这种方法。

第三种方法是使用Android Studio。遗憾的是,Android Studio作为一个专门用于开发Android应用程序的工具,只能创建Android项目,不能创建Kotlin项目。但是没有关系,我们可以随便打开一个Android项目,在里面编写一个Kotlin的main()函数,就可以独立运行Kotlin代码了。

这里就直接打开上一章创建的HelloWorld项目吧,首先找到MainActivity所在的位置,如图2.2所示。

图像说明文字

接下来在MainActivity的同级包结构下创建一个LearnKotlin文件。右击com.example.helloworld包→New→Kotlin File/Class,在弹出的对话框中输入“LearnKotlin”,如图2.3所示。点击“OK”即可完成创建。

接下来,我们在这个LearnKotlin文件中编写一个main()函数,并打印一行日志,如图2.4所示。

图像说明文字

你会发现,main()函数的左边出现了一个运行标志的小箭头。现在我们只要点击一下这个小箭头,并且选择第一个Run选项,就可以运行这段Kotlin代码了。运行结果会在Android Studio下方的Run标签中显示,如图2.5所示。

图像说明文字

可以看到,这里成功打印出了Hello Kotlin!这句话,这说明我们的代码执行成功了。

可能你会问,上一章刚刚说到打印日志尽量不要使用println(),而是应该使用Log,为什么这里却还是使用了println()呢?这是因为Log是Android中提供的日志工具类,而我们现在是独立运行的Kotlin代码,和Android无关,所以自然是无法使用Log的。

这就是在Android Studio中独立运行Kotlin代码的方法,后面我们都会使用这种方法来对本章所学的内容进行运行和测试。那么接下来,就让我们正式进入Kotlin的学习吧。

2.3 编程之本:变量和函数

编程语言之多,让人眼花缭乱。你可能不知道,世界上一共诞生过600多门有记录的编程语言,没有记录的那就更多了。这些编程语言基本上共有的特性就是变量和函数。可以说,变量和函数就是编程语言之本。那么本节我们就来学习一下Kotlin中变量和函数的用法。

2.3.1 变量

先来学习变量。在Kotlin中定义变量的方式和Java区别很大,在Java中如果想要定义一个变量,需要在变量前面声明这个变量的类型,比如说int a表示a是一个整型变量,String b表示b是一个字符串变量。而Kotlin中定义一个变量,只允许在变量前声明两种关键字:val和var。

val(value的简写)用来声明一个不可变的变量,这种变量在初始赋值之后就再也不能重新赋值,对应Java中的final变量。

var(variable的简写)用来声明一个可变的变量,这种变量在初始赋值之后仍然可以再被重新赋值,对应Java中的非final变量。

如果你有Java编程经验的话,可能会在这里产生疑惑,仅仅使用val或者var来声明一个变量,那么编译器怎么能知道这个变量是什么类型呢?这也是Kotlin比较有特色的一点,它拥有出色的类型推导机制。

举个例子,我们打开上一节创建的LearnKotlin文件,在main()函数中编写如下代码:

fun main() {
    val a = 10
    println("a = " + a)
}

注意,Kotlin每一行代码的结尾是不用加分号的,如果你写惯了Java的话,在这里得先熟悉一下。

在上述代码中,我们使用val关键字定义了一个变量a,并将它赋值为10,这里a就会被自动推导成整型变量。因为既然你要把一个整数赋值给a,那么a就只能是整型变量,而如果你要把一个字符串赋值给a的话,那么a就会被自动推导成字符串变量,这就是Kotlin的类型推导机制。

现在我们运行一下main()函数,执行结果如图2.6所示,正是我们所预期的。

图像说明文字

但是Kotlin的类型推导机制并不总是可以正常工作的,比如说如果我们对一个变量延迟赋值的话,Kotlin就无法自动推导它的类型了。这时候就需要显式地声明变量类型才行,Kotlin提供了对这一功能的支持,语法如下所示:

val a: Int = 10

可以看到,我们显式地声明了变量a为Int类型,此时Kotlin就不会再尝试进行类型推导了。如果现在你尝试将一个字符串赋值给a,那么编译器就会抛出类型不匹配的异常。

如果你学过Java并且足够细心的话,你可能发现了Kotlin中Int的首字母是大写的,而Java中int的首字母是小写的。不要小看这一个字母大小写的差距,这表示Kotlin完全抛弃了Java中的基本数据类型,全部使用了对象数据类型。在Java中int是关键字,而在Kotlin中Int变成了一个类,它拥有自己的方法和继承结构。表2.1中列出了Java中的每一个基本数据类型在Kotlin中对应的对象数据类型。

图像说明文字

接下来我们尝试对变量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)
}

现在编译器就不会再报错了,重新运行一下代码,结果如图2.7所示。

图像说明文字

可以看到,a的值变成了100,这说明我们的数学运算操作成功了。

这里你可能会产生疑惑:既然val关键字有这么多的束缚,为什么还要用这个关键字呢?干脆全部用var关键字不就好了。其实Kotlin之所以这样设计,是为了解决Java中final关键字没有被合理使用的问题。

在Java中,除非你主动在变量前声明了final关键字,否则这个变量就是可变的。然而这并不是一件好事,当项目变得越来越复杂,参与开发的人越来越多时,你永远不知道一个可变的变量会在什么时候被谁给修改了,即使它原本不应该被修改,这就经常会导致出现一些很难排查的问题。因此,一个好的编程习惯是,除非一个变量明确允许被修改,否则都应该给它加上final关键字。

但是,不是每个人都能养成这种良好的编程习惯。我相信至少有90%的Java程序员没有主动在变量前加上final关键字的意识,仅仅因为Java对此是不强制的。因此,Kotlin在设计的时候就采用了和Java完全不同的方式,提供了val和var这两个关键字,必须由开发者主动声明该变量是可变的还是不可变的。

那么我们应该什么时候使用val,什么时候使用var呢?这里我告诉你一个小诀窍,就是永远优先使用val来声明一个变量,而当val没有办法满足你的需求时再使用var。这样设计出来的程序会更加健壮,也更加符合高质量的编码规范。

2.3.2 函数

不少刚接触编程的人对于函数和方法这两个概念有些混淆,不明白它们有什么区别。其实,函数和方法就是同一个概念,这两种叫法都是从英文翻译过来的,函数翻译自function,方法翻译自method,它们并没有什么区别,只是不同语言的叫法习惯不一样而已。而因为Java中方法的叫法更普遍一些,Kotlin中函数的叫法更普遍一些,因此本书里可能会交叉使用两种叫法,你只要知道它们是同一种东西就可以了,不用在这个地方产生疑惑。

函数是用来运行代码的载体,你可以在一个函数里编写很多行代码,当运行这个函数时,函数中的所有代码会全部运行。像我们前面使用过的main()函数就是一个函数,只不过它比较特殊,是程序的入口函数,即程序一旦运行,就是从main()函数开始执行的。

但是只有一个main()函数的程序显然是很初级的,和其他编程语言一样,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()的函数,该函数的作用很简单,接收两个整型参数,然后总是返回两个参数中更大的那个数。

注意,上述代码中使用了一个max()函数,这是Kotlin提供的一个内置函数,它的作用就是返回两个参数中更大的那个数,因此我们的largerNumber()函数其实就是对max()函数做了一层封装而已。

现在你可以开始在LearnKotlin文件中实现largerNumber()这个函数了,当你输入“max”这个单词时,Android Studio会自动弹出如图2.8所示的代码提示。

图像说明文字

Android Studio拥有非常智能的代码提示和补全功能,通常你只需要键入部分代码,它就能自动预测你想要编写的内容,并给出相应的提示列表。我们可以通过上下键在提示列表中移动,然后按下“Enter”键,Android Studio就会自动帮我们进行代码补全了。

这里我非常建议你经常使用Android Studio的代码补全功能,可能有些人觉得全部手敲更有成就感,但是我要提醒一句,使用代码补全功能后,Android Studio不仅会帮我们补全代码,还会帮我们自动导包,这一点是很重要的。比如说上述的max()函数,如果你全部手敲出来,那么这个函数一定会提示一个红色的错误,如图2.9所示。

出现这个错误的原因是你没有导入max()函数的包。当然,导包的方法也有很多种,你将光标移动到这个红色的错误上面就能看到导包的快捷键提示,但是最好的做法就是使用Android Studio的代码补全功能,这样导包工作就自动完成了。

现在我们使用代码补全功能再来编写一次max()函数,你会发现LearnKotlin文件的头部自动导入了一个max()函数的包,并且不会再有错误提示了,如图2.10所示。

图像说明文字

导包实际上属于Java的基础知识,但是鉴于本书上一版出版后,有小部分读者反馈按照书上的代码编写之后却提示错误,其实就是没有正确导包导致的,因此这里我特意加上了Android Studio代码补全功能的说明,希望你后面可以多多利用这个功能,就再也没有导包的困扰了。

现在largerNumber()函数已经编写好了,接下来我们可以尝试在main()函数中调用这个函数,并且实现在两个数中找到较大的那个数这样一个简单的功能,代码如下所示:

fun main() {
    val a = 37
    val b = 40
    val value = largerNumber(a, b)
    println("larger number is " + value)
}

fun largerNumber(num1: Int, num2: Int): Int {
    return max(num1, num2)
}

这段代码很简单,我们定义了a、b两个变量,a的值是37,b的值是40,然后调用largerNumber()函数,并将a、b作为参数传入。largerNumber()函数会返回这两个变量中较大的那个数,最后将返回值打印出来。现在运行一下代码,结果如图2.11所示。程序正如我们预期的那样运行了。

图像说明文字

这就是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)

可能你会觉得,函数只有一行代码的情况并不多嘛,这个语法糖也不会很常用吧?其实并不是这样的,因为它还可以结合Kotlin的其他语言特性一起使用,对简化代码方面的帮助很大,后面我们会慢慢学习它更多的使用场景。

2.4 程序的逻辑控制

程序的执行语句主要分为3种:顺序语句、条件语句和循环语句。顺序语句很好理解,就是代码一行一行地往下执行就可以了,但是这种“愣头青”的执行方式在很多情况下并不能满足我们的编程需求,这时就需要引入条件语句和循环语句了,下面我们逐个进行介绍。

2.4.1 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 num2

怎么样?通过一个简单的if语句,我们挖掘出了Kotlin这么多好玩的语法特性,现在你应该能逐渐体会到Kotlin的魅力了吧?

2.4.2 when条件语句

接下来我们开始学习when。Kotlin中的when语句有点类似于Java中的switch语句,但它又远比 switch语句强大得多。

如果你熟悉Java的话,应该知道Java中的switch语句并不怎么好用。首先,switch只能传入整型或短于整型的变量作为条件,JDK 1.7之后增加了对字符串变量的支持,但如果你的判断逻辑使用的并非是上述几种类型的变量,那么不好意思,switch并不适合你。其次,switch中的每个case条件都要在最后主动加上一个break,否则执行完当前case之后会依次执行下面的case,这一特性曾经导致过无数奇怪的bug,就是因为有人忘记添加break。

而Kotlin中的when语句不仅解决了上述痛点,还增加了许多更为强大的新特性,有时候它比if语句还要简单好用,现在我们就来学习一下吧。

我准备带你编写一个查询考试成绩的功能,输入一个学生的姓名,返回该学生考试的分数。我们先用上一小节学习的if语句来实现这个功能,在LearnKotlin文件中编写如下代码:

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语句和if语句一样,也是可以有返回值的,因此我们仍然可以使用单行代码函数的语法糖。

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型,就将该类型打印出来,否则就打印不支持该参数的类型。

现在我们可以尝试在main()函数中调用checkNumber()函数,如下所示:

fun main() {
    val num = 10
    checkNumber(num)
}

这里向checkNumber()函数传入了一个Int型参数。运行一下程序,结果如图2.12所示。

图像说明文字

可以看到,这里成功判断出了参数是Int类型。

而如果我们将参数改为Long型:

fun main() {
    val num = 10L
    checkNumber(num)
}

重新运行一下程序,结果如图2.13所示。

图像说明文字

很显然,我们的程序并不支持此类型的参数。

when语句的基本用法就是这些,但其实when语句还有一种不带参数的用法,虽然这种用法可能不太常用,但有的时候却能发挥很强的扩展性。

拿刚才的getScore()函数举例,如果我们不在when语句中传入参数的话,还可以这么写:

fun getScore(name: String) = when {
    name == "Tom" -> 86
    name == "Jim" -> 77
    name == "Jack" -> 95
    name == "Lily" -> 100
    else -> 0
}

可以看到,这种用法是将判断的表达式完整地写在when的结构体当中。注意,Kotlin中判断字符串或对象是否相等可以直接使用==关键字,而不用像Java那样调用equals()方法。可能你会觉得这种无参数的when语句写起来比较冗余,但有些场景必须使用这种写法才能实现。举个例子,假设所有名字以Tom开头的人,他的分数都是86分,这种场景如果用带参数的when语句来写就无法实现,而使用不带参数的when语句就可以这样写:

fun getScore(name: String) = when {
    name.startsWith("Tom") -> 86
    name == "Jim" -> 77
    name == "Jack" -> 95
    name == "Lily" -> 100
    else -> 0
}

现在不管你传入的名字是Tom还是Tommy,只要是以Tom开头的名字,他的分数就是86分。

通过这一小节的学习,相信你也发现了,Kotlin中的when语句相比于Java中的switch语句要灵活很多,希望你能多写多练,并熟练掌握它的用法。

2.4.3 循环语句

学习完了条件语句之后,接下来我们开始学习Kotlin中的循环语句。

熟悉Java的人应该都知道,Java中主要有两种循环语句:while循环和for循环。而Kotlin也提供了while循环和for循环,其中while循环不管是在语法还是使用技巧上都和Java中的while循环没有任何区别,因此我们就直接跳过不进行讲解了。如果你没有学过Java也没有关系,只要你学过C、C++ 或其他任何主流的编程语言,它们的while循环用法基本是相同的。

下面我们开始学习Kotlin中的for循环。

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)
    }
}

这就是for-in循环最简单的用法了,我们遍历了区间中的每一个元素,并将它打印出来。现在运行一下程序,结果如图2.14所示。

图像说明文字

但是在很多情况下,双端闭区间却不如单端闭区间好用。为什么这么说呢?相信你一定知道数组的下标都是从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的效果。现在重新运行一下代码,结果如图2.15所示。

图像说明文字

可以看到,现在区间中所有奇数的元素都被跳过了。结合step关键字,我们就能够实现一些更加复杂的循环逻辑。

不过,前面我们所学习的 .. 和until关键字都要求区间的左端必须小于等于区间的右端,也就是这两种关键字创建的都是一个升序的区间。如果你想创建一个降序的区间,可以使用downTo关键字,用法如下:

fun main() {
    for (i in 10 downTo 1) {
        println(i)
    }
}

这里我们创建了一个[10, 1]的降序区间,现在重新运行一下代码,结果如图2.16所示。

图像说明文字

另外,降序区间也是可以结合step关键字跳过区间中的一些元素的,这里我就不进行演示了,你可以自己动手试一试。

for-in 循环除了可以对区间进行遍历之外,还可以用于遍历数组和集合,关于集合这部分内容,我们在本章后面的部分就会学到,到时候再延伸for-in循环的相关用法。

如果让我总结一下的话,我觉得for-in循环并没有传统的for-i循环那样灵活,但是却比for-i循环要简单好用得多,而且足够覆盖大部分的使用场景。如果有一些特殊场景使用for-in循环无法实现的话,我们还可以改用while循环的方式来进行实现。

好了,关于Kotlin的循环部分就先讲这么多吧。

2.5 面向对象编程

和很多现代高级语言一样,Kotlin也是面向对象的,因此理解什么是面向对象编程对我们来说就非常重要了。关于面向对象编程的解释,你可以去看很多标准化、概念化的定义,但是我觉得那些定义只有本来就懂的人才能看得懂,而不了解面向对象的人,即使看了那些定义还是不明白什么才是面向对象编程。

因此,这里我想用自己的理解来向你解释什么是面向对象编程。不同于面向过程的语言(比如C语言),面向对象的语言是可以创建类的。类就是对事物的一种封装,比如说人、汽车、房屋、书等任何事物,我们都可以将它封装一个类,类名通常是名词。而类中又可以拥有自己的字段和函数,字段表示该类所拥有的属性,比如说人可以有姓名和年龄,汽车可以有品牌和价格,这些就属于类中的字段,字段名通常也是名词。而函数则表示该类可以有哪些行为,比如说人可以吃饭和睡觉,汽车可以驾驶和保养等,函数名通常是动词。

通过这种类的封装,我们就可以在适当的时候创建该类的对象,然后调用对象中的字段和函数来满足实际编程的需求,这就是面向对象编程最基本的思想。当然,面向对象编程还有很多其他特性,如继承、多态等,但是这些特性都是建立在基本的思想之上的,理解了基本思想之后,其他的特性我们可以在后面慢慢学习。

2.5.1 类与对象

现在我们就按照刚才所学的基本思想来尝试进行面向对象编程。首先创建一个Person类。右击com.example.helloworld包→New→Kotlin File/Class,在弹出的对话框中输入“Person”。对话框在默认情况下自动选中的是创建一个File,File通常是用于编写Kotlin顶层函数和扩展函数的,我们可以点击展开下拉列表进行切换,如图2.17所示。

图像说明文字

这里选中Class表示创建一个类,点击“OK”完成创建,会生成如下所示的代码:

class Person {
}

这是一个空的类实现,可以看到,Kotlin中也是使用class关键字来声明一个类的,这一点和Java一致。现在我们可以在这个类中加入字段和函数来丰富它的功能,这里我准备加入name和age字段,以及一个eat()函数,因为任何一个人都有名字和年龄,也都需要吃饭。

class Person {
    var name = ""
    var age = 0

    fun eat() {
        println(name + " is eating. He is " + age + " years old.")
    }
}

简单解释一下,这里使用var关键字创建了name和age这两个字段,这是因为我们需要在创建对象之后再指定具体的姓名和年龄,而如果使用val关键字的话,初始化之后就不能再重新赋值了。接下来定义了一个eat()函数,并在函数中打印了一句话,非常简单。

Person类已经定义好了,接下来我们看一下如何对这个类进行实例化,代码如下所示:

val p = Person()

Kotlin中实例化一个类的方式和Java是基本类似的,只是去掉了new关键字而已。之所以这么设计,是因为当你调用了某个类的构造函数时,你的意图只可能是对这个类进行实例化,因此即使没有new关键字,也能清晰表达出你的意图。Kotlin本着最简化的设计原则,将诸如new、行尾分号这种不必要的语法结构都取消了。

上述代码将实例化后的类赋值到了p这个变量上面,p就可以称为Person类的一个实例,也可以称为一个对象。

下面我们开始在main()函数中对p对象进行一些操作:

fun main() {
    val p = Person()
    p.name = "Jack"
    p.age = 19
    p.eat()
}

这里将p对象的姓名赋值为Jack,年龄赋值为19,然后调用它的eat()函数,运行结果如图2.18所示。

图像说明文字

这就是面向对象编程最基本的用法了,简单概括一下,就是要先将事物封装成具体的类,然后将事物所拥有的属性和能力分别定义成类中的字段和函数,接下来对类进行实例化,再根据具体的编程需求调用类中的字段和方法即可。

2.5.2 继承与构造函数

现在我们开始学习面向对象编程中另一个极其重要的特性——继承。继承也是基于现实场景总结出来的一个概念,其实非常好理解。比如现在我们要定义一个Student类,每个学生都有自己的学号和年级,因此我们可以在Student类中加入sno和grade字段。但同时学生也是人呀,学生也会有姓名和年龄,也需要吃饭,如果我们在Student类中重复定义name、age字段和eat()函数的话就显得太过冗余了。这个时候就可以让Student类去继承Person类,这样Student就自动拥有了Person中的字段和函数,另外还可以定义自己独有的字段和函数。

这就是面向对象编程中继承的思想,很好理解吧?接下来我们尝试用Kotlin语言实现上述功能。右击com.example.helloworld包→New→Kotlin File/Class,在弹出的对话框中输入“Student”,并选择创建一个Class,你可以通过上下按键快速切换创建类型。

点击“OK”完成创建,并在Student类中加入学号和年级这两个字段,代码如下所示:

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类当中,它一定会提示如图2.19所示的错误。

图像说明文字

这里出现错误的原因也很明显,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提供了一个给函数设定参数默认值的功能,基本上可以替代次构造函数的作用,我们会在本章最后学习这部分内容。但是考虑到知识结构的完整性,我决定还是介绍一下次构造函数的相关知识,顺便探讨一下括号问题在次构造函数上的区别。

你要知道,任何一个类只能有一个主构造函数,但是可以有多个次构造函数。次构造函数也可以用于实例化一个类,这一点和主构造函数没有什么不同,只不过它是有函数体的。

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的继承和构造函数的问题探究得比较深,同时这也是很多人新上手Kotlin时比较难理解的部分,希望你能好好掌握这部分内容。

2.5.3 接口

上一小节的内容比较长,也偏复杂一些,可能学起来有些辛苦。本小节的内容就简单多了,因为Kotlin中的接口部分和Java几乎是完全一致的。

接口是用于实现多态编程的重要组成部分。我们都知道,Java是单继承结构的语言,任何一个类最多只能继承一个父类,但是却可以实现任意多个接口,Kotlin也是如此。

我们可以在接口中定义一系列的抽象行为,然后由具体的类去实现。下面还是通过具体的代码来学习一下,首先创建一个Study接口,并在其中定义几个学习行为。右击com.example.helloworld包→New→Kotlin File/Class,在弹出的对话框中输入“Study”,创建类型选择“Interface”。

然后在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()
}

这里为了向你演示一下多态编程的特性,我故意将代码写得复杂了一点。首先创建了一个Student类的实例,本来是可以直接调用该实例的readBooks()和doHomework()函数的,但是我没有这么做,而是将它传入到了doStudy()函数中。doStudy()函数接收一个Study类型的参数,由于Student类实现了Study接口,因此Student类的实例是可以传递给doStudy()函数的,接下来我们调用了Study接口的readBooks()和doHomework()函数,这种就叫作面向接口编程,也可以称为多态。

现在运行一下代码,结果如图2.20所示。

图像说明文字

这样我们就将Kotlin中接口的用法基本学完了,是不是很简单?不过为了让接口的功能更加灵活,Kotlin还增加了一个额外的功能:允许对接口中定义的函数进行默认实现。其实Java在JDK 1.8之后也开始支持这个功能了,因此总体来说,Kotlin和Java在接口方面的功能仍然是一模一样的。

下面我们学习一下如何对接口中的函数进行默认实现,修改Study接口中的代码,如下所示:

interface Study {
    fun readBooks()

    fun doHomework() {
        println("do homework default implementation.")
    }
}

可以看到,我们给doHomework()函数加上了函数体,并且在里面打印了一行日志。如果接口中的一个函数拥有了函数体,这个函数体中的内容就是它的默认实现。现在当一个类去实现Study接口时,只会强制要求实现readBooks()函数,而doHomework()函数则可以自由选择实现或者不实现,不实现时就会自动使用默认的实现逻辑。

现在回到Student类当中,你会发现如果我们删除了doHomework()函数,代码是不会提示错误的,而删除readBooks()函数则不行。当删除了doHomework()函数之后,重新运行main()函数,结果如图2.21所示。可以看到,程序正如我们所预期的那样运行了。

图像说明文字

现在你已经掌握了Kotlin面向对象编程中最主要的一些内容,接下来我们再学习一个和Java相比变化比较大的部分——函数的可见性修饰符。

熟悉Java的人一定知道,Java中有public、private、protected和default(什么都不写)这4种函数可见性修饰符。Kotlin中也有4种,分别是public、private、protected和internal,需要使用哪种修饰符时,直接定义在fun关键字的前面即可。下面我详细介绍一下Java和Kotlin中这些函数可见性修饰符的异同。

首先private修饰符在两种语言中的作用是一模一样的,都表示只对当前类内部可见。public修饰符的作用虽然也是一致的,表示对所有类都可见,但是在Kotlin中public修饰符是默认项,而在Java中default才是默认项。前面我们定义了那么多的函数,都没有加任何的修饰符,所以它们默认都是public的。protected关键字在Java中表示对当前类、子类和同一包路径下的类可见,在Kotlin中则表示只对当前类和子类可见。Kotlin抛弃了Java中的default可见性(同一包路径下的类可见),引入了一种新的可见性概念,只对同一模块中的类可见,使用的是internal修饰符。比如我们开发了一个模块给别人使用,但是有一些函数只允许在模块内部调用,不想暴露给外部,就可以将这些函数声明成internal。关于模块开发的内容,我们会在本书的最后一章学习。

表2.2更直观地对比了Java和Kotlin中函数可见性修饰符之间的区别。

图像说明文字

2.5.4 数据类与单例类

在面向对象编程这一节,我们已经学习了很多的知识,那么在本节的最后我们再来了解几个Kotlin中特有的知识点,从而圆满完成本节的学习任务。

在一个规范的系统架构中,数据类通常占据着非常重要的角色,它们用于将服务器端或数据库中的数据映射到内存中,为编程逻辑提供数据模型的支持。或许你听说过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来实现就会变得极其简单,右击com.example. helloworld包→New→Kotlin File/Class,在弹出的对话框中输入“Cellphone”,创建类型选择“Class”。然后在创建的类中编写如下代码:

data class Cellphone(val brand: String, val price: Double)

你没看错,只需要一行代码就可以实现了!神奇的地方就在于data这个关键字,当在一个类前面声明了data关键字时,就表明你希望这个类是一个数据类,Kotlin会根据主构造函数中的参数帮你将equals()、hashCode()、toString()等固定且无实际逻辑意义的方法自动生成,从而大大减少了开发的工作量。

另外,当一个类中没有任何代码时,还可以将尾部的大括号省略。

下面我们来测试一下这个数据类,在main()函数中编写如下代码:

fun main() {
    val cellphone1 = Cellphone("Samsung", 1299.99)
    val cellphone2 = Cellphone("Samsung", 1299.99)
    println(cellphone1)
    println("cellphone1 equals cellphone2 " + (cellphone1 == cellphone2))
}

这里我们创建了两个Cellphone对象,首先直接将第一个对象打印出来,然后判断这两个对象是否相等。运行一下程序,结果如图2.22所示。

图像说明文字

很明显,Cellphone数据类已经正常工作了。而如果Cellphone类前面没有data这个关键字,得到的会是截然不同的结果。如果感兴趣的话,你可以自己动手尝试一下。

掌握了数据类的使用技巧之后,接下来我们再来看另外一个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("singletonTest is called.");
    }
}

这段代码其实很好理解,首先为了禁止外部创建Singleton的实例,我们需要用private关键字将Singleton的构造函数私有化,然后给外部提供了一个getInstance()静态方法用于获取Singleton的实例。在getInstance()方法中,我们判断如果当前缓存的Singleton实例为null,就创建一个新的实例,否则直接返回缓存的实例即可,这就是单例模式的工作机制。

而如果我们想调用单例类中的方法,也很简单,比如想调用上述的singletonTest()方法,就可以这样写:

Singleton singleton = Singleton.getInstance();
singleton.singletonTest();

虽然Java中的单例实现并不复杂,但是Kotlin明显做得更好,它同样是将一些固定的、重复的逻辑实现隐藏了起来,只暴露给我们最简单方便的用法。

在Kotlin中创建一个单例类的方式极其简单,只需要将class关键字改成object关键字即可。现在我们尝试创建一个Kotlin版的Singleton单例类,右击com.example.helloworld包→New→Kotlin File/Class,在弹出的对话框中输入“Singleton”,创建类型选择“Object”,点击“OK”完成创建,初始代码如下所示:

object Singleton {
}

现在Singleton就已经是一个单例类了,我们可以直接在这个类中编写需要的函数,比如加入一个singletonTest()函数:

object Singleton {
    fun singletonTest() {
        println("singletonTest is called.")
    }
}

可以看到,在Kotlin中我们不需要私有化构造函数,也不需要提供getInstance()这样的静态方法,只需要把class关键字改成object关键字,一个单例类就创建完成了。而调用单例类中的函数也很简单,比较类似于Java中静态方法的调用方式:

Singleton.singletonTest()

这种写法虽然看上去像是静态方法的调用,但其实Kotlin在背后自动帮我们创建了一个Singleton类的实例,并且保证全局只会存在一个Singleton实例。

这样我们就将Kotlin面向对象编程最主要的知识掌握了,这也是非常充实的一节内容,希望你能好好掌握和消化。要知道,你往后的编程工作基本上是建立在面向对象编程的基础之上的。

2.6 Lambda编程

可能很多Java程序员对于Lambda编程还比较陌生,但其实这并不是什么新鲜的技术。许多现代高级编程语言在很早之前就开始支持Lambda编程了,但是Java却直到JDK 1.8之后才加入了Lambda编程的语法支持。因此,大量早期开发的Java和Android程序其实并未使用Lambda编程的特性。

而Kotlin从第一个版本开始就支持了Lambda编程,并且Kotlin中的Lambda功能极为强大,我甚至认为Lambda才是Kotlin的灵魂所在。不过,本章只是Kotlin的入门章节,我不可能在这短短一节里就将Lambda的方方面面全部覆盖。因此,这一节我们只学习一些Lambda编程的基础知识,而像高阶函数、DSL等高级Lambda技巧,我们会在本书的后续章节慢慢学习。

2.6.1 集合的创建与遍历

集合的函数式API是用来入门Lambda编程的绝佳示例,不过在此之前,我们得先学习创建集合的方式才行。

传统意义上的集合主要就是List和Set,再广泛一点的话,像Map这样的键值对数据结构也可以包含进来。List、Set和Map在Java中都是接口,List的主要实现类是ArrayList和LinkedList,Set的主要实现类是HashSet,Map的主要实现类是HashMap,熟悉Java的人对这些集合的实现类一定不会陌生。

现在我们提出一个需求,创建一个包含许多水果名称的集合。如果是在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循环不仅可以用来遍历区间,还可以用来遍历集合。现在我们就尝试一下使用for-in循环来遍历这个水果集合,在main()函数中编写如下代码:

fun main() {
    val list = listOf("Apple", "Banana", "Orange", "Pear", "Grape")
    for (fruit in list) {
        println(fruit)
    }
}

运行一下代码,结果如图2.23所示。

图像说明文字

不过需要注意的是,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循环对集合进行遍历。现在重新运行一下代码,结果如图2.24所示。

图像说明文字

可以看到,新添加到集合中的水果已经被成功打印出来了。

前面我们介绍的都是List集合的用法,实际上Set集合的用法几乎与此一模一样,只是将创建集合的方式换成了setOf()和mutableSetOf()函数而已。大致代码如下:

val set = setOf("Apple", "Banana", "Orange", "Pear", "Grape")
for (fruit in set) {
    println(fruit)
}

需要注意,Set集合底层是使用hash映射机制来存放数据的,因此集合中的元素无法保证有序,这是和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进行添加和读取数据操作,而是更加推荐使用一种类似于数组下标的语法结构,比如向Map中添加一条数据就可以这么写:

map["Apple"] = 1

而从Map中读取一条数据就可以这么写:

val number = map["Apple"]

因此,上述代码经过优化过后就可以变成如下形式:

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)

这里的键值对组合看上去好像是使用to这个关键字来进行关联的,但其实to并不是关键字,而是一个infix函数,我们会在本书第9章的Kotlin课堂中深入探究infix函数的相关内容。

最后再来看一下如何遍历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)
    }
}

这段代码主要的区别在于,在for-in循环中,我们将Map的键值对变量一起声明到了一对括号里面,这样当进行循环遍历时,每次遍历的结果就会赋值给这两个键值对变量,最后将它们的值打印出来。重新运行一下代码,结果如图2.25所示。

图像说明文字

好了,关于集合的创建与遍历就学到这里,接下来我们开始学习集合的函数式API,从而正式入门Lambda编程。

2.6.2 集合的函数式API

集合的函数式API有很多个,这里我并不打算带你涉猎所有函数式API的用法,而是重点学习函数式API的语法结构,也就是Lambda表达式的语法结构。

首先我们来思考一个需求,如何在一个水果集合里面找到单词最长的那个水果?当然这个需求很简单,也有很多种写法,你可能会很自然地写出如下代码:

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,就可以让这个功能变得更加容易:

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就是一小段可以作为参数传递的代码。从定义上看,这个功能就很厉害了,因为正常情况下,我们向某个函数传参时只能传入变量,而借助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 }

怎么样?通过一步步推导的方式,我们就得到了和一开始那段函数式API一模一样的写法,是不是现在理解起来就非常轻松了呢?

正如本小节开头所说的,这里我们重点学习的是函数式API的语法结构,理解了语法结构之后,集合中的各种其他函数式API都是可以快速掌握的。

接下来我们就再来学习几个集合中比较常用的函数式API,相信这些对于现在的你来说,应该是没有什么困难的。

集合中的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表示式中指定将单词转换成了大写模式,然后遍历这个新生成的集合。运行一下代码,结果如图2.26所示。

图像说明文字

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)
    }
}

可以看到,这里同时使用了filter和map函数,并通过Lambda表示式将水果单词长度限制在5个字母以内。重新运行一下代码,结果如图2.27所示。

图像说明文字

另外值得一提的是,上述代码中我们是先调用了filter函数再调用map函数。如果你改成先调用map函数再调用filter函数,也能实现同样的效果,但是效率就会差很多,因为这样相当于要对集合中所有的元素都进行一次映射转换后再进行过滤,这是完全不必要的。而先进行过滤操作,再对过滤后的元素进行映射转换,就会明显高效得多。

接下来我们继续学习两个比较常用的函数式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个字母以内。现在重新运行一下代码,结果如图2.28所示。

图像说明文字

这样我们就将Lambda表达式的语法结构和几个常用的函数式API的用法都学习完了,虽然集合中还有许多其他函数式API,但是只要掌握了基本的语法规则,其他函数式API的用法只要看一看文档就能掌握了,相信这对你来说并不是难事。

2.6.3 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()

如果你将上述代码写到main()函数中并执行,就会得到如图2.29所示的结果。

图像说明文字

或许你会觉得,既然本书中所有的代码都是使用Kotlin编写的,这种Java函数式API应该并不常用吧?其实并不是这样的,因为我们后面要经常打交道的Android SDK还是使用Java语言编写的,当我们在Kotlin中调用这些SDK接口时,就很可能会用到这种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 {
}

可以看到,使用这种写法,代码明显精简了很多。这段给按钮注册点击事件的代码,我们在正式开始学习Android程序开发之后将会经常用到。

最后提醒你一句,本小节中学习的Java函数式API的使用都限定于从Kotlin中调用Java方法,并且单抽象方法接口也必须是用Java语言定义的。你可能会好奇为什么要这样设计。这是因为Kotlin中有专门的高阶函数来实现更加强大的自定义函数式API功能,从而不需要像Java这样借助单抽象方法接口来实现。关于高阶函数的用法,我们会在本书的第6章进行学习。

2.7 空指针检查

我之前看过某国外机构做的一个统计,Android系统上崩溃率最高的异常类型就是空指针异常(NullPointerException)。相信不只是Android,其他系统上也面临着相同的问题。若要分析其根本原因的话,我觉得主要是因为空指针是一种不受编程语言检查的运行时异常,只能由程序员主动通过逻辑判断来避免,但即使是最出色的程序员,也不可能将所有潜在的空指针异常全部考虑到。

我们来看一段非常简单的Java代码:

public void doStudy(Study study) {
    study.readBooks();
    study.doHomework();
}

这是我们在2.5.3小节编写过的一个doStudy()方法,我将它翻译成了Java版。这段代码没有任何复杂的逻辑,只是接收了一个Study参数,并且调用了参数的readBooks()和doHomework()方法。

这段代码安全吗?不一定,因为这要取决于调用方传入的参数是什么,如果我们向doStudy()方法传入了一个null参数,那么毫无疑问这里就会发生空指针异常。因此,更加稳妥的做法是在调用参数的方法之前先进行一个判空处理,如下所示:

public void doStudy(Study study) {
    if (study != null) {
        study.readBooks();
        study.doHomework();
    }
}

这样就能保证不管传入的参数是什么,这段代码始终都是安全的。

由此可以看出,即使是如此简单的一小段代码,都有产生空指针异常的潜在风险,那么在一个大型项目中,想要完全规避空指针异常几乎是不可能的事情,这也是它高居各类崩溃排行榜首位的原因。

2.7.1 可空类型系统

然而,Kotlin却非常科学地解决了这个问题,它利用编译时判空检查的机制几乎杜绝了空指针异常。虽然编译时判空检查的机制有时候会导致代码变得比较难写,但是不用担心,Kotlin提供了一系列的辅助工具,让我们能轻松地处理各种判空情况。下面我们就逐步开始学习吧。

还是回到刚才的doStudy()函数,现在将这个函数再翻译回Kotlin版本,代码如下所示:

fun doStudy(study: Study) {
    study.readBooks()
    study.doHomework()
}

这段代码看上去和刚才的Java版本并没有什么区别,但实际上它是没有空指针风险的,因为Kotlin默认所有的参数和变量都不可为空,所以这里传入的Study参数也一定不会为空,我们可以放心地调用它的任何函数。如果你尝试向doStudy()函数传入一个null参数,则会提示如图2.30所示的错误。

图像说明文字

也就是说,Kotlin将空指针异常的检查提前到了编译时期,如果我们的程序存在空指针异常的风险,那么在编译的时候会直接报错,修正之后才能成功运行,这样就可以保证程序在运行时期不会出现空指针异常了。

看到这里,你可能产生了巨大的疑惑,所有的参数和变量都不可为空?这可真是前所未闻的事情,那如果我们的业务逻辑就是需要某个参数或者变量为空该怎么办呢?不用担心,Kotlin提供了另外一套可为空的类型系统,只不过在使用可为空的类型系统时,我们需要在编译时期就将所有潜在的空指针异常都处理掉,否则代码将无法编译通过。

那么可为空的类型系统是什么样的呢?很简单,就是在类名的后面加上一个问号。比如,Int表示不可为空的整型,而Int?就表示可为空的整型;String表示不可为空的字符串,而String?就表示可为空的字符串。

回到刚才的doStudy()函数,如果我们希望传入的参数可以为空,那么就应该将参数的类型由Study改成Study?,如图2.31所示。

图像说明文字

可以看到,现在在调用doStudy()函数时传入null参数,就不会再提示错误了。然而你会发现,在doStudy()函数中调用参数的readBooks()和doHomework()方法时,却出现了一个红色下滑线的错误提示,这又是为什么呢?

其实原因也很明显,由于我们将参数改成了可为空的Study?类型,此时调用参数的readBooks()和doHomework()方法都可能造成空指针异常,因此Kotlin在这种情况下不允许编译通过。

那么该如何解决呢?很简单,只要把空指针异常都处理掉就可以了,比如做个判断处理,如下所示:

fun doStudy(study: Study?) {
    if (study != null) {
        study.readBooks()
        study.doHomework()
    }
}

现在代码就可以正常编译通过了,并且还能保证完全不会出现空指针异常。

其实学到这里,我们就已经基本掌握了Kotlin的可空类型系统以及空指针检查的机制,但是为了在编译时期就处理掉所有的空指针异常,通常需要编写很多额外的检查代码才行。如果每处检查代码都使用if判断语句,则会让代码变得比较啰嗦,而且if判断语句还处理不了全局变量的判空问题。为此,Kotlin专门提供了一系列的辅助工具,使开发者能够更轻松地进行判空处理,下面我们就来逐个学习一下。

2.7.2 判空辅助工具

首先学习最常用的?.操作符。这个操作符的作用非常好理解,就是当对象不为空时正常调用相应的方法,当对象为空时则什么都不做。比如以下的判空处理代码:

if (a != null) {
    a.doSomething()
}

这段代码使用?.操作符就可以简化成:

a?.doSomething()

了解了?.操作符的作用,下面我们来看一下如何使用这个操作符对doStudy()函数进行优化,代码如下所示:

fun doStudy(study: Study?) {
    study?.readBooks()
    study?.doHomework()
}

可以看到,这样我们就借助?.操作符将if判断语句去掉了。可能你会觉得使用if语句来进行判空处理也没什么复杂的,那是因为目前的代码还非常简单,当以后我们开发的功能越来越复杂,需要判空的对象也越来越多的时候,你就会觉得?.操作符特别好用了。

下面我们再来学习另外一个非常常用的?:操作符。这个操作符的左右两边都接收一个表达式,如果左边表达式的结果不为空就返回左边表达式的结果,否则就返回右边表达式的结果。观察如下代码:

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函数属于Kotlin中的标准函数,在下一章中我们将会学习更多Kotlin标准函数的用法。

你可能就要问了,这个let函数和空指针检查有什么关系呢?其实let函数的特性配合?.操作符可以在空指针检查的时候起到很大的作用。

我们回到doStudy()函数当中,目前的代码如下所示:

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()
    }
}

在结束本小节内容之前,我还得再讲一点,let函数是可以处理全局变量的判空问题的,而if判断语句则无法做到这一点。比如我们将doStudy()函数中的参数变成一个全局变量,使用let函数仍然可以正常工作,但使用if判断语句则会提示错误,如图2.32所示。

图像说明文字

之所以这里会报错,是因为全局变量的值随时都有可能被其他线程所修改,即使做了判空处理,仍然无法保证if语句中的study变量没有空指针风险。从这一点上也能体现出let函数的优势。

好了,最常用的Kotlin空指针检查辅助工具大概就是这些了,只要能将本节的内容掌握好,你就可以写出更加健壮、几乎杜绝空指针异常的代码了。

2.8 Kotlin中的小魔术

到目前为止,我们已经学习了很多Kotlin方面的编程知识,相信现在的你已经有能力进行一些日常的Kotlin开发工作了。在结束本章内容之前,我们再来学习几个魔术类的小技巧,虽说是小技巧,但是相信我,它们一定会给你带来巨大的帮助。

2.8.1 字符串内嵌表达式

字符串内嵌表达式是我认为Java最应该支持的功能,因为大多数现代高级语言是支持这个非常方便的功能的,但是Java直到今天都还不支持,至于为什么,我也想不明白,或许Java的开发团队有不这么做的原因和道理吧。

不过值得高兴的是,Kotlin从一开始就支持了字符串内嵌表达式的功能,弥补了Java在这一点上的遗憾。在Kotlin中,我们不需要再像使用Java时那样傻傻地拼接字符串了,而是可以直接将表达式写在字符串里面,即使是构建非常复杂的字符串,也会变得轻而易举。

本书到目前为止,我都还没有使用过字符串内嵌表达式的写法,一直在使用传统的加号连接符来拼接字符串。在学完本节的内容之后,我们就会永远和加号连接符的写法说“再见”了。

首先来看一下Kotlin中字符串内嵌表达式的语法规则:

"hello, ${obj.name}. nice to meet you!"

可以看到,Kotlin允许我们在字符串里嵌入${}这种语法结构的表达式,并在运行时使用表达式执行的结果替代这一部分内容。

另外,当表达式中仅有一个变量的时候,还可以将两边的大括号省略,如下所示:

"hello, $name. nice to meet you!"

这种字符串内嵌表达式的写法到底有多么方便,我们通过一个具体的例子来学习一下就知道了。在2.5.4小节中,我们用Java编写了一个Cellphone数据类,其中toString()方法里就使用了比较复杂的拼接字符串的写法。这里我将当时的拼接逻辑单独提炼了出来,代码如下:

val brand = "Samsung"
val price = 1299.99
println("Cellphone(brand=" + brand + ", price=" + price + ")")

可以看到,上述字符串中一共使用了4个加号连接符,这种写法不仅写起来非常吃力,很容易写错,而且在代码可读性方面也很糟糕。

而使用字符串内嵌表达式的写法就变得非常简单了,如下所示:

val brand = "Samsung"
val price = 1299.99
println("Cellphone(brand=$brand, price=$price)")

很明显,这种写法不管是在易读性还是易写性方面都更胜一筹,是Kotlin更加推崇的写法。这个小技巧会给我们以后的开发工作带来巨大的便利。

2.8.2 函数的参数默认值

接下来我们开始学习另外一个非常有用的小技巧——给函数设定参数默认值。

其实之前在学习次构造函数用法的时候我就提到过,次构造函数在Kotlin中很少用,因为Kotlin提供了给函数设定参数默认值的功能,它在很大程度上能够替代次构造函数的作用。

具体来讲,我们可以在定义函数的时候给任意参数设定一个默认值,这样当调用此函数时就不会强制要求调用方为此参数传值,在没有传值的情况下会自动使用参数的默认值。

给参数设定默认值的方式也很简单,观察如下代码:

fun printParams(num: Int, str: String = "hello") {
    println("num is $num , str is $str")
}

可以看到,这里我们给printParams()函数的第二个参数设定了一个默认值,这样当调用printParams()函数时,可以选择给第二个参数传值,也可以选择不传,在不传的情况下就会自动使用默认值。

现在我们在main()函数中调用一下printParams()函数来进行测试,代码如下:

fun printParams(num: Int, str: String = "hello") {
    println("num is $num , str is $str")
}

fun main() {
    printParams(123)
}

注意,这里并没有给第二个参数传值。运行一下代码,结果如图2.33所示。

图像说明文字

可以看到,在没有给第二个参数传值的情况下,printParams()函数自动使用了参数的默认值。

当然上面这个例子比较理想化,因为正好是给最后一个参数设定了默认值,现在我们将代码改成给第一个参数设定默认值,如下所示:

fun printParams(num: Int = 100, str: String) {
    println("num is $num , str is $str")
}

这时如果想让num参数使用默认值该怎么办呢?模仿刚才的写法肯定是行不通的,因为编译器会认为我们想把字符串赋值给第一个num参数,从而报类型不匹配的错误,如图2.34所示。

图像说明文字

不过不用担心,Kotlin提供了另外一种神奇的机制,就是可以通过键值对的方式来传参,从而不必像传统写法那样按照参数定义的顺序来传参。比如调用printParams()函数,我们还可以这样写:

printParams(str = "world", num = 123)

此时哪个参数在前哪个参数在后都无所谓,Kotlin可以准确地将参数匹配上。而使用这种键值对的传参方式之后,我们就可以省略num参数了,代码如下:

fun printParams(num: Int = 100, str: String) {
    println("num is $num , str is $str")
}

fun main() {
    printParams(str = "world")
}

重新运行一下程序,结果如图2.35所示。

图像说明文字

现在你已经掌握了如何给函数设定参数默认值,那么为什么说这个功能可以在很大程度上替代次构造函数的作用呢?

回忆一下当初我们学习次构造函数时所编写的代码:

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) {
    }
}

上述代码中有一个主构造函数和两个次构造函数,次构造函数在这里的作用是提供了使用更少参数来对Student类进行实例化的方式。无参的次构造函数会调用两个参数的次构造函数,并将这两个参数赋值成初始值。两个参数的次构造函数会调用4个参数的主构造函数,并将缺失的两个参数也赋值成初始值。

这种写法在Kotlin中其实是不必要的,因为我们完全可以通过只编写一个主构造函数,然后给参数设定默认值的方式来实现,代码如下所示:

class Student(val sno: String = "", val grade: Int = 0, name: String = "", age: Int = 0) : 
         Person(name, age) { 
}

在给主构造函数的每个参数都设定了默认值之后,我们就可以使用任何传参组合的方式来对Student类进行实例化,当然也包含了刚才两种次构造函数的使用场景。

由此可见,给函数设定参数默认值这个小技巧的作用还是极大的。

2.9 小结与点评

本章的内容可着实不少,在这一章里面,我们全面学习了Kotlin编程中最主要的知识点,包括变量和函数、逻辑控制语句、面向对象编程、Lambda编程、空指针检查机制,等等。虽然这还远不足以涵盖Kotlin的所有内容,但是这里我要祝贺你,现在你已经有足够的实力使用Kotlin来学习Android程序开发了。

因此,从下一章开始,我们将正式踏上Android开发学习之旅。不过在这之后的每一章里,我都会结合相应章节的内容穿插讲解一些Kotlin进阶方面的知识,从而让你在Android和Kotlin两方面都能够持续不断地进步。那么稍事休息,让我们继续前行吧!

目录

  • 彩插
  • 前言
  • 第1章 开始启程,你的第一行Android代码
  • 第2章 探究新语言,快速入门Kotlin编程
  • 第3章 先从看得到的入手,探究Activity
  • 第4章 软件也要拼脸蛋,UI开发的点点滴滴
  • 第5章 手机平板要兼顾,探究Fragment
  • 第6章 全局大喇叭,详解广播机制
  • 第7章 数据存储全方案,详解持久化技术
  • 第8章 跨程序共享数据,探究ContentProvider
  • 第9章 丰富你的程序,运用手机多媒体
  • 第10章 后台默默的劳动者,探究Service
  • 第11章 看看精彩的世界,使用网络技术
  • 第12章 最佳的UI体验,Material Design实战
  • 第13章 高级程序开发组件,探究Jetpack
  • 第14章 继续进阶,你还应该掌握的高级技巧
  • 第15章 进入实战,开发一个天气预报App
  • 第16章 编写并发布一个开源库,PermissionX