那個男人再發力,原來我以前學的 Lambda 都是假的
- 2020 年 4 月 10 日
- 筆記
聽說……Kotlin 可以用 Lambda?

不錯不錯,Java 8 也有 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;但這種邏輯是有毒的,一旦你信了它,你對於匿名函
對比 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 可以歸類,它屬於函數類型的對象。