1. はじめに
プログラミングでは、数字や文字列、オブジェクトなど、いろんなものを比較することが多いよね。でもC#で「オブジェクトの等価性」って実際どういう意味? これ、意外と直感的じゃないんだ。今日は、オブジェクトの比較を決める2つの重要なメソッド、Equals() と GetHashCode() を解説するよ。この2つを理解するのは、特に Dictionary や HashSet みたいなコレクションを使うとき、めっちゃ大事!
C#には主に2種類の等価性があるんだ:
参照の等価性 (Reference Equality):これは、2つの参照型変数がメモリ上の同じオブジェクトを指しているってこと。参照型の場合、== 演算子でチェックできるよ。
MyClass obj1 = new MyClass();
MyClass obj2 = new MyClass();
MyClass obj3 = obj1;
Console.WriteLine(obj1 == obj2); // false(メモリ上で別のオブジェクト)
Console.WriteLine(obj1 == obj3); // true(両方とも同じオブジェクトを指してる)
値の等価性 (Value Equality):
これは、2つの異なるオブジェクト(または値型)がフィールドやプロパティの値が同じってこと。オブジェクトを比較するとき、普通はこっちを期待するよね。そのために Equals() メソッドを使うんだ。
// 例えば、Pointクラスがあるとする
Point p1 = new Point(10, 20);
Point p2 = new Point(10, 20);
Point p3 = new Point(30, 40);
// p1とp2は別のオブジェクトだけど、「値が等しい」とみなしたい
Console.WriteLine(p1.Equals(p2)); // ? Equals()の実装次第
Console.WriteLine(p1.Equals(p3)); // ?
2. Equals() メソッド
Equals() メソッドは、C#の全ての型が継承している基底クラス System.Object に定義されてる。その主な目的は、2つのオブジェクトが値として等しいかどうかを判定すること。
Equals() のデフォルト動作
値型(struct, int, bool など)の場合: デフォルトの Equals() 実装(System.ValueType から継承)は、全フィールドのビットごとの比較をする。全部のフィールドが等しければ、オブジェクトは等しいとみなされる。これは大体期待通りに動くよ。
参照型(class, string, array など)の場合: デフォルトの Equals() 実装( System.Object から継承)は、 参照の等価性をチェックする。つまり obj1.Equals(obj2) は、 obj1 と obj2 が 同じメモリ上のオブジェクトを指してる場合だけ true を返す。class Person // 参照型
{
public string Name { get; set; }
public int Age { get; set; }
}
Person person1 = new Person { Name = "Alice", Age = 30 };
Person person2 = new Person { Name = "Alice", Age = 30 }; // 別のオブジェクトだけど内容は同じ
Console.WriteLine(person1.Equals(person2)); // false(デフォルトだと参照比較)
見ての通り、参照型のデフォルト動作は、たいてい欲しいものじゃない!「Alice」で30歳の2人が、メモリ上で別のオブジェクトでも等しいとみなしたいよね。
自作クラスで Equals() をオーバーライドする
自分のクラスで値の等価性を実現したいなら、Equals() メソッドをオーバーライドしなきゃダメ。
Equals() オーバーライドのルール(契約):
- 反射性: x.Equals(x) は常に true。
- 対称性: x.Equals(y) が true なら、y.Equals(x) も true じゃなきゃダメ。
- 推移性: x.Equals(y) と y.Equals(z) が両方 true なら、x.Equals(z) も true じゃなきゃダメ。
- 一貫性: x.Equals(y) を何回呼んでも、オブジェクトが変わらない限り結果は同じ。
- null対応: x.Equals(null) は常に false。
例: Person クラスで Equals() をオーバーライド
class Person
{
public string Name { get; set; }
public int Age { get; set; }
public Person(string name, int age)
{
Name = name;
Age = age;
}
// Equals()のオーバーライド
public override bool Equals(object? obj)
{
// 1. nullチェック
if (obj == null) return false;
// 2. 型チェック
if (obj.GetType() != this.GetType()) return false;
// 3. 型変換
Person other = (Person)obj;
// 4. フィールドを値で比較
return Name.Equals(other.Name, StringComparison.OrdinalIgnoreCase) &&
Age == other.Age;
}
}
// 使い方:
Person person1 = new Person("Alice", 30);
Person person2 = new Person("Alice", 30);
Person person3 = new Person("Bob", 25);
Console.WriteLine(person1.Equals(person2)); // true(値で比較するようになった!)
Console.WriteLine(person1.Equals(person3)); // false
Console.WriteLine(person1.Equals(null)); // false
3. GetHashCode() メソッド
GetHashCode() も System.Object に定義されてる。これは、オブジェクトを素早く一意に(できるだけ)識別するための整数値(ハッシュコード)を返すメソッドだよ。
GetHashCode() は何のためにある?
ハッシュコードは、ハッシュテーブルベースのコレクションの高速化のために使われる。例えば:
- Dictionary<TKey, TValue>(キーの高速検索にハッシュコードを使う)
- HashSet<T>(要素の一意性チェックにハッシュコードを使う)
- Hashtable
例えば HashSet にオブジェクトを追加したり、Dictionary のキーとして使うとき、まずコレクションはそのオブジェクトのハッシュコードを計算する。これで、全要素をなめるんじゃなくて、同じハッシュコードを持つ「バケツ」だけをすぐ探せる。その「バケツ」の中で、Equals() で本当に等しいかをチェックするんだ。
GetHashCode() オーバーライドのルール(契約):
Equals() をオーバーライドしたら、GetHashCode() も絶対にオーバーライドしなきゃダメ! これはC#で超重要なルール。
- 一貫性: Equals() が2つのオブジェクトで true を返すなら、GetHashCode() も同じ値を返さなきゃダメ。(逆は成り立たない。同じハッシュコードでも違うオブジェクトはあり得る=「衝突」)
- 不変性: GetHashCode() は、比較に使うフィールドが変わらない限り、同じオブジェクトで同じ値を返さなきゃダメ。
- 高速性: GetHashCode() は速くて、重い計算をしないこと。
なぜこれが大事? Equals() だけオーバーライドして GetHashCode() をしないと、コレクション(特にハッシュ系)が正しく動かなくなる:
- Dictionary がキーを見つけられなくなる。
- HashSet が重複を許してしまう(全部ユニークだと思っちゃう)。
なぜなら、デフォルトの GetHashCode() はオブジェクトの参照に基づいたハッシュを返す(参照型の場合)。Equals() が値で比較するようになっても、値が同じでも参照が違えばハッシュコードも違うから、コレクションは「同じ」とみなせなくなる。
Person クラスで GetHashCode() をオーバーライド
良い書き方は、Equals() で使うのと同じフィールドでハッシュコードを作ること。.NETには HashCode.Combine() っていう便利な静的メソッドがあるよ。
class Person
{
public string Name { get; set; }
public int Age { get; set; }
public Person(string name, int age)
{
Name = name;
Age = age;
}
public override bool Equals(object? obj)
{
if (obj == null || obj.GetType() != this.GetType()) return false;
Person other = (Person)obj;
return Name.Equals(other.Name, StringComparison.OrdinalIgnoreCase) &&
Age == other.Age;
}
// GetHashCode()のオーバーライド
public override int GetHashCode()
{
// フィールドのハッシュを合成するのにHashCode.Combineを使う
return HashCode.Combine(Name.ToLowerInvariant(), Age);
}
}
// コレクションでの使い方:
public class Program
{
public static void Main(string[] args)
{
Person p1 = new Person("Alice", 30);
Person p2 = new Person("Alice", 30);
Person p3 = new Person("Bob", 25);
HashSet
uniquePeople = new HashSet
(); uniquePeople.Add(p1); uniquePeople.Add(p2); // p2はp1と値が等しいので追加されない Console.WriteLine($"ユニークな人の数: {uniquePeople.Count}"); // 出力: 1 uniquePeople.Add(p3); Console.WriteLine($"ユニークな人の数: {uniquePeople.Count}"); // 出力: 2 } }
重要ポイント: GetHashCode() で、大文字小文字を無視して比較する文字列フィールド(StringComparison.OrdinalIgnoreCase を Equals で使ってる場合)は、ハッシュ計算前に小文字化(Name.ToLowerInvariant() みたいに)しないとダメ。そうしないと、Equals() は true(Alice == alice)でも、GetHashCode() は違う値を返して契約違反になるよ。
4. == と != 演算子のオーバーロード
クラス(参照型)の場合、== 演算子はデフォルトで参照の等価性をチェックする。でも、Equals() と同じように値の等価性をチェックするようにオーバーロードできるよ。
== オーバーロードのルール:
- == をオーバーロードしたら、!= も絶対にオーバーロードしなきゃダメ。
- オーバーロードした == の動作は、Equals() と同じにするのが推奨。
- == をオーバーロードするなら、GetHashCode() と Equals() もオーバーライド必須。
class Person
{
public string Name { get; set; }
public int Age { get; set; }
// コンストラクタ、Equals、GetHashCodeは前述通り
// == 演算子のオーバーロード
public static bool operator ==(Person? left, Person? right)
{
if (ReferenceEquals(left, null)) // leftがnullかチェック
{
return ReferenceEquals(right, null); // 両方nullなら等しい
}
return left.Equals(right); // それ以外はEquals()を使う
}
// != 演算子のオーバーロード(==をオーバーロードしたら必須)
public static bool operator !=(Person? left, Person? right)
{
return !(left == right);
}
}
// 使い方:
Person p1 = new Person("Alice", 30);
Person p2 = new Person("Alice", 30);
Person p3 = null;
Person p4 = null;
Console.WriteLine(p1 == p2); // true(オーバーロードした==を使う)
Console.WriteLine(p1 == p3); // false
Console.WriteLine(p3 == p4); // true
5. record — 自動的な等価性
C# 9からは、record 型が登場したよ。これは参照型だけど、値の等価性を自動で実装してくれる(Equals()、GetHashCode()、ToString()、==/!= 演算子も全部、フィールドやプロパティに基づいて自動でやってくれる)。だから record はイミュータブルなデータオブジェクトに最適!
public record PersonRecord(string Name, int Age);
// 使い方:
PersonRecord r1 = new PersonRecord("Bob", 25);
PersonRecord r2 = new PersonRecord("Bob", 25);
PersonRecord r3 = new PersonRecord("Charlie", 40);
Console.WriteLine(r1 == r2); // true(値で自動比較!)
Console.WriteLine(r1.Equals(r2)); // true
Console.WriteLine(r1.GetHashCode() == r2.GetHashCode()); // true
Console.WriteLine(r1 == r3); // false
record を使えば、参照型で値型っぽい動作がめっちゃ簡単にできるよ。
6. おすすめポイント
Equals() をオーバーライドしたら、必ず GetHashCode() もオーバーライドしよう! これを守らないと、ハッシュコレクションで予測不能な動作になるよ。
Equals() と GetHashCode() では同じフィールドを使うこと。 「値が等しい」とみなすフィールドだけでハッシュを作ろう。
ミュータブル(変更可能)な型には注意。 Equals() や GetHashCode() で使うフィールドが、オブジェクト生成後に変わる場合、ハッシュコードも変わっちゃう。これはハッシュコレクションにとって超危険で、オブジェクトが「迷子」になる(ハッシュコードが変わるとコレクションが見つけられなくなる)。ハッシュコレクションのキーや要素には、イミュータブル(不変)な型を使うのがベスト。
イミュータブルなデータオブジェクトには record を使おう。 値の等価性の実装がめっちゃ楽になるよ。
== と != のオーバーロードは、参照型で意味があるときだけやろう。 値型は == で既に値比較してる。オーバーロードするなら、Equals() と動作を合わせてね。
GO TO FULL VERSION