3 亿美元的 bug,Kotlin 帮你避免 | 内联类 value class

简介: 3 亿美元的 bug,Kotlin 帮你避免 | 内联类 value class

3亿美元的 bug


假设有这样一个方法:


interface Timer{
    fun delay(long: Long, block: () -> Long)
}


从方法声明可以猜出功能是“延迟long后执行block,并且要求 block 返回一个 Long。”


至于延迟的是秒还是毫秒?block 的返回值表示什么意思?不得而知。


不得不查看接口实现类:


class Timer1 : Timer{
    override fun delay(long: Long, block: () -> Long) {
        handler.postDelayed({
            val seconds = block()
            print(seconds)
        }, long)
    }
}


从 Timer1 的实现可以得知,时间间隔是毫秒,而 block 返回值是秒。


但项目中可能同时存在下面这样的实现:


class Timer2 : Timer {
    override fun delay(long: Long, block: () -> Long) {
        GlobalScope.launch {
            delay(long.toDuration(DurationUnit.SECONDS))
            val milliseconds = block()
            print(milliseconds)
        }
    }
}


此时,时间间隔是秒,而 block 返回值是毫秒。


这就很头痛了,因为使用 Timer 接口时,不知道该如何传参。


理论上接口是一种抽象,在使用它时不需要关心内部实现细节。


显然,Timer 的定义破坏了接口的抽象性。为了保证不出错,在使用时不得不这样做:


val timer = ...
val delaySeconds = 1
if( timer is Timer2 ) {
    timer.delay(seconds) {...}
} else if( timer is Timer1 ) {
    timer.delay(seconds*1000) {...}
}


这样的话,Timer 接口还有什么存在的必要?


多态是编程语言支持的一种特性,这种特性使得静态的代码运行时可能产生动态的行为,这样一来编程时不需要为类型所烦恼,可以编写统一的处理逻辑而不是依赖特定的类型。”


这段话摘自如何“好好利用多态”写出又臭又长又难以维护的代码?| Feeds 流重构方案。上面 Timer 接口的现状恰恰是这段话的反面。


但若不这样做,程序就会发生错误。这类错误中最著名的就是“the Mars Climate Orbiter”,即 NASA 的火星气候探测器。该项目耗资 3 亿美元,却因程序 bug 导致失败。项目中有一个方法返回的值是以lbf·s为单位,而与之配套的另一个方法的入参是以N·s为单位。在物理世界里它们相差十万八千里,但在计算机的世界里它们都表达成double


修复1:语义弱约束


Timer.delay() 方法的参数 long 缺乏语义,在具体业务场景中 long 可以表达非常多的语义,比如:时间戳、毫秒、秒、纳秒等等。


可以通过有意义的变量命名来约束参数的语义:


interface Timer{
    fun delay(seconds: Long, block: () -> Long)
}


这的确可以为参数增加语义,但对返回值就无能为力了,比如 block 的返回值还是语义不明。


除了在参数名上做文章,也可以在类型名上做文章:


typealias Second = Long
interface Timer{ 
    fun delay(seconds: Second, block: () -> Long) 
}


看上去引入了一个新的类型Second,但对于编译器来说SecondLong是一个东西的两个名字。编译器并不会因为你传入了毫秒而报错。


这其实是错误使用typealias的一个示范,typealias 应该用于“化简名字”,比如:


// 把一个长 lambda 化简,取一个表达语义的别名,如此一来方法签名就可以被简化
typealias OnWindowClick = (x: Int, y: Int, view: View) -> Boolean
fun setOnWindowClickListener(block: (x: Int, y: Int, view: View) -> Boolean) {}
fun setOnWindowClickListener(block: OnWindowClick) {}
// 将一个嵌套泛型化简
typealias ViewCache = HashMap<String, List<View>>


typealias 隐藏了细节,降低了复杂度,增加了代码可读性。


还有一种约束语义的方式是添加注释


interface Timer {
    /**
     * @param seconds,the seconds to delay
     * @param block,the block to be invoked after [seconds], 
     *       the return value of it is the consumed time in seconds
     */
    fun delay(seconds: Long, block: () -> Long)
}


修复2:语义强约束


上述这两种约束语义的方式都不是强制性的。假设接口的实现者都阅读了注释并按照规定实现接口,但也无法保证调用者不把毫秒传给 seconds 参数。


可以通过新增一个类型,让编译器帮我们做类型检查:


data class Second(val value: Long)
interface Timer{
    fun delay(second: Second, block: () -> Second)
}


现在如下的调用在编译前就会报错:


timer.delay(1000L){...} // 传入 1000 ms,来表示延迟一秒


现在想延迟一秒,必须这样做:


timer.delay(Second(1)){...}


在方法调用处,通过类型强行提示,可以避免明明想延迟一秒,但却写出这样的代码timer.delay(Second(1000))


不过这样做是有性能代价的,因为原本是基础类型的赋值,现在变成需要构建新的包装对象(在堆中分配内存,并在栈中指向这块内存)。


修复3:内联类


为了解决这种问题,Kotlin 在 1.3.0 推出了inline class,在 1.5.0 用value class取而代之。它的用法如下:


@JvmInline
value class Second(val value: Long)
interface Timer{
    fun delay(second: Second, block: () -> Second) 
}


通过关键词value+@JvmInline声明了一个内联类。然后就可以像这样延迟一秒执行:


timer.delay(Second(1)){...}


因为发生了内联,这行调用和上一节的不同之处在于,当 kotlin 编译成 java 后,内联类型不会被创建,而是将其成员内联到调用处。不过在编译之前会进行类型检查,即下面这样的调用会报错:


timer.delay(1L){...}


内联类在保证类型安全的同时做到了零性能损耗。


对内联类做个总结,它通常用于约束语义,并以零性能损耗的方式通过编译器保证类型安全


内联类注意事项


参数限制


内联类只能在构造方法中声明一个成员参数,在多参数场景下,只能退而求其次使用性能略差的data class


成员变量/方法 & 实现接口


普通类具备的功能,内联类几乎都具备:


@JvmInline
value class Name(val s: String) {
    // init 代码块
    init {
        require(s.length > 0) { }
    }
    // 计算型成员变量(没有backing field)
    val length: Int
        get() = s.length
    // 成员方法
    fun greet() {
        println("Hello, $s")
    }
}
fun main() {
    val name = Name("Kotlin")
    name.greet() // greet 被编译成静态方法
    println(name.length) // length属性的get方法也被编译成静态方法
}


内联类也可以实现接口:


interface Printable {
    fun prettyPrint(): String
}
@JvmInline
value class Name(val s: String) : Printable {
    override fun prettyPrint(): String = "Let's $s!"
}
fun main() {
    val name = Name("Kotlin")
    println(name.prettyPrint()) // prettyPrint 被编译成静态方法
}


但是内联类不能被继承。


内联条件


内联类的成员被内联到调用处是有条件的,条件是 “内联类没有被当成其他类型使用” 。若不满足这个条件,内联会失败,此时会发生装箱,即内联类被当成一个包装类被构建,就没有性能优势了:


interface I
// 一个实现了接口的内联类
@JvmInline
value class Foo(val i: Int) : I
fun asInline(f: Foo) {}
fun <T> asGeneric(x: T) {}
fun asInterface(i: I) {}
fun asNullable(i: Foo?) {}
fun <T> id(x: T): T = x
fun main() {
    val f = Foo(42)
    asInline(f)    // 内联成功:因为内联类被当成其原本的类型Foo使用
    asGeneric(f)   // 内联失败: 因为内联类被当成 T 使用
    asInterface(f) // 内联失败: 因为内联类被当成 I 使用
    asNullable(f)  // 内联失败: 以为内联类被当成 Foo? 使用
}


在 Java 中使用


@JvmInline
value class UInt(val x: Int)
fun compute(x: Int) { }
fun compute(x: UInt) { }


上述两个方法被编译成 java 代码后,拥有完全相同的签名。为了解决这个问题,系统会自动为第二个方法名追加一个哈希码以示区别,它最终会被表达成public final void compute-<hashcode>(int x)


为了能在在 Java 中调用带内联的方法,可以为其添加@JvmName注解:


@JvmInline
value class UInt(val x: Int)
fun compute(x: Int) { }
@JvmName("computeUInt")
fun compute(x: UInt) { }


通过注解为带内联的方法取一个别名。


参考


Value Classes in Kotlin: Good-Bye, Type Aliases!? | QuickBird Studios Blog


Effective Kotlin Item 49: Consider using inline value classes (kt.academy)


Inline classes | Kotlin (kotlinlang.org)


目录
相关文章
|
1月前
|
数据安全/隐私保护 Kotlin
Kotlin - 类成员
Kotlin - 类成员
58 6
|
1月前
|
数据安全/隐私保护 Kotlin
Kotlin - 类成员
Kotlin - 类成员
38 1
|
17天前
|
Kotlin
Kotlin教程笔记(20) - 枚举与密封类
Kotlin教程笔记(20) - 枚举与密封类
35 8
|
1月前
|
Kotlin
Kotlin - 枚举与密封类
Kotlin - 枚举与密封类
24 3
Kotlin - 枚举与密封类
|
18天前
|
Java Kotlin
Kotlin教程笔记(13) - 类及成员的可见性
Kotlin教程笔记(13) - 类及成员的可见性
32 3
|
18天前
|
存储 前端开发 Java
Kotlin教程笔记(18) - 数据类
Kotlin教程笔记(18) - 数据类
|
23天前
|
数据安全/隐私保护 Kotlin
Kotlin教程笔记(7) - 类成员
Kotlin教程笔记(7) - 类成员
27 5
|
27天前
|
数据安全/隐私保护 Kotlin
Kotlin - 类成员
Kotlin - 类成员
45 9
|
21天前
|
Java 开发者 Kotlin
Kotlin教程笔记(2) - 类与构造器
Kotlin教程笔记(2) - 类与构造器
22 1
|
23天前
|
Java Kotlin
​ Kotlin教程笔记(13) - 类及成员的可见性
​ Kotlin教程笔记(13) - 类及成员的可见性