iT邦幫忙

2026 iThome 鐵人賽

DAY 2
0
Software Development

Kotlin Lambda 從零開始系列 第 2

Kotlin Lambda 從零開始 Day 02:Lambda 本質解密 — FunctionN、invokedynamic 與閉包

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260806/201219480TdQehvQRN.jpg

這篇文章會釐清 function type、FunctionN 執行期介面與 invokedynamic 產生策略的差異,再理解閉包捕獲變數的機制,最後會實作 myForEachmyCounter

Kotlin ↔ C# 對照表

面向 Kotlin C#
Lambda 執行期表示 FunctionN 相容物件,Kotlin 2.0 起預設由 invokedynamic 產生 Delegate
閉包捕獲 Ref 物件包裝 var DisplayClass
SAM fun interface 隱式 delegate 轉換
方法參考 ::method method group

Lambda 的各種寫法

Kotlin 裡寫 Lambda 有好幾種方式,長得很像,但不是隨便挑一個就好。大部分的差別來自「編譯器能推導出多少」,少部分則是行為真的不一樣

大括號不是 fun 的簡寫

動手看寫法之前,先確認一件容易誤會的事:fun 是宣告,{ } 是運算式,兩者產出的東西不一樣

fun g() = 42      // 宣告一個方法
val f = { 42 }    // 產出一個值,型別是 () -> Int

f 不是 42,而是一段之後才會執行的程式,要寫 f() 才拿得到 42。差別最明顯的地方是能不能當值用

val bad: () -> Int = g    // 編譯不過
val ok: () -> Int = ::g   // 要用 :: 轉一下

第一行的錯誤訊息很有意思

Initializer type mismatch: expected '() -> Int', actual 'Int'.
Function invocation 'g()' expected.

編譯器根本不覺得 g 是個值,它以為你少打了括號、想呼叫它拿一個 Int。所以 function 的名字本身不是值,{ } 產出的才是;:: 則是幫已經宣告好的 function 補一張「變成值」的票

寫法 是什麼 產物
fun g() = 42 宣告 類別裡的一個方法
{ 42 } 運算式 一個可以呼叫的物件,本身就是值
::g 轉換 把宣告好的方法包成可以呼叫的物件

那個「可以呼叫的物件」在 JVM 上到底是什麼,下面的 FunctionN 會講

還有個陷阱:{ } 不一定是 Lambda。fun main() { }if (x) { }class A { } 裡的大括號都只是程式區塊,只有出現在「這裡需要一個值」的位置時,{ } 才會被當成 Lambda

從完整寫法一路省

同一個「大於 3」的判斷,可以從最囉唆的寫法開始一層一層省

// 1. 變數型別和參數型別都寫出來
val f: (Int) -> Boolean = { x: Int -> x > 3 }

// 2. 變數已經宣告成 (Int) -> Boolean,參數型別可以省
val f: (Int) -> Boolean = { x -> x > 3 }

// 3. 反過來,參數型別留著,變數型別讓編譯器推導
val f = { x: Int -> x > 3 }

// 4. 直接傳給 filter,型別由 filter 的簽名決定,單一參數還能用 it
list.filter { it > 3 }

四種寫法行為完全一樣,省掉的都是編譯器本來就推導得出來的部分。型別資訊要馬寫在變數上,要馬寫在參數上,要馬由接收它的函式簽名提供,至少要有一個地方講清楚;兩邊都不寫,編譯器就會叫

這條「愈寫愈短」的路,C# 也走過,只是它是分好幾個版本才走完的

// 匿名方法,C# 2.0
Func<int, bool> f = delegate(int x) { return x > 3; };

// Lambda 運算式,C# 3.0
Func<int, bool> f = (int x) => { return x > 3; };
Func<int, bool> f = x => x > 3;

// Lambda 有了自然型別,可以用 var 接,C# 10
var f = (int x) => x > 3;

這幾種寫法在今天的 C# 都還能編譯,差別只在它們各自是哪一版加進來的。所以 C# 這邊「寫法變簡單」是版本演進,編譯器版本決定你能省到哪一步;Kotlin 上面那四種從 1.0 就同時存在,停在哪一步取決於當下型別推導有沒有足夠資訊,跟版本無關

it 是 Kotlin 多出來的東西。C# 的參數一定要自己命名,就算只有一個也一樣 (x => x > 3),Kotlin 單參數 Lambda 可以完全不取名。方便歸方便,Lambda 巢狀時兩層 it 會很難讀,那種時候還是老實取個名字

匿名函式和方法參考是另外兩條路

匿名函式不是上面那條階梯的下一階,它反而更長

list.filter(fun(x: Int): Boolean { return x > 3 })

會特地這樣寫,通常是為了明確標出回傳型別,或是為了本篇後半要講的 return 行為

方法參考則是連 Lambda 都省掉

list.map(Employee::name)

它等價於 list.map { it.name },對應 C# 的 method group。不過 C# 的屬性沒辦法這樣傳,只能寫成 Select(e => e.Name)

上面這些寫法在多數情況可以互換,唯一會有問題的是 return:同樣一行 return,寫在 Lambda 裡和寫在匿名函式裡,跳出去的地方不一樣。這篇後半的「Lambda vs 匿名函式 vs 方法參考」會用完整的例子走一次

FunctionN 是型別契約,不等於產生策略

Kotlin 可以把一段行為當成值傳來傳去,(Int) -> Boolean 就是這種值的型別。但 JVM 上沒有這種東西,JVM 只認識類別和物件,沒有「函式型別」這種概念

Kotlin 的解法是準備一組介面,每個介面只有一個 invoke 方法,用參數個數區分

// kotlin.jvm.functions 底下,概念上長這樣
interface Function0<R> { fun invoke(): R }
interface Function1<P1, R> { fun invoke(p1: P1): R }
interface Function2<P1, P2, R> { fun invoke(p1: P1, p2: P2): R }
// 一路到 Function22

所以 (Int) -> Boolean 在 JVM 上就是 Function1<Int, Boolean>() -> Int 就是 Function0<Int>。這也解釋了一件平常不會注意到的事:你寫 f(5),實際跑的是 f.invoke(5)

val f: (Int) -> Boolean = { it > 3 }

f(5)          // 平常這樣寫
f.invoke(5)   // 等價,而且這才是 JVM 真正在做的事

知道 (Int) -> Boolean 就是 Function1 之後,很容易接著推論:那編譯器應該會幫每個 Lambda 生一個實作 Function1 的類別吧 ?

// 你寫的
val isPositive: (Int) -> Boolean = { it > 0 }

// 型別契約上可以這樣理解,但不代表預設 bytecode 會產生這個匿名類別
val isPositive = object : Function1<Int, Boolean> {
    override fun invoke(p1: Int): Boolean = p1 > 0
}

這就是最容易混在一起的地方。這裡其實是兩個獨立的問題

  • 型別契約:這個東西長什麼樣、怎麼呼叫它。答案是 FunctionN 和 invoke
  • 產生策略:這個物件是誰、在什麼時候做出來的。答案會隨 Kotlin 版本改變

Kotlin 1.x 的產生策略確實是編譯期生匿名類別,一個 Lambda 一個 class 檔案,所以「Lambda 就是匿名類別」這個說法流傳很廣。Kotlin/JVM 2.0 起改用 invokedynamic,這個說法就過期了

invokedynamic 在做什麼

invokedynamic 是 JVM 從 Java 7 就有的指令,白話講是「這裡要生一個物件,但我現在不告訴你怎麼生,第一次真的執行到再問」

編譯器在呼叫點只留一張字條,寫著「要一個 Function0」和「本體是這個 static 方法」,實際生成交給 JDK 的 LambdaMetafactory。程式第一次跑到那一行,JVM 才生出真正的類別,之後同一個位置就重複使用

好處是 class 檔案不會隨 Lambda 數量膨脹,而且產生方式握在 JVM 手上,JDK 之後有更好的做法可以直接受惠。需要舊行為時,才用 -Xlambdas=class 切回每個 Lambda 產生 class 的模式

因此閱讀程式碼時可以用 FunctionN 理解型別與呼叫契約;分析 bytecode、物件分配或效能時,則必須把 compiler 版本與 -Xlambdas 設定一起考慮

C# 的 Lambda 會轉成相容的 delegate。兩邊都可能建立可呼叫物件、快取無捕獲 Lambda,也都會受 JIT 最佳化影響,不能只靠「介面 vs delegate」推導實際成本

day 04 的 inline 仍然重要。它能在編譯期把高階函式本體與 Lambda 展開到呼叫處,避開可呼叫物件與間接呼叫。不過「沒 inline 就必然每次建立匿名類別」已經不是 Kotlin 2.x 的正確模型

Lambda 的 capture (閉包) 行為

Lambda 用到自己參數以外的變數時,就得把那個變數一起帶著走,這個動作叫捕獲 (capture)。被捕獲的是它外面的變數,自己的參數不算

下面三行的 { } 都是 Lambda,沒有 -> 表示這個 Lambda 不收參數,有 -> 的話左邊是參數、右邊才是本體

val a = { 42 }           // 無捕獲,本體只用到寫死的 42

var c = 0
val b = { ++c }          // 有捕獲,本體用到外面的 c,得把 c 帶著

val d = { n: Int -> n }  // 無捕獲,n 是自己的參數,不算外面的

會捕獲外部變數的 Lambda 就是閉包 (closure)。它不只能讀,還能改

var count = 0
list.forEach { count++ }  // count 被捕獲了,而且被改了

Kotlin 的閉包可以捕獲 var (可變變數),這點跟 C# 一樣。但 Java 只能捕獲 final (或 effectively final) 變數

為什麼這件事需要特別處理?看一個回傳 Lambda 的函式就懂了,本篇後面要手刻的 myCounter 就長這樣

fun myCounter(): () -> Int {
    var count = 0
    return { ++count }
}

countmyCounter 的區域變數,照理說函式一結束就該消失。但回傳的 Lambda 還活著,而且之後每次呼叫都還要繼續改它。這兩件事沒辦法同時成立,除非把 count 從「函式的區域變數」搬到「某個物件的欄位」,讓它活在 heap 上,不隨函式結束而消失

Java 選了另一條路:只准捕獲不會變的變數,等於把值複製一份給 Lambda。複製之後兩邊就各走各的,所以乾脆禁止你改,免得寫出「改了但外面沒感覺」的 bug。Kotlin 和 C# 則是讓兩邊共用同一份狀態,所以可以改

底層怎麼做到的?當 Lambda 捕獲一個 var 時,編譯器會把它包進一個 Ref 物件

// 你寫的
var count = 0
list.forEach { count++ }

// 編譯後 (概念上)
val countRef = IntRef()
countRef.element = 0
list.forEach { countRef.element++ }

IntRef 是一個有 element 欄位的包裝物件,可以想成一個裝著 count 的箱子。Lambda 和外部程式碼拿到的是同一個箱子,所以誰改都看得到。每種基本型別都有對應的版本,IntRefBooleanRefObjectRef 之類的

代價是多一個物件和一層欄位存取。平常完全不用在意,只有在很熱的路徑上才需要想「這裡真的需要捕獲 var 嗎」

C# 用的是類似概念,叫 DisplayClass:編譯器產生一個隱藏類別,把被捕獲的變數變成這個類別的欄位

實測:不用翻 bytecode 也看得到

產生策略和捕獲都講完了,這兩件事在執行期都看得到,不必動用 bytecode 工具,把 Lambda 的 class 名稱印出來就行

fun noCapture(): () -> Int = { 42 }

fun withCapture(): () -> Int {
    var c = 0
    return { ++c }
}

fun main() {
    val a = noCapture()
    val b = noCapture()
    println("無捕獲 a === b: ${a === b}")
    println("  a: ${a.javaClass.name}")
    println("  b: ${b.javaClass.name}")

    val x = withCapture()
    val y = withCapture()
    println("有捕獲 x === y: ${x === y}")
    println("  x: ${x.javaClass.name}")
    println("  y: ${y.javaClass.name}")
}

預設模式 (invokedynamic) 的輸出

無捕獲 a === b: true
  a: MainKt$$Lambda/0x0000007001001000
  b: MainKt$$Lambda/0x0000007001001000
有捕獲 x === y: false
  x: MainKt$$Lambda/0x0000007001001428
  y: MainKt$$Lambda/0x0000007001001428

想自己切過去比較的話,要注意 -Xlambdas=class編譯器參數,不是 JVM 參數。寫進 IDE run 設定的 JVM arguments 欄位會直接爆掉

Unrecognized option: -Xlambdas=class
Error: Could not create the Java Virtual Machine.

它要加在 module.yaml

product: jvm/app

settings:
  kotlin:
    freeCompilerArgs: [-Xlambdas=class]

Run 設定完全不用動,重新編譯之後同一支程式的輸出就變成

無捕獲 a === b: true
  a: MainKt$noCapture$1
  b: MainKt$noCapture$1
有捕獲 x === y: false
  x: MainKt$withCapture$1
  y: MainKt$withCapture$1

想切回預設,把 settings 那三行拿掉,module.yaml 回到只有 product: jvm/app 一行就好。Gradle 專案的話位置不一樣,是 build.gradle.ktskotlin { compilerOptions { freeCompilerArgs.add("-Xlambdas=class") } }

兩組輸出對照著看,有三件事

  • class 名稱的來源不同MainKt$noCapture$1 是編譯期就決定好的名字,class 檔案真的在磁碟上;MainKt$$Lambda/0x... 後面那串位址是 JVM 執行期生出來的,同一支程式多跑幾次,數字還會變
  • xy 的 class 名稱一模一樣,但 x === y 是 false。同一個 Lambda 只會有一個類別,每次呼叫 withCapture() 都是用這個類別再建一個實體,因為兩個 counter 各自要記住自己的 count
  • 無捕獲的 ab 根本是同一個物件{ 42 } 不需要記住任何東西,一個實體就夠所有人用,這點兩種模式都一樣

最後一點要特別注意:Lambda 會不會產生物件,關鍵在有沒有捕獲,不在有沒有 inline

TDD 實作 myForEach

理論講完,動手驗證。先用最小的 Collection 操作把測試節奏跑一次,順便確認 day 01 建好的專案真的能跑 Red → Green。myForEach 接收一個 action,依序把每個元素交給它,不建立新的集合

Red:先寫測試

@Test
fun `forEach visits every element in order`() {
    val visited = mutableListOf<Int>()

    listOf(1, 2, 3).myForEach { visited.add(it) }

    assertEquals(listOf(1, 2, 3), visited)
}

@Test
fun `forEach on empty list does nothing`() {
    var called = false

    emptyList<Int>().myForEach { called = true }

    assertFalse(called)
}

Green:最小實作

inline fun <T> Iterable<T>.myForEach(action: (T) -> Unit) {
    for (element in this) {
        action(element)
    }
}

action: (T) -> Unit 就是前面講的 function type,呼叫端寫 myForEach { ... } 是 trailing lambda 的語法糖。掛在 Iterable<T> 上的 extension function 和泛型 <T> day 03 再展開,inline 也先加著,day 04 才會看它對 Lambda 呼叫與 bytecode 的影響。現在只需要確認兩件事:元素依原順序走訪,空集合不會呼叫 action

for (element in this) 這行也不是語言內建的魔法,編譯器會把它展開成 iterator()hasNext()next() 三個呼叫,跟 C# foreach 底下的 GetEnumerator()MoveNext()Current 是同一套機制,day 03 會展開講

action 是名字,不是型別

寫過 C# 的看到 action 可能會聯想到 Action<T>,但這兩件事不一樣。C# 的 Action<T>Func<T, R>Predicate<T> 是三個真正的 delegate 型別,型別系統認得它們,而且彼此不通,簽名一樣的 Predicate<int> 也不能直接指派給 Func<int, bool>

Kotlin 這邊只有 function type 一種東西,action 純粹是參數名稱

C# 的型別 Kotlin 的型別 stdlib 慣用的參數名
Action<T> (T) -> Unit action
Func<T, R> (T) -> R transformselector
Predicate<T> (T) -> Boolean predicate

把參數改名叫 banana 編譯器也不會有意見。但還是別亂改,因為參數名稱是 public API 的一部分,具名引數會用到它

listOf(1, 2, 3).forEach(action = { println(it) })

day 03 的 myFilter 收的就是 predicate,後面每個手刻函式都會照 stdlib 的命名走

TDD 實作 myCounter

myForEach 只用到 function type 的呼叫端,換個角度再看 Lambda 捕獲變數時發生什麼事。寫一個 myCounter 函式,回傳一個 () -> Int 的 Lambda,每呼叫一次就遞增計數。測試會驗證回傳值符合 Function0 契約,以及捕獲的 var 會共享同一份狀態;它不負責判斷 compiler 採用 class 還是 invokedynamic

Red:先寫測試

@Test
fun `counter increments on each call`() {
    val counter = myCounter()
    assertEquals(1, counter())
    assertEquals(2, counter())
    assertEquals(3, counter())
}

@Test
fun `two counters are independent`() {
    val a = myCounter()
    val b = myCounter()
    a()
    a()
    assertEquals(1, b())
}

@Test
fun `counter is a Function0 implementation`() {
    val counter: Any = myCounter()
    assertTrue(counter is Function0<*>)
}

三個測試分別驗證:基本遞增、兩個 counter 各自捕獲自己的變數互不干擾、Lambda 在執行期確實是 Function0 的實體。myCounter 不接受任何輸入,沒有 null 或例外路徑要處理,所以不另外寫例外測試

Green:最小實作

先直接手刻一個 Function0 匿名物件,確認 function type 的契約

fun myCounter(): () -> Int {
    var count = 0
    return object : Function0<Int> {
        override fun invoke(): Int {
            count++
            return count
        }
    }
}

這段程式碼能編譯,因為 Function0<Int> 可以用在 () -> Int 的位置。匿名物件和 Lambda 捕獲 count 的機制其實一樣,都是共用前面講的 Ref 物件,差別只在誰持有它,day 04 看 bytecode 時會更清楚

Refactor:往 stdlib 的寫法靠近

手刻匿名物件只是為了看清楚呼叫契約,實際程式改回 Lambda

fun myCounter(): () -> Int {
    var count = 0
    return { ++count }
}

三個測試行為相同,包括 counter is Function0<*>。這表示兩者遵守相同的執行期介面,不表示 compiler 產生了相同的 class 檔案

SAM Conversion (Single Abstract Method)

如果一個 Java 介面只有一個抽象方法,Kotlin 允許用 Lambda 來實作它

// Java 的 Runnable 只有一個 run() 方法
val task: Runnable = Runnable { println("running") }

但 Kotlin 自己定義的介面,要加上 fun 修飾才能用 SAM conversion

先看沒加的版本

interface Predicate<T> {
    fun test(value: T): Boolean
}

val isEven = Predicate<Int> { it % 2 == 0 }  // 編譯不過

錯誤訊息長這樣

Interface 'interface Predicate<T> : Any' does not have constructors.
Unresolved reference 'it'.

第一句其實把原理講完了。Predicate<Int> { ... } 看起來像在呼叫建構式,實際上是在呼叫編譯器合成的 SAM constructor,而 fun 就是叫編譯器把那個 constructor 生出來。沒加 fun,就沒有 constructor 可以呼叫

補上 fun 之後同一行就能編譯

fun interface Predicate<T> {
    fun test(value: T): Boolean
}

val isEven = Predicate<Int> { it % 2 == 0 }

為什麼 Kotlin 要多這一步?因為 Kotlin 原生就有 function type (T) -> Boolean,多數情況下不需要另外定義介面

要注意 fun interface 不是為了 Java 互通而存在。上面 Runnable 那個例子就是證據:它是 Java 介面,沒有任何 fun 修飾,照樣能用 Lambda 賦值。Java 的 SAM 介面本來就享有 SAM conversion。fun interface 解決的是另一半問題 —— 讓 Kotlin 自己宣告的介面也能這樣用

Lambda vs 匿名函式 vs 方法參考

這三種寫法在大部分情境下可以互換,但有幾個行為上的差別

return 行為

fun findFirstPositive(numbers: List<Int>): Int? {
    numbers.forEach {
        if (it > 0) return it  // non-local return,直接從 findFirstPositive 返回
    }
    return null
}

如果 forEach 裡面用的是匿名函式

fun findFirstPositive(numbers: List<Int>): Int? {
    numbers.forEach(fun(n) {
        if (n > 0) return  // local return,只從匿名函式返回,forEach 繼續跑
    })
    return null
}

Lambda 的 return 會穿透到外層函式,這叫 non-local return。它之所以能這樣做,是因為 forEachinline 函式,Lambda 的程式碼直接展開在呼叫處。匿名函式的 return 永遠是 local 的,只從自己返回

那 Lambda 想要「只結束這一輪,繼續跑下一個元素」怎麼辦?加標籤

numbers.forEach {
    if (it < 0) return@forEach  // 只跳過這一筆,其他元素繼續跑
    println(it)
}

return@forEach 是 local return,效果接近迴圈裡的 continue。整理起來就三種落點:Lambda 不加標籤跳出外層函式、Lambda 加標籤只結束這一輪、匿名函式的 return 永遠只離開自己

day 04 會講到 crossinline,那是用來處理「Lambda 被傳到別的執行環境,non-local return 不安全」的情況

方法參考

// 這兩行等價
employees.map { it.name }
employees.map(Employee::name)

Employee::name 是一個 property reference。需要一個實際物件時,編譯器會產生一個最佳化過的 callable reference 類別(PropertyReference1Impl 的子類別),它的 invoke 直接呼叫 getName(),不走反射,效能跟手寫 { it.name } 在同一個量級

不過這裡的 map 剛好看不到那個類別。stdlib 的 mapinlineemployees.map(Employee::name) 展開後 bytecode 直接是 invokevirtual Employee.getName(),一個物件都不會產生。要看到 PropertyReference1Impl 的子類別,得把它傳給非 inline 的高階函式

真的會用到反射的,是透過 kotlin-reflect 把它當 KProperty 操作的時候,例如 .getter.call()

小結

這篇的主軸是把三件常常混在一起的事分開。{ } 是運算式,產出的是一個值;FunctionN 說明那個值長什麼樣、怎麼呼叫;invokedynamic 或 class 模式決定它是誰、什麼時候被做出來的。前兩件事很穩定,第三件會隨 Kotlin 版本改變,所以「Lambda 就是匿名類別」這種說法要看你講的是哪個年代

最實用的一條結論是:Lambda 會不會產生物件,看的是有沒有捕獲外部變數。無捕獲的重複用同一個實體,有捕獲的每次都得再生一個,因為被捕獲的 var 要搬進 Ref 物件才能活得比函式久。這條在兩種產生策略下都成立,光看「有沒有 inline」推不出來

手上也有了兩個手刻函式。myForEach 只是包著一個 for 迴圈,myCounter 更短,但它們已經把 function type、trailing lambda、閉包和 Red → Green 的節奏都跑過一次

下一篇把 Extension Function 和泛型講清楚,這兩個才是手刻 Collection API 天天要用的工具。至於 inline 為什麼能省掉這篇講的那些物件,day 04 會用 bytecode 回答

參考資料


Yes


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin Lambda 從零開始 Day 01:系列導讀 — 為什麼要手刻 Collection API?
系列文
Kotlin Lambda 從零開始2
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言