C#びDictionaryで自作オブジェクトをキー:TKeyに使用する

C#のDictionaryのTKeyにオブジェクトを指定した場合、参照アドレスが同じであれば同じオブジェクトと判断されます。ざっくりいうと何もしないとクラス同士の比較 (a == b) が true だと同じキーと認識されることになります。

で、今回は、自作のクラスの内容(の値)が同じ場合同じキーと認識されるための方法を紹介します。

標準的な動きの確認

まず、既存の動作の確認です。以下のクラスを用意してDictionaryのキーとして使用します。

// 準備するクラス
public class KeyClass
{
    // 名前
    public string Name { get; set; }
    // 番号
    public int ID { get; set; }
}

次に、Dictionaryを作成します。

public static void Main(string[] args)
{
    var table = new Dictionary<KeyClass, string>()
    {
        // 同じ内容のオブジェクトを複数指定できる
        { new KeyClass() { Name ="Takap", ID = 0 }, "Item1" }, 
        { new KeyClass() { Name ="Takap", ID = 0 }, "Item2" }, 
        { new KeyClass() { Name ="Takap", ID = 0 }, "Item3" }, 
    };
}

デフォルトだとオブジェクト間の参照同一性がチェックされて中身を確認しないため、オブジェクトの中身の値が同じでも別々に new したオブジェクトは別のものとして判定されてしまいます。

自作クラスが内容で判断されるようにする

キーが内容で同じか判定するための修正方法ですが、C# の Dictionary のオブジェクトの同一性判定基準は以下の通りです。

  • GetHashCode() で同じ値となる
  • Equals(obj) でtrue となる

したがって、自分で上記メソッドをオーバーラーイドし、参照は無視してオブジェクトの中身が同値であれば同じ値を返すように修正します。

まずは簡単に実装してみます。

// 先ほどと同じクラス
public class KeyClass
{
    // 名前
    public string Name { get; set; }
    // 番号
    public int ID { get; set; }

    // 同じ内容あれば同じ値を返すように変更
    public override int GetHashCode() => HashCode.Combine(Name, ID);

    // 同じ内容であればtrueを返すように変更
    public override bool Equals(object obj)
    {
        var item = obj as KeyClass;
        if(item == null)
        {
            return false;
        }

        return this.Name == item.Name && this.ID == item.ID;
    }
}

次にテーブルへ値を設定します。この場合2つ目でエラーになります。

public static void Main(string[] args)
{
    var table = new Dictionary<KeyClass, string>()
    {
        { new KeyClass() { Name ="Takap", ID = 0 }, "Item1" }, 
        { new KeyClass() { Name ="Takap", ID = 0 }, "Item2" }, 
        // → System.ArgumentException:
        //      同一のキーを含む項目が既に追加されています。
        //      An item with the same key has already been added.
    };
}

演算子をオーバーロードして動きを統一する

上記クラスの場合、Equals と ==、!= で判定が異なってしまいます。C# では大抵は == と Equals が同じと期待するので演算子をオーバーロードします。

また、このクラスを継承した派生クラスがある場合、基底クラス ⇔ 派生クラス間の比較で一部のキーが一致したときに同一オブジェクト判定になってしまうため(クラスをsealed にしない場合)区別するための追加の実装が必要です。

public /*sealed*/ class KeyClass :  IEquatable<KeyClass>
{
    // 名前
    public string Name { get; /*private*/ set; }
    // 番号
    public int ID { get; /*private*/ set; }
    // 自分自身の型の情報(派生クラスを考慮)
    protected virtual Type EqualityContract => typeof(KeyClass);


    public KeyClass() { }
    public KeyClass(string name, int id)
    {
        Name = name;
        ID = id;
    }

    public static bool operator ==(KeyClass left, KeyClass right)
    {
        return ReferenceEquals(left, right) || left is not null && left.Equals(right);
    }
    public static bool operator !=(KeyClass left, KeyClass right)
    {
        return !(left == right);
    }

    // 同じ内容あれば同じ値を返す
    public override int GetHashCode() => HashCode.Combine(EqualityContract, Name, ID);

    // IEquatable の実装
    public bool Equals(KeyClass other)
    {
        return ReferenceEquals(this, other) ||
               other is not null &&
               EqualityContract == other.EqualityContract &&
               EqualityComparer<string>.Default.Equals(Name, other.Name) &&
               EqualityComparer<int>.Default.Equals(ID, other.ID);
    }

    // 同じ内容であればtrueを返す
    public override bool Equals(object obj) => Equals(obj as KeyClass);
}

実装的にはこれで正しいですが、最初に比べてかなり長くなってしまいました。ちゃんと書こうとすると結構面倒です。適当に使い捨てるだけなら最初の実装でも大丈夫です。

動作確認のコードは以下の通りです。Equals と ==、!= で内容を見て同じか判断されます。

public static void Main(string[] args)
{
    // Item1とItem3は内容が同じ
    var item1 = new KeyClass() { Name = "Takap", ID = 0 };
    var item2 = new KeyClass() { Name = "PPPP", ID = 1 };
    var item3 = new KeyClass() { Name = "Takap", ID = 0 };

    if (item1.Equals(item3)) // 比較メソッド
    {
        Console.WriteLine("item1.Equals(item3)");
        // [Console] > item1.Equals(item3)
    }

    if(item1 == item3) // 比較判定
    {
        Console.WriteLine("item1 == item3");
        // [Console] > item1 == item3
    }

    if(item1 != item2) // 不一致判定
    {
        Console.WriteLine("item1 != item2");
        // [Console] > item1 != item2
    }

    var table = new Dictionary<KeyClass, string>();
    table.Add(item1, "item1");
    table.Add(item2, "item2");
    // → System.ArgumentException: '同一のキーを含む項目が既に追加されています。'
    table.Add(item3, "item3");
}

これで不通に使っても違和感がない、Dictionaryのキーにも使えるクラスを作ることができました。