[譯]Kotlin中內聯類的自動裝箱和高效能探索(二)

極客熊貓發表於2018-12-08

翻譯說明:

原標題: Inline Classes and Autoboxing in Kotlin

原文地址: typealias.com/guides/inli…

原文作者: Dave Leeds

在上一篇文章中,我們知道了Kotlin的實驗階段的新特性內聯類是如何讓我們"建立需要的資料型別但是不會損失我們需要的效能"。我們瞭解到:

  • 1、內聯類包裝了基礎型別的值
  • 2、當程式碼被編譯的時候,內聯類的例項將會被替換成基礎型別的值
  • 3、這可以大大提高我們應用程式的效能,特別是當基礎型別是一個基本資料型別時。

但是在某些情況下,內聯類實際上比傳統的普通類執行速度更慢! 在這篇文章中,我們將去探索在不同的場景下使用內聯類編譯程式碼中到底會發生什麼- 因為如果我們知道如何高效地使用他們,我們才能從中獲得更高的效能。

[譯]Kotlin中內聯類的自動裝箱和高效能探索(二)

請記住-內聯類始終還是一個實驗性的特性。儘管我一直在寫內聯類系列的文章,並且內聯類也會經歷很多的迭代和修改。本文目前基於Kotlin 1.3 Release Candidate 146中實現的內聯類。

此外,如果你還沒有閱讀過有關內聯類的文章,那麼你首先要閱讀上一篇文章 [譯]Kotlin中內聯類(inline class)完全解析(一)。那樣你就會全身心投入並準備好閱讀這篇文章。

好的,讓我們現在開始吧!

高效能的奧祕

Alan被徹底激怒了!在學習完內聯類之後,他決定開始在他正在研究的遊戲原型中使用內聯類。為了看看內聯類比傳統的普通類到底有多好,他在他遊戲評分系統中寫了一些有關內聯類的程式碼:

interface Amount { val value: Int }
inline class Points(override val value: Int) : Amount

private var totalScore = 0L

fun main() {
    repeat(1_000_000) {
        val points = Points(it)

        repeat(10_000) {
            addToScore(points)
        }
    }
}

fun addToScore(amount: Amount) {
    totalScore += amount.value
}
複製程式碼

Alan編寫了這段程式碼的測試用例。然後,他刪除第二行inline關鍵字,並再次執行這個測試用例。

令他驚訝的是,使用內聯修飾符inline執行速度實際上明顯比沒有內聯情況慢很多。

“到底發生了什麼?”他想知道。

雖然說內聯類可以比傳統的普通類更高效能執行,但是這一切都取決於我們如何合理使用它們-因為我們如何使用它們決定了值是否在編譯程式碼中真的進行內聯操作。

這是正確的 - 內聯類的例項並不總是在編譯的程式碼中內聯。

什麼時候內聯類不會被內聯

讓我們再一起看下Alan的程式碼,看看我們是否可以弄明白為什麼他寫的內聯類可能沒有被內聯。

我們先來看下這段程式碼:

interface Amount { val value: Int }
inline class Points(override val value: Int) : Amount
複製程式碼

在這段程式碼中,內聯類Points實現了Amount介面。當我們呼叫addToScore()函式時,會引發一個有趣的現象,儘管...

fun addToScore(amount: Amount) {
    totalScore += amount.value
}
複製程式碼

addToScore()函式可以接收任何Amount型別的物件。由於PointsAmount的子型別,所以我們可以傳入一個Points型別例項物件給這個函式。

這是基本的常識,沒問題吧?

但是... 假設我們的Points類的例項都是內聯的-也就是說,在原始碼被編譯的階段,它們(Points類的例項)會被基礎型別(這裡是Int整數型別)給替換掉。-可是addToScore()函式怎麼能接收一個基礎型別(這裡是Int整數型別)的實參呢?畢竟,基礎型別Int並沒有去實現Amount的介面。

[譯]Kotlin中內聯類的自動裝箱和高效能探索(二)

那麼編譯後的程式碼怎麼可能會向addToScore函式傳送一個Int型別(更確切的說是Java中的int型別)的實參,因為int型別是不會去實現Amount介面的。

答案當然是它不能啊!

[譯]Kotlin中內聯類的自動裝箱和高效能探索(二)

因此,在這種場景下,Kotlin還是繼續使用為Points型別,而不是在編譯程式碼中使用整數替換。我們將這個Points類稱為包裝型別,而不是基礎型別Int

[譯]Kotlin中內聯類的自動裝箱和高效能探索(二)

最重要的是需要注意,這並不意味這該類永遠不會被內聯。它只意味著程式碼中某些地方沒有被內聯。例如,讓我們來看一下Alan中的程式碼,看看Points什麼時候是內聯的,什麼時候不是內聯的。

fun main() {
    repeat(1_000_000) {
        val points = Points(it) // <-- Points is inlined as an Int here(Points類在這是內聯的,並被當做Int替換)

        repeat(10_000) {
            addToScore(points)  // <-- Can't pass Int here, so sends it
                                //     as an instance of Points instead.(因為這裡不能被傳入Int,所以這裡必須傳入Points例項)
        }
    }
}
複製程式碼

編譯器將盡可能使用基礎型別(例如,Int,編譯為int),但是當它不能被當做基礎型別使用時,它會自動例項化包裝型別的例項(例如,Points)並把它傳遞出去。可以想象下這是編譯後的程式碼(在Java中)大致如下:

public static void main(String[] arg) {
  for(int i = 0; i < 1000000; i++) {
     int points = i;                     // <--- Inlined here(此處內聯)

     for(short k = 0; k < 10000; k++) {
        addToScore(new Points(points));  // <--- Automatic instantiation!(自動例項化)
     }
  }
}
複製程式碼

您可以將Points類想象為包裝基礎Int值的箱子。

[譯]Kotlin中內聯類的自動裝箱和高效能探索(二)

因為編譯器會自動將值放入箱子中,所以我們把這個過程叫做自動裝箱

現在我們知道了為什麼Alan的程式碼在使用內聯類的時候執行速度會比普通類要慢。每次呼叫addToScore()函式時,都會自動例項化一個新的Points類的例項。所以在內部迴圈迭代過程中總共發生100億次堆分配過程,這就是速度減慢的原因。 (相比之下,使用傳統的普通類,而堆分配過程只發生在外層for迴圈中,總共也只有100萬次).

這種自動裝箱過程一般還是很有用的-它是保證型別安全所必需的操作,當然,它同時也帶來了效能開銷成本,每次建立一個堆上新物件時就會存在這樣效能開銷。所以這就意味著作為開發者,瞭解哪種場景下會發生Kotlin進行自動裝箱操作是非常重要的,這樣我們就可以更明智地決定如何去使用內聯類了。

那麼,接下來讓我們一起來看看自動裝箱過程可能會在哪些場景被觸發!

引用超型別時會觸發自動裝箱操作

正如我們所看到的那樣,當我們將Points物件傳遞給接收Amount型別作為形參的函式式,就觸發了自動裝箱操作。

即使你的內聯類沒有去實現介面,但是必須記住一點,內聯類和普通類一樣,所有內聯類都是Any的子型別。所以當你將內聯類的例項賦值給Any型別的變數或者傳遞給Any型別作為形參的函式時,都會觸發預期中的自動裝箱操作。

例如,假設我們有一個可以記錄日誌的服務介面:

interface LogService {
    fun log(any: Any)
}
複製程式碼

由於這個log()函式可以接收一個Any型別的實參,一旦你傳入一個Points的例項給這個函式,那麼這個例項就會觸發自動裝箱操作。

val points = Points(5)
logService.log(points) // <--- Autoboxing happens here(此處發生自動裝箱操作)
複製程式碼

總之一句話 - 當你使用內聯類的例項(其中需要超型別)時,可能會觸發自動裝箱。

自動裝箱與泛型

當您使用具有泛型的內聯類時,也會發生自動裝箱。例如:

val points = Points(5)

val scoreAudit = listOf(points)      // <-- Autoboxing here(此處發生自動裝箱操作)

fun <T> log(item: T) {
    println(item)
}

log(points)                          // <-- Autoboxing here(此處發生自動裝箱操作)
複製程式碼

在使用泛型時,Kotlin為我們自動裝箱是件好事,否則我們會在編譯程式碼中會遇到型別安全的問題。例如,類似於我們之前的場景,將整數型別的值插入到MutableList<Amount>集合型別中是不安全的,因為整數型別並沒有去實現Amount的介面。

而且,一旦考慮到與Java互操作時,它就會變得更加複雜,例如:

  • 如果Java將List<Points>儲存為List<Integer>,它是否應該可以將該型別的集合傳遞給如下這個Kotlin函式呢?
fun receive(list: List<Int>)
複製程式碼
  • Java將它傳遞給下面這個Kotlin函式又會怎麼樣呢?
fun receive(list: List<Amount>)
複製程式碼
  • Java能否可以構建自己的整數集合並把它傳遞給下面這個Kotlin函式?
fun receive(list: List<Points>)
複製程式碼

相反,Kotlin通過自動裝箱的操作來避免了內聯類和泛型一起使用時的問題。

我們已經看到超型別和泛型兩種場景下如何觸發自動裝箱操作。其實我們還有一個值得去深究的場景 - 那就是可空性的場景!

自動裝箱和可空性

當涉及到可空型別的值時,也可能會觸發自動裝箱操作。這個規則有點不同,主要取決於基礎型別是引用型別還是基本資料型別。所以讓我們一次性來搞定它們。

引用型別

當我們討論內聯類的可空性時,有兩種場景可以為空:

  • 1、內聯類自己的基礎型別存在可空和非空的情況
  • 2、使用內聯類的地方存在可空和非空的情況

例如:

// 1. The underlying type itself can be nullable (`String?`)
// 1. 基礎型別自己存在可空
inline class Nickname(val value: String?)

// 2. The usage can be nullable (`Nickname?`)
//使用內聯類時存在可空
fun logNickname(nickname: Nickname?) {
    // ...
}
複製程式碼

由於我們有兩種場景,並且每個場景下又存在非空與可空兩種情況,因為總共需要考慮四種情況。所以我們為如下四種場景製作一張真值表!

對於每一種情況,我們將考慮:

  • 1、基礎型別的可空和非空
  • 2、使用內聯類地方的可空和非空
  • 3、以及每種情況編譯後的是否觸發自動裝箱操作
    [譯]Kotlin中內聯類的自動裝箱和高效能探索(二)

好訊息的是,當基礎型別是引用型別時,大多數的情況下,使用的內聯類都將被編譯成基礎型別。這就意味著基礎型別的值可以被使用且不會觸發自動裝箱操作。

這裡只有一種情況會觸發自動裝箱操作,我們需要注意 - 當基礎型別和使用型別都為可空型別時。

為什麼在這種情況下會觸發自動裝箱操作?

因為當這兩種場景都存在值可空情況下,你最終得到的將是不同的程式碼分支,具體取決於這兩種場景哪一種是空的。例如,看看這段程式碼:

inline class Nickname(val value: String?)

fun greet(name: Nickname?) {
    if (name == null) {
        println("Who's there?")
    } else if (name.value == null) {
        println("Hello, there.")
    } else {
        println("Greetings, ${name.value}")
    }
}

fun main() {
    greet(Nickname("T-Bone"))
    greet(Nickname(null))
    greet(null)
}
複製程式碼

如果name形參是使用了基礎型別的值-換句話說,如果編譯的程式碼是void greet(String name)-那麼它就不可能出現下面三個判斷分支。那就不清楚name是否為空是應該列印Who's There還是Hello There.

相反,函式如果編譯成這樣void greet(NickName name)將是有效的.這意味著只要我們呼叫該函式,Kotlin就會根據需要自動觸發裝箱操作來包裝基礎型別的值。

嗯,這是可以為空的引用型別!但是可以為空的基本資料型別呢?

基本資料型別

當內聯類、基本資料型別和可空性這三種因素碰在一起,我們會得到一些有趣的自動裝箱的場景。正如我們在上面的引用型別中看到的那樣,可空性出現場景取決於基礎型別可空或非空以及使用內聯類地方的可空或非空。

// 1. The underlying type itself can be nullable (`Int?`)
// 1. 基礎型別自己存在可空
inline class Anniversary(val value: Int?)

// 2. The usage can be nullable (`Anniversary?`)
//使用內聯類時存在可空
fun celebrate(anniversary: Anniversary?) {
    // ...
}
複製程式碼

讓我們構建一個真值表,就像對上面的引用型別一樣做出的總結

[譯]Kotlin中內聯類的自動裝箱和高效能探索(二)

正如你所看到的那樣,上面表格中對於基本資料型別的結果除了場景B不一樣,其他的場景都和引用型別分析結果一樣。但是這裡面還是涉及到了其他很多知識,所以讓我們花點時間一一分析下每一種情況。

對於場景A. 很容易就能分析出來。因為這裡根本就沒有可空型別(都是非空型別),所以型別是內聯的,正如我們所期望的那樣。

對於場景B. 這是一種完全不同於上一個真值表中的場景,不知道你是否還記得,JVM上的intboolean等其他基本資料型別實際上是不能為null的。因此,為了更好相容null,Kotlin在此使用了包裝型別(也就觸發了自動裝箱操作)

對於場景C. 這種場景就更有意思了。一般來說,當你有一個類似Int可以為空的基本資料型別時,在Kotlin中,這種基本資料型別會在編譯的時候轉換成Java中的基本資料型別對應的包裝器型別-例如Integer,它(不像int)可以相容null值。對於場景C而言,實際上在使用內聯類地方編譯時候卻使用基礎型別,因為它本身恰好是一個Java中基本包裝器型別。所以在某種層面上,你可以說基礎型別被自動裝箱了,但是這種自動裝箱操作和內聯類根本就沒有任何關係。

對於場景D. 類似於上面引用型別看到的那樣,當基本型別自身為可空以及使用內聯類地方為可空時,Kotlin將在編譯時使用包裝器型別。具體原因和引用型別同理。

其他需要牢記的點

我們已經介紹了可能導致自動裝箱的主要場景。在使用內聯類時,你可能會發現對Kotlin原始碼編譯後的位元組碼進行反編譯,然後根據反編譯的Java程式碼來分析是否出現自動裝箱有很大的幫助。

要在IntelliJ或Android Studio中執行此操作,只需轉到Tools - > Kotlin - >Show Kotlin Bytecode,然後單擊Decompile按鈕。

[譯]Kotlin中內聯類的自動裝箱和高效能探索(二)

此外,請記住還有很多其他層面上都有可能影響內聯類的效能。即使你對自動裝箱有了充分的瞭解,編譯器優化(Kotlin編譯器和JIT編譯器)之類的東西也會導致與我們的預期效能相差很大。如果需要真正瞭解編碼決策對效能的影響,唯一的辦法就是使用基準測試工具(比如JMH)實際執行測試。

總結

在本文中,我們探討了使用內聯類會出現一些效能影響,並瞭解到哪些場景下會進行自動裝箱。我們已經看到如何使用內聯類並會對其效能產生影響,包括涉及到一些具體的使用場景:

  • 超型別
  • 泛型
  • 可空性

現在我們知道這一點,我們可以做出更加明智的選擇,來高效使用內聯類。

你準備好自己開始使用內聯類了嗎? 你無需等待-你現在就可以在IDE中嘗試使用它!

譯者有話說

這篇文章可以說得上是我看過最好的一篇有關Kotlin內聯類效能優化的文章了,感覺非常不錯,作者分析得很全面也很深入。就連官方也沒有給出過如此詳細介紹。關於譯文中有幾點我需要補充一下:

  • 對於Alan那段糟糕的程式碼使用inline class和普通class程式碼比較,粗略算了下時間,對比了真的比較驚人:

[譯]Kotlin中內聯類的自動裝箱和高效能探索(二)

[譯]Kotlin中內聯類的自動裝箱和高效能探索(二)
可以看到inline class看似是個效能優化操作,但是使用不當效能反而比普通類更加差。

  • 有關譯文中的基礎型別、基本資料型別、引用型別做一個對比解釋,怕有人發矇。

基礎型別: 實際上是針對內聯類中包裝的那個值的型別,它和基礎資料型別不是一個東西。這麼說吧,基礎型別既可以是基本資料型別也可以是引用型別

基本資料型別: 實際上就是常用的Int、Float、Double、Short、Long等型別,注意String是引用型別

引用型別: 實際上就是除了基本資料型別就是引用型別,String和我們平時自定義的類的型別都屬於引用型別。

  • 關於上述基本資料型別中的場景B,可能大家還是有點不能理解。這裡給大傢俱體再分析下。

對於基礎資料型別場景B,為什麼會出現自動裝箱操作? 這是因為在Kotlin中使用內聯類的時候用了可空型別,我們可以用反證法來理解下,假設使用可空型別的內聯類地方被編譯成Java中的int等基本資料型別,在Kotlin中類似如下程式碼:

inline class Age(val value: Int)

fun howOld(age: Age?) {
    if(age == null){
        ...
    }
}
複製程式碼

編譯成類似如下程式碼:


void howOld(int age){
    if(age == null){//這樣的程式碼是會報錯的
        ...
    }
}
複製程式碼

[譯]Kotlin中內聯類的自動裝箱和高效能探索(二)

所以原假設不成立,Kotlin為了相容null,不得不把它自動裝箱使用包裝器型別。

到這裡有關內聯類的知識文章就完全結束了,由於內聯類還是一個實驗性的特性,後期正式版本的API可能會有變動,當然我也緊跟官方最新動態,如果變動會盡快以文章形式總結出來。如果你這一期內聯類知識掌握了,後面在怎麼變動,你都能很快掌握它,並也會得到更多自己的體會。歡迎繼續關注~~~

Kotlin系列文章,歡迎檢視:

原創系列:

翻譯系列:

實戰系列:

[譯]Kotlin中內聯類的自動裝箱和高效能探索(二)

歡迎關注Kotlin開發者聯盟,這裡有最新Kotlin技術文章,每週會不定期翻譯一篇Kotlin國外技術文章。如果你也喜歡Kotlin,歡迎加入我們~~~

相關文章