@SR1s
2018-02-01T06:10:16.000000Z
字数 6750
阅读 1757
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有了俩(Person和Person?)。好处是,对于非空类型的变量,先可以安全的对它进行各种操作了。Kotlin编译器会在编译的时候检查,确保不可空的变量不会被赋值为null。
但问题并没有解决啊。存在变量可空的情况(Person?)还是存在呀。如何确保安全地调用没有空指针异常?Kotlin的设计者在这个基础上,又创造了?.这个安全调用运算符。
安全调用运算符干的事情也很简单,就是让你能安全地操作可空类型的变量。像下面这样:
val string : String?string?.toInt()
实际干的事情就是把两句话用一句话来说:
val string : String?if (string != null)string.toInt()
这就是安全调用运算符,它可以放飞自我去连着用,安全又畅快:
var nullable : Type?nullable1?.nullable2?.nullable3?.nullable4
还有一个是Elvis运算符?:,它有点像Java里的那个三目运算符:(condition) ? true : false,但它又是双目的,只在特定情况下能等价:
/* Java */(value != null) ? value : "default"/* Kotlin */value ?: "default"
那歌怎么唱来着?“简单点,说话的方式,简单点。”
Kotlin还考虑到了转型的情况,给了一个安全转型运算符as?,也是用简单的方式,省掉了冗长的代码:
/* Java */(object instanceof Person) : (Person) object : null/* Kotlin */object as? Person
说完了安全调用相关的操作符,这里还有个非空断言的操作符!!。它的意思是告诉编译器:行了行了,虽然这里写着可空(Person?),但老子确定这里坑定肯定不为空,就不劳神您检查了。编译器内心:mmp,后果自负!!(咆哮状):
val p : Person? = nullPerson!!.age
总结一下,在可空性这方面,有这么4个相关的操作符:
| 操作符 | 名称 | 示例 |
|---|---|---|
?. |
安全调用 | p?.age |
?: |
Elvis | p?: "default" |
as? |
安全转换 | p as? Person |
!! |
非空断言 | p!!.age |
让使用者写出优雅的代码是Kotlin语言的设计目标。可空性及相关的4个操作符是这种追求的产物。然而设计者依然觉得,不够OK。他们在Kotlin的标准库里,添加一系列的拓展函数,这里介绍了一个拓展函数let:
/* 函数原型 */inline fun <T, R> T.let(block: (T) -> R) : R/* 示例 */p?.let {it.ageprintln(it)}
类似的拓展函数还有apply、with、run等,也同样通过?.安全调用操作符来访问。
/* apply */inline fun <T> T.apply(block: T.() -> Unit): T/* apply 示例 */p?.apply {age// 这个this就是上面的p的非空版本,可以理解为p!!println(this)}.age/* with */inline fun <T, R> with(receiver: T, block: T.() -> R): R/* with 示例 */with(p?) {ageprintln(this) // this same as p!!}/* run */inline fun <T, R> run(block: T.() -> R): R/* run 示例 */p?.run {ageprintln(this) // this same as p!!}
这4个拓展函数粗略看起来没有什么差异,实际是有些微不同的,这里不提。
写代码的时候,往往会有这样的场景:先声明一个变量,后面再进行初始化。遇到这种情况,常规的做法是声明变量为不可空,像下面这样:
class Foo {var id: String? = nullfun init(_id: String) {id = _idid?.toInt()}}
这么做也就享受不到不可空带来的安全感了。Kotlin显然不想妥协。为此他们发明了lateinit这个关键字。
lateinit是和var连用的。为什么是var而不是val也很简单,能延迟初始化,只能是变量。用了lateinit之后的代码是这样的:
class Foo {lateinit var id: Stringfun init(_id: String) {id = _idid.toInt()}}
用lateinit var声明的变量,存在着一段空窗期——从变量声明到触发初始化这段时间,变量为空。在这个时间段访问变量,会抛出一个属性初始化前访问的异常。
这么说Kotlin并没有解决问题可空性的问题?对这种情况,我是这么理解的:lateinit是为了确保不可空性而发明的,也就是说,虽然延迟初始化,但它依旧是不能为空的!任何使用这个变量的代码也是带着“这个变量不会为空”的意图来存取它的。因此,写代码的时候也必须确保这个前提条件成立,如果不能确保,那运行时就只能暴力不合作了。不能保证这个前提条件成立的代码,其实是有Bug的,与其内部做判空保护性处理,不如抛出异常,让外部更正错误调用。这么想的话,Kotlin团队真的很严格。
说完了可空性,可空性相关的方法调用,可空性相关的拓展函数以及调和可空性和延迟初始化的矛盾,接下来是高级点的用法:为可空类型加拓展,如:
fun String?.isBlank(): Boolean
这个拓展函数里的this是可空的,因此在访问前需要做判空处理。其实就像定义了一个static boolean isBlank(String str)函数,实现需要兼容入参为空的场景。
上边说到可空性,往往会伴随着?符号,因此,有?的地方,都是可空。还有一种情况,是没有?但可空:使用泛型的时候。
fun <T> printHashCode(t: T) {println(t?.hashCode())}val str: String? = nullprintHashCode(str)
上述这种场景,上边的泛型函数理解为如下的定义,也就不难理解这种特性了:
fun printHashCode(str: String?) {println(str?.hashCode())}
另外,由于Kotlin和Java代码的互操作性,在调用Java方法、重载Java方法时,可空性是不确定的:
/* Java */String getName() {return null;}void setName(String name);/* Kotlin */@Overridefun setName(name: String?)
Kotlin调用第一个Java方法的时候是不知道其返回值是否可空。Kotlin重载第二个Java方法的时候,也不知道其入参是否可空。
这种情况,Kotlin没有做其他处理,Kotlin将这种情况下的类型成为平台类型,是否可空由代码编写者自行判定。如果确定调用方不会返回/传入null,可以声明为非空类型,如果觉得有可能传入null,那就不妨声明为可空类型。
Kotlin有别于Java,在它的类型系统里,一切都是对象,没有int和Integer、boolean和Boolean、long和Long这样的区别,在使用这些基本数据类型的时候,不需要纠结到底是使用字面量类型和装箱类型(对象),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型变量时候,会报错误,需要主动调用转换方法,也即是说1和1L是两个不同类型,不同的数。
插句题外话,Kotlin为String类型定义了一系列转换相关的拓展方法,用来转换字符串成基本数据类型,像toInt()、toBoolean()、toLong()等。
在Java里,所有对象的类型(基本类型除外)的基类都是Object,在Kotlin里,所有非空类型(含基本类型)的基类都是Any,所有非空类型(含基本类型)的基类都是Any?
Kotlin有一个Unit类型,对应Java里的void。虽然类似,但也有差别。在Java里,有void命令字和Void类型,Kotlin里只有Unit。在Java里,void的取值只能为null,在Kotlin里,Unit的值,有且只有Unit。Kotlin里,表示void的含义相比Java更加清晰和统一。
Kotlin还有一个特别的类型Nothing,作为那些“永不返回”的函数的返回类型。这个类型应该是用于标识那些故意设计为不会正常跑完的函数(Nothing用于表明函数的意图):
// 只会抛出异常,不会正常结束的函数fun fail(message: String) {throw IllegalStateException(message)}// 可以放在Elvis运算符的右边,而不必担心类型不匹配val address = person.address ?: fail("No address")// 一个故意设计为无限循环的函数fun main(): Nothing {while(true) {// other code...}}
时断时续三天,终于掰扯到最后一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>):
val containNullList: List<Int?> = listOf(1, 2, 3, null)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里是这样:
fun main(args: Array<String>) {// do something}
创建数组有这么几个常见的方法:
arrayOf(t1: T, t2: T, T...)arrayOfNulls(num: Int)Array<Type>(num: Int, block: (i: Int) -> Type)第一个方法,用给定的参数,创建数组;第二个方法,创建一个指定长度的数组,并用null初始化每个元素;第三个方法,创建一个指定长度数组,并用给定的lambda表达式,根据下标生成对应位置的元素。
另外,Kotlin还为集合创建了转换成数组的拓展函数,非常方便:
val strings = listOf("a", "b", "c")strings.toTypedArray()
另外,用Array<Int>方式声明的数组,会生成对象类型的数组,即Integer类型。为了表示基本数据类型的数组,Kotlin提供了独立的类来承载:IntArray、LongArray、ByteArray、CharArray等。
同样的,有几个常用的创建基本数据类型数组的方法:
IntArray(num: Int)intArrayOf(i1: Int, i2: Int, Int...)IntArray(num: Int, block: (Int) -> Int)含义和前者差不多,只是生成的数组,不是装箱类型的数组。
当然,对于装箱类型的数组,可以调用toIntArray这样的转换函数,将它转换成基本数据类型的数组。
另外,在原有接口的基础上,Kotlin赋予了数组和集合相同的用于数组的拓展函数,如filter、map、flatten等(返回值为列表而非数组)。
另外,如果需要遍历数组内的元素,除了for x in array,还可以使用array.forEachIndexed(block: (Int, Type) -> Unit)的方式来访问。
总体来看,Kotlin在Java的类型系统的基础上做了改进和创新,尽可能地在语言层面做规范、约束,提供辅助,让代码质量得保证。