C#で値を表す自作クラスに等価性を定義する

公開 更新

たとえばintのようなプリミティブ型をラップしたような値を表す自作のクラス(いわゆるValue Object)を作った場合に、等価性(等しいこと)を定義する方法をメモします。

その前に!

まずは Microsoft のガイド型の値の等価性を定義する方法 (C# プログラミング ガイド)を読んでください。もし、意味わからんとなったらこの記事を読む価値があるかもしれません。

どういう話

次のようなものすごく単純化した値オブジェクトを考えてみます。

public class Amount
{
    private readonly int value;
    public Amount(int value)
    {
        this.value = value;
    }
}

このときこのままでは、

Console.WriteLine( new Amount(1) == new Amount(1)); //False

Falseになってしまいます(同じ値として扱いたいのに!)。

値型と参照型

上記がFalseになってしまうのはクラスが参照型だからです。

C#の型には大きく分けて値型と参照型の 2 つがあります。値型(たとえばintとか)の場合等価性の判定にはそのが用いられます(同値性)。

int val1 = 1;
int val2 = 1;
int val3 = val1;
Console.WriteLine(val1 == val2); //True
Console.WriteLine(val1 == val3); //もちろんTrue

一方、参照型(クラスとか)の場合等価性の判定にはその参照先が同一か(インスタンスが同一か)が用いられます(同一性)。

Amount ref1 = new Amount(1);
Amount ref2 = new Amount(1);
Amount ref3 = ref1;
Console.WriteLine(ref1 == ref2); //参照先が異なるのでFalse
Console.WriteLine(ref1 == ref3); //これはTrue

じゃどうする

クラスは参照型なのでたとえ値が同じでもこのままでは「等しい」と評価されないことが分かりました。ただ、今回作ったクラスは「値」としての振る舞いを期待しています(値オブジェクトだし)。

こういった場合はクラスに追加の実装をする必要があります。

IEquatable<T>を実装する

C#にはIEquatable<T>というピッタリなインターフェイスが用意されています。IEquatable<T>を実装すると今回の場合はたとえば次のようになります。

public class Amount : IEquatable<Amount>
{
    private readonly int value;

    public Amount(int value)
    {
        this.value = value;
    }

    public bool Equals(Amount other)
    {
        if(other is null) return false;
        return this.value == other.value;
    }
}

こうすると、

Console.WriteLine(new Amount(1).Equals(new Amount(1))); //True!!!

になります。このIEquatable<T>.Equals(T)List<T>など他の多くのクラスでの等価性の判定に利用されます。よくできてますね。

Object.Equals(Object)をオーバーライドする

すべてのクラスにはObject.Equalsという仮想メソッドが存在しています(これはすべてのクラスがObject型を継承しているから)。

このObject.Equals(Object)は等価性の判定のため、通常List<T>などのクラスから呼び出されるメソッドです(前述の通りIEquatable<T>などが実装されていれば別)。ところで、いまのままではEquals(T)Equals(Object)の動作が一致しません。

Console.WriteLine(new Amount(1).Equals(new Amount(1))); //Equals(Amount)だとTrueだけど
Console.WriteLine(new Amount(1).Equals((object)new Amount(1))); //Equals(Object)だとFalse

これはObject.Equals(Object)が既定の動作として参照による等価性の判定をしているためです。そこで、たとえば次のようにObject.Equals(Object)をオーバーライドします。

public class Amount : IEquatable<Amount>
{
    private readonly int value;

    public Amount(int value)
    {
        this.value = value;
    }

    public bool Equals(Amount other)
    {
        if(other is null) return false;
        return this.value == other.value;
    }

    public override bool Equals(object other)
    {
        return Equals(other as Amount);
    }
}

こうすると同じ結果が得られるようになります。

Console.WriteLine(new Amount(1).Equals(new Amount(1))); //Equals(Amount)もTrue
Console.WriteLine(new Amount(1).Equals((object)new Amount(1))); //Equals(Object)もTrue

なぜIEquatable<T>を実装するのか

はじめからObject.Equals(Object)をオーバーライドすれば、わざわざIEquatable<T>を実装しなくても良い気もしいます。

理由としては、タイプセーフな点とObject型へのキャストを避けられるのでパフォーマンス向上が望める点などでしょうか(まあものすごく大きな恩恵ではない)。個人的にはIEquatable<T>を実装することでクラスの意図が明確になるので基本的に実装しておくべきと思います。

Object.GetHashCode()をオーバーライドする

この状態では、たとえば Visula Studio を使用している場合、下記のような警告を表示してきます。

‘Amount’ は Object.Equals(object o) をオーバーライドしますが、Object.GetHashCode() をオーバーライドしません。

Object.GetHashCodeはハッシュコードを返すメソッドで、たとえばDictionaryの Key の探索に利用されます。このObject.GetHashCodeは 2 つのオブジェクトが等価であれば同一の値を返す必要があります。

たとえば、いまの状態でAmountを Dictonary の Key にすると思い通りの動きをしてくれません。

var dic = new Dictionary<Amount, int> { { new Amount(1), 1 } };
Console.WriteLine(dic.ContainsKey(new Amount(1))); //これはFalse、Keyが見つからない。

これはEquals()をオーバーライドして値による等価性の判定をするようにしたのに、GetHashCode()をオーバーライドしていないため起こります。そこで値が同じであれば同一のハッシュコードを返すように、たとえば下記のようにGetHashCode()をオーバーライドします。

public class Amount : IEquatable<Amount>
{
    private readonly int value;

    public Amount(int value)
    {
        this.value = value;
    }

    public bool Equals(Amount other)
    {
        if(other is null) return false;
        return this.value == other.value;
    }

    public override bool Equals(object other)
    {
        return Equals(other as Amount);
    }

    public override int GetHashCode()
    {
        return HashCode.Combine(value);
    }
}

こうすると先ほどの Dictonary の例も思った通り動作をしてくれます。

var dic = new Dictionary<Amount, int> { { new Amount(1), 1 } };
Console.WriteLine(dic.ContainsKey(new Amount(1))); //True、Keyが見つかる!

オペレーター== !=をオーバーロードする

いろいろ実装してきましたがここで最初に戻ってみます。

Console.WriteLine( new Amount(1) == new Amount(1)); //やっぱりFalse

やっぱり False です…これは既定の動作として参照の等価判定が行われているためです。

しかしEquals==の結果が異なるというのはどうにも気持ち悪く感じます。また、Amountは値としての振る舞いを期待しているのでやはり==!=などのオペレーターが使えると便利ですし直観的です。そこで、たとえば下記のようにオペレーターをオーバーロードします。

public class Amount : IEquatable<Amount>
{
    private readonly int value;

    public Amount(int value)
    {
        this.value = value;
    }

    public bool Equals(Amount other)
    {
        if(other is null) return false;
        return this.value == other.value;
    }

    public override bool Equals(object other)
    {
        return Equals(other as Amount);
    }

    public override int GetHashCode()
    {
        return HashCode.Combine(value);
    }

    public static bool operator ==(Amount lhs, Amount rhs)
    {
        if (lhs is null) return rhs is null;
        return lhs.Equals(rhs);
    }

    public static bool operator !=(Amount lhs, Amount rhs)
    {
        return !(lhs == rhs);
    }
}

こうするとオペレーターを使用しても値による等価判定がされるようになります。

Console.WriteLine( new Amount(1) == new Amount(1)); //True!!!!

やったね!

おわりに

結構やることが多くて大変です。

ただ、Visual Studio を使っていれば自動生成させることが可能なので基本的にはこれを使用すれば良いと思います。今回は手で書きましたがおかしなコードが紛れ込みかねないと思います(上記コードもおかしなとこありそう…)。

また、C#9.0 ではレコード型が導入されました。レコード型を利用すると、同値性による等価判定やGetHashCode()のオーバーライドなどここで実装してきたことが簡単に実現できます。とても便利ですね。

参考

プロフィール画像
tamaosa

釣りと登山が好き。

About | © 2019 Tamaosa, Built with Gatsby