Kotlin特性&原理&技巧大杂烩

2,099 阅读27分钟

前言

本文并没有一条特别明确的主线,内容相对比较零散,会为大家介绍一些Kotlin的知识,包括但不限于 语法特性、底层原理、奇巧淫技等,希望大家能查漏补缺,更好的coding

工具

通过 Android Studio自带的Tools-> Kotlin -> Show Kotlin Bytecode

可以看到右侧会展示当前文件对应的JVM 字节码

点击 Decompile ,就会展示当前Kotlin文件反编译到Java代码后的结果

通过这种手段,可以了解到Kotlin编译器帮我们做了哪些工作,更好的了解我们的Kotlin代码

正文

基础语法

变量类型推断的提示技巧

我们都知道,在Kotlin中,我们使用 var/val variableName:variableType = variableValue来声明变量,同时,因为Kotlin支持类型推导,所以可以简写为var/val variableName = variableValue

像下边这段代码,就是声明了一个变量,值是50.0

fun main(args: Array<String>) {
    val num = 50.0
}

作为有经验的研发,我们都知道,浮点值默认是 double 类型,如果你需要一个float类型,你需要显式地在值后面加F,像这样

fun main(args: Array<String>) {
    val num = 50.0F
}

在这种比较简单的场景下,我们可以轻易看出Kotlin帮我们推断的类型,但是如果场景再复杂一些,我们可能就会比较难进行肉眼的推断,这个时候我们有两种方式来帮我们

通过快捷键 Control + Shift + P ,可以让IDE给我们展示当前选中的Kotlin变量的类型

在IDE的设置中,我们可以通过如下设置,让IDE总是显示当前的类型

打开之后效果如下

除了我们打开的这一个,还有其他范围内的提示可以打开,大家可以自行尝试

Kotlin的包装类型

在Java中,基础类型分为原始类型和包装类型,比如intInteger,在Kotlin中,我们只有Int这一种类型,但是这并不代表intInteger的区别就不存在了,只是Kotlin的编译器帮我们去做了类型的选择

对于下面的代码,左边是Kotlin的原始代码,右边是反编译后的Java代码

fun main(args: Array<String>) {

    val doubleNum = 1.0

    var doubleObjNum: Double? = 1.0
    doubleObjNum = null

    val list = ArrayList<Int>()
    list.add(1)
    
    val nullableList = ArrayList<Int?>()
    nullableList.add(1)
}

可以看到

  • 对于不可空的基础类型,Kotlin编译器会自动为我们选择使用原始类型,而对于可空的基础类型,Kotlin编译器则会选择使用包装类型
  • 对于集合这种只能传包装类的情况,不论你是传可空还是不可空,都会选择使用包装类型

Kotlin中的数组

在Kotlin中,我们使用arrayOf来创建一个数组,但是需要注意的是,使用arrayOf创建的数组都是包装类型的,而对于原始类型,需要使用intArrayOflongArrayOf等API来创建

这是一个比较容易被忽视的细节,需要注意一下,来避免不必要的性能损耗

fun main(args: Array<String>) {

    val intArray = intArrayOf()

    val integerArray = arrayOf<Int>()
}

函数的默认参数

我们都知道,Kotlin的函数提供了默认参数这一特性,但是实现这一特性的原理是Kotlin在编译时,为我们把默认参数在调用处填了进去,而不是真正实现了多个参数的方法

fun main(args: Array<String>) {
    foo()
}

fun foo(arg1: Int = 1, arg2: Long = 2) {

}

但是这么做在部分场景会带来一些奇怪的问题

假如说你是一个SDK的作者,你的lib里提供了一个叫bar的空参方法,然后有其他的模块依赖了你的库,但是其他模块也是以AAR的形式被集成到APP里的,之后有一天,你需要给bar方法扩充功能,增加了一个参数

fun bar(arg: Boolean = false) {
}

之后你打了个版本,改了一下grade,打了个包,试了一下,发现用了这个方法的地方全都直接crash了

原因是因为,你增加了这个参数之后,这个方法的签名就变了,但是其他AAR又没有重新编译,不知道你的方法签名变了,所以运行时就全挂了

因此Kotlin为我们提供了@JvmOverloads注解,通过这个注解,我们可以生成多个方法来避免这一问题

fun main(args: Array<String>) {
    foo()
}

@JvmOverloads
fun foo(arg1: Int = 1, arg2: Long = 2) {

}

但是这种方式会导致包体积的膨胀,一个有N个参数的方法,最终会生成2^N-1个方法

因此我们需要灵活把握,到底什么时候使用@JvmOverloads注解

Run let apply also with的用法

runletapplyalsowith都是Kotlin官方为我们提供的高阶函数

它们各自的receiverargumentreturn的差异可以看第一张图,我们主要来看它们各自的适用场景

run适用于在顶层进行初始化时使用

let在被可空对象调用时,适用于做null值的检查

apply适用于做对象初始化之后的配置

also适用于与程序本身逻辑无关的副作用,比如说打印日志等

let在被非空对象调用时,适用于做对象的映射计算,比如说从一个对象获取信息,之后对另一个对象进行初始化和设置最后返回新的对象

Data Class

在Kotlin中,我们可以在class关键词之前加一个data,来声明它为一个数据类

之后Kotlin在编译的时候,会自动为我们生成copyequalstoStringhashCode还有componentN方法

其中componentN方法可以为我们实现一种叫做「解构」的语法特性

data class Test(
    val name: String,
    val age: Int
)

fun main() {
    val test = Test("test", 1)
    val (name, age) = test
}

我们来一起看一下data class反编译的结果

可以看到,解构语法本质上还是在调componentN方法,N就是在构造方法里定义的第N个参数

同时我们需要注意一下toString这个方法,它会输出你的变量名,因为toString是编译时生成的,所以即使是在release包上,它仍然会不受混淆的影响,把当前变量名进行输出,所以如果你的类含有一些比较敏感的信息,请在使用前思考一下,会不会有安全风险

==和===

在Java中我们一般使用==来判断两个对象的引用是否相等,使用equals方法来判断两个对象是否值相等

我们可能会很自然的把这种想法也带入到Kotlin中,但是在Kotlin中,==equals是相等的,如果我们要判断两个对象的引用是否相等,要使用===来判断

fun main() {
    val str1 = "a"
    val str2 = "b"
    println(str1 == str2)
    println(str1 === str2)
}

Lateinit 和 by lazy

Property initialization using "by lazy" vs. "lateinit"

【译】kotlin中lateinit和by lazy的区别 - 掘金

Kotlin中,我们可以使用lateinit var或者by lazy来实现变量的延迟初始化

但两者的适用场景又不太相同

lateinit var适用于你在声明变量时不知道它的初始值是多少的场景

比如你要声明一个String类型的变量,但是它的初始值依赖后续流程去给它声明

这种情况下,我们当然可以直接把它声明成var str:String? = null,之后再对它进行赋值

但是这样我们后续在使用它的时候都需要对它进行判空操作,代码写起来就怪怪的

这个时候我们就可以把它声明成lateinit var str:String,这样后续它都是一个非空类型了,不过就需要你自己保证代码访问时序的正确性,如果没有赋值就去访问了,会有异常被抛出

lazy更适用于,「一个对象的创建需要消耗大量的资源,而我不知道它到底会不会被用到」的场景,lazy只有在第一次被调用到的时候才会去赋值

fun main() {
    val any by lazy {
        println("create any instance")
        Any()
    }

    println("before creation")
    val value = any
    println("after creation")
}
// before creation
// create any instance
// after creation

那么lazy是如何实现懒加载的呢,lazy本质是生成了一个SynchronizedLazyImpl对象,这个对象初始化的时候会持有一个函数的引用,当调用它的value的时候,会去检查是不是初始化过了,如果初始化过了直接返回,没有的话调用传入的函数,获取到返回值之后再返回,从而实现了一个懒加载的效果

private class SynchronizedLazyImpl<out T>(initializer: () -> T, lock: Any? = null) : Lazy<T>, Serializable {
    private var initializer: (() -> T)? = initializer
    @Volatile private var _value: Any? = UNINITIALIZED_VALUE
    // final field is required to enable safe publication of constructed instance
    private val lock = lock ?: this

    override val value: T
        get() {
            val _v1 = _value
            if (_v1 !== UNINITIALIZED_VALUE) {
                @Suppress("UNCHECKED_CAST")
                return _v1 as T
            }

            return synchronized(lock) {
                val _v2 = _value
                if (_v2 !== UNINITIALIZED_VALUE) {
                    @Suppress("UNCHECKED_CAST") (_v2 as T)
                } else {
                    val typedValue = initializer!!()
                    _value = typedValue
                    initializer = null
                    typedValue
                }
            }
        }
}

但是上面的代码中,我们可以看到,这个实现它在第一次获取值的时候是有加锁来实现线程安全,但是很多时候我们的代码都是单线程调用的,不需要考虑线程安全问题,这个时候就会有额外的性能开销

不过好在Kotlin还为我们提供了其他的Lazy实现版本,比如使用CAS来实现线程安全的SafePublicationLazyImpl,以及没有任何线程安全保护措施的UnsafeLazyImpl

public actual fun <T> lazy(mode: LazyThreadSafetyMode, initializer: () -> T): Lazy<T> =
    when (mode) {
        LazyThreadSafetyMode.SYNCHRONIZED -> SynchronizedLazyImpl(initializer)
        LazyThreadSafetyMode.PUBLICATION -> SafePublicationLazyImpl(initializer)
        LazyThreadSafetyMode.NONE -> UnsafeLazyImpl(initializer)
    }

我们可以在使用时传入mode来选择使用哪一种实现方式

val value by lazy(LazyThreadSafetyMode.NONE) {}

当然也可以进一步封装

fun <T> lazyFast(initializer: () -> T): Lazy<T> = lazy(LazyThreadSafetyMode.NONE, initializer)

然后这么使用

val value by lazyFast {}

看大家个人的偏好

但是在使用lazy的时候,大家需要小心的使用外部的变量,因为有可能会造成内存泄露

我们来看这个例子

class MainActivity {

    private val context: Any = Any()
    
    val foo by lazy {
        println(context)
        Any()
    }
    
}

运算符重载与中缀表达式

对于下面的代码,我们在声明Map的时候,使用to来定义了键值对,之后又使用[]来访问元素,这在Java中是做不到的,那么Kotlin是怎么实现这一效果的呢

fun main() {
    val map = mapOf(
        1 to "one",
        2 to "two"
    )

    val value = map[1]

}

首先我们来看to的实现,只有短短一行

public infix fun <A, B> A.to(that: B): Pair<A, B> = Pair(this, that)

可以看到to也只是一个方法,那我们代码本质上是这样的,下面这段代码同样可以通过编译

val map = mapOf(
    1.to("one"),
    2.to("two")
)

而之所以我们可以像上面那么写,是因为to方法中有一个infix关键字,这个关键字表示这个方法可以做一个「中缀表达式」,通过给方法增加这个关键字,我们可以实现variableA functionName variableB这样的调用,来提高我们代码的可读性

在了解了这一原理之后我们可以认识到,这一特性不局限于Kotlin提供的类,我们同样可以给自己的方法增加这一特性

fun main() {
    val test = Test()
    test foo ""
}

class Test {
    infix fun foo(any: Any) {
        
    }
}

之后我们来看一下通过[]来访问Map的元素是如何实现的

//Kotlin代码
val value = map[1]

//反编译之后的Java代码
String value = (String)map.get(1);

我们可以看到这种方式本质上还是在调Map的get方法,我们来看一下get方法的声明

public operator fun get(key: K): V?

可以看到get方法多了一个operator关键字,Kotlin编译器正是通过这个关键字才把[]和get方法进行了映射,除了get之外Kotlin还提供了大量的operator供我们使用,完整的可以在官网文档查看

Operator overloading | Kotlin

同理,作为一个关键字,我们也可以为自己类覆写操作符方法来让调用者更方便地进行调用

比如下面这个例子,我们通过覆写compareTo方法,可以让调用方直接用 >= 来判断两个对象的大小

class Test {
    operator fun compareTo(any: Any):Int {
        return 0
    }
}

fun main() {
    val test = Test()
    val test2 = Test()

    println(test >= test2)
}

List与Sequence

Kotlin为我们提供了大量的集合操作函数供我们对集合进行操作,比如filtermap等,但这些函数并不是没有副作用的

来看这样一个例子,我们定义了一个1~20的集合

然后通过两次调用filter函数,来先筛选出集合中的偶数,然后又筛选出集合中的5的倍数,最后得到结果10和20,看起来一切都很正常

fun main() {
    val list = (1..20).toList()

    val result = list.filter {
        print("$it ")
        it % 2 == 0
    }.also {
        println()
    }.filter {
        print("$it ")
        it % 5 == 0
    }
    println()
    println(result.toString())
}
// 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 
// 2 4 6 8 10 12 14 16 18 20 
// [10, 20]

但是我们来看一下filter的源码

public inline fun <T> Iterable<T>.filter(predicate: (T) -> Boolean): List<T> {
    return filterTo(ArrayList<T>(), predicate)
}

public inline fun <T, C : MutableCollection<in T>> Iterable<T>.filterTo(destination: C, predicate: (T) -> Boolean): C {
    for (element in this) if (predicate(element)) destination.add(element)
    return destination
}

通过filter的源码我们可以知道,每次filter操作都会创建一个新的集合对象,如果你的操作次数很多并且你的集合对象很大,那么就会有额外的性能开销

针对这种情况,Kotlin为我们提供了Sequence来优化性能

fun main() {
    val list = (1..20).toList()

    val sequenceResult = list.asSequence()
        .filter {
            print("$it ")
            it % 2 == 0
        }.filter {
            print("$it ")
            it % 5 == 0
        }
    
    val iterator = sequenceResult.iterator()
    iterator.forEach {
        print("result : $it ")
    }
}

// 1 2 2 3 4 4 5 6 6 7 8 8 9 10 10 result : 10 11 12 12 13 14 14 15 16 16 17 18 18 19 20 20 result : 20 

对于Sequence,它的计算是惰性的,在调用filter的时候,并不会立即计算,只有在调用它的iteratornext方法的时候才会进行计算,并且它并不会像List的filter一样计算完一个函数的结果之后才会去计算下一个函数的结果,而是对于一个元素,用它直接去走完所有的计算

举个例子

对于1,它走到第一个filter里面,不满足条件,直接就结束了

而对于2,它走到第一个filter里面,符合条件,这个时候会继续拿它去走第二个filter,不符合条件,就返回了

对于10,它走到第一个filter里面,符合条件,这个时候会继续拿它去走第二个filter,依然符合条件,最终就被输出了出来

因此,如果你对集合的操作次数比较多的话,推荐使用Sequence来操作避免不必要的性能损耗

扩展方法

Kotlin提供了扩展方法和扩展属性,能够对一些我们无法修改源码的类,增加一些额外的方法和属性

一个很简单的例子,String是JDK提供的类,我们没有办法直接修改它的源码,但是我们又经常会做一些判空、判断长度的操作,在以往使用Java的时候,我们会使用TextUtils.isEmpty来判断,但是有了Kotlin之后,我们可以像下面这样,给String定义一个扩展方法,之后在方法体中,使用this就可以方法到当前的String对象,从而实现看起来为这个类新增了一个方法的效果

fun main() {
    "".isEmpty()
}

fun String.isEmpty(): Boolean {
    return this.length > 0
}

但这依然是Kotlin编译器的魔法,最终它在调用时,还是以一个方法的形式调用的,所以扩展方法并没有真正的为这个类增加新的方法,而只是让你在写代码时可以像调用方法一样调用工具类,来增加代码的可读性

在了解这一原理之后,我们就可以理解在一些特殊case下,Kotlin的扩展为什么会表现很“奇怪”

在下面这个例子里,类A已经有一个bar方法了,然后我们又通过扩展方法给它定义了一个bar方法,它在语法上是可行的

class A {
    fun bar() = println("inner bar")
}

fun A.bar() = println("extend bar")

fun main() {
    val a = A()

    a.bar()
}

但是我们在调用的时候,永远调到的是类A本身的bar方法,因为从底层来说,类A自己的方法和扩展方法,方法签名是不一样的,Kotlin编译器发现你本身有这个方法了,就不会再给你做扩展方法的调用

我们来看另一个例子

我们现在有一个类A,然后B继承了A,之后分别扩展了一个同名方法printSelf,A的输出A,B的输出B,之后我们在main方法里进行如下调用

open class A {
}

class B : A() {

}


fun A.printSelf() = println("A")

fun B.printSelf() = println("B")

fun main() {
    val a = A()
    a.printSelf()

    val b: A = B()
    b.printSelf()

    val b2 = B()
    b2.printSelf()
}
// 输出结果是
// A
// A
// B

第一和第三个的输出结果都显而易见,但是第二个的输出结果却是A,为什么会这样呢

因为我们把b的类型声明成了A,虽然它是一个B的实例,但Kotlin编译器又不知道你运行时到底是什么,你声明是A,就给你调用A的扩展方法,所以才会有这一结果

尾递归优化

在开发过程中,我们可能会经常使用到递归,从语义上来说,递归更容易被程序员理解,但是也有一个致命的缺点,就是如果层级过深,会报StackOverFlow

在Kotlin中,Kotlin为我们提供了tailrec关键字来做尾递归优化,对于下面这个用来求阶乘的函数,它编译之后结果,不再是一个递归调用,而是一个while循环,这样它就同时具备了很好的可读性和避免了层级过深到来的爆栈问题

fun main() {
    println(factorial(5))
}

tailrec fun factorial(n: Int, run: Int = 1): Long {
    return if (n == 1) run.toLong() else factorial(n-1, run*n)
}

但并不是所有的递归函数都可以使用tailrec关键字来做优化,只有尾递归函数可以这么做,所谓尾递归,是指「对自身的函数调用是它执行的最后一个操作」,只有这样的递归函数,Kotlin的编译器才能为我们做分析和优化

Unit不是void

在Kotlin中,如果一个方法没有声明返回类型,那么它的返回类型会被默认设置为Unit

但是Unit并不等同于Java中的void关键字,void代表没有返回值,而Unit是有返回值的

fun main() {
    val foo = foo()
    println(foo.javaClass)
}

fun foo() {

}

// 输出结果:class kotlin.Unit

从上面这个代码我们可以看出来,Unit的方法返回了一个Unit的对象,我们来看一下Unit是什么

public object Unit {
    override fun toString() = "kotlin.Unit"
}

很容易理解,Unit就是一个全局唯一的单例对象,那么Kotlin这么做的意义是什么呢

答案是为了抹除void的函数的特殊性,void是真正意义上的「什么都不返回」,但是Unit函数是返回了值的,只是这个值在我们平时调用时并不太去关心,这样一来,在Kotlin里,所有的函数就都是有返回值的,这样才能更方便地让Kotlin对函数去做抽象,以实现更多Kotlin的高级特性

不强制的check exception

Kotlin对有异常抛出的方法,在调用处并不强制要求进行try catch 操作

下面这段代码,同样一个方法,在Java中调用,编译器会强制要求你进行try catch,但Kotlin不会

因为在Kotlin设计者的角度来看,有很多Exception只有在很极端的case下才会出现,对于这种情况我们没有必要为了极端case而去在调用的每一处地方都加try catchblock从而使我们的代码到处都是try catch,Kotlin选择把这项权利交给用户,如果你觉得这块代码可能会有问题,你可以自行加上try catch,而不是强制要求每处调用都加。

因此我们在调一些方法的时候,最好还是仔细思考一下,这部分是否需要try catch,从而避免不必要crash

高级特性

高阶函数

所谓高阶函数,是指在Kotlin中,函数也可以作为另一个函数的入参或者返回值

fun foo(function: () -> Unit) {
    function()
}

fun bar(): (() -> Unit) {
    return {  }
}

fun main() {
    foo {
        println("foo")
    }
}

但是我们都知道,JVM本身是没有函数类型的,那Kotlin是怎么实现这种效果的呢

通过反编译,我们可以看到,最终foo方法传入的类型是一个Function0类型,然后调用了Function0的invoke方法,那么这个Function0是啥呢,我们看下它的定义

public interface Function0<out R> : Function<R> {
    /** Invokes the function. */
    public operator fun invoke(): R
}

这个类型有一个invoke方法,就是我们方法的方法体

也就是说,下面这段Kotlin代码

fun main() {
    foo {
        println("foo")
    }
}

等价于这段Java代码

public static void main(String[] args) {
    foo(new Function0<Unit>() {
        @Override
        public Unit invoke() {
            System.out.println("foo");
            return Unit.INSTANCE;
        }
    });
}

所以Kotlin的高阶函数本质上是通过对函数的抽象,然后在运行时通过创建Function对象来实现的。

其中Funtion0代表0个参数的函数类型,那同理的,还有Function1Function2,一直到Function22

那这是否是说,Kotlin的高阶函数的参数的函数最多有22个参数呢?

1.3之前是的,但是从1.3开始,Kotlin提供了FunctionN来应对22个参数以上的场景

@SinceKotlin("1.3")
interface FunctionN<out R> : Function<R>, FunctionBase<R> {
    /**
     * Invokes the function with the specified arguments.
     *
     * Must **throw exception** if the length of passed [args] is not equal to the parameter count returned by [arity].
     *
     * @param args arguments to the function
     */
    operator fun invoke(vararg args: Any?): R

    /**
     * Returns the number of arguments that must be passed to this function.
     */
    override val arity: Int
}

自此,参数的长度不会再受限制

神奇的inline

在上一条中,我们提到了,高阶函数在调用时总会创建新的Function对象,如果这个高阶函数会被频繁调用,那么就会有大量的对象被创建,那么就会有性能问题

为此,Kotlin为我们提供了inline关键字,对于下面这段代码,我们给foo函数加了inline关键字,之后我们来看一下它的反编译的结果

inline fun foo(function: () -> Unit) {
    function()
}

fun main() {
    foo {
        println("foo")
    }
}

可以看到,main方法的调用不再直接调用foo函数,而是把foo函数的函数体直接拷贝了过来进行调用

这就是inline的作用,「内联」,通过inline,我们可以把函数调用替换到实际的调用处,从而避免Function对象的创建,进一步避免性能损耗。

如果你提供的方法对性能要求很高,那么我们推荐你使用inline关键字,但是请注意,inline是有副作用的,因为inline是在编译时进行代码的替换,那么就意味着你inline的函数体里的代码,会被替换到每一个调用的地方,从而导致字节码的膨胀,如果你的产物对产物大小有严格的要求,你需要自己把握性能和包体积之间的取舍关系

reified帮你实现真泛型

众所周知,JVM的泛型是假泛型,虽然我们有办法在运行时获取泛型的类型信息,但是像下面这种代码,是没有办法通过编译的,因为在编译时没办法通过泛型参数T来获取到具体的类型信息

fun <T> foo() {
    println(T::class.java) // 会报错
}

但是Kotlin为我们提供了reified关键字,通过这个关键字,我们就可以让上面的代码成功编译并且运行

inline fun <reified T> foo() {
    println(T::class.java)
}

reified关键字必须和inline搭配使用,上面说过了,inline会把函数体替换到调用处,调用处的泛型类型一定是确定的,那么就可以直接把泛型参数进行替换

比如说这行调用

foo<String>()

调用的泛型类型为String,那inline进行内联的时候,就可以直接把这一行代码替换为

println(String::class.java)

从而达成了「真泛型」的效果

SAM转化与fun interface

对于这样的Java代码

public interface OnClickListener {
    void onClick(Object view);
} 
public class Test {
    private OnClickListener onClickListener;

    public void setOnClickListener(OnClickListener onClickListener) {
        this.onClickListener = onClickListener;
    }
}

我们在进行Kotlin的调用的时候,可以直接这么写

fun main() {
    val test = Test()

    test.setOnClickListener { view ->
        println("clicked")
    }
}

但是为什么呢,正常来说不是应该像下面这么写吗?

fun main() {
    val test = Test()
    test.setOnClickListener(object : OnClickListener {
        override fun onClick(view: Any?) {
            println("clicked")
        }
    })
}

我们把上面的OnClickListener称为SAM(Single Abstract Method 只有一个方法的接口,称之为“单个抽象方法接口”),对于SAM,我们其实并不是想要这个接口类型,而是想要接口中的那个函数,所以Kotlin的编译器帮我们做了底层工作,让我们在上层调用时,对于SAM类型,可以直接传一个和接口中函数类型一致的函数,像这样

fun main() {
    val test = Test()

    test.setOnClickListener({ view ->
        println("clicked")
    })
}

同时由于Kotlin的另一个语法特性,「如果函数是函数入参的最后一个参数,那么它可以被放到括号后,如果放到括号后没有其他参数,则括号可以省略」,最终实现了我们最开始的那种效果

但是需要注意的是,这种SAM转换的特性,只对Java的接口生效,如果我们把接口改为Kotlin

interface OnClickListener {
    fun onClick(view: Any?)
}

则编译就会报错,因为Kotlin编译器不会为Kotlin的SAM做转换,导致类型无法识别

但是这种特性属实是不太友好,所以从Kotlin 1.4开始,支持了fun interface这一特性,我们给Kotlin的接口声明上加一个fun关键字,来标识这是一个SAM的接口

fun interface OnClickListener {
    fun onClick(view: Any?)
}

这样Kotlin针对这个接口,也可以做SAM转化了

代理能帮我们做什么

在Kotlin中,提供了by关键字来做代理

比如说对于以下代码,我们给Bar的构造函数传入了一个Foo的对象,然后把具体的实现代理给了传入的Foo对象,但是这样写起来很麻烦,每新增一个方法都需要我们手动去实现,而且还会导致这个类的行数迅速膨胀。

interface Foo {
    fun foo()
}

class Bar(private val foo: Foo) : Foo {
    override fun foo() {
        foo.foo()
    }
}

但是有了by之后,我们可以这样写

interface Foo {
    fun foo()
}

class Baz(private val foo: Foo) : Foo by foo {

}

现在所有的方法都被自动代理给了foo对象,不需要我们再手动维护

by的作用还不止如此,它还可以代理属性。Kotlin提供了ReadOnlyPropertyReadWriteProperty来让我们做只读和读写的属性的代理,我们来看一个例子

class DelegateObject : ReadWriteProperty<Any?, String> {

    private var content = ""

    override fun getValue(thisRef: Any?, property: KProperty<*>): String {
        content += Random.nextInt().toString()
        return content
    }

    override fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
        content = value + value
    }

}

fun main() {
    var str by DelegateObject()
    println(str)
    str = "foo"
    println(str)
}

//-2143661939
//foofoo-1905752020

在这个例子中,我们把str代理给了DelegateObject对象,那么这个str变量的读写都会被代理到对应的getValuesetValue方法上

在这个代理中,我们让它每次赋值的时候,都会赋付过来的值再额外拼接一遍,在读值的时候会每次都在末尾加一个随机的Int值,这样就有了我们代码的效果,第一次读值的时候虽然我们什么值都没有赋,但是会有一个数字,第二次我们虽然赋值的是foo,但是读值读出来又不一样

通过这种机制,我们可以可以做到很多简洁的工具类的封装,比如说下面这个SharedPreference的代理

class Preference<T>(
    val context: Context,
    val name: String = "",
    val default: T,
    val prefName: String = "default"
)
    : ReadWriteProperty<Any?, T>{

    private val prefs by lazy {
        context.getSharedPreferences(prefName, Context.MODE_PRIVATE)
    }

    override fun getValue(thisRef: Any?, property: KProperty<*>): T {
        return findPreference(findProperName(property))
    }

    private fun findProperName(property: KProperty<*>) = if(name.isEmpty()) property.name else name

    private fun findPreference(key: String): T{
        return when(default){
            is Long -> prefs.getLong(key, default)
            is Int -> prefs.getInt(key, default)
            is Boolean -> prefs.getBoolean(key, default)
            is String -> prefs.getString(key, default)
            else -> throw IllegalArgumentException("Unsupported type.")
        } as T
    }

    override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
        putPreference(findProperName(property), value)
    }

    private fun putPreference(key: String, value: T){
        with(prefs.edit()){
            when(value){
                is Long -> putLong(key, value)
                is Int -> putInt(key, value)
                is Boolean -> putBoolean(key, value)
                is String -> putString(key, value)
                else -> throw IllegalArgumentException("Unsupported type.")
            }
        }.apply()
    }

}

我们在使用的时候,就可以这么使用

var userName by Preference(this, default = "")

这样我们在读写这个值的时候,既可以拿到这个值,又可以直接把这个值和SharedPreference存储的值进行同步,非常方便

Contract的妙用

在开发的过程中,我们可能用过Kotlin提供的函数,比如像下面的isNullOrEmpty,当我们在使用它的时候,如果判断了它不是空,即使它的类型本身是可空的,但是编译器会帮我们做推断,进入到if分支里去后,str一定是不会为空的

但是当我们自己写一个类似的函数的时候,我们会发现,编译器并不会为我们做类型的推断

这是为什么呢?

我们来看一下isNullOrEmpty这个方法的实现

public inline fun CharSequence?.isNullOrEmpty(): Boolean {
    contract {
        returns(false) implies (this@isNullOrEmpty != null)
    }

    return this == null || this.length == 0
}

可以看到这个方法就只是return了null值和lenth的判断,只不过在return前调用了contract

contract意为「契约」或「约定」,这个方法正是通过contract和编译器达成了约定,在这个方法返回false的时候(returns(false)),当前的值一定不为空(implies (this@isNullOrEmpty != null)

知道了原理我们可以如法炮制来改造我们自己的函数

可以发现现在我们自己的函数调用的地方,Kotlin编译器也会为我们做类型的推断了

除了做类型的推断,契约还可以做很多有趣的事情,比如下面这个例子

为什么Kotlin自带的let可以实现对num的赋值,而我们自己实现的myLet不行呢

因为Kotlin自己的let,使用契约告诉了Kotlin编译器,block这个函数只会被调用一次,所以num自然可以可以是val的,而我们自己实现的myLet,编译器不知道block到底会被调用几次,如果被调用多次的话,num2必须要是var的,所以这里才会报错

public inline fun <T, R> T.let(block: (T) -> R): R {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    return block(this)
}

contract目前提供了一下几个方法,供我们和编译器达成约定,通过这几个方法,我们可以实现很多意想不到的效果,大家可以开动脑筋发掘一下

public interface ContractBuilder {
    /**
     * Describes a situation when a function returns normally, without any exceptions thrown.
     *
     * Use [SimpleEffect.implies] function to describe a conditional effect that happens in such case.
     *
     */
    // @sample samples.contracts.returnsContract
    @ContractsDsl public fun returns(): Returns

    /**
     * Describes a situation when a function returns normally with the specified return [value].
     *
     * The possible values of [value] are limited to `true`, `false` or `null`.
     *
     * Use [SimpleEffect.implies] function to describe a conditional effect that happens in such case.
     *
     */
    // @sample samples.contracts.returnsTrueContract
    // @sample samples.contracts.returnsFalseContract
    // @sample samples.contracts.returnsNullContract
    @ContractsDsl public fun returns(value: Any?): Returns

    /**
     * Describes a situation when a function returns normally with any value that is not `null`.
     *
     * Use [SimpleEffect.implies] function to describe a conditional effect that happens in such case.
     *
     */
    // @sample samples.contracts.returnsNotNullContract
    @ContractsDsl public fun returnsNotNull(): ReturnsNotNull

    /**
     * Specifies that the function parameter [lambda] is invoked in place.
     *
     * This contract specifies that:
     * 1. the function [lambda] can only be invoked during the call of the owner function,
     *  and it won't be invoked after that owner function call is completed;
     * 2. _(optionally)_ the function [lambda] is invoked the amount of times specified by the [kind] parameter,
     *  see the [InvocationKind] enum for possible values.
     *
     * A function declaring the `callsInPlace` effect must be _inline_.
     *
     */
    /* @sample samples.contracts.callsInPlaceAtMostOnceContract
    * @sample samples.contracts.callsInPlaceAtLeastOnceContract
    * @sample samples.contracts.callsInPlaceExactlyOnceContract
    * @sample samples.contracts.callsInPlaceUnknownContract
    */
    @ContractsDsl public fun <R> callsInPlace(lambda: Function<R>, kind: InvocationKind = InvocationKind.UNKNOWN): CallsInPlace
}