【譯】探索Kotlin帶來的隱性成本(一)

Knight_Davion發表於2017-09-29

注:來自Medium上的一位Android工程師所寫,作者從位元組碼的層面分析了kotlin一些隱性的效能成本,以及如果避免這些。這個文章有3個部分,這是第一部分。英文原版 medium.com/@BladeCoder…

Lambda表示式和伴隨物件

在2016年, Jake Wharton針對Java的隱性成本進行了一系列有趣的談話。在同一時期,他也開始倡導使用Kotlin語言進行Android開發,但幾乎沒有提到該語言在開發中的隱藏成本,除了推薦使用行內函數。現在,Kotlin在Android Studio 3.0中得到Google的正式支援,我認為通過研究它產生的位元組碼來寫一些這方面的文章是一個不錯的主意。

Kotlin是一種現代化的程式語言,與Java相比,它具有更多的語法糖,而且在後臺還有更多的“黑魔法”,但是其中有一些效能的成本是不可忽略的,尤其是針對一些低端的Android裝置。

這裡並不是反對Kotlin,我非常喜歡這個語言因為它極大的提高了開發效率。但我也認為一個好的開發人員需要知道語言的內部工作原理,以便更明智地使用它。kotlin是非常強大的,有這樣一種名言:“能力越大,責任也越大”。

這些文章盡基於Kotlin 1.1的JVM / Android實現,而不是Javascript實現。

Kotlin位元組碼分析工具

這個工具會為你將kotlin程式碼轉換為位元組碼檔案,在Android Studio中安裝了Kotlin外掛後,選擇“Show Kotlin Bytecode”開啟當前類的位元組碼檔案,然後,可以點選“Decompile”按鈕閱讀等效的Java程式碼。
(譯者注:在Android studio中 首先選中你要開啟的類,然後 tools->kotlin->Show Kotlin Bytecod 即可檢視當前類編譯的位元組碼檔案。)

高階函式和Lambda表示式

Kotlin可以為變數分配函式,並可以將這些函式作為引數傳遞給其他函式。接受其他函式作為引數的函式被稱為高階函式。
kotlin函式可用通過帶有::符號的函式名來引用(譯者注:通俗點講Kotlin 中雙冒號操作符 表示把一個方法當做一個引數,傳遞到另一個方法中進行使用),或者直接宣告為匿名函式,或使用lambda表示式語法,lambda表示式是描述函式的最簡潔的方式。

Kotlin是為Java 6/7和Android提供lambda支援的最佳方式之一。

下面這段程式碼是在資料庫中通過事務執行任意操作,返回值為受影響的行數:

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

我們可以通過使用類似於Groovy的語法將lambda表示式作為最後一個引數來呼叫此函式

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

但是Java 6不能直接支援lambda表示式,那麼他們如何翻譯成位元組碼呢?如你所料,lambda和匿名函式會被編譯為函式物件。

函式物件

下面這段程式碼是上述lambda表示式編譯後的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 it.delete("Customers", null, null);
   }
}複製程式碼

在Android dex檔案中,每個lambda表示式編譯為函式後,應用的方法總數會增加3到4個。

這樣做的好處是這些函式物件的例項只有在使用時才會被建立,這意味著:

  • 對於捕獲lambda表示式,每次將lambda作為引數傳遞時,都將建立一個新函式例項,執行完畢後被當做垃圾回收;
  • 對於非捕獲lambda表示式(純函式),將會建立一個單例的函式例項以便以後複用。

(譯者注:當Lambda表示式訪問一個定義在表示式體外的非靜態變數或者物件時,這個Lambda表示式稱為“捕獲的”。比如,下面這個lambda表示式捕捉了變數x:
int x = 5; return y -> x + y; 為了保證這個lambda表示式宣告是正確的,被它捕獲的變數必須是“有效final”的。所以要麼它們需要用final修飾符號標記,要麼保證它們在賦值後不能被改變。)

由於示例中的呼叫者程式碼使用的是非捕獲lambda表示式的形式,因此它會被編譯為單例,而不是內部類。

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

建議:如果使用捕獲lambda表示式,避免重複呼叫高階函式以便減少垃圾收集器的壓力。

裝箱開銷

在java8 中大約有43個特殊的函式介面用來最大限度的避免裝箱和拆箱操作,而kotlin編譯的函式物件僅實現了通用介面,同時使用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
}複製程式碼

這意味高階函式中作為引數傳遞的函式的輸入值或返回值存在基本資料型別(比如Int,Long)時呼叫高階函式的過程將將涉及到系統的裝箱和拆箱操作,而這在Android上這會對效能造成不可忽視的影響。

在上面被編譯過的程式碼中,你可以看到結果被包裝成了Integer物件,但是最後呼叫程式碼又立即對齊進行了拆箱操作(譯者注:呼叫程式碼最後返回的是Int)。

建議:當作為引數的函式中的輸入輸出值涉及到基本資料型別時,請謹慎呼叫高階函式,頻繁的呼叫將會給系統帶來更大的壓力。

行內函數(Inline functions)

幸運的是Kotlin使用了一個很棒的技巧,以避免在使用lambda表示式時造成的額外開銷,那就是將高階函式宣告為內聯。宣告為內聯的函式會被編譯器直接插入到呼叫者內部。而這對於高階函式的好處是作為其引數的lambda表示式也將會被內聯,實際效果是這樣的:

  • 建立lambda時不會再建立函式物件;
  • 對於輸入輸出為原始資料型別的lambda不在涉及到裝箱和拆箱操作;
  • 應用的方法總數不會再增加;
  • 不會執行實際的函式呼叫,提高了cpu多次處理沉重事務的能力。

當把我們上面的transaction()函式宣告為內聯型別時,呼叫者的程式碼就會變成下面的形式:

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

注意事項:

  • 行內函數中不能直接呼叫它本身或者通過其他的行內函數(譯者注:本身確實不能夠呼叫,但是通過其他內聯貌似可以啊,難道是理解有誤?,有明白的請在評論區留言指點);
  • public型別的行內函數只能訪問本類中的public 函式及欄位;
  • 程式碼體積會變大,多次內聯一個長功能的函式將會使程式碼體積顯著增大,如果這個長程式碼段中又引入的其他長功能程式碼,結果會更糟。

建議:可能的話,儘量將高階函式宣告為內聯,保持程式碼行數為一個較小的數字,將大塊程式碼移動到非行內函數中。

在以後的文章中我們將討論行內函數的其他效能優勢。

伴隨物件

Kotlin類中沒有靜態欄位或者方法,這些欄位和方法可以在類中的伴隨物件中宣告。

伴隨物件訪問私有類欄位

思考以下程式碼:

class MyClass private constructor() {
    private var hello = 0
    companion object {
        fun newInstance() = MyClass()
    }
}複製程式碼

編譯時,伴隨物件會被宣告為單例。這意味著就像任何需要從其他類訪問本類私有欄位的Java類一樣,從伴隨物件訪問外部類的私有欄位(或建構函式)將會生成額外的setter和getter方法。對類欄位的每個讀寫操作都將導致伴隨物件中靜態方法的呼叫。

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

在Java中我們可以通過這些欄位在包中的可見性來避免生成這些setter或getter方法,但是在kotlin中不存在包的可見性。使用public 或者internal 都會導致kotlin生成預設的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方法也是public的,可以直接呼叫,所以不需要上一步的合成方法。但是Kotlin仍然需要呼叫getter方法來讀取一個常量。

事實證明,為了儲存常量kotlin編譯器會在主類中生成一個私有靜態常量欄位而不是在伴隨物件中,但是因為靜態欄位在類中被宣告為私有的這就需要需要另一種合成方法來從伴隨物件訪問它。

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

最後,該合成方法讀取欄位的實際值

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

換句話說,當你訪問伴隨物件中的私有常量欄位時,程式碼的執行流程是這樣的:

  • 呼叫伴隨物件中的靜態方法;
  • 呼叫伴隨物件中的例項方法;
  • 呼叫類中的靜態方法;
  • 讀取靜態欄位並返回其值。

等效的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)
    }
}複製程式碼

其次,可以在伴隨物件的public欄位上使用@JvmField註解來指示編譯器不生成任何getter或setter方法,並將其作為類中的靜態欄位公開,就像純Java常量。實際上,這個註解就是為了相容Java的原因而建立的。此外,它只能用於public欄位。

最後,你還可以使用ProGuard工具優化位元組碼,但這種方式的相容性較差。

建議:合理使用const關鍵字來宣告原始資料型別和String常量避免讀取這些常量帶來的額外開銷
對於其他型別的常量如果你需要頻繁的訪問它,請將它快取在區域性變數中
此外,全域性公共常量最好儲存在本類物件中而不是伴隨物件中

這就是第一篇文章,希望可以幫助你更好地理解這些Kotlin功能,理解這一點你才會在寫出更智慧的程式碼的同時不會犧牲程式碼的可讀性及軟體效能。

相關文章