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

翻譯說明:java

原標題: Inline Classes and Autoboxing in Kotlin安全

原文地址: typealias.com/guides/inli…性能優化

原文做者: Dave Leedsapp

在上一篇文章中,咱們知道了Kotlin的實驗階段的新特性內聯類是如何讓咱們"建立須要的數據類型可是不會損失咱們須要的性能"。咱們瞭解到:ide

  • 一、內聯類包裝了基礎類型的值
  • 二、當代碼被編譯的時候,內聯類的實例將會被替換成基礎類型的值
  • 三、這能夠大大提升咱們應用程序的性能,特別是當基礎類型是一個基本數據類型時。

可是在某些狀況下,內聯類實際上比傳統的普通類執行速度更慢! 在這篇文章中,咱們將去探索在不一樣的場景下使用內聯類編譯代碼中到底會發生什麼- 由於若是咱們知道如何高效地使用他們,咱們才能從中得到更高的性能。 函數

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

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

好的,讓咱們如今開始吧!性能

高性能的奧祕

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的接口。

那麼編譯後的代碼怎麼可能會向addToScore函數發送一個Int類型(更確切的說是Java中的int類型)的實參,由於int類型是不會去實現Amount接口的。

答案固然是它不能啊!

所以,在這種場景下,Kotlin仍是繼續使用爲Points類型,而不是在編譯代碼中使用整數替換。咱們將這個Points類稱爲包裝類型,而不是基礎類型Int

最重要的是須要注意,這並不意味這該類永遠不會被內聯。它只意味着代碼中某些地方沒有被內聯。例如,讓咱們來看一下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值的箱子。

由於編譯器會自動將值放入箱子中,因此咱們把這個過程叫作自動裝箱

如今咱們知道了爲何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. 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?) {
    // ...
}
複製代碼

因爲咱們有兩種場景,而且每一個場景下又存在非空與可空兩種狀況,由於總共須要考慮四種狀況。因此咱們爲以下四種場景製做一張真值表!

對於每一種狀況,咱們將考慮:

  • 一、基礎類型的可空和非空
  • 二、使用內聯類地方的可空和非空
  • 三、以及每種狀況編譯後的是否觸發自動裝箱操做

好消息的是,當基礎類型是引用類型時,大多數的狀況下,使用的內聯類都將被編譯成基礎類型。這就意味着基礎類型的值能夠被使用且不會觸發自動裝箱操做。

這裏只有一種狀況會觸發自動裝箱操做,咱們須要注意 - 當基礎類型和使用類型都爲可空類型時。

爲何在這種狀況下會觸發自動裝箱操做?

由於當這兩種場景都存在值可空狀況下,你最終獲得的將是不一樣的代碼分支,具體取決於這兩種場景哪種是空的。例如,看看這段代碼:

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?) {
    // ...
}
複製代碼

讓咱們構建一個真值表,就像對上面的引用類型同樣作出的總結

正如你所看到的那樣,上面表格中對於基本數據類型的結果除了場景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編譯器和JIT編譯器)之類的東西也會致使與咱們的預期性能相差很大。若是須要真正瞭解編碼決策對性能的影響,惟一的辦法就是使用基準測試工具(好比JMH)實際運行測試。

總結

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

  • 超類型
  • 泛型
  • 可空性

如今咱們知道這一點,咱們能夠作出更加明智的選擇,來高效使用內聯類。

你準備好本身開始使用內聯類了嗎? 你無需等待-你如今就能夠在IDE中嘗試使用它!

譯者有話說

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

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

能夠看到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爲了兼容null,不得不把它自動裝箱使用包裝器類型。

到這裏有關內聯類的知識文章就徹底結束了,因爲內聯類仍是一個實驗性的特性,後期正式版本的API可能會有變更,固然我也緊跟官方最新動態,若是變更會盡快以文章形式總結出來。若是你這一期內聯類知識掌握了,後面在怎麼變更,你都能很快掌握它,並也會獲得更多本身的體會。歡迎繼續關注~~~

Kotlin系列文章,歡迎查看:

原創系列:

翻譯系列:

實戰系列:

歡迎關注Kotlin開發者聯盟,這裏有最新Kotlin技術文章,每週會不按期翻譯一篇Kotlin國外技術文章。若是你也喜歡Kotlin,歡迎加入咱們~~~

相關文章
相關標籤/搜索