オブジェクトの等価性を正しく実装する

文法 難易度: ★★☆☆☆(初級)

前提知識

先にこちらを読んでおくと、この記事がすらすら読めます。

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)はそれを呼ぶだけにする。GetHashCodeHashCode.Combineに、Equalsで比較しているのと同じプロパティを渡して計算する。HashSetはこのハッシュコードで探す場所を絞り込んでからEqualsで照合するので、両方を正しく実装して初めてContainsが値で見つけられる。

最低限の理解

⚠️ 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で正しく見つかることを確認する。

次に読む