1. 前言
為什麼比較器真的很重要…
在真實專案裡,事情沒那麼簡單:物件很複雜,資料來自用戶或其他服務,排序規則(甚至等於的規則)常常會根據需求變來變去。比較器讓你的程式碼更靈活,行為也更可預期、可控。
在 .NET 裡,只要你要排序、查找、分組或排除自訂類別物件的重複,甚至要建立有順序的資料結構時,都會用到比較器。這在集合、演算法、還有跟外部 API 整合時超常見。
比較器在 .NET 裡會出現在哪:
- 集合排序(List<T>.Sort、Array.Sort、OrderBy in LINQ)
- 有順序的資料結構(SortedSet<T>、SortedDictionary<TKey, TValue>)
- 在集合裡查找和比較物件(Contains、IndexOf — 當你要判斷「相等」而不只是順序時)
- 分組、過濾和去重(像是 .Distinct())
實際上為什麼要這樣做?
- 報表需要排序輸出(例如學生名單按字母或平均分數排序)
- 把輸入資料跟標準值比對(像是根據某個條件查找紀錄)
- 省記憶體和加快速度(選對資料結構會讓查找更快,程式更順)
- 驗證唯一性(像是註冊時用 e-mail 當唯一鍵)
2. 生活中的例子:多種排序條件
假設我們有一個 User 類別:
public class User
{
public string FirstName { get; set; } = "";
public string LastName { get; set; } = "";
public string Email { get; set; } = "";
public int Age { get; set; }
// 可以加 override Equals 和 GetHashCode — 這個留給你回家看
}
我們有一個使用者清單,想要:
- 按照姓排序,如果姓一樣就比名字。
- 可以用 e-mail 查找使用者(不分大小寫)。
- 新增時排除重複的使用者。
用 IComparer<T> 排序
方法一:經典寫法 — 寫一個獨立的比較器。
// 按姓和名排序的比較器
public class UserFullNameComparer : IComparer<User>
{
public int Compare(User? x, User? y)
{
if (ReferenceEquals(x, y)) return 0;
if (x is null) return -1;
if (y is null) return 1;
int lastNameComparison = StringComparer.OrdinalIgnoreCase.Compare(x.LastName, y.LastName);
if (lastNameComparison != 0)
return lastNameComparison;
return StringComparer.OrdinalIgnoreCase.Compare(x.FirstName, y.FirstName);
}
}
用法:
var users = new List<User>
{
new User { FirstName = "伊凡", LastName = "彼得羅夫", Age = 20, Email = "ivan.petrov@email.com" },
new User { FirstName = "安娜", LastName = "斯米爾諾娃", Age = 22, Email = "anna.smirnova@email.com" },
new User { FirstName = "彼得", LastName = "彼得羅夫", Age = 18, Email = "petr.petrov@email.com" }
};
users.Sort(new UserFullNameComparer());
foreach (var user in users)
{
Console.WriteLine($"{user.LastName} {user.FirstName} ({user.Age})");
}
// 結果 — 使用者會按姓排序,如果姓一樣就比名字。
小提醒: 如果排序規則在多個地方會用到,而且 lambda 已經救不了你時,就這樣寫。
用 lambda「即時」排序
不想為了一次性的排序寫一堆 class?lambda 來救你!
users.Sort((x, y) =>
{
int lastNameComparison = StringComparer.OrdinalIgnoreCase.Compare(x.LastName, y.LastName);
if (lastNameComparison != 0)
return lastNameComparison;
return StringComparer.OrdinalIgnoreCase.Compare(x.FirstName, y.FirstName);
});
效果一樣,但比較器直接寫在呼叫裡。省行數,但不適合重複用。
3. 巧妙查找:e-mail 不分大小寫比較
現實裡用戶怎麼輸入 e-mail 都有,你是工程師不是法官,所以比對 e-mail 當然要不分大小寫。
我們用比較器和查找來實現:
public class EmailComparer : IEqualityComparer<User>
{
public bool Equals(User? x, User? y)
{
if (ReferenceEquals(x, y)) return true;
if (x is null || y is null) return false;
return string.Equals(x.Email, y.Email, StringComparison.OrdinalIgnoreCase);
}
public int GetHashCode(User obj)
{
return obj.Email?.ToLowerInvariant().GetHashCode() ?? 0;
}
}
在 HashSet 裡用法:
var usersSet = new HashSet<User>(new EmailComparer());
usersSet.Add(new User { Email = "Petrov@example.com" });
bool contains = usersSet.Contains(new User { Email = "petrov@example.com" }); // true!
重要提醒: 自己寫 EqualityComparer 時,兩個方法都要實作:Equals 和 GetHashCode。忘了第二個,集合查找會怪怪的。
4. SortedSet 和 SortedDictionary 的應用
這裡比較器的威力就全開啦。
SortedSet<T> 和 SortedDictionary<TKey, TValue> 如果你沒告訴 .NET 怎麼比,根本沒法用你的物件。比較和相等的邏輯會直接影響哪些元素算不同!
SortedSet<User> 的例子
var sortedUsersByFullName = new SortedSet<User>(new UserFullNameComparer())
{
new User { FirstName = "伊凡", LastName = "彼得羅夫", Age = 20 },
new User { FirstName = "安娜", LastName = "斯米爾諾娃", Age = 22 },
new User { FirstName = "彼得", LastName = "彼得羅夫", Age = 18 },
new User { FirstName = "伊凡", LastName = "彼得羅夫", Age = 25 } // 姓名一樣但年齡不同的重複
};
// "伊凡 彼得羅夫" 不管年齡,只會有一個進集合
Console.WriteLine("SortedSet 裡的使用者:");
foreach (var user in sortedUsersByFullName)
Console.WriteLine($"{user.LastName} {user.FirstName} ({user.Age})");
SortedSet 不會加兩個「伊凡 彼得羅夫」!
重點: 這裡比較器決定「唯一性」的邏輯。如果你只比姓和名,年齡不同但同名同姓的使用者會被當成同一個。
5. 表格:.NET 裡什麼時候用哪種比較器
| 情境 | 要實作什麼 | 用法範例 |
|---|---|---|
| 自然排序(類型的唯一排序規則) | |
|
| 多種排序方式(按名字、年齡、e-mail ...) | (class、lambda) |
|
| 在集合裡找唯一元素 | |
、 |
| 分組、去重 | |
LINQ |
| 按時間、日期或複雜欄位組合排序 | 、即時 lambda |
|
| LINQ 裡用(一次性查詢) | 在 、 裡寫 lambda |
|
6. 實作練習:在集合裡查找和比較
來寫一個註冊功能,e-mail 唯一(不分大小寫)。如果已經有這個 e-mail,要提示用戶。
public static bool RegisterUser(List<User> users, User newUser)
{
// 用 Any + lambda 查唯一 e-mail
bool exists = users.Any(u =>
string.Equals(u.Email, newUser.Email, StringComparison.OrdinalIgnoreCase));
if (exists)
{
Console.WriteLine($"已經有這個 e-mail: {newUser.Email} 的使用者了!");
return false;
}
users.Add(newUser);
Console.WriteLine($"使用者 {newUser.FirstName} 已新增。");
return true;
}
用法:
var userList = new List<User>
{
new User { Email = "first@example.com" }
};
RegisterUser(userList, new User { FirstName = "瓦西亞", Email = "FIRST@example.com" });
// 會顯示:「已經有這個 e-mail: FIRST@example.com 的使用者了!」
7. 用比較器時常見錯誤
大家都愛錯誤清單,但我們來點有畫面感的。
有時候工程師寫自訂型別的比較器,會忘了檢查 null,更慘的是比較邏輯寫錯,導致「順序嚴格性」被破壞。像是比較器回傳矛盾的值,排序就會亂掉,集合裡的物件會消失或「黏」在一起。
還有一個常見誤區,就是 Equals 跟比較器的邏輯不一致。比如 Equals 認為物件不同,但比較器覺得一樣,這樣 SortedSet 或 SortedDictionary 就會亂:明明有這個元素卻找不到。
還有一種情況是只比物件的一個屬性(比如只比姓),但其實同姓的人很多。結果就是物件被「覆蓋」掉、消失,資料就不一致了。也就是說,資料已經不反映真實狀態了 — 會有重複、遺失或邏輯錯亂。
GO TO FULL VERSION