[譯]探索Kotlin中隱藏的效能開銷-Part 2

極客熊貓發表於2019-10-20

翻譯說明:

原標題: Exploring Kotlin’s hidden costs — Part 2

原文地址: medium.com/@BladeCoder…

原文作者: Christophe Beyls

這是關於探索Kotlin中隱藏的效能開銷的第2部分,如果你還沒有看到第1部分,不要忘記閱讀第1部分。

讓我們一起從底層重新探索和發現更多有關Kotlin語法實現細節。

[譯]探索Kotlin中隱藏的效能開銷-Part 2

區域性函式

這是我們之前第一篇文章中沒有介紹過的一種函式: 就是像正常定義普通函式的語法一樣,在其他函式體內部宣告該函式。這些被稱為區域性函式,它們能訪問到外部函式的作用域。

fun someMath(a: Int): Int {
    fun sumSquare(b: Int) = (a + b) * (a + b)

    return sumSquare(1) + sumSquare(2)
}
複製程式碼

我們首先來說下區域性函式最大的侷限性: 區域性函式不能被宣告成內聯的(inline)並且函式體內含有區域性函式的函式也不能被宣告成內聯的(inline). 在這種情況下沒有任何有效的方法可以幫助你避免函式呼叫的開銷。

經過編譯後,這些區域性函式會將被轉化成Function物件, 就類似lambda表示式一樣,並且同樣具有上篇文章part1中講到的關於非行內函數存在很多的限制。反編譯後的java程式碼:

public static final int someMath(final int a) {
   Function1 sumSquare$ = new Function1(1) {
      // $FF: synthetic method
      // $FF: bridge method
      //注: 這是Function1介面生成的泛型合成方法invoke
      public Object invoke(Object var1) {
         return Integer.valueOf(this.invoke(((Number)var1).intValue()));
      }

      //注: 例項的特定方法invoke
      public final int invoke(int b) {
         return (a + b) * (a + b);
      }
   };
   return sumSquare$.invoke(1) + sumSquare$.invoke(2);
}
複製程式碼

但是與lambda表示式相比,它對效能的影響要小得多: 由於該函式的例項物件是從呼叫方就知道的,所以它將直接呼叫該例項的特定方法invoke而不是從Function介面直接呼叫其泛型合成方法invoke。這就意味著從外部函式呼叫區域性函式時,不會進行基本型別的轉換或裝箱操作. 我們可以通過看下位元組碼來驗證一下:

   ALOAD 1
   ICONST_1
   INVOKEVIRTUAL be/myapplication/MyClassKt$someMath$1.invoke    (I)I
   ALOAD 1
   ICONST_2
   INVOKEVIRTUAL be/myapplication/MyClassKt$someMath$1.invoke    (I)I
   IADD //加法操作
   IRETURN
複製程式碼

我們可以看到被呼叫兩次的函式是接收一個 Int 型別的引數並且返回一個 Int 型別的函式,並且加法操作是立即執行的,而無需任何中間的裝箱、拆箱操作。

當然,在每次方法被呼叫期間仍會建立一個新的Function物件。但是這個可以通過將區域性函式改寫為非捕獲的方式來避免這種情況:

fun someMath(a: Int): Int {
    fun sumSquare(a: Int, b: Int) = (a + b) * (a + b)

    return sumSquare(a, 1) + sumSquare(a, 2)
}
複製程式碼

現在相同的Function例項將會被複用,仍然不會進行強制的轉換或裝箱操作。與普通的私有函式相比,此區域性函式的唯一劣勢就是使用一些方法生成額外的類。

區域性函式是私有函式的替代品,其附加好處是能夠訪問外部函式的區域性變數。然而這種好處會伴隨著為外部函式每次呼叫建立Function物件的隱性成本,因此首選使用非捕獲的區域性函式。

空安全

Kotlin語言的最好特性之一就是,它在可空型別和非空型別之間做出了明顯清晰的界限區分。這使得編譯可以通過在執行時禁止將非null或可為null的值分配給非null變數的任何程式碼來有效防止意外的NullPointerException.

非空引數的執行時檢查

下面我們來宣告一個使用非null字串作為採納數的公有函式:

fun sayHello(who: String) {
    println("Hello $who")
}
複製程式碼

現在來看下對應的反編譯後Java程式碼:

public static final void sayHello(@NotNull String who) {
   Intrinsics.checkParameterIsNotNull(who, "who");//執行靜態函式進行非空檢查
   String var1 = "Hello " + who;
   System.out.println(var1);
}
複製程式碼

請注意,Kotlin編譯器對Java是非常友好的,可以看到在函式引數上自動新增了@NotNull註解,因此Java工具可以使用此註解在傳遞空值的時候顯示警告。

但是,註解不足以強制外部呼叫者傳入非null的值。因此,編譯器還在函式的開頭新增一個靜態方法呼叫,該方法將檢查引數,如果為null,則丟擲IllegalArgumentException. 為了使不安全的呼叫者程式碼更易於修復,該函式將盡早且持續丟擲異常,而不是將它置後丟擲執行時的NullPointerException.

實際上,每個公有的函式都有一個對Intrinsics.checkParameterIsNotNull()的靜態呼叫,該呼叫為每個非null引用引數新增。這些檢查不會被新增到私有函式中,因為編譯器保證了Kotlin類中的程式碼為null安全的。

這些靜態呼叫對效能的影響幾乎可以忽略不計,並且在除錯和測試應用程式的時候非常有幫助。話雖如此,如果對於release版本來說你可能認為這是沒必要的額外開銷。在這種情況下,可以使用-Xno-param-assertions編譯器選項或新增以下Proguard規則來禁止執行時的空檢查:

-assumenosideeffects class kotlin.jvm.internal.Intrinsics {
    static void checkParameterIsNotNull(java.lang.Object, java.lang.String);
}
複製程式碼

可空的原生型別

有一點似乎眾所周知,但還是在這裡提醒下: 可空型別始終是引用型別。將原生型別的變數宣告成可空型別可以防止Kotlin使用Java基本資料型別(例如intfloat), 而是使用裝箱的引用型別(例如IntegerFloat),這會避免裝箱和拆想操作帶來的額外開銷。

與Java相反的是它允許你草率地使用幾乎像int變數的Integer變數,這都要歸功於自動裝箱和忽略了null的安全性,可是Kotlin則會強制你在使用可null的型別時編寫空安全的程式碼,因次使用非null型別的好處就變得更顯而易見了:

fun add(a: Int, b: Int): Int {
    return a + b
}
fun add(a: Int?, b: Int?): Int {
    return (a ?: 0) + (b ?: 0)
}
複製程式碼

儘可能使用非null的原生型別,以此來提高程式碼可讀性和效能。

關於陣列

在Kotlin中存在3種型別的陣列:

  • IntArray,FloatArray以及其他原生型別的陣列。
    最終會編譯成 int[],float[]以及其他對應基本資料型別的陣列

  • Array<T>: 非空物件引用型別的陣列
    這裡會涉及到原生型別的裝箱過程

  • Array<T?>: 可空物件引用型別的陣列
    很明顯,這裡也會涉及到原生型別的裝箱過程

如果你需要一個非null原生型別的陣列,最好使用IntArray而不是Array<Int>以避免裝箱過程帶來效能開銷

可變數量的引數(Varargs)

類似Java, Kotlin允許使用可變數量的引數宣告函式。只是宣告的語法有點不一樣而已:

fun printDouble(vararg values: Int) {
    values.forEach { println(it * 2) }
}
複製程式碼

就像在Java中一樣,vararg引數實際上被編譯為給定型別的陣列引數。然後,可以通過三種不同的方式呼叫這些函式:

1.傳遞多個引數

printDouble(1, 2, 3)
複製程式碼

Kotlin編譯器將將此程式碼轉換為新陣列的建立和初始化,就像Java編譯器一樣:

printDouble(new int[]{1, 2, 3});
複製程式碼

所以,建立新陣列會產生開銷,但是與Java相比,這並不是什麼新鮮事。

2.傳遞單個陣列

這裡不同之處就是,在Java中,可以直接將現有的陣列引用作為vararg引數傳遞。在Kotlin中,則需要使用伸展(spread)操作符:

val values = intArrayOf(1, 2, 3)
printDouble(*values)
複製程式碼

在Java中,陣列引用按原樣傳遞給函式,而無需分配額外的陣列空間。然而,如你在反編譯後java程式碼中所見,Kotlin伸展(spread)操作符的編譯方式有所不同:

int[] values = new int[]{1, 2, 3};
printDouble(Arrays.copyOf(values, values.length));
複製程式碼

呼叫函式時,始終會複製現有陣列。好處是程式碼更安全:它允許函式修改陣列而不影響呼叫者程式碼。但是它會分配額外的記憶體

請注意,使用Kotlin程式碼中可變數量的引數呼叫Java方法具有相同的效果。

3.傳遞陣列和引數的混合

Kotlin伸展(spread)運算子的主要好處是它還允許在同一呼叫中將陣列與其他引數混合在一起。

val values = intArrayOf(1, 2, 3)
printDouble(0, *values, 42)
複製程式碼

上述程式碼將會怎樣編譯呢?生成程式碼會十分有趣:

int[] values = new int[]{1, 2, 3};
IntSpreadBuilder var10000 = new IntSpreadBuilder(3);
var10000.add(0);
var10000.addSpread(values);
var10000.add(42);
printDouble(var10000.toArray());
複製程式碼

除了建立新陣列之外,還使用一個臨時生成器物件來計算最終陣列大小並填充它。這給方法呼叫又增加了另一筆小開銷。

即使在使用現有陣列中的值時,在Kotlin中呼叫具有可變數量引數的函式也會增加建立新臨時陣列的成本。對於重複呼叫該函式的效能至關重要的程式碼,請考慮新增具有實際陣列引數而不是vararg的方法

感謝您的閱讀,如果喜歡,請分享這篇文章。

繼續閱讀第3部分委託的屬性範圍

讀者有話說

大概隔了很久很久之前,我好像寫了一篇探索Kotlin中隱藏的效能開銷系列的Part1. 如果沒有讀過第1篇建議也去讀下第1篇,因為這個系列確實對你寫出高效的Kotlin程式碼十分有幫助,也能幫助你從原始碼,編譯層面認清Kotlin語法背後的原理。我更喜歡把這些寫Kotlin程式碼技巧稱為Effective Kotlin, 這也是我最初翻譯這個系列文章的初衷。關於這篇文章,有幾點我需要補充下:

1、為什麼非捕獲區域性函式可以減少開銷

其實關於捕獲和非捕獲的概念,在之前文章中也有所提及,比如在講變數的捕獲,lambda的捕獲和非捕獲。

這裡就以上述區域性函式舉例,下面對比下這兩個函式:

//改寫前的捕獲區域性函式
fun someMath(a: Int): Int {
    fun sumSquare(b: Int) = (a + b) * (a + b)//注意:區域性函式這裡的a是直接引用外部函式的引數a, 
    //因為區域性函式特性可以訪問外部函式的作用域,這裡實際上就存在了變數的捕獲,所以這裡sumSquare稱為捕獲區域性函式

    return sumSquare(1) + sumSquare(2)
}
//改寫前反編譯後程式碼
 public static final int someMath(final int a) {
      //建立Function1物件$fun$sumSquare$1,所以每呼叫一次someMath都會建立一個Function1物件
      <undefinedtype> $fun$sumSquare$1 = new Function1() {
         // $FF: synthetic method
         // $FF: bridge method
         public Object invoke(Object var1) {
            return this.invoke(((Number)var1).intValue());
         }

         public final int invoke(int b) {
            return (a + b) * (a + b);
         }
      };
      return $fun$sumSquare$1.invoke(1) + $fun$sumSquare$1.invoke(2);
   }
複製程式碼

捕獲區域性函式會生成額外的Function物件,所以我們為了減少效能的開銷儘量使用非捕獲區域性函式。

//改寫後的非捕獲區域性函式
fun someMath(a: Int): Int {
    //注意: 可以明顯發現改寫後a引數,直接由函式引數傳入,而不是在區域性函式直接引用外部函式的引數變數,這就是非捕獲區域性函式
    fun sumSquare(a: Int, b: Int) = (a + b) * (a + b)
    return sumSquare(a,1) + sumSquare(a,2)
}

//改寫後反編譯後程式碼
public static final int someMath(int a) {
    //注意:可以看到非捕獲的區域性函式例項是一個單例,多次呼叫都只會複用之前的例項不會重新建立。
    <undefinedtype> $fun$sumSquare$1 = null.INSTANCE;
    return $fun$sumSquare$1.invoke(a, 1) $fun$sumSquare$1.invoke(a, 2);
}
複製程式碼

通過上述對比,應該很清楚知道了什麼是捕獲什麼是非捕獲以及為什麼非捕獲區域性函式會減少效能的開銷。

2、總結下提高Kotlin程式碼效能開銷幾個點

  • 區域性函式是私有函式的替代品,其附加好處是能夠訪問外部函式的區域性變數。然而這種好處會伴隨著為外部函式每次呼叫建立Function物件的隱性成本,因此首選使用非捕獲的區域性函式。
  • 對於release版本應用來說,特別是Android應用,可以使用-Xno-param-assertions編譯器選項或新增以下Proguard規則來禁止執行時的空檢查:
-assumenosideeffects class kotlin.jvm.internal.Intrinsics {
    static void checkParameterIsNotNull(java.lang.Object, java.lang.String);
}
複製程式碼
  • 需要使用非null原生型別的陣列時,最好使用IntArray而不是Array<Int>以避免裝箱過程帶來效能開銷

最後

首先想和一直關注我公眾號和技術部落格的老鐵們說聲抱歉,因為中間已經很久沒更新技術文章,因此有很多人也離開了,但也有人一直默默支援。所以從今天起我又準備開始更新了文章。近期研究dart和flutter也有一段時間了,沉澱了一些技術心得,所以會不定期更新有關一些Dart和Flutter的文章,感謝關注,感謝理解。

[譯]探索Kotlin中隱藏的效能開銷-Part 2

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

Kotlin系列文章,歡迎檢視:

Kotlin邂逅設計模式系列:

資料結構與演算法系列:

翻譯系列:

原創系列:

Effective Kotlin翻譯系列

實戰系列:

相關文章