iT邦幫忙

2026 iThome 鐵人賽

DAY 4
0
Software Development

Kotlin Lambda 從零開始系列 第 4

Kotlin Lambda 從零開始 Day 04:inline 關鍵字 — 編譯期展開與使用限制

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260807/201219480fj7uFk1cv.jpg

這篇文章會說明 inline 如何在編譯期展開高階函式與 Lambda,避開間接呼叫和可能的物件成本,把 myFilter 補成 inline 版本,並整理它的限制

Kotlin ↔ C# 對照表

Kotlin C# 備註
inline fun 無直接對應 C# 沒有語言層級的 Lambda 內聯機制
noinline 標記不要內聯的 Lambda 參數
crossinline 禁止 non-local return 的 Lambda 參數
[MethodImpl(AggressiveInlining)] C# 的方法內聯是 JIT 層級,作用不同

Lambda 的隱藏成本

day 02 把 function type、FunctionN 契約與 Lambda 產生策略分開了。Kotlin 2.4.0 的 JVM 預設使用 invokedynamic,但高階函式仍可能涉及可呼叫物件、capture 與間接呼叫

這代表每次呼叫帶 Lambda 的函式,可能會

  • 建立或取得一個可呼叫物件
  • 保存 capture 狀態
  • 透過 function type 間接呼叫

單次呼叫沒什麼感覺。但想像一下在迴圈裡跑幾萬次 filtermap,這些短命物件就會給 GC 帶來壓力

inline 的機制

加上 inline 之後,編譯器直接把函式本體和 Lambda 本體展開到呼叫處。不建立 FunctionN 物件,不透過 invoke 呼叫

用 bytecode 對比最清楚

沒有 inline

fun <T> Iterable<T>.myFilter(predicate: (T) -> Boolean): List<T> {
    val result = ArrayList<T>()
    for (element in this) {
        if (predicate(element)) {
            result.add(element)
        }
    }
    return result
}

// 呼叫端
val result = numbers.myFilter { it > 3 }

// 呼叫端 bytecode (概念上)
// 1. invokedynamic 呼叫點取得 Function1 相容物件
// 2. 呼叫 myFilter(numbers, predicate)
// 3. myFilter 透過 predicate.invoke(element) 執行判斷

有 inline

inline fun <T> Iterable<T>.myFilter(predicate: (T) -> Boolean): List<T> {
    val result = ArrayList<T>()
    for (element in this) {
        if (predicate(element)) {
            result.add(element)
        }
    }
    return result
}

// 呼叫端
val result = numbers.myFilter { it > 3 }

// 呼叫端 bytecode (概念上)
// 直接展開成
val result = ArrayList<Int>()
for (element in numbers) {
    if (element > 3) result.add(element)
}

差別一目瞭然。inline 版本的呼叫端不會出現任何 FunctionN 物件,Lambda 的 { it > 3 } 直接變成 if (element > 3) 嵌在 for 迴圈裡

上面是概念版。編譯器實際吐出來的東西長什麼樣,下一節用 IntelliJ 反編譯來看

動手驗證:Show Kotlin Bytecode

IntelliJ 的 Tools > Kotlin > Show Kotlin Bytecode 可以直接看編譯結果。但這個工具有兩個坑,踩到的人通常會得到「加不加 inline 好像差不多」的結論

要看呼叫端,不是看函式定義

第一個坑:打開 bytecode 之後,直覺會去找 myFilter 這個函式本身,然後發現 inline 版跟非 inline 版長得幾乎一樣

這不是工具壞掉。inline fun 編譯後,Kotlin 仍然會產生一份實體的 JVM 方法

  • Java 那邊要能呼叫,而 Java 不認得 Kotlin 的 inline
  • 反射與函式參考(::myFilter)需要真的有一個方法可以指
  • 標了 noinline、或用 -Xno-inline 編譯時,也得有實體可用

inline 的效果在呼叫端。所以要另外寫一個呼叫端,例如一個測試方法或 main,去看那個方法的 bytecode

真實的反編譯對照

拿一個過濾掉 null 的測試當呼叫端,按 Decompile 反編譯成 Java

沒有 inline 時

@Test
public final void filter_list_with_null_elements/* $FF was: filter list with null elements*/() {
   Integer[] var2 = new Integer[]{1, null, 3, null};
   List numbers = CollectionsKt.listOf(var2);
   List result = MyLamdbaTestKt.myFilter((Iterable)numbers, myLamdbaTest::filter_list_with_null_elements$lambda$0);
   Integer[] var3 = new Integer[]{1, 3};
   AssertionsKt.assertEquals$default(CollectionsKt.listOf(var3), result, (String)null, 4, (Object)null);
}

先看懂那些名字,不然會以為自己做錯

  • MyLamdbaTestKt 是檔案 MyLamdbaTest.kt 的 file class,myFilter 就宣告在這個檔案的 top level。把它放到別的檔案,名字會跟著換
  • myLamdbaTest 則是測試 class 本身,名字本來就是小寫開頭,不是筆誤也不是什麼實例變數
  • 方法名後面的 /* $FF was: filter list with null elements*/ 是 Fernflower 的註解。原本的 backtick 測試名帶空格,Java 語法寫不出來,反編譯器只好換成底線再把原名放進註解

呼叫 myFilter 那一行藏了三件事

  • myLamdbaTest::filter_list_with_null_elements$lambda$0 是 Fernflower 反編譯 invokedynamic 之後的樣子。Lambda { it != null } 被編譯成一個 synthetic static 方法掛在測試 class 底下,再由 invokedynamic 搭配 LambdaMetafactory 產生實作 Function1 的物件
  • MyLamdbaTestKt.myFilter(...) 是真的 static method call,會壓一個 stack frame
  • 迴圈在 myFilter 裡面,每個元素都要跑一次 predicate.invoke(element),那是 INVOKEINTERFACE 的虛擬呼叫

第一點那個方法是 static 而不是實例方法,因為 { it != null } 沒有捕獲 this:: 左邊印出來的才會是類別名,而不是 this

加上 inline 之後

@Test
public final void filter_list_with_null_elements/* $FF was: filter list with null elements*/() {
   Integer[] var2 = new Integer[]{1, null, 3, null};
   List numbers = CollectionsKt.listOf(var2);
   Iterable $this$myFilter$iv = (Iterable)numbers;
   int $i$f$myFilter = 0;
   ArrayList result$iv = new ArrayList();

   for(Object element$iv : $this$myFilter$iv) {
      Integer it = (Integer)element$iv;
      int var9 = 0;
      if (it != null) {
         result$iv.add(element$iv);
      }
   }

   List result = (List)result$iv;
   Integer[] var11 = new Integer[]{1, 3};
   AssertionsKt.assertEquals$default(CollectionsKt.listOf(var11), result, (String)null, 4, (Object)null);
}

myFilter 這個名字在呼叫端完全消失,函式本體整個搬了進來。沒有 invokedynamic、沒有 Function1、沒有 invoke()、也沒有方法呼叫,Lambda 的 { it != null } 變成 for 迴圈裡的一個 ifmyFilter 展開後留下的唯一 heap 分配是 new ArrayList,那是這個函式本來就要建的結果容器,不是 Lambda 的成本(new Integer[] 是測試自己準備的資料,不算在內)

那些奇怪的變數名

  • $iv 後綴是 inline variable。編譯器把 inline 函式本體的區域變數搬到呼叫端時會加上它,避免跟呼叫端既有的變數撞名。$this$myFilter$iv 是 extension receiver,result$ivelement$iv 是函式本體的區域變數
  • $i$f$myFilter 是 inline function marker,$i$f$ 就是 inline function 的縮寫。值永遠是 0,什麼事都不做,純粹在 LocalVariableTable 標出「這段 bytecode 原本來自 myFilter
  • int var9 = 0; 是 Lambda 的 inline marker,前綴 $i$a$ 代表 inline argument,跟 $i$f$ 剛好成對。原始名字帶特殊字元又太長,Fernflower 索性顯示成 var9。它的存在代表 Lambda 本體也被展開了

這幾個 marker 只是 debug 資訊:LocalVariableTable 多一筆紀錄,bytecode 多一組 ICONST_0 / ISTORE,JIT 會直接消掉,runtime 等於沒有成本

偵錯器能認出「這段是從哪個 inline 函式展開進來的」、步進時對得回原始碼,靠的就是它們

行號則是另一回事,跟這幾個變數無關。編譯器會另外改寫 LineNumberTable,Kotlin 用 SMAP 做 inline 的行號對應。IDE 讀得懂這份對應,純文字的 stack trace 就不一定

Decompile 按鈕不吃 Inline checkbox

第二個坑在工具視窗上那排選項:InlineOptimizationShow offsetsAssertions 四個 checkbox,加一個 JVM Target 下拉選單。JetBrains 沒有為它們寫官方說明文件,行為要翻 IntelliJ 原始碼才知道(以下以 2026-08 的原始碼為準)

你在看的東西 checkbox 有效嗎
Bytecode 面板(ASM 文字,ALOAD / INVOKEDYNAMIC 那種) 有效
Decompile 開出來的 Java 檔 無效,完全被忽略

原因在 KotlinBytecodeToolWindow.ktprocessRequest,這裡會把 checkbox 讀進編譯設定。後面幾段原始碼都是節錄,排版略作調整

return getBytecodeForFile(ktFile, showOffsets = showOffsets.isSelected) {
    if (!enableInline.isSelected)       { disableInline(true) }
    if (!enableAssertions.isSelected)   { disableCallAssertions(true); disableParameterAssertions(true) }
    if (!enableOptimization.isSelected) { disableOptimization(true) }
}

disableInline(true) 對應 CommonConfigurationKeys.DISABLE_INLINE,也就是編譯參數 -Xno-inline,官方描述只有一句 Disable method inlining.

Decompile 按鈕走的是另一條路,KotlinBytecodeDecompiler.kt

val (result, _) = compileSingleFile(file) ?: return emptyMap()   // 沒傳 configurator

compileSingleFile 的第二個參數有預設值

fun compileSingleFile(
    ktFile: KtFile,
    configurator: KaCompilationOptionsBuilder.() -> Unit = {}
): Pair<KaCompilationResult, ClassFileOrigins>?

Decompiler 沒傳 configurator,那幾個 checkbox 就全部落空。它永遠用預設設定重新編一次,再交給 Fernflower 反編譯

這件事有好有壞。好的是 Decompile 的結果忠實反映你實際 build 出來的樣子,不用擔心被 checkbox 誤導

壞的是想親眼看到 -Xno-inline 的差別時,按 Decompile 沒有用。得留在預設的 bytecode 文字面板切換 Inline checkbox:勾起來時呼叫端看不到 INVOKESTATIC ... myFilter,取消勾選就會冒出 INVOKEDYNAMIC invoke(...) 加上 INVOKESTATIC MyLamdbaTestKt.myFilter

為什麼 inline 是語言層級的設計

看到這你可能會想:JIT 不是會自動內聯嗎,為什麼 Kotlin 要在語言層級弄一個 inline

JVM 也會依 runtime profile 做方法內聯、去虛擬化與 escape analysis,但結果取決於實際呼叫型態。語言層級的 inline 不必等呼叫點變熱,compiler 在產生 bytecode 前就把高階函式和可內聯的 Lambda 展開

CLR 的條件比較好。delegate 是原生型別,JIT 對它有直接的最佳化路徑;泛型在 runtime 保留完整型別資訊(reified),Func<int, bool> 會自動特化成 intbool,不像 JVM 那樣全部擦除成 Object

Kotlin 不能改 JVM,所以選擇從編譯期下手。inline 標出來的函式,編譯器直接把本體和 Lambda 一起展開到呼叫處,bytecode 出來就沒有 FunctionN 物件。JIT 看到的就是純粹的 for 迴圈,最佳化空間反而更大

重點在「確定」兩個字。JIT inline 看 runtime profile,效果不可預測;inline 是編譯期保證,標了就一定展開。代價是 library 作者要主動判斷哪些函式該加、binary size 會變大。但對 forEachfiltermap 這類短小、Lambda 用完即丟的 higher-order function,這個取捨划算

noinline — 部分參數不內聯

有時候一個 inline 函式接收多個 Lambda,但其中某個 Lambda 需要被存起來(例如存到某個 callback list)。被存起來的 Lambda 不能內聯,因為內聯後就沒有物件可以存了

inline fun doSomething(
    inlined: () -> Unit,
    noinline stored: () -> Unit  // 這個不會被內聯
) {
    inlined()           // 直接展開
    callbacks.add(stored) // 需要物件才能存,所以不能內聯
}

noinline 告訴編譯器不要展開這個 Lambda 參數,讓它能被儲存或傳到其他位置。至於物件如何建立,仍由 JVM Lambda 產生策略決定

實務上用到 noinline 的機會不多。大部分 Collection 操作的 Lambda 都是當場執行完就丟掉,不需要存起來

crossinline — 禁止 non-local return

day 02 提過,Lambda 在 inline 函式中可以 non-local return,直接從外層函式返回。但有些情況下這不安全

inline fun runInThread(crossinline action: () -> Unit) {
    Thread {
        action()  // action 在另一個 thread 執行
    }.start()
}

如果 action 裡面寫了 return,那個 return 要返回到哪裡?外層函式可能早就執行完了。crossinline 就是告訴編譯器:「這個 Lambda 會被內聯,但不允許 non-local return」

在 Collection API 的手刻中不太會用到 crossinline,因為我們的 Lambda 都是在同一個執行緒、同一個函式內直接跑完的。但知道它的存在,遇到編譯錯誤時才不會一頭霧水

TDD 實作 myFilter (inline 版)

day 02 的 myForEach 已經先加上 inline,把原因留到這篇說明。day 03 的 myFilter 則刻意停在沒有 inline 的版本,現在補上同樣的修飾詞

Red:先寫測試

inline 除了效能,還帶來一個可以直接觀察的行為差異:non-local return。先寫兩個測試

@Test
fun `myFilter behaves the same after adding inline`() {
    val numbers = listOf(1, 2, 3, 4, 5)
    assertEquals(listOf(4, 5), numbers.myFilter { it > 3 })
    assertEquals(emptyList<Int>(), emptyList<Int>().myFilter { it > 0 })
}

@Test
fun `lambda passed to inline myFilter supports non-local return`() {
    fun containsNegative(numbers: List<Int>): Boolean {
        numbers.myFilter {
            if (it < 0) return true
            false
        }
        return false
    }

    assertTrue(containsNegative(listOf(1, -2, 3)))
    assertFalse(containsNegative(listOf(1, 2, 3)))
}

拿 day 03 沒加 inlinemyFilter 跑:第一個測試照樣全綠,第二個直接編譯失敗,錯誤訊息是 'return' is prohibited here.。Lambda 不是 inline 的,return 沒地方跳

空集合、全部符合、全部不符合這些邊界案例 day 03 的測試已經涵蓋,myFilter 本身也沒有會丟例外的路徑(predicate 丟出的例外會原樣往外傳),所以這裡只補 inline 相關的行為

Green:最小實作

// 修改前
fun <T> Iterable<T>.myFilter(predicate: (T) -> Boolean): List<T> { ... }

// 修改後
inline fun <T> Iterable<T>.myFilter(predicate: (T) -> Boolean): List<T> { ... }

就加一個 inline 關鍵字,其他完全不變。編譯錯誤消失,新舊測試一起全綠

Refactor:往 stdlib 的寫法靠近

stdlib 的 filter 簽名是 public inline fun <T> Iterable<T>.filter(predicate: (T) -> Boolean): List<T>,加上 inline 之後我們的版本就跟它一致了,剩下的差異只有 day 03 提過的 filterTo 模式,這裡不用再動

從這篇開始,後續所有 Collection 版的手刻函式都預設使用 inline。不會每次都重新解釋一遍

到了 day 29 Sequence 版時,會反過來解釋「為什麼 Sequence 版的 filter 不能 inline,改用延遲求值換效能」

inline 的代價:何時不該用

inline 不是萬靈丹。函式本體會被展開到每個呼叫處,有幾個面向要付代價

  • Binary size 變大:函式越大、呼叫點越多,展開越貴。stdlib 的 inline 函式幾乎都很短(forEachletapply 都只有幾行),就是這個道理
  • Stack trace 變混亂:行號對不太上,偵錯步進時會跳來跳去
  • 公開的 inline 函式不能呼叫非公開 API:函式本體會被貼到呼叫端,只能用呼叫端看得到的 API。public 的 inline 函式本體裡用了 privateinternal,編譯器會直接擋下,訊息就叫 Public-API inline function cannot access non-public-API。反過來說,private inlineinternal inline 沒有這個限制

經驗法則

  • 函式本體短、Lambda 用完即丟 → 加 inline
  • 函式本體大,或 Lambda 會被存起來 → 別加,或對特定參數標 noinline

從 stdlib 也看得出原則。forEachmapfilterletrunwithapplyalso 都是 inline;但 Sequence 版的 filtermap 不是,因為 Lambda 要被存進 Sequence 物件裡延遲執行,存起來的 Lambda 沒辦法內聯。至於 sortedtoList 這類沒有 Lambda 參數的函式,根本沒有 Lambda 可以展開,inline 對它們沒有意義

與 C# 的對比

C# 沒有 Kotlin 這種語言層級的 inline 函式機制。兩者的最佳化路線不同

  • Kotlin:編譯期主動展開。inline 由開發者標記,效果確定
  • C#:交給編譯器和 JIT 分工,delegate 物件大多還是存在,但呼叫成本被壓低

把 C# 那條路拆開看。沒捕獲變數的 lambda,C# 編譯器在編譯期就把 delegate 快取成靜態欄位,之後重複呼叫拿到的都是同一個物件,這是編譯器行為,不是 JIT 的功勞。有捕獲變數的 delegate 則每次呼叫都會分配一個新的,JIT 的逃逸分析很少能把它消掉。JIT 真正擅長的是去虛擬化和內聯 delegate 的 Invoke 呼叫,讓間接呼叫變便宜;但要把 LINQ 鏈裡的物件分配整個消掉並不常見,畢竟 LINQ 自己還會分配 enumerator

C# 有 [MethodImpl(MethodImplOptions.AggressiveInlining)],但那是提示 JIT 把方法本體內聯,跟 Kotlin 把 Lambda 一起展開是不同層次的事

至於 CLR 泛型特化、JVM 泛型擦除的差異,「為什麼 inline 是語言層級的設計」一節講過了,這裡不重複。結論是各有取捨:Kotlin 是「編譯期保證,代價是 binary size」;C# 是「runtime 自動,效果看 JIT 與實際的呼叫模式」

小結

inline 的核心是把函式與 Lambda 展開到呼叫處,避開間接呼叫與可能的物件成本。代價是 bytecode 變大、公開 inline API 的相容性限制,以及偵錯資訊更複雜,所以不能只看到高階函式就一律加上

基礎篇到這裡結束。Lambda、Extension Function、泛型、inline 四個概念都到位了,從下一篇開始就要大量手刻 Collection API。day 05 從最基本的 filter / filterNot / filterIndexed 開始

參考資料


Yes


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin Lambda 從零開始 Day 03:Extension Function + 泛型 — 手刻 API 的兩大武器
下一篇
Kotlin Lambda 從零開始 Day 05:filter / filterNot / filterIndexed — 最基本的篩選操作
系列文
Kotlin Lambda 從零開始7
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言