💽

【C#】RecordClassのEquals()で躓いた話

に公開

はじめに

C#9.0で追加されたRecord Classはメンバの等価比較を行うメソッドなどを自動実装してくれるので、データクラスとして扱いたいものがあるときに便利です。

これを活用したコードを書いていて思ったように動かずに少し悩んだので、その内容を残しておきます。

想定外だった挙動

例えばこんなPersonクラスがあったとします。

Personクラス
record class Person (int ID)
{
    private string name = string.Empty;
    
    public string Name
    {
        get => this.name;
        set
        {
            if (this.name == value) return;
            
            this.name = value;
            this.OnNameChanged?.Invoke(this, this.name);
        }
    }
    
    public event EventHandler<(string>? OnNameChanged;
}

このPersonクラスに対してこんな処理を行うと、 Equals() の結果はFalseになってしまいました。
下記は簡略化したコードですが、実際にはp1はDBから取得したデータ、p2はJSONで受け取ったデータで、2つの等価比較をしたいというような場面を想像してください。

Personクラスの等価比較
using System;

Person p1 = new(1) { Name = "Taro Yamada" };
p1.OnNameChanged += (_, x) => OutputNewName(x.OldName, x.NewName);

Person p2 = new(1) { Name = "Taro Yamada" };
p2.OnNameChanged += (_, x) => OutputNewName(x.OldName, x.NewName);

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

void OutputNewName(string oldName, string newName)
{
    Console.WriteLine($"{oldName}'s new name is {newName}!");
}

原因はイベント

実際のコードはもっと複雑だったこともあり、ID, Name共に同じ値なのになぜ…?と混乱したのですが、理由は単純なものでした。

Record Classでは全てのメンバの等価比較を行う Equals() メソッドが自動実装されますが、この「全てのメンバ」にイベントも含まれるのです。

SharpLabを使ってILをデコンパイルしたC#コードを見るとよくわかります。

デコンパイルしたC#コードから自動実装されたEquals()メソッド部分を抜き出し
public virtual bool Equals(Person other)
{
    if ((object)this != other)
    {
        if ((object)other != null && EqualityContract == other.EqualityContract && EqualityComparer<int>.Default.Equals(<ID>k__BackingField, other.<ID>k__BackingField) && EqualityComparer<string>.Default.Equals(name, other.name))
        {
            return EqualityComparer<EventHandler<ValueTuple<string, string>>>.Default.Equals(this.OnNameChanged, other.OnNameChanged);
        }
        return false;
    }
    return true;
}

OnNameChanged の比較もしっかりされていますね。
他にもRecord Class内に参照型のメンバがいると同じ問題が発生します。

こんなときの対策

こんなときはRecord Class内に Equals() のオーバーロードを追加すれば大丈夫です。
GetHashCode() のオーバーライドも忘れずに。

Personクラスに追加するコード
public virtual bool Equals(Person? other)
{
	if (other is null) return false;

	return (this.ID.Equals(other.ID) && this.Name.Equals(other.Name));
}

public override int GetHashCode()
{
    HashCode hashcode = new();
    hashcode.Add(this.ID.GetHashCode());
	hashcode.Add(this.Name.GetHashCode());

	return hashcode.ToHashCode();
}

これを追加すると、前半にある「Personクラスの等価比較」の p1.Equals(p2) の結果がTrueに変わります。

SharpLab上のコード も置いておくので実験してみてください。

リリテックラボ

Discussion