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

扔物線發表於2020-04-07

聽說……Kotlin 可以用 Lambda?

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

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

聽說……Kotlin 的 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 方法,你在裡面呼叫就可以;

int a() {
  return b(1);
}
a();
複製程式碼

而如果你想在 a 呼叫時動態設定 b 方法的引數,你就得把引數傳給 a,再從 a 的內部把引數傳給 b:

int a(int param) {
  return b(param);
}
a(1); // 內部呼叫 b(1)
a(2); // 內部呼叫 b(2)
複製程式碼

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

int a(??? method) {
  return method(1);
}
a(method1);
a(method2);
複製程式碼

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

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

public interface Wrapper {
  int method(int param);
}
複製程式碼

然後把這個介面的型別作為外部方法的引數型別:

int a(Wrapper wrapper) {
  return wrapper.method(1);
}
複製程式碼

在呼叫外部方法時,傳遞介面的物件來作為引數:

a(wrapper1);
a(wrapper2);
複製程式碼

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

我們在使用者發生點選行為的時候會觸發點選事件:

// 注:這是簡化後的程式碼,不是 View.java 類的原始碼
public class View {
  OnClickListener mOnClickListener;
  ...
  public void onTouchEvent(MotionEvent e) {
    ...
    mOnClickListener.onClick(this);
    ...
  }
}
複製程式碼

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

public interface OnClickListener {
  void onClick(View v);
}
複製程式碼

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

OnClickListener listener1 = new OnClickListener() {
  @Override
  void onClick(View v) {
    doSomething();
  }
};
view.setOnClickListener(listener1);
複製程式碼

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

而在 Kotlin 裡面,函式的引數也可以是函式型別的:

fun a(funParam: Fun): String {
  return funParam(1);
}
複製程式碼

當一個函式含有函式型別的引數的時候——這句話有點繞啊——如果你呼叫它,你就可以——當然你也必須——傳入一個函式型別的物件給它;

fun b(param: Int): String {
  return param.toString()
}
a(b)
複製程式碼

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

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

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

fun a(funParam: (Int) -> String): String {
  return funParam(1)
}
複製程式碼

看著有點可怕。但是隻有這樣寫,呼叫的人才知道應該傳一個怎樣的函式型別的引數給你。

同樣的,函式型別不只可以作為函式的引數型別,還可以作為函式的返回值型別:

fun c(param: Int): (Int) -> Unit {
  ...
}
複製程式碼

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

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

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

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

a(::b)
val d = ::b
複製程式碼

這……是為什麼呢?

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

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

因為加了兩個冒號,這個函式才變成了一個物件。

什麼意思?

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

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

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

b(1) // 呼叫函式
d(1) // 用物件 a 後面加上括號來實現 b() 的等價操作
(::b)(1) // 用物件 :b 後面加上括號來實現 b() 的等價操作
複製程式碼

但我再說一遍,這個雙冒號的這個東西,它不是一個函式,而是一個物件,一個函式型別的物件。

物件是不能加個括號來呼叫的,對吧?但是函式型別的物件可以。為什麼?因為這其實是個假的呼叫,它是 Kotlin 的語法糖,實際上你對一個函式型別的物件加括號、加引數,它真正呼叫的是這個物件的 invoke() 函式:

d(1) // 實際上會呼叫 d.invoke(1)
(::b)(1) // 實際上會呼叫 (::b).invoke(1)
複製程式碼

所以你可以對一個函式型別的物件呼叫 invoke(),但不能對一個函式這麼做:

b.invoke(1) // 報錯
複製程式碼

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

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

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

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

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

fun b(param: Int): String {
  return param.toString()
}
val d = ::b
複製程式碼

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

val e = d
複製程式碼

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

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

匿名函式

我們繼續講。

要傳一個函式型別的引數,或者把一個函式型別的物件賦值給變數,除了用雙冒號來拿現成的函式使用,你還可以直接把這個函式挪過來寫:

a(fun b(param: Int): String {
  return param.toString()
});
val d = fun b(param: Int): String {
  return param.toString()
}
複製程式碼

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

a(fun(param: Int): String {
  return param.toString()
});
val d = fun(param: Int): String {
  return param.toString()
}
複製程式碼

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

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

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

public interface OnClickListener {
  void onClick(View v);
}
public void setOnClickListener(OnClickListener listener) {
  this.listener = listener;
}
複製程式碼

使用的時候是這麼用的:

view.setOnClickListener(new OnClickListener() {
  @Override
  void onClick(View v) {
    switchToNextPage();
  }
});
複製程式碼

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

fun setOnClickListener(onClick: (View) -> Unit) {
  this.onClick = onClick
}
view.setOnClickListener(fun(v: View): Unit) {
  switchToNextPage()
})
複製程式碼

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

view.setOnClickListener({ v: View ->
  switchToNextPage()
})
複製程式碼

Lambda 表示式

終於講到 Lambda 了。

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

view.setOnClickListener() { v: View ->
  switchToNextPage()
}
複製程式碼

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

view.setOnClickListener { v: View ->
  switchToNextPage()
}
複製程式碼

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

view.setOnClickListener {
  switchToNextPage()
}
複製程式碼

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

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

view.setOnClickListener {
  switchToNextPage()
  it.setVisibility(GONE)
}
複製程式碼

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

靠上下文的推斷。我呼叫的函式在宣告的地方有明確的引數資訊吧?

fun setOnClickListener(onClick: (View) -> Unit) {
  this.onClick = onClick
}
複製程式碼

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

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

val b = fun(param: Int): String {
  return param.toString()
}
複製程式碼

如果也簡寫成 Lambda 的形式:

val b = { param: Int ->
  return param.toString()
}
複製程式碼

就不能省略掉 Lambda 的引數型別了:

val b = {
  return it.toString() // it 報錯
}
複製程式碼

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

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

val b: (Int) -> String = {
  return it.toString() // it 可以被推斷出是 Int 型別
}
複製程式碼

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

val b: (Int) -> String = {
  it.toString() // it 可以被推斷出是 Int 型別
}
複製程式碼

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

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

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

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

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

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

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

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

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

a(fun (param: Int): String {
  return param.toString()
});
val a = fun (param: Int): String {
  return param.toString()
}
複製程式碼

同理,Lambda 其實也是一個函式型別的物件而已。你能怎麼使用雙冒號加函式名,就能怎麼使用匿名函式,以及怎麼使用 Lambda 表示式。

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

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

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

廣告時間:

大家如果喜歡我的視訊,也可以瞭解一下我的 Android 高階進階系列化課程,掃碼加小助理丟丟讓她給你發試聽課:

【碼上開學】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 期望我們直接使用函式型別的引數,而不是用介面這種折中方案。

總結

好,這就是 Kotlin 的高階函式、匿名函式和 Lambda。簡單總結一下:

  • 在 Kotlin 裡,有一類 Java 中不存在的型別,叫做「函式型別」,這一類型別的物件在可以當函式來用的同時,還能作為函式的引數、函式的返回值以及賦值給變數;

  • 建立一個函式型別的物件有三種方式:雙冒號加函式名、匿名函式和 Lambda;

  • 一定要記住:雙冒號加函式名、匿名函式和 Lambda 本質上都是函式型別的物件。在 Kotlin 裡,匿名函式不是函式,Lambda 也不是什麼玄學的所謂「它只是個程式碼塊,沒法歸類」,Kotlin 的 Lambda 可以歸類,它屬於函式型別的物件。

當然了這裡面的各種細節還有很多,這個你可以自己學去,我就不管你了。下期內容是 Kotlin 的擴充套件屬性和擴充套件函式,關注我,不錯過我的任何新內容。大家拜拜~

推薦閱讀

【碼上開學】Kotlin 的變數、函式和型別

【碼上開學】Kotlin 的泛型

【碼上開學】Kotlin 協程的掛起好神奇好難懂?今天我把它的皮給扒了

【碼上開學】到底什麼是「非阻塞式」掛起?協程真的更輕量級嗎?

【扔物線】消失這半年,我去哪了

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

相關文章