【碼上開學】Kotlin 的高階函數、匿名函數和 Lambda 表達式

聽說……Kotlin 可以用 Lambda?

【碼上開學】Kotlin 的高階函數、匿名函數和 Lambda 表達式

不錯不錯,Java 8 也有 Lambda,挺好用的。

聽說……Kotlin 的 Lambda 還能當函數參數?

【碼上開學】Kotlin 的高階函數、匿名函數和 Lambda 表達式

啊挺好挺好,我也來寫一個!

【碼上開學】Kotlin 的高階函數、匿名函數和 Lambda 表達式

哎,報錯了?我改!

【碼上開學】Kotlin 的高階函數、匿名函數和 Lambda 表達式

哎?

我……再改?

【碼上開學】Kotlin 的高階函數、匿名函數和 Lambda 表達式

我……再……改?

【碼上開學】Kotlin 的高階函數、匿名函數和 Lambda 表達式

啊!!!!!!!!!!!!

視頻先行

這是視頻版本:

Kotlin 的高階函數

大家好,我是扔物線朱凱。Kotlin 很方便,但有時候也讓人頭疼,而且越方便的地方越讓人頭疼,比如 Lambda 表達式。很多人因為 Lambda 而被 Kotlin 吸引,但很多人也因為 Lambda 而被 Kotlin 嚇跑。其實大多數已經用了很久 Kotlin 的人,對 Lambda 也只會簡單使用而已,甚至相當一部分人不靠開發工具的自動補全功能,根本就完全不會寫 Lambda。今天我就來跟大家嘮一嘮 Lambda。不過,要講 Lambda,我們得先從 Kotlin 的高階函數——Higher-Order Function 說起。

在 Java 裡,如果你有一個 a 方法需要調用另一個 b 方法,你在裡面調用就可以;

<code>int a() {
return b(1);
}
a();/<code>

而如果你想在 a 調用時動態設置 b 方法的參數,你就得把參數傳給 a,再從 a 的內部把參數傳給 b:

<code>int a(int param) {
return b(param);
}
a(1); // 內部調用 b(1)

a(2); // 內部調用 b(2)/<code>

這都可以做到,不過……如果我想動態設置的不是方法參數,而是方法本身呢?比如我在 a 的內部有一處對別的方法的調用,這個方法可能是 b,可能是 c,不一定是誰,我只知道,我在這裡有一個調用,它的參數類型是 int ,返回值類型也是 int ,而具體在 a 執行的時候內部調用哪個方法,我希望可以動態設置:

<code>int a(??? method) {
return method(1);
}
a(method1);
a(method2);/<code>

或者說,我想把方法作為參數傳到另一個方法裡,這個……可以做到嗎?

不行,也行。在 Java 裡是不允許把方法作為參數傳遞的,但是我們有一個歷史悠久的變通方案:接口。我們可以通過接口的方式來把方法包裝起來:

<code>publicinterface Wrapper {
int method(int param);
}/<code>

然後把這個接口的類型作為外部方法的參數類型:

<code>int a(Wrapper wrapper) {
return wrapper.method(1);
}/<code>

在調用外部方法時,傳遞接口的對象來作為參數:

<code>a(wrapper1);
a(wrapper2);/<code>

如果到這裡你覺得聽暈了,我換個寫法你再感受一下:

我們在用戶發生點擊行為的時候會觸發點擊事件:

<code>// 注:這是簡化後的代碼,不是 View.java 類的源碼
publicclass View {
OnClickListener mOnClickListener;
...
public void onTouchEvent(MotionEvent e) {
...
mOnClickListener.onClick(this);
...
}
}/<code>

所謂的點擊事件,最核心的內容就是調用內部的一個 OnClickListener 的 onClick() 方法:

<code>publicinterface OnClickListener {
void onClick(View v);
}/<code>

而所謂的這個 OnClickListener 其實只是一個殼,它的核心全在內部那個 onClick() 方法。換句話說,我們傳過來一個 OnClickListener:

<code>OnClickListener listener1 = new OnClickListener() {
@Override
void onClick(View v) {
doSomething();
}
};
view.setOnClickListener(listener1);/<code>

本質上其實是傳過來一個可以在稍後被調用的方法(onClick())。只不過因為 Java 不允許傳遞方法,所以我們才把它包進了一個對象裡來進行傳遞。

而在 Kotlin 裡面,函數的參數也可以是函數類型的:

<code>fun a(funParam: Fun): String {
return funParam(1);
}/<code>

當一個函數含有函數類型的參數的時候——這句話有點繞啊——如果你調用它,你就可以——當然你也必須——傳入一個函數類型的對象給它;

<code>fun b(param: Int): String {
return param.toString()
}
a(b)/<code>

不過在具體的寫法上沒有我的示例這麼粗暴。

首先我寫的這個 Fun 作為函數類型其實是錯的,Kotlin 裡並沒有這麼一種類型來標記這個變量是個「函數類型」。因為函數類型不是一「個」類型,而是一「類」類型,因為函數類型可以有各種各樣不同的參數和返回值的類型的搭配,這些搭配屬於不同的函數類型。例如,無參數無返回值(() -> Unit)和單 Int 型參數返回 String (Int -> String)是兩種不同的類型,這個很好理解,就好像 Int 和 String 是兩個不同的類型。所以不能只用 Fun 這個詞來表示「這個參數是個函數類型」,就好像不能用 Class 這個詞來表示「這個參數是某個類」,因為你需要指定,具體是哪種函數類型,或者說這個函數類型的參數,它的參數類型是什麼、返回值類型是什麼,而不能籠統地一句說「它是函數類型」就完了。

所以對於函數類型的參數,你要指明它有幾個參數、參數的類型是什麼以及返回值類型是什麼,那麼寫下來就大概是這個樣子:

<code>fun a(funParam: (Int) -> String): String {
return funParam(1)
}/<code>

看著有點可怕。但是隻有這樣寫,調用的人才知道應該傳一個怎樣的函數類型的參數給你。

同樣的,函數類型不只可以作為函數的參數類型,還可以作為函數的返回值類型:

<code>fun c(param: Int): (Int) -> Unit {
...
}/<code>

這種「參數或者返回值為函數類型的函數」,在 Kotlin 中就被稱為「高階函數」——Higher-Order Functions。

這個所謂的「高階」,總給人一種神秘感:階是什麼?哪裡高了?其實沒有那麼複雜,高階函數這個概念源自數學中的高階函數。在數學裡,如果一個函數使用函數作為它的參數或者結果,它就被稱作是一個「高階函數」。比如求導就是一個典型的例子:你對 f(x) = x 這個函數求導,結果是 1;對 f(x) = x² 這個函數求導,結果是 2x。很明顯,求導函數的參數和結果都是函數,其中 f(x) 的導數是 1 這其實也是一個函數,只不過是一個結果恆為 1 的函數,所以——啊講岔了,總之, Kotlin 裡,這種參數有函數類型或者返回值是函數類型的函數,都叫做高階函數,這只是個對這一類函數的稱呼,沒有任何特殊性,Kotlin 的高階函數沒有任何特殊功能,這是我想說的。

另外,除了作為函數的參數和返回值類型,你把它賦值給一個變量也是可以的。

不過對於一個聲明好的函數,不管是你要把它作為參數傳遞給函數,還是要把它賦值給變量,都得在函數名的左邊加上雙冒號才行:

<code>a(::b)
val d = ::b/<code>

這……是為什麼呢?

雙冒號 ::method 到底是什麼?

如果你上網搜,你會看到這個雙冒號的寫法叫做函數引用 Function Reference,這是 Kotlin 官方的說法。但是這又表示什麼意思?表示它指向上面的函數?那既然都是一個東西,為什麼不直接寫函數名,而要加兩個冒號呢?

因為加了兩個冒號,這個函數才變成了一個對象。

什麼意思?

Kotlin 裡「函數可以作為參數」這件事的本質,是函數在 Kotlin 裡可以作為對象存在——因為只有對象才能被作為參數傳遞啊。賦值也是一樣道理,只有對象才能被賦值給變量啊。但 Kotlin 的函數本身的性質又決定了它沒辦法被當做一個對象。那怎麼辦呢?Kotlin 的選擇是,那就創建一個和函數具有相同功能的對象。怎麼創建?使用雙冒號。

在 Kotlin 裡,一個函數名的左邊加上雙冒號,它就不表示這個函數本身了,而表示一個對象,或者說一個指向對象的引用,但,這個對象可不是函數本身,而是一個和這個函數具有相同功能的對象。

怎麼個相同法呢?你可以怎麼用函數,就能怎麼用這個加了雙冒號的對象:

<code>b(1) // 調用函數
d(1) // 用對象 a 後面加上括號來實現 b() 的等價操作
(::b)(1) // 用對象 :b 後面加上括號來實現 b() 的等價操作/<code>

但我再說一遍,這個雙冒號的這個東西,它不是一個函數,而是一個對象,一個函數類型的對象。

對象是不能加個括號來調用的,對吧?但是函數類型的對象可以。為什麼?因為這其實是個假的調用,它是 Kotlin 的語法糖,實際上你對一個函數類型的對象加括號、加參數,它真正調用的是這個對象的 invoke() 函數:

<code>d(1) // 實際上會調用 d.invoke(1)
(::b)(1) // 實際上會調用 (::b).invoke(1)/<code>

所以你可以對一個函數類型的對象調用 invoke(),但不能對一個函數這麼做:

<code>b.invoke(1) // 報錯/<code>

為什麼?因為只有函數類型的對象有這個自帶的 invoke() 可以用,而函數,不是函數類型的對象。那它是什麼類型的?它什麼類型也不是。函數不是對象,它也沒有類型,函數就是函數,它和對象是兩個維度的東西。

包括雙冒號加上函數名的這個寫法,它是一個指向對象的引用,但並不是指向函數本身,而是指向一個我們在代碼裡看不見的對象。這個對象複製了原函數的功能,但它並不是原函數。

這個……是底層的邏輯,但我知道這個有什麼用呢?

這個知識能幫你解開 Kotlin 的高階函數以及接下來我馬上要講的匿名函數、Lambda 相關的大部分迷惑。

比如我在代碼裡有這麼幾行:

<code>fun b(param: Int): String {
return param.toString()
}
val d = ::b/<code>

那我如果想把 d 賦值給一個新的變量 e:

<code>val e = d/<code>

我等號右邊的 d,應該加雙冒號還是不加呢?

不用試,也不用搜,想一想:這是個賦值操作對吧?賦值操作的右邊是個對象對吧?d 是對象嗎?當然是了,b 不是對象是因為它來自函數名,但 d 已經是個對象了,所以直接寫就行了。

匿名函數

我們繼續講。

要傳一個函數類型的參數,或者把一個函數類型的對象賦值給變量,除了用雙冒號來拿現成的函數使用,你還可以直接把這個函數挪過來寫:

<code>a(fun b(param: Int): String {
return param.toString()
});
val d = fun b(param: Int): String {
return param.toString()
}/<code>

另外,這種寫法的話,函數的名字其實就沒用了,所以你可以把它省掉:

<code>a(fun(param: Int): String {
return param.toString()
});
val d = fun(param: Int): String {
return param.toString()
}/<code>

這種寫法叫做匿名函數。為什麼叫匿名函數?很簡單,因為它沒有名字唄,對吧。等號左邊的不是函數的名字啊,它是變量的名字。這個變量的類型是一種函數類型,具體到我們的示例代碼來說是一種只有一個參數、參數類型是 Int、並且返回值類型為 String 的函數類型。

另外呢,其實剛才那種左邊右邊都有名字的寫法,Kotlin 是不允許的。右邊的函數既然要名字也沒有用,Kotlin 乾脆就不許它有名字了。

所以,如果你在 Java 裡設計一個回調的時候是這麼設計的:

<code>publicinterface OnClickListener {
void onClick(View v);
}
public void setOnClickListener(OnClickListener listener) {
this.listener = listener;
}/<code>

使用的時候是這麼用的:

<code>view.setOnClickListener(new OnClickListener() {
@Override
void onClick(View v) {
switchToNextPage();
}
});/<code>

到了 Kotlin 裡就可以改成這麼寫了:

<code>fun setOnClickListener(onClick: (View) -> Unit) {
this.onClick = onClick
}
view.setOnClickListener(fun(v: View): Unit) {
switchToNextPage()
})/<code>

簡單一點哈?另外大多數(幾乎所有)情況下,匿名函數還能更簡化一點,寫成 Lambda 表達式的形式:

<code>view.setOnClickListener({ v: View ->
switchToNextPage()
})/<code>

Lambda 表達式

終於講到 Lambda 了。

如果 Lambda 是函數的最後一個參數,你可以把 Lambda 寫在括號的外面:

<code>view.setOnClickListener() { v: View ->
switchToNextPage()
}/<code>

而如果 Lambda 是函數唯一的參數,你還可以直接把括號去了:

<code>view.setOnClickListener { v: View ->
switchToNextPage()
}/<code>

另外,如果這個 Lambda 是單參數的,它的這個參數也省略掉不寫:

<code>view.setOnClickListener {
switchToNextPage()
}/<code>

哎,不錯,單參數的時候只要不用這個參數就可以直接不寫了。

其實就算用,也可以不寫,因為 Kotlin 的 Lambda 對於省略的唯一參數有默認的名字:it:

<code>view.setOnClickListener {
switchToNextPage()
it.setVisibility(GONE)
}/<code>

有點爽哈?不過我們先停下想一想:這個 Lambda 這也不寫那也不寫的……它不迷茫嗎?它是怎麼知道自己的參數類型和返回值類型的?

靠上下文的推斷。我調用的函數在聲明的地方有明確的參數信息吧?

<code>fun setOnClickListener(onClick: (View) -> Unit) {
this.onClick = onClick
}/<code>

這裡面把這個參數的參數類型和返回值寫得清清楚楚吧?所以 Lambda 才不用寫的。

所以,當你要把一個匿名函數賦值給變量而不是作為函數參數傳遞的時候:

<code>val b = fun(param: Int): String {
return param.toString()
}/<code>

如果也簡寫成 Lambda 的形式:

<code>val b = { param: Int ->
return param.toString()
}/<code>

就不能省略掉 Lambda 的參數類型了:

<code>val b = {
return it.toString() // it 報錯
}/<code>

為什麼?因為它無法從上下文中推斷出這個參數的類型啊!

如果你出於場景的需求或者個人偏好,就是想在這裡省掉參數類型,那你需要給左邊的變量指明類型:

<code>val b: (Int) -> String = {
return it.toString() // it 可以被推斷出是 Int 類型
}/<code>

另外 Lambda 的返回值不是用 return 來返回,而是直接取最後一行代碼的值:

<code>val b: (Int) -> String = {
it.toString() // it 可以被推斷出是 Int 類型
}/<code>

這個一定注意,Lambda 的返回值別寫 return,如果你寫了,它會把這個作為它外層的函數的返回值來直接結束外層函數。當然如果你就是想這麼做那沒問題啊,但如果你是隻是想返回 Lambda,這麼寫就出錯了。

另外因為 Lambda 是個代碼塊,它總能根據最後一行代碼來推斷出返回值類型,所以它的返回值類型確實可以不寫。實際上,Kotlin 的 Lambda 也是寫不了返回值類型的,語法上就不支持。

現在我再停一下,我們想想:匿名函數和 Lambda……它們到底是什麼?

Kotlin 裡匿名函數和 Lambda 表達式的本質

我們先看匿名函數。它可以作為參數傳遞,也可以賦值給變量,對吧?

但是我們剛才也說過了函數是不能作為參數傳遞,也不能賦值給變量的,對吧?

那為什麼匿名函數就這麼特殊呢?

因為 Kotlin 的匿名函數不——是——函——數。它是個對象。匿名函數雖然名字裡有「函數」兩個字,包括英文的原名也是 Anonymous Function,但它其實不是函數,而是一個對象,一個函數類型的對象。它和雙冒號加函數名是一類東西,和函數不是。

所以,你才可以直接把它當做函數的參數來傳遞以及賦值給變量:

<code>a(fun (param: Int): String {
return param.toString()
});
val a = fun (param: Int): String {
return param.toString()
}/<code>

同理,Lambda 其實也是一個函數類型的對象而已。你能怎麼使用雙冒號加函數名,就能怎麼使用匿名函數,以及怎麼使用 Lambda 表達式。

這,就是 Kotlin 的匿名函數和 Lambda 表達式的本質,它們都是函數類型的對象。Kotlin 的 Lambda 跟 Java 8 的 Lambda 是不一樣的,Java 8 的 Lambda 只是一種便捷寫法,本質上並沒有功能上的突破,而 Kotlin 的 Lambda 是實實在在的對象。

在你知道了在 Kotlin 裡「函數並不能傳遞,傳遞的是對象」和「匿名函數和 Lambda 表達式其實都是對象」這些本質之後,你以後去寫 Kotlin 的高階函數會非常輕鬆非常舒暢。

Kotlin 官方文檔裡對於雙冒號加函數名的寫法叫 Function Reference 函數引用,故意引導大家認為這個引用是指向原函數的,這是為了簡化事情的邏輯,讓大家更好上手 Kotlin;但這種邏輯是有毒的,一旦你信了它,你對於匿名函數和 Lambda 就怎麼也搞不清楚了。

對比 Java 的 Lambda

再說一下 Java 的 Lambda。對於 Kotlin 的 Lambda,有很多從 Java 過來的人表示「好用好用但不會寫」。這是一件很有意思的事情:你都不會寫,那你是怎麼會用的呢?Java 從 8 開始引入了對 Lambda 的支持,對於單抽象方法的接口——簡稱 SAM 接口,Single Abstract Method 接口——對於這類接口,Java 8 允許你用 Lambda 表達式來創建匿名類對象,但它本質上還是在創建一個匿名類對象,只是一種簡化寫法而已,所以 Java 的 Lambda 只靠代碼自動補全就基本上能寫了。而 Kotlin 裡的 Lambda 和 Java 本質上就是不同的,因為 Kotlin 的 Lambda 是實實在在的函數類型的對象,功能更強,寫法更多更靈活,所以很多人從 Java 過來就有點搞不明白了。

另外呢,Kotlin 是不支持使用 Lambda 的方式來簡寫匿名類對象的,因為我們有函數類型的參數嘛,所以這種單函數接口的寫法就直接沒必要了。那你還支持它幹嘛?

不過當和 Java 交互的時候,Kotlin 是支持這種用法的:當你的函數參數是 Java 的單抽象方法的接口的時候,你依然可以使用 Lambda 來寫參數。但這其實也不是 Kotlin

增加了功能,而是對於來自 Java 的單抽象方法的接口,Kotlin 會為它們額外創建一個把參數替換為函數類型的橋接方法,讓你可以間接地創建 Java 的匿名類對象。

這就是為什麼,你會發現當你在 Kotlin 裡調用 View.java 這個類的 setOnClickListener() 的時候,可以傳 Lambda 給它來創建 OnClickListener 對象,但你照著同樣的寫法寫一個 Kotlin 的接口,你卻不能傳 Lambda。因為 Kotlin 期望我們直接使用函數類型的參數,而不是用接口這種折中方案。


分享到:


相關文章: