[譯]Effective Kotlin系列之探索高階函式中inline修飾符(三)

極客熊貓發表於2018-12-10

簡述: 不知道是否有小夥伴還記得我們之前的Effective Kotlin翻譯系列,之前一直忙於趕時髦研究Kotlin 1.3中的新特性。把此係列耽擱了,趕完時髦了還是得踏實探究本質和基礎,從今天開始我們將繼續探索Effective Kotlin系列,今天是Effective Kotlin第三講。

翻譯說明:

原標題: Effective Kotlin: Consider inline modifier for higher-order functions

原文地址: blog.kotlin-academy.com/effective-k…

原文作者: Marcin Moskala

你或許已經注意到了所有集合操作的函式都是內聯的(inline)。你是否問過自己它們為什麼要這麼定義呢? 例如,這是Kotlin標準庫中的filter函式的簡化版本的原始碼:

inline fun <T> Iterable<T>.filter(predicate: (T)->Boolean): List<T>{
    val destination = ArrayList<T>()
    for (element in this) 
        if (predicate(element))
            destination.add(element)
    return destination
}
複製程式碼

這個inline修飾符到底有多重要呢? 假設我們有5000件商品,我們需要對已經購買的商品累計算出總價。我們可以通過以下方式完成:

products.filter{ it.bought }.sumByDouble { it.price }
複製程式碼

在我的機器上,執行上述程式碼平均需要38毫秒。如果這個函式不是內聯的話會是多長時間呢? 不是內聯在我的機器上大概平均42毫秒。你們可以自己檢查嘗試下,這裡是完整原始碼. 這似乎看起來差距不是很大,但每呼叫一次這個函式對集合進行處理時,你都會注意到這個時間差距大約為10%左右。

當我們修改lambda表示式中的區域性變數時,可以發現差距將會更大。對比下面兩個函式:

inline fun repeat(times: Int, action: (Int) -> Unit) {
    for (index in 0 until times) {
        action(index)
    }
}

fun noinlineRepeat(times: Int, action: (Int) -> Unit) {
    for (index in 0 until times) {
        action(index)
    }
}
複製程式碼

你可能已經注意到除了函式名不一樣之外,唯一的區別就是第一個函式使用inline修飾符,而第二個函式沒有。用法也是完全一樣的:

var a = 0
repeat(100_000_000) {
    a += 1
}
var b = 0
noinlineRepeat(100_000_000) {
    b += 1
}
複製程式碼

上述程式碼在執行時間上對比有很大的差異。內聯的repeat函式平均執行時間是0.335ns, 而noinlineRepeat函式平均執行時間是153980484.884ns。大概是內聯repeat函式執行時間的466000倍! 你們可以自己檢查嘗試下,這裡是完整原始碼.

為什麼這個如此重要呢? 這種效能的提升是否有其他的成本呢? 我們應該什麼時候使用內聯(inline)修飾符呢?這些都是重點問題,我們將盡力回答這些問題。然而這一切都需要從最基本的問題開始: 內聯修飾符到底有什麼作用?

內聯修飾符有什麼作用?

我們都知道函式通常是如何被呼叫的。先執行跳轉到函式體,然後執行函式體內所有的語句,最後跳回到最初呼叫函式的位置。

儘管強行對函式使用inline修飾符標記,但是編譯器將會以不同的方式來對它進行處理。在程式碼編譯期間,它用它的主體替換這樣的函式呼叫。 print函式是inline函式:

public inline fun print(message: Int) {
    System.out.print(message)
}
複製程式碼

當我們在main函式中呼叫它時:

fun main(args: Array<String>) {
    print(2)
    print(2)
}
複製程式碼

編譯後,它將變成下面這樣:

 public static final void main(@NotNull String[] args) {
    System.out.print(2)
    System.out.print(2)
}
複製程式碼

這裡有一點不一樣的是我們不需要跳回到另一個函式中。雖然這種影響可以忽略不計。這就是為什麼你定義這樣的行內函數時會在IDEA IntelliJ中發出以下警告:

[譯]Effective Kotlin系列之探索高階函式中inline修飾符(三)

為什麼IntelliJ建議我們在含有lambda表示式作為形參的函式中使用內聯呢?因為當我們行內函數體時,我們不需要從引數中建立lambda表示式例項,而是可以將它們內聯到函式呼叫中來。這個是上述repeat函式的呼叫:

repeat(100) { println("A") }
複製程式碼

將會編譯成這樣:

for (index in 0 until 1000) {
    println("A")
}
複製程式碼

正如你所看見的那樣,lambda表示式的主體println("A")替換了行內函數repeataction(index)的呼叫。讓我們看另一外個例子。filter函式的用法:

val products2 = products.filter { it.bought }
複製程式碼

將被替換為:

val destination = ArrayList<T>()
for (element in this) 
    if (predicate(element))
        destination.add(element)
val products2 = destination
複製程式碼

這是一項非常重要的改進。這是因為JVM天然地不支援lambda表示式。說清楚lambda表示式是如何被編譯的是件很複雜的事。但總的來說,有兩種結果:

  • 匿名類
  • 單獨的類

我們來看個例子。我們有以下lambda表示式:

val lambda: ()->Unit = {
    // body
}
複製程式碼

它變成了JVM中的匿名類:

// Java
Function0 lambda = new Function0() {
   public Object invoke() {
      // code
   }
};
複製程式碼

或者它變成了單獨的檔案中定義的普通類:

// Java
// Additional class in separate file
public class TestInlineKt$lambda implements Function0 {
   public Object invoke() {
      // code
   }
}
// Usage
Function0 lambda = new TestInlineKt$lambda()
複製程式碼

第二種效率更高,我們儘可能使用這種。僅僅當我們需要使用區域性變數時,第一種才是必要的。

這就是為什麼當我們修改區域性變數時,repeatnoinlineRepeat之間存在如此之大的執行速度差異的原因。非行內函數中的Lambda需要編譯為匿名類。這是一個巨大的效能開銷,從而導致它們的建立和使用都較慢。當我們使用行內函數時,我們根本不需要建立任何其他類。自己檢查一下。編譯這段程式碼並把它反編譯為Java程式碼:

fun main(args: Array<String>) {
    var a = 0
    repeat(100_000_000) {
        a += 1
    }
    var b = 0
    noinlineRepeat(100_000_000) {
        b += 1
    }
}
複製程式碼

你會發現一些相似的東西:

/ Java
public static final void main(@NotNull String[] args) {
   int a = 0;
   int times$iv = 100000000;
   int var3 = 0;

   for(int var4 = times$iv; var3 < var4; ++var3) {
      ++a;
   }

   final IntRef b = new IntRef();
   b.element = 0;
   noinlineRepeat(100000000, (Function1)(new Function1() {
      public Object invoke(Object var1) {
         ++b.element;
         return Unit.INSTANCE;
      }
   }));
}
複製程式碼

filter函式例子中,使用行內函數改進效果不是那麼明顯,這是因為lambda表示式在非行內函數中是編譯成普通的類而非匿名類。所以它的建立和使用效率還算比較高,但仍有效能開銷,所以也就證明了最開始那個filter例子為什麼只有10%的執行速度差異。

集合流處理方式與經典處理方式

內聯修飾符是一個非常關鍵的元素,它能使集合流處理的方式與基於迴圈的經典處理方式一樣高效。它經過一次又一次的測試,在程式碼可讀性和效能方面已經優化到極點了,並且相比之下經典處理方式總是有很大的成本。例如,下面的程式碼:

return data.filter { filterLoad(it) }.map { mapLoad(it) }
複製程式碼

工作原理與下面程式碼相同並具有相同的執行時間:

val list = ArrayList<String>()
for (it in data) {
    if (filterLoad(it)) {
        val value = mapLoad(it)
        list.add(value)
    }
}
return list
複製程式碼

基準測量的具體結果(原始碼在這裡):

Benchmark           (size) Mode  Cnt        Score    Error  Units
filterAndMap           10  avgt  200      561.249 ±      1  ns/op
filterAndMap         1000  avgt  200    29803.183 ±    127  ns/op
filterAndMap       100000  avgt  200  3859008.234 ±  50022  ns/op

filterAndMapManual     10  avgt  200      526.825 ±      1  ns/op
filterAndMapManual   1000  avgt  200    28420.161 ±     94  ns/op
filterAndMapManual 100000  avgt  200  3831213.798 ±  34858  ns/op
複製程式碼

從程式的角度來看,這兩個函式幾乎相同。儘管從可讀性的角度來看第一種方式要好很多,這就是為什麼我們應該總是寧願使用智慧的集合流處理函式而不是自己去實現整個處理過程。此外如果stalib庫中集合處理函式不能滿足我們的需求時,請不要猶豫,自己動手編寫集合處理函式。例如,當我需要轉置集合中的集合時,這是我在上一個專案中新增的函式:

fun <E> List<List<E>>.transpose(): List<List<E>> {
    if (isEmpty()) return this

    val width = first().size
    if (any { it.size != width }) {
        throw IllegalArgumentException("All nested lists must have the same size, but sizes were ${map { it.size }}")
    }

    return (0 until width).map { col ->
        (0 until size).map { row -> this[row][col] }
    }
}
複製程式碼

記得寫一些單元測試:

class TransposeTest {

    private val list = listOf(listOf(1, 2, 3), listOf(4, 5, 6))

    @Test
    fun `Transposition of transposition is identity`() {
        Assert.assertEquals(list, list.transpose().transpose())
    }

    @Test
    fun `Simple transposition test`() {
        val transposed = listOf(listOf(1, 4), listOf(2, 5), listOf(3, 6))
        assertEquals(transposed, list.transpose())
    }
}
複製程式碼

內聯修飾符的成本

內聯不應該被過度使用,因為它也是有成本的。我想在程式碼中列印出更多的數字2, 所以我就定義了下面這個函式:

inline fun twoPrintTwo() {
    print(2)
    print(2)
}
複製程式碼

這對我來說可能還不夠,所以我新增了這個函式:

inline fun twoTwoPrintTwo() {
    twoPrintTwo()
    twoPrintTwo()
}
複製程式碼

還是不滿意。我又定義了以下這兩個函式:

inline fun twoTwoTwoPrintTwo() {
    twoTwoPrintTwo()
    twoTwoPrintTwo()
}

fun twoTwoTwoTwoPrintTwo() {
    twoTwoTwoPrintTwo()
    twoTwoTwoPrintTwo()
}
複製程式碼

然後我決定檢查編譯後的程式碼中發生了什麼,所以我將編譯為JVM位元組碼然後將它反編譯成Java程式碼。twoTwoPrintTwo函式已經很長了:

public static final void twoTwoPrintTwo() {
   byte var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
}
複製程式碼

但是twoTwoTwoTwoPrintTwo就更加恐怖了

public static final void twoTwoTwoTwoPrintTwo() {
   byte var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
}
複製程式碼

這說明了行內函數的主要問題: 當我們過度使用它們時,會使得程式碼體積不斷增大。這實際上就是為什麼當我們使用他們時IntelliJ會給出警告提示。

內聯修飾符在不同方面的用法

內聯修飾符因為它特殊的語法特性而發生的變化遠遠超過我們在本篇文章中看到的內容。它可以實化泛型型別。但是它也有一些侷限性。雖然這與Effective Kotlin系列無關並且屬於是另外一個話題。如果你想要我闡述更多有關它,請在Twitter或評論中表達你的想法。

一般來說,我們應該什麼時候使用內聯修飾符呢?

我們使用內聯修飾符時最常見的場景就是把函式作為另一個函式的引數時(高階函式)。集合或字串處理(如filter,map或者joinToString)或者一些獨立的函式(如repeat)就是很好的例子。

這就是為什麼inline修飾符經常被庫開發人員用來做一些重要優化的原因了。他們應該知道它是如何工作的,哪裡還需要被改進以及使用成本是什麼。當我們使用函式型別作為引數來定義自己的工具類函式時,我們也需要在專案中使用inline修飾符。當我們沒有函式型別作為引數,沒有reified實化型別引數並且也不需要非本地返回時,那麼我們很可能不應該使用inline修飾符了。這就是為什麼我們在非上述情況下使用inline修飾符會在Android Studio或IDEA IntelliJ得到一個警告原因。

譯者有話說

這是Effective Kotlin系列第三篇文章,講得是inline行內函數存在使用時潛在隱患,一旦使用不當或者過度使用就會造成效能上損失。基於這一點原作者從發現問題到剖析整個inline行內函數原理以及最後如何去選擇在哪種場景下使用行內函數。我相信有了這篇文章,你對Kotlin中的行內函數應該是瞭然於胸了吧。後面會繼續Effective Kotlin翻譯系列,歡迎繼續關注~~~

Kotlin系列文章,歡迎檢視:

Effective Kotlin翻譯系列

原創系列:

翻譯系列:

實戰系列:

[譯]Effective Kotlin系列之探索高階函式中inline修飾符(三)

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

相關文章