【譯】探索 Kotlin 中的隱性成本(第一部分)

Lambda 表達式和伴生對象

2016年,Jake Wharton 作了一系列有趣的關於 Java 的隱性成本 的討論。差很少同一時期他開始提倡使用 Kotlin 來開發 Android,但對 Kotlin 的隱性成本幾乎隻字未提,除了推薦使用內聯函數。現在 Kotlin 在 Android Studio 3 中被 Google 官方支持,我認爲經過研究 Kotlin 產生的字節碼來講一下關於這方面(隱性成本)的問題是個好主意。html

與 Java 相比,Kotlin 是一種有更多語法糖的現代編程語言,一樣也有不少「黑魔法」運行在幕後,他們中有些有着不容忽視的成本,尤爲是針對老的和低端的 Android 設備上的開發。前端

這不是一個專門針對 Kotlin 的現象:我很喜歡這門語言,它提升了效率,可是我相信一個優秀的開發者須要知道這些語言特性在內部是如何工做的以便更明智地使用他們。Kotlin 是強大的,有句名言說:java

「能力越大,責任越大。」react

本文只關注 Kotlin 1.1 在 JVM/Android 上的實現,不關注 Javascript 上的實現。android

Kotlin 字節碼檢測器

這是一個可選的工具,他能推斷出 Kotlin 代碼是怎樣被轉換成字節碼的。在 Android Studio 中安裝了 Kotlin 插件後,選擇 「Show Kotlin Bytecode」 選項來打開一個顯示當前類的字節碼的面板。而後你能夠點擊 「Decompile」 按鈕來閱讀同等的 Java 代碼。ios

特別是,我將提到的 Kotlin 特性有:git

  • 基本類型裝箱,分配短時間對象
  • 實例化額外的對象在代碼中不是直接可見的
  • 生成額外的方法。正如你可能已知的,在 Android 應用中一個 dex 文件中容許的方法數量是有限的,超限了就須要配置 multidex,然而這有侷限性且有損性能,尤爲是在 Lollipop 以前的 Android 版本中。

注意基準

我故意選擇公佈任何微基準,由於他們中的大多數毫無心義,或者有缺陷,或者二者兼有,而且不可以應用於全部的代碼變化和運行時環境。當相關的代碼運行在循環或者嵌套循環中時負面的性能影響一般會被放大。github

此外,執行時間並非惟一衡量標準,增加的內存使用也必須考慮,由於全部分配的內存最終都必須回收,垃圾回收的成本取決於不少因素,如可用內存和平臺上使用的垃圾回收算法。算法

簡而言之,若是你想知道一個 Kotlin 構造對速度或者內存是否有明顯的影響,你須要在目標平臺上測試你的代碼數據庫


高階函數和 Lambda 表達式

Kotlin 支持將函數賦值給變量並將他們作爲參數傳給其餘函數。接收其餘函數作爲參數的函數被稱爲高階函數。Kotlin 函數能夠經過在他的名字前面加 :: 前綴來引用,或者在代碼中中直接聲明爲一個匿名函數,或者使用最簡潔的 lambda 表達式語法 來描述一個函數。

Kotlin 是爲 Java 6/7 JVM 和 Android 提供 lambda 支持的最好方法之一。

考慮下面的工具函數,在一個數據庫事務中執行任意操做並返回受影響的行數:

fun transaction(db: Database, body: (Database) -> Int): Int {
    db.beginTransaction()
    try {
        val result = body(db)
        db.setTransactionSuccessful()
        return result
    } finally {
        db.endTransaction()
    }
}複製代碼

咱們能夠經過傳遞一個 lambda 表達式作爲最後一個參數來調用這個函數,使用相似於 Groovy 的語法:

val deletedRows = transaction(db) {
    it.delete("Customers", null, null)
}複製代碼

可是 Java 6 的 JVM 並不直接支持 lambda 表達式。他們是如何轉化爲字節碼的呢?如你所料,lambdas 和匿名函數被編譯成 Function 對象。

Function 對象

這是上面的的 lamdba 表達式編譯以後的 Java 表現形式。

class MyClass$myMethod$1 implements Function1 {
   // $FF: synthetic method
   // $FF: bridge method
   public Object invoke(Object var1) {
      return Integer.valueOf(this.invoke((Database)var1));
   }

   public final int invoke(@NotNull Database it) {
      Intrinsics.checkParameterIsNotNull(it, "it");
      return db.delete("Customers", null, null);
   }
}複製代碼

在你的 Android dex 文件中,每個 lambda 表達式都被編譯成一個 Function,這將最終增長3到4個方法

好消息是,這些 Function 對象的新實例只在必要時才建立。在實踐中,這意味着:

  • 捕獲表達式來講,每當一個 lambda 作爲參數傳遞的時候都會生成一個新的 Function 實例,執行完後就會進行垃圾回收。
  • 非捕獲表達式(純函數)來講,會建立一個單例的 Function 實例而且在下次調用時重用。

因爲咱們示例中的調用代碼使用了一個非捕獲的 lambda,所以它被編譯爲一個單例而不是內部類:

this.transaction(db, (Function1)MyClass$myMethod$1.INSTANCE);複製代碼

避免反覆調用那些正在調用捕獲 lambdas的標準的(非內聯)高階函數以減小垃圾回收器的壓力。

裝箱的開銷

與 Java8 大約有43個不一樣的專業方法接口來儘量地避免裝箱和拆箱相反,Kotnlin 編譯出來的 Function 對象只實現了徹底通用的接口,有效地使用任何輸入輸出值的 Object 類型。

/** A function that takes 1 argument. */
public interface Function1<in P1, out R> : Function<R> {
    /** Invokes the function with the specified argument. */
    public operator fun invoke(p1: P1): R
}複製代碼

這意味着調用一個作爲參數傳遞給高階函數的方法時,若是輸入值或者返回值涉及到基本類型(如 IntLong),實際上調用了系統的裝箱和拆箱。這在性能上可能有着不容忽視的影響,特別是在 Android 上。

在上面編譯好的 lambda 中,你能夠看到結果被裝箱成了 Integer 對象。而後調用者代碼立刻又將其拆箱。

當寫一個標準(非內聯)的高階函數(涉及到以基本類型作爲輸入或輸出值的函數作爲參數)時要當心一點。反覆調用這個參數函數會因爲裝箱和拆箱的操做對垃圾回收器形成更多壓力。

內聯函數來補救

幸虧,使用 lambda 表達式時,Kotlin 有一個很是棒的技巧來避免這些成本:將高階函數聲明爲內聯。這將會使編譯器將函數體直接內聯到調用代碼內,徹底避免了方法調用。對高階函數來講好處更大,由於做爲參數傳遞的 lambda 表達式的函數體也會被內聯起來。實際的影響有:

  • 聲明 lambda 時不會有 Function 對象被實例化;
  • 不須要針對 lambda 輸入輸出的基本類型值進行裝箱和拆箱;
  • 方法總數不會增長;
  • 不會執行真正的函數調用。對那些被屢次使用的注重 CPU (計算)的方法來講能夠提升性能。

將咱們的 transaction() 函數聲明爲內聯後,調用代碼變成了:

db.beginTransaction();
int var5;
try {
   int result$iv = db.delete("Customers", null, null);
   db.setTransactionSuccessful();
   var5 = result$iv;
} finally {
   db.endTransaction();
}複製代碼

關於這個殺手鐗特性的一些警告:

  • 內聯函數不能直接調用本身,也不能經過其餘內聯函數來調用;
  • 一個類中被聲明爲公共的內聯函數只能訪問這個類中公共的方法和成員變量;
  • 代碼量會增長。屢次內聯一個長函數會使生成的代碼量明顯增多,尤爲這個長方法又引用了另一個長的內聯方法。

若是可能的話,就將一個高階函數聲明爲內聯。保持簡短,若有必要能夠將大段的代碼塊移至非內聯的方法中。
你還能夠將調用自代碼中影響性能的關鍵部分的函數內聯起來。

咱們將在之後的文章中討論內聯函數的其餘性能優點。


伴生對象

Kotlin 類沒有靜態變量和方法。相應的,類中與實例無關的字段和方法能夠經過伴生對象來聲明。

經過它的伴生對象來訪問私有的類字段

考慮下面的例子:

class MyClass private constructor() {

    private var hello = 0

    companion object {
        fun newInstance() = MyClass()
    }
}複製代碼

編譯的時候,一個伴生對象被實現爲一個單例類。這意味着,就像任何須要從外部類來訪問其私有字段的 Java 類同樣,經過伴生對象來訪問外部類的私有字段(或構造器)將生成額外的 getter 和 setter 方法。每次對一個類字段的讀或寫都會在伴生對象中引發一個靜態的方法調用。

ALOAD 1
INVOKESTATIC be/myapplication/MyClass.access$getHello$p (Lbe/myapplication/MyClass;)I
ISTORE 2複製代碼

在 Java 中咱們會對這些字段使用 package 級別的訪問權限來避免生成這些方法。可是 Kotlin 沒有 package 級別的訪問權限。使用 public 或者 internal 訪問權限來代替的話會生成默認的 getter 和 setter 實例方法來使外部世界可以訪問字段,並且調用實例方法從技術上說比調用靜態方法成本更大。因此不要由於優化的緣由而改變字段的訪問權限。

若是須要從一個伴生對象中反覆的讀或寫一個類字段,你能夠將它的值緩存在一個本地變量中來避免反覆的隱性的方法調用。

訪問伴生對象中聲明的常量

在 Kotlin 中你一般在一個伴生對象中聲明在類中使用的「靜態」常量。

class MyClass {

    companion object {
        private val TAG = "TAG"
    }

    fun helloWorld() {
        println(TAG)
    }
}複製代碼

這段代碼看起來乾淨整潔,可是幕後發生的事情卻十分不堪。

基於上述緣由,訪問一個在伴生對象中聲明爲私有的常量實際上會在這個伴生對象的實現類中生成一個額外的、合成的 getter 方法。

GETSTATIC be/myapplication/MyClass.Companion : Lbe/myapplication/MyClass$Companion;
INVOKESTATIC be/myapplication/MyClass$Companion.access$getTAG$p (Lbe/myapplication/MyClass$Companion;)Ljava/lang/String;
ASTORE 1複製代碼

可是更糟的是,這個合成方法實際上並無返回值;它調用了 Kotlin 生成的實例 getter 方法:

ALOAD 0
INVOKESPECIAL be/myapplication/MyClass$Companion.getTAG ()Ljava/lang/String;
ARETURN複製代碼

當常量被聲明爲 public 而不是 private 時,getter 方法是公共的而且能夠被直接調用,所以不須要上一步的方法。可是 Kotlin 仍然必須經過調用 getter 方法來訪問常量。

因此咱們(真的)解決了(問題)嗎?並無!事實證實,爲了存儲常量值,Kotlin 編譯器實際上在主類級別上而不是伴生對象中生成了一個 private static final 字段。可是,由於在類中靜態字段被聲明爲私有的,在伴生對象中須要有另一個合成方法來訪問它

INVOKESTATIC be/myapplication/MyClass.access$getTAG$cp ()Ljava/lang/String;
ARETURN複製代碼

最終,那個合成方法讀取實際值:

GETSTATIC be/myapplication/MyClass.TAG : Ljava/lang/String;
ARETURN複製代碼

換句話說,當你從一個 Kotlin 類來訪問一個伴生對象中的私有常量字段的時候,與 Java 直接讀取一個靜態字段不一樣,你的代碼實際上會:

  • 在伴生對象上調用一個靜態方法,
  • 而後在伴生對象上調用實例方法,
  • 而後在類中調用靜態方法,
  • 讀取靜態字段而後返回它的值。

這是等同的 Java 代碼:

public final class MyClass {
    private static final String TAG = "TAG";
    public static final Companion companion = new Companion();

    // synthetic
    public static final String access$getTAG$cp() {
        return TAG;
    }

    public static final class Companion {
        private final String getTAG() {
            return MyClass.access$getTAG$cp();
        }

        // synthetic
        public static final String access$getTAG$p(Companion c) {
            return c.getTAG();
        }
    }

    public final void helloWorld() {
        System.out.println(Companion.access$getTAG$p(companion));
    }
}複製代碼

咱們能獲得更少的字節碼嗎?是的,但並非全部狀況都如此。

首先,經過 const 關鍵字聲明值爲編譯時常量來徹底避免任何的方法調用是有可能的。這將有效地在調用代碼中直接內聯這個值,可是隻有基本類型和字符串才能如此使用

class MyClass {

    companion object {
        private const val TAG = "TAG"
    }

    fun helloWorld() {
        println(TAG)
    }
}複製代碼

第二,你能夠在伴生對象的公共字段上使用 @JvmField 註解來告訴編譯器不要生成任何的 getter 和 setter 方法,就像純 Java 中的常量同樣作爲類的一個靜態變量暴露出來。實際上,這個註解只是單獨爲了兼容 Java 而建立的,若是你的常量不須要從 Java 代碼中訪問的話,我是一點也不推薦你用一個晦澀的交互註解來弄亂你漂亮 Kotlin 代碼的。此外,它只能用於公共字段。在 Android 的開發環境中,你可能只在實現 Parcelable 對象的時候纔會使用這個註解:

class MyClass() : Parcelable {

    companion object {
        @JvmField
        val CREATOR = creator { MyClass(it) }
    }

    private constructor(parcel: Parcel) : this()

    override fun writeToParcel(dest: Parcel, flags: Int) {}

    override fun describeContents() = 0
}複製代碼

最後,你也能夠用 ProGuard 工具來優化字節碼,但願經過這種方式來合併這些鏈式方法調用,可是絕對不保證這是有效的。

與 Java 相比,在 Kotlin 中從伴生對象裏讀取一個 static 常量會增長 2 到 3 個額外的間接級別而且每個常量都會生成 2 到 3個方法。
始終用 const 關鍵字來聲明基本類型和字符串常量從而避免這些(成本)。
對其餘類型的常量來講,你不能這麼作,所以若是你須要反覆訪問這個常量的話,你或許能夠把它的值緩存在一個本地變量中。

同時,最好在它們本身的對象而不是伴生對象中來存儲公共的全局常量。


這就是第一篇文章的所有內容了。但願這可讓你更好的理解使用這些 Kotlin 特性的影響。牢記這一點以便在不損失可讀性和性能的狀況下編寫更智能的的代碼。

繼續閱讀第二部分局部函數空值安全可變參數


掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃

相關文章
相關標籤/搜索