1. はじめに
友達に本を棚に並べてって頼んだと想像してみて。もし背表紙に番号が書いてある本(1, 2, 3…)なら、友達は迷わず並べられるよね。「あ、1は2の前、2は3の前だな」って感じ。これが自然な順序(数字の順番)だよ。同じように、タイトルが「A」「B」「C」から始まる本なら、アルファベット順に並べる。これも文字列の「自然な」順序(アルファベット順)だね。
でも、もし背表紙に著者の名字だけが書いてある本だったら?「順番に並べて」って頼んだら、友達は「どの順番?名字?出版年?ページ数?」って聞いてくるはず。ここで問題発生。君のユニークなオブジェクトには明らかな自然な順序がないんだ。
プログラミングでも同じことが起きる。intのリスト List<int> をソートしようとしたら、C#はどうすればいいか分かってる。List<string> も、辞書順(アルファベット順)でOK。でも List<Student> みたいに、Student が名前・名字・年齢・IDとか色々持ってると、C#は迷子になる。どの項目で比較すればいいの?名前?ID?平均点?これが「コレクションのソート」レクチャーで出てきた問題で、List<T>.Sort() を使ったらカスタム型のコレクションでエラーが出たやつだね。
このパズルを解くには、C#に「どの基準で比較するか」をちゃんと教えてあげる必要がある。そのために IComparable<T> インターフェースがあるんだ。
2. インターフェース IComparable<T>
つまり、自分のオブジェクトに「他の同じ型のオブジェクトと比べて自分の位置を知る」能力を持たせたいなら、IComparable<T> インターフェースを使う。これは契約みたいなもの。クラスや構造体がこのインターフェースを実装すると、「よっ!俺のオブジェクトは比較できるぜ。やり方はこれだ!」ってコンパイラに伝える感じ。
どうやって動くの?
IComparable<T> インターフェースは、たった1つのメソッドを定義してる:
public interface IComparable<in T>
{
int CompareTo(T other);
}
このメソッドは T 型(今のオブジェクトと同じ型)のオブジェクトを受け取って、次の値を返さなきゃいけない:
- 負の数(< 0)なら、今のオブジェクトが「小さい」ってこと;
- ゼロ(0)なら、(ソート的に)「等しい」;
- 正の数(> 0)なら、今のオブジェクトが「大きい」。
要は、ボクシングの審判みたいなもん。君が強かったらラウンド勝ちで点数多い、弱かったら少ない、互角なら同点。でもここではもっとシンプルで、符号付きの数字だけ。
なんでこうなってるの?
ソート系のメソッド(たとえば List<T>.Sort())は、リストの要素に対して CompareTo を呼び出して、どっちを先に置くか決めてる。君のクラスがこのインターフェースを実装してれば、ソートできるってわけ!
3. 実践
例えば、こんなユーザークラス(User)があるとする:
public class User
{
public string Name { get; set; }
public int Age { get; set; }
}
ユーザーのリストをソートしてみよう:
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"(最低でも1つのオブジェクトがIComparableを実装してなきゃダメ)。
修正:IComparable<User> を実装しよう
クラスにインターフェースを追加しよう。まずは年齢順(若い順)でソートしてみる:
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); // 年齢でソート
}
}
リストを作って 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}");
}
リストは年齢順に並ぶよ:
マリヤ, 22
アントン, 27
セルゲイ, 31
ビジュアル化:ソート前と後
| 名前 | 年齢 |
|---|---|
| セルゲイ | 31 |
| マリヤ | 22 |
| アントン | 27 |
ソート後:
| 名前 | 年齢 |
|---|---|
| マリヤ | 22 |
| アントン | 27 |
| セルゲイ | 31 |
4. 大事なポイントとよくあるミス
null 対策
CompareTo の中で、other が null じゃないか必ずチェックしよう。これを忘れると NullReferenceException が出る。これはリリース直前のバグ並みに厄介。普通は、比較対象が 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. ソート:君のオブジェクトもOK
List<int> ができることは、君のクラスもできる
これで、比較が必要なメソッドは全部使える:Sort、BinarySearch、ソート済みコレクション(たとえば SortedSet<T>)への追加もOK。
users.Sort();
// usersは年齢(同じなら名前)でソート済み
コースアプリの文脈での例
前にユーザー管理アプリを作ったことがあるとしよう。今なら「自然な」ソート順を既存コードに追加できる。こんな感じ:
// Userクラスはすでに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. 複数ソート:フィールドを組み合わせる
ときどき、もっと複雑なソートが必要なこともある。たとえば、名字→名前→年齢の順で並べたい場合。さっきのやり方を応用して、順番に比較していけばOK:
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 クラスで、XでソートするのかYなのか、原点からの距離なのか分からない場合は、外から比較関数(IComparer<T>)を渡す方がベター。この話は次のレクチャーで!
GO TO FULL VERSION