classのインスタンスは既定では参照(メモリ上の場所)が同じかどうかで比較される。2つの変数が同じインスタンスを指しているかの判定を参照等価、プロパティの値がすべて一致しているかの判定を値等価と呼ぶ。
値が同じでも別物として扱われる
DictionaryのキーやHashSetの要素に自分で定義した型を使うと、値が同じインスタンスなのに見つからないという問題が起きる。原因の多くは、Equalsを上書きしていないために既定の参照等価のまま比較されていることにある。
var p1 = new Point(1, 2);
var p2 = new Point(1, 2);
Console.WriteLine(p1 == p2);
var points = new HashSet<Point> { p1 };
Console.WriteLine(points.Contains(p2));
public class Point
{
public int X { get; }
public int Y { get; }
public Point(int x, int y)
{
X = x;
Y = y;
}
}
False
False
p1とp2はXとYの値が完全に一致しているが、==はFalseを返す。classの==は既定でEqualsと同じ参照等価の判定をするため、別々にnewした時点で別物として扱われる。HashSetのContainsも内部でEqualsを使って要素を探すので、同じ値のp2を渡しても見つからない。
値で比較できるようにする
値が同じなら同じとみなしたい型は、IEquatable<T>を実装したうえでEquals(object)とGetHashCodeもオーバーライド(『virtual/overrideの基本』で説明した、基底クラスのメンバーを派生クラスで上書きする仕組み)する。IEquatable<T>は型安全な比較用のメソッドを約束するインターフェース(『インターフェースの基本』で説明した、実装すべきメンバーを型で約束する仕組み)だ。
var p1 = new Point(1, 2);
var p2 = new Point(1, 2);
Console.WriteLine(p1.Equals(p2));
var points = new HashSet<Point> { p1 };
Console.WriteLine(points.Contains(p2));
public class Point : IEquatable<Point>
{
public int X { get; }
public int Y { get; }
public Point(int x, int y)
{
X = x;
Y = y;
}
public bool Equals(Point? other) => other is not null && X == other.X && Y == other.Y;
public override bool Equals(object? obj) => Equals(obj as Point);
public override int GetHashCode() => HashCode.Combine(X, Y);
}
True
True
Equals(Point)はobjectへのキャストなしに比較できる型安全な版で、Equals(object)はそれを呼ぶだけにする。GetHashCodeはHashCode.Combineに、Equalsで比較しているのと同じプロパティを渡して計算する。HashSetはこのハッシュコードで探す場所を絞り込んでからEqualsで照合するので、両方を正しく実装して初めてContainsが値で見つけられる。
最低限の理解
- Equalsがtrueを返す2つのインスタンスは、必ず同じGetHashCodeを返さなければならない。 これを等価性の契約と呼ぶ
EqualsだけをオーバーライドしてGetHashCodeを直さないと警告CS0659が出る。HashSetやDictionaryでは値が一致していても見つからなくなるGetHashCodeの計算に使うプロパティは、登録後に値を変えられないようにする。書き換えるとハッシュコードが変わり、以後見つからなくなるのでgetだけかinitにする==演算子はEqualsと連動しない。値で比較したいならoperator ==を別途定義する必要がある- 値の一致だけで比較したいシンプルなデータ型なら、ここまでの実装を自動で持つ
recordに任せる選択肢もある
⚠️ Equalsだけ上書きしてGetHashCodeを直さない
// NG: Equalsだけオーバーライドし、GetHashCodeは既定のまま
var p1 = new Point(1, 2);
var p2 = new Point(1, 2);
Console.WriteLine(p1.Equals(p2));
var points = new HashSet<Point> { p1 };
Console.WriteLine(points.Contains(p2));
public class Point
{
public int X { get; }
public int Y { get; }
public Point(int x, int y)
{
X = x;
Y = y;
}
public override bool Equals(object? obj) =>
obj is Point other && X == other.X && Y == other.Y;
}
warning CS0659: 'Point' は Object.Equals(object o) をオーバーライドしますが、Object.GetHashCode() をオーバーライドしません。
True
False
p1.Equals(p2)はTrueなのに、同じ値のp2をContainsで調べるとFalseになる。GetHashCodeが既定の参照ベースのままなので、p1とp2は別のハッシュコードを持ち、HashSetは違う場所を調べてしまう。
// OK: Equalsと同じプロパティでGetHashCodeもセットで実装する
var p1 = new Point(1, 2);
var p2 = new Point(1, 2);
var points = new HashSet<Point> { p1 };
Console.WriteLine(points.Contains(p2));
public class Point
{
public int X { get; }
public int Y { get; }
public Point(int x, int y)
{
X = x;
Y = y;
}
public override bool Equals(object? obj) =>
obj is Point other && X == other.X && Y == other.Y;
public override int GetHashCode() => HashCode.Combine(X, Y);
}
True
課題
自分の業務でよく使うデータ(注文、商品など)を1つ選んでクラスにし、IEquatable<T>とEquals(object)、GetHashCodeを実装して、newで2つ作った同じ値のインスタンスがHashSetで正しく見つかることを確認する。