@SR1s
2018-02-01T14:10:16.000000Z
字数 6750
阅读 1545
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? = null
Person!!.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.age
println(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?) {
age
println(this) // this same as p!!
}
/* run */
inline fun <T, R> run(block: T.() -> R): R
/* run 示例 */
p?.run {
age
println(this) // this same as p!!
}
这4个拓展函数粗略看起来没有什么差异,实际是有些微不同的,这里不提。
写代码的时候,往往会有这样的场景:先声明一个变量,后面再进行初始化。遇到这种情况,常规的做法是声明变量为不可空,像下面这样:
class Foo {
var id: String? = null
fun init(_id: String) {
id = _id
id?.toInt()
}
}
这么做也就享受不到不可空带来的安全感了。Kotlin显然不想妥协。为此他们发明了lateinit
这个关键字。
lateinit
是和var
连用的。为什么是var
而不是val
也很简单,能延迟初始化,只能是变量。用了lateinit
之后的代码是这样的:
class Foo {
lateinit var id: String
fun init(_id: String) {
id = _id
id.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? = null
printHashCode(str)
上述这种场景,上边的泛型函数理解为如下的定义,也就不难理解这种特性了:
fun printHashCode(str: String?) {
println(str?.hashCode())
}
另外,由于Kotlin和Java代码的互操作性,在调用Java方法、重载Java方法时,可空性是不确定的:
/* Java */
String getName() {
return null;
}
void setName(String name);
/* Kotlin */
@Override
fun 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的类型系统的基础上做了改进和创新,尽可能地在语言层面做规范、约束,提供辅助,让代码质量得保证。