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
,但对于编译器来说Second
和Long
是一个东西的两个名字。编译器并不会因为你传入了毫秒而报错。
这其实是错误使用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)