C#のEqualsをoverrideする方法|GetHashCodeとの関係と実装例をわかりやすく解説

はじめに

C#でクラスを作っていると、「中身が同じなら同じオブジェクトとして扱いたい」という場面があります。たとえば、Idが同じPersonは同一人物として扱いたい、座標XYが同じPointは同じ値として扱いたい、といったケースです。

このような値の比較を実現するために使うのが、Equalsのoverrideです。ただし、C#でEqualsをoverrideする場合は、単にEqualsメソッドを書き換えればよいわけではありません。多くの場合、GetHashCodeも一緒にoverrideする必要があります。Microsoftの公式ドキュメントでも、Equals(Object)をoverrideする型はGetHashCodeもoverrideすべきであり、そうしないとハッシュテーブルが正しく動作しない可能性があると説明されています。Microsoft Learn

この記事では、C#でEqualsをoverrideする方法、GetHashCodeとの関係、IEquatable<T>の使い方、==演算子の扱い、record型との違いまで、実装例を交えながらわかりやすく解説します。

1. C#でEqualsをoverrideする前に知っておきたい基本

1-1. Equalsとは何か

Equalsは、2つのオブジェクトが等しいかどうかを判定するためのメソッドです。C#のすべての型は最終的にobjectを継承しているため、Equalsは多くの型で利用できます。

たとえば、次のように使います。

C#
var a = "Hello";
var b = "Hello";

Console.WriteLine(a.Equals(b)); // True

stringの場合は、文字列の内容が同じかどうかで比較されます。一方、自分で作成した通常のクラスでは、何も実装しなければ基本的に参照が同じかどうかで比較されます。

C#
var p1 = new Person("Taro", 20);
var p2 = new Person("Taro", 20);

Console.WriteLine(p1.Equals(p2)); // 通常はFalse

上記のように、NameAgeが同じでも、p1p2は別々にnewされた異なるインスタンスです。そのため、Equalsをoverrideしていない場合、「値が同じ」という意味では等しいと判定されません。

1-2. 参照の等価性と値の等価性の違い

C#で等価性を考えるときは、まず「参照の等価性」と「値の等価性」を分けて理解することが重要です。

参照の等価性とは、2つの変数が同じインスタンスを指しているかどうかを比較する考え方です。

C#
var p1 = new Person("Taro", 20);
var p2 = p1;

Console.WriteLine(object.ReferenceEquals(p1, p2)); // True

この場合、p1p2は同じオブジェクトを参照しているため、参照として等しいといえます。

一方、値の等価性とは、別々のインスタンスであっても、持っている値が同じなら等しいとみなす考え方です。

C#
var p1 = new Person("Taro", 20);
var p2 = new Person("Taro", 20);

// NameとAgeが同じなら等しい、としたい

業務アプリケーションでは、ユーザーID、商品コード、メールアドレス、座標、日付範囲など、「オブジェクトそのもの」ではなく「中身の値」で比較したい場面が多くあります。そのような場合にEqualsをoverrideします。

1-3. object.Equalsと==演算子の違い

Equals==演算子は似ていますが、役割が完全に同じではありません。

Equalsはメソッドであり、オブジェクトの等価性を判定するためにoverrideできます。

C#
p1.Equals(p2);

一方、==は演算子です。

C#
p1 == p2;

参照型の==は、特にオーバーロードしていない限り、基本的に参照が同じかどうかを比較します。つまり、Equalsをoverrideしても、必ずしも==の動作が自動的に変わるわけではありません。

C#
var p1 = new Person("Taro", 20);
var p2 = new Person("Taro", 20);

Console.WriteLine(p1.Equals(p2)); // EqualsをoverrideしていればTrueにできる
Console.WriteLine(p1 == p2); // ==をオーバーロードしていなければFalseになり得る

そのため、Equals==の結果をそろえたい場合は、==演算子と!=演算子も明示的にオーバーロードする必要があります。

1-4. Equalsをoverrideすべきケース

Equalsをoverrideすべき代表的なケースは、次のような場合です。

値オブジェクトとして扱いたいクラスでは、Equalsのoverrideが有効です。たとえば、MoneyEmailAddressProductCodePointDateRangeのように、インスタンスの識別よりも保持している値が重要な型です。

C#
public sealed class ProductCode
{
public string Value { get; }

public ProductCode(string value)
{
Value = value;
}
}

このような型では、別々に生成されたインスタンスでも、Valueが同じなら同じ商品コードとして扱いたいことがあります。

また、Dictionary<TKey, TValue>のキーやHashSet<T>の要素として使いたい型でも、値ベースの等価性が必要になることがあります。Microsoftのドキュメントでは、値が等しいオブジェクトは同じハッシュコードを生成する必要があり、これはDictionary<TKey,TValue>HashSet<T>などのハッシュベースのコレクションで正しく動作するために必要だと説明されています。Microsoft Learn

一方で、すべてのクラスでEqualsをoverrideすべきというわけではありません。エンティティのように、インスタンスの同一性が重要なクラスでは、安易にすべてのプロパティを比較対象にすると設計が崩れることがあります。

2. C#でEqualsをoverrideする基本的な実装方法

2-1. Equals(object? obj)をoverrideする基本形

C#でEqualsをoverrideする基本形は次のとおりです。

C#
public override bool Equals(object? obj)
{
if (obj is null)
{
return false;
}

if (ReferenceEquals(this, obj))
{
return true;
}

if (obj is not Person other)
{
return false;
}

return Name == other.Name && Age == other.Age;
}

この実装では、次の順番で判定しています。

まず、比較対象がnullなら等しくないためfalseを返します。次に、自分自身と同じ参照であれば確実に等しいためtrueを返します。その後、比較対象が同じ型かどうかを確認し、同じ型であれば比較したいプロパティを比較します。

最近のC#では、パターンマッチングを使って次のように簡潔に書くこともできます。

C#
public override bool Equals(object? obj)
{
return obj is Person other
&& Name == other.Name
&& Age == other.Age;
}

ただし、実務ではIEquatable<T>も併用して、Equals(object?)から型固有のEqualsへ処理を委譲する書き方がよく使われます。

2-2. 型チェックとnullチェックの書き方

Equals(object? obj)の引数はobject?です。そのため、次のようなケースに対応する必要があります。

C#
person.Equals(null);
person.Equals("not person");
person.Equals(otherPerson);

nullが渡された場合に例外を出してはいけません。Equalsでは、比較対象がnullなら通常はfalseを返します。

C#
if (obj is null)
{
return false;
}

型が違う場合も、通常はfalseを返します。

C#
if (obj is not Person other)
{
return false;
}

C#では、この2つをまとめて次のように書けます。

C#
if (obj is not Person other)
{
return false;
}

この書き方では、objnullの場合もPersonではないためfalseになります。型チェックとnullチェックを同時に行えるため、シンプルで読みやすい実装になります。

2-3. 比較対象にするフィールド・プロパティの決め方

Equalsをoverrideするときに重要なのは、「何をもって同じとみなすか」を明確にすることです。

たとえば、次のPersonクラスを考えます。

C#
public sealed class Person
{
public int Id { get; }
public string Name { get; }
public int Age { get; }
}

このとき、等価性の基準として次のような考え方があります。

Idが同じなら同じ人物とみなす場合は、Idだけを比較します。

C#
return Id == other.Id;

NameAgeが同じなら同じ値とみなす場合は、NameAgeを比較します。

C#
return Name == other.Name && Age == other.Age;

すべてのプロパティが同じなら等しいとみなす場合は、すべてを比較対象にします。

C#
return Id == other.Id
&& Name == other.Name
&& Age == other.Age;

どれが正解かは、クラスの役割によって変わります。エンティティであればIdを基準にすることが多く、値オブジェクトであれば値を構成するすべてのプロパティを比較することが多いです。

2-4. 実装例:PersonクラスでEqualsをoverrideする

ここでは、NameAgeが同じなら同じPersonとみなす例を見てみましょう。

C#
public sealed class Person
{
public string Name { get; }
public int Age { get; }

public Person(string name, int age)
{
Name = name;
Age = age;
}

public override bool Equals(object? obj)
{
return obj is Person other
&& Name == other.Name
&& Age == other.Age;
}

public override int GetHashCode()
{
return HashCode.Combine(Name, Age);
}
}

使用例は次のとおりです。

C#
var p1 = new Person("Taro", 20);
var p2 = new Person("Taro", 20);
var p3 = new Person("Jiro", 20);

Console.WriteLine(p1.Equals(p2)); // True
Console.WriteLine(p1.Equals(p3)); // False

p1p2は別々のインスタンスですが、NameAgeが同じなのでEqualstrueを返します。一方、p3Nameが異なるためfalseになります。

ここで重要なのは、EqualsだけでなくGetHashCodeもoverrideしている点です。次の章で詳しく解説します。

3. EqualsをoverrideしたらGetHashCodeもoverrideすべき理由

3-1. EqualsとGetHashCodeの関係

Equalsをoverrideしたら、原則としてGetHashCodeもoverrideします。これはC#の等価性実装における非常に重要なルールです。

Equalsは「2つのオブジェクトが等しいかどうか」を判定します。一方、GetHashCodeは、オブジェクトをハッシュベースのコレクションで扱うためのハッシュコードを返します。

Microsoftの公式ドキュメントでは、GetHashCodeをoverrideする場合はEqualsもoverrideすべきであり、その逆も同様だと説明されています。また、overrideしたEqualsが2つのオブジェクトを等しいと判断するなら、その2つのオブジェクトは同じハッシュコードを返さなければならないとされています。Microsoft Learn

つまり、次の関係を守る必要があります。

C#
a.Equals(b) == true

であれば、

C#
a.GetHashCode() == b.GetHashCode()

でなければなりません。

ただし、逆は必ずしも成り立ちません。つまり、ハッシュコードが同じでも、Equalstrueとは限りません。ハッシュコードは衝突する可能性があるためです。

3-2. 同じ値なら同じハッシュコードにする必要がある

次のようなPersonクラスを考えます。

C#
public sealed class Person
{
public string Name { get; }
public int Age { get; }

public Person(string name, int age)
{
Name = name;
Age = age;
}

public override bool Equals(object? obj)
{
return obj is Person other
&& Name == other.Name
&& Age == other.Age;
}
}

このクラスでは、Equalsだけをoverrideしています。しかし、GetHashCodeをoverrideしていません。

この場合、p1.Equals(p2)trueになるかもしれませんが、p1.GetHashCode()p2.GetHashCode()が異なる可能性があります。

C#
var p1 = new Person("Taro", 20);
var p2 = new Person("Taro", 20);

Console.WriteLine(p1.Equals(p2)); // True
Console.WriteLine(p1.GetHashCode() == p2.GetHashCode()); // Falseになり得る

これは、Equalsでは値が同じと判定しているのに、ハッシュコードでは別物として扱われる可能性があるということです。

この状態は、DictionaryHashSetのようなコレクションで問題を引き起こします。

3-3. DictionaryやHashSetで問題が起きる例

GetHashCodeをoverrideし忘れると、HashSet<T>で重複を排除できないことがあります。

C#
public sealed class Person
{
public string Name { get; }
public int Age { get; }

public Person(string name, int age)
{
Name = name;
Age = age;
}

public override bool Equals(object? obj)
{
return obj is Person other
&& Name == other.Name
&& Age == other.Age;
}

// GetHashCodeをoverrideしていない
}

この状態でHashSet<Person>を使うと、次のような問題が起きる可能性があります。

C#
var set = new HashSet<Person>();

set.Add(new Person("Taro", 20));
set.Add(new Person("Taro", 20));

Console.WriteLine(set.Count); // 期待は1だが、2になる可能性がある

HashSet<T>は、要素を管理するときにハッシュコードを使います。Equalsでは等しいのにGetHashCodeが異なると、同じ値のオブジェクトが別の要素として扱われる可能性があります。

Dictionary<TKey, TValue>でも同様です。

C#
var dictionary = new Dictionary<Person, string>();

var p1 = new Person("Taro", 20);
var p2 = new Person("Taro", 20);

dictionary[p1] = "登録済み";

Console.WriteLine(dictionary.ContainsKey(p2)); // 期待はTrueだが、Falseになり得る

このような問題を防ぐために、EqualsをoverrideしたらGetHashCodeも必ず整合性のある形でoverrideします。

3-4. HashCode.Combineを使ったGetHashCodeの実装例

GetHashCodeを実装する簡単な方法は、HashCode.Combineを使うことです。

C#
public override int GetHashCode()
{
return HashCode.Combine(Name, Age);
}

Equalsで比較に使っているプロパティと、GetHashCodeで使うプロパティはそろえるのが基本です。

C#
public sealed class Person
{
public string Name { get; }
public int Age { get; }

public Person(string name, int age)
{
Name = name;
Age = age;
}

public override bool Equals(object? obj)
{
return obj is Person other
&& Name == other.Name
&& Age == other.Age;
}

public override int GetHashCode()
{
return HashCode.Combine(Name, Age);
}
}

このように実装すれば、NameAgeが同じPersonは同じハッシュコードを返すため、HashSetDictionaryでも正しく扱いやすくなります。

C#
var set = new HashSet<Person>();

set.Add(new Person("Taro", 20));
set.Add(new Person("Taro", 20));

Console.WriteLine(set.Count); // 1

4. IEquatable<T>を使ったEqualsの実装方法

4-1. IEquatable<T>とは何か

IEquatable<T>は、特定の型同士を比較するための型安全なEqualsを定義するインターフェイスです。

C#
public interface IEquatable<T>
{
bool Equals(T? other);
}

Equals(object? obj)は引数がobject?なので、どの型でも受け取れます。一方、IEquatable<T>Equals(T? other)は、比較対象の型が明確です。

たとえば、PersonIEquatable<Person>を実装すると、次のようなメソッドを定義できます。

C#
public bool Equals(Person? other)
{
return other is not null
&& Name == other.Name
&& Age == other.Age;
}

Microsoftの公式ドキュメントでは、IEquatable<T>を実装する場合、Equals(Object)GetHashCode()の動作がEquals(T)と一貫するように、基底クラスの実装もoverrideすべきだと説明されています。Microsoft Learn

4-2. Equals(object?)とEquals(T?)の違い

Equals(object?)Equals(T?)の違いは、引数の型です。

C#
public override bool Equals(object? obj)

これはobject型を受け取るため、Person以外の型やnullも渡されます。そのため、型チェックが必要です。

C#
public bool Equals(Person? other)

こちらはPerson?を受け取るため、比較対象がPersonであることがメソッドシグネチャ上で明確です。そのため、型チェックの処理を減らせます。

実装では、Equals(object?)からEquals(Person?)へ委譲する形がよく使われます。

C#
public override bool Equals(object? obj)
{
return Equals(obj as Person);
}

public bool Equals(Person? other)
{
return other is not null
&& Name == other.Name
&& Age == other.Age;
}

このようにすると、等価性の本体をEquals(Person?)に集約できます。

4-3. IEquatable<T>を実装するメリット

IEquatable<T>を実装するメリットは、型安全でわかりやすい比較を提供できることです。

Equals(object?)だけの場合、比較対象がobjectなので、呼び出し側から見ると何と比較できるのかがわかりにくくなります。IEquatable<Person>を実装していれば、「この型はPerson同士の等価性を定義している」と明確になります。

また、ジェネリックコレクションや比較処理で、型固有のEqualsが使われやすくなります。Microsoftのドキュメントでは、値型がEqualsをoverrideする場合、強く型付けされた等価性テストをサポートするためにIEquatable<T>の実装を検討すること、またそれによりボックス化を避けてパフォーマンス向上につながることが説明されています。Microsoft Learn

クラスでも、値オブジェクトとして扱うならIEquatable<T>を実装しておくと、意図が伝わりやすくなります。

4-4. 実装例:IEquatable<Person>を使った書き方

IEquatable<Person>を使った実装例は次のとおりです。

C#
public sealed class Person : IEquatable<Person>
{
public string Name { get; }
public int Age { get; }

public Person(string name, int age)
{
Name = name;
Age = age;
}

public bool Equals(Person? other)
{
if (other is null)
{
return false;
}

if (ReferenceEquals(this, other))
{
return true;
}

return Name == other.Name
&& Age == other.Age;
}

public override bool Equals(object? obj)
{
return Equals(obj as Person);
}

public override int GetHashCode()
{
return HashCode.Combine(Name, Age);
}
}

使用例は次のとおりです。

C#
var p1 = new Person("Taro", 20);
var p2 = new Person("Taro", 20);
var p3 = new Person("Jiro", 20);

Console.WriteLine(p1.Equals(p2)); // True
Console.WriteLine(p1.Equals(p3)); // False
Console.WriteLine(p1.Equals(null)); // False

この形は、C#でEqualsをoverrideする際の基本パターンとして覚えておくと便利です。

5. ==演算子と!=演算子も実装すべきか

5-1. Equalsと==演算子の役割の違い

Equalsをoverrideしたからといって、必ず==演算子もオーバーロードしなければならないわけではありません。

Equalsは、オブジェクトの等価性を判定するための標準的なメソッドです。DictionaryHashSetなどのコレクションでも、基本的にEqualsGetHashCodeの整合性が重要になります。

一方、==演算子は、コード上でより直感的に比較を書くための構文です。

C#
if (p1 == p2)
{
Console.WriteLine("同じ人物です");
}

このように書けると読みやすい場合がありますが、==をオーバーロードすると、その型を使う人は==が値の比較を行うと期待するようになります。そのため、実装する場合はEqualsと結果が矛盾しないようにする必要があります。

5-2. ==演算子をオーバーロードするケース

==演算子をオーバーロードするのが向いているのは、値オブジェクトとして自然に比較できる型です。

たとえば、次のような型です。

C#
public sealed class Money
{
public decimal Amount { get; }
public string Currency { get; }
}

Moneyの場合、金額と通貨が同じなら等しいと考えるのが自然です。

C#
money1 == money2

このような比較が直感的であれば、==演算子を実装する価値があります。

一方、エンティティや状態を持つ複雑なオブジェクトでは、==が何を意味するのか曖昧になることがあります。その場合は、無理に==をオーバーロードせず、Equalsや明示的な比較メソッドを使う方がわかりやすいこともあります。

5-3. !=演算子もセットで実装する理由

C#で==演算子をオーバーロードする場合、通常は!=演算子もセットで実装します。

==だけが値の等価性を表し、!=が別の動きをすると、利用者にとって非常に混乱します。そのため、!=は基本的に==の否定として実装します。

C#
public static bool operator !=(Person? left, Person? right)
{
return !(left == right);
}

これにより、次の関係が自然に保たれます。

C#
p1 != p2

は、

C#
!(p1 == p2)

と同じ意味になります。

5-4. Equals・GetHashCode・==演算子をそろえた実装例

EqualsGetHashCode==演算子、!=演算子をそろえた実装例は次のとおりです。

C#
public sealed class Person : IEquatable<Person>
{
public string Name { get; }
public int Age { get; }

public Person(string name, int age)
{
Name = name;
Age = age;
}

public bool Equals(Person? other)
{
if (other is null)
{
return false;
}

if (ReferenceEquals(this, other))
{
return true;
}

return Name == other.Name
&& Age == other.Age;
}

public override bool Equals(object? obj)
{
return Equals(obj as Person);
}

public override int GetHashCode()
{
return HashCode.Combine(Name, Age);
}

public static bool operator ==(Person? left, Person? right)
{
if (ReferenceEquals(left, right))
{
return true;
}

if (left is null || right is null)
{
return false;
}

return left.Equals(right);
}

public static bool operator !=(Person? left, Person? right)
{
return !(left == right);
}
}

使用例は次のとおりです。

C#
var p1 = new Person("Taro", 20);
var p2 = new Person("Taro", 20);
var p3 = new Person("Jiro", 20);

Console.WriteLine(p1.Equals(p2)); // True
Console.WriteLine(p1 == p2); // True
Console.WriteLine(p1 != p3); // True

このように、EqualsGetHashCode==!=の基準をそろえることで、型の利用者にとって自然で一貫した比較ができます。

6. Equals overrideでよくある失敗と注意点

6-1. GetHashCodeをoverrideし忘れる

最も多い失敗は、EqualsだけをoverrideしてGetHashCodeをoverrideし忘れることです。

C#
public override bool Equals(object? obj)
{
return obj is Person other
&& Name == other.Name
&& Age == other.Age;
}

// GetHashCodeがない

このような実装は、Equalsだけを直接呼ぶ場合には問題に気づきにくいです。しかし、HashSetDictionaryで使うと、同じ値のオブジェクトが別物として扱われる可能性があります。

Equalsをoverrideしたら、基本的に次のようにGetHashCodeも一緒に実装します。

C#
public override int GetHashCode()
{
return HashCode.Combine(Name, Age);
}

6-2. 可変プロパティをハッシュ計算に使う

GetHashCodeで使う値は、できるだけ変更されない値にするべきです。

次のように、変更可能なプロパティをハッシュ計算に使うと危険です。

C#
public sealed class Person : IEquatable<Person>
{
public string Name { get; set; }
public int Age { get; set; }

public override int GetHashCode()
{
return HashCode.Combine(Name, Age);
}

public bool Equals(Person? other)
{
return other is not null
&& Name == other.Name
&& Age == other.Age;
}

public override bool Equals(object? obj)
{
return Equals(obj as Person);
}
}

このクラスをHashSetに追加した後でNameAgeを変更すると、追加時のハッシュコードと変更後のハッシュコードが変わってしまいます。

C#
var person = new Person { Name = "Taro", Age = 20 };

var set = new HashSet<Person>();
set.Add(person);

person.Age = 21;

Console.WriteLine(set.Contains(person)); // Falseになる可能性がある

ハッシュベースのコレクションに入れる型では、等価性に使うプロパティを不変にするのが安全です。

C#
public string Name { get; }
public int Age { get; }

6-3. nullチェックが不十分

Equalsでは、比較対象がnullになる可能性があります。nullチェックを忘れると、NullReferenceExceptionが発生することがあります。

悪い例は次のとおりです。

C#
public bool Equals(Person? other)
{
return Name == other.Name && Age == other.Age;
}

othernullの場合、other.Nameにアクセスした時点で例外になります。

安全な実装は次のとおりです。

C#
public bool Equals(Person? other)
{
return other is not null
&& Name == other.Name
&& Age == other.Age;
}

または、明示的に分岐しても構いません。

C#
public bool Equals(Person? other)
{
if (other is null)
{
return false;
}

return Name == other.Name
&& Age == other.Age;
}

6-4. 継承クラスでEqualsの整合性が崩れる

Equalsのoverrideでは、継承が絡むと難しくなります。

たとえば、基底クラスPersonと派生クラスEmployeeがある場合を考えます。

C#
public class Person
{
public string Name { get; }
}

public class Employee : Person
{
public int EmployeeId { get; }
}

PersonではNameだけを比較し、EmployeeではEmployeeIdも比較したいとします。このとき、次のような問題が起きる可能性があります。

C#
Person p = new Person("Taro");
Employee e = new Employee("Taro", 100);

p.Equals(e)trueだが、e.Equals(p)falseになる、といった非対称な実装になってしまうことがあります。

等価性には、反射律、対称律、推移律のような性質が求められます。継承階層でこれらを保つのは難しいため、値オブジェクトとしてEqualsをoverrideするクラスはsealedにするのが簡単で安全です。

C#
public sealed class Person : IEquatable<Person>
{
// Equalsを安全に実装しやすい
}

継承を許可する場合は、GetType()を使って完全に同じ実行時型だけを比較する、または等価性の設計を慎重に決める必要があります。

C#
public override bool Equals(object? obj)
{
if (obj is null || obj.GetType() != GetType())
{
return false;
}

var other = (Person)obj;
return Name == other.Name;
}

6-5. 浮動小数点数やコレクションを比較する際の注意点

doublefloatのような浮動小数点数を比較対象にする場合は注意が必要です。

C#
public double X { get; }
public double Y { get; }

浮動小数点数は計算誤差を含むことがあるため、単純に==で比較すると期待どおりにならない場合があります。

C#
return X == other.X && Y == other.Y;

座標や測定値のように誤差を許容したい場合は、許容誤差を使った比較を検討します。

C#
private const double Tolerance = 0.000001;

public bool Equals(Point? other)
{
return other is not null
&& Math.Abs(X - other.X) < Tolerance
&& Math.Abs(Y - other.Y) < Tolerance;
}

ただし、許容誤差を使った比較はGetHashCodeとの整合性が難しくなります。Equalsで等しいとみなす範囲と、GetHashCodeが返す値の関係を慎重に設計する必要があります。

また、配列やリストなどのコレクションを比較する場合も注意が必要です。

C#
public int[] Scores { get; }

配列同士を==で比較すると、通常は要素ではなく参照が比較されます。要素の内容で比較したい場合は、SequenceEqualなどを使います。

C#
return Scores.SequenceEqual(other.Scores);

この場合も、GetHashCodeでは各要素を考慮したハッシュ計算が必要です。

C#
public override int GetHashCode()
{
var hash = new HashCode();

foreach (var score in Scores)
{
hash.Add(score);
}

return hash.ToHashCode();
}

7. record型とEquals overrideの違い

7-1. record型ではEqualsが自動実装される

C#には、値ベースの等価性を簡単に扱えるrecord型があります。

C#
public record Person(string Name, int Age);

このように定義すると、NameAgeが同じPersonは等しいと判定されます。

C#
var p1 = new Person("Taro", 20);
var p2 = new Person("Taro", 20);

Console.WriteLine(p1.Equals(p2)); // True
Console.WriteLine(p1 == p2); // True

C#のrecord型では、EqualsGetHashCode==!=などが自動的に合成されます。Microsoftのrecordに関するドキュメントでも、record型にはrecord型を引数に取る仮想EqualsObject.GetHashCode()のoverride、==!=演算子が含まれると説明されています。Microsoft Learn

7-2. classで手動overrideする場合との違い

通常のclassで値ベースの比較をしたい場合は、EqualsGetHashCode、必要に応じてIEquatable<T>==演算子を自分で実装する必要があります。

C#
public sealed class Person : IEquatable<Person>
{
public string Name { get; }
public int Age { get; }

public Person(string name, int age)
{
Name = name;
Age = age;
}

public bool Equals(Person? other)
{
return other is not null
&& Name == other.Name
&& Age == other.Age;
}

public override bool Equals(object? obj)
{
return Equals(obj as Person);
}

public override int GetHashCode()
{
return HashCode.Combine(Name, Age);
}
}

一方、recordなら次の1行で近い動作を実現できます。

C#
public record Person(string Name, int Age);

recordは、値のまとまりを表す型を簡潔に定義したい場合に便利です。

7-3. recordを使うべきケース

recordを使うべき代表的なケースは、値オブジェクトやDTOのように、データのまとまりを表現したい場合です。

たとえば、次のような型です。

C#
public record Money(decimal Amount, string Currency);
public record ProductCode(string Value);
public record Point(int X, int Y);

これらは、インスタンスの同一性よりも、保持している値が重要です。そのため、recordの値ベース等価性と相性がよいです。

また、イミュータブルなデータを扱いたい場合にもrecordは便利です。

C#
var money1 = new Money(1000m, "JPY");
var money2 = money1 with { Amount = 2000m };

with式を使うことで、一部の値だけを変更した新しいインスタンスを作れます。

7-4. 手動overrideを選ぶべきケース

recordが便利とはいえ、常にrecordを使えばよいわけではありません。

手動でEqualsをoverrideした方がよいケースもあります。たとえば、比較対象にするプロパティを細かく制御したい場合です。

C#
public sealed class User : IEquatable<User>
{
public int Id { get; }
public string Name { get; }
public DateTime LastLoginAt { get; }

public bool Equals(User? other)
{
return other is not null && Id == other.Id;
}

public override bool Equals(object? obj)
{
return Equals(obj as User);
}

public override int GetHashCode()
{
return Id.GetHashCode();
}
}

この例では、NameLastLoginAtが違っても、Idが同じなら同じユーザーとみなしています。recordの自動生成される等価性では、主コンストラクターに含めた値が比較対象になりやすいため、このような独自ルールを明確にしたい場合はclassで手動実装する方が適していることがあります。

また、既存のクラス設計がある場合、継承やフレームワークとの兼ね合いがある場合、等価性の仕様を厳密に管理したい場合も、手動overrideを選ぶ価値があります。

8. Equals overrideの動作確認とテスト方法

8-1. 同じ値のオブジェクトがtrueになるか確認する

Equalsをoverrideしたら、まず同じ値を持つ別インスタンスがtrueになるか確認します。

C#
var p1 = new Person("Taro", 20);
var p2 = new Person("Taro", 20);

Console.WriteLine(p1.Equals(p2)); // True

単体テストでは次のように書けます。

C#
[Fact]
public void Equals_ReturnsTrue_WhenValuesAreSame()
{
var p1 = new Person("Taro", 20);
var p2 = new Person("Taro", 20);

Assert.True(p1.Equals(p2));
}

Equalsをoverrideする目的は、多くの場合「別インスタンスでも値が同じなら等しい」と判定することです。このテストは必ず確認しておきたい基本パターンです。

8-2. 異なる値のオブジェクトがfalseになるか確認する

次に、値が異なる場合にfalseになることを確認します。

C#
var p1 = new Person("Taro", 20);
var p2 = new Person("Jiro", 20);
var p3 = new Person("Taro", 30);

Console.WriteLine(p1.Equals(p2)); // False
Console.WriteLine(p1.Equals(p3)); // False

単体テストでは次のように書けます。

C#
[Fact]
public void Equals_ReturnsFalse_WhenValuesAreDifferent()
{
var p1 = new Person("Taro", 20);
var p2 = new Person("Jiro", 20);

Assert.False(p1.Equals(p2));
}

比較対象にしているすべてのプロパティについて、差分がある場合にfalseになるか確認すると安心です。

8-3. nullや別の型との比較を確認する

Equals(object?)では、nullや別の型が渡される可能性があります。

C#
var person = new Person("Taro", 20);

Console.WriteLine(person.Equals(null)); // False
Console.WriteLine(person.Equals("Taro")); // False
Console.WriteLine(person.Equals(new object())); // False

単体テストでは次のように確認します。

C#
[Fact]
public void Equals_ReturnsFalse_WhenOtherIsNull()
{
var person = new Person("Taro", 20);

Assert.False(person.Equals(null));
}

[Fact]
public void Equals_ReturnsFalse_WhenOtherTypeIsDifferent()
{
var person = new Person("Taro", 20);

Assert.False(person.Equals("not person"));
}

これらのテストを入れておくと、nullチェックや型チェックの漏れに気づきやすくなります。

8-4. HashSetやDictionaryで正しく動くか確認する

EqualsGetHashCodeの整合性を確認するには、HashSetDictionaryでテストするのが効果的です。

C#
[Fact]
public void HashSet_TreatsSameValuesAsSameElement()
{
var set = new HashSet<Person>();

set.Add(new Person("Taro", 20));
set.Add(new Person("Taro", 20));

Assert.Equal(1, set.Count);
}

Dictionaryのキーとして使う場合も確認します。

C#
[Fact]
public void Dictionary_CanFindKey_WithSameValueObject()
{
var key1 = new Person("Taro", 20);
var key2 = new Person("Taro", 20);

var dictionary = new Dictionary<Person, string>
{
[key1] = "value"
};

Assert.True(dictionary.ContainsKey(key2));
Assert.Equal("value", dictionary[key2]);
}

このテストが通れば、EqualsGetHashCodeの基本的な整合性が取れていることを確認できます。

9. C#のEquals overrideに関するよくある質問

9-1. Equalsだけoverrideしてもよいのか

原則として、Equalsだけをoverrideするのは避けるべきです。

Equalsをoverrideしたら、GetHashCodeもoverrideしてください。Microsoftの公式ドキュメントでも、Equals(Object)をoverrideする型はGetHashCodeもoverrideすべきであり、そうしないとハッシュテーブルが正しく動作しない可能性があると説明されています。Microsoft Learn

特に、HashSet<T>Dictionary<TKey, TValue>で使う可能性がある型では、GetHashCodeの実装は必須と考えた方が安全です。

9-2. GetHashCodeはどのように実装すればよいのか

基本的には、Equalsで比較に使っているフィールドやプロパティを使ってGetHashCodeを実装します。

C#
public override int GetHashCode()
{
return HashCode.Combine(Name, Age);
}

EqualsNameAgeを比較しているなら、GetHashCodeでもNameAgeを使います。

C#
public bool Equals(Person? other)
{
return other is not null
&& Name == other.Name
&& Age == other.Age;
}

public override int GetHashCode()
{
return HashCode.Combine(Name, Age);
}

比較対象とハッシュ計算対象がずれていると、等価性の整合性が崩れる可能性があります。

9-3. IEquatable<T>は必ず実装すべきか

必ずではありませんが、値ベースの等価性を定義するなら実装をおすすめします。

IEquatable<T>を実装すると、型固有のEqualsを提供できます。

C#
public bool Equals(Person? other)

これにより、Equals(object?)だけを実装するよりも意図が明確になります。また、値型ではボックス化を避けられるため、パフォーマンス面でもメリットがあります。Microsoftのコード分析ルールでも、値型がEqualsをoverrideする場合は、強く型付けされた等価性を提供するためにIEquatable<T>の実装を検討することが説明されています。Microsoft Learn

クラスの場合でも、Equalsをきちんと設計するならIEquatable<T>を実装しておくとよいでしょう。

9-4. record型ならEquals overrideは不要なのか

多くの場合、record型では手動でEqualsをoverrideする必要はありません。

C#
public record Person(string Name, int Age);

このように定義すれば、NameAgeに基づく値ベースの等価性が自動的に提供されます。record型では、EqualsGetHashCode==!=などが自動生成されます。Microsoft Learn

ただし、recordでも独自の等価性ルールを持たせたい場合は、手動実装を検討することがあります。たとえば、すべてのプロパティではなくIdだけで比較したい場合などです。

C#
public record User(int Id, string Name);

この場合、recordの標準的な等価性ではIdNameの両方が比較対象になります。Idだけで比較したいなら、recordの使い方やclassでの手動実装を含めて設計を見直した方がよいことがあります。

9-5. ==演算子のオーバーロードは必要なのか

==演算子のオーバーロードは必須ではありません。

EqualsGetHashCodeを正しく実装していれば、HashSetDictionaryなどの多くの場面では問題なく動作します。

ただし、値オブジェクトとして自然に==で比較したい型では、==!=を実装すると使いやすくなります。

C#
if (money1 == money2)
{
Console.WriteLine("同じ金額です");
}

実装する場合は、Equalsと結果が矛盾しないようにします。

C#
public static bool operator ==(Person? left, Person? right)
{
if (ReferenceEquals(left, right))
{
return true;
}

if (left is null || right is null)
{
return false;
}

return left.Equals(right);
}

public static bool operator !=(Person? left, Person? right)
{
return !(left == right);
}

==を実装するなら、!=もセットで実装するのが基本です。

まとめ

C#でEqualsをoverrideする目的は、オブジェクトを参照ではなく値で比較できるようにすることです。通常のクラスでは、何も実装しないと別々に生成されたインスタンスは等しいとみなされません。そのため、値オブジェクトやキーとして使うクラスでは、Equalsをoverrideして独自の等価性を定義します。

ただし、Equalsだけをoverrideするのは不十分です。Equalsで等しいと判断されるオブジェクトは、同じGetHashCodeを返す必要があります。これを守らないと、HashSet<T>Dictionary<TKey, TValue>で正しく動作しない可能性があります。

基本的な実装パターンは次のとおりです。

C#
public sealed class Person : IEquatable<Person>
{
public string Name { get; }
public int Age { get; }

public Person(string name, int age)
{
Name = name;
Age = age;
}

public bool Equals(Person? other)
{
return other is not null
&& Name == other.Name
&& Age == other.Age;
}

public override bool Equals(object? obj)
{
return Equals(obj as Person);
}

public override int GetHashCode()
{
return HashCode.Combine(Name, Age);
}

public static bool operator ==(Person? left, Person? right)
{
if (ReferenceEquals(left, right))
{
return true;
}

if (left is null || right is null)
{
return false;
}

return left.Equals(right);
}

public static bool operator !=(Person? left, Person? right)
{
return !(left == right);
}
}

Equalsをoverrideするときは、次のポイントを押さえておきましょう。

比較対象にするプロパティを明確にすること、GetHashCodeも必ず整合性のある形でoverrideすること、IEquatable<T>の実装を検討すること、==!=を実装するならEqualsと矛盾しないようにすること、ハッシュ計算に可変プロパティを使わないことが重要です。

また、単純な値のまとまりを表す型であれば、record型を使うことでEqualsGetHashCodeを手動実装せずに済む場合もあります。一方で、比較ルールを細かく制御したい場合や、既存のclass設計に合わせる必要がある場合は、手動でEqualsをoverrideする方が適しています。

C#のEquals overrideは、一見すると小さな実装に見えますが、コレクションの動作やオブジェクト設計に大きく関わります。EqualsGetHashCodeIEquatable<T>、必要に応じた==演算子をセットで考えることで、安全でわかりやすい等価性を実装できます。