一、本章简介
与java相比,kotlin中引入了一些新特性,他们是提升代码可读性的基本要素,比如:对可空的类型和只读集合的支持。与此同时,kotlin去掉了一些java类型系统中不必要的或者有问题的特性,比如把数组作为头等公民来支持。
二、可空性
可空性是kotlin类型系统中帮助你避免NullPointerException错误的特性。现代编译语言包括kotlin解决空指针这类问题的方法是把运行时的错误转换成编译期的错误。通过支持作为类型系统的一部分的可空性,编译器就能在编译期发现很多潜在的错误,从而减少运行时抛出异常的可能性。
1、可空类型
kotlin和java的类型系统之间第一条也可能是最重要的一条区别是:kotlin对可空类型的显式支持。这意味着:它指出你的程序中哪些变量和属性允许为null的方式。如果一个变量可以为null,对变量的方法的调用就是不安全的,因为这样会导致NullPointerException。kotlin不允许这样的调用,因而可以阻止 许多可能得异常。
重申一下,没有问号的类型表示这种类型的变量不能存储null引用。这说明所有常见类型默认都是非空的,除非显式地把它标记为可空。
一旦你有一个可空类型的值,能对它进行的操作也会受到限制。例如,不能在调用它的方法:
那么你可以对它做什么呢?最重要的操作就是和null进行比较。而且一旦你进行了比较操作,编译器就会记住,并且在这次比较发生的作用域内把这个值当做非空对待。
如果if检查是唯一处理可空性的工具,你的代码很快将会变得冗长。幸运的是,kotlin还提供了其他一些工具来帮助我们用更简洁的方式来处理可空值。
2、类型的含义
以String类型为例,在java中,变量可以持有两种值,分别是String的实例和null。这两种值完全不一样:就连java自己的instanceOf运算符都会告诉你null不是String。这两种值的操作也完全不一样:真实的String实例允许你调用它的任何方法,而null值只允许非常有限的操作。
这说明java的类型系统在这种情况下不能很好地工作。
3、安全调用运算符:“?.”
安全调用符?.允许你把一次null检查和一次方法调用合并成一个操作。例如,表达式s?.toUpperCase()等同于下面这种繁琐的写法:if (s != null) s.toUpperCase() else null。
换句话说,如果你试图调用一个非空值的方法,这个方法调用会被正常执行。但如果是null,这次调用不会发生,而整个表达式的值为null。
注意,这次调用的结果类型可是可空的。尽管String.toUpperCase()会返回String类型的值,但s是可空的时候,表达式s?.toUpperCase()的结果类型是String? :
安全调用不仅可以调用方法,还可以用来访问属性。
如果你的对象图中有多个可空类型的属性,通常可以在同一个表达式中方便地使用多个安全调用。
带null检查的方法调用序列在java代码中太常见了,现在你看到了kotlin可以让它们变得更简洁。
4、Elvis运算符:“?:”
kotlin有方便的运算符来替换代替null的默认值。它被称作为Elvis运算符(或者null合并运算符)
Elvis运算符接收两个运算数,如果第一个运算数不为null,运算结果就是第一个运算数,如果第一个运算数为null,运算结果就是第二个运算数。
Elvis运算符经常和安全调用符一起使用,用一个值代替对null对象调用方法时返回的null。
在kotlin中有一种场景下Elvis运算符会特别顺手,像return和throw这样的操作其实是表达式,因此可以把它们写在Elvis运算符的右边。这种情况下,如果Elvis运算符左边的值为null,函数就会立即返回一个值或抛出一个异常。如果函数中需要检查先决条件,这个方式特别有用。
如果一切正常,函数printShippingLable会打印出标签。如果地址不存在,它不会只是抛出一个带行号的NullPointerException,相反,它会报告一个有意义的错误。如果地址存在,标签会包含街道地址、编码、城市和国家。
现在,你了解了kotlin进行“if非空”检查的方式,我们接下来介绍kotlin中instanceOf检查的安全版本:常常和安全调用及Elvis运算符一起出现的安全转换运算符。
5、安全转换:“as?”
之前学习过kotlin用来转换类型的常规运算符:as运算符。和常规的java类型转换一样,如果被转换的值不是你试图转换的类型,就会抛出ClassCastException异常。当然可以结合is检查来确保这个值拥有合适的类型。但是作为一种安全简洁的语言,kotlin有更优雅的解决方案。
一种常见的模式是把安全转换和Elvis运算符结合使用。例如实现equals方法的时候这样使用非常方便。
使用这种模式,可以非常容易地检查实参是否是适当的类型,转换它,并在它的类型不正确的时候返回false,而且这些操作全部在同一个表达式中。当然,这种场景下智能转换也会生效:当你检查过类型并拒绝了null值,编译器就确定了变量otherPerson值的类型是Person并让你能够相应地使用它。
安全调用、安全转换和Elvis运算符都非常有用,他们出现在kotlin代码中的频率非常高。但有时候你并不需要Kotlin的这些支持来处理null值,你只需要直接告诉编译器这个值实际上并不是null。接下来看看是如何做到这一点的。
6、非空断言:“!!”
非空断言是kotlin提供给你的最简单直率的处理可空类型值的工具。它使用双感叹号表示,可以把任何值转换成非空类型。如果对null值做非空断言,则会抛出异常。
如果上面函数中s为null会发生什么呢?kotlin没有其他选择,它会在运行时抛出一个异常(一种特殊的NullPointerException)。但是注意异常抛出的位置是非空断言所在那一行,而不是接下来试图使用那个值的一行。本质上,你在告诉编译器:“我知道这个值不为null,如果我错了我准备好了接受这个异常”。
但是确实存在这样的情况,某些问题适合用非空断言来解决。当你在一个函数中检查一个值是否为null,而在另一个函数中使用这个值时,这种情况下编译器无法识别这种用法是否安全。如果你确信这样的检查一定在其他某个函数中存在,你可能不想在使用这个值之前重复检查,这时候就可以使用非空断言。
还有一个需要牢记的注意事项,当你使用!!并且它的结果是异常时,异常调用栈的跟踪信息只表明异常发生在哪一行,而不会表明异常发生在哪一个表达式。为了让跟踪信息更清晰准确地表示哪个值为null,最好避免在同一行中使用多个!!断言:
到目前为止,我们讨论的都是如何访问可空类型的值。但是如果你要将一个可空值作为实参传递给一个只接受非空值的函数时,应该怎么办?编译器不允许在没有检查的情况下这样做,因为这样不安全。kotlin语言并没有对这样使用场景的特殊支持,但是标准库函数可以帮到你:这个函数叫作let。
7、let函数
let函数让处理可空表达式变得更容易。和安全调用运算符一起,它允许你对表达式求值,检查求值结果是否为null,并把结果保存为一个变量。所有这些动作都在同一个简洁的表达式中。
可空参数最常见的一种用法应该是被传递给一个接收非空参数的函数。
但你还有一种处理方式:使用let函数,并通过安全调用来调用它。let函数做的所有事情就是把一个调用它的对象变成lambda表达式的参数。如果结合安全调用语法,它能有效地把调用let函数的可空对象,转变成非空类型。
let函数只在email的值非空时才被调用,上面的代码变得更短了:email?.let { sendEmailTo(it) }。
注意,如果有一些很长的表达式结果不为null,而你又要使用这些结果时,let表示法特别方便。在这种情况下你不必创建一个单独的变量。对比一下显式地if检查。
当你需要检查多个值是否为null时,可以用嵌套的let调用来处理。但在大多数情况下,这种代码相当啰嗦又并且难以理解。用普通的if表达式来一次性检查所有值通常更简单。
另外一种常见的情况是,属性最终是非空的,但不能使用非空值在构造方法中初始化。接下来看看kotlin是如何处理这种情况的。
这里还是要看看let函数的源码的。
/**
* Calls the specified function [block] with `this` value as its argument and returns its result.
*
* For detailed usage information see the documentation for [scope functions](https://kotlinlang.org/docs/reference/scope-functions.html#let).
*/
@kotlin.internal.InlineOnly
public inline fun <T, R> T.let(block: (T) -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return block(this)
}
可以看到let函数是任何类型的扩展函数 ,所以任何类型对象都可以调用let函数。并且let函数参数是一个lambda表达式,lambda表达式的参数的类型为调用let函数的类型,返回类型为R其他类型。函数体实际执行逻辑是block(this)。也就是说整个let函数做的事情就是使用把调用let函数的对象传递给lambda表达式作为实参,然后执行lambda表达式。
可以理解为block本质上是接受一个非空类型参数的函数。
原本fun(T)现在变成了T?.let {fun(T)} 。看上去是变得更复杂了。安全调用?.能确保如果T为null就不执行,如果T不为null则执行let函数。所以let函数体内部能确保T不为null,就可以把T传递给一个调用非空参数的函数了。所以let函数必须结合安全调用才有意义。
8、延迟初始化的属性
很多框架会在对象实例创建之后用专门的方法来初始化对象。例如在android中,Activity的初始化就发生在onCreate方法中。
但是你不能在构造方法中完全放弃非空属性的初始化,仅仅在一个特殊的方法里初始化它。kotlin通常要求你在构造方法中初始化所有属性,如果某个属性是非空类型,你就必须提供非空的初始化值。否则,你就必须使用可空类型。如果你这样做,该属性的每一次访问都需要null检查或者!!运算符。
这段代码很难看,尤其是你要反复使用这个属性的时候。为了解决这个问题,可以把myService属性声明成可以延迟初始化的,使用lateinit修饰符来完成这样的声明。
注意,延迟初始化的属性都是var,因为需要在构造函数外修改它,而val属性会被编译成必须在构造方法中初始化的final字段。尽管这个属性是非空类型,但是你不需要在构造方法中初始化它。如果你在属性被初始化之前就访问了它,会得到这个异常“lateinit property myService has not been initialized”。
依赖注入??
9、可空类型的扩展
为可空类型定义扩展函数时一种强大的处理null值的方式。可以允许接收者为null的(扩展函数)调用,并在该函数中处理null,而不是在确保变量不为null之后再调用它的方法。只有扩展函数才能做到这一点,普通成员方法的调用是通过对象实例来分发的,因此实例为null时(成员方法)永远不能被执行。
/**
* Returns `true` if this nullable char sequence is either `null` or empty or consists solely of whitespace characters.
*
* @sample samples.text.Strings.stringIsNullOrBlank
*/
@kotlin.internal.InlineOnly
public inline fun CharSequence?.isNullOrBlank(): Boolean {
contract {
returns(false) implies (this@isNullOrBlank != null)
}
return this == null || this.isBlank()
}
不需要安全访问,可以直接调用为可空接收者声明的扩展函数,这样的函数会处理可能的null值。
函数isNullOrBlank显式地检查了null,这种情况下返回true,然后调用isBlank,它只能在非空String上调用。
当你为一个可空类型(以?结尾)定义扩展函数时,这意味着你可以对可空的值调用这个函数:并且函数体中的this可能为null,所以你必须显式地检查。在java中,this永远是非空的,因为它引用的是当前你所在这类的实例。而在kotlin中,这并不永远成立:在可空类型的扩展函数中,this可以为null。
注意,我们之前讨论的let函数也能被可空的接收者调用,但它并不检查值是否为null。如果你在一个可空类型上直接调用let函数,而没有使用安全调用运算符,lambda的实参将会是可空的。
这一节展示了一些意外的状态,如果你没有使用额外的检查来解引用一个变量,比如s.isNullOrBlank(),它并不会立即意味着变量是非空的:这个函数有可能是非空类型的扩展。
10、类型参数的可空性
kotlin中所有泛型类和泛型函数的类型参数默认都是可空的。任何类型,包括可空类型在内,都可以替换类型参数。这种情况下,使用类型参数作为类型的声明都允许为null,尽管类型参数T并没有用问号结尾。
要使类型参数非空,必须要为它指定一个非空的上界,那样泛型会拒绝可空值作为实参。
注意必须使用问号结尾来标记类型为可空的,没有问号就是非空的。类型参数是这个规则唯一的例外。
11、可空性和java
首先,有些时候java代码包含了可空性的信息,这些信息使用注解来表达。当代码中出现了这样的信息时,kotlin就会使用它。因此java中的@Nullable String被kotlin当做String ?,而@NotNull String就是String。
kotlin可以识别多种不同风格的可空性注解,包括在javax.annotation包之中的注解、Android的注解(android.support.annotation)和JetBrains工具支持的注解(org.jetbrains.annotations)。
如果没有这些注解,java类型会变成Kotlin中的平台类型。
平台类型
package main.part6;
public class Person {
private final String name;
public Person(String name) {
this.name = name;
}
public String getName() {
return name;
}
}
getName能不能返回null?这种情况下kotlin编译器完全不知道String类型的可空性,所以你必须自己处理它。如果你确定name不为null,就可以像java中一样按照通常的方式对它解引用,不需要额外的检查。但是这种情况下请准备好接收异常。
注意,这里你看到的不是一个干巴巴的NullPointerException,而是更详细的错误消息,告诉你方法toUpperCase不能在null的接收者上调用。
事实上,对于公有的kotlin函数,编译器会生成对每个非空类型的参数(和接收者)的检查,所以,使用不正确的参数的调用尝试都会立即被报告为异常。注意,这种值检查在函数调用的时候就执行了,而不是等到这些参数被使用的时候,这确保了不正确的调用会被尽早发现,那些由于null值被传给代码不同层次的多个函数之后,并被这些函数访问时而产生难以理解的异常就能被避免。
另一种选择是把getName()的返回类型解释为可空的并安全地访问它。
上面这个例子中,null值被正确地处理了,没有抛出运行时异常。
使用java API时要特别小心。大部分的库都没有(可空性)注解,所以可以把所有类型都解释为非空,但那样会导致错误。 为了避免错误,你应该阅读要用到的java方法的文档(必要时还要查看它的实现),搞清楚它们什么时候会返回null,并给那些方法加上检查。
在kotlin中不能声明一个平台类型的变量,这些类型只能来自java代码,但你可能会在IDE的错误消息中见到它们:
String! 表示法被kotlin编译器用来表示来自java代码的平台类型,你不能在自己的代码中使用这种语法。而且感叹号通常与问题的来源无关,所以通常可以忽略它。它只是强调类型的可空性是未知的。
如前所述,你可以用你喜欢的方式来解释平台类型,即可以是可空的也可以是非空的,所以下面两种声明都是有效的:
我们已经讨论了kotlin怎样看待java的类型。下面我们说说创建混合的kotlin和java类层级关系时会遇到的一些陷阱。
继承
当在kotlin中重写java的方法时,可以选择把参数和返回类型定义成可空的,也可以选择把它们定义成非空的。
注意:在实现java类或接口的方法时一定要搞清楚它的可空性。因为方法的实现可以在非kotlin的代码中被调用,kotlin编译器会为你声明的每一个非空的参数生成一个非空断言。如果java代码传给这个方法一个null值,断言将会触发,你会得到一个异常,即便你从没有在你的实现中访问这个参数的值。
二、基本数据类型和其他基本类型
1、基本数据类型:Int、Boolean及其他
java把基本数据类型和引用类型做了区分。一个基本数据类型(如int)的变量直接存储了它的值,而一个引用类型(如String)的变量存储的是指向包含该对象的内存地址的引用。
基本数据类型的值能够更高效地存储和传递,但你不能对这些值调用方法,或是把它们存放在集合中。java提供了特殊的包装类型(比如java.lang.Integer),在你需要对象的时候对基本数据类型进行封装。因此,你不能用Collection<int>来定义一个整数的集合,而必须使用Collection<Integer>来定义。
kotlin并不区分基本数据类型和包装类型,你使用的永远是同一个类型(比如:Int):
如果基本数据类型和引用类型是一样的,是不是意味着kotlin使用对象来表示所有数字?这样不是非常低效吗?确实低效,所以kotlin并没有这样做。
在运行时,数字类型会尽可能地使用最高效的方式表示。大多数情况下——对于变量、属性、参数和返回类型——kotlin的Int类型会被编译成Java基本数据类型int。唯一不可行的例外是泛型类,比如集合。用作泛型类型参数的基本数据类型会被编译成对应的java包装类型。例如Int类型被用作集合类的类型参数时,集合类将会保存对应包装类型java.lang.Integer的实例。
对应到java基本数据类型的类型完整列表如下:
像Int这样的kotlin类型在底层可以轻易地编译成对应的java基本数据类型。因为两种类型都不能存储null引用。反过来也差不多:当你在kotlin中使用java声明时,java基本数据类型会变成非空类型(而不是平台类型),因为他们不能持有null值。 现在来讨论下这些类型的可空版本。
2、可空的基本数据类型:Int?、Boolean?及其他
kotlin中的可空类型不能用java的基本数据类型表示,因为null只能被存储在java的引用类型的变量中。这意味着任何时候只要使用了基本数据类型的可空版本,它就会编译成对应的包装类型。
注意,普通的可空性规则如何在这里应用。你不能就这样比较Int?类型的两个值,因为他们之中任何一个都可能为null。 相反,你必须检查两个值都不为null。然后,编译器才允许你正常地比较它们。
3、数字转换
kotlin和java之间一条重要的区别就是处理数字转换的方式。kotlin不会自动地把数字从一种类型转换成另一种,即便是转换成范围更大的类型。
每一种基本数据类型(Boolean除外)都定义有转换函数:toByte()、toShort()、toChar()等。这些函数支持双向转换:既可以把小范围的类型扩展到大范围,比如Int.toLong(),也可以把大范围的类型截取到小范围,比如Long.toInt()。
为了避免意外情况,kotlin要求转换必须是显式的,尤其是在比较装箱值的时候。比较两个装箱值的equals方法不仅会检查他们存储的值,还要比较装箱类型。所有,在java中new Integer(42).equals(new Long(42))会返回false。
注意,当你书写数字字面值的时候,一般不需要使用转换函数,一种可能性是用这种特殊的语法来显示地标记常量的类型,比如42L或者42.0f。而且即使你没有用这种语法,当你使用数字字面值去初始化一个类型已知的变量时,又或者是把字面值作为实参传给函数时,必要的转换会自动地发生。此外,算术运算符也被重载了,它们可以接收所有适当的数字类型。例如,下面这段代码并没有任何显式转换,但可以正确地工作:
注意,kotlin算术运算符关于数值范围溢出的行为和java完全一致:kotlin并没有引入由溢出检查带来的额外开销。
4、Any和Any?:根类型
和Object作为java类层级结构的根差不多,Any类型是Kotlin所有非空类型的超类型(非空类型的根)。但是在java中,Object只是所有引用类型的超类(引用类型的根),而基本数据类型并不是类层级结构的一部分。这意味着当你需要Object的时候,不得不使用像Integer这样的包装类型来表示基本数据类型的值。而在kotlin中,Any是所有类型的超类型(所有类型的根),包括像Int这样的基本数据类型。
注意Any是非空类型,所以Any类型的变量不可以持有null值。在kotlin中如果你需要持有任何可能值的变量,包括null在内,必须使用Any?类型。
在底层,Any类型对应Object。kotlin把java方法参数和返回类型中用到的Object类型看作Any(更确切地是当做平台类型,因为其可空性是未知的)。当kotlin函数使用Any时,它会被编译成java字节码中的Object。
在第四章学习了,所有kotlin类都包含下面三个方法:toString、equals和hashCode。这些方法都继承自Any。Any并不能使用其他Object的方法(比如wait和notify),但可以通过手动把值转换成Object来调用这些方法。
5、Unit类型:Kotlin的void
kotlin中的Unit类型完成了java中的void一样的功能。当函数没有什么有意义的结果需要返回时,它可以用作函数的返回类型:
大多数情况下,你不会留意到void和Unit之间的区别。如果你的kotlin函数使用Unit作为返回类型并且没有重写泛型函数,在底层它会被翻译成旧的void函数。如果你要在java代码中重写这个函数,新的java函数需要返回void。
Unit是一个完备的类型,可以作为类型参数,而void却不行。只存在一个值是Unit类型,这个值也叫做Unit,并且(在函数中)会被隐式地返回。当你在重写返回泛型参数的函数时这非常有用,只需要让方法返回Unit类型的值:
和java对比一下,java中为了解决使用“没有值”作为类型参数的任何一种可能解法,都没有kotlin的解决方案这样漂亮。一种选择是使用分开的接口定义来分别表示需要和不需要返回值的接口(如Callable和Runnable)。另一种是用特殊的Void类型作为类型参数。即使你选择了后面这种方式,你还是需要加入一个return null;语句来返回唯一能匹配这个类型的值,因为只要返回类型不是void,你就必须始终显式地return语句。
你也许会好奇为什么kotlin选择使用一个不一样的名字Unit而不是把它叫做Void。在函数式编程语言中,Unit习惯上被用来表示“只有一个实例”,这正式kotlin的Unit和java的void的区别。我们本可以沿用Void这个名字,但是kotlin中还有一个叫做Nothing的类型,它有完全不同的功能。Void和Nothing两种类型的名字含义如此相近,会令人困惑,所以用Unit而不用Void。
6、Nothing类型:这个函数永不返回
对某些kotlin函数来说,“返回类型”的概念没有任何意义,因为它们从来不会成功地结束。例如,许多测试库都有一个叫做fail的函数,它通过抛出带有特定消息的异常来让当前测试失败。一个包含无限循环的函数也永远不会成功地结束。
当分析调用这样函数的代码时,知道函数永远不会正常终止是很有帮助的。kotlin使用一种特殊的返回类型Nothing来表示:
Nothing类型没有任何值,只有被当做函数返回值使用,或者被当作泛型函数返回值的类型参数使用才会有意义。在其他所有情况下,声明一个不能存储任何值的变量时没有意义的。
注意,返回Nothing的函数可以放在Elvis运算符的右边来做先决条件检查:
上面这个例子展示了在类型系统中使用Nothing为什么极其有用。编译器知道这种返回类型的函数从不正常终止,然后再分析调用这个函数的代码时利用这个信息。在上面这个例子中,编译器会把address的类型推断成非空,因为它为null时的分支处理会始终抛出异常。
3、集合和数组
1、可空性和集合
kotlin支持类型参数的可空性。就像变量的类型可以加上?字符来表示变量可以持有null一样,类型在被当做类型参数时也可以用同样的方式标记。 List<Int?>是能持有Int?类型值的列表:换句话说,可以持有Int或者null。
在第一种情况下,列表本身始终不为null,但列表中的每个值都可以为null。第二种类型的变量可能包含空引用而不是列表实例,但列表中的元素保证是非空的。
在另一种上下文中,你可能需要声明一个变量持有可空的列表,并且包含可空的数字。kotlin中的写法是List<Int?>?, 用两个问号。使用变量自己的值的时候,以及使用列表中每个元素的值的时候,都需要使用null检查。
遍历一个包含可空之的集合并过滤掉null是一个非常常见的操作,因此kotlin提供了一个标准库函数filterNotNull来完成。
当然,这种过来也影响了集合的类型。validNumbers的类型是List<Int>,因为过滤保证了集合不会再包含任何为null的元素。
最后,来看看filterNotNull库函数的源码实现
/**
* Returns a list containing all elements that are not `null`.
*
* @sample samples.collections.Collections.Filtering.filterNotNull
*/
public fun <T : Any> Iterable<T?>.filterNotNull(): List<T> {
return filterNotNullTo(ArrayList<T>())
}
/**
* Appends all elements that are not `null` to the given [destination].
*/
public fun <C : MutableCollection<in T>, T : Any> Iterable<T?>.filterNotNullTo(destination: C): C {
for (element in this) if (element != null) destination.add(element)
return destination
}
这里我尝试自己实现了一个简化版本:
fun <T : Any> Iterable<T?>.filterNotNull(): List<T> {
val newList = ArrayList<T>()
this.forEach {
it?.let { newList.add(it) }
}
return newList
}