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

翻譯說明:html

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

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

原文做者: Christophe Beyls設計模式

這是關於探索Kotlin中隱藏的性能開銷的第2部分,若是你尚未看到第1部分,不要忘記閱讀第1部分。數組

讓咱們一塊兒從底層從新探索和發現更多有關Kotlin語法實現細節。安全

局部函數

這是咱們以前第一篇文章中沒有介紹過的一種函數: 就是像正常定義普通函數的語法同樣,在其餘函數體內部聲明該函數。這些被稱爲局部函數,它們能訪問到外部函數的做用域。bash

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

    return sumSquare(1) + sumSquare(2)
}
複製代碼

咱們首先來講下局部函數最大的侷限性: 局部函數不能被聲明成內聯的(inline)而且函數體內含有局部函數的函數也不能被聲明成內聯的(inline). 在這種狀況下沒有任何有效的方法能夠幫助你避免函數調用的開銷。數據結構

通過編譯後,這些局部函數會將被轉化成Function對象, 就相似lambda表達式同樣,而且一樣具備上篇文章part1中講到的關於非內聯函數存在不少的限制。反編譯後的java代碼:app

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。這就意味着從外部函數調用局部函數時,不會進行基本類型的轉換或裝箱操做. 咱們能夠經過看下字節碼來驗證一下:jvm

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, 這也是我最初翻譯這個系列文章的初衷。關於這篇文章,有幾點我須要補充下:

一、爲何非捕獲局部函數能夠減小開銷

其實關於捕獲和非捕獲的概念,在以前文章中也有所說起,好比在講變量的捕獲,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);
}
複製代碼

經過上述對比,應該很清楚知道了什麼是捕獲什麼是非捕獲以及爲何非捕獲局部函數會減小性能的開銷。

二、總結下提升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開發者聯盟,這裏有最新Kotlin技術文章,每週會不按期翻譯一篇Kotlin國外技術文章。若是你也喜歡Kotlin,歡迎加入咱們~~~

Kotlin系列文章,歡迎查看:

Kotlin邂逅設計模式系列:

數據結構與算法系列:

翻譯系列:

原創系列:

Effective Kotlin翻譯系列

實戰系列:

相關文章
相關標籤/搜索