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

PhxNirvana發表於2017-07-31

委託屬性(Delegated propertie)和區間(range)

本系列關於 Kotlin 的前兩篇文章發表之後,讀者們紛至沓來的讚譽讓我受寵若驚,其中還包括 Jake Wharton 的留言。很樂意和大家再次開始探索之旅。不要錯過 第一部分第二部分.

本文我們將探索更多關於 Kotlin 編譯器的祕密,並提供一些可以使程式碼更高效的建議。

委託屬性

委託屬性 是一種通過委託實現擁有 getter 和可選 setter 的 屬性,並允許實現可複用的自定義屬性。

class Example {
    var p: String by Delegate()
}複製程式碼

委託物件必須實現一個擁有 getValue() 方法的操作符,以及 setValue() 方法來實現讀/寫屬性。些方法將會接受包含物件例項以及屬性後設資料作為額外引數。

當一個類宣告委託屬性時,編譯器生成的程式碼會和如下 Java 程式碼相似。

public final class Example {
   @NotNull
   private final Delegate p$delegate = new Delegate();
   // $FF: synthetic field
   static final KProperty[] $$delegatedProperties = new KProperty[]{(KProperty)Reflection.mutableProperty1(new MutablePropertyReference1Impl(Reflection.getOrCreateKotlinClass(Example.class), "p", "getP()Ljava/lang/String;"))};

   @NotNull
   public final String getP() {
      return this.p$delegate.getValue(this, $$delegatedProperties[0]);
   }

   public final void setP(@NotNull String var1) {
      Intrinsics.checkParameterIsNotNull(var1, "<set-?>");
      this.p$delegate.setValue(this, $$delegatedProperties[0], var1);
   }
}複製程式碼

一些靜態屬性後設資料被加入到類中,委託在類的建構函式中初始化,並在每次讀寫屬性時呼叫。

委託例項

在上面的例子中,建立了一個新的委託例項來實現屬性。這就要求委託的實現是有狀態的,例如當其內部快取計算結果時:

class StringDelegate {
    private var cache: String? = null

    operator fun getValue(thisRef: Any?, property: KProperty<*>): String {
        var result = cache
        if (result == null) {
            result = someOperation()
            cache = result
        }
        return result
    }
}複製程式碼

與此同時,當需要額外的引數時,需要建立新的委託例項,並將其傳遞到構造器中:

class Example {
    private val nameView by BindViewDelegate<TextView>(R.id.name)
}複製程式碼

但也有一些情況是只需要一個委託例項來實現任何屬性的:當委託是無狀態,並且它所需要的唯一變數就是已經提供好的包含物件例項和委託名稱時,可以通過將其宣告為 object 來替代 class 實現一個單例委託。

舉個例子,下面的單例委託從 Android Activity 中取回與給定 tag 相匹配的 Fragment

object FragmentDelegate {
    operator fun getValue(thisRef: Activity, property: KProperty<*>): Fragment? {
        return thisRef.fragmentManager.findFragmentByTag(property.name)
    }
}複製程式碼

類似地,任何已有類都可以通過擴充套件變成委託getValue()setValue() 也可以被宣告成 擴充套件方法 來實現。Kotlin 已經提供了內建的擴充套件方法來允許將 Map and MutableMap 例項用作委託,屬性名作為其中的鍵。

如果你選擇複用相同的區域性委託例項來在一個類中實現多屬性,你需要在建構函式中初始化例項。

注意:從 Kotlin 1.1 開始,也可以宣告 方法區域性變數宣告為委託屬性。在這種情況下,委託可以直到該變數在方法內部宣告的時候才去初始化,而不必在建構函式中就執行初始化。

類中宣告的每一個委託屬性都會涉及到與之關聯委託物件的開銷,並會在類中增加一些後設資料。
如果可能的話,儘量在不同的屬性間複用委託。
同時也要考慮一下如果需要宣告大量委託時,委託屬性是不是一個好的選擇。

泛型委託

委託方法也可以被宣告成泛型的,這樣一來不同型別的屬性就可以複用同一個委託類了。

private var maxDelay: Long by SharedPreferencesDelegate<Long>()複製程式碼

然而,如果像上例那樣對基本型別使用泛型委託的話,即便宣告的基本型別非空,也會在每次讀寫屬性的時候觸發裝箱和拆箱的操作

對於非空基本型別的委託屬性來說,最好使用給定型別的特定委託類而不是泛型委託來避免每次訪問屬性時增加裝箱的額外開銷。

標準委託: lazy()

針對常見情形,Kotlin 提供了一些標準委託,如 Delegates.notNull()Delegates.observable()lazy()

lazy() 是一個在第一次讀取時通過給定的 lambda 值來計算屬性的初值,並返回只讀屬性的委託。

private val dateFormat: DateFormat by lazy {
    SimpleDateFormat("dd-MM-yyyy", Locale.getDefault())
}複製程式碼

這是一種簡潔的延遲高消耗的初始化至其真正需要時的方式,在保留程式碼可讀性的同時提升了效能。

需要注意的是,lazy() 並不是行內函數,傳入的 lambda 引數也會被編譯成一個額外的 Function 類,並且不會被內聯到返回的委託物件中。

經常被忽略的一點是 lazy() 有可選的 mode 引數 來決定應該返回 3 種委託的哪一種:

public fun <T> lazy(initializer: () -> T): Lazy<T> = SynchronizedLazyImpl(initializer)
public fun <T> lazy(mode: LazyThreadSafetyMode, initializer: () -> T): Lazy<T> =
        when (mode) {
            LazyThreadSafetyMode.SYNCHRONIZED -> SynchronizedLazyImpl(initializer)
            LazyThreadSafetyMode.PUBLICATION -> SafePublicationLazyImpl(initializer)
            LazyThreadSafetyMode.NONE -> UnsafeLazyImpl(initializer)
        }複製程式碼

預設模式 LazyThreadSafetyMode.SYNCHRONIZED 將提供相對耗費昂貴的 雙重檢查鎖 來保證一旦屬性可以從多執行緒讀取時初始化塊可以安全地執行。

如果你確信屬性只會在單執行緒(如主執行緒)被訪問,那麼可以選擇 LazyThreadSafetyMode.NONE 來代替,從而避免使用鎖的額外開銷

val dateFormat: DateFormat by lazy(LazyThreadSafetyMode.NONE) {
    SimpleDateFormat("dd-MM-yyyy", Locale.getDefault())
}複製程式碼

使用 lazy() 委託來延遲初始化時的大量開銷以及指定模式來避免不必要的鎖。


區間

區間 是 Kotlin 中用來代表一個有限的值集合的特殊表示式。值可以是任何 Comparable 型別。 這些表示式的形式都是建立宣告瞭 ClosedRange 介面的方法。建立區間的主要方法是 .. 操作符方法。

包含

區間表示式的主要作用是使用 in!in 操作符實現包含和不包含。

if (i in 1..10) {
    println(i)
}複製程式碼

該實現針對非空基本型別的區間(包括 IntLongByteShortFloatDouble 以及 Char 的值)實現了優化,所以上面的程式碼可以被優化成這樣:

if(1 <= i && i <= 10) {
   System.out.println(i);
}複製程式碼

零額外支出並且沒有額外物件開銷。區間也可以被包含在 when 表示式中:

val message = when (statusCode) {
    in 200..299 -> "OK"
    in 300..399 -> "Find it somewhere else"
    else -> "Oops"
}複製程式碼

相比一系列的 if{...} else if{...} 程式碼塊,這段程式碼在不降低效率的同時提高了程式碼的可讀性。

然而,如果在宣告和使用之間有至少一次間接呼叫的話,range 會有一些微小的額外開銷。比如下面的程式碼:

private val myRange get() = 1..10

fun rangeTest(i: Int) {
    if (i in myRange) {
        println(i)
    }
}複製程式碼

在編譯後會建立一個額外的 IntRange 物件:

private final IntRange getMyRange() {
   return new IntRange(1, 10);
}

public final void rangeTest(int i) {
   if(this.getMyRange().contains(i)) {
      System.out.println(i);
   }
}複製程式碼

將屬性的 getter 宣告為 inline 的方法也無法避免這個物件的建立。這是 Kotlin 1.1 編譯器可以優化的一個點。至少通過這些特定的區間類避免了裝箱操作。

儘量在使用時直接宣告非空基本型別的區間,不要間接呼叫,來避免額外區間類的建立。
或者直接宣告為常量來複用。

區間也可以用於其他實現了 Comparable 的非基本型別。

if (name in "Alfred".."Alicia") {
    println(name)
}複製程式碼

在這種情況下,最終實現並不會優化,而且總是會建立一個 ClosedRange 物件,如下面編譯後的程式碼所示:

if(RangesKt.rangeTo((Comparable)"Alfred", (Comparable)"Alicia")
   .contains((Comparable)name)) {
   System.out.println(name);
}複製程式碼

如果你需要對一個實現了 Comparable 的非基本型別的區間進行頻繁的包含的話,考慮將這個區間宣告為常量來避免重複建立區間類吧。

迭代:for 迴圈

整型區間 (除了 FloatDouble之外其他的基本型別)也是 級數它們可以被迭代。這就可以將經典 Java 的 for 迴圈用一個更短的表示式替代。

for (i in 1..10) {
    println(i)
}複製程式碼

經過編譯器優化後的程式碼實現了零額外開銷

int i = 1;
byte var3 = 10;
if(i <= var3) {
   while(true) {
      System.out.println(i);
      if(i == var3) {
         break;
      }
      ++i;
   }
}複製程式碼

如果要反向迭代,可以使用 downTo() 中綴方法來代替 ..

for (i in 10 downTo 1) {
    println(i)
}複製程式碼

編譯之後,這也實現了零額外開銷:

int i = 10;
byte var3 = 1;
if(i >= var3) {
   while(true) {
      System.out.println(i);
      if(i == var3) {
         break;
      }
      --i;
   }
}複製程式碼

然而,其他迭代器引數並沒有如此好的優化

反向迭代還有一種結果相同的方式,使用 reversed() 方法結合區間:

for (i in (1..10).reversed()) {
    println(i)
}複製程式碼

編譯後的程式碼並沒有看起來那麼少:

IntProgression var10000 = RangesKt.reversed((IntProgression)(new IntRange(1, 10)));
int i = var10000.getFirst();
int var3 = var10000.getLast();
int var4 = var10000.getStep();
if(var4 > 0) {
   if(i > var3) {
      return;
   }
} else if(i < var3) {
   return;
}

while(true) {
   System.out.println(i);
   if(i == var3) {
      return;
   }

   i += var4;
}複製程式碼

會建立一個臨時的 IntRange 物件來代表區間,然後建立另一個 IntProgression 物件來反轉前者的值。

事實上,任何結合不止一個方法來建立遞進都會生成類似的至少建立兩個微小遞進物件的程式碼。

這個規則也適用於使用 step() 中綴方法來操作遞進的步驟,即使只有一步

for (i in 1..10 step 2) {
    println(i)
}複製程式碼

一個次要提示,當生成的程式碼讀取 IntProgressionlast 屬性時會通過對邊界和步長的小小計算來決定準確的最後值。在上面的程式碼中,最終值是 9。

最後,until() 中綴函式對於迭代也很有用,該函式(執行結果)不包含最大值。

for (i in 0 until size) {
    println(i)
}複製程式碼

遺憾的是,編譯器並沒有針對這個經典的包含區間圍優化,迭代器依然會建立區間物件:

IntRange var10000 = RangesKt.until(0, size);
int i = var10000.getFirst();
int var1 = var10000.getLast();
if(i <= var1) {
   while(true) {
      System.out.println(i);
      if(i == var1) {
         break;
      }
      ++i;
   }
}複製程式碼

這是 Kotlin 1.1 可以提升的另一個點
與此同時,可以通過這樣寫來優化程式碼:

for (i in 0..size - 1) {
    println(i)
}複製程式碼

for 迴圈內部的迭代,最好只用區間表示式的一個單獨方法來呼叫 ..downTo() 來避免額外臨時遞進物件的建立。

迭代:forEach()

作為 for 迴圈的替代,使用區間內聯的擴充套件方法 forEach() 來實現相似的效果可能更吸引人。

(1..10).forEach {
    println(it)
}複製程式碼

但如果仔細觀察這裡使用的 forEach() 方法簽名的話,你就會注意到並沒有優化區間,而只是優化了 Iterable,所以需要建立一個 iterator。下面是編譯後程式碼的 Java 形式:

Iterable $receiver$iv = (Iterable)(new IntRange(1, 10));
Iterator var1 = $receiver$iv.iterator();

while(var1.hasNext()) {
   int element$iv = ((IntIterator)var1).nextInt();
   System.out.println(element$iv);
}複製程式碼

這段程式碼相比前者更為低效,原因是為了建立一個 IntRange 物件,還需要額外建立 IntIterator。但至少它還是生成了基本型別的值。

迭代區間時,最好只使用 for 迴圈而不是區間上的 forEach() 方法來避免額外建立一個迭代器。

迭代:集合

Kotlin 標準庫提供了內建的 indices 擴充套件屬性來生成陣列和 Collection 的區間。

val list = listOf("A", "B", "C")
for (i in list.indices) {
    println(list[i])
}複製程式碼

令人驚訝的是,對這個 indices 的迭代得到了編譯器的優化

List list = CollectionsKt.listOf(new String[]{"A", "B", "C"});
int i = 0;
int var2 = ((Collection)list).size() - 1;
if(i <= var2) {
   while(true) {
      Object var3 = list.get(i);
      System.out.println(var3);
      if(i == var2) {
         break;
      }
      ++i;
   }
}複製程式碼

從上面的程式碼中我們可以看到沒有建立 IntRange 物件,列表的迭代是以最高效率的方式執行的。

這適用於陣列和實現了 Collection 的類,所以你如果期望相同的迭代器效能的話,可以嘗試在特定的類上使用自己的 indices 擴充套件屬性。

inline val SparseArray<*>.indices: IntRange
    get() = 0..size() - 1

fun printValues(map: SparseArray<String>) {
    for (i in map.indices) {
        println(map.valueAt(i))
    }
}複製程式碼

但編譯之後,我們可以發現這並沒有那麼高效率,因為編譯器無法足夠智慧地避免區間物件的產生:

public static final void printValues(@NotNull SparseArray map) {
   Intrinsics.checkParameterIsNotNull(map, "map");
   IntRange var10002 = new IntRange(0, map.size() - 1);
   int i = var10002.getFirst();
   int var2 = var10002.getLast();
   if(i <= var2) {
      while(true) {
         Object $receiver$iv = map.valueAt(i);
         System.out.println($receiver$iv);
         if(i == var2) {
            break;
         }
         ++i;
      }
   }
}複製程式碼

所以,我會建議你避免宣告自定義的 lastIndex 擴充套件屬性:

inline val SparseArray<*>.lastIndex: Int
    get() = size() - 1

fun printValues(map: SparseArray<String>) {
    for (i in 0..map.lastIndex) {
        println(map.valueAt(i))
    }
}複製程式碼

當迭代沒有宣告 Collection自定義集合 時,直接在 for 迴圈中寫自己的序列區間而不是依賴方法或屬性來生成區間,從而避免區間物件的建立。


我在寫本文時興趣盎然,希望你讀起來也一樣。可能你還期待以後有更多的文章,但這三篇已經涵蓋了我目前想要寫的所有內容了。如果喜歡的話請分享。謝謝!


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

相關文章