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

Feximin發表於2017-07-20

區域性函式,空值安全和可變引數

本文是正在進行中的 Kotlin 程式語言系列的第二部分。如果你還未讀過第一部分的話,別忘了去看一下。

讓我們重新看一下 Kotlin 的本質,去發現更多 Kotlin 特性的實現細節。

區域性函式

有一種函式我們在第一篇文章沒有講到:使用常規語法在其他函式內部宣告的函式。這是區域性函式,它們可以訪問外部函式的作用域。

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

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

讓我們先來談談他們最大的侷限性:區域性函式不能被宣告為內聯(還不能?)並且一個包含區域性函式的函式也不能被宣告為內聯。還沒有一個神奇的方法可以避免在這種情況下函式呼叫的成本。

區域性函式在編譯後被轉換為 Function 物件,就像 lambdas 那樣,並且有著和上篇文章中描述的關於非行內函數的大多數相同的限制。編譯之後的 Java 程式碼形式是這樣的:

public static final int someMath(final int a) {
   Function1 sumSquare$ = new Function1(1) {
      // $FF: synthetic method
      // $FF: bridge method
      public Object invoke(Object var1) {
         return Integer.valueOf(this.invoke(((Number)var1).intValue()));
      }

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

但是與 lambdas 相比有一個小的效能損失:由於呼叫者是知道這個函式的真正例項的,它的特定方法將被直接呼叫,而不是呼叫來自 Function 介面的通用合成方法。這意味著當從外部函式呼叫區域性函式的時候不會有強制型別轉換或者基礎型別裝箱現象發生。我們可以通過檢視位元組碼來驗證這一點:

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 或者可空值分配給不可空變數來有效地阻止意想不到的 NullPointerException

不可空引數執行時檢查

讓我們宣告一個公共的接收一個不可空 String 做為引數的函式:

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 註解,因此當一個 null 值傳過來的時候 Java 工具可以據此來顯示一個警告。

但是一個註解還不足以讓外部呼叫實現空值安全。這就是為什麼編譯器在函式的剛開始處還新增了一個可以檢測引數並且如果引數為 null 就丟擲 IllegalArgumentException靜態方法呼叫。為了使不安全的呼叫程式碼更容易修復,這個函式在早期就會失敗而不是在後期隨機地丟擲 NullPointerException

在實踐中,每一個公共的函式都會在每一個不可空引用引數上新增一個 Intrinsics.checkParameterIsNotNull() 靜態呼叫。私有函式不會有這些檢查,因為編譯器會保證 Kotlin 類中的程式碼是空值安全的。

這些靜態呼叫對效能的影響可以忽略不計並且他們在除錯或者測試一個 app 時確實很有用。話雖這麼說,但你還是可能將他們視為一種正式版本中不必要的額外成本。在這種情況下,可以通過使用編譯器選項中的 -Xno-param-assertions 或者新增以下的混淆規則來禁用執行時空值檢查:

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

注意,這條混淆規則只有在優化功能開啟的時候有效。優化功能在預設的安卓混淆配置中是禁用的。

可空的基本型別

雖然顯而易見,但仍需謹記:可空型別都是引用型別。將基礎型別變數宣告為 可空的話,會阻止 Kotlin 使用 Java 中類似 int 或者 float 那樣的基礎型別,相應的類似 Integer 或者 Float 那樣的裝箱引用型別會被使用,這就引起了額外的裝箱或拆箱成本。

與 Java 中允許草率地使用與 int 變數幾乎完全一樣的 Integer 變數相反,由於自動裝箱和不需要考慮空值安全的原因,在使用可空型別時 Kotlin 會迫使你編寫安全的程式碼,因此使用不可空型別的好處變得越來越清晰:

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

為了更好的可讀性和更佳的效能儘量使用不可空基礎型別。

陣列相關

Kotlin 中有三種陣列型別:

  • IntArray, FloatArray 還有其他的:基礎型別陣列。編譯為 int[], float[] 和其他的型別。
  • Array<T>:不可空物件引用型別化陣列,這涉及到對基礎型別的裝箱。
  • Array<T?>:可空物件引用型別化陣列。很明顯,這也涉及到基礎型別的裝箱。

如果你需要一個不可空的基礎型別陣列,最好用 IntArray 而不是 Array<Int> 來避免裝箱(操作)。


可變引數

Kotlin 允許宣告具有數量可變的引數的函式,就像 Java 那樣。宣告語法有點不一樣:

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 中,你可以直接傳入一個現有的陣列引用作為可變引數。但是在 Kotlin 中你需要使用 分佈操作符:

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

在 Java 中,陣列引用被“原樣”傳入函式,而無需分配額外的陣列記憶體。然而,分佈操作符編譯的方式不同,正如你在(等同的)Java 程式碼中看到的:

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

每當呼叫這個函式時,現在的陣列總會被複制。好處是程式碼更安全:允許函式在不影響呼叫者程式碼的情況下修改這個陣列。但是會分配額外的記憶體

注意,在 Kotlin 程式碼中呼叫一個有可變引數的 Java 方法會產生相同的效果。

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

分佈操作符主要的好處是,它還允許在同一個呼叫中陣列引數和其他引數混合在一起進行傳遞。

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());複製程式碼

除了建立新陣列外,一個臨時的 builder 物件被用來計算最終的陣列大小並填充它。這就使得這個方法呼叫又增加了另一個小的成本。

在 Kotlin 中呼叫一個具有可變引數的函式時會增加建立一個新臨時陣列的成本,即使是使用已有陣列的值。對方法被反覆呼叫的效能關鍵性的程式碼來說,考慮新增一個以真正的陣列而不是 可變陣列 為引數的方法。


感謝閱讀,如果你喜歡的話請分享本文。

繼續閱讀第三部分委派屬性範圍


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

相關文章