Kotlin的类型系统

可空性

可空性是Kotlin类型系统中帮助你避免NPE(NullPointerException)错误的特性。

现代编程语言包括Kotlin解决这类问题的方法是把运行时的错误转变成编译期的错误。通过支持作为类型系统的一部分的可空性,编译器就能在编译期发现很多潜在的错误,从而减少运行时抛出异常的可能性。

Java也有一些帮助解决NPE错误的方案。

  • @Nullable@NotNull注解可用来表示值的可空性。有些工具(如IntelliJ IDEA)可利用这些注解来发现可能抛出NPE的位置,但这些工具都不是标准Java编译过程的一部分。

  • 使用Java8中引入的Optional类型来表示这个值可能没有被定义。但这种方式会使代码变得冗长,额外的包装接口还会影响运行时的性能。

可空类型

Kotlin和Java的类型系统之间最大的区别是:Kotlin对可空类型的显式支持。

可空类型是任何类型的后面加?。这样表示这个类型可以存储对应类型的数据,也可以存储null引用。即Type? = Type or null。而没有?的类型是不允许存储null引用,即所有常见类型默认都是非空的。

一个可空类型的值,对它进行的操作会受到限制。

  • 不能再调用它的方法。

  • 不能把它赋值给非空类型的变量。

  • 不能让它作为非空类型的参数的实参。

Kotlin的可空类型并不是非空类型的包装。类型的检查是发生在编译期,这意味着可空类型并不会在运行时带来额外的开销。

安全调用运算符?.

安全调用运算符?.会把一次null检查和一次方法调用合并成一个操作。例如,s?.toUpperCase()等同于if (s != null) s.toUpperCase() else null

安全调用-w350

?.不仅可用来调用方法,也能用来访问属性。

可把多个安全调用链接在一起。val country = company?.address?.country

注意?.的结果为可空类型。

Elvis运算符?:

Elvis运算符?:接收两个运算数,如果第一个运算数不为null,运算结果就是第一个运算数,否则就是第二个运算数。

Elvis运算符-w348

fun Person.countryName() = 
    company?.address?.country ?: "Unknown"
1
2
fun strLenSafe(s: String?): Int = s?.length ?: 0
1

Elvis运算符的一种常用场景是:把return或throw操作写在Elvis运算符的右边,当Elvis运算符左边的值为null时,函数就立即返回一个默认值或者抛出一个异常。

fun printShippingLabel(person: Person) {
    val address = person.company?.address
        ?: throw IllegalArgumentException("No address")
}
1
2
3
4

安全转换运算符as?

和常规的Java类型转换一样,如果被转换的值不是你试图转换的类型,就会抛出ClassCastException异常。

类型转换时,可先使用is来检查类型,再使用as来转换类型。其实可使用Kotlin的安全转换运算符as?来优雅地实现。

as?运算符尝试把值转换成指定的类型,如果值不是合适的类型就返回null。

安全转换-w493

一种常见的模式是把安全转换as?和Elvis运算符?:结合使用。

class Person(val name: String) {
    override fun equals(o: Any?): Boolean {
        val other = 0 as? Person ?: return false
        
        return other.name == name
    }
}
1
2
3
4
5
6
7

非空断言!!

非空断言!!可以把任何值转换成非空类型。如果对null值做非空断言,则会抛出异常。

非空断言-w561

非空断言可以连续调用。

person.company!!.address!!country
1

但若发生异常时,异常调用栈的跟踪信息只表明异常发生在哪一行代码,而不会表明异常发生在哪一个表达式。为了让跟踪信息更精确地显示出哪个值为null,最好避免在同一行中使用多个!!

let函数

let函数会把一个调用它的对象变成lambda表达式的参数。如果结合安全调用运算符?.,它能把调用let函数的可空对象转变成非空类型。

let函数和安全调用结合使用-w519

let函数和安全调用结合使用,能让一个可空对象作为一个接受非空参数的函数的实参。

fun sendEmail(email: String) {
    println("send $email")
}

var email: String? = "daking@qq.com"
email?.let { sendEmail(it) }
1
2
3
4
5
6

延迟初始化的属性

使用lateinit修饰符来声明一个延迟初始化的属性。

private lateinit var service: MyService
1

注意,延迟初始化的属性都是var,因为需要在构造方法外修改它的值。而val属性会被编译成必须在构造方法中初始化的final字段。

尽管lateinit属性是非空类型,但是你不需要在构造方法中初始化它。如果在该属性被初始化之前就访问它,会得到异常“lateinit property xxx has not been initialized”。

lateinit属性常用于依赖注入,它的值被依赖注入框架从外部设置。为了保证和各种Java依赖注入框架的兼容性,Kotlin会自动生成一个和lateinit属性具有相同可见性的字段。

可空类型的扩展

为可空类型定义扩展函数是一种更强大的处理null值的方式。它可以允许接收者为null的调用,并在该函数中处理null,而不是在确保变量不为null之后再调用它的方法。

Kotlin标准库中定义的扩展函数isNullOrBlank,它的接收者为String?

fun String?.isNullOrBlank(): Boolean = 
    this == null || this.isBlank()
1
2

在Java中,this永远是非空的,因为它引用的是当前你所在的类的实例。而在Kotlin中,在可空类型的扩展函数中,this可以为null。

不需要安全访问,可以直接调用为可空接收者声明的扩展函数。

fun verifyUserInput(input: String?) {
    if (input.isNullOrBlank()) {
        println("Please fill in the required fields")
    }
}
1
2
3
4
5

类型参数的可空性

Kotlin中所有泛型类和泛型函数的类型参数默认都是可空的。任何类型,包括可空类型在内,都可以替换类型参数。

fun <T> printHashCode(t: T) {
    println(t?.hashCode()) // 因为t可能为null,所以必须使用安全调用
}

printHashCode(null) // T被推导成Any?
1
2
3
4
5

要使类型参数为非空,必须要为它指定一个非空的上界,那样泛型会拒绝可空值作为实参。

fun <T: Any> printHashCode(t: T) {
    println(t.hashCode())
}

printHashCode(null) // T的上界为Any,T是非空的
1
2
3
4
5

可空性和Java

可空性注解

根据Java代码中的注解,Java类型会在Kotlin中表示为可空类型和非空类型。

Java注解与Kotlin类型

Kotlin可以识别多种不同风格的可空性注解。

  • JSR-305标准的注解,位于javax.annotation包之中。

  • Android注解,位于android.support.annotation包之中。

  • JetBrains工具支持的注解,位于org.jetbrains.annotations包之中。

平台类型

如果Java代码中不存在这些可空性注解,Java类型会变成Kotlin中的平台类型。平台类型本质上就是Kotlin不知道可空性信息的类型,即可以把它当做可空类型处理,也可以当做非空类型处理。

/* person.name为Java属性 */
val s: String? = person.name
val s: String = person.name
1
2
3

平台类型

在Kotlin中不能声明一个平台类型的变量,这些类型只能来自Java代码。

继承

在Kotlin中重写Java的方法时,可以选择把参数和返回类型定义成可空的,也可以选择把它们定义成非空的。

/* Java */
interface StringProcessor {
    void process(String value);
}
1
2
3
4
class StringPrinter : StringProcessor {
    override fun process(value: String?) {
        println(value ?: "value is null")
    }
}
1
2
3
4
5

在实现Java类或接口的方法时一定要分析清楚它的可空性,因为方法的实现可以在非Kotlin代码中被调用。Kotlin编译器会为你声明的每一个非空的参数生成非空断言,如果Java代码传给这个方法一个null值,断言就会触发,最终得到一个异常。

基本数据类型

基本数据类型

Java为基本数据类型提供了对应的包装类型,在需要对象时对基本数据类型进行封装。例如,不能用Collection<int>来定义一个整数的集合,而必须用Collection<Integer>

Kotlin不区分基本数据类型和包装类型,都是使用同一个类型。例如,Int。

val i: Int = 1
val list: List<Int> = listOf(1, 2, 3)
1
2

但这并不意味着Kotlin使用对象来表示所有的基本数据类型。

  • 大多数情况下,对于变量、属性、参数和返回类型,Kotlin的基本数据类型会被编译成Java基本数据类型。例如,Int -> int。

  • 用作泛型类型参数的基本数据类型会被编译成对应的Java包装类型。例如,Int -> Integer。

可空的基本数据类型

Kotlin中的基本数据类型对应的可空类型,因为它们能存储null,所以只能将它们编译成对应的Java包装类型。

数字转换

Kotlin不会自动地把数字从一种类型转换成另外一种,即便是转换成范围更大的类型。Kotlin要求你显式地转换类型。

val i = 1 // 类型推导为Int
val l: Long = i // 错误:类型不匹配,Kotlin不会自动转换
val l: Long = i.toLong() // 正确,显式转换
1
2
3

每一种基本数据类型(Boolean除外)都定义有转换函数:toByte()toShort()toChar()等。这些函数支持双向转换。

Kotlin书写数字字面值的方式:

  • 使用十进制数字表示Int:10。

  • 使用后缀L表示Long:10L。

  • 使用标准浮点数表示Double:0.12、2.0、1.2e10。

  • 使用后缀F表示Float:123.4f。

  • 使用前缀0x或0X表示十六进制字面值。

  • 使用前缀0b或0B表示二进制字面值。

当你使用数字字面值去初始化一个类型已知的变量,或是把字面值作为实参传给函数时,必要的转换会自动发生。此外,算术运算符也被重载成可接收所有适当的数字类型。

Kotlin标准库提供了一套相似的扩展方法,用来把字符串转换成基本数据类型,如toInttoLong等。这些函数都会尝试把字符串解析成对应的类型,如果解析失败则抛出NumberFormatException

Any和Any?

在Kotlin中,Any是所有类型的超类型,包括像Int这样的基本数据类型。

在底层,Any类型对应java.lang.Object

  • Kotlin把Java方法参数和返回类型中用到的Object类型看作Any

  • 当Kotlin函数使用Any时,它会被编译成Java字节码中的Object

Any是非空类型,所以它不能存储null值。而Any?是可空类型,能够持有任何可能值,包括null值。

Unit类型

Kotlin中的Unit类型完成了Java中的void一样的功能。

Unitvoid的区别是:Unit是一个完备的类型,可以作为类型参数,而void不行。Unit类型只有一种值,就是Unit

Unit作为函数的返回类型时,如果没有重写泛型函数,在底层它会被编译成旧的void函数;如果重写泛型函数,函数中会隐式地返回Unit值。

interface Processor<T> {
    fun process(): T
}
1
2
3
class NoResultProcessor :  Processor<Unit> {
    override fun process() {
        // 这里会隐式地返回Unit,不需要显式的return
    }
}
1
2
3
4
5

Nothing类型

Nothing类型没有任何值,只有被当作函数返回值使用,或者被当作泛型函数返回值的类型参数使用才会有意义。

返回类型为Nothing的函数表示该函数永远不会成功地结束。

fun fail(msg: String): Nothing {
    throw IllegalStateException(msg)
}
1
2
3

返回Nothing的函数可以放在Elvis运算符的右边来做先决条件检查。

val address = company.address ?: fail("No address")
1

集合

Kotlin的集合是以Java集合库为基础构建,并通过扩展函数增加集合的特性。

可空性和集合

集合变量自己类型的可空性和用作类型参数的类型的可空性是有区别的。例如,包含可空Int的列表和包含Int的可空列表之间的区别如下图。

List<Int?>与List?-w436

要分清楚是集合的元素可空(List<Int?>),还是集合本身可空(List<Int>?,又或者是两者都可空(List<Int?>?

遍历一个包含可空值的集合并过滤掉null是一个常见的操作,Kotlin提供了一个标准库函数filterNotNull来完成这个事情。

只读集合和可变集合

Kotlin的集合设计与Java的最大不同是:它把访问集合数据的接口和修改集合数据的接口分开了。

  • kotlin.collections.Collection接口是只读集合的基础接口,可使用它来遍历集合、获取集合大小、判断集合中是否包含某个元素。但该接口没有任何添加或删除元素的方法。

  • kotlin.collections.MutableCollection接口继承Collection,可使用它来添加和删除元素、清空集合等。

就像valvar之前的分离一样,只读集合接口与可变集合接口的分离能让程序中更容易理解。例如,如果函数接收Collection表示不会修改集合;如果函数接收MutableCollection表示将会修改数据。

fun <T> copyElements(source: Collection<T>,
                    target: MutableCollection<T>) {
    for (item in source) 【
        target.add(item)
    }
}
1
2
3
4
5
6

注意,不能把只读集合类型的变量作为可变集合类型参数的实参,即使它的值是一个可变集合。

val source: Collection<Int> = arrayListOf(3, 5, 7)
val target: Collection<Int> = arrayListOf(1)
// 错误。即使target的值为可变集合,但变量的类型声明为只读集合,不能作为可变集合类型参数的实参。
copyElements(source, target)
1
2
3
4

只读集合不一定是不可变的。如果你使用的变量拥有一个只读接口类型,它可能只是同一个集合的众多引用中的一个,任何其他的引用都可能拥有一个可变接口类型。

同一个集合的多个引用-w383

Kotlin集合和Java

每一个Kotlin集合实例都是其对应Java集合接口的一个实例,在Kotlin和Java之间转移并不需要转换,不需要包装器也不需要拷贝数据。

但是每一种Java集合接口在Kotlin中都有两种表示:只读的和可变的。

只读的和可变的集合接口-w589

Kotlin中只读接口和可变接口的基本结构与java.util包中的Java集合接口的结构是平行的。可变接口直接对应java.util包中的接口,而它们的只读版本缺少了所有产生改变的方法。

除了集合之外,Kotlin中Map类(它并没有继承Collection或Iterable)也被表示成两种不同的版本:Map和MutableMap。

Kotlin标准库定义了一系列函数用来创建不同类型的集合。

集合类型 只读 可变
List listOf mutableListOfarrayListOf
Set setOf mutableSetOfhashSetOflinkedSetOfsortedSetOf
Map mapOf mutableMapOfhashMapOflinkedMapOfsortedMapOf

Java并不会区分只读集合与可变集合,即使Kotlin中把集合声明成只读的,Java代码也能够修改这个集合。例如,调用一个形参为Collection的Java方法,可把CollectionMutableCollection的值作为实参传递。

作为平台类型的集合

上面讨论过“平台类型”,同样,Java中声明的集合类型也被视为平台类型。

  • 如果Java代码中不存在可空性注解,Java类型会变成Kotlin中的平台类型。平台类型本质上就是Kotlin不知道可空性信息的类型,即可以把它当做可空类型处理,也可以当做非空类型处理。

在重写或实现签名中有集合类型的Java方法时,可根据需要自行决定使用哪种Kotlin类型,主要考虑如下几点:

  • 集合是否可空。

  • 集合中的元素是否可空。

  • 你的方法会不会修改集合。

数组

Kotlin的集合的函数式API也同样适用于数组。

对象数组

Kotlin中的一个数组是一个带有类型参数的类,其元素类型被指定为相应的类型参数。

在Kotlin中创建数组,可使用如下方法:

  • arrayOf函数创建一个数组,它包含的元素是指定为该函数的实参。

  • arrayOfNulls创建一个给定大小的数组,包含的是null元素。当然,它只能用来创建包含元素类型可空的数组。

  • Array构造方法接收数组的大小和一个lambda表达式,调用lambda表达式来创建每一个元素。

/* 创建包含指定元素的数组 */
val numbers = arrayOf(1, 2, 3, 4, 5)
/* 创建大小为5,元素全为null的数组。元素类型为Int? */
val list = arrayOfNulls<Int>(5)
/* 创建从a到z的字符串数组 */
val letters = Array<String>(26) { i -> ('a' + i).toString() }
1
2
3
4
5
6

Kotlin数组类型的类型参数始终会变成对象类型。比如,你声明了一个Array<Int>,它将会是一个包含装箱整型的数组(即它的Java类型将是Integer[])。

基本数据类型的数组

如果你需要创建没有装箱的基本数据类型的数组,必须使用一个基本数据类型数组的特殊类。例如,IntArrayLongArray等,它们分别对应普通的Java基本数据类型数组,如int[]long[]等。

要创建一个基本数据类型的数组,可使用如下方法:

  • 该类型的构造方法接收size参数,并返回一个使用对应基本数据类型默认值初始化好的数组。

  • 工厂函数(比如IntArray的intArrayOf)接收变长参数的值,并创建存储这些值的数组。

  • 该类型的另一种构造方法,接收一个size参数和一个用来初始化每个元素的lambda。

val intArr = IntArray(5)
val intArr = intArrayOf(0, 0, 0, 0, 0)
val intArr = IntArray(5) { i -> i * i } // 0, 1, 4, 9, 16
1
2
3

假如你有一个持有基本数据类型装箱后的值的数组或集合,可用对应的转换函数把它们转换成基本数据类型的值,比如toIntArray()