Kotlin教程(六)Lambda程式設計

胡奚冰發表於2018-04-02

寫在開頭:本人打算開始寫一個Kotlin系列的教程,一是使自己記憶和理解的更加深刻,二是可以分享給同樣想學習Kotlin的同學。系列文章的知識點會以《Kotlin實戰》這本書中順序編寫,在將書中知識點展示出來同時,我也會新增對應的Java程式碼用於對比學習和更好的理解。

Kotlin教程(一)基礎
Kotlin教程(二)函式
Kotlin教程(三)類、物件和介面
Kotlin教程(四)可空性
Kotlin教程(五)型別
Kotlin教程(六)Lambda程式設計
Kotlin教程(七)運算子過載及其他約定
Kotlin教程(八)高階函式
Kotlin教程(九)泛型


Lambda表示式,或簡稱lambda,本質上級就是可以傳遞給其他函式的一小段程式碼。有了lambda,可以輕鬆地把通用的程式碼結構抽取成庫函式,Kotlin標準庫就大量地使用了它們。

Lambda表示式和成員引用

把lambda引入Java 8是Java這門語言演變過程中讓人望眼欲穿的變化之一。為什麼它是如此重要?這一節中,你會發現為何lambda這麼好用,以及Kotlin的lambda語法看起來是什麼樣子的。

Lambda簡介:作為函式引數的程式碼塊

在你程式碼中儲存和傳遞一小段行為是常有的任務。例如,你常常需要表達像這樣的想法:“當一個時間發生的時候執行這個事件處理器”又或者是“把這個操作應用到這個資料介面中所有元素上”。在老版本的Java中,可以使用匿名內部類來實現。這種技巧可以工作但是語法太囉嗦了。
函數語言程式設計提供了另外一種解決問題的方法:把函式當做值來對待。可以直接傳遞函式,而不需要先宣告一個類再傳遞這個類的例項。使用lambda表示式之後,程式碼會更加簡潔。都不需要宣告函式了,可以高效地直接傳遞程式碼塊作為函式引數。
我們來看一個例子。假設你要定義一個點選按鈕的行為,新增一個負責處理點選的監聽器。監聽器實現了相應的介面OnClickListener和它的一個方法onClick:

/* Java */
button.setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            //do something
        }
});
複製程式碼

這樣宣告匿名內部類的寫法實在是太囉嗦了。在Kotlin中我們可以像Java 8一樣使用lambda來消除這些冗餘程式碼。

/* Kotlin */
button.setOnClickListener{ /* do someting */ }
複製程式碼

這段程式碼做了與上面同樣的事情,但是不用再寫囉嗦的匿名內部類了。
之前也說過Kotlin可以使用關鍵字object 匿名內部類,因此,你想寫成普通的方式也是可以的:

button.setOnClickListener(object : View.OnClickListener {
            override fun onClick(v: View?) {
                println("on click")
            }
        })
複製程式碼

上面兩種方式轉換成Java程式碼:

button.setOnClickListener((OnClickListener)null.INSTANCE);
button.setOnClickListener((OnClickListener)(new OnClickListener() {
     public void onClick(@Nullable View v) {
        String var2 = "on click";
        System.out.println(var2);
     }
  }));
複製程式碼

匿名內部類轉換成了Java的匿名內部類。但是lambda應該是Kotlin自己做了特出處理,無法轉換成相應的Java程式碼。

Lambda和集合

我們先來看一個例子,你會用到一個Person類,它包含這個人的名字和年齡資訊:

data class Person(val name: String, val age: Int)
複製程式碼

假設現在你有一個人的列表,需要找到列表中年齡最大的那個人。如果完全不瞭解lambda,你可能會這樣做:

fun findTheOldest(people: List<Person>) {
    var maxAge = 0
    var theOldest: Person? = null
    for (person in people) {
        if (person.age > maxAge) {
            maxAge = person.age
            theOldest = person
        }
    }
    println(theOldest)
}

>>> val people = listOf(Person("Alice", 29), Person("Hubert", 26))
>>> findTheOldest(people)
Person("Alice", 29)
複製程式碼

可以完成目的,但是程式碼稍微有點多。而Kotlin有更好的方法,可以使用庫函式:

>>> val people = listOf(Person("Alice", 29), Person("Hubert", 26))
>>> println(people.maxBy{ it.age })
Person("Alice", 29)
複製程式碼

maxBy函式可以在任何集合上呼叫,且只需要一個實參:一個函式,指定比較哪個值來找到最大元素。花括號中的程式碼{ it.age } 就是實現了這個邏輯的lambda。它接收一個集合中的元素作為實參(使用it引用它)並且返回用來比較的值。這個例子中,集合元素是Person物件,用來比較的是儲存在其age屬性中的年齡。
如果lambda剛好是函式或者屬性的委託,可以用成員引用替換:

people.maxBy{ Person::age }
複製程式碼

雖然lambda看上去很簡潔,但是你可能不是很明白到底是如何寫lambda,以及裡面的規則,我們來學習下lambda表示式的語法吧。

Lambda表示式的語法

一個lambda把一小段行為進行編碼,你能把它當做值到處傳遞。它可以被獨立地宣告並儲存到一個變數中。但是更常見的還是直接宣告它並傳遞給函式。

   //引數           //函式體
{ x: Int, y: Int -> x + y }
複製程式碼

Kotlin的lambda表示式始終用花括號包圍。->把實參和函式體分割開,左邊是引數列表,右邊是函式體。注意引數並沒有用() 括起來。
可以把lambda表示式儲存在一個變數中,把這個變數當做普通函式對待(即通過相應實參呼叫它):

>>> val sum = {x:Int,y:Int -> x + y}
>>> println(sum(1, 2))
3
複製程式碼

如果你樂意,還可以直接呼叫lambda表示式:

>>> { println(42) }()
42
複製程式碼

但是這樣的語法毫無可讀性,也沒有什麼意義(它等價於直接執行lambda函式體中的程式碼)。如果你確實需要把一小段程式碼封閉在一個程式碼塊中,可以使用庫函式run來執行傳遞它的lambda:

>>> run{ println(42) }
42
複製程式碼

在之後的章節我們會了解到這種呼叫和內建語言結構一樣高效且不會帶來額外執行時開銷,以及背後的原因。現在我們繼續看“找到列表中年齡最大”的例子:

>>> val people = listOf(Person("Alice", 29), Person("Hubert", 26))
>>> println(people.maxBy{ it.age })
Person("Alice", 29)
複製程式碼

如果不用任何簡明語法來重寫這個例子,你會得到下面的程式碼:

people.maxBy({ p: Person -> p.age })
複製程式碼

這段程式碼一目瞭然:花括號中的程式碼片段是lambda表示式,把它作為實參傳給函式。這個lambda接收一個型別為Person的引數並返回它的年齡。
但是這段程式碼有點囉嗦。首先,過多的標點符號破壞了可讀性。其次,型別可以從上下文推斷出來並可以省略。最後,這種情況下不需要給lambda的引數分配一個名稱。
讓我們來改進這些地方,先拿花括號開刀。Kotlin有這樣一種語法約定,如果lambda表示式是函式呼叫的最後一個實參,它可以放到括號的外邊。這個例子中,lambda是唯一的實參,所以可以放到括號的後邊:

people.maxBy() { p:Person -> p.age }
複製程式碼

當lambda時函式唯一的實參時,還可以去掉呼叫程式碼中的空括號:

people.maxBy { p:Person -> p.age }
複製程式碼

三種語法形式含義都是一樣的,但最後一種最易讀。如果lambda是唯一的實參,你當然願意在寫程式碼的時候省掉這些括號。而當你有多個實參時,即可以把lambda留在括號內來強調它是一個實參,也可以把它放在括號的外面,兩種選擇都是可行的。如果你想傳遞兩個更多的lambda,不能把超過一個lambda放在外面。
我們來看看這些選項在更復雜的呼叫中是怎樣的。還記得外面在教程二中定義的joinToString函式嗎?Kotlin標準庫中也有定義它,不同之處在於它可以接收一個附加的函式引數。這個函式可以用toString函式以外的方法來把一個元素轉換成字串。下面的例子展示了你可以用它只列印出人的名字:

>>> val names = people.joinToString(separator = " ", transform = { p: Person -> p.name })
>>> println(names)
Alice Hubert
複製程式碼

這種方式使用命名實參來傳遞lambda,清楚地表示了lambda應用到了哪裡。
下面的例子展示課可以怎樣重寫這個呼叫,把lambda放在括號外:

>>> val names = people.joinToString(" ") { p: Person -> p.name }
>>> println(names)
Alice Hubert
複製程式碼

這種方式沒有顯式地表明lambda引用到了哪裡,所以不熟悉被呼叫函式的那些人可能更難理解。

在as或者IDEA中可以使用Alt+Enter喚起操作,使用“Move lambda expression out of parentheses ”把lambda表示式移動到括號外,或“Move lambda expression into parentheses”把lambda表示式移動到括號內。

我們繼續簡化語法,移除引數的型別。

people.maxBy { p:Person -> p.age }
people.maxBy { p -> p.age }  //推匯出引數型別
複製程式碼

和區域性變數一樣,如果lambda引數的型別可以被推匯出來,你就不需要顯示地指定它。以這裡的maxBy函式為例,其引數型別始終和集合的元素型別相同。編譯器知道你是對一個Person物件的集合呼叫maxBy函式,所以它能推導lambda引數也會是Person型別。
也存在編譯器不能推斷出lambda引數型別的情況,但這裡我們暫不討論。可以遵循這樣的一條簡單的規則:先不宣告型別,等編譯器報錯後再來指定它們。
這個例子你能做的最後簡化是使用預設引數名稱it代替命名引數。如果當前上下文期望的是隻有一個引數的lambda且這個引數的型別可以推斷出來,就會生成這個名稱。

people.maxBy { it.age }  //it是自動生成的引數名稱
複製程式碼

僅實參名稱沒有顯示地指定時這個預設的名稱才會生成。

it約定能大大縮短你的程式碼,但你不應該濫用它。尤其是在巢狀lambda的情況下。最好顯式地宣告每個lambda的引數。fouz,很難搞清楚it引用的到底是那個值。如果上下文中引數的型別或意義都不是很明朗,顯式宣告引數的方法也很有效。

如果你用變數儲存lambda,那麼就沒有可以推斷出引數型別的上下文,所以你必須顯式地指定引數型別:

>>> val getAge = { p:Person -> p.age }
>>> people.maxBy(getAge)
複製程式碼

至此你看到的例子都是單個表示式或語句構成的lambda。但是lambda並沒有 被限制在這樣小的規模,它可以包含更多的語句。下面這種情況,最後一個表示式就是(lambda的)結果:

val sum = { x: Int, y: Int ->
        println("Computing the sum of $x and $y ...")
        x + y
    }
>>> println(sum(1, 2))
Computing the sum of 1 and 2 ...
3
複製程式碼

在作用域中訪問變數

當在函式內宣告一個匿名內部類的時候,能夠在這個匿名內部類引用這個函式的引數和區域性變數。也可以用lambda作同樣的事情。如果在函式內部使用lambda,也可以訪問這個函式的引數,還有在lambda之前定義的區域性變數。
我們用標準庫函式forEach來展示這種行為。這個函式能夠遍歷集合中的每一個元素,並在該元素上呼叫給定的lambda。forEach函式只是比普通for迴圈更簡潔一些。

fun printMessageWithPrefix(message: Collection<String>, prefix: String) {
    //接受lambda作為實參指定對每個元素的操作
    message.forEach {
        println("$prefix $it")  //在lambda中訪問prefix引數
    }
}

>>> val errors = listOf("403 Forbidden", "404 Not Found")
>>> printMessageWithPrefix(errors, "Error:")
Error: 403 Forbidden
Error: 404 Not Found
複製程式碼

這裡Kotlin和Java的一個顯著區別是:在Kotlin中不會僅限於訪問final變數,在lambda內部也可以修改這些變數:

fun printProblemCounts(response: Collection<String>) {
    var clientErrors = 0
    var serverErrors = 0
    response.forEach {
        if (it.startsWith("4")) {
            clientErrors++
        } else if (it.startsWith("5")) {
            serverErrors++
        }
    }
    println("$clientErrors client errors, $serverErrors server errors")
}

>>> val response = listOf("200 OK", "418 I'm a teapot", "500 Internal Server Error")
>>> printProblemCounts(response)
1
複製程式碼

和Java不一樣,Kotlin允許在lambda內部訪問非final變數甚至修改它們。從lambda內訪問外部變數,我們稱這些變數被lambda捕獲,就像這個例子中的prefix、clientErrors以及serverErrors一樣。

訪問非final變數甚至修改它們的原理

注意,預設情況下,區域性變數的生命週期被限制在宣告這個變數的函式中,但是如果它被lambda捕獲了,使用這個變數的程式碼可以被儲存並稍後再執行。你可能會問這事什麼原理?當你捕獲final變數時,它的值和使用這個值的lambda程式碼一起儲存。而對非final變數來說,它的值被封裝在一個特殊的包裝器中,這樣你就可以改變這個值,而對這個包裝器的引用會和lambda程式碼一起儲存。
這個原理我在教程三中的匿名內部類中也有提到:訪問建立匿名內部類的函式中的變數是沒有限制在final變數,當時舉了這個例子:

var clickCount = 0 
B().setListener(object : Listener {
    override fun onClick() {
        clickCount++ //修改變數
    }
})
複製程式碼

並且轉換成了Java程式碼:

final IntRef clickCount = new IntRef();
clickCount.element = 0;
(new B()).setListener((Listener)(new Listener() {
   public void onClick() {
      int var1 = clickCount.element++;
   }
}));
複製程式碼

可以看到真實被使用clickCount是int型別數,但在Java中使用確實包裝類IntRef,而真實int變成了clickCount.element。
任何時候你捕獲了一個final變數(val),它的值被拷貝下來,這和Java一樣。而當你捕獲了一個可變變數(var)時,它的值被作為Ref類的一個例項被儲存下來。Ref變數是final的能輕易被捕獲,然而實際值被儲存在其欄位中,並且可以在lambda內修改。

這裡有個重要的注意事項,如果lambda被用作事件處理器或者用在其他非同步執行的情況,對區域性變數的修改只會在lambda執行的時候發生。例如下面這段程式碼並不是記錄按鈕點選次數的正確方法:

fun tryToCountButtonOnClicks(button: Button): Int {
    var clicks = 0
    button.setOnClickListener { clicks++ }
    return clicks
}
複製程式碼

這個函式始終返回0。儘管onClick處理器可以修改clicks的值,你並不能觀察到值發生了變化,因為onClick處理器是在函式返回之後呼叫的。這個函式正確的實現需要把點選次數儲存在函式外依然可以訪問的地方——例如類的屬性,而不是儲存在函式的區域性變數中。

成員引用

你已經看到lambda是如何讓你把程式碼塊作為引數傳遞給函式的。但是如果你想要當做引數傳遞的程式碼已經被定義成了函式,該怎麼辦?當然可以傳遞一個呼叫這個函式的lambda,但這樣做有點多餘。name你能直接傳遞函式嗎?
Kotlin和Java 8一樣,如果把函式轉換成一個值,你就可以傳遞它。使用:: 運算子來轉換:

val getAge = Person::age
複製程式碼

這種表示式稱為成員引用,它提供了簡明語法,來建立一個呼叫單個方法或者訪問單個屬性的函式值。雙冒號把類名稱與你要引用的成員(一個方法或者一個屬性)名稱隔開。
同樣的內容用lambda表示式實現是這樣的:

val getAge = { person: Person -> person.age }
複製程式碼

不管你引用的函式還是屬性,都不要在成員引用的名稱後面加括號。成員引用和呼叫該函式的lambda具有一樣的型別,所以可以互換使用。

還可以引用頂層函式(不是類的成員):

fun salute() = println("Salute!")
>>> run(::salute)
Salute!
複製程式碼

這種情況下,你省略了類名稱,直接以:: 開頭。成員引用::salute 被當做實參傳遞庫函式run,它會呼叫相應的函式。
如果lambda要委託給一個接收多個引數的函式,提供成員引用代替它將會非常方便:

val action = { person: Person, message: String ->
    sendEmail(person, message)  //這個lambda委託給sendEmail函式
}
val nextAction = ::sendEmail  //可以用成員引用代替
複製程式碼

可以用構造方法引用儲存或者延期執行建立類例項的作用。構造方法引用的形式是在雙冒號後指定類名稱:

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

>>> val createPerson = ::Person
>>> val p = createPerson("Hubert", 26)  //建立Person例項的動作被儲存成了值
>>> println(p)
Person(name=Hubert, age=26)
複製程式碼

還可以用同樣的方式引用擴充套件函式:

fun Person.isAdult() = age >= 18
val predicate = Person::isAdult
複製程式碼

儘管isAdult不是Person類的成員,還是可以通過引用訪問它,這和訪問例項的成員沒什麼兩樣:person.isAdult()

繫結引用

在Kotlin 1.0 中,當接受一個類的方法或屬性引用時,你始終需要提供一個該類的例項來呼叫這個引用。Kotlin 1.1 計劃支援繫結成員引用,它允許你使用成員引用語法捕獲特定例項物件上的方法引用。

>>> val p = Person("Hubert", 26)
>>> val personsAgeFunction = Person::age
>>> println(personsAgeFunction(p))
26
>>> val hubertsAgeFunction = p::age  //Kotlin 1.1 中可以使用繫結成員引用
>>> println(hubertsAgeFunction())
26
複製程式碼

注意personsAgeFunction是一個單引數函式(返回給定人的年齡),而hubertsAgeFunction是一個沒有引數的函式(返回p物件的年齡)。在Kotlin 1.1 之前,你需要顯式地寫出lambda{ p.age } ,而不是使用繫結成員引用p::age

集合的函式式API

函數語言程式設計風格在操作集合時提供了很多優勢。大多數任務都可以通過庫函式完成,來簡化你的程式碼。

filter 和 map

filter和map函式形成了集合操作的基礎,很多集合操作都是藉助它們來表達的。
每個函式我們都會給出兩個例子,一個使用數字,另一個使用熟悉的Person類:

data class Person(val name: String, val age: Int)
複製程式碼

filter函式遍歷集合並選出應用給定lambda後會返回true的那些元素:

>>> val list = listOf(1, 2, 3, 4)
>>> println(list.filter {  it % 2 == 0 })  //只留下偶數
[2, 4]
複製程式碼

上面的結果是一個新的集合,它只包含輸入集合中那些滿足判斷是的元素。
如果你想留下那些超過30歲的人,可以用filter:

>>> val people = listOf(Person("Hubert", 26), Person("Bob", 31))
>>> println(people.filter { it.age > 30 })
Person(name=Bob, age=31)
複製程式碼

filter函式可以從集合中移除你不想要的元素,但是它並不會改變這些元素。元素的變換是map的用武之地。
map函式對集合中的每一個元素應用給定的函式並把結果收集到一個新集合。可以把數字列表變換成它們平方的列表,比如:

>>> val list = listOf(1, 2, 3, 4)
>>> println(list.map { it * it })
[1, 4, 9, 16]
複製程式碼

結果是一個新集合,包含的元素個數不變,但是每個元素根據給定的判斷式做了變換。
如果你想列印的只是一個姓名的列表,而不是人的完整資訊列表,可以用map來變換列表:

>>> val people = listOf(Person("Hubert", 26), Person("Bob", 31))
>>> println(people.map { it.name })
[Hubert, Bob]
複製程式碼

這個例子也可以同成員引用漂亮地重寫:

people.map(Person::name)
複製程式碼

可以輕鬆地把多次這樣的呼叫連結起來。例如,列印出年齡超過30歲的人的名字:

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

現在,如果說需要這個分組中所有年齡最大的人的名字,可以先找到分組中人的最大年齡,然後返回所有這個年齡的人,很容易就用lambda寫出如下程式碼:

people.filter { it.age == people.maxBy(Person::age).age }
複製程式碼

但是注意,這段程式碼對每個人都會重複尋找最大年齡的過程,假設集合中有100個人,尋找最大年齡的過程就會執行100遍!下面的解決方法做出了改進,只計算了一次最大年齡:

val maxAge = people.maxBy(Person::age).age
people.filter { it.age == maxAge }
複製程式碼

如果沒有必要就不要重複計算!使用lambda表示式的程式碼看起來簡單,有時候卻掩蓋底層操作的複雜性。始終牢記你寫的程式碼在幹什麼。
還可以對map集合應用過濾和變換函式:

>>> val numbers = mapOf(0 to "zero", 1 to "one")
>>> println(numbers.mapValues { it.value.toUpperCase() })
{0=ZERO, 1=ONE}
複製程式碼

鍵和值分別由各自的函式來處理。filterKeys和mapKeys過濾和變換map集合的鍵,而另外的filterValues和mapValues過濾和變換對應的值。

"all" "any" "count"和"find":對集合應用判斷

另一種常見的任務是檢查集合中所有元素是否都符合某個條件(或者它的變種,是否存在符合的元素)。Kotlin中,它們是通過all和any函式表達的。count函式檢查有多少元素滿足判斷式,而find函式返回第一個符合條件的元素。
為了演示這些函式,我們先來定義一個判斷式,來檢查一個人是否還沒有到28歲:

val canBeInClub27 = { p:Person -> p.age <= 27 }
複製程式碼

如果你對是否所有元素都滿足判斷式感興趣,應該使用all函式:

>>> val people = listOf(Person("Hubert", 26), Person("Bob", 31))
>>> println(people.all(canBeInClub27))
false
複製程式碼

如果你需要檢查集合中是否至少存在一個匹配的元素,那就用any:

>>> val people = listOf(Person("Hubert", 26), Person("Bob", 31))
>>> println(people.any(canBeInClub27))
true
複製程式碼

注意,!all(不是所有)加上某個條件,可以用any加上這個條件的取反來替換,反之亦然。為了讓你的程式碼更容易理解,應該選擇前面不需要否定符號的函式:

>>> val list = listOf(1, 2, 3)
>>> println(!list.all { it == 3 }) //!否定不明顯,這種情況最好使用any
true
>>> println(list.any { it != 3 })  //lambda引數中的條件要取反
true
複製程式碼

第一行檢查是保證不是所有元素都等於3.這和至少有一個元素不是3是一個意思,這正式你在第二行用any做的檢查。
如果你想知道有多少個元素滿足了判斷式,使用count:

>>> val people = listOf(Person("Hubert", 26), Person("Bob", 31))
>>> println(people.count(canBeInClub27))
1
複製程式碼

使用正確的函式完成工作:count VS size

count方法容易被遺忘,然後通過過濾集合之後再取大小來實現它:

>>> println(people.filter(canBeInClub27).size)
1
複製程式碼

在這種情況下,一箇中間集合會被建立並用來儲存所有滿足判斷式的元素。而另一方面,count方法只是跟蹤匹配元素的數量,不關心元素本身,所以更高效。

要找到一個滿足判斷式的元素,使用find函式:

>>> val people = listOf(Person("Hubert", 26), Person("Bob", 31))
>>> println(people.find(canBeInClub27))
Person(name=Hubert, age=26)
複製程式碼

如果有多個匹配的元素就返回其中第一個元素;或者返回null,如果沒有一個元素能滿足判斷式。find還有一個同義方法firstOrNull,可以使用這個方法更清楚地表達你的意圖。

groupBy:把列表轉換成分組的map

假設你需要把所有元素按照不同的特徵劃分成不同的分組。例如,你想把人按年齡分組,相同的年齡的人在一組。把這個特徵直接當做引數傳遞十分方便。groupBy函式可以幫你做到這一點:

>>> val people = listOf(Person("Hubert", 26), Person("Bob", 31), Person("Carol", 31))
>>> println(people.groupBy { it.age })
複製程式碼

這次操作的結果是一個map,是元素分組依據的鍵(這個例子中是age)和元素分組(persons)之間的對映:

{26=[Person(name=Hubert, age=26)], 31=[Person(name=Bob, age=31), Person(name=Carol, age=31)]}
複製程式碼

每一個分組都是存在一個列表中,結果的型別就是Map<Int, List<Person>> 。可以使用像mapKeys和mapValues這樣的函式對這個map做進一步修改。
我們再來看另外一個例子,如何使用成員引用把字串按照首字母分組:

>>> val list = listOf("a", "ab", "b")
>>> println(list.groupBy(String::first))
{a=[a, ab], b=[b]}
複製程式碼

這裡的first並不是String類的成員,而是一個擴充套件,也可以把它當做成員引用訪問。

flatmap 和 flatten :處理巢狀集合中的元素

假設你有一堆書,使用Book類表示:

data class Book(val title: String, val authors: List<String>)
複製程式碼

每本書都可能有一個或者多個作者,可以統計出圖書館中的所有作者的set:

books,flatMap { it.authors }.toSet()
複製程式碼

flatMap函式做了兩件事:首先根據作為實參給定的函式對集合中每個元素做變換(或者說對映),然後把多個列表合併(或者說平鋪)成一個列表。下面這個字串的例子很好地闡明瞭這個概念:

>>> val strings = listOf("abc", "def")
>>> println(strings.flatMap { it.toList() })
[a, b, c, d, e, f]
複製程式碼

字串上的toList函式把它轉換成字串列表。如果和toList一起使用的是map函式,你會得到一個字元列表的列表,就如同下圖的第二行。flapMap函式還會執行後面的步驟,並返回一個包含所有元素的列表。

應用flatMap函式之後的結果

讓我們回到書籍作者的例子,每一本數都可能有多位作者,屬性book.authors儲存了每本書籍的作者集合,flatMap函式把所有書籍作者合併成了一個扁平的列表。toSet呼叫移除了結果集合中的所有重複元素。
當你卡殼在元素集合不得不合並一個的時候,你可能會想起flapMap來。如果你不需要做任何變換,只是需要平鋪一個集合,可以使用flatten函式:listOfLists.flatten()

惰性集合操作:序列

在上一節,你看到了許多鏈式結合函式呼叫的例子,比如map和filter。這些函式會及早地建立中間集合,也就是說每一步的中間結果都被儲存在一個臨時列表。序列給了你執行這些操作的另一種懸著,可以避免建立這些臨時中間物件。
先來看個例子:

people.map(Person::name).filter { it.startsWith("A") }
複製程式碼

Kotlin標準庫參考文件有說明,filter和map都會返回一個列表。這意味著上面例子中的鏈式呼叫會建立兩個列表:一個儲存filter函式的結果,另一個儲存map函式的結果。如果原列表只有兩個元素,這不是什麼問題,但是如果有一百萬個元素,鏈式呼叫就會變得十分低效。
為了提高效率,可以把操作變成使用序列,而不是直接使用集合:

people.asSequence()     //把初始集合轉換成序列
            .map(Person::name)
            .filter { it.startsWith("A") }
            .toList()   //把結果序列轉換回列表
複製程式碼

應用這次操作後的結果和前面的例子一模一樣:一個以字母A開頭的人名列表。但是第二個例子沒有建立任何用於儲存元素的中間集合,所以元素數量巨大的情況下效能將顯著提升。
Kotlin的惰性集合操作的入口就是Sequence介面。這個介面表示的就是一個可以逐個列舉元素的元素序列。Sequence只提供了一個方法:iterator,用來從序列中獲取值。
Sequence介面的強大之處在於其操作的實現方式。序列中的元素求值是惰性的。因此,可以使用序列更高效地對集合元素執行鏈式操作,而不需要穿件額外的集合來儲存過程中產生的中間結果。
可以呼叫擴充套件函式asSequence把任意集合轉換成序列,呼叫toList來做反向的轉換。

執行序列操作:中間和末端操作

序列操作分為兩類:中間的和末端的。一次中間操作返回的時另一個序列,這個新序列知道如何變換原始序列中的元素。而一次末端操作返回的是一個結果,這個結果可能是集合、元素、數字,或者其他從初始集合的變換序列中獲取的任意物件。

                    //中間操作         //末端操作
people.asSequence().map{..}.filter {..}.toList() 
複製程式碼

中間操作始終都是惰性的。先看看下面這個缺少了末端操作的例子:

>>> listOf(1, 2, 3, 4).asSequence()
            .map { print("map($it)"); it *it }
            .filter { print("filter($it)");it % 2 == 0 }
複製程式碼

執行這段程式碼並不會再控制檯上輸出任何內容。這意味著map和filter變換被延期了,它們只有在獲取結果的時候才會被應用(即末端操作呼叫的時候):

>>> listOf(1, 2, 3, 4).asSequence()
            .map { print("map($it)"); it *it }
            .filter { print("filter($it)");it % 2 == 0 }
            .toList()
map(1) filter(1) map(2) filter(4) map(3) filter(9) map(4) filter(16)
複製程式碼

末端操作觸發執行了所有的延期計算。
這個例子中另外一件值得注意的事情是計算執行的順序。一個笨辦法是現在每個元素上呼叫map函式,然後在結果序列的每個元素上在呼叫filter函式。map和filter對集合就是這樣做的,而序列不一樣。對序列來說,所有操作是按順序應用在每一個元素上的,處理完第一個元素(先對映在過濾),然後完成第二個元素的處理,以此類推。
這種方法意味著部分元素根本不會發生任何變換,如果在輪到它們之前就已經取得了結果。我們來看一個map和find的例子。首先把一個數字對映成它的平方,然後找到第一個比數字3大的條目:

>>> println(listOf(1, 2, 3, 4)
            .map { print("map($it); "); it * it }
            .find { print("$it > 3 ?; "); it > 3 })
>>> println("----------------")
>>> println(listOf(1, 2, 3, 4).asSequence()
        .map { print("map($it); "); it * it }
        .find { print("$it > 3 ?; "); it > 3 })
        
map(1); map(2); map(3); map(4); 1 > 3 ?; 4 > 3 ?; 4
----------------
map(1); 1 > 3 ?; map(2); 4 > 3 ?; 4
複製程式碼

第一種情況,當你使用集合的時候,列表被變換成了另一個lieb,所以map變換應用戴每一個元素上,包括了數字3和4.然後第一個滿足判斷式的元素被找到了:數字2的平方。
第二種情況,find呼叫一開始就逐個地處理元素。從原始序列中取一個數字,用map變換它,然後在檢查它是滿足傳給find的判斷式。當進行到數字2時,返現它的平方已經比3大,就把它作為find操作結果返回了。不再需要繼續檢查數字3和4,因為這之前你已經找到結果。
在集合上執行操作的順序也會影響效能。假設你有一個集合,想要列印集合中哪些長度小於某個限制的人名。你需要做兩件事:把每個人對映成他們的名字,然後過濾掉其中哪些不夠短的名字。這種情況可以用任何順序應用map和filter操作。兩種順序得到的結果一樣,但他們應該執行的變化總次數不一樣的:

>>> val people = listOf(Person("Hubert", 26), Person("Alice", 29),
            Person("Bob", 31), Person("Dan", 21))
>>> println(people.asSequence()
       .map { print("map(${it.name}); "); it.name }
        .filter { print("filter($it); ");it.length < 4 }
        .toList())
>>> println("----------------")
>>> println(people.asSequence()
        .filter { print("filter(${it.name}); ");it.name.length < 4 }
        .map { print("map($it); "); it.name }
        .toList())
        

map(Hubert); filter(Hubert); map(Alice); filter(Alice); map(Bob); filter(Bob); map(Dan); filter(Dan); [Bob, Dan]
----------------
filter(Hubert); filter(Alice); filter(Bob); map(Bob); filter(Dan); map(Dan); [Bob, Dan]
複製程式碼

可以看到,如果map在前,每個元素都被變換。而如果filter在前,不合適的元素會被儘早地過濾掉且不會發生變換。

流 VS 序列 如果你很熟悉Java 8 中的流這個概念,你會發現序列就是它的翻版。Kotlin提供了這個概念自己的版本,原因是Java 8的流並不支援哪些基於Java老版本的平臺,例如Android。如果你的目標版本是Java 8,流提供了一個Kotlin集合和序列目前還沒有實現的重要特性:在多個CPU上並行執行流操作(比如map和filter)的能力。可以根據Java的目標版本和你的特殊要求在流和序列之間做出選擇。

建立序列

前面的列表都是使用同一個方法建立序列:在集合上呼叫asSquence()。另一個可能性是使用generateSequence函式。給定序列中的前一個元素,這個函式會計算出下一個元素。下面這個例子就是如何使用generateSequence計算100以內所有自然數之和。

>>> val naturalNumbers = generateSequence(0) { it + 1 }
>>> val numbersTo100 = naturalNumbers.takeWhile { it <= 100 }
>>> println(numbersTo100.sum())
5050
複製程式碼

這個例子中的naturalNumbers和numbersTo100都是有延期操作的序列。這些序列中的實際數字直到你呼叫末端操作(這裡是sum)的時候才會求值。
另一種常見的用例是父序列。如果元素的父元素和它的型別相同(比如人類或者Java檔案),你可能會對它所有祖先組成的序列的特質感興趣。下面這個例子可以查詢檔案是否放在隱藏目錄中,通過建立一個其父類目錄的序列並檢查每個目錄的屬性來實現。

fun File.isInsideHiddenDirectory() = 
        generateSequence(this) { it.parentFile }.any{ it.isHidden}
>>> val file = File("/Users/svtk/.HiddenDir/a.txt")
true
複製程式碼

你生成一個序列,通過提供第一個元素和獲取每個後續元素的方式來實現。如果把any換成find,你還可以得到想要的那個目錄(物件)。注意,使用序列允許你找到需要的目錄之後立即停止遍歷目錄。

使用Java函式式介面

Kotlin的lambda也可以無縫地和Java API互操作。在文章開頭,我們就把lambda傳給Java方法的例子:

/* Kotlin */
button.setOnClickListener{ /* do someting */ }
複製程式碼

Button類通過接收型別為OnClickListner的實參的setOnClickListener方法給按鈕設定一個新的監聽器,在Java(8之前)中我們不得不建立一個匿名類來作為實參傳遞:

/* Java */
button.setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            //do something
        }
});
複製程式碼

在Kotlin中,可以傳遞一個lambda,代替這個例子:

/* Kotlin */
button.setOnClickListener{ view -> ... }
複製程式碼

這個lambda用來實現OnClickListener,它有一個型別為View的引數,和onclick方法一樣。
這種方法可以工作的原因是OnClickListener介面只有一個抽象方法。這種介面被稱為函式式介面,或者SAM介面,SAM代表單抽象方法。Java API 中隨處可見像Runnable和Callable這樣的函式式介面,以及支援它們的方法。Kotlin允許你在呼叫接收函式式介面作為引數的方法時使用lambda,來保證你的Kotlin程式碼即整潔又符合習慣。

和Java不同,Kotlin擁有完全的函式型別。正因為這樣,需要接收lambda作為引數的Kotlin函式應該使用函式型別而不是函式式介面型別,作為這些引數的型別。Kotlin不支援把lambda自動轉換成Kotlin介面物件。我們會在之後的章節中討論宣告函式型別的用法。

是不是非常好奇把lambda傳給Java時到底發生了什麼,是如何銜接的?

把lambda當做引數傳遞給Java方法

可以把lambda傳給任何期望函式式介面的方法。例如下面這個方法,它有一個Runnable型別的引數:

/* Java */
void postponeComputation(int delay, Runnable computation);
複製程式碼

在Kotlin中,可以呼叫它並把一個lambda作為實參傳給它。編譯器會自動把它轉換成一個Runnable的例項。

postponeComputation(1000) { println(42) }
複製程式碼

當我們說一個Runnable的例項時,指的是一個實現了Runnable介面的匿名內部類的例項。編譯器會幫你建立它,並使用lambda作為單抽象方法的方法體。
通過顯示的建立一個實現了Runnable的匿名物件也能達到同樣的效果:

postponeComputation(1000, object: Runnable {
    override fun run() {
        println(42)
    }
})
複製程式碼

但是這裡有一點不一樣。當你顯式地宣告物件時,每次呼叫都會建立一個新的例項。使用lambda的情況不同:如果lambda沒有訪問任何來自定義它的函式的變數,相應的匿名類例項可以在多次呼叫之間重用。
因此完全等價的實現應該是下面這段程式碼中顯示object宣告,它把Runnable例項儲存在一個變數中,並且每次呼叫的時候都使用這個變數:

val runnable = Runnable { println(42) }
fun handleComputation() {
    postponeComputation(1000, runnable)
}
複製程式碼

如果lambda從包圍它的作用域中捕獲了變數,每次呼叫就不再可能重用一同一個例項了。這種情況下,每次呼叫時編譯器都要建立一個新物件,其中儲存著被捕獲的變數的值。

fun handleComputation(id: String) { //lambda會捕獲id這個變數
    postponeComputation(1000) { println(id) } //每次都建立一個Runnable新例項
}
複製程式碼

Lambda的實現細節

自Kotlin 1.0起,每個lambda表示式都會被編譯成一個匿名類,除非它是一個內聯lambda。後續版本計劃支援生成Java 8位元組碼。一旦實現,編譯器就可以避免為每一個lambda表示式都生成一個獨立的.class檔案。如果lambda捕獲了變數,每個被捕獲的變數會在匿名類中有對應的欄位,而且每次呼叫都會建立一個這個匿名 類的新例項。否則,一個單例就會被建立。類的名稱由lambda宣告所在的函式名稱加上字尾衍生出來:上面一個例子就是HandleComputation$1。如果你反編譯之前lambda表示式的程式碼,就會看到:

class HandleComputation$1(val id: String) : Runnable {
    override fun run() {
        println(42)
    }
}
fun handleComputation(id: String) {
    postponeComputation(100, HandleComputation$1(id))
}
複製程式碼

如你所見,編譯器給每個被捕捉的變數生成了一個欄位和一個構造方法引數。

SAM構造方法:顯式地把lambda轉換成函式式介面

SAM構造方法是編譯器生成的函式,讓你執行從lambda到函式式介面例項的顯式轉換。可以在編譯器不會自動應用轉換的上下文中使用它。例如,如果有一個方法返回的時一個函式式介面的例項,不能直接返回一個lambda,要用SAM構造方法把它包起來:

fun createAllDoneRunnable(): Runnable {
    return Runnable { println("All Done!") }
}
>>> createAllDoneRunnable().run()
All Done!
複製程式碼

SAM構造方法的名稱和底層函式式介面的名稱一樣。SAM構造方法只接收一個引數——一個被用作函式式介面單抽象方法體的lambda,並返回實現了這個介面的類的一個例項。
除了返回值外,SAM構造方法還可以用在需要把從lambda省城的函式式介面例項儲存在一個變數中的情況。假設你要在多個按鈕上重用同一個監聽器,就像下面的程式碼一樣:

val listener = OnClickListener { view ->
        val text = when(view.id) {
            R.id.button1 -> "First Button"
            R.id.button2 -> "Second Button"
            else -> "Unknown Button"
        }
        toast(text)
}
button1.setOnClickListener(listener)
button2.setOnClickListener(listener)
複製程式碼

listener會檢查哪個按鈕是點選事件源並作出相應的行為。可以使用實現了OnClickListener的物件宣告來定義監聽器,但是SAM構造方法給你更簡潔的選擇。

Lambda和新增/移除監聽器

注意lambda內部沒有匿名物件那樣的this:沒有辦法引用到lambda轉換成的匿名類例項,從編譯器的角度來看,lambda是一個程式碼塊,不是一個物件,而且也不能把它當成物件引用。Lambda中的this指向的是包圍它的類。
如果你的事件監聽器在處理事件時還需要取消它自己,不能使用lambda這樣做。這種情況使用實現了介面的匿名物件,在匿名物件內,this關鍵字指向該物件例項,可以把它傳給移除監聽器的API。

帶接收者的lambda:with 與 apply

with 函式

很多語法都有這樣的語句,可以用它對同一個物件執行多次操作,而不需要反覆把物件的名稱寫出來。Kotlin也不例外,但它提供的是一個叫with的庫函式,而不是某種特殊的語言結構。
要理解這種用法,我們先看看下面這個例子,稍後你會用with來重構它:

fun alphabet(): String {
    val result = StringBuilder()
    for (letter in 'A'..'Z') {
        result.append(letter)
    }
    result.append("\nNow I know the alphabet!")
    return result.toString()
}

>>> println(alphabet)
ABCDEFGHIJKLMNOPQRSTUVWXYZ
Now I know the alphabet!
複製程式碼

上面這個例子中,你呼叫了result例項上好幾個不同的方法,而且每次呼叫都要重複result這個名稱。這裡情況還不算太糟,但是如果你用到的表示式更長或者重複得更多,該怎麼辦?
我們來看看使用with函式重寫這段程式碼:

fun alphabet(): String {
    val stringBuilder = StringBuilder()
    return with(stringBuilder) { //指定接收者的值,你會呼叫它的方法
        for (letter in 'A'..'Z') {
            this.append(letter)  //通過顯式地this來呼叫接收者值得方法
        } 
        append("\nNow I know the alphabet!") //this可以省略
        this.toString() //從lambda返回
    }
}
複製程式碼

with結構看起來像是一個特殊的語法結構,但它實際上是一個接收兩個引數的函式:這個例子中兩個引數分別是stringBuilder和一個lambda。這裡利用了把lambda放在括號外的約定,這樣整個呼叫看起來就像是內建的語言功能。當然你也可以選擇把它寫成with(stringBuilder, {...})
with函式把它的第一個引數轉換成第二個引數傳給他的lambda的接收者。可以顯式地通過this引用來訪問這個接收者。或者可以省略this引用,不用任何限定符直接訪問這個值得方法和屬性。這個例子中this指向了stringBuilder,這是傳給with的第一個引數。

帶接收者的lambda和擴充套件函式

你可能回憶起曾經見過相似的概念,this指向的時函式接收者。在擴充套件函式體內部,this指向了這個函式的那個型別的例項,而且也可以被省略掉,讓你直接訪問接收者的成員。一個擴充套件函式某種意義上來說就是帶接收者的函式。

讓我們進一步重構初始的alphabet函式,去掉額外的stringBuilder變數:

fun alphabet(): String = with(StringBuilder()) {
    for (letter in 'A'..'Z') {
        append(letter)
    }
    append("\nNow I know the alphabet!")
    toString()
}
複製程式碼

現在這個函式只返回一個表示式,所以使用表示式函式體語法重寫了它。可以建立一個新的StringBuilder例項直接當做實參傳給這個函式,然後在lambda中不需要顯示地this就可以引用這個例項。

方法名衝突

如果你當做引數傳給with的物件已經有這樣的方法,該方法的名稱和你正在使用with的類中的方法一樣,怎麼辦?這種情況下,可以給this引用加上顯式地標籤來表明你要呼叫的時哪個方法。假設函式的alphabet是類OuterClass的一個方法。如果你想引用的是定義在外部類的toString方法而不是StringBuilder,可以用下面這種語句:

this@OuterClass.toString()
複製程式碼

with返回的值是執行lambda程式碼的結果,該結果就是lambda中的最後一個表示式(的值)。但有時候你想返回的是接收者物件,而不是執行lambda的結果。這時apply庫函式就派上用場了。

apply 函式

apply函式幾乎和with函式一模一樣,唯一的區別是apply始終會返回作為實參傳遞給它的物件(接收者物件)。讓我們再一次重構alphabet函式,這一次用的是apply:

fun alphabet(): String = StringBuilder().apply {
    for (letter in 'A'..'Z') {
        append(letter)
    }
    append("\nNow I know the alphabet!")
}.toString()
複製程式碼

apply被宣告成一個擴充套件函式,他的接收者程式設計了作為實參的lambda的接收者。執行apply的結果是StringBuilder,所以接下來你可以呼叫toString把它轉換成String。
許多情況下apply都很有效,其中一種是在建立一個物件例項需要用正確的方式初始化它的一些屬性的時候。在Java中,這通常是通過另外一個單獨的Builder物件來完成的,而在Kotlin中,可以在任意物件上使用apply,完全不需要任何任何來自定義該物件的庫的特別支援。
我們來用apply演示一個Android中建立TextView例項的例子:

fun createViewWithCustomAttr(context: Context) = 
    TextView(context).apply {
        text = "Sample Text"
        textSize = 20.0f
        setPadding(10, 0, 0, 0)
    }
複製程式碼

apply函式允許你使用緊湊的表示式函式體風格。新的TextView例項建立之後立即被傳給了apply。在傳給apply的lambda中,TextView例項變成了接收者,你就可以呼叫它的方法並設定它的屬性。Lambda執行之後,apply返回已經初始化過的接收者例項,它變成了createViewWithCustomAttr函式的結果。
with函式和apply函式是最基本和最通用的使用帶接收者的lambda的例子。更多具體的函式函式也可以使用這種模式。例如,你可以使用標準庫函式buildString進一步簡化alphabet函式,它會負責建立StringBuilder並呼叫toString:

fun alphabet(): String = buildString {
    for (letter in 'A'..'Z') {
        append(letter)
    }
    append("\nNow I know the alphabet!")
}
複製程式碼

相關文章