iT邦幫忙

2026 iThome 鐵人賽

DAY 3
0
Software Development

Kotlin Lambda 從零開始系列 第 3

Kotlin Lambda 從零開始 Day 03:Extension Function + 泛型 — 手刻 API 的兩大武器

  • 分享至 

  • xImage
  •  

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

這篇文章會說明 Extension Function 和泛型的運作方式,然後用這兩個工具 TDD 實作 myFilter

Kotlin ↔ C# 對照表

Kotlin C# 備註
fun Type.method() static void Method(this Type t) Kotlin 用 receiver,C# 用 this 參數
<T> <T> 泛型語法幾乎一樣
<T : Comparable<T>> where T : IComparable<T> 泛型約束
Iterable<T> IEnumerable<T> 最基本的可疊代介面

Extension Function 基礎

Extension Function 讓你不用修改原始類別,就能幫它加上新方法。這是手刻 Collection API 的核心語法

fun <T> Iterable<T>.myFilter(predicate: (T) -> Boolean): List<T> {
    // this 指向呼叫端的 Iterable 實例
    // ...
}

語法上看起來像是 Iterable<T> 的成員方法,但編譯後其實是靜態方法

// 你寫的
fun <T> Iterable<T>.myFilter(predicate: (T) -> Boolean): List<T> { ... }

// 編譯後(概念上)
public static final <T> List<T> myFilter(Iterable<? extends T> $this$myFilter, Function1<? super T, Boolean> predicate) { ... }

第一個參數就是 receiver 本身,參數名 $this$myFilter 是編譯器產生的。也就是說 list.myFilter { ... } 編譯後等於 MyCollectionExtensionsKt.myFilter(list, ...)

那些 ? extends / ? super 現在可以先跳過,它們是 Kotlin 的 Iterable<out T>Function1<in P, out R> 在 Java 簽名上的投影。day 34 講型變時會回來解釋為什麼參數位置是 ? super、回傳位置是 ? extends

C# 的 Extension Method 走的是同一條路,只是寫法不同:C# 用 this 參數修飾,Kotlin 用 Type. 語法。編譯後都是靜態方法

一個容易搞混的點:Extension Function 不是真的「加進」那個類別裡。宣告在類別外面時它存取不到 private 成員,也不參與多型。如果原始類別有同名的成員方法,成員方法永遠優先(宣告在類別內部的 member extension 是另一回事,它看得到自己那個類別的私有成員)

Extension Function 不是 Kotlin 發明的。C# 在 3.0(隨 VS 2008)為了支援 LINQ 引入 extension method,沒有 WhereSelect 這些擴充方法,LINQ 的鏈式語法就寫不出來。Java 一路到 8(2014)才加了 default method 在介面上,但那是介面預設實作,不是真正的擴充

Kotlin 在這條路上比 C# 走得遠

  • Extension property:把 method 寫成 property,例如 val <T> List<T>.lastIndex: Int,泛型參數宣告在 val 之後
  • Member extension:在 class 裡定義的 extension,只在那個 class 範圍內可見
  • Receiver 的 Lambda 型別T.() -> R,讓 Lambda 內部 this 直接是 receiver

這幾個延伸讓 Kotlin 寫得出貼近自然語言的 DSL,例如 Gradle Kotlin DSL、Ktor route definition、kotlinx.html

泛型基礎

單型別參數 <T>

filterforEach 這類操作,輸入是 T,輸出也跟 T 有關

fun <T> Iterable<T>.myFilter(predicate: (T) -> Boolean): List<T>
//  ^^^ T 用在 receiver、predicate 參數、回傳值

雙型別參數 <T, R>

map 這類操作,輸入是 T 但輸出可以是完全不同的型別 R

fun <T, R> Iterable<T>.myMap(transform: (T) -> R): List<R>
//  ^^^^^^ T 是輸入型別,R 是輸出型別

呼叫的時候不需要手動指定型別,Kotlin 的型別推導會自動搞定

// 不用寫 employees.myMap<Employee, String> { it.name }
employees.myMap { it.name }  // 編譯器推導出 T = Employee, R = String

泛型約束

有些操作需要元素支援比較,這時候用泛型約束

fun <T : Comparable<T>> Iterable<T>.mySorted(): List<T>
//      ^^^^^^^^^^^^^^^^ T 必須實作 Comparable

C# 用 where 子句達到同樣效果:where T : IComparable<T>

Iterable / Collection / List / MutableList 的繼承關係

手刻 API 之前,要知道函式該定義在哪一層

Iterable<T>          ← 最基本,只保證有 iterator()
  └── Collection<T>  ← 加上 size、contains、isEmpty
        └── List<T>  ← 加上 get(index)、indexOf
              └── MutableList<T>  ← 可修改:add、remove、set

stdlib 的 filter 定義在 Iterable<T> 上。為什麼選最上層?因為這樣 List、Set、甚至自訂的 Iterable 實作都能用。遵循最小依賴原則,只要求「能疊代」就夠了

我們的 myFilter 也遵循這個慣例

for 迴圈底下是 iterator

day 02 的 myForEach 和後面每個手刻函式都會寫 for (element in this),這行不是語言內建的魔法,編譯器會把它展開成三個呼叫

// 你寫的
for (element in this) {
    // ...
}

// 編譯器展開後 (概念上)
val iterator = this.iterator()
while (iterator.hasNext()) {
    val element = iterator.next()
    // ...
}

C# 的 foreach 是同一套機制,只是名字和分工不同

Kotlin C# 做的事
iterator() GetEnumerator() 取得走訪器
hasNext() MoveNext() 還有沒有下一個
next() Current 取出目前這個

Kotlin 是 hasNext() 先問、next() 再取;C# 的 MoveNext() 則是前進兼回報,值要另外從 Current

兩邊也都不強制實作介面。C# 的 foreach 只要型別有 GetEnumerator() 就跑得動,不必實作 IEnumerable;Kotlin 同理,只要有 operator fun iterator() 就好,而且可以用 extension function 補上去

class Countdown(val from: Int)                       // 沒有實作 Iterable

class CountdownIterator(private var current: Int) {  // 沒有實作 Iterator
    operator fun hasNext() = current > 0
    operator fun next() = current--
}

operator fun Countdown.iterator() = CountdownIterator(from)

for (n in Countdown(3)) print(n)   // 321

所以 Iterable<T> 不是 for 的門票,只是 stdlib 用來標示「這東西能走訪」的共同介面。我們把 myFilter 掛在 Iterable<T> 上,是為了跟 stdlib 一致,不是因為 for 需要它

最後補一個最佳化。for (i in 0..9) 不會建立任何 Iterator,也不會產生 IntRange 物件,bytecode 就是一個 iincif_icmpge 的整數迴圈。走訪陣列也有類似的情況。所以「Kotlin 的 for 一定比 Java 的 index 迴圈慢」並不成立,要看 for 的內容是什麼

TDD 實作 myFilter

Red:先寫測試

@Test
fun `filter numbers greater than 3`() {
    val numbers = listOf(1, 2, 3, 4, 5)
    val result = numbers.myFilter { it > 3 }
    assertEquals(listOf(4, 5), result)
}

@Test
fun `filter employees by department`() {
    val result = employees.myFilter { it.department == "Engineering" }
    assertEquals(3, result.size)
    assertTrue(result.all { it.department == "Engineering" })
}

@Test
fun `filter empty list returns empty list`() {
    val empty = emptyList<Int>()
    val result = empty.myFilter { it > 0 }
    assertEquals(emptyList<Int>(), result)
}

@Test
fun `filter where all match returns all`() {
    val numbers = listOf(1, 2, 3)
    val result = numbers.myFilter { it > 0 }
    assertEquals(listOf(1, 2, 3), result)
}

@Test
fun `filter where none match returns empty`() {
    val numbers = listOf(1, 2, 3)
    val result = numbers.myFilter { it > 10 }
    assertEquals(emptyList<Int>(), result)
}

@Test
fun `filter list with null elements`() {
    val numbers: List<Int?> = listOf(1, null, 3, null)
    val result = numbers.myFilter { it != null }
    assertEquals(listOf(1, 3), result)
}

六個測試涵蓋了:基本篩選、Employee 物件篩選、空集合、全部符合、全部不符合、含 null 元素的集合。myFilter 自己不會丟例外,predicate 丟什麼就往外傳什麼,所以例外的情況就用 nullable 元素的案例來補

Green:最小實作

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
}

結構很單純:建一個空的 ArrayList,遍歷每個元素,符合條件就加進去,最後回傳

Refactor:往 stdlib 的寫法靠近

本篇刻意停在這裡。這個版本最該做的重構是加上 inline,留到 day 04 搭配 bytecode 一起做,省了什麼會看得更清楚

與 stdlib 原始碼比較

從這篇開始,每一篇最後都會跟 stdlib 的原始碼比一次,所以先講清楚去哪裡找,以及為什麼要比

原始碼位置:kotlin.collections_Collections.kt。在 IDE 裡對任何一個 stdlib 函式用「跳到宣告 (Go to Declaration)」就會進去,filtermapfold 這些全都住在同一個檔案

打開後往下捲一點,在 package 宣告下面會看到這段

// NOTE: THIS FILE IS AUTO-GENERATED by the GenerateStandardLib.kt
// See: https://github.com/JetBrains/kotlin/tree/master/libraries/stdlib

這段註記是檔案身分的線索。stdlib 裡檔名開頭有底線的都是產生出來的_Collections.kt_Arrays.kt_Sequences.kt 共用同一份樣板,所以裡面的同名函式會長得很像

沒有底線的就是手寫的,Standard.ktSequences.ktMaps.kt 這些都沒有這段註記。後面幾篇比對 Scope Functions 或 Sequence 的 internal class 時,看的就是手寫檔案

不想開 IDE 也有另一條路。官方 API 文件每個函式頁面的每個多載旁邊都有 [source] 連結,點下去會跳到 GitHub 上對應版本的那一行。Iterable<T>.filter 指到的是 libraries/stdlib/common/src/generated/_Collections.kt,路徑裡的 generated/ 剛好也印證了上面講的底線規則

兩條路各有好處。IDE 這條可以接著往下追、看誰呼叫誰;官方文件這條連結綁在版本上、直接定位到行號,要引用或分享時方便,缺點是頁面只列簽名,實作要再點一次 [source] 才看得到

要先說的是,比對的目的不是把自己的版本改到跟它一模一樣。stdlib 要顧的事情比我們多得多:跨平台、二進位相容、各種容器的多載、極端輸入下的效能。我們手刻的版本只要行為正確、自己讀得懂就夠了

真正有價值的是看它「為什麼多做了那幾件事」。這篇的 filterTo 就是第一個例子,後面幾篇還會遇到提前配置容量、用型別判斷走捷徑、避開中間集合這些取捨。看懂取捨背後的理由,比寫出一模一樣的程式碼有用

stdlib 的 filter 長這樣

public inline fun <T> Iterable<T>.filter(predicate: (T) -> Boolean): List<T> {
    return filterTo(ArrayList<T>(), predicate)
}

public inline fun <T, C : MutableCollection<in T>> Iterable<T>.filterTo(
    destination: C, predicate: (T) -> Boolean
): C {
    for (element in this) if (predicate(element)) destination.add(element)
    return destination
}

差了兩個東西

  1. inline 修飾,day 04 講
  2. filterTo 模式,stdlib 把「加到哪個集合」抽成參數,讓使用者可以指定目標集合型別。這個 xxxTo 模式在 stdlib 中反覆出現(mapToflatMapTo 等),後面會再碰到

我們的版本跳過了 filterTo,直接用 ArrayList。先把概念弄懂,後面有需要再加

另外觀察一下,目前的 myFilter 沒加 inline,這代表什麼?

numbers.myFilter { it > 3 }

在 Kotlin 2.4.0 的預設 JVM 設定下,{ it > 3 } 會由 invokedynamic 呼叫點產生 Function1 相容物件,而不是直接在輸出中放一個 Lambda 匿名類別。無捕獲 Lambda 通常可被快取或由 JVM 最佳化,但這是 compiler 與 runtime 的實作細節,不該假設每次呼叫都會建立新物件

捕獲外部狀態的 Lambda 需要保存那些值;捕獲 var 時還要維持可共享修改的狀態。這會限制 JVM 消除物件的空間,但實際是否分配仍要看 bytecode 與 JIT 結果

day 04 加上 inline 之後,Lambda 本體會直接展開到呼叫處。到時候用 bytecode 比較「inline 前後的呼叫結構」,不再用「有沒有匿名類別」當唯一判準

小結

Extension Function 加上泛型,就是手刻 Collection API 需要的全部語法工具。myFilter 的實作不過是 for + if + add,結構上跟 stdlib 沒有本質差異

但目前的版本少了 inline。下一篇就來補上這最後一塊拼圖,順便用 bytecode 證明 inline 到底省掉了什麼

參考資料


Yes


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin Lambda 從零開始 Day 02:Lambda 本質解密 — FunctionN、invokedynamic 與閉包
下一篇
Kotlin Lambda 從零開始 Day 04:inline 關鍵字 — 編譯期展開與使用限制
系列文
Kotlin Lambda 從零開始7
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言