3 億美元的 bug,Kotlin 幫你避免 | 內聯類 value class

語言: CN / TW / HK

highlight: arduino-light

3億美元的 bug

假設有這樣一個方法: kotlin interface Timer{ fun delay(long: Long, block: () -> Long) } 從方法宣告可以猜出功能是“延遲long後執行block,並且要求 block 返回一個 Long。”

至於延遲的是秒還是毫秒?block 的返回值表示什麼意思?不得而知。

不得不檢視介面實現類: kotlin class Timer1 : Timer{ override fun delay(long: Long, block: () -> Long) { handler.postDelayed({ val seconds = block() print(seconds) }, long) } } 從 Timer1 的實現可以得知,時間間隔是毫秒,而 block 返回值是秒。

但專案中可能同時存在下面這樣的實現: kotlin 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 的定義破壞了介面的抽象性。為了保證不出錯,在使用時不得不這樣做: kotlin 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 可以表達非常多的語義,比如:時間戳、毫秒、秒、納秒等等。

可以通過有意義的變數命名來約束引數的語義: kotlin interface Timer{ fun delay(seconds: Long, block: () -> Long) } 這的確可以為引數增加語義,但對返回值就無能為力了,比如 block 的返回值還是語義不明。

除了在引數名上做文章,也可以在型別名上做文章: ```kotlin typealias Second = Long

interface Timer{ fun delay(seconds: Second, block: () -> Long) } `` 看上去引入了一個新的型別Second,但對於編譯器來說SecondLong`是一個東西的兩個名字。編譯器並不會因為你傳入了毫秒而報錯。

這其實是錯誤使用typealias的一個示範,typealias 應該用於“化簡名字”,比如: ```kotlin // 把一個長 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> ``` typealias 隱藏了細節,降低了複雜度,增加了程式碼可讀性。

還有一種約束語義的方式是添加註釋kotlin 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 引數。

可以通過新增一個型別,讓編譯器幫我們做型別檢查: ```kotlin data class Second(val value: Long)

interface Timer{ fun delay(second: Second, block: () -> Second) } 現在如下的呼叫在編譯前就會報錯:kotlin timer.delay(1000L){...} // 傳入 1000 ms,來表示延遲一秒 現在想延遲一秒,必須這樣做:kotlin timer.delay(Second(1)){...} `` 在方法呼叫處,通過型別強行提示,可以避免明明想延遲一秒,但卻寫出這樣的程式碼timer.delay(Second(1000))`

不過這樣做是有效能代價的,因為原本是基礎型別的賦值,現在變成需要構建新的包裝物件(在堆中分配記憶體,並在棧中指向這塊記憶體)。

修復3:內聯類

為了解決這種問題,Kotlin 在 1.3.0 推出了inline class,在 1.5.0 用value class取而代之。它的用法如下: ```kotlin @JvmInline value class Second(val value: Long)

interface Timer{ fun delay(second: Second, block: () -> Second) } 通過關鍵詞`value`+`@JvmInline`聲明瞭一個內聯類。然後就可以像這樣延遲一秒執行:kotlin timer.delay(Second(1)){...} 因為發生了內聯,這行呼叫和上一節的不同之處在於,當 kotlin 編譯成 java 後,內聯型別不會被建立,而是將其成員內聯到呼叫處。不過在編譯之前會進行型別檢查,即下面這樣的呼叫會報錯:kotlin timer.delay(1L){...} ``` 內聯類在保證型別安全的同時做到了零效能損耗。

對內聯類做個總結,它通常用於約束語義,並以零效能損耗的方式通過編譯器保證型別安全

內聯類注意事項

引數限制

內聯類只能在構造方法中宣告一個成員引數,在多引數場景下,只能退而求其次使用效能略差的data class

成員變數/方法 & 實現介面

普通類具備的功能,內聯類幾乎都具備: ```kotlin @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方法也被編譯成靜態方法 } 內聯類也可以實現介面:kotlin 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 被編譯成靜態方法 } ``` 但是內聯類不能被繼承。

內聯條件

內聯類的成員被內聯到呼叫處是有條件的,條件是 “內聯類沒有被當成其他型別使用” 。若不滿足這個條件,內聯會失敗,此時會發生裝箱,即內聯類被當成一個包裝類被構建,就沒有效能優勢了: ```kotlin interface I

// 一個實現了介面的內聯類 @JvmInline value class Foo(val i: Int) : I

fun asInline(f: Foo) {} fun asGeneric(x: T) {} fun asInterface(i: I) {} fun asNullable(i: Foo?) {}

fun id(x: T): T = x

fun main() { val f = Foo(42) asInline(f) // 內聯成功:因為內聯類被當成其原本的型別Foo使用 asGeneric(f) // 內聯失敗: 因為內聯類被當成 T 使用 asInterface(f) // 內聯失敗: 因為內聯類被當成 I 使用 asNullable(f) // 內聯失敗: 以為內聯類被當成 Foo? 使用 } ```

在 Java 中使用

kotlin @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註解:

```kotlin @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)