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

3,277 阅读6分钟

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)