重学Kotlin之那些你没注意到的细节

6,119 阅读12分钟

前言

大家好,好久不见。从Kotlin发布到现在已经有快十个年头了,从2016年发布正式版发展到现在已经有越来越多的开发者开始使用Kotlin开发项目,特别是安卓开发者,因为谷歌在2017年的 I/O 大会上正式宣布Kotlin正式成为安卓的一级开发语言,在2019年的 I/O大会上又宣布Kotlin为安卓的第一开发语言。。

我从2018年下半年开始学习的Kotlin,最开始买了一本《Kotlin从零到精通》,跟着学习了一遍,把Kotlin的基本语法学习了一遍,由于待的公司比较小,安卓开发者并不多,而且当时正好有一个新的项目,所以我提议整个项目采用Kotlin进行开发,其实当时很冒险,因为那个项目挺着急,三个安卓开发都不咋会Kotin,所以在项目开发期间一直是边学边写,项目完成之后感觉自己对Kotlin已经理解的差不多了(其实只是会用而已,项目中有的地方还是使用Java)。

后来看一些开发者论坛看大家学习Kotlin的越来越多,提到的好多东西竟然都看不懂,最过分的是已经使用kotlin写了一个项目了,Kotlin中的一些关键字都不知道是干啥用的,实在惭愧。

之前一直关注郭神的博客,而且安卓入门也看的是郭神的《第二行代码》。后来听说要出《第三行代码》,而且里面有Kotlin的详细讲解,所以书第一天发售的时候就立马抢了一本签名版,还没来的及好好看看,公司就让我出差了,一直到现在才有空看看,看了看感觉之前写的代码好多都不太好,好多Kotlin中好用的东西都没使用到,我使用的Kotlin只是把Java代码转成了Kotlin,没有一点意义。好了,下面开始知识点了,敲黑板划重点!

Kotlin中的标准函数

Kotlin中的标准函数指的是在 Standard.kt 中定义的函数,下面来写一下我认为经常使用的标准函数吧!

let

最开始决定使用Kotlin的时候的一个重要原因就是它把空指针异常提到了语言层面,但是这也是让很多像我一样的开发者头疼的地方。。

是,Kotlin为了空安全不允许定义为空的,想要定义的话就必须加上问号,但是。。。。很多情况就像下面的代码:

为了Kotlin的空安全必须使用问号点或者两个感叹号点来消除空安全的报错,只是为了消除报错,在Java中根本不需要的好嘛!要是只使用一次两次还好,咱们使用问号或者感叹号还好,但如果是一堆调用的呢?比如下面:

参数多的时候怎么搞。。不说写的速度,烦都烦死了。。。。

后来。。。。发现根本没必要这样写啊!可以这样啊:

    var zhu:ZhuJ? = null

    fun test() {
        zhu?.let { zhu->
            zhu.name
            zhu.phone
            zhu.age
            zhu.sex
        }
    }

是不是瞬间感觉代码优雅了好多。。。全局变量都没问题,方法的实参更没问题了。。

知识点啊兄弟们,早知道这样就不写一堆问号和感叹号了。。。

with

这个标准函数的作用是Lambda中的代码会持有对象的上下文,其最后一行代码为返回值。

这么说不太好理解,来一段代码大家先看下吧:

operator fun String.times(n: Int): String {
    val sb = StringBuilder()
    repeat(n){
        sb.append(this)
    }
    return sb.toString()
}

这是一个String类的运算符重载的一个方法,意思很简单,就是重复字符串,参数为重复几次。大家可以发现,在这个方法中的 StringBuilder 对象被使用了好几回,没一会都需要写一次,但是。。。如果使用了with标准函数的话。。。。

operator fun String.times(n: Int): String {
    return with(StringBuilder()) {
        repeat(n) {
            append(this@times)
        }
        toString()
    }
}

是不是感觉清爽了些许,很多情况可以这样来调用。

run

这个标准函数的作用其实和 with 基本一致,只是使用方法上有所不同,with 需要括号中写入对象来进行操作,run 则是对象点进行操作,上面代码使用 run 改写之后的代码如下:

operator fun String.times(n: Int): String {
    return StringBuilder().run {
        repeat(n) {
            append(this@times)
        }
        toString()
    }
}

apply

这块要注意了,apply 使用方式和run一致,但是不同的是:最后一行不作为返回值,废话不多说,还拿上面代码改写:

operator fun String.times(n: Int): String {
    return StringBuilder().apply {
        repeat(n) {
            append(this@times)
        }
    }.toString()
}

with 和 run 的最后一行都是返回值,而apply泽不是,这块一定要注意。

关键字

这块需要好好的总结下了,真的是,都写了一个项目了连使用语言的关键字都没认全。。一个一个来!

lateinit

这个关键字其实使用的很多,在定义全局变量为空的时候并不是非得用问号设置为可空的,如果你可以确定一定不为空可以使用 lateinit 这个关键字来定义全局变量,举个栗子:

lateinit var zhuJ: ZhuJ

当这样定义全局变量的时候就无需设置为可空了,比如安卓项目中的 adapter ,咱们肯定能确认会赋值,不会为空,那么就可以使用 lateinit 了。

这块需要注意的是,即使咱们觉得不会为空,但肯定会有特殊情况需要进行判断,需要进行判断的话要使用 isInitialized ,使用方法如下:

if (::zhuJ.isInitialized){
    // 判断是否已经进行赋值
}

sealed

这个关键字之前一直没有进行使用,它用来修饰类,含义为密封类,之前一直没搞懂这个密封类有啥说啥用,这两天好好看了下,我理解的作用就是:可以使代码更加严密。

这样说感觉有点抽象,再举个栗子吧,平时咱们在封装一些工具的时候一般只会有成功和失败,咱们的做法一般是定义一个接口,然后再定义一个成功类和失败类来实现这个接口,最后再进行判断:

class Success(val msg: String) : Result
class Fail(val error: Throwable) : Result

fun getResult(result: Result) = when (result) {
    is Success -> result.msg
    is Fail -> result.error.message
    else -> throw IllegalArgumentException()
}

上面代码都是咱们一般写的,虽然只有两种情况,但是必须再写 else 来进行判断,如果不写的话编译就过不了。但如果使用密封类的话就不会有这种情况出现:

sealed class Results
class Success(val mag: String) : Results()
class Failure(val error: Exception) : Results()

fun getMessage(result: Results) {
    when (result) {
        is Success -> {
            println(result.mag)
        }
        is Failure -> {
            println(result.error.toString())
        }
    }
}

不仅不用再写else,而且在进行 when 判断时,kotlin 会检查条件是否包含了所有的子类,如果没有会提示你加上,这样就大大提高的代码的鲁棒性,也不会出现没有判断到的问题。

operator

这个关键字是运算符重载,其实在上面标准函数中已经使用到了,就是可以对运算符进行重新自定义,用来实现一些代码上不对劲但实际上对劲的需求,使用起来也很舒服。

这里来说一下咱们常用的运算符需要重载的函数吧:加号对应 plus、减号对应minus、乘号对应 times、除号对应 div、取余对应 rem、自增对应 inc、自减对应 dec

具体使用方法就是上面那样,再写下吧:

operator fun String.times(n: Int): String {}

internal

这个关键字可以用来修饰类和方法,它的作用很简单,就是限制不同 module 的访问,如果在 A module 中定义了一个 internal 方法,那么这个方法只能在 A module 中进行调用,在 B module 中是无法访问的。

inner

这个关键字很简单,用来修饰类,但是。。。只能用来修饰内部类。

咱们来写个栗子大家就知道了:

class Test {

    var num: String? = null
    
    class Zhu() {
        var nums: String? = null
        fun adds() {
            nums?.let { it.length }
        }
    }

    inner class Jiang() {
        var nums: String? = null
        fun adds() {
            nums?.let {
                it.length
            }
        }
    }
}

上面代码很简单,只是定义了一个 Test 类,其中一个是直接在内部创建的 Zhu 类,另一个是使用 inner 关键字修饰的 Jiang 类。咱们直接来调用下看下有什么区别吧:

    Test().Jiang().nums
    Test.Zhu().nums

大家发现没有,如果要使用 inner 修饰内部类的话需要先获取到 Test 类的实例才可以进行使用,而直接创建的 Zhu 类则不需要。

inline

这个关键字的意思是内联函数,它的用法非常简单,只需要在高阶函数前加上 inline 关键字即可。如果对高阶函数不太清楚的,建议去看下扔物线的一个视频,好像是讲解 Lanbda 的。

简单说下吧,高阶函数并没有想象中的难,只是名字听着感觉很高大上而已,简单来说就是传入方法(其实本质上还是对象)当作方法参数即为高阶函数。高阶函数的原理其实就是把方法参数转为接口,并创建匿名内部类进行调用,所以每次调用这样的 Lambda 都会创建一个新的匿名内部类和接口实例,造成额外的开销。

所以这就是 inline 出现的原因,它可以去掉这些开销,并没有什么特殊的,只是进行替换,就是在你调用的地方把方法参数进行替换,从而减少内存和性能的开销。来看下使用方法吧:

inline fun high(block:(Int,Int) -> Int,block2:(Int,Int) -> Int){
    block.invoke(5,6)
    block2.invoke(4,5)
}

noinline

诶,这个关键字和上面内联函数的关键字好像是吧!这是因为如果一个高阶函数中有两个或以上的方法参数存在的话,如果使用 inline 关键字的话会把所有的方法参数都变为内联函数,为什么不都替换呢?因为内联函数的函数类型参数在编译的时候会进行代码替换,所以没有真正的参数类型,但非内联函数的函数类型参数可以自由的传递给其他任何函数,而内联函数类型参数只允许传递给另一个内联函数。

说了这么多就是为了引出 noinline 的存在意义,使用方法很简单:

inline fun high(block:(Int,Int) -> Int,noinline block2:(Int,Int) -> Int){
    block.invoke(5,6)
    block2.invoke(4,5)
}

是不是很简单,只要在方法参数前加上 noinline 关键字即可。

crossinline

既然已经说了 noinline 关键字,那么也得说下 crossnoinline 了。它的作用是:让无法使用内联函数的方法使用内联函数。

为什么有无法使用内联函数的函数呢?非内联函数无法直接 return ,但是内联函数可以,所以如果在高阶函数中创建或者使用了另外的 Lambda 或匿名类的实现的话即会报错。再举个栗子:

上面代码使用了咱们非常熟悉的 Runnable ,但是发现报错了,为什么呢?上面已经说出了答案,这就是 crossinline 关键字的作用,可以让无法使用内联函数的函数来使用内联函数:

完美解决!crossinline 的作用主要是用于保证内联函数中的 Lambda 表达式中一定不会使用 return 关键字,这样也就没有冲突了。这样也有一个坏处,就是我们也无法调用 Runnable 中使用 return 进行返回了。

infix

这个关键字其实很好用,咱们可以使用它来一些很骚的操作:

 val result = "zhujiang" * 3
 val a = result begin "zhu"

是不是没见过这样的写法?复制到你电脑上肯定报错。infix 主要的作用就是定义一些语义上很舒服的写法,比如上面的 result begin "zhu" 这样的调用方式:

infix fun String.begin(prefix:String):Boolean = startsWith(prefix)

是不是很好用,是不是已经想到很多骚操作了?哈哈哈

但是!

要注意以下两点:

  1. infix 不能定义成顶层函数,必须是某个类的成员函数,可使用扩展方法的方式将它定义到某个类中
  2. infix 函数必须且只有能接收一个参数,类型的话没有限制。

by

这个关键字的意思是委托。来一个使用方法看看吧:

class MySet<T>(val help:HashSet<T>) :Set<T> by help{

    override fun isEmpty(): Boolean {
        return false
    }

}

可以为一些类创建委托类并重写或添加一些自己写的方法。

泛型

泛型大家再熟悉不过了,Java 中咱们使用的也非常多,例如 List 、HashMap<String,String>等等。

kotlin中的泛型

其实使用和 Java 中差不多,栗子又来了:

class Generic <T>{

    fun method(parem:T):T{
        return parem
    }
    
}

上面是在类上的使用,当然方法中也可以进行使用:

    fun <S> meth(parem:S):S{
        return parem
    }

泛型的实化

在 Java 中是绝对没有的,也是不现实的,因为 Java 的泛型擦出机制。。。

但是在Kotlin中是可以实现的,但是。。。。有条件!

  1. 函数必须是内联函数,因为只有内联函数才有替换的操作。
  2. 声明类型时必须加上 reified 关键字来表示该泛型要进行实化。

那么,实化有什么作用呢?来看代码吧:

inline fun <reified T> startActivity(context:Context) {
    context.startActivity(Intent(context,T::class.java))
}

知道了吧。。很方便的!

泛型的逆变和协变

这块。。。说起来有点麻烦,下一篇文章来专门写写泛型的逆变和协变吧!先欠着!

总结

先总结到这里吧,其实 Kotlin 中还有很多好玩的东西需要我们去探索,比如协程,项目中其实用到了很多,但总感觉使用的不够好,需要有空好好扣一扣。虽然说内容并不多,但也写了好久,文中的知识都是跟着郭神的《第三行代码》学习的,也推荐大家购买,一本书能学到一个知识点就不亏,支持正版,别为了省几十块钱下载盗版。。。

如果本文对你有帮助,请别忘记三连啊。如果本文有描述不恰当或错误的地方,请提出来,感激不尽。就这样,下回见。