1. 前言
想像一下你有一個學生清單,你要先按年齡排序,再按姓氏,然後再按平均分數,但只針對那些通過所有考試的人。如果每次這種「一次性」的比較都要寫一個新的 IComparer<T> 類別,你的專案很快就會變成一堆小比較器類別的大雜燴。這樣很麻煩,程式碼又冗長又難讀。
這種情況下,C# 給了我們一個更優雅的解法:可以直接把比較邏輯傳給 Sort 方法,不用另外寫類別。
這時我們就會用到 委派 和它的精簡兄弟——lambda 表達式。
委派——我們的彈性小幫手
在我們進入 lambda 之前,先搞懂什麼是 委派。簡單說,委派就是一種可以存放方法參考的型別。聽起來有點玄?這樣想吧:
想像你有一張待辦清單,有些項目是「指令」或「食譜」。委派就像一個特別的變數,可以存一個「食譜」(方法)的參考。然後你要做這件事時,只要呼叫這個委派變數,它就會「叫」那個方法。
在 C# 裡,委派常用來做 callback(之後再呼叫方法,通常是事件反應)、事件處理(像是按鈕點擊),當然也可以把方法當參數傳給其他方法(讓方法可以呼叫另一個方法),這正是我們現在排序要用的。
List<T>.Sort() 這個方法有好幾個 overload(版本),其中一個就是吃一個叫 Comparison<T> 的委派。
2. Comparison<T> 委派
什麼是 Comparison<T>?
Comparison<T> 是 .NET 內建的委派,專門拿來比較兩個同型別 T 的物件。它的「食譜」長這樣:吃進兩個 T 型別的物件(我們叫他們 x 和 y),回傳一個整數(int):
- 負數(例如 -1),如果 x「小於」y。
- 零(0),如果 x「等於」y。
- 正數(例如 1),如果 x「大於」y。
這套規則跟 IComparable.CompareTo 和 IComparer.Compare 一樣。也就是說,邏輯一樣,只是現在我們可以把它當「方法變數」傳,不用寫一個類別。
來看個例子,回到我們的學生。如果有個 Student 類別:
public class Student
{
public string FirstName { get; set; }
public string LastName { get; set; }
public int Age { get; set; }
public double AverageGrade { get; set; }
public Student(string firstName, string lastName, int age, double averageGrade)
{
FirstName = firstName;
LastName = lastName;
Age = age;
AverageGrade = averageGrade;
}
public void PrintInfo()
{
Console.WriteLine($"學生: {FirstName} {LastName}, 年齡: {Age}, 分數: {AverageGrade:F2}");
}
}
現在要用委派按年齡排序學生清單,我們可以寫一個靜態方法,符合 Comparison<Student> 的簽名:
public class Program
{
// 這個方法符合 Comparison<Student> 委派的簽名
// 它會比較兩個學生的年齡
public static int CompareStudentsByAge(Student student1, Student student2)
{
// 用內建的 CompareTo 方法,
// 會根據比較結果回傳 -1, 0 或 1。
return student1.Age.CompareTo(student2.Age);
}
public static void Main(string[] args)
{
List<Student> students = new List<Student>
{
new Student("伊凡", "彼得羅夫", 20, 4.5),
new Student("瑪麗亞", "西多羅娃", 22, 4.8),
new Student("阿列克謝", "伊萬諾夫", 19, 3.9),
new Student("葉蓮娜", "科茲洛娃", 20, 4.2) // 兩個學生同年齡
};
Console.WriteLine("--- 排序前的學生清單 ---");
foreach (var s in students)
s.PrintInfo();
Console.WriteLine("--- 用委派按年齡排序學生 ---");
students.Sort(CompareStudentsByAge); //把 CompareStudentsByAge 方法當參數傳進去
foreach (var s in students)
s.PrintInfo();
}
}
來拆解一下:
- 我們寫了一個靜態方法 CompareStudentsByAge,吃兩個學生,回傳 int,符合 Comparison<Student> 的規則。
- 在 Main 裡建立學生清單。
- 呼叫 students.Sort(CompareStudentsByAge); 時,我們不是馬上執行 CompareStudentsByAge(),而是傳遞方法參考。List<T>.Sort() 會自己多次呼叫我們的方法,傳不同的學生組合進去。這就像你給人家一個送貨地址,而不是直接把整台卡車開過去。
這種寫法比每次都寫一個比較器類別方便多了。但還可以更精簡!
3. 來認識 Lambda 表達式
就算寫一個像 CompareStudentsByAge 這樣的獨立方法,有時也嫌多餘,尤其比較邏輯很簡單、只用一兩次。這時 C# 就有 lambda 表達式(lambda expressions)。
什麼是 lambda 表達式?其實就是匿名方法,或者我喜歡開玩笑說「無家可歸的方法」。你可以直接在需要的地方寫一小段方法,不用另外宣告。就像直接寫個便條貼貼在任務上,而不是寫一本說明書。
lambda 表達式的主要符號是 =>(唸作「箭頭」或「轉到」)。它把參數和方法主體分開。
lambda 表達式的基本語法
假設你有個委派(方法參考)要傳給 Sort():
public static int CompareStudentsByAge(Student student1, Student student2)
{
return student1.Age.CompareTo(student2.Age);
}
students.Sort(CompareStudentsByAge); //把 CompareStudentsByAge 方法當參數傳進去
其實可以更短:
//直接把匿名方法當參數傳進去
students.Sort( (Student student1, Student student2) => student1.Age.CompareTo(student2.Age) );
這裡我們沒寫方法名,只寫了兩個重點:
- 參數:(Student student1, Student student2)
- 方法內容:student1.Age.CompareTo(student2.Age)
這種精簡寫法就叫 lambda 表達式:(參數) => 表達式
它怎麼運作
C# 編譯器遇到 lambda 表達式時,會自動幫你生出一個真正的方法。
假設你有這樣的程式碼:
students.Sort( (s1, s2) => s2.AverageGrade.CompareTo(s1.AverageGrade) );
編譯結果大概會像這樣:
public static int CompareStudents_Lambda123(Student s1, Student s2)
{
return s2.AverageGrade.CompareTo(s1.AverageGrade);
}
students.Sort( CompareStudents_Lambda123 );
4. 排序範例與 lambda 表達式
我們把學生的例子改寫成 lambda 表達式版本:
public class Program
{
public static void Main(string[] args)
{
List<Student> students = new List<Student>
{
new Student("伊凡", "彼得羅夫", 20, 4.5),
new Student("瑪麗亞", "西多羅娃", 22, 4.8),
new Student("阿列克謝", "伊萬諾夫", 19, 3.9),
new Student("葉蓮娜", "科茲洛娃", 20, 4.2)
};
Console.WriteLine("--- 排序前的學生清單 ---");
foreach (var s in students)
s.PrintInfo();
// 現在比較邏輯直接寫在這裡,「就地」完成
Console.WriteLine("--- 用 lambda 表達式按年齡排序學生 ---");
students.Sort((student1, student2) => student1.Age.CompareTo(student2.Age));
foreach (var s in students)
s.PrintInfo();
// 要按分數遞減排序,只要把結果乘 -1
Console.WriteLine("\n--- 用平均分數遞減排序學生 ---");
// s2.CompareTo(s1) 取代 s1.CompareTo(s2)
students.Sort((s1, s2) => s2.AverageGrade.CompareTo(s1.AverageGrade));
foreach (var s in students)
s.PrintInfo();
}
}
這裡發生了什麼?
- students.Sort((student1, student2) => student1.Age.CompareTo(student2.Age));
- student1 和 student2 是 Sort 會傳給我們匿名方法的參數(就像 x 和 y 在 Comparison<T>)。
- => 是 lambda 運算子。
- student1.Age.CompareTo(student2.Age) 是 lambda 的主體。這裡只有一個表達式,結果就是回傳值。
- 要按平均分數遞減排序,只要把 s1 和 s2 在 CompareTo 裡對調。這是反轉排序順序的經典招。
為什麼這麼方便?
- 精簡: 不用為每個小比較邏輯寫方法或類別。
- 可讀性: 比較邏輯就在 Sort() 旁邊,簡單明瞭,特別適合簡單情境。
- 彈性: 可以隨時「現場」改排序條件。
5. 委派與 Lambda——絕配
你可能會問:lambda 表達式跟委派是一樣的東西嗎?
其實 lambda 表達式只是語法糖(syntax sugar),用來建立委派實例或表達式樹(Expression Tree,這以後再說)。編譯器看到 lambda,就會自動把它變成對應的委派實例。我們這裡 List<T>.Sort() 需要 Comparison<T>,所以 (student1, student2) => student1.Age.CompareTo(student2.Age) 會被轉成 Comparison<Student>。
所以 lambda 讓我們寫出超精簡的程式碼,而委派就是那個「容器」把這段程式碼帶著走、執行。他們是最佳拍檔!
什麼時候用哪個?
- IComparable<T>:當你的型別有自然、明顯的排序方式時用。例如商品主要用貨號排序。這個介面定義「預設」順序。
- IComparer<T>:當你需要多次、可重用的比較邏輯,但又不想「污染」主類別,或有多種排序方式時用。例如一個 IComparer 按價格,一個按名稱,在不同地方用。
- 委派(Comparison<T>)和 Lambda: 最適合一次性、臨時的排序,邏輯簡單不需要獨立類別。這是 C# 排序最常見、最乾淨的寫法。也很適合傳遞邏輯給其他方法,例如過濾(Find, FindAll)或搜尋(FindIndex)等方法(我們之前有學過)。
| 特點 | IComparable<T> | IComparer<T> | Comparison<T> / Lambda 表達式 |
|---|---|---|---|
| 定義在哪? | 在類別 T 裡 | 在獨立比較器類別 | 可以是方法或匿名表達式 |
| 彈性 | 固定「自然」順序 | 多種、可重用順序 | 臨時(現場)、針對單次方法呼叫 |
| 樣板程式 | 少,寫在類別裡 | 中等(獨立類別) | 最少(lambda 最精簡) |
| 使用範例 | |
|
|
| 可讀性 | 適合自然順序 | 看比較器名稱而定 | 簡單、明確,適合特定比較 |
6. 實務應用與未來展望
lambda 表達式不只是排序的「語法糖」。它是現代 C# 程式碼裡超強大的工具,你會常常遇到:
- 在 LINQ(Language Integrated Query): 這大概是 lambda 最常見的應用。LINQ 讓你寫 SQL 風格的查詢,lambda 用來定義過濾、排序、資料投影等條件。我們很快就會學 LINQ,到時你會看到 lambda 有多強大又方便。
- 事件處理: lambda 可以很簡潔地描述事件發生時要做什麼(例如 UI 按鈕點擊)。
- 非同步程式: 用來定義要平行執行的任務。
- 各種 .NET API: 很多 .NET 標準函式庫的方法都吃委派(也就是 lambda),讓你可以加上彈性的邏輯。
所以學會 lambda,不只讓你排序更強,還能幫你理解現代 C# 程式和函式庫。這是每個面試都會加分、每個專案都用得到的技能!
7. 常見錯誤與細節
用委派和 lambda 做比較時,有幾個重點要注意:
比較結果錯誤: 記得 CompareTo 或你的比較邏輯一定要回傳負數、零或正數。如果你不小心回傳了別的,排序可能會亂掉甚至出錯。最常見的新手錯誤是回傳 true 或 false,而不是 int。Sort 需要數字結果,因為它不只要知道元素是否相等,還要知道誰「大」。
處理 null 值: 如果你的集合裡可能有 null,那對 null 物件呼叫方法(像 student1.Age.CompareTo(...),如果 student1 是 null)會噴 NullReferenceException。這時你的比較邏輯要明確處理 null。一般規則是 null 算「小於」任何非 null。如果兩個都是 null,就相等。如果一個 null,另一個不是,null「小」。
// lambda 比較時處理 null 的範例
students.Sort((s1, s2) => {
if (s1 == null && s2 == null) return 0;
if (s1 == null) return -1; // null 最小
if (s2 == null) return 1; // 非 null 比 null 大
return s1.Age.CompareTo(s2.Age); // 兩個都不是 null 就比較
});
幸好,實務上集合很少有 null,但這點還是很重要!
效能: 雖然 lambda 超方便,但如果你在很「熱」的迴圈或超大集合裡狂用,效能可能會比經過優化的 IComparer 類別差一點點。不過大多數情況下差異很小,精簡和可讀性才是王道。
複雜的多層比較: 像我們之前按姓氏再按名字排序的例子,lambda 可以很方便地寫多層條件。這比寫一堆 if 還要好!重點是先判斷第一層比較結果(lastNameComparison != 0),再進下一層。
lambda 和委派是 C# 的基礎觀念,讓你寫出更彈性、更函數式的程式。學會用它們,你的程式會更乾淨、有效率又現代。多多練習,很快你就會用得很順手啦!
GO TO FULL VERSION