Blog


15. Kotlin 究竟该不该用 lateinit?

  • 2020年5月24日
  • Kotlin

使用 lateinit 的初衷

你是如何看待 lateinit?不少同学对它敬而远之,特别是使用 lateinit 踩坑之后。因为被 lateinit 标记的变量,不再接受空安全检查,它的表现更像是一个普通的 Java 变量。也有同学喜欢尽可能的用上它,把 lateinit 作为介于 nonnull 和 nullable 之间的一个状态:对象构造时为 null,在某一个时刻被初始化后一直都是 nonnull,这样属性的不确定性便减少了。

我也是一个 lateinit 的坚定支持者。原因之一是 lateinit 属性比 nullable 属性在行为上更可靠。所谓可靠,即其行为是确定的。当调用 lateinit 变量时,它此时如果没有被初始化,就会抛出UninitializedPropertyAccessException;如果已经初始化了,则操作一定会执行。反看 nullable 变量,你在任一时刻操作它的时候,它都可能不被执行,因为可空变量在任意时刻都可能被置空。这样的行为在排查问题的时候会造成阻碍。为了减少程序运行的不确定性,我更希望尽可能使用 lateinit 代替 nullable。

另一个原因是既然 Kotlin 语言设计者提供这样的关键字,说明是有可用之处的。

使用 lateinit 的坚持

理性分析完,随后我便开始一顿操作。只要是符合以下条件,我就会使用 lateinit 修饰属性:

  • 该属性在对象构造时无法初始化(缺少必要参数),在某个阶段被初始化之后会一直使用。典型的初始化阶段:Activity.onCreate(),自定义模块的 init()
  • 保证对象的调用都在初始化之后
  • 属性无法用空实现代替。

这个策略看起来是没什么问题的,执行的也比较顺利。自测没有问题,测试那边也顺利通过了。但在灰度的期间还是出现了 UninitializedPropertyAccessException

Crash 量也不多,但总还是得解的。Crash 的原因无非就一个:在初始化 lateinit 属性之前调用了该属性。而解决方案根据不同情况有两种:

  • 是异常路径导致,如 Activity.onCreate() 时数据不正确,需要 finish Activity 不再执行后续初始化代码。此时 Activity 仍然会执行 onDestroy(),而 lateinit 属性没有被初始化。如果 onDestroy() 有对 lateinit 属性的操作,此时就会抛出 UninitializedPropertyAccessException

解决方案:使用 ::lateinitVar.isInitialized 方法,对异常路径的 lateinit 属性进行判断,如果没有初始化则不操作。

对比 nullable 属性:lateinit 属性会 crash,nullable 属性不会,且和 lateinit 属性加了初始化判断的效果一致。这种场景下 nullable 属性表现的更好。

  • 是代码逻辑结构不正确导致,如在某些情况下,上层在调用模块 init() 方法之前,就调用了模块的其他方法。此时抛出 UninitializedPropertyAccessException

解决方案:调整代码调用逻辑,保证调用模块init()方法之前不调用模块的其他方法。

对比 nullable 属性:lateinit 属性会 crash,nullable 属性不会。但 lateinit 属性会把问题暴露出来,而 nullable 属性会把问题隐藏起来,导致问题难以发现和解决。

开发者对 lateinit 的争论也大多源自于此。支持 lateinit 的开发者,是希望代码有更好的逻辑性;反对 lateinit 的开发者,是希望代码有更好的健壮性。而对于我来说,我更希望代码有更好的逻辑性,但我也认可“希望代码有更好的健壮性”的想法,就看开发者的取舍了。

这个想法使我坚持使用 lateinit 半年以上。而这一段使用 lateinit 的痛点,也让我开始重新思考 lateinit 的收益。

使用 lateinit 的痛苦

理论和实践都完善了,但使我苦恼的是,UninitializedPropertyAccessException并没有得到高效的解决,而是三头两日时不时的在灰度时冒出来,使我被迫打断当前工作,花上一点时间解决,并延长版本灰度的时间。这不是我想要的效果。

UninitializedPropertyAccessException主要出现这几种场景:

  • 新代码使用了 lateinit 特性,因没有考虑异常路径在测试期间出现 crash;
  • 旧代码重构后对部分属性使用了 lateinit 特性,在复杂的线上环境中出现 crash;
  • 模块内部代码调整/外部调用逻辑调整,如调用时机的调整,导致之前没有问题的代码,在复杂的线上环境中出现 crash。

Kotlin 的 UninitializedPropertyAccessException本质上和 Java 的空指针错误是一样的,都是错误的估计此处对象不可能为空导致的。在 Java 中我们通过增加一堆空判断来解决这个问题,Kotlin 可以使用 nullable 对象。

而 lateinit 通过舍弃空安全机制,把空安全交回到开发者手上(就像 Java 那样)。但在这几个月的实践中,我发现让开发者自己掌控空指针问题,是困难的。

我发现之前我对 lateinit 的思考,缺少了一个很重要的角度:软件工程的角度。代码是不断迭代的,维护者可能不止一个人,而 lateinit 对空指针问题的保护不足,容易让新的空指针问题出现在代码迭代之后。没有从软件工程的角度去看待问题,导致我对代码的规划过于理想,让代码降低了健壮性。现在我想给 lateinit 增加这样一个观点:lateinit 是一个和软件工程相悖的特性,它不利于软件的健康迭代。

使用 lateinit 的建议

如果你仍想使用 lateinit,那么我建议:

  1. 充分考虑异常分支的执行情况;
  2. 充分考虑异常时序的执行情况;
  3. 充分考虑代码稳定性,是否容易发生需求变更导致结构调整。

目前依然有典型的 lateinit 适用场景,如Activity.onCreate()初始化的属性。但是不要忘了如果有可能初始化失败,需要在异常路径onDestroy()上增加::lateinitVar.isInitialized判断。

对于 Fragment,如果在onCreate执行了 finish(),它的异常路径会是onCreateView()onViewCreate()onDestroy()

版权所有,转载请注明出处:
https://sickworm.com/?p=1837




14. Kotlin 使用高阶函数处理集合数据

本文将介绍如何使用 Kotlin 的高阶函数,如sumBy, reduce, fold, mapfilterforEach 等,来应对常见的集合数据处理场景。不了解高阶函数的同学可以先看下之前的文章。

遍历求和 sumBy

场景:输入一个账户列表List,求这些账户的财产总和sum

一般来说 Java 可以这样写:

public int getAccountsSum(List accounts) {
    int sum = 0;
    for (Account a: accounts) {
        sum += a.value;
    }
    return sum;
}

Kotlin 可以使用高阶函数 sumBy

val sum = accounts.sumBy { it.value }

那么sumBy做了什么呢?点击源码可以看到,其实它做的事情和上面 Java 实现的getAccountsSum是一样的,只是增加的值是通过我们传入的 lambda 来计算,而不是写死的Account.value这种通过传入函数来完成函数功能的函数,被称为高阶函数,高阶函数也因此具有很高的通用性和复用效率。

不仅传入函数作为参数的函数被称为高阶函数,返回值为函数的函数也同样被称为高阶函数。

遍历求值 reduce

sumBy有一点不好,他只能求和,而且只接受IntDouble两种类型的值(sumBy:不然我起这个名字干嘛?)。如果我们要得到一个更复杂的逻辑的结果呢?

场景:输入一个列表List,返回它们全部相乘的结果。

Java:

public int getResult(List values) {
    int result = 0;
    for (Account a: accounts) {
        result *= values;
    }
    return result;
}

Kotlin 我们可以使用reduce

val result = values.reduce { acc, v -> acc * v }

reduce 的逻辑是:将初始值acc设置为集合的第一个值,然后从第二个值开始,依次执行acc = lambda(acc, v),遍历完后返回acc。**reduce不仅限做加法运算,它比sumBy具有更广的通用性。

那如果reduce可以代替sumBy,为什么还需要sumBy?——因为它写起来更简单呀!

如果集合为空,reduce会抛出 UnsupportedOperationException(“Empty collection can’t be reduced.”)

更通用的遍历求值 fold

细心的同学已经发现了,sumBy的场景和reduce的场景用的是不同的数据结构。因为acc会被初始化为集合的第一个元素,所以reduce函数的输出也被限制为集合的范型类型。也就是说,sumBy的场景无法用reduce代替。

那 Kotlin 有没有能指定acc类型的高阶函数?有的,它叫fold

我们再回到sumBy的场景:输入一个账户列表List,求这些账户的财产总和sum

val result = accounts.fold(0) { acc, v -> acc + v.value }

foldreduce多了一个参数——初始值,用来赋值给acc。得益于范型,我们可以通过这个办法来指定acc的类型。这样一来,fold可以完美替代sumBy的场景。而相比foldsumBy更专用,表意更清晰,写起来也更简洁。

fold还有另一点好:因为acc由传入参数初始化,所以没有集合不能为空的限制。所以绝大部分情况下,我都建议使用fold来代替reduce

JavaScript 的 reduce 函数就是 Kotlin 的 fold 函数。u1s1,Kotlin 的 reduce 函数挺危险的,还有类型限制,不建议使用。

过滤集合 filter

场景:输入一个账户列表List,返回资产小于 100 的账户:

Java:

public List getPoorAccounts(List accounts) {
    List qbAccounts = new ArrayList<>(); // 想起虾米音乐的 穷逼 VIP 了
    for (Account a: accounts) {
        if (a.value < 100) {
            qbAccounts.add(a);
        }
    }
    return qbAccounts;
}

Kotlin 可以使用filter函数:

val qbAccounts = accounts.filter { it.value < 100 }

filter的逻辑是,新建一个空的 ArrayList(),然后把 lambda 返回值为 true 的元素加入到这个列表里。

列表生成列表 map

场景:输入一个账户列表List,找到所有资产大于 10000 的账户,封装成 VIP 账户返回:

Java:

public List getVipAccounts(List accounts) {
    List vipAccounts = new ArrayList<>();
    for (Account a: accounts) {
        if (a.value >= 10000) {
            vipAccounts.add(new VipAccount(a));
        }
    }
    return vipAccounts;
}

Kotlin 可以通过filter函数加map函数完成:

val vipAccounts = accounts
        .filter { it.value >= 10000 }
        .map { VipAccount(it) }

第一步我们用filter函数筛选出资产大于 10000 的账户,然后用map函数将过滤后的每一个账户转换为VipAccountmap的逻辑也很简单,它回返回一个和调用者大小相同的列表,具体的元素值为 lambda 的执行结果。

实在不适合,就用 forEach 吧

如果遇到了已知高阶函数都不适合的场景,不妨试试用forEach代替传统的 for 循环。为什么?因为写起来稍微简单一点。。

accounts.forEach {
    println("account: $it")
}

什么?你还想要 index下标?

accounts.forEachIndexed { index, account ->
    println("index: $index")
    println("account: $account")
}

不仅forEach有下标版本forEachIndexed,几乎所有高阶函数都有对应的 Indexed 版本。

Kotlin 官方提供了数十个高阶函数,但其实掌握了以上几个高阶函数,基本可以 cover 所有场景了。其他的只是写的简洁还是写的复杂一点的区别。而且你还有另一条路可以走:自己写一个特定的高阶函数。

担心性能?

大家可能会担心,如此频繁的声明 lambda,会不会使得类的数量大量膨胀?其实官方提供的高阶函数,都是用inline关键字修饰的。这意味着不仅高阶函数的调用最终会被函数的实际代码代替,而且声明的 lambda 也会被解析成具体的代码,而不是方法调用。所以Kotlin 高阶函数用 inline 关键字修饰,所以 lambda 不会生成新的 jvm class。而我们在声明自己的高阶函数时,也应该用inline关键字修饰,防止类数量膨胀。

大家可能担心的另一点,像mapfilter这样返回列表的高阶函数,每一次操作都会生成一个列表,这会不会增加垃圾回收的压力?答案是会的。但如果数据量不是万级别的,操作频率不是毫秒级别的,对性能的影响实在小之又小,特别是在移动端的场景更是难以遇到。但我们还是要了解高阶函数对性能开销,在对性能要求高的位置避免对象申请(如UI绘制的回调)。

Java 有高阶函数吗?

Java 也类似高阶函数的能力,如 Collections.sort 这种允许自定义排序的方法,和 Java 8 的 steam API。但因为 Java 没有 inline 无法有效的优化 lambda,且 Java 的 lambda 没有完整的闭包特性,无法修改外部变量。还有一些语法的原因,Java 的高阶函数使用起来相对没有那么舒服。

版权所有,转载请注明出处:
https://sickworm.com/?p=1833




13. Kotlin 作用域函数 run/let/apply/also 的使用

上一篇文章我们介绍了作用域函数,并以其中一个作用函数run为例,介绍了作用域函数的使用和原理。除了run之外,Kotlin 官方还内置了letapplyalso这几个作用域函数,下面我们一起来他们的相同点和区别,并举例说明他们的使用场景。

1. 4 个作用域函数 = 2 个特性的两两组合

runletapplyalso,这 4 个作用域函数,其实是 2 个特性的组合结果:

  • 调用作用域函数的对象,是作为this传入,还是作为唯一参数(默认为it)传入;
  • 作用域函数的返回值,是调用的对象,还是 lambda 的返回值。

配合伪代码解释一下:

val result = object.xxx { param ->
    val lambdaResult = param.toString()
    lambdaResult
}

xxx可以是runletapplyalso其中一个。

如果xxx = run/let,那么 result == lambdaResult
而如果xxx = apply/also,那么result == object。且lambdaResult这一行会报错,因为applyalso“` 的 lambda 返回值必须是 Unit。

lambda 最后一行的值会作为 lambda 的返回值。它等价于 return@xxx lambdaResult@xxx表示返回的是这个lambda,而不是退出整个上层方法。如果是不在最后一行返回的代码,比如异常分支,就可以(也只能)这样用。

如果xxx = let/also,那么param == object
而如果xxx = run/apply的话,那么 lambda 是个无参数 lambda,不存在paramparam -> 这里会报错;

如果 lambda 为单参数 lambda,此时param ->可以省略,Kotlin 提供默认的单参数名it。这个很方便,文章后面的代码都会采用这种简写。

总结成表就是:

特性 返回值为this 返回值为lambda结果
调用对象转换为this apply run
调用对象转换为it also let

我们只需要知道 4 个作用域函数分别是 2 个特性的两两组合即可。用的时候直接查源代码,不需要专门记忆。

2. run/let/apply/also 各自的使用场景举例

我们已经知道这 4 个作用域函数的特点了,那么我们怎么用好它们呢?下面一起来看下这几个作用域函数的使用场景。

run

这是工程中的一段代码:

mRecordViewHelper?.run {
    mEffectsView.visibility = View.INVISIBLE
    mLyricViewer.visibility = View.INVISIBLE
    mStatusView = AudioRecordStatusView(CommonContext.getApplicationContext(), null)
    enableSoloCountBackView()
}

run方法很适合用在对某个对象做一系列操作的地方。这里可空对象mRecordViewHelper使用run方法把自己转换成非空对象this“传入”到 lambda 中,并在 lambda 内部进行一系列的赋值和方法调用。

run的返回值是 lambda 的最后一行结果,在这个例子是Unit。使用 lambda 结果的用例较少,主要就是这种转换为this的用法。如果后续遇到特别适合的再补充。

let

使用run还是使用let更像是一个个人喜好的问题,因为有些人就是不习惯把调用对象转换成this,而是更喜欢用入参it

使用run还是使用let的确没有太大区别。不过还是有这几种情况,我更建议你用let

  • 调用对象主要作为参数,而不是用于初始化或方法调用时。
// 赋值和调用的方法都是类的成员和方法,不是 mRecordViewHelper 的成员和方法
mRecordViewHelper?.run {
    myFirstView = firstView
    mySecondView = secondView
    setTargetView(targetView)
}

// 使用 let,不会存在两个 this 混用,代码可读性更高。
mRecordViewHelper?.let {
    myFirstView = it.firstView
    mySecondView = it.secondView
    setTargetView(it.targetView)
}
  • 当 lambda 中需要用到类的this时;
// 因为 this 的变化,导致引用类的 this 需要加@MainActivity。这个和 Java 匿名类的 MainActivity.this 效果一样
mRecordViewHelper?.run {
    setOnClickListener(this@MainActivity)
}

// let 不会改变 this,在需要引用类的 this 的时候比较方便。
mRecordViewHelper?.let {
    it.setOnClickListener(this)
}

总结一下:当 lambda 主要执行的是调用对象的方法和赋值时,建议使用run;而当调用对象主要用作参数时,建议使用let。当 lambda 会用到类的this时,建议使用let

apply

applyrun的区别主要在于,apply返回的是调用对象。这个特性使得apply很适合用来做类似初始化的工作。如:

class VideoConfig {
    val profile = VideoEncodeProfile().apply {
        audioBitRate = 44100
        videoWidth = 480
        videoHeight = 480
        videoFrameRate = 30
        setDefaultQuality()
    }
}

apply 也很适合用来做 property 的初始化,这样 property 的初始化代码就不用写在 init 块里了,做到了代码的高内聚。上面代码的profile 就是 property 初始化的例子。

also

also 的适用场景,和runlet一样,是与apply来对比的。具体建议也和runlet一样:

当 lambda 主要执行的是调用对象的方法和赋值时,建议使用apply;而当调用对象主要用作参数时,建议使用also。当 lambda 会用到类的this时,建议使用also

3. 只有 4 个作用域函数吗?

细心的同学可能已经发现,在 Standard.kt 中,除了runletapplyalso之外,还有好几个作用域函数。其实掌握了这 4 个作用域函数,已经覆盖了大部分使用场景。剩下的几个使用需求没有那么的迫切,但掌握之后,可以帮助你写出更有 Kotlin 味道的代码。

下一篇文章会介绍 Standard.kt 中剩余的作用域函数。

版权所有,转载请注明出处:
https://sickworm.com/?p=1830