Kotlin 知識梳理(11) 行內函數

澤毛發表於2017-12-21

一、本文概要

本文是對<<Kotlin in Action>>的學習筆記,如果需要執行相應的程式碼可以訪問線上環境 try.kotlinlang.org,這部分的思維導圖為:

Kotlin 知識梳理(11)   行內函數

二、行內函數

當我們使用lambda表示式時,它會被正常地編譯成匿名類。這表示每呼叫一次lambda表示式,一個額外的類就會被建立,並且如果lambda捕捉了某個變數,那麼每次呼叫的時候都會建立一個新的物件,這會帶來執行時的額外開銷,導致使用lambda比使用一個直接執行相同程式碼的函式效率更低。

如果使用inline修飾符標記一個函式,在函式被呼叫的時候編譯器並不會生成函式呼叫的程式碼,而是 使用函式實現的真實程式碼替換每一次的函式呼叫

2.1 行內函數如何運作

當一個函式被宣告為inline時,它的函式體是內聯的,也就是說,函式體會被直接替換到函式被呼叫地方,下面我們來看一個簡單的例子,下面是我們定義的一個內聯的函式:

inline fun inlineFunc(prefix : String, action : () -> Unit) {
    println("call before $prefix")
    action()
    println("call after $prefix")
}
複製程式碼

我們用如下的方法來使用這個行內函數:

fun main(args: Array<String>) {
    inlineFunc("inlineFunc") {
        println("HaHa")
    }
}
複製程式碼

執行結果為:

>> call before inlineFunc
>> HaHa
>> call after inlineFunc
複製程式碼

最終它會被編譯成下面的位元組碼:

fun main(args: Array<String>) {
    println("call before inlineFunc")
    println("HaHa")
    println("call after inlineFunc")
}
複製程式碼

lambda表示式和inlineFunc的實現部分都被內聯了,由lambda生成的位元組碼成了函式呼叫者定義的一部分,而不是被包含在一個實現了函式介面的匿名類中。

傳遞函式型別的變數作為引數

在呼叫行內函數的時候,也可以傳遞函式型別的變數作為引數,還是上面的例子,我們換一種呼叫方式:

fun main(args: Array<String>) {
    val call : () -> Unit = { println("HaHa") }
    inlineFunc("inlineFunc", call)
}
複製程式碼

那麼此時最終被編譯成的Java位元組碼為:

fun main(args: Array<String>) {
    println("call before inlineFunc ")
    action()
    println("call after inlineFunc")
}
複製程式碼

在這種情況,只有inlineFunc的實現部分被內聯了,而lambda的程式碼在行內函數被呼叫點是不可用的。

在兩個不同的位置使用同一個行內函數

如果在兩個不同的位置使用同一個行內函數,但是用的是不同的lambda,那麼行內函數會在每一個被呼叫的位置分別內聯,行內函數的程式碼會被拷貝到使用它的兩個不同位置,並把不同的lambda替換到其中。

2.2 行內函數的限制

鑑於內聯的運作方式,不是所有使用 lambda 的函式都可以被內聯。當函式被內聯的時候,作為引數的lambda表示式的函式體會被 替換到最終生成的程式碼中

這將限制函式體中的lambda引數的使用:

  • 如果lambda引數 被呼叫,這樣的程式碼能被容易地內聯。
  • 如果lambda引數 在某個地方被儲存起來,以便以後繼續使用,lambda表示式的程式碼 將不能被內聯,因此必須要 有一個包含這些程式碼的物件存在

一般來說,引數如果 被直接呼叫或者作為引數傳遞 給另外一個inline函式,它是可以被內聯的,否則,編譯器會 禁止引數被內聯 並給出錯誤資訊Illeagal usage of inline-parameter

例如,許多作用於序列的函式會返回一些類的例項,這些類代表對應的序列操作並接收lambda作為構造方法的引數,以下是Sequence.map函式的定義:

fun <T, R> Sequence<T>.map(transform : (T) -> R) : Sequence<R> {
    return TransformingSequence(this, transform);
}
複製程式碼

map函式沒有直接呼叫作為transform引數傳遞進來的函式。而是將這個函式傳遞給一個類的構造方法,構造方法將它儲存在一個屬性當中。為了支援這一點,作為transform引數傳遞的lambda需要 被編譯成標準的非內聯表示法,即一個實現了函式介面的匿名類。

如果一個函式期望兩個或更多的lambda函式,可以選擇只內聯其中一些引數,因為一個lambda可能會包含很多程式碼或者 以不允許內聯的方式呼叫,接收這樣的非內聯lambda的引數,可以用noinline修飾符來標記它:

inline fun foo(inlined : () -> Unit, noinline noinlined : () -> Unit) {

}
複製程式碼

注意,編譯器完全支援 內聯跨模組的函式或者第三方庫定義的函式,也可以在 Java 中呼叫絕大部分行內函數

2.3 內聯集合操作

大部分標準庫中的集合函式都帶有lambda引數。例如filter,它被宣告為行內函數,這意味著filter函式,以及傳遞給它的lambda位元組碼會被內聯到filter被呼叫的地方,因此我們不用擔心效能問題。

假如我們像下面這樣,連續呼叫filtermap兩個操作:

println(people.filter{ it.age > 30 }.map(Person :: name))
複製程式碼

這個例子使用了一個lambda表示式和一個成員引用,filtermap函式都被宣告為inline函式,所以不會額外產生類或者物件,但是上面的程式碼會建立一箇中間集合來儲存列表過濾的結果。

2.4 決定何時將函式宣告成內聯

對於普通函式的呼叫,JVM已經提供了強大的內聯支援。它會分析程式碼的執行,並在任何通過內聯能夠帶來好處的時候將函式呼叫內聯。

帶有lambda引數的函式內聯能帶來好處:

  • 節約了函式呼叫的開銷,節約了為lambda建立匿名類,以及建立lambda例項物件的開銷。
  • JVM目前並沒有聰明到總是能夠將函式呼叫內聯。
  • 內聯使得我們可以使用一些不可能被普通lambda使用的特性,例如 非區域性返回

但是在使用inline關鍵字的時候,還是應該注意程式碼的長度,如果你要內聯的函式很大,將它的位元組碼拷貝到每一個呼叫點將會極大地增加位元組碼的長度。在這種情況下,你應該將那些與lambda引數無關的程式碼抽取到一個獨立的非行內函數中。

三、高階函式中的控制流

當你使用lambda去替換像迴圈這樣的命令式程式碼結構時,很快就會遇到return表示式的問題,把一個return語句放在迴圈的中間是很簡單的事。但是如果將迴圈替換成一個類似filter的函式呢?

3.1 lambda 中的返回語句:從一個封閉的函式返回

下面,我們通過一個例子來演示,在集合當中尋找名為Alice的人,找到了就直接返回:

data class Person(val name: String, val age: Int)

val people = listOf(Person("Alice", 29), Person("Bob", 31))

fun lookForAlice(people: List<Person>) {
    people.forEach {
        if (it.name == "Alice") {
            println("Found!")
            return
        }
    }
    println("Alice is not found")
}

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

執行結果為:

>> Found !
複製程式碼

如果在lambda中使用return關鍵字,它會 從呼叫 lambda 的函式 中返回,並不只是 從 lambda 中返回,這樣的return語句叫做 非區域性返回,因為它從一個比包含return的程式碼塊更大的程式碼塊中返回了。

需要注意的是,只有 以 lambda 作為引數的函式是行內函數 的時候才能從更外層的函式返回。在一個非內聯的lambda中使用return表示式是不允許的,一個非行內函數可以把它的lambda儲存在變數中,以便在函式返回以後可以繼續使用,這個時候lambda想要去影響函式的返回已經太晚了。

3.2 從 lambda 中返回:使用標籤返回

也可以在lambda表示式中使用區域性返回,類似於for迴圈中的break表示式,它會終止lambda的執行,並接著從呼叫lambda的程式碼處執行。

要區分區域性返回和非區域性返回,要用到標籤。想從一個lambda表示式處返回你可以標記它,然後在return關鍵字後面引用這個標籤。

data class Person(val name: String, val age: Int)

val people = listOf(Person("Alice", 29), Person("Bob", 31))

fun lookForAlice(people: List<Person>) {
    people.forEach label@{
        if (it.name == "Alice") return@label
    }
    println("Alice might be somewhere")
}

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

執行結果為:

>> Alice might be somewhere
複製程式碼

另一種選擇是,使用lambda作為引數的函式的函式名可以作為標籤,也就是上面的forEach,如果你顯示地指定了lambda表示式的標籤,再使用函式名作為標籤沒有任何效果。

3.3 匿名函式:預設使用區域性返回

匿名函式是一種不同的用於編寫傳遞給函式的程式碼塊的方式,先來看一個示例:

data class Person(val name: String, val age: Int)

val people = listOf(Person("Alice", 29), Person("Bob", 31))

fun lookForAlice(people: List<Person>) {
    people.forEach(fun (person) {
        if (person.name == "Alice") return
        println("${person.name} is not Alice")
    })
}

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

執行結果為:

>> Bob is not Alice
複製程式碼

匿名函式和普通函式有相同的指定返回值型別的規則,程式碼塊匿名函式 需要顯示地指定返回型別,如果使用 表示式函式體,就可以省略返回型別。

在匿名函式中,不帶return表示式會從匿名函式返回,而不是從包含匿名函式的函式返回,這條規則很簡單:return從最近的使用fun關鍵字宣告的函式返回。

  • lambda表示式沒有使用fun關鍵字,所以lambda中的return從最外層的函式返回。
  • 匿名函式使用了fun,因此return表示式從匿名函式返回。

儘管匿名函式看起來和普通函式很相似,但它其實是lambda表示式的另一種語法形式而已。關於lambda表示式如何實現,以及在行內函數中如何被內聯的討論同樣適用於匿名函式。


更多文章,歡迎訪問我的 Android 知識梳理系列:

相關文章