2

快速入门 Kotlin 编程

面向对象编程

不同于面向过程的语言(比如 C 语言),面向对象的语言是可以创建类的。类就是对事物的一种封装。

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

类与对象

class Person {
    var name = ""
    var age = 0
    
    fun eat() {
        println(name + " is eating. He is " + age + " years old.")
    }
}

val p = Person()
  • Kotlin 中也是使用 class 关键字来声明一个类的
  • 使用 var 关键字创建了 name 和 age 这两个字段,这是因为我们需要在创建对象之后再指定具体的姓名和年龄,而如果使用 val 关键字的话,初始化之后就不能再重新赋值了
  • Kotlin 中实例化一个类的方式和 Java 是基本类似的,只是去掉了 new 关键字而已

继承与构造函数

可以让 Student 类去继承 Person 类,这样 Student 就自动拥有了 Person 中的字段和函数,另外还可以定义自己独有的字段和函数。想要让 Student 类继承 Person 类,我们得做两件事才行

  • 在 Person 类的前面加上 open 关键字使其可以被继承
  • 在 Java 中继承的关键字是 extends,而在 Kotlin 中变成了一个冒号

    class Student : Person() {
      var sno = ""
      var grade = 0
    }

继承这里最麻烦的就是为什么 Person 类的后面要加上一对括号呢?Java 中继承的时候并不需要括号。要知道为什么,我们要先来看一下 Kotlin 中构造函数的一些知识。

任何一个面向对象的编程语言都会有构造函数的概念,Kotlin 中也有,但是 Kotlin 将构造函数分成了两种:主构造函数和次构造函数

主构造函数将会是你最常用的构造函数,每个类默认都会有一个不带参数的主构造函数,当然你也可以显式地给它指明参数。主构造函数的特点是没有函数体,直接定义在类名的后面即可。比如下面这种写法:

class Student(val sno: String, val grade: Int) : Person() {

}

到这里为止都还挺好理解的吧?但是这和那对括号又有什么关系呢?这就涉及了 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 类一定会报错,这里出现错误的原因也很明显,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) {
    ...
}

学到这里,我们就将 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("001", 1, name, age)

    constructor() : this("geely", 24)

}

次构造函数是通过 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 时比较难理解的部分,希望你能好好掌握这部分内容。

接口

Java 中继承使用的关键字是 extends,实现接口使用的关键字是 implements,而 Kotlin 中统一使用冒号,中间用逗号进行分隔。另外接口的后面不用加上括号,因为它没有构造函数可以去调用,我们来看下代码:

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

数据类与单例类

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

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

在 Kotlin 中创建一个单例类的方式极其简单,只需要将 class 关键字改成 object 关键字即可。

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

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

Singleton.singletonTest()

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

Lambda 编程

Kotlin 从第一个版本开始就支持了 Lambda 编程,并且 Kotlin 中的 Lambda 功能极为强大,我甚至认为 Lambda 才是 Kotlin 的灵魂所在。

不过,本章只是 Kotlin 的入门章节,我不可能在这短短一节里就将 Lambda 的方方面面全部覆盖。因此,这一节我们只学习一些 Lambda 编程的基础知识,而像高阶函数、DSL 等高级 Lambda 技巧,我们会在本书的后续章节慢慢学习。

集合的创建与遍历

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

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

Map 是一种键值对形式的数据结构,因此在用法上和 List、Set 集合有较大的不同。

Kotlin 中并不建议使用 put() 和 get() 方法来对 Map 进行添加和读取数据操作,而是更加推荐使用一种类似于数组下标的语法结构,比如向 Map 中添加一条数据就可以这么写:

map["Apple"] = 1

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

val number = map["Apple"]

当然,这仍然不是最简便的写法,因为 Kotlin 毫无疑问地提供了一对 mapOf() 和 mutableMapOf() 函数来继续简化 Map 的用法。在 mapOf() 函数中,我们可以直接传入初始化的键值对组合来完成对 Map 集合的创建:

val map = mapOf("Apple" to 1, "Banana" to 2, "Orange" to 3, "Pear" to 4, "Grape" to 5)

集合的函数式 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}
prinln("max length fruit is $maxLengthFruit")

上述代码使用的就是函数式 API 的用法,只用一行代码就能找到集合中单词最长的那个水果。或许你现在理解这段代码还比较吃力,那是因为我们还没有开始学习 Lambda 表达式的语法结构,等学完之后再来重新看这段代码时,你就会觉得非常简单易懂了。

首先来看一下 Lambda 的定义,如果用最直白的语言来阐述的话,Lambda 就是一小段可以作为参数传递的代码。从定义上看,这个功能就很厉害了,因为正常情况下,我们向某个函数传参时只能传入变量,而借助 Lambda 却允许传入一小段代码。这里两次使用了“一小段代码”这种描述,那么到底多少代码才算一小段代码呢?Kotlin 对此并没有进行限制,但是通常不建议在 Lambda 表达式中编写太长的代码,否则可能会影响代码的可读性。

接着我们来看一下 Lambda 表达式的语法结构:

{参数名1: 参数类型, 参数名2: 参数类型 -> 函数体}

这是 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 参数是完全按照刚才学习的表达式的语法结构来定义的,因此这段代码应该算是比较好懂的。

这种写法虽然可以正常工作,但是比较啰嗦,可简化的点也非常多,下面我们就开始对这段代码一步步进行简化。

  1. 首先,我们不需要专门定义一个 lambda 变量,而是可以直接将 lambda 表达式传入 maxBy 函数当中,因此第一步简化如下所示:

    val maxLengthFruit = list.maxBy({ fruit: String -> fruit.length })
  2. 然后 Kotlin 规定,当 Lambda 参数是函数的最后一个参数时,可以将 Lambda 表达式移到函数括号的外面,如下所示:

    val maxLengthFruit = list.maxBy() { fruit: String -> fruit.length }
  3. 接下来,如果 Lambda 参数是函数的唯一一个参数的话,还可以将函数的括号省略:

    val maxLengthFruit = list.maxBy{ fruit: String -> fruit.length }
  4. 这样代码看起来就变得清爽多了吧?但是我们还可以继续进行简化。由于 Kotlin 拥有出色的类型推导机制,Lambda 表达式中的参数列表其实在大多数情况下不必声明参数类型,因此代码可以进一步简化成:

    val maxLengthFruit = list.maxBy{ fruit -> fruit.length }
  5. 最后,当 Lambda 表达式的参数列表中只有一个参数时,也不必声明参数名,而是可以使用 it 关键字来代替,那么代码就变成了:

    val maxLengthFruit = list.maxBy { it.length }

    怎么样?通过一步步推导的方式,我们就得到了和一开始那段函数式 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 表示式中编写你需要的逻辑即可。

接下来我们再来学习另外一个比较常用的函数式 API——filter 函数。顾名思义,filter 函数是用来过滤集合中的数据的,它可以单独使用,也可以配合刚才的 map 函数一起使用。

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

空指针检查

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

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

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

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

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

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

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

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

可空类型系统

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

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

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

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

1626921774(1).jpg

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

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

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

1626921875(1).jpg

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

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

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

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

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

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

判空辅助工具

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

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

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

a?.doSomething()

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

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

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

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 判断语句则会提示错误,如下图所示。

1626922292(1).jpg

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

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

Kotlin 中的小魔术

字符串模板

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

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

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

函数的参数默认值

上述代码中有一个主构造函数和两个次构造函数,次构造函数在这里的作用是提供了使用更少参数来对 Student 类进行实例化的方式。无参的次构造函数会调用两个参数的次构造函数,并将这两个参数赋值成初始值。两个参数的次构造函数会调用 4 个参数的主构造函数,并将缺失的两个参数也赋值成初始值。这种写法在 Kotlin 中其实是不必要的,因为我们完全可以通过只编写一个主构造函数,然后给参数设定默认值的方式来实现,代码如下所示:

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

Maenj_Ba_lah
28 声望5 粉丝

真正的大师,永远怀着一颗学徒的心。