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) { }
通过注解为带内联的方法取一个别名。
作者:唐子玄
链接:https://juejin.cn/post/7113704624422387720
更多资讯请关注【公众号】
网友评论