1. 前言
想像一下,你請朋友幫你把書放到書架上。如果這些書的書脊上有編號(1、2、3…),你朋友一定沒問題:「OK,1 在 2 前面,2 在 3 前面。」這就是自然順序(照順序)。同樣,如果書名是「A」、「B」、「C」開頭,他也會照字母順序排。這也是字串的「自然」順序(字母排序)。
但如果書脊上只寫了作者的姓?你說:「幫我排一下順序。」你朋友會問:「哪種順序?按姓?按出版年?還是頁數?」這時候就有問題了。對於你自訂的東西,沒有明顯的自然順序。
寫程式也是一樣。我們要排序一個整數 list List<int>,C# 很清楚怎麼做。對 List<string> 也沒問題,會用字母順序。但如果你有 List<Student>,每個 Student 是一個物件,裡面有名字、姓氏、年齡、ID 還有一堆東西,C# 就傻眼了。他不知道要比哪個欄位。名字?ID?平均分數?這就是我們在「集合排序」那堂課遇到的問題,當你對 List<T>.Sort() 嘗試排序自訂型別時,會直接噴錯。
要解這個謎題,我們得給 C# 一個明確的指示,告訴他要怎麼比我們的物件。這時候就要用 IComparable<T> 這個介面啦。
2. 介面 IComparable<T>
所以,為了讓我們的物件「知道」自己跟其他同類的順序,我們會用 IComparable<T> 這個介面。這是一個合約。當你的 class 或 struct 實作這個介面時,你就像跟編譯器說:「嗨!我的物件可以互相比較,這裡有說明怎麼比。」
怎麼運作?
介面 IComparable<T> 只定義了一個方法:
public interface IComparable<in T>
{
int CompareTo(T other);
}
這個方法會接收一個 T 型別的物件(跟自己同型別),然後要回傳:
- 負數(< 0),如果目前這個物件「小於」被比較的那個;
- 零(0),如果它們「相等」(以排序來看);
- 正數(> 0),如果目前這個物件「大於」對方。
其實這就像比賽的裁判,比如拳擊賽:你表現比較好就贏這回合,分數比較高;表現差就分數低;如果打平就一樣分。這裡更簡單:只要一個有正負號的數字。
為什麼要這樣?
排序的方法(像 List<T>.Sort())會呼叫 CompareTo 來比 list 裡的元素,決定誰要排前面誰要排後面。如果你的 class 有實作這個介面——它就可以被排序啦!
3. 實作練習
假設我們有這樣一個使用者 class(User):
public class User
{
public string Name { get; set; }
public int Age { get; set; }
}
來試試看排序使用者 list:
List<User> users = new List<User>
{
new User { Name = "謝爾蓋", Age = 31 },
new User { Name = "瑪麗亞", Age = 22 },
new User { Name = "安東", Age = 27 }
};
users.Sort(); // 砰!InvalidOperationException
會出現錯誤:"At least one object must implement IComparable"(至少有一個物件要實作 IComparable)。
修正:實作 IComparable<User>
加上介面到我們的 class。先讓排序照年齡——從年輕到年長:
public class User : IComparable<User>
{
public string Name { get; set; }
public int Age { get; set; }
public int CompareTo(User other)
{
// 防呆:如果 other == null,自己「比較大」
if (other == null) return 1;
return this.Age.CompareTo(other.Age); // 按年齡排序
}
}
現在組一個 list,呼叫 users.Sort();,然後把結果印出來:
List<User> users = new List<User>
{
new User { Name = "謝爾蓋", Age = 31 },
new User { Name = "瑪麗亞", Age = 22 },
new User { Name = "安東", Age = 27 }
};
// 按年齡排序(用 CompareTo)
users.Sort();
// 印出排序後的使用者
foreach (User user in users)
{
Console.WriteLine($"{user.Name}, {user.Age}");
}
list 會照年齡排序:
瑪麗亞, 22
安東, 27
謝爾蓋, 31
視覺化:排序前後
| 名字 | 年齡 |
|---|---|
| 謝爾蓋 | 31 |
| 瑪麗亞 | 22 |
| 安東 | 27 |
排序後:
| 名字 | 年齡 |
|---|---|
| 瑪麗亞 | 22 |
| 安東 | 27 |
| 謝爾蓋 | 31 |
4. 重要細節與常見錯誤
防範 null
在 CompareTo 裡面很重要的一點是要檢查 other 有沒有等於 null。如果沒注意這點,物件可能會噴 NullReferenceException——這種 bug 跟發佈前的 bug 一樣陰險。通常如果被比較的物件是 null,大家都會認為目前這個「比較大」:
public int CompareTo(User other)
{
if (other == null) return 1;
// ...
}
要遵守傳遞性
如果 A < B,B < C,那 A 一定要 < C。如果不遵守這個規則,排序會亂七八糟(雖然很有趣,但不對啦!)。
如果要根據多個欄位排序
假設要先按年齡排,如果兩個人同年齡,再按名字字母順序。可以這樣寫:
public int CompareTo(User other)
{
if (other == null) return 1;
int ageCompare = this.Age.CompareTo(other.Age);
if (ageCompare != 0) return ageCompare;
// 如果年齡一樣——比名字
return this.Name.CompareTo(other.Name);
}
5. 排序:你的物件也可以!
List<int> 會的事,你的 class 也會了
現在你可以用任何需要比較的方法:Sort、BinarySearch,甚至插入到排序集合(像 SortedSet<T>)。
users.Sort();
// users 現在已經照年齡(同年齡再比名字)排序
課程應用程式裡的例子
假設你之前已經寫過一個管理使用者的 app。現在我們可以直接在現有程式碼裡加上「自然」排序。會長這樣:
// 我們的 User class 已經實作 IComparable<User>
List<User> users = new List<User>
{
new User { Name = "伊萬", Age = 45 },
new User { Name = "加琳娜", Age = 27 },
new User { Name = "尤里", Age = 27 }
};
// 先按年齡,再按名字排序
users.Sort();
foreach (var u in users)
{
Console.WriteLine($"{u.Name} - {u.Age}");
}
結果:
加琳娜 - 27
尤里 - 27
伊萬 - 45
加琳娜跟尤里同年齡,名字字母順序決定誰先。
6. CompareTo 怎麼運作:硬派數學
再強調一次回傳值的標準:
- 負數(例如 -1):目前物件排在被比較的前面。
- 零:排序上視為一樣。
- 正數(例如 1):目前物件排在被比較的後面。
內建型別(像 Age.CompareTo(other.Age))都已經照這個標準實作了,永遠回傳 -1、0 或 1。
CompareTo 方法的回傳值表
| 回傳值 | 意思 | 範例 |
|---|---|---|
| < 0 | 比較小(排前面) | |
| 0 | 一樣 | |
| > 0 | 比較大(排後面) | |
7. 多重排序:欄位組合比較
有時候要更複雜的排序:比如先比姓氏,再比名字,最後比年級。用剛剛那個方法,可以依序比較:
public class Student : IComparable<Student>
{
public string LastName { get; set; }
public string FirstName { get; set; }
public int Grade { get; set; }
public int CompareTo(Student other)
{
if (other == null) return 1;
int lastNameCompare = this.LastName.CompareTo(other.LastName);
if (lastNameCompare != 0) return lastNameCompare;
int firstNameCompare = this.FirstName.CompareTo(other.FirstName);
if (firstNameCompare != 0) return firstNameCompare;
return this.Grade.CompareTo(other.Grade);
}
}
8. 什麼時候不要實作 IComparable<T>
真實情境(程式人生!):如果一個物件沒有「自然」排序順序,最好不要實作 IComparable<T>。比如你有個 Point class,但你不知道要比 X、Y 還是原點距離——那就讓排序邏輯從外面傳進來,用比較函式(IComparer<T>)。這個我們下堂課再聊。
GO TO FULL VERSION