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

Feximin發表於2017-07-13

Lambda 表示式和伴生物件

2016年,Jake Wharton 做了一系列有趣的關於 Java 的隱性成本 的討論。差不多同一時期他開始提倡使用 Kotlin 來開發 Android,但對 Kotlin 的隱性成本幾乎隻字未提,除了推薦使用行內函數。如今 Kotlin 在 Android Studio 3 中被 Google 官方支援,我認為通過研究 Kotlin 產生的位元組碼來說一下關於這方面(隱性成本)的問題是個好主意。

與 Java 相比,Kotlin 是一種有更多語法糖的現代程式語言,同樣也有很多“黑魔法”執行在幕後,他們中有些有著不容忽視的成本,尤其是針對老的和低端的 Android 裝置上的開發。

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

“能力越大,責任越大。”

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

Kotlin 位元組碼檢測器

這是一個可選的工具,他能推斷出 Kotlin 程式碼是怎樣被轉換成位元組碼的。在 Android Studio 中安裝了 Kotlin 外掛後,選擇 “Show Kotlin Bytecode” 選項來開啟一個顯示當前類的位元組碼的皮膚。然後你可以點選 “Decompile” 按鈕來閱讀同等的 Java 程式碼。

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

  • 基本型別裝箱,分配短期物件
  • 例項化額外的物件在程式碼中不是直接可見的
  • 生成額外的方法。正如你可能已知的,在 Android 應用中一個 dex 檔案中允許的方法數量是有限的,超限了就需要配置 multidex,然而這有侷限性且有損效能,尤其是在 Lollipop 之前的 Android 版本中。

注意基準

我故意選擇公佈任何微基準,因為他們中的大多數毫無意義,或者有缺陷,或者兩者兼有,並且不能夠應用於所有的程式碼變化和執行時環境。當相關的程式碼執行在迴圈或者巢狀迴圈中時負面的效能影響通常會被放大。

此外,執行時間並不是唯一衡量標準,增長的記憶體使用也必須考慮,因為所有分配的記憶體最終都必須回收,垃圾回收的成本取決於很多因素,如可用記憶體和平臺上使用的垃圾回收演算法。

簡而言之,如果你想知道一個 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前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃

相關文章