
這篇文章會說明 inline 如何在編譯期展開高階函式與 Lambda,避開間接呼叫和可能的物件成本,把 myFilter 補成 inline 版本,並整理它的限制
| Kotlin | C# | 備註 |
|---|---|---|
inline fun |
無直接對應 | C# 沒有語言層級的 Lambda 內聯機制 |
noinline |
— | 標記不要內聯的 Lambda 參數 |
crossinline |
— | 禁止 non-local return 的 Lambda 參數 |
| — | [MethodImpl(AggressiveInlining)] |
C# 的方法內聯是 JIT 層級,作用不同 |
day 02 把 function type、FunctionN 契約與 Lambda 產生策略分開了。Kotlin 2.4.0 的 JVM 預設使用 invokedynamic,但高階函式仍可能涉及可呼叫物件、capture 與間接呼叫
這代表每次呼叫帶 Lambda 的函式,可能會
單次呼叫沒什麼感覺。但想像一下在迴圈裡跑幾萬次 filter 或 map,這些短命物件就會給 GC 帶來壓力
加上 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 反編譯來看
IntelliJ 的 Tools > Kotlin > Show Kotlin Bytecode 可以直接看編譯結果。但這個工具有兩個坑,踩到的人通常會得到「加不加 inline 好像差不多」的結論
第一個坑:打開 bytecode 之後,直覺會去找 myFilter 這個函式本身,然後發現 inline 版跟非 inline 版長得幾乎一樣
這不是工具壞掉。inline fun 編譯後,Kotlin 仍然會產生一份實體的 JVM 方法
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 framemyFilter 裡面,每個元素都要跑一次 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 迴圈裡的一個 if。myFilter 展開後留下的唯一 heap 分配是 new ArrayList,那是這個函式本來就要建的結果容器,不是 Lambda 的成本(new Integer[] 是測試自己準備的資料,不算在內)
$iv 後綴是 inline variable。編譯器把 inline 函式本體的區域變數搬到呼叫端時會加上它,避免跟呼叫端既有的變數撞名。$this$myFilter$iv 是 extension receiver,result$iv 和 element$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 就不一定
第二個坑在工具視窗上那排選項:Inline、Optimization、Show offsets、Assertions 四個 checkbox,加一個 JVM Target 下拉選單。JetBrains 沒有為它們寫官方說明文件,行為要翻 IntelliJ 原始碼才知道(以下以 2026-08 的原始碼為準)
| 你在看的東西 | checkbox 有效嗎 |
|---|---|
Bytecode 面板(ASM 文字,ALOAD / INVOKEDYNAMIC 那種) |
有效 |
按 Decompile 開出來的 Java 檔 |
無效,完全被忽略 |
原因在 KotlinBytecodeToolWindow.kt 的 processRequest,這裡會把 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
看到這你可能會想:JIT 不是會自動內聯嗎,為什麼 Kotlin 要在語言層級弄一個 inline
JVM 也會依 runtime profile 做方法內聯、去虛擬化與 escape analysis,但結果取決於實際呼叫型態。語言層級的 inline 不必等呼叫點變熱,compiler 在產生 bytecode 前就把高階函式和可內聯的 Lambda 展開
CLR 的條件比較好。delegate 是原生型別,JIT 對它有直接的最佳化路徑;泛型在 runtime 保留完整型別資訊(reified),Func<int, bool> 會自動特化成 int 與 bool,不像 JVM 那樣全部擦除成 Object
Kotlin 不能改 JVM,所以選擇從編譯期下手。inline 標出來的函式,編譯器直接把本體和 Lambda 一起展開到呼叫處,bytecode 出來就沒有 FunctionN 物件。JIT 看到的就是純粹的 for 迴圈,最佳化空間反而更大
重點在「確定」兩個字。JIT inline 看 runtime profile,效果不可預測;inline 是編譯期保證,標了就一定展開。代價是 library 作者要主動判斷哪些函式該加、binary size 會變大。但對 forEach、filter、map 這類短小、Lambda 用完即丟的 higher-order function,這個取捨划算
有時候一個 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 都是當場執行完就丟掉,不需要存起來
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 都是在同一個執行緒、同一個函式內直接跑完的。但知道它的存在,遇到編譯錯誤時才不會一頭霧水
day 02 的 myForEach 已經先加上 inline,把原因留到這篇說明。day 03 的 myFilter 則刻意停在沒有 inline 的版本,現在補上同樣的修飾詞
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 沒加 inline 的 myFilter 跑:第一個測試照樣全綠,第二個直接編譯失敗,錯誤訊息是 'return' is prohibited here.。Lambda 不是 inline 的,return 沒地方跳
空集合、全部符合、全部不符合這些邊界案例 day 03 的測試已經涵蓋,myFilter 本身也沒有會丟例外的路徑(predicate 丟出的例外會原樣往外傳),所以這裡只補 inline 相關的行為
// 修改前
fun <T> Iterable<T>.myFilter(predicate: (T) -> Boolean): List<T> { ... }
// 修改後
inline fun <T> Iterable<T>.myFilter(predicate: (T) -> Boolean): List<T> { ... }
就加一個 inline 關鍵字,其他完全不變。編譯錯誤消失,新舊測試一起全綠
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 函式幾乎都很短(forEach、let、apply 都只有幾行),就是這個道理public 的 inline 函式本體裡用了 private 或 internal,編譯器會直接擋下,訊息就叫 Public-API inline function cannot access non-public-API。反過來說,private inline 或 internal inline 沒有這個限制經驗法則
inline
noinline
從 stdlib 也看得出原則。forEach、map、filter、let、run、with、apply、also 都是 inline;但 Sequence 版的 filter、map 不是,因為 Lambda 要被存進 Sequence 物件裡延遲執行,存起來的 Lambda 沒辦法內聯。至於 sorted、toList 這類沒有 Lambda 參數的函式,根本沒有 Lambda 可以展開,inline 對它們沒有意義
C# 沒有 Kotlin 這種語言層級的 inline 函式機制。兩者的最佳化路線不同
inline 由開發者標記,效果確定把 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 開始
同步刊登於 Blog
圖片來源:AI 產生