
這篇文章會用 TDD 手刻 mySortedWith 和 myReversed,搞懂 Comparator 的組合方式。上一篇用 sortedBy 做單欄位排序,這篇處理多欄位的情境
| Kotlin | C# LINQ | 備註 |
|---|---|---|
sortedWith(comparator) |
OrderBy().ThenBy() 鏈式 |
C# 用鏈式,Kotlin 用 Comparator 組合 |
compareBy { }.thenBy { } |
同上 | Kotlin 的 Comparator 建構 DSL |
reversed() |
Reverse() |
List<T>.Reverse() 是原地反轉 |
Comparator<T> { a, b -> } |
Comparer<T>.Create(...) |
IComparer<T> 不能直接吃 Lambda |
Comparator.then(other) |
無對應,要自己刻 | C# 的 BCL 沒有 IComparer 組合工具 |
C# 的多欄位排序寫法是 .OrderBy(x => x.Department).ThenBy(x => x.Salary),一路串下去。Kotlin 走不同的路:先組合出一個 Comparator,再一次餵給 sortedWith
day 15 出現過 compareBy 和 compareByDescending,但沒有解釋背後的 Comparator 介面
public fun interface Comparator<T> {
fun compare(a: T, b: T): Int
}
compare(a, b) 的回傳規則跟 Comparable 的 compareTo 一樣:負數代表 a 排前面,正數代表 b 排前面,零代表相等
差別在角色。Comparable 是型別自己實作的(「我知道怎麼跟同類比大小」),Comparator 是外部提供的(「我來告訴你們誰排前面」)
因為 Comparator 是 fun interface(只有一個抽象方法),可以用 Lambda 建立
val byAge = Comparator<Employee> { a, b -> a.age - b.age }
val bySalary = Comparator<Employee> { a, b -> a.salary - b.salary }
不過手寫 Lambda 容易出錯(減法在邊界值可能溢位),實務上用 compareBy 更安全
Kotlin 這邊兩個角色,C# 那邊拆成三個。Comparable<T>.compareTo 對應 IComparable<T>.CompareTo,都是型別自己會比;Comparator<T>.compare 對應 IComparer<T>.Compare,都是外部告訴你怎麼比。多出來的第三個是 Comparison<T> delegate
差別在建立方式。Kotlin 的 Comparator 是 fun interface,上面那兩行直接用 Lambda 就寫得出來。C# 的 IComparer<T> 是普通介面,不能拿 Lambda 賦值,要嘛用 Comparer<T>.Create 包一層(.NET Framework 4.5 起提供),要嘛自己寫一個 class 實作介面
var byAge = Comparer<Employee>.Create((a, b) => a.Age.CompareTo(b.Age));
var bySalary = Comparer<Employee>.Create((a, b) => a.Salary.CompareTo(b.Salary));
C# 這邊減法一樣會溢位,所以改用 CompareTo
能直接吃 Lambda 的是 Comparison<T>。List<T>.Sort 有一個參數是 Comparison<T> 的多載,所以 list.Sort((a, b) => a.Age.CompareTo(b.Age)) 寫得出來。而 Comparer<T>.Create 做的事,就是把 Comparison<T> 轉成 IComparer<T>
@Test
fun `sortedWith comparator by salary`() {
val result = employees.mySortedWith(compareBy { it.salary })
assertEquals("Frank", result[0].name) // 55000
assertEquals("Grace", result.last().name) // 92000
}
@Test
fun `sortedWith multi-field department then salary`() {
val comparator = compareBy<Employee> { it.department }
.thenBy { it.salary }
val result = employees.mySortedWith(comparator)
// Engineering: Bob(72000), Alice(85000), Grace(92000)
assertEquals("Bob", result[0].name)
assertEquals("Alice", result[1].name)
assertEquals("Grace", result[2].name)
// HR: Frank(55000), Eve(60000)
assertEquals("Frank", result[3].name)
assertEquals("Eve", result[4].name)
}
@Test
fun `sortedWith lambda comparator`() {
val result = employees.mySortedWith(Comparator { a, b -> a.age - b.age })
assertEquals("Bob", result[0].name) // age 25
assertEquals("Grace", result.last().name) // age 45
}
@Test
fun `sortedWith empty list returns empty list`() {
val result = emptyList<Employee>().mySortedWith(compareBy { it.salary })
assertEquals(emptyList<Employee>(), result)
}
@Test
fun `sortedWith single element list`() {
val one = listOf(employees[0])
val result = one.mySortedWith(compareBy { it.salary })
assertEquals(one, result)
}
第二個測試是重點。先按部門排(字母序:Engineering < HR < Marketing),部門相同再按薪水排。compareBy 建立第一層比較,.thenBy 接上第二層
後面兩個是邊界案例:空集合排出來還是空集合,單一元素不用排也安全回傳。至於 null,mySortedWith 不像 mySorted 那樣靠泛型約束擋 null — 集合本身可以裝 Employee?,但 null 怎麼排是 Comparator 的責任。實務上會用 nullsFirst() / nullsLast() 包一層,這篇先不展開,重點放在 Comparator 的組合
fun <T> Iterable<T>.mySortedWith(comparator: Comparator<in T>): List<T> {
val list = this.toMutableList()
list.sortWith(comparator)
return list
}
跟 mySorted 結構一模一樣,差在接受一個外部的 Comparator 而不是依賴 T : Comparable<T>
注意參數型別是 Comparator<in T> 而不是 Comparator<T>。in 是型變(variance)修飾,代表這個 Comparator 可以接受 T 的父型別。比方說 Comparator<Any> 能拿來排序 List<Employee>,因為 Any 是 Employee 的父型別。day 34 會完整講型變,這裡先知道有這個東西就好
照 day 15 的手法,用 apply 縮成一行,這也是 stdlib sortedWith 在一般 Iterable 路徑上的寫法
fun <T> Iterable<T>.mySortedWith(comparator: Comparator<in T>): List<T> {
return this.toMutableList().apply { sortWith(comparator) }
}
順便兌現 day 15 留下的承諾。當時 mySortedBy 的 Refactor 刻意停住,等的就是 mySortedWith — 現在它有了,mySortedBy 可以改寫成一行
inline fun <T, R : Comparable<R>> Iterable<T>.mySortedBy(
crossinline selector: (T) -> R?
): List<T> {
return mySortedWith(compareBy(selector))
}
兩個 descending 版本也一起完成。mySortedByDescending 把 compareBy 換成 compareByDescending 就好,mySortedDescending 則是改用 day 15 提過的 reverseOrder(),不用再自己組一個反向 Comparator
fun <T : Comparable<T>> Iterable<T>.mySortedDescending(): List<T> {
return mySortedWith(reverseOrder())
}
inline fun <T, R : Comparable<R>> Iterable<T>.mySortedByDescending(
crossinline selector: (T) -> R?
): List<T> {
return mySortedWith(compareByDescending(selector))
}
整個排序家族最後都收斂到 mySortedWith 這一個入口,等一下比較 stdlib 原始碼時會看到一樣的結構
compareBy 和 thenBy 不是我們手刻的(它們是建立 Comparator 的工具函式,不是 Collection 操作),但要理解它們怎麼運作
// 單欄位
val byDept = compareBy<Employee> { it.department }
// 多欄位:先部門,再薪水
val byDeptThenSalary = compareBy<Employee> { it.department }
.thenBy { it.salary }
// 混合升降序:先部門升序,再薪水降序
val mixed = compareBy<Employee> { it.department }
.thenByDescending { it.salary }
thenBy 回傳一個新的 Comparator,它的邏輯是:先用前一個 Comparator 比,如果相等(回傳 0)才用 thenBy 的 selector 比。這就是「多欄位排序」的本質:一連串的 fallback
stdlib 的 thenBy 就是這樣做的,它先呼叫前一個 Comparator,只有結果為 0 才用 selector 決勝負
public inline fun <T> Comparator<T>.thenBy(crossinline selector: (T) -> Comparable<*>?): Comparator<T> =
Comparator { a, b ->
val previousCompare = this@thenBy.compare(a, b)
if (previousCompare != 0) previousCompare else compareValuesBy(a, b, selector)
}
this@thenBy.compare(a, b) 就是「前一個現成的 Comparator」。所以它跟下面這個 then 的組合方式幾乎是同一件事,只是 thenBy 收的是 selector、then 收的是另一個 Comparator
// 邏輯等同的 then 組合
fun <T> Comparator<T>.then(other: Comparator<T>): Comparator<T> {
return Comparator { a, b ->
val result = this.compare(a, b)
if (result != 0) result else other.compare(a, b)
}
}
前一個比出結果就用前一個,比不出(等於 0)就往下一個問。可以一直串下去,想比幾個欄位就串幾個
Kotlin 的 compareBy<Employee> { it.department }.thenBy { it.salary } 是一個獨立物件,跟資料無關。可以存進變數、當參數傳來傳去
C# 的 ThenBy 不是這樣。它是 IOrderedEnumerable<T> 的 extension method,必須先有一個被 OrderBy 或 OrderByDescending 排過的序列,它才存在。沒辦法脫離資料,把 OrderBy().ThenBy() 存成一個「比較器」變數
想要一個可重用的多欄位比較器,得自己組
var byDeptThenSalary = Comparer<Employee>.Create((a, b) =>
{
var r = string.CompareOrdinal(a.Department, b.Department);
return r != 0 ? r : a.Salary.CompareTo(b.Salary);
});
list.Sort(byDeptThenSalary); // 原地排序,不回傳新 list
var sorted = list.OrderBy(x => x, byDeptThenSalary); // 這個才對應 sortedWith
上面那個 then,Kotlin stdlib 本來就有現成的;C# 的 BCL 沒有,得自己刻。而且 extension method 在 C# 只能放在 static class 裡,不像 Kotlin 可以直接寫成 top-level 函式
public static class ComparerExtensions
{
public static IComparer<T> Then<T>(this IComparer<T> first, IComparer<T> second) =>
Comparer<T>.Create((a, b) =>
{
var r = first.Compare(a, b);
return r != 0 ? r : second.Compare(a, b);
});
}
日常寫 LINQ 查詢時 OrderBy().ThenBy() 很順,但只要想把排序規則抽出來共用,就得繞這一段路
真要挑一個對應 sortedWith 的,是 OrderBy(x => x, comparer),一樣回傳新序列、一樣 stable。List<T>.Sort 和 Array.Sort 雖然也收 IComparer<T>,但它們是原地排序,比較像 Kotlin 的 sortWith。另外 OrderBy 平常那個 OrderBy(x => x.Salary, comparer) 的用法收的是 IComparer<TKey>,比的是 selector 取出來的鍵,跟這裡比整個元素的 IComparer<T> 不是同一層
還有兩個從 C# 過來容易誤判的地方。LINQ 的 OrderBy 是 stable sort,List<T>.Sort 卻是 unstable(introsort),Kotlin 的 sortedWith 在 JVM 上走 TimSort 是 stable。多欄位排序如果從 LINQ 改用 List.Sort,相等元素的原始順序就不保證了
另一個是字串。C# 的 OrderBy(x => x.Department) 用 Comparer<string>.Default,是 culture-sensitive(走 CurrentCulture);Kotlin 的 compareBy { it.department } 底層是 String.compareTo,比的是 UTF-16 code unit,屬於 ordinal。同一份資料兩邊可能排出不一樣的結果,C# 要 ordinal 得明講 StringComparer.Ordinal
@Test
fun `reversed list`() {
val numbers = listOf(1, 2, 3, 4, 5)
val result = numbers.myReversed()
assertEquals(listOf(5, 4, 3, 2, 1), result)
}
@Test
fun `reversed does not modify original`() {
val numbers = listOf(1, 2, 3)
val result = numbers.myReversed()
assertEquals(listOf(3, 2, 1), result)
assertEquals(listOf(1, 2, 3), numbers)
}
@Test
fun `reversed empty list returns empty list`() {
assertEquals(emptyList<Int>(), emptyList<Int>().myReversed())
}
@Test
fun `reversed single element list`() {
assertEquals(listOf(42), listOf(42).myReversed())
}
空集合反轉還是空集合,單一元素反轉等於自己 — 兩個邊界都該安全通過。myReversed 不挑元素型別,也沒有 null 相關的特殊行為要測,純粹把順序倒過來
fun <T> Iterable<T>.myReversed(): List<T> {
val list = this.toMutableList()
list.reverse()
return list
}
reversed() 回傳新 List,reverse() 原地反轉。又是 day 15 講的過去分詞 vs 動詞原形的命名規則
這三行已經是 stdlib reversed() 的形狀 — 複製一份、原地反轉、回傳副本,stdlib 只多了一個「元素不超過一個就直接回傳」的捷徑。沒什麼好再動的,直接收工
stdlib 還有一個 asReversed(),行為不一樣
val original = mutableListOf(1, 2, 3)
val reversed = original.reversed() // 複製一份再反轉
val asReversed = original.asReversed() // 建立反向的 view
original.add(4)
println(reversed) // [3, 2, 1] 不受影響
println(asReversed) // [4, 3, 2, 1] 跟著變了!
reversed() 是完整複製,之後兩邊互不相干。asReversed() 是 view,底層還是同一份資料,原始 List 改了 view 也跟著動。效能好但要小心副作用
stdlib 把 view 跟 copy 都提供出來,是「讓使用者選」的設計哲學
C# LINQ 的 Reverse() 是 deferred execution 的 IEnumerable 操作,但要小心:它不是「邊走邊反向」。一旦開始疊代,它會先把整個來源序列緩衝到內部陣列,再從尾巴往前 yield。反轉本身就需要一份完整的 buffer,跟 Kotlin 的 reversed() 一樣得先有全部資料才能反過來。Java 的 Collections.reverse(list) 則是原地反轉。它收的是 java.util.List,Java 沒有唯讀清單這個型別、傳唯讀清單進去要到執行期才丟 UnsupportedOperationException;從 Kotlin 呼叫時型別系統會先擋下來,只能傳 MutableList
Reverse 這個名字在 C# 還藏了一個陷阱
var list = new List<int> { 1, 2, 3 };
list.Reverse(); // List<T> 自己的方法,原地反轉,回傳 void
var r = list.AsEnumerable().Reverse(); // LINQ extension method,回傳新序列
同一個名字扛了兩種行為,只能靠「是不是 extension method」分辨。而多載解析會讓型別自己的方法優先勝出,在 List<T> 上直接寫 .Reverse() 拿到的一定是原地反轉那個。Kotlin 用 reversed / reverse 兩個名字把這件事講明白,就是 day 15 那條過去分詞 vs 動詞原形的規則。另外 C# 也沒有 asReversed() 這種共享 view 的東西
Kotlin 走「明確區分」這條路。reversed() = 完整複製,asReversed() = 共享 view。許多 asXxx() 函式不會複製元素,而是回傳原物件或一個輕量 wrapper,例如 asSequence()、asReversed()、asIterable()。但這是各 API 的行為,不能只靠 as 前綴推定所有函式都零配置
看到 asXxx() 時,可以先預期它不會複製全部元素,再查該函式文件確認是否建立 wrapper、是否共享底層資料
原始碼位置:kotlin.collections 的 _Collections.kt、kotlin.comparisons 的 Comparisons.kt
public fun <T> Iterable<T>.sortedWith(comparator: Comparator<in T>): List<T> {
if (this is Collection) {
if (size <= 1) return this.toList()
@Suppress("UNCHECKED_CAST")
return (toTypedArray<Any?>() as Array<T>).apply { sortWith(comparator) }.asList()
}
return toMutableList().apply { sortWith(comparator) }
}
跟 day 15 看到的一樣。如果是 Collection 且只有 0 或 1 個元素,跳過排序直接回傳。否則轉成陣列排序(JVM 上陣列排序比 List 快)
其實 day 15 的 sortedBy 底層就是呼叫 sortedWith(compareBy(selector))。整個排序體系的終點都是 sortedWith,只是語法糖的層次不同
sortedBy { it.salary }
→ sortedWith(compareBy { it.salary })
→ toMutableList().sortWith(comparator)
→ java.util.Arrays.sort() (TimSort)
排序篇到這裡結束。兩篇走過的路線
sortedBy(單欄位,靠 Comparable) → sortedWith(多欄位,靠 Comparator 組合)
泛型約束也從 T : Comparable<T>(型別自己會比)演進到 Comparator<in T>(外部告訴你怎麼比)。這兩種模式在 Java/Kotlin 世界裡到處都是
| 手刻函式 | 篇號 | 用途 |
|---|---|---|
mySorted |
day 15 | 依自然順序排序,要求 T : Comparable<T> |
mySortedDescending |
day 15 | 依自然順序反向排序 |
mySortedBy |
day 15 | 依 selector 取出的鍵排序 |
mySortedByDescending |
day 15 | 依 selector 取出的鍵反向排序 |
mySortedWith |
day 16 | 依外部傳入的 Comparator 排序,撐起多欄位排序 |
myReversed |
day 16 | 複製一份再反轉順序 |
排序篇兩篇手刻了六個函式,把「型別自己會比」和「外部告訴你怎麼比」兩種排序模式都走過一遍
下一篇進入聚合篇。day 17 從 fold 開始,Lambda 的簽名會變成 (acc, element) -> acc,帶著累加器一路滾下去
同步刊登於 Blog
圖片來源:AI 產生