[譯]Kotlin中內聯類(inline class)完全解析(一)

mikyou發表於2018-12-06

翻譯說明:

原標題: An Introduction to Inline Classes in Kotlin

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

原文作者: Dave Leeds

無論你是編寫執行在雲端的大規模資料流程程式還是低功耗手機執行的應用程式,大多數的開發者都希望他們的程式碼能夠快速執行。現在,Kotlin的最新實驗性的特性內聯類允許建立我們想要的資料型別,並且還不會損失我們需要的效能!

在這一系列新文章中,我們將從上到下徹底研究一番內聯類!

在本篇文章中,我們將會研究inline class是什麼, 它的工作原理是什麼以及在使用它的時候我們如何去權衡選擇。然後,在接下來的文章中,我們將深入瞭解內聯類的內容,以確切瞭解它是如何實現的,並研究它如何與Java進行互操作。

請記住-這是一個實驗階段的語法特性,並且它正在被積極開發和完善。當前這篇文章是基於Kotlin-1.3M1版本的內聯類實現。

如果你想自己去嘗試使用它,我還寫了一篇配套文章how to enable them in your IDE,以便您可以立即開始使用內聯類和其他Kotlin 1.3功能!

強型別和普通值: 內聯類的案例

星期一早上8點,在給自己倒了一杯新鮮的熱氣騰騰的咖啡之後,然後在專案管理系統中領到一份任務。上面寫道:

向新使用者傳送歡迎電子郵件 - 在註冊後四天

因為已經編寫好了郵件系統,您可以啟動郵件排程程式的介面,正如你下面所看到的:

interface MailScheduler {
    fun sendEmail(email: Email, delay: Int)
}
複製程式碼

看看這個函式,你知道你需要呼叫它...但是為了將電子郵件延遲4天,你會傳遞什麼引數呢?

這個delay引數型別是Int. 所以我們僅僅知道這是一個Integer,但是我們並不知道它的單位是什麼-你是應該傳入4天呢? 或者它代表幾個小時,如果是這樣你傳入的應該是96(24 * 4)。又或者它的單位是分鐘、秒、毫秒...

我們應該如何去優化這個程式碼呢?

怎樣才能讓這個程式碼變得更好呢?

如果編譯器能夠強制指定正確的時間單位。例如,假設接收引數型別不是Int,讓我們更新下interface中函式,讓它接收一個強型別Minutes

interface MailScheduler {
    fun sendEmail(email: Email, delay: Minutes)
}
複製程式碼

現在我們有了強型別系統為我們工作! 我們不可能向這個函式傳送一個Seconds型別引數,因為它只接受Minutes型別的引數!考慮以下程式碼與先前版本相比如何能夠在很大程度上減少錯誤:

val defaultDelay = Days(2)

fun send(email: Email) {
    mailScheduler.sendEmail(email, defaultDelay.toMinutes())
}
複製程式碼

當我們可以充分利用型別系統時,我們提高了程式碼的健壯性。

但是開發者通常不會選擇去為了單一普通值做個包裝器類,而更多是通過傳遞Int、Float、Boolean這種基礎型別。

為什麼會這樣呢?

通常,由於效能原因,我們反對建立這樣的強型別。您可能還記得,JVM上的記憶體看起來像這樣:

[譯]Kotlin中內聯類(inline class)完全解析(一)

當我們建立一個基本型別的區域性變數(即函式內定義的函式引數和變數)時 - 如Int、Float、Boolean - 這些值被儲存在部分JVM 記憶體堆疊中。將這些基礎型別的值儲存在堆疊上所涉及的效能開銷並不大。

在另一方面,每當我們例項化一個物件時,該物件例項就儲存在JVM堆上了。我們在儲存和使用物件例項時會有效能損失 - 堆分配和記憶體提取的效能代價很高。雖然看起來每個物件效能開銷微不足道,但是累積起來,它對程式碼執行速度產生嚴重的影響。

如果我們能夠在不受效能影響的情況下獲得強型別系統的所有好處,那不是很好嗎?

實際上,Kotlin新特性inline class就是為了解決這樣的問題而設計的。

讓我們一起來看看

內聯類的介紹

內聯類很容易去建立-僅僅需要在你定義的類前面加上inline關鍵字即可。

inline class Hours(val value: Int) {
    fun toMinutes() = Minutes(value * 60)
}
複製程式碼

就是這樣!這個類現在將作為您定義值的強型別,並且在許多情況下,它和常規非內聯類相比效能成本幾乎相同。

您可以像任何其他類一樣例項化和使用內聯類。您最終可能需要在程式碼中的某個位置引用裡面包裝的普通值 - 這個位置通常是在與另一個庫或系統的邊界處。 當然,在這一點上,您可以像通常使用任何其他類一樣訪問這個值。

您應該知道的關鍵術語

內聯類包裝基礎型別的值。並且這個值也是有個型別的,我們把它稱之為基礎型別

[譯]Kotlin中內聯類(inline class)完全解析(一)

為什麼內聯類可以高效能執行

那麼,內聯類為什麼可以和普通類更好地執行呢?

你可以像這樣去例項化一個內聯類

val period = Hours(24)
複製程式碼

...實際上該類並未在編譯程式碼中例項化!事實上,就JVM而言,實際上相當於下面這樣的程式碼......

int period = 24;
複製程式碼

正如您所看到的,在此編譯版本的程式碼中沒有Hours概念 - 它只是將基礎值分配給int型別的變數! 同樣,當您使用內聯類作為函式引數的型別時也是這樣的:

fun wait(period: Hours) { /* ... */ }
複製程式碼

...它可以有效地編譯成如下這樣......

void wait(int period) { /* ... */ }
複製程式碼

因此,我們的程式碼中內聯了基礎型別和基礎值。換句話說,編譯後的程式碼只使用了int整數型別,因此我們避免了在堆記憶體上建立和訪問物件的開銷成本。

但是請等一下!

還記得Hours類有一個名為toMinutes()的函式嗎?因為編譯後的程式碼使用的是int而不是Hours物件例項,因此想像一下呼叫toMinutes()函式時會發生什麼呢?

inline class Hours(val value: Int) {
    fun toMinutes() = Minutes(value * 60)
}
複製程式碼

Hours.toMinutes()的編譯程式碼如下所示:

public static final int toMinutes(int $this) {
	return $this * 60;
}
複製程式碼

如果我們在Kotlin中呼叫Hours(24).toMinutes(),它可以有效地編譯為toMinutes(24).

沒問題,確實可以像這樣處理函式,但是類成員屬性呢?如果我們希望Hours除了主要的基礎值之外還包括其他一些資料,該怎麼辦?

一切事情都是有它的權衡的,那麼這是其中之一 - 內聯類除了基礎值之外不能有任何其他成員屬性。讓我們探討其他的。

權衡和使用限制

現在我們知道內聯類可以通過編譯程式碼中的基礎值來表示,我們已經準備好了解使用它們時應注意哪些使用限制。

首先,內聯類必須包含一個基礎值,這就意味它需要一個主構造器來接收 這個基礎值,此外它必須是隻讀的(val)。你可以定義你想要的基礎值變數名。

inline class Seconds()              // nope - needs to accept a value!
inline class Minutes(value: Int)    // nope - value needs to be a property
inline class Hours(var value: Int)  // nope - property needs to be read-only
inline class Days(val value: Int)   // yes!
inline class Months(val count: Int) // yes! - name it what you want
複製程式碼

如果有需要,可以將該屬性設為私有的,但建構函式必須是公有的。

inline class Years private constructor(val value: Int) // nope - constructor must be public
inline class Decades(private val value: Int)           // yes!
複製程式碼

內聯類中不能包含init block初始化塊。我會在下一篇發表的文章中探討內聯類如何與Java進行互操作,這點將會徹底說明白。

inline class Centuries(val value: Int) {
	// nope - "Inline class cannot have an initializer block"
    init { 
        require(value >= 0)
    }
}
複製程式碼

正如我們在上面發現的那樣,除了一個基礎值之外,我們的內聯類主構造器不能包含其他任何成員屬性。

// nope - "Inline class must have exactly one primary constructor parameter"
inline class Years(val count: Int, val startYear: Int)
複製程式碼

但是呢,它的內部是可以擁有成員屬性的,只要它們僅基於構造器中那個基礎值計算,或者從可以靜態解析的某個值或物件計算 - 來自單例,頂級物件,常量等。

object Conversions {
    const val MINUTES_PER_HOUR = 60    
}

inline class Hours(val value: Int) {
    val valueAsMinutes get() = value * Conversions.MINUTES_PER_HOUR
}
複製程式碼

不允許類繼承 - 內聯類不能繼承另一個類,並且它們不能被另一個類繼承。 (Kotlin 1.3-M1在技術上確實允許內聯類繼承另一個類,但在即將釋出的版本中會對此進行更正)

open class TimeUnit
inline class Seconds(val value: Int) : TimeUnit() // nope - cannot extend classes

open inline class Minutes(val value: Int) // nope - "Inline classes can only be final"
複製程式碼

如果您需要將內聯類作為子型別,那很好 - 您可以實現介面而不是繼承基類。

interface TimeUnit {
	val value: Int
}

inline class Hours(override val value: Int) : TimeUnit  // yes!
複製程式碼

內聯類必須在頂級宣告。巢狀/內部類不能內聯的。

class Outer {
	 // nope - "Inline classes are only allowed on top level"
    inline class Inner(val value: Int)
}

inline class TopLevelInline(val value: Int) // yes!
複製程式碼

目前,也不支援內聯列舉類。

// nope - "Modifier 'inline' is not applicable to 'enum class'"
inline enum class TimeUnits(val value: Int) {
    SECONDS_PER_MINUTE(60),
    MINUTES_PER_HOUR(60),
    HOURS_PER_DAY(24)
}
複製程式碼

Type Aliases(型別別名) 與 Inline Classes(內聯類)對比

因為它們都包含基礎型別,所以內聯類很容易與型別別名混淆。但是有一些關鍵的差異使它們在不同的場景下得以應用。

型別別名為基礎型別提供備用名稱。例如,您可以為String這樣的常見型別新增別名,併為其指定在特定上下文中有意義的描述性名稱,比如UsernameUsername型別的變數實際上是原始碼和編譯程式碼中String型別的變數同一個東西,只是不同名稱而已。例如,您可以這樣做:

typealias Username = String

fun validate(name: Username) {
    if(name.length < 5) {
        println("Username $name is too short.")
    }
}
複製程式碼

注意到我們是可以在name上直接呼叫.length的,這是因為name實際上就是個String,儘管我們在宣告引數型別的時候使用的是別名Username.

在另一面,內聯類實際上是基礎型別的包裝器,因此當你需要使用基礎值的時候,需要做拆箱操作。例如我們使用內聯類來重寫上面別名的例子:

inline class Username(val value: String)

fun validate(name: Username) {
    if (name.value.length < 5) {
        println("Username ${name.value} is too short.")
    }
}
複製程式碼

注意到我們是必須這樣name.value.length而不是name.length,我們必須解開這個包裝器取出裡面的值。

但是最大的區別在於與分配相容性有關。內聯類為你提供型別的安全性,型別別名則沒有。 型別別名與其基礎型別相同。例如,看如下程式碼:

typealias Username = String
typealias Password = String

fun authenticate(user: Username, pass: Password) { /* ... */ }

fun main(args: Array<String>) {
    val username: Username = "joe.user"
    val password: Password = "super-secret"
    authenticate(password, username)
}
複製程式碼

在這種情況下,UsernamePassword僅僅是String另一個不同名稱而已,甚至你可以將UsernamePassword任意調換位置。實際上,這正是我們在上面的程式碼中所做的 - 當我們呼叫authenticate()函式時,即使我們將UsernamePassword位置弄反了,但編譯器依然認為是合法的。

另一方面,如果你對上面同一個案例使用內聯類,那麼編譯器將會很幸運告訴你這是不合法的:

inline class Username(val value: String)
inline class Password(val value: String)

fun authenticate(user: Username, pass: Password) { /* ... */ }

fun main(args: Array<String>) {
    val username: Username = Username("joe.user")
    val password: Password = Password("super-secret")
    authenticate(password, username) // <--- Compiler error here! =)
}
複製程式碼

這點非常強大!這強大到寫程式碼時候就告訴我們,我們寫了一個bug。我們無需等待自動測試、QA工程師或使用者告訴我們。非常棒!

包裝

你自己準備開始嘗試內聯類了嗎? 如果是,請立即閱讀 how to enable inline classes

雖然我們已經介紹了相關的基礎知識,但在使用它們時要記住一些令人困惑的注意和限制。實際上,如果你不瞭解內部的原理,使用內聯類可能會寫出比正常普通類執行速度更慢的程式碼。

在下一篇文章中,我們將深入研究內聯類底層工作原理,以便於你在運用的時候更加高效。

譯者有話說

還記得上篇Kotlin新特性的文章嗎,實際上關於inline class內容在上篇基本講的很清楚了。但是上篇文章篇幅有限,特定找了一篇比較全面的inline class相關英文文章再次梳理和鞏固內聯類的知識。並且原文作者更是寫了一系列有關inline class的文章,從它的使用到基本介紹最後到剖析內部原理,講得非常清楚。當然我還會繼續翻譯他的最後一篇深入inline class內部原理文章。歡迎大家持續關注~~~

Kotlin系列文章,歡迎檢視:

原創系列:

翻譯系列:

實戰系列:

[譯]Kotlin中內聯類(inline class)完全解析(一)

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

相關文章