[关闭]
@SR1s 2018-02-01T14:10:16.000000Z 字数 6750 阅读 1545

Kotlin学习梳理总结(1): Kotlin的类型系统

Kotlin


周末两天突击学习了下Kotlin,凭记忆做些总结。

可空性是Kotlin类型系统的一部分

可空性

Kotlin里有一个很重要的特性:可空性。顾名思义,可空性就是一个变量是否能为空的意思。在Java,只有基本类型是不可为空。本质上来说,基本类型的变量存的是值,而对象类型的变量,存的是引用。值不可空,引用可空。

据说,80%的bug是低级错误造成的,而这里面,又有80%是空指针异常(NullPointerException)。数据怎么来的,是否准确,已经无从考证。这说明,空指针异常是那么常见那么低级,导致Kotlin语言的设计者坐不住,决定管一管这事了(虽然Java搞了个Optional和了把稀泥,但他们觉得这不OK)。

怎么管?他们决定把可空性单独拉出来,作为一个需要考虑的语言特性来实现。

假设有个类,叫Person,那么在声明变量的时候,Kotlin和Java的差异如下:

是否能为空 Kotlin Java Java + 注解
可空 var p : Person? Person p @Nullable Person p
不可空 var p : Person - @NotNull Person p

在Java里,对象类型的变量就是可空的。而在Kotlin里,可不可空,写代码的时候就要决定了,这么设计意思让我们用的时候心里有点B数。

很好,一开始Java只有一种类型(Person),现在用Kotlin有了俩(PersonPerson?)。好处是,对于非空类型的变量,先可以安全的对它进行各种操作了。Kotlin编译器会在编译的时候检查,确保不可空的变量不会被赋值为null

加赠操作符大礼包

但问题并没有解决啊。存在变量可空的情况(Person?)还是存在呀。如何确保安全地调用没有空指针异常?Kotlin的设计者在这个基础上,又创造了?.这个安全调用运算符。

安全调用运算符干的事情也很简单,就是让你能安全地操作可空类型的变量。像下面这样:

  1. val string : String?
  2. string?.toInt()

实际干的事情就是把两句话用一句话来说:

  1. val string : String?
  2. if (string != null)
  3. string.toInt()

这就是安全调用运算符,它可以放飞自我去连着用,安全又畅快:

  1. var nullable : Type?
  2. nullable1?.nullable2?.nullable3?.nullable4

还有一个是Elvis运算符?:,它有点像Java里的那个三目运算符:(condition) ? true : false,但它又是双目的,只在特定情况下能等价:

  1. /* Java */
  2. (value != null) ? value : "default"
  3. /* Kotlin */
  4. value ?: "default"

那歌怎么唱来着?“简单点,说话的方式,简单点。”

Kotlin还考虑到了转型的情况,给了一个安全转型运算符as?,也是用简单的方式,省掉了冗长的代码:

  1. /* Java */
  2. (object instanceof Person) : (Person) object : null
  3. /* Kotlin */
  4. object as? Person

说完了安全调用相关的操作符,这里还有个非空断言的操作符!!。它的意思是告诉编译器:行了行了,虽然这里写着可空(Person?),但老子确定这里坑定肯定不为空,就不劳神您检查了。编译器内心:mmp,后果自负!!(咆哮状):

  1. val p : Person? = null
  2. Person!!.age

总结一下,在可空性这方面,有这么4个相关的操作符:

操作符 名称 示例
?. 安全调用 p?.age
?: Elvis p?: "default"
as? 安全转换 p as? Person
!! 非空断言 p!!.age

标准库提供的拓展函数

让使用者写出优雅的代码是Kotlin语言的设计目标。可空性及相关的4个操作符是这种追求的产物。然而设计者依然觉得,不够OK。他们在Kotlin的标准库里,添加一系列的拓展函数,这里介绍了一个拓展函数let

  1. /* 函数原型 */
  2. inline fun <T, R> T.let(block: (T) -> R) : R
  3. /* 示例 */
  4. p?.let {
  5. it.age
  6. println(it)
  7. }

类似的拓展函数还有applywithrun等,也同样通过?.安全调用操作符来访问。

  1. /* apply */
  2. inline fun <T> T.apply(block: T.() -> Unit): T
  3. /* apply 示例 */
  4. p?.apply {
  5. age
  6. // 这个this就是上面的p的非空版本,可以理解为p!!
  7. println(this)
  8. }.age
  9. /* with */
  10. inline fun <T, R> with(receiver: T, block: T.() -> R): R
  11. /* with 示例 */
  12. with(p?) {
  13. age
  14. println(this) // this same as p!!
  15. }
  16. /* run */
  17. inline fun <T, R> run(block: T.() -> R): R
  18. /* run 示例 */
  19. p?.run {
  20. age
  21. println(this) // this same as p!!
  22. }

这4个拓展函数粗略看起来没有什么差异,实际是有些微不同的,这里不提。

lateinit: 调和初始化变量和不可空性

写代码的时候,往往会有这样的场景:先声明一个变量,后面再进行初始化。遇到这种情况,常规的做法是声明变量为不可空,像下面这样:

  1. class Foo {
  2. var id: String? = null
  3. fun init(_id: String) {
  4. id = _id
  5. id?.toInt()
  6. }
  7. }

这么做也就享受不到不可空带来的安全感了。Kotlin显然不想妥协。为此他们发明了lateinit这个关键字。

lateinit是和var连用的。为什么是var而不是val也很简单,能延迟初始化,只能是变量。用了lateinit之后的代码是这样的:

  1. class Foo {
  2. lateinit var id: String
  3. fun init(_id: String) {
  4. id = _id
  5. id.toInt()
  6. }
  7. }

lateinit var声明的变量,存在着一段空期——从变量声明到触发初始化这段时间,变量为空。在这个时间段访问变量,会抛出一个属性初始化前访问的异常。

这么说Kotlin并没有解决问题可空性的问题?对这种情况,我是这么理解的:lateinit是为了确保不可空性而发明的,也就是说,虽然延迟初始化,但它依旧是不能为空的!任何使用这个变量的代码也是带着“这个变量不会为空”的意图来存取它的。因此,写代码的时候也必须确保这个前提条件成立,如果不能确保,那运行时就只能暴力不合作了。不能保证这个前提条件成立的代码,其实是有Bug的,与其内部做判空保护性处理,不如抛出异常,让外部更正错误调用。这么想的话,Kotlin团队真的很严格。

可空类型的其他场景

说完了可空性,可空性相关的方法调用,可空性相关的拓展函数以及调和可空性和延迟初始化的矛盾,接下来是高级点的用法:为可空类型加拓展,如:

  1. fun String?.isBlank(): Boolean

这个拓展函数里的this是可空的,因此在访问前需要做判空处理。其实就像定义了一个static boolean isBlank(String str)函数,实现需要兼容入参为空的场景。

上边说到可空性,往往会伴随着?符号,因此,有?的地方,都是可空。还有一种情况,是没有?但可空:使用泛型的时候。

  1. fun <T> printHashCode(t: T) {
  2. println(t?.hashCode())
  3. }
  4. val str: String? = null
  5. printHashCode(str)

上述这种场景,上边的泛型函数理解为如下的定义,也就不难理解这种特性了:

  1. fun printHashCode(str: String?) {
  2. println(str?.hashCode())
  3. }

另外,由于Kotlin和Java代码的互操作性,在调用Java方法、重载Java方法时,可空性是不确定的:

  1. /* Java */
  2. String getName() {
  3. return null;
  4. }
  5. void setName(String name);
  6. /* Kotlin */
  7. @Override
  8. fun setName(name: String?)

Kotlin调用第一个Java方法的时候是不知道其返回值是否可空。Kotlin重载第二个Java方法的时候,也不知道其入参是否可空。

这种情况,Kotlin没有做其他处理,Kotlin将这种情况下的类型成为平台类型,是否可空由代码编写者自行判定。如果确定调用方不会返回/传入null,可以声明为非空类型,如果觉得有可能传入null,那就不妨声明为可空类型。

Kotlin的基本数据类型和基本类型

基本数据类型

Kotlin有别于Java,在它的类型系统里,一切都是对象,没有intIntegerbooleanBooleanlongLong这样的区别,在使用这些基本数据类型的时候,不需要纠结到底是使用字面量类型和装箱类型(对象),Kotlin编译器会自动按需转换成最优的类型。

可以这么理解:

Kotlin类型 Java 类型 Kotlin类型 Java类型
Boolean boolean Boolean? Boolean
Byte byte Byte? Byte
Char char Char? Character
Short short Short? Short
Int int Int? Integer
Long long Long? Long
Float float Float? Float
Double double Double? Double

另外,Kotlin不会自动做类型转换,如Int不会自动转成Long,在将Int赋值给Long型变量时候,会报错误,需要主动调用转换方法,也即是说11L是两个不同类型,不同的数。

插句题外话,Kotlin为String类型定义了一系列转换相关的拓展方法,用来转换字符串成基本数据类型,像toInt()toBoolean()toLong()等。

根类型:Object和Any、Any?

在Java里,所有对象的类型(基本类型除外)的基类都是Object,在Kotlin里,所有非空类型(含基本类型)的基类都是Any,所有非空类型(含基本类型)的基类都是Any?

其他基本类型:Unit、Nothing

Kotlin有一个Unit类型,对应Java里的void。虽然类似,但也有差别。在Java里,有void命令字和Void类型,Kotlin里只有Unit。在Java里,void的取值只能为null,在Kotlin里,Unit的值,有且只有Unit。Kotlin里,表示void的含义相比Java更加清晰和统一。

Kotlin还有一个特别的类型Nothing,作为那些“永不返回”的函数的返回类型。这个类型应该是用于标识那些故意设计为不会正常跑完的函数(Nothing用于表明函数的意图):

  1. // 只会抛出异常,不会正常结束的函数
  2. fun fail(message: String) {
  3. throw IllegalStateException(message)
  4. }
  5. // 可以放在Elvis运算符的右边,而不必担心类型不匹配
  6. val address = person.address ?: fail("No address")
  7. // 一个故意设计为无限循环的函数
  8. fun main(): Nothing {
  9. while(true) {
  10. // other code...
  11. }
  12. }

集合和数组

时断时续三天,终于掰扯到最后一Part。

集合和可空性

首先是集合的声明,涉及可空性的情况下,有4种组合:

声明类型 含义
list: List<Int> 数组list不可空,数组内的元素也不可空
list: List<Int>? 数组list可空,数组内的元素不可空
list: List<Int?> 数组list不可空,数组内的元素可空
list: List<Int?>? 数组list可空,数组内的元素也可空

核心弄明白其中的差异,在使用的时候准确合理地选对类型。Kotlin编译器就能依此在编译的时候做校验。

另外,考虑到在可空的集合里过滤null是一个常见的需求,Kotlin的标准库提供了filterNotNull函数,经过这个函数处理,集合的类型会转换成非空的版本,即(Collection<Type?> -> Collection<Type>):

  1. val containNullList: List<Int?> = listOf(1, 2, 3, null)
  2. val validList = containNullList.filterNotNull() // List<Int>

只读集合和可变集合

在集合上,Kotlin又一次做了一个和Java不一样的设计。Kotlin把集合分为了只读集合和可变集合,kotlin.collections.Collection集合只提供了读取集合相关属性、访问元素的接口,但不包含修改集合相关的接口。kotlin.collections.MutableCollection接口继承自kotlin.collections.Collection接口,增加了修改(添加、移除、清空)集合相关的接口。

Kotlin设计者发现,大多数情况下,代码提供集合只是为了传递数据,而只有少数地方才真正需要修改集合。为了保证数据的安全性,避免接收数据方操作集合带来的意外,他们根据可变性,对集合做了区分,在语言层面上实现了UnmodifiableCollection的特性,避免了额外的开销。

但只读集合并不是线程安全的。传递过来的集合,虽然类型声明上描述的是只读,但传递方创建的时候可能是可变集合。在多线程情况下,传递方做数据修改,而接收方进行数据访问,则可能会导致ConcurrentModificationException。对这种场景,需要选用支持并发访问的数据结构。

用于创建集合的函数有:

集合类型 只读 可变
List listOf mutableListOf、arrayListOf
Set setOf mutableSetOf、hashSetOf、linkedSetOf、sortedSetOf
Map mapOf mutableMapOf、hashMapOf、linkedMapOf、sortedMaoOf

由于Kotlin和Java的互操作性,集合也存在平台类型,调用的时候,需要自行决定如何理解其可空性。

数组

数据也是一种常见的数据结构,不同于Java用Type[]来声明数组,Kotlin采用Array<Type>作为声明数组的方式。一般的main函数定义,在Kotlin里是这样:

  1. fun main(args: Array<String>) {
  2. // do something
  3. }

创建数组有这么几个常见的方法:

  1. arrayOf(t1: T, t2: T, T...)
  2. arrayOfNulls(num: Int)
  3. Array<Type>(num: Int, block: (i: Int) -> Type)

第一个方法,用给定的参数,创建数组;第二个方法,创建一个指定长度的数组,并用null初始化每个元素;第三个方法,创建一个指定长度数组,并用给定的lambda表达式,根据下标生成对应位置的元素。

另外,Kotlin还为集合创建了转换成数组的拓展函数,非常方便:

  1. val strings = listOf("a", "b", "c")
  2. strings.toTypedArray()

另外,用Array<Int>方式声明的数组,会生成对象类型的数组,即Integer类型。为了表示基本数据类型的数组,Kotlin提供了独立的类来承载:IntArrayLongArrayByteArrayCharArray等。

同样的,有几个常用的创建基本数据类型数组的方法:

  1. IntArray(num: Int)
  2. intArrayOf(i1: Int, i2: Int, Int...)
  3. IntArray(num: Int, block: (Int) -> Int)

含义和前者差不多,只是生成的数组,不是装箱类型的数组。

当然,对于装箱类型的数组,可以调用toIntArray这样的转换函数,将它转换成基本数据类型的数组。

另外,在原有接口的基础上,Kotlin赋予了数组和集合相同的用于数组的拓展函数,如filtermapflatten等(返回值为列表而非数组)。

另外,如果需要遍历数组内的元素,除了for x in array,还可以使用array.forEachIndexed(block: (Int, Type) -> Unit)的方式来访问。

总体来看,Kotlin在Java的类型系统的基础上做了改进和创新,尽可能地在语言层面做规范、约束,提供辅助,让代码质量得保证。

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注