重學 Kotlin —— inline,包治百病的性能良藥?

本文永久更新地址:https://xiaozhuanlan.com/topic/3458207169

重學 Kotlin 已經來到了第三期,前面已經介紹了:

object,史上最 “快” 單例 ?

typealias ,穿了馬甲,我就不認識你了?

今天的主角是 inline ,這不是一個 Kotlin 特有的概念,大多數編程語言都支持內聯。

內聯函數 的語義很簡單:把函數體複製粘貼到函數調用處 。使用起來也毫無困難,用 inline關鍵字修飾函數即可。

然而問題的關鍵並不是如何使用 inline ,而是什麼時候使用  inline ?  既然 Kotlin 提供了內聯,它肯定是爲了性能優化而存在的,那麼,它又真的是包治百病的性能良藥嗎?

今天,我們就一起來刨根挖底,尋找一下答案。

目錄

  1. inline 的本質

  2. 建議我不要用 inline ?

  3. Java 支持內聯嗎?

  4. 拯救 Lambda

  5. Java 是如何優化 Lambda 的?

  6. 不想內聯怎麼辦?

  7. 如何從 Lambda 返回?

  8. 最後

inline 的本質

前面已經說過  inline 就是 把函數體複製粘貼到函數調用處 ,完全是編譯器的小把戲。本着嚴謹科學的態度,我們還是來反編譯驗證一下。

inline fun test() {
    println("I'm a inline function")
}

fun run() { test() }

run() 函數中調用了內聯函數 test()。反編譯查看對應的 java 代碼:

public static final void test() {
    String var1 = "I'm a inline function";
    System.out.println(var1);
}

public static final void run() {
    String var1 = "I'm a inline function";
    System.out.println(var1);
}

可以看到 run() 函數中並沒有直接調用 test() 函數,而是把 test() 函數的代碼直接放入自己的函數體中。這就是 inline 的功效。

那麼,問題就來了。這樣就可以提高運行效率嗎?如果可以,爲這裏是 重學 Kotlin 系列第二篇,本文永久更新地址 :https://xiaozhuanlan.com/topic/0846175293 。

可能還真的就不認識了。

今天的主角是 type alias,翻譯過來叫 類型別名。先來看一下文章目錄:

  1. 什麼是 typealias ?

  2. typealias 的本質

  3. typealias 存在的意義是什麼?

  4. typealias 的使用注意事項

什麼是 typealias ?

這是一個很基礎的關鍵字,但可能很多人沒有使用過。它的作用十分簡單,給已有類型取一個別名,可以像使用原類型一樣使用這個 “類型別名”

舉個簡單的例子:

typealias Name = String
val name : Name = "java"
println(name.length)

String 取個別名 Name ,在使用過程中,NameString 是完全等價的。

既然是等價的,使用別名的意義是什麼呢?

別急,typealias 不僅僅支持給類取別名,它的用法豐富的讓你想象不到。

// 類和接口
typealias Name = String
typealias Data = Serializable

// 函數類型
typealias GetName = () -> String
typealias Handler = CoroutineScope.() -> Unit

// 泛型
typealias P<T> = Comparable<T>
typealias Pairs<K, V> = HashMap<K, V>
typealias Numbers = Array<Number>

// object
object Single {}
typealias X = Single

class Util {
    companion object {
        val count = 1
    }
}
typealias Count = Util.Companion

// inner class
typealias NotificationBuilder = NotificationCompat.Builder

class Outer { inner class Inner }
typealias In = Outer.Inner

// 枚舉
enum class Color { RED, YELLOW, GREEN }
typealias color = Color

// 註解
typealias Jvm = JvmStatic

上面的枚舉用法中,需要注意的一點是,只能爲枚舉類 Color 取別名,無法爲具體的枚舉值取別名 。諸如 typealias Red = Color.RED 是不允許的。

幾乎沒有 typealias 不適用的類型。說到現在,你又得疑問了,類型別名存在的意義是什麼 ?這樣簡單的取個別名,爲什麼不直接使用原類型呢 ?

typealias 的本質

暫且別急,我們先來看一下 typealias 的實現原理,說不定可以有一些發現。

反編譯下面這個簡單的例子:

typealias Binary = ByteArray
fun getBinary(string: String) : Binary = string.toByteArray()

查看其 Java 代碼 :

public final class TypealiasKt {
   @NotNull
   public static final byte[] getBinary(@NotNull String string) {
      Intrinsics.checkParameterIsNotNull(string, "string");
      Charset var2 = Charsets.UTF_8;
      boolean var3 = false;
      byte[] var10000 = string.getBytes(var2);
      Intrinsics.checkExpressionValueIsNotNull(var10000, "(this as java.lang.String).getBytes(charset)");
      return var10000;
   }
}

代碼中根本找不到類型別名 Binary 的蹤影。經過編譯之後,類型別名會被原類型直接替換。這僅僅只是 Kotlin 豐富的語法糖之一,編譯器在其中做了一些手腳。

typealias 存在的意義是什麼 ?

現在,你估計更加困惑了。

開發者手動聲明一個類型別名,編譯器再自動替換回原類型。意義何在?

唯一能想到的一點大概只有 "代碼可讀性" ,這裏的代碼可讀性還要打上了一個大大的引號。

複雜的業務邏輯下,你的代碼中可能會出現超長命名,多參數,多泛型類型的類名,接口名,函數名。

typealias FileTable<K> = MutableMap<K, MutableList<File>>
typealias OnPermissionResult = ActivityCompat.OnRequestPermissionsResultCallback
typealias SimpleName = LonglonglonglonglonglonglonglonglonglonglonglonglonglonglongName

用類型別名來代替原本可讀性不好(名字太長或者聲明覆雜)的類型名,這可能就是 typealias 的主要作用。

至於到底有沒有提升可讀性?我覺得這是有代價的。因此而喪失的是直觀的類型聲明。以上面代碼塊中的 FileTable 爲例,一眼看過去,你能發現它是一個 MutableMap<K, MutableList<File>> 嗎?肯定不能。特別在團隊開發中,除了這個代碼的貢獻者,可能每一位都要到類型別名聲明處進行查看。

有人可能也會有不一樣的聲音。統一的全局聲明很正常,而且也方便做統一修改,避免到代碼使用處一一修改。況且 IDE 都會自動提示類型別名的聲明。沒有不使用 typealias 的道理。

所以,這是一個仁者見仁,智者見智的問題。你覺得有用就可以使用,任何一門技術肯定都有它的使用場景,並沒有必要去過多爭論。

我用協程還是用 RxJava?我用 LiveData 還是用事件總線?我用 ViewBinding 還是 DataBinding ?......

這幾個問題可能比較常見,但是上面的每一組選擇,如果你真正深入瞭解其技術本質的話,就會發現它們的使用場景並不一樣,也就不會存在 如何選擇 的疑問了。

typealias 使用注意事項

有點跑偏了,再回到 typealias 。後面再說一些 typealias 的注意事項,內容會比較零散,後續也可能繼續增加。

typealias 可以寫在哪裏?

只能聲明在文件頂層位置,其他任何地方都不行。

與 Java 如何交互?

拒絕和 Java 進行交互。

禁止套娃!

首先我們是可以 給別名取別名 的,如下所示:

typealias Names<T> = Array<T>
typealias SubNames<T> = Names<T>

雖然沒有太大意義,但是語法上是正確的。

下面這樣套娃肯定是不行的。

typealias R = R

typealias L = List<L>
typealias A<T> = List<A<T>>

typealias R1 = (Int) -> R2
typealias R2 = (R1) -> Int

上面的每一行代碼都是無法編譯的。

可見性

頂層位置的 typealias 可以使用可見性修飾符 public 、 private 、 internal 。同時,typealias 不能修改原有類型的可見性。舉個例子:

private class LongName{}
typealias ShortName = LongName // 'public' typealias exposes 'private' in expanded type LongName

上面的代碼會編譯失敗, public 的類型別名無法應用在 private 的原類型上。類型別名和原類型的可見性必須保持一致。

導入同名類的處理

對於在同一個類中導入兩個同名類,通常的做法是, import 其中一個類,另一個使用全限定名。如下所示:

fun main() {
    val user1 = User()
    val user2 = com.test2.User()
}

這樣或多或少不大美觀,可以使用 typealias 處理一下。

typealias User2 = com.test2.User

fun main() {
    val user1 = User()
    val user2 = User2()
}

另外, import ... as ... 也可以解決這個問題。

import com.test1.User
import com.test2.User as User2

fun main() {
    val user1 = User()
    val user2 = User2()
}

最後

不妨翻翻你的代碼庫,看看有沒有可以使用 typealias 進行優化的 “爛” 命名。如果有的話,歡迎來到評論區交流分享。

往期目錄 :object,史上最 “快” 單例 ?什麼?

我們先從 JVM 的方法執行機制說起。

JVM 進行方法調用和方法執行依賴 棧幀,每一個方法從調用開始至執行完成的過程,都對應着一個棧幀在虛擬機棧裏從入棧到出棧的過程。

線程的棧幀是存儲在虛擬機棧中,以上面示例代碼的 未內聯 版本爲例,對應的方法執行過程和對應的棧幀結構如下所示:

未內聯的情況下,整個執行過程中會產生兩個方法棧幀,每一個方法棧幀都包括了 局部變量表、操作數棧、動態連接、方法返回地址和一些額外的附加信息 。

使用內聯的情況下,只需要一個方法棧幀,降低了方法調用的成本。

乍一看,的確的提高了運行效率,畢竟少用一個棧幀嘛。

然而?

建議不要使用 inline ?

一切看起來都很美好,除了 IDE 給我的刺眼提示。

Expected performance impact from inlining is insignificant. Inlining works best for functions with parameters of functional types

大致意思是在這裏使用內聯對性能的影響微乎其微,或者說沒有什麼意義。Kotlin 的內聯最好用在函數參數類型中。

不急着解釋,首先來一發靈魂拷問。

Java 支持內聯嗎?

你可以說不支持,因爲 Java 並沒有提供類似 inline 的顯示聲明內聯函數的方法。

但是 JVM 是支持的。Java 把內聯優化交給虛擬機來進行,從而避免開發者的濫用。

典型的一種濫用, 內聯超長方法 ,極大的增大字節碼長度,反而得不償失。你可以注意 Kotlin 標準庫中的內聯函數,基本都是簡短的函數。

對於普通的函數調用,JVM 已經提供了足夠的內聯支持。因此,在 Kotlin 中,沒有必要爲普通函數使用內聯,交給 JVM 就行了。

另外,Java 代碼是由 javac 編譯的,Kotlin 代碼是由 kotlinc 編譯的,而 JVM 可以對字節碼做統一的內聯優化。所以,可以推斷出,不管是 javac ,還是 kotlinc,在編譯期是沒有內聯優化的。

至於 JVM 具體的內聯優化機制,我瞭解的並不多,這裏就不做過多介紹了。後續如果我看到相關資料,會到這裏進行補充。

所以,上一節中 IDE 給開發者的提示就很明瞭了。

JVM 已經提供了內聯支持,所以沒有必要在 Kotlin 中內聯普通函數。

那麼問題又來了。既然 JVM 已經支持內聯優化,Kotlin 的內聯存在的意義是什麼 ? 答案就是 Lambda  。

拯救 Lambda

爲什麼要拯救 Lambda,我們首先得知道Kotlin 的 Lambda 對於 JVM 而言究竟是什麼。

Kotlin 標準庫中有一個叫 runCatching  的函數,我在這裏實現一個簡化版本 runCatch ,參數是一個函數類型。

fun runCatch(block: () -> Unit){
    try {
        block()
    }catch (e:Exception){
        e.printStackTrace()
    }
}

fun run(){
    runCatch { println("xxx") }
}

反編譯生成的 Java 代碼如下所示:

public final class InlineKt {
    public static final void runCatch(@NotNull Function0<Unit> block) {
        Intrinsics.checkParameterIsNotNull(block, (String)"block");
        try {
            block.invoke();
        }
        catch (Exception e) {
            e.printStackTrace();
        }
    }

    public static final void run() {
        InlineKt.runCatch((Function0<Unit>)((Function0)run.1.INSTANCE));
    }
}

static final class InlineKt.run.1 extends Lambda implements Function0<Unit> {
    public static final InlineKt.run.1 INSTANCE = new /* invalid duplicate definition of identical inner class */;

    public final void invoke() {
        String string = "xxx";
        boolean bl = false;
        System.out.println((Object)string);
    }

    InlineKt.run.1() {
    }
}

Kotlin 自誕生之初,就以 兼容 Java 爲首要目標。因此,Kotlin 對於 Lambda 表達式的處理是編譯生成匿名類。

經過編譯器編譯之後, runCatch() 方法中的 Lambda 參數被替換爲 Function0<Unit> 類型,在 run() 方法中實際調用 runCatch()  時傳入的參數是實現了 Function0<Unit>  接口的 InlineKt.run.1  ,並重寫 了 invoke() 方法。

所以,調用 runCatch() 的時候,會創建一個額外的類 InlineKt.run.1。這是 Lambda 沒有捕捉變量的場景。如果捕捉了變量,表現會怎麼樣?

fun runCatch(block: () -> Unit){
    try {
        block()
    }catch (e:Exception){
        e.printStackTrace()
    }
}

fun run(){
    var message = "xxx"
    runCatch { println(message) }
}

在 Lambda 內部捕捉了外部變量 message  ,其對應的 java 代碼如下所示:

public final class InlineKt {
    public static final void runCatch(@NotNull Function0<Unit> block) {
        Intrinsics.checkParameterIsNotNull(block, (String)"block");
        try {
            block.invoke();
        }
        catch (Exception e) {
            e.printStackTrace();
        }
    }

    public static final void run() {
        void message;
        Ref.ObjectRef objectRef = new Ref.ObjectRef();
        objectRef.element = "xxx";
        // 這裏每次運行都會 new 一個對象
        InlineKt.runCatch((Function0<Unit>)((Function0)new Function0<Unit>((Ref.ObjectRef)message){
            final /* synthetic */ Ref.ObjectRef $message;

            public final void invoke() {
                String string = (String)this.$message.element;
                boolean bl = false;
                System.out.println((Object)string);
            }
            {
                this.$message = objectRef;
                super(0);
            }
        }));
    }
}

如果 Lambda 捕捉了外部變量,那麼每次運行都會 new 一個持有外部變量值的 Function0<Unit>  對象。這比未發生捕捉變量的情況更加糟糕。

總而言之,Kotlin 的 Lambda 爲了完全兼容到 Java6,不僅增大了編譯代碼的體積,也帶來了額外的運行時開銷。爲了解決這個問題,Kotlin 提供了 inline 關鍵字。

Kotlin 內聯函數的作用是消除 lambda 帶來的額外開銷

runCatch()  加持 inline :

inline fun runCatch(block: () -> Unit){
    try {
        block()
    }catch (e:Exception){
        e.printStackTrace()
    }
}

fun run(){
    var message = "xxx"
    runCatch { println(message) }
}

反編譯查看 java 代碼:

public static final void run() {
      Object message = "xxx";
      boolean var1 = false;
      try {
         int var2 = false;
         System.out.println(message);
      } catch (Exception var5) {
         var5.printStackTrace();
      }
}

runCatch() 的代碼被直接內聯到 run() 方法中,沒有額外生成其他類,消除了 Lambda 帶來的額外開銷。

Java 是如何優化 Lambda 的?

既然 Kotlin 的 Lambda 存在性能問題,那旁邊的 Java 大兄弟肯定也逃脫不了。

從 Java8 開始,Java 藉助 invokedynamic 來完成的 Lambda 的優化。

invokedynamic  用於支持動態語言調用。在首次調用時,它會生成一個調用點,並綁定該調用點對應的方法句柄。後續調用時,直接運行該調用點對應的方法句柄即可。說直白一點,第一次調用 invokeddynamic 時,會找到此處應該運行的方法並綁定, 後續運行時就直接告訴你這裏應該執行哪個方法。

關於 invokedynamic 的詳細介紹,可以閱讀極客時間專欄 《深入拆解Java虛擬機》的第 8,9 兩講。

JVM 是怎麼實現 invokedynamic 的?(上)

JVM 是怎麼實現 invokedynamic 的?(下)

不想內聯怎麼辦?

一個高階函數一旦被標記爲內聯,它的方法體和所有 Lambda 參數都會被內聯。

inline fun test(block1: () -> Unit, block2: () -> Unit) {
    block1()
    println("xxx")
    block2()
}

test() 函數被標記爲了 inline  ,所以它的函數體以及兩個 Lambda 參數都會被內聯。但是由於我要傳入的 block1  代碼塊巨長(或者其他原因),我並不想將其內聯,這時候就要使用 noinline  。

inline fun test(noinline block1: () -> Unit, block2: () -> Unit) {
    block1()
    println("xxx")
    block2()
}

這樣, block1 就不會被內聯了。篇幅原因,這裏就不展示 Java 代碼了,相信你也能很容易理解 noinline

如何從 Lambda 返回?

首先,普通的 lambda 是不允許直接使用 return 的 。

fun runCatch(block: () -> Unit) {
    try {
        print("before lambda")
        block()
        print("after lambda")
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

fun run() {
    // 普通 lambda 不允許 return
    runCatch { return }
}

上面的代碼沒有辦法通過編譯,IDE 會提示你 return is not allowed here 。而  inline  可以讓我們突破這個限制。

// 標記爲 inline
inline fun runCatch(block: () -> Unit) {
    try {
        print("before lambda")
        block()
        print("after lambda")
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

fun run() {
    runCatch { return }
}

上面的代碼是可以正常編譯運行的。和之前的例子唯一的區別就是多了 inline 。

既然允許 return ,那麼這裏究竟是從 Lambda 中返回,繼續運行後面的代碼?還是直接結束外層函數的運行呢?看一下 run() 方法的執行結果。

before lambda

從運行結果來看,是直接結束外層函數的運行。其實不難理解,這個 return 是直接內聯到 run() 方法內部的,相當於在 run() 方法中直接調用 return。從反編譯的 java 代碼看,一目瞭然。

   public static final void run() {
      boolean var0 = false;

      try {
         String var1 = "before lambda";
         System.out.print(var1);
         int var2 = false;
      } catch (Exception var3) {
         var3.printStackTrace();
      }
   }

編譯器直接把 return 之後的代碼優化掉了。這樣的場景叫做 non-local return (非局部返回) 。

但是有些時候我並不想直接退出外層函數,而是僅僅退出 Lambda 的運行,就可以這樣寫。

inline fun runCatch(block: () -> Unit) {
    try {
        print("before lambda")
        block()
        print("after lambda")
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

fun run() {
    // 從 lambda 中返回
    runCatch { return@runCatch }
}

return@label,這樣就會繼續執行 Lambda 之後的代碼了。這樣的場景叫做 局部返回  。

還有一種場景,我是 API 的設計者,我不想 API 使用者進行非局部返回 ,改變我的代碼流程。同時我又想使用 inline ,這樣其實是衝突的。前面介紹過,內聯會讓 Lambda 允許非局部返回。

crossinline  就是爲了解決這一衝突而生。它可以在保持內聯的情況下,禁止 lambda 從外層函數直接返回。

inline fun runCatch(crossinline block: () -> Unit) {
    try {
        print("before lambda")
        block()
        print("after lambda")
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

fun run() {
    runCatch { return }
}

添加  crossinline 之後,上面的代碼將無法編譯。但下面的代碼仍然是可以編譯運行的。

inline fun runCatch(crossinline block: () -> Unit) {
    try {
        print("before lambda")
        block()
        print("after lambda")
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

fun run() {
    runCatch { return@runCatch }
}

crossinline 可以阻止非局部返回,但並不能阻止局部返回,其實也沒有必要。

最後

關於內聯函數,一口氣說了這麼多,總結一下。

在 Kotlin 中,內聯函數是用來彌補高階函數中 Lambda 帶來的額外運行開銷的。對於普通函數,沒有必要使用內聯,因爲 JVM 已經提供了一定的內聯支持。

對指定的 Lambda 參數使用 noinline ,可以避免該 Lambda 被內聯。

普通的 Lambda 不支持非局部返回,內聯之後允許非局部返回。既要內聯,又要禁止非局部返回,請使用 crossinline

除了內聯函數之外,Kotlin 1.3 開始支持 inline class ,但這是一個實驗性 API,需要手動開啓編譯器支持。不知道大家對內聯類有什麼獨特的看法,歡迎在評論區交流。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章